mirror of
https://github.com/home-assistant/core.git
synced 2026-09-24 15:31:52 -05:00
Add uptime device class to the sensor platform (#164266)
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
committed by
abmantis
co-authored by
Copilot
parent
d8a389afe0
commit
ed99a9c7d9
@@ -66,8 +66,6 @@ SWITCH_TYPE_WIFINETWORK = "WiFiNetwork"
|
||||
|
||||
BUTTON_TYPE_WOL = "WakeOnLan"
|
||||
|
||||
UPTIME_DEVIATION = 5
|
||||
|
||||
FRITZ_EXCEPTIONS = (
|
||||
ConnectionError,
|
||||
FritzActionError,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user