3xbatt-2xinverter

This commit is contained in:
2026-04-25 19:00:44 -04:00
parent 9aca623336
commit 396e810895
8 changed files with 421 additions and 33 deletions

View File

@@ -356,17 +356,52 @@ def decode_eg4_modbus_regs(regs: list[int]) -> dict[str, Any]:
# reg 46 increments ~1.25 Hz on live bus — likely uptime in deciseconds
out["uptime_ds"] = regs[46]
# --- block-2 strings (regs 105..123) — fetched on the second Modbus read ---
if len(regs) >= 124:
out["model"] = _ascii_from_regs(regs, 105, 10) # 20 chars
out["firmware_version"] = _ascii_from_regs(regs, 117, 3) # 6 chars (e.g. "Z03T21")
out["firmware_date"] = _ascii_from_regs(regs, 120, 4) # 8 chars (e.g. "20260206")
return out
class ModbusActivePoller:
"""One instance per pack. Opens its own serial port, issues a single
read-holding-regs fn=0x03 on every `poll()` call, returns raw registers
(or raises). Graceful: a pack whose port doesn't exist or whose BMS is
off will raise on poll, and main loop catches + rate-limits the noise."""
def _ascii_from_regs(regs: list[int], start: int, count_regs: int) -> str:
"""Convert `count_regs` u16 values into an ASCII string (high byte first
per Modbus convention). Trailing nulls and non-printable trailing junk
are stripped."""
chars: list[str] = []
for r in regs[start:start + count_regs]:
for ch in ((r >> 8) & 0xFF, r & 0xFF):
if ch == 0:
break
if 32 <= ch < 127:
chars.append(chr(ch))
else:
continue
break
return "".join(chars).rstrip()
READ_START = 0x0000
READ_COUNT = 47
class ModbusActivePoller:
"""One instance per pack. Opens its own serial port, issues two
read-holding-regs fn=0x03 queries per `poll()` call, and returns a
sparse 136-register list (indices 0-38 from the first read, 45-135
from the second, gap at 39-44 zero-padded).
The two reads mirror what lv_host.app's BMS Tool issues in its
monitoring loop:
block 1 — count=39 @ 0 (live status)
block 2 — count=91 @ 0x2d (counters + model + firmware strings)
Graceful: a pack whose port doesn't exist or whose BMS is off will
raise on poll, and the main loop catches + rate-limits the noise."""
BLOCK_1_START = 0x0000
BLOCK_1_COUNT = 39
BLOCK_2_START = 0x002D # = 45
BLOCK_2_COUNT = 91 # covers regs 45..135
TOTAL_REG_COUNT = BLOCK_2_START + BLOCK_2_COUNT # 136
def __init__(self, port: str, baud: int, address: int, timeout_s: float = 1.0):
self._port_path = port
@@ -382,11 +417,9 @@ class ModbusActivePoller:
bytesize=8, parity="N", stopbits=1,
)
def poll(self) -> list[int]:
self._open()
def _read_block(self, start: int, count: int) -> list[int]:
body = bytes([self._address, 0x03,
self.READ_START >> 8, self.READ_START & 0xFF,
self.READ_COUNT >> 8, self.READ_COUNT & 0xFF])
start >> 8, start & 0xFF, count >> 8, count & 0xFF])
crc = crc16_modbus(body)
frame = body + bytes([crc & 0xFF, crc >> 8])
@@ -394,7 +427,7 @@ class ModbusActivePoller:
self._ser.reset_input_buffer()
self._ser.write(frame)
expected = 3 + self.READ_COUNT * 2 + 2 # addr + func + bc + data + crc
expected = 3 + count * 2 + 2
buf = bytearray()
deadline = time.monotonic() + self._timeout_s
while time.monotonic() < deadline and len(buf) < expected:
@@ -402,18 +435,33 @@ class ModbusActivePoller:
if chunk:
buf.extend(chunk)
raw = bytes(buf)
log.debug("pack 0x%02x tx=%s rx=%s", self._address, frame.hex(" "), raw.hex(" "))
log.debug("pack 0x%02x [%d@%d] tx=%s rx=%s",
self._address, count, start, frame.hex(" "), raw.hex(" "))
if len(raw) < 5 or raw[0] != self._address or raw[1] != 0x03:
raise RuntimeError(f"no/bad response ({len(raw)} B)")
raise RuntimeError(f"no/bad response ({len(raw)} B) for read({count}@{start})")
bc = raw[2]
if len(raw) < 3 + bc + 2:
raise RuntimeError(f"truncated response ({len(raw)} B, expected {3 + bc + 2})")
raise RuntimeError(f"truncated response for read({count}@{start})")
if not _crc_ok(raw, 0, 3 + bc + 2):
raise RuntimeError("CRC mismatch")
raise RuntimeError(f"CRC mismatch for read({count}@{start})")
data = raw[3:3 + bc]
return [(data[i] << 8) | data[i + 1] for i in range(0, len(data), 2)]
def poll(self) -> list[int]:
self._open()
# Build a sparse 136-element register array.
regs = [0] * self.TOTAL_REG_COUNT
block1 = self._read_block(self.BLOCK_1_START, self.BLOCK_1_COUNT)
for i, v in enumerate(block1):
regs[self.BLOCK_1_START + i] = v
# Brief gap between queries — RS-485 silence + give BMS a moment
time.sleep(0.05)
block2 = self._read_block(self.BLOCK_2_START, self.BLOCK_2_COUNT)
for i, v in enumerate(block2):
regs[self.BLOCK_2_START + i] = v
return regs
def close(self) -> None:
if self._ser is not None and self._ser.is_open:
self._ser.close()
@@ -637,6 +685,9 @@ _FIELD_META.update({
"bms_version_hi": (None, None, None, "mdi:chip"),
"bms_version_lo": (None, None, None, "mdi:chip"),
"uptime_ds": (None, None, "total_increasing", "mdi:timer-outline"),
"model": (None, None, None, "mdi:battery-outline"),
"firmware_version": (None, None, None, "mdi:chip"),
"firmware_date": (None, None, None, "mdi:calendar"),
})
for _name in _WARNING_BITS:
_FIELD_META[f"warning_{_name}"] = (None, None, None, "mdi:alert")
@@ -882,13 +933,14 @@ def run_modbus_per_pack(cfg: AppConfig, publisher: MQTTPublisher,
def _mock_modbus_regs(address: int, tick: int) -> list[int]:
"""Synthesise 47 realistic-looking registers for dry-run mode."""
"""Synthesise 136 realistic-looking registers for dry-run mode (covers
both block 1 [0..38] and block 2 [45..135] reads of the live daemon)."""
rng = random.Random(address * 1000 + tick)
base_mv = 3280 + rng.randint(-3, 3)
cells_mv = [base_mv + rng.randint(-8, 8) for _ in range(16)]
regs: list[int] = [0] * 47
regs: list[int] = [0] * 136
regs[0] = sum(cells_mv) // 10 # pack voltage × 100
regs[1] = (30000 - rng.randint(-500, 2000)) & 0xFFFF # current (×100 biased)
regs[1] = (30000 - rng.randint(-500, 2000)) & 0xFFFF
for i, mv in enumerate(cells_mv, start=2):
regs[i] = mv
regs[18] = 21 + rng.randint(-1, 1)
@@ -907,6 +959,17 @@ def _mock_modbus_regs(address: int, tick: int) -> list[int]:
regs[36] = 16 # cell count
regs[37] = 1000 # 100.0 Ah
regs[46] = (tick * 5) & 0xFFFF # runtime counter
# block-2 strings, packed high-byte-first per Modbus convention
def _pack_str(target: list[int], offset: int, s: str) -> None:
for i in range(0, len(s), 2):
hi = ord(s[i])
lo = ord(s[i + 1]) if i + 1 < len(s) else 0
target[offset + i // 2] = (hi << 8) | lo
_pack_str(regs, 105, "LFP-51.2V100Ah-V1.0")
_pack_str(regs, 117, "Z03T21")
_pack_str(regs, 120, "20260206")
return regs