diff --git a/.gitignore b/.gitignore
index c307d216a..4cbff3a05 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,5 @@ _wasi_sdk_fetch
# MkDocs
/site/
+
+output/
diff --git a/radio/src/gui/gui_common.h b/radio/src/gui/gui_common.h
index b51d456db..4b49466e3 100644
--- a/radio/src/gui/gui_common.h
+++ b/radio/src/gui/gui_common.h
@@ -54,6 +54,7 @@ enum SwitchContext
int getFirstAvailable(int min, int max, IsValueAvailable isValueAvailable);
bool isChannelUsed(int channel);
+int getChannelsUsed();
bool checkSourceAvailable(int source, uint32_t sourceTypes);
bool checkSwitchAvailable(int swtch, uint32_t swtchTypes);
bool isInputAvailable(int input);
diff --git a/radio/src/storage/sdcard_common.cpp b/radio/src/storage/sdcard_common.cpp
index e73aad32d..068ccacd5 100644
--- a/radio/src/storage/sdcard_common.cpp
+++ b/radio/src/storage/sdcard_common.cpp
@@ -27,6 +27,10 @@
#include "hal/abnormal_reboot.h"
+#if defined(SIMU)
+extern bool simuCreateDefaultSettings;
+#endif
+
#if defined(COLORLCD)
#include "layout.h"
#include "theme_manager.h"
@@ -211,6 +215,18 @@ void storageReadAll()
g_eeGeneral.modelCustomScriptsDisabled = true;
if (loadRadioSettings() != nullptr) {
+#if defined(SIMU)
+ if (simuCreateDefaultSettings) {
+ // Web simulator first run: silently create defaults without
+ // STORAGE WARNING alert or calibration screen.
+ simuCreateDefaultSettings = false;
+ storageFormat();
+ g_eeGeneral.chkSum = evalChkSum();
+ storageDirty(EE_GENERAL);
+ storageDirty(EE_MODEL);
+ storageCheck(true);
+ } else
+#endif
storageEraseAll(true);
}
#if !defined(STORAGE_MODELSLIST)
diff --git a/radio/src/targets/simu/simulib.cpp b/radio/src/targets/simu/simulib.cpp
index 68578b670..8e2a05616 100644
--- a/radio/src/targets/simu/simulib.cpp
+++ b/radio/src/targets/simu/simulib.cpp
@@ -33,6 +33,8 @@
#include "debug.h"
#include "switches.h"
#include "input_mapping.h"
+#include "gui/gui_common.h"
+#include "mixes.h"
#if defined(GVARS)
#include "gvars.h"
#endif
@@ -54,6 +56,7 @@ char * main_thread_error = nullptr;
bool simu_shutdown = false;
bool simu_running = false;
+bool simuCreateDefaultSettings = false;
volatile rotenc_t rotencValue = 0;
@@ -115,6 +118,11 @@ static void* bootloaderThread(void*)
}
#endif
+void simuCreateDefaults()
+{
+ simuCreateDefaultSettings = true;
+}
+
void simuStart(bool tests)
{
if (simu_running)
@@ -759,6 +767,21 @@ uint8_t simuCopyMixOutputs(int16_t* buf, uint8_t maxCount)
return n;
}
+bool simuIsChannelUsed(uint8_t channel)
+{
+ return isChannelUsed(channel);
+}
+
+int simuGetChannelsUsed()
+{
+ return getChannelsUsed();
+}
+
+uint8_t simuGetMixCount()
+{
+ return getMixCount();
+}
+
uint8_t simuGetNumLogicalSwitches()
{
return MAX_LOGICAL_SWITCHES;
diff --git a/radio/src/targets/simu/simulib.h b/radio/src/targets/simu/simulib.h
index bbce5b426..ccebd0a23 100644
--- a/radio/src/targets/simu/simulib.h
+++ b/radio/src/targets/simu/simulib.h
@@ -123,6 +123,11 @@ uint8_t WASM_EXPORT(simuGetNumChannels)();
uint8_t WASM_EXPORT(simuCopyChannelOutputs)(int16_t* buf, uint8_t maxCount);
uint8_t WASM_EXPORT(simuCopyMixOutputs)(int16_t* buf, uint8_t maxCount);
+// Channel/mixer queries.
+bool WASM_EXPORT(simuIsChannelUsed)(uint8_t channel);
+int WASM_EXPORT(simuGetChannelsUsed)();
+uint8_t WASM_EXPORT(simuGetMixCount)();
+
// Bulk copy logical switch states into buf (uint8_t[], 0 or 1). Returns count.
uint8_t WASM_EXPORT(simuGetNumLogicalSwitches)();
uint8_t WASM_EXPORT(simuCopyLogicalSwitches)(uint8_t* buf, uint8_t maxCount);
@@ -162,6 +167,14 @@ void WASM_IMPORT(simuTrace)(const char* text);
// listener so the frame can be rendered without polling.
void WASM_IMPORT(simuLcdNotify)();
+// First-run helper: request default radio.yml + model creation if
+// no settings file exists. Call before simuStart(). The actual file
+// I/O runs inside storageReadAll() on a worker thread.
+void WASM_EXPORT(simuCreateDefaults)();
+
+// Flag checked by storageReadAll() to silently create defaults.
+extern bool simuCreateDefaultSettings;
+
// Aux serial bridge (firmware -> host). port_nr is 0 for AUX1, 1 for AUX2.
// encoding values match SimulatorSerialEncoding (0=8N1, 1=8E2, 2=PXX1_PWM)
// and ETX_Encoding_* — they share the same numeric values. Called when the
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 000000000..2cb9fdea2
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,4 @@
+node_modules
+dist
+.vite
+public/*.wasm
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 000000000..c0e516616
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,145 @@
+# EdgeTX Web Simulator
+
+Browser-based EdgeTX radio simulator using WebAssembly + WASI threads.
+
+## Prerequisites
+
+- Node.js 20+
+- Pre-built `.wasm` modules in `public/` (see [Building WASM Modules](#building-wasm-modules))
+
+## Quick Start
+
+```bash
+npm install
+npm run dev
+```
+
+Open http://localhost:5173 in Chrome, Edge, Safari, or any browser supporting `SharedArrayBuffer` and `Atomics.waitAsync`.
+
+## Building WASM Modules
+
+The build requires [wasi-sdk](https://github.com/WebAssembly/wasi-sdk). The build script resolves it automatically:
+
+1. `$WASI_SDK_PATH` environment variable (if set)
+2. `/opt/wasi-sdk/` (default install path, used in CI)
+3. Auto-download via `cmake/FetchWasiSDK.cmake` (fetched once, cached across builds)
+
+From the EdgeTX repository root:
+
+```bash
+# Build all supported radios
+tools/build-wasm-modules.sh
+
+# Build specific radios only
+FLAVOR="tx16s;t12;x9dp2019" tools/build-wasm-modules.sh
+```
+
+Output `.wasm` files are written to `output/`. Copy them to `web/public/`:
+
+```bash
+cp output/*.wasm web/public/
+```
+
+### Supported Radios
+
+The radios available in the web UI are defined in `public/radios.json`. This file is **generated** from the authoritative hardware definitions in `radio/src/boards/hw_defs/`. To regenerate it (e.g. after adding a new radio target):
+
+```bash
+node web/scripts/gen-radios-json.js
+```
+
+The script extracts inputs, switches, trims, keys, and display info from the hw_defs JSON files. Key left/right side placement matches Companion's layout.
+
+## Architecture
+
+```
+ Browser Main Thread
+ ┌─────────────────────────────────────────────────────┐
+ │ │
+ │ App.svelte ──── WasmRunner ──── LcdRenderer │
+ │ (UI, controls) (loader) (WebGL canvas) │
+ │ │ │ │
+ │ │ SharedArrayBuffer │
+ │ │ (analogs, LCD sync) │
+ │ │ │ │
+ │ ▼ ▼ │
+ │ AudioContext WASM Worker Threads (WASI) │
+ │ (scheduled ┌──────────────────────────┐ │
+ │ playback) │ worker.ts + FsProxyClient│ │
+ │ └────────────┬─────────────┘ │
+ │ SAB+Atomics (sync I/O) │
+ │ │ │
+ │ ┌────────────▼─────────────┐ │
+ │ │ FS Worker (fs-worker.ts) │ │
+ │ │ OpfsBackend (OPFS) │ │
+ │ └──────────────────────────┘ │
+ └─────────────────────────────────────────────────────┘
+```
+
+### Lifecycle
+
+1. **Radio selection** — user picks a radio from the dropdown (persisted to localStorage). The FS Worker is spawned and OPFS is scanned for existing data. File uploads are available immediately.
+2. **Run** — WASM module is fetched, compiled, and instantiated. Worker threads are spawned with shared memory. The FS Worker's Atomics loop is started. The simulator begins running.
+3. **Stop** — firmware shutdown is signalled, worker threads are terminated after a grace period. The FS Worker stays alive for uploads.
+4. **Radio switch** — the old instance is torn down (FS Worker included), and a new one is initialized for the selected radio.
+
+### Key Components
+
+- **WasmRunner** — loads WASM, creates shared memory, manages the FS Worker and WASI thread pool. Exposes `initFs()` (spawn FS Worker), `load()` (compile + instantiate WASM), `stopSim()` (terminate threads), and `stopFs()` (terminate FS Worker).
+- **FS Worker** (`fs-worker.ts`) — dedicated worker that owns all OPFS state. Serves synchronous filesystem requests from WASM workers via `SharedArrayBuffer` + `Atomics`, and async UI requests (uploads, reads, wipe) via `postMessage`.
+- **OpfsBackend** (`opfs-backend.ts`) — in-memory directory tree backed by OPFS `SyncAccessHandle`s. Provides a synchronous Node.js-like filesystem API.
+- **FsProxyClient** (`fs-proxy-client.ts`) — worker-side stub that implements `fs.*Sync` methods by writing requests to a shared buffer and blocking on `Atomics.wait` until the FS Worker responds.
+- **LcdRenderer** (`lcd-renderer.ts`) — WebGL-based renderer supporting RGB565 (16-bit color), 4-bit grayscale, and 1-bit monochrome (column-major) LCD formats.
+- **Audio** — worker threads relay PCM samples via `postMessage`. The main thread schedules them as `AudioBufferSource` nodes for gapless 32 kHz playback.
+
+## Key Files
+
+| File | Description |
+|------|-------------|
+| `src/App.svelte` | Main UI: radio selector, LCD display, controls, file management |
+| `src/lib/wasm-runner.ts` | WASM loader, FS Worker lifecycle, thread management |
+| `src/lib/worker.ts` | WASM worker thread entry point (WASI + thread init) |
+| `src/lib/fs-worker.ts` | FS Worker: OPFS owner, Atomics dispatch loop, UI file ops |
+| `src/lib/opfs-backend.ts` | OPFS-backed synchronous filesystem implementation |
+| `src/lib/fs-proxy-client.ts` | Worker-side blocking filesystem proxy |
+| `src/lib/fs-proxy-protocol.ts` | Shared protocol constants and serialization |
+| `src/lib/lcd-renderer.ts` | LCD framebuffer rendering (RGB565, 4-bit grayscale, 1-bit mono) |
+| `public/radios.json` | Radio definitions (generated — do not edit manually) |
+| `public/_headers` | COOP/COEP headers for production deployment |
+| `scripts/gen-radios-json.js` | Generates `radios.json` from `radio/src/boards/hw_defs/` |
+
+## Browser Requirements
+
+- **SharedArrayBuffer** (requires COOP/COEP headers, configured in `vite.config.ts` and `public/_headers`)
+- **Atomics.waitAsync** — Chrome 87+, Safari 16.4+, Edge 87+, Firefox 145+
+- **WebAssembly threads** (shared memory)
+- **Origin Private File System** (persistent storage across sessions)
+
+## Development
+
+```bash
+npm run dev # Start dev server with HMR
+npm run build # Production build to dist/
+npm run preview # Preview production build
+```
+
+### Debugging
+
+- **Filesystem tracing**: Open browser console and run `fsTrace = true` to log all filesystem operations.
+- **Trace window**: The simulator UI includes a scrollable trace output showing firmware boot messages and runtime output.
+
+## Production Deployment
+
+The dev server sets the required COOP/COEP headers automatically. For production, `public/_headers` provides them for platforms like Cloudflare Pages. For other hosts, configure your web server to set:
+
+```
+Cross-Origin-Embedder-Policy: require-corp
+Cross-Origin-Opener-Policy: same-origin
+```
+
+### Cloudflare Pages
+
+```bash
+npm run build
+npx wrangler pages deploy dist --project-name=edgetx-simulator
+```
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 000000000..4b8d5ef67
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ EdgeTX Simulator
+
+
+
+
+
+
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 000000000..5a87f38d2
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,1468 @@
+{
+ "name": "edgetx-simulator-web",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "edgetx-simulator-web",
+ "version": "0.1.0",
+ "dependencies": {
+ "@emnapi/wasi-threads": "^1.0.0",
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
+ "@types/node": "^20.11.0",
+ "svelte": "^5.0.0",
+ "typescript": "^5.3.3",
+ "vite": "^6.0.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
+ "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
+ "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
+ "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
+ "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
+ "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
+ "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
+ "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
+ "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
+ "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
+ "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
+ "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
+ "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
+ "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
+ "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
+ "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
+ "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
+ "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
+ "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
+ "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
+ "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
+ "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
+ "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
+ "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
+ "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@sveltejs/acorn-typescript": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
+ "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^8.9.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz",
+ "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
+ "debug": "^4.4.1",
+ "deepmerge": "^4.3.1",
+ "kleur": "^4.1.5",
+ "magic-string": "^0.30.17",
+ "vitefu": "^1.0.6"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22"
+ },
+ "peerDependencies": {
+ "svelte": "^5.0.0",
+ "vite": "^6.0.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte-inspector": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz",
+ "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.7"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22"
+ },
+ "peerDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
+ "svelte": "^5.0.0",
+ "vite": "^6.0.0"
+ }
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+ "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.37",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
+ "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
+ "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/devalue": {
+ "version": "5.6.3",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz",
+ "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/esm-env": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
+ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esrap": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz",
+ "integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/is-reference": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
+ "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.6"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/locate-character": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
+ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.8",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
+ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.59.0",
+ "@rollup/rollup-android-arm64": "4.59.0",
+ "@rollup/rollup-darwin-arm64": "4.59.0",
+ "@rollup/rollup-darwin-x64": "4.59.0",
+ "@rollup/rollup-freebsd-arm64": "4.59.0",
+ "@rollup/rollup-freebsd-x64": "4.59.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.59.0",
+ "@rollup/rollup-linux-arm64-musl": "4.59.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.59.0",
+ "@rollup/rollup-linux-loong64-musl": "4.59.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.59.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.59.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-musl": "4.59.0",
+ "@rollup/rollup-openbsd-x64": "4.59.0",
+ "@rollup/rollup-openharmony-arm64": "4.59.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.59.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.59.0",
+ "@rollup/rollup-win32-x64-gnu": "4.59.0",
+ "@rollup/rollup-win32-x64-msvc": "4.59.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/svelte": {
+ "version": "5.53.7",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.7.tgz",
+ "integrity": "sha512-uxck1KI7JWtlfP3H6HOWi/94soAl23jsGJkBzN2BAWcQng0+lTrRNhxActFqORgnO9BHVd1hKJhG+ljRuIUWfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@sveltejs/acorn-typescript": "^1.0.5",
+ "@types/estree": "^1.0.5",
+ "@types/trusted-types": "^2.0.7",
+ "acorn": "^8.12.1",
+ "aria-query": "5.3.1",
+ "axobject-query": "^4.1.0",
+ "clsx": "^2.1.1",
+ "devalue": "^5.6.3",
+ "esm-env": "^1.2.1",
+ "esrap": "^2.2.2",
+ "is-reference": "^3.0.3",
+ "locate-character": "^3.0.0",
+ "magic-string": "^0.30.11",
+ "zimmerframe": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "6.4.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
+ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitefu": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz",
+ "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "tests/deps/*",
+ "tests/projects/*",
+ "tests/projects/workspace/packages/*"
+ ],
+ "peerDependencies": {
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/zimmerframe": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 000000000..10ea7ed0a
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "edgetx-simulator-web",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
+ "@types/node": "^20.11.0",
+ "svelte": "^5.0.0",
+ "typescript": "^5.3.3",
+ "vite": "^6.0.0"
+ },
+ "dependencies": {
+ "@emnapi/wasi-threads": "^1.0.0",
+ "@tybys/wasm-util": "^0.10.1"
+ }
+}
diff --git a/web/public/.gitkeep b/web/public/.gitkeep
new file mode 100644
index 000000000..3ddeda0c2
--- /dev/null
+++ b/web/public/.gitkeep
@@ -0,0 +1 @@
+Place simulator.wasm here
diff --git a/web/public/_headers b/web/public/_headers
new file mode 100644
index 000000000..a2395ae61
--- /dev/null
+++ b/web/public/_headers
@@ -0,0 +1,3 @@
+/*
+ Cross-Origin-Embedder-Policy: require-corp
+ Cross-Origin-Opener-Policy: same-origin
diff --git a/web/public/radios.json b/web/public/radios.json
new file mode 100644
index 000000000..742c98aac
--- /dev/null
+++ b/web/public/radios.json
@@ -0,0 +1,5709 @@
+[
+ {
+ "name": "Fatfish F16",
+ "wasm": "edgetx-f16-simulator.wasm",
+ "display": {
+ "w": 480,
+ "h": 272,
+ "depth": 16
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "6POS",
+ "default": "MULTIPOS"
+ },
+ {
+ "name": "P3",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "SL1",
+ "type": "FLEX",
+ "label": "LS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL2",
+ "type": "FLEX",
+ "label": "RS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "EXT1",
+ "type": "FLEX",
+ "label": "EXT1"
+ },
+ {
+ "name": "EXT2",
+ "type": "FLEX",
+ "label": "EXT2"
+ },
+ {
+ "name": "EXT3",
+ "type": "FLEX",
+ "label": "EXT3"
+ },
+ {
+ "name": "EXT4",
+ "type": "FLEX",
+ "label": "EXT4"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SE",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SG",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SH",
+ "type": "2POS",
+ "default": "TOGGLE"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ },
+ {
+ "name": "T5"
+ },
+ {
+ "name": "T6"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_MODEL",
+ "label": "MDL",
+ "side": "R"
+ },
+ {
+ "key": "KEY_SYS",
+ "label": "SYS",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEUP",
+ "label": "PAGE◀",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_TELE",
+ "label": "TELE",
+ "side": "R"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "RTN",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "Flysky EL18",
+ "wasm": "edgetx-el18-simulator.wasm",
+ "display": {
+ "w": 320,
+ "h": 480,
+ "depth": 16
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "3POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SE",
+ "type": "3POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SF",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SG",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SH",
+ "type": "3POS",
+ "default": "TOGGLE"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_EXIT",
+ "label": "EXIT",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "Flysky NB4+",
+ "wasm": "edgetx-nb4p-simulator.wasm",
+ "display": {
+ "w": 320,
+ "h": 480,
+ "depth": 16
+ },
+ "inputs": [
+ {
+ "name": "ST",
+ "type": "STICK",
+ "label": "ST"
+ },
+ {
+ "name": "TH",
+ "type": "STICK",
+ "label": "TH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "VR1L",
+ "default": "POT"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "VR1R",
+ "default": "POT"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SB",
+ "type": "2POS",
+ "default": "TOGGLE"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_EXIT",
+ "label": "EXIT",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "Flysky NV14",
+ "wasm": "edgetx-nv14-simulator.wasm",
+ "display": {
+ "w": 320,
+ "h": 480,
+ "depth": 16
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "3POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SE",
+ "type": "3POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SF",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SG",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SH",
+ "type": "3POS",
+ "default": "TOGGLE"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_EXIT",
+ "label": "EXIT",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "Flysky PA01",
+ "wasm": "edgetx-pa01-simulator.wasm",
+ "display": {
+ "w": 320,
+ "h": 240,
+ "depth": 16
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "SL1",
+ "type": "FLEX",
+ "label": "LS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL2",
+ "type": "FLEX",
+ "label": "RS",
+ "default": "SLIDER"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SE",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SW1",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW2",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW3",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW4",
+ "type": "2POS",
+ "default": "2POS"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_MODEL",
+ "label": "MDL",
+ "side": "R"
+ },
+ {
+ "key": "KEY_PAGEUP",
+ "label": "PAGE◀",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "EXIT",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "Flysky PL18",
+ "wasm": "edgetx-pl18-simulator.wasm",
+ "display": {
+ "w": 480,
+ "h": 320,
+ "depth": 16
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT"
+ },
+ {
+ "name": "P3",
+ "type": "FLEX",
+ "label": "S3",
+ "default": "POT"
+ },
+ {
+ "name": "SL1",
+ "type": "FLEX",
+ "label": "LS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL2",
+ "type": "FLEX",
+ "label": "RS",
+ "default": "SLIDER"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SE",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SF",
+ "type": "3POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SG",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SH",
+ "type": "3POS",
+ "default": "TOGGLE"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ },
+ {
+ "name": "T5"
+ },
+ {
+ "name": "T6"
+ },
+ {
+ "name": "T7"
+ },
+ {
+ "name": "T8"
+ }
+ ],
+ "keys": []
+ },
+ {
+ "name": "Flysky PL18EV",
+ "wasm": "edgetx-pl18ev-simulator.wasm",
+ "display": {
+ "w": 480,
+ "h": 320,
+ "depth": 16
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT"
+ },
+ {
+ "name": "P3",
+ "type": "FLEX",
+ "label": "S3",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "SL1",
+ "type": "FLEX",
+ "label": "LS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL2",
+ "type": "FLEX",
+ "label": "RS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "EXT1",
+ "type": "FLEX",
+ "label": "EXT1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "EXT2",
+ "type": "FLEX",
+ "label": "EXT2",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "EXT3",
+ "type": "FLEX",
+ "label": "EXT3",
+ "default": "MULTIPOS"
+ },
+ {
+ "name": "EXT4",
+ "type": "FLEX",
+ "label": "EXT4",
+ "default": "MULTIPOS"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SE",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SF",
+ "type": "3POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SG",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SH",
+ "type": "3POS",
+ "default": "3POS"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ },
+ {
+ "name": "T5"
+ },
+ {
+ "name": "T6"
+ },
+ {
+ "name": "T7"
+ },
+ {
+ "name": "T8"
+ }
+ ],
+ "keys": []
+ },
+ {
+ "name": "Flysky PL18U",
+ "wasm": "edgetx-pl18u-simulator.wasm",
+ "display": {
+ "w": 480,
+ "h": 320,
+ "depth": 16
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT"
+ },
+ {
+ "name": "P3",
+ "type": "FLEX",
+ "label": "S3",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "SL1",
+ "type": "FLEX",
+ "label": "LS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL2",
+ "type": "FLEX",
+ "label": "RS",
+ "default": "SLIDER"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SE",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SF",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SG",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SH",
+ "type": "3POS",
+ "default": "TOGGLE"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ },
+ {
+ "name": "T5"
+ },
+ {
+ "name": "T6"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_EXIT",
+ "label": "EXIT",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "Flysky ST16",
+ "wasm": "edgetx-st16-simulator.wasm",
+ "display": {
+ "w": 480,
+ "h": 320,
+ "depth": 16
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT"
+ },
+ {
+ "name": "SL1",
+ "type": "FLEX",
+ "label": "LS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL2",
+ "type": "FLEX",
+ "label": "RS",
+ "default": "SLIDER"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "3POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SE",
+ "type": "3POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SF",
+ "type": "3POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SG",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SH",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SI",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SJ",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SW1",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW2",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW3",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW4",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW5",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW6",
+ "type": "2POS",
+ "default": "2POS"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "EXIT",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "FrSky Horus X10",
+ "wasm": "edgetx-x10-simulator.wasm",
+ "display": {
+ "w": 480,
+ "h": 272,
+ "depth": 16
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "6POS",
+ "default": "MULTIPOS"
+ },
+ {
+ "name": "P3",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "SL1",
+ "type": "FLEX",
+ "label": "LS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL2",
+ "type": "FLEX",
+ "label": "RS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "EXT1",
+ "type": "FLEX",
+ "label": "EXT1"
+ },
+ {
+ "name": "EXT2",
+ "type": "FLEX",
+ "label": "EXT2"
+ },
+ {
+ "name": "EXT3",
+ "type": "FLEX",
+ "label": "EXT3"
+ },
+ {
+ "name": "EXT4",
+ "type": "FLEX",
+ "label": "EXT4"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SE",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SG",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SH",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SI",
+ "type": "2POS",
+ "default": "NONE"
+ },
+ {
+ "name": "SJ",
+ "type": "2POS",
+ "default": "NONE"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ },
+ {
+ "name": "T5"
+ },
+ {
+ "name": "T6"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_MODEL",
+ "label": "MDL",
+ "side": "R"
+ },
+ {
+ "key": "KEY_SYS",
+ "label": "SYS",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_TELE",
+ "label": "TELE",
+ "side": "R"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "RTN",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "FrSky Horus X10 Express",
+ "wasm": "edgetx-x10express-simulator.wasm",
+ "display": {
+ "w": 480,
+ "h": 272,
+ "depth": 16
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "6POS",
+ "default": "MULTIPOS"
+ },
+ {
+ "name": "P3",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "SL1",
+ "type": "FLEX",
+ "label": "LS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL2",
+ "type": "FLEX",
+ "label": "RS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "EXT1",
+ "type": "FLEX",
+ "label": "EXT1"
+ },
+ {
+ "name": "EXT2",
+ "type": "FLEX",
+ "label": "EXT2"
+ },
+ {
+ "name": "EXT3",
+ "type": "FLEX",
+ "label": "EXT3"
+ },
+ {
+ "name": "EXT4",
+ "type": "FLEX",
+ "label": "EXT4"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SE",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SG",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SH",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SI",
+ "type": "2POS",
+ "default": "NONE"
+ },
+ {
+ "name": "SJ",
+ "type": "2POS",
+ "default": "NONE"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ },
+ {
+ "name": "T5"
+ },
+ {
+ "name": "T6"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_MODEL",
+ "label": "MDL",
+ "side": "R"
+ },
+ {
+ "key": "KEY_SYS",
+ "label": "SYS",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_TELE",
+ "label": "TELE",
+ "side": "R"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "RTN",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "FrSky Horus X12s",
+ "wasm": "edgetx-x12s-simulator.wasm",
+ "display": {
+ "w": 480,
+ "h": 272,
+ "depth": 16
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "6POS",
+ "default": "MULTIPOS"
+ },
+ {
+ "name": "P3",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "SL1",
+ "type": "FLEX",
+ "label": "LS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL2",
+ "type": "FLEX",
+ "label": "RS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL3",
+ "type": "FLEX",
+ "label": "L1",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL4",
+ "type": "FLEX",
+ "label": "L2",
+ "default": "SLIDER"
+ },
+ {
+ "name": "JSx",
+ "type": "FLEX",
+ "label": "JSx",
+ "default": "AXIS_X"
+ },
+ {
+ "name": "JSy",
+ "type": "FLEX",
+ "label": "JSy",
+ "default": "AXIS_Y"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SE",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SG",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SH",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SI",
+ "type": "2POS",
+ "default": "NONE"
+ },
+ {
+ "name": "SJ",
+ "type": "2POS",
+ "default": "NONE"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ },
+ {
+ "name": "T5"
+ },
+ {
+ "name": "T6"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_MODEL",
+ "label": "MDL",
+ "side": "R"
+ },
+ {
+ "key": "KEY_SYS",
+ "label": "SYS",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEUP",
+ "label": "PAGE◀",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_TELE",
+ "label": "TELE",
+ "side": "R"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "RTN",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "FrSky QX7 Access",
+ "wasm": "edgetx-x7access-simulator.wasm",
+ "display": {
+ "w": 128,
+ "h": 64,
+ "depth": 1
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SH",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SI",
+ "type": "2POS",
+ "default": "NONE"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_MENU",
+ "label": "MENU",
+ "side": "L"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "EXIT",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "FrSky X9D Plus 2019",
+ "wasm": "edgetx-x9d+2019-simulator.wasm",
+ "display": {
+ "w": 212,
+ "h": 64,
+ "depth": 4
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "SL1",
+ "type": "FLEX",
+ "label": "LS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL2",
+ "type": "FLEX",
+ "label": "RS",
+ "default": "SLIDER"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SE",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SG",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SH",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SI",
+ "type": "2POS",
+ "default": "TOGGLE"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_MENU",
+ "label": "MENU",
+ "side": "L"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "EXIT",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "FrSky X9E",
+ "wasm": "edgetx-x9e-simulator.wasm",
+ "display": {
+ "w": 212,
+ "h": 64,
+ "depth": 4
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "F1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "F2",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P3",
+ "type": "FLEX",
+ "label": "F3",
+ "default": "NONE"
+ },
+ {
+ "name": "P4",
+ "type": "FLEX",
+ "label": "F4",
+ "default": "NONE"
+ },
+ {
+ "name": "SL1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL3",
+ "type": "FLEX",
+ "label": "LS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL4",
+ "type": "FLEX",
+ "label": "RS",
+ "default": "SLIDER"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SE",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SG",
+ "type": "3POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SH",
+ "type": "2POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SI",
+ "type": "3POS",
+ "default": "NONE"
+ },
+ {
+ "name": "SJ",
+ "type": "3POS",
+ "default": "NONE"
+ },
+ {
+ "name": "SK",
+ "type": "3POS",
+ "default": "NONE"
+ },
+ {
+ "name": "SL",
+ "type": "3POS",
+ "default": "NONE"
+ },
+ {
+ "name": "SM",
+ "type": "3POS",
+ "default": "NONE"
+ },
+ {
+ "name": "SN",
+ "type": "3POS",
+ "default": "NONE"
+ },
+ {
+ "name": "SO",
+ "type": "3POS",
+ "default": "NONE"
+ },
+ {
+ "name": "SP",
+ "type": "3POS",
+ "default": "NONE"
+ },
+ {
+ "name": "SQ",
+ "type": "3POS",
+ "default": "NONE"
+ },
+ {
+ "name": "SR",
+ "type": "3POS",
+ "default": "NONE"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_MENU",
+ "label": "MENU",
+ "side": "L"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "EXIT",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "HelloRadioSky V14",
+ "wasm": "edgetx-v14-simulator.wasm",
+ "display": {
+ "w": 128,
+ "h": 64,
+ "depth": 1
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P3",
+ "type": "FLEX",
+ "label": "S3",
+ "default": "MULTIPOS"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SE",
+ "type": "3POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SF",
+ "type": "3POS",
+ "default": "TOGGLE"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_MODEL",
+ "label": "MDL",
+ "side": "R"
+ },
+ {
+ "key": "KEY_SYS",
+ "label": "SYS",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEUP",
+ "label": "PAGE◀",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_TELE",
+ "label": "TELE",
+ "side": "R"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "EXIT",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "HelloRadioSky V16",
+ "wasm": "edgetx-v16-simulator.wasm",
+ "display": {
+ "w": 480,
+ "h": 272,
+ "depth": 16
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "6POS",
+ "default": "MULTIPOS"
+ },
+ {
+ "name": "P3",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "SL1",
+ "type": "FLEX",
+ "label": "LS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL2",
+ "type": "FLEX",
+ "label": "RS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "EXT1",
+ "type": "FLEX",
+ "label": "EXT1"
+ },
+ {
+ "name": "EXT2",
+ "type": "FLEX",
+ "label": "EXT2"
+ },
+ {
+ "name": "EXT3",
+ "type": "FLEX",
+ "label": "EXT3"
+ },
+ {
+ "name": "EXT4",
+ "type": "FLEX",
+ "label": "EXT4"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SE",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SG",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SH",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SI",
+ "type": "2POS",
+ "default": "NONE"
+ },
+ {
+ "name": "SJ",
+ "type": "2POS",
+ "default": "NONE"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ },
+ {
+ "name": "T5"
+ },
+ {
+ "name": "T6"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_MODEL",
+ "label": "MDL",
+ "side": "R"
+ },
+ {
+ "key": "KEY_SYS",
+ "label": "SYS",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEUP",
+ "label": "PAGE◀",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_TELE",
+ "label": "TELE",
+ "side": "R"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "RTN",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "iFlight Commando 8",
+ "wasm": "edgetx-commando8-simulator.wasm",
+ "display": {
+ "w": 128,
+ "h": 64,
+ "depth": 1
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "2POS",
+ "default": "2POS"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_MODEL",
+ "label": "MDL",
+ "side": "R"
+ },
+ {
+ "key": "KEY_SYS",
+ "label": "SYS",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEUP",
+ "label": "PAGE◀",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_PLUS",
+ "label": "Up",
+ "side": "R"
+ },
+ {
+ "key": "KEY_MINUS",
+ "label": "Down",
+ "side": "L"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "BCK",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "Jumper Bumblebee",
+ "wasm": "edgetx-bumblebee-simulator.wasm",
+ "display": {
+ "w": 128,
+ "h": 64,
+ "depth": 1
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SD",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SE",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SG",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SH",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SW1",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW2",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW3",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW4",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW5",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW6",
+ "type": "2POS",
+ "default": "2POS"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_MENU",
+ "label": "MENU",
+ "side": "L"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "EXIT",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "Jumper T-Pro S",
+ "wasm": "edgetx-tpros-simulator.wasm",
+ "display": {
+ "w": 128,
+ "h": 64,
+ "depth": 1
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SD",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SE",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "2POS"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_MENU",
+ "label": "MENU",
+ "side": "L"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "EXIT",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "Jumper T-Pro V2",
+ "wasm": "edgetx-tprov2-simulator.wasm",
+ "display": {
+ "w": 128,
+ "h": 64,
+ "depth": 1
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SD",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SE",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SG",
+ "type": "2POS",
+ "default": "NONE"
+ },
+ {
+ "name": "SH",
+ "type": "2POS",
+ "default": "NONE"
+ },
+ {
+ "name": "SW1",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW2",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW3",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW4",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW5",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW6",
+ "type": "2POS",
+ "default": "2POS"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_MENU",
+ "label": "MENU",
+ "side": "L"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "EXIT",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "Jumper T12 MAX",
+ "wasm": "edgetx-t12max-simulator.wasm",
+ "display": {
+ "w": 128,
+ "h": 64,
+ "depth": 1
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SB",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SE",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "2POS"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_MENU",
+ "label": "MENU",
+ "side": "L"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "EXIT",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "Jumper T14",
+ "wasm": "edgetx-t14-simulator.wasm",
+ "display": {
+ "w": 128,
+ "h": 64,
+ "depth": 1
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SB",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SE",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "TOGGLE"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_MENU",
+ "label": "MENU",
+ "side": "L"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "EXIT",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "Jumper T15",
+ "wasm": "edgetx-t15-simulator.wasm",
+ "display": {
+ "w": 480,
+ "h": 320,
+ "depth": 16
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SE",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW1",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW2",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW3",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW4",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW5",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW6",
+ "type": "2POS",
+ "default": "2POS"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_MODEL",
+ "label": "MDL",
+ "side": "R"
+ },
+ {
+ "key": "KEY_SYS",
+ "label": "SYS",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_TELE",
+ "label": "TELE",
+ "side": "R"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "EXIT",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "Jumper T15 Pro",
+ "wasm": "edgetx-t15pro-simulator.wasm",
+ "display": {
+ "w": 480,
+ "h": 320,
+ "depth": 16
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SE",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW1",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW2",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW3",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW4",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW5",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW6",
+ "type": "2POS",
+ "default": "2POS"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_MODEL",
+ "label": "MDL",
+ "side": "R"
+ },
+ {
+ "key": "KEY_SYS",
+ "label": "SYS",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_TELE",
+ "label": "TELE",
+ "side": "R"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "EXIT",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "Jumper T16",
+ "wasm": "edgetx-t16-simulator.wasm",
+ "display": {
+ "w": 480,
+ "h": 272,
+ "depth": 16
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "6POS",
+ "default": "MULTIPOS"
+ },
+ {
+ "name": "P3",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "SL1",
+ "type": "FLEX",
+ "label": "LS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL2",
+ "type": "FLEX",
+ "label": "RS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "EXT1",
+ "type": "FLEX",
+ "label": "EXT1"
+ },
+ {
+ "name": "EXT2",
+ "type": "FLEX",
+ "label": "EXT2"
+ },
+ {
+ "name": "EXT3",
+ "type": "FLEX",
+ "label": "EXT3"
+ },
+ {
+ "name": "EXT4",
+ "type": "FLEX",
+ "label": "EXT4"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SE",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SG",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SH",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SI",
+ "type": "2POS",
+ "default": "NONE"
+ },
+ {
+ "name": "SJ",
+ "type": "2POS",
+ "default": "NONE"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ },
+ {
+ "name": "T5"
+ },
+ {
+ "name": "T6"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_MODEL",
+ "label": "MDL",
+ "side": "R"
+ },
+ {
+ "key": "KEY_SYS",
+ "label": "SYS",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_TELE",
+ "label": "TELE",
+ "side": "R"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "RTN",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "Jumper T18",
+ "wasm": "edgetx-t18-simulator.wasm",
+ "display": {
+ "w": 480,
+ "h": 272,
+ "depth": 16
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "6POS",
+ "default": "MULTIPOS"
+ },
+ {
+ "name": "P3",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "SL1",
+ "type": "FLEX",
+ "label": "LS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL2",
+ "type": "FLEX",
+ "label": "RS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "EXT1",
+ "type": "FLEX",
+ "label": "EXT1"
+ },
+ {
+ "name": "EXT2",
+ "type": "FLEX",
+ "label": "EXT2"
+ },
+ {
+ "name": "EXT3",
+ "type": "FLEX",
+ "label": "EXT3"
+ },
+ {
+ "name": "EXT4",
+ "type": "FLEX",
+ "label": "EXT4"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SE",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SG",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SH",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SI",
+ "type": "2POS",
+ "default": "NONE"
+ },
+ {
+ "name": "SJ",
+ "type": "2POS",
+ "default": "NONE"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ },
+ {
+ "name": "T5"
+ },
+ {
+ "name": "T6"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_MODEL",
+ "label": "MDL",
+ "side": "R"
+ },
+ {
+ "key": "KEY_SYS",
+ "label": "SYS",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_TELE",
+ "label": "TELE",
+ "side": "R"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "RTN",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "Jumper T20",
+ "wasm": "edgetx-t20-simulator.wasm",
+ "display": {
+ "w": 128,
+ "h": 64,
+ "depth": 1
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "SL1",
+ "type": "FLEX",
+ "label": "S3",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL2",
+ "type": "FLEX",
+ "label": "S4",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL3",
+ "type": "FLEX",
+ "label": "SL",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL4",
+ "type": "FLEX",
+ "label": "SR",
+ "default": "SLIDER"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SB",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SE",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SG",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SH",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SI",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SJ",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SW1",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW2",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW3",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW4",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW5",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW6",
+ "type": "2POS",
+ "default": "2POS"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ },
+ {
+ "name": "T5"
+ },
+ {
+ "name": "T6"
+ },
+ {
+ "name": "T7"
+ },
+ {
+ "name": "T8"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_MENU",
+ "label": "MENU",
+ "side": "L"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "EXIT",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "Jumper T20 V2",
+ "wasm": "edgetx-t20v2-simulator.wasm",
+ "display": {
+ "w": 128,
+ "h": 64,
+ "depth": 1
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "SL1",
+ "type": "FLEX",
+ "label": "S3",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL2",
+ "type": "FLEX",
+ "label": "S4",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL3",
+ "type": "FLEX",
+ "label": "SL",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL4",
+ "type": "FLEX",
+ "label": "SR",
+ "default": "SLIDER"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SB",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SE",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SG",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SH",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SI",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SJ",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SW1",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW2",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW3",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW4",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW5",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW6",
+ "type": "2POS",
+ "default": "2POS"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ },
+ {
+ "name": "T5"
+ },
+ {
+ "name": "T6"
+ },
+ {
+ "name": "T7"
+ },
+ {
+ "name": "T8"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_MENU",
+ "label": "MENU",
+ "side": "L"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "EXIT",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "RadioMaster Boxer",
+ "wasm": "edgetx-boxer-simulator.wasm",
+ "display": {
+ "w": 128,
+ "h": 64,
+ "depth": 1
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P3",
+ "type": "FLEX",
+ "label": "6P",
+ "default": "MULTIPOS"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SE",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "TOGGLE"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_MODEL",
+ "label": "MDL",
+ "side": "R"
+ },
+ {
+ "key": "KEY_SYS",
+ "label": "SYS",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEUP",
+ "label": "PAGE◀",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_TELE",
+ "label": "TELE",
+ "side": "R"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "RTN",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "RadioMaster GX12",
+ "wasm": "edgetx-gx12-simulator.wasm",
+ "display": {
+ "w": 128,
+ "h": 64,
+ "depth": 1
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "P1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "P2",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "SL1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "SLIDER"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SE",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SF",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SG",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SH",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SW1",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW2",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW3",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW4",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW5",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW6",
+ "type": "2POS",
+ "default": "2POS"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_MODEL",
+ "label": "MDL",
+ "side": "R"
+ },
+ {
+ "key": "KEY_SYS",
+ "label": "SYS",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEUP",
+ "label": "PAGE◀",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_TELE",
+ "label": "TELE",
+ "side": "R"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "RTN",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "RadioMaster MT12",
+ "wasm": "edgetx-mt12-simulator.wasm",
+ "display": {
+ "w": 128,
+ "h": 64,
+ "depth": 1
+ },
+ "inputs": [
+ {
+ "name": "ST",
+ "type": "STICK",
+ "label": "ST"
+ },
+ {
+ "name": "TH",
+ "type": "STICK",
+ "label": "TH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT"
+ },
+ {
+ "name": "P3",
+ "type": "FLEX",
+ "label": "S3",
+ "default": "NONE"
+ },
+ {
+ "name": "P4",
+ "type": "FLEX",
+ "label": "S4",
+ "default": "NONE"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SB",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SC",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SD",
+ "type": "2POS",
+ "default": "TOGGLE"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ },
+ {
+ "name": "T5"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_MODEL",
+ "label": "MDL",
+ "side": "R"
+ },
+ {
+ "key": "KEY_SYS",
+ "label": "SYS",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEUP",
+ "label": "PAGE◀",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_TELE",
+ "label": "TELE",
+ "side": "R"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "RTN",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "RadioMaster Pocket",
+ "wasm": "edgetx-pocket-simulator.wasm",
+ "display": {
+ "w": 128,
+ "h": 64,
+ "depth": 1
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SE",
+ "type": "2POS",
+ "default": "TOGGLE"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_MODEL",
+ "label": "MDL",
+ "side": "R"
+ },
+ {
+ "key": "KEY_SYS",
+ "label": "SYS",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEUP",
+ "label": "PAGE◀",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_TELE",
+ "label": "TELE",
+ "side": "R"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "RTN",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "RadioMaster TX12MK2",
+ "wasm": "edgetx-tx12mk2-simulator.wasm",
+ "display": {
+ "w": 128,
+ "h": 64,
+ "depth": 1
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SE",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SF",
+ "type": "3POS",
+ "default": "3POS"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_MODEL",
+ "label": "MDL",
+ "side": "R"
+ },
+ {
+ "key": "KEY_SYS",
+ "label": "SYS",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEUP",
+ "label": "PAGE◀",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_TELE",
+ "label": "TELE",
+ "side": "R"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "RTN",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "RadioMaster TX15",
+ "wasm": "edgetx-tx15-simulator.wasm",
+ "display": {
+ "w": 480,
+ "h": 320,
+ "depth": 16
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SE",
+ "type": "3POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SF",
+ "type": "3POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SW1",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW2",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW3",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW4",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW5",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW6",
+ "type": "2POS",
+ "default": "2POS"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_MODEL",
+ "label": "MDL",
+ "side": "R"
+ },
+ {
+ "key": "KEY_SYS",
+ "label": "SYS",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEUP",
+ "label": "PAGE◀",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_TELE",
+ "label": "TELE",
+ "side": "R"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "EXIT",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "RadioMaster TX16S",
+ "wasm": "edgetx-tx16s-simulator.wasm",
+ "display": {
+ "w": 480,
+ "h": 272,
+ "depth": 16
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "6POS",
+ "default": "MULTIPOS"
+ },
+ {
+ "name": "P3",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "SL1",
+ "type": "FLEX",
+ "label": "LS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL2",
+ "type": "FLEX",
+ "label": "RS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "EXT1",
+ "type": "FLEX",
+ "label": "EXT1"
+ },
+ {
+ "name": "EXT2",
+ "type": "FLEX",
+ "label": "EXT2"
+ },
+ {
+ "name": "EXT3",
+ "type": "FLEX",
+ "label": "EXT3"
+ },
+ {
+ "name": "EXT4",
+ "type": "FLEX",
+ "label": "EXT4"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SE",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SG",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SH",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SI",
+ "type": "2POS",
+ "default": "NONE"
+ },
+ {
+ "name": "SJ",
+ "type": "2POS",
+ "default": "NONE"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ },
+ {
+ "name": "T5"
+ },
+ {
+ "name": "T6"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_MODEL",
+ "label": "MDL",
+ "side": "R"
+ },
+ {
+ "key": "KEY_SYS",
+ "label": "SYS",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEUP",
+ "label": "PAGE◀",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_TELE",
+ "label": "TELE",
+ "side": "R"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "RTN",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "RadioMaster TX16S MK3",
+ "wasm": "edgetx-tx16smk3-simulator.wasm",
+ "display": {
+ "w": 800,
+ "h": 480,
+ "depth": 16
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT_CENTER"
+ },
+ {
+ "name": "SL1",
+ "type": "FLEX",
+ "label": "LS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "SL2",
+ "type": "FLEX",
+ "label": "RS",
+ "default": "SLIDER"
+ },
+ {
+ "name": "EXT1",
+ "type": "FLEX",
+ "label": "EXT1",
+ "default": "NONE"
+ },
+ {
+ "name": "EXT2",
+ "type": "FLEX",
+ "label": "EXT2",
+ "default": "NONE"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SE",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SG",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SH",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SI",
+ "type": "2POS",
+ "default": "NONE"
+ },
+ {
+ "name": "SJ",
+ "type": "2POS",
+ "default": "NONE"
+ },
+ {
+ "name": "SW1",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW2",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW3",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW4",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW5",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SW6",
+ "type": "2POS",
+ "default": "2POS"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ },
+ {
+ "name": "T5"
+ },
+ {
+ "name": "T6"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_MODEL",
+ "label": "MDL",
+ "side": "R"
+ },
+ {
+ "key": "KEY_SYS",
+ "label": "SYS",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEUP",
+ "label": "PAGE◀",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_TELE",
+ "label": "TELE",
+ "side": "R"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "RTN",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ },
+ {
+ "name": "RadioMaster Zorro",
+ "wasm": "edgetx-zorro-simulator.wasm",
+ "display": {
+ "w": 128,
+ "h": 64,
+ "depth": 1
+ },
+ "inputs": [
+ {
+ "name": "LH",
+ "type": "STICK",
+ "label": "LH"
+ },
+ {
+ "name": "LV",
+ "type": "STICK",
+ "label": "LV"
+ },
+ {
+ "name": "RV",
+ "type": "STICK",
+ "label": "RV"
+ },
+ {
+ "name": "RH",
+ "type": "STICK",
+ "label": "RH"
+ },
+ {
+ "name": "P1",
+ "type": "FLEX",
+ "label": "S1",
+ "default": "POT"
+ },
+ {
+ "name": "P2",
+ "type": "FLEX",
+ "label": "S2",
+ "default": "POT"
+ }
+ ],
+ "switches": [
+ {
+ "name": "SA",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SB",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SC",
+ "type": "3POS",
+ "default": "3POS"
+ },
+ {
+ "name": "SD",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SE",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SF",
+ "type": "2POS",
+ "default": "2POS"
+ },
+ {
+ "name": "SG",
+ "type": "2POS",
+ "default": "TOGGLE"
+ },
+ {
+ "name": "SH",
+ "type": "2POS",
+ "default": "TOGGLE"
+ }
+ ],
+ "trims": [
+ {
+ "name": "T1"
+ },
+ {
+ "name": "T2"
+ },
+ {
+ "name": "T3"
+ },
+ {
+ "name": "T4"
+ }
+ ],
+ "keys": [
+ {
+ "key": "KEY_MODEL",
+ "label": "MDL",
+ "side": "R"
+ },
+ {
+ "key": "KEY_SYS",
+ "label": "SYS",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEUP",
+ "label": "PAGE◀",
+ "side": "L"
+ },
+ {
+ "key": "KEY_PAGEDN",
+ "label": "PAGE▶",
+ "side": "R"
+ },
+ {
+ "key": "KEY_TELE",
+ "label": "TELE",
+ "side": "R"
+ },
+ {
+ "key": "KEY_EXIT",
+ "label": "RTN",
+ "side": "L"
+ },
+ {
+ "key": "KEY_ENTER",
+ "label": "Enter ⏎",
+ "side": "R"
+ }
+ ]
+ }
+]
diff --git a/web/scripts/gen-radios-json.js b/web/scripts/gen-radios-json.js
new file mode 100644
index 000000000..b7aa0abbe
--- /dev/null
+++ b/web/scripts/gen-radios-json.js
@@ -0,0 +1,180 @@
+#!/usr/bin/env node
+//
+// Generate web/public/radios.json from the authoritative hardware definition
+// JSON files in radio/src/boards/hw_defs/.
+//
+// Usage: node web/scripts/gen-radios-json.js [flavour1 flavour2 ...]
+//
+// Flavour names match the hw_defs filenames (e.g. "tx16s", "x9d+2019").
+// If no flavours are specified, generates entries for all hw_defs files.
+//
+// The key left/right side mapping matches Companion's radioKeyDefinitions
+// table in companion/src/simulation/simulateduiwidget.cpp.
+
+import { readFileSync, writeFileSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const ROOT = join(__dirname, '..', '..');
+const HW_DEFS = join(ROOT, 'radio', 'src', 'boards', 'hw_defs');
+const OUTPUT = join(ROOT, 'web', 'public', 'radios.json');
+
+// Key side and order — matches Companion's radioKeyDefinitions table
+// (companion/src/simulation/simulateduiwidget.cpp)
+const KEY_LAYOUT = {
+ KEY_SYS: { side: 'L', row: 0 },
+ KEY_MODEL: { side: 'R', row: 0 },
+ KEY_PAGEUP: { side: 'L', row: 1 },
+ KEY_PAGEDN: { side: 'R', row: 1 },
+ KEY_UP: { side: 'L', row: 2 },
+ KEY_DOWN: { side: 'R', row: 2 },
+ KEY_LEFT: { side: 'L', row: 3 },
+ KEY_RIGHT: { side: 'R', row: 3 },
+ KEY_MINUS: { side: 'L', row: 4 },
+ KEY_PLUS: { side: 'R', row: 4 },
+ KEY_TELE: { side: 'R', row: 5 },
+ KEY_MENU: { side: 'L', row: 6 },
+ KEY_SHIFT: { side: 'R', row: 6 },
+ KEY_EXIT: { side: 'L', row: 7 },
+ KEY_ENTER: { side: 'R', row: 7 },
+};
+
+// Pretty labels for keys (unicode symbols instead of plain text)
+const KEY_LABELS = {
+ KEY_PAGEUP: 'PAGE\u25C0', // PAGE◀
+ KEY_PAGEDN: 'PAGE\u25B6', // PAGE▶
+ KEY_UP: '\u25B2', // ▲
+ KEY_DOWN: '\u25BC', // ▼
+ KEY_LEFT: '\u25C0', // ◀
+ KEY_RIGHT: '\u25B6', // ▶
+ KEY_ENTER: 'Enter \u23CE', // Enter ⏎
+};
+
+/** Build display name lookup from fw.json (the authoritative radio name list). */
+function loadDisplayNames() {
+ try {
+ const fw = JSON.parse(readFileSync(join(ROOT, 'fw.json'), 'utf-8'));
+ const map = {};
+ for (const [name, prefix] of fw.targets) {
+ // prefix is e.g. "tx16s-", "x9dp2019-" — strip trailing dash
+ const key = prefix.replace(/-$/, '');
+ map[key] = name;
+ }
+ return map;
+ } catch {
+ return {};
+ }
+}
+
+const DISPLAY_NAMES = loadDisplayNames();
+
+function getDisplayName(flavour) {
+ // fw.json uses build target names (x9dp2019), hw_defs uses flavour (x9d+2019)
+ // Try both the flavour and the p-substituted form
+ if (DISPLAY_NAMES[flavour]) return DISPLAY_NAMES[flavour];
+ const pForm = flavour.replace('+', 'p');
+ if (DISPLAY_NAMES[pForm]) return DISPLAY_NAMES[pForm];
+ return flavour.toUpperCase();
+}
+
+function processFlavour(flavour) {
+ let jsonFile;
+ try {
+ jsonFile = readFileSync(join(HW_DEFS, `${flavour}.json`), 'utf-8');
+ } catch {
+ console.warn(` ⚠ Skipping ${flavour}: no hw_defs JSON found`);
+ return null;
+ }
+
+ const hw = JSON.parse(jsonFile);
+
+ // Inputs: sticks + flex (skip VBAT, RTC_BAT, etc.)
+ const inputs = (hw.adc_inputs?.inputs ?? [])
+ .filter(i => i.type === 'STICK' || i.type === 'FLEX')
+ .map(i => {
+ const entry = { name: i.name, type: i.type };
+ if (i.label) entry.label = i.label;
+ else entry.label = i.name;
+ if (i.default) entry.default = i.default;
+ return entry;
+ });
+
+ // Switches: hardware type 'ADC' means 3POS (analog-read switch)
+ const switches = (hw.switches ?? []).map(s => {
+ const swType = (s.type === '3POS' || s.type === 'ADC') ? '3POS' : '2POS';
+ return { name: s.name, type: swType, default: s.default || swType };
+ });
+
+ // Trims
+ const trims = (hw.trims ?? []).map(t => ({ name: t.name }));
+
+ // Keys with side mapping, pretty labels, sorted by Companion grid row
+ const keys = (hw.keys ?? []).map(k => ({
+ key: k.key,
+ label: KEY_LABELS[k.key] || k.label || k.name,
+ side: (KEY_LAYOUT[k.key] || {}).side || 'R',
+ })).sort((a, b) => {
+ const ra = (KEY_LAYOUT[a.key] || {}).row ?? 99;
+ const rb = (KEY_LAYOUT[b.key] || {}).row ?? 99;
+ return ra - rb;
+ });
+
+ // Display / LCD info
+ const disp = hw.display ?? {};
+ const display = {
+ w: disp.w ?? 480,
+ h: disp.h ?? 272,
+ depth: disp.depth ?? 16,
+ };
+
+ // WASM filename uses the flavour name directly
+ return {
+ name: getDisplayName(flavour),
+ wasm: `edgetx-${flavour}-simulator.wasm`,
+ display,
+ inputs,
+ switches,
+ trims,
+ keys,
+ };
+}
+
+// Determine which flavours to generate: args, or fw.json targets (supported radios only)
+let flavours = process.argv.slice(2);
+if (flavours.length === 0) {
+ // Use fw.json as the source of supported targets
+ // The prefix in fw.json is the build target name (e.g. "x9dp2019-")
+ // which may differ from the hw_defs flavour (e.g. "x9d+2019")
+ const fw = JSON.parse(readFileSync(join(ROOT, 'fw.json'), 'utf-8'));
+ flavours = fw.targets.map(([, prefix]) => {
+ const buildTarget = prefix.replace(/-$/, '');
+ // Check if hw_defs file exists under this name; if not, try + substitution
+ try {
+ readFileSync(join(HW_DEFS, `${buildTarget}.json`));
+ return buildTarget;
+ } catch {
+ const plusForm = buildTarget.replace('dp', 'd+');
+ try {
+ readFileSync(join(HW_DEFS, `${plusForm}.json`));
+ return plusForm;
+ } catch {
+ return buildTarget; // will be skipped later
+ }
+ }
+ });
+}
+
+console.log(`Generating radios.json for ${flavours.length} flavours...`);
+
+const radios = [];
+for (const flavour of flavours) {
+ const entry = processFlavour(flavour);
+ if (entry) {
+ radios.push(entry);
+ console.log(` ✓ ${flavour} → ${entry.name}`);
+ }
+}
+
+writeFileSync(OUTPUT, JSON.stringify(radios, null, 2) + '\n');
+console.log(`\nWrote ${radios.length} entries to ${OUTPUT}`);
diff --git a/web/src/App.svelte b/web/src/App.svelte
new file mode 100644
index 000000000..fe24d4c24
--- /dev/null
+++ b/web/src/App.svelte
@@ -0,0 +1,2302 @@
+
+
+
+
+
+
+
+
+
+ {#snippet switchCtrl(sw: SwitchDef, index: number)}
+
+
{sw.name}
+
handleSwitchTrackClick(index, sw, e)}
+ onkeydown={(e) => handleSwitchKey(index, sw, e)}>
+ {#if sw.type === '3POS'}
+
+
+
+ {:else}
+
+
+ {/if}
+
+
handleSwitchDrag(index, sw, e)}
+ ontouchstart={(e) => handleSwitchTouchDrag(index, sw, e)}
+ >
+
+
+ {/snippet}
+
+ {#snippet lcdScreen()}
+
+ {#key canvasKey}
+
+ {/key}
+
+ {/snippet}
+
+ {#snippet leftGimbalBlock()}
+
+ {#if (currentRadio?.trims?.length ?? 0) >= 4}
+
+
+ T3
+
+
+ {/if}
+
+
Left Stick
+
handleGimbalDown('left', e)}
+ ontouchstart={(e) => handleGimbalTouchStart('left', e)}
+ role="slider"
+ aria-label="Left gimbal"
+ tabindex="0"
+ aria-valuemin={-1024}
+ aria-valuemax={1024}
+ aria-valuenow={analogValues[getStickIndices().lh] ?? 0}
+ >
+
+
+
+
+ {#if (currentRadio?.trims?.length ?? 0) >= 4}
+
+
+ T4
+
+
+ {/if}
+
+
+ {/snippet}
+
+ {#snippet rightGimbalBlock()}
+
+
+
Right Stick
+
handleGimbalDown('right', e)}
+ ontouchstart={(e) => handleGimbalTouchStart('right', e)}
+ role="slider"
+ aria-label="Right gimbal"
+ tabindex="0"
+ aria-valuemin={-1024}
+ aria-valuemax={1024}
+ aria-valuenow={analogValues[getStickIndices().rv] ?? 0}
+ >
+
+
+
+
+ {#if (currentRadio?.trims?.length ?? 0) >= 2}
+
+
+ T1
+
+
+ {/if}
+
+ {#if (currentRadio?.trims?.length ?? 0) >= 2}
+
+
+ T2
+
+
+ {/if}
+
+ {/snippet}
+
+ {#snippet flexCtrl(input: InputDef, index: number)}
+ {#if input.default === 'MULTIPOS'}
+
+
{input.label}
+
+ {#each [0, 1, 2, 3, 4, 5] as pos}
+
+ {/each}
+
+
+
+ {:else}
+ {@render potCtrl(input, index)}
+ {/if}
+ {/snippet}
+
+ {#snippet potsRow()}
+ {#if getPotsAndMultipos().length > 0}
+
+ {#if getCustomSwitches().length > 0}
+
+ {#each getLeftPotsAndMultipos() as { input, index }}
+ {@render flexCtrl(input, index)}
+ {/each}
+ {#each getCustomSwitches() as { sw, index: swIdx }, csIdx}
+
+
+ {sw.name}
+
+ {/each}
+ {#each getRightPotsAndMultipos() as { input, index }}
+ {@render flexCtrl(input, index)}
+ {/each}
+ {:else}
+
+ {#each getPotsAndMultipos() as { input, index }}
+ {@render flexCtrl(input, index)}
+ {/each}
+ {/if}
+
+ {/if}
+ {/snippet}
+
+ {#snippet customSwitchesRow()}
+ {#if getCustomSwitches().length > 0}
+
+ {#each getCustomSwitches() as { sw, index }, csIdx}
+
+
+ {sw.name}
+
+ {/each}
+
+ {/if}
+ {/snippet}
+
+ {#snippet potCtrl(input: InputDef, index: number)}
+
+ {input.label}
+ handlePotChange(index, e)}
+ class="pot-knob" />
+ {analogValues[index]}
+
+ {/snippet}
+
+ {#snippet keyRow()}
+ {#if currentRadio?.keys?.length}
+
+ {#each getLeftKeys() as keyDef}
+
+ {/each}
+ {#each getRightKeys() as keyDef}
+
+ {/each}
+
+ {/if}
+ {/snippet}
+
+ {#snippet extraTrims()}
+ {#if (currentRadio?.trims?.length ?? 0) > 4}
+
+ {/if}
+ {/snippet}
+
+
+ {#if isPortrait && currentRadio}
+
+
+
+
+
+ {#each getLeftSwitches() as { sw, index }}
+ {@render switchCtrl(sw, index)}
+ {/each}
+
+ {#if getLeftPotsAndMultipos().length > 0}
+
+ {#each getLeftPotsAndMultipos() as { input, index }}
+ {@render flexCtrl(input, index)}
+ {/each}
+
+ {/if}
+ {#if getSliders().length > 0}
+
+ {#each getSliders().slice(0, 1) as { input, index }}
+
+ {input.label}
+ handlePotChange(index, e)}
+ orient="vertical" class="vertical-slider" />
+
+ {/each}
+
+ {/if}
+ {@render leftGimbalBlock()}
+
+
+
+
+ {@render lcdScreen()}
+ {@render keyRow()}
+
+
+
+
+
+ {#each getRightSwitches() as { sw, index }}
+ {@render switchCtrl(sw, index)}
+ {/each}
+
+ {#if getRightPotsAndMultipos().length > 0}
+
+ {#each getRightPotsAndMultipos() as { input, index }}
+ {@render flexCtrl(input, index)}
+ {/each}
+
+ {/if}
+ {#if getSliders().length > 1}
+
+ {#each getSliders().slice(1, 2) as { input, index }}
+
+ {input.label}
+ handlePotChange(index, e)}
+ orient="vertical" class="vertical-slider" />
+
+ {/each}
+
+ {/if}
+ {@render rightGimbalBlock()}
+
+
+
+ {#if getPotsAndMultipos().length === 0}
+ {@render customSwitchesRow()}
+ {/if}
+ {@render extraTrims()}
+
+ {:else}
+
+
+ {#if currentRadio?.keys?.length}
+
+ {#each getLeftKeys() as keyDef}
+
+ {/each}
+
+ {/if}
+ {@render lcdScreen()}
+ {#if currentRadio?.keys?.length}
+
+ {#each getRightKeys() as keyDef}
+
+ {/each}
+
+ {/if}
+
+
+ {#if currentRadio}
+ {@render potsRow()}
+ {#if getPotsAndMultipos().length === 0}
+ {@render customSwitchesRow()}
+ {/if}
+
+
+
+ {#each getLeftSwitches() as { sw, index }}
+ {@render switchCtrl(sw, index)}
+ {/each}
+
+
+ {#if getSliders().length > 0}
+
+ {#each getSliders().slice(0, 1) as { input, index }}
+
+ {input.label}
+ handlePotChange(index, e)}
+ orient="vertical" class="vertical-slider" />
+
+ {/each}
+
+ {/if}
+
+ {@render leftGimbalBlock()}
+ {@render rightGimbalBlock()}
+
+ {#if getSliders().length > 1}
+
+ {#each getSliders().slice(1, 2) as { input, index }}
+
+ {input.label}
+ handlePotChange(index, e)}
+ orient="vertical" class="vertical-slider" />
+
+ {/each}
+
+ {/if}
+
+
+ {#each getRightSwitches() as { sw, index }}
+ {@render switchCtrl(sw, index)}
+ {/each}
+
+
+
+ {@render extraTrims()}
+ {/if}
+ {/if}
+
+
+
+ Trace output{#if running}{fps} fps{/if}
+ {traceLog}
+
+
+
+
diff --git a/web/src/assets/edgetx-logo.svg b/web/src/assets/edgetx-logo.svg
new file mode 100644
index 000000000..ef3c91e28
--- /dev/null
+++ b/web/src/assets/edgetx-logo.svg
@@ -0,0 +1,22 @@
+
+
+
diff --git a/web/src/lib/fs-proxy-client.ts b/web/src/lib/fs-proxy-client.ts
new file mode 100644
index 000000000..0e67f9776
--- /dev/null
+++ b/web/src/lib/fs-proxy-client.ts
@@ -0,0 +1,343 @@
+/**
+ * Worker-thread side of the filesystem proxy.
+ * Implements a synchronous Node.js-style fs interface by forwarding
+ * calls to the main thread via SharedArrayBuffer + Atomics.wait.
+ */
+
+import {
+ FsOp,
+ IDX_REQUEST_FLAG, IDX_RESPONSE_FLAG, IDX_OPCODE,
+ IDX_ARG1, IDX_ARG2, IDX_ARG3, IDX_ARG4,
+ IDX_RESULT, IDX_ERROR_CODE, IDX_DATA_LEN,
+ errorCodeToString, deserializeStat,
+} from './fs-proxy-protocol';
+
+class BigIntStatsProxy {
+ dev: bigint; ino: bigint; mode: bigint; nlink: bigint;
+ uid: bigint; gid: bigint; rdev: bigint; size: bigint;
+ blksize: bigint; blocks: bigint;
+ atimeMs: bigint; mtimeMs: bigint; ctimeMs: bigint; birthtimeMs: bigint;
+ atimeNs: bigint; mtimeNs: bigint; ctimeNs: bigint; birthtimeNs: bigint;
+ atime: Date; mtime: Date; ctime: Date; birthtime: Date;
+ private _flags: number;
+
+ constructor(d: ReturnType) {
+ this.dev = d.dev; this.ino = d.ino; this.mode = d.mode; this.nlink = d.nlink;
+ this.uid = d.uid; this.gid = d.gid; this.rdev = d.rdev; this.size = d.size;
+ this.blksize = d.blksize; this.blocks = d.blocks;
+ this.atimeMs = d.atimeMs; this.mtimeMs = d.mtimeMs;
+ this.ctimeMs = d.ctimeMs; this.birthtimeMs = d.birthtimeMs;
+ this.atimeNs = d.atimeMs * 1000000n;
+ this.mtimeNs = d.mtimeMs * 1000000n;
+ this.ctimeNs = d.ctimeMs * 1000000n;
+ this.birthtimeNs = d.birthtimeMs * 1000000n;
+ this.atime = new Date(Number(d.atimeMs));
+ this.mtime = new Date(Number(d.mtimeMs));
+ this.ctime = new Date(Number(d.ctimeMs));
+ this.birthtime = new Date(Number(d.birthtimeMs));
+ this._flags = d.flags;
+ }
+
+ isFile(): boolean { return !!(this._flags & 1); }
+ isDirectory(): boolean { return !!(this._flags & 2); }
+ isSymbolicLink(): boolean { return !!(this._flags & 4); }
+ isCharacterDevice(): boolean { return !!(this._flags & 8); }
+ isBlockDevice(): boolean { return !!(this._flags & 16); }
+ isSocket(): boolean { return !!(this._flags & 32); }
+ isFIFO(): boolean { return !!(this._flags & 64); }
+}
+
+class DirentProxy {
+ name: string;
+ private _type: number;
+
+ constructor(name: string, type: number) {
+ this.name = name;
+ this._type = type;
+ }
+
+ isFile(): boolean { return this._type === 1; }
+ isDirectory(): boolean { return this._type === 2; }
+ isSymbolicLink(): boolean { return this._type === 3; }
+ isCharacterDevice(): boolean { return this._type === 4; }
+ isBlockDevice(): boolean { return this._type === 5; }
+ isSocket(): boolean { return this._type === 6; }
+ isFIFO(): boolean { return this._type === 7; }
+}
+
+export class FsProxyClient {
+ private ctrl: Int32Array;
+ private data: Uint8Array;
+ private wake: Int32Array | null;
+ private encoder = new TextEncoder();
+ private decoder = new TextDecoder();
+
+ constructor(ctrlBuffer: SharedArrayBuffer, dataBuffer: SharedArrayBuffer, wakeBuffer?: SharedArrayBuffer) {
+ this.ctrl = new Int32Array(ctrlBuffer);
+ this.data = new Uint8Array(dataBuffer);
+ this.wake = wakeBuffer ? new Int32Array(wakeBuffer) : null;
+ }
+
+ private call(opcode: FsOp): void {
+ Atomics.store(this.ctrl, IDX_OPCODE, opcode);
+ Atomics.store(this.ctrl, IDX_RESPONSE_FLAG, 0);
+ Atomics.store(this.ctrl, IDX_REQUEST_FLAG, 1);
+ Atomics.notify(this.ctrl, IDX_REQUEST_FLAG);
+ if (this.wake) {
+ Atomics.store(this.wake, 0, 1);
+ Atomics.notify(this.wake, 0);
+ }
+ Atomics.wait(this.ctrl, IDX_RESPONSE_FLAG, 0);
+ }
+
+ private checkError(): void {
+ const code = Atomics.load(this.ctrl, IDX_ERROR_CODE);
+ if (code !== 0) {
+ const len = Atomics.load(this.ctrl, IDX_DATA_LEN);
+ const msg = this.decoder.decode(this.data.slice(0, len));
+ const err = new Error(msg);
+ (err as any).code = errorCodeToString(code);
+ throw err;
+ }
+ }
+
+ private writeStr(s: string, offset = 0): number {
+ const bytes = this.encoder.encode(s);
+ this.data.set(bytes, offset);
+ return bytes.length;
+ }
+
+ openSync(path: string, flags: number | string, mode: number = 0o666): number {
+ if (typeof flags === 'string') {
+ // Send flags as string: a1=-1 sentinel, data=[pathLen:u32][path][flagsStr]
+ const pathBytes = this.encoder.encode(path);
+ const flagsBytes = this.encoder.encode(flags);
+ const dv = new DataView(this.data.buffer, this.data.byteOffset);
+ dv.setUint32(0, pathBytes.length, true);
+ this.data.set(pathBytes, 4);
+ this.data.set(flagsBytes, 4 + pathBytes.length);
+ Atomics.store(this.ctrl, IDX_ARG1, -1);
+ Atomics.store(this.ctrl, IDX_ARG2, mode);
+ Atomics.store(this.ctrl, IDX_DATA_LEN, 4 + pathBytes.length + flagsBytes.length);
+ } else {
+ const len = this.writeStr(path);
+ Atomics.store(this.ctrl, IDX_ARG1, flags);
+ Atomics.store(this.ctrl, IDX_ARG2, mode);
+ Atomics.store(this.ctrl, IDX_DATA_LEN, len);
+ }
+ this.call(FsOp.OPEN);
+ this.checkError();
+ return Atomics.load(this.ctrl, IDX_RESULT);
+ }
+
+ closeSync(fd: number): void {
+ Atomics.store(this.ctrl, IDX_ARG1, fd);
+ Atomics.store(this.ctrl, IDX_DATA_LEN, 0);
+ this.call(FsOp.CLOSE);
+ this.checkError();
+ }
+
+ readSync(fd: number, buffer: Uint8Array, offset: number, length: number, position: number | null): number {
+ Atomics.store(this.ctrl, IDX_ARG1, fd);
+ Atomics.store(this.ctrl, IDX_ARG2, length);
+ if (position === null || position === undefined) {
+ Atomics.store(this.ctrl, IDX_ARG3, 0);
+ Atomics.store(this.ctrl, IDX_ARG4, -1);
+ } else {
+ Atomics.store(this.ctrl, IDX_ARG3, position | 0);
+ Atomics.store(this.ctrl, IDX_ARG4, Math.floor(position / 0x100000000) | 0);
+ }
+ Atomics.store(this.ctrl, IDX_DATA_LEN, 0);
+ this.call(FsOp.READ);
+ this.checkError();
+ const bytesRead = Atomics.load(this.ctrl, IDX_RESULT);
+ buffer.set(this.data.subarray(0, bytesRead), offset);
+ return bytesRead;
+ }
+
+ writeSync(fd: number, buffer: Uint8Array, offset: number, length: number, position: number | null): number {
+ this.data.set(buffer.subarray(offset, offset + length), 0);
+ Atomics.store(this.ctrl, IDX_ARG1, fd);
+ if (position === null || position === undefined) {
+ Atomics.store(this.ctrl, IDX_ARG2, 0);
+ Atomics.store(this.ctrl, IDX_ARG3, -1);
+ } else {
+ Atomics.store(this.ctrl, IDX_ARG2, position | 0);
+ Atomics.store(this.ctrl, IDX_ARG3, Math.floor(position / 0x100000000) | 0);
+ }
+ Atomics.store(this.ctrl, IDX_DATA_LEN, length);
+ this.call(FsOp.WRITE);
+ this.checkError();
+ return Atomics.load(this.ctrl, IDX_RESULT);
+ }
+
+ fstatSync(fd: number, _options?: { bigint: boolean }): BigIntStatsProxy {
+ Atomics.store(this.ctrl, IDX_ARG1, fd);
+ Atomics.store(this.ctrl, IDX_DATA_LEN, 0);
+ this.call(FsOp.FSTAT);
+ this.checkError();
+ return new BigIntStatsProxy(deserializeStat(this.data, 0));
+ }
+
+ statSync(path: string, _options?: { bigint: boolean }): BigIntStatsProxy {
+ const len = this.writeStr(path);
+ Atomics.store(this.ctrl, IDX_DATA_LEN, len);
+ this.call(FsOp.STAT);
+ this.checkError();
+ return new BigIntStatsProxy(deserializeStat(this.data, 0));
+ }
+
+ lstatSync(path: string, _options?: { bigint: boolean }): BigIntStatsProxy {
+ const len = this.writeStr(path);
+ Atomics.store(this.ctrl, IDX_DATA_LEN, len);
+ this.call(FsOp.LSTAT);
+ this.checkError();
+ return new BigIntStatsProxy(deserializeStat(this.data, 0));
+ }
+
+ ftruncateSync(fd: number, len: number): void {
+ Atomics.store(this.ctrl, IDX_ARG1, fd);
+ Atomics.store(this.ctrl, IDX_ARG2, len);
+ Atomics.store(this.ctrl, IDX_DATA_LEN, 0);
+ this.call(FsOp.FTRUNCATE);
+ this.checkError();
+ }
+
+ futimesSync(fd: number, atime: number, mtime: number): void {
+ Atomics.store(this.ctrl, IDX_ARG1, fd);
+ const tv = new DataView(this.data.buffer, this.data.byteOffset, 16);
+ tv.setFloat64(0, atime, true);
+ tv.setFloat64(8, mtime, true);
+ Atomics.store(this.ctrl, IDX_DATA_LEN, 16);
+ this.call(FsOp.FUTIMES);
+ this.checkError();
+ }
+
+ utimesSync(path: string, atime: number, mtime: number): void {
+ const pathLen = this.writeStr(path);
+ Atomics.store(this.ctrl, IDX_ARG1, pathLen);
+ const tv = new DataView(this.data.buffer, this.data.byteOffset + pathLen, 16);
+ tv.setFloat64(0, atime, true);
+ tv.setFloat64(8, mtime, true);
+ Atomics.store(this.ctrl, IDX_DATA_LEN, pathLen + 16);
+ this.call(FsOp.UTIMES);
+ this.checkError();
+ }
+
+ mkdirSync(path: string): void {
+ const len = this.writeStr(path);
+ Atomics.store(this.ctrl, IDX_DATA_LEN, len);
+ this.call(FsOp.MKDIR);
+ this.checkError();
+ }
+
+ readdirSync(path: string, options?: { withFileTypes: boolean }): any[] {
+ const len = this.writeStr(path);
+ Atomics.store(this.ctrl, IDX_ARG1, options?.withFileTypes ? 1 : 0);
+ Atomics.store(this.ctrl, IDX_DATA_LEN, len);
+ this.call(FsOp.READDIR);
+ this.checkError();
+
+ const respLen = Atomics.load(this.ctrl, IDX_DATA_LEN);
+
+ if (options?.withFileTypes) {
+ const dv = new DataView(this.data.buffer, this.data.byteOffset);
+ let off = 0;
+ const count = dv.getUint32(off, true); off += 4;
+ const result: DirentProxy[] = [];
+ for (let i = 0; i < count; i++) {
+ const type = this.data[off++];
+ const nameLen = dv.getUint32(off, true); off += 4;
+ const name = this.decoder.decode(this.data.slice(off, off + nameLen));
+ off += nameLen;
+ result.push(new DirentProxy(name, type));
+ }
+ return result;
+ } else {
+ const json = this.decoder.decode(this.data.slice(0, respLen));
+ return JSON.parse(json);
+ }
+ }
+
+ renameSync(oldPath: string, newPath: string): void {
+ const oldBytes = this.encoder.encode(oldPath);
+ const newBytes = this.encoder.encode(newPath);
+ const dv = new DataView(this.data.buffer, this.data.byteOffset);
+ dv.setUint32(0, oldBytes.length, true);
+ this.data.set(oldBytes, 4);
+ this.data.set(newBytes, 4 + oldBytes.length);
+ Atomics.store(this.ctrl, IDX_DATA_LEN, 4 + oldBytes.length + newBytes.length);
+ this.call(FsOp.RENAME);
+ this.checkError();
+ }
+
+ rmdirSync(path: string): void {
+ const len = this.writeStr(path);
+ Atomics.store(this.ctrl, IDX_DATA_LEN, len);
+ this.call(FsOp.RMDIR);
+ this.checkError();
+ }
+
+ unlinkSync(path: string): void {
+ const len = this.writeStr(path);
+ Atomics.store(this.ctrl, IDX_DATA_LEN, len);
+ this.call(FsOp.UNLINK);
+ this.checkError();
+ }
+
+ linkSync(existingPath: string, newPath: string): void {
+ const oldBytes = this.encoder.encode(existingPath);
+ const newBytes = this.encoder.encode(newPath);
+ const dv = new DataView(this.data.buffer, this.data.byteOffset);
+ dv.setUint32(0, oldBytes.length, true);
+ this.data.set(oldBytes, 4);
+ this.data.set(newBytes, 4 + oldBytes.length);
+ Atomics.store(this.ctrl, IDX_DATA_LEN, 4 + oldBytes.length + newBytes.length);
+ this.call(FsOp.LINK);
+ this.checkError();
+ }
+
+ symlinkSync(target: string, path: string): void {
+ const targetBytes = this.encoder.encode(target);
+ const pathBytes = this.encoder.encode(path);
+ const dv = new DataView(this.data.buffer, this.data.byteOffset);
+ dv.setUint32(0, targetBytes.length, true);
+ this.data.set(targetBytes, 4);
+ this.data.set(pathBytes, 4 + targetBytes.length);
+ Atomics.store(this.ctrl, IDX_DATA_LEN, 4 + targetBytes.length + pathBytes.length);
+ this.call(FsOp.SYMLINK);
+ this.checkError();
+ }
+
+ readlinkSync(path: string): string {
+ const len = this.writeStr(path);
+ Atomics.store(this.ctrl, IDX_DATA_LEN, len);
+ this.call(FsOp.READLINK);
+ this.checkError();
+ const respLen = Atomics.load(this.ctrl, IDX_DATA_LEN);
+ return this.decoder.decode(this.data.slice(0, respLen));
+ }
+
+ realpathSync(path: string, _encoding?: string): string {
+ const len = this.writeStr(path);
+ Atomics.store(this.ctrl, IDX_DATA_LEN, len);
+ this.call(FsOp.REALPATH);
+ this.checkError();
+ const respLen = Atomics.load(this.ctrl, IDX_DATA_LEN);
+ return this.decoder.decode(this.data.slice(0, respLen));
+ }
+
+ fdatasyncSync(fd: number): void {
+ Atomics.store(this.ctrl, IDX_ARG1, fd);
+ Atomics.store(this.ctrl, IDX_DATA_LEN, 0);
+ this.call(FsOp.FDATASYNC);
+ this.checkError();
+ }
+
+ fsyncSync(fd: number): void {
+ Atomics.store(this.ctrl, IDX_ARG1, fd);
+ Atomics.store(this.ctrl, IDX_DATA_LEN, 0);
+ this.call(FsOp.FSYNC);
+ this.checkError();
+ }
+}
diff --git a/web/src/lib/fs-proxy-protocol.ts b/web/src/lib/fs-proxy-protocol.ts
new file mode 100644
index 000000000..ea11d91a5
--- /dev/null
+++ b/web/src/lib/fs-proxy-protocol.ts
@@ -0,0 +1,126 @@
+/**
+ * Shared protocol constants for the filesystem proxy.
+ * Imported by both main thread (host) and worker (client).
+ */
+
+export enum FsOp {
+ OPEN = 1,
+ CLOSE,
+ READ,
+ WRITE,
+ FSTAT,
+ STAT,
+ LSTAT,
+ FTRUNCATE,
+ FUTIMES,
+ UTIMES,
+ MKDIR,
+ READDIR,
+ RENAME,
+ RMDIR,
+ UNLINK,
+ LINK,
+ SYMLINK,
+ READLINK,
+ REALPATH,
+ FDATASYNC,
+ FSYNC,
+}
+
+// Control buffer Int32 indices
+export const IDX_REQUEST_FLAG = 0;
+export const IDX_RESPONSE_FLAG = 1;
+export const IDX_OPCODE = 2;
+export const IDX_ARG1 = 3;
+export const IDX_ARG2 = 4;
+export const IDX_ARG3 = 5;
+export const IDX_ARG4 = 6;
+export const IDX_RESULT = 7;
+export const IDX_ERROR_CODE = 8;
+export const IDX_DATA_LEN = 9;
+
+export const CTRL_BUFFER_SIZE = 64 * 4; // 64 Int32s
+export const DATA_BUFFER_SIZE = 4 * 1024 * 1024; // 4 MB
+export const WAKE_BUFFER_SIZE = 4; // single Int32 shared across all channels
+
+// Error code mapping (Node.js errno strings → numeric)
+const ERROR_CODE_MAP: Record = {
+ EPERM: 1, ENOENT: 2, ESRCH: 3, EINTR: 4, EIO: 5,
+ ENXIO: 6, EBADF: 9, EAGAIN: 11, ENOMEM: 12, EACCES: 13,
+ EEXIST: 17, ENODEV: 19, ENOTDIR: 20, EISDIR: 21,
+ EINVAL: 22, EMFILE: 24, ENOSPC: 28, EROFS: 30,
+ ENOTEMPTY: 39, ENOSYS: 38, ELOOP: 40,
+};
+
+const CODE_TO_STRING: Record = {};
+for (const [k, v] of Object.entries(ERROR_CODE_MAP)) {
+ CODE_TO_STRING[v] = k;
+}
+
+export function errorStringToCode(code: string): number {
+ return ERROR_CODE_MAP[code] ?? 255;
+}
+
+export function errorCodeToString(code: number): string {
+ return CODE_TO_STRING[code] ?? 'EUNKNOWN';
+}
+
+// Stat serialization: 128 bytes as Float64Array (16 doubles) + flags byte
+export const STAT_SIZE = 15 * 8 + 8; // 128 bytes
+
+export interface StatData {
+ dev: bigint; ino: bigint; mode: bigint; nlink: bigint;
+ uid: bigint; gid: bigint; rdev: bigint; size: bigint;
+ blksize: bigint; blocks: bigint;
+ atimeMs: bigint; mtimeMs: bigint; ctimeMs: bigint; birthtimeMs: bigint;
+ flags: number; // packed booleans
+}
+
+export function serializeStat(stat: any, buf: Uint8Array, offset: number): void {
+ const view = new DataView(buf.buffer, buf.byteOffset + offset, STAT_SIZE);
+ view.setFloat64(0, Number(stat.dev), true);
+ view.setFloat64(8, Number(stat.ino), true);
+ view.setFloat64(16, Number(stat.mode), true);
+ view.setFloat64(24, Number(stat.nlink), true);
+ view.setFloat64(32, Number(stat.uid), true);
+ view.setFloat64(40, Number(stat.gid), true);
+ view.setFloat64(48, Number(stat.rdev), true);
+ view.setFloat64(56, Number(stat.size), true);
+ view.setFloat64(64, Number(stat.blksize), true);
+ view.setFloat64(72, Number(stat.blocks), true);
+ view.setFloat64(80, Number(stat.atimeMs), true);
+ view.setFloat64(88, Number(stat.mtimeMs), true);
+ view.setFloat64(96, Number(stat.ctimeMs), true);
+ view.setFloat64(104, Number(stat.birthtimeMs), true);
+
+ let flags = 0;
+ if (stat.isFile()) flags |= 1;
+ if (stat.isDirectory()) flags |= 2;
+ if (typeof stat.isSymbolicLink === 'function' && stat.isSymbolicLink()) flags |= 4;
+ if (typeof stat.isCharacterDevice === 'function' && stat.isCharacterDevice()) flags |= 8;
+ if (typeof stat.isBlockDevice === 'function' && stat.isBlockDevice()) flags |= 16;
+ if (typeof stat.isSocket === 'function' && stat.isSocket()) flags |= 32;
+ if (typeof stat.isFIFO === 'function' && stat.isFIFO()) flags |= 64;
+ view.setFloat64(112, flags, true);
+}
+
+export function deserializeStat(buf: Uint8Array, offset: number): StatData {
+ const view = new DataView(buf.buffer, buf.byteOffset + offset, STAT_SIZE);
+ return {
+ dev: BigInt(view.getFloat64(0, true)),
+ ino: BigInt(view.getFloat64(8, true)),
+ mode: BigInt(view.getFloat64(16, true)),
+ nlink: BigInt(view.getFloat64(24, true)),
+ uid: BigInt(view.getFloat64(32, true)),
+ gid: BigInt(view.getFloat64(40, true)),
+ rdev: BigInt(view.getFloat64(48, true)),
+ size: BigInt(view.getFloat64(56, true)),
+ blksize: BigInt(view.getFloat64(64, true)),
+ blocks: BigInt(view.getFloat64(72, true)),
+ atimeMs: BigInt(view.getFloat64(80, true)),
+ mtimeMs: BigInt(view.getFloat64(88, true)),
+ ctimeMs: BigInt(view.getFloat64(96, true)),
+ birthtimeMs: BigInt(view.getFloat64(104, true)),
+ flags: view.getFloat64(112, true),
+ };
+}
diff --git a/web/src/lib/fs-worker.ts b/web/src/lib/fs-worker.ts
new file mode 100644
index 000000000..876438d31
--- /dev/null
+++ b/web/src/lib/fs-worker.ts
@@ -0,0 +1,414 @@
+/**
+ * Dedicated FS Worker — owns all OPFS state and serves filesystem
+ * requests from WASM workers (via SharedArrayBuffer + Atomics) and
+ * the main thread (via its own async channel).
+ *
+ * Lifecycle:
+ * 1. Main thread posts 'init' → backend scans OPFS, posts 'ready'
+ * 2. Main thread posts 'channel' × N → worker stores channels
+ * 3. Main thread posts 'start' → worker enters Atomics loop
+ * 4. Main thread posts 'stop' → worker exits loop, posts 'stopped'
+ */
+
+import { OpfsBackend } from './opfs-backend';
+import {
+ FsOp,
+ IDX_REQUEST_FLAG, IDX_RESPONSE_FLAG, IDX_OPCODE,
+ IDX_ARG1, IDX_ARG2, IDX_ARG3, IDX_ARG4,
+ IDX_RESULT, IDX_ERROR_CODE, IDX_DATA_LEN,
+ DATA_BUFFER_SIZE, STAT_SIZE,
+ errorStringToCode, serializeStat,
+} from './fs-proxy-protocol';
+
+interface Channel {
+ ctrl: Int32Array;
+ data: Uint8Array;
+}
+
+let backend: OpfsBackend;
+let channels: Channel[] = [];
+let wake: Int32Array;
+let running = false;
+
+const encoder = new TextEncoder();
+const decoder = new TextDecoder();
+
+function trace(msg: string) {
+ self.postMessage({ type: 'trace', text: `[fs-worker] ${msg}\n` });
+}
+
+// --- Message handler (init / channel registration / lifecycle) ---
+
+self.onmessage = async (e: MessageEvent) => {
+ switch (e.data.type) {
+ case 'init': {
+ wake = new Int32Array(e.data.wakeBuffer);
+ backend = new OpfsBackend();
+ await backend.init(e.data.radioKey);
+ trace(`init done, radioKey="${e.data.radioKey}", hasContent=${backend.hasContent}`);
+ self.postMessage({ type: 'ready', hasContent: backend.hasContent });
+ break;
+ }
+ case 'channel': {
+ channels.push({
+ ctrl: new Int32Array(e.data.ctrlBuffer),
+ data: new Uint8Array(e.data.dataBuffer),
+ });
+ trace(`channel registered (total: ${channels.length})`);
+ break;
+ }
+ case 'start': {
+ if (!running) {
+ running = true;
+ trace('mainLoop starting');
+ mainLoop();
+ } else {
+ trace('mainLoop already running, ignoring start');
+ }
+ break;
+ }
+ case 'stop': {
+ running = false;
+ if (wake) {
+ Atomics.store(wake, 0, 1);
+ Atomics.notify(wake, 0);
+ }
+ break;
+ }
+
+ // --- UI operations (used by main thread before/after sim runs) ---
+
+ case 'readTextFile': {
+ try {
+ const data = await backend.readFile(e.data.path);
+ const text = new TextDecoder().decode(data);
+ self.postMessage({ type: 'readTextFileDone', id: e.data.id, text });
+ } catch (err: any) {
+ self.postMessage({ type: 'readTextFileDone', id: e.data.id, error: err?.message ?? 'read error' });
+ }
+ break;
+ }
+ case 'writeFile': {
+ try {
+ await backend.writeFile(e.data.path, new Uint8Array(e.data.data));
+ self.postMessage({ type: 'writeFileDone', id: e.data.id });
+ } catch (err: any) {
+ self.postMessage({ type: 'writeFileDone', id: e.data.id, error: err?.message ?? 'write error' });
+ }
+ break;
+ }
+ case 'wipe': {
+ await backend.wipe();
+ self.postMessage({ type: 'wiped', id: e.data.id });
+ break;
+ }
+ case 'listFiles': {
+ const files = backend.listFiles(e.data.basePath ?? '/');
+ self.postMessage({ type: 'listFilesDone', id: e.data.id, files });
+ break;
+ }
+ }
+};
+
+// --- Main loop ---
+
+async function mainLoop() {
+ trace(`mainLoop entered, channels=${channels.length}`);
+ while (running) {
+ const asyncWork = runSyncBatch();
+ if (asyncWork) {
+ await asyncWork();
+ } else {
+ // Yield to let the event loop process messages (stop, etc.)
+ await new Promise(resolve => setTimeout(resolve, 0));
+ }
+ }
+ trace('mainLoop exiting, closing handles');
+ backend.closeAll();
+ self.postMessage({ type: 'stopped' });
+}
+
+/**
+ * Process requests synchronously until an async operation is needed
+ * or we've been idle long enough to yield for the event loop.
+ */
+function runSyncBatch(): (() => Promise) | null {
+ let idleCount = 0;
+
+ while (running) {
+ let didWork = false;
+
+ for (const ch of channels) {
+ if (Atomics.load(ch.ctrl, IDX_REQUEST_FLAG) !== 0) {
+ const asyncNeeded = dispatch(ch);
+ if (asyncNeeded) return asyncNeeded;
+ didWork = true;
+ }
+ }
+
+ if (didWork) {
+ idleCount = 0;
+ } else {
+ idleCount++;
+ if (idleCount >= 2) return null; // yield after ~10ms idle
+ Atomics.wait(wake, 0, 0, 5);
+ Atomics.store(wake, 0, 0);
+ }
+ }
+ return null;
+}
+
+// --- Request dispatch ---
+
+function dispatch(ch: Channel): (() => Promise) | null {
+ const opcode = Atomics.load(ch.ctrl, IDX_OPCODE) as FsOp;
+ const a1 = Atomics.load(ch.ctrl, IDX_ARG1);
+ const a2 = Atomics.load(ch.ctrl, IDX_ARG2);
+ const a3 = Atomics.load(ch.ctrl, IDX_ARG3);
+ const a4 = Atomics.load(ch.ctrl, IDX_ARG4);
+ const dataLen = Atomics.load(ch.ctrl, IDX_DATA_LEN);
+
+ try {
+ switch (opcode) {
+
+ // ============ Async operations ============
+
+ case FsOp.OPEN: {
+ let path: string;
+ let flags: number | string;
+ if (a1 === -1) {
+ // String flags: [pathLen:u32][path][flagsStr]
+ const pathLen = new DataView(ch.data.buffer, ch.data.byteOffset, 4).getUint32(0, true);
+ path = decoder.decode(ch.data.slice(4, 4 + pathLen));
+ flags = decoder.decode(ch.data.slice(4 + pathLen, dataLen));
+ } else {
+ path = decoder.decode(ch.data.slice(0, dataLen));
+ flags = a1;
+ }
+ const mode = a2;
+ return async () => {
+ try {
+ const fd = await backend.open(path, flags, mode);
+ Atomics.store(ch.ctrl, IDX_RESULT, fd);
+ respond(ch);
+ } catch (e: any) { respondError(ch, e); }
+ };
+ }
+
+ case FsOp.MKDIR: {
+ const path = decoder.decode(ch.data.slice(0, dataLen));
+ return async () => {
+ try {
+ await backend.mkdir(path);
+ respond(ch);
+ } catch (e: any) { respondError(ch, e); }
+ };
+ }
+
+ case FsOp.RENAME: {
+ const dv = new DataView(ch.data.buffer, ch.data.byteOffset);
+ const oldLen = dv.getUint32(0, true);
+ const oldPath = decoder.decode(ch.data.slice(4, 4 + oldLen));
+ const newPath = decoder.decode(ch.data.slice(4 + oldLen, dataLen));
+ return async () => {
+ try {
+ await backend.rename(oldPath, newPath);
+ respond(ch);
+ } catch (e: any) { respondError(ch, e); }
+ };
+ }
+
+ case FsOp.RMDIR: {
+ const path = decoder.decode(ch.data.slice(0, dataLen));
+ return async () => {
+ try {
+ await backend.rmdir(path);
+ respond(ch);
+ } catch (e: any) { respondError(ch, e); }
+ };
+ }
+
+ case FsOp.UNLINK: {
+ const path = decoder.decode(ch.data.slice(0, dataLen));
+ return async () => {
+ try {
+ await backend.unlink(path);
+ respond(ch);
+ } catch (e: any) { respondError(ch, e); }
+ };
+ }
+
+ // ============ Sync operations ============
+
+ case FsOp.CLOSE: {
+ backend.close(a1);
+ respond(ch);
+ return null;
+ }
+
+ case FsOp.READ: {
+ const len = Math.min(a2, DATA_BUFFER_SIZE);
+ const pos = a4 === -1 ? null : (a4 * 0x100000000 + (a3 >>> 0));
+ const { data, bytesRead } = backend.read(a1, len, pos);
+ ch.data.set(data.subarray(0, bytesRead), 0);
+ Atomics.store(ch.ctrl, IDX_RESULT, bytesRead);
+ Atomics.store(ch.ctrl, IDX_DATA_LEN, bytesRead);
+ respond(ch);
+ return null;
+ }
+
+ case FsOp.WRITE: {
+ const pos = a3 === -1 ? null : (a3 * 0x100000000 + (a2 >>> 0));
+ const writeData = ch.data.slice(0, dataLen);
+ const written = backend.write(a1, writeData, pos);
+ Atomics.store(ch.ctrl, IDX_RESULT, written);
+ respond(ch);
+ return null;
+ }
+
+ case FsOp.FSTAT: {
+ const stat = backend.fstat(a1);
+ serializeStat(stat, ch.data, 0);
+ Atomics.store(ch.ctrl, IDX_DATA_LEN, STAT_SIZE);
+ respond(ch);
+ return null;
+ }
+
+ case FsOp.STAT: {
+ const path = decoder.decode(ch.data.slice(0, dataLen));
+ const stat = backend.stat(path);
+ serializeStat(stat, ch.data, 0);
+ Atomics.store(ch.ctrl, IDX_DATA_LEN, STAT_SIZE);
+ respond(ch);
+ return null;
+ }
+
+ case FsOp.LSTAT: {
+ const path = decoder.decode(ch.data.slice(0, dataLen));
+ const stat = backend.lstat(path);
+ serializeStat(stat, ch.data, 0);
+ Atomics.store(ch.ctrl, IDX_DATA_LEN, STAT_SIZE);
+ respond(ch);
+ return null;
+ }
+
+ case FsOp.FTRUNCATE: {
+ backend.ftruncate(a1, a2);
+ respond(ch);
+ return null;
+ }
+
+ case FsOp.FUTIMES: {
+ const tv = new DataView(ch.data.buffer, ch.data.byteOffset, 16);
+ backend.futimes(a1, tv.getFloat64(0, true), tv.getFloat64(8, true));
+ respond(ch);
+ return null;
+ }
+
+ case FsOp.UTIMES: {
+ const path = decoder.decode(ch.data.slice(0, a1));
+ const tv = new DataView(ch.data.buffer, ch.data.byteOffset + a1, 16);
+ backend.utimes(path, tv.getFloat64(0, true), tv.getFloat64(8, true));
+ respond(ch);
+ return null;
+ }
+
+ case FsOp.READDIR: {
+ const path = decoder.decode(ch.data.slice(0, dataLen));
+ if (a1) {
+ // withFileTypes
+ const entries = backend.readdir(path, true);
+ let off = 0;
+ const dv = new DataView(ch.data.buffer, ch.data.byteOffset);
+ dv.setUint32(off, entries.length, true); off += 4;
+ for (const ent of entries) {
+ let type = 0;
+ if (ent.isFile()) type = 1;
+ else if (ent.isDirectory()) type = 2;
+ ch.data[off++] = type;
+ const nameBytes = encoder.encode(ent.name);
+ dv.setUint32(off, nameBytes.length, true); off += 4;
+ ch.data.set(nameBytes, off); off += nameBytes.length;
+ }
+ Atomics.store(ch.ctrl, IDX_DATA_LEN, off);
+ } else {
+ const entries = backend.readdir(path, false) as string[];
+ const json = JSON.stringify(entries);
+ const bytes = encoder.encode(json);
+ ch.data.set(bytes, 0);
+ Atomics.store(ch.ctrl, IDX_DATA_LEN, bytes.length);
+ }
+ respond(ch);
+ return null;
+ }
+
+ case FsOp.LINK:
+ throw Object.assign(new Error('ENOSYS: hardlinks not supported'), { code: 'ENOSYS' });
+
+ case FsOp.SYMLINK:
+ throw Object.assign(new Error('ENOSYS: symlinks not supported'), { code: 'ENOSYS' });
+
+ case FsOp.READLINK: {
+ // OPFS has no symlinks — return EINVAL (not a symlink), which is
+ // what Node.js returns for non-symlink paths. The WASI library
+ // calls readlinkSync during path resolution and expects EINVAL to
+ // mean "not a symlink, continue resolving."
+ const rlPath = decoder.decode(ch.data.slice(0, dataLen));
+ throw Object.assign(new Error(`EINVAL: ${rlPath}`), { code: 'EINVAL' });
+ }
+
+ case FsOp.REALPATH: {
+ const path = decoder.decode(ch.data.slice(0, dataLen));
+ const resolved = backend.realpath(path);
+ const bytes = encoder.encode(resolved);
+ ch.data.set(bytes, 0);
+ Atomics.store(ch.ctrl, IDX_DATA_LEN, bytes.length);
+ respond(ch);
+ return null;
+ }
+
+ case FsOp.FDATASYNC: {
+ backend.fdatasync(a1);
+ respond(ch);
+ return null;
+ }
+
+ case FsOp.FSYNC: {
+ backend.fsync(a1);
+ respond(ch);
+ return null;
+ }
+
+ default:
+ throw Object.assign(new Error(`unsupported fs op: ${opcode}`), { code: 'ENOSYS' });
+ }
+ } catch (e: any) {
+ respondError(ch, e);
+ return null;
+ }
+}
+
+// --- Response helpers ---
+
+function respond(ch: Channel): void {
+ Atomics.store(ch.ctrl, IDX_ERROR_CODE, 0);
+ Atomics.store(ch.ctrl, IDX_REQUEST_FLAG, 0);
+ Atomics.store(ch.ctrl, IDX_RESPONSE_FLAG, 1);
+ Atomics.notify(ch.ctrl, IDX_RESPONSE_FLAG);
+}
+
+function respondError(ch: Channel, e: any): void {
+ // Don't log EINVAL from readlink (expected, very frequent) or ENOENT (normal)
+ if (e?.code !== 'EINVAL' && e?.code !== 'ENOENT') {
+ trace(`ERROR ${e?.code ?? '?'}: ${e?.message ?? e}`);
+ }
+ const code = e?.code ? errorStringToCode(e.code) : 255;
+ Atomics.store(ch.ctrl, IDX_ERROR_CODE, code);
+ const msg = encoder.encode(e?.message ?? 'unknown error');
+ const len = Math.min(msg.length, DATA_BUFFER_SIZE);
+ ch.data.set(msg.subarray(0, len), 0);
+ Atomics.store(ch.ctrl, IDX_DATA_LEN, len);
+ Atomics.store(ch.ctrl, IDX_REQUEST_FLAG, 0);
+ Atomics.store(ch.ctrl, IDX_RESPONSE_FLAG, 1);
+ Atomics.notify(ch.ctrl, IDX_RESPONSE_FLAG);
+}
diff --git a/web/src/lib/lcd-renderer.ts b/web/src/lib/lcd-renderer.ts
new file mode 100644
index 000000000..4b9aae1a1
--- /dev/null
+++ b/web/src/lib/lcd-renderer.ts
@@ -0,0 +1,211 @@
+// ---- Shader sources ----
+
+const VERT_SRC = `
+attribute vec2 aPos;
+varying vec2 vUv;
+void main() {
+ vUv = vec2(aPos.x * 0.5 + 0.5, 0.5 - aPos.y * 0.5);
+ gl_Position = vec4(aPos, 0.0, 1.0);
+}`;
+
+const FRAG_SRC = `
+precision mediump float;
+varying vec2 vUv;
+uniform sampler2D uTex;
+uniform vec2 uLcdSize; // LCD pixel dimensions (e.g. 212, 64)
+uniform int uDotMatrix; // 1 = dot-matrix effect, 0 = plain
+uniform vec3 uBacklight; // backlight color (normalised)
+uniform float uGridAlpha; // grid line blend strength
+
+void main() {
+ // Nearest-neighbour sample of the LCD texture
+ vec2 texel = (floor(vUv * uLcdSize) + 0.5) / uLcdSize;
+ vec4 pixel = texture2D(uTex, texel);
+
+ if (uDotMatrix == 0) {
+ gl_FragColor = pixel;
+ return;
+ }
+
+ // Position within the LCD pixel cell (0..1)
+ vec2 cell = fract(vUv * uLcdSize);
+
+ // Distance from cell edge (0 at edge, 0.5 at centre)
+ vec2 d = 0.5 - abs(cell - 0.5);
+
+ // Grid line: smoothstep near the edge
+ float gridH = 1.0 - smoothstep(0.0, 0.25, d.x);
+ float gridV = 1.0 - smoothstep(0.0, 0.25, d.y);
+ float grid = max(gridH, gridV);
+
+ // Blend pixel colour toward backlight at grid lines
+ vec3 color = mix(pixel.rgb, uBacklight, grid * uGridAlpha);
+ gl_FragColor = vec4(color, 1.0);
+}`;
+
+// Backlight color (matches Companion: rgb(47, 123, 227))
+const BG_R = 47 / 255, BG_G = 123 / 255, BG_B = 227 / 255;
+
+// ---- Framebuffer decode helpers (CPU → RGBA texture data) ----
+
+/** Decode RGB565 framebuffer into RGBA. */
+function decodeRgb565(data: Uint8Array, w: number, h: number, out: Uint8Array): void {
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
+ for (let i = 0; i < w * h; i++) {
+ const rgb565 = view.getUint16(i * 2, true);
+ const j = i * 4;
+ out[j] = ((rgb565 >> 11) & 0x1f) << 3;
+ out[j + 1] = ((rgb565 >> 5) & 0x3f) << 2;
+ out[j + 2] = (rgb565 & 0x1f) << 3;
+ out[j + 3] = 255;
+ }
+}
+
+/** Decode 4-bit grayscale framebuffer into RGBA with backlight tint. */
+function decode4bit(data: Uint8Array, w: number, h: number, out: Uint8Array): void {
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ const byteIdx = (y >> 1) * w + x;
+ const nibble = (y & 1) ? (data[byteIdx] >> 4) & 0x0f : data[byteIdx] & 0x0f;
+ const t = nibble / 15;
+ const j = (y * w + x) * 4;
+ out[j] = (BG_R * 255 * (1 - t)) | 0;
+ out[j + 1] = (BG_G * 255 * (1 - t)) | 0;
+ out[j + 2] = (BG_B * 255 * (1 - t)) | 0;
+ out[j + 3] = 255;
+ }
+ }
+}
+
+/** Decode 1-bit monochrome framebuffer into RGBA with backlight tint. */
+function decode1bit(data: Uint8Array, w: number, h: number, out: Uint8Array): void {
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ const byteIdx = (y >> 3) * w + x;
+ const bit = (data[byteIdx] >> (y & 7)) & 1;
+ const j = (y * w + x) * 4;
+ if (bit) {
+ out[j] = 0; out[j + 1] = 0; out[j + 2] = 0;
+ } else {
+ out[j] = BG_R * 255; out[j + 1] = BG_G * 255; out[j + 2] = BG_B * 255;
+ }
+ out[j + 3] = 255;
+ }
+ }
+}
+
+// ---- WebGL LCD Renderer ----
+
+export class LcdRenderer {
+ private gl: WebGLRenderingContext;
+ private texture: WebGLTexture;
+ private texBuf: Uint8Array | null = null;
+ private texW = 0;
+ private texH = 0;
+
+ // Uniform locations
+ private uTex: WebGLUniformLocation;
+ private uLcdSize: WebGLUniformLocation;
+ private uDotMatrix: WebGLUniformLocation;
+ private uBacklight: WebGLUniformLocation;
+ private uGridAlpha: WebGLUniformLocation;
+
+ constructor(canvas: HTMLCanvasElement) {
+ const gl = canvas.getContext('webgl', { antialias: false, alpha: false })!;
+ if (!gl) throw new Error('WebGL not available');
+ this.gl = gl;
+
+ // Compile shaders
+ const vs = this.compileShader(gl.VERTEX_SHADER, VERT_SRC);
+ const fs = this.compileShader(gl.FRAGMENT_SHADER, FRAG_SRC);
+ const prog = gl.createProgram()!;
+ gl.attachShader(prog, vs);
+ gl.attachShader(prog, fs);
+ gl.linkProgram(prog);
+ if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
+ throw new Error('Shader link: ' + gl.getProgramInfoLog(prog));
+ }
+ gl.useProgram(prog);
+
+ // Get uniform locations
+ this.uTex = gl.getUniformLocation(prog, 'uTex')!;
+ this.uLcdSize = gl.getUniformLocation(prog, 'uLcdSize')!;
+ this.uDotMatrix = gl.getUniformLocation(prog, 'uDotMatrix')!;
+ this.uBacklight = gl.getUniformLocation(prog, 'uBacklight')!;
+ this.uGridAlpha = gl.getUniformLocation(prog, 'uGridAlpha')!;
+
+ // Full-screen quad
+ const buf = gl.createBuffer()!;
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
+ -1, -1, 1, -1, -1, 1,
+ -1, 1, 1, -1, 1, 1,
+ ]), gl.STATIC_DRAW);
+ const aPos = gl.getAttribLocation(prog, 'aPos');
+ gl.enableVertexAttribArray(aPos);
+ gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
+
+ // Create texture
+ this.texture = gl.createTexture()!;
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ }
+
+ /** Resize the canvas and viewport. Call when LCD dimensions are known. */
+ resize(canvasWidth: number, canvasHeight: number): void {
+ const canvas = this.gl.canvas as HTMLCanvasElement;
+ canvas.width = canvasWidth;
+ canvas.height = canvasHeight;
+ this.gl.viewport(0, 0, canvasWidth, canvasHeight);
+ }
+
+ /** Render an LCD frame. */
+ render(data: Uint8Array, width: number, height: number, depth: number): void {
+ const gl = this.gl;
+
+ // (Re)allocate texture buffer if LCD size changed
+ if (width !== this.texW || height !== this.texH) {
+ this.texW = width;
+ this.texH = height;
+ this.texBuf = new Uint8Array(width * height * 4);
+ }
+
+ // Decode framebuffer into RGBA
+ const buf = this.texBuf!;
+ if (depth === 16) {
+ decodeRgb565(data, width, height, buf);
+ } else if (depth === 4) {
+ decode4bit(data, width, height, buf);
+ } else {
+ decode1bit(data, width, height, buf);
+ }
+
+ // Upload texture
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, buf);
+
+ // Set uniforms
+ gl.uniform1i(this.uTex, 0);
+ gl.uniform2f(this.uLcdSize, width, height);
+ gl.uniform1i(this.uDotMatrix, depth < 16 ? 1 : 0);
+ gl.uniform3f(this.uBacklight, BG_R, BG_G, BG_B);
+ gl.uniform1f(this.uGridAlpha, 0.5);
+
+ // Draw
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
+ }
+
+ private compileShader(type: number, src: string): WebGLShader {
+ const gl = this.gl;
+ const shader = gl.createShader(type)!;
+ gl.shaderSource(shader, src);
+ gl.compileShader(shader);
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+ throw new Error('Shader compile: ' + gl.getShaderInfoLog(shader));
+ }
+ return shader;
+ }
+}
diff --git a/web/src/lib/opfs-backend.ts b/web/src/lib/opfs-backend.ts
new file mode 100644
index 000000000..e875760bf
--- /dev/null
+++ b/web/src/lib/opfs-backend.ts
@@ -0,0 +1,677 @@
+/**
+ * OPFS-backed filesystem for the FS Worker.
+ *
+ * Maintains a lightweight in-memory directory index (metadata only) for
+ * synchronous path resolution and readdir. File I/O uses OPFS
+ * SyncAccessHandles for synchronous read/write without any memfs layer.
+ *
+ * Async operations (open, mkdir, unlink, rmdir, rename) need OPFS directory
+ * mutations which are inherently async. The FS Worker handles these by
+ * briefly breaking out of its synchronous Atomics loop.
+ */
+
+// --- Directory index node types ---
+
+interface FileNode {
+ name: string;
+ kind: 'file';
+ handle: FileSystemFileHandle;
+ size: number;
+ mtime: number;
+}
+
+interface DirNode {
+ name: string;
+ kind: 'directory';
+ handle: FileSystemDirectoryHandle;
+ children: Map;
+ mtime: number;
+}
+
+type FsNode = FileNode | DirNode;
+
+// --- Open file tracking ---
+
+interface OpenFlags {
+ read: boolean;
+ write: boolean;
+ append: boolean;
+ create: boolean;
+ truncate: boolean;
+ exclusive: boolean;
+}
+
+/** Shared SyncAccessHandle with refcounting for multiple fds on the same file. */
+interface SharedHandle {
+ syncHandle: FileSystemSyncAccessHandle;
+ node: FileNode;
+ refCount: number;
+}
+
+interface OpenFd {
+ /** Shared handle (null for directory fds). */
+ shared: SharedHandle | null;
+ /** The filesystem node (file or directory). */
+ node: FsNode;
+ /** Current read/write position. */
+ position: number;
+ flags: OpenFlags;
+}
+
+// --- Flag parsing ---
+
+function parseOpenFlags(flags: number | string): OpenFlags {
+ if (typeof flags === 'string') {
+ const f = flags;
+ const plus = f.includes('+');
+ return {
+ read: f[0] === 'r' || plus,
+ write: f[0] === 'w' || f[0] === 'a' || plus,
+ append: f[0] === 'a',
+ create: f[0] === 'w' || f[0] === 'a',
+ truncate: f[0] === 'w' && !plus,
+ exclusive: f.includes('x'),
+ };
+ }
+ // Numeric flags (Node.js / Linux O_* values)
+ const access = flags & 3;
+ return {
+ read: access === 0 || access === 2, // O_RDONLY=0, O_RDWR=2
+ write: access === 1 || access === 2, // O_WRONLY=1, O_RDWR=2
+ append: !!(flags & 0o2000), // O_APPEND
+ create: !!(flags & 0o100), // O_CREAT
+ truncate: !!(flags & 0o1000), // O_TRUNC
+ exclusive: !!(flags & 0o200), // O_EXCL
+ };
+}
+
+// --- Stat result (compatible with serializeStat in fs-proxy-protocol) ---
+
+class StatResult {
+ dev = 0;
+ ino: number;
+ mode: number;
+ nlink = 1;
+ uid = 0;
+ gid = 0;
+ rdev = 0;
+ size: number;
+ blksize = 4096;
+ blocks: number;
+ atimeMs: number;
+ mtimeMs: number;
+ ctimeMs: number;
+ birthtimeMs: number;
+ private _isFile: boolean;
+
+ constructor(isFile: boolean, size: number, mtime: number, ino: number) {
+ this._isFile = isFile;
+ this.ino = ino;
+ this.mode = isFile ? 0o100644 : 0o040755;
+ this.size = size;
+ this.blocks = Math.ceil(size / 512);
+ this.atimeMs = mtime;
+ this.mtimeMs = mtime;
+ this.ctimeMs = mtime;
+ this.birthtimeMs = mtime;
+ }
+
+ isFile() { return this._isFile; }
+ isDirectory() { return !this._isFile; }
+ isSymbolicLink() { return false; }
+ isCharacterDevice() { return false; }
+ isBlockDevice() { return false; }
+ isSocket() { return false; }
+ isFIFO() { return false; }
+}
+
+// --- Backend ---
+
+export class OpfsBackend {
+ private root: DirNode | null = null;
+ private fdTable = new Map();
+ /** Path → shared SyncAccessHandle for refcounting across multiple fds. */
+ private openHandles = new Map();
+ private nextFd = 3; // 0-2 reserved for stdin/stdout/stderr
+ private nextIno = 1;
+
+ /**
+ * Initialize: get the OPFS directory for this radio and build the
+ * in-memory directory index by scanning the OPFS tree.
+ */
+ async init(radioKey: string): Promise {
+ const storageRoot = await navigator.storage.getDirectory();
+ const edgetx = await storageRoot.getDirectoryHandle('edgetx-web', { create: true });
+ const radioDir = await edgetx.getDirectoryHandle(radioKey, { create: true });
+
+ this.root = {
+ name: '',
+ kind: 'directory',
+ handle: radioDir,
+ children: new Map(),
+ mtime: Date.now(),
+ };
+
+ await this.scanDir(this.root);
+ }
+
+ /** Whether the OPFS tree has any content for this radio. */
+ get hasContent(): boolean {
+ return this.root !== null && this.root.children.size > 0;
+ }
+
+ /** Close all open handles (for shutdown). */
+ closeAll(): void {
+ for (const [fd] of this.fdTable) {
+ try { this.close(fd); } catch { /* best effort */ }
+ }
+ }
+
+ // --- Async scan ---
+
+ private async scanDir(dir: DirNode): Promise {
+ for await (const [name, handle] of (dir.handle as any).entries()) {
+ if (handle.kind === 'directory') {
+ const child: DirNode = {
+ name,
+ kind: 'directory',
+ handle: handle as FileSystemDirectoryHandle,
+ children: new Map(),
+ mtime: Date.now(),
+ };
+ dir.children.set(name, child);
+ await this.scanDir(child);
+ } else {
+ const file = await (handle as FileSystemFileHandle).getFile();
+ const child: FileNode = {
+ name,
+ kind: 'file',
+ handle: handle as FileSystemFileHandle,
+ size: file.size,
+ mtime: file.lastModified,
+ };
+ dir.children.set(name, child);
+ }
+ }
+ }
+
+ // --- Synchronous path resolution ---
+
+ private normalizePath(path: string): string {
+ return '/' + path.split('/').filter(Boolean).join('/');
+ }
+
+ private resolve(path: string): FsNode {
+ if (!this.root) throw this.err('ENOENT', path);
+
+ const segments = path.split('/').filter(Boolean);
+ let node: FsNode = this.root;
+
+ for (const seg of segments) {
+ if (seg === '.') continue;
+ if (seg === '..') throw this.err('EINVAL', path);
+ if (node.kind !== 'directory') throw this.err('ENOTDIR', path);
+ const child = node.children.get(seg);
+ if (!child) throw this.err('ENOENT', path);
+ node = child;
+ }
+
+ return node;
+ }
+
+ private resolveParent(path: string): { parent: DirNode; name: string } {
+ if (!this.root) throw this.err('ENOENT', path);
+
+ const segments = path.split('/').filter(Boolean);
+ if (segments.length === 0) throw this.err('EINVAL', path);
+
+ const name = segments.pop()!;
+ let node: FsNode = this.root;
+
+ for (const seg of segments) {
+ if (seg === '.') continue;
+ if (seg === '..') throw this.err('EINVAL', path);
+ if (node.kind !== 'directory') throw this.err('ENOTDIR', path);
+ const child = node.children.get(seg);
+ if (!child) throw this.err('ENOENT', path);
+ node = child;
+ }
+
+ if (node.kind !== 'directory') throw this.err('ENOTDIR', path);
+ return { parent: node, name };
+ }
+
+ // --- File operations ---
+
+ /** Open a file or directory. ASYNC (needs createSyncAccessHandle). */
+ async open(path: string, flags: number | string, _mode: number): Promise {
+ const parsed = parseOpenFlags(flags);
+ const normPath = this.normalizePath(path);
+
+ let node: FsNode;
+ let created = false;
+
+ try {
+ node = this.resolve(path);
+ } catch (e: any) {
+ if (e.code === 'ENOENT' && parsed.create) {
+ node = await this.createFile(path);
+ created = true;
+ } else {
+ throw e;
+ }
+ }
+
+ if (!created && parsed.create && parsed.exclusive) {
+ throw this.err('EEXIST', path);
+ }
+
+ // Directory fd: no SyncAccessHandle, just a trackable fd for WASI
+ if (node.kind === 'directory') {
+ const fd = this.nextFd++;
+ this.fdTable.set(fd, { shared: null, node, position: 0, flags: parsed });
+ return fd;
+ }
+
+ // File fd: reuse existing SyncAccessHandle if already open (refcounting)
+ let shared = this.openHandles.get(normPath);
+ if (shared) {
+ shared.refCount++;
+ } else {
+ const syncHandle = await (node as FileNode).handle.createSyncAccessHandle();
+ shared = { syncHandle, node: node as FileNode, refCount: 1 };
+ this.openHandles.set(normPath, shared);
+ }
+
+ if (parsed.truncate) {
+ shared.syncHandle.truncate(0);
+ shared.node.size = 0;
+ shared.node.mtime = Date.now();
+ }
+
+ const fd = this.nextFd++;
+ this.fdTable.set(fd, {
+ shared,
+ node,
+ position: parsed.append ? shared.syncHandle.getSize() : 0,
+ flags: parsed,
+ });
+
+ return fd;
+ }
+
+ /** Close a file descriptor. Sync. */
+ close(fd: number): void {
+ const openFd = this.fdTable.get(fd);
+ if (!openFd) throw this.err('EBADF', `fd ${fd}`);
+
+ if (openFd.shared) {
+ openFd.shared.refCount--;
+ if (openFd.shared.refCount <= 0) {
+ openFd.shared.syncHandle.flush();
+ openFd.shared.node.size = openFd.shared.syncHandle.getSize();
+ openFd.shared.syncHandle.close();
+ // Remove from openHandles
+ for (const [path, h] of this.openHandles) {
+ if (h === openFd.shared) {
+ this.openHandles.delete(path);
+ break;
+ }
+ }
+ }
+ }
+
+ this.fdTable.delete(fd);
+ }
+
+ /** Read from an open file. Sync. */
+ read(fd: number, length: number, position: number | null): { data: Uint8Array; bytesRead: number } {
+ const openFd = this.getFd(fd, 'file');
+ const pos = position ?? openFd.position;
+ const buf = new Uint8Array(length);
+ const bytesRead = openFd.shared!.syncHandle.read(buf, { at: pos });
+
+ if (position === null) {
+ openFd.position += bytesRead;
+ }
+
+ return { data: buf, bytesRead };
+ }
+
+ /** Write to an open file. Sync. */
+ write(fd: number, data: Uint8Array, position: number | null): number {
+ const openFd = this.getFd(fd, 'file');
+ const sh = openFd.shared!;
+
+ let pos: number;
+ if (openFd.flags.append) {
+ pos = sh.syncHandle.getSize();
+ } else {
+ pos = position ?? openFd.position;
+ }
+
+ const written = sh.syncHandle.write(data, { at: pos });
+
+ if (position === null || openFd.flags.append) {
+ openFd.position = pos + written;
+ }
+
+ sh.node.size = sh.syncHandle.getSize();
+ sh.node.mtime = Date.now();
+ return written;
+ }
+
+ /** Get file status by fd. Sync. */
+ fstat(fd: number): StatResult {
+ const openFd = this.fdTable.get(fd);
+ if (!openFd) throw this.err('EBADF', `fd ${fd}`);
+
+ if (openFd.node.kind === 'directory') {
+ return new StatResult(false, 0, openFd.node.mtime, this.nextIno++);
+ }
+
+ const size = openFd.shared ? openFd.shared.syncHandle.getSize() : (openFd.node as FileNode).size;
+ return new StatResult(true, size, openFd.node.mtime, this.nextIno++);
+ }
+
+ /** Get file status by path. Sync. */
+ stat(path: string): StatResult {
+ const node = this.resolve(path);
+ const size = node.kind === 'file' ? node.size : 0;
+ return new StatResult(node.kind === 'file', size, node.mtime, this.nextIno++);
+ }
+
+ /** Same as stat (no symlink support). Sync. */
+ lstat(path: string): StatResult {
+ return this.stat(path);
+ }
+
+ /** Truncate an open file. Sync. */
+ ftruncate(fd: number, len: number): void {
+ const openFd = this.getFd(fd, 'file');
+ openFd.shared!.syncHandle.truncate(len);
+ openFd.shared!.node.size = len;
+ openFd.shared!.node.mtime = Date.now();
+ }
+
+ /** Update timestamps by fd. Sync (in-memory only). */
+ futimes(fd: number, _atime: number, mtime: number): void {
+ const openFd = this.fdTable.get(fd);
+ if (!openFd) throw this.err('EBADF', `fd ${fd}`);
+ openFd.node.mtime = mtime;
+ }
+
+ /** Update timestamps by path. Sync (in-memory only). */
+ utimes(path: string, _atime: number, mtime: number): void {
+ const node = this.resolve(path);
+ node.mtime = mtime;
+ }
+
+ /** Create a directory. ASYNC. */
+ async mkdir(path: string): Promise {
+ const { parent, name } = this.resolveParent(path);
+
+ if (parent.children.has(name)) {
+ throw this.err('EEXIST', path);
+ }
+
+ const handle = await parent.handle.getDirectoryHandle(name, { create: true });
+ parent.children.set(name, {
+ name,
+ kind: 'directory',
+ handle,
+ children: new Map(),
+ mtime: Date.now(),
+ });
+ }
+
+ /** List directory contents. Sync (reads from in-memory index). */
+ readdir(path: string, withFileTypes: boolean): any[] {
+ const node = this.resolve(path);
+ if (node.kind !== 'directory') throw this.err('ENOTDIR', path);
+
+ if (withFileTypes) {
+ return Array.from(node.children.values()).map(child => ({
+ name: child.name,
+ isFile: () => child.kind === 'file',
+ isDirectory: () => child.kind === 'directory',
+ isSymbolicLink: () => false,
+ isCharacterDevice: () => false,
+ isBlockDevice: () => false,
+ isSocket: () => false,
+ isFIFO: () => false,
+ }));
+ }
+ return Array.from(node.children.keys());
+ }
+
+ /** Rename / move a file or directory. ASYNC. */
+ async rename(oldPath: string, newPath: string): Promise {
+ const oldNode = this.resolve(oldPath);
+ const { parent: oldParent, name: oldName } = this.resolveParent(oldPath);
+ const { parent: newParent, name: newName } = this.resolveParent(newPath);
+
+ // Reject rename while file is open (SyncAccessHandle holds a lock)
+ const normOld = this.normalizePath(oldPath);
+ if (this.openHandles.has(normOld)) {
+ throw this.err('EBUSY', oldPath);
+ }
+
+ // Remove existing target if present
+ if (newParent.children.has(newName)) {
+ const existing = newParent.children.get(newName)!;
+ if (existing.kind === 'directory' && existing.children.size > 0) {
+ throw this.err('ENOTEMPTY', newPath);
+ }
+ await newParent.handle.removeEntry(newName, { recursive: false });
+ newParent.children.delete(newName);
+ }
+
+ // Use move() API if available (Chrome 110+, Firefox 129+)
+ if (typeof (oldNode.handle as any).move === 'function') {
+ await (oldNode.handle as any).move(newParent.handle, newName);
+ oldParent.children.delete(oldName);
+ oldNode.name = newName;
+ newParent.children.set(newName, oldNode);
+ } else if (oldNode.kind === 'file') {
+ // Fallback: read content, create new file, delete old
+ const file = await oldNode.handle.getFile();
+ const content = new Uint8Array(await file.arrayBuffer());
+
+ const newHandle = await newParent.handle.getFileHandle(newName, { create: true });
+ const syncHandle = await newHandle.createSyncAccessHandle();
+ syncHandle.write(content);
+ syncHandle.flush();
+ const newSize = syncHandle.getSize();
+ syncHandle.close();
+
+ await oldParent.handle.removeEntry(oldName);
+ oldParent.children.delete(oldName);
+
+ newParent.children.set(newName, {
+ name: newName,
+ kind: 'file',
+ handle: newHandle,
+ size: newSize,
+ mtime: Date.now(),
+ });
+ } else {
+ throw this.err('ENOSYS', 'directory rename requires move() API');
+ }
+ }
+
+ /** Remove an empty directory. ASYNC. */
+ async rmdir(path: string): Promise {
+ const { parent, name } = this.resolveParent(path);
+ const child = parent.children.get(name);
+ if (!child) throw this.err('ENOENT', path);
+ if (child.kind !== 'directory') throw this.err('ENOTDIR', path);
+ if (child.children.size > 0) throw this.err('ENOTEMPTY', path);
+
+ await parent.handle.removeEntry(name);
+ parent.children.delete(name);
+ }
+
+ /** Delete a file. ASYNC. */
+ async unlink(path: string): Promise {
+ const { parent, name } = this.resolveParent(path);
+ const child = parent.children.get(name);
+ if (!child) throw this.err('ENOENT', path);
+ if (child.kind === 'directory') throw this.err('EISDIR', path);
+
+ // Force-close any open SyncAccessHandle for this file
+ const normPath = this.normalizePath(path);
+ const existing = this.openHandles.get(normPath);
+ if (existing) {
+ existing.syncHandle.close();
+ this.openHandles.delete(normPath);
+ }
+
+ await parent.handle.removeEntry(name);
+ parent.children.delete(name);
+ }
+
+ // Unsupported (OPFS has no symlinks / hardlinks)
+ link(): never { throw this.err('ENOSYS', 'hardlinks not supported'); }
+ symlink(): never { throw this.err('ENOSYS', 'symlinks not supported'); }
+ readlink(): never { throw this.err('ENOSYS', 'symlinks not supported'); }
+
+ /** Normalize path and verify it exists. Sync. */
+ realpath(path: string): string {
+ this.resolve(path);
+ return this.normalizePath(path);
+ }
+
+ /** Flush file data to OPFS. Sync. */
+ fdatasync(fd: number): void {
+ const openFd = this.getFd(fd, 'file');
+ openFd.shared!.syncHandle.flush();
+ }
+
+ /** Flush file data + metadata. Sync. */
+ fsync(fd: number): void {
+ this.fdatasync(fd);
+ }
+
+ // --- Bulk operations (for main-thread UI) ---
+
+ /** Recursively delete all content under the OPFS root. ASYNC. */
+ async wipe(): Promise {
+ if (!this.root) return;
+ this.closeAll();
+ await this.clearDir(this.root);
+ }
+
+ /** Write a file into the OPFS tree, creating parent directories as needed. ASYNC. */
+ async writeFile(path: string, data: Uint8Array): Promise {
+ // Ensure parent directories exist
+ const segments = path.split('/').filter(Boolean);
+ let dir = this.root!;
+ for (let i = 0; i < segments.length - 1; i++) {
+ const seg = segments[i];
+ let child = dir.children.get(seg);
+ if (!child) {
+ const handle = await dir.handle.getDirectoryHandle(seg, { create: true });
+ child = { name: seg, kind: 'directory', handle, children: new Map(), mtime: Date.now() };
+ dir.children.set(seg, child);
+ }
+ if (child.kind !== 'directory') throw this.err('ENOTDIR', path);
+ dir = child;
+ }
+
+ const fileName = segments[segments.length - 1];
+ let fileNode = dir.children.get(fileName);
+ let fileHandle: FileSystemFileHandle;
+
+ if (fileNode && fileNode.kind === 'file') {
+ fileHandle = fileNode.handle;
+ } else {
+ fileHandle = await dir.handle.getFileHandle(fileName, { create: true });
+ }
+
+ const syncHandle = await fileHandle.createSyncAccessHandle();
+ syncHandle.truncate(0);
+ syncHandle.write(data);
+ syncHandle.flush();
+ const size = syncHandle.getSize();
+ syncHandle.close();
+
+ dir.children.set(fileName, {
+ name: fileName,
+ kind: 'file',
+ handle: fileHandle,
+ size,
+ mtime: Date.now(),
+ });
+ }
+
+ /** Read a file's full contents. ASYNC. */
+ async readFile(path: string): Promise {
+ const node = this.resolve(path);
+ if (node.kind !== 'file') throw this.err('EISDIR', path);
+
+ const file = await node.handle.getFile();
+ return new Uint8Array(await file.arrayBuffer());
+ }
+
+ /** Recursively list all file paths under a directory. Sync. */
+ listFiles(basePath = '/'): string[] {
+ const result: string[] = [];
+ const node = this.resolve(basePath);
+ if (node.kind === 'directory') {
+ this.listFilesRec(basePath, node, result);
+ }
+ return result;
+ }
+
+ // --- Private helpers ---
+
+ private async createFile(path: string): Promise {
+ const { parent, name } = this.resolveParent(path);
+ const handle = await parent.handle.getFileHandle(name, { create: true });
+ const node: FileNode = {
+ name,
+ kind: 'file',
+ handle,
+ size: 0,
+ mtime: Date.now(),
+ };
+ parent.children.set(name, node);
+ return node;
+ }
+
+ private async clearDir(dir: DirNode): Promise {
+ const names = Array.from(dir.children.keys());
+ for (const name of names) {
+ await dir.handle.removeEntry(name, { recursive: true });
+ }
+ dir.children.clear();
+ }
+
+ private listFilesRec(basePath: string, dir: DirNode, result: string[]): void {
+ for (const child of dir.children.values()) {
+ const childPath = basePath === '/' ? `/${child.name}` : `${basePath}/${child.name}`;
+ if (child.kind === 'directory') {
+ this.listFilesRec(childPath, child, result);
+ } else {
+ result.push(childPath);
+ }
+ }
+ }
+
+ /** Get an open fd, throwing EBADF or EISDIR as appropriate. */
+ private getFd(fd: number, expect: 'file'): OpenFd & { shared: SharedHandle };
+ private getFd(fd: number, expect?: string): OpenFd;
+ private getFd(fd: number, expect?: string): OpenFd {
+ const openFd = this.fdTable.get(fd);
+ if (!openFd) throw this.err('EBADF', `fd ${fd}`);
+ if (expect === 'file' && !openFd.shared) throw this.err('EISDIR', `fd ${fd}`);
+ return openFd;
+ }
+
+ private err(code: string, detail: string): Error {
+ const e = new Error(`${code}: ${detail}`);
+ (e as any).code = code;
+ return e;
+ }
+}
diff --git a/web/src/lib/wasm-runner.ts b/web/src/lib/wasm-runner.ts
new file mode 100644
index 000000000..9ff218f5e
--- /dev/null
+++ b/web/src/lib/wasm-runner.ts
@@ -0,0 +1,445 @@
+import { WASIThreads } from '@emnapi/wasi-threads';
+import type { WASIInstance } from '@emnapi/wasi-threads';
+import { WASI } from '@tybys/wasm-util';
+import { CTRL_BUFFER_SIZE, DATA_BUFFER_SIZE, WAKE_BUFFER_SIZE } from './fs-proxy-protocol';
+
+/**
+ * Minimal fs stub for the main thread's WASI instance.
+ * The main thread only needs enough to satisfy preopen setup — all real
+ * filesystem I/O happens in worker threads via FsProxyClient → FS Worker.
+ */
+const stubFs = (() => {
+ let nextFd = 3;
+ const dirStat = () => ({
+ dev: 0n, ino: 0n, mode: 0o040755n, nlink: 1n,
+ uid: 0n, gid: 0n, rdev: 0n, size: 0n,
+ blksize: 4096n, blocks: 0n,
+ atimeMs: 0n, mtimeMs: 0n, ctimeMs: 0n, birthtimeMs: 0n,
+ atimeNs: 0n, mtimeNs: 0n, ctimeNs: 0n, birthtimeNs: 0n,
+ atime: new Date(0), mtime: new Date(0), ctime: new Date(0), birthtime: new Date(0),
+ isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false,
+ isCharacterDevice: () => false, isBlockDevice: () => false,
+ isSocket: () => false, isFIFO: () => false,
+ });
+ return {
+ openSync() { return nextFd++; },
+ closeSync() {},
+ fstatSync() { return dirStat(); },
+ statSync() { return dirStat(); },
+ lstatSync() { return dirStat(); },
+ readdirSync() { return []; },
+ readSync() { return 0; },
+ writeSync(_fd: number, _b: any, _o: number, len: number) { return len; },
+ mkdirSync() {}, renameSync() {}, rmdirSync() {}, unlinkSync() {},
+ linkSync() {}, symlinkSync() {},
+ readlinkSync(p: string) { return p; },
+ realpathSync(p: string) { return p; },
+ ftruncateSync() {}, futimesSync() {}, utimesSync() {},
+ fdatasyncSync() {}, fsyncSync() {},
+ };
+})();
+
+export interface SimulatorExports {
+ memory: WebAssembly.Memory;
+ malloc: (size: number) => number;
+ free: (ptr: number) => void;
+
+ simuInit: () => void;
+ simuStart: (tests: number) => void;
+ simuStop: () => void;
+ simuIsRunning: () => number;
+ simuFatfsSetPaths: (sdPath: number, settingsPath: number) => void;
+ simuCreateDefaults: () => void;
+
+ simuSetKey: (key: number, state: number) => void;
+ simuSetTrim: (trim: number, state: number) => void;
+ simuSetSwitch: (swtch: number, state: number) => void;
+ simuSetTrimValue: (idx: number, value: number) => void;
+ simuTouchDown: (x: number, y: number) => void;
+ simuTouchUp: () => void;
+ simuRotaryEncoderEvent: (steps: number) => void;
+
+ simuLcdChanged: () => number;
+ simuLcdCopy: (buf: number, maxLen: number) => number;
+ simuLcdGetWidth: () => number;
+ simuLcdGetHeight: () => number;
+ simuLcdGetDepth: () => number;
+ simuLcdFlushed: () => void;
+
+ simuGetCapability: (cap: number) => number;
+ simuAudioGetVolume: () => number;
+
+ simuIsChannelUsed: (channel: number) => number;
+ simuGetChannelsUsed: () => number;
+ simuGetMixCount: () => number;
+
+ simuGetNumCustomSwitches: () => number;
+ simuGetCustomSwitchState: (idx: number) => number;
+ simuGetCustomSwitchColor: (idx: number) => number;
+}
+
+export type TraceCallback = (text: string) => void;
+export type AudioCallback = (samples: Int16Array) => void;
+
+/** Derive a short radio key from a wasm filename. */
+export function radioKeyFromWasm(wasmFile: string): string {
+ const base = wasmFile.replace(/.*\//, '').replace(/\.wasm$/, '');
+ const m = base.match(/^edgetx-(.+)-simulator$/);
+ return m ? m[1] : base;
+}
+
+/** Parse the WASM binary import section to find the memory import limits. */
+function getMemoryImport(bytes: Uint8Array): { initial: number; maximum: number } {
+ // Minimal WASM binary parser – just enough to find the memory import.
+ let off = 8; // skip magic + version
+
+ function readU32Leb(): number {
+ let result = 0, shift = 0;
+ while (true) {
+ const b = bytes[off++];
+ result |= (b & 0x7f) << shift;
+ if ((b & 0x80) === 0) return result;
+ shift += 7;
+ }
+ }
+
+ function readName(): string {
+ const len = readU32Leb();
+ const s = new TextDecoder().decode(bytes.subarray(off, off + len));
+ off += len;
+ return s;
+ }
+
+ while (off < bytes.length) {
+ const sectionId = bytes[off++];
+ const sectionLen = readU32Leb();
+ const sectionEnd = off + sectionLen;
+
+ if (sectionId !== 2) { // not Import section
+ off = sectionEnd;
+ continue;
+ }
+
+ const count = readU32Leb();
+ for (let i = 0; i < count; i++) {
+ const mod = readName();
+ const name = readName();
+ const kind = bytes[off++];
+ if (kind === 2) {
+ // Memory import: flags, initial, [maximum]
+ const flags = readU32Leb();
+ const initial = readU32Leb();
+ const maximum = (flags & 1) ? readU32Leb() : 65536;
+ if (mod === 'env' && name === 'memory') {
+ return { initial, maximum };
+ }
+ } else if (kind === 0) { readU32Leb(); } // func: typeidx
+ else if (kind === 1) { readU32Leb(); readU32Leb(); readU32Leb(); } // table: reftype + limits
+ else if (kind === 3) { off += 2; } // global: valtype + mut
+ else if (kind === 4) { readU32Leb(); } // tag: typeidx
+ }
+ break;
+ }
+ // Fallback if not found
+ return { initial: 256, maximum: 32768 };
+}
+
+export class WasmRunner {
+ private wasiThreads!: WASIThreads;
+ private _exports: SimulatorExports | null = null;
+
+ private analogBuffer = new SharedArrayBuffer(32 * 2);
+ private analogValues = new Int16Array(this.analogBuffer);
+ /** LCD sync: Int32[0] = frame sequence number, incremented by firmware on each refresh. */
+ private lcdSyncBuffer = new SharedArrayBuffer(4);
+ private lcdSync = new Int32Array(this.lcdSyncBuffer);
+ private onTrace: TraceCallback;
+ private onAudio: AudioCallback;
+ private fsWorker: Worker | null = null;
+ private wakeBuffer: SharedArrayBuffer | null = null;
+ private nextFsReqId = 0;
+ /** Persistent WASM-side buffer for simuLcdCopy (allocated on first use). */
+ private wasmLcdBuf = 0;
+ private wasmLcdBufSize = 0;
+ /** Set to true (or type `fsTrace = true` in browser console) to log fs proxy ops. */
+ fsTrace = false;
+
+ constructor(onTrace: TraceCallback, onAudio: AudioCallback) {
+ this.onTrace = onTrace;
+ this.onAudio = onAudio;
+ }
+
+ get exports(): SimulatorExports | null {
+ return this._exports;
+ }
+
+ get hasFsWorker(): boolean {
+ return this.fsWorker !== null;
+ }
+
+ /**
+ * Spawn the FS Worker and scan OPFS for existing content.
+ * This enables file uploads/reads before WASM is loaded.
+ */
+ async initFs(radioKey: string): Promise<{ hasContent: boolean }> {
+ this.fsWorker = new Worker(new URL('./fs-worker.ts', import.meta.url), { type: 'module' });
+ this.fsWorker.addEventListener('message', (e) => {
+ if (e.data?.type === 'trace') this.onTrace(e.data.text);
+ });
+ this.wakeBuffer = new SharedArrayBuffer(WAKE_BUFFER_SIZE);
+
+ const hasContent = await new Promise((resolve, reject) => {
+ const onMsg = (e: MessageEvent) => {
+ if (e.data.type === 'ready') {
+ this.fsWorker!.removeEventListener('message', onMsg);
+ this.fsWorker!.removeEventListener('error', onErr);
+ resolve(e.data.hasContent);
+ }
+ };
+ const onErr = (e: ErrorEvent) => {
+ this.fsWorker!.removeEventListener('message', onMsg);
+ reject(new Error(e.message));
+ };
+ this.fsWorker!.addEventListener('message', onMsg);
+ this.fsWorker!.addEventListener('error', onErr, { once: true });
+ this.fsWorker!.postMessage({ type: 'init', radioKey, wakeBuffer: this.wakeBuffer });
+ });
+
+ return { hasContent };
+ }
+
+ /**
+ * Load and instantiate the WASM module. Requires initFs() first.
+ */
+ async load(wasmPath: string): Promise {
+ if (!this.fsWorker) throw new Error('initFs() must be called before load()');
+
+ // --- 1. Create main-thread WASI (stub fs — real I/O happens in workers) ---
+ const wasi = new WASI({
+ version: 'preview1',
+ fs: stubFs as any,
+ preopens: { '/': '/' },
+ print: (s: string) => this.onTrace(s + '\n'),
+ printErr: (s: string) => this.onTrace(s + '\n'),
+ });
+
+ this.wasiThreads = new WASIThreads({
+ wasi: wasi as WASIInstance,
+ reuseWorker: { size: 4, strict: true },
+ waitThreadStart: typeof window === 'undefined' ? 1000 : false,
+ onCreateWorker: () => {
+ const worker = new Worker(new URL('./worker.ts', import.meta.url), {
+ type: 'module',
+ });
+ worker.postMessage({ type: 'analog-buffer', buffer: this.analogBuffer });
+ worker.postMessage({ type: 'lcd-sync', buffer: this.lcdSyncBuffer });
+ // Create FS channel for this worker and register with FS Worker
+ const ctrlBuffer = new SharedArrayBuffer(CTRL_BUFFER_SIZE);
+ const dataBuffer = new SharedArrayBuffer(DATA_BUFFER_SIZE);
+ this.fsWorker!.postMessage({ type: 'channel', ctrlBuffer, dataBuffer });
+ worker.postMessage({ type: 'wake-buffer', buffer: this.wakeBuffer });
+ worker.postMessage({ type: 'fs-channel', ctrlBuffer, dataBuffer });
+ worker.addEventListener('message', (e) => {
+ if (e.data?.type === 'trace') {
+ this.onTrace(e.data.text);
+ } else if (e.data?.type === 'audio') {
+ this.onAudio(e.data.samples);
+ }
+ });
+ return worker;
+ },
+ });
+
+ // --- 3. Start FS Worker event loop (before preloadWorkers, which triggers FS calls) ---
+ this.fsWorker.postMessage({ type: 'start' });
+
+ // --- 4. Load and compile WASM ---
+ const response = await fetch(wasmPath);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch WASM: ${response.statusText}`);
+ }
+
+ const clone = response.clone();
+ const reader = response.body!.getReader();
+ const { value: headerBytes } = await reader.read();
+ reader.cancel();
+ const { initial, maximum } = getMemoryImport(headerBytes!);
+ const memory = new WebAssembly.Memory({ initial, maximum, shared: true });
+
+ const readCStr = (ptr: number): string => {
+ const view = new Uint8Array(memory.buffer);
+ let end = ptr;
+ while (view[end] !== 0) end++;
+ return new TextDecoder('utf-8').decode(view.subarray(ptr, end));
+ };
+
+ const wasiObj = this.wasiThreads.wasi;
+ const { module, instance } = await WebAssembly.instantiateStreaming(
+ clone,
+ {
+ wasi_snapshot_preview1:
+ wasiObj.wasiImport as WebAssembly.ModuleImports,
+ wasi: { ...this.wasiThreads.getImportObject().wasi },
+ env: {
+ memory,
+ simuGetAnalog: (idx: number): number => {
+ return this.analogValues[idx] ?? 0;
+ },
+ simuQueueAudio: (buf: number, len: number): void => {
+ const view = new Int16Array(memory.buffer, buf, len / 2);
+ this.onAudio(view);
+ },
+ simuTrace: (ptr: number): void => {
+ this.onTrace(readCStr(ptr));
+ },
+ simuLcdNotify: (): void => {
+ Atomics.add(this.lcdSync, 0, 1);
+ Atomics.notify(this.lcdSync, 0);
+ },
+ },
+ }
+ );
+
+ this._exports = instance.exports as unknown as SimulatorExports;
+
+ this.wasiThreads.initialize(instance, module, memory);
+ await this.wasiThreads.preloadWorkers();
+ }
+
+ // --- FS Worker message helpers ---
+
+ private fsMessage(type: string, payload: Record = {}, transfer: Transferable[] = []): Promise {
+ return new Promise((resolve, reject) => {
+ if (!this.fsWorker) return reject(new Error('FS Worker not running'));
+ const id = ++this.nextFsReqId;
+ const handler = (e: MessageEvent) => {
+ if (e.data.id === id) {
+ this.fsWorker!.removeEventListener('message', handler);
+ if (e.data.error) reject(new Error(e.data.error));
+ else resolve(e.data);
+ }
+ };
+ this.fsWorker.addEventListener('message', handler);
+ this.fsWorker.postMessage({ ...payload, type, id }, { transfer });
+ });
+ }
+
+ async fsReadTextFile(path: string): Promise {
+ try {
+ const result = await this.fsMessage('readTextFile', { path });
+ return result.text;
+ } catch {
+ return null;
+ }
+ }
+
+ async fsWriteFile(path: string, data: ArrayBuffer): Promise {
+ await this.fsMessage('writeFile', { path, data }, [data]);
+ }
+
+ async fsWipe(): Promise {
+ await this.fsMessage('wipe');
+ }
+
+ async fsListFiles(basePath = '/'): Promise {
+ const result = await this.fsMessage('listFiles', { basePath });
+ return result.files;
+ }
+
+ /** Terminate WASM worker threads but keep the FS Worker alive. */
+ stopSim(): void {
+ if (this.wasiThreads) {
+ this.wasiThreads.terminateAllThreads();
+ }
+ this._exports = null;
+ // Reset LCD buffer pointer — it belonged to the old WASM memory
+ this.wasmLcdBuf = 0;
+ this.wasmLcdBufSize = 0;
+ }
+
+ /** Stop the FS Worker and clean up. */
+ async stopFs(): Promise {
+ if (!this.fsWorker) return;
+ await new Promise((resolve) => {
+ const handler = (e: MessageEvent) => {
+ if (e.data.type === 'stopped') {
+ this.fsWorker!.removeEventListener('message', handler);
+ resolve();
+ }
+ };
+ this.fsWorker!.addEventListener('message', handler);
+ this.fsWorker!.postMessage({ type: 'stop' });
+ });
+ this.fsWorker.terminate();
+ this.fsWorker = null;
+ }
+
+ setAnalog(index: number, value: number): void {
+ if (index >= 0 && index < this.analogValues.length) {
+ this.analogValues[index] = value;
+ }
+ }
+
+ /** Allocate a C string in WASM linear memory and return its pointer. */
+ private allocCStr(s: string): number {
+ const ex = this._exports!;
+ const encoded = new TextEncoder().encode(s);
+ const ptr = ex.malloc(encoded.length + 1);
+ if (!ptr) throw new Error('malloc failed');
+ // Re-derive view after malloc (buffer may have grown)
+ const view = new Uint8Array(ex.memory.buffer);
+ view.set(encoded, ptr);
+ view[ptr + encoded.length] = 0;
+ return ptr;
+ }
+
+ /** Tell the firmware where the SD card / settings directories are. */
+ setFatfsPaths(sdPath: string, settingsPath: string): void {
+ const ex = this._exports;
+ if (!ex) return;
+
+ const sdPtr = this.allocCStr(sdPath);
+ const settingsPtr = this.allocCStr(settingsPath);
+ ex.simuFatfsSetPaths(sdPtr, settingsPtr);
+ ex.free(sdPtr);
+ ex.free(settingsPtr);
+ }
+
+ /**
+ * Wait for the firmware to signal a new LCD frame.
+ * Returns true if a frame is ready, false on timeout.
+ * Uses Atomics.waitAsync so the main thread is not blocked.
+ */
+ async waitForLcdFrame(timeout = 100): Promise {
+ const current = Atomics.load(this.lcdSync, 0);
+ const result = Atomics.waitAsync(this.lcdSync, 0, current, timeout);
+ if (result.async) {
+ const status = await result.value;
+ return status === 'ok';
+ }
+ // 'not-equal' means the value already changed — frame is ready
+ return true;
+ }
+
+ /** Copy LCD framebuffer from WASM memory into a host-side Uint8Array */
+ copyLcd(size: number): Uint8Array | null {
+ const ex = this._exports;
+ if (!ex) return null;
+
+ // Allocate persistent WASM buffer on first use or if size changed
+ if (!this.wasmLcdBuf || this.wasmLcdBufSize < size) {
+ if (this.wasmLcdBuf) ex.free(this.wasmLcdBuf);
+ this.wasmLcdBuf = ex.malloc(size);
+ this.wasmLcdBufSize = this.wasmLcdBuf ? size : 0;
+ if (!this.wasmLcdBuf) return null;
+ }
+
+ const copied = ex.simuLcdCopy(this.wasmLcdBuf, size);
+ if (copied === 0) return null;
+
+ const mem = new Uint8Array(ex.memory.buffer, this.wasmLcdBuf, copied);
+ const result = new Uint8Array(copied);
+ result.set(mem);
+ return result;
+ }
+}
diff --git a/web/src/lib/worker.ts b/web/src/lib/worker.ts
new file mode 100644
index 000000000..708210087
--- /dev/null
+++ b/web/src/lib/worker.ts
@@ -0,0 +1,99 @@
+import { ThreadMessageHandler, WASIThreads } from '@emnapi/wasi-threads';
+import { WASI } from '@tybys/wasm-util';
+import { FsProxyClient } from './fs-proxy-client';
+
+// Catch all errors for debugging
+globalThis.addEventListener('error', (e) => {
+ postMessage({ type: 'trace', text: `[worker error] ${e.message} at ${e.filename}:${e.lineno}\n` });
+});
+globalThis.addEventListener('unhandledrejection', (e) => {
+ postMessage({ type: 'trace', text: `[worker rejection] ${e.reason}\n` });
+});
+
+// Shared analog values buffer, received from main thread before thread start
+let analogValues: Int16Array | null = null;
+
+// LCD sync buffer, received from main thread before thread start
+let lcdSync: Int32Array | null = null;
+
+// Filesystem proxy client, received from main thread before thread start
+let fsClient: FsProxyClient | null = null;
+let wakeBuffer: SharedArrayBuffer | null = null;
+
+const handler = new ThreadMessageHandler({
+ async onLoad({ wasmModule, wasmMemory }) {
+ const post = (s: string) => postMessage({ type: 'trace', text: s + '\n' });
+
+ if (!fsClient) {
+ throw new Error('No fs-channel received before thread start');
+ }
+ const fs = fsClient;
+
+ let wasi: WASI;
+ try {
+ wasi = new WASI({
+ version: 'preview1',
+ fs: fs as any,
+ preopens: { '/': '/' },
+ print: post,
+ printErr: post,
+ });
+ } catch (e: any) {
+ post('[worker] WASI init error: ' + (e?.stack ?? e?.message ?? e));
+ throw e;
+ }
+ const wasiThreads = new WASIThreads({
+ wasi: wasi as any,
+ childThread: true,
+ });
+
+ const instance = await WebAssembly.instantiate(wasmModule, {
+ env: {
+ memory: wasmMemory,
+ simuGetAnalog: (idx: number): number => analogValues?.[idx] ?? 0,
+ simuQueueAudio: (buf: number, len: number): void => {
+ const samples = new Int16Array(wasmMemory.buffer, buf, len / 2);
+ // Copy and relay to main thread for playback
+ postMessage({ type: 'audio', samples: new Int16Array(samples) });
+ },
+ simuTrace: (_ptr: number): void => {},
+ simuLcdNotify: (): void => {
+ if (lcdSync) {
+ Atomics.add(lcdSync, 0, 1);
+ Atomics.notify(lcdSync, 0);
+ }
+ },
+ },
+ wasi_snapshot_preview1: wasi.wasiImport,
+ wasi: { ...wasiThreads.getImportObject().wasi },
+ });
+
+ const initialized = wasiThreads.initialize(
+ instance,
+ wasmModule,
+ wasmMemory
+ );
+
+ return { module: wasmModule, instance: initialized };
+ },
+});
+
+globalThis.onmessage = function (e) {
+ if (e.data?.type === 'analog-buffer') {
+ analogValues = new Int16Array(e.data.buffer);
+ return;
+ }
+ if (e.data?.type === 'lcd-sync') {
+ lcdSync = new Int32Array(e.data.buffer);
+ return;
+ }
+ if (e.data?.type === 'wake-buffer') {
+ wakeBuffer = e.data.buffer;
+ return;
+ }
+ if (e.data?.type === 'fs-channel') {
+ fsClient = new FsProxyClient(e.data.ctrlBuffer, e.data.dataBuffer, wakeBuffer ?? undefined);
+ return;
+ }
+ handler.handle(e);
+};
diff --git a/web/src/main.ts b/web/src/main.ts
new file mode 100644
index 000000000..0554ca7e9
--- /dev/null
+++ b/web/src/main.ts
@@ -0,0 +1,8 @@
+import App from './App.svelte';
+import { mount } from 'svelte';
+
+const app = mount(App, {
+ target: document.getElementById('app')!,
+});
+
+export default app;
diff --git a/web/src/opfs.d.ts b/web/src/opfs.d.ts
new file mode 100644
index 000000000..65e217221
--- /dev/null
+++ b/web/src/opfs.d.ts
@@ -0,0 +1,14 @@
+/** Type declarations for OPFS SyncAccessHandle APIs (not yet in TypeScript's DOM lib). */
+
+interface FileSystemSyncAccessHandle {
+ read(buffer: ArrayBufferView, options?: { at?: number }): number;
+ write(buffer: ArrayBufferView, options?: { at?: number }): number;
+ truncate(newSize: number): void;
+ getSize(): number;
+ flush(): void;
+ close(): void;
+}
+
+interface FileSystemFileHandle {
+ createSyncAccessHandle(): Promise;
+}
diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts
new file mode 100644
index 000000000..4078e7476
--- /dev/null
+++ b/web/src/vite-env.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/web/svelte.config.js b/web/svelte.config.js
new file mode 100644
index 000000000..4c6b24b10
--- /dev/null
+++ b/web/svelte.config.js
@@ -0,0 +1,5 @@
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+export default {
+ preprocess: vitePreprocess(),
+};
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 000000000..5d2a30c95
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "lib": ["ES2024", "DOM", "DOM.Iterable"],
+ "types": ["svelte"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.svelte"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 000000000..1043e23fb
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,21 @@
+import { defineConfig } from 'vite';
+import { svelte } from '@sveltejs/vite-plugin-svelte';
+
+export default defineConfig({
+ plugins: [svelte()],
+ define: {
+ 'process.env': '{"NODE_DEBUG_NATIVE": null}',
+ },
+ server: {
+ headers: {
+ 'Cross-Origin-Embedder-Policy': 'require-corp',
+ 'Cross-Origin-Opener-Policy': 'same-origin',
+ },
+ },
+ preview: {
+ headers: {
+ 'Cross-Origin-Embedder-Policy': 'require-corp',
+ 'Cross-Origin-Opener-Policy': 'same-origin',
+ },
+ },
+});