Files
gree-controller/src/api/history.rs
T
2026-09-18 10:27:39 +02:00

1257 lines
43 KiB
Rust

#[derive(Debug, Deserialize)]
struct ReadingsQuery {
device_id: Option<String>,
hours: Option<i64>,
limit: Option<u32>,
}
async fn readings(
State(state): State<AppState>,
Query(query): Query<ReadingsQuery>,
) -> Result<Json<Value>, 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<String>,
zone_id: Option<String>,
device_id: Option<String>,
entity_id: Option<String>,
hours: Option<i64>,
limit: Option<u32>,
}
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<Reading>) -> Vec<ZoneReading> {
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<Utc>,
bucket_seconds: i64,
limit: u32,
) -> Result<Vec<ZoneReading>, 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<String> =
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<Utc>,
bucket_seconds: i64,
limit: u32,
outdoor_entity: &str,
) -> Result<Vec<HaReading>, AppError> {
let mut values = state
.db
.list_ha_history(None, since.clone(), bucket_seconds, limit)?;
let mut existing: std::collections::HashSet<String> =
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<Utc>,
bucket_seconds: i64,
limit: u32,
) -> Result<(Vec<Reading>, String, Option<String>), 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<Utc>,
bucket_seconds: i64,
limit: u32,
) -> Result<(Vec<ZoneReading>, String, Option<String>), 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<Utc>,
bucket_seconds: i64,
limit: u32,
outdoor_entity: &str,
) -> Result<(Vec<HaReading>, String, Option<String>), 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<Vec<HaReading>, 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<T>(values: &mut Vec<T>, 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<AppState>,
Query(query): Query<HistoryQuery>,
) -> Result<Json<Value>, 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::<Vec<_>>();
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<String>,
series: Vec<String>,
hours: Option<i64>,
lang: Option<String>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct PublicCustomChartShare {
title: String,
series: Vec<String>,
hours: i64,
lang: String,
}
#[derive(Debug, Deserialize)]
struct PublicCustomChartQuery {
hours: Option<i64>,
}
#[derive(Debug, serde::Serialize)]
struct PublicChartPoint {
timestamp: chrono::DateTime<Utc>,
value: f64,
}
#[derive(Debug, serde::Serialize)]
struct PublicChartSeries {
key: String,
label: String,
label_key: &'static str,
dashed: bool,
points: Vec<PublicChartPoint>,
}
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<AppState>,
Json(input): Json<CreatePublicCustomChartRequest>,
) -> Result<Json<Value>, 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::<String>();
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<Reading>, field: &str) -> Vec<PublicChartPoint> {
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<ZoneReading>, field: &str) -> Vec<PublicChartPoint> {
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<AppState>,
Path(token): Path<String>,
Query(query): Query<PublicCustomChartQuery>,
) -> Result<Json<Value>, 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<AppState>) -> Result<Json<Value>, 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<String>,
/// Device id or `group:<installation-id>`.
target_id: Option<String>,
interval: Option<String>,
source: Option<String>,
days: Option<i64>,
limit: Option<u32>,
/// none, previous_day, previous_period or previous_year.
compare: Option<String>,
}
async fn energy_history(
State(state): State<AppState>,
Query(query): Query<EnergyHistoryQuery>,
) -> Result<Json<Value>, 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::<Utc>::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::<Utc>::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<String> = 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<Utc> {
chrono::DateTime::<Utc>::from_naive_utc_and_offset(
date.and_hms_opt(0, 0, 0).expect("valid midnight"),
Utc,
)
}
fn bucket_start(
timestamp: chrono::DateTime<Utc>,
interval_name: &str,
) -> chrono::DateTime<Utc> {
let date = timestamp.date_naive();
match interval_name {
"hourly" => chrono::DateTime::<Utc>::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<Item = &'a EnergyReading>,
interval_name: &str,
shift_days: i64,
) -> Vec<Value> {
let mut buckets: BTreeMap<chrono::DateTime<Utc>, 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::<Vec<_>>();
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::<Vec<_>>();
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<Utc>, stop: chrono::DateTime<Utc>| -> 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::<String> },
"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<String>,
hours: Option<i64>,
limit: Option<u32>,
}
async fn combined_network_history(
state: &AppState,
target_id: Option<&str>,
since: chrono::DateTime<Utc>,
bucket_seconds: i64,
limit: u32,
) -> Result<(Vec<NetworkReading>, String, Option<String>), 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<AppState>,
Query(query): Query<NetworkHistoryQuery>,
) -> Result<Json<Value>, 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::<Value>::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,
})))
}