Add support for total and total_increasing sensor state classes (#54523)

* Add support for amount and meter sensor state classes

* Ignore last_reset for STATE_CLASS_METER sensors

* Update tests

* Rename STATE_CLASS_METER to STATE_CLASS_AMOUNT_INCREASING

* Rename STATE_CLASS_AMOUNT to STATE_CLASS_TOTAL

* Fix typo

* Log warning if last_reset set together with state_class measurement

* Fix warning message
This commit is contained in:
Erik Montnemery 2021-08-13 12:35:23 +02:00 committed by GitHub
parent 821b93b0d0
commit 029873a088
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 296 additions and 28 deletions

View file

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

View file

@ -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"},
STATE_CLASS_TOTAL: {
DEVICE_CLASS_ENERGY: {"sum"},
DEVICE_CLASS_HUMIDITY: {"mean", "min", "max"},
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"},
DEVICE_CLASS_GAS: {"sum"},
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,13 +323,20 @@ 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
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

View file

@ -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 (<class 'custom_components.test.sensor.MockSensor'>) "
"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

View file

@ -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³", "", 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", "", "", 1),
("gas", "ft³", "", 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", "", "", 1),
("gas", "ft³", "", 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()

View file

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