Docs: reflect this session's findings across the repo
- top-level README.md (new): system overview, subsystem map, skills pointer, notable findings. - eg4battery README/NOTES: 3 -> 6 packs (pack 6 oddball 0x01/115200); SoC drift + calibration section; closed-loop comms evaluated and rejected (loses per-pack telemetry, no native protocol, doesn't fix drift); how to force a grid charge via output-priority SUB. - LVX6048 README: closed-loop pending item -> resolved decision; new "SoC calibration & known firmware quirks" section (POP single-digit/POP01, MCHGC charge-lock, re_discharge=re-discharge can't exceed float, PIRI lag, powermon adhoc wedge); skills pointer. - lvx-control README: POP encoding fix, POP crc-but-applies quirk, verify-by- behavior, grid-charge-via-SUB usage. - troubleshoot-inverter skill: corrected the stale "dead string per inverter" claim — both strings healthy; low PV is tilt/heat/shade/curtailment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -101,10 +101,15 @@ fallback when it didn't.
|
|||||||
```bash
|
```bash
|
||||||
"$SNAP" -w 12 -g 'lvx6048_[12]_(mppt1_input_power|mppt2_input_power|device_mode)/' 'homeassistant/sensor/+/state'
|
"$SNAP" -w 12 -g 'lvx6048_[12]_(mppt1_input_power|mppt2_input_power|device_mode)/' 'homeassistant/sensor/+/state'
|
||||||
```
|
```
|
||||||
Cross-reference array memory (`project_solar_array_config`): 9s2p per inverter, and
|
Cross-reference array memory (`project_solar_array_config`): single MPPT per inverter,
|
||||||
there's a **suspected dead string per inverter** — one MPPT reading ~half the other,
|
9s2p into it. **"Low" PV is mostly expected, not a fault** (verified 2026-06-24): at a
|
||||||
or roughly half nameplate in full sun, is consistent with that and worth confirming
|
clear-noon peak each inverter made ~16 A @ ~300 V — a down string would read ~10 A, so
|
||||||
at the combiner, not a software bug. Zero PV at night/heavy shade is normal.
|
both parallel strings are live. The ~66%-of-nameplate output is 45° tilt (wrong for
|
||||||
|
high summer sun), heat derate, tree shading, AND **demand-throttling** (at noon the
|
||||||
|
battery charge pins at its cap, so the array is curtailed — not weak). To judge the
|
||||||
|
array, measure on a clear noon with the bank *hungry* (uncurtailed): ~16 A @ 300 V then
|
||||||
|
is healthy. Zero PV at night/heavy shade is normal. Don't diagnose "low PV" off a
|
||||||
|
single off-peak or demand-limited sample.
|
||||||
|
|
||||||
## 6. Report
|
## 6. Report
|
||||||
State: which unit, mode, fault (decoded), cluster health, link state, what you
|
State: which unit, mode, fault (decoded), cluster health, link state, what you
|
||||||
|
|||||||
@@ -196,9 +196,42 @@ then restart the three services.
|
|||||||
PF) are implemented in `lvx-flash/flash.py` for offline use, but aren't
|
PF) are implemented in `lvx-flash/flash.py` for offline use, but aren't
|
||||||
exposed as HA button/select entities. Deferred until monitoring has been
|
exposed as HA button/select entities. Deferred until monitoring has been
|
||||||
stable for at least a week.
|
stable for at least a week.
|
||||||
- **Closed-loop BMS comms.** Currently open-loop — inverters estimate SoC
|
- **Closed-loop BMS comms — evaluated 2026-06, NOT pursuing.** Stays open-loop.
|
||||||
from voltage, batteries don't push real-time SoC / charge limits to the
|
Closed-loop would need the LP4V2 to emulate Pylontech-CAN (no native EG4
|
||||||
inverter. Closed-loop would give better SoC accuracy and dynamic CC
|
support on the LVX BMS port) and would require the inter-pack daisy-chain that
|
||||||
tapering near full. Path is the dedicated CAN port on the master pack →
|
silences slave packs' RS485 ports — i.e. trade away the per-pack/per-cell
|
||||||
inverter BMS port (separate cable from the inter-pack daisy-chain).
|
telemetry that's our best diagnostic. And it wouldn't fix SoC accuracy: it just
|
||||||
Deferred.
|
forwards the BMS's own drifted SoC. The cure for drift is a periodic full charge
|
||||||
|
(see below), done open-loop. See `../eg4battery/NOTES.md` §"Closed-loop BMS comms".
|
||||||
|
|
||||||
|
## SoC calibration & known firmware quirks
|
||||||
|
|
||||||
|
**SoC drift fix.** The EG4 pack SoC counters drift because the conservative profile
|
||||||
|
rarely drives a true full charge. A periodic **full charge to absorption** re-anchors
|
||||||
|
every pack to 100%. Automated by the `calibration-charge` skill —
|
||||||
|
[`../.claude/skills/calibration-charge/`](../.claude/skills/calibration-charge/):
|
||||||
|
solar-only on a clear day, or grid-assisted via **output-priority SUB** on a cloudy
|
||||||
|
day (`../.claude/skills/lib/grid-cal-monitor`, which safety-monitors and auto-reverts).
|
||||||
|
|
||||||
|
**LVX6048 firmware quirks** (learned the hard way; baked into the tooling):
|
||||||
|
- **PI18 `POP` is single-digit.** `solar_battery_utility` encodes as `POP1`, NOT
|
||||||
|
`POP01` — the inverter *silently rejects* the malformed `POP01` (no error on the
|
||||||
|
result topic). Fixed in `lvx-control` + `lvx-flash/flash.py` `POP_MAP`. A `POP` set
|
||||||
|
returns "crc check fails" on the result topic but still applies; `PCP` returns "Succeeded".
|
||||||
|
- **`MCHGC`/`MUCHGC` are locked while charging** — a charge-current change NAKs whenever
|
||||||
|
`mppt1_charger_status=charging` (even though `device_mode` reads `Battery`). Set them
|
||||||
|
only in a pre-charge idle window.
|
||||||
|
- **`stop_charge_voltage` is really `battery_re_discharge_voltage`** and can't exceed
|
||||||
|
`float`; firmware NAKs `0`/"Full". Don't use it to force a grid charge — use POP=SUB.
|
||||||
|
- **PIRI readback lags ~5 min**, so verify a setter by *behavior*
|
||||||
|
(`line_power_direction`, `device_mode`), not the readback entity.
|
||||||
|
- **powermon's adhoc queue can wedge** (commands stop landing on the result topic);
|
||||||
|
`sudo systemctl restart powermon.service powermon2.service` clears it.
|
||||||
|
|
||||||
|
## Monitoring & troubleshooting skills
|
||||||
|
|
||||||
|
Agent-runnable skills for this install live in
|
||||||
|
[`../.claude/skills/`](../.claude/skills/) (see `REFERENCE.md` there for the system
|
||||||
|
map): `solar-health-check`, `troubleshoot-inverter`, `troubleshoot-battery`,
|
||||||
|
`power-usage`, and `calibration-charge`, plus helpers `lib/solar-snapshot` (live MQTT)
|
||||||
|
and `lib/ha-history` (HA recorder lookback).
|
||||||
|
|||||||
@@ -41,18 +41,35 @@ Risky setters (battery thresholds, type, output mode, factory reset) are
|
|||||||
intentionally **not** exposed here — those should go through
|
intentionally **not** exposed here — those should go through
|
||||||
`lvx-flash/flash.py apply` with an explicit profile and confirmation.
|
`lvx-flash/flash.py apply` with an explicit profile and confirmation.
|
||||||
|
|
||||||
### Known limitation
|
### Known limitations & quirks
|
||||||
|
|
||||||
`max_charging_current` (MCHGC) and `max_utility_charging_current` (MUCHGC)
|
`max_charging_current` (MCHGC) and `max_utility_charging_current` (MUCHGC)
|
||||||
return `Failed` via PI18 when the inverter is actively charging (mode 06)
|
return `Failed` whenever the inverter is **actively charging** (charger_status
|
||||||
— the firmware appears to lock these setters during charge cycles. Other
|
= `charging`, even while `device_mode` reads `Battery`) — the firmware locks
|
||||||
setters (POP / PCP / PSP / PEI / PDI) work in all observed modes. If you
|
these charge-current setters during charge. Set them only in a pre-charge idle
|
||||||
need to reliably change the charge-current caps, either:
|
window, or via the LCD (Programs 02 / 11), or `lvx-flash/flash.py apply`.
|
||||||
|
|
||||||
- wait for the inverter to settle into Standby (mode 01) and retry, or
|
Other quirks (confirmed 2026-06-25):
|
||||||
- change via the LCD (Programs 02 / 11), or
|
- **`POP` is single-digit.** `output_priority=solar_battery_utility` must encode
|
||||||
- use `lvx-flash/flash.py apply` (it stops the powermon services first,
|
as `POP1`, NOT `POP01` — the inverter *silently rejects* the malformed `POP01`
|
||||||
giving exclusive USB access).
|
(nothing on the `result` topic, no effect). Fixed in `POP_MAP` here and in
|
||||||
|
`flash.py`. If reinstalling, make sure the live `/usr/local/bin/lvx-control`
|
||||||
|
has the fix.
|
||||||
|
- A **`POP` set returns "crc check fails"** on the `result` topic but still
|
||||||
|
applies; `PCP` returns a clean "Succeeded". So verify a POP change by
|
||||||
|
**behavior** (`device_mode` / `line_power_direction`), not the result string.
|
||||||
|
- **PIRI readback lags ~5 min** — don't trust the `*_output_source_priority`
|
||||||
|
sensor to confirm a just-issued change; watch the behavior instead.
|
||||||
|
- If commands stop landing on the `result` topic entirely, powermon's adhoc
|
||||||
|
queue has wedged → `sudo systemctl restart powermon.service powermon2.service`.
|
||||||
|
|
||||||
|
### Forcing a full grid charge (calibration)
|
||||||
|
|
||||||
|
To grid-charge the bank to full (e.g. SoC calibration on a cloudy day), set
|
||||||
|
`output_priority` → `solar_utility_battery` (SUB) so the inverter runs loads off
|
||||||
|
grid and charges the battery to full, plus `charger_priority` → `solar_and_utility`.
|
||||||
|
Revert to `solar_battery_utility` + `solar_first` when done. Automated and
|
||||||
|
safety-monitored by `../../.claude/skills/lib/grid-cal-monitor`.
|
||||||
|
|
||||||
Track the `result` topic to see the actual outcome of each command.
|
Track the `result` topic to see the actual outcome of each command.
|
||||||
|
|
||||||
|
|||||||
51
README.md
Normal file
51
README.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# solar — home power monitoring & control
|
||||||
|
|
||||||
|
Monitoring, control, and calibration tooling for an off-grid-leaning solar + storage
|
||||||
|
install, all published to one Home Assistant MQTT broker (`10.0.0.41`).
|
||||||
|
|
||||||
|
## The system
|
||||||
|
|
||||||
|
```
|
||||||
|
6× EG4 LifePower4 v2 packs ──RS485 (1 FTDI each)──┐
|
||||||
|
2× MPP Solar LVX6048 inverters ──USB-HID/PI18──────┤ monitoring Pi ──MQTT──► Home Assistant
|
||||||
|
1× OpenEVSE charger (10.0.0.249) ───────────────────┘ (daemons) 10.0.0.41:1883
|
||||||
|
```
|
||||||
|
|
||||||
|
- **14.4 kW PV** (36×400 W, 4×9s strings, 9s2p per inverter, 45° south) → 2 paralleled
|
||||||
|
LVX6048 inverters → **~30 kWh** EG4 LifePower4 bank (6× 100 Ah, 16S).
|
||||||
|
|
||||||
|
## Subsystems
|
||||||
|
|
||||||
|
| Dir | What | Entities |
|
||||||
|
|-----|------|----------|
|
||||||
|
| [`LVX6048/`](./LVX6048/) | 2 inverters via powermon (PI18/USB) + `lvx-flash` settings tool + `lvx-control` HA→PI18 bridge | `lvx6048_{1,2}_*` |
|
||||||
|
| [`eg4battery/`](./eg4battery/) | 6 battery packs via RS485/Modbus daemon | `lifepower4_{1..6}_*` |
|
||||||
|
| [`openevse/`](./openevse/) | EV charger HA-discovery publisher | `openevse_*` |
|
||||||
|
| [`battery/`](./battery/) | legacy V1 protocol decoder (historical) | — |
|
||||||
|
|
||||||
|
Each subsystem has its own `README.md` / `Install.md` / `NOTES.md`.
|
||||||
|
|
||||||
|
## Agent skills
|
||||||
|
|
||||||
|
Agent-runnable skills for monitoring, troubleshooting, and calibration live in
|
||||||
|
[`.claude/skills/`](./.claude/skills/) — start with
|
||||||
|
[`.claude/skills/REFERENCE.md`](./.claude/skills/REFERENCE.md) (system map, real HA
|
||||||
|
entity ids, known firmware quirks, action policy):
|
||||||
|
|
||||||
|
- `solar-health-check` — whole-system sweep + cross-checks + R/Y/G verdict
|
||||||
|
- `troubleshoot-inverter` / `troubleshoot-battery` — subsystem deep-dives
|
||||||
|
- `power-usage` — load vs PV vs grid vs battery balance
|
||||||
|
- `calibration-charge` — re-anchor drifted EG4 SoC via a full charge
|
||||||
|
- helpers: `lib/solar-snapshot` (live MQTT), `lib/ha-history` (HA recorder lookback),
|
||||||
|
`lib/grid-cal-monitor` (supervised grid calibration with auto-revert)
|
||||||
|
|
||||||
|
## Notable findings (see per-subsystem docs + the skills' REFERENCE)
|
||||||
|
|
||||||
|
- **EG4 SoC drifts** (counters never re-anchor without a full charge) → fixed by the
|
||||||
|
`calibration-charge` skill.
|
||||||
|
- **"Low" PV is mostly geometry + curtailment, not a fault** — both strings are healthy
|
||||||
|
(~16 A @ 300 V at clear-noon peak); the shortfall is 45° tilt, heat, trees, and the
|
||||||
|
battery charge cap throttling midday harvest.
|
||||||
|
- **LVX6048 firmware quirks**: PI18 `POP` is single-digit (`POP1`, not `POP01`);
|
||||||
|
MCHGC locked while charging; force a full grid charge via output-priority **SUB**, not
|
||||||
|
the voltage thresholds; PIRI readback lags ~5 min (verify by behavior).
|
||||||
@@ -148,7 +148,11 @@ come from decoding the Comm1/Comm2 hub bus instead (a future mode).
|
|||||||
|
|
||||||
### Adapters
|
### Adapters
|
||||||
|
|
||||||
On this host, three USB-FTDI adapters are plugged into the three packs' RS485 ports:
|
On this host there are now **6 packs**, each with its own USB-FTDI adapter on its
|
||||||
|
RS485 port. The config of record (all 6 `packs:` entries with their `/dev/serial/
|
||||||
|
by-id/...` paths, addresses, and bauds) is `~/.config/eg4-battery/eg4-battery.yaml`
|
||||||
|
and the example at `config/eg4-battery.yaml.example`. Packs 1–5 are addr `0x40` @
|
||||||
|
9600; **pack 6** is the odd one, addr `0x01` @ 115200. First three (historical):
|
||||||
|
|
||||||
| Adapter ID | Pack | `/dev/serial/by-id/...` |
|
| Adapter ID | Pack | `/dev/serial/by-id/...` |
|
||||||
|------------------|----------------|--------------------------------------------------------|
|
|------------------|----------------|--------------------------------------------------------|
|
||||||
@@ -156,12 +160,34 @@ On this host, three USB-FTDI adapters are plugged into the three packs' RS485 po
|
|||||||
| A994XGUY | bat2 (RS485) | `usb-FTDI_FT232R_USB_UART_A994XGUY-if00-port0` |
|
| A994XGUY | bat2 (RS485) | `usb-FTDI_FT232R_USB_UART_A994XGUY-if00-port0` |
|
||||||
| A994XMBR | bat3 (RS485) | `usb-FTDI_FT232R_USB_UART_A994XMBR-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.
|
Each pack gets polled on its own bus → no shared-bus arbitration, no master/slave coordination needed.
|
||||||
|
|
||||||
## LVX6048 compatibility (still true)
|
## 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.
|
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.
|
||||||
|
|
||||||
|
### Closed-loop BMS comms — evaluated, NOT recommended (2026-06)
|
||||||
|
|
||||||
|
Going closed-loop (daisy-chain packs → master CAN → inverter) was assessed and
|
||||||
|
**rejected** for this install:
|
||||||
|
1. **No native protocol** — would rely on the LP4V2 emulating Pylontech-CAN; unverified.
|
||||||
|
2. **Loses per-pack monitoring** — closed-loop needs the inter-pack Comm daisy-chain,
|
||||||
|
which silences slave packs' RS485 ports (see "RS485 only works standalone" above).
|
||||||
|
The 6-FTDI per-pack/per-cell telemetry — our best diagnostic — collapses to
|
||||||
|
master-only. Bad trade.
|
||||||
|
3. **Doesn't fix the real pain (SoC drift)** — closed-loop just forwards the BMS's own
|
||||||
|
(drifted) SoC to the inverter. The cure is a periodic full charge (see eg4battery
|
||||||
|
README §"SoC drift & calibration"), doable open-loop today.
|
||||||
|
|
||||||
|
### Forcing a full GRID charge (for calibration on a cloudy day)
|
||||||
|
|
||||||
|
The lever is the inverter's **output priority**, NOT the voltage thresholds: switch
|
||||||
|
POP to **SUB** (`solar_utility_battery`) so the inverter runs loads off grid AND
|
||||||
|
charges the bank to full from grid+solar; pair with `charger_priority=solar_and_utility`.
|
||||||
|
Revert to `solar_battery_utility` + `solar_first` when done. Both via lvx-control.
|
||||||
|
(Raising `re_discharge`/`stop_charge_voltage` does NOT work — firmware NAKs it.)
|
||||||
|
Automated + safety-monitored + auto-reverting in `../.claude/skills/lib/grid-cal-monitor`.
|
||||||
|
|
||||||
## Bring-up checklist (when a new pack goes live)
|
## 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.
|
1. Wire: plug USB-FTDI adapter (stock pin-1-2 cable) into the pack's **RS485** port.
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ RS-485 and publishes per-pack telemetry to MQTT with HA auto-discovery.
|
|||||||
|
|
||||||
## Status: live
|
## Status: live
|
||||||
|
|
||||||
All 3 packs publishing in `modbus_per_pack` mode, each on its own FTDI
|
All **6 packs** publishing in `modbus_per_pack` mode, each on its own FTDI
|
||||||
RS-485 adapter. Per pack, ~70 named entities + 136 raw `register_NN` series:
|
RS-485 adapter (packs 1–5 at addr `0x40`/9600; pack 6 is an oddball at addr
|
||||||
|
`0x01`/115200). Per pack, ~70 named entities + 136 raw `register_NN` series:
|
||||||
|
|
||||||
```
|
```
|
||||||
lifepower4_1_pack_voltage 52.56 V (16 cells × 3.285 V)
|
lifepower4_1_pack_voltage 52.56 V (16 cells × 3.285 V)
|
||||||
@@ -41,6 +42,18 @@ Set by `bus.mode` in `~/.config/eg4-battery/eg4-battery.yaml`:
|
|||||||
See [`NOTES.md`](./NOTES.md) for architecture, register map, LVX6048
|
See [`NOTES.md`](./NOTES.md) for architecture, register map, LVX6048
|
||||||
compatibility findings, and bring-up checklist.
|
compatibility findings, and bring-up checklist.
|
||||||
|
|
||||||
|
## SoC drift & calibration
|
||||||
|
|
||||||
|
The per-pack BMS SoC is coulomb-counted and **drifts** because the conservative
|
||||||
|
LVX6048 charge profile rarely drives a true full charge, so the counters never
|
||||||
|
re-anchor to 100% (observed 8–70% spread across packs at an identical resting
|
||||||
|
voltage — they're all physically at the same charge; the spread is pure drift).
|
||||||
|
Fix is a periodic **full charge to absorption**, which re-anchors every pack to
|
||||||
|
100%. Automated by the `calibration-charge` skill (solar-only, or grid-assisted
|
||||||
|
via output-priority SUB on a cloudy day) — see
|
||||||
|
[`../.claude/skills/calibration-charge/`](../.claude/skills/calibration-charge/)
|
||||||
|
and `../.claude/skills/lib/grid-cal-monitor`.
|
||||||
|
|
||||||
## What's in the box
|
## What's in the box
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user