Do not break Alexa sync when encounter bad entity (#39380)

This commit is contained in:
Paulus Schoutsen 2020-08-30 14:36:00 +02:00 committed by GitHub
parent b4db9f615d
commit ba75856f2b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 107 additions and 29 deletions

View file

@ -1,5 +1,6 @@
"""Alexa capabilities.""" """Alexa capabilities."""
import logging import logging
from typing import List, Optional
from homeassistant.components import ( from homeassistant.components import (
cover, cover,
@ -36,6 +37,7 @@ from homeassistant.const import (
STATE_UNKNOWN, STATE_UNKNOWN,
STATE_UNLOCKED, STATE_UNLOCKED,
) )
from homeassistant.core import State
import homeassistant.util.color as color_util import homeassistant.util.color as color_util
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
@ -71,32 +73,32 @@ class AlexaCapability:
supported_locales = {"en-US"} supported_locales = {"en-US"}
def __init__(self, entity, instance=None): def __init__(self, entity: State, instance: Optional[str] = None):
"""Initialize an Alexa capability.""" """Initialize an Alexa capability."""
self.entity = entity self.entity = entity
self.instance = instance self.instance = instance
def name(self): def name(self) -> str:
"""Return the Alexa API name of this interface.""" """Return the Alexa API name of this interface."""
raise NotImplementedError raise NotImplementedError
@staticmethod @staticmethod
def properties_supported(): def properties_supported() -> List[dict]:
"""Return what properties this entity supports.""" """Return what properties this entity supports."""
return [] return []
@staticmethod @staticmethod
def properties_proactively_reported(): def properties_proactively_reported() -> bool:
"""Return True if properties asynchronously reported.""" """Return True if properties asynchronously reported."""
return False return False
@staticmethod @staticmethod
def properties_retrievable(): def properties_retrievable() -> bool:
"""Return True if properties can be retrieved.""" """Return True if properties can be retrieved."""
return False return False
@staticmethod @staticmethod
def properties_non_controllable(): def properties_non_controllable() -> bool:
"""Return True if non controllable.""" """Return True if non controllable."""
return None return None
@ -237,20 +239,34 @@ class AlexaCapability:
"""Return properties serialized for an API response.""" """Return properties serialized for an API response."""
for prop in self.properties_supported(): for prop in self.properties_supported():
prop_name = prop["name"] prop_name = prop["name"]
prop_value = self.get_property(prop_name) try:
if prop_value is not None: prop_value = self.get_property(prop_name)
result = { except UnsupportedProperty:
"name": prop_name, raise
"namespace": self.name(), except Exception: # pylint: disable=broad-except
"value": prop_value, _LOGGER.exception(
"timeOfSample": dt_util.utcnow().strftime(DATE_FORMAT), "Unexpected error getting %s.%s property from %s",
"uncertaintyInMilliseconds": 0, self.name(),
} prop_name,
instance = self.instance self.entity,
if instance is not None: )
result["instance"] = instance prop_value = None
yield result if prop_value is None:
continue
result = {
"name": prop_name,
"namespace": self.name(),
"value": prop_value,
"timeOfSample": dt_util.utcnow().strftime(DATE_FORMAT),
"uncertaintyInMilliseconds": 0,
}
instance = self.instance
if instance is not None:
result["instance"] = instance
yield result
class Alexa(AlexaCapability): class Alexa(AlexaCapability):

View file

@ -1,6 +1,6 @@
"""Alexa entity adapters.""" """Alexa entity adapters."""
import logging import logging
from typing import List from typing import TYPE_CHECKING, List
from homeassistant.components import ( from homeassistant.components import (
alarm_control_panel, alarm_control_panel,
@ -34,7 +34,7 @@ from homeassistant.const import (
TEMP_CELSIUS, TEMP_CELSIUS,
TEMP_FAHRENHEIT, TEMP_FAHRENHEIT,
) )
from homeassistant.core import callback from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import network from homeassistant.helpers import network
from homeassistant.util.decorator import Registry from homeassistant.util.decorator import Registry
@ -42,6 +42,7 @@ from .capabilities import (
Alexa, Alexa,
AlexaBrightnessController, AlexaBrightnessController,
AlexaCameraStreamController, AlexaCameraStreamController,
AlexaCapability,
AlexaChannelController, AlexaChannelController,
AlexaColorController, AlexaColorController,
AlexaColorTemperatureController, AlexaColorTemperatureController,
@ -72,6 +73,9 @@ from .capabilities import (
) )
from .const import CONF_DESCRIPTION, CONF_DISPLAY_CATEGORIES from .const import CONF_DESCRIPTION, CONF_DISPLAY_CATEGORIES
if TYPE_CHECKING:
from .config import AbstractConfig
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
ENTITY_ADAPTERS = Registry() ENTITY_ADAPTERS = Registry()
@ -203,7 +207,7 @@ class AlexaEntity:
The API handlers should manipulate entities only through this interface. The API handlers should manipulate entities only through this interface.
""" """
def __init__(self, hass, config, entity): def __init__(self, hass: HomeAssistant, config: "AbstractConfig", entity: State):
"""Initialize Alexa Entity.""" """Initialize Alexa Entity."""
self.hass = hass self.hass = hass
self.config = config self.config = config
@ -246,13 +250,13 @@ class AlexaEntity:
""" """
raise NotImplementedError raise NotImplementedError
def get_interface(self, capability): def get_interface(self, capability) -> AlexaCapability:
"""Return the given AlexaInterface. """Return the given AlexaInterface.
Raises _UnsupportedInterface. Raises _UnsupportedInterface.
""" """
def interfaces(self): def interfaces(self) -> List[AlexaCapability]:
"""Return a list of supported interfaces. """Return a list of supported interfaces.
Used for discovery. The list should contain AlexaInterface instances. Used for discovery. The list should contain AlexaInterface instances.
@ -280,11 +284,18 @@ class AlexaEntity:
} }
locale = self.config.locale locale = self.config.locale
capabilities = [ capabilities = []
i.serialize_discovery()
for i in self.interfaces() for i in self.interfaces():
if locale in i.supported_locales if locale not in i.supported_locales:
] continue
try:
capabilities.append(i.serialize_discovery())
except Exception: # pylint: disable=broad-except
_LOGGER.exception(
"Error serializing %s discovery for %s", i.name(), self.entity
)
result["capabilities"] = capabilities result["capabilities"] = capabilities

View file

@ -33,6 +33,7 @@ from . import (
reported_properties, reported_properties,
) )
from tests.async_mock import patch
from tests.common import async_mock_service from tests.common import async_mock_service
@ -756,3 +757,25 @@ async def test_report_image_processing(hass):
"humanPresenceDetectionState", "humanPresenceDetectionState",
{"value": "DETECTED"}, {"value": "DETECTED"},
) )
async def test_get_property_blowup(hass, caplog):
"""Test we handle a property blowing up."""
hass.states.async_set(
"climate.downstairs",
climate.HVAC_MODE_AUTO,
{
"friendly_name": "Climate Downstairs",
"supported_features": 91,
climate.ATTR_CURRENT_TEMPERATURE: 34,
ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS,
},
)
with patch(
"homeassistant.components.alexa.capabilities.float",
side_effect=Exception("Boom Fail"),
):
properties = await reported_properties(hass, "climate.downstairs")
properties.assert_not_has_property("Alexa.ThermostatController", "temperature")
assert "Boom Fail" in caplog.text

View file

@ -3,6 +3,8 @@ from homeassistant.components.alexa import smart_home
from . import DEFAULT_CONFIG, get_new_request from . import DEFAULT_CONFIG, get_new_request
from tests.async_mock import patch
async def test_unsupported_domain(hass): async def test_unsupported_domain(hass):
"""Discovery ignores entities of unknown domains.""" """Discovery ignores entities of unknown domains."""
@ -16,3 +18,29 @@ async def test_unsupported_domain(hass):
msg = msg["event"] msg = msg["event"]
assert not msg["payload"]["endpoints"] assert not msg["payload"]["endpoints"]
async def test_serialize_discovery_recovers(hass, caplog):
"""Test we handle an interface raising unexpectedly during serialize discovery."""
request = get_new_request("Alexa.Discovery", "Discover")
hass.states.async_set("switch.bla", "on", {"friendly_name": "Boop Woz"})
with patch(
"homeassistant.components.alexa.capabilities.AlexaPowerController.serialize_discovery",
side_effect=TypeError,
):
msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request)
assert "event" in msg
msg = msg["event"]
interfaces = {
ifc["interface"] for ifc in msg["payload"]["endpoints"][0]["capabilities"]
}
assert "Alexa.PowerController" not in interfaces
assert (
f"Error serializing Alexa.PowerController discovery for {hass.states.get('switch.bla')}"
in caplog.text
)