From ec5130ea7d5c337594d6bb7a37be6f4a1ca1c53b Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Mon, 14 Sep 2026 16:28:01 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01S2tpeaiHUriQbbnQ4SCbkf --- README.md | 42 ++ deploy.sh | 16 +- ha/dashboards/van-router.yaml | 139 +++++++ router-status/config.json | 17 + router-status/van-router-mqtt | 546 ++++++++++++++++++++++++++ router-status/van-router-mqtt.service | 13 + 6 files changed, 772 insertions(+), 1 deletion(-) create mode 100644 ha/dashboards/van-router.yaml create mode 100644 router-status/config.json create mode 100644 router-status/van-router-mqtt create mode 100644 router-status/van-router-mqtt.service diff --git a/README.md b/README.md index d35b47d..f3a1688 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,16 @@ This directory is the source of truth. The live system files live under `/etc`, ### `cockpit/vanrouter/` — web UI `manifest.json`, `index.html`, `vanrouter.css`, `vanrouter.js` → `/usr/share/cockpit/vanrouter/`. +Being superseded by `router-status/` below (Cockpit's PAM+TLS login was unreliable +— rejected Firefox sometimes, failed outright from Android — and it was a resource +hog); left in place until the Home Assistant dashboard fully covers its actions. + +### `router-status/` — same status/actions, via MQTT -> Home Assistant +| file | → installs to | purpose | +|---|---|---| +| `van-router-mqtt` | `/usr/local/sbin/van-router-mqtt` | status+control daemon (Python, paho-mqtt): AP/clients/Starlink/WAN/modem, HA MQTT discovery; republishes van-failover/van-thermal/van-battery's own state.json verbatim rather than recomputing them | +| `config.json` | `/etc/van-router-mqtt/config.json` | broker + interface identifiers (`@TOKEN@` templated like `failover/config.json` — no secrets, rendered fresh every deploy) | +| `van-router-mqtt.service` | `/etc/systemd/system/van-router-mqtt.service` | `Restart=always` | ### `power/` — never sleep + thermal monitor | file | → installs to | purpose | @@ -161,6 +171,16 @@ supply (laptop UPS-style monitor), unrelated hardware and purpose. The `homeassistant` LAN name comes from `ap/van-ap-dnsmasq.conf` (`host-record` → `10.42.0.1`). The retired HAOS VM's domain XML (`ha_van.xml`) lives in git history. +`custom_components/` and `dashboards/` are **not deployed by `deploy.sh`** — HA's +`/srv/homeassistant` is hand-managed live config (it has other integrations installed +via HACS that were never in this repo, e.g. `mbapi2020`); these two are kept here purely +as a reference/backup copy of the hand-authored pieces. `dashboards/van-router.yaml` is +the Lovelace dashboard for `van-router-mqtt`'s entities — the live copy is +`/srv/homeassistant/dashboards/van-router.yaml`, wired in via a `lovelace: dashboards:` +block manually added to `/srv/homeassistant/configuration.yaml` (also not repo-tracked; +see that file's own comment). After editing the dashboard, copy both places and +`systemctl restart homeassistant` (or use HA's own "reload" for lovelace-only changes). + --- ## 3. Components & how to operate @@ -303,6 +323,28 @@ script's docstring for the full field layout. - The Access Points card shows both bands (5GHz `hostapd`, 2.4GHz `hostapd-2g`) with per-band client lists and **Restart** buttons; the radio list lives in `vanrouter.js` (`const APS`). - **Prefer** sets the manual WAN preference (see *Manual preference* above); **Up/Down** connect/disconnect the NM device. +### Router status via MQTT (`van-router-mqtt`) — replacing Cockpit above +- Publishes the same status Cockpit's plugin showed, and accepts the same actions, over + MQTT with Home Assistant discovery — no web server/login of its own; HA (already running + on this Pi) owns the UI. Broker is the local Mosquitto on `127.0.0.1:1883` (already used by + HA itself and by `van-gps-owntracks`/`li3`); it currently allows anonymous connections, so + `config.json` carries no credentials. +- State topics (`van/router/...`): `ap/5g`, `ap/2g`, `clients`, `starlink`, `wan`, + `deploywarn`, plus verbatim republishes of `van-failover`'s and `van-thermal`'s own + `/run/van-*/state.json` (those two daemons were **not** modified — `van-thermal` is + deliberately stdlib-only for boot resilience, and `van-failover`'s control loop wasn't + touched). `van/router/battery` only appears if `power/van-battery` (documented in this + README, not actually deployed on this host) ever starts writing its state file. +- Command topics (`vanlink/cmd/...`): `ap/{5g,2g}/restart`, `modem/restart`, + `wan/{wifi,starlink,cellular}/set` (`connect`/`disconnect`), `wan/prefer` + (`auto`/`wifi`/`starlink`/`cellular` — writes the same `/run/van-failover/prefer` file + Cockpit's "Prefer" button did). Wi-Fi scan/connect (wlan0 as a WAN) was **not** ported — + it's an interactive password-prompt flow that doesn't map to MQTT/HA entities well and is + only used during initial setup; use `nmcli` directly for that. +- Verify: `journalctl -u van-router-mqtt -f`, `mosquitto_sub -h localhost -t 'van/router/#' -v`. + Entities should appear in HA under the "Van Router" device once the integration picks up + the retained discovery messages (Settings → Devices & Services → MQTT). + ### Home Assistant (native Podman container) - HA Container runs on the host via a **Podman Quadlet** (`ha/homeassistant.container` → `/etc/containers/systemd/`; systemd generates `homeassistant.service`). **Host network**: diff --git a/deploy.sh b/deploy.sh index a0cdeb7..a8868b4 100755 --- a/deploy.sh +++ b/deploy.sh @@ -176,6 +176,18 @@ install -D -m0644 cockpit/cockpit-session-nofile.conf /etc/systemd/system/cockpi command -v grpcurl >/dev/null 2>&1 \ || warn "grpcurl missing (Starlink card will say so): install linux_arm64 binary from github.com/fullstorydev/grpcurl/releases" +echo "== router status (MQTT -> Home Assistant, replacing the Cockpit plugin above) ==" +# Same status/actions as the cockpit plugin, published via MQTT discovery +# instead of a web UI of its own (Cockpit's login was unreliable and it was a +# resource hog on this Pi). No secrets in config.json (the local Mosquitto +# broker currently allows anonymous connections) so it's rendered + installed +# every deploy, same as failover/config.json. +install -D -m0755 router-status/van-router-mqtt /usr/local/sbin/van-router-mqtt +install_rendered router-status/config.json /etc/van-router-mqtt/config.json +install -D -m0644 router-status/van-router-mqtt.service /etc/systemd/system/van-router-mqtt.service +python3 -c 'import paho.mqtt' 2>/dev/null \ + || warn "python3-paho-mqtt missing (apt install python3-paho-mqtt) — van-router-mqtt won't start" + echo "== gps (cellular modem GNSS -> gpsd) ==" if dpkg -s gpsd >/dev/null 2>&1; then install -D -m0644 gps/77-modem-gps.rules /etc/udev/rules.d/77-modem-gps.rules @@ -260,7 +272,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-sms-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 van-router-mqtt >/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)" @@ -271,6 +283,7 @@ systemctl restart van-nvme-watch systemctl restart van-sms-watch systemctl restart van-gps-owntracks systemctl restart van-li3-battery +systemctl restart van-router-mqtt # Pick up unmanaged-devices changes so NM releases/keeps the right interfaces. nmcli general reload 2>/dev/null || systemctl reload NetworkManager 2>/dev/null || true # restart in dependency order: bridge + members first, then hostapd enslaves the @@ -297,4 +310,5 @@ 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-router-mqtt -n 20 ; mosquitto_sub -h localhost -t 'van/router/#' -v -C 8" echo " journalctl -u van-sms-watch -n 20 ; tail -f /var/log/van-sms.jsonl" diff --git a/ha/dashboards/van-router.yaml b/ha/dashboards/van-router.yaml new file mode 100644 index 0000000..3cf9854 --- /dev/null +++ b/ha/dashboards/van-router.yaml @@ -0,0 +1,139 @@ +title: Van Router +views: + - title: Van Router + path: van-router + icon: mdi:router-wireless + cards: + - type: glance + title: At a glance + entities: + - entity: sensor.van_router_ap_5ghz + name: 5GHz AP + - entity: sensor.van_router_ap_2_4ghz + name: 2.4GHz AP + - entity: sensor.van_router_starlink + name: Starlink + - entity: sensor.van_router_wan_failover + name: Active WAN + - entity: sensor.van_router_connected_clients + name: Clients + - entity: sensor.van_router_thermal + name: Thermal + + - type: vertical-stack + cards: + - type: entities + title: 5GHz AP + entities: + - type: attribute + entity: sensor.van_router_ap_5ghz + attribute: ssid + name: SSID + - type: attribute + entity: sensor.van_router_ap_5ghz + attribute: channel + name: Channel + - type: attribute + entity: sensor.van_router_ap_5ghz + attribute: client_count + name: Clients + - entity: button.van_router_restart_5ghz_ap + name: Restart + - type: entities + title: 2.4GHz AP + entities: + - type: attribute + entity: sensor.van_router_ap_2_4ghz + attribute: ssid + name: SSID + - type: attribute + entity: sensor.van_router_ap_2_4ghz + attribute: channel + name: Channel + - type: attribute + entity: sensor.van_router_ap_2_4ghz + attribute: client_count + name: Clients + - entity: button.van_router_restart_2_4ghz_ap + name: Restart + + - type: markdown + title: Connected clients + content: | + {%- set rows = state_attr('sensor.van_router_connected_clients', 'rows') or [] -%} + {%- if rows | length == 0 %} + No clients connected. + {%- else %} + | Host | IP | MAC | Connection | Signal | TX rate | + |---|---|---|---|---|---| + {%- for r in rows %} + | {{ (r.host or '—').replace('-', '‑').replace(' ', ' ') }} | {{ r.ip or '—' }} | {{ r.mac }} | {{ r.conn }} | {{ r.sig or '—' }} | {{ (r.tx.split(' ')[:2] | join(' ')).replace('/', '⁠/⁠') if r.tx else '—' }} | + {%- endfor -%} + {%- endif %} + + - type: entities + title: WAN failover + entities: + - entity: select.van_router_preferred_wan + name: Preferred WAN + - entity: switch.van_router_wan_wifi + name: Wi-Fi WAN + - entity: switch.van_router_wan_starlink + name: Starlink WAN + - entity: switch.van_router_wan_cellular + name: Cellular WAN + - entity: button.van_router_restart_modem + name: Restart modem + + - type: markdown + title: WAN detail + content: | + {%- set wans = state_attr('sensor.van_router_wan_failover', 'wans') or [] -%} + | WAN | Priority | Device | Status | Metric | + |---|---|---|---|---| + {%- for w in wans %} + | {{ '★ ' if w.active else '' }}{{ w.name }} | {{ w.priority }}{{ ' (preferred)' if w.preferred else '' }} | {{ w.device or '—' }} | {{ 'up' if w.up else ('absent' if not w.present else 'down') }} | {{ w.route_metric if w.route_metric is not none else '—' }} | + {%- endfor %} + + - type: entities + title: Starlink + entities: + - type: attribute + entity: sensor.van_router_starlink + attribute: latency_ms + name: Latency (PoP) + - type: attribute + entity: sensor.van_router_starlink + attribute: downlink_bps + name: Downlink (bps) + - type: attribute + entity: sensor.van_router_starlink + attribute: uplink_bps + name: Uplink (bps) + - type: attribute + entity: sensor.van_router_starlink + attribute: obstructed_pct + name: Obstructed % + + - type: markdown + title: Thermal + content: | + {%- set sensors = state_attr('sensor.van_router_thermal', 'sensors') or {} -%} + | Sensor | Value | Level | + |---|---|---| + {%- for name, s in sensors.items() %} + | {{ name }} | {{ s.temp if s.kind == 'temp' else (s.rpm ~ ' rpm' if s.kind == 'fan' else s.now) }} | {{ s.level }} | + {%- endfor %} + + - type: conditional + conditions: + - condition: state + entity: sensor.van_router_deploy_warnings + state_not: "0" + card: + type: markdown + title: Deploy warnings + content: | + {%- for w in state_attr('sensor.van_router_deploy_warnings', 'warnings') or [] %} + - {{ w }} + {%- endfor %} diff --git a/router-status/config.json b/router-status/config.json new file mode 100644 index 0000000..fa1fa07 --- /dev/null +++ b/router-status/config.json @@ -0,0 +1,17 @@ +{ + "broker": "localhost", + "port": 1883, + "client_id": "van-router-mqtt", + "device_id": "van_router", + "discovery_prefix": "homeassistant", + "state_root": "van/router", + "cmd_root": "vanlink/cmd", + "poll_interval_s": 5, + "wifi_5g_iface": "@WIFI_5G_IFACE@", + "wifi_2g_iface": "@WIFI_2G_IFACE@", + "lan_usb_iface": "@LAN_USB_IFACE@", + "starlink_iface": "@STARLINK_IFACE@", + "starlink_dish": "192.168.100.1:9200", + "modem_usb_vendor": "@MODEM_USB_VENDOR@", + "wifi_wan_iface": "wlan0" +} diff --git a/router-status/van-router-mqtt b/router-status/van-router-mqtt new file mode 100644 index 0000000..7998d52 --- /dev/null +++ b/router-status/van-router-mqtt @@ -0,0 +1,546 @@ +#!/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) diff --git a/router-status/van-router-mqtt.service b/router-status/van-router-mqtt.service new file mode 100644 index 0000000..c842a5e --- /dev/null +++ b/router-status/van-router-mqtt.service @@ -0,0 +1,13 @@ +[Unit] +Description=Van Router status/control -> MQTT (Home Assistant discovery) +After=NetworkManager.service network-online.target mosquitto.service +Wants=NetworkManager.service + +[Service] +Type=simple +ExecStart=/usr/local/sbin/van-router-mqtt +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target