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:
parent
821b93b0d0
commit
029873a088
5 changed files with 296 additions and 28 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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")
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue