add controls to lvx
This commit is contained in:
@@ -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
|
||||
|
||||
164
LVX6048/2026-04-27-control.md
Normal file
164
LVX6048/2026-04-27-control.md
Normal 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 (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/<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 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 <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.
|
||||
@@ -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, 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
14
LVX6048/etc/systemd/system/lvx-control.service
Normal file
14
LVX6048/etc/systemd/system/lvx-control.service
Normal 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
|
||||
106
LVX6048/homeassistant/README.md
Normal file
106
LVX6048/homeassistant/README.md
Normal 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, 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 <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**.
|
||||
85
LVX6048/homeassistant/lovelace_controls.yaml
Normal file
85
LVX6048/homeassistant/lovelace_controls.yaml
Normal 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
|
||||
81
LVX6048/homeassistant/mqtt_controls.yaml
Normal file
81
LVX6048/homeassistant/mqtt_controls.yaml
Normal 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
|
||||
@@ -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"
|
||||
|
||||
87
LVX6048/lvx-control/README.md
Normal file
87
LVX6048/lvx-control/README.md
Normal 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
133
LVX6048/lvx-control/lvx-control
Executable 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())
|
||||
@@ -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")
|
||||
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user