725 lines
29 KiB
Python
725 lines
29 KiB
Python
|
|
#!/home/noise/.local/share/uv/tools/powermon/bin/python
|
||
|
|
"""
|
||
|
|
lvx-flash — apply a YAML settings profile to an LVX6048 via PI18 over USB-HID.
|
||
|
|
|
||
|
|
Usage:
|
||
|
|
./flash.py dump --device /dev/lvx6048-1 --out profiles/current.yaml
|
||
|
|
./flash.py diff --device /dev/lvx6048-1 --profile profiles/X.yaml
|
||
|
|
./flash.py apply --device /dev/lvx6048-1 --profile profiles/X.yaml --confirm
|
||
|
|
./flash.py compare --device-a /dev/lvx6048-1 --device-b /dev/lvx6048-2
|
||
|
|
./flash.py sync-check --device-a /dev/lvx6048-1 --device-b /dev/lvx6048-2
|
||
|
|
|
||
|
|
Safety:
|
||
|
|
- `apply` stops powermon.service for the duration and restarts it afterwards.
|
||
|
|
- Every set is followed by a PIRI readback that must match, or the run aborts.
|
||
|
|
- Values are range-checked before any write.
|
||
|
|
- Settings are applied in a safe order (cutoff < stop-discharge < float < bulk).
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import asyncio
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Any, Callable
|
||
|
|
|
||
|
|
import yaml
|
||
|
|
|
||
|
|
from powermon.commands.command import Command
|
||
|
|
from powermon.commands.result import ResultType
|
||
|
|
from powermon.protocols import get_protocol_definition
|
||
|
|
from powermon.ports.usbport import USBPort
|
||
|
|
|
||
|
|
|
||
|
|
PROTOCOL = "PI18"
|
||
|
|
UNIT_INDEX = 0 # parallel-stack unit index for PCP / MCHGC / MUCHGC. Master = 0.
|
||
|
|
|
||
|
|
# Default serial numbers for this stack. Used when --device / --serial are
|
||
|
|
# omitted; each command will glob /dev/hidraw* and probe PI18 ID on each to
|
||
|
|
# find the matching unit, so cable/hub-port moves do not require reconfig.
|
||
|
|
SERIAL_UNIT_1 = "1496142109100037000000"
|
||
|
|
SERIAL_UNIT_2 = "1496142408100255000000"
|
||
|
|
|
||
|
|
# -------- profile-key → PI18 encoding + PIRI readback index --------
|
||
|
|
|
||
|
|
# PIRI test response has 26 fields; indices below are 0-based.
|
||
|
|
# Source: powermon/protocols/pi18.py :: QUERY_COMMANDS["PIRI"].reading_definitions
|
||
|
|
PIRI = {
|
||
|
|
"battery_voltage": 7, # V (rated, read-only)
|
||
|
|
"stop_discharge_voltage": 8, # V (BUCD field 1, a.k.a. "re-charge")
|
||
|
|
"stop_charge_voltage": 9, # V (BUCD field 2, a.k.a. "re-discharge"; 0 = Full)
|
||
|
|
"cutoff_voltage": 10, # V (PSDV)
|
||
|
|
"bulk_voltage": 11, # V (MCHGV field 1)
|
||
|
|
"float_voltage": 12, # V (MCHGV field 2)
|
||
|
|
"battery_type": 13, # enum index (PBT)
|
||
|
|
"max_utility_charging_current": 14, # A (MUCHGC)
|
||
|
|
"max_charging_current": 15, # A (MCHGC)
|
||
|
|
"output_source_priority": 17, # enum index (POP)
|
||
|
|
"charger_priority": 18, # enum index (PCP)
|
||
|
|
"machine_type": 20, # enum index (PEI/PDI: 0=Off Grid, 1=Grid Tie)
|
||
|
|
"solar_power_priority": 23, # enum index (PSP)
|
||
|
|
}
|
||
|
|
|
||
|
|
# Per-key guidance emitted as inline comments by `dump`.
|
||
|
|
# 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.",
|
||
|
|
"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.",
|
||
|
|
"float_voltage": "V 48.0..58.4 — held while on grid (pair: bulk_voltage; must be <= bulk_voltage). Only honored when battery_type=USER.",
|
||
|
|
"max_charging_current": "A 10,20,30,40,50,60,70,80 — combined solar+AC cap",
|
||
|
|
"max_utility_charging_current": "A 2,10,20,30,40,50,60,70,80 — grid-side cap only",
|
||
|
|
"output_source_priority": "enum: solar_utility_battery | solar_battery_utility",
|
||
|
|
"charger_priority": "enum: solar_first | solar_and_utility | solar_only",
|
||
|
|
"solar_power_priority": "enum: battery_load_utility_ac | load_battery_utility",
|
||
|
|
"grid_tie": "enum: enabled | disabled (PEI/PDI)",
|
||
|
|
}
|
||
|
|
|
||
|
|
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"}
|
||
|
|
PBT_MAP = {"AGM": "0", "FLOODED": "1", "USER": "2"}
|
||
|
|
|
||
|
|
# enum indices in PIRI (for readback verification)
|
||
|
|
POP_PIRI = {"0": "solar_utility_battery", "1": "solar_battery_utility"}
|
||
|
|
PCP_PIRI = {"0": "solar_first", "1": "solar_and_utility", "2": "solar_only"}
|
||
|
|
PSP_PIRI = {"0": "battery_load_utility_ac", "1": "load_battery_utility"}
|
||
|
|
PBT_PIRI = {"0": "AGM", "1": "FLOODED", "2": "USER"}
|
||
|
|
MACHINE_PIRI = {"0": "off_grid", "1": "grid_tie"}
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class Setting:
|
||
|
|
key: str
|
||
|
|
encoder: Callable[[Any], str] # profile value -> PI18 raw command (no prefix/CRC)
|
||
|
|
decoder: Callable[[str], Any] # PIRI raw field -> profile value
|
||
|
|
piri_field: int
|
||
|
|
pair_keys: tuple[str, ...] = () # if set, these keys are encoded together in one command
|
||
|
|
|
||
|
|
|
||
|
|
def _v_to_tenths(v: float | int) -> str:
|
||
|
|
return f"{int(round(float(v) * 10)):03d}"
|
||
|
|
|
||
|
|
|
||
|
|
def _tenths_to_v(raw: str) -> float:
|
||
|
|
return int(raw) / 10.0
|
||
|
|
|
||
|
|
|
||
|
|
def _amps_to_str(a: int) -> str:
|
||
|
|
return f"{int(a):03d}"
|
||
|
|
|
||
|
|
|
||
|
|
def _amps_from_str(raw: str) -> int:
|
||
|
|
return int(raw)
|
||
|
|
|
||
|
|
|
||
|
|
# Applied in this order. Safe: set low-range protections before high-range; currents before priorities.
|
||
|
|
SCHEDULE: list[Setting] = [
|
||
|
|
Setting("battery_type",
|
||
|
|
encoder=lambda v: f"PBT{PBT_MAP[v]}",
|
||
|
|
decoder=lambda r: PBT_PIRI.get(r.lstrip("0") or "0", r),
|
||
|
|
piri_field=PIRI["battery_type"]),
|
||
|
|
Setting("cutoff_voltage",
|
||
|
|
encoder=lambda v: f"PSDV{_v_to_tenths(v)}",
|
||
|
|
decoder=_tenths_to_v,
|
||
|
|
piri_field=PIRI["cutoff_voltage"]),
|
||
|
|
# BUCD sets stop-discharge and stop-charge together
|
||
|
|
Setting("stop_discharge_voltage",
|
||
|
|
encoder=lambda pair: f"BUCD{_v_to_tenths(pair[0])},{'000' if pair[1] in (0,0.0) else _v_to_tenths(pair[1])}",
|
||
|
|
decoder=_tenths_to_v,
|
||
|
|
piri_field=PIRI["stop_discharge_voltage"],
|
||
|
|
pair_keys=("stop_discharge_voltage", "stop_charge_voltage")),
|
||
|
|
# MCHGV sets bulk and float together
|
||
|
|
Setting("bulk_voltage",
|
||
|
|
encoder=lambda pair: f"MCHGV{_v_to_tenths(pair[0])},{_v_to_tenths(pair[1])}",
|
||
|
|
decoder=_tenths_to_v,
|
||
|
|
piri_field=PIRI["bulk_voltage"],
|
||
|
|
pair_keys=("bulk_voltage", "float_voltage")),
|
||
|
|
Setting("max_utility_charging_current",
|
||
|
|
encoder=lambda a: f"MUCHGC{UNIT_INDEX},{_amps_to_str(a)}",
|
||
|
|
decoder=_amps_from_str,
|
||
|
|
piri_field=PIRI["max_utility_charging_current"]),
|
||
|
|
Setting("max_charging_current",
|
||
|
|
encoder=lambda a: f"MCHGC{UNIT_INDEX},{_amps_to_str(a)}",
|
||
|
|
decoder=_amps_from_str,
|
||
|
|
piri_field=PIRI["max_charging_current"]),
|
||
|
|
Setting("output_source_priority",
|
||
|
|
encoder=lambda v: f"POP{POP_MAP[v]}",
|
||
|
|
decoder=lambda r: POP_PIRI.get(r.lstrip("0") or "0", r),
|
||
|
|
piri_field=PIRI["output_source_priority"]),
|
||
|
|
Setting("charger_priority",
|
||
|
|
encoder=lambda v: f"PCP{UNIT_INDEX},{PCP_MAP[v]}",
|
||
|
|
decoder=lambda r: PCP_PIRI.get(r.lstrip("0") or "0", r),
|
||
|
|
piri_field=PIRI["charger_priority"]),
|
||
|
|
Setting("solar_power_priority",
|
||
|
|
encoder=lambda v: f"PSP{PSP_MAP[v]}",
|
||
|
|
decoder=lambda r: PSP_PIRI.get(r.lstrip("0") or "0", r),
|
||
|
|
piri_field=PIRI["solar_power_priority"]),
|
||
|
|
Setting("grid_tie",
|
||
|
|
encoder=lambda v: "PEI" if v == "enabled" else "PDI",
|
||
|
|
decoder=lambda r: "grid_tie" if r.lstrip("0") == "1" else "off_grid",
|
||
|
|
piri_field=PIRI["machine_type"]),
|
||
|
|
]
|
||
|
|
|
||
|
|
# -------- range / consistency validation --------
|
||
|
|
|
||
|
|
def _validate(profile: dict) -> list[str]:
|
||
|
|
errs: list[str] = []
|
||
|
|
def rng(key, lo, hi):
|
||
|
|
if key in profile and not (lo <= profile[key] <= hi):
|
||
|
|
errs.append(f"{key}={profile[key]} out of range [{lo}, {hi}]")
|
||
|
|
|
||
|
|
rng("cutoff_voltage", 40.0, 48.0)
|
||
|
|
rng("stop_discharge_voltage", 44.0, 51.0)
|
||
|
|
if "stop_charge_voltage" in profile and profile["stop_charge_voltage"] != 0:
|
||
|
|
rng("stop_charge_voltage", 48.0, 58.0)
|
||
|
|
rng("bulk_voltage", 48.0, 58.4)
|
||
|
|
rng("float_voltage", 48.0, 58.4)
|
||
|
|
rng("max_charging_current", 10, 80)
|
||
|
|
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")
|
||
|
|
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")
|
||
|
|
if "max_charging_current" in profile and profile["max_charging_current"] not in (10,20,30,40,50,60,70,80):
|
||
|
|
errs.append("max_charging_current must be one of 10,20,...,80")
|
||
|
|
if "max_utility_charging_current" in profile and profile["max_utility_charging_current"] not in (2,10,20,30,40,50,60,70,80):
|
||
|
|
errs.append("max_utility_charging_current must be one of 2,10,20,...,80")
|
||
|
|
|
||
|
|
for k, allowed in [("output_source_priority", POP_MAP), ("charger_priority", PCP_MAP),
|
||
|
|
("solar_power_priority", PSP_MAP), ("battery_type", PBT_MAP)]:
|
||
|
|
if k in profile and profile[k] not in allowed:
|
||
|
|
errs.append(f"{k}={profile[k]!r} not in {list(allowed)}")
|
||
|
|
if "grid_tie" in profile and profile["grid_tie"] not in ("enabled", "disabled"):
|
||
|
|
errs.append("grid_tie must be 'enabled' or 'disabled'")
|
||
|
|
return errs
|
||
|
|
|
||
|
|
# -------- powermon glue --------
|
||
|
|
|
||
|
|
async def _open_port(path: str) -> USBPort:
|
||
|
|
protocol = get_protocol_definition(protocol=PROTOCOL)
|
||
|
|
port = USBPort(path=path, protocol=protocol)
|
||
|
|
port.path = path
|
||
|
|
await port.connect()
|
||
|
|
if not port.is_connected():
|
||
|
|
raise RuntimeError(f"could not open {path}")
|
||
|
|
return port
|
||
|
|
|
||
|
|
|
||
|
|
async def _resolve_path(device: str | None, serial: str | None) -> str:
|
||
|
|
"""Return a concrete device path.
|
||
|
|
|
||
|
|
If `serial` is given, glob /dev/hidraw* and probe each candidate via PI18 ID
|
||
|
|
until one matches. Otherwise return `device` verbatim (typically the
|
||
|
|
resolver-maintained /dev/lvx6048-N symlink). --serial takes precedence for
|
||
|
|
cases where the resolver hasn't run (e.g. early boot, bare hidraw probing).
|
||
|
|
"""
|
||
|
|
import glob as _glob
|
||
|
|
if serial:
|
||
|
|
pass # fall through to probe
|
||
|
|
elif device:
|
||
|
|
return device
|
||
|
|
else:
|
||
|
|
raise RuntimeError("must supply --device or --serial")
|
||
|
|
candidates = sorted(_glob.glob("/dev/hidraw*"))
|
||
|
|
if not candidates:
|
||
|
|
raise RuntimeError("no /dev/hidraw* devices present")
|
||
|
|
for path in candidates:
|
||
|
|
try:
|
||
|
|
port = await _open_port(path)
|
||
|
|
except Exception:
|
||
|
|
continue
|
||
|
|
try:
|
||
|
|
parts = await _read_raw_parts(port, "ID")
|
||
|
|
# ID returns one field, so parts[0] is the inverter serial
|
||
|
|
if parts and parts[0] == str(serial):
|
||
|
|
return path
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
finally:
|
||
|
|
await port.disconnect()
|
||
|
|
raise RuntimeError(f"no device at /dev/hidraw* responds with serial {serial!r}")
|
||
|
|
|
||
|
|
|
||
|
|
def _build_command(code: str, proto) -> Command:
|
||
|
|
cd = proto.get_command_definition(code)
|
||
|
|
if cd is None:
|
||
|
|
raise RuntimeError(f"unknown PI18 command: {code!r}")
|
||
|
|
cmd = Command(code=code, commandtype="basic", outputs=[], trigger=None)
|
||
|
|
cmd.command_definition = cd
|
||
|
|
cmd.full_command = proto.get_full_command(code)
|
||
|
|
return cmd
|
||
|
|
|
||
|
|
|
||
|
|
async def _send(port: USBPort, code: str):
|
||
|
|
cmd = _build_command(code, port.protocol)
|
||
|
|
result = await port.send_and_receive(cmd)
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
async def _read_piri(port: USBPort, retries: int = 3) -> dict[str, str]:
|
||
|
|
"""Return {name: raw_string} for each PIRI field.
|
||
|
|
|
||
|
|
Retries on transient decode failures — powermon's parser can IndexError when
|
||
|
|
the hidraw fd still holds leftover bytes from a prior command (e.g. running
|
||
|
|
multiple queries back-to-back in sync-check).
|
||
|
|
"""
|
||
|
|
cmd = _build_command("PIRI", port.protocol)
|
||
|
|
last_err: Any = None
|
||
|
|
for _ in range(retries):
|
||
|
|
try:
|
||
|
|
result = await port.send_and_receive(cmd)
|
||
|
|
except (IndexError, KeyError, ValueError) as e:
|
||
|
|
last_err = e
|
||
|
|
await asyncio.sleep(0.3)
|
||
|
|
continue
|
||
|
|
if result is not None and getattr(result, "is_valid", True):
|
||
|
|
raw = result.raw_response
|
||
|
|
if raw.startswith(b"^D"):
|
||
|
|
raw = raw[5:] # ^D + 3-digit length
|
||
|
|
if raw.endswith(b"\r"):
|
||
|
|
raw = raw[:-3] # 2-byte CRC + \r
|
||
|
|
parts = raw.decode("ascii", errors="replace").split(",")
|
||
|
|
return {i: p for i, p in enumerate(parts)} | {"_parts": parts}
|
||
|
|
last_err = getattr(result, "error_messages", None) or result
|
||
|
|
await asyncio.sleep(0.3)
|
||
|
|
raise RuntimeError(f"PIRI read failed after {retries} attempts: {last_err}")
|
||
|
|
|
||
|
|
|
||
|
|
def _piri_raw(piri: dict, idx: int) -> str:
|
||
|
|
return piri["_parts"][idx]
|
||
|
|
|
||
|
|
|
||
|
|
async def _wait_ack(port: USBPort, code: str) -> bool:
|
||
|
|
"""Send a setter. Return True on ^1, False on ^0 / unknown."""
|
||
|
|
cmd = _build_command(code, port.protocol)
|
||
|
|
result = await port.send_and_receive(cmd)
|
||
|
|
raw = getattr(result, "raw_response", b"") or b""
|
||
|
|
# setter response framed as ^Dlll^1... or similar. Just look for ^1 / ^0 anywhere.
|
||
|
|
if b"^1" in raw:
|
||
|
|
return True
|
||
|
|
if b"^0" in raw:
|
||
|
|
return False
|
||
|
|
# fallback: inspect parsed readings
|
||
|
|
for r in (result.readings or []):
|
||
|
|
v = str(r.data_value).lower()
|
||
|
|
if "succeed" in v:
|
||
|
|
return True
|
||
|
|
if "fail" in v:
|
||
|
|
return False
|
||
|
|
return False
|
||
|
|
|
||
|
|
# -------- dump / diff / apply --------
|
||
|
|
|
||
|
|
def _dump_profile(piri_raw: list[str]) -> dict:
|
||
|
|
"""Convert PIRI raw fields into a profile dict, using the SCHEDULE decoders."""
|
||
|
|
out: dict[str, Any] = {}
|
||
|
|
# walk SCHEDULE once; pairs already have both halves
|
||
|
|
for s in SCHEDULE:
|
||
|
|
if s.key == "grid_tie":
|
||
|
|
mt = piri_raw[PIRI["machine_type"]]
|
||
|
|
out["grid_tie"] = "enabled" if mt.lstrip("0") == "1" else "disabled"
|
||
|
|
continue
|
||
|
|
out[s.key] = s.decoder(piri_raw[s.piri_field])
|
||
|
|
# fill pair's partner keys too (bulk/float already via bulk_voltage row; pair_keys picks up the second)
|
||
|
|
# BUCD pair
|
||
|
|
out["stop_discharge_voltage"] = _tenths_to_v(piri_raw[PIRI["stop_discharge_voltage"]])
|
||
|
|
out["stop_charge_voltage"] = _tenths_to_v(piri_raw[PIRI["stop_charge_voltage"]])
|
||
|
|
out["bulk_voltage"] = _tenths_to_v(piri_raw[PIRI["bulk_voltage"]])
|
||
|
|
out["float_voltage"] = _tenths_to_v(piri_raw[PIRI["float_voltage"]])
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def _diff(want: dict, have: dict) -> list[tuple[str, Any, Any]]:
|
||
|
|
diffs = []
|
||
|
|
for k, v in want.items():
|
||
|
|
if k not in have:
|
||
|
|
continue
|
||
|
|
hv = have[k]
|
||
|
|
if isinstance(v, float) or isinstance(hv, float):
|
||
|
|
if abs(float(v) - float(hv)) > 0.05:
|
||
|
|
diffs.append((k, hv, v))
|
||
|
|
else:
|
||
|
|
if v != hv:
|
||
|
|
diffs.append((k, hv, v))
|
||
|
|
return diffs
|
||
|
|
|
||
|
|
|
||
|
|
def _systemctl(action: str) -> None:
|
||
|
|
# Stop both units so neither holds the hidraw fd for the one we're writing to.
|
||
|
|
subprocess.run(["sudo", "systemctl", action, "powermon.service", "powermon2.service"], check=True)
|
||
|
|
|
||
|
|
|
||
|
|
async def cmd_dump(args) -> int:
|
||
|
|
path = await _resolve_path(args.device, args.serial)
|
||
|
|
port = await _open_port(path)
|
||
|
|
try:
|
||
|
|
piri = await _read_piri(port)
|
||
|
|
finally:
|
||
|
|
await port.disconnect()
|
||
|
|
prof = _dump_profile(piri["_parts"])
|
||
|
|
# Drop any keys whose values aren't round-trippable via their PI18 setter.
|
||
|
|
errs = _validate(prof)
|
||
|
|
skipped: list[str] = []
|
||
|
|
for e in errs:
|
||
|
|
for k in list(prof):
|
||
|
|
if e.startswith(k + "=") or e.startswith(k + " "):
|
||
|
|
if k in prof:
|
||
|
|
skipped.append(f"{k}={prof.pop(k)!r} ({e})")
|
||
|
|
break
|
||
|
|
out_path = Path(args.out)
|
||
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
with out_path.open("w") as f:
|
||
|
|
f.write("# LVX6048 settings profile. Edit freely; all keys are optional.\n")
|
||
|
|
f.write("# `flash.py diff` previews changes; `flash.py apply --confirm` writes.\n")
|
||
|
|
if skipped:
|
||
|
|
f.write("#\n# skipped (read-only or out of settable range):\n")
|
||
|
|
for s in skipped:
|
||
|
|
f.write(f"# {s}\n")
|
||
|
|
f.write("\n")
|
||
|
|
for k, v in prof.items():
|
||
|
|
doc = KEY_DOCS.get(k)
|
||
|
|
if doc:
|
||
|
|
f.write(f"# {doc}\n")
|
||
|
|
# one-key dump preserves native YAML formatting for scalars
|
||
|
|
f.write(yaml.safe_dump({k: v}, sort_keys=False, default_flow_style=False))
|
||
|
|
f.write("\n")
|
||
|
|
print(f"wrote {out_path} with {len(prof)} keys" + (f" ({len(skipped)} skipped)" if skipped else ""))
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
async def cmd_diff(args) -> int:
|
||
|
|
with open(args.profile) as f:
|
||
|
|
want = yaml.safe_load(f) or {}
|
||
|
|
errs = _validate(want)
|
||
|
|
if errs:
|
||
|
|
for e in errs:
|
||
|
|
print(f"INVALID: {e}")
|
||
|
|
return 2
|
||
|
|
path = await _resolve_path(args.device, args.serial)
|
||
|
|
port = await _open_port(path)
|
||
|
|
try:
|
||
|
|
piri = await _read_piri(port)
|
||
|
|
finally:
|
||
|
|
await port.disconnect()
|
||
|
|
have = _dump_profile(piri["_parts"])
|
||
|
|
diffs = _diff(want, have)
|
||
|
|
if not diffs:
|
||
|
|
print("no diff — profile matches device")
|
||
|
|
return 0
|
||
|
|
print(f"{len(diffs)} setting(s) would change:")
|
||
|
|
for k, hv, wv in diffs:
|
||
|
|
print(f" {k}: {hv!r} -> {wv!r}")
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
async def cmd_apply(args) -> int:
|
||
|
|
with open(args.profile) as f:
|
||
|
|
want = yaml.safe_load(f) or {}
|
||
|
|
errs = _validate(want)
|
||
|
|
if errs:
|
||
|
|
for e in errs:
|
||
|
|
print(f"INVALID: {e}")
|
||
|
|
return 2
|
||
|
|
if not args.confirm:
|
||
|
|
print("refusing to write without --confirm (use `diff` to preview)")
|
||
|
|
return 2
|
||
|
|
|
||
|
|
path = await _resolve_path(args.device, args.serial)
|
||
|
|
print("stopping powermon.service...")
|
||
|
|
_systemctl("stop")
|
||
|
|
try:
|
||
|
|
port = await _open_port(path)
|
||
|
|
try:
|
||
|
|
piri = await _read_piri(port)
|
||
|
|
have = _dump_profile(piri["_parts"])
|
||
|
|
diffs = _diff(want, have)
|
||
|
|
if not diffs:
|
||
|
|
print("nothing to do")
|
||
|
|
return 0
|
||
|
|
|
||
|
|
pending = {k for k, _, _ in diffs}
|
||
|
|
applied: list[str] = []
|
||
|
|
for s in SCHEDULE:
|
||
|
|
# handle pair settings once
|
||
|
|
if s.pair_keys:
|
||
|
|
a, b = s.pair_keys
|
||
|
|
if a not in pending and b not in pending:
|
||
|
|
continue
|
||
|
|
if a not in want or b not in want:
|
||
|
|
print(f"SKIP {a}/{b}: pair requires both keys in profile")
|
||
|
|
continue
|
||
|
|
code = s.encoder((want[a], want[b]))
|
||
|
|
print(f"-> {code} ({a}={want[a]}, {b}={want[b]})")
|
||
|
|
ok = await _wait_ack(port, code)
|
||
|
|
if not ok:
|
||
|
|
print(f"FAIL: inverter NAK on {code}")
|
||
|
|
return 3
|
||
|
|
# readback
|
||
|
|
piri2 = await _read_piri(port)
|
||
|
|
new_a = _tenths_to_v(piri2["_parts"][PIRI[a]])
|
||
|
|
new_b = _tenths_to_v(piri2["_parts"][PIRI[b]])
|
||
|
|
if abs(new_a - float(want[a])) > 0.05 or abs(new_b - float(want[b])) > 0.05:
|
||
|
|
print(f"FAIL: readback mismatch: {a}={new_a}, {b}={new_b}")
|
||
|
|
return 3
|
||
|
|
applied += [a, b]
|
||
|
|
continue
|
||
|
|
if s.key not in pending:
|
||
|
|
continue
|
||
|
|
code = s.encoder(want[s.key])
|
||
|
|
print(f"-> {code} ({s.key}={want[s.key]})")
|
||
|
|
ok = await _wait_ack(port, code)
|
||
|
|
if not ok:
|
||
|
|
print(f"FAIL: inverter NAK on {code}")
|
||
|
|
return 3
|
||
|
|
piri2 = await _read_piri(port)
|
||
|
|
actual = s.decoder(piri2["_parts"][s.piri_field])
|
||
|
|
expected = want[s.key]
|
||
|
|
if isinstance(actual, float):
|
||
|
|
match = abs(float(actual) - float(expected)) <= 0.05
|
||
|
|
else:
|
||
|
|
match = actual == expected or (s.key == "grid_tie" and actual == ("grid_tie" if expected == "enabled" else "off_grid"))
|
||
|
|
if not match:
|
||
|
|
print(f"FAIL: readback mismatch on {s.key}: got {actual!r}, want {expected!r}")
|
||
|
|
return 3
|
||
|
|
applied.append(s.key)
|
||
|
|
|
||
|
|
print(f"OK — applied {len(applied)} setting(s): {', '.join(applied)}")
|
||
|
|
return 0
|
||
|
|
finally:
|
||
|
|
await port.disconnect()
|
||
|
|
finally:
|
||
|
|
print("restarting powermon.service...")
|
||
|
|
_systemctl("start")
|
||
|
|
|
||
|
|
|
||
|
|
# -------- sync-check: are the two units in a valid parallel state? --------
|
||
|
|
|
||
|
|
# PI18 MOD codes
|
||
|
|
MOD_NAMES = {
|
||
|
|
"00": "Power on", "01": "Standby", "02": "Bypass",
|
||
|
|
"03": "Battery", "04": "Fault", "05": "Hybrid",
|
||
|
|
}
|
||
|
|
|
||
|
|
# PI18 FWS fault-code map (cross-referenced with PI30 QPGS fault codes)
|
||
|
|
FAULT_NAMES = {
|
||
|
|
"00": "No fault",
|
||
|
|
"01": "Fan is locked",
|
||
|
|
"02": "Over temperature",
|
||
|
|
"03": "Battery voltage is too high",
|
||
|
|
"04": "Battery voltage is too low",
|
||
|
|
"05": "Output short circuited or Over temperature",
|
||
|
|
"06": "Output voltage is too high",
|
||
|
|
"07": "Over load time out",
|
||
|
|
"08": "Bus voltage is too high",
|
||
|
|
"09": "Bus soft start failed",
|
||
|
|
"11": "Main relay failed",
|
||
|
|
"51": "Over current inverter",
|
||
|
|
"52": "Bus soft start failed",
|
||
|
|
"53": "Inverter soft start failed",
|
||
|
|
"54": "Self-test failed",
|
||
|
|
"55": "Over DC voltage on output of inverter",
|
||
|
|
"56": "Battery connection is open",
|
||
|
|
"57": "Current sensor failed",
|
||
|
|
"58": "Output voltage is too low",
|
||
|
|
"60": "Inverter negative power",
|
||
|
|
"71": "Parallel version different",
|
||
|
|
"72": "Output circuit failed",
|
||
|
|
"80": "CAN communication failed",
|
||
|
|
"81": "Parallel host line lost",
|
||
|
|
"82": "Parallel synchronized signal lost",
|
||
|
|
"83": "Parallel battery voltage detect different",
|
||
|
|
"84": "Parallel Line voltage or frequency detect different",
|
||
|
|
"85": "Parallel Line input current unbalanced",
|
||
|
|
"86": "Parallel output setting different",
|
||
|
|
}
|
||
|
|
|
||
|
|
# GS field indices (see powermon/protocols/pi18.py :: QUERY_COMMANDS["GS"])
|
||
|
|
GS_AC_OUTPUT_V = 2
|
||
|
|
GS_AC_OUTPUT_HZ = 3
|
||
|
|
GS_PARALLEL_VALID = 27
|
||
|
|
|
||
|
|
|
||
|
|
async def _read_raw_parts(port: USBPort, code: str, retries: int = 3) -> list[str]:
|
||
|
|
cmd = _build_command(code, port.protocol)
|
||
|
|
last_err: Any = None
|
||
|
|
for _ in range(retries):
|
||
|
|
result = await port.send_and_receive(cmd)
|
||
|
|
if result is not None and getattr(result, "is_valid", False):
|
||
|
|
raw = result.raw_response
|
||
|
|
if raw.startswith(b"^D"):
|
||
|
|
raw = raw[5:] # ^D + 3-digit length
|
||
|
|
if raw.endswith(b"\r"):
|
||
|
|
raw = raw[:-3] # 2-byte CRC + \r
|
||
|
|
return raw.decode("ascii", errors="replace").split(",")
|
||
|
|
last_err = getattr(result, "error_messages", None) or result
|
||
|
|
await asyncio.sleep(0.3)
|
||
|
|
raise RuntimeError(f"{code} read failed after {retries} attempts: {last_err}")
|
||
|
|
|
||
|
|
|
||
|
|
async def _snapshot_sync(path: str) -> dict[str, Any]:
|
||
|
|
port = await _open_port(path)
|
||
|
|
try:
|
||
|
|
gs = await _read_raw_parts(port, "GS")
|
||
|
|
fws = await _read_raw_parts(port, "FWS")
|
||
|
|
mod = await _read_raw_parts(port, "MOD")
|
||
|
|
vfw = await _read_raw_parts(port, "VFW")
|
||
|
|
finally:
|
||
|
|
await port.disconnect()
|
||
|
|
return {
|
||
|
|
"parallel_valid": gs[GS_PARALLEL_VALID] == "1",
|
||
|
|
"ac_output_v": _tenths_to_v(gs[GS_AC_OUTPUT_V]),
|
||
|
|
"ac_output_hz": int(gs[GS_AC_OUTPUT_HZ]) / 10.0,
|
||
|
|
"fault_code": fws[0],
|
||
|
|
"fault_name": FAULT_NAMES.get(fws[0], f"unknown ({fws[0]})"),
|
||
|
|
"mode_raw": mod[0],
|
||
|
|
"mode": MOD_NAMES.get(mod[0], mod[0]),
|
||
|
|
"main_cpu": vfw[0],
|
||
|
|
"slave_cpu": vfw[1],
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
async def cmd_sync_check(args) -> int:
|
||
|
|
path_a = await _resolve_path(args.device_a, args.serial_a)
|
||
|
|
path_b = await _resolve_path(args.device_b, args.serial_b)
|
||
|
|
a = await _snapshot_sync(path_a)
|
||
|
|
b = await _snapshot_sync(path_b)
|
||
|
|
|
||
|
|
def _row(label: str, s: dict) -> str:
|
||
|
|
valid = "valid" if s["parallel_valid"] else "NOT VALID"
|
||
|
|
return (f"{label}: fw={s['main_cpu']}/{s['slave_cpu']} mode={s['mode']} "
|
||
|
|
f"parallel={valid} fault={s['fault_name']} "
|
||
|
|
f"vac={s['ac_output_v']}V fac={s['ac_output_hz']}Hz")
|
||
|
|
|
||
|
|
print(_row(path_a, a))
|
||
|
|
print(_row(path_b, b))
|
||
|
|
|
||
|
|
issues: list[str] = []
|
||
|
|
if a["main_cpu"] != b["main_cpu"] or a["slave_cpu"] != b["slave_cpu"]:
|
||
|
|
issues.append(f"firmware mismatch: {a['main_cpu']}/{a['slave_cpu']} vs {b['main_cpu']}/{b['slave_cpu']} — parallel requires matching firmware on both units")
|
||
|
|
if not a["parallel_valid"]:
|
||
|
|
issues.append(f"{path_a}: GS parallel_instance_number = Not valid")
|
||
|
|
if not b["parallel_valid"]:
|
||
|
|
issues.append(f"{path_b}: GS parallel_instance_number = Not valid")
|
||
|
|
if a["fault_code"] != "00":
|
||
|
|
issues.append(f"{path_a}: active fault {a['fault_code']} ({a['fault_name']})")
|
||
|
|
if b["fault_code"] != "00":
|
||
|
|
issues.append(f"{path_b}: active fault {b['fault_code']} ({b['fault_name']})")
|
||
|
|
if a["mode_raw"] != b["mode_raw"]:
|
||
|
|
issues.append(f"mode differs: {a['mode']} vs {b['mode']}")
|
||
|
|
if abs(a["ac_output_hz"] - b["ac_output_hz"]) > 0.1 and a["ac_output_hz"] > 0 and b["ac_output_hz"] > 0:
|
||
|
|
issues.append(f"AC output frequency diverges: {a['ac_output_hz']}Hz vs {b['ac_output_hz']}Hz (>0.1Hz = not phase-locked)")
|
||
|
|
if abs(a["ac_output_v"] - b["ac_output_v"]) > 2.0 and a["ac_output_v"] > 0 and b["ac_output_v"] > 0:
|
||
|
|
issues.append(f"AC output voltage diverges: {a['ac_output_v']}V vs {b['ac_output_v']}V (>2V gap)")
|
||
|
|
if a["ac_output_v"] == 0 and b["ac_output_v"] == 0:
|
||
|
|
issues.append("both units AC output = 0 V (idle / not producing); frequency/voltage sync cannot be verified until at least one is inverting")
|
||
|
|
|
||
|
|
print()
|
||
|
|
if not issues:
|
||
|
|
print("SYNC OK")
|
||
|
|
return 0
|
||
|
|
print(f"{len(issues)} issue(s):")
|
||
|
|
for i in issues:
|
||
|
|
print(f" - {i}")
|
||
|
|
return 1
|
||
|
|
|
||
|
|
|
||
|
|
async def cmd_compare(args) -> int:
|
||
|
|
async def _snapshot(path: str) -> dict:
|
||
|
|
port = await _open_port(path)
|
||
|
|
try:
|
||
|
|
piri = await _read_piri(port)
|
||
|
|
finally:
|
||
|
|
await port.disconnect()
|
||
|
|
return _dump_profile(piri["_parts"])
|
||
|
|
|
||
|
|
path_a = await _resolve_path(args.device_a, args.serial_a)
|
||
|
|
path_b = await _resolve_path(args.device_b, args.serial_b)
|
||
|
|
a = await _snapshot(path_a)
|
||
|
|
b = await _snapshot(path_b)
|
||
|
|
|
||
|
|
shared = sorted(set(a) & set(b))
|
||
|
|
diffs: list[tuple[str, Any, Any]] = []
|
||
|
|
for k in shared:
|
||
|
|
av, bv = a[k], b[k]
|
||
|
|
if isinstance(av, float) or isinstance(bv, float):
|
||
|
|
if abs(float(av) - float(bv)) > 0.05:
|
||
|
|
diffs.append((k, av, bv))
|
||
|
|
elif av != bv:
|
||
|
|
diffs.append((k, av, bv))
|
||
|
|
|
||
|
|
a_only = sorted(set(a) - set(b))
|
||
|
|
b_only = sorted(set(b) - set(a))
|
||
|
|
|
||
|
|
if not diffs and not a_only and not b_only:
|
||
|
|
print(f"MATCH — {len(shared)} settings identical on {path_a} and {path_b}")
|
||
|
|
return 0
|
||
|
|
|
||
|
|
if diffs:
|
||
|
|
kw = max(len(k) for k, _, _ in diffs)
|
||
|
|
print(f"{len(diffs)} setting(s) differ (of {len(shared)} shared):")
|
||
|
|
for k, av, bv in diffs:
|
||
|
|
print(f" {k:<{kw}} {path_a}={av!r} {path_b}={bv!r}")
|
||
|
|
if a_only:
|
||
|
|
print(f"only on {path_a}: {', '.join(a_only)}")
|
||
|
|
if b_only:
|
||
|
|
print(f"only on {path_b}: {', '.join(b_only)}")
|
||
|
|
return 1
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
ap = argparse.ArgumentParser(description="Flash LVX6048 settings profiles via PI18.")
|
||
|
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
||
|
|
|
||
|
|
def _add_common(p):
|
||
|
|
# --device uses the resolver-maintained symlink by default. --serial is
|
||
|
|
# an explicit fallback that probes /dev/hidraw* via PI18 ID — only use
|
||
|
|
# when the resolver hasn't run (e.g. early boot / debugging).
|
||
|
|
p.add_argument("--device", default="/dev/lvx6048-1",
|
||
|
|
help="device path (default: /dev/lvx6048-1 symlink)")
|
||
|
|
p.add_argument("--serial", default=None,
|
||
|
|
help="override --device by probing /dev/hidraw* for this PI18 serial")
|
||
|
|
|
||
|
|
d = sub.add_parser("dump", help="read current settings into a YAML profile")
|
||
|
|
_add_common(d)
|
||
|
|
d.add_argument("--out", required=True)
|
||
|
|
|
||
|
|
df = sub.add_parser("diff", help="show what would change if a profile were applied")
|
||
|
|
_add_common(df)
|
||
|
|
df.add_argument("--profile", required=True)
|
||
|
|
|
||
|
|
ap_ = sub.add_parser("apply", help="apply a profile (requires --confirm)")
|
||
|
|
_add_common(ap_)
|
||
|
|
ap_.add_argument("--profile", required=True)
|
||
|
|
ap_.add_argument("--confirm", action="store_true")
|
||
|
|
|
||
|
|
def _add_pair(p):
|
||
|
|
p.add_argument("--device-a", default="/dev/lvx6048-1", help="device path for unit A")
|
||
|
|
p.add_argument("--device-b", default="/dev/lvx6048-2", help="device path for unit B")
|
||
|
|
p.add_argument("--serial-a", default=None, help="override --device-a by probing PI18 serial")
|
||
|
|
p.add_argument("--serial-b", default=None, help="override --device-b by probing PI18 serial")
|
||
|
|
|
||
|
|
cp = sub.add_parser("compare", help="diff live settings between two inverters")
|
||
|
|
_add_pair(cp)
|
||
|
|
|
||
|
|
sc = sub.add_parser("sync-check", help="verify two paralleled inverters are in sync")
|
||
|
|
_add_pair(sc)
|
||
|
|
|
||
|
|
args = ap.parse_args()
|
||
|
|
handler = {
|
||
|
|
"dump": cmd_dump, "diff": cmd_diff, "apply": cmd_apply,
|
||
|
|
"compare": cmd_compare, "sync-check": cmd_sync_check,
|
||
|
|
}[args.cmd]
|
||
|
|
return asyncio.run(handler(args))
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
sys.exit(main())
|