mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 12:17:07 +00:00
Add "significant change" base (#45555)
This commit is contained in:
parent
38361b134a
commit
d082be787f
44
homeassistant/components/sensor/significant_change.py
Normal file
44
homeassistant/components/sensor/significant_change.py
Normal 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
|
140
homeassistant/helpers/significant_change.py
Normal file
140
homeassistant/helpers/significant_change.py
Normal 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
|
@ -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"])
|
||||
|
@ -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
|
@ -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)
|
59
tests/components/sensor/test_significant_change.py
Normal file
59
tests/components/sensor/test_significant_change.py
Normal 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)
|
50
tests/helpers/test_significant_change.py
Normal file
50
tests/helpers/test_significant_change.py
Normal 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))
|
Loading…
x
Reference in New Issue
Block a user