#!/usr/bin/env python3 """van-router-mqtt — publishes campervan router status to MQTT (Home Assistant discovery) and executes control commands, replacing the Cockpit "Van Router" plugin (cockpit/vanrouter/). Cockpit was a resource hog on this Pi and its login (PAM+TLS) rejected Firefox sometimes and Android entirely; this daemon sidesteps that by not running a web server or auth stack of its own at all — HA (already running here) owns the UI and its own login. AP/clients/Starlink/WAN/deploy-warnings are computed here exactly like vanrouter.js did (same shell-outs, same parsing). Failover/thermal/battery state is NOT recomputed — those daemons already write /run/van-*/state.json each cycle; this just republishes that file's content verbatim. (van-thermal is deliberately stdlib-only for boot resilience; van-battery is documented in the README but not actually deployed on this host, so its topic just stays unpublished until/unless that changes — same as Cockpit's own "daemon not running" fallback.) WAN toggle/prefer commands write the same files/run the same nmcli calls the Cockpit plugin did (including PREFER, which van-failover already reads) — this daemon owns no failover state of its own. """ import glob import json import os import subprocess import sys import time import paho.mqtt.client as mqtt CONFIG = "/etc/van-router-mqtt/config.json" CONFIG_PATH = sys.argv[1] if len(sys.argv) > 1 else CONFIG def log(msg): print(msg, flush=True) def credentials_path(config_path): return os.path.join(os.path.dirname(config_path), "credentials.json") def load_credentials(config_path, cfg): """Merge username/password from credentials.json (next to the config file) into cfg. The config is re-rendered on every deploy, so secrets live in this separate 0600 file. Never raises; never logs the values.""" path = credentials_path(config_path) if not os.path.exists(path): return cfg try: with open(path) as f: cred = json.load(f) if not isinstance(cred, dict): raise ValueError("not a JSON object") except (OSError, ValueError) as e: log(f"credentials file {path} ignored ({type(e).__name__}); connecting anonymously") return cfg if not cred.get("username"): log(f"credentials file {path} has no username; connecting anonymously") return cfg cfg["username"] = cred["username"] cfg["password"] = cred.get("password", "") return cfg if __name__ == "__main__": with open(CONFIG_PATH) as f: cfg = json.load(f) cfg = load_credentials(CONFIG_PATH, cfg) else: # imported (tests): no config file, no side effects import collections cfg = collections.defaultdict(str) APS = [ {"id": "5g", "iface": cfg["wifi_5g_iface"], "unit": "hostapd", "band": "5GHz"}, {"id": "2g", "iface": cfg["wifi_2g_iface"], "unit": "hostapd-2g", "band": "2.4GHz"}, ] LAN_PORTS = {"eth0": "LAN (eth0)", cfg["lan_usb_iface"]: "LAN (USB)"} STARLINK_IFACE = cfg["starlink_iface"] STARLINK_DISH = cfg.get("starlink_dish", "192.168.100.1:9200") MODEM_USB_VENDOR = cfg["modem_usb_vendor"] WIFI_WAN_IFACE = cfg.get("wifi_wan_iface", "wlan0") FAILOVER_STATE = "/run/van-failover/state.json" FAILOVER_PREFER = "/run/van-failover/prefer" THERMAL_STATE = "/run/van-thermal/state.json" BATTERY_STATE = "/run/van-battery/state.json" DEPLOYWARN = "/var/lib/vanlink/deploy-warnings.json" broker = cfg.get("broker", "localhost") mqtt_port = cfg.get("port", 1883) device_id = cfg.get("device_id", "van_router") discovery_prefix = cfg.get("discovery_prefix", "homeassistant") state_root = cfg.get("state_root", "van/router") cmd_root = cfg.get("cmd_root", "vanlink/cmd") poll_interval_s = cfg.get("poll_interval_s", 5) DEVICE_INFO = {"identifiers": [device_id], "name": "Van Router", "manufacturer": "vanlink"} WAN_NAMES = ("wifi", "starlink", "cellular") def sh(args, timeout=10): try: return subprocess.run(args, capture_output=True, text=True, timeout=timeout) except (subprocess.TimeoutExpired, OSError): return None def read_json_file(path): try: with open(path) as f: return json.load(f) except (OSError, json.JSONDecodeError): return None def split_nmcli(line): # nmcli -t escapes ':' inside field values as '\:'. parts, cur, esc = [], "", False for ch in line: if esc: cur += ch esc = False elif ch == "\\": esc = True elif ch == ":": parts.append(cur) cur = "" else: cur += ch parts.append(cur) return parts # ---------------- Access points + clients ---------------- def collect_ap(ap): active = "inactive" r = sh(["systemctl", "is-active", ap["unit"]]) if r and r.stdout.strip(): active = r.stdout.strip() ssid = chan = width = None r = sh(["iw", "dev", ap["iface"], "info"]) info = r.stdout if r else "" for line in info.splitlines(): line = line.strip() if line.startswith("ssid "): ssid = line[len("ssid "):] if line.startswith("channel "): # "channel 149 (5745 MHz), width: 80 MHz, center1: 5775 MHz" — chan # and width share one line, so these can't be elif'd against each other. # Keep just the channel/freq part; width is published as its own field. chan = line.split(",", 1)[0] if "width:" in line: width = line.split("width:", 1)[1].split(",")[0].strip() stations = [] r = sh(["iw", "dev", ap["iface"], "station", "dump"]) out = r.stdout if r else "" for block in out.split("Station ")[1:]: mac = block.split(" ")[0] sig = tx = None for line in block.splitlines(): line = line.strip() if line.startswith("signal:"): sig = line.split(":", 1)[1].strip().split(" ")[0] elif line.startswith("tx bitrate:"): tx = line.split(":", 1)[1].strip() stations.append({"mac": mac, "sig": sig, "tx": tx}) return {"band": ap["band"], "unit": ap["unit"], "active": active, "ssid": ssid, "channel": chan, "width": width, "client_count": len(stations), "stations": stations} def collect_client_dir(): """MAC (lowercase) -> {ip, host}. Kernel neighbor table first, dnsmasq leases on top (authoritative + hostname), same precedence as vanrouter.js.""" d = {} r = sh(["ip", "-j", "neigh", "show", "dev", "br0"]) try: for n in json.loads(r.stdout) if r and r.stdout.strip() else []: if n.get("lladdr") and n.get("dst") and ":" not in n["dst"]: d[n["lladdr"].lower()] = {"ip": n["dst"], "host": None} except json.JSONDecodeError: pass try: with open("/var/lib/misc/dnsmasq.leases") as f: for line in f: parts = line.split() if len(parts) >= 4: d[parts[1].lower()] = {"ip": parts[2], "host": None if parts[3] == "*" else parts[3]} except OSError: pass return d def collect_clients(aps, client_dir): rows, seen = [], set() for ap in aps: for s in ap["stations"]: mac = s["mac"].lower() seen.add(mac) c = client_dir.get(mac, {}) rows.append({"conn": ap["band"], "mac": s["mac"], "ip": c.get("ip"), "host": c.get("host"), "sig": s["sig"], "tx": s["tx"]}) r = sh(["bridge", "-j", "fdb", "show", "br", "br0"]) try: for e in json.loads(r.stdout) if r and r.stdout.strip() else []: conn = LAN_PORTS.get(e.get("ifname")) mac = (e.get("mac") or "").lower() if not conn or e.get("master") != "br0" or e.get("state") == "permanent" or mac in seen: continue seen.add(mac) c = client_dir.get(mac, {}) rows.append({"conn": conn, "mac": e["mac"], "ip": c.get("ip"), "host": c.get("host"), "sig": None, "tx": None}) except json.JSONDecodeError: pass rows.sort(key=lambda r: (r["conn"], r["ip"] or "")) return rows # ---------------- Starlink ---------------- def collect_starlink(): r = sh(["ip", "link", "show", STARLINK_IFACE]) if not (r and r.returncode == 0): return {"state": "absent"} which = sh(["which", "grpcurl"]) if not (which and which.returncode == 0): return {"state": "no_grpcurl"} r = sh(["grpcurl", "-plaintext", "-max-time", "3", "-d", '{"get_status":{}}', STARLINK_DISH, "SpaceX.API.Device.Device/Handle"], timeout=4) if not (r and r.returncode == 0 and r.stdout.strip().startswith("{")): return {"state": "unreachable"} try: st = (json.loads(r.stdout).get("dishGetStatus") or {}) except json.JSONDecodeError: return {"state": "unreachable"} obs = st.get("obstructionStats") or {} alerts = [k for k, v in (st.get("alerts") or {}).items() if v] if st.get("outage"): state = (st["outage"].get("cause") or "outage").lower() elif obs.get("currentlyObstructed"): state = "obstructed" else: state = "online" return { "state": state, "alerts": alerts, "uptime_s": (st.get("deviceState") or {}).get("uptimeS"), "software_version": (st.get("deviceInfo") or {}).get("softwareVersion"), "latency_ms": st.get("popPingLatencyMs"), "downlink_bps": st.get("downlinkThroughputBps"), "uplink_bps": st.get("uplinkThroughputBps"), "obstructed_pct": (obs.get("fractionObstructed") or 0) * 100 if obs.get("fractionObstructed") is not None else None, } # ---------------- Modem + WAN/uplinks ---------------- def collect_modem(): r = sh(["mmcli", "-m", "any", "-K"]) if not (r and r.returncode == 0): return None kv = {} for line in r.stdout.splitlines(): if ":" in line: k, v = line.split(":", 1) kv[k.strip()] = v.strip() if not kv.get("modem.generic.state"): return None netdev, techs = None, [] for k, v in kv.items(): if k.startswith("modem.generic.ports.value"): if v.endswith("(net)"): netdev = v.split(" ")[0] if k.startswith("modem.generic.access-technologies.value"): techs.append(v) op = kv.get("modem.3gpp.operator-name") return {"netdev": netdev, "signal": kv.get("modem.generic.signal-quality.value"), "tech": "/".join(techs), "operator": None if op == "--" else op} def collect_wan(): modem = collect_modem() r = sh(["nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status"]) devs = [] for line in (r.stdout.strip().splitlines() if r and r.stdout.strip() else []): f = split_nmcli(line) if len(f) < 3: continue device, dtype, state, connection = f[0], f[1], f[2], ":".join(f[3:]) if dtype not in ("ethernet", "wifi", "gsm") or state == "unmanaged": continue if any(a["iface"] == device for a in APS): continue devs.append({"device": device, "type": dtype, "state": state, "connection": connection or None}) r = sh(["ip", "-j", "route", "show", "default"]) routes = json.loads(r.stdout) if r and r.stdout.strip() else [] metric_by_dev = {rt["dev"]: rt.get("metric") for rt in routes if rt.get("dev")} active_dev = min(metric_by_dev, key=lambda d: metric_by_dev[d] or 0) if metric_by_dev else None r = sh(["ip", "-j", "-4", "addr"]) ip_by_dev = {} for a in (json.loads(r.stdout) if r and r.stdout.strip() else []): info = next((x for x in a.get("addr_info", []) if x.get("family") == "inet"), None) if info: ip_by_dev[a["ifname"]] = f"{info['local']}/{info['prefixlen']}" for d in devs: if d["type"] == "gsm": d["name"] = "cellular" if modem: if modem["netdev"]: d["device"] = modem["netdev"] d["signal"] = modem["signal"] d["tech"] = modem["tech"] d["operator"] = modem["operator"] elif d["device"] == WIFI_WAN_IFACE: d["name"] = "wifi" elif d["device"] == STARLINK_IFACE: d["name"] = "starlink" else: d["name"] = None d["metric"] = metric_by_dev.get(d["device"]) d["ip"] = ip_by_dev.get(d["device"]) d["active"] = d["device"] == active_dev return devs def collect_deploywarn(): return read_json_file(DEPLOYWARN) or {"warnings": []} def read_prefer(): try: with open(FAILOVER_PREFER) as f: return f.read().strip() or None except OSError: return None # ---------------- Commands ---------------- def restart_ap(ap_id): ap = next((a for a in APS if a["id"] == ap_id), None) if ap: sh(["systemctl", "restart", ap["unit"]]) def restart_modem(): r = sh(["mmcli", "-m", "any", "--disable"]) if r and r.returncode == 0: sh(["mmcli", "-m", "any", "--enable"]) return # MM couldn't talk to the modem at all: USB unbind/bind by Quectel vendor id, # same fallback vanrouter.js used. dev = None for f in glob.glob("/sys/bus/usb/devices/*/idVendor"): try: if open(f).read().strip() == MODEM_USB_VENDOR: dev = f.split("/")[-2] break except OSError: continue if not dev: log("modem restart: no USB device found for vendor " + MODEM_USB_VENDOR) return try: open("/sys/bus/usb/drivers/usb/unbind", "w").write(dev) time.sleep(3) open("/sys/bus/usb/drivers/usb/bind", "w").write(dev) except OSError as e: log(f"modem restart: USB rebind failed: {e}") def wan_device_for(name, wan_rows): row = next((w for w in wan_rows if w.get("name") == name), None) return row["device"] if row else None def toggle_wan(name, verb, wan_rows): dev = wan_device_for(name, wan_rows) if not dev: log(f"wan toggle: no device currently known for '{name}'") return sh(["nmcli", "device", verb, dev]) def prefer_wan(name, wan_rows): if name == "auto": try: os.remove(FAILOVER_PREFER) except OSError: pass return dev = wan_device_for(name, wan_rows) if not dev: log(f"prefer wan: no device currently known for '{name}'") return os.makedirs(os.path.dirname(FAILOVER_PREFER), exist_ok=True) with open(FAILOVER_PREFER, "w") as f: f.write(dev) # ---------------- MQTT discovery ---------------- def _sensor(key, name, state_topic, value_template=None, json_attrs=False, device_class=None, entity_category=None): p = {"name": name, "unique_id": f"{device_id}_{key}", "device": DEVICE_INFO, "state_topic": state_topic, "expire_after": poll_interval_s * 4} if value_template: p["value_template"] = value_template if json_attrs: p["json_attributes_topic"] = state_topic if device_class: p["device_class"] = device_class if entity_category: p["entity_category"] = entity_category return p def _button(key, name, command_topic): return {"name": name, "unique_id": f"{device_id}_{key}", "command_topic": command_topic, "device": DEVICE_INFO} def _switch(key, name, command_topic, state_topic, value_template): # state_on/state_off default to payload_on/payload_off ("connect"/"disconnect") # when not set explicitly, NOT to "ON"/"OFF" -- must match value_template's # literal output or the entity sits at "unknown" forever (found via live check). return {"name": name, "unique_id": f"{device_id}_{key}", "command_topic": command_topic, "state_topic": state_topic, "value_template": value_template, "payload_on": "connect", "payload_off": "disconnect", "state_on": "ON", "state_off": "OFF", "device": DEVICE_INFO, "expire_after": poll_interval_s * 4} def _select(key, name, command_topic, options, state_topic=None, value_template=None): p = {"name": name, "unique_id": f"{device_id}_{key}", "command_topic": command_topic, "options": options, "device": DEVICE_INFO} if state_topic: p["state_topic"] = state_topic p["value_template"] = value_template return p def publish_discovery(mqc): def pub(component, key, payload): mqc.publish(f"{discovery_prefix}/{component}/{device_id}/{key}/config", json.dumps(payload), retain=True) for ap in APS: t = f"{state_root}/ap/{ap['id']}" pub("sensor", f"ap_{ap['id']}", _sensor( f"ap_{ap['id']}", f"AP {ap['band']}", t, "{{ value_json.active }}", json_attrs=True)) pub("button", f"ap_{ap['id']}_restart", _button( f"ap_{ap['id']}_restart", f"Restart {ap['band']} AP", f"{cmd_root}/ap/{ap['id']}/restart")) pub("sensor", "clients", _sensor( "clients", "Connected Clients", f"{state_root}/clients", "{{ value_json.rows | length }}", json_attrs=True)) pub("sensor", "starlink", _sensor( "starlink", "Starlink", f"{state_root}/starlink", "{{ value_json.state }}", json_attrs=True)) pub("sensor", "wan", _sensor( "wan", "WAN / Uplinks", f"{state_root}/wan", "{{ (value_json.rows | selectattr('active','eq',true) | map(attribute='device') | first) or 'none' }}", json_attrs=True)) pub("sensor", "deploywarn", _sensor( "deploywarn", "Deploy Warnings", f"{state_root}/deploywarn", "{{ value_json.warnings | length }}", json_attrs=True, entity_category="diagnostic")) pub("sensor", "failover", _sensor( "failover", "WAN Failover", f"{state_root}/failover", "{{ value_json.active_device }}", json_attrs=True)) pub("sensor", "thermal", _sensor( "thermal", "Thermal", f"{state_root}/thermal", "{% set levels = value_json.sensors.values() | map(attribute='level') | list %}" "{{ 'crit' if 'crit' in levels else ('warn' if 'warn' in levels else 'ok') }}", json_attrs=True)) pub("sensor", "battery", _sensor( "battery", "System Battery", f"{state_root}/battery", "{{ value_json.capacity }}", json_attrs=True, device_class="battery")) pub("button", "modem_restart", _button("modem_restart", "Restart Modem", f"{cmd_root}/modem/restart")) for name in WAN_NAMES: pub("switch", f"wan_{name}", _switch( f"wan_{name}", f"WAN {name}", f"{cmd_root}/wan/{name}/set", f"{state_root}/wan", "{{ 'ON' if (value_json.rows | selectattr('name','eq','" + name + "') " "| map(attribute='state') | first) == 'connected' else 'OFF' }}")) pub("select", "wan_prefer", _select( "wan_prefer", "Preferred WAN", f"{cmd_root}/wan/prefer", ["auto", *WAN_NAMES], f"{state_root}/wan_prefer", "{{ value_json.prefer }}")) # ---------------- publish state / dispatch commands ---------------- def publish_state(mqc): aps = [collect_ap(a) for a in APS] client_dir = collect_client_dir() wan_rows = collect_wan() mqc.publish(f"{state_root}/wan", json.dumps({"rows": wan_rows})) for ap, meta in zip(aps, APS): mqc.publish(f"{state_root}/ap/{meta['id']}", json.dumps(ap)) mqc.publish(f"{state_root}/clients", json.dumps({"rows": collect_clients(aps, client_dir)})) mqc.publish(f"{state_root}/starlink", json.dumps(collect_starlink())) mqc.publish(f"{state_root}/deploywarn", json.dumps(collect_deploywarn())) mqc.publish(f"{state_root}/wan_prefer", json.dumps({"prefer": read_prefer() or "auto"})) failover = read_json_file(FAILOVER_STATE) if failover is not None: mqc.publish(f"{state_root}/failover", json.dumps(failover)) thermal = read_json_file(THERMAL_STATE) if thermal is not None: mqc.publish(f"{state_root}/thermal", json.dumps(thermal)) battery = read_json_file(BATTERY_STATE) if battery is not None: mqc.publish(f"{state_root}/battery", json.dumps(battery)) return wan_rows COMMANDS = {} for _ap in APS: COMMANDS[f"{cmd_root}/ap/{_ap['id']}/restart"] = (lambda ap_id: lambda payload, wan_rows: restart_ap(ap_id))(_ap["id"]) COMMANDS[f"{cmd_root}/modem/restart"] = lambda payload, wan_rows: restart_modem() COMMANDS[f"{cmd_root}/wan/prefer"] = lambda payload, wan_rows: prefer_wan(payload.strip(), wan_rows) for _name in WAN_NAMES: COMMANDS[f"{cmd_root}/wan/{_name}/set"] = ( lambda name: lambda payload, wan_rows: toggle_wan(name, payload.strip(), wan_rows))(_name) def on_message(mqc, userdata, msg): fn = COMMANDS.get(msg.topic) if not fn: return payload = msg.payload.decode("utf-8", errors="replace") log(f"command {msg.topic} = {payload!r}") # A command may need the current WAN device mapping (toggle/prefer resolve # a WAN *name* to whatever device currently backs it) — recollect fresh # rather than trust a possibly-stale cached copy. wan_rows = collect_wan() try: fn(payload, wan_rows) except Exception as e: log(f"command {msg.topic} failed: {e}") publish_state(mqc) # reflect the result immediately, don't wait for next poll REFUSAL_REASONS = { 1: "unacceptable protocol version", 2: "client identifier rejected", 3: "server unavailable", 4: "bad username or password", 5: "not authorised", 132: "unsupported protocol version", 133: "client identifier rejected", 134: "bad username or password", 135: "not authorised", 136: "server unavailable", } _mqtt_state = None # None | "connected" | "refused" | "down": log changes only def refusal_reason(rc): n = getattr(rc, "value", rc) if n in REFUSAL_REASONS: return REFUSAL_REASONS[n] return f"unknown reason, rc={n}" def note_connect(rc): """rc: paho reason code (int-like) of a CONNACK. Logs on state change only.""" global _mqtt_state if getattr(rc, "value", rc) != 0: if _mqtt_state != "refused": log(f"MQTT connect refused by broker ({refusal_reason(rc)}) — if the broker " f"requires authentication, set username/password in {credentials_path(CONFIG_PATH)}") _mqtt_state = "refused" else: if _mqtt_state != "connected": log(f"MQTT connected to {broker}:{mqtt_port}") _mqtt_state = "connected" def note_disconnect(rc): global _mqtt_state if _mqtt_state == "connected": log(f"MQTT disconnected (rc={getattr(rc, 'value', rc)})") if _mqtt_state != "refused": _mqtt_state = "down" def on_connect(mqc, userdata, flags, reason_code, properties=None): note_connect(reason_code) if getattr(reason_code, "value", reason_code) != 0: return # (Re)subscribe and republish discovery on EVERY successful connect: a clean # session drops subscriptions on reconnect and a refused first connect loses them. try: for topic in COMMANDS: mqc.subscribe(topic) publish_discovery(mqc) except Exception as e: log(f"MQTT on_connect setup failed: {type(e).__name__}: {e}") def on_disconnect(mqc, userdata, disconnect_flags, reason_code, properties=None): note_disconnect(reason_code) def main(): mqc = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=cfg.get("client_id", "van-router-mqtt")) if cfg.get("username"): mqc.username_pw_set(cfg["username"], cfg.get("password", "")) mqc.on_message = on_message mqc.on_connect = on_connect mqc.on_disconnect = on_disconnect mqc.connect(broker, mqtt_port, keepalive=60) mqc.loop_start() log(f"van-router-mqtt started: broker={broker}:{mqtt_port} poll={poll_interval_s}s") while True: publish_state(mqc) time.sleep(poll_interval_s) if __name__ == "__main__": try: main() except KeyboardInterrupt: sys.exit(0)