Skip to content

Commit b7c2ae2

Browse files
committed
fix(comm): persistent dongle connection (LuxPower-standard lifecycle)
The EG4/LuxPower dongle is designed for ONE long-lived TCP connection with heartbeats (TcpFunction 0xC1). We were opening a fresh connection per read and per write, which the dongle drops intermittently — surfacing as flaky local reads/writes (broken pipe, 'No valid response') while the EG4 cloud (persistent channel) stayed solid. - New collector/comm/persistent.py: process-wide persistent socket with auto-reconnect, TCP keepalive, 30s heartbeats, and a thread-safe transact() that serializes all reads/writes and retries through drops. - tcp_active transport now polls over the shared persistent connection. - API read/write/multi-write helpers now use the shared connection. - Frontend saveSchedule no longer aborts the whole batch on one failure: it retries each register and reports per-register results. Verified: 6/6 AC first registers (152-157) write and stick first-try via the API; collector snapshots stable at 105 registers.
1 parent 72afc08 commit b7c2ae2

5 files changed

Lines changed: 452 additions & 243 deletions

File tree

‎api/__init__.py‎

Lines changed: 40 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import json
77
import logging
88
import os
9+
import time
910
import shutil
1011
import subprocess
1112
from datetime import datetime, timedelta, timezone
@@ -1450,44 +1451,30 @@ def _read_holding_block(
14501451
count: int,
14511452
timeout: float = 10.0,
14521453
) -> Tuple[bool, Dict[int, int], str]:
1453-
"""Read a contiguous block of holding registers directly from the inverter."""
1454+
"""Read a contiguous block of holding registers directly from the inverter.
1455+
1456+
Uses the process-wide persistent dongle connection (LuxPower-standard
1457+
lifecycle) instead of a fresh connection per read.
1458+
"""
1459+
from collector.comm.persistent import get_shared_connection
1460+
14541461
req = build_read_request(datalog_serial, inverter_serial, MODBUS_READ_HOLD, start, count)
1455-
sock: Optional[socket.socket] = None
1456-
try:
1457-
sock = socket.create_connection((host, port), timeout=timeout)
1458-
sock.settimeout(timeout)
1459-
sock.sendall(req)
1460-
deadline = time.time() + timeout
1461-
buffer = b""
1462-
result: Dict[int, int] = {}
1463-
while time.time() < deadline:
1464-
remaining = deadline - time.time()
1465-
if remaining <= 0:
1466-
break
1467-
sock.settimeout(remaining)
1468-
try:
1469-
chunk = sock.recv(4096)
1470-
except socket.timeout:
1471-
break
1472-
if not chunk:
1473-
break
1474-
buffer += chunk
1475-
for frame in find_frames(buffer):
1476-
if frame.is_error:
1477-
return False, {}, f"Modbus error response: code {frame.error_code}"
1478-
if frame.is_read_hold and frame.register == start:
1479-
for i, raw_val in enumerate(frame.values):
1480-
result[start + i] = raw_val
1481-
return True, result, "ok"
1462+
conn = get_shared_connection(host, port, datalog_serial, inverter_serial, timeout=timeout)
1463+
1464+
def match(frame) -> bool:
1465+
if frame.is_error:
1466+
return True
1467+
return frame.is_read_hold and frame.register == start
1468+
1469+
frame = conn.transact(req, match, timeout=timeout)
1470+
if frame is None:
14821471
return False, {}, f"No valid holding-register response for {start}/{count}"
1483-
except Exception as exc:
1484-
return False, {}, f"Holding read failed: {exc}"
1485-
finally:
1486-
if sock is not None:
1487-
try:
1488-
sock.close()
1489-
except Exception:
1490-
pass
1472+
if frame.is_error:
1473+
return False, {}, f"Modbus error response: code {frame.error_code}"
1474+
result: Dict[int, int] = {}
1475+
for i, raw_val in enumerate(frame.values):
1476+
result[start + i] = raw_val
1477+
return True, result, "ok"
14911478

14921479

14931480
def _read_all_holding_registers(dongle: dict) -> Dict[int, int]:
@@ -1668,14 +1655,23 @@ def api_holding_put(name: str, body: HoldingUpdate):
16681655
if not dongle["datalog_serial"] or not dongle["inverter_serial"]:
16691656
raise HTTPException(400, "datalog_serial / inverter_serial not configured")
16701657

1671-
ok, msg = _write_holding_register(
1672-
dongle["dongle_host"],
1673-
dongle["dongle_port"],
1674-
dongle["datalog_serial"],
1675-
dongle["inverter_serial"],
1676-
reg,
1677-
raw_value,
1678-
)
1658+
# The dongle intermittently drops individual write transactions (returns
1659+
# no valid response). Retry a few times before surfacing a 502 — a retry
1660+
# usually lands the write (observed needing 1-2 attempts per register).
1661+
ok = False
1662+
msg = ""
1663+
for attempt in range(4):
1664+
ok, msg = _write_holding_register(
1665+
dongle["dongle_host"],
1666+
dongle["dongle_port"],
1667+
dongle["datalog_serial"],
1668+
dongle["inverter_serial"],
1669+
reg,
1670+
raw_value,
1671+
)
1672+
if ok:
1673+
break
1674+
time.sleep(0.7)
16791675
if not ok:
16801676
raise HTTPException(502, f"Failed to write {name}: {msg}")
16811677
logger.info("Wrote holding register %s (address %d) = %d", name, reg, raw_value)

‎api/static/index.html‎

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1056,19 +1056,38 @@ <h4 style="margin-bottom:0.5rem;">${g.title}</h4>
10561056
try {
10571057
// Write sequentially (not in parallel) — the dongle rejects bursts of
10581058
// simultaneous write connections, which surfaced as intermittent 502s.
1059+
// Do NOT abort the whole batch on one failure: the dongle is flaky and
1060+
// drops individual registers, so we record failures and continue, then
1061+
// report exactly which registers did and did not write.
10591062
let written = 0;
1063+
const failed = [];
10601064
for (const [name, value] of Object.entries(values)) {
1061-
const r = await fetch(API + '/api/holding/' + encodeURIComponent(name), {
1062-
method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify({value: Number(value)})
1063-
});
1064-
if (!r.ok) {
1065-
let detail = r.statusText;
1066-
try { const j = await r.json(); if (j.detail) detail = j.detail; } catch (e) {}
1067-
throw new Error(name + ': ' + detail);
1065+
let ok = false;
1066+
// Retry a few times (the dongle intermittently drops a write; a retry
1067+
// usually lands it — observed needing 1-2 attempts per register).
1068+
for (let attempt = 0; attempt < 4 && !ok; attempt++) {
1069+
try {
1070+
const r = await fetch(API + '/api/holding/' + encodeURIComponent(name), {
1071+
method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify({value: Number(value)})
1072+
});
1073+
if (r.ok) { ok = true; break; }
1074+
let detail = r.statusText;
1075+
try { const j = await r.json(); if (j.detail) detail = j.detail; } catch (e) {}
1076+
if (attempt === 3) failed.push(name + ': ' + detail);
1077+
} catch (e) {
1078+
if (attempt === 3) failed.push(name + ': ' + e.message);
1079+
}
1080+
await new Promise(res => setTimeout(res, 700));
10681081
}
1069-
written++;
1082+
if (ok) written++;
1083+
}
1084+
if (failed.length) {
1085+
msg.textContent = `Value set — ${written} written, ${failed.length} FAILED (${failed.join('; ')})`;
1086+
msg.style.color = 'var(--red-color)';
1087+
} else {
1088+
msg.textContent = `Value set — ${written} register${written === 1 ? '' : 's'} written.`;
1089+
msg.style.color = 'var(--green-color)';
10701090
}
1071-
msg.textContent = `Value set — ${written} register${written === 1 ? '' : 's'} written.`; msg.style.color = 'var(--green-color)';
10721091
} catch (e) {
10731092
msg.textContent = 'Error setting value: ' + e.message; msg.style.color = 'var(--red-color)';
10741093
}

‎collector/automation.py‎

Lines changed: 53 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -100,45 +100,30 @@ def _write_holding_register(
100100
value: int,
101101
timeout: float = 10.0,
102102
) -> Tuple[bool, str]:
103-
"""Send a WriteSingleRegister request and verify the echo."""
103+
"""Send a WriteSingleRegister request and verify the echo.
104+
105+
Uses the process-wide persistent dongle connection (LuxPower-standard
106+
lifecycle: one long-lived socket with heartbeats) instead of opening a
107+
fresh connection per write, which the dongle drops intermittently.
108+
"""
109+
from .comm.persistent import get_shared_connection
110+
104111
req = build_write_request(datalog_serial, inverter_serial, register, value)
105-
sock: Optional[socket.socket] = None
106-
try:
107-
sock = socket.create_connection((host, port), timeout=timeout)
108-
sock.settimeout(timeout)
109-
sock.sendall(req)
110-
111-
deadline = time.time() + timeout
112-
buffer = b""
113-
while time.time() < deadline:
114-
remaining = deadline - time.time()
115-
if remaining <= 0:
116-
break
117-
sock.settimeout(remaining)
118-
try:
119-
chunk = sock.recv(4096)
120-
except socket.timeout:
121-
break
122-
if not chunk:
123-
break
124-
buffer += chunk
125-
frames = find_frames(buffer)
126-
for frame in frames:
127-
if frame.is_error:
128-
return False, f"Modbus error response: code {frame.error_code}"
129-
if frame.device_function == MODBUS_WRITE_SINGLE and frame.register == register:
130-
if frame.values and frame.values[0] == value:
131-
return True, f"Wrote register {register} = {value}"
132-
return True, f"Write accepted (echo value {frame.values})"
112+
conn = get_shared_connection(host, port, datalog_serial, inverter_serial, timeout=timeout)
113+
114+
def match(frame) -> bool:
115+
if frame.is_error:
116+
return True
117+
return frame.device_function == MODBUS_WRITE_SINGLE and frame.register == register
118+
119+
frame = conn.transact(req, match, timeout=timeout)
120+
if frame is None:
133121
return False, "No valid write response received"
134-
except Exception as exc:
135-
return False, f"Write failed: {exc}"
136-
finally:
137-
if sock is not None:
138-
try:
139-
sock.close()
140-
except Exception:
141-
pass
122+
if frame.is_error:
123+
return False, f"Modbus error response: code {frame.error_code}"
124+
if frame.values and frame.values[0] == value:
125+
return True, f"Wrote register {register} = {value}"
126+
return True, f"Write accepted (echo value {frame.values})"
142127

143128

144129
def _write_holding_registers(
@@ -154,47 +139,28 @@ def _write_holding_registers(
154139
155140
Used by the 7-day scheduling block (registers 500-723), which the inverter
156141
only accepts via multi-register writes (no single-register writes).
142+
Uses the process-wide persistent dongle connection.
157143
"""
144+
from .comm.persistent import get_shared_connection
145+
158146
req = build_write_multi_request(
159147
datalog_serial, inverter_serial, start_register, values
160148
)
161-
sock: Optional[socket.socket] = None
162-
try:
163-
sock = socket.create_connection((host, port), timeout=timeout)
164-
sock.settimeout(timeout)
165-
sock.sendall(req)
166-
167-
deadline = time.time() + timeout
168-
buffer = b""
169-
while time.time() < deadline:
170-
remaining = deadline - time.time()
171-
if remaining <= 0:
172-
break
173-
sock.settimeout(remaining)
174-
try:
175-
chunk = sock.recv(4096)
176-
except socket.timeout:
177-
break
178-
if not chunk:
179-
break
180-
buffer += chunk
181-
frames = find_frames(buffer)
182-
for frame in frames:
183-
if frame.is_error:
184-
return False, f"Modbus error response: code {frame.error_code}"
185-
if frame.device_function == MODBUS_WRITE_MULTI and frame.register == start_register:
186-
if frame.write_count == len(values):
187-
return True, f"Wrote {len(values)} registers starting at {start_register}"
188-
return True, f"Write accepted (echo count {frame.write_count})"
149+
conn = get_shared_connection(host, port, datalog_serial, inverter_serial, timeout=timeout)
150+
151+
def match(frame) -> bool:
152+
if frame.is_error:
153+
return True
154+
return frame.device_function == MODBUS_WRITE_MULTI and frame.register == start_register
155+
156+
frame = conn.transact(req, match, timeout=timeout)
157+
if frame is None:
189158
return False, "No valid write response received"
190-
except Exception as exc:
191-
return False, f"Write failed: {exc}"
192-
finally:
193-
if sock is not None:
194-
try:
195-
sock.close()
196-
except Exception:
197-
pass
159+
if frame.is_error:
160+
return False, f"Modbus error response: code {frame.error_code}"
161+
if frame.write_count == len(values):
162+
return True, f"Wrote {len(values)} registers starting at {start_register}"
163+
return True, f"Write accepted (echo count {frame.write_count})"
198164

199165

200166
def _read_holding_register(
@@ -209,41 +175,21 @@ def _read_holding_register(
209175
req = build_read_request(
210176
datalog_serial, inverter_serial, MODBUS_READ_HOLD, register, 1
211177
)
212-
sock: Optional[socket.socket] = None
213-
try:
214-
sock = socket.create_connection((host, port), timeout=timeout)
215-
sock.settimeout(timeout)
216-
sock.sendall(req)
217-
218-
deadline = time.time() + timeout
219-
buffer = b""
220-
while time.time() < deadline:
221-
remaining = deadline - time.time()
222-
if remaining <= 0:
223-
break
224-
sock.settimeout(remaining)
225-
try:
226-
chunk = sock.recv(4096)
227-
except socket.timeout:
228-
break
229-
if not chunk:
230-
break
231-
buffer += chunk
232-
frames = find_frames(buffer)
233-
for frame in frames:
234-
if frame.is_error:
235-
return False, None, f"Modbus error response: code {frame.error_code}"
236-
if frame.is_read_hold and frame.register == register and frame.values:
237-
return True, frame.values[0], f"Read back {register} = {frame.values[0]}"
178+
from .comm.persistent import get_shared_connection
179+
180+
conn = get_shared_connection(host, port, datalog_serial, inverter_serial, timeout=timeout)
181+
182+
def match(frame) -> bool:
183+
if frame.is_error:
184+
return True
185+
return frame.is_read_hold and frame.register == register and bool(frame.values)
186+
187+
frame = conn.transact(req, match, timeout=timeout)
188+
if frame is None:
238189
return False, None, "No valid read response received"
239-
except Exception as exc:
240-
return False, None, f"Read failed: {exc}"
241-
finally:
242-
if sock is not None:
243-
try:
244-
sock.close()
245-
except Exception:
246-
pass
190+
if frame.is_error:
191+
return False, None, f"Modbus error response: code {frame.error_code}"
192+
return True, frame.values[0], f"Read back {register} = {frame.values[0]}"
247193

248194

249195
# ── SolarAssistant setting name → lux-mon holding-register name ─────────────

0 commit comments

Comments
 (0)