Restore Google/Alexa extra significant change checks (#46335)

This commit is contained in:
Paulus Schoutsen 2021-02-10 16:30:29 +01:00 committed by Franck Nijhof
parent 291746334a
commit b5061d0232
No known key found for this signature in database
GPG key ID: D62583BA8AB11CA3
6 changed files with 191 additions and 49 deletions

View file

@ -8,7 +8,7 @@ import aiohttp
import async_timeout import async_timeout
from homeassistant.const import HTTP_ACCEPTED, MATCH_ALL, STATE_ON from homeassistant.const import HTTP_ACCEPTED, MATCH_ALL, STATE_ON
from homeassistant.core import State from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers.significant_change import create_checker from homeassistant.helpers.significant_change import create_checker
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
@ -28,7 +28,20 @@ async def async_enable_proactive_mode(hass, smart_home_config):
# Validate we can get access token. # Validate we can get access token.
await smart_home_config.async_get_access_token() await smart_home_config.async_get_access_token()
checker = await create_checker(hass, DOMAIN) @callback
def extra_significant_check(
hass: HomeAssistant,
old_state: str,
old_attrs: dict,
old_extra_arg: dict,
new_state: str,
new_attrs: dict,
new_extra_arg: dict,
):
"""Check if the serialized data has changed."""
return old_extra_arg is not None and old_extra_arg != new_extra_arg
checker = await create_checker(hass, DOMAIN, extra_significant_check)
async def async_entity_state_listener( async def async_entity_state_listener(
changed_entity: str, changed_entity: str,
@ -70,15 +83,22 @@ async def async_enable_proactive_mode(hass, smart_home_config):
if not should_report and not should_doorbell: if not should_report and not should_doorbell:
return return
if not checker.async_is_significant_change(new_state):
return
if should_doorbell: if should_doorbell:
should_report = False should_report = False
if should_report:
alexa_properties = list(alexa_changed_entity.serialize_properties())
else:
alexa_properties = None
if not checker.async_is_significant_change(
new_state, extra_arg=alexa_properties
):
return
if should_report: if should_report:
await async_send_changereport_message( await async_send_changereport_message(
hass, smart_home_config, alexa_changed_entity hass, smart_home_config, alexa_changed_entity, alexa_properties
) )
elif should_doorbell: elif should_doorbell:
@ -92,7 +112,7 @@ async def async_enable_proactive_mode(hass, smart_home_config):
async def async_send_changereport_message( async def async_send_changereport_message(
hass, config, alexa_entity, *, invalidate_access_token=True hass, config, alexa_entity, alexa_properties, *, invalidate_access_token=True
): ):
"""Send a ChangeReport message for an Alexa entity. """Send a ChangeReport message for an Alexa entity.
@ -107,7 +127,7 @@ async def async_send_changereport_message(
payload = { payload = {
API_CHANGE: { API_CHANGE: {
"cause": {"type": Cause.APP_INTERACTION}, "cause": {"type": Cause.APP_INTERACTION},
"properties": list(alexa_entity.serialize_properties()), "properties": alexa_properties,
} }
} }
@ -146,7 +166,7 @@ async def async_send_changereport_message(
): ):
config.async_invalidate_access_token() config.async_invalidate_access_token()
return await async_send_changereport_message( return await async_send_changereport_message(
hass, config, alexa_entity, invalidate_access_token=False hass, config, alexa_entity, alexa_properties, invalidate_access_token=False
) )
_LOGGER.error( _LOGGER.error(

View file

@ -38,42 +38,59 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig
if not entity.is_supported(): if not entity.is_supported():
return return
if not checker.async_is_significant_change(new_state):
return
try: try:
entity_data = entity.query_serialize() entity_data = entity.query_serialize()
except SmartHomeError as err: except SmartHomeError as err:
_LOGGER.debug("Not reporting state for %s: %s", changed_entity, err.code) _LOGGER.debug("Not reporting state for %s: %s", changed_entity, err.code)
return return
if not checker.async_is_significant_change(new_state, extra_arg=entity_data):
return
_LOGGER.debug("Reporting state for %s: %s", changed_entity, entity_data) _LOGGER.debug("Reporting state for %s: %s", changed_entity, entity_data)
await google_config.async_report_state_all( await google_config.async_report_state_all(
{"devices": {"states": {changed_entity: entity_data}}} {"devices": {"states": {changed_entity: entity_data}}}
) )
@callback
def extra_significant_check(
hass: HomeAssistant,
old_state: str,
old_attrs: dict,
old_extra_arg: dict,
new_state: str,
new_attrs: dict,
new_extra_arg: dict,
):
"""Check if the serialized data has changed."""
return old_extra_arg != new_extra_arg
async def inital_report(_now): async def inital_report(_now):
"""Report initially all states.""" """Report initially all states."""
nonlocal unsub, checker nonlocal unsub, checker
entities = {} entities = {}
checker = await create_checker(hass, DOMAIN) checker = await create_checker(hass, DOMAIN, extra_significant_check)
for entity in async_get_entities(hass, google_config): for entity in async_get_entities(hass, google_config):
if not entity.should_expose(): if not entity.should_expose():
continue continue
# Tell our significant change checker that we're reporting
# So it knows with subsequent changes what was already reported.
if not checker.async_is_significant_change(entity.state):
continue
try: try:
entities[entity.entity_id] = entity.query_serialize() entity_data = entity.query_serialize()
except SmartHomeError: except SmartHomeError:
continue continue
# Tell our significant change checker that we're reporting
# So it knows with subsequent changes what was already reported.
if not checker.async_is_significant_change(
entity.state, extra_arg=entity_data
):
continue
entities[entity.entity_id] = entity_data
if not entities: if not entities:
return return

View file

@ -27,7 +27,7 @@ The following cases will never be passed to your function:
- state adding/removing - state adding/removing
""" """
from types import MappingProxyType from types import MappingProxyType
from typing import Any, Callable, Dict, Optional, Union from typing import Any, Callable, Dict, Optional, Tuple, Union
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State, callback from homeassistant.core import HomeAssistant, State, callback
@ -47,13 +47,28 @@ CheckTypeFunc = Callable[
Optional[bool], Optional[bool],
] ]
ExtraCheckTypeFunc = Callable[
[
HomeAssistant,
str,
Union[dict, MappingProxyType],
Any,
str,
Union[dict, MappingProxyType],
Any,
],
Optional[bool],
]
async def create_checker( async def create_checker(
hass: HomeAssistant, _domain: str hass: HomeAssistant,
_domain: str,
extra_significant_check: Optional[ExtraCheckTypeFunc] = None,
) -> "SignificantlyChangedChecker": ) -> "SignificantlyChangedChecker":
"""Create a significantly changed checker for a domain.""" """Create a significantly changed checker for a domain."""
await _initialize(hass) await _initialize(hass)
return SignificantlyChangedChecker(hass) return SignificantlyChangedChecker(hass, extra_significant_check)
# Marked as singleton so multiple calls all wait for same output. # Marked as singleton so multiple calls all wait for same output.
@ -105,34 +120,46 @@ class SignificantlyChangedChecker:
Will always compare the entity to the last entity that was considered significant. Will always compare the entity to the last entity that was considered significant.
""" """
def __init__(self, hass: HomeAssistant) -> None: def __init__(
self,
hass: HomeAssistant,
extra_significant_check: Optional[ExtraCheckTypeFunc] = None,
) -> None:
"""Test if an entity has significantly changed.""" """Test if an entity has significantly changed."""
self.hass = hass self.hass = hass
self.last_approved_entities: Dict[str, State] = {} self.last_approved_entities: Dict[str, Tuple[State, Any]] = {}
self.extra_significant_check = extra_significant_check
@callback @callback
def async_is_significant_change(self, new_state: State) -> bool: def async_is_significant_change(
"""Return if this was a significant change.""" self, new_state: State, *, extra_arg: Optional[Any] = None
old_state: Optional[State] = self.last_approved_entities.get( ) -> bool:
"""Return if this was a significant change.
Extra kwargs are passed to the extra significant checker.
"""
old_data: Optional[Tuple[State, Any]] = self.last_approved_entities.get(
new_state.entity_id new_state.entity_id
) )
# First state change is always ok to report # First state change is always ok to report
if old_state is None: if old_data is None:
self.last_approved_entities[new_state.entity_id] = new_state self.last_approved_entities[new_state.entity_id] = (new_state, extra_arg)
return True return True
old_state, old_extra_arg = old_data
# Handle state unknown or unavailable # Handle state unknown or unavailable
if new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): if new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
if new_state.state == old_state.state: if new_state.state == old_state.state:
return False return False
self.last_approved_entities[new_state.entity_id] = new_state self.last_approved_entities[new_state.entity_id] = (new_state, extra_arg)
return True return True
# If last state was unknown/unavailable, also significant. # If last state was unknown/unavailable, also significant.
if old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): if old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
self.last_approved_entities[new_state.entity_id] = new_state self.last_approved_entities[new_state.entity_id] = (new_state, extra_arg)
return True return True
functions: Optional[Dict[str, CheckTypeFunc]] = self.hass.data.get( functions: Optional[Dict[str, CheckTypeFunc]] = self.hass.data.get(
@ -144,11 +171,7 @@ class SignificantlyChangedChecker:
check_significantly_changed = functions.get(new_state.domain) check_significantly_changed = functions.get(new_state.domain)
# No platform available means always true. if check_significantly_changed is not None:
if check_significantly_changed is None:
self.last_approved_entities[new_state.entity_id] = new_state
return True
result = check_significantly_changed( result = check_significantly_changed(
self.hass, self.hass,
old_state.state, old_state.state,
@ -160,7 +183,24 @@ class SignificantlyChangedChecker:
if result is False: if result is False:
return False return False
if self.extra_significant_check is not None:
result = self.extra_significant_check(
self.hass,
old_state.state,
old_state.attributes,
old_extra_arg,
new_state.state,
new_state.attributes,
extra_arg,
)
if result is False:
return False
# Result is either True or None. # Result is either True or None.
# None means the function doesn't know. For now assume it's True # None means the function doesn't know. For now assume it's True
self.last_approved_entities[new_state.entity_id] = new_state self.last_approved_entities[new_state.entity_id] = (
new_state,
extra_arg,
)
return True return True

View file

@ -178,6 +178,7 @@ async def test_doorbell_event(hass, aioclient_mock):
async def test_proactive_mode_filter_states(hass, aioclient_mock): async def test_proactive_mode_filter_states(hass, aioclient_mock):
"""Test all the cases that filter states.""" """Test all the cases that filter states."""
aioclient_mock.post(TEST_URL, text="", status=202)
await state_report.async_enable_proactive_mode(hass, DEFAULT_CONFIG) await state_report.async_enable_proactive_mode(hass, DEFAULT_CONFIG)
# First state should report # First state should report
@ -186,7 +187,8 @@ async def test_proactive_mode_filter_states(hass, aioclient_mock):
"on", "on",
{"friendly_name": "Test Contact Sensor", "device_class": "door"}, {"friendly_name": "Test Contact Sensor", "device_class": "door"},
) )
assert len(aioclient_mock.mock_calls) == 0 await hass.async_block_till_done()
assert len(aioclient_mock.mock_calls) == 1
aioclient_mock.clear_requests() aioclient_mock.clear_requests()
@ -238,3 +240,24 @@ async def test_proactive_mode_filter_states(hass, aioclient_mock):
await hass.async_block_till_done() await hass.async_block_till_done()
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(aioclient_mock.mock_calls) == 0 assert len(aioclient_mock.mock_calls) == 0
# If serializes to same properties, it should not report
aioclient_mock.post(TEST_URL, text="", status=202)
with patch(
"homeassistant.components.alexa.entities.AlexaEntity.serialize_properties",
return_value=[{"same": "info"}],
):
hass.states.async_set(
"binary_sensor.same_serialize",
"off",
{"friendly_name": "Test Contact Sensor", "device_class": "door"},
)
await hass.async_block_till_done()
hass.states.async_set(
"binary_sensor.same_serialize",
"off",
{"friendly_name": "Test Contact Sensor", "device_class": "door"},
)
await hass.async_block_till_done()
assert len(aioclient_mock.mock_calls) == 1

View file

@ -46,6 +46,24 @@ async def test_report_state(hass, caplog, legacy_patchable_time):
"devices": {"states": {"light.kitchen": {"on": True, "online": True}}} "devices": {"states": {"light.kitchen": {"on": True, "online": True}}}
} }
# Test that if serialize returns same value, we don't send
with patch(
"homeassistant.components.google_assistant.report_state.GoogleEntity.query_serialize",
return_value={"same": "info"},
), patch.object(BASIC_CONFIG, "async_report_state_all", AsyncMock()) as mock_report:
# New state, so reported
hass.states.async_set("light.double_report", "on")
await hass.async_block_till_done()
# Changed, but serialize is same, so filtered out by extra check
hass.states.async_set("light.double_report", "off")
await hass.async_block_till_done()
assert len(mock_report.mock_calls) == 1
assert mock_report.mock_calls[0][1][0] == {
"devices": {"states": {"light.double_report": {"same": "info"}}}
}
# Test that only significant state changes are reported # Test that only significant state changes are reported
with patch.object( with patch.object(
BASIC_CONFIG, "async_report_state_all", AsyncMock() BASIC_CONFIG, "async_report_state_all", AsyncMock()

View file

@ -5,7 +5,6 @@ from homeassistant.components.sensor import DEVICE_CLASS_BATTERY
from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import State from homeassistant.core import State
from homeassistant.helpers import significant_change from homeassistant.helpers import significant_change
from homeassistant.setup import async_setup_component
@pytest.fixture(name="checker") @pytest.fixture(name="checker")
@ -26,8 +25,6 @@ async def checker_fixture(hass):
async def test_signicant_change(hass, checker): async def test_signicant_change(hass, checker):
"""Test initialize helper works.""" """Test initialize helper works."""
assert await async_setup_component(hass, "sensor", {})
ent_id = "test_domain.test_entity" ent_id = "test_domain.test_entity"
attrs = {ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY} attrs = {ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY}
@ -48,3 +45,30 @@ async def test_signicant_change(hass, checker):
# State turned unavailable # State turned unavailable
assert checker.async_is_significant_change(State(ent_id, "100", attrs)) assert checker.async_is_significant_change(State(ent_id, "100", attrs))
assert checker.async_is_significant_change(State(ent_id, STATE_UNAVAILABLE, attrs)) assert checker.async_is_significant_change(State(ent_id, STATE_UNAVAILABLE, attrs))
async def test_significant_change_extra(hass, checker):
"""Test extra significant checker works."""
ent_id = "test_domain.test_entity"
attrs = {ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY}
assert checker.async_is_significant_change(State(ent_id, "100", attrs), extra_arg=1)
assert checker.async_is_significant_change(State(ent_id, "200", attrs), extra_arg=1)
# Reset the last significiant change to 100 to repeat test but with
# extra checker installed.
assert checker.async_is_significant_change(State(ent_id, "100", attrs), extra_arg=1)
def extra_significant_check(
hass, old_state, old_attrs, old_extra_arg, new_state, new_attrs, new_extra_arg
):
return old_extra_arg != new_extra_arg
checker.extra_significant_check = extra_significant_check
# This is normally a significant change (100 -> 200), but the extra arg check marks it
# as insignificant.
assert not checker.async_is_significant_change(
State(ent_id, "200", attrs), extra_arg=1
)
assert checker.async_is_significant_change(State(ent_id, "200", attrs), extra_arg=2)