Robbo
Initial: WASM sensor stack β€” core types, MCU module (32KB), WIT contract
7932636 unverified
// 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<usize, ReadingQuality> {
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<u16, ReadingQuality> {
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<usize, ReadingQuality> {
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<ModuleState> = 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<i32, ReadingQuality> {
// 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<i32> {
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::<SensorConfig>(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::<SensorConfig>(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);
}
}