Remove the Sofar waiting-ends sensor (#180899)

This commit is contained in:
darkrain-nl
2026-08-31 11:43:47 +00:00
committed by Franck Nijhof
parent a21ae6d818
commit 5f2303b901
6 changed files with 44 additions and 249 deletions
@@ -27,6 +27,16 @@ from .sensor import SENSOR_DESCRIPTIONS
PLATFORMS: list[Platform] = [Platform.SENSOR]
def _async_remove_stale_waiting_time(hass: HomeAssistant, serial: str) -> None:
"""Drop the removed waiting-time entity so it doesn't linger unavailable."""
registry = er.async_get(hass)
entity_id = registry.async_get_entity_id(
SENSOR_DOMAIN, DOMAIN, f"{serial}_waiting_time"
)
if entity_id is not None:
registry.async_remove(entity_id)
def _async_seed_high_water_marks(
hass: HomeAssistant, serial: str, device: SofarInverter
) -> None:
@@ -55,6 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SofarConfigEntry) -> boo
"""Set up Sofar Inverter Modbus from a config entry."""
serial = entry.unique_id
assert serial is not None
_async_remove_stale_waiting_time(hass, serial)
inverter_type, model = identify(serial)
if not inverter_type:
raise ConfigEntryError(
+3 -59
View File
@@ -1,13 +1,12 @@
"""Support for Sofar sensors."""
from collections.abc import Callable, Mapping
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from datetime import date
from enum import IntEnum
from typing import cast, override
from sofar_modbus.modern.device import SofarInverter
from sofar_modbus.modern.enums import SystemState
from homeassistant.components.sensor import (
RestoreSensor,
@@ -31,27 +30,12 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from homeassistant.util.variance import ignore_variance
# 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 .coordinator import SofarConfigEntry
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,
@@ -114,8 +98,6 @@ 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,
@@ -140,37 +122,6 @@ 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."""
# A positive register alone doesn't mean the countdown is active.
_ACTIVE_STATES = (SystemState.WAITING, SystemState.CHECKING)
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
or component.system_state not in self._ACTIVE_STATES
):
# 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."""
@@ -381,13 +332,6 @@ SENSOR_DESCRIPTIONS: tuple[SofarSensorDescription, ...] = (
"self_charging",
],
),
SofarSensorDescription(
key="waiting_time",
component="state",
translation_key="waiting_ends",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
SofarSensorDescription(
key="inverter_temperature_1",
component="state",
@@ -470,9 +470,6 @@
},
"voltage_phase_l2n": {
"name": "Voltage phase L2N"
},
"waiting_ends": {
"name": "Waiting ends"
}
}
},
@@ -7461,57 +7461,6 @@
'state': '0.0',
})
# ---
# name: test_all_entities[sensor.hydxxktl_3p_waiting_ends-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.hydxxktl_3p_waiting_ends',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Waiting ends',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Waiting ends',
'platform': 'sofar',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'waiting_ends',
'unique_id': 'SP1XXES100XX_waiting_time',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[sensor.hydxxktl_3p_waiting_ends-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'timestamp',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'HYDxxKTL-3P Waiting ends',
}),
'context': <ANY>,
'entity_id': 'sensor.hydxxktl_3p_waiting_ends',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_all_entities[sensor.pv_string_1_current-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
+27
View File
@@ -81,6 +81,33 @@ async def test_setup_and_unload_entry(
assert entry.state is ConfigEntryState.NOT_LOADED
async def test_setup_removes_the_stale_waiting_time_entity(
hass: HomeAssistant,
mock_connection: MockModbusConnection,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test an upgrade drops the removed waiting-time entity too."""
mock_config_entry.add_to_hass(hass)
entry = entity_registry.async_get_or_create(
SENSOR_DOMAIN,
DOMAIN,
f"{MOCK_SERIAL}_waiting_time",
config_entry=mock_config_entry,
)
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)
assert entity_registry.async_get(entry.entity_id) is None
async def test_setup_entry_unrecognized_inverter_raises_setup_error(
hass: HomeAssistant,
) -> None:
+3 -136
View File
@@ -20,10 +20,8 @@ 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,
@@ -98,13 +96,13 @@ async def test_sensor_entities_created_and_state(
@pytest.mark.parametrize(
("serial", "model", "seed", "created", "enabled"),
[
pytest.param(MOCK_SERIAL, MOCK_MODEL, seed_pv_inverter, 73, 23, id="pv"),
pytest.param(MOCK_SERIAL, MOCK_MODEL, seed_pv_inverter, 72, 22, id="pv"),
pytest.param(
MOCK_HYBRID_SERIAL,
MOCK_HYBRID_MODEL,
seed_hybrid_inverter,
139,
46,
138,
45,
id="hybrid",
),
],
@@ -377,134 +375,3 @@ 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[0x0404] = 0 # Waiting
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_ignores_a_stale_register_while_grid_connected(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_connection: MockModbusConnection,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test a positive register is ignored once the inverter is connected."""
mock_config_entry.add_to_hass(hass)
unit = mock_connection.for_unit(1)
unit.holding[0x0404] = 2 # Grid connected
unit.holding[0x0417] = 60 # Left over from the last startup wait
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
assert hass.states.get(entity_id).state == STATE_UNKNOWN
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[0x0404] = 0 # Waiting
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