diff --git a/.github/workflows/build_portduino_wasm.yml b/.github/workflows/build_portduino_wasm.yml new file mode 100644 index 000000000..03bc2c1f2 --- /dev/null +++ b/.github/workflows/build_portduino_wasm.yml @@ -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 diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index e5c75d2cc..73420a716 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -146,6 +146,14 @@ jobs: checks: write 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: permissions: # Needed for pushing to GHCR. contents: read diff --git a/.gitignore b/.gitignore index 55e90a8f2..bee3fe4c3 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,11 @@ userPrefs.jsonc.mcp-session-bak # compiled .proto outputs are ephemeral build artifacts. build/fixtures/ 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/ diff --git a/.semgrepignore b/.semgrepignore index b4267ad23..fff35f6b7 100644 --- a/.semgrepignore +++ b/.semgrepignore @@ -1,2 +1,6 @@ .github/workflows/main_matrix.yml 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/ diff --git a/bin/config.d/lora-piggystick-lr1121.yaml b/bin/config.d/lora-piggystick-lr1121.yaml index e11c78dd3..d6b88198b 100644 --- a/bin/config.d/lora-piggystick-lr1121.yaml +++ b/bin/config.d/lora-piggystick-lr1121.yaml @@ -1,5 +1,5 @@ Meta: - name: Lora Meshstick SX1262 + name: PiggyStick LR1121 support: community compatible: - usb @@ -12,6 +12,6 @@ Lora: Busy: 4 spidev: ch341 DIO3_TCXO_VOLTAGE: 1.8 -# USB_Serialnum: 12345678 + # USB_Serialnum: 12345678 USB_PID: 0x5512 USB_VID: 0x1A86 diff --git a/extra_scripts/wasm_link_flags.py b/extra_scripts/wasm_link_flags.py new file mode 100644 index 000000000..4b7747364 --- /dev/null +++ b/extra_scripts/wasm_link_flags.py @@ -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", + ] + ) diff --git a/src/Power.cpp b/src/Power.cpp index 8104b53a6..31d98dba9 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -838,6 +838,14 @@ void Power::reboot() NVIC_SystemReset(); #elif defined(ARCH_RP2040) 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) deInitApiServer(); #ifdef __linux__ diff --git a/src/main.cpp b/src/main.cpp index 333db08cd..e76e071a1 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,4 +1,7 @@ #include "configuration.h" +#ifdef ARCH_PORTDUINO_WASM +#include +#endif #if !MESHTASTIC_EXCLUDE_GPS #include "GPS.h" #endif @@ -97,7 +100,9 @@ NRF54L15Bluetooth *nrf54l15Bluetooth = nullptr; #ifdef ARCH_PORTDUINO #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" +#endif #include "platform/portduino/PortduinoGlue.h" #include #include @@ -1110,10 +1115,12 @@ void setup() if (!rIf) RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_NO_RADIO); else { +#ifndef ARCH_PORTDUINO_WASM // Log bit rate to debug output LOG_DEBUG("LoRA bitrate = %f bytes / sec", (float(meshtastic_Constants_DATA_PAYLOAD_LEN) / (float(rIf->getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN)))) * 1000); +#endif router->addInterface(std::move(rIf)); } @@ -1400,7 +1407,16 @@ void loop() #ifdef DEBUG_LOOP_TIMING LOG_DEBUG("main loop delay: %d", delayMsec); #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); +#endif } } #endif diff --git a/src/mesh/HardwareRNG.cpp b/src/mesh/HardwareRNG.cpp index a34a9477c..ead66ac5f 100644 --- a/src/mesh/HardwareRNG.cpp +++ b/src/mesh/HardwareRNG.cpp @@ -128,6 +128,9 @@ bool fill(uint8_t *buffer, size_t length, bool useRadioEntropy) if (generated == static_cast(length)) { filled = true; } +#elif defined(__EMSCRIPTEN__) + // Browser/wasm: no getrandom/arc4random — fall through to std::random_device, + // which emscripten backs with crypto.getRandomValues(). #else // arc4random_buf is available on Darwin/BSD and cannot fail. ::arc4random_buf(buffer, length); diff --git a/src/mesh/InterfacesTemplates.cpp b/src/mesh/InterfacesTemplates.cpp index 6f6b2d2a1..6e27b56f3 100644 --- a/src/mesh/InterfacesTemplates.cpp +++ b/src/mesh/InterfacesTemplates.cpp @@ -8,8 +8,10 @@ #include "SX126xInterface.h" #include "SX128xInterface.cpp" #include "SX128xInterface.h" +#ifndef ARCH_PORTDUINO_WASM // TCP socket API server excluded in the browser/wasm build #include "api/ServerAPI.cpp" #include "api/ServerAPI.h" +#endif // We need this declaration for proper linking in derived classes #if RADIOLIB_EXCLUDE_SX126X != 1 diff --git a/src/mesh/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp index 9aefb7a8a..3f3dc0a23 100644 --- a/src/mesh/LR11x0Interface.cpp +++ b/src/mesh/LR11x0Interface.cpp @@ -371,7 +371,11 @@ template bool LR11x0Interface::sleep() template int16_t LR11x0Interface::getCurrentRSSI() { +#ifdef ARCH_PORTDUINO_WASM + float rssi = lora.getRSSI(); // installed RadioLib's LR11x0 getRSSI() is 0-arg +#else float rssi = lora.getRSSI(false, true); +#endif return (int16_t)round(rssi); } #endif diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index 920df6000..a2e6e4d79 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -328,10 +328,18 @@ template void SX126xInterface::startReceive() setTransmitEnable(false); 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. int err = lora.startReceiveDutyCycleAuto(preambleLength, 8, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS); + const char *rxMethod = "startReceiveDutyCycleAuto"; +#endif 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 if (err != RADIOLIB_ERR_NONE) portduino_status.LoRa_in_error = true; diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 2c72d83cb..301223a2f 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -232,6 +232,18 @@ void portduinoSetup() concurrency::hasBeenSetup = true; 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) { portduino_config.lora_module = use_simradio; } else if (configPath != nullptr) { @@ -275,10 +287,12 @@ void portduinoSetup() } } +#ifndef ARCH_PORTDUINO_WASM if (yamlOnly) { std::cout << portduino_config.emit_yaml() << std::endl; exit(EXIT_SUCCESS); } +#endif if (portduino_config.force_simradio) { std::cout << "Running in simulated mode." << std::endl; @@ -733,6 +747,16 @@ int initGPIOPin(int pinNum, const std::string &gpioChipName, int line) #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) { YAML::Node yamlConfig; @@ -1090,6 +1114,7 @@ bool loadConfig(const char *configPath) } return true; } +#endif // !ARCH_PORTDUINO_WASM // 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) @@ -1129,6 +1154,10 @@ bool MAC_from_string(std::string mac_str, uint8_t *dmac) std::string exec(const char *cmd) { // 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 buffer; std::string result; std::unique_ptr pipe(popen(cmd, "r"), pclose); @@ -1141,6 +1170,7 @@ std::string exec(const char *cmd) return result; } +#ifndef ARCH_PORTDUINO_WASM void readGPIOFromYaml(YAML::Node sourceNode, pinMapping &destPin, int pinDefault) { if (sourceNode.IsMap()) { @@ -1155,3 +1185,4 @@ void readGPIOFromYaml(YAML::Node sourceNode, pinMapping &destPin, int pinDefault destPin.gpiochip = portduino_config.lora_default_gpiochip; } } +#endif // !ARCH_PORTDUINO_WASM diff --git a/src/platform/portduino/PortduinoGlue.h b/src/platform/portduino/PortduinoGlue.h index b38cfca25..d13efd983 100644 --- a/src/platform/portduino/PortduinoGlue.h +++ b/src/platform/portduino/PortduinoGlue.h @@ -8,7 +8,12 @@ #include "LR11x0Interface.h" #include "Module.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" +#endif extern struct portduino_status_struct { 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); void getMacAddr(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); +#endif std::string exec(const char *cmd); extern struct portduino_config_struct { @@ -221,6 +228,7 @@ extern struct portduino_config_struct { &tbRightPin, &tbPressPin}; +#ifndef ARCH_PORTDUINO_WASM std::string emit_yaml() { YAML::Emitter out; @@ -569,4 +577,5 @@ extern struct portduino_config_struct { out << YAML::EndMap; // General return out.c_str(); } +#endif // !ARCH_PORTDUINO_WASM } portduino_config; diff --git a/src/platform/portduino/wasm/README.md b/src/platform/portduino/wasm/README.md new file mode 100644 index 000000000..c7549c676 --- /dev/null +++ b/src/platform/portduino/wasm/README.md @@ -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_env.sh` (or +`export EMSDK=`) 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 +``` diff --git a/src/platform/portduino/wasm/include/libpinedio-usb.h b/src/platform/portduino/wasm/include/libpinedio-usb.h new file mode 100644 index 000000000..419fecb25 --- /dev/null +++ b/src/platform/portduino/wasm/include/libpinedio-usb.h @@ -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 +#include + +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 diff --git a/src/platform/portduino/wasm/js/bridge.js b/src/platform/portduino/wasm/js/bridge.js new file mode 100644 index 000000000..cfbdf9332 --- /dev/null +++ b/src/platform/portduino/wasm/js/bridge.js @@ -0,0 +1,115 @@ +// JS side of the wasm WebUSB bridge. The C backend (libpinedio_webusb.c) calls +// Module.ch341. 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; + }, + }; +} diff --git a/src/platform/portduino/wasm/js/ch341.js b/src/platform/portduino/wasm/js/ch341.js new file mode 100644 index 000000000..f4da3843f --- /dev/null +++ b/src/platform/portduino/wasm/js/ch341.js @@ -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); + } +} diff --git a/src/platform/portduino/wasm/js/protocol.js b/src/platform/portduino/wasm/js/protocol.js new file mode 100644 index 000000000..01f1e7396 --- /dev/null +++ b/src/platform/portduino/wasm/js/protocol.js @@ -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; +} diff --git a/src/platform/portduino/wasm/libpinedio_webusb.c b/src/platform/portduino/wasm/libpinedio_webusb.c new file mode 100644 index 000000000..c095e1828 --- /dev/null +++ b/src/platform/portduino/wasm/libpinedio_webusb.c @@ -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 +#include + +// ---- 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; +} diff --git a/src/platform/portduino/wasm/portduino_glue_wasm.cpp b/src/platform/portduino/wasm/portduino_glue_wasm.cpp new file mode 100644 index 000000000..01b02e924 --- /dev/null +++ b/src/platform/portduino/wasm/portduino_glue_wasm.cpp @@ -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 +#include +#include +#include +#include + +// 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