Skip to main content

lm_rs/
types.rs

1use serde::{Deserialize, Serialize};
2use std::time::{SystemTime, UNIX_EPOCH};
3
4/// Authentication tokens returned from login
5#[derive(Debug, Clone)]
6pub struct Credentials {
7    pub access_token: String,
8    pub refresh_token: String,
9    pub username: String,
10    /// Installation key for new authentication system
11    pub installation_key: Option<crate::installation_key::InstallationKey>,
12}
13
14#[derive(Debug, Deserialize, Clone)]
15pub struct Machine {
16    #[serde(rename = "serialNumber")]
17    pub serial_number: String,
18    #[serde(rename = "modelName")]
19    pub model: Option<String>,
20    pub name: Option<String>,
21    #[allow(dead_code)] // Field from API but not currently used in display
22    pub location: Option<String>,
23    pub connected: bool,
24}
25
26#[derive(Debug, Deserialize)]
27pub struct MachinesResponse {
28    pub things: Vec<Machine>,
29}
30
31#[derive(Debug, Serialize)]
32pub struct MachineCommand {
33    pub mode: String,
34}
35
36#[derive(Debug, Deserialize)]
37pub struct MachineStatus {
38    pub widgets: Vec<Widget>,
39}
40
41#[derive(Debug, Deserialize)]
42pub struct Widget {
43    pub code: String,
44    pub output: Option<WidgetOutput>,
45}
46
47#[derive(Debug, Deserialize)]
48pub struct WidgetOutput {
49    pub status: Option<String>,
50    #[allow(dead_code)]
51    pub mode: Option<String>,
52    // Boiler-specific fields
53    #[serde(rename = "readyStartTime")]
54    pub ready_start_time: Option<u64>,
55}
56
57impl MachineStatus {
58    pub fn is_on(&self) -> bool {
59        // Look for the CMMachineStatus widget
60        for widget in &self.widgets {
61            if widget.code == "CMMachineStatus" {
62                if let Some(output) = &widget.output {
63                    if let Some(status) = &output.status {
64                        return status != "StandBy";
65                    }
66                }
67            }
68        }
69        false // Default to off if we can't determine the status
70    }
71
72    pub fn get_status_string(&self) -> String {
73        self.get_status_string_with_time(None)
74    }
75
76    pub fn get_status_string_with_time(&self, current_time_ms: Option<u64>) -> String {
77        // First, check if machine is powered on
78        let mut is_powered_on = false;
79        for widget in &self.widgets {
80            if widget.code == "CMMachineStatus" {
81                if let Some(output) = &widget.output {
82                    if let Some(status) = &output.status {
83                        match status.as_str() {
84                            "StandBy" => return "Standby".to_string(),
85                            "PoweredOn" => {
86                                is_powered_on = true;
87                                break;
88                            }
89                            _ => return status.clone(),
90                        }
91                    }
92                }
93            }
94        }
95
96        if !is_powered_on {
97            return "Unknown".to_string();
98        }
99
100        // Machine is powered on, now check boiler status
101        for widget in &self.widgets {
102            if widget.code == "CMCoffeeBoiler" {
103                if let Some(output) = &widget.output {
104                    if let Some(status) = &output.status {
105                        if status == "Ready" {
106                            return "On (Ready)".to_string();
107                        } else if status == "NoWater" {
108                            return "On (No water)".to_string();
109                        } else if let Some(ready_time) = output.ready_start_time {
110                            let now = current_time_ms.unwrap_or_else(|| {
111                                SystemTime::now()
112                                    .duration_since(UNIX_EPOCH)
113                                    .unwrap()
114                                    .as_millis() as u64
115                            });
116
117                            if ready_time > now {
118                                let seconds_remaining = (ready_time - now) / 1000;
119                                let minutes_remaining = seconds_remaining / 60;
120
121                                if minutes_remaining == 0 {
122                                    return "On (Ready in < 1 min)".to_string();
123                                } else if minutes_remaining == 1 {
124                                    return "On (Ready in 1 min)".to_string();
125                                } else {
126                                    return format!("On (Ready in {} mins)", minutes_remaining);
127                                }
128                            } else {
129                                // Ready time is in the past, should be ready soon
130                                return "On (Ready in < 1 min)".to_string();
131                            }
132                        } else {
133                            // Heating but no ready time
134                            return "On (Ready soon)".to_string();
135                        }
136                    }
137                }
138            }
139        }
140
141        // Machine is on but we don't have boiler info
142        "On".to_string()
143    }
144}
145
146impl Machine {
147    pub async fn get_status_display(&self, client: &crate::client::LaMarzoccoClient) -> String {
148        if !self.connected {
149            return "Unavailable".to_string();
150        }
151
152        match client.get_machine_status(&self.serial_number).await {
153            Ok(status) => status.get_status_string(),
154            Err(_) => "Unknown".to_string(),
155        }
156    }
157}
158
159impl MachineCommand {
160    pub fn turn_on() -> Self {
161        Self {
162            mode: "BrewingMode".to_string(),
163        }
164    }
165
166    pub fn turn_off() -> Self {
167        Self {
168            mode: "StandBy".to_string(),
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn test_machine_command_creation() {
179        let on_command = MachineCommand::turn_on();
180        assert_eq!(on_command.mode, "BrewingMode");
181
182        let off_command = MachineCommand::turn_off();
183        assert_eq!(off_command.mode, "StandBy");
184    }
185
186    #[test]
187    fn test_machine_command_json_serialization() {
188        let on_command = MachineCommand::turn_on();
189        let json = serde_json::to_string(&on_command).unwrap();
190        assert!(json.contains("BrewingMode"));
191
192        let off_command = MachineCommand::turn_off();
193        let json = serde_json::to_string(&off_command).unwrap();
194        assert!(json.contains("StandBy"));
195    }
196
197    #[test]
198    fn test_machine_status_parsing() {
199        // Test StandBy status
200        let standby_status = MachineStatus {
201            widgets: vec![Widget {
202                code: "CMMachineStatus".to_string(),
203                output: Some(WidgetOutput {
204                    status: Some("StandBy".to_string()),
205                    mode: None,
206                    ready_start_time: None,
207                }),
208            }],
209        };
210
211        assert!(!standby_status.is_on());
212        assert_eq!(standby_status.get_status_string(), "Standby");
213
214        // Test PoweredOn status
215        let powered_on_status = MachineStatus {
216            widgets: vec![Widget {
217                code: "CMMachineStatus".to_string(),
218                output: Some(WidgetOutput {
219                    status: Some("PoweredOn".to_string()),
220                    mode: None,
221                    ready_start_time: None,
222                }),
223            }],
224        };
225
226        assert!(powered_on_status.is_on());
227        assert_eq!(powered_on_status.get_status_string(), "On");
228
229        // Test missing widget
230        let no_status = MachineStatus { widgets: vec![] };
231
232        assert!(!no_status.is_on());
233        assert_eq!(no_status.get_status_string(), "Unknown");
234    }
235
236    #[test]
237    fn test_machine_status_with_ready_time() {
238        // Test warming status with ready_start_time
239        let status_warming = MachineStatus {
240            widgets: vec![
241                Widget {
242                    code: "CMMachineStatus".to_string(),
243                    output: Some(WidgetOutput {
244                        status: Some("PoweredOn".to_string()),
245                        mode: None,
246                        ready_start_time: None, // This widget doesn't have ready time
247                    }),
248                },
249                Widget {
250                    code: "CMCoffeeBoiler".to_string(),
251                    output: Some(WidgetOutput {
252                        status: Some("Heating".to_string()),
253                        mode: None,
254                        ready_start_time: Some(1748515947000), // Future timestamp
255                    }),
256                },
257            ],
258        };
259
260        assert!(status_warming.is_on());
261
262        // Test with a fixed current time to avoid flaky tests
263        // readyStartTime in fixture is 1748515947000 (Jan 29, 2025 15:32:27 UTC)
264        // Let's set current time to 5 minutes earlier: 1748515647000
265        let fixed_current_time = 1748515647000; // 5 minutes before ready time
266        let warming_status = status_warming.get_status_string_with_time(Some(fixed_current_time));
267        assert_eq!(warming_status, "On (Ready in 5 mins)");
268
269        // Test when current time is exactly at ready time
270        let warming_status_ready = status_warming.get_status_string_with_time(Some(1748515947000));
271        assert_eq!(warming_status_ready, "On (Ready in < 1 min)");
272
273        // Test when current time is past ready time
274        let warming_status_past = status_warming.get_status_string_with_time(Some(1748515947001));
275        assert_eq!(warming_status_past, "On (Ready in < 1 min)");
276
277        // Test when ready in 1 minute
278        let one_minute_before = 1748515947000 - 60000; // 1 minute before
279        let warming_status_1min =
280            status_warming.get_status_string_with_time(Some(one_minute_before));
281        assert_eq!(warming_status_1min, "On (Ready in 1 min)");
282
283        // Test when ready in less than 1 minute
284        let thirty_seconds_before = 1748515947000 - 30000; // 30 seconds before
285        let warming_status_soon =
286            status_warming.get_status_string_with_time(Some(thirty_seconds_before));
287        assert_eq!(warming_status_soon, "On (Ready in < 1 min)");
288    }
289
290    #[test]
291    fn test_machine_status_error_conditions() {
292        // Test empty widgets
293        let status = MachineStatus { widgets: vec![] };
294        assert!(!status.is_on());
295        assert_eq!(status.get_status_string(), "Unknown");
296
297        // Test wrong widget type
298        let status = MachineStatus {
299            widgets: vec![Widget {
300                code: "WrongWidget".to_string(),
301                output: Some(WidgetOutput {
302                    status: Some("PoweredOn".to_string()),
303                    mode: None,
304                    ready_start_time: None,
305                }),
306            }],
307        };
308        assert!(!status.is_on());
309        assert_eq!(status.get_status_string(), "Unknown");
310
311        // Test no output
312        let status = MachineStatus {
313            widgets: vec![Widget {
314                code: "CMMachineStatus".to_string(),
315                output: None,
316            }],
317        };
318        assert!(!status.is_on());
319        assert_eq!(status.get_status_string(), "Unknown");
320
321        // Test no status field
322        let status = MachineStatus {
323            widgets: vec![Widget {
324                code: "CMMachineStatus".to_string(),
325                output: Some(WidgetOutput {
326                    status: None,
327                    mode: Some("SomeMode".to_string()),
328                    ready_start_time: None,
329                }),
330            }],
331        };
332        assert!(!status.is_on());
333        assert_eq!(status.get_status_string(), "Unknown");
334    }
335
336    #[test]
337    fn test_machine_status_no_water() {
338        // Test NoWater status
339        let status_no_water = MachineStatus {
340            widgets: vec![
341                Widget {
342                    code: "CMMachineStatus".to_string(),
343                    output: Some(WidgetOutput {
344                        status: Some("PoweredOn".to_string()),
345                        mode: None,
346                        ready_start_time: None,
347                    }),
348                },
349                Widget {
350                    code: "CMCoffeeBoiler".to_string(),
351                    output: Some(WidgetOutput {
352                        status: Some("NoWater".to_string()),
353                        mode: None,
354                        ready_start_time: None, // null when no water
355                    }),
356                },
357            ],
358        };
359
360        assert!(status_no_water.is_on());
361        assert_eq!(status_no_water.get_status_string(), "On (No water)");
362    }
363
364    #[test]
365    fn test_machine_properties() {
366        let machine = Machine {
367            serial_number: "TEST123".to_string(),
368            model: Some("Test Model".to_string()),
369            name: Some("Test Machine".to_string()),
370            location: Some("Test Location".to_string()),
371            connected: false,
372        };
373
374        // Test machine properties
375        assert_eq!(machine.serial_number, "TEST123");
376        assert_eq!(machine.model, Some("Test Model".to_string()));
377        assert_eq!(machine.name, Some("Test Machine".to_string()));
378        assert_eq!(machine.location, Some("Test Location".to_string()));
379        assert!(!machine.connected);
380    }
381}