diff --git a/README.md b/README.md index 72ca0e9..79c91aa 100644 --- a/README.md +++ b/README.md @@ -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. --- diff --git a/deploy.sh b/deploy.sh index fece603..eb6217e 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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 allowDNS=1'." diff --git a/power/battery-config.json b/power/battery-config.json new file mode 100644 index 0000000..bee46a5 --- /dev/null +++ b/power/battery-config.json @@ -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" +} diff --git a/power/pushover.json.example b/power/pushover.json.example new file mode 100644 index 0000000..107222a --- /dev/null +++ b/power/pushover.json.example @@ -0,0 +1,4 @@ +{ + "token": "REPLACE_ME", + "user": "REPLACE_ME" +} diff --git a/power/van-battery b/power/van-battery new file mode 100644 index 0000000..289b785 --- /dev/null +++ b/power/van-battery @@ -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) diff --git a/power/van-battery.service b/power/van-battery.service new file mode 100644 index 0000000..5a34716 --- /dev/null +++ b/power/van-battery.service @@ -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