shelly-core 0.2.2

Core Shelly device API and model types (Gen1/Gen2/Gen3 HTTP + RPC clients)
Documentation
use serde::{Deserialize, Serialize};
use std::fmt;
use std::net::IpAddr;

use super::gen1_responses::Gen1ShellyResponse;
use super::gen2_responses::Gen2ShellyResponse;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DeviceGeneration {
    Gen1,
    Gen2,
    Gen3,
}

impl fmt::Display for DeviceGeneration {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Gen1 => write!(f, "Gen1"),
            Self::Gen2 => write!(f, "Gen2"),
            Self::Gen3 => write!(f, "Gen3"),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceInfo {
    pub ip: IpAddr,
    pub name: Option<String>,
    pub id: String,
    pub mac: String,
    pub model: String,
    pub generation: DeviceGeneration,
    pub firmware_version: String,
    pub auth_enabled: bool,
    pub num_outputs: u8,
    pub num_meters: u8,
    pub app: Option<String>,
    pub device_type: Option<String>,
}

impl DeviceInfo {
    pub fn display_name(&self) -> &str {
        self.name.as_deref().unwrap_or(&self.id)
    }

    pub fn from_gen1_shelly(ip: IpAddr, shelly: &serde_json::Value) -> Option<Self> {
        let resp: Gen1ShellyResponse = serde_json::from_value(shelly.clone()).ok()?;

        let hostname = format!(
            "shelly{}-{}",
            resp.device_type.to_lowercase().replace("shsw-", ""),
            resp.mac
        );

        Some(Self {
            ip,
            name: None,
            id: hostname,
            mac: resp.mac,
            model: resp.device_type.clone(),
            generation: DeviceGeneration::Gen1,
            firmware_version: resp.fw,
            auth_enabled: resp.auth,
            num_outputs: resp.num_outputs,
            num_meters: resp.num_meters,
            app: None,
            device_type: Some(resp.device_type),
        })
    }

    pub fn from_gen2_shelly(ip: IpAddr, shelly: &serde_json::Value) -> Option<Self> {
        let resp: Gen2ShellyResponse = serde_json::from_value(shelly.clone()).ok()?;

        let generation = if resp.generation >= 3 {
            DeviceGeneration::Gen3
        } else {
            DeviceGeneration::Gen2
        };

        Some(Self {
            ip,
            name: resp.name,
            id: resp.id,
            mac: resp.mac,
            model: resp.model,
            generation,
            firmware_version: resp.ver,
            auth_enabled: resp.auth_en,
            num_outputs: 1,
            num_meters: 1,
            app: resp.app,
            device_type: None,
        })
    }

    pub fn from_shelly_response(ip: IpAddr, shelly: &serde_json::Value) -> Option<Self> {
        if shelly.get("gen").is_some() {
            Self::from_gen2_shelly(ip, shelly)
        } else if shelly.get("type").is_some() {
            Self::from_gen1_shelly(ip, shelly)
        } else {
            None
        }
    }
}

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

    fn ip() -> IpAddr {
        "192.0.2.1".parse().unwrap()
    }

    #[test]
    fn gen1_device_from_shelly_response() {
        let shelly = json!({
            "type": "SHSW-PM",
            "mac": "AABBCCDDEEFF",
            "auth": false,
            "fw": "20230913-114003/v1.14.0-gcb84623",
            "num_outputs": 1,
            "num_meters": 1
        });

        let device = DeviceInfo::from_shelly_response(ip(), &shelly).unwrap();
        assert_eq!(device.generation, DeviceGeneration::Gen1);
        assert_eq!(device.model, "SHSW-PM");
        assert_eq!(device.mac, "AABBCCDDEEFF");
        assert_eq!(device.firmware_version, "20230913-114003/v1.14.0-gcb84623");
        assert!(!device.auth_enabled);
        assert_eq!(device.num_outputs, 1);
        assert_eq!(device.num_meters, 1);
        assert_eq!(device.id, "shellypm-AABBCCDDEEFF");
        assert!(device.name.is_none());
        assert_eq!(device.device_type.as_deref(), Some("SHSW-PM"));
        assert!(device.app.is_none());
    }

    #[test]
    fn gen2_device_from_shelly_response() {
        let shelly = json!({
            "id": "shellyplus1pm-aabbccddeeff",
            "mac": "AABBCCDDEEFF",
            "model": "SNSW-001P16EU",
            "gen": 2,
            "fw_id": "20230913-114003",
            "ver": "1.0.0",
            "app": "Plus1PM",
            "auth_en": false,
            "name": "Living Room"
        });

        let device = DeviceInfo::from_shelly_response(ip(), &shelly).unwrap();
        assert_eq!(device.generation, DeviceGeneration::Gen2);
        assert_eq!(device.model, "SNSW-001P16EU");
        assert_eq!(device.mac, "AABBCCDDEEFF");
        assert_eq!(device.id, "shellyplus1pm-aabbccddeeff");
        assert_eq!(device.name.as_deref(), Some("Living Room"));
        assert_eq!(device.app.as_deref(), Some("Plus1PM"));
        assert!(!device.auth_enabled);
        assert_eq!(device.firmware_version, "1.0.0");
    }

    #[test]
    fn gen3_device_from_shelly_response() {
        let shelly = json!({
            "id": "shelly1minig3-aabbccddeeff",
            "mac": "AABBCCDDEEFF",
            "model": "S3SW-001X8EU",
            "gen": 3,
            "fw_id": "20240101-000000",
            "ver": "2.0.0",
            "app": "Mini1G3",
            "auth_en": true,
            "name": null
        });

        let device = DeviceInfo::from_shelly_response(ip(), &shelly).unwrap();
        assert_eq!(device.generation, DeviceGeneration::Gen3);
        assert_eq!(device.model, "S3SW-001X8EU");
        assert!(device.auth_enabled);
        assert!(device.name.is_none());
        assert_eq!(device.app.as_deref(), Some("Mini1G3"));
    }

    #[test]
    fn missing_optional_fields_gen1() {
        let shelly = json!({
            "type": "SHSW-1",
            "mac": "112233445566"
        });

        let device = DeviceInfo::from_shelly_response(ip(), &shelly).unwrap();
        assert_eq!(device.generation, DeviceGeneration::Gen1);
        assert!(!device.auth_enabled);
        assert_eq!(device.firmware_version, "unknown");
        assert_eq!(device.num_outputs, 1);
        assert_eq!(device.num_meters, 0);
    }

    #[test]
    fn missing_optional_fields_gen2() {
        let shelly = json!({
            "id": "shellyplus1-abc",
            "mac": "112233445566",
            "gen": 2
        });

        let device = DeviceInfo::from_shelly_response(ip(), &shelly).unwrap();
        assert_eq!(device.generation, DeviceGeneration::Gen2);
        assert_eq!(device.model, "unknown");
        assert_eq!(device.firmware_version, "unknown");
        assert!(device.name.is_none());
        assert!(device.app.is_none());
    }

    #[test]
    fn unknown_extra_fields_ignored() {
        let shelly = json!({
            "type": "SHSW-PM",
            "mac": "AABBCCDDEEFF",
            "auth": false,
            "fw": "1.0.0",
            "num_outputs": 1,
            "num_meters": 1,
            "totally_new_field": "should be ignored",
            "another_field": 42
        });

        let device = DeviceInfo::from_shelly_response(ip(), &shelly).unwrap();
        assert_eq!(device.model, "SHSW-PM");
    }

    #[test]
    fn unrecognized_response_returns_none() {
        let shelly = json!({"random": "data"});
        assert!(DeviceInfo::from_shelly_response(ip(), &shelly).is_none());
    }

    #[test]
    fn display_name_uses_name_when_present() {
        let shelly = json!({
            "id": "shellyplus1pm-abc",
            "mac": "AABBCCDDEEFF",
            "gen": 2,
            "ver": "1.0",
            "name": "Kitchen Light"
        });
        let device = DeviceInfo::from_shelly_response(ip(), &shelly).unwrap();
        assert_eq!(device.display_name(), "Kitchen Light");
    }

    #[test]
    fn display_name_falls_back_to_id() {
        let shelly = json!({
            "type": "SHSW-PM",
            "mac": "AABBCCDDEEFF",
            "fw": "1.0"
        });
        let device = DeviceInfo::from_shelly_response(ip(), &shelly).unwrap();
        assert_eq!(device.display_name(), "shellypm-AABBCCDDEEFF");
    }

    #[test]
    fn gen1_hostname_strips_shsw_prefix() {
        let shelly = json!({
            "type": "SHSW-25",
            "mac": "AABBCCDDEEFF",
            "fw": "1.0"
        });
        let device = DeviceInfo::from_gen1_shelly(ip(), &shelly).unwrap();
        assert_eq!(device.id, "shelly25-AABBCCDDEEFF");
    }
}