diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 483d8b88f2e..087328ed4a6 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -73,8 +73,16 @@ DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) # The state represents a measurement in present time STATE_CLASS_MEASUREMENT: Final = "measurement" +# The state represents a total amount, e.g. a value of a stock portfolio +STATE_CLASS_TOTAL: Final = "total" +# The state represents a monotonically increasing total, e.g. an amount of consumed gas +STATE_CLASS_TOTAL_INCREASING: Final = "total_increasing" -STATE_CLASSES: Final[list[str]] = [STATE_CLASS_MEASUREMENT] +STATE_CLASSES: Final[list[str]] = [ + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, + STATE_CLASS_TOTAL_INCREASING, +] STATE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.In(STATE_CLASSES)) @@ -118,6 +126,7 @@ class SensorEntity(Entity): _attr_native_unit_of_measurement: str | None _attr_native_value: StateType = None _attr_state_class: str | None + _last_reset_reported = False _temperature_conversion_reported = False @property @@ -151,6 +160,25 @@ class SensorEntity(Entity): def state_attributes(self) -> dict[str, Any] | None: """Return state attributes.""" if last_reset := self.last_reset: + if ( + last_reset is not None + and self.state_class == STATE_CLASS_MEASUREMENT + and not self._last_reset_reported + ): + self._last_reset_reported = True + report_issue = self._suggest_report_issue() + _LOGGER.warning( + "Entity %s (%s) with state_class %s has set last_reset. Setting " + "last_reset for entities with state_class other than 'total' is " + "deprecated and will be removed from Home Assistant Core 2021.10. " + "Please update your configuration if state_class is manually " + "configured, otherwise %s", + self.entity_id, + type(self), + self.state_class, + report_issue, + ) + return {ATTR_LAST_RESET: last_reset.isoformat()} return None diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index fb7393cfe1d..66366934d27 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -17,6 +17,9 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, + STATE_CLASS_TOTAL_INCREASING, + STATE_CLASSES, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -50,15 +53,27 @@ from . import ATTR_LAST_RESET, DOMAIN _LOGGER = logging.getLogger(__name__) DEVICE_CLASS_OR_UNIT_STATISTICS = { - DEVICE_CLASS_BATTERY: {"mean", "min", "max"}, - DEVICE_CLASS_ENERGY: {"sum"}, - DEVICE_CLASS_HUMIDITY: {"mean", "min", "max"}, - DEVICE_CLASS_MONETARY: {"sum"}, - DEVICE_CLASS_POWER: {"mean", "min", "max"}, - DEVICE_CLASS_PRESSURE: {"mean", "min", "max"}, - DEVICE_CLASS_TEMPERATURE: {"mean", "min", "max"}, - DEVICE_CLASS_GAS: {"sum"}, - PERCENTAGE: {"mean", "min", "max"}, + STATE_CLASS_TOTAL: { + DEVICE_CLASS_ENERGY: {"sum"}, + DEVICE_CLASS_GAS: {"sum"}, + DEVICE_CLASS_MONETARY: {"sum"}, + }, + STATE_CLASS_MEASUREMENT: { + DEVICE_CLASS_BATTERY: {"mean", "min", "max"}, + DEVICE_CLASS_HUMIDITY: {"mean", "min", "max"}, + DEVICE_CLASS_POWER: {"mean", "min", "max"}, + DEVICE_CLASS_PRESSURE: {"mean", "min", "max"}, + DEVICE_CLASS_TEMPERATURE: {"mean", "min", "max"}, + PERCENTAGE: {"mean", "min", "max"}, + # Deprecated, support will be removed in Home Assistant 2021.10 + DEVICE_CLASS_ENERGY: {"sum"}, + DEVICE_CLASS_GAS: {"sum"}, + DEVICE_CLASS_MONETARY: {"sum"}, + }, + STATE_CLASS_TOTAL_INCREASING: { + DEVICE_CLASS_ENERGY: {"sum"}, + DEVICE_CLASS_GAS: {"sum"}, + }, } # Normalized units which will be stored in the statistics table @@ -109,24 +124,28 @@ UNIT_CONVERSIONS: dict[str, dict[str, Callable]] = { WARN_UNSUPPORTED_UNIT = set() -def _get_entities(hass: HomeAssistant) -> list[tuple[str, str]]: - """Get (entity_id, device_class) of all sensors for which to compile statistics.""" +def _get_entities(hass: HomeAssistant) -> list[tuple[str, str, str]]: + """Get (entity_id, state_class, key) of all sensors for which to compile statistics. + + Key is either a device class or a unit and is used to index the + DEVICE_CLASS_OR_UNIT_STATISTICS map. + """ all_sensors = hass.states.all(DOMAIN) entity_ids = [] for state in all_sensors: - if state.attributes.get(ATTR_STATE_CLASS) != STATE_CLASS_MEASUREMENT: + if (state_class := state.attributes.get(ATTR_STATE_CLASS)) not in STATE_CLASSES: continue if ( key := state.attributes.get(ATTR_DEVICE_CLASS) - ) in DEVICE_CLASS_OR_UNIT_STATISTICS: - entity_ids.append((state.entity_id, key)) + ) in DEVICE_CLASS_OR_UNIT_STATISTICS[state_class]: + entity_ids.append((state.entity_id, state_class, key)) if ( key := state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - ) in DEVICE_CLASS_OR_UNIT_STATISTICS: - entity_ids.append((state.entity_id, key)) + ) in DEVICE_CLASS_OR_UNIT_STATISTICS[state_class]: + entity_ids.append((state.entity_id, state_class, key)) return entity_ids @@ -228,8 +247,8 @@ def compile_statistics( hass, start - datetime.timedelta.resolution, end, [i[0] for i in entities] ) - for entity_id, key in entities: - wanted_statistics = DEVICE_CLASS_OR_UNIT_STATISTICS[key] + for entity_id, state_class, key in entities: + wanted_statistics = DEVICE_CLASS_OR_UNIT_STATISTICS[state_class][key] if entity_id not in history_list: continue @@ -272,9 +291,28 @@ def compile_statistics( for fstate, state in fstates: - if "last_reset" not in state.attributes: + # Deprecated, will be removed in Home Assistant 2021.10 + if ( + "last_reset" not in state.attributes + and state_class == STATE_CLASS_MEASUREMENT + ): continue - if (last_reset := state.attributes["last_reset"]) != old_last_reset: + + reset = False + if ( + state_class != STATE_CLASS_TOTAL_INCREASING + and (last_reset := state.attributes.get("last_reset")) + != old_last_reset + ): + reset = True + elif old_state is None and last_reset is None: + reset = True + elif state_class == STATE_CLASS_TOTAL_INCREASING and ( + old_state is None or fstate < old_state + ): + reset = True + + if reset: # The sensor has been reset, update the sum if old_state is not None: _sum += new_state - old_state @@ -285,14 +323,21 @@ def compile_statistics( else: new_state = fstate - if last_reset is None or new_state is None or old_state is None: + # Deprecated, will be removed in Home Assistant 2021.10 + if last_reset is None and state_class == STATE_CLASS_MEASUREMENT: + # No valid updates + result.pop(entity_id) + continue + + if new_state is None or old_state is None: # No valid updates result.pop(entity_id) continue # Update the sum with the last state _sum += new_state - old_state - stat["last_reset"] = dt_util.parse_datetime(last_reset) + if last_reset is not None: + stat["last_reset"] = dt_util.parse_datetime(last_reset) stat["sum"] = _sum stat["state"] = new_state @@ -307,8 +352,8 @@ def list_statistic_ids(hass: HomeAssistant, statistic_type: str | None = None) - statistic_ids = {} - for entity_id, key in entities: - provided_statistics = DEVICE_CLASS_OR_UNIT_STATISTICS[key] + for entity_id, state_class, key in entities: + provided_statistics = DEVICE_CLASS_OR_UNIT_STATISTICS[state_class][key] if statistic_type is not None and statistic_type not in provided_statistics: continue diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index f09cd489489..793bcaf4f99 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -1,6 +1,7 @@ """The test for sensor device automation.""" from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util async def test_deprecated_temperature_conversion( @@ -28,3 +29,24 @@ async def test_deprecated_temperature_conversion( "your configuration if device_class is manually configured, otherwise report it " "to the custom component author." ) in caplog.text + + +async def test_deprecated_last_reset(hass, caplog, enable_custom_integrations): + """Test warning on deprecated last reset.""" + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", state_class="measurement", last_reset=dt_util.utc_from_timestamp(0) + ) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + assert ( + "Entity sensor.test () " + "with state_class measurement has set last_reset. Setting last_reset for " + "entities with state_class other than 'total' is deprecated and will be " + "removed from Home Assistant Core 2021.10. Please update your configuration if " + "state_class is manually configured, otherwise report it to the custom " + "component author." + ) in caplog.text diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index a612bc75a77..45d81e4b678 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -154,6 +154,7 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize("state_class", ["measurement", "total"]) @pytest.mark.parametrize( "device_class,unit,native_unit,factor", [ @@ -165,8 +166,8 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes ("gas", "ft³", "m³", 0.0283168466), ], ) -def test_compile_hourly_sum_statistics( - hass_recorder, caplog, device_class, unit, native_unit, factor +def test_compile_hourly_sum_statistics_amount( + hass_recorder, caplog, state_class, device_class, unit, native_unit, factor ): """Test compiling hourly statistics.""" zero = dt_util.utcnow() @@ -175,7 +176,7 @@ def test_compile_hourly_sum_statistics( setup_component(hass, "sensor", {}) attributes = { "device_class": device_class, - "state_class": "measurement", + "state_class": state_class, "unit_of_measurement": unit, "last_reset": None, } @@ -237,6 +238,168 @@ def test_compile_hourly_sum_statistics( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize( + "device_class,unit,native_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), + ], +) +def test_compile_hourly_sum_statistics_total_no_reset( + hass_recorder, caplog, device_class, unit, native_unit, factor +): + """Test compiling hourly statistics.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": "total", + "unit_of_measurement": unit, + } + seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] + + four, eight, states = record_meter_states( + hass, zero, "sensor.test1", attributes, seq + ) + hist = history.get_significant_states( + hass, zero - timedelta.resolution, eight + timedelta.resolution + ) + assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + wait_recording_done(hass) + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + 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": None, + "state": approx(factor * seq[2]), + "sum": approx(factor * 10.0), + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(factor * seq[5]), + "sum": approx(factor * 30.0), + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(factor * seq[8]), + "sum": approx(factor * 60.0), + }, + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + +@pytest.mark.parametrize( + "device_class,unit,native_unit,factor", + [ + ("energy", "kWh", "kWh", 1), + ("energy", "Wh", "kWh", 1 / 1000), + ("gas", "m³", "m³", 1), + ("gas", "ft³", "m³", 0.0283168466), + ], +) +def test_compile_hourly_sum_statistics_total_increasing( + hass_recorder, caplog, device_class, unit, native_unit, factor +): + """Test compiling hourly statistics.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": "total_increasing", + "unit_of_measurement": unit, + } + seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] + + four, eight, states = record_meter_states( + hass, zero, "sensor.test1", attributes, seq + ) + hist = history.get_significant_states( + hass, zero - timedelta.resolution, eight + timedelta.resolution + ) + assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + wait_recording_done(hass) + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + 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": None, + "state": approx(factor * seq[2]), + "sum": approx(factor * 10.0), + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(factor * seq[5]), + "sum": approx(factor * 40.0), + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(factor * seq[8]), + "sum": approx(factor * 70.0), + }, + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): """Test compiling hourly statistics.""" zero = dt_util.utcnow() diff --git a/tests/testing_config/custom_components/test/sensor.py b/tests/testing_config/custom_components/test/sensor.py index f4b2e96321e..63f47a0f854 100644 --- a/tests/testing_config/custom_components/test/sensor.py +++ b/tests/testing_config/custom_components/test/sensor.py @@ -71,6 +71,11 @@ class MockSensor(MockEntity, sensor.SensorEntity): """Return the class of this sensor.""" return self._handle("device_class") + @property + def last_reset(self): + """Return the last_reset of this sensor.""" + return self._handle("last_reset") + @property def native_unit_of_measurement(self): """Return the native unit_of_measurement of this sensor.""" @@ -80,3 +85,8 @@ class MockSensor(MockEntity, sensor.SensorEntity): def native_value(self): """Return the native value of this sensor.""" return self._handle("native_value") + + @property + def state_class(self): + """Return the state class of this sensor.""" + return self._handle("state_class")