diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index d1ed328703e..393f8b22fbe 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -2311,16 +2311,12 @@ class SensorStateTrait(_Trait): name = TRAIT_SENSOR_STATE commands = [] - @staticmethod - def supported(domain, features, device_class, _): + @classmethod + def supported(cls, domain, features, device_class, _): """Test if state is supported.""" - return domain == sensor.DOMAIN and device_class in ( - sensor.DEVICE_CLASS_AQI, - sensor.DEVICE_CLASS_CO, - sensor.DEVICE_CLASS_CO2, - sensor.DEVICE_CLASS_PM25, - sensor.DEVICE_CLASS_PM10, - sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + return ( + domain == sensor.DOMAIN + and device_class in SensorStateTrait.sensor_types.keys() ) def sync_attributes(self): diff --git a/homeassistant/components/light/significant_change.py b/homeassistant/components/light/significant_change.py index 9e0f10fae47..79f447f5794 100644 --- a/homeassistant/components/light/significant_change.py +++ b/homeassistant/components/light/significant_change.py @@ -4,10 +4,7 @@ from __future__ import annotations from typing import Any from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.significant_change import ( - check_numeric_changed, - either_one_none, -) +from homeassistant.helpers.significant_change import check_absolute_change from . import ( ATTR_BRIGHTNESS, @@ -37,24 +34,21 @@ def async_check_significant_change( old_color = old_attrs.get(ATTR_HS_COLOR) new_color = new_attrs.get(ATTR_HS_COLOR) - if either_one_none(old_color, new_color): - return True - if old_color and new_color: # Range 0..360 - if check_numeric_changed(old_color[0], new_color[0], 5): + if check_absolute_change(old_color[0], new_color[0], 5): return True # Range 0..100 - if check_numeric_changed(old_color[1], new_color[1], 3): + if check_absolute_change(old_color[1], new_color[1], 3): return True - if check_numeric_changed( + if check_absolute_change( old_attrs.get(ATTR_BRIGHTNESS), new_attrs.get(ATTR_BRIGHTNESS), 3 ): return True - if check_numeric_changed( + if check_absolute_change( # Default range 153..500 old_attrs.get(ATTR_COLOR_TEMP), new_attrs.get(ATTR_COLOR_TEMP), @@ -62,7 +56,7 @@ def async_check_significant_change( ): return True - if check_numeric_changed( + if check_absolute_change( # Range 0..255 old_attrs.get(ATTR_WHITE_VALUE), new_attrs.get(ATTR_WHITE_VALUE), diff --git a/homeassistant/components/sensor/significant_change.py b/homeassistant/components/sensor/significant_change.py index cda80991242..5c180be62f3 100644 --- a/homeassistant/components/sensor/significant_change.py +++ b/homeassistant/components/sensor/significant_change.py @@ -9,8 +9,33 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_percentage_change, +) -from . import DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE +from . import ( + DEVICE_CLASS_AQI, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CO, + DEVICE_CLASS_CO2, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, +) + + +def _absolute_and_relative_change( + old_state: int | float | None, + new_state: int | float | None, + absolute_change: int | float, + percentage_change: int | float, +) -> bool: + return check_absolute_change( + old_state, new_state, absolute_change + ) and check_percentage_change(old_state, new_state, percentage_change) @callback @@ -28,20 +53,35 @@ def async_check_significant_change( if device_class is None: return None + absolute_change: float | None = None + percentage_change: float | None = None if device_class == DEVICE_CLASS_TEMPERATURE: if new_attrs.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_FAHRENHEIT: - change: float | int = 1 + absolute_change = 1.0 else: - change = 0.5 - - old_value = float(old_state) - new_value = float(new_state) - return abs(old_value - new_value) >= change + absolute_change = 0.5 if device_class in (DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY): - old_value = float(old_state) - new_value = float(new_state) + absolute_change = 1.0 - return abs(old_value - new_value) >= 1 + if device_class in ( + DEVICE_CLASS_AQI, + DEVICE_CLASS_CO, + DEVICE_CLASS_CO2, + DEVICE_CLASS_PM25, + DEVICE_CLASS_PM10, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + ): + absolute_change = 1.0 + percentage_change = 2.0 + + if absolute_change is not None and percentage_change is not None: + return _absolute_and_relative_change( + float(old_state), float(new_state), absolute_change, percentage_change + ) + if absolute_change is not None: + return check_absolute_change( + float(old_state), float(new_state), absolute_change + ) return None diff --git a/homeassistant/helpers/significant_change.py b/homeassistant/helpers/significant_change.py index b34df0075a3..d2791def987 100644 --- a/homeassistant/helpers/significant_change.py +++ b/homeassistant/helpers/significant_change.py @@ -95,25 +95,55 @@ def either_one_none(val1: Any | None, val2: Any | None) -> bool: return (val1 is None and val2 is not None) or (val1 is not None and val2 is None) -def check_numeric_changed( +def _check_numeric_change( + old_state: int | float | None, + new_state: int | float | None, + change: int | float, + metric: Callable[[int | float, int | float], int | float], +) -> bool: + """Check if two numeric values have changed.""" + if old_state is None and new_state is None: + return False + + if either_one_none(old_state, new_state): + return True + + assert old_state is not None + assert new_state is not None + + if metric(old_state, new_state) >= change: + return True + + return False + + +def check_absolute_change( val1: int | float | None, val2: int | float | None, change: int | float, ) -> bool: """Check if two numeric values have changed.""" - if val1 is None and val2 is None: - return False + return _check_numeric_change( + val1, val2, change, lambda val1, val2: abs(val1 - val2) + ) - if either_one_none(val1, val2): - return True - assert val1 is not None - assert val2 is not None +def check_percentage_change( + old_state: int | float | None, + new_state: int | float | None, + change: int | float, +) -> bool: + """Check if two numeric values have changed.""" - if abs(val1 - val2) >= change: - return True + def percentage_change(old_state: int | float, new_state: int | float) -> float: + if old_state == new_state: + return 0 + try: + return (abs(new_state - old_state) / old_state) * 100.0 + except ZeroDivisionError: + return float("inf") - return False + return _check_numeric_change(old_state, new_state, change, percentage_change) class SignificantlyChangedChecker: diff --git a/tests/components/sensor/test_significant_change.py b/tests/components/sensor/test_significant_change.py index 12b74345011..22a2c22ecc7 100644 --- a/tests/components/sensor/test_significant_change.py +++ b/tests/components/sensor/test_significant_change.py @@ -1,5 +1,8 @@ """Test the sensor significant change platform.""" +import pytest + from homeassistant.components.sensor.significant_change import ( + DEVICE_CLASS_AQI, DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -12,48 +15,54 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ) +AQI_ATTRS = { + ATTR_DEVICE_CLASS: DEVICE_CLASS_AQI, +} -async def test_significant_change_temperature(): +BATTERY_ATTRS = { + ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, +} + +HUMIDITY_ATTRS = { + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, +} + +TEMP_CELSIUS_ATTRS = { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, +} + +TEMP_FREEDOM_ATTRS = { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT, +} + + +@pytest.mark.parametrize( + "old_state,new_state,attrs,result", + [ + ("0", "1", AQI_ATTRS, True), + ("1", "0", AQI_ATTRS, True), + ("0.1", "0.5", AQI_ATTRS, False), + ("0.5", "0.1", AQI_ATTRS, False), + ("99", "100", AQI_ATTRS, False), + ("100", "99", AQI_ATTRS, False), + ("101", "99", AQI_ATTRS, False), + ("99", "101", AQI_ATTRS, True), + ("100", "100", BATTERY_ATTRS, False), + ("100", "99", BATTERY_ATTRS, True), + ("100", "100", HUMIDITY_ATTRS, False), + ("100", "99", HUMIDITY_ATTRS, True), + ("12", "12", TEMP_CELSIUS_ATTRS, False), + ("12", "13", TEMP_CELSIUS_ATTRS, True), + ("12.1", "12.2", TEMP_CELSIUS_ATTRS, False), + ("70", "71", TEMP_FREEDOM_ATTRS, True), + ("70", "70.5", TEMP_FREEDOM_ATTRS, False), + ], +) +async def test_significant_change_temperature(old_state, new_state, attrs, result): """Detect temperature significant changes.""" - celsius_attrs = { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - } - assert not async_check_significant_change( - None, "12", celsius_attrs, "12", celsius_attrs + assert ( + async_check_significant_change(None, old_state, attrs, new_state, attrs) + is result ) - assert async_check_significant_change( - None, "12", celsius_attrs, "13", celsius_attrs - ) - assert not 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 async_check_significant_change( - None, "70", freedom_attrs, "71", freedom_attrs - ) - assert not async_check_significant_change( - None, "70", freedom_attrs, "70.5", freedom_attrs - ) - - -async def test_significant_change_battery(): - """Detect battery significant changes.""" - attrs = { - ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, - } - assert not async_check_significant_change(None, "100", attrs, "100", attrs) - assert async_check_significant_change(None, "100", attrs, "99", attrs) - - -async def test_significant_change_humidity(): - """Detect humidity significant changes.""" - attrs = { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - } - assert not async_check_significant_change(None, "100", attrs, "100", attrs) - assert async_check_significant_change(None, "100", attrs, "99", attrs)