#!/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()