// Synapse Agriculture — Core Types // // These types are the Rust-native mirror of wit/sensor.wit. // They exist so that crates which DON'T use the component model // (like synapse-web for the browser) can still share the same // data structures without pulling in wit-bindgen. // // Rule: if you change a type here, the WIT file must change too, // and vice versa. They are two representations of one contract. #![cfg_attr(not(feature = "std"), no_std)] extern crate alloc; use alloc::vec::Vec; use minicbor::{Decode, Encode}; // --------------------------------------------------------------------------- // Measurement units — what physical quantity a reading represents // --------------------------------------------------------------------------- /// Maps 1:1 to the measurement-unit enum in sensor.wit. /// The u8 repr keeps CBOR encoding to a single byte. #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] #[cbor(index_only)] pub enum MeasurementUnit { // Water chemistry #[n(0)] Ph, #[n(1)] Ec, #[n(2)] DissolvedOxygen, #[n(3)] Orp, #[n(4)] TemperatureWater, // Soil #[n(10)] MoistureVwc, #[n(11)] TemperatureSoil, // Atmosphere #[n(20)] TemperatureAir, #[n(21)] Humidity, #[n(22)] Pressure, #[n(23)] LightLux, #[n(24)] LightPar, // Power (Layer 7) #[n(30)] Voltage, #[n(31)] Current, #[n(32)] Power, #[n(33)] BatterySoc, } // --------------------------------------------------------------------------- // Reading quality — self-diagnostic assessment // --------------------------------------------------------------------------- #[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, } // --------------------------------------------------------------------------- // Core reading type — one sensor measurement with full provenance // --------------------------------------------------------------------------- /// A single sensor reading. This is the atomic unit of data in Synapse. /// Everything upstream (InfluxDB, Grafana, Board agents) consumes these. /// /// Design decision: s32 fixed-point instead of f32. /// On Cortex-M0+/M33 without FPU, soft-float is ~10x slower than integer /// math. pH 7.23 is stored as 7230 (value * 1000). Calibration formulas /// use integer multiply-then-divide to stay in s32 throughout. /// The browser and host can convert to f64 for display. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct Reading { /// Unix timestamp in milliseconds #[n(0)] pub timestamp_ms: u64, /// Sensor channel (physical probe identifier on this node) #[n(1)] pub channel: u8, /// Raw ADC/digital value before calibration #[n(2)] pub raw_value: i32, /// Calibrated value, fixed-point * 1000 #[n(3)] pub calibrated_value: i32, /// What this reading measures #[n(4)] pub unit: MeasurementUnit, /// Self-diagnostic quality flag #[n(5)] pub quality: ReadingQuality, } // --------------------------------------------------------------------------- // Calibration — linear two-point cal coefficients // --------------------------------------------------------------------------- /// Linear calibration: calibrated = (raw * slope / 1000) + offset /// Slope and offset are both fixed-point * 1000. /// /// Example: pH probe reads raw 1650 at pH 7.0 and raw 2200 at pH 4.0. /// slope = (4000 - 7000) / (2200 - 1650) = -3000 / 550 ≈ -5454 /// offset = 7000 - (1650 * -5454 / 1000) = 7000 + 8999 = 15999 /// calibrate(1650) = (1650 * -5454 / 1000) + 15999 = -9000 + 15999 = 6999 ≈ 7.0 ✓ /// calibrate(2200) = (2200 * -5454 / 1000) + 15999 = -11999 + 15999 = 4000 ≈ 4.0 ✓ #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] pub struct Calibration { #[n(0)] pub slope: i32, #[n(1)] pub offset: i32, } impl Calibration { /// Apply this calibration to a raw reading. /// All arithmetic stays in i32 — no floating point needed. /// The intermediate multiply uses i64 to prevent overflow /// (raw up to ~2M * slope up to ~100K = fits in i64 fine). pub fn apply(&self, raw: i32) -> i32 { let intermediate = (raw as i64) * (self.slope as i64) / 1000; (intermediate as i32) + self.offset } /// Identity calibration — passes raw through unchanged. /// Used as default when no cal data exists yet. pub const fn identity() -> Self { Self { slope: 1000, // 1.0 in fixed-point offset: 0, } } /// Construct calibration from two known reference points. /// (raw1, known1) and (raw2, known2), all in fixed-point * 1000. /// Returns None if the two raw values are identical (divide by zero). pub fn from_two_point(raw1: i32, known1: i32, raw2: i32, known2: i32) -> Option { let raw_diff = raw2 - raw1; if raw_diff == 0 { return None; } // slope = (known2 - known1) * 1000 / (raw2 - raw1) let slope = ((known2 as i64 - known1 as i64) * 1000) / raw_diff as i64; // offset = known1 - (raw1 * slope / 1000) let offset = known1 as i64 - (raw1 as i64 * slope / 1000); Some(Self { slope: slope as i32, offset: offset as i32, }) } } // --------------------------------------------------------------------------- // Sensor config — pushed from gateway to node // --------------------------------------------------------------------------- /// Configuration for a sensor node. Pushed from gateway via LoRa OTA /// or set at initial provisioning. Serialized as CBOR for LoRa transport. #[derive(Debug, Clone, Encode, Decode)] pub struct SensorConfig { /// How often to sample, in seconds #[n(0)] pub sample_interval_secs: u32, /// Bitmask of active channels (bit 0 = channel 0, etc.) #[n(1)] pub active_channels: u8, /// Per-channel calibration coefficients /// Index in this vec = channel number #[n(2)] pub calibrations: Vec, } impl SensorConfig { /// Check if a specific channel is enabled in the bitmask pub fn is_channel_active(&self, channel: u8) -> bool { channel < 8 && (self.active_channels & (1 << channel)) != 0 } /// Get calibration for a channel, falling back to identity if missing pub fn cal_for(&self, channel: u8) -> Calibration { self.calibrations .get(channel as usize) .copied() .unwrap_or(Calibration::identity()) } } // --------------------------------------------------------------------------- // Transmission payload — what goes over LoRa // --------------------------------------------------------------------------- /// The complete payload for one LoRa transmission. /// Designed to fit in a single LoRa packet at SF7/BW125: /// Max payload = 242 bytes /// CBOR header + node_id + seq + battery ≈ 10 bytes /// Each Reading ≈ 18-22 bytes CBOR /// So roughly 10-12 readings per packet /// /// If a node has more channels than fit in one packet, /// the module splits across multiple transmissions. #[derive(Debug, Clone, Encode, Decode)] pub struct TransmissionPayload { /// Unique node ID within a site (set at provisioning) #[n(0)] pub node_id: u16, /// Monotonic sequence number — wraps at u16::MAX /// Gateway uses this for dedup and gap detection #[n(1)] pub sequence: u16, /// Battery voltage in millivolts (power health monitoring) #[n(2)] pub battery_mv: u16, /// All readings from this sample cycle #[n(3)] pub readings: Vec, } // --------------------------------------------------------------------------- // MQTT topic builder — generates the Synapse topic namespace // --------------------------------------------------------------------------- /// Builds MQTT topic strings matching the Synapse namespace convention: /// synapse/site/{site}/zone/{zone}/node/{node_id}/reading /// synapse/site/{site}/zone/{zone}/node/{node_id}/health /// synapse/site/{site}/zone/{zone}/node/{node_id}/config /// /// Only available with std feature (String requires alloc+std for formatting). /// The MCU doesn't build MQTT topics — the gateway does. #[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") } } // --------------------------------------------------------------------------- // Conversion helpers for display layers (gateway, host, browser) // --------------------------------------------------------------------------- #[cfg(feature = "std")] impl Reading { /// Convert fixed-point calibrated_value to f64 for display pub fn calibrated_f64(&self) -> f64 { self.calibrated_value as f64 / 1000.0 } /// Human-readable unit string for dashboards 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 => "%", } } } // --------------------------------------------------------------------------- // Tests — these run native on Houston, validating logic before WASM compile // --------------------------------------------------------------------------- #[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() { // pH 7.0 probe reads raw 1650, pH 4.0 reads raw 2200 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); // Allow ±10 (0.01 pH) for integer rounding 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, }; // Encode to CBOR let mut buf = alloc::vec![0u8; 0]; minicbor::encode(&reading, &mut buf).expect("encode failed"); // Verify it's compact enough for LoRa // A single reading should be well under 30 bytes assert!(buf.len() < 30, "reading CBOR too large: {} bytes", buf.len()); // Decode and verify roundtrip 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"); // Two-reading payload should fit comfortably in LoRa 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, // channels 0 and 2 active calibrations: alloc::vec![ Calibration::identity(), // ch 0 Calibration::identity(), // ch 1 (inactive but cal exists) ], }; 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)); // out of range // Channel 2 has no cal entry — should fall back to identity let cal2 = config.cal_for(2); assert_eq!(cal2.slope, 1000); assert_eq!(cal2.offset, 0); } }