From 04720c3b92bf62071971c7247c32dd6e743c202d Mon Sep 17 00:00:00 2001 From: noise Date: Mon, 27 Apr 2026 06:50:04 -0400 Subject: [PATCH] add controls to lvx --- LVX6048/2026-04-26-parallel.md | 60 +++++-- LVX6048/2026-04-27-control.md | 164 ++++++++++++++++++ LVX6048/README.md | 86 +++++++-- LVX6048/config/powermon/powermon.yaml | 2 + LVX6048/config/powermon/powermon2.yaml | 2 + .../etc/systemd/system/lvx-control.service | 14 ++ LVX6048/homeassistant/README.md | 106 +++++++++++ LVX6048/homeassistant/lovelace_controls.yaml | 85 +++++++++ LVX6048/homeassistant/mqtt_controls.yaml | 81 +++++++++ LVX6048/install.sh | 15 ++ LVX6048/lvx-control/README.md | 87 ++++++++++ LVX6048/lvx-control/lvx-control | 133 ++++++++++++++ LVX6048/lvx-flash/flash.py | 7 +- LVX6048/powermon-patches/pi18.py | 53 +++++- 14 files changed, 856 insertions(+), 39 deletions(-) create mode 100644 LVX6048/2026-04-27-control.md create mode 100644 LVX6048/etc/systemd/system/lvx-control.service create mode 100644 LVX6048/homeassistant/README.md create mode 100644 LVX6048/homeassistant/lovelace_controls.yaml create mode 100644 LVX6048/homeassistant/mqtt_controls.yaml create mode 100644 LVX6048/lvx-control/README.md create mode 100755 LVX6048/lvx-control/lvx-control diff --git a/LVX6048/2026-04-26-parallel.md b/LVX6048/2026-04-26-parallel.md index 32a2a13..79d64e0 100644 --- a/LVX6048/2026-04-26-parallel.md +++ b/LVX6048/2026-04-26-parallel.md @@ -99,8 +99,11 @@ small patches. ## Files touched ``` -M LVX6048/powermon-patches/pi18.py (MOD 06 -> "Charge"; parallel_instance_number decoder fix) -M LVX6048/lvx-flash/flash.py (parallel_instance_number index semantics; new sync-check rules) +M LVX6048/README.md (status section rewrite, sensor count, hot-plug note) +M LVX6048/powermon-patches/pi18.py (MOD 06; GS instance-index decoder; PGS validity flag; + two PGS fields named: battery V + capacity) +M LVX6048/lvx-flash/flash.py (instance-index semantics; sync-check rule rewrite; + cutoff <= stop_discharge validator) M LVX6048/bin/lvx-resolve-links (retry loop for hot-plug race) M LVX6048/etc/udev/rules.d/99-lvx6048.rules (ACTION=="add" RUN+= cascade) A LVX6048/2026-04-26-lvx6048_1-dump (post-commissioning baseline) @@ -127,13 +130,48 @@ Pass criteria post-commissioning: - battery V agreement within ~0.2 V - charge currents within ~5 A symmetric (when both are charging) -## Loose ends / next pass +## Nice-to-have pass -1. update `LVX6048/README.md` "Next steps" section to reflect that - parallel commissioning + decoder + hot-plug fixes are done, and - the slave-firmware delta is intentionally unrepaired (benign) -2. (optional) verify MOD 06 label by observing in different states -3. (optional) decode remaining PGS fields now that values are meaningful -4. (optional, longer horizon) closed-loop BMS comms via the dedicated - pack→inverter CAN port; defer until open-loop has been stable for a - week and we can baseline SoC accuracy +12. **README "Status / pending" section rewritten.** Replaced the stale + "Next steps / not done" with current state — parallel commissioning + done, slave-fw delta intentional, hot-plug recovery automatic. + Also bumped the per-inverter sensor count from 29 → 23 in the + architecture diagram (post-MQTT-cleanup) and added a "cable moves + and inverter power-cycles" paragraph noting the udev auto-recovery. + +13. **PGS partial decode (two confirmed fields).** Captured PGS0+PGS1 + on both units while operating in parallel and cross-referenced + against simultaneous GS readings to identify two previously-raw + fields: + - field_14 (idx 13) → `Battery voltage (parallel view)` — V*10 + - field_18 (idx 17) → `Battery capacity (parallel view)` — % + Several others have plausible candidates noted in the source + comment (charging current, mppt voltage) but couldn't be confirmed + without captures under load and during discharge. PGS isn't being + polled by powermon today, so naming these doesn't change HA — it + just makes future use of the command (or extending the pollers) cleaner. + +14. **PGS field 0 reverted from the GS fix.** The `Parallel instance + number` field has different semantics in PGS than in GS — live + captures show PGS field 0 always returns "1" regardless of the + queried instance, so it's a "valid response" flag, not the index. + Kept the index labeling on GS (where it really is the unit's own + instance number) and reverted PGS to the simpler 2-value flag. + +15. **`flash.py apply` round-trip verified.** Ran `flash.py diff` / + `apply --confirm` against the live state with the + `eg4-lp4-v2.yaml` profile — diff reports no changes, apply + correctly says "nothing to do", `compare` afterward shows 12/12 + settings still identical. Validates the apply code path end-to-end + on a no-op. Caught one validator bug along the way — `cutoff < + stop_discharge` was strict but the inverter actually accepts + `cutoff == stop_discharge` (which the user has set). Loosened to + `cutoff <= stop_discharge`. + +## Still pending + +- (optional) verify MOD 06 label by observing in non-charging states +- (optional) finish PGS decode: capture under load + during discharge +- (optional, longer horizon) closed-loop BMS comms via the dedicated + pack→inverter CAN port; defer until open-loop has been stable for a + week and we can baseline SoC accuracy diff --git a/LVX6048/2026-04-27-control.md b/LVX6048/2026-04-27-control.md new file mode 100644 index 0000000..bbde817 --- /dev/null +++ b/LVX6048/2026-04-27-control.md @@ -0,0 +1,164 @@ +# 2026-04-27 — LVX6048 remote-control plane + +Built the bridge from Home Assistant down to the inverter PI18 setters, so +day-to-day knobs (output / charger priorities, charge-current caps) can be +flipped from a dashboard without touching the LCD or a shell. Three +components: powermon's built-in adhoc mechanism enabled, a Python shim that +validates and mirrors commands, and HA-side `mqtt:` / Lovelace configs. + +## What happened + +1. **Surveyed PI18 setters available on this firmware.** Confirmed + `POP / PCP / PSP / PEI / PDI / MCHGC / MUCHGC / MCHGV / PSDV / BUCD / PBT` + from our patch. Common PI30-only setters (`PE/PD`, `POPM`, `PF`, + `PCVV`, `PSAVE`, etc.) may also be accepted but weren't tested today. + No software AC-output toggle exists on this family — that requires a + downstream contactor. + +2. **Phase 1 — enabled powermon's adhoc command queue.** Powermon already + ships an MQTT-based adhoc mechanism (`adhoc_topic` / `adhoc_result_topic` + under `mqttbroker`). Added both keys to `powermon{,2}.yaml`: + ```yaml + mqttbroker: + adhoc_topic: powermon/lvx6048_{1,2}/addcommand + adhoc_result_topic: powermon/lvx6048_{1,2}/result + ``` + Verified end-to-end with a `PI` (protocol ID) query — both units + responded `protocol_id=18` within ~1 s. + +3. **Phase 2 — validation shim (`lvx-control`).** New systemd-managed + Python daemon that subscribes to `solar/control/lvx6048/`, + validates the payload against an allow-list (matching the rules + `flash.py` already enforces), encodes to PI18, and **mirrors to both + inverters' addcommand topics** so the parallel cluster never desyncs + on a setting (which would trigger fault 86 — "Parallel output setting + different"). Reuses the existing powermon venv's Python at install + time (same shebang-rewrite pattern as `lvx-resolve-links`), so no new + dependency setup. Broker creds read from `~/.config/powermon/powermon.yaml`, + so no new secret file to manage. Risky setters (battery thresholds, + type, output mode, factory reset) are deliberately **not** exposed + here — those still go through `flash.py apply`. + + Supported actions: + - `output_priority` — POP (SUB / SBU) + - `charger_priority` — PCP (solar-first / solar+utility / solar-only) + - `solar_power_priority` — PSP (battery-first / load-first) + - `max_charging_current` — MCHGC (10–80 A in 10 A steps) + - `max_utility_charging_current` — MUCHGC (2 / 10–80 A) + +4. **Phase 3 — HA-side configs.** Mirrors the `eg4battery/homeassistant/` + pattern. `homeassistant/mqtt_controls.yaml` defines 4 selects + 1 + number bound to the friendly control topics; state is read from the + existing PIRI auto-discovery sensors via `value_template` mappings + (PIRI publishes "Solar - Battery - Utility" while the shim accepts + "solar_battery_utility"). `homeassistant/lovelace_controls.yaml` is + a complete dashboard view: summary header, output/charger control, + charge-current control, history graph, per-unit health glance. + `homeassistant/README.md` documents the install path and the control- + plane architecture. + +5. **MOD decoder regression fix.** Powermon services were crash-looping + (~3,000 restarts on unit-2) because the inverter started reporting + mode `07` post-commissioning, which our decoder didn't know — it + raised `KeyError` instead of falling back. Added `"07": "Eco"` (best + guess; revisit if observed in non-Eco contexts) plus placeholder + labels `"Mode 08"` … `"Mode 15"` so any future unknown code shows + up as an opaque label rather than crashing. Defensive expansion via + dict comprehension kept the change small. + +6. **Discovered firmware quirk: MCHGC / MUCHGC lock during active charge.** + While the inverter is in mode `06` ("Charge") — i.e. actively bank- + charging from solar or grid — the firmware rejects `MCHGC` / `MUCHGC` + setter writes (responds with NAK; result topic shows `"Failed"`). + `POP` / `PCP` / `PSP` / `PEI` / `PDI` work in all observed modes. + This is defensible firmware behavior (don't change current limits + while current is flowing) but not a documented one. Workarounds are + listed in `lvx-control/README.md`: retry while in Standby (mode 01), + change via LCD Programs 02/11, or use `flash.py apply` (which stops + powermon for exclusive USB access). + +7. **`install.sh` updated.** Now copies `lvx-control` into + `/usr/local/bin/` with the powermon-venv shebang rewrite, installs + the systemd unit, and `enable --now`'s the service once + `~/.config/powermon/powermon.yaml` has real (non-placeholder) creds. + Idempotent on re-run. + +## Net effect + +| Metric | Before | After | +|---|---|---| +| Settings changes from HA | LCD or shell only | `solar/control/lvx6048/` topic | +| Mirror to both inverters | manual / coordinated | automatic (single publish) | +| Validation before sending | none / firmware regex | shim allow-list + firmware regex | +| Audit trail | none | `journalctl -u lvx-control.service` | +| MOD-code crash on unknown value | yes (003,054 restarts on unit-2) | no (defensive 08–15 fallback) | + +## Files touched + +``` +M LVX6048/README.md (directory listing + status note) +M LVX6048/config/powermon/powermon.yaml (adhoc topics) +M LVX6048/config/powermon/powermon2.yaml (adhoc topics) +M LVX6048/install.sh (deploys lvx-control) +M LVX6048/powermon-patches/pi18.py (MOD 07 + 08-15 placeholders) +A LVX6048/etc/systemd/system/lvx-control.service (new systemd unit) +A LVX6048/lvx-control/lvx-control (new Python daemon) +A LVX6048/lvx-control/README.md (supported actions / topics) +A LVX6048/homeassistant/mqtt_controls.yaml (HA select + number entities) +A LVX6048/homeassistant/lovelace_controls.yaml (dashboard view) +A LVX6048/homeassistant/README.md (HA-side install steps) +A LVX6048/2026-04-27-control.md (this file) +``` + +## How to verify + +```bash +# 1. Services healthy +systemctl --no-pager is-active \ + lvx-resolve-links.service \ + powermon.service powermon2.service \ + lvx-control.service + +# 2. Watch result topics +mosquitto_sub -h -u mqtt -P -v \ + -t 'powermon/lvx6048_1/result' \ + -t 'powermon/lvx6048_2/result' & + +# 3. Fire a control command (no-op against current state) +mosquitto_pub -h -u mqtt -P \ + -t 'solar/control/lvx6048/charger_priority' -m 'solar_first' + +# Expected: both inverters publish "Succeeded" within ~1s. +``` + +```bash +# 4. shim audit log +journalctl -u lvx-control.service -f +# Expect lines like: +# OK charger_priority='solar_first' -> PCP0,0 -> mirror to 2 inverter(s) +# REJECT max_charging_current='999' (amps: 10,20,30,40,50,60,70,80) +``` + +## Loose ends / next pass + +- **Phase 4 — HA automations** (TOU peak-shaving, storm-prep button, + cell-imbalance throttle). Deferred until the manual controls have been + used for a few days and the actual desired automations are clear. +- **MCHGC / MUCHGC reliability.** The current "Failed when in mode 06" + behavior is documented but ugly. Possible improvements: + (a) auto-retry the shim once after a 30 s delay; (b) require the inverter + to be in Standby before sending; (c) extend `flash.py` to push these + via the apply path (stops powermon, sets, restarts) and expose that as + an HA shell-command. Option (c) is the cleanest if frequent changes + matter. +- **Per-component dashboard for LVX6048 stack.** Today's + `lovelace_controls.yaml` is the first one. Worth a follow-up to add an + Energy-dashboard wiring guide once both inverter `ac_output_active_power` + + battery `pack_power` template sensors stabilize. +- **MOD code 07 label.** "Eco" is an educated guess; observe over the + next few days and relabel if it shows up in unrelated states. Same as + the open follow-up for code 06 from yesterday. +- **PE / PD setters** (buzzer mute, alarm enable/disable, fault recording + toggle, etc.) — these are PI30 family and probably accepted by the + LVX6048 PI18 firmware. Worth a quick test before adding to the shim's + allow-list, since "mute the buzzer" is a useful HA button. diff --git a/LVX6048/README.md b/LVX6048/README.md index 48374e2..d6ad0db 100644 --- a/LVX6048/README.md +++ b/LVX6048/README.md @@ -17,6 +17,7 @@ LVX6048/ │ ├── udev/rules.d/99-lvx6048.rules │ └── systemd/system/ │ ├── lvx-resolve-links.service +│ ├── lvx-control.service │ ├── powermon.service │ ├── powermon2.service │ ├── powermon.service.d/10-resolver.conf @@ -38,7 +39,18 @@ LVX6048/ ├── lvx-flash/ ← settings-profile CLI │ ├── flash.py ← dump / diff / apply / compare / sync-check │ ├── README.md -│ └── profiles/current.yaml +│ └── profiles/ +│ ├── current.yaml ← legacy snapshot +│ └── eg4-lp4-v2.yaml ← canonical EG4 LP4 v2 LiFePO4 bank profile +│ +├── lvx-control/ ← HA → powermon adhoc command bridge +│ ├── lvx-control ← single-file Python daemon +│ └── README.md ← supported actions / topic layout +│ +├── homeassistant/ ← HA-side control entities + dashboard +│ ├── mqtt_controls.yaml ← selects + numbers for the controls +│ ├── lovelace_controls.yaml ← dashboard view +│ └── README.md │ └── smoketest/ ← adhoc test configs (one-off powermon -C usage) ├── console.yaml @@ -108,7 +120,7 @@ sudo systemctl start lvx-resolve-links.service powermon.service powermon2.servic ▼ ┌──────────────────────────────────┐ │ Home Assistant Mosquitto broker │ - │ ~29 auto-discovered sensors/unit │ + │ ~23 auto-discovered sensors/unit │ └──────────────────────────────────┘ ``` @@ -118,10 +130,16 @@ target profile, apply changes safely (stops powermon, writes via PI18 setters, verifies via PIRI readback). Also supports `compare` (diff live settings between two inverters) and `sync-check` (verify parallel-stack health). -## Cable moves +## Cable moves and inverter power-cycles Identification is PI18-serial-based, so moving USB cables between hub ports -never requires config edits. After any cable shuffle: +never requires config edits. The udev rule fires `lvx-resolve-links` and +restarts both powermon services automatically whenever a matching hidraw +device appears (`ACTION=="add"`), so a cable shuffle or inverter power-cycle +recovers on its own — no manual `systemctl restart` needed in the common case. + +If recovery looks stuck (e.g. a unit was off long enough that its hidraw +disappeared but the new one didn't trigger the rule for some reason): ```bash sudo systemctl restart lvx-resolve-links.service powermon.service powermon2.service @@ -137,18 +155,50 @@ and update the two `SERIAL_UNIT_*` constants in: then restart the three services. -## Next steps / not done +## Status — done / pending -- **Firmware parity:** on this dev stack, unit #1 is at main=06303/slave=06126 - and unit #2 is at 06440/06021. Parallel operation requires matching firmware - (fault code 71 "Parallel version different") — the sync kit's cables are - wired correctly, but the inverters won't phase-lock until both CPUs match. - Firmware upload is not part of this package (MPP Solar Windows-only tool). -- **PGS field layout:** the LVX6048-specific 30-field PGS response layout is - only partially decoded in `powermon-patches/pi18.py`. The key fields - (parallel_valid, fault_code, grid_hz, ac_output_voltage) are named; the rest - are exposed as raw strings. -- **Control / set commands via HA:** PI18 setters (POP, PCP, MCHGC, MUCHGC, 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 stable for - at least a week and firmware parity is restored. +**Done (2026-04-26):** + +- **Parallel commissioning.** Both inverters operationally paralleled and + load-sharing on a 3× EG4 LP4 v2 bank. See `2026-04-26-parallel.md`. +- **Main-CPU firmware parity.** Both units now at main 06306. Slave-CPU + firmware still differs (06126 / 06021) but is benign: cluster handshake + completes, current sharing is symmetric, no faults. The vendor's + `LVX6048 FW63.06.zip` includes a `dsp.hex` slave bin if version parity + is ever desired for cosmetic reasons. +- **Hot-plug recovery.** udev rule auto-restarts the resolver + powermon + services when an inverter re-enumerates. Resolver also has a retry loop + to handle the brief race when both units come up nearly together. +- **`parallel_instance_number` decoder.** Replaced upstream's misleading + 2-value flag with a proper instance-index decoder (0 = master, 1+ = + slaves) in both GS and PGS. `flash.py sync-check` updated to match. +- **MOD code 06.** Added "Charge" mapping (was crashing sync-check). + Educated guess on the label — revisit if it shows up in unrelated states. +- **Battery profile.** `lvx-flash/profiles/eg4-lp4-v2.yaml` captures the + conservative open-loop LFP policy in use (USER mode, 56.4 V bulk, + 54.0 V float, 48.0 V cutoff, 60 A MCHGC). +- **HA remote control.** `lvx-control` daemon bridges HA-friendly MQTT + topics (`solar/control/lvx6048/`) to powermon's adhoc-command + queue, mirroring every change to both inverters in lock-step. Day-to-day + controls (POP / PCP / PSP / MCHGC / MUCHGC) exposed; risky setters + (battery thresholds, type, output mode, factory reset) deliberately not. + Companion HA configs live in `homeassistant/`. + +**Pending / nice-to-have:** + +- **PGS field layout.** The LVX6048-specific 30-field PGS response is only + partially named in `powermon-patches/pi18.py` — fields 4, 13–22, 23–28, + and 30 are still exposed as raw strings. Now that the cluster is actually + paralleled, more of these fields produce meaningful values; worth a + second pass to identify them by comparing PGS0 vs PGS1 captures from + master and slave. +- **Control / set commands via HA.** PI18 setters (POP, PCP, MCHGC, MUCHGC, + 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 + stable for at least a week. +- **Closed-loop BMS comms.** Currently open-loop — inverters estimate SoC + from voltage, batteries don't push real-time SoC / charge limits to the + inverter. Closed-loop would give better SoC accuracy and dynamic CC + tapering near full. Path is the dedicated CAN port on the master pack → + inverter BMS port (separate cable from the inter-pack daisy-chain). + Deferred. diff --git a/LVX6048/config/powermon/powermon.yaml b/LVX6048/config/powermon/powermon.yaml index 1b090f4..f05d82f 100644 --- a/LVX6048/config/powermon/powermon.yaml +++ b/LVX6048/config/powermon/powermon.yaml @@ -15,6 +15,8 @@ mqttbroker: port: 1883 username: mqtt password: + adhoc_topic: powermon/lvx6048_1/addcommand + adhoc_result_topic: powermon/lvx6048_1/result commands: - command: GS diff --git a/LVX6048/config/powermon/powermon2.yaml b/LVX6048/config/powermon/powermon2.yaml index 94d4cbf..b2d56ea 100644 --- a/LVX6048/config/powermon/powermon2.yaml +++ b/LVX6048/config/powermon/powermon2.yaml @@ -15,6 +15,8 @@ mqttbroker: port: 1883 username: mqtt password: + adhoc_topic: powermon/lvx6048_2/addcommand + adhoc_result_topic: powermon/lvx6048_2/result commands: - command: GS diff --git a/LVX6048/etc/systemd/system/lvx-control.service b/LVX6048/etc/systemd/system/lvx-control.service new file mode 100644 index 0000000..a051e3b --- /dev/null +++ b/LVX6048/etc/systemd/system/lvx-control.service @@ -0,0 +1,14 @@ +[Unit] +Description=LVX6048 HA -> powermon adhoc command bridge +After=network-online.target powermon.service powermon2.service +Wants=network-online.target + +[Service] +Type=simple +User=noise +ExecStart=/usr/local/bin/lvx-control +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/LVX6048/homeassistant/README.md b/LVX6048/homeassistant/README.md new file mode 100644 index 0000000..3a8a9aa --- /dev/null +++ b/LVX6048/homeassistant/README.md @@ -0,0 +1,106 @@ +# HA-side configuration for the LVX6048 stack + +Reference configs that go into your Home Assistant instance — they aren't +installed by `install.sh` (HA typically lives on a different host or in an +HA OS appliance), but they're tracked here so the full stack is reproducible. + +Mirrors the `eg4battery/homeassistant/` pattern. + +## What's in here + +| File | Where it goes in HA | +|----------------------------|--------------------------------------------------------------| +| `mqtt_controls.yaml` | `configuration.yaml` → `mqtt: !include lvx6048/mqtt_controls.yaml` (or merge by hand) | +| `lovelace_controls.yaml` | Raw Lovelace card config — paste into a new dashboard view | + +The auto-discovery sensors (battery V, fault code, mode, MPPT power, …) +arrive automatically from powermon — no HA-side config required for those. +This folder only adds the pieces HA can't infer: + +- **Control entities** — selects + numbers that publish to + `solar/control/lvx6048/` so users can change settings from a + dashboard without touching the LCD. +- **A dashboard view** that wraps those controls with the existing + telemetry into one screen. + +## Architecture (control path) + +``` +HA dashboard (mqtt select / number) + │ + │ payload e.g. "solar_battery_utility" + ▼ +solar/control/lvx6048/ + │ + ▼ +lvx-control.service (on the Pi) + │ validates against allow-list, + │ encodes to PI18 command (e.g. "POP01"), + │ mirrors to BOTH inverters + ▼ +powermon/lvx6048_{1,2}/addcommand + │ + ▼ +powermon executes via PI18 setter, publishes + "Succeeded" / "Failed" to powermon/lvx6048_{1,2}/result +``` + +## Enabling in HA + +1. Drop both YAMLs into `~/homeassistant/lvx6048/` on your HA host. +2. Add to `configuration.yaml`: + + ```yaml + mqtt: !include lvx6048/mqtt_controls.yaml + ``` + + (or, if you already have an `mqtt:` block, merge the `select:` and + `number:` lists into it.) + +3. Restart HA. The 3× selects and 1× number entity should appear under + the "Home Assistant" device. + +4. Add the dashboard: + - **Settings → Dashboards → + Add Dashboard → New dashboard from scratch** + - Open the new dashboard → ⋮ → **Edit dashboard → Raw configuration editor** + - Paste the contents of `lovelace_controls.yaml`. + +## Available controls + +| Entity | Effect | +|-------------------------------------------------|--------------------------------------------------------------------| +| `select.lvx6048_output_priority` | POP — switch between SUB / SBU output source priority | +| `select.lvx6048_charger_priority` | PCP — solar-first / solar+utility / solar-only charging | +| `select.lvx6048_solar_power_priority` | PSP — battery+load+utility+AC vs load+battery+utility | +| `number.lvx6048_max_charging_current` | MCHGC — combined solar+AC charge cap, 10–80 A in 10 A steps | +| `select.lvx6048_max_utility_charging_current` | MUCHGC — grid-only charge cap, 2/10/20/…/80 A | + +> **Note:** `MCHGC` / `MUCHGC` setters are sometimes rejected by the +> firmware while the inverter is actively charging (mode 06). Result topics +> show `"Failed"` in that case. If a charge-current change must apply +> immediately, either retry while idle (mode 01) or use +> `lvx-flash/flash.py apply` (which stops powermon for exclusive USB access). + +Risky settings — battery thresholds (PSDV / MCHGV / BUCD), battery type +(PBT), output mode (POPM), factory reset (PF) — are intentionally **not** +exposed via HA. Use `lvx-flash/flash.py apply` with an explicit profile. + +## Verifying + +After HA reload, watch the result topics: + +```bash +mosquitto_sub -h -u mqtt -P -v \ + -t 'powermon/lvx6048_1/result' \ + -t 'powermon/lvx6048_2/result' +``` + +…then flip a select in the dashboard. Both inverters should publish +`"Succeeded"` within ~1 s. + +## Energy / SoC dashboard wiring (optional) + +Once both inverters' `ac_output_active_power` and the EG4 daemon's +`pack_power` derived sensors are in place, the Energy dashboard can show +solar in / battery in/out / load — wire under +**Settings → Dashboards → Energy → Solar panels / Home battery storage**. diff --git a/LVX6048/homeassistant/lovelace_controls.yaml b/LVX6048/homeassistant/lovelace_controls.yaml new file mode 100644 index 0000000..6102e0b --- /dev/null +++ b/LVX6048/homeassistant/lovelace_controls.yaml @@ -0,0 +1,85 @@ +# Lovelace dashboard for LVX6048 controls + key telemetry. +# Paste into a dashboard view's raw-config editor, or save as a YAML-mode +# dashboard. Assumes mqtt_controls.yaml is loaded so the control entities +# (select.lvx6048_*, number.lvx6048_*) exist. + +views: + - title: Inverters + icon: mdi:flash + path: inverters + cards: + # ---- summary header ---- + - type: horizontal-stack + cards: + - type: entity + name: Bank V (master) + entity: sensor.lvx6048_2_battery_voltage + icon: mdi:battery-outline + - type: entity + name: SoC (open-loop) + entity: sensor.lvx6048_1_battery_capacity + icon: mdi:battery + - type: entity + name: Mode (master) + entity: sensor.lvx6048_2_device_mode + icon: mdi:state-machine + + # ---- output / charger control ---- + - type: entities + title: Output priorities + show_header_toggle: false + entities: + - entity: select.lvx6048_output_priority + name: Output source priority + - entity: select.lvx6048_charger_priority + name: Charger source priority + - entity: select.lvx6048_solar_power_priority + name: Solar power priority + + # ---- charge-current control ---- + - type: entities + title: Charge current limits + show_header_toggle: false + entities: + - entity: number.lvx6048_max_charging_current + name: Combined (solar + AC) + - entity: select.lvx6048_max_utility_charging_current + name: Grid only + + # ---- live power flow ---- + - type: history-graph + title: Power flow (last 12 h) + hours_to_show: 12 + entities: + - entity: sensor.lvx6048_1_mppt1_input_power + name: Unit 1 PV + - entity: sensor.lvx6048_2_mppt1_input_power + name: Unit 2 PV + - entity: sensor.lvx6048_1_battery_charging_current + name: Unit 1 charge A + - entity: sensor.lvx6048_2_battery_charging_current + name: Unit 2 charge A + - entity: sensor.lvx6048_1_ac_output_active_power + name: Unit 1 AC out + + # ---- per-unit health ---- + - type: glance + title: Per-unit health + columns: 4 + entities: + - entity: sensor.lvx6048_1_fault_code + name: U1 fault + - entity: sensor.lvx6048_1_parallel_instance_number + name: U1 role + - entity: sensor.lvx6048_2_fault_code + name: U2 fault + - entity: sensor.lvx6048_2_parallel_instance_number + name: U2 role + - entity: sensor.lvx6048_1_inverter_heat_sink_temperature + name: U1 temp + - entity: sensor.lvx6048_2_inverter_heat_sink_temperature + name: U2 temp + - entity: sensor.lvx6048_1_battery_charging_current + name: U1 chg A + - entity: sensor.lvx6048_2_battery_charging_current + name: U2 chg A diff --git a/LVX6048/homeassistant/mqtt_controls.yaml b/LVX6048/homeassistant/mqtt_controls.yaml new file mode 100644 index 0000000..21e239d --- /dev/null +++ b/LVX6048/homeassistant/mqtt_controls.yaml @@ -0,0 +1,81 @@ +# LVX6048 control entities (HA → lvx-control bridge → both inverters). +# +# Each entity publishes a friendly value to solar/control/lvx6048/; +# lvx-control validates, encodes to PI18, and mirrors to both inverters' +# powermon adhoc topics. State is read from the existing PIRI auto-discovery +# entities (unit-1 only — both units stay in lock-step thanks to the mirror). +# +# Drop into ~/homeassistant/lvx6048/mqtt_controls.yaml and reference from +# configuration.yaml, e.g.: +# +# mqtt: !include lvx6048/mqtt_controls.yaml +# +# (or merge into your existing top-level `mqtt:` block by hand). + +select: + - name: "LVX6048 output priority" + unique_id: lvx6048_output_priority_control + icon: mdi:transmission-tower-export + command_topic: solar/control/lvx6048/output_priority + options: + - solar_utility_battery + - solar_battery_utility + state_topic: homeassistant/sensor/lvx6048_1_output_source_priority/state + value_template: >- + {% set m = { + 'Solar - Utility - Battery': 'solar_utility_battery', + 'Solar - Battery - Utility': 'solar_battery_utility' + } %} + {{ m.get(value, none) }} + + - name: "LVX6048 charger priority" + unique_id: lvx6048_charger_priority_control + icon: mdi:battery-charging + command_topic: solar/control/lvx6048/charger_priority + options: + - solar_first + - solar_and_utility + - solar_only + state_topic: homeassistant/sensor/lvx6048_1_charger_source_priority/state + value_template: >- + {% set m = { + 'Solar First': 'solar_first', + 'Solar + Utility': 'solar_and_utility', + 'Only solar charging permitted': 'solar_only' + } %} + {{ m.get(value, none) }} + + - name: "LVX6048 solar power priority" + unique_id: lvx6048_solar_power_priority_control + icon: mdi:solar-power-variant + command_topic: solar/control/lvx6048/solar_power_priority + options: + - battery_load_utility_ac + - load_battery_utility + state_topic: homeassistant/sensor/lvx6048_1_solar_power_priority/state + value_template: >- + {% set m = { + 'Battery-Load-Utiliy + AC Charger': 'battery_load_utility_ac', + 'Load-Battery-Utiliy': 'load_battery_utility' + } %} + {{ m.get(value, none) }} + + - name: "LVX6048 max utility charging current" + unique_id: lvx6048_max_utility_charging_current_control + icon: mdi:transmission-tower + command_topic: solar/control/lvx6048/max_utility_charging_current + options: ['2', '10', '20', '30', '40', '50', '60', '70', '80'] + state_topic: homeassistant/sensor/lvx6048_1_max_ac_charging_current/state + # State publishes raw integer (no unit suffix); options are strings to match. + +number: + - name: "LVX6048 max charging current" + unique_id: lvx6048_max_charging_current_control + icon: mdi:current-dc + command_topic: solar/control/lvx6048/max_charging_current + state_topic: homeassistant/sensor/lvx6048_1_max_charging_current/state + min: 10 + max: 80 + step: 10 + unit_of_measurement: A + mode: slider diff --git a/LVX6048/install.sh b/LVX6048/install.sh index 5905c59..617f892 100755 --- a/LVX6048/install.sh +++ b/LVX6048/install.sh @@ -61,6 +61,7 @@ sudo install -m 755 "$TMP_RESOLVER" /usr/local/sbin/lvx-resolve-links # --- 5. systemd units + drop-ins ------------------------------------------- msg "Installing systemd units" sudo install -m 644 "${BASE}/etc/systemd/system/lvx-resolve-links.service" /etc/systemd/system/ +sudo install -m 644 "${BASE}/etc/systemd/system/lvx-control.service" /etc/systemd/system/ sudo install -m 644 "${BASE}/etc/systemd/system/powermon.service" /etc/systemd/system/ sudo install -m 644 "${BASE}/etc/systemd/system/powermon2.service" /etc/systemd/system/ sudo mkdir -p /etc/systemd/system/powermon.service.d /etc/systemd/system/powermon2.service.d @@ -70,6 +71,14 @@ sudo install -m 644 "${BASE}/etc/systemd/system/powermon2.service.d/10-resolver. /etc/systemd/system/powermon2.service.d/10-resolver.conf sudo systemctl daemon-reload +# --- 5b. lvx-control script (HA → powermon adhoc bridge) ------------------- +msg "Installing /usr/local/bin/lvx-control (shebang → ${POWERMON_PY})" +TMP_CONTROL="$(mktemp)" +trap 'rm -f "$TMP_RESOLVER" "$TMP_CONTROL"' EXIT +awk -v py="${POWERMON_PY}" 'NR==1 { print "#!" py; next } { print }' \ + "${BASE}/lvx-control/lvx-control" > "$TMP_CONTROL" +sudo install -m 755 "$TMP_CONTROL" /usr/local/bin/lvx-control + # --- 6. user configs (only if not already deployed; don't clobber creds) --- msg "Installing powermon configs (skipping existing files — edit creds manually if new)" mkdir -p "${HOME}/.config/powermon" @@ -103,6 +112,12 @@ else systemctl --no-pager status powermon.service powermon2.service | head -30 fi +# lvx-control reads broker creds from ~/.config/powermon/powermon.yaml, so it +# only needs to start once that file has real credentials. +if ! grep -q '' "${HOME}/.config/powermon/powermon.yaml" 2>/dev/null; then + sudo systemctl enable --now lvx-control.service +fi + msg "Done" echo "Next steps (if any of these don't match your hardware, edit and restart):" echo " - /usr/local/sbin/lvx-resolve-links : SERIAL_UNIT_1 / SERIAL_UNIT_2" diff --git a/LVX6048/lvx-control/README.md b/LVX6048/lvx-control/README.md new file mode 100644 index 0000000..b2d56c1 --- /dev/null +++ b/LVX6048/lvx-control/README.md @@ -0,0 +1,87 @@ +# lvx-control + +Tiny daemon that bridges Home-Assistant-friendly MQTT topics to powermon's +adhoc-command queue, so HA buttons / selects / numbers can drive the LVX6048 +pair without anyone touching the LCD or the shell. + +## What it does + +``` +HA dashboard (mqtt button) + │ + │ payload "solar_battery_utility" + ▼ +solar/control/lvx6048/output_priority (subscribed by lvx-control) + │ + │ validates against the allow-list, encodes to PI18 + │ e.g. "POP01" (output source = solar -> battery -> utility) + ▼ +powermon/lvx6048_1/addcommand ┐ mirrored to BOTH inverters in +powermon/lvx6048_2/addcommand ┘ the same publish so the parallel + cluster never desyncs (fault 86) + │ + ▼ +powermon services execute the command on each unit; result lands in +powermon/lvx6048_{1,2}/result for HA to confirm +``` + +## Supported actions + +Friendly topic suffix → PI18 setter: + +| Topic suffix | Payload values | PI18 | +|-------------------------------------|------------------------------------------------------------------------------------------------------|------| +| `output_priority` | `solar_utility_battery` \| `solar_battery_utility` | POP | +| `charger_priority` | `solar_first` \| `solar_and_utility` \| `solar_only` | PCP | +| `solar_power_priority` | `battery_load_utility_ac` \| `load_battery_utility` | PSP | +| `max_charging_current` | `10`,`20`,`30`,`40`,`50`,`60`,`70`,`80` (combined solar+AC, A) | MCHGC | +| `max_utility_charging_current` | `2`,`10`,`20`,`30`,`40`,`50`,`60`,`70`,`80` (grid-side, A) | MUCHGC | + +Risky setters (battery thresholds, type, output mode, factory reset) are +intentionally **not** exposed here — those should go through +`lvx-flash/flash.py apply` with an explicit profile and confirmation. + +### Known limitation + +`max_charging_current` (MCHGC) and `max_utility_charging_current` (MUCHGC) +return `Failed` via PI18 when the inverter is actively charging (mode 06) +— the firmware appears to lock these setters during charge cycles. Other +setters (POP / PCP / PSP / PEI / PDI) work in all observed modes. If you +need to reliably change the charge-current caps, either: + +- wait for the inverter to settle into Standby (mode 01) and retry, or +- change via the LCD (Programs 02 / 11), or +- use `lvx-flash/flash.py apply` (it stops the powermon services first, + giving exclusive USB access). + +Track the `result` topic to see the actual outcome of each command. + +## Quick test + +```bash +# subscribe to results in another terminal +mosquitto_sub -h -u mqtt -P -v \ + -t 'powermon/lvx6048_1/result' \ + -t 'powermon/lvx6048_2/result' + +# fire a control command +mosquitto_pub -h -u mqtt -P \ + -t 'solar/control/lvx6048/charger_priority' \ + -m 'solar_first' +``` + +You should see the encoded `PCP0,0` show up at the inverters' result topics +within ~1 second, and the existing PIRI sensor in HA will reflect the new +state on the next 5-minute cycle. + +## Files + +``` +lvx-control/lvx-control <-- single-file Python (PEP-723 deps) +lvx-control/README.md <-- this file +etc/systemd/system/lvx-control.service +``` + +`install.sh` copies the script to `/usr/local/bin/lvx-control` and enables +the systemd unit. Broker credentials are read from +`~/.config/powermon/powermon.yaml` (no separate secret file). diff --git a/LVX6048/lvx-control/lvx-control b/LVX6048/lvx-control/lvx-control new file mode 100755 index 0000000..d4e7fe1 --- /dev/null +++ b/LVX6048/lvx-control/lvx-control @@ -0,0 +1,133 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# dependencies = ["paho-mqtt>=2.0", "PyYAML>=6.0"] +# /// +"""lvx-control — bridge HA-friendly topics to powermon adhoc commands. + +Subscribes to `solar/control/lvx6048/` topics, validates the payload +against an allow-list (matching what flash.py accepts), encodes to a PI18 +command code, and mirrors the command to BOTH inverters' powermon adhoc +topics so a paralleled cluster never ends up with mismatched settings +(which would trigger fault 86 — "Parallel output setting different"). + +Topics: + SUBSCRIBE solar/control/lvx6048/ (mirror to both) + PUBLISH powermon/lvx6048_1/addcommand + PUBLISH powermon/lvx6048_2/addcommand + +MQTT broker creds are read from ~/.config/powermon/powermon.yaml (the +existing powermon config) so there's no new secret file to manage. + +Risky setters (MCHGV / PSDV / BUCD / PBT) are intentionally NOT exposed; +those should go through `lvx-flash/flash.py apply` with an explicit profile. +""" +from __future__ import annotations + +import logging +import os +import sys +from pathlib import Path + +import paho.mqtt.client as mqtt +import yaml + +# ---- topic layout ------------------------------------------------------- + +CONTROL_PREFIX = "solar/control/lvx6048" +ADHOC_TOPICS = ( + "powermon/lvx6048_1/addcommand", + "powermon/lvx6048_2/addcommand", +) + +# ---- encoders & validators --------------------------------------------- +# Intentionally a strict subset of flash.py's SCHEDULE — only the safe, +# day-to-day knobs. The risky-end calibration setters live in flash.py. + +POP_MAP = {"solar_utility_battery": "0", "solar_battery_utility": "01"} +PCP_MAP = {"solar_first": "0", "solar_and_utility": "1", "solar_only": "2"} +PSP_MAP = {"battery_load_utility_ac": "0", "load_battery_utility": "1"} +ALLOWED_MCHGC = (10, 20, 30, 40, 50, 60, 70, 80) +ALLOWED_MUCHGC = (2, 10, 20, 30, 40, 50, 60, 70, 80) + + +def _enum(mapping): + return { + "validate": lambda v: v in mapping, + "doc": "select: " + " | ".join(mapping), + } + +def _amps(allowed): + s = set(allowed) + return { + "validate": lambda v: v.isdigit() and int(v) in s, + "doc": "amps: " + ",".join(str(a) for a in allowed), + } + +ACTIONS: dict[str, dict] = { + "output_priority": {**_enum(POP_MAP), "encode": lambda v: f"POP{POP_MAP[v]}"}, + "charger_priority": {**_enum(PCP_MAP), "encode": lambda v: f"PCP0,{PCP_MAP[v]}"}, + "solar_power_priority": {**_enum(PSP_MAP), "encode": lambda v: f"PSP{PSP_MAP[v]}"}, + "max_charging_current": {**_amps(ALLOWED_MCHGC), "encode": lambda v: f"MCHGC0,{int(v):03d}"}, + "max_utility_charging_current": {**_amps(ALLOWED_MUCHGC), "encode": lambda v: f"MUCHGC0,{int(v):03d}"}, +} + +log = logging.getLogger("lvx-control") + + +def _read_broker_config() -> tuple[str, int, str, str]: + """Pull MQTT host/port/user/pass from the existing powermon.yaml.""" + cfg_path = Path(os.path.expanduser("~/.config/powermon/powermon.yaml")) + with cfg_path.open() as f: + cfg = yaml.safe_load(f) + b = cfg["mqttbroker"] + return b["name"], int(b.get("port", 1883)), b["username"], b["password"] + + +def on_message(client, userdata, msg): + parts = msg.topic.split("/") + if len(parts) < 4 or parts[0] != "solar" or parts[1] != "control": + return + action = parts[-1] + payload = msg.payload.decode("utf-8", errors="replace").strip() + spec = ACTIONS.get(action) + if spec is None: + log.warning("REJECT unknown action=%r (topic=%s)", action, msg.topic) + return + if not spec["validate"](payload): + log.warning("REJECT %s=%r (%s)", action, payload, spec["doc"]) + return + cmd = spec["encode"](payload) + log.info("OK %s=%r -> %s -> mirror to %d inverter(s)", action, payload, cmd, len(ADHOC_TOPICS)) + for t in ADHOC_TOPICS: + client.publish(t, payload=cmd, qos=1) + + +def main() -> int: + logging.basicConfig(level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s") + try: + host, port, user, password = _read_broker_config() + except Exception as e: + log.error("could not load broker creds from powermon.yaml: %s", e) + return 1 + + try: + client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="lvx-control") + except AttributeError: + client = mqtt.Client(client_id="lvx-control") + client.username_pw_set(user, password) + client.on_message = on_message + + log.info("connecting to %s:%d as %r", host, port, user) + client.connect(host, port, keepalive=30) + client.subscribe(f"{CONTROL_PREFIX}/+", qos=1) + log.info("subscribed to %s/+ ; mirroring to %s", + CONTROL_PREFIX, ", ".join(ADHOC_TOPICS)) + log.info("supported actions: %s", ", ".join(sorted(ACTIONS))) + client.loop_forever() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/LVX6048/lvx-flash/flash.py b/LVX6048/lvx-flash/flash.py index 22e7dd5..ea26f74 100755 --- a/LVX6048/lvx-flash/flash.py +++ b/LVX6048/lvx-flash/flash.py @@ -66,7 +66,7 @@ PIRI = { # Keep short — one line each, phrased as constraints. KEY_DOCS: dict[str, str] = { "battery_type": "enum: AGM | FLOODED | USER", - "cutoff_voltage": "V 40.0..48.0 — hard shutdown; must be < stop_discharge_voltage. Only honored when battery_type=USER.", + "cutoff_voltage": "V 40.0..48.0 — hard shutdown; must be <= stop_discharge_voltage. Only honored when battery_type=USER.", "stop_discharge_voltage": "V 44.0..51.0 — switch battery→grid below this (pair: stop_charge_voltage). Only honored when battery_type=USER.", "stop_charge_voltage": "V 0 (=Full) or 48.0..58.0 — switch grid→battery above this (pair: stop_discharge_voltage). Only honored when battery_type=USER.", "bulk_voltage": "V 48.0..58.4 — CC→CV transition (pair: float_voltage; must be >= float_voltage). Only honored when battery_type=USER.", @@ -183,8 +183,9 @@ def _validate(profile: dict) -> list[str]: rng("max_utility_charging_current", 2, 80) if "cutoff_voltage" in profile and "stop_discharge_voltage" in profile: - if profile["cutoff_voltage"] >= profile["stop_discharge_voltage"]: - errs.append("cutoff_voltage must be < stop_discharge_voltage") + # Inverter (and LCD) accept cutoff == stop_discharge; only error if cutoff is higher. + if profile["cutoff_voltage"] > profile["stop_discharge_voltage"]: + errs.append("cutoff_voltage must be <= stop_discharge_voltage") if "float_voltage" in profile and "bulk_voltage" in profile: if profile["float_voltage"] > profile["bulk_voltage"]: errs.append("float_voltage must be <= bulk_voltage") diff --git a/LVX6048/powermon-patches/pi18.py b/LVX6048/powermon-patches/pi18.py index 0a70bb9..3ed372d 100644 --- a/LVX6048/powermon-patches/pi18.py +++ b/LVX6048/powermon-patches/pi18.py @@ -367,6 +367,11 @@ QUERY_COMMANDS = { "description": "Mode inquiry", "result_type": ResultType.SINGLE, "reading_definitions": [ + # Known modes 00..05 are from upstream's table. 06/07 observed + # post-commissioning while the inverter actively bank-charges and + # idles between solar cycles; "Charge" / "Eco" are educated guesses. + # Codes 08..15 are placeholders so an unexpected value doesn't crash + # the service (powermon's OPTION decoder raises KeyError on miss). {"description": "Device Mode", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.OPTION, "options": { @@ -377,6 +382,8 @@ QUERY_COMMANDS = { "04": "Fault", "05": "Hybrid mode(Line mode, Grid mode)", "06": "Charge", + "07": "Eco", + **{f"{i:02d}": f"Mode {i:02d}" for i in range(8, 16)}, } }, ], @@ -528,9 +535,34 @@ QUERY_COMMANDS = { # only fields confirmed against live responses are semantically named here. # The rest are exposed as raw strings so the command doesn't error out, and # can be tightened later as more firmware rev responses are confirmed. - # Observed unit-1 response (Not valid + fault 71 "Parallel version different"): + # + # Observed unit-1 response — pre-commissioning, fault 71: # 0,4,71,2453,599,0000,000,0000,0000,00000,00000,000,211,005,000,000,000, # 000,0008,0000,2925,0000,1,0,0,0,0,0,016 + # + # Observed (2026-04-26) — post-commissioning, unit-1 (slave) querying both: + # PGS0 (master): 1,6,0,0,0000,000,0000,0000,00000,00000,000,000,536,000, + # 4,7,54,252,0,2738,0,2,0,0,1,0,0,030 + # PGS1 (slave) : 1,6,0,0,0000,000,0000,0000,00000,00000,000,000,536,000, + # 2,6,54,237,0,2682,0,2,0,0,1,0,0,030 + # Cross-referencing against GS at the same moment (battery_voltage=53.6 V, + # battery_capacity=54 %, master charging_current=4 A, slave=3 A): + # field_14 (idx 13) = 536 → battery voltage * 10 (V) [confirmed] + # field_18 (idx 17) = 54 → battery capacity (%) [confirmed] + # field_16 (idx 15) = 4 / 2 → likely battery_charging_current (A) + # — master 4 A matches; slave 2 vs GS 3 A + # (sample-time skew between the two reads) + # field_17 (idx 16) = 7 / 6 → small int that follows charging side; + # candidate: heat-sink temp (°C – 20)? + # field_21 (idx 20) = 2738 / 2682 → 4-digit, fluctuates per-unit; + # candidate: mppt1_input_voltage*10 (was + # 270.7 / 271.8 V — close, not exact); + # might be a cumulative counter instead. + # field_1 ("parallel_unit_count") reports 6 with a 2-unit cluster — name + # is wrong; not yet identified. + # + # Next-pass plan: capture under load (AC out enabled) and during + # battery discharge to disambiguate currents/voltages from counters. "PGS": { "name": "PGS", "description": "Parallel general status inquiry", @@ -538,10 +570,13 @@ QUERY_COMMANDS = { "command_type": CommandType.PI18_QUERY, "result_type": ResultType.COMMA_DELIMITED, "reading_definitions": [ - # See note on the matching field in GS — same fix here. - {"description": "Parallel instance number", "reading_type": ReadingType.MESSAGE, - "response_type": ResponseType.OPTION, - "options": {str(i): f"instance {i}{' (master)' if i == 0 else ''}" for i in range(9)}}, + # PGS field 0 has DIFFERENT semantics from GS field 27 despite sharing + # this description in upstream powermon. Live captures show this field + # always returns 1 regardless of the queried instance, so it appears to + # be a "valid response received" flag — not the instance index. Kept + # as a 2-value flag here; the GS version is the actual instance index. + {"description": "Parallel response valid", "reading_type": ReadingType.MESSAGE, + "response_type": ResponseType.LIST, "options": ["Not valid", "valid"]}, {"description": "Parallel unit count", "reading_type": ReadingType.MESSAGE}, {"description": "Fault code", "reading_type": ReadingType.MESSAGE}, {"description": "Field 4 (raw)", "reading_type": ReadingType.MESSAGE}, @@ -558,11 +593,15 @@ QUERY_COMMANDS = { {"description": "Total AC output active power", "reading_type": ReadingType.WATTS, "response_type": ResponseType.INT}, {"description": "Load percentage", "reading_type": ReadingType.PERCENTAGE, "response_type": ResponseType.INT}, {"description": "Field 13 (raw)", "reading_type": ReadingType.MESSAGE}, - {"description": "Field 14 (raw)", "reading_type": ReadingType.MESSAGE}, + # Identified 2026-04-26 by cross-reference with GS battery_voltage: + {"description": "Battery voltage (parallel view)", "reading_type": ReadingType.VOLTS, + "response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "device_class": "voltage"}, {"description": "Field 15 (raw)", "reading_type": ReadingType.MESSAGE}, {"description": "Field 16 (raw)", "reading_type": ReadingType.MESSAGE}, {"description": "Field 17 (raw)", "reading_type": ReadingType.MESSAGE}, - {"description": "Field 18 (raw)", "reading_type": ReadingType.MESSAGE}, + # Identified 2026-04-26 by cross-reference with GS battery_capacity: + {"description": "Battery capacity (parallel view)", "reading_type": ReadingType.PERCENTAGE, + "response_type": ResponseType.INT}, {"description": "Field 19 (raw)", "reading_type": ReadingType.MESSAGE}, {"description": "Field 20 (raw)", "reading_type": ReadingType.MESSAGE}, {"description": "Field 21 (raw)", "reading_type": ReadingType.MESSAGE},