use std::sync::Arc;
use std::time::Duration;
use chrono::{Datelike, Duration as ChronoDuration, Timelike, Utc};
use reqwest::Client;
use serde_json::{json, Value};
use tokio::sync::Mutex;
use tracing::debug;
use crate::auth::FirebaseAuth;
use crate::error::{Result, RointeError};
use crate::firebase::rtdb::RtdbClient;
use crate::models::{
device::RointeDevice,
energy::EnergyConsumptionData,
enums::{HvacMode, Preset},
installation::{Installation, Zone},
};
pub struct RointeClient {
auth: Arc<Mutex<FirebaseAuth>>,
rtdb: RtdbClient,
}
impl RointeClient {
pub async fn new(email: &str, password: &str) -> Result<Self> {
let client = Client::builder()
.pool_max_idle_per_host(10)
.pool_idle_timeout(Duration::from_secs(90))
.tcp_keepalive(Duration::from_secs(60))
.build()
.map_err(RointeError::Network)?;
let auth = FirebaseAuth::login(client.clone(), email, password).await?;
let rtdb = RtdbClient::new(client);
Ok(Self {
auth: Arc::new(Mutex::new(auth)),
rtdb,
})
}
async fn token(&self) -> Result<String> {
self.auth.lock().await.ensure_valid_token().await
}
async fn local_id(&self) -> String {
self.auth.lock().await.local_id.clone()
}
pub async fn get_installations(&self) -> Result<Vec<Installation>> {
let token = self.token().await?;
let local_id = self.local_id().await;
let order_by = "\"userid\"";
let equal_to = format!("\"{}\"", local_id);
let raw: Value = self
.rtdb
.get(
"/installations2.json",
&token,
&[("orderBy", order_by), ("equalTo", &equal_to)],
)
.await?;
if raw.is_null() {
return Ok(vec![]);
}
let map: std::collections::HashMap<String, Value> =
serde_json::from_value(raw).map_err(|e| {
RointeError::Firebase(format!("Failed to parse installations: {e}"))
})?;
let installations = map
.into_iter()
.map(|(id, value)| {
let mut inst: Installation = serde_json::from_value(value).map_err(|e| {
RointeError::Firebase(format!("Failed to parse installation {id}: {e}"))
})?;
inst.id = id;
Ok(inst)
})
.collect::<Result<Vec<_>>>()?;
debug!("Found {} installation(s)", installations.len());
Ok(installations)
}
pub async fn discover_devices(&self, installation_id: &str) -> Result<Vec<String>> {
let installations = self.get_installations().await?;
let installation = installations
.into_iter()
.find(|i| i.id == installation_id)
.ok_or_else(|| RointeError::DeviceNotFound(installation_id.to_string()))?;
let mut device_ids = Vec::new();
if let Some(zones) = &installation.zones {
for zone in zones.values() {
collect_device_ids(zone, &mut device_ids);
}
}
debug!(
"Discovered {} device(s) in installation {installation_id}",
device_ids.len()
);
Ok(device_ids)
}
pub async fn get_device(&self, device_id: &str) -> Result<RointeDevice> {
let token = self.token().await?;
let path = format!("/devices/{device_id}.json");
self.rtdb.get(&path, &token, &[]).await
}
pub async fn set_temperature(&self, device_id: &str, temp: f64) -> Result<()> {
let token = self.token().await?;
let path = format!("/devices/{device_id}/data.json");
let now = Utc::now().timestamp_millis();
let body = json!({
"temp": temp,
"mode": "manual",
"power": true,
"last_sync_datetime_app": now,
});
self.rtdb.patch(&path, &token, &body).await
}
pub async fn set_preset(&self, device_id: &str, preset: Preset) -> Result<()> {
let device = self.get_device(device_id).await?;
let token = self.token().await?;
let path = format!("/devices/{device_id}/data.json");
let now = Utc::now().timestamp_millis();
let (temp, status) = match preset {
Preset::Comfort => (device.data.comfort, "comfort"),
Preset::Eco => (device.data.eco, "eco"),
Preset::Ice => (device.data.ice, "ice"),
};
let body = json!({
"power": true,
"mode": "manual",
"temp": temp,
"status": status,
"last_sync_datetime_app": now,
});
self.rtdb.patch(&path, &token, &body).await
}
pub async fn set_mode(&self, device_id: &str, mode: HvacMode) -> Result<()> {
let device = self.get_device(device_id).await?;
let token = self.token().await?;
let path = format!("/devices/{device_id}/data.json");
match mode {
HvacMode::Off => {
let now = Utc::now().timestamp_millis();
let step1 = json!({ "temp": 20, "last_sync_datetime_app": now });
self.rtdb.patch(&path, &token, &step1).await?;
let now2 = Utc::now().timestamp_millis();
let step2 = json!({
"power": false,
"mode": "manual",
"status": "off",
"last_sync_datetime_app": now2,
});
self.rtdb.patch(&path, &token, &step2).await
}
HvacMode::Heat => {
let now = Utc::now().timestamp_millis();
let step1 = json!({
"temp": device.data.comfort,
"last_sync_datetime_app": now,
});
self.rtdb.patch(&path, &token, &step1).await?;
let now2 = Utc::now().timestamp_millis();
let step2 = json!({
"mode": "manual",
"power": true,
"status": "none",
"last_sync_datetime_app": now2,
});
self.rtdb.patch(&path, &token, &step2).await
}
HvacMode::Auto => {
let schedule_temp = schedule_temp_for_now(&device);
let now = Utc::now().timestamp_millis();
let step1 = json!({
"temp": schedule_temp,
"last_sync_datetime_app": now,
});
self.rtdb.patch(&path, &token, &step1).await?;
let now2 = Utc::now().timestamp_millis();
let step2 = json!({
"mode": "auto",
"power": true,
"last_sync_datetime_app": now2,
});
self.rtdb.patch(&path, &token, &step2).await
}
}
}
pub async fn get_energy_stats(
&self,
device_id: &str,
) -> Result<EnergyConsumptionData> {
let token = self.token().await?;
let now = Utc::now();
for hours_back in 0..=5i64 {
let dt = now - ChronoDuration::hours(hours_back);
let path = format!(
"/history_statistics/{}/daily/{}/{}/{}/energy/{}0000.json",
device_id,
dt.format("%Y"),
dt.format("%m"),
dt.format("%d"),
dt.format("%H"),
);
if let Ok(data) = self
.rtdb
.get::<EnergyConsumptionData>(&path, &token, &[])
.await
{
if data.kw_h.is_some() {
return Ok(data);
}
}
}
Ok(EnergyConsumptionData {
kw_h: None,
effective_power: None,
})
}
}
fn collect_device_ids(zone: &Zone, device_ids: &mut Vec<String>) {
if let Some(devices) = &zone.devices {
device_ids.extend(devices.keys().cloned());
}
if let Some(sub_zones) = &zone.zones {
for sub_zone in sub_zones.values() {
collect_device_ids(sub_zone, device_ids);
}
}
}
fn schedule_temp_for_now(device: &RointeDevice) -> f64 {
let now = Utc::now();
let day = now.weekday().num_days_from_monday() as usize; let hour = now.hour() as usize;
schedule_temp(device, day, hour)
}
fn schedule_temp(device: &RointeDevice, day: usize, hour: usize) -> f64 {
if device.data.ice_mode {
return device.data.ice;
}
if let Some(schedule) = &device.data.schedule {
if let Some(day_str) = schedule.get(day) {
if let Some(slot) = day_str.chars().nth(hour) {
return match slot {
'C' => device.data.comfort,
'E' => device.data.eco,
_ => 20.0,
};
}
}
}
20.0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{
device::{DeviceData, FirmwareInfo, RointeDevice},
enums::{DeviceMode, DeviceStatus},
};
fn make_device(ice_mode: bool) -> RointeDevice {
RointeDevice {
data: DeviceData {
name: "Test Radiator".to_string(),
device_type: "radiator".to_string(),
product_version: Some("v2".to_string()),
nominal_power: Some(1500),
power: true,
mode: DeviceMode::Manual,
status: DeviceStatus::Comfort,
temp: 21.0,
temp_calc: None,
temp_probe: None,
comfort: 21.0,
eco: 18.0,
ice: 8.0,
ice_mode,
schedule: Some(vec![
"CCCCCCCCEEEEEEEEEEEEEECC".to_string(),
"CCCCCCCCEEEEEEEEEEEEEECC".to_string(),
"CCCCCCCCEEEEEEEEEEEEEECC".to_string(),
"CCCCCCCCEEEEEEEEEEEEEECC".to_string(),
"CCCCCCCCEEEEEEEEEEEEEECC".to_string(),
"CCCCCCCCCCCCCCCCCCCCCCCC".to_string(),
"CCCCCCCCCCCCCCCCCCCCCCCC".to_string(),
]),
schedule_day: Some(0),
schedule_hour: Some(0),
um_max_temp: Some(30.0),
um_min_temp: Some(7.0),
user_mode: Some(false),
last_sync_datetime_app: 1708360000000,
last_sync_datetime_device: None,
},
serialnumber: Some("ROINTE12345".to_string()),
firmware: Some(FirmwareInfo {
firmware_version_device: Some("3.2.1".to_string()),
}),
}
}
#[test]
fn test_schedule_temp_comfort_hour() {
let device = make_device(false);
assert_eq!(schedule_temp(&device, 0, 0), 21.0);
assert_eq!(schedule_temp(&device, 0, 7), 21.0);
}
#[test]
fn test_schedule_temp_eco_hour() {
let device = make_device(false);
assert_eq!(schedule_temp(&device, 0, 8), 18.0);
assert_eq!(schedule_temp(&device, 0, 21), 18.0);
}
#[test]
fn test_schedule_temp_comfort_late_evening() {
let device = make_device(false);
assert_eq!(schedule_temp(&device, 0, 22), 21.0);
assert_eq!(schedule_temp(&device, 0, 23), 21.0);
}
#[test]
fn test_schedule_temp_ice_mode_overrides() {
let device = make_device(true);
assert_eq!(schedule_temp(&device, 0, 0), 8.0);
assert_eq!(schedule_temp(&device, 5, 12), 8.0);
}
#[test]
fn test_schedule_temp_weekend_all_comfort() {
let device = make_device(false);
for hour in 0..24 {
assert_eq!(schedule_temp(&device, 5, hour), 21.0);
}
}
#[test]
fn test_schedule_temp_no_schedule_fallback() {
let mut device = make_device(false);
device.data.schedule = None;
assert_eq!(schedule_temp(&device, 0, 12), 20.0);
}
}