Files
shaggy-solar/eg4battery/bin/eg4-battery
2026-04-24 16:34:10 -04:00

970 lines
39 KiB
Plaintext
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <config.yaml>
eg4-battery -C <config.yaml> --dry-run # mock bus, print, exit
eg4-battery -C <config.yaml> --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 <addr> <cmd> 00 <chk> 0D
# chk = (0x100 - (addr + cmd + len)) & 0xFF
# reply (variable): 7E <addr> <cmd> <len> [10 groups] <chk> 0D
# each group: <type_byte> <count> <count × big-endian uint16>
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 <addr> <cmd> <len>
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())