v0.8.14
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
#[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()? {
|
||||
let Some(entity_id) = zone.ha_entity_id.as_deref().filter(|value| !value.trim().is_empty()) else { 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 outdoor_entity = outdoor_entity.trim();
|
||||
if !outdoor_entity.is_empty() && !existing.contains(outdoor_entity) {
|
||||
for zone in state.db.list_zones()? {
|
||||
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: outdoor_entity.to_string(), zone_id: None, kind: "outdoor".into(), timestamp: row.timestamp, temperature });
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
if added { break; }
|
||||
}
|
||||
}
|
||||
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 outdoor_entity = state.settings.read().await.home_assistant.outdoor_entity_id.clone();
|
||||
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())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn control_plan(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
|
||||
Ok(Json(serde_json::to_value(engine::build_control_plan(&state).await?)?))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user