Robbo commited on
Commit
7932636
·
unverified ·
0 Parent(s):

Initial: WASM sensor stack — core types, MCU module (32KB), WIT contract

Browse files
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ /target/
2
+ result
3
+ .direnv/
Cargo.lock ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "byteorder"
7
+ version = "1.5.0"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
10
+
11
+ [[package]]
12
+ name = "cfg-if"
13
+ version = "1.0.4"
14
+ source = "registry+https://github.com/rust-lang/crates.io-index"
15
+ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
16
+
17
+ [[package]]
18
+ name = "dlmalloc"
19
+ version = "0.2.13"
20
+ source = "registry+https://github.com/rust-lang/crates.io-index"
21
+ checksum = "9f5b01c17f85ee988d832c40e549a64bd89ab2c9f8d8a613bdf5122ae507e294"
22
+ dependencies = [
23
+ "cfg-if",
24
+ "libc",
25
+ "windows-sys",
26
+ ]
27
+
28
+ [[package]]
29
+ name = "hash32"
30
+ version = "0.3.1"
31
+ source = "registry+https://github.com/rust-lang/crates.io-index"
32
+ checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
33
+ dependencies = [
34
+ "byteorder",
35
+ ]
36
+
37
+ [[package]]
38
+ name = "heapless"
39
+ version = "0.8.0"
40
+ source = "registry+https://github.com/rust-lang/crates.io-index"
41
+ checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
42
+ dependencies = [
43
+ "hash32",
44
+ "stable_deref_trait",
45
+ ]
46
+
47
+ [[package]]
48
+ name = "libc"
49
+ version = "0.2.184"
50
+ source = "registry+https://github.com/rust-lang/crates.io-index"
51
+ checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
52
+
53
+ [[package]]
54
+ name = "minicbor"
55
+ version = "0.25.1"
56
+ source = "registry+https://github.com/rust-lang/crates.io-index"
57
+ checksum = "c0452a60c1863c1f50b5f77cd295e8d2786849f35883f0b9e18e7e6e1b5691b0"
58
+ dependencies = [
59
+ "minicbor-derive",
60
+ ]
61
+
62
+ [[package]]
63
+ name = "minicbor-derive"
64
+ version = "0.15.3"
65
+ source = "registry+https://github.com/rust-lang/crates.io-index"
66
+ checksum = "bd2209fff77f705b00c737016a48e73733d7fbccb8b007194db148f03561fb70"
67
+ dependencies = [
68
+ "proc-macro2",
69
+ "quote",
70
+ "syn",
71
+ ]
72
+
73
+ [[package]]
74
+ name = "proc-macro2"
75
+ version = "1.0.106"
76
+ source = "registry+https://github.com/rust-lang/crates.io-index"
77
+ checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
78
+ dependencies = [
79
+ "unicode-ident",
80
+ ]
81
+
82
+ [[package]]
83
+ name = "quote"
84
+ version = "1.0.45"
85
+ source = "registry+https://github.com/rust-lang/crates.io-index"
86
+ checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
87
+ dependencies = [
88
+ "proc-macro2",
89
+ ]
90
+
91
+ [[package]]
92
+ name = "stable_deref_trait"
93
+ version = "1.2.1"
94
+ source = "registry+https://github.com/rust-lang/crates.io-index"
95
+ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
96
+
97
+ [[package]]
98
+ name = "syn"
99
+ version = "2.0.117"
100
+ source = "registry+https://github.com/rust-lang/crates.io-index"
101
+ checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
102
+ dependencies = [
103
+ "proc-macro2",
104
+ "quote",
105
+ "unicode-ident",
106
+ ]
107
+
108
+ [[package]]
109
+ name = "synapse-core"
110
+ version = "0.1.0"
111
+ dependencies = [
112
+ "heapless",
113
+ "minicbor",
114
+ ]
115
+
116
+ [[package]]
117
+ name = "synapse-sensor"
118
+ version = "0.1.0"
119
+ dependencies = [
120
+ "dlmalloc",
121
+ "heapless",
122
+ "minicbor",
123
+ "synapse-core",
124
+ ]
125
+
126
+ [[package]]
127
+ name = "synapse-web"
128
+ version = "0.1.0"
129
+ dependencies = [
130
+ "minicbor",
131
+ "synapse-core",
132
+ ]
133
+
134
+ [[package]]
135
+ name = "unicode-ident"
136
+ version = "1.0.24"
137
+ source = "registry+https://github.com/rust-lang/crates.io-index"
138
+ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
139
+
140
+ [[package]]
141
+ name = "windows-link"
142
+ version = "0.2.1"
143
+ source = "registry+https://github.com/rust-lang/crates.io-index"
144
+ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
145
+
146
+ [[package]]
147
+ name = "windows-sys"
148
+ version = "0.61.2"
149
+ source = "registry+https://github.com/rust-lang/crates.io-index"
150
+ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
151
+ dependencies = [
152
+ "windows-link",
153
+ ]
Cargo.toml ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [workspace]
2
+ resolver = "2"
3
+ members = [
4
+ "crates/synapse-core",
5
+ "crates/synapse-sensor",
6
+ "crates/synapse-web",
7
+ ]
8
+
9
+ [workspace.package]
10
+ version = "0.1.0"
11
+ edition = "2021"
12
+ license = "AGPL-3.0-or-later"
13
+ repository = "http://git.houston.local/robbo/synapse-wasm"
14
+ authors = ["Robert David Adams III <robbo@synapseagriculture.com>"]
15
+
16
+ [workspace.dependencies]
17
+ # Shared across crates — pin versions here, inherit in members
18
+ minicbor = { version = "0.25", features = ["alloc", "derive"] } # CBOR serialization (tiny, no-std compatible)
19
+ heapless = "0.8" # Fixed-size collections for no-std (MCU-safe)
20
+
21
+ # These only apply to std-capable targets (gateway, host, browser)
22
+ # MCU crate uses no_std and doesn't pull these in
23
+ [workspace.metadata.unused]
24
+ # Placeholder — workspace.dependencies above are what matters
25
+
26
+ [profile.release]
27
+ # Aggressive optimization for WASM binary size
28
+ opt-level = "z" # Optimize for size over speed
29
+ lto = true # Link-time optimization across crate boundaries
30
+ codegen-units = 1 # Single codegen unit = better optimization, slower compile
31
+ panic = "abort" # No unwinding = smaller binary (critical for MCU)
32
+ strip = true # Strip debug symbols from release builds
build.sh ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Synapse Agriculture — WASM build pipeline
3
+ # Run from workspace root inside `nix develop`
4
+ #
5
+ # This script enforces the correct build ordering:
6
+ # 1. Native tests (fastest feedback loop)
7
+ # 2. WASM compilation (catches target-specific issues)
8
+ # 3. Size profiling (validates MCU memory budget)
9
+ # 4. Runtime validation (wasm3 / wasmtime)
10
+ #
11
+ # Each step gates the next — fail fast, don't waste time.
12
+
13
+ set -euo pipefail
14
+
15
+ RED='\033[0;31m'
16
+ GREEN='\033[0;32m'
17
+ YELLOW='\033[1;33m'
18
+ CYAN='\033[0;36m'
19
+ NC='\033[0m'
20
+
21
+ WASM_TARGET="wasm32-unknown-unknown"
22
+ WASI_TARGET="wasm32-wasip1"
23
+ SENSOR_WASM="target/${WASM_TARGET}/release/synapse_sensor.wasm"
24
+ OPTIMIZED_WASM="target/wasm-opt/synapse_sensor.wasm"
25
+
26
+ # MCU memory budget (RP2350: 520KB total SRAM)
27
+ # wasm3 runtime: ~64KB, LoRa + HAL: ~48KB, heap: ~100KB
28
+ # Leaves roughly 300KB for the WASM module binary
29
+ MAX_WASM_SIZE_KB=300
30
+
31
+ step() { echo -e "\n${CYAN}═══ $1 ═══${NC}\n"; }
32
+ pass() { echo -e "${GREEN} ✓ $1${NC}"; }
33
+ fail() { echo -e "${RED} ✗ $1${NC}"; exit 1; }
34
+ warn() { echo -e "${YELLOW} ⚠ $1${NC}"; }
35
+
36
+ # ── Step 1: Native tests ──────────────────────────────────────────────────
37
+ # Run all tests on the host architecture (x86_64).
38
+ # This validates shared types, calibration math, CBOR serialization,
39
+ # and ASCII parsing WITHOUT any WASM complexity.
40
+ # This is your fastest iteration loop — stay here until green.
41
+
42
+ step "Step 1: Native tests (all crates)"
43
+ cargo test --workspace --quiet 2>&1
44
+ pass "All native tests passed"
45
+
46
+ # ── Step 2: Compile sensor module to WASM ─────────────────────────────────
47
+ # Target: wasm32-unknown-unknown (bare WASM, no WASI)
48
+ # This is what runs on the MCU via wasm3.
49
+ # cdylib crate-type in synapse-sensor produces the .wasm file.
50
+ # release profile uses opt-level=z, LTO, panic=abort, strip=true
51
+ # for minimum binary size.
52
+
53
+ step "Step 2: Compile synapse-sensor → WASM (MCU target)"
54
+ cargo build \
55
+ --package synapse-sensor \
56
+ --target "${WASM_TARGET}" \
57
+ --release \
58
+ --quiet 2>&1
59
+
60
+ if [ ! -f "${SENSOR_WASM}" ]; then
61
+ fail "WASM binary not found at ${SENSOR_WASM}"
62
+ fi
63
+
64
+ RAW_SIZE=$(wc -c < "${SENSOR_WASM}")
65
+ RAW_SIZE_KB=$((RAW_SIZE / 1024))
66
+ pass "Raw WASM binary: ${RAW_SIZE_KB}KB (${RAW_SIZE} bytes)"
67
+
68
+ # ── Step 3: Optimize for size ─────────────────────────────────────────────
69
+ # wasm-opt from Binaryen does aggressive dead code elimination,
70
+ # constant folding, and code deduplication. The -Oz flag optimizes
71
+ # purely for size (vs -O4 which optimizes for speed).
72
+ # This typically cuts Rust WASM binaries by 40-60%.
73
+
74
+ step "Step 3: Size optimization (wasm-opt -Oz)"
75
+ mkdir -p "$(dirname ${OPTIMIZED_WASM})"
76
+ wasm-opt -Oz \
77
+ --strip-debug \
78
+ --strip-producers \
79
+ -o "${OPTIMIZED_WASM}" \
80
+ "${SENSOR_WASM}" 2>&1
81
+
82
+ OPT_SIZE=$(wc -c < "${OPTIMIZED_WASM}")
83
+ OPT_SIZE_KB=$((OPT_SIZE / 1024))
84
+ SAVINGS=$(( (RAW_SIZE - OPT_SIZE) * 100 / RAW_SIZE ))
85
+ pass "Optimized WASM: ${OPT_SIZE_KB}KB (${OPT_SIZE} bytes, ${SAVINGS}% reduction)"
86
+
87
+ # ── Step 4: MCU memory budget check ──────────────────────────────────────
88
+ # Hard gate: if the module exceeds the RP2350 memory budget, stop.
89
+ # Better to catch this now than when flashing hardware in the field.
90
+
91
+ step "Step 4: MCU memory budget check (max ${MAX_WASM_SIZE_KB}KB)"
92
+ if [ "${OPT_SIZE_KB}" -gt "${MAX_WASM_SIZE_KB}" ]; then
93
+ fail "WASM module (${OPT_SIZE_KB}KB) exceeds MCU budget (${MAX_WASM_SIZE_KB}KB)"
94
+ echo " Run: twiggy top ${OPTIMIZED_WASM}"
95
+ echo " to find what's taking space"
96
+ exit 1
97
+ fi
98
+ pass "Module fits in MCU budget: ${OPT_SIZE_KB}KB / ${MAX_WASM_SIZE_KB}KB"
99
+
100
+ # ── Step 5: Size profiling ────────────────────────────────────────────────
101
+ # Even if we're under budget, know where the bytes are going.
102
+ # twiggy shows the top functions by size — if serde or fmt
103
+ # machinery snuck in, you'll see it here.
104
+
105
+ step "Step 5: Size profile (top 15 largest items)"
106
+ if command -v twiggy &>/dev/null; then
107
+ twiggy top "${OPTIMIZED_WASM}" -n 15 2>&1 || warn "twiggy failed (non-fatal)"
108
+ else
109
+ warn "twiggy not found — skip size profiling"
110
+ fi
111
+
112
+ # ── Step 6: WASM validation ──────────────────────────────────────────────
113
+ # wasm-tools validate checks the module against the WASM spec.
114
+ # Catches issues like invalid opcodes, type mismatches, or
115
+ # features the MCU runtime doesn't support.
116
+
117
+ step "Step 6: WASM module validation"
118
+ if command -v wasm-tools &>/dev/null; then
119
+ wasm-tools validate "${OPTIMIZED_WASM}" 2>&1
120
+ pass "Module passes WASM spec validation"
121
+ else
122
+ warn "wasm-tools not found — skip validation"
123
+ fi
124
+
125
+ # ── Step 7: Export inspection ─────────────────────────────────────────────
126
+ # Verify the module exports the expected guest functions.
127
+ # The wasm3 runtime on the MCU will look for these by name.
128
+
129
+ step "Step 7: Export verification"
130
+ if command -v wasm-tools &>/dev/null; then
131
+ EXPORTS=$(wasm-tools dump "${OPTIMIZED_WASM}" 2>/dev/null | grep -c "export" || echo "0")
132
+ echo " Module has ${EXPORTS} exports"
133
+
134
+ # Check for our required guest functions
135
+ for func in guest_init guest_sample guest_reconfigure; do
136
+ if wasm2wat "${OPTIMIZED_WASM}" 2>/dev/null | grep -q "\"${func}\""; then
137
+ pass "Found export: ${func}"
138
+ else
139
+ fail "Missing required export: ${func}"
140
+ fi
141
+ done
142
+ else
143
+ warn "wasm-tools not found — skip export check"
144
+ fi
145
+
146
+ # ── Step 8: Import verification ───────────────────────────────────────────
147
+ # Verify the module imports match what the MCU host firmware provides.
148
+ # Any import the host doesn't implement = crash at instantiation.
149
+
150
+ step "Step 8: Import verification (host function dependencies)"
151
+ if command -v wabt &>/dev/null && command -v wasm2wat &>/dev/null; then
152
+ echo " Required host functions:"
153
+ wasm2wat "${OPTIMIZED_WASM}" 2>/dev/null \
154
+ | grep '(import' \
155
+ | sed 's/.*"\([^"]*\)".*/ → \1/' \
156
+ || warn "Could not extract imports"
157
+ else
158
+ warn "wabt not found — skip import check"
159
+ fi
160
+
161
+ # ── Summary ───────────────────────────────────────────────────────────────
162
+ step "BUILD COMPLETE"
163
+ echo -e " ${GREEN}Native tests: PASS${NC}"
164
+ echo -e " ${GREEN}WASM compile: PASS${NC}"
165
+ echo -e " ${GREEN}Size budget: ${OPT_SIZE_KB}KB / ${MAX_WASM_SIZE_KB}KB${NC}"
166
+ echo -e " ${GREEN}Spec valid: PASS${NC}"
167
+ echo ""
168
+ echo " Artifacts:"
169
+ echo " Raw: ${SENSOR_WASM}"
170
+ echo " Optimized: ${OPTIMIZED_WASM}"
171
+ echo ""
172
+ echo " Next steps:"
173
+ echo " wasmtime (WASI): needs wasm32-wasip1 target build"
174
+ echo " wasm3 (MCU sim): wasm3 ${OPTIMIZED_WASM} --func guest_sample"
175
+ echo " Browser: trunk serve crates/synapse-web"
176
+ echo ""
crates/synapse-core/Cargo.toml ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [package]
2
+ name = "synapse-core"
3
+ description = "Shared types and calibration logic for the Synapse sensor stack"
4
+ version.workspace = true
5
+ edition.workspace = true
6
+ license.workspace = true
7
+
8
+ [features]
9
+ default = ["std"]
10
+ std = ["minicbor/std"]
11
+ # no default features = no_std (MCU target)
12
+ # The MCU crate depends on synapse-core with default-features = false
13
+
14
+ [dependencies]
15
+ minicbor = { workspace = true }
16
+ heapless = { workspace = true }
crates/synapse-core/src/lib.rs ADDED
@@ -0,0 +1,387 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Synapse Agriculture — Core Types
2
+ //
3
+ // These types are the Rust-native mirror of wit/sensor.wit.
4
+ // They exist so that crates which DON'T use the component model
5
+ // (like synapse-web for the browser) can still share the same
6
+ // data structures without pulling in wit-bindgen.
7
+ //
8
+ // Rule: if you change a type here, the WIT file must change too,
9
+ // and vice versa. They are two representations of one contract.
10
+
11
+ #![cfg_attr(not(feature = "std"), no_std)]
12
+
13
+ extern crate alloc;
14
+ use alloc::vec::Vec;
15
+ use minicbor::{Decode, Encode};
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Measurement units — what physical quantity a reading represents
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /// Maps 1:1 to the measurement-unit enum in sensor.wit.
22
+ /// The u8 repr keeps CBOR encoding to a single byte.
23
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)]
24
+ #[cbor(index_only)]
25
+ pub enum MeasurementUnit {
26
+ // Water chemistry
27
+ #[n(0)] Ph,
28
+ #[n(1)] Ec,
29
+ #[n(2)] DissolvedOxygen,
30
+ #[n(3)] Orp,
31
+ #[n(4)] TemperatureWater,
32
+
33
+ // Soil
34
+ #[n(10)] MoistureVwc,
35
+ #[n(11)] TemperatureSoil,
36
+
37
+ // Atmosphere
38
+ #[n(20)] TemperatureAir,
39
+ #[n(21)] Humidity,
40
+ #[n(22)] Pressure,
41
+ #[n(23)] LightLux,
42
+ #[n(24)] LightPar,
43
+
44
+ // Power (Layer 7)
45
+ #[n(30)] Voltage,
46
+ #[n(31)] Current,
47
+ #[n(32)] Power,
48
+ #[n(33)] BatterySoc,
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Reading quality — self-diagnostic assessment
53
+ // ---------------------------------------------------------------------------
54
+
55
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)]
56
+ #[cbor(index_only)]
57
+ pub enum ReadingQuality {
58
+ #[n(0)] Good,
59
+ #[n(1)] Degraded,
60
+ #[n(2)] CalNeeded,
61
+ #[n(3)] Fault,
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Core reading type — one sensor measurement with full provenance
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /// A single sensor reading. This is the atomic unit of data in Synapse.
69
+ /// Everything upstream (InfluxDB, Grafana, Board agents) consumes these.
70
+ ///
71
+ /// Design decision: s32 fixed-point instead of f32.
72
+ /// On Cortex-M0+/M33 without FPU, soft-float is ~10x slower than integer
73
+ /// math. pH 7.23 is stored as 7230 (value * 1000). Calibration formulas
74
+ /// use integer multiply-then-divide to stay in s32 throughout.
75
+ /// The browser and host can convert to f64 for display.
76
+ #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
77
+ pub struct Reading {
78
+ /// Unix timestamp in milliseconds
79
+ #[n(0)] pub timestamp_ms: u64,
80
+ /// Sensor channel (physical probe identifier on this node)
81
+ #[n(1)] pub channel: u8,
82
+ /// Raw ADC/digital value before calibration
83
+ #[n(2)] pub raw_value: i32,
84
+ /// Calibrated value, fixed-point * 1000
85
+ #[n(3)] pub calibrated_value: i32,
86
+ /// What this reading measures
87
+ #[n(4)] pub unit: MeasurementUnit,
88
+ /// Self-diagnostic quality flag
89
+ #[n(5)] pub quality: ReadingQuality,
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Calibration — linear two-point cal coefficients
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /// Linear calibration: calibrated = (raw * slope / 1000) + offset
97
+ /// Slope and offset are both fixed-point * 1000.
98
+ ///
99
+ /// Example: pH probe reads raw 1650 at pH 7.0 and raw 2200 at pH 4.0.
100
+ /// slope = (4000 - 7000) / (2200 - 1650) = -3000 / 550 ≈ -5454
101
+ /// offset = 7000 - (1650 * -5454 / 1000) = 7000 + 8999 = 15999
102
+ /// calibrate(1650) = (1650 * -5454 / 1000) + 15999 = -9000 + 15999 = 6999 ≈ 7.0 ✓
103
+ /// calibrate(2200) = (2200 * -5454 / 1000) + 15999 = -11999 + 15999 = 4000 ≈ 4.0 ✓
104
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)]
105
+ pub struct Calibration {
106
+ #[n(0)] pub slope: i32,
107
+ #[n(1)] pub offset: i32,
108
+ }
109
+
110
+ impl Calibration {
111
+ /// Apply this calibration to a raw reading.
112
+ /// All arithmetic stays in i32 — no floating point needed.
113
+ /// The intermediate multiply uses i64 to prevent overflow
114
+ /// (raw up to ~2M * slope up to ~100K = fits in i64 fine).
115
+ pub fn apply(&self, raw: i32) -> i32 {
116
+ let intermediate = (raw as i64) * (self.slope as i64) / 1000;
117
+ (intermediate as i32) + self.offset
118
+ }
119
+
120
+ /// Identity calibration — passes raw through unchanged.
121
+ /// Used as default when no cal data exists yet.
122
+ pub const fn identity() -> Self {
123
+ Self {
124
+ slope: 1000, // 1.0 in fixed-point
125
+ offset: 0,
126
+ }
127
+ }
128
+
129
+ /// Construct calibration from two known reference points.
130
+ /// (raw1, known1) and (raw2, known2), all in fixed-point * 1000.
131
+ /// Returns None if the two raw values are identical (divide by zero).
132
+ pub fn from_two_point(raw1: i32, known1: i32, raw2: i32, known2: i32) -> Option<Self> {
133
+ let raw_diff = raw2 - raw1;
134
+ if raw_diff == 0 {
135
+ return None;
136
+ }
137
+ // slope = (known2 - known1) * 1000 / (raw2 - raw1)
138
+ let slope = ((known2 as i64 - known1 as i64) * 1000) / raw_diff as i64;
139
+ // offset = known1 - (raw1 * slope / 1000)
140
+ let offset = known1 as i64 - (raw1 as i64 * slope / 1000);
141
+ Some(Self {
142
+ slope: slope as i32,
143
+ offset: offset as i32,
144
+ })
145
+ }
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Sensor config — pushed from gateway to node
150
+ // ---------------------------------------------------------------------------
151
+
152
+ /// Configuration for a sensor node. Pushed from gateway via LoRa OTA
153
+ /// or set at initial provisioning. Serialized as CBOR for LoRa transport.
154
+ #[derive(Debug, Clone, Encode, Decode)]
155
+ pub struct SensorConfig {
156
+ /// How often to sample, in seconds
157
+ #[n(0)] pub sample_interval_secs: u32,
158
+ /// Bitmask of active channels (bit 0 = channel 0, etc.)
159
+ #[n(1)] pub active_channels: u8,
160
+ /// Per-channel calibration coefficients
161
+ /// Index in this vec = channel number
162
+ #[n(2)] pub calibrations: Vec<Calibration>,
163
+ }
164
+
165
+ impl SensorConfig {
166
+ /// Check if a specific channel is enabled in the bitmask
167
+ pub fn is_channel_active(&self, channel: u8) -> bool {
168
+ channel < 8 && (self.active_channels & (1 << channel)) != 0
169
+ }
170
+
171
+ /// Get calibration for a channel, falling back to identity if missing
172
+ pub fn cal_for(&self, channel: u8) -> Calibration {
173
+ self.calibrations
174
+ .get(channel as usize)
175
+ .copied()
176
+ .unwrap_or(Calibration::identity())
177
+ }
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Transmission payload — what goes over LoRa
182
+ // ---------------------------------------------------------------------------
183
+
184
+ /// The complete payload for one LoRa transmission.
185
+ /// Designed to fit in a single LoRa packet at SF7/BW125:
186
+ /// Max payload = 242 bytes
187
+ /// CBOR header + node_id + seq + battery ≈ 10 bytes
188
+ /// Each Reading ≈ 18-22 bytes CBOR
189
+ /// So roughly 10-12 readings per packet
190
+ ///
191
+ /// If a node has more channels than fit in one packet,
192
+ /// the module splits across multiple transmissions.
193
+ #[derive(Debug, Clone, Encode, Decode)]
194
+ pub struct TransmissionPayload {
195
+ /// Unique node ID within a site (set at provisioning)
196
+ #[n(0)] pub node_id: u16,
197
+ /// Monotonic sequence number — wraps at u16::MAX
198
+ /// Gateway uses this for dedup and gap detection
199
+ #[n(1)] pub sequence: u16,
200
+ /// Battery voltage in millivolts (power health monitoring)
201
+ #[n(2)] pub battery_mv: u16,
202
+ /// All readings from this sample cycle
203
+ #[n(3)] pub readings: Vec<Reading>,
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // MQTT topic builder — generates the Synapse topic namespace
208
+ // ---------------------------------------------------------------------------
209
+
210
+ /// Builds MQTT topic strings matching the Synapse namespace convention:
211
+ /// synapse/site/{site}/zone/{zone}/node/{node_id}/reading
212
+ /// synapse/site/{site}/zone/{zone}/node/{node_id}/health
213
+ /// synapse/site/{site}/zone/{zone}/node/{node_id}/config
214
+ ///
215
+ /// Only available with std feature (String requires alloc+std for formatting).
216
+ /// The MCU doesn't build MQTT topics — the gateway does.
217
+ #[cfg(feature = "std")]
218
+ pub mod topics {
219
+ use alloc::format;
220
+ use alloc::string::String;
221
+
222
+ pub fn reading(site: &str, zone: &str, node_id: u16) -> String {
223
+ format!("synapse/site/{site}/zone/{zone}/node/{node_id}/reading")
224
+ }
225
+
226
+ pub fn health(site: &str, zone: &str, node_id: u16) -> String {
227
+ format!("synapse/site/{site}/zone/{zone}/node/{node_id}/health")
228
+ }
229
+
230
+ pub fn config(site: &str, zone: &str, node_id: u16) -> String {
231
+ format!("synapse/site/{site}/zone/{zone}/node/{node_id}/config")
232
+ }
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Conversion helpers for display layers (gateway, host, browser)
237
+ // ---------------------------------------------------------------------------
238
+
239
+ #[cfg(feature = "std")]
240
+ impl Reading {
241
+ /// Convert fixed-point calibrated_value to f64 for display
242
+ pub fn calibrated_f64(&self) -> f64 {
243
+ self.calibrated_value as f64 / 1000.0
244
+ }
245
+
246
+ /// Human-readable unit string for dashboards
247
+ pub fn unit_str(&self) -> &'static str {
248
+ match self.unit {
249
+ MeasurementUnit::Ph => "pH",
250
+ MeasurementUnit::Ec => "µS/cm",
251
+ MeasurementUnit::DissolvedOxygen => "mg/L",
252
+ MeasurementUnit::Orp => "mV",
253
+ MeasurementUnit::TemperatureWater => "°C",
254
+ MeasurementUnit::MoistureVwc => "%",
255
+ MeasurementUnit::TemperatureSoil => "°C",
256
+ MeasurementUnit::TemperatureAir => "°C",
257
+ MeasurementUnit::Humidity => "%",
258
+ MeasurementUnit::Pressure => "hPa",
259
+ MeasurementUnit::LightLux => "lux",
260
+ MeasurementUnit::LightPar => "µmol/m²/s",
261
+ MeasurementUnit::Voltage => "mV",
262
+ MeasurementUnit::Current => "mA",
263
+ MeasurementUnit::Power => "mW",
264
+ MeasurementUnit::BatterySoc => "%",
265
+ }
266
+ }
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // Tests — these run native on Houston, validating logic before WASM compile
271
+ // ---------------------------------------------------------------------------
272
+
273
+ #[cfg(test)]
274
+ mod tests {
275
+ use super::*;
276
+
277
+ #[test]
278
+ fn identity_calibration_passes_through() {
279
+ let cal = Calibration::identity();
280
+ assert_eq!(cal.apply(1234), 1234);
281
+ assert_eq!(cal.apply(-500), -500);
282
+ assert_eq!(cal.apply(0), 0);
283
+ }
284
+
285
+ #[test]
286
+ fn two_point_calibration_ph() {
287
+ // pH 7.0 probe reads raw 1650, pH 4.0 reads raw 2200
288
+ let cal = Calibration::from_two_point(1650, 7000, 2200, 4000)
289
+ .expect("should not be None");
290
+
291
+ let ph7 = cal.apply(1650);
292
+ let ph4 = cal.apply(2200);
293
+
294
+ // Allow ±10 (0.01 pH) for integer rounding
295
+ assert!((ph7 - 7000).abs() < 10, "pH 7 cal: got {ph7}, expected ~7000");
296
+ assert!((ph4 - 4000).abs() < 10, "pH 4 cal: got {ph4}, expected ~4000");
297
+ }
298
+
299
+ #[test]
300
+ fn two_point_rejects_identical_raw() {
301
+ assert!(Calibration::from_two_point(100, 1000, 100, 2000).is_none());
302
+ }
303
+
304
+ #[test]
305
+ fn cbor_roundtrip_reading() {
306
+ let reading = Reading {
307
+ timestamp_ms: 1712345678000,
308
+ channel: 0,
309
+ raw_value: 1650,
310
+ calibrated_value: 7023,
311
+ unit: MeasurementUnit::Ph,
312
+ quality: ReadingQuality::Good,
313
+ };
314
+
315
+ // Encode to CBOR
316
+ let mut buf = alloc::vec![0u8; 0];
317
+ minicbor::encode(&reading, &mut buf).expect("encode failed");
318
+
319
+ // Verify it's compact enough for LoRa
320
+ // A single reading should be well under 30 bytes
321
+ assert!(buf.len() < 30, "reading CBOR too large: {} bytes", buf.len());
322
+
323
+ // Decode and verify roundtrip
324
+ let decoded: Reading = minicbor::decode(&buf).expect("decode failed");
325
+ assert_eq!(reading, decoded);
326
+ }
327
+
328
+ #[test]
329
+ fn cbor_roundtrip_payload() {
330
+ let payload = TransmissionPayload {
331
+ node_id: 1,
332
+ sequence: 42,
333
+ battery_mv: 3700,
334
+ readings: alloc::vec![
335
+ Reading {
336
+ timestamp_ms: 1712345678000,
337
+ channel: 0,
338
+ raw_value: 1650,
339
+ calibrated_value: 7023,
340
+ unit: MeasurementUnit::Ph,
341
+ quality: ReadingQuality::Good,
342
+ },
343
+ Reading {
344
+ timestamp_ms: 1712345678000,
345
+ channel: 1,
346
+ raw_value: 890,
347
+ calibrated_value: 1250,
348
+ unit: MeasurementUnit::Ec,
349
+ quality: ReadingQuality::Good,
350
+ },
351
+ ],
352
+ };
353
+
354
+ let mut buf = alloc::vec![0u8; 0];
355
+ minicbor::encode(&payload, &mut buf).expect("encode failed");
356
+
357
+ // Two-reading payload should fit comfortably in LoRa
358
+ assert!(buf.len() < 100, "payload CBOR too large: {} bytes", buf.len());
359
+
360
+ let decoded: TransmissionPayload = minicbor::decode(&buf).expect("decode failed");
361
+ assert_eq!(payload.node_id, decoded.node_id);
362
+ assert_eq!(payload.readings.len(), decoded.readings.len());
363
+ }
364
+
365
+ #[test]
366
+ fn channel_bitmask_logic() {
367
+ let config = SensorConfig {
368
+ sample_interval_secs: 30,
369
+ active_channels: 0b00000101, // channels 0 and 2 active
370
+ calibrations: alloc::vec![
371
+ Calibration::identity(), // ch 0
372
+ Calibration::identity(), // ch 1 (inactive but cal exists)
373
+ ],
374
+ };
375
+
376
+ assert!(config.is_channel_active(0));
377
+ assert!(!config.is_channel_active(1));
378
+ assert!(config.is_channel_active(2));
379
+ assert!(!config.is_channel_active(7));
380
+ assert!(!config.is_channel_active(8)); // out of range
381
+
382
+ // Channel 2 has no cal entry — should fall back to identity
383
+ let cal2 = config.cal_for(2);
384
+ assert_eq!(cal2.slope, 1000);
385
+ assert_eq!(cal2.offset, 0);
386
+ }
387
+ }
crates/synapse-sensor/Cargo.toml ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [package]
2
+ name = "synapse-sensor"
3
+ description = "WASM sensor module — runs on MCU (wasm3) and gateway (wasmtime)"
4
+ version.workspace = true
5
+ edition.workspace = true
6
+ license.workspace = true
7
+
8
+ [lib]
9
+ # cdylib produces a .wasm file when targeting wasm32
10
+ # rlib allows other Rust crates to depend on this for testing
11
+ crate-type = ["cdylib", "rlib"]
12
+
13
+ [dependencies]
14
+ synapse-core = { path = "../synapse-core", default-features = false }
15
+ minicbor = { workspace = true }
16
+ heapless = { workspace = true }
17
+ dlmalloc = { version = "0.2", features = ["global"] }
18
+
19
+ # wit-bindgen generates guest-side bindings from the WIT definition
20
+ # Only needed when building as a component (gateway/host targets)
21
+ # For the MCU target (core wasm via wasm3), we use raw exports instead
22
+ # wit-bindgen = { version = "0.36", optional = true }
23
+
24
+ [features]
25
+ default = []
26
+ # Enable for component model builds (gateway/host)
27
+ # Disable for core module builds (MCU via wasm3)
28
+ # component = ["wit-bindgen"]
crates/synapse-sensor/src/lib.rs ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Synapse Agriculture — Sensor WASM Module
2
+ //
3
+ // This is the "application" that runs inside the wasm3 runtime on the MCU.
4
+ // The wasm3 runtime is the "kernel" flashed onto the RP2350.
5
+ // This module is deployed separately via LoRa OTA from the gateway.
6
+ //
7
+ // Architecture:
8
+ // RP2350 boots → wasm3 runtime starts → loads this .wasm from flash
9
+ // → calls guest_init() → enters main loop calling guest_sample()
10
+ // → module calls host functions (read_i2c, transmit, sleep) via imports
11
+ //
12
+ // The host functions are implemented in C/Rust on the RP2350 bare metal
13
+ // firmware. They talk to real hardware. This module never touches hardware
14
+ // directly — it only sees the sandbox the host exposes.
15
+
16
+ #![no_std]
17
+
18
+ extern crate alloc;
19
+
20
+ // Required for no_std WASM: global allocator and panic handler.
21
+ // On the real MCU, you'd use a fixed-size bump allocator (e.g., embedded-alloc).
22
+ // For now, dlmalloc works for WASM targets and is what wasm-pack uses.
23
+ use alloc::vec::Vec;
24
+
25
+ #[cfg(target_arch = "wasm32")]
26
+ #[global_allocator]
27
+ static ALLOC: dlmalloc::GlobalDlmalloc = dlmalloc::GlobalDlmalloc;
28
+
29
+ #[cfg(target_arch = "wasm32")]
30
+ #[panic_handler]
31
+ fn panic(_info: &core::panic::PanicInfo) -> ! {
32
+ core::arch::wasm32::unreachable()
33
+ }
34
+ use synapse_core::{
35
+ Calibration, MeasurementUnit, Reading, ReadingQuality,
36
+ SensorConfig, TransmissionPayload,
37
+ };
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Host function imports — these are provided by the wasm3 runtime
41
+ // ---------------------------------------------------------------------------
42
+ // For the MCU target, these are raw extern "C" imports that wasm3 links
43
+ // at module load time. The RP2350 firmware registers these functions
44
+ // with the wasm3 runtime before instantiating the module.
45
+ //
46
+ // For the component model target (gateway/host), these would come from
47
+ // wit-bindgen instead. That's a future enhancement — for now, we target
48
+ // core wasm only, which is what wasm3 supports.
49
+
50
+ extern "C" {
51
+ /// Read bytes from an I2C device.
52
+ /// Returns number of bytes actually read, or negative on error.
53
+ /// Data is written to the `buf` pointer (must be in WASM linear memory).
54
+ fn host_read_i2c(address: u8, register: u8, buf: *mut u8, buf_len: u8) -> i32;
55
+
56
+ /// Read a raw 12-bit ADC value from the specified channel.
57
+ /// Returns the value (0-4095) or negative on error.
58
+ fn host_read_adc(channel: u8) -> i32;
59
+
60
+ /// Get current timestamp in milliseconds from RTC or host clock.
61
+ fn host_get_timestamp_ms() -> u64;
62
+
63
+ /// Transmit a LoRa packet. Data is read from the `buf` pointer.
64
+ /// Returns number of bytes queued, or negative on error.
65
+ fn host_transmit(buf: *const u8, buf_len: u32) -> i32;
66
+
67
+ /// Sleep for the specified number of milliseconds.
68
+ /// The WASM module yields execution; host enters low-power mode.
69
+ fn host_sleep_ms(duration_ms: u32);
70
+
71
+ /// Log a message (for debugging — may be compiled out on MCU).
72
+ fn host_log(level: u8, msg: *const u8, msg_len: u32);
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Safe wrappers around host imports
77
+ // ---------------------------------------------------------------------------
78
+
79
+ fn read_i2c(address: u8, register: u8, buf: &mut [u8]) -> Result<usize, ReadingQuality> {
80
+ let result = unsafe { host_read_i2c(address, register, buf.as_mut_ptr(), buf.len() as u8) };
81
+ if result < 0 {
82
+ Err(ReadingQuality::Fault)
83
+ } else {
84
+ Ok(result as usize)
85
+ }
86
+ }
87
+
88
+ fn read_adc(channel: u8) -> Result<u16, ReadingQuality> {
89
+ let result = unsafe { host_read_adc(channel) };
90
+ if result < 0 {
91
+ Err(ReadingQuality::Fault)
92
+ } else {
93
+ Ok(result as u16)
94
+ }
95
+ }
96
+
97
+ fn timestamp_ms() -> u64 {
98
+ unsafe { host_get_timestamp_ms() }
99
+ }
100
+
101
+ fn transmit(data: &[u8]) -> Result<usize, ReadingQuality> {
102
+ let result = unsafe { host_transmit(data.as_ptr(), data.len() as u32) };
103
+ if result < 0 {
104
+ Err(ReadingQuality::Fault)
105
+ } else {
106
+ Ok(result as usize)
107
+ }
108
+ }
109
+
110
+ fn sleep(ms: u32) {
111
+ unsafe { host_sleep_ms(ms) }
112
+ }
113
+
114
+ fn log_info(msg: &str) {
115
+ unsafe { host_log(1, msg.as_ptr(), msg.len() as u32) }
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Module state — lives in WASM linear memory, persists across calls
120
+ // ---------------------------------------------------------------------------
121
+
122
+ /// Global module state. Initialized in guest_init, used in guest_sample.
123
+ /// This is safe because WASM is single-threaded — no concurrency concerns.
124
+ static mut STATE: Option<ModuleState> = None;
125
+
126
+ struct ModuleState {
127
+ config: SensorConfig,
128
+ sequence: u16,
129
+ node_id: u16,
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Atlas Scientific EZO protocol helpers
134
+ // ---------------------------------------------------------------------------
135
+ // Atlas Scientific EZO-series probes (pH, EC, DO, ORP) use a simple
136
+ // I2C protocol: write a command byte, wait, read the response.
137
+ // Response format: [status_byte, ascii_data...]
138
+ // Status: 1 = success, 2 = failed, 254 = pending, 255 = no data
139
+
140
+ const ATLAS_CMD_READ: u8 = b'R';
141
+ const ATLAS_STATUS_SUCCESS: u8 = 1;
142
+
143
+ /// Read a value from an Atlas Scientific EZO probe.
144
+ /// Returns the reading as fixed-point * 1000, or an error quality.
145
+ fn read_atlas_ezo(i2c_address: u8) -> Result<i32, ReadingQuality> {
146
+ // Send read command
147
+ let cmd = [ATLAS_CMD_READ];
148
+ let result = unsafe {
149
+ host_read_i2c(i2c_address, cmd[0], core::ptr::null_mut(), 0)
150
+ };
151
+ if result < 0 {
152
+ return Err(ReadingQuality::Fault);
153
+ }
154
+
155
+ // Wait for measurement (Atlas EZO needs ~900ms for pH)
156
+ sleep(1000);
157
+
158
+ // Read response (up to 16 bytes: status + ASCII float)
159
+ let mut buf = [0u8; 16];
160
+ let bytes_read = read_i2c(i2c_address, 0, &mut buf)?;
161
+
162
+ if bytes_read < 2 || buf[0] != ATLAS_STATUS_SUCCESS {
163
+ return Err(ReadingQuality::Fault);
164
+ }
165
+
166
+ // Parse ASCII float response to fixed-point * 1000
167
+ // Atlas returns something like "7.23\0" as ASCII bytes
168
+ parse_ascii_fixed_point(&buf[1..bytes_read])
169
+ .ok_or(ReadingQuality::Degraded)
170
+ }
171
+
172
+ /// Parse ASCII decimal string (e.g., "7.23") to fixed-point * 1000 (7230).
173
+ /// No floating point used — pure integer parsing for MCU efficiency.
174
+ fn parse_ascii_fixed_point(bytes: &[u8]) -> Option<i32> {
175
+ let mut result: i32 = 0;
176
+ let mut decimal_places: i32 = -1; // -1 = haven't seen decimal point yet
177
+ let mut negative = false;
178
+
179
+ for &b in bytes {
180
+ match b {
181
+ b'-' if result == 0 => negative = true,
182
+ b'0'..=b'9' => {
183
+ result = result * 10 + (b - b'0') as i32;
184
+ if decimal_places >= 0 {
185
+ decimal_places += 1;
186
+ }
187
+ }
188
+ b'.' => {
189
+ if decimal_places >= 0 {
190
+ return None; // second decimal point
191
+ }
192
+ decimal_places = 0;
193
+ }
194
+ 0 | b'\r' | b'\n' => break, // null terminator or newline
195
+ _ => return None, // unexpected character
196
+ }
197
+ }
198
+
199
+ // Scale to * 1000 fixed-point
200
+ let scale = match decimal_places {
201
+ -1 | 0 => 1000, // no decimal or "7." → 7000
202
+ 1 => 100, // "7.2" → 7200
203
+ 2 => 10, // "7.23" → 7230
204
+ 3 => 1, // "7.230" → 7230
205
+ _ => return None, // more than 3 decimal places, truncate would lose data
206
+ };
207
+
208
+ result *= scale;
209
+ if negative {
210
+ result = -result;
211
+ }
212
+ Some(result)
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Guest exports — called by the wasm3 host runtime
217
+ // ---------------------------------------------------------------------------
218
+
219
+ /// Called once at boot. Receives serialized config via CBOR.
220
+ /// Returns 1 on success, 0 on failure.
221
+ #[no_mangle]
222
+ pub extern "C" fn guest_init(config_ptr: *const u8, config_len: u32, node_id: u16) -> u32 {
223
+ let config_bytes = unsafe {
224
+ core::slice::from_raw_parts(config_ptr, config_len as usize)
225
+ };
226
+
227
+ match minicbor::decode::<SensorConfig>(config_bytes) {
228
+ Ok(config) => {
229
+ log_info("sensor module initialized");
230
+ unsafe {
231
+ STATE = Some(ModuleState {
232
+ config,
233
+ sequence: 0,
234
+ node_id,
235
+ });
236
+ }
237
+ 1
238
+ }
239
+ Err(_) => {
240
+ log_info("failed to parse config");
241
+ 0
242
+ }
243
+ }
244
+ }
245
+
246
+ /// Called each sample cycle. Reads all active sensors, builds payload,
247
+ /// serializes to CBOR, and transmits via LoRa.
248
+ /// Returns number of bytes transmitted, or 0 on failure.
249
+ #[no_mangle]
250
+ pub extern "C" fn guest_sample() -> u32 {
251
+ let state = unsafe {
252
+ match STATE.as_mut() {
253
+ Some(s) => s,
254
+ None => return 0,
255
+ }
256
+ };
257
+
258
+ let now = timestamp_ms();
259
+ let mut readings = Vec::new();
260
+
261
+ // Read each active channel
262
+ for ch in 0..8u8 {
263
+ if !state.config.is_channel_active(ch) {
264
+ continue;
265
+ }
266
+
267
+ let cal = state.config.cal_for(ch);
268
+
269
+ // For this reference implementation, channels 0-3 are Atlas EZO I2C
270
+ // with addresses 0x63 (pH), 0x64 (EC), 0x61 (DO), 0x62 (ORP).
271
+ // Channels 4-7 are ADC inputs for analog sensors.
272
+ // Real deployments would have this mapping in the config.
273
+ let (raw, unit, quality) = match ch {
274
+ 0 => match read_atlas_ezo(0x63) {
275
+ Ok(v) => (v, MeasurementUnit::Ph, ReadingQuality::Good),
276
+ Err(q) => (0, MeasurementUnit::Ph, q),
277
+ },
278
+ 1 => match read_atlas_ezo(0x64) {
279
+ Ok(v) => (v, MeasurementUnit::Ec, ReadingQuality::Good),
280
+ Err(q) => (0, MeasurementUnit::Ec, q),
281
+ },
282
+ 2 => match read_atlas_ezo(0x61) {
283
+ Ok(v) => (v, MeasurementUnit::DissolvedOxygen, ReadingQuality::Good),
284
+ Err(q) => (0, MeasurementUnit::DissolvedOxygen, q),
285
+ },
286
+ 3 => match read_atlas_ezo(0x62) {
287
+ Ok(v) => (v, MeasurementUnit::Orp, ReadingQuality::Good),
288
+ Err(q) => (0, MeasurementUnit::Orp, q),
289
+ },
290
+ 4..=7 => match read_adc(ch - 4) {
291
+ Ok(v) => (v as i32, MeasurementUnit::MoistureVwc, ReadingQuality::Good),
292
+ Err(q) => (0, MeasurementUnit::MoistureVwc, q),
293
+ },
294
+ _ => continue,
295
+ };
296
+
297
+ readings.push(Reading {
298
+ timestamp_ms: now,
299
+ channel: ch,
300
+ raw_value: raw,
301
+ calibrated_value: cal.apply(raw),
302
+ unit,
303
+ quality,
304
+ });
305
+ }
306
+
307
+ // Build transmission payload
308
+ let payload = TransmissionPayload {
309
+ node_id: state.node_id,
310
+ sequence: state.sequence,
311
+ battery_mv: read_adc(7).unwrap_or(0), // ADC ch7 = battery voltage divider
312
+ readings,
313
+ };
314
+
315
+ // Increment sequence (wraps at u16::MAX)
316
+ state.sequence = state.sequence.wrapping_add(1);
317
+
318
+ // Serialize to CBOR and transmit
319
+ let mut buf = Vec::new();
320
+ match minicbor::encode(&payload, &mut buf) {
321
+ Ok(()) => {
322
+ match transmit(&buf) {
323
+ Ok(n) => n as u32,
324
+ Err(_) => {
325
+ log_info("transmit failed");
326
+ 0
327
+ }
328
+ }
329
+ }
330
+ Err(_) => {
331
+ log_info("cbor encode failed");
332
+ 0
333
+ }
334
+ }
335
+ }
336
+
337
+ /// Receive new config from gateway. Returns 1 on success, 0 on failure.
338
+ #[no_mangle]
339
+ pub extern "C" fn guest_reconfigure(config_ptr: *const u8, config_len: u32) -> u32 {
340
+ let config_bytes = unsafe {
341
+ core::slice::from_raw_parts(config_ptr, config_len as usize)
342
+ };
343
+
344
+ match minicbor::decode::<SensorConfig>(config_bytes) {
345
+ Ok(config) => {
346
+ if let Some(state) = unsafe { STATE.as_mut() } {
347
+ state.config = config;
348
+ log_info("reconfigured");
349
+ 1
350
+ } else {
351
+ 0
352
+ }
353
+ }
354
+ Err(_) => 0,
355
+ }
356
+ }
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // Tests — run natively on Houston, not under WASM
360
+ // ---------------------------------------------------------------------------
361
+
362
+ #[cfg(test)]
363
+ mod tests {
364
+ use super::*;
365
+
366
+ #[test]
367
+ fn parse_ascii_ph() {
368
+ assert_eq!(parse_ascii_fixed_point(b"7.23"), Some(7230));
369
+ assert_eq!(parse_ascii_fixed_point(b"4.0"), Some(4000));
370
+ assert_eq!(parse_ascii_fixed_point(b"14"), Some(14000));
371
+ assert_eq!(parse_ascii_fixed_point(b"0.5"), Some(500));
372
+ assert_eq!(parse_ascii_fixed_point(b"-1.5"), Some(-1500));
373
+ }
374
+
375
+ #[test]
376
+ fn parse_ascii_null_terminated() {
377
+ assert_eq!(parse_ascii_fixed_point(b"7.23\0\0\0"), Some(7230));
378
+ assert_eq!(parse_ascii_fixed_point(b"4.01\r\n"), Some(4010));
379
+ }
380
+
381
+ #[test]
382
+ fn parse_ascii_rejects_garbage() {
383
+ assert_eq!(parse_ascii_fixed_point(b"abc"), None);
384
+ assert_eq!(parse_ascii_fixed_point(b"7.2.3"), None);
385
+ }
386
+ }
crates/synapse-web/Cargo.toml ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [package]
2
+ name = "synapse-web"
3
+ description = "Build a Farm — browser WASM frontend"
4
+ version.workspace = true
5
+ edition.workspace = true
6
+ license.workspace = true
7
+
8
+ [dependencies]
9
+ synapse-core = { path = "../synapse-core" } # std features = default
10
+ minicbor = { workspace = true }
11
+ # Leptos for reactive WASM UI — uncomment when ready to build frontend
12
+ # leptos = { version = "0.7", features = ["csr"] }
13
+ # leptos_meta = { version = "0.7" }
14
+ # leptos_router = { version = "0.7" }
crates/synapse-web/src/lib.rs ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Synapse Agriculture — Build a Farm Web Frontend
2
+ //
3
+ // This crate compiles to WASM and runs in the browser.
4
+ // It shares synapse-core types with the sensor modules,
5
+ // meaning the same Reading/TransmissionPayload types that
6
+ // the MCU produces are what the dashboard renders.
7
+ //
8
+ // For now this is a stub proving the shared type import works.
9
+ // Leptos UI comes next once the core+sensor crates stabilize.
10
+
11
+ use synapse_core::{
12
+ MeasurementUnit, Reading, ReadingQuality, TransmissionPayload,
13
+ };
14
+
15
+ /// Demonstrate that synapse-core types work in std/browser context.
16
+ /// This will become the data layer for the Leptos dashboard.
17
+ pub fn decode_payload(cbor_bytes: &[u8]) -> Result<TransmissionPayload, String> {
18
+ minicbor::decode(cbor_bytes).map_err(|e| format!("CBOR decode error: {e}"))
19
+ }
20
+
21
+ /// Format a reading for dashboard display.
22
+ /// Uses the std-only helpers from synapse-core (calibrated_f64, unit_str).
23
+ pub fn format_reading(reading: &Reading) -> String {
24
+ let quality_indicator = match reading.quality {
25
+ ReadingQuality::Good => "",
26
+ ReadingQuality::Degraded => " ⚠",
27
+ ReadingQuality::CalNeeded => " 🔧",
28
+ ReadingQuality::Fault => " ❌",
29
+ };
30
+
31
+ format!(
32
+ "{:.2} {}{}",
33
+ reading.calibrated_f64(),
34
+ reading.unit_str(),
35
+ quality_indicator
36
+ )
37
+ }
38
+
39
+ #[cfg(test)]
40
+ mod tests {
41
+ use super::*;
42
+ use synapse_core::Calibration;
43
+
44
+ #[test]
45
+ fn format_ph_reading() {
46
+ let r = Reading {
47
+ timestamp_ms: 1712345678000,
48
+ channel: 0,
49
+ raw_value: 1650,
50
+ calibrated_value: 7230,
51
+ unit: MeasurementUnit::Ph,
52
+ quality: ReadingQuality::Good,
53
+ };
54
+ let s = format_reading(&r);
55
+ assert!(s.contains("7.23"), "expected '7.23' in '{s}'");
56
+ assert!(s.contains("pH"), "expected 'pH' in '{s}'");
57
+ }
58
+
59
+ #[test]
60
+ fn format_fault_reading() {
61
+ let r = Reading {
62
+ timestamp_ms: 0,
63
+ channel: 0,
64
+ raw_value: 0,
65
+ calibrated_value: 0,
66
+ unit: MeasurementUnit::DissolvedOxygen,
67
+ quality: ReadingQuality::Fault,
68
+ };
69
+ let s = format_reading(&r);
70
+ assert!(s.contains("❌"), "expected fault indicator in '{s}'");
71
+ }
72
+
73
+ #[test]
74
+ fn cbor_roundtrip_from_simulated_node() {
75
+ // Simulate what a sensor node would transmit
76
+ let payload = TransmissionPayload {
77
+ node_id: 1,
78
+ sequence: 0,
79
+ battery_mv: 3700,
80
+ readings: vec![Reading {
81
+ timestamp_ms: 1712345678000,
82
+ channel: 0,
83
+ raw_value: 1650,
84
+ calibrated_value: 7230,
85
+ unit: MeasurementUnit::Ph,
86
+ quality: ReadingQuality::Good,
87
+ }],
88
+ };
89
+
90
+ // Encode (as the MCU would)
91
+ let mut buf = Vec::new();
92
+ minicbor::encode(&payload, &mut buf).unwrap();
93
+
94
+ // Decode (as the browser would)
95
+ let decoded = decode_payload(&buf).unwrap();
96
+ assert_eq!(decoded.node_id, 1);
97
+
98
+ let formatted = format_reading(&decoded.readings[0]);
99
+ assert!(formatted.contains("7.23"));
100
+ }
101
+ }
flake.nix ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ description = "Synapse Agriculture — WASM-native farm stack development environment";
3
+
4
+ inputs = {
5
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
6
+ rust-overlay.url = "github:oxalica/rust-overlay";
7
+ flake-utils.url = "github:numtide/flake-utils";
8
+ };
9
+
10
+ outputs = { self, nixpkgs, rust-overlay, flake-utils }:
11
+ flake-utils.lib.eachDefaultSystem (system:
12
+ let
13
+ overlays = [ (import rust-overlay) ];
14
+ pkgs = import nixpkgs { inherit system overlays; };
15
+
16
+ # Rust stable with WASM targets.
17
+ # Two targets, two purposes:
18
+ # wasm32-unknown-unknown → bare WASM for MCU (wasm3) and browser
19
+ # wasm32-wasip1 → WASI preview 1 for gateway/host (wasmtime)
20
+ rustToolchain = pkgs.rust-bin.stable.latest.default.override {
21
+ extensions = [ "rust-src" "rust-analyzer" ];
22
+ targets = [
23
+ "wasm32-unknown-unknown"
24
+ "wasm32-wasip1"
25
+ ];
26
+ };
27
+
28
+ in
29
+ {
30
+ devShells.default = pkgs.mkShell {
31
+ buildInputs = with pkgs; [
32
+ # ── Rust compilation ──────────────────────────────────────
33
+ rustToolchain
34
+
35
+ # ── WASM runtimes ─────────────────────────────────────────
36
+ wasmtime # Full WASI + Component Model runtime
37
+ # Tests gateway/host modules with all capabilities
38
+
39
+ # ── WASM binary tools ─────────────────────────────────────
40
+ binaryen # wasm-opt: aggressive size optimization
41
+ # wasm-opt -Oz -o small.wasm big.wasm
42
+ # Critical for MCU — can cut binaries 40-60%
43
+
44
+ wabt # wasm2wat / wat2wasm: bytecode inspection
45
+ # When something breaks, read the WAT
46
+
47
+ wasm-tools # Bytecode Alliance multi-tool:
48
+ # validate, component lower, strip, compose
49
+ # component lower = component → core wasm for MCU
50
+
51
+ # ── Web frontend ──────────────────────────────────────────
52
+ trunk # Dev server for Leptos/Yew WASM apps
53
+ # Handles wasm-bindgen, assets, hot reload
54
+ wasm-pack # WASM npm package builder (JS interop)
55
+ wasm-bindgen-cli
56
+
57
+ # ── Size profiling ────────────────────────────────────────
58
+ twiggy # WASM code size profiler
59
+ # twiggy top module.wasm — largest functions
60
+ # twiggy dominators module.wasm — dep graph
61
+ # RP2350 budget: ~400KB for module + heap
62
+
63
+ # ── Build deps ────────────────────────────────────────────
64
+ cmake # For building wasm3 from source
65
+ pkg-config
66
+ openssl
67
+
68
+ # ── Dev workflow ──────────────────────────────────────────
69
+ cargo-watch # cargo watch -x test — auto-test on save
70
+ ];
71
+
72
+ shellHook = ''
73
+ echo ""
74
+ echo " ┌──────────────────────────────────────────────┐"
75
+ echo " │ Synapse Agriculture — WASM Dev Environment │"
76
+ echo " └──────────────────────────────────────────────┘"
77
+ echo ""
78
+ echo " Rust: $(rustc --version 2>/dev/null || echo 'loading...')"
79
+ echo " wasmtime: $(wasmtime --version 2>/dev/null || echo 'loading...')"
80
+ echo " wasm-opt: $(wasm-opt --version 2>/dev/null || echo 'loading...')"
81
+ echo ""
82
+ echo " Build targets:"
83
+ echo " wasm32-unknown-unknown → MCU (wasm3) + browser"
84
+ echo " wasm32-wasip1 → gateway/host (wasmtime)"
85
+ echo ""
86
+ echo " Quick start:"
87
+ echo " cargo test all native tests"
88
+ echo " cargo build -p synapse-sensor --target wasm32-unknown-unknown --release"
89
+ echo " wasm-opt -Oz -o opt.wasm target/.../synapse_sensor.wasm"
90
+ echo " twiggy top opt.wasm size check"
91
+ echo ""
92
+ '';
93
+ };
94
+ }
95
+ );
96
+ }
run.sh ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ export PATH="/run/current-system/sw/bin:/root/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH"
3
+ set -euo pipefail
4
+ cd /opt/synapse-wasm
5
+
6
+ echo "=== ENV ==="
7
+ rustc --version
8
+ cargo --version
9
+
10
+ echo ""
11
+ echo "=== STEP 1: NATIVE TESTS ==="
12
+ cargo test --workspace 2>&1
13
+
14
+ echo ""
15
+ echo "=== STEP 2: WASM TARGET CHECK ==="
16
+ if rustc --print target-list | grep -q wasm32-unknown-unknown; then
17
+ echo "wasm32 target available — building..."
18
+ cargo build --package synapse-sensor --target wasm32-unknown-unknown --release 2>&1
19
+ WASM="target/wasm32-unknown-unknown/release/synapse_sensor.wasm"
20
+ RAW=$(wc -c < "$WASM")
21
+ echo "Raw WASM: $RAW bytes ($((RAW/1024))KB)"
22
+
23
+ # Try wasm-opt if available
24
+ if command -v wasm-opt &>/dev/null; then
25
+ mkdir -p target/wasm-opt
26
+ wasm-opt -Oz --strip-debug --strip-producers -o target/wasm-opt/synapse_sensor.wasm "$WASM"
27
+ OPT=$(wc -c < target/wasm-opt/synapse_sensor.wasm)
28
+ echo "Optimized: $OPT bytes ($((OPT/1024))KB) — $(( (RAW - OPT) * 100 / RAW ))% reduction"
29
+ fi
30
+
31
+ if command -v twiggy &>/dev/null; then
32
+ echo ""
33
+ echo "=== TWIGGY TOP 10 ==="
34
+ twiggy top target/wasm-opt/synapse_sensor.wasm -n 10 2>&1 || true
35
+ fi
36
+
37
+ if command -v wasm-tools &>/dev/null; then
38
+ echo ""
39
+ echo "=== VALIDATE ==="
40
+ wasm-tools validate target/wasm-opt/synapse_sensor.wasm 2>&1 && echo "VALID" || echo "INVALID"
41
+ fi
42
+
43
+ if command -v wasm2wat &>/dev/null; then
44
+ echo ""
45
+ echo "=== EXPORTS ==="
46
+ wasm2wat "$WASM" 2>/dev/null | grep 'export' || echo "(none)"
47
+ echo ""
48
+ echo "=== IMPORTS ==="
49
+ wasm2wat "$WASM" 2>/dev/null | grep 'import' || echo "(none)"
50
+ fi
51
+ else
52
+ echo "wasm32 target NOT in system rustc — need NixOS config update"
53
+ fi
54
+
55
+ echo ""
56
+ echo "=== COMPLETE ==="
shell.nix ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ { pkgs ? import <nixpkgs> {} }:
2
+ pkgs.mkShell {
3
+ buildInputs = with pkgs; [
4
+ rustc cargo rustfmt clippy
5
+ gcc
6
+ pkg-config openssl
7
+ wasmtime binaryen wabt wasm-tools
8
+ twiggy trunk wasm-pack wasm-bindgen-cli cargo-watch
9
+ ];
10
+ shellHook = "export CC=gcc";
11
+ }
wit/sensor.wit ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Synapse Agriculture — Sensor Module Interface
2
+ // This WIT definition is the IMMORTAL CONTRACT between:
3
+ // - Guest: sensor WASM modules (runs on MCU/gateway/host)
4
+ // - Host: wasm3 (MCU), wasmtime (gateway/host), browser VM
5
+ //
6
+ // Adding a new sensor type = add to this file, regen bindings,
7
+ // type-check catches every integration point at compile time.
8
+ //
9
+ // Changing this file is a BREAKING CHANGE across the entire stack.
10
+ // Treat it like a database migration — versioned, reviewed, irreversible.
11
+
12
+ package synapse:sensor@0.1.0;
13
+
14
+ /// Types shared across all sensor modules and host runtimes.
15
+ /// These compile into synapse-core and are used everywhere.
16
+ interface types {
17
+ /// Sensor reading with metadata for provenance tracking
18
+ record reading {
19
+ /// Unix timestamp in milliseconds (from host clock or RTC)
20
+ timestamp-ms: u64,
21
+ /// Sensor channel identifier (maps to physical probe)
22
+ channel: u8,
23
+ /// Raw ADC or digital value before calibration
24
+ raw-value: s32,
25
+ /// Calibrated value as fixed-point (value * 1000)
26
+ /// Using s32 instead of f32 because wasm3 soft-float
27
+ /// on Cortex-M is slow and we don't need the precision
28
+ calibrated-value: s32,
29
+ /// Unit of measurement after calibration
30
+ unit: measurement-unit,
31
+ /// Quality/confidence flag from self-diagnostics
32
+ quality: reading-quality,
33
+ }
34
+
35
+ /// Fixed-point calibration coefficients for linear cal:
36
+ /// calibrated = (raw * slope / 1000) + (offset / 1000)
37
+ /// Two-point cal: derive slope/offset from known standards
38
+ record calibration {
39
+ slope: s32, // multiplied by 1000
40
+ offset: s32, // multiplied by 1000
41
+ }
42
+
43
+ /// What physical quantity this reading represents
44
+ enum measurement-unit {
45
+ /// Water chemistry
46
+ ph, // pH units (0-14)
47
+ ec, // electrical conductivity, µS/cm
48
+ dissolved-oxygen, // mg/L
49
+ orp, // mV
50
+ temperature-water, // °C * 1000
51
+
52
+ /// Soil
53
+ moisture-vwc, // volumetric water content, % * 1000
54
+ temperature-soil, // °C * 1000
55
+
56
+ /// Atmosphere
57
+ temperature-air, // °C * 1000
58
+ humidity, // % * 1000
59
+ pressure, // hPa * 1000
60
+ light-lux, // lux
61
+ light-par, // µmol/m²/s (photosynthetically active)
62
+
63
+ /// Power (Layer 7)
64
+ voltage, // mV
65
+ current, // mA
66
+ power, // mW
67
+ battery-soc, // state of charge, % * 10
68
+ }
69
+
70
+ /// Self-diagnostic quality assessment
71
+ enum reading-quality {
72
+ good,
73
+ degraded, // reading taken but outside expected range
74
+ cal-needed, // calibration overdue or drift detected
75
+ fault, // sensor not responding or shorted
76
+ }
77
+
78
+ /// Configuration pushed from gateway to node
79
+ record sensor-config {
80
+ /// Sampling interval in seconds
81
+ sample-interval-secs: u32,
82
+ /// Which channels to read (bitmask, up to 8 channels)
83
+ active-channels: u8,
84
+ /// Per-channel calibration (indexed by channel number)
85
+ calibrations: list<calibration>,
86
+ }
87
+
88
+ /// Compact transmission payload for LoRa
89
+ /// Designed to fit in a single LoRa packet (<= 242 bytes at SF7)
90
+ record transmission-payload {
91
+ /// Node identifier (unique per site)
92
+ node-id: u16,
93
+ /// Sequence number for dedup and gap detection
94
+ sequence: u16,
95
+ /// Battery voltage in mV (for power monitoring)
96
+ battery-mv: u16,
97
+ /// All readings from this sample cycle
98
+ readings: list<reading>,
99
+ }
100
+ }
101
+
102
+ /// Host functions provided TO the sensor module BY the runtime.
103
+ /// The MCU firmware implements these against real hardware.
104
+ /// The test harness implements them as mocks.
105
+ /// The browser implements them as no-ops or simulations.
106
+ interface host {
107
+ use types.{reading, calibration};
108
+
109
+ /// Read raw value from I2C sensor
110
+ /// address: 7-bit I2C device address (e.g., 0x63 for Atlas pH)
111
+ /// register: register to read from
112
+ /// length: bytes to read (max 32)
113
+ /// Returns: raw bytes from device, or error
114
+ read-i2c: func(address: u8, register: u8, length: u8) -> result<list<u8>, sensor-error>;
115
+
116
+ /// Read ADC channel (for analog sensors)
117
+ /// channel: ADC channel number (0-7 on RP2350)
118
+ /// Returns: raw 12-bit ADC value (0-4095)
119
+ read-adc: func(channel: u8) -> result<u16, sensor-error>;
120
+
121
+ /// Get current timestamp from RTC or host clock
122
+ get-timestamp-ms: func() -> u64;
123
+
124
+ /// Queue a LoRa transmission
125
+ /// payload: CBOR-encoded bytes to transmit
126
+ /// Returns: number of bytes queued, or error
127
+ transmit: func(payload: list<u8>) -> result<u32, sensor-error>;
128
+
129
+ /// Enter low-power sleep for specified duration
130
+ /// The WASM module yields execution here; host handles
131
+ /// actual MCU sleep modes (DORMANT on RP2350)
132
+ sleep-ms: func(duration-ms: u32);
133
+
134
+ /// Log a diagnostic message (forwarded to gateway if possible)
135
+ /// Compiled out / no-op on MCU builds via feature flag
136
+ log: func(level: log-level, message: string);
137
+
138
+ enum sensor-error {
139
+ /// Device not responding on bus
140
+ not-found,
141
+ /// Bus arbitration failure
142
+ bus-error,
143
+ /// Device returned NAK
144
+ nak,
145
+ /// Read timed out
146
+ timeout,
147
+ /// Transmission queue full
148
+ queue-full,
149
+ /// Generic / unclassified
150
+ other,
151
+ }
152
+
153
+ enum log-level {
154
+ debug,
155
+ info,
156
+ warn,
157
+ error,
158
+ }
159
+ }
160
+
161
+ /// The interface that every sensor module MUST implement.
162
+ /// This is the guest-side contract — the "main" of the module.
163
+ interface guest {
164
+ use types.{reading, sensor-config, transmission-payload};
165
+
166
+ /// Called once at boot. Host passes stored config.
167
+ /// Module initializes internal state, validates config.
168
+ /// Returns: true if init succeeded, false to signal fault.
169
+ init: func(config: sensor-config) -> bool;
170
+
171
+ /// Called each sample cycle by the host's main loop.
172
+ /// Module reads sensors (via host.read-i2c / host.read-adc),
173
+ /// applies calibration, builds readings list.
174
+ /// Returns: payload ready for LoRa transmission.
175
+ sample: func() -> transmission-payload;
176
+
177
+ /// Called when gateway pushes new config (e.g., new cal values).
178
+ /// Module validates and applies, returns success/failure.
179
+ reconfigure: func(config: sensor-config) -> bool;
180
+
181
+ /// Self-diagnostic. Module checks sensor responsiveness,
182
+ /// validates readings against expected ranges, reports health.
183
+ /// Returns: list of (channel, quality) pairs.
184
+ diagnose: func() -> list<tuple<u8, reading-quality>>;
185
+
186
+ use host.{sensor-error};
187
+
188
+ /// Redeclare quality enum access for diagnose return
189
+ use types.{reading-quality};
190
+ }
191
+
192
+ /// The complete world — wires guest to host.
193
+ /// cargo-component uses this to generate the full bindings.
194
+ world sensor-node {
195
+ import host;
196
+ export guest;
197
+ }