Portduino WASM hello world (still experimental)#10793
Conversation
Compile the full portduino firmware to WebAssembly (Emscripten) so a real node runs in a browser tab or headless Node, driving a LoRa radio over WebUSB through a CH341 — the desktop Ch341Hal path with its libusb backend swapped for a WebUSB one. The native/desktop portduino build is unchanged.
New build env under src/platform/portduino/wasm/ (excluded from the native build_src_filter): WebUSB libpinedio backend, config/FS/region/MAC/PhoneAPI glue, wasm setup/loop, JS WebUSB runtime, and build stubs. bin/build-portduino-wasm.sh runs a standalone cached emcc build to build/wasm/meshnode.{mjs,wasm}.
Six firmware sources gain #ifdef ARCH_PORTDUINO_WASM guards (single-threaded cooperative emscripten_sleep loop, continuous RX, US region default, std RNG, no-popen exec); none affect non-wasm builds.
…apter
bin/build-portduino-wasm.sh: auto-detect native-macos (macOS) vs native (Linux/CI) libdeps with a NATIVE_ENV override, and use the SAME env's Crypto with an XEdDSA-present guard. Drops the heltec-v3/Crypto borrow — the meshtastic/Crypto pin already ships XEdDSA; the native libdeps cache was just stale. No env pin change.
Add .github/workflows/build_portduino_wasm.yml: build the ARCH_PORTDUINO_WASM target in CI (ubuntu + emsdk, native libdeps) so it can't silently bit-rot; asserts build/wasm/meshnode.{mjs,wasm}.
src/platform/portduino/wasm: add wasm_set_lora_* setters so the JS host can configure any CH341 LoRa adapter (module, USB ids, DIO/TCXO, SPI speed, pins); wasm_config_apply falls back to the MeshToad defaults when unset.
…c script)
Replace the standalone emcc build (bin/build-portduino-wasm.sh) with a normal
PlatformIO env, `pio run -e wasm`, using the new meshtastic/platform-wasm
platform (emcc/em++ + Asyncify, the WASM sibling of platform-native). The
portduino WebAssembly node is now built the same way as every other target.
- variants/native/portduino/platformio.ini: add [env:wasm] (platform pinned to
platform-wasm, board wasm). Translates the script's source set into a curated
build_src_filter, the ~30 EXCLUDE_* defines, and the lib set; defines
ARCH_PORTDUINO_WASM in-repo so correctness doesn't hinge on the platform's
board.json.
- extra_scripts/wasm_link_flags.py: the firmware-specific emcc *link* settings
(EXPORT_NAME, EXPORTED_RUNTIME_METHODS, EXPORTED_FUNCTIONS, ASYNCIFY_IMPORTS).
PlatformIO feeds build_flags to compile only, so these must ride LINKFLAGS;
without it the WebUSB Asyncify seam and the JS host's runtime methods are
dropped (the _wasm_* exports survive only via EMSCRIPTEN_KEEPALIVE).
- PortduinoGlue.{h,cpp}: guard the yaml-cpp dependency out of the WASM build
(#ifndef ARCH_PORTDUINO_WASM around the include, emit_yaml/loadConfig/
readGPIOFromYaml). The browser node configures via the wasm_set_lora_* setters
and dead-strips the YAML path; this drops the host yaml-cpp build dependency
entirely. Native is unchanged (guards are inert there).
- portduino_glue_wasm.cpp / portduino_main_wasm.cpp: repair EM_ASM JS that a
formatter had mangled (!== -> != =, regex split) in the prior landing; the
emcc link succeeds regardless, so CI now runs `node --check meshnode.mjs`.
- .github/workflows/build_portduino_wasm.yml: build via `pio run -e wasm`
(artifacts under .pio/build/wasm/), trigger on the shared inputs the env
inherits (root platformio.ini, bin/platformio-*.py).
- NodeDB.cpp: drop the dead ARCH_PORTDUINO_WASM region-default branch (region
now defaults the same as native).
- Crypto renovate pins: add the missing gitBranch so they track upstream.
Output: .pio/build/wasm/meshnode.{mjs,wasm} (ES module, factory createMeshNode).
Verified: pio run -e wasm (against the published platform archive), node --check,
module instantiates in Node with all exports; native-macos + Docker native unit
tests (450/450) still pass.
platform-wasm's LICENSE was always GPLv3 (matching this firmware), but its platform.json/README still declared Apache-2.0 (mis-copied from platform-native). That's fixed upstream in b83fa5b; bump the [env:wasm] pin to it. Build output is unchanged (license metadata only). Verified: pio run -e wasm against b83fa5b.
In wasm the reboot path is live (main.cpp -> Power::powerCommandsCheck -> Power::reboot), but Power::reboot's ARCH_PORTDUINO arm tore down SPI/Wire/Serial and then called the no-op ::reboot() stub — leaving the node running with a dead radio until the tab was manually reloaded. Triggers include an admin/phone reboot, factory reset, the "reconfigure failed" path, and the 60 s stuck-TX hardware watchdog (RadioLibInterface). - Power::reboot(): add an ARCH_PORTDUINO_WASM arm (before ARCH_PORTDUINO, since the wasm build defines both) that skips the host teardown and just calls ::reboot(). notifyReboot already let modules persist. - ::reboot() (glue): hand off to the JS host — browser reloads the tab (NodeDB state survives via IDBFS, same identity returns); headless calls Module.onReboot if provided, else logs. Loose !=/== so clang-format doesn't mangle the EM_ASM JS. - README: document the reboot handoff + the Module.onReboot hook. Verified: pio run -e wasm + node --check (EM_ASM intact); native-macos unaffected.
Rename [env:wasm] -> [env:native-wasm] for consistency with the portduino
native family (native, native-macos, native-tft). The build dir follows to
.pio/build/native-wasm/ (artifact is still meshnode.{mjs,wasm}); the PIOENV
guard in extra_scripts/wasm_link_flags.py, the README, and the companion wrapper
move with it. The board stays `wasm`.
Also wire the build into normal CI: build_portduino_wasm.yml becomes a reusable
workflow (workflow_call) invoked as the `build-wasm` job of main_matrix.yml, so
the WebAssembly node is built like every other platform instead of on a separate
path trigger.
`pio run -e native-wasm` failed with "emcc not found" whenever it was invoked from a shell that hadn't sourced emsdk_env.sh — a VS Code task, an IDE build button, a bare terminal. Add a pre: extra script that probes the usual emsdk locations ($EMSDK_ENV, $EMSDK, ~/emsdk, ./.emsdk, the sibling companion checkout), sources emsdk_env.sh, and imports the resulting environment so the platform builder and emcc see PATH/EMSDK/EM_CONFIG. No-op when emcc is already reachable (CI), silent when no SDK is found (the platform emits its own error).
⚡ Try this PR in the Web FlasherWarning This is an automated, unreviewed CI test build. Back up your device configuration Supported boards built by this PR (25)
Build artifacts expire on 2026-07-27. Updated for |
There was a problem hiding this comment.
Pull request overview
This PR adds a new Portduino WebAssembly (Emscripten) build target (native-wasm) so the Meshtastic firmware can run in a browser/Node.js environment and drive a real LoRa radio over WebUSB (CH341), while keeping existing native Portduino builds unaffected via ARCH_PORTDUINO_WASM guards.
Changes:
- Introduces a new PlatformIO environment (
[env:native-wasm]) plus Emscripten pre/post scripts to reliably locate the SDK and apply firmware-specific link flags/exports. - Adds WASM-specific firmware adaptations (cooperative sleep, continuous RX, excluded subsystems, RNG tweaks, API server exclusions).
- Adds CI coverage for the WASM target via a reusable workflow that builds and uploads
meshnode.{mjs,wasm}artifacts.
Reviewed changes
Copilot reviewed 24 out of 26 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| variants/native/portduino/platformio.ini | Adds [env:native-wasm] PlatformIO environment and curated source/lib settings for Emscripten builds. |
| variants/native/portduino.ini | Excludes wasm-only sources from the native Portduino build filter and updates renovate metadata. |
| src/Power.cpp | Routes reboot behavior to host-controlled restart for WASM builds. |
| src/platform/portduino/wasm/stubs/serializer_stub.cpp | Adds MeshPacketSerializer stubs to avoid jsoncpp dependency in WASM build. |
| src/platform/portduino/wasm/stubs/argp.h | Provides a minimal <argp.h> stub for Emscripten builds. |
| src/platform/portduino/wasm/README.md | Documents WASM build/run flow and directory layout. |
| src/platform/portduino/wasm/portduino_main_wasm.cpp | Adds cooperative WASM entry points (wasm_setup, wasm_loop_once) driven by JS. |
| src/platform/portduino/wasm/portduino_glue_wasm.cpp | Implements WASM-side glue (VFS mount, MAC identity, region setter, PhoneAPI bridge, delay/yield, reboot handoff). |
| src/platform/portduino/wasm/libpinedio_webusb.c | Implements libpinedio API over WebUSB via Asyncify/EM_ASYNC_JS. |
| src/platform/portduino/wasm/js/protocol.js | Adds CH341 framing helpers shared by WASM bridge and JS transport. |
| src/platform/portduino/wasm/js/ch341.js | Implements a WebUSB CH341 transport for SPI/GPIO operations. |
| src/platform/portduino/wasm/js/bridge.js | Bridges WASM C imports to the JS CH341 transport, marshalling heap buffers. |
| src/platform/portduino/wasm/include/libpinedio-usb.h | Adds a WebUSB/wasm-compatible libpinedio header used by Ch341Hal. |
| src/platform/portduino/PortduinoGlue.h | Guards YAML-dependent declarations for WASM builds (no yaml-cpp). |
| src/platform/portduino/PortduinoGlue.cpp | Adds WASM-specific portduinoSetup path and stubs out YAML config and shell exec usage. |
| src/mesh/SX126xInterface.cpp | Uses continuous RX mode for SX126x under WASM to avoid WebUSB stalls. |
| src/mesh/LR11x0Interface.cpp | Adjusts LR11x0 RSSI API usage for WASM/RadioLib constraints. |
| src/mesh/InterfacesTemplates.cpp | Excludes the TCP ServerAPI include path for WASM builds. |
| src/mesh/HardwareRNG.cpp | Adds Emscripten RNG fallback commentary/behavior alignment. |
| src/main.cpp | Adds WASM-specific includes, excludes raspi HTTP server, and uses cooperative sleep in the main loop. |
| extra_scripts/wasm_link_flags.py | Injects firmware-specific Emscripten link flags/exports into the WASM env. |
| extra_scripts/wasm_emsdk_env.py | Attempts to auto-source emsdk environment for native-wasm builds when emcc isn’t on PATH. |
| bin/config.d/lora-piggystick-lr1121.yaml | Fixes/updates metadata/comment formatting for a LoRa USB config YAML. |
| .gitignore | Ignores legacy standalone WASM build outputs and optional in-repo emsdk checkout. |
| .github/workflows/main_matrix.yml | Adds a dedicated CI job to build the WASM target via a reusable workflow. |
| .github/workflows/build_portduino_wasm.yml | New reusable workflow to build and upload WASM artifacts and syntax-check the generated .mjs. |
- js/bridge.js: import CH341 from "./ch341.js" (sibling in this layout), not "../src/ch341.js" which doesn't resolve here. - js/ch341.js: a zero-length transferIn while MISO bytes are still outstanding now throws instead of breaking out with a partially-filled buffer — silent SPI corruption becomes a loud error, matching the comment above it. - libpinedio_webusb.c: webusb_set_auto_cs honors the AUTO_CS option (? 1 : 0) instead of the always-on ? 1 : 1. Runtime behavior is unchanged — Ch341Hal sets AUTO_CS=0 right after pinedio_init (RadioLib drives the active-low NSS); the option just isn't set yet at init, so this now correctly defaults off. - SX126xInterface.cpp: the RX-start error log now names the method actually called (startReceive vs startReceiveDutyCycleAuto) instead of hardcoding the duty-cycle name in the WASM branch.
The Emscripten SDK auto-location moved into the platform-wasm builder, so the firmware no longer needs its own pre: extra script. Remove extra_scripts/wasm_emsdk_env.py and bump the platform pin to the build that carries the bootstrap. The wasm_link_flags.py post script stays — those exported fns / runtime methods / Asyncify import seam are firmware-app-specific.
The companion repo was renamed meshtastic-web-node -> meshtasticd-wasm-node; fix the stale name in the wasm README and bump the platform pin to the build that promotes the canonical name in its emsdk auto-location.
…retry
Two robustness fixes for the browser node:
- Re-entrancy guard. The node is single-threaded + Asyncify: while setup()/loop()
is suspended inside a WebUSB transfer, the JS event loop is free, so a stray
DOM/timer callback that re-enters a wasm_* entry point starts a second Asyncify
unwind ("async operation already in flight" abort) or clobbers shared PhoneAPI
state (observed as a "PhoneAPI::available unexpected state" flood). Add a
g_wasm_in_firmware flag set around setup()/loop() (portduino_main_wasm.cpp); the
wasm_set_region / wasm_api_to_radio / wasm_api_from_radio / wasm_api_available
entry points now reject a mid-tick call (return busy) instead of corrupting or
aborting. The host must still call them between ticks — this is the safety net
the design lacked, not a substitute for the JS queue.
- CH341 open retry (js/bridge.js). First-connect WebUSB is flaky — the interface
is briefly unclaimable right after the grant, or held by a prior session,
giving a transient "Could not open SPI: -1". Retry the open with a short
backoff, closing the device between attempts so claimInterface starts clean.
The `check` board matrix runs `pio check` (cppcheck) over all of src/, including src/platform/portduino/wasm/. cppcheck can't parse the EM_ASYNC_JS/ EM_JS macros (Syntax Error: AST broken at libpinedio_webusb.c:39, internalAstError) and these sources are not part of any checked board build ([env:native-wasm] is board_level=extra, compiled by the build-wasm CI job). Suppress the wasm dir in suppressions.txt, the same way generated/ and .pio/ are already excluded.
IDBFS syncfs is async; the explicit wasm_fs_sync (5s timer + post-save + beforeunload) could overlap a prior in-flight sync, warning "2 FS.syncfs operations in flight at once". Serialize: if a sync is running, mark a pending re-sync and let the in-flight one chain it on completion — at most one in flight, trailing writes still flushed. (Companion drops IDBFS autoPersist so this is the single persistence path.)
- extra_scripts/wasm_link_flags.py: restore the trunk-ignore-all(ruff/F821, flake8/F821) header every other SCons extra_script carries; Import/env are SConscript-injected globals, so ruff/flake8 flag them as undefined. - .semgrepignore: exclude src/platform/portduino/wasm/js/ (browser WebUSB glue, not part of the firmware binary). The unsafe-formatstring rule false-positives on its benign retry/diagnostic console logs.
There was a problem hiding this comment.
Why are we editing this file? Weird
There was a problem hiding this comment.
Because I found multiple Lora Meshstick SX1262 named modules in the available config.d yamls. It was confusing
Firmware Size Report22 targets | vs
Show 17 more target(s)
Updated for 66eba74 |
Co-authored-by: Austin <vidplace7@gmail.com>
Portduino WASM hello world (still experimental) (meshtastic#10793)
This pull request adds support for building and running the PortDuino firmware as a WebAssembly (WASM) module, enabling the Meshtastic node to run in a browser or Node.js environment and communicate with a LoRa radio via WebUSB. The changes introduce a new CI workflow, WASM-specific build and runtime logic, and conditional compilation guards to keep the native build unaffected.
The most important changes are:
WASM Platform Support in Firmware:
ARCH_PORTDUINO_WASMpreprocessor guards throughout the codebase to adapt the firmware for the browser environment. This includes:emscripten_sleep()instead of thread-based delays.