diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 1d06422056d..6d31862509b 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -2,15 +2,17 @@ import asyncio import json import logging +from typing import Dict, Optional import aiohttp import async_timeout from homeassistant.const import HTTP_ACCEPTED, MATCH_ALL, STATE_ON +from homeassistant.core import State import homeassistant.util.dt as dt_util from .const import API_CHANGE, Cause -from .entities import ENTITY_ADAPTERS, generate_alexa_id +from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id from .messages import AlexaResponse _LOGGER = logging.getLogger(__name__) @@ -25,7 +27,13 @@ async def async_enable_proactive_mode(hass, smart_home_config): # Validate we can get access token. await smart_home_config.async_get_access_token() - async def async_entity_state_listener(changed_entity, old_state, new_state): + progress: Dict[str, AlexaEntity] = {} + + async def async_entity_state_listener( + changed_entity: str, + old_state: Optional[State], + new_state: Optional[State], + ): if not hass.is_running: return @@ -39,24 +47,79 @@ async def async_enable_proactive_mode(hass, smart_home_config): _LOGGER.debug("Not exposing %s because filtered by config", changed_entity) return - alexa_changed_entity = ENTITY_ADAPTERS[new_state.domain]( + alexa_changed_entity: AlexaEntity = ENTITY_ADAPTERS[new_state.domain]( hass, smart_home_config, new_state ) + # Queue up entity to be sent later. + # If two states come in while we are reporting the state, only the last one will be reported. + if changed_entity in progress: + progress[changed_entity] = alexa_changed_entity + return + + # Determine how entity should be reported on + should_report = False + should_doorbell = False + for interface in alexa_changed_entity.interfaces(): - if interface.properties_proactively_reported(): - await async_send_changereport_message( - hass, smart_home_config, alexa_changed_entity - ) - return + if not should_report and interface.properties_proactively_reported(): + should_report = True + 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 + should_doorbell = True + break + + if not should_report and not should_doorbell: + return + + if should_doorbell: + should_report = False + + # Store current state change information + last_state: Optional[AlexaEntity] = None + if old_state: + last_state = ENTITY_ADAPTERS[old_state.domain]( + hass, smart_home_config, old_state + ) + progress[changed_entity] = alexa_changed_entity + + # Start reporting on entity. Keep reporting as long as new states come in + # while we were reporting a state. + while last_state != progress[changed_entity]: + to_report = progress[changed_entity] + alexa_properties = None + + if should_report: + # this sends all the properties of the Alexa Entity, whether they have + # changed or not. this should be improved, and properties that have not + # changed should be moved to the 'context' object + alexa_properties = list(alexa_changed_entity.serialize_properties()) + + if last_state and last_state.entity.state == to_report.entity.state: + old_alexa_properties = list(last_state.serialize_properties()) + if old_alexa_properties == alexa_properties: + return + + try: + if should_report: + await async_send_changereport_message( + hass, smart_home_config, alexa_changed_entity, alexa_properties + ) + + elif should_doorbell: + await async_send_doorbell_event_message( + hass, smart_home_config, alexa_changed_entity + ) + except Exception: + progress.pop(changed_entity) + raise + + last_state = to_report + + progress.pop(changed_entity) return hass.helpers.event.async_track_state_change( MATCH_ALL, async_entity_state_listener @@ -64,7 +127,7 @@ async def async_enable_proactive_mode(hass, smart_home_config): async def async_send_changereport_message( - hass, config, alexa_entity, *, invalidate_access_token=True + hass, config, alexa_entity, properties, *, invalidate_access_token=True ): """Send a ChangeReport message for an Alexa entity. @@ -76,11 +139,6 @@ async def async_send_changereport_message( endpoint = alexa_entity.alexa_id() - # this sends all the properties of the Alexa Entity, whether they have - # changed or not. this should be improved, and properties that have not - # changed should be moved to the 'context' object - properties = list(alexa_entity.serialize_properties()) - payload = { API_CHANGE: {"cause": {"type": Cause.APP_INTERACTION}, "properties": properties} } @@ -120,7 +178,7 @@ async def async_send_changereport_message( ): config.async_invalidate_access_token() return await async_send_changereport_message( - hass, config, alexa_entity, invalidate_access_token=False + hass, config, alexa_entity, properties, invalidate_access_token=False ) _LOGGER.error( diff --git a/tests/components/alexa/test_state_report.py b/tests/components/alexa/test_state_report.py index 42a8ab48279..afa024ce89b 100644 --- a/tests/components/alexa/test_state_report.py +++ b/tests/components/alexa/test_state_report.py @@ -1,4 +1,8 @@ """Test report state.""" +import asyncio +from unittest.mock import Mock, patch + +from homeassistant import core from homeassistant.components.alexa import state_report from . import DEFAULT_CONFIG, TEST_URL @@ -171,3 +175,141 @@ async def test_doorbell_event(hass, aioclient_mock): 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" + + +async def test_proactive_mode_filter_states(hass, aioclient_mock): + """Test all the cases that filter states.""" + hass.states.async_set( + "binary_sensor.test_contact", + "on", + {"friendly_name": "Test Contact Sensor", "device_class": "door"}, + ) + + await state_report.async_enable_proactive_mode(hass, DEFAULT_CONFIG) + + # Force update should not report + hass.states.async_set( + "binary_sensor.test_contact", + "on", + {"friendly_name": "Test Contact Sensor", "device_class": "door"}, + force_update=True, + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + assert len(aioclient_mock.mock_calls) == 0 + + # hass not running should not report + hass.states.async_set( + "binary_sensor.test_contact", + "off", + {"friendly_name": "Test Contact Sensor", "device_class": "door"}, + ) + with patch.object(hass, "state", core.CoreState.stopping): + await hass.async_block_till_done() + await hass.async_block_till_done() + assert len(aioclient_mock.mock_calls) == 0 + + # unsupported entity should not report + hass.states.async_set( + "binary_sensor.test_contact", + "on", + {"friendly_name": "Test Contact Sensor", "device_class": "door"}, + ) + with patch.dict( + "homeassistant.components.alexa.state_report.ENTITY_ADAPTERS", {}, clear=True + ): + await hass.async_block_till_done() + await hass.async_block_till_done() + assert len(aioclient_mock.mock_calls) == 0 + + # Not exposed by config should not report + hass.states.async_set( + "binary_sensor.test_contact", + "off", + {"friendly_name": "Test Contact Sensor", "device_class": "door"}, + ) + with patch.object(DEFAULT_CONFIG, "should_expose", return_value=False): + await hass.async_block_till_done() + await hass.async_block_till_done() + assert len(aioclient_mock.mock_calls) == 0 + + # Removing an entity + hass.states.async_remove("binary_sensor.test_contact") + await hass.async_block_till_done() + await hass.async_block_till_done() + assert len(aioclient_mock.mock_calls) == 0 + + +async def test_proactive_mode_filter_in_progress(hass, aioclient_mock): + """When in progress, queue up state.""" + hass.states.async_set( + "binary_sensor.test_contact", + "off", + {"friendly_name": "Test Contact Sensor", "device_class": "door"}, + ) + + await state_report.async_enable_proactive_mode(hass, DEFAULT_CONFIG) + + # Progress should filter out the 2nd event. + long_sendchange = asyncio.Event() + + with patch( + "homeassistant.components.alexa.state_report.async_send_changereport_message", + Mock(side_effect=lambda *args: long_sendchange.wait()), + ) as mock_report: + hass.states.async_set( + "binary_sensor.test_contact", + "on", + { + "friendly_name": "Test Contact Sensor", + "device_class": "door", + "update": 1, + }, + ) + + await asyncio.sleep(0) + await asyncio.sleep(0) + await asyncio.sleep(0) + + assert len(mock_report.mock_calls) == 1 + + with patch( + "homeassistant.components.alexa.state_report.async_send_changereport_message", + ) as mock_report_2: + hass.states.async_set( + "binary_sensor.test_contact", + "off", + { + "friendly_name": "Test Contact Sensor", + "device_class": "door", + "update": 2, + }, + ) + hass.states.async_set( + "binary_sensor.test_contact", + "on", + { + "friendly_name": "Test Contact Sensor", + "device_class": "door", + "update": 3, + }, + ) + hass.states.async_set( + "binary_sensor.test_contact", + "off", + { + "friendly_name": "Test Contact Sensor", + "device_class": "door", + "update": 4, + }, + ) + + await asyncio.sleep(0) + await asyncio.sleep(0) + await asyncio.sleep(0) + long_sendchange.set() + await hass.async_block_till_done() + + # Should be 1 because the 4rd state change + assert len(mock_report_2.mock_calls) == 1 + mock_report_2.mock_calls[0][1][2].entity.attributes["update"] == 4