#!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.11" # dependencies = [ # "pyserial>=3.5", # "paho-mqtt>=2.0", # "pyyaml>=6.0", # ] # /// """ eg4-battery — telemetry bridge from EG4 LifePower4 v2 BMSes to MQTT/HA. Three modes, selected via `bus.mode` in the config: modbus_per_pack — RECOMMENDED. One FTDI RS-485 adapter per pack. Each pack has its own (port, address, baud) in the `packs:` list. Uses Modbus RTU fn=0x03 read-47-regs at 0x0000. Decoder extracts named fields (pack V, 16 cell voltages, temps, SoC, SoH, Capacity, warnings, protections) — register map reverse-engineered from the EG4 `lv_host.app` BMS Tool's SQLite schema + UI labels. active — LEGACY. Single FTDI adapter on a dedicated bus, EG4 7E/0D protocol at 9600 baud. Was ported from the V1 firmware via `battery/eg4_lifepower.py`; V2 hardware doesn't speak this protocol in practice. Kept for reference / possible V1 deployments. passive — LEGACY. Listen-only Modbus-RTU sniffer at 19200 baud. Originally targeted the LVX6048 BMS bus; LVX6048 doesn't poll EG4 packs that way, so the mode is diagnostic only. Usage: eg4-battery -C eg4-battery -C --dry-run # mock bus, print, exit eg4-battery -C --trace # log every frame """ from __future__ import annotations import argparse import asyncio import dataclasses import json import logging import random import struct import sys import time from pathlib import Path from struct import unpack_from from typing import Any, Iterator import paho.mqtt.client as mqtt import serial import yaml log = logging.getLogger("eg4-battery") # ============================================================================= # === config ================================================================== # ============================================================================= @dataclasses.dataclass class PackConfig: name: str # HA entity prefix / device identifier (e.g. "lifepower4_1") address: int # protocol-level address (Modbus slave ID, or EG4 7E address) port: str | None = None # per-pack port (modbus_per_pack mode only) baud: int | None = None # per-pack baud override (modbus_per_pack mode only) @dataclasses.dataclass class BusConfig: mode: str # "modbus_per_pack" | "active" | "passive" transport: str = "serial" # "serial" | "mock" port: str = "" # shared port (active / passive modes) baud: int = 9600 read_chunk: int = 512 timeout_s: float = 1.5 # per-query timeout poll_interval_s: float = 10.0 # full round-robin cycle target @dataclasses.dataclass class MQTTConfig: host: str port: int username: str password: str discovery_prefix: str = "homeassistant" @dataclasses.dataclass class AppConfig: bus: BusConfig mqtt: MQTTConfig packs: list[PackConfig] cell_count: int = 16 # active mode only def load_config(path: Path) -> AppConfig: raw = yaml.safe_load(path.read_text()) return AppConfig( bus=BusConfig(**raw["bus"]), mqtt=MQTTConfig(**raw["mqtt"]), packs=[PackConfig(**p) for p in raw["packs"]], cell_count=raw.get("cell_count", 16), ) # ============================================================================= # === active mode: EG4 7E/0D protocol ========================================= # ============================================================================= # Verified against `battery/eg4_lifepower.py`. Frame: # request (6 bytes): 7E 00 0D # chk = (0x100 - (addr + cmd + len)) & 0xFF # reply (variable): 7E [10 groups] 0D # each group: CMD_GENERAL_STATUS = 0x01 # cells, V, I, SoC, cap, temps, cycles, alarms CMD_FW_VER = 0x33 CMD_HW_VER = 0x42 def encode_eg4_request(address: int, cmd: int, length: int = 0) -> bytes: chk = (0x100 - (address + cmd + length)) & 0xFF return bytes([0x7E, address, cmd, length, chk, 0x0D]) def decode_eg4_general_status(data: bytes, cell_count: int) -> dict[str, Any]: """Decode a fn=0x01 reply into a flat dict keyed for HA. Mirrors `battery/eg4_lifepower.py::parse_status`. Permissive framing check (header/footer); upstream doesn't validate the reply CRC and neither do we until we know the algorithm.""" if not data or len(data) < 6 or data[0] != 0x7E or data[-1] != 0x0D: raise ValueError(f"bad framing: {data.hex(' ')[:120]}") groups: list[list[int]] = [] i = 4 # skip 7E for _ in range(10): if i + 2 > len(data): raise ValueError(f"truncated payload at group {len(groups)}") group_len = data[i + 1] end = i + 2 + group_len * 2 if end > len(data): raise ValueError(f"group {len(groups)} overruns frame (end={end}, len={len(data)})") payload = data[i + 2:end] groups.append([unpack_from(">H", payload, k)[0] for k in range(0, len(payload), 2)]) i = end out: dict[str, Any] = {} # group 0 — cell voltages (mV; mask 0x7FFF per upstream — top bit is some flag) cells = [(v & 0x7FFF) / 1000.0 for v in groups[0][:cell_count]] for idx, cv in enumerate(cells, start=1): out[f"cell_{idx:02d}_voltage"] = round(cv, 3) if cells: vmin, vmax = min(cells), max(cells) out["cell_voltage_min"] = round(vmin, 3) out["cell_voltage_max"] = round(vmax, 3) out["cell_voltage_delta_mv"] = round((vmax - vmin) * 1000) out["cell_lowest"] = cells.index(vmin) + 1 out["cell_highest"] = cells.index(vmax) + 1 # group 1 — current (signed; encoded as 30000 - A×100; positive = charge) if groups[1]: out["current"] = round((30000 - groups[1][0]) / 100.0, 2) # group 2 — SoC × 100 if groups[2]: out["soc"] = round(groups[2][0] / 100.0, 1) # group 3 — capacity (Ah × 100) if groups[3]: out["capacity_ah"] = round(groups[3][0] / 100.0, 2) # group 4 — temperatures (low byte − 50 °C) for idx, raw in enumerate(groups[4][:6], start=1): out[f"temperature_{idx}"] = (raw & 0xFF) - 50 # group 5 — alarm bitfield (second word per upstream) flags = groups[5][1] if len(groups[5]) > 1 else 0 out["alarm_current_over"] = "on" if flags & 0b00001000 else "off" out["alarm_voltage_high"] = "on" if flags & 0b00010000 else "off" out["alarm_voltage_low"] = "on" if flags & 0b00100000 else "off" out["alarm_temp_high_chg"] = "on" if flags & 0b01000000 else "off" out["alarm_temp_low_chg"] = "on" if flags & 0b10000000 else "off" # group 6 — cycle count if groups[6]: out["cycle_count"] = groups[6][0] # group 7 — pack voltage (V × 100) if groups[7]: out["pack_voltage"] = round(groups[7][0] / 100.0, 2) # groups 8-9 — undecoded; leave as future work return out # ============================================================================= # === passive mode: Modbus RTU framing ======================================== # ============================================================================= def crc16_modbus(data: bytes) -> int: crc = 0xFFFF for b in data: crc ^= b for _ in range(8): if crc & 1: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 return crc def _crc_ok(buf: bytes, start: int, length: int) -> bool: if start + length > len(buf): return False body = buf[start:start + length - 2] expected = buf[start + length - 2] | (buf[start + length - 1] << 8) return crc16_modbus(body) == expected _MODBUS_FUNCS = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x0F, 0x10, 0x16, 0x17} def parse_modbus_frame_at(buf: bytes, start: int) -> tuple[int, str] | None: if start + 4 > len(buf): return None func = buf[start + 1] # exception response (5 bytes), only for legitimate function codes if func >= 0x80 and (func & 0x7F) in _MODBUS_FUNCS \ and start + 5 <= len(buf) and 1 <= buf[start + 2] <= 11 \ and _crc_ok(buf, start, 5): return (5, "exception") if func == 0x03: # query: 8 bytes if _crc_ok(buf, start, 8): return (8, "query") # response: 1 + 1 + 1 + byte_count + 2 if start + 3 <= len(buf): byte_count = buf[start + 2] if 2 <= byte_count <= 250 and byte_count % 2 == 0: total = 3 + byte_count + 2 if _crc_ok(buf, start, total): return (total, "response") return None @dataclasses.dataclass class ModbusFrame: address: int function: int kind: str raw: bytes @property def registers(self) -> list[int]: if self.kind != "response" or self.function != 0x03: return [] bc = self.raw[2] d = self.raw[3:3 + bc] return [(d[i] << 8) | d[i + 1] for i in range(0, len(d), 2)] def decode_modbus_response(frame: ModbusFrame) -> dict[str, Any]: """Raw-register dump; promote to named fields once we know the layout.""" return {f"register_{i:02d}": v for i, v in enumerate(frame.registers)} # ---- modbus_per_pack active-poll decoder (EG4 LP4V2) ----------------------- # Register map derived from lv_host.app BMS Tool SQLite schema + UI labels + # live probing of a single pack. See ../NOTES.md "Register map" section. # High-confidence fields promoted to named entities; unknowns (reg 32, 35, # 38-40, 43-45) still emitted as register_NN for correlation. _WARNING_BITS = [ "pack_ov", "cell_ov", "pack_uv", "cell_uv", "charge_oc", "discharge_oc", "temp_anomaly", "mos_ot", "charge_ot", "discharge_ot", "charge_ut", "discharge_ut", "low_capacity", "other_error", ] _PROTECTION_BITS = [ "pack_ov", "cell_ov", "pack_uv", "cell_uv", "charge_oc", "discharge_oc", "temp_anomaly", "mos_ot", "charge_ot", "discharge_ot", "charge_ut", "discharge_ut", "float_stopped", "discharge_sc", ] def _signed16(v: int) -> int: return v - 0x10000 if v & 0x8000 else v def decode_eg4_modbus_regs(regs: list[int]) -> dict[str, Any]: """Decode the 47-reg read-holding-regs response from an LP4V2 BMS. Emits named HA entities where meaning is known; raw register_NN passthrough for the rest.""" out: dict[str, Any] = {} # always emit raw registers — invaluable for future refinement for i, v in enumerate(regs): out[f"register_{i:02d}"] = v if len(regs) < 47: return out # --- pack-level V / I (regs 0, 1) --- out["pack_voltage"] = round(regs[0] / 100.0, 2) out["pack_current"] = round(_signed16(regs[1]) / 100.0, 2) # --- 16 cell voltages (regs 2-17), mV --- cells_v = [regs[2 + i] / 1000.0 for i in range(16)] for i, cv in enumerate(cells_v, start=1): out[f"cell_{i:02d}_voltage"] = round(cv, 3) vmin, vmax = min(cells_v), max(cells_v) out["cell_voltage_min"] = round(vmin, 3) out["cell_voltage_max"] = round(vmax, 3) out["cell_voltage_delta_mv"] = round((vmax - vmin) * 1000) out["cell_lowest"] = cells_v.index(vmin) + 1 out["cell_highest"] = cells_v.index(vmax) + 1 # --- temperatures (regs 18-21 = Temp_01..04, reg 24 = Temp_PCB) --- out["temperature_01"] = regs[18] out["temperature_02"] = regs[19] out["temperature_03"] = regs[20] out["temperature_04"] = regs[21] out["temperature_pcb"] = regs[24] # --- SoC / SoH (regs 22, 23) --- out["soc"] = regs[22] out["soh"] = regs[23] # --- heater / status (regs 25-30) --- # reg 30 has been observed = 1 on a healthy pack; treat as binary out["heater"] = "on" if regs[30] & 0x01 else "off" # --- max charge/discharge current limit (reg 31), A --- out["max_current_limit"] = round(regs[31] / 100.0, 2) # --- bitfields: warnings (reg 33), protections (reg 34), error code (reg 35) --- warn = regs[33] for i, name in enumerate(_WARNING_BITS): out[f"warning_{name}"] = "on" if (warn >> i) & 1 else "off" prot = regs[34] for i, name in enumerate(_PROTECTION_BITS): out[f"protection_{name}"] = "on" if (prot >> i) & 1 else "off" out["error_code"] = regs[35] # --- static-ish (regs 36, 37) --- out["cell_count"] = regs[36] out["capacity_ah"] = round(regs[37] / 10.0, 1) out["remaining_ah"] = round(regs[38] / 100.0, 2) out["cycle_count"] = regs[39] out["battery_mode"] = regs[40] # BMS firmware version — regs 41 & 42 appear to hold version codes; emit # the raw u16s alongside a decimal representation for easier HA display out["bms_version_hi"] = regs[41] out["bms_version_lo"] = regs[42] # reg 46 increments ~1.25 Hz on live bus — likely uptime in deciseconds out["uptime_ds"] = regs[46] 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.""" READ_START = 0x0000 READ_COUNT = 47 def __init__(self, port: str, baud: int, address: int, timeout_s: float = 1.0): self._port_path = port self._baud = baud self._address = address self._timeout_s = timeout_s self._ser: serial.Serial | None = None def _open(self) -> None: if self._ser is None or not self._ser.is_open: self._ser = serial.Serial( port=self._port_path, baudrate=self._baud, timeout=0.2, bytesize=8, parity="N", stopbits=1, ) def poll(self) -> list[int]: self._open() body = bytes([self._address, 0x03, self.READ_START >> 8, self.READ_START & 0xFF, self.READ_COUNT >> 8, self.READ_COUNT & 0xFF]) crc = crc16_modbus(body) frame = body + bytes([crc & 0xFF, crc >> 8]) assert self._ser is not None self._ser.reset_input_buffer() self._ser.write(frame) expected = 3 + self.READ_COUNT * 2 + 2 # addr + func + bc + data + crc buf = bytearray() deadline = time.monotonic() + self._timeout_s while time.monotonic() < deadline and len(buf) < expected: chunk = self._ser.read(expected - len(buf)) if chunk: buf.extend(chunk) raw = bytes(buf) log.debug("pack 0x%02x tx=%s rx=%s", self._address, 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)") bc = raw[2] if len(raw) < 3 + bc + 2: raise RuntimeError(f"truncated response ({len(raw)} B, expected {3 + bc + 2})") if not _crc_ok(raw, 0, 3 + bc + 2): raise RuntimeError("CRC mismatch") data = raw[3:3 + bc] return [(data[i] << 8) | data[i + 1] for i in range(0, len(data), 2)] def close(self) -> None: if self._ser is not None and self._ser.is_open: self._ser.close() # ============================================================================= # === transports ============================================================== # ============================================================================= # Two abstractions; main loop picks the right one based on bus.mode. class ActiveTransport: """Request-response transport for active mode.""" def query_general(self, address: int) -> bytes: raise NotImplementedError def close(self) -> None: pass class PassiveListener: """Continuous frame-iterator for passive mode.""" def frames(self) -> Iterator[ModbusFrame]: raise NotImplementedError def close(self) -> None: pass # --- active: serial + mock -------------------------------------------------- class SerialActiveTransport(ActiveTransport): def __init__(self, port: str, baud: int, timeout_s: float): self._timeout_s = timeout_s self._ser = serial.Serial(port=port, baudrate=baud, timeout=0.25, bytesize=8, parity="N", stopbits=1) def query_general(self, address: int) -> bytes: frame = encode_eg4_request(address, CMD_GENERAL_STATUS) log.debug("TX addr=0x%02x: %s", address, frame.hex()) self._ser.reset_input_buffer() self._ser.write(frame) buf = bytearray() deadline = time.monotonic() + self._timeout_s while time.monotonic() < deadline: chunk = self._ser.read(256) if chunk: buf.extend(chunk) if buf[0:1] == b"\x7E" and buf.endswith(b"\x0D"): break log.debug("RX addr=0x%02x: %s", address, bytes(buf).hex()) return bytes(buf) def close(self) -> None: self._ser.close() class MockActiveTransport(ActiveTransport): """Synthesise EG4 7E/0D replies. Values drift per call so HA dashboards look alive in dry-run mode.""" def __init__(self, cell_count: int = 16): self._cell_count = cell_count self._call = 0 def query_general(self, address: int) -> bytes: self._call += 1 rng = random.Random(address * 1000 + self._call) base_mv = 3280 + rng.randint(-5, 5) cells_mv = [max(0, min(0x7FFF, base_mv + rng.randint(-8, 8))) for _ in range(self._cell_count)] current_x100 = rng.randint(-500, 2000) current_raw = 30000 - current_x100 soc_x100 = (50 + rng.randint(-2, 2)) * 100 cap_ah_x100 = 5000 + rng.randint(-10, 10) temps_raw = [50 + 25 + rng.randint(-3, 3) for _ in range(4)] cycles = 42 + address pack_v_x100 = round(sum(cells_mv) / 10) def grp(gid: int, values: list[int]) -> bytes: return bytes([gid, len(values)]) + b"".join( struct.pack(">H", v & 0xFFFF) for v in values ) body = b"".join([ grp(0x01, cells_mv), grp(0x02, [current_raw]), grp(0x03, [soc_x100]), grp(0x04, [cap_ah_x100]), grp(0x05, temps_raw), grp(0x06, [0, 0]), # alarms = clear grp(0x07, [cycles]), grp(0x08, [pack_v_x100]), grp(0x09, []), grp(0x0A, []), ]) # checksum byte tolerated as 0x00 by the upstream parser return bytes([0x7E, address, CMD_GENERAL_STATUS, len(body) & 0xFF]) \ + body + bytes([0x00, 0x0D]) # --- passive: serial + mock ------------------------------------------------- class SerialPassiveListener(PassiveListener): _BUF_MAX = 4096 def __init__(self, port: str, baud: int, read_chunk: int = 512): self._read_chunk = read_chunk self._ser = serial.Serial(port=port, baudrate=baud, timeout=0.1, bytesize=8, parity="N", stopbits=1) self._buf = bytearray() def frames(self) -> Iterator[ModbusFrame]: while True: chunk = self._ser.read(self._read_chunk) if chunk: self._buf.extend(chunk) if len(self._buf) > self._BUF_MAX: del self._buf[:self._BUF_MAX // 2] yield from self._extract() def _extract(self) -> Iterator[ModbusFrame]: i = 0 while i < len(self._buf) - 4: r = parse_modbus_frame_at(self._buf, i) if r is None: i += 1 continue length, kind = r raw = bytes(self._buf[i:i + length]) yield ModbusFrame(address=raw[0], function=raw[1], kind=kind, raw=raw) del self._buf[:i + length] i = 0 def close(self) -> None: self._ser.close() class MockPassiveListener(PassiveListener): def __init__(self, packs: list[PackConfig], gap_s: float = 0.5): self._packs = packs self._gap_s = gap_s self._tick = 0 def frames(self) -> Iterator[ModbusFrame]: while True: for pack in self._packs: self._tick += 1 q = self._build_query(pack.address) yield ModbusFrame(address=pack.address, function=0x03, kind="query", raw=q) time.sleep(0.05) r = self._build_response(pack.address) yield ModbusFrame(address=pack.address, function=0x03, kind="response", raw=r) time.sleep(self._gap_s) def _build_query(self, addr: int) -> bytes: body = bytes([addr, 0x03, 0x00, 0x00, 0x00, 0x2F]) crc = crc16_modbus(body) return body + bytes([crc & 0xFF, crc >> 8]) def _build_response(self, addr: int) -> bytes: rng = random.Random(addr * 1000 + self._tick) regs = [3280 + rng.randint(-5, 5) for _ in range(16)] regs += [round(52.48 * 100), 50_00, rng.randint(0, 100)] while len(regs) < 47: regs.append(rng.randint(0, 100)) body = bytes([addr, 0x03, len(regs) * 2]) + b"".join( struct.pack(">H", r & 0xFFFF) for r in regs ) crc = crc16_modbus(body) return body + bytes([crc & 0xFF, crc >> 8]) # ============================================================================= # === MQTT publisher (HA auto-discovery) ====================================== # ============================================================================= # Field metadata. Active and passive modes emit different keys; both sets # coexist here without overlap. _FIELD_META: dict[str, tuple[str | None, str | None, str | None, str | None]] = { # active mode (EG4 7E/0D decoded) "pack_voltage": ("V", "voltage", "measurement", "mdi:battery-outline"), "current": ("A", "current", "measurement", "mdi:current-dc"), "soc": ("%", "battery", "measurement", "mdi:battery-70"), "capacity_ah": ("Ah", None, "measurement", "mdi:battery-clock"), "cycle_count": (None, None, "total", "mdi:counter"), "cell_voltage_min": ("V", "voltage", "measurement", "mdi:arrow-down-bold"), "cell_voltage_max": ("V", "voltage", "measurement", "mdi:arrow-up-bold"), "cell_voltage_delta_mv": ("mV", None, "measurement", "mdi:sine-wave"), "cell_lowest": (None, None, "measurement", "mdi:numeric"), "cell_highest": (None, None, "measurement", "mdi:numeric"), "alarm_current_over": (None, None, None, "mdi:alert-octagon"), "alarm_voltage_high": (None, None, None, "mdi:alert"), "alarm_voltage_low": (None, None, None, "mdi:alert"), "alarm_temp_high_chg": (None, None, None, "mdi:thermometer-alert"), "alarm_temp_low_chg": (None, None, None, "mdi:thermometer-alert"), } for _i in range(1, 33): _FIELD_META[f"cell_{_i:02d}_voltage"] = ("V", "voltage", "measurement", "mdi:battery-outline") for _i in range(1, 7): _FIELD_META[f"temperature_{_i}"] = ("°C", "temperature", "measurement", "mdi:thermometer") # modbus_per_pack named fields (EG4 register map) _FIELD_META.update({ "pack_current": ("A", "current", "measurement", "mdi:current-dc"), "temperature_01": ("°C", "temperature", "measurement", "mdi:thermometer"), "temperature_02": ("°C", "temperature", "measurement", "mdi:thermometer"), "temperature_03": ("°C", "temperature", "measurement", "mdi:thermometer"), "temperature_04": ("°C", "temperature", "measurement", "mdi:thermometer"), "temperature_pcb": ("°C", "temperature", "measurement", "mdi:chip"), "heater": (None, None, None, "mdi:heating-coil"), "max_current_limit": ("A", "current", "measurement", "mdi:current-dc"), "error_code": (None, None, None, "mdi:alert-octagon"), "cell_count": (None, None, "measurement", "mdi:numeric"), "remaining_ah": ("Ah", None, "measurement", "mdi:battery-clock"), "battery_mode": (None, None, None, "mdi:state-machine"), "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"), }) for _name in _WARNING_BITS: _FIELD_META[f"warning_{_name}"] = (None, None, None, "mdi:alert") for _name in _PROTECTION_BITS: _FIELD_META[f"protection_{_name}"] = (None, None, None, "mdi:shield-alert") def field_meta(key: str) -> tuple[str | None, str | None, str | None, str | None]: if key.startswith("register_"): return (None, None, "measurement", "mdi:numeric") return _FIELD_META.get(key, (None, None, None, None)) class MQTTPublisher: def __init__(self, cfg: MQTTConfig, dry_run: bool = False): self._cfg = cfg self._dry_run = dry_run self._client: mqtt.Client | None = None self._discovered: set[tuple[str, str]] = set() if not dry_run: c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="eg4-battery") c.username_pw_set(cfg.username, cfg.password) c.connect(cfg.host, cfg.port, keepalive=60) c.loop_start() self._client = c log.info("connected to MQTT %s:%d", cfg.host, cfg.port) def publish_pack(self, pack_name: str, readings: dict[str, Any]) -> None: for key, value in readings.items(): self._publish_one(pack_name, key, value) def _publish_one(self, pack_name: str, key: str, value: Any) -> None: entity_id = f"{pack_name}_{key}" state_topic = f"{self._cfg.discovery_prefix}/sensor/{entity_id}/state" disco_key = (pack_name, key) if disco_key not in self._discovered: self._publish_discovery(pack_name, key, state_topic) self._discovered.add(disco_key) payload = json.dumps(value) if isinstance(value, (dict, list)) else str(value) if self._dry_run: print(f" {state_topic} {payload}") else: self._client.publish(state_topic, payload, qos=0, retain=False) def _publish_discovery(self, pack_name: str, key: str, state_topic: str) -> None: unit, device_class, state_class, icon = field_meta(key) cfg = { "name": f"{pack_name} {key}", "state_topic": state_topic, "unique_id": f"{pack_name}_{key}_eg4", "device": { "name": f"EG4 LifePower4 {pack_name}", "identifiers": [pack_name], "model": "LifePower4 48V 100Ah v2 Auto-Addressing", "manufacturer": "EG4 Electronics", }, } if unit is not None: cfg["unit_of_measurement"] = unit if device_class is not None: cfg["device_class"] = device_class if state_class is not None: cfg["state_class"] = state_class if icon is not None: cfg["icon"] = icon topic = f"{self._cfg.discovery_prefix}/sensor/{pack_name}_{key}/config" payload = json.dumps(cfg) if self._dry_run: print(f" [discovery] {topic} {payload}") else: self._client.publish(topic, payload, qos=0, retain=True) def close(self) -> None: if self._client is not None: self._client.loop_stop() self._client.disconnect() # ============================================================================= # === per-pack state & rate-limited logging =================================== # ============================================================================= @dataclasses.dataclass class _PackState: ok: bool = False last_error_category: str = "" consecutive_errors: int = 0 response_count: int = 0 first_seen_logged: bool = False _FAIL_HEARTBEAT_CYCLES = 360 # re-log a stuck failure every ~hour at 10 s cadence def _resolve_pack_name(addr: int, packs: list[PackConfig]) -> str: for p in packs: if p.address == addr: return p.name return f"lifepower4_addr_{addr:02x}" # ============================================================================= # === main loops ============================================================== # ============================================================================= def run_active(transport: ActiveTransport, publisher: MQTTPublisher, cfg: AppConfig, states: dict[str, _PackState], one_cycle: bool = False) -> None: """Round-robin poll every configured pack; rate-limit error noise.""" while True: cycle_start = time.monotonic() for pack in cfg.packs: st = states.setdefault(pack.name, _PackState()) try: raw = transport.query_general(pack.address) if not raw: raise RuntimeError(f"empty response from addr=0x{pack.address:02x}") readings = decode_eg4_general_status(raw, cell_count=cfg.cell_count) publisher.publish_pack(pack.name, readings) st.response_count += 1 if not st.ok and st.consecutive_errors > 0: log.info("pack %s (0x%02x): recovered after %d failed cycle(s)", pack.name, pack.address, st.consecutive_errors) st.ok = True st.consecutive_errors = 0 except Exception as e: category = f"{type(e).__name__}:{str(e).split(':', 1)[0]}" if st.ok or category != st.last_error_category: log.warning("pack %s (0x%02x): %s", pack.name, pack.address, e) elif st.consecutive_errors > 0 and st.consecutive_errors % _FAIL_HEARTBEAT_CYCLES == 0: log.warning("pack %s (0x%02x): still failing (%d cycles): %s", pack.name, pack.address, st.consecutive_errors, e) st.ok = False st.last_error_category = category st.consecutive_errors += 1 if one_cycle: return elapsed = time.monotonic() - cycle_start time.sleep(max(0.0, cfg.bus.poll_interval_s - elapsed)) def run_passive(listener: PassiveListener, publisher: MQTTPublisher, cfg: AppConfig, trace: bool, max_frames: int | None = None) -> None: """Consume frames as they arrive; publish on every fn=0x03 response.""" states: dict[int, _PackState] = {} seen_unconfigured: set[int] = set() configured = {p.address for p in cfg.packs} n = 0 for frame in listener.frames(): n += 1 if trace: log.debug("%r raw=%s", frame, frame.raw.hex(" ")) if frame.kind != "response" or frame.function != 0x03: if max_frames is not None and n >= max_frames: return continue st = states.setdefault(frame.address, _PackState()) st.response_count += 1 if not st.first_seen_logged: if frame.address in configured: log.info("first response from configured pack 0x%02x (%s)", frame.address, _resolve_pack_name(frame.address, cfg.packs)) elif frame.address not in seen_unconfigured: log.warning("response from unconfigured slave 0x%02x — auto-naming as %s", frame.address, _resolve_pack_name(frame.address, cfg.packs)) seen_unconfigured.add(frame.address) st.first_seen_logged = True try: readings = decode_modbus_response(frame) except Exception as e: log.warning("decode failed for addr 0x%02x: %s (raw=%s)", frame.address, e, frame.raw.hex(" ")) continue publisher.publish_pack(_resolve_pack_name(frame.address, cfg.packs), readings) if max_frames is not None and n >= max_frames: return def run_modbus_per_pack(cfg: AppConfig, publisher: MQTTPublisher, states: dict[str, _PackState], one_cycle: bool = False, dry_run: bool = False) -> None: """One adapter per pack. Each `PackConfig` must have `port` and `baud` set. Round-robin poll every pack on its own serial port; decode Modbus response into named HA entities + raw register_NN dump.""" pollers: dict[str, ModbusActivePoller] = {} mock_regs_call: dict[str, int] = {} def make_poller(p: PackConfig) -> ModbusActivePoller | None: if dry_run: return None # mock path, no real poller if not p.port: log.warning("pack %s: no `port` set in config; skipping", p.name) return None baud = p.baud or cfg.bus.baud try: return ModbusActivePoller(p.port, baud, p.address, cfg.bus.timeout_s) except Exception as e: log.warning("pack %s: could not open %s: %s", p.name, p.port, e) return None for p in cfg.packs: pl = make_poller(p) if pl is not None: pollers[p.name] = pl try: while True: cycle_start = time.monotonic() for p in cfg.packs: st = states.setdefault(p.name, _PackState()) try: if dry_run: mock_regs_call[p.name] = mock_regs_call.get(p.name, 0) + 1 regs = _mock_modbus_regs(p.address, mock_regs_call[p.name]) else: if p.name not in pollers: raise RuntimeError(f"no poller configured for {p.name}") regs = pollers[p.name].poll() readings = decode_eg4_modbus_regs(regs) publisher.publish_pack(p.name, readings) st.response_count += 1 if not st.ok and st.consecutive_errors > 0: log.info("pack %s: recovered after %d failed cycle(s)", p.name, st.consecutive_errors) st.ok = True st.consecutive_errors = 0 except Exception as e: category = f"{type(e).__name__}:{str(e).split(':', 1)[0]}" if st.ok or category != st.last_error_category: log.warning("pack %s (0x%02x): %s", p.name, p.address, e) elif st.consecutive_errors > 0 \ and st.consecutive_errors % _FAIL_HEARTBEAT_CYCLES == 0: log.warning("pack %s (0x%02x): still failing (%d cycles): %s", p.name, p.address, st.consecutive_errors, e) st.ok = False st.last_error_category = category st.consecutive_errors += 1 if one_cycle: return elapsed = time.monotonic() - cycle_start time.sleep(max(0.0, cfg.bus.poll_interval_s - elapsed)) finally: for pl in pollers.values(): pl.close() def _mock_modbus_regs(address: int, tick: int) -> list[int]: """Synthesise 47 realistic-looking registers for dry-run mode.""" 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[0] = sum(cells_mv) // 10 # pack voltage × 100 regs[1] = (30000 - rng.randint(-500, 2000)) & 0xFFFF # current (×100 biased) for i, mv in enumerate(cells_mv, start=2): regs[i] = mv regs[18] = 21 + rng.randint(-1, 1) regs[19] = 21 + rng.randint(-1, 1) regs[20] = 20 + rng.randint(-1, 1) regs[21] = 54 + rng.randint(-1, 1) regs[22] = 100 regs[23] = 100 regs[24] = 55 regs[30] = 1 regs[31] = 5493 regs[32] = 10752 regs[33] = 0 # no warnings regs[34] = 0 # no protections regs[35] = 0 # error code regs[36] = 16 # cell count regs[37] = 1000 # 100.0 Ah regs[46] = (tick * 5) & 0xFFFF # runtime counter return regs def main() -> int: ap = argparse.ArgumentParser( description="EG4 LifePower4 v2 → MQTT bridge.") ap.add_argument("-C", "--config", required=True, type=Path) ap.add_argument("--dry-run", action="store_true", help="Mock-bus smoke test — one cycle, print, exit.") ap.add_argument("--trace", action="store_true", help="Log every frame.") args = ap.parse_args() logging.basicConfig( level=logging.DEBUG if args.trace else logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) cfg = load_config(args.config) valid_modes = {"modbus_per_pack", "active", "passive"} if cfg.bus.mode not in valid_modes: raise SystemExit(f"bus.mode must be one of {valid_modes}, got {cfg.bus.mode!r}") if cfg.bus.transport not in {"serial", "mock"}: raise SystemExit(f"bus.transport must be 'serial' or 'mock', got {cfg.bus.transport!r}") publisher = MQTTPublisher(cfg.mqtt, dry_run=args.dry_run) log.info("eg4-battery starting: mode=%s %d configured pack(s)", cfg.bus.mode, len(cfg.packs)) use_mock = args.dry_run or cfg.bus.transport == "mock" try: if cfg.bus.mode == "modbus_per_pack": run_modbus_per_pack(cfg, publisher, states={}, one_cycle=args.dry_run, dry_run=args.dry_run) elif cfg.bus.mode == "active": transport: ActiveTransport transport = (MockActiveTransport(cell_count=cfg.cell_count) if use_mock else SerialActiveTransport(cfg.bus.port, cfg.bus.baud, cfg.bus.timeout_s)) try: run_active(transport, publisher, cfg, states={}, one_cycle=args.dry_run) finally: transport.close() else: # passive listener: PassiveListener listener = (MockPassiveListener(cfg.packs) if use_mock else SerialPassiveListener(cfg.bus.port, cfg.bus.baud, cfg.bus.read_chunk)) try: run_passive(listener, publisher, cfg, trace=args.trace, max_frames=(2 * len(cfg.packs) if args.dry_run else None)) finally: listener.close() return 0 except KeyboardInterrupt: return 0 finally: publisher.close() if __name__ == "__main__": sys.exit(main())