3xbatt-2xinverter
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user