use anyhow::Result; use crate::Router; pub struct ScannedNetwork { pub ssid: String, pub bssid: String, pub signal: i32, pub channel: u8, pub encryption: String, } pub fn scan_networks(router: &Router) -> Result> { let iface = find_wireless_iface(router)?; let output = router.run_ok(&format!("iwinfo {} scan 2>&1", iface))?; if output.contains("No scan results") || output.trim().is_empty() { return Ok(vec![]); } parse_iwinfo_scan(&output) } fn find_wireless_iface(router: &Router) -> Result { // Fast path: interface already up (radio was previously enabled) let (out, _) = router.run("iw dev 2>/dev/null | awk '/Interface/{print $2}' | head -1")?; if !out.trim().is_empty() { return Ok(out.trim().to_string()); } // No interface yet — common on a freshly-flashed OpenWrt where radio0 is // disabled by default. Verify the radio PHY exists at all. let (phy_out, _) = router.run("ls /sys/class/ieee80211/ 2>/dev/null | head -1")?; if phy_out.trim().is_empty() { anyhow::bail!("No wireless radio found on this router"); } // Enable radio0 and bring wifi up so netifd creates the virtual interface. tracing::info!("[{}] Radio present but no interface — enabling radio0 and running wifi up", router.host); router.run_ok("uci set wireless.radio0.disabled=0 && uci commit wireless && wifi up 2>&1")?; // Wait up to 8s for netifd to create the interface (it's asynchronous) for _ in 0..8 { std::thread::sleep(std::time::Duration::from_secs(1)); let (out2, _) = router.run("iw dev 2>/dev/null | awk '/Interface/{print $2}' | head -1")?; if !out2.trim().is_empty() { return Ok(out2.trim().to_string()); } } anyhow::bail!("WiFi radio enabled but no interface appeared — check OpenWrt wireless config") } fn parse_iwinfo_scan(output: &str) -> Result> { let mut networks: Vec = Vec::new(); let mut current: Option = None; for line in output.lines() { let line = line.trim(); if line.starts_with("Cell ") { if let Some(n) = current.take() { if !n.ssid.is_empty() { networks.push(n); } } let bssid = line.split("Address:").nth(1).unwrap_or("").trim().to_string(); current = Some(ScannedNetwork { ssid: String::new(), bssid, signal: -100, channel: 0, encryption: "none".to_string(), }); } else if let Some(ref mut n) = current { if let Some(rest) = line.strip_prefix("ESSID:") { n.ssid = rest.trim().trim_matches('"').to_string(); } else if line.contains("Channel:") && !line.starts_with("Encryption") { if let Some(ch_part) = line.split("Channel:").nth(1) { n.channel = ch_part.trim().split_whitespace().next() .and_then(|s| s.parse().ok()) .unwrap_or(0); } } else if line.starts_with("Signal:") { if let Some(dbm_str) = line.split_whitespace().nth(1) { n.signal = dbm_str.parse().unwrap_or(-100); } } else if let Some(rest) = line.strip_prefix("Encryption:") { n.encryption = normalize_encryption(rest.trim()); } } } if let Some(n) = current { if !n.ssid.is_empty() { networks.push(n); } } networks.sort_by(|a, b| b.signal.cmp(&a.signal)); Ok(networks) } fn normalize_encryption(raw: &str) -> String { let lower = raw.to_lowercase(); if lower.contains("wpa3") || lower.contains("sae") { "sae".to_string() } else if lower.contains("wpa2") || lower.contains("psk2") { "psk2".to_string() } else if lower.contains("wpa") { "psk".to_string() } else if lower.contains("none") || lower.contains("open") || lower.is_empty() { "none".to_string() } else { lower } }