Skip to content

Commit aea0d13

Browse files
authored
Merge pull request #111 from RAR/fix/settings-state-loss
fix: settings entities silently report 0/off when the snapshot is lost
2 parents 7989720 + 908cad6 commit aea0d13

6 files changed

Lines changed: 303 additions & 17 deletions

File tree

custom_components/monitormysolar/coordinator.py

Lines changed: 97 additions & 11 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
@@ -202,22 +211,25 @@ def is_ota_in_progress(self, dongle_id: str) -> bool:
202211
"""Whether a dongle is currently running an OTA update."""
203212
return dongle_id in getattr(self, "_ota_in_progress", ())
204213

205-
async def request_snapshot(self, dongle_id: str, version: str = "", force: bool = False) -> None:
214+
async def request_snapshot(self, dongle_id: str, version: str = "", force: bool = False) -> bool:
206215
"""Ask a dongle for a full /input + /hold snapshot (once per session).
207216
208217
Dongles on FW >= 4.3.0 only publish change-data, so without this the
209218
entities stay 'unknown' until each value happens to change. Gated to fire
210219
once per dongle per HA session unless force=True (e.g. a reconnect).
220+
221+
Returns True only if the request was actually published, so callers can
222+
avoid recording a retry/debounce window against a request that never left.
211223
"""
212224
if self.is_ota_in_progress(dongle_id):
213225
LOGGER.info(
214226
"Suppressing snapshot request for %s: OTA in progress", dongle_id
215227
)
216-
return
228+
return False
217229
if not force and dongle_id in self._snapshot_requested:
218-
return
230+
return False
219231
if not self._needs_snapshot(version):
220-
return
232+
return False
221233
try:
222234
await mqtt.async_publish(
223235
self.hass,
@@ -226,12 +238,15 @@ async def request_snapshot(self, dongle_id: str, version: str = "", force: bool
226238
qos=1,
227239
retain=False,
228240
)
229-
self._snapshot_requested.add(dongle_id)
230-
LOGGER.info(
231-
"Requested full snapshot from %s (version=%s)", dongle_id, version or "unknown"
232-
)
233241
except Exception as e:
234242
LOGGER.debug(f"Snapshot request publish failed for {dongle_id} (non-fatal): {e}")
243+
return False
244+
self._snapshot_requested.add(dongle_id)
245+
LOGGER.info(
246+
"Requested full snapshot from %s (version=%s)", dongle_id, version or "unknown"
247+
)
248+
self._arm_snapshot_retry(dongle_id, version)
249+
return True
235250

236251
async def request_recovery_snapshot(self, dongle_id: str, reason: str) -> None:
237252
"""Force a snapshot after a dongle recovers, debounced per dongle.
@@ -252,11 +267,74 @@ async def request_recovery_snapshot(self, dongle_id: str, reason: str) -> None:
252267
last = self._last_recovery_snapshot.get(dongle_id, 0.0)
253268
if now - last < self._recovery_snapshot_debounce:
254269
return
255-
self._last_recovery_snapshot[dongle_id] = now
256270
LOGGER.info("Recovery snapshot for %s (%s)", dongle_id, reason)
257-
await self.request_snapshot(
271+
# Only start the debounce window once the request has actually gone out.
272+
# Recovery triggers fire while the dongle is rebooting, so a request can
273+
# be lost before it is subscribed; stamping first would swallow the
274+
# follow-up triggers and leave settings entities empty for the session.
275+
if await self.request_snapshot(
258276
dongle_id, self.current_fw_versions.get(dongle_id, ""), force=True
259-
)
277+
):
278+
self._last_recovery_snapshot[dongle_id] = now
279+
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)
260338

261339
async def mark_dongle_seen(self, dongle_id: str) -> None:
262340
"""Record that a message arrived from a dongle and detect gap recovery.
@@ -1283,6 +1361,10 @@ async def _async_handle_mqtt_message(self, msg) -> None:
12831361
# which on FW >= 4.3.0 (change-data only) may be a long time. The
12841362
# firmware publishes it on <dongle>/snap/input and <dongle>/snap/hold.
12851363
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)
12861368
await self.process_message(dongle_id, topic, msg.payload)
12871369
self.async_set_updated_data(self.entities)
12881370
# Skip other message processing during startup to prevent excessive updates
@@ -1755,6 +1837,10 @@ async def log_ignored_entities(_):
17551837
async def stop_mqtt_subscription(self):
17561838
"""Stop all MQTT subscriptions."""
17571839
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)
17581844
for key, unsubscribe in list(self._mqtt_unsubscribe_callbacks.items()):
17591845
try:
17601846
unsubscribe()

custom_components/monitormysolar/number.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def __init__(self, entity_info, hass, entry: MonitorMySolarEntry, bank_name, don
8383
self.entity_info = entity_info
8484
self._attr_name = entity_info["name"]
8585
self._attr_unique_id = f"{entry.entry_id}_{dongle_id}_{entity_info['unique_id']}".lower()
86-
self._attr_native_value = 0
86+
self._attr_native_value = None
8787
self._dongle_id = dongle_id
8888
self._formatted_dongle_id = self.coordinator.get_formatted_dongle_id(dongle_id)
8989
self._entity_type = entity_info["unique_id"]
@@ -229,7 +229,7 @@ def __init__(self, entity_info, hass, entry: MonitorMySolarEntry, dongle_ids):
229229
self.entity_info = entity_info
230230
self._name = entity_info["name"]
231231
self._unique_id = f"{entry.entry_id}_{entity_info['unique_id']}".lower()
232-
self._attr_native_value = 0
232+
self._attr_native_value = None
233233
self._dongle_ids = dongle_ids
234234
self._virtual_id = "combined_parallel"
235235
self._formatted_dongle_id = "combined"

custom_components/monitormysolar/switch.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def __init__(self, entity_info, hass, entry: MonitorMySolarEntry, bank_name, don
9292
self.entity_info = entity_info
9393
self._name = entity_info["name"]
9494
self._unique_id = f"{entry.entry_id}_{dongle_id}_{entity_info['unique_id']}".lower()
95-
self._state = False
95+
self._state = None
9696
self._dongle_id = dongle_id
9797
self._formatted_dongle_id = self.coordinator.get_formatted_dongle_id(dongle_id)
9898
self._entity_type = entity_info["unique_id"]
@@ -231,7 +231,7 @@ def __init__(self, entity_info, hass, entry: MonitorMySolarEntry, dongle_ids):
231231
self.entity_info = entity_info
232232
self._name = entity_info["name"]
233233
self._unique_id = f"{entry.entry_id}_{entity_info['unique_id']}".lower()
234-
self._state = False
234+
self._state = None
235235
self._dongle_ids = dongle_ids
236236
self._virtual_id = "combined_parallel"
237237
self._formatted_dongle_id = "combined"

tests/test_recovery_snapshot.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@ def _run(coro):
1313
return asyncio.run(coro)
1414

1515

16-
def _prep(coordinator, monkeypatch):
17-
# Capture request_snapshot calls without publishing.
16+
def _prep(coordinator, monkeypatch, published=True):
17+
# Capture request_snapshot calls without publishing. `published` models its
18+
# return value: True = the request actually went out, False = the publish
19+
# failed or was suppressed.
1820
calls = []
1921

2022
async def fake_request_snapshot(dongle_id, version="", force=False):
2123
calls.append((dongle_id, force))
24+
return published
2225

2326
monkeypatch.setattr(coordinator, "request_snapshot", fake_request_snapshot)
2427
coordinator.current_fw_versions = {}
@@ -36,6 +39,28 @@ def test_recovery_snapshot_debounced(coordinator, monkeypatch):
3639
assert calls == [("dongle-A", True)]
3740

3841

42+
def test_failed_publish_does_not_burn_debounce(coordinator, monkeypatch):
43+
"""A request that never went out must not suppress the follow-up triggers.
44+
45+
Recovery triggers fire while the dongle is still rebooting, so the first
46+
attempt can be published before it has resubscribed and is then lost. If
47+
the debounce window were stamped regardless, the follow-up triggers would
48+
be swallowed and — because FW >= 4.3.0 only streams change-data — the
49+
dongle's settings entities would stay empty for the rest of the session.
50+
51+
This is the same rule the OTA-suppression path already follows; it just
52+
also has to apply when the publish itself fails.
53+
"""
54+
calls = _prep(coordinator, monkeypatch, published=False)
55+
coordinator._last_recovery_snapshot = {}
56+
coordinator._recovery_snapshot_debounce = 30.0
57+
58+
_run(coordinator.request_recovery_snapshot("dongle-A", "reboot detected"))
59+
_run(coordinator.request_recovery_snapshot("dongle-A", "availability online"))
60+
assert calls == [("dongle-A", True), ("dongle-A", True)]
61+
assert "dongle-A" not in coordinator._last_recovery_snapshot
62+
63+
3964
def test_recovery_snapshot_per_dongle(coordinator, monkeypatch):
4065
calls = _prep(coordinator, monkeypatch)
4166
coordinator._last_recovery_snapshot = {}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Settings entities must not report a fabricated value before data arrives.
2+
3+
Dongles on FW >= 4.3.0 only stream change-data, so a hold register (a setting)
4+
reaches HA exactly once, in the connect-time snapshot. If that snapshot is lost
5+
the entity never receives a value -- and a number seeded with 0 or a switch
6+
seeded with False is then indistinguishable from a real reading of 0/off.
7+
Seeding None instead surfaces it as 'unknown', which is what select.py already
8+
does.
9+
"""
10+
from unittest.mock import MagicMock
11+
12+
13+
def _entry(entity_id):
14+
entry = MagicMock()
15+
coord = entry.runtime_data
16+
coord.build_entity_id.return_value = entity_id
17+
coord.get_formatted_dongle_id.return_value = "dongle_x"
18+
coord.get_firmware_code.return_value = "AAAA"
19+
return entry
20+
21+
22+
def test_number_has_no_value_before_data_arrives():
23+
from custom_components.monitormysolar.number import InverterNumber
24+
25+
entry = _entry("number.dongle_x_acchgsoclimit")
26+
n = InverterNumber(
27+
{"name": "AC Charge SOC Limit", "unique_id": "acchgsoclimit", "min": 0, "max": 100},
28+
MagicMock(), entry, "holdbank1", "dongle-X",
29+
)
30+
assert n._attr_native_value is None
31+
32+
33+
def test_switch_has_no_state_before_data_arrives():
34+
from custom_components.monitormysolar.switch import InverterSwitch
35+
36+
entry = _entry("switch.dongle_x_accharge")
37+
s = InverterSwitch(
38+
{"name": "AC Charge", "unique_id": "accharge"},
39+
MagicMock(), entry, "holdbank1", "dongle-X",
40+
)
41+
assert s.is_on is None
42+
43+
44+
def test_select_already_has_no_state_before_data_arrives():
45+
"""Baseline: select.py was already correct; number/switch now match it."""
46+
from custom_components.monitormysolar.select import InverterSelect
47+
48+
entry = _entry("select.dongle_x_acchargetype")
49+
sel = InverterSelect(
50+
{"name": "AC Charge Type", "unique_id": "acchargetype", "options": ["Time", "SOC/Volt"]},
51+
MagicMock(), entry, "dongle-X",
52+
)
53+
assert sel.current_option is None

0 commit comments

Comments
 (0)