1use serde::{Deserialize, Serialize};
38
39use aranet_types::{CurrentReading, DeviceType};
40
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46#[non_exhaustive]
47pub enum ValidationWarning {
48 Co2TooLow { value: u16, min: u16 },
50 Co2TooHigh { value: u16, max: u16 },
52 TemperatureTooLow { value: f32, min: f32 },
54 TemperatureTooHigh { value: f32, max: f32 },
56 PressureTooLow { value: f32, min: f32 },
58 PressureTooHigh { value: f32, max: f32 },
60 HumidityOutOfRange { value: u8 },
62 BatteryOutOfRange { value: u8 },
64 Co2Zero,
66 AllZeros,
68 RadonTooHigh { value: u32, max: u32 },
70 RadiationRateTooHigh { value: f32, max: f32 },
72 RadiationTotalTooHigh { value: f64, max: f64 },
74}
75
76impl std::fmt::Display for ValidationWarning {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 match self {
79 ValidationWarning::Co2TooLow { value, min } => {
80 write!(f, "CO2 {} ppm is below minimum {} ppm", value, min)
81 }
82 ValidationWarning::Co2TooHigh { value, max } => {
83 write!(f, "CO2 {} ppm exceeds maximum {} ppm", value, max)
84 }
85 ValidationWarning::TemperatureTooLow { value, min } => {
86 write!(f, "Temperature {}°C is below minimum {}°C", value, min)
87 }
88 ValidationWarning::TemperatureTooHigh { value, max } => {
89 write!(f, "Temperature {}°C exceeds maximum {}°C", value, max)
90 }
91 ValidationWarning::PressureTooLow { value, min } => {
92 write!(f, "Pressure {} hPa is below minimum {} hPa", value, min)
93 }
94 ValidationWarning::PressureTooHigh { value, max } => {
95 write!(f, "Pressure {} hPa exceeds maximum {} hPa", value, max)
96 }
97 ValidationWarning::HumidityOutOfRange { value } => {
98 write!(f, "Humidity {}% is out of valid range (0-100)", value)
99 }
100 ValidationWarning::BatteryOutOfRange { value } => {
101 write!(f, "Battery {}% is out of valid range (0-100)", value)
102 }
103 ValidationWarning::Co2Zero => {
104 write!(f, "CO2 reading is zero - possible sensor error")
105 }
106 ValidationWarning::AllZeros => {
107 write!(f, "All readings are zero - possible communication error")
108 }
109 ValidationWarning::RadonTooHigh { value, max } => {
110 write!(f, "Radon {} Bq/m³ exceeds maximum {} Bq/m³", value, max)
111 }
112 ValidationWarning::RadiationRateTooHigh { value, max } => {
113 write!(
114 f,
115 "Radiation rate {} µSv/h exceeds maximum {} µSv/h",
116 value, max
117 )
118 }
119 ValidationWarning::RadiationTotalTooHigh { value, max } => {
120 write!(
121 f,
122 "Radiation total {} µSv exceeds maximum {} µSv",
123 value, max
124 )
125 }
126 }
127 }
128}
129
130#[derive(Debug, Clone)]
132pub struct ValidationResult {
133 pub is_valid: bool,
135 pub warnings: Vec<ValidationWarning>,
137}
138
139impl ValidationResult {
140 pub fn valid() -> Self {
142 Self {
143 is_valid: true,
144 warnings: Vec::new(),
145 }
146 }
147
148 pub fn invalid(warnings: Vec<ValidationWarning>) -> Self {
150 Self {
151 is_valid: false,
152 warnings,
153 }
154 }
155
156 pub fn valid_with_warnings(warnings: Vec<ValidationWarning>) -> Self {
158 Self {
159 is_valid: true,
160 warnings,
161 }
162 }
163
164 pub fn has_warnings(&self) -> bool {
166 !self.warnings.is_empty()
167 }
168}
169
170#[derive(Debug, Clone)]
172pub struct ValidatorConfig {
173 pub co2_min: u16,
175 pub co2_max: u16,
177 pub temperature_min: f32,
179 pub temperature_max: f32,
181 pub pressure_min: f32,
183 pub pressure_max: f32,
185 pub radon_max: u32,
187 pub radiation_rate_max: f32,
189 pub radiation_total_max: f64,
191 pub warn_on_zero_co2: bool,
193 pub warn_on_all_zeros: bool,
195}
196
197impl Default for ValidatorConfig {
198 fn default() -> Self {
199 Self {
200 co2_min: 300, co2_max: 10000, temperature_min: -40.0,
203 temperature_max: 85.0,
204 pressure_min: 300.0, pressure_max: 1100.0, radon_max: 1000, radiation_rate_max: 100.0, radiation_total_max: 100000.0, warn_on_zero_co2: true,
210 warn_on_all_zeros: true,
211 }
212 }
213}
214
215impl ValidatorConfig {
216 #[must_use]
218 pub fn new() -> Self {
219 Self::default()
220 }
221
222 #[must_use]
224 pub fn co2_min(mut self, min: u16) -> Self {
225 self.co2_min = min;
226 self
227 }
228
229 #[must_use]
231 pub fn co2_max(mut self, max: u16) -> Self {
232 self.co2_max = max;
233 self
234 }
235
236 #[must_use]
238 pub fn co2_range(mut self, min: u16, max: u16) -> Self {
239 self.co2_min = min;
240 self.co2_max = max;
241 self
242 }
243
244 #[must_use]
246 pub fn temperature_min(mut self, min: f32) -> Self {
247 self.temperature_min = min;
248 self
249 }
250
251 #[must_use]
253 pub fn temperature_max(mut self, max: f32) -> Self {
254 self.temperature_max = max;
255 self
256 }
257
258 #[must_use]
260 pub fn temperature_range(mut self, min: f32, max: f32) -> Self {
261 self.temperature_min = min;
262 self.temperature_max = max;
263 self
264 }
265
266 #[must_use]
268 pub fn pressure_min(mut self, min: f32) -> Self {
269 self.pressure_min = min;
270 self
271 }
272
273 #[must_use]
275 pub fn pressure_max(mut self, max: f32) -> Self {
276 self.pressure_max = max;
277 self
278 }
279
280 #[must_use]
282 pub fn pressure_range(mut self, min: f32, max: f32) -> Self {
283 self.pressure_min = min;
284 self.pressure_max = max;
285 self
286 }
287
288 #[must_use]
290 pub fn warn_on_zero_co2(mut self, warn: bool) -> Self {
291 self.warn_on_zero_co2 = warn;
292 self
293 }
294
295 #[must_use]
297 pub fn warn_on_all_zeros(mut self, warn: bool) -> Self {
298 self.warn_on_all_zeros = warn;
299 self
300 }
301
302 #[must_use]
304 pub fn radon_max(mut self, max: u32) -> Self {
305 self.radon_max = max;
306 self
307 }
308
309 #[must_use]
311 pub fn radiation_rate_max(mut self, max: f32) -> Self {
312 self.radiation_rate_max = max;
313 self
314 }
315
316 #[must_use]
318 pub fn radiation_total_max(mut self, max: f64) -> Self {
319 self.radiation_total_max = max;
320 self
321 }
322
323 pub fn strict() -> Self {
325 Self {
326 co2_min: 350,
327 co2_max: 5000,
328 temperature_min: -10.0,
329 temperature_max: 50.0,
330 pressure_min: 800.0,
331 pressure_max: 1100.0,
332 radon_max: 300, radiation_rate_max: 10.0,
334 radiation_total_max: 10000.0,
335 warn_on_zero_co2: true,
336 warn_on_all_zeros: true,
337 }
338 }
339
340 pub fn relaxed() -> Self {
342 Self {
343 co2_min: 0,
344 co2_max: 20000,
345 temperature_min: -50.0,
346 temperature_max: 100.0,
347 pressure_min: 200.0,
348 pressure_max: 1200.0,
349 radon_max: 5000,
350 radiation_rate_max: 1000.0,
351 radiation_total_max: 1000000.0,
352 warn_on_zero_co2: false,
353 warn_on_all_zeros: false,
354 }
355 }
356
357 pub fn for_aranet4() -> Self {
362 Self {
363 co2_min: 300, co2_max: 10000, temperature_min: -40.0,
366 temperature_max: 60.0, pressure_min: 300.0,
368 pressure_max: 1100.0,
369 radon_max: 0, radiation_rate_max: 0.0, radiation_total_max: 0.0, warn_on_zero_co2: true,
373 warn_on_all_zeros: true,
374 }
375 }
376
377 pub fn for_aranet2() -> Self {
385 Self {
386 co2_min: 0, co2_max: 65535, temperature_min: -40.0,
389 temperature_max: 60.0,
390 pressure_min: 0.0, pressure_max: 2000.0, radon_max: 0, radiation_rate_max: 0.0, radiation_total_max: 0.0, warn_on_zero_co2: false, warn_on_all_zeros: false,
397 }
398 }
399
400 pub fn for_aranet_radon() -> Self {
408 Self {
409 co2_min: 0, co2_max: 65535, temperature_min: -40.0,
412 temperature_max: 60.0,
413 pressure_min: 300.0,
414 pressure_max: 1100.0,
415 radon_max: 1000, radiation_rate_max: 0.0, radiation_total_max: 0.0, warn_on_zero_co2: false,
419 warn_on_all_zeros: false,
420 }
421 }
422
423 pub fn for_aranet_radiation() -> Self {
431 Self {
432 co2_min: 0, co2_max: 65535, temperature_min: -40.0,
435 temperature_max: 60.0,
436 pressure_min: 300.0,
437 pressure_max: 1100.0,
438 radon_max: 0, radiation_rate_max: 100.0, radiation_total_max: 100000.0, warn_on_zero_co2: false,
442 warn_on_all_zeros: false,
443 }
444 }
445
446 #[must_use]
468 pub fn for_device(device_type: DeviceType) -> Self {
469 match device_type {
470 DeviceType::Aranet4 => Self::for_aranet4(),
471 DeviceType::Aranet2 => Self::for_aranet2(),
472 DeviceType::AranetRadon => Self::for_aranet_radon(),
473 DeviceType::AranetRadiation => Self::for_aranet_radiation(),
474 _ => Self::default(),
475 }
476 }
477}
478
479#[derive(Debug, Clone, Default)]
481pub struct ReadingValidator {
482 config: ValidatorConfig,
483}
484
485impl ReadingValidator {
486 pub fn new(config: ValidatorConfig) -> Self {
488 Self { config }
489 }
490
491 pub fn config(&self) -> &ValidatorConfig {
493 &self.config
494 }
495
496 pub fn validate(&self, reading: &CurrentReading) -> ValidationResult {
498 let mut warnings = Vec::new();
499
500 if self.config.warn_on_all_zeros
502 && reading.co2 == 0
503 && reading.temperature.abs() < f32::EPSILON
504 && reading.pressure.abs() < f32::EPSILON
505 && reading.humidity == 0
506 {
507 warnings.push(ValidationWarning::AllZeros);
508 return ValidationResult::invalid(warnings);
509 }
510
511 if reading.co2 > 0 {
513 if reading.co2 < self.config.co2_min {
514 warnings.push(ValidationWarning::Co2TooLow {
515 value: reading.co2,
516 min: self.config.co2_min,
517 });
518 }
519 if reading.co2 > self.config.co2_max {
520 warnings.push(ValidationWarning::Co2TooHigh {
521 value: reading.co2,
522 max: self.config.co2_max,
523 });
524 }
525 } else if self.config.warn_on_zero_co2 {
526 warnings.push(ValidationWarning::Co2Zero);
527 }
528
529 if reading.temperature < self.config.temperature_min {
531 warnings.push(ValidationWarning::TemperatureTooLow {
532 value: reading.temperature,
533 min: self.config.temperature_min,
534 });
535 }
536 if reading.temperature > self.config.temperature_max {
537 warnings.push(ValidationWarning::TemperatureTooHigh {
538 value: reading.temperature,
539 max: self.config.temperature_max,
540 });
541 }
542
543 if reading.pressure > 0.0 {
545 if reading.pressure < self.config.pressure_min {
546 warnings.push(ValidationWarning::PressureTooLow {
547 value: reading.pressure,
548 min: self.config.pressure_min,
549 });
550 }
551 if reading.pressure > self.config.pressure_max {
552 warnings.push(ValidationWarning::PressureTooHigh {
553 value: reading.pressure,
554 max: self.config.pressure_max,
555 });
556 }
557 }
558
559 if reading.humidity > 100 {
561 warnings.push(ValidationWarning::HumidityOutOfRange {
562 value: reading.humidity,
563 });
564 }
565
566 if reading.battery > 100 {
568 warnings.push(ValidationWarning::BatteryOutOfRange {
569 value: reading.battery,
570 });
571 }
572
573 if let Some(radon) = reading.radon
575 && radon > self.config.radon_max
576 {
577 warnings.push(ValidationWarning::RadonTooHigh {
578 value: radon,
579 max: self.config.radon_max,
580 });
581 }
582
583 if let Some(rate) = reading.radiation_rate
585 && rate > self.config.radiation_rate_max
586 {
587 warnings.push(ValidationWarning::RadiationRateTooHigh {
588 value: rate,
589 max: self.config.radiation_rate_max,
590 });
591 }
592
593 if let Some(total) = reading.radiation_total
595 && total > self.config.radiation_total_max
596 {
597 warnings.push(ValidationWarning::RadiationTotalTooHigh {
598 value: total,
599 max: self.config.radiation_total_max,
600 });
601 }
602
603 if warnings.is_empty() {
604 ValidationResult::valid()
605 } else {
606 let has_critical = warnings.iter().any(|w| {
608 matches!(
609 w,
610 ValidationWarning::AllZeros
611 | ValidationWarning::Co2TooHigh { .. }
612 | ValidationWarning::TemperatureTooHigh { .. }
613 | ValidationWarning::RadonTooHigh { .. }
614 | ValidationWarning::RadiationRateTooHigh { .. }
615 )
616 });
617
618 if has_critical {
619 ValidationResult::invalid(warnings)
620 } else {
621 ValidationResult::valid_with_warnings(warnings)
622 }
623 }
624 }
625
626 pub fn is_co2_valid(&self, co2: u16) -> bool {
628 co2 >= self.config.co2_min && co2 <= self.config.co2_max
629 }
630
631 pub fn is_temperature_valid(&self, temp: f32) -> bool {
633 temp >= self.config.temperature_min && temp <= self.config.temperature_max
634 }
635}
636
637#[cfg(test)]
638mod tests {
639 use super::*;
640 use aranet_types::Status;
641
642 fn make_reading(co2: u16, temp: f32, pressure: f32, humidity: u8) -> CurrentReading {
643 CurrentReading {
644 co2,
645 temperature: temp,
646 pressure,
647 humidity,
648 battery: 80,
649 status: Status::Green,
650 interval: 300,
651 age: 60,
652 captured_at: None,
653 radon: None,
654 radiation_rate: None,
655 radiation_total: None,
656 }
657 }
658
659 #[test]
660 fn test_valid_reading() {
661 let validator = ReadingValidator::default();
662 let reading = make_reading(800, 22.5, 1013.2, 50);
663 let result = validator.validate(&reading);
664 assert!(result.is_valid);
665 assert!(result.warnings.is_empty());
666 }
667
668 #[test]
669 fn test_co2_too_high() {
670 let validator = ReadingValidator::default();
671 let reading = make_reading(15000, 22.5, 1013.2, 50);
672 let result = validator.validate(&reading);
673 assert!(!result.is_valid);
674 assert!(
675 result
676 .warnings
677 .iter()
678 .any(|w| matches!(w, ValidationWarning::Co2TooHigh { .. }))
679 );
680 }
681
682 #[test]
683 fn test_all_zeros() {
684 let validator = ReadingValidator::default();
685 let reading = make_reading(0, 0.0, 0.0, 0);
686 let result = validator.validate(&reading);
687 assert!(!result.is_valid);
688 assert!(
689 result
690 .warnings
691 .iter()
692 .any(|w| matches!(w, ValidationWarning::AllZeros))
693 );
694 }
695
696 #[test]
697 fn test_humidity_out_of_range() {
698 let validator = ReadingValidator::default();
699 let reading = make_reading(800, 22.5, 1013.2, 150);
700 let result = validator.validate(&reading);
701 assert!(result.has_warnings());
702 assert!(
703 result
704 .warnings
705 .iter()
706 .any(|w| matches!(w, ValidationWarning::HumidityOutOfRange { .. }))
707 );
708 }
709
710 #[test]
711 fn test_for_device_aranet4() {
712 let config = ValidatorConfig::for_device(DeviceType::Aranet4);
713 assert_eq!(config.co2_min, 300);
714 assert_eq!(config.co2_max, 10000);
715 assert!(config.warn_on_zero_co2);
716 }
717
718 #[test]
719 fn test_for_device_aranet2() {
720 let config = ValidatorConfig::for_device(DeviceType::Aranet2);
721 assert_eq!(config.co2_min, 0); assert!(!config.warn_on_zero_co2);
723 }
724
725 #[test]
726 fn test_for_device_aranet_radon() {
727 let config = ValidatorConfig::for_device(DeviceType::AranetRadon);
728 assert_eq!(config.radon_max, 1000);
729 assert!(!config.warn_on_zero_co2);
730 }
731
732 #[test]
733 fn test_for_device_aranet_radiation() {
734 let config = ValidatorConfig::for_device(DeviceType::AranetRadiation);
735 assert_eq!(config.radiation_rate_max, 100.0);
736 assert_eq!(config.radiation_total_max, 100000.0);
737 assert!(!config.warn_on_zero_co2);
738 }
739}