Robbo
Initial: WASM sensor stack — core types, MCU module (32KB), WIT contract
7932636 unverified
// 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<Self> {
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<Calibration>,
}
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<Reading>,
}
// ---------------------------------------------------------------------------
// 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);
}
}