mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 17:04:04 -04:00
Publish the Sofar waiting time as the moment it ends (#180309)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user