diff --git a/src/reachy_mini/daemon/app/dashboard/static/js/wifi.js b/src/reachy_mini/daemon/app/dashboard/static/js/wifi.js index 3a620a408..b12cdd6f2 100644 --- a/src/reachy_mini/daemon/app/dashboard/static/js/wifi.js +++ b/src/reachy_mini/daemon/app/dashboard/static/js/wifi.js @@ -1,4 +1,3 @@ - const getStatus = async () => { return await fetch('/wifi/status') .then(response => response.json()) @@ -121,6 +120,9 @@ const handleStatus = (status) => { }); } + const connectedDiv = document.getElementById('wifi-connected'); + connectedDiv.classList.add('hidden'); + if (mode == 'hotspot') { statusDiv.innerText = 'Hotspot mode active. 🔌'; @@ -130,6 +132,9 @@ const handleStatus = (status) => { } statusDiv.innerText = `Connected to WiFi (SSID: ${status.connected_network}). 📶`; + document.getElementById('wifi-network').innerText = status.connected_network; + document.getElementById('wifi-ip').innerText = status.ip_address || ''; + connectedDiv.classList.remove('hidden'); } else if (mode == 'disconnected') { statusDiv.innerText = 'WiFi disconnected. ❌'; @@ -158,6 +163,9 @@ const cleanAndRefresh = async () => { const statusDiv = document.getElementById('wifi-status'); statusDiv.innerText = 'Checking WiFi configuration...'; + const connectedDiv = document.getElementById('wifi-connected'); + connectedDiv.classList.add('hidden'); + const knownNetworksDiv = document.getElementById('known-networks'); knownNetworksDiv.classList.add('hidden'); @@ -170,7 +178,193 @@ const cleanAndRefresh = async () => { addWifi.classList.remove('hidden'); }; +// --- Secondary WiFi (wlan1) — mirrors primary pattern --- + +const getStatus2 = async () => { + return await fetch('/wifi/status2') + .then(response => response.json()) + .catch(error => { + console.error('Error fetching secondary WiFi status:', error); + return { exists: false, connected: false, ssid: null, ip_address: null, known_networks: [], busy: false }; + }); +}; + +const refreshStatus2 = async () => { + const status = await getStatus2(); + handleStatus2(status); +}; + +const scanAndListWifiNetworks2 = async () => { + await fetch('/wifi/scan2', { method: 'POST' }) + .then(response => response.json()) + .then(data => { + const ssidSelect = document.getElementById('ssid2'); + ssidSelect.innerHTML = ''; + data.forEach(ssid => { + const option = document.createElement('option'); + option.value = ssid; + option.textContent = ssid; + ssidSelect.appendChild(option); + }); + }) + .catch(() => { + const ssidSelect = document.getElementById('ssid2'); + const option = document.createElement('option'); + option.value = ""; + option.textContent = "Unable to load networks"; + ssidSelect.appendChild(option); + }); +}; + +const connectToWifi2 = (_) => { + const ssid = document.getElementById('ssid2').value; + const password = document.getElementById('password2').value; + + if (!ssid) { + alert('Please enter an SSID.'); + return; + } + + fetch(`/wifi/connect2?ssid=${encodeURIComponent(ssid)}&password=${encodeURIComponent(password)}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + .then(response => { + if (!response.ok) { + return response.json().then(errData => { + throw new Error(errData.detail || 'Failed to connect to WiFi'); + }); + } + + // Clear the form fields + document.getElementById('ssid2').value = ''; + document.getElementById('password2').value = ''; + + return response.json(); + }) + .then(data => { + handleStatus2({ exists: true, connected: false, ssid: null, ip_address: null, known_networks: [], busy: true }); + }) + .catch(error => { + console.error('Error connecting to secondary WiFi:', error); + alert(`Error connecting to WiFi: ${error.message}`); + }); + return false; // Prevent form submission +}; + +const handleStatus2 = (status) => { + const statusDiv = document.getElementById('wifi2-status'); + const connectedDiv = document.getElementById('wifi2-connected'); + const noAdapterDiv = document.getElementById('wifi2-no-adapter'); + + const knownNetworksDiv = document.getElementById('known-networks2'); + const knownNetworksList = document.getElementById('known-networks-list2'); + + connectedDiv.classList.add('hidden'); + noAdapterDiv.classList.add('hidden'); + + // Known networks — same rendering as primary + knownNetworksList.innerHTML = ''; + if (status.known_networks !== undefined && Array.isArray(status.known_networks) && status.known_networks.length > 0) { + knownNetworksDiv.classList.remove('hidden'); + status.known_networks.forEach((network) => { + const li = document.createElement('li'); + li.classList = 'flex flex-row items-center mb-1 gap-4 justify-left'; + + const nameSpan = document.createElement('span'); + nameSpan.innerText = network; + li.appendChild(nameSpan); + + knownNetworksList.appendChild(li); + }); + } else { + knownNetworksDiv.classList.add('hidden'); + } + + if (status.busy) { + statusDiv.innerText = 'Changing your WiFi configuration... Please wait ⏳'; + return; + } + + if (!status.exists) { + statusDiv.innerText = 'WiFi adapter not available. ❌'; + document.getElementById('add-wifi2').classList.add('hidden'); + noAdapterDiv.classList.remove('hidden'); + return; + } + + if (status.connected) { + statusDiv.innerText = `Connected to WiFi (SSID: ${status.ssid}). 📶`; + document.getElementById('wifi2-network').innerText = status.ssid; + document.getElementById('wifi2-ip').innerText = status.ip_address || ''; + connectedDiv.classList.remove('hidden'); + } else { + statusDiv.innerText = 'WiFi disconnected. ❌'; + } + + document.getElementById('add-wifi2').classList.remove('hidden'); +}; + +const disconnectWifi2 = () => { + fetch('/wifi/disconnect2', { method: 'POST' }) + .then(response => { + if (!response.ok) { + return response.json().then(errData => { + throw new Error(errData.detail || 'Failed to disconnect'); + }); + } + handleStatus2({ exists: true, connected: false, ssid: null, ip_address: null, known_networks: [], busy: true }); + }) + .catch(error => { + console.error('Error disconnecting secondary WiFi:', error); + alert(`Error disconnecting: ${error.message}`); + }); +}; + +const createWlan1 = () => { + fetch('/wifi/create_interface', { method: 'POST' }) + .then(response => { + if (!response.ok) { + return response.json().then(errData => { + throw new Error(errData.detail || 'Failed to create interface'); + }); + } + cleanAndRefresh2(); + }) + .catch(error => { + console.error('Error creating wlan1:', error); + alert(`Error creating interface: ${error.message}`); + }); +}; + +const cleanAndRefresh2 = async () => { + const statusDiv = document.getElementById('wifi2-status'); + statusDiv.innerText = 'Checking WiFi configuration...'; + + const knownNetworksDiv = document.getElementById('known-networks2'); + knownNetworksDiv.classList.add('hidden'); + + const connectedDiv = document.getElementById('wifi2-connected'); + connectedDiv.classList.add('hidden'); + + const addWifi = document.getElementById('add-wifi2'); + addWifi.classList.add('hidden'); + + await scanAndListWifiNetworks2(); + await refreshStatus2(); + + addWifi.classList.remove('hidden'); +}; + +// --- Initialization --- + window.addEventListener('load', async () => { await cleanAndRefresh(); - setInterval(refreshStatus, 1000); -}); \ No newline at end of file + await cleanAndRefresh2(); + setInterval(() => { + refreshStatus(); + refreshStatus2(); + }, 1000); +}); diff --git a/src/reachy_mini/daemon/app/dashboard/templates/sections/wifi.html b/src/reachy_mini/daemon/app/dashboard/templates/sections/wifi.html index 9d55b418e..7c8f691be 100644 --- a/src/reachy_mini/daemon/app/dashboard/templates/sections/wifi.html +++ b/src/reachy_mini/daemon/app/dashboard/templates/sections/wifi.html @@ -9,6 +9,10 @@
Checking WiFi configuration...
+
- \ No newline at end of file + + +
+
+
Secondary WiFi (wlan1)
+ +
+ +
+
Checking WiFi configuration...
+ + +
+ + + +
diff --git a/src/reachy_mini/daemon/app/routers/wifi_config.py b/src/reachy_mini/daemon/app/routers/wifi_config.py index a4d96cf4b..4e0cd0370 100644 --- a/src/reachy_mini/daemon/app/routers/wifi_config.py +++ b/src/reachy_mini/daemon/app/routers/wifi_config.py @@ -1,6 +1,7 @@ """WiFi Configuration Routers.""" import logging +import subprocess from enum import Enum from threading import Lock, Thread @@ -10,6 +11,7 @@ HOTSPOT_SSID = "reachy-mini-ap" HOTSPOT_PASSWORD = "reachy-mini" +NMCLI_COMMAND_TIMEOUT = 10 # Timeout in seconds for nmcli/iw commands router = APIRouter( @@ -17,6 +19,7 @@ ) busy_lock = Lock() +busy_lock2 = Lock() error: Exception | None = None logger = logging.getLogger(__name__) @@ -36,6 +39,37 @@ class WifiStatus(BaseModel): mode: WifiMode known_networks: list[str] connected_network: str | None + ip_address: str | None + + +class SecondaryWifiStatus(BaseModel): + """Secondary WiFi adapter (wlan1) status.""" + + exists: bool + connected: bool + ssid: str | None + ip_address: str | None + known_networks: list[str] + busy: bool + + +def _get_iface_ip(iface: str) -> str | None: + """Get IP address of a network interface.""" + try: + result = subprocess.run( + ["ip", "-4", "addr", "show", iface], + capture_output=True, text=True, + timeout=NMCLI_COMMAND_TIMEOUT, + ) + if result.returncode != 0: + return None + for line in result.stdout.splitlines(): + line = line.strip() + if line.startswith("inet "): + return line.split()[1].split("/")[0] + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + logger.error(f"Failed to get IP for {iface}: {e}") + return None def get_current_wifi_mode() -> WifiMode: @@ -46,7 +80,7 @@ def get_current_wifi_mode() -> WifiMode: conn = get_wifi_connections() if check_if_connection_active("Hotspot"): return WifiMode.HOTSPOT - elif any(c.device != "--" for c in conn): + elif any(c.device == "wlan0" for c in conn): return WifiMode.WLAN else: return WifiMode.DISCONNECTED @@ -58,14 +92,17 @@ def get_wifi_status() -> WifiStatus: mode = get_current_wifi_mode() connections = get_wifi_connections() - known_networks = [c.name for c in connections if c.name != "Hotspot"] + # Filter to wlan0 connections only (exclude wlan1 secondary adapter) + known_networks = [c.name for c in connections if c.name != "Hotspot" and not c.name.endswith("-wlan1")] - connected_network = next((c.name for c in connections if c.device != "--"), None) + connected_network = next((c.name for c in connections if c.device == "wlan0"), None) + ip_address = _get_iface_ip("wlan0") if connected_network else None return WifiStatus( mode=mode, known_networks=known_networks, connected_network=connected_network, + ip_address=ip_address, ) @@ -224,6 +261,280 @@ def forget_all() -> None: Thread(target=forget_all).start() +# --- Secondary WiFi (wlan1) endpoints --- + + +def _wlan1_exists() -> bool: + """Check if wlan1 interface exists.""" + try: + result = subprocess.run( + ["ip", "link", "show", "wlan1"], + capture_output=True, text=True, + timeout=NMCLI_COMMAND_TIMEOUT, + ) + return result.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + logger.error(f"Failed to check wlan1 existence: {e}") + return False + + +def _get_wlan1_ip() -> str | None: + """Get IP address of wlan1.""" + return _get_iface_ip("wlan1") + + +def _get_wlan1_active_connection() -> str | None: + """Get the active NM connection name on wlan1.""" + try: + result = subprocess.run( + ["nmcli", "-t", "-f", "NAME,DEVICE", "con", "show", "--active"], + capture_output=True, text=True, + timeout=NMCLI_COMMAND_TIMEOUT, + ) + if result.returncode != 0: + return None + for line in result.stdout.splitlines(): + parts = line.split(":") + if len(parts) >= 2 and parts[-1] == "wlan1": + return parts[0] + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + logger.error(f"Failed to get wlan1 active connection: {e}") + return None + + +def _get_wlan1_ssid() -> str | None: + """Get the SSID that wlan1 is connected to.""" + try: + result = subprocess.run( + ["nmcli", "-t", "-f", "DEVICE,ACTIVE,SSID", "dev", "wifi"], + capture_output=True, text=True, + timeout=NMCLI_COMMAND_TIMEOUT, + ) + if result.returncode != 0: + return None + for line in result.stdout.splitlines(): + parts = line.split(":") + if len(parts) >= 3 and parts[0] == "wlan1" and parts[1] == "yes": + return parts[2] + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + logger.error(f"Failed to get wlan1 SSID: {e}") + return None + + +def _get_wlan1_known_networks() -> list[str]: + """Get saved NM connection profiles bound to wlan1.""" + try: + result = subprocess.run( + ["nmcli", "-t", "-f", "NAME,TYPE,DEVICE", "con", "show"], + capture_output=True, text=True, + timeout=NMCLI_COMMAND_TIMEOUT, + ) + if result.returncode != 0: + return [] + networks = [] + for line in result.stdout.splitlines(): + parts = line.split(":") + if len(parts) >= 3 and parts[1] == "802-11-wireless" and parts[2] == "wlan1": + networks.append(parts[0]) + return networks + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + logger.error(f"Failed to get wlan1 known networks: {e}") + return [] + + +def _ensure_wlan1() -> bool: + """Create wlan1 virtual interface if it doesn't exist. Returns True if exists/created.""" + if _wlan1_exists(): + return True + logger.info("Creating wlan1 virtual interface...") + try: + subprocess.run( + ["sudo", "iw", "dev", "wlan0", "interface", "add", "wlan1", "type", "managed"], + capture_output=True, text=True, + timeout=NMCLI_COMMAND_TIMEOUT, + check=True, + ) + subprocess.run( + ["sudo", "ip", "link", "set", "wlan1", "up"], + capture_output=True, text=True, + timeout=NMCLI_COMMAND_TIMEOUT, + ) + return True + except subprocess.CalledProcessError as e: + logger.error(f"Failed to create wlan1: {e.stderr}") + return False + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + logger.error(f"Failed to create wlan1: {e}") + return False + + +@router.get("/status2") +def get_secondary_wifi_status() -> SecondaryWifiStatus: + """Get secondary WiFi adapter (wlan1) status.""" + if not _wlan1_exists(): + return SecondaryWifiStatus( + exists=False, connected=False, ssid=None, ip_address=None, + known_networks=[], busy=busy_lock2.locked(), + ) + + ssid = _get_wlan1_ssid() + ip_addr = _get_wlan1_ip() + connected = ssid is not None and ip_addr is not None + known = _get_wlan1_known_networks() + + return SecondaryWifiStatus( + exists=True, + connected=connected, + ssid=ssid, + ip_address=ip_addr, + known_networks=known, + busy=busy_lock2.locked(), + ) + + +@router.post("/scan2") +def scan_secondary_wifi() -> list[str]: + """Scan for networks visible from wlan1.""" + if not _wlan1_exists(): + raise HTTPException(status_code=404, detail="wlan1 interface does not exist.") + + try: + subprocess.run( + ["nmcli", "dev", "wifi", "rescan", "ifname", "wlan1"], + capture_output=True, text=True, + timeout=NMCLI_COMMAND_TIMEOUT, + ) + result = subprocess.run( + ["nmcli", "-t", "-f", "SSID,SIGNAL", "dev", "wifi", "list", "ifname", "wlan1"], + capture_output=True, text=True, + timeout=NMCLI_COMMAND_TIMEOUT, + ) + if result.returncode != 0: + return [] + + seen = set() + ssids = [] + for line in result.stdout.splitlines(): + parts = line.rsplit(":", 1) + ssid = parts[0].strip() + if ssid and ssid not in seen: + seen.add(ssid) + ssids.append(ssid) + return ssids + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + logger.error(f"Failed to scan wlan1 networks: {e}") + return [] + + +@router.post("/connect2") +def connect_secondary_wifi(ssid: str, password: str) -> None: + """Connect wlan1 to a network.""" + if busy_lock2.locked(): + raise HTTPException(status_code=409, detail="Another operation is in progress on wlan1.") + + def connect() -> None: + with busy_lock2: + try: + if not _ensure_wlan1(): + logger.error("Cannot create wlan1 interface") + return + + con_name = f"{ssid}-wlan1" + + # Check if connection profile already exists + check = subprocess.run( + ["nmcli", "-t", "-f", "NAME", "con", "show"], + capture_output=True, text=True, + timeout=NMCLI_COMMAND_TIMEOUT, + ) + profile_exists = con_name in check.stdout.splitlines() + + if profile_exists: + # Update password and bring up + subprocess.run( + ["nmcli", "con", "modify", con_name, + "wifi-sec.key-mgmt", "wpa-psk", + "wifi-sec.psk", password], + capture_output=True, text=True, + timeout=NMCLI_COMMAND_TIMEOUT, + ) + result = subprocess.run( + ["nmcli", "con", "up", con_name], + capture_output=True, text=True, + timeout=NMCLI_COMMAND_TIMEOUT, + ) + else: + # Create new connection profile + result = subprocess.run( + ["nmcli", "con", "add", + "type", "wifi", + "ifname", "wlan1", + "con-name", con_name, + "ssid", ssid, + "wifi-sec.key-mgmt", "wpa-psk", + "wifi-sec.psk", password], + capture_output=True, text=True, + timeout=NMCLI_COMMAND_TIMEOUT, + ) + if result.returncode != 0: + logger.error(f"Failed to create connection for {ssid}: {result.stderr}") + return + + result = subprocess.run( + ["nmcli", "con", "up", con_name], + capture_output=True, text=True, + timeout=NMCLI_COMMAND_TIMEOUT, + ) + + if result.returncode != 0: + logger.error(f"Failed to connect wlan1 to {ssid}: {result.stderr}") + else: + logger.info(f"wlan1 connected to {ssid}") + + except Exception as e: + logger.error(f"Error connecting wlan1 to {ssid}: {e}") + + Thread(target=connect).start() + + +@router.post("/disconnect2") +def disconnect_secondary_wifi() -> None: + """Disconnect wlan1.""" + if busy_lock2.locked(): + raise HTTPException(status_code=409, detail="Another operation is in progress on wlan1.") + + con_name = _get_wlan1_active_connection() + if not con_name: + raise HTTPException(status_code=400, detail="wlan1 has no active connection.") + + def disconnect() -> None: + with busy_lock2: + try: + subprocess.run( + ["nmcli", "con", "down", con_name], + capture_output=True, text=True, + timeout=NMCLI_COMMAND_TIMEOUT, + check=True, + ) + logger.info(f"wlan1 disconnected from {con_name}") + except subprocess.CalledProcessError as e: + logger.error(f"Failed to disconnect wlan1: {e.stderr}") + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + logger.error(f"Error disconnecting wlan1: {e}") + + Thread(target=disconnect).start() + + +@router.post("/create_interface") +def create_secondary_interface() -> dict[str, str]: + """Create wlan1 virtual interface if missing.""" + if _wlan1_exists(): + return {"status": "already_exists"} + if _ensure_wlan1(): + return {"status": "created"} + raise HTTPException(status_code=500, detail="Failed to create wlan1 interface.") + + # NMCLI WRAPPERS def scan_available_wifi() -> list[nmcli.data.device.DeviceWifi]: """Scan for available WiFi networks."""