Skip to main content

shelly_core/model/
device.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3use std::net::IpAddr;
4
5use super::gen1_responses::Gen1ShellyResponse;
6use super::gen2_responses::Gen2ShellyResponse;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9pub enum DeviceGeneration {
10    Gen1,
11    Gen2,
12    Gen3,
13}
14
15impl fmt::Display for DeviceGeneration {
16    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17        match self {
18            Self::Gen1 => write!(f, "Gen1"),
19            Self::Gen2 => write!(f, "Gen2"),
20            Self::Gen3 => write!(f, "Gen3"),
21        }
22    }
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct DeviceInfo {
27    pub ip: IpAddr,
28    pub name: Option<String>,
29    pub id: String,
30    pub mac: String,
31    pub model: String,
32    pub generation: DeviceGeneration,
33    pub firmware_version: String,
34    pub auth_enabled: bool,
35    pub num_outputs: u8,
36    pub num_meters: u8,
37    pub app: Option<String>,
38    pub device_type: Option<String>,
39}
40
41impl DeviceInfo {
42    pub fn display_name(&self) -> &str {
43        self.name.as_deref().unwrap_or(&self.id)
44    }
45
46    pub fn from_gen1_shelly(ip: IpAddr, shelly: &serde_json::Value) -> Option<Self> {
47        let resp: Gen1ShellyResponse = serde_json::from_value(shelly.clone()).ok()?;
48
49        let hostname = format!(
50            "shelly{}-{}",
51            resp.device_type.to_lowercase().replace("shsw-", ""),
52            resp.mac
53        );
54
55        Some(Self {
56            ip,
57            name: None,
58            id: hostname,
59            mac: resp.mac,
60            model: resp.device_type.clone(),
61            generation: DeviceGeneration::Gen1,
62            firmware_version: resp.fw,
63            auth_enabled: resp.auth,
64            num_outputs: resp.num_outputs,
65            num_meters: resp.num_meters,
66            app: None,
67            device_type: Some(resp.device_type),
68        })
69    }
70
71    pub fn from_gen2_shelly(ip: IpAddr, shelly: &serde_json::Value) -> Option<Self> {
72        let resp: Gen2ShellyResponse = serde_json::from_value(shelly.clone()).ok()?;
73
74        let generation = if resp.generation >= 3 {
75            DeviceGeneration::Gen3
76        } else {
77            DeviceGeneration::Gen2
78        };
79
80        Some(Self {
81            ip,
82            name: resp.name,
83            id: resp.id,
84            mac: resp.mac,
85            model: resp.model,
86            generation,
87            firmware_version: resp.ver,
88            auth_enabled: resp.auth_en,
89            num_outputs: 1,
90            num_meters: 1,
91            app: resp.app,
92            device_type: None,
93        })
94    }
95
96    pub fn from_shelly_response(ip: IpAddr, shelly: &serde_json::Value) -> Option<Self> {
97        if shelly.get("gen").is_some() {
98            Self::from_gen2_shelly(ip, shelly)
99        } else if shelly.get("type").is_some() {
100            Self::from_gen1_shelly(ip, shelly)
101        } else {
102            None
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use serde_json::json;
111
112    fn ip() -> IpAddr {
113        "192.0.2.1".parse().unwrap()
114    }
115
116    #[test]
117    fn gen1_device_from_shelly_response() {
118        let shelly = json!({
119            "type": "SHSW-PM",
120            "mac": "AABBCCDDEEFF",
121            "auth": false,
122            "fw": "20230913-114003/v1.14.0-gcb84623",
123            "num_outputs": 1,
124            "num_meters": 1
125        });
126
127        let device = DeviceInfo::from_shelly_response(ip(), &shelly).unwrap();
128        assert_eq!(device.generation, DeviceGeneration::Gen1);
129        assert_eq!(device.model, "SHSW-PM");
130        assert_eq!(device.mac, "AABBCCDDEEFF");
131        assert_eq!(device.firmware_version, "20230913-114003/v1.14.0-gcb84623");
132        assert!(!device.auth_enabled);
133        assert_eq!(device.num_outputs, 1);
134        assert_eq!(device.num_meters, 1);
135        assert_eq!(device.id, "shellypm-AABBCCDDEEFF");
136        assert!(device.name.is_none());
137        assert_eq!(device.device_type.as_deref(), Some("SHSW-PM"));
138        assert!(device.app.is_none());
139    }
140
141    #[test]
142    fn gen2_device_from_shelly_response() {
143        let shelly = json!({
144            "id": "shellyplus1pm-aabbccddeeff",
145            "mac": "AABBCCDDEEFF",
146            "model": "SNSW-001P16EU",
147            "gen": 2,
148            "fw_id": "20230913-114003",
149            "ver": "1.0.0",
150            "app": "Plus1PM",
151            "auth_en": false,
152            "name": "Living Room"
153        });
154
155        let device = DeviceInfo::from_shelly_response(ip(), &shelly).unwrap();
156        assert_eq!(device.generation, DeviceGeneration::Gen2);
157        assert_eq!(device.model, "SNSW-001P16EU");
158        assert_eq!(device.mac, "AABBCCDDEEFF");
159        assert_eq!(device.id, "shellyplus1pm-aabbccddeeff");
160        assert_eq!(device.name.as_deref(), Some("Living Room"));
161        assert_eq!(device.app.as_deref(), Some("Plus1PM"));
162        assert!(!device.auth_enabled);
163        assert_eq!(device.firmware_version, "1.0.0");
164    }
165
166    #[test]
167    fn gen3_device_from_shelly_response() {
168        let shelly = json!({
169            "id": "shelly1minig3-aabbccddeeff",
170            "mac": "AABBCCDDEEFF",
171            "model": "S3SW-001X8EU",
172            "gen": 3,
173            "fw_id": "20240101-000000",
174            "ver": "2.0.0",
175            "app": "Mini1G3",
176            "auth_en": true,
177            "name": null
178        });
179
180        let device = DeviceInfo::from_shelly_response(ip(), &shelly).unwrap();
181        assert_eq!(device.generation, DeviceGeneration::Gen3);
182        assert_eq!(device.model, "S3SW-001X8EU");
183        assert!(device.auth_enabled);
184        assert!(device.name.is_none());
185        assert_eq!(device.app.as_deref(), Some("Mini1G3"));
186    }
187
188    #[test]
189    fn missing_optional_fields_gen1() {
190        let shelly = json!({
191            "type": "SHSW-1",
192            "mac": "112233445566"
193        });
194
195        let device = DeviceInfo::from_shelly_response(ip(), &shelly).unwrap();
196        assert_eq!(device.generation, DeviceGeneration::Gen1);
197        assert!(!device.auth_enabled);
198        assert_eq!(device.firmware_version, "unknown");
199        assert_eq!(device.num_outputs, 1);
200        assert_eq!(device.num_meters, 0);
201    }
202
203    #[test]
204    fn missing_optional_fields_gen2() {
205        let shelly = json!({
206            "id": "shellyplus1-abc",
207            "mac": "112233445566",
208            "gen": 2
209        });
210
211        let device = DeviceInfo::from_shelly_response(ip(), &shelly).unwrap();
212        assert_eq!(device.generation, DeviceGeneration::Gen2);
213        assert_eq!(device.model, "unknown");
214        assert_eq!(device.firmware_version, "unknown");
215        assert!(device.name.is_none());
216        assert!(device.app.is_none());
217    }
218
219    #[test]
220    fn unknown_extra_fields_ignored() {
221        let shelly = json!({
222            "type": "SHSW-PM",
223            "mac": "AABBCCDDEEFF",
224            "auth": false,
225            "fw": "1.0.0",
226            "num_outputs": 1,
227            "num_meters": 1,
228            "totally_new_field": "should be ignored",
229            "another_field": 42
230        });
231
232        let device = DeviceInfo::from_shelly_response(ip(), &shelly).unwrap();
233        assert_eq!(device.model, "SHSW-PM");
234    }
235
236    #[test]
237    fn unrecognized_response_returns_none() {
238        let shelly = json!({"random": "data"});
239        assert!(DeviceInfo::from_shelly_response(ip(), &shelly).is_none());
240    }
241
242    #[test]
243    fn display_name_uses_name_when_present() {
244        let shelly = json!({
245            "id": "shellyplus1pm-abc",
246            "mac": "AABBCCDDEEFF",
247            "gen": 2,
248            "ver": "1.0",
249            "name": "Kitchen Light"
250        });
251        let device = DeviceInfo::from_shelly_response(ip(), &shelly).unwrap();
252        assert_eq!(device.display_name(), "Kitchen Light");
253    }
254
255    #[test]
256    fn display_name_falls_back_to_id() {
257        let shelly = json!({
258            "type": "SHSW-PM",
259            "mac": "AABBCCDDEEFF",
260            "fw": "1.0"
261        });
262        let device = DeviceInfo::from_shelly_response(ip(), &shelly).unwrap();
263        assert_eq!(device.display_name(), "shellypm-AABBCCDDEEFF");
264    }
265
266    #[test]
267    fn gen1_hostname_strips_shsw_prefix() {
268        let shelly = json!({
269            "type": "SHSW-25",
270            "mac": "AABBCCDDEEFF",
271            "fw": "1.0"
272        });
273        let device = DeviceInfo::from_gen1_shelly(ip(), &shelly).unwrap();
274        assert_eq!(device.id, "shelly25-AABBCCDDEEFF");
275    }
276}