Files
Andreas WredeandClaude Sonnet 5 5d88e1b30c deploy: hardware-instance templating, wlan0 boot watchdog, deploy-time warnings in Cockpit
- deploy.conf templates interface names/USB IDs (@TOKEN@ substitution) across
  ap/* configs so a dongle swap only needs deploy.conf edited, not the repo
  configs themselves; drops ap/rtw88.conf (old 2.4GHz dongle retired for the
  DWA-171, which needs no such power-save override).
- failover/van-wlan-watchdog: recovers wlan0 from NM's post-boot no-secrets
  wedge (a boot-time supplicant race, not a real credential failure).
- deploy.sh: warn() collects dependency/config warnings (missing python3-gps,
  python3-paho-mqtt, mobile-broadband-provider-info, grpcurl, gpsd; netplan
  drift; unedited example configs) into /var/lib/vanlink/deploy-warnings.json,
  rendered as an amber Cockpit card so they're visible without reading deploy
  output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 11:25:11 -04:00

93 lines
3.8 KiB
Python

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