working in production - not entirely validated. parallel mode working.

This commit is contained in:
2026-04-26 19:26:44 -04:00
parent f771ec2b46
commit 00ab311d92
8 changed files with 344 additions and 27 deletions

View File

@@ -0,0 +1,39 @@
# LVX6048 settings profile. Edit freely; all keys are optional.
# `flash.py diff` previews changes; `flash.py apply --confirm` writes.
#
# skipped (read-only or out of settable range):
# max_charging_current=100 (max_charging_current=100 out of range [10, 80])
# enum: AGM | FLOODED | USER
battery_type: AGM
# V 40.0..48.0 — hard shutdown; must be < stop_discharge_voltage. Only honored when battery_type=USER.
cutoff_voltage: 40.8
# V 44.0..51.0 — switch battery→grid below this (pair: stop_charge_voltage). Only honored when battery_type=USER.
stop_discharge_voltage: 46.0
# V 48.0..58.4 — CC→CV transition (pair: float_voltage; must be >= float_voltage). Only honored when battery_type=USER.
bulk_voltage: 56.4
# A 2,10,20,30,40,50,60,70,80 — grid-side cap only
max_utility_charging_current: 30
# enum: solar_utility_battery | solar_battery_utility
output_source_priority: solar_battery_utility
# enum: solar_first | solar_and_utility | solar_only
charger_priority: solar_first
# enum: battery_load_utility_ac | load_battery_utility
solar_power_priority: battery_load_utility_ac
# enum: enabled | disabled (PEI/PDI)
grid_tie: disabled
# V 0 (=Full) or 48.0..58.0 — switch grid→battery above this (pair: stop_discharge_voltage). Only honored when battery_type=USER.
stop_charge_voltage: 54.0
# V 48.0..58.4 — held while on grid (pair: bulk_voltage; must be <= bulk_voltage). Only honored when battery_type=USER.
float_voltage: 54.0

View File

@@ -0,0 +1,39 @@
# LVX6048 settings profile. Edit freely; all keys are optional.
# `flash.py diff` previews changes; `flash.py apply --confirm` writes.
# enum: AGM | FLOODED | USER
battery_type: AGM
# V 40.0..48.0 — hard shutdown; must be < stop_discharge_voltage. Only honored when battery_type=USER.
cutoff_voltage: 40.8
# V 44.0..51.0 — switch battery→grid below this (pair: stop_charge_voltage). Only honored when battery_type=USER.
stop_discharge_voltage: 46.0
# V 48.0..58.4 — CC→CV transition (pair: float_voltage; must be >= float_voltage). Only honored when battery_type=USER.
bulk_voltage: 56.4
# A 2,10,20,30,40,50,60,70,80 — grid-side cap only
max_utility_charging_current: 30
# A 10,20,30,40,50,60,70,80 — combined solar+AC cap
max_charging_current: 60
# enum: solar_utility_battery | solar_battery_utility
output_source_priority: solar_battery_utility
# enum: solar_first | solar_and_utility | solar_only
charger_priority: solar_first
# enum: battery_load_utility_ac | load_battery_utility
solar_power_priority: battery_load_utility_ac
# enum: enabled | disabled (PEI/PDI)
grid_tie: disabled
# V 0 (=Full) or 48.0..58.0 — switch grid→battery above this (pair: stop_discharge_voltage). Only honored when battery_type=USER.
stop_charge_voltage: 54.0
# V 48.0..58.4 — held while on grid (pair: bulk_voltage; must be <= bulk_voltage). Only honored when battery_type=USER.
float_voltage: 54.0

View File

@@ -0,0 +1,139 @@
# 2026-04-26 (afternoon) — LVX6048 parallel-mode commissioning
Got the two LVX6048 inverters operationally paralleled, sharing battery
charge from the EG4 LP4 v2 bank symmetrically. Several discoveries about
the firmware's parallel-state reporting along the way, plus a couple of
small patches.
## What happened
1. **Firmware parity (main CPU): achieved.** Pre-flash main versions
were 06303 (unit 1) and 06440 (unit 2). User flashed both to
**06306** using the MPP Solar `InfiniVMasterCPU_Reflash` tool. Active
fault 71 ("Parallel version different") cleared.
2. **Slave-CPU firmware: still skewed but benign.** Slaves remain
06126 / 06021. The vendor's `LVX6048 FW63.06.zip` *does* include a
`dsp.hex` slave bin, but the operational evidence (load-sharing,
matched battery V, no faults, master/slave handshake completing)
indicates the slave delta is not blocking parallel function on this
firmware family. **Not flashing the DSPs** — filed for the record only.
3. **Battery wiring + commissioning.** Both inverters tied to the same
3× EG4 LP4 v2 100 Ah bank (300 Ah at 51.2 V nominal). After fault 83
("Parallel battery voltage detect different") cleared on first
battery-connected boot, both inverters entered MOD `06` and began
sharing the charge load (~55 A each, ~110 A combined ≈ 0.37 C).
4. **MCHGC mismatch (100 A vs 60 A) → fixed.** Unit 1 was at 100 A —
above the PI18 `MCHGC` setter's 80 A ceiling, so it had to be set
via the LCD (Program 02). User dropped unit 1 to 60 A so both
match. Confirmed via `flash.py compare` (12/12 settings match).
5. **Patch (g) tweak — `outputformats/hass.py`** *(unrelated to parallel
work but lives in the same patch tree).* No change here today; the
morning's cleanup is in commit `f771ec2`.
6. **Patch (h) — `pi18.py` MOD decoder.** Inverter started reporting
MOD code `06` after batteries connected; the existing decoder only
knew 00..05 and crashed (`KeyError: '06'`). Added `"06": "Charge"`.
The "Charge" label is an educated guess based on observed behavior
(active charging, no AC out); revisit if it turns up in unrelated
states.
7. **`parallel_instance_number` decoder fixed.** The GS/PGS field is
the parallel-instance index per the PI18 spec (0 = master, 1+ =
slaves), not a 2-value flag — but powermon upstream (and our patch)
was decoding it as `["Not valid", "valid"]`, which made the master
appear as "Not valid" in HA. Replaced with an `OPTION` decoder
labeling each index ("instance 0 (master)", "instance 1", ...) in
both GS field 27 and PGS field 0. `flash.py sync-check` updated
accordingly: removed the false-positive "GS parallel_instance_number
= Not valid" issue, replaced with index-collision and no-master
checks.
8. **`lvx-resolve-links` now auto-runs on USB hot-plug.** Added an
`ACTION=="add"` udev rule for vendor 0665 / product 5161 that
`RUN+=`s `systemctl --no-block restart lvx-resolve-links.service
powermon.service powermon2.service`. Powermon's `Requires=` /
`After=` already serialize the chain. Added a retry loop to the
resolver script (poll up to ~10 s for both expected serials) so a
single hidraw appearing slightly before its sibling doesn't leave a
symlink missing. Tested via `udevadm trigger --action=add
--subsystem-match=hidraw`: all three services restarted in lock-step
and symlinks rewrote cleanly.
9. **Battery profile YAML for the bank.** Created
`lvx-flash/profiles/eg4-lp4-v2.yaml` capturing the conservative
off-grid LFP policy (USER mode, bulk 56.4 V / float 54.0 V, cutoff
48.0 V, stop-charge 54.0 V). Live state already matches — applying
would be a no-op. Kept as the canonical record of the chosen policy.
10. **Sanity dumps**`2026-04-26-lvx6048_{1,2}-dump` capture per-unit
settings post-commissioning. Useful as a baseline for diff-on-suspect.
11. **Battery side: re-discovered that EG4 LP4 v2's inter-pack
daisy-chain silences slave packs.** User had wired the inter-pack
`Link-In/Link-Out` chain earlier in the day; it elects a master
and demotes slaves on their independent RS485 ports. Pulling the
chain restored all three packs to MQTT immediately (all reporting
54.62 V agreement). Daemon needed no change. Memorized for next
time.
## Net effect
| Metric | Before | After |
|---|---|---|
| Main-CPU firmware | 06303 / 06440 | **06306 / 06306** ✓ |
| Slave-CPU firmware | 06126 / 06021 | unchanged (benign) |
| Active fault 71 | yes | cleared |
| Active fault 83 | n/a (no battery) | cleared |
| MCHGC delta | 100 A vs 60 A | 60 A / 60 A ✓ |
| Settings parity | mismatch | 12/12 match |
| Charge-current sharing | n/a | 55 A / 57 A symmetric |
| Battery V agreement | n/a | 55.1 V / 55.1 V identical |
| `parallel_instance_number` | "Not valid" / "valid" (master mislabeled) | "instance 0 (master)" / "instance 1" |
| MOD 06 crash on sync-check | yes | fixed |
| Hot-plug symlink recovery | manual `systemctl restart` | automatic via udev `RUN+=` |
## 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/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)
A LVX6048/2026-04-26-lvx6048_2-dump (post-commissioning baseline)
A LVX6048/lvx-flash/profiles/eg4-lp4-v2.yaml (canonical battery profile)
A LVX6048/2026-04-26-parallel.md (this file)
```
## How to verify
```bash
sudo systemctl stop powermon.service powermon2.service
sudo systemctl restart lvx-resolve-links.service # always run after inverter power-cycle
/home/noise/solar/LVX6048/lvx-flash/flash.py sync-check \
--device-a /dev/lvx6048-1 --device-b /dev/lvx6048-2
sudo systemctl start powermon.service powermon2.service
```
Pass criteria post-commissioning:
- main fw 06306 on both
- `mode=06` (Charge) or `mode=03` (Battery) or `mode=05` (Hybrid) — i.e. healthy operating modes, no `04` (Fault)
- `fault=No fault` on both
- both `parallel_*` values present (one as master/0, one as slave/1)
- battery V agreement within ~0.2 V
- charge currents within ~5 A symmetric (when both are charging)
## Loose ends / next 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
pack→inverter CAN port; defer until open-loop has been stable for a
week and we can baseline SoC accuracy

View File

@@ -63,19 +63,35 @@ def _relink(link: str, target: str) -> None:
async def main() -> int:
candidates = sorted(glob.glob("/dev/hidraw*"))
if not candidates:
print("no /dev/hidraw* devices present", file=sys.stderr)
return 1
# Hot-plug case: udev fires this script as soon as one hidraw appears, but
# a sibling inverter coming up at nearly the same moment may still be
# enumerating. Retry-probe up to ~10 s waiting for all expected serials,
# so a transient single-device sighting doesn't leave one symlink missing.
expected = set(LINK_FOR_SERIAL.keys())
deadline = asyncio.get_event_loop().time() + 10.0
sn_to_path: dict[str, str] = {}
for p in candidates:
sn = await probe_serial(p)
if sn:
print(f"{p}: serial {sn}")
sn_to_path[sn] = p
else:
print(f"{p}: no PI18 response (probably not an LVX6048)")
seen_paths: set[str] = set()
while True:
candidates = sorted(glob.glob("/dev/hidraw*"))
for p in candidates:
if p in seen_paths:
continue
seen_paths.add(p)
sn = await probe_serial(p)
if sn:
print(f"{p}: serial {sn}")
sn_to_path[sn] = p
else:
print(f"{p}: no PI18 response (probably not an LVX6048)")
if expected.issubset(sn_to_path):
break
if asyncio.get_event_loop().time() >= deadline:
break
await asyncio.sleep(0.5)
if not sn_to_path:
print("no LVX6048 devices found on /dev/hidraw*", file=sys.stderr)
return 1
missing = []
for sn, link in LINK_FOR_SERIAL.items():

View File

@@ -1,6 +1,13 @@
# LVX6048 (MPP Solar / Voltronic) USB-HID — dialout access only.
# Logical unit identification is done via PI18 `ID` query at powermon startup
# (see powermon.yaml: path: /dev/hidraw*, serial_number: <SN>).
# No SYMLINK here — powermon resolves the right /dev/hidrawN by inverter serial,
# which survives cable moves / hub port changes.
# Logical unit identification is done via the lvx-resolve-links oneshot, which
# reads each inverter's PI18 serial and writes /dev/lvx6048-{1,2} symlinks.
# powermon services have Requires=lvx-resolve-links so they wait for it at boot.
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0665", ATTRS{idProduct}=="5161", MODE="0660", GROUP="dialout"
# Hot-plug recovery: when an inverter re-enumerates (e.g. after a power cycle),
# hidraw indices can shuffle, leaving /dev/lvx6048-{1,2} symlinks pointing at
# the wrong physical unit. Re-run the resolver and bounce the powermon services
# so HA stops receiving crossed-up entity data. Fires only on add (avoid double
# restart on disconnect+reconnect).
SUBSYSTEM=="hidraw", ACTION=="add", ATTRS{idVendor}=="0665", ATTRS{idProduct}=="5161", \
RUN+="/bin/systemctl --no-block restart lvx-resolve-links.service powermon.service powermon2.service"

View File

@@ -544,7 +544,7 @@ FAULT_NAMES = {
# 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
GS_PARALLEL_INDEX = 27 # 0 = master, 1+ = slaves; one unit per cluster reports 0
async def _read_raw_parts(port: USBPort, code: str, retries: int = 3) -> list[str]:
@@ -574,7 +574,7 @@ async def _snapshot_sync(path: str) -> dict[str, Any]:
finally:
await port.disconnect()
return {
"parallel_valid": gs[GS_PARALLEL_VALID] == "1",
"parallel_index": int(gs[GS_PARALLEL_INDEX]),
"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],
@@ -593,9 +593,10 @@ async def cmd_sync_check(args) -> int:
b = await _snapshot_sync(path_b)
def _row(label: str, s: dict) -> str:
valid = "valid" if s["parallel_valid"] else "NOT VALID"
idx = s["parallel_index"]
role = f"instance {idx}{' (master)' if idx == 0 else ''}"
return (f"{label}: fw={s['main_cpu']}/{s['slave_cpu']} mode={s['mode']} "
f"parallel={valid} fault={s['fault_name']} "
f"parallel={role} fault={s['fault_name']} "
f"vac={s['ac_output_v']}V fac={s['ac_output_hz']}Hz")
print(_row(path_a, a))
@@ -604,10 +605,10 @@ async def cmd_sync_check(args) -> int:
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["parallel_index"] == b["parallel_index"]:
issues.append(f"both units report the same parallel instance index ({a['parallel_index']}); should differ — cluster handshake incomplete")
if 0 not in (a["parallel_index"], b["parallel_index"]):
issues.append(f"neither unit reports instance 0 — cluster has no elected master (got {a['parallel_index']}, {b['parallel_index']})")
if a["fault_code"] != "00":
issues.append(f"{path_a}: active fault {a['fault_code']} ({a['fault_name']})")
if b["fault_code"] != "00":

View File

@@ -0,0 +1,59 @@
# LVX6048 settings profile — EG4 LifePower4 v2 LiFePO4 bank, open-loop.
#
# Designed for: 3× EG4 LP4 v2 100 Ah in parallel (300 Ah, ~15.4 kWh).
# Apply identically to both inverters in a parallel pair:
# sudo systemctl stop powermon.service powermon2.service
# ./flash.py apply --device /dev/lvx6048-1 --profile profiles/eg4-lp4-v2.yaml --confirm
# ./flash.py apply --device /dev/lvx6048-2 --profile profiles/eg4-lp4-v2.yaml --confirm
# sudo systemctl start powermon.service powermon2.service
# ./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
#
# Voltage rationale (16S LFP, 3.2 V/cell nominal = 51.2 V pack):
# bulk 56.4 V = 3.525 V/cell — long-life sweet spot for LFP
# float 54.0 V = 3.375 V/cell — rest near mid-knee, avoid sitting on the top
# stop_charge 54.0 V — grid charges only to ~mid-knee; solar handles the bulk top-off
# stop_dis 48.0 V = 3.000 V/cell — soft "switch to grid" point
# cutoff 48.0 V = 3.000 V/cell — inverter hard shutdown floor (BMS still protects below)
# Conservative off-grid policy: keep grid as a soft top-up, let solar do the bulk work.
#
# Current rationale (300 Ah bank):
# 60 A/unit × 2 units = 120 A combined ≈ 0.4 C — well under bank's continuous spec
# 30 A/unit MUCHGC keeps grid-charging conservative (60 A combined)
# enum: AGM | FLOODED | USER
# USER required to enable the per-cell custom voltages below.
battery_type: USER
# V 40.0..48.0 — hard shutdown; must be < stop_discharge_voltage.
cutoff_voltage: 48.0
# V 44.0..51.0 — switch battery→grid below this (pair: stop_charge_voltage).
stop_discharge_voltage: 48.0
# V 0 (=Full) or 48.0..58.0 — switch grid→battery above this. 0 = let bulk define.
stop_charge_voltage: 54.0
# V 48.0..58.4 — CC→CV transition (pair: float_voltage; must be >= float_voltage).
bulk_voltage: 56.4
# V 48.0..58.4 — held while on grid (pair: bulk_voltage; must be <= bulk_voltage).
float_voltage: 54.0
# A 10,20,30,40,50,60,70,80 — combined solar+AC cap, per unit.
max_charging_current: 60
# A 2,10,20,30,40,50,60,70,80 — grid-side cap, per unit.
max_utility_charging_current: 30
# enum: solar_utility_battery | solar_battery_utility
output_source_priority: solar_battery_utility
# enum: solar_first | solar_and_utility | solar_only
charger_priority: solar_first
# enum: battery_load_utility_ac | load_battery_utility
solar_power_priority: battery_load_utility_ac
# enum: enabled | disabled (PEI/PDI)
grid_tie: disabled

View File

@@ -336,9 +336,23 @@ QUERY_COMMANDS = {
"2": "output",
},
},
# Parallel-cluster index. PI18 spec: 0 = master, 1+ = slaves.
# Upstream powermon labels this as a 2-value flag ["Not valid", "valid"]
# which is wrong — masters end up labeled "Not valid". Fixed to expose
# the actual index. Range covers up to max_parallel_units (typ. 9).
{"description": "Parallel instance number", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.LIST,
"options": ["Not valid", "valid"],
"response_type": ResponseType.OPTION,
"options": {
"0": "instance 0 (master)",
"1": "instance 1",
"2": "instance 2",
"3": "instance 3",
"4": "instance 4",
"5": "instance 5",
"6": "instance 6",
"7": "instance 7",
"8": "instance 8",
},
},
],
@@ -362,6 +376,7 @@ QUERY_COMMANDS = {
"03": "Battery",
"04": "Fault",
"05": "Hybrid mode(Line mode, Grid mode)",
"06": "Charge",
}
},
],
@@ -523,8 +538,10 @@ 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.LIST, "options": ["Not valid", "valid"]},
"response_type": ResponseType.OPTION,
"options": {str(i): f"instance {i}{' (master)' if i == 0 else ''}" for i in range(9)}},
{"description": "Parallel unit count", "reading_type": ReadingType.MESSAGE},
{"description": "Fault code", "reading_type": ReadingType.MESSAGE},
{"description": "Field 4 (raw)", "reading_type": ReadingType.MESSAGE},