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`
|
# vanlink — campervan router on `wayback`
|
||||||
|
|
||||||
|
> **This clone = the Pi 4 port (host `wan`, branch `wan`).** Onboard `eth0`+`wlan0` are
|
||||||
|
> NM-managed WANs (netplan `ap/50-van-wan.yaml`); the AP radios + wired LAN port arrive
|
||||||
|
> with the USB hub from wayback (same MAC-derived names, so all configs port verbatim).
|
||||||
|
> Dropped here: HA VM, battery/lid (no hardware), heartbeat + ZeroTier (not installed yet).
|
||||||
|
> Watchdog is 10s (bcm2835 max 15s); thermal watches `cpu_thermal`.
|
||||||
|
|
||||||
Turns **wayback** (Asus ZenBook UX391U, Ubuntu 24.04, zabbly kernel) into a self-contained
|
Turns **wayback** (Asus ZenBook UX391U, Ubuntu 24.04, zabbly kernel) into a self-contained
|
||||||
campervan hub/router/AP:
|
campervan hub/router/AP:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
table ip van_router_nat
|
||||||
delete table ip van_router_nat
|
delete table ip van_router_nat
|
||||||
table ip van_router_nat {
|
table ip van_router_nat {
|
||||||
chain prerouting {
|
|
||||||
type nat hook prerouting priority dstnat; policy accept;
|
|
||||||
# Home Assistant VM (ha_van) is bridged onto br0 at 10.42.0.50 — clients reach
|
|
||||||
# it directly. Keep the legacy http://10.42.0.1:8123 URL working for anything
|
|
||||||
# that bookmarked it (phones, ZT clients).
|
|
||||||
ip daddr 10.42.0.1 tcp dport 8123 dnat to 10.42.0.50:8123
|
|
||||||
}
|
|
||||||
chain postrouting {
|
chain postrouting {
|
||||||
type nat hook postrouting priority srcnat; policy accept;
|
type nat hook postrouting priority srcnat; policy accept;
|
||||||
ip saddr 10.42.0.0/24 oifname != "br0" masquerade
|
ip saddr 10.42.0.0/24 oifname != "br0" masquerade
|
||||||
# Hairpin for the legacy 10.42.0.1:8123 DNAT when the client sits on the same
|
|
||||||
# subnet as the HA VM: without masquerade the VM would reply directly on br0
|
|
||||||
# from 10.42.0.50 and the client (expecting 10.42.0.1) would drop it. ZT-sourced
|
|
||||||
# traffic doesn't match and doesn't need it — VM replies route back through us.
|
|
||||||
ip saddr 10.42.0.0/24 ip daddr 10.42.0.50 tcp dport 8123 oifname "br0" masquerade
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,5 @@ domain-needed
|
|||||||
bogus-priv
|
bogus-priv
|
||||||
dhcp-authoritative
|
dhcp-authoritative
|
||||||
dhcp-range=10.42.0.10,10.42.0.254,255.255.255.0,12h
|
dhcp-range=10.42.0.10,10.42.0.254,255.255.255.0,12h
|
||||||
# Home Assistant VM (libvirt ha_van, bridged onto br0) — pinned address, resolves
|
|
||||||
# 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:router,10.42.0.1
|
||||||
dhcp-option=option:dns-server,10.42.0.1
|
dhcp-option=option:dns-server,10.42.0.1
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Deploy vanlink configs/scripts from this directory to their system locations.
|
# Deploy vanlink configs/scripts (Pi 4 "wan" port) to their system locations.
|
||||||
# Usage: cd ~/vanlink && sudo ./deploy.sh
|
# Usage: cd ~/vanlink && sudo ./deploy.sh
|
||||||
# Idempotent. See README.md §4 for the two manual steps this does NOT do
|
# Idempotent. Netplan (onboard eth0+wlan0 = NM-managed WANs) is NOT touched here —
|
||||||
# (zerotier-systemd-manager binary install, hostapd unmask).
|
# reference copy in ap/50-van-wan.yaml, applied once manually (apply flaps uplinks).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$(readlink -f "$0")")"
|
cd "$(dirname "$(readlink -f "$0")")"
|
||||||
[ "$(id -u)" = 0 ] || { echo "Run with sudo (writes to /etc, /usr)."; exit 1; }
|
[ "$(id -u)" = 0 ] || { echo "Run with sudo (writes to /etc, /usr)."; exit 1; }
|
||||||
@@ -11,13 +11,13 @@ echo "== access point =="
|
|||||||
install -D -m0644 ap/hostapd.conf /etc/hostapd/hostapd.conf
|
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/hostapd-restart.conf /etc/systemd/system/hostapd.service.d/restart.conf
|
||||||
install -D -m0644 ap/default-hostapd /etc/default/hostapd
|
install -D -m0644 ap/default-hostapd /etc/default/hostapd
|
||||||
install -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.conf /etc/hostapd/hostapd-2g.conf
|
||||||
install -D -m0644 ap/hostapd-2g.service /etc/systemd/system/hostapd-2g.service
|
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/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/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/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.conf /etc/van-ap/dnsmasq.conf
|
||||||
install -D -m0644 ap/van-ap-dnsmasq.service /etc/systemd/system/van-ap-dnsmasq.service
|
install -D -m0644 ap/van-ap-dnsmasq.service /etc/systemd/system/van-ap-dnsmasq.service
|
||||||
install -D -m0644 ap/10-van-ap.network /etc/systemd/network/10-van-ap.network
|
install -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 -m0755 failover/50-disable-eee /etc/NetworkManager/dispatcher.d/50-disable-eee
|
||||||
install -D -m0644 failover/99-van-arp.conf /etc/sysctl.d/99-van-arp.conf
|
install -D -m0644 failover/99-van-arp.conf /etc/sysctl.d/99-van-arp.conf
|
||||||
|
|
||||||
echo "== zerotier managed dns =="
|
|
||||||
install -D -m0644 dns/zt-search.conf /etc/systemd/network/99-ztuga7c2kh.network.d/search.conf
|
|
||||||
|
|
||||||
echo "== cockpit plugin =="
|
echo "== cockpit plugin =="
|
||||||
install -d /usr/share/cockpit/vanrouter
|
install -d /usr/share/cockpit/vanrouter
|
||||||
install -m0644 cockpit/vanrouter/* /usr/share/cockpit/vanrouter/
|
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 -m0755 power/van-thermal /usr/local/sbin/van-thermal
|
||||||
install -D -m0644 power/thermal-config.json /etc/van-thermal/config.json
|
install -D -m0644 power/thermal-config.json /etc/van-thermal/config.json
|
||||||
install -D -m0644 power/van-thermal.service /etc/systemd/system/van-thermal.service
|
install -D -m0644 power/van-thermal.service /etc/systemd/system/van-thermal.service
|
||||||
|
# Pushover secrets live only on the system (0600), never in the repo. Path kept
|
||||||
echo "== battery monitor =="
|
# under /etc/van-battery/ for parity with wayback's van-thermal default.
|
||||||
install -D -m0755 power/van-battery /usr/local/sbin/van-battery
|
|
||||||
install -D -m0644 power/battery-config.json /etc/van-battery/config.json
|
|
||||||
install -D -m0644 power/van-battery.service /etc/systemd/system/van-battery.service
|
|
||||||
# Pushover secrets live only on the system (0600), never in the repo. Seed from the
|
|
||||||
# template on first deploy; never clobber a filled-in file on later deploys.
|
|
||||||
if [ ! -f /etc/van-battery/pushover.json ]; then
|
if [ ! -f /etc/van-battery/pushover.json ]; then
|
||||||
install -D -m0600 power/pushover.json.example /etc/van-battery/pushover.json
|
install -D -m0600 power/pushover.json.example /etc/van-battery/pushover.json
|
||||||
echo " -> seeded /etc/van-battery/pushover.json (EDIT IT: add Pushover token + user)"
|
echo " -> seeded /etc/van-battery/pushover.json (EDIT IT: add Pushover token + user)"
|
||||||
fi
|
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 =="
|
echo "== hardware watchdog =="
|
||||||
install -D -m0644 power/10-vanlink-watchdog.conf /etc/systemd/system.conf.d/10-vanlink-watchdog.conf
|
install -D -m0644 power/10-vanlink-watchdog.conf /etc/systemd/system.conf.d/10-vanlink-watchdog.conf
|
||||||
|
|
||||||
echo "== heartbeat client (dead-man's switch) =="
|
|
||||||
install -D -m0644 heartbeat/hbc.yaml /etc/hbc.yaml
|
|
||||||
install -D -m0644 heartbeat/hbc.service /etc/systemd/system/hbc.service
|
|
||||||
# The hbc binary itself (~/bin/hbc + venv) is installed once via the heartbeat
|
|
||||||
# project's installer — see README §4. Only start the service if it's present.
|
|
||||||
if [ ! -x /home/andreas/bin/hbc ]; then
|
|
||||||
echo " -> /home/andreas/bin/hbc not found; run 'sh ~/git/heartbeat/scripts/hb_install.sh client' (README §4)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "== apply =="
|
echo "== apply =="
|
||||||
sysctl --system >/dev/null
|
sysctl --system >/dev/null
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
# Re-exec PID1 so the system.conf.d watchdog drop-in takes effect (daemon-reload alone
|
# Re-exec PID1 so the system.conf.d watchdog drop-in takes effect (daemon-reload
|
||||||
# does NOT re-arm RuntimeWatchdogSec). Safe online: re-exec keeps all services running.
|
# alone does NOT re-arm RuntimeWatchdogSec). Safe online.
|
||||||
systemctl daemon-reexec
|
systemctl daemon-reexec
|
||||||
# networkd here owns only the AP + ZT overlay (neither a real uplink), so its wait-online
|
# networkd here owns only the AP radios + bridge + wired LAN port (no real uplink);
|
||||||
# can never satisfy "online" and just burns its 120s timeout, stalling network-online.target
|
# its wait-online would just stall network-online.target. NM-wait-online covers WANs.
|
||||||
# and ZeroTier by ~2min every boot. Real uplink readiness is covered by NetworkManager-wait-online.
|
|
||||||
systemctl mask systemd-networkd-wait-online.service >/dev/null 2>&1 || true
|
systemctl mask systemd-networkd-wait-online.service >/dev/null 2>&1 || true
|
||||||
# pick up the lid drop-in (re-execs logind; does NOT drop the network)
|
|
||||||
systemctl restart systemd-logind >/dev/null 2>&1 || true
|
|
||||||
systemctl unmask hostapd >/dev/null 2>&1 || true
|
systemctl unmask hostapd >/dev/null 2>&1 || true
|
||||||
systemctl enable regdomain.service hostapd hostapd-2g van-ap-dnsmasq nftables systemd-networkd van-failover van-thermal van-battery van-ap-watchdog van-ap-watchdog-2g >/dev/null 2>&1 || true
|
systemctl enable regdomain.service hostapd hostapd-2g van-ap-dnsmasq nftables systemd-networkd van-failover van-thermal van-ap-watchdog van-ap-watchdog-2g >/dev/null 2>&1 || true
|
||||||
systemctl restart van-thermal van-battery
|
systemctl restart van-thermal
|
||||||
# Heartbeat: only enable/start once the client binary is installed (README §4).
|
# Pick up unmanaged-devices changes so NM releases/keeps the right interfaces.
|
||||||
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.
|
|
||||||
nmcli general reload 2>/dev/null || systemctl reload NetworkManager 2>/dev/null || true
|
nmcli general reload 2>/dev/null || systemctl reload NetworkManager 2>/dev/null || true
|
||||||
# restart in dependency order: br0 + AP iface + LAN member first, then hostapd adds
|
# restart in dependency order: bridge + members first, then hostapd enslaves the
|
||||||
# the wlan to br0, then dnsmasq binds br0, then NAT/failover
|
# radios, then dnsmasq binds br0, then NAT/failover
|
||||||
systemctl restart systemd-networkd
|
systemctl restart systemd-networkd
|
||||||
systemctl restart hostapd hostapd-2g van-ap-dnsmasq nftables van-failover
|
systemctl restart hostapd hostapd-2g van-ap-dnsmasq nftables van-failover
|
||||||
# AP watchdogs last, after hostapd is back up (they only ever restart a wedged hostapd)
|
# AP watchdogs last, after hostapd is back up (they only ever restart a wedged hostapd)
|
||||||
@@ -109,10 +78,10 @@ systemctl restart van-ap-watchdog van-ap-watchdog-2g
|
|||||||
networkctl reload 2>/dev/null || true
|
networkctl reload 2>/dev/null || true
|
||||||
|
|
||||||
echo
|
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 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-failover/state.json"
|
||||||
echo " cat /run/van-thermal/state.json # CPU + NVMe temps"
|
echo " cat /run/van-thermal/state.json"
|
||||||
echo " cat /run/van-battery/state.json # mains/battery + charge %"
|
|
||||||
echo " 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'."
|
|
||||||
|
|||||||
@@ -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/"
|
"http://cp.cloudflare.com/"
|
||||||
],
|
],
|
||||||
"wans": [
|
"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": "starlink", "device": "enxd8ec5eeb3512", "metric": 200 },
|
||||||
{ "name": "cellular", "connection": "Koodo", "metric": 300 }
|
{ "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.
|
# Hardware watchdog for unattended operation.
|
||||||
#
|
#
|
||||||
# PID1 pets /dev/watchdog0 (intel_oc_wdt) every RuntimeWatchdogSec/2. If systemd
|
# PID1 pets /dev/watchdog0 (bcm2835_wdt) every RuntimeWatchdogSec/2. If systemd
|
||||||
# itself wedges for longer than RuntimeWatchdogSec, the chip hard-resets the box —
|
# itself wedges for longer than RuntimeWatchdogSec, the chip hard-resets the box (bcm2835 max is 15s, hence 10s here; wayback uses 20s) —
|
||||||
# the only way to recover a hung router with nobody there to open the lid.
|
# the only way to recover a hung router with nobody there to open the lid.
|
||||||
# (See the EC-latch / USB-hub-hang history.) RebootWatchdogSec also guards against
|
# (See the EC-latch / USB-hub-hang history.) RebootWatchdogSec also guards against
|
||||||
# a reboot that hangs partway.
|
# a reboot that hangs partway.
|
||||||
[Manager]
|
[Manager]
|
||||||
RuntimeWatchdogSec=20s
|
RuntimeWatchdogSec=10s
|
||||||
RebootWatchdogSec=5min
|
RebootWatchdogSec=5min
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"poll_interval": 30,
|
|
||||||
"ac_path": "/sys/class/power_supply/AC0/online",
|
|
||||||
"battery_path": "/sys/class/power_supply/BAT0",
|
|
||||||
"warn_levels": [25, 20, 15],
|
|
||||||
"shutdown_level": 10,
|
|
||||||
"shutdown_grace": 8,
|
|
||||||
"credentials_path": "/etc/van-battery/pushover.json"
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,12 @@
|
|||||||
"pushover_level": "warn",
|
"pushover_level": "warn",
|
||||||
"credentials_path": "/etc/van-battery/pushover.json",
|
"credentials_path": "/etc/van-battery/pushover.json",
|
||||||
"sensors": [
|
"sensors": [
|
||||||
{ "name": "cpu", "hwmon": "coretemp", "label": "Package id 0", "warn": 80, "crit": 95, "clear_margin": 5 },
|
{
|
||||||
{ "name": "nvme", "hwmon": "nvme", "label": "Composite", "warn": 65, "crit": 70, "clear_margin": 5 }
|
"name": "cpu",
|
||||||
|
"hwmon": "cpu_thermal",
|
||||||
|
"warn": 80,
|
||||||
|
"crit": 85,
|
||||||
|
"clear_margin": 5
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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