# LVX6048 → HA Monitoring Install Target: Debian-family Linux (developed on Raspberry Pi CM5), two LVX6048s over USB, HA MQTT broker on the LAN. > **Shortcut:** the top-level [`install.sh`](./install.sh) automates every section below. This document explains what it does and why, and how to do each step by hand if you need to diverge. Path conventions in the snippets below use `$BASE` to mean the root of this package (e.g. `~/solar/LVX6048`). Every canonical file lives under `$BASE/` mirroring its system destination path — e.g. `$BASE/etc/udev/rules.d/99-lvx6048.rules` deploys to `/etc/udev/rules.d/99-lvx6048.rules`. ## 1. Install powermon ```bash uv tool install --with bleak powermon powermon --listProtocols # confirm PI18 present ``` `mpp-solar` is yanked from PyPI; `powermon` replaces it for both adhoc and daemon use. ## 2. udev rule + serial-keyed symlinks The LVX6048 reports no USB serial string in the USB descriptor, so vid:pid matching applies to both units equally. Rather than pin devices by USB hub port (which breaks the moment you move a cable), we use the PI18 `ID` query — which *does* return each inverter's real serial number — and build stable `/dev/lvx6048-{1,2}` symlinks from that. ### 2a. udev rule — dialout access The rule grants `dialout` access to any LVX6048 hidraw; the logical "which unit is this?" decision happens in `lvx-resolve-links` (next step). Canonical: [`etc/udev/rules.d/99-lvx6048.rules`](./etc/udev/rules.d/99-lvx6048.rules). Deploy: ```bash sudo install -m 644 "$BASE/etc/udev/rules.d/99-lvx6048.rules" /etc/udev/rules.d/99-lvx6048.rules sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=hidraw ls -l /dev/hidraw* # both should be group=dialout, mode 0660 ``` ### 2b. `lvx-resolve-links` — serial → symlink resolver [`bin/lvx-resolve-links`](./bin/lvx-resolve-links) runs once at boot, globs `/dev/hidraw*`, sends PI18 `ID` to each, and creates: ``` /dev/lvx6048-1 → /dev/hidrawX (where X matches SERIAL_UNIT_1) /dev/lvx6048-2 → /dev/hidrawX (where X matches SERIAL_UNIT_2) ``` A single resolver pass, exclusive, before any powermon service starts — sidesteps the collision you get if each service probes independently (each probe open-read-close would race the sibling service already holding the hidraw fd). Deploy the script + systemd oneshot: ```bash sudo install -m 755 "$BASE/bin/lvx-resolve-links" /usr/local/sbin/lvx-resolve-links # rewrite the shebang in the installed copy to point at powermon's venv python sudo sed -i "1c#!$HOME/.local/share/uv/tools/powermon/bin/python" /usr/local/sbin/lvx-resolve-links sudo install -m 644 "$BASE/etc/systemd/system/lvx-resolve-links.service" /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable --now lvx-resolve-links.service ls -l /dev/lvx6048-* # both symlinks should now exist ``` ### 2c. Capturing a unit's serial If you ever swap an inverter, capture the new serial and update `SERIAL_UNIT_*` in both `/usr/local/sbin/lvx-resolve-links` and [`lvx-flash/flash.py`](./lvx-flash/flash.py): ```bash sudo systemctl stop powermon.service powermon2.service lvx-resolve-links.service TMP=$(mktemp --suffix=.yaml) printf 'loop: once\ndevice:\n name: probe\n port: {type: usb, path: /dev/hidraw0, protocol: PI18}\ncommands:\n - {command: ID, trigger: {loops: 1}}\n' > "$TMP" ~/.local/bin/powermon -C "$TMP" 2>&1 | grep serial_number rm -f "$TMP" sudo systemctl start lvx-resolve-links.service powermon.service powermon2.service ``` The two current serials are hard-coded as `SERIAL_UNIT_1` / `SERIAL_UNIT_2` in both files. ## 3. Smoke test ```bash powermon -C "$BASE/smoketest/console.yaml" # continuous console output (unit #1) # or one-shot against either unit: powermon --adhoc GS -C ~/.config/powermon/powermon.yaml powermon --adhoc GS -C ~/.config/powermon/powermon2.yaml ``` ## 4. powermon configs Powermon only supports **one `device:` per config file**, so each inverter needs its own config + its own systemd unit. Canonical (mode 600): [`config/powermon/powermon.yaml`](./config/powermon/powermon.yaml), [`config/powermon/powermon2.yaml`](./config/powermon/powermon2.yaml). The snapshots contain `` / `` / `` placeholders — fill them in after copying into place. Deploy: ```bash mkdir -p ~/.config/powermon install -m 600 "$BASE/config/powermon/powermon.yaml" ~/.config/powermon/powermon.yaml install -m 600 "$BASE/config/powermon/powermon2.yaml" ~/.config/powermon/powermon2.yaml # edit the two files: replace , , placeholders ``` Key points (same for both, differ only in device name / path / topic prefix): - `loop: 1` at top level — without it, powermon defaults to `"once"` and exits after one pass - `device.port.path: /dev/lvx6048-1` (or `-2`) — resolved via the resolver symlinks from §2b, not via powermon's own wildcard-+-serial probing (which collides when two services run simultaneously) - `device.port.protocol: PI18` — PI30 returns CRC errors on this unit - MQTT `username`/`password` are HA Mosquitto add-on creds (broker rejects anonymous by default) - One `hass`-formatted MQTT output per command; ~28 entities auto-discovered per inverter, published at `homeassistant/sensor/lvx6048_{1,2}_/state` - `entity_id_prefix` / topic = `lvx6048_1` for unit #1, `lvx6048_2` for unit #2 Poll cadence: `GS`/5s, `MOD`/10s, `ET`/60s, `PIRI`/300s. ## 5. Local patches (pinned to powermon 1.0.18, survive until `uv tool upgrade`) The six patches are shipped as full file drop-ins under [`powermon-patches/`](./powermon-patches/) (see [its README](./powermon-patches/README.md) for per-file detail). To apply manually: ```bash POWERMON_SITE="$(ls -d ~/.local/share/uv/tools/powermon/lib/python*/site-packages/powermon)" install -m 644 "$BASE/powermon-patches/pi18.py" "$POWERMON_SITE/protocols/pi18.py" install -m 644 "$BASE/powermon-patches/usbport.py" "$POWERMON_SITE/ports/usbport.py" install -m 644 "$BASE/powermon-patches/mqttbroker.py" "$POWERMON_SITE/libs/mqttbroker.py" install -m 644 "$BASE/powermon-patches/port_config_model.py" "$POWERMON_SITE/configmodel/port_config_model.py" install -m 644 "$BASE/powermon-patches/ports_init.py" "$POWERMON_SITE/ports/__init__.py" ``` What each patch fixes: **a.** `protocols/pi18.py` ~line 323: `"DC/AC power direction"` → `"DC-AC power direction"`. The slash creates a bogus extra MQTT topic level. **b.** `ports/usbport.py` `send_and_receive()`: (1) drain any leftover bytes from the hidraw fd before sending (non-blocking read loop swallowing `BlockingIOError`); (2) wrap the main `os.read` in the retry loop with `try: … except BlockingIOError: continue`. Without (1), a prior command's late HID bytes get parsed as the next command's reply → `KeyError` in response decoding. Without (2), an empty buffer on first read aborts the command. **c.** `libs/mqttbroker.py`: broaden `connect()`'s `except ConnectionRefusedError` to `except (ConnectionRefusedError, OSError)`, and narrow `publish()`'s bare `except Exception` to `except (OSError, RuntimeError, ValueError)` with a clearer log message. Without this, any network blip to the broker (e.g. HA restart, `Errno 113 No route to host`) crashes the daemon. **d.** `protocols/pi18.py`: append two new query commands to `QUERY_COMMANDS` — `FWS` (Fault and Warning Status, fault code + ~32 warning bits, cross-referenced with PI30 `QPGS` fault codes including `71 "Parallel version different"` and the rest of the `8x` parallel-comm family) and `PGS` (Parallel General Status, regex `PGS(\d+)$`, LVX6048-specific 30-field layout with only the high-confidence fields named and the rest exposed as raw strings). Bump `check_definitions_count(expected=24)` to `26`. These are used by `lvx-flash sync-check`. **e.** `configmodel/port_config_model.py`: add `serial_number: None | str | int = Field(default=None)` to `UsbPortConfig`. The model is `NoExtraBaseModel` (extra-forbidden), so powermon rejects the `serial_number:` config key without this even though `USBPort.resolve_path` already consumes it. **f.** `ports/__init__.py` `from_config()`: change `port_config['serial_number'] = serial_number` to be a fallback (`if port_config.get('serial_number') is None:`). The device-level `serial_number` is the logical HA identifier (`lvx6048_1`), the port-level one is the hardware PI18 serial (22-digit) — different fields, different purposes; they must not be conflated. **g.** `outputformats/hass.py`: rewrite the discovery payload assembly to (1) drop the bogus `last_reset` field that upstream emits unconditionally (HA spec only allows it on `state_class: total` cumulative counters; on `measurement` sensors HA warns and ignores), (2) drop `force_update: "true"` default which makes HA store every state publish even on unchanged values (bloats the recorder DB for slowly-changing fields), (3) emit `suggested_display_precision` based on a per-unit map (so HA's voltage/current device classes don't truncate `52.5 V` to `52 V` in the frontend), (4) default `state_class: measurement` for numeric sensors lacking an explicit class (required for HA long-term statistics + Energy dashboard wiring). Also tweaks patch (a) further: `"DC-AC power direction"` → `"DC AC power direction"` so the HA entity_id becomes `dc_ac_power_direction` (underscore) rather than the non-conforming `dc-ac_power_direction`. Patches (e) and (f) enable powermon's native wildcard-path + serial-matching flow, which we *don't* currently use — two services probing independently at startup race each other over the same hidraw. Our current setup uses the external `lvx-resolve-links` resolver (§2b) instead, so (e) and (f) are dormant but kept applied in case we ever want to use powermon's native resolver for a single-service deployment. ## 6. systemd services One powermon unit per inverter, both gated on `lvx-resolve-links.service`. Canonical: [`etc/systemd/system/powermon.service`](./etc/systemd/system/powermon.service), [`powermon2.service`](./etc/systemd/system/powermon2.service), [`lvx-resolve-links.service`](./etc/systemd/system/lvx-resolve-links.service), [`powermon.service.d/10-resolver.conf`](./etc/systemd/system/powermon.service.d/10-resolver.conf) (and its sibling under `powermon2.service.d/`). Deploy: ```bash sudo install -m 644 "$BASE/etc/systemd/system/powermon.service" /etc/systemd/system/ sudo install -m 644 "$BASE/etc/systemd/system/powermon2.service" /etc/systemd/system/ sudo mkdir -p /etc/systemd/system/powermon.service.d /etc/systemd/system/powermon2.service.d sudo install -m 644 "$BASE/etc/systemd/system/powermon.service.d/10-resolver.conf" \ /etc/systemd/system/powermon.service.d/10-resolver.conf sudo install -m 644 "$BASE/etc/systemd/system/powermon2.service.d/10-resolver.conf" \ /etc/systemd/system/powermon2.service.d/10-resolver.conf sudo systemctl daemon-reload sudo systemctl enable --now powermon.service powermon2.service journalctl -u powermon.service -u powermon2.service -f ``` The drop-in adds `After=lvx-resolve-links.service` + `Requires=lvx-resolve-links.service` so both powermon services wait for the symlinks before starting. ## 7. Verify ```bash # live sample of what both units are publishing (12 s = ~2 full GS cycles per unit) timeout 15 mosquitto_sub -h 10.0.0.41 -u mqtt -P \ -t 'homeassistant/sensor/+/state' -W 12 -v | grep lvx6048_ ``` Should show ~58 messages (~29 per unit per GS cycle) over that window, with both `lvx6048_1_*` and `lvx6048_2_*` entities. In HA: **Settings → Devices & Services → MQTT** → two devices `LVX6048` (and `LVX6048 #2`), each with ~29 auto-discovered sensors. ## 8. Moving cables Because identification is PI18-serial-based, cable / USB hub moves don't require config changes. After any cable shuffle: ```bash sudo systemctl restart lvx-resolve-links.service powermon.service powermon2.service ``` The resolver re-probes, symlinks update, powermon services latch onto the correct device by symlink. No hardcoded hub-port references anywhere in the stack.