add controls to lvx

This commit is contained in:
2026-04-27 06:50:04 -04:00
parent 00ab311d92
commit 04720c3b92
14 changed files with 856 additions and 39 deletions

View File

@@ -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
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

View File

@@ -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/<action>`,
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 (1080 A in 10 A steps)
- `max_utility_charging_current` — MUCHGC (2 / 1080 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/<action>` 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 0815 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 <broker> -u mqtt -P <pass> -v \
-t 'powermon/lvx6048_1/result' \
-t 'powermon/lvx6048_2/result' &
# 3. Fire a control command (no-op against current state)
mosquitto_pub -h <broker> -u mqtt -P <pass> \
-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.

View File

@@ -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/<action>`) 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, 1322, 2328,
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.

View File

@@ -15,6 +15,8 @@ mqttbroker:
port: 1883
username: mqtt
password: <MQTT_PASSWORD>
adhoc_topic: powermon/lvx6048_1/addcommand
adhoc_result_topic: powermon/lvx6048_1/result
commands:
- command: GS

View File

@@ -15,6 +15,8 @@ mqttbroker:
port: 1883
username: mqtt
password: <MQTT_PASSWORD>
adhoc_topic: powermon/lvx6048_2/addcommand
adhoc_result_topic: powermon/lvx6048_2/result
commands:
- command: GS

View File

@@ -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

View File

@@ -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/<action>` 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/<action>
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, 1080 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 <broker> -u mqtt -P <pass> -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**.

View File

@@ -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

View File

@@ -0,0 +1,81 @@
# LVX6048 control entities (HA → lvx-control bridge → both inverters).
#
# Each entity publishes a friendly value to solar/control/lvx6048/<action>;
# 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

View File

@@ -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 '<MQTT_PASSWORD>' "${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"

View File

@@ -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 <broker> -u mqtt -P <pass> -v \
-t 'powermon/lvx6048_1/result' \
-t 'powermon/lvx6048_2/result'
# fire a control command
mosquitto_pub -h <broker> -u mqtt -P <pass> \
-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).

133
LVX6048/lvx-control/lvx-control Executable file
View File

@@ -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/<action>` 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/<action> (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())

View File

@@ -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")

View File

@@ -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},