diff --git a/homeassistant/components/sofar/__init__.py b/homeassistant/components/sofar/__init__.py index 863983c3b260..f9cdc509fdfc 100644 --- a/homeassistant/components/sofar/__init__.py +++ b/homeassistant/components/sofar/__init__.py @@ -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( diff --git a/homeassistant/components/sofar/sensor.py b/homeassistant/components/sofar/sensor.py index 9a4fdcb4304b..ee1f4c985787 100644 --- a/homeassistant/components/sofar/sensor.py +++ b/homeassistant/components/sofar/sensor.py @@ -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", diff --git a/homeassistant/components/sofar/strings.json b/homeassistant/components/sofar/strings.json index ca13c38cbdc2..36a439963c73 100644 --- a/homeassistant/components/sofar/strings.json +++ b/homeassistant/components/sofar/strings.json @@ -470,9 +470,6 @@ }, "voltage_phase_l2n": { "name": "Voltage phase L2N" - }, - "waiting_ends": { - "name": "Waiting ends" } } }, diff --git a/tests/components/sofar/snapshots/test_sensor.ambr b/tests/components/sofar/snapshots/test_sensor.ambr index 372a5bde4ec1..a7fd4a5971c0 100644 --- a/tests/components/sofar/snapshots/test_sensor.ambr +++ b/tests/components/sofar/snapshots/test_sensor.ambr @@ -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': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.hydxxktl_3p_waiting_ends', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Waiting ends', - 'options': dict({ - }), - 'original_device_class': , - '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({ - : 'timestamp', - : 'HYDxxKTL-3P Waiting ends', - }), - 'context': , - 'entity_id': 'sensor.hydxxktl_3p_waiting_ends', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[sensor.pv_string_1_current-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/sofar/test_init.py b/tests/components/sofar/test_init.py index 1e373c716f4f..5b985739c8f5 100644 --- a/tests/components/sofar/test_init.py +++ b/tests/components/sofar/test_init.py @@ -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: diff --git a/tests/components/sofar/test_sensor.py b/tests/components/sofar/test_sensor.py index 359320a81763..72bb690b1bc6 100644 --- a/tests/components/sofar/test_sensor.py +++ b/tests/components/sofar/test_sensor.py @@ -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