Skip to main content

shelly_core/model/
status.rs

1use serde::{Deserialize, Serialize};
2
3use super::gen1_responses::{Gen1Meter, Gen1Relay, Gen1StatusResponse};
4use super::gen2_responses::{Gen2InputStatus, Gen2SwitchStatus};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct SwitchStatus {
8    pub id: u8,
9    pub output: bool,
10    pub source: Option<String>,
11    pub power_watts: Option<f64>,
12    pub voltage: Option<f64>,
13    pub current: Option<f64>,
14    pub frequency: Option<f64>,
15    pub temperature_c: Option<f64>,
16    pub total_energy_wh: Option<f64>,
17    pub timer_active: bool,
18    pub timer_remaining: Option<f64>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct InputStatus {
23    pub id: u8,
24    pub state: bool,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct WifiStatus {
29    pub connected: bool,
30    pub ssid: Option<String>,
31    pub ip: Option<String>,
32    pub rssi: Option<i32>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct DeviceStatus {
37    pub switches: Vec<SwitchStatus>,
38    pub inputs: Vec<InputStatus>,
39    pub wifi: Option<WifiStatus>,
40    pub uptime: Option<u64>,
41    pub time: Option<String>,
42    pub cloud_connected: Option<bool>,
43    pub mqtt_connected: Option<bool>,
44    pub ram_free: Option<u64>,
45    pub temperature_c: Option<f64>,
46}
47
48impl SwitchStatus {
49    pub fn from_gen1_relay(id: u8, relay: &Gen1Relay, meter: Option<&Gen1Meter>) -> Self {
50        let timer_remaining = if relay.has_timer {
51            relay.timer_remaining
52        } else {
53            None
54        };
55
56        let (power_watts, total_energy_wh) = if let Some(m) = meter {
57            (m.power, m.total)
58        } else {
59            (None, None)
60        };
61
62        Self {
63            id,
64            output: relay.ison,
65            source: relay.source.clone(),
66            power_watts,
67            voltage: None,
68            current: None,
69            frequency: None,
70            temperature_c: None,
71            total_energy_wh,
72            timer_active: relay.has_timer,
73            timer_remaining,
74        }
75    }
76
77    pub fn from_gen2_switch(sw: &Gen2SwitchStatus) -> Self {
78        let temperature_c = sw.temperature.as_ref().and_then(|t| t.t_c);
79        let total_energy_wh = sw.aenergy.as_ref().and_then(|e| e.total);
80        let timer_active = sw.timer_started_at.is_some() && sw.timer_duration.is_some();
81
82        Self {
83            id: sw.id,
84            output: sw.output,
85            source: sw.source.clone(),
86            power_watts: sw.apower,
87            voltage: sw.voltage,
88            current: sw.current,
89            frequency: sw.frequency,
90            temperature_c,
91            total_energy_wh,
92            timer_active,
93            timer_remaining: sw.timer_duration,
94        }
95    }
96
97    /// Parse from raw Gen1 JSON (used by API layer that still works with raw values).
98    pub fn from_gen1_relay_json(
99        id: u8,
100        relay: &serde_json::Value,
101        meter: Option<&serde_json::Value>,
102    ) -> Self {
103        let relay: Gen1Relay = serde_json::from_value(relay.clone()).unwrap_or(Gen1Relay {
104            ison: false,
105            source: None,
106            has_timer: false,
107            timer_remaining: None,
108        });
109        let meter: Option<Gen1Meter> = meter.and_then(|m| serde_json::from_value(m.clone()).ok());
110        Self::from_gen1_relay(id, &relay, meter.as_ref())
111    }
112
113    /// Parse from raw Gen2 JSON (used by API layer that still works with raw values).
114    pub fn from_gen2_switch_json(sw: &serde_json::Value) -> Self {
115        let sw: Gen2SwitchStatus = serde_json::from_value(sw.clone()).unwrap_or(Gen2SwitchStatus {
116            id: 0,
117            output: false,
118            source: None,
119            apower: None,
120            voltage: None,
121            current: None,
122            frequency: None,
123            temperature: None,
124            aenergy: None,
125            timer_started_at: None,
126            timer_duration: None,
127        });
128        Self::from_gen2_switch(&sw)
129    }
130}
131
132impl DeviceStatus {
133    pub fn from_gen1(status: &serde_json::Value) -> Self {
134        let resp: Gen1StatusResponse =
135            serde_json::from_value(status.clone()).unwrap_or(Gen1StatusResponse {
136                relays: Vec::new(),
137                meters: Vec::new(),
138                inputs: Vec::new(),
139                wifi_sta: None,
140                uptime: None,
141                time: None,
142                cloud: None,
143                mqtt: None,
144                ram_free: None,
145                tmp: None,
146                temperature: None,
147            });
148
149        let switches: Vec<SwitchStatus> = resp
150            .relays
151            .iter()
152            .enumerate()
153            .map(|(i, relay)| {
154                let meter = resp.meters.get(i);
155                SwitchStatus::from_gen1_relay(i as u8, relay, meter)
156            })
157            .collect();
158
159        let inputs: Vec<InputStatus> = resp
160            .inputs
161            .iter()
162            .enumerate()
163            .map(|(i, input)| InputStatus {
164                id: i as u8,
165                state: input.input != 0,
166            })
167            .collect();
168
169        let wifi = resp.wifi_sta.map(|w| WifiStatus {
170            connected: w.connected,
171            ssid: w.ssid,
172            ip: w.ip,
173            rssi: w.rssi.map(|v| v as i32),
174        });
175
176        let temperature_c = resp.tmp.and_then(|t| t.t_c).or(resp.temperature);
177
178        Self {
179            switches,
180            inputs,
181            wifi,
182            uptime: resp.uptime,
183            time: resp.time,
184            cloud_connected: resp.cloud.and_then(|c| c.connected),
185            mqtt_connected: resp.mqtt.and_then(|m| m.connected),
186            ram_free: resp.ram_free,
187            temperature_c,
188        }
189    }
190
191    pub fn from_gen2(status: &serde_json::Value) -> Self {
192        let mut switches = Vec::new();
193        let mut inputs = Vec::new();
194
195        // Gen2/3 status has dynamic keys like "switch:0", "input:0"
196        for (key, value) in status.as_object().into_iter().flatten() {
197            if key.starts_with("switch:") {
198                if let Ok(sw) = serde_json::from_value::<Gen2SwitchStatus>(value.clone()) {
199                    switches.push(SwitchStatus::from_gen2_switch(&sw));
200                }
201            } else if key.starts_with("input:")
202                && let Ok(input) = serde_json::from_value::<Gen2InputStatus>(value.clone())
203            {
204                inputs.push(InputStatus {
205                    id: input.id,
206                    state: input.state,
207                });
208            }
209        }
210
211        switches.sort_by_key(|s| s.id);
212        inputs.sort_by_key(|i| i.id);
213
214        let wifi = status
215            .get("wifi")
216            .and_then(|w| {
217                serde_json::from_value::<super::gen2_responses::Gen2WifiStatus>(w.clone()).ok()
218            })
219            .map(|w| WifiStatus {
220                connected: w.status.as_deref() == Some("got ip"),
221                ssid: w.ssid,
222                ip: w.sta_ip,
223                rssi: w.rssi.map(|v| v as i32),
224            });
225
226        let sys = status.get("sys").and_then(|s| {
227            serde_json::from_value::<super::gen2_responses::Gen2SysStatus>(s.clone()).ok()
228        });
229
230        let cloud_connected = status
231            .get("cloud")
232            .and_then(|c| {
233                serde_json::from_value::<super::gen2_responses::Gen2Cloud>(c.clone()).ok()
234            })
235            .and_then(|c| c.connected);
236
237        let mqtt_connected = status
238            .get("mqtt")
239            .and_then(|m| serde_json::from_value::<super::gen2_responses::Gen2Mqtt>(m.clone()).ok())
240            .and_then(|m| m.connected);
241
242        let temperature_c = switches.first().and_then(|s| s.temperature_c);
243
244        Self {
245            switches,
246            inputs,
247            wifi,
248            uptime: sys.as_ref().and_then(|s| s.uptime),
249            time: sys.as_ref().and_then(|s| s.time.clone()),
250            cloud_connected,
251            mqtt_connected,
252            ram_free: sys.and_then(|s| s.ram_free),
253            temperature_c,
254        }
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use serde_json::json;
262
263    #[test]
264    fn gen1_full_status() {
265        let status = json!({
266            "relays": [
267                {"ison": true, "source": "http", "has_timer": false, "timer_remaining": 0}
268            ],
269            "meters": [
270                {"power": 42.5, "total": 12345.6}
271            ],
272            "inputs": [
273                {"input": 1}
274            ],
275            "wifi_sta": {
276                "connected": true,
277                "ssid": "MyNetwork",
278                "ip": "192.0.2.5",
279                "rssi": -58
280            },
281            "uptime": 86400,
282            "time": "14:30",
283            "cloud": {"connected": true},
284            "mqtt": {"connected": false},
285            "ram_free": 32000,
286            "tmp": {"tC": 38.5, "tF": 101.3, "is_valid": true}
287        });
288
289        let ds = DeviceStatus::from_gen1(&status);
290        assert_eq!(ds.switches.len(), 1);
291        assert!(ds.switches[0].output);
292        assert_eq!(ds.switches[0].source.as_deref(), Some("http"));
293        assert_eq!(ds.switches[0].power_watts, Some(42.5));
294        assert_eq!(ds.switches[0].total_energy_wh, Some(12345.6));
295        assert!(!ds.switches[0].timer_active);
296
297        assert_eq!(ds.inputs.len(), 1);
298        assert!(ds.inputs[0].state);
299
300        let wifi = ds.wifi.unwrap();
301        assert!(wifi.connected);
302        assert_eq!(wifi.ssid.as_deref(), Some("MyNetwork"));
303        assert_eq!(wifi.ip.as_deref(), Some("192.0.2.5"));
304        assert_eq!(wifi.rssi, Some(-58));
305
306        assert_eq!(ds.uptime, Some(86400));
307        assert_eq!(ds.time.as_deref(), Some("14:30"));
308        assert_eq!(ds.cloud_connected, Some(true));
309        assert_eq!(ds.mqtt_connected, Some(false));
310        assert_eq!(ds.ram_free, Some(32000));
311        assert_eq!(ds.temperature_c, Some(38.5));
312    }
313
314    #[test]
315    fn gen2_full_status() {
316        let status = json!({
317            "switch:0": {
318                "id": 0,
319                "source": "WS",
320                "output": true,
321                "apower": 100.5,
322                "voltage": 224.0,
323                "current": 0.45,
324                "freq": 50.0,
325                "temperature": {"tC": 42.0, "tF": 107.6},
326                "aenergy": {"total": 5678.9, "by_minute": [0.0], "minute_ts": 0}
327            },
328            "input:0": {
329                "id": 0,
330                "state": true
331            },
332            "wifi": {
333                "sta_ip": "192.0.2.10",
334                "status": "got ip",
335                "ssid": "HomeNet",
336                "rssi": -45
337            },
338            "sys": {
339                "uptime": 3600,
340                "time": "09:15",
341                "ram_free": 64000
342            },
343            "cloud": {"connected": true},
344            "mqtt": {"connected": true}
345        });
346
347        let ds = DeviceStatus::from_gen2(&status);
348        assert_eq!(ds.switches.len(), 1);
349        assert!(ds.switches[0].output);
350        assert_eq!(ds.switches[0].source.as_deref(), Some("WS"));
351        assert_eq!(ds.switches[0].power_watts, Some(100.5));
352        assert_eq!(ds.switches[0].voltage, Some(224.0));
353        assert_eq!(ds.switches[0].current, Some(0.45));
354        assert_eq!(ds.switches[0].frequency, Some(50.0));
355        assert_eq!(ds.switches[0].temperature_c, Some(42.0));
356        assert_eq!(ds.switches[0].total_energy_wh, Some(5678.9));
357
358        assert_eq!(ds.inputs.len(), 1);
359        assert!(ds.inputs[0].state);
360
361        let wifi = ds.wifi.unwrap();
362        assert!(wifi.connected);
363        assert_eq!(wifi.ssid.as_deref(), Some("HomeNet"));
364        assert_eq!(wifi.ip.as_deref(), Some("192.0.2.10"));
365        assert_eq!(wifi.rssi, Some(-45));
366
367        assert_eq!(ds.uptime, Some(3600));
368        assert_eq!(ds.time.as_deref(), Some("09:15"));
369        assert_eq!(ds.cloud_connected, Some(true));
370        assert_eq!(ds.mqtt_connected, Some(true));
371        assert_eq!(ds.ram_free, Some(64000));
372        assert_eq!(ds.temperature_c, Some(42.0));
373    }
374
375    #[test]
376    fn gen1_minimal_status() {
377        let status = json!({});
378
379        let ds = DeviceStatus::from_gen1(&status);
380        assert!(ds.switches.is_empty());
381        assert!(ds.inputs.is_empty());
382        assert!(ds.wifi.is_none());
383        assert!(ds.uptime.is_none());
384        assert!(ds.temperature_c.is_none());
385    }
386
387    #[test]
388    fn gen2_minimal_status() {
389        let status = json!({});
390
391        let ds = DeviceStatus::from_gen2(&status);
392        assert!(ds.switches.is_empty());
393        assert!(ds.inputs.is_empty());
394        assert!(ds.wifi.is_none());
395        assert!(ds.uptime.is_none());
396    }
397
398    #[test]
399    fn gen1_multiple_relays() {
400        let status = json!({
401            "relays": [
402                {"ison": true, "source": "http", "has_timer": false},
403                {"ison": false, "source": "switch", "has_timer": true, "timer_remaining": 30.0}
404            ],
405            "meters": [
406                {"power": 10.0, "total": 100.0},
407                {"power": 20.0, "total": 200.0}
408            ]
409        });
410
411        let ds = DeviceStatus::from_gen1(&status);
412        assert_eq!(ds.switches.len(), 2);
413
414        assert!(ds.switches[0].output);
415        assert_eq!(ds.switches[0].id, 0);
416        assert_eq!(ds.switches[0].power_watts, Some(10.0));
417
418        assert!(!ds.switches[1].output);
419        assert_eq!(ds.switches[1].id, 1);
420        assert_eq!(ds.switches[1].power_watts, Some(20.0));
421        assert!(ds.switches[1].timer_active);
422        assert_eq!(ds.switches[1].timer_remaining, Some(30.0));
423    }
424
425    #[test]
426    fn gen2_multiple_switches_sorted() {
427        let status = json!({
428            "switch:1": {"id": 1, "output": false},
429            "switch:0": {"id": 0, "output": true}
430        });
431
432        let ds = DeviceStatus::from_gen2(&status);
433        assert_eq!(ds.switches.len(), 2);
434        assert_eq!(ds.switches[0].id, 0);
435        assert!(ds.switches[0].output);
436        assert_eq!(ds.switches[1].id, 1);
437        assert!(!ds.switches[1].output);
438    }
439
440    #[test]
441    fn gen1_temperature_fallback() {
442        // When tmp is missing, falls back to top-level temperature field
443        let status = json!({
444            "temperature": 35.0
445        });
446        let ds = DeviceStatus::from_gen1(&status);
447        assert_eq!(ds.temperature_c, Some(35.0));
448    }
449
450    #[test]
451    fn gen2_wifi_not_connected() {
452        let status = json!({
453            "wifi": {
454                "sta_ip": null,
455                "status": "disconnected",
456                "ssid": null,
457                "rssi": -90
458            }
459        });
460        let ds = DeviceStatus::from_gen2(&status);
461        let wifi = ds.wifi.unwrap();
462        assert!(!wifi.connected);
463    }
464
465    #[test]
466    fn gen2_timer_active() {
467        let status = json!({
468            "switch:0": {
469                "id": 0,
470                "output": true,
471                "timer_started_at": 1000.0,
472                "timer_duration": 60.0
473            }
474        });
475
476        let ds = DeviceStatus::from_gen2(&status);
477        assert!(ds.switches[0].timer_active);
478        assert_eq!(ds.switches[0].timer_remaining, Some(60.0));
479    }
480}