fix(phase26): dual-radio — wlan0 permanent AP, wlan1 internet uplink (BUG-01)

This commit is contained in:
Mathieu Moran
2026-08-11 03:44:01 -04:00
parent 89bbc4fdb8
commit 592e049f59
3 changed files with 60 additions and 60 deletions
+30 -47
View File
@@ -199,32 +199,19 @@ def write_status(action, error=None):
def restore_fallback(): def restore_fallback():
"""Try home-wifi on wlan1. wlan0 AP is permanent and never touched."""
if activate(HOME_CONNECTION): if activate(HOME_CONNECTION):
write_status("connected_home") write_status("connected_home")
return True return True
# If the primary interface (wlan1) failed, try the wlan0 fallback profiles write_status("no_connection", "No home WiFi or phone hotspot available.")
if INTERFACE == PRIMARY_INTERFACE and _interface_exists(FALLBACK_INTERFACE): return True
# Temporarily try connections bound to wlan0
for conn in (f"{HOME_CONNECTION}-fb", AP_CONNECTION):
proc = subprocess.run(
["nmcli", "--wait", "30", "connection", "up", conn],
capture_output=True, text=True, timeout=35,
)
if proc.returncode == 0:
write_status(f"fallback_wlan0_{conn}")
return True
if activate(AP_CONNECTION):
write_status("restored_local_hotspot")
return True
write_status("recovery_failed", "Could not activate home WiFi or local hotspot.")
return False
def _ap_client_count(): def _ap_client_count():
"""Return number of stations connected to the AP on INTERFACE.""" """Return number of stations connected to the AP on wlan0."""
try: try:
proc = subprocess.run( proc = subprocess.run(
["iw", "dev", INTERFACE, "station", "dump"], ["iw", "dev", "wlan0", "station", "dump"],
capture_output=True, text=True, timeout=5, capture_output=True, text=True, timeout=5,
) )
return proc.stdout.count("Station ") return proc.stdout.count("Station ")
@@ -233,15 +220,10 @@ def _ap_client_count():
def check_uplink(): def check_uplink():
"""Manage internet uplink on wlan1 only. wlan0 is a permanent AP, never touched."""
current = active_connection() current = active_connection()
phone_configured = connection_exists(PHONE_CONNECTION) phone_configured = connection_exists(PHONE_CONNECTION)
# Never tear down the AP if a client is connected — the user is actively
# using the hotspot (e.g. phone browsing the web UI while driving).
# Skip this netwatch cycle and let the next one try again.
if current == AP_CONNECTION and _ap_client_count() > 0:
write_status("ap_has_clients_skipped_check")
return True
if current == PHONE_CONNECTION: if current == PHONE_CONNECTION:
if internet_reachable(): if internet_reachable():
write_status("phone_connected") write_status("phone_connected")
@@ -255,18 +237,20 @@ def check_uplink():
"Phone hotspot is connected but internet is not reachable.", "Phone hotspot is connected but internet is not reachable.",
) )
return True return True
return restore_fallback() return _connect_home_or_idle()
if phone_configured: if phone_configured:
if current == AP_CONNECTION: # Only take down the phone hotspot if it's up — never touch the AP on wlan0
run_nmcli("connection", "down", AP_CONNECTION, check=False) was_phone = current == PHONE_CONNECTION
if was_phone:
run_nmcli("connection", "down", PHONE_CONNECTION, check=False)
run_nmcli("device", "wifi", "rescan", "ifname", INTERFACE, check=False) run_nmcli("device", "wifi", "rescan", "ifname", INTERFACE, check=False)
time.sleep(5) time.sleep(5)
if not phone_is_visible(): if not phone_is_visible():
if current != AP_CONNECTION and active_connection(): if not was_phone and active_connection():
write_status("phone_not_in_range_current_connection_preserved") write_status("phone_not_in_range_current_connection_preserved")
return True return True
return restore_fallback() return _connect_home_or_idle()
if activate(PHONE_CONNECTION): if activate(PHONE_CONNECTION):
time.sleep(3) time.sleep(3)
if internet_reachable(): if internet_reachable():
@@ -281,20 +265,28 @@ def check_uplink():
"Phone hotspot is connected but internet is not reachable.", "Phone hotspot is connected but internet is not reachable.",
) )
return True return True
return restore_fallback() return _connect_home_or_idle()
if active_connection() == HOME_CONNECTION: if active_connection() == HOME_CONNECTION:
write_status("phone_unavailable_home_preserved") write_status("phone_unavailable_home_preserved")
return True return True
return restore_fallback() return _connect_home_or_idle()
if current != AP_CONNECTION: # Phone not configured. Home wifi is the only uplink on wlan1.
write_status("phone_not_configured") if current == HOME_CONNECTION:
write_status("home_connected_no_phone")
return True return True
run_nmcli("connection", "down", AP_CONNECTION, check=False) # Not on home-wifi — try to connect
run_nmcli("device", "wifi", "rescan", "ifname", INTERFACE, check=False) return _connect_home_or_idle()
time.sleep(5)
return restore_fallback()
def _connect_home_or_idle():
"""Try home-wifi on wlan1. wlan0 AP is never touched."""
if activate(HOME_CONNECTION):
write_status("connected_home")
return True
write_status("no_uplink_available", "No home WiFi or phone hotspot in range.")
return True
def wait_for_phone(timeout=120): def wait_for_phone(timeout=120):
@@ -304,11 +296,8 @@ def wait_for_phone(timeout=120):
write_status("phone_connected") write_status("phone_connected")
return True return True
original_connection = active_connection()
write_status("waiting_for_phone") write_status("waiting_for_phone")
time.sleep(10) time.sleep(10)
if original_connection == AP_CONNECTION:
run_nmcli("connection", "down", AP_CONNECTION, check=False)
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
while time.monotonic() < deadline: while time.monotonic() < deadline:
@@ -317,13 +306,7 @@ def wait_for_phone(timeout=120):
if phone_is_visible(): if phone_is_visible():
return check_uplink() return check_uplink()
if original_connection != AP_CONNECTION and active_connection(): restored = _connect_home_or_idle()
write_status(
"phone_wait_timed_out_current_connection_preserved",
"Phone hotspot did not appear within two minutes.",
)
return True
restored = restore_fallback()
if restored: if restored:
write_status( write_status(
"phone_wait_timed_out_fallback_restored", "phone_wait_timed_out_fallback_restored",
+27 -9
View File
@@ -134,17 +134,35 @@ def get_status():
connectivity = values[2] connectivity = values[2]
phone_ssid = _phone_ssid() phone_ssid = _phone_ssid()
internet_reachable = connectivity == "4 (full)" internet_reachable = connectivity == "4 (full)"
# Check wlan0 AP state (permanent hotspot)
ap_active = False
ap_ip = ""
try:
ap_active = subprocess.run(
["nmcli", "-g", "GENERAL.CONNECTION", "device", "show", "wlan0"],
capture_output=True, text=True, timeout=5,
).stdout.strip() == "Hotspot-local"
if ap_active:
ap_ip = subprocess.run(
["nmcli", "-g", "IP4.ADDRESS", "device", "show", "wlan0"],
capture_output=True, text=True, timeout=5,
).stdout.strip().split("/", 1)[0]
except (OSError, subprocess.SubprocessError):
pass
# Mode reflects wlan1 uplink state, not wlan0 AP
uplink_mode = (
"phone" if active == PHONE_CONNECTION
else "wifi" if active
else "disconnected"
)
return { return {
"active_connection": active, "active_connection": active,
"mode": ( "mode": uplink_mode,
"phone" "ap_active": ap_active,
if active == PHONE_CONNECTION "ap_ip": ap_ip,
else "local_hotspot"
if active == "Hotspot"
else "wifi"
if active
else "disconnected"
),
"state": values[1], "state": values[1],
"internet_reachable": internet_reachable, "internet_reachable": internet_reachable,
"connectivity": connectivity, "connectivity": connectivity,
+3 -4
View File
@@ -255,13 +255,12 @@ async function loadUplinkStatus() {
const disconnectedInfo = document.getElementById('in-car-disconnected-info'); const disconnectedInfo = document.getElementById('in-car-disconnected-info');
const hotspotUrl = document.getElementById('hotspot-url'); const hotspotUrl = document.getElementById('hotspot-url');
const copyBtn = document.getElementById('copy-hotspot-url'); const copyBtn = document.getElementById('copy-hotspot-url');
if (status.mode === 'local_hotspot') { if (status.ap_active && status.ap_ip) {
const ip = status.ip_address || '10.42.0.1'; hotspotUrl.textContent = `http://${status.ap_ip}`;
hotspotUrl.textContent = `http://${ip}`;
hotspotInfo.hidden = false; hotspotInfo.hidden = false;
wifiInfo.hidden = true; wifiInfo.hidden = true;
disconnectedInfo.hidden = true; disconnectedInfo.hidden = true;
if (copyBtn) copyBtn.onclick = () => navigator.clipboard.writeText(`http://${ip}`); if (copyBtn) copyBtn.onclick = () => navigator.clipboard.writeText(`http://${status.ap_ip}`);
} else if (status.mode === 'wifi' || status.mode === 'phone') { } else if (status.mode === 'wifi' || status.mode === 'phone') {
hotspotInfo.hidden = true; hotspotInfo.hidden = true;
wifiInfo.hidden = false; wifiInfo.hidden = false;