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:
co-authored by
GitHub
Austin
parent
25c71cb9f1
commit
0baf8b1db6
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -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__
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 "";
|
||||||
|
}
|
||||||
@@ -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/*
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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/>
|
||||||
|
|||||||
Reference in New Issue
Block a user