From 855540af45eccf879b4ec79367ed9e518fea91cb Mon Sep 17 00:00:00 2001 From: N1c084 Date: Thu, 8 Oct 2026 21:06:32 +0200 Subject: [PATCH] Re-send sensor states after Home Assistant comes back online Home Assistant does not keep non-retained MQTT states across a restart. On homeassistant/status "online" the add-on only republishes discovery and availability, so sensors reported on a fixed schedule stay unknown until their next report (up to 5 min), and change-only sensors until their next change. on_ha_connected now starts republish_all_states() in the background (it runs under the discovery lock): 10 s and 60 s after HA comes online, every running inverter re-sends the last published value of each non-retained sensor. A value published meanwhile is sent again after ours; a failed send is logged and the pass goes on. Co-Authored-By: Claude Opus 5.5 --- hass-addon-sunsynk-edge/CHANGELOG.md | 6 + src/ha_addon_sunsynk_multi/__main__.py | 12 +- src/ha_addon_sunsynk_multi/a_inverter.py | 52 +++++++ .../ha_addon_sunsynk_multi/test_republish.py | 139 ++++++++++++++++++ 4 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 src/tests/ha_addon_sunsynk_multi/test_republish.py diff --git a/hass-addon-sunsynk-edge/CHANGELOG.md b/hass-addon-sunsynk-edge/CHANGELOG.md index 7aee4a59..ae2591b2 100644 --- a/hass-addon-sunsynk-edge/CHANGELOG.md +++ b/hass-addon-sunsynk-edge/CHANGELOG.md @@ -2,6 +2,12 @@ +## Unreleased + +- Re-send the last published sensor states 10 s and 60 s after Home Assistant comes back online, so + schedule-reported sensors (energy, temperatures) are no longer unknown for up to 5 min after an HA + restart. + ## Release 1.3.0 All items below are changes since add-on **1.2.0**. diff --git a/src/ha_addon_sunsynk_multi/__main__.py b/src/ha_addon_sunsynk_multi/__main__.py index 036ec329..ec3eb370 100644 --- a/src/ha_addon_sunsynk_multi/__main__.py +++ b/src/ha_addon_sunsynk_multi/__main__.py @@ -9,7 +9,7 @@ from sunsynk import VERSION from sunsynk.utils import pretty_table_sensors -from .a_inverter import STATE +from .a_inverter import STATE, republish_all_states from .a_sensor import MQTT, SS_TOPIC from .driver import callback_discovery_info, init_driver from .errors import print_errors @@ -93,10 +93,18 @@ async def main_loop() -> int: ) return 2 + republish_task: asyncio.Task[None] | None = None + async def on_ha_connected() -> None: - """When MQTT is connected, ensure we update MQTT availability states.""" + """When HA comes online: update availability, schedule a state re-send.""" + nonlocal republish_task for ist in STATE: await ist.set_lifecycle(None) + # HA does not keep non-retained states across a restart. Re-send them in + # the background: this runs under the discovery lock and must not wait. + if republish_task is not None and not republish_task.done(): + republish_task.cancel() + republish_task = asyncio.create_task(republish_all_states()) MQTT.on_ha_connected = on_ha_connected diff --git a/src/ha_addon_sunsynk_multi/a_inverter.py b/src/ha_addon_sunsynk_multi/a_inverter.py index 96ef4379..b858cef1 100644 --- a/src/ha_addon_sunsynk_multi/a_inverter.py +++ b/src/ha_addon_sunsynk_multi/a_inverter.py @@ -259,6 +259,39 @@ async def publish_sensors(self, *, states: dict[ASensor, ValType]) -> None: else: await state.publish(value) + async def republish_states(self) -> int: + """Re-send the last published value of every non-retained sensor. + + Home Assistant does not keep non-retained states across a restart, so + sensors reported on a fixed schedule (energy, temperatures) or only on + change would stay unknown until their next report. Retained (RW) sensors + come back from the broker and are skipped. Does nothing unless the + inverter is running. Returns the number of sensors re-sent. + """ + if self.lifecycle != "running": + return 0 + count = 0 + for asen in list(self.ss.values()): + if asen.entity is None or asen.retain: + continue + val = asen.value + if val is None: + continue + try: + await asen.entity.send_state(MQTT, val) + # ASensor.publish() updates _last only after its own send: if one + # was in flight, the older value just went out after it. Send the + # newer value again so it is the last one Home Assistant receives. + # ponytail: one re-check; a second publish landing in that window + # too would need a loop. + if asen.value is not None and asen.value != val: + await asen.entity.send_state(MQTT, asen.value) + except Exception as err: + _LOG.warning("Republish %s failed: %s", asen.name, err) + continue + count += 1 + return count + async def connect(self) -> None: """Connect.""" await self.set_lifecycle("starting") @@ -463,6 +496,25 @@ async def publish_stats(self, period: int) -> None: STATE: list[AInverter] = [] +REPUBLISH_DELAYS = (10, 60) +"""Seconds after Home Assistant comes online to re-send states: the first pass +once HA processed the discovery payload, the second for late subscriptions.""" + + +async def republish_all_states(delays: Iterable[float] = REPUBLISH_DELAYS) -> None: + """Re-send the states of all inverters after Home Assistant came online.""" + start = time.monotonic() + for delay in delays: + await asyncio.sleep(max(0.0, start + delay - time.monotonic())) + for ist in STATE: + count = await ist.republish_states() + _LOG.debug( + "Republished %s states for %s (%ss after HA online)", + count, + ist.opt.ha_prefix, + delay, + ) + def compact_exception_group(err: ExceptionGroup) -> str: """Compact exception group.""" diff --git a/src/tests/ha_addon_sunsynk_multi/test_republish.py b/src/tests/ha_addon_sunsynk_multi/test_republish.py new file mode 100644 index 00000000..ed49b5ef --- /dev/null +++ b/src/tests/ha_addon_sunsynk_multi/test_republish.py @@ -0,0 +1,139 @@ +"""Tests for re-sending states when Home Assistant comes back online.""" + +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, call + +import pytest + +from ha_addon_sunsynk_multi import a_inverter +from ha_addon_sunsynk_multi.a_inverter import AInverter, republish_all_states +from ha_addon_sunsynk_multi.a_sensor import MQTT, ASensor +from ha_addon_sunsynk_multi.sensor_options import SensorOption +from sunsynk.sensors import Sensor +from sunsynk.sunsynk import ValType + +from .conftest import NOSCHEDULE, ist_factory + + +def _asensor( + name: str, value: ValType, *, retain: bool = False, entity: bool = True +) -> ASensor: + """Return an ASensor whose last published value is `value`.""" + ent = None + if entity: + ent = MagicMock() + ent.send_state = AsyncMock() + return ASensor( + opt=SensorOption( + sensor=Sensor(1, name, "W"), schedule=NOSCHEDULE, visible=True + ), + entity=ent, + retain=retain, + _last=value, + ) + + +def _running_ist() -> AInverter: + ist = ist_factory() + ist.lifecycle = "running" + ist.ss = { + "power": _asensor("power", 100), + "energy": _asensor("energy", 12.3), + "never": _asensor("never", None), + "rw": _asensor("rw", 50, retain=True), + "hidden": _asensor("hidden", 7, entity=False), + } + return ist + + +async def test_republish_states_sends_last_published_non_retained() -> None: + """Only non-retained sensors with an entity and a published value are re-sent.""" + ist = _running_ist() + + assert await ist.republish_states() == 2 + + ist.ss["power"].entity.send_state.assert_awaited_once_with(MQTT, 100) # type: ignore[union-attr] + ist.ss["energy"].entity.send_state.assert_awaited_once_with(MQTT, 12.3) # type: ignore[union-attr] + ist.ss["never"].entity.send_state.assert_not_awaited() # type: ignore[union-attr] + ist.ss["rw"].entity.send_state.assert_not_awaited() # type: ignore[union-attr] + # never written by a republish + assert ist.ss["power"].value == 100 + + +@pytest.mark.parametrize("lifecycle", ["starting", "stale_quiet", "stale_probing"]) +async def test_republish_states_only_when_running(lifecycle: str) -> None: + """Do not re-send values from an inverter that is not being read.""" + ist = _running_ist() + ist.lifecycle = lifecycle # type: ignore[assignment] + + assert await ist.republish_states() == 0 + ist.ss["power"].entity.send_state.assert_not_awaited() # type: ignore[union-attr] + + +async def test_republish_states_resends_value_published_meanwhile() -> None: + """A publish in flight updates _last after our send: re-send the newer value.""" + ist = _running_ist() + power = ist.ss["power"] + + async def send_state(_client: Any, payload: Any, **_kw: Any) -> None: + if payload == 100: + power._last = 900 # the in-flight ASensor.publish(900) completes + + power.entity.send_state.side_effect = send_state # type: ignore[union-attr] + + await ist.republish_states() + + assert power.entity.send_state.await_args_list == [call(MQTT, 100), call(MQTT, 900)] # type: ignore[union-attr] + assert power.value == 900 + + +async def test_republish_states_send_failure_does_not_stop_the_pass() -> None: + """A failed send is logged and the other sensors are still re-sent.""" + ist = _running_ist() + ist.ss["power"].entity.send_state.side_effect = ConnectionError("broker gone") # type: ignore[union-attr] + + assert await ist.republish_states() == 1 + + ist.ss["energy"].entity.send_state.assert_awaited_once_with(MQTT, 12.3) # type: ignore[union-attr] + + +async def test_republish_all_states_every_running_inverter_every_pass( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Each pass re-sends the states of every running inverter, and only those.""" + ist1, ist2 = _running_ist(), _running_ist() + ist2.lifecycle = "starting" + monkeypatch.setattr(a_inverter, "STATE", [ist1, ist2]) + + await republish_all_states(delays=(0, 0)) + + assert ist1.ss["power"].entity.send_state.await_count == 2 # type: ignore[union-attr] + assert ist1.ss["energy"].entity.send_state.await_count == 2 # type: ignore[union-attr] + ist2.ss["power"].entity.send_state.assert_not_awaited() # type: ignore[union-attr] + + +async def test_republish_all_states_default_delays_from_ha_online( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The passes run 10 s and 60 s after HA came online, not 60 s after pass one.""" + clock = [1000.0] + sleeps: list[float] = [] + + async def fake_sleep(seconds: float) -> None: + sleeps.append(seconds) + clock[0] += seconds + + async def pass_takes_2s() -> int: + clock[0] += 2 + return 1 + + ist = _running_ist() + monkeypatch.setattr(ist, "republish_states", pass_takes_2s) + monkeypatch.setattr(a_inverter, "STATE", [ist]) + monkeypatch.setattr(a_inverter, "asyncio", SimpleNamespace(sleep=fake_sleep)) + monkeypatch.setattr(a_inverter, "time", SimpleNamespace(monotonic=lambda: clock[0])) + + await republish_all_states() + + assert sleeps == [10, 48]