This commit is contained in:
Mateusz Gruszczyński
2026-08-30 13:39:29 +02:00
parent 3e950ab5fa
commit 5c05eddb8f
83 changed files with 10130 additions and 9954 deletions
+52
View File
@@ -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)
}
}
+60
View File
@@ -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(())
}
}
+70
View File
@@ -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)
}
}
+129
View File
@@ -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(&timestamp)
.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)
}
}
+82
View File
@@ -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)
}
}
+50
View File
@@ -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(&timestamp)
.map(|value| value.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now()),
temperature: row.get(5)?,
})
}
}
+86
View File
@@ -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)
}
}
+70
View File
@@ -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));
}
}
+70
View File
@@ -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(&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)?,
})
}
}