#!/usr/bin/env python3 """van-modem-watch — pages if the EC25 modem never enumerates at boot. Detection only, deliberately no recovery attempt. On 2026-08-04 the modem (then on a shared powered USB hub) sometimes failed to enumerate on a cold boot. Investigation ruled out a boot-timing race and a bad cable/port: a genuine hub-commanded VBUS power-cycle (confirmed via kernel disconnect/ reconnect events, held off for a full 10s) did not recover it, even with the rest of the system already up and stable — only a real physical unplug/ replug of the connector ever did. That means no software action from this host can fix it once it happens; the modem was moved off the hub onto the Pi's native USB port as the actual fix (a direct port doesn't reproduce the failure). This just watches for a recurrence and pages, since if it comes back the only real fix is someone physically reseating the connector. Pushover credentials shared with van-battery/van-thermal/van-nvme-watch (/etc/van-battery/pushover.json, 0600). Publishes /run/van-modem-watch/ state.json (same convention as the other watchdogs). Stdlib only. Usage: van-modem-watch [modem-usb-vendor] Defaults to 2c7c (Quectel) — see deploy.conf. """ import json import socket import sys import time import urllib.parse import urllib.request from datetime import datetime, timezone from pathlib import Path MODEM_VENDOR = sys.argv[1] if len(sys.argv) > 1 else "2c7c" INTERVAL = 20 # seconds between checks BUDGET_S = 6 * INTERVAL # ~2 minutes of retries after boot, then give up quietly USB_DEVICES = Path("/sys/bus/usb/devices") STATE_DIR = Path("/run/van-modem-watch") STATE_PATH = STATE_DIR / "state.json" CREDENTIALS_PATH = Path("/etc/van-battery/pushover.json") HOST = socket.gethostname() PLACEHOLDERS = {"", "REPLACE_ME", "your-token-here", "your-user-key-here"} def log(msg, level="info"): # systemd journal severity prefixes (sd-daemon), same convention as van-ap-watchdog. pri = {"info": "<6>", "warn": "<4>", "crit": "<2>"}.get(level, "<6>") print(pri + msg, flush=True) def modem_present(vendor): for f in USB_DEVICES.glob("*/idVendor"): try: if f.read_text().strip() == vendor: return True except OSError: continue return False def load_creds(): try: c = json.loads(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 {CREDENTIALS_PATH} unreadable ({e})", "warn") return None def pushover(title, message, attempts=3, retry_delay=15): creds = load_creds() 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, }).encode() req = urllib.request.Request("https://api.pushover.net/1/messages.json", data=data) for attempt in range(1, attempts + 1): 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: last = attempt == attempts log(f"pushover send failed ({attempt}/{attempts}): {e}", "warn") if not last: time.sleep(retry_delay) return False def write_state(payload): STATE_DIR.mkdir(parents=True, exist_ok=True) tmp = STATE_PATH.with_suffix(".tmp") tmp.write_text(json.dumps(payload)) tmp.replace(STATE_PATH) def main(): log(f"van-modem-watch up: watching for USB vendor {MODEM_VENDOR} for up to " f"{BUDGET_S}s after boot") elapsed = 0 while elapsed < BUDGET_S: if modem_present(MODEM_VENDOR): log("modem present — exiting") write_state({ "updated": datetime.now(timezone.utc).isoformat(timespec="seconds"), "present": True, }) return time.sleep(INTERVAL) elapsed += INTERVAL message = (f"modem (USB vendor {MODEM_VENDOR}) not seen {BUDGET_S}s after boot — " f"needs a physical unplug/replug, no software fix works for this") log(message, "crit") write_state({ "updated": datetime.now(timezone.utc).isoformat(timespec="seconds"), "present": False, }) pushover(f"🚨 {HOST}: cellular modem absent after boot", message) if __name__ == "__main__": try: main() except KeyboardInterrupt: sys.exit(0)