1use serde::{Deserialize, Serialize};
41
42use aranet_types::{CurrentReading, DeviceType};
43
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49#[non_exhaustive]
50pub enum ValidationWarning {
51 Co2TooLow { value: u16, min: u16 },
53 Co2TooHigh { value: u16, max: u16 },
55 TemperatureTooLow { value: f32, min: f32 },
57 TemperatureTooHigh { value: f32, max: f32 },
59 PressureTooLow { value: f32, min: f32 },
61 PressureTooHigh { value: f32, max: f32 },
63 HumidityOutOfRange { value: u8 },
65 BatteryOutOfRange { value: u8 },
67 Co2Zero,
69 AllZeros,
71 RadonTooHigh { value: u32, max: u32 },
73 RadiationRateTooHigh { value: f32, max: f32 },
75 RadiationTotalTooHigh { value: f64, max: f64 },
77}
78
79impl std::fmt::Display for ValidationWarning {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 match self {
82 ValidationWarning::Co2TooLow { value, min } => {
83 write!(f, "CO2 {} ppm is below minimum {} ppm", value, min)
84 }
85 ValidationWarning::Co2TooHigh { value, max } => {
86 write!(f, "CO2 {} ppm exceeds maximum {} ppm", value, max)
87 }
88 ValidationWarning::TemperatureTooLow { value, min } => {
89 write!(f, "Temperature {}°C is below minimum {}°C", value, min)
90 }
91 ValidationWarning::TemperatureTooHigh { value, max } => {
92 write!(f, "Temperature {}°C exceeds maximum {}°C", value, max)
93 }
94 ValidationWarning::PressureTooLow { value, min } => {
95 write!(f, "Pressure {} hPa is below minimum {} hPa", value, min)
96 }
97 ValidationWarning::PressureTooHigh { value, max } => {
98 write!(f, "Pressure {} hPa exceeds maximum {} hPa", value, max)
99 }
100 ValidationWarning::HumidityOutOfRange { value } => {
101 write!(f, "Humidity {}% is out of valid range (0-100)", value)
102 }
103 ValidationWarning::BatteryOutOfRange { value } => {
104 write!(f, "Battery {}% is out of valid range (0-100)", value)
105 }
106 ValidationWarning::Co2Zero => {
107 write!(f, "CO2 reading is zero - possible sensor error")
108 }
109 ValidationWarning::AllZeros => {
110 write!(f, "All readings are zero - possible communication error")
111 }
112 ValidationWarning::RadonTooHigh { value, max } => {
113 write!(f, "Radon {} Bq/m³ exceeds maximum {} Bq/m³", value, max)
114 }
115 ValidationWarning::RadiationRateTooHigh { value, max } => {
116 write!(
117 f,
118 "Radiation rate {} µSv/h exceeds maximum {} µSv/h",
119 value, max
120 )
121 }
122 ValidationWarning::RadiationTotalTooHigh { value, max } => {
123 write!(
124 f,
125 "Radiation total {} µSv exceeds maximum {} µSv",
126 value, max
127 )
128 }
129 }
130 }
131}
132
133#[derive(Debug, Clone)]
135pub struct ValidationResult {
136 pub is_valid: bool,
138 pub warnings: Vec<ValidationWarning>,
140}
141
142impl ValidationResult {
143 pub fn valid() -> Self {
145 Self {
146 is_valid: true,
147 warnings: Vec::new(),
148 }
149 }
150
151 pub fn invalid(warnings: Vec<ValidationWarning>) -> Self {
153 Self {
154 is_valid: false,
155 warnings,
156 }
157 }
158
159 pub fn valid_with_warnings(warnings: Vec<ValidationWarning>) -> Self {
161 Self {
162 is_valid: true,
163 warnings,
164 }
165 }
166
167 pub fn has_warnings(&self) -> bool {
169 !self.warnings.is_empty()
170 }
171}
172
173#[derive(Debug, Clone)]
175pub struct ValidatorConfig {
176 pub co2_min: u16,
178 pub co2_max: u16,
180 pub temperature_min: f32,
182 pub temperature_max: f32,
184 pub pressure_min: f32,
186 pub pressure_max: f32,
188 pub radon_max: u32,
190 pub radiation_rate_max: f32,
192 pub radiation_total_max: f64,
194 pub warn_on_zero_co2: bool,
196 pub warn_on_all_zeros: bool,
198}
199
200impl Default for ValidatorConfig {
201 fn default() -> Self {
202 Self {
203 co2_min: 300, co2_max: 10000, temperature_min: -40.0,
206 temperature_max: 85.0,
207 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,
213 warn_on_all_zeros: true,
214 }
215 }
216}
217
218impl ValidatorConfig {
219 #[must_use]
221 pub fn new() -> Self {
222 Self::default()
223 }
224
225 #[must_use]
227 pub fn co2_min(mut self, min: u16) -> Self {
228 self.co2_min = min;
229 self
230 }
231
232 #[must_use]
234 pub fn co2_max(mut self, max: u16) -> Self {
235 self.co2_max = max;
236 self
237 }
238
239 #[must_use]
241 pub fn co2_range(mut self, min: u16, max: u16) -> Self {
242 self.co2_min = min;
243 self.co2_max = max;
244 self
245 }
246
247 #[must_use]
249 pub fn temperature_min(mut self, min: f32) -> Self {
250 self.temperature_min = min;
251 self
252 }
253
254 #[must_use]
256 pub fn temperature_max(mut self, max: f32) -> Self {
257 self.temperature_max = max;
258 self
259 }
260
261 #[must_use]
263 pub fn temperature_range(mut self, min: f32, max: f32) -> Self {
264 self.temperature_min = min;
265 self.temperature_max = max;
266 self
267 }
268
269 #[must_use]
271 pub fn pressure_min(mut self, min: f32) -> Self {
272 self.pressure_min = min;
273 self
274 }
275
276 #[must_use]
278 pub fn pressure_max(mut self, max: f32) -> Self {
279 self.pressure_max = max;
280 self
281 }
282
283 #[must_use]
285 pub fn pressure_range(mut self, min: f32, max: f32) -> Self {
286 self.pressure_min = min;
287 self.pressure_max = max;
288 self
289 }
290
291 #[must_use]
293 pub fn warn_on_zero_co2(mut self, warn: bool) -> Self {
294 self.warn_on_zero_co2 = warn;
295 self
296 }
297
298 #[must_use]
300 pub fn warn_on_all_zeros(mut self, warn: bool) -> Self {
301 self.warn_on_all_zeros = warn;
302 self
303 }
304
305 #[must_use]
307 pub fn radon_max(mut self, max: u32) -> Self {
308 self.radon_max = max;
309 self
310 }
311
312 #[must_use]
314 pub fn radiation_rate_max(mut self, max: f32) -> Self {
315 self.radiation_rate_max = max;
316 self
317 }
318
319 #[must_use]
321 pub fn radiation_total_max(mut self, max: f64) -> Self {
322 self.radiation_total_max = max;
323 self
324 }
325
326 pub fn strict() -> Self {
328 Self {
329 co2_min: 350,
330 co2_max: 5000,
331 temperature_min: -10.0,
332 temperature_max: 50.0,
333 pressure_min: 800.0,
334 pressure_max: 1100.0,
335 radon_max: 300, radiation_rate_max: 10.0,
337 radiation_total_max: 10000.0,
338 warn_on_zero_co2: true,
339 warn_on_all_zeros: true,
340 }
341 }
342
343 pub fn relaxed() -> Self {
345 Self {
346 co2_min: 0,
347 co2_max: 20000,
348 temperature_min: -50.0,
349 temperature_max: 100.0,
350 pressure_min: 200.0,
351 pressure_max: 1200.0,
352 radon_max: 5000,
353 radiation_rate_max: 1000.0,
354 radiation_total_max: 1000000.0,
355 warn_on_zero_co2: false,
356 warn_on_all_zeros: false,
357 }
358 }
359
360 pub fn for_aranet4() -> Self {
365 Self {
366 co2_min: 300, co2_max: 10000, temperature_min: -40.0,
369 temperature_max: 60.0, pressure_min: 300.0,
371 pressure_max: 1100.0,
372 radon_max: 0, radiation_rate_max: 0.0, radiation_total_max: 0.0, warn_on_zero_co2: true,
376 warn_on_all_zeros: true,
377 }
378 }
379
380 pub fn for_aranet2() -> Self {
388 Self {
389 co2_min: 0, co2_max: 65535, temperature_min: -40.0,
392 temperature_max: 60.0,
393 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,
400 }
401 }
402
403 pub fn for_aranet_radon() -> Self {
411 Self {
412 co2_min: 0, co2_max: 65535, temperature_min: -40.0,
415 temperature_max: 60.0,
416 pressure_min: 300.0,
417 pressure_max: 1100.0,
418 radon_max: 1000, radiation_rate_max: 0.0, radiation_total_max: 0.0, warn_on_zero_co2: false,
422 warn_on_all_zeros: false,
423 }
424 }
425
426 pub fn for_aranet_radiation() -> Self {
434 Self {
435 co2_min: 0, co2_max: 65535, temperature_min: -40.0,
438 temperature_max: 60.0,
439 pressure_min: 300.0,
440 pressure_max: 1100.0,
441 radon_max: 0, radiation_rate_max: 100.0, radiation_total_max: 100000.0, warn_on_zero_co2: false,
445 warn_on_all_zeros: false,
446 }
447 }
448
449 #[must_use]
471 pub fn for_device(device_type: DeviceType) -> Self {
472 match device_type {
473 DeviceType::Aranet4 => Self::for_aranet4(),
474 DeviceType::Aranet2 => Self::for_aranet2(),
475 DeviceType::AranetRadon => Self::for_aranet_radon(),
476 DeviceType::AranetRadiation => Self::for_aranet_radiation(),
477 _ => Self::default(),
478 }
479 }
480}
481
482#[derive(Debug, Clone, Default)]
484pub struct ReadingValidator {
485 config: ValidatorConfig,
486}
487
488impl ReadingValidator {
489 pub fn new(config: ValidatorConfig) -> Self {
491 Self { config }
492 }
493
494 pub fn config(&self) -> &ValidatorConfig {
496 &self.config
497 }
498
499 pub fn validate(&self, reading: &CurrentReading) -> ValidationResult {
501 let mut warnings = Vec::new();
502
503 if self.config.warn_on_all_zeros
505 && reading.co2 == 0
506 && reading.temperature.abs() < f32::EPSILON
507 && reading.pressure.abs() < f32::EPSILON
508 && reading.humidity == 0
509 {
510 warnings.push(ValidationWarning::AllZeros);
511 return ValidationResult::invalid(warnings);
512 }
513
514 if reading.co2 > 0 {
516 if reading.co2 < self.config.co2_min {
517 warnings.push(ValidationWarning::Co2TooLow {
518 value: reading.co2,
519 min: self.config.co2_min,
520 });
521 }
522 if reading.co2 > self.config.co2_max {
523 warnings.push(ValidationWarning::Co2TooHigh {
524 value: reading.co2,
525 max: self.config.co2_max,
526 });
527 }
528 } else if self.config.warn_on_zero_co2 {
529 warnings.push(ValidationWarning::Co2Zero);
530 }
531
532 if reading.temperature < self.config.temperature_min {
534 warnings.push(ValidationWarning::TemperatureTooLow {
535 value: reading.temperature,
536 min: self.config.temperature_min,
537 });
538 }
539 if reading.temperature > self.config.temperature_max {
540 warnings.push(ValidationWarning::TemperatureTooHigh {
541 value: reading.temperature,
542 max: self.config.temperature_max,
543 });
544 }
545
546 if reading.pressure > 0.0 {
548 if reading.pressure < self.config.pressure_min {
549 warnings.push(ValidationWarning::PressureTooLow {
550 value: reading.pressure,
551 min: self.config.pressure_min,
552 });
553 }
554 if reading.pressure > self.config.pressure_max {
555 warnings.push(ValidationWarning::PressureTooHigh {
556 value: reading.pressure,
557 max: self.config.pressure_max,
558 });
559 }
560 }
561
562 if reading.humidity > 100 {
564 warnings.push(ValidationWarning::HumidityOutOfRange {
565 value: reading.humidity,
566 });
567 }
568
569 if reading.battery > 100 {
571 warnings.push(ValidationWarning::BatteryOutOfRange {
572 value: reading.battery,
573 });
574 }
575
576 if let Some(radon) = reading.radon
578 && radon > self.config.radon_max
579 {
580 warnings.push(ValidationWarning::RadonTooHigh {
581 value: radon,
582 max: self.config.radon_max,
583 });
584 }
585
586 if let Some(rate) = reading.radiation_rate
588 && rate > self.config.radiation_rate_max
589 {
590 warnings.push(ValidationWarning::RadiationRateTooHigh {
591 value: rate,
592 max: self.config.radiation_rate_max,
593 });
594 }
595
596 if let Some(total) = reading.radiation_total
598 && total > self.config.radiation_total_max
599 {
600 warnings.push(ValidationWarning::RadiationTotalTooHigh {
601 value: total,
602 max: self.config.radiation_total_max,
603 });
604 }
605
606 if warnings.is_empty() {
607 ValidationResult::valid()
608 } else {
609 let has_critical = warnings.iter().any(|w| {
611 matches!(
612 w,
613 ValidationWarning::AllZeros
614 | ValidationWarning::Co2TooHigh { .. }
615 | ValidationWarning::TemperatureTooHigh { .. }
616 | ValidationWarning::RadonTooHigh { .. }
617 | ValidationWarning::RadiationRateTooHigh { .. }
618 )
619 });
620
621 if has_critical {
622 ValidationResult::invalid(warnings)
623 } else {
624 ValidationResult::valid_with_warnings(warnings)
625 }
626 }
627 }
628
629 pub fn is_co2_valid(&self, co2: u16) -> bool {
631 co2 >= self.config.co2_min && co2 <= self.config.co2_max
632 }
633
634 pub fn is_temperature_valid(&self, temp: f32) -> bool {
636 temp >= self.config.temperature_min && temp <= self.config.temperature_max
637 }
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643 use aranet_types::Status;
644
645 fn make_reading(co2: u16, temp: f32, pressure: f32, humidity: u8) -> CurrentReading {
646 CurrentReading {
647 co2,
648 temperature: temp,
649 pressure,
650 humidity,
651 battery: 80,
652 status: Status::Green,
653 interval: 300,
654 age: 60,
655 captured_at: None,
656 radon: None,
657 radiation_rate: None,
658 radiation_total: None,
659 radon_avg_24h: None,
660 radon_avg_7d: None,
661 radon_avg_30d: None,
662 }
663 }
664
665 #[test]
666 fn test_valid_reading() {
667 let validator = ReadingValidator::default();
668 let reading = make_reading(800, 22.5, 1013.2, 50);
669 let result = validator.validate(&reading);
670 assert!(result.is_valid);
671 assert!(result.warnings.is_empty());
672 }
673
674 #[test]
675 fn test_co2_too_high() {
676 let validator = ReadingValidator::default();
677 let reading = make_reading(15000, 22.5, 1013.2, 50);
678 let result = validator.validate(&reading);
679 assert!(!result.is_valid);
680 assert!(
681 result
682 .warnings
683 .iter()
684 .any(|w| matches!(w, ValidationWarning::Co2TooHigh { .. }))
685 );
686 }
687
688 #[test]
689 fn test_all_zeros() {
690 let validator = ReadingValidator::default();
691 let reading = make_reading(0, 0.0, 0.0, 0);
692 let result = validator.validate(&reading);
693 assert!(!result.is_valid);
694 assert!(
695 result
696 .warnings
697 .iter()
698 .any(|w| matches!(w, ValidationWarning::AllZeros))
699 );
700 }
701
702 #[test]
703 fn test_humidity_out_of_range() {
704 let validator = ReadingValidator::default();
705 let reading = make_reading(800, 22.5, 1013.2, 150);
706 let result = validator.validate(&reading);
707 assert!(result.has_warnings());
708 assert!(
709 result
710 .warnings
711 .iter()
712 .any(|w| matches!(w, ValidationWarning::HumidityOutOfRange { .. }))
713 );
714 }
715
716 #[test]
717 fn test_for_device_aranet4() {
718 let config = ValidatorConfig::for_device(DeviceType::Aranet4);
719 assert_eq!(config.co2_min, 300);
720 assert_eq!(config.co2_max, 10000);
721 assert!(config.warn_on_zero_co2);
722 }
723
724 #[test]
725 fn test_for_device_aranet2() {
726 let config = ValidatorConfig::for_device(DeviceType::Aranet2);
727 assert_eq!(config.co2_min, 0); assert!(!config.warn_on_zero_co2);
729 }
730
731 #[test]
732 fn test_for_device_aranet_radon() {
733 let config = ValidatorConfig::for_device(DeviceType::AranetRadon);
734 assert_eq!(config.radon_max, 1000);
735 assert!(!config.warn_on_zero_co2);
736 }
737
738 #[test]
739 fn test_for_device_aranet_radiation() {
740 let config = ValidatorConfig::for_device(DeviceType::AranetRadiation);
741 assert_eq!(config.radiation_rate_max, 100.0);
742 assert_eq!(config.radiation_total_max, 100000.0);
743 assert!(!config.warn_on_zero_co2);
744 }
745}