initialize
This commit is contained in:
177
LVX6048/Install.md
Normal file
177
LVX6048/Install.md
Normal 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
308
LVX6048/Monitoring.md
Normal 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` §4–6.
|
||||
|
||||
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
154
LVX6048/README.md
Normal 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
98
LVX6048/bin/lvx-resolve-links
Executable 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()))
|
||||
62
LVX6048/config/powermon/powermon.yaml
Normal file
62
LVX6048/config/powermon/powermon.yaml
Normal 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
|
||||
62
LVX6048/config/powermon/powermon2.yaml
Normal file
62
LVX6048/config/powermon/powermon2.yaml
Normal 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
|
||||
13
LVX6048/etc/systemd/system/lvx-resolve-links.service
Normal file
13
LVX6048/etc/systemd/system/lvx-resolve-links.service
Normal 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
|
||||
15
LVX6048/etc/systemd/system/powermon.service
Normal file
15
LVX6048/etc/systemd/system/powermon.service
Normal 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
|
||||
@@ -0,0 +1,3 @@
|
||||
[Unit]
|
||||
After=lvx-resolve-links.service
|
||||
Requires=lvx-resolve-links.service
|
||||
15
LVX6048/etc/systemd/system/powermon2.service
Normal file
15
LVX6048/etc/systemd/system/powermon2.service
Normal 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
|
||||
@@ -0,0 +1,3 @@
|
||||
[Unit]
|
||||
After=lvx-resolve-links.service
|
||||
Requires=lvx-resolve-links.service
|
||||
6
LVX6048/etc/udev/rules.d/99-lvx6048.rules
Normal file
6
LVX6048/etc/udev/rules.d/99-lvx6048.rules
Normal 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
109
LVX6048/install.sh
Executable 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"
|
||||
73
LVX6048/lvx-flash/README.md
Normal file
73
LVX6048/lvx-flash/README.md
Normal 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
724
LVX6048/lvx-flash/flash.py
Executable 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())
|
||||
39
LVX6048/lvx-flash/profiles/current.yaml
Normal file
39
LVX6048/lvx-flash/profiles/current.yaml
Normal 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
|
||||
|
||||
28
LVX6048/powermon-patches/README.md
Normal file
28
LVX6048/powermon-patches/README.md
Normal 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.
|
||||
220
LVX6048/powermon-patches/mqttbroker.py
Normal file
220
LVX6048/powermon-patches/mqttbroker.py
Normal 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)
|
||||
651
LVX6048/powermon-patches/pi18.py
Normal file
651
LVX6048/powermon-patches/pi18.py
Normal 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
|
||||
38
LVX6048/powermon-patches/port_config_model.py
Normal file
38
LVX6048/powermon-patches/port_config_model.py
Normal 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)
|
||||
76
LVX6048/powermon-patches/ports_init.py
Normal file
76
LVX6048/powermon-patches/ports_init.py
Normal 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
|
||||
169
LVX6048/powermon-patches/usbport.py
Normal file
169
LVX6048/powermon-patches/usbport.py
Normal 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
|
||||
26
LVX6048/smoketest/console.yaml
Normal file
26
LVX6048/smoketest/console.yaml
Normal 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
|
||||
12
LVX6048/smoketest/smoketest.yaml
Normal file
12
LVX6048/smoketest/smoketest.yaml
Normal 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
|
||||
Reference in New Issue
Block a user