v0.5.0
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user