From db22cb50d6e523bbb27747fc385237bb62131aee Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Fri, 21 Aug 2026 15:40:22 -0400 Subject: [PATCH] modem: add van-sms-send CLI for sending SMS ad hoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mmcli's inline "number=...,text=..." properties string breaks on plain spaces/quotes/colons in the text — found while testing van-sms-watch. Sidesteps that entirely by passing the message via --messaging-create-sms-with-text= instead. Tested live (multi-word text, punctuation, multi-line stdin input all delivered). Co-Authored-By: Claude Sonnet 5 --- deploy.sh | 3 ++ modem/van-sms-send | 96 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100755 modem/van-sms-send diff --git a/deploy.sh b/deploy.sh index 5093c1b..a0cdeb7 100755 --- a/deploy.sh +++ b/deploy.sh @@ -156,6 +156,9 @@ install_rendered failover/van-modem-watch.service /etc/systemd/system/van-modem- # 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 +# CLI to send an SMS ad hoc (needs sudo — ModemManager's Messaging actions +# are PolicyKit-gated). Not a service, just a tool: sudo van-sms-send +install -D -m0755 modem/van-sms-send /usr/local/sbin/van-sms-send echo "== cockpit plugin ==" install -d /usr/share/cockpit/vanrouter diff --git a/modem/van-sms-send b/modem/van-sms-send new file mode 100755 index 0000000..fa398a6 --- /dev/null +++ b/modem/van-sms-send @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""van-sms-send — send an SMS through the EC25 modem from the command line. + +Usage: + van-sms-send + echo "message text" | van-sms-send + +The message is written to a temp file and passed via mmcli's +--messaging-create-sms-with-text (rather than inline in the "number=..., +text=..." properties string) — mmcli's properties-string parser breaks on +plain spaces/quotes/colons in inline text (found the hard way testing +van-sms-watch), the file form has none of that trouble. + +Needs root: ModemManager's Messaging/Device.Control D-Bus actions are +PolicyKit-gated and unauthorized for a plain user session (read-only +actions like listing SMS work unprivileged; creating/sending don't). Run +with sudo. + +Stdlib only. +""" + +import json +import subprocess +import sys +import tempfile + + +def die(msg): + print(f"van-sms-send: {msg}", file=sys.stderr) + sys.exit(1) + + +def run_json(args): + """Run mmcli with -J and return the parsed dict, dying on any failure.""" + try: + out = subprocess.run(["mmcli", "-J", *args], capture_output=True, + text=True, timeout=20) + except Exception as e: + die(f"failed to run mmcli {' '.join(args)}: {e}") + if out.returncode != 0: + die(f"mmcli {' '.join(args)} failed: {out.stderr.strip() or out.stdout.strip()}") + try: + return json.loads(out.stdout) + except Exception as e: + die(f"mmcli {' '.join(args)} gave unparseable JSON ({e}): {out.stdout}") + + +def run_action(args): + """Run mmcli for an action whose success is exit-code only (no JSON body + even with -J, e.g. --send). Dies on failure.""" + try: + out = subprocess.run(["mmcli", *args], capture_output=True, text=True, timeout=20) + except Exception as e: + die(f"failed to run mmcli {' '.join(args)}: {e}") + if out.returncode != 0: + die(f"mmcli {' '.join(args)} failed: {out.stderr.strip() or out.stdout.strip()}") + + +def find_modem(): + modems = run_json(["-L"]).get("modem-list") or [] + return modems[0] if modems else None + + +def main(): + if len(sys.argv) < 2: + die("usage: van-sms-send (or pipe text on stdin)") + number = sys.argv[1] + if len(sys.argv) > 2: + text = " ".join(sys.argv[2:]) + elif not sys.stdin.isatty(): + text = sys.stdin.read().rstrip("\n") + else: + die("no message text given as an argument or on stdin") + if not text: + die("message text is empty") + + modem = find_modem() + if not modem: + die("no modem found (mmcli -L found none)") + + with tempfile.NamedTemporaryFile("w", suffix=".txt") as f: + f.write(text) + f.flush() + created = run_json(["-m", modem, f"--messaging-create-sms=number={number}", + f"--messaging-create-sms-with-text={f.name}"]) + + sms_path = (created.get("modem") or {}).get("messaging", {}).get("created-sms") + if not sms_path: + die(f"couldn't find created SMS path in mmcli output: {created}") + + run_action(["-m", modem, "-s", sms_path, "--send"]) + print(f"sent to {number}: {text}") + + +if __name__ == "__main__": + main()