#!/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)
