1414 single socket (the dongle rejects concurrent/burst connections)
1515 * automatic reconnect + retry when the dongle drops the connection
1616
17- IMPORTANT — matches lxp-bridge (LuxPower's own reference) exactly :
17+ IMPORTANT — matches lxp-bridge (LuxPower's own reference):
1818
19- * lxp-bridge default is `heartbeats: false`; it relies on TCP keepalive
20- (60s) to hold the connection, NOT proactive heartbeat packets.
21- * When heartbeats ARE enabled, lxp-bridge is REACTIVE: it only echoes back
22- a heartbeat the dongle sends; it never initiates one.
23-
24- We deliberately do NOT send proactive heartbeats. Sending unsolicited
25- protocol=2 heartbeat packets interleaved with protocol=1 read/write traffic
26- causes the dongle to close the connection ("Broken pipe"), which is exactly
27- the flakiness we are eliminating.
19+ * lxp-bridge sends a Heartbeat (0xC1, protocol=2) after 120s of silence
20+ to keep the dongle session alive (inverter.rs: "If no messages received
21+ for 120 seconds, send a heartbeat"). We do the same via a background
22+ heartbeat thread.
23+ * lxp-bridge waits RECONNECT_DELAY_SECS=5 between reconnection attempts;
24+ we use the same delay to avoid wedging the dongle's session state with
25+ rapid-fire reconnects.
26+ * We also detect stale responses: if the dongle returns byte-identical
27+ register data across consecutive reads, we force a full reconnect
28+ instead of trusting the cached response.
2829
2930Both the API (reads + writes) and the collector (polling) use this class.
3031"""
3536import time
3637from typing import Callable , Optional
3738
38- from ..protocol import find_frames
39+ from ..protocol import find_frames , build_heartbeat
3940
4041logger = logging .getLogger ("luxmon.comm.persistent" )
4142
@@ -69,16 +70,38 @@ def __init__(
6970 self ._connect_count = 0
7071 self ._tx_count = 0
7172
73+ # Heartbeat thread: keeps the dongle session alive (lxp-bridge sends
74+ # a heartbeat after 120s of silence). Started in start(), stopped in
75+ # stop().
76+ self ._hb_thread : Optional [threading .Thread ] = None
77+ self ._hb_stop = threading .Event ()
78+ self ._hb_interval = 60.0 # check every 60s; send if 120s+ silence
79+
80+ # Staleness detection: remember the last response signature so we can
81+ # detect the dongle serving frozen/cached data (the 9/7 wedge).
82+ self ._last_resp_sig : Optional [bytes ] = None
83+ self ._stale_count = 0
84+ self ._stale_threshold = 5 # N identical responses => force reconnect
85+
7286 # ── lifecycle ────────────────────────────────────────────────────────────
7387
7488 def start (self ) -> None :
75- """Open the connection."""
89+ """Open the connection and start the heartbeat thread ."""
7690 self ._connect ()
91+ self ._hb_stop .clear ()
92+ self ._hb_thread = threading .Thread (
93+ target = self ._heartbeat_loop , name = "lux-dongle-heartbeat" , daemon = True
94+ )
95+ self ._hb_thread .start ()
7796 logger .info (
7897 "Persistent dongle connection started (%s:%d)" , self .host , self .port
7998 )
8099
81100 def stop (self ) -> None :
101+ self ._hb_stop .set ()
102+ if self ._hb_thread is not None :
103+ self ._hb_thread .join (timeout = 2.0 )
104+ self ._hb_thread = None
82105 with self ._lock :
83106 if self ._sock is not None :
84107 try :
@@ -138,6 +161,65 @@ def _ensure_connected(self) -> None:
138161 self ._sock = None
139162 self ._connect ()
140163
164+ # ── heartbeat ───────────────────────────────────────────────────────────
165+
166+ def _heartbeat_loop (self ) -> None :
167+ """Send a heartbeat after 120s of silence (matches lxp-bridge).
168+
169+ The dongle expects periodic traffic to keep the session alive. If we
170+ go silent too long, the session goes stale and the dongle starts
171+ serving cached data (the 9/7 wedge). lxp-bridge sends a Heartbeat
172+ (0xC1, protocol=2) after 120s of no received messages; we mirror that.
173+ """
174+ last_rx = time .time ()
175+ while not self ._hb_stop .wait (self ._hb_interval ):
176+ with self ._lock :
177+ if self ._sock is None :
178+ continue
179+ # Track last successful receive time via a lightweight probe.
180+ try :
181+ self ._sock .settimeout (0.2 )
182+ self ._sock .recv (1 , socket .MSG_PEEK )
183+ self ._sock .settimeout (self .timeout )
184+ except socket .timeout :
185+ pass # no pending data — connection alive
186+ except OSError :
187+ self ._sock = None
188+ continue
189+ if time .time () - last_rx >= 120 :
190+ try :
191+ hb = build_heartbeat (self .datalog_serial )
192+ self ._sock .sendall (hb )
193+ self ._tx_count += 1
194+ logger .debug ("Sent heartbeat to dongle" )
195+ except OSError as exc :
196+ logger .warning ("Heartbeat send failed: %s" , exc )
197+ self ._sock = None
198+ last_rx = time .time ()
199+
200+ # ── staleness detection ──────────────────────────────────────────────────
201+
202+ def _note_response (self , raw : bytes ) -> None :
203+ """Track response signatures to detect frozen/cached dongle data."""
204+ if raw == self ._last_resp_sig :
205+ self ._stale_count += 1
206+ if self ._stale_count >= self ._stale_threshold :
207+ logger .warning (
208+ "Dongle returned identical data %d times — forcing reconnect "
209+ "(possible stale/cached response)" ,
210+ self ._stale_count ,
211+ )
212+ self ._stale_count = 0
213+ if self ._sock is not None :
214+ try :
215+ self ._sock .close ()
216+ except OSError :
217+ pass
218+ self ._sock = None
219+ else :
220+ self ._last_resp_sig = raw
221+ self ._stale_count = 0
222+
141223 # ── transactions ────────────────────────────────────────────────────────
142224
143225 def transact (
@@ -185,6 +267,10 @@ def transact(
185267 buffer += chunk
186268 for frame in find_frames (buffer ):
187269 if match (frame ):
270+ # Staleness guard: identical responses across
271+ # consecutive reads => dongle serving cached
272+ # data; force a reconnect.
273+ self ._note_response (frame .raw )
188274 return frame
189275 # If we got here without a match and the socket is still
190276 # alive, the dongle simply didn't answer this request.
@@ -202,7 +288,7 @@ def transact(
202288 except OSError :
203289 pass
204290 self ._sock = None
205- time .sleep (0.4 * ( attempt + 1 ))
291+ time .sleep (5.0 ) # lxp-bridge RECONNECT_DELAY_SECS
206292 return None
207293
208294
0 commit comments