Compare commits
@@ -1,5 +1,11 @@
|
|||||||
# vanlink — campervan router on `wayback`
|
# vanlink — campervan router on `wayback`
|
||||||
|
|
||||||
|
> **This clone = the Pi 4 port (host `wan`, branch `wan`).** Onboard `eth0`+`wlan0` are
|
||||||
|
> NM-managed WANs (netplan `ap/50-van-wan.yaml`); the AP radios + wired LAN port arrive
|
||||||
|
> with the USB hub from wayback (same MAC-derived names, so all configs port verbatim).
|
||||||
|
> Dropped here: HA VM, battery/lid (no hardware), heartbeat + ZeroTier (not installed yet).
|
||||||
|
> Watchdog is 10s (bcm2835 max 15s); thermal watches `cpu_thermal`.
|
||||||
|
|
||||||
Turns **wayback** (Asus ZenBook UX391U, Ubuntu 24.04, zabbly kernel) into a self-contained
|
Turns **wayback** (Asus ZenBook UX391U, Ubuntu 24.04, zabbly kernel) into a self-contained
|
||||||
campervan hub/router/AP:
|
campervan hub/router/AP:
|
||||||
|
|
||||||
@@ -42,6 +48,7 @@ This directory is the source of truth. The live system files live under `/etc`,
|
|||||||
| NAT + forwarding | **nftables** + sysctl |
|
| NAT + forwarding | **nftables** + sysctl |
|
||||||
| WAN health + failover | **van-failover** daemon |
|
| WAN health + failover | **van-failover** daemon |
|
||||||
| Temperature monitor / alert / log | **van-thermal** daemon |
|
| Temperature monitor / alert / log | **van-thermal** daemon |
|
||||||
|
| NVMe I/O-timeout/reset watchdog + alert | **van-nvme-watch** daemon |
|
||||||
| Battery monitor / low-charge alert + shutdown | **van-battery** daemon |
|
| Battery monitor / low-charge alert + shutdown | **van-battery** daemon |
|
||||||
| Auto-reboot on hang | **systemd hardware watchdog** (`intel_oc_wdt`) |
|
| Auto-reboot on hang | **systemd hardware watchdog** (`intel_oc_wdt`) |
|
||||||
| Liveness / dead-man's switch + metrics | **hbc** heartbeat client → hbd.wrede.pvt |
|
| Liveness / dead-man's switch + metrics | **hbc** heartbeat client → hbd.wrede.pvt |
|
||||||
@@ -55,7 +62,7 @@ This directory is the source of truth. The live system files live under `/etc`,
|
|||||||
- Cockpit: `https://192.168.10.251:9090` (or `.27`, or ZeroTier). Log in with a Unix account; enable *Administrative access* for action buttons.
|
- Cockpit: `https://192.168.10.251:9090` (or `.27`, or ZeroTier). Log in with a Unix account; enable *Administrative access* for action buttons.
|
||||||
- Regulatory domain **CA** (unlocks 5GHz ch149–161 @30dBm, no DFS).
|
- Regulatory domain **CA** (unlocks 5GHz ch149–161 @30dBm, no DFS).
|
||||||
- ZeroTier network `d3ecf5726d041b2a`, pushed DNS domain `wrede.pvt` via `192.168.196.115` + `192.168.10.5`.
|
- ZeroTier network `d3ecf5726d041b2a`, pushed DNS domain `wrede.pvt` via `192.168.196.115` + `192.168.10.5`.
|
||||||
- Home Assistant (HAOS in libvirt VM `ha_van`) is bridged into `br0` at **`10.42.0.50:8123`** (pinned DHCP lease, name `homeassistant`); legacy URL `http://10.42.0.1:8123` still works via DNAT.
|
- Home Assistant runs **natively on this host** (Podman Quadlet, host network) at **`http://10.42.0.1:8123`** (name `homeassistant`). The old HAOS VM `ha_van` (`10.42.0.50`) was retired 2026-07-07.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -85,7 +92,7 @@ This directory is the source of truth. The live system files live under `/etc`,
|
|||||||
| `nftables.conf` | `/etc/nftables.conf` | NAT: `masquerade ip saddr 10.42.0.0/24 oifname != br0` → follows whatever WAN is active |
|
| `nftables.conf` | `/etc/nftables.conf` | NAT: `masquerade ip saddr 10.42.0.0/24 oifname != br0` → follows whatever WAN is active |
|
||||||
| `99-van-router.conf` | `/etc/sysctl.d/99-van-router.conf` | `net.ipv4.ip_forward=1` |
|
| `99-van-router.conf` | `/etc/sysctl.d/99-van-router.conf` | `net.ipv4.ip_forward=1` |
|
||||||
| `regdomain.service` | `/etc/systemd/system/regdomain.service` | `iw reg set CA` at boot, before NetworkManager |
|
| `regdomain.service` | `/etc/systemd/system/regdomain.service` | `iw reg set CA` at boot, before NetworkManager |
|
||||||
| `rtw89.conf` | `/etc/modprobe.d/rtw89.conf` | `options rtw89_core disable_ps_mode=Y` (else AP drops beacon when idle) |
|
| `rtw89.conf` | `/etc/modprobe.d/rtw89.conf` | 5GHz dongle: `disable_ps_mode=Y` (else AP drops beacon when idle) + blacklists in-kernel rtw89 so the out-of-tree morrownr/rtw89 dkms driver (installed separately, not by deploy.sh) loads instead — see gotchas below |
|
||||||
| `rtw88.conf` | `/etc/modprobe.d/rtw88.conf` | 2.4GHz dongle: `disable_lps_deep=Y` (same PS reasoning) + `switch_usb_mode=N` (don't self-upgrade to USB3 — it radiates into 2.4GHz) |
|
| `rtw88.conf` | `/etc/modprobe.d/rtw88.conf` | 2.4GHz dongle: `disable_lps_deep=Y` (same PS reasoning) + `switch_usb_mode=N` (don't self-upgrade to USB3 — it radiates into 2.4GHz) |
|
||||||
|
|
||||||
### `failover/` — multi-WAN
|
### `failover/` — multi-WAN
|
||||||
@@ -115,6 +122,9 @@ This directory is the source of truth. The live system files live under `/etc`,
|
|||||||
| `van-thermal` | `/usr/local/sbin/van-thermal` | temperature daemon (Python): publishes state, alerts, logs history |
|
| `van-thermal` | `/usr/local/sbin/van-thermal` | temperature daemon (Python): publishes state, alerts, logs history |
|
||||||
| `thermal-config.json` | `/etc/van-thermal/config.json` | sensors + warn/crit thresholds + sample/log intervals |
|
| `thermal-config.json` | `/etc/van-thermal/config.json` | sensors + warn/crit thresholds + sample/log intervals |
|
||||||
| `van-thermal.service` | `/etc/systemd/system/van-thermal.service` | `Restart=always` |
|
| `van-thermal.service` | `/etc/systemd/system/van-thermal.service` | `Restart=always` |
|
||||||
|
| `van-nvme-watch` | `/usr/local/sbin/van-nvme-watch` | NVMe watchdog (Python): tails `journalctl -k` for I/O-timeout/reset events, Pushover alerts |
|
||||||
|
| `nvme-watch-config.json` | `/etc/van-nvme-watch/config.json` | grace period, cooldown, device paths |
|
||||||
|
| `van-nvme-watch.service` | `/etc/systemd/system/van-nvme-watch.service` | `Restart=always` |
|
||||||
| `van-battery` | `/usr/local/sbin/van-battery` | battery daemon (Python): Pushover low-charge alerts + safe shutdown |
|
| `van-battery` | `/usr/local/sbin/van-battery` | battery daemon (Python): Pushover low-charge alerts + safe shutdown |
|
||||||
| `battery-config.json` | `/etc/van-battery/config.json` | warn levels, shutdown level, poll interval, paths |
|
| `battery-config.json` | `/etc/van-battery/config.json` | warn levels, shutdown level, poll interval, paths |
|
||||||
| `van-battery.service` | `/etc/systemd/system/van-battery.service` | `Restart=always` |
|
| `van-battery.service` | `/etc/systemd/system/van-battery.service` | `Restart=always` |
|
||||||
@@ -133,13 +143,23 @@ 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
|
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.
|
heartbeat project's own installer — see §4. `deploy.sh` only starts the service once it exists.
|
||||||
|
|
||||||
### `ha/` — Home Assistant VM
|
### `li3/` — RV house battery (BLE BMS → MQTT/HA)
|
||||||
| file | → installs to | purpose |
|
| file | → installs to | purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ha_van.xml` | *(reference only — `virsh define ha/ha_van.xml` to restore)* | libvirt domain: HAOS VM, virtio NIC bridged onto `br0` (MAC `52:54:00:ad:0a:01`). Disk image lives outside the repo. |
|
| `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 touched by `deploy.sh` — the VM's LAN address/name come from `ap/van-ap-dnsmasq.conf`
|
Not to be confused with `power/van-battery` — that's the host's own AC/battery power
|
||||||
(`dhcp-host` → `10.42.0.50`, `homeassistant`) and the legacy-URL DNAT from `ap/nftables.conf`.
|
supply (laptop UPS-style monitor), unrelated hardware and purpose.
|
||||||
|
|
||||||
|
### `ha/` — Home Assistant (native container)
|
||||||
|
| file | → installs to | purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `homeassistant.container` | `/etc/containers/systemd/homeassistant.container` | Podman Quadlet: HA Container on the host network (`:8123`), config in `/srv/homeassistant`, host D-Bus mounted for onboard Bluetooth. |
|
||||||
|
|
||||||
|
The `homeassistant` LAN name comes from `ap/van-ap-dnsmasq.conf` (`host-record` →
|
||||||
|
`10.42.0.1`). The retired HAOS VM's domain XML (`ha_van.xml`) lives in git history.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -174,6 +194,15 @@ Not touched by `deploy.sh` — the VM's LAN address/name come from `ap/van-ap-dn
|
|||||||
- **Tuning**: edit `/etc/van-thermal/config.json` (thresholds, intervals, sensor list), then `systemctl restart van-thermal`. Defaults: CPU warn 80 / crit 95 °C (silicon crit is 100), NVMe warn 65 / crit 70 °C (drive crit ~71).
|
- **Tuning**: edit `/etc/van-thermal/config.json` (thresholds, intervals, sensor list), then `systemctl restart van-thermal`. Defaults: CPU warn 80 / crit 95 °C (silicon crit is 100), NVMe warn 65 / crit 70 °C (drive crit ~71).
|
||||||
- Status: `systemctl status van-thermal` or `cat /run/van-thermal/state.json`.
|
- Status: `systemctl status van-thermal` or `cat /run/van-thermal/state.json`.
|
||||||
|
|
||||||
|
### NVMe watchdog (`van-nvme-watch`)
|
||||||
|
- Event-driven, not polled: tails `journalctl -kf` for the `nvme nvmeN: I/O tag ... timeout, reset controller` signature that crashed and corrupted the root fs on 2026-08-02 (recurred 2026-08-04, self-healed — see the memory notes for that investigation). There's no sensor to sample, only a log line to catch.
|
||||||
|
- On a match it watches a `grace_period` (default 20 s) for either a clean re-init (self-healed) or a second timeout / an ext4 error following it (escalated) before alerting, so the Pushover message already says which outcome happened — no need to SSH in during a scare.
|
||||||
|
- The alert body includes a live `smartctl`/`tune2fs` snapshot (SMART health, critical-warning flag, media error count, filesystem state).
|
||||||
|
- **Live state**: `/run/van-nvme-watch/state.json` (last event, same convention as van-failover/van-thermal).
|
||||||
|
- Shares Pushover credentials with van-thermal/van-battery (`/etc/van-battery/pushover.json`).
|
||||||
|
- **Tuning**: edit `/etc/van-nvme-watch/config.json` (`grace_period`, `cooldown`, device paths), then `systemctl restart van-nvme-watch`.
|
||||||
|
- Status: `systemctl status van-nvme-watch`, `journalctl -u van-nvme-watch -f`, or `cat /run/van-nvme-watch/state.json`.
|
||||||
|
|
||||||
### Battery monitor (`van-battery`)
|
### Battery monitor (`van-battery`)
|
||||||
- Watches mains vs battery via `/sys/class/power_supply/AC0/online` (0 = on battery) and charge via `BAT0/capacity`. Both resolve by `type` (Mains/Battery) if those names ever differ.
|
- Watches mains vs battery via `/sys/class/power_supply/AC0/online` (0 = on battery) and charge via `BAT0/capacity`. Both resolve by `type` (Mains/Battery) if those names ever differ.
|
||||||
- **Only while on battery**, it sends escalating **Pushover** alerts at **25 / 20 / 15 %**, and at **10 %** sends a final alert and runs `systemctl poweroff` (after `shutdown_grace`, default 8 s, so the alert flushes first).
|
- **Only while on battery**, it sends escalating **Pushover** alerts at **25 / 20 / 15 %**, and at **10 %** sends a final alert and runs `systemctl poweroff` (after `shutdown_grace`, default 8 s, so the alert flushes first).
|
||||||
@@ -184,9 +213,83 @@ Not touched by `deploy.sh` — the VM's LAN address/name come from `ap/van-ap-dn
|
|||||||
- **Caveat — the 10 % shutdown is one-way.** A laptop won't power itself back on when mains returns: "restore on AC loss" is a BIOS/firmware feature (not OS-controllable — `/proc/acpi/wakeup` only covers wake-from-suspend). If your BIOS exposes an "AC power-on / restore on AC loss" option, enable it so the router reboots itself once shore/solar power is back; this ZenBook likely doesn't have it, in which case a low-battery shutdown needs a manual power-on.
|
- **Caveat — the 10 % shutdown is one-way.** A laptop won't power itself back on when mains returns: "restore on AC loss" is a BIOS/firmware feature (not OS-controllable — `/proc/acpi/wakeup` only covers wake-from-suspend). If your BIOS exposes an "AC power-on / restore on AC loss" option, enable it so the router reboots itself once shore/solar power is back; this ZenBook likely doesn't have it, in which case a low-battery shutdown needs a manual power-on.
|
||||||
|
|
||||||
### Adding the 4G/5G modem
|
### Adding the 4G/5G modem
|
||||||
1. Plug the USB modem in. ModemManager + the existing `Koodo` gsm NM connection (autoconnect) bring it up.
|
1. Plug the USB modem in. ModemManager + the `Koodo` gsm NM connection (autoconnect) bring it up.
|
||||||
|
On a fresh host create the profile once:
|
||||||
|
`nmcli con add type gsm ifname "*" con-name Koodo apn sp.koodo.com connection.autoconnect yes`
|
||||||
2. It auto-joins as the `cellular` WAN at metric 300 (last resort). Nothing else to configure.
|
2. It auto-joins as the `cellular` WAN at metric 300 (last resort). Nothing else to configure.
|
||||||
3. Confirm with `mmcli -L` and the Cockpit failover card (cellular flips from `absent` to `up`).
|
3. Confirm with `mmcli -L` and the Cockpit failover card (cellular flips from `absent` to `up`).
|
||||||
|
4. Gotcha (handled in `van-failover` since the Quectel EC25-AF landed): for MBIM/QMI modems
|
||||||
|
NM's device is the control port (`cdc-wdm0`) while IP + routes live on the wwan netdev
|
||||||
|
(e.g. `wwu1u2i4`). The daemon maps via `GENERAL.IP-IFACE` — probing or `ip route`
|
||||||
|
against `cdc-wdm0` silently sees nothing. The Cockpit WAN table does the same mapping
|
||||||
|
(shows the netdev + its IP; nmcli actions still target `cdc-wdm0`).
|
||||||
|
5. The Cockpit WAN row shows signal % / tech / operator (from `mmcli`) and has a
|
||||||
|
**Restart** button: `mmcli --disable && --enable` (the EC25 MBIM plugin doesn't
|
||||||
|
support `--reset`; autoconnect reconnects), falling back to a USB unbind/bind of the
|
||||||
|
Quectel device (vendor `2c7c`) if ModemManager can't reach the modem.
|
||||||
|
|
||||||
|
### GPS (gpsd from the cellular modem)
|
||||||
|
The EC25-AF has a GNSS engine that streams NMEA on its USB interface 01 (`ttyUSB1`,
|
||||||
|
`gps` port in `mmcli`). Needs its own GPS antenna on the modem's GNSS connector for a fix.
|
||||||
|
- **One-time modem config** (persists in modem NV; survives reboot/replug/Restart):
|
||||||
|
GNSS auto-start via `AT+QGPSCFG="autogps",1` + `AT+QGPS=1`. ModemManager holds both
|
||||||
|
AT ports and swallows replies, so stop it first:
|
||||||
|
`systemctl stop ModemManager`, send the ATs on `/dev/ttyUSB3`, `systemctl start ModemManager`.
|
||||||
|
- **Host side** (deployed by deploy.sh, needs `apt install gpsd gpsd-clients`):
|
||||||
|
`gps/77-modem-gps.rules` udev rule matches the NMEA tty (2c7c:0125 if01), symlinks it
|
||||||
|
to `/dev/modem-gps`, and hot-adds it to gpsd via `gpsdctl@%k` — the same mechanism as
|
||||||
|
gpsd's own 60-gpsd.rules, so plug/unplug/renumbering just works. `gps/gpsd.default`
|
||||||
|
(`/etc/default/gpsd`) keeps `DEVICES` empty (hotplug does it) and runs gpsd with `-n`.
|
||||||
|
- Verify: `gpspipe -r -n 10` (raw NMEA), `cgps` (fix view), `systemctl status gpsdctl@ttyUSB1`.
|
||||||
|
- Don't enable MM location APIs (`mmcli --location-enable-gps-*`) at the same time —
|
||||||
|
MM would open the NMEA port and fight gpsd; `gps-unmanaged` is the only safe one.
|
||||||
|
|
||||||
|
### OwnTracks publisher (gpsd -> MQTT)
|
||||||
|
`gps/van-gps-owntracks` (service `van-gps-owntracks`) streams TPV fixes from local
|
||||||
|
gpsd and publishes OwnTracks location JSON to `owntracks/rv/gps` on home.wrede.ca
|
||||||
|
(tid `rv`) — at most every 10 min when parked, immediately after >250 m of movement.
|
||||||
|
Port of the wayback-era `gps_to_owntracks.py`, adapted to apt-only deps
|
||||||
|
(`python3-gps`, `python3-paho-mqtt` — the old `gpsdclient` is pip-only) and the
|
||||||
|
paho 2.x callback API.
|
||||||
|
- Broker credentials live only in `/etc/van-gps/config.json` (0600, seeded from
|
||||||
|
`gps/config.json.example` — edit after first deploy). All knobs (broker, topic,
|
||||||
|
intervals, gpsd host) live there too.
|
||||||
|
- **client_id must stay `vanq-wan` (or anything unique)**: some legacy client still
|
||||||
|
holds `vanq` on the broker (old copy on wayback?) and the broker kicks whoever
|
||||||
|
shares its ID — the symptom is connect/disconnect every few seconds.
|
||||||
|
- On MQTT disconnect the script exits and systemd restarts it (RestartSec=15);
|
||||||
|
that *is* the reconnect logic, so a red blip after a WAN failover is normal.
|
||||||
|
- Debug tracing: `kill -HUP` the process toggles per-fix logging to the journal.
|
||||||
|
- Verify: `journalctl -u van-gps-owntracks` shows `mqtt connect` and stays quiet;
|
||||||
|
a restart publishes the current fix immediately (first-message path), so
|
||||||
|
subscribing to `owntracks/rv/gps` while restarting shows a live message.
|
||||||
|
- 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`) — as soon as a scan fails 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 <pw> -t van/li3_battery/state`.
|
||||||
|
|
||||||
### ZeroTier managed DNS
|
### 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.
|
- `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.
|
||||||
@@ -200,20 +303,25 @@ Not touched by `deploy.sh` — the VM's LAN address/name come from `ap/van-ap-dn
|
|||||||
- The Access Points card shows both bands (5GHz `hostapd`, 2.4GHz `hostapd-2g`) with per-band client lists and **Restart** buttons; the radio list lives in `vanrouter.js` (`const APS`).
|
- The Access Points card shows both bands (5GHz `hostapd`, 2.4GHz `hostapd-2g`) with per-band client lists and **Restart** buttons; the radio list lives in `vanrouter.js` (`const APS`).
|
||||||
- **Prefer** sets the manual WAN preference (see *Manual preference* above); **Up/Down** connect/disconnect the NM device.
|
- **Prefer** sets the manual WAN preference (see *Manual preference* above); **Up/Down** connect/disconnect the NM device.
|
||||||
|
|
||||||
### Home Assistant VM (`ha_van`)
|
### Home Assistant (native Podman container)
|
||||||
- HAOS runs as a libvirt KVM VM whose NIC is **bridged into `br0`** — it is a first-class LAN
|
- HA Container runs on the host via a **Podman Quadlet** (`ha/homeassistant.container` →
|
||||||
device, not NAT'd behind libvirt's `virbr0`. VanLink + wired clients reach it directly at
|
`/etc/containers/systemd/`; systemd generates `homeassistant.service`). **Host network**:
|
||||||
`http://10.42.0.50:8123` (or `http://homeassistant:8123` / `homeassistant.local` via mDNS);
|
HA binds `:8123` directly, so clients use `http://10.42.0.1:8123` (or
|
||||||
ZT clients route in via the ZT-managed `10.42.0.0/24` route. mDNS/SSDP device discovery works
|
`http://homeassistant:8123`); mDNS/SSDP discovery sees the LAN because there's no bridge
|
||||||
because the VM shares the clients' L2 segment.
|
or NAT in the way. Config lives in `/srv/homeassistant`.
|
||||||
- The old NAT-era URL `http://10.42.0.1:8123` keeps working: nftables DNATs it to `10.42.0.50`,
|
- **Bluetooth**: host BlueZ (`bluetooth.service`) serves the Pi's onboard `hci0` to HA over
|
||||||
with a hairpin masquerade for same-subnet clients (see comments in `ap/nftables.conf`).
|
the mounted `/run/dbus` socket — add the Bluetooth integration in HA and it appears.
|
||||||
- History: the VM used to sit on libvirt's `default` NAT net (`192.168.122.50`) with a
|
- No add-on store (that was HAOS's Supervisor): Mosquitto/Zigbee2MQTT-style add-ons become
|
||||||
`/etc/libvirt/hooks/network` hook inserting FORWARD accepts above libvirt's REJECT. That broke
|
their own containers/services if ever needed.
|
||||||
whenever libvirtd re-inserted its chains on restart (hook doesn't fire then) — bridging removed
|
- Operate: `systemctl {status,restart} homeassistant`. Update: bump/pull the image
|
||||||
the whole failure mode. The hook and the libvirt DHCP reservation are gone.
|
(`podman pull ghcr.io/home-assistant/home-assistant:stable`) and restart. After editing
|
||||||
- Operate: `virsh {start,shutdown,domstate} ha_van`; autostart is per libvirt config. Verify:
|
the `.container` file: `./deploy.sh` (or install + `systemctl daemon-reload`), then
|
||||||
`curl -s -o /dev/null -w '%{http_code}' http://10.42.0.50:8123/` → `200`.
|
`systemctl restart homeassistant`. Verify:
|
||||||
|
`curl -s -o /dev/null -w '%{http_code}' http://10.42.0.1:8123/` → `200`.
|
||||||
|
- History: HA started as a HAOS VM on wayback (libvirt NAT, then bridged onto `br0` at
|
||||||
|
`10.42.0.50` with a legacy-URL DNAT), moved to this Pi as an aarch64 VM 2026-07-06, and
|
||||||
|
went native 2026-07-07 — the fixed 2 GiB VM allocation starved the 4 GB Pi. The domain
|
||||||
|
XML (`ha/ha_van.xml`) and the DNAT/hairpin nftables rules are in git history.
|
||||||
|
|
||||||
### Never sleep (lid-closed operation)
|
### Never sleep (lid-closed operation)
|
||||||
- wayback lives lid-closed in the van and must stay up. Stock logind `HandleLidSwitch=suspend` would sleep it on lid close (even on AC). The `power/10-vanlink-nolid.conf` drop-in sets all three lid actions to `ignore`; `deploy.sh` also masks every sleep target so idle / GUI / a stray `systemctl suspend` can't suspend it.
|
- wayback lives lid-closed in the van and must stay up. Stock logind `HandleLidSwitch=suspend` would sleep it on lid close (even on AC). The `power/10-vanlink-nolid.conf` drop-in sets all three lid actions to `ignore`; `deploy.sh` also masks every sleep target so idle / GUI / a stray `systemctl suspend` can't suspend it.
|
||||||
@@ -258,7 +366,8 @@ Four things `deploy.sh` does **not** do (one-time, manual):
|
|||||||
|
|
||||||
- **AP on hostapd, not NetworkManager.** NM's hotspot caps the rtw89 radio at HT20/20MHz; hostapd gives the full VHT80/HE (WiFi-6). The AP iface is therefore NM-*unmanaged*; networkd gives it its static IP.
|
- **AP on hostapd, not NetworkManager.** NM's hotspot caps the rtw89 radio at HT20/20MHz; hostapd gives the full VHT80/HE (WiFi-6). The AP iface is therefore NM-*unmanaged*; networkd gives it its static IP.
|
||||||
- **`rtw89` power-save must be off** (`disable_ps_mode=Y`) or the AP stops beaconing when idle and the SSID vanishes.
|
- **`rtw89` power-save must be off** (`disable_ps_mode=Y`) or the AP stops beaconing when idle and the SSID vanishes.
|
||||||
- **RTL8852BU is USB-2.0 and hangs under load if it shares a USB hub.** Keep the AP dongle on its **own** USB controller, separate from the WAN ethernet. Symptom of a shared bus: `c2h reg timeout` + `Polling beacon packet empty fail` under throughput, SSID drops. (`timed out to flush queues` alone is benign.)
|
- **RTL8852BU is USB-2.0 and hangs under load if it shares a USB hub.** Keep the AP dongle on its **own** USB controller, separate from the WAN ethernet. Symptom of a shared bus: `c2h reg timeout` + `Polling beacon packet empty fail` under throughput, SSID drops. (`timed out to flush queues` alone is benign.) True regardless of which driver (in-kernel or out-of-tree) is bound — it's a physical bus-bandwidth issue, not a driver bug.
|
||||||
|
- **5GHz dongle runs the out-of-tree `morrownr/rtw89` driver (since 2026-08-23), not the in-kernel one.** This Ubuntu kernel base (`7.0.0-1017-raspi`) predates mainline's rtw89 USB2→3 auto-switch, so the in-kernel driver permanently caps the 8852BU at USB2/480M. The out-of-tree driver has that switch (`switch_usb_mode=y` in `ap/rtw89.conf`) and needs a real USB3-capable port to actually benefit from it — a USB2-only port still caps it regardless of driver (confirmed live: 480M on a USB2-only controller, 5000M/SuperSpeed after moving to a dedicated USB3 controller). Driver itself logs `"2.4 GHz performance may be better in a USB 2 port"` on load — watch the 2.4GHz radio for new interference now that 5GHz runs SuperSpeed signalling nearby. Installed/updated via `sudo ./ap/install-rtw89-driver.sh` (pins a specific upstream commit, `dkms install`, `make install_fw`) — **not** run automatically by `deploy.sh`, since a dkms rebuild is slow and shouldn't fire on every routine deploy; run it manually after a fresh Pi provisioning or to bump the pin. DKMS itself auto-rebuilds across kernel upgrades. Module names get a `_git` suffix (`rtw89_8852bu_git` etc.) — `ap/rtw89.conf`'s blacklist stops the in-kernel modules from claiming the device instead. **Gotcha hit switching a live system over**: the in-kernel `rtw89_core` won't unload while its own in-kernel dependents (`rtw89_8852b`, `rtw89_8852b_common`) are still loaded — remove all of them first or the out-of-tree module fails to load (`exports duplicate symbol`). The install script's own output has the exact recovery steps.
|
||||||
- **van-failover changes metrics via `ip route`, never `nmcli device reapply`.** `reapply` **resets the r8152 USB-ethernet carrier**, which caused a ~10s-ping-drop flapping feedback loop. Pure `ip route` changes are carrier-safe.
|
- **van-failover changes metrics via `ip route`, never `nmcli device reapply`.** `reapply` **resets the r8152 USB-ethernet carrier**, which caused a ~10s-ping-drop flapping feedback loop. Pure `ip route` changes are carrier-safe.
|
||||||
- **Disable EEE on USB ethernet** (`50-disable-eee` dispatcher) — its idle power-save parks the *backup* WAN link and breaks health probes.
|
- **Disable EEE on USB ethernet** (`50-disable-eee` dispatcher) — its idle power-save parks the *backup* WAN link and breaks health probes.
|
||||||
- **Per-WAN probing needs `curl --interface if!<dev>`** (forces `SO_BINDTODEVICE`); plain `--interface <name>` only sets the source IP and still routes via the default WAN. `rp_filter` is loose (`2`), required for this.
|
- **Per-WAN probing needs `curl --interface if!<dev>`** (forces `SO_BINDTODEVICE`); plain `--interface <name>` only sets the source IP and still routes via the default WAN. `rp_filter` is loose (`2`), required for this.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[Match]
|
[Match]
|
||||||
Name=wlxc83a35a4ee55
|
Name=@WIFI_5G_IFACE@
|
||||||
|
|
||||||
[Link]
|
[Link]
|
||||||
RequiredForOnline=no
|
RequiredForOnline=no
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[Match]
|
[Match]
|
||||||
Name=wlxd8ec5e2faa8c
|
Name=@WIFI_2G_IFACE@
|
||||||
|
|
||||||
[Link]
|
[Link]
|
||||||
RequiredForOnline=no
|
RequiredForOnline=no
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# LAN bridge: joins the AP Wi-Fi (added by hostapd, once it's in AP mode) and the
|
# LAN bridge: joins the AP Wi-Fi radios (added by hostapd, once in AP mode) and the
|
||||||
# wired LAN port (enx00e04c331140, added by networkd) into one 10.42.0.0/24 segment.
|
# wired LAN ports (enx00e04c331140 + onboard eth0, added by networkd) into one
|
||||||
# STP off: only two member ports, no loops, and it avoids the forwarding delay that
|
# 10.42.0.0/24 segment. STP off: all members are leaf ports, no loops (don't cable
|
||||||
|
# the two wired ports to the same switch), and it avoids the forwarding delay that
|
||||||
# would otherwise stall the first DHCP handshake on a freshly-plugged client.
|
# would otherwise stall the first DHCP handshake on a freshly-plugged client.
|
||||||
[NetDev]
|
[NetDev]
|
||||||
Name=br0
|
Name=br0
|
||||||
|
|||||||
@@ -10,3 +10,7 @@ RequiredForOnline=no
|
|||||||
[Network]
|
[Network]
|
||||||
Address=10.42.0.1/24
|
Address=10.42.0.1/24
|
||||||
ConfigureWithoutCarrier=yes
|
ConfigureWithoutCarrier=yes
|
||||||
|
# Resolve .local (mDNS) for devices on the van's own LAN (ESPHome nodes,
|
||||||
|
# etc.) — scoped to br0 only, not the WAN links, since mDNS is link-local
|
||||||
|
# and that's the only interface those devices are actually on.
|
||||||
|
MulticastDNS=yes
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# gets DHCP/DNS from the same dnsmasq as Wi-Fi clients. NM must leave this device
|
# gets DHCP/DNS from the same dnsmasq as Wi-Fi clients. NM must leave this device
|
||||||
# alone (see van-ap-unmanaged.conf) for networkd to own it here.
|
# alone (see van-ap-unmanaged.conf) for networkd to own it here.
|
||||||
[Match]
|
[Match]
|
||||||
Name=enx00e04c331140
|
Name=@LAN_USB_IFACE@
|
||||||
|
|
||||||
[Link]
|
[Link]
|
||||||
RequiredForOnline=no
|
RequiredForOnline=no
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Onboard GbE (RP1 eth0) as the internal LAN port: enslaved to br0 so anything
|
||||||
|
# plugged in lands on 10.42.0.0/24 with DHCP/DNS from the same dnsmasq as Wi-Fi
|
||||||
|
# clients. Was the metric-150 ethernet WAN until 2026-07-12 — with wifi +
|
||||||
|
# Starlink + cellular as uplinks, the wired port earns its keep as LAN instead.
|
||||||
|
# NM must leave it alone (see van-ap-unmanaged.conf) for networkd to own it here.
|
||||||
|
[Match]
|
||||||
|
Name=eth0
|
||||||
|
|
||||||
|
[Link]
|
||||||
|
RequiredForOnline=no
|
||||||
|
|
||||||
|
[Network]
|
||||||
|
Bridge=br0
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# /etc/netplan/50-van-wan.yaml — replaces cloud-init's 50-cloud-init.yaml.
|
||||||
|
# The WANs handed to NetworkManager so van-failover can steer them (mirrors
|
||||||
|
# wayback where NM owns all WANs): wlan0 = wifi uplink (Wapana at home /
|
||||||
|
# campsite wifi), enxd8ec5eeb3512 = Starlink. The AP radios and the wired LAN
|
||||||
|
# ports (USB dongle + onboard eth0, since 2026-07-12 an internal LAN port) are
|
||||||
|
# deliberately absent: systemd-networkd/hostapd own them, and
|
||||||
|
# van-ap-unmanaged.conf hides them from NM.
|
||||||
|
# Apply once by hand: netplan generate && netplan apply (flaps the uplinks).
|
||||||
|
network:
|
||||||
|
version: 2
|
||||||
|
ethernets:
|
||||||
|
# Starlink dish uplink (RTL8153 USB NIC, MAC-named — travels with the
|
||||||
|
# adapter, see STARLINK_IFACE in deploy.conf). The /32 link route keeps the
|
||||||
|
# dish's management address reachable no matter which WAN holds the
|
||||||
|
# default route: the dish answers on 192.168.100.1 (gRPC :9200) even while
|
||||||
|
# the uplink sits behind CGNAT.
|
||||||
|
@STARLINK_IFACE@:
|
||||||
|
renderer: NetworkManager
|
||||||
|
optional: true
|
||||||
|
dhcp4: true
|
||||||
|
routes:
|
||||||
|
- to: 192.168.100.1/32
|
||||||
|
scope: link
|
||||||
|
# never-default: van-failover is the sole owner of the default route (it manages it
|
||||||
|
# directly via `ip route`, deliberately never `nmcli device reapply`d — that resets
|
||||||
|
# r8152 USB-ethernet carriers). Without this, NM's own DHCP client reinstalls its own
|
||||||
|
# default route (metric 100, NM's ethernet default) on every lease renewal — observed
|
||||||
|
# every ~8s here (dish hands out a 16s lease) — which van-failover's loop prunes within
|
||||||
|
# ~1s, but during that window it can tie wifi's own healthy base metric (also 100).
|
||||||
|
# Setting this at connection-creation time (vs. a live `nmcli modify`, which does NOT
|
||||||
|
# take effect on an already-active connection without a reactivation) closes the race
|
||||||
|
# for good.
|
||||||
|
networkmanager:
|
||||||
|
passthrough:
|
||||||
|
ipv4.never-default: "true"
|
||||||
|
wifis:
|
||||||
|
wlan0:
|
||||||
|
renderer: NetworkManager
|
||||||
|
optional: true
|
||||||
|
dhcp4: true
|
||||||
|
# IPv6 is deliberate as of 2026-07-12: NM does SLAAC/DHCPv6 itself.
|
||||||
|
# Before this it only worked by accident — dracut's runtime catch-all
|
||||||
|
# /run/systemd/network/zzzz-dracut-default.network had networkd
|
||||||
|
# co-managing wlan0 (second DHCPv4 client + surprise IPv6); deploy.sh
|
||||||
|
# now masks that file. Privacy (temporary) addresses stay off so hbd
|
||||||
|
# and DNS see one stable source address per uplink.
|
||||||
|
dhcp6: true
|
||||||
|
ipv6-privacy: false
|
||||||
|
# never-default: see the Starlink stanza above — same reasoning, applies to every
|
||||||
|
# van-failover-managed WAN.
|
||||||
|
networkmanager:
|
||||||
|
passthrough:
|
||||||
|
ipv4.never-default: "true"
|
||||||
|
access-points:
|
||||||
|
"Wapana":
|
||||||
|
auth:
|
||||||
|
key-management: "psk"
|
||||||
|
password: "6e1335fd97165a7d2618bec19824be363a2766d7765f91aa14773d871eaa59dc"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Fixed upstream resolvers for this router — see deploy.conf's DNS_RESOLVERS.
|
||||||
|
# Never the WAN-provided ones: the failover/60-van-wan-dns NM dispatcher
|
||||||
|
# strips each WAN's DHCP/RA-provided DNS from resolved as soon as it appears
|
||||||
|
# (NM's own ipv4/ipv6.ignore-auto-dns can't be set as a config-file default —
|
||||||
|
# it's rejected as an unknown key there, even though it's a real per-
|
||||||
|
# connection property), so these are the only unicast resolvers in play,
|
||||||
|
# regardless of whether the WAN is Wapana, Starlink, or cellular.
|
||||||
|
# Domains=~. makes them the default route for every query (there being no
|
||||||
|
# competing per-link DNS to prioritize over them in the first place).
|
||||||
|
#
|
||||||
|
# This does NOT affect .local (mDNS) resolution — that's handled separately,
|
||||||
|
# per-link, only on br0 (see 21-van-br0.network's MulticastDNS=yes), so
|
||||||
|
# ESPHome/other mDNS devices on the van's own LAN still resolve.
|
||||||
|
#
|
||||||
|
# ZeroTier's own DNS (zt.wrede.pvt, via zerotier-systemd-manager) is a
|
||||||
|
# separate, more-specific routing domain on the ztuga7c2kh link and is
|
||||||
|
# unaffected by this — resolved always prefers a domain-specific route over
|
||||||
|
# the Domains=~. fallback.
|
||||||
|
[Resolve]
|
||||||
|
DNS=@DNS_RESOLVERS@
|
||||||
|
Domains=~.
|
||||||
|
# Global default: resolved gates per-link MulticastDNS=yes settings behind
|
||||||
|
# this — a link can't enable mDNS on its own if the global default is "no"
|
||||||
|
# (confirmed: "Setting mDNS support level yes for X, but the global support
|
||||||
|
# level is no"). br0 (21-van-br0.network) opts in; every WAN link is opted
|
||||||
|
# back out explicitly by failover/60-van-wan-dns so mDNS stays scoped to the
|
||||||
|
# van's own LAN and never leaks onto Wapana/Starlink/cellular.
|
||||||
|
MulticastDNS=yes
|
||||||
@@ -1 +1,11 @@
|
|||||||
net.ipv4.ip_forward=1
|
net.ipv4.ip_forward=1
|
||||||
|
|
||||||
|
# Ubuntu ships /usr/lib/sysctl.d/55-ipv6-privacy.conf with use_tempaddr=2
|
||||||
|
# (prefer rotating privacy addresses) system-wide. The WAN uplinks need one
|
||||||
|
# stable IPv6 source per interface: address churn at boot (this default plus
|
||||||
|
# the dracut networkd catch-all deploy.sh masks) poisoned upstream per-flow
|
||||||
|
# state for long-lived UDP flows created mid-churn — see the 2026-07-12/13
|
||||||
|
# "wan IPv6 overdue" investigation. 99- beats 55- lexically so this wins
|
||||||
|
# when `sysctl --system` runs.
|
||||||
|
net.ipv6.conf.all.use_tempaddr=0
|
||||||
|
net.ipv6.conf.default.use_tempaddr=0
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
interface=wlxd8ec5e2faa8c
|
interface=@WIFI_2G_IFACE@
|
||||||
# Second, independent hostapd instance (hostapd-2g.service) — 2.4GHz band of the same
|
# Second, independent hostapd instance (hostapd-2g.service) — 2.4GHz band of the same
|
||||||
# VanLink network, bridged into the same br0 segment as the 5GHz AP and the wired port.
|
# VanLink network, bridged into the same br0 segment as the 5GHz AP and the wired port.
|
||||||
# Separate process on purpose: a USB wedge on one radio must never take down the other.
|
# Separate process on purpose: a USB wedge on one radio must never take down the other.
|
||||||
@@ -17,7 +17,7 @@ wmm_enabled=1
|
|||||||
# HT20 only — 40MHz in 2.4GHz overlaps most of the band and coexistence would force
|
# HT20 only — 40MHz in 2.4GHz overlaps most of the band and coexistence would force
|
||||||
# it back to 20MHz near any neighbor anyway.
|
# it back to 20MHz near any neighbor anyway.
|
||||||
ieee80211n=1
|
ieee80211n=1
|
||||||
ht_capab=[LDPC][SHORT-GI-20]
|
ht_capab=[SHORT-GI-20]
|
||||||
auth_algs=1
|
auth_algs=1
|
||||||
wpa=2
|
wpa=2
|
||||||
wpa_passphrase=1foot11foot11
|
wpa_passphrase=1foot11foot11
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=VanLink 2.4GHz AP (hostapd on wlxd8ec5e2faa8c, RTL8822BU)
|
Description=VanLink 2.4GHz AP (hostapd on @WIFI_2G_IFACE@ — see deploy.conf for current hardware)
|
||||||
# Same USB re-enumeration hazards as the 5GHz AP (see hostapd-restart.conf for the
|
# Same USB re-enumeration hazards as the 5GHz AP (see hostapd-restart.conf for the
|
||||||
# full story): never stop retrying, and back off a few seconds so the USB device can
|
# full story): never stop retrying, and back off a few seconds so the USB device can
|
||||||
# re-enumerate before the next attempt.
|
# re-enumerate before the next attempt.
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
interface=wlxc83a35a4ee55
|
interface=@WIFI_5G_IFACE@
|
||||||
# Put the AP into br0 so Wi-Fi and the wired LAN port share one 10.42.0.0/24 segment.
|
# Put the AP into br0 so Wi-Fi and the wired LAN port share one 10.42.0.0/24 segment.
|
||||||
# hostapd adds the wlan to the bridge after setting AP mode; the bridge itself + its
|
# hostapd adds the wlan to the bridge after setting AP mode; the bridge itself + its
|
||||||
# wired member + the gateway IP are defined under /etc/systemd/network (2x-van-br0/lan).
|
# wired member + the gateway IP are defined under /etc/systemd/network (2x-van-br0/lan).
|
||||||
|
|||||||
Executable
+67
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ap/install-rtw89-driver.sh — install/update the out-of-tree morrownr/rtw89 driver
|
||||||
|
# for the 5GHz AP dongle (RTL8852BU, USB).
|
||||||
|
#
|
||||||
|
# Why: this Ubuntu kernel base predates mainline rtw89's USB2->3 auto-switch for this
|
||||||
|
# chip, so the in-kernel rtw89_8852bu permanently caps the dongle at USB2/480M. The
|
||||||
|
# morrownr/rtw89 driver has that switch. ap/rtw89.conf blacklists the in-kernel rtw89
|
||||||
|
# modules so udev loads this one instead — that file is deploy.sh's job to install;
|
||||||
|
# this script is the (heavier, slower, not idempotent-per-deploy) driver build/install
|
||||||
|
# itself, kept separate so a routine `deploy.sh` run doesn't trigger a dkms rebuild.
|
||||||
|
#
|
||||||
|
# Run manually after a fresh Pi provisioning, or to bump PIN_COMMIT to a newer upstream
|
||||||
|
# revision: sudo ./ap/install-rtw89-driver.sh
|
||||||
|
#
|
||||||
|
# Safe to re-run — no-ops if the pinned commit is already built+installed for the
|
||||||
|
# running kernel (e.g. after a kernel upgrade where DKMS's own auto-rebuild already
|
||||||
|
# handled it).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
[ "$(id -u)" -eq 0 ] || { echo "Run as root (sudo)." >&2; exit 1; }
|
||||||
|
|
||||||
|
REPO_URL=https://github.com/morrownr/rtw89
|
||||||
|
# Pinned for reproducibility — bump deliberately, not by tracking a moving branch.
|
||||||
|
PIN_COMMIT=8b2b78deb357d01fd5808164046e31239139ed9f
|
||||||
|
PKG_NAME=rtw89
|
||||||
|
PKG_VERSION=7.3 # from upstream's dkms.conf; independent of PIN_COMMIT
|
||||||
|
SRC="/usr/src/${PKG_NAME}-${PKG_VERSION}"
|
||||||
|
KVER=$(uname -r)
|
||||||
|
|
||||||
|
if [ -d "$SRC/.git" ]; then
|
||||||
|
git -C "$SRC" fetch origin
|
||||||
|
else
|
||||||
|
rm -rf "$SRC"
|
||||||
|
git clone "$REPO_URL" "$SRC"
|
||||||
|
fi
|
||||||
|
git -C "$SRC" checkout "$PIN_COMMIT"
|
||||||
|
|
||||||
|
if dkms status "${PKG_NAME}/${PKG_VERSION}" 2>/dev/null | grep -q "${KVER}.*: installed" \
|
||||||
|
&& [ "$(git -C "$SRC" rev-parse HEAD)" = "$PIN_COMMIT" ]; then
|
||||||
|
echo "rtw89 driver already installed at pinned commit ${PIN_COMMIT:0:12} for ${KVER}; nothing to do."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
dkms remove "${PKG_NAME}/${PKG_VERSION}" --all 2>/dev/null || true
|
||||||
|
dkms add "$SRC"
|
||||||
|
dkms install "${PKG_NAME}/${PKG_VERSION}"
|
||||||
|
make -C "$SRC" install_fw
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
rtw89 driver installed (commit ${PIN_COMMIT:0:12}) for kernel ${KVER}.
|
||||||
|
|
||||||
|
If in-kernel rtw89 modules are currently loaded, they won't unload themselves —
|
||||||
|
loading this driver alongside them fails with "exports duplicate symbol" (hit this
|
||||||
|
2026-08-23: rtw89_core stayed resident because rtw89_8852b/rtw89_8852b_common, its own
|
||||||
|
in-kernel dependents, were still loaded too). To switch a live system over:
|
||||||
|
|
||||||
|
sudo rmmod rtw89_8852bu rtw89_8852b rtw89_8852b_common rtw89_core rtw89_usb 2>/dev/null
|
||||||
|
# then force the AP dongle to re-enumerate, e.g.:
|
||||||
|
echo 0 | sudo tee /sys/bus/usb/devices/<bus-port>/authorized
|
||||||
|
echo 1 | sudo tee /sys/bus/usb/devices/<bus-port>/authorized
|
||||||
|
|
||||||
|
(or just reboot). hostapd's Restart=always + van-ap-watchdog recover the AP
|
||||||
|
automatically once the new driver claims the interface — no manual hostapd restart
|
||||||
|
needed. ap/rtw89.conf must already be deployed (sudo ./deploy.sh) so the blacklist is
|
||||||
|
in place before the device re-enumerates, or the in-kernel driver will just reclaim it.
|
||||||
|
EOF
|
||||||
@@ -4,20 +4,8 @@
|
|||||||
table ip van_router_nat
|
table ip van_router_nat
|
||||||
delete table ip van_router_nat
|
delete table ip van_router_nat
|
||||||
table ip van_router_nat {
|
table ip van_router_nat {
|
||||||
chain prerouting {
|
|
||||||
type nat hook prerouting priority dstnat; policy accept;
|
|
||||||
# Home Assistant VM (ha_van) is bridged onto br0 at 10.42.0.50 — clients reach
|
|
||||||
# it directly. Keep the legacy http://10.42.0.1:8123 URL working for anything
|
|
||||||
# that bookmarked it (phones, ZT clients).
|
|
||||||
ip daddr 10.42.0.1 tcp dport 8123 dnat to 10.42.0.50:8123
|
|
||||||
}
|
|
||||||
chain postrouting {
|
chain postrouting {
|
||||||
type nat hook postrouting priority srcnat; policy accept;
|
type nat hook postrouting priority srcnat; policy accept;
|
||||||
ip saddr 10.42.0.0/24 oifname != "br0" masquerade
|
ip saddr 10.42.0.0/24 oifname != "br0" masquerade
|
||||||
# Hairpin for the legacy 10.42.0.1:8123 DNAT when the client sits on the same
|
|
||||||
# subnet as the HA VM: without masquerade the VM would reply directly on br0
|
|
||||||
# from 10.42.0.50 and the client (expecting 10.42.0.1) would drop it. ZT-sourced
|
|
||||||
# traffic doesn't match and doesn't need it — VM replies route back through us.
|
|
||||||
ip saddr 10.42.0.0/24 ip daddr 10.42.0.50 tcp dport 8123 oifname "br0" masquerade
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
# 2.4GHz AP dongle (Linksys WUSB6300 v2, RTL8822BU, rtw88_8822bu).
|
|
||||||
# Deep power-save has no business on an always-on AP radio — same reasoning as the
|
|
||||||
# rtw89 disable_ps_mode gotcha on the 5GHz dongle (rtw89.conf).
|
|
||||||
options rtw88_core disable_lps_deep=Y
|
|
||||||
# Don't let the driver switch a USB2-enumerated dongle up to USB3: USB3 signalling
|
|
||||||
# radiates broadband noise right across the 2.4GHz band, and HT20 rates fit in USB2
|
|
||||||
# easily. NOTE: this only blocks the driver's own USB2->3 switch — a dongle sitting in
|
|
||||||
# a USB3 port still enumerates at SuperSpeed (it currently does); move it to a USB2
|
|
||||||
# port if 2.4GHz range/interference ever looks poor.
|
|
||||||
options rtw88_usb switch_usb_mode=N
|
|
||||||
+48
-1
@@ -1 +1,48 @@
|
|||||||
options rtw89_core disable_ps_mode=Y
|
# 5GHz AP dongle (Realtek RTL8852BU). Since 2026-08-23 this uses the out-of-tree
|
||||||
|
# morrownr/rtw89 driver (dkms package "rtw89", modules suffixed _git) instead of the
|
||||||
|
# in-kernel rtw89_8852bu — the in-kernel driver on this Ubuntu kernel base predates
|
||||||
|
# mainline's USB2->3 auto-switch, so the dongle was permanently capped at USB2/480M.
|
||||||
|
# Install: https://github.com/morrownr/rtw89 (`sudo dkms install`, `sudo make
|
||||||
|
# install_fw`). This file both tunes the driver and blacklists the in-kernel modules
|
||||||
|
# so udev picks the out-of-tree ones on (re)enumeration.
|
||||||
|
options rtw89_core_git disable_ps_mode=Y
|
||||||
|
options rtw89_usb_git switch_usb_mode=y
|
||||||
|
|
||||||
|
# Blacklist the in-kernel rtw89 drivers.
|
||||||
|
blacklist rtw89_8851bu
|
||||||
|
blacklist rtw89_8851be
|
||||||
|
blacklist rtw89_8851b
|
||||||
|
blacklist rtw89_8852au
|
||||||
|
blacklist rtw89_8852ae
|
||||||
|
blacklist rtw89_8852a
|
||||||
|
blacklist rtw89_8852b_common
|
||||||
|
blacklist rtw89_8852bu
|
||||||
|
blacklist rtw89_8852be
|
||||||
|
blacklist rtw89_8852b
|
||||||
|
blacklist rtw89_8852bte
|
||||||
|
blacklist rtw89_8852bt
|
||||||
|
blacklist rtw89_8852cu
|
||||||
|
blacklist rtw89_8852ce
|
||||||
|
blacklist rtw89_8852c
|
||||||
|
blacklist rtw89_8922au
|
||||||
|
blacklist rtw89_8922ae
|
||||||
|
blacklist rtw89_8922a
|
||||||
|
blacklist rtw89_8922de
|
||||||
|
blacklist rtw89_8922d
|
||||||
|
blacklist rtw89_core
|
||||||
|
blacklist rtw89_usb
|
||||||
|
blacklist rtw89_pci
|
||||||
|
|
||||||
|
# Blacklist Larry Finger's out-of-tree rtw89 driver too, in case it's ever installed.
|
||||||
|
blacklist rtw89core
|
||||||
|
blacklist rtw89pci
|
||||||
|
blacklist rtw_8851b
|
||||||
|
blacklist rtw_8851be
|
||||||
|
blacklist rtw_8852a
|
||||||
|
blacklist rtw_8852ae
|
||||||
|
blacklist rtw_8852b
|
||||||
|
blacklist rtw_8852be
|
||||||
|
blacklist rtw_8852c
|
||||||
|
blacklist rtw_8852ce
|
||||||
|
blacklist rtw_8922a
|
||||||
|
blacklist rtw_8922ae
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ domain-needed
|
|||||||
bogus-priv
|
bogus-priv
|
||||||
dhcp-authoritative
|
dhcp-authoritative
|
||||||
dhcp-range=10.42.0.10,10.42.0.254,255.255.255.0,12h
|
dhcp-range=10.42.0.10,10.42.0.254,255.255.255.0,12h
|
||||||
# Home Assistant VM (libvirt ha_van, bridged onto br0) — pinned address, resolves
|
# Home Assistant runs natively on this host (Podman, host network, :8123) —
|
||||||
# as "homeassistant" via this dnsmasq; HAOS also announces homeassistant.local (mDNS).
|
# "homeassistant" is just another name for the router address.
|
||||||
dhcp-host=52:54:00:ad:0a:01,10.42.0.50,homeassistant
|
host-record=homeassistant,10.42.0.1
|
||||||
dhcp-option=option:router,10.42.0.1
|
dhcp-option=option:router,10.42.0.1
|
||||||
dhcp-option=option:dns-server,10.42.0.1
|
dhcp-option=option:dns-server,10.42.0.1
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
[keyfile]
|
[keyfile]
|
||||||
# The AP wlans (5GHz + 2.4GHz) and the wired LAN port are all owned by
|
# The AP wlans (5GHz + 2.4GHz) and the wired LAN ports (USB dongle + onboard
|
||||||
# systemd-networkd/hostapd (bridged into br0), so NetworkManager must not touch them.
|
# eth0) are all owned by systemd-networkd/hostapd (bridged into br0), so
|
||||||
unmanaged-devices=interface-name:wlxc83a35a4ee55;interface-name:wlxd8ec5e2faa8c;interface-name:enx00e04c331140
|
# NetworkManager must not touch them.
|
||||||
|
unmanaged-devices=interface-name:@WIFI_5G_IFACE@;interface-name:@WIFI_2G_IFACE@;interface-name:@LAN_USB_IFACE@;interface-name:eth0
|
||||||
|
|||||||
+72
-12
@@ -8,16 +8,23 @@ exits and systemd restarts it (Restart=always, no start limit) until the interfa
|
|||||||
returns. This daemon is the backstop for the case systemd *can't* see: hostapd stays
|
returns. This daemon is the backstop for the case systemd *can't* see: hostapd stays
|
||||||
running but the radio has wedged and stopped serving (dmesg "timed out to flush queues").
|
running but the radio has wedged and stopped serving (dmesg "timed out to flush queues").
|
||||||
|
|
||||||
Every `interval` seconds it checks two things: hostapd's self-reported state via the
|
Every `interval` seconds it checks three things: hostapd's self-reported state via the
|
||||||
control socket (hostapd_cli status -> state=ENABLED), AND the kernel's ground truth for
|
control socket (hostapd_cli status -> state=ENABLED), the kernel's ground truth for the
|
||||||
the netdev (operstate up + still a port of the bridge). Both matter because they fail
|
netdev (operstate up + still a port of the bridge), and the kernel log for a TX-queue
|
||||||
independently: hostapd_cli keeps answering state=ENABLED off stale in-memory state after
|
wedge on this radio's driver (rtw88/rtw89 "timed out to flush queue(s)"). All three
|
||||||
the USB radio is torn down and re-enumerated underneath a still-running hostapd — the
|
matter because they fail independently: hostapd_cli keeps answering state=ENABLED off
|
||||||
netdev is recreated DOWN and dropped from the bridge, but hostapd never noticed and never
|
stale in-memory state after the USB radio is torn down and re-enumerated underneath a
|
||||||
exited, so Restart=always never fired. The link check catches exactly that. If the AP is
|
still-running hostapd — the netdev is recreated DOWN and dropped from the bridge, but
|
||||||
unhealthy for `fail_threshold` checks in a row, it clears any failed state and restarts
|
hostapd never noticed and never exited, so Restart=always never fired. The link check
|
||||||
hostapd. If the interface is simply gone (mid re-enumeration) it waits — there is
|
catches exactly that. Separately, the radio can wedge without any re-enumeration at
|
||||||
nothing to restart onto, and Restart=always reclaims it when it reappears. Stdlib only.
|
all: hostapd keeps reporting state=ENABLED and the netdev stays up/bridged throughout,
|
||||||
|
but the driver silently stops moving frames (2026-08-19: 5GHz AP unreachable for ~1.5h,
|
||||||
|
hostapd and link both reported healthy the whole time; dmesg showed
|
||||||
|
"rtw89_8852bu ...: timed out to flush queues" at the moment clients dropped). The
|
||||||
|
queue-flush check catches that. If the AP is unhealthy for `fail_threshold` checks in a
|
||||||
|
row, it clears any failed state and restarts hostapd. If the interface is simply gone
|
||||||
|
(mid re-enumeration) it waits — there is nothing to restart onto, and Restart=always
|
||||||
|
reclaims it when it reappears. Stdlib only.
|
||||||
|
|
||||||
Usage: van-ap-watchdog [hostapd.conf path] [systemd unit]
|
Usage: van-ap-watchdog [hostapd.conf path] [systemd unit]
|
||||||
Defaults watch the 5GHz AP (/etc/hostapd/hostapd.conf, unit hostapd); the 2.4GHz
|
Defaults watch the 5GHz AP (/etc/hostapd/hostapd.conf, unit hostapd); the 2.4GHz
|
||||||
@@ -28,6 +35,7 @@ import re
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
HOSTAPD_CONF = sys.argv[1] if len(sys.argv) > 1 else "/etc/hostapd/hostapd.conf"
|
HOSTAPD_CONF = sys.argv[1] if len(sys.argv) > 1 else "/etc/hostapd/hostapd.conf"
|
||||||
@@ -85,6 +93,47 @@ def link_healthy(ifname, bridge):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def driver_name(ifname):
|
||||||
|
"""Kernel driver bound to the interface's USB device (e.g. rtw89_8852bu). Used to
|
||||||
|
scope the queue-flush-timeout check to this radio, so the 2.4GHz and 5GHz watchdog
|
||||||
|
instances don't trip on each other's dmesg lines.
|
||||||
|
|
||||||
|
Path.resolve() doesn't raise on a missing path — it just returns the syntactic
|
||||||
|
path unchanged — so if this runs before the interface has enumerated (e.g. right
|
||||||
|
at watchdog startup, mid USB re-enum) a naive .resolve().name silently returns the
|
||||||
|
literal string "driver" instead of None, and that bogus value gets cached forever
|
||||||
|
by the caller's `if driver is None: retry` check. Explicitly check existence first
|
||||||
|
so a not-yet-enumerated device correctly yields None and gets retried."""
|
||||||
|
p = Path(f"/sys/class/net/{ifname}/device/driver")
|
||||||
|
try:
|
||||||
|
if not p.exists():
|
||||||
|
return None
|
||||||
|
return p.resolve().name
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def queue_flush_wedged(driver, since):
|
||||||
|
"""True if this radio's driver logged a TX-queue-flush timeout since `since`. Both
|
||||||
|
rtw88 ("timed out to flush queue %d") and rtw89 ("timed out to flush queues") share
|
||||||
|
the substring "timed out to flush queue". This is the one signal that still catches
|
||||||
|
a wedge when hostapd_cli and the netdev both keep reporting healthy (see module
|
||||||
|
docstring, 2026-08-19 incident)."""
|
||||||
|
if not driver:
|
||||||
|
return False
|
||||||
|
pattern = rf"^{re.escape(driver)} .*timed out to flush queue"
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
["journalctl", "-k", "--since", since.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"-g", pattern, "-o", "cat", "--no-pager"],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
).stdout
|
||||||
|
except (OSError, subprocess.SubprocessError) as e:
|
||||||
|
log(f"journalctl queue-flush check failed: {e}", "warn")
|
||||||
|
return False
|
||||||
|
return bool(out.strip())
|
||||||
|
|
||||||
|
|
||||||
def ap_enabled(ifname):
|
def ap_enabled(ifname):
|
||||||
"""True if hostapd reports the AP as beaconing (state=ENABLED). False if it's
|
"""True if hostapd reports the AP as beaconing (state=ENABLED). False if it's
|
||||||
running but not enabled; None if the control socket is unreachable (hostapd down)."""
|
running but not enabled; None if the control socket is unreachable (hostapd down)."""
|
||||||
@@ -120,13 +169,17 @@ def main():
|
|||||||
log("no interface= in hostapd.conf; nothing to watch", "crit")
|
log("no interface= in hostapd.conf; nothing to watch", "crit")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
bridge = ap_bridge()
|
bridge = ap_bridge()
|
||||||
|
driver = driver_name(ifname)
|
||||||
log(f"van-ap-watchdog up: watching {ifname}"
|
log(f"van-ap-watchdog up: watching {ifname}"
|
||||||
f"{f' on {bridge}' if bridge else ''} every {INTERVAL}s "
|
f"{f' on {bridge}' if bridge else ''}"
|
||||||
|
f"{f' (driver {driver})' if driver else ''} every {INTERVAL}s "
|
||||||
f"(restart after {FAIL_THRESHOLD} bad checks)")
|
f"(restart after {FAIL_THRESHOLD} bad checks)")
|
||||||
|
|
||||||
bad = 0
|
bad = 0
|
||||||
waiting = False # latch so "interface absent" logs once, not every tick
|
waiting = False # latch so "interface absent" logs once, not every tick
|
||||||
|
last_check = datetime.now() # window start for the queue-flush dmesg check
|
||||||
while True:
|
while True:
|
||||||
|
now = datetime.now()
|
||||||
if not iface_present(ifname):
|
if not iface_present(ifname):
|
||||||
if not waiting:
|
if not waiting:
|
||||||
log(f"{ifname} absent — USB re-enumeration in progress; "
|
log(f"{ifname} absent — USB re-enumeration in progress; "
|
||||||
@@ -135,7 +188,13 @@ def main():
|
|||||||
bad = 0
|
bad = 0
|
||||||
else:
|
else:
|
||||||
waiting = False
|
waiting = False
|
||||||
if ap_enabled(ifname) and link_healthy(ifname, bridge):
|
if driver is None: # fill in late if the watchdog started before the device existed
|
||||||
|
driver = driver_name(ifname)
|
||||||
|
wedged = queue_flush_wedged(driver, last_check)
|
||||||
|
if wedged:
|
||||||
|
log(f"{ifname} ({driver}) logged a TX queue flush timeout — "
|
||||||
|
f"radio wedged despite healthy hostapd/link state", "warn")
|
||||||
|
if ap_enabled(ifname) and link_healthy(ifname, bridge) and not wedged:
|
||||||
if bad:
|
if bad:
|
||||||
log(f"AP {ifname} beaconing again")
|
log(f"AP {ifname} beaconing again")
|
||||||
bad = 0
|
bad = 0
|
||||||
@@ -144,6 +203,7 @@ def main():
|
|||||||
if bad >= FAIL_THRESHOLD:
|
if bad >= FAIL_THRESHOLD:
|
||||||
recover(ifname)
|
recover(ifname)
|
||||||
bad = 0
|
bad = 0
|
||||||
|
last_check = now
|
||||||
time.sleep(INTERVAL)
|
time.sleep(INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# cockpit-session@.service drop-in: the Python cockpit-bridge frees spawn-pipe
|
||||||
|
# fds only at GC time; the stock 1024 soft limit is too tight for a polling
|
||||||
|
# dashboard and EMFILEs the polkit/sudo escalation spawn. Hard limit is 524288.
|
||||||
|
[Service]
|
||||||
|
LimitNOFILE=65535
|
||||||
@@ -15,11 +15,21 @@
|
|||||||
<span id="updated" class="muted">loading…</span>
|
<span id="updated" class="muted">loading…</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="deploywarn-card" style="display:none">
|
||||||
|
<h3>Deploy Warnings</h3>
|
||||||
|
<div id="deploywarn"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Access Points</h3>
|
<h3>Access Points</h3>
|
||||||
<div id="ap"></div>
|
<div id="ap"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Clients</h3>
|
||||||
|
<div id="clients"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Temperatures</h3>
|
<h3>Temperatures</h3>
|
||||||
<div id="thermal"></div>
|
<div id="thermal"></div>
|
||||||
@@ -35,11 +45,24 @@
|
|||||||
<div id="failover"></div>
|
<div id="failover"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Starlink</h3>
|
||||||
|
<div id="starlink"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h3>Wi-Fi Networks</h3>
|
||||||
|
<button id="wifi-scan" class="btn">Scan</button>
|
||||||
|
</div>
|
||||||
|
<div id="wifi"><p class="muted">Click Scan to search for networks.</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>WAN / Uplinks</h3>
|
<h3>WAN / Uplinks</h3>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Device</th><th>Type</th><th>State</th><th>IPv4</th><th>Metric</th><th>Actions</th></tr>
|
<tr><th>Device</th><th>Type</th><th>State</th><th>IPv4</th><th>Signal</th><th>Metric</th><th>Actions</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="wan"></tbody>
|
<tbody id="wan"></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
.vr-head { display: flex; align-items: baseline; gap: 14px; }
|
.vr-head { display: flex; align-items: baseline; gap: 14px; }
|
||||||
.vr-head h2 { margin: 0; }
|
.vr-head h2 { margin: 0; }
|
||||||
.card { background: #fff; border: 1px solid #d2d2d2; border-radius: 6px;
|
.card { background: #fff; border: 1px solid #d2d2d2; border-radius: 6px;
|
||||||
padding: 16px; margin-top: 16px; }
|
padding: 16px; margin-top: 16px; font-family: "Red Hat Text", "RedHatText", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans JP", "Noto Sans KR", "Noto Sans Malayalam", "Noto Sans SC", "Noto Sans TC", "Noto Sans Thai", Helvetica, Arial, sans-serif; }
|
||||||
.card-head { display: flex; align-items: center; justify-content: space-between; }
|
.card-head { display: flex; align-items: center; justify-content: space-between; }
|
||||||
.card h3 { margin: 0 0 10px 0; }
|
.card h3 { margin: 0 0 10px 0; }
|
||||||
.card-head h3 { margin: 0; }
|
.card-head h3 { margin: 0; }
|
||||||
@@ -23,3 +23,7 @@ th { color: #6a6e73; font-weight: 600; }
|
|||||||
.active-wan { font-weight: 700; color: #0066cc; }
|
.active-wan { font-weight: 700; color: #0066cc; }
|
||||||
.btn { margin-left: 6px; padding: 3px 10px; cursor: pointer; }
|
.btn { margin-left: 6px; padding: 3px 10px; cursor: pointer; }
|
||||||
.btn[disabled] { cursor: default; opacity: 0.5; }
|
.btn[disabled] { cursor: default; opacity: 0.5; }
|
||||||
|
#deploywarn-card { border-color: #e08a00; }
|
||||||
|
.warn-list { margin: 0; padding-left: 20px; }
|
||||||
|
.warn-list li { background: #f5d9a8; color: #5f4414; border-radius: 4px;
|
||||||
|
padding: 4px 8px; margin: 4px 0; list-style: none; margin-left: -20px; }
|
||||||
|
|||||||
+402
-82
@@ -1,15 +1,26 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
// Basic Van Router dashboard for Cockpit.
|
// Basic Van Router dashboard for Cockpit.
|
||||||
// Read-only status via cockpit.spawn (logged-in user); mutating actions use
|
// All read-only status is gathered in ONE cockpit.spawn per refresh: the Python
|
||||||
|
// bridge only frees spawn-pipe fds at GC time, so a dozen spawns every 5s marched
|
||||||
|
// it into its 1024-fd limit (EMFILE on admin escalation). Mutating actions use
|
||||||
// { superuser: "require" } which triggers Cockpit's admin (polkit) escalation.
|
// { superuser: "require" } which triggers Cockpit's admin (polkit) escalation.
|
||||||
|
|
||||||
// AP radios (MAC-derived iface names, stable) — each runs its own hostapd unit.
|
// AP radios (MAC-derived iface names, stable) — each runs its own hostapd unit.
|
||||||
|
// Interface names/vendor ID below are templated from deploy.conf at deploy
|
||||||
|
// time (see deploy.sh's render()) — edit deploy.conf, not the values here.
|
||||||
const APS = [
|
const APS = [
|
||||||
{ iface: "wlxc83a35a4ee55", unit: "hostapd", band: "5GHz" }, // RTL8852BU
|
{ iface: "@WIFI_5G_IFACE@", unit: "hostapd", band: "5GHz" },
|
||||||
{ iface: "wlxd8ec5e2faa8c", unit: "hostapd-2g", band: "2.4GHz" }, // RTL8822BU
|
{ iface: "@WIFI_2G_IFACE@", unit: "hostapd-2g", band: "2.4GHz" },
|
||||||
];
|
];
|
||||||
const PREFER_FILE = "/run/van-failover/prefer"; // van-failover reads this to pick the preferred WAN
|
const PREFER_FILE = "/run/van-failover/prefer"; // van-failover reads this to pick the preferred WAN
|
||||||
|
// Wired LAN bridge ports: clients behind them are found via the bridge FDB.
|
||||||
|
const LAN_PORTS = { "eth0": "LAN (eth0)", "@LAN_USB_IFACE@": "LAN (USB)" };
|
||||||
|
// Starlink dish: gRPC status API on the fixed management IP (reached via the /32
|
||||||
|
// link route the netplan profile installs on the RTL8153 uplink).
|
||||||
|
const STARLINK = { iface: "@STARLINK_IFACE@", dish: "192.168.100.1:9200" };
|
||||||
|
// Cellular modem (Quectel EC25-AF): used to find the USB device for a hard restart.
|
||||||
|
const MODEM_USB_VENDOR = "@MODEM_USB_VENDOR@";
|
||||||
|
|
||||||
function run(args, opts) {
|
function run(args, opts) {
|
||||||
return cockpit.spawn(args, Object.assign({ err: "message" }, opts || {}));
|
return cockpit.spawn(args, Object.assign({ err: "message" }, opts || {}));
|
||||||
@@ -26,53 +37,107 @@ function esc_sh(s) {
|
|||||||
return "'" + String(s).replace(/'/g, "'\\''") + "'";
|
return "'" + String(s).replace(/'/g, "'\\''") + "'";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- one-spawn status collection ---------- */
|
||||||
|
|
||||||
|
// Each section's output is preceded by a @@vr:<name>@@ marker line.
|
||||||
|
const STATUS_SCRIPT = (() => {
|
||||||
|
const parts = [];
|
||||||
|
const add = (name, cmd) =>
|
||||||
|
parts.push(`printf '\\n@@vr:%s@@\\n' ${esc_sh(name)}; { ${cmd}; } 2>/dev/null || true`);
|
||||||
|
APS.forEach((a, i) => {
|
||||||
|
add(`active${i}`, `systemctl is-active ${a.unit}`);
|
||||||
|
add(`info${i}`, `iw dev ${a.iface} info`);
|
||||||
|
add(`stations${i}`, `iw dev ${a.iface} station dump`);
|
||||||
|
});
|
||||||
|
add("deploywarn", "cat /var/lib/vanlink/deploy-warnings.json");
|
||||||
|
add("neigh", "ip -j neigh show dev br0");
|
||||||
|
add("fdb", "bridge -j fdb show br br0");
|
||||||
|
add("leases", "cat /var/lib/misc/dnsmasq.leases");
|
||||||
|
add("thermal", "cat /run/van-thermal/state.json");
|
||||||
|
// Sentinel words (ABSENT/NOGRPCURL/UNREACHABLE) let the renderer tell the
|
||||||
|
// three failure modes apart; anything starting with '{' is dish status JSON.
|
||||||
|
add("starlink",
|
||||||
|
`if ! ip link show ${STARLINK.iface} >/dev/null 2>&1; then echo ABSENT; ` +
|
||||||
|
`elif ! command -v grpcurl >/dev/null 2>&1; then echo NOGRPCURL; ` +
|
||||||
|
`else timeout 4 grpcurl -plaintext -max-time 3 -d '{"get_status":{}}' ` +
|
||||||
|
`${STARLINK.dish} SpaceX.API.Device.Device/Handle || echo UNREACHABLE; fi`);
|
||||||
|
add("battery", "cat /run/van-battery/state.json");
|
||||||
|
add("failover", "cat /run/van-failover/state.json");
|
||||||
|
add("modem", "mmcli -m any -K");
|
||||||
|
add("devices", "nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device status");
|
||||||
|
add("routes", "ip -j route show default");
|
||||||
|
add("addrs", "ip -j -4 addr");
|
||||||
|
return parts.join("\n");
|
||||||
|
})();
|
||||||
|
|
||||||
|
function parseSections(out) {
|
||||||
|
const secs = {};
|
||||||
|
let cur = null;
|
||||||
|
out.split("\n").forEach(line => {
|
||||||
|
const m = line.match(/^@@vr:(\w+)@@$/);
|
||||||
|
if (m) { cur = m[1]; secs[cur] = []; }
|
||||||
|
else if (cur !== null) secs[cur].push(line);
|
||||||
|
});
|
||||||
|
Object.keys(secs).forEach(k => { secs[k] = secs[k].join("\n"); });
|
||||||
|
return secs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJSON(text, fallback) {
|
||||||
|
try { return JSON.parse(text); } catch (e) { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Deploy warnings (deploy.sh's collected warn() calls) ---------- */
|
||||||
|
|
||||||
|
function renderDeployWarnings(dw) {
|
||||||
|
const card = document.getElementById("deploywarn-card");
|
||||||
|
const warnings = (dw && dw.warnings) || [];
|
||||||
|
if (!warnings.length) { card.style.display = "none"; return; }
|
||||||
|
card.style.display = "";
|
||||||
|
let html = `<ul class="warn-list">`;
|
||||||
|
warnings.forEach(w => { html += `<li>${esc(w)}</li>`; });
|
||||||
|
html += `</ul>`;
|
||||||
|
html += `<p class="muted">From last deploy (${esc(dw.deployed || "—")}) — ` +
|
||||||
|
`re-run <code>sudo ./deploy.sh</code> after fixing to clear.</p>`;
|
||||||
|
document.getElementById("deploywarn").innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Access Point ---------- */
|
/* ---------- Access Point ---------- */
|
||||||
|
|
||||||
async function readAP(a) {
|
function parseAP(a, i, secs) {
|
||||||
let active = "inactive";
|
const active = (secs[`active${i}`] || "").trim() || "inactive";
|
||||||
try { active = (await sh(`systemctl is-active ${a.unit} || true`)).trim(); } catch (e) { /* ignore */ }
|
|
||||||
|
|
||||||
let info = "";
|
const info = secs[`info${i}`] || "";
|
||||||
try { info = await run(["iw", "dev", a.iface, "info"]); } catch (e) { info = ""; }
|
|
||||||
const ssid = (info.match(/\bssid (.+)/) || [])[1];
|
const ssid = (info.match(/\bssid (.+)/) || [])[1];
|
||||||
const chan = (info.match(/\bchannel \d+[^\n]*/) || [])[0];
|
const chan = (info.match(/\bchannel \d+[^\n]*/) || [])[0];
|
||||||
const width = (info.match(/\bwidth: ([^\n,]+)/) || [])[1];
|
const width = (info.match(/\bwidth: ([^\n,]+)/) || [])[1];
|
||||||
|
|
||||||
let stations = [];
|
const stations = (secs[`stations${i}`] || "").split(/Station /).slice(1).map(b => ({
|
||||||
try {
|
|
||||||
const dump = await run(["iw", "dev", a.iface, "station", "dump"]);
|
|
||||||
stations = dump.split(/Station /).slice(1).map(b => ({
|
|
||||||
mac: b.split(" ")[0],
|
mac: b.split(" ")[0],
|
||||||
sig: (b.match(/signal:\s*([\-\d]+)/) || [])[1],
|
sig: (b.match(/signal:\s*([\-\d]+)/) || [])[1],
|
||||||
tx: (b.match(/tx bitrate:\s*([\d.]+ MBit\/s)/) || [])[1]
|
tx: (b.match(/tx bitrate:\s*([\d.]+ MBit\/s)/) || [])[1]
|
||||||
}));
|
}));
|
||||||
} catch (e) { stations = []; }
|
|
||||||
|
|
||||||
return { band: a.band, unit: a.unit, active, ssid, chan, width, stations };
|
return { band: a.band, unit: a.unit, active, ssid, chan, width, stations };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readClientDir() {
|
function parseClientDir(secs) {
|
||||||
// MAC -> { ip, host } for AP clients. Kernel neighbor table first (covers
|
// MAC -> { ip, host } for AP clients. Kernel neighbor table first (covers
|
||||||
// static-IP clients), then dnsmasq leases on top (authoritative + hostname).
|
// static-IP clients), then dnsmasq leases on top (authoritative + hostname).
|
||||||
const dir = {};
|
const dir = {};
|
||||||
try {
|
parseJSON(secs.neigh, []).forEach(n => {
|
||||||
JSON.parse(await run(["ip", "-j", "neigh", "show", "dev", "br0"])).forEach(n => {
|
|
||||||
if (n.lladdr && n.dst && !n.dst.includes(":")) // IPv4 only
|
if (n.lladdr && n.dst && !n.dst.includes(":")) // IPv4 only
|
||||||
dir[n.lladdr.toLowerCase()] = { ip: n.dst, host: null };
|
dir[n.lladdr.toLowerCase()] = { ip: n.dst, host: null };
|
||||||
});
|
});
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
try {
|
|
||||||
// lease line: <expiry-epoch> <mac> <ip> <hostname|*> <client-id>
|
// lease line: <expiry-epoch> <mac> <ip> <hostname|*> <client-id>
|
||||||
(await run(["cat", "/var/lib/misc/dnsmasq.leases"])).trim().split("\n").forEach(l => {
|
(secs.leases || "").trim().split("\n").forEach(l => {
|
||||||
const f = l.split(" ");
|
const f = l.split(" ");
|
||||||
if (f.length >= 4)
|
if (f.length >= 4)
|
||||||
dir[f[1].toLowerCase()] = { ip: f[2], host: f[3] === "*" ? null : f[3] };
|
dir[f[1].toLowerCase()] = { ip: f[2], host: f[3] === "*" ? null : f[3] };
|
||||||
});
|
});
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
return dir;
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAPs(aps, dir) {
|
function renderAPs(aps) {
|
||||||
const el = document.getElementById("ap");
|
const el = document.getElementById("ap");
|
||||||
el.innerHTML = "";
|
el.innerHTML = "";
|
||||||
aps.forEach(ap => {
|
aps.forEach(ap => {
|
||||||
@@ -87,32 +152,59 @@ function renderAPs(aps, dir) {
|
|||||||
html += ` <span class="pill bad">not beaconing</span>`;
|
html += ` <span class="pill bad">not beaconing</span>`;
|
||||||
html += ` Clients: <b>${ap.stations.length}</b>` +
|
html += ` Clients: <b>${ap.stations.length}</b>` +
|
||||||
` <button class="btn ap-restart">Restart</button></p>`;
|
` <button class="btn ap-restart">Restart</button></p>`;
|
||||||
|
|
||||||
if (ap.stations.length) {
|
|
||||||
html += `<table><thead><tr><th>MAC</th><th>IP</th><th>Hostname</th><th>Signal</th><th>TX rate</th></tr></thead><tbody>`;
|
|
||||||
ap.stations.forEach(s => {
|
|
||||||
const c = dir[s.mac.toLowerCase()] || {};
|
|
||||||
html += `<tr><td>${esc(s.mac)}</td><td>${esc(c.ip || "—")}</td><td>${esc(c.host || "—")}</td>` +
|
|
||||||
`<td>${esc(s.sig || "?")} dBm</td><td>${esc(s.tx || "?")}</td></tr>`;
|
|
||||||
});
|
|
||||||
html += `</tbody></table>`;
|
|
||||||
}
|
|
||||||
div.innerHTML = html;
|
div.innerHTML = html;
|
||||||
div.querySelector(".ap-restart").onclick = () => restartAP(ap.unit);
|
div.querySelector(".ap-restart").onclick = () => restartAP(ap.unit);
|
||||||
el.appendChild(div);
|
el.appendChild(div);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Temperatures (van-thermal daemon state) ---------- */
|
/* ---------- Clients (Wi-Fi stations + wired bridge-port FDB) ---------- */
|
||||||
|
|
||||||
async function readThermal() {
|
function collectClients(aps, secs, dir) {
|
||||||
try {
|
// Wi-Fi clients come from the hostapd station dumps (with signal/rate);
|
||||||
return JSON.parse(await run(["cat", "/run/van-thermal/state.json"]));
|
// wired ones from learned bridge-FDB entries on the LAN ports.
|
||||||
} catch (e) {
|
const rows = [];
|
||||||
return null;
|
const seen = new Set();
|
||||||
}
|
aps.forEach(ap => ap.stations.forEach(s => {
|
||||||
|
const c = dir[s.mac.toLowerCase()] || {};
|
||||||
|
seen.add(s.mac.toLowerCase());
|
||||||
|
rows.push({ conn: ap.band, mac: s.mac, ip: c.ip, host: c.host,
|
||||||
|
sig: s.sig ? s.sig + " dBm" : "?", tx: s.tx || "?" });
|
||||||
|
}));
|
||||||
|
parseJSON(secs.fdb, []).forEach(e => {
|
||||||
|
const conn = LAN_PORTS[e.ifname];
|
||||||
|
const mac = (e.mac || "").toLowerCase();
|
||||||
|
// learned entries only: "permanent" = the port's own MAC, and FDB
|
||||||
|
// duplicates entries per vlan — hence the seen-dedupe.
|
||||||
|
if (!conn || e.master !== "br0" || e.state === "permanent" || seen.has(mac))
|
||||||
|
return;
|
||||||
|
seen.add(mac);
|
||||||
|
const c = dir[mac] || {};
|
||||||
|
rows.push({ conn, mac: e.mac, ip: c.ip, host: c.host, sig: "—", tx: "—" });
|
||||||
|
});
|
||||||
|
rows.sort((a, b) => a.conn.localeCompare(b.conn) || (a.ip || "").localeCompare(b.ip || ""));
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderClients(rows) {
|
||||||
|
const el = document.getElementById("clients");
|
||||||
|
if (!rows.length) {
|
||||||
|
el.innerHTML = `<p class="muted">No clients connected.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = `<table><thead><tr><th>Hostname</th><th>IP</th><th>MAC</th>` +
|
||||||
|
`<th>Connection</th><th>Signal</th><th>TX rate</th></tr></thead><tbody>`;
|
||||||
|
rows.forEach(r => {
|
||||||
|
html += `<tr><td>${esc(r.host || "—")}</td><td>${esc(r.ip || "—")}</td>` +
|
||||||
|
`<td>${esc(r.mac)}</td><td>${esc(r.conn)}</td>` +
|
||||||
|
`<td>${esc(r.sig)}</td><td>${esc(r.tx)}</td></tr>`;
|
||||||
|
});
|
||||||
|
html += `</tbody></table>`;
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Temperatures (van-thermal daemon state) ---------- */
|
||||||
|
|
||||||
function renderThermal(th) {
|
function renderThermal(th) {
|
||||||
const el = document.getElementById("thermal");
|
const el = document.getElementById("thermal");
|
||||||
if (!th || !th.sensors) {
|
if (!th || !th.sensors) {
|
||||||
@@ -120,30 +212,92 @@ function renderThermal(th) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pillClass = { ok: "ok", warn: "warn", crit: "bad" };
|
const pillClass = { ok: "ok", warn: "warn", crit: "bad" };
|
||||||
let html = `<table><thead><tr><th>Sensor</th><th>Temp</th><th>Status</th><th>Warn / Crit</th></tr></thead><tbody>`;
|
let html = `<table><thead><tr><th>Sensor</th><th>Value</th><th>Status</th><th>Limits</th></tr></thead><tbody>`;
|
||||||
Object.keys(th.sensors).forEach(name => {
|
Object.keys(th.sensors).forEach(name => {
|
||||||
const s = th.sensors[name];
|
const s = th.sensors[name];
|
||||||
const temp = s.temp == null ? "—" : `${esc(s.temp)} °C`;
|
let value, limits;
|
||||||
|
if (s.kind === "fan") {
|
||||||
|
value = s.rpm == null ? "—" : `${esc(s.rpm)} RPM`;
|
||||||
|
limits = s.pwm == null ? "—" : `pwm ${esc(s.pwm)}/255`;
|
||||||
|
} else if (s.kind === "undervolt") {
|
||||||
|
value = s.now == null ? "—"
|
||||||
|
: s.now ? "undervoltage"
|
||||||
|
: s.since_boot ? "dip since boot" : "ok";
|
||||||
|
limits = "—";
|
||||||
|
} else {
|
||||||
|
value = s.temp == null ? "—" : `${esc(s.temp)} °C`;
|
||||||
|
limits = `${esc(s.warn)} / ${esc(s.crit)} °C`;
|
||||||
|
}
|
||||||
const lvl = s.level || "ok";
|
const lvl = s.level || "ok";
|
||||||
html += `<tr><td>${esc(name.toUpperCase())}</td><td>${temp}</td>` +
|
html += `<tr><td>${esc(name.toUpperCase())}</td><td>${value}</td>` +
|
||||||
`<td><span class="pill ${pillClass[lvl] || ""}">${esc(lvl)}</span></td>` +
|
`<td><span class="pill ${pillClass[lvl] || ""}">${esc(lvl)}</span></td>` +
|
||||||
`<td class="muted">${esc(s.warn)} / ${esc(s.crit)} °C</td></tr>`;
|
`<td class="muted">${limits}</td></tr>`;
|
||||||
});
|
});
|
||||||
html += `</tbody></table>`;
|
html += `</tbody></table>`;
|
||||||
html += `<p class="muted">History: <code>/var/log/van-thermal.csv</code> · alerts: <code>journalctl -u van-thermal</code></p>`;
|
html += `<p class="muted">History: <code>/var/log/van-thermal.csv</code> · alerts: <code>journalctl -u van-thermal</code></p>`;
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Battery / power source (van-battery daemon state) ---------- */
|
/* ---------- Starlink (dish gRPC get_status via grpcurl) ---------- */
|
||||||
|
|
||||||
async function readBattery() {
|
function fmtMbps(bps) {
|
||||||
try {
|
return bps == null ? "—" : (bps / 1e6).toFixed(1) + " Mbps";
|
||||||
return JSON.parse(await run(["cat", "/run/van-battery/state.json"]));
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtUptime(s) {
|
||||||
|
s = parseInt(s, 10);
|
||||||
|
if (isNaN(s)) return "—";
|
||||||
|
const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60);
|
||||||
|
return (d ? `${d}d ` : "") + (d || h ? `${h}h ` : "") + `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStarlink(raw) {
|
||||||
|
const el = document.getElementById("starlink");
|
||||||
|
const t = (raw || "").trim();
|
||||||
|
if (!t || t === "ABSENT") {
|
||||||
|
el.innerHTML = `<p class="muted">Starlink adapter (<code>${esc(STARLINK.iface)}</code>) not plugged in.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t === "NOGRPCURL") {
|
||||||
|
el.innerHTML = `<p class="muted">grpcurl not installed — the dish status API is gRPC. ` +
|
||||||
|
`arm64 binary: <code>github.com/fullstorydev/grpcurl/releases</code></p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t === "UNREACHABLE" || t[0] !== "{") {
|
||||||
|
el.innerHTML = `<p><span class="pill bad">dish unreachable</span> ` +
|
||||||
|
`<span class="muted">adapter present but 192.168.100.1 not answering ` +
|
||||||
|
`(dish booting / unpowered / route missing?)</span></p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const st = (parseJSON(t, {}) || {}).dishGetStatus || {};
|
||||||
|
const obs = st.obstructionStats || {};
|
||||||
|
const alerts = Object.keys(st.alerts || {}).filter(k => st.alerts[k]);
|
||||||
|
|
||||||
|
let state;
|
||||||
|
if (st.outage)
|
||||||
|
state = `<span class="pill bad">${esc(st.outage.cause || "OUTAGE")}</span>`;
|
||||||
|
else if (obs.currentlyObstructed)
|
||||||
|
state = `<span class="pill warn">obstructed</span>`;
|
||||||
|
else
|
||||||
|
state = `<span class="pill ok">online</span>`;
|
||||||
|
|
||||||
|
let html = `<p>${state}` +
|
||||||
|
(alerts.length ? ` <span class="pill warn">alerts: ${esc(alerts.join(", "))}</span>` : "") +
|
||||||
|
` <span class="muted">uptime ${esc(fmtUptime((st.deviceState || {}).uptimeS))}` +
|
||||||
|
` · sw ${esc((st.deviceInfo || {}).softwareVersion || "—")}</span></p>`;
|
||||||
|
html += `<table><thead><tr><th>Latency (PoP)</th><th>Down</th><th>Up</th><th>Obstructed</th></tr></thead><tbody>`;
|
||||||
|
html += `<tr><td>${st.popPingLatencyMs == null || st.popPingLatencyMs < 0 ? "—" : esc(st.popPingLatencyMs.toFixed(0)) + " ms"}</td>` +
|
||||||
|
`<td>${esc(fmtMbps(st.downlinkThroughputBps))}</td>` +
|
||||||
|
`<td>${esc(fmtMbps(st.uplinkThroughputBps))}</td>` +
|
||||||
|
`<td>${obs.fractionObstructed == null ? "—" : esc((obs.fractionObstructed * 100).toFixed(1)) + " %"}</td></tr>`;
|
||||||
|
html += `</tbody></table>`;
|
||||||
|
html += `<p class="muted">Dish web UI: <code>http://192.168.100.1</code> (from the van LAN)</p>`;
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Battery / power source (van-battery daemon state) ---------- */
|
||||||
|
|
||||||
function renderBattery(b) {
|
function renderBattery(b) {
|
||||||
const el = document.getElementById("battery");
|
const el = document.getElementById("battery");
|
||||||
if (!b || b.capacity == null) {
|
if (!b || b.capacity == null) {
|
||||||
@@ -175,14 +329,6 @@ function renderBattery(b) {
|
|||||||
|
|
||||||
/* ---------- WAN failover (van-failover daemon state) ---------- */
|
/* ---------- WAN failover (van-failover daemon state) ---------- */
|
||||||
|
|
||||||
async function readFailover() {
|
|
||||||
try {
|
|
||||||
return JSON.parse(await run(["cat", "/run/van-failover/state.json"]));
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFailover(fo) {
|
function renderFailover(fo) {
|
||||||
const el = document.getElementById("failover");
|
const el = document.getElementById("failover");
|
||||||
if (!fo || !fo.wans) {
|
if (!fo || !fo.wans) {
|
||||||
@@ -193,7 +339,7 @@ function renderFailover(fo) {
|
|||||||
` <span class="muted">· updated ${esc(fo.updated || "")}</span></p>`;
|
` <span class="muted">· updated ${esc(fo.updated || "")}</span></p>`;
|
||||||
html += `<table><thead><tr><th>WAN</th><th>Priority</th><th>Device</th><th>Status</th><th>Metric</th></tr></thead><tbody>`;
|
html += `<table><thead><tr><th>WAN</th><th>Priority</th><th>Device</th><th>Status</th><th>Metric</th></tr></thead><tbody>`;
|
||||||
fo.wans.forEach(w => {
|
fo.wans.forEach(w => {
|
||||||
const status = !w.present ? `<span class="pill">absent</span>`
|
const status = !w.present ? `<span class="pill warn">absent</span>`
|
||||||
: w.up ? `<span class="pill ok">up</span>`
|
: w.up ? `<span class="pill ok">up</span>`
|
||||||
: `<span class="pill bad">down</span>`;
|
: `<span class="pill bad">down</span>`;
|
||||||
const prio = w.preferred ? `${esc(w.priority)} <span class="muted">(preferred)</span>` : esc(w.priority);
|
const prio = w.preferred ? `${esc(w.priority)} <span class="muted">(preferred)</span>` : esc(w.priority);
|
||||||
@@ -205,33 +351,168 @@ function renderFailover(fo) {
|
|||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Wi-Fi network selector (wlan0 WAN) ---------- */
|
||||||
|
|
||||||
|
// The onboard radio used for the WiFi WAN — unlike the AP dongles it's never
|
||||||
|
// templated, this host only ever has the one.
|
||||||
|
const WIFI_IFACE = "wlan0";
|
||||||
|
|
||||||
|
// SSIDs the van's own AP radios are currently beaconing (kept live from refresh()'s
|
||||||
|
// `iw dev ... info` parse, not hardcoded) — wlan0 "connecting" to its own AP would be
|
||||||
|
// a nonsensical loop, so those rows get their Connect button disabled below.
|
||||||
|
let ownAPSSIDs = new Set();
|
||||||
|
|
||||||
|
// nmcli -t escapes ':' inside field values as '\:' — split on unescaped ':' only.
|
||||||
|
// SSIDs are environment-controlled strings (the van parks near arbitrary APs), so
|
||||||
|
// worth handling properly rather than a naive split(":").
|
||||||
|
function splitNmcli(line) {
|
||||||
|
const parts = [];
|
||||||
|
let cur = "";
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
if (line[i] === "\\" && line[i + 1] === ":") { cur += ":"; i++; }
|
||||||
|
else if (line[i] === ":") { parts.push(cur); cur = ""; }
|
||||||
|
else cur += line[i];
|
||||||
|
}
|
||||||
|
parts.push(cur);
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWifiList(rows) {
|
||||||
|
const el = document.getElementById("wifi");
|
||||||
|
if (!rows.length) {
|
||||||
|
el.innerHTML = `<p class="muted">No networks found. Try Scan.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = `<table><thead><tr><th>SSID</th><th>Signal</th><th>Security</th><th></th></tr></thead><tbody>`;
|
||||||
|
rows.forEach(r => {
|
||||||
|
const ownAP = ownAPSSIDs.has(r.ssid);
|
||||||
|
const label = r.inUse ? "Connected" : ownAP ? "Own AP" : "Connect";
|
||||||
|
html += `<tr><td>${r.inUse ? "★ " : ""}${esc(r.ssid)}</td>` +
|
||||||
|
`<td>${esc(r.signal)}%</td><td>${esc(r.security || "open")}</td>` +
|
||||||
|
`<td class="acts"><button class="btn wifi-connect"${ownAP ? ` title="This is the van's own VanLink AP — wlan0 can't usefully connect to it"` : ""}>${label}</button></td></tr>`;
|
||||||
|
});
|
||||||
|
html += `</tbody></table>`;
|
||||||
|
el.innerHTML = html;
|
||||||
|
el.querySelectorAll(".wifi-connect").forEach((btn, i) => {
|
||||||
|
const r = rows[i];
|
||||||
|
btn.disabled = r.inUse || ownAPSSIDs.has(r.ssid);
|
||||||
|
btn.onclick = () => connectWifi(r);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanWifi() {
|
||||||
|
const el = document.getElementById("wifi");
|
||||||
|
el.innerHTML = `<p class="muted">Scanning…</p>`;
|
||||||
|
try {
|
||||||
|
// Actually triggering a rescan (not just listing NM's cache) needs the
|
||||||
|
// org.freedesktop.NetworkManager.wifi.scan polkit action, which a plain
|
||||||
|
// Cockpit session doesn't have — without escalation this silently returns
|
||||||
|
// only the cached/connected AP instead of erroring.
|
||||||
|
const out = await run(["nmcli", "-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY",
|
||||||
|
"device", "wifi", "list", "ifname", WIFI_IFACE, "--rescan", "yes"],
|
||||||
|
{ superuser: "require" });
|
||||||
|
const seen = new Set();
|
||||||
|
const rows = out.trim().split("\n").filter(Boolean).map(splitNmcli).map(f => ({
|
||||||
|
inUse: f[0].indexOf("*") !== -1, ssid: f[1], signal: f[2], security: f[3],
|
||||||
|
})).filter(r => {
|
||||||
|
if (!r.ssid || seen.has(r.ssid)) return false;
|
||||||
|
seen.add(r.ssid);
|
||||||
|
return true;
|
||||||
|
}).sort((a, b) => (b.inUse - a.inUse) || (parseInt(b.signal, 10) - parseInt(a.signal, 10)));
|
||||||
|
renderWifiList(rows);
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = `<p class="muted">Scan failed: ${esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectWifi(r) {
|
||||||
|
if (ownAPSSIDs.has(r.ssid)) return; // belt-and-suspenders; button is disabled too
|
||||||
|
// Try without a password first — reuses a saved profile's stored secret (or
|
||||||
|
// just works for an open network); only prompt if NM actually needs one.
|
||||||
|
try {
|
||||||
|
await run(["nmcli", "device", "wifi", "connect", r.ssid, "ifname", WIFI_IFACE],
|
||||||
|
{ superuser: "require" });
|
||||||
|
scanWifi();
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
if (!/secret|password|psk/i.test(e.message || "")) {
|
||||||
|
window.alert("Connect failed: " + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pwd = window.prompt(`Password for "${r.ssid}":`);
|
||||||
|
if (!pwd) return;
|
||||||
|
try {
|
||||||
|
await run(["nmcli", "device", "wifi", "connect", r.ssid, "password", pwd, "ifname", WIFI_IFACE],
|
||||||
|
{ superuser: "require" });
|
||||||
|
} catch (e) { window.alert("Connect failed: " + e.message); }
|
||||||
|
scanWifi();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("wifi-scan").onclick = scanWifi;
|
||||||
|
|
||||||
/* ---------- WAN / uplinks ---------- */
|
/* ---------- WAN / uplinks ---------- */
|
||||||
|
|
||||||
async function readWAN() {
|
// mmcli -K key-values -> { netdev, signal, tech, operator } (null if no modem).
|
||||||
const devOut = await run(["nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status"]);
|
// NM's gsm device is the control port (cdc-wdm0); IP/routes live on the wwan netdev.
|
||||||
const devs = devOut.trim().split("\n").map(line => {
|
function parseModem(secs) {
|
||||||
|
const kv = {};
|
||||||
|
(secs.modem || "").trim().split("\n").forEach(l => {
|
||||||
|
const i = l.indexOf(":");
|
||||||
|
if (i > 0) kv[l.slice(0, i).trim()] = l.slice(i + 1).trim();
|
||||||
|
});
|
||||||
|
if (!kv["modem.generic.state"]) return null;
|
||||||
|
let netdev = null;
|
||||||
|
const techs = [];
|
||||||
|
Object.keys(kv).forEach(k => {
|
||||||
|
if (k.startsWith("modem.generic.ports.value")) {
|
||||||
|
const m = kv[k].match(/^(\S+) \(net\)$/);
|
||||||
|
if (m) netdev = m[1];
|
||||||
|
}
|
||||||
|
if (k.startsWith("modem.generic.access-technologies.value"))
|
||||||
|
techs.push(kv[k]);
|
||||||
|
});
|
||||||
|
const op = kv["modem.3gpp.operator-name"];
|
||||||
|
return {
|
||||||
|
netdev,
|
||||||
|
signal: kv["modem.generic.signal-quality.value"],
|
||||||
|
tech: techs.join("/"),
|
||||||
|
operator: op === "--" ? null : op,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWAN(secs) {
|
||||||
|
const modem = parseModem(secs);
|
||||||
|
const devs = (secs.devices || "").trim().split("\n").filter(Boolean).map(line => {
|
||||||
const [device, type, state, ...rest] = line.split(":");
|
const [device, type, state, ...rest] = line.split(":");
|
||||||
return { device, type, state, connection: rest.join(":") };
|
return { device, type, state, connection: rest.join(":"), nmdev: device };
|
||||||
}).filter(d => (d.type === "ethernet" || d.type === "wifi") &&
|
}).filter(d => (d.type === "ethernet" || d.type === "wifi" || d.type === "gsm") &&
|
||||||
|
d.state !== "unmanaged" && // networkd-owned LAN ports (eth0, enx* dongle)
|
||||||
!APS.some(a => a.iface === d.device));
|
!APS.some(a => a.iface === d.device));
|
||||||
|
|
||||||
let routes = [];
|
const routes = parseJSON(secs.routes, []);
|
||||||
try { routes = JSON.parse(await run(["ip", "-j", "route", "show", "default"])); } catch (e) { routes = []; }
|
|
||||||
const metricByDev = {};
|
const metricByDev = {};
|
||||||
routes.forEach(r => { if (r.dev) metricByDev[r.dev] = r.metric; });
|
routes.forEach(r => { if (r.dev) metricByDev[r.dev] = r.metric; });
|
||||||
const activeDev = routes.length
|
const activeDev = routes.length
|
||||||
? routes.slice().sort((a, b) => (a.metric || 0) - (b.metric || 0))[0].dev
|
? routes.slice().sort((a, b) => (a.metric || 0) - (b.metric || 0))[0].dev
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
let addrs = [];
|
|
||||||
try { addrs = JSON.parse(await run(["ip", "-j", "-4", "addr"])); } catch (e) { addrs = []; }
|
|
||||||
const ipByDev = {};
|
const ipByDev = {};
|
||||||
addrs.forEach(a => {
|
parseJSON(secs.addrs, []).forEach(a => {
|
||||||
const info = (a.addr_info || []).find(x => x.family === "inet");
|
const info = (a.addr_info || []).find(x => x.family === "inet");
|
||||||
if (info) ipByDev[a.ifname] = info.local + "/" + info.prefixlen;
|
if (info) ipByDev[a.ifname] = info.local + "/" + info.prefixlen;
|
||||||
});
|
});
|
||||||
|
|
||||||
devs.forEach(d => {
|
devs.forEach(d => {
|
||||||
|
if (d.type === "gsm" && modem) {
|
||||||
|
// show the routed netdev; keep nmdev (cdc-wdm0) for nmcli actions
|
||||||
|
if (modem.netdev) d.device = modem.netdev;
|
||||||
|
if (modem.signal != null)
|
||||||
|
d.signal = `${esc(modem.signal)}%` +
|
||||||
|
(modem.tech || modem.operator
|
||||||
|
? ` <span class="muted">${esc([modem.tech, modem.operator].filter(Boolean).join(" · "))}</span>`
|
||||||
|
: "");
|
||||||
|
}
|
||||||
d.metric = metricByDev[d.device];
|
d.metric = metricByDev[d.device];
|
||||||
d.ip = ipByDev[d.device];
|
d.ip = ipByDev[d.device];
|
||||||
d.active = d.device === activeDev;
|
d.active = d.device === activeDev;
|
||||||
@@ -239,17 +520,26 @@ async function readWAN() {
|
|||||||
return devs;
|
return devs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NM device states that count as "ready to serve traffic" vs. transient vs.
|
||||||
|
// broken/unusable — drives the state-column pill color.
|
||||||
|
const WAN_STATE_PILL = { connected: "ok", connecting: "warn" };
|
||||||
|
|
||||||
function renderWAN(devs) {
|
function renderWAN(devs) {
|
||||||
const tb = document.getElementById("wan");
|
const tb = document.getElementById("wan");
|
||||||
tb.innerHTML = "";
|
tb.innerHTML = "";
|
||||||
devs.forEach(d => {
|
devs.forEach(d => {
|
||||||
const isUp = d.state === "connected";
|
const isConnected = d.state === "connected";
|
||||||
|
// No carrier: nmcli can't connect a device with nothing on the other
|
||||||
|
// end of the wire, so the button would just fail — disable it rather
|
||||||
|
// than let it produce a silent "Toggle failed" alert.
|
||||||
|
const noCarrier = d.state === "unavailable";
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
tr.innerHTML =
|
tr.innerHTML =
|
||||||
`<td class="${d.active ? "active-wan" : ""}">${d.active ? "★ " : ""}${esc(d.device)}</td>` +
|
`<td class="${d.active ? "active-wan" : ""}">${d.active ? "★ " : ""}${esc(d.device)}</td>` +
|
||||||
`<td>${esc(d.type)}</td>` +
|
`<td>${esc(d.type)}</td>` +
|
||||||
`<td>${esc(d.state)}</td>` +
|
`<td><span class="pill ${WAN_STATE_PILL[d.state] || "bad"}">${esc(d.state)}</span></td>` +
|
||||||
`<td>${esc(d.ip || "—")}</td>` +
|
`<td>${esc(d.ip || "—")}</td>` +
|
||||||
|
`<td>${d.signal || "—"}</td>` +
|
||||||
`<td>${d.metric != null ? esc(d.metric) : "—"}</td>` +
|
`<td>${d.metric != null ? esc(d.metric) : "—"}</td>` +
|
||||||
`<td class="acts"></td>`;
|
`<td class="acts"></td>`;
|
||||||
const acts = tr.querySelector(".acts");
|
const acts = tr.querySelector(".acts");
|
||||||
@@ -264,10 +554,19 @@ function renderWAN(devs) {
|
|||||||
}
|
}
|
||||||
const tog = document.createElement("button");
|
const tog = document.createElement("button");
|
||||||
tog.className = "btn";
|
tog.className = "btn";
|
||||||
tog.textContent = isUp ? "Down" : "Up";
|
tog.textContent = isConnected ? "Disconnect" : "Connect";
|
||||||
tog.onclick = () => toggleWAN(d, isUp);
|
tog.disabled = !isConnected && noCarrier;
|
||||||
|
tog.onclick = () => toggleWAN(d, isConnected);
|
||||||
acts.appendChild(tog);
|
acts.appendChild(tog);
|
||||||
|
|
||||||
|
if (d.type === "gsm") {
|
||||||
|
const rst = document.createElement("button");
|
||||||
|
rst.className = "btn";
|
||||||
|
rst.textContent = "Restart";
|
||||||
|
rst.onclick = () => restartModem();
|
||||||
|
acts.appendChild(rst);
|
||||||
|
}
|
||||||
|
|
||||||
tb.appendChild(tr);
|
tb.appendChild(tr);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -284,14 +583,31 @@ async function preferWAN(chosen) {
|
|||||||
setTimeout(refresh, 4500); // daemon enforces on its next probe loop
|
setTimeout(refresh, 4500); // daemon enforces on its next probe loop
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleWAN(d, isUp) {
|
async function toggleWAN(d, isConnected) {
|
||||||
try {
|
try {
|
||||||
const verb = isUp ? "disconnect" : "connect";
|
const verb = isConnected ? "disconnect" : "connect";
|
||||||
await run(["nmcli", "device", verb, d.device], { superuser: "require" });
|
await run(["nmcli", "device", verb, d.nmdev], { superuser: "require" });
|
||||||
} catch (e) { window.alert("Toggle failed: " + e.message); }
|
} catch (e) { window.alert("Toggle failed: " + e.message); }
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function restartModem() {
|
||||||
|
// Radio bounce via ModemManager (the EC25 MBIM plugin doesn't support --reset);
|
||||||
|
// the autoconnect Koodo profile reconnects on enable (verified). If MM can't
|
||||||
|
// talk to the modem at all, fall back to a USB unbind/bind (found by Quectel
|
||||||
|
// vendor id) so the whole driver stack + MM re-probe the device.
|
||||||
|
const script =
|
||||||
|
`if ! { mmcli -m any --disable && mmcli -m any --enable; }; then ` +
|
||||||
|
`dev=""; for f in /sys/bus/usb/devices/*/idVendor; do ` +
|
||||||
|
`[ "$(cat "$f")" = ${esc_sh(MODEM_USB_VENDOR)} ] && { dev=$(basename "$(dirname "$f")"); break; }; done; ` +
|
||||||
|
`[ -n "$dev" ] || { echo "no modem USB device found" >&2; exit 1; }; ` +
|
||||||
|
`echo "$dev" > /sys/bus/usb/drivers/usb/unbind; sleep 3; ` +
|
||||||
|
`echo "$dev" > /sys/bus/usb/drivers/usb/bind; fi`;
|
||||||
|
try { await sh(script, { superuser: "require" }); }
|
||||||
|
catch (e) { window.alert("Modem restart failed: " + e.message); }
|
||||||
|
setTimeout(refresh, 8000);
|
||||||
|
}
|
||||||
|
|
||||||
async function restartAP(unit) {
|
async function restartAP(unit) {
|
||||||
try { await run(["systemctl", "restart", unit], { superuser: "require" }); }
|
try { await run(["systemctl", "restart", unit], { superuser: "require" }); }
|
||||||
catch (e) { window.alert("Restart failed: " + e.message); }
|
catch (e) { window.alert("Restart failed: " + e.message); }
|
||||||
@@ -302,13 +618,17 @@ async function restartAP(unit) {
|
|||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
const [aps, dir, th, bat, fo, wan] = await Promise.all([
|
const secs = parseSections(await sh(STATUS_SCRIPT));
|
||||||
Promise.all(APS.map(readAP)), readClientDir(), readThermal(), readBattery(), readFailover(), readWAN()]);
|
renderDeployWarnings(parseJSON(secs.deploywarn, null));
|
||||||
renderAPs(aps, dir);
|
const aps = APS.map((a, i) => parseAP(a, i, secs));
|
||||||
renderThermal(th);
|
ownAPSSIDs = new Set(aps.map(a => a.ssid).filter(Boolean));
|
||||||
renderBattery(bat);
|
renderAPs(aps);
|
||||||
renderFailover(fo);
|
renderClients(collectClients(aps, secs, parseClientDir(secs)));
|
||||||
renderWAN(wan);
|
renderThermal(parseJSON(secs.thermal, null));
|
||||||
|
renderStarlink(secs.starlink);
|
||||||
|
renderBattery(parseJSON(secs.battery, null));
|
||||||
|
renderFailover(parseJSON(secs.failover, null));
|
||||||
|
renderWAN(parseWAN(secs));
|
||||||
document.getElementById("updated").textContent = "updated " + new Date().toLocaleTimeString();
|
document.getElementById("updated").textContent = "updated " + new Date().toLocaleTimeString();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById("updated").textContent = "error: " + (e.message || e);
|
document.getElementById("updated").textContent = "error: " + (e.message || e);
|
||||||
|
|||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
# deploy.conf — hardware-instance identifiers for this Pi's vanlink deployment.
|
||||||
|
#
|
||||||
|
# These are the values that change when a USB Wi-Fi dongle, LAN adapter, or
|
||||||
|
# modem gets physically swapped (MAC-derived interface names are stable per
|
||||||
|
# physical device, but change when the device changes). Edit here and run
|
||||||
|
# `sudo ./deploy.sh` — it substitutes @TOKEN@ placeholders in the repo's
|
||||||
|
# config templates with these values before installing them.
|
||||||
|
#
|
||||||
|
# Find a new device's interface name after plugging it in: `iw dev` (wifi) or
|
||||||
|
# `ip -br link` (wired). USB vendor ID: `lsusb`.
|
||||||
|
# Preview a rendered file without deploying: `sudo ./deploy.sh render <file>`
|
||||||
|
|
||||||
|
# 5GHz AP radio (currently: Realtek RTL8852BU, driver rtw89_8852bu)
|
||||||
|
WIFI_5G_IFACE=wlxc83a35a4ee55
|
||||||
|
|
||||||
|
# 2.4GHz AP radio (currently: D-Link DWA-171, RTL8821CU, driver rtw88_8821cu)
|
||||||
|
#WIFI_2G_IFACE=wlx3c3332002066
|
||||||
|
WIFI_2G_IFACE=wlxf4f26d1760a7
|
||||||
|
|
||||||
|
# Wired LAN port, USB-attached gigabit adapter (RTL8153-family)
|
||||||
|
LAN_USB_IFACE=enx00e04c331140
|
||||||
|
|
||||||
|
# Starlink dish uplink, USB-attached gigabit adapter (RTL8153-family)
|
||||||
|
#STARLINK_IFACE=enxd8ec5eeb3512 # White Belkin dongle
|
||||||
|
#STARLINK_IFACE=enx6c1ff7d210a5 # UGreen 8-port hub's built-in Ethernet (ASIX AX88179B)
|
||||||
|
STARLINK_IFACE=enx3c8cf861752b # TrendNet USB dongle
|
||||||
|
|
||||||
|
# Cellular modem USB vendor ID (Quectel EC25-AF)
|
||||||
|
MODEM_USB_VENDOR=2c7c
|
||||||
|
|
||||||
|
# Fixed upstream DNS resolvers — this router always uses these, never a WAN's
|
||||||
|
# own DHCP/RA-provided servers (NetworkManager is told to ignore those
|
||||||
|
# entirely; see ap/van-wan-dns.conf + ap/99-van-router-dns.conf). Keeps
|
||||||
|
# resolution identical on Wapana, Starlink, or cellular.
|
||||||
|
DNS_RESOLVERS="1.1.1.1 8.8.8.8"
|
||||||
@@ -1,118 +1,300 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Deploy vanlink configs/scripts from this directory to their system locations.
|
# Deploy vanlink configs/scripts (Pi 4 "wan" port) to their system locations.
|
||||||
# Usage: cd ~/vanlink && sudo ./deploy.sh
|
# Usage: cd ~/vanlink && sudo ./deploy.sh
|
||||||
# Idempotent. See README.md §4 for the two manual steps this does NOT do
|
# sudo ./deploy.sh render <file> # preview a templated file on stdout
|
||||||
# (zerotier-systemd-manager binary install, hostapd unmask).
|
# Idempotent. Netplan (wlan0 + Starlink NIC = NM-managed WANs; eth0 is a LAN
|
||||||
|
# port on br0) is NOT deployed here — reference copy in ap/50-van-wan.yaml,
|
||||||
|
# applied once manually (apply flaps uplinks). We do check it for drift below.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$(readlink -f "$0")")"
|
cd "$(dirname "$(readlink -f "$0")")"
|
||||||
|
|
||||||
|
# deploy.conf holds hardware-instance identifiers (interface names, USB vendor
|
||||||
|
# IDs) that change when a dongle/adapter gets physically swapped. Config files
|
||||||
|
# below carry @TOKEN@ placeholders substituted from these variables via
|
||||||
|
# render()/install_rendered() — edit deploy.conf, not the individual configs.
|
||||||
|
set -a
|
||||||
|
source ./deploy.conf
|
||||||
|
set +a
|
||||||
|
|
||||||
|
render() { # render <file> -> stdout, with @TOKEN@ placeholders substituted
|
||||||
|
sed -e "s|@WIFI_5G_IFACE@|$WIFI_5G_IFACE|g" \
|
||||||
|
-e "s|@WIFI_2G_IFACE@|$WIFI_2G_IFACE|g" \
|
||||||
|
-e "s|@LAN_USB_IFACE@|$LAN_USB_IFACE|g" \
|
||||||
|
-e "s|@STARLINK_IFACE@|$STARLINK_IFACE|g" \
|
||||||
|
-e "s|@MODEM_USB_VENDOR@|$MODEM_USB_VENDOR|g" \
|
||||||
|
-e "s|@DNS_RESOLVERS@|$DNS_RESOLVERS|g" \
|
||||||
|
"$1"
|
||||||
|
}
|
||||||
|
install_rendered() { # install_rendered <src> <dst> [mode]
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp)
|
||||||
|
render "$1" > "$tmp"
|
||||||
|
install -D -m"${3:-0644}" "$tmp" "$2"
|
||||||
|
rm -f "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "${1:-}" = "render" ]; then
|
||||||
|
[ -n "${2:-}" ] || { echo "Usage: $0 render <file>"; exit 1; }
|
||||||
|
render "$2"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
[ "$(id -u)" = 0 ] || { echo "Run with sudo (writes to /etc, /usr)."; exit 1; }
|
[ "$(id -u)" = 0 ] || { echo "Run with sudo (writes to /etc, /usr)."; exit 1; }
|
||||||
|
|
||||||
|
# Collected below and persisted to WARNINGS_FILE so the Cockpit vanrouter page
|
||||||
|
# can surface deploy-time issues (missing deps, unedited example configs, drift)
|
||||||
|
# without someone having to remember to scroll back through deploy output.
|
||||||
|
WARNINGS=()
|
||||||
|
WARNINGS_FILE=/var/lib/vanlink/deploy-warnings.json
|
||||||
|
warn() { # warn <message...> — prints " -> <message>" (as before) and records it
|
||||||
|
echo " -> $*"
|
||||||
|
WARNINGS+=("$*")
|
||||||
|
}
|
||||||
|
write_warnings() {
|
||||||
|
install -d -m0755 "$(dirname "$WARNINGS_FILE")"
|
||||||
|
{
|
||||||
|
printf '{\n "deployed": "%s",\n "warnings": [' "$(date -Iseconds)"
|
||||||
|
local first=1 w e
|
||||||
|
for w in "${WARNINGS[@]}"; do
|
||||||
|
[ "$first" = 1 ] || printf ','
|
||||||
|
first=0
|
||||||
|
e=${w//\\/\\\\}; e=${e//\"/\\\"}
|
||||||
|
printf '\n "%s"' "$e"
|
||||||
|
done
|
||||||
|
printf '\n ]\n}\n'
|
||||||
|
} > "$WARNINGS_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "== netplan drift check =="
|
||||||
|
# Netplan is never installed by this script (applying it flaps the uplinks —
|
||||||
|
# see note below), so it's easy to edit ap/50-van-wan.yaml and forget the
|
||||||
|
# manual `netplan apply` step. Warn loudly rather than silently drifting.
|
||||||
|
if [ ! -f /etc/netplan/50-van-wan.yaml ]; then
|
||||||
|
warn "netplan: /etc/netplan/50-van-wan.yaml is missing — repo config was never deployed. Run: sudo ./deploy.sh render ap/50-van-wan.yaml | sudo tee /etc/netplan/50-van-wan.yaml && sudo netplan generate && sudo netplan apply"
|
||||||
|
elif ! diff -q <(render ap/50-van-wan.yaml) /etc/netplan/50-van-wan.yaml >/dev/null 2>&1; then
|
||||||
|
warn "netplan: /etc/netplan/50-van-wan.yaml differs from ap/50-van-wan.yaml (rendered). Deploy manually: sudo ./deploy.sh render ap/50-van-wan.yaml | sudo tee /etc/netplan/50-van-wan.yaml && sudo netplan generate && sudo netplan apply"
|
||||||
|
diff -u /etc/netplan/50-van-wan.yaml <(render ap/50-van-wan.yaml) || true
|
||||||
|
fi
|
||||||
|
|
||||||
echo "== access point =="
|
echo "== access point =="
|
||||||
install -D -m0644 ap/hostapd.conf /etc/hostapd/hostapd.conf
|
install_rendered ap/hostapd.conf /etc/hostapd/hostapd.conf
|
||||||
install -D -m0644 ap/hostapd-restart.conf /etc/systemd/system/hostapd.service.d/restart.conf
|
install -D -m0644 ap/hostapd-restart.conf /etc/systemd/system/hostapd.service.d/restart.conf
|
||||||
install -D -m0644 ap/default-hostapd /etc/default/hostapd
|
install -D -m0644 ap/default-hostapd /etc/default/hostapd
|
||||||
|
install_rendered ap/hostapd-2g.conf /etc/hostapd/hostapd-2g.conf
|
||||||
|
install_rendered ap/hostapd-2g.service /etc/systemd/system/hostapd-2g.service
|
||||||
|
install_rendered ap/11-van-ap-2g.network /etc/systemd/network/11-van-ap-2g.network
|
||||||
|
install -D -m0644 ap/van-ap-watchdog-2g.service /etc/systemd/system/van-ap-watchdog-2g.service
|
||||||
install -D -m0755 ap/van-ap-watchdog /usr/local/sbin/van-ap-watchdog
|
install -D -m0755 ap/van-ap-watchdog /usr/local/sbin/van-ap-watchdog
|
||||||
install -D -m0644 ap/van-ap-watchdog.service /etc/systemd/system/van-ap-watchdog.service
|
install -D -m0644 ap/van-ap-watchdog.service /etc/systemd/system/van-ap-watchdog.service
|
||||||
install -D -m0644 ap/hostapd-2g.conf /etc/hostapd/hostapd-2g.conf
|
|
||||||
install -D -m0644 ap/hostapd-2g.service /etc/systemd/system/hostapd-2g.service
|
|
||||||
install -D -m0644 ap/11-van-ap-2g.network /etc/systemd/network/11-van-ap-2g.network
|
|
||||||
install -D -m0644 ap/van-ap-watchdog-2g.service /etc/systemd/system/van-ap-watchdog-2g.service
|
|
||||||
install -D -m0644 ap/rtw88.conf /etc/modprobe.d/rtw88.conf
|
|
||||||
install -D -m0644 ap/van-ap-dnsmasq.conf /etc/van-ap/dnsmasq.conf
|
install -D -m0644 ap/van-ap-dnsmasq.conf /etc/van-ap/dnsmasq.conf
|
||||||
install -D -m0644 ap/van-ap-dnsmasq.service /etc/systemd/system/van-ap-dnsmasq.service
|
install -D -m0644 ap/van-ap-dnsmasq.service /etc/systemd/system/van-ap-dnsmasq.service
|
||||||
install -D -m0644 ap/10-van-ap.network /etc/systemd/network/10-van-ap.network
|
install_rendered ap/10-van-ap.network /etc/systemd/network/10-van-ap.network
|
||||||
install -D -m0644 ap/20-van-br0.netdev /etc/systemd/network/20-van-br0.netdev
|
install -D -m0644 ap/20-van-br0.netdev /etc/systemd/network/20-van-br0.netdev
|
||||||
install -D -m0644 ap/21-van-br0.network /etc/systemd/network/21-van-br0.network
|
install -D -m0644 ap/21-van-br0.network /etc/systemd/network/21-van-br0.network
|
||||||
install -D -m0644 ap/22-van-lan.network /etc/systemd/network/22-van-lan.network
|
install_rendered ap/22-van-lan.network /etc/systemd/network/22-van-lan.network
|
||||||
install -D -m0644 ap/van-ap-unmanaged.conf /etc/NetworkManager/conf.d/van-ap-unmanaged.conf
|
install -D -m0644 ap/23-van-lan-eth0.network /etc/systemd/network/23-van-lan-eth0.network
|
||||||
|
install_rendered ap/van-ap-unmanaged.conf /etc/NetworkManager/conf.d/van-ap-unmanaged.conf
|
||||||
|
# Mask dracut's initramfs-generated catch-all (/run/systemd/network/
|
||||||
|
# zzzz-dracut-default.network, regenerated every boot): it matches every
|
||||||
|
# unconfigured link, so networkd co-managed the NM-owned WANs — a second
|
||||||
|
# DHCPv4 client on wlan0 and IPv6 that worked only by accident. NM owns WAN
|
||||||
|
# IPv6 now (see ap/50-van-wan.yaml). /etc overrides /run; /dev/null masks.
|
||||||
|
ln -sfn /dev/null /etc/systemd/network/zzzz-dracut-default.network
|
||||||
install -D -m0644 ap/nftables.conf /etc/nftables.conf
|
install -D -m0644 ap/nftables.conf /etc/nftables.conf
|
||||||
install -D -m0644 ap/regdomain.service /etc/systemd/system/regdomain.service
|
install -D -m0644 ap/regdomain.service /etc/systemd/system/regdomain.service
|
||||||
install -D -m0644 ap/rtw89.conf /etc/modprobe.d/rtw89.conf
|
install -D -m0644 ap/rtw89.conf /etc/modprobe.d/rtw89.conf
|
||||||
install -D -m0644 ap/99-van-router.conf /etc/sysctl.d/99-van-router.conf
|
install -D -m0644 ap/99-van-router.conf /etc/sysctl.d/99-van-router.conf
|
||||||
|
|
||||||
|
echo "== dns =="
|
||||||
|
# Fixed upstream resolvers (deploy.conf's DNS_RESOLVERS), never a WAN's own
|
||||||
|
# DHCP/RA-provided DNS — see the files themselves for the full rationale.
|
||||||
|
# (NM's ipv4/ipv6.ignore-auto-dns can't be set as a config-file connection
|
||||||
|
# default — NM rejects it there — so 60-van-wan-dns enforces this directly
|
||||||
|
# against resolved instead, on every WAN connect/lease event.)
|
||||||
|
# .local (mDNS) is handled separately, scoped to br0 (see 21-van-br0.network
|
||||||
|
# above). ZeroTier-managed DNS (zt.wrede.pvt) is a separate, additive path.
|
||||||
|
install_rendered ap/99-van-router-dns.conf /etc/systemd/resolved.conf.d/99-van-router-dns.conf
|
||||||
|
install -D -m0755 failover/60-van-wan-dns /etc/NetworkManager/dispatcher.d/60-van-wan-dns
|
||||||
|
# ZeroTier-managed DNS for zt.wrede.pvt (see README's "ZeroTier managed DNS").
|
||||||
|
# allowDNS on the network + the search-domain drop-in are repo-tracked so a
|
||||||
|
# reimage doesn't need the manual `zerotier-cli set ... allowDNS=1` step
|
||||||
|
# remembered by hand; the manager binary itself is a hand-installed .deb
|
||||||
|
# (not in apt) — see https://github.com/zerotier/zerotier-systemd-manager/releases.
|
||||||
|
install -D -m0600 dns/zt-network.local.conf /var/lib/zerotier-one/networks.d/d3ecf5726d041b2a.local.conf
|
||||||
|
install -D -m0644 dns/zt-search.conf /etc/systemd/network/99-ztuga7c2kh.network.d/search.conf
|
||||||
|
dpkg -s zerotier-systemd-manager >/dev/null 2>&1 \
|
||||||
|
|| warn "zerotier-systemd-manager not installed — zt.wrede.pvt won't resolve. Install the arm64 .deb from https://github.com/zerotier/zerotier-systemd-manager/releases"
|
||||||
|
|
||||||
echo "== failover =="
|
echo "== failover =="
|
||||||
install -D -m0755 failover/van-failover /usr/local/sbin/van-failover
|
install -D -m0755 failover/van-failover /usr/local/sbin/van-failover
|
||||||
install -D -m0644 failover/config.json /etc/van-failover/config.json
|
install_rendered failover/config.json /etc/van-failover/config.json
|
||||||
install -D -m0644 failover/van-failover.service /etc/systemd/system/van-failover.service
|
install -D -m0644 failover/van-failover.service /etc/systemd/system/van-failover.service
|
||||||
install -D -m0755 failover/50-disable-eee /etc/NetworkManager/dispatcher.d/50-disable-eee
|
install -D -m0755 failover/50-disable-eee /etc/NetworkManager/dispatcher.d/50-disable-eee
|
||||||
install -D -m0644 failover/99-van-arp.conf /etc/sysctl.d/99-van-arp.conf
|
install -D -m0644 failover/99-van-arp.conf /etc/sysctl.d/99-van-arp.conf
|
||||||
|
# Backstop for wlan0's post-boot NM no-secrets wedge (see the script's docstring).
|
||||||
|
install -D -m0755 failover/van-wlan-watchdog /usr/local/sbin/van-wlan-watchdog
|
||||||
|
install -D -m0644 failover/van-wlan-watchdog.service /etc/systemd/system/van-wlan-watchdog.service
|
||||||
|
|
||||||
echo "== zerotier managed dns =="
|
echo "== cellular modem (GSM/LTE) =="
|
||||||
install -D -m0644 dns/zt-search.conf /etc/systemd/network/99-ztuga7c2kh.network.d/search.conf
|
# NM's gsm.auto-config APN lookup needs this apt-only carrier database; without
|
||||||
|
# it even the right APN can't be auto-detected, and MVNOs (e.g. Koodo, which
|
||||||
|
# isn't listed under its own name — only under host network "Telus Mobility")
|
||||||
|
# often aren't in it anyway, so the modem's NM connection profile may still
|
||||||
|
# need an explicit gsm.apn set by hand regardless.
|
||||||
|
dpkg -s mobile-broadband-provider-info >/dev/null 2>&1 \
|
||||||
|
|| warn "mobile-broadband-provider-info missing (apt install mobile-broadband-provider-info) — GSM APN auto-config will fail"
|
||||||
|
# Backstop for the modem sometimes never enumerating at boot (see the
|
||||||
|
# script's docstring) — detection + Pushover alert only. No automated
|
||||||
|
# recovery: a hub power-cycle was proven not to fix this (only a genuine
|
||||||
|
# physical unplug/replug does), so the modem is now on the Pi's native USB
|
||||||
|
# port rather than through the hub. This just pages if it ever recurs.
|
||||||
|
install -D -m0755 failover/van-modem-watch /usr/local/sbin/van-modem-watch
|
||||||
|
install_rendered failover/van-modem-watch.service /etc/systemd/system/van-modem-watch.service
|
||||||
|
# SMS archive + Pushover relay: polls ModemManager for inbound SMS, appends
|
||||||
|
# each to /var/log/van-sms.jsonl, pages via Pushover, then deletes it from
|
||||||
|
# the modem's own flash storage (small, fills up silently otherwise). MMS
|
||||||
|
# can't be fetched here (see script docstring) — notification-only paging.
|
||||||
|
install -D -m0755 modem/van-sms-watch /usr/local/sbin/van-sms-watch
|
||||||
|
install -D -m0644 modem/van-sms-watch.service /etc/systemd/system/van-sms-watch.service
|
||||||
|
# CLI to send an SMS ad hoc (needs sudo — ModemManager's Messaging actions
|
||||||
|
# are PolicyKit-gated). Not a service, just a tool: sudo van-sms-send <number> <text>
|
||||||
|
install -D -m0755 modem/van-sms-send /usr/local/sbin/van-sms-send
|
||||||
|
|
||||||
echo "== cockpit plugin =="
|
echo "== cockpit plugin =="
|
||||||
install -d /usr/share/cockpit/vanrouter
|
install -d /usr/share/cockpit/vanrouter
|
||||||
install -m0644 cockpit/vanrouter/* /usr/share/cockpit/vanrouter/
|
for f in cockpit/vanrouter/*; do
|
||||||
|
b=$(basename "$f")
|
||||||
|
if [ "$b" = "vanrouter.js" ]; then
|
||||||
|
install_rendered "$f" "/usr/share/cockpit/vanrouter/$b"
|
||||||
|
else
|
||||||
|
install -m0644 "$f" "/usr/share/cockpit/vanrouter/$b"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# Bridge fd headroom (Python bridge frees spawn pipes only at GC; 1024 is too tight)
|
||||||
|
install -D -m0644 cockpit/cockpit-session-nofile.conf /etc/systemd/system/cockpit-session@.service.d/nofile.conf
|
||||||
|
# The Starlink card queries the dish's gRPC API; grpcurl isn't packaged in apt.
|
||||||
|
command -v grpcurl >/dev/null 2>&1 \
|
||||||
|
|| warn "grpcurl missing (Starlink card will say so): install linux_arm64 binary from github.com/fullstorydev/grpcurl/releases"
|
||||||
|
|
||||||
|
echo "== gps (cellular modem GNSS -> gpsd) =="
|
||||||
|
if dpkg -s gpsd >/dev/null 2>&1; then
|
||||||
|
install -D -m0644 gps/77-modem-gps.rules /etc/udev/rules.d/77-modem-gps.rules
|
||||||
|
install -D -m0644 gps/gpsd.default /etc/default/gpsd
|
||||||
|
udevadm control --reload
|
||||||
|
else
|
||||||
|
warn "gpsd not installed (apt install gpsd gpsd-clients) — skipping GPS setup"
|
||||||
|
fi
|
||||||
|
# OwnTracks publisher: gpsd fix -> MQTT (broker + creds live only on the
|
||||||
|
# system, 0600 — same pattern as pushover.json).
|
||||||
|
install -D -m0755 gps/van-gps-owntracks /usr/local/sbin/van-gps-owntracks
|
||||||
|
install -D -m0644 gps/van-gps-owntracks.service /etc/systemd/system/van-gps-owntracks.service
|
||||||
|
if [ ! -f /etc/van-gps/config.json ]; then
|
||||||
|
install -D -m0600 gps/config.json.example /etc/van-gps/config.json
|
||||||
|
warn "seeded /etc/van-gps/config.json (EDIT IT: add MQTT username + password)"
|
||||||
|
fi
|
||||||
|
python3 -c 'import gps' 2>/dev/null \
|
||||||
|
|| warn "python3-gps missing (apt install python3-gps) — van-gps-owntracks won't start"
|
||||||
|
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 =="
|
echo "== thermal monitor =="
|
||||||
install -D -m0755 power/van-thermal /usr/local/sbin/van-thermal
|
install -D -m0755 power/van-thermal /usr/local/sbin/van-thermal
|
||||||
install -D -m0644 power/thermal-config.json /etc/van-thermal/config.json
|
install -D -m0644 power/thermal-config.json /etc/van-thermal/config.json
|
||||||
install -D -m0644 power/van-thermal.service /etc/systemd/system/van-thermal.service
|
install -D -m0644 power/van-thermal.service /etc/systemd/system/van-thermal.service
|
||||||
|
# Pushover secrets live only on the system (0600), never in the repo. Path kept
|
||||||
echo "== battery monitor =="
|
# under /etc/van-battery/ for parity with wayback's van-thermal default.
|
||||||
install -D -m0755 power/van-battery /usr/local/sbin/van-battery
|
|
||||||
install -D -m0644 power/battery-config.json /etc/van-battery/config.json
|
|
||||||
install -D -m0644 power/van-battery.service /etc/systemd/system/van-battery.service
|
|
||||||
# Pushover secrets live only on the system (0600), never in the repo. Seed from the
|
|
||||||
# template on first deploy; never clobber a filled-in file on later deploys.
|
|
||||||
if [ ! -f /etc/van-battery/pushover.json ]; then
|
if [ ! -f /etc/van-battery/pushover.json ]; then
|
||||||
install -D -m0600 power/pushover.json.example /etc/van-battery/pushover.json
|
install -D -m0600 power/pushover.json.example /etc/van-battery/pushover.json
|
||||||
echo " -> seeded /etc/van-battery/pushover.json (EDIT IT: add Pushover token + user)"
|
warn "seeded /etc/van-battery/pushover.json (EDIT IT: add Pushover token + user)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "== power / never-sleep =="
|
echo "== nvme watchdog =="
|
||||||
install -D -m0644 power/10-vanlink-nolid.conf /etc/systemd/logind.conf.d/10-vanlink-nolid.conf
|
install -D -m0755 power/van-nvme-watch /usr/local/sbin/van-nvme-watch
|
||||||
# Belt-and-suspenders: a router must never suspend from idle, GUI, or a stray `systemctl suspend`.
|
install -D -m0644 power/nvme-watch-config.json /etc/van-nvme-watch/config.json
|
||||||
systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target >/dev/null 2>&1 || true
|
install -D -m0644 power/van-nvme-watch.service /etc/systemd/system/van-nvme-watch.service
|
||||||
|
|
||||||
|
echo "== home assistant =="
|
||||||
|
# Native HA (Podman Quadlet, replaced the ha_van VM). daemon-reload below
|
||||||
|
# regenerates homeassistant.service; started (not restarted) at the end so a
|
||||||
|
# deploy never bounces HA — after editing the .container, restart it manually.
|
||||||
|
install -D -m0644 ha/homeassistant.container /etc/containers/systemd/homeassistant.container
|
||||||
|
install -d -m0755 /srv/homeassistant
|
||||||
|
# ESPHome dashboard (sibling container, same rationale as HA above — no
|
||||||
|
# Supervisor/add-on store here).
|
||||||
|
install -D -m0644 ha/esphome.container /etc/containers/systemd/esphome.container
|
||||||
|
install -d -m0755 /srv/esphome
|
||||||
|
# Frigate NVR (sibling container, same rationale as HA/ESPHome above).
|
||||||
|
install -D -m0644 ha/frigate.container /etc/containers/systemd/frigate.container
|
||||||
|
install -d -m0755 /srv/frigate/config /srv/frigate/storage
|
||||||
|
|
||||||
echo "== hardware watchdog =="
|
echo "== hardware watchdog =="
|
||||||
install -D -m0644 power/10-vanlink-watchdog.conf /etc/systemd/system.conf.d/10-vanlink-watchdog.conf
|
install -D -m0644 power/10-vanlink-watchdog.conf /etc/systemd/system.conf.d/10-vanlink-watchdog.conf
|
||||||
|
|
||||||
echo "== heartbeat client (dead-man's switch) =="
|
|
||||||
install -D -m0644 heartbeat/hbc.yaml /etc/hbc.yaml
|
|
||||||
install -D -m0644 heartbeat/hbc.service /etc/systemd/system/hbc.service
|
|
||||||
# The hbc binary itself (~/bin/hbc + venv) is installed once via the heartbeat
|
|
||||||
# project's installer — see README §4. Only start the service if it's present.
|
|
||||||
if [ ! -x /home/andreas/bin/hbc ]; then
|
|
||||||
echo " -> /home/andreas/bin/hbc not found; run 'sh ~/git/heartbeat/scripts/hb_install.sh client' (README §4)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "== apply =="
|
echo "== apply =="
|
||||||
sysctl --system >/dev/null
|
sysctl --system >/dev/null
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
# Re-exec PID1 so the system.conf.d watchdog drop-in takes effect (daemon-reload alone
|
# Re-exec PID1 so the system.conf.d watchdog drop-in takes effect (daemon-reload
|
||||||
# does NOT re-arm RuntimeWatchdogSec). Safe online: re-exec keeps all services running.
|
# alone does NOT re-arm RuntimeWatchdogSec). Safe online.
|
||||||
systemctl daemon-reexec
|
systemctl daemon-reexec
|
||||||
# networkd here owns only the AP + ZT overlay (neither a real uplink), so its wait-online
|
# Safe online: picks up 99-van-router-dns.conf immediately. NM's
|
||||||
# can never satisfy "online" and just burns its 120s timeout, stalling network-online.target
|
# ignore-auto-dns only takes effect on a connection's next activation though —
|
||||||
# and ZeroTier by ~2min every boot. Real uplink readiness is covered by NetworkManager-wait-online.
|
# an already-up WAN keeps its currently-applied DNS until it reconnects (or
|
||||||
|
# reboot), deliberately not forced here (reconnecting a WAN flaps it).
|
||||||
|
systemctl restart systemd-resolved
|
||||||
|
# networkd here owns only the AP radios + bridge + wired LAN port (no real uplink);
|
||||||
|
# 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 mask systemd-networkd-wait-online.service >/dev/null 2>&1 || true
|
||||||
# pick up the lid drop-in (re-execs logind; does NOT drop the network)
|
|
||||||
systemctl restart systemd-logind >/dev/null 2>&1 || true
|
|
||||||
systemctl unmask hostapd >/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-battery van-ap-watchdog van-ap-watchdog-2g >/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-sms-watch van-gps-owntracks van-li3-battery >/dev/null 2>&1 || true
|
||||||
systemctl restart van-thermal van-battery
|
# bluetooth: host BlueZ serves the onboard hci0 to the HA container over D-Bus
|
||||||
# Heartbeat: only enable/start once the client binary is installed (README §4).
|
systemctl enable --now bluetooth >/dev/null 2>&1 || true
|
||||||
if [ -x /home/andreas/bin/hbc ]; then
|
systemctl start homeassistant || warn "homeassistant failed to start (podman/quadlet — check journalctl -u homeassistant)"
|
||||||
systemctl enable hbc >/dev/null 2>&1 || true
|
systemctl start esphome || warn "esphome failed to start (podman/quadlet — check journalctl -u esphome)"
|
||||||
systemctl restart hbc
|
systemctl start frigate || warn "frigate failed to start (podman/quadlet — check journalctl -u frigate)"
|
||||||
fi
|
systemctl restart van-thermal
|
||||||
# Pick up the unmanaged-devices change so NM releases the wired LAN port (drops its
|
systemctl restart van-nvme-watch
|
||||||
# old 192.168.10.x lease); networkd then enslaves it to br0 on the restart below.
|
systemctl restart van-sms-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
|
nmcli general reload 2>/dev/null || systemctl reload NetworkManager 2>/dev/null || true
|
||||||
# restart in dependency order: br0 + AP iface + LAN member first, then hostapd adds
|
# restart in dependency order: bridge + members first, then hostapd enslaves the
|
||||||
# the wlan to br0, then dnsmasq binds br0, then NAT/failover
|
# radios, then dnsmasq binds br0, then NAT/failover
|
||||||
systemctl restart systemd-networkd
|
systemctl restart systemd-networkd
|
||||||
systemctl restart hostapd hostapd-2g van-ap-dnsmasq nftables van-failover
|
systemctl restart van-ap-dnsmasq nftables van-failover van-wlan-watchdog
|
||||||
|
# The AP radios live on the USB hub and may be absent; the start then fails but
|
||||||
|
# Restart=always keeps retrying and claims them the moment they enumerate.
|
||||||
|
systemctl restart hostapd hostapd-2g \
|
||||||
|
|| echo " -> hostapd(-2g) waiting for AP radios (USB hub not plugged in)"
|
||||||
# AP watchdogs last, after hostapd is back up (they only ever restart a wedged hostapd)
|
# AP watchdogs last, after hostapd is back up (they only ever restart a wedged hostapd)
|
||||||
systemctl restart van-ap-watchdog van-ap-watchdog-2g
|
systemctl restart van-ap-watchdog van-ap-watchdog-2g
|
||||||
networkctl reload 2>/dev/null || true
|
networkctl reload 2>/dev/null || true
|
||||||
|
|
||||||
|
write_warnings
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Deployed. Verify:"
|
echo "Deployed. NOTE: until the USB hub (AP radios + LAN/Starlink adapters) is"
|
||||||
echo " iw dev wlxc83a35a4ee55 info | grep -E 'ssid|channel|width'"
|
echo "plugged in, hostapd/hostapd-2g just retry every 5s — that is by design."
|
||||||
|
echo "Verify (with hub present):"
|
||||||
|
echo " iw dev $WIFI_5G_IFACE info | grep -E 'ssid|channel|width'"
|
||||||
|
echo " iw dev $WIFI_2G_IFACE info | grep -E 'ssid|channel|width'"
|
||||||
echo " cat /run/van-failover/state.json"
|
echo " cat /run/van-failover/state.json"
|
||||||
echo " cat /run/van-thermal/state.json # CPU + NVMe temps"
|
echo " cat /run/van-thermal/state.json"
|
||||||
echo " cat /run/van-battery/state.json # mains/battery + charge %"
|
echo " cat /run/van-nvme-watch/state.json"
|
||||||
echo " systemctl status hbc # heartbeat client -> hbd.wrede.pvt"
|
echo " journalctl -u van-li3-battery -n 20"
|
||||||
echo "Manual one-time steps (see README §4): zerotier-systemd-manager binary + 'zerotier-cli set <nwid> allowDNS=1'."
|
echo " journalctl -u van-sms-watch -n 20 ; tail -f /var/log/van-sms.jsonl"
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
# vim: ft=systemd
|
|
||||||
# --- Managed by zerotier-systemd-manager. Do not remove this comment. ---
|
|
||||||
[Match]
|
|
||||||
Name=ztuga7c2kh
|
|
||||||
|
|
||||||
[Network]
|
|
||||||
Description=suspicious_house
|
|
||||||
DHCP=no
|
|
||||||
DNS=192.168.196.115
|
|
||||||
DNS=192.168.10.5
|
|
||||||
Domains=~wrede.pvt ~196.168.192.in-addr.arpa ~c.e.3.d.d.f.ip6.arpa
|
|
||||||
ConfigureWithoutCarrier=true
|
|
||||||
KeepConfiguration=static
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Update zerotier per-interface DNS settings
|
|
||||||
Requires=zerotier-one.service
|
|
||||||
After=zerotier-one.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
ExecStart=/usr/bin/zerotier-systemd-manager
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Update zerotier per-interface DNS settings
|
|
||||||
|
|
||||||
[Timer]
|
|
||||||
OnStartupSec=1min
|
|
||||||
OnUnitInactiveSec=1min
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=timers.target
|
|
||||||
+7
-2
@@ -1,3 +1,8 @@
|
|||||||
|
# vim: ft=systemd
|
||||||
|
# Adds zt.wrede.pvt as a search domain (bare-hostname completion, e.g.
|
||||||
|
# `ssh rosepark` -> rosepark.zt.wrede.pvt) alongside the routing-only
|
||||||
|
# ~zt.wrede.pvt entry that zerotier-systemd-manager writes into
|
||||||
|
# 99-ztuga7c2kh.network itself (that file is fully manager-owned — don't
|
||||||
|
# edit it directly, this .network.d drop-in layers on top instead).
|
||||||
[Network]
|
[Network]
|
||||||
Domains=
|
Domains=zt.wrede.pvt
|
||||||
Domains=wrede.pvt ~196.168.192.in-addr.arpa ~c.e.3.d.d.f.ip6.arpa
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""NM dispatcher: never let a WAN's DHCP/RA-provided DNS servers get used,
|
||||||
|
and keep mDNS scoped to the van's own LAN (never a WAN).
|
||||||
|
|
||||||
|
This router only resolves via the fixed servers in
|
||||||
|
/etc/systemd/resolved.conf.d/99-van-router-dns.conf (deploy.conf's
|
||||||
|
DNS_RESOLVERS) — never whatever a WAN happens to hand out (Wapana, a
|
||||||
|
campsite AP, Starlink, cellular). NetworkManager's own ipv4/ipv6.ignore-auto-
|
||||||
|
dns can't be set as a config-file connection default: NM rejects it there
|
||||||
|
("unknown key") even though it's a real, settable per-connection property —
|
||||||
|
so this enforces the same outcome directly against systemd-resolved instead,
|
||||||
|
on every WAN connect/lease event, for every current and future WAN profile.
|
||||||
|
|
||||||
|
Also disables mDNS on the WAN link: 99-van-router-dns.conf sets the global
|
||||||
|
MulticastDNS default to "yes" (a prerequisite for br0's own per-link
|
||||||
|
MulticastDNS=yes to mean anything — resolved gates per-link "yes" behind the
|
||||||
|
global default), so every link starts out mDNS-enabled unless told
|
||||||
|
otherwise; this opts each WAN back out as it comes up.
|
||||||
|
|
||||||
|
Also records what DNS was advertised (but never used) to /run/van-wan-dns/,
|
||||||
|
for reference/debugging.
|
||||||
|
|
||||||
|
NM dispatcher calling convention: argv = [iface, action].
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
STATE_DIR = "/run/van-wan-dns"
|
||||||
|
|
||||||
|
|
||||||
|
def clear_link_dns(iface):
|
||||||
|
subprocess.run(["resolvectl", "dns", iface, ""], check=False)
|
||||||
|
subprocess.run(["resolvectl", "domain", iface, ""], check=False)
|
||||||
|
subprocess.run(["resolvectl", "mdns", iface, "no"], check=False)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
iface, action = sys.argv[1], sys.argv[2]
|
||||||
|
if action not in ("up", "dhcp4-change", "dhcp6-change"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Strip whatever DNS/search-domain this link just got from DHCP/RA —
|
||||||
|
# global DNS=/Domains=~. (99-van-router-dns.conf) then wins for everything.
|
||||||
|
# NM commits its own DNS to resolved asynchronously, shortly *after* this
|
||||||
|
# dispatcher fires — a single clear here loses that race, so retry over a
|
||||||
|
# few seconds (confirmed empirically: one clear immediately gets
|
||||||
|
# clobbered, a clear a couple seconds later sticks).
|
||||||
|
for _ in range(5):
|
||||||
|
clear_link_dns(iface)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
out = subprocess.run(["nmcli", "-t", "-f", "DHCP4.OPTION", "device", "show", iface],
|
||||||
|
capture_output=True, text=True, check=False).stdout
|
||||||
|
|
||||||
|
servers = domain = None
|
||||||
|
for line in out.splitlines():
|
||||||
|
if ":" not in line or " = " not in line:
|
||||||
|
continue
|
||||||
|
_, kv = line.split(":", 1)
|
||||||
|
key, _, val = kv.partition(" = ")
|
||||||
|
if key == "domain_name_servers":
|
||||||
|
servers = val.split()
|
||||||
|
elif key == "domain_name":
|
||||||
|
domain = val.strip() or None
|
||||||
|
|
||||||
|
if not servers:
|
||||||
|
return
|
||||||
|
|
||||||
|
os.makedirs(STATE_DIR, exist_ok=True)
|
||||||
|
with open(f"{STATE_DIR}/{iface}.json", "w") as f:
|
||||||
|
json.dump({
|
||||||
|
"iface": iface,
|
||||||
|
"dns_advertised": servers,
|
||||||
|
"domain_advertised": domain,
|
||||||
|
"updated": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
||||||
|
}, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
{
|
{
|
||||||
"probe_interval": 4,
|
"probe_interval": 60,
|
||||||
"probe_timeout": 3,
|
"probe_timeout": 3,
|
||||||
"fail_threshold": 3,
|
"fail_threshold": 2,
|
||||||
"ok_threshold": 2,
|
"ok_threshold": 1,
|
||||||
"probe_urls": [
|
"probe_urls": [
|
||||||
"http://connectivity-check.ubuntu.com/",
|
"http://connectivity-check.ubuntu.com/"
|
||||||
"http://www.gstatic.com/generate_204",
|
|
||||||
"http://cp.cloudflare.com/"
|
|
||||||
],
|
],
|
||||||
"wans": [
|
"wans": [
|
||||||
{ "name": "wifi", "device": "wlp1s0", "metric": 100 },
|
{ "name": "wifi", "device": "wlan0", "metric": 100 },
|
||||||
{ "name": "starlink", "device": "enxd8ec5eeb3512", "metric": 200 },
|
{ "name": "starlink", "device": "@STARLINK_IFACE@", "metric": 200 },
|
||||||
{ "name": "cellular", "connection": "Koodo", "metric": 300 }
|
{ "name": "cellular", "connection": "Koodo", "metric": 300 }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+61
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Refresh the SD card (mmcblk0p2) as a live boot-fallback clone of the
|
||||||
|
# running root filesystem. Run manually after a push:
|
||||||
|
# sudo ./failover/sync-sd-backup.sh
|
||||||
|
#
|
||||||
|
# EEPROM BOOT_ORDER falls back to the SD card if the primary boot disk
|
||||||
|
# (NVMe) doesn't come up. /etc/fstab, /etc/machine-id, and /var/log are
|
||||||
|
# excluded from the sync so the clone keeps its own partition identity
|
||||||
|
# (PARTUUIDs differ per disk) and boot/journal history instead of
|
||||||
|
# inheriting the source disk's — see failover/README or repo memory
|
||||||
|
# "sd-fallback-fstab-clobber" for what breaks if those leak across.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
[ "$(id -u)" -eq 0 ] || { echo "Run as root (sudo)." >&2; exit 1; }
|
||||||
|
|
||||||
|
SD_ROOT_PART=/dev/mmcblk0p2
|
||||||
|
MOUNT_POINT=/mnt/sd-backup-root
|
||||||
|
|
||||||
|
root_src=$(findmnt -no SOURCE /)
|
||||||
|
if [ "$root_src" = "$SD_ROOT_PART" ]; then
|
||||||
|
echo "Currently booted from $SD_ROOT_PART — refusing to sync SD onto itself." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$MOUNT_POINT"
|
||||||
|
mounted_here=0
|
||||||
|
if ! mountpoint -q "$MOUNT_POINT"; then
|
||||||
|
mount "$SD_ROOT_PART" "$MOUNT_POINT"
|
||||||
|
mounted_here=1
|
||||||
|
else
|
||||||
|
mount -o remount,rw "$MOUNT_POINT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [ "$mounted_here" -eq 1 ]; then
|
||||||
|
umount "$MOUNT_POINT"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# rsync exit 24 ("partial transfer due to vanished source files") is expected on a
|
||||||
|
# live root — files like container shm sockets or in-progress logs routinely disappear
|
||||||
|
# mid-sync. Tolerate that one code; anything else is a real failure and still aborts.
|
||||||
|
rc=0
|
||||||
|
rsync -aHAX --numeric-ids --delete \
|
||||||
|
--info=progress2 \
|
||||||
|
--exclude=/proc/* --exclude=/sys/* --exclude=/dev/* --exclude=/run/* \
|
||||||
|
--exclude=/tmp/* --exclude=/mnt/* --exclude=/media/* --exclude=/lost+found \
|
||||||
|
--exclude=/boot/firmware/* --exclude=/swapfile \
|
||||||
|
--exclude=/etc/fstab --exclude=/etc/machine-id --exclude=/var/log/* \
|
||||||
|
--stats / "$MOUNT_POINT/" || rc=$?
|
||||||
|
|
||||||
|
if [ "$rc" -ne 0 ] && [ "$rc" -ne 24 ]; then
|
||||||
|
echo "rsync failed (exit $rc)." >&2
|
||||||
|
exit "$rc"
|
||||||
|
fi
|
||||||
|
if [ "$rc" -eq 24 ]; then
|
||||||
|
echo "Note: some files vanished mid-sync (rsync exit 24) — normal on a live root." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "SD backup ($SD_ROOT_PART) synced from $root_src."
|
||||||
Executable
+61
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Refresh the USB disk (sda) as a live boot-fallback clone of the
|
||||||
|
# running root filesystem. Run manually after a push:
|
||||||
|
# sudo ./failover/sync-usb-backup.sh
|
||||||
|
#
|
||||||
|
# EEPROM BOOT_ORDER falls back to the SD card, then this USB disk, if the
|
||||||
|
# primary boot disk (NVMe) doesn't come up. /etc/fstab, /etc/machine-id,
|
||||||
|
# and /var/log are excluded from the sync so the clone keeps its own
|
||||||
|
# partition identity (PARTUUIDs differ per disk) and boot/journal history
|
||||||
|
# instead of inheriting the source disk's — see failover/README or repo
|
||||||
|
# memory "sd-fallback-fstab-clobber" for what breaks if those leak across.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
[ "$(id -u)" -eq 0 ] || { echo "Run as root (sudo)." >&2; exit 1; }
|
||||||
|
|
||||||
|
USB_ROOT_PART=/dev/sda2
|
||||||
|
MOUNT_POINT=/mnt/usb-backup-root
|
||||||
|
|
||||||
|
root_src=$(findmnt -no SOURCE /)
|
||||||
|
if [ "$root_src" = "$USB_ROOT_PART" ]; then
|
||||||
|
echo "Currently booted from $USB_ROOT_PART — refusing to sync USB onto itself." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$MOUNT_POINT"
|
||||||
|
mounted_here=0
|
||||||
|
if ! mountpoint -q "$MOUNT_POINT"; then
|
||||||
|
mount "$USB_ROOT_PART" "$MOUNT_POINT"
|
||||||
|
mounted_here=1
|
||||||
|
else
|
||||||
|
mount -o remount,rw "$MOUNT_POINT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [ "$mounted_here" -eq 1 ]; then
|
||||||
|
umount "$MOUNT_POINT"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# rsync exit 24 ("partial transfer due to vanished source files") is expected on a
|
||||||
|
# live root — files like container shm sockets or in-progress logs routinely disappear
|
||||||
|
# mid-sync. Tolerate that one code; anything else is a real failure and still aborts.
|
||||||
|
rc=0
|
||||||
|
rsync -aHAX --numeric-ids --delete \
|
||||||
|
--info=progress2 \
|
||||||
|
--exclude=/proc/* --exclude=/sys/* --exclude=/dev/* --exclude=/run/* \
|
||||||
|
--exclude=/tmp/* --exclude=/mnt/* --exclude=/media/* --exclude=/lost+found \
|
||||||
|
--exclude=/boot/firmware/* --exclude=/swapfile \
|
||||||
|
--exclude=/etc/fstab --exclude=/etc/machine-id --exclude=/var/log/* \
|
||||||
|
--stats / "$MOUNT_POINT/" || rc=$?
|
||||||
|
|
||||||
|
if [ "$rc" -ne 0 ] && [ "$rc" -ne 24 ]; then
|
||||||
|
echo "rsync failed (exit $rc)." >&2
|
||||||
|
exit "$rc"
|
||||||
|
fi
|
||||||
|
if [ "$rc" -eq 24 ]; then
|
||||||
|
echo "Note: some files vanished mid-sync (rsync exit 24) — normal on a live root." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "USB backup ($USB_ROOT_PART) synced from $root_src."
|
||||||
+112
-27
@@ -68,7 +68,19 @@ def resolve(wan, actives):
|
|||||||
return dev, conn
|
return dev, conn
|
||||||
conn = wan.get("connection")
|
conn = wan.get("connection")
|
||||||
dev = next((a["device"] for a in actives if a["name"] == conn), None)
|
dev = next((a["device"] for a in actives if a["name"] == conn), None)
|
||||||
return dev, conn
|
return ip_iface(dev), conn
|
||||||
|
|
||||||
|
|
||||||
|
def ip_iface(dev):
|
||||||
|
"""The routed netdev for an NM device. For MBIM/QMI modems NM's device is the
|
||||||
|
control port (cdc-wdm0) while IP/routes live on the wwan netdev — probing and
|
||||||
|
`ip route` must use the latter."""
|
||||||
|
if not dev:
|
||||||
|
return dev
|
||||||
|
r = sh(["nmcli", "-g", "GENERAL.IP-IFACE", "device", "show", dev])
|
||||||
|
if r and r.returncode == 0 and r.stdout.strip():
|
||||||
|
return r.stdout.strip()
|
||||||
|
return dev
|
||||||
|
|
||||||
|
|
||||||
def probe_one(dev, url, timeout):
|
def probe_one(dev, url, timeout):
|
||||||
@@ -105,7 +117,29 @@ def routes_on(dev):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def enforce_route(dev, metric):
|
def _del_default(dev, r):
|
||||||
|
args = ["ip", "route", "del", "default", "dev", dev, "metric", str(r["metric"])]
|
||||||
|
if r["gw"]:
|
||||||
|
args[4:4] = ["via", r["gw"]]
|
||||||
|
sh(args)
|
||||||
|
|
||||||
|
|
||||||
|
def has_ipv4(dev):
|
||||||
|
"""True if dev currently carries an IPv4 address (carrier genuinely up), used as the
|
||||||
|
signal to install a gateway-less default route for point-to-point/on-link WANs (e.g.
|
||||||
|
the EC25 modem's QMI raw-ip /29, which has no gateway at all — nh 0.0.0.0 — unlike
|
||||||
|
a normal DHCP WAN whose gateway is just temporarily unknown)."""
|
||||||
|
r = sh(["ip", "-4", "-j", "addr", "show", "dev", dev])
|
||||||
|
if not (r and r.stdout.strip()):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
return bool(data and data[0].get("addr_info"))
|
||||||
|
except (json.JSONDecodeError, IndexError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_route(dev, metric, conn=None):
|
||||||
"""Ensure exactly one default route on dev at the desired metric, via `ip route`.
|
"""Ensure exactly one default route on dev at the desired metric, via `ip route`.
|
||||||
NEVER use `nmcli device reapply` — it resets r8152 USB-ethernet carriers and causes
|
NEVER use `nmcli device reapply` — it resets r8152 USB-ethernet carriers and causes
|
||||||
a failover flap. This is a pure routing change (carrier-safe, verified)."""
|
a failover flap. This is a pure routing change (carrier-safe, verified)."""
|
||||||
@@ -115,37 +149,62 @@ def enforce_route(dev, metric):
|
|||||||
if any(r["metric"] == metric for r in rts):
|
if any(r["metric"] == metric for r in rts):
|
||||||
# desired metric already present; just prune any stale others
|
# desired metric already present; just prune any stale others
|
||||||
for r in rts:
|
for r in rts:
|
||||||
if r["metric"] != metric and r["gw"]:
|
if r["metric"] != metric:
|
||||||
sh(["ip", "route", "del", "default", "via", r["gw"], "dev", dev, "metric", str(r["metric"])])
|
_del_default(dev, r)
|
||||||
return
|
return
|
||||||
# Prefer the gw from an existing default route; fall back to NM's known gateway so we can
|
# Prefer the gw from an existing default route; fall back to NM's known gateway so we can
|
||||||
# also *restore* a route that went missing while the carrier is still up (not just rebase one).
|
# also *restore* a route that went missing while the carrier is still up (not just rebase one).
|
||||||
gw = next((r["gw"] for r in rts if r["gw"]), None) or device_gateway(dev)
|
gw = next((r["gw"] for r in rts if r["gw"]), None) or nm_gateway(dev, conn)
|
||||||
if not gw:
|
args = ["ip", "route", "add", "default", "dev", dev, "metric", str(metric), "proto", "static"]
|
||||||
return # no gateway known (carrier down); profile metric still set
|
if gw:
|
||||||
r = sh(["ip", "route", "add", "default", "via", gw, "dev", dev, "metric", str(metric), "proto", "static"])
|
args[4:4] = ["via", gw]
|
||||||
|
elif not has_ipv4(dev):
|
||||||
|
return # no gateway known and carrier not actually up; nothing to route via
|
||||||
|
r = sh(args)
|
||||||
if not (r and r.returncode == 0):
|
if not (r and r.returncode == 0):
|
||||||
# Add failed — most likely another dev transiently holds this exact metric during a
|
# Add failed — most likely another dev transiently holds this exact metric during a
|
||||||
# preference swap. Leave the existing route intact and retry next loop; do NOT prune,
|
# preference swap. Leave the existing route intact and retry next loop; do NOT prune,
|
||||||
# or we'd strand this dev with no default route at all.
|
# or we'd strand this dev with no default route at all.
|
||||||
return
|
return
|
||||||
for old in rts:
|
for old in rts:
|
||||||
if old["metric"] != metric and old["gw"]:
|
if old["metric"] != metric:
|
||||||
sh(["ip", "route", "del", "default", "via", old["gw"], "dev", dev, "metric", str(old["metric"])])
|
_del_default(dev, old)
|
||||||
|
|
||||||
|
|
||||||
def device_gateway(dev):
|
def nm_gateway(dev, conn):
|
||||||
"""NM's gateway for a device — available even when its default route is missing."""
|
"""NM's gateway — available even when the default route is missing. Try the device,
|
||||||
r = sh(["nmcli", "-g", "IP4.GATEWAY", "device", "show", dev])
|
then the connection (a wwan netdev is not an NM device, but its connection is active).
|
||||||
|
Falls back to the raw DHCP4 lease's `routers` option: with ipv4.never-default set (so NM
|
||||||
|
never installs its own competing default route — see set_profile_metric), NM also stops
|
||||||
|
populating IP4.GATEWAY, even though the DHCP-negotiated router is still known underneath."""
|
||||||
|
for kind, name in (("device", dev), ("connection", conn)):
|
||||||
|
if name:
|
||||||
|
r = sh(["nmcli", "-g", "IP4.GATEWAY", kind, "show", name])
|
||||||
|
if r and r.returncode == 0 and r.stdout.strip():
|
||||||
|
return r.stdout.strip()
|
||||||
|
if dev:
|
||||||
|
r = sh(["nmcli", "-g", "DHCP4.OPTION", "device", "show", dev])
|
||||||
if r and r.returncode == 0:
|
if r and r.returncode == 0:
|
||||||
return r.stdout.strip() or None
|
for opt in r.stdout.split(" | "):
|
||||||
|
key, _, val = opt.strip().partition(" = ")
|
||||||
|
if key == "routers" and val:
|
||||||
|
return val.split()[0]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def set_profile_metric(conn, metric):
|
def set_profile_metric(conn, metric):
|
||||||
"""Update the NM profile's route-metric (no reapply) so NM re-assertions stay consistent."""
|
"""Update the NM profile's route-metric (no reapply) so NM re-assertions stay consistent.
|
||||||
|
Also pins never-default=yes: without it, NM's own DHCP client reinstalls its own default
|
||||||
|
route (its device-type default metric, e.g. 100 for ethernet) on every lease renewal,
|
||||||
|
racing the `ip route`-managed one enforce_route() maintains (observed every ~8s on a
|
||||||
|
Starlink dongle with a 16s DHCP lease). Like the metric change, this only takes effect on
|
||||||
|
this connection's *next* activation, not the currently-active one (a live `nmcli modify`
|
||||||
|
doesn't retroactively change an already-active connection's installed routes, and we
|
||||||
|
deliberately never `nmcli device reapply` — see enforce_route) — belt-and-suspenders for
|
||||||
|
connections van-failover doesn't own the netplan source for (e.g. cellular)."""
|
||||||
if conn:
|
if conn:
|
||||||
sh(["nmcli", "connection", "modify", conn, "ipv4.route-metric", str(metric)])
|
sh(["nmcli", "connection", "modify", conn,
|
||||||
|
"ipv4.route-metric", str(metric), "ipv4.never-default", "yes"])
|
||||||
|
|
||||||
|
|
||||||
def read_prefer():
|
def read_prefer():
|
||||||
@@ -177,24 +236,36 @@ def main():
|
|||||||
wans = cfg["wans"]
|
wans = cfg["wans"]
|
||||||
|
|
||||||
# Optimistic start: assume up so healthy WANs immediately get their base metric.
|
# Optimistic start: assume up so healthy WANs immediately get their base metric.
|
||||||
rt = {w["name"]: {"up": True, "ok": ok_th, "fail": 0, "applied": None} for w in wans}
|
rt = {w["name"]: {"up": True, "ok": ok_th, "fail": 0, "applied": None, "healthy": None} for w in wans}
|
||||||
|
# Per-WAN independent probe clock: on the road every link can be flaky independently, so a
|
||||||
|
# struggling WAN retries on its own schedule instead of a shared round dragging healthy WANs
|
||||||
|
# into extra probes (metered cellular data) or throttling a failing one down to the slow
|
||||||
|
# steady-state cadence. 0.0 (epoch) means "due immediately" — probe everything on startup.
|
||||||
|
next_due = {w["name"]: 0.0 for w in wans}
|
||||||
print(f"van-failover started: {[w['name'] for w in wans]}", flush=True)
|
print(f"van-failover started: {[w['name'] for w in wans]}", flush=True)
|
||||||
|
|
||||||
pool = ThreadPoolExecutor(max_workers=max(4, len(wans) * len(urls)))
|
pool = ThreadPoolExecutor(max_workers=max(4, len(wans) * len(urls)))
|
||||||
while True:
|
while True:
|
||||||
|
now = time.monotonic()
|
||||||
actives = active_connections()
|
actives = active_connections()
|
||||||
resolved = {w["name"]: resolve(w, actives) for w in wans}
|
resolved = {w["name"]: resolve(w, actives) for w in wans}
|
||||||
prefer_dev = read_prefer()
|
prefer_dev = read_prefer()
|
||||||
# A WAN is "present" (probeable/manageable) only with both an active device and connection.
|
# A WAN is "present" (probeable/manageable) only with both an active device and connection.
|
||||||
present = {w["name"]: bool(resolved[w["name"]][0]) and bool(resolved[w["name"]][1]) for w in wans}
|
present = {w["name"]: bool(resolved[w["name"]][0]) and bool(resolved[w["name"]][1]) for w in wans}
|
||||||
|
due = [w for w in wans if present[w["name"]] and now >= next_due[w["name"]]]
|
||||||
|
|
||||||
# Probe every (present WAN x url) pair concurrently; a WAN is healthy if ANY url
|
# Probe only WANs that are due, one url-fan-out per WAN, concurrently. A WAN is healthy
|
||||||
# returns 204. Concurrency bounds a failed WAN to ~one timeout, not N serial timeouts.
|
# if ANY url returns 204. `finish` tracks each WAN's own completion time (not one shared
|
||||||
health = {w["name"]: (False if present[w["name"]] else None) for w in wans}
|
# round time) — needed below to tell an instant failure (DNS/route error, no time spent
|
||||||
|
# waiting) apart from a real probe_timeout-bound one.
|
||||||
|
health = {w["name"]: False for w in due}
|
||||||
|
finish = {}
|
||||||
tasks = [(w["name"], pool.submit(probe_one, resolved[w["name"]][0], url, ptimeout))
|
tasks = [(w["name"], pool.submit(probe_one, resolved[w["name"]][0], url, ptimeout))
|
||||||
for w in wans if present[w["name"]] for url in urls]
|
for w in due for url in urls]
|
||||||
for name, fut in tasks:
|
for name, fut in tasks:
|
||||||
if fut.result():
|
ok = fut.result()
|
||||||
|
finish[name] = max(finish.get(name, now), time.monotonic())
|
||||||
|
if ok:
|
||||||
health[name] = True
|
health[name] = True
|
||||||
|
|
||||||
for w in wans:
|
for w in wans:
|
||||||
@@ -205,26 +276,37 @@ def main():
|
|||||||
base = PREFER_METRIC if prefer_dev and dev == prefer_dev else w["metric"]
|
base = PREFER_METRIC if prefer_dev and dev == prefer_dev else w["metric"]
|
||||||
s = rt[name]
|
s = rt[name]
|
||||||
if not present[name]:
|
if not present[name]:
|
||||||
# Absent (e.g. modem unplugged): don't probe/penalize; just keep the
|
# Absent (e.g. modem unplugged): don't probe/penalize; just keep the profile's
|
||||||
# profile's base metric so it lands at the right priority when it connects.
|
# base metric so it lands at the right priority when it connects, and probe it
|
||||||
|
# right away once it reappears.
|
||||||
|
next_due[name] = 0.0
|
||||||
if conn and s["applied"] != base:
|
if conn and s["applied"] != base:
|
||||||
set_profile_metric(conn, base)
|
set_profile_metric(conn, base)
|
||||||
s["applied"] = base
|
s["applied"] = base
|
||||||
continue
|
continue
|
||||||
|
if name in finish:
|
||||||
|
s["healthy"] = health[name]
|
||||||
if health[name]:
|
if health[name]:
|
||||||
s["ok"] += 1
|
s["ok"] += 1
|
||||||
s["fail"] = 0
|
s["fail"] = 0
|
||||||
if s["ok"] >= ok_th:
|
if s["ok"] >= ok_th:
|
||||||
s["up"] = True
|
s["up"] = True
|
||||||
|
next_due[name] = now + interval
|
||||||
else:
|
else:
|
||||||
s["fail"] += 1
|
s["fail"] += 1
|
||||||
s["ok"] = 0
|
s["ok"] = 0
|
||||||
if s["fail"] >= fail_th:
|
if s["fail"] >= fail_th:
|
||||||
s["up"] = False
|
s["up"] = False
|
||||||
|
# Retry as soon as we've spent a full probe_timeout since this attempt
|
||||||
|
# started — immediately if the failure itself ate the whole timeout (a real
|
||||||
|
# timeout), otherwise topped up with a short wait. That's the early-out for
|
||||||
|
# instant failures (DNS/route errors that return in milliseconds): without
|
||||||
|
# the top-up, a permanently-unreachable WAN would retry in a tight loop.
|
||||||
|
next_due[name] = max(finish[name], now + ptimeout)
|
||||||
desired = base if s["up"] else base + PENALTY
|
desired = base if s["up"] else base + PENALTY
|
||||||
# Enforce the live route every loop (carrier-safe, corrects any NM drift);
|
# Enforce the live route every loop (carrier-safe, corrects any NM drift);
|
||||||
# update the NM profile only on an actual state change.
|
# update the NM profile only on an actual state change.
|
||||||
enforce_route(dev, desired)
|
enforce_route(dev, desired, conn)
|
||||||
if s["applied"] != desired:
|
if s["applied"] != desired:
|
||||||
print(f"{name}: {'UP' if s['up'] else 'DOWN'} -> metric {desired}", flush=True)
|
print(f"{name}: {'UP' if s['up'] else 'DOWN'} -> metric {desired}", flush=True)
|
||||||
set_profile_metric(conn, desired)
|
set_profile_metric(conn, desired)
|
||||||
@@ -239,12 +321,15 @@ def main():
|
|||||||
report.append({
|
report.append({
|
||||||
"name": name, "priority": w["metric"], "device": dev, "connection": conn,
|
"name": name, "priority": w["metric"], "device": dev, "connection": conn,
|
||||||
"present": present[name], "up": rt[name]["up"] if present[name] else False,
|
"present": present[name], "up": rt[name]["up"] if present[name] else False,
|
||||||
"healthy": health[name], "preferred": dev is not None and dev == prefer_dev,
|
"healthy": rt[name]["healthy"] if present[name] else None,
|
||||||
|
"preferred": dev is not None and dev == prefer_dev,
|
||||||
"route_metric": routes.get(dev), "active": dev is not None and dev == active_dev,
|
"route_metric": routes.get(dev), "active": dev is not None and dev == active_dev,
|
||||||
})
|
})
|
||||||
write_state({"updated": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
write_state({"updated": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
||||||
"active_device": active_dev, "wans": report})
|
"active_device": active_dev, "wans": report})
|
||||||
time.sleep(interval)
|
|
||||||
|
upcoming = [next_due[w["name"]] for w in wans if present[w["name"]]]
|
||||||
|
time.sleep(max(0.0, min(upcoming) - time.monotonic()) if upcoming else interval)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Executable
+135
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""van-modem-watch — pages if the EC25 modem never enumerates at boot.
|
||||||
|
|
||||||
|
Detection only, deliberately no recovery attempt. On 2026-08-04 the modem
|
||||||
|
(then on a shared powered USB hub) sometimes failed to enumerate on a cold
|
||||||
|
boot. Investigation ruled out a boot-timing race and a bad cable/port: a
|
||||||
|
genuine hub-commanded VBUS power-cycle (confirmed via kernel disconnect/
|
||||||
|
reconnect events, held off for a full 10s) did not recover it, even with the
|
||||||
|
rest of the system already up and stable — only a real physical unplug/
|
||||||
|
replug of the connector ever did. That means no software action from this
|
||||||
|
host can fix it once it happens; the modem was moved off the hub onto the
|
||||||
|
Pi's native USB port as the actual fix (a direct port doesn't reproduce the
|
||||||
|
failure). This just watches for a recurrence and pages, since if it comes
|
||||||
|
back the only real fix is someone physically reseating the connector.
|
||||||
|
|
||||||
|
Pushover credentials shared with van-battery/van-thermal/van-nvme-watch
|
||||||
|
(/etc/van-battery/pushover.json, 0600). Publishes /run/van-modem-watch/
|
||||||
|
state.json (same convention as the other watchdogs). Stdlib only.
|
||||||
|
|
||||||
|
Usage: van-modem-watch [modem-usb-vendor]
|
||||||
|
Defaults to 2c7c (Quectel) — see deploy.conf.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
MODEM_VENDOR = sys.argv[1] if len(sys.argv) > 1 else "2c7c"
|
||||||
|
INTERVAL = 20 # seconds between checks
|
||||||
|
BUDGET_S = 6 * INTERVAL # ~2 minutes of retries after boot, then give up quietly
|
||||||
|
USB_DEVICES = Path("/sys/bus/usb/devices")
|
||||||
|
STATE_DIR = Path("/run/van-modem-watch")
|
||||||
|
STATE_PATH = STATE_DIR / "state.json"
|
||||||
|
CREDENTIALS_PATH = Path("/etc/van-battery/pushover.json")
|
||||||
|
HOST = socket.gethostname()
|
||||||
|
PLACEHOLDERS = {"", "REPLACE_ME", "your-token-here", "your-user-key-here"}
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg, level="info"):
|
||||||
|
# systemd journal severity prefixes (sd-daemon), same convention as van-ap-watchdog.
|
||||||
|
pri = {"info": "<6>", "warn": "<4>", "crit": "<2>"}.get(level, "<6>")
|
||||||
|
print(pri + msg, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def modem_present(vendor):
|
||||||
|
for f in USB_DEVICES.glob("*/idVendor"):
|
||||||
|
try:
|
||||||
|
if f.read_text().strip() == vendor:
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def load_creds():
|
||||||
|
try:
|
||||||
|
c = json.loads(CREDENTIALS_PATH.read_text())
|
||||||
|
token, user = str(c.get("token", "")).strip(), str(c.get("user", "")).strip()
|
||||||
|
if token in PLACEHOLDERS or user in PLACEHOLDERS:
|
||||||
|
return None
|
||||||
|
return token, user
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
log(f"credentials {CREDENTIALS_PATH} unreadable ({e})", "warn")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def pushover(title, message, attempts=3, retry_delay=15):
|
||||||
|
creds = load_creds()
|
||||||
|
if not creds:
|
||||||
|
log(f"pushover skipped (no credentials): {title} — {message}", "warn")
|
||||||
|
return False
|
||||||
|
token, user = creds
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"token": token, "user": user, "title": title, "message": message,
|
||||||
|
}).encode()
|
||||||
|
req = urllib.request.Request("https://api.pushover.net/1/messages.json", data=data)
|
||||||
|
for attempt in range(1, attempts + 1):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
ok = resp.status == 200
|
||||||
|
if not ok:
|
||||||
|
log(f"pushover HTTP {resp.status}", "warn")
|
||||||
|
return ok
|
||||||
|
except Exception as e:
|
||||||
|
last = attempt == attempts
|
||||||
|
log(f"pushover send failed ({attempt}/{attempts}): {e}", "warn")
|
||||||
|
if not last:
|
||||||
|
time.sleep(retry_delay)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def write_state(payload):
|
||||||
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = STATE_PATH.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(payload))
|
||||||
|
tmp.replace(STATE_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
log(f"van-modem-watch up: watching for USB vendor {MODEM_VENDOR} for up to "
|
||||||
|
f"{BUDGET_S}s after boot")
|
||||||
|
elapsed = 0
|
||||||
|
while elapsed < BUDGET_S:
|
||||||
|
if modem_present(MODEM_VENDOR):
|
||||||
|
log("modem present — exiting")
|
||||||
|
write_state({
|
||||||
|
"updated": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||||
|
"present": True,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
time.sleep(INTERVAL)
|
||||||
|
elapsed += INTERVAL
|
||||||
|
|
||||||
|
message = (f"modem (USB vendor {MODEM_VENDOR}) not seen {BUDGET_S}s after boot — "
|
||||||
|
f"needs a physical unplug/replug, no software fix works for this")
|
||||||
|
log(message, "crit")
|
||||||
|
write_state({
|
||||||
|
"updated": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||||
|
"present": False,
|
||||||
|
})
|
||||||
|
pushover(f"🚨 {HOST}: cellular modem absent after boot", message)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
sys.exit(0)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=EC25 modem boot-presence watchdog — pages if it never enumerated (no auto-recovery, see script docstring)
|
||||||
|
After=systemd-udev-settle.service
|
||||||
|
Wants=systemd-udev-settle.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/sbin/van-modem-watch @MODEM_USB_VENDOR@
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""van-wlan-watchdog — recover wlan0 from NetworkManager's post-boot no-secrets wedge.
|
||||||
|
|
||||||
|
Seen on 2026-07-30: wlan0 (onboard radio, used as the WiFi WAN) sometimes fails its
|
||||||
|
very first post-boot association attempt with a spurious supplicant "psk mismatch"
|
||||||
|
(a boot-time brcmfmac firmware/regulatory race, not a real credential problem — a
|
||||||
|
second attempt with the *same* stored secret succeeds immediately). NetworkManager
|
||||||
|
treats any handshake failure it reads as bad secrets as terminal: it does not retry
|
||||||
|
autoconnect after a no-secrets failure, so the device just sits in `disconnected`
|
||||||
|
until something explicitly re-triggers it. This is that trigger.
|
||||||
|
|
||||||
|
Deliberately does NOT hardcode a connection/SSID name: which WiFi network wlan0 uses
|
||||||
|
changes with wherever the van is parked (home, a campsite, a neighbour's AP — see
|
||||||
|
config profiles managed from Cockpit's Networking tab or the Wi-Fi selector on the Van
|
||||||
|
Router page). `nmcli device connect wlan0` lets NM pick amongst whatever profiles are
|
||||||
|
saved and in range on its own, exactly like its normal autoconnect would.
|
||||||
|
|
||||||
|
Bounded to a few minutes after boot, then exits — this is a backstop for the boot race
|
||||||
|
above, not a permanent watcher. It must NOT fight a deliberate later disconnect (e.g.
|
||||||
|
the Cockpit "Disconnect" button), which is why it doesn't loop forever.
|
||||||
|
|
||||||
|
Usage: van-wlan-watchdog [device]
|
||||||
|
Defaults to wlan0 (the AP radios are separate USB dongles, watched by
|
||||||
|
van-ap-watchdog instead).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
DEVICE = sys.argv[1] if len(sys.argv) > 1 else "wlan0"
|
||||||
|
INTERVAL = 15 # seconds between checks
|
||||||
|
BUDGET_S = 8 * INTERVAL # ~2 minutes of retries after boot, then give up quietly
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg, level="info"):
|
||||||
|
# systemd journal severity prefixes (sd-daemon), same convention as van-ap-watchdog.
|
||||||
|
pri = {"info": "<6>", "warn": "<4>", "crit": "<2>"}.get(level, "<6>")
|
||||||
|
print(pri + msg, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def device_state(dev):
|
||||||
|
"""NM's state word for dev (e.g. 'connected', 'disconnected', 'unavailable'), or
|
||||||
|
None if nmcli failed or the device isn't known to NM yet."""
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
["nmcli", "-t", "-f", "GENERAL.STATE", "device", "show", dev],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
).stdout.strip()
|
||||||
|
except (OSError, subprocess.SubprocessError) as e:
|
||||||
|
log(f"nmcli device show {dev} failed: {e}", "warn")
|
||||||
|
return None
|
||||||
|
# terse output looks like "GENERAL.STATE:30 (disconnected)"
|
||||||
|
if "(" in out and out.endswith(")"):
|
||||||
|
return out.rsplit("(", 1)[1][:-1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def connect(dev):
|
||||||
|
log(f"{dev} is disconnected — nudging NM to reconnect (autoconnect doesn't retry "
|
||||||
|
f"after a no-secrets failure, only manual/dispatcher re-activation does)", "warn")
|
||||||
|
r = subprocess.run(["nmcli", "device", "connect", dev],
|
||||||
|
capture_output=True, text=True, timeout=45)
|
||||||
|
if r.returncode == 0:
|
||||||
|
log(f"{dev} reconnected")
|
||||||
|
else:
|
||||||
|
log(f"nmcli device connect {dev} returned {r.returncode}: {r.stderr.strip()}", "warn")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
log(f"van-wlan-watchdog up: watching {DEVICE} for up to {BUDGET_S}s after boot")
|
||||||
|
elapsed = 0
|
||||||
|
while elapsed < BUDGET_S:
|
||||||
|
state = device_state(DEVICE)
|
||||||
|
if state == "disconnected":
|
||||||
|
connect(DEVICE)
|
||||||
|
elif state == "connected":
|
||||||
|
log(f"{DEVICE} connected — exiting")
|
||||||
|
return
|
||||||
|
# "unavailable" (no known/in-range AP), "unmanaged", or unreadable: nothing to
|
||||||
|
# nudge — retrying nmcli device connect would just fail again.
|
||||||
|
time.sleep(INTERVAL)
|
||||||
|
elapsed += INTERVAL
|
||||||
|
log(f"{DEVICE} still not connected after {BUDGET_S}s — giving up "
|
||||||
|
f"(leaving it for manual/Cockpit reconnect from here on)", "warn")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
sys.exit(0)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=wlan0 boot-reconnect backstop (NM no-secrets wedge after a boot-time supplicant glitch)
|
||||||
|
After=NetworkManager.service
|
||||||
|
Wants=NetworkManager.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/sbin/van-wlan-watchdog
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Quectel EC25-AF GNSS NMEA port (USB interface 01): hand it to gpsd on hotplug
|
||||||
|
# via gpsd's own gpsdctl@ mechanism (same pattern as its 60-gpsd.rules) and give
|
||||||
|
# it a stable name. NMEA only streams while the GNSS engine is on — enabled
|
||||||
|
# persistently in modem NV via AT+QGPSCFG="autogps",1 (one-time; see README).
|
||||||
|
ACTION=="add", SUBSYSTEM=="tty", ENV{ID_VENDOR_ID}=="2c7c", ENV{ID_MODEL_ID}=="0125", ENV{ID_USB_INTERFACE_NUM}=="01", SYMLINK+="modem-gps", GROUP="dialout", TAG+="systemd", ENV{SYSTEMD_WANTS}+="gpsdctl@%k.service"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"broker": "home.wrede.ca",
|
||||||
|
"port": 1883,
|
||||||
|
"username": "CHANGE_ME",
|
||||||
|
"password": "CHANGE_ME",
|
||||||
|
"client_id": "vanq-wan",
|
||||||
|
"topic": "owntracks/rv/gps",
|
||||||
|
"tid": "rv",
|
||||||
|
"gpsd_host": "127.0.0.1",
|
||||||
|
"idle_delay_s": 600,
|
||||||
|
"move_trigger_m": 250,
|
||||||
|
"mqtt_version": "3"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Devices gpsd should collect to at boot time.
|
||||||
|
# Empty: the EC25 NMEA port is hot-added via udev (77-modem-gps.rules -> gpsdctl@).
|
||||||
|
DEVICES=""
|
||||||
|
|
||||||
|
# -n: read GNSS data even with no clients connected (keeps the fix warm).
|
||||||
|
GPSD_OPTIONS="-n"
|
||||||
|
|
||||||
|
# Automatically hot add/remove USB GPS devices via gpsdctl (our udev rule uses this).
|
||||||
|
USBAUTO="true"
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
#!/usr/bin/python3 -u
|
||||||
|
# Publish gpsd fixes as OwnTracks location messages over MQTT.
|
||||||
|
# Ported from gps_to_owntracks.py (wayback era): gpsdclient -> python3-gps
|
||||||
|
# (apt-only deps), paho 1.x -> 2.x callback API, hard-coded broker secrets ->
|
||||||
|
# /etc/van-gps/config.json. Exits on MQTT disconnect/DNS failure by design —
|
||||||
|
# systemd Restart=always reconnects with a fresh session.
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import signal
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import socket
|
||||||
|
|
||||||
|
import gps as gpsd
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
from paho.mqtt.properties import Properties
|
||||||
|
from paho.mqtt.packettypes import PacketTypes
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from math import radians, sin, cos, acos
|
||||||
|
|
||||||
|
debug = False # toggle at runtime with SIGHUP
|
||||||
|
config_path = sys.argv[1] if len(sys.argv) > 1 else "/etc/van-gps/config.json"
|
||||||
|
with open(config_path) as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
|
||||||
|
version = cfg.get("mqtt_version", "3") # '3' or '5'
|
||||||
|
mytransport = "tcp" # or 'websockets'
|
||||||
|
broker = cfg["broker"]
|
||||||
|
myport = cfg.get("port", 1883)
|
||||||
|
mq_user = cfg["username"]
|
||||||
|
mq_pw = cfg["password"]
|
||||||
|
mq_id = cfg.get("client_id", "vanq-wan")
|
||||||
|
gpsd_host = cfg.get("gpsd_host", "127.0.0.1")
|
||||||
|
idle_delay = cfg.get("idle_delay_s", 600) # seconds between messages when not moving
|
||||||
|
keep_alive = idle_delay + 60
|
||||||
|
distance_move_trigger = cfg.get("move_trigger_m", 250) # meters of movement that triggers a message
|
||||||
|
|
||||||
|
mytopic = cfg.get("topic", "owntracks/rv/gps")
|
||||||
|
tid = cfg.get("tid", "rv")
|
||||||
|
|
||||||
|
|
||||||
|
def handler(signum, frame):
|
||||||
|
global debug
|
||||||
|
debug = not debug
|
||||||
|
print('debug is now', debug)
|
||||||
|
|
||||||
|
|
||||||
|
def great_circle(llon1, llat1, llon2, llat2):
|
||||||
|
lon1, lat1, lon2, lat2 = map(radians, [llon1, llat1, llon2, llat2])
|
||||||
|
|
||||||
|
a = sin(lat1) * sin(lat2) + cos(lat1) * cos(lat2) * cos(lon1 - lon2)
|
||||||
|
if a >= 1.0:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
res = int(6371000 * (acos(a)))
|
||||||
|
except ValueError as e:
|
||||||
|
print("gc error: ", e)
|
||||||
|
print(" coords: ", llon1, llat1, llon2, llat2)
|
||||||
|
return 0
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def on_disconnect(client, userdata, flags, reason_code, properties):
|
||||||
|
print("mqtt disconnect:", reason_code)
|
||||||
|
os._exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def connect():
|
||||||
|
if version == '5':
|
||||||
|
properties = Properties(PacketTypes.CONNECT)
|
||||||
|
properties.SessionExpiryInterval = 30 * 60 # in seconds
|
||||||
|
|
||||||
|
client.connect(broker,
|
||||||
|
port=myport,
|
||||||
|
clean_start=mqtt.MQTT_CLEAN_START_FIRST_ONLY,
|
||||||
|
properties=properties,
|
||||||
|
keepalive=keep_alive)
|
||||||
|
elif version == '3':
|
||||||
|
client.connect(broker, port=myport, keepalive=keep_alive)
|
||||||
|
print("mqtt connect")
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Main
|
||||||
|
#
|
||||||
|
|
||||||
|
print("van-gps-owntracks start", time.asctime(), debug, os.getpid())
|
||||||
|
|
||||||
|
signal.signal(signal.SIGHUP, handler)
|
||||||
|
|
||||||
|
if version == '5':
|
||||||
|
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
|
||||||
|
client_id=mq_id,
|
||||||
|
transport=mytransport,
|
||||||
|
protocol=mqtt.MQTTv5)
|
||||||
|
if version == '3':
|
||||||
|
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
|
||||||
|
client_id=mq_id,
|
||||||
|
transport=mytransport,
|
||||||
|
protocol=mqtt.MQTTv311,
|
||||||
|
clean_session=True)
|
||||||
|
|
||||||
|
client.username_pw_set(mq_user, mq_pw)
|
||||||
|
client.on_disconnect = on_disconnect
|
||||||
|
|
||||||
|
try:
|
||||||
|
connect()
|
||||||
|
except socket.gaierror as e:
|
||||||
|
print("could not connect mqtt, %s" % e)
|
||||||
|
sys.exit(1)
|
||||||
|
client.loop_start()
|
||||||
|
|
||||||
|
pubproperties = Properties(PacketTypes.PUBLISH)
|
||||||
|
pubproperties.MessageExpiryInterval = keep_alive # in seconds; MQTT5 only
|
||||||
|
|
||||||
|
session = gpsd.gps(host=gpsd_host)
|
||||||
|
session.stream(gpsd.WATCH_ENABLE)
|
||||||
|
lastmsg = {'tst': 0, 'tid': tid, 'lat': 0, 'lon': 0}
|
||||||
|
while session.read() == 0:
|
||||||
|
result = session.data
|
||||||
|
if not result or result.get('class') != 'TPV':
|
||||||
|
continue
|
||||||
|
msg = {'_type': 'location', 'tid': tid, 't': 'p'}
|
||||||
|
|
||||||
|
if result.get('mode', 0) <= 1:
|
||||||
|
continue
|
||||||
|
if not ('lat' in result and 'lon' in result):
|
||||||
|
continue
|
||||||
|
msg['lat'] = result['lat']
|
||||||
|
msg['lon'] = result['lon']
|
||||||
|
if 'alt' in result:
|
||||||
|
msg['alt'] = int(result['alt'])
|
||||||
|
if 'speed' in result:
|
||||||
|
msg['vel'] = int(result['speed']) * 3.6
|
||||||
|
if 'eph' in result:
|
||||||
|
msg['acc'] = int(result['eph'])
|
||||||
|
if 'track' in result:
|
||||||
|
msg['cog'] = int(result['track'])
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if 'time' not in result:
|
||||||
|
continue
|
||||||
|
msg['tst'] = int(datetime.fromisoformat(result['time']).timestamp())
|
||||||
|
jmsg = json.dumps(msg)
|
||||||
|
if lastmsg != msg:
|
||||||
|
gc = great_circle(lastmsg['lat'], lastmsg['lon'], msg['lat'], msg['lon'])
|
||||||
|
if debug:
|
||||||
|
print("\rgc is ", gc, ' age is ', msg['tst'] - lastmsg['tst'], " ", end="")
|
||||||
|
if msg['tst'] - lastmsg['tst'] >= idle_delay or gc > distance_move_trigger:
|
||||||
|
if debug:
|
||||||
|
print('* ', gc, jmsg)
|
||||||
|
print('* ', mytopic, pubproperties)
|
||||||
|
rc = client.publish(mytopic, jmsg, qos=1, retain=True, properties=pubproperties)
|
||||||
|
if debug:
|
||||||
|
print("* client.publish rc=", rc)
|
||||||
|
lastmsg = msg
|
||||||
|
|
||||||
|
print("gpsd stream ended", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Publish gpsd fixes to OwnTracks via MQTT
|
||||||
|
After=network-online.target gpsd.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/local/sbin/van-gps-owntracks
|
||||||
|
# The script exits on MQTT disconnect / DNS failure by design; a fresh start
|
||||||
|
# is the reconnect. 15s so a dead WAN doesn't turn this into a hot loop.
|
||||||
|
Restart=always
|
||||||
|
RestartSec=15
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""The Carefree Connects BT12 awning integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import Platform
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import Bt12Coordinator
|
||||||
|
|
||||||
|
PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT, Platform.SENSOR]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
coordinator = Bt12Coordinator(hass, entry.data["address"])
|
||||||
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
if unload_ok:
|
||||||
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
return unload_ok
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Config flow for the Carefree Connects BT12 awning integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components.bluetooth import (
|
||||||
|
BluetoothServiceInfoBleak,
|
||||||
|
async_discovered_service_info,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigFlow
|
||||||
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
|
class Bt12ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for a Carefree BT12 awning controller."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._discovery_info: BluetoothServiceInfoBleak | None = None
|
||||||
|
self._discovered: dict[str, str] = {}
|
||||||
|
|
||||||
|
async def async_step_bluetooth(
|
||||||
|
self, discovery_info: BluetoothServiceInfoBleak
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle a discovered BT12 advertisement (from any Bluetooth source)."""
|
||||||
|
await self.async_set_unique_id(discovery_info.address)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
self._discovery_info = discovery_info
|
||||||
|
self.context["title_placeholders"] = {"name": discovery_info.name}
|
||||||
|
return await self.async_step_bluetooth_confirm()
|
||||||
|
|
||||||
|
async def async_step_bluetooth_confirm(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
assert self._discovery_info is not None
|
||||||
|
if user_input is not None:
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=self._discovery_info.name,
|
||||||
|
data={
|
||||||
|
"address": self._discovery_info.address,
|
||||||
|
"name": self._discovery_info.name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="bluetooth_confirm",
|
||||||
|
description_placeholders={"name": self._discovery_info.name},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Manual entry, plus a dropdown of any BT12 already seen advertising."""
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
if user_input is not None:
|
||||||
|
address = user_input["address"]
|
||||||
|
await self.async_set_unique_id(address, raise_on_progress=False)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
name = self._discovered.get(address, "BT12 Awning")
|
||||||
|
return self.async_create_entry(title=name, data={"address": address, "name": name})
|
||||||
|
|
||||||
|
current_addresses = self._async_current_ids()
|
||||||
|
for info in async_discovered_service_info(self.hass, connectable=True):
|
||||||
|
if info.address in current_addresses:
|
||||||
|
continue
|
||||||
|
if info.name == "BT12":
|
||||||
|
self._discovered[info.address] = info.name
|
||||||
|
|
||||||
|
if not self._discovered:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=vol.Schema({vol.Required("address"): str}),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=vol.Schema({vol.Required("address"): vol.In(self._discovered)}),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Constants for the Carefree Connects BT12 awning integration."""
|
||||||
|
|
||||||
|
DOMAIN = "carefree_bt12"
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""On-demand BLE command dispatch to one Carefree BT12 awning controller.
|
||||||
|
|
||||||
|
Unlike the li3 battery, this device doesn't stream continuously -- the
|
||||||
|
official app connects, sends one command, and disconnects (or lingers
|
||||||
|
briefly on generic housekeeping unrelated to any command). We mirror the
|
||||||
|
"connect, act, disconnect" shape rather than holding a persistent connection.
|
||||||
|
|
||||||
|
Commands were reverse-engineered 2026-08-24 from Bluetooth HCI snoop captures
|
||||||
|
(adb bugreport) of the official "Carefree Connects (BT12)" Android app,
|
||||||
|
confirmed across three independent captures including two isolated single-
|
||||||
|
action captures. See vanlink project memory (carefree-bt12-*) for the full
|
||||||
|
methodology and raw evidence.
|
||||||
|
|
||||||
|
Both GATT characteristics live under service 02060001-50e1-405f-bab0-
|
||||||
|
6bb582b4d96e. All four known commands are Write Command (no response) to
|
||||||
|
02060002; 02060003 is the paired notify characteristic. There is a second,
|
||||||
|
unrelated write-only characteristic (71dc0002-9247-11e7-abc4-cec278b6b50a,
|
||||||
|
also the advertised service UUID) that the app never touched in any capture
|
||||||
|
-- not used here.
|
||||||
|
|
||||||
|
The notify channel is NOT decoded yet. We still subscribe and capture
|
||||||
|
whatever comes back (surfaced via a diagnostic sensor) purely to build up
|
||||||
|
data for that follow-on reverse-engineering effort -- don't assume the
|
||||||
|
values there mean anything yet.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from bleak import BleakClient
|
||||||
|
from bleak.exc import BleakError
|
||||||
|
from bleak_retry_connector import establish_connection
|
||||||
|
|
||||||
|
from homeassistant.components import bluetooth
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
WRITE_CHAR_UUID = "02060002-50e1-405f-bab0-6bb582b4d96e"
|
||||||
|
NOTIFY_CHAR_UUID = "02060003-50e1-405f-bab0-6bb582b4d96e"
|
||||||
|
|
||||||
|
# Confirmed against three independent BLE HCI snoop captures. The device
|
||||||
|
# toggles motor state internally -- there is no separate "stop" byte,
|
||||||
|
# re-sending the same direction while it's moving is what stops it (matches
|
||||||
|
# the app's own UI, which has no Stop button either).
|
||||||
|
CMD_EXTEND = bytes.fromhex("80050101ffff")
|
||||||
|
CMD_RETRACT = bytes.fromhex("80050102ffff")
|
||||||
|
CMD_LIGHT_ON = bytes.fromhex("801a03030019ffff")
|
||||||
|
CMD_LIGHT_OFF = bytes.fromhex("801a03030001ffff")
|
||||||
|
|
||||||
|
NOTIFY_LISTEN_S = 3
|
||||||
|
MAX_NOTIFICATIONS_KEPT = 20
|
||||||
|
|
||||||
|
|
||||||
|
class Bt12Coordinator:
|
||||||
|
"""Owns on-demand BLE command dispatch and fans out state to entities."""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, address: str) -> None:
|
||||||
|
self.hass = hass
|
||||||
|
self.address = address
|
||||||
|
self.moving_direction: str | None = None # "extend" | "retract" | None
|
||||||
|
self.light_on: bool | None = None
|
||||||
|
self.last_notifications: list[str] = [] # hex strings, most recent last
|
||||||
|
self._listeners: list[Callable[[], None]] = []
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_add_listener(self, update_callback: Callable[[], None]) -> Callable[[], None]:
|
||||||
|
self._listeners.append(update_callback)
|
||||||
|
|
||||||
|
def remove_listener() -> None:
|
||||||
|
self._listeners.remove(update_callback)
|
||||||
|
|
||||||
|
return remove_listener
|
||||||
|
|
||||||
|
def _notify_listeners(self) -> None:
|
||||||
|
for update_callback in list(self._listeners):
|
||||||
|
update_callback()
|
||||||
|
|
||||||
|
async def async_send_command(self, payload: bytes) -> None:
|
||||||
|
"""Connect, write one command, listen briefly for notify replies, disconnect."""
|
||||||
|
async with self._lock:
|
||||||
|
ble_device = bluetooth.async_ble_device_from_address(
|
||||||
|
self.hass, self.address, connectable=True
|
||||||
|
)
|
||||||
|
if ble_device is None:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
f"BT12 {self.address} not currently visible to any Bluetooth source"
|
||||||
|
)
|
||||||
|
|
||||||
|
def notify_handler(_sender, data: bytearray) -> None:
|
||||||
|
hex_val = data.hex()
|
||||||
|
_LOGGER.debug("BT12 %s notify: %s", self.address, hex_val)
|
||||||
|
self.last_notifications.append(hex_val)
|
||||||
|
del self.last_notifications[:-MAX_NOTIFICATIONS_KEPT]
|
||||||
|
self._notify_listeners()
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = await establish_connection(BleakClient, ble_device, ble_device.address)
|
||||||
|
except (BleakError, EOFError, TimeoutError) as err:
|
||||||
|
raise HomeAssistantError(f"BT12 {self.address} connect failed: {err}") from err
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.start_notify(NOTIFY_CHAR_UUID, notify_handler)
|
||||||
|
await client.write_gatt_char(WRITE_CHAR_UUID, payload, response=False)
|
||||||
|
await asyncio.sleep(NOTIFY_LISTEN_S)
|
||||||
|
finally:
|
||||||
|
if client.is_connected:
|
||||||
|
await client.disconnect()
|
||||||
|
|
||||||
|
async def async_extend(self) -> None:
|
||||||
|
await self.async_send_command(CMD_EXTEND)
|
||||||
|
self.moving_direction = None if self.moving_direction == "extend" else "extend"
|
||||||
|
self._notify_listeners()
|
||||||
|
|
||||||
|
async def async_retract(self) -> None:
|
||||||
|
await self.async_send_command(CMD_RETRACT)
|
||||||
|
self.moving_direction = None if self.moving_direction == "retract" else "retract"
|
||||||
|
self._notify_listeners()
|
||||||
|
|
||||||
|
async def async_stop(self) -> None:
|
||||||
|
"""No dedicated stop byte -- resend whichever direction is currently moving."""
|
||||||
|
if self.moving_direction == "extend":
|
||||||
|
await self.async_send_command(CMD_EXTEND)
|
||||||
|
elif self.moving_direction == "retract":
|
||||||
|
await self.async_send_command(CMD_RETRACT)
|
||||||
|
self.moving_direction = None
|
||||||
|
self._notify_listeners()
|
||||||
|
|
||||||
|
async def async_light_on(self) -> None:
|
||||||
|
await self.async_send_command(CMD_LIGHT_ON)
|
||||||
|
self.light_on = True
|
||||||
|
self._notify_listeners()
|
||||||
|
|
||||||
|
async def async_light_off(self) -> None:
|
||||||
|
await self.async_send_command(CMD_LIGHT_OFF)
|
||||||
|
self.light_on = False
|
||||||
|
self._notify_listeners()
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Cover platform for the Carefree BT12 awning."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverEntityFeature
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import Bt12Coordinator
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
|
) -> None:
|
||||||
|
coordinator: Bt12Coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, entry.data["address"])},
|
||||||
|
name=entry.data.get("name", "BT12 Awning"),
|
||||||
|
manufacturer="Carefree of Colorado",
|
||||||
|
model="BT12",
|
||||||
|
)
|
||||||
|
async_add_entities([Bt12Cover(coordinator, device_info)])
|
||||||
|
|
||||||
|
|
||||||
|
class Bt12Cover(CoverEntity):
|
||||||
|
"""The awning. Open = extended, close = retracted.
|
||||||
|
|
||||||
|
Blind control only -- no position/status feedback decoded yet, so state
|
||||||
|
here is entirely assumed/optimistic (see coordinator.py's
|
||||||
|
last_notifications for the raw, still-undecoded notify traffic). There's
|
||||||
|
also no dedicated stop command on this device: re-sending whichever
|
||||||
|
direction is currently moving is what stops it, so stop_cover just
|
||||||
|
replays the last-commanded direction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_name = None
|
||||||
|
_attr_assumed_state = True
|
||||||
|
_attr_should_poll = False
|
||||||
|
_attr_device_class = CoverDeviceClass.AWNING
|
||||||
|
_attr_supported_features = (
|
||||||
|
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, coordinator: Bt12Coordinator, device_info: DeviceInfo) -> None:
|
||||||
|
self._coordinator = coordinator
|
||||||
|
self._attr_unique_id = f"{coordinator.address}_awning"
|
||||||
|
self._attr_device_info = device_info
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_closed(self) -> bool | None:
|
||||||
|
return None # unknown -- no position feedback decoded yet
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_opening(self) -> bool:
|
||||||
|
return self._coordinator.moving_direction == "extend"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_closing(self) -> bool:
|
||||||
|
return self._coordinator.moving_direction == "retract"
|
||||||
|
|
||||||
|
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||||
|
await self._coordinator.async_extend()
|
||||||
|
|
||||||
|
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||||
|
await self._coordinator.async_retract()
|
||||||
|
|
||||||
|
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||||
|
await self._coordinator.async_stop()
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
self.async_on_remove(self._coordinator.async_add_listener(self._handle_update))
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_update(self) -> None:
|
||||||
|
self.async_write_ha_state()
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Light platform for the Carefree BT12 awning's built-in LED strip."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.light import ColorMode, LightEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import Bt12Coordinator
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
|
) -> None:
|
||||||
|
coordinator: Bt12Coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, entry.data["address"])},
|
||||||
|
name=entry.data.get("name", "BT12 Awning"),
|
||||||
|
manufacturer="Carefree of Colorado",
|
||||||
|
model="BT12",
|
||||||
|
)
|
||||||
|
async_add_entities([Bt12Light(coordinator, device_info)])
|
||||||
|
|
||||||
|
|
||||||
|
class Bt12Light(LightEntity):
|
||||||
|
"""The awning's LED strip.
|
||||||
|
|
||||||
|
"On" always sends a fixed level byte (0x19) -- no brightness-setting
|
||||||
|
command has been decoded yet (the app's slider wasn't isolated in
|
||||||
|
reverse-engineering), so this is on/off only for now.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_name = "Light"
|
||||||
|
_attr_assumed_state = True
|
||||||
|
_attr_should_poll = False
|
||||||
|
_attr_color_mode = ColorMode.ONOFF
|
||||||
|
_attr_supported_color_modes = {ColorMode.ONOFF}
|
||||||
|
|
||||||
|
def __init__(self, coordinator: Bt12Coordinator, device_info: DeviceInfo) -> None:
|
||||||
|
self._coordinator = coordinator
|
||||||
|
self._attr_unique_id = f"{coordinator.address}_light"
|
||||||
|
self._attr_device_info = device_info
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool | None:
|
||||||
|
return self._coordinator.light_on
|
||||||
|
|
||||||
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
|
await self._coordinator.async_light_on()
|
||||||
|
|
||||||
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
|
await self._coordinator.async_light_off()
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
self.async_on_remove(self._coordinator.async_add_listener(self._handle_update))
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_update(self) -> None:
|
||||||
|
self.async_write_ha_state()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"domain": "carefree_bt12",
|
||||||
|
"name": "Carefree Connects BT12 Awning",
|
||||||
|
"codeowners": ["@aew"],
|
||||||
|
"config_flow": true,
|
||||||
|
"dependencies": ["bluetooth"],
|
||||||
|
"documentation": "https://github.com/wrede/vanlink",
|
||||||
|
"iot_class": "local_push",
|
||||||
|
"requirements": ["bleak-retry-connector>=3.0.0"],
|
||||||
|
"version": "0.1.0",
|
||||||
|
"bluetooth": [
|
||||||
|
{
|
||||||
|
"local_name": "BT12",
|
||||||
|
"connectable": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Diagnostic sensor exposing raw BT12 notify replies, undecoded.
|
||||||
|
|
||||||
|
The notify channel (02060003) isn't reverse-engineered yet. This entity just
|
||||||
|
surfaces whatever comes back after each command, in order to build up real
|
||||||
|
data for that follow-on work -- don't assume the values mean anything yet.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.components.sensor import SensorEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import Bt12Coordinator
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
|
) -> None:
|
||||||
|
coordinator: Bt12Coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, entry.data["address"])},
|
||||||
|
name=entry.data.get("name", "BT12 Awning"),
|
||||||
|
manufacturer="Carefree of Colorado",
|
||||||
|
model="BT12",
|
||||||
|
)
|
||||||
|
async_add_entities([Bt12LastNotification(coordinator, device_info)])
|
||||||
|
|
||||||
|
|
||||||
|
class Bt12LastNotification(SensorEntity):
|
||||||
|
"""Raw hex of the most recent GATT notification."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_name = "Last Notification"
|
||||||
|
_attr_should_poll = False
|
||||||
|
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||||
|
|
||||||
|
def __init__(self, coordinator: Bt12Coordinator, device_info: DeviceInfo) -> None:
|
||||||
|
self._coordinator = coordinator
|
||||||
|
self._attr_unique_id = f"{coordinator.address}_last_notification"
|
||||||
|
self._attr_device_info = device_info
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> str | None:
|
||||||
|
if not self._coordinator.last_notifications:
|
||||||
|
return None
|
||||||
|
return self._coordinator.last_notifications[-1]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def extra_state_attributes(self) -> dict:
|
||||||
|
return {"recent": self._coordinator.last_notifications}
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
self.async_on_remove(self._coordinator.async_add_listener(self._handle_update))
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_update(self) -> None:
|
||||||
|
self.async_write_ha_state()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"bluetooth_confirm": {
|
||||||
|
"description": "Add the awning controller `{name}`?"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"address": "Device"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "This awning controller is already configured"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"bluetooth_confirm": {
|
||||||
|
"description": "Add the awning controller `{name}`?"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"address": "Device"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "This awning controller is already configured"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""The Lithionics Li3 BMS integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import Platform
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import Li3Coordinator
|
||||||
|
|
||||||
|
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
coordinator = Li3Coordinator(hass, entry.data["address"])
|
||||||
|
await coordinator.async_start()
|
||||||
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
if unload_ok:
|
||||||
|
coordinator: Li3Coordinator = hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
await coordinator.async_stop()
|
||||||
|
return unload_ok
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Config flow for the Lithionics Li3 BMS integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components.bluetooth import (
|
||||||
|
BluetoothServiceInfoBleak,
|
||||||
|
async_discovered_service_info,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigFlow
|
||||||
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
|
class Li3ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for a Lithionics Li3 BMS."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._discovery_info: BluetoothServiceInfoBleak | None = None
|
||||||
|
self._discovered: dict[str, str] = {}
|
||||||
|
|
||||||
|
async def async_step_bluetooth(
|
||||||
|
self, discovery_info: BluetoothServiceInfoBleak
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle a discovered Li3 advertisement (from any Bluetooth source)."""
|
||||||
|
await self.async_set_unique_id(discovery_info.address)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
self._discovery_info = discovery_info
|
||||||
|
self.context["title_placeholders"] = {"name": discovery_info.name}
|
||||||
|
return await self.async_step_bluetooth_confirm()
|
||||||
|
|
||||||
|
async def async_step_bluetooth_confirm(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
assert self._discovery_info is not None
|
||||||
|
if user_input is not None:
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=self._discovery_info.name,
|
||||||
|
data={
|
||||||
|
"address": self._discovery_info.address,
|
||||||
|
"name": self._discovery_info.name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="bluetooth_confirm",
|
||||||
|
description_placeholders={"name": self._discovery_info.name},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Manual entry, plus a dropdown of any Li3 already seen advertising."""
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
if user_input is not None:
|
||||||
|
address = user_input["address"]
|
||||||
|
await self.async_set_unique_id(address, raise_on_progress=False)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
name = self._discovered.get(address, "Li3 Battery")
|
||||||
|
return self.async_create_entry(title=name, data={"address": address, "name": name})
|
||||||
|
|
||||||
|
current_addresses = self._async_current_ids()
|
||||||
|
for info in async_discovered_service_info(self.hass, connectable=True):
|
||||||
|
if info.address in current_addresses:
|
||||||
|
continue
|
||||||
|
if info.name and info.name.startswith("Li3-"):
|
||||||
|
self._discovered[info.address] = info.name
|
||||||
|
|
||||||
|
if not self._discovered:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=vol.Schema({vol.Required("address"): str}),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=vol.Schema({vol.Required("address"): vol.In(self._discovered)}),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Constants for the Lithionics Li3 BMS integration."""
|
||||||
|
|
||||||
|
DOMAIN = "li3_battery"
|
||||||
|
|
||||||
|
# (key, name, unit, device_class, display_precision) -- precision None means a
|
||||||
|
# non-numeric value (hex code, version string, serial number): published as-is,
|
||||||
|
# no rounding, no state_class. Mirrors van-li3-battery's SENSORS list.
|
||||||
|
SENSORS = [
|
||||||
|
("voltage", "Pack Voltage", "V", "voltage", 2),
|
||||||
|
("cell1_voltage", "Cell 1 Voltage", "V", "voltage", 2),
|
||||||
|
("cell2_voltage", "Cell 2 Voltage", "V", "voltage", 2),
|
||||||
|
("cell3_voltage", "Cell 3 Voltage", "V", "voltage", 2),
|
||||||
|
("cell4_voltage", "Cell 4 Voltage", "V", "voltage", 2),
|
||||||
|
("current", "Current", "A", "current", 2),
|
||||||
|
("soc", "State of Charge", "%", "battery", 0),
|
||||||
|
("bms_temperature", "BMS Temperature", "°F", "temperature", 1),
|
||||||
|
("battery_temperature", "Battery Temperature", "°F", "temperature", 1),
|
||||||
|
("remaining_capacity", "Remaining Capacity", "Ah", None, 0),
|
||||||
|
("remaining_time", "Remaining Time", "min", "duration", 0),
|
||||||
|
("can_charger_voltage", "CAN Charger Voltage", "V", "voltage", 1),
|
||||||
|
("can_charger_current", "CAN Charger Current", "A", "current", 1),
|
||||||
|
("can_charger_status", "CAN Charger Status", None, None, None),
|
||||||
|
("can_status", "CAN Status", None, None, None),
|
||||||
|
]
|
||||||
|
|
||||||
|
# From the one-time "$info" response -- published as diagnostic entities.
|
||||||
|
INFO_SENSORS = [
|
||||||
|
("total_consumed", "Lifetime Consumed", "Ah", None, 0),
|
||||||
|
("last_fault_code", "Last Fault Code", None, None, None),
|
||||||
|
("highest_recorded_temp", "Highest Recorded Temperature", "°F", "temperature", 0),
|
||||||
|
("lowest_recorded_temp", "Lowest Recorded Temperature", "°F", "temperature", 0),
|
||||||
|
("firmware_version", "Firmware Version", None, None, None),
|
||||||
|
("aging_factor_temp", "Aging Factor (Temp)", None, None, 0),
|
||||||
|
("aging_factor_soc", "Aging Factor (SOC)", None, None, 0),
|
||||||
|
("serial_number", "Serial Number", None, None, None),
|
||||||
|
]
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""Persistent BLE connection to one Li3 BMS.
|
||||||
|
|
||||||
|
Unlike a typical HA polling coordinator, the Li3 streams telemetry
|
||||||
|
continuously once connected, so this holds a long-lived connection instead of
|
||||||
|
connect/read/disconnect cycles. bluetooth.async_ble_device_from_address picks
|
||||||
|
whichever known Bluetooth source (the host's local adapter, or any connected
|
||||||
|
ESPHome Bluetooth proxy) currently has the device, so a single weak vantage
|
||||||
|
point no longer has to carry the whole link -- see vanlink's li3-battery
|
||||||
|
project memory for why that matters here.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from bleak import BleakClient
|
||||||
|
from bleak.exc import BleakError
|
||||||
|
from bleak_retry_connector import establish_connection
|
||||||
|
|
||||||
|
from homeassistant.components import bluetooth
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
|
||||||
|
from .parser import (
|
||||||
|
FFE1_CHAR_UUID,
|
||||||
|
build_state_payload,
|
||||||
|
parse_info_line,
|
||||||
|
parse_line,
|
||||||
|
parse_trace_line,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
RETRY_DELAY_S = 8
|
||||||
|
|
||||||
|
|
||||||
|
class Li3Coordinator:
|
||||||
|
"""Owns the BLE connection to one Li3 BMS and fans out updates to entities."""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, address: str) -> None:
|
||||||
|
self.hass = hass
|
||||||
|
self.address = address
|
||||||
|
self.data: dict = {}
|
||||||
|
self.info: dict = {}
|
||||||
|
self.available = False
|
||||||
|
self._listeners: list[Callable[[], None]] = []
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
self._stopping = False
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_add_listener(self, update_callback: Callable[[], None]) -> Callable[[], None]:
|
||||||
|
self._listeners.append(update_callback)
|
||||||
|
|
||||||
|
def remove_listener() -> None:
|
||||||
|
self._listeners.remove(update_callback)
|
||||||
|
|
||||||
|
return remove_listener
|
||||||
|
|
||||||
|
def _notify_listeners(self) -> None:
|
||||||
|
for update_callback in list(self._listeners):
|
||||||
|
update_callback()
|
||||||
|
|
||||||
|
async def async_start(self) -> None:
|
||||||
|
self._stopping = False
|
||||||
|
self._task = self.hass.loop.create_task(self._run())
|
||||||
|
|
||||||
|
async def async_stop(self) -> None:
|
||||||
|
self._stopping = True
|
||||||
|
if self._task is not None:
|
||||||
|
self._task.cancel()
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _run(self) -> None:
|
||||||
|
while not self._stopping:
|
||||||
|
ble_device = bluetooth.async_ble_device_from_address(
|
||||||
|
self.hass, self.address, connectable=True
|
||||||
|
)
|
||||||
|
if ble_device is None:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Li3 %s not currently visible to any Bluetooth source", self.address
|
||||||
|
)
|
||||||
|
await asyncio.sleep(RETRY_DELAY_S)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await self._stream(ble_device)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except (BleakError, EOFError, TimeoutError) as err:
|
||||||
|
_LOGGER.debug("Li3 %s connection error: %s", self.address, err)
|
||||||
|
self.available = False
|
||||||
|
self._notify_listeners()
|
||||||
|
await asyncio.sleep(RETRY_DELAY_S)
|
||||||
|
|
||||||
|
async def _stream(self, ble_device) -> None:
|
||||||
|
buf = ""
|
||||||
|
info_published = False
|
||||||
|
latest_cs: dict = {}
|
||||||
|
latest_trace: dict = {}
|
||||||
|
|
||||||
|
def notify_handler(_sender, data: bytearray) -> None:
|
||||||
|
nonlocal buf, info_published
|
||||||
|
buf += data.decode("utf-8", errors="replace")
|
||||||
|
while "\r\n" in buf:
|
||||||
|
line, buf = buf.split("\r\n", 1)
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if line.startswith("&"):
|
||||||
|
trace = parse_trace_line(line)
|
||||||
|
if trace:
|
||||||
|
latest_trace.update(trace)
|
||||||
|
if latest_cs:
|
||||||
|
self.data = build_state_payload(latest_cs, latest_trace)
|
||||||
|
self._notify_listeners()
|
||||||
|
elif line.startswith("$"):
|
||||||
|
if not info_published:
|
||||||
|
info = parse_info_line(line)
|
||||||
|
if info:
|
||||||
|
self.info = info
|
||||||
|
info_published = True
|
||||||
|
self._notify_listeners()
|
||||||
|
else:
|
||||||
|
reading = parse_line(line)
|
||||||
|
if reading:
|
||||||
|
latest_cs.update(reading)
|
||||||
|
self.data = build_state_payload(latest_cs, latest_trace)
|
||||||
|
self._notify_listeners()
|
||||||
|
|
||||||
|
_LOGGER.debug("Connecting to Li3 %s", self.address)
|
||||||
|
client = await establish_connection(BleakClient, ble_device, ble_device.address)
|
||||||
|
try:
|
||||||
|
self.available = True
|
||||||
|
self._notify_listeners()
|
||||||
|
await client.start_notify(FFE1_CHAR_UUID, notify_handler)
|
||||||
|
await client.write_gatt_char(FFE1_CHAR_UUID, b"$traceon\r\n", response=False)
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
await client.write_gatt_char(FFE1_CHAR_UUID, b"$info\r\n", response=False)
|
||||||
|
|
||||||
|
while client.is_connected and not self._stopping:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
finally:
|
||||||
|
self.available = False
|
||||||
|
if client.is_connected:
|
||||||
|
await client.disconnect()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"domain": "li3_battery",
|
||||||
|
"name": "Lithionics Li3 BMS",
|
||||||
|
"codeowners": ["@aew"],
|
||||||
|
"config_flow": true,
|
||||||
|
"dependencies": ["bluetooth"],
|
||||||
|
"documentation": "https://github.com/wrede/vanlink",
|
||||||
|
"iot_class": "local_push",
|
||||||
|
"requirements": ["bleak-retry-connector>=3.0.0"],
|
||||||
|
"version": "0.1.0",
|
||||||
|
"bluetooth": [
|
||||||
|
{
|
||||||
|
"local_name": "Li3-*",
|
||||||
|
"connectable": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Lithionics Li3 BMS wire protocol.
|
||||||
|
|
||||||
|
Ported verbatim from vanlink's van-li3-battery (li3/van-li3-battery in the
|
||||||
|
vanlink repo). 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.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
FFE1_CHAR_UUID = "0000ffe1-0000-1000-8000-00805f9b34fb"
|
||||||
|
|
||||||
|
# The "status" field (main Cs telemetry line, and identically-coded but
|
||||||
|
# cumulative/latched "last_fault_code" from $info) is a 24-bit flag mask. Bit
|
||||||
|
# meanings pulled from the app's own "advanced" string-array resource
|
||||||
|
# (com.lithionics.bms base.apk, array/advanced -- dumped with aapt since the
|
||||||
|
# app's Kotlin StatusCodeTable class references stale/wrong resource IDs and
|
||||||
|
# can't be trusted). Index 0 = bit 23 (MSB) down to index 23 = bit 0 (LSB);
|
||||||
|
# blank entries are unused bits. Applied to "status" only -- last_fault_code
|
||||||
|
# is a lifetime latch (many bits accumulate over time) and isn't meaningfully
|
||||||
|
# summarized the same way.
|
||||||
|
STATUS_FLAGS = [
|
||||||
|
"", "BMS Temp High", "Overcurrent State", "Charge OFF", "Aux Input State",
|
||||||
|
"Cell Temp Low", "Cell Temp High", "AGSR State", "Temp Sensor Fault",
|
||||||
|
"CAN Charger Fault", "CAN Charger Present", "AC Power Present",
|
||||||
|
"Contactor Flutter", "Pre-Charge Fault", "Contactor Fault",
|
||||||
|
"Contactor State", "Power Off State", "Battery Protection", "Low Voltage",
|
||||||
|
"Reserve Range", "OptoLoop Open", "NeverDie Reserve", "Charge Detected",
|
||||||
|
"High Voltage",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def decode_status(hex_code):
|
||||||
|
try:
|
||||||
|
value = int(hex_code, 16)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return hex_code
|
||||||
|
active = [
|
||||||
|
label for i, label in enumerate(STATUS_FLAGS)
|
||||||
|
if label and (value >> (23 - i)) & 1
|
||||||
|
]
|
||||||
|
return ", ".join(active) if active else "OK"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_line(line):
|
||||||
|
"""Cs-series main telemetry line (no prefix character)."""
|
||||||
|
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 parse_trace_line(line):
|
||||||
|
"""'&' trace line: &,batteryId,remaining,remainingTime,canChargerVoltage,
|
||||||
|
canChargerCurrent,canChargerStatus,canStatus"""
|
||||||
|
parts = line.split(",")
|
||||||
|
try:
|
||||||
|
return {
|
||||||
|
"remaining_capacity": int(parts[2]),
|
||||||
|
"remaining_time": int(parts[3]),
|
||||||
|
"can_charger_voltage": round(int(parts[4]) * 0.1, 1),
|
||||||
|
"can_charger_current": round(int(parts[5]) * 0.1, 1),
|
||||||
|
"can_charger_status": parts[6],
|
||||||
|
"can_status": parts[7],
|
||||||
|
}
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_info_line(line):
|
||||||
|
"""'$' info line (response to $info): $,totalConsumed,lastFaultCode,
|
||||||
|
highestRecordedTemp,lowestRecordedTemp,firmwareVersion,agingFactorTemp,
|
||||||
|
agingFactorSoc,serialNumber"""
|
||||||
|
parts = line.split(",")
|
||||||
|
try:
|
||||||
|
return {
|
||||||
|
"total_consumed": int(parts[1]),
|
||||||
|
"last_fault_code": parts[2],
|
||||||
|
"highest_recorded_temp": int(parts[3]),
|
||||||
|
"lowest_recorded_temp": int(parts[4]),
|
||||||
|
"firmware_version": parts[5],
|
||||||
|
"aging_factor_temp": int(parts[6]),
|
||||||
|
"aging_factor_soc": int(parts[7]),
|
||||||
|
"serial_number": parts[8],
|
||||||
|
}
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_state_payload(cs_reading, trace_fields):
|
||||||
|
# A non-zero status means the rest of the Cs line's fields are unreliable
|
||||||
|
# (observed 2026-08-18: status '69' alongside e.g. current=341, soc=340,
|
||||||
|
# cell4_voltage=34013.63) -- publish only the status in that case. Trace
|
||||||
|
# ("&" line) fields come from a separate message and are published
|
||||||
|
# regardless.
|
||||||
|
status = cs_reading["status"]
|
||||||
|
if status != "000000":
|
||||||
|
payload = {"status": status}
|
||||||
|
else:
|
||||||
|
payload = {
|
||||||
|
"voltage": cs_reading["voltage"],
|
||||||
|
"cell1_voltage": cs_reading["cell1"],
|
||||||
|
"cell2_voltage": cs_reading["cell2"],
|
||||||
|
"cell3_voltage": cs_reading["cell3"],
|
||||||
|
"cell4_voltage": cs_reading["cell4"],
|
||||||
|
"current": cs_reading["current_a"],
|
||||||
|
"soc": cs_reading["soc_pct"],
|
||||||
|
"bms_temperature": cs_reading["bms_temp_f"],
|
||||||
|
"battery_temperature": cs_reading["batt_temp_f"],
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
payload["status_text"] = decode_status(status)
|
||||||
|
payload.update(trace_fields)
|
||||||
|
return payload
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Sensor platform for the Lithionics Li3 BMS integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .const import DOMAIN, INFO_SENSORS, SENSORS
|
||||||
|
from .coordinator import Li3Coordinator
|
||||||
|
|
||||||
|
_DEVICE_CLASS_MAP = {
|
||||||
|
"voltage": SensorDeviceClass.VOLTAGE,
|
||||||
|
"current": SensorDeviceClass.CURRENT,
|
||||||
|
"battery": SensorDeviceClass.BATTERY,
|
||||||
|
"temperature": SensorDeviceClass.TEMPERATURE,
|
||||||
|
"duration": SensorDeviceClass.DURATION,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
|
) -> None:
|
||||||
|
coordinator: Li3Coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, entry.data["address"])},
|
||||||
|
name=entry.data.get("name", "Li3 Battery"),
|
||||||
|
manufacturer="Lithionics",
|
||||||
|
model="Li3 BMS",
|
||||||
|
)
|
||||||
|
|
||||||
|
entities: list[SensorEntity] = [
|
||||||
|
Li3Sensor(coordinator, device_info, key, name, unit, device_class, precision, "data")
|
||||||
|
for key, name, unit, device_class, precision in SENSORS
|
||||||
|
]
|
||||||
|
entities.append(
|
||||||
|
Li3Sensor(coordinator, device_info, "status_text", "Status", None, None, None, "data")
|
||||||
|
)
|
||||||
|
entities.extend(
|
||||||
|
Li3Sensor(
|
||||||
|
coordinator, device_info, key, name, unit, device_class, precision, "info",
|
||||||
|
diagnostic=True,
|
||||||
|
)
|
||||||
|
for key, name, unit, device_class, precision in INFO_SENSORS
|
||||||
|
)
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
|
class Li3Sensor(SensorEntity):
|
||||||
|
"""A single field of the Li3 BMS, read live from the coordinator."""
|
||||||
|
|
||||||
|
_attr_should_poll = False
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: Li3Coordinator,
|
||||||
|
device_info: DeviceInfo,
|
||||||
|
key: str,
|
||||||
|
name: str,
|
||||||
|
unit: str | None,
|
||||||
|
device_class: str | None,
|
||||||
|
precision: int | None,
|
||||||
|
source: str,
|
||||||
|
diagnostic: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self._coordinator = coordinator
|
||||||
|
self._key = key
|
||||||
|
self._source = source
|
||||||
|
self._attr_name = name
|
||||||
|
self._attr_native_unit_of_measurement = unit
|
||||||
|
self._attr_device_class = _DEVICE_CLASS_MAP.get(device_class or "")
|
||||||
|
self._attr_unique_id = f"{coordinator.address}_{key}"
|
||||||
|
self._attr_device_info = device_info
|
||||||
|
if precision is not None:
|
||||||
|
self._attr_suggested_display_precision = precision
|
||||||
|
self._attr_state_class = SensorStateClass.MEASUREMENT
|
||||||
|
if diagnostic:
|
||||||
|
self._attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||||
|
|
||||||
|
def _current_source(self) -> dict:
|
||||||
|
return self._coordinator.data if self._source == "data" else self._coordinator.info
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
return self._key in self._current_source()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self):
|
||||||
|
return self._current_source().get(self._key)
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
self.async_on_remove(self._coordinator.async_add_listener(self._handle_update))
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_update(self) -> None:
|
||||||
|
self.async_write_ha_state()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"bluetooth_confirm": {
|
||||||
|
"description": "Add the Li3 battery `{name}`?"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"address": "Device"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "This battery is already configured"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"bluetooth_confirm": {
|
||||||
|
"description": "Add the Li3 battery `{name}`?"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"address": "Device"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "This battery is already configured"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# ESPHome dashboard as a Podman Quadlet, same pattern as homeassistant.container
|
||||||
|
# (this isn't Home Assistant Supervised, so there's no add-on store — a sibling
|
||||||
|
# container is the equivalent). Installed by deploy.sh to /etc/containers/systemd/;
|
||||||
|
# systemd generates esphome.service from it.
|
||||||
|
#
|
||||||
|
# Host networking: the dashboard binds :6052 directly (http://10.42.0.1:6052),
|
||||||
|
# and ESPHome's mDNS-based OTA discovery/flashing of already-provisioned nodes
|
||||||
|
# needs to see the LAN as the host does — a bridged network would need explicit
|
||||||
|
# port/mDNS forwarding for the same result.
|
||||||
|
[Unit]
|
||||||
|
Description=ESPHome dashboard (Podman container)
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Container]
|
||||||
|
Image=ghcr.io/esphome/esphome:stable
|
||||||
|
ContainerName=esphome
|
||||||
|
Network=host
|
||||||
|
Volume=/srv/esphome:/config
|
||||||
|
Environment=TZ=America/Toronto
|
||||||
|
# Initial flash of a new device needs USB serial access; OTA re-flashes of an
|
||||||
|
# already-provisioned node don't. Uncomment and set the real path to flash
|
||||||
|
# over USB (find it with `ls /dev/ttyUSB* /dev/ttyACM*` after plugging the
|
||||||
|
# device in):
|
||||||
|
#Volume=/dev/ttyUSB0:/dev/ttyUSB0
|
||||||
|
#AddCapability=SYS_ADMIN
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=always
|
||||||
|
# First start pulls the image; PlatformIO also downloads toolchains on a
|
||||||
|
# node's first compile — both want a working WAN, not just a fast one.
|
||||||
|
TimeoutStartSec=900
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Frigate NVR as a Podman Quadlet, same pattern as homeassistant.container /
|
||||||
|
# esphome.container. Installed by deploy.sh to /etc/containers/systemd/;
|
||||||
|
# systemd generates frigate.service from it. Note: "Privileged=true" is NOT a
|
||||||
|
# real Quadlet key — the generator silently skips the whole file if you use
|
||||||
|
# it (no frigate.service is generated at all, so `systemctl enable frigate`
|
||||||
|
# fails with "Unit frigate.service does not exist"). Use PodmanArgs=--privileged.
|
||||||
|
#
|
||||||
|
# Host networking: dashboard/API bind :5000, go2rtc webrtc on :8971.
|
||||||
|
# Config (/srv/frigate/config/config.yml) and recordings (/srv/frigate/storage)
|
||||||
|
# are host state, not shipped by deploy.sh — same as /srv/homeassistant.
|
||||||
|
[Unit]
|
||||||
|
Description=Frigate NVR (Podman container)
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Container]
|
||||||
|
Image=ghcr.io/blakeblackshear/frigate:stable
|
||||||
|
ContainerName=frigate
|
||||||
|
Network=host
|
||||||
|
Volume=/srv/frigate/config:/config
|
||||||
|
Volume=/srv/frigate/storage:/media/frigate
|
||||||
|
Volume=/etc/localtime:/etc/localtime:ro
|
||||||
|
Environment=TZ=America/Toronto
|
||||||
|
|
||||||
|
PublishPort=5000:5000
|
||||||
|
PublishPort=8971:8971
|
||||||
|
|
||||||
|
# FFmpeg video buffers need more than the 64m Podman default.
|
||||||
|
ShmSize=64m
|
||||||
|
|
||||||
|
# Privileged (not just AddCapability) for full /dev access to camera/GPU
|
||||||
|
# devices without hand-enumerating every /dev/videoN node.
|
||||||
|
PodmanArgs=--privileged
|
||||||
|
GroupAdd=keep-groups
|
||||||
|
AddCapability=CAP_PERFMON
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=always
|
||||||
|
# First start pulls the image; allow for a slow uplink.
|
||||||
|
TimeoutStartSec=900
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
<domain type='kvm'>
|
|
||||||
<name>ha_van</name>
|
|
||||||
<uuid>af014c94-de20-4f52-8d6b-438f16cd82e6</uuid>
|
|
||||||
<description>Home Assistant OS</description>
|
|
||||||
<memory unit='KiB'>4194304</memory>
|
|
||||||
<currentMemory unit='KiB'>4194304</currentMemory>
|
|
||||||
<vcpu placement='static'>2</vcpu>
|
|
||||||
<os firmware='efi'>
|
|
||||||
<type arch='x86_64' machine='pc-i440fx-noble-v2'>hvm</type>
|
|
||||||
<firmware>
|
|
||||||
<feature enabled='no' name='enrolled-keys'/>
|
|
||||||
<feature enabled='no' name='secure-boot'/>
|
|
||||||
</firmware>
|
|
||||||
<loader readonly='yes' type='pflash'>/usr/share/OVMF/OVMF_CODE_4M.fd</loader>
|
|
||||||
<nvram template='/usr/share/OVMF/OVMF_VARS_4M.fd'>/var/lib/libvirt/qemu/nvram/ha_van_VARS.fd</nvram>
|
|
||||||
<boot dev='hd'/>
|
|
||||||
</os>
|
|
||||||
<features>
|
|
||||||
<acpi/>
|
|
||||||
<apic/>
|
|
||||||
</features>
|
|
||||||
<cpu mode='host-passthrough' check='none' migratable='on'/>
|
|
||||||
<clock offset='utc'>
|
|
||||||
<timer name='rtc' tickpolicy='catchup'/>
|
|
||||||
<timer name='pit' tickpolicy='delay'/>
|
|
||||||
<timer name='hpet' present='no'/>
|
|
||||||
</clock>
|
|
||||||
<on_poweroff>destroy</on_poweroff>
|
|
||||||
<on_reboot>restart</on_reboot>
|
|
||||||
<on_crash>destroy</on_crash>
|
|
||||||
<pm>
|
|
||||||
<suspend-to-mem enabled='no'/>
|
|
||||||
<suspend-to-disk enabled='no'/>
|
|
||||||
</pm>
|
|
||||||
<devices>
|
|
||||||
<emulator>/usr/bin/qemu-system-x86_64</emulator>
|
|
||||||
<disk type='file' device='disk'>
|
|
||||||
<driver name='qemu' type='qcow2'/>
|
|
||||||
<source file='/var/lib/libvirt/images/haos_ova-17.3.qcow2'/>
|
|
||||||
<target dev='sda' bus='scsi'/>
|
|
||||||
<address type='drive' controller='0' bus='0' target='0' unit='0'/>
|
|
||||||
</disk>
|
|
||||||
<controller type='scsi' index='0' model='virtio-scsi'>
|
|
||||||
<address type='pci' domain='0x0000' bus='0x00' slot='0x03' function='0x0'/>
|
|
||||||
</controller>
|
|
||||||
<controller type='usb' index='0' model='ich9-ehci1'>
|
|
||||||
<address type='pci' domain='0x0000' bus='0x00' slot='0x04' function='0x7'/>
|
|
||||||
</controller>
|
|
||||||
<controller type='usb' index='0' model='ich9-uhci1'>
|
|
||||||
<master startport='0'/>
|
|
||||||
<address type='pci' domain='0x0000' bus='0x00' slot='0x04' function='0x0' multifunction='on'/>
|
|
||||||
</controller>
|
|
||||||
<controller type='usb' index='0' model='ich9-uhci2'>
|
|
||||||
<master startport='2'/>
|
|
||||||
<address type='pci' domain='0x0000' bus='0x00' slot='0x04' function='0x1'/>
|
|
||||||
</controller>
|
|
||||||
<controller type='usb' index='0' model='ich9-uhci3'>
|
|
||||||
<master startport='4'/>
|
|
||||||
<address type='pci' domain='0x0000' bus='0x00' slot='0x04' function='0x2'/>
|
|
||||||
</controller>
|
|
||||||
<controller type='pci' index='0' model='pci-root'/>
|
|
||||||
<interface type='bridge'>
|
|
||||||
<mac address='52:54:00:ad:0a:01'/>
|
|
||||||
<source bridge='br0'/>
|
|
||||||
<model type='virtio'/>
|
|
||||||
<address type='pci' domain='0x0000' bus='0x00' slot='0x02' function='0x0'/>
|
|
||||||
</interface>
|
|
||||||
<serial type='pty'>
|
|
||||||
<target type='isa-serial' port='0'>
|
|
||||||
<model name='isa-serial'/>
|
|
||||||
</target>
|
|
||||||
</serial>
|
|
||||||
<console type='pty'>
|
|
||||||
<target type='serial' port='0'/>
|
|
||||||
</console>
|
|
||||||
<input type='mouse' bus='ps2'/>
|
|
||||||
<input type='keyboard' bus='ps2'/>
|
|
||||||
<tpm model='tpm-crb'>
|
|
||||||
<backend type='emulator' version='2.0'/>
|
|
||||||
</tpm>
|
|
||||||
<audio id='1' type='none'/>
|
|
||||||
<memballoon model='virtio'>
|
|
||||||
<address type='pci' domain='0x0000' bus='0x00' slot='0x05' function='0x0'/>
|
|
||||||
</memballoon>
|
|
||||||
</devices>
|
|
||||||
</domain>
|
|
||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Home Assistant Container as a Podman Quadlet (replaced the ha_van HAOS VM
|
||||||
|
# 2026-07-07 — the fixed 2 GiB VM allocation starved the 4 GB Pi; VM domain XML
|
||||||
|
# is in git history under ha/ha_van.xml). Installed by deploy.sh to
|
||||||
|
# /etc/containers/systemd/; systemd generates homeassistant.service from it.
|
||||||
|
#
|
||||||
|
# Host networking: HA binds :8123 on the host directly, so the LAN URL is
|
||||||
|
# http://10.42.0.1:8123 (name: homeassistant) — no bridge port, no DNAT.
|
||||||
|
# /run/dbus gives HA the host BlueZ stack = the Pi's onboard Bluetooth (hci0).
|
||||||
|
[Unit]
|
||||||
|
Description=Home Assistant (Podman container)
|
||||||
|
Wants=network-online.target bluetooth.service
|
||||||
|
After=network-online.target bluetooth.service
|
||||||
|
|
||||||
|
[Container]
|
||||||
|
Image=ghcr.io/home-assistant/home-assistant:stable
|
||||||
|
ContainerName=homeassistant
|
||||||
|
Network=host
|
||||||
|
Volume=/srv/homeassistant:/config
|
||||||
|
Volume=/run/dbus:/run/dbus:ro
|
||||||
|
Environment=TZ=America/Toronto
|
||||||
|
# Bluetooth needs two escapes: Ubuntu's dbus-daemon mediates D-Bus per
|
||||||
|
# AppArmor label and the default containers profile can't send to BlueZ
|
||||||
|
# (AddMatch denied), and habluetooth wants NET_ADMIN+NET_RAW for adapter
|
||||||
|
# recovery. HA is rootful + host-net anyway.
|
||||||
|
PodmanArgs=--security-opt apparmor=unconfined
|
||||||
|
AddCapability=NET_ADMIN NET_RAW
|
||||||
|
# Podman's default 10s stop window SIGKILLs HA mid-flush and the recorder
|
||||||
|
# complains about an unclean sqlite shutdown on the next start.
|
||||||
|
StopTimeout=120
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=always
|
||||||
|
# First start pulls the image (~600 MB) — allow for a slow uplink.
|
||||||
|
TimeoutStartSec=900
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Heartbeat client (hbc) — dead-man's switch + metrics to hbd.wrede.pvt
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
# Least privilege: the hbd server can push CMD (run a shell command on the client),
|
|
||||||
# so this runs as the unprivileged user, not root. hbc lives in andreas' venv install.
|
|
||||||
User=andreas
|
|
||||||
ExecStart=/home/andreas/bin/hbc -b -c /etc/hbc.yaml hbd.wrede.pvt
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# hbc (heartbeat client) config for wayback — the van router's dead-man's switch.
|
|
||||||
# Deployed to /etc/hbc.yaml by deploy.sh. Server host is passed on the command line
|
|
||||||
# (hbd.wrede.pvt, see hbc.service). No secrets here.
|
|
||||||
#
|
|
||||||
# The client (~/bin/hbc) is installed once via the heartbeat project's own installer —
|
|
||||||
# see README §4. This file only tunes intervals + which metric plugins to ship.
|
|
||||||
|
|
||||||
interval: 15 # heartbeat every 15s (server flags overdue a few s after a miss)
|
|
||||||
|
|
||||||
plugins:
|
|
||||||
cpu_monitor: { interval: 300 }
|
|
||||||
memory_monitor: { interval: 300 } # ZFS ARC-aware
|
|
||||||
disk_monitor: { interval: 300 }
|
|
||||||
network_monitor: { interval: 300 }
|
|
||||||
zfs_monitor: { interval: 300 } # zroot health/capacity (readable as the service user)
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Executable
+376
@@ -0,0 +1,376 @@
|
|||||||
|
#!/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. We restart
|
||||||
|
# bluetooth.service ourselves as soon as a scan fails to clear that, rate-
|
||||||
|
# limited (BLUETOOTH_RESTART_COOLDOWN_S) 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 = 1 # 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, display_precision) -- precision None means
|
||||||
|
# a non-numeric value (hex code, version string, serial number): published
|
||||||
|
# as-is with no unit, no rounding, no state_class.
|
||||||
|
("voltage", "Pack Voltage", "V", "voltage", 2),
|
||||||
|
("cell1_voltage", "Cell 1 Voltage", "V", "voltage", 2),
|
||||||
|
("cell2_voltage", "Cell 2 Voltage", "V", "voltage", 2),
|
||||||
|
("cell3_voltage", "Cell 3 Voltage", "V", "voltage", 2),
|
||||||
|
("cell4_voltage", "Cell 4 Voltage", "V", "voltage", 2),
|
||||||
|
("current", "Current", "A", "current", 2),
|
||||||
|
("soc", "State of Charge", "%", "battery", 0),
|
||||||
|
("bms_temperature", "BMS Temperature", "°F", "temperature", 1),
|
||||||
|
("battery_temperature", "Battery Temperature", "°F", "temperature", 1),
|
||||||
|
# From "&" trace lines (streamed continuously once $traceon is sent) --
|
||||||
|
# CAN-charger bus fields. This battery has no CAN charger wired up, so
|
||||||
|
# can_charger_voltage/current are observed as a fixed sentinel and
|
||||||
|
# can_charger_status/can_status never change; published anyway since
|
||||||
|
# they're genuine decoded fields.
|
||||||
|
("remaining_capacity", "Remaining Capacity", "Ah", None, 0),
|
||||||
|
("remaining_time", "Remaining Time", "min", "duration", 0),
|
||||||
|
("can_charger_voltage", "CAN Charger Voltage", "V", "voltage", 1),
|
||||||
|
("can_charger_current", "CAN Charger Current", "A", "current", 1),
|
||||||
|
("can_charger_status", "CAN Charger Status", None, None, None),
|
||||||
|
("can_status", "CAN Status", None, None, None),
|
||||||
|
]
|
||||||
|
|
||||||
|
# From the "$" info line, sent once by the device right after $info and never
|
||||||
|
# repeated -- published separately (own retained state topic) rather than
|
||||||
|
# merged into the periodic telemetry above.
|
||||||
|
INFO_SENSORS = [
|
||||||
|
("total_consumed", "Lifetime Consumed", "Ah", None, 0),
|
||||||
|
("last_fault_code", "Last Fault Code", None, None, None),
|
||||||
|
("highest_recorded_temp", "Highest Recorded Temperature", "°F", "temperature", 0),
|
||||||
|
("lowest_recorded_temp", "Lowest Recorded Temperature", "°F", "temperature", 0),
|
||||||
|
("firmware_version", "Firmware Version", None, None, None),
|
||||||
|
("aging_factor_temp", "Aging Factor (Temp)", None, None, 0),
|
||||||
|
("aging_factor_soc", "Aging Factor (SOC)", None, None, 0),
|
||||||
|
("serial_number", "Serial Number", None, None, None),
|
||||||
|
]
|
||||||
|
|
||||||
|
info_topic = cfg.get("info_topic", f"van/{device_id}/info")
|
||||||
|
|
||||||
|
# The "status" field (main Cs telemetry line, and identically-coded but
|
||||||
|
# cumulative/latched "last_fault_code" from $info) is a 24-bit flag mask.
|
||||||
|
# Bit meanings pulled from the app's own "advanced" string-array resource
|
||||||
|
# (com.lithionics.bms base.apk, array/advanced -- dumped with aapt since the
|
||||||
|
# app's Kotlin StatusCodeTable class references stale/wrong resource IDs and
|
||||||
|
# can't be trusted). Index 0 = bit 23 (MSB) down to index 23 = bit 0 (LSB);
|
||||||
|
# blank entries are unused bits. Applied to "status" only -- last_fault_code
|
||||||
|
# is a lifetime latch (many bits accumulate over time) and isn't meaningfully
|
||||||
|
# summarized the same way.
|
||||||
|
STATUS_FLAGS = [
|
||||||
|
"", "BMS Temp High", "Overcurrent State", "Charge OFF", "Aux Input State",
|
||||||
|
"Cell Temp Low", "Cell Temp High", "AGSR State", "Temp Sensor Fault",
|
||||||
|
"CAN Charger Fault", "CAN Charger Present", "AC Power Present",
|
||||||
|
"Contactor Flutter", "Pre-Charge Fault", "Contactor Fault",
|
||||||
|
"Contactor State", "Power Off State", "Battery Protection", "Low Voltage",
|
||||||
|
"Reserve Range", "OptoLoop Open", "NeverDie Reserve", "Charge Detected",
|
||||||
|
"High Voltage",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def decode_status(hex_code):
|
||||||
|
try:
|
||||||
|
value = int(hex_code, 16)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return hex_code
|
||||||
|
active = [
|
||||||
|
label for i, label in enumerate(STATUS_FLAGS)
|
||||||
|
if label and (value >> (23 - i)) & 1
|
||||||
|
]
|
||||||
|
return ", ".join(active) if active else "OK"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_line(line):
|
||||||
|
"""Cs-series main telemetry line (no prefix character)."""
|
||||||
|
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 parse_trace_line(line):
|
||||||
|
"""'&' trace line: &,batteryId,remaining,remainingTime,canChargerVoltage,
|
||||||
|
canChargerCurrent,canChargerStatus,canStatus"""
|
||||||
|
parts = line.split(",")
|
||||||
|
try:
|
||||||
|
return {
|
||||||
|
"remaining_capacity": int(parts[2]),
|
||||||
|
"remaining_time": int(parts[3]),
|
||||||
|
"can_charger_voltage": round(int(parts[4]) * 0.1, 1),
|
||||||
|
"can_charger_current": round(int(parts[5]) * 0.1, 1),
|
||||||
|
"can_charger_status": parts[6],
|
||||||
|
"can_status": parts[7],
|
||||||
|
}
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_info_line(line):
|
||||||
|
"""'$' info line (response to $info): $,totalConsumed,lastFaultCode,
|
||||||
|
highestRecordedTemp,lowestRecordedTemp,firmwareVersion,agingFactorTemp,
|
||||||
|
agingFactorSoc,serialNumber"""
|
||||||
|
parts = line.split(",")
|
||||||
|
try:
|
||||||
|
return {
|
||||||
|
"total_consumed": int(parts[1]),
|
||||||
|
"last_fault_code": parts[2],
|
||||||
|
"highest_recorded_temp": int(parts[3]),
|
||||||
|
"lowest_recorded_temp": int(parts[4]),
|
||||||
|
"firmware_version": parts[5],
|
||||||
|
"aging_factor_temp": int(parts[6]),
|
||||||
|
"aging_factor_soc": int(parts[7]),
|
||||||
|
"serial_number": parts[8],
|
||||||
|
}
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sensor_config(key, name, unit, device_class, precision, topic_state, device_info, expire_after=None):
|
||||||
|
numeric = precision is not None
|
||||||
|
payload = {
|
||||||
|
"name": name,
|
||||||
|
"unique_id": f"{device_id}_{key}",
|
||||||
|
"state_topic": topic_state,
|
||||||
|
"unit_of_measurement": unit,
|
||||||
|
"device_class": device_class,
|
||||||
|
"value_template": (
|
||||||
|
f"{{{{ value_json.{key} | round({precision}) }}}}"
|
||||||
|
if numeric
|
||||||
|
else f"{{{{ value_json.{key} }}}}"
|
||||||
|
),
|
||||||
|
"device": device_info,
|
||||||
|
}
|
||||||
|
if expire_after is not None:
|
||||||
|
payload["expire_after"] = expire_after
|
||||||
|
if numeric:
|
||||||
|
payload["suggested_display_precision"] = precision
|
||||||
|
payload["state_class"] = "measurement"
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def publish_discovery(mqc):
|
||||||
|
device_info = {
|
||||||
|
"identifiers": [device_id],
|
||||||
|
"name": device_name,
|
||||||
|
"manufacturer": "Lithionics",
|
||||||
|
"model": "Li3 BMS",
|
||||||
|
}
|
||||||
|
for key, name, unit, device_class, precision in SENSORS:
|
||||||
|
topic = f"{discovery_prefix}/sensor/{device_id}/{key}/config"
|
||||||
|
payload = _sensor_config(
|
||||||
|
key, name, unit, device_class, precision, state_topic, device_info,
|
||||||
|
expire_after=publish_interval_s * 4,
|
||||||
|
)
|
||||||
|
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": "{{ value_json.status_text }}",
|
||||||
|
"device": device_info,
|
||||||
|
"expire_after": publish_interval_s * 4,
|
||||||
|
}
|
||||||
|
mqc.publish(status_topic, json.dumps(status_payload), retain=True)
|
||||||
|
|
||||||
|
for key, name, unit, device_class, precision in INFO_SENSORS:
|
||||||
|
topic = f"{discovery_prefix}/sensor/{device_id}/{key}/config"
|
||||||
|
payload = _sensor_config(key, name, unit, device_class, precision, info_topic, device_info)
|
||||||
|
payload["entity_category"] = "diagnostic"
|
||||||
|
mqc.publish(topic, json.dumps(payload), retain=True)
|
||||||
|
|
||||||
|
|
||||||
|
def publish_info(mqc, info):
|
||||||
|
"""Publish the once-per-connection '$info' fields as their own retained
|
||||||
|
message, separate from the periodic telemetry state."""
|
||||||
|
mqc.publish(info_topic, json.dumps(info), retain=True)
|
||||||
|
print("published info:", info)
|
||||||
|
|
||||||
|
|
||||||
|
def build_state_payload(cs_reading, trace_fields):
|
||||||
|
# A non-zero status means the rest of the Cs line's fields are unreliable
|
||||||
|
# (observed 2026-08-18: status '69' alongside e.g. current=341, soc=340,
|
||||||
|
# cell4_voltage=34013.63) — publish only the status in that case. Trace
|
||||||
|
# ("&" line) fields come from a separate message and are published
|
||||||
|
# regardless.
|
||||||
|
status = cs_reading["status"]
|
||||||
|
if status != "000000":
|
||||||
|
payload = {"status": status}
|
||||||
|
else:
|
||||||
|
payload = {
|
||||||
|
"voltage": cs_reading["voltage"],
|
||||||
|
"cell1_voltage": cs_reading["cell1"],
|
||||||
|
"cell2_voltage": cs_reading["cell2"],
|
||||||
|
"cell3_voltage": cs_reading["cell3"],
|
||||||
|
"cell4_voltage": cs_reading["cell4"],
|
||||||
|
"current": cs_reading["current_a"],
|
||||||
|
"soc": cs_reading["soc_pct"],
|
||||||
|
"bms_temperature": cs_reading["bms_temp_f"],
|
||||||
|
"battery_temperature": cs_reading["batt_temp_f"],
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
payload["status_text"] = decode_status(status)
|
||||||
|
payload.update(trace_fields)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def restart_bluetooth_service():
|
||||||
|
print("scan failed; 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_cs = {}
|
||||||
|
latest_trace = {}
|
||||||
|
info_published = False
|
||||||
|
|
||||||
|
def notify_handler(_sender, data):
|
||||||
|
nonlocal buf, info_published
|
||||||
|
buf += data.decode("utf-8", errors="replace")
|
||||||
|
while "\r\n" in buf:
|
||||||
|
line, buf = buf.split("\r\n", 1)
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if line.startswith("&"):
|
||||||
|
trace = parse_trace_line(line)
|
||||||
|
if trace:
|
||||||
|
latest_trace.update(trace)
|
||||||
|
elif line.startswith("$"):
|
||||||
|
if not info_published:
|
||||||
|
info = parse_info_line(line)
|
||||||
|
if info:
|
||||||
|
publish_info(mqc, info)
|
||||||
|
info_published = True
|
||||||
|
else:
|
||||||
|
reading = parse_line(line)
|
||||||
|
if reading:
|
||||||
|
latest_cs.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_cs and (now - last_publish) >= publish_interval_s:
|
||||||
|
payload = build_state_payload(latest_cs, latest_trace)
|
||||||
|
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())
|
||||||
@@ -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
|
||||||
Executable
+96
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""van-sms-send — send an SMS through the EC25 modem from the command line.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
van-sms-send <number> <text...>
|
||||||
|
echo "message text" | van-sms-send <number>
|
||||||
|
|
||||||
|
The message is written to a temp file and passed via mmcli's
|
||||||
|
--messaging-create-sms-with-text (rather than inline in the "number=...,
|
||||||
|
text=..." properties string) — mmcli's properties-string parser breaks on
|
||||||
|
plain spaces/quotes/colons in inline text (found the hard way testing
|
||||||
|
van-sms-watch), the file form has none of that trouble.
|
||||||
|
|
||||||
|
Needs root: ModemManager's Messaging/Device.Control D-Bus actions are
|
||||||
|
PolicyKit-gated and unauthorized for a plain user session (read-only
|
||||||
|
actions like listing SMS work unprivileged; creating/sending don't). Run
|
||||||
|
with sudo.
|
||||||
|
|
||||||
|
Stdlib only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
def die(msg):
|
||||||
|
print(f"van-sms-send: {msg}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def run_json(args):
|
||||||
|
"""Run mmcli with -J and return the parsed dict, dying on any failure."""
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["mmcli", "-J", *args], capture_output=True,
|
||||||
|
text=True, timeout=20)
|
||||||
|
except Exception as e:
|
||||||
|
die(f"failed to run mmcli {' '.join(args)}: {e}")
|
||||||
|
if out.returncode != 0:
|
||||||
|
die(f"mmcli {' '.join(args)} failed: {out.stderr.strip() or out.stdout.strip()}")
|
||||||
|
try:
|
||||||
|
return json.loads(out.stdout)
|
||||||
|
except Exception as e:
|
||||||
|
die(f"mmcli {' '.join(args)} gave unparseable JSON ({e}): {out.stdout}")
|
||||||
|
|
||||||
|
|
||||||
|
def run_action(args):
|
||||||
|
"""Run mmcli for an action whose success is exit-code only (no JSON body
|
||||||
|
even with -J, e.g. --send). Dies on failure."""
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["mmcli", *args], capture_output=True, text=True, timeout=20)
|
||||||
|
except Exception as e:
|
||||||
|
die(f"failed to run mmcli {' '.join(args)}: {e}")
|
||||||
|
if out.returncode != 0:
|
||||||
|
die(f"mmcli {' '.join(args)} failed: {out.stderr.strip() or out.stdout.strip()}")
|
||||||
|
|
||||||
|
|
||||||
|
def find_modem():
|
||||||
|
modems = run_json(["-L"]).get("modem-list") or []
|
||||||
|
return modems[0] if modems else None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
die("usage: van-sms-send <number> <text...> (or pipe text on stdin)")
|
||||||
|
number = sys.argv[1]
|
||||||
|
if len(sys.argv) > 2:
|
||||||
|
text = " ".join(sys.argv[2:])
|
||||||
|
elif not sys.stdin.isatty():
|
||||||
|
text = sys.stdin.read().rstrip("\n")
|
||||||
|
else:
|
||||||
|
die("no message text given as an argument or on stdin")
|
||||||
|
if not text:
|
||||||
|
die("message text is empty")
|
||||||
|
|
||||||
|
modem = find_modem()
|
||||||
|
if not modem:
|
||||||
|
die("no modem found (mmcli -L found none)")
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile("w", suffix=".txt") as f:
|
||||||
|
f.write(text)
|
||||||
|
f.flush()
|
||||||
|
created = run_json(["-m", modem, f"--messaging-create-sms=number={number}",
|
||||||
|
f"--messaging-create-sms-with-text={f.name}"])
|
||||||
|
|
||||||
|
sms_path = (created.get("modem") or {}).get("messaging", {}).get("created-sms")
|
||||||
|
if not sms_path:
|
||||||
|
die(f"couldn't find created SMS path in mmcli output: {created}")
|
||||||
|
|
||||||
|
run_action(["-m", modem, "-s", sms_path, "--send"])
|
||||||
|
print(f"sent to {number}: {text}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+244
@@ -0,0 +1,244 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""van-sms-watch — archives inbound SMS on the EC25 modem and pages via Pushover.
|
||||||
|
|
||||||
|
Polls ModemManager (via `mmcli -J`, no dbus dependency) for SMS sitting in the
|
||||||
|
modem's "mt" storage. Each *received* message (pdu-type "deliver" — this
|
||||||
|
skips the modem's own "submit"/"sent" records) is appended as one JSON line
|
||||||
|
to /var/log/van-sms.jsonl, pushed via Pushover, then deleted from modem
|
||||||
|
storage — that storage is small flash on the EC25 itself and fills up
|
||||||
|
silently over time, so anything present in a poll is by definition new and
|
||||||
|
needs archiving before it's gone.
|
||||||
|
|
||||||
|
MMS is not decodable here: MMS arrives over SMS only as a WAP-push binary
|
||||||
|
notification (a URL to fetch over the carrier's MMS APN with carrier-specific
|
||||||
|
auth) — ModemManager hands it back with no `text`, just raw `data` hex.
|
||||||
|
Decoding WSP and fetching that URL is real added complexity with no stdlib
|
||||||
|
support and no guarantee the MMS APN is even reachable from this modem's
|
||||||
|
general-internet APN. Instead: detect it (deliver + no text + non-empty
|
||||||
|
data), save the raw record, and page with a note that the content itself
|
||||||
|
wasn't retrieved — better than silently dropping it.
|
||||||
|
|
||||||
|
Pushover credentials shared with van-battery/van-thermal/van-modem-watch
|
||||||
|
(/etc/van-battery/pushover.json, 0600). Publishes /run/van-sms-watch/
|
||||||
|
state.json (same convention as the other watchdogs). Stdlib only (mmcli
|
||||||
|
does the ModemManager talking over subprocess).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
INTERVAL = 15 # seconds between polls
|
||||||
|
LOG_PATH = Path("/var/log/van-sms.jsonl")
|
||||||
|
STATE_DIR = Path("/run/van-sms-watch")
|
||||||
|
STATE_PATH = STATE_DIR / "state.json"
|
||||||
|
CREDENTIALS_PATH = Path("/etc/van-battery/pushover.json")
|
||||||
|
HOST = socket.gethostname()
|
||||||
|
PLACEHOLDERS = {"", "REPLACE_ME", "your-token-here", "your-user-key-here"}
|
||||||
|
PUSHOVER_MAX = 900 # Pushover message cap is 1024 bytes; leave headroom for the prefix
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg, level="info"):
|
||||||
|
# systemd journal severity prefixes (sd-daemon), same convention as the other watchdogs.
|
||||||
|
pri = {"info": "<6>", "warn": "<4>", "crit": "<2>"}.get(level, "<6>")
|
||||||
|
print(pri + msg, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def load_creds():
|
||||||
|
try:
|
||||||
|
c = json.loads(CREDENTIALS_PATH.read_text())
|
||||||
|
token, user = str(c.get("token", "")).strip(), str(c.get("user", "")).strip()
|
||||||
|
if token in PLACEHOLDERS or user in PLACEHOLDERS:
|
||||||
|
return None
|
||||||
|
return token, user
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
log(f"credentials {CREDENTIALS_PATH} unreadable ({e})", "warn")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def pushover(title, message, attempts=3, retry_delay=15):
|
||||||
|
creds = load_creds()
|
||||||
|
if not creds:
|
||||||
|
log(f"pushover skipped (no credentials): {title} — {message}", "warn")
|
||||||
|
return False
|
||||||
|
token, user = creds
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"token": token, "user": user, "title": title, "message": message,
|
||||||
|
}).encode()
|
||||||
|
req = urllib.request.Request("https://api.pushover.net/1/messages.json", data=data)
|
||||||
|
for attempt in range(1, attempts + 1):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
ok = resp.status == 200
|
||||||
|
if not ok:
|
||||||
|
log(f"pushover HTTP {resp.status}", "warn")
|
||||||
|
return ok
|
||||||
|
except Exception as e:
|
||||||
|
last = attempt == attempts
|
||||||
|
log(f"pushover send failed ({attempt}/{attempts}): {e}", "warn")
|
||||||
|
if not last:
|
||||||
|
time.sleep(retry_delay)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def mmcli_json(args):
|
||||||
|
"""Run mmcli with -J and return the parsed dict, or None on any failure."""
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["mmcli", "-J", *args], capture_output=True,
|
||||||
|
text=True, timeout=15)
|
||||||
|
except Exception as e:
|
||||||
|
log(f"mmcli {' '.join(args)} failed to run ({e})", "warn")
|
||||||
|
return None
|
||||||
|
if out.returncode != 0:
|
||||||
|
log(f"mmcli {' '.join(args)} exited {out.returncode}: {out.stderr.strip()}", "warn")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(out.stdout)
|
||||||
|
except Exception as e:
|
||||||
|
log(f"mmcli {' '.join(args)} gave unparseable JSON ({e})", "warn")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_modem():
|
||||||
|
"""Path of the first modem ModemManager knows about, or None. Re-resolved
|
||||||
|
each poll — the modem index can shift across a ModemManager restart."""
|
||||||
|
d = mmcli_json(["-L"])
|
||||||
|
if not d:
|
||||||
|
return None
|
||||||
|
modems = d.get("modem-list") or []
|
||||||
|
return modems[0] if modems else None
|
||||||
|
|
||||||
|
|
||||||
|
def list_sms(modem_path):
|
||||||
|
d = mmcli_json(["-m", modem_path, "--messaging-list-sms"])
|
||||||
|
if d is None:
|
||||||
|
return []
|
||||||
|
return d.get("modem.messaging.sms") or []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_sms(modem_path, sms_path):
|
||||||
|
d = mmcli_json(["-m", modem_path, "-s", sms_path])
|
||||||
|
if d is None:
|
||||||
|
return None
|
||||||
|
return d.get("sms")
|
||||||
|
|
||||||
|
|
||||||
|
def delete_sms(modem_path, sms_path):
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
["mmcli", "-m", modem_path, f"--messaging-delete-sms={sms_path}"],
|
||||||
|
capture_output=True, text=True, timeout=15)
|
||||||
|
except Exception as e:
|
||||||
|
log(f"delete {sms_path} failed to run ({e})", "warn")
|
||||||
|
return False
|
||||||
|
if out.returncode != 0:
|
||||||
|
log(f"delete {sms_path} exited {out.returncode}: {out.stderr.strip()}", "warn")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def archive(record):
|
||||||
|
try:
|
||||||
|
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(LOG_PATH, "a") as f:
|
||||||
|
f.write(json.dumps(record) + "\n")
|
||||||
|
return True
|
||||||
|
except OSError as e:
|
||||||
|
log(f"archive write failed: {e}", "warn")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def process(modem_path, sms_path):
|
||||||
|
sms = fetch_sms(modem_path, sms_path)
|
||||||
|
if sms is None:
|
||||||
|
return
|
||||||
|
props = sms.get("properties", {})
|
||||||
|
content = sms.get("content", {})
|
||||||
|
|
||||||
|
if props.get("pdu-type") != "deliver":
|
||||||
|
return # not an inbound message (e.g. the modem's own "submit"/"sent" records)
|
||||||
|
|
||||||
|
number = content.get("number", "unknown")
|
||||||
|
text = content.get("text", "--")
|
||||||
|
data = content.get("data", "--")
|
||||||
|
timestamp = props.get("timestamp", "--")
|
||||||
|
is_mms = text in ("--", "") and data not in ("--", "")
|
||||||
|
|
||||||
|
record = {
|
||||||
|
"time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||||
|
"modem_timestamp": timestamp,
|
||||||
|
"from": number,
|
||||||
|
"kind": "mms-notification" if is_mms else "sms",
|
||||||
|
"text": None if is_mms else text,
|
||||||
|
"data_hex": data if is_mms else None,
|
||||||
|
"smsc": props.get("smsc", "--"),
|
||||||
|
}
|
||||||
|
saved = archive(record)
|
||||||
|
if saved:
|
||||||
|
log(f"archived {record['kind']} from {number}")
|
||||||
|
else:
|
||||||
|
log(f"failed to archive {record['kind']} from {number} — not deleting from modem", "warn")
|
||||||
|
|
||||||
|
if is_mms:
|
||||||
|
title = f"📎 {HOST}: MMS notification from {number}"
|
||||||
|
body = ("MMS received but content can't be fetched on this device "
|
||||||
|
"(needs the carrier's MMS APN + WAP-push decode, not implemented). "
|
||||||
|
f"Raw notification archived to {LOG_PATH}.")
|
||||||
|
else:
|
||||||
|
title = f"📩 {HOST}: SMS from {number}"
|
||||||
|
body = text if len(text) <= PUSHOVER_MAX else text[:PUSHOVER_MAX] + "… (truncated)"
|
||||||
|
pushover(title, body)
|
||||||
|
|
||||||
|
if saved:
|
||||||
|
if delete_sms(modem_path, sms_path):
|
||||||
|
log(f"deleted {sms_path} from modem storage")
|
||||||
|
else:
|
||||||
|
log(f"left {sms_path} on modem storage (delete failed) — will retry next poll", "warn")
|
||||||
|
|
||||||
|
|
||||||
|
def write_state(payload):
|
||||||
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = STATE_PATH.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(payload))
|
||||||
|
tmp.replace(STATE_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
log(f"van-sms-watch up: polling every {INTERVAL}s, archiving to {LOG_PATH}")
|
||||||
|
modem_present = None # unknown yet, so the first observation always logs
|
||||||
|
|
||||||
|
while True:
|
||||||
|
modem_path = find_modem()
|
||||||
|
present = modem_path is not None
|
||||||
|
if present != modem_present:
|
||||||
|
log("modem present" if present else "modem not found (skipping poll)",
|
||||||
|
"info" if present else "warn")
|
||||||
|
modem_present = present
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
if modem_path:
|
||||||
|
for sms_path in list_sms(modem_path):
|
||||||
|
process(modem_path, sms_path)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
write_state({
|
||||||
|
"updated": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||||
|
"modem_present": present,
|
||||||
|
"last_poll_messages": count,
|
||||||
|
})
|
||||||
|
time.sleep(INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
sys.exit(0)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=SMS archive + Pushover relay for the EC25 cellular modem
|
||||||
|
After=ModemManager.service network-online.target
|
||||||
|
Wants=ModemManager.service network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/sbin/van-sms-watch
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
[Login]
|
|
||||||
# wayback is an always-on router living lid-closed in the van.
|
|
||||||
# Default logind suspends on lid close (incl. on AC); ignore the lid in every state.
|
|
||||||
HandleLidSwitch=ignore
|
|
||||||
HandleLidSwitchExternalPower=ignore
|
|
||||||
HandleLidSwitchDocked=ignore
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# Hardware watchdog for unattended operation.
|
# Hardware watchdog for unattended operation.
|
||||||
#
|
#
|
||||||
# PID1 pets /dev/watchdog0 (intel_oc_wdt) every RuntimeWatchdogSec/2. If systemd
|
# PID1 pets /dev/watchdog0 (bcm2835_wdt) every RuntimeWatchdogSec/2. If systemd
|
||||||
# itself wedges for longer than RuntimeWatchdogSec, the chip hard-resets the box —
|
# itself wedges for longer than RuntimeWatchdogSec, the chip hard-resets the box (bcm2835 max is 15s, hence 10s here; wayback uses 20s) —
|
||||||
# the only way to recover a hung router with nobody there to open the lid.
|
# the only way to recover a hung router with nobody there to open the lid.
|
||||||
# (See the EC-latch / USB-hub-hang history.) RebootWatchdogSec also guards against
|
# (See the EC-latch / USB-hub-hang history.) RebootWatchdogSec also guards against
|
||||||
# a reboot that hangs partway.
|
# a reboot that hangs partway.
|
||||||
[Manager]
|
[Manager]
|
||||||
RuntimeWatchdogSec=20s
|
RuntimeWatchdogSec=10s
|
||||||
RebootWatchdogSec=5min
|
RebootWatchdogSec=5min
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"poll_interval": 30,
|
|
||||||
"ac_path": "/sys/class/power_supply/AC0/online",
|
|
||||||
"battery_path": "/sys/class/power_supply/BAT0",
|
|
||||||
"warn_levels": [25, 20, 15],
|
|
||||||
"shutdown_level": 10,
|
|
||||||
"shutdown_grace": 8,
|
|
||||||
"credentials_path": "/etc/van-battery/pushover.json"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"nvme_device": "nvme0",
|
||||||
|
"fs_device": "/dev/nvme0n1p2",
|
||||||
|
"grace_period": 20,
|
||||||
|
"cooldown": 300,
|
||||||
|
"credentials_path": "/etc/van-battery/pushover.json"
|
||||||
|
}
|
||||||
@@ -6,7 +6,37 @@
|
|||||||
"pushover_level": "warn",
|
"pushover_level": "warn",
|
||||||
"credentials_path": "/etc/van-battery/pushover.json",
|
"credentials_path": "/etc/van-battery/pushover.json",
|
||||||
"sensors": [
|
"sensors": [
|
||||||
{ "name": "cpu", "hwmon": "coretemp", "label": "Package id 0", "warn": 80, "crit": 95, "clear_margin": 5 },
|
{
|
||||||
{ "name": "nvme", "hwmon": "nvme", "label": "Composite", "warn": 65, "crit": 70, "clear_margin": 5 }
|
"name": "cpu",
|
||||||
|
"hwmon": "cpu_thermal",
|
||||||
|
"warn": 80,
|
||||||
|
"crit": 85,
|
||||||
|
"clear_margin": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "nvme",
|
||||||
|
"hwmon": "nvme",
|
||||||
|
"label": "Composite",
|
||||||
|
"warn": 65,
|
||||||
|
"crit": 70,
|
||||||
|
"clear_margin": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rp1",
|
||||||
|
"hwmon": "rp1_adc",
|
||||||
|
"warn": 80,
|
||||||
|
"crit": 85,
|
||||||
|
"clear_margin": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fan",
|
||||||
|
"kind": "fan",
|
||||||
|
"hwmon": "pwmfan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "undervolt",
|
||||||
|
"kind": "undervolt",
|
||||||
|
"hwmon": "rpi_volt"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,231 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""van-battery — battery / mains monitor for the campervan router (wayback).
|
|
||||||
|
|
||||||
While running **off mains** (AC offline, i.e. on battery) it sends escalating
|
|
||||||
Pushover alerts as the charge drops past each warn level, and at the shutdown
|
|
||||||
level it sends a final alert and powers the machine off cleanly.
|
|
||||||
|
|
||||||
Alerts are edge-triggered per discharge episode: each severity fires once, and
|
|
||||||
the whole sequence re-arms when mains power returns. Plug-out already below a
|
|
||||||
warn level fires a single alert for the current severity, not a burst.
|
|
||||||
|
|
||||||
Pushover credentials live in a separate 0600 secrets file (see credentials_path),
|
|
||||||
never in this repo. Missing/placeholder creds disable sending but NOT the
|
|
||||||
shutdown — running flat must always power down safely. Stdlib only.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
CONFIG_PATH = os.environ.get("VAN_BATTERY_CONFIG", "/etc/van-battery/config.json")
|
|
||||||
STATE_DIR = Path("/run/van-battery")
|
|
||||||
STATE_PATH = STATE_DIR / "state.json"
|
|
||||||
PSY = Path("/sys/class/power_supply")
|
|
||||||
HOST = socket.gethostname()
|
|
||||||
|
|
||||||
DEFAULTS = {
|
|
||||||
"poll_interval": 30, # seconds between reads (battery moves slowly)
|
|
||||||
"ac_path": "/sys/class/power_supply/AC0/online",
|
|
||||||
"battery_path": "/sys/class/power_supply/BAT0",
|
|
||||||
"warn_levels": [25, 20, 15], # Pushover alert only
|
|
||||||
"shutdown_level": 10, # Pushover alert + poweroff
|
|
||||||
"shutdown_grace": 8, # seconds to let the alert flush before poweroff
|
|
||||||
"credentials_path": "/etc/van-battery/pushover.json",
|
|
||||||
}
|
|
||||||
|
|
||||||
PLACEHOLDERS = {"", "REPLACE_ME", "your-token-here", "your-user-key-here"}
|
|
||||||
|
|
||||||
|
|
||||||
def log(msg, level="info"):
|
|
||||||
pri = {"info": "<6>", "warn": "<4>", "crit": "<2>"}.get(level, "<6>")
|
|
||||||
print(pri + msg, flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def load_config():
|
|
||||||
cfg = dict(DEFAULTS)
|
|
||||||
try:
|
|
||||||
with open(CONFIG_PATH) as f:
|
|
||||||
cfg.update(json.load(f))
|
|
||||||
except FileNotFoundError:
|
|
||||||
log(f"config {CONFIG_PATH} not found, using built-in defaults")
|
|
||||||
except Exception as e:
|
|
||||||
log(f"config {CONFIG_PATH} unreadable ({e}), using defaults", "warn")
|
|
||||||
return cfg
|
|
||||||
|
|
||||||
|
|
||||||
def _by_type(kind):
|
|
||||||
"""Find a power_supply dir by its `type` (Mains / Battery) — fallback when the
|
|
||||||
configured AC0/BAT0 name isn't present on this machine."""
|
|
||||||
for d in sorted(PSY.glob("*")):
|
|
||||||
try:
|
|
||||||
if (d / "type").read_text().strip() == kind:
|
|
||||||
return d
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def read_on_battery(cfg):
|
|
||||||
"""True if running on battery (mains absent), False if on mains, None if unknown."""
|
|
||||||
p = Path(cfg["ac_path"])
|
|
||||||
if not p.exists():
|
|
||||||
d = _by_type("Mains")
|
|
||||||
p = (d / "online") if d else None
|
|
||||||
if not p or not p.exists():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return p.read_text().strip() == "0"
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def read_capacity(cfg):
|
|
||||||
"""Battery charge percentage (int), or None."""
|
|
||||||
d = Path(cfg["battery_path"])
|
|
||||||
if not (d / "capacity").exists():
|
|
||||||
d = _by_type("Battery") or d
|
|
||||||
try:
|
|
||||||
return int((d / "capacity").read_text().strip())
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def load_creds(cfg):
|
|
||||||
try:
|
|
||||||
c = json.loads(Path(cfg["credentials_path"]).read_text())
|
|
||||||
token, user = str(c.get("token", "")).strip(), str(c.get("user", "")).strip()
|
|
||||||
if token in PLACEHOLDERS or user in PLACEHOLDERS:
|
|
||||||
return None
|
|
||||||
return token, user
|
|
||||||
except FileNotFoundError:
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
log(f"credentials {cfg['credentials_path']} unreadable ({e})", "warn")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def pushover(cfg, title, message, priority=0):
|
|
||||||
creds = load_creds(cfg)
|
|
||||||
if not creds:
|
|
||||||
log(f"pushover skipped (no credentials): {title} — {message}", "warn")
|
|
||||||
return False
|
|
||||||
token, user = creds
|
|
||||||
data = urllib.parse.urlencode({
|
|
||||||
"token": token, "user": user, "title": title,
|
|
||||||
"message": message, "priority": priority,
|
|
||||||
}).encode()
|
|
||||||
req = urllib.request.Request("https://api.pushover.net/1/messages.json", data=data)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
||||||
ok = resp.status == 200
|
|
||||||
if not ok:
|
|
||||||
log(f"pushover HTTP {resp.status}", "warn")
|
|
||||||
return ok
|
|
||||||
except Exception as e:
|
|
||||||
log(f"pushover send failed: {e}", "warn")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def severity(cap, levels):
|
|
||||||
"""Most-severe (lowest) threshold the capacity has reached, or None if above all.
|
|
||||||
levels: thresholds sorted ascending. cap=12, levels=[10,15,20,25] -> 15."""
|
|
||||||
reached = [t for t in levels if cap <= t]
|
|
||||||
return min(reached) if reached else None
|
|
||||||
|
|
||||||
|
|
||||||
def poweroff(cfg):
|
|
||||||
log("shutdown level reached — powering off", "crit")
|
|
||||||
time.sleep(cfg["shutdown_grace"]) # give the Pushover POST time to land first
|
|
||||||
try:
|
|
||||||
subprocess.run(["systemctl", "poweroff"], check=False)
|
|
||||||
except Exception as e:
|
|
||||||
log(f"poweroff failed: {e}", "crit")
|
|
||||||
|
|
||||||
|
|
||||||
def write_state(on_batt, cap, armed, warn_levels, shutdown_level):
|
|
||||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
payload = {
|
|
||||||
"updated": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
||||||
"on_battery": on_batt,
|
|
||||||
"capacity": cap,
|
|
||||||
"alerted_below": armed, # lowest level alerted this discharge episode, or null
|
|
||||||
"warn_levels": warn_levels,
|
|
||||||
"shutdown_level": shutdown_level,
|
|
||||||
}
|
|
||||||
tmp = STATE_PATH.with_suffix(".tmp")
|
|
||||||
tmp.write_text(json.dumps(payload))
|
|
||||||
tmp.replace(STATE_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
cfg = load_config()
|
|
||||||
levels = sorted(cfg["warn_levels"] + [cfg["shutdown_level"]])
|
|
||||||
shutdown_level = cfg["shutdown_level"]
|
|
||||||
last_alerted = None # lowest threshold alerted in the current discharge episode
|
|
||||||
shutdown_issued = False
|
|
||||||
prev_on_batt = None
|
|
||||||
log(f"van-battery up: poll {cfg['poll_interval']}s, warn {cfg['warn_levels']}, "
|
|
||||||
f"shutdown {shutdown_level}%")
|
|
||||||
|
|
||||||
while True:
|
|
||||||
on_batt = read_on_battery(cfg)
|
|
||||||
cap = read_capacity(cfg)
|
|
||||||
|
|
||||||
capstr = f"{cap}%" if cap is not None else "unknown charge"
|
|
||||||
|
|
||||||
# Mains <-> battery transition alerts. Skip the very first sample (prev is None)
|
|
||||||
# so a restart while already on battery doesn't fire a spurious "on battery".
|
|
||||||
if on_batt is not None and prev_on_batt is not None and on_batt != prev_on_batt:
|
|
||||||
if on_batt:
|
|
||||||
log(f"mains lost — running on battery at {capstr}", "warn")
|
|
||||||
pushover(cfg, f"⚡ {HOST}: on battery",
|
|
||||||
f"Mains power lost — now running on battery ({capstr}). "
|
|
||||||
f"Low alerts at {cfg['warn_levels']}%, auto-shutdown at {shutdown_level}%.")
|
|
||||||
else:
|
|
||||||
log(f"mains restored at {capstr} — alerts re-armed")
|
|
||||||
pushover(cfg, f"🔌 {HOST}: back on mains",
|
|
||||||
f"Mains power restored ({capstr}). Battery alert sequence re-armed.")
|
|
||||||
|
|
||||||
if on_batt is False:
|
|
||||||
# On mains: re-arm the whole sequence for the next discharge episode.
|
|
||||||
last_alerted = None
|
|
||||||
shutdown_issued = False
|
|
||||||
elif on_batt is True and cap is not None:
|
|
||||||
sev = severity(cap, levels)
|
|
||||||
if sev is not None and (last_alerted is None or sev < last_alerted):
|
|
||||||
last_alerted = sev
|
|
||||||
is_shutdown = sev <= shutdown_level
|
|
||||||
if is_shutdown:
|
|
||||||
pushover(cfg, f"⚠ {HOST}: battery {cap}% — shutting down",
|
|
||||||
f"On battery at {cap}% (≤{shutdown_level}%). Powering off now to "
|
|
||||||
f"protect the system.", priority=1)
|
|
||||||
if not shutdown_issued:
|
|
||||||
shutdown_issued = True
|
|
||||||
poweroff(cfg)
|
|
||||||
else:
|
|
||||||
log(f"battery {cap}% on battery — alerting (level {sev})", "warn")
|
|
||||||
pushover(cfg, f"{HOST}: battery {cap}%",
|
|
||||||
f"Running on battery, charge down to {cap}% (alert at {sev}%). "
|
|
||||||
f"Shutdown at {shutdown_level}%.")
|
|
||||||
|
|
||||||
prev_on_batt = on_batt
|
|
||||||
try:
|
|
||||||
write_state(on_batt, cap, last_alerted, cfg["warn_levels"], shutdown_level)
|
|
||||||
except OSError as e:
|
|
||||||
log(f"state write failed: {e}", "warn")
|
|
||||||
time.sleep(cfg["poll_interval"])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
main()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
sys.exit(0)
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Battery monitor + low-charge Pushover alerts / safe shutdown for the campervan router
|
|
||||||
After=local-fs.target network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=/usr/local/sbin/van-battery
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""van-nvme-watch — pages when the NVMe root hits an I/O-timeout/reset event.
|
||||||
|
|
||||||
|
This exact kernel signature ("nvme nvmeN: I/O tag ... timeout, reset
|
||||||
|
controller") crashed and corrupted the NVMe root on 2026-08-02, then recurred
|
||||||
|
2026-08-04 and self-healed. Root cause is still unconfirmed (suspected
|
||||||
|
USB/PCIe host-bandwidth contention during heavy interface churn), so this
|
||||||
|
just watches for it recurring rather than trying to prevent it.
|
||||||
|
|
||||||
|
Tails `journalctl -kf` (event-driven, not polled) rather than sysfs like
|
||||||
|
van-thermal, since there's no sensor to sample — only a log line to catch.
|
||||||
|
On a match it watches a short grace window for either a clean controller
|
||||||
|
re-init (self-healed) or a follow-up ext4 error / a second timeout (escalating)
|
||||||
|
before paging, and includes a live SMART + superblock snapshot in the message
|
||||||
|
so the phone alert already answers "is the filesystem actually at risk".
|
||||||
|
|
||||||
|
Publishes /run/van-nvme-watch/state.json (last event, same convention as
|
||||||
|
van-failover/van-thermal). Pushover credentials shared with van-battery/
|
||||||
|
van-thermal (/etc/van-battery/pushover.json, 0600). Stdlib only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CONFIG_PATH = os.environ.get("VAN_NVME_WATCH_CONFIG", "/etc/van-nvme-watch/config.json")
|
||||||
|
STATE_DIR = Path("/run/van-nvme-watch")
|
||||||
|
STATE_PATH = STATE_DIR / "state.json"
|
||||||
|
HOST = socket.gethostname()
|
||||||
|
|
||||||
|
DEFAULTS = {
|
||||||
|
"nvme_device": "nvme0", # controller name as it appears in dmesg / smartctl target
|
||||||
|
"fs_device": "/dev/nvme0n1p2", # for the superblock snapshot
|
||||||
|
"grace_period": 20, # seconds to watch for escalation after a timeout line
|
||||||
|
"cooldown": 300, # minimum seconds between Pushover sends
|
||||||
|
"credentials_path": "/etc/van-battery/pushover.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
# The exact crash/recurrence signature, kept nvme-scoped and loose enough to
|
||||||
|
# catch variant opcodes/tags/queue ids without matching unrelated nvme lines.
|
||||||
|
TIMEOUT_RE = re.compile(r"nvme (nvme\d+): .*\b(?:timeout|reset controller)\b", re.IGNORECASE)
|
||||||
|
# Signs the event is more than a clean self-heal: an actual filesystem error,
|
||||||
|
# or forced read-only remount, following the reset.
|
||||||
|
ESCALATION_RE = re.compile(
|
||||||
|
r"EXT4-fs error|EXT4-fs.*remount.*read-only|Remounting filesystem read-only|"
|
||||||
|
r"Buffer I/O error|I/O error, dev nvme",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
PLACEHOLDERS = {"", "REPLACE_ME", "your-token-here", "your-user-key-here"}
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg, level="info"):
|
||||||
|
pri = {"info": "<6>", "warn": "<4>", "crit": "<2>"}.get(level, "<6>")
|
||||||
|
print(pri + msg, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def load_creds(cfg):
|
||||||
|
try:
|
||||||
|
c = json.loads(Path(cfg["credentials_path"]).read_text())
|
||||||
|
token, user = str(c.get("token", "")).strip(), str(c.get("user", "")).strip()
|
||||||
|
if token in PLACEHOLDERS or user in PLACEHOLDERS:
|
||||||
|
return None
|
||||||
|
return token, user
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
log(f"credentials {cfg['credentials_path']} unreadable ({e})", "warn")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def pushover(cfg, title, message, priority=0, attempts=3, retry_delay=15):
|
||||||
|
creds = load_creds(cfg)
|
||||||
|
if not creds:
|
||||||
|
log(f"pushover skipped (no credentials): {title} — {message}", "warn")
|
||||||
|
return False
|
||||||
|
token, user = creds
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"token": token, "user": user, "title": title,
|
||||||
|
"message": message, "priority": priority,
|
||||||
|
}).encode()
|
||||||
|
req = urllib.request.Request("https://api.pushover.net/1/messages.json", data=data)
|
||||||
|
for attempt in range(1, attempts + 1):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
ok = resp.status == 200
|
||||||
|
if not ok:
|
||||||
|
log(f"pushover HTTP {resp.status}", "warn")
|
||||||
|
return ok
|
||||||
|
except Exception as e:
|
||||||
|
last = attempt == attempts
|
||||||
|
log(f"pushover send failed ({attempt}/{attempts}): {e}", "warn")
|
||||||
|
if not last:
|
||||||
|
time.sleep(retry_delay)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
cfg = dict(DEFAULTS)
|
||||||
|
try:
|
||||||
|
with open(CONFIG_PATH) as f:
|
||||||
|
cfg.update(json.load(f))
|
||||||
|
except FileNotFoundError:
|
||||||
|
log(f"config {CONFIG_PATH} not found, using built-in defaults")
|
||||||
|
except Exception as e:
|
||||||
|
log(f"config {CONFIG_PATH} unreadable ({e}), using built-in defaults", "warn")
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_health(cfg):
|
||||||
|
"""Best-effort SMART + superblock snapshot for the alert body — answers
|
||||||
|
"did this one actually hurt the filesystem" without needing to SSH in."""
|
||||||
|
info = {}
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["smartctl", "-a", f"/dev/{cfg['nvme_device']}"],
|
||||||
|
capture_output=True, text=True, timeout=15).stdout
|
||||||
|
for key, pattern in (
|
||||||
|
("smart_health", r"SMART overall-health self-assessment test result:\s*(\S+)"),
|
||||||
|
("smart_critical_warning", r"Critical Warning:\s*(\S+)"),
|
||||||
|
("smart_media_errors", r"Media and Data Integrity Errors:\s*(\d+)"),
|
||||||
|
):
|
||||||
|
m = re.search(pattern, out)
|
||||||
|
info[key] = m.group(1) if m else "?"
|
||||||
|
except Exception as e:
|
||||||
|
info["smart_error"] = str(e)
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["tune2fs", "-l", cfg["fs_device"]],
|
||||||
|
capture_output=True, text=True, timeout=15).stdout
|
||||||
|
m = re.search(r"Filesystem state:\s*(\S+)", out)
|
||||||
|
info["fs_state"] = m.group(1) if m else "?"
|
||||||
|
except Exception as e:
|
||||||
|
info["fs_state_error"] = str(e)
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def write_state(payload):
|
||||||
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = STATE_PATH.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(payload))
|
||||||
|
tmp.replace(STATE_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def journal_reader(q):
|
||||||
|
"""Runs forever in a background thread, pushing new kernel log lines onto
|
||||||
|
q. journalctl itself can exit (journald restart, etc.) — respawn it."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["journalctl", "-kf", "-n", "0", "-o", "cat"],
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||||
|
text=True, bufsize=1)
|
||||||
|
for line in proc.stdout:
|
||||||
|
q.put(line.rstrip("\n"))
|
||||||
|
proc.wait()
|
||||||
|
log(f"journalctl exited (code {proc.returncode}), restarting in 5s", "warn")
|
||||||
|
except Exception as e:
|
||||||
|
log(f"journalctl reader error: {e}, restarting in 5s", "warn")
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
|
def finalize(cfg, pending, escalated, reason, alert_state):
|
||||||
|
"""Wrap up a pending event: snapshot health, log, publish state, and
|
||||||
|
Pushover (subject to cooldown). alert_state is a {"last_alert": monotonic}
|
||||||
|
box shared across calls so the cooldown persists across events."""
|
||||||
|
health = snapshot_health(cfg)
|
||||||
|
outcome = "ESCALATED" if escalated else "self-healed"
|
||||||
|
icon = "🚨" if escalated else "⚠️"
|
||||||
|
priority = 1 if escalated else 0
|
||||||
|
|
||||||
|
lines = [f"first: {pending['line']}"]
|
||||||
|
if pending.get("count", 1) > 1:
|
||||||
|
lines.append(f"repeated {pending['count']}x within the grace window")
|
||||||
|
if pending.get("escalation_line"):
|
||||||
|
lines.append(f"then: {pending['escalation_line']}")
|
||||||
|
lines.append(f"fs state: {health.get('fs_state', '?')} | "
|
||||||
|
f"SMART: {health.get('smart_health', '?')}, "
|
||||||
|
f"critical_warning={health.get('smart_critical_warning', '?')}, "
|
||||||
|
f"media_errors={health.get('smart_media_errors', '?')}")
|
||||||
|
message = "\n".join(lines)
|
||||||
|
|
||||||
|
log(f"nvme event {outcome} ({reason}): {message}", "crit" if escalated else "warn")
|
||||||
|
|
||||||
|
write_state({
|
||||||
|
"updated": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||||
|
"device": pending["device"],
|
||||||
|
"count": pending.get("count", 1),
|
||||||
|
"escalated": escalated,
|
||||||
|
"reason": reason,
|
||||||
|
"first_line": pending["line"],
|
||||||
|
"escalation_line": pending.get("escalation_line"),
|
||||||
|
"health": health,
|
||||||
|
})
|
||||||
|
|
||||||
|
if time.monotonic() - alert_state["last_alert"] >= cfg["cooldown"]:
|
||||||
|
pushover(cfg, f"{icon} {HOST}: NVMe timeout — {outcome}", message, priority=priority)
|
||||||
|
alert_state["last_alert"] = time.monotonic()
|
||||||
|
else:
|
||||||
|
log("pushover suppressed (cooldown)", "warn")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cfg = load_config()
|
||||||
|
log(f"van-nvme-watch up: tailing journalctl -k for nvme timeout/reset events "
|
||||||
|
f"(grace period {cfg['grace_period']}s, cooldown {cfg['cooldown']}s)")
|
||||||
|
|
||||||
|
q = queue.Queue()
|
||||||
|
threading.Thread(target=journal_reader, args=(q,), daemon=True).start()
|
||||||
|
|
||||||
|
pending = None
|
||||||
|
alert_state = {"last_alert": 0.0}
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if pending is not None:
|
||||||
|
remaining = pending["deadline"] - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
finalize(cfg, pending, False, "grace period elapsed", alert_state)
|
||||||
|
pending = None
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
line = q.get(timeout=remaining)
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
line = q.get()
|
||||||
|
|
||||||
|
if pending is None:
|
||||||
|
m = TIMEOUT_RE.search(line)
|
||||||
|
if m:
|
||||||
|
pending = {"device": m.group(1), "line": line, "count": 1,
|
||||||
|
"deadline": time.monotonic() + cfg["grace_period"]}
|
||||||
|
log(f"nvme timeout detected: {line}", "warn")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if TIMEOUT_RE.search(line):
|
||||||
|
pending["count"] += 1
|
||||||
|
if pending["count"] >= 2:
|
||||||
|
finalize(cfg, pending, True, "repeated timeouts", alert_state)
|
||||||
|
pending = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ESCALATION_RE.search(line):
|
||||||
|
pending["escalation_line"] = line
|
||||||
|
finalize(cfg, pending, True, "filesystem error followed", alert_state)
|
||||||
|
pending = None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
sys.exit(0)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=NVMe I/O-timeout/reset watchdog + Pushover alert for the campervan router
|
||||||
|
After=local-fs.target network-online.target systemd-journald.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/sbin/van-nvme-watch
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
+160
-32
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""van-thermal — temperature monitor for the campervan router (wayback).
|
"""van-thermal — temperature + health monitor for the campervan router.
|
||||||
|
|
||||||
One small daemon that does three jobs off a single sysfs sample loop:
|
One small daemon that does three jobs off a single sysfs sample loop:
|
||||||
1. publishes /run/van-thermal/state.json (the Cockpit "Temps" card reads this,
|
1. publishes /run/van-thermal/state.json (the Cockpit "Temps" card reads this,
|
||||||
@@ -10,7 +10,10 @@ One small daemon that does three jobs off a single sysfs sample loop:
|
|||||||
3. appends throttled CSV history to /var/log/van-thermal.csv with self-rotation
|
3. appends throttled CSV history to /var/log/van-thermal.csv with self-rotation
|
||||||
|
|
||||||
Sensors are resolved by hwmon *name* + *label* at runtime, never by hwmonN index
|
Sensors are resolved by hwmon *name* + *label* at runtime, never by hwmonN index
|
||||||
(that number is assigned at boot and is not stable). Pushover credentials are shared
|
(that number is assigned at boot and is not stable). Besides temperatures a sensor
|
||||||
|
spec may set "kind": "fan" (alerts when the fan is commanded on but reads 0 RPM) or
|
||||||
|
"kind": "undervolt" (live rpi_volt alarm, plus the firmware's latched since-boot bit
|
||||||
|
so dips between samples still surface). Pushover credentials are shared
|
||||||
with van-battery (/etc/van-battery/pushover.json, 0600); missing/placeholder creds
|
with van-battery (/etc/van-battery/pushover.json, 0600); missing/placeholder creds
|
||||||
disable sending but nothing else. Stdlib only.
|
disable sending but nothing else. Stdlib only.
|
||||||
"""
|
"""
|
||||||
@@ -18,6 +21,7 @@ disable sending but nothing else. Stdlib only.
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@@ -50,6 +54,7 @@ DEFAULTS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LEVELS = ("ok", "warn", "crit")
|
LEVELS = ("ok", "warn", "crit")
|
||||||
|
ICONS = {"fan": "🌀", "undervolt": "⚡"} # pushover title icons for non-temp kinds
|
||||||
PLACEHOLDERS = {"", "REPLACE_ME", "your-token-here", "your-user-key-here"}
|
PLACEHOLDERS = {"", "REPLACE_ME", "your-token-here", "your-user-key-here"}
|
||||||
|
|
||||||
|
|
||||||
@@ -73,7 +78,7 @@ def load_creds(cfg):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def pushover(cfg, title, message, priority=0):
|
def pushover(cfg, title, message, priority=0, attempts=3, retry_delay=15):
|
||||||
creds = load_creds(cfg)
|
creds = load_creds(cfg)
|
||||||
if not creds:
|
if not creds:
|
||||||
log(f"pushover skipped (no credentials): {title} — {message}", "warn")
|
log(f"pushover skipped (no credentials): {title} — {message}", "warn")
|
||||||
@@ -84,6 +89,10 @@ def pushover(cfg, title, message, priority=0):
|
|||||||
"message": message, "priority": priority,
|
"message": message, "priority": priority,
|
||||||
}).encode()
|
}).encode()
|
||||||
req = urllib.request.Request("https://api.pushover.net/1/messages.json", data=data)
|
req = urllib.request.Request("https://api.pushover.net/1/messages.json", data=data)
|
||||||
|
# Retry network failures (DNS not up yet at boot, WAN flap) — they block the
|
||||||
|
# sample loop briefly, which is fine at this cadence. A non-200 means Pushover
|
||||||
|
# rejected the request (bad token etc.); retrying won't change that.
|
||||||
|
for attempt in range(1, attempts + 1):
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
ok = resp.status == 200
|
ok = resp.status == 200
|
||||||
@@ -91,7 +100,10 @@ def pushover(cfg, title, message, priority=0):
|
|||||||
log(f"pushover HTTP {resp.status}", "warn")
|
log(f"pushover HTTP {resp.status}", "warn")
|
||||||
return ok
|
return ok
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"pushover send failed: {e}", "warn")
|
last = attempt == attempts
|
||||||
|
log(f"pushover send failed ({attempt}/{attempts}): {e}", "warn")
|
||||||
|
if not last:
|
||||||
|
time.sleep(retry_delay)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -119,15 +131,28 @@ def find_hwmon(name):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_hwmon(spec, cache):
|
||||||
|
"""Cached hwmon-dir lookup by name; re-resolved when the dir vanished."""
|
||||||
|
d = cache.get(spec["hwmon"])
|
||||||
|
if d is None or not d.exists():
|
||||||
|
d = find_hwmon(spec["hwmon"])
|
||||||
|
cache[spec["hwmon"]] = d
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def read_hwmon_int(d, fname):
|
||||||
|
try:
|
||||||
|
return int((d / fname).read_text().strip())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def read_temp(spec, cache):
|
def read_temp(spec, cache):
|
||||||
"""Read one sensor's temperature in °C, or None if unavailable.
|
"""Read one sensor's temperature in °C, or None if unavailable.
|
||||||
|
|
||||||
spec: {hwmon, label}. Resolves hwmon dir + the tempN whose *_label matches,
|
spec: {hwmon, label}. Resolves hwmon dir + the tempN whose *_label matches,
|
||||||
falling back to temp1 when the chip exposes no labels."""
|
falling back to temp1 when the chip exposes no labels."""
|
||||||
d = cache.get(spec["hwmon"])
|
d = resolve_hwmon(spec, cache)
|
||||||
if d is None or not d.exists():
|
|
||||||
d = find_hwmon(spec["hwmon"])
|
|
||||||
cache[spec["hwmon"]] = d
|
|
||||||
if d is None:
|
if d is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -152,6 +177,65 @@ def read_temp(spec, cache):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_fan(spec, cache):
|
||||||
|
"""(rpm, pwm) for a pwmfan hwmon, either None when unavailable."""
|
||||||
|
d = resolve_hwmon(spec, cache)
|
||||||
|
if d is None:
|
||||||
|
return None, None
|
||||||
|
rpm = read_hwmon_int(d, "fan1_input")
|
||||||
|
if rpm is None:
|
||||||
|
cache[spec["hwmon"]] = None # hwmon may have re-enumerated
|
||||||
|
return None, None
|
||||||
|
return rpm, read_hwmon_int(d, "pwm1")
|
||||||
|
|
||||||
|
|
||||||
|
def read_undervolt(spec, cache):
|
||||||
|
"""(now, since_boot) undervoltage flags.
|
||||||
|
|
||||||
|
Live alarm from the rpi_volt hwmon; the firmware's latched since-boot bit
|
||||||
|
(get_throttled bit 16) catches dips shorter than the sample interval."""
|
||||||
|
now = None
|
||||||
|
d = resolve_hwmon(spec, cache)
|
||||||
|
if d is not None:
|
||||||
|
v = read_hwmon_int(d, "in0_lcrit_alarm")
|
||||||
|
if v is None:
|
||||||
|
cache[spec["hwmon"]] = None
|
||||||
|
else:
|
||||||
|
now = bool(v)
|
||||||
|
since_boot = None
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["vcgencmd", "get_throttled"], capture_output=True,
|
||||||
|
text=True, timeout=5).stdout
|
||||||
|
bits = int(out.split("=")[1], 16)
|
||||||
|
since_boot = bool(bits & 0x10000)
|
||||||
|
if now is None:
|
||||||
|
now = bool(bits & 0x1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return now, since_boot
|
||||||
|
|
||||||
|
|
||||||
|
def classify_fan(rpm, pwm, prev_level):
|
||||||
|
"""A fan commanded on (pwm > 0) reading 0 RPM is stalled/unplugged: first
|
||||||
|
such sample is warn, a consecutive one escalates to crit. pwm == 0 with
|
||||||
|
0 RPM is the firmware idling the fan on a cool SoC — that's ok."""
|
||||||
|
if rpm is None:
|
||||||
|
return prev_level
|
||||||
|
if rpm > 0 or pwm is None or pwm == 0:
|
||||||
|
return "ok"
|
||||||
|
return "crit" if prev_level in ("warn", "crit") else "warn"
|
||||||
|
|
||||||
|
|
||||||
|
def classify_undervolt(now, since_boot, prev_level):
|
||||||
|
"""crit while actively under-volted; warn (sticky until reboot) once a dip
|
||||||
|
has been latched, so a transient still gets one page + a yellow pill."""
|
||||||
|
if now is None:
|
||||||
|
return prev_level
|
||||||
|
if now:
|
||||||
|
return "crit"
|
||||||
|
return "warn" if since_boot else "ok"
|
||||||
|
|
||||||
|
|
||||||
def classify(temp, spec, prev_level):
|
def classify(temp, spec, prev_level):
|
||||||
"""Level with hysteresis: step up at the threshold, step down only after
|
"""Level with hysteresis: step up at the threshold, step down only after
|
||||||
dropping clear_margin below it, so a sensor on the line doesn't oscillate."""
|
dropping clear_margin below it, so a sensor on the line doesn't oscillate."""
|
||||||
@@ -168,7 +252,7 @@ def classify(temp, spec, prev_level):
|
|||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
def announce(name, temp, old, new):
|
def announce(name, disp, old, new):
|
||||||
"""Log a level change to the journal. Returns the crossing direction
|
"""Log a level change to the journal. Returns the crossing direction
|
||||||
("rising" / "falling") so the caller can decide whether to page via
|
("rising" / "falling") so the caller can decide whether to page via
|
||||||
Pushover, or None when the level is unchanged."""
|
Pushover, or None when the level is unchanged."""
|
||||||
@@ -177,7 +261,7 @@ def announce(name, temp, old, new):
|
|||||||
rising = LEVELS.index(new) > LEVELS.index(old)
|
rising = LEVELS.index(new) > LEVELS.index(old)
|
||||||
sev = {"crit": "crit", "warn": "warn", "ok": "info"}[new]
|
sev = {"crit": "crit", "warn": "warn", "ok": "info"}[new]
|
||||||
arrow = "rose to" if rising else "fell back to"
|
arrow = "rose to" if rising else "fell back to"
|
||||||
log(f"{name} {arrow} {new.upper()} ({temp:.1f}°C)", sev if rising else "info")
|
log(f"{name} {arrow} {new.upper()} ({disp})", sev if rising else "info")
|
||||||
return "rising" if rising else "falling"
|
return "rising" if rising else "falling"
|
||||||
|
|
||||||
|
|
||||||
@@ -189,20 +273,39 @@ def rotate_log(path, max_bytes):
|
|||||||
log(f"log rotate failed: {e}", "warn")
|
log(f"log rotate failed: {e}", "warn")
|
||||||
|
|
||||||
|
|
||||||
|
def csv_columns(s):
|
||||||
|
unit = {"temp": "c", "fan": "rpm", "undervolt": "uv"}.get(s.get("kind", "temp"), "v")
|
||||||
|
return [f"{s['name']}_{unit}", f"{s['name']}_lvl"]
|
||||||
|
|
||||||
|
|
||||||
|
def csv_value(r):
|
||||||
|
kind = r.get("kind", "temp")
|
||||||
|
if kind == "fan":
|
||||||
|
return "" if r["rpm"] is None else str(r["rpm"])
|
||||||
|
if kind == "undervolt":
|
||||||
|
return "" if r["now"] is None else str(int(r["now"]))
|
||||||
|
return "" if r["temp"] is None else f"{r['temp']:.1f}"
|
||||||
|
|
||||||
|
|
||||||
def write_csv(cfg, readings):
|
def write_csv(cfg, readings):
|
||||||
path = Path(cfg["log_path"])
|
path = Path(cfg["log_path"])
|
||||||
rotate_log(path, cfg["log_max_bytes"])
|
rotate_log(path, cfg["log_max_bytes"])
|
||||||
new = not path.exists()
|
header = ",".join(["time"] + sum([csv_columns(s) for s in cfg["sensors"]], []))
|
||||||
try:
|
try:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if path.exists():
|
||||||
|
with open(path) as f:
|
||||||
|
if f.readline().rstrip("\n") != header:
|
||||||
|
# Sensor set changed — rotate so columns stay aligned with the header.
|
||||||
|
path.replace(path.with_suffix(path.suffix + ".1"))
|
||||||
|
new = not path.exists()
|
||||||
with open(path, "a") as f:
|
with open(path, "a") as f:
|
||||||
if new:
|
if new:
|
||||||
f.write(",".join(["time"] + sum(
|
f.write(header + "\n")
|
||||||
[[f"{s['name']}_c", f"{s['name']}_lvl"] for s in cfg["sensors"]], [])) + "\n")
|
|
||||||
row = [datetime.now(timezone.utc).isoformat(timespec="seconds")]
|
row = [datetime.now(timezone.utc).isoformat(timespec="seconds")]
|
||||||
for s in cfg["sensors"]:
|
for s in cfg["sensors"]:
|
||||||
r = readings[s["name"]]
|
r = readings[s["name"]]
|
||||||
row += ["" if r["temp"] is None else f"{r['temp']:.1f}", r["level"]]
|
row += [csv_value(r), r["level"]]
|
||||||
f.write(",".join(row) + "\n")
|
f.write(",".join(row) + "\n")
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
log(f"csv write failed: {e}", "warn")
|
log(f"csv write failed: {e}", "warn")
|
||||||
@@ -233,31 +336,56 @@ def main():
|
|||||||
while True:
|
while True:
|
||||||
readings = {}
|
readings = {}
|
||||||
for s in cfg["sensors"]:
|
for s in cfg["sensors"]:
|
||||||
|
name, kind = s["name"], s.get("kind", "temp")
|
||||||
|
old = levels[name]
|
||||||
|
|
||||||
|
if kind == "fan":
|
||||||
|
rpm, pwm = read_fan(s, cache)
|
||||||
|
new = classify_fan(rpm, pwm, old)
|
||||||
|
disp = None if rpm is None else f"{rpm} RPM"
|
||||||
|
detail = (f"fan turning at {rpm} RPM (pwm {pwm}/255)." if new == "ok" else
|
||||||
|
f"fan reads 0 RPM while commanded on (pwm {pwm}/255) — "
|
||||||
|
"stalled, blocked, or unplugged.")
|
||||||
|
reading = {"kind": kind, "rpm": rpm, "pwm": pwm}
|
||||||
|
elif kind == "undervolt":
|
||||||
|
uv_now, uv_boot = read_undervolt(s, cache)
|
||||||
|
new = classify_undervolt(uv_now, uv_boot, old)
|
||||||
|
disp = None if uv_now is None else (
|
||||||
|
"UNDERVOLTAGE" if uv_now else
|
||||||
|
"dip since boot" if uv_boot else "supply ok")
|
||||||
|
detail = {"crit": "supply voltage below threshold right now — check PSU and cabling.",
|
||||||
|
"warn": "an undervoltage dip was latched since boot (supply ok now; "
|
||||||
|
"latch clears on reboot).",
|
||||||
|
"ok": "supply voltage ok."}[new]
|
||||||
|
reading = {"kind": kind, "now": uv_now, "since_boot": uv_boot}
|
||||||
|
else:
|
||||||
temp = read_temp(s, cache)
|
temp = read_temp(s, cache)
|
||||||
new = classify(temp, s, levels[s["name"]])
|
new = classify(temp, s, old)
|
||||||
if temp is not None:
|
disp = None if temp is None else f"{temp:.1f}°C"
|
||||||
old = levels[s["name"]]
|
|
||||||
direction = announce(s["name"], temp, old, new)
|
|
||||||
if direction == "rising" and LEVELS.index(new) >= alert_idx:
|
|
||||||
icon = "🔥" if new == "crit" else "🌡"
|
|
||||||
thr = s["crit"] if new == "crit" else s["warn"]
|
thr = s["crit"] if new == "crit" else s["warn"]
|
||||||
pushover(cfg, f"{icon} {HOST}: {s['name']} {new.upper()} {temp:.1f}°C",
|
detail = (f"below warn ({s['warn']}°C)." if new == "ok" else
|
||||||
f"{s['name']} temperature {temp:.1f}°C crossed {new.upper()} "
|
f"crossed the {new.upper()} threshold ({thr}°C); "
|
||||||
f"threshold ({thr}°C). warn {s['warn']}, crit {s['crit']}.",
|
f"warn {s['warn']}, crit {s['crit']}.")
|
||||||
|
reading = {"kind": kind, "temp": None if temp is None else round(temp, 1),
|
||||||
|
"warn": s["warn"], "crit": s["crit"]}
|
||||||
|
|
||||||
|
if disp is not None:
|
||||||
|
direction = announce(name, disp, old, new)
|
||||||
|
if direction == "rising" and LEVELS.index(new) >= alert_idx:
|
||||||
|
icon = ICONS.get(kind, "🔥" if new == "crit" else "🌡")
|
||||||
|
pushover(cfg, f"{icon} {HOST}: {name} {new.upper()} — {disp}",
|
||||||
|
f"{name}: {detail}",
|
||||||
priority=1 if new == "crit" else 0)
|
priority=1 if new == "crit" else 0)
|
||||||
elif direction == "falling" and LEVELS.index(old) >= alert_idx:
|
elif direction == "falling" and LEVELS.index(old) >= alert_idx:
|
||||||
# Recovery: page only when leaving a level we'd have paged about,
|
# Recovery: page only when leaving a level we'd have paged about,
|
||||||
# so the phone that got the rising alert also gets the all-clear.
|
# so the phone that got the rising alert also gets the all-clear.
|
||||||
label = "NORMAL" if new == "ok" else new.upper()
|
label = "NORMAL" if new == "ok" else new.upper()
|
||||||
pushover(cfg, f"✅ {HOST}: {s['name']} back to {label} {temp:.1f}°C",
|
pushover(cfg, f"✅ {HOST}: {name} back to {label} — {disp}",
|
||||||
f"{s['name']} temperature {temp:.1f}°C dropped back to {label} "
|
f"{name}: {detail}", priority=0)
|
||||||
f"(warn {s['warn']}, crit {s['crit']}).",
|
|
||||||
priority=0)
|
levels[name] = new
|
||||||
levels[s["name"]] = new
|
reading["level"] = new
|
||||||
readings[s["name"]] = {
|
readings[name] = reading
|
||||||
"temp": None if temp is None else round(temp, 1),
|
|
||||||
"level": new, "warn": s["warn"], "crit": s["crit"],
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
write_state(readings)
|
write_state(readings)
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Temperature monitor (CPU + NVMe) for the campervan router
|
Description=Thermal + health monitor (temps, fan, undervoltage) for the campervan router
|
||||||
After=local-fs.target
|
# Order after network-online so a boot-time threshold crossing (common: the Pi
|
||||||
|
# boots hot) can actually reach Pushover — the very first sample fires within
|
||||||
|
# seconds of start. If no WAN comes up, wait-online times out and we start anyway.
|
||||||
|
After=local-fs.target network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
|||||||
Reference in New Issue
Block a user