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> { self.list_payloads(queries::LIST_ZONES) } pub fn get_zone(&self, id: &str) -> Result> { self.get_payload(queries::GET_ZONE, id) } pub fn delete_zone(&self, id: &str) -> Result { 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> { self.list_payloads(queries::LIST_GROUPS) } pub fn get_group(&self, id: &str) -> Result> { self.get_payload(queries::GET_GROUP, id) } pub fn delete_group(&self, id: &str) -> Result { self.delete_by_id("groups", id) } pub fn save_device_group(&self, group: &DeviceGroup) -> Result<()> { let payload = Self::to_json(group)?; let conn = self.lock()?; conn.execute( queries::UPSERT_DEVICE_GROUP, params![group.id, payload, group.updated_at.to_rfc3339()], )?; Ok(()) } pub fn list_device_groups(&self) -> Result> { self.list_payloads(queries::LIST_DEVICE_GROUPS) } pub fn get_device_group(&self, id: &str) -> Result> { self.get_payload(queries::GET_DEVICE_GROUP, id) } pub fn device_group_for_device(&self, device_id: &str) -> Result> { Ok(self .list_device_groups()? .into_iter() .find(|group| group.device_ids.iter().any(|id| id == device_id))) } pub fn delete_device_group(&self, id: &str) -> Result { self.delete_by_id("device_groups", id) } } impl Db { /// Merge device transition timestamps into zone JSON without replacing newer zone state. /// `updated_at` acts as a compare-and-swap token but is intentionally not changed here: /// thermostat cycles use it to detect real configuration/ownership updates. pub fn merge_zone_device_transition_timestamps( &self, device_id: &str, power_changed: bool, mode_changed: bool, at: DateTime, ) -> Result> { if !power_changed && !mode_changed { return Ok(Vec::new()); } let zone_ids: Vec = self .list_zones()? .into_iter() .filter(|zone| zone.device_id == device_id) .map(|zone| zone.id) .collect(); let mut updated = Vec::with_capacity(zone_ids.len()); for zone_id in zone_ids { let mut saved = false; for _ in 0..8 { let Some(mut zone) = self.get_zone(&zone_id)? else { break; }; let expected_updated_at = zone.updated_at.to_rfc3339(); if power_changed { zone.last_power_change_at = Some(at); } if mode_changed { zone.last_mode_change_at = Some(at); } let payload = Self::to_json(&zone)?; let conn = self.lock()?; let changed = conn.execute( "UPDATE zones SET payload = ?1 WHERE id = ?2 AND updated_at = ?3", params![payload, zone.id, expected_updated_at], )?; drop(conn); if changed > 0 { updated.push(zone); saved = true; break; } } if !saved && self.get_zone(&zone_id)?.is_some() { return Err(anyhow::anyhow!( "zone {zone_id} kept changing while device transition timestamps were merged" )); } } Ok(updated) } }