diff --git a/homeassistant/components/sofar/sensor.py b/homeassistant/components/sofar/sensor.py index b3effe0f2859..5dbc238f4072 100644 --- a/homeassistant/components/sofar/sensor.py +++ b/homeassistant/components/sofar/sensor.py @@ -1,7 +1,8 @@ """Support for Sofar sensors.""" +from collections.abc import Callable from dataclasses import dataclass -from datetime import date +from datetime import date, datetime, timedelta from enum import IntEnum from typing import cast, override @@ -27,12 +28,27 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util +from homeassistant.util.variance import ignore_variance -from .coordinator import SofarConfigEntry +# Aliased: a module-level SCAN_INTERVAL would set the platform's poll. +from .const import SCAN_INTERVAL as _POLL_INTERVAL +from .coordinator import SofarConfigEntry, SofarRuntimeData from .entity import SofarEntity, SofarEntityDescription PARALLEL_UPDATES = 0 +# Two polls of slack, so jitter does not republish a steady countdown. +_COUNTDOWN_VARIANCE = timedelta(seconds=_POLL_INTERVAL * 2) + + +def _deadline_filter() -> Callable[[int], datetime]: + """Turn remaining seconds into a deadline, holding it steady.""" + return ignore_variance( + lambda seconds: dt_util.utcnow() + timedelta(seconds=seconds), + _COUNTDOWN_VARIANCE, + ) + async def async_setup_entry( hass: HomeAssistant, @@ -44,18 +60,27 @@ async def async_setup_entry( served = runtime_data.served_components entities: list[SensorEntity] = [ - ( - SofarTotalSensor - if description.state_class - in (SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING) - else SofarSensor - )(runtime_data, description) + _sensor_class(description)(runtime_data, description) for description in SENSOR_DESCRIPTIONS if description.component in served ] async_add_entities(entities) +def _sensor_class( + description: SofarSensorDescription, +) -> type[SofarSensor | SofarTotalSensor]: + """Pick the entity class a description's semantics ask for.""" + if description.device_class is SensorDeviceClass.TIMESTAMP: + return SofarCountdownSensor + if description.state_class in ( + SensorStateClass.TOTAL, + SensorStateClass.TOTAL_INCREASING, + ): + return SofarTotalSensor + return SofarSensor + + class SofarSensor(SofarEntity, SensorEntity): """Defines a Sofar sensor.""" @@ -72,6 +97,30 @@ class SofarSensor(SofarEntity, SensorEntity): return cast(str | int | float | date | None, value) +class SofarCountdownSensor(SofarSensor): + """Defines a Sofar countdown, published as the moment it runs out.""" + + def __init__( + self, + runtime_data: SofarRuntimeData, + entity_description: SofarSensorDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(runtime_data, entity_description) + self._deadline = _deadline_filter() + + @property + @override + def native_value(self) -> datetime | None: + component = getattr(self.coordinator.device, self.entity_description.component) + seconds = getattr(component, self.entity_description.key) + if not isinstance(seconds, int) or seconds <= 0: + # A restart must not land inside the finished countdown's slack. + self._deadline = _deadline_filter() + return None + return self._deadline(seconds) + + class SofarTotalSensor(SofarEntity, RestoreSensor): """Defines a Sofar cumulative total sensor.""" @@ -168,9 +217,8 @@ SENSOR_DESCRIPTIONS: tuple[SofarSensorDescription, ...] = ( SofarSensorDescription( key="waiting_time", component="state", - translation_key="waiting_time", - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.SECONDS, + translation_key="waiting_ends", + device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, ), SofarSensorDescription( diff --git a/homeassistant/components/sofar/strings.json b/homeassistant/components/sofar/strings.json index a685637117b6..e5d4d2b404f5 100644 --- a/homeassistant/components/sofar/strings.json +++ b/homeassistant/components/sofar/strings.json @@ -700,8 +700,8 @@ "voltage_phase_l2n": { "name": "Voltage phase L2N" }, - "waiting_time": { - "name": "Waiting time" + "waiting_ends": { + "name": "Waiting ends" } } }, diff --git a/tests/components/sofar/snapshots/test_sensor.ambr b/tests/components/sofar/snapshots/test_sensor.ambr index e2b57dd19590..4ae63c9a8d16 100644 --- a/tests/components/sofar/snapshots/test_sensor.ambr +++ b/tests/components/sofar/snapshots/test_sensor.ambr @@ -10107,7 +10107,7 @@ 'state': '0.0', }) # --- -# name: test_all_entities[sensor.hydxxktl_3p_waiting_time-entry] +# name: test_all_entities[sensor.hydxxktl_3p_waiting_ends-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -10121,7 +10121,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.hydxxktl_3p_waiting_time', + 'entity_id': 'sensor.hydxxktl_3p_waiting_ends', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10129,36 +10129,32 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Waiting time', + 'object_id_base': 'Waiting ends', 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Waiting time', + 'original_name': 'Waiting ends', 'platform': 'sofar', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'waiting_time', + 'translation_key': 'waiting_ends', 'unique_id': 'SP1XXES100XX_waiting_time', - 'unit_of_measurement': , + 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.hydxxktl_3p_waiting_time-state] +# name: test_all_entities[sensor.hydxxktl_3p_waiting_ends-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'duration', - : 'HYDxxKTL-3P Waiting time', - : , + : 'timestamp', + : 'HYDxxKTL-3P Waiting ends', }), 'context': , - 'entity_id': 'sensor.hydxxktl_3p_waiting_time', + 'entity_id': 'sensor.hydxxktl_3p_waiting_ends', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': 'unknown', }) # --- diff --git a/tests/components/sofar/test_sensor.py b/tests/components/sofar/test_sensor.py index 4235d7be3fa9..104296f0077d 100644 --- a/tests/components/sofar/test_sensor.py +++ b/tests/components/sofar/test_sensor.py @@ -20,8 +20,10 @@ from homeassistant.components.sofar.sensor import ( SofarSensorDescription, SofarTotalSensor, ) +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util from . import ( MOCK_HYBRID_MODEL, @@ -375,3 +377,104 @@ async def test_total_sensor_total_increasing_uses_corrected_value( assert sensor.native_value == 42.0 mock_corrected.assert_called_once_with("load_consumption_total") assert sensor.available + + +async def test_idle_countdown_reports_no_deadline( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, +) -> None: + """Test a countdown at zero reports nothing, not a moment already past.""" + entity_id = entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_waiting_time" + ) + assert entity_id is not None + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + +async def test_countdown_holds_its_deadline_until_it_restarts( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, + mock_connection: MockModbusConnection, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a countdown ticking with the clock keeps one deadline.""" + mock_config_entry.add_to_hass(hass) + unit = mock_connection.for_unit(1) + unit.holding[0x0417] = 300 + + with patch( + "homeassistant.components.sofar.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_id = entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_waiting_time" + ) + assert entity_id is not None + # The exact moment: a wrong sign or unit must not slip through. + deadline = (dt_util.utcnow() + timedelta(seconds=300)).isoformat(timespec="seconds") + assert hass.states.get(entity_id).state == deadline + + # A second of poll jitter must not republish the deadline as a new one. + unit.holding[0x0417] = 296 + freezer.tick(timedelta(seconds=SCAN_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == deadline + + # Restarted, so it really is a different moment now. + unit.holding[0x0417] = 600 + freezer.tick(timedelta(seconds=SCAN_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state != deadline + + +async def test_countdown_restarting_after_idle_gets_a_new_deadline( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, + mock_connection: MockModbusConnection, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a finished countdown's deadline is not reused by the next one.""" + mock_config_entry.add_to_hass(hass) + unit = mock_connection.for_unit(1) + unit.holding[0x0417] = 10 + + with patch( + "homeassistant.components.sofar.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_id = entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_waiting_time" + ) + assert entity_id is not None + finished = hass.states.get(entity_id).state + + unit.holding[0x0417] = 0 + freezer.tick(timedelta(seconds=SCAN_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + # Close enough to the old deadline to fall inside the variance window. + unit.holding[0x0417] = 5 + freezer.tick(timedelta(seconds=SCAN_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + restarted = (dt_util.utcnow() + timedelta(seconds=5)).isoformat(timespec="seconds") + assert hass.states.get(entity_id).state == restarted + assert hass.states.get(entity_id).state != finished