From d082be787f1826852a620cfb6195453632b7e8ac Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 26 Jan 2021 14:13:27 +0100 Subject: [PATCH] Add "significant change" base (#45555) --- .../components/sensor/significant_change.py | 44 ++++++ homeassistant/helpers/significant_change.py | 140 ++++++++++++++++++ script/scaffold/docs.py | 6 + .../integration/significant_change.py | 22 +++ .../tests/test_significant_change.py | 14 ++ .../sensor/test_significant_change.py | 59 ++++++++ tests/helpers/test_significant_change.py | 50 +++++++ 7 files changed, 335 insertions(+) create mode 100644 homeassistant/components/sensor/significant_change.py create mode 100644 homeassistant/helpers/significant_change.py create mode 100644 script/scaffold/templates/significant_change/integration/significant_change.py create mode 100644 script/scaffold/templates/significant_change/tests/test_significant_change.py create mode 100644 tests/components/sensor/test_significant_change.py create mode 100644 tests/helpers/test_significant_change.py diff --git a/homeassistant/components/sensor/significant_change.py b/homeassistant/components/sensor/significant_change.py new file mode 100644 index 00000000000..08d0da68d8b --- /dev/null +++ b/homeassistant/components/sensor/significant_change.py @@ -0,0 +1,44 @@ +"""Helper to test significant sensor state changes.""" +from typing import Any, Optional + +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + TEMP_FAHRENHEIT, +) +from homeassistant.core import HomeAssistant + +from . import DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE + + +async def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> Optional[bool]: + """Test if state significantly changed.""" + device_class = new_attrs.get(ATTR_DEVICE_CLASS) + + if device_class is None: + return None + + if device_class == DEVICE_CLASS_TEMPERATURE: + if new_attrs.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_FAHRENHEIT: + change = 0.03 + else: + change = 0.05 + + old_value = float(old_state) + new_value = float(new_state) + return abs(1 - old_value / new_value) > change + + if device_class in (DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY): + old_value = float(old_state) + new_value = float(new_state) + + return abs(old_value - new_value) > 2 + + return None diff --git a/homeassistant/helpers/significant_change.py b/homeassistant/helpers/significant_change.py new file mode 100644 index 00000000000..b600e97c4e9 --- /dev/null +++ b/homeassistant/helpers/significant_change.py @@ -0,0 +1,140 @@ +"""Helpers to help find if an entity has changed significantly. + +Does this with help of the integration. Looks at significant_change.py +platform for a function `async_check_significant_change`: + +```python +from typing import Optional +from homeassistant.core import HomeAssistant + +async def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs, +) -> Optional[bool] +``` + +Return boolean to indicate if significantly changed. If don't know, return None. + +**kwargs will allow us to expand this feature in the future, like passing in a +level of significance. + +The following cases will never be passed to your function: +- if either state is unknown/unavailable +- state adding/removing +""" +from types import MappingProxyType +from typing import Any, Callable, Dict, Optional, Union + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant, State, callback + +from .integration_platform import async_process_integration_platforms + +PLATFORM = "significant_change" +DATA_FUNCTIONS = "significant_change" +CheckTypeFunc = Callable[ + [ + HomeAssistant, + str, + Union[dict, MappingProxyType], + str, + Union[dict, MappingProxyType], + ], + Optional[bool], +] + + +async def create_checker( + hass: HomeAssistant, _domain: str +) -> "SignificantlyChangedChecker": + """Create a significantly changed checker for a domain.""" + await _initialize(hass) + return SignificantlyChangedChecker(hass) + + +# Marked as singleton so multiple calls all wait for same output. +async def _initialize(hass: HomeAssistant) -> None: + """Initialize the functions.""" + if DATA_FUNCTIONS in hass.data: + return + + functions = hass.data[DATA_FUNCTIONS] = {} + + async def process_platform( + hass: HomeAssistant, component_name: str, platform: Any + ) -> None: + """Process a significant change platform.""" + functions[component_name] = platform.async_check_significant_change + + await async_process_integration_platforms(hass, PLATFORM, process_platform) + + +class SignificantlyChangedChecker: + """Class to keep track of entities to see if they have significantly changed. + + Will always compare the entity to the last entity that was considered significant. + """ + + def __init__(self, hass: HomeAssistant) -> None: + """Test if an entity has significantly changed.""" + self.hass = hass + self.last_approved_entities: Dict[str, State] = {} + + @callback + def async_is_significant_change(self, new_state: State) -> bool: + """Return if this was a significant change.""" + old_state: Optional[State] = self.last_approved_entities.get( + new_state.entity_id + ) + + # First state change is always ok to report + if old_state is None: + self.last_approved_entities[new_state.entity_id] = new_state + return True + + # Handle state unknown or unavailable + if new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): + if new_state.state == old_state.state: + return False + + self.last_approved_entities[new_state.entity_id] = new_state + return True + + # If last state was unknown/unavailable, also significant. + if old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): + self.last_approved_entities[new_state.entity_id] = new_state + return True + + functions: Optional[Dict[str, CheckTypeFunc]] = self.hass.data.get( + DATA_FUNCTIONS + ) + + if functions is None: + raise RuntimeError("Significant Change not initialized") + + check_significantly_changed = functions.get(new_state.domain) + + # No platform available means always true. + if check_significantly_changed is None: + self.last_approved_entities[new_state.entity_id] = new_state + return True + + result = check_significantly_changed( + self.hass, + old_state.state, + old_state.attributes, + new_state.state, + new_state.attributes, + ) + + if result is False: + return False + + # Result is either True or None. + # None means the function doesn't know. For now assume it's True + self.last_approved_entities[new_state.entity_id] = new_state + return True diff --git a/script/scaffold/docs.py b/script/scaffold/docs.py index f4416a7b7e8..3a871301b97 100644 --- a/script/scaffold/docs.py +++ b/script/scaffold/docs.py @@ -35,6 +35,11 @@ DATA = { "docs": "https://developers.home-assistant.io/docs/en/reproduce_state_index.html", "extra": "You will now need to update the code to make sure that every attribute that can occur in the state will cause the right service to be called.", }, + "significant_change": { + "title": "Significant Change", + "docs": "https://developers.home-assistant.io/docs/en/significant_change_index.html", + "extra": "You will now need to update the code to make sure that entities with different device classes are correctly considered.", + }, } @@ -73,4 +78,5 @@ def print_relevant_docs(template: str, info: Info) -> None: ) if "extra" in data: + print() print(data["extra"]) diff --git a/script/scaffold/templates/significant_change/integration/significant_change.py b/script/scaffold/templates/significant_change/integration/significant_change.py new file mode 100644 index 00000000000..26f5b84d99e --- /dev/null +++ b/script/scaffold/templates/significant_change/integration/significant_change.py @@ -0,0 +1,22 @@ +"""Helper to test significant NEW_NAME state changes.""" +from typing import Any, Optional + +from homeassistant.const import ATTR_DEVICE_CLASS +from homeassistant.core import HomeAssistant + + +async def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> Optional[bool]: + """Test if state significantly changed.""" + device_class = new_attrs.get(ATTR_DEVICE_CLASS) + + if device_class is None: + return None + + return None diff --git a/script/scaffold/templates/significant_change/tests/test_significant_change.py b/script/scaffold/templates/significant_change/tests/test_significant_change.py new file mode 100644 index 00000000000..b377211983c --- /dev/null +++ b/script/scaffold/templates/significant_change/tests/test_significant_change.py @@ -0,0 +1,14 @@ +"""Test the sensor significant change platform.""" +from homeassistant.components.NEW_DOMAIN.significant_change import ( + async_check_significant_change, +) +from homeassistant.const import ATTR_DEVICE_CLASS + + +async def test_significant_change(): + """Detect NEW_NAME significant change.""" + attrs = {ATTR_DEVICE_CLASS: "some_device_class"} + + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + + assert async_check_significant_change(None, "on", attrs, "off", attrs) diff --git a/tests/components/sensor/test_significant_change.py b/tests/components/sensor/test_significant_change.py new file mode 100644 index 00000000000..0386322625f --- /dev/null +++ b/tests/components/sensor/test_significant_change.py @@ -0,0 +1,59 @@ +"""Test the sensor significant change platform.""" +from homeassistant.components.sensor.significant_change import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + async_check_significant_change, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) + + +async def test_significant_change_temperature(): + """Detect temperature significant changes.""" + celsius_attrs = { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + } + assert not await async_check_significant_change( + None, "12", celsius_attrs, "12", celsius_attrs + ) + assert await async_check_significant_change( + None, "12", celsius_attrs, "13", celsius_attrs + ) + assert not await async_check_significant_change( + None, "12.1", celsius_attrs, "12.2", celsius_attrs + ) + + freedom_attrs = { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT, + } + assert await async_check_significant_change( + None, "70", freedom_attrs, "74", freedom_attrs + ) + assert not await async_check_significant_change( + None, "70", freedom_attrs, "71", freedom_attrs + ) + + +async def test_significant_change_battery(): + """Detect battery significant changes.""" + attrs = { + ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, + } + assert not await async_check_significant_change(None, "100", attrs, "100", attrs) + assert await async_check_significant_change(None, "100", attrs, "97", attrs) + + +async def test_significant_change_humidity(): + """Detect humidity significant changes.""" + attrs = { + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + } + assert not await async_check_significant_change(None, "100", attrs, "100", attrs) + assert await async_check_significant_change(None, "100", attrs, "97", attrs) diff --git a/tests/helpers/test_significant_change.py b/tests/helpers/test_significant_change.py new file mode 100644 index 00000000000..e72951d36dd --- /dev/null +++ b/tests/helpers/test_significant_change.py @@ -0,0 +1,50 @@ +"""Test significant change helper.""" +import pytest + +from homeassistant.components.sensor import DEVICE_CLASS_BATTERY +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import State +from homeassistant.helpers import significant_change +from homeassistant.setup import async_setup_component + + +@pytest.fixture(name="checker") +async def checker_fixture(hass): + """Checker fixture.""" + checker = await significant_change.create_checker(hass, "test") + + def async_check_significant_change( + _hass, old_state, _old_attrs, new_state, _new_attrs, **kwargs + ): + return abs(float(old_state) - float(new_state)) > 4 + + hass.data[significant_change.DATA_FUNCTIONS][ + "test_domain" + ] = async_check_significant_change + return checker + + +async def test_signicant_change(hass, checker): + """Test initialize helper works.""" + assert await async_setup_component(hass, "sensor", {}) + + ent_id = "test_domain.test_entity" + attrs = {ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY} + + assert checker.async_is_significant_change(State(ent_id, "100", attrs)) + + # Same state is not significant. + assert not checker.async_is_significant_change(State(ent_id, "100", attrs)) + + # State under 5 difference is not significant. (per test mock) + assert not checker.async_is_significant_change(State(ent_id, "96", attrs)) + + # Make sure we always compare against last significant change + assert checker.async_is_significant_change(State(ent_id, "95", attrs)) + + # State turned unknown + assert checker.async_is_significant_change(State(ent_id, STATE_UNKNOWN, attrs)) + + # State turned unavailable + assert checker.async_is_significant_change(State(ent_id, "100", attrs)) + assert checker.async_is_significant_change(State(ent_id, STATE_UNAVAILABLE, attrs))