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
This commit is contained in:
Andreas Wrede
2026-09-14 16:28:01 -04:00
co-authored by Claude Sonnet 5
parent 918003d2e3
commit ec5130ea7d
6 changed files with 772 additions and 1 deletions
+42
View File
@@ -114,6 +114,16 @@ This directory is the source of truth. The live system files live under `/etc`,
### `cockpit/vanrouter/` — web UI ### `cockpit/vanrouter/` — web UI
`manifest.json`, `index.html`, `vanrouter.css`, `vanrouter.js` → `/usr/share/cockpit/vanrouter/`. `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 ### `power/` — never sleep + thermal monitor
| file | → installs to | purpose | | 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` → 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. `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 ## 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`). - 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. - **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) ### Home Assistant (native Podman container)
- HA Container runs on the host via a **Podman Quadlet** (`ha/homeassistant.container` → - HA Container runs on the host via a **Podman Quadlet** (`ha/homeassistant.container` →
`/etc/containers/systemd/`; systemd generates `homeassistant.service`). **Host network**: `/etc/containers/systemd/`; systemd generates `homeassistant.service`). **Host network**:
+15 -1
View File
@@ -176,6 +176,18 @@ install -D -m0644 cockpit/cockpit-session-nofile.conf /etc/systemd/system/cockpi
command -v grpcurl >/dev/null 2>&1 \ 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" || 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) ==" echo "== gps (cellular modem GNSS -> gpsd) =="
if dpkg -s gpsd >/dev/null 2>&1; then 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 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. # 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 mask systemd-networkd-wait-online.service >/dev/null 2>&1 || true
systemctl unmask hostapd >/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 # bluetooth: host BlueZ serves the onboard hci0 to the HA container over D-Bus
systemctl enable --now bluetooth >/dev/null 2>&1 || true systemctl enable --now bluetooth >/dev/null 2>&1 || true
systemctl start homeassistant || warn "homeassistant failed to start (podman/quadlet — check journalctl -u homeassistant)" 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-sms-watch
systemctl restart van-gps-owntracks systemctl restart van-gps-owntracks
systemctl restart van-li3-battery systemctl restart van-li3-battery
systemctl restart van-router-mqtt
# Pick up unmanaged-devices changes so NM releases/keeps the right interfaces. # 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 nmcli general reload 2>/dev/null || systemctl reload NetworkManager 2>/dev/null || true
# restart in dependency order: bridge + members first, then hostapd enslaves the # 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-thermal/state.json"
echo " cat /run/van-nvme-watch/state.json" echo " cat /run/van-nvme-watch/state.json"
echo " journalctl -u van-li3-battery -n 20" 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" echo " journalctl -u van-sms-watch -n 20 ; tail -f /var/log/van-sms.jsonl"
+139
View File
@@ -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 %}
+17
View File
@@ -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"
}
+546
View File
@@ -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)
+13
View File
@@ -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