File size: 14,341 Bytes
7932636
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
// 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);
    }
}