#[derive(Debug, Deserialize)] struct ReadingsQuery { device_id: Option, hours: Option, limit: Option, } async fn readings( State(state): State, Query(query): Query, ) -> Result, AppError> { let hours = query.hours.unwrap_or(24).clamp(1, 24 * 3650); let values = state.db.list_readings( query.device_id.as_deref(), Utc::now() - ChronoDuration::hours(hours), query.limit.unwrap_or(1500), )?; Ok(Json(json!({"readings": values}))) } #[derive(Debug, Deserialize)] struct HistoryQuery { scope: Option, zone_id: Option, device_id: Option, entity_id: Option, hours: Option, limit: Option, } fn history_bucket_seconds(hours: i64) -> i64 { match hours { 1..=6 => 30, 7..=24 => 120, 25..=168 => 600, 169..=720 => 1800, 721..=2160 => 7200, 2161..=8760 => 21600, _ => 86400, } } fn fallback_zone_rows(zone: &Zone, device: &Device, readings: Vec) -> Vec { readings .into_iter() .map(|reading| ZoneReading { id: reading.id, zone_id: zone.id.clone(), device_id: zone.device_id.clone(), timestamp: reading.timestamp, gree_temperature: reading.indoor_temperature, external_temperature: None, control_temperature: reading.indoor_temperature, target_temperature: Some(reading.target_temperature), device_setpoint: Some(reading.target_temperature), outdoor_temperature: reading.outdoor_temperature, power: reading.power, mode: device.mode.clone(), fan_speed: device.fan_speed, demand: false, control_source: "gree_history_fallback".into(), active_preset: "history".into(), }) .collect() } fn zone_history_with_fallback( state: &AppState, zone_id: Option<&str>, since: chrono::DateTime, bucket_seconds: i64, limit: u32, ) -> Result, AppError> { let mut values = state .db .list_zone_history(zone_id, since.clone(), bucket_seconds, limit)?; if let Some(zone_id) = zone_id { if values.is_empty() { let zone = state .db .get_zone(zone_id)? .ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?; if let Some(device) = state.db.get_device(&zone.device_id)? { let rows = state.db.list_device_history( Some(&zone.device_id), since.clone(), bucket_seconds, limit, )?; values = fallback_zone_rows(&zone, &device, rows); } } return Ok(values); } let existing: std::collections::HashSet = values.iter().map(|row| row.zone_id.clone()).collect(); for zone in state.db.list_zones()? { if existing.contains(&zone.id) { continue; } let Some(device) = state.db.get_device(&zone.device_id)? else { continue; }; let rows = state.db.list_device_history( Some(&zone.device_id), since.clone(), bucket_seconds, limit, )?; values.extend(fallback_zone_rows(&zone, &device, rows)); } values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp)); if values.len() > limit as usize { let keep_from = values.len() - limit as usize; values.drain(0..keep_from); } Ok(values) } fn sensor_history_with_fallback( state: &AppState, since: chrono::DateTime, bucket_seconds: i64, limit: u32, outdoor_entity: &str, ) -> Result, AppError> { let mut values = state .db .list_ha_history(None, since.clone(), bucket_seconds, limit)?; let mut existing: std::collections::HashSet = values.iter().map(|row| row.entity_id.clone()).collect(); for zone in state.db.list_zones()? { if !matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { continue; } let entity_id = zone.ha_entity_id.as_deref().map(str::trim).unwrap_or(""); if entity_id.is_empty() { continue; } if existing.contains(entity_id) { continue; } let rows = state .db .list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?; let mut added = false; for row in rows { if let Some(temperature) = row.external_temperature { values.push(HaReading { id: row.id, entity_id: entity_id.to_string(), zone_id: Some(zone.id.clone()), kind: "room".into(), timestamp: row.timestamp, temperature, }); added = true; } } if added { existing.insert(entity_id.to_string()); } } let global_outdoor_entity = outdoor_entity.trim(); for zone in state.db.list_zones()? { let zone_override = zone .ha_outdoor_entity_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); let entity_id = zone_override.unwrap_or(global_outdoor_entity); if entity_id.is_empty() || existing.contains(entity_id) { continue; } let rows = state .db .list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?; let mut added = false; for row in rows { if let Some(temperature) = row.outdoor_temperature { values.push(HaReading { id: row.id, entity_id: entity_id.to_string(), zone_id: zone_override.map(|_| zone.id.clone()), kind: "outdoor".into(), timestamp: row.timestamp, temperature, }); added = true; } } if added { existing.insert(entity_id.to_string()); } } values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp)); if values.len() > limit as usize { let keep_from = values.len() - limit as usize; values.drain(0..keep_from); } Ok(values) } async fn combined_device_history( state: &AppState, device_id: Option<&str>, since: chrono::DateTime, bucket_seconds: i64, limit: u32, ) -> Result<(Vec, String, Option), AppError> { let influx = state.settings.read().await.influxdb.clone(); let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64); if !influx.enabled || since >= cutoff { return Ok(( state .db .list_device_history(device_id, since, bucket_seconds, limit)?, "sqlite".into(), None, )); } let mut warning = None; let mut values = match influxdb::query_devices( &state.http, &influx, device_id, since, cutoff, bucket_seconds, limit, ) .await { Ok(rows) => rows, Err(err) => { warning = Some(err.to_string()); state.log( "warn", "influx.query_error", "InfluxDB device history query failed", json!({"error": err.to_string()}), ); state .db .list_device_history(device_id, since, bucket_seconds, limit)? } }; if warning.is_none() { values.extend( state .db .list_device_history(device_id, cutoff, bucket_seconds, limit)?, ); } values.sort_by_key(|row| row.timestamp); trim_history(&mut values, limit); let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" }; Ok((values, source.into(), warning)) } async fn combined_zone_history( state: &AppState, zone_id: Option<&str>, since: chrono::DateTime, bucket_seconds: i64, limit: u32, ) -> Result<(Vec, String, Option), AppError> { let influx = state.settings.read().await.influxdb.clone(); let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64); if !influx.enabled || since >= cutoff { return Ok(( zone_history_with_fallback(state, zone_id, since, bucket_seconds, limit)?, "sqlite".into(), None, )); } let mut warning = None; let mut values = match influxdb::query_zones( &state.http, &influx, zone_id, since, cutoff, bucket_seconds, limit, ) .await { Ok(rows) => rows, Err(err) => { warning = Some(err.to_string()); state.log( "warn", "influx.query_error", "InfluxDB zone history query failed", json!({"error": err.to_string()}), ); zone_history_with_fallback(state, zone_id, since, bucket_seconds, limit)? } }; if warning.is_none() { values.extend(zone_history_with_fallback( state, zone_id, cutoff, bucket_seconds, limit, )?); } values.sort_by_key(|row| row.timestamp); trim_history(&mut values, limit); let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" }; Ok((values, source.into(), warning)) } async fn combined_sensor_history( state: &AppState, entity_id: Option<&str>, since: chrono::DateTime, bucket_seconds: i64, limit: u32, outdoor_entity: &str, ) -> Result<(Vec, String, Option), AppError> { let influx = state.settings.read().await.influxdb.clone(); let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64); let local = |start| -> Result, AppError> { if entity_id.is_some() { Ok(state .db .list_ha_history(entity_id, start, bucket_seconds, limit)?) } else { sensor_history_with_fallback(state, start, bucket_seconds, limit, outdoor_entity) } }; if !influx.enabled || since >= cutoff { return Ok((local(since)?, "sqlite".into(), None)); } let mut warning = None; let mut values = match influxdb::query_ha( &state.http, &influx, entity_id, since, cutoff, bucket_seconds, limit, ) .await { Ok(rows) => rows, Err(err) => { warning = Some(err.to_string()); state.log( "warn", "influx.query_error", "InfluxDB HA history query failed", json!({"error": err.to_string()}), ); local(since)? } }; if warning.is_none() { values.extend(local(cutoff)?); } values.sort_by_key(|row| row.timestamp); trim_history(&mut values, limit); let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" }; Ok((values, source.into(), warning)) } fn trim_history(values: &mut Vec, limit: u32) { if values.len() > limit as usize { let keep_from = values.len() - limit as usize; values.drain(0..keep_from); } } async fn history( State(state): State, Query(query): Query, ) -> Result, AppError> { let hours = query.hours.unwrap_or(24).clamp(1, 24 * 3650); let since = Utc::now() - ChronoDuration::hours(hours); let bucket_seconds = history_bucket_seconds(hours); let limit = query.limit.unwrap_or(12_000).clamp(1, 20_000); let scope = query.scope.as_deref().unwrap_or("zones"); let ha_settings = state.settings.read().await.home_assistant.clone(); let outdoor_entity = ha_settings.outdoor_entity_id; let (device_count, zone_count, ha_count) = state.db.history_counts()?; match scope { "devices" => { let device_id = query .device_id .as_deref() .filter(|value| !value.is_empty() && *value != "all"); let (readings, storage, warning) = combined_device_history(&state, device_id, since, bucket_seconds, limit).await?; Ok(Json(json!({ "scope": "devices", "readings": readings, "bucket_seconds": bucket_seconds, "storage": storage, "storage_warning": warning, "counts": {"devices": device_count, "zones": zone_count, "ha": ha_count} }))) } "sensors" => { let entity_id = query .entity_id .as_deref() .filter(|value| !value.is_empty() && *value != "all"); let (readings, storage, warning) = combined_sensor_history( &state, entity_id, since, bucket_seconds, limit, &outdoor_entity, ) .await?; Ok(Json(json!({ "scope": "sensors", "readings": readings, "bucket_seconds": bucket_seconds, "storage": storage, "storage_warning": warning, "counts": {"devices": device_count, "zones": zone_count, "ha": ha_count} }))) } "overview" => { let (zones, zone_storage, zone_warning) = combined_zone_history(&state, None, since, bucket_seconds, limit).await?; let (devices, device_storage, device_warning) = combined_device_history(&state, None, since, bucket_seconds, limit).await?; let (sensors, sensor_storage, sensor_warning) = combined_sensor_history( &state, None, since, bucket_seconds, limit, &outdoor_entity, ) .await?; let storage_warning = [zone_warning, device_warning, sensor_warning] .into_iter() .flatten() .collect::>(); Ok(Json(json!({ "scope": "overview", "bucket_seconds": bucket_seconds, "zones": zones, "devices": devices, "sensors": sensors, "storage": {"zones": zone_storage, "devices": device_storage, "sensors": sensor_storage}, "storage_warning": storage_warning, "counts": {"devices": device_count, "zones": zone_count, "ha": ha_count} }))) } "zones" | "zone" => { let zone_id = query .zone_id .as_deref() .filter(|value| !value.is_empty() && *value != "all"); if let Some(zone_id) = zone_id { if state.db.get_zone(zone_id)?.is_none() { return Err(AppError::NotFound(format!("zone {zone_id}"))); } } let (readings, storage, warning) = combined_zone_history(&state, zone_id, since, bucket_seconds, limit).await?; Ok(Json(json!({ "scope": "zones", "readings": readings, "bucket_seconds": bucket_seconds, "storage": storage, "storage_warning": warning, "counts": {"devices": device_count, "zones": zone_count, "ha": ha_count} }))) } _ => Err(AppError::BadRequest( "history scope must be overview, zones, devices or sensors".into(), )), } } #[derive(Debug, Deserialize)] struct CreatePublicCustomChartRequest { title: Option, series: Vec, hours: Option, lang: Option, } #[derive(Debug, serde::Serialize, serde::Deserialize)] struct PublicCustomChartShare { title: String, series: Vec, hours: i64, lang: String, } #[derive(Debug, Deserialize)] struct PublicCustomChartQuery { hours: Option, } #[derive(Debug, serde::Serialize)] struct PublicChartPoint { timestamp: chrono::DateTime, value: f64, } #[derive(Debug, serde::Serialize)] struct PublicChartSeries { key: String, label: String, label_key: &'static str, dashed: bool, points: Vec, } fn validate_public_chart_spec(series: &[String]) -> Result<(), AppError> { if series.is_empty() || series.len() > 16 || series .iter() .any(|item| item.is_empty() || item.len() > 256) { return Err(AppError::BadRequest( "invalid custom chart definition".into(), )); } for key in series { let mut parts = key.splitn(3, '|'); let kind = parts.next().unwrap_or_default(); let id = parts.next().unwrap_or_default(); let field = parts.next().unwrap_or_default(); if id.trim().is_empty() || public_chart_field_label_key(kind, field).is_none() { return Err(AppError::BadRequest(format!( "unsupported custom chart series: {key}" ))); } } Ok(()) } fn generate_public_chart_token() -> String { let mut bytes = [0u8; 32]; rand::fill(&mut bytes); format!("chart_{}", URL_SAFE_NO_PAD.encode(bytes)) } async fn create_public_custom_chart( State(state): State, Json(input): Json, ) -> Result, AppError> { validate_public_chart_spec(&input.series)?; let hours = input.hours.unwrap_or(24).clamp(1, 24 * 3650); let requested_lang = input.lang.as_deref().unwrap_or_default().trim(); let lang = LANGUAGE_ASSETS .iter() .find(|(code, _)| *code == requested_lang) .map(|(code, _)| (*code).to_string()) .unwrap_or_else(|| DEFAULT_LANGUAGE_CODE.to_string()); let title = input .title .as_deref() .map(str::trim) .unwrap_or_default() .chars() .take(120) .collect::(); let share = PublicCustomChartShare { title, series: input.series, hours, lang, }; let token = generate_public_chart_token(); let token_hash = hash_token(&token); state .db .save_public_chart_share(&token_hash, &serde_json::to_value(&share)?)?; Ok(Json(json!({ "path": format!("/charts/custom/{token}") }))) } fn public_chart_field_label_key(kind: &str, field: &str) -> Option<&'static str> { match (kind, field) { ("device", "indoor") => Some("publicChart.field.deviceIndoor"), ("device", "outdoor") => Some("publicChart.field.deviceOutdoor"), ("device", "target") => Some("publicChart.field.deviceTarget"), ("installation", "outdoor") => Some("publicChart.field.sharedOutdoor"), ("zone", "control") => Some("publicChart.field.zoneControl"), ("zone", "gree") => Some("publicChart.field.greeSensor"), ("zone", "external") => Some("publicChart.field.roomSensor"), ("zone", "target") => Some("publicChart.field.comfortTarget"), ("zone", "device_target") => Some("publicChart.field.deviceSetpoint"), ("zone", "outdoor") => Some("publicChart.field.outdoor"), ("ha", "temperature") => Some("publicChart.field.temperature"), _ => None, } } fn reading_points(rows: Vec, field: &str) -> Vec { rows.into_iter() .filter_map(|row| { let value = match field { "indoor" => row.indoor_temperature, "outdoor" => row.outdoor_temperature, "target" => Some(row.target_temperature), _ => None, }?; value.is_finite().then_some(PublicChartPoint { timestamp: row.timestamp, value, }) }) .collect() } fn zone_reading_points(rows: Vec, field: &str) -> Vec { rows.into_iter() .filter_map(|row| { let value = match field { "control" => row.control_temperature, "gree" => row.gree_temperature, "external" => row.external_temperature, "target" => row.target_temperature, "device_target" => row.device_setpoint, "outdoor" => row.outdoor_temperature, _ => None, }?; value.is_finite().then_some(PublicChartPoint { timestamp: row.timestamp, value, }) }) .collect() } async fn public_custom_chart( State(state): State, Path(token): Path, Query(query): Query, ) -> Result, AppError> { if token.len() < 32 || token.len() > 128 || !token.starts_with("chart_") { return Err(AppError::NotFound("chart id not found".into())); } let payload = state .db .get_public_chart_share(&hash_token(&token))? .ok_or_else(|| AppError::NotFound("chart id not found".into()))?; let share: PublicCustomChartShare = serde_json::from_value(payload)?; validate_public_chart_spec(&share.series)?; let hours = query.hours.unwrap_or(share.hours).clamp(1, 24 * 3650); let lang = LANGUAGE_ASSETS .iter() .find(|(code, _)| *code == share.lang.as_str()) .map(|(code, _)| *code) .unwrap_or(DEFAULT_LANGUAGE_CODE); let since = Utc::now() - ChronoDuration::hours(hours); let bucket_seconds = history_bucket_seconds(hours); let limit = 20_000; let ha_settings = state.settings.read().await.home_assistant.clone(); let outdoor_entity = ha_settings.outdoor_entity_id.clone(); let mut series = Vec::with_capacity(share.series.len()); for key in share.series { let mut parts = key.splitn(3, '|'); let kind = parts.next().unwrap_or_default(); let id = parts.next().unwrap_or_default(); let field = parts.next().unwrap_or_default(); let label_key = public_chart_field_label_key(kind, field).ok_or_else(|| { AppError::BadRequest(format!("unsupported custom chart series: {key}")) })?; let item = match kind { "device" => { let device = state .db .get_device(id)? .ok_or_else(|| AppError::NotFound(format!("device {id}")))?; let (rows, _, _) = combined_device_history(&state, Some(id), since.clone(), bucket_seconds, limit) .await?; PublicChartSeries { key: key.clone(), label: device.name, label_key, dashed: field == "target", points: reading_points(rows, field), } } "installation" => { let group = state .db .get_device_group(id)? .ok_or_else(|| AppError::NotFound(format!("device group {id}")))?; let representative = group .outdoor_temperature_device_id .as_deref() .filter(|device_id| { group .device_ids .iter() .any(|member| member.as_str() == *device_id) }) .or_else(|| group.device_ids.first().map(String::as_str)) .ok_or_else(|| { AppError::BadRequest(format!("device group {id} has no devices")) })?; let (rows, _, _) = combined_device_history( &state, Some(representative), since.clone(), bucket_seconds, limit, ) .await?; PublicChartSeries { key: key.clone(), label: group.name, label_key, dashed: false, points: reading_points(rows, "outdoor"), } } "zone" => { let zone = state .db .get_zone(id)? .ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; let (rows, _, _) = combined_zone_history(&state, Some(id), since.clone(), bucket_seconds, limit) .await?; PublicChartSeries { key: key.clone(), label: zone.name, label_key, dashed: matches!(field, "target" | "device_target" | "outdoor"), points: zone_reading_points(rows, field), } } "ha" => { let (rows, _, _) = combined_sensor_history( &state, Some(id), since.clone(), bucket_seconds, limit, &outdoor_entity, ) .await?; let alias = ha_settings .sensor_aliases .get(id) .map(String::as_str) .filter(|value| !value.trim().is_empty()) .unwrap_or(id); PublicChartSeries { key: key.clone(), label: format!("HA ยท {}", alias), label_key, dashed: true, points: rows .into_iter() .filter(|row| row.entity_id == id && row.temperature.is_finite()) .map(|row| PublicChartPoint { timestamp: row.timestamp, value: row.temperature, }) .collect(), } } _ => { return Err(AppError::BadRequest(format!( "unsupported custom chart series: {key}" ))) } }; series.push(item); } Ok(Json(json!({ "title": share.title, "lang": lang, "hours": hours, "bucket_seconds": bucket_seconds, "series": series, }))) } async fn control_plan(State(state): State) -> Result, AppError> { let snapshot = engine::get_control_plan_snapshot(&state).await?; Ok(Json(serde_json::to_value(snapshot.plan.as_ref())?)) } #[derive(Debug, Deserialize)] struct EnergyHistoryQuery { /// Backward-compatible single-device selector. device_id: Option, /// Device id or `group:`. target_id: Option, interval: Option, source: Option, days: Option, limit: Option, /// none, previous_day, previous_period or previous_year. compare: Option, } async fn energy_history( State(state): State, Query(query): Query, ) -> Result, AppError> { use chrono::{Datelike, NaiveDate, Timelike, Weekday}; use std::collections::BTreeMap; let target_id = query .target_id .as_deref() .or(query.device_id.as_deref()) .filter(|value| !value.trim().is_empty()) .ok_or_else(|| AppError::BadRequest("energy target_id is required".into()))?; let interval_name = query.interval.as_deref().unwrap_or("daily"); if !matches!(interval_name, "hourly" | "daily" | "weekly" | "monthly") { return Err(AppError::BadRequest( "energy interval must be hourly, daily, weekly or monthly".into(), )); } let days = query.days.unwrap_or(31).clamp(1, 3650); let compare_name = query.compare.as_deref().unwrap_or("none"); let compare_shift_days = match compare_name { "none" => None, "previous_day" => Some(1), "previous_period" => Some(days), "previous_year" => Some(365), _ => { return Err(AppError::BadRequest( "energy compare must be none, previous_day, previous_period or previous_year" .into(), )) } }; let requested_source = query.source.as_deref().unwrap_or("auto"); if !matches!(requested_source, "auto" | "gree_cloud" | "home_assistant") { return Err(AppError::BadRequest( "energy source must be auto, gree_cloud or home_assistant".into(), )); } let ( target_type, public_target_id, target_name, member_device_ids, configured_source, storage_id, preferred_source, ) = if let Some(group_id) = target_id.strip_prefix("group:") { let group = state .db .get_device_group(group_id)? .ok_or_else(|| AppError::NotFound(format!("device group {group_id}")))?; let auto_source = if group.energy_device_id.is_some() { Some("gree_cloud") } else if group.ha_energy_entity_id.is_some() { Some("home_assistant") } else { None }; let selected = match requested_source { "gree_cloud" => Some("gree_cloud"), "home_assistant" => Some("home_assistant"), _ => match group.energy_source { EnergySourcePreference::GreeCloud => Some("gree_cloud"), EnergySourcePreference::HomeAssistant => Some("home_assistant"), EnergySourcePreference::Auto => auto_source, }, }; let storage_id = match selected { Some("gree_cloud") => group.energy_device_id.clone().ok_or_else(|| { AppError::BadRequest("installation has no GREE Cloud energy source device".into()) })?, _ => format!("group:{}", group.id), }; ( "group", format!("group:{}", group.id), group.name, group.device_ids, group.energy_source, storage_id, selected, ) } else { let device = state .db .get_device(target_id)? .ok_or_else(|| AppError::NotFound(format!("device {target_id}")))?; let selected = match requested_source { "gree_cloud" => Some("gree_cloud"), "home_assistant" => Some("home_assistant"), "auto" => match device.energy_source { EnergySourcePreference::GreeCloud => Some("gree_cloud"), EnergySourcePreference::HomeAssistant => Some("home_assistant"), EnergySourcePreference::Auto => None, }, _ => None, }; ( "device", device.id.clone(), device.name, vec![device.id.clone()], device.energy_source, device.id, selected, ) }; let now = Utc::now(); let since = now - ChronoDuration::days(days); let comparison_since = compare_shift_days.map(|shift| since - ChronoDuration::days(shift)); let month_start_for_load = chrono::DateTime::::from_naive_utc_and_offset( chrono::NaiveDate::from_ymd_opt(now.year(), now.month(), 1) .expect("valid current month") .and_hms_opt(0, 0, 0) .expect("valid midnight"), Utc, ); let previous_month_date_for_load = month_start_for_load.date_naive() - ChronoDuration::days(1); let previous_month_start_for_load = chrono::DateTime::::from_naive_utc_and_offset( chrono::NaiveDate::from_ymd_opt( previous_month_date_for_load.year(), previous_month_date_for_load.month(), 1, ) .expect("valid previous month") .and_hms_opt(0, 0, 0) .expect("valid midnight"), Utc, ); let mut load_since = since.min(previous_month_start_for_load); if let Some(compare_since) = comparison_since { load_since = load_since.min(compare_since); } let limit = query.limit.unwrap_or(100_000).clamp(1, 200_000); let influx = state.settings.read().await.influxdb.clone(); let cutoff = now - ChronoDuration::days(influx.history_threshold_days as i64); let mut storage = "sqlite".to_string(); let mut storage_warning: Option = None; let mut samples = if influx.enabled && load_since < cutoff { match influxdb::query_energy( &state.http, &influx, &storage_id, None, load_since, cutoff, 3600, limit, ) .await { Ok(mut archived) => { archived.extend(state.db.list_energy_readings(&storage_id, cutoff, limit)?); storage = "influx+sqlite".into(); archived } Err(err) => { storage = "sqlite_fallback".into(); storage_warning = Some(err.to_string()); state.log( "warn", "influx.query_error", "InfluxDB energy history query failed", json!({"target_id": public_target_id, "storage_id": storage_id, "error": err.to_string()}), ); state .db .list_energy_readings(&storage_id, load_since, limit)? } } } else { state .db .list_energy_readings(&storage_id, load_since, limit)? }; samples.sort_by_key(|row| row.timestamp); let selected_source = if let Some(source) = preferred_source { Some(source) } else if requested_source == "auto" { if samples.iter().any(|row| row.source == "gree_cloud") { Some("gree_cloud") } else if samples.iter().any(|row| row.source == "home_assistant") { Some("home_assistant") } else { None } } else { Some(requested_source) }; if let Some(source) = selected_source { samples.retain(|row| row.source == source); } else { samples.clear(); } fn midnight(date: NaiveDate) -> chrono::DateTime { chrono::DateTime::::from_naive_utc_and_offset( date.and_hms_opt(0, 0, 0).expect("valid midnight"), Utc, ) } fn bucket_start( timestamp: chrono::DateTime, interval_name: &str, ) -> chrono::DateTime { let date = timestamp.date_naive(); match interval_name { "hourly" => chrono::DateTime::::from_naive_utc_and_offset( date.and_hms_opt(timestamp.hour(), 0, 0) .expect("valid hour"), Utc, ), "weekly" => { let iso = date.iso_week(); midnight( NaiveDate::from_isoywd_opt(iso.year(), iso.week(), Weekday::Mon) .expect("valid ISO week"), ) } "monthly" => midnight( NaiveDate::from_ymd_opt(date.year(), date.month(), 1).expect("valid month"), ), _ => midnight(date), } } fn bucket_rows<'a>( rows: impl Iterator, interval_name: &str, shift_days: i64, ) -> Vec { let mut buckets: BTreeMap, f64> = BTreeMap::new(); for sample in rows { let shifted = sample.timestamp + ChronoDuration::days(shift_days); *buckets .entry(bucket_start(shifted, interval_name)) .or_default() += sample.consumption_kwh.max(0.0); } buckets.into_iter().map(|(start, consumption_kwh)| json!({"start": start, "consumption_kwh": consumption_kwh.max(0.0)})).collect() } let period_samples = samples .iter() .filter(|row| row.timestamp >= since && row.timestamp <= now) .collect::>(); let buckets = bucket_rows(period_samples.iter().copied(), interval_name, 0); let comparison = if let Some(shift) = compare_shift_days { let compare_end = now - ChronoDuration::days(shift); let compare_start = since - ChronoDuration::days(shift); let rows = samples .iter() .filter(|row| row.timestamp >= compare_start && row.timestamp <= compare_end) .collect::>(); let total: f64 = rows.iter().map(|row| row.consumption_kwh.max(0.0)).sum(); Some(json!({ "kind": compare_name, "shift_days": shift, "period_start": compare_start, "period_end": compare_end, "period_total": total, "buckets": bucket_rows(rows.iter().copied(), interval_name, shift), })) } else { None }; let today_start = midnight(now.date_naive()); let yesterday_start = today_start - ChronoDuration::days(1); let month_start = midnight(NaiveDate::from_ymd_opt(now.year(), now.month(), 1).expect("valid current month")); let previous_month_date = month_start.date_naive() - ChronoDuration::days(1); let previous_month_start = midnight( NaiveDate::from_ymd_opt(previous_month_date.year(), previous_month_date.month(), 1) .expect("valid previous month"), ); let sum_range = |start: chrono::DateTime, stop: chrono::DateTime| -> f64 { samples .iter() .filter(|row| row.timestamp >= start && row.timestamp < stop) .map(|row| row.consumption_kwh.max(0.0)) .sum() }; let period_total: f64 = period_samples .iter() .map(|row| row.consumption_kwh.max(0.0)) .sum(); let latest = samples.last().cloned(); Ok(Json(json!({ "target_id": public_target_id, "target_type": target_type, "target_name": target_name, "member_device_ids": member_device_ids, "device_id": if target_type == "device" { Some(public_target_id.clone()) } else { None:: }, "source": selected_source.unwrap_or("none"), "configured_source": configured_source, "interval": interval_name, "unit": "kWh", "period_days": days, "storage": storage, "storage_warning": storage_warning, "buckets": buckets, "comparison": comparison, "summary": { "today": sum_range(today_start, now + ChronoDuration::seconds(1)), "yesterday": sum_range(yesterday_start, today_start), "current_month": sum_range(month_start, now + ChronoDuration::seconds(1)), "previous_month": sum_range(previous_month_start, month_start), "period_total": period_total, }, "latest": latest, }))) } #[derive(Debug, Deserialize)] struct NetworkHistoryQuery { target_id: Option, hours: Option, limit: Option, } async fn combined_network_history( state: &AppState, target_id: Option<&str>, since: chrono::DateTime, bucket_seconds: i64, limit: u32, ) -> Result<(Vec, String, Option), AppError> { let influx = state.settings.read().await.influxdb.clone(); let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64); if !influx.enabled || since >= cutoff { return Ok(( state .db .list_network_history(target_id, since, bucket_seconds, limit)?, "sqlite".into(), None, )); } let mut warning = None; let mut values = match influxdb::query_network( &state.http, &influx, target_id, since, cutoff, bucket_seconds, limit, ) .await { Ok(rows) => rows, Err(err) => { warning = Some(err.to_string()); state.log( "warn", "influx.query_error", "InfluxDB connectivity history query failed", json!({"target_id": target_id, "error": err.to_string()}), ); state .db .list_network_history(target_id, since, bucket_seconds, limit)? } }; if warning.is_none() { values.extend( state .db .list_network_history(target_id, cutoff, bucket_seconds, limit)?, ); } values.sort_by_key(|row| row.timestamp); trim_history(&mut values, limit); Ok(( values, if warning.is_some() { "sqlite_fallback".into() } else { "influx+sqlite".into() }, warning, )) } async fn network_history( State(state): State, Query(query): Query, ) -> Result, AppError> { let hours = query.hours.unwrap_or(24).clamp(1, 24 * 3650); let since = Utc::now() - ChronoDuration::hours(hours); let bucket_seconds = history_bucket_seconds(hours); let limit = query.limit.unwrap_or(20_000).clamp(1, 50_000); let target_id = query .target_id .as_deref() .filter(|value| !value.is_empty() && *value != "all"); let (readings, storage, warning) = combined_network_history(&state, target_id, since, bucket_seconds, limit).await?; let settings = state.settings.read().await.clone(); let mut targets = Vec::::new(); for device in state.db.list_devices()? { if device.enabled && device.connection_type == ConnectionType::Local && !device.simulated { targets.push(json!({ "id": device.id, "name": device.name, "kind": "device", "source": "local_udp", })); } } let has_rest = readings.iter().any(|row| row.target_id == "cloud:rest"); let has_mqtt = readings.iter().any(|row| row.target_id == "cloud:mqtt"); if (settings.gree_cloud.enabled && settings.gree_cloud.connectivity_metrics_enabled) || has_rest { targets.push(json!({"id":"cloud:rest","name":"GREE Cloud REST","kind":"cloud_service","source":"cloud_rest"})); } if (settings.gree_cloud.enabled && settings.gree_cloud.connectivity_metrics_enabled) || has_mqtt { targets.push(json!({"id":"cloud:mqtt","name":"GREE Cloud MQTT","kind":"cloud_service","source":"cloud_mqtt"})); } Ok(Json(json!({ "readings": readings, "targets": targets, "bucket_seconds": bucket_seconds, "storage": storage, "storage_warning": warning, }))) }