1use std::path::{Path, PathBuf};
40
41use rusqlite::{Connection, OptionalExtension};
42use time::OffsetDateTime;
43use tracing::{debug, info, warn};
44
45use aranet_types::{CurrentReading, DeviceInfo, DeviceType, HistoryRecord, Status};
46
47fn timestamp_from_unix(ts: i64) -> OffsetDateTime {
54 match OffsetDateTime::from_unix_timestamp(ts) {
55 Ok(dt) if ts > 0 => dt,
56 Ok(_) => {
57 warn!(
58 "Unexpected zero/negative timestamp {ts} in database, \
59 returning epoch sentinel. Run 'aranet cache check' to inspect database integrity."
60 );
61 OffsetDateTime::UNIX_EPOCH
62 }
63 Err(_) => {
64 warn!(
65 "Corrupted timestamp {ts} in database (out of representable range), \
66 returning epoch sentinel. Run 'aranet cache check' to inspect database integrity."
67 );
68 OffsetDateTime::UNIX_EPOCH
69 }
70 }
71}
72
73fn radon_from_i64(v: i64, context: &str) -> Option<u32> {
76 match u32::try_from(v) {
77 Ok(val) => Some(val),
78 Err(_) => {
79 warn!("Invalid radon value {v} in {context} (expected non-negative u32)");
80 None
81 }
82 }
83}
84
85use crate::error::{Error, Result};
86use crate::models::{StoredDevice, StoredHistoryRecord, StoredReading, SyncState};
87use crate::queries::{HistoryQuery, ReadingQuery};
88use crate::schema;
89
90pub struct Store {
140 conn: Connection,
141 path: Option<PathBuf>,
142}
143
144impl Store {
145 pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
163 let path = path.as_ref();
164
165 if let Some(parent) = path.parent() {
167 std::fs::create_dir_all(parent).map_err(|e| Error::CreateDirectory {
168 path: parent.to_path_buf(),
169 source: e,
170 })?;
171 }
172
173 debug!("Opening database at {}", path.display());
174 let conn = Connection::open(path)?;
175
176 conn.execute_batch(
178 "PRAGMA foreign_keys = ON;
179 PRAGMA journal_mode = WAL;
180 PRAGMA synchronous = NORMAL;",
181 )?;
182
183 schema::initialize(&conn)?;
185
186 Ok(Self {
187 conn,
188 path: Some(path.to_path_buf()),
189 })
190 }
191
192 pub fn open_default() -> Result<Self> {
208 Self::open(crate::default_db_path())
209 }
210
211 pub fn open_in_memory() -> Result<Self> {
226 let conn = Connection::open_in_memory()?;
227 conn.execute_batch("PRAGMA foreign_keys = ON;")?;
228 schema::initialize(&conn)?;
229 Ok(Self { conn, path: None })
230 }
231
232 pub fn database_path(&self) -> Option<&Path> {
236 self.path.as_deref()
237 }
238
239 pub fn upsert_device(&self, device_id: &str, name: Option<&str>) -> Result<StoredDevice> {
267 let now = OffsetDateTime::now_utc().unix_timestamp();
268
269 self.conn.execute(
270 "INSERT INTO devices (id, name, first_seen, last_seen) VALUES (?1, ?2, ?3, ?3)
271 ON CONFLICT(id) DO UPDATE SET
272 name = COALESCE(?2, name),
273 last_seen = ?3",
274 rusqlite::params![device_id, name, now],
275 )?;
276
277 self.get_device(device_id)?
278 .ok_or_else(|| Error::DeviceNotFound(device_id.to_string()))
279 }
280
281 pub fn update_device_metadata(
286 &self,
287 device_id: &str,
288 name: Option<&str>,
289 device_type: Option<DeviceType>,
290 ) -> Result<()> {
291 let device_type_str = device_type.map(|dt| format!("{:?}", dt));
292 let now = OffsetDateTime::now_utc().unix_timestamp();
293
294 self.conn.execute(
295 "UPDATE devices SET
296 name = COALESCE(?2, name),
297 device_type = COALESCE(?3, device_type),
298 last_seen = ?4
299 WHERE id = ?1",
300 rusqlite::params![device_id, name, device_type_str, now],
301 )?;
302
303 Ok(())
304 }
305
306 pub fn update_device_info(&self, device_id: &str, info: &DeviceInfo) -> Result<()> {
311 let device_type = DeviceType::from_name(&info.model).map(|dt| format!("{:?}", dt));
313
314 let name = if info.name.is_empty() {
315 None
316 } else {
317 Some(&info.name)
318 };
319
320 self.conn.execute(
321 "UPDATE devices SET
322 name = COALESCE(?2, name),
323 device_type = COALESCE(?3, device_type),
324 serial = COALESCE(?4, serial),
325 firmware = COALESCE(?5, firmware),
326 hardware = COALESCE(?6, hardware),
327 last_seen = ?7
328 WHERE id = ?1",
329 rusqlite::params![
330 device_id,
331 name,
332 device_type,
333 &info.serial,
334 &info.firmware,
335 &info.hardware,
336 OffsetDateTime::now_utc().unix_timestamp()
337 ],
338 )?;
339
340 Ok(())
341 }
342
343 pub fn get_device(&self, device_id: &str) -> Result<Option<StoredDevice>> {
367 let mut stmt = self.conn.prepare(
368 "SELECT id, name, device_type, serial, firmware, hardware, first_seen, last_seen
369 FROM devices WHERE id = ?",
370 )?;
371
372 let device = stmt
373 .query_row([device_id], |row| {
374 Ok(StoredDevice {
375 id: row.get(0)?,
376 name: row.get(1)?,
377 device_type: row
378 .get::<_, Option<String>>(2)?
379 .and_then(|s| parse_device_type(&s)),
380 serial: row.get(3)?,
381 firmware: row.get(4)?,
382 hardware: row.get(5)?,
383 first_seen: timestamp_from_unix(row.get(6)?),
384 last_seen: timestamp_from_unix(row.get(7)?),
385 })
386 })
387 .optional()?;
388
389 Ok(device)
390 }
391
392 pub fn list_devices(&self) -> Result<Vec<StoredDevice>> {
414 let mut stmt = self.conn.prepare(
415 "SELECT id, name, device_type, serial, firmware, hardware, first_seen, last_seen
416 FROM devices ORDER BY last_seen DESC",
417 )?;
418
419 let devices = stmt
420 .query_map([], |row| {
421 Ok(StoredDevice {
422 id: row.get(0)?,
423 name: row.get(1)?,
424 device_type: row
425 .get::<_, Option<String>>(2)?
426 .and_then(|s| parse_device_type(&s)),
427 serial: row.get(3)?,
428 firmware: row.get(4)?,
429 hardware: row.get(5)?,
430 first_seen: timestamp_from_unix(row.get(6)?),
431 last_seen: timestamp_from_unix(row.get(7)?),
432 })
433 })?
434 .collect::<std::result::Result<Vec<_>, _>>()?;
435
436 Ok(devices)
437 }
438
439 pub fn delete_device(&self, device_id: &str) -> Result<bool> {
444 let tx = self.conn.unchecked_transaction()?;
445
446 tx.execute(
448 "DELETE FROM history WHERE device_id = ?1",
449 rusqlite::params![device_id],
450 )?;
451
452 tx.execute(
453 "DELETE FROM readings WHERE device_id = ?1",
454 rusqlite::params![device_id],
455 )?;
456
457 tx.execute(
458 "DELETE FROM sync_state WHERE device_id = ?1",
459 rusqlite::params![device_id],
460 )?;
461
462 let rows_deleted = tx.execute(
463 "DELETE FROM devices WHERE id = ?1",
464 rusqlite::params![device_id],
465 )?;
466
467 tx.commit()?;
468
469 Ok(rows_deleted > 0)
470 }
471
472 pub fn prune_history(&self, older_than: OffsetDateTime) -> Result<u64> {
476 let ts = older_than.unix_timestamp();
477 let deleted = self.conn.execute(
478 "DELETE FROM history WHERE timestamp < ?1",
479 rusqlite::params![ts],
480 )?;
481 Ok(deleted as u64)
482 }
483
484 pub fn prune_readings(&self, older_than: OffsetDateTime) -> Result<u64> {
488 let ts = older_than.unix_timestamp();
489 let deleted = self.conn.execute(
490 "DELETE FROM readings WHERE captured_at < ?1",
491 rusqlite::params![ts],
492 )?;
493 Ok(deleted as u64)
494 }
495
496 pub fn vacuum(&self) -> Result<()> {
498 self.conn.execute_batch("VACUUM;")?;
499 Ok(())
500 }
501}
502
503fn parse_device_type(s: &str) -> Option<DeviceType> {
504 match s {
505 "Aranet4" => Some(DeviceType::Aranet4),
506 "Aranet2" => Some(DeviceType::Aranet2),
507 "AranetRadon" => Some(DeviceType::AranetRadon),
508 "AranetRadiation" => Some(DeviceType::AranetRadiation),
509 _ => None,
510 }
511}
512
513fn parse_status(s: &str) -> Status {
514 match s {
515 "Green" => Status::Green,
516 "Yellow" => Status::Yellow,
517 "Red" => Status::Red,
518 "Error" => Status::Error,
519 _ => {
520 tracing::warn!("Unknown status value '{}', defaulting to Error", s);
521 Status::Error
522 }
523 }
524}
525
526impl Store {
528 pub fn insert_reading(&self, device_id: &str, reading: &CurrentReading) -> Result<i64> {
563 self.upsert_device(device_id, None)?;
565
566 let captured_at = reading
567 .captured_at
568 .unwrap_or_else(OffsetDateTime::now_utc)
569 .unix_timestamp();
570
571 self.conn.execute(
572 "INSERT INTO readings (device_id, captured_at, co2, temperature, pressure,
573 humidity, battery, status, radon, radiation_rate, radiation_total,
574 radon_avg_24h, radon_avg_7d, radon_avg_30d)
575 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
576 rusqlite::params![
577 device_id,
578 captured_at,
579 reading.co2,
580 reading.temperature,
581 reading.pressure,
582 reading.humidity,
583 reading.battery,
584 format!("{:?}", reading.status),
585 reading.radon,
586 reading.radiation_rate,
587 reading.radiation_total,
588 reading.radon_avg_24h,
589 reading.radon_avg_7d,
590 reading.radon_avg_30d,
591 ],
592 )?;
593
594 Ok(self.conn.last_insert_rowid())
595 }
596
597 pub fn query_readings(&self, query: &ReadingQuery) -> Result<Vec<StoredReading>> {
628 let sql = query.build_sql();
629 let (_, params) = query.build_where();
630
631 debug!("Executing query: {}", sql);
632
633 let params_ref: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
634
635 let mut stmt = self.conn.prepare(&sql)?;
636 let readings = stmt
637 .query_map(params_ref.as_slice(), |row| {
638 Ok(StoredReading {
639 id: row.get(0)?,
640 device_id: row.get(1)?,
641 captured_at: timestamp_from_unix(row.get(2)?),
642 co2: u16::try_from(row.get::<_, i64>(3)?).unwrap_or_else(|e| {
643 warn!("Invalid co2 value in database: {e}");
644 0
645 }),
646 temperature: row.get(4)?,
647 pressure: row.get(5)?,
648 humidity: u8::try_from(row.get::<_, i64>(6)?).unwrap_or_else(|e| {
649 warn!("Invalid humidity value in database: {e}");
650 0
651 }),
652 battery: u8::try_from(row.get::<_, i64>(7)?).unwrap_or_else(|e| {
653 warn!("Invalid battery value in database: {e}");
654 0
655 }),
656 status: parse_status(&row.get::<_, String>(8)?),
657 radon: row
658 .get::<_, Option<i64>>(9)?
659 .and_then(|v| radon_from_i64(v, "readings")),
660 radiation_rate: row.get(10)?,
661 radiation_total: row.get(11)?,
662 radon_avg_24h: row
663 .get::<_, Option<i64>>(12)?
664 .and_then(|v| radon_from_i64(v, "readings")),
665 radon_avg_7d: row
666 .get::<_, Option<i64>>(13)?
667 .and_then(|v| radon_from_i64(v, "readings")),
668 radon_avg_30d: row
669 .get::<_, Option<i64>>(14)?
670 .and_then(|v| radon_from_i64(v, "readings")),
671 })
672 })?
673 .collect::<std::result::Result<Vec<_>, _>>()?;
674
675 Ok(readings)
676 }
677
678 pub fn get_latest_reading(&self, device_id: &str) -> Result<Option<StoredReading>> {
703 let query = ReadingQuery::new().device(device_id).limit(1);
704 let mut readings = self.query_readings(&query)?;
705 Ok(readings.pop())
706 }
707
708 pub fn list_latest_readings(&self) -> Result<Vec<(StoredDevice, StoredReading)>> {
713 let mut stmt = self.conn.prepare(
714 "SELECT
715 d.id, d.name, d.device_type, d.serial, d.firmware, d.hardware, d.first_seen, d.last_seen,
716 r.id, r.device_id, r.captured_at, r.co2, r.temperature, r.pressure, r.humidity, r.battery,
717 r.status, r.radon, r.radiation_rate, r.radiation_total, r.radon_avg_24h, r.radon_avg_7d, r.radon_avg_30d
718 FROM devices d
719 JOIN readings r ON r.id = (
720 SELECT latest.id
721 FROM readings latest
722 WHERE latest.device_id = d.id
723 ORDER BY latest.captured_at DESC, latest.id DESC
724 LIMIT 1
725 )
726 ORDER BY d.last_seen DESC",
727 )?;
728
729 let rows = stmt
730 .query_map([], |row| {
731 let device = StoredDevice {
732 id: row.get(0)?,
733 name: row.get(1)?,
734 device_type: row
735 .get::<_, Option<String>>(2)?
736 .and_then(|s| parse_device_type(&s)),
737 serial: row.get(3)?,
738 firmware: row.get(4)?,
739 hardware: row.get(5)?,
740 first_seen: timestamp_from_unix(row.get(6)?),
741 last_seen: timestamp_from_unix(row.get(7)?),
742 };
743 let reading = StoredReading {
744 id: row.get(8)?,
745 device_id: row.get(9)?,
746 captured_at: timestamp_from_unix(row.get(10)?),
747 co2: u16::try_from(row.get::<_, i64>(11)?).unwrap_or(0),
748 temperature: row.get(12)?,
749 pressure: row.get(13)?,
750 humidity: u8::try_from(row.get::<_, i64>(14)?).unwrap_or(0),
751 battery: u8::try_from(row.get::<_, i64>(15)?).unwrap_or(0),
752 status: parse_status(&row.get::<_, String>(16)?),
753 radon: row
754 .get::<_, Option<i64>>(17)?
755 .and_then(|v| radon_from_i64(v, "latest_readings")),
756 radiation_rate: row.get(18)?,
757 radiation_total: row.get(19)?,
758 radon_avg_24h: row
759 .get::<_, Option<i64>>(20)?
760 .and_then(|v| radon_from_i64(v, "latest_readings")),
761 radon_avg_7d: row
762 .get::<_, Option<i64>>(21)?
763 .and_then(|v| radon_from_i64(v, "latest_readings")),
764 radon_avg_30d: row
765 .get::<_, Option<i64>>(22)?
766 .and_then(|v| radon_from_i64(v, "latest_readings")),
767 };
768
769 Ok((device, reading))
770 })?
771 .collect::<std::result::Result<Vec<_>, _>>()?;
772
773 Ok(rows)
774 }
775
776 pub fn count_readings(&self, device_id: Option<&str>) -> Result<u64> {
798 let count: i64 = match device_id {
799 Some(id) => self.conn.query_row(
800 "SELECT COUNT(*) FROM readings WHERE device_id = ?",
801 [id],
802 |row| row.get(0),
803 )?,
804 None => self
805 .conn
806 .query_row("SELECT COUNT(*) FROM readings", [], |row| row.get(0))?,
807 };
808
809 Ok(count as u64)
810 }
811}
812
813impl Store {
815 pub fn insert_history(&self, device_id: &str, records: &[HistoryRecord]) -> Result<usize> {
857 self.upsert_device(device_id, None)?;
859
860 let tx = self.conn.unchecked_transaction()?;
861 let synced_at = OffsetDateTime::now_utc().unix_timestamp();
862 let mut inserted = 0;
863
864 for record in records {
865 let result = tx.execute(
866 "INSERT OR IGNORE INTO history (device_id, timestamp, synced_at, co2,
867 temperature, pressure, humidity, radon, radiation_rate, radiation_total)
868 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
869 rusqlite::params![
870 device_id,
871 record.timestamp.unix_timestamp(),
872 synced_at,
873 record.co2,
874 record.temperature,
875 record.pressure,
876 record.humidity,
877 record.radon,
878 record.radiation_rate,
879 record.radiation_total,
880 ],
881 )?;
882 inserted += result;
883 }
884
885 tx.commit()?;
886
887 let skipped = records.len() - inserted;
888 if skipped > 0 {
889 info!(
890 "Inserted {} new history records for {} ({} duplicates skipped)",
891 inserted, device_id, skipped
892 );
893 } else {
894 info!(
895 "Inserted {} new history records for {}",
896 inserted, device_id
897 );
898 }
899 Ok(inserted)
900 }
901
902 pub fn query_history(&self, query: &HistoryQuery) -> Result<Vec<StoredHistoryRecord>> {
930 let sql = query.build_sql();
931 let (_, params) = query.build_where();
932 let params_ref: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
933
934 let mut stmt = self.conn.prepare(&sql)?;
935 let records = stmt
936 .query_map(params_ref.as_slice(), |row| {
937 Ok(StoredHistoryRecord {
938 id: row.get(0)?,
939 device_id: row.get(1)?,
940 timestamp: timestamp_from_unix(row.get(2)?),
941 synced_at: timestamp_from_unix(row.get(3)?),
942 co2: u16::try_from(row.get::<_, i64>(4)?).unwrap_or_else(|e| {
943 warn!("Invalid co2 value in history: {e}");
944 0
945 }),
946 temperature: row.get(5)?,
947 pressure: row.get(6)?,
948 humidity: u8::try_from(row.get::<_, i64>(7)?).unwrap_or_else(|e| {
949 warn!("Invalid humidity value in history: {e}");
950 0
951 }),
952 radon: row
953 .get::<_, Option<i64>>(8)?
954 .and_then(|v| radon_from_i64(v, "history")),
955 radiation_rate: row.get(9)?,
956 radiation_total: row.get(10)?,
957 })
958 })?
959 .collect::<std::result::Result<Vec<_>, _>>()?;
960
961 Ok(records)
962 }
963
964 pub fn count_history(&self, device_id: Option<&str>) -> Result<u64> {
986 let count: i64 = match device_id {
987 Some(id) => self.conn.query_row(
988 "SELECT COUNT(*) FROM history WHERE device_id = ?",
989 [id],
990 |row| row.get(0),
991 )?,
992 None => self
993 .conn
994 .query_row("SELECT COUNT(*) FROM history", [], |row| row.get(0))?,
995 };
996
997 Ok(count as u64)
998 }
999}
1000
1001impl Store {
1003 pub fn get_sync_state(&self, device_id: &str) -> Result<Option<SyncState>> {
1030 let mut stmt = self.conn.prepare(
1031 "SELECT device_id, last_history_index, total_readings, last_sync_at
1032 FROM sync_state WHERE device_id = ?",
1033 )?;
1034
1035 let state = stmt
1036 .query_row([device_id], |row| {
1037 Ok(SyncState {
1038 device_id: row.get(0)?,
1039 last_history_index: row
1040 .get::<_, Option<i64>>(1)?
1041 .and_then(|v| u16::try_from(v).ok()),
1042 total_readings: row
1043 .get::<_, Option<i64>>(2)?
1044 .and_then(|v| u16::try_from(v).ok()),
1045 last_sync_at: row.get::<_, Option<i64>>(3)?.map(timestamp_from_unix),
1046 })
1047 })
1048 .optional()?;
1049
1050 Ok(state)
1051 }
1052
1053 pub fn update_sync_state(
1082 &self,
1083 device_id: &str,
1084 last_index: u16,
1085 total_readings: u16,
1086 ) -> Result<()> {
1087 let now = OffsetDateTime::now_utc().unix_timestamp();
1088
1089 self.conn.execute(
1090 "INSERT INTO sync_state (device_id, last_history_index, total_readings, last_sync_at)
1091 VALUES (?1, ?2, ?3, ?4)
1092 ON CONFLICT(device_id) DO UPDATE SET
1093 last_history_index = ?2,
1094 total_readings = ?3,
1095 last_sync_at = ?4",
1096 rusqlite::params![device_id, last_index, total_readings, now],
1097 )?;
1098
1099 debug!(
1100 "Updated sync state for {}: index={}, total={}",
1101 device_id, last_index, total_readings
1102 );
1103
1104 Ok(())
1105 }
1106
1107 pub fn calculate_sync_start(&self, device_id: &str, current_total: u16) -> Result<u16> {
1120 let state = self.get_sync_state(device_id)?;
1121
1122 match state {
1123 Some(s) if s.total_readings == Some(current_total) => {
1124 if s.last_sync_at.is_some() {
1131 let latest_stored = self.get_latest_history_timestamp(device_id)?;
1132
1133 match latest_stored {
1134 Some(latest_ts) => {
1135 let now = OffsetDateTime::now_utc();
1136 let time_since_latest = now - latest_ts;
1137
1138 if time_since_latest > time::Duration::minutes(10) {
1141 debug!(
1142 "Buffer may have wrapped for {} (latest record is {} min old), doing full sync",
1143 device_id,
1144 time_since_latest.whole_minutes()
1145 );
1146 return Ok(1);
1147 }
1148
1149 debug!("No new readings for {}", device_id);
1151 Ok(current_total.saturating_add(1))
1152 }
1153 None => {
1154 debug!(
1157 "Sync state exists but no history for {}, doing full sync",
1158 device_id
1159 );
1160 Ok(1)
1161 }
1162 }
1163 } else {
1164 debug!("No sync timestamp for {}, doing full sync", device_id);
1166 Ok(1)
1167 }
1168 }
1169 Some(s) if s.last_history_index.is_some() => {
1170 let last_index = match s.last_history_index {
1172 Some(idx) => idx,
1173 None => unreachable!("guarded by is_some() in match arm"),
1174 };
1175 let prev_total = s.total_readings.unwrap_or(0);
1176
1177 if current_total < prev_total {
1179 debug!(
1180 "Device total decreased ({} -> {}) for {}, device was reset - doing full sync",
1181 prev_total, current_total, device_id
1182 );
1183 return Ok(1);
1184 }
1185
1186 let new_count = current_total.saturating_sub(prev_total);
1187
1188 if new_count > 0 {
1189 let start = last_index.saturating_add(1);
1191
1192 if start > current_total {
1195 debug!(
1196 "Start index {} exceeds device total {} for {}, doing full sync",
1197 start, current_total, device_id
1198 );
1199 return Ok(1);
1200 }
1201
1202 debug!(
1203 "Incremental sync for {}: {} new readings, starting at {}",
1204 device_id, new_count, start
1205 );
1206 Ok(start)
1207 } else {
1208 Ok(current_total.saturating_add(1))
1209 }
1210 }
1211 _ => {
1212 debug!(
1214 "First sync for {}: downloading all {} readings",
1215 device_id, current_total
1216 );
1217 Ok(1)
1218 }
1219 }
1220 }
1221
1222 fn get_latest_history_timestamp(&self, device_id: &str) -> Result<Option<OffsetDateTime>> {
1226 let ts: Option<i64> = self
1227 .conn
1228 .query_row(
1229 "SELECT MAX(timestamp) FROM history WHERE device_id = ?",
1230 [device_id],
1231 |row| row.get(0),
1232 )
1233 .optional()?
1234 .flatten();
1235
1236 Ok(ts.map(timestamp_from_unix))
1237 }
1238}
1239
1240#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1242pub struct HistoryStats {
1243 pub count: u64,
1245 pub min: HistoryAggregates,
1247 pub max: HistoryAggregates,
1249 pub avg: HistoryAggregates,
1251 pub time_range: Option<(OffsetDateTime, OffsetDateTime)>,
1253}
1254
1255#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1257pub struct HistoryAggregates {
1258 pub co2: Option<f64>,
1260 pub temperature: Option<f64>,
1262 pub pressure: Option<f64>,
1264 pub humidity: Option<f64>,
1266 pub radon: Option<f64>,
1268}
1269
1270impl Store {
1272 pub fn history_stats(&self, query: &HistoryQuery) -> Result<HistoryStats> {
1305 let mut conditions = Vec::new();
1306 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1307
1308 if let Some(ref device_id) = query.device_id {
1309 conditions.push("device_id = ?");
1310 params.push(Box::new(device_id.clone()));
1311 }
1312
1313 if let Some(since) = query.since {
1314 conditions.push("timestamp >= ?");
1315 params.push(Box::new(since.unix_timestamp()));
1316 }
1317
1318 if let Some(until) = query.until {
1319 conditions.push("timestamp <= ?");
1320 params.push(Box::new(until.unix_timestamp()));
1321 }
1322
1323 let where_clause = if conditions.is_empty() {
1324 String::new()
1325 } else {
1326 format!("WHERE {}", conditions.join(" AND "))
1327 };
1328
1329 let sql = format!(
1330 "SELECT
1331 COUNT(*) as count,
1332 MIN(co2) as min_co2, MAX(co2) as max_co2, AVG(co2) as avg_co2,
1333 MIN(temperature) as min_temp, MAX(temperature) as max_temp, AVG(temperature) as avg_temp,
1334 MIN(pressure) as min_press, MAX(pressure) as max_press, AVG(pressure) as avg_press,
1335 MIN(humidity) as min_hum, MAX(humidity) as max_hum, AVG(humidity) as avg_hum,
1336 MIN(radon) as min_radon, MAX(radon) as max_radon, AVG(radon) as avg_radon,
1337 MIN(timestamp) as min_ts, MAX(timestamp) as max_ts
1338 FROM history {}",
1339 where_clause
1340 );
1341
1342 let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
1343
1344 let stats = self.conn.query_row(&sql, params_refs.as_slice(), |row| {
1345 let count: i64 = row.get(0)?;
1346 let min_ts: Option<i64> = row.get(16)?;
1347 let max_ts: Option<i64> = row.get(17)?;
1348
1349 let time_range = match (min_ts, max_ts) {
1350 (Some(min), Some(max)) => {
1351 Some((timestamp_from_unix(min), timestamp_from_unix(max)))
1352 }
1353 _ => None,
1354 };
1355
1356 Ok(HistoryStats {
1357 count: count as u64,
1358 min: HistoryAggregates {
1359 co2: row.get::<_, Option<i64>>(1)?.map(|v| v as f64),
1360 temperature: row.get(4)?,
1361 pressure: row.get(7)?,
1362 humidity: row.get::<_, Option<i64>>(10)?.map(|v| v as f64),
1363 radon: row.get::<_, Option<i64>>(13)?.map(|v| v as f64),
1364 },
1365 max: HistoryAggregates {
1366 co2: row.get::<_, Option<i64>>(2)?.map(|v| v as f64),
1367 temperature: row.get(5)?,
1368 pressure: row.get(8)?,
1369 humidity: row.get::<_, Option<i64>>(11)?.map(|v| v as f64),
1370 radon: row.get::<_, Option<i64>>(14)?.map(|v| v as f64),
1371 },
1372 avg: HistoryAggregates {
1373 co2: row.get(3)?,
1374 temperature: row.get(6)?,
1375 pressure: row.get(9)?,
1376 humidity: row.get(12)?,
1377 radon: row.get(15)?,
1378 },
1379 time_range,
1380 })
1381 })?;
1382
1383 Ok(stats)
1384 }
1385
1386 pub fn export_history_csv(&self, query: &HistoryQuery) -> Result<String> {
1413 let sql = query.build_sql_with_select(
1414 "SELECT timestamp, device_id, co2, temperature, pressure, humidity, radon, \
1415 radiation_rate, radiation_total FROM history",
1416 );
1417 let (_, params) = query.build_where();
1418 let params_ref: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
1419 let mut stmt = self.conn.prepare(&sql)?;
1420 let mut wtr = csv::Writer::from_writer(Vec::new());
1421
1422 wtr.write_record([
1424 "timestamp",
1425 "device_id",
1426 "co2",
1427 "temperature",
1428 "pressure",
1429 "humidity",
1430 "radon",
1431 "radiation_rate",
1432 "radiation_total",
1433 ])
1434 .map_err(|e| Error::Io(std::io::Error::other(e)))?;
1435
1436 let rows = stmt.query_map(params_ref.as_slice(), |row| {
1437 Ok((
1438 timestamp_from_unix(row.get(0)?),
1439 row.get::<_, String>(1)?,
1440 u16::try_from(row.get::<_, i64>(2)?).unwrap_or_else(|e| {
1441 warn!("Invalid co2 value in export row: {e}");
1442 0
1443 }),
1444 row.get::<_, f32>(3)?,
1445 row.get::<_, f32>(4)?,
1446 u8::try_from(row.get::<_, i64>(5)?).unwrap_or_else(|e| {
1447 warn!("Invalid humidity value in export row: {e}");
1448 0
1449 }),
1450 row.get::<_, Option<i64>>(6)?
1451 .and_then(|v| u32::try_from(v).ok()),
1452 row.get::<_, Option<f64>>(7)?,
1453 row.get::<_, Option<f64>>(8)?,
1454 ))
1455 })?;
1456
1457 for row in rows {
1459 let (
1460 timestamp,
1461 device_id,
1462 co2,
1463 temperature,
1464 pressure,
1465 humidity,
1466 radon,
1467 radiation_rate,
1468 radiation_total,
1469 ) = row?;
1470 let timestamp = match timestamp.format(&time::format_description::well_known::Rfc3339) {
1471 Ok(ts) => ts,
1472 Err(e) => {
1473 warn!("Skipping CSV row with unformattable timestamp: {e}");
1474 continue;
1475 }
1476 };
1477 let radon = radon.map(|r| r.to_string()).unwrap_or_default();
1478 let radiation_rate = radiation_rate
1479 .map(|r| format!("{:.4}", r))
1480 .unwrap_or_default();
1481 let radiation_total = radiation_total
1482 .map(|r| format!("{:.4}", r))
1483 .unwrap_or_default();
1484
1485 wtr.write_record(&[
1486 timestamp,
1487 device_id,
1488 co2.to_string(),
1489 format!("{:.1}", temperature),
1490 format!("{:.2}", pressure),
1491 humidity.to_string(),
1492 radon,
1493 radiation_rate,
1494 radiation_total,
1495 ])
1496 .map_err(|e| Error::Io(std::io::Error::other(e)))?;
1497 }
1498
1499 let bytes = wtr
1500 .into_inner()
1501 .map_err(|e| Error::Io(std::io::Error::other(e)))?;
1502 String::from_utf8(bytes).map_err(|e| Error::Io(std::io::Error::other(e)))
1503 }
1504
1505 pub fn export_history_json(&self, query: &HistoryQuery) -> Result<String> {
1527 let sql = query.build_sql();
1528 let (_, params) = query.build_where();
1529 let params_ref: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
1530 let mut stmt = self.conn.prepare(&sql)?;
1531 let rows = stmt.query_map(params_ref.as_slice(), |row| {
1532 Ok(StoredHistoryRecord {
1533 id: row.get(0)?,
1534 device_id: row.get(1)?,
1535 timestamp: timestamp_from_unix(row.get(2)?),
1536 synced_at: timestamp_from_unix(row.get(3)?),
1537 co2: u16::try_from(row.get::<_, i64>(4)?).unwrap_or_else(|e| {
1538 warn!("Invalid co2 value in JSON export: {e}");
1539 0
1540 }),
1541 temperature: row.get(5)?,
1542 pressure: row.get(6)?,
1543 humidity: u8::try_from(row.get::<_, i64>(7)?).unwrap_or_else(|e| {
1544 warn!("Invalid humidity value in JSON export: {e}");
1545 0
1546 }),
1547 radon: row
1548 .get::<_, Option<i64>>(8)?
1549 .and_then(|v| radon_from_i64(v, "json_export")),
1550 radiation_rate: row.get(9)?,
1551 radiation_total: row.get(10)?,
1552 })
1553 })?;
1554
1555 let mut json = String::from("[");
1556 let mut first = true;
1557
1558 for row in rows {
1559 let record = row?;
1560 let record_json = serde_json::to_string_pretty(&record)?;
1561 if first {
1562 json.push('\n');
1563 first = false;
1564 } else {
1565 json.push_str(",\n");
1566 }
1567
1568 for line in record_json.lines() {
1569 json.push_str(" ");
1570 json.push_str(line);
1571 json.push('\n');
1572 }
1573 }
1574
1575 json.push(']');
1576
1577 Ok(json)
1578 }
1579
1580 pub fn import_history_csv(&self, csv_data: &str) -> Result<ImportResult> {
1590 let mut reader = csv::ReaderBuilder::new()
1591 .has_headers(true)
1592 .flexible(true)
1593 .trim(csv::Trim::All)
1594 .from_reader(csv_data.as_bytes());
1595
1596 let mut total = 0;
1597 let mut imported = 0;
1598 let mut skipped = 0;
1599 let mut errors = Vec::new();
1600 let mut device_records: std::collections::HashMap<String, Vec<HistoryRecord>> =
1601 std::collections::HashMap::new();
1602
1603 for (line_num, result) in reader.records().enumerate() {
1604 total += 1;
1605 let line = line_num + 2; let record = match result {
1608 Ok(r) => r,
1609 Err(e) => {
1610 errors.push(format!("Line {}: parse error - {}", line, e));
1611 skipped += 1;
1612 continue;
1613 }
1614 };
1615
1616 let timestamp_str = record.get(0).unwrap_or("").trim();
1618 let device_id = record.get(1).unwrap_or("").trim();
1619 let co2_str = record.get(2).unwrap_or("").trim();
1620 let temp_str = record.get(3).unwrap_or("").trim();
1621 let pressure_str = record.get(4).unwrap_or("").trim();
1622 let humidity_str = record.get(5).unwrap_or("").trim();
1623 let radon_str = record.get(6).unwrap_or("").trim();
1624
1625 if device_id.is_empty() {
1627 errors.push(format!("Line {}: missing device_id", line));
1628 skipped += 1;
1629 continue;
1630 }
1631
1632 if timestamp_str.is_empty() {
1633 errors.push(format!("Line {}: missing timestamp", line));
1634 skipped += 1;
1635 continue;
1636 }
1637
1638 let timestamp = match OffsetDateTime::parse(
1640 timestamp_str,
1641 &time::format_description::well_known::Rfc3339,
1642 ) {
1643 Ok(ts) => ts,
1644 Err(_) => {
1645 errors.push(format!(
1646 "Line {}: invalid timestamp '{}'",
1647 line, timestamp_str
1648 ));
1649 skipped += 1;
1650 continue;
1651 }
1652 };
1653
1654 let co2: u16 = match co2_str.parse::<u32>() {
1658 Ok(v) if v <= 10000 => v as u16, Ok(v) => {
1660 errors.push(format!(
1661 "Line {}: CO2 value {} exceeds maximum of 10000 ppm",
1662 line, v
1663 ));
1664 skipped += 1;
1665 continue;
1666 }
1667 Err(_) if co2_str.is_empty() => 0,
1668 Err(_) => {
1669 errors.push(format!("Line {}: invalid CO2 value '{}'", line, co2_str));
1670 skipped += 1;
1671 continue;
1672 }
1673 };
1674
1675 let temperature: f32 = match temp_str.parse::<f32>() {
1676 Ok(v) if (-40.0..=100.0).contains(&v) => v,
1677 Ok(v) => {
1678 errors.push(format!(
1679 "Line {}: temperature {} is outside valid range (-40 to 100°C)",
1680 line, v
1681 ));
1682 skipped += 1;
1683 continue;
1684 }
1685 Err(_) if temp_str.is_empty() => 0.0,
1686 Err(_) => {
1687 errors.push(format!(
1688 "Line {}: invalid temperature value '{}'",
1689 line, temp_str
1690 ));
1691 skipped += 1;
1692 continue;
1693 }
1694 };
1695
1696 let pressure: f32 = match pressure_str.parse::<f32>() {
1697 Ok(v) if v == 0.0 || (800.0..=1200.0).contains(&v) => v,
1698 Ok(v) => {
1699 errors.push(format!(
1700 "Line {}: pressure {} is outside valid range (800-1200 hPa)",
1701 line, v
1702 ));
1703 skipped += 1;
1704 continue;
1705 }
1706 Err(_) if pressure_str.is_empty() => 0.0,
1707 Err(_) => {
1708 errors.push(format!(
1709 "Line {}: invalid pressure value '{}'",
1710 line, pressure_str
1711 ));
1712 skipped += 1;
1713 continue;
1714 }
1715 };
1716
1717 let humidity: u8 = match humidity_str.parse::<u8>() {
1718 Ok(v) if v <= 100 => v,
1719 Ok(v) => {
1720 errors.push(format!(
1721 "Line {}: humidity {} exceeds maximum of 100%",
1722 line, v
1723 ));
1724 skipped += 1;
1725 continue;
1726 }
1727 Err(_) if humidity_str.is_empty() => 0,
1728 Err(_) => {
1729 errors.push(format!(
1730 "Line {}: invalid humidity value '{}'",
1731 line, humidity_str
1732 ));
1733 skipped += 1;
1734 continue;
1735 }
1736 };
1737
1738 let radon: Option<u32> = if radon_str.is_empty() {
1739 None
1740 } else {
1741 match radon_str.parse::<u32>() {
1742 Ok(v) if v <= 100000 => Some(v), Ok(v) => {
1744 errors.push(format!(
1745 "Line {}: radon value {} exceeds maximum of 100000 Bq/m³",
1746 line, v
1747 ));
1748 skipped += 1;
1749 continue;
1750 }
1751 Err(_) => {
1752 errors.push(format!(
1753 "Line {}: invalid radon value '{}'",
1754 line, radon_str
1755 ));
1756 skipped += 1;
1757 continue;
1758 }
1759 }
1760 };
1761
1762 let history_record = HistoryRecord {
1764 timestamp,
1765 co2,
1766 temperature,
1767 pressure,
1768 humidity,
1769 radon,
1770 radiation_rate: None,
1771 radiation_total: None,
1772 };
1773
1774 device_records
1775 .entry(device_id.to_string())
1776 .or_default()
1777 .push(history_record);
1778 }
1779
1780 for (device_id, records) in device_records {
1781 self.upsert_device(&device_id, None)?;
1782 let total_records = records.len();
1783 let count = self.insert_history(&device_id, &records)?;
1784 imported += count;
1785 skipped += total_records.saturating_sub(count);
1786 }
1787
1788 Ok(ImportResult {
1789 total,
1790 imported,
1791 skipped,
1792 errors,
1793 })
1794 }
1795
1796 pub fn import_history_json(&self, json_data: &str) -> Result<ImportResult> {
1802 let records: Vec<StoredHistoryRecord> = serde_json::from_str(json_data)?;
1803
1804 let total = records.len();
1805 let mut imported = 0;
1806 let mut skipped = 0;
1807 let mut device_records: std::collections::HashMap<String, Vec<HistoryRecord>> =
1808 std::collections::HashMap::new();
1809
1810 for record in records {
1811 let device_id = record.device_id.clone();
1812 device_records
1813 .entry(device_id)
1814 .or_default()
1815 .push(record.to_history());
1816 }
1817
1818 for (device_id, records) in device_records {
1819 self.upsert_device(&device_id, None)?;
1820 let total_records = records.len();
1821 let count = self.insert_history(&device_id, &records)?;
1822 imported += count;
1823 skipped += total_records.saturating_sub(count);
1824 }
1825
1826 Ok(ImportResult {
1827 total,
1828 imported,
1829 skipped,
1830 errors: Vec::new(),
1831 })
1832 }
1833}
1834
1835#[derive(Debug, Clone)]
1837pub struct ImportResult {
1838 pub total: usize,
1840 pub imported: usize,
1842 pub skipped: usize,
1844 pub errors: Vec<String>,
1846}
1847
1848#[cfg(test)]
1849mod tests {
1850 use super::*;
1851 use aranet_types::Status;
1852
1853 fn create_test_reading() -> CurrentReading {
1854 CurrentReading {
1855 co2: 800,
1856 temperature: 22.5,
1857 pressure: 1013.0,
1858 humidity: 45,
1859 battery: 85,
1860 status: Status::Green,
1861 interval: 60,
1862 age: 30,
1863 captured_at: Some(OffsetDateTime::now_utc()),
1864 radon: None,
1865 radiation_rate: None,
1866 radiation_total: None,
1867 radon_avg_24h: None,
1868 radon_avg_7d: None,
1869 radon_avg_30d: None,
1870 }
1871 }
1872
1873 #[test]
1874 fn test_open_in_memory() {
1875 let store = Store::open_in_memory().unwrap();
1876 let devices = store.list_devices().unwrap();
1877 assert!(devices.is_empty());
1878 }
1879
1880 #[test]
1881 fn test_upsert_device() {
1882 let store = Store::open_in_memory().unwrap();
1883
1884 let device = store.upsert_device("test-device", Some("Test")).unwrap();
1885 assert_eq!(device.id, "test-device");
1886 assert_eq!(device.name, Some("Test".to_string()));
1887
1888 let device = store
1890 .upsert_device("test-device", Some("New Name"))
1891 .unwrap();
1892 assert_eq!(device.name, Some("New Name".to_string()));
1893 }
1894
1895 #[test]
1896 fn test_insert_and_query_reading() {
1897 let store = Store::open_in_memory().unwrap();
1898 let reading = create_test_reading();
1899
1900 store.insert_reading("test-device", &reading).unwrap();
1901
1902 let query = ReadingQuery::new().device("test-device");
1903 let readings = store.query_readings(&query).unwrap();
1904
1905 assert_eq!(readings.len(), 1);
1906 assert_eq!(readings[0].co2, 800);
1907 assert_eq!(readings[0].temperature, 22.5);
1908 }
1909
1910 #[test]
1911 fn test_get_latest_reading() {
1912 let store = Store::open_in_memory().unwrap();
1913
1914 let mut reading1 = create_test_reading();
1915 reading1.co2 = 700;
1916 store.insert_reading("test-device", &reading1).unwrap();
1917
1918 let mut reading2 = create_test_reading();
1919 reading2.co2 = 900;
1920 store.insert_reading("test-device", &reading2).unwrap();
1921
1922 let latest = store.get_latest_reading("test-device").unwrap().unwrap();
1923 assert_eq!(latest.co2, 900);
1924 }
1925
1926 #[test]
1927 fn test_list_latest_readings_returns_one_row_per_device() {
1928 let store = Store::open_in_memory().unwrap();
1929
1930 let mut alpha_old = create_test_reading();
1931 alpha_old.co2 = 700;
1932 alpha_old.captured_at = Some(OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(1));
1933 store.insert_reading("alpha", &alpha_old).unwrap();
1934
1935 let mut alpha_new = create_test_reading();
1936 alpha_new.co2 = 900;
1937 alpha_new.captured_at = Some(OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(2));
1938 store.insert_reading("alpha", &alpha_new).unwrap();
1939
1940 let mut beta = create_test_reading();
1941 beta.co2 = 500;
1942 beta.captured_at = Some(OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(3));
1943 store.insert_reading("beta", &beta).unwrap();
1944
1945 store.upsert_device("gamma", Some("No Reading")).unwrap();
1946
1947 let latest = store.list_latest_readings().unwrap();
1948
1949 assert_eq!(latest.len(), 2);
1950 let latest_by_device: std::collections::HashMap<_, _> = latest
1951 .into_iter()
1952 .map(|(device, reading)| (device.id, reading.co2))
1953 .collect();
1954 assert_eq!(latest_by_device.get("alpha"), Some(&900));
1955 assert_eq!(latest_by_device.get("beta"), Some(&500));
1956 assert!(!latest_by_device.contains_key("gamma"));
1957 }
1958
1959 #[test]
1960 fn test_insert_history_deduplication() {
1961 let store = Store::open_in_memory().unwrap();
1962
1963 let now = OffsetDateTime::now_utc();
1964 let records = vec![
1965 HistoryRecord {
1966 timestamp: now,
1967 co2: 800,
1968 temperature: 22.0,
1969 pressure: 1013.0,
1970 humidity: 45,
1971 radon: None,
1972 radiation_rate: None,
1973 radiation_total: None,
1974 },
1975 HistoryRecord {
1976 timestamp: now, co2: 850,
1978 temperature: 23.0,
1979 pressure: 1014.0,
1980 humidity: 46,
1981 radon: None,
1982 radiation_rate: None,
1983 radiation_total: None,
1984 },
1985 ];
1986
1987 let inserted = store.insert_history("test-device", &records).unwrap();
1988 assert_eq!(inserted, 1); let count = store.count_history(Some("test-device")).unwrap();
1991 assert_eq!(count, 1);
1992 }
1993
1994 #[test]
1995 fn test_sync_state() {
1996 let store = Store::open_in_memory().unwrap();
1997 store.upsert_device("test-device", None).unwrap();
1998
1999 let state = store.get_sync_state("test-device").unwrap();
2001 assert!(state.is_none());
2002
2003 store.update_sync_state("test-device", 100, 100).unwrap();
2005
2006 let state = store.get_sync_state("test-device").unwrap().unwrap();
2007 assert_eq!(state.last_history_index, Some(100));
2008 assert_eq!(state.total_readings, Some(100));
2009 assert!(state.last_sync_at.is_some());
2010 }
2011
2012 #[test]
2013 fn test_calculate_sync_start() {
2014 let store = Store::open_in_memory().unwrap();
2015 store.upsert_device("test-device", None).unwrap();
2016
2017 let start = store.calculate_sync_start("test-device", 100).unwrap();
2019 assert_eq!(start, 1);
2020
2021 let now = OffsetDateTime::now_utc();
2023 let records = vec![HistoryRecord {
2024 timestamp: now,
2025 co2: 800,
2026 temperature: 22.0,
2027 pressure: 1013.0,
2028 humidity: 45,
2029 radon: None,
2030 radiation_rate: None,
2031 radiation_total: None,
2032 }];
2033 store.insert_history("test-device", &records).unwrap();
2034 store.update_sync_state("test-device", 100, 100).unwrap();
2035
2036 let start = store.calculate_sync_start("test-device", 100).unwrap();
2038 assert_eq!(start, 101);
2039
2040 let start = store.calculate_sync_start("test-device", 110).unwrap();
2042 assert_eq!(start, 101);
2043 }
2044
2045 #[test]
2046 fn test_calculate_sync_start_cache_cleared() {
2047 let store = Store::open_in_memory().unwrap();
2048 store.upsert_device("test-device", None).unwrap();
2049
2050 store.update_sync_state("test-device", 100, 100).unwrap();
2052
2053 let start = store.calculate_sync_start("test-device", 100).unwrap();
2055 assert_eq!(start, 1);
2056 }
2057
2058 #[test]
2059 fn test_calculate_sync_start_buffer_wrapped() {
2060 let store = Store::open_in_memory().unwrap();
2061 store.upsert_device("test-device", None).unwrap();
2062
2063 let old_time = OffsetDateTime::now_utc() - time::Duration::minutes(30);
2065 let records = vec![HistoryRecord {
2066 timestamp: old_time,
2067 co2: 800,
2068 temperature: 22.0,
2069 pressure: 1013.0,
2070 humidity: 45,
2071 radon: None,
2072 radiation_rate: None,
2073 radiation_total: None,
2074 }];
2075 store.insert_history("test-device", &records).unwrap();
2076 store.update_sync_state("test-device", 100, 100).unwrap();
2077
2078 let start = store.calculate_sync_start("test-device", 100).unwrap();
2081 assert_eq!(start, 1);
2082 }
2083
2084 #[test]
2085 fn test_calculate_sync_start_index_overflow() {
2086 let store = Store::open_in_memory().unwrap();
2087 store.upsert_device("test-device", None).unwrap();
2088
2089 store.update_sync_state("test-device", 500, 500).unwrap();
2091
2092 let start = store.calculate_sync_start("test-device", 200).unwrap();
2095 assert_eq!(start, 1);
2096 }
2097
2098 #[test]
2099 fn test_import_history_csv() {
2100 let store = Store::open_in_memory().unwrap();
2101
2102 let csv_data = r#"timestamp,device_id,co2,temperature,pressure,humidity,radon
21032024-01-15T10:30:00Z,Aranet4 17C3C,800,22.5,1013.25,45,
21042024-01-15T11:30:00Z,Aranet4 17C3C,850,23.0,1014.00,48,
21052024-01-15T12:30:00Z,AranetRn+ 306B8,0,21.0,1012.00,50,150
2106"#;
2107
2108 let result = store.import_history_csv(csv_data).unwrap();
2109
2110 assert_eq!(result.total, 3);
2111 assert_eq!(result.imported, 3);
2112 assert_eq!(result.skipped, 0);
2113 assert!(result.errors.is_empty());
2114
2115 let devices = store.list_devices().unwrap();
2117 assert_eq!(devices.len(), 2);
2118
2119 let query = HistoryQuery::new().device("Aranet4 17C3C");
2121 let records = store.query_history(&query).unwrap();
2122 assert_eq!(records.len(), 2);
2123 assert_eq!(records[0].co2, 850); assert_eq!(records[1].co2, 800); let query = HistoryQuery::new().device("AranetRn+ 306B8");
2128 let records = store.query_history(&query).unwrap();
2129 assert_eq!(records.len(), 1);
2130 assert_eq!(records[0].radon, Some(150));
2131 }
2132
2133 #[test]
2134 fn test_import_history_csv_deduplication() {
2135 let store = Store::open_in_memory().unwrap();
2136
2137 let csv_data = r#"timestamp,device_id,co2,temperature,pressure,humidity,radon
21382024-01-15T10:30:00Z,test-device,800,22.5,1013.25,45,
2139"#;
2140
2141 let result = store.import_history_csv(csv_data).unwrap();
2143 assert_eq!(result.imported, 1);
2144
2145 let result = store.import_history_csv(csv_data).unwrap();
2147 assert_eq!(result.imported, 0);
2148 assert_eq!(result.skipped, 1);
2149 }
2150
2151 #[test]
2152 fn test_import_history_csv_with_errors() {
2153 let store = Store::open_in_memory().unwrap();
2154
2155 let csv_data = r#"timestamp,device_id,co2,temperature,pressure,humidity,radon
2156invalid-timestamp,test-device,800,22.5,1013.25,45,
21572024-01-15T10:30:00Z,,800,22.5,1013.25,45,
21582024-01-15T11:30:00Z,valid-device,900,23.0,1014.00,50,
2159"#;
2160
2161 let result = store.import_history_csv(csv_data).unwrap();
2162
2163 assert_eq!(result.total, 3);
2164 assert_eq!(result.imported, 1);
2165 assert_eq!(result.skipped, 2);
2166 assert_eq!(result.errors.len(), 2);
2167 }
2168
2169 #[test]
2170 fn test_import_history_json() {
2171 let store = Store::open_in_memory().unwrap();
2172
2173 let json_data = r#"[
2174 {
2175 "id": 0,
2176 "device_id": "Aranet4 17C3C",
2177 "timestamp": "2024-01-15T10:30:00Z",
2178 "synced_at": "2024-01-15T12:00:00Z",
2179 "co2": 800,
2180 "temperature": 22.5,
2181 "pressure": 1013.25,
2182 "humidity": 45,
2183 "radon": null,
2184 "radiation_rate": null,
2185 "radiation_total": null
2186 },
2187 {
2188 "id": 0,
2189 "device_id": "Aranet4 17C3C",
2190 "timestamp": "2024-01-15T11:30:00Z",
2191 "synced_at": "2024-01-15T12:00:00Z",
2192 "co2": 850,
2193 "temperature": 23.0,
2194 "pressure": 1014.0,
2195 "humidity": 48,
2196 "radon": null,
2197 "radiation_rate": null,
2198 "radiation_total": null
2199 }
2200 ]"#;
2201
2202 let result = store.import_history_json(json_data).unwrap();
2203
2204 assert_eq!(result.total, 2);
2205 assert_eq!(result.imported, 2);
2206 assert_eq!(result.skipped, 0);
2207
2208 let query = HistoryQuery::new().device("Aranet4 17C3C");
2210 let records = store.query_history(&query).unwrap();
2211 assert_eq!(records.len(), 2);
2212 }
2213
2214 #[test]
2217 fn test_history_stats_empty() {
2218 let store = Store::open_in_memory().unwrap();
2219
2220 let query = HistoryQuery::new();
2221 let stats = store.history_stats(&query).unwrap();
2222
2223 assert_eq!(stats.count, 0);
2224 assert!(stats.min.co2.is_none());
2225 assert!(stats.max.co2.is_none());
2226 assert!(stats.avg.co2.is_none());
2227 assert!(stats.time_range.is_none());
2228 }
2229
2230 #[test]
2231 fn test_history_stats_single_record() {
2232 let store = Store::open_in_memory().unwrap();
2233
2234 let now = OffsetDateTime::now_utc();
2235 let records = vec![HistoryRecord {
2236 timestamp: now,
2237 co2: 800,
2238 temperature: 22.5,
2239 pressure: 1013.0,
2240 humidity: 45,
2241 radon: None,
2242 radiation_rate: None,
2243 radiation_total: None,
2244 }];
2245
2246 store.insert_history("test-device", &records).unwrap();
2247
2248 let query = HistoryQuery::new();
2249 let stats = store.history_stats(&query).unwrap();
2250
2251 assert_eq!(stats.count, 1);
2252 assert_eq!(stats.min.co2, Some(800.0));
2253 assert_eq!(stats.max.co2, Some(800.0));
2254 assert_eq!(stats.avg.co2, Some(800.0));
2255 assert_eq!(stats.min.temperature, Some(22.5));
2256 assert_eq!(stats.max.temperature, Some(22.5));
2257 }
2258
2259 #[test]
2260 fn test_history_stats_multiple_records() {
2261 let store = Store::open_in_memory().unwrap();
2262
2263 let base_time = OffsetDateTime::now_utc();
2264 let records = vec![
2265 HistoryRecord {
2266 timestamp: base_time,
2267 co2: 600,
2268 temperature: 20.0,
2269 pressure: 1010.0,
2270 humidity: 40,
2271 radon: None,
2272 radiation_rate: None,
2273 radiation_total: None,
2274 },
2275 HistoryRecord {
2276 timestamp: base_time + time::Duration::hours(1),
2277 co2: 800,
2278 temperature: 22.0,
2279 pressure: 1012.0,
2280 humidity: 50,
2281 radon: None,
2282 radiation_rate: None,
2283 radiation_total: None,
2284 },
2285 HistoryRecord {
2286 timestamp: base_time + time::Duration::hours(2),
2287 co2: 1000,
2288 temperature: 24.0,
2289 pressure: 1014.0,
2290 humidity: 60,
2291 radon: None,
2292 radiation_rate: None,
2293 radiation_total: None,
2294 },
2295 ];
2296
2297 store.insert_history("test-device", &records).unwrap();
2298
2299 let query = HistoryQuery::new();
2300 let stats = store.history_stats(&query).unwrap();
2301
2302 assert_eq!(stats.count, 3);
2303 assert_eq!(stats.min.co2, Some(600.0));
2304 assert_eq!(stats.max.co2, Some(1000.0));
2305 assert_eq!(stats.avg.co2, Some(800.0));
2306 assert_eq!(stats.min.temperature, Some(20.0));
2307 assert_eq!(stats.max.temperature, Some(24.0));
2308 assert_eq!(stats.avg.humidity, Some(50.0));
2309 }
2310
2311 #[test]
2312 fn test_history_stats_with_device_filter() {
2313 let store = Store::open_in_memory().unwrap();
2314
2315 let now = OffsetDateTime::now_utc();
2316
2317 store
2319 .insert_history(
2320 "device-1",
2321 &[HistoryRecord {
2322 timestamp: now,
2323 co2: 1200,
2324 temperature: 25.0,
2325 pressure: 1015.0,
2326 humidity: 55,
2327 radon: None,
2328 radiation_rate: None,
2329 radiation_total: None,
2330 }],
2331 )
2332 .unwrap();
2333
2334 store
2336 .insert_history(
2337 "device-2",
2338 &[HistoryRecord {
2339 timestamp: now,
2340 co2: 400,
2341 temperature: 18.0,
2342 pressure: 1010.0,
2343 humidity: 35,
2344 radon: None,
2345 radiation_rate: None,
2346 radiation_total: None,
2347 }],
2348 )
2349 .unwrap();
2350
2351 let query = HistoryQuery::new().device("device-1");
2353 let stats = store.history_stats(&query).unwrap();
2354
2355 assert_eq!(stats.count, 1);
2356 assert_eq!(stats.avg.co2, Some(1200.0));
2357 }
2358
2359 #[test]
2360 fn test_history_stats_with_time_range() {
2361 let store = Store::open_in_memory().unwrap();
2362
2363 let base_time = OffsetDateTime::now_utc();
2364 let records = vec![
2365 HistoryRecord {
2366 timestamp: base_time - time::Duration::days(2),
2367 co2: 500,
2368 temperature: 19.0,
2369 pressure: 1008.0,
2370 humidity: 40,
2371 radon: None,
2372 radiation_rate: None,
2373 radiation_total: None,
2374 },
2375 HistoryRecord {
2376 timestamp: base_time,
2377 co2: 800,
2378 temperature: 22.0,
2379 pressure: 1012.0,
2380 humidity: 50,
2381 radon: None,
2382 radiation_rate: None,
2383 radiation_total: None,
2384 },
2385 ];
2386
2387 store.insert_history("test-device", &records).unwrap();
2388
2389 let query = HistoryQuery::new().since(base_time - time::Duration::hours(1));
2391 let stats = store.history_stats(&query).unwrap();
2392
2393 assert_eq!(stats.count, 1);
2394 assert_eq!(stats.avg.co2, Some(800.0));
2395 }
2396
2397 #[test]
2398 fn test_history_stats_with_radon() {
2399 let store = Store::open_in_memory().unwrap();
2400
2401 let now = OffsetDateTime::now_utc();
2402 let records = vec![
2403 HistoryRecord {
2404 timestamp: now,
2405 co2: 0,
2406 temperature: 20.0,
2407 pressure: 1010.0,
2408 humidity: 50,
2409 radon: Some(100),
2410 radiation_rate: None,
2411 radiation_total: None,
2412 },
2413 HistoryRecord {
2414 timestamp: now + time::Duration::hours(1),
2415 co2: 0,
2416 temperature: 20.0,
2417 pressure: 1010.0,
2418 humidity: 50,
2419 radon: Some(200),
2420 radiation_rate: None,
2421 radiation_total: None,
2422 },
2423 ];
2424
2425 store.insert_history("radon-device", &records).unwrap();
2426
2427 let query = HistoryQuery::new();
2428 let stats = store.history_stats(&query).unwrap();
2429
2430 assert_eq!(stats.count, 2);
2431 assert_eq!(stats.min.radon, Some(100.0));
2432 assert_eq!(stats.max.radon, Some(200.0));
2433 assert_eq!(stats.avg.radon, Some(150.0));
2434 }
2435
2436 #[test]
2437 fn test_history_stats_time_range_values() {
2438 let store = Store::open_in_memory().unwrap();
2439
2440 use time::macros::datetime;
2442 let start = datetime!(2024-01-01 00:00:00 UTC);
2443 let end = datetime!(2024-01-08 00:00:00 UTC);
2444
2445 let records = vec![
2446 HistoryRecord {
2447 timestamp: start,
2448 co2: 700,
2449 temperature: 21.0,
2450 pressure: 1011.0,
2451 humidity: 45,
2452 radon: None,
2453 radiation_rate: None,
2454 radiation_total: None,
2455 },
2456 HistoryRecord {
2457 timestamp: end,
2458 co2: 900,
2459 temperature: 23.0,
2460 pressure: 1013.0,
2461 humidity: 55,
2462 radon: None,
2463 radiation_rate: None,
2464 radiation_total: None,
2465 },
2466 ];
2467
2468 store.insert_history("test-device", &records).unwrap();
2469
2470 let query = HistoryQuery::new();
2471 let stats = store.history_stats(&query).unwrap();
2472
2473 let (min_ts, max_ts) = stats.time_range.unwrap();
2474 assert_eq!(min_ts, start);
2475 assert_eq!(max_ts, end);
2476 }
2477
2478 #[test]
2481 fn test_export_history_csv_empty() {
2482 let store = Store::open_in_memory().unwrap();
2483
2484 let query = HistoryQuery::new();
2485 let csv = store.export_history_csv(&query).unwrap();
2486
2487 assert!(csv.starts_with("timestamp,device_id,co2,temperature,pressure,humidity,radon,radiation_rate,radiation_total\n"));
2488 assert_eq!(csv.lines().count(), 1);
2490 }
2491
2492 #[test]
2493 fn test_export_history_csv_with_data() {
2494 let store = Store::open_in_memory().unwrap();
2495
2496 let csv_data = r#"timestamp,device_id,co2,temperature,pressure,humidity,radon
24972024-01-15T10:30:00Z,test-device,800,22.5,1013.25,45,
2498"#;
2499 store.import_history_csv(csv_data).unwrap();
2500
2501 let query = HistoryQuery::new();
2502 let csv = store.export_history_csv(&query).unwrap();
2503
2504 assert!(csv.contains("test-device"));
2505 assert!(csv.contains("800"));
2506 assert!(csv.contains("22.5"));
2507 assert!(csv.contains("1013.25"));
2508 assert!(csv.contains("45"));
2509 }
2510
2511 #[test]
2512 fn test_export_history_csv_with_radon() {
2513 let store = Store::open_in_memory().unwrap();
2514
2515 let now = OffsetDateTime::now_utc();
2516 let records = vec![HistoryRecord {
2517 timestamp: now,
2518 co2: 0,
2519 temperature: 20.0,
2520 pressure: 1010.0,
2521 humidity: 50,
2522 radon: Some(150),
2523 radiation_rate: None,
2524 radiation_total: None,
2525 }];
2526
2527 store.insert_history("radon-device", &records).unwrap();
2528
2529 let query = HistoryQuery::new();
2530 let csv = store.export_history_csv(&query).unwrap();
2531
2532 assert!(csv.contains("150"));
2533 }
2534
2535 #[test]
2536 fn test_export_history_csv_format() {
2537 let store = Store::open_in_memory().unwrap();
2538
2539 let csv_data = r#"timestamp,device_id,co2,temperature,pressure,humidity,radon
25402024-01-15T10:30:00Z,device-1,800,22.5,1013.25,45,
25412024-01-15T11:30:00Z,device-1,850,23.0,1014.00,48,
2542"#;
2543 store.import_history_csv(csv_data).unwrap();
2544
2545 let query = HistoryQuery::new().oldest_first();
2546 let csv = store.export_history_csv(&query).unwrap();
2547
2548 let lines: Vec<&str> = csv.lines().collect();
2549 assert_eq!(lines.len(), 3); assert!(lines[0].contains("timestamp"));
2553 assert!(lines[0].contains("device_id"));
2554 assert!(lines[0].contains("co2"));
2555
2556 assert!(lines[1].contains("800"));
2558 assert!(lines[2].contains("850"));
2559 }
2560
2561 #[test]
2562 fn test_export_history_json_empty() {
2563 let store = Store::open_in_memory().unwrap();
2564
2565 let query = HistoryQuery::new();
2566 let json = store.export_history_json(&query).unwrap();
2567
2568 assert_eq!(json.trim(), "[]");
2569 }
2570
2571 #[test]
2572 fn test_export_history_json_with_data() {
2573 let store = Store::open_in_memory().unwrap();
2574
2575 let now = OffsetDateTime::now_utc();
2576 let records = vec![HistoryRecord {
2577 timestamp: now,
2578 co2: 800,
2579 temperature: 22.5,
2580 pressure: 1013.0,
2581 humidity: 45,
2582 radon: None,
2583 radiation_rate: None,
2584 radiation_total: None,
2585 }];
2586
2587 store.insert_history("test-device", &records).unwrap();
2588
2589 let query = HistoryQuery::new();
2590 let json = store.export_history_json(&query).unwrap();
2591
2592 let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
2594 assert_eq!(parsed.len(), 1);
2595 assert_eq!(parsed[0]["device_id"], "test-device");
2596 assert_eq!(parsed[0]["co2"], 800);
2597 }
2598
2599 #[test]
2600 fn test_export_import_json_roundtrip() {
2601 let store = Store::open_in_memory().unwrap();
2602
2603 let now = OffsetDateTime::now_utc();
2604 let original_records = vec![
2605 HistoryRecord {
2606 timestamp: now,
2607 co2: 750,
2608 temperature: 21.5,
2609 pressure: 1012.0,
2610 humidity: 48,
2611 radon: None,
2612 radiation_rate: None,
2613 radiation_total: None,
2614 },
2615 HistoryRecord {
2616 timestamp: now + time::Duration::hours(1),
2617 co2: 850,
2618 temperature: 22.5,
2619 pressure: 1013.0,
2620 humidity: 52,
2621 radon: None,
2622 radiation_rate: None,
2623 radiation_total: None,
2624 },
2625 ];
2626
2627 store
2628 .insert_history("roundtrip-device", &original_records)
2629 .unwrap();
2630
2631 let query = HistoryQuery::new()
2633 .device("roundtrip-device")
2634 .oldest_first();
2635 let json = store.export_history_json(&query).unwrap();
2636
2637 let store2 = Store::open_in_memory().unwrap();
2639 let result = store2.import_history_json(&json).unwrap();
2640
2641 assert_eq!(result.imported, 2);
2642
2643 let records = store2.query_history(&query).unwrap();
2645 assert_eq!(records.len(), 2);
2646 assert_eq!(records[0].co2, 750);
2647 assert_eq!(records[1].co2, 850);
2648 }
2649
2650 #[test]
2653 fn test_query_readings_with_pagination() {
2654 let store = Store::open_in_memory().unwrap();
2655
2656 for i in 0..10 {
2658 let mut reading = create_test_reading();
2659 reading.co2 = 700 + i * 10;
2660 store.insert_reading("paginated-device", &reading).unwrap();
2661 }
2662
2663 let query = ReadingQuery::new()
2665 .device("paginated-device")
2666 .oldest_first()
2667 .limit(3)
2668 .offset(2);
2669
2670 let readings = store.query_readings(&query).unwrap();
2671 assert_eq!(readings.len(), 3);
2672 assert_eq!(readings[0].co2, 720); assert_eq!(readings[2].co2, 740); }
2675
2676 #[test]
2677 fn test_query_readings_time_range() {
2678 let store = Store::open_in_memory().unwrap();
2679
2680 let base_time = OffsetDateTime::now_utc();
2681
2682 let mut reading1 = create_test_reading();
2684 reading1.captured_at = Some(base_time - time::Duration::days(2));
2685 reading1.co2 = 600;
2686 store.insert_reading("time-device", &reading1).unwrap();
2687
2688 let mut reading2 = create_test_reading();
2689 reading2.captured_at = Some(base_time - time::Duration::hours(1));
2690 reading2.co2 = 800;
2691 store.insert_reading("time-device", &reading2).unwrap();
2692
2693 let mut reading3 = create_test_reading();
2694 reading3.captured_at = Some(base_time);
2695 reading3.co2 = 900;
2696 store.insert_reading("time-device", &reading3).unwrap();
2697
2698 let query = ReadingQuery::new()
2700 .device("time-device")
2701 .since(base_time - time::Duration::days(1));
2702
2703 let readings = store.query_readings(&query).unwrap();
2704 assert_eq!(readings.len(), 2);
2705 }
2706
2707 #[test]
2708 fn test_query_history_with_pagination() {
2709 let store = Store::open_in_memory().unwrap();
2710
2711 let base_time = OffsetDateTime::now_utc();
2712 let records: Vec<_> = (0..10)
2713 .map(|i| HistoryRecord {
2714 timestamp: base_time + time::Duration::hours(i),
2715 co2: 700 + (i as u16) * 10,
2716 temperature: 22.0,
2717 pressure: 1013.0,
2718 humidity: 50,
2719 radon: None,
2720 radiation_rate: None,
2721 radiation_total: None,
2722 })
2723 .collect();
2724
2725 store.insert_history("paginated-device", &records).unwrap();
2726
2727 let query = HistoryQuery::new()
2729 .device("paginated-device")
2730 .oldest_first()
2731 .limit(3)
2732 .offset(2);
2733
2734 let results = store.query_history(&query).unwrap();
2735 assert_eq!(results.len(), 3);
2736 assert_eq!(results[0].co2, 720);
2737 assert_eq!(results[2].co2, 740);
2738 }
2739
2740 #[test]
2743 fn test_update_device_info() {
2744 let store = Store::open_in_memory().unwrap();
2745 store.upsert_device("info-device", None).unwrap();
2746
2747 let info = aranet_types::DeviceInfo {
2748 name: "My Aranet4".to_string(),
2749 model: "Aranet4".to_string(),
2750 serial: "ABC123".to_string(),
2751 firmware: "v1.2.0".to_string(),
2752 hardware: "1.0".to_string(),
2753 ..Default::default()
2754 };
2755
2756 store.update_device_info("info-device", &info).unwrap();
2757
2758 let device = store.get_device("info-device").unwrap().unwrap();
2759 assert_eq!(device.name, Some("My Aranet4".to_string()));
2760 assert_eq!(device.serial, Some("ABC123".to_string()));
2761 assert_eq!(device.firmware, Some("v1.2.0".to_string()));
2762 assert_eq!(device.device_type, Some(aranet_types::DeviceType::Aranet4));
2763 }
2764
2765 #[test]
2766 fn test_update_device_info_aranet2() {
2767 let store = Store::open_in_memory().unwrap();
2768 store.upsert_device("aranet2-device", None).unwrap();
2769
2770 let info = aranet_types::DeviceInfo {
2771 name: "My Aranet2".to_string(),
2772 model: "Aranet2".to_string(),
2773 serial: "XYZ789".to_string(),
2774 firmware: "v2.0.0".to_string(),
2775 hardware: "2.0".to_string(),
2776 ..Default::default()
2777 };
2778
2779 store.update_device_info("aranet2-device", &info).unwrap();
2780
2781 let device = store.get_device("aranet2-device").unwrap().unwrap();
2782 assert_eq!(device.device_type, Some(aranet_types::DeviceType::Aranet2));
2783 }
2784
2785 #[test]
2786 fn test_update_device_info_radon() {
2787 let store = Store::open_in_memory().unwrap();
2788 store.upsert_device("radon-device", None).unwrap();
2789
2790 let info = aranet_types::DeviceInfo {
2791 name: "My AranetRn+".to_string(),
2792 model: "AranetRn+ Radon".to_string(),
2793 serial: "RAD001".to_string(),
2794 firmware: "v1.0.0".to_string(),
2795 hardware: "1.0".to_string(),
2796 ..Default::default()
2797 };
2798
2799 store.update_device_info("radon-device", &info).unwrap();
2800
2801 let device = store.get_device("radon-device").unwrap().unwrap();
2802 assert_eq!(
2803 device.device_type,
2804 Some(aranet_types::DeviceType::AranetRadon)
2805 );
2806 }
2807
2808 #[test]
2809 fn test_update_device_metadata() {
2810 let store = Store::open_in_memory().unwrap();
2811 store.upsert_device("meta-device", None).unwrap();
2812
2813 store
2814 .update_device_metadata(
2815 "meta-device",
2816 Some("Kitchen Sensor"),
2817 Some(aranet_types::DeviceType::Aranet4),
2818 )
2819 .unwrap();
2820
2821 let device = store.get_device("meta-device").unwrap().unwrap();
2822 assert_eq!(device.name, Some("Kitchen Sensor".to_string()));
2823 assert_eq!(device.device_type, Some(aranet_types::DeviceType::Aranet4));
2824 }
2825
2826 #[test]
2827 fn test_list_devices_ordered_by_last_seen() {
2828 let store = Store::open_in_memory().unwrap();
2829
2830 store.upsert_device("device-a", Some("First")).unwrap();
2833 std::thread::sleep(std::time::Duration::from_secs(1));
2834 store.upsert_device("device-b", Some("Second")).unwrap();
2835 std::thread::sleep(std::time::Duration::from_secs(1));
2836 store.upsert_device("device-c", Some("Third")).unwrap();
2837
2838 let devices = store.list_devices().unwrap();
2839 assert_eq!(devices.len(), 3);
2840
2841 assert!(devices[0].last_seen >= devices[1].last_seen);
2845 assert!(devices[1].last_seen >= devices[2].last_seen);
2846 }
2847
2848 #[test]
2849 fn test_count_readings() {
2850 let store = Store::open_in_memory().unwrap();
2851
2852 for _ in 0..5 {
2854 store
2855 .insert_reading("device-1", &create_test_reading())
2856 .unwrap();
2857 }
2858 for _ in 0..3 {
2859 store
2860 .insert_reading("device-2", &create_test_reading())
2861 .unwrap();
2862 }
2863
2864 assert_eq!(store.count_readings(Some("device-1")).unwrap(), 5);
2866 assert_eq!(store.count_readings(Some("device-2")).unwrap(), 3);
2867 assert_eq!(store.count_readings(Some("nonexistent")).unwrap(), 0);
2868
2869 assert_eq!(store.count_readings(None).unwrap(), 8);
2871 }
2872
2873 #[test]
2874 fn test_count_history() {
2875 let store = Store::open_in_memory().unwrap();
2876
2877 let now = OffsetDateTime::now_utc();
2878
2879 let records: Vec<_> = (0..5)
2881 .map(|i| HistoryRecord {
2882 timestamp: now + time::Duration::hours(i),
2883 co2: 800,
2884 temperature: 22.0,
2885 pressure: 1013.0,
2886 humidity: 50,
2887 radon: None,
2888 radiation_rate: None,
2889 radiation_total: None,
2890 })
2891 .collect();
2892
2893 store.insert_history("device-1", &records).unwrap();
2894 store.insert_history("device-2", &records[..3]).unwrap();
2895
2896 assert_eq!(store.count_history(Some("device-1")).unwrap(), 5);
2897 assert_eq!(store.count_history(Some("device-2")).unwrap(), 3);
2898 assert_eq!(store.count_history(None).unwrap(), 8);
2899 }
2900
2901 #[test]
2904 fn test_reading_with_all_sensor_types() {
2905 let store = Store::open_in_memory().unwrap();
2906
2907 let reading = create_test_reading();
2909 store.insert_reading("aranet4", &reading).unwrap();
2910
2911 let mut radon_reading = create_test_reading();
2913 radon_reading.co2 = 0;
2914 radon_reading.radon = Some(150);
2915 store.insert_reading("aranet-rn", &radon_reading).unwrap();
2916
2917 let mut rad_reading = create_test_reading();
2919 rad_reading.co2 = 0;
2920 rad_reading.radiation_rate = Some(0.12);
2921 rad_reading.radiation_total = Some(0.003);
2922 store.insert_reading("aranet-rad", &rad_reading).unwrap();
2923
2924 let aranet4_readings = store
2926 .query_readings(&ReadingQuery::new().device("aranet4"))
2927 .unwrap();
2928 assert_eq!(aranet4_readings.len(), 1);
2929 assert_eq!(aranet4_readings[0].co2, 800);
2930
2931 let radon_readings = store
2932 .query_readings(&ReadingQuery::new().device("aranet-rn"))
2933 .unwrap();
2934 assert_eq!(radon_readings.len(), 1);
2935 assert_eq!(radon_readings[0].radon, Some(150));
2936
2937 let rad_readings = store
2938 .query_readings(&ReadingQuery::new().device("aranet-rad"))
2939 .unwrap();
2940 assert_eq!(rad_readings.len(), 1);
2941 assert_eq!(rad_readings[0].radiation_rate, Some(0.12));
2942 }
2943
2944 #[test]
2945 fn test_device_not_found_error() {
2946 let store = Store::open_in_memory().unwrap();
2947
2948 let result = store.get_device("nonexistent");
2951 assert!(result.unwrap().is_none());
2952 }
2953
2954 #[test]
2955 fn test_empty_device_name() {
2956 let store = Store::open_in_memory().unwrap();
2957
2958 let info = aranet_types::DeviceInfo {
2960 name: "".to_string(),
2961 model: "Aranet4".to_string(),
2962 ..Default::default()
2963 };
2964
2965 store.upsert_device("empty-name-device", None).unwrap();
2966 store
2967 .update_device_info("empty-name-device", &info)
2968 .unwrap();
2969
2970 let device = store.get_device("empty-name-device").unwrap().unwrap();
2971 assert!(device.name.is_none());
2973 }
2974
2975 #[test]
2976 fn test_import_csv_invalid_json() {
2977 let store = Store::open_in_memory().unwrap();
2978
2979 let result = store.import_history_json("not valid json");
2980 assert!(result.is_err());
2981 }
2982
2983 #[test]
2984 fn test_reading_with_all_status_types() {
2985 let store = Store::open_in_memory().unwrap();
2986
2987 for status in [Status::Green, Status::Yellow, Status::Red, Status::Error] {
2988 let mut reading = create_test_reading();
2989 reading.status = status;
2990 let device_id = format!("status-{:?}", status);
2991 store.insert_reading(&device_id, &reading).unwrap();
2992
2993 let stored = store.get_latest_reading(&device_id).unwrap().unwrap();
2994 assert_eq!(stored.status, status);
2995 }
2996 }
2997
2998 #[tokio::test]
3004 async fn test_concurrent_reading_inserts() {
3005 use std::sync::Arc;
3006 use tokio::sync::Mutex;
3007
3008 let store = Arc::new(Mutex::new(Store::open_in_memory().unwrap()));
3009
3010 let mut handles = Vec::new();
3012 for task_id in 0..10 {
3013 let store = Arc::clone(&store);
3014 handles.push(tokio::spawn(async move {
3015 for i in 0..10 {
3016 let reading = CurrentReading {
3017 co2: 400 + (task_id * 100) + i,
3018 temperature: 20.0 + (task_id as f32),
3019 pressure: 1013.0,
3020 humidity: 50,
3021 battery: 85,
3022 status: Status::Green,
3023 interval: 60,
3024 age: 0,
3025 captured_at: Some(OffsetDateTime::now_utc()),
3026 radon: None,
3027 radiation_rate: None,
3028 radiation_total: None,
3029 radon_avg_24h: None,
3030 radon_avg_7d: None,
3031 radon_avg_30d: None,
3032 };
3033 let device_id = format!("concurrent-device-{}", task_id);
3034 let guard = store.lock().await;
3035 guard.insert_reading(&device_id, &reading).unwrap();
3036 }
3037 }));
3038 }
3039
3040 for handle in handles {
3042 handle.await.unwrap();
3043 }
3044
3045 let guard = store.lock().await;
3047 let total = guard.count_readings(None).unwrap();
3048 assert_eq!(total, 100); }
3050
3051 #[tokio::test]
3052 async fn test_concurrent_reads_and_writes() {
3053 use std::sync::Arc;
3054 use tokio::sync::Mutex;
3055
3056 let store = Arc::new(Mutex::new(Store::open_in_memory().unwrap()));
3057
3058 {
3060 let guard = store.lock().await;
3061 for i in 0..10 {
3062 let reading = CurrentReading {
3063 co2: 500 + i * 50,
3064 temperature: 22.0,
3065 pressure: 1013.0,
3066 humidity: 50,
3067 battery: 85,
3068 status: Status::Green,
3069 interval: 60,
3070 age: 0,
3071 captured_at: Some(OffsetDateTime::now_utc()),
3072 radon: None,
3073 radiation_rate: None,
3074 radiation_total: None,
3075 radon_avg_24h: None,
3076 radon_avg_7d: None,
3077 radon_avg_30d: None,
3078 };
3079 guard.insert_reading("shared-device", &reading).unwrap();
3080 }
3081 }
3082
3083 let mut handles = Vec::new();
3085
3086 for _ in 0..5 {
3088 let store = Arc::clone(&store);
3089 handles.push(tokio::spawn(async move {
3090 for _ in 0..10 {
3091 let guard = store.lock().await;
3092 let readings = guard
3093 .query_readings(&ReadingQuery::new().device("shared-device"))
3094 .unwrap();
3095 assert!(!readings.is_empty());
3096 drop(guard);
3097 tokio::task::yield_now().await;
3098 }
3099 }));
3100 }
3101
3102 for task_id in 0..3 {
3104 let store = Arc::clone(&store);
3105 handles.push(tokio::spawn(async move {
3106 for i in 0..5 {
3107 let reading = CurrentReading {
3108 co2: 1000 + (task_id * 100) + i,
3109 temperature: 25.0,
3110 pressure: 1015.0,
3111 humidity: 55,
3112 battery: 80,
3113 status: Status::Yellow,
3114 interval: 60,
3115 age: 0,
3116 captured_at: Some(OffsetDateTime::now_utc()),
3117 radon: None,
3118 radiation_rate: None,
3119 radiation_total: None,
3120 radon_avg_24h: None,
3121 radon_avg_7d: None,
3122 radon_avg_30d: None,
3123 };
3124 let guard = store.lock().await;
3125 guard.insert_reading("shared-device", &reading).unwrap();
3126 drop(guard);
3127 tokio::task::yield_now().await;
3128 }
3129 }));
3130 }
3131
3132 for handle in handles {
3134 handle.await.unwrap();
3135 }
3136
3137 let guard = store.lock().await;
3139 let total = guard.count_readings(Some("shared-device")).unwrap();
3140 assert_eq!(total, 10 + (3 * 5)); }
3142
3143 #[tokio::test]
3144 async fn test_concurrent_device_upserts() {
3145 use std::sync::Arc;
3146 use tokio::sync::Mutex;
3147
3148 let store = Arc::new(Mutex::new(Store::open_in_memory().unwrap()));
3149
3150 let mut handles = Vec::new();
3152 for i in 0..20 {
3153 let store = Arc::clone(&store);
3154 handles.push(tokio::spawn(async move {
3155 let guard = store.lock().await;
3156 guard
3157 .upsert_device("contested-device", Some(&format!("Name-{}", i)))
3158 .unwrap();
3159 }));
3160 }
3161
3162 for handle in handles {
3163 handle.await.unwrap();
3164 }
3165
3166 let guard = store.lock().await;
3168 let device = guard.get_device("contested-device").unwrap().unwrap();
3169 assert!(device.name.unwrap().starts_with("Name-"));
3170 }
3171}