From 97a86216c692b667b17a1642d2a99ed84511b664 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:31:42 +0200 Subject: [PATCH] feat(sim): board feature parity (reset/GPS/sensors/admin/keyboard) + input fixes Rounds out the browser sim harness with the rest of the physical board's interactions: a reset button (JS-driven, since board.reboot() is inert under -sEXIT_RUNTIME=0), GPS input wired into a real LocationProvider via new SimSensorManager, JS-settable battery/environment telemetry, an admin/repeater-login test hook (sendRoomLogin against the default "password"), and full physical-keyboard text entry (printable ASCII passthrough into the existing KeyboardWidget, Tab->KEY_KB_ENTER submit). Also fixes three real bugs found while exercising all of this in a real browser: - UITask.cpp's native-only stdin poll branch had no __EMSCRIPTEN__ exclusion, so it also compiled into the wasm build and called a real, blocking window.prompt() on nearly every frame -- the actual cause of the reported time/controls jumping. Now gated to native only. - 'n'/'p' were mapped as Next/Prev keyboard shortcuts, colliding with typing those literal letters. Removed the shortcuts; added explicit Next/Prev buttons to mesh.html (previously relied solely on them). - Buttons grabbed native browser keyboard focus on click, so a later stray Enter/Space could silently re-trigger a previously-clicked button (e.g. Reset). mousedown now calls preventDefault() on all buttons. Co-Authored-By: Claude Sonnet 5 --- examples/companion_radio/main.cpp | 50 ++++ examples/companion_radio/ui-new/UITask.cpp | 16 +- variants/sim/SimLocationProvider.h | 15 +- variants/sim/SimMainBoard.h | 22 +- variants/sim/SimSensorManager.h | 55 ++++ variants/sim/target.cpp | 32 ++- variants/sim/target.h | 9 +- variants/sim/web/index.html | 197 +++++++++++++- variants/sim/web/mesh.html | 303 ++++++++++++++++++--- 9 files changed, 644 insertions(+), 55 deletions(-) create mode 100644 variants/sim/SimSensorManager.h diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 61bbab53..57772f66 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -422,4 +422,54 @@ extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_open_dm_with_first_contact() { return 1; } #endif + +// Same idea as findFirstChatContact() above, but for the first known +// admin-loginable contact (a repeater or room server) instead of another +// chat instance. +static bool findFirstAdminContact(ContactInfo& out) { + int n = the_mesh.getNumContacts(); + for (int i = 0; i < n; i++) { + ContactInfo ci; + if (the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, ci) && + (ci.type == ADV_TYPE_REPEATER || ci.type == ADV_TYPE_ROOM)) { + out = ci; + return true; + } + } + return false; +} + +#ifdef DISPLAY_CLASS +// Jumps the on-device UI straight to AdminScreen for the first known +// repeater/room contact -- UITask::openAdminFor(ci, false), the exact same +// function NearbyScreen's "Nodes" Hold-Enter admin action calls (see +// NearbyScreen.h:709), so a test harness can screenshot the real Admin +// screen instead of scripting contact-list navigation key-by-key. Returns +// 1 if a repeater/room contact was found and the screen switched, 0 if not. +extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_open_admin_with_first_repeater() { + if (!g_sim_ready) return 0; + ContactInfo ci; + if (!findFirstAdminContact(ci)) return 0; + ui_task.openAdminFor(ci, false); + return 1; +} +#endif + +// Submits a login against the first known repeater/room contact via +// MyMesh::sendRoomLogin() -- the exact same function AdminScreen's own +// submit button calls (examples/companion_radio/ui-new/AdminScreen.h) -- +// so a test harness can verify the admin/password flow without scripting +// the on-device virtual keyboard. Only reports whether the login *request* +// was sent (matching sim_test_send_msg_to_first_contact()'s same +// synchronous-only contract) -- the actual accept/reject arrives async via +// AbstractUITask::onRoomLoginResult() and is visible on AdminScreen once +// sim_test_open_admin_with_first_repeater() has switched to it. Returns 1 +// if sent, 0 if send failed, -1 if no repeater/room contact known yet. +extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_login_first_repeater(const char* password) { + if (!g_sim_ready) return -1; + ContactInfo ci; + if (!findFirstAdminContact(ci)) return -1; + uint32_t est_timeout; + return the_mesh.sendRoomLogin(ci, password, est_timeout) ? 1 : 0; +} #endif diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 7b26b8b0..dc03769e 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2424,7 +2424,21 @@ void UITask::loop() { } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { if (!_locked) enqueueKey(handleTripleClick(KEY_SELECT)); } -#elif defined(SIM_PLATFORM) +#elif defined(SIM_PLATFORM) && !defined(__EMSCRIPTEN__) + // Native terminal input ONLY -- this branch previously had no + // __EMSCRIPTEN__ exclusion, so it also compiled into the wasm build + // (SIM_PLATFORM is defined there too, and UI_HAS_JOYSTICK/PIN_USER_BTN + // are both unset for variants/sim). Every tick it called real select()/ + // read() on fd 0; under Emscripten, with no stdin ever wired up, that + // hits the runtime's default TTY device, which falls back to a real, + // blocking window.prompt("Input: ") -- so every single browser tab + // running the wasm build was popping a native dialog on nearly every + // frame, discovered by seeing Playwright's page 'dialog' event fire + // continuously from the moment the module boots. The wasm build's own + // input already comes through sim_enqueue_key()/injectSimKey() (see + // above, in the #if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) + // block) -- this stdin-poll branch was only ever meant for Phase 1's + // native terminal target. // Native terminal input: stdin is put into raw/non-canonical mode by // variants/sim/sim_main.cpp's main(), so keys arrive here one at a time // with no Enter-to-submit line buffering. Non-blocking select() on fd 0 diff --git a/variants/sim/SimLocationProvider.h b/variants/sim/SimLocationProvider.h index 3afe77b2..9a9fe159 100644 --- a/variants/sim/SimLocationProvider.h +++ b/variants/sim/SimLocationProvider.h @@ -49,12 +49,15 @@ public: }; // Single instance, mirroring the pattern of every other Sim* global -// (board/radio_driver/rtc_clock/sensors) declared in target.h/target.cpp -- -// this one isn't wired into target.h/.cpp itself since nothing on the -// SIM_PLATFORM boot path currently constructs a LocationProvider at all -// (see the class comment above), so it lives here instead as a -// self-contained, opt-in addition. -extern SensorManager sensors; // defined in variants/sim/target.cpp +// (board/radio_driver/rtc_clock/sensors) declared in target.h/target.cpp. +// Typed as SimSensorManager, not the SensorManager base, because C++ +// requires every declaration of the same global variable to agree on its +// exact type -- target.cpp's actual `SimSensorManager sensors;` definition +// would otherwise conflict with a base-typed extern here. This only +// compiles because target.h includes SimSensorManager.h (which declares +// the class) before this file -- see target.h's own comment on that +// ordering. +extern SimSensorManager sensors; // defined in variants/sim/target.cpp inline SimLocationProvider& sim_location_provider() { static SimLocationProvider instance; return instance; diff --git a/variants/sim/SimMainBoard.h b/variants/sim/SimMainBoard.h index 86398ea4..9ad4ddf3 100644 --- a/variants/sim/SimMainBoard.h +++ b/variants/sim/SimMainBoard.h @@ -7,10 +7,20 @@ // mesh::MainBoard implementation for the native sim build -- no real // hardware, so battery/manufacturer/reboot are all just plausible fakes. class SimMainBoard : public mesh::MainBoard { + // Real telemetry code (examples/companion_radio/MyMesh.cpp, + // examples/simple_room_server/MyMesh.cpp) reads getBattMilliVolts() + // directly rather than going through SensorManager -- so a JS-settable + // battery level lives here, not on SimSensorManager. Static (not a plain + // member) so the EMSCRIPTEN_KEEPALIVE free function below can reach it + // without needing a reference to the one `board` global (target.cpp) + // plumbed through, same reasoning as SimLocationProvider.h's singleton. + static uint16_t& battMilliVoltsRef() { static uint16_t mv = 4000; return mv; } + public: void begin() { } - uint16_t getBattMilliVolts() override { return 4000; } // pretend full battery + uint16_t getBattMilliVolts() override { return battMilliVoltsRef(); } + static void setBattMilliVolts(uint16_t mv) { battMilliVoltsRef() = mv; } const char* getManufacturerName() const override { return "MeshCore Sim (native)"; } void reboot() override { @@ -24,3 +34,13 @@ public: void onBootComplete() override { } void sleep(uint32_t secs) override { } // no-op: native process never actually sleeps the CPU }; + +#ifdef __EMSCRIPTEN__ +#include +// C linkage so JS's ccall('sim_battery_set_mv', ...) can find it by its +// exact, unmangled name -- same pattern as SimLocationProvider.h's +// sim_location_set(). +extern "C" inline EMSCRIPTEN_KEEPALIVE void sim_battery_set_mv(int mv) { + SimMainBoard::setBattMilliVolts((uint16_t)mv); +} +#endif diff --git a/variants/sim/SimSensorManager.h b/variants/sim/SimSensorManager.h new file mode 100644 index 00000000..3a453ade --- /dev/null +++ b/variants/sim/SimSensorManager.h @@ -0,0 +1,55 @@ +#pragma once + +#include + +// SensorManager subclass wiring the sim's already-existing +// SimLocationProvider (SimLocationProvider.h) into the real +// getLocationProvider() hook -- the base SensorManager (previously used +// as-is in target.cpp) always returns NULL there (see +// SimLocationProvider.h's own comment on this exact gap), so on-device +// screens that check "do I have a GPS fix" (CompassScreen.h, +// NearbyScreen.h) never saw a fix even after sim_location_set() was +// called, even though the *advertised* position (sensors.node_lat/lon) +// already worked. Also adds one representative JS-settable environment +// telemetry channel (temperature) to prove the querySensors() mechanism -- +// same channel-numbering convention as the real +// src/helpers/sensors/EnvironmentSensorManager.cpp: GPS on +// TELEM_CHANNEL_SELF, each other active sensor on the next channel. +// +// getLocationProvider()/querySensors() are declared here but DEFINED in +// target.cpp, not inline -- they need SimLocationProvider.h's complete +// type (for the LocationProvider* upcast, and to call +// sim_location_provider()), and SimLocationProvider.h in turn needs THIS +// class's complete type for its own `extern SimSensorManager sensors;` +// declaration (see that file's comment) -- pulling SimLocationProvider.h +// in here too would make the two headers mutually dependent on each +// other's complete type with no valid include order. target.cpp already +// includes target.h, which includes both in the one order that works +// (this file first, then SimLocationProvider.h), so it's the natural +// place for the bodies that need both. +// +// Deliberately doesn't fake any of the ~15 real I2C sensor chip drivers +// (BME280/INA219/etc, EnvironmentSensorManager.cpp) -- those are +// hardware-specific and out of scope; one JS-settable channel is enough to +// prove the mechanism and is trivially extended later if a specific sensor +// type turns out to matter for a demo. +class SimSensorManager : public SensorManager { + static float& envTemperatureRef() { static float t = 21.0f; return t; } + +public: + LocationProvider* getLocationProvider() override; + bool querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) override; + + static void setEnvTemperature(float celsius) { envTemperatureRef() = celsius; } +}; + +#ifdef __EMSCRIPTEN__ +#include +// C linkage so JS's ccall('sim_env_temperature_set', ...) can find it by +// its exact, unmangled name -- same pattern as SimLocationProvider.h's +// sim_location_set() / SimMainBoard.h's sim_battery_set_mv(). Doesn't touch +// SimLocationProvider.h, so it's safe to define inline here. +extern "C" inline EMSCRIPTEN_KEEPALIVE void sim_env_temperature_set(float celsius) { + SimSensorManager::setEnvTemperature(celsius); +} +#endif diff --git a/variants/sim/target.cpp b/variants/sim/target.cpp index c8922fea..4a7193bf 100644 --- a/variants/sim/target.cpp +++ b/variants/sim/target.cpp @@ -9,12 +9,32 @@ SimSerialClass Serial; SimMainBoard board; SimRadio radio_driver; SimRTCClock rtc_clock; -SensorManager sensors; // base class: no real sensors in Phase 1 +// Phase 4: SimSensorManager wires in the sim's GPS + one JS-settable +// environment channel -- see SimSensorManager.h (its methods' bodies are +// defined further down in this file, not there, for the reason explained +// in that header's comment). +SimSensorManager sensors; #ifdef DISPLAY_CLASS DISPLAY_CLASS display; #endif +// SimSensorManager's methods (see that header's comment on why they're +// defined here rather than inline). +LocationProvider* SimSensorManager::getLocationProvider() { + return &sim_location_provider(); +} + +bool SimSensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) { + if ((requester_permissions & TELEM_PERM_LOCATION) && sim_location_provider().isValid()) { + telemetry.addGPS(TELEM_CHANNEL_SELF, (float)node_lat, (float)node_lon, (float)node_altitude); + } + if (requester_permissions & TELEM_PERM_ENVIRONMENT) { + telemetry.addTemperature(TELEM_CHANNEL_SELF + 1, envTemperatureRef()); + } + return true; +} + bool radio_init() { // No real radio hardware to initialise -- always succeeds (see // variants/sim/SimRadio.h; Phase 3 of the sim plan is where two SimRadio @@ -28,7 +48,15 @@ mesh::LocalIdentity radio_new_identity() { return mesh::LocalIdentity(&rng); } -#ifdef __EMSCRIPTEN__ +// DISPLAY_CLASS too, not just __EMSCRIPTEN__: SimDisplayDriverCanvas itself +// only exists when DISPLAY_CLASS is defined (target.h only #includes +// SimDisplayDriver.h -- where the class lives -- inside its own #ifdef +// DISPLAY_CLASS block), and the headless repeater/room_server wasm builds +// never define DISPLAY_CLASS at all. This was a latent gap from the +// pixel-perfect-font change (companion_radio's own build_wasm.sh happens to +// always define DISPLAY_CLASS, so it never surfaced there) -- only found now +// that build_wasm_repeater.sh got rebuilt for Phase 4. +#if defined(__EMSCRIPTEN__) && defined(DISPLAY_CLASS) // Real bitmap-font text rendering for SimDisplayDriverCanvas::print() // (declared in SimDisplayDriver.h, defined here -- the one TU allowed to // include MiscFixedRenderer.h; see that header's own "include only from a diff --git a/variants/sim/target.h b/variants/sim/target.h index 819721a6..a1a464c1 100644 --- a/variants/sim/target.h +++ b/variants/sim/target.h @@ -11,6 +11,13 @@ #include "SimMainBoard.h" #include "SimRTCClock.h" #include +// SimSensorManager.h before SimLocationProvider.h, deliberately: the +// `sensors` global's real type is SimSensorManager (defined in +// target.cpp), and SimLocationProvider.h's own `extern SimSensorManager +// sensors;` declaration (see that file's comment) needs the complete type +// already visible -- C++ requires every declaration of the same global to +// agree on its exact type, not just something covariant/compatible. +#include "SimSensorManager.h" // Included here (rather than only where it's used) so its JS-facing // sim_location_set() EMSCRIPTEN_KEEPALIVE export actually gets compiled // into every SIM_PLATFORM target that includes target.h (companion_radio @@ -25,7 +32,7 @@ extern SimMainBoard board; extern SimRadio radio_driver; extern SimRTCClock rtc_clock; -extern SensorManager sensors; +extern SimSensorManager sensors; #ifdef DISPLAY_CLASS extern DISPLAY_CLASS display; diff --git a/variants/sim/web/index.html b/variants/sim/web/index.html index 0f943dd6..def79093 100644 --- a/variants/sim/web/index.html +++ b/variants/sim/web/index.html @@ -66,6 +66,41 @@ .dpad .down { grid-column: 2; grid-row: 3; } .row { display: flex; gap: 8px; } .row button { padding: 8px 14px; } + .panel { + width: 512px; + box-sizing: border-box; + background: #222; + border: 1px solid #333; + border-radius: 6px; + padding: 8px 10px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + font-size: 12px; + } + .panel input { + background: #111; + color: #eee; + border: 1px solid #444; + border-radius: 4px; + padding: 4px 6px; + font-size: 12px; + } + .panel input[type=number] { width: 70px; } + .panel input[type=password] { width: 110px; } + .panel button { + background: #2a2a2a; + color: #eee; + border: 1px solid #555; + border-radius: 4px; + padding: 5px 10px; + font-size: 12px; + cursor: pointer; + } + .panel button:active { background: #444; } + .panel .reset-btn { background: #4a2222; border-color: #733; } + .panel .reset-btn:active { background: #622; } #log { width: 512px; height: 90px; @@ -95,6 +130,30 @@ + + + +
+ GPS: + + + + +
+ +
+ Sensors: + + + + +
+ +
+ Admin: + + +
@@ -110,6 +169,17 @@ logEl.scrollTop = logEl.scrollHeight; } + // Buttons must never grab real browser keyboard focus -- without this a + // clicked button (e.g. Reset) stays focused, and a LATER physical + // Enter/Space keypress meant for the simulated device's own OK button + // instead re-activates that still-focused HTML button (a real browser + // default for + + + + +
+ GPS: + + + +
+
+ + + + +
+
+ + +
@@ -124,9 +171,10 @@
📡
relayed: 0
loading...
+ -
+

Instance B (companion_radio)

loading...
@@ -137,9 +185,30 @@ + + + +
+
+ GPS: + + + +
+
+ + + + +
+
+ + +
+
Click a device panel to focus it -- a focused panel's canvas border turns amber, and physical keyboard typing (letters/digits/Backspace/Tab-to-submit, same as the single-instance harness) goes to whichever panel is focused.
@@ -171,6 +240,14 @@ logEl.scrollTop = logEl.scrollHeight; } + // Buttons must never grab real browser keyboard focus -- see + // web/index.html's comment on this same line for the full rationale + // (a lingering-focused Reset/Admin button gets silently re-activated by + // a later physical Enter/Space keypress meant for the focused device). + document.querySelectorAll('button').forEach((btn) => { + btn.addEventListener('mousedown', (e) => e.preventDefault()); + }); + const MAX_PKT = 256; // MAX_TRANS_UNIT (255) rounded up, src/MeshCore.h // Per-instance wrapper: allocates a persistent scratch TX-poll buffer, @@ -261,6 +338,60 @@ // UITask::handleLongPress()). const LONG_PRESS_MS = 1000; + // A/B/R are `let`, not `const`: Reset (see resetInstance() below) + // replaces one with a freshly re-instantiated module, so every other + // reference to "the current A" needs to go through these bindings + // rather than closing over a fixed object. + let A, B, R; + + // Wait for an instance's real setup() to finish (see sim_is_ready() in + // examples/companion_radio/main.cpp + examples/simple_repeater/main.cpp) + // -- the MODULARIZE factory promise resolves once the wasm module is + // instantiated, well before the async IDBFS-ready callback actually + // invokes setup(). + async function waitReady(inst, statusElId) { + for (let i = 0; i < 200; i++) { + if (inst.isReady()) { + document.getElementById(statusElId).textContent = 'ready'; + return; + } + await new Promise(r => setTimeout(r, 50)); + } + document.getElementById(statusElId).textContent = 'TIMED OUT waiting for boot'; + log(`WARNING: instance ${inst.label} never became ready`); + } + + function rewireEther() { + // Topology: A <-> R <-> B. No direct A<->B link -- see file header. + etherLinks = [ + { from: A, to: [R] }, + { from: B, to: [R] }, + { from: R, to: [A, B] }, + ]; + } + + // Reset: a real reset button re-runs setup() from scratch on real + // hardware. board.reboot() (SimMainBoard.h) just exit(0)s, which is + // inert under -sEXIT_RUNTIME=0 (freezes the tab instead of resetting + // anything -- see web/index.html's own comment on this same trap), so + // instead of calling into that C++ path, this re-calls the MODULARIZE + // factory function for just this one instance -- each call already + // creates a fully independent module (its own linear memory), which is + // exactly what "the device restarted" means here. The other two + // instances keep running untouched. Identity/prefs still persist + // (same IDBFS database, keyed by the same instance tag) -- same proof + // Phase 2 established for a full page reload. + async function resetInstance(key) { + log(`resetting instance ${key}...`); + document.getElementById('status-' + key).textContent = 'resetting...'; + if (key === 'A') A = wrapInstance(await MeshCoreSim({ simInstanceTag: 'A' }), 'A'); + else if (key === 'B') B = wrapInstance(await MeshCoreSim({ simInstanceTag: 'B' }), 'B'); + else if (key === 'R') R = wrapInstance(await MeshCoreSimRepeater({ simInstanceTag: 'R' }), 'R'); + await waitReady(key === 'A' ? A : key === 'B' ? B : R, 'status-' + key); + rewireEther(); + log(`instance ${key} reset complete.`); + } + async function main() { log('booting instance A (companion_radio)...'); const modA = await MeshCoreSim({ simInstanceTag: 'A' }); @@ -269,26 +400,10 @@ log('booting instance R (simple_repeater)...'); const modR = await MeshCoreSimRepeater({ simInstanceTag: 'R' }); - const A = wrapInstance(modA, 'A'); - const B = wrapInstance(modB, 'B'); - const R = wrapInstance(modR, 'R'); + A = wrapInstance(modA, 'A'); + B = wrapInstance(modB, 'B'); + R = wrapInstance(modR, 'R'); - // Wait for each instance's real setup() to finish (see sim_is_ready() - // in examples/companion_radio/main.cpp + examples/simple_repeater/main.cpp) - // -- the MODULARIZE factory promise resolves once the wasm module is - // instantiated, well before the async IDBFS-ready callback actually - // invokes setup(). - async function waitReady(inst, statusElId) { - for (let i = 0; i < 200; i++) { - if (inst.isReady()) { - document.getElementById(statusElId).textContent = 'ready'; - return; - } - await new Promise(r => setTimeout(r, 50)); - } - document.getElementById(statusElId).textContent = 'TIMED OUT waiting for boot'; - log(`WARNING: instance ${inst.label} never became ready`); - } await Promise.all([ waitReady(A, 'status-A'), waitReady(B, 'status-B'), @@ -296,27 +411,28 @@ ]); log('all instances ready.'); - // Topology: A <-> R <-> B. No direct A<->B link -- see file header. - etherLinks = [ - { from: A, to: [R] }, - { from: B, to: [R] }, - { from: R, to: [A, B] }, - ]; + rewireEther(); startEther(); setInterval(() => pollRelay(R), 100); + // Note: A/B/R can change identity after a Reset (resetInstance() + // above), so this handler must resolve `inst` fresh on every + // pointerdown/up rather than closing over whichever object was + // current when the listener was attached. + function currentInst(key) { return key === 'A' ? A : key === 'B' ? B : R; } + document.querySelectorAll('button[data-instance]').forEach((btn) => { - const inst = btn.dataset.instance === 'A' ? A : B; + const key = btn.dataset.instance; const code = Number(btn.dataset.key); let timer = null, longFired = false; btn.addEventListener('pointerdown', (e) => { e.preventDefault(); longFired = false; - timer = setTimeout(() => { longFired = true; sendKeyLongPress(inst, code); }, LONG_PRESS_MS); + timer = setTimeout(() => { longFired = true; sendKeyLongPress(currentInst(key), code); }, LONG_PRESS_MS); }); btn.addEventListener('pointerup', () => { if (timer) { clearTimeout(timer); timer = null; } - if (!longFired) sendKey(inst, code); + if (!longFired) sendKey(currentInst(key), code); }); const cancel = () => { if (timer) { clearTimeout(timer); timer = null; } }; btn.addEventListener('pointerleave', cancel); @@ -382,7 +498,132 @@ log('=== demo sequence complete ==='); }); - window.__meshSim = { A, B, R }; // exposed for console poking / Playwright + // Reset buttons -- see resetInstance() above for why this re-calls + // the MODULARIZE factory instead of going through board.reboot(). + document.querySelectorAll('button[data-reset]').forEach((btn) => { + btn.addEventListener('click', () => { resetInstance(btn.dataset.reset); }); + }); + + // GPS/battery/temperature: same already-exported functions as the + // single-instance harness (sim_location_set, sim_battery_set_mv, + // sim_env_temperature_set -- see SimLocationProvider.h/SimMainBoard.h/ + // SimSensorManager.h), called against whichever instance's own + // module the button belongs to. + document.querySelectorAll('button[data-gps-set]').forEach((btn) => { + const key = btn.dataset.gpsSet; + btn.addEventListener('click', () => { + const lat = parseFloat(document.getElementById('gps-lat-' + key).value) || 0; + const lon = parseFloat(document.getElementById('gps-lon-' + key).value) || 0; + currentInst(key).mod.ccall('sim_location_set', null, ['number','number','number'], [lat, lon, 0]); + log(`${key}: GPS set to ${lat}, ${lon}`); + }); + }); + document.querySelectorAll('button[data-batt-set]').forEach((btn) => { + const key = btn.dataset.battSet; + btn.addEventListener('click', () => { + const mv = parseInt(document.getElementById('batt-mv-' + key).value, 10) || 0; + currentInst(key).mod.ccall('sim_battery_set_mv', null, ['number'], [mv]); + log(`${key}: battery set to ${mv}mV`); + }); + }); + document.querySelectorAll('button[data-temp-set]').forEach((btn) => { + const key = btn.dataset.tempSet; + btn.addEventListener('click', () => { + const c = parseFloat(document.getElementById('env-temp-' + key).value) || 0; + currentInst(key).mod.ccall('sim_env_temperature_set', null, ['number'], [c]); + log(`${key}: env temperature set to ${c}C`); + }); + }); + + // Admin/repeater login: opens the real AdminScreen for the first + // known repeater/room contact and submits a login via the real + // sendRoomLogin() (see the sim_test_open_admin_with_first_repeater()/ + // sim_test_login_first_repeater() hooks in + // examples/companion_radio/main.cpp). R is a simple_repeater, so once + // A/B have discovered it as a contact (e.g. via "Send Advert" + + // relay), this should find it. Default password "password" matches + // the sim repeater's un-overridden ADMIN_PASSWORD default. + document.querySelectorAll('button[data-admin-open]').forEach((btn) => { + const key = btn.dataset.adminOpen; + btn.addEventListener('click', () => { + const ok = currentInst(key).mod.ccall('sim_test_open_admin_with_first_repeater', 'number', [], []); + log(`${key}: opened AdminScreen (ok=${ok})`); + }); + }); + document.querySelectorAll('button[data-admin-login]').forEach((btn) => { + const key = btn.dataset.adminLogin; + btn.addEventListener('click', () => { + const pw = document.getElementById('admin-pw-' + key).value; + const result = currentInst(key).mod.ccall('sim_test_login_first_repeater', 'number', ['string'], [pw]); + log(`${key}: login request result=${result} (1=sent,0=send failed,-1=no contact yet)`); + }); + }); + + // Click-to-focus: physical keyboard typing (letters/digits/Backspace/ + // Tab-to-submit, same mapping as the single-instance harness) goes to + // whichever device panel was last clicked, shown by an amber canvas + // border. Defaults to A. + let focusedKey = 'A'; + function setFocus(key) { + focusedKey = key; + document.querySelectorAll('.device').forEach((el) => { + el.classList.toggle('focused', el.dataset.instance === key); + }); + } + document.querySelectorAll('.device').forEach((el) => { + el.addEventListener('click', () => setFocus(el.dataset.instance)); + }); + setFocus('A'); + + // Same KEYMAP/resolveKey/press-and-hold logic as web/index.html -- + // see that file's comment for the full rationale (full printable- + // ASCII passthrough, Tab->KEY_KB_ENTER, Backspace is a real + // backspace not Cancel, WASD/space-as-Enter removed). Routes to + // whichever instance is currently focused instead of a single fixed + // module. + // NOTE: no 'n'/'p' Next/Prev shortcuts (see web/index.html's comment -- + // they collided with typing the literal letters 'n'/'p'). The + // on-screen Next(n)/Prev(p) buttons still work via click. + const KEYMAP = { + ArrowUp: 0xB5, ArrowDown: 0xB6, ArrowLeft: 0xB4, ArrowRight: 0xB7, + Enter: 13, Escape: 27, Backspace: 0x08, Tab: 0x05, + }; + function resolveKey(e) { + if (e.key in KEYMAP) return KEYMAP[e.key]; + if (e.key.length === 1) { + const c = e.key.charCodeAt(0); + if (c >= 0x20 && c <= 0x7E) return c; + } + return null; + } + function focusedOnRealInput() { + const el = document.activeElement; + return el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'); + } + const keyHold = new Map(); + window.addEventListener('keydown', (e) => { + if (focusedOnRealInput()) return; + const code = resolveKey(e); + if (code === null) return; + e.preventDefault(); + if (keyHold.has(e.key)) return; + const state = { longFired: false }; + state.timer = setTimeout(() => { state.longFired = true; sendKeyLongPress(currentInst(focusedKey), code); }, LONG_PRESS_MS); + keyHold.set(e.key, state); + }); + window.addEventListener('keyup', (e) => { + if (focusedOnRealInput()) return; + const state = keyHold.get(e.key); + if (!state) return; + clearTimeout(state.timer); + if (!state.longFired) sendKey(currentInst(focusedKey), resolveKey(e)); + keyHold.delete(e.key); + }); + + Object.defineProperty(window, '__meshSim', { + get: () => ({ A, B, R }), configurable: true, + }); // exposed for console poking / Playwright -- a live getter since + // A/B/R can be replaced by resetInstance() after this runs. } main().catch((err) => {