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