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
pull/108840/head
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 datetime import date, datetime
import logging
from typing import Any
import voluptuous as vol
from homeassistant.components.sensor import (
ATTR_LAST_RESET,
CONF_STATE_CLASS,
DEVICE_CLASSES_SCHEMA,
DOMAIN as SENSOR_DOMAIN,
@ -15,6 +17,7 @@ from homeassistant.components.sensor import (
RestoreSensor,
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.components.sensor.helpers import async_parse_date_datetime
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.trigger_template_entity import TEMPLATE_SENSOR_BASE_SCHEMA
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
from . import TriggerUpdateCoordinator
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.Required(CONF_STATE): cv.template,
vol.Optional(ATTR_LAST_RESET): cv.template,
}
)
.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,
)
_LOGGER = logging.getLogger(__name__)
@callback
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_state_class = config.get(CONF_STATE_CLASS)
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:
self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, object_id, hass=hass
@ -247,9 +271,20 @@ class SensorTemplate(TemplateEntity, SensorEntity):
self.add_template_attribute(
"_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()
@callback
def _update_last_reset(self, result):
self._attr_last_reset = result
@callback
def _update_state(self, result):
super()._update_state(result)
@ -283,6 +318,13 @@ class TriggerSensorEntity(TriggerEntity, RestoreSensor):
) -> None:
"""Initialize."""
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_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT)
@ -310,6 +352,18 @@ class TriggerSensorEntity(TriggerEntity, RestoreSensor):
"""Process new 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 (
state := self._rendered.get(CONF_STATE)
) is None or self.device_class not in (

View File

@ -1,6 +1,6 @@
"""The test for the Template sensor platform."""
from asyncio import Event
from datetime import timedelta
from datetime import datetime, timedelta
from unittest.mock import ANY, patch
import pytest
@ -8,6 +8,7 @@ from syrupy.assertion import SnapshotAssertion
from homeassistant.bootstrap import async_from_config_dict
from homeassistant.components import sensor, template
from homeassistant.components.template.sensor import TriggerSensorEntity
from homeassistant.const import (
ATTR_ENTITY_PICTURE,
ATTR_ICON,
@ -1456,6 +1457,212 @@ async def test_trigger_entity_device_class_errors_works(hass: HomeAssistant) ->
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:
"""Test entity device class parsing works."""
# State of timestamp sensors are always in UTC