Files
shaggy-solar/eg4battery/NOTES.md
2026-04-25 19:00:44 -04:00

12 KiB
Raw Permalink Blame History

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)

  1. 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.
  2. 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.
  3. lv_host.app reverse-engineering. The BMS Tool is a Qt app. Its Mach-O binary contains:
    • SQLite schema for the total table → 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, usModbusAskRegBase etc. → confirms Modbus RTU fn 0x03 read-holding-regs.
  4. 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_NN for 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)

  1. Wire: plug USB-FTDI adapter (stock pin-1-2 cable) into the pack's RS485 port.
  2. Confirm the pack's BMS is powered (LEDs steady on Comm1 + Comm2, not dark).
  3. Verify the /dev/serial/by-id/... symlink exists for the adapter.
  4. Add a pack entry to the config:
    packs:
      - name: lifepower4_N
        address: 0x40
        port: /dev/serial/by-id/usb-FTDI_...-if00-port0
        baud: 9600
    
  5. sudo systemctl restart eg4-battery.service. Watch journal — within ~10 s you should see the first MQTT publish, or WARNING: no/bad response if the pack isn't answering.
  6. In HA: EG4 LifePower4 lifepower4_N device 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