Updated hostapd.conf to run 5GHz AP on channel 149 with MT7925E (mt76 driver). Now supports full 802.11ac (VHT) and 802.11ax (WiFi 6) capabilities. Also fixed deploy.sh rendering issue that left unsubstituted placeholders in network configs, causing Ethernet flaps. Configs are now properly deployed. - hw_mode=a (5GHz), channel=149 - VHT and HE (WiFi 6) capabilities enabled - Proper bridge configuration with gateway IP 10.42.1.1/24 - DHCP range 10.42.1.10-254 verified working Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
vanlink — campervan router on wayback
This clone = the Pi 4 port (host
wan, branchwan). Onboardeth0+wlan0are NM-managed WANs (netplanap/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 watchescpu_thermal.
Turns wayback (Asus ZenBook UX391U, Ubuntu 24.04, zabbly kernel) into a self-contained campervan hub/router/AP:
- WiFi-6 access point for client devices (hand-rolled hostapd, not NetworkManager).
- Multi-WAN failover across WiFi (neighbour AP) → Ethernet (Starlink) → 4G/5G cellular.
- ZeroTier-managed DNS so
*.wrede.pvtresolves over the overlay when away from home. - Cockpit dashboard ("Van Router") for status + manual control.
This directory is the source of truth. The live system files live under /etc, /usr/...;
deploy.sh copies from here to there. Edit here, run sudo ./deploy.sh, done.
1. Architecture at a glance
WiFi clients (VanLink, 5GHz ch149, 80MHz WPA2) wired LAN clients
wlxc83a35a4ee55 (RTL8852BU, rtw89, hostapd) enx00e04c331140
└───────────┬───────────────────┘
br0 10.42.1.1/24 ← LAN bridge
│ dnsmasq DHCP .10–.254 + DNS
│ nftables masquerade (oifname != br0)
│ ip_forward=1
┌─────────────────┼──────────────────────────────────────┐
wlp1s0 (m100) enxd8ec5eeb3512 (m200) Koodo gsm (m300)
neighbour WiFi USB ethernet → Starlink 4G/5G modem (when present)
└──────── van-failover picks lowest-metric HEALTHY WAN ────┘
NetworkManager owns the WAN side. systemd-networkd owns the AP IP + the ZeroTier DNS link.
Roles / responsibilities
| Concern | Owner |
|---|---|
| WAN interfaces (eth / wifi / gsm), DHCP-client, metrics | NetworkManager |
LAN bridge br0 + IP (10.42.1.1) + wired LAN member |
systemd-networkd (2x-van-br0/lan) |
AP beaconing / WPA + adding the wlan to br0 |
hostapd (bridge=br0; AP iface + wired port are NM-unmanaged) |
| Keep the AP beaconing across USB re-enumeration (Starlink flap) | hostapd Restart=always/no start-limit drop-in + van-ap-watchdog daemon (recovers a wedged radio) |
| AP DHCP + DNS | dnsmasq (dedicated instance, bound to AP only) |
| 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 |
| ZeroTier DNS → resolved | zerotier-systemd-manager + systemd-networkd |
| Web UI | Cockpit + vanrouter plugin |
Key network facts
- LAN:
10.42.1.0/24, gateway10.42.0.1, DHCP.10–.254— shared by Wi-Fi (VanLink, dual-band: 5GHzwlxc83a35a4ee55+ 2.4GHzwlxd8ec5e2faa8c) and the wired portenx00e04c331140, all bridged intobr0. - WAN priority (metrics): wifi 100 → eth/Starlink 200 → 4G 300 (lower = preferred).
- Management / recovery: ethernet
192.168.10.251, wifi192.168.10.27, ZeroTier192.168.196.22. - Cockpit:
https://192.168.10.251:9090(or.27, or ZeroTier). Log in with a Unix account; enable Administrative access for action buttons. - Regulatory domain CA (unlocks 5GHz ch149–161 @30dBm, no DFS).
- ZeroTier network
d3ecf5726d041b2a, pushed DNS domainwrede.pvtvia192.168.196.115+192.168.10.5. - Home Assistant runs natively on this host (Podman Quadlet, host network) at
http://10.42.1.1:8123(namehomeassistant). The old HAOS VMha_van(10.42.0.50) was retired 2026-07-07.
2. Directory layout → system destinations
deploy.sh performs exactly this mapping.
ap/ — access point
| file | → installs to | purpose |
|---|---|---|
hostapd.conf |
/etc/hostapd/hostapd.conf |
AP: SSID VanLink, ch149, VHT80/HE, WPA2-PSK, country CA, ctrl_interface for the watchdog. WPA passphrase lives here. |
hostapd-restart.conf |
/etc/systemd/system/hostapd.service.d/restart.conf |
Restart=always + StartLimitIntervalSec=0 so hostapd never gives up after a USB re-enumeration bounces the radio |
van-ap-watchdog |
/usr/local/sbin/van-ap-watchdog |
watches hostapd_cli status; restarts hostapd if the radio wedges (running but not ENABLED) |
van-ap-watchdog.service |
/etc/systemd/system/van-ap-watchdog.service |
runs the watchdog |
default-hostapd |
/etc/default/hostapd |
DAEMON_CONF=... |
van-ap-dnsmasq.conf |
/etc/van-ap/dnsmasq.conf |
DHCP/DNS bound to AP iface (bind-dynamic, so it does not clash with systemd-resolved) |
van-ap-dnsmasq.service |
/etc/systemd/system/van-ap-dnsmasq.service |
dedicated dnsmasq unit (uses the dnsmasq-base binary; the distro dnsmasq service is NOT used) |
hostapd-2g.conf |
/etc/hostapd/hostapd-2g.conf |
2.4GHz AP (Linksys WUSB6300v2 / RTL8822BU): same SSID+PSK, ch6, HT20. Own hostapd instance so one radio wedging never kills the other. |
hostapd-2g.service |
/etc/systemd/system/hostapd-2g.service |
dedicated unit for the 2.4GHz hostapd (same Restart=always / no-start-limit semantics as the 5GHz drop-in) |
van-ap-watchdog-2g.service |
/etc/systemd/system/van-ap-watchdog-2g.service |
second watchdog instance: van-ap-watchdog /etc/hostapd/hostapd-2g.conf hostapd-2g |
10-van-ap.network |
/etc/systemd/network/10-van-ap.network |
brings the AP wlan up with no IP (it's a br0 member; hostapd enslaves it) |
11-van-ap-2g.network |
/etc/systemd/network/11-van-ap-2g.network |
same, for the 2.4GHz wlan |
20-van-br0.netdev |
/etc/systemd/network/20-van-br0.netdev |
creates the LAN bridge br0 (STP off) |
21-van-br0.network |
/etc/systemd/network/21-van-br0.network |
static 10.42.1.1/24 on br0 (ConfigureWithoutCarrier) |
22-van-lan.network |
/etc/systemd/network/22-van-lan.network |
enslaves the wired LAN port enx00e04c331140 to br0 |
van-ap-unmanaged.conf |
/etc/NetworkManager/conf.d/van-ap-unmanaged.conf |
tells NM to leave the AP wlan and the wired LAN port alone |
nftables.conf |
/etc/nftables.conf |
NAT: masquerade ip saddr 10.42.1.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 |
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
| file | → installs to | purpose |
|---|---|---|
van-failover |
/usr/local/sbin/van-failover |
the daemon (Python) |
config.json |
/etc/van-failover/config.json |
WAN list + priorities + probe settings |
van-failover.service |
/etc/systemd/system/van-failover.service |
Restart=always |
50-disable-eee |
/etc/NetworkManager/dispatcher.d/50-disable-eee |
disables EEE on en*/eth* at connect (r8152 idle parking) |
99-van-arp.conf |
/etc/sysctl.d/99-van-arp.conf |
arp_ignore=1 / arp_announce=2 (multi-NIC ARP hygiene) |
dns/ — ZeroTier managed DNS
| file | → installs to | purpose |
|---|---|---|
zt-search.conf |
/etc/systemd/network/99-ztuga7c2kh.network.d/search.conf |
makes wrede.pvt a search domain (bare-hostname completion) |
zt-network.local.conf |
/var/lib/zerotier-one/networks.d/d3ecf5726d041b2a.local.conf |
contains allowDNS=1 (prerequisite) |
99-ztuga7c2kh.network.generated |
(reference only — generated by the manager) | what zerotier-systemd-manager writes |
zerotier-systemd-manager.{service,timer} |
/usr/lib/systemd/system/ |
the manager (binary installed separately, see §4) |
cockpit/vanrouter/ — web UI
manifest.json, index.html, vanrouter.css, vanrouter.js → /usr/share/cockpit/vanrouter/.
Being superseded by router-status/ below (Cockpit's PAM+TLS login was unreliable
— rejected Firefox sometimes, failed outright from Android — and it was a resource
hog); left in place until the Home Assistant dashboard fully covers its actions.
router-status/ — same status/actions, via MQTT -> Home Assistant
| file | → installs to | purpose |
|---|---|---|
van-router-mqtt |
/usr/local/sbin/van-router-mqtt |
status+control daemon (Python, paho-mqtt): AP/clients/Starlink/WAN/modem, HA MQTT discovery; republishes van-failover/van-thermal/van-battery's own state.json verbatim rather than recomputing them |
config.json |
/etc/van-router-mqtt/config.json |
broker + interface identifiers (@TOKEN@ templated like failover/config.json — no secrets, rendered fresh every deploy) |
van-router-mqtt.service |
/etc/systemd/system/van-router-mqtt.service |
Restart=always |
power/ — never sleep + thermal monitor
| file | → installs to | purpose |
|---|---|---|
10-vanlink-nolid.conf |
/etc/systemd/logind.conf.d/10-vanlink-nolid.conf |
logind ignores the lid in all states (closed / on AC / docked) |
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 |
pushover.json.example |
→ /etc/van-battery/pushover.json (seeded if absent) |
Pushover token+user template; real file is 0600, not in the repo |
10-vanlink-watchdog.conf |
/etc/systemd/system.conf.d/10-vanlink-watchdog.conf |
hardware watchdog (RuntimeWatchdogSec=20s); applied via daemon-reexec |
deploy.sh additionally masks sleep.target suspend.target hibernate.target hybrid-sleep.target
(no repo file — symlinks to /dev/null under /etc/systemd/system/) so nothing else can suspend either.
heartbeat/ — dead-man's switch
| file | → installs to | purpose |
|---|---|---|
hbc.yaml |
/etc/hbc.yaml |
heartbeat client config (interval + metric plugins) |
hbc.service |
/etc/systemd/system/hbc.service |
runs ~/bin/hbc as andreas → hbd.wrede.pvt:50003 |
The hbc binary itself (venv at ~/venvs/hbd, symlink ~/bin/hbc) is installed once via the
heartbeat project's own installer — see §4. deploy.sh only starts the service once it exists.
li3/ — RV house battery (BLE BMS → MQTT/HA)
| file | → installs to | purpose |
|---|---|---|
van-li3-battery |
/usr/local/sbin/van-li3-battery |
BLE→MQTT daemon (Python, bleak + paho-mqtt): reads the Lithionics Li3 BMS, publishes HA MQTT discovery + state |
config.json.example |
→ /etc/van-li3/config.json (seeded if absent) |
BLE address, broker, MQTT topic/discovery template; real file is 0600, not in the repo |
van-li3-battery.service |
/etc/systemd/system/van-li3-battery.service |
Restart=always |
Not to be confused with power/van-battery — that's the host's own AC/battery power
supply (laptop UPS-style monitor), unrelated hardware and purpose.
ha/ — Home Assistant (native container)
| file | → installs to | purpose |
|---|---|---|
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.1.1). The retired HAOS VM's domain XML (ha_van.xml) lives in git history.
custom_components/ and dashboards/ are not deployed by deploy.sh — HA's
/srv/homeassistant is hand-managed live config (it has other integrations installed
via HACS that were never in this repo, e.g. mbapi2020); these two are kept here purely
as a reference/backup copy of the hand-authored pieces. dashboards/van-router.yaml is
the Lovelace dashboard for van-router-mqtt's entities — the live copy is
/srv/homeassistant/dashboards/van-router.yaml, wired in via a lovelace: dashboards:
block manually added to /srv/homeassistant/configuration.yaml (also not repo-tracked;
see that file's own comment). After editing the dashboard, copy both places and
systemctl restart homeassistant (or use HA's own "reload" for lovelace-only changes).
3. Components & how to operate
Access Point
- Dual band, one SSID: 5GHz (
hostapd.conf→ unithostapd, ch149 80MHz VHT+HE) and 2.4GHz (hostapd-2g.conf→ unithostapd-2g, ch6 HT20, for range + 2.4-only IoT). Same SSID/PSK — clients pick their band. Deliberately two hostapd processes: a USB wedge on one radio never takes the other down, and each has its own watchdog (van-ap-watchdog,van-ap-watchdog-2g). - Change SSID / channel / password: edit
ap/hostapd.confand/orap/hostapd-2g.conf, thensudo cpto/etc/hostapd/(or./deploy.sh) and restart the matching unit. - DHCP range / DNS options:
ap/van-ap-dnsmasq.conf+ restartvan-ap-dnsmasq. - Services:
hostapd,hostapd-2g,van-ap-dnsmasq,systemd-networkd,nftables,regdomain. - Do not add
noscanto hostapd.conf — Ubuntu's hostapd rejects it and fails to start. - Verify each is actually beaconing with
iw dev wlxc83a35a4ee55 info/iw dev wlxd8ec5e2faa8c info(ssid+channel+width present), not justsystemctl is-active.
Multi-WAN failover (van-failover)
- Probes each WAN's real internet every few seconds (HTTP-204 check, captive-portal-aware), demotes a failed WAN by raising its route-metric, fails back on recovery (hysteresis).
- Status:
systemctl status van-failover,journalctl -u van-failover -f, or the Cockpit "WAN Failover" card. Live state JSON at/run/van-failover/state.json. - Tune priorities/timing in
failover/config.json(wans[].metric,probe_interval,probe_timeout,fail_threshold,ok_threshold,probe_urls) then restart. - WAN identity: eth/wifi keyed by
device, cellular by NMconnectionname (Koodo). - The daemon changes metrics with
ip route, nevernmcli device reapply— see Gotchas.
Manual preference (Cockpit "Prefer" button)
- The daemon is the single writer of route-metrics. To force a specific WAN to the top it reads an optional file
/run/van-failover/prefercontaining one device name (e.g.enxd8ec5eeb3512). That WAN gets base metric 50 (below every config metric) so it wins while healthy; the health PENALTY still stacks on top, so failover/fail-back are unchanged. Empty/absent file = follow config priorities. - The Cockpit "Prefer" button just writes that file (root via polkit) — it does not set metrics itself. Doing so directly would fight the daemon (reverted within one probe loop) and
reapply-flap the USB carrier. The daemon applies the change on its next loop (~probe_interval);state.jsoncarries apreferredflag the UI shows as "(preferred)". - Single-WAN, sticky override with no explicit "clear": to return to automatic priority, prefer your top WAN (wifi) or
rm /run/van-failover/prefer. Ephemeral by design —/runis wiped on reboot, so a reboot returns to config priorities. (To persist it, pointPREFERat/etc/van-failover/preferin the daemon and the matching path invanrouter.js.) - Swapping the preference between two WANs is collision-safe: a second default route can't take the new WAN's target metric while the old preferred WAN still holds it, so
enforce_routekeeps the existing route and retries next loop (settles in ~2 loops) instead of stranding the interface.enforce_routealso restores a default route that went missing while the carrier is up (gateway from NM viadevice_gateway), not just rebases an existing one.
Thermal monitor (van-thermal)
- One daemon off a single sysfs sample loop does all three jobs: live Cockpit readout, threshold alerting, history.
- Live state:
/run/van-thermal/state.json(atomic-swapped each sample). The Cockpit "Temperatures" card reads this — no shelling out tosensorsper refresh. Sensors are resolved by hwmon name + label (coretemp/Package id 0,nvme/Composite), never byhwmonNindex (not stable across boots). - Alerts: level changes (ok ↔ warn ↔ crit) are logged to the journal with hysteresis (
clear_margin, default 5 °C) so a sensor sitting on the line doesn't spam. Watch withjournalctl -u van-thermal -f; warn/crit carry sd-daemon severity (-p warning/-p err). - History: throttled CSV at
/var/log/van-thermal.csv(one row perlog_interval, default 60 s), self-rotating to.csv.1pastlog_max_bytes(5 MB). - Tuning: edit
/etc/van-thermal/config.json(thresholds, intervals, sensor list), thensystemctl 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-thermalorcat /run/van-thermal/state.json.
NVMe watchdog (van-nvme-watch)
- Event-driven, not polled: tails
journalctl -kffor thenvme nvmeN: I/O tag ... timeout, reset controllersignature 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/tune2fssnapshot (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), thensystemctl restart van-nvme-watch. - Status:
systemctl status van-nvme-watch,journalctl -u van-nvme-watch -f, orcat /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 viaBAT0/capacity. Both resolve bytype(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(aftershutdown_grace, default 8 s, so the alert flushes first). - Edge-triggered per discharge episode: each severity fires once; the sequence re-arms when mains returns. Unplugging already below a warn level fires a single alert for the current severity (no burst), then shutdown at 10 %.
- Credentials live in
/etc/van-battery/pushover.json(mode 0600), seeded frompushover.json.exampleon first deploy and never committed. Missing/placeholder creds disable sending but not the shutdown — running flat must always power down safely (the skip is logged to the journal). - Tune in
/etc/van-battery/config.json(warn_levels,shutdown_level,poll_interval), thensystemctl restart van-battery. - Status:
systemctl status van-battery,journalctl -u van-battery -f, orcat /run/van-battery/state.json. - 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/wakeuponly 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
- Plug the USB modem in. ModemManager + the
Koodogsm 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 - It auto-joins as the
cellularWAN at metric 300 (last resort). Nothing else to configure. - Confirm with
mmcli -Land the Cockpit failover card (cellular flips fromabsenttoup). - Gotcha (handled in
van-failoversince 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 viaGENERAL.IP-IFACE— probing orip routeagainstcdc-wdm0silently sees nothing. The Cockpit WAN table does the same mapping (shows the netdev + its IP; nmcli actions still targetcdc-wdm0). - 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 (vendor2c7c) 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.rulesudev rule matches the NMEA tty (2c7c:0125 if01), symlinks it to/dev/modem-gps, and hot-adds it to gpsd viagpsdctl@%k— the same mechanism as gpsd's own 60-gpsd.rules, so plug/unplug/renumbering just works.gps/gpsd.default(/etc/default/gpsd) keepsDEVICESempty (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-unmanagedis 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 fromgps/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 holdsvanqon 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 -HUPthe process toggles per-fix logging to the journal. - Verify:
journalctl -u van-gps-owntracksshowsmqtt connectand stays quiet; a restart publishes the current fix immediately (first-message path), so subscribing toowntracks/rv/gpswhile 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 fromli3/config.json.example— edit after first deploy, same pattern asvan-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 (
Discoveringstaysyesforever, every subsequent connect fails withle-connection-abort-by-local) — as soon as a scan fails the daemon restartsbluetooth.serviceitself 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-batteryholds it, the Li3 phone app can't connect simultaneously.systemctl stop van-li3-batteryto free it up for the app. - Verify:
journalctl -u van-li3-battery -f(look forconnected/published: {...}), ormosquitto_sub -h localhost -u homeassistant -P <pw> -t van/li3_battery/state.
ZeroTier managed DNS
wrede.pvtresolves over ZeroTier when off the home LAN. Mechanism:allowDNS=1(prereq) +zerotier-systemd-managerwrites99-ztuga7c2kh.network, networkd applies it to resolved.- Verify:
resolvectl status ztuga7c2khshowsDNSscope + the two servers +wrede.pvt. - NOTE: ZeroTier's native DNS push is a no-op on Linux (
setDns ... not implemented), which is why the manager is required. systemd-networkd-wait-onlineis masked so the ZT link does not gatenetwork-online.target— see Gotchas.
Cockpit dashboard
https://<wayback>:9090→ "Van Router". Reload the browser after deploying plugin changes (Cockpit caches packages per session).- Read-only status works as any user; Prefer/Up/Down/Restart need Administrative access (polkit).
- The Access Points card shows both bands (5GHz
hostapd, 2.4GHzhostapd-2g) with per-band client lists and Restart buttons; the radio list lives invanrouter.js(const APS). - Prefer sets the manual WAN preference (see Manual preference above); Up/Down connect/disconnect the NM device.
Router status via MQTT (van-router-mqtt) — replacing Cockpit above
- Publishes the same status Cockpit's plugin showed, and accepts the same actions, over
MQTT with Home Assistant discovery — no web server/login of its own; HA (already running
on this Pi) owns the UI. Broker is the local Mosquitto on
127.0.0.1:1883(already used by HA itself and byvan-gps-owntracks/li3); it currently allows anonymous connections, soconfig.jsoncarries no credentials. - State topics (
van/router/...):ap/5g,ap/2g,clients,starlink,wan,deploywarn, plus verbatim republishes ofvan-failover's andvan-thermal's own/run/van-*/state.json(those two daemons were not modified —van-thermalis deliberately stdlib-only for boot resilience, andvan-failover's control loop wasn't touched).van/router/batteryonly appears ifpower/van-battery(documented in this README, not actually deployed on this host) ever starts writing its state file. - Command topics (
vanlink/cmd/...):ap/{5g,2g}/restart,modem/restart,wan/{wifi,starlink,cellular}/set(connect/disconnect),wan/prefer(auto/wifi/starlink/cellular— writes the same/run/van-failover/preferfile Cockpit's "Prefer" button did). Wi-Fi scan/connect (wlan0 as a WAN) was not ported — it's an interactive password-prompt flow that doesn't map to MQTT/HA entities well and is only used during initial setup; usenmclidirectly for that. - Verify:
journalctl -u van-router-mqtt -f,mosquitto_sub -h localhost -t 'van/router/#' -v. Entities should appear in HA under the "Van Router" device once the integration picks up the retained discovery messages (Settings → Devices & Services → MQTT).
Home Assistant (native Podman container)
- HA Container runs on the host via a Podman Quadlet (
ha/homeassistant.container→/etc/containers/systemd/; systemd generateshomeassistant.service). Host network: HA binds:8123directly, so clients usehttp://10.42.1.1:8123(orhttp://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 onboardhci0to HA over the mounted/run/dbussocket — 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.containerfile:./deploy.sh(or install +systemctl daemon-reload), thensystemctl restart homeassistant. Verify:curl -s -o /dev/null -w '%{http_code}' http://10.42.1.1:8123/→200. - History: HA started as a HAOS VM on wayback (libvirt NAT, then bridged onto
br0at10.42.1.50with 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=suspendwould sleep it on lid close (even on AC). Thepower/10-vanlink-nolid.confdrop-in sets all three lid actions toignore;deploy.shalso masks every sleep target so idle / GUI / a straysystemctl suspendcan't suspend it. - Verify:
busctl get-property org.freedesktop.login1 /org/freedesktop/login1 org.freedesktop.login1.Manager HandleLidSwitch→"ignore", andsystemctl is-enabled suspend.target→masked.busctl call ... CanSuspendshould return"no". - To re-enable sleep (e.g. if wayback is ever a laptop again):
(then remove the
sudo rm /etc/systemd/logind.conf.d/10-vanlink-nolid.conf sudo systemctl unmask sleep.target suspend.target hibernate.target hybrid-sleep.target sudo systemctl restart systemd-logindpower/block fromdeploy.sh, or the next deploy re-applies it).
4. Deploy / rebuild
cd ~/vanlink
sudo ./deploy.sh # copies all files to their system locations, reloads + enables services
Four things deploy.sh does not do (one-time, manual):
- zerotier-systemd-manager binary (v0.4.0, hand-installed — not in the ZeroTier apt repo):
# copy /usr/bin/zerotier-systemd-manager from a host that has it (e.g. wertvoll), then: sudo systemctl enable --now zerotier-systemd-manager.timer sudo zerotier-cli set d3ecf5726d041b2a allowDNS=1 - hostapd unmask (Ubuntu ships it masked):
sudo systemctl unmask hostapd. - Pushover credentials for
van-battery— deploy seeds/etc/van-battery/pushover.json(0600) with placeholders; fill in your app token + user key, thensudo systemctl restart van-battery. Until then low-battery alerts are skipped (logged), but the 10 % auto-shutdown still works. - Heartbeat client
hbc(dead-man's switch) — install the binary once into andreas' venv:git clone https://git.wrede.ca/andreas/heartbeat.git ~/git/heartbeat # if not already cloned sh ~/git/heartbeat/scripts/hb_install.sh client # -> ~/venvs/hbd + ~/bin/hbcdeploy.shinstalls/etc/hbc.yaml+ thehbc.serviceunit and starts it once~/bin/hbcexists. The service reports to hbd.wrede.pvt (UDP 50003) as userandreas. Upgrade later with~/bin/hb_install.sh client.
5. Key design decisions & hard-won gotchas
- 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.
rtw89power-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 failunder throughput, SSID drops. (timed out to flush queuesalone 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/rtw89driver (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=yinap/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 viasudo ./ap/install-rtw89-driver.sh(pins a specific upstream commit,dkms install,make install_fw) — not run automatically bydeploy.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_gitsuffix (rtw89_8852bu_gitetc.) —ap/rtw89.conf's blacklist stops the in-kernel modules from claiming the device instead. Gotcha hit switching a live system over: the in-kernelrtw89_corewon'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, nevernmcli device reapply.reapplyresets the r8152 USB-ethernet carrier, which caused a ~10s-ping-drop flapping feedback loop. Pureip routechanges are carrier-safe. - Disable EEE on USB ethernet (
50-disable-eeedispatcher) — its idle power-save parks the backup WAN link and breaks health probes. - Per-WAN probing needs
curl --interface if!<dev>(forcesSO_BINDTODEVICE); plain--interface <name>only sets the source IP and still routes via the default WAN.rp_filteris loose (2), required for this. arp_ignore/arp_announcematter only when two WANs share a subnet (a home-LAN test artifact; Starlink + neighbour-wifi will be on different subnets in the van). Harmless to keep.- dnsmasq uses
bind-dynamicbound to the AP iface so it coexists with systemd-resolved (no port-53 fight) — resolved stays intact for the host. systemd-networkd-wait-onlinemust be masked or it deadlocks boot. networkd here manages only two links — the AP (wlxc…, local-only) and the ZT overlayztuga7c2kh— and neither is a real uplink. The ZT link only appears oncezerotier-onestarts, butzerotier-oneis orderedAfter=network-online.target, so wait-online would block on the not-yet-existing link until its 120s timeout. Marking the linksRequiredForOnline=nodoes not help: with zero candidate links, wait-online can never satisfy "online" and times out anyway (systemd 255 — verified:Timeout occurred while waiting for network connectivityat exactly +120s). Both add ~2min to every boot and delay ZeroTier (the recovery path). The fix is to mask the service entirely (deploy.shdoes this); real uplink readiness is covered byNetworkManager-wait-online, which owns the actual WANs.
6. Recovery / safety
- Three independent management paths: ethernet
.251, wifi.27, ZeroTier192.168.196.22. Changing default-route metrics does not affect the local-subnet (SSH) routes. - The AP fan/EC, hostapd, networkd, nftables, sysctl, failover, regdomain are all enabled and reboot-persistent.
7. Pending / future ideas
- Real-world soak once the actual Starlink terminal + USB 4G/5G modem are installed (current eth is the home LAN; subnets/probe targets may want tuning).
- Cockpit plugin: add an AP-config form (SSID/channel/PSK editing
hostapd.conf). (Manual failover override via the "Prefer" button is done — see §3.) - Consider pinning a fixed interface name for the WAN ethernet (USB MAC-derived names change if the adapter is swapped).