v0.8.14
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
impl Db {
|
||||
pub fn save_zone(&self, zone: &Zone) -> Result<()> {
|
||||
let payload = Self::to_json(zone)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_ZONE,
|
||||
params![zone.id, payload, zone.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_zones(&self) -> Result<Vec<Zone>> {
|
||||
self.list_payloads(queries::LIST_ZONES)
|
||||
}
|
||||
|
||||
pub fn get_zone(&self, id: &str) -> Result<Option<Zone>> {
|
||||
self.get_payload(queries::GET_ZONE, id)
|
||||
}
|
||||
|
||||
pub fn delete_zone(&self, id: &str) -> Result<bool> {
|
||||
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)
|
||||
}
|
||||
|
||||
pub fn save_group(&self, group: &ClimateGroup) -> Result<()> {
|
||||
let payload = Self::to_json(group)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_GROUP,
|
||||
params![group.id, payload, group.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_groups(&self) -> Result<Vec<ClimateGroup>> {
|
||||
self.list_payloads(queries::LIST_GROUPS)
|
||||
}
|
||||
|
||||
pub fn get_group(&self, id: &str) -> Result<Option<ClimateGroup>> {
|
||||
self.get_payload(queries::GET_GROUP, id)
|
||||
}
|
||||
|
||||
pub fn delete_group(&self, id: &str) -> Result<bool> {
|
||||
self.delete_by_id("groups", id)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
impl Db {
|
||||
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()?,
|
||||
groups: self.list_groups()?,
|
||||
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 group in &export.groups {
|
||||
let payload = Self::to_json(group)?;
|
||||
tx.execute(queries::UPSERT_GROUP, params![group.id, payload, group.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()?;
|
||||
value.map(Self::from_json).transpose()
|
||||
}
|
||||
|
||||
pub fn save_runtime_settings(&self, settings: &RuntimeSettings) -> Result<()> {
|
||||
let value = Self::to_json(settings)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_RUNTIME_SETTINGS,
|
||||
params![value, Utc::now().to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
impl Db {
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let conn = Connection::open(path)
|
||||
.with_context(|| format!("cannot open SQLite database {}", path.display()))?;
|
||||
conn.busy_timeout(std::time::Duration::from_secs(5))?;
|
||||
conn.execute_batch(queries::INIT_SCHEMA)?;
|
||||
Ok(Self { conn: Arc::new(Mutex::new(conn)) })
|
||||
}
|
||||
|
||||
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Connection>> {
|
||||
self.conn.lock().map_err(|_| anyhow::anyhow!("database mutex poisoned"))
|
||||
}
|
||||
|
||||
fn from_json<T: DeserializeOwned>(payload: String) -> Result<T> {
|
||||
Ok(serde_json::from_str(&payload)?)
|
||||
}
|
||||
|
||||
fn to_json<T: Serialize>(value: &T) -> Result<String> {
|
||||
Ok(serde_json::to_string(value)?)
|
||||
}
|
||||
|
||||
pub fn count_devices(&self) -> Result<u64> {
|
||||
let conn = self.lock()?;
|
||||
let count: i64 = conn.query_row(queries::COUNT_DEVICES, [], |row| row.get(0))?;
|
||||
Ok(count.max(0) as u64)
|
||||
}
|
||||
|
||||
pub fn save_device(&self, device: &Device) -> Result<()> {
|
||||
let payload = Self::to_json(device)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_DEVICE,
|
||||
params![device.id, device.mac, device.name, device.ip, device.simulated as i64, payload, device.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_devices(&self) -> Result<Vec<Device>> {
|
||||
let conn = self.lock()?;
|
||||
let mut stmt = conn.prepare(queries::LIST_DEVICES)?;
|
||||
let payloads = stmt.query_map([], |row| row.get::<_, String>(0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
payloads.into_iter().map(Self::from_json).collect()
|
||||
}
|
||||
|
||||
pub fn get_device(&self, id: &str) -> Result<Option<Device>> {
|
||||
let conn = self.lock()?;
|
||||
let payload: Option<String> = conn.query_row(queries::GET_DEVICE_BY_ID, [id], |row| row.get(0)).optional()?;
|
||||
payload.map(Self::from_json).transpose()
|
||||
}
|
||||
|
||||
pub fn get_device_by_mac(&self, mac: &str) -> Result<Option<Device>> {
|
||||
let conn = self.lock()?;
|
||||
let payload: Option<String> = conn.query_row(queries::GET_DEVICE_BY_MAC, [mac], |row| row.get(0)).optional()?;
|
||||
payload.map(Self::from_json).transpose()
|
||||
}
|
||||
|
||||
pub fn delete_device(&self, id: &str) -> Result<bool> {
|
||||
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_SCHEDULES_BY_DEVICE_ID, [id])?;
|
||||
tx.execute(queries::DELETE_ZONES_BY_DEVICE_ID, [id])?;
|
||||
let changed = tx.execute(queries::DELETE_DEVICE, [id])? > 0;
|
||||
tx.commit()?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
impl Db {
|
||||
pub fn add_reading(&self, reading: &Reading) -> Result<i64> {
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::INSERT_READING,
|
||||
params![reading.device_id, reading.timestamp.to_rfc3339(), reading.indoor_temperature,
|
||||
reading.outdoor_temperature, reading.target_temperature, reading.power as i64, reading.source],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn list_readings(&self, device_id: Option<&str>, since: DateTime<Utc>, limit: u32) -> Result<Vec<Reading>> {
|
||||
let conn = self.lock()?;
|
||||
let limit = limit.clamp(1, 5000) as i64;
|
||||
let mut rows_out = Vec::new();
|
||||
if let Some(device_id) = device_id {
|
||||
let mut stmt = conn.prepare(queries::LIST_READINGS_BY_DEVICE)?;
|
||||
let rows = stmt.query_map(params![device_id, since.to_rfc3339(), limit], Self::map_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
} else {
|
||||
let mut stmt = conn.prepare(queries::LIST_READINGS_ALL)?;
|
||||
let rows = stmt.query_map(params![since.to_rfc3339(), limit], Self::map_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
}
|
||||
Ok(rows_out)
|
||||
}
|
||||
|
||||
pub fn list_device_history(&self, device_id: Option<&str>, since: DateTime<Utc>, bucket_seconds: i64, limit: u32) -> Result<Vec<Reading>> {
|
||||
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(device_id) = device_id {
|
||||
let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_BY_DEVICE_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![device_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
} else {
|
||||
let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_ALL_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
}
|
||||
Ok(rows_out)
|
||||
}
|
||||
|
||||
fn map_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result<Reading> {
|
||||
let timestamp: String = row.get(2)?;
|
||||
Ok(Reading {
|
||||
id: row.get(0)?,
|
||||
device_id: row.get(1)?,
|
||||
timestamp: DateTime::parse_from_rfc3339(×tamp)
|
||||
.map(|v| v.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now()),
|
||||
indoor_temperature: row.get(3)?,
|
||||
outdoor_temperature: row.get(4)?,
|
||||
target_temperature: row.get(5)?,
|
||||
power: row.get::<_, i64>(6)? != 0,
|
||||
source: row.get(7)?,
|
||||
})
|
||||
}
|
||||
|
||||
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()?;
|
||||
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;
|
||||
let ha = conn.execute(queries::PRUNE_HA_READINGS, [before.to_rfc3339()])? as u64;
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
impl Db {
|
||||
pub fn history_counts(&self) -> Result<(u64, u64, u64)> {
|
||||
let conn = self.lock()?;
|
||||
let (device, zone, ha): (i64, i64, i64) = conn.query_row(queries::HISTORY_COUNTS, [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
|
||||
Ok((device.max(0) as u64, zone.max(0) as u64, ha.max(0) as u64))
|
||||
}
|
||||
|
||||
pub fn log_event(&self, level: &str, kind: &str, message: &str, metadata: &Value) -> Result<i64> {
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::INSERT_EVENT,
|
||||
params![Utc::now().to_rfc3339(), level, kind, message, serde_json::to_string(metadata)?],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn list_events(&self, limit: u32) -> Result<Vec<EventLog>> {
|
||||
let conn = self.lock()?;
|
||||
let mut stmt = conn.prepare(queries::LIST_EVENTS)?;
|
||||
let rows = stmt.query_map([limit.clamp(1, 1000) as i64], |row| {
|
||||
let ts: String = row.get(1)?;
|
||||
let metadata: String = row.get(5)?;
|
||||
Ok(EventLog {
|
||||
id: row.get(0)?,
|
||||
timestamp: DateTime::parse_from_rfc3339(&ts).map(|v| v.with_timezone(&Utc)).unwrap_or_else(|_| Utc::now()),
|
||||
level: row.get(2)?,
|
||||
kind: row.get(3)?,
|
||||
message: row.get(4)?,
|
||||
metadata: serde_json::from_str(&metadata).unwrap_or(Value::Null),
|
||||
})
|
||||
})?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>().map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn prune_events(&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_EVENTS, [before.to_rfc3339()])? as u64)
|
||||
}
|
||||
|
||||
pub fn list_api_tokens(&self) -> Result<Vec<ApiTokenInfo>> {
|
||||
let conn = self.lock()?;
|
||||
let mut stmt = conn.prepare(queries::LIST_API_TOKENS)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let created_at: String = row.get(3)?;
|
||||
Ok(ApiTokenInfo {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
token_prefix: row.get(2)?,
|
||||
created_at: DateTime::parse_from_rfc3339(&created_at)
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now()),
|
||||
})
|
||||
})?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>().map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn save_api_token(&self, token: &ApiTokenInfo, token_hash: &str) -> Result<()> {
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::INSERT_API_TOKEN,
|
||||
params![token.id, token.name, token_hash, token.token_prefix, token.created_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn api_token_exists(&self, token_hash: &str) -> Result<bool> {
|
||||
let conn = self.lock()?;
|
||||
let found: Option<i64> = conn.query_row(
|
||||
queries::API_TOKEN_EXISTS,
|
||||
[token_hash],
|
||||
|row| row.get(0),
|
||||
).optional()?;
|
||||
Ok(found.is_some())
|
||||
}
|
||||
|
||||
pub fn delete_api_token(&self, id: &str) -> Result<bool> {
|
||||
let conn = self.lock()?;
|
||||
Ok(conn.execute(queries::DELETE_API_TOKEN, [id])? > 0)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
impl Db {
|
||||
pub fn add_ha_reading_if_due(&self, reading: &HaReading, 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_HA_READING_IF_DUE,
|
||||
params![
|
||||
reading.entity_id,
|
||||
reading.zone_id,
|
||||
reading.kind,
|
||||
reading.timestamp.to_rfc3339(),
|
||||
reading.temperature,
|
||||
cutoff.to_rfc3339(),
|
||||
],
|
||||
)?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
pub fn list_ha_history(&self, entity_id: Option<&str>, since: DateTime<Utc>, bucket_seconds: i64, limit: u32) -> Result<Vec<HaReading>> {
|
||||
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(entity_id) = entity_id {
|
||||
let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_BY_ENTITY_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![entity_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_ha_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
} else {
|
||||
let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_ALL_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_ha_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
}
|
||||
Ok(rows_out)
|
||||
}
|
||||
|
||||
fn map_ha_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result<HaReading> {
|
||||
let timestamp: String = row.get(4)?;
|
||||
Ok(HaReading {
|
||||
id: row.get(0)?,
|
||||
entity_id: row.get(1)?,
|
||||
zone_id: row.get(2)?,
|
||||
kind: row.get(3)?,
|
||||
timestamp: DateTime::parse_from_rfc3339(×tamp)
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now()),
|
||||
temperature: row.get(5)?,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
impl Db {
|
||||
pub fn save_schedule(&self, schedule: &Schedule) -> Result<()> {
|
||||
let payload = Self::to_json(schedule)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_SCHEDULE,
|
||||
params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_schedules(&self) -> Result<Vec<Schedule>> {
|
||||
self.list_payloads(queries::LIST_SCHEDULES)
|
||||
}
|
||||
|
||||
pub fn get_schedule(&self, id: &str) -> Result<Option<Schedule>> {
|
||||
self.get_payload(queries::GET_SCHEDULE, id)
|
||||
}
|
||||
|
||||
pub fn delete_schedule(&self, id: &str) -> Result<bool> {
|
||||
self.delete_by_id("schedules", id)
|
||||
}
|
||||
|
||||
pub fn replace_schedules_for_zone(&self, zone_id: &str, schedules: &[Schedule]) -> Result<()> {
|
||||
let mut conn = self.lock()?;
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(queries::DELETE_SCHEDULES_BY_ZONE_ID, [zone_id])?;
|
||||
for schedule in schedules {
|
||||
let payload = Self::to_json(schedule)?;
|
||||
tx.execute(
|
||||
queries::UPSERT_SCHEDULE,
|
||||
params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_automation(&self, item: &Automation) -> Result<()> {
|
||||
let payload = Self::to_json(item)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_AUTOMATION,
|
||||
params![item.id, payload, item.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_automations(&self) -> Result<Vec<Automation>> {
|
||||
self.list_payloads(queries::LIST_AUTOMATIONS)
|
||||
}
|
||||
|
||||
pub fn get_automation(&self, id: &str) -> Result<Option<Automation>> {
|
||||
self.get_payload(queries::GET_AUTOMATION, id)
|
||||
}
|
||||
|
||||
pub fn delete_automation(&self, id: &str) -> Result<bool> {
|
||||
self.delete_by_id("automations", id)
|
||||
}
|
||||
|
||||
fn list_payloads<T: DeserializeOwned>(&self, sql: &str) -> Result<Vec<T>> {
|
||||
let conn = self.lock()?;
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let payloads = stmt.query_map([], |row| row.get::<_, String>(0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
payloads.into_iter().map(Self::from_json).collect()
|
||||
}
|
||||
|
||||
fn get_payload<T: DeserializeOwned>(&self, sql: &str, id: &str) -> Result<Option<T>> {
|
||||
let conn = self.lock()?;
|
||||
let payload: Option<String> = conn.query_row(sql, [id], |row| row.get(0)).optional()?;
|
||||
payload.map(Self::from_json).transpose()
|
||||
}
|
||||
|
||||
fn delete_by_id(&self, table: &str, id: &str) -> Result<bool> {
|
||||
let sql = match table {
|
||||
"schedules" => queries::DELETE_SCHEDULE,
|
||||
"automations" => queries::DELETE_AUTOMATION,
|
||||
"groups" => queries::DELETE_GROUP,
|
||||
_ => anyhow::bail!("unsupported table"),
|
||||
};
|
||||
let conn = self.lock()?;
|
||||
Ok(conn.execute(sql, [id])? > 0)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
#[cfg(test)]
|
||||
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();
|
||||
let db = Db::open(&dir.path().join("test.db")).unwrap();
|
||||
let device = Device::simulated_default();
|
||||
db.save_device(&device).unwrap();
|
||||
let loaded = db.get_device(&device.id).unwrap().unwrap();
|
||||
assert_eq!(loaded.mac, device.mac);
|
||||
assert_eq!(db.list_devices().unwrap().len(), 1);
|
||||
db.log_event("info", "test", "ok", &serde_json::json!({"a":1})).unwrap();
|
||||
assert_eq!(db.list_events(10).unwrap().len(), 1);
|
||||
{
|
||||
let conn = db.lock().unwrap();
|
||||
conn.execute(queries::INSERT_EVENT, rusqlite::params![(Utc::now() - Duration::days(40)).to_rfc3339(), "info", "old", "old", "{}"] ).unwrap();
|
||||
}
|
||||
assert_eq!(db.prune_events(30).unwrap(), 1);
|
||||
assert_eq!(db.list_events(10).unwrap().len(), 1);
|
||||
|
||||
let access_token = ApiTokenInfo {
|
||||
id: "token-1".into(),
|
||||
name: "Home Assistant".into(),
|
||||
token_prefix: "gree_controller_test...".into(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
db.save_api_token(&access_token, "test-hash").unwrap();
|
||||
assert!(db.api_token_exists("test-hash").unwrap());
|
||||
assert_eq!(db.list_api_tokens().unwrap().len(), 1);
|
||||
assert!(db.delete_api_token(&access_token.id).unwrap());
|
||||
assert!(!db.api_token_exists("test-hash").unwrap());
|
||||
|
||||
let now = Utc::now();
|
||||
db.add_reading(&Reading {
|
||||
id: 0, device_id: device.id.clone(), timestamp: now.clone(), indoor_temperature: Some(22.5),
|
||||
outdoor_temperature: Some(31.0), target_temperature: 23.0, power: true, source: "gree".into(),
|
||||
}).unwrap();
|
||||
assert_eq!(db.list_device_history(Some(&device.id), now.clone() - Duration::minutes(1), 30, 100).unwrap().len(), 1);
|
||||
|
||||
db.add_ha_reading_if_due(&HaReading {
|
||||
id: 0, entity_id: "sensor.room".into(), zone_id: Some("zone-room".into()), kind: "room".into(),
|
||||
timestamp: now.clone(), temperature: 22.1,
|
||||
}, 15).unwrap();
|
||||
assert_eq!(db.list_ha_history(Some("sensor.room"), now.clone() - Duration::minutes(1), 30, 100).unwrap().len(), 1);
|
||||
assert_eq!(db.history_counts().unwrap(), (1, 0, 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
impl Db {
|
||||
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_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)?,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user