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():
"""Try home-wifi on wlan1. wlan0 AP is permanent and never touched."""
if activate(HOME_CONNECTION):
write_status("connected_home")
return True
# If the primary interface (wlan1) failed, try the wlan0 fallback profiles
if INTERFACE == PRIMARY_INTERFACE and _interface_exists(FALLBACK_INTERFACE):
# 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
write_status("no_connection", "No home WiFi or phone hotspot available.")
return True
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:
proc = subprocess.run(
["iw", "dev", INTERFACE, "station", "dump"],
["iw", "dev", "wlan0", "station", "dump"],
capture_output=True, text=True, timeout=5,
)
return proc.stdout.count("Station ")
@@ -233,15 +220,10 @@ def _ap_client_count():
def check_uplink():
"""Manage internet uplink on wlan1 only. wlan0 is a permanent AP, never touched."""
current = active_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 internet_reachable():
write_status("phone_connected")
@@ -255,18 +237,20 @@ def check_uplink():
"Phone hotspot is connected but internet is not reachable.",
)
return True
return restore_fallback()
return _connect_home_or_idle()
if phone_configured:
if current == AP_CONNECTION:
run_nmcli("connection", "down", AP_CONNECTION, check=False)
# Only take down the phone hotspot if it's up — never touch the AP on wlan0
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)
time.sleep(5)
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")
return True
return restore_fallback()
return _connect_home_or_idle()
if activate(PHONE_CONNECTION):
time.sleep(3)
if internet_reachable():
@@ -281,20 +265,28 @@ def check_uplink():
"Phone hotspot is connected but internet is not reachable.",
)
return True
return restore_fallback()
return _connect_home_or_idle()
if active_connection() == HOME_CONNECTION:
write_status("phone_unavailable_home_preserved")
return True
return restore_fallback()
return _connect_home_or_idle()
if current != AP_CONNECTION:
write_status("phone_not_configured")
# Phone not configured. Home wifi is the only uplink on wlan1.
if current == HOME_CONNECTION:
write_status("home_connected_no_phone")
return True
run_nmcli("connection", "down", AP_CONNECTION, check=False)
run_nmcli("device", "wifi", "rescan", "ifname", INTERFACE, check=False)
time.sleep(5)
return restore_fallback()
# Not on home-wifi — try to connect
return _connect_home_or_idle()
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):
@@ -304,11 +296,8 @@ def wait_for_phone(timeout=120):
write_status("phone_connected")
return True
original_connection = active_connection()
write_status("waiting_for_phone")
time.sleep(10)
if original_connection == AP_CONNECTION:
run_nmcli("connection", "down", AP_CONNECTION, check=False)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
@@ -317,13 +306,7 @@ def wait_for_phone(timeout=120):
if phone_is_visible():
return check_uplink()
if original_connection != AP_CONNECTION and active_connection():
write_status(
"phone_wait_timed_out_current_connection_preserved",
"Phone hotspot did not appear within two minutes.",
)
return True
restored = restore_fallback()
restored = _connect_home_or_idle()
if restored:
write_status(
"phone_wait_timed_out_fallback_restored",
+27 -9
View File
@@ -134,17 +134,35 @@ def get_status():
connectivity = values[2]
phone_ssid = _phone_ssid()
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 {
"active_connection": active,
"mode": (
"phone"
if active == PHONE_CONNECTION
else "local_hotspot"
if active == "Hotspot"
else "wifi"
if active
else "disconnected"
),
"mode": uplink_mode,
"ap_active": ap_active,
"ap_ip": ap_ip,
"state": values[1],
"internet_reachable": internet_reachable,
"connectivity": connectivity,
+3 -4
View File
@@ -255,13 +255,12 @@ async function loadUplinkStatus() {
const disconnectedInfo = document.getElementById('in-car-disconnected-info');
const hotspotUrl = document.getElementById('hotspot-url');
const copyBtn = document.getElementById('copy-hotspot-url');
if (status.mode === 'local_hotspot') {
const ip = status.ip_address || '10.42.0.1';
hotspotUrl.textContent = `http://${ip}`;
if (status.ap_active && status.ap_ip) {
hotspotUrl.textContent = `http://${status.ap_ip}`;
hotspotInfo.hidden = false;
wifiInfo.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') {
hotspotInfo.hidden = true;
wifiInfo.hidden = false;