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
@@ -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