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

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