This commit is contained in:
Mateusz Gruszczyński
2026-08-23 23:32:26 +02:00
parent b23578a0c8
commit f569b7db14
42 changed files with 597 additions and 4104 deletions
+21
View File
@@ -55,6 +55,7 @@ pub fn router(state: AppState) -> Router {
.route("/api/automations", get(list_automations).post(create_automation))
.route("/api/automations/:id", get(get_automation).put(update_automation).delete(delete_automation))
.route("/api/readings", get(readings))
.route("/api/history", get(history))
.route("/api/events", get(events))
.route("/api/settings", get(get_settings).put(update_settings))
.route("/api/access-tokens", get(list_access_tokens).post(create_access_token))
@@ -730,6 +731,26 @@ async fn readings(State(state): State<AppState>, Query(query): Query<ReadingsQue
Ok(Json(json!({"readings": values})))
}
#[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 {
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})))
}
#[derive(Debug, Deserialize)]
struct EventsQuery { limit: Option<u32> }
async fn events(State(state): State<AppState>, Query(query): Query<EventsQuery>) -> Result<Json<Value>, AppError> {
+90 -2
View File
@@ -5,7 +5,7 @@ use rusqlite::{params, Connection, OptionalExtension};
use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value;
use crate::{
models::{ApiTokenInfo, Automation, Device, EventLog, Reading, RuntimeSettings, Schedule, Zone},
models::{ApiTokenInfo, Automation, Device, EventLog, Reading, RuntimeSettings, Schedule, Zone, ZoneReading},
queries,
};
@@ -75,6 +75,7 @@ impl Db {
let mut conn = self.lock()?;
let tx = conn.transaction()?;
tx.execute(queries::DELETE_DEVICE_READINGS, [id])?;
tx.execute(queries::DELETE_ZONE_READINGS_BY_DEVICE_ID, [id])?;
tx.execute(queries::DELETE_ZONES_BY_DEVICE_ID, [id])?;
let changed = tx.execute(queries::DELETE_DEVICE, [id])? > 0;
tx.commit()?;
@@ -103,6 +104,7 @@ impl Db {
let mut conn = self.lock()?;
let tx = conn.transaction()?;
tx.execute(queries::DELETE_SCHEDULES_BY_ZONE_ID, [id])?;
tx.execute(queries::DELETE_ZONE_READINGS_BY_ZONE_ID, [id])?;
let changed = tx.execute(queries::DELETE_ZONE, [id])? > 0;
tx.commit()?;
Ok(changed)
@@ -236,7 +238,93 @@ impl Db {
pub fn prune_readings(&self, retention_days: i64) -> Result<u64> {
let before = Utc::now() - Duration::days(retention_days.max(1));
let conn = self.lock()?;
Ok(conn.execute(queries::PRUNE_READINGS, [before.to_rfc3339()])? as u64)
let device = conn.execute(queries::PRUNE_READINGS, [before.to_rfc3339()])? as u64;
let zone = conn.execute(queries::PRUNE_ZONE_READINGS, [before.to_rfc3339()])? as u64;
Ok(device + zone)
}
pub fn add_zone_reading_if_due(&self, reading: &ZoneReading, min_interval_seconds: i64) -> Result<bool> {
let cutoff = reading.timestamp.clone() - Duration::seconds(min_interval_seconds.max(1));
let conn = self.lock()?;
let changed = conn.execute(
queries::INSERT_ZONE_READING_IF_DUE,
params![
reading.zone_id,
reading.device_id,
reading.timestamp.to_rfc3339(),
reading.gree_temperature,
reading.external_temperature,
reading.control_temperature,
reading.target_temperature,
reading.device_setpoint,
reading.outdoor_temperature,
reading.power as i64,
reading.mode,
reading.fan_speed as i64,
reading.demand as i64,
reading.control_source,
reading.active_preset,
cutoff.to_rfc3339(),
],
)?;
Ok(changed > 0)
}
pub fn list_zone_readings(&self, zone_id: Option<&str>, since: DateTime<Utc>, limit: u32) -> Result<Vec<ZoneReading>> {
let conn = self.lock()?;
let limit = limit.clamp(1, 20_000) as i64;
let mut rows_out = Vec::new();
if let Some(zone_id) = zone_id {
let mut stmt = conn.prepare(queries::LIST_ZONE_READINGS_BY_ZONE)?;
let rows = stmt.query_map(params![zone_id, since.to_rfc3339(), limit], Self::map_zone_reading)?;
for row in rows { rows_out.push(row?); }
} else {
let mut stmt = conn.prepare(queries::LIST_ZONE_READINGS_ALL)?;
let rows = stmt.query_map(params![since.to_rfc3339(), limit], Self::map_zone_reading)?;
for row in rows { rows_out.push(row?); }
}
Ok(rows_out)
}
pub fn list_zone_history(&self, zone_id: Option<&str>, since: DateTime<Utc>, bucket_seconds: i64, limit: u32) -> Result<Vec<ZoneReading>> {
let conn = self.lock()?;
let bucket_seconds = bucket_seconds.max(1);
let limit = limit.clamp(1, 20_000) as i64;
let mut rows_out = Vec::new();
if let Some(zone_id) = zone_id {
let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_BY_ZONE_BUCKETED)?;
let rows = stmt.query_map(params![zone_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_zone_reading)?;
for row in rows { rows_out.push(row?); }
} else {
let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_ALL_BUCKETED)?;
let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_zone_reading)?;
for row in rows { rows_out.push(row?); }
}
Ok(rows_out)
}
fn map_zone_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result<ZoneReading> {
let timestamp: String = row.get(3)?;
Ok(ZoneReading {
id: row.get(0)?,
zone_id: row.get(1)?,
device_id: row.get(2)?,
timestamp: DateTime::parse_from_rfc3339(&timestamp)
.map(|v| v.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now()),
gree_temperature: row.get(4)?,
external_temperature: row.get(5)?,
control_temperature: row.get(6)?,
target_temperature: row.get(7)?,
device_setpoint: row.get(8)?,
outdoor_temperature: row.get(9)?,
power: row.get::<_, i64>(10)? != 0,
mode: row.get(11)?,
fan_speed: row.get::<_, i64>(12)?.clamp(0, 255) as u8,
demand: row.get::<_, i64>(13)? != 0,
control_source: row.get(14)?,
active_preset: row.get(15)?,
})
}
pub fn log_event(&self, level: &str, kind: &str, message: &str, metadata: &Value) -> Result<i64> {
+30 -1
View File
@@ -6,7 +6,7 @@ use tokio::time::sleep;
use crate::{
error::AppError,
home_assistant,
models::{Automation, Device, DeviceCommand, Reading, Schedule, Zone},
models::{Automation, Device, DeviceCommand, Reading, Schedule, Zone, ZoneReading},
state::AppState,
};
@@ -341,6 +341,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})),
}
}
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds)?;
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
continue;
@@ -352,6 +353,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
zone.effective_setpoint = Some(target);
let Some(temp) = temperature else {
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds)?;
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
continue;
@@ -422,12 +424,39 @@ async fn control_zones(state: &AppState) -> Result<()> {
}
}
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds)?;
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
}
Ok(())
}
fn record_zone_history(state: &AppState, zone: &Zone, outdoor_temperature: Option<f64>, poll_interval_seconds: u64) -> Result<()> {
let Some(device) = state.db.get_device(&zone.device_id)? else { return Ok(()); };
let reading = ZoneReading {
id: 0,
zone_id: zone.id.clone(),
device_id: zone.device_id.clone(),
timestamp: Utc::now(),
gree_temperature: zone.device_temperature,
external_temperature: zone.external_temperature,
control_temperature: zone.current_temperature,
target_temperature: zone.effective_setpoint,
device_setpoint: zone.device_setpoint.or(Some(device.target_temperature)),
outdoor_temperature,
power: device.power,
mode: if zone.effective_mode.is_empty() { device.mode.clone() } else { zone.effective_mode.clone() },
fan_speed: device.fan_speed,
demand: zone.demand,
control_source: zone.control_temperature_source.clone(),
active_preset: zone.active_preset.clone(),
};
// History is deliberately less frequent than the zone control loop to keep SQLite compact.
let interval = poll_interval_seconds.max(15) as i64;
state.db.add_zone_reading_if_due(&reading, interval)?;
Ok(())
}
fn select_zone_temperature(zone: &Zone, device_temperature: Option<f64>, external_temperature: Option<f64>) -> (Option<f64>, String, bool) {
match zone.sensor_source.as_str() {
"home_assistant" => match (external_temperature, device_temperature) {
+20
View File
@@ -323,6 +323,26 @@ pub struct Reading {
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZoneReading {
pub id: i64,
pub zone_id: String,
pub device_id: String,
pub timestamp: DateTime<Utc>,
pub gree_temperature: Option<f64>,
pub external_temperature: Option<f64>,
pub control_temperature: Option<f64>,
pub target_temperature: Option<f64>,
pub device_setpoint: Option<f64>,
pub outdoor_temperature: Option<f64>,
pub power: bool,
pub mode: String,
pub fan_speed: u8,
pub demand: bool,
pub control_source: String,
pub active_preset: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventLog {
pub id: i64,
+87
View File
@@ -63,6 +63,29 @@ CREATE TABLE IF NOT EXISTS readings (
CREATE INDEX IF NOT EXISTS readings_device_time_idx
ON readings(device_id, timestamp DESC);
CREATE TABLE IF NOT EXISTS zone_readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
zone_id TEXT NOT NULL,
device_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
gree_temperature REAL,
external_temperature REAL,
control_temperature REAL,
target_temperature REAL,
device_setpoint REAL,
outdoor_temperature REAL,
power INTEGER NOT NULL,
mode TEXT NOT NULL,
fan_speed INTEGER NOT NULL,
demand INTEGER NOT NULL,
control_source TEXT NOT NULL,
active_preset TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS zone_readings_zone_time_idx
ON zone_readings(zone_id, timestamp DESC);
CREATE INDEX IF NOT EXISTS zone_readings_time_idx
ON zone_readings(timestamp DESC);
CREATE TABLE IF NOT EXISTS event_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
@@ -85,6 +108,8 @@ INSERT OR IGNORE INTO schema_migrations(version, applied_at)
VALUES (1, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
VALUES (2, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
VALUES (3, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
"#;
pub const COUNT_DEVICES: &str = "SELECT COUNT(*) FROM devices";
@@ -175,6 +200,68 @@ LIMIT ?2
pub const PRUNE_READINGS: &str = "DELETE FROM readings WHERE timestamp < ?1";
pub const INSERT_ZONE_READING_IF_DUE: &str = r#"
INSERT INTO zone_readings(
zone_id, device_id, timestamp, gree_temperature, external_temperature,
control_temperature, target_temperature, device_setpoint, outdoor_temperature,
power, mode, fan_speed, demand, control_source, active_preset
)
SELECT ?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15
WHERE NOT EXISTS (
SELECT 1 FROM zone_readings WHERE zone_id=?1 AND timestamp>=?16 LIMIT 1
)
"#;
pub const LIST_ZONE_READINGS_BY_ZONE: &str = r#"
SELECT id,zone_id,device_id,timestamp,gree_temperature,external_temperature,
control_temperature,target_temperature,device_setpoint,outdoor_temperature,
power,mode,fan_speed,demand,control_source,active_preset
FROM zone_readings
WHERE zone_id=?1 AND timestamp>=?2
ORDER BY timestamp ASC
LIMIT ?3
"#;
pub const LIST_ZONE_READINGS_ALL: &str = r#"
SELECT id,zone_id,device_id,timestamp,gree_temperature,external_temperature,
control_temperature,target_temperature,device_setpoint,outdoor_temperature,
power,mode,fan_speed,demand,control_source,active_preset
FROM zone_readings
WHERE timestamp>=?1
ORDER BY timestamp ASC
LIMIT ?2
"#;
pub const LIST_ZONE_HISTORY_BY_ZONE_BUCKETED: &str = r#"
SELECT MIN(id),zone_id,MAX(device_id),MIN(timestamp),
AVG(gree_temperature),AVG(external_temperature),AVG(control_temperature),
AVG(target_temperature),AVG(device_setpoint),AVG(outdoor_temperature),
MAX(power),MAX(mode),CAST(ROUND(AVG(fan_speed)) AS INTEGER),MAX(demand),
MAX(control_source),MAX(active_preset)
FROM zone_readings
WHERE zone_id=?1 AND timestamp>=?2
GROUP BY zone_id, CAST(unixepoch(timestamp)/?3 AS INTEGER)
ORDER BY MIN(timestamp) ASC
LIMIT ?4
"#;
pub const LIST_ZONE_HISTORY_ALL_BUCKETED: &str = r#"
SELECT MIN(id),zone_id,MAX(device_id),MIN(timestamp),
AVG(gree_temperature),AVG(external_temperature),AVG(control_temperature),
AVG(target_temperature),AVG(device_setpoint),AVG(outdoor_temperature),
MAX(power),MAX(mode),CAST(ROUND(AVG(fan_speed)) AS INTEGER),MAX(demand),
MAX(control_source),MAX(active_preset)
FROM zone_readings
WHERE timestamp>=?1
GROUP BY zone_id, CAST(unixepoch(timestamp)/?2 AS INTEGER)
ORDER BY MIN(timestamp) ASC
LIMIT ?3
"#;
pub const DELETE_ZONE_READINGS_BY_ZONE_ID: &str = "DELETE FROM zone_readings WHERE zone_id=?1";
pub const DELETE_ZONE_READINGS_BY_DEVICE_ID: &str = "DELETE FROM zone_readings WHERE device_id=?1";
pub const PRUNE_ZONE_READINGS: &str = "DELETE FROM zone_readings WHERE timestamp < ?1";
pub const INSERT_EVENT: &str =
"INSERT INTO event_log(timestamp,level,kind,message,metadata) VALUES(?1,?2,?3,?4,?5)";
pub const LIST_EVENTS: &str =