Skip to main content

rointe_core/
client.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use chrono::{Datelike, Duration as ChronoDuration, Timelike, Utc};
5use reqwest::Client;
6use serde_json::{json, Value};
7use tokio::sync::Mutex;
8use tracing::debug;
9
10use crate::auth::FirebaseAuth;
11use crate::error::{Result, RointeError};
12use crate::firebase::rtdb::RtdbClient;
13use crate::models::{
14    device::RointeDevice,
15    energy::EnergyConsumptionData,
16    enums::{HvacMode, Preset},
17    installation::{Installation, Zone},
18};
19
20/// High-level API for authenticating, discovering, and controlling Rointe devices.
21///
22/// All methods are `async` and require a Tokio runtime. Create an instance
23/// with [`RointeClient::new`] and reuse it across requests — it maintains an
24/// internal HTTP connection pool and handles token refresh automatically.
25pub struct RointeClient {
26    auth: Arc<Mutex<FirebaseAuth>>,
27    rtdb: RtdbClient,
28}
29
30impl RointeClient {
31    /// Authenticate with Rointe / Firebase and return a ready client.
32    ///
33    /// # Example
34    ///
35    /// ```no_run
36    /// # use rointe_core::RointeClient;
37    /// # async fn run() -> rointe_core::Result<()> {
38    /// let client = RointeClient::new("user@example.com", "s3cr3t").await?;
39    /// # Ok(()) }
40    /// ```
41    pub async fn new(email: &str, password: &str) -> Result<Self> {
42        let client = Client::builder()
43            .pool_max_idle_per_host(10)
44            .pool_idle_timeout(Duration::from_secs(90))
45            .tcp_keepalive(Duration::from_secs(60))
46            .build()
47            .map_err(RointeError::Network)?;
48
49        let auth = FirebaseAuth::login(client.clone(), email, password).await?;
50        let rtdb = RtdbClient::new(client);
51
52        Ok(Self {
53            auth: Arc::new(Mutex::new(auth)),
54            rtdb,
55        })
56    }
57
58    // ── Internal helpers ────────────────────────────────────────────────────
59
60    async fn token(&self) -> Result<String> {
61        self.auth.lock().await.ensure_valid_token().await
62    }
63
64    async fn local_id(&self) -> String {
65        self.auth.lock().await.local_id.clone()
66    }
67
68    // ── Public API ───────────────────────────────────────────────────────────
69
70    /// Return all installations belonging to the authenticated user.
71    ///
72    /// # Example
73    ///
74    /// ```no_run
75    /// # use rointe_core::RointeClient;
76    /// # async fn run(client: &RointeClient) -> rointe_core::Result<()> {
77    /// let installations = client.get_installations().await?;
78    /// for inst in &installations {
79    ///     println!("{}: {}", inst.id, inst.name.as_deref().unwrap_or("—"));
80    /// }
81    /// # Ok(()) }
82    /// ```
83    pub async fn get_installations(&self) -> Result<Vec<Installation>> {
84        let token = self.token().await?;
85        let local_id = self.local_id().await;
86
87        // Firebase orderBy / equalTo values must be quoted JSON strings.
88        let order_by = "\"userid\"";
89        let equal_to = format!("\"{}\"", local_id);
90
91        let raw: Value = self
92            .rtdb
93            .get(
94                "/installations2.json",
95                &token,
96                &[("orderBy", order_by), ("equalTo", &equal_to)],
97            )
98            .await?;
99
100        if raw.is_null() {
101            return Ok(vec![]);
102        }
103
104        let map: std::collections::HashMap<String, Value> =
105            serde_json::from_value(raw).map_err(|e| {
106                RointeError::Firebase(format!("Failed to parse installations: {e}"))
107            })?;
108
109        let installations = map
110            .into_iter()
111            .map(|(id, value)| {
112                let mut inst: Installation = serde_json::from_value(value).map_err(|e| {
113                    RointeError::Firebase(format!("Failed to parse installation {id}: {e}"))
114                })?;
115                inst.id = id;
116                Ok(inst)
117            })
118            .collect::<Result<Vec<_>>>()?;
119
120        debug!("Found {} installation(s)", installations.len());
121        Ok(installations)
122    }
123
124    /// Return all device IDs found within an installation (recurses through zones).
125    ///
126    /// # Example
127    ///
128    /// ```no_run
129    /// # use rointe_core::RointeClient;
130    /// # async fn run(client: &RointeClient) -> rointe_core::Result<()> {
131    /// let device_ids = client.discover_devices("installation-id").await?;
132    /// println!("Found {} device(s)", device_ids.len());
133    /// # Ok(()) }
134    /// ```
135    pub async fn discover_devices(&self, installation_id: &str) -> Result<Vec<String>> {
136        let installations = self.get_installations().await?;
137
138        let installation = installations
139            .into_iter()
140            .find(|i| i.id == installation_id)
141            .ok_or_else(|| RointeError::DeviceNotFound(installation_id.to_string()))?;
142
143        let mut device_ids = Vec::new();
144        if let Some(zones) = &installation.zones {
145            for zone in zones.values() {
146                collect_device_ids(zone, &mut device_ids);
147            }
148        }
149
150        debug!(
151            "Discovered {} device(s) in installation {installation_id}",
152            device_ids.len()
153        );
154        Ok(device_ids)
155    }
156
157    /// Fetch the current state of a single device.
158    ///
159    /// # Example
160    ///
161    /// ```no_run
162    /// # use rointe_core::RointeClient;
163    /// # async fn run(client: &RointeClient) -> rointe_core::Result<()> {
164    /// let device = client.get_device("device-id").await?;
165    /// println!("{}: {:.1}°C ({})", device.data.name, device.data.temp,
166    ///     if device.data.power { "ON" } else { "OFF" });
167    /// # Ok(()) }
168    /// ```
169    pub async fn get_device(&self, device_id: &str) -> Result<RointeDevice> {
170        let token = self.token().await?;
171        let path = format!("/devices/{device_id}.json");
172        self.rtdb.get(&path, &token, &[]).await
173    }
174
175    /// Set the target temperature (switches to manual mode, powers on).
176    ///
177    /// # Example
178    ///
179    /// ```no_run
180    /// # use rointe_core::RointeClient;
181    /// # async fn run(client: &RointeClient) -> rointe_core::Result<()> {
182    /// client.set_temperature("device-id", 21.5).await?;
183    /// # Ok(()) }
184    /// ```
185    pub async fn set_temperature(&self, device_id: &str, temp: f64) -> Result<()> {
186        let token = self.token().await?;
187        let path = format!("/devices/{device_id}/data.json");
188        let now = Utc::now().timestamp_millis();
189
190        let body = json!({
191            "temp": temp,
192            "mode": "manual",
193            "power": true,
194            "last_sync_datetime_app": now,
195        });
196
197        self.rtdb.patch(&path, &token, &body).await
198    }
199
200    /// Activate a comfort preset (comfort / eco / ice).
201    ///
202    /// # Example
203    ///
204    /// ```no_run
205    /// # use rointe_core::{RointeClient, Preset};
206    /// # async fn run(client: &RointeClient) -> rointe_core::Result<()> {
207    /// client.set_preset("device-id", Preset::Eco).await?;
208    /// # Ok(()) }
209    /// ```
210    pub async fn set_preset(&self, device_id: &str, preset: Preset) -> Result<()> {
211        let device = self.get_device(device_id).await?;
212        let token = self.token().await?;
213        let path = format!("/devices/{device_id}/data.json");
214        let now = Utc::now().timestamp_millis();
215
216        let (temp, status) = match preset {
217            Preset::Comfort => (device.data.comfort, "comfort"),
218            Preset::Eco => (device.data.eco, "eco"),
219            Preset::Ice => (device.data.ice, "ice"),
220        };
221
222        let body = json!({
223            "power": true,
224            "mode": "manual",
225            "temp": temp,
226            "status": status,
227            "last_sync_datetime_app": now,
228        });
229
230        self.rtdb.patch(&path, &token, &body).await
231    }
232
233    /// Set the HVAC operating mode.
234    ///
235    /// - [`HvacMode::Off`]  — two-step power-off sequence
236    /// - [`HvacMode::Heat`] — two-step power-on (heats to comfort temperature)
237    /// - [`HvacMode::Auto`] — two-step switch to schedule-following auto mode
238    ///
239    /// # Example
240    ///
241    /// ```no_run
242    /// # use rointe_core::{RointeClient, HvacMode};
243    /// # async fn run(client: &RointeClient) -> rointe_core::Result<()> {
244    /// client.set_mode("device-id", HvacMode::Auto).await?;
245    /// # Ok(()) }
246    /// ```
247    pub async fn set_mode(&self, device_id: &str, mode: HvacMode) -> Result<()> {
248        let device = self.get_device(device_id).await?;
249        let token = self.token().await?;
250        let path = format!("/devices/{device_id}/data.json");
251
252        match mode {
253            HvacMode::Off => {
254                let now = Utc::now().timestamp_millis();
255                let step1 = json!({ "temp": 20, "last_sync_datetime_app": now });
256                self.rtdb.patch(&path, &token, &step1).await?;
257
258                let now2 = Utc::now().timestamp_millis();
259                let step2 = json!({
260                    "power": false,
261                    "mode": "manual",
262                    "status": "off",
263                    "last_sync_datetime_app": now2,
264                });
265                self.rtdb.patch(&path, &token, &step2).await
266            }
267
268            HvacMode::Heat => {
269                let now = Utc::now().timestamp_millis();
270                let step1 = json!({
271                    "temp": device.data.comfort,
272                    "last_sync_datetime_app": now,
273                });
274                self.rtdb.patch(&path, &token, &step1).await?;
275
276                let now2 = Utc::now().timestamp_millis();
277                let step2 = json!({
278                    "mode": "manual",
279                    "power": true,
280                    "status": "none",
281                    "last_sync_datetime_app": now2,
282                });
283                self.rtdb.patch(&path, &token, &step2).await
284            }
285
286            HvacMode::Auto => {
287                let schedule_temp = schedule_temp_for_now(&device);
288                let now = Utc::now().timestamp_millis();
289                let step1 = json!({
290                    "temp": schedule_temp,
291                    "last_sync_datetime_app": now,
292                });
293                self.rtdb.patch(&path, &token, &step1).await?;
294
295                let now2 = Utc::now().timestamp_millis();
296                let step2 = json!({
297                    "mode": "auto",
298                    "power": true,
299                    "last_sync_datetime_app": now2,
300                });
301                self.rtdb.patch(&path, &token, &step2).await
302            }
303        }
304    }
305
306    /// Return the most recent hourly energy stats for a device.
307    ///
308    /// Tries the current hour and walks back up to 5 hours to find a non-null
309    /// record. Returns an empty [`EnergyConsumptionData`] if no data is found
310    /// (not all device models report energy statistics).
311    ///
312    /// # Example
313    ///
314    /// ```no_run
315    /// # use rointe_core::RointeClient;
316    /// # async fn run(client: &RointeClient) -> rointe_core::Result<()> {
317    /// let stats = client.get_energy_stats("device-id").await?;
318    /// if let Some(kwh) = stats.kw_h {
319    ///     println!("Last hour: {kwh:.3} kWh");
320    /// }
321    /// # Ok(()) }
322    /// ```
323    pub async fn get_energy_stats(
324        &self,
325        device_id: &str,
326    ) -> Result<EnergyConsumptionData> {
327        let token = self.token().await?;
328        let now = Utc::now();
329
330        for hours_back in 0..=5i64 {
331            let dt = now - ChronoDuration::hours(hours_back);
332            let path = format!(
333                "/history_statistics/{}/daily/{}/{}/{}/energy/{}0000.json",
334                device_id,
335                dt.format("%Y"),
336                dt.format("%m"),
337                dt.format("%d"),
338                dt.format("%H"),
339            );
340
341            if let Ok(data) = self
342                .rtdb
343                .get::<EnergyConsumptionData>(&path, &token, &[])
344                .await
345            {
346                if data.kw_h.is_some() {
347                    return Ok(data);
348                }
349            }
350        }
351
352        Ok(EnergyConsumptionData {
353            kw_h: None,
354            effective_power: None,
355        })
356    }
357}
358
359// ── Helpers ──────────────────────────────────────────────────────────────────
360
361/// Recursively collect all device IDs from a zone and its sub-zones.
362fn collect_device_ids(zone: &Zone, device_ids: &mut Vec<String>) {
363    if let Some(devices) = &zone.devices {
364        device_ids.extend(devices.keys().cloned());
365    }
366    if let Some(sub_zones) = &zone.zones {
367        for sub_zone in sub_zones.values() {
368            collect_device_ids(sub_zone, device_ids);
369        }
370    }
371}
372
373/// Determine the temperature the device should target right now based on its
374/// weekly schedule, ice mode, and the current UTC time.
375///
376/// Split into a testable inner function that takes explicit day and hour.
377fn schedule_temp_for_now(device: &RointeDevice) -> f64 {
378    let now = Utc::now();
379    let day = now.weekday().num_days_from_monday() as usize; // 0 = Monday
380    let hour = now.hour() as usize;
381    schedule_temp(device, day, hour)
382}
383
384fn schedule_temp(device: &RointeDevice, day: usize, hour: usize) -> f64 {
385    if device.data.ice_mode {
386        return device.data.ice;
387    }
388
389    if let Some(schedule) = &device.data.schedule {
390        if let Some(day_str) = schedule.get(day) {
391            if let Some(slot) = day_str.chars().nth(hour) {
392                return match slot {
393                    'C' => device.data.comfort,
394                    'E' => device.data.eco,
395                    _ => 20.0,
396                };
397            }
398        }
399    }
400
401    20.0
402}
403
404// ── Tests ─────────────────────────────────────────────────────────────────────
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use crate::models::{
410        device::{DeviceData, FirmwareInfo, RointeDevice},
411        enums::{DeviceMode, DeviceStatus},
412    };
413
414    fn make_device(ice_mode: bool) -> RointeDevice {
415        RointeDevice {
416            data: DeviceData {
417                name: "Test Radiator".to_string(),
418                device_type: "radiator".to_string(),
419                product_version: Some("v2".to_string()),
420                nominal_power: Some(1500),
421                power: true,
422                mode: DeviceMode::Manual,
423                status: DeviceStatus::Comfort,
424                temp: 21.0,
425                temp_calc: None,
426                temp_probe: None,
427                comfort: 21.0,
428                eco: 18.0,
429                ice: 8.0,
430                ice_mode,
431                // "CCCCCCCCEEEEEEEEEEEEEECC": hours 0-7 = C, 8-21 = E, 22-23 = C
432                schedule: Some(vec![
433                    "CCCCCCCCEEEEEEEEEEEEEECC".to_string(),
434                    "CCCCCCCCEEEEEEEEEEEEEECC".to_string(),
435                    "CCCCCCCCEEEEEEEEEEEEEECC".to_string(),
436                    "CCCCCCCCEEEEEEEEEEEEEECC".to_string(),
437                    "CCCCCCCCEEEEEEEEEEEEEECC".to_string(),
438                    "CCCCCCCCCCCCCCCCCCCCCCCC".to_string(),
439                    "CCCCCCCCCCCCCCCCCCCCCCCC".to_string(),
440                ]),
441                schedule_day: Some(0),
442                schedule_hour: Some(0),
443                um_max_temp: Some(30.0),
444                um_min_temp: Some(7.0),
445                user_mode: Some(false),
446                last_sync_datetime_app: 1708360000000,
447                last_sync_datetime_device: None,
448            },
449            serialnumber: Some("ROINTE12345".to_string()),
450            firmware: Some(FirmwareInfo {
451                firmware_version_device: Some("3.2.1".to_string()),
452            }),
453        }
454    }
455
456    #[test]
457    fn test_schedule_temp_comfort_hour() {
458        let device = make_device(false);
459        // Monday hour 0 → 'C' → comfort (21.0)
460        assert_eq!(schedule_temp(&device, 0, 0), 21.0);
461        assert_eq!(schedule_temp(&device, 0, 7), 21.0);
462    }
463
464    #[test]
465    fn test_schedule_temp_eco_hour() {
466        let device = make_device(false);
467        // Monday hour 8 → 'E' → eco (18.0)
468        assert_eq!(schedule_temp(&device, 0, 8), 18.0);
469        assert_eq!(schedule_temp(&device, 0, 21), 18.0);
470    }
471
472    #[test]
473    fn test_schedule_temp_comfort_late_evening() {
474        let device = make_device(false);
475        // Monday hour 22 → 'C' → comfort (21.0)
476        assert_eq!(schedule_temp(&device, 0, 22), 21.0);
477        assert_eq!(schedule_temp(&device, 0, 23), 21.0);
478    }
479
480    #[test]
481    fn test_schedule_temp_ice_mode_overrides() {
482        let device = make_device(true);
483        // Ice mode always returns ice temp regardless of schedule
484        assert_eq!(schedule_temp(&device, 0, 0), 8.0);
485        assert_eq!(schedule_temp(&device, 5, 12), 8.0);
486    }
487
488    #[test]
489    fn test_schedule_temp_weekend_all_comfort() {
490        let device = make_device(false);
491        // Saturday (day 5) is all C's
492        for hour in 0..24 {
493            assert_eq!(schedule_temp(&device, 5, hour), 21.0);
494        }
495    }
496
497    #[test]
498    fn test_schedule_temp_no_schedule_fallback() {
499        let mut device = make_device(false);
500        device.data.schedule = None;
501        // No schedule → fallback 20.0
502        assert_eq!(schedule_temp(&device, 0, 12), 20.0);
503    }
504}