#!/usr/bin/env python3 """Manage the Pi's phone-hotspot, home-WiFi, and local-AP priority.""" import json import os import subprocess import sys import tempfile import time import uuid PHONE_CONNECTION = "phone-hotspot" HOME_CONNECTION = "home-wifi" AP_CONNECTION = "Hotspot-local" # 2026-08-14 (FEAT-14): dual T3U — wlan1 (T3U #1) hosts the in-car AP so phones # get 5GHz AC throughput; a second T3U (wlan2) carries the internet uplink # (home-wifi / phone-hotspot); the onboard BCM43438 (wlan0) is only a fallback. AP_INTERFACE = "wlan1" PRIMARY_INTERFACE = "wlan2" # T3U #2 — internet uplink FALLBACK_INTERFACE = "wlan0" # onboard BCM43438 — fallback if T3U #2 is absent STATUS_PATH = "/var/lib/yaris-obd/uplink-status.json" PHONE_KEYFILE = ( "/etc/NetworkManager/system-connections/" "phone-hotspot.nmconnection" ) class UplinkError(RuntimeError): pass def _interface_exists(iface): """Return True if the kernel sees this network interface.""" return os.path.exists(f"/sys/class/net/{iface}") def resolve_interface(): """Return PRIMARY_INTERFACE if it exists, else FALLBACK_INTERFACE.""" if _interface_exists(PRIMARY_INTERFACE): return PRIMARY_INTERFACE if _interface_exists(FALLBACK_INTERFACE): return FALLBACK_INTERFACE return PRIMARY_INTERFACE # return primary anyway; nmcli will error clearly # Module-level convenience — resolved once at startup. INTERFACE = resolve_interface() def run_nmcli(*args, timeout=45, check=True): process = subprocess.run( ["nmcli", *args], capture_output=True, text=True, timeout=timeout, ) if check and process.returncode != 0: detail = (process.stderr or process.stdout).strip() raise UplinkError(detail or f"nmcli exited {process.returncode}") return process def connection_exists(name): return run_nmcli( "-g", "connection.id", "connection", "show", name, check=False, ).returncode == 0 def active_connection(): process = run_nmcli( "-g", "GENERAL.CONNECTION", "device", "show", INTERFACE, check=False, ) return process.stdout.strip() if process.returncode == 0 else "" def ipv4_address(): process = run_nmcli( "-g", "IP4.ADDRESS", "device", "show", INTERFACE, check=False, ) return ( process.stdout.strip().split("/", 1)[0] if process.returncode == 0 else "" ) def internet_reachable(): process = run_nmcli( "-g", "GENERAL.IP4-CONNECTIVITY", "device", "show", INTERFACE, check=False, ) return process.returncode == 0 and process.stdout.strip() == "4 (full)" def phone_ssid(): process = run_nmcli( "-g", "802-11-wireless.ssid", "connection", "show", PHONE_CONNECTION, check=False, ) return process.stdout.strip() if process.returncode == 0 else "" def phone_is_visible(): ssid = phone_ssid() if not ssid: return False process = run_nmcli( "-t", "--escape", "no", "-f", "SSID", "device", "wifi", "list", "ifname", INTERFACE, check=False, ) return process.returncode == 0 and ssid in process.stdout.splitlines() def activate(name, wait=30): if not connection_exists(name): return False process = run_nmcli( "--wait", str(wait), "connection", "up", name, timeout=wait + 5, check=False, ) return process.returncode == 0 def write_status(action, error=None): previous = {} try: with open(STATUS_PATH, encoding="utf-8") as handle: previous = json.load(handle) except (FileNotFoundError, OSError, json.JSONDecodeError): pass current = active_connection() last_phone_ip = previous.get("last_phone_ip") if current == PHONE_CONNECTION: last_phone_ip = ipv4_address() or last_phone_ip status = { "action": action, "active_connection": current, "interface": INTERFACE, "error": error, "last_phone_ip": last_phone_ip, "updated_at": time.time(), } os.makedirs(os.path.dirname(STATUS_PATH), mode=0o755, exist_ok=True) temporary_path = None try: with tempfile.NamedTemporaryFile( "w", encoding="utf-8", dir=os.path.dirname(STATUS_PATH), prefix=".uplink-status-", delete=False, ) as handle: temporary_path = handle.name os.fchmod(handle.fileno(), 0o644) json.dump(status, handle) handle.flush() os.fsync(handle.fileno()) os.replace(temporary_path, STATUS_PATH) except OSError: if temporary_path is not None: try: os.remove(temporary_path) except FileNotFoundError: pass raise def restore_fallback(): """Try home-wifi on the uplink interface. The AP on wlan1 is never touched.""" if activate(HOME_CONNECTION): write_status("connected_home") return True 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 wlan1 (T3U).""" try: proc = subprocess.run( ["iw", "dev", AP_INTERFACE, "station", "dump"], capture_output=True, text=True, timeout=5, ) return proc.stdout.count("Station ") except (OSError, subprocess.SubprocessError): return 0 def check_uplink(): """Manage internet uplink on the uplink interface only. wlan1 AP is never touched.""" current = active_connection() phone_configured = connection_exists(PHONE_CONNECTION) if current == PHONE_CONNECTION: if internet_reachable(): write_status("phone_connected") return True if activate(HOME_CONNECTION): write_status("phone_without_internet_connected_home") return True if active_connection() == PHONE_CONNECTION or activate(PHONE_CONNECTION): write_status( "phone_connected_without_internet", "Phone hotspot is connected but internet is not reachable.", ) return True return _connect_home_or_idle() if phone_configured: # Only take down the phone hotspot if it's up — never touch the AP on wlan1 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 not was_phone and active_connection(): write_status("phone_not_in_range_current_connection_preserved") return True return _connect_home_or_idle() if activate(PHONE_CONNECTION): time.sleep(3) if internet_reachable(): write_status("connected_phone") return True if activate(HOME_CONNECTION): write_status("phone_without_internet_connected_home") return True if active_connection() == PHONE_CONNECTION: write_status( "phone_connected_without_internet", "Phone hotspot is connected but internet is not reachable.", ) return True return _connect_home_or_idle() if active_connection() == HOME_CONNECTION: write_status("phone_unavailable_home_preserved") return True return _connect_home_or_idle() # Phone not configured. Home wifi is the only uplink. if current == HOME_CONNECTION: write_status("home_connected_no_phone") return True # Not on home-wifi — try to connect return _connect_home_or_idle() def _connect_home_or_idle(): """Try home-wifi on the uplink interface. The AP on wlan1 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): if not connection_exists(PHONE_CONNECTION): raise UplinkError("No phone hotspot is configured.") if active_connection() == PHONE_CONNECTION: write_status("phone_connected") return True write_status("waiting_for_phone") time.sleep(10) deadline = time.monotonic() + timeout while time.monotonic() < deadline: run_nmcli("device", "wifi", "rescan", "ifname", INTERFACE, check=False) time.sleep(5) if phone_is_visible(): return check_uplink() restored = _connect_home_or_idle() if restored: write_status( "phone_wait_timed_out_fallback_restored", "Phone hotspot did not appear within two minutes.", ) return restored def validate_credentials(payload): if not isinstance(payload, dict): raise UplinkError("Expected a JSON object.") ssid = payload.get("ssid") password = payload.get("password") if not isinstance(ssid, str) or not ssid or len(ssid.encode("utf-8")) > 32: raise UplinkError("SSID must be between 1 and 32 bytes.") if "\x00" in ssid or "\n" in ssid or "\r" in ssid: raise UplinkError("SSID contains unsupported characters.") if ";" in ssid: raise UplinkError("SSID cannot contain a semicolon.") if not isinstance(password, str) or not 8 <= len(password.encode("utf-8")) <= 63: raise UplinkError("Password must be between 8 and 63 bytes.") if "\x00" in password or "\n" in password or "\r" in password: raise UplinkError("Password contains unsupported characters.") return ssid, password def keyfile_escape(value): leading_spaces = len(value) - len(value.lstrip(" ")) trailing_spaces = len(value) - len(value.rstrip(" ")) end = len(value) - trailing_spaces if trailing_spaces else len(value) middle = value[leading_spaces:end].replace("\\", "\\\\") return "\\s" * leading_spaces + middle + "\\s" * trailing_spaces def configure(): raw = sys.stdin.buffer.read(2049) if len(raw) > 2048: raise UplinkError("Configuration request is too large.") try: payload = json.loads(raw) except (UnicodeDecodeError, json.JSONDecodeError) as error: raise UplinkError("Invalid configuration JSON.") from error ssid, password = validate_credentials(payload) was_active = active_connection() == PHONE_CONNECTION run_nmcli("connection", "delete", PHONE_CONNECTION, check=False) keyfile = f"""[connection] id={PHONE_CONNECTION} uuid={uuid.uuid4()} type=wifi interface-name={INTERFACE} autoconnect=true autoconnect-priority=20 [wifi] mode=infrastructure ssid={keyfile_escape(ssid)} mtu=1400 [wifi-security] key-mgmt=wpa-psk psk={keyfile_escape(password)} [ipv4] method=auto dhcp-send-hostname=true dhcp-hostname=yaris dhcp-client-id=yaris-obd # Use public DNS instead of the phone's DHCP-provided resolver — some # carrier/hotspot resolvers fail to resolve the server hostname (e.g. the # .dev Cloudflare tunnel), which broke uploads on the phone hotspot. dns=8.8.8.8;1.1.1.1; ignore-auto-dns=true [ipv6] method=auto """ temporary_path = None try: os.makedirs(os.path.dirname(PHONE_KEYFILE), mode=0o700, exist_ok=True) with tempfile.NamedTemporaryFile( "w", encoding="utf-8", dir=os.path.dirname(PHONE_KEYFILE), prefix=".phone-hotspot-", delete=False, ) as handle: temporary_path = handle.name os.fchmod(handle.fileno(), 0o600) handle.write(keyfile) handle.flush() os.fsync(handle.fileno()) os.replace(temporary_path, PHONE_KEYFILE) temporary_path = None run_nmcli("connection", "reload") if not connection_exists(PHONE_CONNECTION): raise UplinkError("NetworkManager did not load the phone hotspot.") except (OSError, UplinkError, subprocess.TimeoutExpired): if temporary_path is not None: try: os.remove(temporary_path) except FileNotFoundError: pass try: os.remove(PHONE_KEYFILE) except FileNotFoundError: pass run_nmcli("connection", "reload", check=False) run_nmcli("connection", "delete", PHONE_CONNECTION, check=False) if was_active: try: restore_fallback() except (OSError, subprocess.TimeoutExpired, UplinkError): pass raise write_status("phone_configured") if was_active: check_uplink() def remove(): was_active = active_connection() == PHONE_CONNECTION run_nmcli("connection", "delete", PHONE_CONNECTION, check=False) try: os.remove(PHONE_KEYFILE) except FileNotFoundError: pass if was_active: restore_fallback() else: write_status("phone_removed") def record_current_connection(): current = active_connection() actions = { PHONE_CONNECTION: "network_phone_connected", HOME_CONNECTION: "network_home_connected", AP_CONNECTION: "network_local_hotspot_active", } write_status(actions.get(current, "network_disconnected")) def main(): if len(sys.argv) != 2 or sys.argv[1] not in { "check", "configure", "connect", "record", "wait", "remove", }: print( "Usage: yaris-uplink {check|configure|connect|record|wait|remove}", file=sys.stderr, ) return 2 action = sys.argv[1] try: if action == "configure": configure() elif action == "remove": remove() elif action == "wait": wait_for_phone() elif action == "record": record_current_connection() else: check_uplink() except (OSError, subprocess.TimeoutExpired, UplinkError) as error: try: write_status(f"{action}_failed", str(error)) except OSError: pass print(str(error), file=sys.stderr) return 1 return 0 if __name__ == "__main__": raise SystemExit(main())