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...
+
Known Networks:
@@ -43,4 +47,69 @@
-
\ No newline at end of file
+
+
+
+
+
Secondary WiFi (wlan1)
+
+
+
+
+
Checking WiFi configuration...
+
+ Network:
+
IP:
+
+
+
+
+
+
+
+
+
+
wlan1 interface not found.
+
+
+
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."""