Robbo commited on
Commit ·
7932636
unverified ·
0
Parent(s):
Initial: WASM sensor stack — core types, MCU module (32KB), WIT contract
Browse files- .gitignore +3 -0
- Cargo.lock +153 -0
- Cargo.toml +32 -0
- build.sh +176 -0
- crates/synapse-core/Cargo.toml +16 -0
- crates/synapse-core/src/lib.rs +387 -0
- crates/synapse-sensor/Cargo.toml +28 -0
- crates/synapse-sensor/src/lib.rs +386 -0
- crates/synapse-web/Cargo.toml +14 -0
- crates/synapse-web/src/lib.rs +101 -0
- flake.nix +96 -0
- run.sh +56 -0
- shell.nix +11 -0
- wit/sensor.wit +197 -0
.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 |
+
}
|