battery: van-battery daemon (low-charge Pushover alerts + safe shutdown)

While on battery (AC0/online=0), sends escalating Pushover alerts at 25/20/15%
and at 10% sends a final alert then `systemctl poweroff` (after shutdown_grace
so the alert flushes first). Edge-triggered per discharge episode; re-arms when
mains returns; plug-out already-low collapses to a single alert (no burst).

Pushover token+user live only in /etc/van-battery/pushover.json (0600), seeded
from pushover.json.example on first deploy and never committed. Missing/
placeholder creds disable sending but NOT the shutdown — running flat must
always power down safely. Publishes /run/van-battery/state.json. Stdlib only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Andreas Wrede
2026-06-28 21:48:24 -04:00
co-authored by Claude Opus 4.8
parent ef6c1ce182
commit b75b5b16c7
6 changed files with 273 additions and 3 deletions
+15 -1
View File
@@ -40,6 +40,7 @@ This directory is the source of truth. The live system files live under `/etc`,
| NAT + forwarding | **nftables** + sysctl |
| WAN health + failover | **van-failover** daemon |
| Temperature monitor / alert / log | **van-thermal** daemon |
| Battery monitor / low-charge alert + shutdown | **van-battery** daemon |
| ZeroTier DNS → resolved | **zerotier-systemd-manager** + systemd-networkd |
| Web UI | **Cockpit** + `vanrouter` plugin |
@@ -98,6 +99,10 @@ This directory is the source of truth. The live system files live under `/etc`,
| `van-thermal` | `/usr/local/sbin/van-thermal` | temperature daemon (Python): publishes state, alerts, logs history |
| `thermal-config.json` | `/etc/van-thermal/config.json` | sensors + warn/crit thresholds + sample/log intervals |
| `van-thermal.service` | `/etc/systemd/system/van-thermal.service` | `Restart=always` |
| `van-battery` | `/usr/local/sbin/van-battery` | battery daemon (Python): Pushover low-charge alerts + safe shutdown |
| `battery-config.json` | `/etc/van-battery/config.json` | warn levels, shutdown level, poll interval, paths |
| `van-battery.service` | `/etc/systemd/system/van-battery.service` | `Restart=always` |
| `pushover.json.example` | → `/etc/van-battery/pushover.json` (seeded if absent) | Pushover token+user **template**; real file is 0600, **not** in the repo |
`deploy.sh` additionally **masks** `sleep.target suspend.target hibernate.target hybrid-sleep.target`
(no repo file — symlinks to `/dev/null` under `/etc/systemd/system/`) so nothing else can suspend either.
@@ -135,6 +140,14 @@ This directory is the source of truth. The live system files live under `/etc`,
- **Tuning**: edit `/etc/van-thermal/config.json` (thresholds, intervals, sensor list), then `systemctl restart van-thermal`. Defaults: CPU warn 80 / crit 95 °C (silicon crit is 100), NVMe warn 65 / crit 70 °C (drive crit ~71).
- Status: `systemctl status van-thermal` or `cat /run/van-thermal/state.json`.
### Battery monitor (`van-battery`)
- Watches mains vs battery via `/sys/class/power_supply/AC0/online` (0 = on battery) and charge via `BAT0/capacity`. Both resolve by `type` (Mains/Battery) if those names ever differ.
- **Only while on battery**, it sends escalating **Pushover** alerts at **25 / 20 / 15 %**, and at **10 %** sends a final alert and runs `systemctl poweroff` (after `shutdown_grace`, default 8 s, so the alert flushes first).
- Edge-triggered per discharge episode: each severity fires once; the sequence **re-arms when mains returns**. Unplugging already below a warn level fires a single alert for the current severity (no burst), then shutdown at 10 %.
- **Credentials** live in `/etc/van-battery/pushover.json` (mode 0600), seeded from `pushover.json.example` on first deploy and **never committed**. Missing/placeholder creds disable *sending* but **not** the shutdown — running flat must always power down safely (the skip is logged to the journal).
- Tune in `/etc/van-battery/config.json` (`warn_levels`, `shutdown_level`, `poll_interval`), then `systemctl restart van-battery`.
- Status: `systemctl status van-battery`, `journalctl -u van-battery -f`, or `cat /run/van-battery/state.json`.
### Adding the 4G/5G modem
1. Plug the USB modem in. ModemManager + the existing `Koodo` gsm NM connection (autoconnect) bring it up.
2. It auto-joins as the `cellular` WAN at metric 300 (last resort). Nothing else to configure.
@@ -171,7 +184,7 @@ cd ~/vanlink
sudo ./deploy.sh # copies all files to their system locations, reloads + enables services
```
Two things `deploy.sh` does **not** do (one-time, manual):
Three things `deploy.sh` does **not** do (one-time, manual):
1. **zerotier-systemd-manager binary** (v0.4.0, hand-installed — not in the ZeroTier apt repo):
```bash
@@ -180,6 +193,7 @@ Two things `deploy.sh` does **not** do (one-time, manual):
sudo zerotier-cli set d3ecf5726d041b2a allowDNS=1
```
2. **hostapd unmask** (Ubuntu ships it masked): `sudo systemctl unmask hostapd`.
3. **Pushover credentials** for `van-battery` — deploy seeds `/etc/van-battery/pushover.json` (0600) with placeholders; fill in your app token + user key, then `sudo systemctl restart van-battery`. Until then low-battery alerts are skipped (logged), but the 10 % auto-shutdown still works.
---
+14 -2
View File
@@ -38,6 +38,17 @@ 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.
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`.
@@ -53,8 +64,8 @@ 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 van-ap-dnsmasq nftables systemd-networkd van-failover van-thermal >/dev/null 2>&1 || true
systemctl restart van-thermal
systemctl enable regdomain.service hostapd van-ap-dnsmasq nftables systemd-networkd van-failover van-thermal van-battery >/dev/null 2>&1 || true
systemctl restart van-thermal van-battery
# restart in dependency order; AP iface IP first, then hostapd/dnsmasq, then NAT/failover
systemctl restart systemd-networkd
systemctl restart hostapd van-ap-dnsmasq nftables van-failover
@@ -65,4 +76,5 @@ echo "Deployed. Verify:"
echo " iw dev wlxc83a35a4ee55 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 "Manual one-time steps (see README §4): zerotier-systemd-manager binary + 'zerotier-cli set <nwid> allowDNS=1'."
+9
View File
@@ -0,0 +1,9 @@
{
"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"
}
+4
View File
@@ -0,0 +1,4 @@
{
"token": "REPLACE_ME",
"user": "REPLACE_ME"
}
+218
View File
@@ -0,0 +1,218 @@
#!/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):
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
}
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)
if on_batt is False:
# On mains: re-arm the whole sequence for the next discharge episode.
if prev_on_batt:
log("mains restored — alerts re-armed")
last_alerted = None
shutdown_issued = False
elif on_batt is True and cap is not None:
if prev_on_batt is False:
log(f"mains lost — running on battery at {cap}%", "warn")
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)
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
@@ -0,0 +1,13 @@
[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