Add "significant change" base (#45555)

This commit is contained in:
Paulus Schoutsen 2021-01-26 14:13:27 +01:00 committed by GitHub
parent 38361b134a
commit d082be787f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 335 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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"])

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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))