Skip to content

Commit 855540a

Browse files
N1c084claude
andcommitted
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 <noreply@anthropic.com>
1 parent 4628ca7 commit 855540a

4 files changed

Lines changed: 207 additions & 2 deletions

File tree

‎hass-addon-sunsynk-edge/CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
<https://github.com/kellerza/sunsynk/commits/>
44

5+
## Unreleased
6+
7+
- Re-send the last published sensor states 10 s and 60 s after Home Assistant comes back online, so
8+
schedule-reported sensors (energy, temperatures) are no longer unknown for up to 5 min after an HA
9+
restart.
10+
511
## Release 1.3.0
612

713
All items below are changes since add-on **1.2.0**.

‎src/ha_addon_sunsynk_multi/__main__.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from sunsynk import VERSION
1010
from sunsynk.utils import pretty_table_sensors
1111

12-
from .a_inverter import STATE
12+
from .a_inverter import STATE, republish_all_states
1313
from .a_sensor import MQTT, SS_TOPIC
1414
from .driver import callback_discovery_info, init_driver
1515
from .errors import print_errors
@@ -93,10 +93,18 @@ async def main_loop() -> int:
9393
)
9494
return 2
9595

96+
republish_task: asyncio.Task[None] | None = None
97+
9698
async def on_ha_connected() -> None:
97-
"""When MQTT is connected, ensure we update MQTT availability states."""
99+
"""When HA comes online: update availability, schedule a state re-send."""
100+
nonlocal republish_task
98101
for ist in STATE:
99102
await ist.set_lifecycle(None)
103+
# HA does not keep non-retained states across a restart. Re-send them in
104+
# the background: this runs under the discovery lock and must not wait.
105+
if republish_task is not None and not republish_task.done():
106+
republish_task.cancel()
107+
republish_task = asyncio.create_task(republish_all_states())
100108

101109
MQTT.on_ha_connected = on_ha_connected
102110

‎src/ha_addon_sunsynk_multi/a_inverter.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,39 @@ async def publish_sensors(self, *, states: dict[ASensor, ValType]) -> None:
259259
else:
260260
await state.publish(value)
261261

262+
async def republish_states(self) -> int:
263+
"""Re-send the last published value of every non-retained sensor.
264+
265+
Home Assistant does not keep non-retained states across a restart, so
266+
sensors reported on a fixed schedule (energy, temperatures) or only on
267+
change would stay unknown until their next report. Retained (RW) sensors
268+
come back from the broker and are skipped. Does nothing unless the
269+
inverter is running. Returns the number of sensors re-sent.
270+
"""
271+
if self.lifecycle != "running":
272+
return 0
273+
count = 0
274+
for asen in list(self.ss.values()):
275+
if asen.entity is None or asen.retain:
276+
continue
277+
val = asen.value
278+
if val is None:
279+
continue
280+
try:
281+
await asen.entity.send_state(MQTT, val)
282+
# ASensor.publish() updates _last only after its own send: if one
283+
# was in flight, the older value just went out after it. Send the
284+
# newer value again so it is the last one Home Assistant receives.
285+
# ponytail: one re-check; a second publish landing in that window
286+
# too would need a loop.
287+
if asen.value is not None and asen.value != val:
288+
await asen.entity.send_state(MQTT, asen.value)
289+
except Exception as err:
290+
_LOG.warning("Republish %s failed: %s", asen.name, err)
291+
continue
292+
count += 1
293+
return count
294+
262295
async def connect(self) -> None:
263296
"""Connect."""
264297
await self.set_lifecycle("starting")
@@ -463,6 +496,25 @@ async def publish_stats(self, period: int) -> None:
463496

464497
STATE: list[AInverter] = []
465498

499+
REPUBLISH_DELAYS = (10, 60)
500+
"""Seconds after Home Assistant comes online to re-send states: the first pass
501+
once HA processed the discovery payload, the second for late subscriptions."""
502+
503+
504+
async def republish_all_states(delays: Iterable[float] = REPUBLISH_DELAYS) -> None:
505+
"""Re-send the states of all inverters after Home Assistant came online."""
506+
start = time.monotonic()
507+
for delay in delays:
508+
await asyncio.sleep(max(0.0, start + delay - time.monotonic()))
509+
for ist in STATE:
510+
count = await ist.republish_states()
511+
_LOG.debug(
512+
"Republished %s states for %s (%ss after HA online)",
513+
count,
514+
ist.opt.ha_prefix,
515+
delay,
516+
)
517+
466518

467519
def compact_exception_group(err: ExceptionGroup) -> str:
468520
"""Compact exception group."""
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""Tests for re-sending states when Home Assistant comes back online."""
2+
3+
from types import SimpleNamespace
4+
from typing import Any
5+
from unittest.mock import AsyncMock, MagicMock, call
6+
7+
import pytest
8+
9+
from ha_addon_sunsynk_multi import a_inverter
10+
from ha_addon_sunsynk_multi.a_inverter import AInverter, republish_all_states
11+
from ha_addon_sunsynk_multi.a_sensor import MQTT, ASensor
12+
from ha_addon_sunsynk_multi.sensor_options import SensorOption
13+
from sunsynk.sensors import Sensor
14+
from sunsynk.sunsynk import ValType
15+
16+
from .conftest import NOSCHEDULE, ist_factory
17+
18+
19+
def _asensor(
20+
name: str, value: ValType, *, retain: bool = False, entity: bool = True
21+
) -> ASensor:
22+
"""Return an ASensor whose last published value is `value`."""
23+
ent = None
24+
if entity:
25+
ent = MagicMock()
26+
ent.send_state = AsyncMock()
27+
return ASensor(
28+
opt=SensorOption(
29+
sensor=Sensor(1, name, "W"), schedule=NOSCHEDULE, visible=True
30+
),
31+
entity=ent,
32+
retain=retain,
33+
_last=value,
34+
)
35+
36+
37+
def _running_ist() -> AInverter:
38+
ist = ist_factory()
39+
ist.lifecycle = "running"
40+
ist.ss = {
41+
"power": _asensor("power", 100),
42+
"energy": _asensor("energy", 12.3),
43+
"never": _asensor("never", None),
44+
"rw": _asensor("rw", 50, retain=True),
45+
"hidden": _asensor("hidden", 7, entity=False),
46+
}
47+
return ist
48+
49+
50+
async def test_republish_states_sends_last_published_non_retained() -> None:
51+
"""Only non-retained sensors with an entity and a published value are re-sent."""
52+
ist = _running_ist()
53+
54+
assert await ist.republish_states() == 2
55+
56+
ist.ss["power"].entity.send_state.assert_awaited_once_with(MQTT, 100) # type: ignore[union-attr]
57+
ist.ss["energy"].entity.send_state.assert_awaited_once_with(MQTT, 12.3) # type: ignore[union-attr]
58+
ist.ss["never"].entity.send_state.assert_not_awaited() # type: ignore[union-attr]
59+
ist.ss["rw"].entity.send_state.assert_not_awaited() # type: ignore[union-attr]
60+
# never written by a republish
61+
assert ist.ss["power"].value == 100
62+
63+
64+
@pytest.mark.parametrize("lifecycle", ["starting", "stale_quiet", "stale_probing"])
65+
async def test_republish_states_only_when_running(lifecycle: str) -> None:
66+
"""Do not re-send values from an inverter that is not being read."""
67+
ist = _running_ist()
68+
ist.lifecycle = lifecycle # type: ignore[assignment]
69+
70+
assert await ist.republish_states() == 0
71+
ist.ss["power"].entity.send_state.assert_not_awaited() # type: ignore[union-attr]
72+
73+
74+
async def test_republish_states_resends_value_published_meanwhile() -> None:
75+
"""A publish in flight updates _last after our send: re-send the newer value."""
76+
ist = _running_ist()
77+
power = ist.ss["power"]
78+
79+
async def send_state(_client: Any, payload: Any, **_kw: Any) -> None:
80+
if payload == 100:
81+
power._last = 900 # the in-flight ASensor.publish(900) completes
82+
83+
power.entity.send_state.side_effect = send_state # type: ignore[union-attr]
84+
85+
await ist.republish_states()
86+
87+
assert power.entity.send_state.await_args_list == [call(MQTT, 100), call(MQTT, 900)] # type: ignore[union-attr]
88+
assert power.value == 900
89+
90+
91+
async def test_republish_states_send_failure_does_not_stop_the_pass() -> None:
92+
"""A failed send is logged and the other sensors are still re-sent."""
93+
ist = _running_ist()
94+
ist.ss["power"].entity.send_state.side_effect = ConnectionError("broker gone") # type: ignore[union-attr]
95+
96+
assert await ist.republish_states() == 1
97+
98+
ist.ss["energy"].entity.send_state.assert_awaited_once_with(MQTT, 12.3) # type: ignore[union-attr]
99+
100+
101+
async def test_republish_all_states_every_running_inverter_every_pass(
102+
monkeypatch: pytest.MonkeyPatch,
103+
) -> None:
104+
"""Each pass re-sends the states of every running inverter, and only those."""
105+
ist1, ist2 = _running_ist(), _running_ist()
106+
ist2.lifecycle = "starting"
107+
monkeypatch.setattr(a_inverter, "STATE", [ist1, ist2])
108+
109+
await republish_all_states(delays=(0, 0))
110+
111+
assert ist1.ss["power"].entity.send_state.await_count == 2 # type: ignore[union-attr]
112+
assert ist1.ss["energy"].entity.send_state.await_count == 2 # type: ignore[union-attr]
113+
ist2.ss["power"].entity.send_state.assert_not_awaited() # type: ignore[union-attr]
114+
115+
116+
async def test_republish_all_states_default_delays_from_ha_online(
117+
monkeypatch: pytest.MonkeyPatch,
118+
) -> None:
119+
"""The passes run 10 s and 60 s after HA came online, not 60 s after pass one."""
120+
clock = [1000.0]
121+
sleeps: list[float] = []
122+
123+
async def fake_sleep(seconds: float) -> None:
124+
sleeps.append(seconds)
125+
clock[0] += seconds
126+
127+
async def pass_takes_2s() -> int:
128+
clock[0] += 2
129+
return 1
130+
131+
ist = _running_ist()
132+
monkeypatch.setattr(ist, "republish_states", pass_takes_2s)
133+
monkeypatch.setattr(a_inverter, "STATE", [ist])
134+
monkeypatch.setattr(a_inverter, "asyncio", SimpleNamespace(sleep=fake_sleep))
135+
monkeypatch.setattr(a_inverter, "time", SimpleNamespace(monotonic=lambda: clock[0]))
136+
137+
await republish_all_states()
138+
139+
assert sleeps == [10, 48]

0 commit comments

Comments
 (0)