// Synapse Agriculture — Sensor WASM Module // // This is the "application" that runs inside the wasm3 runtime on the MCU. // The wasm3 runtime is the "kernel" flashed onto the RP2350. // This module is deployed separately via LoRa OTA from the gateway. // // Architecture: // RP2350 boots → wasm3 runtime starts → loads this .wasm from flash // → calls guest_init() → enters main loop calling guest_sample() // → module calls host functions (read_i2c, transmit, sleep) via imports // // The host functions are implemented in C/Rust on the RP2350 bare metal // firmware. They talk to real hardware. This module never touches hardware // directly — it only sees the sandbox the host exposes. #![no_std] extern crate alloc; // Required for no_std WASM: global allocator and panic handler. // On the real MCU, you'd use a fixed-size bump allocator (e.g., embedded-alloc). // For now, dlmalloc works for WASM targets and is what wasm-pack uses. use alloc::vec::Vec; #[cfg(target_arch = "wasm32")] #[global_allocator] static ALLOC: dlmalloc::GlobalDlmalloc = dlmalloc::GlobalDlmalloc; #[cfg(target_arch = "wasm32")] #[panic_handler] fn panic(_info: &core::panic::PanicInfo) -> ! { core::arch::wasm32::unreachable() } use synapse_core::{ Calibration, MeasurementUnit, Reading, ReadingQuality, SensorConfig, TransmissionPayload, }; // --------------------------------------------------------------------------- // Host function imports — these are provided by the wasm3 runtime // --------------------------------------------------------------------------- // For the MCU target, these are raw extern "C" imports that wasm3 links // at module load time. The RP2350 firmware registers these functions // with the wasm3 runtime before instantiating the module. // // For the component model target (gateway/host), these would come from // wit-bindgen instead. That's a future enhancement — for now, we target // core wasm only, which is what wasm3 supports. extern "C" { /// Read bytes from an I2C device. /// Returns number of bytes actually read, or negative on error. /// Data is written to the `buf` pointer (must be in WASM linear memory). fn host_read_i2c(address: u8, register: u8, buf: *mut u8, buf_len: u8) -> i32; /// Read a raw 12-bit ADC value from the specified channel. /// Returns the value (0-4095) or negative on error. fn host_read_adc(channel: u8) -> i32; /// Get current timestamp in milliseconds from RTC or host clock. fn host_get_timestamp_ms() -> u64; /// Transmit a LoRa packet. Data is read from the `buf` pointer. /// Returns number of bytes queued, or negative on error. fn host_transmit(buf: *const u8, buf_len: u32) -> i32; /// Sleep for the specified number of milliseconds. /// The WASM module yields execution; host enters low-power mode. fn host_sleep_ms(duration_ms: u32); /// Log a message (for debugging — may be compiled out on MCU). fn host_log(level: u8, msg: *const u8, msg_len: u32); } // --------------------------------------------------------------------------- // Safe wrappers around host imports // --------------------------------------------------------------------------- fn read_i2c(address: u8, register: u8, buf: &mut [u8]) -> Result { let result = unsafe { host_read_i2c(address, register, buf.as_mut_ptr(), buf.len() as u8) }; if result < 0 { Err(ReadingQuality::Fault) } else { Ok(result as usize) } } fn read_adc(channel: u8) -> Result { let result = unsafe { host_read_adc(channel) }; if result < 0 { Err(ReadingQuality::Fault) } else { Ok(result as u16) } } fn timestamp_ms() -> u64 { unsafe { host_get_timestamp_ms() } } fn transmit(data: &[u8]) -> Result { let result = unsafe { host_transmit(data.as_ptr(), data.len() as u32) }; if result < 0 { Err(ReadingQuality::Fault) } else { Ok(result as usize) } } fn sleep(ms: u32) { unsafe { host_sleep_ms(ms) } } fn log_info(msg: &str) { unsafe { host_log(1, msg.as_ptr(), msg.len() as u32) } } // --------------------------------------------------------------------------- // Module state — lives in WASM linear memory, persists across calls // --------------------------------------------------------------------------- /// Global module state. Initialized in guest_init, used in guest_sample. /// This is safe because WASM is single-threaded — no concurrency concerns. static mut STATE: Option = None; struct ModuleState { config: SensorConfig, sequence: u16, node_id: u16, } // --------------------------------------------------------------------------- // Atlas Scientific EZO protocol helpers // --------------------------------------------------------------------------- // Atlas Scientific EZO-series probes (pH, EC, DO, ORP) use a simple // I2C protocol: write a command byte, wait, read the response. // Response format: [status_byte, ascii_data...] // Status: 1 = success, 2 = failed, 254 = pending, 255 = no data const ATLAS_CMD_READ: u8 = b'R'; const ATLAS_STATUS_SUCCESS: u8 = 1; /// Read a value from an Atlas Scientific EZO probe. /// Returns the reading as fixed-point * 1000, or an error quality. fn read_atlas_ezo(i2c_address: u8) -> Result { // Send read command let cmd = [ATLAS_CMD_READ]; let result = unsafe { host_read_i2c(i2c_address, cmd[0], core::ptr::null_mut(), 0) }; if result < 0 { return Err(ReadingQuality::Fault); } // Wait for measurement (Atlas EZO needs ~900ms for pH) sleep(1000); // Read response (up to 16 bytes: status + ASCII float) let mut buf = [0u8; 16]; let bytes_read = read_i2c(i2c_address, 0, &mut buf)?; if bytes_read < 2 || buf[0] != ATLAS_STATUS_SUCCESS { return Err(ReadingQuality::Fault); } // Parse ASCII float response to fixed-point * 1000 // Atlas returns something like "7.23\0" as ASCII bytes parse_ascii_fixed_point(&buf[1..bytes_read]) .ok_or(ReadingQuality::Degraded) } /// Parse ASCII decimal string (e.g., "7.23") to fixed-point * 1000 (7230). /// No floating point used — pure integer parsing for MCU efficiency. fn parse_ascii_fixed_point(bytes: &[u8]) -> Option { let mut result: i32 = 0; let mut decimal_places: i32 = -1; // -1 = haven't seen decimal point yet let mut negative = false; for &b in bytes { match b { b'-' if result == 0 => negative = true, b'0'..=b'9' => { result = result * 10 + (b - b'0') as i32; if decimal_places >= 0 { decimal_places += 1; } } b'.' => { if decimal_places >= 0 { return None; // second decimal point } decimal_places = 0; } 0 | b'\r' | b'\n' => break, // null terminator or newline _ => return None, // unexpected character } } // Scale to * 1000 fixed-point let scale = match decimal_places { -1 | 0 => 1000, // no decimal or "7." → 7000 1 => 100, // "7.2" → 7200 2 => 10, // "7.23" → 7230 3 => 1, // "7.230" → 7230 _ => return None, // more than 3 decimal places, truncate would lose data }; result *= scale; if negative { result = -result; } Some(result) } // --------------------------------------------------------------------------- // Guest exports — called by the wasm3 host runtime // --------------------------------------------------------------------------- /// Called once at boot. Receives serialized config via CBOR. /// Returns 1 on success, 0 on failure. #[no_mangle] pub extern "C" fn guest_init(config_ptr: *const u8, config_len: u32, node_id: u16) -> u32 { let config_bytes = unsafe { core::slice::from_raw_parts(config_ptr, config_len as usize) }; match minicbor::decode::(config_bytes) { Ok(config) => { log_info("sensor module initialized"); unsafe { STATE = Some(ModuleState { config, sequence: 0, node_id, }); } 1 } Err(_) => { log_info("failed to parse config"); 0 } } } /// Called each sample cycle. Reads all active sensors, builds payload, /// serializes to CBOR, and transmits via LoRa. /// Returns number of bytes transmitted, or 0 on failure. #[no_mangle] pub extern "C" fn guest_sample() -> u32 { let state = unsafe { match STATE.as_mut() { Some(s) => s, None => return 0, } }; let now = timestamp_ms(); let mut readings = Vec::new(); // Read each active channel for ch in 0..8u8 { if !state.config.is_channel_active(ch) { continue; } let cal = state.config.cal_for(ch); // For this reference implementation, channels 0-3 are Atlas EZO I2C // with addresses 0x63 (pH), 0x64 (EC), 0x61 (DO), 0x62 (ORP). // Channels 4-7 are ADC inputs for analog sensors. // Real deployments would have this mapping in the config. let (raw, unit, quality) = match ch { 0 => match read_atlas_ezo(0x63) { Ok(v) => (v, MeasurementUnit::Ph, ReadingQuality::Good), Err(q) => (0, MeasurementUnit::Ph, q), }, 1 => match read_atlas_ezo(0x64) { Ok(v) => (v, MeasurementUnit::Ec, ReadingQuality::Good), Err(q) => (0, MeasurementUnit::Ec, q), }, 2 => match read_atlas_ezo(0x61) { Ok(v) => (v, MeasurementUnit::DissolvedOxygen, ReadingQuality::Good), Err(q) => (0, MeasurementUnit::DissolvedOxygen, q), }, 3 => match read_atlas_ezo(0x62) { Ok(v) => (v, MeasurementUnit::Orp, ReadingQuality::Good), Err(q) => (0, MeasurementUnit::Orp, q), }, 4..=7 => match read_adc(ch - 4) { Ok(v) => (v as i32, MeasurementUnit::MoistureVwc, ReadingQuality::Good), Err(q) => (0, MeasurementUnit::MoistureVwc, q), }, _ => continue, }; readings.push(Reading { timestamp_ms: now, channel: ch, raw_value: raw, calibrated_value: cal.apply(raw), unit, quality, }); } // Build transmission payload let payload = TransmissionPayload { node_id: state.node_id, sequence: state.sequence, battery_mv: read_adc(7).unwrap_or(0), // ADC ch7 = battery voltage divider readings, }; // Increment sequence (wraps at u16::MAX) state.sequence = state.sequence.wrapping_add(1); // Serialize to CBOR and transmit let mut buf = Vec::new(); match minicbor::encode(&payload, &mut buf) { Ok(()) => { match transmit(&buf) { Ok(n) => n as u32, Err(_) => { log_info("transmit failed"); 0 } } } Err(_) => { log_info("cbor encode failed"); 0 } } } /// Receive new config from gateway. Returns 1 on success, 0 on failure. #[no_mangle] pub extern "C" fn guest_reconfigure(config_ptr: *const u8, config_len: u32) -> u32 { let config_bytes = unsafe { core::slice::from_raw_parts(config_ptr, config_len as usize) }; match minicbor::decode::(config_bytes) { Ok(config) => { if let Some(state) = unsafe { STATE.as_mut() } { state.config = config; log_info("reconfigured"); 1 } else { 0 } } Err(_) => 0, } } // --------------------------------------------------------------------------- // Tests — run natively on Houston, not under WASM // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn parse_ascii_ph() { assert_eq!(parse_ascii_fixed_point(b"7.23"), Some(7230)); assert_eq!(parse_ascii_fixed_point(b"4.0"), Some(4000)); assert_eq!(parse_ascii_fixed_point(b"14"), Some(14000)); assert_eq!(parse_ascii_fixed_point(b"0.5"), Some(500)); assert_eq!(parse_ascii_fixed_point(b"-1.5"), Some(-1500)); } #[test] fn parse_ascii_null_terminated() { assert_eq!(parse_ascii_fixed_point(b"7.23\0\0\0"), Some(7230)); assert_eq!(parse_ascii_fixed_point(b"4.01\r\n"), Some(4010)); } #[test] fn parse_ascii_rejects_garbage() { assert_eq!(parse_ascii_fixed_point(b"abc"), None); assert_eq!(parse_ascii_fixed_point(b"7.2.3"), None); } }