This commit is contained in:
Mateusz Gruszczyński
2026-08-24 00:23:47 +02:00
parent 6e3075fae5
commit 10bc9aa099
21 changed files with 1110 additions and 260 deletions
+178 -18
View File
@@ -21,7 +21,7 @@ use crate::{
engine,
error::AppError,
home_assistant,
models::{ApiTokenInfo, Automation, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, RuntimeSettings, Schedule, Zone, ZoneControlPatch},
models::{ApiTokenInfo, Automation, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
protocol::merge_discovered,
state::AppState,
};
@@ -259,6 +259,7 @@ async fn add_device(State(state): State<AppState>, Json(input): Json<ManualDevic
light: true,
current_temperature: if input.simulated { Some(25.0) } else { None },
outdoor_temperature: None,
temperature_sensor_offset: None,
online: input.simulated,
last_seen: if input.simulated { Some(now) } else { None },
last_error: None,
@@ -413,7 +414,7 @@ impl ZoneInput {
sensor_source: self.sensor_source, ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()),
external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference,
device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(),
active_preset: "comfort".into(), manual_preset: None, manual_override_until: None,
active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None,
effective_mode: String::new(), effective_setpoint: None, device_setpoint: None,
demand: false, last_action_at: None,
created_at, updated_at: Utc::now(),
@@ -444,6 +445,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
zone.control_temperature_source = existing.control_temperature_source;
zone.active_preset = existing.active_preset;
zone.manual_preset = existing.manual_preset;
zone.manual_setpoint = existing.manual_setpoint;
zone.manual_override_until = existing.manual_override_until;
zone.effective_mode = existing.effective_mode;
zone.effective_setpoint = existing.effective_setpoint;
@@ -460,8 +462,10 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
if let Some(value) = patch.setpoint {
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); }
zone.setpoint = (value * 2.0).round() / 2.0;
zone.manual_preset = Some("custom".into());
let value = (value * 2.0).round() / 2.0;
zone.setpoint = value;
zone.manual_setpoint = Some(value);
zone.effective_setpoint = Some(value);
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
}
if let Some(value) = patch.mode.as_deref() {
@@ -478,10 +482,12 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
match value {
"auto" => {
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
}
"comfort" | "sleep" | "away" | "custom" => {
zone.manual_preset = Some(value.to_string());
zone.manual_setpoint = None;
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
}
_ => return Err(AppError::BadRequest("unsupported zone preset".into())),
@@ -489,6 +495,7 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
}
if patch.clear_override.unwrap_or(false) {
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
}
if let Some(value) = patch.enabled { zone.enabled = value; }
@@ -496,7 +503,7 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({
"zone_id": zone.id, "setpoint": zone.setpoint, "mode": zone.mode,
"zone_id": zone.id, "setpoint": zone.setpoint, "manual_setpoint": zone.manual_setpoint, "mode": zone.mode,
"inherit_house_mode": zone.inherit_house_mode, "preset": zone.manual_preset,
"override_until": zone.manual_override_until, "enabled": zone.enabled
}));
@@ -531,9 +538,11 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
for zone in &mut zones {
if input.preset == "auto" {
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
} else {
zone.manual_preset = Some(input.preset.clone());
zone.manual_setpoint = None;
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
}
zone.updated_at = Utc::now();
@@ -732,23 +741,174 @@ async fn readings(State(state): State<AppState>, Query(query): Query<ReadingsQue
}
#[derive(Debug, Deserialize)]
struct HistoryQuery { zone_id: Option<String>, hours: Option<i64>, limit: Option<u32> }
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 * 31);
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 bucket_seconds = match hours {
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,
_ => 1800,
};
let values = state.db.list_zone_history(zone_id, Utc::now() - ChronoDuration::hours(hours), bucket_seconds, query.limit.unwrap_or(12_000))?;
Ok(Json(json!({"readings": values, "bucket_seconds": bucket_seconds})))
}
}
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 history(State(state): State<AppState>, Query(query): Query<HistoryQuery>) -> Result<Json<Value>, AppError> {
let hours = query.hours.unwrap_or(24).clamp(1, 24 * 31);
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 = state.db.list_device_history(device_id, since.clone(), bucket_seconds, limit)?;
Ok(Json(json!({
"scope": "devices", "readings": readings, "bucket_seconds": bucket_seconds,
"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 = if entity_id.is_some() { state.db.list_ha_history(entity_id, since.clone(), bucket_seconds, limit)? } else { sensor_history_with_fallback(&state, since.clone(), bucket_seconds, limit, &outdoor_entity)? };
Ok(Json(json!({
"scope": "sensors", "readings": readings, "bucket_seconds": bucket_seconds,
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
})))
}
"overview" => {
let zones = zone_history_with_fallback(&state, None, since.clone(), bucket_seconds, limit)?;
let devices = state.db.list_device_history(None, since.clone(), bucket_seconds, limit)?;
let sensors = sensor_history_with_fallback(&state, since.clone(), bucket_seconds, limit, &outdoor_entity)?;
Ok(Json(json!({
"scope": "overview", "bucket_seconds": bucket_seconds,
"zones": zones, "devices": devices, "sensors": sensors,
"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 = zone_history_with_fallback(&state, zone_id, since.clone(), bucket_seconds, limit)?;
Ok(Json(json!({
"scope": "zones", "readings": readings, "bucket_seconds": bucket_seconds,
"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)]