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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
23448294a4
commit
9bf142074a
@@ -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:
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# /etc/netplan/50-van-wan.yaml — replaces cloud-init's 50-cloud-init.yaml.
|
||||
# Both onboard interfaces are 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), eth0 = ethernet uplink. The AP radios and
|
||||
# the wired LAN port (USB dongles, wlx*/enx* MAC-named) 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 both uplinks).
|
||||
network:
|
||||
version: 2
|
||||
ethernets:
|
||||
eth0:
|
||||
renderer: NetworkManager
|
||||
optional: true
|
||||
dhcp4: true
|
||||
wifis:
|
||||
wlan0:
|
||||
renderer: NetworkManager
|
||||
optional: true
|
||||
dhcp4: true
|
||||
access-points:
|
||||
"Wapana":
|
||||
auth:
|
||||
key-management: "psk"
|
||||
password: "6e1335fd97165a7d2618bec19824be363a2766d7765f91aa14773d871eaa59dc"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,5 @@ 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
|
||||
dhcp-option=option:router,10.42.0.1
|
||||
dhcp-option=option:dns-server,10.42.0.1
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/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).
|
||||
# Idempotent. Netplan (onboard eth0+wlan0 = NM-managed WANs) is NOT touched here —
|
||||
# reference copy in ap/50-van-wan.yaml, applied once manually (apply flaps uplinks).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$(readlink -f "$0")")"
|
||||
[ "$(id -u)" = 0 ] || { echo "Run with sudo (writes to /etc, /usr)."; exit 1; }
|
||||
@@ -11,13 +11,13 @@ echo "== access point =="
|
||||
install -D -m0644 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 -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 -m0755 ap/van-ap-watchdog /usr/local/sbin/van-ap-watchdog
|
||||
install -D -m0644 ap/van-ap-watchdog.service /etc/systemd/system/van-ap-watchdog.service
|
||||
install -D -m0644 ap/van-ap-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
|
||||
@@ -37,9 +37,6 @@ install -D -m0644 failover/van-failover.service /etc/systemd/system/van-failover
|
||||
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
|
||||
|
||||
echo "== zerotier managed dns =="
|
||||
install -D -m0644 dns/zt-search.conf /etc/systemd/network/99-ztuga7c2kh.network.d/search.conf
|
||||
|
||||
echo "== cockpit plugin =="
|
||||
install -d /usr/share/cockpit/vanrouter
|
||||
install -m0644 cockpit/vanrouter/* /usr/share/cockpit/vanrouter/
|
||||
@@ -48,60 +45,32 @@ 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)"
|
||||
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 "== 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.
|
||||
# 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-ap-watchdog van-ap-watchdog-2g >/dev/null 2>&1 || true
|
||||
systemctl restart van-thermal
|
||||
# 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
|
||||
# AP watchdogs last, after hostapd is back up (they only ever restart a wedged hostapd)
|
||||
@@ -109,10 +78,10 @@ systemctl restart van-ap-watchdog van-ap-watchdog-2g
|
||||
networkctl reload 2>/dev/null || true
|
||||
|
||||
echo
|
||||
echo "Deployed. Verify:"
|
||||
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 wlxc83a35a4ee55 info | grep -E 'ssid|channel|width'"
|
||||
echo " iw dev wlxd8ec5e2faa8c 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"
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# vim: ft=systemd
|
||||
# --- Managed by zerotier-systemd-manager. Do not remove this comment. ---
|
||||
[Match]
|
||||
Name=ztuga7c2kh
|
||||
|
||||
[Network]
|
||||
Description=suspicious_house
|
||||
DHCP=no
|
||||
DNS=192.168.196.115
|
||||
DNS=192.168.10.5
|
||||
Domains=~wrede.pvt ~196.168.192.in-addr.arpa ~c.e.3.d.d.f.ip6.arpa
|
||||
ConfigureWithoutCarrier=true
|
||||
KeepConfiguration=static
|
||||
@@ -1,8 +0,0 @@
|
||||
[Unit]
|
||||
Description=Update zerotier per-interface DNS settings
|
||||
Requires=zerotier-one.service
|
||||
After=zerotier-one.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/zerotier-systemd-manager
|
||||
@@ -1,9 +0,0 @@
|
||||
[Unit]
|
||||
Description=Update zerotier per-interface DNS settings
|
||||
|
||||
[Timer]
|
||||
OnStartupSec=1min
|
||||
OnUnitInactiveSec=1min
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,4 +0,0 @@
|
||||
allowManaged=1
|
||||
allowGlobal=0
|
||||
allowDefault=0
|
||||
allowDNS=1
|
||||
@@ -1,3 +0,0 @@
|
||||
[Network]
|
||||
Domains=
|
||||
Domains=wrede.pvt ~196.168.192.in-addr.arpa ~c.e.3.d.d.f.ip6.arpa
|
||||
@@ -9,7 +9,8 @@
|
||||
"http://cp.cloudflare.com/"
|
||||
],
|
||||
"wans": [
|
||||
{ "name": "wifi", "device": "wlp1s0", "metric": 100 },
|
||||
{ "name": "wifi", "device": "wlan0", "metric": 100 },
|
||||
{ "name": "eth", "device": "eth0", "metric": 150 },
|
||||
{ "name": "starlink", "device": "enxd8ec5eeb3512", "metric": 200 },
|
||||
{ "name": "cellular", "connection": "Koodo", "metric": 300 }
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
[Unit]
|
||||
Description=Heartbeat client (hbc) — dead-man's switch + metrics to hbd.wrede.pvt
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Least privilege: the hbd server can push CMD (run a shell command on the client),
|
||||
# so this runs as the unprivileged user, not root. hbc lives in andreas' venv install.
|
||||
User=andreas
|
||||
ExecStart=/home/andreas/bin/hbc -b -c /etc/hbc.yaml hbd.wrede.pvt
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,15 +0,0 @@
|
||||
# hbc (heartbeat client) config for wayback — the van router's dead-man's switch.
|
||||
# Deployed to /etc/hbc.yaml by deploy.sh. Server host is passed on the command line
|
||||
# (hbd.wrede.pvt, see hbc.service). No secrets here.
|
||||
#
|
||||
# The client (~/bin/hbc) is installed once via the heartbeat project's own installer —
|
||||
# see README §4. This file only tunes intervals + which metric plugins to ship.
|
||||
|
||||
interval: 15 # heartbeat every 15s (server flags overdue a few s after a miss)
|
||||
|
||||
plugins:
|
||||
cpu_monitor: { interval: 300 }
|
||||
memory_monitor: { interval: 300 } # ZFS ARC-aware
|
||||
disk_monitor: { interval: 300 }
|
||||
network_monitor: { interval: 300 }
|
||||
zfs_monitor: { interval: 300 } # zroot health/capacity (readable as the service user)
|
||||
@@ -1,6 +0,0 @@
|
||||
[Login]
|
||||
# wayback is an always-on router living lid-closed in the van.
|
||||
# Default logind suspends on lid close (incl. on AC); ignore the lid in every state.
|
||||
HandleLidSwitch=ignore
|
||||
HandleLidSwitchExternalPower=ignore
|
||||
HandleLidSwitchDocked=ignore
|
||||
@@ -1,10 +1,10 @@
|
||||
# Hardware watchdog for unattended operation.
|
||||
#
|
||||
# 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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -6,7 +6,12 @@
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""van-battery — battery / mains monitor for the campervan router (wayback).
|
||||
|
||||
While running **off mains** (AC offline, i.e. on battery) it sends escalating
|
||||
Pushover alerts as the charge drops past each warn level, and at the shutdown
|
||||
level it sends a final alert and powers the machine off cleanly.
|
||||
|
||||
Alerts are edge-triggered per discharge episode: each severity fires once, and
|
||||
the whole sequence re-arms when mains power returns. Plug-out already below a
|
||||
warn level fires a single alert for the current severity, not a burst.
|
||||
|
||||
Pushover credentials live in a separate 0600 secrets file (see credentials_path),
|
||||
never in this repo. Missing/placeholder creds disable sending but NOT the
|
||||
shutdown — running flat must always power down safely. Stdlib only.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_PATH = os.environ.get("VAN_BATTERY_CONFIG", "/etc/van-battery/config.json")
|
||||
STATE_DIR = Path("/run/van-battery")
|
||||
STATE_PATH = STATE_DIR / "state.json"
|
||||
PSY = Path("/sys/class/power_supply")
|
||||
HOST = socket.gethostname()
|
||||
|
||||
DEFAULTS = {
|
||||
"poll_interval": 30, # seconds between reads (battery moves slowly)
|
||||
"ac_path": "/sys/class/power_supply/AC0/online",
|
||||
"battery_path": "/sys/class/power_supply/BAT0",
|
||||
"warn_levels": [25, 20, 15], # Pushover alert only
|
||||
"shutdown_level": 10, # Pushover alert + poweroff
|
||||
"shutdown_grace": 8, # seconds to let the alert flush before poweroff
|
||||
"credentials_path": "/etc/van-battery/pushover.json",
|
||||
}
|
||||
|
||||
PLACEHOLDERS = {"", "REPLACE_ME", "your-token-here", "your-user-key-here"}
|
||||
|
||||
|
||||
def log(msg, level="info"):
|
||||
pri = {"info": "<6>", "warn": "<4>", "crit": "<2>"}.get(level, "<6>")
|
||||
print(pri + msg, flush=True)
|
||||
|
||||
|
||||
def load_config():
|
||||
cfg = dict(DEFAULTS)
|
||||
try:
|
||||
with open(CONFIG_PATH) as f:
|
||||
cfg.update(json.load(f))
|
||||
except FileNotFoundError:
|
||||
log(f"config {CONFIG_PATH} not found, using built-in defaults")
|
||||
except Exception as e:
|
||||
log(f"config {CONFIG_PATH} unreadable ({e}), using defaults", "warn")
|
||||
return cfg
|
||||
|
||||
|
||||
def _by_type(kind):
|
||||
"""Find a power_supply dir by its `type` (Mains / Battery) — fallback when the
|
||||
configured AC0/BAT0 name isn't present on this machine."""
|
||||
for d in sorted(PSY.glob("*")):
|
||||
try:
|
||||
if (d / "type").read_text().strip() == kind:
|
||||
return d
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def read_on_battery(cfg):
|
||||
"""True if running on battery (mains absent), False if on mains, None if unknown."""
|
||||
p = Path(cfg["ac_path"])
|
||||
if not p.exists():
|
||||
d = _by_type("Mains")
|
||||
p = (d / "online") if d else None
|
||||
if not p or not p.exists():
|
||||
return None
|
||||
try:
|
||||
return p.read_text().strip() == "0"
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def read_capacity(cfg):
|
||||
"""Battery charge percentage (int), or None."""
|
||||
d = Path(cfg["battery_path"])
|
||||
if not (d / "capacity").exists():
|
||||
d = _by_type("Battery") or d
|
||||
try:
|
||||
return int((d / "capacity").read_text().strip())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def load_creds(cfg):
|
||||
try:
|
||||
c = json.loads(Path(cfg["credentials_path"]).read_text())
|
||||
token, user = str(c.get("token", "")).strip(), str(c.get("user", "")).strip()
|
||||
if token in PLACEHOLDERS or user in PLACEHOLDERS:
|
||||
return None
|
||||
return token, user
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except Exception as e:
|
||||
log(f"credentials {cfg['credentials_path']} unreadable ({e})", "warn")
|
||||
return None
|
||||
|
||||
|
||||
def pushover(cfg, title, message, priority=0):
|
||||
creds = load_creds(cfg)
|
||||
if not creds:
|
||||
log(f"pushover skipped (no credentials): {title} — {message}", "warn")
|
||||
return False
|
||||
token, user = creds
|
||||
data = urllib.parse.urlencode({
|
||||
"token": token, "user": user, "title": title,
|
||||
"message": message, "priority": priority,
|
||||
}).encode()
|
||||
req = urllib.request.Request("https://api.pushover.net/1/messages.json", data=data)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
ok = resp.status == 200
|
||||
if not ok:
|
||||
log(f"pushover HTTP {resp.status}", "warn")
|
||||
return ok
|
||||
except Exception as e:
|
||||
log(f"pushover send failed: {e}", "warn")
|
||||
return False
|
||||
|
||||
|
||||
def severity(cap, levels):
|
||||
"""Most-severe (lowest) threshold the capacity has reached, or None if above all.
|
||||
levels: thresholds sorted ascending. cap=12, levels=[10,15,20,25] -> 15."""
|
||||
reached = [t for t in levels if cap <= t]
|
||||
return min(reached) if reached else None
|
||||
|
||||
|
||||
def poweroff(cfg):
|
||||
log("shutdown level reached — powering off", "crit")
|
||||
time.sleep(cfg["shutdown_grace"]) # give the Pushover POST time to land first
|
||||
try:
|
||||
subprocess.run(["systemctl", "poweroff"], check=False)
|
||||
except Exception as e:
|
||||
log(f"poweroff failed: {e}", "crit")
|
||||
|
||||
|
||||
def write_state(on_batt, cap, armed, warn_levels, shutdown_level):
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"updated": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"on_battery": on_batt,
|
||||
"capacity": cap,
|
||||
"alerted_below": armed, # lowest level alerted this discharge episode, or null
|
||||
"warn_levels": warn_levels,
|
||||
"shutdown_level": shutdown_level,
|
||||
}
|
||||
tmp = STATE_PATH.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(payload))
|
||||
tmp.replace(STATE_PATH)
|
||||
|
||||
|
||||
def main():
|
||||
cfg = load_config()
|
||||
levels = sorted(cfg["warn_levels"] + [cfg["shutdown_level"]])
|
||||
shutdown_level = cfg["shutdown_level"]
|
||||
last_alerted = None # lowest threshold alerted in the current discharge episode
|
||||
shutdown_issued = False
|
||||
prev_on_batt = None
|
||||
log(f"van-battery up: poll {cfg['poll_interval']}s, warn {cfg['warn_levels']}, "
|
||||
f"shutdown {shutdown_level}%")
|
||||
|
||||
while True:
|
||||
on_batt = read_on_battery(cfg)
|
||||
cap = read_capacity(cfg)
|
||||
|
||||
capstr = f"{cap}%" if cap is not None else "unknown charge"
|
||||
|
||||
# Mains <-> battery transition alerts. Skip the very first sample (prev is None)
|
||||
# so a restart while already on battery doesn't fire a spurious "on battery".
|
||||
if on_batt is not None and prev_on_batt is not None and on_batt != prev_on_batt:
|
||||
if on_batt:
|
||||
log(f"mains lost — running on battery at {capstr}", "warn")
|
||||
pushover(cfg, f"⚡ {HOST}: on battery",
|
||||
f"Mains power lost — now running on battery ({capstr}). "
|
||||
f"Low alerts at {cfg['warn_levels']}%, auto-shutdown at {shutdown_level}%.")
|
||||
else:
|
||||
log(f"mains restored at {capstr} — alerts re-armed")
|
||||
pushover(cfg, f"🔌 {HOST}: back on mains",
|
||||
f"Mains power restored ({capstr}). Battery alert sequence re-armed.")
|
||||
|
||||
if on_batt is False:
|
||||
# On mains: re-arm the whole sequence for the next discharge episode.
|
||||
last_alerted = None
|
||||
shutdown_issued = False
|
||||
elif on_batt is True and cap is not None:
|
||||
sev = severity(cap, levels)
|
||||
if sev is not None and (last_alerted is None or sev < last_alerted):
|
||||
last_alerted = sev
|
||||
is_shutdown = sev <= shutdown_level
|
||||
if is_shutdown:
|
||||
pushover(cfg, f"⚠ {HOST}: battery {cap}% — shutting down",
|
||||
f"On battery at {cap}% (≤{shutdown_level}%). Powering off now to "
|
||||
f"protect the system.", priority=1)
|
||||
if not shutdown_issued:
|
||||
shutdown_issued = True
|
||||
poweroff(cfg)
|
||||
else:
|
||||
log(f"battery {cap}% on battery — alerting (level {sev})", "warn")
|
||||
pushover(cfg, f"{HOST}: battery {cap}%",
|
||||
f"Running on battery, charge down to {cap}% (alert at {sev}%). "
|
||||
f"Shutdown at {shutdown_level}%.")
|
||||
|
||||
prev_on_batt = on_batt
|
||||
try:
|
||||
write_state(on_batt, cap, last_alerted, cfg["warn_levels"], shutdown_level)
|
||||
except OSError as e:
|
||||
log(f"state write failed: {e}", "warn")
|
||||
time.sleep(cfg["poll_interval"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
@@ -1,13 +0,0 @@
|
||||
[Unit]
|
||||
Description=Battery monitor + low-charge Pushover alerts / safe shutdown for the campervan router
|
||||
After=local-fs.target network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/sbin/van-battery
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user