initialize
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
177
LVX6048/Install.md
Normal file
177
LVX6048/Install.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# LVX6048 → HA Monitoring Install
|
||||||
|
|
||||||
|
Target: Debian-family Linux (developed on Raspberry Pi CM5), two LVX6048s over USB, HA MQTT broker on the LAN.
|
||||||
|
|
||||||
|
> **Shortcut:** the top-level [`install.sh`](./install.sh) automates every section below. This document explains what it does and why, and how to do each step by hand if you need to diverge.
|
||||||
|
|
||||||
|
Path conventions in the snippets below use `$BASE` to mean the root of this package (e.g. `~/solar/LVX6048`). Every canonical file lives under `$BASE/` mirroring its system destination path — e.g. `$BASE/etc/udev/rules.d/99-lvx6048.rules` deploys to `/etc/udev/rules.d/99-lvx6048.rules`.
|
||||||
|
|
||||||
|
## 1. Install powermon
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv tool install --with bleak powermon
|
||||||
|
powermon --listProtocols # confirm PI18 present
|
||||||
|
```
|
||||||
|
|
||||||
|
`mpp-solar` is yanked from PyPI; `powermon` replaces it for both adhoc and daemon use.
|
||||||
|
|
||||||
|
## 2. udev rule + serial-keyed symlinks
|
||||||
|
|
||||||
|
The LVX6048 reports no USB serial string in the USB descriptor, so vid:pid matching applies to both units equally. Rather than pin devices by USB hub port (which breaks the moment you move a cable), we use the PI18 `ID` query — which *does* return each inverter's real serial number — and build stable `/dev/lvx6048-{1,2}` symlinks from that.
|
||||||
|
|
||||||
|
### 2a. udev rule — dialout access
|
||||||
|
|
||||||
|
The rule grants `dialout` access to any LVX6048 hidraw; the logical "which unit is this?" decision happens in `lvx-resolve-links` (next step).
|
||||||
|
|
||||||
|
Canonical: [`etc/udev/rules.d/99-lvx6048.rules`](./etc/udev/rules.d/99-lvx6048.rules). Deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -m 644 "$BASE/etc/udev/rules.d/99-lvx6048.rules" /etc/udev/rules.d/99-lvx6048.rules
|
||||||
|
sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=hidraw
|
||||||
|
ls -l /dev/hidraw* # both should be group=dialout, mode 0660
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2b. `lvx-resolve-links` — serial → symlink resolver
|
||||||
|
|
||||||
|
[`bin/lvx-resolve-links`](./bin/lvx-resolve-links) runs once at boot, globs `/dev/hidraw*`, sends PI18 `ID` to each, and creates:
|
||||||
|
|
||||||
|
```
|
||||||
|
/dev/lvx6048-1 → /dev/hidrawX (where X matches SERIAL_UNIT_1)
|
||||||
|
/dev/lvx6048-2 → /dev/hidrawX (where X matches SERIAL_UNIT_2)
|
||||||
|
```
|
||||||
|
|
||||||
|
A single resolver pass, exclusive, before any powermon service starts — sidesteps the collision you get if each service probes independently (each probe open-read-close would race the sibling service already holding the hidraw fd).
|
||||||
|
|
||||||
|
Deploy the script + systemd oneshot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -m 755 "$BASE/bin/lvx-resolve-links" /usr/local/sbin/lvx-resolve-links
|
||||||
|
# rewrite the shebang in the installed copy to point at powermon's venv python
|
||||||
|
sudo sed -i "1c#!$HOME/.local/share/uv/tools/powermon/bin/python" /usr/local/sbin/lvx-resolve-links
|
||||||
|
sudo install -m 644 "$BASE/etc/systemd/system/lvx-resolve-links.service" /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now lvx-resolve-links.service
|
||||||
|
ls -l /dev/lvx6048-* # both symlinks should now exist
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2c. Capturing a unit's serial
|
||||||
|
|
||||||
|
If you ever swap an inverter, capture the new serial and update `SERIAL_UNIT_*` in both `/usr/local/sbin/lvx-resolve-links` and [`lvx-flash/flash.py`](./lvx-flash/flash.py):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl stop powermon.service powermon2.service lvx-resolve-links.service
|
||||||
|
TMP=$(mktemp --suffix=.yaml)
|
||||||
|
printf 'loop: once\ndevice:\n name: probe\n port: {type: usb, path: /dev/hidraw0, protocol: PI18}\ncommands:\n - {command: ID, trigger: {loops: 1}}\n' > "$TMP"
|
||||||
|
~/.local/bin/powermon -C "$TMP" 2>&1 | grep serial_number
|
||||||
|
rm -f "$TMP"
|
||||||
|
sudo systemctl start lvx-resolve-links.service powermon.service powermon2.service
|
||||||
|
```
|
||||||
|
|
||||||
|
The two current serials are hard-coded as `SERIAL_UNIT_1` / `SERIAL_UNIT_2` in both files.
|
||||||
|
|
||||||
|
## 3. Smoke test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
powermon -C "$BASE/smoketest/console.yaml" # continuous console output (unit #1)
|
||||||
|
# or one-shot against either unit:
|
||||||
|
powermon --adhoc GS -C ~/.config/powermon/powermon.yaml
|
||||||
|
powermon --adhoc GS -C ~/.config/powermon/powermon2.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. powermon configs
|
||||||
|
|
||||||
|
Powermon only supports **one `device:` per config file**, so each inverter needs its own config + its own systemd unit.
|
||||||
|
|
||||||
|
Canonical (mode 600): [`config/powermon/powermon.yaml`](./config/powermon/powermon.yaml), [`config/powermon/powermon2.yaml`](./config/powermon/powermon2.yaml). The snapshots contain `<MQTT_BROKER_IP>` / `<MQTT_USER>` / `<MQTT_PASSWORD>` placeholders — fill them in after copying into place. Deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config/powermon
|
||||||
|
install -m 600 "$BASE/config/powermon/powermon.yaml" ~/.config/powermon/powermon.yaml
|
||||||
|
install -m 600 "$BASE/config/powermon/powermon2.yaml" ~/.config/powermon/powermon2.yaml
|
||||||
|
# edit the two files: replace <MQTT_BROKER_IP>, <MQTT_USER>, <MQTT_PASSWORD> placeholders
|
||||||
|
```
|
||||||
|
|
||||||
|
Key points (same for both, differ only in device name / path / topic prefix):
|
||||||
|
|
||||||
|
- `loop: 1` at top level — without it, powermon defaults to `"once"` and exits after one pass
|
||||||
|
- `device.port.path: /dev/lvx6048-1` (or `-2`) — resolved via the resolver symlinks from §2b, not via powermon's own wildcard-+-serial probing (which collides when two services run simultaneously)
|
||||||
|
- `device.port.protocol: PI18` — PI30 returns CRC errors on this unit
|
||||||
|
- MQTT `username`/`password` are HA Mosquitto add-on creds (broker rejects anonymous by default)
|
||||||
|
- One `hass`-formatted MQTT output per command; ~28 entities auto-discovered per inverter, published at `homeassistant/sensor/lvx6048_{1,2}_<field>/state`
|
||||||
|
- `entity_id_prefix` / topic = `lvx6048_1` for unit #1, `lvx6048_2` for unit #2
|
||||||
|
|
||||||
|
Poll cadence: `GS`/5s, `MOD`/10s, `ET`/60s, `PIRI`/300s.
|
||||||
|
|
||||||
|
## 5. Local patches (pinned to powermon 1.0.18, survive until `uv tool upgrade`)
|
||||||
|
|
||||||
|
The six patches are shipped as full file drop-ins under [`powermon-patches/`](./powermon-patches/) (see [its README](./powermon-patches/README.md) for per-file detail). To apply manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POWERMON_SITE="$(ls -d ~/.local/share/uv/tools/powermon/lib/python*/site-packages/powermon)"
|
||||||
|
install -m 644 "$BASE/powermon-patches/pi18.py" "$POWERMON_SITE/protocols/pi18.py"
|
||||||
|
install -m 644 "$BASE/powermon-patches/usbport.py" "$POWERMON_SITE/ports/usbport.py"
|
||||||
|
install -m 644 "$BASE/powermon-patches/mqttbroker.py" "$POWERMON_SITE/libs/mqttbroker.py"
|
||||||
|
install -m 644 "$BASE/powermon-patches/port_config_model.py" "$POWERMON_SITE/configmodel/port_config_model.py"
|
||||||
|
install -m 644 "$BASE/powermon-patches/ports_init.py" "$POWERMON_SITE/ports/__init__.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
What each patch fixes:
|
||||||
|
|
||||||
|
**a.** `protocols/pi18.py` ~line 323: `"DC/AC power direction"` → `"DC-AC power direction"`. The slash creates a bogus extra MQTT topic level.
|
||||||
|
|
||||||
|
**b.** `ports/usbport.py` `send_and_receive()`: (1) drain any leftover bytes from the hidraw fd before sending (non-blocking read loop swallowing `BlockingIOError`); (2) wrap the main `os.read` in the retry loop with `try: … except BlockingIOError: continue`. Without (1), a prior command's late HID bytes get parsed as the next command's reply → `KeyError` in response decoding. Without (2), an empty buffer on first read aborts the command.
|
||||||
|
|
||||||
|
**c.** `libs/mqttbroker.py`: broaden `connect()`'s `except ConnectionRefusedError` to `except (ConnectionRefusedError, OSError)`, and narrow `publish()`'s bare `except Exception` to `except (OSError, RuntimeError, ValueError)` with a clearer log message. Without this, any network blip to the broker (e.g. HA restart, `Errno 113 No route to host`) crashes the daemon.
|
||||||
|
|
||||||
|
**d.** `protocols/pi18.py`: append two new query commands to `QUERY_COMMANDS` — `FWS` (Fault and Warning Status, fault code + ~32 warning bits, cross-referenced with PI30 `QPGS` fault codes including `71 "Parallel version different"` and the rest of the `8x` parallel-comm family) and `PGS` (Parallel General Status, regex `PGS(\d+)$`, LVX6048-specific 30-field layout with only the high-confidence fields named and the rest exposed as raw strings). Bump `check_definitions_count(expected=24)` to `26`. These are used by `lvx-flash sync-check`.
|
||||||
|
|
||||||
|
**e.** `configmodel/port_config_model.py`: add `serial_number: None | str | int = Field(default=None)` to `UsbPortConfig`. The model is `NoExtraBaseModel` (extra-forbidden), so powermon rejects the `serial_number:` config key without this even though `USBPort.resolve_path` already consumes it.
|
||||||
|
|
||||||
|
**f.** `ports/__init__.py` `from_config()`: change `port_config['serial_number'] = serial_number` to be a fallback (`if port_config.get('serial_number') is None:`). The device-level `serial_number` is the logical HA identifier (`lvx6048_1`), the port-level one is the hardware PI18 serial (22-digit) — different fields, different purposes; they must not be conflated.
|
||||||
|
|
||||||
|
Patches (e) and (f) enable powermon's native wildcard-path + serial-matching flow, which we *don't* currently use — two services probing independently at startup race each other over the same hidraw. Our current setup uses the external `lvx-resolve-links` resolver (§2b) instead, so (e) and (f) are dormant but kept applied in case we ever want to use powermon's native resolver for a single-service deployment.
|
||||||
|
|
||||||
|
## 6. systemd services
|
||||||
|
|
||||||
|
One powermon unit per inverter, both gated on `lvx-resolve-links.service`. Canonical:
|
||||||
|
[`etc/systemd/system/powermon.service`](./etc/systemd/system/powermon.service),
|
||||||
|
[`powermon2.service`](./etc/systemd/system/powermon2.service),
|
||||||
|
[`lvx-resolve-links.service`](./etc/systemd/system/lvx-resolve-links.service),
|
||||||
|
[`powermon.service.d/10-resolver.conf`](./etc/systemd/system/powermon.service.d/10-resolver.conf) (and its sibling under `powermon2.service.d/`).
|
||||||
|
|
||||||
|
Deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -m 644 "$BASE/etc/systemd/system/powermon.service" /etc/systemd/system/
|
||||||
|
sudo install -m 644 "$BASE/etc/systemd/system/powermon2.service" /etc/systemd/system/
|
||||||
|
sudo mkdir -p /etc/systemd/system/powermon.service.d /etc/systemd/system/powermon2.service.d
|
||||||
|
sudo install -m 644 "$BASE/etc/systemd/system/powermon.service.d/10-resolver.conf" \
|
||||||
|
/etc/systemd/system/powermon.service.d/10-resolver.conf
|
||||||
|
sudo install -m 644 "$BASE/etc/systemd/system/powermon2.service.d/10-resolver.conf" \
|
||||||
|
/etc/systemd/system/powermon2.service.d/10-resolver.conf
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now powermon.service powermon2.service
|
||||||
|
journalctl -u powermon.service -u powermon2.service -f
|
||||||
|
```
|
||||||
|
|
||||||
|
The drop-in adds `After=lvx-resolve-links.service` + `Requires=lvx-resolve-links.service` so both powermon services wait for the symlinks before starting.
|
||||||
|
|
||||||
|
## 7. Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# live sample of what both units are publishing (12 s = ~2 full GS cycles per unit)
|
||||||
|
timeout 15 mosquitto_sub -h 10.0.0.41 -u mqtt -P <pass> \
|
||||||
|
-t 'homeassistant/sensor/+/state' -W 12 -v | grep lvx6048_
|
||||||
|
```
|
||||||
|
|
||||||
|
Should show ~58 messages (~29 per unit per GS cycle) over that window, with both `lvx6048_1_*` and `lvx6048_2_*` entities. In HA: **Settings → Devices & Services → MQTT** → two devices `LVX6048` (and `LVX6048 #2`), each with ~29 auto-discovered sensors.
|
||||||
|
|
||||||
|
## 8. Moving cables
|
||||||
|
|
||||||
|
Because identification is PI18-serial-based, cable / USB hub moves don't require config changes. After any cable shuffle:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart lvx-resolve-links.service powermon.service powermon2.service
|
||||||
|
```
|
||||||
|
|
||||||
|
The resolver re-probes, symlinks update, powermon services latch onto the correct device by symlink. No hardcoded hub-port references anywhere in the stack.
|
||||||
308
LVX6048/Monitoring.md
Normal file
308
LVX6048/Monitoring.md
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
# LVX6048 Monitoring & Control via Home Assistant
|
||||||
|
|
||||||
|
Integration plan for monitoring (and eventually controlling) 2x LVX6048 inverters from Home Assistant via a Raspberry Pi running a Python poller that publishes to MQTT.
|
||||||
|
|
||||||
|
Continue dev from the Raspberry Pi.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
[LVX6048 #1] ──USB─┐
|
||||||
|
├──► [Raspberry Pi] ──MQTT──► [Home Assistant]
|
||||||
|
[LVX6048 #2] ──USB─┘ (mpp-solar/powermon (auto-discovers
|
||||||
|
Python daemon, ~40 entities
|
||||||
|
systemd service) per inverter)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why USB (not RS485)
|
||||||
|
|
||||||
|
The LVX6048 is an MPP Solar / Voltronic-family unit. Its **documented, supported** comm path is USB-HID speaking the **PI18** protocol (some firmware revisions also accept PI30). The RS485 port exists on the hardware but is undocumented for external monitoring on this model — it's primarily intended for parallel/BMS comms. Use USB.
|
||||||
|
|
||||||
|
### Why mpp-solar
|
||||||
|
|
||||||
|
[`jblance/mpp-solar`](https://github.com/jblance/mpp-solar) is the canonical Python package for this inverter family. It ships:
|
||||||
|
|
||||||
|
- PI18 / PI18LVX / PI30 / PI30MAX protocol drivers (all known query + set commands)
|
||||||
|
- A `powermon` daemon for continuous polling
|
||||||
|
- Built-in MQTT publisher with **Home Assistant auto-discovery** topic format — no HA YAML needed, entities appear automatically
|
||||||
|
|
||||||
|
## Hardware Checklist
|
||||||
|
|
||||||
|
- [ ] Raspberry Pi (Pi 4 or Pi 5 recommended; Pi 3B+ works) running Raspberry Pi OS 64-bit
|
||||||
|
- [ ] microSD card (32GB+) or USB SSD
|
||||||
|
- [ ] 2x USB-A to USB-B cables (one per inverter; ~3ft typical — **keep short**, USB-HID is not spec'd for long runs)
|
||||||
|
- [ ] Pi physically located near the inverter cabinet
|
||||||
|
- [ ] Ethernet (preferred) or WiFi to reach the HA MQTT broker
|
||||||
|
- [ ] Existing MQTT broker running on HA (confirmed operational)
|
||||||
|
|
||||||
|
## Step 1 — Base Pi Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update && sudo apt upgrade -y
|
||||||
|
sudo apt install -y python3-pip python3-venv pipx git
|
||||||
|
pipx ensurepath
|
||||||
|
```
|
||||||
|
|
||||||
|
Reboot (or `exec $SHELL`) so `pipx` PATH takes effect.
|
||||||
|
|
||||||
|
## Step 2 — Install mpp-solar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pipx install mpp-solar
|
||||||
|
# verify
|
||||||
|
mpp-solar --version
|
||||||
|
mpp-solar --listProtocols
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect `PI18`, `PI18LVX`, `PI18SV`, `PI30`, `PI30MAX` in the protocol list.
|
||||||
|
|
||||||
|
## Step 3 — Plug in Inverters & Identify Them
|
||||||
|
|
||||||
|
Connect inverter #1 first, alone, and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -l /dev/hidraw*
|
||||||
|
lsusb
|
||||||
|
dmesg | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the `/dev/hidraw*` device that appeared and the USB VID:PID (typical MPP Solar is `0665:5161` — confirm on your unit).
|
||||||
|
|
||||||
|
Grab the serial number for a stable udev rule:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
udevadm info -a -n /dev/hidraw0 | grep -i serial | head -3
|
||||||
|
```
|
||||||
|
|
||||||
|
Write down **SERIAL_A**. Unplug #1, plug in #2, repeat to get **SERIAL_B**.
|
||||||
|
|
||||||
|
## Step 4 — Smoke Test
|
||||||
|
|
||||||
|
With inverter #1 still plugged in, query it directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mpp-solar -p /dev/hidraw0 -P PI18 -c GS
|
||||||
|
```
|
||||||
|
|
||||||
|
`GS` is the PI18 "General Status" query. Expected output: battery voltage, SoC, PV watts, load watts, grid voltage, inverter mode, etc. If `PI18` returns gibberish or CRC errors, try `PI30`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mpp-solar -p /dev/hidraw0 -P PI30 -c QPIGS
|
||||||
|
```
|
||||||
|
|
||||||
|
Whichever protocol returns clean data → that's the one to use in the config below. **Record which protocol worked.**
|
||||||
|
|
||||||
|
Also grab rated info once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mpp-solar -p /dev/hidraw0 -P PI18 -c PIRI # PI18 rated info
|
||||||
|
# or
|
||||||
|
mpp-solar -p /dev/hidraw0 -P PI30 -c QPIRI # PI30 rated info
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5 — Stable Device Names (udev)
|
||||||
|
|
||||||
|
Without this, `/dev/hidraw0` and `/dev/hidraw1` can swap on reboot, and the wrong inverter's data ends up under the wrong entity in HA.
|
||||||
|
|
||||||
|
> **As built on this CM5:** the LVX6048 returns no USB serial string, so serial-based matching below is aspirational. We ended up matching by USB hub port instead — see [`99-lvx6048.rules`](./99-lvx6048.rules) and `Install.md` §2.
|
||||||
|
|
||||||
|
Create `/etc/udev/rules.d/99-lvx6048.rules`:
|
||||||
|
|
||||||
|
```
|
||||||
|
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0665", ATTRS{idProduct}=="5161", ATTRS{serial}=="SERIAL_A", SYMLINK+="lvx6048-1", MODE="0660", GROUP="dialout"
|
||||||
|
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0665", ATTRS{idProduct}=="5161", ATTRS{serial}=="SERIAL_B", SYMLINK+="lvx6048-2", MODE="0660", GROUP="dialout"
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `SERIAL_A` / `SERIAL_B` and the VID:PID with the real values from Step 3. Then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo usermod -aG dialout $USER
|
||||||
|
sudo udevadm control --reload-rules
|
||||||
|
sudo udevadm trigger
|
||||||
|
ls -l /dev/lvx6048-*
|
||||||
|
```
|
||||||
|
|
||||||
|
Both symlinks should be present. Log out / back in so the `dialout` group applies.
|
||||||
|
|
||||||
|
## Step 6 — powermon Config
|
||||||
|
|
||||||
|
> **As built:** the `ports:` (plural) schema shown below does **not** work with current powermon — its config model allows exactly one `device:` per file. We ship one config file per inverter ([`powermon.yaml`](./powermon.yaml), [`powermon2.yaml`](./powermon2.yaml)) and run two systemd units. See `Install.md` §4–6.
|
||||||
|
|
||||||
|
Create `~/.config/powermon/powermon.yaml` (adjust MQTT creds and protocol name to match Step 4):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
device:
|
||||||
|
name: lvx6048_pair
|
||||||
|
id: solar_lvx6048
|
||||||
|
|
||||||
|
mqttbroker:
|
||||||
|
name: <HA_MQTT_BROKER_IP>
|
||||||
|
port: 1883
|
||||||
|
username: <MQTT_USER>
|
||||||
|
password: <MQTT_PASSWORD>
|
||||||
|
adhoc_topic: powermon/adhoc
|
||||||
|
adhoc_result_topic: powermon/adhoc/results
|
||||||
|
|
||||||
|
api:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
daemon:
|
||||||
|
type: systemd
|
||||||
|
keepalive: 60
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- name: lvx1
|
||||||
|
type: usb
|
||||||
|
path: /dev/lvx6048-1
|
||||||
|
protocol: PI18 # confirmed in Step 4
|
||||||
|
commands:
|
||||||
|
- command: GS
|
||||||
|
trigger: { every: 5 }
|
||||||
|
outputs: { name: mqtt_ha, type: hass, tag: lvx6048_1 }
|
||||||
|
- command: PIRI
|
||||||
|
trigger: { every: 300 }
|
||||||
|
outputs: { name: mqtt_ha, type: hass, tag: lvx6048_1 }
|
||||||
|
- command: MOD
|
||||||
|
trigger: { every: 10 }
|
||||||
|
outputs: { name: mqtt_ha, type: hass, tag: lvx6048_1 }
|
||||||
|
- command: FWS
|
||||||
|
trigger: { every: 30 }
|
||||||
|
outputs: { name: mqtt_ha, type: hass, tag: lvx6048_1 }
|
||||||
|
|
||||||
|
- name: lvx2
|
||||||
|
type: usb
|
||||||
|
path: /dev/lvx6048-2
|
||||||
|
protocol: PI18
|
||||||
|
commands:
|
||||||
|
- command: GS
|
||||||
|
trigger: { every: 5 }
|
||||||
|
outputs: { name: mqtt_ha, type: hass, tag: lvx6048_2 }
|
||||||
|
- command: PIRI
|
||||||
|
trigger: { every: 300 }
|
||||||
|
outputs: { name: mqtt_ha, type: hass, tag: lvx6048_2 }
|
||||||
|
- command: MOD
|
||||||
|
trigger: { every: 10 }
|
||||||
|
outputs: { name: mqtt_ha, type: hass, tag: lvx6048_2 }
|
||||||
|
- command: FWS
|
||||||
|
trigger: { every: 30 }
|
||||||
|
outputs: { name: mqtt_ha, type: hass, tag: lvx6048_2 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Command reference (PI18):**
|
||||||
|
- `GS` — General Status (voltages, currents, power, SoC, temps) → poll fast
|
||||||
|
- `PIRI` — Protocol / Rated Info (nameplate values) → poll slow
|
||||||
|
- `MOD` — Operating Mode (Grid / Battery / Fault / Standby) → poll medium
|
||||||
|
- `FWS` — Fault / Warning Status → poll medium
|
||||||
|
- `ET` — Total Energy (lifetime kWh) → optional, add with `every: 60`
|
||||||
|
- `ED` — Daily Energy → optional
|
||||||
|
|
||||||
|
Run it in the foreground once to confirm MQTT is flowing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
powermon -C ~/.config/powermon/powermon.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
In Home Assistant, go to **Settings → Devices & Services → MQTT** — two new devices (`lvx6048_1`, `lvx6048_2`) should appear with all their sensors auto-discovered.
|
||||||
|
|
||||||
|
## Step 7 — systemd Service
|
||||||
|
|
||||||
|
Create `/etc/systemd/system/powermon.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=powermon LVX6048 → MQTT bridge
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=pi
|
||||||
|
Group=dialout
|
||||||
|
ExecStart=/home/pi/.local/bin/powermon -C /home/pi/.config/powermon/powermon.yaml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
# Tolerate USB dropouts / HA broker restarts
|
||||||
|
StartLimitIntervalSec=300
|
||||||
|
StartLimitBurst=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Adjust `User=` / paths if not running as `pi`. Enable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now powermon.service
|
||||||
|
systemctl status powermon.service
|
||||||
|
journalctl -u powermon.service -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 8 — Home Assistant Dashboard
|
||||||
|
|
||||||
|
Entities auto-appear. Minimum useful Lovelace card:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: entities
|
||||||
|
title: Solar System
|
||||||
|
entities:
|
||||||
|
- sensor.lvx6048_1_battery_capacity # %
|
||||||
|
- sensor.lvx6048_1_battery_voltage # V
|
||||||
|
- sensor.lvx6048_1_pv_input_power # W
|
||||||
|
- sensor.lvx6048_1_ac_output_active_power # W
|
||||||
|
- sensor.lvx6048_1_grid_voltage # V
|
||||||
|
- sensor.lvx6048_1_inverter_heat_sink_temperature
|
||||||
|
- sensor.lvx6048_1_working_mode
|
||||||
|
- sensor.lvx6048_2_battery_capacity
|
||||||
|
- sensor.lvx6048_2_pv_input_power
|
||||||
|
- sensor.lvx6048_2_ac_output_active_power
|
||||||
|
- sensor.lvx6048_2_working_mode
|
||||||
|
```
|
||||||
|
|
||||||
|
Entity IDs will reflect the `tag:` values from the config. Exact names depend on protocol driver — check the MQTT integration page.
|
||||||
|
|
||||||
|
Recommended extras:
|
||||||
|
- **Energy dashboard** — map `ET` (total lifetime energy) as a solar production source
|
||||||
|
- **Automation**: alert when `working_mode` changes to `Fault` on either unit
|
||||||
|
- **Utility meter** helpers on `ac_output_active_power` for daily/weekly kWh
|
||||||
|
|
||||||
|
## Control (Deferred)
|
||||||
|
|
||||||
|
Once monitoring is stable, control commands are available via the same library. PI18 set commands include:
|
||||||
|
|
||||||
|
- `POP` — Output source priority (SUB / SBU / etc.)
|
||||||
|
- `PCP` — Charger priority (Solar first / Solar+Utility / only Solar)
|
||||||
|
- `MCHGC` — Max total charging current
|
||||||
|
- `MUCHGC` — Max utility charging current
|
||||||
|
- `PF` — Restore defaults (careful)
|
||||||
|
|
||||||
|
Exposed in HA as `button`/`select` entities via powermon's MQTT command topic. Plan this as a separate phase after at least a week of stable monitoring data.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Likely cause | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| `Permission denied` on `/dev/hidraw*` | User not in `dialout` | `sudo usermod -aG dialout $USER`, re-login |
|
||||||
|
| CRC errors / garbled output | Wrong protocol selected | Try `PI30` instead of `PI18` (or vice versa) |
|
||||||
|
| Device disappears after reboot | udev serial mismatch | Re-check `udevadm info -a`, verify serial string |
|
||||||
|
| Symlinks swap between units | Two units report same serial | Fall back to `KERNELS=="1-1.2"` USB-port matching |
|
||||||
|
| HA entities don't appear | MQTT discovery disabled or wrong prefix | Verify `discovery: true` in HA MQTT integration; default prefix is `homeassistant/` |
|
||||||
|
| Entities appear but never update | powermon service not actually running | `journalctl -u powermon.service -n 100` |
|
||||||
|
| One inverter works, other doesn't | Parallel-mode RS232 quirk | Query the *master* unit; the slave may not respond to USB queries while linked |
|
||||||
|
|
||||||
|
### Parallel-mode caveat
|
||||||
|
|
||||||
|
The 2x LVX6048s are wired in parallel (per `README.md`). In parallel mode, some MPP Solar firmware only responds to USB/PI commands on the **master** unit — the slave echoes the master's data or returns errors. If Step 4 smoke tests show one unit timing out, check the LCD to see which is master. If only the master responds, a single poller gives you combined system data; per-unit telemetry may require the parallel/sync RJ45 cable's side-channel (undocumented) or firmware that doesn't gate slave comms.
|
||||||
|
|
||||||
|
**Recommendation:** do Step 4 on each inverter individually (unplug parallel comm cable, query via USB, replug) to confirm both respond. Then test again with parallel cable connected.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [jblance/mpp-solar](https://github.com/jblance/mpp-solar) — Python library
|
||||||
|
- [powermon](https://github.com/jblance/powermon) — daemon mode (split out of mpp-solar)
|
||||||
|
- [Set commands on LVX6048 — discussion #344](https://github.com/jblance/mpp-solar/discussions/344)
|
||||||
|
- [LVX6048WP user manual](https://watts247.com/manuals/mpp/PIP-LVX6048WP/LVX6048WP-manual.pdf)
|
||||||
|
- [MPP Solar LVX6048 product page](https://www.mppsolar.com/v3/lvx6048/)
|
||||||
|
- [HA community — LVX6048 integration thread](https://community.home-assistant.io/t/lvx6048-inverter/851522)
|
||||||
|
- [Connecting to LVX6048WP via USB with Raspberry Pi](https://diysolarforum.com/threads/connecting-to-lvx6048wp-via-usb-with-raspberry-pi.46774/)
|
||||||
154
LVX6048/README.md
Normal file
154
LVX6048/README.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# LVX6048 → Home Assistant
|
||||||
|
|
||||||
|
Reproducible install package for monitoring 2× MPP Solar LVX6048 inverters over
|
||||||
|
USB-HID (PI18) via [`powermon`](https://github.com/jblance/powermon), publishing
|
||||||
|
to a Home Assistant MQTT broker with HA auto-discovery.
|
||||||
|
|
||||||
|
## What's in the box
|
||||||
|
|
||||||
|
```
|
||||||
|
LVX6048/
|
||||||
|
├── README.md ← start here
|
||||||
|
├── Install.md ← detailed walkthrough (what install.sh does)
|
||||||
|
├── Monitoring.md ← background / design notes
|
||||||
|
├── install.sh ← one-shot installer (idempotent, safe to re-run)
|
||||||
|
│
|
||||||
|
├── etc/ mirror of target system paths
|
||||||
|
│ ├── udev/rules.d/99-lvx6048.rules
|
||||||
|
│ └── systemd/system/
|
||||||
|
│ ├── lvx-resolve-links.service
|
||||||
|
│ ├── powermon.service
|
||||||
|
│ ├── powermon2.service
|
||||||
|
│ ├── powermon.service.d/10-resolver.conf
|
||||||
|
│ └── powermon2.service.d/10-resolver.conf
|
||||||
|
│
|
||||||
|
├── config/powermon/ lands at ~/.config/powermon/ (mode 600)
|
||||||
|
│ ├── powermon.yaml ← unit #1 — edit MQTT creds before deploying
|
||||||
|
│ └── powermon2.yaml ← unit #2 — edit MQTT creds before deploying
|
||||||
|
│
|
||||||
|
├── bin/
|
||||||
|
│ └── lvx-resolve-links ← installed as /usr/local/sbin/lvx-resolve-links
|
||||||
|
│ (install.sh rewrites the shebang to the uv-installed python)
|
||||||
|
│
|
||||||
|
├── powermon-patches/ ← drop-in files for the uv tool install
|
||||||
|
│ ├── README.md ← what each patch does + upgrade path
|
||||||
|
│ ├── pi18.py, usbport.py, mqttbroker.py,
|
||||||
|
│ └── port_config_model.py, ports_init.py
|
||||||
|
│
|
||||||
|
├── lvx-flash/ ← settings-profile CLI
|
||||||
|
│ ├── flash.py ← dump / diff / apply / compare / sync-check
|
||||||
|
│ ├── README.md
|
||||||
|
│ └── profiles/current.yaml
|
||||||
|
│
|
||||||
|
└── smoketest/ ← adhoc test configs (one-off powermon -C usage)
|
||||||
|
├── console.yaml
|
||||||
|
└── smoketest.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick start (reproducing on a fresh machine)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone / scp this folder into place (e.g. /home/<user>/solar/LVX6048)
|
||||||
|
cd ~/solar/LVX6048
|
||||||
|
|
||||||
|
# 2. Install uv if not already: https://docs.astral.sh/uv/
|
||||||
|
# 3. Run the installer
|
||||||
|
./install.sh
|
||||||
|
|
||||||
|
# 4. Edit the two things install.sh warns about:
|
||||||
|
# a. ~/.config/powermon/powermon{,2}.yaml — MQTT broker IP / user / password
|
||||||
|
# b. /usr/local/sbin/lvx-resolve-links — SERIAL_UNIT_1 / SERIAL_UNIT_2 (see below)
|
||||||
|
# lvx-flash/flash.py — same two constants
|
||||||
|
|
||||||
|
# 5. Capture each inverter's PI18 serial (with services stopped):
|
||||||
|
sudo systemctl stop lvx-resolve-links.service powermon.service powermon2.service
|
||||||
|
for d in /dev/hidraw0 /dev/hidraw1; do
|
||||||
|
TMP=$(mktemp --suffix=.yaml)
|
||||||
|
printf 'loop: once\ndevice:\n name: probe\n port: {type: usb, path: %s, protocol: PI18}\ncommands:\n - {command: ID, trigger: {loops: 1}}\n' "$d" > "$TMP"
|
||||||
|
echo "=== $d ==="
|
||||||
|
~/.local/bin/powermon -C "$TMP" 2>&1 | grep serial_number
|
||||||
|
rm -f "$TMP"; sleep 2
|
||||||
|
done
|
||||||
|
# Edit SERIAL_UNIT_{1,2} in /usr/local/sbin/lvx-resolve-links and lvx-flash/flash.py,
|
||||||
|
# then:
|
||||||
|
sudo systemctl start lvx-resolve-links.service powermon.service powermon2.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## How the pieces fit together
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────┐ ┌─────────────────────────┐
|
||||||
|
│ LVX6048 #1 (USB-HID) │ ──▶ │ /dev/hidraw{0|1} │
|
||||||
|
│ LVX6048 #2 (USB-HID) │ ──▶ │ (vid:pid 0665:5161) │
|
||||||
|
└─────────────────────────┘ └──────────┬──────────────┘
|
||||||
|
│
|
||||||
|
│ (99-lvx6048.rules → group=dialout)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ lvx-resolve-links.service │
|
||||||
|
│ (oneshot, runs before powermon) │
|
||||||
|
│ │
|
||||||
|
│ probes each hidraw w/ PI18 ID │
|
||||||
|
│ creates /dev/lvx6048-{1,2} │
|
||||||
|
│ symlinks keyed to serial │
|
||||||
|
└──────────┬───────────────────────┘
|
||||||
|
│
|
||||||
|
│ After= / Requires=
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ powermon.service (unit #1) │
|
||||||
|
│ powermon2.service (unit #2) │
|
||||||
|
│ │
|
||||||
|
│ poll GS / MOD / PIRI / ET │
|
||||||
|
│ publish to HA auto-discovery │
|
||||||
|
│ topics under homeassistant/* │
|
||||||
|
└──────────┬───────────────────────┘
|
||||||
|
│ MQTT (port 1883)
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Home Assistant Mosquitto broker │
|
||||||
|
│ ~29 auto-discovered sensors/unit │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Separate from the monitoring pipeline, **`lvx-flash/`** is a manual settings
|
||||||
|
tool: dump the inverter's current config into a YAML profile, diff against a
|
||||||
|
target profile, apply changes safely (stops powermon, writes via PI18 setters,
|
||||||
|
verifies via PIRI readback). Also supports `compare` (diff live settings
|
||||||
|
between two inverters) and `sync-check` (verify parallel-stack health).
|
||||||
|
|
||||||
|
## Cable moves
|
||||||
|
|
||||||
|
Identification is PI18-serial-based, so moving USB cables between hub ports
|
||||||
|
never requires config edits. After any cable shuffle:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart lvx-resolve-links.service powermon.service powermon2.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Replacing an inverter
|
||||||
|
|
||||||
|
When a unit is swapped, capture its new PI18 serial (see step 5 of Quick start)
|
||||||
|
and update the two `SERIAL_UNIT_*` constants in:
|
||||||
|
|
||||||
|
- `/usr/local/sbin/lvx-resolve-links`
|
||||||
|
- `~/solar/LVX6048/lvx-flash/flash.py`
|
||||||
|
|
||||||
|
then restart the three services.
|
||||||
|
|
||||||
|
## Next steps / not done
|
||||||
|
|
||||||
|
- **Firmware parity:** on this dev stack, unit #1 is at main=06303/slave=06126
|
||||||
|
and unit #2 is at 06440/06021. Parallel operation requires matching firmware
|
||||||
|
(fault code 71 "Parallel version different") — the sync kit's cables are
|
||||||
|
wired correctly, but the inverters won't phase-lock until both CPUs match.
|
||||||
|
Firmware upload is not part of this package (MPP Solar Windows-only tool).
|
||||||
|
- **PGS field layout:** the LVX6048-specific 30-field PGS response layout is
|
||||||
|
only partially decoded in `powermon-patches/pi18.py`. The key fields
|
||||||
|
(parallel_valid, fault_code, grid_hz, ac_output_voltage) are named; the rest
|
||||||
|
are exposed as raw strings.
|
||||||
|
- **Control / set commands via HA:** PI18 setters (POP, PCP, MCHGC, MUCHGC, PF)
|
||||||
|
are implemented in `lvx-flash/flash.py` for offline use, but aren't exposed
|
||||||
|
as HA button/select entities. Deferred until monitoring has been stable for
|
||||||
|
at least a week and firmware parity is restored.
|
||||||
98
LVX6048/bin/lvx-resolve-links
Executable file
98
LVX6048/bin/lvx-resolve-links
Executable file
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""lvx-resolve-links — create /dev/lvx6048-N symlinks keyed to PI18 serial.
|
||||||
|
|
||||||
|
Globs /dev/hidraw*, sends PI18 `ID` to each, and creates
|
||||||
|
/dev/lvx6048-1 -> /dev/hidrawX where X's serial matches SERIAL_UNIT_1
|
||||||
|
/dev/lvx6048-2 -> /dev/hidrawX where X's serial matches SERIAL_UNIT_2
|
||||||
|
|
||||||
|
Must run as root. Intended as a systemd oneshot before powermon*.service.
|
||||||
|
|
||||||
|
Runs a single discovery pass with exclusive access — unlike powermon's native
|
||||||
|
resolve_path which each service performs independently at startup, causing
|
||||||
|
collisions when a sibling service is already holding the target hidraw.
|
||||||
|
|
||||||
|
Edit SERIAL_UNIT_1 / SERIAL_UNIT_2 when a unit is replaced.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
SERIAL_UNIT_1 = "1496142109100037000000"
|
||||||
|
SERIAL_UNIT_2 = "1496142408100255000000"
|
||||||
|
|
||||||
|
LINK_FOR_SERIAL = {
|
||||||
|
SERIAL_UNIT_1: "/dev/lvx6048-1",
|
||||||
|
SERIAL_UNIT_2: "/dev/lvx6048-2",
|
||||||
|
}
|
||||||
|
|
||||||
|
sys.path.insert(0, "/home/noise/.local/share/uv/tools/powermon/lib/python3.11/site-packages")
|
||||||
|
from powermon.protocols import get_protocol_definition # noqa: E402
|
||||||
|
from powermon.ports.usbport import USBPort # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
async def probe_serial(path: str) -> str | None:
|
||||||
|
proto = get_protocol_definition(protocol="PI18")
|
||||||
|
port = USBPort(path=path, protocol=proto)
|
||||||
|
port.path = path
|
||||||
|
try:
|
||||||
|
await port.connect()
|
||||||
|
if not port.is_connected():
|
||||||
|
return None
|
||||||
|
cmd = proto.get_id_command()
|
||||||
|
res = await port.send_and_receive(command=cmd)
|
||||||
|
await port.disconnect()
|
||||||
|
except Exception as e:
|
||||||
|
print(f" probe {path}: {e.__class__.__name__}: {e}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
if res is None or not getattr(res, "is_valid", False) or not res.readings:
|
||||||
|
return None
|
||||||
|
return str(res.readings[0].data_value)
|
||||||
|
|
||||||
|
|
||||||
|
def _relink(link: str, target: str) -> None:
|
||||||
|
target_basename = os.path.basename(target)
|
||||||
|
try:
|
||||||
|
if os.path.islink(link) or os.path.exists(link):
|
||||||
|
os.unlink(link)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
os.symlink(target_basename, link)
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> int:
|
||||||
|
candidates = sorted(glob.glob("/dev/hidraw*"))
|
||||||
|
if not candidates:
|
||||||
|
print("no /dev/hidraw* devices present", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
sn_to_path: dict[str, str] = {}
|
||||||
|
for p in candidates:
|
||||||
|
sn = await probe_serial(p)
|
||||||
|
if sn:
|
||||||
|
print(f"{p}: serial {sn}")
|
||||||
|
sn_to_path[sn] = p
|
||||||
|
else:
|
||||||
|
print(f"{p}: no PI18 response (probably not an LVX6048)")
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
for sn, link in LINK_FOR_SERIAL.items():
|
||||||
|
if sn in sn_to_path:
|
||||||
|
_relink(link, sn_to_path[sn])
|
||||||
|
print(f"symlink {link} -> {os.path.basename(sn_to_path[sn])}")
|
||||||
|
else:
|
||||||
|
missing.append((link, sn))
|
||||||
|
try:
|
||||||
|
if os.path.islink(link):
|
||||||
|
os.unlink(link)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
print(f"WARNING: {link} serial {sn} not found on any /dev/hidraw*")
|
||||||
|
|
||||||
|
return 0 if not missing else 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(asyncio.run(main()))
|
||||||
62
LVX6048/config/powermon/powermon.yaml
Normal file
62
LVX6048/config/powermon/powermon.yaml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
loop: 1
|
||||||
|
|
||||||
|
device:
|
||||||
|
name: LVX6048 #1
|
||||||
|
serial_number: lvx6048_1
|
||||||
|
manufacturer: MPP Solar
|
||||||
|
model: LVX6048
|
||||||
|
port:
|
||||||
|
type: usb
|
||||||
|
path: /dev/lvx6048-1
|
||||||
|
protocol: PI18
|
||||||
|
|
||||||
|
mqttbroker:
|
||||||
|
name: <MQTT_BROKER_IP> # e.g. 10.0.0.41 (HA Mosquitto broker)
|
||||||
|
port: 1883
|
||||||
|
username: <MQTT_USER>
|
||||||
|
password: <MQTT_PASSWORD>
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- command: GS
|
||||||
|
trigger:
|
||||||
|
every: 5
|
||||||
|
outputs:
|
||||||
|
- type: mqtt
|
||||||
|
topic: powermon/lvx6048_1/GS
|
||||||
|
format:
|
||||||
|
type: hass
|
||||||
|
discovery_prefix: homeassistant
|
||||||
|
entity_id_prefix: lvx6048_1
|
||||||
|
|
||||||
|
- command: MOD
|
||||||
|
trigger:
|
||||||
|
every: 10
|
||||||
|
outputs:
|
||||||
|
- type: mqtt
|
||||||
|
topic: powermon/lvx6048_1/MOD
|
||||||
|
format:
|
||||||
|
type: hass
|
||||||
|
discovery_prefix: homeassistant
|
||||||
|
entity_id_prefix: lvx6048_1
|
||||||
|
|
||||||
|
- command: PIRI
|
||||||
|
trigger:
|
||||||
|
every: 300
|
||||||
|
outputs:
|
||||||
|
- type: mqtt
|
||||||
|
topic: powermon/lvx6048_1/PIRI
|
||||||
|
format:
|
||||||
|
type: hass
|
||||||
|
discovery_prefix: homeassistant
|
||||||
|
entity_id_prefix: lvx6048_1
|
||||||
|
|
||||||
|
- command: ET
|
||||||
|
trigger:
|
||||||
|
every: 60
|
||||||
|
outputs:
|
||||||
|
- type: mqtt
|
||||||
|
topic: powermon/lvx6048_1/ET
|
||||||
|
format:
|
||||||
|
type: hass
|
||||||
|
discovery_prefix: homeassistant
|
||||||
|
entity_id_prefix: lvx6048_1
|
||||||
62
LVX6048/config/powermon/powermon2.yaml
Normal file
62
LVX6048/config/powermon/powermon2.yaml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
loop: 1
|
||||||
|
|
||||||
|
device:
|
||||||
|
name: LVX6048 #2
|
||||||
|
serial_number: lvx6048_2
|
||||||
|
manufacturer: MPP Solar
|
||||||
|
model: LVX6048
|
||||||
|
port:
|
||||||
|
type: usb
|
||||||
|
path: /dev/lvx6048-2
|
||||||
|
protocol: PI18
|
||||||
|
|
||||||
|
mqttbroker:
|
||||||
|
name: <MQTT_BROKER_IP> # e.g. 10.0.0.41 (HA Mosquitto broker)
|
||||||
|
port: 1883
|
||||||
|
username: <MQTT_USER>
|
||||||
|
password: <MQTT_PASSWORD>
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- command: GS
|
||||||
|
trigger:
|
||||||
|
every: 5
|
||||||
|
outputs:
|
||||||
|
- type: mqtt
|
||||||
|
topic: powermon/lvx6048_2/GS
|
||||||
|
format:
|
||||||
|
type: hass
|
||||||
|
discovery_prefix: homeassistant
|
||||||
|
entity_id_prefix: lvx6048_2
|
||||||
|
|
||||||
|
- command: MOD
|
||||||
|
trigger:
|
||||||
|
every: 10
|
||||||
|
outputs:
|
||||||
|
- type: mqtt
|
||||||
|
topic: powermon/lvx6048_2/MOD
|
||||||
|
format:
|
||||||
|
type: hass
|
||||||
|
discovery_prefix: homeassistant
|
||||||
|
entity_id_prefix: lvx6048_2
|
||||||
|
|
||||||
|
- command: PIRI
|
||||||
|
trigger:
|
||||||
|
every: 300
|
||||||
|
outputs:
|
||||||
|
- type: mqtt
|
||||||
|
topic: powermon/lvx6048_2/PIRI
|
||||||
|
format:
|
||||||
|
type: hass
|
||||||
|
discovery_prefix: homeassistant
|
||||||
|
entity_id_prefix: lvx6048_2
|
||||||
|
|
||||||
|
- command: ET
|
||||||
|
trigger:
|
||||||
|
every: 60
|
||||||
|
outputs:
|
||||||
|
- type: mqtt
|
||||||
|
topic: powermon/lvx6048_2/ET
|
||||||
|
format:
|
||||||
|
type: hass
|
||||||
|
discovery_prefix: homeassistant
|
||||||
|
entity_id_prefix: lvx6048_2
|
||||||
13
LVX6048/etc/systemd/system/lvx-resolve-links.service
Normal file
13
LVX6048/etc/systemd/system/lvx-resolve-links.service
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Resolve LVX6048 hidraw symlinks by PI18 serial
|
||||||
|
After=systemd-udev-settle.service
|
||||||
|
Wants=systemd-udev-settle.service
|
||||||
|
# powermon services Requires= this unit, so they won't start until it succeeds.
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
ExecStart=/usr/local/sbin/lvx-resolve-links
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
15
LVX6048/etc/systemd/system/powermon.service
Normal file
15
LVX6048/etc/systemd/system/powermon.service
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=powermon LVX6048 -> MQTT bridge
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=noise
|
||||||
|
Group=dialout
|
||||||
|
ExecStart=/home/noise/.local/bin/powermon -C /home/noise/.config/powermon/powermon.yaml
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[Unit]
|
||||||
|
After=lvx-resolve-links.service
|
||||||
|
Requires=lvx-resolve-links.service
|
||||||
15
LVX6048/etc/systemd/system/powermon2.service
Normal file
15
LVX6048/etc/systemd/system/powermon2.service
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=powermon LVX6048 #2 -> MQTT bridge
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=noise
|
||||||
|
Group=dialout
|
||||||
|
ExecStart=/home/noise/.local/bin/powermon -C /home/noise/.config/powermon/powermon2.yaml
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[Unit]
|
||||||
|
After=lvx-resolve-links.service
|
||||||
|
Requires=lvx-resolve-links.service
|
||||||
6
LVX6048/etc/udev/rules.d/99-lvx6048.rules
Normal file
6
LVX6048/etc/udev/rules.d/99-lvx6048.rules
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# LVX6048 (MPP Solar / Voltronic) USB-HID — dialout access only.
|
||||||
|
# Logical unit identification is done via PI18 `ID` query at powermon startup
|
||||||
|
# (see powermon.yaml: path: /dev/hidraw*, serial_number: <SN>).
|
||||||
|
# No SYMLINK here — powermon resolves the right /dev/hidrawN by inverter serial,
|
||||||
|
# which survives cable moves / hub port changes.
|
||||||
|
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0665", ATTRS{idProduct}=="5161", MODE="0660", GROUP="dialout"
|
||||||
109
LVX6048/install.sh
Executable file
109
LVX6048/install.sh
Executable file
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# LVX6048 → Home Assistant monitoring — reproducible installer.
|
||||||
|
#
|
||||||
|
# Installs powermon + our patches, drops in udev rule / systemd units / resolver
|
||||||
|
# script / powermon configs, and starts the services. Idempotent: safe to re-run.
|
||||||
|
#
|
||||||
|
# Assumptions:
|
||||||
|
# - Debian-family Linux (Raspberry Pi OS, Ubuntu) with systemd
|
||||||
|
# - `uv` is installed and on $PATH (https://docs.astral.sh/uv/)
|
||||||
|
# - User has sudo
|
||||||
|
# - MQTT broker reachable (edit config/powermon/*.yaml first if the broker IP /
|
||||||
|
# credentials aren't yet in place)
|
||||||
|
#
|
||||||
|
# After install, finish by editing:
|
||||||
|
# ~/.config/powermon/powermon{,2}.yaml — MQTT broker + credentials
|
||||||
|
# /usr/local/sbin/lvx-resolve-links — SERIAL_UNIT_1 / SERIAL_UNIT_2
|
||||||
|
# lvx-flash/flash.py — SERIAL_UNIT_1 / SERIAL_UNIT_2
|
||||||
|
# then:
|
||||||
|
# sudo systemctl restart lvx-resolve-links powermon.service powermon2.service
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PINNED_POWERMON="1.0.18" # version we developed/tested patches against
|
||||||
|
BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
msg() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; }
|
||||||
|
|
||||||
|
# --- 1. powermon -----------------------------------------------------------
|
||||||
|
msg "Installing powermon ${PINNED_POWERMON} via uv"
|
||||||
|
command -v uv >/dev/null || { echo "uv not found — install from https://docs.astral.sh/uv/"; exit 1; }
|
||||||
|
uv tool install --with bleak "powermon==${PINNED_POWERMON}"
|
||||||
|
|
||||||
|
POWERMON_VENV="${HOME}/.local/share/uv/tools/powermon"
|
||||||
|
POWERMON_SITE="$(ls -d "${POWERMON_VENV}"/lib/python*/site-packages/powermon)"
|
||||||
|
POWERMON_PY="${POWERMON_VENV}/bin/python"
|
||||||
|
[ -d "$POWERMON_SITE" ] || { echo "could not locate powermon site-packages under ${POWERMON_VENV}"; exit 1; }
|
||||||
|
|
||||||
|
# --- 2. powermon patches ---------------------------------------------------
|
||||||
|
msg "Applying powermon patches into ${POWERMON_SITE}"
|
||||||
|
install -m 644 "${BASE}/powermon-patches/pi18.py" "${POWERMON_SITE}/protocols/pi18.py"
|
||||||
|
install -m 644 "${BASE}/powermon-patches/port_config_model.py" "${POWERMON_SITE}/configmodel/port_config_model.py"
|
||||||
|
install -m 644 "${BASE}/powermon-patches/ports_init.py" "${POWERMON_SITE}/ports/__init__.py"
|
||||||
|
install -m 644 "${BASE}/powermon-patches/usbport.py" "${POWERMON_SITE}/ports/usbport.py"
|
||||||
|
install -m 644 "${BASE}/powermon-patches/mqttbroker.py" "${POWERMON_SITE}/libs/mqttbroker.py"
|
||||||
|
|
||||||
|
# --- 3. udev (LVX6048 hidraw → dialout perms) ------------------------------
|
||||||
|
msg "Installing udev rule"
|
||||||
|
sudo install -m 644 "${BASE}/etc/udev/rules.d/99-lvx6048.rules" /etc/udev/rules.d/99-lvx6048.rules
|
||||||
|
sudo udevadm control --reload-rules
|
||||||
|
sudo udevadm trigger --subsystem-match=hidraw
|
||||||
|
|
||||||
|
# --- 4. resolver script (serial → /dev/lvx6048-N symlinks) -----------------
|
||||||
|
msg "Installing /usr/local/sbin/lvx-resolve-links (shebang → ${POWERMON_PY})"
|
||||||
|
TMP_RESOLVER="$(mktemp)"
|
||||||
|
trap 'rm -f "$TMP_RESOLVER"' EXIT
|
||||||
|
awk -v py="${POWERMON_PY}" 'NR==1 { print "#!" py; next } { print }' \
|
||||||
|
"${BASE}/bin/lvx-resolve-links" > "$TMP_RESOLVER"
|
||||||
|
sudo install -m 755 "$TMP_RESOLVER" /usr/local/sbin/lvx-resolve-links
|
||||||
|
|
||||||
|
# --- 5. systemd units + drop-ins -------------------------------------------
|
||||||
|
msg "Installing systemd units"
|
||||||
|
sudo install -m 644 "${BASE}/etc/systemd/system/lvx-resolve-links.service" /etc/systemd/system/
|
||||||
|
sudo install -m 644 "${BASE}/etc/systemd/system/powermon.service" /etc/systemd/system/
|
||||||
|
sudo install -m 644 "${BASE}/etc/systemd/system/powermon2.service" /etc/systemd/system/
|
||||||
|
sudo mkdir -p /etc/systemd/system/powermon.service.d /etc/systemd/system/powermon2.service.d
|
||||||
|
sudo install -m 644 "${BASE}/etc/systemd/system/powermon.service.d/10-resolver.conf" \
|
||||||
|
/etc/systemd/system/powermon.service.d/10-resolver.conf
|
||||||
|
sudo install -m 644 "${BASE}/etc/systemd/system/powermon2.service.d/10-resolver.conf" \
|
||||||
|
/etc/systemd/system/powermon2.service.d/10-resolver.conf
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
|
||||||
|
# --- 6. user configs (only if not already deployed; don't clobber creds) ---
|
||||||
|
msg "Installing powermon configs (skipping existing files — edit creds manually if new)"
|
||||||
|
mkdir -p "${HOME}/.config/powermon"
|
||||||
|
for f in powermon.yaml powermon2.yaml; do
|
||||||
|
dest="${HOME}/.config/powermon/${f}"
|
||||||
|
if [ -e "$dest" ]; then
|
||||||
|
echo " $dest already exists — not overwriting"
|
||||||
|
else
|
||||||
|
install -m 600 "${BASE}/config/powermon/${f}" "$dest"
|
||||||
|
echo " wrote $dest (mode 600) — edit MQTT credentials before enabling services"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- 7. enable services ----------------------------------------------------
|
||||||
|
msg "Enabling services"
|
||||||
|
sudo systemctl enable --now lvx-resolve-links.service
|
||||||
|
# Only auto-start powermon services if credentials look real (no placeholder present)
|
||||||
|
if grep -q '<MQTT_PASSWORD>' "${HOME}/.config/powermon/powermon.yaml" 2>/dev/null; then
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Credentials in ~/.config/powermon/powermon.yaml still contain a placeholder.
|
||||||
|
Edit that file + powermon2.yaml, then:
|
||||||
|
|
||||||
|
sudo systemctl enable --now powermon.service powermon2.service
|
||||||
|
journalctl -u powermon.service -u powermon2.service -f
|
||||||
|
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
sudo systemctl enable --now powermon.service powermon2.service
|
||||||
|
sleep 3
|
||||||
|
systemctl --no-pager status powermon.service powermon2.service | head -30
|
||||||
|
fi
|
||||||
|
|
||||||
|
msg "Done"
|
||||||
|
echo "Next steps (if any of these don't match your hardware, edit and restart):"
|
||||||
|
echo " - /usr/local/sbin/lvx-resolve-links : SERIAL_UNIT_1 / SERIAL_UNIT_2"
|
||||||
|
echo " - ${BASE}/lvx-flash/flash.py : SERIAL_UNIT_1 / SERIAL_UNIT_2"
|
||||||
|
echo " - to re-probe after a cable move : sudo systemctl restart lvx-resolve-links.service powermon.service powermon2.service"
|
||||||
73
LVX6048/lvx-flash/README.md
Normal file
73
LVX6048/lvx-flash/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# lvx-flash
|
||||||
|
|
||||||
|
Apply declarative YAML settings profiles to an LVX6048 inverter via PI18.
|
||||||
|
|
||||||
|
Reuses the patched `powermon` install at `~/.local/share/uv/tools/powermon/` — the shebang in `flash.py` points there, so no extra setup is needed.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Snapshot the inverter's current settings into an editable profile.
|
||||||
|
./flash.py dump --out profiles/current.yaml
|
||||||
|
|
||||||
|
# 2. Copy/edit. Preview the diff against the live device at any time.
|
||||||
|
./flash.py diff --profile profiles/winter.yaml
|
||||||
|
|
||||||
|
# 3. Apply. The tool stops powermon.service for the duration, writes each
|
||||||
|
# changed setting via the corresponding PI18 set command, verifies via
|
||||||
|
# PIRI readback, and restarts powermon.service at the end.
|
||||||
|
./flash.py apply --profile profiles/winter.yaml --confirm
|
||||||
|
|
||||||
|
# 4. Compare two inverters live (for parallel setups). Exits 0 if identical,
|
||||||
|
# 1 if divergent. Useful as a quick "are the two in sync?" check.
|
||||||
|
./flash.py compare --device-a /dev/lvx6048-1 --device-b /dev/lvx6048-2
|
||||||
|
|
||||||
|
# 5. Runtime sync-check for paralleled units. Reads GS+FWS+MOD+VFW from each
|
||||||
|
# and reports firmware mismatch, parallel-valid flag, active fault codes,
|
||||||
|
# mode mismatch, AC voltage/frequency divergence. Exits 0 on pass, 1 on fail.
|
||||||
|
./flash.py sync-check --device-a /dev/lvx6048-1 --device-b /dev/lvx6048-2
|
||||||
|
```
|
||||||
|
|
||||||
|
Device selection on every subcommand:
|
||||||
|
|
||||||
|
- `--serial SN` (default — uses `SERIAL_UNIT_1` for single-device commands, `SERIAL_UNIT_{1,2}` for pairs) — globs `/dev/hidraw*` and picks the one whose PI18 `ID` matches. Resilient to cable moves.
|
||||||
|
- `--device PATH` — explicit hidraw path, skips auto-resolution. Useful for probing an unknown unit.
|
||||||
|
|
||||||
|
The known-stack serials are hard-coded as `SERIAL_UNIT_1` / `SERIAL_UNIT_2` constants at the top of `flash.py`; edit them if a unit is replaced.
|
||||||
|
|
||||||
|
`sync-check` depends on the `FWS` / `PGS` additions to `powermon/protocols/pi18.py`; the `--serial` flow depends on the `UsbPortConfig.serial_number` field addition. Both are described in `Install.md` §5(d)(e).
|
||||||
|
|
||||||
|
## Profile schema
|
||||||
|
|
||||||
|
All keys are optional. Only present keys are applied; the rest are left alone.
|
||||||
|
|
||||||
|
| key | range / enum | PI18 setter |
|
||||||
|
|----------------------------------|------------------------------------------------------------------|-------------|
|
||||||
|
| `battery_type` | `AGM` \| `FLOODED` \| `USER` | PBT |
|
||||||
|
| `cutoff_voltage` | 40.0 – 48.0 V | PSDV |
|
||||||
|
| `stop_discharge_voltage` | 44.0 – 51.0 V | BUCD (pair) |
|
||||||
|
| `stop_charge_voltage` | 0 (full) or 48.0 – 58.0 V | BUCD (pair) |
|
||||||
|
| `bulk_voltage` | 48.0 – 58.4 V | MCHGV (pair)|
|
||||||
|
| `float_voltage` | 48.0 – 58.4 V (≤ bulk_voltage) | MCHGV (pair)|
|
||||||
|
| `max_charging_current` | 10, 20, 30, 40, 50, 60, 70, 80 A | MCHGC |
|
||||||
|
| `max_utility_charging_current` | 2, 10, 20, 30, 40, 50, 60, 70, 80 A | MUCHGC |
|
||||||
|
| `output_source_priority` | `solar_utility_battery` \| `solar_battery_utility` | POP |
|
||||||
|
| `charger_priority` | `solar_first` \| `solar_and_utility` \| `solar_only` | PCP |
|
||||||
|
| `solar_power_priority` | `battery_load_utility_ac` \| `load_battery_utility` | PSP |
|
||||||
|
| `grid_tie` | `enabled` \| `disabled` | PEI / PDI |
|
||||||
|
|
||||||
|
Pair settings (BUCD, MCHGV) must have both halves present.
|
||||||
|
|
||||||
|
## Safety
|
||||||
|
|
||||||
|
- `apply` refuses to run without `--confirm`.
|
||||||
|
- Range + consistency validation runs before any write. A failure aborts before touching the inverter.
|
||||||
|
- Each write is followed by a PIRI readback that must match to within 0.05 V. A mismatch aborts.
|
||||||
|
- Settings are applied in an order that's safe for a battery: cutoff → BUCD → MCHGV → currents → priorities → grid-tie mode.
|
||||||
|
- `powermon.service` is stopped for the write window so the two processes don't fight over `/dev/lvx6048-1`, then restarted even if the run aborts.
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
- Parallel setups: PCP / MCHGC / MUCHGC are indexed by parallel unit (the tool sends unit 0 — master). Some firmware only accepts sets on the master; the slave mirrors silently.
|
||||||
|
- `PIRI` reports `max_charging_current` as the effective combined (solar + AC) cap, which can exceed MCHGC's 80 A range. `dump` omits it with a comment when that happens.
|
||||||
|
- Changing `battery_type` while the unit is actively charging is generally allowed by the firmware but not recommended.
|
||||||
724
LVX6048/lvx-flash/flash.py
Executable file
724
LVX6048/lvx-flash/flash.py
Executable file
@@ -0,0 +1,724 @@
|
|||||||
|
#!/home/noise/.local/share/uv/tools/powermon/bin/python
|
||||||
|
"""
|
||||||
|
lvx-flash — apply a YAML settings profile to an LVX6048 via PI18 over USB-HID.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
./flash.py dump --device /dev/lvx6048-1 --out profiles/current.yaml
|
||||||
|
./flash.py diff --device /dev/lvx6048-1 --profile profiles/X.yaml
|
||||||
|
./flash.py apply --device /dev/lvx6048-1 --profile profiles/X.yaml --confirm
|
||||||
|
./flash.py compare --device-a /dev/lvx6048-1 --device-b /dev/lvx6048-2
|
||||||
|
./flash.py sync-check --device-a /dev/lvx6048-1 --device-b /dev/lvx6048-2
|
||||||
|
|
||||||
|
Safety:
|
||||||
|
- `apply` stops powermon.service for the duration and restarts it afterwards.
|
||||||
|
- Every set is followed by a PIRI readback that must match, or the run aborts.
|
||||||
|
- Values are range-checked before any write.
|
||||||
|
- Settings are applied in a safe order (cutoff < stop-discharge < float < bulk).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from powermon.commands.command import Command
|
||||||
|
from powermon.commands.result import ResultType
|
||||||
|
from powermon.protocols import get_protocol_definition
|
||||||
|
from powermon.ports.usbport import USBPort
|
||||||
|
|
||||||
|
|
||||||
|
PROTOCOL = "PI18"
|
||||||
|
UNIT_INDEX = 0 # parallel-stack unit index for PCP / MCHGC / MUCHGC. Master = 0.
|
||||||
|
|
||||||
|
# Default serial numbers for this stack. Used when --device / --serial are
|
||||||
|
# omitted; each command will glob /dev/hidraw* and probe PI18 ID on each to
|
||||||
|
# find the matching unit, so cable/hub-port moves do not require reconfig.
|
||||||
|
SERIAL_UNIT_1 = "1496142109100037000000"
|
||||||
|
SERIAL_UNIT_2 = "1496142408100255000000"
|
||||||
|
|
||||||
|
# -------- profile-key → PI18 encoding + PIRI readback index --------
|
||||||
|
|
||||||
|
# PIRI test response has 26 fields; indices below are 0-based.
|
||||||
|
# Source: powermon/protocols/pi18.py :: QUERY_COMMANDS["PIRI"].reading_definitions
|
||||||
|
PIRI = {
|
||||||
|
"battery_voltage": 7, # V (rated, read-only)
|
||||||
|
"stop_discharge_voltage": 8, # V (BUCD field 1, a.k.a. "re-charge")
|
||||||
|
"stop_charge_voltage": 9, # V (BUCD field 2, a.k.a. "re-discharge"; 0 = Full)
|
||||||
|
"cutoff_voltage": 10, # V (PSDV)
|
||||||
|
"bulk_voltage": 11, # V (MCHGV field 1)
|
||||||
|
"float_voltage": 12, # V (MCHGV field 2)
|
||||||
|
"battery_type": 13, # enum index (PBT)
|
||||||
|
"max_utility_charging_current": 14, # A (MUCHGC)
|
||||||
|
"max_charging_current": 15, # A (MCHGC)
|
||||||
|
"output_source_priority": 17, # enum index (POP)
|
||||||
|
"charger_priority": 18, # enum index (PCP)
|
||||||
|
"machine_type": 20, # enum index (PEI/PDI: 0=Off Grid, 1=Grid Tie)
|
||||||
|
"solar_power_priority": 23, # enum index (PSP)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Per-key guidance emitted as inline comments by `dump`.
|
||||||
|
# Keep short — one line each, phrased as constraints.
|
||||||
|
KEY_DOCS: dict[str, str] = {
|
||||||
|
"battery_type": "enum: AGM | FLOODED | USER",
|
||||||
|
"cutoff_voltage": "V 40.0..48.0 — hard shutdown; must be < stop_discharge_voltage. Only honored when battery_type=USER.",
|
||||||
|
"stop_discharge_voltage": "V 44.0..51.0 — switch battery→grid below this (pair: stop_charge_voltage). Only honored when battery_type=USER.",
|
||||||
|
"stop_charge_voltage": "V 0 (=Full) or 48.0..58.0 — switch grid→battery above this (pair: stop_discharge_voltage). Only honored when battery_type=USER.",
|
||||||
|
"bulk_voltage": "V 48.0..58.4 — CC→CV transition (pair: float_voltage; must be >= float_voltage). Only honored when battery_type=USER.",
|
||||||
|
"float_voltage": "V 48.0..58.4 — held while on grid (pair: bulk_voltage; must be <= bulk_voltage). Only honored when battery_type=USER.",
|
||||||
|
"max_charging_current": "A 10,20,30,40,50,60,70,80 — combined solar+AC cap",
|
||||||
|
"max_utility_charging_current": "A 2,10,20,30,40,50,60,70,80 — grid-side cap only",
|
||||||
|
"output_source_priority": "enum: solar_utility_battery | solar_battery_utility",
|
||||||
|
"charger_priority": "enum: solar_first | solar_and_utility | solar_only",
|
||||||
|
"solar_power_priority": "enum: battery_load_utility_ac | load_battery_utility",
|
||||||
|
"grid_tie": "enum: enabled | disabled (PEI/PDI)",
|
||||||
|
}
|
||||||
|
|
||||||
|
POP_MAP = {"solar_utility_battery": "0", "solar_battery_utility": "01"}
|
||||||
|
PCP_MAP = {"solar_first": "0", "solar_and_utility": "1", "solar_only": "2"}
|
||||||
|
PSP_MAP = {"battery_load_utility_ac": "0", "load_battery_utility": "1"}
|
||||||
|
PBT_MAP = {"AGM": "0", "FLOODED": "1", "USER": "2"}
|
||||||
|
|
||||||
|
# enum indices in PIRI (for readback verification)
|
||||||
|
POP_PIRI = {"0": "solar_utility_battery", "1": "solar_battery_utility"}
|
||||||
|
PCP_PIRI = {"0": "solar_first", "1": "solar_and_utility", "2": "solar_only"}
|
||||||
|
PSP_PIRI = {"0": "battery_load_utility_ac", "1": "load_battery_utility"}
|
||||||
|
PBT_PIRI = {"0": "AGM", "1": "FLOODED", "2": "USER"}
|
||||||
|
MACHINE_PIRI = {"0": "off_grid", "1": "grid_tie"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Setting:
|
||||||
|
key: str
|
||||||
|
encoder: Callable[[Any], str] # profile value -> PI18 raw command (no prefix/CRC)
|
||||||
|
decoder: Callable[[str], Any] # PIRI raw field -> profile value
|
||||||
|
piri_field: int
|
||||||
|
pair_keys: tuple[str, ...] = () # if set, these keys are encoded together in one command
|
||||||
|
|
||||||
|
|
||||||
|
def _v_to_tenths(v: float | int) -> str:
|
||||||
|
return f"{int(round(float(v) * 10)):03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _tenths_to_v(raw: str) -> float:
|
||||||
|
return int(raw) / 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def _amps_to_str(a: int) -> str:
|
||||||
|
return f"{int(a):03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _amps_from_str(raw: str) -> int:
|
||||||
|
return int(raw)
|
||||||
|
|
||||||
|
|
||||||
|
# Applied in this order. Safe: set low-range protections before high-range; currents before priorities.
|
||||||
|
SCHEDULE: list[Setting] = [
|
||||||
|
Setting("battery_type",
|
||||||
|
encoder=lambda v: f"PBT{PBT_MAP[v]}",
|
||||||
|
decoder=lambda r: PBT_PIRI.get(r.lstrip("0") or "0", r),
|
||||||
|
piri_field=PIRI["battery_type"]),
|
||||||
|
Setting("cutoff_voltage",
|
||||||
|
encoder=lambda v: f"PSDV{_v_to_tenths(v)}",
|
||||||
|
decoder=_tenths_to_v,
|
||||||
|
piri_field=PIRI["cutoff_voltage"]),
|
||||||
|
# BUCD sets stop-discharge and stop-charge together
|
||||||
|
Setting("stop_discharge_voltage",
|
||||||
|
encoder=lambda pair: f"BUCD{_v_to_tenths(pair[0])},{'000' if pair[1] in (0,0.0) else _v_to_tenths(pair[1])}",
|
||||||
|
decoder=_tenths_to_v,
|
||||||
|
piri_field=PIRI["stop_discharge_voltage"],
|
||||||
|
pair_keys=("stop_discharge_voltage", "stop_charge_voltage")),
|
||||||
|
# MCHGV sets bulk and float together
|
||||||
|
Setting("bulk_voltage",
|
||||||
|
encoder=lambda pair: f"MCHGV{_v_to_tenths(pair[0])},{_v_to_tenths(pair[1])}",
|
||||||
|
decoder=_tenths_to_v,
|
||||||
|
piri_field=PIRI["bulk_voltage"],
|
||||||
|
pair_keys=("bulk_voltage", "float_voltage")),
|
||||||
|
Setting("max_utility_charging_current",
|
||||||
|
encoder=lambda a: f"MUCHGC{UNIT_INDEX},{_amps_to_str(a)}",
|
||||||
|
decoder=_amps_from_str,
|
||||||
|
piri_field=PIRI["max_utility_charging_current"]),
|
||||||
|
Setting("max_charging_current",
|
||||||
|
encoder=lambda a: f"MCHGC{UNIT_INDEX},{_amps_to_str(a)}",
|
||||||
|
decoder=_amps_from_str,
|
||||||
|
piri_field=PIRI["max_charging_current"]),
|
||||||
|
Setting("output_source_priority",
|
||||||
|
encoder=lambda v: f"POP{POP_MAP[v]}",
|
||||||
|
decoder=lambda r: POP_PIRI.get(r.lstrip("0") or "0", r),
|
||||||
|
piri_field=PIRI["output_source_priority"]),
|
||||||
|
Setting("charger_priority",
|
||||||
|
encoder=lambda v: f"PCP{UNIT_INDEX},{PCP_MAP[v]}",
|
||||||
|
decoder=lambda r: PCP_PIRI.get(r.lstrip("0") or "0", r),
|
||||||
|
piri_field=PIRI["charger_priority"]),
|
||||||
|
Setting("solar_power_priority",
|
||||||
|
encoder=lambda v: f"PSP{PSP_MAP[v]}",
|
||||||
|
decoder=lambda r: PSP_PIRI.get(r.lstrip("0") or "0", r),
|
||||||
|
piri_field=PIRI["solar_power_priority"]),
|
||||||
|
Setting("grid_tie",
|
||||||
|
encoder=lambda v: "PEI" if v == "enabled" else "PDI",
|
||||||
|
decoder=lambda r: "grid_tie" if r.lstrip("0") == "1" else "off_grid",
|
||||||
|
piri_field=PIRI["machine_type"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
# -------- range / consistency validation --------
|
||||||
|
|
||||||
|
def _validate(profile: dict) -> list[str]:
|
||||||
|
errs: list[str] = []
|
||||||
|
def rng(key, lo, hi):
|
||||||
|
if key in profile and not (lo <= profile[key] <= hi):
|
||||||
|
errs.append(f"{key}={profile[key]} out of range [{lo}, {hi}]")
|
||||||
|
|
||||||
|
rng("cutoff_voltage", 40.0, 48.0)
|
||||||
|
rng("stop_discharge_voltage", 44.0, 51.0)
|
||||||
|
if "stop_charge_voltage" in profile and profile["stop_charge_voltage"] != 0:
|
||||||
|
rng("stop_charge_voltage", 48.0, 58.0)
|
||||||
|
rng("bulk_voltage", 48.0, 58.4)
|
||||||
|
rng("float_voltage", 48.0, 58.4)
|
||||||
|
rng("max_charging_current", 10, 80)
|
||||||
|
rng("max_utility_charging_current", 2, 80)
|
||||||
|
|
||||||
|
if "cutoff_voltage" in profile and "stop_discharge_voltage" in profile:
|
||||||
|
if profile["cutoff_voltage"] >= profile["stop_discharge_voltage"]:
|
||||||
|
errs.append("cutoff_voltage must be < stop_discharge_voltage")
|
||||||
|
if "float_voltage" in profile and "bulk_voltage" in profile:
|
||||||
|
if profile["float_voltage"] > profile["bulk_voltage"]:
|
||||||
|
errs.append("float_voltage must be <= bulk_voltage")
|
||||||
|
if "max_charging_current" in profile and profile["max_charging_current"] not in (10,20,30,40,50,60,70,80):
|
||||||
|
errs.append("max_charging_current must be one of 10,20,...,80")
|
||||||
|
if "max_utility_charging_current" in profile and profile["max_utility_charging_current"] not in (2,10,20,30,40,50,60,70,80):
|
||||||
|
errs.append("max_utility_charging_current must be one of 2,10,20,...,80")
|
||||||
|
|
||||||
|
for k, allowed in [("output_source_priority", POP_MAP), ("charger_priority", PCP_MAP),
|
||||||
|
("solar_power_priority", PSP_MAP), ("battery_type", PBT_MAP)]:
|
||||||
|
if k in profile and profile[k] not in allowed:
|
||||||
|
errs.append(f"{k}={profile[k]!r} not in {list(allowed)}")
|
||||||
|
if "grid_tie" in profile and profile["grid_tie"] not in ("enabled", "disabled"):
|
||||||
|
errs.append("grid_tie must be 'enabled' or 'disabled'")
|
||||||
|
return errs
|
||||||
|
|
||||||
|
# -------- powermon glue --------
|
||||||
|
|
||||||
|
async def _open_port(path: str) -> USBPort:
|
||||||
|
protocol = get_protocol_definition(protocol=PROTOCOL)
|
||||||
|
port = USBPort(path=path, protocol=protocol)
|
||||||
|
port.path = path
|
||||||
|
await port.connect()
|
||||||
|
if not port.is_connected():
|
||||||
|
raise RuntimeError(f"could not open {path}")
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_path(device: str | None, serial: str | None) -> str:
|
||||||
|
"""Return a concrete device path.
|
||||||
|
|
||||||
|
If `serial` is given, glob /dev/hidraw* and probe each candidate via PI18 ID
|
||||||
|
until one matches. Otherwise return `device` verbatim (typically the
|
||||||
|
resolver-maintained /dev/lvx6048-N symlink). --serial takes precedence for
|
||||||
|
cases where the resolver hasn't run (e.g. early boot, bare hidraw probing).
|
||||||
|
"""
|
||||||
|
import glob as _glob
|
||||||
|
if serial:
|
||||||
|
pass # fall through to probe
|
||||||
|
elif device:
|
||||||
|
return device
|
||||||
|
else:
|
||||||
|
raise RuntimeError("must supply --device or --serial")
|
||||||
|
candidates = sorted(_glob.glob("/dev/hidraw*"))
|
||||||
|
if not candidates:
|
||||||
|
raise RuntimeError("no /dev/hidraw* devices present")
|
||||||
|
for path in candidates:
|
||||||
|
try:
|
||||||
|
port = await _open_port(path)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parts = await _read_raw_parts(port, "ID")
|
||||||
|
# ID returns one field, so parts[0] is the inverter serial
|
||||||
|
if parts and parts[0] == str(serial):
|
||||||
|
return path
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
await port.disconnect()
|
||||||
|
raise RuntimeError(f"no device at /dev/hidraw* responds with serial {serial!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_command(code: str, proto) -> Command:
|
||||||
|
cd = proto.get_command_definition(code)
|
||||||
|
if cd is None:
|
||||||
|
raise RuntimeError(f"unknown PI18 command: {code!r}")
|
||||||
|
cmd = Command(code=code, commandtype="basic", outputs=[], trigger=None)
|
||||||
|
cmd.command_definition = cd
|
||||||
|
cmd.full_command = proto.get_full_command(code)
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
async def _send(port: USBPort, code: str):
|
||||||
|
cmd = _build_command(code, port.protocol)
|
||||||
|
result = await port.send_and_receive(cmd)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_piri(port: USBPort, retries: int = 3) -> dict[str, str]:
|
||||||
|
"""Return {name: raw_string} for each PIRI field.
|
||||||
|
|
||||||
|
Retries on transient decode failures — powermon's parser can IndexError when
|
||||||
|
the hidraw fd still holds leftover bytes from a prior command (e.g. running
|
||||||
|
multiple queries back-to-back in sync-check).
|
||||||
|
"""
|
||||||
|
cmd = _build_command("PIRI", port.protocol)
|
||||||
|
last_err: Any = None
|
||||||
|
for _ in range(retries):
|
||||||
|
try:
|
||||||
|
result = await port.send_and_receive(cmd)
|
||||||
|
except (IndexError, KeyError, ValueError) as e:
|
||||||
|
last_err = e
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
continue
|
||||||
|
if result is not None and getattr(result, "is_valid", True):
|
||||||
|
raw = result.raw_response
|
||||||
|
if raw.startswith(b"^D"):
|
||||||
|
raw = raw[5:] # ^D + 3-digit length
|
||||||
|
if raw.endswith(b"\r"):
|
||||||
|
raw = raw[:-3] # 2-byte CRC + \r
|
||||||
|
parts = raw.decode("ascii", errors="replace").split(",")
|
||||||
|
return {i: p for i, p in enumerate(parts)} | {"_parts": parts}
|
||||||
|
last_err = getattr(result, "error_messages", None) or result
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
raise RuntimeError(f"PIRI read failed after {retries} attempts: {last_err}")
|
||||||
|
|
||||||
|
|
||||||
|
def _piri_raw(piri: dict, idx: int) -> str:
|
||||||
|
return piri["_parts"][idx]
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_ack(port: USBPort, code: str) -> bool:
|
||||||
|
"""Send a setter. Return True on ^1, False on ^0 / unknown."""
|
||||||
|
cmd = _build_command(code, port.protocol)
|
||||||
|
result = await port.send_and_receive(cmd)
|
||||||
|
raw = getattr(result, "raw_response", b"") or b""
|
||||||
|
# setter response framed as ^Dlll^1... or similar. Just look for ^1 / ^0 anywhere.
|
||||||
|
if b"^1" in raw:
|
||||||
|
return True
|
||||||
|
if b"^0" in raw:
|
||||||
|
return False
|
||||||
|
# fallback: inspect parsed readings
|
||||||
|
for r in (result.readings or []):
|
||||||
|
v = str(r.data_value).lower()
|
||||||
|
if "succeed" in v:
|
||||||
|
return True
|
||||||
|
if "fail" in v:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
# -------- dump / diff / apply --------
|
||||||
|
|
||||||
|
def _dump_profile(piri_raw: list[str]) -> dict:
|
||||||
|
"""Convert PIRI raw fields into a profile dict, using the SCHEDULE decoders."""
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
# walk SCHEDULE once; pairs already have both halves
|
||||||
|
for s in SCHEDULE:
|
||||||
|
if s.key == "grid_tie":
|
||||||
|
mt = piri_raw[PIRI["machine_type"]]
|
||||||
|
out["grid_tie"] = "enabled" if mt.lstrip("0") == "1" else "disabled"
|
||||||
|
continue
|
||||||
|
out[s.key] = s.decoder(piri_raw[s.piri_field])
|
||||||
|
# fill pair's partner keys too (bulk/float already via bulk_voltage row; pair_keys picks up the second)
|
||||||
|
# BUCD pair
|
||||||
|
out["stop_discharge_voltage"] = _tenths_to_v(piri_raw[PIRI["stop_discharge_voltage"]])
|
||||||
|
out["stop_charge_voltage"] = _tenths_to_v(piri_raw[PIRI["stop_charge_voltage"]])
|
||||||
|
out["bulk_voltage"] = _tenths_to_v(piri_raw[PIRI["bulk_voltage"]])
|
||||||
|
out["float_voltage"] = _tenths_to_v(piri_raw[PIRI["float_voltage"]])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _diff(want: dict, have: dict) -> list[tuple[str, Any, Any]]:
|
||||||
|
diffs = []
|
||||||
|
for k, v in want.items():
|
||||||
|
if k not in have:
|
||||||
|
continue
|
||||||
|
hv = have[k]
|
||||||
|
if isinstance(v, float) or isinstance(hv, float):
|
||||||
|
if abs(float(v) - float(hv)) > 0.05:
|
||||||
|
diffs.append((k, hv, v))
|
||||||
|
else:
|
||||||
|
if v != hv:
|
||||||
|
diffs.append((k, hv, v))
|
||||||
|
return diffs
|
||||||
|
|
||||||
|
|
||||||
|
def _systemctl(action: str) -> None:
|
||||||
|
# Stop both units so neither holds the hidraw fd for the one we're writing to.
|
||||||
|
subprocess.run(["sudo", "systemctl", action, "powermon.service", "powermon2.service"], check=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_dump(args) -> int:
|
||||||
|
path = await _resolve_path(args.device, args.serial)
|
||||||
|
port = await _open_port(path)
|
||||||
|
try:
|
||||||
|
piri = await _read_piri(port)
|
||||||
|
finally:
|
||||||
|
await port.disconnect()
|
||||||
|
prof = _dump_profile(piri["_parts"])
|
||||||
|
# Drop any keys whose values aren't round-trippable via their PI18 setter.
|
||||||
|
errs = _validate(prof)
|
||||||
|
skipped: list[str] = []
|
||||||
|
for e in errs:
|
||||||
|
for k in list(prof):
|
||||||
|
if e.startswith(k + "=") or e.startswith(k + " "):
|
||||||
|
if k in prof:
|
||||||
|
skipped.append(f"{k}={prof.pop(k)!r} ({e})")
|
||||||
|
break
|
||||||
|
out_path = Path(args.out)
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with out_path.open("w") as f:
|
||||||
|
f.write("# LVX6048 settings profile. Edit freely; all keys are optional.\n")
|
||||||
|
f.write("# `flash.py diff` previews changes; `flash.py apply --confirm` writes.\n")
|
||||||
|
if skipped:
|
||||||
|
f.write("#\n# skipped (read-only or out of settable range):\n")
|
||||||
|
for s in skipped:
|
||||||
|
f.write(f"# {s}\n")
|
||||||
|
f.write("\n")
|
||||||
|
for k, v in prof.items():
|
||||||
|
doc = KEY_DOCS.get(k)
|
||||||
|
if doc:
|
||||||
|
f.write(f"# {doc}\n")
|
||||||
|
# one-key dump preserves native YAML formatting for scalars
|
||||||
|
f.write(yaml.safe_dump({k: v}, sort_keys=False, default_flow_style=False))
|
||||||
|
f.write("\n")
|
||||||
|
print(f"wrote {out_path} with {len(prof)} keys" + (f" ({len(skipped)} skipped)" if skipped else ""))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_diff(args) -> int:
|
||||||
|
with open(args.profile) as f:
|
||||||
|
want = yaml.safe_load(f) or {}
|
||||||
|
errs = _validate(want)
|
||||||
|
if errs:
|
||||||
|
for e in errs:
|
||||||
|
print(f"INVALID: {e}")
|
||||||
|
return 2
|
||||||
|
path = await _resolve_path(args.device, args.serial)
|
||||||
|
port = await _open_port(path)
|
||||||
|
try:
|
||||||
|
piri = await _read_piri(port)
|
||||||
|
finally:
|
||||||
|
await port.disconnect()
|
||||||
|
have = _dump_profile(piri["_parts"])
|
||||||
|
diffs = _diff(want, have)
|
||||||
|
if not diffs:
|
||||||
|
print("no diff — profile matches device")
|
||||||
|
return 0
|
||||||
|
print(f"{len(diffs)} setting(s) would change:")
|
||||||
|
for k, hv, wv in diffs:
|
||||||
|
print(f" {k}: {hv!r} -> {wv!r}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_apply(args) -> int:
|
||||||
|
with open(args.profile) as f:
|
||||||
|
want = yaml.safe_load(f) or {}
|
||||||
|
errs = _validate(want)
|
||||||
|
if errs:
|
||||||
|
for e in errs:
|
||||||
|
print(f"INVALID: {e}")
|
||||||
|
return 2
|
||||||
|
if not args.confirm:
|
||||||
|
print("refusing to write without --confirm (use `diff` to preview)")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
path = await _resolve_path(args.device, args.serial)
|
||||||
|
print("stopping powermon.service...")
|
||||||
|
_systemctl("stop")
|
||||||
|
try:
|
||||||
|
port = await _open_port(path)
|
||||||
|
try:
|
||||||
|
piri = await _read_piri(port)
|
||||||
|
have = _dump_profile(piri["_parts"])
|
||||||
|
diffs = _diff(want, have)
|
||||||
|
if not diffs:
|
||||||
|
print("nothing to do")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
pending = {k for k, _, _ in diffs}
|
||||||
|
applied: list[str] = []
|
||||||
|
for s in SCHEDULE:
|
||||||
|
# handle pair settings once
|
||||||
|
if s.pair_keys:
|
||||||
|
a, b = s.pair_keys
|
||||||
|
if a not in pending and b not in pending:
|
||||||
|
continue
|
||||||
|
if a not in want or b not in want:
|
||||||
|
print(f"SKIP {a}/{b}: pair requires both keys in profile")
|
||||||
|
continue
|
||||||
|
code = s.encoder((want[a], want[b]))
|
||||||
|
print(f"-> {code} ({a}={want[a]}, {b}={want[b]})")
|
||||||
|
ok = await _wait_ack(port, code)
|
||||||
|
if not ok:
|
||||||
|
print(f"FAIL: inverter NAK on {code}")
|
||||||
|
return 3
|
||||||
|
# readback
|
||||||
|
piri2 = await _read_piri(port)
|
||||||
|
new_a = _tenths_to_v(piri2["_parts"][PIRI[a]])
|
||||||
|
new_b = _tenths_to_v(piri2["_parts"][PIRI[b]])
|
||||||
|
if abs(new_a - float(want[a])) > 0.05 or abs(new_b - float(want[b])) > 0.05:
|
||||||
|
print(f"FAIL: readback mismatch: {a}={new_a}, {b}={new_b}")
|
||||||
|
return 3
|
||||||
|
applied += [a, b]
|
||||||
|
continue
|
||||||
|
if s.key not in pending:
|
||||||
|
continue
|
||||||
|
code = s.encoder(want[s.key])
|
||||||
|
print(f"-> {code} ({s.key}={want[s.key]})")
|
||||||
|
ok = await _wait_ack(port, code)
|
||||||
|
if not ok:
|
||||||
|
print(f"FAIL: inverter NAK on {code}")
|
||||||
|
return 3
|
||||||
|
piri2 = await _read_piri(port)
|
||||||
|
actual = s.decoder(piri2["_parts"][s.piri_field])
|
||||||
|
expected = want[s.key]
|
||||||
|
if isinstance(actual, float):
|
||||||
|
match = abs(float(actual) - float(expected)) <= 0.05
|
||||||
|
else:
|
||||||
|
match = actual == expected or (s.key == "grid_tie" and actual == ("grid_tie" if expected == "enabled" else "off_grid"))
|
||||||
|
if not match:
|
||||||
|
print(f"FAIL: readback mismatch on {s.key}: got {actual!r}, want {expected!r}")
|
||||||
|
return 3
|
||||||
|
applied.append(s.key)
|
||||||
|
|
||||||
|
print(f"OK — applied {len(applied)} setting(s): {', '.join(applied)}")
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
await port.disconnect()
|
||||||
|
finally:
|
||||||
|
print("restarting powermon.service...")
|
||||||
|
_systemctl("start")
|
||||||
|
|
||||||
|
|
||||||
|
# -------- sync-check: are the two units in a valid parallel state? --------
|
||||||
|
|
||||||
|
# PI18 MOD codes
|
||||||
|
MOD_NAMES = {
|
||||||
|
"00": "Power on", "01": "Standby", "02": "Bypass",
|
||||||
|
"03": "Battery", "04": "Fault", "05": "Hybrid",
|
||||||
|
}
|
||||||
|
|
||||||
|
# PI18 FWS fault-code map (cross-referenced with PI30 QPGS fault codes)
|
||||||
|
FAULT_NAMES = {
|
||||||
|
"00": "No fault",
|
||||||
|
"01": "Fan is locked",
|
||||||
|
"02": "Over temperature",
|
||||||
|
"03": "Battery voltage is too high",
|
||||||
|
"04": "Battery voltage is too low",
|
||||||
|
"05": "Output short circuited or Over temperature",
|
||||||
|
"06": "Output voltage is too high",
|
||||||
|
"07": "Over load time out",
|
||||||
|
"08": "Bus voltage is too high",
|
||||||
|
"09": "Bus soft start failed",
|
||||||
|
"11": "Main relay failed",
|
||||||
|
"51": "Over current inverter",
|
||||||
|
"52": "Bus soft start failed",
|
||||||
|
"53": "Inverter soft start failed",
|
||||||
|
"54": "Self-test failed",
|
||||||
|
"55": "Over DC voltage on output of inverter",
|
||||||
|
"56": "Battery connection is open",
|
||||||
|
"57": "Current sensor failed",
|
||||||
|
"58": "Output voltage is too low",
|
||||||
|
"60": "Inverter negative power",
|
||||||
|
"71": "Parallel version different",
|
||||||
|
"72": "Output circuit failed",
|
||||||
|
"80": "CAN communication failed",
|
||||||
|
"81": "Parallel host line lost",
|
||||||
|
"82": "Parallel synchronized signal lost",
|
||||||
|
"83": "Parallel battery voltage detect different",
|
||||||
|
"84": "Parallel Line voltage or frequency detect different",
|
||||||
|
"85": "Parallel Line input current unbalanced",
|
||||||
|
"86": "Parallel output setting different",
|
||||||
|
}
|
||||||
|
|
||||||
|
# GS field indices (see powermon/protocols/pi18.py :: QUERY_COMMANDS["GS"])
|
||||||
|
GS_AC_OUTPUT_V = 2
|
||||||
|
GS_AC_OUTPUT_HZ = 3
|
||||||
|
GS_PARALLEL_VALID = 27
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_raw_parts(port: USBPort, code: str, retries: int = 3) -> list[str]:
|
||||||
|
cmd = _build_command(code, port.protocol)
|
||||||
|
last_err: Any = None
|
||||||
|
for _ in range(retries):
|
||||||
|
result = await port.send_and_receive(cmd)
|
||||||
|
if result is not None and getattr(result, "is_valid", False):
|
||||||
|
raw = result.raw_response
|
||||||
|
if raw.startswith(b"^D"):
|
||||||
|
raw = raw[5:] # ^D + 3-digit length
|
||||||
|
if raw.endswith(b"\r"):
|
||||||
|
raw = raw[:-3] # 2-byte CRC + \r
|
||||||
|
return raw.decode("ascii", errors="replace").split(",")
|
||||||
|
last_err = getattr(result, "error_messages", None) or result
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
raise RuntimeError(f"{code} read failed after {retries} attempts: {last_err}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _snapshot_sync(path: str) -> dict[str, Any]:
|
||||||
|
port = await _open_port(path)
|
||||||
|
try:
|
||||||
|
gs = await _read_raw_parts(port, "GS")
|
||||||
|
fws = await _read_raw_parts(port, "FWS")
|
||||||
|
mod = await _read_raw_parts(port, "MOD")
|
||||||
|
vfw = await _read_raw_parts(port, "VFW")
|
||||||
|
finally:
|
||||||
|
await port.disconnect()
|
||||||
|
return {
|
||||||
|
"parallel_valid": gs[GS_PARALLEL_VALID] == "1",
|
||||||
|
"ac_output_v": _tenths_to_v(gs[GS_AC_OUTPUT_V]),
|
||||||
|
"ac_output_hz": int(gs[GS_AC_OUTPUT_HZ]) / 10.0,
|
||||||
|
"fault_code": fws[0],
|
||||||
|
"fault_name": FAULT_NAMES.get(fws[0], f"unknown ({fws[0]})"),
|
||||||
|
"mode_raw": mod[0],
|
||||||
|
"mode": MOD_NAMES.get(mod[0], mod[0]),
|
||||||
|
"main_cpu": vfw[0],
|
||||||
|
"slave_cpu": vfw[1],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_sync_check(args) -> int:
|
||||||
|
path_a = await _resolve_path(args.device_a, args.serial_a)
|
||||||
|
path_b = await _resolve_path(args.device_b, args.serial_b)
|
||||||
|
a = await _snapshot_sync(path_a)
|
||||||
|
b = await _snapshot_sync(path_b)
|
||||||
|
|
||||||
|
def _row(label: str, s: dict) -> str:
|
||||||
|
valid = "valid" if s["parallel_valid"] else "NOT VALID"
|
||||||
|
return (f"{label}: fw={s['main_cpu']}/{s['slave_cpu']} mode={s['mode']} "
|
||||||
|
f"parallel={valid} fault={s['fault_name']} "
|
||||||
|
f"vac={s['ac_output_v']}V fac={s['ac_output_hz']}Hz")
|
||||||
|
|
||||||
|
print(_row(path_a, a))
|
||||||
|
print(_row(path_b, b))
|
||||||
|
|
||||||
|
issues: list[str] = []
|
||||||
|
if a["main_cpu"] != b["main_cpu"] or a["slave_cpu"] != b["slave_cpu"]:
|
||||||
|
issues.append(f"firmware mismatch: {a['main_cpu']}/{a['slave_cpu']} vs {b['main_cpu']}/{b['slave_cpu']} — parallel requires matching firmware on both units")
|
||||||
|
if not a["parallel_valid"]:
|
||||||
|
issues.append(f"{path_a}: GS parallel_instance_number = Not valid")
|
||||||
|
if not b["parallel_valid"]:
|
||||||
|
issues.append(f"{path_b}: GS parallel_instance_number = Not valid")
|
||||||
|
if a["fault_code"] != "00":
|
||||||
|
issues.append(f"{path_a}: active fault {a['fault_code']} ({a['fault_name']})")
|
||||||
|
if b["fault_code"] != "00":
|
||||||
|
issues.append(f"{path_b}: active fault {b['fault_code']} ({b['fault_name']})")
|
||||||
|
if a["mode_raw"] != b["mode_raw"]:
|
||||||
|
issues.append(f"mode differs: {a['mode']} vs {b['mode']}")
|
||||||
|
if abs(a["ac_output_hz"] - b["ac_output_hz"]) > 0.1 and a["ac_output_hz"] > 0 and b["ac_output_hz"] > 0:
|
||||||
|
issues.append(f"AC output frequency diverges: {a['ac_output_hz']}Hz vs {b['ac_output_hz']}Hz (>0.1Hz = not phase-locked)")
|
||||||
|
if abs(a["ac_output_v"] - b["ac_output_v"]) > 2.0 and a["ac_output_v"] > 0 and b["ac_output_v"] > 0:
|
||||||
|
issues.append(f"AC output voltage diverges: {a['ac_output_v']}V vs {b['ac_output_v']}V (>2V gap)")
|
||||||
|
if a["ac_output_v"] == 0 and b["ac_output_v"] == 0:
|
||||||
|
issues.append("both units AC output = 0 V (idle / not producing); frequency/voltage sync cannot be verified until at least one is inverting")
|
||||||
|
|
||||||
|
print()
|
||||||
|
if not issues:
|
||||||
|
print("SYNC OK")
|
||||||
|
return 0
|
||||||
|
print(f"{len(issues)} issue(s):")
|
||||||
|
for i in issues:
|
||||||
|
print(f" - {i}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_compare(args) -> int:
|
||||||
|
async def _snapshot(path: str) -> dict:
|
||||||
|
port = await _open_port(path)
|
||||||
|
try:
|
||||||
|
piri = await _read_piri(port)
|
||||||
|
finally:
|
||||||
|
await port.disconnect()
|
||||||
|
return _dump_profile(piri["_parts"])
|
||||||
|
|
||||||
|
path_a = await _resolve_path(args.device_a, args.serial_a)
|
||||||
|
path_b = await _resolve_path(args.device_b, args.serial_b)
|
||||||
|
a = await _snapshot(path_a)
|
||||||
|
b = await _snapshot(path_b)
|
||||||
|
|
||||||
|
shared = sorted(set(a) & set(b))
|
||||||
|
diffs: list[tuple[str, Any, Any]] = []
|
||||||
|
for k in shared:
|
||||||
|
av, bv = a[k], b[k]
|
||||||
|
if isinstance(av, float) or isinstance(bv, float):
|
||||||
|
if abs(float(av) - float(bv)) > 0.05:
|
||||||
|
diffs.append((k, av, bv))
|
||||||
|
elif av != bv:
|
||||||
|
diffs.append((k, av, bv))
|
||||||
|
|
||||||
|
a_only = sorted(set(a) - set(b))
|
||||||
|
b_only = sorted(set(b) - set(a))
|
||||||
|
|
||||||
|
if not diffs and not a_only and not b_only:
|
||||||
|
print(f"MATCH — {len(shared)} settings identical on {path_a} and {path_b}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if diffs:
|
||||||
|
kw = max(len(k) for k, _, _ in diffs)
|
||||||
|
print(f"{len(diffs)} setting(s) differ (of {len(shared)} shared):")
|
||||||
|
for k, av, bv in diffs:
|
||||||
|
print(f" {k:<{kw}} {path_a}={av!r} {path_b}={bv!r}")
|
||||||
|
if a_only:
|
||||||
|
print(f"only on {path_a}: {', '.join(a_only)}")
|
||||||
|
if b_only:
|
||||||
|
print(f"only on {path_b}: {', '.join(b_only)}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="Flash LVX6048 settings profiles via PI18.")
|
||||||
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
def _add_common(p):
|
||||||
|
# --device uses the resolver-maintained symlink by default. --serial is
|
||||||
|
# an explicit fallback that probes /dev/hidraw* via PI18 ID — only use
|
||||||
|
# when the resolver hasn't run (e.g. early boot / debugging).
|
||||||
|
p.add_argument("--device", default="/dev/lvx6048-1",
|
||||||
|
help="device path (default: /dev/lvx6048-1 symlink)")
|
||||||
|
p.add_argument("--serial", default=None,
|
||||||
|
help="override --device by probing /dev/hidraw* for this PI18 serial")
|
||||||
|
|
||||||
|
d = sub.add_parser("dump", help="read current settings into a YAML profile")
|
||||||
|
_add_common(d)
|
||||||
|
d.add_argument("--out", required=True)
|
||||||
|
|
||||||
|
df = sub.add_parser("diff", help="show what would change if a profile were applied")
|
||||||
|
_add_common(df)
|
||||||
|
df.add_argument("--profile", required=True)
|
||||||
|
|
||||||
|
ap_ = sub.add_parser("apply", help="apply a profile (requires --confirm)")
|
||||||
|
_add_common(ap_)
|
||||||
|
ap_.add_argument("--profile", required=True)
|
||||||
|
ap_.add_argument("--confirm", action="store_true")
|
||||||
|
|
||||||
|
def _add_pair(p):
|
||||||
|
p.add_argument("--device-a", default="/dev/lvx6048-1", help="device path for unit A")
|
||||||
|
p.add_argument("--device-b", default="/dev/lvx6048-2", help="device path for unit B")
|
||||||
|
p.add_argument("--serial-a", default=None, help="override --device-a by probing PI18 serial")
|
||||||
|
p.add_argument("--serial-b", default=None, help="override --device-b by probing PI18 serial")
|
||||||
|
|
||||||
|
cp = sub.add_parser("compare", help="diff live settings between two inverters")
|
||||||
|
_add_pair(cp)
|
||||||
|
|
||||||
|
sc = sub.add_parser("sync-check", help="verify two paralleled inverters are in sync")
|
||||||
|
_add_pair(sc)
|
||||||
|
|
||||||
|
args = ap.parse_args()
|
||||||
|
handler = {
|
||||||
|
"dump": cmd_dump, "diff": cmd_diff, "apply": cmd_apply,
|
||||||
|
"compare": cmd_compare, "sync-check": cmd_sync_check,
|
||||||
|
}[args.cmd]
|
||||||
|
return asyncio.run(handler(args))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
39
LVX6048/lvx-flash/profiles/current.yaml
Normal file
39
LVX6048/lvx-flash/profiles/current.yaml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# LVX6048 settings profile. Edit freely; all keys are optional.
|
||||||
|
# `flash.py diff` previews changes; `flash.py apply --confirm` writes.
|
||||||
|
#
|
||||||
|
# skipped (read-only or out of settable range):
|
||||||
|
# max_charging_current=100 (max_charging_current=100 out of range [10, 80])
|
||||||
|
|
||||||
|
# enum: AGM | FLOODED | USER
|
||||||
|
battery_type: AGM
|
||||||
|
|
||||||
|
# V 40.0..48.0 — hard shutdown; must be < stop_discharge_voltage. Only honored when battery_type=USER.
|
||||||
|
cutoff_voltage: 40.8
|
||||||
|
|
||||||
|
# V 44.0..51.0 — switch battery→grid below this (pair: stop_charge_voltage). Only honored when battery_type=USER.
|
||||||
|
stop_discharge_voltage: 46.0
|
||||||
|
|
||||||
|
# V 48.0..58.4 — CC→CV transition (pair: float_voltage; must be >= float_voltage). Only honored when battery_type=USER.
|
||||||
|
bulk_voltage: 56.4
|
||||||
|
|
||||||
|
# A 2,10,20,30,40,50,60,70,80 — grid-side cap only
|
||||||
|
max_utility_charging_current: 30
|
||||||
|
|
||||||
|
# enum: solar_utility_battery | solar_battery_utility
|
||||||
|
output_source_priority: solar_battery_utility
|
||||||
|
|
||||||
|
# enum: solar_first | solar_and_utility | solar_only
|
||||||
|
charger_priority: solar_first
|
||||||
|
|
||||||
|
# enum: battery_load_utility_ac | load_battery_utility
|
||||||
|
solar_power_priority: battery_load_utility_ac
|
||||||
|
|
||||||
|
# enum: enabled | disabled (PEI/PDI)
|
||||||
|
grid_tie: disabled
|
||||||
|
|
||||||
|
# V 0 (=Full) or 48.0..58.0 — switch grid→battery above this (pair: stop_discharge_voltage). Only honored when battery_type=USER.
|
||||||
|
stop_charge_voltage: 54.0
|
||||||
|
|
||||||
|
# V 48.0..58.4 — held while on grid (pair: bulk_voltage; must be <= bulk_voltage). Only honored when battery_type=USER.
|
||||||
|
float_voltage: 54.0
|
||||||
|
|
||||||
28
LVX6048/powermon-patches/README.md
Normal file
28
LVX6048/powermon-patches/README.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# powermon patches
|
||||||
|
|
||||||
|
Drop-in replacements for files inside a `uv tool install powermon==1.0.18` tree.
|
||||||
|
Each file below lands at the indicated path — the top-level `install.sh` does the copy.
|
||||||
|
|
||||||
|
| Snapshot here | Target (inside `$POWERMON_SITE`) | Purpose |
|
||||||
|
|--------------------------|---------------------------------------|---------|
|
||||||
|
| `pi18.py` | `protocols/pi18.py` | (a) rename `"DC/AC power direction"` → `"DC-AC power direction"` so the slash doesn't create a bogus MQTT topic level. (d) add `FWS` (fault + warning status) and `PGS<n>` (parallel general status) query commands; bump `check_definitions_count(expected=24)` → `26`. |
|
||||||
|
| `usbport.py` | `ports/usbport.py` | (b) drain leftover bytes from the hidraw fd before sending (non-blocking read loop swallowing `BlockingIOError`); wrap the main `os.read` in the retry loop so an empty first read doesn't abort. Otherwise late HID bytes from a prior command get parsed as the next reply → `KeyError`. |
|
||||||
|
| `mqttbroker.py` | `libs/mqttbroker.py` | (c) broaden `connect()`'s `except ConnectionRefusedError` to `(ConnectionRefusedError, OSError)` and narrow `publish()`'s bare `except Exception` to `(OSError, RuntimeError, ValueError)`. Otherwise any broker blip (HA restart, `Errno 113 No route to host`) crashes the daemon. |
|
||||||
|
| `port_config_model.py` | `configmodel/port_config_model.py` | (e) add `serial_number: None \| str \| int = Field(default=None)` to `UsbPortConfig`. The model is `NoExtraBaseModel`, so powermon rejects `serial_number:` at the port level without this. |
|
||||||
|
| `ports_init.py` | `ports/__init__.py` | (f) in `from_config()`, make `port_config['serial_number'] = serial_number` a fallback (`if port_config.get('serial_number') is None:`). Device-level `serial_number` is the HA identifier (e.g. `lvx6048_1`); the port-level one is the hardware PI18 serial — they must not be conflated. |
|
||||||
|
|
||||||
|
Patches (a)–(d) are load-bearing for the live setup. Patches (e) and (f) enable
|
||||||
|
powermon's native wildcard-path + serial-matching flow for a single-daemon
|
||||||
|
setup; we don't currently exercise that because two services probing
|
||||||
|
independently at startup race each other — the external `lvx-resolve-links`
|
||||||
|
oneshot handles identification instead. (e)/(f) are kept applied for future
|
||||||
|
flexibility.
|
||||||
|
|
||||||
|
## Upgrade path
|
||||||
|
|
||||||
|
These patches are pinned against **powermon 1.0.18**. Before bumping powermon:
|
||||||
|
|
||||||
|
1. Install the new version in a scratch location: `uv tool install --prefix /tmp/pm-next 'powermon==X.Y.Z'`
|
||||||
|
2. Diff each of the five files against the pristine upstream copy.
|
||||||
|
3. Re-apply each patch by hand into the new files (they're short — see descriptions above).
|
||||||
|
4. Drop the new files into this folder and re-run `./install.sh` on the target.
|
||||||
220
LVX6048/powermon-patches/mqttbroker.py
Normal file
220
LVX6048/powermon-patches/mqttbroker.py
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
""" powermon / libs / mqttbroker.py """
|
||||||
|
import logging
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
import paho.mqtt.client as mqtt_client
|
||||||
|
|
||||||
|
from powermon.libs.config import safe_config
|
||||||
|
|
||||||
|
# Set-up logger
|
||||||
|
log = logging.getLogger("mqttbroker")
|
||||||
|
|
||||||
|
|
||||||
|
class MqttBroker:
|
||||||
|
""" Wrapper for mqtt broker connectivity and message proccessing """
|
||||||
|
def __str__(self):
|
||||||
|
if self.disabled:
|
||||||
|
return "MqttBroker DISABLED"
|
||||||
|
else:
|
||||||
|
return f"MqttBroker name: {self.name}, port: {self.port}, user: {self.username}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, config=None) -> 'MqttBroker':
|
||||||
|
""" build the mqtt broker object from a config dict """
|
||||||
|
log.debug("mqttbroker config: %s", safe_config(config))
|
||||||
|
|
||||||
|
if config:
|
||||||
|
name = config.get("name")
|
||||||
|
port = config.get("port", 1883)
|
||||||
|
username = config.get("username")
|
||||||
|
password = config.get("password")
|
||||||
|
mqtt_broker = cls(name=name, port=port, username=username, password=password)
|
||||||
|
mqtt_broker.adhoc_topic = config.get("adhoc_topic")
|
||||||
|
mqtt_broker.adhoc_result_topic = config.get("adhoc_result_topic")
|
||||||
|
return mqtt_broker
|
||||||
|
else:
|
||||||
|
return cls(name=None)
|
||||||
|
|
||||||
|
def __init__(self, name, port=None, username=None, password=None):
|
||||||
|
self.name = name
|
||||||
|
self.port = port
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.is_connected = False
|
||||||
|
|
||||||
|
if self.name is None:
|
||||||
|
self.disabled = True
|
||||||
|
else:
|
||||||
|
self.disabled = False
|
||||||
|
self.mqttc = mqtt_client.Client()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def adhoc_topic(self) -> str:
|
||||||
|
""" return the adhoc command topic """
|
||||||
|
return getattr(self, "_adhoc_topic", None)
|
||||||
|
|
||||||
|
@adhoc_topic.setter
|
||||||
|
def adhoc_topic(self, value):
|
||||||
|
log.debug("setting adhoc topic to: %s", value)
|
||||||
|
self._adhoc_topic = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def adhoc_result_topic(self) -> str:
|
||||||
|
""" return the adhoc result topic """
|
||||||
|
return getattr(self, "_adhoc_result_topic", None)
|
||||||
|
|
||||||
|
@adhoc_result_topic.setter
|
||||||
|
def adhoc_result_topic(self, value):
|
||||||
|
log.debug("setting adhoc result topic to: %s", value)
|
||||||
|
self._adhoc_result_topic = value
|
||||||
|
|
||||||
|
def on_connect(self, client, userdata, flags, rc):
|
||||||
|
""" callback for connect """
|
||||||
|
# 0: Connection successful
|
||||||
|
# 1: Connection refused - incorrect protocol version
|
||||||
|
# 2: Connection refused - invalid client identifier
|
||||||
|
# 3: Connection refused - server unavailable
|
||||||
|
# 4: Connection refused - bad username or password
|
||||||
|
# 5: Connection refused - not authorised
|
||||||
|
# 6-255: Currently unused.
|
||||||
|
log.debug("on_connect called - client: %s, userdata: %s, flags: %s", client, userdata, flags)
|
||||||
|
connection_result = [
|
||||||
|
"Connection successful",
|
||||||
|
"Connection refused - incorrect protocol version",
|
||||||
|
"Connection refused - invalid client identifier",
|
||||||
|
"Connection refused - server unavailable",
|
||||||
|
"Connection refused - bad username or password",
|
||||||
|
"Connection refused - not authorised",
|
||||||
|
]
|
||||||
|
log.debug("MqttBroker connection returned result: %s %s", rc, connection_result[rc])
|
||||||
|
if rc == 0:
|
||||||
|
self.is_connected = True
|
||||||
|
return
|
||||||
|
self.is_connected = False
|
||||||
|
|
||||||
|
def on_disconnect(self, client, userdata, rc):
|
||||||
|
""" callback for disconnect """
|
||||||
|
log.debug("on_disconnect called - client: %s, userdata: %s, rc: %s", client, userdata, rc)
|
||||||
|
self.is_connected = False
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
""" connect to mqtt broker """
|
||||||
|
if self.disabled:
|
||||||
|
log.info("MQTT broker not enabled, was a broker name defined? '%s'", self.name)
|
||||||
|
return
|
||||||
|
if not self.name:
|
||||||
|
log.info("MQTT could not connect as no broker name")
|
||||||
|
return
|
||||||
|
self.mqttc.on_connect = self.on_connect
|
||||||
|
self.mqttc.on_disconnect = self.on_disconnect
|
||||||
|
# if name is screen just return without connecting
|
||||||
|
if self.name == "screen":
|
||||||
|
# allows checking of message formats
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
log.debug("Connecting to %s on port %s", self.name, self.port)
|
||||||
|
if self.username:
|
||||||
|
# auth = {"username": mqtt_user, "password": mqtt_pass}
|
||||||
|
_password = "********" if self.password is not None else "None"
|
||||||
|
log.info("Using mqtt authentication, username: %s, password: %s", self.username, _password)
|
||||||
|
self.mqttc.username_pw_set(self.username, password=self.password)
|
||||||
|
else:
|
||||||
|
log.debug("No mqtt authentication used")
|
||||||
|
# auth = None
|
||||||
|
self.mqttc.connect(self.name, port=self.port, keepalive=60)
|
||||||
|
self.mqttc.loop_start()
|
||||||
|
sleep(1)
|
||||||
|
except (ConnectionRefusedError, OSError) as ex:
|
||||||
|
log.warning("%s connection failed: '%s'", self.name, ex)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
""" start mqtt broker """
|
||||||
|
if self.disabled:
|
||||||
|
return
|
||||||
|
if self.is_connected:
|
||||||
|
self.mqttc.loop_start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
""" stop mqtt broker """
|
||||||
|
log.debug("Stopping mqttbroker connection")
|
||||||
|
if self.disabled:
|
||||||
|
return
|
||||||
|
self.mqttc.loop_stop()
|
||||||
|
|
||||||
|
# def set(self, variable, value):
|
||||||
|
# setattr(self, variable, value)
|
||||||
|
|
||||||
|
# def update(self, variable, value):
|
||||||
|
# # only override if value is not None
|
||||||
|
# if value is None:
|
||||||
|
# return
|
||||||
|
# setattr(self, variable, value)
|
||||||
|
|
||||||
|
def subscribe(self, topic, callback):
|
||||||
|
""" subscribe to a mqtt topic """
|
||||||
|
if not self.name:
|
||||||
|
return
|
||||||
|
if self.disabled:
|
||||||
|
return
|
||||||
|
# check if connected, connect if not
|
||||||
|
if not self.is_connected:
|
||||||
|
log.debug("Not connected, connecting")
|
||||||
|
self.connect()
|
||||||
|
# Register callback
|
||||||
|
self.mqttc.on_message = callback
|
||||||
|
if self.is_connected:
|
||||||
|
# Subscribe to command topic
|
||||||
|
log.debug("Subscribing to topic: %s", topic)
|
||||||
|
self.mqttc.subscribe(topic, qos=0)
|
||||||
|
else:
|
||||||
|
log.warning("Did not subscribe to topic: %s as not connected to broker", topic)
|
||||||
|
|
||||||
|
def post_adhoc_command(self, command_code):
|
||||||
|
""" shortcut function to publish an adhoc command """
|
||||||
|
self.publish(topic=self.adhoc_topic, payload=command_code)
|
||||||
|
|
||||||
|
def post_adhoc_result(self, payload):
|
||||||
|
""" shortcut function to publish the results of an adhoc command """
|
||||||
|
self.publish(topic=self.adhoc_result_topic, payload=payload)
|
||||||
|
|
||||||
|
|
||||||
|
def publish(self, topic: str = None, payload: str = None):
|
||||||
|
""" function to publish messages to mqtt broker """
|
||||||
|
if self.disabled:
|
||||||
|
log.debug("Cannot publish msg as mqttbroker disabled")
|
||||||
|
return
|
||||||
|
# log.debug("Publishing '%s' to '%s'", payload, topic)
|
||||||
|
if self.name == "screen":
|
||||||
|
print(f"mqtt debug - topic: '{topic}', payload: '{payload}'")
|
||||||
|
return
|
||||||
|
# check if connected, connect if not
|
||||||
|
if not self.is_connected:
|
||||||
|
log.debug("Not connected, connecting")
|
||||||
|
self.connect()
|
||||||
|
sleep(1)
|
||||||
|
if not self.is_connected:
|
||||||
|
log.warning("mqtt broker did not connect")
|
||||||
|
return
|
||||||
|
if isinstance(topic, bytes):
|
||||||
|
topic = topic.decode('utf-8')
|
||||||
|
if isinstance(payload, bytes):
|
||||||
|
payload = payload.decode('utf-8')
|
||||||
|
try:
|
||||||
|
infot = self.mqttc.publish(topic, payload)
|
||||||
|
infot.wait_for_publish(5)
|
||||||
|
except (OSError, RuntimeError, ValueError) as e:
|
||||||
|
log.warning("mqtt publish failed: %s", e)
|
||||||
|
|
||||||
|
# def setAdhocCommands(self, config={}, callback=None):
|
||||||
|
# if not config:
|
||||||
|
# return
|
||||||
|
# if self.disabled:
|
||||||
|
# log.debug("Cannot setAdhocCommands as mqttbroker disabled")
|
||||||
|
# return
|
||||||
|
|
||||||
|
# adhoc_commands = config.get("adhoc_commands")
|
||||||
|
# # sub to command topic if defined
|
||||||
|
# adhoc_commands_topic = adhoc_commands.get("topic")
|
||||||
|
# if adhoc_commands_topic is not None:
|
||||||
|
# log.info("Setting adhoc commands topic to %s", adhoc_commands_topic)
|
||||||
|
# self.subscribe(adhoc_commands_topic, callback)
|
||||||
651
LVX6048/powermon-patches/pi18.py
Normal file
651
LVX6048/powermon-patches/pi18.py
Normal file
@@ -0,0 +1,651 @@
|
|||||||
|
""" powermon / protocols / pi18.py """
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from powermon.commands.command import CommandType
|
||||||
|
from powermon.commands.command_definition import CommandDefinition
|
||||||
|
from powermon.commands.reading_definition import ReadingType, ResponseType
|
||||||
|
from powermon.commands.result import ResultType
|
||||||
|
from powermon.libs.errors import CommandDefinitionMissing, InvalidCRC, InvalidResponse
|
||||||
|
from powermon.ports import PortType
|
||||||
|
from powermon.protocols.abstractprotocol import AbstractProtocol
|
||||||
|
from powermon.protocols.helpers import crc_pi30 as crc
|
||||||
|
from powermon.protocols.pi30 import BATTERY_TYPE_LIST, OUTPUT_MODE_LIST
|
||||||
|
|
||||||
|
log = logging.getLogger("pi18")
|
||||||
|
|
||||||
|
SETTER_COMMANDS = {
|
||||||
|
"POP": {
|
||||||
|
"name": "POP",
|
||||||
|
"command_type": CommandType.PI18_SETTER,
|
||||||
|
"description": "Set Device Output Source Priority",
|
||||||
|
"help": " -- examples: POP0 (set utility first), POP01 (set solar first)",
|
||||||
|
"regex": "POP([01])$",
|
||||||
|
},
|
||||||
|
"PSP": {
|
||||||
|
"name": "PSP",
|
||||||
|
"command_type": CommandType.PI18_SETTER,
|
||||||
|
"description": "Set Solar Power priority",
|
||||||
|
"help": " -- examples: PSP0 (Battery-Load-Utility +AC Charge), PSP1 (Load-Battery-Utility)",
|
||||||
|
"regex": "PSP([01])$",
|
||||||
|
},
|
||||||
|
"PEI": {
|
||||||
|
"name": "PEI",
|
||||||
|
"command_type": CommandType.PI18_SETTER,
|
||||||
|
"description": "Set Machine type, enable: Grid-Tie",
|
||||||
|
"help": " -- examples: PEI (enable Grid-Tie)",
|
||||||
|
},
|
||||||
|
"PDI": {
|
||||||
|
"name": "PDI",
|
||||||
|
"command_type": CommandType.PI18_SETTER,
|
||||||
|
"description": "Set Machine type, disable: Grid-Tie",
|
||||||
|
"help": " -- examples: PDI (disable Grid-Tie)",
|
||||||
|
},
|
||||||
|
"PCP": {
|
||||||
|
"name": "PCP",
|
||||||
|
"command_type": CommandType.PI18_SETTER,
|
||||||
|
"description": "Set Device Charger Priority",
|
||||||
|
"help": " -- examples: PCP0,1 (set unit 0 [0-9] to Solar and Utility) PCP0,0 (set unit 0 to Solar first), PCP0,1 (set unit 0 to Solar and Utility), PCP0,2 (set unit 0 to solar only charging)",
|
||||||
|
"regex": "PCP([0-9],[012])$",
|
||||||
|
},
|
||||||
|
"MCHGC": {
|
||||||
|
"name": "MCHGC",
|
||||||
|
"command_type": CommandType.PI18_SETTER,
|
||||||
|
"description": "Set Battery Max Charging Current Solar + AC",
|
||||||
|
"help": " -- examples: MCHGC0,040 (set unit 0 to max charging current of 40A), MCHGC1,060 (set unit 1 to max charging current of 60A) [010 020 030 040 050 060 070 080]",
|
||||||
|
"regex": "MCHGC([0-9],0[1-8]0)$",
|
||||||
|
},
|
||||||
|
"MUCHGC": {
|
||||||
|
"name": "MUCHGC",
|
||||||
|
"command_type": CommandType.PI18_SETTER,
|
||||||
|
"description": "Set Battery Max AC Charging Current",
|
||||||
|
"help": " -- examples: MUCHGC0,040 (set unit 0 to max charging current of 40A), MUCHGC1,060 (set unit 1 to max charging current of 60A) [002 010 020 030 040 050 060 070 080]",
|
||||||
|
"regex": "MUCHGC([0-9]),(002|0[1-8]0)$",
|
||||||
|
},
|
||||||
|
"PBT": {
|
||||||
|
"name": "PBT",
|
||||||
|
"command_type": CommandType.PI18_SETTER,
|
||||||
|
"description": "Set Battery Type",
|
||||||
|
"help": " -- examples: PBT0 (set battery as AGM), PBT1 (set battery as FLOODED), PBT2 (set battery as USER)",
|
||||||
|
"regex": "PBT([012])$",
|
||||||
|
},
|
||||||
|
"MCHGV": {
|
||||||
|
"name": "MCHGV",
|
||||||
|
"command_type": CommandType.PI18_SETTER,
|
||||||
|
"description": "Set Battery Bulk,Float Charging Voltages",
|
||||||
|
"help": " -- example MCHGV552,540 - set battery charging voltage Bulk to 52.2V, float 54V (set Bulk Voltage [480~584] in 0.1V xxx, Float Voltage [480~584] in 0.1V yyy)",
|
||||||
|
# Regex 48.0 - 58.4 Volt
|
||||||
|
"regex": "MCHGV(4[8-9][0-9]|5[0-7][0-9]|58[0-5]),(4[8-9][0-9]|5[0-7][0-9]|58[0-4])$",
|
||||||
|
},
|
||||||
|
"PSDV": {
|
||||||
|
"name": "PSDV",
|
||||||
|
"command_type": CommandType.PI18_SETTER,
|
||||||
|
"description": "Set Battery Cut-off Voltage",
|
||||||
|
"help": " -- example PSDV400 - set battery cut-off voltage to 40V [400~480V] for 48V unit)",
|
||||||
|
# Regex 40 to 48V
|
||||||
|
"regex": "PSDV(4[0-7][0-9]|480)$",
|
||||||
|
},
|
||||||
|
"BUCD": {
|
||||||
|
"name": "BUCD",
|
||||||
|
"command_type": CommandType.PI18_SETTER,
|
||||||
|
"description": "Set Battery Stop dis,charging when Grid is available",
|
||||||
|
"help": " -- example BUCD440,480 - set Stop discharge Voltage [440~510] in 0.1V xxx, Stop Charge Voltage [000(Full) or 480~580] in 0.1V yyy",
|
||||||
|
# Regex 44 to 51V, Full|48V to 58V
|
||||||
|
"regex": "BUCD((4[4-9]0|5[0-1]0),(000|4[8-9]0|5[0-8]0))$",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
QUERY_COMMANDS = {
|
||||||
|
"PI": {
|
||||||
|
"name": "PI",
|
||||||
|
"command_type": CommandType.PI18_QUERY,
|
||||||
|
"description": "Protocol ID inquiry",
|
||||||
|
"help": " -- queries the protocol ID",
|
||||||
|
"result_type": ResultType.SINGLE,
|
||||||
|
"reading_definitions": [
|
||||||
|
{"description": "Protocol ID"},
|
||||||
|
],
|
||||||
|
"test_responses": [
|
||||||
|
b"^D00518m\xae\r"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ID": {
|
||||||
|
"name": "ID",
|
||||||
|
"aliases": ["default", "get_id"],
|
||||||
|
"command_type": CommandType.PI18_QUERY,
|
||||||
|
"description": "Device Serial Number inquiry",
|
||||||
|
"help": " -- queries the device serial number",
|
||||||
|
"result_type": ResultType.SINGLE,
|
||||||
|
"reading_definitions": [{"description": "Serial Number"}],
|
||||||
|
"test_responses": [
|
||||||
|
b"^D02514012345678901234567\r",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"ET": {
|
||||||
|
"name": "ET",
|
||||||
|
"command_type": CommandType.PI18_QUERY,
|
||||||
|
"description": "Total PV Generated Energy Inquiry",
|
||||||
|
"result_type": ResultType.SINGLE,
|
||||||
|
"reading_definitions": [
|
||||||
|
{"description": "Total PV Generated Energy", "reading_type": ReadingType.WATT_HOURS,
|
||||||
|
"response_type": ResponseType.INT, "icon": "mdi:solar-power", "device_class": "energy", "state_class": "total"},
|
||||||
|
],
|
||||||
|
"test_responses": [
|
||||||
|
b""
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"EY": {
|
||||||
|
"name": "EY",
|
||||||
|
"command_type": CommandType.PI18_QUERY,
|
||||||
|
"description": "Yearly PV Generated Energy Inquiry",
|
||||||
|
"result_type": ResultType.SINGLE,
|
||||||
|
"reading_definitions": [
|
||||||
|
{"description": "PV Generated Energy for Year", "reading_type": ReadingType.WATT_HOURS,
|
||||||
|
"response_type": ResponseType.INT, "icon": "mdi:counter", "device_class": "energy", "state_class": "total"},
|
||||||
|
{"description": "Year", "reading_type": ReadingType.YEAR,
|
||||||
|
"response_type": ResponseType.INFO_FROM_COMMAND, "format_template": "int(cn[3:])"},
|
||||||
|
],
|
||||||
|
"test_responses": [
|
||||||
|
b"^D01105580051\x0b\x9f\r",
|
||||||
|
],
|
||||||
|
"regex": "EY(\\d\\d\\d\\d)$",
|
||||||
|
},
|
||||||
|
"EM": {
|
||||||
|
"name": "EM",
|
||||||
|
"command_type": CommandType.PI18_QUERY,
|
||||||
|
"description": "Monthly PV Generated Energy Inquiry",
|
||||||
|
"result_type": ResultType.SINGLE,
|
||||||
|
"reading_definitions": [
|
||||||
|
{"description": "PV Generated Energy for Month", "reading_type": ReadingType.WATT_HOURS,
|
||||||
|
"response_type": ResponseType.INT, "icon": "mdi:solar-power", "device_class": "energy", "state_class": "total"},
|
||||||
|
{"description": "Year", "reading_type": ReadingType.YEAR,
|
||||||
|
"response_type": ResponseType.INFO_FROM_COMMAND, "format_template": "int(cn[3:7])"},
|
||||||
|
{"description": "Month", "reading_type": ReadingType.MONTH,
|
||||||
|
"response_type": ResponseType.INFO_FROM_COMMAND, "format_template": "calendar.month_name[int(cn[7:])]"},
|
||||||
|
],
|
||||||
|
"test_responses": [
|
||||||
|
b"",
|
||||||
|
],
|
||||||
|
"regex": "EM(\\d\\d\\d\\d\\d\\d)$",
|
||||||
|
},
|
||||||
|
"ED": {
|
||||||
|
"name": "ED",
|
||||||
|
"command_type": CommandType.PI18_QUERY,
|
||||||
|
"description": "Daily PV Generated Energy Inquiry",
|
||||||
|
"help": " -- display daily generated energy, format is QEDyyyymmdd",
|
||||||
|
"result_type": ResultType.SINGLE,
|
||||||
|
"reading_definitions": [
|
||||||
|
{"description": "PV Generated Energy for Day", "reading_type": ReadingType.WATT_HOURS,
|
||||||
|
"response_type": ResponseType.INT, "icon": "mdi:solar-power", "device_class": "energy", "state_class": "total"},
|
||||||
|
{"description": "Year", "reading_type": ReadingType.YEAR,
|
||||||
|
"response_type": ResponseType.INFO_FROM_COMMAND, "format_template": "int(cn[3:7])"},
|
||||||
|
{"description": "Month", "reading_type": ReadingType.MONTH,
|
||||||
|
"response_type": ResponseType.INFO_FROM_COMMAND, "format_template": "calendar.month_name[int(cn[7:9])]"},
|
||||||
|
{"description": "Day", "reading_type": ReadingType.DAY,
|
||||||
|
"response_type": ResponseType.INFO_FROM_COMMAND, "format_template": "int(cn[9:11])"},
|
||||||
|
],
|
||||||
|
"test_responses": [
|
||||||
|
b"(00238800!J\r",
|
||||||
|
],
|
||||||
|
"regex": "ED(\\d\\d\\d\\d\\d\\d\\d\\d)$",
|
||||||
|
},
|
||||||
|
"PIRI": {
|
||||||
|
"name": "PIRI",
|
||||||
|
"command_type": CommandType.PI18_QUERY,
|
||||||
|
"description": "Current Settings inquiry",
|
||||||
|
"help": " -- queries the current settings from the Inverter",
|
||||||
|
"result_type": ResultType.COMMA_DELIMITED,
|
||||||
|
"reading_definitions": [
|
||||||
|
{"description": "AC Input Voltage", "reading_type": ReadingType.VOLTS,
|
||||||
|
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
|
||||||
|
{"description": "AC Input Current", "reading_type": ReadingType.CURRENT,
|
||||||
|
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
|
||||||
|
{"description": "AC Output Voltage", "reading_type": ReadingType.VOLTS,
|
||||||
|
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
|
||||||
|
{"description": "AC Output Frequency", "reading_type": ReadingType.FREQUENCY,
|
||||||
|
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
|
||||||
|
{"description": "AC Output Current", "reading_type": ReadingType.CURRENT,
|
||||||
|
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
|
||||||
|
{"description": "AC Output Apparent Power", "reading_type": ReadingType.APPARENT_POWER},
|
||||||
|
{"description": "AC Output Active Power", "reading_type": ReadingType.WATTS},
|
||||||
|
{"description": "Battery Voltage", "reading_type": ReadingType.VOLTS,
|
||||||
|
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
|
||||||
|
{"description": "Battery re-charge Voltage", "reading_type": ReadingType.VOLTS,
|
||||||
|
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
|
||||||
|
{"description": "Battery re-discharge Voltage", "reading_type": ReadingType.VOLTS,
|
||||||
|
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
|
||||||
|
{"description": "Battery Under Voltage", "reading_type": ReadingType.VOLTS, "response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
|
||||||
|
{"description": "Battery Bulk Charge Voltage", "reading_type": ReadingType.VOLTS, "response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
|
||||||
|
{"description": "Battery Float Charge Voltage", "reading_type": ReadingType.VOLTS, "response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
|
||||||
|
{"description": "Battery Type", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.LIST, "options": BATTERY_TYPE_LIST},
|
||||||
|
{"description": "Max AC Charging Current", "reading_type": ReadingType.CURRENT},
|
||||||
|
{"description": "Max Charging Current", "reading_type": ReadingType.CURRENT},
|
||||||
|
{"description": "Input Voltage Range", "response_type": ResponseType.LIST, "options": ["Appliance", "UPS"]},
|
||||||
|
{"description": "Output Source Priority",
|
||||||
|
"response_type": ResponseType.LIST, "options": ["Solar - Utility - Battery", "Solar - Battery - Utility"]},
|
||||||
|
{"description": "Charger Source Priority",
|
||||||
|
"response_type": ResponseType.LIST, "options": ["Solar First", "Solar + Utility", "Only solar charging permitted"]},
|
||||||
|
{"description": "Max Parallel Units"},
|
||||||
|
{"description": "Machine Type", "response_type": ResponseType.LIST, "options": ["Off Grid", "Grid Tie"]},
|
||||||
|
{"description": "Topology", "response_type": ResponseType.LIST, "options": ["transformerless", "transformer"]},
|
||||||
|
{"description": "Output Mode", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.LIST, "options": OUTPUT_MODE_LIST},
|
||||||
|
{"description": "Solar power priority", "response_type": ResponseType.LIST, "options": ["Battery-Load-Utiliy + AC Charger", "Load-Battery-Utiliy"]},
|
||||||
|
{"description": "MPPT strings"},
|
||||||
|
{"description": "Unknown flags?", "response_type": ResponseType.STRING},
|
||||||
|
],
|
||||||
|
"test_responses": [
|
||||||
|
b"^D0882300,217,2300,500,217,5000,5000,480,480,530,440,570,570,2,10,070,1,1,1,9,0,0,0,0,1,00\xe1k\r",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"GS": {
|
||||||
|
"name": "GS",
|
||||||
|
"command_type": CommandType.PI18_QUERY,
|
||||||
|
"description": "General Status Parameters inquiry",
|
||||||
|
"result_type": ResultType.COMMA_DELIMITED,
|
||||||
|
"reading_definitions": [
|
||||||
|
{"description": "AC Input Voltage", "reading_type": ReadingType.VOLTS,
|
||||||
|
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:transmission-tower-export", "device_class": "voltage"},
|
||||||
|
{"description": "AC Input Frequency", "reading_type": ReadingType.FREQUENCY,
|
||||||
|
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:current-ac", "device_class": "frequency"},
|
||||||
|
{"description": "AC Output Voltage", "reading_type": ReadingType.VOLTS,
|
||||||
|
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:transmission-tower-export", "device_class": "voltage"},
|
||||||
|
{"description": "AC Output Frequency", "reading_type": ReadingType.FREQUENCY,
|
||||||
|
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:current-ac", "device_class": "frequency"},
|
||||||
|
{"description": "AC Output Apparent Power", "reading_type": ReadingType.APPARENT_POWER,
|
||||||
|
"response_type": ResponseType.INT, "icon": "mdi:power-plug", "device_class": "apparent_power"},
|
||||||
|
{"description": "AC Output Active Power", "reading_type": ReadingType.WATTS,
|
||||||
|
"response_type": ResponseType.INT, "icon": "mdi:power-plug", "device_class": "power", "state_class": "measurement"},
|
||||||
|
{"description": "AC Output Load", "reading_type": ReadingType.PERCENTAGE,
|
||||||
|
"response_type": ResponseType.INT, "icon": "mdi:brightness-percent"},
|
||||||
|
{"description": "Battery Voltage", "reading_type": ReadingType.VOLTS,
|
||||||
|
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:battery-outline", "device_class": "voltage"},
|
||||||
|
{"description": "Battery Voltage from SCC", "reading_type": ReadingType.VOLTS,
|
||||||
|
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:battery-outline", "device_class": "voltage"},
|
||||||
|
{"description": "Battery Voltage from SCC2", "reading_type": ReadingType.VOLTS,
|
||||||
|
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:battery-outline", "device_class": "voltage"},
|
||||||
|
{"description": "Battery Discharge Current", "reading_type": ReadingType.CURRENT,
|
||||||
|
"response_type": ResponseType.INT, "icon": "mdi:battery-negative", "device_class": "current"},
|
||||||
|
{"description": "Battery Charging Current", "reading_type": ReadingType.CURRENT,
|
||||||
|
"response_type": ResponseType.INT, "icon": "mdi:current-dc", "device_class": "current"},
|
||||||
|
{"description": "Battery Capacity", "reading_type": ReadingType.PERCENTAGE,
|
||||||
|
"response_type": ResponseType.INT, "icon": "mdi:brightness-percent", "device_class": "battery"},
|
||||||
|
{"description": "Inverter heat sink temperature", "reading_type": ReadingType.TEMPERATURE,
|
||||||
|
"response_type": ResponseType.INT, "icon": "mdi:details", "device_class": "temperature"},
|
||||||
|
{"description": "MPPT1 charger temperature", "reading_type": ReadingType.TEMPERATURE,
|
||||||
|
"response_type": ResponseType.INT, "icon": "mdi:details", "device_class": "temperature"},
|
||||||
|
{"description": "MPPT2 charger temperature", "reading_type": ReadingType.TEMPERATURE,
|
||||||
|
"response_type": ResponseType.INT, "icon": "mdi:details", "device_class": "temperature"},
|
||||||
|
{"description": "MPPT1 Input Power", "reading_type": ReadingType.WATTS,
|
||||||
|
"response_type": ResponseType.INT, "icon": "mdi:solar-power", "device_class": "power", "state_class": "measurement"},
|
||||||
|
{"description": "MPPT2 Input Power", "reading_type": ReadingType.WATTS,
|
||||||
|
"response_type": ResponseType.INT, "icon": "mdi:solar-power", "device_class": "power", "state_class": "measurement"},
|
||||||
|
{"description": "MPPT1 Input Voltage", "reading_type": ReadingType.VOLTS,
|
||||||
|
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:solar-power", "device_class": "voltage"},
|
||||||
|
{"description": "MPPT2 Input Voltage", "reading_type": ReadingType.VOLTS,
|
||||||
|
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:solar-power", "device_class": "voltage"},
|
||||||
|
{"description": "Setting value configuration state", "reading_type": ReadingType.MESSAGE,
|
||||||
|
"response_type": ResponseType.OPTION,
|
||||||
|
"options": {
|
||||||
|
"0": "Nothing changed",
|
||||||
|
"1": "Something changed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"description": "MPPT1 charger status", "reading_type": ReadingType.MESSAGE,
|
||||||
|
"response_type": ResponseType.OPTION,
|
||||||
|
"options": {
|
||||||
|
"0": "abnormal",
|
||||||
|
"1": "normal but not charged",
|
||||||
|
"2": "charging",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"description": "MPPT2 charger status", "reading_type": ReadingType.MESSAGE,
|
||||||
|
"response_type": ResponseType.OPTION,
|
||||||
|
"options": {
|
||||||
|
"0": "abnormal",
|
||||||
|
"1": "normal but not charged",
|
||||||
|
"2": "charging",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"description": "Load connection", "reading_type": ReadingType.MESSAGE,
|
||||||
|
"response_type": ResponseType.OPTION,
|
||||||
|
"options": {
|
||||||
|
"0": "disconnect",
|
||||||
|
"1": "connect",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"description": "Battery power direction", "reading_type": ReadingType.MESSAGE,
|
||||||
|
"response_type": ResponseType.OPTION,
|
||||||
|
"options": {
|
||||||
|
"0": "donothing",
|
||||||
|
"1": "charge",
|
||||||
|
"2": "discharge",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"description": "DC-AC power direction", "reading_type": ReadingType.MESSAGE,
|
||||||
|
"response_type": ResponseType.OPTION,
|
||||||
|
"options": {
|
||||||
|
"0": "donothing",
|
||||||
|
"1": "AC-DC",
|
||||||
|
"2": "DC-AC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"description": "Line power direction", "reading_type": ReadingType.MESSAGE,
|
||||||
|
"response_type": ResponseType.OPTION,
|
||||||
|
"options": {
|
||||||
|
"0": "donothing",
|
||||||
|
"1": "input",
|
||||||
|
"2": "output",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"description": "Parallel instance number", "reading_type": ReadingType.MESSAGE,
|
||||||
|
"response_type": ResponseType.LIST,
|
||||||
|
"options": ["Not valid", "valid"],
|
||||||
|
},
|
||||||
|
|
||||||
|
],
|
||||||
|
"test_responses": [
|
||||||
|
b"D1062232,499,2232,499,0971,0710,019,008,000,000,000,000,000,044,000,000,0520,0000,1941,0000,0,2,0,1,0,2,1,0\x09\x7b\r",
|
||||||
|
b"^D1062232,499,2232,499,1406,1376,028,549,000,000,000,010,095,060,000,000,0082,0000,1604,0000,0,2,0,1,1,1,1,0D\x12\r",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"MOD": {
|
||||||
|
"name": "MOD",
|
||||||
|
"command_type": CommandType.PI18_QUERY,
|
||||||
|
"description": "Mode inquiry",
|
||||||
|
"result_type": ResultType.SINGLE,
|
||||||
|
"reading_definitions": [
|
||||||
|
{"description": "Device Mode", "reading_type": ReadingType.MESSAGE,
|
||||||
|
"response_type": ResponseType.OPTION,
|
||||||
|
"options": {
|
||||||
|
"00": "Power on",
|
||||||
|
"01": "Standby",
|
||||||
|
"02": "Bypass",
|
||||||
|
"03": "Battery",
|
||||||
|
"04": "Fault",
|
||||||
|
"05": "Hybrid mode(Line mode, Grid mode)",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"test_responses": [
|
||||||
|
b"^D00505\xd9\x9f\r",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"MCHGCR": {
|
||||||
|
"name": "MCHGCR",
|
||||||
|
"command_type": CommandType.PI18_QUERY,
|
||||||
|
"description": "Max Charging Current Options inquiry",
|
||||||
|
"help": " -- queries the maximum charging current setting of the Inverter",
|
||||||
|
"result_type": ResultType.MULTIVALUED,
|
||||||
|
"reading_definitions": [
|
||||||
|
{"description": "Max Charging Current Options", "reading_type": ReadingType.MESSAGE_AMPS,
|
||||||
|
"response_type": ResponseType.STRING
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"test_responses": [
|
||||||
|
b"^D034010,020,030,040,050,060,070,080\x161\r",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"MUCHGCR": {
|
||||||
|
"name": "MUCHGCR",
|
||||||
|
"command_type": CommandType.PI18_QUERY,
|
||||||
|
"description": "Max Utility Charging Current Options inquiry",
|
||||||
|
"help": " -- queries the maximum utility charging current setting of the Inverter",
|
||||||
|
"result_type": ResultType.MULTIVALUED,
|
||||||
|
"reading_definitions": [
|
||||||
|
{"reading_type": ReadingType.MESSAGE_AMPS, "description": "Max Utility Charging Current", "response_type": ResponseType.STRING}
|
||||||
|
],
|
||||||
|
"test_responses": [
|
||||||
|
b"^D038002,010,020,030,040,050,060,070,080\xd01\r"
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"FLAG": {
|
||||||
|
"name": "FLAG",
|
||||||
|
"command_type": CommandType.PI18_QUERY,
|
||||||
|
"description": "Query enable/disable flag status",
|
||||||
|
"result_type": ResultType.COMMA_DELIMITED,
|
||||||
|
"reading_definitions": [
|
||||||
|
{"description": "Buzzer beep", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Overload bypass function", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Display back to default page", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Overload restart", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Over temperature restart", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Backlight on", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Alarm primary source interrupt", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Fault code record", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Reserved", "reading_type": ReadingType.MESSAGE},
|
||||||
|
],
|
||||||
|
"test_responses": [
|
||||||
|
b"^D0200,0,0,0,0,1,0,0,12\xc2\x39\r",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"VFW": {
|
||||||
|
"name": "VFW",
|
||||||
|
"description": "Device CPU version inquiry",
|
||||||
|
"command_type": CommandType.PI18_QUERY,
|
||||||
|
"result_type": ResultType.COMMA_DELIMITED,
|
||||||
|
"reading_definitions": [
|
||||||
|
{"description": "Main CPU Version", "reading_type": ReadingType.MESSAGE},
|
||||||
|
{"description": "Slave 1 CPU Version", "reading_type": ReadingType.MESSAGE},
|
||||||
|
{"description": "Slave 2 CPU Version", "reading_type": ReadingType.MESSAGE},
|
||||||
|
],
|
||||||
|
"test_responses": [
|
||||||
|
b"^D02005220,00000,00000\x3e\xf8\r",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
# Fault + warning bitmap. 2-digit fault code followed by ~32 0/1 warning bits.
|
||||||
|
# Fault-code list cross-referenced with PI30 QPGS (same firmware family).
|
||||||
|
"FWS": {
|
||||||
|
"name": "FWS",
|
||||||
|
"description": "Fault and warning status inquiry",
|
||||||
|
"command_type": CommandType.PI18_QUERY,
|
||||||
|
"result_type": ResultType.COMMA_DELIMITED,
|
||||||
|
"reading_definitions": [
|
||||||
|
{"description": "Fault code", "reading_type": ReadingType.MESSAGE,
|
||||||
|
"response_type": ResponseType.OPTION,
|
||||||
|
"options": {
|
||||||
|
"00": "No fault",
|
||||||
|
"01": "Fan is locked",
|
||||||
|
"02": "Over temperature",
|
||||||
|
"03": "Battery voltage is too high",
|
||||||
|
"04": "Battery voltage is too low",
|
||||||
|
"05": "Output short circuited or Over temperature",
|
||||||
|
"06": "Output voltage is too high",
|
||||||
|
"07": "Over load time out",
|
||||||
|
"08": "Bus voltage is too high",
|
||||||
|
"09": "Bus soft start failed",
|
||||||
|
"11": "Main relay failed",
|
||||||
|
"51": "Over current inverter",
|
||||||
|
"52": "Bus soft start failed",
|
||||||
|
"53": "Inverter soft start failed",
|
||||||
|
"54": "Self-test failed",
|
||||||
|
"55": "Over DC voltage on output of inverter",
|
||||||
|
"56": "Battery connection is open",
|
||||||
|
"57": "Current sensor failed",
|
||||||
|
"58": "Output voltage is too low",
|
||||||
|
"60": "Inverter negative power",
|
||||||
|
"71": "Parallel version different",
|
||||||
|
"72": "Output circuit failed",
|
||||||
|
"80": "CAN communication failed",
|
||||||
|
"81": "Parallel host line lost",
|
||||||
|
"82": "Parallel synchronized signal lost",
|
||||||
|
"83": "Parallel battery voltage detect different",
|
||||||
|
"84": "Parallel Line voltage or frequency detect different",
|
||||||
|
"85": "Parallel Line input current unbalanced",
|
||||||
|
"86": "Parallel output setting different",
|
||||||
|
}},
|
||||||
|
{"description": "PV loss warning", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Inverter fault", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Bus over", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Bus under", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Bus soft fail", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Line fail", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "OPV short", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Inverter voltage too low", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Inverter voltage too high", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Over temperature", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Fan locked", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Battery voltage high", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Battery low alarm", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Battery under shutdown", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Battery derating", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Overload", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "EEPROM fault", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Inverter over current", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Inverter soft fail", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Self test fail", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "OP DC voltage over", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Battery open", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Current sensor fail", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Battery short", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Power limit", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "PV voltage high", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "MPPT overload fault", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "MPPT overload warning", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Battery too low to charge", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Battery weak", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
{"description": "Battery equalization", "response_type": ResponseType.ENABLED_BOOL},
|
||||||
|
],
|
||||||
|
"test_responses": [
|
||||||
|
b"^D07100,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0\xaa\xaa\r",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
# Per-unit parallel view. PGS<n>, n = 0..N-1 (0 is master).
|
||||||
|
# LVX6048 emits a 30-field layout that differs from the PI30 QPGS layout;
|
||||||
|
# only fields confirmed against live responses are semantically named here.
|
||||||
|
# The rest are exposed as raw strings so the command doesn't error out, and
|
||||||
|
# can be tightened later as more firmware rev responses are confirmed.
|
||||||
|
# Observed unit-1 response (Not valid + fault 71 "Parallel version different"):
|
||||||
|
# 0,4,71,2453,599,0000,000,0000,0000,00000,00000,000,211,005,000,000,000,
|
||||||
|
# 000,0008,0000,2925,0000,1,0,0,0,0,0,016
|
||||||
|
"PGS": {
|
||||||
|
"name": "PGS",
|
||||||
|
"description": "Parallel general status inquiry",
|
||||||
|
"help": " -- example: PGS0 queries parallel status for instance 0 (master)",
|
||||||
|
"command_type": CommandType.PI18_QUERY,
|
||||||
|
"result_type": ResultType.COMMA_DELIMITED,
|
||||||
|
"reading_definitions": [
|
||||||
|
{"description": "Parallel instance number", "reading_type": ReadingType.MESSAGE,
|
||||||
|
"response_type": ResponseType.LIST, "options": ["Not valid", "valid"]},
|
||||||
|
{"description": "Parallel unit count", "reading_type": ReadingType.MESSAGE},
|
||||||
|
{"description": "Fault code", "reading_type": ReadingType.MESSAGE},
|
||||||
|
{"description": "Field 4 (raw)", "reading_type": ReadingType.MESSAGE},
|
||||||
|
{"description": "Grid frequency", "reading_type": ReadingType.FREQUENCY,
|
||||||
|
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "device_class": "frequency"},
|
||||||
|
{"description": "AC output voltage", "reading_type": ReadingType.VOLTS,
|
||||||
|
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "device_class": "voltage"},
|
||||||
|
{"description": "AC output frequency (raw)", "reading_type": ReadingType.MESSAGE},
|
||||||
|
{"description": "AC output apparent power", "reading_type": ReadingType.APPARENT_POWER,
|
||||||
|
"response_type": ResponseType.INT, "device_class": "apparent_power"},
|
||||||
|
{"description": "AC output active power", "reading_type": ReadingType.WATTS,
|
||||||
|
"response_type": ResponseType.INT, "device_class": "power"},
|
||||||
|
{"description": "Total AC output apparent power", "reading_type": ReadingType.APPARENT_POWER, "response_type": ResponseType.INT},
|
||||||
|
{"description": "Total AC output active power", "reading_type": ReadingType.WATTS, "response_type": ResponseType.INT},
|
||||||
|
{"description": "Load percentage", "reading_type": ReadingType.PERCENTAGE, "response_type": ResponseType.INT},
|
||||||
|
{"description": "Field 13 (raw)", "reading_type": ReadingType.MESSAGE},
|
||||||
|
{"description": "Field 14 (raw)", "reading_type": ReadingType.MESSAGE},
|
||||||
|
{"description": "Field 15 (raw)", "reading_type": ReadingType.MESSAGE},
|
||||||
|
{"description": "Field 16 (raw)", "reading_type": ReadingType.MESSAGE},
|
||||||
|
{"description": "Field 17 (raw)", "reading_type": ReadingType.MESSAGE},
|
||||||
|
{"description": "Field 18 (raw)", "reading_type": ReadingType.MESSAGE},
|
||||||
|
{"description": "Field 19 (raw)", "reading_type": ReadingType.MESSAGE},
|
||||||
|
{"description": "Field 20 (raw)", "reading_type": ReadingType.MESSAGE},
|
||||||
|
{"description": "Field 21 (raw)", "reading_type": ReadingType.MESSAGE},
|
||||||
|
{"description": "Field 22 (raw)", "reading_type": ReadingType.MESSAGE},
|
||||||
|
{"description": "Flag 23 (raw)", "reading_type": ReadingType.MESSAGE},
|
||||||
|
{"description": "Flag 24 (raw)", "reading_type": ReadingType.MESSAGE},
|
||||||
|
{"description": "Flag 25 (raw)", "reading_type": ReadingType.MESSAGE},
|
||||||
|
{"description": "Flag 26 (raw)", "reading_type": ReadingType.MESSAGE},
|
||||||
|
{"description": "Flag 27 (raw)", "reading_type": ReadingType.MESSAGE},
|
||||||
|
{"description": "Flag 28 (raw)", "reading_type": ReadingType.MESSAGE},
|
||||||
|
{"description": "Field 30 (raw)", "reading_type": ReadingType.MESSAGE},
|
||||||
|
],
|
||||||
|
"test_responses": [
|
||||||
|
b"^D1130,4,71,2453,599,0000,000,0000,0000,00000,00000,000,211,005,000,000,000,000,0008,0000,2925,0000,1,0,0,0,0,0,016\x8f\xad\r",
|
||||||
|
],
|
||||||
|
"regex": "PGS(\\d+)$",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
COMMANDS_TO_REMOVE = []
|
||||||
|
|
||||||
|
|
||||||
|
class PI18(AbstractProtocol):
|
||||||
|
""" pi18 protocol handler """
|
||||||
|
def __str__(self):
|
||||||
|
return "PI18 protocol handler"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.protocol_id = b"PI18"
|
||||||
|
self.add_command_definitions(QUERY_COMMANDS)
|
||||||
|
self.add_command_definitions(SETTER_COMMANDS, result_type=ResultType.PI18_ACK)
|
||||||
|
self.remove_command_definitions(COMMANDS_TO_REMOVE)
|
||||||
|
self.check_definitions_count(expected=26) # Count of all Commands
|
||||||
|
self.add_supported_ports([PortType.SERIAL, PortType.USB])
|
||||||
|
|
||||||
|
def check_crc(self, response: str, command_definition: CommandDefinition = None):
|
||||||
|
""" crc check, override for now """
|
||||||
|
log.debug("check crc for %s in pi18", response)
|
||||||
|
if response.startswith(b"^D") or response.startswith(b"^1") or response.startswith(b"^0"):
|
||||||
|
# get response CRC
|
||||||
|
data_to_check = response[:-3]
|
||||||
|
crc_high, crc_low = crc(data_to_check)
|
||||||
|
# print(crc_high, crc_low)
|
||||||
|
# print(response[-3], response[-2])
|
||||||
|
if (crc_high, crc_low) == (response[-3], response[-2]):
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
log.info("PI18 response check_crc doesnt match calc (%x, %x), got (%x, %x)", crc_high, crc_low, response[-3], response[-2])
|
||||||
|
raise InvalidCRC(f"PI18 response check_crc doesnt match calc ({crc_high:02x}, {crc_low:02x}), got ({response[-3]:02x}, {response[-2]:02x})")
|
||||||
|
else:
|
||||||
|
log.info("PI18 response doesnt start with ^D - check_crc fails")
|
||||||
|
raise InvalidResponse("PI18 response starts with invalid character - crc check fails")
|
||||||
|
|
||||||
|
log.info("PI18 response check_crc fall through")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def trim_response(self, response: str, command_definition: CommandDefinition = None) -> str:
|
||||||
|
""" Remove extra characters from response """
|
||||||
|
log.debug("trim %s, definition: %s", response, command_definition)
|
||||||
|
if response.startswith(b"^D"):
|
||||||
|
# trim ^Dxxx where xxx is data length
|
||||||
|
response = response[5:]
|
||||||
|
if response.endswith(b'\r'):
|
||||||
|
# has checksum, so trim last 3 chars
|
||||||
|
response = response[:-3]
|
||||||
|
if response.startswith(b'('):
|
||||||
|
# pi30 style response
|
||||||
|
response = response[1:]
|
||||||
|
# if response.startswith(b'^1') or response.startswith(b'^0'):
|
||||||
|
# # ACK / NACK response
|
||||||
|
# response = response[1:]
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_full_command(self, command: str) -> bytes:
|
||||||
|
""" generate the full command including prefix, crc and \n as needed """
|
||||||
|
log.info("Using protocol: %s with %i commands", self.protocol_id, len(self.command_definitions))
|
||||||
|
command_defn = self.get_command_definition(command)
|
||||||
|
|
||||||
|
# raise exception if no command definition is found
|
||||||
|
if command_defn is None:
|
||||||
|
raise CommandDefinitionMissing(f"No definition found in PI18 for {command}")
|
||||||
|
|
||||||
|
# full command is ^PlllCCCcrc\n or ^SlllCCCcrc\n
|
||||||
|
# lll = length of all except ^Dlll
|
||||||
|
# CCC = command
|
||||||
|
# crc = 2 bytes
|
||||||
|
length = len(command) + 3
|
||||||
|
# Determine prefix
|
||||||
|
match command_defn.command_type:
|
||||||
|
case CommandType.PI18_QUERY:
|
||||||
|
prefix = "^P"
|
||||||
|
case CommandType.PI18_SETTER:
|
||||||
|
prefix = "^S"
|
||||||
|
case _:
|
||||||
|
# edge case / default PI30 command / maybe this should raise an error
|
||||||
|
prefix = "("
|
||||||
|
full_command = bytes(f"{prefix}{length:#03d}{command}", "utf-8")
|
||||||
|
crc_high, crc_low = crc(full_command)
|
||||||
|
full_command += bytes([crc_high, crc_low, 13])
|
||||||
|
|
||||||
|
log.debug("full command: %s", full_command)
|
||||||
|
return full_command
|
||||||
38
LVX6048/powermon-patches/port_config_model.py
Normal file
38
LVX6048/powermon-patches/port_config_model.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
""" pydantic definitions for the powermon port config model
|
||||||
|
"""
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from powermon.configmodel import NoExtraBaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class BlePortConfig(NoExtraBaseModel):
|
||||||
|
""" model/allowed elements for ble port config """
|
||||||
|
type: Literal["ble"]
|
||||||
|
mac: str
|
||||||
|
protocol: None | str
|
||||||
|
victron_key: None | str = Field(default=None, repr=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SerialPortConfig(NoExtraBaseModel):
|
||||||
|
""" model/allowed elements for serial port config """
|
||||||
|
type: Literal["serial"]
|
||||||
|
path: str
|
||||||
|
baud: None | int = Field(default=None)
|
||||||
|
protocol: None | str
|
||||||
|
|
||||||
|
|
||||||
|
class UsbPortConfig(NoExtraBaseModel):
|
||||||
|
""" model/allowed elements for usb port config """
|
||||||
|
type: Literal["usb"]
|
||||||
|
path: None | str
|
||||||
|
protocol: None | str
|
||||||
|
serial_number: None | str | int = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPortConfig(NoExtraBaseModel):
|
||||||
|
""" model/allowed elements for test port config """
|
||||||
|
type: Literal["test"]
|
||||||
|
response_number: None | int = Field(default=None)
|
||||||
|
protocol: None | str = Field(default=None)
|
||||||
76
LVX6048/powermon-patches/ports_init.py
Normal file
76
LVX6048/powermon-patches/ports_init.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
""" powermon / ports / __init__.py """
|
||||||
|
import logging
|
||||||
|
from enum import StrEnum, auto
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from powermon.libs.errors import ConfigError
|
||||||
|
|
||||||
|
|
||||||
|
# Set-up logger
|
||||||
|
log = logging.getLogger("ports")
|
||||||
|
|
||||||
|
|
||||||
|
class PortType(StrEnum):
|
||||||
|
""" enumeration of supported / known port types """
|
||||||
|
UNKNOWN = auto()
|
||||||
|
TEST = auto()
|
||||||
|
SERIAL = auto()
|
||||||
|
USB = auto()
|
||||||
|
BLE = auto()
|
||||||
|
|
||||||
|
JKBLE = auto()
|
||||||
|
MQTT = auto()
|
||||||
|
VSERIAL = auto()
|
||||||
|
DALYSERIAL = auto()
|
||||||
|
ESP32 = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class PortTypeDTO(BaseModel):
|
||||||
|
""" data transfer model for PortType class """
|
||||||
|
port_type: PortType
|
||||||
|
|
||||||
|
def from_config(port_config, serial_number=None):
|
||||||
|
""" get a port object from config data """
|
||||||
|
log.debug("port_config: %s", port_config)
|
||||||
|
|
||||||
|
port_object = None
|
||||||
|
if not port_config:
|
||||||
|
raise ConfigError("no port config supplied")
|
||||||
|
|
||||||
|
# port type is mandatory
|
||||||
|
port_type = port_config.get("type")
|
||||||
|
log.debug("portType: %s", port_type)
|
||||||
|
|
||||||
|
# return None if port type is not defined
|
||||||
|
if port_type is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# add serial_number to config — but only if the port config didn't already
|
||||||
|
# specify one. Port-level serial_number is the hardware serial used by
|
||||||
|
# USBPort.resolve_path for wildcard matching; device-level serial_number is
|
||||||
|
# the logical HA identifier and is unrelated.
|
||||||
|
if port_config.get('serial_number') is None:
|
||||||
|
port_config['serial_number'] = serial_number
|
||||||
|
|
||||||
|
# build port object
|
||||||
|
match port_type:
|
||||||
|
case PortType.TEST:
|
||||||
|
from powermon.ports.testport import TestPort
|
||||||
|
port_object = TestPort.from_config(config=port_config)
|
||||||
|
case PortType.SERIAL:
|
||||||
|
from powermon.ports.serialport import SerialPort
|
||||||
|
port_object = SerialPort.from_config(config=port_config)
|
||||||
|
case PortType.USB:
|
||||||
|
from powermon.ports.usbport import USBPort
|
||||||
|
port_object = USBPort.from_config(config=port_config)
|
||||||
|
# Pattern for port types that cause problems when imported
|
||||||
|
case PortType.BLE:
|
||||||
|
log.debug("port_type BLE found")
|
||||||
|
from powermon.ports.bleport import BlePort
|
||||||
|
port_object = BlePort.from_config(config=port_config)
|
||||||
|
case _:
|
||||||
|
log.info("port type object not found for %s", port_type)
|
||||||
|
raise ConfigError(f"Invalid port type: '{port_type}'")
|
||||||
|
|
||||||
|
return port_object
|
||||||
169
LVX6048/powermon-patches/usbport.py
Normal file
169
LVX6048/powermon-patches/usbport.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
""" powermon / ports / usbport.py """
|
||||||
|
# import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from glob import glob
|
||||||
|
|
||||||
|
from powermon.commands.command import Command
|
||||||
|
from powermon.commands.result import Result, ResultType
|
||||||
|
from powermon.libs.errors import ConfigError, PowermonProtocolError
|
||||||
|
from powermon.ports import PortType
|
||||||
|
from powermon.ports.abstractport import AbstractPort, _AbstractPortDTO
|
||||||
|
from powermon.protocols import get_protocol_definition
|
||||||
|
|
||||||
|
log = logging.getLogger("USBPort")
|
||||||
|
|
||||||
|
|
||||||
|
class UsbPortDTO(_AbstractPortDTO):
|
||||||
|
""" data transfer model for SerialPort class """
|
||||||
|
path: str
|
||||||
|
serial_number: None | int | str
|
||||||
|
|
||||||
|
|
||||||
|
class USBPort(AbstractPort):
|
||||||
|
""" usb port object """
|
||||||
|
@classmethod
|
||||||
|
async def from_config(cls, config=None):
|
||||||
|
log.debug("building usb port. config:%s", config)
|
||||||
|
path = config.get("path", "/dev/hidraw0")
|
||||||
|
serial_number = config.get("serial_number")
|
||||||
|
# get protocol handler, default to PI30 if not supplied
|
||||||
|
protocol = get_protocol_definition(protocol=config.get("protocol", "PI30"))
|
||||||
|
# instantiate class
|
||||||
|
_class = cls(path=path, protocol=protocol)
|
||||||
|
# deal with wildcard path resolution
|
||||||
|
_class.path = await _class.resolve_path(path, serial_number)
|
||||||
|
return _class
|
||||||
|
|
||||||
|
def __init__(self, path, protocol) -> None:
|
||||||
|
self.port_type = PortType.USB
|
||||||
|
super().__init__(protocol=protocol)
|
||||||
|
|
||||||
|
self.path = None
|
||||||
|
self.port = None
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_path(self, path, serial_number):
|
||||||
|
"""Async method to resolve a valid path by testing each one."""
|
||||||
|
# expand 'wildcard'
|
||||||
|
paths = glob(path)
|
||||||
|
if not paths:
|
||||||
|
raise ConfigError(f"No matching paths found on this system for {path}")
|
||||||
|
|
||||||
|
if len(paths) == 1:
|
||||||
|
return paths[0] # only one valid result
|
||||||
|
|
||||||
|
# More than one valid path
|
||||||
|
# check we have something to look for
|
||||||
|
if serial_number is None:
|
||||||
|
raise ConfigError("Wildcard paths require a serial_number in config.")
|
||||||
|
# check we have get_id in this protocol
|
||||||
|
try:
|
||||||
|
command = self.protocol.get_id_command()
|
||||||
|
except PowermonProtocolError as ex:
|
||||||
|
raise ConfigError(f"No get_id in protocol: {self.protocol.protocol_id}") from ex
|
||||||
|
|
||||||
|
for _path in paths:
|
||||||
|
log.debug("Checking path: %s for serial_number: %s", _path, serial_number)
|
||||||
|
self.path = _path
|
||||||
|
await self.connect()
|
||||||
|
res = await self.send_and_receive(command=command)
|
||||||
|
await self.disconnect()
|
||||||
|
|
||||||
|
if res.is_valid and str(res.readings[0].data_value) == str(serial_number):
|
||||||
|
log.info("SUCCESS: path: %s matches serial_number: %s", _path, serial_number)
|
||||||
|
return _path # return the matching path
|
||||||
|
raise ConfigError(f"None of the paths match serial_number: {serial_number}")
|
||||||
|
|
||||||
|
|
||||||
|
def to_dto(self):
|
||||||
|
dto = UsbPortDTO(port_type="usb", path=self.path, protocol=self.protocol.to_dto())
|
||||||
|
return dto
|
||||||
|
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
return self.port is not None
|
||||||
|
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
if self.is_connected():
|
||||||
|
log.debug("USBPort already connected")
|
||||||
|
return True
|
||||||
|
log.debug("USBPort connecting. path:%s, protocol:%s", self.path, self.protocol)
|
||||||
|
try:
|
||||||
|
self.port = os.open(self.path, os.O_RDWR | os.O_NONBLOCK)
|
||||||
|
log.debug("USBPort port number $%s", self.port)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Error openning usb port: %s", e)
|
||||||
|
self.port = None
|
||||||
|
self.error_message = e
|
||||||
|
return self.is_connected()
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
log.debug("USBPort disconnecting: %i", self.port)
|
||||||
|
if self.port is not None:
|
||||||
|
os.close(self.port)
|
||||||
|
self.port = None
|
||||||
|
|
||||||
|
async def send_and_receive(self, command: Command) -> Result:
|
||||||
|
if not self.is_connected():
|
||||||
|
log.warning("USBPort not connected")
|
||||||
|
return command.build_result(result_type=ResultType.ERROR, raw_response=b"USBPort not connected", protocol=self.protocol)
|
||||||
|
response_line = bytes()
|
||||||
|
|
||||||
|
# Drain any leftover bytes from previous command's response so we
|
||||||
|
# don't parse them as this command's reply.
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
stale = os.read(self.port, 256)
|
||||||
|
if not stale:
|
||||||
|
break
|
||||||
|
log.debug("drained %i stale bytes: %s", len(stale), stale)
|
||||||
|
except BlockingIOError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Send the command to the open usb connection
|
||||||
|
full_command = command.full_command
|
||||||
|
cmd_len = len(full_command)
|
||||||
|
log.debug("length of to_send: %i", cmd_len)
|
||||||
|
# for command of len < 8 it ok just to send
|
||||||
|
# otherwise need to pack to a multiple of 8 bytes and send 8 at a time
|
||||||
|
try:
|
||||||
|
if cmd_len <= 8:
|
||||||
|
# Send all at once
|
||||||
|
log.debug("sending full_command in on shot")
|
||||||
|
time.sleep(0.05)
|
||||||
|
os.write(self.port, full_command)
|
||||||
|
else:
|
||||||
|
log.debug("multiple chunk send")
|
||||||
|
chunks = [full_command[i:i + 8] for i in range(0, cmd_len, 8)]
|
||||||
|
for chunk in chunks:
|
||||||
|
# pad chunk to 8 bytes
|
||||||
|
if len(chunk) < 8:
|
||||||
|
padding = 8 - len(chunk)
|
||||||
|
chunk += b'\x00' * padding
|
||||||
|
log.debug("sending chunk: %s", chunk)
|
||||||
|
time.sleep(0.05)
|
||||||
|
os.write(self.port, chunk)
|
||||||
|
time.sleep(0.25)
|
||||||
|
# Read from the usb connection
|
||||||
|
# try to a max of 100 times
|
||||||
|
for _ in range(100):
|
||||||
|
# attempt to deal with resource busy and other failures to read
|
||||||
|
time.sleep(0.15)
|
||||||
|
try:
|
||||||
|
r = os.read(self.port, 256)
|
||||||
|
except BlockingIOError:
|
||||||
|
continue
|
||||||
|
response_line += r
|
||||||
|
# Finished is \r is in byte_response
|
||||||
|
if bytes([13]) in response_line:
|
||||||
|
# remove anything after the \r
|
||||||
|
response_line = response_line[: response_line.find(bytes([13])) + 1]
|
||||||
|
break
|
||||||
|
except BrokenPipeError as e:
|
||||||
|
log.debug("USB read error: %s", e)
|
||||||
|
log.debug("usb response was: %s", response_line)
|
||||||
|
# response = self.get_protocol().check_response_and_trim(response_line)
|
||||||
|
result = command.build_result(raw_response=response_line, protocol=self.protocol)
|
||||||
|
|
||||||
|
return result
|
||||||
26
LVX6048/smoketest/console.yaml
Normal file
26
LVX6048/smoketest/console.yaml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
device:
|
||||||
|
name: lvx6048_console
|
||||||
|
serial_number: lvx6048_console
|
||||||
|
port:
|
||||||
|
type: usb
|
||||||
|
path: /dev/lvx6048-1
|
||||||
|
protocol: PI18
|
||||||
|
commands:
|
||||||
|
- command: GS
|
||||||
|
trigger:
|
||||||
|
every: 5
|
||||||
|
outputs:
|
||||||
|
- type: screen
|
||||||
|
format: table
|
||||||
|
- command: MOD
|
||||||
|
trigger:
|
||||||
|
every: 10
|
||||||
|
outputs:
|
||||||
|
- type: screen
|
||||||
|
format: table
|
||||||
|
- command: PIRI
|
||||||
|
trigger:
|
||||||
|
every: 300
|
||||||
|
outputs:
|
||||||
|
- type: screen
|
||||||
|
format: table
|
||||||
12
LVX6048/smoketest/smoketest.yaml
Normal file
12
LVX6048/smoketest/smoketest.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
device:
|
||||||
|
name: lvx6048_smoketest
|
||||||
|
serial_number: lvx6048_smoketest
|
||||||
|
port:
|
||||||
|
type: usb
|
||||||
|
path: /dev/lvx6048-1
|
||||||
|
protocol: PI18
|
||||||
|
commands:
|
||||||
|
- command: GS
|
||||||
|
outputs:
|
||||||
|
- type: screen
|
||||||
|
format: table
|
||||||
13
battery/.claude/settings.local.json
Normal file
13
battery/.claude/settings.local.json
Normal 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
134
battery/eg4_lifepower.py
Normal 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
64
battery/probe.py
Normal 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
1
battery/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pyserial>=3.5
|
||||||
208
battery/sweep.py
Normal file
208
battery/sweep.py
Normal 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
157
eg4battery/Install.md
Normal 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
160
eg4battery/NOTES.md
Normal 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
95
eg4battery/README.md
Normal 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
969
eg4battery/bin/eg4-battery
Executable 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())
|
||||||
48
eg4battery/config/eg4-battery.yaml.example
Normal file
48
eg4battery/config/eg4-battery.yaml.example
Normal 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
|
||||||
19
eg4battery/etc/systemd/system/eg4-battery.service
Normal file
19
eg4battery/etc/systemd/system/eg4-battery.service
Normal 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
|
||||||
5
eg4battery/etc/udev/rules.d/99-eg4-rs485.rules
Normal file
5
eg4battery/etc/udev/rules.d/99-eg4-rs485.rules
Normal 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"
|
||||||
58
eg4battery/homeassistant/README.md
Normal file
58
eg4battery/homeassistant/README.md
Normal 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.
|
||||||
116
eg4battery/homeassistant/lovelace_overview.yaml
Normal file
116
eg4battery/homeassistant/lovelace_overview.yaml
Normal 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
|
||||||
51
eg4battery/homeassistant/recorder.yaml
Normal file
51
eg4battery/homeassistant/recorder.yaml
Normal 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
|
||||||
174
eg4battery/homeassistant/template_sensors.yaml
Normal file
174
eg4battery/homeassistant/template_sensors.yaml
Normal 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
93
eg4battery/install.sh
Executable 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"
|
||||||
Binary file not shown.
@@ -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>
|
||||||
@@ -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;
|
||||||
Binary file not shown.
@@ -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>
|
||||||
@@ -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;
|
||||||
Binary file not shown.
@@ -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>
|
||||||
@@ -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;
|
||||||
Binary file not shown.
@@ -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>
|
||||||
@@ -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;
|
||||||
Binary file not shown.
@@ -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>
|
||||||
@@ -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;
|
||||||
Binary file not shown.
@@ -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>
|
||||||
@@ -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;
|
||||||
Binary file not shown.
@@ -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>
|
||||||
@@ -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;
|
||||||
Binary file not shown.
@@ -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>
|
||||||
@@ -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;
|
||||||
Binary file not shown.
@@ -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>
|
||||||
@@ -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;
|
||||||
Binary file not shown.
@@ -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>
|
||||||
@@ -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;
|
||||||
Binary file not shown.
@@ -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>
|
||||||
@@ -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;
|
||||||
Binary file not shown.
@@ -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>
|
||||||
@@ -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;
|
||||||
Binary file not shown.
@@ -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>
|
||||||
@@ -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;
|
||||||
Binary file not shown.
@@ -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>
|
||||||
@@ -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;
|
||||||
Binary file not shown.
@@ -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>
|
||||||
@@ -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;
|
||||||
Binary file not shown.
@@ -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>
|
||||||
@@ -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;
|
||||||
Binary file not shown.
@@ -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>
|
||||||
@@ -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;
|
||||||
Binary file not shown.
@@ -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
Reference in New Issue
Block a user