From d0020587014b7be8d4a462d086d0f669f732e838 Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Fri, 21 Aug 2026 15:12:54 -0400 Subject: [PATCH] modem: add van-sms-watch to archive inbound SMS + Pushover alert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polls ModemManager for SMS on the EC25's own flash storage, appends each inbound message to /var/log/van-sms.jsonl, pages via Pushover (shared van-battery creds), then deletes it from modem storage so it doesn't silently fill up. MMS can't be decoded (WAP-push notification needs the carrier's separate MMS APN) — detected and paged as notification-only. Wired into deploy.sh; installed/verified live on the Pi (backlog SMS archived + paged successfully) ahead of committing. Co-Authored-By: Claude Sonnet 5 --- deploy.sh | 10 +- modem/van-sms-watch | 244 ++++++++++++++++++++++++++++++++++++ modem/van-sms-watch.service | 13 ++ 3 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 modem/van-sms-watch create mode 100644 modem/van-sms-watch.service diff --git a/deploy.sh b/deploy.sh index 0d361e3..5093c1b 100755 --- a/deploy.sh +++ b/deploy.sh @@ -150,6 +150,12 @@ dpkg -s mobile-broadband-provider-info >/dev/null 2>&1 \ # port rather than through the hub. This just pages if it ever recurs. install -D -m0755 failover/van-modem-watch /usr/local/sbin/van-modem-watch install_rendered failover/van-modem-watch.service /etc/systemd/system/van-modem-watch.service +# SMS archive + Pushover relay: polls ModemManager for inbound SMS, appends +# each to /var/log/van-sms.jsonl, pages via Pushover, then deletes it from +# the modem's own flash storage (small, fills up silently otherwise). MMS +# can't be fetched here (see script docstring) — notification-only paging. +install -D -m0755 modem/van-sms-watch /usr/local/sbin/van-sms-watch +install -D -m0644 modem/van-sms-watch.service /etc/systemd/system/van-sms-watch.service echo "== cockpit plugin ==" install -d /usr/share/cockpit/vanrouter @@ -251,7 +257,7 @@ systemctl restart systemd-resolved # its wait-online would just stall network-online.target. NM-wait-online covers WANs. systemctl mask systemd-networkd-wait-online.service >/dev/null 2>&1 || true systemctl unmask hostapd >/dev/null 2>&1 || true -systemctl enable regdomain.service hostapd hostapd-2g van-ap-dnsmasq nftables systemd-networkd van-failover van-thermal van-nvme-watch van-ap-watchdog van-ap-watchdog-2g van-wlan-watchdog van-modem-watch van-gps-owntracks van-li3-battery >/dev/null 2>&1 || true +systemctl enable regdomain.service hostapd hostapd-2g van-ap-dnsmasq nftables systemd-networkd van-failover van-thermal van-nvme-watch van-ap-watchdog van-ap-watchdog-2g van-wlan-watchdog van-modem-watch van-sms-watch van-gps-owntracks van-li3-battery >/dev/null 2>&1 || true # bluetooth: host BlueZ serves the onboard hci0 to the HA container over D-Bus systemctl enable --now bluetooth >/dev/null 2>&1 || true systemctl start homeassistant || warn "homeassistant failed to start (podman/quadlet — check journalctl -u homeassistant)" @@ -259,6 +265,7 @@ systemctl start esphome || warn "esphome failed to start (podman/quadlet — che systemctl start frigate || warn "frigate failed to start (podman/quadlet — check journalctl -u frigate)" systemctl restart van-thermal systemctl restart van-nvme-watch +systemctl restart van-sms-watch systemctl restart van-gps-owntracks systemctl restart van-li3-battery # Pick up unmanaged-devices changes so NM releases/keeps the right interfaces. @@ -287,3 +294,4 @@ echo " cat /run/van-failover/state.json" echo " cat /run/van-thermal/state.json" echo " cat /run/van-nvme-watch/state.json" echo " journalctl -u van-li3-battery -n 20" +echo " journalctl -u van-sms-watch -n 20 ; tail -f /var/log/van-sms.jsonl" diff --git a/modem/van-sms-watch b/modem/van-sms-watch new file mode 100644 index 0000000..4255f54 --- /dev/null +++ b/modem/van-sms-watch @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""van-sms-watch — archives inbound SMS on the EC25 modem and pages via Pushover. + +Polls ModemManager (via `mmcli -J`, no dbus dependency) for SMS sitting in the +modem's "mt" storage. Each *received* message (pdu-type "deliver" — this +skips the modem's own "submit"/"sent" records) is appended as one JSON line +to /var/log/van-sms.jsonl, pushed via Pushover, then deleted from modem +storage — that storage is small flash on the EC25 itself and fills up +silently over time, so anything present in a poll is by definition new and +needs archiving before it's gone. + +MMS is not decodable here: MMS arrives over SMS only as a WAP-push binary +notification (a URL to fetch over the carrier's MMS APN with carrier-specific +auth) — ModemManager hands it back with no `text`, just raw `data` hex. +Decoding WSP and fetching that URL is real added complexity with no stdlib +support and no guarantee the MMS APN is even reachable from this modem's +general-internet APN. Instead: detect it (deliver + no text + non-empty +data), save the raw record, and page with a note that the content itself +wasn't retrieved — better than silently dropping it. + +Pushover credentials shared with van-battery/van-thermal/van-modem-watch +(/etc/van-battery/pushover.json, 0600). Publishes /run/van-sms-watch/ +state.json (same convention as the other watchdogs). Stdlib only (mmcli +does the ModemManager talking over subprocess). +""" + +import json +import socket +import subprocess +import sys +import time +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +INTERVAL = 15 # seconds between polls +LOG_PATH = Path("/var/log/van-sms.jsonl") +STATE_DIR = Path("/run/van-sms-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"} +PUSHOVER_MAX = 900 # Pushover message cap is 1024 bytes; leave headroom for the prefix + + +def log(msg, level="info"): + # systemd journal severity prefixes (sd-daemon), same convention as the other watchdogs. + pri = {"info": "<6>", "warn": "<4>", "crit": "<2>"}.get(level, "<6>") + print(pri + msg, flush=True) + + +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 mmcli_json(args): + """Run mmcli with -J and return the parsed dict, or None on any failure.""" + try: + out = subprocess.run(["mmcli", "-J", *args], capture_output=True, + text=True, timeout=15) + except Exception as e: + log(f"mmcli {' '.join(args)} failed to run ({e})", "warn") + return None + if out.returncode != 0: + log(f"mmcli {' '.join(args)} exited {out.returncode}: {out.stderr.strip()}", "warn") + return None + try: + return json.loads(out.stdout) + except Exception as e: + log(f"mmcli {' '.join(args)} gave unparseable JSON ({e})", "warn") + return None + + +def find_modem(): + """Path of the first modem ModemManager knows about, or None. Re-resolved + each poll — the modem index can shift across a ModemManager restart.""" + d = mmcli_json(["-L"]) + if not d: + return None + modems = d.get("modem-list") or [] + return modems[0] if modems else None + + +def list_sms(modem_path): + d = mmcli_json(["-m", modem_path, "--messaging-list-sms"]) + if d is None: + return [] + return d.get("modem.messaging.sms") or [] + + +def fetch_sms(modem_path, sms_path): + d = mmcli_json(["-m", modem_path, "-s", sms_path]) + if d is None: + return None + return d.get("sms") + + +def delete_sms(modem_path, sms_path): + try: + out = subprocess.run( + ["mmcli", "-m", modem_path, f"--messaging-delete-sms={sms_path}"], + capture_output=True, text=True, timeout=15) + except Exception as e: + log(f"delete {sms_path} failed to run ({e})", "warn") + return False + if out.returncode != 0: + log(f"delete {sms_path} exited {out.returncode}: {out.stderr.strip()}", "warn") + return False + return True + + +def archive(record): + try: + LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(LOG_PATH, "a") as f: + f.write(json.dumps(record) + "\n") + return True + except OSError as e: + log(f"archive write failed: {e}", "warn") + return False + + +def process(modem_path, sms_path): + sms = fetch_sms(modem_path, sms_path) + if sms is None: + return + props = sms.get("properties", {}) + content = sms.get("content", {}) + + if props.get("pdu-type") != "deliver": + return # not an inbound message (e.g. the modem's own "submit"/"sent" records) + + number = content.get("number", "unknown") + text = content.get("text", "--") + data = content.get("data", "--") + timestamp = props.get("timestamp", "--") + is_mms = text in ("--", "") and data not in ("--", "") + + record = { + "time": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "modem_timestamp": timestamp, + "from": number, + "kind": "mms-notification" if is_mms else "sms", + "text": None if is_mms else text, + "data_hex": data if is_mms else None, + "smsc": props.get("smsc", "--"), + } + saved = archive(record) + if saved: + log(f"archived {record['kind']} from {number}") + else: + log(f"failed to archive {record['kind']} from {number} — not deleting from modem", "warn") + + if is_mms: + title = f"📎 {HOST}: MMS notification from {number}" + body = ("MMS received but content can't be fetched on this device " + "(needs the carrier's MMS APN + WAP-push decode, not implemented). " + f"Raw notification archived to {LOG_PATH}.") + else: + title = f"📩 {HOST}: SMS from {number}" + body = text if len(text) <= PUSHOVER_MAX else text[:PUSHOVER_MAX] + "… (truncated)" + pushover(title, body) + + if saved: + if delete_sms(modem_path, sms_path): + log(f"deleted {sms_path} from modem storage") + else: + log(f"left {sms_path} on modem storage (delete failed) — will retry next poll", "warn") + + +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-sms-watch up: polling every {INTERVAL}s, archiving to {LOG_PATH}") + modem_present = None # unknown yet, so the first observation always logs + + while True: + modem_path = find_modem() + present = modem_path is not None + if present != modem_present: + log("modem present" if present else "modem not found (skipping poll)", + "info" if present else "warn") + modem_present = present + + count = 0 + if modem_path: + for sms_path in list_sms(modem_path): + process(modem_path, sms_path) + count += 1 + + write_state({ + "updated": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "modem_present": present, + "last_poll_messages": count, + }) + time.sleep(INTERVAL) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + sys.exit(0) diff --git a/modem/van-sms-watch.service b/modem/van-sms-watch.service new file mode 100644 index 0000000..c84df76 --- /dev/null +++ b/modem/van-sms-watch.service @@ -0,0 +1,13 @@ +[Unit] +Description=SMS archive + Pushover relay for the EC25 cellular modem +After=ModemManager.service network-online.target +Wants=ModemManager.service network-online.target + +[Service] +Type=simple +ExecStart=/usr/local/sbin/van-sms-watch +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target