Skip to content

Commit e5a3ef9

Browse files
committed
Add EG4 A5/5A proprietary BMS RS-485 driver and register it
1 parent b2ed0c4 commit e5a3ef9

5 files changed

Lines changed: 596 additions & 0 deletions

File tree

‎collector/rs485/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ and RS-485 collection runs as a separate daemon.
1212

1313
| Driver | Device / protocol |
1414
|----------------|------------------------------------------------------|
15+
| `eg4_a5_bms` | EG4 battery BMS via proprietary A5/5A serial protocol |
16+
| `eg4_bms` | EG4 LL LiFePO4 battery BMS via Modbus RTU |
1517
| `jk_bms` | JiKong (JK) BMS over UART/RS-485 |
1618
| `modbus_rtu` | Generic Modbus RTU master |
1719
| `raw` | Listen-only hex dump for discovery / unknown devices |

‎collector/rs485/eg4_a5_bms.py‎

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
"""EG4 battery BMS driver via the proprietary A5/5A serial protocol.
2+
3+
This driver decodes the broadcast frames emitted by EG4 rack/wall-mount
4+
LiFePO4 batteries on their "PC ready" / RS-485 / display port. The frame
5+
format is:
6+
7+
a5 5a <length> <command> <subcommand> <payload...>
8+
9+
where <length> is the number of bytes following the length byte
10+
(i.e. command + subcommand + payload). Frames we care about:
11+
12+
command=0x82, subcommand=0x10 -> status frame
13+
command=0x82, subcommand=0x11 -> cell voltages frame
14+
15+
The BMS appears to broadcast these continuously when a master/display is
16+
connected, so the driver can operate in passive listen mode. It also sends a
17+
minimal poll probe to wake a quiet bus when needed.
18+
19+
Reference: reverse-engineered from live captures on an EG4 200Ah battery with
20+
a Silicon Labs CP2102 USB-to-UART bridge at 115200 8N1.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import logging
26+
import time
27+
from typing import Any, Dict, Optional, Tuple
28+
29+
from . import Rs485Device, Rs485DeviceConfig
30+
31+
logger = logging.getLogger("luxmon.rs485.eg4_a5_bms")
32+
33+
try:
34+
import serial
35+
except ImportError as exc: # pragma: no cover
36+
raise ImportError(
37+
"pyserial is required for RS-485 support. "
38+
"Install it with: pip install pyserial>=3.5"
39+
) from exc
40+
41+
42+
MAGIC = bytes([0xA5, 0x5A])
43+
44+
# Command/subcommand pairs
45+
CMD_STATUS = (0x82, 0x10)
46+
CMD_CELLS = (0x82, 0x11)
47+
CMD_BALANCING = (0x82, 0x13)
48+
49+
# Minimal poll probes used to wake a quiet BMS. Format:
50+
# a5 5a <len=5> 82 <sub=0x21> <index> 00 00
51+
_POLL_STATUS = bytes([0xA5, 0x5A, 0x05, 0x82, 0x21, 0x13, 0x00, 0x00])
52+
_POLL_CELLS = bytes([0xA5, 0x5A, 0x05, 0x82, 0x21, 0x23, 0x00, 0x00])
53+
54+
55+
def _split_frames(data: bytes) -> list[bytes]:
56+
"""Split a byte stream into A5/5A length-delimited frames."""
57+
frames: list[bytes] = []
58+
i = 0
59+
n = len(data)
60+
while i < n - 2:
61+
if data[i : i + 2] == MAGIC:
62+
length = data[i + 2]
63+
end = i + 3 + length
64+
if end <= n:
65+
frames.append(data[i:end])
66+
i = end
67+
continue
68+
i += 1
69+
return frames
70+
71+
72+
def _u16_be(data: bytes, offset: int) -> int:
73+
return (data[offset] << 8) | data[offset + 1]
74+
75+
76+
def _s16_be(data: bytes, offset: int) -> int:
77+
val = _u16_be(data, offset)
78+
if val >= 0x8000:
79+
val -= 0x10000
80+
return val
81+
82+
83+
class Eg4A5BmsDevice(Rs485Device):
84+
"""EG4 battery BMS using the A5/5A proprietary protocol."""
85+
86+
name = "eg4_a5_bms"
87+
label = "EG4 battery BMS (A5/5A protocol)"
88+
89+
def __init__(self, config: Rs485DeviceConfig):
90+
super().__init__(config)
91+
self._port: Optional[serial.Serial] = None
92+
self._latest: Dict[str, Any] = {}
93+
94+
def _open(self) -> serial.Serial:
95+
if self._port is not None and self._port.is_open:
96+
return self._port
97+
self._port = serial.Serial(
98+
port=self.cfg.port,
99+
baudrate=self.cfg.baudrate,
100+
bytesize=serial.EIGHTBITS,
101+
parity=serial.PARITY_NONE,
102+
stopbits=serial.STOPBITS_ONE,
103+
timeout=self.cfg.timeout,
104+
write_timeout=self.cfg.timeout,
105+
)
106+
logger.debug("Opened %s at %d baud", self.cfg.port, self.cfg.baudrate)
107+
return self._port
108+
109+
def close(self) -> None:
110+
if self._port is not None and self._port.is_open:
111+
try:
112+
self._port.close()
113+
except Exception:
114+
logger.exception("Error closing serial port")
115+
self._port = None
116+
117+
def read(self) -> Dict[str, Dict[str, Any]]:
118+
port = self._open()
119+
port.reset_input_buffer()
120+
121+
# Send a couple of poll probes to make sure a quiet BMS starts talking.
122+
try:
123+
port.write(_POLL_STATUS)
124+
time.sleep(0.05)
125+
port.write(_POLL_CELLS)
126+
except Exception:
127+
logger.exception("Failed to write poll probe")
128+
129+
# Listen for one timeout window; the BMS broadcasts continuously.
130+
deadline = time.time() + max(self.cfg.timeout, 0.5)
131+
chunks: list[bytes] = []
132+
while time.time() < deadline:
133+
chunk = port.read(2048)
134+
if chunk:
135+
chunks.append(chunk)
136+
else:
137+
# No more buffered data right now; if we already parsed
138+
# something useful we can stop early.
139+
if self._latest:
140+
break
141+
142+
if not chunks:
143+
return {}
144+
145+
raw = b"".join(chunks)
146+
frames = _split_frames(raw)
147+
if not frames:
148+
logger.debug("No A5/5A frames found in %d bytes", len(raw))
149+
return {}
150+
151+
for frame in frames:
152+
if len(frame) < 5:
153+
continue
154+
cmd, sub = frame[3], frame[4]
155+
payload = frame[5:]
156+
157+
if (cmd, sub) == CMD_STATUS:
158+
parsed = self._parse_status(payload)
159+
if parsed:
160+
self._latest.update(parsed)
161+
elif (cmd, sub) == CMD_CELLS:
162+
parsed = self._parse_cells(payload)
163+
if parsed:
164+
self._latest.update(parsed)
165+
166+
if not self._latest:
167+
return {}
168+
169+
result = self._build_result()
170+
logger.info(
171+
"EG4 A5 BMS: %.2f V, %.2f A, %.1f%% SOC, %d cells",
172+
result.get("voltage", {}).get("value", 0.0),
173+
result.get("current", {}).get("value", 0.0),
174+
result.get("soc", {}).get("value", 0.0),
175+
int(result.get("cell_count", {}).get("value", 0.0)),
176+
)
177+
return result
178+
179+
def _parse_status(self, payload: bytes) -> Optional[Dict[str, Any]]:
180+
"""Parse the 0x82 0x10 status payload.
181+
182+
Payload layout (35 bytes observed, big-endian 16-bit values after an
183+
initial zero/status byte):
184+
185+
offset 0 : unknown status byte (always 0x00 in captures)
186+
offset 1-2 : pack voltage * 0.01 V
187+
offset 3-4 : current * 0.1 A, signed (negative = discharge)
188+
offset 5-6 : temperature (likely MOSFET/PCB, °C)
189+
offset 7-8 : unknown / 0
190+
offset 9-10: unknown (possibly SOC or SOH; varies 85-86 in capture)
191+
offset 11-12: unknown (35 in capture)
192+
offset 13-14: unknown (36 in capture)
193+
offset 15-16: unknown / 0
194+
offset 17-18: average cell voltage, mV
195+
offset 19-34: status/protection bit fields (still being mapped)
196+
"""
197+
if len(payload) < 19:
198+
return None
199+
200+
result: Dict[str, Any] = {}
201+
result["voltage_raw"] = _u16_be(payload, 1)
202+
result["current_raw"] = _s16_be(payload, 3)
203+
result["voltage"] = round(result["voltage_raw"] * 0.01, 2)
204+
result["current"] = round(result["current_raw"] * 0.1, 2)
205+
result["power"] = round(result["voltage"] * result["current"], 2)
206+
207+
# Temperature candidate at offset 5
208+
result["temperature_pcb_raw"] = _u16_be(payload, 5)
209+
result["temperature_pcb"] = float(result["temperature_pcb_raw"])
210+
211+
# Other candidate numeric fields (exported raw for verification)
212+
result["field_7_8"] = _u16_be(payload, 7)
213+
result["field_9_10"] = _u16_be(payload, 9)
214+
result["field_11_12"] = _u16_be(payload, 11)
215+
result["field_13_14"] = _u16_be(payload, 13)
216+
result["field_15_16"] = _u16_be(payload, 15)
217+
result["avg_cell_voltage"] = _u16_be(payload, 17)
218+
219+
# Status/protection bits after the average cell voltage
220+
if len(payload) >= 21:
221+
result["status_word"] = _u16_be(payload, 19)
222+
if len(payload) >= 23:
223+
result["protection_word"] = _u16_be(payload, 21)
224+
if len(payload) >= 25:
225+
result["error_word"] = _u16_be(payload, 23)
226+
227+
return result
228+
229+
def _parse_cells(self, payload: bytes) -> Optional[Dict[str, Any]]:
230+
"""Parse the 0x82 0x11 cell-voltage payload.
231+
232+
Layout (57 bytes observed):
233+
offset 0 : unknown / 0x00
234+
offset 1-32: 16 cell voltages in mV (big-endian 16-bit each)
235+
offset 33-56: reserved / zero in captures
236+
"""
237+
if len(payload) < 33:
238+
return None
239+
240+
cell_voltages: list[int] = []
241+
min_v = 10000
242+
max_v = 0
243+
min_idx = 0
244+
max_idx = 0
245+
sum_v = 0
246+
247+
for i in range(16):
248+
mv = _u16_be(payload, 1 + i * 2)
249+
cell_voltages.append(mv)
250+
sum_v += mv
251+
if mv < min_v:
252+
min_v = mv
253+
min_idx = i + 1
254+
if mv > max_v:
255+
max_v = mv
256+
max_idx = i + 1
257+
258+
result: Dict[str, Any] = {}
259+
result["cell_count"] = 16
260+
for i, mv in enumerate(cell_voltages, start=1):
261+
result[f"cell_{i}_voltage"] = mv * 0.001
262+
result["cell_min_voltage"] = min_v * 0.001
263+
result["cell_max_voltage"] = max_v * 0.001
264+
result["cell_avg_voltage"] = round(sum_v / 16.0 * 0.001, 3)
265+
result["cell_delta_voltage"] = round((max_v - min_v) * 0.001, 3)
266+
result["cell_min_index"] = float(min_idx)
267+
result["cell_max_index"] = float(max_idx)
268+
return result
269+
270+
def _build_result(self) -> Dict[str, Dict[str, Any]]:
271+
"""Convert the internal flat dictionary to lux-mon snapshot shape."""
272+
out: Dict[str, Dict[str, Any]] = {}
273+
274+
mapping: Dict[str, Tuple[str, str]] = {
275+
"voltage": ("voltage", "V"),
276+
"current": ("current", "A"),
277+
"power": ("power", "W"),
278+
"temperature_pcb": ("temperature_pcb", "°C"),
279+
"avg_cell_voltage": ("avg_cell_voltage", "mV"),
280+
"cell_count": ("cell_count", ""),
281+
"cell_min_voltage": ("cell_min_voltage", "V"),
282+
"cell_max_voltage": ("cell_max_voltage", "V"),
283+
"cell_avg_voltage": ("cell_avg_voltage", "V"),
284+
"cell_delta_voltage": ("cell_delta_voltage", "V"),
285+
"cell_min_index": ("cell_min_index", ""),
286+
"cell_max_index": ("cell_max_index", ""),
287+
"status_word": ("status_word", ""),
288+
"protection_word": ("protection_word", ""),
289+
"error_word": ("error_word", ""),
290+
}
291+
292+
for key, (out_key, unit) in mapping.items():
293+
if key in self._latest:
294+
out[out_key] = {"value": float(self._latest[key]), "unit": unit}
295+
296+
for i in range(1, 17):
297+
key = f"cell_{i}_voltage"
298+
if key in self._latest:
299+
out[key] = {"value": float(self._latest[key]), "unit": "V"}
300+
301+
# Expose raw status/protection words as booleans for easy dashboards.
302+
if "status_word" in self._latest:
303+
word = int(self._latest["status_word"])
304+
out["charging"] = {"value": float(bool(word & 0x0001)), "unit": ""}
305+
out["discharging"] = {"value": float(bool(word & 0x0002)), "unit": ""}
306+
out["heating"] = {"value": float(bool(word & 0x8000)), "unit": ""}
307+
308+
if "protection_word" in self._latest:
309+
word = int(self._latest["protection_word"])
310+
out["protection_active"] = {"value": float(bool(word)), "unit": ""}
311+
312+
return out

0 commit comments

Comments
 (0)