use std::net::{IpAddr, Ipv4Addr, UdpSocket};
use futures::stream::{self, StreamExt};
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 async fn discover(
clients: &[&dyn SmartDevice],
hosts: &[String],
concurrency: usize,
credentials: Option<&DeviceCredentials>,
) -> Vec<Discovered> {
let mut result: Vec<Discovered> = stream::iter(hosts.iter().cloned())
.map(|host| {
let credentials = credentials.cloned();
async move {
let target = DeviceTarget::new(host).with_credentials(credentials);
for client in clients {
if let Ok(Some(snapshot)) = client.probe(&target).await {
return Some(Discovered {
vendor: client.vendor(),
snapshot,
});
}
}
None
}
})
.buffer_unordered(concurrency.max(1))
.filter_map(|found| async move { found })
.collect()
.await;
result.sort_by(|a, b| a.snapshot.host.cmp(&b.snapshot.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);
#[async_trait::async_trait]
impl SmartDevice for FakeDevice {
fn vendor(&self) -> Vendor {
self.0
}
async 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)
}
}
async fn status(&self, _target: &DeviceTarget) -> Result<DeviceSnapshot> {
unimplemented!("FakeDevice does not support status")
}
async fn set_power(
&self,
_target: &DeviceTarget,
_channel: Option<u8>,
_action: PowerAction,
) -> Result<Relay> {
unimplemented!("FakeDevice does not support set_power")
}
async fn firmware_version(&self, _target: &DeviceTarget) -> Result<Option<String>> {
unimplemented!("FakeDevice does not support firmware_version")
}
async fn firmware_update(
&self,
_target: &DeviceTarget,
_ota_url: Option<&str>,
) -> Result<()> {
unimplemented!("FakeDevice does not support firmware_update")
}
async fn config_get(&self, _target: &DeviceTarget, _setting: &str) -> Result<Value> {
unimplemented!("FakeDevice does not support config_get")
}
async fn config_set(
&self,
_target: &DeviceTarget,
_setting: &str,
_value: &str,
) -> Result<Value> {
unimplemented!("FakeDevice does not support config_set")
}
async fn backup(&self, _target: &DeviceTarget) -> Result<Vec<u8>> {
unimplemented!("FakeDevice does not support backup")
}
async fn console(&self, _target: &DeviceTarget, _command: &str) -> Result<Value> {
unimplemented!("FakeDevice does not support console")
}
}
struct FakeErrDevice(Vendor);
#[async_trait::async_trait]
impl SmartDevice for FakeErrDevice {
fn vendor(&self) -> Vendor {
self.0
}
async fn probe(&self, target: &DeviceTarget) -> Result<Option<DeviceSnapshot>> {
Err(Error::Network {
host: target.host.clone(),
message: "unreachable".to_string(),
})
}
async fn status(&self, _target: &DeviceTarget) -> Result<DeviceSnapshot> {
unimplemented!("FakeErrDevice does not support status")
}
async fn set_power(
&self,
_target: &DeviceTarget,
_channel: Option<u8>,
_action: PowerAction,
) -> Result<Relay> {
unimplemented!("FakeErrDevice does not support set_power")
}
async fn firmware_version(&self, _target: &DeviceTarget) -> Result<Option<String>> {
unimplemented!("FakeErrDevice does not support firmware_version")
}
async fn firmware_update(
&self,
_target: &DeviceTarget,
_ota_url: Option<&str>,
) -> Result<()> {
unimplemented!("FakeErrDevice does not support firmware_update")
}
async fn config_get(&self, _target: &DeviceTarget, _setting: &str) -> Result<Value> {
unimplemented!("FakeErrDevice does not support config_get")
}
async fn config_set(
&self,
_target: &DeviceTarget,
_setting: &str,
_value: &str,
) -> Result<Value> {
unimplemented!("FakeErrDevice does not support config_set")
}
async fn backup(&self, _target: &DeviceTarget) -> Result<Vec<u8>> {
unimplemented!("FakeErrDevice does not support backup")
}
async fn console(&self, _target: &DeviceTarget, _command: &str) -> Result<Value> {
unimplemented!("FakeErrDevice does not support console")
}
}
#[tokio::test]
async 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).await;
assert_eq!(found.len(), 1);
assert_eq!(found[0].vendor, Vendor::Tasmota);
assert_eq!(found[0].snapshot.host, "192.0.2.5");
}
#[tokio::test]
async 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).await;
assert!(found.is_empty());
}
#[tokio::test]
async fn discover_treats_probe_err_like_no_match() {
let err_device = FakeErrDevice(Vendor::Shelly);
let fake = FakeDevice(Vendor::Tasmota);
let clients: [&dyn SmartDevice; 2] = [&err_device, &fake];
let hosts = vec!["192.0.2.5".to_string()];
let found = discover(&clients, &hosts, 4, None).await;
assert_eq!(found.len(), 1);
assert_eq!(found[0].vendor, Vendor::Tasmota);
assert_eq!(found[0].snapshot.host, "192.0.2.5");
}
}