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:
Andreas Wrede
2026-07-06 16:59:34 -04:00
co-authored by Claude Fable 5
parent 23448294a4
commit 9bf142074a
20 changed files with 63 additions and 487 deletions
+6
View File
@@ -1,5 +1,11 @@
# vanlink — campervan router on `wayback`
> **This clone = the Pi 4 port (host `wan`, branch `wan`).** Onboard `eth0`+`wlan0` are
> NM-managed WANs (netplan `ap/50-van-wan.yaml`); the AP radios + wired LAN port arrive
> with the USB hub from wayback (same MAC-derived names, so all configs port verbatim).
> Dropped here: HA VM, battery/lid (no hardware), heartbeat + ZeroTier (not installed yet).
> Watchdog is 10s (bcm2835 max 15s); thermal watches `cpu_thermal`.
Turns **wayback** (Asus ZenBook UX391U, Ubuntu 24.04, zabbly kernel) into a self-contained
campervan hub/router/AP:
+24
View File
@@ -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"
-12
View File
@@ -4,20 +4,8 @@
table ip van_router_nat
delete table ip van_router_nat
table ip van_router_nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
# Home Assistant VM (ha_van) is bridged onto br0 at 10.42.0.50 — clients reach
# it directly. Keep the legacy http://10.42.0.1:8123 URL working for anything
# that bookmarked it (phones, ZT clients).
ip daddr 10.42.0.1 tcp dport 8123 dnat to 10.42.0.50:8123
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
ip saddr 10.42.0.0/24 oifname != "br0" masquerade
# Hairpin for the legacy 10.42.0.1:8123 DNAT when the client sits on the same
# subnet as the HA VM: without masquerade the VM would reply directly on br0
# from 10.42.0.50 and the client (expecting 10.42.0.1) would drop it. ZT-sourced
# traffic doesn't match and doesn't need it — VM replies route back through us.
ip saddr 10.42.0.0/24 ip daddr 10.42.0.50 tcp dport 8123 oifname "br0" masquerade
}
}
-3
View File
@@ -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
+21 -52
View File
@@ -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"
-13
View File
@@ -1,13 +0,0 @@
# vim: ft=systemd
# --- Managed by zerotier-systemd-manager. Do not remove this comment. ---
[Match]
Name=ztuga7c2kh
[Network]
Description=suspicious_house
DHCP=no
DNS=192.168.196.115
DNS=192.168.10.5
Domains=~wrede.pvt ~196.168.192.in-addr.arpa ~c.e.3.d.d.f.ip6.arpa
ConfigureWithoutCarrier=true
KeepConfiguration=static
-8
View File
@@ -1,8 +0,0 @@
[Unit]
Description=Update zerotier per-interface DNS settings
Requires=zerotier-one.service
After=zerotier-one.service
[Service]
Type=oneshot
ExecStart=/usr/bin/zerotier-systemd-manager
-9
View File
@@ -1,9 +0,0 @@
[Unit]
Description=Update zerotier per-interface DNS settings
[Timer]
OnStartupSec=1min
OnUnitInactiveSec=1min
[Install]
WantedBy=timers.target
-4
View File
@@ -1,4 +0,0 @@
allowManaged=1
allowGlobal=0
allowDefault=0
allowDNS=1
-3
View File
@@ -1,3 +0,0 @@
[Network]
Domains=
Domains=wrede.pvt ~196.168.192.in-addr.arpa ~c.e.3.d.d.f.ip6.arpa
+2 -1
View File
@@ -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 }
]
-87
View File
@@ -1,87 +0,0 @@
<domain type='kvm'>
<name>ha_van</name>
<uuid>af014c94-de20-4f52-8d6b-438f16cd82e6</uuid>
<description>Home Assistant OS</description>
<memory unit='KiB'>4194304</memory>
<currentMemory unit='KiB'>4194304</currentMemory>
<vcpu placement='static'>2</vcpu>
<os firmware='efi'>
<type arch='x86_64' machine='pc-i440fx-noble-v2'>hvm</type>
<firmware>
<feature enabled='no' name='enrolled-keys'/>
<feature enabled='no' name='secure-boot'/>
</firmware>
<loader readonly='yes' type='pflash'>/usr/share/OVMF/OVMF_CODE_4M.fd</loader>
<nvram template='/usr/share/OVMF/OVMF_VARS_4M.fd'>/var/lib/libvirt/qemu/nvram/ha_van_VARS.fd</nvram>
<boot dev='hd'/>
</os>
<features>
<acpi/>
<apic/>
</features>
<cpu mode='host-passthrough' check='none' migratable='on'/>
<clock offset='utc'>
<timer name='rtc' tickpolicy='catchup'/>
<timer name='pit' tickpolicy='delay'/>
<timer name='hpet' present='no'/>
</clock>
<on_poweroff>destroy</on_poweroff>
<on_reboot>restart</on_reboot>
<on_crash>destroy</on_crash>
<pm>
<suspend-to-mem enabled='no'/>
<suspend-to-disk enabled='no'/>
</pm>
<devices>
<emulator>/usr/bin/qemu-system-x86_64</emulator>
<disk type='file' device='disk'>
<driver name='qemu' type='qcow2'/>
<source file='/var/lib/libvirt/images/haos_ova-17.3.qcow2'/>
<target dev='sda' bus='scsi'/>
<address type='drive' controller='0' bus='0' target='0' unit='0'/>
</disk>
<controller type='scsi' index='0' model='virtio-scsi'>
<address type='pci' domain='0x0000' bus='0x00' slot='0x03' function='0x0'/>
</controller>
<controller type='usb' index='0' model='ich9-ehci1'>
<address type='pci' domain='0x0000' bus='0x00' slot='0x04' function='0x7'/>
</controller>
<controller type='usb' index='0' model='ich9-uhci1'>
<master startport='0'/>
<address type='pci' domain='0x0000' bus='0x00' slot='0x04' function='0x0' multifunction='on'/>
</controller>
<controller type='usb' index='0' model='ich9-uhci2'>
<master startport='2'/>
<address type='pci' domain='0x0000' bus='0x00' slot='0x04' function='0x1'/>
</controller>
<controller type='usb' index='0' model='ich9-uhci3'>
<master startport='4'/>
<address type='pci' domain='0x0000' bus='0x00' slot='0x04' function='0x2'/>
</controller>
<controller type='pci' index='0' model='pci-root'/>
<interface type='bridge'>
<mac address='52:54:00:ad:0a:01'/>
<source bridge='br0'/>
<model type='virtio'/>
<address type='pci' domain='0x0000' bus='0x00' slot='0x02' function='0x0'/>
</interface>
<serial type='pty'>
<target type='isa-serial' port='0'>
<model name='isa-serial'/>
</target>
</serial>
<console type='pty'>
<target type='serial' port='0'/>
</console>
<input type='mouse' bus='ps2'/>
<input type='keyboard' bus='ps2'/>
<tpm model='tpm-crb'>
<backend type='emulator' version='2.0'/>
</tpm>
<audio id='1' type='none'/>
<memballoon model='virtio'>
<address type='pci' domain='0x0000' bus='0x00' slot='0x05' function='0x0'/>
</memballoon>
</devices>
</domain>
-16
View File
@@ -1,16 +0,0 @@
[Unit]
Description=Heartbeat client (hbc) — dead-man's switch + metrics to hbd.wrede.pvt
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Least privilege: the hbd server can push CMD (run a shell command on the client),
# so this runs as the unprivileged user, not root. hbc lives in andreas' venv install.
User=andreas
ExecStart=/home/andreas/bin/hbc -b -c /etc/hbc.yaml hbd.wrede.pvt
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
-15
View File
@@ -1,15 +0,0 @@
# hbc (heartbeat client) config for wayback — the van router's dead-man's switch.
# Deployed to /etc/hbc.yaml by deploy.sh. Server host is passed on the command line
# (hbd.wrede.pvt, see hbc.service). No secrets here.
#
# The client (~/bin/hbc) is installed once via the heartbeat project's own installer —
# see README §4. This file only tunes intervals + which metric plugins to ship.
interval: 15 # heartbeat every 15s (server flags overdue a few s after a miss)
plugins:
cpu_monitor: { interval: 300 }
memory_monitor: { interval: 300 } # ZFS ARC-aware
disk_monitor: { interval: 300 }
network_monitor: { interval: 300 }
zfs_monitor: { interval: 300 } # zroot health/capacity (readable as the service user)
-6
View File
@@ -1,6 +0,0 @@
[Login]
# wayback is an always-on router living lid-closed in the van.
# Default logind suspends on lid close (incl. on AC); ignore the lid in every state.
HandleLidSwitch=ignore
HandleLidSwitchExternalPower=ignore
HandleLidSwitchDocked=ignore
+3 -3
View File
@@ -1,10 +1,10 @@
# Hardware watchdog for unattended operation.
#
# PID1 pets /dev/watchdog0 (intel_oc_wdt) every RuntimeWatchdogSec/2. If systemd
# itself wedges for longer than RuntimeWatchdogSec, the chip hard-resets the box —
# PID1 pets /dev/watchdog0 (bcm2835_wdt) every RuntimeWatchdogSec/2. If systemd
# itself wedges for longer than RuntimeWatchdogSec, the chip hard-resets the box (bcm2835 max is 15s, hence 10s here; wayback uses 20s)
# the only way to recover a hung router with nobody there to open the lid.
# (See the EC-latch / USB-hub-hang history.) RebootWatchdogSec also guards against
# a reboot that hangs partway.
[Manager]
RuntimeWatchdogSec=20s
RuntimeWatchdogSec=10s
RebootWatchdogSec=5min
-9
View File
@@ -1,9 +0,0 @@
{
"poll_interval": 30,
"ac_path": "/sys/class/power_supply/AC0/online",
"battery_path": "/sys/class/power_supply/BAT0",
"warn_levels": [25, 20, 15],
"shutdown_level": 10,
"shutdown_grace": 8,
"credentials_path": "/etc/van-battery/pushover.json"
}
+7 -2
View File
@@ -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
}
]
}
-231
View File
@@ -1,231 +0,0 @@
#!/usr/bin/env python3
"""van-battery — battery / mains monitor for the campervan router (wayback).
While running **off mains** (AC offline, i.e. on battery) it sends escalating
Pushover alerts as the charge drops past each warn level, and at the shutdown
level it sends a final alert and powers the machine off cleanly.
Alerts are edge-triggered per discharge episode: each severity fires once, and
the whole sequence re-arms when mains power returns. Plug-out already below a
warn level fires a single alert for the current severity, not a burst.
Pushover credentials live in a separate 0600 secrets file (see credentials_path),
never in this repo. Missing/placeholder creds disable sending but NOT the
shutdown — running flat must always power down safely. Stdlib only.
"""
import json
import os
import socket
import subprocess
import sys
import time
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
CONFIG_PATH = os.environ.get("VAN_BATTERY_CONFIG", "/etc/van-battery/config.json")
STATE_DIR = Path("/run/van-battery")
STATE_PATH = STATE_DIR / "state.json"
PSY = Path("/sys/class/power_supply")
HOST = socket.gethostname()
DEFAULTS = {
"poll_interval": 30, # seconds between reads (battery moves slowly)
"ac_path": "/sys/class/power_supply/AC0/online",
"battery_path": "/sys/class/power_supply/BAT0",
"warn_levels": [25, 20, 15], # Pushover alert only
"shutdown_level": 10, # Pushover alert + poweroff
"shutdown_grace": 8, # seconds to let the alert flush before poweroff
"credentials_path": "/etc/van-battery/pushover.json",
}
PLACEHOLDERS = {"", "REPLACE_ME", "your-token-here", "your-user-key-here"}
def log(msg, level="info"):
pri = {"info": "<6>", "warn": "<4>", "crit": "<2>"}.get(level, "<6>")
print(pri + msg, flush=True)
def load_config():
cfg = dict(DEFAULTS)
try:
with open(CONFIG_PATH) as f:
cfg.update(json.load(f))
except FileNotFoundError:
log(f"config {CONFIG_PATH} not found, using built-in defaults")
except Exception as e:
log(f"config {CONFIG_PATH} unreadable ({e}), using defaults", "warn")
return cfg
def _by_type(kind):
"""Find a power_supply dir by its `type` (Mains / Battery) — fallback when the
configured AC0/BAT0 name isn't present on this machine."""
for d in sorted(PSY.glob("*")):
try:
if (d / "type").read_text().strip() == kind:
return d
except OSError:
continue
return None
def read_on_battery(cfg):
"""True if running on battery (mains absent), False if on mains, None if unknown."""
p = Path(cfg["ac_path"])
if not p.exists():
d = _by_type("Mains")
p = (d / "online") if d else None
if not p or not p.exists():
return None
try:
return p.read_text().strip() == "0"
except OSError:
return None
def read_capacity(cfg):
"""Battery charge percentage (int), or None."""
d = Path(cfg["battery_path"])
if not (d / "capacity").exists():
d = _by_type("Battery") or d
try:
return int((d / "capacity").read_text().strip())
except (OSError, ValueError):
return None
def load_creds(cfg):
try:
c = json.loads(Path(cfg["credentials_path"]).read_text())
token, user = str(c.get("token", "")).strip(), str(c.get("user", "")).strip()
if token in PLACEHOLDERS or user in PLACEHOLDERS:
return None
return token, user
except FileNotFoundError:
return None
except Exception as e:
log(f"credentials {cfg['credentials_path']} unreadable ({e})", "warn")
return None
def pushover(cfg, title, message, priority=0):
creds = load_creds(cfg)
if not creds:
log(f"pushover skipped (no credentials): {title} — {message}", "warn")
return False
token, user = creds
data = urllib.parse.urlencode({
"token": token, "user": user, "title": title,
"message": message, "priority": priority,
}).encode()
req = urllib.request.Request("https://api.pushover.net/1/messages.json", data=data)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
ok = resp.status == 200
if not ok:
log(f"pushover HTTP {resp.status}", "warn")
return ok
except Exception as e:
log(f"pushover send failed: {e}", "warn")
return False
def severity(cap, levels):
"""Most-severe (lowest) threshold the capacity has reached, or None if above all.
levels: thresholds sorted ascending. cap=12, levels=[10,15,20,25] -> 15."""
reached = [t for t in levels if cap <= t]
return min(reached) if reached else None
def poweroff(cfg):
log("shutdown level reached — powering off", "crit")
time.sleep(cfg["shutdown_grace"]) # give the Pushover POST time to land first
try:
subprocess.run(["systemctl", "poweroff"], check=False)
except Exception as e:
log(f"poweroff failed: {e}", "crit")
def write_state(on_batt, cap, armed, warn_levels, shutdown_level):
STATE_DIR.mkdir(parents=True, exist_ok=True)
payload = {
"updated": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"on_battery": on_batt,
"capacity": cap,
"alerted_below": armed, # lowest level alerted this discharge episode, or null
"warn_levels": warn_levels,
"shutdown_level": shutdown_level,
}
tmp = STATE_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(payload))
tmp.replace(STATE_PATH)
def main():
cfg = load_config()
levels = sorted(cfg["warn_levels"] + [cfg["shutdown_level"]])
shutdown_level = cfg["shutdown_level"]
last_alerted = None # lowest threshold alerted in the current discharge episode
shutdown_issued = False
prev_on_batt = None
log(f"van-battery up: poll {cfg['poll_interval']}s, warn {cfg['warn_levels']}, "
f"shutdown {shutdown_level}%")
while True:
on_batt = read_on_battery(cfg)
cap = read_capacity(cfg)
capstr = f"{cap}%" if cap is not None else "unknown charge"
# Mains <-> battery transition alerts. Skip the very first sample (prev is None)
# so a restart while already on battery doesn't fire a spurious "on battery".
if on_batt is not None and prev_on_batt is not None and on_batt != prev_on_batt:
if on_batt:
log(f"mains lost — running on battery at {capstr}", "warn")
pushover(cfg, f"⚡ {HOST}: on battery",
f"Mains power lost — now running on battery ({capstr}). "
f"Low alerts at {cfg['warn_levels']}%, auto-shutdown at {shutdown_level}%.")
else:
log(f"mains restored at {capstr} — alerts re-armed")
pushover(cfg, f"🔌 {HOST}: back on mains",
f"Mains power restored ({capstr}). Battery alert sequence re-armed.")
if on_batt is False:
# On mains: re-arm the whole sequence for the next discharge episode.
last_alerted = None
shutdown_issued = False
elif on_batt is True and cap is not None:
sev = severity(cap, levels)
if sev is not None and (last_alerted is None or sev < last_alerted):
last_alerted = sev
is_shutdown = sev <= shutdown_level
if is_shutdown:
pushover(cfg, f"⚠ {HOST}: battery {cap}% — shutting down",
f"On battery at {cap}% (≤{shutdown_level}%). Powering off now to "
f"protect the system.", priority=1)
if not shutdown_issued:
shutdown_issued = True
poweroff(cfg)
else:
log(f"battery {cap}% on battery — alerting (level {sev})", "warn")
pushover(cfg, f"{HOST}: battery {cap}%",
f"Running on battery, charge down to {cap}% (alert at {sev}%). "
f"Shutdown at {shutdown_level}%.")
prev_on_batt = on_batt
try:
write_state(on_batt, cap, last_alerted, cfg["warn_levels"], shutdown_level)
except OSError as e:
log(f"state write failed: {e}", "warn")
time.sleep(cfg["poll_interval"])
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit(0)
-13
View File
@@ -1,13 +0,0 @@
[Unit]
Description=Battery monitor + low-charge Pushover alerts / safe shutdown for the campervan router
After=local-fs.target network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/sbin/van-battery
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target