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