Add 'last_reset' for 'total' state_class template sensor (#100806)

* Add last_reset to trigger based template sensors

* Add last_reset to state based template sensors

* CI check fixes

* Add pytests

* Add test cases for last_reset datetime parsing

* Add test for static last_reset value

* Fix ruff-format
This commit is contained in:
RoboMagus 2024-01-25 11:12:03 +01:00 committed by GitHub
parent 3965f20526
commit c54b65fdf0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 264 additions and 3 deletions

View File

@ -2,11 +2,13 @@
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime from datetime import date, datetime
import logging
from typing import Any from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
ATTR_LAST_RESET,
CONF_STATE_CLASS, CONF_STATE_CLASS,
DEVICE_CLASSES_SCHEMA, DEVICE_CLASSES_SCHEMA,
DOMAIN as SENSOR_DOMAIN, DOMAIN as SENSOR_DOMAIN,
@ -15,6 +17,7 @@ from homeassistant.components.sensor import (
RestoreSensor, RestoreSensor,
SensorDeviceClass, SensorDeviceClass,
SensorEntity, SensorEntity,
SensorStateClass,
) )
from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.components.sensor.helpers import async_parse_date_datetime
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -41,6 +44,7 @@ from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.trigger_template_entity import TEMPLATE_SENSOR_BASE_SCHEMA from homeassistant.helpers.trigger_template_entity import TEMPLATE_SENSOR_BASE_SCHEMA
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
from . import TriggerUpdateCoordinator from . import TriggerUpdateCoordinator
from .const import ( from .const import (
@ -63,14 +67,29 @@ LEGACY_FIELDS = {
} }
SENSOR_SCHEMA = ( def validate_last_reset(val):
"""Run extra validation checks."""
if (
val.get(ATTR_LAST_RESET) is not None
and val.get(CONF_STATE_CLASS) != SensorStateClass.TOTAL
):
raise vol.Invalid(
"last_reset is only valid for template sensors with state_class 'total'"
)
return val
SENSOR_SCHEMA = vol.All(
vol.Schema( vol.Schema(
{ {
vol.Required(CONF_STATE): cv.template, vol.Required(CONF_STATE): cv.template,
vol.Optional(ATTR_LAST_RESET): cv.template,
} }
) )
.extend(TEMPLATE_SENSOR_BASE_SCHEMA.schema) .extend(TEMPLATE_SENSOR_BASE_SCHEMA.schema)
.extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema) .extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema),
validate_last_reset,
) )
@ -138,6 +157,8 @@ PLATFORM_SCHEMA = vol.All(
extra_validation_checks, extra_validation_checks,
) )
_LOGGER = logging.getLogger(__name__)
@callback @callback
def _async_create_template_tracking_entities( def _async_create_template_tracking_entities(
@ -236,6 +257,9 @@ class SensorTemplate(TemplateEntity, SensorEntity):
self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_device_class = config.get(CONF_DEVICE_CLASS)
self._attr_state_class = config.get(CONF_STATE_CLASS) self._attr_state_class = config.get(CONF_STATE_CLASS)
self._template: template.Template = config[CONF_STATE] self._template: template.Template = config[CONF_STATE]
self._attr_last_reset_template: None | template.Template = config.get(
ATTR_LAST_RESET
)
if (object_id := config.get(CONF_OBJECT_ID)) is not None: if (object_id := config.get(CONF_OBJECT_ID)) is not None:
self.entity_id = async_generate_entity_id( self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, object_id, hass=hass ENTITY_ID_FORMAT, object_id, hass=hass
@ -247,9 +271,20 @@ class SensorTemplate(TemplateEntity, SensorEntity):
self.add_template_attribute( self.add_template_attribute(
"_attr_native_value", self._template, None, self._update_state "_attr_native_value", self._template, None, self._update_state
) )
if self._attr_last_reset_template is not None:
self.add_template_attribute(
"_attr_last_reset",
self._attr_last_reset_template,
cv.datetime,
self._update_last_reset,
)
super()._async_setup_templates() super()._async_setup_templates()
@callback
def _update_last_reset(self, result):
self._attr_last_reset = result
@callback @callback
def _update_state(self, result): def _update_state(self, result):
super()._update_state(result) super()._update_state(result)
@ -283,6 +318,13 @@ class TriggerSensorEntity(TriggerEntity, RestoreSensor):
) -> None: ) -> None:
"""Initialize.""" """Initialize."""
super().__init__(hass, coordinator, config) super().__init__(hass, coordinator, config)
if (last_reset_template := config.get(ATTR_LAST_RESET)) is not None:
if last_reset_template.is_static:
self._static_rendered[ATTR_LAST_RESET] = last_reset_template.template
else:
self._to_render_simple.append(ATTR_LAST_RESET)
self._attr_state_class = config.get(CONF_STATE_CLASS) self._attr_state_class = config.get(CONF_STATE_CLASS)
self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT)
@ -310,6 +352,18 @@ class TriggerSensorEntity(TriggerEntity, RestoreSensor):
"""Process new data.""" """Process new data."""
super()._process_data() super()._process_data()
# Update last_reset
if ATTR_LAST_RESET in self._rendered:
parsed_timestamp = dt_util.parse_datetime(self._rendered[ATTR_LAST_RESET])
if parsed_timestamp is None:
_LOGGER.warning(
"%s rendered invalid timestamp for last_reset attribute: %s",
self.entity_id,
self._rendered.get(ATTR_LAST_RESET),
)
else:
self._attr_last_reset = parsed_timestamp
if ( if (
state := self._rendered.get(CONF_STATE) state := self._rendered.get(CONF_STATE)
) is None or self.device_class not in ( ) is None or self.device_class not in (

View File

@ -1,6 +1,6 @@
"""The test for the Template sensor platform.""" """The test for the Template sensor platform."""
from asyncio import Event from asyncio import Event
from datetime import timedelta from datetime import datetime, timedelta
from unittest.mock import ANY, patch from unittest.mock import ANY, patch
import pytest import pytest
@ -8,6 +8,7 @@ from syrupy.assertion import SnapshotAssertion
from homeassistant.bootstrap import async_from_config_dict from homeassistant.bootstrap import async_from_config_dict
from homeassistant.components import sensor, template from homeassistant.components import sensor, template
from homeassistant.components.template.sensor import TriggerSensorEntity
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_PICTURE, ATTR_ENTITY_PICTURE,
ATTR_ICON, ATTR_ICON,
@ -1456,6 +1457,212 @@ async def test_trigger_entity_device_class_errors_works(hass: HomeAssistant) ->
assert ts_state.state == STATE_UNKNOWN assert ts_state.state == STATE_UNKNOWN
async def test_entity_last_reset_total_increasing(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test last_reset is disallowed for total_increasing state_class."""
# State of timestamp sensors are always in UTC
now = dt_util.utcnow()
with patch("homeassistant.util.dt.now", return_value=now):
assert await async_setup_component(
hass,
"template",
{
"template": [
{
"sensor": [
{
"name": "TotalIncreasing entity",
"state": "{{ 0 }}",
"state_class": "total_increasing",
"last_reset": "{{ today_at('00:00:00')}}",
},
],
},
],
},
)
await hass.async_block_till_done()
totalincreasing_state = hass.states.get("sensor.totalincreasing_entity")
assert totalincreasing_state is None
assert (
"last_reset is only valid for template sensors with state_class 'total'"
in caplog.text
)
async def test_entity_last_reset_setup(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test last_reset works for template sensors."""
# State of timestamp sensors are always in UTC
now = dt_util.utcnow()
with patch("homeassistant.util.dt.now", return_value=now):
assert await async_setup_component(
hass,
"template",
{
"template": [
{
"sensor": [
{
"name": "Total entity",
"state": "{{ states('sensor.test_state') | int(0) + 1 }}",
"state_class": "total",
"last_reset": "{{ now() }}",
},
{
"name": "Static last_reset entity",
"state": "{{ states('sensor.test_state') | int(0) }}",
"state_class": "total",
"last_reset": "2023-01-01T00:00:00",
},
],
},
{
"trigger": {
"platform": "state",
"entity_id": [
"sensor.test_state",
],
},
"sensor": {
"name": "Total trigger entity",
"state": "{{ states('sensor.test_state') | int(0) + 2 }}",
"state_class": "total",
"last_reset": "{{ as_datetime('2023-01-01') }}",
},
},
],
},
)
await hass.async_block_till_done()
# Trigger update
hass.states.async_set("sensor.test_state", "0")
await hass.async_block_till_done()
await hass.async_block_till_done()
static_state = hass.states.get("sensor.static_last_reset_entity")
assert static_state is not None
assert static_state.state == "0"
assert static_state.attributes.get("state_class") == "total"
assert (
static_state.attributes.get("last_reset")
== datetime(2023, 1, 1, 0, 0, 0).isoformat()
)
total_state = hass.states.get("sensor.total_entity")
assert total_state is not None
assert total_state.state == "1"
assert total_state.attributes.get("state_class") == "total"
assert total_state.attributes.get("last_reset") == now.isoformat()
total_trigger_state = hass.states.get("sensor.total_trigger_entity")
assert total_trigger_state is not None
assert total_trigger_state.state == "2"
assert total_trigger_state.attributes.get("state_class") == "total"
assert (
total_trigger_state.attributes.get("last_reset")
== datetime(2023, 1, 1).isoformat()
)
async def test_entity_last_reset_static_value(hass: HomeAssistant) -> None:
"""Test static last_reset marked as static_rendered."""
tse = TriggerSensorEntity(
hass,
None,
{
"name": Template("Static last_reset entity", hass),
"state": Template("{{ states('sensor.test_state') | int(0) }}", hass),
"state_class": "total",
"last_reset": Template("2023-01-01T00:00:00", hass),
},
)
assert "last_reset" in tse._static_rendered
async def test_entity_last_reset_parsing(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test last_reset works for template sensors."""
# State of timestamp sensors are always in UTC
now = dt_util.utcnow()
with patch(
"homeassistant.components.template.sensor._LOGGER.warning"
) as mocked_warning, patch(
"homeassistant.components.template.template_entity._LOGGER.error"
) as mocked_error, patch("homeassistant.util.dt.now", return_value=now):
assert await async_setup_component(
hass,
"template",
{
"template": [
{
"sensor": [
{
"name": "Total entity",
"state": "{{ states('sensor.test_state') | int(0) + 1 }}",
"state_class": "total",
"last_reset": "{{ 'not a datetime' }}",
},
],
},
{
"trigger": {
"platform": "state",
"entity_id": [
"sensor.test_state",
],
},
"sensor": {
"name": "Total trigger entity",
"state": "{{ states('sensor.test_state') | int(0) + 2 }}",
"state_class": "total",
"last_reset": "{{ 'not a datetime' }}",
},
},
],
},
)
await hass.async_block_till_done()
# Trigger update
hass.states.async_set("sensor.test_state", "0")
await hass.async_block_till_done()
await hass.async_block_till_done()
# Trigger based datetime parsing warning:
mocked_warning.assert_called_once_with(
"%s rendered invalid timestamp for last_reset attribute: %s",
"sensor.total_trigger_entity",
"not a datetime",
)
# State based datetime parsing error
mocked_error.assert_called_once()
args, _ = mocked_error.call_args
assert len(args) == 6
assert args[0] == (
"Error validating template result '%s' "
"from template '%s' "
"for attribute '%s' in entity %s "
"validation message '%s'"
)
assert args[1] == "not a datetime"
assert args[3] == "_attr_last_reset"
assert args[4] == "sensor.total_entity"
assert args[5] == "Invalid datetime specified: not a datetime"
async def test_entity_device_class_parsing_works(hass: HomeAssistant) -> None: async def test_entity_device_class_parsing_works(hass: HomeAssistant) -> None:
"""Test entity device class parsing works.""" """Test entity device class parsing works."""
# State of timestamp sensors are always in UTC # State of timestamp sensors are always in UTC