add controls to lvx
This commit is contained in:
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())
|
||||
Reference in New Issue
Block a user