use std::net::{IpAddr, Ipv4Addr, UdpSocket};
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::device::{Discovered, SmartDevice};
use crate::error::{Error, Result};
use crate::target::{DeviceCredentials, DeviceTarget};
pub fn hosts_in_cidr(cidr: &str) -> Result<Vec<String>> {
let (addr, prefix) = cidr.split_once('/').ok_or_else(|| Error::Parse {
host: String::new(),
message: format!("invalid CIDR `{cidr}` (expected e.g. 192.0.2.0/24)"),
})?;
let base: Ipv4Addr = addr.parse().map_err(|_| Error::Parse {
host: String::new(),
message: format!("invalid IPv4 address in CIDR `{cidr}`"),
})?;
let prefix: u32 = prefix.parse().map_err(|_| Error::Parse {
host: String::new(),
message: format!("invalid prefix length in CIDR `{cidr}`"),
})?;
if prefix > 32 {
return Err(Error::Parse {
host: String::new(),
message: format!("prefix /{prefix} out of range"),
});
}
if prefix < 16 {
return Err(Error::Parse {
host: String::new(),
message: format!("refusing to scan a range larger than /16 (got /{prefix})"),
});
}
let base_u32 = u32::from(base);
let host_bits = 32 - prefix;
let mask = !0u32 << host_bits;
let network = base_u32 & mask;
let count = 1u64 << host_bits;
let mut hosts = Vec::new();
if count <= 2 {
for i in 0..count {
hosts.push(Ipv4Addr::from(network + i as u32).to_string());
}
} else {
for i in 1..(count - 1) {
hosts.push(Ipv4Addr::from(network + i as u32).to_string());
}
}
Ok(hosts)
}
pub fn detect_local_cidr() -> Option<String> {
let sock = UdpSocket::bind("0.0.0.0:0").ok()?;
sock.connect("192.0.2.1:80").ok()?;
let ip = sock.local_addr().ok()?.ip();
match ip {
IpAddr::V4(v4) => {
let o = v4.octets();
Some(format!("{}.{}.{}.0/24", o[0], o[1], o[2]))
}
IpAddr::V6(_) => None,
}
}
pub fn discover(
clients: &[&dyn SmartDevice],
hosts: &[String],
concurrency: usize,
credentials: Option<&DeviceCredentials>,
) -> Vec<Discovered> {
let next = AtomicUsize::new(0);
let found: Mutex<Vec<Discovered>> = Mutex::new(Vec::new());
let workers = concurrency.max(1).min(hosts.len().max(1));
std::thread::scope(|scope| {
for _ in 0..workers {
scope.spawn(|| {
loop {
let idx = next.fetch_add(1, Ordering::Relaxed);
if idx >= hosts.len() {
break;
}
let host = &hosts[idx];
let target =
DeviceTarget::new(host.clone()).with_credentials(credentials.cloned());
for client in clients {
if let Ok(Some(snapshot)) = client.probe(&target) {
found.lock().unwrap().push(Discovered {
host: host.clone(),
vendor: client.vendor(),
snapshot,
});
break;
}
}
}
});
}
});
let mut result = found.into_inner().unwrap();
result.sort_by(|a, b| a.host.cmp(&b.host));
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{DeviceSnapshot, PowerAction, Relay};
use crate::target::Vendor;
use serde_json::Value;
#[test]
fn cidr_24_excludes_network_and_broadcast() {
let hosts = hosts_in_cidr("192.0.2.0/24").unwrap();
assert_eq!(hosts.len(), 254);
assert_eq!(hosts[0], "192.0.2.1");
assert_eq!(hosts[253], "192.0.2.254");
}
#[test]
fn cidr_rejects_too_large_and_malformed() {
assert!(hosts_in_cidr("192.0.2.0/8").is_err());
assert!(hosts_in_cidr("not-a-cidr").is_err());
assert!(hosts_in_cidr("192.0.2.0/33").is_err());
}
#[test]
fn cidr_32_is_single_host() {
let hosts = hosts_in_cidr("198.51.100.7/32").unwrap();
assert_eq!(hosts, vec!["198.51.100.7"]);
}
struct FakeDevice(Vendor);
impl SmartDevice for FakeDevice {
fn vendor(&self) -> Vendor {
self.0
}
fn probe(&self, target: &DeviceTarget) -> Result<Option<DeviceSnapshot>> {
if target.host == "192.0.2.5" {
Ok(Some(DeviceSnapshot {
host: target.host.clone(),
..Default::default()
}))
} else {
Ok(None)
}
}
fn status(&self, _target: &DeviceTarget) -> Result<DeviceSnapshot> {
unimplemented!("FakeDevice does not support status")
}
fn set_power(
&self,
_target: &DeviceTarget,
_channel: Option<u8>,
_action: PowerAction,
) -> Result<Relay> {
unimplemented!("FakeDevice does not support set_power")
}
fn firmware_version(&self, _target: &DeviceTarget) -> Result<Option<String>> {
unimplemented!("FakeDevice does not support firmware_version")
}
fn firmware_update(&self, _target: &DeviceTarget, _ota_url: Option<&str>) -> Result<()> {
unimplemented!("FakeDevice does not support firmware_update")
}
fn config_get(&self, _target: &DeviceTarget, _setting: &str) -> Result<Value> {
unimplemented!("FakeDevice does not support config_get")
}
fn config_set(
&self,
_target: &DeviceTarget,
_setting: &str,
_value: &str,
) -> Result<Value> {
unimplemented!("FakeDevice does not support config_set")
}
fn backup(&self, _target: &DeviceTarget) -> Result<Vec<u8>> {
unimplemented!("FakeDevice does not support backup")
}
fn console(&self, _target: &DeviceTarget, _command: &str) -> Result<Value> {
unimplemented!("FakeDevice does not support console")
}
}
#[test]
fn discover_classifies_only_the_confirmed_host() {
let fake = FakeDevice(Vendor::Tasmota);
let clients: [&dyn SmartDevice; 1] = [&fake];
let hosts = vec!["192.0.2.5".to_string(), "192.0.2.6".to_string()];
let found = discover(&clients, &hosts, 4, None);
assert_eq!(found.len(), 1);
assert_eq!(found[0].host, "192.0.2.5");
assert_eq!(found[0].vendor, Vendor::Tasmota);
assert_eq!(found[0].snapshot.host, "192.0.2.5");
}
#[test]
fn discover_returns_empty_when_no_client_confirms() {
let fake = FakeDevice(Vendor::Shelly);
let clients: [&dyn SmartDevice; 1] = [&fake];
let hosts = vec!["192.0.2.6".to_string(), "192.0.2.7".to_string()];
let found = discover(&clients, &hosts, 4, None);
assert!(found.is_empty());
}
}