mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-09-14 15:16:40 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <emscripten.h>
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
|
||||
#include <helpers/SensorManager.h>
|
||||
|
||||
// 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 <emscripten.h>
|
||||
// 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
|
||||
+30
-2
@@ -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
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
#include "SimMainBoard.h"
|
||||
#include "SimRTCClock.h"
|
||||
#include <helpers/SensorManager.h>
|
||||
// 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;
|
||||
|
||||
+184
-13
@@ -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 @@
|
||||
<button data-key="27">Esc / Cancel</button>
|
||||
<button data-key="0xF2">Prev (p)</button>
|
||||
<button data-key="0xF1">Next (n)</button>
|
||||
<button id="btn-reset" class="reset-btn">Reset</button>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<span>GPS:</span>
|
||||
<input id="gps-lat" type="number" step="0.0001" placeholder="lat" value="51.5074">
|
||||
<input id="gps-lon" type="number" step="0.0001" placeholder="lon" value="-0.1278">
|
||||
<input id="gps-alt" type="number" step="1" placeholder="alt (m)" value="35">
|
||||
<button id="btn-gps-set">Set GPS</button>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<span>Sensors:</span>
|
||||
<label>Battery (mV) <input id="batt-mv" type="number" step="10" value="4000"></label>
|
||||
<button id="btn-batt-set">Set</button>
|
||||
<label>Temp (°C) <input id="env-temp" type="number" step="0.5" value="21"></label>
|
||||
<button id="btn-temp-set">Set</button>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<span>Admin:</span>
|
||||
<button id="btn-admin-open">Open Admin (first repeater)</button>
|
||||
<input id="admin-pw" type="password" placeholder="password" value="password">
|
||||
<button id="btn-admin-login">Login</button>
|
||||
</div>
|
||||
|
||||
<div id="log"></div>
|
||||
@@ -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 <button> elements). preventDefault() on mousedown is the
|
||||
// standard way to suppress focus-on-click without affecting the
|
||||
// click/pointerdown/pointerup handlers below (separate event types).
|
||||
document.querySelectorAll('button').forEach((btn) => {
|
||||
btn.addEventListener('mousedown', (e) => e.preventDefault());
|
||||
});
|
||||
|
||||
// console.log/.error from inside the wasm module (the sim_fs_mount_idbfs
|
||||
// EM_ASM block, IDBFS internals, etc.) are useful to see in-page too,
|
||||
// not just devtools -- forward them into #log without disturbing the
|
||||
@@ -163,36 +233,137 @@
|
||||
btn.addEventListener('pointercancel', cancel);
|
||||
});
|
||||
|
||||
// Keyboard passthrough: arrows/Enter/Escape/n/p/WASD, same mapping
|
||||
// UITask.cpp's native SIM_PLATFORM stdin branch already uses. Same
|
||||
// press-and-hold logic as the buttons above, keyed by e.key so
|
||||
// multiple keys can be held independently; the `keyHold.has()` guard
|
||||
// ignores the browser's own keydown auto-repeat (which would
|
||||
// otherwise restart the long-press timer on every repeat tick).
|
||||
// Keyboard passthrough: arrows/Enter/Escape/n/p, same mapping
|
||||
// UITask.cpp's native SIM_PLATFORM stdin branch already uses, PLUS
|
||||
// full printable-ASCII passthrough (letters/digits/punctuation) and
|
||||
// a real Backspace -- this is what makes typing a node name, a chat
|
||||
// message, or an admin password with a real keyboard work at all.
|
||||
// KeyboardWidget.h's own comment on its "direct-typing passthrough"
|
||||
// branch (0x20-0x7E insert, 0x08 backspace, KEY_KB_ENTER submits)
|
||||
// confirms real hardware already accepts exactly these raw byte
|
||||
// values from a literal-ASCII input source (its own example is
|
||||
// CardKB) -- so this is that same input source, just fed from a
|
||||
// real keyboard's keydown events instead of an I2C CardKB
|
||||
// peripheral. Tab -> KEY_KB_ENTER ("submit the field") rather than
|
||||
// Enter itself: Enter must keep meaning KEY_ENTER (13, ordinary
|
||||
// menu-select) everywhere else in the UI, and Tab has no other
|
||||
// meaning here to collide with. Backspace no longer doubles as
|
||||
// Cancel (it did before this change) -- Escape already covers
|
||||
// Cancel identically, and a working Backspace-while-typing matters
|
||||
// more. WASD and space-as-Enter (both present before this change)
|
||||
// are gone too, for the same reason -- 'w'/'a'/'s'/'d'/' ' must now
|
||||
// type as themselves. Arrow keys still work exactly as before.
|
||||
// Same press-and-hold logic as the buttons above, keyed by
|
||||
// e.key so multiple keys can be held independently; the
|
||||
// `keyHold.has()` guard ignores the browser's own keydown
|
||||
// auto-repeat (which would otherwise restart the long-press timer
|
||||
// on every repeat tick).
|
||||
// NOTE: no 'n'/'p' Next/Prev shortcuts here (there were in earlier
|
||||
// phases, before full ASCII passthrough existed) -- they collided
|
||||
// with typing the literal letters 'n'/'p' into any text field. The
|
||||
// on-screen Next(n)/Prev(p) buttons below still work via click.
|
||||
const KEYMAP = {
|
||||
ArrowUp: 0xB5, ArrowDown: 0xB6, ArrowLeft: 0xB4, ArrowRight: 0xB7,
|
||||
Enter: 13, ' ': 13,
|
||||
Escape: 27, Backspace: 27,
|
||||
w: 0xB5, s: 0xB6, a: 0xB4, d: 0xB7,
|
||||
n: 0xF1, p: 0xF2,
|
||||
Enter: 13,
|
||||
Escape: 27,
|
||||
Backspace: 0x08,
|
||||
Tab: 0x05, // KEY_KB_ENTER -- submit the current text field
|
||||
};
|
||||
// Printable ASCII passthrough (KeyboardWidget.h's 0x20-0x7E direct-
|
||||
// insert range) -- covers every letter/digit/punctuation key not
|
||||
// already claimed by KEYMAP above. A single-character e.key IS the
|
||||
// literal typed character already (browsers report the
|
||||
// shift-applied glyph, e.g. 'A' or '!', not a raw scancode), so no
|
||||
// separate shift-handling is needed here.
|
||||
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;
|
||||
}
|
||||
// Don't hijack typing into the GPS/sensor/admin-password <input>
|
||||
// fields below -- those are native HTML inputs the browser already
|
||||
// handles; only forward keystrokes to the simulated device when
|
||||
// focus is elsewhere (the canvas, the body, a button).
|
||||
function focusedOnRealInput() {
|
||||
const el = document.activeElement;
|
||||
return el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA');
|
||||
}
|
||||
const keyHold = new Map();
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (!(e.key in KEYMAP)) return;
|
||||
if (focusedOnRealInput()) return;
|
||||
const code = resolveKey(e);
|
||||
if (code === null) return;
|
||||
e.preventDefault();
|
||||
if (keyHold.has(e.key)) return; // auto-repeat, not a new press
|
||||
const code = KEYMAP[e.key];
|
||||
const state = { longFired: false };
|
||||
state.timer = setTimeout(() => { state.longFired = true; sendKeyLongPress(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(KEYMAP[e.key]);
|
||||
if (!state.longFired) sendKey(resolveKey(e));
|
||||
keyHold.delete(e.key);
|
||||
});
|
||||
|
||||
// Reset: a real reset button re-runs setup() from scratch on real
|
||||
// hardware -- under Emscripten, board.reboot() (SimMainBoard.h)
|
||||
// just exit(0)s, which is inert with -sEXIT_RUNTIME=0 (freezes the
|
||||
// tab instead of resetting anything), so this is a real browser
|
||||
// page reload instead. Identity/prefs already persist across this
|
||||
// via IDBFS (see this file's own header comment) -- same proof
|
||||
// Phase 2 established.
|
||||
document.getElementById('btn-reset').addEventListener('click', () => {
|
||||
location.reload();
|
||||
});
|
||||
|
||||
// GPS: wires up the already-exported sim_location_set() (see
|
||||
// SimLocationProvider.h) -- now that SimSensorManager actually
|
||||
// plugs a LocationProvider into getLocationProvider(), the
|
||||
// on-device Compass/Nearby screens will show a real fix after this.
|
||||
document.getElementById('btn-gps-set').addEventListener('click', () => {
|
||||
const lat = parseFloat(document.getElementById('gps-lat').value) || 0;
|
||||
const lon = parseFloat(document.getElementById('gps-lon').value) || 0;
|
||||
const alt = parseFloat(document.getElementById('gps-alt').value) || 0;
|
||||
Module.ccall('sim_location_set', null, ['number','number','number'], [lat, lon, alt]);
|
||||
log(`GPS set: ${lat}, ${lon}, ${alt}m`);
|
||||
});
|
||||
|
||||
// Sensors: battery (SimMainBoard.h) and one representative
|
||||
// environment channel (SimSensorManager.h) -- both JS-settable the
|
||||
// same way sim_location_set() already was.
|
||||
document.getElementById('btn-batt-set').addEventListener('click', () => {
|
||||
const mv = parseInt(document.getElementById('batt-mv').value, 10) || 0;
|
||||
Module.ccall('sim_battery_set_mv', null, ['number'], [mv]);
|
||||
log(`battery set: ${mv}mV`);
|
||||
});
|
||||
document.getElementById('btn-temp-set').addEventListener('click', () => {
|
||||
const c = parseFloat(document.getElementById('env-temp').value) || 0;
|
||||
Module.ccall('sim_env_temperature_set', null, ['number'], [c]);
|
||||
log(`env temperature set: ${c}C`);
|
||||
});
|
||||
|
||||
// Admin: open the real AdminScreen for the first known repeater/
|
||||
// room contact, then submit 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) -- the sim's repeater/room
|
||||
// server default to ADMIN_PASSWORD "password" (never overridden by
|
||||
// variants/sim/), so that's this field's default value too.
|
||||
document.getElementById('btn-admin-open').addEventListener('click', () => {
|
||||
const ok = Module.ccall('sim_test_open_admin_with_first_repeater', 'number', [], []);
|
||||
log(ok ? 'opened AdminScreen for first repeater/room contact' : 'no repeater/room contact known yet');
|
||||
});
|
||||
document.getElementById('btn-admin-login').addEventListener('click', () => {
|
||||
const pw = document.getElementById('admin-pw').value;
|
||||
const result = Module.ccall('sim_test_login_first_repeater', 'number', ['string'], [pw]);
|
||||
log(`login request result=${result} (1=sent,0=send failed,-1=no contact yet; actual accept/reject is async -- check the Admin screen)`);
|
||||
});
|
||||
}).catch((err) => {
|
||||
statusEl.textContent = 'failed to start: ' + err;
|
||||
console.error(err);
|
||||
|
||||
+272
-31
@@ -98,6 +98,33 @@
|
||||
}
|
||||
.ether-config { font-size: 11px; color: #999; display: flex; gap: 14px; align-items: center; }
|
||||
.ether-config input[type=number] { width: 56px; }
|
||||
.device.focused canvas.sim-canvas { border-color: #ffb000; }
|
||||
.device-panel {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: #222;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.device-panel input {
|
||||
background: #111;
|
||||
color: #eee;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
padding: 3px 5px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.device-panel input[type=number] { width: 58px; }
|
||||
.device-panel input[type=password] { width: 90px; }
|
||||
.device-panel button { padding: 4px 8px; font-size: 11px; }
|
||||
.reset-btn { background: #4a2222 !important; border-color: #733 !important; }
|
||||
.reset-btn:active { background: #622 !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -105,7 +132,7 @@
|
||||
<div class="sub">Real companion_radio x2 (A, B) + real simple_repeater (R), same compiled wasm as the single-instance harness -- topology is A <-> R <-> B only (no direct A-B link), so any delivered message proves real relay routing.</div>
|
||||
|
||||
<div class="devices">
|
||||
<div class="device">
|
||||
<div class="device" id="device-A" data-instance="A">
|
||||
<h2>Instance A (companion_radio)</h2>
|
||||
<canvas id="sim-canvas-A" class="sim-canvas" width="128" height="64"></canvas>
|
||||
<div class="status" id="status-A">loading...</div>
|
||||
@@ -116,6 +143,26 @@
|
||||
<button data-instance="A" data-key="0xB7">→</button>
|
||||
<button data-instance="A" data-key="0xB6">↓</button>
|
||||
<button data-instance="A" data-key="27">Esc</button>
|
||||
<button data-instance="A" data-key="0xF2">Prev</button>
|
||||
<button data-instance="A" data-key="0xF1">Next</button>
|
||||
<button class="reset-btn" data-reset="A">Reset</button>
|
||||
</div>
|
||||
<div class="device-panel">
|
||||
<span>GPS:</span>
|
||||
<input id="gps-lat-A" type="number" step="0.0001" value="51.5074">
|
||||
<input id="gps-lon-A" type="number" step="0.0001" value="-0.1278">
|
||||
<button data-gps-set="A">Set</button>
|
||||
</div>
|
||||
<div class="device-panel">
|
||||
<label>Batt(mV) <input id="batt-mv-A" type="number" step="10" value="4000"></label>
|
||||
<button data-batt-set="A">Set</button>
|
||||
<label>Temp(°C) <input id="env-temp-A" type="number" step="0.5" value="21"></label>
|
||||
<button data-temp-set="A">Set</button>
|
||||
</div>
|
||||
<div class="device-panel">
|
||||
<button data-admin-open="A">Open Admin</button>
|
||||
<input id="admin-pw-A" type="password" value="password">
|
||||
<button data-admin-login="A">Login</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,9 +171,10 @@
|
||||
<div class="tower" id="tower">📡</div>
|
||||
<div class="relay-count" id="relay-count">relayed: 0</div>
|
||||
<div class="status" id="status-R">loading...</div>
|
||||
<button class="reset-btn" data-reset="R">Reset</button>
|
||||
</div>
|
||||
|
||||
<div class="device">
|
||||
<div class="device" id="device-B" data-instance="B">
|
||||
<h2>Instance B (companion_radio)</h2>
|
||||
<canvas id="sim-canvas-B" class="sim-canvas" width="128" height="64"></canvas>
|
||||
<div class="status" id="status-B">loading...</div>
|
||||
@@ -137,9 +185,30 @@
|
||||
<button data-instance="B" data-key="0xB7">→</button>
|
||||
<button data-instance="B" data-key="0xB6">↓</button>
|
||||
<button data-instance="B" data-key="27">Esc</button>
|
||||
<button data-instance="B" data-key="0xF2">Prev</button>
|
||||
<button data-instance="B" data-key="0xF1">Next</button>
|
||||
<button class="reset-btn" data-reset="B">Reset</button>
|
||||
</div>
|
||||
<div class="device-panel">
|
||||
<span>GPS:</span>
|
||||
<input id="gps-lat-B" type="number" step="0.0001" value="40.7128">
|
||||
<input id="gps-lon-B" type="number" step="0.0001" value="-74.0060">
|
||||
<button data-gps-set="B">Set</button>
|
||||
</div>
|
||||
<div class="device-panel">
|
||||
<label>Batt(mV) <input id="batt-mv-B" type="number" step="10" value="4000"></label>
|
||||
<button data-batt-set="B">Set</button>
|
||||
<label>Temp(°C) <input id="env-temp-B" type="number" step="0.5" value="21"></label>
|
||||
<button data-temp-set="B">Set</button>
|
||||
</div>
|
||||
<div class="device-panel">
|
||||
<button data-admin-open="B">Open Admin</button>
|
||||
<input id="admin-pw-B" type="password" value="password">
|
||||
<button data-admin-login="B">Login</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sub" style="margin-top:8px;">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.</div>
|
||||
|
||||
<div id="controls">
|
||||
<div class="ether-config">
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user