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
20pub struct RointeClient {
26 auth: Arc<Mutex<FirebaseAuth>>,
27 rtdb: RtdbClient,
28}
29
30impl RointeClient {
31 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 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 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 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 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 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 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 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 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 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
359fn 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
373fn schedule_temp_for_now(device: &RointeDevice) -> f64 {
378 let now = Utc::now();
379 let day = now.weekday().num_days_from_monday() as usize; 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#[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 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 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 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 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 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 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 assert_eq!(schedule_temp(&device, 0, 12), 20.0);
503 }
504}