From 7f3adce675af39892f6afb79d0daeb53fc22a606 Mon Sep 17 00:00:00 2001 From: RDFurman Date: Tue, 7 Sep 2021 08:32:26 -0600 Subject: [PATCH 01/14] Try to avoid rate limiting in honeywell (#55304) * Limit parallel update and sleep loop * Use asyncio sleep instead * Extract sleep to const for testing * Make loop sleep 0 in test --- homeassistant/components/honeywell/__init__.py | 17 ++++++++++------- homeassistant/components/honeywell/climate.py | 4 +++- tests/components/honeywell/test_init.py | 4 ++++ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 29f0dbb8392..03dc9ea9c8c 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -1,4 +1,5 @@ """Support for Honeywell (US) Total Connect Comfort climate systems.""" +import asyncio from datetime import timedelta import somecomfort @@ -9,7 +10,8 @@ from homeassistant.util import Throttle from .const import _LOGGER, CONF_DEV_ID, CONF_LOC_ID, DOMAIN -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) +UPDATE_LOOP_SLEEP_TIME = 5 +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300) PLATFORMS = ["climate"] @@ -42,7 +44,7 @@ async def async_setup_entry(hass, config): return False data = HoneywellData(hass, client, username, password, devices) - await data.update() + await data.async_update() hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][config.entry_id] = data hass.config_entries.async_setup_platforms(config, PLATFORMS) @@ -102,18 +104,19 @@ class HoneywellData: self.devices = devices return True - def _refresh_devices(self): + async def _refresh_devices(self): """Refresh each enabled device.""" for device in self.devices: - device.refresh() + await self._hass.async_add_executor_job(device.refresh) + await asyncio.sleep(UPDATE_LOOP_SLEEP_TIME) @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def update(self) -> None: + async def async_update(self) -> None: """Update the state.""" retries = 3 while retries > 0: try: - await self._hass.async_add_executor_job(self._refresh_devices) + await self._refresh_devices() break except ( somecomfort.client.APIRateLimited, @@ -124,7 +127,7 @@ class HoneywellData: if retries == 0: raise exp - result = await self._hass.async_add_executor_job(self._retry()) + result = await self._retry() if not result: raise exp diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 230aa8ec424..8088a73506d 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -107,6 +107,8 @@ HW_FAN_MODE_TO_HA = { "follow schedule": FAN_AUTO, } +PARALLEL_UPDATES = 1 + async def async_setup_entry(hass, config, async_add_entities, discovery_info=None): """Set up the Honeywell thermostat.""" @@ -384,4 +386,4 @@ class HoneywellUSThermostat(ClimateEntity): async def async_update(self): """Get the latest state from the service.""" - await self._data.update() + await self._data.async_update() diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index 7cc6b64cd63..619d770c59e 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -1,11 +1,14 @@ """Test honeywell setup process.""" +from unittest.mock import patch + from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +@patch("homeassistant.components.honeywell.UPDATE_LOOP_SLEEP_TIME", 0) async def test_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry): """Initialize the config entry.""" config_entry.add_to_hass(hass) @@ -15,6 +18,7 @@ async def test_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry): assert hass.states.async_entity_ids_count() == 1 +@patch("homeassistant.components.honeywell.UPDATE_LOOP_SLEEP_TIME", 0) async def test_setup_multiple_thermostats( hass: HomeAssistant, config_entry: MockConfigEntry, location, another_device ) -> None: From 9ecb75dc70348bfdf79015de16377aba152f2336 Mon Sep 17 00:00:00 2001 From: Pascal Winters Date: Wed, 8 Sep 2021 05:54:40 +0200 Subject: [PATCH 02/14] Edit unit of measurement for gas/electricity supplier prices (#55771) Co-authored-by: Paulus Schoutsen --- .../components/dsmr_reader/definitions.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index 533b2f0dd38..1e9834e7e5e 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Callable +from typing import Callable, Final from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, @@ -24,6 +24,9 @@ from homeassistant.const import ( VOLUME_CUBIC_METERS, ) +PRICE_EUR_KWH: Final = f"EUR/{ENERGY_KILO_WATT_HOUR}" +PRICE_EUR_M3: Final = f"EUR/{VOLUME_CUBIC_METERS}" + def dsmr_transform(value): """Transform DSMR version value to right format.""" @@ -301,31 +304,31 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_1", name="Low tariff delivered price", icon="mdi:currency-eur", - native_unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_2", name="High tariff delivered price", icon="mdi:currency-eur", - native_unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_returned_1", name="Low tariff returned price", icon="mdi:currency-eur", - native_unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_returned_2", name="High tariff returned price", icon="mdi:currency-eur", - native_unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_gas", name="Gas price", icon="mdi:currency-eur", - native_unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=PRICE_EUR_M3, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/fixed_cost", From e7fd24eadee037e8d2bb2199251dc03341ceadc3 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 7 Sep 2021 07:12:54 +0100 Subject: [PATCH 03/14] Integration Sensor Initial State (#55875) * initial state is UNAVAILABLE * update tests --- homeassistant/components/integration/sensor.py | 11 ++++++++--- tests/components/integration/test_sensor.py | 7 +++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index cd3e376b792..d36e2da54c1 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -106,7 +106,7 @@ class IntegrationSensor(RestoreEntity, SensorEntity): """Initialize the integration sensor.""" self._sensor_source_id = source_entity self._round_digits = round_digits - self._state = 0 + self._state = STATE_UNAVAILABLE self._method = integration_method self._name = name if name is not None else f"{source_entity} integral" @@ -187,7 +187,10 @@ class IntegrationSensor(RestoreEntity, SensorEntity): except AssertionError as err: _LOGGER.error("Could not calculate integral: %s", err) else: - self._state += integral + if isinstance(self._state, Decimal): + self._state += integral + else: + self._state = integral self.async_write_ha_state() async_track_state_change_event( @@ -202,7 +205,9 @@ class IntegrationSensor(RestoreEntity, SensorEntity): @property def native_value(self): """Return the state of the sensor.""" - return round(self._state, self._round_digits) + if isinstance(self._state, Decimal): + return round(self._state, self._round_digits) + return self._state @property def native_unit_of_measurement(self): diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index e8aaf906936..58df0a53a00 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -81,7 +81,6 @@ async def test_restore_state(hass: HomeAssistant) -> None: "platform": "integration", "name": "integration", "source": "sensor.power", - "unit": ENERGY_KILO_WATT_HOUR, "round": 2, } } @@ -114,7 +113,6 @@ async def test_restore_state_failed(hass: HomeAssistant) -> None: "platform": "integration", "name": "integration", "source": "sensor.power", - "unit": ENERGY_KILO_WATT_HOUR, } } @@ -123,9 +121,10 @@ async def test_restore_state_failed(hass: HomeAssistant) -> None: state = hass.states.get("sensor.integration") assert state - assert state.state == "0" - assert state.attributes.get("unit_of_measurement") == ENERGY_KILO_WATT_HOUR + assert state.state == "unavailable" + assert state.attributes.get("unit_of_measurement") is None assert state.attributes.get("state_class") == STATE_CLASS_TOTAL_INCREASING + assert "device_class" not in state.attributes From 980fcef36f63e5d78cdb449dea4bc808918e24ca Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 7 Sep 2021 11:34:41 +0200 Subject: [PATCH 04/14] Fix available property for Xiaomi Miio fan platform (#55889) * Fix available * Suggested change --- homeassistant/components/xiaomi_miio/fan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 70f51019ec9..ae25fd389b1 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -301,7 +301,7 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): @property def available(self): """Return true when state is known.""" - return self._available + return super().available and self._available @property def extra_state_attributes(self): From 21ebf4f3e61547a2d6cd1ea0550f3bce6148f9e4 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 7 Sep 2021 17:50:07 -0400 Subject: [PATCH 05/14] Allow multiple template.select platform entries (#55908) --- homeassistant/components/template/select.py | 26 +++++++-------- tests/components/template/test_select.py | 36 +++++++++++++++++++-- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 944c80cbfa4..96e86e8caec 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -46,27 +46,27 @@ SELECT_SCHEMA = vol.Schema( async def _async_create_entities( - hass: HomeAssistant, entities: list[dict[str, Any]], unique_id_prefix: str | None + hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None ) -> list[TemplateSelect]: """Create the Template select.""" - for entity in entities: - unique_id = entity.get(CONF_UNIQUE_ID) - + entities = [] + for definition in definitions: + unique_id = definition.get(CONF_UNIQUE_ID) if unique_id and unique_id_prefix: unique_id = f"{unique_id_prefix}-{unique_id}" - - return [ + entities.append( TemplateSelect( hass, - entity.get(CONF_NAME, DEFAULT_NAME), - entity[CONF_STATE], - entity.get(CONF_AVAILABILITY), - entity[CONF_SELECT_OPTION], - entity[ATTR_OPTIONS], - entity.get(CONF_OPTIMISTIC, DEFAULT_OPTIMISTIC), + definition.get(CONF_NAME, DEFAULT_NAME), + definition[CONF_STATE], + definition.get(CONF_AVAILABILITY), + definition[CONF_SELECT_OPTION], + definition[ATTR_OPTIONS], + definition.get(CONF_OPTIMISTIC, DEFAULT_OPTIMISTIC), unique_id, ) - ] + ) + return entities async def async_setup_platform( diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index eb94a9284f4..ca4a30b1cd6 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -60,6 +60,38 @@ async def test_missing_optional_config(hass, calls): _verify(hass, "a", ["a", "b"]) +async def test_multiple_configs(hass, calls): + """Test: multiple select entities get created.""" + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "select": [ + { + "state": "{{ 'a' }}", + "select_option": {"service": "script.select_option"}, + "options": "{{ ['a', 'b'] }}", + }, + { + "state": "{{ 'a' }}", + "select_option": {"service": "script.select_option"}, + "options": "{{ ['a', 'b'] }}", + }, + ] + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, "a", ["a", "b"]) + _verify(hass, "a", ["a", "b"], f"{_TEST_SELECT}_2") + + async def test_missing_required_keys(hass, calls): """Test: missing required fields will fail.""" with assert_setup_component(0, "template"): @@ -250,9 +282,9 @@ async def test_trigger_select(hass): assert events[0].event_type == "test_number_event" -def _verify(hass, expected_current_option, expected_options): +def _verify(hass, expected_current_option, expected_options, entity_name=_TEST_SELECT): """Verify select's state.""" - state = hass.states.get(_TEST_SELECT) + state = hass.states.get(entity_name) attributes = state.attributes assert state.state == str(expected_current_option) assert attributes.get(SELECT_ATTR_OPTIONS) == expected_options From 19d7cb4439a3d2e45d4bbffa4f8ab867089f13ad Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Wed, 8 Sep 2021 17:54:44 +0300 Subject: [PATCH 06/14] Bump aioswitcher to 2.0.5 (#55934) --- homeassistant/components/switcher_kis/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index e982855e497..33ec7a67d92 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -3,7 +3,7 @@ "name": "Switcher", "documentation": "https://www.home-assistant.io/integrations/switcher_kis/", "codeowners": ["@tomerfi","@thecode"], - "requirements": ["aioswitcher==2.0.4"], + "requirements": ["aioswitcher==2.0.5"], "iot_class": "local_push", "config_flow": true } diff --git a/requirements_all.txt b/requirements_all.txt index 6562f28a5c3..53ab6ff1197 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -243,7 +243,7 @@ aiorecollect==1.0.8 aioshelly==0.6.4 # homeassistant.components.switcher_kis -aioswitcher==2.0.4 +aioswitcher==2.0.5 # homeassistant.components.syncthing aiosyncthing==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5b8a0dbb6ba..859bdbf2d8c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -164,7 +164,7 @@ aiorecollect==1.0.8 aioshelly==0.6.4 # homeassistant.components.switcher_kis -aioswitcher==2.0.4 +aioswitcher==2.0.5 # homeassistant.components.syncthing aiosyncthing==0.5.1 From 8ee4b49aa9b894b18c93d3a5acc46ebddaee2743 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Sep 2021 16:55:40 +0200 Subject: [PATCH 07/14] Do not let one bad statistic spoil the bunch (#55942) --- .../components/recorder/statistics.py | 10 +- tests/components/recorder/test_statistics.py | 105 +++++++++++++++++- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index ddc542d23b7..21c286f8eb6 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -8,6 +8,7 @@ import logging from typing import TYPE_CHECKING, Any, Callable from sqlalchemy import bindparam +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext import baked from sqlalchemy.orm.scoping import scoped_session @@ -215,7 +216,14 @@ def compile_statistics(instance: Recorder, start: datetime) -> bool: metadata_id = _update_or_add_metadata( instance.hass, session, entity_id, stat["meta"] ) - session.add(Statistics.from_stats(metadata_id, start, stat["stat"])) + try: + session.add(Statistics.from_stats(metadata_id, start, stat["stat"])) + except SQLAlchemyError: + _LOGGER.exception( + "Unexpected exception when inserting statistics %s:%s ", + metadata_id, + stat, + ) session.add(StatisticsRuns(start=start)) return True diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 318d82422d7..ac1681e2628 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -3,11 +3,15 @@ from datetime import timedelta from unittest.mock import patch, sentinel +import pytest from pytest import approx from homeassistant.components.recorder import history from homeassistant.components.recorder.const import DATA_INSTANCE -from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat +from homeassistant.components.recorder.models import ( + Statistics, + process_timestamp_to_utc_isoformat, +) from homeassistant.components.recorder.statistics import ( get_last_statistics, statistics_during_period, @@ -94,6 +98,105 @@ def test_compile_hourly_statistics(hass_recorder): assert stats == {} +@pytest.fixture +def mock_sensor_statistics(): + """Generate some fake statistics.""" + sensor_stats = { + "meta": {"unit_of_measurement": "dogs", "has_mean": True, "has_sum": False}, + "stat": {}, + } + + def get_fake_stats(): + return { + "sensor.test1": sensor_stats, + "sensor.test2": sensor_stats, + "sensor.test3": sensor_stats, + } + + with patch( + "homeassistant.components.sensor.recorder.compile_statistics", + return_value=get_fake_stats(), + ): + yield + + +@pytest.fixture +def mock_from_stats(): + """Mock out Statistics.from_stats.""" + counter = 0 + real_from_stats = Statistics.from_stats + + def from_stats(metadata_id, start, stats): + nonlocal counter + if counter == 0 and metadata_id == 2: + counter += 1 + return None + return real_from_stats(metadata_id, start, stats) + + with patch( + "homeassistant.components.recorder.statistics.Statistics.from_stats", + side_effect=from_stats, + autospec=True, + ): + yield + + +def test_compile_hourly_statistics_exception( + hass_recorder, mock_sensor_statistics, mock_from_stats +): + """Test exception handling when compiling hourly statistics.""" + + def mock_from_stats(): + raise ValueError + + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + + now = dt_util.utcnow() + recorder.do_adhoc_statistics(period="hourly", start=now) + recorder.do_adhoc_statistics(period="hourly", start=now + timedelta(hours=1)) + wait_recording_done(hass) + expected_1 = { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(now), + "mean": None, + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + expected_2 = { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(now + timedelta(hours=1)), + "mean": None, + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + expected_stats1 = [ + {**expected_1, "statistic_id": "sensor.test1"}, + {**expected_2, "statistic_id": "sensor.test1"}, + ] + expected_stats2 = [ + {**expected_2, "statistic_id": "sensor.test2"}, + ] + expected_stats3 = [ + {**expected_1, "statistic_id": "sensor.test3"}, + {**expected_2, "statistic_id": "sensor.test3"}, + ] + + stats = statistics_during_period(hass, now) + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.test3": expected_stats3, + } + + def test_rename_entity(hass_recorder): """Test statistics is migrated when entity_id is changed.""" hass = hass_recorder() From 81462d8655bb1ce76f5c0604afbd2ac555771edd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Sep 2021 17:05:16 +0200 Subject: [PATCH 08/14] Do not allow `inf` or `nan` sensor states in statistics (#55943) --- homeassistant/components/sensor/recorder.py | 38 +++++++----- tests/components/sensor/test_recorder.py | 65 +++++++++++++++++++++ 2 files changed, 89 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 8bf251ffb18..ae4da8e7fe2 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -4,6 +4,7 @@ from __future__ import annotations import datetime import itertools import logging +import math from typing import Callable from homeassistant.components.recorder import history, statistics @@ -172,6 +173,14 @@ def _get_units(fstates: list[tuple[float, State]]) -> set[str | None]: return {item[1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) for item in fstates} +def _parse_float(state: str) -> float: + """Parse a float string, throw on inf or nan.""" + fstate = float(state) + if math.isnan(fstate) or math.isinf(fstate): + raise ValueError + return fstate + + def _normalize_states( hass: HomeAssistant, entity_history: list[State], @@ -186,9 +195,10 @@ def _normalize_states( fstates = [] for state in entity_history: try: - fstates.append((float(state.state), state)) - except ValueError: + fstate = _parse_float(state.state) + except (ValueError, TypeError): # TypeError to guard for NULL state in DB continue + fstates.append((fstate, state)) if fstates: all_units = _get_units(fstates) @@ -218,20 +228,20 @@ def _normalize_states( for state in entity_history: try: - fstate = float(state.state) - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - # Exclude unsupported units from statistics - if unit not in UNIT_CONVERSIONS[device_class]: - if WARN_UNSUPPORTED_UNIT not in hass.data: - hass.data[WARN_UNSUPPORTED_UNIT] = set() - if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]: - hass.data[WARN_UNSUPPORTED_UNIT].add(entity_id) - _LOGGER.warning("%s has unknown unit %s", entity_id, unit) - continue - - fstates.append((UNIT_CONVERSIONS[device_class][unit](fstate), state)) + fstate = _parse_float(state.state) except ValueError: continue + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + # Exclude unsupported units from statistics + if unit not in UNIT_CONVERSIONS[device_class]: + if WARN_UNSUPPORTED_UNIT not in hass.data: + hass.data[WARN_UNSUPPORTED_UNIT] = set() + if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]: + hass.data[WARN_UNSUPPORTED_UNIT].add(entity_id) + _LOGGER.warning("%s has unknown unit %s", entity_id, unit) + continue + + fstates.append((UNIT_CONVERSIONS[device_class][unit](fstate), state)) return DEVICE_CLASS_UNITS[device_class], fstates diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index aeeab317eb1..0633a9db471 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -1,6 +1,7 @@ """The tests for sensor recorder platform.""" # pylint: disable=protected-access,invalid-name from datetime import timedelta +import math from unittest.mock import patch import pytest @@ -349,6 +350,70 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize("state_class", ["measurement"]) +@pytest.mark.parametrize( + "device_class,unit,native_unit,factor", + [ + ("energy", "kWh", "kWh", 1), + ], +) +def test_compile_hourly_sum_statistics_nan_inf_state( + hass_recorder, caplog, state_class, device_class, unit, native_unit, factor +): + """Test compiling hourly statistics with nan and inf states.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": state_class, + "unit_of_measurement": unit, + "last_reset": None, + } + seq = [10, math.nan, 15, 15, 20, math.inf, 20, 10] + + states = {"sensor.test1": []} + one = zero + for i in range(len(seq)): + one = one + timedelta(minutes=1) + _states = record_meter_state( + hass, one, "sensor.test1", attributes, seq[i : i + 1] + ) + states["sensor.test1"].extend(_states["sensor.test1"]) + + hist = history.get_significant_states( + hass, + zero - timedelta.resolution, + one + timedelta.resolution, + significant_changes_only=False, + ) + assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "max": None, + "mean": None, + "min": None, + "last_reset": process_timestamp_to_utc_isoformat(one), + "state": approx(factor * seq[7]), + "sum": approx(factor * (seq[2] + seq[3] + seq[4] + seq[6] + seq[7])), + }, + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize( "device_class,unit,native_unit,factor", [ From 413430bdba302d404fcdcc89970633746ea42d99 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Sep 2021 17:08:48 +0200 Subject: [PATCH 09/14] Fix handling of imperial units in long term statistics (#55959) --- .../components/recorder/statistics.py | 21 ++++++++++----- homeassistant/components/sensor/recorder.py | 2 +- tests/components/recorder/test_statistics.py | 14 +++++----- tests/components/sensor/test_recorder.py | 26 ++++++++++++------- 4 files changed, 40 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 21c286f8eb6..db82eb1ee39 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -377,11 +377,11 @@ def statistics_during_period( ) if not stats: return {} - return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata) + return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata, True) def get_last_statistics( - hass: HomeAssistant, number_of_stats: int, statistic_id: str + hass: HomeAssistant, number_of_stats: int, statistic_id: str, convert_units: bool ) -> dict[str, list[dict]]: """Return the last number_of_stats statistics for a statistic_id.""" statistic_ids = [statistic_id] @@ -411,7 +411,9 @@ def get_last_statistics( if not stats: return {} - return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata) + return _sorted_statistics_to_dict( + hass, stats, statistic_ids, metadata, convert_units + ) def _sorted_statistics_to_dict( @@ -419,11 +421,16 @@ def _sorted_statistics_to_dict( stats: list, statistic_ids: list[str] | None, metadata: dict[str, StatisticMetaData], + convert_units: bool, ) -> dict[str, list[dict]]: """Convert SQL results into JSON friendly data structure.""" result: dict = defaultdict(list) units = hass.config.units + def no_conversion(val: Any, _: Any) -> float | None: + """Return x.""" + return val # type: ignore + # Set all statistic IDs to empty lists in result set to maintain the order if statistic_ids is not None: for stat_id in statistic_ids: @@ -436,9 +443,11 @@ def _sorted_statistics_to_dict( for meta_id, group in groupby(stats, lambda stat: stat.metadata_id): # type: ignore unit = metadata[meta_id]["unit_of_measurement"] statistic_id = metadata[meta_id]["statistic_id"] - convert: Callable[[Any, Any], float | None] = UNIT_CONVERSIONS.get( - unit, lambda x, units: x # type: ignore - ) + convert: Callable[[Any, Any], float | None] + if convert_units: + convert = UNIT_CONVERSIONS.get(unit, lambda x, units: x) # type: ignore + else: + convert = no_conversion ent_results = result[meta_id] ent_results.extend( { diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index ae4da8e7fe2..4ba33d7a902 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -390,7 +390,7 @@ def compile_statistics( # noqa: C901 last_reset = old_last_reset = None new_state = old_state = None _sum = 0 - last_stats = statistics.get_last_statistics(hass, 1, entity_id) + last_stats = statistics.get_last_statistics(hass, 1, entity_id, False) if entity_id in last_stats: # We have compiled history for this sensor before, use that as a starting point last_reset = old_last_reset = last_stats[entity_id][0]["last_reset"] diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index ac1681e2628..0580460a537 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -36,7 +36,7 @@ def test_compile_hourly_statistics(hass_recorder): for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}): stats = statistics_during_period(hass, zero, **kwargs) assert stats == {} - stats = get_last_statistics(hass, 0, "sensor.test1") + stats = get_last_statistics(hass, 0, "sensor.test1", True) assert stats == {} recorder.do_adhoc_statistics(period="hourly", start=zero) @@ -82,19 +82,19 @@ def test_compile_hourly_statistics(hass_recorder): assert stats == {} # Test get_last_statistics - stats = get_last_statistics(hass, 0, "sensor.test1") + stats = get_last_statistics(hass, 0, "sensor.test1", True) assert stats == {} - stats = get_last_statistics(hass, 1, "sensor.test1") + stats = get_last_statistics(hass, 1, "sensor.test1", True) assert stats == {"sensor.test1": [{**expected_2, "statistic_id": "sensor.test1"}]} - stats = get_last_statistics(hass, 2, "sensor.test1") + stats = get_last_statistics(hass, 2, "sensor.test1", True) assert stats == {"sensor.test1": expected_stats1[::-1]} - stats = get_last_statistics(hass, 3, "sensor.test1") + stats = get_last_statistics(hass, 3, "sensor.test1", True) assert stats == {"sensor.test1": expected_stats1[::-1]} - stats = get_last_statistics(hass, 1, "sensor.test3") + stats = get_last_statistics(hass, 1, "sensor.test3", True) assert stats == {} @@ -219,7 +219,7 @@ def test_rename_entity(hass_recorder): for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}): stats = statistics_during_period(hass, zero, **kwargs) assert stats == {} - stats = get_last_statistics(hass, 0, "sensor.test1") + stats = get_last_statistics(hass, 0, "sensor.test1", True) assert stats == {} recorder.do_adhoc_statistics(period="hourly", start=zero) diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 0633a9db471..c886007bc1c 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -18,6 +18,7 @@ from homeassistant.components.recorder.statistics import ( from homeassistant.const import STATE_UNAVAILABLE from homeassistant.setup import setup_component import homeassistant.util.dt as dt_util +from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM from tests.components.recorder.common import wait_recording_done @@ -194,22 +195,29 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes @pytest.mark.parametrize("state_class", ["measurement"]) @pytest.mark.parametrize( - "device_class,unit,native_unit,factor", + "units,device_class,unit,display_unit,factor", [ - ("energy", "kWh", "kWh", 1), - ("energy", "Wh", "kWh", 1 / 1000), - ("monetary", "EUR", "EUR", 1), - ("monetary", "SEK", "SEK", 1), - ("gas", "m³", "m³", 1), - ("gas", "ft³", "m³", 0.0283168466), + (IMPERIAL_SYSTEM, "energy", "kWh", "kWh", 1), + (IMPERIAL_SYSTEM, "energy", "Wh", "kWh", 1 / 1000), + (IMPERIAL_SYSTEM, "monetary", "EUR", "EUR", 1), + (IMPERIAL_SYSTEM, "monetary", "SEK", "SEK", 1), + (IMPERIAL_SYSTEM, "gas", "m³", "ft³", 35.314666711), + (IMPERIAL_SYSTEM, "gas", "ft³", "ft³", 1), + (METRIC_SYSTEM, "energy", "kWh", "kWh", 1), + (METRIC_SYSTEM, "energy", "Wh", "kWh", 1 / 1000), + (METRIC_SYSTEM, "monetary", "EUR", "EUR", 1), + (METRIC_SYSTEM, "monetary", "SEK", "SEK", 1), + (METRIC_SYSTEM, "gas", "m³", "m³", 1), + (METRIC_SYSTEM, "gas", "ft³", "m³", 0.0283168466), ], ) def test_compile_hourly_sum_statistics_amount( - hass_recorder, caplog, state_class, device_class, unit, native_unit, factor + hass_recorder, caplog, units, state_class, device_class, unit, display_unit, factor ): """Test compiling hourly statistics.""" zero = dt_util.utcnow() hass = hass_recorder() + hass.config.units = units recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) attributes = { @@ -236,7 +244,7 @@ def test_compile_hourly_sum_statistics_amount( wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ - {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + {"statistic_id": "sensor.test1", "unit_of_measurement": display_unit} ] stats = statistics_during_period(hass, zero) assert stats == { From 5cba7932f36870b1ffcddae8ce48eb748a2d38cc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 8 Sep 2021 08:22:38 -0700 Subject: [PATCH 10/14] Bumped version to 2021.9.5 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index edba8a1a739..ee903de1abd 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "4" +PATCH_VERSION: Final = "5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From e3815c6c2e827af14978512963d09cb05cafd3ac Mon Sep 17 00:00:00 2001 From: Ruslan Sayfutdinov Date: Tue, 7 Sep 2021 19:29:17 +0100 Subject: [PATCH 11/14] Pin setuptools<58 --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ec7aeb7afb0..d91c18c2e6b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -580,7 +580,7 @@ jobs: python -m venv venv . venv/bin/activate - pip install -U "pip<20.3" setuptools wheel + pip install -U "pip<20.3" "setuptools<58" wheel pip install -r requirements_all.txt pip install -r requirements_test.txt pip install -e . From a17d2d7c71a5fe780b3aae85c96b8eda834685e6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 7 Sep 2021 20:53:43 -0700 Subject: [PATCH 12/14] Fix gas validation (#55886) --- homeassistant/components/energy/validate.py | 83 +++++++++++++++++---- tests/components/energy/test_validate.py | 56 ++++++++++++++ 2 files changed, 125 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 01709081d68..9ee6df30b5e 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -1,6 +1,7 @@ """Validate the energy preferences provide valid data.""" from __future__ import annotations +from collections.abc import Sequence import dataclasses from typing import Any @@ -10,12 +11,24 @@ from homeassistant.const import ( ENERGY_WATT_HOUR, STATE_UNAVAILABLE, STATE_UNKNOWN, + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, ) from homeassistant.core import HomeAssistant, callback, valid_entity_id from . import data from .const import DOMAIN +ENERGY_USAGE_UNITS = (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR) +ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy" +GAS_USAGE_UNITS = ( + ENERGY_WATT_HOUR, + ENERGY_KILO_WATT_HOUR, + VOLUME_CUBIC_METERS, + VOLUME_CUBIC_FEET, +) +GAS_UNIT_ERROR = "entity_unexpected_unit_gas" + @dataclasses.dataclass class ValidationIssue: @@ -43,8 +56,12 @@ class EnergyPreferencesValidation: @callback -def _async_validate_energy_stat( - hass: HomeAssistant, stat_value: str, result: list[ValidationIssue] +def _async_validate_usage_stat( + hass: HomeAssistant, + stat_value: str, + allowed_units: Sequence[str], + unit_error: str, + result: list[ValidationIssue], ) -> None: """Validate a statistic.""" has_entity_source = valid_entity_id(stat_value) @@ -91,10 +108,8 @@ def _async_validate_energy_stat( unit = state.attributes.get("unit_of_measurement") - if unit not in (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR): - result.append( - ValidationIssue("entity_unexpected_unit_energy", stat_value, unit) - ) + if unit not in allowed_units: + result.append(ValidationIssue(unit_error, stat_value, unit)) state_class = state.attributes.get("state_class") @@ -211,8 +226,12 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: if source["type"] == "grid": for flow in source["flow_from"]: - _async_validate_energy_stat( - hass, flow["stat_energy_from"], source_result + _async_validate_usage_stat( + hass, + flow["stat_energy_from"], + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, ) if flow.get("stat_cost") is not None: @@ -229,7 +248,13 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: ) for flow in source["flow_to"]: - _async_validate_energy_stat(hass, flow["stat_energy_to"], source_result) + _async_validate_usage_stat( + hass, + flow["stat_energy_to"], + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, + ) if flow.get("stat_compensation") is not None: _async_validate_cost_stat( @@ -247,7 +272,13 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: ) elif source["type"] == "gas": - _async_validate_energy_stat(hass, source["stat_energy_from"], source_result) + _async_validate_usage_stat( + hass, + source["stat_energy_from"], + GAS_USAGE_UNITS, + GAS_UNIT_ERROR, + source_result, + ) if source.get("stat_cost") is not None: _async_validate_cost_stat(hass, source["stat_cost"], source_result) @@ -263,15 +294,39 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: ) elif source["type"] == "solar": - _async_validate_energy_stat(hass, source["stat_energy_from"], source_result) + _async_validate_usage_stat( + hass, + source["stat_energy_from"], + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, + ) elif source["type"] == "battery": - _async_validate_energy_stat(hass, source["stat_energy_from"], source_result) - _async_validate_energy_stat(hass, source["stat_energy_to"], source_result) + _async_validate_usage_stat( + hass, + source["stat_energy_from"], + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, + ) + _async_validate_usage_stat( + hass, + source["stat_energy_to"], + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, + ) for device in manager.data["device_consumption"]: device_result: list[ValidationIssue] = [] result.device_consumption.append(device_result) - _async_validate_energy_stat(hass, device["stat_consumption"], device_result) + _async_validate_usage_stat( + hass, + device["stat_consumption"], + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + device_result, + ) return result diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 9a0b2105007..31f40bd24ea 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -441,3 +441,59 @@ async def test_validation_grid_price_errors( ], "device_consumption": [], } + + +async def test_validation_gas(hass, mock_energy_manager, mock_is_entity_recorded): + """Test validating gas with sensors for energy and cost/compensation.""" + mock_is_entity_recorded["sensor.gas_cost_1"] = False + mock_is_entity_recorded["sensor.gas_compensation_1"] = False + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "gas", + "stat_energy_from": "sensor.gas_consumption_1", + "stat_cost": "sensor.gas_cost_1", + }, + { + "type": "gas", + "stat_energy_from": "sensor.gas_consumption_2", + "stat_cost": "sensor.gas_cost_2", + }, + ] + } + ) + hass.states.async_set( + "sensor.gas_consumption_1", + "10.10", + {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + ) + hass.states.async_set( + "sensor.gas_consumption_2", + "10.10", + {"unit_of_measurement": "kWh", "state_class": "total_increasing"}, + ) + hass.states.async_set( + "sensor.gas_cost_2", + "10.10", + {"unit_of_measurement": "EUR/kWh", "state_class": "total_increasing"}, + ) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [ + [ + { + "type": "entity_unexpected_unit_gas", + "identifier": "sensor.gas_consumption_1", + "value": "beers", + }, + { + "type": "recorder_untracked", + "identifier": "sensor.gas_cost_1", + "value": None, + }, + ], + [], + ], + "device_consumption": [], + } From cbe4b2dc1d17502843e2623427ab06f54e5e1a43 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Sep 2021 21:46:28 +0200 Subject: [PATCH 13/14] Add support for state class measurement to energy cost sensor (#55962) --- homeassistant/components/energy/sensor.py | 52 +++- homeassistant/components/energy/validate.py | 17 +- tests/components/energy/test_sensor.py | 288 +++++++++++++++++++- tests/components/energy/test_validate.py | 11 +- 4 files changed, 332 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 45ef8ea5c17..5db085343bc 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -1,13 +1,16 @@ """Helper sensor for calculating utility costs.""" from __future__ import annotations +import copy from dataclasses import dataclass import logging from typing import Any, Final, Literal, TypeVar, cast from homeassistant.components.sensor import ( + ATTR_LAST_RESET, ATTR_STATE_CLASS, DEVICE_CLASS_MONETARY, + STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) @@ -18,14 +21,19 @@ from homeassistant.const import ( ENERGY_WATT_HOUR, VOLUME_CUBIC_METERS, ) -from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.core import HomeAssistant, State, callback, split_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +import homeassistant.util.dt as dt_util from .const import DOMAIN from .data import EnergyManager, async_get_manager +SUPPORTED_STATE_CLASSES = [ + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +] _LOGGER = logging.getLogger(__name__) @@ -206,15 +214,16 @@ class EnergyCostSensor(SensorEntity): f"{config[adapter.entity_energy_key]}_{adapter.entity_id_suffix}" ) self._attr_device_class = DEVICE_CLASS_MONETARY - self._attr_state_class = STATE_CLASS_TOTAL_INCREASING + self._attr_state_class = STATE_CLASS_MEASUREMENT self._config = config - self._last_energy_sensor_state: StateType | None = None + self._last_energy_sensor_state: State | None = None self._cur_value = 0.0 - def _reset(self, energy_state: StateType) -> None: + def _reset(self, energy_state: State) -> None: """Reset the cost sensor.""" self._attr_native_value = 0.0 self._cur_value = 0.0 + self._attr_last_reset = dt_util.utcnow() self._last_energy_sensor_state = energy_state self.async_write_ha_state() @@ -228,9 +237,8 @@ class EnergyCostSensor(SensorEntity): if energy_state is None: return - if ( - state_class := energy_state.attributes.get(ATTR_STATE_CLASS) - ) != STATE_CLASS_TOTAL_INCREASING: + state_class = energy_state.attributes.get(ATTR_STATE_CLASS) + if state_class not in SUPPORTED_STATE_CLASSES: if not self._wrong_state_class_reported: self._wrong_state_class_reported = True _LOGGER.warning( @@ -240,6 +248,13 @@ class EnergyCostSensor(SensorEntity): ) return + # last_reset must be set if the sensor is STATE_CLASS_MEASUREMENT + if ( + state_class == STATE_CLASS_MEASUREMENT + and ATTR_LAST_RESET not in energy_state.attributes + ): + return + try: energy = float(energy_state.state) except ValueError: @@ -273,7 +288,7 @@ class EnergyCostSensor(SensorEntity): if self._last_energy_sensor_state is None: # Initialize as it's the first time all required entities are in place. - self._reset(energy_state.state) + self._reset(energy_state) return energy_unit = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -298,20 +313,29 @@ class EnergyCostSensor(SensorEntity): ) return - if reset_detected( + if state_class != STATE_CLASS_TOTAL_INCREASING and energy_state.attributes.get( + ATTR_LAST_RESET + ) != self._last_energy_sensor_state.attributes.get(ATTR_LAST_RESET): + # Energy meter was reset, reset cost sensor too + energy_state_copy = copy.copy(energy_state) + energy_state_copy.state = "0.0" + self._reset(energy_state_copy) + elif state_class == STATE_CLASS_TOTAL_INCREASING and reset_detected( self.hass, cast(str, self._config[self._adapter.entity_energy_key]), energy, - float(self._last_energy_sensor_state), + float(self._last_energy_sensor_state.state), ): # Energy meter was reset, reset cost sensor too - self._reset(0) + energy_state_copy = copy.copy(energy_state) + energy_state_copy.state = "0.0" + self._reset(energy_state_copy) # Update with newly incurred cost - old_energy_value = float(self._last_energy_sensor_state) + old_energy_value = float(self._last_energy_sensor_state.state) self._cur_value += (energy - old_energy_value) * energy_price self._attr_native_value = round(self._cur_value, 2) - self._last_energy_sensor_state = energy_state.state + self._last_energy_sensor_state = energy_state async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 9ee6df30b5e..7097788aa30 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -113,7 +113,11 @@ def _async_validate_usage_stat( state_class = state.attributes.get("state_class") - if state_class != sensor.STATE_CLASS_TOTAL_INCREASING: + supported_state_classes = [ + sensor.STATE_CLASS_MEASUREMENT, + sensor.STATE_CLASS_TOTAL_INCREASING, + ] + if state_class not in supported_state_classes: result.append( ValidationIssue( "entity_unexpected_state_class_total_increasing", @@ -140,16 +144,13 @@ def _async_validate_price_entity( return try: - value: float | None = float(state.state) + float(state.state) except ValueError: result.append( ValidationIssue("entity_state_non_numeric", entity_id, state.state) ) return - if value is not None and value < 0: - result.append(ValidationIssue("entity_negative_state", entity_id, value)) - unit = state.attributes.get("unit_of_measurement") if unit is None or not unit.endswith( @@ -203,7 +204,11 @@ def _async_validate_cost_entity( state_class = state.attributes.get("state_class") - if state_class != sensor.STATE_CLASS_TOTAL_INCREASING: + supported_state_classes = [ + sensor.STATE_CLASS_MEASUREMENT, + sensor.STATE_CLASS_TOTAL_INCREASING, + ] + if state_class not in supported_state_classes: result.append( ValidationIssue( "entity_unexpected_state_class_total_increasing", entity_id, state_class diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index 542ea3296ce..31c73fc5b7a 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -77,7 +77,7 @@ async def test_cost_sensor_no_states(hass, hass_storage) -> None: ), ], ) -async def test_cost_sensor_price_entity( +async def test_cost_sensor_price_entity_total_increasing( hass, hass_storage, hass_ws_client, @@ -89,7 +89,7 @@ async def test_cost_sensor_price_entity( cost_sensor_entity_id, flow_type, ) -> None: - """Test energy cost price from sensor entity.""" + """Test energy cost price from total_increasing type sensor entity.""" def _compile_statistics(_): return compile_statistics(hass, now, now + timedelta(seconds=1)) @@ -136,6 +136,7 @@ async def test_cost_sensor_price_entity( } now = dt_util.utcnow() + last_reset_cost_sensor = now.isoformat() # Optionally initialize dependent entities if initial_energy is not None: @@ -152,7 +153,9 @@ async def test_cost_sensor_price_entity( state = hass.states.get(cost_sensor_entity_id) assert state.state == initial_cost assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + if initial_cost != "unknown": + assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == "measurement" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # Optional late setup of dependent entities @@ -168,7 +171,8 @@ async def test_cost_sensor_price_entity( state = hass.states.get(cost_sensor_entity_id) assert state.state == "0.0" assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == "measurement" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # # Unique ID temp disabled @@ -185,6 +189,7 @@ async def test_cost_sensor_price_entity( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "10.0" # 0 EUR + (10-0) kWh * 1 EUR/kWh = 10 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor # Nothing happens when price changes if price_entity is not None: @@ -199,6 +204,7 @@ async def test_cost_sensor_price_entity( assert msg["success"] state = hass.states.get(cost_sensor_entity_id) assert state.state == "10.0" # 10 EUR + (10-10) kWh * 2 EUR/kWh = 10 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor # Additional consumption is using the new price hass.states.async_set( @@ -209,6 +215,7 @@ async def test_cost_sensor_price_entity( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "19.0" # 10 EUR + (14.5-10) kWh * 2 EUR/kWh = 19 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor # Check generated statistics await async_wait_recording_done_without_instance(hass) @@ -225,6 +232,7 @@ async def test_cost_sensor_price_entity( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "18.0" # 19 EUR + (14-14.5) kWh * 2 EUR/kWh = 18 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor # Energy sensor is reset, with initial state at 4kWh, 0 kWh is used as zero-point hass.states.async_set( @@ -235,6 +243,8 @@ async def test_cost_sensor_price_entity( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "8.0" # 0 EUR + (4-0) kWh * 2 EUR/kWh = 8 EUR + assert state.attributes["last_reset"] != last_reset_cost_sensor + last_reset_cost_sensor = state.attributes["last_reset"] # Energy use bumped to 10 kWh hass.states.async_set( @@ -245,6 +255,213 @@ async def test_cost_sensor_price_entity( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "20.0" # 8 EUR + (10-4) kWh * 2 EUR/kWh = 20 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor + + # Check generated statistics + await async_wait_recording_done_without_instance(hass) + statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + assert cost_sensor_entity_id in statistics + assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 38.0 + + +@pytest.mark.parametrize("initial_energy,initial_cost", [(0, "0.0"), (None, "unknown")]) +@pytest.mark.parametrize( + "price_entity,fixed_price", [("sensor.energy_price", None), (None, 1)] +) +@pytest.mark.parametrize( + "usage_sensor_entity_id,cost_sensor_entity_id,flow_type", + [ + ("sensor.energy_consumption", "sensor.energy_consumption_cost", "flow_from"), + ( + "sensor.energy_production", + "sensor.energy_production_compensation", + "flow_to", + ), + ], +) +@pytest.mark.parametrize("energy_state_class", ["measurement"]) +async def test_cost_sensor_price_entity_total( + hass, + hass_storage, + hass_ws_client, + initial_energy, + initial_cost, + price_entity, + fixed_price, + usage_sensor_entity_id, + cost_sensor_entity_id, + flow_type, + energy_state_class, +) -> None: + """Test energy cost price from total type sensor entity.""" + + def _compile_statistics(_): + return compile_statistics(hass, now, now + timedelta(seconds=1)) + + energy_attributes = { + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_STATE_CLASS: energy_state_class, + } + + await async_init_recorder_component(hass) + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "entity_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": price_entity, + "number_energy_price": fixed_price, + } + ] + if flow_type == "flow_from" + else [], + "flow_to": [ + { + "stat_energy_to": "sensor.energy_production", + "entity_energy_to": "sensor.energy_production", + "stat_compensation": None, + "entity_energy_price": price_entity, + "number_energy_price": fixed_price, + } + ] + if flow_type == "flow_to" + else [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + now = dt_util.utcnow() + last_reset = dt_util.utc_from_timestamp(0).isoformat() + last_reset_cost_sensor = now.isoformat() + + # Optionally initialize dependent entities + if initial_energy is not None: + hass.states.async_set( + usage_sensor_entity_id, + initial_energy, + {**energy_attributes, **{"last_reset": last_reset}}, + ) + hass.states.async_set("sensor.energy_price", "1") + + with patch("homeassistant.util.dt.utcnow", return_value=now): + await setup_integration(hass) + + state = hass.states.get(cost_sensor_entity_id) + assert state.state == initial_cost + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY + if initial_cost != "unknown": + assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == "measurement" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" + + # Optional late setup of dependent entities + if initial_energy is None: + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set( + usage_sensor_entity_id, + "0", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "0.0" + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY + assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == "measurement" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" + + # # Unique ID temp disabled + # # entity_registry = er.async_get(hass) + # # entry = entity_registry.async_get(cost_sensor_entity_id) + # # assert entry.unique_id == "energy_energy_consumption cost" + + # Energy use bumped to 10 kWh + hass.states.async_set( + usage_sensor_entity_id, + "10", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "10.0" # 0 EUR + (10-0) kWh * 1 EUR/kWh = 10 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor + + # Nothing happens when price changes + if price_entity is not None: + hass.states.async_set(price_entity, "2") + await hass.async_block_till_done() + else: + energy_data = copy.deepcopy(energy_data) + energy_data["energy_sources"][0][flow_type][0]["number_energy_price"] = 2 + client = await hass_ws_client(hass) + await client.send_json({"id": 5, "type": "energy/save_prefs", **energy_data}) + msg = await client.receive_json() + assert msg["success"] + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "10.0" # 10 EUR + (10-10) kWh * 2 EUR/kWh = 10 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor + + # Additional consumption is using the new price + hass.states.async_set( + usage_sensor_entity_id, + "14.5", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "19.0" # 10 EUR + (14.5-10) kWh * 2 EUR/kWh = 19 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor + + # Check generated statistics + await async_wait_recording_done_without_instance(hass) + statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + assert cost_sensor_entity_id in statistics + assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 19.0 + + # Energy sensor has a small dip + hass.states.async_set( + usage_sensor_entity_id, + "14", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "18.0" # 19 EUR + (14-14.5) kWh * 2 EUR/kWh = 18 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor + + # Energy sensor is reset, with initial state at 4kWh, 0 kWh is used as zero-point + last_reset = (now + timedelta(seconds=1)).isoformat() + hass.states.async_set( + usage_sensor_entity_id, + "4", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "8.0" # 0 EUR + (4-0) kWh * 2 EUR/kWh = 8 EUR + assert state.attributes["last_reset"] != last_reset_cost_sensor + last_reset_cost_sensor = state.attributes["last_reset"] + + # Energy use bumped to 10 kWh + hass.states.async_set( + usage_sensor_entity_id, + "10", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "20.0" # 8 EUR + (10-4) kWh * 2 EUR/kWh = 20 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor # Check generated statistics await async_wait_recording_done_without_instance(hass) @@ -284,6 +501,7 @@ async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: now = dt_util.utcnow() + # Initial state: 10kWh hass.states.async_set( "sensor.energy_consumption", 10000, @@ -296,7 +514,7 @@ async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: state = hass.states.get("sensor.energy_consumption_cost") assert state.state == "0.0" - # Energy use bumped to 10 kWh + # Energy use bumped by 10 kWh hass.states.async_set( "sensor.energy_consumption", 20000, @@ -361,7 +579,7 @@ async def test_cost_sensor_handle_gas(hass, hass_storage) -> None: async def test_cost_sensor_wrong_state_class( hass, hass_storage, caplog, state_class ) -> None: - """Test energy sensor rejects wrong state_class.""" + """Test energy sensor rejects state_class with wrong state_class.""" energy_attributes = { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_STATE_CLASS: state_class, @@ -417,3 +635,61 @@ async def test_cost_sensor_wrong_state_class( state = hass.states.get("sensor.energy_consumption_cost") assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize("state_class", ["measurement"]) +async def test_cost_sensor_state_class_measurement_no_reset( + hass, hass_storage, caplog, state_class +) -> None: + """Test energy sensor rejects state_class with no last_reset.""" + energy_attributes = { + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_STATE_CLASS: state_class, + } + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "entity_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": None, + "number_energy_price": 0.5, + } + ], + "flow_to": [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + now = dt_util.utcnow() + + hass.states.async_set( + "sensor.energy_consumption", + 10000, + energy_attributes, + ) + + with patch("homeassistant.util.dt.utcnow", return_value=now): + await setup_integration(hass) + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == STATE_UNKNOWN + + # Energy use bumped to 10 kWh + hass.states.async_set( + "sensor.energy_consumption", + 20000, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == STATE_UNKNOWN diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 31f40bd24ea..8c67f3eabaf 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -382,15 +382,6 @@ async def test_validation_grid_price_not_exist(hass, mock_energy_manager): "value": "123,123.12", }, ), - ( - "-100", - "$/kWh", - { - "type": "entity_negative_state", - "identifier": "sensor.grid_price_1", - "value": -100.0, - }, - ), ( "123", "$/Ws", @@ -414,7 +405,7 @@ async def test_validation_grid_price_errors( hass.states.async_set( "sensor.grid_price_1", state, - {"unit_of_measurement": unit, "state_class": "total_increasing"}, + {"unit_of_measurement": unit, "state_class": "measurement"}, ) await mock_energy_manager.async_update( { From 8f344252c454da0c547090a3c51bb68e1c21b93a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Sep 2021 21:47:48 +0200 Subject: [PATCH 14/14] Add significant change support to AQI type sensors (#55833) --- .../components/google_assistant/trait.py | 14 +-- .../components/light/significant_change.py | 18 ++-- .../components/sensor/significant_change.py | 60 ++++++++++-- homeassistant/helpers/significant_change.py | 50 ++++++++-- .../sensor/test_significant_change.py | 93 ++++++++++--------- 5 files changed, 152 insertions(+), 83 deletions(-) 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)