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}