Files
firmware/src/platform/portduino/wasm

ARCH_PORTDUINO_WASM - meshtasticd in WebAssembly (LoRa over WebUSB)

Builds the full portduino firmware (setup()/loop()) to WebAssembly with Emscripten, so a real Meshtastic node runs in a browser tab (or headless Node) and drives a LoRa radio over WebUSB through a CH341 USB-to-SPI bridge - the same Ch341Hal path the desktop meshtasticd uses, with the libusb backend swapped for a WebUSB one. The desktop/native portduino build is untouched.

Layout (this dir is excluded from the native PlatformIO build_src_filter)

file role
portduino_glue_wasm.cpp LoRa config (MeshToad default + wasm_set_lora_* setters, no YAML), VFS mount, region/MAC helpers, and the wasm_api_* PhoneAPI bridge
portduino_main_wasm.cpp wasm_setup() / wasm_loop_once() - JS drives the cooperative loop
libpinedio_webusb.c WebUSB libpinedio backend (sync C ↔ async WebUSB via Asyncify EM_ASYNC_JS)
include/libpinedio-usb.h the 12-fn libpinedio API the backend implements
stubs/ argp.h shim + jsoncpp serializer stub (MQTT-only, excluded)
js/ the WebUSB runtime: bridge.js (implements the C backend's imports), ch341.js (CH341 transport), protocol.js (framing)

In-tree, six firmware sources carry small #ifdef ARCH_PORTDUINO_WASM guards (single-threaded cooperative sleep, continuous RX, region default, RNG, etc.): src/main.cpp, src/mesh/{NodeDB,SX126xInterface,InterfacesTemplates,LR11x0Interface,HardwareRNG}.cpp, src/platform/portduino/PortduinoGlue.cpp. None affect non-wasm builds.

Build

This is a normal PlatformIO env ([env:native-wasm]) built with the meshtastic/platform-wasm platform (emcc/em++), exactly like any other board target.

Prereq: an Emscripten SDK on PATH - source <emsdk>/emsdk_env.sh (or export EMSDK=<path>) so the platform builder can locate emcc.

pio run -e native-wasm            # emcc compile + Asyncify link
pio run -e native-wasm -t clean   # wipe the build dir

Output: .pio/build/native-wasm/meshnode.mjs + meshnode.wasm (ES module, Asyncify, factory createMeshNode, exports _wasm_setup, _wasm_loop_once, _wasm_fs_sync, _wasm_set_region, _wasm_api_to_radio, _wasm_api_from_radio, _wasm_api_available, _wasm_api_is_connected, the _wasm_set_lora_* setters).

Run

The C backend imports webusb_* functions; js/bridge.js implements them on top of js/ch341.js. Minimal host flow:

import createMeshNode from "./meshnode.mjs";
import { CH341 } from "./js/ch341.js";
import { createCH341Bridge } from "./js/bridge.js";

const dev = (await CH341.request()).device; // WebUSB device picker (Chromium)
const Module = await createMeshNode({ noInitialRun: true });
Module.ch341 = createCH341Bridge(Module, dev); // wire WebUSB before boot
await Module.ccall("wasm_setup", null, [], [], { async: true });
const pump = async () => {
  await Module.ccall("wasm_loop_once", "number", [], [], { async: true });
  setTimeout(pump, 5);
};
pump();

API control: feed a ToRadio protobuf with wasm_api_to_radio(ptr,len) and drain FromRadio with wasm_api_from_radio(out,max) - the firmware's own PhoneAPI, unframed. The official @meshtastic/core SDK drives it through a ~40-line in-process transport (see the meshtasticd-wasm-node repo, which hosts the dev server, the SDK-UI page, the headless node-usb runner, and the TCP :4403 bridge for the Python CLI). WebUSB is Chromium-only.

Reboot: the firmware can't restart itself in wasm, so a reboot (admin/phone command, factory reset, or the 60 s stuck-TX watchdog) hands off to the host. In a browser it calls location.reload() - NodeDB state survives via IDBFS, so the node comes back with the same identity. Headless, provide a Module.onReboot callback to handle it (re-instantiate the module, process.exit() for a supervisor to restart, etc.); without one it just logs and keeps running.

const Module = await createMeshNode({ noInitialRun: true });
Module.onReboot = () => process.exit(0); // optional; headless restart policy