mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 19:27:45 +00:00
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:
parent
3965f20526
commit
c54b65fdf0
@ -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 (
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user