diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index 032efb3f4ae9..5050907c1d87 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -66,8 +66,6 @@ SWITCH_TYPE_WIFINETWORK = "WiFiNetwork" BUTTON_TYPE_WOL = "WakeOnLan" -UPTIME_DEVIATION = 5 - FRITZ_EXCEPTIONS = ( ConnectionError, FritzActionError, diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 41fa3fca056d..4bd54a751d50 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -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, ), diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index 27c63742f7b8..0fc5618c122f 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -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) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 3148b0d13c2a..73ea6db50365 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -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 diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 26fde2405969..85dd700e5ef1 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -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}, diff --git a/homeassistant/components/sensor/helpers.py b/homeassistant/components/sensor/helpers.py index 12a5dcefdf8d..c404c697da00 100644 --- a/homeassistant/components/sensor/helpers.py +++ b/homeassistant/components/sensor/helpers.py @@ -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 diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json index 59d57da28034..966e19439e38 100644 --- a/homeassistant/components/sensor/icons.json +++ b/homeassistant/components/sensor/icons.json @@ -163,6 +163,9 @@ "timestamp": { "default": "mdi:clock" }, + "uptime": { + "default": "mdi:clock-start" + }, "volatile_organic_compounds": { "default": "mdi:molecule" }, diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 33b56f1b0f1d..e51c139e8dea 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -297,6 +297,9 @@ "timestamp": { "name": "Timestamp" }, + "uptime": { + "name": "Uptime" + }, "volatile_organic_compounds": { "name": "Volatile organic compounds" }, diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 6d53f674188b..878c136c26c9 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -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, diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py index dc8f52763c32..87d03c011527 100644 --- a/homeassistant/helpers/trigger_template_entity.py +++ b/homeassistant/helpers/trigger_template_entity.py @@ -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: diff --git a/tests/components/fritz/snapshots/test_sensor.ambr b/tests/components/fritz/snapshots/test_sensor.ambr index d820dda43ee9..48ed12dec8fe 100644 --- a/tests/components/fritz/snapshots/test_sensor.ambr +++ b/tests/components/fritz/snapshots/test_sensor.ambr @@ -24,7 +24,7 @@ 'object_id_base': 'Connection uptime', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , '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': , @@ -349,7 +349,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , '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': , @@ -882,7 +882,7 @@ 'object_id_base': 'Connection uptime', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , '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': , @@ -1207,7 +1207,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , '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': , @@ -1740,7 +1740,7 @@ 'object_id_base': 'Connection uptime', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , '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': , @@ -2065,7 +2065,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , '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': , @@ -2598,7 +2598,7 @@ 'object_id_base': 'Connection uptime', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , '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': , @@ -2981,7 +2981,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , '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': , diff --git a/tests/components/fritz/test_sensor.py b/tests/components/fritz/test_sensor.py index d00327994d63..94cb74f63a0e 100644 --- a/tests/components/fritz/test_sensor.py +++ b/tests/components/fritz/test_sensor.py @@ -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, }, }, } diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index dc9fb1d34c27..79f07c5285d3 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -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() diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index cfc5ec81064d..49299a9537a4 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -2216,6 +2216,7 @@ 'options': list([ 'date', 'timestamp', + 'uptime', 'absolute_humidity', 'apparent_power', 'aqi', diff --git a/tests/components/sensor/common.py b/tests/components/sensor/common.py index 4dedababad1c..3721e7be2206 100644 --- a/tests/components/sensor/common.py +++ b/tests/components/sensor/common.py @@ -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, diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index 67f07e3293a3..59aeb8cc8d53 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -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 diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index 8b407ac5576c..d796dd1158a6 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -103,6 +103,7 @@ async def test_get_triggers( SensorDeviceClass.DATE, SensorDeviceClass.ENUM, SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.UPTIME, } expected_triggers = [ { diff --git a/tests/components/sensor/test_helpers.py b/tests/components/sensor/test_helpers.py index e197579fa667..2fc89f258557 100644 --- a/tests/components/sensor/test_helpers.py +++ b/tests/components/sensor/test_helpers.py @@ -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 diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index e9ae2ba4f752..3a03a608ce88 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -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 = { diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index be995e352487..cec1e5976e28 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -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( diff --git a/tests/helpers/test_trigger_template_entity.py b/tests/helpers/test_trigger_template_entity.py index 08f6c7de8197..0bbe521eef00 100644 --- a/tests/helpers/test_trigger_template_entity.py +++ b/tests/helpers/test_trigger_template_entity.py @@ -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