mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 17:04:04 -04:00
Remove the Sofar waiting-ends sensor (#180899)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user