From 65d8c703770537d4e7d14d636c4f96a95c9fe3fc Mon Sep 17 00:00:00 2001 From: ochlocracy <5885236+ochlocracy@users.noreply.github.com> Date: Wed, 23 Oct 2019 14:41:26 -0400 Subject: [PATCH] Rebase Implement Alexa.DoorbellEventSource Interface Controller (#27726) --- .../components/alexa/capabilities.py | 42 ++++++++++-- homeassistant/components/alexa/entities.py | 8 ++- .../components/alexa/state_report.py | 66 ++++++++++++++++++- tests/components/alexa/__init__.py | 2 +- tests/components/alexa/test_smart_home.py | 22 +++++++ tests/components/alexa/test_state_report.py | 31 +++++++++ 6 files changed, 161 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 246429ad6c9..deb83813dbc 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -91,6 +91,15 @@ class AlexaCapability: """Applicable only to scenes.""" return None + @staticmethod + def capability_proactively_reported(): + """Return True if the capability is proactively reported. + + Set properties_proactively_reported() for proactively reported properties. + Applicable to DoorbellEventSource. + """ + return None + @staticmethod def capability_resources(): """Applicable to ToggleController, RangeController, and ModeController interfaces.""" @@ -103,16 +112,20 @@ class AlexaCapability: def serialize_discovery(self): """Serialize according to the Discovery API.""" - result = { - "type": "AlexaInterface", - "interface": self.name(), - "version": "3", - "properties": { + result = {"type": "AlexaInterface", "interface": self.name(), "version": "3"} + + properties_supported = self.properties_supported() + if properties_supported: + result["properties"] = { "supported": self.properties_supported(), "proactivelyReported": self.properties_proactively_reported(), "retrievable": self.properties_retrievable(), - }, - } + } + + # pylint: disable=assignment-from-none + proactively_reported = self.capability_proactively_reported() + if proactively_reported is not None: + result["proactivelyReported"] = proactively_reported # pylint: disable=assignment-from-none non_controllable = self.properties_non_controllable() @@ -1050,3 +1063,18 @@ class AlexaChannelController(AlexaCapability): def name(self): """Return the Alexa API name of this interface.""" return "Alexa.ChannelController" + + +class AlexaDoorbellEventSource(AlexaCapability): + """Implements Alexa.DoorbellEventSource. + + https://developer.amazon.com/docs/device-apis/alexa-doorbelleventsource.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.DoorbellEventSource" + + def capability_proactively_reported(self): + """Return True for proactively reported capability.""" + return True diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index f6fc9936a02..d84848e9aba 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -38,6 +38,7 @@ from .capabilities import ( AlexaColorController, AlexaColorTemperatureController, AlexaContactSensor, + AlexaDoorbellEventSource, AlexaEndpointHealth, AlexaInputController, AlexaLockController, @@ -84,7 +85,7 @@ class DisplayCategory: DOOR = "DOOR" # Indicates a doorbell. - DOOR_BELL = "DOORBELL" + DOORBELL = "DOORBELL" # Indicates a fan. FAN = "FAN" @@ -500,6 +501,11 @@ class BinarySensorCapabilities(AlexaEntity): elif sensor_type is self.TYPE_MOTION: yield AlexaMotionSensor(self.hass, self.entity) + entity_conf = self.config.entity_config.get(self.entity.entity_id, {}) + if CONF_DISPLAY_CATEGORIES in entity_conf: + if entity_conf[CONF_DISPLAY_CATEGORIES] == DisplayCategory.DOORBELL: + yield AlexaDoorbellEventSource(self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) def get_type(self): diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 42c16919a45..b5e1b741f0c 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -6,7 +6,8 @@ import logging import aiohttp import async_timeout -from homeassistant.const import MATCH_ALL +import homeassistant.util.dt as dt_util +from homeassistant.const import MATCH_ALL, STATE_ON from .const import API_CHANGE, Cause from .entities import ENTITY_ADAPTERS @@ -45,6 +46,14 @@ async def async_enable_proactive_mode(hass, smart_home_config): hass, smart_home_config, alexa_changed_entity ) return + if ( + interface.name() == "Alexa.DoorbellEventSource" + and new_state.state == STATE_ON + ): + await async_send_doorbell_event_message( + hass, smart_home_config, alexa_changed_entity + ) + return return hass.helpers.event.async_track_state_change( MATCH_ALL, async_entity_state_listener @@ -184,3 +193,58 @@ async def async_send_delete_message(hass, config, entity_ids): return await session.post( config.endpoint, headers=headers, json=message_serialized, allow_redirects=True ) + + +async def async_send_doorbell_event_message(hass, config, alexa_entity): + """Send a DoorbellPress event message for an Alexa entity. + + https://developer.amazon.com/docs/smarthome/send-events-to-the-alexa-event-gateway.html + """ + token = await config.async_get_access_token() + + headers = {"Authorization": f"Bearer {token}"} + + endpoint = alexa_entity.alexa_id() + + message = AlexaResponse( + name="DoorbellPress", + namespace="Alexa.DoorbellEventSource", + payload={ + "cause": {"type": Cause.PHYSICAL_INTERACTION}, + "timestamp": f"{dt_util.utcnow().replace(tzinfo=None).isoformat()}Z", + }, + ) + + message.set_endpoint_full(token, endpoint) + + message_serialized = message.serialize() + session = hass.helpers.aiohttp_client.async_get_clientsession() + + try: + with async_timeout.timeout(DEFAULT_TIMEOUT): + response = await session.post( + config.endpoint, + headers=headers, + json=message_serialized, + allow_redirects=True, + ) + + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.error("Timeout sending report to Alexa.") + return + + response_text = await response.text() + + _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) + _LOGGER.debug("Received (%s): %s", response.status, response_text) + + if response.status == 202: + return + + response_json = json.loads(response_text) + + _LOGGER.error( + "Error when sending DoorbellPress event to Alexa: %s: %s", + response_json["payload"]["code"], + response_json["payload"]["description"], + ) diff --git a/tests/components/alexa/__init__.py b/tests/components/alexa/__init__.py index 4fd8bf6f2a9..0fa1961ad61 100644 --- a/tests/components/alexa/__init__.py +++ b/tests/components/alexa/__init__.py @@ -13,7 +13,7 @@ TEST_TOKEN_URL = "https://api.amazon.com/auth/o2/token" class MockConfig(config.AbstractConfig): """Mock Alexa config.""" - entity_config = {} + entity_config = {"binary_sensor.test_doorbell": {"display_categories": "DOORBELL"}} @property def supports_auth(self): diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 139c8c9740b..c50c0748147 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1196,6 +1196,28 @@ async def test_motion_sensor(hass): properties.assert_equal("Alexa.MotionSensor", "detectionState", "DETECTED") +async def test_doorbell_sensor(hass): + """Test doorbell sensor discovery.""" + device = ( + "binary_sensor.test_doorbell", + "off", + {"friendly_name": "Test Doorbell Sensor", "device_class": "occupancy"}, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "binary_sensor#test_doorbell" + assert appliance["displayCategories"][0] == "DOORBELL" + assert appliance["friendlyName"] == "Test Doorbell Sensor" + + capabilities = assert_endpoint_capabilities( + appliance, "Alexa.DoorbellEventSource", "Alexa.EndpointHealth" + ) + + doorbell_capability = get_capability(capabilities, "Alexa.DoorbellEventSource") + assert doorbell_capability is not None + assert doorbell_capability["proactivelyReported"] is True + + async def test_unknown_sensor(hass): """Test sensors of unknown quantities are not discovered.""" device = ( diff --git a/tests/components/alexa/test_state_report.py b/tests/components/alexa/test_state_report.py index 310180ef5d0..2c58d1ed45e 100644 --- a/tests/components/alexa/test_state_report.py +++ b/tests/components/alexa/test_state_report.py @@ -137,3 +137,34 @@ async def test_send_delete_message(hass, aioclient_mock): call_json["event"]["payload"]["endpoints"][0]["endpointId"] == "binary_sensor#test_contact" ) + + +async def test_doorbell_event(hass, aioclient_mock): + """Test doorbell press reports.""" + aioclient_mock.post(TEST_URL, text="", status=202) + + hass.states.async_set( + "binary_sensor.test_doorbell", + "off", + {"friendly_name": "Test Doorbell Sensor", "device_class": "occupancy"}, + ) + + await state_report.async_enable_proactive_mode(hass, DEFAULT_CONFIG) + + hass.states.async_set( + "binary_sensor.test_doorbell", + "on", + {"friendly_name": "Test Doorbell Sensor", "device_class": "occupancy"}, + ) + + # To trigger event listener + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + call = aioclient_mock.mock_calls + + call_json = call[0][2] + assert call_json["event"]["header"]["namespace"] == "Alexa.DoorbellEventSource" + assert call_json["event"]["header"]["name"] == "DoorbellPress" + assert call_json["event"]["payload"]["cause"]["type"] == "PHYSICAL_INTERACTION" + assert call_json["event"]["endpoint"]["endpointId"] == "binary_sensor#test_doorbell"