This commit is contained in:
Mateusz Gruszczyński
2026-08-24 14:05:43 +02:00
parent eb02f66056
commit 04fc91b9f4
30 changed files with 2257 additions and 175 deletions
+118 -1
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, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading},
models::{ApiTokenInfo, Automation, ConfigurationExport, Device, EventLog, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading},
queries,
};
@@ -252,6 +252,40 @@ impl Db {
})
}
pub fn history_before(&self, before: DateTime<Utc>, limit_per_family: u32) -> Result<(Vec<Reading>, Vec<ZoneReading>, Vec<HaReading>)> {
let conn = self.lock()?;
let limit = limit_per_family.clamp(1, 5_000) as i64;
let before = before.to_rfc3339();
let devices = {
let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_BEFORE)?;
let rows = stmt.query_map(params![before.clone(), limit], Self::map_reading)?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
};
let zones = {
let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_BEFORE)?;
let rows = stmt.query_map(params![before.clone(), limit], Self::map_zone_reading)?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
};
let ha = {
let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_BEFORE)?;
let rows = stmt.query_map(params![before, limit], Self::map_ha_reading)?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
};
Ok((devices, zones, ha))
}
pub fn delete_history_batch(&self, devices: &[Reading], zones: &[ZoneReading], ha: &[HaReading]) -> Result<u64> {
let mut conn = self.lock()?;
let tx = conn.transaction()?;
let mut changed = 0_u64;
for row in devices { changed += tx.execute(queries::DELETE_READING_BY_ID, [row.id])? as u64; }
for row in zones { changed += tx.execute(queries::DELETE_ZONE_READING_BY_ID, [row.id])? as u64; }
for row in ha { changed += tx.execute(queries::DELETE_HA_READING_BY_ID, [row.id])? as u64; }
tx.commit()?;
Ok(changed)
}
pub fn prune_readings(&self, retention_days: i64) -> Result<u64> {
let before = Utc::now() - Duration::days(retention_days.max(1));
let conn = self.lock()?;
@@ -261,6 +295,31 @@ impl Db {
Ok(device + zone + ha)
}
/// Compact history to the same practical resolution used by charts.
/// 1-7 days: one sample / 10 minutes, 7+ days: one sample / 30 minutes.
pub fn compact_history(&self, retention_days: i64) -> Result<u64> {
let now = Utc::now();
let one_day = now - Duration::days(1);
let seven_days = now - Duration::days(7);
let retention = now - Duration::days(retention_days.max(1));
let conn = self.lock()?;
let mut changed = 0_u64;
for (bucket, older_than, newer_than) in [
(600_i64, one_day, seven_days),
(1800_i64, seven_days, retention),
] {
if older_than <= newer_than { continue; }
let args = params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()];
changed += conn.execute(queries::COMPACT_DEVICE_HISTORY, args)? as u64;
let args = params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()];
changed += conn.execute(queries::COMPACT_ZONE_HISTORY, args)? as u64;
let args = params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()];
changed += conn.execute(queries::COMPACT_HA_HISTORY, args)? as u64;
}
conn.execute_batch("PRAGMA optimize;")?;
Ok(changed)
}
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()?;
@@ -451,6 +510,44 @@ impl Db {
Ok(conn.execute(queries::DELETE_API_TOKEN, [id])? > 0)
}
pub fn export_configuration(&self, settings: RuntimeSettings) -> Result<ConfigurationExport> {
Ok(ConfigurationExport {
format_version: 1,
exported_at: Utc::now(),
settings,
devices: self.list_devices()?,
zones: self.list_zones()?,
schedules: self.list_schedules()?,
automations: self.list_automations()?,
})
}
pub fn replace_configuration(&self, export: &ConfigurationExport) -> Result<()> {
let mut conn = self.lock()?;
let tx = conn.transaction()?;
tx.execute_batch(queries::CLEAR_CONFIGURATION)?;
for device in &export.devices {
let payload = Self::to_json(device)?;
tx.execute(queries::UPSERT_DEVICE, params![device.id, device.mac, device.name, device.ip, device.simulated as i64, payload, device.updated_at.to_rfc3339()])?;
}
for zone in &export.zones {
let payload = Self::to_json(zone)?;
tx.execute(queries::UPSERT_ZONE, params![zone.id, payload, zone.updated_at.to_rfc3339()])?;
}
for schedule in &export.schedules {
let payload = Self::to_json(schedule)?;
tx.execute(queries::UPSERT_SCHEDULE, params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()])?;
}
for item in &export.automations {
let payload = Self::to_json(item)?;
tx.execute(queries::UPSERT_AUTOMATION, params![item.id, payload, item.updated_at.to_rfc3339()])?;
}
let settings_json = Self::to_json(&export.settings)?;
tx.execute(queries::UPSERT_RUNTIME_SETTINGS, params![settings_json, Utc::now().to_rfc3339()])?;
tx.commit()?;
Ok(())
}
pub fn load_runtime_settings(&self) -> Result<Option<RuntimeSettings>> {
let conn = self.lock()?;
let value: Option<String> = conn.query_row(queries::LOAD_RUNTIME_SETTINGS, [], |row| row.get(0)).optional()?;
@@ -473,6 +570,26 @@ mod tests {
use super::*;
use crate::models::{ApiTokenInfo, Device, HaReading, Reading};
#[test]
fn history_compaction_keeps_one_sample_per_old_bucket() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open(&dir.path().join("compact.db")).unwrap();
let device = Device::simulated_default();
db.save_device(&device).unwrap();
let seconds = (Utc::now().timestamp() - 2 * 86_400) / 600 * 600;
let base = DateTime::<Utc>::from_timestamp(seconds, 0).unwrap();
for offset in [10_i64, 20_i64] {
db.add_reading(&Reading {
id: 0, device_id: device.id.clone(), timestamp: base + Duration::seconds(offset),
indoor_temperature: Some(22.0), outdoor_temperature: None, target_temperature: 23.0,
power: true, source: "gree".into(),
}).unwrap();
}
assert_eq!(db.history_counts().unwrap().0, 2);
assert_eq!(db.compact_history(30).unwrap(), 1);
assert_eq!(db.history_counts().unwrap().0, 1);
}
#[test]
fn sqlite_round_trip() {
let dir = tempfile::tempdir().unwrap();