HA renders JSON null as 'None'; map None to the string 'unavailable' for the WAN, AP, Starlink, failover, thermal and battery payloads. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
649 lines
24 KiB
Python
649 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
"""van-router-mqtt — publishes campervan router status to MQTT (Home Assistant
|
|
discovery) and executes control commands, replacing the Cockpit "Van Router"
|
|
plugin (cockpit/vanrouter/). Cockpit was a resource hog on this Pi and its
|
|
login (PAM+TLS) rejected Firefox sometimes and Android entirely; this daemon
|
|
sidesteps that by not running a web server or auth stack of its own at all —
|
|
HA (already running here) owns the UI and its own login.
|
|
|
|
AP/clients/Starlink/WAN/deploy-warnings are computed here exactly like
|
|
vanrouter.js did (same shell-outs, same parsing). Failover/thermal/battery
|
|
state is NOT recomputed — those daemons already write /run/van-*/state.json
|
|
each cycle; this just republishes that file's content verbatim. (van-thermal
|
|
is deliberately stdlib-only for boot resilience; van-battery is documented in
|
|
the README but not actually deployed on this host, so its topic just stays
|
|
unpublished until/unless that changes — same as Cockpit's own "daemon not
|
|
running" fallback.)
|
|
|
|
WAN toggle/prefer commands write the same files/run the same nmcli calls the
|
|
Cockpit plugin did (including PREFER, which van-failover already reads) —
|
|
this daemon owns no failover state of its own.
|
|
"""
|
|
import glob
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
import paho.mqtt.client as mqtt
|
|
|
|
CONFIG = "/etc/van-router-mqtt/config.json"
|
|
CONFIG_PATH = sys.argv[1] if len(sys.argv) > 1 else CONFIG
|
|
|
|
|
|
def log(msg):
|
|
print(msg, flush=True)
|
|
|
|
|
|
def credentials_path(config_path):
|
|
return os.path.join(os.path.dirname(config_path), "credentials.json")
|
|
|
|
|
|
def load_credentials(config_path, cfg):
|
|
"""Merge username/password from credentials.json (next to the config file)
|
|
into cfg. The config is re-rendered on every deploy, so secrets live in
|
|
this separate 0600 file. Never raises; never logs the values."""
|
|
path = credentials_path(config_path)
|
|
if not os.path.exists(path):
|
|
return cfg
|
|
try:
|
|
with open(path) as f:
|
|
cred = json.load(f)
|
|
if not isinstance(cred, dict):
|
|
raise ValueError("not a JSON object")
|
|
except (OSError, ValueError) as e:
|
|
log(f"credentials file {path} ignored ({type(e).__name__}); connecting anonymously")
|
|
return cfg
|
|
if not cred.get("username"):
|
|
log(f"credentials file {path} has no username; connecting anonymously")
|
|
return cfg
|
|
cfg["username"] = cred["username"]
|
|
cfg["password"] = cred.get("password", "")
|
|
return cfg
|
|
|
|
|
|
if __name__ == "__main__":
|
|
with open(CONFIG_PATH) as f:
|
|
cfg = json.load(f)
|
|
cfg = load_credentials(CONFIG_PATH, cfg)
|
|
else: # imported (tests): no config file, no side effects
|
|
import collections
|
|
cfg = collections.defaultdict(str)
|
|
|
|
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 mark_unavailable(obj):
|
|
"""Copy of obj with every None replaced by 'unavailable' -- HA treats that string
|
|
as a missing value, whereas null renders as 'None'."""
|
|
if obj is None:
|
|
return "unavailable"
|
|
if isinstance(obj, dict):
|
|
return {k: mark_unavailable(v) for k, v in obj.items()}
|
|
if isinstance(obj, list):
|
|
return [mark_unavailable(v) for v in obj]
|
|
return obj
|
|
|
|
|
|
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(mark_unavailable({"rows": wan_rows})))
|
|
for ap, meta in zip(aps, APS):
|
|
mqc.publish(f"{state_root}/ap/{meta['id']}", json.dumps(mark_unavailable(ap)))
|
|
mqc.publish(f"{state_root}/clients", json.dumps({"rows": collect_clients(aps, client_dir)}))
|
|
mqc.publish(f"{state_root}/starlink", json.dumps(mark_unavailable(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(mark_unavailable(failover)))
|
|
thermal = read_json_file(THERMAL_STATE)
|
|
if thermal is not None:
|
|
mqc.publish(f"{state_root}/thermal", json.dumps(mark_unavailable(thermal)))
|
|
battery = read_json_file(BATTERY_STATE)
|
|
if battery is not None:
|
|
mqc.publish(f"{state_root}/battery", json.dumps(mark_unavailable(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
|
|
|
|
|
|
REFUSAL_REASONS = {
|
|
1: "unacceptable protocol version", 2: "client identifier rejected",
|
|
3: "server unavailable", 4: "bad username or password", 5: "not authorised",
|
|
132: "unsupported protocol version", 133: "client identifier rejected",
|
|
134: "bad username or password", 135: "not authorised", 136: "server unavailable",
|
|
}
|
|
_mqtt_state = None # None | "connected" | "refused" | "down": log changes only
|
|
|
|
|
|
def refusal_reason(rc):
|
|
n = getattr(rc, "value", rc)
|
|
if n in REFUSAL_REASONS:
|
|
return REFUSAL_REASONS[n]
|
|
return f"unknown reason, rc={n}"
|
|
|
|
|
|
def note_connect(rc):
|
|
"""rc: paho reason code (int-like) of a CONNACK. Logs on state change only."""
|
|
global _mqtt_state
|
|
if getattr(rc, "value", rc) != 0:
|
|
if _mqtt_state != "refused":
|
|
log(f"MQTT connect refused by broker ({refusal_reason(rc)}) — if the broker "
|
|
f"requires authentication, set username/password in {credentials_path(CONFIG_PATH)}")
|
|
_mqtt_state = "refused"
|
|
else:
|
|
if _mqtt_state != "connected":
|
|
log(f"MQTT connected to {broker}:{mqtt_port}")
|
|
_mqtt_state = "connected"
|
|
|
|
|
|
def note_disconnect(rc):
|
|
global _mqtt_state
|
|
if _mqtt_state == "connected":
|
|
log(f"MQTT disconnected (rc={getattr(rc, 'value', rc)})")
|
|
if _mqtt_state != "refused":
|
|
_mqtt_state = "down"
|
|
|
|
|
|
def on_connect(mqc, userdata, flags, reason_code, properties=None):
|
|
note_connect(reason_code)
|
|
if getattr(reason_code, "value", reason_code) != 0:
|
|
return
|
|
# (Re)subscribe and republish discovery on EVERY successful connect: a clean
|
|
# session drops subscriptions on reconnect and a refused first connect loses them.
|
|
try:
|
|
for topic in COMMANDS:
|
|
mqc.subscribe(topic)
|
|
publish_discovery(mqc)
|
|
except Exception as e:
|
|
log(f"MQTT on_connect setup failed: {type(e).__name__}: {e}")
|
|
|
|
|
|
def on_disconnect(mqc, userdata, disconnect_flags, reason_code, properties=None):
|
|
note_disconnect(reason_code)
|
|
|
|
|
|
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.on_connect = on_connect
|
|
mqc.on_disconnect = on_disconnect
|
|
mqc.connect(broker, mqtt_port, keepalive=60)
|
|
mqc.loop_start()
|
|
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)
|