Skip to content

Commit 908cad6

Browse files
committed
fix: verify the snapshot reply arrived, and retry if it didn't
Publishing a snapshot request is not the same as delivering one. The recovery triggers (boot-count change, availability online) fire while the dongle is still coming back, so a request can go out before it has resubscribed and is then dropped. Because FW >= 4.3.0 streams change-data only and settings never change on their own, nothing re-sends them: one lost request leaves every setting entity empty until the next reboot. Arm a timer on each successful publish and disarm it when the reply arrives on <dongle>/snap/hold; if it doesn't, re-request, bounded to _snapshot_max_retries (3) at _snapshot_retry_delay (20s). Only /snap/hold counts as delivery. The hold half is what carries the settings, so an /snap/input-only reply must not disarm the retry. Retries respect the existing OTA suppression: a dongle mid-OTA has no snapshot queue and a {"what":"all"} request reboot-loops it, so a timer that fires during an OTA is dropped rather than re-requesting. Armed timers are also cancelled in stop_mqtt_subscription(), so a reload doesn't orphan them. Tests: 6 new tests, all verified to fail before this change and pass after. 185 passed.
1 parent 972b815 commit 908cad6

2 files changed

Lines changed: 199 additions & 0 deletions

File tree

custom_components/monitormysolar/coordinator.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@ def __init__(
9898
self._dongle_stale_after = 90.0
9999
# Don't send more than one recovery snapshot per dongle within this window.
100100
self._recovery_snapshot_debounce = 30.0
101+
# A snapshot request can be lost: the recovery triggers fire while the
102+
# dongle is still coming back, so it may not have resubscribed yet. On
103+
# FW >= 4.3.0 nothing re-sends hold registers, so a lost request leaves
104+
# every setting entity empty until the next reboot. Verify the reply
105+
# (<dongle>/snap/hold) actually arrives and retry a bounded number of times.
106+
self._snapshot_retry: Dict[str, Any] = {} # dongle -> cancel callback
107+
self._snapshot_retry_attempts: Dict[str, int] = {}
108+
self._snapshot_retry_delay = 20.0
109+
self._snapshot_max_retries = 3
101110
self._has_gridboss = entry.data.get("has_gridboss", False) # Track if GridBoss is enabled
102111
self._gridboss_dongle = entry.data.get("gridboss_dongle", "") # Track which dongle is GridBoss
103112
self._last_fault_warning_data = {} # Track last fault/warning data to prevent duplicate processing
@@ -236,6 +245,7 @@ async def request_snapshot(self, dongle_id: str, version: str = "", force: bool
236245
LOGGER.info(
237246
"Requested full snapshot from %s (version=%s)", dongle_id, version or "unknown"
238247
)
248+
self._arm_snapshot_retry(dongle_id, version)
239249
return True
240250

241251
async def request_recovery_snapshot(self, dongle_id: str, reason: str) -> None:
@@ -267,6 +277,65 @@ async def request_recovery_snapshot(self, dongle_id: str, reason: str) -> None:
267277
):
268278
self._last_recovery_snapshot[dongle_id] = now
269279

280+
def _cancel_snapshot_retry(self, dongle_id: str) -> None:
281+
"""Drop any armed retry timer for a dongle."""
282+
# getattr: test coordinators are built via __new__ and skip __init__.
283+
pending = getattr(self, "_snapshot_retry", None)
284+
if not pending:
285+
return
286+
cancel = pending.pop(dongle_id, None)
287+
if cancel is not None:
288+
cancel()
289+
290+
def _arm_snapshot_retry(self, dongle_id: str, version: str) -> None:
291+
"""Re-request the snapshot if its reply doesn't arrive in time.
292+
293+
A published request is not a delivered one: the recovery triggers fire
294+
while the dongle is still reconnecting, so the request can go out before
295+
it has resubscribed. Since FW >= 4.3.0 never re-sends hold registers on
296+
its own, that single loss would leave every setting entity empty for the
297+
rest of the session.
298+
"""
299+
if getattr(self, "_snapshot_retry", None) is None:
300+
self._snapshot_retry = {}
301+
if getattr(self, "_snapshot_retry_attempts", None) is None:
302+
self._snapshot_retry_attempts = {}
303+
self._cancel_snapshot_retry(dongle_id)
304+
attempts = self._snapshot_retry_attempts.get(dongle_id, 0)
305+
max_retries = getattr(self, "_snapshot_max_retries", 3)
306+
if attempts >= max_retries:
307+
LOGGER.warning(
308+
"Snapshot from %s still unanswered after %d retries - its settings "
309+
"entities will stay unknown until it reboots or reconnects",
310+
dongle_id, attempts,
311+
)
312+
return
313+
delay = getattr(self, "_snapshot_retry_delay", 20.0)
314+
315+
async def _retry(_now) -> None:
316+
self._snapshot_retry.pop(dongle_id, None)
317+
if self.is_ota_in_progress(dongle_id):
318+
return
319+
self._snapshot_retry_attempts[dongle_id] = attempts + 1
320+
LOGGER.warning(
321+
"No snapshot reply from %s after %.0fs - retrying (%d/%d)",
322+
dongle_id, delay, attempts + 1, max_retries,
323+
)
324+
await self.request_snapshot(dongle_id, version, force=True)
325+
326+
self._snapshot_retry[dongle_id] = async_call_later(self.hass, delay, _retry)
327+
328+
def _note_snapshot_delivered(self, dongle_id: str) -> None:
329+
"""Record that a dongle answered its snapshot request.
330+
331+
Called when <dongle>/snap/hold arrives — the hold half is what carries
332+
the settings, so an /snap/input-only reply deliberately does not count.
333+
"""
334+
self._cancel_snapshot_retry(dongle_id)
335+
attempts = getattr(self, "_snapshot_retry_attempts", None)
336+
if attempts is not None:
337+
attempts.pop(dongle_id, None)
338+
270339
async def mark_dongle_seen(self, dongle_id: str) -> None:
271340
"""Record that a message arrived from a dongle and detect gap recovery.
272341
@@ -1292,6 +1361,10 @@ async def _async_handle_mqtt_message(self, msg) -> None:
12921361
# which on FW >= 4.3.0 (change-data only) may be a long time. The
12931362
# firmware publishes it on <dongle>/snap/input and <dongle>/snap/hold.
12941363
elif topic.endswith("/snap/input") or topic.endswith("/snap/hold"):
1364+
if topic.endswith("/snap/hold"):
1365+
# The hold half carries the settings; an input-only reply
1366+
# leaves them empty, so it must not disarm the retry.
1367+
self._note_snapshot_delivered(dongle_id)
12951368
await self.process_message(dongle_id, topic, msg.payload)
12961369
self.async_set_updated_data(self.entities)
12971370
# Skip other message processing during startup to prevent excessive updates
@@ -1764,6 +1837,10 @@ async def log_ignored_entities(_):
17641837
async def stop_mqtt_subscription(self):
17651838
"""Stop all MQTT subscriptions."""
17661839
LOGGER.debug(f"Stopping MQTT subscriptions for all dongles")
1840+
# Drop armed snapshot retries first: once unsubscribed there is nothing
1841+
# left to answer them, and a reload would leave the timers orphaned.
1842+
for dongle_id in list(getattr(self, "_snapshot_retry", {})):
1843+
self._cancel_snapshot_retry(dongle_id)
17671844
for key, unsubscribe in list(self._mqtt_unsubscribe_callbacks.items()):
17681845
try:
17691846
unsubscribe()

tests/test_snapshot_retry.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Snapshot requests are verified and retried until the reply arrives.
2+
3+
Publishing a snapshot request is not the same as delivering one: the recovery
4+
triggers fire while a dongle is still reconnecting, so a request can go out
5+
before it has resubscribed. On FW >= 4.3.0 nothing re-sends hold registers, so
6+
a single lost request leaves every setting entity empty for the session.
7+
8+
The coordinator arms a timer on each successful publish and disarms it when
9+
<dongle>/snap/hold arrives.
10+
"""
11+
import asyncio
12+
13+
14+
def _run(coro):
15+
return asyncio.run(coro)
16+
17+
18+
def _fire(timers):
19+
"""Fire the oldest armed timer, one-shot: a fired handle is spent."""
20+
_delay, cb = timers.pop(0)
21+
return _run(cb(None))
22+
23+
24+
def _prep(coordinator, monkeypatch):
25+
"""Wire a coordinator with a captured async_call_later and no real publish."""
26+
from custom_components.monitormysolar import coordinator as coord_mod
27+
28+
published = []
29+
30+
async def fake_publish(hass, topic, payload, **kwargs):
31+
published.append(topic)
32+
33+
monkeypatch.setattr(coord_mod.mqtt, "async_publish", fake_publish)
34+
35+
timers = [] # (delay, callback)
36+
37+
def fake_call_later(hass, delay, cb):
38+
timers.append((delay, cb))
39+
cancelled = {"done": False}
40+
41+
def _cancel():
42+
cancelled["done"] = True
43+
for i, (d, c) in enumerate(timers):
44+
if c is cb:
45+
timers.pop(i)
46+
break
47+
48+
return _cancel
49+
50+
monkeypatch.setattr(coord_mod, "async_call_later", fake_call_later)
51+
52+
coordinator._snapshot_requested = set()
53+
coordinator._snapshot_retry = {}
54+
coordinator._snapshot_retry_attempts = {}
55+
coordinator._snapshot_retry_delay = 20.0
56+
coordinator._snapshot_max_retries = 3
57+
return published, timers
58+
59+
60+
def test_successful_publish_arms_a_retry(coordinator, monkeypatch):
61+
published, timers = _prep(coordinator, monkeypatch)
62+
63+
_run(coordinator.request_snapshot("dongle-A", "4.3.1.1C6", force=True))
64+
assert published == ["dongle-A/snapshot/request"]
65+
assert len(timers) == 1 and timers[0][0] == 20.0
66+
assert "dongle-A" in coordinator._snapshot_retry
67+
68+
69+
def test_snap_hold_disarms_the_retry(coordinator, monkeypatch):
70+
published, timers = _prep(coordinator, monkeypatch)
71+
72+
_run(coordinator.request_snapshot("dongle-A", "4.3.1.1C6", force=True))
73+
coordinator._note_snapshot_delivered("dongle-A")
74+
75+
assert coordinator._snapshot_retry == {}
76+
assert timers == [] # cancelled, so it can never re-request
77+
78+
79+
def test_input_only_reply_does_not_disarm(coordinator, monkeypatch):
80+
"""/snap/input carries no settings, so it must not count as delivery."""
81+
published, timers = _prep(coordinator, monkeypatch)
82+
83+
_run(coordinator.request_snapshot("dongle-A", "4.3.1.1C6", force=True))
84+
# Only _note_snapshot_delivered() disarms, and the dispatcher calls it for
85+
# /snap/hold alone -- an input-only reply leaves the timer armed.
86+
assert "dongle-A" in coordinator._snapshot_retry
87+
88+
89+
def test_unanswered_request_is_retried(coordinator, monkeypatch):
90+
published, timers = _prep(coordinator, monkeypatch)
91+
92+
_run(coordinator.request_snapshot("dongle-A", "4.3.1.1C6", force=True))
93+
# Reply never arrives -> the timer fires.
94+
_fire(timers)
95+
96+
assert published == ["dongle-A/snapshot/request"] * 2
97+
assert coordinator._snapshot_retry_attempts["dongle-A"] == 1
98+
99+
100+
def test_retries_are_bounded(coordinator, monkeypatch):
101+
published, timers = _prep(coordinator, monkeypatch)
102+
103+
_run(coordinator.request_snapshot("dongle-A", "4.3.1.1C6", force=True))
104+
for _ in range(10):
105+
if not timers:
106+
break
107+
_fire(timers)
108+
109+
# Initial publish + at most _snapshot_max_retries retries, then it gives up.
110+
assert len(published) == 1 + 3
111+
assert coordinator._snapshot_retry == {}
112+
113+
114+
def test_retry_suppressed_during_ota(coordinator, monkeypatch):
115+
"""An OTA'ing dongle has no snapshot queue: a request reboot-loops it."""
116+
published, timers = _prep(coordinator, monkeypatch)
117+
118+
_run(coordinator.request_snapshot("dongle-A", "4.3.1.1C6", force=True))
119+
coordinator.set_ota_in_progress("dongle-A", True)
120+
_fire(timers)
121+
122+
assert published == ["dongle-A/snapshot/request"] # no second request

0 commit comments

Comments
 (0)