Portduino WASM hello world (still experimental) (#10793)

* Add ARCH_PORTDUINO_WASM build: meshtasticd in WebAssembly over WebUSB

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.

* PortDuino WASM: CI build job, cross-platform libdeps, configurable adapter

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.

* WASM: build as a first-class [env:wasm] via platform-wasm (retire emcc 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.

* Fix name on the Piggystick

* wasm: pin platform-wasm at the GPL-3.0-relicensed commit

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.

* wasm: make reboot() actually restart the node (was a no-op)

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.

* wasm: rename env to native-wasm and run it in the main CI matrix

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.

* native-wasm: auto-locate the Emscripten SDK (pre-build script)

`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).

* wasm: address PR review feedback

- 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.

* native-wasm: drop the emsdk bootstrap shim (now in platform-wasm)

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.

* wasm: use the canonical companion name (meshtasticd-wasm-node)

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.

* wasm: re-entrancy guard for the API/region entry points + flaky-open 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.

* wasm: exclude emscripten-only sources from cppcheck

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.

* wasm: coalesce FS.syncfs so two never run at once

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.)

* wasm: silence false-positive SAST on the emscripten glue

- 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.

* Update .github/workflows/build_portduino_wasm.yml

Co-authored-by: Austin <vidplace7@gmail.com>

---------

Co-authored-by: Austin <vidplace7@gmail.com>
This commit is contained in:
Ben Meadors
2026-06-28 06:50:35 -05:00
committed by GitHub
co-authored by GitHub Austin
parent 25c71cb9f1
commit 0baf8b1db6
27 changed files with 1641 additions and 5 deletions
@@ -0,0 +1,61 @@
name: Build PortDuino WASM
# Reusable workflow — called as the `build-wasm` job of the main CI workflow
# (main_matrix.yml), so the WebAssembly portduino node is built like every other
# platform. Builds the [env:native-wasm] target (src/platform/portduino/wasm/) with
# `pio run -e native-wasm` so it can't silently bit-rot. Software/CI only — asserts the
# full mesh stack + RadioLib + Crypto compile and link to meshnode.{mjs,wasm}
# via the meshtastic/platform-wasm PlatformIO platform (emcc/Asyncify). It does
# NOT exercise the radio (that's CH341/WebUSB, hardware).
on:
workflow_call:
workflow_dispatch:
permissions: {}
jobs:
build-wasm:
name: Build PortDuino WASM
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
# Python + PlatformIO. (`pio run -e native-wasm` installs its own libdeps and the
# platform-wasm/framework-portduino packages; it needs no native apt libs.)
- name: Setup native build
uses: ./.github/actions/setup-native
- name: Setup Emscripten SDK
uses: emscripten-core/setup-emsdk@v16
with:
version: 6.0.1
actions-cache-folder: emsdk-cache
- name: Build PortDuino WASM
run: platformio run -e native-wasm
- name: Assert WASM artifacts
run: |
OUT=.pio/build/native-wasm
if [ ! -s "$OUT/meshnode.mjs" ] || [ ! -s "$OUT/meshnode.wasm" ]; then
echo "::error::wasm artifacts missing"; exit 1
fi
# The emcc link succeeds even if embedded EM_ASM JS is malformed, so
# parse the generated ES-module loader to catch runtime-JS breakage
# (e.g. a formatter splitting !== inside EM_ASM).
node --check "$OUT/meshnode.mjs" || { echo "::error::meshnode.mjs failed JS parse"; exit 1; }
ls -lah "$OUT"/meshnode.*
- name: Upload WASM artifacts
if: success()
uses: actions/upload-artifact@v7
with:
name: portduino-wasm
overwrite: true
path: |
.pio/build/native-wasm/meshnode.mjs
.pio/build/native-wasm/meshnode.wasm
retention-days: 14
+8
View File
@@ -146,6 +146,14 @@ jobs:
checks: write checks: write
uses: ./.github/workflows/test_native.yml uses: ./.github/workflows/test_native.yml
build-wasm:
# Build the WebAssembly portduino node ([env:native-wasm]) as part of normal CI,
# like the other platforms. It's a dedicated job (not a row in the `build`
# matrix) because its artifact is meshnode.{mjs,wasm} — not a flashable
# .bin/.uf2/.hex — and it needs the Emscripten SDK; board_level=extra keeps
# it out of generate_ci_matrix.py.
uses: ./.github/workflows/build_portduino_wasm.yml
docker: docker:
permissions: # Needed for pushing to GHCR. permissions: # Needed for pushing to GHCR.
contents: read contents: read
+8
View File
@@ -66,3 +66,11 @@ userPrefs.jsonc.mcp-session-bak
# compiled .proto outputs are ephemeral build artifacts. # compiled .proto outputs are ephemeral build artifacts.
build/fixtures/ build/fixtures/
bin/_generated/ bin/_generated/
# portduino WASM build (pio run -e native-wasm) + Emscripten SDK. The artifacts land in
# .pio/build/native-wasm/ (already ignored via .pio/); these cover the retired
# standalone emcc build's output dirs and any in-repo emsdk checkout.
build/wasm/
build/wasm-obj/
build/wasm-*.log
.emsdk/
+4
View File
@@ -1,2 +1,6 @@
.github/workflows/main_matrix.yml .github/workflows/main_matrix.yml
src/mesh/compression/unishox2.cpp src/mesh/compression/unishox2.cpp
# Emscripten/WebUSB browser glue for the wasm node — not part of the firmware
# binary or its security surface. The format-string rule false-positives on its
# benign retry/diagnostic console logs.
src/platform/portduino/wasm/js/
+2 -2
View File
@@ -1,5 +1,5 @@
Meta: Meta:
name: Lora Meshstick SX1262 name: PiggyStick LR1121
support: community support: community
compatible: compatible:
- usb - usb
@@ -12,6 +12,6 @@ Lora:
Busy: 4 Busy: 4
spidev: ch341 spidev: ch341
DIO3_TCXO_VOLTAGE: 1.8 DIO3_TCXO_VOLTAGE: 1.8
# USB_Serialnum: 12345678 # USB_Serialnum: 12345678
USB_PID: 0x5512 USB_PID: 0x5512
USB_VID: 0x1A86 USB_VID: 0x1A86
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
# trunk-ignore-all(ruff/F821)
# trunk-ignore-all(flake8/F821): For SConstruct imports
#
# Firmware-specific Emscripten *link* settings for [env:native-wasm].
#
# PlatformIO routes `build_flags` to the compile step, not the link step, so the
# WASM node's link-time emscripten settings have to be appended to LINKFLAGS
# here. The generic, app-agnostic flags (Asyncify, MODULARIZE, ALLOW_MEMORY_
# GROWTH, the ES6 module shape) live in the platform-wasm builder; what belongs
# to *this firmware* is:
#
# * EXPORT_NAME — the ES-module factory name consumers import.
# * EXPORTED_RUNTIME_METHODS — ccall/cwrap/callMain + FS/IDBFS/NODEFS/PATH and
# the string helpers the JS host + bridge drive.
# * EXPORTED_FUNCTIONS — the C entry points (the wasm_* API is also kept via
# EMSCRIPTEN_KEEPALIVE in source; _malloc/_free are
# needed so the host can marshal protobuf buffers).
# * ASYNCIFY_IMPORTS — the WebUSB seam: these imported C functions suspend
# the stack (Asyncify) while a WebUSB transfer awaits.
#
# Only attached to the wasm env (see extra_scripts in [env:native-wasm]); a guard keeps
# it inert if it is ever pulled into another env.
#
Import("env")
if env["PIOENV"] == "native-wasm":
env.Append(
LINKFLAGS=[
"-sEXPORT_NAME=createMeshNode",
"-sEXPORTED_RUNTIME_METHODS=ccall,cwrap,callMain,FS,IDBFS,NODEFS,PATH,HEAPU8,UTF8ToString,stringToUTF8",
"-sEXPORTED_FUNCTIONS=_main,_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,_wasm_set_lora_module,"
"_wasm_set_lora_usb_ids,_wasm_set_lora_usb_serial,"
"_wasm_set_lora_dio_config,_wasm_set_lora_spi_speed,_wasm_set_lora_pin,"
"_malloc,_free",
"-sASYNCIFY_IMPORTS=webusb_open,webusb_transceive,webusb_digital_write,"
"webusb_digital_read,webusb_close",
]
)
+8
View File
@@ -838,6 +838,14 @@ void Power::reboot()
NVIC_SystemReset(); NVIC_SystemReset();
#elif defined(ARCH_RP2040) #elif defined(ARCH_RP2040)
rp2040.reboot(); rp2040.reboot();
#elif defined(ARCH_PORTDUINO_WASM)
// Browser/headless WASM node: no in-process restart. notifyReboot above
// already let modules persist; hand off to the host (reboot() ->
// location.reload() in a tab, or Module.onReboot() headless). Deliberately
// skip the ARCH_PORTDUINO SPI/Wire/Serial teardown below — it would kill the
// radio with no actual restart to follow, leaving a wedged node. Must come
// before the ARCH_PORTDUINO arm: the wasm build defines both macros.
::reboot();
#elif defined(ARCH_PORTDUINO) #elif defined(ARCH_PORTDUINO)
deInitApiServer(); deInitApiServer();
#ifdef __linux__ #ifdef __linux__
+16
View File
@@ -1,4 +1,7 @@
#include "configuration.h" #include "configuration.h"
#ifdef ARCH_PORTDUINO_WASM
#include <emscripten.h>
#endif
#if !MESHTASTIC_EXCLUDE_GPS #if !MESHTASTIC_EXCLUDE_GPS
#include "GPS.h" #include "GPS.h"
#endif #endif
@@ -97,7 +100,9 @@ NRF54L15Bluetooth *nrf54l15Bluetooth = nullptr;
#ifdef ARCH_PORTDUINO #ifdef ARCH_PORTDUINO
#include "linux/LinuxHardwareI2C.h" #include "linux/LinuxHardwareI2C.h"
#ifndef ARCH_PORTDUINO_WASM // raspi HTTP server (ulfius/zlib/openssl) excluded in the browser/wasm build
#include "mesh/raspihttp/PiWebServer.h" #include "mesh/raspihttp/PiWebServer.h"
#endif
#include "platform/portduino/PortduinoGlue.h" #include "platform/portduino/PortduinoGlue.h"
#include <cstdlib> #include <cstdlib>
#include <fstream> #include <fstream>
@@ -1110,10 +1115,12 @@ void setup()
if (!rIf) if (!rIf)
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_NO_RADIO); RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_NO_RADIO);
else { else {
#ifndef ARCH_PORTDUINO_WASM
// Log bit rate to debug output // Log bit rate to debug output
LOG_DEBUG("LoRA bitrate = %f bytes / sec", (float(meshtastic_Constants_DATA_PAYLOAD_LEN) / LOG_DEBUG("LoRA bitrate = %f bytes / sec", (float(meshtastic_Constants_DATA_PAYLOAD_LEN) /
(float(rIf->getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN)))) * (float(rIf->getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN)))) *
1000); 1000);
#endif
router->addInterface(std::move(rIf)); router->addInterface(std::move(rIf));
} }
@@ -1400,7 +1407,16 @@ void loop()
#ifdef DEBUG_LOOP_TIMING #ifdef DEBUG_LOOP_TIMING
LOG_DEBUG("main loop delay: %d", delayMsec); LOG_DEBUG("main loop delay: %d", delayMsec);
#endif #endif
#ifdef ARCH_PORTDUINO_WASM
// Single-threaded wasm: mainDelay's InterruptableDelay is a pthread
// cond/mutex semaphore that no other thread can ever give(), and
// emscripten's single-threaded pthread_cond_timedwait busy-spins. Suspend
// cooperatively via Asyncify instead, capping idle sleep so the per-tick
// IRQ poll latency stays bounded (RX/TX-done is detected by polling).
emscripten_sleep(delayMsec > 50 ? 50 : delayMsec);
#else
mainDelay.delay(delayMsec); mainDelay.delay(delayMsec);
#endif
} }
} }
#endif #endif
+3
View File
@@ -128,6 +128,9 @@ bool fill(uint8_t *buffer, size_t length, bool useRadioEntropy)
if (generated == static_cast<ssize_t>(length)) { if (generated == static_cast<ssize_t>(length)) {
filled = true; filled = true;
} }
#elif defined(__EMSCRIPTEN__)
// Browser/wasm: no getrandom/arc4random — fall through to std::random_device,
// which emscripten backs with crypto.getRandomValues().
#else #else
// arc4random_buf is available on Darwin/BSD and cannot fail. // arc4random_buf is available on Darwin/BSD and cannot fail.
::arc4random_buf(buffer, length); ::arc4random_buf(buffer, length);
+2
View File
@@ -8,8 +8,10 @@
#include "SX126xInterface.h" #include "SX126xInterface.h"
#include "SX128xInterface.cpp" #include "SX128xInterface.cpp"
#include "SX128xInterface.h" #include "SX128xInterface.h"
#ifndef ARCH_PORTDUINO_WASM // TCP socket API server excluded in the browser/wasm build
#include "api/ServerAPI.cpp" #include "api/ServerAPI.cpp"
#include "api/ServerAPI.h" #include "api/ServerAPI.h"
#endif
// We need this declaration for proper linking in derived classes // We need this declaration for proper linking in derived classes
#if RADIOLIB_EXCLUDE_SX126X != 1 #if RADIOLIB_EXCLUDE_SX126X != 1
+4
View File
@@ -371,7 +371,11 @@ template <typename T> bool LR11x0Interface<T>::sleep()
template <typename T> int16_t LR11x0Interface<T>::getCurrentRSSI() template <typename T> int16_t LR11x0Interface<T>::getCurrentRSSI()
{ {
#ifdef ARCH_PORTDUINO_WASM
float rssi = lora.getRSSI(); // installed RadioLib's LR11x0 getRSSI() is 0-arg
#else
float rssi = lora.getRSSI(false, true); float rssi = lora.getRSSI(false, true);
#endif
return (int16_t)round(rssi); return (int16_t)round(rssi);
} }
#endif #endif
+9 -1
View File
@@ -328,10 +328,18 @@ template <typename T> void SX126xInterface<T>::startReceive()
setTransmitEnable(false); setTransmitEnable(false);
setStandby(); setStandby();
#ifdef ARCH_PORTDUINO_WASM
// Continuous RX in the browser: duty-cycle sleep parks BUSY high between RX
// windows and stalls the slow WebUSB SPI link. No battery to save here.
int err = lora.startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS);
const char *rxMethod = "startReceive";
#else
// We use a 16 bit preamble so this should save some power by letting radio sit in standby mostly. // We use a 16 bit preamble so this should save some power by letting radio sit in standby mostly.
int err = lora.startReceiveDutyCycleAuto(preambleLength, 8, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS); int err = lora.startReceiveDutyCycleAuto(preambleLength, 8, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS);
const char *rxMethod = "startReceiveDutyCycleAuto";
#endif
if (err != RADIOLIB_ERR_NONE) if (err != RADIOLIB_ERR_NONE)
LOG_ERROR("SX126X startReceiveDutyCycleAuto %s%d", radioLibErr, err); LOG_ERROR("SX126X %s %s%d", rxMethod, radioLibErr, err);
#ifdef ARCH_PORTDUINO #ifdef ARCH_PORTDUINO
if (err != RADIOLIB_ERR_NONE) if (err != RADIOLIB_ERR_NONE)
portduino_status.LoRa_in_error = true; portduino_status.LoRa_in_error = true;
+31
View File
@@ -232,6 +232,18 @@ void portduinoSetup()
concurrency::hasBeenSetup = true; concurrency::hasBeenSetup = true;
consoleInit(); consoleInit();
#ifdef ARCH_PORTDUINO_WASM
// Browser build: no YAML/filesystem config. Apply a hardcoded SX1262/CH341
// setup and create the WebUSB-backed Ch341Hal, then skip the Linux config path.
{
extern void wasm_config_apply();
wasm_config_apply();
ch341Hal =
new Ch341Hal(0, portduino_config.lora_usb_serial_num, portduino_config.lora_usb_vid, portduino_config.lora_usb_pid);
}
return;
#endif
if (portduino_config.force_simradio == true) { if (portduino_config.force_simradio == true) {
portduino_config.lora_module = use_simradio; portduino_config.lora_module = use_simradio;
} else if (configPath != nullptr) { } else if (configPath != nullptr) {
@@ -275,10 +287,12 @@ void portduinoSetup()
} }
} }
#ifndef ARCH_PORTDUINO_WASM
if (yamlOnly) { if (yamlOnly) {
std::cout << portduino_config.emit_yaml() << std::endl; std::cout << portduino_config.emit_yaml() << std::endl;
exit(EXIT_SUCCESS); exit(EXIT_SUCCESS);
} }
#endif
if (portduino_config.force_simradio) { if (portduino_config.force_simradio) {
std::cout << "Running in simulated mode." << std::endl; std::cout << "Running in simulated mode." << std::endl;
@@ -733,6 +747,16 @@ int initGPIOPin(int pinNum, const std::string &gpioChipName, int line)
#endif #endif
} }
#ifdef ARCH_PORTDUINO_WASM
// Browser node: configuration comes from the wasm_set_lora_* setters, not a YAML
// file. Reached only as dead code after portduinoSetup()'s early return; kept
// defined (and yaml-free) so those references still link.
bool loadConfig(const char *configPath)
{
(void)configPath;
return false;
}
#else
bool loadConfig(const char *configPath) bool loadConfig(const char *configPath)
{ {
YAML::Node yamlConfig; YAML::Node yamlConfig;
@@ -1090,6 +1114,7 @@ bool loadConfig(const char *configPath)
} }
return true; return true;
} }
#endif // !ARCH_PORTDUINO_WASM
// https://stackoverflow.com/questions/874134/find-out-if-string-ends-with-another-string-in-c // https://stackoverflow.com/questions/874134/find-out-if-string-ends-with-another-string-in-c
static bool ends_with(std::string_view str, std::string_view suffix) static bool ends_with(std::string_view str, std::string_view suffix)
@@ -1129,6 +1154,10 @@ bool MAC_from_string(std::string mac_str, uint8_t *dmac)
std::string exec(const char *cmd) std::string exec(const char *cmd)
{ // https://stackoverflow.com/a/478960 { // https://stackoverflow.com/a/478960
#ifdef ARCH_PORTDUINO_WASM
(void)cmd; // no shell/popen in the browser — shell-outs degrade to empty
return "";
#endif
std::array<char, 128> buffer; std::array<char, 128> buffer;
std::string result; std::string result;
std::unique_ptr<FILE, decltype(&pclose)> pipe(popen(cmd, "r"), pclose); std::unique_ptr<FILE, decltype(&pclose)> pipe(popen(cmd, "r"), pclose);
@@ -1141,6 +1170,7 @@ std::string exec(const char *cmd)
return result; return result;
} }
#ifndef ARCH_PORTDUINO_WASM
void readGPIOFromYaml(YAML::Node sourceNode, pinMapping &destPin, int pinDefault) void readGPIOFromYaml(YAML::Node sourceNode, pinMapping &destPin, int pinDefault)
{ {
if (sourceNode.IsMap()) { if (sourceNode.IsMap()) {
@@ -1155,3 +1185,4 @@ void readGPIOFromYaml(YAML::Node sourceNode, pinMapping &destPin, int pinDefault
destPin.gpiochip = portduino_config.lora_default_gpiochip; destPin.gpiochip = portduino_config.lora_default_gpiochip;
} }
} }
#endif // !ARCH_PORTDUINO_WASM
+9
View File
@@ -8,7 +8,12 @@
#include "LR11x0Interface.h" #include "LR11x0Interface.h"
#include "Module.h" #include "Module.h"
#include "mesh/generated/meshtastic/mesh.pb.h" #include "mesh/generated/meshtastic/mesh.pb.h"
#ifndef ARCH_PORTDUINO_WASM
// The browser (WASM) node configures the radio via the wasm_set_lora_* setters
// instead of a YAML file, so it has no yaml-cpp dependency. Everything that uses
// YAML below (emit_yaml / loadConfig / readGPIOFromYaml) is likewise guarded.
#include "yaml-cpp/yaml.h" #include "yaml-cpp/yaml.h"
#endif
extern struct portduino_status_struct { extern struct portduino_status_struct {
bool LoRa_in_error = false; bool LoRa_in_error = false;
@@ -64,7 +69,9 @@ bool loadConfig(const char *configPath);
static bool ends_with(std::string_view str, std::string_view suffix); static bool ends_with(std::string_view str, std::string_view suffix);
void getMacAddr(uint8_t *dmac); void getMacAddr(uint8_t *dmac);
bool MAC_from_string(std::string mac_str, uint8_t *dmac); bool MAC_from_string(std::string mac_str, uint8_t *dmac);
#ifndef ARCH_PORTDUINO_WASM
void readGPIOFromYaml(YAML::Node sourceNode, pinMapping &destPin, int pinDefault = RADIOLIB_NC); void readGPIOFromYaml(YAML::Node sourceNode, pinMapping &destPin, int pinDefault = RADIOLIB_NC);
#endif
std::string exec(const char *cmd); std::string exec(const char *cmd);
extern struct portduino_config_struct { extern struct portduino_config_struct {
@@ -221,6 +228,7 @@ extern struct portduino_config_struct {
&tbRightPin, &tbRightPin,
&tbPressPin}; &tbPressPin};
#ifndef ARCH_PORTDUINO_WASM
std::string emit_yaml() std::string emit_yaml()
{ {
YAML::Emitter out; YAML::Emitter out;
@@ -569,4 +577,5 @@ extern struct portduino_config_struct {
out << YAML::EndMap; // General out << YAML::EndMap; // General
return out.c_str(); return out.c_str();
} }
#endif // !ARCH_PORTDUINO_WASM
} portduino_config; } portduino_config;
+82
View File
@@ -0,0 +1,82 @@
# 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](https://github.com/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`.
```sh
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:
```js
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.
```js
const Module = await createMeshNode({ noInitialRun: true });
Module.onReboot = () => process.exit(0); // optional; headless restart policy
```
@@ -0,0 +1,79 @@
// WebUSB/wasm-compatible drop-in for libch341-spi-userspace's public header.
//
// Same API surface as the upstream libpinedio-usb.h that the firmware's
// Ch341Hal (src/platform/portduino/USBHal.h) compiles against, but WITHOUT the
// libusb / pthread dependencies — the implementation (libpinedio_webusb.c)
// forwards to a JS WebUSB bridge via Emscripten. The struct keeps only the
// fields Ch341Hal actually touches (serial_number, product_string, in_error,
// options[]); GPIO/CS state now lives on the JS side.
#ifndef PINEDIO_USB_WEBUSB_H
#define PINEDIO_USB_WEBUSB_H
#ifdef __cplusplus
extern "C" {
#endif
#include <stdbool.h>
#include <stdint.h>
enum pinedio_int_pin {
PINEDIO_PIN_D0,
PINEDIO_PIN_D1,
PINEDIO_PIN_D2,
PINEDIO_PIN_D3,
PINEDIO_PIN_D4,
PINEDIO_PIN_D5,
PINEDIO_PIN_D6,
PINEDIO_PIN_D7,
PINEDIO_PIN_ERR,
PINEDIO_PIN_PEMP,
PINEDIO_PIN_INT,
PINEDIO_INT_PIN_MAX
};
enum pinedio_int_mode {
PINEDIO_INT_MODE_RISING = 0x01,
PINEDIO_INT_MODE_FALLING = 0x02,
};
enum pinedio_option {
PINEDIO_OPTION_AUTO_CS,
PINEDIO_OPTION_SEARCH_SERIAL,
PINEDIO_OPTION_VID,
PINEDIO_OPTION_PID,
PINEDIO_OPTION_MAX
};
struct pinedio_inst_int {
uint8_t previous_state;
enum pinedio_int_mode mode;
void (*callback)(void);
};
struct pinedio_inst {
bool in_error;
struct pinedio_inst_int interrupts[PINEDIO_INT_PIN_MAX];
uint32_t options[PINEDIO_OPTION_MAX];
char serial_number[9];
char product_string[97];
};
typedef struct pinedio_inst pinedio_inst;
int32_t pinedio_init(struct pinedio_inst *inst, void *driver);
int32_t pinedio_set_option(struct pinedio_inst *inst, enum pinedio_option option, uint32_t value);
int32_t pinedio_set_pin_mode(struct pinedio_inst *inst, uint32_t pin, uint32_t mode);
int32_t pinedio_digital_write(struct pinedio_inst *inst, uint32_t pin, bool active);
int32_t pinedio_set_cs(struct pinedio_inst *inst, bool active);
int32_t pinedio_write_read(struct pinedio_inst *inst, uint8_t *writearr, uint32_t writecnt, uint8_t *readarr, uint32_t readcnt);
int32_t pinedio_transceive(struct pinedio_inst *inst, uint8_t *write_buf, uint8_t *read_buf, uint32_t count);
int32_t pinedio_digital_read(struct pinedio_inst *inst, uint32_t pin);
int32_t pinedio_get_irq_state(struct pinedio_inst *inst, uint32_t pin);
int32_t pinedio_attach_interrupt(struct pinedio_inst *inst, enum pinedio_int_pin int_pin, enum pinedio_int_mode int_mode,
void (*callback)(void));
int32_t pinedio_deattach_interrupt(struct pinedio_inst *inst, enum pinedio_int_pin int_pin);
void pinedio_deinit(struct pinedio_inst *inst);
#ifdef __cplusplus
}
#endif
#endif // PINEDIO_USB_WEBUSB_H
+115
View File
@@ -0,0 +1,115 @@
// JS side of the wasm WebUSB bridge. The C backend (libpinedio_webusb.c) calls
// Module.ch341.<method> from EM_ASYNC_JS/EM_JS. This wraps the same CH341
// transport used by the pure-JS probe, marshalling buffers in/out of the wasm
// heap.
//
// Wiring (with MODULARIZE'd Emscripten output):
// const device = (await CH341.request()).device; // user gesture
// const Module = await createModule({ noInitialRun: true });
// Module.ch341 = createCH341Bridge(Module, device);
// Module.callMain([]); // runs C main()
//
// IMPORTANT: a wasm heap can grow across an `await`, so always re-read
// Module.HEAPU8 *after* awaiting, never cache it across a suspension point.
import { CH341 } from "./ch341.js";
export function createCH341Bridge(Module, device) {
let ch = null;
function writeCString(str, ptr, max) {
const bytes = new TextEncoder().encode(str);
const n = Math.min(bytes.length, max - 1);
const heap = Module.HEAPU8;
heap.set(bytes.subarray(0, n), ptr);
heap[ptr + n] = 0;
}
return {
// vid/pid/serial come from the C side; if a device was already handed in
// (typical — selected via a user gesture), just open that one.
async open(vid, pid, serial) {
let dev = device;
if (!dev) {
dev = await CH341.tryReconnect({ vid, pid }).then((c) =>
c ? c.device : null,
);
if (!dev) return -2; // no granted device; page must requestDevice first
}
// First-connect is flaky: the WebUSB interface can be momentarily
// unclaimable right after the grant, or still held by a prior session
// (yields the transient "Could not open SPI: -1"). Retry with a short
// backoff, resetting the device between attempts so claimInterface doesn't
// trip over a half-open device.
const attempts = 4;
for (let i = 0; i < attempts; i++) {
ch = new CH341(dev);
try {
await ch.open();
return 0;
} catch (e) {
console.warn(`CH341 open attempt ${i + 1}/${attempts} failed:`, e);
try {
await dev.close(); // release/close so the next attempt starts clean
} catch (_) {}
if (i < attempts - 1)
await new Promise((r) => setTimeout(r, 200 * (i + 1)));
}
}
console.error("CH341 open failed after", attempts, "attempts");
return -1;
},
// Read `count` bytes from the heap at writePtr, full-duplex transfer, write
// the result back at readPtr. Returns 0 / negative.
async transceive(writePtr, readPtr, count) {
try {
const out = Module.HEAPU8.slice(writePtr, writePtr + count); // copy before await
const inBuf = await ch.transceive(out);
Module.HEAPU8.set(inBuf, readPtr); // re-read HEAPU8 (may have grown)
return 0;
} catch (e) {
console.error("transceive failed:", e);
return -1;
}
},
async digitalWrite(pin, value) {
try {
await ch.digitalWrite(pin, value);
return 0;
} catch (e) {
return -1;
}
},
async digitalRead(pin) {
try {
return await ch.digitalRead(pin);
} catch (e) {
return -1;
}
},
setPinMode(pin, output) {
ch.setPinMode(pin, output);
},
setAutoCS(enabled) {
if (ch) ch.autoCS = enabled;
},
getSerial(ptr, max) {
writeCString(ch?.serial || "", ptr, max);
},
getProduct(ptr, max) {
writeCString(ch?.product || "", ptr, max);
},
async close() {
if (ch) await ch.close();
ch = null;
},
};
}
+186
View File
@@ -0,0 +1,186 @@
// WebUSB driver for the CH341 USB-to-SPI bridge.
//
// This is the browser-side transport. It mirrors the public surface that the
// firmware's Ch341Hal (src/platform/portduino/USBHal.h) needs from the
// libpinedio C API — transceive / digitalWrite / digitalRead / setPinMode /
// setCS — but backed by async WebUSB instead of libusb.
//
// Every method is async (WebUSB is Promise-only). The wasm build will call the
// same WebUSB primitives from C via Asyncify so a synchronous C SPI transfer
// can await these. Here, used directly, it's the pure-JS transport for the
// hardware checkpoint (web/probe.js).
import * as proto from "./protocol.js";
// Opt-in per-op USB tracing (node: DEBUG_USB=1). When a run wedges, the tail of
// the log shows the last USB ops + timing, so a stuck BUSY poll vs a stalled
// transfer is obvious.
let __usbOp = 0;
const __now = () =>
typeof performance !== "undefined" ? performance.now() : Date.now();
const __dbg =
typeof process !== "undefined" && process?.env?.DEBUG_USB
? (m) => console.error(`[usb#${++__usbOp} ${__now().toFixed(0)}ms] ${m}`)
: null;
const __hex = (a, n = 6) =>
Array.from(a.slice(0, n))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
export class CH341 {
/** @param {USBDevice} device */
constructor(device) {
this.device = device;
this.dMode = 0; // D0..D7 direction bitfield (1 = output)
this.dState = 0; // D0..D7 output level bitfield
this.autoCS = true; // toggle CS around each transceive
this.csPin = 0; // D0 is CS on the common CH341 LoRa adapters
this.csActiveLow = true; // NSS is active-low: assert (select) = drive CS LOW
this.interfaceNumber = 0;
}
// Prompt the user to pick a CH341 (must be called from a user gesture).
static async request({ vid = proto.VID, pid = proto.PID } = {}) {
if (!("usb" in navigator))
throw new Error("WebUSB unavailable (use Chromium over https/localhost)");
const device = await navigator.usb.requestDevice({
filters: [{ vendorId: vid, productId: pid }],
});
return new CH341(device);
}
// Reconnect to an already-granted device without a prompt, if present.
static async tryReconnect({ vid = proto.VID, pid = proto.PID } = {}) {
if (!("usb" in navigator)) return null;
const devices = await navigator.usb.getDevices();
const device = devices.find(
(d) => d.vendorId === vid && d.productId === pid,
);
return device ? new CH341(device) : null;
}
async open() {
await this.device.open();
// Don't read .configuration (the node-usb backend throws "device is not
// configured" instead of returning null); just (re)select config 1, which is
// idempotent in the browser if it's already active.
try {
await this.device.selectConfiguration(1);
} catch (_) {}
// The SPI bridge lives on interface 0 with bulk EP 0x02/0x82.
await this.device.claimInterface(this.interfaceNumber);
this.serial = this.device.serialNumber || "";
this.product = this.device.productName || "";
// CH341A SPI bus pins MUST be configured as outputs or the chip never
// clocks SCK/MOSI and every MISO byte reads back 0xFF. D3=SCK, D5=MOSI
// (D7=MISO stays input). This mirrors Ch341Hal's constructor
// (USBHal.h: pinedio_set_pin_mode(&pinedio, 3, true) / (5, true)).
this.setPinMode(3, true); // SCK (DCK)
this.setPinMode(5, true); // MOSI (DOUT)
this.setPinMode(this.csPin, true); // CS (NSS)
await this.setCS(false); // transmits direction bits + idles CS deasserted
}
async close() {
try {
await this.device.releaseInterface(this.interfaceNumber);
} catch (_) {}
try {
await this.device.close();
} catch (_) {}
}
async _out(bytes) {
const r = await this.device.transferOut(proto.WRITE_EP, bytes);
if (r.status !== "ok") throw new Error(`USB OUT ${r.status}`);
return r.bytesWritten;
}
async _in(len) {
const r = await this.device.transferIn(proto.READ_EP, len);
if (r.status !== "ok") throw new Error(`USB IN ${r.status}`);
return new Uint8Array(r.data.buffer, r.data.byteOffset, r.data.byteLength);
}
// GPIO direction is recorded here and applied on the next digitalWrite,
// exactly as libpinedio does (its set_pin_mode does not transmit on its own).
setPinMode(pin, output) {
if (output) this.dMode |= 1 << pin;
else this.dMode &= ~(1 << pin);
}
async digitalWrite(pin, value) {
if (value) this.dState |= 1 << pin;
else this.dState &= ~(1 << pin);
await this._out(proto.buildUioOut(this.dState, this.dMode));
}
async setCS(active) {
// active === "chip selected". On active-low NSS (the usual case) that means
// driving the CS line LOW. libpinedio's upstream AUTO_CS drove it HIGH,
// which leaves the radio deselected on these adapters (MISO reads 0xFF).
const level = this.csActiveLow ? (active ? 0 : 1) : active ? 1 : 0;
return this.digitalWrite(this.csPin, level);
}
async digitalRead(pin) {
__dbg && __dbg(`dR D${pin} start`);
await this._out(proto.buildGetInput());
const reply = await this._in(6);
const v = proto.inputPin(reply, pin);
__dbg && __dbg(`dR D${pin}=${v} (in ${reply.length}B)`);
return v;
}
// Full-duplex SPI transfer: returns a Uint8Array of the same length as the
// write buffer (MISO sampled for every byte clocked out).
async transceive(writeBytes) {
const data =
writeBytes instanceof Uint8Array
? writeBytes
: Uint8Array.from(writeBytes);
__dbg && __dbg(`tx ${data.length}B out=${__hex(data)} start`);
if (this.autoCS) await this.setCS(true);
try {
const packets = proto.buildSpiStreamPackets(data);
const read = new Uint8Array(data.length);
let off = 0;
for (const pkt of packets) {
const dataLen = pkt.length - 1;
await this._out(pkt);
// WebUSB transferIn can return FEWER bytes than requested; accumulate
// until we have all dataLen MISO bytes, or the device returns nothing.
// Treating a short read's missing bytes as 0 corrupts the SPI response
// and puts the radio in a bad state (intermittent hangs during init).
let got = 0;
while (got < dataLen) {
const r = await this._in(dataLen - got);
// A zero-length read means the device gave us nothing while MISO bytes
// were still outstanding. Leaving them 0 would silently corrupt the SPI
// response, so fail loudly instead of returning a partial buffer.
if (r.length === 0)
throw new Error(
`CH341 SPI short read: ${got}/${dataLen} MISO bytes (device returned 0)`,
);
for (let i = 0; i < r.length; i++)
read[off + got + i] = proto.reverseByte(r[i]);
got += r.length;
}
off += dataLen;
}
__dbg && __dbg(`tx ${data.length}B in=${__hex(read)} done`);
return read;
} finally {
if (this.autoCS) await this.setCS(false);
}
}
// Convenience: write-then-read with CS held across the whole exchange (used
// for register reads where the read phase follows command+address bytes).
async writeRead(writeBytes, readLen) {
const tx = new Uint8Array(writeBytes.length + readLen);
tx.set(writeBytes, 0); // read phase clocks 0x00
const rx = await this.transceive(tx);
return rx.slice(writeBytes.length);
}
}
@@ -0,0 +1,96 @@
// CH341 USB-to-SPI wire protocol — pure framing functions, no WebUSB/DOM.
//
// Faithful port of the framing in libch341-spi-userspace (libpinedio-usb.c):
// - SPI is streamed with command 0xA8; each USB packet is <=32 bytes
// (1 command byte + up to 31 data bytes). The CH341 is bit-reversed on the
// wire, so every SPI byte (TX and RX) is bit-reversed.
// - GPIO (D0..D7) is driven with UIO stream command 0xAB. D0 is wired to CS.
// - Inputs are read with the 0xA0 status command.
//
// These functions are deliberately side-effect free so they can be unit-tested
// under node without any hardware, and reused verbatim by both the browser
// driver (src/ch341.js) and the wasm C backend's JS glue.
export const VID = 0x1a86;
export const PID = 0x5512;
// WebUSB endpoint *numbers* (direction is implied by transferIn/transferOut).
// libpinedio uses EP 0x02 (OUT) and 0x82 (IN) — both endpoint number 2.
export const WRITE_EP = 2;
export const READ_EP = 2;
export const CMD_SPI_STREAM = 0xa8;
export const CMD_UIO_STREAM = 0xab;
export const UIO_STM_OUT = 0x80;
export const UIO_STM_DIR = 0x40;
export const UIO_STM_END = 0x20;
export const CMD_GET_STATUS = 0xa0;
export const PACKET_LENGTH = 0x20; // 32
export const DATA_PER_PACKET = PACKET_LENGTH - 1; // 31
// Reverse the bit order of a byte (CH341 clocks SPI MSB/LSB swapped).
export function reverseByte(x) {
x &= 0xff;
x = ((x >> 1) & 0x55) | ((x << 1) & 0xaa);
x = ((x >> 2) & 0x33) | ((x << 2) & 0xcc);
x = ((x >> 4) & 0x0f) | ((x << 4) & 0xf0);
return x & 0xff;
}
// Split an SPI write buffer into bit-reversed 0xA8 stream packets.
// Returns an array of Uint8Array, each [0xA8, rev(b0), rev(b1), ...] with at
// most 31 data bytes. The number of SPI data bytes equals writeBytes.length
// (1:1 full-duplex), so the expected total read length is writeBytes.length.
export function buildSpiStreamPackets(writeBytes) {
const data =
writeBytes instanceof Uint8Array ? writeBytes : Uint8Array.from(writeBytes);
const packets = [];
for (let off = 0; off < data.length; off += DATA_PER_PACKET) {
const n = Math.min(DATA_PER_PACKET, data.length - off);
const pkt = new Uint8Array(1 + n);
pkt[0] = CMD_SPI_STREAM;
for (let i = 0; i < n; i++) pkt[1 + i] = reverseByte(data[off + i]);
packets.push(pkt);
}
return packets;
}
// Un-reverse a buffer of bytes read back from the device (in place on a copy).
export function unreverseBytes(bytes) {
const out = new Uint8Array(bytes.length);
for (let i = 0; i < bytes.length; i++) out[i] = reverseByte(bytes[i]);
return out;
}
// UIO packet that drives output levels (D0..D5) and sets direction.
// Mirrors pinedio_digital_write: [0xAB, 0x80|state, 0x40|dir, 0x20].
// state/dir are masked to 6 bits so they don't collide with the command bits.
export function buildUioOut(stateBits, modeBits) {
return new Uint8Array([
CMD_UIO_STREAM,
UIO_STM_OUT | (stateBits & 0x3f),
UIO_STM_DIR | (modeBits & 0x3f),
UIO_STM_END,
]);
}
// UIO packet that sets direction only. Mirrors pinedio_set_pin_mode:
// [0xAB, 0x40|dir, 0x20].
export function buildUioDir(modeBits) {
return new Uint8Array([
CMD_UIO_STREAM,
UIO_STM_DIR | (modeBits & 0x3f),
UIO_STM_END,
]);
}
// Status/input request. Device replies with up to 6 bytes; D0..D7 live in [0].
export function buildGetInput() {
return new Uint8Array([CMD_GET_STATUS]);
}
// Read a single D0..D7 input level from a status reply (byte 0 holds D0..D7).
export function inputPin(statusReply, pin) {
return (statusReply[0] >> pin) & 1;
}
@@ -0,0 +1,172 @@
// libpinedio API implemented over WebUSB (Emscripten), replacing the libusb
// backend for the wasm build. Each SPI/GPIO operation forwards to a JS bridge
// (Module.ch341, see wasm/bridge.js) that owns the actual USBDevice and reuses
// the framing in src/protocol.js.
//
// KEY DESIGN POINTS
// * EM_ASYNC_JS makes a *synchronous-looking* C call await a WebUSB Promise.
// This requires linking with Asyncify (or JSPI). One async suspend per SPI
// transfer (not per packet) keeps the Asyncify cost down.
// * The "single outstanding USB op" invariant is automatic: every C call here
// awaits a complete JS operation before returning, and the node is
// single-threaded-cooperative, so no transfer overlaps another.
// * attachInterrupt does NOT spawn a thread (the upstream lib polls a pthread
// over USB). We record the callback only and rely on the firmware's
// pollMissedIrqs()/IRQ-flag polling from the main loop instead.
// * After any `await`, the wasm heap may have grown (ALLOW_MEMORY_GROWTH), so
// the JS bridge MUST re-read Module.HEAPU8 when writing results back.
#include "libpinedio-usb.h"
#include <emscripten.h>
#include <string.h>
// ---- JS bridge imports ------------------------------------------------------
// Async (suspend) operations:
EM_ASYNC_JS(int, webusb_open, (int vid, int pid, int serialPtr), {
const serial = serialPtr ? UTF8ToString(serialPtr) : "";
return await Module.ch341.open(vid, pid, serial);
});
EM_ASYNC_JS(int, webusb_transceive, (int writePtr, int readPtr, int count),
{ return await Module.ch341.transceive(writePtr, readPtr, count); });
EM_ASYNC_JS(int, webusb_digital_write, (int pin, int value), { return await Module.ch341.digitalWrite(pin, value); });
EM_ASYNC_JS(int, webusb_digital_read, (int pin), { return await Module.ch341.digitalRead(pin); });
EM_ASYNC_JS(void, webusb_close, (void), {
if (Module.ch341)
await Module.ch341.close();
});
// Synchronous (no USB) operations:
EM_JS(void, webusb_set_pin_mode, (int pin, int output), { Module.ch341.setPinMode(pin, !!output); });
EM_JS(void, webusb_set_auto_cs, (int enabled), { Module.ch341.setAutoCS(!!enabled); });
EM_JS(void, webusb_get_serial, (int ptr, int max), { Module.ch341.getSerial(ptr, max); });
EM_JS(void, webusb_get_product, (int ptr, int max), { Module.ch341.getProduct(ptr, max); });
// ---- libpinedio API ---------------------------------------------------------
int32_t pinedio_set_option(struct pinedio_inst *inst, enum pinedio_option option, uint32_t value)
{
if (option < PINEDIO_OPTION_MAX)
inst->options[option] = value;
if (option == PINEDIO_OPTION_AUTO_CS)
webusb_set_auto_cs((int)value);
return 0;
}
int32_t pinedio_init(struct pinedio_inst *inst, void *driver)
{
(void)driver;
inst->in_error = false;
for (int i = 0; i < PINEDIO_INT_PIN_MAX; i++)
inst->interrupts[i].callback = NULL;
uint32_t vid = inst->options[PINEDIO_OPTION_VID] ? inst->options[PINEDIO_OPTION_VID] : 0x1A86;
uint32_t pid = inst->options[PINEDIO_OPTION_PID] ? inst->options[PINEDIO_OPTION_PID] : 0x5512;
int serialPtr = inst->options[PINEDIO_OPTION_SEARCH_SERIAL] ? (int)inst->serial_number : 0;
int ret = webusb_open((int)vid, (int)pid, serialPtr);
if (ret != 0) {
inst->in_error = true;
return ret < 0 ? ret : -2;
}
// Honor the configured Auto-CS. Ch341Hal sets PINEDIO_OPTION_AUTO_CS=0 (CS is
// left to RadioLib's NSS, which drives it active-low correctly); it has not
// been set yet at init, so this defaults off until Ch341Hal applies it.
webusb_set_auto_cs(inst->options[PINEDIO_OPTION_AUTO_CS] ? 1 : 0);
webusb_get_serial((int)inst->serial_number, sizeof(inst->serial_number));
webusb_get_product((int)inst->product_string, sizeof(inst->product_string));
return 0;
}
int32_t pinedio_set_pin_mode(struct pinedio_inst *inst, uint32_t pin, uint32_t mode)
{
(void)inst;
webusb_set_pin_mode((int)pin, (int)mode);
return 0;
}
int32_t pinedio_digital_write(struct pinedio_inst *inst, uint32_t pin, bool active)
{
int ret = webusb_digital_write((int)pin, active ? 1 : 0);
if (ret < 0)
inst->in_error = true;
return ret;
}
int32_t pinedio_set_cs(struct pinedio_inst *inst, bool active)
{
return pinedio_digital_write(inst, 0, active); // D0 is CS
}
int32_t pinedio_transceive(struct pinedio_inst *inst, uint8_t *write_buf, uint8_t *read_buf, uint32_t count)
{
int ret = webusb_transceive((int)write_buf, (int)read_buf, (int)count);
if (ret < 0) {
inst->in_error = true;
return -1;
}
return 0;
}
int32_t pinedio_write_read(struct pinedio_inst *inst, uint8_t *writearr, uint32_t writecnt, uint8_t *readarr, uint32_t readcnt)
{
// Not on Ch341Hal's hot path; emulate with a single full-duplex transfer.
uint32_t total = writecnt + readcnt;
uint8_t buf[total]; // VLA; transfers here are small (register reads)
memcpy(buf, writearr, writecnt);
memset(buf + writecnt, 0, readcnt);
int ret = webusb_transceive((int)buf, (int)buf, (int)total);
if (ret < 0) {
inst->in_error = true;
return -1;
}
memcpy(readarr, buf + writecnt, readcnt);
return 0;
}
int32_t pinedio_digital_read(struct pinedio_inst *inst, uint32_t pin)
{
int ret = webusb_digital_read((int)pin);
if (ret < 0) {
inst->in_error = true;
return ret;
}
return ret;
}
int32_t pinedio_get_irq_state(struct pinedio_inst *inst, uint32_t pin)
{
return pinedio_digital_read(inst, pin);
}
// No poll thread: record the callback so enable/disableInterrupt bookkeeping in
// RadioLib works; actual RX/TX detection is by polling IRQ flags in the loop.
int32_t pinedio_attach_interrupt(struct pinedio_inst *inst, enum pinedio_int_pin int_pin, enum pinedio_int_mode int_mode,
void (*callback)(void))
{
if (int_pin >= PINEDIO_INT_PIN_MAX)
return -1;
inst->interrupts[int_pin].previous_state = 255;
inst->interrupts[int_pin].mode = int_mode;
inst->interrupts[int_pin].callback = callback;
return 0;
}
int32_t pinedio_deattach_interrupt(struct pinedio_inst *inst, enum pinedio_int_pin int_pin)
{
if (int_pin >= PINEDIO_INT_PIN_MAX)
return -1;
inst->interrupts[int_pin].callback = NULL;
return 0;
}
void pinedio_deinit(struct pinedio_inst *inst)
{
webusb_close();
inst->in_error = false;
}
@@ -0,0 +1,426 @@
// WASM-side portduino glue (ARCH_PORTDUINO_WASM). Replaces the Linux YAML/
// filesystem config path with a CH341 setup (a MeshToad by default, or whatever
// the JS host pre-sets via the wasm_set_lora_* setters), mounts a persistent FS
// (IDBFS in the browser / NODEFS headless), resolves a per-node MAC, and bridges
// the firmware's PhoneAPI to JS (wasm_api_*). Also supplies
// delay()/yield()->emscripten_sleep and the other framework symbols normally
// provided by the excluded linux/LinuxCommon.cpp.
//
// Wiring in the firmware tree (already in place, all #ifdef ARCH_PORTDUINO_WASM):
// - PortduinoGlue.cpp portduinoSetup(): calls wasm_config_apply() and
// constructs the WebUSB-backed Ch341Hal, then returns before the YAML path.
// - exec() short-circuits to "" (no popen/shell in the browser).
// Downstream is unchanged: Ch341Hal -> libpinedio_webusb.c -> WebUSB.
#include "CryptoEngine.h" // crypto->ensurePkiKeys()
#include "MeshRadio.h" // initRegion()
#include "MeshService.h" // service->reloadConfig()
#include "NodeDB.h" // config, owner globals + SEGMENT_CONFIG
#include "PhoneAPI.h" // the transport-agnostic client API seam
#include "PortduinoFS.h" // portduinoVFS
#include "PortduinoGlue.h" // declares `portduino_config` + Ch341Hal
#include "RadioInterface.h" // RadioInterface::validateConfig*, instance
#include <cstdio>
#include <cstring>
#include <emscripten.h>
#include <string>
#include <sys/stat.h>
// Ask the JS host to persist the emscripten FS to its backing store. In the
// browser the /meshdata mount is IDBFS, so this flushes MEMFS->IndexedDB
// (async; fire-and-forget). Under headless node /meshdata is NODEFS (writes are
// already synchronous on the host fs) so syncfs is a harmless no-op there.
extern "C" EMSCRIPTEN_KEEPALIVE void wasm_fs_sync()
{
// Coalesce: IDBFS syncfs is async, and overlapping syncs warn "2 FS.syncfs
// operations in flight". Never run two at once — if one is in flight, mark a
// pending re-sync and let the running one chain it when it finishes.
// NOTE: loose != / == and string ops only — clang-format mangles !== and
// /regex/ literals inside EM_ASM JS (splitting them into invalid tokens).
EM_ASM({
try {
if (typeof FS == "undefined" || !FS.syncfs)
return;
if (Module.__fsSyncing) {
Module.__fsSyncPending = true;
return;
}
var run = function()
{
Module.__fsSyncing = true;
Module.__fsSyncPending = false;
FS.syncfs(
false, function(err) {
Module.__fsSyncing = false;
if (err)
console.warn("syncfs:", err);
if (Module.__fsSyncPending)
run();
});
};
run();
} catch (e) {
console.warn("syncfs threw:", e);
}
});
}
// Point the portduino VFS at /meshdata so NodeDB/config saves succeed (the
// framework main.cpp we replaced normally does this; without it every save fails
// with "File system is not mounted"). The JS host has already FS.mount'ed an
// IDBFS (browser) or NODEFS (headless) backend at /meshdata for real persistence
// (see web/fs-setup.js); here we just ensure the subtree exists and set the root.
void wasm_fs_mount()
{
mkdir("/meshdata", 0777);
mkdir("/meshdata/prefs", 0777);
mkdir("/meshdata/oem", 0777);
portduinoVFS->mountpoint("/meshdata");
}
// Resolve a per-instance MAC address (12 uppercase hex chars, no colons — the
// MAC_from_string format getMacAddr() expects). The lower 4 bytes become the
// 32-bit NodeNum (pickNewNodeNum: mac[2..5]), so this is the node's identity on
// the mesh — every browser node MUST get a distinct one or they collide on the
// same NodeNum. Priority:
// 1. MESH_MAC env (headless determinism / parity tests, e.g. DEAD00C0FFEE),
// 2. a value persisted in the /meshdata tree (survives reload/restart),
// 3. a freshly generated locally-administered random MAC, which we persist.
// Runs inside wasm_config_apply() (called by portduinoSetup, before setup()'s
// pickNewNodeNum), and /meshdata is already JS-mounted + populated by then.
static std::string wasm_resolve_mac()
{
// 1) explicit override (works in node via process.env; ignored in browser).
char env[32] = {0};
EM_ASM(
{
try {
var m = (typeof process != "undefined" && process.env && process.env.MESH_MAC) || "";
stringToUTF8(String(m).split(":").join(""), $0, 32);
} catch (e) {
}
},
env);
if (strlen(env) >= 12)
return std::string(env).substr(0, 12);
// 2) persisted identity (raw POSIX path, independent of portduinoVFS mountpoint).
if (FILE *f = fopen("/meshdata/oem/mac", "rb")) {
char buf[13] = {0};
size_t n = fread(buf, 1, 12, f);
fclose(f);
if (n == 12)
return std::string(buf, 12);
}
// 3) generate a locally-administered, unicast MAC and persist it.
unsigned char m[6] = {0};
EM_ASM(
{
try {
var c = (typeof crypto != "undefined" && crypto.getRandomValues) ? crypto : null;
if (c)
c.getRandomValues(HEAPU8.subarray($0, $0 + 6));
else
for (var i = 0; i < 6; i++)
HEAPU8[$0 + i] = (Math.random() * 256) | 0;
} catch (e) {
for (var j = 0; j < 6; j++)
HEAPU8[$0 + j] = (Math.random() * 256) | 0;
}
},
m);
m[0] = (m[0] | 0x02) & 0xFE; // locally administered (bit1=1), unicast (bit0=0)
char hex[13];
snprintf(hex, sizeof(hex), "%02X%02X%02X%02X%02X%02X", m[0], m[1], m[2], m[3], m[4], m[5]);
if (FILE *f = fopen("/meshdata/oem/mac", "wb")) {
fwrite(hex, 1, 12, f);
fclose(f);
wasm_fs_sync(); // browser: push the new identity to IndexedDB
}
return std::string(hex, 12);
}
// ---- Per-adapter config setters --------------------------------------------
// JS may call these BEFORE wasm_setup() to drive a non-MeshToad CH341 LoRa
// adapter. wasm_set_lora_module is the trigger: left unset (use_simradio, the
// struct default), wasm_config_apply() falls back to the MeshToad defaults.
extern "C" EMSCRIPTEN_KEEPALIVE void wasm_set_lora_module(int module_enum)
{
portduino_config.lora_module = (lora_module_enum)module_enum;
}
extern "C" EMSCRIPTEN_KEEPALIVE void wasm_set_lora_usb_ids(int vid, int pid)
{
portduino_config.lora_usb_vid = vid;
portduino_config.lora_usb_pid = pid;
}
extern "C" EMSCRIPTEN_KEEPALIVE void wasm_set_lora_usb_serial(const char *serial)
{
portduino_config.lora_usb_serial_num = serial ? std::string(serial) : "";
}
extern "C" EMSCRIPTEN_KEEPALIVE void wasm_set_lora_dio_config(int dio2_as_rf_switch, int dio3_tcxo_mv)
{
portduino_config.dio2_as_rf_switch = (dio2_as_rf_switch != 0);
portduino_config.dio3_tcxo_voltage = dio3_tcxo_mv;
}
extern "C" EMSCRIPTEN_KEEPALIVE void wasm_set_lora_spi_speed(int hz)
{
portduino_config.spiSpeed = hz;
}
// Pin name -> the matching portduino_config field. Sets .pin + .enabled to match
// the default path (the CH341 backend addresses by D-line number = .pin).
extern "C" EMSCRIPTEN_KEEPALIVE void wasm_set_lora_pin(const char *name, int pin)
{
if (!name)
return;
std::string n(name);
pinMapping *t = nullptr;
if (n == "CS")
t = &portduino_config.lora_cs_pin;
else if (n == "IRQ")
t = &portduino_config.lora_irq_pin;
else if (n == "BUSY")
t = &portduino_config.lora_busy_pin;
else if (n == "RESET")
t = &portduino_config.lora_reset_pin;
else if (n == "RXEN")
t = &portduino_config.lora_rxen_pin;
else if (n == "TXEN")
t = &portduino_config.lora_txen_pin;
else if (n == "ANT_SW")
t = &portduino_config.lora_sx126x_ant_sw_pin;
if (t) {
t->pin = pin;
t->enabled = (pin != (int)RADIOLIB_NC);
}
}
// LoRa adapter config. A MeshToad (E22/SX1262 over CH341) by default, or whatever
// the JS host pre-set via the wasm_set_lora_* setters before wasm_setup(). The
// browser/headless invariants (CH341 SPI dev, no screen/GPS, MAC) always apply.
// C++ linkage to match the `extern void wasm_config_apply();` decl in PortduinoGlue.cpp.
void wasm_config_apply()
{
if (portduino_config.lora_module == use_simradio) {
// Nothing pre-set by JS -> MeshToad E22/SX1262 defaults.
portduino_config.lora_module = use_sx1262;
portduino_config.lora_usb_vid = 0x1A86;
portduino_config.lora_usb_pid = 0x5512;
portduino_config.lora_usb_serial_num = ""; // first matching device
portduino_config.dio2_as_rf_switch = true; // E22 uses DIO2 as the TX/RX switch
portduino_config.dio3_tcxo_voltage = 1800; // 1.8 V TCXO
portduino_config.spiSpeed = 2000000;
// MeshToad CH341 D-line pin map (bin/config.d/lora-usb-meshtoad-e22.yaml).
auto setPin = [](pinMapping &p, int n) {
p.pin = n;
p.enabled = true;
};
setPin(portduino_config.lora_cs_pin, 0); // CS = D0
setPin(portduino_config.lora_irq_pin, 6); // IRQ = D6
setPin(portduino_config.lora_busy_pin, 4); // BUSY = D4
setPin(portduino_config.lora_reset_pin, 2); // RESET = D2
setPin(portduino_config.lora_rxen_pin, 1); // RXen = D1
}
portduino_config.lora_spi_dev = "ch341"; // every adapter here is WebUSB/CH341
portduino_config.displayPanel = no_screen;
portduino_config.has_gps = false;
portduino_config.MaxNodes = 80; // small DB for the browser
// Per-instance unique MAC (persisted in /meshdata, env-overridable). The lower
// 4 bytes become this node's NodeNum; also short-circuits getMacAddr()'s popen.
portduino_config.mac_address = wasm_resolve_mac();
}
// Set the LoRa region at runtime from the UI (browser <select>) or headless
// (MESH_REGION env, applied by run-node.mjs). Mirrors AdminModule's set_config
// (lora) region path exactly (AdminModule.cpp:904-937): validate -> first-region
// keygen + enable tx -> initRegion() -> reloadConfig (resetRadioConfig +
// reconfigure observer recomputes the carrier freq + saveToDisk). A region change
// is reconfigure-only — no reboot. Returns 0 on success, -1 if validation fails.
// `region` is a meshtastic_Config_LoRaConfig_RegionCode enum value.
// 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
// or timer callback could re-enter a wasm_* entry point — starting a second
// Asyncify unwind ("async operation already in flight" abort) or clobbering shared
// PhoneAPI state. The host MUST call these only BETWEEN wasm_loop_once() ticks; the
// flag (set around setup()/loop() in portduino_main_wasm.cpp) lets the entry points
// reject a mid-tick call rather than corrupt/abort. The JS-side queue is the real
// fix — this is just the safety net the design otherwise lacked.
extern "C" volatile bool g_wasm_in_firmware = false;
extern "C" EMSCRIPTEN_KEEPALIVE int wasm_set_region(int region)
{
if (g_wasm_in_firmware)
return -2; // busy: re-entered mid-tick; call between wasm_loop_once() ticks
auto newRegion = (meshtastic_Config_LoRaConfig_RegionCode)region;
if (config.lora.region == newRegion)
return 0; // no-op
meshtastic_Config_LoRaConfig validated = config.lora;
validated.region = newRegion;
if (!(RadioInterface::validateConfigRegion(validated) && RadioInterface::validateConfigLora(validated)))
return -1;
bool wasUnset = (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET);
if (wasUnset && newRegion > meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
if (crypto)
crypto->ensurePkiKeys(config.security, owner); // first real region -> generate keys
#endif
validated.tx_enabled = true;
}
if (!wasUnset && newRegion == meshtastic_Config_LoRaConfig_RegionCode_UNSET)
validated.tx_enabled = false;
config.lora = validated;
initRegion(); // repoint myRegion at the new region table
if (service)
service->reloadConfig(SEGMENT_CONFIG); // reconfigure radio (new freq) + persist
wasm_fs_sync(); // browser: flush config.proto to IndexedDB
return 0;
}
// ---------------------------------------------------------------------------
// Client/phone API seam. The firmware's PhoneAPI is the transport-agnostic
// request/response state machine that the TCP/serial/BLE/HTTP servers all wrap;
// those servers are excluded here, so we expose PhoneAPI directly to JS instead.
// A JS-side transport (browser in-process, or a headless HTTP/TCP bridge) feeds
// ToRadio protobufs in via wasm_api_to_radio() and drains FromRadio protobufs
// out via wasm_api_from_radio() — exactly the unframed contract the device HTTP
// API uses. PhoneAPI is abstract on checkIsConnected(); in-process we're always
// connected. We poll available() rather than push from onNowHasData(), to keep
// all API calls OUT of the loop's Asyncify suspend (the JS pump drains only
// between wasm_loop_once() ticks — never re-entering wasm mid-SPI).
class WasmPhoneAPI : public PhoneAPI
{
public:
virtual bool checkIsConnected() override { return true; }
};
static WasmPhoneAPI *g_wasmPhone = nullptr;
// Lazily construct after MeshService exists (PhoneAPI's ctor observes
// service->fromNumChanged). The first API call happens post-setup(), so service
// is live by then — no wasm_setup() ordering change needed.
static WasmPhoneAPI *phone()
{
if (!g_wasmPhone && service)
g_wasmPhone = new WasmPhoneAPI();
return g_wasmPhone;
}
// JS -> device: hand one serialized ToRadio protobuf (UNFRAMED) to the node.
// The SDK's want_config_id, text messages, admin, etc. all arrive here. Returns
// 1 if accepted, 0 if rejected (e.g. per-portnum throttle — not a transport error).
extern "C" EMSCRIPTEN_KEEPALIVE int wasm_api_to_radio(const uint8_t *buf, size_t len)
{
if (g_wasm_in_firmware)
return 0; // busy: re-entered mid-tick; the host must drain between ticks
WasmPhoneAPI *p = phone();
return (p && p->handleToRadio((uint8_t *)buf, len)) ? 1 : 0;
}
// device -> JS: write ONE serialized FromRadio into out[0..max). Returns byte
// count, or 0 when nothing is ready this call. out must be >= 512.
extern "C" EMSCRIPTEN_KEEPALIVE int wasm_api_from_radio(uint8_t *out, size_t max)
{
if (g_wasm_in_firmware)
return 0; // busy: re-entered mid-tick; drain between ticks
WasmPhoneAPI *p = phone();
if (!p || max < 512 || !p->available())
return 0;
return (int)p->getFromRadio(out);
}
// Cheap readiness check (no 512B buffer needed) for the JS pump.
extern "C" EMSCRIPTEN_KEEPALIVE int wasm_api_available()
{
if (g_wasm_in_firmware)
return 0; // busy: re-entered mid-tick
WasmPhoneAPI *p = phone();
return (p && p->available()) ? 1 : 0;
}
// True once a config handshake is in progress / complete (state != NOTHING).
extern "C" EMSCRIPTEN_KEEPALIVE int wasm_api_is_connected()
{
WasmPhoneAPI *p = phone();
return (p && p->isConnected()) ? 1 : 0;
}
// delay()/yield() normally come from framework linux/LinuxCommon.cpp (excluded:
// it's POSIX/threads). We provide them via Asyncify's emscripten_sleep so
// init-time blocking yields to the browser event loop instead of freezing the
// tab — exactly the behavior we want at runtime. C++ linkage to match Arduino.h.
void delay(unsigned long ms)
{
emscripten_sleep(ms);
}
void yield(void)
{
emscripten_sleep(0);
}
// --- other framework symbols normally from linux/LinuxCommon.cpp (excluded) ---
#include <cstdlib>
long random(long howbig)
{
return howbig > 0 ? (::rand() % howbig) : 0;
}
long random(long howsmall, long howbig)
{
return howsmall >= howbig ? howsmall : howsmall + random(howbig - howsmall);
}
void randomSeed(unsigned long seed)
{
::srand((unsigned)seed);
}
// realHardware is defined by the framework's Linux hardware shims (now compiled in).
// Restart the node. There is no in-process restart in wasm, so we hand off to
// the JS host: a browser reloads the tab (NodeDB state survives via IDBFS, so it
// comes back as the same node); a headless host calls Module.onReboot() if it
// provided one (re-instantiate or exit as it sees fit), otherwise we just log —
// the caller (Power::reboot) already let modules persist via the reboot
// observers. NOTE: loose !=/== and double-quoted strings only — clang-format
// mangles !== and /regex/ literals inside EM_ASM JS into invalid runtime tokens.
void reboot()
{
EM_ASM({
try {
if (typeof Module != "undefined" && typeof Module.onReboot == "function") {
Module.onReboot();
return;
}
if (typeof location != "undefined" && location.reload) {
location.reload();
return;
}
if (typeof console != "undefined")
console.warn("[wasm] reboot requested but no Module.onReboot hook; node left running");
} catch (e) {
if (typeof console != "undefined")
console.warn("reboot hook threw:", e);
}
});
}
// tone()/noTone() — no buzzer in the browser (from linux/LinuxCommon.cpp, excluded).
void tone(unsigned char, unsigned int, unsigned long) {}
void noTone(unsigned char) {}
void delayMicroseconds(unsigned int us)
{
// No sub-ms sleep in the browser; yield for short waits, round up longer ones.
if (us >= 1000)
emscripten_sleep(us / 1000);
else
emscripten_sleep(0);
}
// graphics/Screen.cpp is excluded; TextMessageModule references this. No screen → never wake.
bool shouldWakeOnReceivedMessage()
{
return false;
}
// TCP phone/API server is excluded in the wasm build — no-op stubs so main.cpp links.
void initApiServer(int) {}
void deInitApiServer() {}
@@ -0,0 +1,67 @@
// WASM replacement for framework-portduino's cores/portduino/main.cpp.
// The original uses argp (CLI parse), ftw/nftw (FS walk) and an internal
// while(1){ loop(); usleep(loopDelay); } pump — none of which fit the browser.
//
// Here: run the firmware setup() once from main(), then RETURN. JavaScript
// drives the cooperative scheduler by calling wasm_loop_once() on a timer,
// using the delay (ms-until-next-task) the firmware itself computes. This keeps
// the Asyncify stack shallow (we suspend only inside WebUSB transfers, not
// across idle time) and lets JS pump WebUSB IRQ polling between ticks.
//
// firmware loop() already does: service->loop(); mainController.runOrDelay();
// RadioLibInterface::instance->pollMissedIrqs(); (src/main.cpp:1223+)
// so we just invoke it once per tick.
#include <cstdio>
#include <emscripten.h>
// Firmware entry points (Arduino-style), defined in src/main.cpp with C linkage.
extern "C" void setup();
extern "C" void loop();
// portduinoSetup() is the firmware's portduino init (PortduinoGlue.cpp). Under
// ARCH_PORTDUINO_WASM it applies the wasm config (wasm_config_apply) and creates
// the WebUSB-backed Ch341Hal, then returns (no YAML/filesystem). Must run before setup().
extern void portduinoSetup();
extern void wasm_fs_mount(); // points portduinoVFS at /meshdata (portduino_glue_wasm.cpp)
// Re-entrancy guard (defined in portduino_glue_wasm.cpp). True while the firmware
// is executing setup()/loop() — including while it is Asyncify-suspended inside a
// WebUSB transfer — so the wasm_* API/region entry points reject a mid-tick
// re-entry from JS instead of corrupting state or aborting Asyncify. The host is
// expected to call them only between ticks; this is the safety net.
extern "C" volatile bool g_wasm_in_firmware;
// Boot the node. Call from JS AFTER Module.ch341 (the WebUSB bridge) is wired up.
extern "C" EMSCRIPTEN_KEEPALIVE void wasm_setup()
{
g_wasm_in_firmware = true;
wasm_fs_mount();
portduinoSetup();
setup();
g_wasm_in_firmware = false;
}
// Called repeatedly by JS on a timer. Returns the firmware's requested delay in
// ms until it next wants to run (mainController.runOrDelay()'s value is consumed
// inside loop(); we return a conservative pump interval the JS side can cap).
//
// If you want the exact next-delay, expose it: have a small in-tree change make
// loop() stash mainController.runOrDelay() in a global, or just poll fast (e.g.
// JS setTimeout(_, 5)) — RX latency is bounded by pollMissedIrqs() each tick.
extern "C" EMSCRIPTEN_KEEPALIVE int wasm_loop_once()
{
g_wasm_in_firmware = true;
loop();
g_wasm_in_firmware = false;
return 5; // ms; JS caps the scheduling cadence
}
// INVOKE_RUN=0, so main is only run if JS calls callMain(). We keep it for
// parity with the harness: boot on main() when present.
int main()
{
// INVOKE_RUN=0: the page calls wasm_setup() after wiring up Module.ch341.
printf("[meshnode] wasm loaded — set Module.ch341, then call wasm_setup()\n");
return 0;
}
+78
View File
@@ -0,0 +1,78 @@
// Minimal stub of glibc <argp.h> for the wasm build.
//
// framework-portduino's Arduino.h includes <argp.h> unconditionally, and
// PortduinoGlue.cpp DEFINES argp option tables / a parse_opt callback — but
// nothing in the wasm build CALLS argp_parse (that lived in the framework
// main.cpp we replaced with portduino_main_wasm.cpp). So only the types and
// macros are needed to compile; no argp functions are linked.
#ifndef WASM_ARGP_STUB_H
#define WASM_ARGP_STUB_H
#include <errno.h>
#ifndef ARGP_ERR_UNKNOWN
#define ARGP_ERR_UNKNOWN E2BIG
#endif
#define ARGP_KEY_ARG 0
#define ARGP_KEY_INIT 0x1000002
#define ARGP_KEY_END 0x1000001
#define OPTION_ARG_OPTIONAL 0x1
#ifdef __cplusplus
extern "C" {
#endif
typedef int error_t;
struct argp_state {
const struct argp *argp;
int argc;
char **argv;
int next;
unsigned flags;
unsigned arg_num;
int quoted;
void *input;
void **child_inputs;
void *hook;
char *name;
void *err_stream;
void *out_stream;
void *pstate;
};
struct argp_option {
const char *name;
int key;
const char *arg;
int flags;
const char *doc;
int group;
};
typedef error_t (*argp_parser_t)(int key, char *arg, struct argp_state *state);
struct argp_child {
const struct argp *argp;
int flags;
const char *header;
int group;
};
struct argp {
const struct argp_option *options;
argp_parser_t parser;
const char *args_doc;
const char *doc;
const struct argp_child *children;
char *(*help_filter)(int key, const char *text, void *input);
const char *argp_domain;
};
extern const char *argp_program_version;
extern const char *argp_program_bug_address;
#ifdef __cplusplus
}
#endif
#endif // WASM_ARGP_STUB_H
@@ -0,0 +1,18 @@
// Stub for MeshPacketSerializer: the real impl (serialization/MeshPacketSerializer.cpp)
// needs jsoncpp (<json/json.h>) and is only used for MQTT JSON output, which the
// wasm build excludes. Router.cpp still references these symbols, so provide
// empty implementations to satisfy the link without pulling in jsoncpp.
#include "serialization/MeshPacketSerializer.h"
std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp, bool shouldLog)
{
(void)mp;
(void)shouldLog;
return "";
}
std::string MeshPacketSerializer::JsonSerializeEncrypted(const meshtastic_MeshPacket *mp)
{
(void)mp;
return "";
}
+5
View File
@@ -40,6 +40,11 @@ cstyleCast
*:.pio/* *:.pio/*
*:*/libdeps/* *:*/libdeps/*
*:*/generated/* *:*/generated/*
// emscripten-only wasm node: EM_ASYNC_JS/EM_JS expand to constructs cppcheck
// can't parse (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, not pio check). Don't analyze them here.
*:*/platform/portduino/wasm/*
noExplicitConstructor:*/mqtt/* noExplicitConstructor:*/mqtt/*
postfixOperator:*/mqtt/* postfixOperator:*/mqtt/*
+3 -2
View File
@@ -9,7 +9,8 @@ build_src_filter =
${env.build_src_filter} ${env.build_src_filter}
-<platform/> -<platform/>
+<platform/portduino> +<platform/portduino>
-<nimble/> -<platform/portduino/wasm/> ; emscripten-only sources, built by [env:native-wasm] (pio run -e native-wasm)
-<nimble/>
-<mesh/wifi/> -<mesh/wifi/>
-<mesh/http/> -<mesh/http/>
+<mesh/raspihttp/> +<mesh/raspihttp/>
@@ -22,7 +23,7 @@ lib_deps =
${networking_extra.lib_deps} ${networking_extra.lib_deps}
${radiolib_base.lib_deps} ${radiolib_base.lib_deps}
${environmental_base.lib_deps} ${environmental_base.lib_deps}
# renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master
https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
lovyan03/LovyanGFX@1.2.21 lovyan03/LovyanGFX@1.2.21
+108
View File
@@ -245,3 +245,111 @@ build_flags = ${env:native-macos.build_flags}
-fno-omit-frame-pointer -fno-omit-frame-pointer
build_src_filter = ${env:native-macos.build_src_filter} build_src_filter = ${env:native-macos.build_src_filter}
lib_ignore = ${env:native-macos.lib_ignore} lib_ignore = ${env:native-macos.lib_ignore}
; ---------------------------------------------------------------------------
; WASM (Emscripten) — the portduino node compiled to WebAssembly, driving a real
; LoRa radio over WebUSB through a CH341 (src/platform/portduino/wasm/). The same
; setup()/loop() firmware that runs native, but the radio HAL talks WebUSB and
; the cooperative loop suspends via Asyncify. Software/CI build target; the radio
; path itself is hardware (CH341/WebUSB, Chromium). See the README in that dir.
;
; Toolchain comes from the meshtastic/platform-wasm PlatformIO platform (emcc/
; em++). Prereq: an Emscripten SDK on PATH — `source <emsdk>/emsdk_env.sh` (or
; export EMSDK=<path>) — so the platform builder can locate emcc.
;
; Build: pio run -e native-wasm -> .pio/build/native-wasm/meshnode.{mjs,wasm}
; ---------------------------------------------------------------------------
[env:native-wasm]
platform =
# renovate: datasource=git-refs depName=platform-wasm packageName=https://github.com/meshtastic/platform-wasm gitBranch=master
https://github.com/meshtastic/platform-wasm/archive/7834113c8ee05bedc6af9c9cce7f515a1560e2c6.zip
framework = arduino
board = wasm
board_level = extra
; wasm-ld doesn't accept GNU ld's `-Wl,-Map,<file>` (inherited from [env]); strip
; it. The linker map isn't meaningful for the emcc/Asyncify output anyway.
build_unflags = -Wl,-Map,"${platformio.build_dir}"/output.map
build_flags = ${arduino_base.build_flags}
-I variants/native/portduino
-I src/platform/portduino
; FIRST: our <argp.h> stub shadows the missing glibc header
-I src/platform/portduino/wasm/stubs
-I src/platform/portduino/wasm/include
-I src/platform/portduino/wasm
-D ARCH_PORTDUINO
; ARCH_PORTDUINO_WASM gates every wasm guard in the firmware (the glue/main
; entry points, the single-threaded cooperative paths, region/RNG, etc.). The
; platform's board.json also injects it, but define it here too so the build's
; correctness never hinges on an out-of-repo board file.
-D ARCH_PORTDUINO_WASM
-DRADIOLIB_EEPROM_UNSUPPORTED
-fexceptions
; firmware uses gettimeofday(); the native toolchain pulls <sys/time.h> in
; transitively but emscripten doesn't, so force it.
-include sys/time.h
; Headless browser node — no screen / GPS / I2C / sensors / host services.
; Same exclusions the standalone emcc build used; gate out hardware/host code
; the tab can't run (the EXCLUDE_* defines also keep LDF from pulling the libs).
-DHAS_SCREEN=0
-DMESHTASTIC_EXCLUDE_SCREEN=1
-DMESHTASTIC_EXCLUDE_GPS=1 -DNO_GPS=1 -DNO_EXT_GPIO=1
-DMESHTASTIC_EXCLUDE_I2C=1
-DMESHTASTIC_EXCLUDE_ACCELEROMETER=1 -DMESHTASTIC_EXCLUDE_MAGNETOMETER=1
-DMESHTASTIC_EXCLUDE_AUDIO=1 -DMESHTASTIC_EXCLUDE_INPUTBROKER=1
-DMESHTASTIC_EXCLUDE_MQTT=1 -DMESHTASTIC_EXCLUDE_TZ=1
-DMESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR=1 -DMESHTASTIC_EXCLUDE_DETECTIONSENSOR=1
-DMESHTASTIC_EXCLUDE_EXTERNALNOTIFICATION=1 -DMESHTASTIC_EXCLUDE_CANNEDMESSAGES=1
-DMESHTASTIC_EXCLUDE_STOREFORWARD=1 -DMESHTASTIC_EXCLUDE_SERIAL=1
-DMESHTASTIC_EXCLUDE_PAXCOUNTER=1 -DMESHTASTIC_EXCLUDE_WAYPOINT=1
; The firmware-specific emcc *link* settings (exported fns, runtime methods, the
; WebUSB Asyncify import seam, the ES-module factory name) can't ride in
; build_flags — PlatformIO only feeds those to the compile step — so they're
; appended to LINKFLAGS by this post script. (The generic Asyncify/MODULARIZE/
; memory flags and emsdk auto-location both come from the platform-wasm builder.)
extra_scripts =
${env.extra_scripts}
post:extra_scripts/wasm_link_flags.py
; chain+ so the EXCLUDE_* / HAS_SCREEN defines gate sensor/screen #includes out
; of LDF (otherwise it would try to build host/incompatible sensor libs).
lib_ldf_mode = chain+
lib_deps =
${env.lib_deps}
${radiolib_base.lib_deps}
# renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master
https://github.com/meshtastic/Crypto/archive/1aa30eb536bd52a576fde6dfa393bf7349cf102d.zip
# renovate: datasource=custom.pio depName=Melopero RV3028 packageName=melopero/library/Melopero RV3028
melopero/Melopero RV3028@1.2.0
lib_ignore =
LovyanGFX
SD
; Curated source set — the proven standalone emcc build's file list expressed as
; a PlatformIO filter. Deny everything, then add exactly what the browser node
; links: the wasm cooperative-loop entry, the mesh stack (top-level + generated),
; concurrency, the portduino glue, and the non-esp32 modules (the EXCLUDE_*
; defines gate the unwanted module bodies). serialization/ stays excluded — the
; wasm stub replaces MeshPacketSerializer (jsoncpp is MQTT-only).
build_src_filter =
-<*>
+<main.cpp>
+<PowerFSM.cpp> +<Power.cpp> +<airtime.cpp> +<sleep.cpp>
+<RedirectablePrint.cpp> +<SerialConsole.cpp> +<Observer.cpp>
+<FSCommon.cpp> +<SafeFile.cpp> +<MessageStore.cpp> +<meshUtils.cpp>
+<memGet.cpp> +<GpioLogic.cpp> +<PowerMon.cpp> +<SPILock.cpp>
+<xmodem.cpp> +<DisplayFormatters.cpp>
+<detect/ScanI2C.cpp> +<detect/ScanI2CTwoWire.cpp> +<detect/ScanI2CConsumer.cpp>
+<power/PowerHAL.cpp>
+<gps/RTC.cpp>
+<buzz/buzz.cpp> +<buzz/BuzzerFeedbackThread.cpp>
+<mesh/*.cpp> +<mesh/*.c>
-<mesh/LR2021Interface.cpp> -<mesh/LR20x0Interface.cpp>
+<mesh/generated/>
+<concurrency/>
+<platform/portduino/PortduinoGlue.cpp> +<platform/portduino/SimRadio.cpp>
+<platform/portduino/wasm/>
+<modules/> -<modules/esp32/>