Compare commits

46 Commits
Author SHA1 Message Date
Andreas WredeandClaude Sonnet 5 918003d2e3 ha: add carefree_bt12 integration for the Carefree Connects awning
Commands reverse-engineered 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 live tests:
extend/retract (feature 0x05, values 0x01/0x02 -- the device toggles motor
state internally, there's no separate stop byte) and light on/off (feature
0x1a, values 0x19/0x01). Both live under GATT characteristic 02060002 on
service 02060001-50e1-405f-bab0-6bb582b4d96e.

Connects on demand per command (mirrors the app's own connect/act/disconnect
pattern) rather than holding a persistent connection like li3_battery, since
this device doesn't stream telemetry. Cover/light state is assumed/optimistic
-- the notify channel (02060003) isn't decoded yet, so a diagnostic sensor
just surfaces raw undecoded replies to build up data for that follow-on work.

Verified end-to-end against the real device: light on/off and awning
extend both worked through Home Assistant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:52:06 -04:00
Andreas Wrede 7911339743 changed devices for Starlink and 2.4GHz AP 2026-08-24 14:37:07 -04:00
Andreas WredeandClaude Sonnet 5 d25f48727f ha: add li3_battery HA integration, replacing van-li3-battery
The li3 BMS has a persistently marginal BLE link from any single fixed
vantage point on this Pi (onboard adapter, a USB dongle that turned out to
be BR/EDR-only, and the Athom ESPHome BT proxy even after moving it closer).
Move capture into HA proper so bluetooth.async_ble_device_from_address can
pick whichever known source currently has the device, instead of hardcoding
one. Protocol parsing ported verbatim from li3/van-li3-battery.

van-li3-battery is disabled on the Pi; its MQTT discovery entities were
cleared.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 11:49:44 -04:00
Andreas WredeandClaude Sonnet 5 94e6a2d811 failover: tolerate rsync's benign vanished-file exit code in backup syncs
Both sync-sd-backup.sh and sync-usb-backup.sh used set -e, so rsync
returning exit 24 (partial transfer due to vanished source files --
expected on a live root, e.g. a container's shm socket disappearing
mid-sync) aborted the script before it printed its own success message,
even though the actual file transfer completed fully. Only exit 24 is
now tolerated; any other rsync failure still aborts as before.

Found running sync-sd-backup.sh today: it transferred all 919k files
correctly but exited non-zero and skipped its completion line.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJfEELeh3ercpRBp8yYrYS
2026-08-23 15:55:38 -04:00
Andreas WredeandClaude Sonnet 5 5c793186ef ap: switch 5GHz driver to out-of-tree morrownr/rtw89 for USB3 support
The in-kernel rtw89_8852bu on this Ubuntu kernel base predates mainline's
USB2->3 auto-switch for this chip, permanently capping the AP dongle at
USB2/480M. morrownr/rtw89 (dkms) has that switch. ap/rtw89.conf now
blacklists the in-kernel rtw89 modules and tunes the replacement
(disable_ps_mode + switch_usb_mode); ap/install-rtw89-driver.sh builds
and installs it, pinned to a specific upstream commit, kept separate
from deploy.sh since a dkms rebuild is too slow to run on every deploy.

Verified live: negotiates USB3/5000M on a dedicated USB3 controller
(480M on a USB2-only one, as expected), AP recovered via hostapd's
Restart=always + van-ap-watchdog with no manual intervention. Hit and
documented one real gotcha along the way: in-kernel rtw89_core refused
to unload while its own dependents (rtw89_8852b, rtw89_8852b_common)
were still loaded, which blocked the new module with a duplicate-symbol
error until all in-kernel modules were removed first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJfEELeh3ercpRBp8yYrYS
2026-08-23 15:42:23 -04:00
Andreas WredeandClaude Sonnet 5 6999b2d12b failover: add sync-usb-backup.sh, the reverse of sync-sd-backup.sh
Refreshes the USB disk (sda2) as a live boot-fallback clone of the
running root, same self-sync guard/excludes/rsync flags as the SD
script. Fixed copy-paste leftovers from sync-sd-backup.sh (SD_ROOT_PART,
mount point, and log message all still said "SD").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJfEELeh3ercpRBp8yYrYS
2026-08-23 14:53:31 -04:00
Andreas WredeandClaude Sonnet 5 a99a39a859 ap: fix driver_name() silently caching wrong value on startup race
Path.resolve() doesn't raise on a nonexistent path — it just returns the
syntactic path unchanged — so when driver_name() ran before the radio's
netdev had enumerated (startup race with USB re-enum), it silently
returned the literal string "driver" instead of None. The loop's retry
guard (`if driver is None: retry`) never fired since "driver" is truthy,
so the bogus value was cached for the service's entire uptime and the
queue-flush wedge regex could never match real driver names — the 3rd
check added 2026-08-19 was silently inert. Fixes 2026-08-23 recurrence
where the 5GHz SSID went invisible and hostapd never got restarted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJfEELeh3ercpRBp8yYrYS
2026-08-23 14:49:27 -04:00
Andreas WredeandClaude Sonnet 5 db22cb50d6 modem: add van-sms-send CLI for sending SMS ad hoc
mmcli's inline "number=...,text=..." properties string breaks on plain
spaces/quotes/colons in the text — found while testing van-sms-watch.
Sidesteps that entirely by passing the message via
--messaging-create-sms-with-text=<tempfile> instead. Tested live
(multi-word text, punctuation, multi-line stdin input all delivered).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 15:40:22 -04:00
Andreas WredeandClaude Sonnet 5 a74257e44b modem: fix executable bit on van-sms-watch
Was checked in 100644, inconsistent with the other repo scripts
(deploy.sh's install -D -m0755 masked this in practice).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 15:13:06 -04:00
Andreas WredeandClaude Sonnet 5 d002058701 modem: add van-sms-watch to archive inbound SMS + Pushover alert
Polls ModemManager for SMS on the EC25's own flash storage, appends each
inbound message to /var/log/van-sms.jsonl, pages via Pushover (shared
van-battery creds), then deletes it from modem storage so it doesn't
silently fill up. MMS can't be decoded (WAP-push notification needs the
carrier's separate MMS APN) — detected and paged as notification-only.

Wired into deploy.sh; installed/verified live on the Pi (backlog SMS
archived + paged successfully) ahead of committing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 15:12:54 -04:00
Andreas Wrede e5e776405a li3: decode status flags, fix info-sensor expire_after, restart bluetooth after first failed scan
- status_text: decode the 24-bit "status" field into human-readable flag
  names (e.g. "Cell Temp Low, Low Voltage") instead of raw hex, using the
  bit table pulled from com.lithionics.bms's array/advanced resource (dumped
  with aapt -- the app's own StatusCodeTable class references stale/wrong
  resource IDs and can't be trusted for this). Applied to "status" only;
  last_fault_code is a lifetime latch that accumulates many bits over time
  and isn't meaningfully summarized the same way.

- Fixed the info sensors (total_consumed, firmware_version, serial_number,
  etc.) showing "Unavailable" in HA a minute after connecting: they were
  getting the same expire_after as the periodic telemetry sensors, but the
  $info line the come from is only sent once per BLE connection, so HA's
  expire timer always fired. These are retained-topic diagnostics meant to
  hold their last value indefinitely; expire_after now only applies to the
  periodic SENSORS group.

- STUCK_DISCOVERY_THRESHOLD 3 -> 1: restart bluetooth.service after the
  first failed scan instead of waiting for three, cutting reconnect time
  roughly in third. Still rate-limited via BLUETOOTH_RESTART_COOLDOWN_S.

Also removed the per-line "RAW ..." debug print (was flooding the journal).

All three changes verified live on host wan via journalctl/mosquitto_sub.
2026-08-18 10:18:56 -04:00
Andreas Wrede 382f99b508 li3: publish CAN-trace fields with telemetry, info fields as separate retained topic
Decoded the "&" and "$" line formats from log1 by matching them against the
decompiled com.lithionics.bms app: TraceFormat/InfoFormat gate on the raw
line's first byte ('&'=trace, '$'=info) before BmsSeries.create() ever sees
the row, which is why the earlier decode (MainBmsCsParameters) never touched
them.

"&" trace lines stream continuously (enabled by our own $traceon) and carry
CAN-charger-bus fields: remaining_capacity, remaining_time, can_charger_
voltage/current, can_charger_status, can_status. These merge into the
existing periodic state message alongside the Cs telemetry.

"$" info is sent once per connection (response to $info) and never repeats:
total_consumed, last_fault_code, highest/lowest_recorded_temp, firmware_
version, aging_factor_temp/soc, serial_number. Published to its own retained
topic (van/<device_id>/info) instead of the periodic one, with its own
discovery config (entity_category: diagnostic) — so HA keeps the last known
value across BMS disconnects rather than expiring it.

Deployed and verified live on host wan: info message publishes once on
connect, state messages carry the merged fields, and 14 new HA entities
registered with correct precision.
2026-08-18 09:56:32 -04:00
Andreas Wrede 7eab5ed422 fix: adjust precision of temperatures and current 2026-08-18 07:22:45 -04:00
Andreas Wrede 7a1c47d34c li3: add Lithionics Li3 house battery BMS -> Home Assistant MQTT
Publishes the RV's 12V LiFePO4 house battery to HA via MQTT discovery
(pack voltage, 4 cell voltages, current, SOC, BMS/battery temp, status).
Connects over the battery's BLE HM-10 UART module (service ffe0/char
ffe1, no pairing) using bleak; protocol reverse-engineered from the
com.lithionics.bms Android app's own BLE/parsing code.

Deliberately named li3/ and van-li3-battery, not battery/van-battery —
that name is reserved for the host's own AC/UPS power-supply monitor
(different hardware, unrelated concern).

Self-heals a bluetoothd discovery-state wedge (Discovering stuck "yes",
connects failing with le-connection-abort-by-local) that shows up after
repeated failed connects to this device on the Pi's onboard adapter: the
daemon retries scan/connect internally (MQTT session and HA entities
stay up across retries) and restarts bluetooth.service itself after 3
consecutive scan failures, rate-limited to once per 5 min.
2026-08-17 17:26:14 -04:00
Andreas WredeandClaude Sonnet 5 b7758c9cd5 ha: add Frigate NVR as a Podman Quadlet, wire into deploy.sh
Same sibling-container pattern as HA/ESPHome. The hand-written unit used
Privileged=true, which isn't a real Quadlet key — the generator silently
drops the whole file on an unsupported key, so frigate.service was never
generated (hence `systemctl enable frigate` saying the unit doesn't exist).
Fixed with PodmanArgs=--privileged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 21:12:45 -04:00
Andreas Wrede a4c03496c0 add: don't be so quiet 2026-08-12 07:04:51 -04:00
Andreas Wrede 584f2e0b82 match font to rest of cockpit 2026-08-07 09:13:12 -04:00
Andreas WredeandClaude Sonnet 5 b0283f6083 failover: fix enforce_route() to handle gateway-less WANs (cellular)
The EC25/Koodo GSM connection is QMI raw-ip with an on-link /29 and no
gateway at all (nh 0.0.0.0), not a normal DHCP WAN with a temporarily
unknown gateway. enforce_route() required a gw and silently gave up
when none was found, so cellular could be reported up/healthy/preferred
in state.json while never actually getting a default route (Prefer
button had no effect since it only touches metric, not this bailout).
Now falls back to installing a gateway-less `ip route ... dev DEV`
default when the device genuinely has an IPv4 address (carrier truly
up), mirroring what NM itself would install without never-default.
Also fixes stale-route pruning to handle gateway-less old routes,
which the previous gw-only check skipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 15:10:21 -04:00
Andreas WredeandClaude Sonnet 5 6d5184e2e7 failover: ipv4.never-default + per-WAN probe scheduling; tune probe cadence
van-failover is now the sole owner of default-route selection: netplan/NM
profiles get ipv4.never-default so NM's own DHCP/lease renewals stop
reinstalling competing default routes (was racing van-failover's enforce_route
on Starlink's 16s lease). set_profile_metric() also pins never-default on
NM-managed connections it doesn't own the netplan source for (e.g. cellular),
and nm_gateway() falls back to the raw DHCP4 lease's `routers` option since NM
stops populating IP4.GATEWAY once never-default is set.

Probing moved from one shared round to independent per-WAN retry scheduling,
so a flaky WAN retries on its own clock instead of dragging healthy WANs into
extra probes or throttling a failing one to the slow steady-state cadence.

config.json: probe_interval 4->60, fail/ok_threshold 3/2->2/1, single probe
URL — verified live across this reboot (cellular took ~2min after NM reported
"activated" to actually pass traffic; van-failover correctly withheld/
deprioritized the default route until then, then installed it automatically).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 14:22:46 -04:00
Andreas WredeandClaude Sonnet 5 21c8870c0d failover: add sync-sd-backup.sh — script the post-push SD fallback refresh
The SD card (mmcblk0p2) is refreshed as a live boot-fallback clone of
NVMe root after pushes, but that rsync was being hand-typed each time
and its exclude list didn't cover /etc/fstab. That let it clobber the
SD's own fstab with the NVMe's PARTUUIDs, breaking SD-only boot (only
mounts correctly if the NVMe happens to also be present). Also excludes
/etc/machine-id and /var/log so the clone doesn't inherit the NVMe's
identity/journal history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 20:28:51 -04:00
Andreas WredeandClaude Sonnet 5 af18dc0755 failover: wire up van-modem-watch in deploy.sh
Completes the van-modem-usb-kick -> van-modem-watch swap from the
previous commit: deploy.sh was still installing/enabling the old unit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 20:28:46 -04:00
Andreas WredeandClaude Sonnet 5 3169297148 failover: replace van-modem-usb-kick with van-modem-watch (detect-only, no recovery)
Hub power-cycle recovery was proven not to work — only a physical
unplug/replug clears a cold-boot enumeration failure. Modem moved to the
Pi's native USB port as the actual fix; this just pages via Pushover if
it recurs instead of attempting a recovery that doesn't work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 20:28:21 -04:00
Andreas WredeandClaude Sonnet 5 a1bad186b1 failover: add van-modem-usb-kick — recover EC25 modem when it never enumerates at boot
Seen after moving the modem onto the new powered UGreen hub: the port
sometimes never signals a connect during boot (no descriptor-read attempt at
all in dmesg), so the hub never rescans it. Unbind/rebinding the inner hub
chip reproduces the same connect edge a physical unplug/replug does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 10:17:36 -04:00
Andreas Wrede e7a82126f2 power: add van-nvme-watch — pages on NVMe I/O-timeout/reset recurrence
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
(and recurred 2026-08-04, that time self-healing). Watches a short grace
window to tell a clean self-heal from an escalation (repeated timeout or a
following ext4 error) before paging via the existing Pushover credentials,
with a live SMART/superblock snapshot in the alert body.
2026-08-04 08:44:53 -04:00
Andreas Wrede 86fa9509df failover: fix Starlink interface + template config.json from deploy.conf
STARLINK_IFACE pointed at the wrong device after the USB hub swap (was
accidentally set to the 5GHz AP dongle's interface). failover/config.json
also hardcoded the old interface name instead of being templated like the
other configs, so deploy.conf changes never reached it — switched it to
@STARLINK_IFACE@ and deploy.sh now renders it.
2026-08-04 08:35:18 -04:00
Andreas Wrede 1d3a053f5d set mqtt msgs to retain 2026-08-02 17:09:51 -04:00
Andreas WredeandClaude Sonnet 5 8efb8dba5f dns: fixed 1.1.1.1/8.8.8.8 lockdown + scoped mDNS, esphome sibling container
- deploy.conf: DNS_RESOLVERS, always 1.1.1.1/8.8.8.8, never a WAN's own
  DHCP/RA-provided servers (previously whatever Wapana handed out).
- ap/99-van-router-dns.conf: global resolved config (fixed DNS, Domains=~.,
  global MulticastDNS=yes — a prerequisite for any per-link mDNS to work at
  all, not just an on/off toggle).
- failover/60-van-wan-dns: NM dispatcher that strips each WAN's DNS/search-
  domain and disables its mDNS via resolvectl on every connect/lease event
  (NM's own ipv4/ipv6.ignore-auto-dns can't be set as a config-file default —
  confirmed rejected as an unknown key — so this enforces it directly
  instead), retried over ~5s to beat NM's own async DNS commit. Also logs
  what each WAN advertised, never used, to /run/van-wan-dns/.
- ap/21-van-br0.network: MulticastDNS=yes, scoped to the van's own LAN only
  — .local/mDNS now resolves for ESPHome and other LAN devices without
  leaking mDNS onto Wapana/Starlink/cellular.
- dns/: ZeroTier-managed DNS (zt.wrede.pvt) made reproducible — installed
  the official zerotier-systemd-manager package (verified against upstream
  checksums), additive to the above so *.zt.wrede.pvt keeps resolving over
  the overlay independent of WAN.
- ha/esphome.container: ESPHome dashboard as a sibling Podman Quadlet to
  Home Assistant, same host-network/config-volume pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 16:09:01 -04:00
Andreas WredeandClaude Sonnet 5 5d88e1b30c deploy: hardware-instance templating, wlan0 boot watchdog, deploy-time warnings in Cockpit
- deploy.conf templates interface names/USB IDs (@TOKEN@ substitution) across
  ap/* configs so a dongle swap only needs deploy.conf edited, not the repo
  configs themselves; drops ap/rtw88.conf (old 2.4GHz dongle retired for the
  DWA-171, which needs no such power-save override).
- failover/van-wlan-watchdog: recovers wlan0 from NM's post-boot no-secrets
  wedge (a boot-time supplicant race, not a real credential failure).
- deploy.sh: warn() collects dependency/config warnings (missing python3-gps,
  python3-paho-mqtt, mobile-broadband-provider-info, grpcurl, gpsd; netplan
  drift; unedited example configs) into /var/lib/vanlink/deploy-warnings.json,
  rendered as an amber Cockpit card so they're visible without reading deploy
  output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 11:25:11 -04:00
Andreas WredeandClaude Fable 5 8d5415f326 gps: modem GNSS -> gpsd -> OwnTracks/MQTT publisher
EC25-AF GNSS wiring (udev hotplug into gpsd) plus van-gps-owntracks, a
port of the wayback-era gps_to_owntracks.py: apt-only deps (python3-gps
instead of pip-only gpsdclient, paho 2.x callback API), broker secrets
moved out of the code into /etc/van-gps/config.json (0600, seeded from
a sanitized example), gpsd host now localhost. client_id is vanq-wan —
a legacy client still holds vanq on the broker and shared IDs get
kicked in a connect/disconnect loop. Exit-on-disconnect + systemd
Restart=always is the reconnect logic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 22:03:40 -04:00
Andreas WredeandClaude Fable 5 d2a2b19b85 cockpit: cellular WAN row with signal + Restart button
Include the gsm device in the WAN/Uplinks table, shown as its routed
netdev with IP and metric (nmcli actions still target cdc-wdm0). New
Signal column fed by mmcli -K in the single status spawn (percent, tech,
operator). Restart = mmcli disable/enable — the EC25 MBIM plugin doesn't
support --reset — with a USB unbind/bind fallback (vendor 2c7c) when
ModemManager can't reach the modem. README: modem setup steps (Koodo
profile creation, MBIM netdev gotcha, Cockpit row).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 17:28:22 -04:00
Andreas WredeandClaude Fable 5 fe9e6d3b30 failover: resolve MBIM/QMI modems to their wwan netdev
NM reports a gsm connection's device as the control port (cdc-wdm0), but
IP + routes live on the wwan netdev (wwu1u2i4 on the Quectel EC25-AF), so
probes and route enforcement silently saw nothing (cellular stuck "down",
no metric). Map connection-keyed WANs through GENERAL.IP-IFACE, and let
the gateway fallback query the connection since the netdev isn't an NM
device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 17:28:12 -04:00
Andreas WredeandClaude Fable 5 7c36757d35 sysctl: correct the use_tempaddr comment with the real root cause
The rotating privacy address was not itself the packet-loss mechanism:
the 2026-07-13 discrimination experiment (4 flows, dual-ended capture)
showed the loss keyed to hbc's exact 5-tuple — upstream per-flow state
poisoned when the flow was created during boot/apply address churn, kept
alive forever by the 10s heartbeat cadence. Fresh flows to the same
host/port were clean; restarting hbc fixed it instantly. use_tempaddr=0
stays: it removes one source of the churn that poisons newborn flows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuPg8vz6FmDUj4SgEJ525C
2026-07-13 13:13:08 -04:00
Andreas WredeandClaude Sonnet 5 09259eb6cc sysctl: force use_tempaddr=0 to beat Ubuntu's 55-ipv6-privacy.conf default
Root cause of the flow-selective IPv6 heartbeat loss investigated
2026-07-12/13: Ubuntu ships /usr/lib/sysctl.d/55-ipv6-privacy.conf with
use_tempaddr=2 system-wide. Nothing on wlan0 overrode it before this
morning's netplan fix, so the interface carried a rotating privacy
address alongside the stable one. hbc's long-lived flow eventually
straddled a temp-address deprecation event mid-flight, which looked
exactly like random upstream packet loss (in-transit, IPv6-only,
flow-selective) and cost a day of packet captures before the cause
turned out to be this default fighting the WAN config instead.

The netplan/NM ipv6.ip6-privacy=false fix already forces this per
connection, but that's a timing-dependent override racing a package
default. This makes it explicit and permanent: 99- loads after (and
wins over) 55- the next time sysctl --system runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 12:06:33 -04:00
Andreas WredeandClaude Sonnet 5 daa545f15c deploy.sh: warn on netplan drift between repo and /etc/netplan
The 07-12 IPv6 fix sat in ap/50-van-wan.yaml for a day without ever being
copied to /etc/netplan/ or applied — deploy.sh deliberately skips netplan
(applying it flaps the uplinks) so nothing caught the gap. Now it diffs
the two and prints the manual deploy commands when they disagree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 11:04:19 -04:00
andreas 4aae62372e deument last change 2026-07-12 12:22:45 -04:00
Andreas WredeandClaude Fable 5 776f020af3 cockpit: unified Clients card (Wi-Fi + wired via bridge FDB)
New "Clients" card lists everything on the van LAN in one table — hostname,
IP, MAC, connection (5GHz / 2.4GHz / LAN eth0 / LAN USB), signal, TX rate.
Wi-Fi rows come from the hostapd station dumps as before; wired rows from
learned bridge-FDB entries on the LAN ports (the port's own MAC is
"permanent", and FDB duplicates entries per vlan — both filtered), enriched
with IP/hostname from dnsmasq leases + neighbor table.

The per-band station tables move out of the Access Points card (which keeps
its status line + client count), and the WAN/Uplinks table now hides
unmanaged devices so the networkd-owned LAN ports don't show up as ghost
WAN rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 07:44:57 -04:00
Andreas WredeandClaude Fable 5 a36b64c7c4 lan: repurpose onboard eth0 as an internal LAN port on br0
With wifi + Starlink + cellular as uplinks, the onboard GbE earns its keep
as a wired LAN port instead of the metric-150 ethernet WAN: eth0 leaves
netplan/NM (added to van-ap-unmanaged.conf) and joins br0 via networkd
(23-van-lan-eth0.network), so wired clients get 10.42.0.x DHCP/DNS and NAT
exactly like Wi-Fi clients. The eth WAN is gone from van-failover's config.

Cutover notes: delete the old netplan-eth0 / stray eth0 NM profiles, then
nmcli general reload + networkctl reload/reconfigure eth0. NEVER cable this
port (or the USB LAN dongle) back into an upstream LAN — dnsmasq on br0
would serve rogue DHCP there; STP stays off, so don't cable both wired LAN
ports to the same switch either.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 07:44:44 -04:00
Andreas WredeandClaude Fable 5 751facd511 starlink: dish route + status card on the Van Router page
The Starlink RTL8153 gets a declarative NM profile in 50-van-wan.yaml
(replaces the auto 'Wired connection 1') with a 192.168.100.1/32 link route,
so the dish's management address stays reachable from the router and — via
the existing !br0 masquerade — from the van LAN, regardless of which WAN
holds the default route.

New Cockpit "Starlink" card queries the dish's gRPC API (:9200, get_status
via grpcurl — not packaged in apt, deploy.sh warns when missing) inside the
existing single batched spawn: online/obstructed/outage pill, alerts,
uptime, sw version, PoP latency, down/up throughput, obstruction %.
Sentinels distinguish adapter-absent / no-grpcurl / dish-unreachable.

Gotcha captured while cutting over: a WAN profile without ipv4.route-metric
makes NM re-assert its DHCP default (metric ~101) against van-failover's
enforce_route pruning every cycle; the profile metric must match (failover's
set_profile_metric does this on health transitions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 07:06:43 -04:00
Andreas WredeandClaude Fable 5 2da97d26f4 thermal: watch NVMe/RP1 temps, fan stall, and undervoltage on the Pi 5
van-thermal grows sensor kinds beyond plain temperatures: "fan" alerts when
pwmfan is commanded on (pwm > 0) but reads 0 RPM (warn on first sample, crit
if it persists — 0 RPM with pwm 0 is just the firmware idling a cool SoC),
and "undervolt" goes crit on the live rpi_volt alarm plus a sticky warn off
the firmware's latched since-boot bit, so dips shorter than the 10s sample
interval still surface once. All kinds share the existing hysteresis /
journal / Pushover machinery (fan 🌀, undervolt ).

Config adds nvme Composite (65/70), rp1_adc (80/85 — the RP1 die drives all
USB/eth I/O), fan, and undervolt to the existing cpu sensor. The CSV logger
now derives per-kind columns and self-rotates when the header changes; the
Cockpit Temps card renders RPM + pwm duty and undervoltage state alongside
the temperatures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 06:41:17 -04:00
Andreas WredeandClaude Fable 5 6fe683000d thermal+ha: don't lose boot-time pages; stop HA gracefully
van-thermal fires its first sample seconds after start, and the Pi often
boots hot — order after network-online.target and retry network failures
in the Pushover send path (3 attempts, 15s apart) so that page survives
DNS not being up yet. Non-200 responses still don't retry.

The Quadlet gets StopTimeout=120: podman's default 10s window SIGKILLed
HA mid-flush and the recorder complained about an unclean sqlite shutdown
on every start. Verified clean after a full stop/start cycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:06:16 -04:00
Andreas WredeandClaude Fable 5 3add42a0cb ha: replace the HAOS VM with a native Podman Quadlet container
The fixed 2 GiB ha_van allocation starved the 4 GB Pi. HA Container now
runs on the host network (http://10.42.0.1:8123): ha/homeassistant.container
installs to /etc/containers/systemd/, config in /srv/homeassistant, host
D-Bus mounted for onboard Bluetooth (needs apparmor=unconfined — Ubuntu's
dbus-daemon mediates per AppArmor label and denies AddMatch to BlueZ —
plus NET_ADMIN/NET_RAW for habluetooth adapter recovery).

Drop the VM-era plumbing: the 10.42.0.50 dhcp-host pin becomes a
host-record for 10.42.0.1, and the legacy-URL DNAT + hairpin masquerade
go away entirely. ha_van.xml retired to git history.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:46:30 -04:00
Andreas WredeandClaude Fable 5 8ab5a4cbd8 ha: fresh HAOS install + VNC console for the HAOS CLI
The backup-restore from the x86 instance left Core stopped with the
Supervisor idle and healthy-looking — no restart, download, or disk
activity, and it survived a guest reboot. The old config was minimal,
so: fresh 18.1 image, configure from scratch (old disk kept aside as
*.old-restored). Add VNC graphics + virtio-gpu while the domain was
down: the HAOS CLI runs only on the graphical console (no serial
getty), so without this there is no way into a broken guest — view it
via Cockpit's Virtual Machines page (cockpit-machines now installed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:16:17 -04:00
Andreas WredeandClaude Fable 5 c40662361f ha: move the Home Assistant VM onto the Pi as an aarch64 guest
The x86 ha_van stayed behind on wayback at the port (9bf1420 dropped
ha/ and its DNAT/lease); with the hub moved and the Pi owning
10.42.0.0/24, HA follows. New domain XML: machine=virt + AAVMF EFI,
virtio-scsi HAOS 18.1 aarch64 image, 2 GiB (data restored from an HA
full backup — the x86 qcow2 can't cross architectures). Same MAC, so
the dnsmasq pin (10.42.0.50 / "homeassistant") and the legacy
10.42.0.1:8123 DNAT + br0 hairpin port back verbatim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:19:56 -04:00
Andreas WredeandClaude Fable 5 7a92548114 cockpit: fix bridge EMFILE — batch status reads into one spawn, raise fd limit
The Python cockpit-bridge frees spawn-pipe fds only at GC time; the Van
Router page's ~12 cockpit.spawn calls every 5s saw-toothed the bridge to
its 1024-fd soft limit, so the polkit admin-escalation spawn failed with
"Too many files open" whenever it landed near a peak.

Two-sided fix:
- vanrouter.js gathers all read-only status in ONE `sh -c` spawn per
  refresh, sections delimited by @@vr:<name>@@ marker lines (~12x less
  pipe churn). Mutating actions unchanged.
- cockpit-session@.service drop-in raises LimitNOFILE to 65535
  (hard limit is 524288), installed by deploy.sh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:32:24 -04:00
Andreas WredeandClaude Fable 5 7066207333 deploy: tolerate absent AP radios (hub not yet plugged in)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:06:13 -04:00
Andreas WredeandClaude Fable 5 9bf142074a port to Pi 4 'wan': onboard eth0+wlan0 as NM WANs, AP stack verbatim
The USB hub (5GHz + 2.4GHz AP dongles, Starlink + LAN RTL8153s) moves over
from wayback; MAC-derived wlx*/enx* names travel with it, so hostapd/
networkd/cockpit configs are unchanged. Pi diffs only: failover WAN list
(wlan0 wifi 100, eth0 150, starlink USB 200, Koodo 300), cpu_thermal
sensor, bcm2835 watchdog 10s, no HA DNAT/lease, and deploy.sh drops
battery/lid/heartbeat/ZT-dns. Netplan reference in ap/50-van-wan.yaml.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:59:34 -04:00
86 changed files with 4416 additions and 734 deletions
+131 -22
View File
@@ -1,5 +1,11 @@
# 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
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 |
| WAN health + failover | **van-failover** 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 |
| Auto-reboot on hang | **systemd hardware watchdog** (`intel_oc_wdt`) |
| 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.
- Regulatory domain **CA** (unlocks 5GHz ch149161 @30dBm, no DFS).
- 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 |
| `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 |
| `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) |
### `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 |
| `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-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 |
| `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` |
@@ -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
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 |
|---|---|---|
| `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`
(`dhcp-host``10.42.0.50`, `homeassistant`) and the legacy-URL DNAT from `ap/nftables.conf`.
Not to be confused with `power/van-battery` — that's the host's own AC/battery power
supply (laptop UPS-style monitor), unrelated hardware and purpose.
### `ha/` — Home Assistant (native container)
| file | → installs to | purpose |
|---|---|---|
| `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).
- 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`)
- 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).
@@ -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.
### 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.
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
- `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`).
- **Prefer** sets the manual WAN preference (see *Manual preference* above); **Up/Down** connect/disconnect the NM device.
### Home Assistant VM (`ha_van`)
- HAOS runs as a libvirt KVM VM whose NIC is **bridged into `br0`** — it is a first-class LAN
device, not NAT'd behind libvirt's `virbr0`. VanLink + wired clients reach it directly at
`http://10.42.0.50:8123` (or `http://homeassistant:8123` / `homeassistant.local` via mDNS);
ZT clients route in via the ZT-managed `10.42.0.0/24` route. mDNS/SSDP device discovery works
because the VM shares the clients' L2 segment.
- The old NAT-era URL `http://10.42.0.1:8123` keeps working: nftables DNATs it to `10.42.0.50`,
with a hairpin masquerade for same-subnet clients (see comments in `ap/nftables.conf`).
- History: the VM used to sit on libvirt's `default` NAT net (`192.168.122.50`) with a
`/etc/libvirt/hooks/network` hook inserting FORWARD accepts above libvirt's REJECT. That broke
whenever libvirtd re-inserted its chains on restart (hook doesn't fire then) — bridging removed
the whole failure mode. The hook and the libvirt DHCP reservation are gone.
- Operate: `virsh {start,shutdown,domstate} ha_van`; autostart is per libvirt config. Verify:
`curl -s -o /dev/null -w '%{http_code}' http://10.42.0.50:8123/``200`.
### Home Assistant (native Podman container)
- HA Container runs on the host via a **Podman Quadlet** (`ha/homeassistant.container`
`/etc/containers/systemd/`; systemd generates `homeassistant.service`). **Host network**:
HA binds `:8123` directly, so clients use `http://10.42.0.1:8123` (or
`http://homeassistant:8123`); mDNS/SSDP discovery sees the LAN because there's no bridge
or NAT in the way. Config lives in `/srv/homeassistant`.
- **Bluetooth**: host BlueZ (`bluetooth.service`) serves the Pi's onboard `hci0` to HA over
the mounted `/run/dbus` socket — add the Bluetooth integration in HA and it appears.
- No add-on store (that was HAOS's Supervisor): Mosquitto/Zigbee2MQTT-style add-ons become
their own containers/services if ever needed.
- Operate: `systemctl {status,restart} homeassistant`. Update: bump/pull the image
(`podman pull ghcr.io/home-assistant/home-assistant:stable`) and restart. After editing
the `.container` file: `./deploy.sh` (or install + `systemctl daemon-reload`), then
`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)
- 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.
- **`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.
- **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.
+1 -1
View File
@@ -1,5 +1,5 @@
[Match]
Name=wlxc83a35a4ee55
Name=@WIFI_5G_IFACE@
[Link]
RequiredForOnline=no
+1 -1
View File
@@ -1,5 +1,5 @@
[Match]
Name=wlxd8ec5e2faa8c
Name=@WIFI_2G_IFACE@
[Link]
RequiredForOnline=no
+4 -3
View File
@@ -1,6 +1,7 @@
# LAN bridge: joins the AP Wi-Fi (added by hostapd, once it's in AP mode) and the
# wired LAN port (enx00e04c331140, added by networkd) into one 10.42.0.0/24 segment.
# STP off: only two member ports, no loops, and it avoids the forwarding delay that
# LAN bridge: joins the AP Wi-Fi radios (added by hostapd, once in AP mode) and the
# wired LAN ports (enx00e04c331140 + onboard eth0, added by networkd) into one
# 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.
[NetDev]
Name=br0
+4
View File
@@ -10,3 +10,7 @@ RequiredForOnline=no
[Network]
Address=10.42.0.1/24
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
+1 -1
View File
@@ -2,7 +2,7 @@
# 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.
[Match]
Name=enx00e04c331140
Name=@LAN_USB_IFACE@
[Link]
RequiredForOnline=no
+13
View File
@@ -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
+58
View File
@@ -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"
+28
View File
@@ -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
+10
View File
@@ -1 +1,11 @@
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
View File
@@ -1,4 +1,4 @@
interface=wlxd8ec5e2faa8c
interface=@WIFI_2G_IFACE@
# 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.
# 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
# it back to 20MHz near any neighbor anyway.
ieee80211n=1
ht_capab=[LDPC][SHORT-GI-20]
ht_capab=[SHORT-GI-20]
auth_algs=1
wpa=2
wpa_passphrase=1foot11foot11
+1 -1
View File
@@ -1,5 +1,5 @@
[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
# full story): never stop retrying, and back off a few seconds so the USB device can
# re-enumerate before the next attempt.
+1 -1
View File
@@ -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.
# 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).
+67
View File
@@ -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
-12
View File
@@ -4,20 +4,8 @@
table ip van_router_nat
delete 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 {
type nat hook postrouting priority srcnat; policy accept;
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
}
}
-10
View File
@@ -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
View File
@@ -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
+3 -3
View File
@@ -5,8 +5,8 @@ domain-needed
bogus-priv
dhcp-authoritative
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
# as "homeassistant" via this dnsmasq; HAOS also announces homeassistant.local (mDNS).
dhcp-host=52:54:00:ad:0a:01,10.42.0.50,homeassistant
# Home Assistant runs natively on this host (Podman, host network, :8123) —
# "homeassistant" is just another name for the router address.
host-record=homeassistant,10.42.0.1
dhcp-option=option:router,10.42.0.1
dhcp-option=option:dns-server,10.42.0.1
+4 -3
View File
@@ -1,4 +1,5 @@
[keyfile]
# The AP wlans (5GHz + 2.4GHz) and the wired LAN port are all owned by
# systemd-networkd/hostapd (bridged into br0), so NetworkManager must not touch them.
unmanaged-devices=interface-name:wlxc83a35a4ee55;interface-name:wlxd8ec5e2faa8c;interface-name:enx00e04c331140
# The AP wlans (5GHz + 2.4GHz) and the wired LAN ports (USB dongle + onboard
# eth0) are all owned by systemd-networkd/hostapd (bridged into br0), so
# 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
View File
@@ -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
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
control socket (hostapd_cli status -> state=ENABLED), AND the kernel's ground truth for
the netdev (operstate up + still a port of the bridge). Both matter because they fail
independently: hostapd_cli keeps answering state=ENABLED off stale in-memory state after
the USB radio is torn down and re-enumerated underneath a still-running hostapd — the
netdev is recreated DOWN and dropped from the bridge, but hostapd never noticed and never
exited, so Restart=always never fired. The link check catches exactly 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.
Every `interval` seconds it checks three things: hostapd's self-reported state via the
control socket (hostapd_cli status -> state=ENABLED), the kernel's ground truth for the
netdev (operstate up + still a port of the bridge), and the kernel log for a TX-queue
wedge on this radio's driver (rtw88/rtw89 "timed out to flush queue(s)"). All three
matter because they fail independently: hostapd_cli keeps answering state=ENABLED off
stale in-memory state after the USB radio is torn down and re-enumerated underneath a
still-running hostapd — the netdev is recreated DOWN and dropped from the bridge, but
hostapd never noticed and never exited, so Restart=always never fired. The link check
catches exactly that. Separately, the radio can wedge without any re-enumeration at
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]
Defaults watch the 5GHz AP (/etc/hostapd/hostapd.conf, unit hostapd); the 2.4GHz
@@ -28,6 +35,7 @@ import re
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
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
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):
"""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)."""
@@ -120,13 +169,17 @@ def main():
log("no interface= in hostapd.conf; nothing to watch", "crit")
sys.exit(1)
bridge = ap_bridge()
driver = driver_name(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)")
bad = 0
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:
now = datetime.now()
if not iface_present(ifname):
if not waiting:
log(f"{ifname} absent — USB re-enumeration in progress; "
@@ -135,7 +188,13 @@ def main():
bad = 0
else:
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:
log(f"AP {ifname} beaconing again")
bad = 0
@@ -144,6 +203,7 @@ def main():
if bad >= FAIL_THRESHOLD:
recover(ifname)
bad = 0
last_check = now
time.sleep(INTERVAL)
+5
View File
@@ -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
+24 -1
View File
@@ -15,11 +15,21 @@
<span id="updated" class="muted">loading…</span>
</div>
<div class="card" id="deploywarn-card" style="display:none">
<h3>Deploy Warnings</h3>
<div id="deploywarn"></div>
</div>
<div class="card">
<h3>Access Points</h3>
<div id="ap"></div>
</div>
<div class="card">
<h3>Clients</h3>
<div id="clients"></div>
</div>
<div class="card">
<h3>Temperatures</h3>
<div id="thermal"></div>
@@ -35,11 +45,24 @@
<div id="failover"></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">
<h3>WAN / Uplinks</h3>
<table>
<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>
<tbody id="wan"></tbody>
</table>
+5 -1
View File
@@ -2,7 +2,7 @@
.vr-head { display: flex; align-items: baseline; gap: 14px; }
.vr-head h2 { margin: 0; }
.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 h3 { margin: 0 0 10px 0; }
.card-head h3 { margin: 0; }
@@ -23,3 +23,7 @@ th { color: #6a6e73; font-weight: 600; }
.active-wan { font-weight: 700; color: #0066cc; }
.btn { margin-left: 6px; padding: 3px 10px; cursor: pointer; }
.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; }
+414 -94
View File
@@ -1,15 +1,26 @@
"use strict";
// 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.
// 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 = [
{ iface: "wlxc83a35a4ee55", unit: "hostapd", band: "5GHz" }, // RTL8852BU
{ iface: "wlxd8ec5e2faa8c", unit: "hostapd-2g", band: "2.4GHz" }, // RTL8822BU
{ iface: "@WIFI_5G_IFACE@", unit: "hostapd", band: "5GHz" },
{ 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
// 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) {
return cockpit.spawn(args, Object.assign({ err: "message" }, opts || {}));
@@ -26,53 +37,107 @@ function esc_sh(s) {
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 ---------- */
async function readAP(a) {
let active = "inactive";
try { active = (await sh(`systemctl is-active ${a.unit} || true`)).trim(); } catch (e) { /* ignore */ }
function parseAP(a, i, secs) {
const active = (secs[`active${i}`] || "").trim() || "inactive";
let info = "";
try { info = await run(["iw", "dev", a.iface, "info"]); } catch (e) { info = ""; }
const info = secs[`info${i}`] || "";
const ssid = (info.match(/\bssid (.+)/) || [])[1];
const chan = (info.match(/\bchannel \d+[^\n]*/) || [])[0];
const width = (info.match(/\bwidth: ([^\n,]+)/) || [])[1];
let stations = [];
try {
const dump = await run(["iw", "dev", a.iface, "station", "dump"]);
stations = dump.split(/Station /).slice(1).map(b => ({
mac: b.split(" ")[0],
sig: (b.match(/signal:\s*([\-\d]+)/) || [])[1],
tx: (b.match(/tx bitrate:\s*([\d.]+ MBit\/s)/) || [])[1]
}));
} catch (e) { stations = []; }
const stations = (secs[`stations${i}`] || "").split(/Station /).slice(1).map(b => ({
mac: b.split(" ")[0],
sig: (b.match(/signal:\s*([\-\d]+)/) || [])[1],
tx: (b.match(/tx bitrate:\s*([\d.]+ MBit\/s)/) || [])[1]
}));
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
// static-IP clients), then dnsmasq leases on top (authoritative + hostname).
const dir = {};
try {
JSON.parse(await run(["ip", "-j", "neigh", "show", "dev", "br0"])).forEach(n => {
if (n.lladdr && n.dst && !n.dst.includes(":")) // IPv4 only
dir[n.lladdr.toLowerCase()] = { ip: n.dst, host: null };
});
} catch (e) { /* ignore */ }
try {
// lease line: <expiry-epoch> <mac> <ip> <hostname|*> <client-id>
(await run(["cat", "/var/lib/misc/dnsmasq.leases"])).trim().split("\n").forEach(l => {
const f = l.split(" ");
if (f.length >= 4)
dir[f[1].toLowerCase()] = { ip: f[2], host: f[3] === "*" ? null : f[3] };
});
} catch (e) { /* ignore */ }
parseJSON(secs.neigh, []).forEach(n => {
if (n.lladdr && n.dst && !n.dst.includes(":")) // IPv4 only
dir[n.lladdr.toLowerCase()] = { ip: n.dst, host: null };
});
// lease line: <expiry-epoch> <mac> <ip> <hostname|*> <client-id>
(secs.leases || "").trim().split("\n").forEach(l => {
const f = l.split(" ");
if (f.length >= 4)
dir[f[1].toLowerCase()] = { ip: f[2], host: f[3] === "*" ? null : f[3] };
});
return dir;
}
function renderAPs(aps, dir) {
function renderAPs(aps) {
const el = document.getElementById("ap");
el.innerHTML = "";
aps.forEach(ap => {
@@ -87,32 +152,59 @@ function renderAPs(aps, dir) {
html += ` &nbsp; <span class="pill bad">not beaconing</span>`;
html += ` &nbsp; Clients: <b>${ap.stations.length}</b>` +
` <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.querySelector(".ap-restart").onclick = () => restartAP(ap.unit);
el.appendChild(div);
});
}
/* ---------- Temperatures (van-thermal daemon state) ---------- */
/* ---------- Clients (Wi-Fi stations + wired bridge-port FDB) ---------- */
async function readThermal() {
try {
return JSON.parse(await run(["cat", "/run/van-thermal/state.json"]));
} catch (e) {
return null;
}
function collectClients(aps, secs, dir) {
// Wi-Fi clients come from the hostapd station dumps (with signal/rate);
// wired ones from learned bridge-FDB entries on the LAN ports.
const rows = [];
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) {
const el = document.getElementById("thermal");
if (!th || !th.sensors) {
@@ -120,30 +212,92 @@ function renderThermal(th) {
return;
}
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 => {
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";
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 class="muted">${esc(s.warn)} / ${esc(s.crit)} °C</td></tr>`;
`<td class="muted">${limits}</td></tr>`;
});
html += `</tbody></table>`;
html += `<p class="muted">History: <code>/var/log/van-thermal.csv</code> · alerts: <code>journalctl -u van-thermal</code></p>`;
el.innerHTML = html;
}
/* ---------- Battery / power source (van-battery daemon state) ---------- */
/* ---------- Starlink (dish gRPC get_status via grpcurl) ---------- */
async function readBattery() {
try {
return JSON.parse(await run(["cat", "/run/van-battery/state.json"]));
} catch (e) {
return null;
}
function fmtMbps(bps) {
return bps == null ? "—" : (bps / 1e6).toFixed(1) + " Mbps";
}
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>` : "") +
` &nbsp; <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) {
const el = document.getElementById("battery");
if (!b || b.capacity == null) {
@@ -175,14 +329,6 @@ function renderBattery(b) {
/* ---------- 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) {
const el = document.getElementById("failover");
if (!fo || !fo.wans) {
@@ -193,7 +339,7 @@ function renderFailover(fo) {
` <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>`;
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>`
: `<span class="pill bad">down</span>`;
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;
}
/* ---------- 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 ---------- */
async function readWAN() {
const devOut = await run(["nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status"]);
const devs = devOut.trim().split("\n").map(line => {
// mmcli -K key-values -> { netdev, signal, tech, operator } (null if no modem).
// NM's gsm device is the control port (cdc-wdm0); IP/routes live on the wwan netdev.
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(":");
return { device, type, state, connection: rest.join(":") };
}).filter(d => (d.type === "ethernet" || d.type === "wifi") &&
return { device, type, state, connection: rest.join(":"), nmdev: device };
}).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));
let routes = [];
try { routes = JSON.parse(await run(["ip", "-j", "route", "show", "default"])); } catch (e) { routes = []; }
const routes = parseJSON(secs.routes, []);
const metricByDev = {};
routes.forEach(r => { if (r.dev) metricByDev[r.dev] = r.metric; });
const activeDev = routes.length
? routes.slice().sort((a, b) => (a.metric || 0) - (b.metric || 0))[0].dev
: null;
let addrs = [];
try { addrs = JSON.parse(await run(["ip", "-j", "-4", "addr"])); } catch (e) { addrs = []; }
const ipByDev = {};
addrs.forEach(a => {
parseJSON(secs.addrs, []).forEach(a => {
const info = (a.addr_info || []).find(x => x.family === "inet");
if (info) ipByDev[a.ifname] = info.local + "/" + info.prefixlen;
});
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.ip = ipByDev[d.device];
d.active = d.device === activeDev;
@@ -239,17 +520,26 @@ async function readWAN() {
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) {
const tb = document.getElementById("wan");
tb.innerHTML = "";
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");
tr.innerHTML =
`<td class="${d.active ? "active-wan" : ""}">${d.active ? "★ " : ""}${esc(d.device)}</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>${d.signal || "—"}</td>` +
`<td>${d.metric != null ? esc(d.metric) : "—"}</td>` +
`<td class="acts"></td>`;
const acts = tr.querySelector(".acts");
@@ -264,10 +554,19 @@ function renderWAN(devs) {
}
const tog = document.createElement("button");
tog.className = "btn";
tog.textContent = isUp ? "Down" : "Up";
tog.onclick = () => toggleWAN(d, isUp);
tog.textContent = isConnected ? "Disconnect" : "Connect";
tog.disabled = !isConnected && noCarrier;
tog.onclick = () => toggleWAN(d, isConnected);
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);
});
}
@@ -284,14 +583,31 @@ async function preferWAN(chosen) {
setTimeout(refresh, 4500); // daemon enforces on its next probe loop
}
async function toggleWAN(d, isUp) {
async function toggleWAN(d, isConnected) {
try {
const verb = isUp ? "disconnect" : "connect";
await run(["nmcli", "device", verb, d.device], { superuser: "require" });
const verb = isConnected ? "disconnect" : "connect";
await run(["nmcli", "device", verb, d.nmdev], { superuser: "require" });
} catch (e) { window.alert("Toggle failed: " + e.message); }
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) {
try { await run(["systemctl", "restart", unit], { superuser: "require" }); }
catch (e) { window.alert("Restart failed: " + e.message); }
@@ -302,13 +618,17 @@ async function restartAP(unit) {
async function refresh() {
try {
const [aps, dir, th, bat, fo, wan] = await Promise.all([
Promise.all(APS.map(readAP)), readClientDir(), readThermal(), readBattery(), readFailover(), readWAN()]);
renderAPs(aps, dir);
renderThermal(th);
renderBattery(bat);
renderFailover(fo);
renderWAN(wan);
const secs = parseSections(await sh(STATUS_SCRIPT));
renderDeployWarnings(parseJSON(secs.deploywarn, null));
const aps = APS.map((a, i) => parseAP(a, i, secs));
ownAPSSIDs = new Set(aps.map(a => a.ssid).filter(Boolean));
renderAPs(aps);
renderClients(collectClients(aps, secs, parseClientDir(secs)));
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();
} catch (e) {
document.getElementById("updated").textContent = "error: " + (e.message || e);
+35
View File
@@ -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"
+244 -62
View File
@@ -1,118 +1,300 @@
#!/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
# Idempotent. See README.md §4 for the two manual steps this does NOT do
# (zerotier-systemd-manager binary install, hostapd unmask).
# sudo ./deploy.sh render <file> # preview a templated file on stdout
# 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
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; }
# 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 =="
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/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 -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.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/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 -D -m0644 ap/van-ap-unmanaged.conf /etc/NetworkManager/conf.d/van-ap-unmanaged.conf
install_rendered ap/22-van-lan.network /etc/systemd/network/22-van-lan.network
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/regdomain.service /etc/systemd/system/regdomain.service
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
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 =="
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 -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
# 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 =="
install -D -m0644 dns/zt-search.conf /etc/systemd/network/99-ztuga7c2kh.network.d/search.conf
echo "== cellular modem (GSM/LTE) =="
# 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 =="
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 =="
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/van-thermal.service /etc/systemd/system/van-thermal.service
echo "== battery monitor =="
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.
# Pushover secrets live only on the system (0600), never in the repo. Path kept
# under /etc/van-battery/ for parity with wayback's van-thermal default.
if [ ! -f /etc/van-battery/pushover.json ]; then
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
echo "== power / never-sleep =="
install -D -m0644 power/10-vanlink-nolid.conf /etc/systemd/logind.conf.d/10-vanlink-nolid.conf
# Belt-and-suspenders: a router must never suspend from idle, GUI, or a stray `systemctl suspend`.
systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target >/dev/null 2>&1 || true
echo "== nvme watchdog =="
install -D -m0755 power/van-nvme-watch /usr/local/sbin/van-nvme-watch
install -D -m0644 power/nvme-watch-config.json /etc/van-nvme-watch/config.json
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 =="
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 =="
sysctl --system >/dev/null
systemctl daemon-reload
# Re-exec PID1 so the system.conf.d watchdog drop-in takes effect (daemon-reload alone
# does NOT re-arm RuntimeWatchdogSec). Safe online: re-exec keeps all services running.
# Re-exec PID1 so the system.conf.d watchdog drop-in takes effect (daemon-reload
# alone does NOT re-arm RuntimeWatchdogSec). Safe online.
systemctl daemon-reexec
# networkd here owns only the AP + ZT overlay (neither a real uplink), so its wait-online
# can never satisfy "online" and just burns its 120s timeout, stalling network-online.target
# and ZeroTier by ~2min every boot. Real uplink readiness is covered by NetworkManager-wait-online.
# Safe online: picks up 99-van-router-dns.conf immediately. NM's
# ignore-auto-dns only takes effect on a connection's next activation though —
# 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
# 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 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 restart van-thermal van-battery
# Heartbeat: only enable/start once the client binary is installed (README §4).
if [ -x /home/andreas/bin/hbc ]; then
systemctl enable hbc >/dev/null 2>&1 || true
systemctl restart hbc
fi
# Pick up the unmanaged-devices change so NM releases the wired LAN port (drops its
# old 192.168.10.x lease); networkd then enslaves it to br0 on the restart below.
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
# bluetooth: host BlueZ serves the onboard hci0 to the HA container over D-Bus
systemctl enable --now bluetooth >/dev/null 2>&1 || true
systemctl start homeassistant || warn "homeassistant failed to start (podman/quadlet — check journalctl -u homeassistant)"
systemctl start esphome || warn "esphome failed to start (podman/quadlet — check journalctl -u esphome)"
systemctl start frigate || warn "frigate failed to start (podman/quadlet — check journalctl -u frigate)"
systemctl restart van-thermal
systemctl restart van-nvme-watch
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
# restart in dependency order: br0 + AP iface + LAN member first, then hostapd adds
# the wlan to br0, then dnsmasq binds br0, then NAT/failover
# restart in dependency order: bridge + members first, then hostapd enslaves the
# radios, then dnsmasq binds br0, then NAT/failover
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)
systemctl restart van-ap-watchdog van-ap-watchdog-2g
networkctl reload 2>/dev/null || true
write_warnings
echo
echo "Deployed. Verify:"
echo " iw dev wlxc83a35a4ee55 info | grep -E 'ssid|channel|width'"
echo "Deployed. NOTE: until the USB hub (AP radios + LAN/Starlink adapters) is"
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-thermal/state.json # CPU + NVMe temps"
echo " cat /run/van-battery/state.json # mains/battery + charge %"
echo " systemctl status hbc # heartbeat client -> hbd.wrede.pvt"
echo "Manual one-time steps (see README §4): zerotier-systemd-manager binary + 'zerotier-cli set <nwid> allowDNS=1'."
echo " cat /run/van-thermal/state.json"
echo " cat /run/van-nvme-watch/state.json"
echo " journalctl -u van-li3-battery -n 20"
echo " journalctl -u van-sms-watch -n 20 ; tail -f /var/log/van-sms.jsonl"
-13
View File
@@ -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
-8
View File
@@ -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
-9
View File
@@ -1,9 +0,0 @@
[Unit]
Description=Update zerotier per-interface DNS settings
[Timer]
OnStartupSec=1min
OnUnitInactiveSec=1min
[Install]
WantedBy=timers.target
+7 -2
View File
@@ -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]
Domains=
Domains=wrede.pvt ~196.168.192.in-addr.arpa ~c.e.3.d.d.f.ip6.arpa
Domains=zt.wrede.pvt
+83
View File
@@ -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()
+6 -8
View File
@@ -1,16 +1,14 @@
{
"probe_interval": 4,
"probe_interval": 60,
"probe_timeout": 3,
"fail_threshold": 3,
"ok_threshold": 2,
"fail_threshold": 2,
"ok_threshold": 1,
"probe_urls": [
"http://connectivity-check.ubuntu.com/",
"http://www.gstatic.com/generate_204",
"http://cp.cloudflare.com/"
"http://connectivity-check.ubuntu.com/"
],
"wans": [
{ "name": "wifi", "device": "wlp1s0", "metric": 100 },
{ "name": "starlink", "device": "enxd8ec5eeb3512", "metric": 200 },
{ "name": "wifi", "device": "wlan0", "metric": 100 },
{ "name": "starlink", "device": "@STARLINK_IFACE@", "metric": 200 },
{ "name": "cellular", "connection": "Koodo", "metric": 300 }
]
}
+61
View File
@@ -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."
+61
View File
@@ -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."
+123 -38
View File
@@ -68,7 +68,19 @@ def resolve(wan, actives):
return dev, conn
conn = wan.get("connection")
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):
@@ -105,7 +117,29 @@ def routes_on(dev):
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`.
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)."""
@@ -115,37 +149,62 @@ def enforce_route(dev, metric):
if any(r["metric"] == metric for r in rts):
# desired metric already present; just prune any stale others
for r in rts:
if r["metric"] != metric and r["gw"]:
sh(["ip", "route", "del", "default", "via", r["gw"], "dev", dev, "metric", str(r["metric"])])
if r["metric"] != metric:
_del_default(dev, r)
return
# 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).
gw = next((r["gw"] for r in rts if r["gw"]), None) or device_gateway(dev)
if not gw:
return # no gateway known (carrier down); profile metric still set
r = sh(["ip", "route", "add", "default", "via", gw, "dev", dev, "metric", str(metric), "proto", "static"])
gw = next((r["gw"] for r in rts if r["gw"]), None) or nm_gateway(dev, conn)
args = ["ip", "route", "add", "default", "dev", dev, "metric", str(metric), "proto", "static"]
if gw:
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):
# 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,
# or we'd strand this dev with no default route at all.
return
for old in rts:
if old["metric"] != metric and old["gw"]:
sh(["ip", "route", "del", "default", "via", old["gw"], "dev", dev, "metric", str(old["metric"])])
if old["metric"] != metric:
_del_default(dev, old)
def device_gateway(dev):
"""NM's gateway for a device — available even when its default route is missing."""
r = sh(["nmcli", "-g", "IP4.GATEWAY", "device", "show", dev])
if r and r.returncode == 0:
return r.stdout.strip() or None
def nm_gateway(dev, conn):
"""NM's gateway — available even when the default route is missing. Try the device,
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:
for opt in r.stdout.split(" | "):
key, _, val = opt.strip().partition(" = ")
if key == "routers" and val:
return val.split()[0]
return None
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:
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():
@@ -177,24 +236,36 @@ def main():
wans = cfg["wans"]
# 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)
pool = ThreadPoolExecutor(max_workers=max(4, len(wans) * len(urls)))
while True:
now = time.monotonic()
actives = active_connections()
resolved = {w["name"]: resolve(w, actives) for w in wans}
prefer_dev = read_prefer()
# 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}
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
# returns 204. Concurrency bounds a failed WAN to ~one timeout, not N serial timeouts.
health = {w["name"]: (False if present[w["name"]] else None) for w in wans}
# Probe only WANs that are due, one url-fan-out per WAN, concurrently. A WAN is healthy
# if ANY url returns 204. `finish` tracks each WAN's own completion time (not one shared
# 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))
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:
if fut.result():
ok = fut.result()
finish[name] = max(finish.get(name, now), time.monotonic())
if ok:
health[name] = True
for w in wans:
@@ -205,26 +276,37 @@ def main():
base = PREFER_METRIC if prefer_dev and dev == prefer_dev else w["metric"]
s = rt[name]
if not present[name]:
# Absent (e.g. modem unplugged): don't probe/penalize; just keep the
# profile's base metric so it lands at the right priority when it connects.
# Absent (e.g. modem unplugged): don't probe/penalize; just keep the profile's
# 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:
set_profile_metric(conn, base)
s["applied"] = base
continue
if health[name]:
s["ok"] += 1
s["fail"] = 0
if s["ok"] >= ok_th:
s["up"] = True
else:
s["fail"] += 1
s["ok"] = 0
if s["fail"] >= fail_th:
s["up"] = False
if name in finish:
s["healthy"] = health[name]
if health[name]:
s["ok"] += 1
s["fail"] = 0
if s["ok"] >= ok_th:
s["up"] = True
next_due[name] = now + interval
else:
s["fail"] += 1
s["ok"] = 0
if s["fail"] >= fail_th:
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
# Enforce the live route every loop (carrier-safe, corrects any NM drift);
# update the NM profile only on an actual state change.
enforce_route(dev, desired)
enforce_route(dev, desired, conn)
if s["applied"] != desired:
print(f"{name}: {'UP' if s['up'] else 'DOWN'} -> metric {desired}", flush=True)
set_profile_metric(conn, desired)
@@ -239,12 +321,15 @@ def main():
report.append({
"name": name, "priority": w["metric"], "device": dev, "connection": conn,
"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,
})
write_state({"updated": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"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__":
+135
View File
@@ -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)
+11
View File
@@ -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
+92
View File
@@ -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)
+11
View File
@@ -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
+5
View File
@@ -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"
+13
View File
@@ -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"
}
+9
View File
@@ -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"
+161
View File
@@ -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)
+14
View File
@@ -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,
)
+36
View File
@@ -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
}
]
}
+133
View File
@@ -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"
}
}
}
+35
View File
@@ -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
+43
View File
@@ -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
-87
View File
@@ -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>
+37
View File
@@ -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
-16
View File
@@ -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
-15
View File
@@ -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)
+12
View File
@@ -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
}
+376
View File
@@ -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())
+17
View File
@@ -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
+96
View File
@@ -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()
+244
View File
@@ -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)
+13
View File
@@ -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
-6
View File
@@ -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
+3 -3
View File
@@ -1,10 +1,10 @@
# Hardware watchdog for unattended operation.
#
# PID1 pets /dev/watchdog0 (intel_oc_wdt) every RuntimeWatchdogSec/2. If systemd
# itself wedges for longer than RuntimeWatchdogSec, the chip hard-resets the box —
# PID1 pets /dev/watchdog0 (bcm2835_wdt) every RuntimeWatchdogSec/2. If systemd
# 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.
# (See the EC-latch / USB-hub-hang history.) RebootWatchdogSec also guards against
# a reboot that hangs partway.
[Manager]
RuntimeWatchdogSec=20s
RuntimeWatchdogSec=10s
RebootWatchdogSec=5min
-9
View File
@@ -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"
}
+7
View File
@@ -0,0 +1,7 @@
{
"nvme_device": "nvme0",
"fs_device": "/dev/nvme0n1p2",
"grace_period": 20,
"cooldown": 300,
"credentials_path": "/etc/van-battery/pushover.json"
}
+32 -2
View File
@@ -6,7 +6,37 @@
"pushover_level": "warn",
"credentials_path": "/etc/van-battery/pushover.json",
"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"
}
]
}
-231
View File
@@ -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)
-13
View File
@@ -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
+261
View File
@@ -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)
+13
View File
@@ -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
+169 -41
View File
@@ -1,5 +1,5 @@
#!/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:
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
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
disable sending but nothing else. Stdlib only.
"""
@@ -18,6 +21,7 @@ disable sending but nothing else. Stdlib only.
import json
import os
import socket
import subprocess
import sys
import time
import urllib.parse
@@ -50,6 +54,7 @@ DEFAULTS = {
}
LEVELS = ("ok", "warn", "crit")
ICONS = {"fan": "🌀", "undervolt": "⚡"} # pushover title icons for non-temp kinds
PLACEHOLDERS = {"", "REPLACE_ME", "your-token-here", "your-user-key-here"}
@@ -73,7 +78,7 @@ def load_creds(cfg):
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)
if not creds:
log(f"pushover skipped (no credentials): {title} — {message}", "warn")
@@ -84,15 +89,22 @@ def pushover(cfg, title, message, priority=0):
"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
# 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:
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():
@@ -119,15 +131,28 @@ def find_hwmon(name):
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):
"""Read one sensor's temperature in °C, or None if unavailable.
spec: {hwmon, label}. Resolves hwmon dir + the tempN whose *_label matches,
falling back to temp1 when the chip exposes no labels."""
d = cache.get(spec["hwmon"])
if d is None or not d.exists():
d = find_hwmon(spec["hwmon"])
cache[spec["hwmon"]] = d
d = resolve_hwmon(spec, cache)
if d is None:
return None
@@ -152,6 +177,65 @@ def read_temp(spec, cache):
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):
"""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."""
@@ -168,7 +252,7 @@ def classify(temp, spec, prev_level):
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
("rising" / "falling") so the caller can decide whether to page via
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)
sev = {"crit": "crit", "warn": "warn", "ok": "info"}[new]
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"
@@ -189,20 +273,39 @@ def rotate_log(path, max_bytes):
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):
path = Path(cfg["log_path"])
rotate_log(path, cfg["log_max_bytes"])
new = not path.exists()
header = ",".join(["time"] + sum([csv_columns(s) for s in cfg["sensors"]], []))
try:
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:
if new:
f.write(",".join(["time"] + sum(
[[f"{s['name']}_c", f"{s['name']}_lvl"] for s in cfg["sensors"]], [])) + "\n")
f.write(header + "\n")
row = [datetime.now(timezone.utc).isoformat(timespec="seconds")]
for s in cfg["sensors"]:
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")
except OSError as e:
log(f"csv write failed: {e}", "warn")
@@ -233,31 +336,56 @@ def main():
while True:
readings = {}
for s in cfg["sensors"]:
temp = read_temp(s, cache)
new = classify(temp, s, levels[s["name"]])
if temp is not None:
old = levels[s["name"]]
direction = announce(s["name"], temp, old, new)
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)
new = classify(temp, s, old)
disp = None if temp is None else f"{temp:.1f}°C"
thr = s["crit"] if new == "crit" else s["warn"]
detail = (f"below warn ({s['warn']}°C)." if new == "ok" else
f"crossed the {new.upper()} threshold ({thr}°C); "
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 = "🔥" if new == "crit" else "🌡"
thr = s["crit"] if new == "crit" else s["warn"]
pushover(cfg, f"{icon} {HOST}: {s['name']} {new.upper()} {temp:.1f}°C",
f"{s['name']} temperature {temp:.1f}°C crossed {new.upper()} "
f"threshold ({thr}°C). warn {s['warn']}, crit {s['crit']}.",
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)
elif direction == "falling" and LEVELS.index(old) >= alert_idx:
# 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.
label = "NORMAL" if new == "ok" else new.upper()
pushover(cfg, f"✅ {HOST}: {s['name']} back to {label} {temp:.1f}°C",
f"{s['name']} temperature {temp:.1f}°C dropped back to {label} "
f"(warn {s['warn']}, crit {s['crit']}).",
priority=0)
levels[s["name"]] = new
readings[s["name"]] = {
"temp": None if temp is None else round(temp, 1),
"level": new, "warn": s["warn"], "crit": s["crit"],
}
pushover(cfg, f"✅ {HOST}: {name} back to {label} — {disp}",
f"{name}: {detail}", priority=0)
levels[name] = new
reading["level"] = new
readings[name] = reading
try:
write_state(readings)
+6 -2
View File
@@ -1,6 +1,10 @@
[Unit]
Description=Temperature monitor (CPU + NVMe) for the campervan router
After=local-fs.target
Description=Thermal + health monitor (temps, fan, undervoltage) for the campervan router
# 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]
Type=simple