Skip to content

Commit 40867fc

Browse files
committed
fix: add WriteMultipleRegisters (0x10) transport + write confirmation
1 parent a44b960 commit 40867fc

5 files changed

Lines changed: 330 additions & 7 deletions

File tree

‎api/__init__.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ async def get_response(self, path: str, scope: Scope) -> Any:
5151
# Single source of truth for the lux-mon version. The footer reads this via
5252
# /api/version, and FastAPI's version field derives from it. bump-version.sh
5353
# updates this one constant.
54-
LUXMON_VERSION = "2.5.11"
54+
LUXMON_VERSION = "2.5.12"
5555

5656
app = FastAPI(title="lux-mon API", version=LUXMON_VERSION)
5757

@@ -1545,7 +1545,7 @@ def api_holding_put(name: str, body: HoldingUpdate):
15451545
if not ok:
15461546
raise HTTPException(502, f"Failed to write {name}: {msg}")
15471547
logger.info("Wrote holding register %s (address %d) = %d", name, reg, raw_value)
1548-
return {"name": name, "address": reg, "raw": raw_value, "written": True}
1548+
return {"name": name, "address": reg, "raw": raw_value, "written": True, "message": msg}
15491549

15501550

15511551
@app.get("/api/quick-charge/status")

‎api/static/index.html‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,15 +1022,21 @@ <h4 style="margin-bottom:0.5rem;">${g.title}</h4>
10221022
try {
10231023
// Write sequentially (not in parallel) — the dongle rejects bursts of
10241024
// simultaneous write connections, which surfaced as intermittent 502s.
1025+
let written = 0;
10251026
for (const [name, value] of Object.entries(values)) {
10261027
const r = await fetch(API + '/api/holding/' + encodeURIComponent(name), {
10271028
method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify({value: Number(value)})
10281029
});
1029-
if (!r.ok) throw new Error(name + ': ' + r.statusText);
1030+
if (!r.ok) {
1031+
let detail = r.statusText;
1032+
try { const j = await r.json(); if (j.detail) detail = j.detail; } catch (e) {}
1033+
throw new Error(name + ': ' + detail);
1034+
}
1035+
written++;
10301036
}
1031-
msg.textContent = 'Schedule saved.'; msg.style.color = 'var(--green-color)';
1037+
msg.textContent = `Value set — ${written} register${written === 1 ? '' : 's'} written.`; msg.style.color = 'var(--green-color)';
10321038
} catch (e) {
1033-
msg.textContent = 'Error saving schedule: ' + e.message; msg.style.color = 'var(--red-color)';
1039+
msg.textContent = 'Error setting value: ' + e.message; msg.style.color = 'var(--red-color)';
10341040
}
10351041
}
10361042

@@ -1359,7 +1365,7 @@ <h4 style="margin-bottom:0.5rem;">${g.title}</h4>
13591365
body: JSON.stringify({ value })
13601366
});
13611367
} catch (e) {
1362-
showAutoMsg(`Failed to write ${displayName}: ${e.message}`);
1368+
showAutoMsg(`Error setting value for ${displayName}: ${e.message}`);
13631369
return;
13641370
}
13651371
}
@@ -1379,7 +1385,7 @@ <h4 style="margin-bottom:0.5rem;">${g.title}</h4>
13791385
}
13801386
}
13811387
}
1382-
showAutoMsg('Values written' + (minutes ? ` and automations disabled for ${minutes} min` : '') + '.');
1388+
showAutoMsg('Values set' + (minutes ? ` and automations disabled for ${minutes} min` : '') + '.');
13831389
await loadAutomationState();
13841390
await loadActiveAutomationValues();
13851391
renderAutomations();

‎collector/automation.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,10 @@
5656
HOLDING_BY_NAME,
5757
HOLDING_REGISTERS,
5858
MODBUS_READ_HOLD,
59+
MODBUS_WRITE_MULTI,
5960
MODBUS_WRITE_SINGLE,
6061
build_read_request,
62+
build_write_multi_request,
6163
build_write_request,
6264
find_frames,
6365
)
@@ -139,6 +141,62 @@ def _write_holding_register(
139141
pass
140142

141143

144+
def _write_holding_registers(
145+
host: str,
146+
port: int,
147+
datalog_serial: str,
148+
inverter_serial: str,
149+
start_register: int,
150+
values: List[int],
151+
timeout: float = 10.0,
152+
) -> Tuple[bool, str]:
153+
"""Send a WriteMultipleRegisters (0x10) request and verify the echo.
154+
155+
Used by the 7-day scheduling block (registers 500-723), which the inverter
156+
only accepts via multi-register writes (no single-register writes).
157+
"""
158+
req = build_write_multi_request(
159+
datalog_serial, inverter_serial, start_register, values
160+
)
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})"
189+
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
198+
199+
142200
def _read_holding_register(
143201
host: str,
144202
port: int,

‎collector/protocol.py‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,7 @@ class LuxFrame:
439439
inverter_serial: str = ""
440440
register: int = 0
441441
values: list[int] = field(default_factory=list)
442+
write_count: int = 0
442443
is_error: bool = False
443444
error_code: int = 0
444445

@@ -629,6 +630,66 @@ def build_write_request(
629630
return bytes(pkt)
630631

631632

633+
def build_write_multi_request(
634+
datalog_serial: str,
635+
inverter_serial: str,
636+
start_register: int,
637+
values: list[int],
638+
) -> bytes:
639+
"""
640+
Build a WriteMultipleRegisters (0x10) request packet.
641+
642+
Modbus data frame layout:
643+
addr(1) + func(1) + inv_serial(10) + start_reg(2) + count(2)
644+
+ byte_count(1) + values(N*2) + crc(2)
645+
646+
The outer LuxPower packet is variable-length: the data length prefix
647+
(bytes 18-19) and the frame length (bytes 4-5) both grow with the number
648+
of registers being written.
649+
650+
Used by the 7-day scheduling block (registers 500-723), which the
651+
inverter only accepts via multi-register writes (no single writes).
652+
"""
653+
if not values:
654+
raise ValueError("WriteMultipleRegisters requires at least one value")
655+
if len(values) > 123:
656+
raise ValueError(f"Too many registers for one write: {len(values)} (max 123)")
657+
for v in values:
658+
if not (0 <= v <= 0xFFFF):
659+
raise ValueError(f"Modbus register value out of range: {v}")
660+
661+
n = len(values)
662+
# Modbus data frame: addr(1)+func(1)+serial(10)+reg(2)+count(2)+bytecount(1)+values(2n)+crc(2)
663+
data_len = 1 + 1 + 10 + 2 + 2 + 1 + (2 * n) + 2
664+
# Outer packet: header(18) + datalen(2) + data(data_len)
665+
total = 18 + 2 + data_len
666+
frame_len = total - 6
667+
668+
pkt = bytearray(total)
669+
pkt[0:2] = PREFIX
670+
struct.pack_into('<H', pkt, 2, 1) # protocol
671+
struct.pack_into('<H', pkt, 4, frame_len) # frame length
672+
pkt[6] = 0x01
673+
pkt[7] = TCP_FUNC_TRANSLATED_DATA
674+
pkt[8:18] = _serial_to_bytes(datalog_serial)
675+
struct.pack_into('<H', pkt, 18, data_len) # data length
676+
677+
pkt[20] = 0x00 # address
678+
pkt[21] = MODBUS_WRITE_MULTI # function 0x10
679+
pkt[22:32] = _serial_to_bytes(inverter_serial)
680+
struct.pack_into('<H', pkt, 32, start_register)
681+
struct.pack_into('<H', pkt, 34, n) # register count
682+
pkt[36] = 2 * n # byte count
683+
for i, v in enumerate(values):
684+
struct.pack_into('<H', pkt, 37 + i * 2, v)
685+
686+
crc_start = 20
687+
crc_end = 37 + 2 * n
688+
crc = crc16_modbus(bytes(pkt[crc_start:crc_end]))
689+
struct.pack_into('<H', pkt, crc_end, crc)
690+
return bytes(pkt)
691+
692+
632693
# ── Frame Parser ────────────────────────────────────────────────────
633694

634695
def parse_frame(data: bytes) -> Optional[LuxFrame]:
@@ -712,6 +773,14 @@ def _parse_data_frame(data: bytes, frame: LuxFrame) -> None:
712773
if offset + 2 <= len(data) - 2: # -2 for CRC
713774
val = struct.unpack_from('<H', data, offset)[0]
714775
frame.values.append(val)
776+
elif frame.device_function == MODBUS_WRITE_MULTI:
777+
# Write-multiple (0x10) echo: addr + func + serial + start_reg(2)
778+
# + count(2) + crc. It echoes the start register and count, NOT the
779+
# values. We store the count in a dedicated field so the caller can
780+
# confirm the write was accepted.
781+
if len(data) >= reg_start + 4 + 2: # reg + count + crc
782+
frame.register = struct.unpack_from('<H', data, reg_start)[0]
783+
frame.write_count = struct.unpack_from('<H', data, reg_start + 2)[0]
715784

716785

717786
def find_frames(data: bytes) -> list[LuxFrame]:

0 commit comments

Comments
 (0)