diff --git a/LVX6048/2026-04-26-lvx6048_1-dump b/LVX6048/2026-04-26-lvx6048_1-dump new file mode 100644 index 0000000..583315d --- /dev/null +++ b/LVX6048/2026-04-26-lvx6048_1-dump @@ -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 + diff --git a/LVX6048/2026-04-26-lvx6048_2-dump b/LVX6048/2026-04-26-lvx6048_2-dump new file mode 100644 index 0000000..ea13203 --- /dev/null +++ b/LVX6048/2026-04-26-lvx6048_2-dump @@ -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 + diff --git a/LVX6048/2026-04-26-parallel.md b/LVX6048/2026-04-26-parallel.md new file mode 100644 index 0000000..32a2a13 --- /dev/null +++ b/LVX6048/2026-04-26-parallel.md @@ -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 diff --git a/LVX6048/bin/lvx-resolve-links b/LVX6048/bin/lvx-resolve-links index a0abeb8..02a4512 100755 --- a/LVX6048/bin/lvx-resolve-links +++ b/LVX6048/bin/lvx-resolve-links @@ -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(): diff --git a/LVX6048/etc/udev/rules.d/99-lvx6048.rules b/LVX6048/etc/udev/rules.d/99-lvx6048.rules index 70b1fb8..d8c4a10 100644 --- a/LVX6048/etc/udev/rules.d/99-lvx6048.rules +++ b/LVX6048/etc/udev/rules.d/99-lvx6048.rules @@ -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: ). -# 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" diff --git a/LVX6048/lvx-flash/flash.py b/LVX6048/lvx-flash/flash.py index 6f65b26..22e7dd5 100755 --- a/LVX6048/lvx-flash/flash.py +++ b/LVX6048/lvx-flash/flash.py @@ -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": diff --git a/LVX6048/lvx-flash/profiles/eg4-lp4-v2.yaml b/LVX6048/lvx-flash/profiles/eg4-lp4-v2.yaml new file mode 100644 index 0000000..26ef672 --- /dev/null +++ b/LVX6048/lvx-flash/profiles/eg4-lp4-v2.yaml @@ -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 diff --git a/LVX6048/powermon-patches/pi18.py b/LVX6048/powermon-patches/pi18.py index b32d89b..0a70bb9 100644 --- a/LVX6048/powermon-patches/pi18.py +++ b/LVX6048/powermon-patches/pi18.py @@ -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},