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-formatpull/108840/head
parent
3965f20526
commit
c54b65fdf0
|
@ -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 (
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue