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:
co-authored by
Claude Sonnet 5
parent
918003d2e3
commit
ec5130ea7d
@@ -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**:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user