mirror of
https://github.com/ruvnet/RuView.git
synced 2026-04-28 05:59:32 +00:00
- ADR-042: Coherent Human Channel Imaging (non-CSI sensing protocol) with DDD domain model (6 bounded contexts) - 24 new WASM edge modules: medical (5), retail (5), security (5), building (5), industrial (5), exotic (8) - README: plain-language rewrites, moved detail sections below TOC, added edge module links to use case tables, firmware release docs - User guide: firmware release table, edge intelligence documentation - .gitignore: added rules for wasm, esp32 temp files, NVS binaries - WASM edge crate: cargo config, integration tests, module registry Co-Authored-By: claude-flow <ruv@ruv.net>
569 lines
19 KiB
Python
569 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""ESP32 WASM Module On-Device Test Suite
|
|
|
|
Uploads WASM edge modules to the ESP32-S3 and captures execution proof.
|
|
Tests representative modules from each category against the 4 WASM slots.
|
|
|
|
Usage:
|
|
python scripts/esp32_wasm_test.py --host 192.168.1.71 --port 8032
|
|
python scripts/esp32_wasm_test.py --discover # scan subnet for ESP32
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import struct
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
import urllib.error
|
|
import socket
|
|
import datetime
|
|
|
|
|
|
# ─── WASM Module Generators ─────────────────────────────────────────────────
|
|
#
|
|
# Each generator produces a valid MVP WASM binary that:
|
|
# 1. Imports from "csi" namespace (matching firmware)
|
|
# 2. Exports on_frame() → i32 (required entry point)
|
|
# 3. Uses ≤2 memory pages (128 KB)
|
|
# 4. Contains no bulk-memory ops (MVP only)
|
|
# 5. Emits events via csi_emit_event(event_id, value)
|
|
#
|
|
# The modules are tiny (200-800 bytes) but exercise real host API calls
|
|
# and produce measurable event output.
|
|
|
|
def leb128_u(val):
|
|
"""Encode unsigned LEB128."""
|
|
out = bytearray()
|
|
while True:
|
|
b = val & 0x7F
|
|
val >>= 7
|
|
if val:
|
|
out.append(b | 0x80)
|
|
else:
|
|
out.append(b)
|
|
break
|
|
return bytes(out)
|
|
|
|
|
|
def leb128_s(val):
|
|
"""Encode signed LEB128."""
|
|
out = bytearray()
|
|
while True:
|
|
b = val & 0x7F
|
|
val >>= 7
|
|
if (val == 0 and not (b & 0x40)) or (val == -1 and (b & 0x40)):
|
|
out.append(b)
|
|
break
|
|
else:
|
|
out.append(b | 0x80)
|
|
return bytes(out)
|
|
|
|
|
|
def section(section_id, data):
|
|
"""Wrap data in a WASM section."""
|
|
return bytes([section_id]) + leb128_u(len(data)) + data
|
|
|
|
|
|
def vec(items):
|
|
"""WASM vector: count + items."""
|
|
return leb128_u(len(items)) + b"".join(items)
|
|
|
|
|
|
def func_type(params, results):
|
|
"""Encode a func type (0x60 params results)."""
|
|
return b"\x60" + vec([bytes([p]) for p in params]) + vec([bytes([r]) for r in results])
|
|
|
|
|
|
def import_entry(module, name, kind_byte, type_idx):
|
|
"""Encode an import entry."""
|
|
mod_enc = leb128_u(len(module)) + module.encode()
|
|
name_enc = leb128_u(len(name)) + name.encode()
|
|
return mod_enc + name_enc + bytes([0x00]) + leb128_u(type_idx) # kind=func
|
|
|
|
|
|
def export_entry(name, kind, idx):
|
|
"""Encode an export entry."""
|
|
return leb128_u(len(name)) + name.encode() + bytes([kind]) + leb128_u(idx)
|
|
|
|
|
|
I32 = 0x7F
|
|
F32 = 0x7D
|
|
|
|
# Opcodes
|
|
OP_LOCAL_GET = 0x20
|
|
OP_I32_CONST = 0x41
|
|
OP_F32_CONST = 0x43
|
|
OP_CALL = 0x10
|
|
OP_DROP = 0x1A
|
|
OP_END = 0x0B
|
|
|
|
|
|
def f32_bytes(val):
|
|
"""Encode f32 constant."""
|
|
return struct.pack("<f", val)
|
|
|
|
|
|
def build_module(name, event_id, event_value, imports_needed=None):
|
|
"""Build a minimal WASM module that calls csi_emit_event on each frame.
|
|
|
|
The on_frame function:
|
|
1. Calls csi_emit_event(event_id, event_value)
|
|
2. Returns 1 (success)
|
|
|
|
Args:
|
|
name: Module name for logging
|
|
event_id: Event ID to emit (i32)
|
|
event_value: Event value to emit (f32)
|
|
imports_needed: List of (name, param_types, result_types) for extra imports
|
|
"""
|
|
if imports_needed is None:
|
|
imports_needed = []
|
|
|
|
# Type section: define function signatures
|
|
types = []
|
|
|
|
# Type 0: (i32, f32) -> void [csi_emit_event]
|
|
types.append(func_type([I32, F32], []))
|
|
|
|
# Type 1: () -> i32 [on_frame export]
|
|
types.append(func_type([], [I32]))
|
|
|
|
# Type 2+: additional import types
|
|
extra_type_map = {}
|
|
for imp_name, params, results in imports_needed:
|
|
sig = (tuple(params), tuple(results))
|
|
if sig not in extra_type_map:
|
|
extra_type_map[sig] = len(types)
|
|
types.append(func_type(params, results))
|
|
|
|
type_sec = section(1, vec(types))
|
|
|
|
# Import section
|
|
imports = []
|
|
# Import 0: csi_emit_event (type 0)
|
|
imports.append(import_entry("csi", "csi_emit_event", 0, 0))
|
|
|
|
import_idx = 1
|
|
extra_import_indices = {}
|
|
for imp_name, params, results in imports_needed:
|
|
sig = (tuple(params), tuple(results))
|
|
tidx = extra_type_map[sig]
|
|
imports.append(import_entry("csi", imp_name, 0, tidx))
|
|
extra_import_indices[imp_name] = import_idx
|
|
import_idx += 1
|
|
|
|
import_sec = section(2, vec(imports))
|
|
|
|
# Function section: 1 local function (on_frame)
|
|
func_sec = section(3, vec([leb128_u(1)])) # type index 1
|
|
|
|
# Memory section: 1 page (64KB), max 2 pages
|
|
mem_sec = section(5, b"\x01" + b"\x01\x01\x02") # 1 memory, limits: min=1, max=2
|
|
|
|
# Export section: export on_frame as "on_frame" (func, idx = import_count)
|
|
on_frame_idx = len(imports) # local func index offset by imports
|
|
exports = [export_entry("on_frame", 0, on_frame_idx)]
|
|
# Also export memory
|
|
exports.append(export_entry("memory", 2, 0))
|
|
export_sec = section(7, vec(exports))
|
|
|
|
# Code section: on_frame body
|
|
# Calls csi_emit_event(event_id, event_value), returns 1
|
|
body = bytearray()
|
|
body.append(0x00) # 0 local declarations
|
|
|
|
# Call csi_emit_event(event_id, event_value)
|
|
body.append(OP_I32_CONST)
|
|
body.extend(leb128_s(event_id))
|
|
body.append(OP_F32_CONST)
|
|
body.extend(f32_bytes(event_value))
|
|
body.append(OP_CALL)
|
|
body.extend(leb128_u(0)) # call import 0 (csi_emit_event)
|
|
|
|
# Return 1
|
|
body.append(OP_I32_CONST)
|
|
body.extend(leb128_s(1))
|
|
body.append(OP_END)
|
|
|
|
body_with_size = leb128_u(len(body)) + bytes(body)
|
|
code_sec = section(10, vec([body_with_size]))
|
|
|
|
# Assemble
|
|
wasm = b"\x00asm" + struct.pack("<I", 1) # magic + version
|
|
wasm += type_sec + import_sec + func_sec + mem_sec + export_sec + code_sec
|
|
|
|
return wasm
|
|
|
|
|
|
# ─── Category Module Definitions ────────────────────────────────────────────
|
|
|
|
CATEGORY_MODULES = [
|
|
{
|
|
"name": "core_gesture",
|
|
"category": "Core",
|
|
"event_id": 1,
|
|
"event_value": 0.85,
|
|
"description": "Gesture detection event (coherence=0.85)",
|
|
},
|
|
{
|
|
"name": "med_fall_detect",
|
|
"category": "Medical & Health",
|
|
"event_id": 100,
|
|
"event_value": 0.92,
|
|
"description": "Fall detection alert (confidence=0.92)",
|
|
},
|
|
{
|
|
"name": "sec_intrusion",
|
|
"category": "Security & Safety",
|
|
"event_id": 200,
|
|
"event_value": 0.78,
|
|
"description": "Intrusion detection (score=0.78)",
|
|
},
|
|
{
|
|
"name": "bld_zone_occupied",
|
|
"category": "Smart Building",
|
|
"event_id": 300,
|
|
"event_value": 3.0,
|
|
"description": "Zone occupancy (3 persons detected)",
|
|
},
|
|
{
|
|
"name": "ret_queue_len",
|
|
"category": "Retail & Hospitality",
|
|
"event_id": 400,
|
|
"event_value": 5.0,
|
|
"description": "Queue length estimate (5 people)",
|
|
},
|
|
{
|
|
"name": "ind_proximity",
|
|
"category": "Industrial",
|
|
"event_id": 500,
|
|
"event_value": 1.5,
|
|
"description": "Proximity warning (1.5m distance)",
|
|
},
|
|
{
|
|
"name": "exo_sleep_stage",
|
|
"category": "Exotic & Research",
|
|
"event_id": 600,
|
|
"event_value": 2.0,
|
|
"description": "Sleep stage detection (stage 2 = light sleep)",
|
|
},
|
|
{
|
|
"name": "sig_coherence",
|
|
"category": "Signal Intelligence",
|
|
"event_id": 700,
|
|
"event_value": 0.91,
|
|
"description": "Coherence gate score (0.91)",
|
|
},
|
|
{
|
|
"name": "lrn_gesture_learned",
|
|
"category": "Adaptive Learning",
|
|
"event_id": 730,
|
|
"event_value": 0.88,
|
|
"description": "Gesture learned (DTW score=0.88)",
|
|
},
|
|
{
|
|
"name": "spt_influence",
|
|
"category": "Spatial & Temporal",
|
|
"event_id": 760,
|
|
"event_value": 0.72,
|
|
"description": "PageRank influence score (0.72)",
|
|
},
|
|
{
|
|
"name": "ais_replay_attack",
|
|
"category": "AI Security",
|
|
"event_id": 820,
|
|
"event_value": 0.95,
|
|
"description": "Replay attack detected (confidence=0.95)",
|
|
},
|
|
{
|
|
"name": "qnt_entanglement",
|
|
"category": "Quantum & Autonomous",
|
|
"event_id": 850,
|
|
"event_value": 0.67,
|
|
"description": "Quantum entanglement coherence (0.67)",
|
|
},
|
|
]
|
|
|
|
|
|
# ─── ESP32 Communication ────────────────────────────────────────────────────
|
|
|
|
def discover_esp32(subnet="192.168.1", port=8032, start=1, end=80):
|
|
"""Scan subnet for ESP32 WASM runtime."""
|
|
print(f"Scanning {subnet}.{start}-{end} for WASM runtime on port {port}...")
|
|
for i in range(start, end + 1):
|
|
ip = f"{subnet}.{i}"
|
|
try:
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
sock.settimeout(0.3)
|
|
if sock.connect_ex((ip, port)) == 0:
|
|
sock.close()
|
|
url = f"http://{ip}:{port}/wasm/status"
|
|
try:
|
|
resp = urllib.request.urlopen(url, timeout=2)
|
|
data = json.loads(resp.read())
|
|
if "slots" in data:
|
|
print(f" Found ESP32 at {ip}:{port} — {len(data['slots'])} WASM slots")
|
|
return ip
|
|
except Exception:
|
|
pass
|
|
else:
|
|
sock.close()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def get_status(host, port):
|
|
"""Get WASM runtime status from ESP32."""
|
|
url = f"http://{host}:{port}/wasm/status"
|
|
resp = urllib.request.urlopen(url, timeout=5)
|
|
return json.loads(resp.read())
|
|
|
|
|
|
def upload_module(host, port, slot, wasm_bytes, name="test"):
|
|
"""Upload a WASM module to a specific slot."""
|
|
url = f"http://{host}:{port}/wasm/upload?slot={slot}"
|
|
req = urllib.request.Request(
|
|
url,
|
|
data=wasm_bytes,
|
|
headers={"Content-Type": "application/wasm"},
|
|
method="POST",
|
|
)
|
|
try:
|
|
resp = urllib.request.urlopen(req, timeout=10)
|
|
return json.loads(resp.read())
|
|
except urllib.error.HTTPError as e:
|
|
body = e.read().decode(errors="replace")
|
|
return {"error": f"HTTP {e.code}: {body}"}
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
|
|
def get_slot_status(host, port, slot):
|
|
"""Get status for a specific WASM slot."""
|
|
status = get_status(host, port)
|
|
if "slots" in status and slot < len(status["slots"]):
|
|
return status["slots"][slot]
|
|
return None
|
|
|
|
|
|
def reset_slot(host, port, slot):
|
|
"""Try to reset/unload a WASM slot."""
|
|
url = f"http://{host}:{port}/wasm/{slot}"
|
|
req = urllib.request.Request(url, method="DELETE")
|
|
try:
|
|
resp = urllib.request.urlopen(req, timeout=5)
|
|
return json.loads(resp.read())
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
# ─── Test Runner ─────────────────────────────────────────────────────────────
|
|
|
|
def run_test_suite(host, port, wasm_binary_path=None):
|
|
"""Run the full on-device test suite.
|
|
|
|
Tests 12 category modules across 4 WASM slots (3 rounds of 4).
|
|
Captures event counts and timing as proof of execution.
|
|
"""
|
|
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
results = []
|
|
|
|
print("=" * 70)
|
|
print(f" ESP32 WASM On-Device Test Suite — {timestamp}")
|
|
print("=" * 70)
|
|
print()
|
|
|
|
# 1. Get initial status
|
|
try:
|
|
status = get_status(host, port)
|
|
except Exception as e:
|
|
print(f"ERROR: Cannot reach ESP32 at {host}:{port}: {e}")
|
|
return []
|
|
|
|
n_slots = len(status.get("slots", []))
|
|
print(f"ESP32 WASM Runtime: {n_slots} slots available")
|
|
print(f"Host: {host}:{port}")
|
|
print()
|
|
|
|
# 2. Test full Rust library if path provided
|
|
if wasm_binary_path:
|
|
print("─── Phase 1: Full Rust WASM Library Upload ───")
|
|
try:
|
|
with open(wasm_binary_path, "rb") as f:
|
|
wasm_data = f.read()
|
|
print(f" Binary: {len(wasm_data)} bytes")
|
|
result = upload_module(host, port, 0, wasm_data, "edge_library")
|
|
print(f" Upload result: {json.dumps(result)}")
|
|
if result.get("started"):
|
|
time.sleep(2)
|
|
slot = get_slot_status(host, port, 0)
|
|
if slot:
|
|
print(f" Running: {slot.get('frames', 0)} frames, "
|
|
f"{slot.get('events', 0)} events, "
|
|
f"mean {slot.get('mean_us', 0)}us")
|
|
results.append({
|
|
"name": "edge_library_full",
|
|
"category": "Full Library",
|
|
"size": len(wasm_data),
|
|
"upload": result,
|
|
"slot_status": slot,
|
|
"pass": result.get("started", False),
|
|
})
|
|
else:
|
|
results.append({
|
|
"name": "edge_library_full",
|
|
"category": "Full Library",
|
|
"size": len(wasm_data),
|
|
"upload": result,
|
|
"pass": False,
|
|
})
|
|
except Exception as e:
|
|
print(f" Error: {e}")
|
|
results.append({
|
|
"name": "edge_library_full",
|
|
"category": "Full Library",
|
|
"error": str(e),
|
|
"pass": False,
|
|
})
|
|
print()
|
|
|
|
# 3. Test per-category synthetic modules (4 at a time across slots)
|
|
print("─── Phase 2: Per-Category Module Tests ───")
|
|
print()
|
|
|
|
modules = CATEGORY_MODULES
|
|
batch_size = min(n_slots, 4)
|
|
|
|
for batch_start in range(0, len(modules), batch_size):
|
|
batch = modules[batch_start:batch_start + batch_size]
|
|
print(f" Batch {batch_start // batch_size + 1}: "
|
|
f"{', '.join(m['name'] for m in batch)}")
|
|
|
|
# Upload batch
|
|
for i, mod in enumerate(batch):
|
|
slot = i % n_slots
|
|
wasm = build_module(mod["name"], mod["event_id"], mod["event_value"])
|
|
print(f" [{slot}] {mod['name']} ({len(wasm)} bytes) — {mod['description']}")
|
|
|
|
result = upload_module(host, port, slot, wasm, mod["name"])
|
|
if "error" in result:
|
|
print(f" FAIL: {result['error']}")
|
|
results.append({**mod, "size": len(wasm), "upload": result, "pass": False})
|
|
continue
|
|
|
|
print(f" Upload: {json.dumps(result, separators=(',', ':'))}")
|
|
results.append({
|
|
**mod, "size": len(wasm), "upload": result,
|
|
"pass": result.get("started", False),
|
|
})
|
|
|
|
# Let modules run for 3 seconds to accumulate frames/events
|
|
print(f" Waiting 3s for frame processing...")
|
|
time.sleep(3)
|
|
|
|
# Capture slot status as proof
|
|
status = get_status(host, port)
|
|
for i, mod in enumerate(batch):
|
|
slot = i % n_slots
|
|
if slot < len(status.get("slots", [])):
|
|
ss = status["slots"][slot]
|
|
frames = ss.get("frames", 0)
|
|
events = ss.get("events", 0)
|
|
errors = ss.get("errors", 0)
|
|
mean_us = ss.get("mean_us", 0)
|
|
max_us = ss.get("max_us", 0)
|
|
|
|
# Find the result and update it
|
|
for r in results:
|
|
if r.get("name") == mod["name"] and "slot_proof" not in r:
|
|
r["slot_proof"] = {
|
|
"frames": frames,
|
|
"events": events,
|
|
"errors": errors,
|
|
"mean_us": mean_us,
|
|
"max_us": max_us,
|
|
}
|
|
passed = frames > 0 and events > 0 and errors == 0
|
|
r["pass"] = r["pass"] and passed
|
|
status_str = "PASS" if passed else "FAIL"
|
|
print(f" [{slot}] {mod['name']}: {frames} frames, "
|
|
f"{events} events, {errors} errors, "
|
|
f"mean {mean_us}us, max {max_us}us — {status_str}")
|
|
break
|
|
|
|
print()
|
|
|
|
# 4. Summary
|
|
print("=" * 70)
|
|
print(" TEST SUMMARY")
|
|
print("=" * 70)
|
|
passed = sum(1 for r in results if r.get("pass"))
|
|
failed = sum(1 for r in results if not r.get("pass"))
|
|
print(f" Passed: {passed}/{len(results)}")
|
|
print(f" Failed: {failed}/{len(results)}")
|
|
print()
|
|
|
|
for r in results:
|
|
status_str = "PASS" if r.get("pass") else "FAIL"
|
|
proof = r.get("slot_proof", {})
|
|
frames = proof.get("frames", "?")
|
|
events = proof.get("events", "?")
|
|
mean_us = proof.get("mean_us", "?")
|
|
print(f" [{status_str}] {r.get('category', '?'):24s} {r.get('name', '?'):24s} "
|
|
f"frames={frames} events={events} latency={mean_us}us")
|
|
|
|
print()
|
|
print(f" Timestamp: {timestamp}")
|
|
print(f" ESP32: {host}:{port}")
|
|
print()
|
|
|
|
# 5. Save proof JSON
|
|
proof_path = f"docs/edge-modules/esp32_test_proof_{timestamp}.json"
|
|
try:
|
|
proof_data = {
|
|
"timestamp": timestamp,
|
|
"host": f"{host}:{port}",
|
|
"results": results,
|
|
"summary": {
|
|
"total": len(results),
|
|
"passed": passed,
|
|
"failed": failed,
|
|
},
|
|
}
|
|
import os
|
|
os.makedirs(os.path.dirname(proof_path), exist_ok=True)
|
|
with open(proof_path, "w") as f:
|
|
json.dump(proof_data, f, indent=2)
|
|
print(f" Proof saved to: {proof_path}")
|
|
except Exception as e:
|
|
print(f" Warning: Could not save proof file: {e}")
|
|
|
|
return results
|
|
|
|
|
|
# ─── Main ───────────────────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="ESP32 WASM On-Device Test Suite")
|
|
parser.add_argument("--host", default="192.168.1.71", help="ESP32 IP address")
|
|
parser.add_argument("--port", type=int, default=8032, help="WASM HTTP port")
|
|
parser.add_argument("--discover", action="store_true", help="Scan subnet for ESP32")
|
|
parser.add_argument("--wasm", help="Path to full Rust WASM binary to test")
|
|
parser.add_argument("--subnet", default="192.168.1", help="Subnet to scan")
|
|
args = parser.parse_args()
|
|
|
|
if args.discover:
|
|
host = discover_esp32(args.subnet, args.port)
|
|
if not host:
|
|
print("No ESP32 found. Check that device is powered and connected to WiFi.")
|
|
sys.exit(1)
|
|
args.host = host
|
|
|
|
results = run_test_suite(args.host, args.port, args.wasm)
|
|
sys.exit(0 if all(r.get("pass") for r in results) else 1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|