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,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