#!/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(" 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(" 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()