initialize

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

177
LVX6048/Install.md Normal file
View File

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

308
LVX6048/Monitoring.md Normal file
View File

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

154
LVX6048/README.md Normal file
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
# LVX6048 (MPP Solar / Voltronic) USB-HID — dialout access only.
# Logical unit identification is done via PI18 `ID` query at powermon startup
# (see powermon.yaml: path: /dev/hidraw*, serial_number: <SN>).
# No SYMLINK here — powermon resolves the right /dev/hidrawN by inverter serial,
# which survives cable moves / hub port changes.
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0665", ATTRS{idProduct}=="5161", MODE="0660", GROUP="dialout"

109
LVX6048/install.sh Executable file
View File

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

View File

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

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

@@ -0,0 +1,724 @@
#!/home/noise/.local/share/uv/tools/powermon/bin/python
"""
lvx-flash — apply a YAML settings profile to an LVX6048 via PI18 over USB-HID.
Usage:
./flash.py dump --device /dev/lvx6048-1 --out profiles/current.yaml
./flash.py diff --device /dev/lvx6048-1 --profile profiles/X.yaml
./flash.py apply --device /dev/lvx6048-1 --profile profiles/X.yaml --confirm
./flash.py compare --device-a /dev/lvx6048-1 --device-b /dev/lvx6048-2
./flash.py sync-check --device-a /dev/lvx6048-1 --device-b /dev/lvx6048-2
Safety:
- `apply` stops powermon.service for the duration and restarts it afterwards.
- Every set is followed by a PIRI readback that must match, or the run aborts.
- Values are range-checked before any write.
- Settings are applied in a safe order (cutoff < stop-discharge < float < bulk).
"""
from __future__ import annotations
import argparse
import asyncio
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
import yaml
from powermon.commands.command import Command
from powermon.commands.result import ResultType
from powermon.protocols import get_protocol_definition
from powermon.ports.usbport import USBPort
PROTOCOL = "PI18"
UNIT_INDEX = 0 # parallel-stack unit index for PCP / MCHGC / MUCHGC. Master = 0.
# Default serial numbers for this stack. Used when --device / --serial are
# omitted; each command will glob /dev/hidraw* and probe PI18 ID on each to
# find the matching unit, so cable/hub-port moves do not require reconfig.
SERIAL_UNIT_1 = "1496142109100037000000"
SERIAL_UNIT_2 = "1496142408100255000000"
# -------- profile-key → PI18 encoding + PIRI readback index --------
# PIRI test response has 26 fields; indices below are 0-based.
# Source: powermon/protocols/pi18.py :: QUERY_COMMANDS["PIRI"].reading_definitions
PIRI = {
"battery_voltage": 7, # V (rated, read-only)
"stop_discharge_voltage": 8, # V (BUCD field 1, a.k.a. "re-charge")
"stop_charge_voltage": 9, # V (BUCD field 2, a.k.a. "re-discharge"; 0 = Full)
"cutoff_voltage": 10, # V (PSDV)
"bulk_voltage": 11, # V (MCHGV field 1)
"float_voltage": 12, # V (MCHGV field 2)
"battery_type": 13, # enum index (PBT)
"max_utility_charging_current": 14, # A (MUCHGC)
"max_charging_current": 15, # A (MCHGC)
"output_source_priority": 17, # enum index (POP)
"charger_priority": 18, # enum index (PCP)
"machine_type": 20, # enum index (PEI/PDI: 0=Off Grid, 1=Grid Tie)
"solar_power_priority": 23, # enum index (PSP)
}
# Per-key guidance emitted as inline comments by `dump`.
# Keep short — one line each, phrased as constraints.
KEY_DOCS: dict[str, str] = {
"battery_type": "enum: AGM | FLOODED | USER",
"cutoff_voltage": "V 40.0..48.0 — hard shutdown; must be < stop_discharge_voltage. Only honored when battery_type=USER.",
"stop_discharge_voltage": "V 44.0..51.0 — switch battery→grid below this (pair: stop_charge_voltage). Only honored when battery_type=USER.",
"stop_charge_voltage": "V 0 (=Full) or 48.0..58.0 — switch grid→battery above this (pair: stop_discharge_voltage). Only honored when battery_type=USER.",
"bulk_voltage": "V 48.0..58.4 — CC→CV transition (pair: float_voltage; must be >= float_voltage). Only honored when battery_type=USER.",
"float_voltage": "V 48.0..58.4 — held while on grid (pair: bulk_voltage; must be <= bulk_voltage). Only honored when battery_type=USER.",
"max_charging_current": "A 10,20,30,40,50,60,70,80 — combined solar+AC cap",
"max_utility_charging_current": "A 2,10,20,30,40,50,60,70,80 — grid-side cap only",
"output_source_priority": "enum: solar_utility_battery | solar_battery_utility",
"charger_priority": "enum: solar_first | solar_and_utility | solar_only",
"solar_power_priority": "enum: battery_load_utility_ac | load_battery_utility",
"grid_tie": "enum: enabled | disabled (PEI/PDI)",
}
POP_MAP = {"solar_utility_battery": "0", "solar_battery_utility": "01"}
PCP_MAP = {"solar_first": "0", "solar_and_utility": "1", "solar_only": "2"}
PSP_MAP = {"battery_load_utility_ac": "0", "load_battery_utility": "1"}
PBT_MAP = {"AGM": "0", "FLOODED": "1", "USER": "2"}
# enum indices in PIRI (for readback verification)
POP_PIRI = {"0": "solar_utility_battery", "1": "solar_battery_utility"}
PCP_PIRI = {"0": "solar_first", "1": "solar_and_utility", "2": "solar_only"}
PSP_PIRI = {"0": "battery_load_utility_ac", "1": "load_battery_utility"}
PBT_PIRI = {"0": "AGM", "1": "FLOODED", "2": "USER"}
MACHINE_PIRI = {"0": "off_grid", "1": "grid_tie"}
@dataclass
class Setting:
key: str
encoder: Callable[[Any], str] # profile value -> PI18 raw command (no prefix/CRC)
decoder: Callable[[str], Any] # PIRI raw field -> profile value
piri_field: int
pair_keys: tuple[str, ...] = () # if set, these keys are encoded together in one command
def _v_to_tenths(v: float | int) -> str:
return f"{int(round(float(v) * 10)):03d}"
def _tenths_to_v(raw: str) -> float:
return int(raw) / 10.0
def _amps_to_str(a: int) -> str:
return f"{int(a):03d}"
def _amps_from_str(raw: str) -> int:
return int(raw)
# Applied in this order. Safe: set low-range protections before high-range; currents before priorities.
SCHEDULE: list[Setting] = [
Setting("battery_type",
encoder=lambda v: f"PBT{PBT_MAP[v]}",
decoder=lambda r: PBT_PIRI.get(r.lstrip("0") or "0", r),
piri_field=PIRI["battery_type"]),
Setting("cutoff_voltage",
encoder=lambda v: f"PSDV{_v_to_tenths(v)}",
decoder=_tenths_to_v,
piri_field=PIRI["cutoff_voltage"]),
# BUCD sets stop-discharge and stop-charge together
Setting("stop_discharge_voltage",
encoder=lambda pair: f"BUCD{_v_to_tenths(pair[0])},{'000' if pair[1] in (0,0.0) else _v_to_tenths(pair[1])}",
decoder=_tenths_to_v,
piri_field=PIRI["stop_discharge_voltage"],
pair_keys=("stop_discharge_voltage", "stop_charge_voltage")),
# MCHGV sets bulk and float together
Setting("bulk_voltage",
encoder=lambda pair: f"MCHGV{_v_to_tenths(pair[0])},{_v_to_tenths(pair[1])}",
decoder=_tenths_to_v,
piri_field=PIRI["bulk_voltage"],
pair_keys=("bulk_voltage", "float_voltage")),
Setting("max_utility_charging_current",
encoder=lambda a: f"MUCHGC{UNIT_INDEX},{_amps_to_str(a)}",
decoder=_amps_from_str,
piri_field=PIRI["max_utility_charging_current"]),
Setting("max_charging_current",
encoder=lambda a: f"MCHGC{UNIT_INDEX},{_amps_to_str(a)}",
decoder=_amps_from_str,
piri_field=PIRI["max_charging_current"]),
Setting("output_source_priority",
encoder=lambda v: f"POP{POP_MAP[v]}",
decoder=lambda r: POP_PIRI.get(r.lstrip("0") or "0", r),
piri_field=PIRI["output_source_priority"]),
Setting("charger_priority",
encoder=lambda v: f"PCP{UNIT_INDEX},{PCP_MAP[v]}",
decoder=lambda r: PCP_PIRI.get(r.lstrip("0") or "0", r),
piri_field=PIRI["charger_priority"]),
Setting("solar_power_priority",
encoder=lambda v: f"PSP{PSP_MAP[v]}",
decoder=lambda r: PSP_PIRI.get(r.lstrip("0") or "0", r),
piri_field=PIRI["solar_power_priority"]),
Setting("grid_tie",
encoder=lambda v: "PEI" if v == "enabled" else "PDI",
decoder=lambda r: "grid_tie" if r.lstrip("0") == "1" else "off_grid",
piri_field=PIRI["machine_type"]),
]
# -------- range / consistency validation --------
def _validate(profile: dict) -> list[str]:
errs: list[str] = []
def rng(key, lo, hi):
if key in profile and not (lo <= profile[key] <= hi):
errs.append(f"{key}={profile[key]} out of range [{lo}, {hi}]")
rng("cutoff_voltage", 40.0, 48.0)
rng("stop_discharge_voltage", 44.0, 51.0)
if "stop_charge_voltage" in profile and profile["stop_charge_voltage"] != 0:
rng("stop_charge_voltage", 48.0, 58.0)
rng("bulk_voltage", 48.0, 58.4)
rng("float_voltage", 48.0, 58.4)
rng("max_charging_current", 10, 80)
rng("max_utility_charging_current", 2, 80)
if "cutoff_voltage" in profile and "stop_discharge_voltage" in profile:
if profile["cutoff_voltage"] >= profile["stop_discharge_voltage"]:
errs.append("cutoff_voltage must be < stop_discharge_voltage")
if "float_voltage" in profile and "bulk_voltage" in profile:
if profile["float_voltage"] > profile["bulk_voltage"]:
errs.append("float_voltage must be <= bulk_voltage")
if "max_charging_current" in profile and profile["max_charging_current"] not in (10,20,30,40,50,60,70,80):
errs.append("max_charging_current must be one of 10,20,...,80")
if "max_utility_charging_current" in profile and profile["max_utility_charging_current"] not in (2,10,20,30,40,50,60,70,80):
errs.append("max_utility_charging_current must be one of 2,10,20,...,80")
for k, allowed in [("output_source_priority", POP_MAP), ("charger_priority", PCP_MAP),
("solar_power_priority", PSP_MAP), ("battery_type", PBT_MAP)]:
if k in profile and profile[k] not in allowed:
errs.append(f"{k}={profile[k]!r} not in {list(allowed)}")
if "grid_tie" in profile and profile["grid_tie"] not in ("enabled", "disabled"):
errs.append("grid_tie must be 'enabled' or 'disabled'")
return errs
# -------- powermon glue --------
async def _open_port(path: str) -> USBPort:
protocol = get_protocol_definition(protocol=PROTOCOL)
port = USBPort(path=path, protocol=protocol)
port.path = path
await port.connect()
if not port.is_connected():
raise RuntimeError(f"could not open {path}")
return port
async def _resolve_path(device: str | None, serial: str | None) -> str:
"""Return a concrete device path.
If `serial` is given, glob /dev/hidraw* and probe each candidate via PI18 ID
until one matches. Otherwise return `device` verbatim (typically the
resolver-maintained /dev/lvx6048-N symlink). --serial takes precedence for
cases where the resolver hasn't run (e.g. early boot, bare hidraw probing).
"""
import glob as _glob
if serial:
pass # fall through to probe
elif device:
return device
else:
raise RuntimeError("must supply --device or --serial")
candidates = sorted(_glob.glob("/dev/hidraw*"))
if not candidates:
raise RuntimeError("no /dev/hidraw* devices present")
for path in candidates:
try:
port = await _open_port(path)
except Exception:
continue
try:
parts = await _read_raw_parts(port, "ID")
# ID returns one field, so parts[0] is the inverter serial
if parts and parts[0] == str(serial):
return path
except Exception:
pass
finally:
await port.disconnect()
raise RuntimeError(f"no device at /dev/hidraw* responds with serial {serial!r}")
def _build_command(code: str, proto) -> Command:
cd = proto.get_command_definition(code)
if cd is None:
raise RuntimeError(f"unknown PI18 command: {code!r}")
cmd = Command(code=code, commandtype="basic", outputs=[], trigger=None)
cmd.command_definition = cd
cmd.full_command = proto.get_full_command(code)
return cmd
async def _send(port: USBPort, code: str):
cmd = _build_command(code, port.protocol)
result = await port.send_and_receive(cmd)
return result
async def _read_piri(port: USBPort, retries: int = 3) -> dict[str, str]:
"""Return {name: raw_string} for each PIRI field.
Retries on transient decode failures — powermon's parser can IndexError when
the hidraw fd still holds leftover bytes from a prior command (e.g. running
multiple queries back-to-back in sync-check).
"""
cmd = _build_command("PIRI", port.protocol)
last_err: Any = None
for _ in range(retries):
try:
result = await port.send_and_receive(cmd)
except (IndexError, KeyError, ValueError) as e:
last_err = e
await asyncio.sleep(0.3)
continue
if result is not None and getattr(result, "is_valid", True):
raw = result.raw_response
if raw.startswith(b"^D"):
raw = raw[5:] # ^D + 3-digit length
if raw.endswith(b"\r"):
raw = raw[:-3] # 2-byte CRC + \r
parts = raw.decode("ascii", errors="replace").split(",")
return {i: p for i, p in enumerate(parts)} | {"_parts": parts}
last_err = getattr(result, "error_messages", None) or result
await asyncio.sleep(0.3)
raise RuntimeError(f"PIRI read failed after {retries} attempts: {last_err}")
def _piri_raw(piri: dict, idx: int) -> str:
return piri["_parts"][idx]
async def _wait_ack(port: USBPort, code: str) -> bool:
"""Send a setter. Return True on ^1, False on ^0 / unknown."""
cmd = _build_command(code, port.protocol)
result = await port.send_and_receive(cmd)
raw = getattr(result, "raw_response", b"") or b""
# setter response framed as ^Dlll^1... or similar. Just look for ^1 / ^0 anywhere.
if b"^1" in raw:
return True
if b"^0" in raw:
return False
# fallback: inspect parsed readings
for r in (result.readings or []):
v = str(r.data_value).lower()
if "succeed" in v:
return True
if "fail" in v:
return False
return False
# -------- dump / diff / apply --------
def _dump_profile(piri_raw: list[str]) -> dict:
"""Convert PIRI raw fields into a profile dict, using the SCHEDULE decoders."""
out: dict[str, Any] = {}
# walk SCHEDULE once; pairs already have both halves
for s in SCHEDULE:
if s.key == "grid_tie":
mt = piri_raw[PIRI["machine_type"]]
out["grid_tie"] = "enabled" if mt.lstrip("0") == "1" else "disabled"
continue
out[s.key] = s.decoder(piri_raw[s.piri_field])
# fill pair's partner keys too (bulk/float already via bulk_voltage row; pair_keys picks up the second)
# BUCD pair
out["stop_discharge_voltage"] = _tenths_to_v(piri_raw[PIRI["stop_discharge_voltage"]])
out["stop_charge_voltage"] = _tenths_to_v(piri_raw[PIRI["stop_charge_voltage"]])
out["bulk_voltage"] = _tenths_to_v(piri_raw[PIRI["bulk_voltage"]])
out["float_voltage"] = _tenths_to_v(piri_raw[PIRI["float_voltage"]])
return out
def _diff(want: dict, have: dict) -> list[tuple[str, Any, Any]]:
diffs = []
for k, v in want.items():
if k not in have:
continue
hv = have[k]
if isinstance(v, float) or isinstance(hv, float):
if abs(float(v) - float(hv)) > 0.05:
diffs.append((k, hv, v))
else:
if v != hv:
diffs.append((k, hv, v))
return diffs
def _systemctl(action: str) -> None:
# Stop both units so neither holds the hidraw fd for the one we're writing to.
subprocess.run(["sudo", "systemctl", action, "powermon.service", "powermon2.service"], check=True)
async def cmd_dump(args) -> int:
path = await _resolve_path(args.device, args.serial)
port = await _open_port(path)
try:
piri = await _read_piri(port)
finally:
await port.disconnect()
prof = _dump_profile(piri["_parts"])
# Drop any keys whose values aren't round-trippable via their PI18 setter.
errs = _validate(prof)
skipped: list[str] = []
for e in errs:
for k in list(prof):
if e.startswith(k + "=") or e.startswith(k + " "):
if k in prof:
skipped.append(f"{k}={prof.pop(k)!r} ({e})")
break
out_path = Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w") as f:
f.write("# LVX6048 settings profile. Edit freely; all keys are optional.\n")
f.write("# `flash.py diff` previews changes; `flash.py apply --confirm` writes.\n")
if skipped:
f.write("#\n# skipped (read-only or out of settable range):\n")
for s in skipped:
f.write(f"# {s}\n")
f.write("\n")
for k, v in prof.items():
doc = KEY_DOCS.get(k)
if doc:
f.write(f"# {doc}\n")
# one-key dump preserves native YAML formatting for scalars
f.write(yaml.safe_dump({k: v}, sort_keys=False, default_flow_style=False))
f.write("\n")
print(f"wrote {out_path} with {len(prof)} keys" + (f" ({len(skipped)} skipped)" if skipped else ""))
return 0
async def cmd_diff(args) -> int:
with open(args.profile) as f:
want = yaml.safe_load(f) or {}
errs = _validate(want)
if errs:
for e in errs:
print(f"INVALID: {e}")
return 2
path = await _resolve_path(args.device, args.serial)
port = await _open_port(path)
try:
piri = await _read_piri(port)
finally:
await port.disconnect()
have = _dump_profile(piri["_parts"])
diffs = _diff(want, have)
if not diffs:
print("no diff — profile matches device")
return 0
print(f"{len(diffs)} setting(s) would change:")
for k, hv, wv in diffs:
print(f" {k}: {hv!r} -> {wv!r}")
return 0
async def cmd_apply(args) -> int:
with open(args.profile) as f:
want = yaml.safe_load(f) or {}
errs = _validate(want)
if errs:
for e in errs:
print(f"INVALID: {e}")
return 2
if not args.confirm:
print("refusing to write without --confirm (use `diff` to preview)")
return 2
path = await _resolve_path(args.device, args.serial)
print("stopping powermon.service...")
_systemctl("stop")
try:
port = await _open_port(path)
try:
piri = await _read_piri(port)
have = _dump_profile(piri["_parts"])
diffs = _diff(want, have)
if not diffs:
print("nothing to do")
return 0
pending = {k for k, _, _ in diffs}
applied: list[str] = []
for s in SCHEDULE:
# handle pair settings once
if s.pair_keys:
a, b = s.pair_keys
if a not in pending and b not in pending:
continue
if a not in want or b not in want:
print(f"SKIP {a}/{b}: pair requires both keys in profile")
continue
code = s.encoder((want[a], want[b]))
print(f"-> {code} ({a}={want[a]}, {b}={want[b]})")
ok = await _wait_ack(port, code)
if not ok:
print(f"FAIL: inverter NAK on {code}")
return 3
# readback
piri2 = await _read_piri(port)
new_a = _tenths_to_v(piri2["_parts"][PIRI[a]])
new_b = _tenths_to_v(piri2["_parts"][PIRI[b]])
if abs(new_a - float(want[a])) > 0.05 or abs(new_b - float(want[b])) > 0.05:
print(f"FAIL: readback mismatch: {a}={new_a}, {b}={new_b}")
return 3
applied += [a, b]
continue
if s.key not in pending:
continue
code = s.encoder(want[s.key])
print(f"-> {code} ({s.key}={want[s.key]})")
ok = await _wait_ack(port, code)
if not ok:
print(f"FAIL: inverter NAK on {code}")
return 3
piri2 = await _read_piri(port)
actual = s.decoder(piri2["_parts"][s.piri_field])
expected = want[s.key]
if isinstance(actual, float):
match = abs(float(actual) - float(expected)) <= 0.05
else:
match = actual == expected or (s.key == "grid_tie" and actual == ("grid_tie" if expected == "enabled" else "off_grid"))
if not match:
print(f"FAIL: readback mismatch on {s.key}: got {actual!r}, want {expected!r}")
return 3
applied.append(s.key)
print(f"OK — applied {len(applied)} setting(s): {', '.join(applied)}")
return 0
finally:
await port.disconnect()
finally:
print("restarting powermon.service...")
_systemctl("start")
# -------- sync-check: are the two units in a valid parallel state? --------
# PI18 MOD codes
MOD_NAMES = {
"00": "Power on", "01": "Standby", "02": "Bypass",
"03": "Battery", "04": "Fault", "05": "Hybrid",
}
# PI18 FWS fault-code map (cross-referenced with PI30 QPGS fault codes)
FAULT_NAMES = {
"00": "No fault",
"01": "Fan is locked",
"02": "Over temperature",
"03": "Battery voltage is too high",
"04": "Battery voltage is too low",
"05": "Output short circuited or Over temperature",
"06": "Output voltage is too high",
"07": "Over load time out",
"08": "Bus voltage is too high",
"09": "Bus soft start failed",
"11": "Main relay failed",
"51": "Over current inverter",
"52": "Bus soft start failed",
"53": "Inverter soft start failed",
"54": "Self-test failed",
"55": "Over DC voltage on output of inverter",
"56": "Battery connection is open",
"57": "Current sensor failed",
"58": "Output voltage is too low",
"60": "Inverter negative power",
"71": "Parallel version different",
"72": "Output circuit failed",
"80": "CAN communication failed",
"81": "Parallel host line lost",
"82": "Parallel synchronized signal lost",
"83": "Parallel battery voltage detect different",
"84": "Parallel Line voltage or frequency detect different",
"85": "Parallel Line input current unbalanced",
"86": "Parallel output setting different",
}
# GS field indices (see powermon/protocols/pi18.py :: QUERY_COMMANDS["GS"])
GS_AC_OUTPUT_V = 2
GS_AC_OUTPUT_HZ = 3
GS_PARALLEL_VALID = 27
async def _read_raw_parts(port: USBPort, code: str, retries: int = 3) -> list[str]:
cmd = _build_command(code, port.protocol)
last_err: Any = None
for _ in range(retries):
result = await port.send_and_receive(cmd)
if result is not None and getattr(result, "is_valid", False):
raw = result.raw_response
if raw.startswith(b"^D"):
raw = raw[5:] # ^D + 3-digit length
if raw.endswith(b"\r"):
raw = raw[:-3] # 2-byte CRC + \r
return raw.decode("ascii", errors="replace").split(",")
last_err = getattr(result, "error_messages", None) or result
await asyncio.sleep(0.3)
raise RuntimeError(f"{code} read failed after {retries} attempts: {last_err}")
async def _snapshot_sync(path: str) -> dict[str, Any]:
port = await _open_port(path)
try:
gs = await _read_raw_parts(port, "GS")
fws = await _read_raw_parts(port, "FWS")
mod = await _read_raw_parts(port, "MOD")
vfw = await _read_raw_parts(port, "VFW")
finally:
await port.disconnect()
return {
"parallel_valid": gs[GS_PARALLEL_VALID] == "1",
"ac_output_v": _tenths_to_v(gs[GS_AC_OUTPUT_V]),
"ac_output_hz": int(gs[GS_AC_OUTPUT_HZ]) / 10.0,
"fault_code": fws[0],
"fault_name": FAULT_NAMES.get(fws[0], f"unknown ({fws[0]})"),
"mode_raw": mod[0],
"mode": MOD_NAMES.get(mod[0], mod[0]),
"main_cpu": vfw[0],
"slave_cpu": vfw[1],
}
async def cmd_sync_check(args) -> int:
path_a = await _resolve_path(args.device_a, args.serial_a)
path_b = await _resolve_path(args.device_b, args.serial_b)
a = await _snapshot_sync(path_a)
b = await _snapshot_sync(path_b)
def _row(label: str, s: dict) -> str:
valid = "valid" if s["parallel_valid"] else "NOT VALID"
return (f"{label}: fw={s['main_cpu']}/{s['slave_cpu']} mode={s['mode']} "
f"parallel={valid} fault={s['fault_name']} "
f"vac={s['ac_output_v']}V fac={s['ac_output_hz']}Hz")
print(_row(path_a, a))
print(_row(path_b, b))
issues: list[str] = []
if a["main_cpu"] != b["main_cpu"] or a["slave_cpu"] != b["slave_cpu"]:
issues.append(f"firmware mismatch: {a['main_cpu']}/{a['slave_cpu']} vs {b['main_cpu']}/{b['slave_cpu']} — parallel requires matching firmware on both units")
if not a["parallel_valid"]:
issues.append(f"{path_a}: GS parallel_instance_number = Not valid")
if not b["parallel_valid"]:
issues.append(f"{path_b}: GS parallel_instance_number = Not valid")
if a["fault_code"] != "00":
issues.append(f"{path_a}: active fault {a['fault_code']} ({a['fault_name']})")
if b["fault_code"] != "00":
issues.append(f"{path_b}: active fault {b['fault_code']} ({b['fault_name']})")
if a["mode_raw"] != b["mode_raw"]:
issues.append(f"mode differs: {a['mode']} vs {b['mode']}")
if abs(a["ac_output_hz"] - b["ac_output_hz"]) > 0.1 and a["ac_output_hz"] > 0 and b["ac_output_hz"] > 0:
issues.append(f"AC output frequency diverges: {a['ac_output_hz']}Hz vs {b['ac_output_hz']}Hz (>0.1Hz = not phase-locked)")
if abs(a["ac_output_v"] - b["ac_output_v"]) > 2.0 and a["ac_output_v"] > 0 and b["ac_output_v"] > 0:
issues.append(f"AC output voltage diverges: {a['ac_output_v']}V vs {b['ac_output_v']}V (>2V gap)")
if a["ac_output_v"] == 0 and b["ac_output_v"] == 0:
issues.append("both units AC output = 0 V (idle / not producing); frequency/voltage sync cannot be verified until at least one is inverting")
print()
if not issues:
print("SYNC OK")
return 0
print(f"{len(issues)} issue(s):")
for i in issues:
print(f" - {i}")
return 1
async def cmd_compare(args) -> int:
async def _snapshot(path: str) -> dict:
port = await _open_port(path)
try:
piri = await _read_piri(port)
finally:
await port.disconnect()
return _dump_profile(piri["_parts"])
path_a = await _resolve_path(args.device_a, args.serial_a)
path_b = await _resolve_path(args.device_b, args.serial_b)
a = await _snapshot(path_a)
b = await _snapshot(path_b)
shared = sorted(set(a) & set(b))
diffs: list[tuple[str, Any, Any]] = []
for k in shared:
av, bv = a[k], b[k]
if isinstance(av, float) or isinstance(bv, float):
if abs(float(av) - float(bv)) > 0.05:
diffs.append((k, av, bv))
elif av != bv:
diffs.append((k, av, bv))
a_only = sorted(set(a) - set(b))
b_only = sorted(set(b) - set(a))
if not diffs and not a_only and not b_only:
print(f"MATCH — {len(shared)} settings identical on {path_a} and {path_b}")
return 0
if diffs:
kw = max(len(k) for k, _, _ in diffs)
print(f"{len(diffs)} setting(s) differ (of {len(shared)} shared):")
for k, av, bv in diffs:
print(f" {k:<{kw}} {path_a}={av!r} {path_b}={bv!r}")
if a_only:
print(f"only on {path_a}: {', '.join(a_only)}")
if b_only:
print(f"only on {path_b}: {', '.join(b_only)}")
return 1
def main() -> int:
ap = argparse.ArgumentParser(description="Flash LVX6048 settings profiles via PI18.")
sub = ap.add_subparsers(dest="cmd", required=True)
def _add_common(p):
# --device uses the resolver-maintained symlink by default. --serial is
# an explicit fallback that probes /dev/hidraw* via PI18 ID — only use
# when the resolver hasn't run (e.g. early boot / debugging).
p.add_argument("--device", default="/dev/lvx6048-1",
help="device path (default: /dev/lvx6048-1 symlink)")
p.add_argument("--serial", default=None,
help="override --device by probing /dev/hidraw* for this PI18 serial")
d = sub.add_parser("dump", help="read current settings into a YAML profile")
_add_common(d)
d.add_argument("--out", required=True)
df = sub.add_parser("diff", help="show what would change if a profile were applied")
_add_common(df)
df.add_argument("--profile", required=True)
ap_ = sub.add_parser("apply", help="apply a profile (requires --confirm)")
_add_common(ap_)
ap_.add_argument("--profile", required=True)
ap_.add_argument("--confirm", action="store_true")
def _add_pair(p):
p.add_argument("--device-a", default="/dev/lvx6048-1", help="device path for unit A")
p.add_argument("--device-b", default="/dev/lvx6048-2", help="device path for unit B")
p.add_argument("--serial-a", default=None, help="override --device-a by probing PI18 serial")
p.add_argument("--serial-b", default=None, help="override --device-b by probing PI18 serial")
cp = sub.add_parser("compare", help="diff live settings between two inverters")
_add_pair(cp)
sc = sub.add_parser("sync-check", help="verify two paralleled inverters are in sync")
_add_pair(sc)
args = ap.parse_args()
handler = {
"dump": cmd_dump, "diff": cmd_diff, "apply": cmd_apply,
"compare": cmd_compare, "sync-check": cmd_sync_check,
}[args.cmd]
return asyncio.run(handler(args))
if __name__ == "__main__":
sys.exit(main())

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash(strings -n 4 \"51.2V 100Ah LP4V2 Auto-Addressing RS485 Z03T21 Firmware.bin\")",
"Read(//tmp/**)",
"WebSearch",
"WebFetch(domain:eg4electronics.com)",
"WebFetch(domain:diysolarforum.com)",
"WebFetch(domain:github.com)",
"WebFetch(domain:raw.githubusercontent.com)"
]
}
}

134
battery/eg4_lifepower.py Normal file
View File

@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Standalone EG4 LifePower4 (V1/V2) decoder over USB-RS485.
Adapted from Louisvdw/dbus-serialbattery (bms/lifepower.py) stripped of its
Victron dbus dependencies. Pure pyserial + stdlib.
Frame format (observed on wire, not standard Modbus):
request : 7E <addr> <cmd> 00 <chk> 0D (6 bytes)
reply : 7E ... 0D (variable, status payload
contains 10 groups of
16-bit unsigned ints)
Verified commands: general status (01), firmware ver (33), hardware ver (42).
Address byte is typically 0x01 for a single battery; multipack setups have the
master at 0x01 and only the master answers external RS485.
Usage:
python3 eg4_lifepower.py # auto-detect port
python3 eg4_lifepower.py /dev/tty.usbserial-XXX # explicit port
"""
from __future__ import annotations
import glob
import sys
import time
from dataclasses import dataclass, field
from struct import unpack_from
import serial # pip install pyserial
BAUD = 9600
CMD_GENERAL = bytes([0x7E, 0x01, 0x01, 0x00, 0xFE, 0x0D])
CMD_HW_VER = bytes([0x7E, 0x01, 0x42, 0x00, 0xFC, 0x0D])
CMD_FW_VER = bytes([0x7E, 0x01, 0x33, 0x00, 0xFE, 0x0D])
@dataclass
class Status:
cell_voltages: list[float] = field(default_factory=list) # volts
current: float = 0.0 # amps (signed, +charge)
soc: float = 0.0 # percent
capacity_ah: float = 0.0
temps_c: list[int] = field(default_factory=list) # up to 6
cycles: int = 0
pack_voltage: float = 0.0
alarms: dict = field(default_factory=dict)
def summary(self) -> str:
cells = self.cell_voltages
cmin, cmax = (min(cells), max(cells)) if cells else (0, 0)
return (
f"pack={self.pack_voltage:6.2f}V I={self.current:+7.2f}A "
f"SoC={self.soc:5.1f}% cap={self.capacity_ah:6.2f}Ah "
f"cells={len(cells)} ({cmin:.3f}..{cmax:.3f}V Δ={cmax-cmin:.3f}) "
f"temps={self.temps_c} cycles={self.cycles} alarms={self.alarms}"
)
def send(port: serial.Serial, cmd: bytes, read_n: int = 256) -> bytes:
port.reset_input_buffer()
port.write(cmd)
time.sleep(0.2)
return port.read(read_n)
def parse_status(data: bytes) -> Status:
"""Parse the general-status reply. Raises ValueError on bad framing."""
if not data or data[0] != 0x7E or data[-1] != 0x0D:
raise ValueError(f"bad framing: {data.hex(' ')}")
groups: list[list[int]] = []
i = 4 # skip 7E <addr> <cmd> <len> — payload starts at byte 4
for _ in range(10):
if i + 2 > len(data):
raise ValueError("truncated payload")
group_len = data[i + 1]
end = i + 2 + (group_len * 2)
payload = data[i + 2 : end]
values = [unpack_from(">H", payload, k)[0] for k in range(0, len(payload), 2)]
groups.append(values)
i = end
s = Status()
s.cell_voltages = [(v & 0x7FFF) / 1000 for v in groups[0]]
s.current = (30000 - groups[1][0]) / 100
s.soc = groups[2][0] / 100
s.capacity_ah = groups[3][0] / 100
s.temps_c = [(t & 0xFF) - 50 for t in groups[4][:6]]
flags = groups[5][1] if len(groups[5]) > 1 else 0
s.alarms = {
"current_over": bool(flags & 0b00001000),
"voltage_high": bool(flags & 0b00010000),
"voltage_low": bool(flags & 0b00100000),
"temp_high_chg": bool(flags & 0b01000000),
"temp_low_chg": bool(flags & 0b10000000),
}
s.cycles = groups[6][0]
s.pack_voltage = groups[7][0] / 100
return s
def decode_ascii(data: bytes) -> str:
return data.decode("ascii", errors="ignore").strip()
def autodetect() -> str | None:
for pat in ("/dev/tty.usbserial*", "/dev/tty.usbmodem*", "/dev/ttyUSB*", "/dev/ttyACM*"):
hits = glob.glob(pat)
if hits:
return hits[0]
return None
def main() -> None:
port_path = sys.argv[1] if len(sys.argv) > 1 else autodetect()
if not port_path:
sys.exit("no serial port found; pass one explicitly")
print(f"opening {port_path} @ {BAUD} 8N1")
with serial.Serial(port_path, BAUD, bytesize=8, parity="N", stopbits=1, timeout=1.5) as p:
hw = send(p, CMD_HW_VER)
fw = send(p, CMD_FW_VER)
if hw:
print(f"hw: {decode_ascii(hw)}")
if fw:
print(f"fw: {decode_ascii(fw)}")
raw = send(p, CMD_GENERAL)
print(f"raw ({len(raw)}B): {raw.hex(' ')}")
if raw:
print(parse_status(raw).summary())
if __name__ == "__main__":
main()

64
battery/probe.py Normal file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Sanity-check byte-level probe for EG4 LifePower4 V2 over USB-RS485.
Sends the 'general status' frame and prints the raw reply. If you see bytes
starting with 0x7E coming back, the protocol is confirmed and eg4_lifepower.py
will decode them.
Usage:
python3 probe.py # auto-detect likely USB-serial port
python3 probe.py /dev/tty.usbserial-XXX # explicit port
python3 probe.py COM5 # Windows
"""
import sys
import time
import glob
import serial # pip install pyserial
BAUD = 9600
CMDS = {
"general": bytes.fromhex("7E01010000FE0D"[:-2] + "0D"), # 7E 01 01 00 FE 0D
"hw_ver": bytes.fromhex("7E01420000FC0D"[:-2] + "0D"), # 7E 01 42 00 FC 0D
"fw_ver": bytes.fromhex("7E01330000FE0D"[:-2] + "0D"), # 7E 01 33 00 FE 0D
}
# (the [:-2]+"0D" dance above is just to keep the literal aligned with the
# canonical 6-byte frames documented upstream; simpler form below)
CMDS = {
"general": bytes([0x7E, 0x01, 0x01, 0x00, 0xFE, 0x0D]),
"hw_ver": bytes([0x7E, 0x01, 0x42, 0x00, 0xFC, 0x0D]),
"fw_ver": bytes([0x7E, 0x01, 0x33, 0x00, 0xFE, 0x0D]),
}
def autodetect() -> str | None:
candidates = (
glob.glob("/dev/tty.usbserial*") # macOS FTDI/CH340
+ glob.glob("/dev/tty.usbmodem*") # macOS CDC-ACM
+ glob.glob("/dev/ttyUSB*") # Linux FTDI/CH340
+ glob.glob("/dev/ttyACM*") # Linux CDC-ACM
)
return candidates[0] if candidates else None
def probe(port: str) -> None:
print(f"opening {port} @ {BAUD} 8N1")
with serial.Serial(port, BAUD, bytesize=8, parity="N", stopbits=1, timeout=1.5) as s:
for name, cmd in CMDS.items():
s.reset_input_buffer()
s.write(cmd)
time.sleep(0.2)
reply = s.read(256)
print(f"\n[{name}] sent {cmd.hex(' ')}")
if reply:
print(f" got ({len(reply)} bytes) {reply.hex(' ')}")
if reply[0] == 0x7E and reply[-1] == 0x0D:
print(" -> frame looks valid (7E ... 0D)")
else:
print(" got (nothing — timeout)")
if __name__ == "__main__":
port = sys.argv[1] if len(sys.argv) > 1 else autodetect()
if not port:
sys.exit("no serial port found; pass one explicitly: python3 probe.py /dev/tty.usbserial-XXXX")
probe(port)

1
battery/requirements.txt Normal file
View File

@@ -0,0 +1 @@
pyserial>=3.5

208
battery/sweep.py Normal file
View File

@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""Address sweep + protocol probe for EG4 LifePower4 over USB-RS485.
Tries three candidate protocols across a range of unit addresses:
1. Legacy 7E/0D binary framing — addresses 0x00..0x10
2. PACE BMS V25 (ASCII-hex inside 7E/0D) — addresses 0x01..0x10
Used by Pylontech, Solark, Deye, Lux, Growatt, MegaRev, etc.
3. Standard Modbus RTU (fn 0x03) — addresses 1..16, reads 39 holding regs
Any non-empty reply is printed raw. First byte tells you which protocol:
- 7E + ASCII-hex payload = PACE V25
- 7E + binary payload = legacy
- matching addr byte + 0x03 = Modbus
Usage:
python3 sweep.py # auto-detect port, 9600
python3 sweep.py /dev/ttyUSB0 # explicit port
python3 sweep.py /dev/ttyUSB0 19200 # explicit port + baud
"""
import glob
import sys
import time
import serial # pip install pyserial
DEFAULT_BAUD = 9600
def autodetect() -> str | None:
for pat in ("/dev/tty.usbserial*", "/dev/tty.usbmodem*", "/dev/ttyUSB*", "/dev/ttyACM*"):
hits = glob.glob(pat)
if hits:
return hits[0]
return None
def frame_7e(addr: int, cmd: int = 0x01, length: int = 0x00) -> bytes:
chk = (0x100 - (addr + cmd + length)) & 0xFF
return bytes([0x7E, addr, cmd, length, chk, 0x0D])
def crc16_modbus(data: bytes) -> int:
crc = 0xFFFF
for b in data:
crc ^= b
for _ in range(8):
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
return crc
def frame_modbus(addr: int, func: int = 0x03, start: int = 0x0000, count: int = 39) -> bytes:
body = bytes([addr, func, start >> 8, start & 0xFF, count >> 8, count & 0xFF])
crc = crc16_modbus(body)
return body + bytes([crc & 0xFF, crc >> 8]) # Modbus CRC is LSB-first on wire
def _pace_lenid(n: int) -> int:
"""V25 LENID = (lchecksum << 12) | (n & 0x0FFF); lchecksum per PACE spec."""
s = ((n >> 8) & 0x0F) + ((n >> 4) & 0x0F) + (n & 0x0F)
lchk = ((~s) + 1) & 0x0F
return (lchk << 12) | (n & 0x0FFF)
def frame_pace(addr: int, ver: int = 0x20, cid1: int = 0x4A, cid2: int = 0x42, info: bytes = b"") -> bytes:
"""Build a PACE BMS request frame (ASCII-hex inside ~…\\r).
Defaults reproduce the EG4 LifePower4 V20 read-analog frame verified against
nkinnan/esphome-pace-bms: ver=0x20 (V20), cid1=0x4A (EG4/Narada family),
cid2=0x42 (read analog), empty INFO payload (EG4 variant — busId is *not*
repeated in the payload, unlike generic PACE). Known-good at addr 1:
~20014A420000FDA2\\r
"""
info_ascii = info.hex().upper().encode()
lenid = _pace_lenid(len(info_ascii))
body = f"{ver:02X}{addr:02X}{cid1:02X}{cid2:02X}{lenid:04X}".encode() + info_ascii
chksum = ((-sum(body)) & 0xFFFF)
return b"~" + body + f"{chksum:04X}".encode() + b"\r"
def exchange(port: serial.Serial, tx: bytes, wait: float = 0.25, read_n: int = 256) -> bytes:
port.reset_input_buffer()
port.write(tx)
time.sleep(wait)
return port.read(read_n)
def classify_7e(rx: bytes) -> str:
if not rx:
return ""
if rx[0] == 0x7E and rx.endswith(b"\x0D"):
return " <- 7E framed reply"
return " <- unexpected bytes"
def classify_pace(rx: bytes) -> str:
if not rx:
return ""
if rx[0] == 0x7E and rx.endswith(b"\r"):
# V25 payload is ASCII-hex between ~ and \r
mid = rx[1:-1]
if mid.isascii() and all(c in b"0123456789ABCDEFabcdef" for c in mid):
return " <- PACE V25 reply"
return " <- 7E-framed but non-ASCII (likely legacy, not V25)"
return " <- unexpected bytes"
def classify_modbus(rx: bytes, addr: int) -> str:
if not rx:
return ""
if len(rx) >= 3 and rx[0] == addr and rx[1] == 0x03:
return " <- Modbus reply"
if len(rx) >= 3 and rx[0] == addr and rx[1] == 0x83:
return f" <- Modbus exception (code {rx[2]:#04x})"
return " <- unexpected bytes"
COMMON_BAUDS = (9600, 19200, 38400, 57600, 115200)
def passive_listen(port_path: str, seconds: int = 15) -> None:
"""Open at each baud for N seconds and dump whatever the pack transmits
unsolicited. If the pack is in broadcast mode (Pylontech CAN-style,
Victron, some Growatt modes), we'll see frames arrive without asking.
"""
print(f"passive listen on {port_path}{seconds}s per baud")
for baud in COMMON_BAUDS:
try:
with serial.Serial(port_path, baud, bytesize=8, parity="N", stopbits=1, timeout=0.25) as p:
p.reset_input_buffer()
deadline = time.monotonic() + seconds
buf = bytearray()
while time.monotonic() < deadline:
chunk = p.read(256)
if chunk:
buf.extend(chunk)
ascii_preview = buf.decode("ascii", errors="replace") if buf else ""
tag = " <- traffic!" if buf else ""
print(f" @ {baud:6d} 8N1: {len(buf):4d}B{tag}")
if buf:
print(f" hex: {buf.hex(' ')}")
print(f" ascii: {ascii_preview!r}")
except serial.SerialException as e:
print(f" @ {baud}: {e}")
def quick_baud_scan(port_path: str) -> None:
"""At addr 0x01, send one legacy + one V25 + one Modbus frame at each baud.
Any non-empty reply means we've found the right baud + a protocol that the
pack is willing to answer — then rerun the full sweep at that baud.
"""
print(f"opening {port_path} — scanning baud rates {COMMON_BAUDS}")
for baud in COMMON_BAUDS:
try:
with serial.Serial(port_path, baud, bytesize=8, parity="N", stopbits=1, timeout=2.0) as p:
print(f"\n @ {baud} 8N1:")
for label, tx in (
("legacy", frame_7e(0x01)),
("pace", frame_pace(0x01)),
("modbus", frame_modbus(0x01)),
):
rx = exchange(p, tx, wait=0.3)
tag = ""
if rx:
tag = " <- REPLY!"
print(f" {label:7s}: tx {tx.hex(' ')} rx ({len(rx):3d}B) {rx.hex(' ')}{tag}")
except serial.SerialException as e:
print(f" @ {baud}: {e}")
def sweep(port_path: str, baud: int) -> None:
print(f"opening {port_path} @ {baud} 8N1 (2s timeout)")
with serial.Serial(port_path, baud, bytesize=8, parity="N", stopbits=1, timeout=2.0) as p:
print("\n[7E protocol — general-status sweep]")
for addr in range(0x00, 0x11):
tx = frame_7e(addr)
rx = exchange(p, tx)
print(f" addr 0x{addr:02X}: tx {tx.hex(' ')} rx ({len(rx):3d}B) {rx.hex(' ')}{classify_7e(rx)}")
print("\n[PACE V20 (EG4/Narada variant) — read-analog-data sweep]")
for addr in list(range(0x01, 0x11)) + [0xFF]: # include broadcast 0xFF
tx = frame_pace(addr)
rx = exchange(p, tx, wait=0.35)
print(f" addr 0x{addr:02X}: tx {tx.decode('ascii', errors='replace').strip()!r} rx ({len(rx):3d}B) {rx.hex(' ')}{classify_pace(rx)}")
print("\n[Modbus RTU — read 39 holding regs sweep]")
for addr in range(1, 17):
tx = frame_modbus(addr)
rx = exchange(p, tx)
print(f" addr 0x{addr:02X}: tx {tx.hex(' ')} rx ({len(rx):3d}B) {rx.hex(' ')}{classify_modbus(rx, addr)}")
if __name__ == "__main__":
args = sys.argv[1:]
scan_only = "--scan" in args
listen_only = "--listen" in args
args = [a for a in args if a not in ("--scan", "--listen")]
port = args[0] if args else autodetect()
if not port:
sys.exit("no serial port found; pass one explicitly")
if listen_only:
passive_listen(port)
sys.exit(0)
if scan_only or len(args) < 2:
quick_baud_scan(port)
if scan_only:
sys.exit(0)
baud = int(args[1]) if len(args) > 1 else DEFAULT_BAUD
sweep(port, baud)

157
eg4battery/Install.md Normal file
View File

@@ -0,0 +1,157 @@
# EG4 LifePower4 v2 → HA Monitoring Install
Target: Debian-family Linux (developed on Raspberry Pi CM5), one USB-to-RS-485
adapter per pack's **RS485** socket, HA MQTT broker on the LAN.
> **Shortcut:** [`install.sh`](./install.sh) automates §3§7 and supports
> `--dry-run` for a no-hardware smoke test. This doc explains what it does
> and how to do it by hand.
Path conventions: `$BASE` = root of this package (e.g. `~/solar/eg4battery`).
## 1. Prerequisites
- `uv` on `$PATH` ([docs](https://docs.astral.sh/uv/))
- `sudo`
- **One USB-to-RS-485 adapter per pack**. FTDI-based is what we've tested
(FT232R + MAX485 combo, identified by the Linux kernel as `FT232R USB UART`
with a unique serial-number suffix). CH340 / CP210x also fine — adjust
the udev rule's vendor/product ID.
## 2. Cabling — read this before wiring
LP4V2 back panel has four RJ45 sockets: `CAN`, `RS485`, `Comm1`, `Comm2`.
Only `RS485` is relevant for our daemon:
| Socket | Use for monitoring? |
|--------|---------------------------------------------------------------------|
| CAN | No — separate bus, CAN signaling, for inverter BMS comms |
| RS485 | **Yes.** External monitor port. Pin 1-2 = B/A. Modbus RTU at 9600. |
| Comm1 | No — inter-pack hub bus (19200 Modbus). Leave for pack daisy-chain. |
| Comm2 | No — same internal bus as Comm1. |
The stock EG4 USB-RS-485 cable (included with each pack) is already wired
correctly for the RS485 socket (pins 1-2 / A-B).
**Topology**: each pack gets its own adapter plugged into its **RS485** socket.
No daisy chain required for monitoring — each pack is a dedicated bus. The
Comm1↔Comm2 daisy chain between packs is separate and carries the inter-pack
hub bus (not our concern).
## 3. udev rule
Grants `dialout` group access to FTDI USB-serial adapters.
```bash
sudo install -m 644 "$BASE/etc/udev/rules.d/99-eg4-rs485.rules" \
/etc/udev/rules.d/99-eg4-rs485.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --subsystem-match=tty
ls -l /dev/serial/by-id/ # expect one symlink per adapter
```
## 4. Daemon binary
```bash
sudo install -m 755 "$BASE/bin/eg4-battery" /usr/local/bin/eg4-battery
```
uv handles deps; no venv work on your side.
## 5. Config
Canonical template: [`config/eg4-battery.yaml.example`](./config/eg4-battery.yaml.example).
```bash
mkdir -p ~/.config/eg4-battery
install -m 600 "$BASE/config/eg4-battery.yaml.example" \
~/.config/eg4-battery/eg4-battery.yaml
# Edit — see "mode selection" below
```
### 5a. mode selection
- **`modbus_per_pack`** (default / recommended). Each pack listed with its own
`port:`, `address:` and `baud:` — the daemon opens one serial port per pack
and polls each independently.
```yaml
bus:
mode: modbus_per_pack
timeout_s: 1.0
poll_interval_s: 10.0
packs:
- name: lifepower4_1
address: 0x40
port: /dev/serial/by-id/usb-FTDI_FT232R_USB_UART_<ID1>-if00-port0
baud: 9600
- name: lifepower4_2
address: 0x40
port: /dev/serial/by-id/usb-FTDI_FT232R_USB_UART_<ID2>-if00-port0
baud: 9600
```
- **`active`** (legacy, V1 hardware only) — single shared bus, EG4 7E/0D
protocol at 9600. Not used on V2 Auto-Addressing hardware.
- **`passive`** (diagnostic) — listen-only Modbus sniff at 19200. See
[`NOTES.md`](./NOTES.md) "Modes" for details.
### 5b. MQTT creds
Replace `<MQTT_BROKER_IP>`, `<MQTT_USER>`, `<MQTT_PASSWORD>`. `install.sh`
will not auto-start the service while those placeholders remain.
## 6. Smoke test without hardware
```bash
eg4-battery -C ~/.config/eg4-battery/eg4-battery.yaml --dry-run
```
Mock transport, one cycle per pack, prints every discovery-config and
state-topic / payload to stdout. Confirms the pipeline end-to-end before
hardware is involved.
## 7. systemd
```bash
sudo install -m 644 "$BASE/etc/systemd/system/eg4-battery.service" \
/etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now eg4-battery.service
journalctl -u eg4-battery.service -f
```
Service includes `Environment=PATH=…` so uv is found under systemd.
## 8. Bring up additional packs
When a new pack comes online:
1. Plug its adapter into the pack's **RS485** socket and power the pack.
2. `ls -l /dev/serial/by-id/` — note the new symlink.
3. Add / update an entry in `~/.config/eg4-battery/eg4-battery.yaml`:
```yaml
- name: lifepower4_N
address: 0x40
port: /dev/serial/by-id/usb-FTDI_FT232R_USB_UART_<ID>-if00-port0
baud: 9600
```
4. `sudo systemctl restart eg4-battery.service`. The journal shows
`pack lifepower4_N: recovered after N failed cycle(s)` within ~10 s when
the pack starts responding, and HA auto-discovers ~65 entities.
## 9. Verify MQTT flow
```bash
# modbus_per_pack: all named + raw register entities per pack
mosquitto_sub -h <broker> -u mqtt -P <pass> \
-t 'homeassistant/sensor/lifepower4_+_pack_voltage/state' \
-t 'homeassistant/sensor/lifepower4_+_soc/state' \
-t 'homeassistant/sensor/lifepower4_+_cell_voltage_delta_mv/state' \
-v
```
## 10. Swapping modes later
Change `bus.mode` in the config, restart the service. Config reshape varies
per mode — see §5a. No binary redeploy needed.

160
eg4battery/NOTES.md Normal file
View File

@@ -0,0 +1,160 @@
# EG4 LifePower4 v2 — architecture & protocol notes
## Modes — choose per deployment
`bus.mode` in the config picks one of three daemon modes:
| Mode | Wire protocol | Baud | Role | Status |
|------------------|----------------------|-------|----------------|------------------------------------------------------------|
| `modbus_per_pack`| Modbus RTU, fn 0x03 | 9600 | master (per pack)| **Primary path for LP4V2 Auto-Addressing.** One FTDI per pack's RS485 port, polls once per cycle, fully decodes into named HA entities. |
| `active` | EG4 7E/0D legacy | 9600 | master (shared) | **Legacy.** V1 firmware only. V2 packs don't respond to this protocol — kept for reference. |
| `passive` | Modbus RTU sniff | 19200 | listener | **Diagnostic.** Originally intended to listen on an LVX6048 BMS bus; the LVX6048 doesn't poll EG4 packs that way, so has no production use here. |
## How we got here (summary)
1. **Port matrix test** (2026-04-24). Established that the LP4V2 back panel's four RJ45s (CAN / RS485 / Comm1 / Comm2) carry three distinct electrical buses:
- Comm1 + Comm2 = inter-pack hub bus (19200 Modbus, master-to-slave coordination). Pin 1-2 and pin 7-8 both tap the same bus.
- RS485 = external monitor bus. Inactive until an external master drives it.
- CAN = separate bus, not in scope.
2. **EG4 BMS Tool capture.** User confirmed the stock Windows/macOS BMS Tool connects to the RS485 port at 9600 baud, Modbus slave ID **0x40**. Our first canonical Modbus probe at that address returned a clean 99-byte reply.
3. **`lv_host.app` reverse-engineering.** The BMS Tool is a Qt app. Its Mach-O binary contains:
- SQLite schema for the `total` table → complete list of 39 fields the BMS Tool stores per cycle.
- String-table names for warning / protection bit flags.
- C++ symbols `BmsDatalog::allFunctionModbusAnalysis`, `BmsMonitoring::allFunctionModbusAnalysis`, `usModbusAskRegBase` etc. → confirms Modbus RTU fn 0x03 read-holding-regs.
4. **Register map construction.** Correlated live register values (cell voltages, pack V) against the SQL field list to derive the 47-reg map below. High-confidence fields promoted to named HA entities; unknowns still emitted as `register_NN` for future correlation.
## Register map (Modbus fn 0x03, start 0x0000, count 47)
From live observation + lv_host.app schema:
| Reg | Observed | Field | Scale | Unit | HA entity suffix |
|-------|--------------|---------------------|---------|------|----------------------|
| 00 | 5256 | Total_Voltage | × 0.01 | V | `pack_voltage` |
| 01 | 0 (signed) | Current_I | × 0.01 | A | `pack_current` |
| 02-17 | ~3285 each | Vol_Cell01..16 | × 0.001 | V | `cell_01_voltage`..`cell_16_voltage` + `cell_voltage_min/max/delta_mv/lowest/highest` |
| 18 | 21 | Temp_01 | × 1 | °C | `temperature_01` |
| 19 | 21 | Temp_02 | × 1 | °C | `temperature_02` |
| 20 | 20 | Temp_03 | × 1 | °C | `temperature_03` |
| 21 | 54 | Temp_04 | × 1 | °C | `temperature_04` |
| 22 | 100 | SOC | × 1 | % | `soc` |
| 23 | 100 | SOH | × 1 | % | `soh` |
| 24 | 55 | Temp_PCB | × 1 | °C | `temperature_pcb` |
| 25-29 | 0 | reserved | | | (register_NN only) |
| 30 | 1 | Heater (bit 0) | | | `heater` (on/off) |
| 31 | 5493 | MAX_Curren | × 0.01 | A | `max_current_limit` |
| 32 | 10752 | *?* | | | (register_32) |
| 33 | ? | Warning bitfield | | | `warning_*` (14 bits)|
| 34 | ? | Protection bitfield | | | `protection_*` (14 bits)|
| 35 | 0 | Error_Code | | | `error_code` |
| 36 | 16 | Cell_Num | | | `cell_count` |
| 37 | 1000 | Capacity | × 0.1 | Ah | `capacity_ah` |
| 38 | 0 | Remaining | × 0.01 | Ah | `remaining_ah` |
| 39 | 0 | CycleNum | | | `cycle_count` |
| 40 | 7 | Battery_Mode (enum) | | | `battery_mode` |
| 41 | 0x0fff | BMS_Version (hi) | | | `bms_version_hi` |
| 42 | 0x07ff | BMS_Version (lo) | | | `bms_version_lo` |
| 43-45 | — | *?* | | | (register_NN only) |
| 46 | +1.25 Hz | runtime counter | × 0.1 s?| | `uptime_ds` |
**Confidence levels**: Bold-worthy certain (confirmed by live values + UI labels): pack_voltage, cells 01-16, SOC, SOH, cell_count, capacity_ah. Medium (fits data, unverified): temps, current, bitfields, Battery_Mode. Unknown: regs 32, 35 (probably Error_Code but value always 0 so far), 38-40, 43-45.
## Warning / protection bit maps
From the UI labels in lv_host.app (bit 0 = first listed):
| Bit | Warning (reg 33) | Protection (reg 34) |
|-----|-------------------|---------------------|
| 0 | pack_ov | pack_ov |
| 1 | cell_ov | cell_ov |
| 2 | pack_uv | pack_uv |
| 3 | cell_uv | cell_uv |
| 4 | charge_oc | charge_oc |
| 5 | discharge_oc | discharge_oc |
| 6 | temp_anomaly | temp_anomaly |
| 7 | mos_ot | mos_ot |
| 8 | charge_ot | charge_ot |
| 9 | discharge_ot | discharge_ot |
| 10 | charge_ut | charge_ut |
| 11 | discharge_ut | discharge_ut |
| 12 | low_capacity | float_stopped |
| 13 | other_error | discharge_sc |
Each bit becomes an HA sensor reporting `on` / `off`. Exact bit ordering is guessed from UI display order — adjust if the EG4 tool ever shows a flag we don't match.
## Hardware topology notes
### Critical: RS485 port only works when the pack is standalone
**Empirical finding** (2026-04-24): the LP4V2's external `RS485` port
only answers Modbus queries when the pack is **not** daisy-chained to
other packs via `Comm1`/`Comm2`. Specifically:
- With daisy chains intact (bat1 Comm2 → bat2 Comm1 etc.), one pack
elects as master and polls slaves over the internal hub bus
(19200 Modbus on Comm1/Comm2). Slave packs' RS485 ports go silent —
only the master responds externally.
- Remove the inter-pack Comm jumpers and each pack becomes a
self-contained master: its Comm1/Comm2 LEDs flash (it's trying to
poll slaves that aren't there), and its own RS485 port becomes fully
live for external queries.
**Implication**: `modbus_per_pack` mode requires **each pack standalone**
— one FTDI adapter per pack's RS485 port, no inter-pack Comm jumpers.
This is how we got bat1 responding cleanly. If the batteries later
need to be daisy-chained to an inverter, only the master pack's RS485
port will answer external queries; slave per-pack data would need to
come from decoding the Comm1/Comm2 hub bus instead (a future mode).
### Port roles on the LP4V2 pack (from the port matrix test)
| Port | Role | Pins carrying signal | Protocol / baud |
|-------|--------------------------------|-------------------------|-----------------------|
| CAN | Inverter CAN comms | (CAN-specific pinout) | CANbus (not RS-485) |
| RS485 | External monitor | 1-2 | Modbus RTU @ 9600 |
| Comm1 | Inter-pack hub bus (in/out) | 1-2 and 7-8 both tap it | Modbus RTU @ 19200 |
| Comm2 | Inter-pack hub bus (in/out) | 1-2 and 7-8 both tap it | Modbus RTU @ 19200 |
- The **stock USB-RS485 cable** ships wired to pins 1-2 — usable on either Comm or RS485.
- The **pin 7-8 modified cable** only gains us the Comm/Comm2 hub-bus tap; since pins 1-2 reach the same bus, it's not strictly necessary. Kept in the toolkit for diagnostic purposes.
- Factory inter-pack jumpers (between packs) are 8-conductor CAT5 — they carry both pin pairs.
### Adapters
On this host, three USB-FTDI adapters are plugged into the three packs' RS485 ports:
| Adapter ID | Pack | `/dev/serial/by-id/...` |
|------------------|----------------|--------------------------------------------------------|
| A994XMVK | bat1 (RS485) | `usb-FTDI_FT232R_USB_UART_A994XMVK-if00-port0` |
| A994XGUY | bat2 (RS485) | `usb-FTDI_FT232R_USB_UART_A994XGUY-if00-port0` |
| A994XMBR | bat3 (RS485) | `usb-FTDI_FT232R_USB_UART_A994XMBR-if00-port0` |
Each pack gets polled on its own bus → no shared-bus arbitration, no master/slave coordination needed, pack Modbus address is 0x40 for all of them.
## LVX6048 compatibility (still true)
LVX6048 BMS port protocols: `PYL` (Pylontech), `LIb` (MPP LIO), `WEC` (WECO), `SOL` (Soltaro), `VSC` (Pylontech-CAN), `USE` (voltage-only). **No native EG4 LP4V2 support.** For inverter↔battery comms, set `P05/P14 = USE` and manage charge profile via `lvx-flash`. See DIY Solar Forum threads 67496 & 96019, LVX6048WP manual §9-2.
## Bring-up checklist (when a new pack goes live)
1. Wire: plug USB-FTDI adapter (stock pin-1-2 cable) into the pack's **RS485** port.
2. Confirm the pack's BMS is powered (LEDs steady on Comm1 + Comm2, not dark).
3. Verify the `/dev/serial/by-id/...` symlink exists for the adapter.
4. Add a pack entry to the config:
```yaml
packs:
- name: lifepower4_N
address: 0x40
port: /dev/serial/by-id/usb-FTDI_...-if00-port0
baud: 9600
```
5. `sudo systemctl restart eg4-battery.service`. Watch journal — within ~10 s you should see the first MQTT publish, or `WARNING: no/bad response` if the pack isn't answering.
6. In HA: `EG4 LifePower4 lifepower4_N` device appears with ~65 auto-discovered entities.
## Sources / references
- `lv_host.app` (Qt) — Contents/MacOS/lv_host Mach-O binary + my1.db/my2.db SQLite schemas
- `../battery/eg4_lifepower.py` — V1 7E/0D decoder (Louisvdw/dbus-serialbattery port), historical reference
- `../battery/sweep.py` — protocol + baud scanner used for initial triage
- EG4 "Cables Needed for Updating" PDF
- EG4 Community Forum: "Specs for LifePower4 V2 BAT-COM ports"
- LVX6048WP manual §9-2 (BMS pinout), §Programs P03/P05

95
eg4battery/README.md Normal file
View File

@@ -0,0 +1,95 @@
# EG4 LifePower4 v2 → Home Assistant
Daemon that polls EG4 LifePower4 48V 100Ah v2 (Auto-Addressing) packs over
RS-485 and publishes per-pack telemetry to MQTT with HA auto-discovery.
## Status: live
As of 2026-04-24, `bat1` is live via `modbus_per_pack` mode on its RS485 port,
reporting all ~65 entities into HA:
```
lifepower4_1_pack_voltage 52.56 V (16 cells × 3.285 V)
lifepower4_1_cell_01_voltage 3.285 V
lifepower4_1_cell_16_voltage 3.285 V
lifepower4_1_cell_voltage_delta_mv 2 (outstanding balance)
lifepower4_1_soc 100 %
lifepower4_1_capacity_ah 100.0 Ah
lifepower4_1_temperature_01 21 °C
lifepower4_1_temperature_pcb 55 °C
... plus 14 warning bits, 14 protection bits, all 47 raw registers
```
`bat2` and `bat3` are wired but unpowered — the daemon logs one warning per
unreachable pack per startup and keeps retrying silently. They'll come online
automatically when the user powers them up.
## Modes
Set by `bus.mode` in `~/.config/eg4-battery/eg4-battery.yaml`:
| Mode | When to use |
|-------------------|---------------------------------------------------------|
| `modbus_per_pack` | **Default.** One FTDI per pack's RS485 port. Fully decoded HA entities. |
| `active` | Legacy 7E/0D (V1 firmware only). Not used on V2 hardware. |
| `passive` | Listen-only Modbus sniff (19200). Diagnostic use. |
See [`NOTES.md`](./NOTES.md) for architecture, register map, LVX6048
compatibility findings, and bring-up checklist.
## What's in the box
```
eg4battery/
├── README.md ← start here
├── Install.md ← detailed walkthrough + mode-switch howto
├── NOTES.md ← architecture, register map, port matrix
├── install.sh ← idempotent installer (supports --dry-run)
├── bin/eg4-battery ← single-file daemon (uv PEP-723 inline deps)
├── config/eg4-battery.yaml.example ← template, multiple-pack config
├── etc/ mirror of target paths (Pi side)
│ ├── udev/rules.d/99-eg4-rs485.rules
│ └── systemd/system/eg4-battery.service
├── homeassistant/ ← drop into your HA config dir
│ ├── README.md (what goes where + retention tiers)
│ ├── recorder.yaml (exclude noisy / diagnostic entities)
│ ├── template_sensors.yaml (derived: power, temp_max, cell_imbalance, stack rollups)
│ └── lovelace_overview.yaml (3-pack stack dashboard)
└── tmp/ ad-hoc diagnostics
├── port-probe (single-cycle 9600/19200/7E probe)
├── eg4-snapshot (47-reg dump for BMS Tool cross-check)
└── bms-tool-ref/ (unpacked vendor BMS Tool for RE reference)
```
## Quick start on a fresh host
```bash
cd ~/solar/eg4battery
./install.sh --dry-run # mock cycle, prints MQTT payloads, exits
# Edit ~/.config/eg4-battery/eg4-battery.yaml:
# - For modbus_per_pack: one 'packs:' entry per pack, each with port + address + baud
# - mqtt.host / username / password
./install.sh # real deploy; auto-starts once creds are filled
journalctl -u eg4-battery.service -f
```
## Related packages
- [`../LVX6048/`](../LVX6048/) — inverter-side monitoring via PI18 over USB-HID.
Same MQTT broker. Between the two packages, HA sees every useful number from
the stack.
## Acknowledgements
- `battery/eg4_lifepower.py` — V1 protocol decoder adapted from
[`Louisvdw/dbus-serialbattery`](https://github.com/Louisvdw/dbus-serialbattery).
Historical; V2 firmware moved to Modbus on a different port.
- EG4 Electronics `lv_host.app` — the vendor BMS Tool; its Qt binary's SQLite
schema and strings gave us the register-to-field mapping.

969
eg4battery/bin/eg4-battery Executable file
View File

@@ -0,0 +1,969 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "pyserial>=3.5",
# "paho-mqtt>=2.0",
# "pyyaml>=6.0",
# ]
# ///
"""
eg4-battery — telemetry bridge from EG4 LifePower4 v2 BMSes to MQTT/HA.
Three modes, selected via `bus.mode` in the config:
modbus_per_pack — RECOMMENDED. One FTDI RS-485 adapter per pack. Each pack
has its own (port, address, baud) in the `packs:` list.
Uses Modbus RTU fn=0x03 read-47-regs at 0x0000. Decoder
extracts named fields (pack V, 16 cell voltages, temps,
SoC, SoH, Capacity, warnings, protections) — register
map reverse-engineered from the EG4 `lv_host.app` BMS
Tool's SQLite schema + UI labels.
active — LEGACY. Single FTDI adapter on a dedicated bus, EG4
7E/0D protocol at 9600 baud. Was ported from the V1
firmware via `battery/eg4_lifepower.py`; V2 hardware
doesn't speak this protocol in practice. Kept for
reference / possible V1 deployments.
passive — LEGACY. Listen-only Modbus-RTU sniffer at 19200 baud.
Originally targeted the LVX6048 BMS bus; LVX6048 doesn't
poll EG4 packs that way, so the mode is diagnostic only.
Usage:
eg4-battery -C <config.yaml>
eg4-battery -C <config.yaml> --dry-run # mock bus, print, exit
eg4-battery -C <config.yaml> --trace # log every frame
"""
from __future__ import annotations
import argparse
import asyncio
import dataclasses
import json
import logging
import random
import struct
import sys
import time
from pathlib import Path
from struct import unpack_from
from typing import Any, Iterator
import paho.mqtt.client as mqtt
import serial
import yaml
log = logging.getLogger("eg4-battery")
# =============================================================================
# === config ==================================================================
# =============================================================================
@dataclasses.dataclass
class PackConfig:
name: str # HA entity prefix / device identifier (e.g. "lifepower4_1")
address: int # protocol-level address (Modbus slave ID, or EG4 7E address)
port: str | None = None # per-pack port (modbus_per_pack mode only)
baud: int | None = None # per-pack baud override (modbus_per_pack mode only)
@dataclasses.dataclass
class BusConfig:
mode: str # "modbus_per_pack" | "active" | "passive"
transport: str = "serial" # "serial" | "mock"
port: str = "" # shared port (active / passive modes)
baud: int = 9600
read_chunk: int = 512
timeout_s: float = 1.5 # per-query timeout
poll_interval_s: float = 10.0 # full round-robin cycle target
@dataclasses.dataclass
class MQTTConfig:
host: str
port: int
username: str
password: str
discovery_prefix: str = "homeassistant"
@dataclasses.dataclass
class AppConfig:
bus: BusConfig
mqtt: MQTTConfig
packs: list[PackConfig]
cell_count: int = 16 # active mode only
def load_config(path: Path) -> AppConfig:
raw = yaml.safe_load(path.read_text())
return AppConfig(
bus=BusConfig(**raw["bus"]),
mqtt=MQTTConfig(**raw["mqtt"]),
packs=[PackConfig(**p) for p in raw["packs"]],
cell_count=raw.get("cell_count", 16),
)
# =============================================================================
# === active mode: EG4 7E/0D protocol =========================================
# =============================================================================
# Verified against `battery/eg4_lifepower.py`. Frame:
# request (6 bytes): 7E <addr> <cmd> 00 <chk> 0D
# chk = (0x100 - (addr + cmd + len)) & 0xFF
# reply (variable): 7E <addr> <cmd> <len> [10 groups] <chk> 0D
# each group: <type_byte> <count> <count × big-endian uint16>
CMD_GENERAL_STATUS = 0x01 # cells, V, I, SoC, cap, temps, cycles, alarms
CMD_FW_VER = 0x33
CMD_HW_VER = 0x42
def encode_eg4_request(address: int, cmd: int, length: int = 0) -> bytes:
chk = (0x100 - (address + cmd + length)) & 0xFF
return bytes([0x7E, address, cmd, length, chk, 0x0D])
def decode_eg4_general_status(data: bytes, cell_count: int) -> dict[str, Any]:
"""Decode a fn=0x01 reply into a flat dict keyed for HA. Mirrors
`battery/eg4_lifepower.py::parse_status`. Permissive framing check
(header/footer); upstream doesn't validate the reply CRC and neither
do we until we know the algorithm."""
if not data or len(data) < 6 or data[0] != 0x7E or data[-1] != 0x0D:
raise ValueError(f"bad framing: {data.hex(' ')[:120]}")
groups: list[list[int]] = []
i = 4 # skip 7E <addr> <cmd> <len>
for _ in range(10):
if i + 2 > len(data):
raise ValueError(f"truncated payload at group {len(groups)}")
group_len = data[i + 1]
end = i + 2 + group_len * 2
if end > len(data):
raise ValueError(f"group {len(groups)} overruns frame (end={end}, len={len(data)})")
payload = data[i + 2:end]
groups.append([unpack_from(">H", payload, k)[0] for k in range(0, len(payload), 2)])
i = end
out: dict[str, Any] = {}
# group 0 — cell voltages (mV; mask 0x7FFF per upstream — top bit is some flag)
cells = [(v & 0x7FFF) / 1000.0 for v in groups[0][:cell_count]]
for idx, cv in enumerate(cells, start=1):
out[f"cell_{idx:02d}_voltage"] = round(cv, 3)
if cells:
vmin, vmax = min(cells), max(cells)
out["cell_voltage_min"] = round(vmin, 3)
out["cell_voltage_max"] = round(vmax, 3)
out["cell_voltage_delta_mv"] = round((vmax - vmin) * 1000)
out["cell_lowest"] = cells.index(vmin) + 1
out["cell_highest"] = cells.index(vmax) + 1
# group 1 — current (signed; encoded as 30000 - A×100; positive = charge)
if groups[1]:
out["current"] = round((30000 - groups[1][0]) / 100.0, 2)
# group 2 — SoC × 100
if groups[2]:
out["soc"] = round(groups[2][0] / 100.0, 1)
# group 3 — capacity (Ah × 100)
if groups[3]:
out["capacity_ah"] = round(groups[3][0] / 100.0, 2)
# group 4 — temperatures (low byte 50 °C)
for idx, raw in enumerate(groups[4][:6], start=1):
out[f"temperature_{idx}"] = (raw & 0xFF) - 50
# group 5 — alarm bitfield (second word per upstream)
flags = groups[5][1] if len(groups[5]) > 1 else 0
out["alarm_current_over"] = "on" if flags & 0b00001000 else "off"
out["alarm_voltage_high"] = "on" if flags & 0b00010000 else "off"
out["alarm_voltage_low"] = "on" if flags & 0b00100000 else "off"
out["alarm_temp_high_chg"] = "on" if flags & 0b01000000 else "off"
out["alarm_temp_low_chg"] = "on" if flags & 0b10000000 else "off"
# group 6 — cycle count
if groups[6]:
out["cycle_count"] = groups[6][0]
# group 7 — pack voltage (V × 100)
if groups[7]:
out["pack_voltage"] = round(groups[7][0] / 100.0, 2)
# groups 8-9 — undecoded; leave as future work
return out
# =============================================================================
# === passive mode: Modbus RTU framing ========================================
# =============================================================================
def crc16_modbus(data: bytes) -> int:
crc = 0xFFFF
for b in data:
crc ^= b
for _ in range(8):
if crc & 1:
crc = (crc >> 1) ^ 0xA001
else:
crc >>= 1
return crc
def _crc_ok(buf: bytes, start: int, length: int) -> bool:
if start + length > len(buf):
return False
body = buf[start:start + length - 2]
expected = buf[start + length - 2] | (buf[start + length - 1] << 8)
return crc16_modbus(body) == expected
_MODBUS_FUNCS = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x0F, 0x10, 0x16, 0x17}
def parse_modbus_frame_at(buf: bytes, start: int) -> tuple[int, str] | None:
if start + 4 > len(buf):
return None
func = buf[start + 1]
# exception response (5 bytes), only for legitimate function codes
if func >= 0x80 and (func & 0x7F) in _MODBUS_FUNCS \
and start + 5 <= len(buf) and 1 <= buf[start + 2] <= 11 \
and _crc_ok(buf, start, 5):
return (5, "exception")
if func == 0x03:
# query: 8 bytes
if _crc_ok(buf, start, 8):
return (8, "query")
# response: 1 + 1 + 1 + byte_count + 2
if start + 3 <= len(buf):
byte_count = buf[start + 2]
if 2 <= byte_count <= 250 and byte_count % 2 == 0:
total = 3 + byte_count + 2
if _crc_ok(buf, start, total):
return (total, "response")
return None
@dataclasses.dataclass
class ModbusFrame:
address: int
function: int
kind: str
raw: bytes
@property
def registers(self) -> list[int]:
if self.kind != "response" or self.function != 0x03:
return []
bc = self.raw[2]
d = self.raw[3:3 + bc]
return [(d[i] << 8) | d[i + 1] for i in range(0, len(d), 2)]
def decode_modbus_response(frame: ModbusFrame) -> dict[str, Any]:
"""Raw-register dump; promote to named fields once we know the layout."""
return {f"register_{i:02d}": v for i, v in enumerate(frame.registers)}
# ---- modbus_per_pack active-poll decoder (EG4 LP4V2) -----------------------
# Register map derived from lv_host.app BMS Tool SQLite schema + UI labels +
# live probing of a single pack. See ../NOTES.md "Register map" section.
# High-confidence fields promoted to named entities; unknowns (reg 32, 35,
# 38-40, 43-45) still emitted as register_NN for correlation.
_WARNING_BITS = [
"pack_ov", "cell_ov", "pack_uv", "cell_uv",
"charge_oc", "discharge_oc", "temp_anomaly", "mos_ot",
"charge_ot", "discharge_ot", "charge_ut", "discharge_ut",
"low_capacity", "other_error",
]
_PROTECTION_BITS = [
"pack_ov", "cell_ov", "pack_uv", "cell_uv",
"charge_oc", "discharge_oc", "temp_anomaly", "mos_ot",
"charge_ot", "discharge_ot", "charge_ut", "discharge_ut",
"float_stopped", "discharge_sc",
]
def _signed16(v: int) -> int:
return v - 0x10000 if v & 0x8000 else v
def decode_eg4_modbus_regs(regs: list[int]) -> dict[str, Any]:
"""Decode the 47-reg read-holding-regs response from an LP4V2 BMS.
Emits named HA entities where meaning is known; raw register_NN
passthrough for the rest."""
out: dict[str, Any] = {}
# always emit raw registers — invaluable for future refinement
for i, v in enumerate(regs):
out[f"register_{i:02d}"] = v
if len(regs) < 47:
return out
# --- pack-level V / I (regs 0, 1) ---
out["pack_voltage"] = round(regs[0] / 100.0, 2)
out["pack_current"] = round(_signed16(regs[1]) / 100.0, 2)
# --- 16 cell voltages (regs 2-17), mV ---
cells_v = [regs[2 + i] / 1000.0 for i in range(16)]
for i, cv in enumerate(cells_v, start=1):
out[f"cell_{i:02d}_voltage"] = round(cv, 3)
vmin, vmax = min(cells_v), max(cells_v)
out["cell_voltage_min"] = round(vmin, 3)
out["cell_voltage_max"] = round(vmax, 3)
out["cell_voltage_delta_mv"] = round((vmax - vmin) * 1000)
out["cell_lowest"] = cells_v.index(vmin) + 1
out["cell_highest"] = cells_v.index(vmax) + 1
# --- temperatures (regs 18-21 = Temp_01..04, reg 24 = Temp_PCB) ---
out["temperature_01"] = regs[18]
out["temperature_02"] = regs[19]
out["temperature_03"] = regs[20]
out["temperature_04"] = regs[21]
out["temperature_pcb"] = regs[24]
# --- SoC / SoH (regs 22, 23) ---
out["soc"] = regs[22]
out["soh"] = regs[23]
# --- heater / status (regs 25-30) ---
# reg 30 has been observed = 1 on a healthy pack; treat as binary
out["heater"] = "on" if regs[30] & 0x01 else "off"
# --- max charge/discharge current limit (reg 31), A ---
out["max_current_limit"] = round(regs[31] / 100.0, 2)
# --- bitfields: warnings (reg 33), protections (reg 34), error code (reg 35) ---
warn = regs[33]
for i, name in enumerate(_WARNING_BITS):
out[f"warning_{name}"] = "on" if (warn >> i) & 1 else "off"
prot = regs[34]
for i, name in enumerate(_PROTECTION_BITS):
out[f"protection_{name}"] = "on" if (prot >> i) & 1 else "off"
out["error_code"] = regs[35]
# --- static-ish (regs 36, 37) ---
out["cell_count"] = regs[36]
out["capacity_ah"] = round(regs[37] / 10.0, 1)
out["remaining_ah"] = round(regs[38] / 100.0, 2)
out["cycle_count"] = regs[39]
out["battery_mode"] = regs[40]
# BMS firmware version — regs 41 & 42 appear to hold version codes; emit
# the raw u16s alongside a decimal representation for easier HA display
out["bms_version_hi"] = regs[41]
out["bms_version_lo"] = regs[42]
# reg 46 increments ~1.25 Hz on live bus — likely uptime in deciseconds
out["uptime_ds"] = regs[46]
return out
class ModbusActivePoller:
"""One instance per pack. Opens its own serial port, issues a single
read-holding-regs fn=0x03 on every `poll()` call, returns raw registers
(or raises). Graceful: a pack whose port doesn't exist or whose BMS is
off will raise on poll, and main loop catches + rate-limits the noise."""
READ_START = 0x0000
READ_COUNT = 47
def __init__(self, port: str, baud: int, address: int, timeout_s: float = 1.0):
self._port_path = port
self._baud = baud
self._address = address
self._timeout_s = timeout_s
self._ser: serial.Serial | None = None
def _open(self) -> None:
if self._ser is None or not self._ser.is_open:
self._ser = serial.Serial(
port=self._port_path, baudrate=self._baud, timeout=0.2,
bytesize=8, parity="N", stopbits=1,
)
def poll(self) -> list[int]:
self._open()
body = bytes([self._address, 0x03,
self.READ_START >> 8, self.READ_START & 0xFF,
self.READ_COUNT >> 8, self.READ_COUNT & 0xFF])
crc = crc16_modbus(body)
frame = body + bytes([crc & 0xFF, crc >> 8])
assert self._ser is not None
self._ser.reset_input_buffer()
self._ser.write(frame)
expected = 3 + self.READ_COUNT * 2 + 2 # addr + func + bc + data + crc
buf = bytearray()
deadline = time.monotonic() + self._timeout_s
while time.monotonic() < deadline and len(buf) < expected:
chunk = self._ser.read(expected - len(buf))
if chunk:
buf.extend(chunk)
raw = bytes(buf)
log.debug("pack 0x%02x tx=%s rx=%s", self._address, frame.hex(" "), raw.hex(" "))
if len(raw) < 5 or raw[0] != self._address or raw[1] != 0x03:
raise RuntimeError(f"no/bad response ({len(raw)} B)")
bc = raw[2]
if len(raw) < 3 + bc + 2:
raise RuntimeError(f"truncated response ({len(raw)} B, expected {3 + bc + 2})")
if not _crc_ok(raw, 0, 3 + bc + 2):
raise RuntimeError("CRC mismatch")
data = raw[3:3 + bc]
return [(data[i] << 8) | data[i + 1] for i in range(0, len(data), 2)]
def close(self) -> None:
if self._ser is not None and self._ser.is_open:
self._ser.close()
# =============================================================================
# === transports ==============================================================
# =============================================================================
# Two abstractions; main loop picks the right one based on bus.mode.
class ActiveTransport:
"""Request-response transport for active mode."""
def query_general(self, address: int) -> bytes:
raise NotImplementedError
def close(self) -> None:
pass
class PassiveListener:
"""Continuous frame-iterator for passive mode."""
def frames(self) -> Iterator[ModbusFrame]:
raise NotImplementedError
def close(self) -> None:
pass
# --- active: serial + mock --------------------------------------------------
class SerialActiveTransport(ActiveTransport):
def __init__(self, port: str, baud: int, timeout_s: float):
self._timeout_s = timeout_s
self._ser = serial.Serial(port=port, baudrate=baud, timeout=0.25,
bytesize=8, parity="N", stopbits=1)
def query_general(self, address: int) -> bytes:
frame = encode_eg4_request(address, CMD_GENERAL_STATUS)
log.debug("TX addr=0x%02x: %s", address, frame.hex())
self._ser.reset_input_buffer()
self._ser.write(frame)
buf = bytearray()
deadline = time.monotonic() + self._timeout_s
while time.monotonic() < deadline:
chunk = self._ser.read(256)
if chunk:
buf.extend(chunk)
if buf[0:1] == b"\x7E" and buf.endswith(b"\x0D"):
break
log.debug("RX addr=0x%02x: %s", address, bytes(buf).hex())
return bytes(buf)
def close(self) -> None:
self._ser.close()
class MockActiveTransport(ActiveTransport):
"""Synthesise EG4 7E/0D replies. Values drift per call so HA dashboards
look alive in dry-run mode."""
def __init__(self, cell_count: int = 16):
self._cell_count = cell_count
self._call = 0
def query_general(self, address: int) -> bytes:
self._call += 1
rng = random.Random(address * 1000 + self._call)
base_mv = 3280 + rng.randint(-5, 5)
cells_mv = [max(0, min(0x7FFF, base_mv + rng.randint(-8, 8)))
for _ in range(self._cell_count)]
current_x100 = rng.randint(-500, 2000)
current_raw = 30000 - current_x100
soc_x100 = (50 + rng.randint(-2, 2)) * 100
cap_ah_x100 = 5000 + rng.randint(-10, 10)
temps_raw = [50 + 25 + rng.randint(-3, 3) for _ in range(4)]
cycles = 42 + address
pack_v_x100 = round(sum(cells_mv) / 10)
def grp(gid: int, values: list[int]) -> bytes:
return bytes([gid, len(values)]) + b"".join(
struct.pack(">H", v & 0xFFFF) for v in values
)
body = b"".join([
grp(0x01, cells_mv),
grp(0x02, [current_raw]),
grp(0x03, [soc_x100]),
grp(0x04, [cap_ah_x100]),
grp(0x05, temps_raw),
grp(0x06, [0, 0]), # alarms = clear
grp(0x07, [cycles]),
grp(0x08, [pack_v_x100]),
grp(0x09, []),
grp(0x0A, []),
])
# checksum byte tolerated as 0x00 by the upstream parser
return bytes([0x7E, address, CMD_GENERAL_STATUS, len(body) & 0xFF]) \
+ body + bytes([0x00, 0x0D])
# --- passive: serial + mock -------------------------------------------------
class SerialPassiveListener(PassiveListener):
_BUF_MAX = 4096
def __init__(self, port: str, baud: int, read_chunk: int = 512):
self._read_chunk = read_chunk
self._ser = serial.Serial(port=port, baudrate=baud, timeout=0.1,
bytesize=8, parity="N", stopbits=1)
self._buf = bytearray()
def frames(self) -> Iterator[ModbusFrame]:
while True:
chunk = self._ser.read(self._read_chunk)
if chunk:
self._buf.extend(chunk)
if len(self._buf) > self._BUF_MAX:
del self._buf[:self._BUF_MAX // 2]
yield from self._extract()
def _extract(self) -> Iterator[ModbusFrame]:
i = 0
while i < len(self._buf) - 4:
r = parse_modbus_frame_at(self._buf, i)
if r is None:
i += 1
continue
length, kind = r
raw = bytes(self._buf[i:i + length])
yield ModbusFrame(address=raw[0], function=raw[1], kind=kind, raw=raw)
del self._buf[:i + length]
i = 0
def close(self) -> None:
self._ser.close()
class MockPassiveListener(PassiveListener):
def __init__(self, packs: list[PackConfig], gap_s: float = 0.5):
self._packs = packs
self._gap_s = gap_s
self._tick = 0
def frames(self) -> Iterator[ModbusFrame]:
while True:
for pack in self._packs:
self._tick += 1
q = self._build_query(pack.address)
yield ModbusFrame(address=pack.address, function=0x03, kind="query", raw=q)
time.sleep(0.05)
r = self._build_response(pack.address)
yield ModbusFrame(address=pack.address, function=0x03, kind="response", raw=r)
time.sleep(self._gap_s)
def _build_query(self, addr: int) -> bytes:
body = bytes([addr, 0x03, 0x00, 0x00, 0x00, 0x2F])
crc = crc16_modbus(body)
return body + bytes([crc & 0xFF, crc >> 8])
def _build_response(self, addr: int) -> bytes:
rng = random.Random(addr * 1000 + self._tick)
regs = [3280 + rng.randint(-5, 5) for _ in range(16)]
regs += [round(52.48 * 100), 50_00, rng.randint(0, 100)]
while len(regs) < 47:
regs.append(rng.randint(0, 100))
body = bytes([addr, 0x03, len(regs) * 2]) + b"".join(
struct.pack(">H", r & 0xFFFF) for r in regs
)
crc = crc16_modbus(body)
return body + bytes([crc & 0xFF, crc >> 8])
# =============================================================================
# === MQTT publisher (HA auto-discovery) ======================================
# =============================================================================
# Field metadata. Active and passive modes emit different keys; both sets
# coexist here without overlap.
_FIELD_META: dict[str, tuple[str | None, str | None, str | None, str | None]] = {
# active mode (EG4 7E/0D decoded)
"pack_voltage": ("V", "voltage", "measurement", "mdi:battery-outline"),
"current": ("A", "current", "measurement", "mdi:current-dc"),
"soc": ("%", "battery", "measurement", "mdi:battery-70"),
"capacity_ah": ("Ah", None, "measurement", "mdi:battery-clock"),
"cycle_count": (None, None, "total", "mdi:counter"),
"cell_voltage_min": ("V", "voltage", "measurement", "mdi:arrow-down-bold"),
"cell_voltage_max": ("V", "voltage", "measurement", "mdi:arrow-up-bold"),
"cell_voltage_delta_mv": ("mV", None, "measurement", "mdi:sine-wave"),
"cell_lowest": (None, None, "measurement", "mdi:numeric"),
"cell_highest": (None, None, "measurement", "mdi:numeric"),
"alarm_current_over": (None, None, None, "mdi:alert-octagon"),
"alarm_voltage_high": (None, None, None, "mdi:alert"),
"alarm_voltage_low": (None, None, None, "mdi:alert"),
"alarm_temp_high_chg": (None, None, None, "mdi:thermometer-alert"),
"alarm_temp_low_chg": (None, None, None, "mdi:thermometer-alert"),
}
for _i in range(1, 33):
_FIELD_META[f"cell_{_i:02d}_voltage"] = ("V", "voltage", "measurement", "mdi:battery-outline")
for _i in range(1, 7):
_FIELD_META[f"temperature_{_i}"] = ("°C", "temperature", "measurement", "mdi:thermometer")
# modbus_per_pack named fields (EG4 register map)
_FIELD_META.update({
"pack_current": ("A", "current", "measurement", "mdi:current-dc"),
"temperature_01": ("°C", "temperature", "measurement", "mdi:thermometer"),
"temperature_02": ("°C", "temperature", "measurement", "mdi:thermometer"),
"temperature_03": ("°C", "temperature", "measurement", "mdi:thermometer"),
"temperature_04": ("°C", "temperature", "measurement", "mdi:thermometer"),
"temperature_pcb": ("°C", "temperature", "measurement", "mdi:chip"),
"heater": (None, None, None, "mdi:heating-coil"),
"max_current_limit": ("A", "current", "measurement", "mdi:current-dc"),
"error_code": (None, None, None, "mdi:alert-octagon"),
"cell_count": (None, None, "measurement", "mdi:numeric"),
"remaining_ah": ("Ah", None, "measurement", "mdi:battery-clock"),
"battery_mode": (None, None, None, "mdi:state-machine"),
"bms_version_hi": (None, None, None, "mdi:chip"),
"bms_version_lo": (None, None, None, "mdi:chip"),
"uptime_ds": (None, None, "total_increasing", "mdi:timer-outline"),
})
for _name in _WARNING_BITS:
_FIELD_META[f"warning_{_name}"] = (None, None, None, "mdi:alert")
for _name in _PROTECTION_BITS:
_FIELD_META[f"protection_{_name}"] = (None, None, None, "mdi:shield-alert")
def field_meta(key: str) -> tuple[str | None, str | None, str | None, str | None]:
if key.startswith("register_"):
return (None, None, "measurement", "mdi:numeric")
return _FIELD_META.get(key, (None, None, None, None))
class MQTTPublisher:
def __init__(self, cfg: MQTTConfig, dry_run: bool = False):
self._cfg = cfg
self._dry_run = dry_run
self._client: mqtt.Client | None = None
self._discovered: set[tuple[str, str]] = set()
if not dry_run:
c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="eg4-battery")
c.username_pw_set(cfg.username, cfg.password)
c.connect(cfg.host, cfg.port, keepalive=60)
c.loop_start()
self._client = c
log.info("connected to MQTT %s:%d", cfg.host, cfg.port)
def publish_pack(self, pack_name: str, readings: dict[str, Any]) -> None:
for key, value in readings.items():
self._publish_one(pack_name, key, value)
def _publish_one(self, pack_name: str, key: str, value: Any) -> None:
entity_id = f"{pack_name}_{key}"
state_topic = f"{self._cfg.discovery_prefix}/sensor/{entity_id}/state"
disco_key = (pack_name, key)
if disco_key not in self._discovered:
self._publish_discovery(pack_name, key, state_topic)
self._discovered.add(disco_key)
payload = json.dumps(value) if isinstance(value, (dict, list)) else str(value)
if self._dry_run:
print(f" {state_topic} {payload}")
else:
self._client.publish(state_topic, payload, qos=0, retain=False)
def _publish_discovery(self, pack_name: str, key: str, state_topic: str) -> None:
unit, device_class, state_class, icon = field_meta(key)
cfg = {
"name": f"{pack_name} {key}",
"state_topic": state_topic,
"unique_id": f"{pack_name}_{key}_eg4",
"device": {
"name": f"EG4 LifePower4 {pack_name}",
"identifiers": [pack_name],
"model": "LifePower4 48V 100Ah v2 Auto-Addressing",
"manufacturer": "EG4 Electronics",
},
}
if unit is not None: cfg["unit_of_measurement"] = unit
if device_class is not None: cfg["device_class"] = device_class
if state_class is not None: cfg["state_class"] = state_class
if icon is not None: cfg["icon"] = icon
topic = f"{self._cfg.discovery_prefix}/sensor/{pack_name}_{key}/config"
payload = json.dumps(cfg)
if self._dry_run:
print(f" [discovery] {topic} {payload}")
else:
self._client.publish(topic, payload, qos=0, retain=True)
def close(self) -> None:
if self._client is not None:
self._client.loop_stop()
self._client.disconnect()
# =============================================================================
# === per-pack state & rate-limited logging ===================================
# =============================================================================
@dataclasses.dataclass
class _PackState:
ok: bool = False
last_error_category: str = ""
consecutive_errors: int = 0
response_count: int = 0
first_seen_logged: bool = False
_FAIL_HEARTBEAT_CYCLES = 360 # re-log a stuck failure every ~hour at 10 s cadence
def _resolve_pack_name(addr: int, packs: list[PackConfig]) -> str:
for p in packs:
if p.address == addr:
return p.name
return f"lifepower4_addr_{addr:02x}"
# =============================================================================
# === main loops ==============================================================
# =============================================================================
def run_active(transport: ActiveTransport, publisher: MQTTPublisher, cfg: AppConfig,
states: dict[str, _PackState], one_cycle: bool = False) -> None:
"""Round-robin poll every configured pack; rate-limit error noise."""
while True:
cycle_start = time.monotonic()
for pack in cfg.packs:
st = states.setdefault(pack.name, _PackState())
try:
raw = transport.query_general(pack.address)
if not raw:
raise RuntimeError(f"empty response from addr=0x{pack.address:02x}")
readings = decode_eg4_general_status(raw, cell_count=cfg.cell_count)
publisher.publish_pack(pack.name, readings)
st.response_count += 1
if not st.ok and st.consecutive_errors > 0:
log.info("pack %s (0x%02x): recovered after %d failed cycle(s)",
pack.name, pack.address, st.consecutive_errors)
st.ok = True
st.consecutive_errors = 0
except Exception as e:
category = f"{type(e).__name__}:{str(e).split(':', 1)[0]}"
if st.ok or category != st.last_error_category:
log.warning("pack %s (0x%02x): %s", pack.name, pack.address, e)
elif st.consecutive_errors > 0 and st.consecutive_errors % _FAIL_HEARTBEAT_CYCLES == 0:
log.warning("pack %s (0x%02x): still failing (%d cycles): %s",
pack.name, pack.address, st.consecutive_errors, e)
st.ok = False
st.last_error_category = category
st.consecutive_errors += 1
if one_cycle:
return
elapsed = time.monotonic() - cycle_start
time.sleep(max(0.0, cfg.bus.poll_interval_s - elapsed))
def run_passive(listener: PassiveListener, publisher: MQTTPublisher, cfg: AppConfig,
trace: bool, max_frames: int | None = None) -> None:
"""Consume frames as they arrive; publish on every fn=0x03 response."""
states: dict[int, _PackState] = {}
seen_unconfigured: set[int] = set()
configured = {p.address for p in cfg.packs}
n = 0
for frame in listener.frames():
n += 1
if trace:
log.debug("%r raw=%s", frame, frame.raw.hex(" "))
if frame.kind != "response" or frame.function != 0x03:
if max_frames is not None and n >= max_frames:
return
continue
st = states.setdefault(frame.address, _PackState())
st.response_count += 1
if not st.first_seen_logged:
if frame.address in configured:
log.info("first response from configured pack 0x%02x (%s)",
frame.address, _resolve_pack_name(frame.address, cfg.packs))
elif frame.address not in seen_unconfigured:
log.warning("response from unconfigured slave 0x%02x — auto-naming as %s",
frame.address, _resolve_pack_name(frame.address, cfg.packs))
seen_unconfigured.add(frame.address)
st.first_seen_logged = True
try:
readings = decode_modbus_response(frame)
except Exception as e:
log.warning("decode failed for addr 0x%02x: %s (raw=%s)",
frame.address, e, frame.raw.hex(" "))
continue
publisher.publish_pack(_resolve_pack_name(frame.address, cfg.packs), readings)
if max_frames is not None and n >= max_frames:
return
def run_modbus_per_pack(cfg: AppConfig, publisher: MQTTPublisher,
states: dict[str, _PackState], one_cycle: bool = False,
dry_run: bool = False) -> None:
"""One adapter per pack. Each `PackConfig` must have `port` and `baud`
set. Round-robin poll every pack on its own serial port; decode
Modbus response into named HA entities + raw register_NN dump."""
pollers: dict[str, ModbusActivePoller] = {}
mock_regs_call: dict[str, int] = {}
def make_poller(p: PackConfig) -> ModbusActivePoller | None:
if dry_run:
return None # mock path, no real poller
if not p.port:
log.warning("pack %s: no `port` set in config; skipping", p.name)
return None
baud = p.baud or cfg.bus.baud
try:
return ModbusActivePoller(p.port, baud, p.address, cfg.bus.timeout_s)
except Exception as e:
log.warning("pack %s: could not open %s: %s", p.name, p.port, e)
return None
for p in cfg.packs:
pl = make_poller(p)
if pl is not None:
pollers[p.name] = pl
try:
while True:
cycle_start = time.monotonic()
for p in cfg.packs:
st = states.setdefault(p.name, _PackState())
try:
if dry_run:
mock_regs_call[p.name] = mock_regs_call.get(p.name, 0) + 1
regs = _mock_modbus_regs(p.address, mock_regs_call[p.name])
else:
if p.name not in pollers:
raise RuntimeError(f"no poller configured for {p.name}")
regs = pollers[p.name].poll()
readings = decode_eg4_modbus_regs(regs)
publisher.publish_pack(p.name, readings)
st.response_count += 1
if not st.ok and st.consecutive_errors > 0:
log.info("pack %s: recovered after %d failed cycle(s)",
p.name, st.consecutive_errors)
st.ok = True
st.consecutive_errors = 0
except Exception as e:
category = f"{type(e).__name__}:{str(e).split(':', 1)[0]}"
if st.ok or category != st.last_error_category:
log.warning("pack %s (0x%02x): %s", p.name, p.address, e)
elif st.consecutive_errors > 0 \
and st.consecutive_errors % _FAIL_HEARTBEAT_CYCLES == 0:
log.warning("pack %s (0x%02x): still failing (%d cycles): %s",
p.name, p.address, st.consecutive_errors, e)
st.ok = False
st.last_error_category = category
st.consecutive_errors += 1
if one_cycle:
return
elapsed = time.monotonic() - cycle_start
time.sleep(max(0.0, cfg.bus.poll_interval_s - elapsed))
finally:
for pl in pollers.values():
pl.close()
def _mock_modbus_regs(address: int, tick: int) -> list[int]:
"""Synthesise 47 realistic-looking registers for dry-run mode."""
rng = random.Random(address * 1000 + tick)
base_mv = 3280 + rng.randint(-3, 3)
cells_mv = [base_mv + rng.randint(-8, 8) for _ in range(16)]
regs: list[int] = [0] * 47
regs[0] = sum(cells_mv) // 10 # pack voltage × 100
regs[1] = (30000 - rng.randint(-500, 2000)) & 0xFFFF # current (×100 biased)
for i, mv in enumerate(cells_mv, start=2):
regs[i] = mv
regs[18] = 21 + rng.randint(-1, 1)
regs[19] = 21 + rng.randint(-1, 1)
regs[20] = 20 + rng.randint(-1, 1)
regs[21] = 54 + rng.randint(-1, 1)
regs[22] = 100
regs[23] = 100
regs[24] = 55
regs[30] = 1
regs[31] = 5493
regs[32] = 10752
regs[33] = 0 # no warnings
regs[34] = 0 # no protections
regs[35] = 0 # error code
regs[36] = 16 # cell count
regs[37] = 1000 # 100.0 Ah
regs[46] = (tick * 5) & 0xFFFF # runtime counter
return regs
def main() -> int:
ap = argparse.ArgumentParser(
description="EG4 LifePower4 v2 → MQTT bridge.")
ap.add_argument("-C", "--config", required=True, type=Path)
ap.add_argument("--dry-run", action="store_true",
help="Mock-bus smoke test — one cycle, print, exit.")
ap.add_argument("--trace", action="store_true", help="Log every frame.")
args = ap.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.trace else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
cfg = load_config(args.config)
valid_modes = {"modbus_per_pack", "active", "passive"}
if cfg.bus.mode not in valid_modes:
raise SystemExit(f"bus.mode must be one of {valid_modes}, got {cfg.bus.mode!r}")
if cfg.bus.transport not in {"serial", "mock"}:
raise SystemExit(f"bus.transport must be 'serial' or 'mock', got {cfg.bus.transport!r}")
publisher = MQTTPublisher(cfg.mqtt, dry_run=args.dry_run)
log.info("eg4-battery starting: mode=%s %d configured pack(s)",
cfg.bus.mode, len(cfg.packs))
use_mock = args.dry_run or cfg.bus.transport == "mock"
try:
if cfg.bus.mode == "modbus_per_pack":
run_modbus_per_pack(cfg, publisher, states={},
one_cycle=args.dry_run, dry_run=args.dry_run)
elif cfg.bus.mode == "active":
transport: ActiveTransport
transport = (MockActiveTransport(cell_count=cfg.cell_count) if use_mock
else SerialActiveTransport(cfg.bus.port, cfg.bus.baud, cfg.bus.timeout_s))
try:
run_active(transport, publisher, cfg, states={}, one_cycle=args.dry_run)
finally:
transport.close()
else: # passive
listener: PassiveListener
listener = (MockPassiveListener(cfg.packs) if use_mock
else SerialPassiveListener(cfg.bus.port, cfg.bus.baud, cfg.bus.read_chunk))
try:
run_passive(listener, publisher, cfg, trace=args.trace,
max_frames=(2 * len(cfg.packs) if args.dry_run else None))
finally:
listener.close()
return 0
except KeyboardInterrupt:
return 0
finally:
publisher.close()
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,48 @@
# eg4-battery config — deploys to ~/.config/eg4-battery/eg4-battery.yaml (mode 600)
bus:
# ---- mode: pick one ----
# active ← RECOMMENDED. We are the master on a dedicated bus to the
# battery's pin-1/2 external monitor port. Speaks the EG4
# 7E/0D protocol at 9600 baud. Returns named, decoded HA
# entities (pack V, cell voltages, SoC, temps, alarms).
#
# passive ← Listen-only Modbus-RTU sniffer at 19200 baud. Use only when
# the FTDI is on a bus that already has a Modbus master
# (e.g. the LVX6048 parallel-comm bus — although see NOTES.md:
# the LVX6048 doesn't poll EG4 packs that way, so this mode
# is mostly diagnostic). Publishes raw `register_NN` per slave.
mode: active
transport: serial # serial | mock (--dry-run on the CLI forces mock)
# Stable /dev/serial/by-id symlink survives USB reshuffles. Find yours with
# ls -l /dev/serial/by-id/
port: /dev/serial/by-id/usb-FTDI_<YOUR_ADAPTER_ID>-if00-port0
# Default 9600 (active EG4 protocol). Set 19200 for passive Modbus mode.
baud: 9600
# Active mode only:
timeout_s: 1.5 # per-query response wait
poll_interval_s: 10.0 # round-robin cycle target
mqtt:
host: <MQTT_BROKER_IP> # e.g. 10.0.0.41 (HA Mosquitto broker)
port: 1883
username: <MQTT_USER>
password: <MQTT_PASSWORD>
discovery_prefix: homeassistant
# One entry per pack. `name` is the HA entity prefix and device identifier.
# `address` is the EG4 7E protocol address in active mode (master = 1, slaves
# typically 2, 3, ...) or the Modbus slave ID in passive mode.
packs:
- name: lifepower4_1
address: 1
- name: lifepower4_2
address: 2
- name: lifepower4_3
address: 3
cell_count: 16 # 16S for 48V LFP — active mode only

View File

@@ -0,0 +1,19 @@
[Unit]
Description=EG4 LifePower4 RS485 -> MQTT bridge
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=noise
Group=dialout
# Systemd's default PATH does not include ~/.local/bin where `uv` is installed.
# The script's `#!/usr/bin/env -S uv run --script` shebang needs to find uv.
Environment=PATH=/home/noise/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# uv manages the ephemeral venv per the PEP-723 inline deps in the script
ExecStart=/usr/local/bin/eg4-battery -C /home/noise/.config/eg4-battery/eg4-battery.yaml
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,5 @@
# EG4 LifePower4 RS485 bus via USB-serial adapter.
# Grants dialout group access; /dev/serial/by-id/ symlink survives USB moves.
# Adjust idVendor/idProduct if you use a CP210x (0x10c4:0xea60) or CH340
# (0x1a86:0x7523) adapter instead of the reference FTDI FT232R.
SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", MODE="0660", GROUP="dialout"

View File

@@ -0,0 +1,58 @@
# HA-side configuration for the eg4-battery daemon
Reference configs that go into your Home Assistant instance — they aren't
installed by `install.sh` (HA typically lives on a different host / in an
HA OS appliance), but they're tracked here so the full stack is reproducible.
## What's in here
| File | Where it goes in HA |
|---------------------------|-----------------------------------------------------|
| `recorder.yaml` | `configuration.yaml` → under a `recorder:` key, or merge into existing |
| `template_sensors.yaml` | `configuration.yaml` → under a top-level `template:` list, or include via `!include` |
| `lovelace_overview.yaml` | Raw Lovelace card config — paste into a new dashboard view |
All of it assumes pack names `lifepower4_1`, `lifepower4_2`, `lifepower4_3`
matching the daemon's default config. If you renamed your packs, do a
`sed -i 's/lifepower4_/your_prefix_/' *.yaml` first.
## Recommended retention tiers
Full rationale in [`../NOTES.md`](../NOTES.md) and the architecture thread,
but the short version:
- **Tier 1 — keep forever**: `pack_voltage`, `pack_current`, `soc`, `soh`,
`cycle_count`, `cell_voltage_min/max/delta_mv`, `capacity_ah`.
- **Tier 2 — keep short**: all 14 `warning_*` + 14 `protection_*`,
`error_code`, `remaining_ah`, `heater`, the derived `temperature_max`
and `pack_power`.
- **Tier 3 — exclude** (the `recorder.yaml` here does this): all 47 raw
`register_NN` entities, the 16 individual `cell_NN_voltage` series,
static metadata (`bms_version_*`, `battery_mode`, `cell_count`, etc.),
and the `uptime_ds` counter that increments every second.
## Enabling in HA
Easiest path:
```yaml
# configuration.yaml
# merge our recorder exclusions with your existing recorder config
recorder: !include eg4_battery/recorder.yaml
# include the template sensors (creates a new `template:` list block)
template: !include eg4_battery/template_sensors.yaml
```
And drop the two YAMLs into `~/homeassistant/eg4_battery/`.
If you already have `recorder:` or `template:` keys elsewhere, merge by
hand — HA doesn't allow two definitions of the same top-level key.
## Energy dashboard wiring (optional)
Once the derived `pack_power` template sensors exist, add them to the
Energy dashboard via **Settings → Dashboards → Energy → Home battery
storage** — each pack's `pack_power` integrates to `pack_energy_in_kwh`
and `pack_energy_out_kwh` automatically, with per-pack bars.

View File

@@ -0,0 +1,116 @@
# Lovelace card config for a 3-pack LifePower4 stack overview.
# Paste into a dashboard view's raw-config editor, or drop in as a YAML-mode
# dashboard. Assumes the template sensors in template_sensors.yaml exist.
views:
- title: Battery Stack
icon: mdi:battery
path: batteries
cards:
# ---- stack summary row ----
- type: horizontal-stack
cards:
- type: gauge
name: Stack SoC
entity: sensor.lifepower4_stack_soc_avg
min: 0
max: 100
severity:
green: 40
yellow: 20
red: 0
- type: entity
name: Stack Power
entity: sensor.lifepower4_stack_pack_power_total
icon: mdi:flash
- type: entity
name: Hottest Point
entity: sensor.lifepower4_stack_temperature_max
icon: mdi:thermometer
# ---- per-pack summary cards ----
- type: horizontal-stack
cards:
- !include_named pack_summary_pack1.yaml
- !include_named pack_summary_pack2.yaml
- !include_named pack_summary_pack3.yaml
# ---- pack voltage time series ----
- type: history-graph
title: Pack voltage (24 h)
hours_to_show: 24
entities:
- sensor.lifepower4_1_pack_voltage
- sensor.lifepower4_2_pack_voltage
- sensor.lifepower4_3_pack_voltage
# ---- SoC + SoH trend ----
- type: history-graph
title: SoC / SoH
hours_to_show: 168 # 1 week
entities:
- sensor.lifepower4_1_soc
- sensor.lifepower4_2_soc
- sensor.lifepower4_3_soc
- sensor.lifepower4_1_soh
- sensor.lifepower4_2_soh
- sensor.lifepower4_3_soh
# ---- cell balance health (the crucial long-term metric) ----
- type: history-graph
title: Cell voltage delta (mV) — rising = balance degrading
hours_to_show: 168
entities:
- sensor.lifepower4_1_cell_voltage_delta_mv
- sensor.lifepower4_2_cell_voltage_delta_mv
- sensor.lifepower4_3_cell_voltage_delta_mv
# ---- any active warnings/protections — glance card, visible red when on ----
- type: entities
title: Alarms (should all be "off" on a healthy stack)
show_header_toggle: false
entities:
- type: section
label: Pack 1
- entity: sensor.lifepower4_1_warning_cell_ov
name: Cell OV
- entity: sensor.lifepower4_1_warning_cell_uv
name: Cell UV
- entity: sensor.lifepower4_1_warning_charge_oc
name: Charge OC
- entity: sensor.lifepower4_1_warning_discharge_oc
name: Discharge OC
- entity: sensor.lifepower4_1_warning_mos_ot
name: MOSFET OT
- entity: sensor.lifepower4_1_warning_low_capacity
name: Low Capacity
- entity: sensor.lifepower4_1_protection_cell_ov
name: PROT Cell OV
- entity: sensor.lifepower4_1_protection_cell_uv
name: PROT Cell UV
# (repeat structure for pack_2 and pack_3, omitted for brevity)
# Per-pack summary card template — save as three copies named
# pack_summary_pack1.yaml, pack_summary_pack2.yaml, pack_summary_pack3.yaml
# with only the N suffix different.
#
# type: entities
# title: Pack 1
# show_header_toggle: false
# entities:
# - entity: sensor.lifepower4_1_pack_voltage
# name: Voltage
# - entity: sensor.lifepower4_1_pack_current
# name: Current
# - entity: sensor.lifepower4_1_pack_power
# name: Power
# - entity: sensor.lifepower4_1_soc
# name: SoC
# - entity: sensor.lifepower4_1_soh
# name: SoH
# - entity: sensor.lifepower4_1_temperature_max
# name: Hottest
# - entity: sensor.lifepower4_1_cell_voltage_delta_mv
# name: Cell Δ
# - entity: sensor.lifepower4_1_cycle_count
# name: Cycles

View File

@@ -0,0 +1,51 @@
# HA recorder exclusions for the eg4-battery daemon's MQTT entities.
#
# Merge with your existing recorder config; if you don't have one, this whole
# file can be referenced as `recorder: !include eg4_battery/recorder.yaml`.
#
# Rationale:
# - register_NN entities are raw Modbus registers, diagnostic only
# - individual cell voltages are redundant once you have min/max/delta
# - uptime / version / static config values are pure noise in a timeseries
#
# Everything NOT in `entity_globs` below keeps recording normally, including
# the Tier-1 (pack_voltage / soc / soh / cycle_count / cell_voltage_min/max/
# delta_mv / capacity_ah) and Tier-2 (warnings / protections / error_code)
# entities. See ../NOTES.md for the retention-tier breakdown.
exclude:
entity_globs:
# raw Modbus register dump — diagnostic only
- sensor.lifepower4_*_register_*
# 16 individual cells per pack = 48 noisy series.
# cell_voltage_min / _max / _delta_mv already capture 95% of the info.
# Comment this out if you're debugging a specific drifting cell.
- sensor.lifepower4_*_cell_01_voltage
- sensor.lifepower4_*_cell_02_voltage
- sensor.lifepower4_*_cell_03_voltage
- sensor.lifepower4_*_cell_04_voltage
- sensor.lifepower4_*_cell_05_voltage
- sensor.lifepower4_*_cell_06_voltage
- sensor.lifepower4_*_cell_07_voltage
- sensor.lifepower4_*_cell_08_voltage
- sensor.lifepower4_*_cell_09_voltage
- sensor.lifepower4_*_cell_10_voltage
- sensor.lifepower4_*_cell_11_voltage
- sensor.lifepower4_*_cell_12_voltage
- sensor.lifepower4_*_cell_13_voltage
- sensor.lifepower4_*_cell_14_voltage
- sensor.lifepower4_*_cell_15_voltage
- sensor.lifepower4_*_cell_16_voltage
# static metadata (doesn't change, no reason to keep history)
- sensor.lifepower4_*_bms_version_hi
- sensor.lifepower4_*_bms_version_lo
- sensor.lifepower4_*_cell_count
- sensor.lifepower4_*_cell_highest
- sensor.lifepower4_*_cell_lowest
- sensor.lifepower4_*_battery_mode
- sensor.lifepower4_*_max_current_limit
# uptime counter — increments every second, kills the recorder's write cache
- sensor.lifepower4_*_uptime_ds

View File

@@ -0,0 +1,174 @@
# Derived template sensors for the eg4-battery daemon's 3-pack stack.
# Include into configuration.yaml as:
# template: !include eg4_battery/template_sensors.yaml
#
# Per-pack entities created:
# sensor.lifepower4_N_pack_power W (V × I, signed; + = charging)
# sensor.lifepower4_N_temperature_max °C (max of 5 temp sensors)
# sensor.lifepower4_N_cell_imbalance_pct % (delta / min_cell) × 100
#
# Stack-wide rollups:
# sensor.lifepower4_stack_pack_power_total W (sum of all 3 pack_powers)
# sensor.lifepower4_stack_soc_avg % (average SoC across packs)
# sensor.lifepower4_stack_temperature_max °C (hottest point anywhere)
- sensor:
# ---- pack 1 ----
- name: "lifepower4_1 pack_power"
unique_id: lifepower4_1_pack_power
unit_of_measurement: "W"
device_class: power
state_class: measurement
state: >
{% set v = states('sensor.lifepower4_1_pack_voltage') | float(0) %}
{% set i = states('sensor.lifepower4_1_pack_current') | float(0) %}
{{ (v * i) | round(1) }}
- name: "lifepower4_1 temperature_max"
unique_id: lifepower4_1_temperature_max
unit_of_measurement: "°C"
device_class: temperature
state_class: measurement
state: >
{% set t = [
states('sensor.lifepower4_1_temperature_01') | int(0),
states('sensor.lifepower4_1_temperature_02') | int(0),
states('sensor.lifepower4_1_temperature_03') | int(0),
states('sensor.lifepower4_1_temperature_04') | int(0),
states('sensor.lifepower4_1_temperature_pcb') | int(0),
] %}
{{ t | max }}
- name: "lifepower4_1 cell_imbalance_pct"
unique_id: lifepower4_1_cell_imbalance_pct
unit_of_measurement: "%"
state_class: measurement
state: >
{% set d = states('sensor.lifepower4_1_cell_voltage_delta_mv') | float(0) %}
{% set mn = states('sensor.lifepower4_1_cell_voltage_min') | float(0) %}
{{ (d / (mn * 1000) * 100) | round(3) if mn > 0 else 0 }}
# ---- pack 2 ----
- name: "lifepower4_2 pack_power"
unique_id: lifepower4_2_pack_power
unit_of_measurement: "W"
device_class: power
state_class: measurement
state: >
{% set v = states('sensor.lifepower4_2_pack_voltage') | float(0) %}
{% set i = states('sensor.lifepower4_2_pack_current') | float(0) %}
{{ (v * i) | round(1) }}
- name: "lifepower4_2 temperature_max"
unique_id: lifepower4_2_temperature_max
unit_of_measurement: "°C"
device_class: temperature
state_class: measurement
state: >
{% set t = [
states('sensor.lifepower4_2_temperature_01') | int(0),
states('sensor.lifepower4_2_temperature_02') | int(0),
states('sensor.lifepower4_2_temperature_03') | int(0),
states('sensor.lifepower4_2_temperature_04') | int(0),
states('sensor.lifepower4_2_temperature_pcb') | int(0),
] %}
{{ t | max }}
- name: "lifepower4_2 cell_imbalance_pct"
unique_id: lifepower4_2_cell_imbalance_pct
unit_of_measurement: "%"
state_class: measurement
state: >
{% set d = states('sensor.lifepower4_2_cell_voltage_delta_mv') | float(0) %}
{% set mn = states('sensor.lifepower4_2_cell_voltage_min') | float(0) %}
{{ (d / (mn * 1000) * 100) | round(3) if mn > 0 else 0 }}
# ---- pack 3 ----
- name: "lifepower4_3 pack_power"
unique_id: lifepower4_3_pack_power
unit_of_measurement: "W"
device_class: power
state_class: measurement
state: >
{% set v = states('sensor.lifepower4_3_pack_voltage') | float(0) %}
{% set i = states('sensor.lifepower4_3_pack_current') | float(0) %}
{{ (v * i) | round(1) }}
- name: "lifepower4_3 temperature_max"
unique_id: lifepower4_3_temperature_max
unit_of_measurement: "°C"
device_class: temperature
state_class: measurement
state: >
{% set t = [
states('sensor.lifepower4_3_temperature_01') | int(0),
states('sensor.lifepower4_3_temperature_02') | int(0),
states('sensor.lifepower4_3_temperature_03') | int(0),
states('sensor.lifepower4_3_temperature_04') | int(0),
states('sensor.lifepower4_3_temperature_pcb') | int(0),
] %}
{{ t | max }}
- name: "lifepower4_3 cell_imbalance_pct"
unique_id: lifepower4_3_cell_imbalance_pct
unit_of_measurement: "%"
state_class: measurement
state: >
{% set d = states('sensor.lifepower4_3_cell_voltage_delta_mv') | float(0) %}
{% set mn = states('sensor.lifepower4_3_cell_voltage_min') | float(0) %}
{{ (d / (mn * 1000) * 100) | round(3) if mn > 0 else 0 }}
# ---- stack-wide rollups ----
- name: "lifepower4_stack pack_power_total"
unique_id: lifepower4_stack_pack_power_total
unit_of_measurement: "W"
device_class: power
state_class: measurement
state: >
{% set p = [
states('sensor.lifepower4_1_pack_power') | float(0),
states('sensor.lifepower4_2_pack_power') | float(0),
states('sensor.lifepower4_3_pack_power') | float(0),
] %}
{{ p | sum | round(1) }}
- name: "lifepower4_stack soc_avg"
unique_id: lifepower4_stack_soc_avg
unit_of_measurement: "%"
device_class: battery
state_class: measurement
state: >
{% set s = [
states('sensor.lifepower4_1_soc') | float(0),
states('sensor.lifepower4_2_soc') | float(0),
states('sensor.lifepower4_3_soc') | float(0),
] %}
{{ (s | sum / s | length) | round(1) }}
- name: "lifepower4_stack temperature_max"
unique_id: lifepower4_stack_temperature_max
unit_of_measurement: "°C"
device_class: temperature
state_class: measurement
state: >
{% set t = [
states('sensor.lifepower4_1_temperature_max') | int(0),
states('sensor.lifepower4_2_temperature_max') | int(0),
states('sensor.lifepower4_3_temperature_max') | int(0),
] %}
{{ t | max }}
# --- integration → energy (pack_power integrated to Wh) ---
# Paste this at the top level of configuration.yaml, NOT inside `template:`:
#
# sensor:
# - platform: integration
# source: sensor.lifepower4_1_pack_power
# name: lifepower4_1_pack_energy
# unit_prefix: k
# round: 3
# method: left
# # ... repeat for pack 2 and 3 ...
#
# Then wire the resulting sensor.lifepower4_N_pack_energy into the HA
# Energy dashboard → Home battery storage → one entry per pack.

93
eg4battery/install.sh Executable file
View File

@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# EG4 LifePower4 → Home Assistant monitoring — reproducible installer.
#
# Installs the eg4-battery daemon onto /usr/local/bin (uv handles deps via the
# script's PEP-723 inline metadata), drops in the udev rule + systemd unit,
# and seeds the config template. Idempotent: safe to re-run.
#
# Assumptions:
# - Debian-family Linux with systemd
# - `uv` on $PATH (https://docs.astral.sh/uv/)
# - User has sudo
#
# After install, finish by editing:
# ~/.config/eg4-battery/eg4-battery.yaml — bus.port, MQTT creds, pack addresses
# then:
# ./install.sh --dry-run — end-to-end smoke test (no HW needed)
# sudo systemctl enable --now eg4-battery.service
# journalctl -u eg4-battery.service -f
set -euo pipefail
BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DRY_RUN=0
for arg in "$@"; do
[ "$arg" = "--dry-run" ] && DRY_RUN=1
done
msg() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; }
# --- 1. dependency check ---------------------------------------------------
msg "Checking prerequisites"
command -v uv >/dev/null || { echo "uv not found — install from https://docs.astral.sh/uv/"; exit 1; }
# --- 2. entry point --------------------------------------------------------
msg "Installing /usr/local/bin/eg4-battery"
sudo install -m 755 "${BASE}/bin/eg4-battery" /usr/local/bin/eg4-battery
# --- 3. udev rule ----------------------------------------------------------
msg "Installing udev rule (FTDI/CH340/CP210x → dialout)"
sudo install -m 644 "${BASE}/etc/udev/rules.d/99-eg4-rs485.rules" /etc/udev/rules.d/99-eg4-rs485.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --subsystem-match=tty
# --- 4. systemd unit -------------------------------------------------------
msg "Installing systemd unit"
sudo install -m 644 "${BASE}/etc/systemd/system/eg4-battery.service" /etc/systemd/system/eg4-battery.service
sudo systemctl daemon-reload
# --- 5. user config (only if not already present) --------------------------
msg "Installing config template"
mkdir -p "${HOME}/.config/eg4-battery"
dest="${HOME}/.config/eg4-battery/eg4-battery.yaml"
if [ -e "$dest" ]; then
echo " $dest already exists — not overwriting"
else
install -m 600 "${BASE}/config/eg4-battery.yaml.example" "$dest"
echo " wrote $dest (mode 600) — edit bus.port, MQTT creds, pack addresses"
fi
# --- 6. smoke test ---------------------------------------------------------
if [ "$DRY_RUN" = "1" ]; then
msg "Dry-run: running one mock-bus cycle (no MQTT publish)"
/usr/local/bin/eg4-battery -C "$dest" --dry-run
echo
echo "(above output is what would be published to MQTT under real run)"
fi
# --- 7. enable -------------------------------------------------------------
if grep -q '<MQTT_PASSWORD>' "$dest" 2>/dev/null; then
cat <<EOF
Credentials in $dest still contain a placeholder.
Edit the file, verify with: eg4-battery -C $dest --dry-run
Then enable the daemon:
sudo systemctl enable --now eg4-battery.service
journalctl -u eg4-battery.service -f
EOF
else
msg "Enabling eg4-battery.service"
sudo systemctl enable --now eg4-battery.service
sleep 3
systemctl --no-pager status eg4-battery.service | head -15
fi
msg "Done"
echo "Next steps:"
echo " - While batteries are still in the mail: ./install.sh --dry-run"
echo " - Once one pack is wired: edit ~/.config/eg4-battery/eg4-battery.yaml,"
echo " set bus.transport: serial + address: 1 only,"
echo " validate per ../NOTES.md"
echo " - Full three-pack bring-up: uncomment addresses 2 and 3"

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>QtCore</string>
<key>CFBundleIdentifier</key>
<string>org.qt-project.QtCore</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>5.14</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>5.14.2</string>
<key>NOTE</key>
<string>Please, do NOT change this file -- It was generated by Qt/QMake.</string>
</dict>
</plist>

View File

@@ -0,0 +1,7 @@
QMAKE_PRO_INPUT = corelib.pro
QMAKE_PRL_TARGET = QtCore
QMAKE_PRL_CONFIG = lex yacc exceptions depend_includepath testcase_targets import_plugins import_qpa_plugin asset_catalogs rez prepare_docs qt_docs_targets qt_build_extra file_copies qmake_use qt warn_on release link_prl app_bundle incremental global_init_link_order lib_version_first sdk clang_pch_style shared shared qt_framework release macos osx macx mac darwin unix posix gcc clang llvm sse2 aesni sse3 ssse3 sse4_1 sse4_2 avx avx2 avx512f avx512bw avx512cd avx512dq avx512er avx512ifma avx512pf avx512vbmi avx512vl compile_examples f16c force_debug_info largefile precompile_header rdrnd shani x86SimdAlways prefix_build force_independent no_warn_empty_obj_files utf8_source create_prl link_prl no_private_qt_headers_warning QTDIR_build qt_example_installs testcase_exceptions explicitlib testcase_no_bundle warning_clean exceptions qt_tracepoints moc resources simd optimize_full pcre2 generated_privates module_frameworks lib_bundle relative_qt_rpath app_extension_api_only git_build target_qt c++11 strict_c++ c++14 c99 c11 hide_symbols separate_debug_info need_fwd_pri qt_install_module create_cmake sliced_bundle compiler_supports_fpmath create_pc have_target dll debug_info objective_c any_bundle arch_haswell avx512common avx512core thread
QMAKE_PRL_VERSION = 5.14.2
QMAKE_PRL_LIBS = -framework DiskArbitration -framework IOKit
QMAKE_PRL_LIBS_FOR_CMAKE = -framework;DiskArbitration;-framework;IOKit;

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>QtCore</string>
<key>CFBundleIdentifier</key>
<string>org.qt-project.QtCore</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>5.14</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>5.14.2</string>
<key>NOTE</key>
<string>Please, do NOT change this file -- It was generated by Qt/QMake.</string>
</dict>
</plist>

View File

@@ -0,0 +1,7 @@
QMAKE_PRO_INPUT = corelib.pro
QMAKE_PRL_TARGET = QtCore
QMAKE_PRL_CONFIG = lex yacc exceptions depend_includepath testcase_targets import_plugins import_qpa_plugin asset_catalogs rez prepare_docs qt_docs_targets qt_build_extra file_copies qmake_use qt warn_on release link_prl app_bundle incremental global_init_link_order lib_version_first sdk clang_pch_style shared shared qt_framework release macos osx macx mac darwin unix posix gcc clang llvm sse2 aesni sse3 ssse3 sse4_1 sse4_2 avx avx2 avx512f avx512bw avx512cd avx512dq avx512er avx512ifma avx512pf avx512vbmi avx512vl compile_examples f16c force_debug_info largefile precompile_header rdrnd shani x86SimdAlways prefix_build force_independent no_warn_empty_obj_files utf8_source create_prl link_prl no_private_qt_headers_warning QTDIR_build qt_example_installs testcase_exceptions explicitlib testcase_no_bundle warning_clean exceptions qt_tracepoints moc resources simd optimize_full pcre2 generated_privates module_frameworks lib_bundle relative_qt_rpath app_extension_api_only git_build target_qt c++11 strict_c++ c++14 c99 c11 hide_symbols separate_debug_info need_fwd_pri qt_install_module create_cmake sliced_bundle compiler_supports_fpmath create_pc have_target dll debug_info objective_c any_bundle arch_haswell avx512common avx512core thread
QMAKE_PRL_VERSION = 5.14.2
QMAKE_PRL_LIBS = -framework DiskArbitration -framework IOKit
QMAKE_PRL_LIBS_FOR_CMAKE = -framework;DiskArbitration;-framework;IOKit;

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>QtCore</string>
<key>CFBundleIdentifier</key>
<string>org.qt-project.QtCore</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>5.14</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>5.14.2</string>
<key>NOTE</key>
<string>Please, do NOT change this file -- It was generated by Qt/QMake.</string>
</dict>
</plist>

View File

@@ -0,0 +1,7 @@
QMAKE_PRO_INPUT = corelib.pro
QMAKE_PRL_TARGET = QtCore
QMAKE_PRL_CONFIG = lex yacc exceptions depend_includepath testcase_targets import_plugins import_qpa_plugin asset_catalogs rez prepare_docs qt_docs_targets qt_build_extra file_copies qmake_use qt warn_on release link_prl app_bundle incremental global_init_link_order lib_version_first sdk clang_pch_style shared shared qt_framework release macos osx macx mac darwin unix posix gcc clang llvm sse2 aesni sse3 ssse3 sse4_1 sse4_2 avx avx2 avx512f avx512bw avx512cd avx512dq avx512er avx512ifma avx512pf avx512vbmi avx512vl compile_examples f16c force_debug_info largefile precompile_header rdrnd shani x86SimdAlways prefix_build force_independent no_warn_empty_obj_files utf8_source create_prl link_prl no_private_qt_headers_warning QTDIR_build qt_example_installs testcase_exceptions explicitlib testcase_no_bundle warning_clean exceptions qt_tracepoints moc resources simd optimize_full pcre2 generated_privates module_frameworks lib_bundle relative_qt_rpath app_extension_api_only git_build target_qt c++11 strict_c++ c++14 c99 c11 hide_symbols separate_debug_info need_fwd_pri qt_install_module create_cmake sliced_bundle compiler_supports_fpmath create_pc have_target dll debug_info objective_c any_bundle arch_haswell avx512common avx512core thread
QMAKE_PRL_VERSION = 5.14.2
QMAKE_PRL_LIBS = -framework DiskArbitration -framework IOKit
QMAKE_PRL_LIBS_FOR_CMAKE = -framework;DiskArbitration;-framework;IOKit;

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>QtDBus</string>
<key>CFBundleIdentifier</key>
<string>org.qt-project.QtDBus</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>5.14</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>5.14.2</string>
<key>NOTE</key>
<string>Please, do NOT change this file -- It was generated by Qt/QMake.</string>
</dict>
</plist>

View File

@@ -0,0 +1,7 @@
QMAKE_PRO_INPUT = dbus.pro
QMAKE_PRL_TARGET = QtDBus
QMAKE_PRL_CONFIG = lex yacc depend_includepath testcase_targets import_plugins import_qpa_plugin asset_catalogs rez prepare_docs qt_docs_targets qt_build_extra file_copies qmake_use qt warn_on release link_prl app_bundle incremental global_init_link_order lib_version_first sdk clang_pch_style shared shared qt_framework release macos osx macx mac darwin unix posix gcc clang llvm sse2 aesni sse3 ssse3 sse4_1 sse4_2 avx avx2 avx512f avx512bw avx512cd avx512dq avx512er avx512ifma avx512pf avx512vbmi avx512vl compile_examples f16c force_debug_info largefile precompile_header rdrnd shani x86SimdAlways prefix_build force_independent no_warn_empty_obj_files utf8_source create_prl link_prl no_private_qt_headers_warning QTDIR_build qt_example_installs exceptions_off testcase_exceptions explicitlib testcase_no_bundle warning_clean module_frameworks lib_bundle relative_qt_rpath app_extension_api_only git_build target_qt c++11 strict_c++ c++14 c99 c11 hide_symbols separate_debug_info need_fwd_pri qt_install_module create_cmake sliced_bundle compiler_supports_fpmath create_pc have_target dll debug_info objective_c any_bundle thread moc resources
QMAKE_PRL_VERSION = 5.14.2
QMAKE_PRL_LIBS = -F$$[QT_INSTALL_LIBS] -framework QtCore -framework DiskArbitration -framework IOKit
QMAKE_PRL_LIBS_FOR_CMAKE = -F$$[QT_INSTALL_LIBS];-framework;QtCore;-framework;DiskArbitration;-framework;IOKit;

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>QtDBus</string>
<key>CFBundleIdentifier</key>
<string>org.qt-project.QtDBus</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>5.14</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>5.14.2</string>
<key>NOTE</key>
<string>Please, do NOT change this file -- It was generated by Qt/QMake.</string>
</dict>
</plist>

View File

@@ -0,0 +1,7 @@
QMAKE_PRO_INPUT = dbus.pro
QMAKE_PRL_TARGET = QtDBus
QMAKE_PRL_CONFIG = lex yacc depend_includepath testcase_targets import_plugins import_qpa_plugin asset_catalogs rez prepare_docs qt_docs_targets qt_build_extra file_copies qmake_use qt warn_on release link_prl app_bundle incremental global_init_link_order lib_version_first sdk clang_pch_style shared shared qt_framework release macos osx macx mac darwin unix posix gcc clang llvm sse2 aesni sse3 ssse3 sse4_1 sse4_2 avx avx2 avx512f avx512bw avx512cd avx512dq avx512er avx512ifma avx512pf avx512vbmi avx512vl compile_examples f16c force_debug_info largefile precompile_header rdrnd shani x86SimdAlways prefix_build force_independent no_warn_empty_obj_files utf8_source create_prl link_prl no_private_qt_headers_warning QTDIR_build qt_example_installs exceptions_off testcase_exceptions explicitlib testcase_no_bundle warning_clean module_frameworks lib_bundle relative_qt_rpath app_extension_api_only git_build target_qt c++11 strict_c++ c++14 c99 c11 hide_symbols separate_debug_info need_fwd_pri qt_install_module create_cmake sliced_bundle compiler_supports_fpmath create_pc have_target dll debug_info objective_c any_bundle thread moc resources
QMAKE_PRL_VERSION = 5.14.2
QMAKE_PRL_LIBS = -F$$[QT_INSTALL_LIBS] -framework QtCore -framework DiskArbitration -framework IOKit
QMAKE_PRL_LIBS_FOR_CMAKE = -F$$[QT_INSTALL_LIBS];-framework;QtCore;-framework;DiskArbitration;-framework;IOKit;

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>QtDBus</string>
<key>CFBundleIdentifier</key>
<string>org.qt-project.QtDBus</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>5.14</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>5.14.2</string>
<key>NOTE</key>
<string>Please, do NOT change this file -- It was generated by Qt/QMake.</string>
</dict>
</plist>

View File

@@ -0,0 +1,7 @@
QMAKE_PRO_INPUT = dbus.pro
QMAKE_PRL_TARGET = QtDBus
QMAKE_PRL_CONFIG = lex yacc depend_includepath testcase_targets import_plugins import_qpa_plugin asset_catalogs rez prepare_docs qt_docs_targets qt_build_extra file_copies qmake_use qt warn_on release link_prl app_bundle incremental global_init_link_order lib_version_first sdk clang_pch_style shared shared qt_framework release macos osx macx mac darwin unix posix gcc clang llvm sse2 aesni sse3 ssse3 sse4_1 sse4_2 avx avx2 avx512f avx512bw avx512cd avx512dq avx512er avx512ifma avx512pf avx512vbmi avx512vl compile_examples f16c force_debug_info largefile precompile_header rdrnd shani x86SimdAlways prefix_build force_independent no_warn_empty_obj_files utf8_source create_prl link_prl no_private_qt_headers_warning QTDIR_build qt_example_installs exceptions_off testcase_exceptions explicitlib testcase_no_bundle warning_clean module_frameworks lib_bundle relative_qt_rpath app_extension_api_only git_build target_qt c++11 strict_c++ c++14 c99 c11 hide_symbols separate_debug_info need_fwd_pri qt_install_module create_cmake sliced_bundle compiler_supports_fpmath create_pc have_target dll debug_info objective_c any_bundle thread moc resources
QMAKE_PRL_VERSION = 5.14.2
QMAKE_PRL_LIBS = -F$$[QT_INSTALL_LIBS] -framework QtCore -framework DiskArbitration -framework IOKit
QMAKE_PRL_LIBS_FOR_CMAKE = -F$$[QT_INSTALL_LIBS];-framework;QtCore;-framework;DiskArbitration;-framework;IOKit;

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>QtGui</string>
<key>CFBundleIdentifier</key>
<string>org.qt-project.QtGui</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>5.14</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>5.14.2</string>
<key>NOTE</key>
<string>Please, do NOT change this file -- It was generated by Qt/QMake.</string>
</dict>
</plist>

View File

@@ -0,0 +1,7 @@
QMAKE_PRO_INPUT = gui.pro
QMAKE_PRL_TARGET = QtGui
QMAKE_PRL_CONFIG = lex yacc depend_includepath testcase_targets import_plugins import_qpa_plugin asset_catalogs rez prepare_docs qt_docs_targets qt_build_extra file_copies qmake_use qt warn_on release link_prl app_bundle incremental global_init_link_order lib_version_first sdk clang_pch_style shared shared qt_framework release macos osx macx mac darwin unix posix gcc clang llvm sse2 aesni sse3 ssse3 sse4_1 sse4_2 avx avx2 avx512f avx512bw avx512cd avx512dq avx512er avx512ifma avx512pf avx512vbmi avx512vl compile_examples f16c force_debug_info largefile precompile_header rdrnd shani x86SimdAlways prefix_build force_independent no_warn_empty_obj_files utf8_source create_prl link_prl no_private_qt_headers_warning QTDIR_build qt_example_installs exceptions_off testcase_exceptions explicitlib testcase_no_bundle warning_clean simd optimize_full opengl generated_privates module_frameworks lib_bundle relative_qt_rpath app_extension_api_only git_build target_qt c++11 strict_c++ c++14 c99 c11 hide_symbols separate_debug_info need_fwd_pri qt_install_module create_cmake sliced_bundle compiler_supports_fpmath create_pc qt_tracepoints have_target dll debug_info objective_c any_bundle arch_haswell avx512common avx512core thread moc resources
QMAKE_PRL_VERSION = 5.14.2
QMAKE_PRL_LIBS = -F$$[QT_INSTALL_LIBS] -framework AppKit -framework Metal -framework QtCore -framework DiskArbitration -framework IOKit
QMAKE_PRL_LIBS_FOR_CMAKE = -F$$[QT_INSTALL_LIBS];-framework;AppKit;-framework;Metal;-framework;QtCore;-framework;DiskArbitration;-framework;IOKit;

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>QtGui</string>
<key>CFBundleIdentifier</key>
<string>org.qt-project.QtGui</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>5.14</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>5.14.2</string>
<key>NOTE</key>
<string>Please, do NOT change this file -- It was generated by Qt/QMake.</string>
</dict>
</plist>

View File

@@ -0,0 +1,7 @@
QMAKE_PRO_INPUT = gui.pro
QMAKE_PRL_TARGET = QtGui
QMAKE_PRL_CONFIG = lex yacc depend_includepath testcase_targets import_plugins import_qpa_plugin asset_catalogs rez prepare_docs qt_docs_targets qt_build_extra file_copies qmake_use qt warn_on release link_prl app_bundle incremental global_init_link_order lib_version_first sdk clang_pch_style shared shared qt_framework release macos osx macx mac darwin unix posix gcc clang llvm sse2 aesni sse3 ssse3 sse4_1 sse4_2 avx avx2 avx512f avx512bw avx512cd avx512dq avx512er avx512ifma avx512pf avx512vbmi avx512vl compile_examples f16c force_debug_info largefile precompile_header rdrnd shani x86SimdAlways prefix_build force_independent no_warn_empty_obj_files utf8_source create_prl link_prl no_private_qt_headers_warning QTDIR_build qt_example_installs exceptions_off testcase_exceptions explicitlib testcase_no_bundle warning_clean simd optimize_full opengl generated_privates module_frameworks lib_bundle relative_qt_rpath app_extension_api_only git_build target_qt c++11 strict_c++ c++14 c99 c11 hide_symbols separate_debug_info need_fwd_pri qt_install_module create_cmake sliced_bundle compiler_supports_fpmath create_pc qt_tracepoints have_target dll debug_info objective_c any_bundle arch_haswell avx512common avx512core thread moc resources
QMAKE_PRL_VERSION = 5.14.2
QMAKE_PRL_LIBS = -F$$[QT_INSTALL_LIBS] -framework AppKit -framework Metal -framework QtCore -framework DiskArbitration -framework IOKit
QMAKE_PRL_LIBS_FOR_CMAKE = -F$$[QT_INSTALL_LIBS];-framework;AppKit;-framework;Metal;-framework;QtCore;-framework;DiskArbitration;-framework;IOKit;

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>QtGui</string>
<key>CFBundleIdentifier</key>
<string>org.qt-project.QtGui</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>5.14</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>5.14.2</string>
<key>NOTE</key>
<string>Please, do NOT change this file -- It was generated by Qt/QMake.</string>
</dict>
</plist>

View File

@@ -0,0 +1,7 @@
QMAKE_PRO_INPUT = gui.pro
QMAKE_PRL_TARGET = QtGui
QMAKE_PRL_CONFIG = lex yacc depend_includepath testcase_targets import_plugins import_qpa_plugin asset_catalogs rez prepare_docs qt_docs_targets qt_build_extra file_copies qmake_use qt warn_on release link_prl app_bundle incremental global_init_link_order lib_version_first sdk clang_pch_style shared shared qt_framework release macos osx macx mac darwin unix posix gcc clang llvm sse2 aesni sse3 ssse3 sse4_1 sse4_2 avx avx2 avx512f avx512bw avx512cd avx512dq avx512er avx512ifma avx512pf avx512vbmi avx512vl compile_examples f16c force_debug_info largefile precompile_header rdrnd shani x86SimdAlways prefix_build force_independent no_warn_empty_obj_files utf8_source create_prl link_prl no_private_qt_headers_warning QTDIR_build qt_example_installs exceptions_off testcase_exceptions explicitlib testcase_no_bundle warning_clean simd optimize_full opengl generated_privates module_frameworks lib_bundle relative_qt_rpath app_extension_api_only git_build target_qt c++11 strict_c++ c++14 c99 c11 hide_symbols separate_debug_info need_fwd_pri qt_install_module create_cmake sliced_bundle compiler_supports_fpmath create_pc qt_tracepoints have_target dll debug_info objective_c any_bundle arch_haswell avx512common avx512core thread moc resources
QMAKE_PRL_VERSION = 5.14.2
QMAKE_PRL_LIBS = -F$$[QT_INSTALL_LIBS] -framework AppKit -framework Metal -framework QtCore -framework DiskArbitration -framework IOKit
QMAKE_PRL_LIBS_FOR_CMAKE = -F$$[QT_INSTALL_LIBS];-framework;AppKit;-framework;Metal;-framework;QtCore;-framework;DiskArbitration;-framework;IOKit;

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>QtNetwork</string>
<key>CFBundleIdentifier</key>
<string>org.qt-project.QtNetwork</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>5.14</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>5.14.2</string>
<key>NOTE</key>
<string>Please, do NOT change this file -- It was generated by Qt/QMake.</string>
</dict>
</plist>

View File

@@ -0,0 +1,7 @@
QMAKE_PRO_INPUT = network.pro
QMAKE_PRL_TARGET = QtNetwork
QMAKE_PRL_CONFIG = lex yacc depend_includepath testcase_targets import_plugins import_qpa_plugin asset_catalogs rez prepare_docs qt_docs_targets qt_build_extra file_copies qmake_use qt warn_on release link_prl app_bundle incremental global_init_link_order lib_version_first sdk clang_pch_style shared shared qt_framework release macos osx macx mac darwin unix posix gcc clang llvm sse2 aesni sse3 ssse3 sse4_1 sse4_2 avx avx2 avx512f avx512bw avx512cd avx512dq avx512er avx512ifma avx512pf avx512vbmi avx512vl compile_examples f16c force_debug_info largefile precompile_header rdrnd shani x86SimdAlways prefix_build force_independent no_warn_empty_obj_files utf8_source create_prl link_prl no_private_qt_headers_warning QTDIR_build qt_example_installs exceptions_off testcase_exceptions explicitlib testcase_no_bundle warning_clean generated_privates module_frameworks lib_bundle relative_qt_rpath app_extension_api_only git_build target_qt c++11 strict_c++ c++14 c99 c11 hide_symbols separate_debug_info need_fwd_pri qt_install_module create_cmake sliced_bundle compiler_supports_fpmath create_pc have_target dll debug_info objective_c any_bundle thread moc resources
QMAKE_PRL_VERSION = 5.14.2
QMAKE_PRL_LIBS = -F$$[QT_INSTALL_LIBS] -framework QtCore -framework DiskArbitration -framework IOKit
QMAKE_PRL_LIBS_FOR_CMAKE = -F$$[QT_INSTALL_LIBS];-framework;QtCore;-framework;DiskArbitration;-framework;IOKit;

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>QtNetwork</string>
<key>CFBundleIdentifier</key>
<string>org.qt-project.QtNetwork</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>5.14</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>5.14.2</string>
<key>NOTE</key>
<string>Please, do NOT change this file -- It was generated by Qt/QMake.</string>
</dict>
</plist>

View File

@@ -0,0 +1,7 @@
QMAKE_PRO_INPUT = network.pro
QMAKE_PRL_TARGET = QtNetwork
QMAKE_PRL_CONFIG = lex yacc depend_includepath testcase_targets import_plugins import_qpa_plugin asset_catalogs rez prepare_docs qt_docs_targets qt_build_extra file_copies qmake_use qt warn_on release link_prl app_bundle incremental global_init_link_order lib_version_first sdk clang_pch_style shared shared qt_framework release macos osx macx mac darwin unix posix gcc clang llvm sse2 aesni sse3 ssse3 sse4_1 sse4_2 avx avx2 avx512f avx512bw avx512cd avx512dq avx512er avx512ifma avx512pf avx512vbmi avx512vl compile_examples f16c force_debug_info largefile precompile_header rdrnd shani x86SimdAlways prefix_build force_independent no_warn_empty_obj_files utf8_source create_prl link_prl no_private_qt_headers_warning QTDIR_build qt_example_installs exceptions_off testcase_exceptions explicitlib testcase_no_bundle warning_clean generated_privates module_frameworks lib_bundle relative_qt_rpath app_extension_api_only git_build target_qt c++11 strict_c++ c++14 c99 c11 hide_symbols separate_debug_info need_fwd_pri qt_install_module create_cmake sliced_bundle compiler_supports_fpmath create_pc have_target dll debug_info objective_c any_bundle thread moc resources
QMAKE_PRL_VERSION = 5.14.2
QMAKE_PRL_LIBS = -F$$[QT_INSTALL_LIBS] -framework QtCore -framework DiskArbitration -framework IOKit
QMAKE_PRL_LIBS_FOR_CMAKE = -F$$[QT_INSTALL_LIBS];-framework;QtCore;-framework;DiskArbitration;-framework;IOKit;

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>QtNetwork</string>
<key>CFBundleIdentifier</key>
<string>org.qt-project.QtNetwork</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>5.14</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>5.14.2</string>
<key>NOTE</key>
<string>Please, do NOT change this file -- It was generated by Qt/QMake.</string>
</dict>
</plist>

View File

@@ -0,0 +1,7 @@
QMAKE_PRO_INPUT = network.pro
QMAKE_PRL_TARGET = QtNetwork
QMAKE_PRL_CONFIG = lex yacc depend_includepath testcase_targets import_plugins import_qpa_plugin asset_catalogs rez prepare_docs qt_docs_targets qt_build_extra file_copies qmake_use qt warn_on release link_prl app_bundle incremental global_init_link_order lib_version_first sdk clang_pch_style shared shared qt_framework release macos osx macx mac darwin unix posix gcc clang llvm sse2 aesni sse3 ssse3 sse4_1 sse4_2 avx avx2 avx512f avx512bw avx512cd avx512dq avx512er avx512ifma avx512pf avx512vbmi avx512vl compile_examples f16c force_debug_info largefile precompile_header rdrnd shani x86SimdAlways prefix_build force_independent no_warn_empty_obj_files utf8_source create_prl link_prl no_private_qt_headers_warning QTDIR_build qt_example_installs exceptions_off testcase_exceptions explicitlib testcase_no_bundle warning_clean generated_privates module_frameworks lib_bundle relative_qt_rpath app_extension_api_only git_build target_qt c++11 strict_c++ c++14 c99 c11 hide_symbols separate_debug_info need_fwd_pri qt_install_module create_cmake sliced_bundle compiler_supports_fpmath create_pc have_target dll debug_info objective_c any_bundle thread moc resources
QMAKE_PRL_VERSION = 5.14.2
QMAKE_PRL_LIBS = -F$$[QT_INSTALL_LIBS] -framework QtCore -framework DiskArbitration -framework IOKit
QMAKE_PRL_LIBS_FOR_CMAKE = -F$$[QT_INSTALL_LIBS];-framework;QtCore;-framework;DiskArbitration;-framework;IOKit;

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>QtPrintSupport</string>
<key>CFBundleIdentifier</key>
<string>org.qt-project.QtPrintSupport</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>5.14</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>5.14.2</string>
<key>NOTE</key>
<string>Please, do NOT change this file -- It was generated by Qt/QMake.</string>
</dict>
</plist>

View File

@@ -0,0 +1,7 @@
QMAKE_PRO_INPUT = printsupport.pro
QMAKE_PRL_TARGET = QtPrintSupport
QMAKE_PRL_CONFIG = lex yacc depend_includepath testcase_targets import_plugins import_qpa_plugin asset_catalogs rez prepare_docs qt_docs_targets qt_build_extra file_copies qmake_use qt warn_on release link_prl app_bundle incremental global_init_link_order lib_version_first sdk clang_pch_style shared shared qt_framework release macos osx macx mac darwin unix posix gcc clang llvm sse2 aesni sse3 ssse3 sse4_1 sse4_2 avx avx2 avx512f avx512bw avx512cd avx512dq avx512er avx512ifma avx512pf avx512vbmi avx512vl compile_examples f16c force_debug_info largefile precompile_header rdrnd shani x86SimdAlways prefix_build force_independent no_warn_empty_obj_files utf8_source create_prl link_prl no_private_qt_headers_warning QTDIR_build qt_example_installs exceptions_off testcase_exceptions explicitlib testcase_no_bundle warning_clean generated_privates module_frameworks lib_bundle relative_qt_rpath app_extension_api_only git_build target_qt c++11 strict_c++ c++14 c99 c11 hide_symbols separate_debug_info need_fwd_pri qt_install_module create_cmake sliced_bundle compiler_supports_fpmath create_pc have_target dll debug_info objective_c any_bundle thread uic opengl moc resources
QMAKE_PRL_VERSION = 5.14.2
QMAKE_PRL_LIBS = -F$$[QT_INSTALL_LIBS] -framework QtWidgets -framework QtGui -framework AppKit -framework Metal -framework QtCore -framework DiskArbitration -framework IOKit
QMAKE_PRL_LIBS_FOR_CMAKE = -F$$[QT_INSTALL_LIBS];-framework;QtWidgets;-framework;QtGui;-framework;AppKit;-framework;Metal;-framework;QtCore;-framework;DiskArbitration;-framework;IOKit;

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>QtPrintSupport</string>
<key>CFBundleIdentifier</key>
<string>org.qt-project.QtPrintSupport</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>5.14</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>5.14.2</string>
<key>NOTE</key>
<string>Please, do NOT change this file -- It was generated by Qt/QMake.</string>
</dict>
</plist>

View File

@@ -0,0 +1,7 @@
QMAKE_PRO_INPUT = printsupport.pro
QMAKE_PRL_TARGET = QtPrintSupport
QMAKE_PRL_CONFIG = lex yacc depend_includepath testcase_targets import_plugins import_qpa_plugin asset_catalogs rez prepare_docs qt_docs_targets qt_build_extra file_copies qmake_use qt warn_on release link_prl app_bundle incremental global_init_link_order lib_version_first sdk clang_pch_style shared shared qt_framework release macos osx macx mac darwin unix posix gcc clang llvm sse2 aesni sse3 ssse3 sse4_1 sse4_2 avx avx2 avx512f avx512bw avx512cd avx512dq avx512er avx512ifma avx512pf avx512vbmi avx512vl compile_examples f16c force_debug_info largefile precompile_header rdrnd shani x86SimdAlways prefix_build force_independent no_warn_empty_obj_files utf8_source create_prl link_prl no_private_qt_headers_warning QTDIR_build qt_example_installs exceptions_off testcase_exceptions explicitlib testcase_no_bundle warning_clean generated_privates module_frameworks lib_bundle relative_qt_rpath app_extension_api_only git_build target_qt c++11 strict_c++ c++14 c99 c11 hide_symbols separate_debug_info need_fwd_pri qt_install_module create_cmake sliced_bundle compiler_supports_fpmath create_pc have_target dll debug_info objective_c any_bundle thread uic opengl moc resources
QMAKE_PRL_VERSION = 5.14.2
QMAKE_PRL_LIBS = -F$$[QT_INSTALL_LIBS] -framework QtWidgets -framework QtGui -framework AppKit -framework Metal -framework QtCore -framework DiskArbitration -framework IOKit
QMAKE_PRL_LIBS_FOR_CMAKE = -F$$[QT_INSTALL_LIBS];-framework;QtWidgets;-framework;QtGui;-framework;AppKit;-framework;Metal;-framework;QtCore;-framework;DiskArbitration;-framework;IOKit;

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>QtPrintSupport</string>
<key>CFBundleIdentifier</key>
<string>org.qt-project.QtPrintSupport</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>5.14</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>5.14.2</string>
<key>NOTE</key>
<string>Please, do NOT change this file -- It was generated by Qt/QMake.</string>
</dict>
</plist>

View File

@@ -0,0 +1,7 @@
QMAKE_PRO_INPUT = printsupport.pro
QMAKE_PRL_TARGET = QtPrintSupport
QMAKE_PRL_CONFIG = lex yacc depend_includepath testcase_targets import_plugins import_qpa_plugin asset_catalogs rez prepare_docs qt_docs_targets qt_build_extra file_copies qmake_use qt warn_on release link_prl app_bundle incremental global_init_link_order lib_version_first sdk clang_pch_style shared shared qt_framework release macos osx macx mac darwin unix posix gcc clang llvm sse2 aesni sse3 ssse3 sse4_1 sse4_2 avx avx2 avx512f avx512bw avx512cd avx512dq avx512er avx512ifma avx512pf avx512vbmi avx512vl compile_examples f16c force_debug_info largefile precompile_header rdrnd shani x86SimdAlways prefix_build force_independent no_warn_empty_obj_files utf8_source create_prl link_prl no_private_qt_headers_warning QTDIR_build qt_example_installs exceptions_off testcase_exceptions explicitlib testcase_no_bundle warning_clean generated_privates module_frameworks lib_bundle relative_qt_rpath app_extension_api_only git_build target_qt c++11 strict_c++ c++14 c99 c11 hide_symbols separate_debug_info need_fwd_pri qt_install_module create_cmake sliced_bundle compiler_supports_fpmath create_pc have_target dll debug_info objective_c any_bundle thread uic opengl moc resources
QMAKE_PRL_VERSION = 5.14.2
QMAKE_PRL_LIBS = -F$$[QT_INSTALL_LIBS] -framework QtWidgets -framework QtGui -framework AppKit -framework Metal -framework QtCore -framework DiskArbitration -framework IOKit
QMAKE_PRL_LIBS_FOR_CMAKE = -F$$[QT_INSTALL_LIBS];-framework;QtWidgets;-framework;QtGui;-framework;AppKit;-framework;Metal;-framework;QtCore;-framework;DiskArbitration;-framework;IOKit;

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>QtQml</string>
<key>CFBundleIdentifier</key>
<string>org.qt-project.QtQml</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>5.14</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>5.14.2</string>
<key>NOTE</key>
<string>Please, do NOT change this file -- It was generated by Qt/QMake.</string>
</dict>
</plist>

View File

@@ -0,0 +1,7 @@
QMAKE_PRO_INPUT = qml.pro
QMAKE_PRL_TARGET = QtQml
QMAKE_PRL_CONFIG = lex yacc depend_includepath testcase_targets import_plugins import_qpa_plugin asset_catalogs rez prepare_docs qt_docs_targets qt_build_extra file_copies qmake_use qt warn_on release link_prl app_bundle incremental global_init_link_order lib_version_first sdk clang_pch_style shared shared qt_framework release macos osx macx mac darwin unix posix gcc clang llvm sse2 aesni sse3 ssse3 sse4_1 sse4_2 avx avx2 avx512f avx512bw avx512cd avx512dq avx512er avx512ifma avx512pf avx512vbmi avx512vl compile_examples f16c force_debug_info largefile precompile_header rdrnd shani x86SimdAlways prefix_build force_independent no_warn_empty_obj_files utf8_source create_prl link_prl no_private_qt_headers_warning QTDIR_build qt_example_installs exceptions_off testcase_exceptions explicitlib testcase_no_bundle warning_clean python_available qt_tracepoints qlalr generated_privates module_frameworks lib_bundle relative_qt_rpath app_extension_api_only git_build target_qt c++11 strict_c++ c++14 c99 c11 hide_symbols separate_debug_info need_fwd_pri qt_install_module create_cmake sliced_bundle compiler_supports_fpmath create_pc have_target dll debug_info objective_c any_bundle thread moc resources
QMAKE_PRL_VERSION = 5.14.2
QMAKE_PRL_LIBS = -F$$[QT_INSTALL_LIBS] -framework QtNetwork -framework QtCore -framework DiskArbitration -framework IOKit
QMAKE_PRL_LIBS_FOR_CMAKE = -F$$[QT_INSTALL_LIBS];-framework;QtNetwork;-framework;QtCore;-framework;DiskArbitration;-framework;IOKit;

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>QtQml</string>
<key>CFBundleIdentifier</key>
<string>org.qt-project.QtQml</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>5.14</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>5.14.2</string>
<key>NOTE</key>
<string>Please, do NOT change this file -- It was generated by Qt/QMake.</string>
</dict>
</plist>

View File

@@ -0,0 +1,7 @@
QMAKE_PRO_INPUT = qml.pro
QMAKE_PRL_TARGET = QtQml
QMAKE_PRL_CONFIG = lex yacc depend_includepath testcase_targets import_plugins import_qpa_plugin asset_catalogs rez prepare_docs qt_docs_targets qt_build_extra file_copies qmake_use qt warn_on release link_prl app_bundle incremental global_init_link_order lib_version_first sdk clang_pch_style shared shared qt_framework release macos osx macx mac darwin unix posix gcc clang llvm sse2 aesni sse3 ssse3 sse4_1 sse4_2 avx avx2 avx512f avx512bw avx512cd avx512dq avx512er avx512ifma avx512pf avx512vbmi avx512vl compile_examples f16c force_debug_info largefile precompile_header rdrnd shani x86SimdAlways prefix_build force_independent no_warn_empty_obj_files utf8_source create_prl link_prl no_private_qt_headers_warning QTDIR_build qt_example_installs exceptions_off testcase_exceptions explicitlib testcase_no_bundle warning_clean python_available qt_tracepoints qlalr generated_privates module_frameworks lib_bundle relative_qt_rpath app_extension_api_only git_build target_qt c++11 strict_c++ c++14 c99 c11 hide_symbols separate_debug_info need_fwd_pri qt_install_module create_cmake sliced_bundle compiler_supports_fpmath create_pc have_target dll debug_info objective_c any_bundle thread moc resources
QMAKE_PRL_VERSION = 5.14.2
QMAKE_PRL_LIBS = -F$$[QT_INSTALL_LIBS] -framework QtNetwork -framework QtCore -framework DiskArbitration -framework IOKit
QMAKE_PRL_LIBS_FOR_CMAKE = -F$$[QT_INSTALL_LIBS];-framework;QtNetwork;-framework;QtCore;-framework;DiskArbitration;-framework;IOKit;

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>QtQml</string>
<key>CFBundleIdentifier</key>
<string>org.qt-project.QtQml</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>5.14</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>5.14.2</string>
<key>NOTE</key>
<string>Please, do NOT change this file -- It was generated by Qt/QMake.</string>
</dict>
</plist>

Some files were not shown because too many files have changed in this diff Show More