v0.4.2
This commit is contained in:
+21
@@ -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> {
|
||||
|
||||
@@ -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(×tamp)
|
||||
.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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user