@@ -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 ()
0 commit comments