Publish the Sofar waiting time as the moment it ends (#180309)

This commit is contained in:
darkrain-nl
2026-08-26 13:00:13 -04:00
committed by GitHub
parent ee299b7c4e
commit 5bf9663333
4 changed files with 176 additions and 29 deletions
+59 -11
View File
@@ -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(
+2 -2
View File
@@ -700,8 +700,8 @@
"voltage_phase_l2n": {
"name": "Voltage phase L2N"
},
"waiting_time": {
"name": "Waiting time"
"waiting_ends": {
"name": "Waiting ends"
}
}
},
@@ -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': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'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': <SensorDeviceClass.DURATION: 'duration'>,
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'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': <UnitOfTime.SECONDS: 's'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'duration',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'HYDxxKTL-3P Waiting time',
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTime.SECONDS: 's'>,
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'timestamp',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'HYDxxKTL-3P Waiting ends',
}),
'context': <ANY>,
'entity_id': 'sensor.hydxxktl_3p_waiting_time',
'entity_id': 'sensor.hydxxktl_3p_waiting_ends',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
'state': 'unknown',
})
# ---
+103
View File
@@ -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