2026-06-29 15:11:48 +00:00
|
|
|
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<Vec<ScannedNetwork>> {
|
|
|
|
|
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<String> {
|
2026-06-29 16:21:27 +00:00
|
|
|
// Fast path: interface already up (radio was previously enabled)
|
2026-06-29 16:08:35 +00:00
|
|
|
let (out, _) = router.run("iw dev 2>/dev/null | awk '/Interface/{print $2}' | head -1")?;
|
2026-06-29 16:21:27 +00:00
|
|
|
if !out.trim().is_empty() {
|
|
|
|
|
return Ok(out.trim().to_string());
|
2026-06-29 15:11:48 +00:00
|
|
|
}
|
2026-06-29 16:21:27 +00:00
|
|
|
|
|
|
|
|
// 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");
|
2026-06-29 15:11:48 +00:00
|
|
|
}
|
2026-06-29 16:21:27 +00:00
|
|
|
|
|
|
|
|
// 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")
|
2026-06-29 15:11:48 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_iwinfo_scan(output: &str) -> Result<Vec<ScannedNetwork>> {
|
|
|
|
|
let mut networks: Vec<ScannedNetwork> = Vec::new();
|
|
|
|
|
let mut current: Option<ScannedNetwork> = 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
|
|
|
|
|
}
|
|
|
|
|
}
|