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 @@ + + + + +
+
+
+ + Simulator +
+
+ + + {#if !running && !loading} + + {/if} + {#if running} + + {/if} + {#if fsReady} + + + {#if fsHasContent} + + {/if} + {/if} + +
+

{status}

+
+ + + + {#snippet switchCtrl(sw: SwitchDef, index: number)} +
+ {sw.name} +
handleSwitchTrackClick(index, sw, e)} + onkeydown={(e) => handleSwitchKey(index, sw, e)}> + {#if sw.type === '3POS'} +
+
+
+ {:else} +
+
+ {/if} + + +
+
+ {/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} +
+ {#each (currentRadio?.trims ?? []).slice(4) as trim, i} +
+ + {trim.name} + +
+ {/each} +
+ {/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', + }, + }, +});