Files
vanlink/router-status/van-router-mqtt
T
Andreas WredeandClaude Sonnet 5 ec5130ea7d router-status: replace Cockpit's Van Router plugin with MQTT -> Home Assistant
Cockpit's login was unreliable (Firefox rejected sometimes, Android failed
outright with a TLS handshake error) and it was a resource hog on this Pi.
van-router-mqtt publishes the same AP/client/WAN/Starlink/modem status and
accepts the same control actions (AP/modem restart, WAN connect/disconnect,
prefer) via MQTT with HA discovery, using the local Mosquitto broker already
running here. It republishes van-failover/van-thermal's own state.json files
verbatim rather than modifying either daemon (van-thermal is deliberately
stdlib-only; van-failover's control loop wasn't worth an added dependency).

Adds a Lovelace dashboard (ha/dashboards/van-router.yaml) consuming those
entities. Cockpit's plugin is left deployed for now, not yet retired.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2tpeaiHUriQbbnQ4SCbkf
2026-09-14 16:28:01 -04:00

547 lines
20 KiB
Python

#!/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"
with open(sys.argv[1] if len(sys.argv) > 1 else CONFIG) as f:
cfg = json.load(f)
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 log(msg):
print(msg, flush=True)
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
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.connect(broker, mqtt_port, keepalive=60)
for topic in COMMANDS:
mqc.subscribe(topic)
mqc.loop_start()
publish_discovery(mqc)
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)