Skip to main content

aranet_store/
models.rs

1//! Data models for stored sensor data.
2//!
3//! This module defines the types returned by [`Store`](crate::Store) queries:
4//!
5//! - [`StoredDevice`] - Device metadata and tracking information
6//! - [`StoredReading`] - Current/real-time sensor readings with database IDs
7//! - [`StoredHistoryRecord`] - Historical readings downloaded from device memory
8//! - [`SyncState`] - Tracks incremental history sync progress
9//!
10//! All types implement `Serialize` and `Deserialize` for easy JSON export/import.
11
12use serde::{Deserialize, Serialize};
13use time::OffsetDateTime;
14
15use aranet_types::{CurrentReading, DeviceType, HistoryRecord, Status};
16
17/// A device stored in the database with metadata and tracking information.
18///
19/// Devices are automatically created when readings are inserted. Additional
20/// metadata (name, type, firmware version) can be updated separately.
21///
22/// # Example
23///
24/// ```
25/// use aranet_store::Store;
26///
27/// let store = Store::open_in_memory()?;
28/// let device = store.upsert_device("Aranet4 17C3C", Some("Kitchen"))?;
29///
30/// println!("Device: {}", device.id);
31/// println!("Name: {:?}", device.name);
32/// println!("First seen: {}", device.first_seen);
33/// println!("Last seen: {}", device.last_seen);
34/// # Ok::<(), aranet_store::Error>(())
35/// ```
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct StoredDevice {
38    /// Device identifier (address or UUID).
39    pub id: String,
40    /// Device name.
41    pub name: Option<String>,
42    /// Device type.
43    pub device_type: Option<DeviceType>,
44    /// Serial number.
45    pub serial: Option<String>,
46    /// Firmware version.
47    pub firmware: Option<String>,
48    /// Hardware version.
49    pub hardware: Option<String>,
50    /// First time this device was seen.
51    #[serde(with = "time::serde::rfc3339")]
52    pub first_seen: OffsetDateTime,
53    /// Last time this device was seen.
54    #[serde(with = "time::serde::rfc3339")]
55    pub last_seen: OffsetDateTime,
56}
57
58/// A current sensor reading stored in the database.
59///
60/// This represents a point-in-time reading captured from a device, typically
61/// via BLE connection. Unlike [`StoredHistoryRecord`], these are readings
62/// captured by your application, not downloaded from the device's internal memory.
63///
64/// # Supported Sensor Types
65///
66/// - **Aranet4**: CO2, temperature, pressure, humidity
67/// - **Aranet2**: Temperature, humidity
68/// - **AranetRn+ (Radon)**: Radon level, temperature, humidity, pressure
69/// - **AranetRad (Radiation)**: Radiation rate/total, temperature, humidity
70///
71/// Fields for unsupported sensors (e.g., `radon` for Aranet4) will be `None`.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct StoredReading {
74    /// Database row ID.
75    pub id: i64,
76    /// Device identifier.
77    pub device_id: String,
78    /// When this reading was captured.
79    #[serde(with = "time::serde::rfc3339")]
80    pub captured_at: OffsetDateTime,
81    /// CO2 concentration in ppm.
82    pub co2: u16,
83    /// Temperature in Celsius.
84    pub temperature: f32,
85    /// Pressure in hPa.
86    pub pressure: f32,
87    /// Humidity percentage.
88    pub humidity: u8,
89    /// Battery percentage.
90    pub battery: u8,
91    /// Status indicator.
92    pub status: Status,
93    /// Radon level (Bq/m3) for radon devices.
94    pub radon: Option<u32>,
95    /// Radiation rate in uSv/h for radiation devices.
96    pub radiation_rate: Option<f32>,
97    /// Total radiation dose in mSv for radiation devices.
98    pub radiation_total: Option<f64>,
99    /// 24-hour average radon concentration in Bq/m³ (radon devices only).
100    pub radon_avg_24h: Option<u32>,
101    /// 7-day average radon concentration in Bq/m³ (radon devices only).
102    pub radon_avg_7d: Option<u32>,
103    /// 30-day average radon concentration in Bq/m³ (radon devices only).
104    pub radon_avg_30d: Option<u32>,
105}
106
107impl StoredReading {
108    /// Create a `StoredReading` from an `aranet_types::CurrentReading` with an explicit row ID.
109    pub fn from_reading_with_id(device_id: &str, reading: &CurrentReading, id: i64) -> Self {
110        Self {
111            id,
112            device_id: device_id.to_string(),
113            captured_at: reading.captured_at.unwrap_or_else(OffsetDateTime::now_utc),
114            co2: reading.co2,
115            temperature: reading.temperature,
116            pressure: reading.pressure,
117            humidity: reading.humidity,
118            battery: reading.battery,
119            status: reading.status,
120            radon: reading.radon,
121            radiation_rate: reading.radiation_rate,
122            radiation_total: reading.radiation_total,
123            radon_avg_24h: reading.radon_avg_24h,
124            radon_avg_7d: reading.radon_avg_7d,
125            radon_avg_30d: reading.radon_avg_30d,
126        }
127    }
128
129    /// Create a `StoredReading` from an `aranet_types::CurrentReading`.
130    ///
131    /// The database `id` is set to 0 and will be assigned by SQLite on insert.
132    /// If `captured_at` is `None` in the source reading, the current time is used.
133    ///
134    /// # Arguments
135    ///
136    /// * `device_id` - The device identifier this reading came from
137    /// * `reading` - The source reading from `aranet-types`
138    pub fn from_reading(device_id: &str, reading: &CurrentReading) -> Self {
139        Self::from_reading_with_id(device_id, reading, 0)
140    }
141
142    /// Convert back to an `aranet_types::CurrentReading`.
143    ///
144    /// Note: Some fields are not preserved in storage:
145    /// - `interval` and `age` are set to 0
146    ///
147    /// Use this when you need to pass stored data to functions expecting `CurrentReading`.
148    pub fn to_reading(&self) -> CurrentReading {
149        CurrentReading {
150            co2: self.co2,
151            temperature: self.temperature,
152            pressure: self.pressure,
153            humidity: self.humidity,
154            battery: self.battery,
155            status: self.status,
156            interval: 0,
157            age: 0,
158            captured_at: Some(self.captured_at),
159            radon: self.radon,
160            radiation_rate: self.radiation_rate,
161            radiation_total: self.radiation_total,
162            radon_avg_24h: self.radon_avg_24h,
163            radon_avg_7d: self.radon_avg_7d,
164            radon_avg_30d: self.radon_avg_30d,
165        }
166    }
167}
168
169/// A historical sensor reading downloaded from device memory.
170///
171/// Aranet devices store readings in internal memory at their configured interval.
172/// These records are downloaded via BLE and cached locally to avoid repeated
173/// downloads. The `timestamp` is the original measurement time from the device,
174/// while `synced_at` tracks when it was downloaded to this database.
175///
176/// Records are deduplicated by `(device_id, timestamp)` - downloading the same
177/// record twice will not create duplicates.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct StoredHistoryRecord {
180    /// Database row ID.
181    pub id: i64,
182    /// Device identifier.
183    pub device_id: String,
184    /// Timestamp of the reading from the device.
185    #[serde(with = "time::serde::rfc3339")]
186    pub timestamp: OffsetDateTime,
187    /// When this record was synced to the database.
188    #[serde(with = "time::serde::rfc3339")]
189    pub synced_at: OffsetDateTime,
190    /// CO2 concentration in ppm.
191    pub co2: u16,
192    /// Temperature in Celsius.
193    pub temperature: f32,
194    /// Pressure in hPa.
195    pub pressure: f32,
196    /// Humidity percentage.
197    pub humidity: u8,
198    /// Radon level (Bq/m3) for radon devices.
199    pub radon: Option<u32>,
200    /// Radiation rate in uSv/h for radiation devices.
201    pub radiation_rate: Option<f32>,
202    /// Total radiation dose in mSv for radiation devices.
203    pub radiation_total: Option<f64>,
204}
205
206impl StoredHistoryRecord {
207    /// Create a `StoredHistoryRecord` from an `aranet_types::HistoryRecord`.
208    ///
209    /// The database `id` is set to 0 and will be assigned by SQLite on insert.
210    /// The `synced_at` timestamp is set to the current time.
211    ///
212    /// # Arguments
213    ///
214    /// * `device_id` - The device identifier this record came from
215    /// * `record` - The source history record from `aranet-types`
216    pub fn from_history(device_id: &str, record: &HistoryRecord) -> Self {
217        Self {
218            id: 0,
219            device_id: device_id.to_string(),
220            timestamp: record.timestamp,
221            synced_at: OffsetDateTime::now_utc(),
222            co2: record.co2,
223            temperature: record.temperature,
224            pressure: record.pressure,
225            humidity: record.humidity,
226            radon: record.radon,
227            radiation_rate: record.radiation_rate,
228            radiation_total: record.radiation_total,
229        }
230    }
231
232    /// Convert back to an `aranet_types::HistoryRecord`.
233    ///
234    /// Use this when you need to pass stored data to functions expecting `HistoryRecord`.
235    /// The `synced_at` metadata is not included in the result.
236    pub fn to_history(&self) -> HistoryRecord {
237        HistoryRecord {
238            timestamp: self.timestamp,
239            co2: self.co2,
240            temperature: self.temperature,
241            pressure: self.pressure,
242            humidity: self.humidity,
243            radon: self.radon,
244            radiation_rate: self.radiation_rate,
245            radiation_total: self.radiation_total,
246        }
247    }
248}
249
250/// Tracks incremental sync progress for a device's history.
251///
252/// Aranet devices use a ring buffer for history storage, with a 1-based index.
253/// `SyncState` tracks the last downloaded index so subsequent syncs can
254/// download only new records instead of re-downloading everything.
255///
256/// # Incremental Sync Algorithm
257///
258/// 1. Read device's current `total_readings` count
259/// 2. Call [`Store::calculate_sync_start`](crate::Store::calculate_sync_start) to get start index
260/// 3. Download records from `start_index` to `total_readings`
261/// 4. Call [`Store::update_sync_state`](crate::Store::update_sync_state) to save progress
262///
263/// # Example
264///
265/// ```
266/// use aranet_store::Store;
267///
268/// let store = Store::open_in_memory()?;
269/// store.upsert_device("Aranet4 17C3C", None)?;
270///
271/// // First sync downloads all 500 records
272/// let start = store.calculate_sync_start("Aranet4 17C3C", 500)?;
273/// assert_eq!(start, 1); // Start from beginning
274///
275/// // After syncing, save state
276/// store.update_sync_state("Aranet4 17C3C", 500, 500)?;
277///
278/// // Next sync: device now has 510 records
279/// let start = store.calculate_sync_start("Aranet4 17C3C", 510)?;
280/// assert_eq!(start, 501); // Only download new records
281/// # Ok::<(), aranet_store::Error>(())
282/// ```
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct SyncState {
285    /// Device identifier.
286    pub device_id: String,
287    /// Last downloaded history index (1-based).
288    pub last_history_index: Option<u16>,
289    /// Total readings on device at last sync.
290    pub total_readings: Option<u16>,
291    /// When the last sync completed.
292    #[serde(with = "time::serde::rfc3339::option")]
293    pub last_sync_at: Option<OffsetDateTime>,
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use time::macros::datetime;
300
301    // ==================== StoredReading Tests ====================
302
303    fn create_current_reading() -> CurrentReading {
304        CurrentReading {
305            co2: 850,
306            temperature: 23.5,
307            pressure: 1015.25,
308            humidity: 48,
309            battery: 75,
310            status: Status::Green,
311            interval: 60,
312            age: 45,
313            captured_at: Some(datetime!(2024-06-15 14:30:00 UTC)),
314            radon: None,
315            radiation_rate: None,
316            radiation_total: None,
317            radon_avg_24h: None,
318            radon_avg_7d: None,
319            radon_avg_30d: None,
320        }
321    }
322
323    fn create_current_reading_radon() -> CurrentReading {
324        CurrentReading {
325            co2: 0,
326            temperature: 21.0,
327            pressure: 1013.0,
328            humidity: 55,
329            battery: 90,
330            status: Status::Yellow,
331            interval: 3600,
332            age: 1800,
333            captured_at: Some(datetime!(2024-06-15 12:00:00 UTC)),
334            radon: Some(150),
335            radiation_rate: None,
336            radiation_total: None,
337            radon_avg_24h: Some(145),
338            radon_avg_7d: Some(140),
339            radon_avg_30d: Some(138),
340        }
341    }
342
343    fn create_current_reading_radiation() -> CurrentReading {
344        CurrentReading {
345            co2: 0,
346            temperature: 20.0,
347            pressure: 1010.0,
348            humidity: 50,
349            battery: 80,
350            status: Status::Green,
351            interval: 60,
352            age: 30,
353            captured_at: Some(datetime!(2024-06-15 16:00:00 UTC)),
354            radon: None,
355            radiation_rate: Some(0.12),
356            radiation_total: Some(0.0025),
357            radon_avg_24h: None,
358            radon_avg_7d: None,
359            radon_avg_30d: None,
360        }
361    }
362
363    #[test]
364    fn test_stored_reading_from_reading_basic() {
365        let reading = create_current_reading();
366        let stored = StoredReading::from_reading("aranet4-abc", &reading);
367
368        assert_eq!(stored.id, 0); // ID is set by database
369        assert_eq!(stored.device_id, "aranet4-abc");
370        assert_eq!(stored.co2, 850);
371        assert_eq!(stored.temperature, 23.5);
372        assert_eq!(stored.pressure, 1015.25);
373        assert_eq!(stored.humidity, 48);
374        assert_eq!(stored.battery, 75);
375        assert_eq!(stored.status, Status::Green);
376        assert_eq!(stored.captured_at, datetime!(2024-06-15 14:30:00 UTC));
377        assert!(stored.radon.is_none());
378        assert!(stored.radiation_rate.is_none());
379        assert!(stored.radiation_total.is_none());
380    }
381
382    #[test]
383    fn test_stored_reading_from_reading_with_radon() {
384        let reading = create_current_reading_radon();
385        let stored = StoredReading::from_reading("aranet-rn", &reading);
386
387        assert_eq!(stored.radon, Some(150));
388        assert!(stored.radiation_rate.is_none());
389        assert!(stored.radiation_total.is_none());
390    }
391
392    #[test]
393    fn test_stored_reading_from_reading_with_radiation() {
394        let reading = create_current_reading_radiation();
395        let stored = StoredReading::from_reading("aranet-rad", &reading);
396
397        assert!(stored.radon.is_none());
398        assert_eq!(stored.radiation_rate, Some(0.12));
399        assert_eq!(stored.radiation_total, Some(0.0025));
400    }
401
402    #[test]
403    fn test_stored_reading_from_reading_without_captured_at() {
404        let mut reading = create_current_reading();
405        reading.captured_at = None;
406
407        let before = OffsetDateTime::now_utc();
408        let stored = StoredReading::from_reading("device", &reading);
409        let after = OffsetDateTime::now_utc();
410
411        // Should use current time if captured_at is None
412        assert!(stored.captured_at >= before);
413        assert!(stored.captured_at <= after);
414    }
415
416    #[test]
417    fn test_stored_reading_to_reading_roundtrip() {
418        let original = create_current_reading();
419        let stored = StoredReading::from_reading("test-device", &original);
420        let converted = stored.to_reading();
421
422        assert_eq!(converted.co2, original.co2);
423        assert_eq!(converted.temperature, original.temperature);
424        assert_eq!(converted.pressure, original.pressure);
425        assert_eq!(converted.humidity, original.humidity);
426        assert_eq!(converted.battery, original.battery);
427        assert_eq!(converted.status, original.status);
428        assert_eq!(converted.captured_at, original.captured_at);
429        assert_eq!(converted.radon, original.radon);
430        assert_eq!(converted.radiation_rate, original.radiation_rate);
431        assert_eq!(converted.radiation_total, original.radiation_total);
432    }
433
434    #[test]
435    fn test_stored_reading_to_reading_sets_defaults() {
436        let reading = create_current_reading();
437        let stored = StoredReading::from_reading("test", &reading);
438        let converted = stored.to_reading();
439
440        // These fields are lost in storage but should have defaults
441        assert_eq!(converted.interval, 0);
442        assert_eq!(converted.age, 0);
443    }
444
445    #[test]
446    fn test_stored_reading_to_reading_with_radon() {
447        let original = create_current_reading_radon();
448        let stored = StoredReading::from_reading("radon-device", &original);
449        let converted = stored.to_reading();
450
451        assert_eq!(converted.radon, Some(150));
452        assert_eq!(converted.radon_avg_24h, Some(145));
453        assert_eq!(converted.radon_avg_7d, Some(140));
454        assert_eq!(converted.radon_avg_30d, Some(138));
455    }
456
457    #[test]
458    fn test_stored_reading_all_status_values() {
459        for status in [Status::Green, Status::Yellow, Status::Red, Status::Error] {
460            let mut reading = create_current_reading();
461            reading.status = status;
462            let stored = StoredReading::from_reading("dev", &reading);
463            assert_eq!(stored.status, status);
464        }
465    }
466
467    #[test]
468    fn test_stored_reading_serialization() {
469        let reading = create_current_reading();
470        let stored = StoredReading::from_reading("test", &reading);
471
472        let json = serde_json::to_string(&stored).unwrap();
473        let deserialized: StoredReading = serde_json::from_str(&json).unwrap();
474
475        assert_eq!(deserialized.device_id, stored.device_id);
476        assert_eq!(deserialized.co2, stored.co2);
477        assert_eq!(deserialized.temperature, stored.temperature);
478    }
479
480    #[test]
481    fn test_stored_reading_clone() {
482        let reading = create_current_reading();
483        let stored = StoredReading::from_reading("test", &reading);
484        let cloned = stored.clone();
485
486        assert_eq!(cloned.device_id, stored.device_id);
487        assert_eq!(cloned.co2, stored.co2);
488    }
489
490    // ==================== StoredHistoryRecord Tests ====================
491
492    fn create_history_record() -> HistoryRecord {
493        HistoryRecord {
494            timestamp: datetime!(2024-05-20 10:00:00 UTC),
495            co2: 720,
496            temperature: 21.5,
497            pressure: 1018.5,
498            humidity: 52,
499            radon: None,
500            radiation_rate: None,
501            radiation_total: None,
502        }
503    }
504
505    fn create_history_record_radon() -> HistoryRecord {
506        HistoryRecord {
507            timestamp: datetime!(2024-05-20 11:00:00 UTC),
508            co2: 0,
509            temperature: 20.0,
510            pressure: 1012.0,
511            humidity: 60,
512            radon: Some(180),
513            radiation_rate: None,
514            radiation_total: None,
515        }
516    }
517
518    fn create_history_record_radiation() -> HistoryRecord {
519        HistoryRecord {
520            timestamp: datetime!(2024-05-20 12:00:00 UTC),
521            co2: 0,
522            temperature: 19.5,
523            pressure: 1011.0,
524            humidity: 58,
525            radon: None,
526            radiation_rate: Some(0.15),
527            radiation_total: Some(0.003),
528        }
529    }
530
531    #[test]
532    fn test_stored_history_record_from_history_basic() {
533        let record = create_history_record();
534        let stored = StoredHistoryRecord::from_history("device-123", &record);
535
536        assert_eq!(stored.id, 0);
537        assert_eq!(stored.device_id, "device-123");
538        assert_eq!(stored.timestamp, datetime!(2024-05-20 10:00:00 UTC));
539        assert_eq!(stored.co2, 720);
540        assert_eq!(stored.temperature, 21.5);
541        assert_eq!(stored.pressure, 1018.5);
542        assert_eq!(stored.humidity, 52);
543        assert!(stored.radon.is_none());
544        assert!(stored.radiation_rate.is_none());
545        assert!(stored.radiation_total.is_none());
546    }
547
548    #[test]
549    fn test_stored_history_record_from_history_sets_synced_at() {
550        let record = create_history_record();
551
552        let before = OffsetDateTime::now_utc();
553        let stored = StoredHistoryRecord::from_history("device", &record);
554        let after = OffsetDateTime::now_utc();
555
556        assert!(stored.synced_at >= before);
557        assert!(stored.synced_at <= after);
558    }
559
560    #[test]
561    fn test_stored_history_record_from_history_with_radon() {
562        let record = create_history_record_radon();
563        let stored = StoredHistoryRecord::from_history("radon-dev", &record);
564
565        assert_eq!(stored.radon, Some(180));
566        assert!(stored.radiation_rate.is_none());
567    }
568
569    #[test]
570    fn test_stored_history_record_from_history_with_radiation() {
571        let record = create_history_record_radiation();
572        let stored = StoredHistoryRecord::from_history("rad-dev", &record);
573
574        assert!(stored.radon.is_none());
575        assert_eq!(stored.radiation_rate, Some(0.15));
576        assert_eq!(stored.radiation_total, Some(0.003));
577    }
578
579    #[test]
580    fn test_stored_history_record_to_history_roundtrip() {
581        let original = create_history_record();
582        let stored = StoredHistoryRecord::from_history("test", &original);
583        let converted = stored.to_history();
584
585        assert_eq!(converted.timestamp, original.timestamp);
586        assert_eq!(converted.co2, original.co2);
587        assert_eq!(converted.temperature, original.temperature);
588        assert_eq!(converted.pressure, original.pressure);
589        assert_eq!(converted.humidity, original.humidity);
590        assert_eq!(converted.radon, original.radon);
591        assert_eq!(converted.radiation_rate, original.radiation_rate);
592        assert_eq!(converted.radiation_total, original.radiation_total);
593    }
594
595    #[test]
596    fn test_stored_history_record_to_history_radon_roundtrip() {
597        let original = create_history_record_radon();
598        let stored = StoredHistoryRecord::from_history("test", &original);
599        let converted = stored.to_history();
600
601        assert_eq!(converted.radon, Some(180));
602    }
603
604    #[test]
605    fn test_stored_history_record_to_history_radiation_roundtrip() {
606        let original = create_history_record_radiation();
607        let stored = StoredHistoryRecord::from_history("test", &original);
608        let converted = stored.to_history();
609
610        assert_eq!(converted.radiation_rate, Some(0.15));
611        assert_eq!(converted.radiation_total, Some(0.003));
612    }
613
614    #[test]
615    fn test_stored_history_record_serialization() {
616        let record = create_history_record();
617        let stored = StoredHistoryRecord::from_history("test", &record);
618
619        let json = serde_json::to_string(&stored).unwrap();
620        let deserialized: StoredHistoryRecord = serde_json::from_str(&json).unwrap();
621
622        assert_eq!(deserialized.device_id, stored.device_id);
623        assert_eq!(deserialized.timestamp, stored.timestamp);
624        assert_eq!(deserialized.co2, stored.co2);
625    }
626
627    #[test]
628    fn test_stored_history_record_clone() {
629        let record = create_history_record();
630        let stored = StoredHistoryRecord::from_history("test", &record);
631        let cloned = stored.clone();
632
633        assert_eq!(cloned.device_id, stored.device_id);
634        assert_eq!(cloned.timestamp, stored.timestamp);
635    }
636
637    // ==================== StoredDevice Tests ====================
638
639    #[test]
640    fn test_stored_device_serialization() {
641        let device = StoredDevice {
642            id: "aranet4-xyz".to_string(),
643            name: Some("Living Room".to_string()),
644            device_type: Some(DeviceType::Aranet4),
645            serial: Some("1234567".to_string()),
646            firmware: Some("v1.2.0".to_string()),
647            hardware: Some("1.0".to_string()),
648            first_seen: datetime!(2024-01-01 00:00:00 UTC),
649            last_seen: datetime!(2024-06-15 12:00:00 UTC),
650        };
651
652        let json = serde_json::to_string(&device).unwrap();
653        let deserialized: StoredDevice = serde_json::from_str(&json).unwrap();
654
655        assert_eq!(deserialized.id, device.id);
656        assert_eq!(deserialized.name, device.name);
657        assert_eq!(deserialized.device_type, device.device_type);
658        assert_eq!(deserialized.serial, device.serial);
659        assert_eq!(deserialized.firmware, device.firmware);
660        assert_eq!(deserialized.first_seen, device.first_seen);
661        assert_eq!(deserialized.last_seen, device.last_seen);
662    }
663
664    #[test]
665    fn test_stored_device_all_device_types() {
666        for device_type in [
667            DeviceType::Aranet4,
668            DeviceType::Aranet2,
669            DeviceType::AranetRadon,
670            DeviceType::AranetRadiation,
671        ] {
672            let device = StoredDevice {
673                id: "test".to_string(),
674                name: None,
675                device_type: Some(device_type),
676                serial: None,
677                firmware: None,
678                hardware: None,
679                first_seen: OffsetDateTime::now_utc(),
680                last_seen: OffsetDateTime::now_utc(),
681            };
682
683            let json = serde_json::to_string(&device).unwrap();
684            let deserialized: StoredDevice = serde_json::from_str(&json).unwrap();
685            assert_eq!(deserialized.device_type, Some(device_type));
686        }
687    }
688
689    #[test]
690    fn test_stored_device_optional_fields() {
691        let device = StoredDevice {
692            id: "minimal-device".to_string(),
693            name: None,
694            device_type: None,
695            serial: None,
696            firmware: None,
697            hardware: None,
698            first_seen: datetime!(2024-06-01 00:00:00 UTC),
699            last_seen: datetime!(2024-06-01 00:00:00 UTC),
700        };
701
702        assert!(device.name.is_none());
703        assert!(device.device_type.is_none());
704        assert!(device.serial.is_none());
705        assert!(device.firmware.is_none());
706        assert!(device.hardware.is_none());
707    }
708
709    #[test]
710    fn test_stored_device_clone() {
711        let device = StoredDevice {
712            id: "clone-test".to_string(),
713            name: Some("Test".to_string()),
714            device_type: Some(DeviceType::Aranet4),
715            serial: Some("123".to_string()),
716            firmware: Some("v1.0".to_string()),
717            hardware: Some("1.0".to_string()),
718            first_seen: OffsetDateTime::now_utc(),
719            last_seen: OffsetDateTime::now_utc(),
720        };
721
722        let cloned = device.clone();
723        assert_eq!(cloned.id, device.id);
724        assert_eq!(cloned.name, device.name);
725    }
726
727    // ==================== SyncState Tests ====================
728
729    #[test]
730    fn test_sync_state_serialization() {
731        let state = SyncState {
732            device_id: "sync-device".to_string(),
733            last_history_index: Some(500),
734            total_readings: Some(500),
735            last_sync_at: Some(datetime!(2024-06-15 18:00:00 UTC)),
736        };
737
738        let json = serde_json::to_string(&state).unwrap();
739        let deserialized: SyncState = serde_json::from_str(&json).unwrap();
740
741        assert_eq!(deserialized.device_id, state.device_id);
742        assert_eq!(deserialized.last_history_index, state.last_history_index);
743        assert_eq!(deserialized.total_readings, state.total_readings);
744        assert_eq!(deserialized.last_sync_at, state.last_sync_at);
745    }
746
747    #[test]
748    fn test_sync_state_with_none_values() {
749        let state = SyncState {
750            device_id: "new-device".to_string(),
751            last_history_index: None,
752            total_readings: None,
753            last_sync_at: None,
754        };
755
756        let json = serde_json::to_string(&state).unwrap();
757        let deserialized: SyncState = serde_json::from_str(&json).unwrap();
758
759        assert!(deserialized.last_history_index.is_none());
760        assert!(deserialized.total_readings.is_none());
761        assert!(deserialized.last_sync_at.is_none());
762    }
763
764    #[test]
765    fn test_sync_state_clone() {
766        let state = SyncState {
767            device_id: "clone-test".to_string(),
768            last_history_index: Some(100),
769            total_readings: Some(100),
770            last_sync_at: Some(OffsetDateTime::now_utc()),
771        };
772
773        let cloned = state.clone();
774        assert_eq!(cloned.device_id, state.device_id);
775        assert_eq!(cloned.last_history_index, state.last_history_index);
776    }
777
778    #[test]
779    fn test_sync_state_debug() {
780        let state = SyncState {
781            device_id: "debug-test".to_string(),
782            last_history_index: Some(42),
783            total_readings: Some(42),
784            last_sync_at: None,
785        };
786
787        let debug_str = format!("{:?}", state);
788        assert!(debug_str.contains("SyncState"));
789        assert!(debug_str.contains("debug-test"));
790        assert!(debug_str.contains("42"));
791    }
792
793    // ==================== Edge Cases ====================
794
795    #[test]
796    fn test_stored_reading_extreme_values() {
797        let reading = CurrentReading {
798            co2: u16::MAX,
799            temperature: f32::MAX,
800            pressure: f32::MAX,
801            humidity: u8::MAX,
802            battery: u8::MAX,
803            status: Status::Error,
804            interval: u16::MAX,
805            age: u16::MAX,
806            captured_at: Some(OffsetDateTime::UNIX_EPOCH),
807            radon: Some(u32::MAX),
808            radiation_rate: Some(f32::MAX),
809            radiation_total: Some(f64::MAX),
810            radon_avg_24h: None,
811            radon_avg_7d: None,
812            radon_avg_30d: None,
813        };
814
815        let stored = StoredReading::from_reading("extreme", &reading);
816        let converted = stored.to_reading();
817
818        assert_eq!(converted.co2, u16::MAX);
819        assert_eq!(converted.humidity, u8::MAX);
820        assert_eq!(converted.battery, u8::MAX);
821        assert_eq!(converted.radon, Some(u32::MAX));
822    }
823
824    #[test]
825    fn test_stored_reading_zero_values() {
826        let reading = CurrentReading {
827            co2: 0,
828            temperature: 0.0,
829            pressure: 0.0,
830            humidity: 0,
831            battery: 0,
832            status: Status::Green,
833            interval: 0,
834            age: 0,
835            captured_at: Some(OffsetDateTime::UNIX_EPOCH),
836            radon: Some(0),
837            radiation_rate: Some(0.0),
838            radiation_total: Some(0.0),
839            radon_avg_24h: None,
840            radon_avg_7d: None,
841            radon_avg_30d: None,
842        };
843
844        let stored = StoredReading::from_reading("zero", &reading);
845        let converted = stored.to_reading();
846
847        assert_eq!(converted.co2, 0);
848        assert_eq!(converted.temperature, 0.0);
849        assert_eq!(converted.radon, Some(0));
850    }
851
852    #[test]
853    fn test_stored_history_record_zero_values() {
854        let record = HistoryRecord {
855            timestamp: OffsetDateTime::UNIX_EPOCH,
856            co2: 0,
857            temperature: 0.0,
858            pressure: 0.0,
859            humidity: 0,
860            radon: Some(0),
861            radiation_rate: Some(0.0),
862            radiation_total: Some(0.0),
863        };
864
865        let stored = StoredHistoryRecord::from_history("zero", &record);
866        let converted = stored.to_history();
867
868        assert_eq!(converted.co2, 0);
869        assert_eq!(converted.radon, Some(0));
870    }
871}