initialize
This commit is contained in:
13
battery/.claude/settings.local.json
Normal file
13
battery/.claude/settings.local.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(strings -n 4 \"51.2V 100Ah LP4V2 Auto-Addressing RS485 Z03T21 Firmware.bin\")",
|
||||
"Read(//tmp/**)",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:eg4electronics.com)",
|
||||
"WebFetch(domain:diysolarforum.com)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)"
|
||||
]
|
||||
}
|
||||
}
|
||||
134
battery/eg4_lifepower.py
Normal file
134
battery/eg4_lifepower.py
Normal file
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Standalone EG4 LifePower4 (V1/V2) decoder over USB-RS485.
|
||||
|
||||
Adapted from Louisvdw/dbus-serialbattery (bms/lifepower.py) stripped of its
|
||||
Victron dbus dependencies. Pure pyserial + stdlib.
|
||||
|
||||
Frame format (observed on wire, not standard Modbus):
|
||||
request : 7E <addr> <cmd> 00 <chk> 0D (6 bytes)
|
||||
reply : 7E ... 0D (variable, status payload
|
||||
contains 10 groups of
|
||||
16-bit unsigned ints)
|
||||
|
||||
Verified commands: general status (01), firmware ver (33), hardware ver (42).
|
||||
Address byte is typically 0x01 for a single battery; multipack setups have the
|
||||
master at 0x01 and only the master answers external RS485.
|
||||
|
||||
Usage:
|
||||
python3 eg4_lifepower.py # auto-detect port
|
||||
python3 eg4_lifepower.py /dev/tty.usbserial-XXX # explicit port
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from struct import unpack_from
|
||||
|
||||
import serial # pip install pyserial
|
||||
|
||||
BAUD = 9600
|
||||
|
||||
CMD_GENERAL = bytes([0x7E, 0x01, 0x01, 0x00, 0xFE, 0x0D])
|
||||
CMD_HW_VER = bytes([0x7E, 0x01, 0x42, 0x00, 0xFC, 0x0D])
|
||||
CMD_FW_VER = bytes([0x7E, 0x01, 0x33, 0x00, 0xFE, 0x0D])
|
||||
|
||||
|
||||
@dataclass
|
||||
class Status:
|
||||
cell_voltages: list[float] = field(default_factory=list) # volts
|
||||
current: float = 0.0 # amps (signed, +charge)
|
||||
soc: float = 0.0 # percent
|
||||
capacity_ah: float = 0.0
|
||||
temps_c: list[int] = field(default_factory=list) # up to 6
|
||||
cycles: int = 0
|
||||
pack_voltage: float = 0.0
|
||||
alarms: dict = field(default_factory=dict)
|
||||
|
||||
def summary(self) -> str:
|
||||
cells = self.cell_voltages
|
||||
cmin, cmax = (min(cells), max(cells)) if cells else (0, 0)
|
||||
return (
|
||||
f"pack={self.pack_voltage:6.2f}V I={self.current:+7.2f}A "
|
||||
f"SoC={self.soc:5.1f}% cap={self.capacity_ah:6.2f}Ah "
|
||||
f"cells={len(cells)} ({cmin:.3f}..{cmax:.3f}V Δ={cmax-cmin:.3f}) "
|
||||
f"temps={self.temps_c} cycles={self.cycles} alarms={self.alarms}"
|
||||
)
|
||||
|
||||
|
||||
def send(port: serial.Serial, cmd: bytes, read_n: int = 256) -> bytes:
|
||||
port.reset_input_buffer()
|
||||
port.write(cmd)
|
||||
time.sleep(0.2)
|
||||
return port.read(read_n)
|
||||
|
||||
|
||||
def parse_status(data: bytes) -> Status:
|
||||
"""Parse the general-status reply. Raises ValueError on bad framing."""
|
||||
if not data or data[0] != 0x7E or data[-1] != 0x0D:
|
||||
raise ValueError(f"bad framing: {data.hex(' ')}")
|
||||
|
||||
groups: list[list[int]] = []
|
||||
i = 4 # skip 7E <addr> <cmd> <len> — payload starts at byte 4
|
||||
for _ in range(10):
|
||||
if i + 2 > len(data):
|
||||
raise ValueError("truncated payload")
|
||||
group_len = data[i + 1]
|
||||
end = i + 2 + (group_len * 2)
|
||||
payload = data[i + 2 : end]
|
||||
values = [unpack_from(">H", payload, k)[0] for k in range(0, len(payload), 2)]
|
||||
groups.append(values)
|
||||
i = end
|
||||
|
||||
s = Status()
|
||||
s.cell_voltages = [(v & 0x7FFF) / 1000 for v in groups[0]]
|
||||
s.current = (30000 - groups[1][0]) / 100
|
||||
s.soc = groups[2][0] / 100
|
||||
s.capacity_ah = groups[3][0] / 100
|
||||
s.temps_c = [(t & 0xFF) - 50 for t in groups[4][:6]]
|
||||
flags = groups[5][1] if len(groups[5]) > 1 else 0
|
||||
s.alarms = {
|
||||
"current_over": bool(flags & 0b00001000),
|
||||
"voltage_high": bool(flags & 0b00010000),
|
||||
"voltage_low": bool(flags & 0b00100000),
|
||||
"temp_high_chg": bool(flags & 0b01000000),
|
||||
"temp_low_chg": bool(flags & 0b10000000),
|
||||
}
|
||||
s.cycles = groups[6][0]
|
||||
s.pack_voltage = groups[7][0] / 100
|
||||
return s
|
||||
|
||||
|
||||
def decode_ascii(data: bytes) -> str:
|
||||
return data.decode("ascii", errors="ignore").strip()
|
||||
|
||||
|
||||
def autodetect() -> str | None:
|
||||
for pat in ("/dev/tty.usbserial*", "/dev/tty.usbmodem*", "/dev/ttyUSB*", "/dev/ttyACM*"):
|
||||
hits = glob.glob(pat)
|
||||
if hits:
|
||||
return hits[0]
|
||||
return None
|
||||
|
||||
|
||||
def main() -> None:
|
||||
port_path = sys.argv[1] if len(sys.argv) > 1 else autodetect()
|
||||
if not port_path:
|
||||
sys.exit("no serial port found; pass one explicitly")
|
||||
print(f"opening {port_path} @ {BAUD} 8N1")
|
||||
with serial.Serial(port_path, BAUD, bytesize=8, parity="N", stopbits=1, timeout=1.5) as p:
|
||||
hw = send(p, CMD_HW_VER)
|
||||
fw = send(p, CMD_FW_VER)
|
||||
if hw:
|
||||
print(f"hw: {decode_ascii(hw)}")
|
||||
if fw:
|
||||
print(f"fw: {decode_ascii(fw)}")
|
||||
raw = send(p, CMD_GENERAL)
|
||||
print(f"raw ({len(raw)}B): {raw.hex(' ')}")
|
||||
if raw:
|
||||
print(parse_status(raw).summary())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
64
battery/probe.py
Normal file
64
battery/probe.py
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sanity-check byte-level probe for EG4 LifePower4 V2 over USB-RS485.
|
||||
|
||||
Sends the 'general status' frame and prints the raw reply. If you see bytes
|
||||
starting with 0x7E coming back, the protocol is confirmed and eg4_lifepower.py
|
||||
will decode them.
|
||||
|
||||
Usage:
|
||||
python3 probe.py # auto-detect likely USB-serial port
|
||||
python3 probe.py /dev/tty.usbserial-XXX # explicit port
|
||||
python3 probe.py COM5 # Windows
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
import glob
|
||||
import serial # pip install pyserial
|
||||
|
||||
BAUD = 9600
|
||||
CMDS = {
|
||||
"general": bytes.fromhex("7E01010000FE0D"[:-2] + "0D"), # 7E 01 01 00 FE 0D
|
||||
"hw_ver": bytes.fromhex("7E01420000FC0D"[:-2] + "0D"), # 7E 01 42 00 FC 0D
|
||||
"fw_ver": bytes.fromhex("7E01330000FE0D"[:-2] + "0D"), # 7E 01 33 00 FE 0D
|
||||
}
|
||||
# (the [:-2]+"0D" dance above is just to keep the literal aligned with the
|
||||
# canonical 6-byte frames documented upstream; simpler form below)
|
||||
CMDS = {
|
||||
"general": bytes([0x7E, 0x01, 0x01, 0x00, 0xFE, 0x0D]),
|
||||
"hw_ver": bytes([0x7E, 0x01, 0x42, 0x00, 0xFC, 0x0D]),
|
||||
"fw_ver": bytes([0x7E, 0x01, 0x33, 0x00, 0xFE, 0x0D]),
|
||||
}
|
||||
|
||||
|
||||
def autodetect() -> str | None:
|
||||
candidates = (
|
||||
glob.glob("/dev/tty.usbserial*") # macOS FTDI/CH340
|
||||
+ glob.glob("/dev/tty.usbmodem*") # macOS CDC-ACM
|
||||
+ glob.glob("/dev/ttyUSB*") # Linux FTDI/CH340
|
||||
+ glob.glob("/dev/ttyACM*") # Linux CDC-ACM
|
||||
)
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
|
||||
def probe(port: str) -> None:
|
||||
print(f"opening {port} @ {BAUD} 8N1")
|
||||
with serial.Serial(port, BAUD, bytesize=8, parity="N", stopbits=1, timeout=1.5) as s:
|
||||
for name, cmd in CMDS.items():
|
||||
s.reset_input_buffer()
|
||||
s.write(cmd)
|
||||
time.sleep(0.2)
|
||||
reply = s.read(256)
|
||||
print(f"\n[{name}] sent {cmd.hex(' ')}")
|
||||
if reply:
|
||||
print(f" got ({len(reply)} bytes) {reply.hex(' ')}")
|
||||
if reply[0] == 0x7E and reply[-1] == 0x0D:
|
||||
print(" -> frame looks valid (7E ... 0D)")
|
||||
else:
|
||||
print(" got (nothing — timeout)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = sys.argv[1] if len(sys.argv) > 1 else autodetect()
|
||||
if not port:
|
||||
sys.exit("no serial port found; pass one explicitly: python3 probe.py /dev/tty.usbserial-XXXX")
|
||||
probe(port)
|
||||
1
battery/requirements.txt
Normal file
1
battery/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
pyserial>=3.5
|
||||
208
battery/sweep.py
Normal file
208
battery/sweep.py
Normal file
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Address sweep + protocol probe for EG4 LifePower4 over USB-RS485.
|
||||
|
||||
Tries three candidate protocols across a range of unit addresses:
|
||||
1. Legacy 7E/0D binary framing — addresses 0x00..0x10
|
||||
2. PACE BMS V25 (ASCII-hex inside 7E/0D) — addresses 0x01..0x10
|
||||
Used by Pylontech, Solark, Deye, Lux, Growatt, MegaRev, etc.
|
||||
3. Standard Modbus RTU (fn 0x03) — addresses 1..16, reads 39 holding regs
|
||||
|
||||
Any non-empty reply is printed raw. First byte tells you which protocol:
|
||||
- 7E + ASCII-hex payload = PACE V25
|
||||
- 7E + binary payload = legacy
|
||||
- matching addr byte + 0x03 = Modbus
|
||||
|
||||
Usage:
|
||||
python3 sweep.py # auto-detect port, 9600
|
||||
python3 sweep.py /dev/ttyUSB0 # explicit port
|
||||
python3 sweep.py /dev/ttyUSB0 19200 # explicit port + baud
|
||||
"""
|
||||
import glob
|
||||
import sys
|
||||
import time
|
||||
|
||||
import serial # pip install pyserial
|
||||
|
||||
DEFAULT_BAUD = 9600
|
||||
|
||||
|
||||
def autodetect() -> str | None:
|
||||
for pat in ("/dev/tty.usbserial*", "/dev/tty.usbmodem*", "/dev/ttyUSB*", "/dev/ttyACM*"):
|
||||
hits = glob.glob(pat)
|
||||
if hits:
|
||||
return hits[0]
|
||||
return None
|
||||
|
||||
|
||||
def frame_7e(addr: int, cmd: int = 0x01, length: int = 0x00) -> bytes:
|
||||
chk = (0x100 - (addr + cmd + length)) & 0xFF
|
||||
return bytes([0x7E, addr, cmd, length, chk, 0x0D])
|
||||
|
||||
|
||||
def crc16_modbus(data: bytes) -> int:
|
||||
crc = 0xFFFF
|
||||
for b in data:
|
||||
crc ^= b
|
||||
for _ in range(8):
|
||||
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
|
||||
return crc
|
||||
|
||||
|
||||
def frame_modbus(addr: int, func: int = 0x03, start: int = 0x0000, count: int = 39) -> bytes:
|
||||
body = bytes([addr, func, start >> 8, start & 0xFF, count >> 8, count & 0xFF])
|
||||
crc = crc16_modbus(body)
|
||||
return body + bytes([crc & 0xFF, crc >> 8]) # Modbus CRC is LSB-first on wire
|
||||
|
||||
|
||||
def _pace_lenid(n: int) -> int:
|
||||
"""V25 LENID = (lchecksum << 12) | (n & 0x0FFF); lchecksum per PACE spec."""
|
||||
s = ((n >> 8) & 0x0F) + ((n >> 4) & 0x0F) + (n & 0x0F)
|
||||
lchk = ((~s) + 1) & 0x0F
|
||||
return (lchk << 12) | (n & 0x0FFF)
|
||||
|
||||
|
||||
def frame_pace(addr: int, ver: int = 0x20, cid1: int = 0x4A, cid2: int = 0x42, info: bytes = b"") -> bytes:
|
||||
"""Build a PACE BMS request frame (ASCII-hex inside ~…\\r).
|
||||
|
||||
Defaults reproduce the EG4 LifePower4 V20 read-analog frame verified against
|
||||
nkinnan/esphome-pace-bms: ver=0x20 (V20), cid1=0x4A (EG4/Narada family),
|
||||
cid2=0x42 (read analog), empty INFO payload (EG4 variant — busId is *not*
|
||||
repeated in the payload, unlike generic PACE). Known-good at addr 1:
|
||||
~20014A420000FDA2\\r
|
||||
"""
|
||||
info_ascii = info.hex().upper().encode()
|
||||
lenid = _pace_lenid(len(info_ascii))
|
||||
body = f"{ver:02X}{addr:02X}{cid1:02X}{cid2:02X}{lenid:04X}".encode() + info_ascii
|
||||
chksum = ((-sum(body)) & 0xFFFF)
|
||||
return b"~" + body + f"{chksum:04X}".encode() + b"\r"
|
||||
|
||||
|
||||
def exchange(port: serial.Serial, tx: bytes, wait: float = 0.25, read_n: int = 256) -> bytes:
|
||||
port.reset_input_buffer()
|
||||
port.write(tx)
|
||||
time.sleep(wait)
|
||||
return port.read(read_n)
|
||||
|
||||
|
||||
def classify_7e(rx: bytes) -> str:
|
||||
if not rx:
|
||||
return ""
|
||||
if rx[0] == 0x7E and rx.endswith(b"\x0D"):
|
||||
return " <- 7E framed reply"
|
||||
return " <- unexpected bytes"
|
||||
|
||||
|
||||
def classify_pace(rx: bytes) -> str:
|
||||
if not rx:
|
||||
return ""
|
||||
if rx[0] == 0x7E and rx.endswith(b"\r"):
|
||||
# V25 payload is ASCII-hex between ~ and \r
|
||||
mid = rx[1:-1]
|
||||
if mid.isascii() and all(c in b"0123456789ABCDEFabcdef" for c in mid):
|
||||
return " <- PACE V25 reply"
|
||||
return " <- 7E-framed but non-ASCII (likely legacy, not V25)"
|
||||
return " <- unexpected bytes"
|
||||
|
||||
|
||||
def classify_modbus(rx: bytes, addr: int) -> str:
|
||||
if not rx:
|
||||
return ""
|
||||
if len(rx) >= 3 and rx[0] == addr and rx[1] == 0x03:
|
||||
return " <- Modbus reply"
|
||||
if len(rx) >= 3 and rx[0] == addr and rx[1] == 0x83:
|
||||
return f" <- Modbus exception (code {rx[2]:#04x})"
|
||||
return " <- unexpected bytes"
|
||||
|
||||
|
||||
COMMON_BAUDS = (9600, 19200, 38400, 57600, 115200)
|
||||
|
||||
|
||||
def passive_listen(port_path: str, seconds: int = 15) -> None:
|
||||
"""Open at each baud for N seconds and dump whatever the pack transmits
|
||||
unsolicited. If the pack is in broadcast mode (Pylontech CAN-style,
|
||||
Victron, some Growatt modes), we'll see frames arrive without asking.
|
||||
"""
|
||||
print(f"passive listen on {port_path} — {seconds}s per baud")
|
||||
for baud in COMMON_BAUDS:
|
||||
try:
|
||||
with serial.Serial(port_path, baud, bytesize=8, parity="N", stopbits=1, timeout=0.25) as p:
|
||||
p.reset_input_buffer()
|
||||
deadline = time.monotonic() + seconds
|
||||
buf = bytearray()
|
||||
while time.monotonic() < deadline:
|
||||
chunk = p.read(256)
|
||||
if chunk:
|
||||
buf.extend(chunk)
|
||||
ascii_preview = buf.decode("ascii", errors="replace") if buf else ""
|
||||
tag = " <- traffic!" if buf else ""
|
||||
print(f" @ {baud:6d} 8N1: {len(buf):4d}B{tag}")
|
||||
if buf:
|
||||
print(f" hex: {buf.hex(' ')}")
|
||||
print(f" ascii: {ascii_preview!r}")
|
||||
except serial.SerialException as e:
|
||||
print(f" @ {baud}: {e}")
|
||||
|
||||
|
||||
def quick_baud_scan(port_path: str) -> None:
|
||||
"""At addr 0x01, send one legacy + one V25 + one Modbus frame at each baud.
|
||||
Any non-empty reply means we've found the right baud + a protocol that the
|
||||
pack is willing to answer — then rerun the full sweep at that baud.
|
||||
"""
|
||||
print(f"opening {port_path} — scanning baud rates {COMMON_BAUDS}")
|
||||
for baud in COMMON_BAUDS:
|
||||
try:
|
||||
with serial.Serial(port_path, baud, bytesize=8, parity="N", stopbits=1, timeout=2.0) as p:
|
||||
print(f"\n @ {baud} 8N1:")
|
||||
for label, tx in (
|
||||
("legacy", frame_7e(0x01)),
|
||||
("pace", frame_pace(0x01)),
|
||||
("modbus", frame_modbus(0x01)),
|
||||
):
|
||||
rx = exchange(p, tx, wait=0.3)
|
||||
tag = ""
|
||||
if rx:
|
||||
tag = " <- REPLY!"
|
||||
print(f" {label:7s}: tx {tx.hex(' ')} rx ({len(rx):3d}B) {rx.hex(' ')}{tag}")
|
||||
except serial.SerialException as e:
|
||||
print(f" @ {baud}: {e}")
|
||||
|
||||
|
||||
def sweep(port_path: str, baud: int) -> None:
|
||||
print(f"opening {port_path} @ {baud} 8N1 (2s timeout)")
|
||||
with serial.Serial(port_path, baud, bytesize=8, parity="N", stopbits=1, timeout=2.0) as p:
|
||||
print("\n[7E protocol — general-status sweep]")
|
||||
for addr in range(0x00, 0x11):
|
||||
tx = frame_7e(addr)
|
||||
rx = exchange(p, tx)
|
||||
print(f" addr 0x{addr:02X}: tx {tx.hex(' ')} rx ({len(rx):3d}B) {rx.hex(' ')}{classify_7e(rx)}")
|
||||
|
||||
print("\n[PACE V20 (EG4/Narada variant) — read-analog-data sweep]")
|
||||
for addr in list(range(0x01, 0x11)) + [0xFF]: # include broadcast 0xFF
|
||||
tx = frame_pace(addr)
|
||||
rx = exchange(p, tx, wait=0.35)
|
||||
print(f" addr 0x{addr:02X}: tx {tx.decode('ascii', errors='replace').strip()!r} rx ({len(rx):3d}B) {rx.hex(' ')}{classify_pace(rx)}")
|
||||
|
||||
print("\n[Modbus RTU — read 39 holding regs sweep]")
|
||||
for addr in range(1, 17):
|
||||
tx = frame_modbus(addr)
|
||||
rx = exchange(p, tx)
|
||||
print(f" addr 0x{addr:02X}: tx {tx.hex(' ')} rx ({len(rx):3d}B) {rx.hex(' ')}{classify_modbus(rx, addr)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = sys.argv[1:]
|
||||
scan_only = "--scan" in args
|
||||
listen_only = "--listen" in args
|
||||
args = [a for a in args if a not in ("--scan", "--listen")]
|
||||
port = args[0] if args else autodetect()
|
||||
if not port:
|
||||
sys.exit("no serial port found; pass one explicitly")
|
||||
if listen_only:
|
||||
passive_listen(port)
|
||||
sys.exit(0)
|
||||
if scan_only or len(args) < 2:
|
||||
quick_baud_scan(port)
|
||||
if scan_only:
|
||||
sys.exit(0)
|
||||
baud = int(args[1]) if len(args) > 1 else DEFAULT_BAUD
|
||||
sweep(port, baud)
|
||||
Reference in New Issue
Block a user