initialize

This commit is contained in:
2026-04-24 16:34:10 -04:00
commit 9aca623336
202 changed files with 6718 additions and 0 deletions

177
LVX6048/Install.md Normal file
View File

@@ -0,0 +1,177 @@
# 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 `<MQTT_BROKER_IP>` / `<MQTT_USER>` / `<MQTT_PASSWORD>` 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 <MQTT_BROKER_IP>, <MQTT_USER>, <MQTT_PASSWORD> 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}_<field>/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.
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 <pass> \
-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.

308
LVX6048/Monitoring.md Normal file
View File

@@ -0,0 +1,308 @@
# LVX6048 Monitoring & Control via Home Assistant
Integration plan for monitoring (and eventually controlling) 2x LVX6048 inverters from Home Assistant via a Raspberry Pi running a Python poller that publishes to MQTT.
Continue dev from the Raspberry Pi.
## Architecture
```
[LVX6048 #1] ──USB─┐
├──► [Raspberry Pi] ──MQTT──► [Home Assistant]
[LVX6048 #2] ──USB─┘ (mpp-solar/powermon (auto-discovers
Python daemon, ~40 entities
systemd service) per inverter)
```
### Why USB (not RS485)
The LVX6048 is an MPP Solar / Voltronic-family unit. Its **documented, supported** comm path is USB-HID speaking the **PI18** protocol (some firmware revisions also accept PI30). The RS485 port exists on the hardware but is undocumented for external monitoring on this model — it's primarily intended for parallel/BMS comms. Use USB.
### Why mpp-solar
[`jblance/mpp-solar`](https://github.com/jblance/mpp-solar) is the canonical Python package for this inverter family. It ships:
- PI18 / PI18LVX / PI30 / PI30MAX protocol drivers (all known query + set commands)
- A `powermon` daemon for continuous polling
- Built-in MQTT publisher with **Home Assistant auto-discovery** topic format — no HA YAML needed, entities appear automatically
## Hardware Checklist
- [ ] Raspberry Pi (Pi 4 or Pi 5 recommended; Pi 3B+ works) running Raspberry Pi OS 64-bit
- [ ] microSD card (32GB+) or USB SSD
- [ ] 2x USB-A to USB-B cables (one per inverter; ~3ft typical — **keep short**, USB-HID is not spec'd for long runs)
- [ ] Pi physically located near the inverter cabinet
- [ ] Ethernet (preferred) or WiFi to reach the HA MQTT broker
- [ ] Existing MQTT broker running on HA (confirmed operational)
## Step 1 — Base Pi Setup
```bash
sudo apt update && sudo apt upgrade -y
sudo apt install -y python3-pip python3-venv pipx git
pipx ensurepath
```
Reboot (or `exec $SHELL`) so `pipx` PATH takes effect.
## Step 2 — Install mpp-solar
```bash
pipx install mpp-solar
# verify
mpp-solar --version
mpp-solar --listProtocols
```
Expect `PI18`, `PI18LVX`, `PI18SV`, `PI30`, `PI30MAX` in the protocol list.
## Step 3 — Plug in Inverters & Identify Them
Connect inverter #1 first, alone, and run:
```bash
ls -l /dev/hidraw*
lsusb
dmesg | tail -20
```
Note the `/dev/hidraw*` device that appeared and the USB VID:PID (typical MPP Solar is `0665:5161` — confirm on your unit).
Grab the serial number for a stable udev rule:
```bash
udevadm info -a -n /dev/hidraw0 | grep -i serial | head -3
```
Write down **SERIAL_A**. Unplug #1, plug in #2, repeat to get **SERIAL_B**.
## Step 4 — Smoke Test
With inverter #1 still plugged in, query it directly:
```bash
mpp-solar -p /dev/hidraw0 -P PI18 -c GS
```
`GS` is the PI18 "General Status" query. Expected output: battery voltage, SoC, PV watts, load watts, grid voltage, inverter mode, etc. If `PI18` returns gibberish or CRC errors, try `PI30`:
```bash
mpp-solar -p /dev/hidraw0 -P PI30 -c QPIGS
```
Whichever protocol returns clean data → that's the one to use in the config below. **Record which protocol worked.**
Also grab rated info once:
```bash
mpp-solar -p /dev/hidraw0 -P PI18 -c PIRI # PI18 rated info
# or
mpp-solar -p /dev/hidraw0 -P PI30 -c QPIRI # PI30 rated info
```
## Step 5 — Stable Device Names (udev)
Without this, `/dev/hidraw0` and `/dev/hidraw1` can swap on reboot, and the wrong inverter's data ends up under the wrong entity in HA.
> **As built on this CM5:** the LVX6048 returns no USB serial string, so serial-based matching below is aspirational. We ended up matching by USB hub port instead — see [`99-lvx6048.rules`](./99-lvx6048.rules) and `Install.md` §2.
Create `/etc/udev/rules.d/99-lvx6048.rules`:
```
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0665", ATTRS{idProduct}=="5161", ATTRS{serial}=="SERIAL_A", SYMLINK+="lvx6048-1", MODE="0660", GROUP="dialout"
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0665", ATTRS{idProduct}=="5161", ATTRS{serial}=="SERIAL_B", SYMLINK+="lvx6048-2", MODE="0660", GROUP="dialout"
```
Replace `SERIAL_A` / `SERIAL_B` and the VID:PID with the real values from Step 3. Then:
```bash
sudo usermod -aG dialout $USER
sudo udevadm control --reload-rules
sudo udevadm trigger
ls -l /dev/lvx6048-*
```
Both symlinks should be present. Log out / back in so the `dialout` group applies.
## Step 6 — powermon Config
> **As built:** the `ports:` (plural) schema shown below does **not** work with current powermon — its config model allows exactly one `device:` per file. We ship one config file per inverter ([`powermon.yaml`](./powermon.yaml), [`powermon2.yaml`](./powermon2.yaml)) and run two systemd units. See `Install.md` §46.
Create `~/.config/powermon/powermon.yaml` (adjust MQTT creds and protocol name to match Step 4):
```yaml
device:
name: lvx6048_pair
id: solar_lvx6048
mqttbroker:
name: <HA_MQTT_BROKER_IP>
port: 1883
username: <MQTT_USER>
password: <MQTT_PASSWORD>
adhoc_topic: powermon/adhoc
adhoc_result_topic: powermon/adhoc/results
api:
enabled: false
daemon:
type: systemd
keepalive: 60
ports:
- name: lvx1
type: usb
path: /dev/lvx6048-1
protocol: PI18 # confirmed in Step 4
commands:
- command: GS
trigger: { every: 5 }
outputs: { name: mqtt_ha, type: hass, tag: lvx6048_1 }
- command: PIRI
trigger: { every: 300 }
outputs: { name: mqtt_ha, type: hass, tag: lvx6048_1 }
- command: MOD
trigger: { every: 10 }
outputs: { name: mqtt_ha, type: hass, tag: lvx6048_1 }
- command: FWS
trigger: { every: 30 }
outputs: { name: mqtt_ha, type: hass, tag: lvx6048_1 }
- name: lvx2
type: usb
path: /dev/lvx6048-2
protocol: PI18
commands:
- command: GS
trigger: { every: 5 }
outputs: { name: mqtt_ha, type: hass, tag: lvx6048_2 }
- command: PIRI
trigger: { every: 300 }
outputs: { name: mqtt_ha, type: hass, tag: lvx6048_2 }
- command: MOD
trigger: { every: 10 }
outputs: { name: mqtt_ha, type: hass, tag: lvx6048_2 }
- command: FWS
trigger: { every: 30 }
outputs: { name: mqtt_ha, type: hass, tag: lvx6048_2 }
```
**Command reference (PI18):**
- `GS` — General Status (voltages, currents, power, SoC, temps) → poll fast
- `PIRI` — Protocol / Rated Info (nameplate values) → poll slow
- `MOD` — Operating Mode (Grid / Battery / Fault / Standby) → poll medium
- `FWS` — Fault / Warning Status → poll medium
- `ET` — Total Energy (lifetime kWh) → optional, add with `every: 60`
- `ED` — Daily Energy → optional
Run it in the foreground once to confirm MQTT is flowing:
```bash
powermon -C ~/.config/powermon/powermon.yaml
```
In Home Assistant, go to **Settings → Devices & Services → MQTT** — two new devices (`lvx6048_1`, `lvx6048_2`) should appear with all their sensors auto-discovered.
## Step 7 — systemd Service
Create `/etc/systemd/system/powermon.service`:
```ini
[Unit]
Description=powermon LVX6048 → MQTT bridge
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=pi
Group=dialout
ExecStart=/home/pi/.local/bin/powermon -C /home/pi/.config/powermon/powermon.yaml
Restart=on-failure
RestartSec=10
# Tolerate USB dropouts / HA broker restarts
StartLimitIntervalSec=300
StartLimitBurst=10
[Install]
WantedBy=multi-user.target
```
Adjust `User=` / paths if not running as `pi`. Enable:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now powermon.service
systemctl status powermon.service
journalctl -u powermon.service -f
```
## Step 8 — Home Assistant Dashboard
Entities auto-appear. Minimum useful Lovelace card:
```yaml
type: entities
title: Solar System
entities:
- sensor.lvx6048_1_battery_capacity # %
- sensor.lvx6048_1_battery_voltage # V
- sensor.lvx6048_1_pv_input_power # W
- sensor.lvx6048_1_ac_output_active_power # W
- sensor.lvx6048_1_grid_voltage # V
- sensor.lvx6048_1_inverter_heat_sink_temperature
- sensor.lvx6048_1_working_mode
- sensor.lvx6048_2_battery_capacity
- sensor.lvx6048_2_pv_input_power
- sensor.lvx6048_2_ac_output_active_power
- sensor.lvx6048_2_working_mode
```
Entity IDs will reflect the `tag:` values from the config. Exact names depend on protocol driver — check the MQTT integration page.
Recommended extras:
- **Energy dashboard** — map `ET` (total lifetime energy) as a solar production source
- **Automation**: alert when `working_mode` changes to `Fault` on either unit
- **Utility meter** helpers on `ac_output_active_power` for daily/weekly kWh
## Control (Deferred)
Once monitoring is stable, control commands are available via the same library. PI18 set commands include:
- `POP` — Output source priority (SUB / SBU / etc.)
- `PCP` — Charger priority (Solar first / Solar+Utility / only Solar)
- `MCHGC` — Max total charging current
- `MUCHGC` — Max utility charging current
- `PF` — Restore defaults (careful)
Exposed in HA as `button`/`select` entities via powermon's MQTT command topic. Plan this as a separate phase after at least a week of stable monitoring data.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| `Permission denied` on `/dev/hidraw*` | User not in `dialout` | `sudo usermod -aG dialout $USER`, re-login |
| CRC errors / garbled output | Wrong protocol selected | Try `PI30` instead of `PI18` (or vice versa) |
| Device disappears after reboot | udev serial mismatch | Re-check `udevadm info -a`, verify serial string |
| Symlinks swap between units | Two units report same serial | Fall back to `KERNELS=="1-1.2"` USB-port matching |
| HA entities don't appear | MQTT discovery disabled or wrong prefix | Verify `discovery: true` in HA MQTT integration; default prefix is `homeassistant/` |
| Entities appear but never update | powermon service not actually running | `journalctl -u powermon.service -n 100` |
| One inverter works, other doesn't | Parallel-mode RS232 quirk | Query the *master* unit; the slave may not respond to USB queries while linked |
### Parallel-mode caveat
The 2x LVX6048s are wired in parallel (per `README.md`). In parallel mode, some MPP Solar firmware only responds to USB/PI commands on the **master** unit — the slave echoes the master's data or returns errors. If Step 4 smoke tests show one unit timing out, check the LCD to see which is master. If only the master responds, a single poller gives you combined system data; per-unit telemetry may require the parallel/sync RJ45 cable's side-channel (undocumented) or firmware that doesn't gate slave comms.
**Recommendation:** do Step 4 on each inverter individually (unplug parallel comm cable, query via USB, replug) to confirm both respond. Then test again with parallel cable connected.
## References
- [jblance/mpp-solar](https://github.com/jblance/mpp-solar) — Python library
- [powermon](https://github.com/jblance/powermon) — daemon mode (split out of mpp-solar)
- [Set commands on LVX6048 — discussion #344](https://github.com/jblance/mpp-solar/discussions/344)
- [LVX6048WP user manual](https://watts247.com/manuals/mpp/PIP-LVX6048WP/LVX6048WP-manual.pdf)
- [MPP Solar LVX6048 product page](https://www.mppsolar.com/v3/lvx6048/)
- [HA community — LVX6048 integration thread](https://community.home-assistant.io/t/lvx6048-inverter/851522)
- [Connecting to LVX6048WP via USB with Raspberry Pi](https://diysolarforum.com/threads/connecting-to-lvx6048wp-via-usb-with-raspberry-pi.46774/)

154
LVX6048/README.md Normal file
View File

@@ -0,0 +1,154 @@
# LVX6048 → Home Assistant
Reproducible install package for monitoring 2× MPP Solar LVX6048 inverters over
USB-HID (PI18) via [`powermon`](https://github.com/jblance/powermon), publishing
to a Home Assistant MQTT broker with HA auto-discovery.
## What's in the box
```
LVX6048/
├── README.md ← start here
├── Install.md ← detailed walkthrough (what install.sh does)
├── Monitoring.md ← background / design notes
├── install.sh ← one-shot installer (idempotent, safe to re-run)
├── etc/ mirror of target system paths
│ ├── udev/rules.d/99-lvx6048.rules
│ └── systemd/system/
│ ├── lvx-resolve-links.service
│ ├── powermon.service
│ ├── powermon2.service
│ ├── powermon.service.d/10-resolver.conf
│ └── powermon2.service.d/10-resolver.conf
├── config/powermon/ lands at ~/.config/powermon/ (mode 600)
│ ├── powermon.yaml ← unit #1 — edit MQTT creds before deploying
│ └── powermon2.yaml ← unit #2 — edit MQTT creds before deploying
├── bin/
│ └── lvx-resolve-links ← installed as /usr/local/sbin/lvx-resolve-links
│ (install.sh rewrites the shebang to the uv-installed python)
├── powermon-patches/ ← drop-in files for the uv tool install
│ ├── README.md ← what each patch does + upgrade path
│ ├── pi18.py, usbport.py, mqttbroker.py,
│ └── port_config_model.py, ports_init.py
├── lvx-flash/ ← settings-profile CLI
│ ├── flash.py ← dump / diff / apply / compare / sync-check
│ ├── README.md
│ └── profiles/current.yaml
└── smoketest/ ← adhoc test configs (one-off powermon -C usage)
├── console.yaml
└── smoketest.yaml
```
## Quick start (reproducing on a fresh machine)
```bash
# 1. Clone / scp this folder into place (e.g. /home/<user>/solar/LVX6048)
cd ~/solar/LVX6048
# 2. Install uv if not already: https://docs.astral.sh/uv/
# 3. Run the installer
./install.sh
# 4. Edit the two things install.sh warns about:
# a. ~/.config/powermon/powermon{,2}.yaml — MQTT broker IP / user / password
# b. /usr/local/sbin/lvx-resolve-links — SERIAL_UNIT_1 / SERIAL_UNIT_2 (see below)
# lvx-flash/flash.py — same two constants
# 5. Capture each inverter's PI18 serial (with services stopped):
sudo systemctl stop lvx-resolve-links.service powermon.service powermon2.service
for d in /dev/hidraw0 /dev/hidraw1; do
TMP=$(mktemp --suffix=.yaml)
printf 'loop: once\ndevice:\n name: probe\n port: {type: usb, path: %s, protocol: PI18}\ncommands:\n - {command: ID, trigger: {loops: 1}}\n' "$d" > "$TMP"
echo "=== $d ==="
~/.local/bin/powermon -C "$TMP" 2>&1 | grep serial_number
rm -f "$TMP"; sleep 2
done
# Edit SERIAL_UNIT_{1,2} in /usr/local/sbin/lvx-resolve-links and lvx-flash/flash.py,
# then:
sudo systemctl start lvx-resolve-links.service powermon.service powermon2.service
```
## How the pieces fit together
```
┌─────────────────────────┐ ┌─────────────────────────┐
│ LVX6048 #1 (USB-HID) │ ──▶ │ /dev/hidraw{0|1} │
│ LVX6048 #2 (USB-HID) │ ──▶ │ (vid:pid 0665:5161) │
└─────────────────────────┘ └──────────┬──────────────┘
│ (99-lvx6048.rules → group=dialout)
┌──────────────────────────────────┐
│ lvx-resolve-links.service │
│ (oneshot, runs before powermon) │
│ │
│ probes each hidraw w/ PI18 ID │
│ creates /dev/lvx6048-{1,2} │
│ symlinks keyed to serial │
└──────────┬───────────────────────┘
│ After= / Requires=
┌──────────────────────────────────┐
│ powermon.service (unit #1) │
│ powermon2.service (unit #2) │
│ │
│ poll GS / MOD / PIRI / ET │
│ publish to HA auto-discovery │
│ topics under homeassistant/* │
└──────────┬───────────────────────┘
│ MQTT (port 1883)
┌──────────────────────────────────┐
│ Home Assistant Mosquitto broker │
│ ~29 auto-discovered sensors/unit │
└──────────────────────────────────┘
```
Separate from the monitoring pipeline, **`lvx-flash/`** is a manual settings
tool: dump the inverter's current config into a YAML profile, diff against a
target profile, apply changes safely (stops powermon, writes via PI18 setters,
verifies via PIRI readback). Also supports `compare` (diff live settings
between two inverters) and `sync-check` (verify parallel-stack health).
## Cable moves
Identification is PI18-serial-based, so moving USB cables between hub ports
never requires config edits. After any cable shuffle:
```bash
sudo systemctl restart lvx-resolve-links.service powermon.service powermon2.service
```
## Replacing an inverter
When a unit is swapped, capture its new PI18 serial (see step 5 of Quick start)
and update the two `SERIAL_UNIT_*` constants in:
- `/usr/local/sbin/lvx-resolve-links`
- `~/solar/LVX6048/lvx-flash/flash.py`
then restart the three services.
## Next steps / not done
- **Firmware parity:** on this dev stack, unit #1 is at main=06303/slave=06126
and unit #2 is at 06440/06021. Parallel operation requires matching firmware
(fault code 71 "Parallel version different") — the sync kit's cables are
wired correctly, but the inverters won't phase-lock until both CPUs match.
Firmware upload is not part of this package (MPP Solar Windows-only tool).
- **PGS field layout:** the LVX6048-specific 30-field PGS response layout is
only partially decoded in `powermon-patches/pi18.py`. The key fields
(parallel_valid, fault_code, grid_hz, ac_output_voltage) are named; the rest
are exposed as raw strings.
- **Control / set commands via HA:** PI18 setters (POP, PCP, MCHGC, MUCHGC, PF)
are implemented in `lvx-flash/flash.py` for offline use, but aren't exposed
as HA button/select entities. Deferred until monitoring has been stable for
at least a week and firmware parity is restored.

98
LVX6048/bin/lvx-resolve-links Executable file
View File

@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""lvx-resolve-links — create /dev/lvx6048-N symlinks keyed to PI18 serial.
Globs /dev/hidraw*, sends PI18 `ID` to each, and creates
/dev/lvx6048-1 -> /dev/hidrawX where X's serial matches SERIAL_UNIT_1
/dev/lvx6048-2 -> /dev/hidrawX where X's serial matches SERIAL_UNIT_2
Must run as root. Intended as a systemd oneshot before powermon*.service.
Runs a single discovery pass with exclusive access — unlike powermon's native
resolve_path which each service performs independently at startup, causing
collisions when a sibling service is already holding the target hidraw.
Edit SERIAL_UNIT_1 / SERIAL_UNIT_2 when a unit is replaced.
"""
from __future__ import annotations
import asyncio
import glob
import os
import sys
SERIAL_UNIT_1 = "1496142109100037000000"
SERIAL_UNIT_2 = "1496142408100255000000"
LINK_FOR_SERIAL = {
SERIAL_UNIT_1: "/dev/lvx6048-1",
SERIAL_UNIT_2: "/dev/lvx6048-2",
}
sys.path.insert(0, "/home/noise/.local/share/uv/tools/powermon/lib/python3.11/site-packages")
from powermon.protocols import get_protocol_definition # noqa: E402
from powermon.ports.usbport import USBPort # noqa: E402
async def probe_serial(path: str) -> str | None:
proto = get_protocol_definition(protocol="PI18")
port = USBPort(path=path, protocol=proto)
port.path = path
try:
await port.connect()
if not port.is_connected():
return None
cmd = proto.get_id_command()
res = await port.send_and_receive(command=cmd)
await port.disconnect()
except Exception as e:
print(f" probe {path}: {e.__class__.__name__}: {e}", file=sys.stderr)
return None
if res is None or not getattr(res, "is_valid", False) or not res.readings:
return None
return str(res.readings[0].data_value)
def _relink(link: str, target: str) -> None:
target_basename = os.path.basename(target)
try:
if os.path.islink(link) or os.path.exists(link):
os.unlink(link)
except FileNotFoundError:
pass
os.symlink(target_basename, link)
async def main() -> int:
candidates = sorted(glob.glob("/dev/hidraw*"))
if not candidates:
print("no /dev/hidraw* devices present", file=sys.stderr)
return 1
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)")
missing = []
for sn, link in LINK_FOR_SERIAL.items():
if sn in sn_to_path:
_relink(link, sn_to_path[sn])
print(f"symlink {link} -> {os.path.basename(sn_to_path[sn])}")
else:
missing.append((link, sn))
try:
if os.path.islink(link):
os.unlink(link)
except FileNotFoundError:
pass
print(f"WARNING: {link} serial {sn} not found on any /dev/hidraw*")
return 0 if not missing else 2
if __name__ == "__main__":
sys.exit(asyncio.run(main()))

View File

@@ -0,0 +1,62 @@
loop: 1
device:
name: LVX6048 #1
serial_number: lvx6048_1
manufacturer: MPP Solar
model: LVX6048
port:
type: usb
path: /dev/lvx6048-1
protocol: PI18
mqttbroker:
name: <MQTT_BROKER_IP> # e.g. 10.0.0.41 (HA Mosquitto broker)
port: 1883
username: <MQTT_USER>
password: <MQTT_PASSWORD>
commands:
- command: GS
trigger:
every: 5
outputs:
- type: mqtt
topic: powermon/lvx6048_1/GS
format:
type: hass
discovery_prefix: homeassistant
entity_id_prefix: lvx6048_1
- command: MOD
trigger:
every: 10
outputs:
- type: mqtt
topic: powermon/lvx6048_1/MOD
format:
type: hass
discovery_prefix: homeassistant
entity_id_prefix: lvx6048_1
- command: PIRI
trigger:
every: 300
outputs:
- type: mqtt
topic: powermon/lvx6048_1/PIRI
format:
type: hass
discovery_prefix: homeassistant
entity_id_prefix: lvx6048_1
- command: ET
trigger:
every: 60
outputs:
- type: mqtt
topic: powermon/lvx6048_1/ET
format:
type: hass
discovery_prefix: homeassistant
entity_id_prefix: lvx6048_1

View File

@@ -0,0 +1,62 @@
loop: 1
device:
name: LVX6048 #2
serial_number: lvx6048_2
manufacturer: MPP Solar
model: LVX6048
port:
type: usb
path: /dev/lvx6048-2
protocol: PI18
mqttbroker:
name: <MQTT_BROKER_IP> # e.g. 10.0.0.41 (HA Mosquitto broker)
port: 1883
username: <MQTT_USER>
password: <MQTT_PASSWORD>
commands:
- command: GS
trigger:
every: 5
outputs:
- type: mqtt
topic: powermon/lvx6048_2/GS
format:
type: hass
discovery_prefix: homeassistant
entity_id_prefix: lvx6048_2
- command: MOD
trigger:
every: 10
outputs:
- type: mqtt
topic: powermon/lvx6048_2/MOD
format:
type: hass
discovery_prefix: homeassistant
entity_id_prefix: lvx6048_2
- command: PIRI
trigger:
every: 300
outputs:
- type: mqtt
topic: powermon/lvx6048_2/PIRI
format:
type: hass
discovery_prefix: homeassistant
entity_id_prefix: lvx6048_2
- command: ET
trigger:
every: 60
outputs:
- type: mqtt
topic: powermon/lvx6048_2/ET
format:
type: hass
discovery_prefix: homeassistant
entity_id_prefix: lvx6048_2

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Resolve LVX6048 hidraw symlinks by PI18 serial
After=systemd-udev-settle.service
Wants=systemd-udev-settle.service
# powermon services Requires= this unit, so they won't start until it succeeds.
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/lvx-resolve-links
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,15 @@
[Unit]
Description=powermon LVX6048 -> MQTT bridge
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=noise
Group=dialout
ExecStart=/home/noise/.local/bin/powermon -C /home/noise/.config/powermon/powermon.yaml
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,3 @@
[Unit]
After=lvx-resolve-links.service
Requires=lvx-resolve-links.service

View File

@@ -0,0 +1,15 @@
[Unit]
Description=powermon LVX6048 #2 -> MQTT bridge
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=noise
Group=dialout
ExecStart=/home/noise/.local/bin/powermon -C /home/noise/.config/powermon/powermon2.yaml
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,3 @@
[Unit]
After=lvx-resolve-links.service
Requires=lvx-resolve-links.service

View File

@@ -0,0 +1,6 @@
# 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.
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0665", ATTRS{idProduct}=="5161", MODE="0660", GROUP="dialout"

109
LVX6048/install.sh Executable file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# LVX6048 → Home Assistant monitoring — reproducible installer.
#
# Installs powermon + our patches, drops in udev rule / systemd units / resolver
# script / powermon configs, and starts the services. Idempotent: safe to re-run.
#
# Assumptions:
# - Debian-family Linux (Raspberry Pi OS, Ubuntu) with systemd
# - `uv` is installed and on $PATH (https://docs.astral.sh/uv/)
# - User has sudo
# - MQTT broker reachable (edit config/powermon/*.yaml first if the broker IP /
# credentials aren't yet in place)
#
# After install, finish by editing:
# ~/.config/powermon/powermon{,2}.yaml — MQTT broker + credentials
# /usr/local/sbin/lvx-resolve-links — SERIAL_UNIT_1 / SERIAL_UNIT_2
# lvx-flash/flash.py — SERIAL_UNIT_1 / SERIAL_UNIT_2
# then:
# sudo systemctl restart lvx-resolve-links powermon.service powermon2.service
set -euo pipefail
PINNED_POWERMON="1.0.18" # version we developed/tested patches against
BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
msg() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; }
# --- 1. powermon -----------------------------------------------------------
msg "Installing powermon ${PINNED_POWERMON} via uv"
command -v uv >/dev/null || { echo "uv not found — install from https://docs.astral.sh/uv/"; exit 1; }
uv tool install --with bleak "powermon==${PINNED_POWERMON}"
POWERMON_VENV="${HOME}/.local/share/uv/tools/powermon"
POWERMON_SITE="$(ls -d "${POWERMON_VENV}"/lib/python*/site-packages/powermon)"
POWERMON_PY="${POWERMON_VENV}/bin/python"
[ -d "$POWERMON_SITE" ] || { echo "could not locate powermon site-packages under ${POWERMON_VENV}"; exit 1; }
# --- 2. powermon patches ---------------------------------------------------
msg "Applying powermon patches into ${POWERMON_SITE}"
install -m 644 "${BASE}/powermon-patches/pi18.py" "${POWERMON_SITE}/protocols/pi18.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"
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"
# --- 3. udev (LVX6048 hidraw → dialout perms) ------------------------------
msg "Installing udev rule"
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
# --- 4. resolver script (serial → /dev/lvx6048-N symlinks) -----------------
msg "Installing /usr/local/sbin/lvx-resolve-links (shebang → ${POWERMON_PY})"
TMP_RESOLVER="$(mktemp)"
trap 'rm -f "$TMP_RESOLVER"' EXIT
awk -v py="${POWERMON_PY}" 'NR==1 { print "#!" py; next } { print }' \
"${BASE}/bin/lvx-resolve-links" > "$TMP_RESOLVER"
sudo install -m 755 "$TMP_RESOLVER" /usr/local/sbin/lvx-resolve-links
# --- 5. systemd units + drop-ins -------------------------------------------
msg "Installing systemd units"
sudo install -m 644 "${BASE}/etc/systemd/system/lvx-resolve-links.service" /etc/systemd/system/
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
# --- 6. user configs (only if not already deployed; don't clobber creds) ---
msg "Installing powermon configs (skipping existing files — edit creds manually if new)"
mkdir -p "${HOME}/.config/powermon"
for f in powermon.yaml powermon2.yaml; do
dest="${HOME}/.config/powermon/${f}"
if [ -e "$dest" ]; then
echo " $dest already exists — not overwriting"
else
install -m 600 "${BASE}/config/powermon/${f}" "$dest"
echo " wrote $dest (mode 600) — edit MQTT credentials before enabling services"
fi
done
# --- 7. enable services ----------------------------------------------------
msg "Enabling services"
sudo systemctl enable --now lvx-resolve-links.service
# Only auto-start powermon services if credentials look real (no placeholder present)
if grep -q '<MQTT_PASSWORD>' "${HOME}/.config/powermon/powermon.yaml" 2>/dev/null; then
cat <<EOF
Credentials in ~/.config/powermon/powermon.yaml still contain a placeholder.
Edit that file + powermon2.yaml, then:
sudo systemctl enable --now powermon.service powermon2.service
journalctl -u powermon.service -u powermon2.service -f
EOF
else
sudo systemctl enable --now powermon.service powermon2.service
sleep 3
systemctl --no-pager status powermon.service powermon2.service | head -30
fi
msg "Done"
echo "Next steps (if any of these don't match your hardware, edit and restart):"
echo " - /usr/local/sbin/lvx-resolve-links : SERIAL_UNIT_1 / SERIAL_UNIT_2"
echo " - ${BASE}/lvx-flash/flash.py : SERIAL_UNIT_1 / SERIAL_UNIT_2"
echo " - to re-probe after a cable move : sudo systemctl restart lvx-resolve-links.service powermon.service powermon2.service"

View File

@@ -0,0 +1,73 @@
# lvx-flash
Apply declarative YAML settings profiles to an LVX6048 inverter via PI18.
Reuses the patched `powermon` install at `~/.local/share/uv/tools/powermon/` — the shebang in `flash.py` points there, so no extra setup is needed.
## Commands
```bash
# 1. Snapshot the inverter's current settings into an editable profile.
./flash.py dump --out profiles/current.yaml
# 2. Copy/edit. Preview the diff against the live device at any time.
./flash.py diff --profile profiles/winter.yaml
# 3. Apply. The tool stops powermon.service for the duration, writes each
# changed setting via the corresponding PI18 set command, verifies via
# PIRI readback, and restarts powermon.service at the end.
./flash.py apply --profile profiles/winter.yaml --confirm
# 4. Compare two inverters live (for parallel setups). Exits 0 if identical,
# 1 if divergent. Useful as a quick "are the two in sync?" check.
./flash.py compare --device-a /dev/lvx6048-1 --device-b /dev/lvx6048-2
# 5. Runtime sync-check for paralleled units. Reads GS+FWS+MOD+VFW from each
# and reports firmware mismatch, parallel-valid flag, active fault codes,
# mode mismatch, AC voltage/frequency divergence. Exits 0 on pass, 1 on fail.
./flash.py sync-check --device-a /dev/lvx6048-1 --device-b /dev/lvx6048-2
```
Device selection on every subcommand:
- `--serial SN` (default — uses `SERIAL_UNIT_1` for single-device commands, `SERIAL_UNIT_{1,2}` for pairs) — globs `/dev/hidraw*` and picks the one whose PI18 `ID` matches. Resilient to cable moves.
- `--device PATH` — explicit hidraw path, skips auto-resolution. Useful for probing an unknown unit.
The known-stack serials are hard-coded as `SERIAL_UNIT_1` / `SERIAL_UNIT_2` constants at the top of `flash.py`; edit them if a unit is replaced.
`sync-check` depends on the `FWS` / `PGS` additions to `powermon/protocols/pi18.py`; the `--serial` flow depends on the `UsbPortConfig.serial_number` field addition. Both are described in `Install.md` §5(d)(e).
## Profile schema
All keys are optional. Only present keys are applied; the rest are left alone.
| key | range / enum | PI18 setter |
|----------------------------------|------------------------------------------------------------------|-------------|
| `battery_type` | `AGM` \| `FLOODED` \| `USER` | PBT |
| `cutoff_voltage` | 40.0 48.0 V | PSDV |
| `stop_discharge_voltage` | 44.0 51.0 V | BUCD (pair) |
| `stop_charge_voltage` | 0 (full) or 48.0 58.0 V | BUCD (pair) |
| `bulk_voltage` | 48.0 58.4 V | MCHGV (pair)|
| `float_voltage` | 48.0 58.4 V (≤ bulk_voltage) | MCHGV (pair)|
| `max_charging_current` | 10, 20, 30, 40, 50, 60, 70, 80 A | MCHGC |
| `max_utility_charging_current` | 2, 10, 20, 30, 40, 50, 60, 70, 80 A | MUCHGC |
| `output_source_priority` | `solar_utility_battery` \| `solar_battery_utility` | POP |
| `charger_priority` | `solar_first` \| `solar_and_utility` \| `solar_only` | PCP |
| `solar_power_priority` | `battery_load_utility_ac` \| `load_battery_utility` | PSP |
| `grid_tie` | `enabled` \| `disabled` | PEI / PDI |
Pair settings (BUCD, MCHGV) must have both halves present.
## Safety
- `apply` refuses to run without `--confirm`.
- Range + consistency validation runs before any write. A failure aborts before touching the inverter.
- Each write is followed by a PIRI readback that must match to within 0.05 V. A mismatch aborts.
- Settings are applied in an order that's safe for a battery: cutoff → BUCD → MCHGV → currents → priorities → grid-tie mode.
- `powermon.service` is stopped for the write window so the two processes don't fight over `/dev/lvx6048-1`, then restarted even if the run aborts.
## Caveats
- Parallel setups: PCP / MCHGC / MUCHGC are indexed by parallel unit (the tool sends unit 0 — master). Some firmware only accepts sets on the master; the slave mirrors silently.
- `PIRI` reports `max_charging_current` as the effective combined (solar + AC) cap, which can exceed MCHGC's 80 A range. `dump` omits it with a comment when that happens.
- Changing `battery_type` while the unit is actively charging is generally allowed by the firmware but not recommended.

724
LVX6048/lvx-flash/flash.py Executable file
View File

@@ -0,0 +1,724 @@
#!/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())

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,28 @@
# powermon patches
Drop-in replacements for files inside a `uv tool install powermon==1.0.18` tree.
Each file below lands at the indicated path — the top-level `install.sh` does the copy.
| Snapshot here | Target (inside `$POWERMON_SITE`) | Purpose |
|--------------------------|---------------------------------------|---------|
| `pi18.py` | `protocols/pi18.py` | (a) rename `"DC/AC power direction"``"DC-AC power direction"` so the slash doesn't create a bogus MQTT topic level. (d) add `FWS` (fault + warning status) and `PGS<n>` (parallel general status) query commands; bump `check_definitions_count(expected=24)``26`. |
| `usbport.py` | `ports/usbport.py` | (b) drain leftover bytes from the hidraw fd before sending (non-blocking read loop swallowing `BlockingIOError`); wrap the main `os.read` in the retry loop so an empty first read doesn't abort. Otherwise late HID bytes from a prior command get parsed as the next reply → `KeyError`. |
| `mqttbroker.py` | `libs/mqttbroker.py` | (c) broaden `connect()`'s `except ConnectionRefusedError` to `(ConnectionRefusedError, OSError)` and narrow `publish()`'s bare `except Exception` to `(OSError, RuntimeError, ValueError)`. Otherwise any broker blip (HA restart, `Errno 113 No route to host`) crashes the daemon. |
| `port_config_model.py` | `configmodel/port_config_model.py` | (e) add `serial_number: None \| str \| int = Field(default=None)` to `UsbPortConfig`. The model is `NoExtraBaseModel`, so powermon rejects `serial_number:` at the port level without this. |
| `ports_init.py` | `ports/__init__.py` | (f) in `from_config()`, make `port_config['serial_number'] = serial_number` a fallback (`if port_config.get('serial_number') is None:`). Device-level `serial_number` is the HA identifier (e.g. `lvx6048_1`); the port-level one is the hardware PI18 serial — they must not be conflated. |
Patches (a)(d) are load-bearing for the live setup. Patches (e) and (f) enable
powermon's native wildcard-path + serial-matching flow for a single-daemon
setup; we don't currently exercise that because two services probing
independently at startup race each other — the external `lvx-resolve-links`
oneshot handles identification instead. (e)/(f) are kept applied for future
flexibility.
## Upgrade path
These patches are pinned against **powermon 1.0.18**. Before bumping powermon:
1. Install the new version in a scratch location: `uv tool install --prefix /tmp/pm-next 'powermon==X.Y.Z'`
2. Diff each of the five files against the pristine upstream copy.
3. Re-apply each patch by hand into the new files (they're short — see descriptions above).
4. Drop the new files into this folder and re-run `./install.sh` on the target.

View File

@@ -0,0 +1,220 @@
""" powermon / libs / mqttbroker.py """
import logging
from time import sleep
import paho.mqtt.client as mqtt_client
from powermon.libs.config import safe_config
# Set-up logger
log = logging.getLogger("mqttbroker")
class MqttBroker:
""" Wrapper for mqtt broker connectivity and message proccessing """
def __str__(self):
if self.disabled:
return "MqttBroker DISABLED"
else:
return f"MqttBroker name: {self.name}, port: {self.port}, user: {self.username}"
@classmethod
def from_config(cls, config=None) -> 'MqttBroker':
""" build the mqtt broker object from a config dict """
log.debug("mqttbroker config: %s", safe_config(config))
if config:
name = config.get("name")
port = config.get("port", 1883)
username = config.get("username")
password = config.get("password")
mqtt_broker = cls(name=name, port=port, username=username, password=password)
mqtt_broker.adhoc_topic = config.get("adhoc_topic")
mqtt_broker.adhoc_result_topic = config.get("adhoc_result_topic")
return mqtt_broker
else:
return cls(name=None)
def __init__(self, name, port=None, username=None, password=None):
self.name = name
self.port = port
self.username = username
self.password = password
self.is_connected = False
if self.name is None:
self.disabled = True
else:
self.disabled = False
self.mqttc = mqtt_client.Client()
@property
def adhoc_topic(self) -> str:
""" return the adhoc command topic """
return getattr(self, "_adhoc_topic", None)
@adhoc_topic.setter
def adhoc_topic(self, value):
log.debug("setting adhoc topic to: %s", value)
self._adhoc_topic = value
@property
def adhoc_result_topic(self) -> str:
""" return the adhoc result topic """
return getattr(self, "_adhoc_result_topic", None)
@adhoc_result_topic.setter
def adhoc_result_topic(self, value):
log.debug("setting adhoc result topic to: %s", value)
self._adhoc_result_topic = value
def on_connect(self, client, userdata, flags, rc):
""" callback for connect """
# 0: Connection successful
# 1: Connection refused - incorrect protocol version
# 2: Connection refused - invalid client identifier
# 3: Connection refused - server unavailable
# 4: Connection refused - bad username or password
# 5: Connection refused - not authorised
# 6-255: Currently unused.
log.debug("on_connect called - client: %s, userdata: %s, flags: %s", client, userdata, flags)
connection_result = [
"Connection successful",
"Connection refused - incorrect protocol version",
"Connection refused - invalid client identifier",
"Connection refused - server unavailable",
"Connection refused - bad username or password",
"Connection refused - not authorised",
]
log.debug("MqttBroker connection returned result: %s %s", rc, connection_result[rc])
if rc == 0:
self.is_connected = True
return
self.is_connected = False
def on_disconnect(self, client, userdata, rc):
""" callback for disconnect """
log.debug("on_disconnect called - client: %s, userdata: %s, rc: %s", client, userdata, rc)
self.is_connected = False
def connect(self):
""" connect to mqtt broker """
if self.disabled:
log.info("MQTT broker not enabled, was a broker name defined? '%s'", self.name)
return
if not self.name:
log.info("MQTT could not connect as no broker name")
return
self.mqttc.on_connect = self.on_connect
self.mqttc.on_disconnect = self.on_disconnect
# if name is screen just return without connecting
if self.name == "screen":
# allows checking of message formats
return
try:
log.debug("Connecting to %s on port %s", self.name, self.port)
if self.username:
# auth = {"username": mqtt_user, "password": mqtt_pass}
_password = "********" if self.password is not None else "None"
log.info("Using mqtt authentication, username: %s, password: %s", self.username, _password)
self.mqttc.username_pw_set(self.username, password=self.password)
else:
log.debug("No mqtt authentication used")
# auth = None
self.mqttc.connect(self.name, port=self.port, keepalive=60)
self.mqttc.loop_start()
sleep(1)
except (ConnectionRefusedError, OSError) as ex:
log.warning("%s connection failed: '%s'", self.name, ex)
def start(self):
""" start mqtt broker """
if self.disabled:
return
if self.is_connected:
self.mqttc.loop_start()
def stop(self):
""" stop mqtt broker """
log.debug("Stopping mqttbroker connection")
if self.disabled:
return
self.mqttc.loop_stop()
# def set(self, variable, value):
# setattr(self, variable, value)
# def update(self, variable, value):
# # only override if value is not None
# if value is None:
# return
# setattr(self, variable, value)
def subscribe(self, topic, callback):
""" subscribe to a mqtt topic """
if not self.name:
return
if self.disabled:
return
# check if connected, connect if not
if not self.is_connected:
log.debug("Not connected, connecting")
self.connect()
# Register callback
self.mqttc.on_message = callback
if self.is_connected:
# Subscribe to command topic
log.debug("Subscribing to topic: %s", topic)
self.mqttc.subscribe(topic, qos=0)
else:
log.warning("Did not subscribe to topic: %s as not connected to broker", topic)
def post_adhoc_command(self, command_code):
""" shortcut function to publish an adhoc command """
self.publish(topic=self.adhoc_topic, payload=command_code)
def post_adhoc_result(self, payload):
""" shortcut function to publish the results of an adhoc command """
self.publish(topic=self.adhoc_result_topic, payload=payload)
def publish(self, topic: str = None, payload: str = None):
""" function to publish messages to mqtt broker """
if self.disabled:
log.debug("Cannot publish msg as mqttbroker disabled")
return
# log.debug("Publishing '%s' to '%s'", payload, topic)
if self.name == "screen":
print(f"mqtt debug - topic: '{topic}', payload: '{payload}'")
return
# check if connected, connect if not
if not self.is_connected:
log.debug("Not connected, connecting")
self.connect()
sleep(1)
if not self.is_connected:
log.warning("mqtt broker did not connect")
return
if isinstance(topic, bytes):
topic = topic.decode('utf-8')
if isinstance(payload, bytes):
payload = payload.decode('utf-8')
try:
infot = self.mqttc.publish(topic, payload)
infot.wait_for_publish(5)
except (OSError, RuntimeError, ValueError) as e:
log.warning("mqtt publish failed: %s", e)
# def setAdhocCommands(self, config={}, callback=None):
# if not config:
# return
# if self.disabled:
# log.debug("Cannot setAdhocCommands as mqttbroker disabled")
# return
# adhoc_commands = config.get("adhoc_commands")
# # sub to command topic if defined
# adhoc_commands_topic = adhoc_commands.get("topic")
# if adhoc_commands_topic is not None:
# log.info("Setting adhoc commands topic to %s", adhoc_commands_topic)
# self.subscribe(adhoc_commands_topic, callback)

View File

@@ -0,0 +1,651 @@
""" powermon / protocols / pi18.py """
import logging
from powermon.commands.command import CommandType
from powermon.commands.command_definition import CommandDefinition
from powermon.commands.reading_definition import ReadingType, ResponseType
from powermon.commands.result import ResultType
from powermon.libs.errors import CommandDefinitionMissing, InvalidCRC, InvalidResponse
from powermon.ports import PortType
from powermon.protocols.abstractprotocol import AbstractProtocol
from powermon.protocols.helpers import crc_pi30 as crc
from powermon.protocols.pi30 import BATTERY_TYPE_LIST, OUTPUT_MODE_LIST
log = logging.getLogger("pi18")
SETTER_COMMANDS = {
"POP": {
"name": "POP",
"command_type": CommandType.PI18_SETTER,
"description": "Set Device Output Source Priority",
"help": " -- examples: POP0 (set utility first), POP01 (set solar first)",
"regex": "POP([01])$",
},
"PSP": {
"name": "PSP",
"command_type": CommandType.PI18_SETTER,
"description": "Set Solar Power priority",
"help": " -- examples: PSP0 (Battery-Load-Utility +AC Charge), PSP1 (Load-Battery-Utility)",
"regex": "PSP([01])$",
},
"PEI": {
"name": "PEI",
"command_type": CommandType.PI18_SETTER,
"description": "Set Machine type, enable: Grid-Tie",
"help": " -- examples: PEI (enable Grid-Tie)",
},
"PDI": {
"name": "PDI",
"command_type": CommandType.PI18_SETTER,
"description": "Set Machine type, disable: Grid-Tie",
"help": " -- examples: PDI (disable Grid-Tie)",
},
"PCP": {
"name": "PCP",
"command_type": CommandType.PI18_SETTER,
"description": "Set Device Charger Priority",
"help": " -- examples: PCP0,1 (set unit 0 [0-9] to Solar and Utility) PCP0,0 (set unit 0 to Solar first), PCP0,1 (set unit 0 to Solar and Utility), PCP0,2 (set unit 0 to solar only charging)",
"regex": "PCP([0-9],[012])$",
},
"MCHGC": {
"name": "MCHGC",
"command_type": CommandType.PI18_SETTER,
"description": "Set Battery Max Charging Current Solar + AC",
"help": " -- examples: MCHGC0,040 (set unit 0 to max charging current of 40A), MCHGC1,060 (set unit 1 to max charging current of 60A) [010 020 030 040 050 060 070 080]",
"regex": "MCHGC([0-9],0[1-8]0)$",
},
"MUCHGC": {
"name": "MUCHGC",
"command_type": CommandType.PI18_SETTER,
"description": "Set Battery Max AC Charging Current",
"help": " -- examples: MUCHGC0,040 (set unit 0 to max charging current of 40A), MUCHGC1,060 (set unit 1 to max charging current of 60A) [002 010 020 030 040 050 060 070 080]",
"regex": "MUCHGC([0-9]),(002|0[1-8]0)$",
},
"PBT": {
"name": "PBT",
"command_type": CommandType.PI18_SETTER,
"description": "Set Battery Type",
"help": " -- examples: PBT0 (set battery as AGM), PBT1 (set battery as FLOODED), PBT2 (set battery as USER)",
"regex": "PBT([012])$",
},
"MCHGV": {
"name": "MCHGV",
"command_type": CommandType.PI18_SETTER,
"description": "Set Battery Bulk,Float Charging Voltages",
"help": " -- example MCHGV552,540 - set battery charging voltage Bulk to 52.2V, float 54V (set Bulk Voltage [480~584] in 0.1V xxx, Float Voltage [480~584] in 0.1V yyy)",
# Regex 48.0 - 58.4 Volt
"regex": "MCHGV(4[8-9][0-9]|5[0-7][0-9]|58[0-5]),(4[8-9][0-9]|5[0-7][0-9]|58[0-4])$",
},
"PSDV": {
"name": "PSDV",
"command_type": CommandType.PI18_SETTER,
"description": "Set Battery Cut-off Voltage",
"help": " -- example PSDV400 - set battery cut-off voltage to 40V [400~480V] for 48V unit)",
# Regex 40 to 48V
"regex": "PSDV(4[0-7][0-9]|480)$",
},
"BUCD": {
"name": "BUCD",
"command_type": CommandType.PI18_SETTER,
"description": "Set Battery Stop dis,charging when Grid is available",
"help": " -- example BUCD440,480 - set Stop discharge Voltage [440~510] in 0.1V xxx, Stop Charge Voltage [000(Full) or 480~580] in 0.1V yyy",
# Regex 44 to 51V, Full|48V to 58V
"regex": "BUCD((4[4-9]0|5[0-1]0),(000|4[8-9]0|5[0-8]0))$",
},
}
QUERY_COMMANDS = {
"PI": {
"name": "PI",
"command_type": CommandType.PI18_QUERY,
"description": "Protocol ID inquiry",
"help": " -- queries the protocol ID",
"result_type": ResultType.SINGLE,
"reading_definitions": [
{"description": "Protocol ID"},
],
"test_responses": [
b"^D00518m\xae\r"
]
},
"ID": {
"name": "ID",
"aliases": ["default", "get_id"],
"command_type": CommandType.PI18_QUERY,
"description": "Device Serial Number inquiry",
"help": " -- queries the device serial number",
"result_type": ResultType.SINGLE,
"reading_definitions": [{"description": "Serial Number"}],
"test_responses": [
b"^D02514012345678901234567\r",
],
},
"ET": {
"name": "ET",
"command_type": CommandType.PI18_QUERY,
"description": "Total PV Generated Energy Inquiry",
"result_type": ResultType.SINGLE,
"reading_definitions": [
{"description": "Total PV Generated Energy", "reading_type": ReadingType.WATT_HOURS,
"response_type": ResponseType.INT, "icon": "mdi:solar-power", "device_class": "energy", "state_class": "total"},
],
"test_responses": [
b""
],
},
"EY": {
"name": "EY",
"command_type": CommandType.PI18_QUERY,
"description": "Yearly PV Generated Energy Inquiry",
"result_type": ResultType.SINGLE,
"reading_definitions": [
{"description": "PV Generated Energy for Year", "reading_type": ReadingType.WATT_HOURS,
"response_type": ResponseType.INT, "icon": "mdi:counter", "device_class": "energy", "state_class": "total"},
{"description": "Year", "reading_type": ReadingType.YEAR,
"response_type": ResponseType.INFO_FROM_COMMAND, "format_template": "int(cn[3:])"},
],
"test_responses": [
b"^D01105580051\x0b\x9f\r",
],
"regex": "EY(\\d\\d\\d\\d)$",
},
"EM": {
"name": "EM",
"command_type": CommandType.PI18_QUERY,
"description": "Monthly PV Generated Energy Inquiry",
"result_type": ResultType.SINGLE,
"reading_definitions": [
{"description": "PV Generated Energy for Month", "reading_type": ReadingType.WATT_HOURS,
"response_type": ResponseType.INT, "icon": "mdi:solar-power", "device_class": "energy", "state_class": "total"},
{"description": "Year", "reading_type": ReadingType.YEAR,
"response_type": ResponseType.INFO_FROM_COMMAND, "format_template": "int(cn[3:7])"},
{"description": "Month", "reading_type": ReadingType.MONTH,
"response_type": ResponseType.INFO_FROM_COMMAND, "format_template": "calendar.month_name[int(cn[7:])]"},
],
"test_responses": [
b"",
],
"regex": "EM(\\d\\d\\d\\d\\d\\d)$",
},
"ED": {
"name": "ED",
"command_type": CommandType.PI18_QUERY,
"description": "Daily PV Generated Energy Inquiry",
"help": " -- display daily generated energy, format is QEDyyyymmdd",
"result_type": ResultType.SINGLE,
"reading_definitions": [
{"description": "PV Generated Energy for Day", "reading_type": ReadingType.WATT_HOURS,
"response_type": ResponseType.INT, "icon": "mdi:solar-power", "device_class": "energy", "state_class": "total"},
{"description": "Year", "reading_type": ReadingType.YEAR,
"response_type": ResponseType.INFO_FROM_COMMAND, "format_template": "int(cn[3:7])"},
{"description": "Month", "reading_type": ReadingType.MONTH,
"response_type": ResponseType.INFO_FROM_COMMAND, "format_template": "calendar.month_name[int(cn[7:9])]"},
{"description": "Day", "reading_type": ReadingType.DAY,
"response_type": ResponseType.INFO_FROM_COMMAND, "format_template": "int(cn[9:11])"},
],
"test_responses": [
b"(00238800!J\r",
],
"regex": "ED(\\d\\d\\d\\d\\d\\d\\d\\d)$",
},
"PIRI": {
"name": "PIRI",
"command_type": CommandType.PI18_QUERY,
"description": "Current Settings inquiry",
"help": " -- queries the current settings from the Inverter",
"result_type": ResultType.COMMA_DELIMITED,
"reading_definitions": [
{"description": "AC Input Voltage", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
{"description": "AC Input Current", "reading_type": ReadingType.CURRENT,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
{"description": "AC Output Voltage", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
{"description": "AC Output Frequency", "reading_type": ReadingType.FREQUENCY,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
{"description": "AC Output Current", "reading_type": ReadingType.CURRENT,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
{"description": "AC Output Apparent Power", "reading_type": ReadingType.APPARENT_POWER},
{"description": "AC Output Active Power", "reading_type": ReadingType.WATTS},
{"description": "Battery Voltage", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
{"description": "Battery re-charge Voltage", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
{"description": "Battery re-discharge Voltage", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
{"description": "Battery Under Voltage", "reading_type": ReadingType.VOLTS, "response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
{"description": "Battery Bulk Charge Voltage", "reading_type": ReadingType.VOLTS, "response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
{"description": "Battery Float Charge Voltage", "reading_type": ReadingType.VOLTS, "response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
{"description": "Battery Type", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.LIST, "options": BATTERY_TYPE_LIST},
{"description": "Max AC Charging Current", "reading_type": ReadingType.CURRENT},
{"description": "Max Charging Current", "reading_type": ReadingType.CURRENT},
{"description": "Input Voltage Range", "response_type": ResponseType.LIST, "options": ["Appliance", "UPS"]},
{"description": "Output Source Priority",
"response_type": ResponseType.LIST, "options": ["Solar - Utility - Battery", "Solar - Battery - Utility"]},
{"description": "Charger Source Priority",
"response_type": ResponseType.LIST, "options": ["Solar First", "Solar + Utility", "Only solar charging permitted"]},
{"description": "Max Parallel Units"},
{"description": "Machine Type", "response_type": ResponseType.LIST, "options": ["Off Grid", "Grid Tie"]},
{"description": "Topology", "response_type": ResponseType.LIST, "options": ["transformerless", "transformer"]},
{"description": "Output Mode", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.LIST, "options": OUTPUT_MODE_LIST},
{"description": "Solar power priority", "response_type": ResponseType.LIST, "options": ["Battery-Load-Utiliy + AC Charger", "Load-Battery-Utiliy"]},
{"description": "MPPT strings"},
{"description": "Unknown flags?", "response_type": ResponseType.STRING},
],
"test_responses": [
b"^D0882300,217,2300,500,217,5000,5000,480,480,530,440,570,570,2,10,070,1,1,1,9,0,0,0,0,1,00\xe1k\r",
]
},
"GS": {
"name": "GS",
"command_type": CommandType.PI18_QUERY,
"description": "General Status Parameters inquiry",
"result_type": ResultType.COMMA_DELIMITED,
"reading_definitions": [
{"description": "AC Input Voltage", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:transmission-tower-export", "device_class": "voltage"},
{"description": "AC Input Frequency", "reading_type": ReadingType.FREQUENCY,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:current-ac", "device_class": "frequency"},
{"description": "AC Output Voltage", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:transmission-tower-export", "device_class": "voltage"},
{"description": "AC Output Frequency", "reading_type": ReadingType.FREQUENCY,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:current-ac", "device_class": "frequency"},
{"description": "AC Output Apparent Power", "reading_type": ReadingType.APPARENT_POWER,
"response_type": ResponseType.INT, "icon": "mdi:power-plug", "device_class": "apparent_power"},
{"description": "AC Output Active Power", "reading_type": ReadingType.WATTS,
"response_type": ResponseType.INT, "icon": "mdi:power-plug", "device_class": "power", "state_class": "measurement"},
{"description": "AC Output Load", "reading_type": ReadingType.PERCENTAGE,
"response_type": ResponseType.INT, "icon": "mdi:brightness-percent"},
{"description": "Battery Voltage", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:battery-outline", "device_class": "voltage"},
{"description": "Battery Voltage from SCC", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:battery-outline", "device_class": "voltage"},
{"description": "Battery Voltage from SCC2", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:battery-outline", "device_class": "voltage"},
{"description": "Battery Discharge Current", "reading_type": ReadingType.CURRENT,
"response_type": ResponseType.INT, "icon": "mdi:battery-negative", "device_class": "current"},
{"description": "Battery Charging Current", "reading_type": ReadingType.CURRENT,
"response_type": ResponseType.INT, "icon": "mdi:current-dc", "device_class": "current"},
{"description": "Battery Capacity", "reading_type": ReadingType.PERCENTAGE,
"response_type": ResponseType.INT, "icon": "mdi:brightness-percent", "device_class": "battery"},
{"description": "Inverter heat sink temperature", "reading_type": ReadingType.TEMPERATURE,
"response_type": ResponseType.INT, "icon": "mdi:details", "device_class": "temperature"},
{"description": "MPPT1 charger temperature", "reading_type": ReadingType.TEMPERATURE,
"response_type": ResponseType.INT, "icon": "mdi:details", "device_class": "temperature"},
{"description": "MPPT2 charger temperature", "reading_type": ReadingType.TEMPERATURE,
"response_type": ResponseType.INT, "icon": "mdi:details", "device_class": "temperature"},
{"description": "MPPT1 Input Power", "reading_type": ReadingType.WATTS,
"response_type": ResponseType.INT, "icon": "mdi:solar-power", "device_class": "power", "state_class": "measurement"},
{"description": "MPPT2 Input Power", "reading_type": ReadingType.WATTS,
"response_type": ResponseType.INT, "icon": "mdi:solar-power", "device_class": "power", "state_class": "measurement"},
{"description": "MPPT1 Input Voltage", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:solar-power", "device_class": "voltage"},
{"description": "MPPT2 Input Voltage", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:solar-power", "device_class": "voltage"},
{"description": "Setting value configuration state", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.OPTION,
"options": {
"0": "Nothing changed",
"1": "Something changed",
},
},
{"description": "MPPT1 charger status", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.OPTION,
"options": {
"0": "abnormal",
"1": "normal but not charged",
"2": "charging",
},
},
{"description": "MPPT2 charger status", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.OPTION,
"options": {
"0": "abnormal",
"1": "normal but not charged",
"2": "charging",
},
},
{"description": "Load connection", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.OPTION,
"options": {
"0": "disconnect",
"1": "connect",
},
},
{"description": "Battery power direction", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.OPTION,
"options": {
"0": "donothing",
"1": "charge",
"2": "discharge",
},
},
{"description": "DC-AC power direction", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.OPTION,
"options": {
"0": "donothing",
"1": "AC-DC",
"2": "DC-AC",
},
},
{"description": "Line power direction", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.OPTION,
"options": {
"0": "donothing",
"1": "input",
"2": "output",
},
},
{"description": "Parallel instance number", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.LIST,
"options": ["Not valid", "valid"],
},
],
"test_responses": [
b"D1062232,499,2232,499,0971,0710,019,008,000,000,000,000,000,044,000,000,0520,0000,1941,0000,0,2,0,1,0,2,1,0\x09\x7b\r",
b"^D1062232,499,2232,499,1406,1376,028,549,000,000,000,010,095,060,000,000,0082,0000,1604,0000,0,2,0,1,1,1,1,0D\x12\r",
],
},
"MOD": {
"name": "MOD",
"command_type": CommandType.PI18_QUERY,
"description": "Mode inquiry",
"result_type": ResultType.SINGLE,
"reading_definitions": [
{"description": "Device Mode", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.OPTION,
"options": {
"00": "Power on",
"01": "Standby",
"02": "Bypass",
"03": "Battery",
"04": "Fault",
"05": "Hybrid mode(Line mode, Grid mode)",
}
},
],
"test_responses": [
b"^D00505\xd9\x9f\r",
],
},
"MCHGCR": {
"name": "MCHGCR",
"command_type": CommandType.PI18_QUERY,
"description": "Max Charging Current Options inquiry",
"help": " -- queries the maximum charging current setting of the Inverter",
"result_type": ResultType.MULTIVALUED,
"reading_definitions": [
{"description": "Max Charging Current Options", "reading_type": ReadingType.MESSAGE_AMPS,
"response_type": ResponseType.STRING
}
],
"test_responses": [
b"^D034010,020,030,040,050,060,070,080\x161\r",
],
},
"MUCHGCR": {
"name": "MUCHGCR",
"command_type": CommandType.PI18_QUERY,
"description": "Max Utility Charging Current Options inquiry",
"help": " -- queries the maximum utility charging current setting of the Inverter",
"result_type": ResultType.MULTIVALUED,
"reading_definitions": [
{"reading_type": ReadingType.MESSAGE_AMPS, "description": "Max Utility Charging Current", "response_type": ResponseType.STRING}
],
"test_responses": [
b"^D038002,010,020,030,040,050,060,070,080\xd01\r"
],
},
"FLAG": {
"name": "FLAG",
"command_type": CommandType.PI18_QUERY,
"description": "Query enable/disable flag status",
"result_type": ResultType.COMMA_DELIMITED,
"reading_definitions": [
{"description": "Buzzer beep", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
{"description": "Overload bypass function", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
{"description": "Display back to default page", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
{"description": "Overload restart", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
{"description": "Over temperature restart", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
{"description": "Backlight on", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
{"description": "Alarm primary source interrupt", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
{"description": "Fault code record", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
{"description": "Reserved", "reading_type": ReadingType.MESSAGE},
],
"test_responses": [
b"^D0200,0,0,0,0,1,0,0,12\xc2\x39\r",
],
},
"VFW": {
"name": "VFW",
"description": "Device CPU version inquiry",
"command_type": CommandType.PI18_QUERY,
"result_type": ResultType.COMMA_DELIMITED,
"reading_definitions": [
{"description": "Main CPU Version", "reading_type": ReadingType.MESSAGE},
{"description": "Slave 1 CPU Version", "reading_type": ReadingType.MESSAGE},
{"description": "Slave 2 CPU Version", "reading_type": ReadingType.MESSAGE},
],
"test_responses": [
b"^D02005220,00000,00000\x3e\xf8\r",
],
},
# Fault + warning bitmap. 2-digit fault code followed by ~32 0/1 warning bits.
# Fault-code list cross-referenced with PI30 QPGS (same firmware family).
"FWS": {
"name": "FWS",
"description": "Fault and warning status inquiry",
"command_type": CommandType.PI18_QUERY,
"result_type": ResultType.COMMA_DELIMITED,
"reading_definitions": [
{"description": "Fault code", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.OPTION,
"options": {
"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",
}},
{"description": "PV loss warning", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Inverter fault", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Bus over", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Bus under", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Bus soft fail", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Line fail", "response_type": ResponseType.ENABLED_BOOL},
{"description": "OPV short", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Inverter voltage too low", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Inverter voltage too high", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Over temperature", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Fan locked", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Battery voltage high", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Battery low alarm", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Battery under shutdown", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Battery derating", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Overload", "response_type": ResponseType.ENABLED_BOOL},
{"description": "EEPROM fault", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Inverter over current", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Inverter soft fail", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Self test fail", "response_type": ResponseType.ENABLED_BOOL},
{"description": "OP DC voltage over", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Battery open", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Current sensor fail", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Battery short", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Power limit", "response_type": ResponseType.ENABLED_BOOL},
{"description": "PV voltage high", "response_type": ResponseType.ENABLED_BOOL},
{"description": "MPPT overload fault", "response_type": ResponseType.ENABLED_BOOL},
{"description": "MPPT overload warning", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Battery too low to charge", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Battery weak", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Battery equalization", "response_type": ResponseType.ENABLED_BOOL},
],
"test_responses": [
b"^D07100,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0\xaa\xaa\r",
],
},
# Per-unit parallel view. PGS<n>, n = 0..N-1 (0 is master).
# LVX6048 emits a 30-field layout that differs from the PI30 QPGS layout;
# only fields confirmed against live responses are semantically named here.
# The rest are exposed as raw strings so the command doesn't error out, and
# can be tightened later as more firmware rev responses are confirmed.
# Observed unit-1 response (Not valid + fault 71 "Parallel version different"):
# 0,4,71,2453,599,0000,000,0000,0000,00000,00000,000,211,005,000,000,000,
# 000,0008,0000,2925,0000,1,0,0,0,0,0,016
"PGS": {
"name": "PGS",
"description": "Parallel general status inquiry",
"help": " -- example: PGS0 queries parallel status for instance 0 (master)",
"command_type": CommandType.PI18_QUERY,
"result_type": ResultType.COMMA_DELIMITED,
"reading_definitions": [
{"description": "Parallel instance number", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.LIST, "options": ["Not valid", "valid"]},
{"description": "Parallel unit count", "reading_type": ReadingType.MESSAGE},
{"description": "Fault code", "reading_type": ReadingType.MESSAGE},
{"description": "Field 4 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Grid frequency", "reading_type": ReadingType.FREQUENCY,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "device_class": "frequency"},
{"description": "AC output voltage", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "device_class": "voltage"},
{"description": "AC output frequency (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "AC output apparent power", "reading_type": ReadingType.APPARENT_POWER,
"response_type": ResponseType.INT, "device_class": "apparent_power"},
{"description": "AC output active power", "reading_type": ReadingType.WATTS,
"response_type": ResponseType.INT, "device_class": "power"},
{"description": "Total AC output apparent power", "reading_type": ReadingType.APPARENT_POWER, "response_type": ResponseType.INT},
{"description": "Total AC output active power", "reading_type": ReadingType.WATTS, "response_type": ResponseType.INT},
{"description": "Load percentage", "reading_type": ReadingType.PERCENTAGE, "response_type": ResponseType.INT},
{"description": "Field 13 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Field 14 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Field 15 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Field 16 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Field 17 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Field 18 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Field 19 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Field 20 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Field 21 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Field 22 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Flag 23 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Flag 24 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Flag 25 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Flag 26 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Flag 27 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Flag 28 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Field 30 (raw)", "reading_type": ReadingType.MESSAGE},
],
"test_responses": [
b"^D1130,4,71,2453,599,0000,000,0000,0000,00000,00000,000,211,005,000,000,000,000,0008,0000,2925,0000,1,0,0,0,0,0,016\x8f\xad\r",
],
"regex": "PGS(\\d+)$",
},
}
COMMANDS_TO_REMOVE = []
class PI18(AbstractProtocol):
""" pi18 protocol handler """
def __str__(self):
return "PI18 protocol handler"
def __init__(self) -> None:
super().__init__()
self.protocol_id = b"PI18"
self.add_command_definitions(QUERY_COMMANDS)
self.add_command_definitions(SETTER_COMMANDS, result_type=ResultType.PI18_ACK)
self.remove_command_definitions(COMMANDS_TO_REMOVE)
self.check_definitions_count(expected=26) # Count of all Commands
self.add_supported_ports([PortType.SERIAL, PortType.USB])
def check_crc(self, response: str, command_definition: CommandDefinition = None):
""" crc check, override for now """
log.debug("check crc for %s in pi18", response)
if response.startswith(b"^D") or response.startswith(b"^1") or response.startswith(b"^0"):
# get response CRC
data_to_check = response[:-3]
crc_high, crc_low = crc(data_to_check)
# print(crc_high, crc_low)
# print(response[-3], response[-2])
if (crc_high, crc_low) == (response[-3], response[-2]):
return True
else:
log.info("PI18 response check_crc doesnt match calc (%x, %x), got (%x, %x)", crc_high, crc_low, response[-3], response[-2])
raise InvalidCRC(f"PI18 response check_crc doesnt match calc ({crc_high:02x}, {crc_low:02x}), got ({response[-3]:02x}, {response[-2]:02x})")
else:
log.info("PI18 response doesnt start with ^D - check_crc fails")
raise InvalidResponse("PI18 response starts with invalid character - crc check fails")
log.info("PI18 response check_crc fall through")
return False
def trim_response(self, response: str, command_definition: CommandDefinition = None) -> str:
""" Remove extra characters from response """
log.debug("trim %s, definition: %s", response, command_definition)
if response.startswith(b"^D"):
# trim ^Dxxx where xxx is data length
response = response[5:]
if response.endswith(b'\r'):
# has checksum, so trim last 3 chars
response = response[:-3]
if response.startswith(b'('):
# pi30 style response
response = response[1:]
# if response.startswith(b'^1') or response.startswith(b'^0'):
# # ACK / NACK response
# response = response[1:]
return response
def get_full_command(self, command: str) -> bytes:
""" generate the full command including prefix, crc and \n as needed """
log.info("Using protocol: %s with %i commands", self.protocol_id, len(self.command_definitions))
command_defn = self.get_command_definition(command)
# raise exception if no command definition is found
if command_defn is None:
raise CommandDefinitionMissing(f"No definition found in PI18 for {command}")
# full command is ^PlllCCCcrc\n or ^SlllCCCcrc\n
# lll = length of all except ^Dlll
# CCC = command
# crc = 2 bytes
length = len(command) + 3
# Determine prefix
match command_defn.command_type:
case CommandType.PI18_QUERY:
prefix = "^P"
case CommandType.PI18_SETTER:
prefix = "^S"
case _:
# edge case / default PI30 command / maybe this should raise an error
prefix = "("
full_command = bytes(f"{prefix}{length:#03d}{command}", "utf-8")
crc_high, crc_low = crc(full_command)
full_command += bytes([crc_high, crc_low, 13])
log.debug("full command: %s", full_command)
return full_command

View File

@@ -0,0 +1,38 @@
""" pydantic definitions for the powermon port config model
"""
from typing import Literal
from pydantic import Field
from powermon.configmodel import NoExtraBaseModel
class BlePortConfig(NoExtraBaseModel):
""" model/allowed elements for ble port config """
type: Literal["ble"]
mac: str
protocol: None | str
victron_key: None | str = Field(default=None, repr=False)
class SerialPortConfig(NoExtraBaseModel):
""" model/allowed elements for serial port config """
type: Literal["serial"]
path: str
baud: None | int = Field(default=None)
protocol: None | str
class UsbPortConfig(NoExtraBaseModel):
""" model/allowed elements for usb port config """
type: Literal["usb"]
path: None | str
protocol: None | str
serial_number: None | str | int = Field(default=None)
class TestPortConfig(NoExtraBaseModel):
""" model/allowed elements for test port config """
type: Literal["test"]
response_number: None | int = Field(default=None)
protocol: None | str = Field(default=None)

View File

@@ -0,0 +1,76 @@
""" powermon / ports / __init__.py """
import logging
from enum import StrEnum, auto
from pydantic import BaseModel
from powermon.libs.errors import ConfigError
# Set-up logger
log = logging.getLogger("ports")
class PortType(StrEnum):
""" enumeration of supported / known port types """
UNKNOWN = auto()
TEST = auto()
SERIAL = auto()
USB = auto()
BLE = auto()
JKBLE = auto()
MQTT = auto()
VSERIAL = auto()
DALYSERIAL = auto()
ESP32 = auto()
class PortTypeDTO(BaseModel):
""" data transfer model for PortType class """
port_type: PortType
def from_config(port_config, serial_number=None):
""" get a port object from config data """
log.debug("port_config: %s", port_config)
port_object = None
if not port_config:
raise ConfigError("no port config supplied")
# port type is mandatory
port_type = port_config.get("type")
log.debug("portType: %s", port_type)
# return None if port type is not defined
if port_type is None:
return None
# add serial_number to config — but only if the port config didn't already
# specify one. Port-level serial_number is the hardware serial used by
# USBPort.resolve_path for wildcard matching; device-level serial_number is
# the logical HA identifier and is unrelated.
if port_config.get('serial_number') is None:
port_config['serial_number'] = serial_number
# build port object
match port_type:
case PortType.TEST:
from powermon.ports.testport import TestPort
port_object = TestPort.from_config(config=port_config)
case PortType.SERIAL:
from powermon.ports.serialport import SerialPort
port_object = SerialPort.from_config(config=port_config)
case PortType.USB:
from powermon.ports.usbport import USBPort
port_object = USBPort.from_config(config=port_config)
# Pattern for port types that cause problems when imported
case PortType.BLE:
log.debug("port_type BLE found")
from powermon.ports.bleport import BlePort
port_object = BlePort.from_config(config=port_config)
case _:
log.info("port type object not found for %s", port_type)
raise ConfigError(f"Invalid port type: '{port_type}'")
return port_object

View File

@@ -0,0 +1,169 @@
""" powermon / ports / usbport.py """
# import asyncio
import logging
import os
import time
from glob import glob
from powermon.commands.command import Command
from powermon.commands.result import Result, ResultType
from powermon.libs.errors import ConfigError, PowermonProtocolError
from powermon.ports import PortType
from powermon.ports.abstractport import AbstractPort, _AbstractPortDTO
from powermon.protocols import get_protocol_definition
log = logging.getLogger("USBPort")
class UsbPortDTO(_AbstractPortDTO):
""" data transfer model for SerialPort class """
path: str
serial_number: None | int | str
class USBPort(AbstractPort):
""" usb port object """
@classmethod
async def from_config(cls, config=None):
log.debug("building usb port. config:%s", config)
path = config.get("path", "/dev/hidraw0")
serial_number = config.get("serial_number")
# get protocol handler, default to PI30 if not supplied
protocol = get_protocol_definition(protocol=config.get("protocol", "PI30"))
# instantiate class
_class = cls(path=path, protocol=protocol)
# deal with wildcard path resolution
_class.path = await _class.resolve_path(path, serial_number)
return _class
def __init__(self, path, protocol) -> None:
self.port_type = PortType.USB
super().__init__(protocol=protocol)
self.path = None
self.port = None
async def resolve_path(self, path, serial_number):
"""Async method to resolve a valid path by testing each one."""
# expand 'wildcard'
paths = glob(path)
if not paths:
raise ConfigError(f"No matching paths found on this system for {path}")
if len(paths) == 1:
return paths[0] # only one valid result
# More than one valid path
# check we have something to look for
if serial_number is None:
raise ConfigError("Wildcard paths require a serial_number in config.")
# check we have get_id in this protocol
try:
command = self.protocol.get_id_command()
except PowermonProtocolError as ex:
raise ConfigError(f"No get_id in protocol: {self.protocol.protocol_id}") from ex
for _path in paths:
log.debug("Checking path: %s for serial_number: %s", _path, serial_number)
self.path = _path
await self.connect()
res = await self.send_and_receive(command=command)
await self.disconnect()
if res.is_valid and str(res.readings[0].data_value) == str(serial_number):
log.info("SUCCESS: path: %s matches serial_number: %s", _path, serial_number)
return _path # return the matching path
raise ConfigError(f"None of the paths match serial_number: {serial_number}")
def to_dto(self):
dto = UsbPortDTO(port_type="usb", path=self.path, protocol=self.protocol.to_dto())
return dto
def is_connected(self) -> bool:
return self.port is not None
async def connect(self) -> bool:
if self.is_connected():
log.debug("USBPort already connected")
return True
log.debug("USBPort connecting. path:%s, protocol:%s", self.path, self.protocol)
try:
self.port = os.open(self.path, os.O_RDWR | os.O_NONBLOCK)
log.debug("USBPort port number $%s", self.port)
except Exception as e:
log.warning("Error openning usb port: %s", e)
self.port = None
self.error_message = e
return self.is_connected()
async def disconnect(self) -> None:
log.debug("USBPort disconnecting: %i", self.port)
if self.port is not None:
os.close(self.port)
self.port = None
async def send_and_receive(self, command: Command) -> Result:
if not self.is_connected():
log.warning("USBPort not connected")
return command.build_result(result_type=ResultType.ERROR, raw_response=b"USBPort not connected", protocol=self.protocol)
response_line = bytes()
# Drain any leftover bytes from previous command's response so we
# don't parse them as this command's reply.
try:
while True:
stale = os.read(self.port, 256)
if not stale:
break
log.debug("drained %i stale bytes: %s", len(stale), stale)
except BlockingIOError:
pass
# Send the command to the open usb connection
full_command = command.full_command
cmd_len = len(full_command)
log.debug("length of to_send: %i", cmd_len)
# for command of len < 8 it ok just to send
# otherwise need to pack to a multiple of 8 bytes and send 8 at a time
try:
if cmd_len <= 8:
# Send all at once
log.debug("sending full_command in on shot")
time.sleep(0.05)
os.write(self.port, full_command)
else:
log.debug("multiple chunk send")
chunks = [full_command[i:i + 8] for i in range(0, cmd_len, 8)]
for chunk in chunks:
# pad chunk to 8 bytes
if len(chunk) < 8:
padding = 8 - len(chunk)
chunk += b'\x00' * padding
log.debug("sending chunk: %s", chunk)
time.sleep(0.05)
os.write(self.port, chunk)
time.sleep(0.25)
# Read from the usb connection
# try to a max of 100 times
for _ in range(100):
# attempt to deal with resource busy and other failures to read
time.sleep(0.15)
try:
r = os.read(self.port, 256)
except BlockingIOError:
continue
response_line += r
# Finished is \r is in byte_response
if bytes([13]) in response_line:
# remove anything after the \r
response_line = response_line[: response_line.find(bytes([13])) + 1]
break
except BrokenPipeError as e:
log.debug("USB read error: %s", e)
log.debug("usb response was: %s", response_line)
# response = self.get_protocol().check_response_and_trim(response_line)
result = command.build_result(raw_response=response_line, protocol=self.protocol)
return result

View File

@@ -0,0 +1,26 @@
device:
name: lvx6048_console
serial_number: lvx6048_console
port:
type: usb
path: /dev/lvx6048-1
protocol: PI18
commands:
- command: GS
trigger:
every: 5
outputs:
- type: screen
format: table
- command: MOD
trigger:
every: 10
outputs:
- type: screen
format: table
- command: PIRI
trigger:
every: 300
outputs:
- type: screen
format: table

View File

@@ -0,0 +1,12 @@
device:
name: lvx6048_smoketest
serial_number: lvx6048_smoketest
port:
type: usb
path: /dev/lvx6048-1
protocol: PI18
commands:
- command: GS
outputs:
- type: screen
format: table