switchkit 0.2.0

Vendor-neutral abstraction for smart-plug devices (Shelly, Tasmota).
Documentation
//! Discover devices by probing a range of hosts against known vendor clients.
//!
//! No addresses are hardcoded: the range is taken from a CIDR (`hosts_in_cidr`)
//! or derived from the host's own primary IPv4 (`detect_local_cidr`, assuming a
//! `/24`). A host is classified to a vendor only when that vendor's client
//! confirms it via [`crate::device::SmartDevice::probe`].

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};

/// Expand an IPv4 CIDR (e.g. `192.0.2.0/24`) into candidate host addresses,
/// excluding the network and broadcast addresses. Bounded to `/16` to avoid
/// accidentally enumerating millions of hosts. A `/32` yields the single host.
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;
    // host_bits is 0..=16 (prefix is bounded to 16..=32 above), so this never
    // overflows. For /32 this yields an all-ones mask, keeping the single host.
    let mask = !0u32 << host_bits;
    let network = base_u32 & mask;
    let count = 1u64 << host_bits;

    let mut hosts = Vec::new();
    if count <= 2 {
        // /31 and /32: no network/broadcast convention, use all addresses.
        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)
}

/// Best-effort detection of the host's primary IPv4, used to derive a default
/// `/24` scan range. Uses the UDP-connect trick (no packets are sent) against a
/// documentation address so nothing real is contacted.
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,
    }
}

/// Probe every host, concurrently, against every client and return the hosts that
/// were confirmed by a vendor.
///
/// For each host, clients are tried in order; the first `Ok(Some(snapshot))`
/// classifies the host and stops trying further clients for it. `Ok(None)` and
/// `Err` are treated identically: "not this vendor, try the next client". A host
/// that no client confirms is not returned - a vendor is never guessed.
/// `concurrency` caps the number of in-flight probes.
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"]);
    }

    /// A trivial in-test `SmartDevice` that only confirms one specific host.
    /// Every other method is unreachable in this test and either panics or
    /// returns an error if the test ever exercises it by mistake.
    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")
        }
    }

    /// A `SmartDevice` whose `probe` always fails, used to prove `discover` treats
    /// a probe `Err` the same as a reachable non-match: skip to the next client,
    /// never surface the error or misclassify the host.
    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() {
        // `&dyn SmartDevice` compiling here proves the trait is object-safe.
        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() {
        // The first client errors on every probe; the second confirms. The
        // error must be skipped, not surfaced, and must not block classification.
        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");
    }
}