| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| #![cfg_attr(not(feature = "std"), no_std)] |
|
|
| extern crate alloc; |
| use alloc::vec::Vec; |
| use minicbor::{Decode, Encode}; |
|
|
| |
| |
| |
|
|
| |
| |
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] |
| #[cbor(index_only)] |
| pub enum MeasurementUnit { |
| |
| #[n(0)] Ph, |
| #[n(1)] Ec, |
| #[n(2)] DissolvedOxygen, |
| #[n(3)] Orp, |
| #[n(4)] TemperatureWater, |
|
|
| |
| #[n(10)] MoistureVwc, |
| #[n(11)] TemperatureSoil, |
|
|
| |
| #[n(20)] TemperatureAir, |
| #[n(21)] Humidity, |
| #[n(22)] Pressure, |
| #[n(23)] LightLux, |
| #[n(24)] LightPar, |
|
|
| |
| #[n(30)] Voltage, |
| #[n(31)] Current, |
| #[n(32)] Power, |
| #[n(33)] BatterySoc, |
| } |
|
|
| |
| |
| |
|
|
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] |
| #[cbor(index_only)] |
| pub enum ReadingQuality { |
| #[n(0)] Good, |
| #[n(1)] Degraded, |
| #[n(2)] CalNeeded, |
| #[n(3)] Fault, |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] |
| pub struct Reading { |
| |
| #[n(0)] pub timestamp_ms: u64, |
| |
| #[n(1)] pub channel: u8, |
| |
| #[n(2)] pub raw_value: i32, |
| |
| #[n(3)] pub calibrated_value: i32, |
| |
| #[n(4)] pub unit: MeasurementUnit, |
| |
| #[n(5)] pub quality: ReadingQuality, |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] |
| pub struct Calibration { |
| #[n(0)] pub slope: i32, |
| #[n(1)] pub offset: i32, |
| } |
|
|
| impl Calibration { |
| |
| |
| |
| |
| pub fn apply(&self, raw: i32) -> i32 { |
| let intermediate = (raw as i64) * (self.slope as i64) / 1000; |
| (intermediate as i32) + self.offset |
| } |
|
|
| |
| |
| pub const fn identity() -> Self { |
| Self { |
| slope: 1000, |
| offset: 0, |
| } |
| } |
|
|
| |
| |
| |
| pub fn from_two_point(raw1: i32, known1: i32, raw2: i32, known2: i32) -> Option<Self> { |
| let raw_diff = raw2 - raw1; |
| if raw_diff == 0 { |
| return None; |
| } |
| |
| let slope = ((known2 as i64 - known1 as i64) * 1000) / raw_diff as i64; |
| |
| let offset = known1 as i64 - (raw1 as i64 * slope / 1000); |
| Some(Self { |
| slope: slope as i32, |
| offset: offset as i32, |
| }) |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| #[derive(Debug, Clone, Encode, Decode)] |
| pub struct SensorConfig { |
| |
| #[n(0)] pub sample_interval_secs: u32, |
| |
| #[n(1)] pub active_channels: u8, |
| |
| |
| #[n(2)] pub calibrations: Vec<Calibration>, |
| } |
|
|
| impl SensorConfig { |
| |
| pub fn is_channel_active(&self, channel: u8) -> bool { |
| channel < 8 && (self.active_channels & (1 << channel)) != 0 |
| } |
|
|
| |
| pub fn cal_for(&self, channel: u8) -> Calibration { |
| self.calibrations |
| .get(channel as usize) |
| .copied() |
| .unwrap_or(Calibration::identity()) |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| #[derive(Debug, Clone, Encode, Decode)] |
| pub struct TransmissionPayload { |
| |
| #[n(0)] pub node_id: u16, |
| |
| |
| #[n(1)] pub sequence: u16, |
| |
| #[n(2)] pub battery_mv: u16, |
| |
| #[n(3)] pub readings: Vec<Reading>, |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| #[cfg(feature = "std")] |
| pub mod topics { |
| use alloc::format; |
| use alloc::string::String; |
|
|
| pub fn reading(site: &str, zone: &str, node_id: u16) -> String { |
| format!("synapse/site/{site}/zone/{zone}/node/{node_id}/reading") |
| } |
|
|
| pub fn health(site: &str, zone: &str, node_id: u16) -> String { |
| format!("synapse/site/{site}/zone/{zone}/node/{node_id}/health") |
| } |
|
|
| pub fn config(site: &str, zone: &str, node_id: u16) -> String { |
| format!("synapse/site/{site}/zone/{zone}/node/{node_id}/config") |
| } |
| } |
|
|
| |
| |
| |
|
|
| #[cfg(feature = "std")] |
| impl Reading { |
| |
| pub fn calibrated_f64(&self) -> f64 { |
| self.calibrated_value as f64 / 1000.0 |
| } |
|
|
| |
| pub fn unit_str(&self) -> &'static str { |
| match self.unit { |
| MeasurementUnit::Ph => "pH", |
| MeasurementUnit::Ec => "µS/cm", |
| MeasurementUnit::DissolvedOxygen => "mg/L", |
| MeasurementUnit::Orp => "mV", |
| MeasurementUnit::TemperatureWater => "°C", |
| MeasurementUnit::MoistureVwc => "%", |
| MeasurementUnit::TemperatureSoil => "°C", |
| MeasurementUnit::TemperatureAir => "°C", |
| MeasurementUnit::Humidity => "%", |
| MeasurementUnit::Pressure => "hPa", |
| MeasurementUnit::LightLux => "lux", |
| MeasurementUnit::LightPar => "µmol/m²/s", |
| MeasurementUnit::Voltage => "mV", |
| MeasurementUnit::Current => "mA", |
| MeasurementUnit::Power => "mW", |
| MeasurementUnit::BatterySoc => "%", |
| } |
| } |
| } |
|
|
| |
| |
| |
|
|
| #[cfg(test)] |
| mod tests { |
| use super::*; |
|
|
| #[test] |
| fn identity_calibration_passes_through() { |
| let cal = Calibration::identity(); |
| assert_eq!(cal.apply(1234), 1234); |
| assert_eq!(cal.apply(-500), -500); |
| assert_eq!(cal.apply(0), 0); |
| } |
|
|
| #[test] |
| fn two_point_calibration_ph() { |
| |
| let cal = Calibration::from_two_point(1650, 7000, 2200, 4000) |
| .expect("should not be None"); |
|
|
| let ph7 = cal.apply(1650); |
| let ph4 = cal.apply(2200); |
|
|
| |
| assert!((ph7 - 7000).abs() < 10, "pH 7 cal: got {ph7}, expected ~7000"); |
| assert!((ph4 - 4000).abs() < 10, "pH 4 cal: got {ph4}, expected ~4000"); |
| } |
|
|
| #[test] |
| fn two_point_rejects_identical_raw() { |
| assert!(Calibration::from_two_point(100, 1000, 100, 2000).is_none()); |
| } |
|
|
| #[test] |
| fn cbor_roundtrip_reading() { |
| let reading = Reading { |
| timestamp_ms: 1712345678000, |
| channel: 0, |
| raw_value: 1650, |
| calibrated_value: 7023, |
| unit: MeasurementUnit::Ph, |
| quality: ReadingQuality::Good, |
| }; |
|
|
| |
| let mut buf = alloc::vec![0u8; 0]; |
| minicbor::encode(&reading, &mut buf).expect("encode failed"); |
|
|
| |
| |
| assert!(buf.len() < 30, "reading CBOR too large: {} bytes", buf.len()); |
|
|
| |
| let decoded: Reading = minicbor::decode(&buf).expect("decode failed"); |
| assert_eq!(reading, decoded); |
| } |
|
|
| #[test] |
| fn cbor_roundtrip_payload() { |
| let payload = TransmissionPayload { |
| node_id: 1, |
| sequence: 42, |
| battery_mv: 3700, |
| readings: alloc::vec![ |
| Reading { |
| timestamp_ms: 1712345678000, |
| channel: 0, |
| raw_value: 1650, |
| calibrated_value: 7023, |
| unit: MeasurementUnit::Ph, |
| quality: ReadingQuality::Good, |
| }, |
| Reading { |
| timestamp_ms: 1712345678000, |
| channel: 1, |
| raw_value: 890, |
| calibrated_value: 1250, |
| unit: MeasurementUnit::Ec, |
| quality: ReadingQuality::Good, |
| }, |
| ], |
| }; |
|
|
| let mut buf = alloc::vec![0u8; 0]; |
| minicbor::encode(&payload, &mut buf).expect("encode failed"); |
|
|
| |
| assert!(buf.len() < 100, "payload CBOR too large: {} bytes", buf.len()); |
|
|
| let decoded: TransmissionPayload = minicbor::decode(&buf).expect("decode failed"); |
| assert_eq!(payload.node_id, decoded.node_id); |
| assert_eq!(payload.readings.len(), decoded.readings.len()); |
| } |
|
|
| #[test] |
| fn channel_bitmask_logic() { |
| let config = SensorConfig { |
| sample_interval_secs: 30, |
| active_channels: 0b00000101, |
| calibrations: alloc::vec![ |
| Calibration::identity(), |
| Calibration::identity(), |
| ], |
| }; |
|
|
| assert!(config.is_channel_active(0)); |
| assert!(!config.is_channel_active(1)); |
| assert!(config.is_channel_active(2)); |
| assert!(!config.is_channel_active(7)); |
| assert!(!config.is_channel_active(8)); |
|
|
| |
| let cal2 = config.cal_for(2); |
| assert_eq!(cal2.slope, 1000); |
| assert_eq!(cal2.offset, 0); |
| } |
| } |
|
|