12 KiB
EG4 LifePower4 v2 — architecture & protocol notes
Modes — choose per deployment
bus.mode in the config picks one of three daemon modes:
| Mode | Wire protocol | Baud | Role | Status |
|---|---|---|---|---|
modbus_per_pack |
Modbus RTU, fn 0x03 | 9600 | master (per pack) | Primary path for LP4V2 Auto-Addressing. One FTDI per pack's RS485 port, polls once per cycle, fully decodes into named HA entities. |
active |
EG4 7E/0D legacy | 9600 | master (shared) | Legacy. V1 firmware only. V2 packs don't respond to this protocol — kept for reference. |
passive |
Modbus RTU sniff | 19200 | listener | Diagnostic. Originally intended to listen on an LVX6048 BMS bus; the LVX6048 doesn't poll EG4 packs that way, so has no production use here. |
How we got here (summary)
- Port matrix test (2026-04-24). Established that the LP4V2 back panel's four RJ45s (CAN / RS485 / Comm1 / Comm2) carry three distinct electrical buses:
- Comm1 + Comm2 = inter-pack hub bus (19200 Modbus, master-to-slave coordination). Pin 1-2 and pin 7-8 both tap the same bus.
- RS485 = external monitor bus. Inactive until an external master drives it.
- CAN = separate bus, not in scope.
- EG4 BMS Tool capture. User confirmed the stock Windows/macOS BMS Tool connects to the RS485 port at 9600 baud, Modbus slave ID 0x40. Our first canonical Modbus probe at that address returned a clean 99-byte reply.
lv_host.appreverse-engineering. The BMS Tool is a Qt app. Its Mach-O binary contains:- SQLite schema for the
totaltable → complete list of 39 fields the BMS Tool stores per cycle. - String-table names for warning / protection bit flags.
- C++ symbols
BmsDatalog::allFunctionModbusAnalysis,BmsMonitoring::allFunctionModbusAnalysis,usModbusAskRegBaseetc. → confirms Modbus RTU fn 0x03 read-holding-regs.
- SQLite schema for the
- Register map construction. Correlated live register values (cell voltages, pack V) against the SQL field list to derive the 47-reg map below. High-confidence fields promoted to named HA entities; unknowns still emitted as
register_NNfor future correlation.
Modbus polling — two reads per cycle
Reverse-engineered from lv_host.app's MainWindow::vsSerialSend: the
BMS Tool uses three distinct Modbus query frames (cases in a jump table),
two of which our daemon now mirrors:
| Block | Modbus frame | What it covers |
|---|---|---|
| 1 | fn=03 start=0x0000 count=39 |
regs 0-38 — live status (V, I, cells, temps, alarms) |
| 2 | fn=03 start=0x002D count=91 |
regs 45-135 — counters at 45-46, model + FW strings at 105-123 |
| 0 (skipped) | fn=03 start=0x0069 count=48 |
regs 105-152 — overlaps block 2's range; no new content |
Block 2 is sparse (regs 47-104 and 124-135 read as zero on a healthy idle pack), but it's where the model string and firmware version+date live, encoded as ASCII u16 words. We do both reads every cycle; combined they take ~150 ms per pack at 9600 baud.
There are also two dynamic query frames (cases 3 & 4 in the BMS Tool)
built at runtime from object state. We haven't analyzed those yet —
they're presumably involved in writing config / fetching the unique pack
serial number (BMS_ISDN). Outstanding work.
Register map (block 1 + block 2)
From live observation + lv_host.app schema:
| Reg | Observed | Field | Scale | Unit | HA entity suffix |
|---|---|---|---|---|---|
| 00 | 5256 | Total_Voltage | × 0.01 | V | pack_voltage |
| 01 | 0 (signed) | Current_I | × 0.01 | A | pack_current |
| 02-17 | ~3285 each | Vol_Cell01..16 | × 0.001 | V | cell_01_voltage..cell_16_voltage + cell_voltage_min/max/delta_mv/lowest/highest |
| 18 | 21 | Temp_01 | × 1 | °C | temperature_01 |
| 19 | 21 | Temp_02 | × 1 | °C | temperature_02 |
| 20 | 20 | Temp_03 | × 1 | °C | temperature_03 |
| 21 | 54 | Temp_04 | × 1 | °C | temperature_04 |
| 22 | 100 | SOC | × 1 | % | soc |
| 23 | 100 | SOH | × 1 | % | soh |
| 24 | 55 | Temp_PCB | × 1 | °C | temperature_pcb |
| 25-29 | 0 | reserved | (register_NN only) | ||
| 30 | 1 | Heater (bit 0) | heater (on/off) |
||
| 31 | 5493 | MAX_Curren | × 0.01 | A | max_current_limit |
| 32 | 10752 | ? | (register_32) | ||
| 33 | ? | Warning bitfield | warning_* (14 bits) |
||
| 34 | ? | Protection bitfield | protection_* (14 bits) |
||
| 35 | 0 | Error_Code | error_code |
||
| 36 | 16 | Cell_Num | cell_count |
||
| 37 | 1000 | Capacity | × 0.1 | Ah | capacity_ah |
| 38 | 0 | Remaining | × 0.01 | Ah | remaining_ah |
| 39 | 0 | CycleNum | cycle_count |
||
| 40 | 7 | Battery_Mode (enum) | battery_mode |
||
| 41 | 0x0fff | BMS_Version (hi) | bms_version_hi |
||
| 42 | 0x07ff | BMS_Version (lo) | bms_version_lo |
||
| 43-45 | — | ? | (register_NN only) | ||
| 46 | +1.25 Hz | runtime counter | × 0.1 s? | uptime_ds |
|
| 47-104 | 0 | sparse / unused | (raw register_NN) |
||
| 105-114 | ASCII | model string | model (e.g. "LFP-51.2V100Ah-V1.0") |
||
| 115-116 | 0 | padding | (raw) | ||
| 117-119 | ASCII | firmware version | firmware_version ("Z03T21") |
||
| 120-123 | ASCII | firmware build date | firmware_date ("YYYYMMDD") |
||
| 124-135 | 0 | unused | (raw) |
Confidence levels: Bold-worthy certain (confirmed by live values + UI labels): pack_voltage, cells 01-16, SOC, SOH, cell_count, capacity_ah. Medium (fits data, unverified): temps, current, bitfields, Battery_Mode. Unknown: regs 32, 35 (probably Error_Code but value always 0 so far), 38-40, 43-45.
Warning / protection bit maps
From the UI labels in lv_host.app (bit 0 = first listed):
| Bit | Warning (reg 33) | Protection (reg 34) |
|---|---|---|
| 0 | pack_ov | pack_ov |
| 1 | cell_ov | cell_ov |
| 2 | pack_uv | pack_uv |
| 3 | cell_uv | cell_uv |
| 4 | charge_oc | charge_oc |
| 5 | discharge_oc | discharge_oc |
| 6 | temp_anomaly | temp_anomaly |
| 7 | mos_ot | mos_ot |
| 8 | charge_ot | charge_ot |
| 9 | discharge_ot | discharge_ot |
| 10 | charge_ut | charge_ut |
| 11 | discharge_ut | discharge_ut |
| 12 | low_capacity | float_stopped |
| 13 | other_error | discharge_sc |
Each bit becomes an HA sensor reporting on / off. Exact bit ordering is guessed from UI display order — adjust if the EG4 tool ever shows a flag we don't match.
Hardware topology notes
Critical: RS485 port only works when the pack is standalone
Empirical finding (2026-04-24): the LP4V2's external RS485 port
only answers Modbus queries when the pack is not daisy-chained to
other packs via Comm1/Comm2. Specifically:
- With daisy chains intact (bat1 Comm2 → bat2 Comm1 etc.), one pack elects as master and polls slaves over the internal hub bus (19200 Modbus on Comm1/Comm2). Slave packs' RS485 ports go silent — only the master responds externally.
- Remove the inter-pack Comm jumpers and each pack becomes a self-contained master: its Comm1/Comm2 LEDs flash (it's trying to poll slaves that aren't there), and its own RS485 port becomes fully live for external queries.
Implication: modbus_per_pack mode requires each pack standalone
— one FTDI adapter per pack's RS485 port, no inter-pack Comm jumpers.
This is how we got bat1 responding cleanly. If the batteries later
need to be daisy-chained to an inverter, only the master pack's RS485
port will answer external queries; slave per-pack data would need to
come from decoding the Comm1/Comm2 hub bus instead (a future mode).
Port roles on the LP4V2 pack (from the port matrix test)
| Port | Role | Pins carrying signal | Protocol / baud |
|---|---|---|---|
| CAN | Inverter CAN comms | (CAN-specific pinout) | CANbus (not RS-485) |
| RS485 | External monitor | 1-2 | Modbus RTU @ 9600 |
| Comm1 | Inter-pack hub bus (in/out) | 1-2 and 7-8 both tap it | Modbus RTU @ 19200 |
| Comm2 | Inter-pack hub bus (in/out) | 1-2 and 7-8 both tap it | Modbus RTU @ 19200 |
- The stock USB-RS485 cable ships wired to pins 1-2 — usable on either Comm or RS485.
- The pin 7-8 modified cable only gains us the Comm/Comm2 hub-bus tap; since pins 1-2 reach the same bus, it's not strictly necessary. Kept in the toolkit for diagnostic purposes.
- Factory inter-pack jumpers (between packs) are 8-conductor CAT5 — they carry both pin pairs.
Adapters
On this host, three USB-FTDI adapters are plugged into the three packs' RS485 ports:
| Adapter ID | Pack | /dev/serial/by-id/... |
|---|---|---|
| A994XMVK | bat1 (RS485) | usb-FTDI_FT232R_USB_UART_A994XMVK-if00-port0 |
| A994XGUY | bat2 (RS485) | usb-FTDI_FT232R_USB_UART_A994XGUY-if00-port0 |
| A994XMBR | bat3 (RS485) | usb-FTDI_FT232R_USB_UART_A994XMBR-if00-port0 |
Each pack gets polled on its own bus → no shared-bus arbitration, no master/slave coordination needed, pack Modbus address is 0x40 for all of them.
LVX6048 compatibility (still true)
LVX6048 BMS port protocols: PYL (Pylontech), LIb (MPP LIO), WEC (WECO), SOL (Soltaro), VSC (Pylontech-CAN), USE (voltage-only). No native EG4 LP4V2 support. For inverter↔battery comms, set P05/P14 = USE and manage charge profile via lvx-flash. See DIY Solar Forum threads 67496 & 96019, LVX6048WP manual §9-2.
Bring-up checklist (when a new pack goes live)
- Wire: plug USB-FTDI adapter (stock pin-1-2 cable) into the pack's RS485 port.
- Confirm the pack's BMS is powered (LEDs steady on Comm1 + Comm2, not dark).
- Verify the
/dev/serial/by-id/...symlink exists for the adapter. - Add a pack entry to the config:
packs: - name: lifepower4_N address: 0x40 port: /dev/serial/by-id/usb-FTDI_...-if00-port0 baud: 9600 sudo systemctl restart eg4-battery.service. Watch journal — within ~10 s you should see the first MQTT publish, orWARNING: no/bad responseif the pack isn't answering.- In HA:
EG4 LifePower4 lifepower4_Ndevice appears with ~65 auto-discovered entities.
Sources / references
lv_host.app(Qt) — Contents/MacOS/lv_host Mach-O binary + my1.db/my2.db SQLite schemas../battery/eg4_lifepower.py— V1 7E/0D decoder (Louisvdw/dbus-serialbattery port), historical reference../battery/sweep.py— protocol + baud scanner used for initial triage- EG4 "Cables Needed for Updating" PDF
- EG4 Community Forum: "Specs for LifePower4 V2 BAT-COM ports"
- LVX6048WP manual §9-2 (BMS pinout), §Programs P03/P05