From 7a1c47d34cc2d6bb75e8d4456c422b42fb22b9ec Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Mon, 17 Aug 2026 17:26:14 -0400 Subject: [PATCH] li3: add Lithionics Li3 house battery BMS -> Home Assistant MQTT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publishes the RV's 12V LiFePO4 house battery to HA via MQTT discovery (pack voltage, 4 cell voltages, current, SOC, BMS/battery temp, status). Connects over the battery's BLE HM-10 UART module (service ffe0/char ffe1, no pairing) using bleak; protocol reverse-engineered from the com.lithionics.bms Android app's own BLE/parsing code. Deliberately named li3/ and van-li3-battery, not battery/van-battery — that name is reserved for the host's own AC/UPS power-supply monitor (different hardware, unrelated concern). Self-heals a bluetoothd discovery-state wedge (Discovering stuck "yes", connects failing with le-connection-abort-by-local) that shows up after repeated failed connects to this device on the Pi's onboard adapter: the daemon retries scan/connect internally (MQTT session and HA entities stay up across retries) and restarts bluetooth.service itself after 3 consecutive scan failures, rate-limited to once per 5 min. --- README.md | 35 ++++++ deploy.sh | 19 ++- li3/config.json.example | 12 ++ li3/van-li3-battery | 224 ++++++++++++++++++++++++++++++++++++ li3/van-li3-battery.service | 17 +++ 5 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 li3/config.json.example create mode 100755 li3/van-li3-battery create mode 100644 li3/van-li3-battery.service diff --git a/README.md b/README.md index 0ee5d66..6ef4a73 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,16 @@ This directory is the source of truth. The live system files live under `/etc`, The `hbc` binary itself (venv at `~/venvs/hbd`, symlink `~/bin/hbc`) is installed once via the heartbeat project's own installer — see §4. `deploy.sh` only starts the service once it exists. +### `li3/` — RV house battery (BLE BMS → MQTT/HA) +| file | → installs to | purpose | +|---|---|---| +| `van-li3-battery` | `/usr/local/sbin/van-li3-battery` | BLE→MQTT daemon (Python, bleak + paho-mqtt): reads the Lithionics Li3 BMS, publishes HA MQTT discovery + state | +| `config.json.example` | → `/etc/van-li3/config.json` (seeded if absent) | BLE address, broker, MQTT topic/discovery **template**; real file is 0600, **not** in the repo | +| `van-li3-battery.service` | `/etc/systemd/system/van-li3-battery.service` | `Restart=always` | + +Not to be confused with `power/van-battery` — that's the host's own AC/battery power +supply (laptop UPS-style monitor), unrelated hardware and purpose. + ### `ha/` — Home Assistant (native container) | file | → installs to | purpose | |---|---|---| @@ -256,6 +266,31 @@ paho 2.x callback API. - Note: the broker holds an ancient *retained* message on this topic from a 2023 OwnTracks device (tid `RV`, Winegard SSID); our publishes are not retained. +### Li3 battery monitor (`van-li3-battery`) +Publishes the RV's 12V LiFePO4 house battery (Lithionics Li3, BLE HM-10 UART module — +service `ffe0`/char `ffe1`, no pairing) to Home Assistant as 10 sensor entities (pack +voltage, 4 cell voltages, current, SOC, BMS/battery temperature, status) via MQTT +discovery. Protocol (CSV telemetry lines after sending `$traceon`+`$info`) reverse- +engineered from the `com.lithionics.bms` Android app's own BLE/parsing code — see the +script's docstring for the full field layout. +- Not the same battery as `power/van-battery` (host's own AC/UPS power supply) — + different hardware, different concern, deliberately different naming. +- Broker credentials live only in `/etc/van-li3/config.json` (0600, seeded from + `li3/config.json.example` — edit after first deploy, same pattern as `van-gps`). +- **Self-healing BLE**: scan/connect retries happen *inside* the running process (MQTT + session and HA entities stay up across them, no flapping) rather than relying on + systemd restarts. This Pi's onboard Bluetooth adapter (Cypress/CYW43) occasionally + wedges bluetoothd's discovery state after a run of failed connects to this specific + device (`Discovering` stays `yes` forever, every subsequent connect fails with + `le-connection-abort-by-local`) — after 3 consecutive scan failures the daemon + restarts `bluetooth.service` itself to clear it, rate-limited to once per 5 min so it + doesn't repeatedly disrupt the AP's other BLE gear (motion sensors, IR remote). +- This BLE module accepts only **one central connection at a time** — while + `van-li3-battery` holds it, the Li3 phone app can't connect simultaneously. + `systemctl stop van-li3-battery` to free it up for the app. +- Verify: `journalctl -u van-li3-battery -f` (look for `connected` / `published: {...}`), + or `mosquitto_sub -h localhost -u homeassistant -P -t van/li3_battery/state`. + ### ZeroTier managed DNS - `wrede.pvt` resolves over ZeroTier when off the home LAN. Mechanism: `allowDNS=1` (prereq) + `zerotier-systemd-manager` writes `99-ztuga7c2kh.network`, networkd applies it to resolved. - Verify: `resolvectl status ztuga7c2kh` shows `DNS` scope + the two servers + `wrede.pvt`. diff --git a/deploy.sh b/deploy.sh index 6c00efd..0d361e3 100755 --- a/deploy.sh +++ b/deploy.sh @@ -188,6 +188,21 @@ python3 -c 'import gps' 2>/dev/null \ python3 -c 'import paho.mqtt' 2>/dev/null \ || warn "python3-paho-mqtt missing (apt install python3-paho-mqtt) — van-gps-owntracks won't start" +echo "== li3 (RV house battery BMS -> MQTT/HA) ==" +# NOT the laptop's own AC/battery monitor — that's power/van-battery (different +# hardware, different concern). This is the Lithionics Li3 12V LiFePO4 house +# battery, over its BLE HM-10 UART module (no pairing) -> Home Assistant MQTT +# discovery. Broker + creds live only on the system (0600, seeded from +# li3/config.json.example — same pattern as gps/pushover). +install -D -m0755 li3/van-li3-battery /usr/local/sbin/van-li3-battery +install -D -m0644 li3/van-li3-battery.service /etc/systemd/system/van-li3-battery.service +if [ ! -f /etc/van-li3/config.json ]; then + install -D -m0600 li3/config.json.example /etc/van-li3/config.json + warn "seeded /etc/van-li3/config.json (EDIT IT: add MQTT username + password)" +fi +python3 -c 'import bleak' 2>/dev/null \ + || warn "python3-bleak missing (apt install python3-bleak) — van-li3-battery won't start" + echo "== thermal monitor ==" install -D -m0755 power/van-thermal /usr/local/sbin/van-thermal install -D -m0644 power/thermal-config.json /etc/van-thermal/config.json @@ -236,7 +251,7 @@ systemctl restart systemd-resolved # its wait-online would just stall network-online.target. NM-wait-online covers WANs. systemctl mask systemd-networkd-wait-online.service >/dev/null 2>&1 || true systemctl unmask hostapd >/dev/null 2>&1 || true -systemctl enable regdomain.service hostapd hostapd-2g van-ap-dnsmasq nftables systemd-networkd van-failover van-thermal van-nvme-watch van-ap-watchdog van-ap-watchdog-2g van-wlan-watchdog van-modem-watch van-gps-owntracks >/dev/null 2>&1 || true +systemctl enable regdomain.service hostapd hostapd-2g van-ap-dnsmasq nftables systemd-networkd van-failover van-thermal van-nvme-watch van-ap-watchdog van-ap-watchdog-2g van-wlan-watchdog van-modem-watch van-gps-owntracks van-li3-battery >/dev/null 2>&1 || true # bluetooth: host BlueZ serves the onboard hci0 to the HA container over D-Bus systemctl enable --now bluetooth >/dev/null 2>&1 || true systemctl start homeassistant || warn "homeassistant failed to start (podman/quadlet — check journalctl -u homeassistant)" @@ -245,6 +260,7 @@ systemctl start frigate || warn "frigate failed to start (podman/quadlet — che systemctl restart van-thermal systemctl restart van-nvme-watch systemctl restart van-gps-owntracks +systemctl restart van-li3-battery # Pick up unmanaged-devices changes so NM releases/keeps the right interfaces. nmcli general reload 2>/dev/null || systemctl reload NetworkManager 2>/dev/null || true # restart in dependency order: bridge + members first, then hostapd enslaves the @@ -270,3 +286,4 @@ echo " iw dev $WIFI_2G_IFACE info | grep -E 'ssid|channel|width'" echo " cat /run/van-failover/state.json" echo " cat /run/van-thermal/state.json" echo " cat /run/van-nvme-watch/state.json" +echo " journalctl -u van-li3-battery -n 20" diff --git a/li3/config.json.example b/li3/config.json.example new file mode 100644 index 0000000..02c64bd --- /dev/null +++ b/li3/config.json.example @@ -0,0 +1,12 @@ +{ + "ble_address": "B0:D2:78:5C:16:87", + "broker": "localhost", + "port": 1883, + "username": "CHANGE_ME", + "password": "CHANGE_ME", + "client_id": "van-li3-battery", + "device_id": "li3_battery", + "device_name": "Li3 Battery", + "discovery_prefix": "homeassistant", + "publish_interval_s": 15 +} diff --git a/li3/van-li3-battery b/li3/van-li3-battery new file mode 100755 index 0000000..a8bc956 --- /dev/null +++ b/li3/van-li3-battery @@ -0,0 +1,224 @@ +#!/usr/bin/python3 -u +# Publish Lithionics Li3 BMS battery telemetry to Home Assistant via MQTT +# discovery. Protocol reverse-engineered from the com.lithionics.bms Android +# app (BLEMaster / MainBmsCsParameters): classic HM-10 BLE-UART (service +# ffe0, characteristic ffe1, notify+write, no pairing). On connect we send +# "$traceon" then "$info"; the device then streams CRLF-terminated CSV +# telemetry lines forever. +# +# BLE connect failures are retried internally (MQTT session stays up across +# them) rather than exiting, because bluetoothd on this Pi's onboard adapter +# occasionally wedges its discovery state after a run of failed connection +# attempts (Discovering stays "yes" forever, and every subsequent connect +# fails with le-connection-abort-by-local) — observed happening from our own +# repeated scan/connect cycling, not anything external. After several +# consecutive failures we restart bluetooth.service ourselves to clear that, +# rate-limited so we don't do it so often it disrupts the other BLE gear on +# this hub (motion sensors, IR remote, etc. also served by bluetoothd here). +# +# Only exits (letting systemd Restart=always give us a fresh process) on +# MQTT-level failure, which shouldn't happen in normal operation. + +import asyncio +import json +import subprocess +import sys +import time + +import paho.mqtt.client as mqtt +from bleak import BleakClient, BleakScanner + +BLE_RETRY_DELAY_S = 8 +STUCK_DISCOVERY_THRESHOLD = 3 # consecutive scan failures before we intervene +BLUETOOTH_RESTART_COOLDOWN_S = 300 # don't restart bluetooth.service more than this often + +config_path = sys.argv[1] if len(sys.argv) > 1 else "/etc/van-li3/config.json" +with open(config_path) as f: + cfg = json.load(f) + +ADDR = cfg["ble_address"] +FFE1 = "0000ffe1-0000-1000-8000-00805f9b34fb" + +broker = cfg["broker"] +mqtt_port = cfg.get("port", 1883) +mq_user = cfg["username"] +mq_pw = cfg["password"] +client_id = cfg.get("client_id", "van-li3-battery") + +device_id = cfg.get("device_id", "li3_battery") +device_name = cfg.get("device_name", "Li3 Battery") + +discovery_prefix = cfg.get("discovery_prefix", "homeassistant") +state_topic = cfg.get("state_topic", f"van/{device_id}/state") +publish_interval_s = cfg.get("publish_interval_s", 15) + +SENSORS = [ + # (key, name, unit, device_class, value_template_field, display_precision) + ("voltage", "Pack Voltage", "V", "voltage", "voltage", 2), + ("cell1_voltage", "Cell 1 Voltage", "V", "voltage", "cell1", 2), + ("cell2_voltage", "Cell 2 Voltage", "V", "voltage", "cell2", 2), + ("cell3_voltage", "Cell 3 Voltage", "V", "voltage", "cell3", 2), + ("cell4_voltage", "Cell 4 Voltage", "V", "voltage", "cell4", 2), + ("current", "Current", "A", "current", "current_a", 0), + ("soc", "State of Charge", "%", "battery", "soc_pct", 0), + ("bms_temperature", "BMS Temperature", "°F", "temperature", "bms_temp_f", 0), + ("battery_temperature", "Battery Temperature", "°F", "temperature", "batt_temp_f", 0), +] + + +def parse_line(line): + line = line.strip() + if not line or line.startswith("&") or line.startswith("$"): + return None + parts = line.split(",") + try: + f0 = int(parts[0]) + except ValueError: + return None + if not (101 <= f0 <= 9999): + return None # not a Cs-series main telemetry line + try: + return { + "voltage": round(f0 * 0.01, 2), + "cell1": round(int(parts[1]) * 0.01, 2), + "cell2": round(int(parts[2]) * 0.01, 2), + "cell3": round(int(parts[3]) * 0.01, 2), + "cell4": round(int(parts[4]) * 0.01, 2), + "bms_temp_f": int(parts[5]), + "batt_temp_f": int(parts[6]), + "current_a": int(parts[7]), + "soc_pct": int(parts[8]), + "status": parts[9] if len(parts) > 9 else "?", + } + except (ValueError, IndexError): + return None + + +def publish_discovery(mqc): + device_info = { + "identifiers": [device_id], + "name": device_name, + "manufacturer": "Lithionics", + "model": "Li3 BMS", + } + for key, name, unit, device_class, _field, precision in SENSORS: + topic = f"{discovery_prefix}/sensor/{device_id}/{key}/config" + payload = { + "name": name, + "unique_id": f"{device_id}_{key}", + "state_topic": state_topic, + "unit_of_measurement": unit, + "device_class": device_class, + "value_template": f"{{{{ value_json.{key} | round({precision}) }}}}", + "suggested_display_precision": precision, + "device": device_info, + "expire_after": publish_interval_s * 4, + "state_class": "measurement", + } + mqc.publish(topic, json.dumps(payload), retain=True) + + status_topic = f"{discovery_prefix}/sensor/{device_id}/status/config" + status_payload = { + "name": "Status", + "unique_id": f"{device_id}_status", + "state_topic": state_topic, + "value_template": "{{ 'OK' if value_json.status == '000000' else value_json.status }}", + "device": device_info, + "expire_after": publish_interval_s * 4, + } + mqc.publish(status_topic, json.dumps(status_payload), retain=True) + + +def build_state_payload(reading): + return { + "voltage": reading["voltage"], + "cell1_voltage": reading["cell1"], + "cell2_voltage": reading["cell2"], + "cell3_voltage": reading["cell3"], + "cell4_voltage": reading["cell4"], + "current": reading["current_a"], + "soc": reading["soc_pct"], + "bms_temperature": reading["bms_temp_f"], + "battery_temperature": reading["batt_temp_f"], + "status": reading["status"], + } + + +def restart_bluetooth_service(): + print("too many consecutive scan failures; restarting bluetooth.service") + subprocess.run(["systemctl", "restart", "bluetooth.service"], check=False) + time.sleep(3) + + +async def find_device(): + """Scan/connect retry loop. Never gives up; self-heals a wedged + bluetoothd discovery state along the way. Returns a found device.""" + consecutive_failures = 0 + last_bluetooth_restart = 0.0 + while True: + print("scanning for device...") + dev = await BleakScanner.find_device_by_address(ADDR, timeout=20) + if dev: + return dev + consecutive_failures += 1 + print(f"device not found in scan (attempt {consecutive_failures})") + if consecutive_failures >= STUCK_DISCOVERY_THRESHOLD: + now = time.time() + if (now - last_bluetooth_restart) >= BLUETOOTH_RESTART_COOLDOWN_S: + restart_bluetooth_service() + last_bluetooth_restart = now + consecutive_failures = 0 + await asyncio.sleep(BLE_RETRY_DELAY_S) + + +async def stream_from_device(dev, mqc): + """Connect to `dev` and publish readings until it disconnects.""" + buf = "" + last_publish = 0.0 + latest = {} + + def notify_handler(_sender, data): + nonlocal buf + buf += data.decode("utf-8", errors="replace") + while "\r\n" in buf: + line, buf = buf.split("\r\n", 1) + reading = parse_line(line) + if reading: + latest.update(reading) + + async with BleakClient(dev, timeout=15) as client: + print("connected") + await client.start_notify(FFE1, notify_handler) + await client.write_gatt_char(FFE1, b"$traceon\r\n", response=False) + await asyncio.sleep(2) + await client.write_gatt_char(FFE1, b"$info\r\n", response=False) + + while client.is_connected: + await asyncio.sleep(1) + now = time.time() + if latest and (now - last_publish) >= publish_interval_s: + payload = build_state_payload(latest) + mqc.publish(state_topic, json.dumps(payload)) + print("published:", payload) + last_publish = now + print("disconnected") + + +async def main(): + mqc = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=client_id) + mqc.username_pw_set(mq_user, mq_pw) + mqc.connect(broker, mqtt_port, keepalive=60) + mqc.loop_start() + publish_discovery(mqc) + + while True: + dev = await find_device() + try: + await stream_from_device(dev, mqc) + except Exception as e: + print("connection error:", e) + await asyncio.sleep(BLE_RETRY_DELAY_S) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/li3/van-li3-battery.service b/li3/van-li3-battery.service new file mode 100644 index 0000000..0fc92f6 --- /dev/null +++ b/li3/van-li3-battery.service @@ -0,0 +1,17 @@ +# /etc/systemd/system/van-li3-battery.service +[Unit] +Description=Publish Li3 BMS battery telemetry to Home Assistant via MQTT +After=network-online.target bluetooth.target mosquitto.service +Wants=network-online.target + +[Service] +ExecStart=/usr/local/sbin/van-li3-battery +# The script retries BLE scan/connect internally (including self-healing a +# wedged bluetoothd discovery state — see the script's docstring) and only +# exits on MQTT-level failure, which shouldn't happen in normal operation. +# Restart=always is a backstop for that case, matching van-gps-owntracks. +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target