switchkit 0.2.0

Vendor-neutral abstraction for smart-plug devices (Shelly, Tasmota).
Documentation
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PowerAction {
    On,
    Off,
    Toggle,
}

impl PowerAction {
    pub fn as_str(&self) -> &'static str {
        match self {
            PowerAction::On => "on",
            PowerAction::Off => "off",
            PowerAction::Toggle => "toggle",
        }
    }
}

/// A relay's on/off state. A value a core could NOT confidently map is `Unknown`, with the
/// raw text preserved - never guessed off.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum RelayState {
    On,
    Off,
    Unknown(String),
}

impl RelayState {
    pub fn as_str(&self) -> &str {
        match self {
            RelayState::On => "on",
            RelayState::Off => "off",
            RelayState::Unknown(raw) => raw,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Relay {
    pub index: u8,
    pub state: RelayState,
    pub raw: String,
}

/// Energy metering. Present only on metering models; each reading independently optional.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Energy {
    pub power_w: Option<f64>,
    pub today_kwh: Option<f64>,
    pub total_kwh: Option<f64>,
    pub voltage_v: Option<f64>,
    pub current_a: Option<f64>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Firmware {
    pub version: Option<String>,
    pub update_available: Option<bool>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct NetInfo {
    pub ip: Option<String>,
    pub mac: Option<String>,
    pub hostname: Option<String>,
}

/// Wi-Fi signal, normalized across vendors. `quality_percent` (0-100) is what the UI shows;
/// `rssi_dbm` carries the raw dBm when the vendor reports it. A vendor that provides neither
/// leaves both `None`. NEVER fabricate one from the other with a made-up formula: only set
/// `quality_percent` from a real percentage, and `rssi_dbm` from a real dBm value.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Signal {
    pub quality_percent: Option<u8>,
    pub rssi_dbm: Option<i64>,
}

impl Signal {
    /// From a Tasmota-style 0-100 quality percentage (clamped to 0..=100).
    pub fn from_quality_percent(pct: i64) -> Self {
        Signal {
            quality_percent: Some(pct.clamp(0, 100) as u8),
            rssi_dbm: None,
        }
    }
    /// From a Shelly-style dBm value. Does NOT invent a percentage - `quality_percent` stays
    /// `None` unless the vendor also reports a real percentage.
    pub fn from_dbm(dbm: i64) -> Self {
        Signal {
            quality_percent: None,
            rssi_dbm: Some(dbm),
        }
    }
}

/// What a specific device supports, derived per-device by the core from the device's own
/// response - never a vendor-wide constant (a metering plug and a non-metering relay of the
/// same vendor differ). Drives which controls the UI shows.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Capabilities {
    pub metering: bool,
    pub multi_channel: bool,
    pub firmware_ota: bool,
    pub config_backup: bool,
    /// A raw command / RPC surface for the admin panel (Tasmota console, Shelly RPC).
    pub console: bool,
}

/// A SUCCESSFUL reading of one device. There is no "offline snapshot": offline is the
/// consumer's `Result::Err` from `SmartDevice::status()`. Every telemetry field is
/// `Option`/`Vec` so absence is first-class and never a fabricated `0`.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct DeviceSnapshot {
    pub host: String,
    pub name: Option<String>,
    pub model: Option<String>,
    pub generation: Option<String>,
    pub capabilities: Capabilities,
    pub relays: Vec<Relay>,
    pub energy: Option<Energy>,
    pub signal: Option<Signal>,
    pub temperature_c: Option<f64>,
    pub firmware: Option<Firmware>,
    pub net: NetInfo,
    pub uptime: Option<String>,
}

impl DeviceSnapshot {
    /// Display name: `name` if present and non-empty, else the host.
    pub fn display_name(&self) -> &str {
        match &self.name {
            Some(n) if !n.is_empty() => n,
            _ => &self.host,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn relay_state_unknown_preserves_raw_text() {
        let state = RelayState::Unknown("x".to_string());

        assert_eq!(state.as_str(), "x");
    }

    #[test]
    fn signal_from_quality_percent_clamps_and_leaves_dbm_none() {
        let signal = Signal::from_quality_percent(150);

        assert_eq!(signal.quality_percent, Some(100));
        assert_eq!(signal.rssi_dbm, None);
    }

    #[test]
    fn signal_from_dbm_leaves_quality_percent_none() {
        let signal = Signal::from_dbm(-55);

        assert_eq!(signal.rssi_dbm, Some(-55));
        assert_eq!(signal.quality_percent, None);
    }

    #[test]
    fn energy_default_is_all_none() {
        let energy = Energy::default();

        assert_eq!(energy.power_w, None);
        assert_eq!(energy.today_kwh, None);
        assert_eq!(energy.total_kwh, None);
        assert_eq!(energy.voltage_v, None);
        assert_eq!(energy.current_a, None);
    }

    #[test]
    fn device_snapshot_display_name_falls_back_to_host() {
        let snapshot = DeviceSnapshot {
            host: "192.0.2.10".to_string(),
            ..Default::default()
        };

        assert_eq!(snapshot.display_name(), "192.0.2.10");
    }

    #[test]
    fn device_snapshot_display_name_prefers_name() {
        let snapshot = DeviceSnapshot {
            host: "192.0.2.10".to_string(),
            name: Some("Freezer".to_string()),
            ..Default::default()
        };

        assert_eq!(snapshot.display_name(), "Freezer");
    }

    #[test]
    fn device_snapshot_default_telemetry_is_absent() {
        let snapshot = DeviceSnapshot::default();

        assert!(snapshot.energy.is_none());
        assert!(snapshot.signal.is_none());
        assert!(snapshot.temperature_c.is_none());
        assert!(snapshot.firmware.is_none());
        assert!(snapshot.uptime.is_none());
        assert!(snapshot.name.is_none());
        assert!(snapshot.model.is_none());
        assert!(snapshot.generation.is_none());
        assert!(snapshot.relays.is_empty());
        assert_eq!(snapshot.capabilities, Capabilities::default());
        assert_eq!(snapshot.net, NetInfo::default());
    }
}