# 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: ```yaml 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