Skip to content

Portduino WASM hello world (still experimental)#10793

Merged
thebentern merged 19 commits into
developfrom
portduino-wasm
Jun 28, 2026
Merged

Portduino WASM hello world (still experimental)#10793
thebentern merged 19 commits into
developfrom
portduino-wasm

Conversation

@thebentern

Copy link
Copy Markdown
Contributor

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:

  • Added ARCH_PORTDUINO_WASM preprocessor guards throughout the codebase to adapt the firmware for the browser environment. This includes:
    • Cooperative sleep with emscripten_sleep() instead of thread-based delays.
    • Continuous RX mode for the radio in the browser to avoid stalling the WebUSB SPI link.
    • Using Emscripten's random device for cryptographic randomness in the browser.
    • Adapting RSSI and other radio API calls to WASM constraints.

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).
@thebentern thebentern requested review from Copilot and vidplace7 June 25, 2026 20:29
@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

⚡ Try this PR in the Web Flasher

Flash this PR in the Web Flasher

firmware commit boards expires

Warning

This is an automated, unreviewed CI test build. Back up your device configuration
before flashing, and only flash devices you are able to recover.

Supported boards built by this PR (25)
Device Board Platform
Crowpanel Adv 3.5 TFT elecrow-adv-35-tft esp32-s3
Heltec HT62 heltec-ht62-esp32c3-sx1262 esp32-c3
Heltec Mesh Node 096 heltec-mesh-node-t096 nrf52840
Heltec Mesh Node T1 heltec-mesh-node-t1 nrf52840
Heltec Mesh Node T114 heltec-mesh-node-t114 nrf52840
Heltec V3 heltec-v3 esp32-s3
Heltec V4 heltec-v4 esp32-s3
Raspberry Pi Pico pico rp2040
Raspberry Pi Pico W picow rp2040
RAK WisMesh Tag rak_wismeshtag nrf52840
RAK WisBlock 11200 rak11200 esp32
RAK WisBlock 11310 rak11310 rp2040
RAK3312 rak3312 esp32-s3
RAK WisBlock 4631 rak4631 nrf52840
Seeed Wio Tracker L1 seeed_wio_tracker_L1 nrf52840
Seeed Xiao NRF52840 Kit seeed_xiao_nrf52840_kit nrf52840
Seeed Xiao ESP32-S3 seeed-xiao-s3 esp32-s3
Station G2 station-g2 esp32-s3
Station G3 station-g3 esp32-s3
LILYGO T-Deck t-deck-tft esp32-s3
LILYGO T-Echo t-echo nrf52840
LILYGO T-Echo Plus t-echo-plus nrf52840
LILYGO T-Impulse Plus t-impulse-plus nrf52840
LilyGo T3-C6 tlora-c6 esp32-c6
Seeed SenseCAP T1000-E tracker-t1000-e nrf52840

Build artifacts expire on 2026-07-27. Updated for 0308faf.

@github-actions github-actions Bot added the hardware-support Hardware related: new devices or modules, problems specific to hardware label Jun 25, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/platform/portduino/wasm/js/bridge.js Outdated
Comment thread src/platform/portduino/wasm/js/ch341.js
Comment thread src/platform/portduino/wasm/libpinedio_webusb.c Outdated
Comment thread src/mesh/SX126xInterface.cpp Outdated
- 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we editing this file? Weird

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because I found multiple Lora Meshstick SX1262 named modules in the available config.d yamls. It was confusing

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Firmware Size Report

22 targets | vs develop: 22 increased, net +171,708 (+167.7 KB)

Target Size vs develop
heltec-v4 2,277,216 📈 +9,600 (+9.4 KB)
t-eth-elite 2,491,408 📈 +9,520 (+9.3 KB)
elecrow-adv-35-tft 3,416,544 📈 +9,456 (+9.2 KB)
heltec-vision-master-e213-inkhud 2,225,200 📈 +9,072 (+8.9 KB)
heltec-ht62-esp32c3-sx1262 2,134,976 📈 +8,832 (+8.6 KB)
Show 17 more target(s)
Target Size vs develop
heltec-v3 2,264,000 📈 +8,784 (+8.6 KB)
tlora-c6 2,368,256 📈 +8,704 (+8.5 KB)
rak11200 1,860,544 📈 +8,560 (+8.4 KB)
seeed-xiao-s3 2,276,064 📈 +8,320 (+8.1 KB)
rak3312 2,272,160 📈 +8,256 (+8.1 KB)
station-g2 2,266,256 📈 +8,064 (+7.9 KB)
station-g3 2,266,272 📈 +8,064 (+7.9 KB)
picow 1,244,776 📈 +7,776 (+7.6 KB)
t-deck-tft 3,810,592 📈 +7,424 (+7.2 KB)
pico 782,608 📈 +7,344 (+7.2 KB)
seeed_xiao_rp2040 780,808 📈 +7,344 (+7.2 KB)
pico2w 1,220,404 📈 +7,336 (+7.2 KB)
rak11310 805,344 📈 +7,176 (+7.0 KB)
seeed_xiao_rp2350 767,840 📈 +6,904 (+6.7 KB)
pico2 769,680 📈 +6,888 (+6.7 KB)
rak3172 186,296 📈 +4,188 (+4.1 KB)
wio-e5 238,556 📈 +4,096 (+4.0 KB)

Updated for 66eba74

Comment thread .github/workflows/build_portduino_wasm.yml
@thebentern thebentern changed the title Portduino WASM hello world Portduino WASM hello world (still experimental) Jun 28, 2026
@thebentern thebentern merged commit 0baf8b1 into develop Jun 28, 2026
91 of 92 checks passed
madeofstown added a commit to madeofstown/meshtastic-firmware that referenced this pull request Jun 28, 2026
Portduino WASM hello world (still experimental) (meshtastic#10793)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

hardware-support Hardware related: new devices or modules, problems specific to hardware

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants