Add uptime device class to the sensor platform (#164266)

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Simone Chemelli
2026-04-27 14:35:28 +01:00
committed by abmantis
co-authored by Copilot
parent d8a389afe0
commit ed99a9c7d9
21 changed files with 171 additions and 62 deletions
-2
View File
@@ -66,8 +66,6 @@ SWITCH_TYPE_WIFINETWORK = "WiFiNetwork"
BUTTON_TYPE_WOL = "WakeOnLan"
UPTIME_DEVIATION = 5
FRITZ_EXCEPTIONS = (
ConnectionError,
FritzActionError,
+6 -19
View File
@@ -28,7 +28,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
from .const import DSL_CONNECTION, UPTIME_DEVIATION
from .const import DSL_CONNECTION
from .coordinator import FritzConfigEntry
from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription
from .models import ConnectionInfo
@@ -39,31 +39,18 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
def _uptime_calculation(seconds_uptime: float, last_value: datetime | None) -> datetime:
"""Calculate uptime with deviation."""
delta_uptime = utcnow() - timedelta(seconds=seconds_uptime)
if (
not last_value
or abs((delta_uptime - last_value).total_seconds()) > UPTIME_DEVIATION
):
return delta_uptime
return last_value
def _retrieve_device_uptime_state(
status: FritzStatus, last_value: datetime
status: FritzStatus, last_value: datetime | None
) -> datetime:
"""Return uptime from device."""
return _uptime_calculation(status.device_uptime, last_value)
return utcnow() - timedelta(seconds=status.device_uptime)
def _retrieve_connection_uptime_state(
status: FritzStatus, last_value: datetime | None
) -> datetime:
"""Return uptime from connection."""
return _uptime_calculation(status.connection_uptime, last_value)
return utcnow() - timedelta(seconds=status.connection_uptime)
def _retrieve_external_ip_state(status: FritzStatus, last_value: str) -> str:
@@ -200,7 +187,7 @@ CONNECTION_SENSOR_TYPES: tuple[FritzConnectionSensorEntityDescription, ...] = (
FritzConnectionSensorEntityDescription(
key="connection_uptime",
translation_key="connection_uptime",
device_class=SensorDeviceClass.TIMESTAMP,
device_class=SensorDeviceClass.UPTIME,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=_retrieve_connection_uptime_state,
),
@@ -308,7 +295,7 @@ DEVICE_SENSOR_TYPES: tuple[FritzDeviceSensorEntityDescription, ...] = (
FritzDeviceSensorEntityDescription(
key="device_uptime",
translation_key="device_uptime",
device_class=SensorDeviceClass.TIMESTAMP,
device_class=SensorDeviceClass.UPTIME,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=_retrieve_device_uptime_state,
),
@@ -225,7 +225,7 @@ async def async_attach_trigger( # noqa: C901
elif (
new_state.domain == "sensor"
and new_state.attributes.get(ATTR_DEVICE_CLASS)
== sensor.SensorDeviceClass.TIMESTAMP
in (sensor.SensorDeviceClass.TIMESTAMP, sensor.SensorDeviceClass.UPTIME)
and new_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
):
trigger_dt = dt_util.parse_datetime(new_state.state)
+29 -3
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Mapping
from collections.abc import Callable, Mapping
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
@@ -32,6 +32,7 @@ from homeassistant.helpers.typing import UNDEFINED, ConfigType, StateType, Undef
from homeassistant.util import dt as dt_util
from homeassistant.util.enum import try_parse_enum
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.variance import ignore_variance
from .const import ( # noqa: F401
AMBIGUOUS_UNITS,
@@ -63,6 +64,8 @@ ENTITY_ID_FORMAT: Final = DOMAIN + ".{}"
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
SCAN_INTERVAL: Final = timedelta(seconds=30)
UPTIME_DEFAULT_TOLERANCE_SECONDS: Final = 60
UPTIME_MIN_TOLERANCE_SECONDS: Final = 5
__all__ = [
"ATTR_LAST_RESET",
@@ -180,6 +183,9 @@ TEMPERATURE_UNITS = {UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT}
class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Base class for sensor entities."""
# Allow per-entity override of drift tolerance
_attr_uptime_drift_tolerance: int = UPTIME_DEFAULT_TOLERANCE_SECONDS
_entity_component_unrecorded_attributes = frozenset({ATTR_OPTIONS})
entity_description: SensorEntityDescription
@@ -201,6 +207,19 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
_sensor_option_display_precision: int | None = None
_sensor_option_unit_of_measurement: str | None | UndefinedType = UNDEFINED
_invalid_suggested_unit_of_measurement_reported = False
_get_uptime: Callable[[datetime], datetime] | None = None
def _normalize_uptime(self, current_uptime: datetime) -> datetime:
"""Normalize uptime to suppress small drift between updates."""
if self._get_uptime is None:
drift_tolerance = max(
self._attr_uptime_drift_tolerance, UPTIME_MIN_TOLERANCE_SECONDS
)
self._get_uptime = ignore_variance(
func=lambda value: value,
ignored_variance=timedelta(seconds=drift_tolerance),
)
return self._get_uptime(current_uptime)
@callback
def add_to_platform_start(
@@ -610,10 +629,14 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
# Checks below only apply if there is a value
if value is None:
if device_class is SensorDeviceClass.UPTIME:
# Reset baseline so the first uptime after unavailable is not
# compared against a stale value.
self._get_uptime = None
return None
# Received a datetime
if device_class is SensorDeviceClass.TIMESTAMP:
if device_class in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME):
try:
# We cast the value, to avoid using isinstance, but satisfy
# typechecking. The errors are guarded in this try.
@@ -627,10 +650,13 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if value.tzinfo != UTC:
value = value.astimezone(UTC)
if device_class is SensorDeviceClass.UPTIME:
value = self._normalize_uptime(value)
return value.isoformat(timespec="seconds")
except (AttributeError, OverflowError, TypeError) as err:
raise ValueError(
f"Invalid datetime: {self.entity_id} has timestamp device class "
f"Invalid datetime: {self.entity_id} has {device_class.value} device class "
f"but provides state {value}:{type(value)} resulting in '{err}'"
) from err
+16
View File
@@ -117,6 +117,20 @@ class SensorDeviceClass(StrEnum):
ISO8601 format: https://en.wikipedia.org/wiki/ISO_8601
"""
UPTIME = "uptime"
"""Uptime.
Represents the point in time when a device or service last restarted.
Small drift between updates is automatically suppressed in
`SensorEntity.state` to avoid unnecessary state changes caused by clock
jitter.
Unit of measurement: `None`
ISO8601 format: https://en.wikipedia.org/wiki/ISO_8601
"""
# Numerical device classes, these should be aligned with NumberDeviceClass
ABSOLUTE_HUMIDITY = "absolute_humidity"
"""Absolute humidity.
@@ -516,6 +530,7 @@ NON_NUMERIC_DEVICE_CLASSES = {
SensorDeviceClass.DATE,
SensorDeviceClass.ENUM,
SensorDeviceClass.TIMESTAMP,
SensorDeviceClass.UPTIME,
}
DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(SensorDeviceClass))
@@ -816,6 +831,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = {
SensorDeviceClass.TEMPERATURE: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.TEMPERATURE_DELTA: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.TIMESTAMP: set(),
SensorDeviceClass.UPTIME: set(),
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.VOLTAGE: {SensorStateClass.MEASUREMENT},
+1 -1
View File
@@ -18,7 +18,7 @@ def async_parse_date_datetime(
value: str, entity_id: str, device_class: SensorDeviceClass | str | None
) -> datetime | date | None:
"""Parse datetime string to a data or datetime."""
if device_class == SensorDeviceClass.TIMESTAMP:
if device_class in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME):
if (parsed_timestamp := dt_util.parse_datetime(value)) is None:
_LOGGER.warning("%s rendered invalid timestamp: %s", entity_id, value)
return None
@@ -163,6 +163,9 @@
"timestamp": {
"default": "mdi:clock"
},
"uptime": {
"default": "mdi:clock-start"
},
"volatile_organic_compounds": {
"default": "mdi:molecule"
},
@@ -297,6 +297,9 @@
"timestamp": {
"name": "Timestamp"
},
"uptime": {
"name": "Uptime"
},
"volatile_organic_compounds": {
"name": "Volatile organic compounds"
},
+2 -2
View File
@@ -1476,7 +1476,7 @@ def time(
after = datetime.strptime(after_entity.state, "%H:%M:%S").time()
elif (
after_entity.attributes.get(ATTR_DEVICE_CLASS)
== SensorDeviceClass.TIMESTAMP
in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME)
) and after_entity.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
@@ -1506,7 +1506,7 @@ def time(
return False
elif (
before_entity.attributes.get(ATTR_DEVICE_CLASS)
== SensorDeviceClass.TIMESTAMP
in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME)
) and before_entity.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
@@ -403,12 +403,13 @@ class ManualTriggerSensorEntity(ManualTriggerEntity, SensorEntity):
def _set_native_value_with_possible_timestamp(self, value: Any) -> None:
"""Set native value with possible timestamp.
If self.device_class is `date` or `timestamp`,
If self.device_class is `date`, `timestamp`, or `uptime`,
it will try to parse the value to a date/datetime object.
"""
if self.device_class not in (
SensorDeviceClass.DATE,
SensorDeviceClass.TIMESTAMP,
SensorDeviceClass.UPTIME,
):
self._attr_native_value = value
elif value is not None:
@@ -24,7 +24,7 @@
'object_id_base': 'Connection uptime',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Connection uptime',
'platform': 'fritz',
@@ -39,7 +39,7 @@
# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_connection_uptime-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'device_class': 'uptime',
'friendly_name': 'Mock Title Connection uptime',
}),
'context': <ANY>,
@@ -349,7 +349,7 @@
'object_id_base': 'Last restart',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Last restart',
'platform': 'fritz',
@@ -364,7 +364,7 @@
# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_last_restart-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'device_class': 'uptime',
'friendly_name': 'Mock Title Last restart',
}),
'context': <ANY>,
@@ -882,7 +882,7 @@
'object_id_base': 'Connection uptime',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Connection uptime',
'platform': 'fritz',
@@ -897,7 +897,7 @@
# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_connection_uptime-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'device_class': 'uptime',
'friendly_name': 'Mock Title Connection uptime',
}),
'context': <ANY>,
@@ -1207,7 +1207,7 @@
'object_id_base': 'Last restart',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Last restart',
'platform': 'fritz',
@@ -1222,7 +1222,7 @@
# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_last_restart-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'device_class': 'uptime',
'friendly_name': 'Mock Title Last restart',
}),
'context': <ANY>,
@@ -1740,7 +1740,7 @@
'object_id_base': 'Connection uptime',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Connection uptime',
'platform': 'fritz',
@@ -1755,7 +1755,7 @@
# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_connection_uptime-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'device_class': 'uptime',
'friendly_name': 'Mock Title Connection uptime',
}),
'context': <ANY>,
@@ -2065,7 +2065,7 @@
'object_id_base': 'Last restart',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Last restart',
'platform': 'fritz',
@@ -2080,7 +2080,7 @@
# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_last_restart-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'device_class': 'uptime',
'friendly_name': 'Mock Title Last restart',
}),
'context': <ANY>,
@@ -2598,7 +2598,7 @@
'object_id_base': 'Connection uptime',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Connection uptime',
'platform': 'fritz',
@@ -2613,7 +2613,7 @@
# name: test_sensor_setup[sensor.mock_title_connection_uptime-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'device_class': 'uptime',
'friendly_name': 'Mock Title Connection uptime',
}),
'context': <ANY>,
@@ -2981,7 +2981,7 @@
'object_id_base': 'Last restart',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Last restart',
'platform': 'fritz',
@@ -2996,7 +2996,7 @@
# name: test_sensor_setup[sensor.mock_title_last_restart-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'device_class': 'uptime',
'friendly_name': 'Mock Title Last restart',
}),
'context': <ANY>,
+4 -4
View File
@@ -11,7 +11,7 @@ import pytest
from requests.exceptions import RequestException
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.fritz.const import DOMAIN, SCAN_INTERVAL, UPTIME_DEVIATION
from homeassistant.components.fritz.const import DOMAIN, SCAN_INTERVAL
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
@@ -95,13 +95,13 @@ async def test_sensor_uptime_spike(
assert (state := hass.states.get(entity_id))
assert state.state == "2026-01-16T06:00:21+00:00"
# Simulate uptime spike by setting uptime to a value between
# the previous one and a delta smaller than UPTIME_DEVIATION
# Simulate uptime spike by setting uptime to a value that shifts
# the resulting timestamp only by 1 second.
base_uptime = MOCK_FB_SERVICES["DeviceInfo1"]["GetInfo"]["NewUpTime"]
update_uptime = {
"DeviceInfo1": {
"GetInfo": {
"NewUpTime": base_uptime + SCAN_INTERVAL - UPTIME_DEVIATION + 1,
"NewUpTime": base_uptime + SCAN_INTERVAL + 1,
},
},
}
@@ -521,11 +521,16 @@ async def test_untrack_time_change(hass: HomeAssistant) -> None:
@pytest.mark.parametrize(
("at_sensor"), ["sensor.next_alarm", "{{ 'sensor.next_alarm' }}"]
)
@pytest.mark.parametrize(
"device_class",
[SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME],
)
async def test_if_fires_using_at_sensor(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
service_calls: list[ServiceCall],
at_sensor: str,
device_class: SensorDeviceClass,
) -> None:
"""Test for firing at sensor time."""
now = dt_util.now()
@@ -535,7 +540,7 @@ async def test_if_fires_using_at_sensor(
hass.states.async_set(
"sensor.next_alarm",
trigger_dt.isoformat(),
{ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP},
{ATTR_DEVICE_CLASS: device_class},
)
time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1)
@@ -572,7 +577,7 @@ async def test_if_fires_using_at_sensor(
hass.states.async_set(
"sensor.next_alarm",
trigger_dt.isoformat(),
{ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP},
{ATTR_DEVICE_CLASS: device_class},
)
await hass.async_block_till_done()
@@ -589,13 +594,13 @@ async def test_if_fires_using_at_sensor(
hass.states.async_set(
"sensor.next_alarm",
trigger_dt.isoformat(),
{ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP},
{ATTR_DEVICE_CLASS: device_class},
)
await hass.async_block_till_done()
hass.states.async_set(
"sensor.next_alarm",
broken,
{ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP},
{ATTR_DEVICE_CLASS: device_class},
)
await hass.async_block_till_done()
@@ -609,7 +614,7 @@ async def test_if_fires_using_at_sensor(
hass.states.async_set(
"sensor.next_alarm",
trigger_dt.isoformat(),
{ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP},
{ATTR_DEVICE_CLASS: device_class},
)
await hass.async_block_till_done()
hass.states.async_set(
@@ -633,12 +638,17 @@ async def test_if_fires_using_at_sensor(
({"minutes": 5}, timedelta(minutes=5)),
],
)
@pytest.mark.parametrize(
"device_class",
[SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME],
)
async def test_if_fires_using_at_sensor_with_offset(
hass: HomeAssistant,
service_calls: list[ServiceCall],
freezer: FrozenDateTimeFactory,
offset: str | dict[str, int],
delta: timedelta,
device_class: SensorDeviceClass,
) -> None:
"""Test for firing at sensor time."""
now = dt_util.now()
@@ -649,7 +659,7 @@ async def test_if_fires_using_at_sensor_with_offset(
hass.states.async_set(
"sensor.next_alarm",
start_dt.isoformat(),
{ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP},
{ATTR_DEVICE_CLASS: device_class},
)
time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1)
@@ -693,7 +703,7 @@ async def test_if_fires_using_at_sensor_with_offset(
hass.states.async_set(
"sensor.next_alarm",
start_dt.isoformat(),
{ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP},
{ATTR_DEVICE_CLASS: device_class},
)
await hass.async_block_till_done()
@@ -2216,6 +2216,7 @@
'options': list([
'date',
'timestamp',
'uptime',
'absolute_humidity',
'apparent_power',
'aqi',
+1
View File
@@ -95,6 +95,7 @@ UNITS_OF_MEASUREMENT = {
SensorDeviceClass.TEMPERATURE: UnitOfTemperature.CELSIUS,
SensorDeviceClass.TEMPERATURE_DELTA: UnitOfTemperature.CELSIUS,
SensorDeviceClass.TIMESTAMP: None,
SensorDeviceClass.UPTIME: None,
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: CONCENTRATION_PARTS_PER_MILLION,
SensorDeviceClass.VOLTAGE: UnitOfElectricPotential.VOLT,
@@ -101,6 +101,7 @@ async def test_get_conditions(
SensorDeviceClass.DATE,
SensorDeviceClass.ENUM,
SensorDeviceClass.TIMESTAMP,
SensorDeviceClass.UPTIME,
}
expected_conditions = [
{
@@ -202,6 +203,7 @@ async def test_get_conditions_no_state(
SensorDeviceClass.DATE, # No condition
SensorDeviceClass.ENUM, # No condition
SensorDeviceClass.TIMESTAMP, # No condition
SensorDeviceClass.UPTIME, # No condition
SensorDeviceClass.AQI, # No unit of measurement
SensorDeviceClass.PH, # No unit of measurement
SensorDeviceClass.MONETARY, # No unit of measurement
@@ -103,6 +103,7 @@ async def test_get_triggers(
SensorDeviceClass.DATE,
SensorDeviceClass.ENUM,
SensorDeviceClass.TIMESTAMP,
SensorDeviceClass.UPTIME,
}
expected_triggers = [
{
+7 -2
View File
@@ -6,10 +6,15 @@ from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.components.sensor.helpers import async_parse_date_datetime
def test_async_parse_datetime(caplog: pytest.LogCaptureFixture) -> None:
@pytest.mark.parametrize(
"device_class",
[SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME],
)
def test_async_parse_datetime(
caplog: pytest.LogCaptureFixture, device_class: SensorDeviceClass
) -> None:
"""Test async_parse_date_datetime."""
entity_id = "sensor.timestamp"
device_class = SensorDeviceClass.TIMESTAMP
assert (
async_parse_date_datetime(
"2021-12-12 12:12Z", entity_id, device_class
+44 -1
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from collections.abc import Generator
from datetime import UTC, date, datetime
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
import math
from typing import Any
@@ -23,6 +23,7 @@ from homeassistant.components.sensor import (
DEVICE_CLASS_UNITS,
DOMAIN,
NON_NUMERIC_DEVICE_CLASSES,
UPTIME_DEFAULT_TOLERANCE_SECONDS,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
@@ -283,6 +284,44 @@ async def test_datetime_conversion(
assert state.state == test_timestamp.isoformat()
@pytest.mark.parametrize("drift_tolerance", [UPTIME_DEFAULT_TOLERANCE_SECONDS, 10])
async def test_uptime_device_class_auto_normalizes_drift(
hass: HomeAssistant, drift_tolerance
) -> None:
"""Test uptime device class suppresses small drift automatically."""
initial_uptime = datetime(2026, 2, 14, 9, 30, tzinfo=UTC)
entity = MockSensor(
name="Test",
native_value=initial_uptime,
device_class=SensorDeviceClass.UPTIME,
)
entity._attr_uptime_drift_tolerance = drift_tolerance
setup_test_component_platform(hass, sensor.DOMAIN, [entity])
assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}})
await hass.async_block_till_done()
assert (state := hass.states.get(entity.entity_id))
assert state.state == initial_uptime.isoformat(timespec="seconds")
entity._values["native_value"] = initial_uptime + timedelta(
seconds=drift_tolerance - 1
)
entity.async_write_ha_state()
await hass.async_block_till_done()
assert (state := hass.states.get(entity.entity_id))
assert state.state == initial_uptime.isoformat(timespec="seconds")
updated_uptime = initial_uptime + timedelta(seconds=drift_tolerance + 1)
entity._values["native_value"] = updated_uptime
entity.async_write_ha_state()
await hass.async_block_till_done()
assert (state := hass.states.get(entity.entity_id))
assert state.state == updated_uptime.isoformat(timespec="seconds")
async def test_a_sensor_with_a_non_numeric_device_class(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
@@ -2200,6 +2239,7 @@ async def test_invalid_enumeration_entity_without_device_class(
SensorDeviceClass.DATE,
SensorDeviceClass.ENUM,
SensorDeviceClass.TIMESTAMP,
SensorDeviceClass.UPTIME,
],
)
async def test_non_numeric_device_class_with_unit_of_measurement(
@@ -2554,6 +2594,7 @@ async def test_device_classes_with_invalid_state_class(
(SensorDeviceClass.ENUM, None, None, None, False),
(SensorDeviceClass.DATE, None, None, None, False),
(SensorDeviceClass.TIMESTAMP, None, None, None, False),
(SensorDeviceClass.UPTIME, None, None, None, False),
("custom", None, None, None, False),
(SensorDeviceClass.POWER, None, "V", None, True),
(None, SensorStateClass.MEASUREMENT, None, None, True),
@@ -3097,6 +3138,7 @@ def test_device_class_units_are_complete() -> None:
SensorDeviceClass.ENUM,
SensorDeviceClass.MONETARY,
SensorDeviceClass.TIMESTAMP,
SensorDeviceClass.UPTIME,
}
unit_device_classes = {
device_class.value for device_class in SensorDeviceClass
@@ -3126,6 +3168,7 @@ def test_device_class_converters_are_complete() -> None:
SensorDeviceClass.SIGNAL_STRENGTH,
SensorDeviceClass.SOUND_PRESSURE,
SensorDeviceClass.TIMESTAMP,
SensorDeviceClass.UPTIME,
SensorDeviceClass.WIND_DIRECTION,
}
converter_device_classes = {
+6
View File
@@ -1146,6 +1146,11 @@ async def test_time_using_sensor(hass: HomeAssistant) -> None:
"2020-06-01 01:00:00.000000+00:00", # 6 pm local time
{ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP},
)
hass.states.async_set(
"sensor.uptime_am",
"2021-06-03 13:00:00.000000+00:00", # 6 am local time
{ATTR_DEVICE_CLASS: SensorDeviceClass.UPTIME},
)
hass.states.async_set(
"sensor.no_device_class",
"2020-06-01 01:00:00.000000+00:00",
@@ -1168,6 +1173,7 @@ async def test_time_using_sensor(hass: HomeAssistant) -> None:
return_value=dt_util.now().replace(hour=9),
):
assert condition.time(hass, after="sensor.am", before="sensor.pm")
assert condition.time(hass, after="sensor.uptime_am", before="sensor.pm")
assert not condition.time(hass, after="sensor.pm", before="sensor.am")
with patch(
@@ -296,14 +296,20 @@ async def test_trigger_template_complex(hass: HomeAssistant) -> None:
assert entity.some_other_key == {"test_key": "test_data"}
@pytest.mark.parametrize(
"device_class",
[SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME],
)
async def test_manual_trigger_sensor_entity_with_date(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
device_class: SensorDeviceClass,
) -> None:
"""Test manual trigger template entity when availability template isn't used."""
config = {
CONF_NAME: template.Template("test_entity", hass),
CONF_STATE: template.Template("{{ as_datetime(value) }}", hass),
CONF_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP,
CONF_DEVICE_CLASS: device_class,
}
class TestEntity(ManualTriggerSensorEntity):
@@ -328,4 +334,4 @@ async def test_manual_trigger_sensor_entity_with_date(
"2025-01-01T00:00:00+00:00", entity.entity_id, entity.device_class
)
assert entity.state == "2025-01-01T00:00:00+00:00"
assert entity.device_class == SensorDeviceClass.TIMESTAMP
assert entity.device_class == device_class