#!/usr/bin/env python3
"""van-wlan-watchdog — recover wlan0 from NetworkManager's post-boot no-secrets wedge.

Seen on 2026-07-30: wlan0 (onboard radio, used as the WiFi WAN) sometimes fails its
very first post-boot association attempt with a spurious supplicant "psk mismatch"
(a boot-time brcmfmac firmware/regulatory race, not a real credential problem — a
second attempt with the *same* stored secret succeeds immediately). NetworkManager
treats any handshake failure it reads as bad secrets as terminal: it does not retry
autoconnect after a no-secrets failure, so the device just sits in `disconnected`
until something explicitly re-triggers it. This is that trigger.

Deliberately does NOT hardcode a connection/SSID name: which WiFi network wlan0 uses
changes with wherever the van is parked (home, a campsite, a neighbour's AP — see
config profiles managed from Cockpit's Networking tab or the Wi-Fi selector on the Van
Router page). `nmcli device connect wlan0` lets NM pick amongst whatever profiles are
saved and in range on its own, exactly like its normal autoconnect would.

Bounded to a few minutes after boot, then exits — this is a backstop for the boot race
above, not a permanent watcher. It must NOT fight a deliberate later disconnect (e.g.
the Cockpit "Disconnect" button), which is why it doesn't loop forever.

Usage: van-wlan-watchdog [device]
Defaults to wlan0 (the AP radios are separate USB dongles, watched by
van-ap-watchdog instead).
"""

import subprocess
import sys
import time

DEVICE = sys.argv[1] if len(sys.argv) > 1 else "wlan0"
INTERVAL = 15           # seconds between checks
BUDGET_S = 8 * INTERVAL  # ~2 minutes of retries after boot, then give up quietly


def log(msg, level="info"):
    # systemd journal severity prefixes (sd-daemon), same convention as van-ap-watchdog.
    pri = {"info": "<6>", "warn": "<4>", "crit": "<2>"}.get(level, "<6>")
    print(pri + msg, flush=True)


def device_state(dev):
    """NM's state word for dev (e.g. 'connected', 'disconnected', 'unavailable'), or
    None if nmcli failed or the device isn't known to NM yet."""
    try:
        out = subprocess.run(
            ["nmcli", "-t", "-f", "GENERAL.STATE", "device", "show", dev],
            capture_output=True, text=True, timeout=10,
        ).stdout.strip()
    except (OSError, subprocess.SubprocessError) as e:
        log(f"nmcli device show {dev} failed: {e}", "warn")
        return None
    # terse output looks like "GENERAL.STATE:30 (disconnected)"
    if "(" in out and out.endswith(")"):
        return out.rsplit("(", 1)[1][:-1]
    return None


def connect(dev):
    log(f"{dev} is disconnected — nudging NM to reconnect (autoconnect doesn't retry "
        f"after a no-secrets failure, only manual/dispatcher re-activation does)", "warn")
    r = subprocess.run(["nmcli", "device", "connect", dev],
                       capture_output=True, text=True, timeout=45)
    if r.returncode == 0:
        log(f"{dev} reconnected")
    else:
        log(f"nmcli device connect {dev} returned {r.returncode}: {r.stderr.strip()}", "warn")


def main():
    log(f"van-wlan-watchdog up: watching {DEVICE} for up to {BUDGET_S}s after boot")
    elapsed = 0
    while elapsed < BUDGET_S:
        state = device_state(DEVICE)
        if state == "disconnected":
            connect(DEVICE)
        elif state == "connected":
            log(f"{DEVICE} connected — exiting")
            return
        # "unavailable" (no known/in-range AP), "unmanaged", or unreadable: nothing to
        # nudge — retrying nmcli device connect would just fail again.
        time.sleep(INTERVAL)
        elapsed += INTERVAL
    log(f"{DEVICE} still not connected after {BUDGET_S}s — giving up "
        f"(leaving it for manual/Cockpit reconnect from here on)", "warn")


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        sys.exit(0)
