From c54b65fdf0d5f87be2be9fab45038b386c3d5233 Mon Sep 17 00:00:00 2001 From: RoboMagus <68224306+RoboMagus@users.noreply.github.com> Date: Thu, 25 Jan 2024 11:12:03 +0100 Subject: [PATCH] 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 --- homeassistant/components/template/sensor.py | 58 +++++- tests/components/template/test_sensor.py | 209 +++++++++++++++++++- 2 files changed, 264 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index e757f561a7e..3a3d0682805 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -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 ( diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index d25f638cfdb..314218fc849 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -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