Files

189 lines
12 KiB
Markdown
Raw Permalink Normal View History

2026-04-24 16:34:10 -04:00
# 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.
2026-04-25 19:00:44 -04:00
## 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)
2026-04-24 16:34:10 -04:00
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` |
2026-04-25 19:00:44 -04:00
| 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) |
2026-04-24 16:34:10 -04:00
**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