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:
Jakub
2026-09-03 11:31:42 +02:00
co-authored by Claude Sonnet 5
parent d7242ddc21
commit 97a86216c6
9 changed files with 644 additions and 55 deletions
+9 -6
View File
@@ -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;
+21 -1
View File
@@ -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
+55
View File
@@ -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
View File
@@ -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
+8 -1
View File
@@ -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
View File
@@ -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 (&deg;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
View File
@@ -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 &lt;-&gt; R &lt;-&gt; 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">&rarr;</button>
<button data-instance="A" data-key="0xB6">&darr;</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(&deg;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">&#128225;</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">&rarr;</button>
<button data-instance="B" data-key="0xB6">&darr;</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(&deg;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) => {