mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-09-14 15:16:40 +00:00
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>
636 lines
28 KiB
HTML
636 lines
28 KiB
HTML
<!DOCTYPE html>
|
|
<!--
|
|
Phase 3 proof harness -- two real companion_radio instances (A, B) and one
|
|
real simple_repeater instance (R), all three the SAME compiled binaries as
|
|
build_wasm.sh/build_wasm_repeater.sh produce (no special "multi" build),
|
|
loaded TWICE (A/B) and once (R) via their MODULARIZE factory functions
|
|
(MeshCoreSim()/MeshCoreSimRepeater()) -- each call returns an independent
|
|
Module instance with its own linear memory/globals, confirmed empirically
|
|
(see the Phase 3 report). They are bridged ONLY by the plain JS "ether"
|
|
loop below, which shuttles real raw packet bytes between each instance's
|
|
SimRadio TX/RX queues (sim_radio_poll_tx()/sim_radio_inject_rx(), see
|
|
variants/sim/SimRadio.h) -- no protocol logic is reimplemented here, this
|
|
is pure transport plumbing.
|
|
|
|
Topology (deliberately NOT a full mesh): A <-> R <-> B only. A and B are
|
|
never wired directly to each other, so any message that reaches the other
|
|
side MUST have been relayed by R -- this is what proves A->R->B routing
|
|
rather than a direct A->B shortcut.
|
|
|
|
Serve this directory with a real static file server (http://, not
|
|
file://), e.g. from this directory:
|
|
python3 -m http.server 8080
|
|
then open http://localhost:8080/mesh.html .
|
|
-->
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>MeshCore sim -- two devices + repeater (Phase 3)</title>
|
|
<style>
|
|
body {
|
|
background: #1b1b1b;
|
|
color: #ddd;
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
padding: 20px;
|
|
}
|
|
h1 { font-size: 16px; font-weight: 600; margin: 0 0 4px; }
|
|
.sub { font-size: 12px; color: #999; margin-bottom: 16px; }
|
|
.devices { display: flex; gap: 24px; align-items: flex-start; flex-wrap: wrap; }
|
|
.device { display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
|
.device h2 { font-size: 13px; margin: 0; color: #ffb000; }
|
|
canvas.sim-canvas {
|
|
background: #000;
|
|
border: 2px solid #444;
|
|
image-rendering: pixelated;
|
|
width: 384px;
|
|
height: 192px;
|
|
}
|
|
.status { font-size: 11px; color: #999; min-height: 1.2em; text-align: center; }
|
|
.row { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
|
button {
|
|
background: #2a2a2a;
|
|
color: #eee;
|
|
border: 1px solid #555;
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
padding: 6px 10px;
|
|
cursor: pointer;
|
|
}
|
|
button:active { background: #444; }
|
|
button:disabled { opacity: 0.4; cursor: default; }
|
|
.repeater {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 12px 18px;
|
|
border: 2px solid #444;
|
|
border-radius: 8px;
|
|
min-width: 160px;
|
|
justify-content: center;
|
|
}
|
|
.repeater h2 { font-size: 13px; margin: 0; color: #7fd0ff; }
|
|
.tower {
|
|
font-size: 32px;
|
|
line-height: 1;
|
|
filter: grayscale(1) brightness(0.6);
|
|
transition: filter 0.15s, transform 0.15s;
|
|
}
|
|
.tower.relaying {
|
|
filter: grayscale(0) brightness(1.3);
|
|
transform: scale(1.15);
|
|
}
|
|
.relay-count { font-size: 12px; color: #7fd0ff; }
|
|
#controls { margin-top: 20px; display: flex; flex-direction: column; gap: 10px; }
|
|
#controls .row { justify-content: flex-start; }
|
|
#log {
|
|
margin-top: 16px;
|
|
width: 100%;
|
|
max-width: 900px;
|
|
height: 220px;
|
|
overflow-y: auto;
|
|
background: #111;
|
|
border: 1px solid #333;
|
|
font: 11px/1.5 ui-monospace, monospace;
|
|
color: #7a7;
|
|
padding: 8px 10px;
|
|
white-space: pre-wrap;
|
|
}
|
|
.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>
|
|
<h1>MeshCore sim -- two devices + repeater, bridged by a JS "ether"</h1>
|
|
<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" 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>
|
|
<div class="row">
|
|
<button data-instance="A" data-key="0xB5">↑</button>
|
|
<button data-instance="A" data-key="0xB4">←</button>
|
|
<button data-instance="A" data-key="13">OK</button>
|
|
<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>
|
|
|
|
<div class="repeater">
|
|
<h2>Instance R (simple_repeater)</h2>
|
|
<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" 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>
|
|
<div class="row">
|
|
<button data-instance="B" data-key="0xB5">↑</button>
|
|
<button data-instance="B" data-key="0xB4">←</button>
|
|
<button data-instance="B" data-key="13">OK</button>
|
|
<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">
|
|
<label>ether delay (ms): <input type="number" id="ether-delay" value="150" min="0" max="5000"></label>
|
|
<label>drop probability (0-1): <input type="number" id="ether-drop" value="0" min="0" max="1" step="0.05"></label>
|
|
<span id="ether-status">ether: stopped</span>
|
|
</div>
|
|
<div class="row">
|
|
<button id="btn-advert-a">Send Advert (A, flood)</button>
|
|
<button id="btn-advert-b">Send Advert (B, flood)</button>
|
|
<button id="btn-dm-ab">Send DM A→B</button>
|
|
<button id="btn-dm-ba">Send DM B→A</button>
|
|
<button id="btn-open-dm-a">Open DM screen on A</button>
|
|
<button id="btn-open-dm-b">Open DM screen on B</button>
|
|
<button id="btn-run-demo">Run full demo (advert both ways, send A→B, open B's DM)</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="log"></div>
|
|
|
|
<script src="build/meshcore_sim.js"></script>
|
|
<script src="build/repeater/meshcore_sim_repeater.js"></script>
|
|
<script>
|
|
const logEl = document.getElementById('log');
|
|
function log(msg) {
|
|
const line = document.createElement('div');
|
|
line.textContent = '[' + new Date().toISOString().substr(11, 12) + '] ' + msg;
|
|
logEl.appendChild(line);
|
|
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,
|
|
// exposes small ccall-based helpers. `mod` is a ready Emscripten Module
|
|
// instance (post-await on the MODULARIZE factory promise).
|
|
function wrapInstance(mod, label) {
|
|
const txBuf = mod._malloc(MAX_PKT);
|
|
return {
|
|
mod, label, txBuf,
|
|
isReady() { return mod.ccall('sim_is_ready', 'number', [], []) === 1; },
|
|
pollTx() {
|
|
// Drain every queued outbound packet this tick (SimRadio's queue
|
|
// is a bounded FIFO -- see variants/sim/SimRadio.h -- so a single
|
|
// poll might not be enough if several packets queued up between
|
|
// ether ticks).
|
|
const packets = [];
|
|
for (;;) {
|
|
const n = mod.ccall('sim_radio_poll_tx', 'number', ['number', 'number'], [txBuf, MAX_PKT]);
|
|
if (n <= 0) break;
|
|
packets.push(mod.HEAPU8.slice(txBuf, txBuf + n));
|
|
}
|
|
return packets;
|
|
},
|
|
injectRx(bytes) {
|
|
const ptr = mod._malloc(bytes.length);
|
|
mod.HEAPU8.set(bytes, ptr);
|
|
mod.ccall('sim_radio_inject_rx', null, ['number', 'number'], [ptr, bytes.length]);
|
|
mod._free(ptr);
|
|
},
|
|
};
|
|
}
|
|
|
|
// --- The "ether": fixed delay + optional flat drop probability, per
|
|
// the plan's "keep it simple, not a full RF model" instruction. Each
|
|
// link below is one-directional; the topology array at the bottom
|
|
// encodes A<->R<->B with no direct A<->B link.
|
|
let etherLinks = []; // [{from, to}]
|
|
let etherTimer = null;
|
|
|
|
function etherTick() {
|
|
const delayMs = Number(document.getElementById('ether-delay').value) || 0;
|
|
const dropProb = Math.min(1, Math.max(0, Number(document.getElementById('ether-drop').value) || 0));
|
|
for (const link of etherLinks) {
|
|
const packets = link.from.pollTx();
|
|
for (const pkt of packets) {
|
|
for (const to of link.to) {
|
|
if (Math.random() < dropProb) {
|
|
log(`ether: dropped ${pkt.length}B ${link.from.label} -> ${to.label}`);
|
|
continue;
|
|
}
|
|
setTimeout(() => {
|
|
to.injectRx(pkt);
|
|
}, delayMs);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function startEther() {
|
|
if (etherTimer) return;
|
|
etherTimer = setInterval(etherTick, 60);
|
|
document.getElementById('ether-status').textContent = 'ether: running';
|
|
}
|
|
|
|
let lastRelayCount = 0;
|
|
function pollRelay(R) {
|
|
if (!R.isReady()) return;
|
|
const n = R.mod.ccall('sim_repeater_get_relay_count', 'number', [], []);
|
|
document.getElementById('relay-count').textContent = 'relayed: ' + n;
|
|
if (n > lastRelayCount) {
|
|
log(`*** Repeater R relayed a packet (count ${lastRelayCount} -> ${n}) ***`);
|
|
const tower = document.getElementById('tower');
|
|
tower.classList.add('relaying');
|
|
setTimeout(() => tower.classList.remove('relaying'), 400);
|
|
}
|
|
lastRelayCount = n;
|
|
}
|
|
|
|
function sendKey(inst, code) {
|
|
inst.mod.ccall('sim_enqueue_key', null, ['number'], [code]);
|
|
}
|
|
function sendKeyLongPress(inst, code) {
|
|
inst.mod.ccall('sim_enqueue_key_longpress', null, ['number'], [code]);
|
|
}
|
|
// Same 1000ms MomentaryButton hold threshold as the single-instance
|
|
// harness (web/index.html) -- see that file's comment for why holding
|
|
// OK/Enter matters (real context-menu access via
|
|
// 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' });
|
|
log('booting instance B (companion_radio)...');
|
|
const modB = await MeshCoreSim({ simInstanceTag: 'B' });
|
|
log('booting instance R (simple_repeater)...');
|
|
const modR = await MeshCoreSimRepeater({ simInstanceTag: 'R' });
|
|
|
|
A = wrapInstance(modA, 'A');
|
|
B = wrapInstance(modB, 'B');
|
|
R = wrapInstance(modR, 'R');
|
|
|
|
await Promise.all([
|
|
waitReady(A, 'status-A'),
|
|
waitReady(B, 'status-B'),
|
|
waitReady(R, 'status-R'),
|
|
]);
|
|
log('all instances ready.');
|
|
|
|
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 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(currentInst(key), code); }, LONG_PRESS_MS);
|
|
});
|
|
btn.addEventListener('pointerup', () => {
|
|
if (timer) { clearTimeout(timer); timer = null; }
|
|
if (!longFired) sendKey(currentInst(key), code);
|
|
});
|
|
const cancel = () => { if (timer) { clearTimeout(timer); timer = null; } };
|
|
btn.addEventListener('pointerleave', cancel);
|
|
btn.addEventListener('pointercancel', cancel);
|
|
});
|
|
|
|
document.getElementById('btn-advert-a').addEventListener('click', () => {
|
|
const ok = A.mod.ccall('sim_test_advert_flood', 'number', [], []);
|
|
log(`A: sent flood self-advert (ok=${ok})`);
|
|
});
|
|
document.getElementById('btn-advert-b').addEventListener('click', () => {
|
|
const ok = B.mod.ccall('sim_test_advert_flood', 'number', [], []);
|
|
log(`B: sent flood self-advert (ok=${ok})`);
|
|
});
|
|
document.getElementById('btn-dm-ab').addEventListener('click', () => {
|
|
const text = 'Hello B, this is A! (' + new Date().toLocaleTimeString() + ')';
|
|
const result = A.mod.ccall('sim_test_send_msg_to_first_contact', 'number', ['string'], [text]);
|
|
log(`A -> B: sendMessage() result=${result} (1=flood,2=direct,0=failed,-1=no contact yet) text="${text}"`);
|
|
});
|
|
document.getElementById('btn-dm-ba').addEventListener('click', () => {
|
|
const text = 'Hello A, this is B! (' + new Date().toLocaleTimeString() + ')';
|
|
const result = B.mod.ccall('sim_test_send_msg_to_first_contact', 'number', ['string'], [text]);
|
|
log(`B -> A: sendMessage() result=${result} (1=flood,2=direct,0=failed,-1=no contact yet) text="${text}"`);
|
|
});
|
|
document.getElementById('btn-open-dm-a').addEventListener('click', () => {
|
|
const ok = A.mod.ccall('sim_test_open_dm_with_first_contact', 'number', [], []);
|
|
log(`A: opened DM screen (ok=${ok})`);
|
|
});
|
|
document.getElementById('btn-open-dm-b').addEventListener('click', () => {
|
|
const ok = B.mod.ccall('sim_test_open_dm_with_first_contact', 'number', [], []);
|
|
log(`B: opened DM screen (ok=${ok})`);
|
|
});
|
|
|
|
async function waitForContacts(inst, minCount, timeoutMs) {
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
const n = inst.mod.ccall('sim_test_get_num_contacts', 'number', [], []);
|
|
if (n >= minCount) return true;
|
|
await new Promise(r => setTimeout(r, 100));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
document.getElementById('btn-run-demo').addEventListener('click', async () => {
|
|
log('=== running full demo sequence ===');
|
|
A.mod.ccall('sim_test_advert_flood', 'number', [], []);
|
|
log('A: sent flood advert');
|
|
B.mod.ccall('sim_test_advert_flood', 'number', [], []);
|
|
log('B: sent flood advert');
|
|
const gotA = await waitForContacts(A, 1, 5000);
|
|
const gotB = await waitForContacts(B, 1, 5000);
|
|
log(`A has contact: ${gotA}, B has contact: ${gotB}`);
|
|
if (!gotA || !gotB) {
|
|
log('demo aborted: advert propagation through repeater did not complete in time');
|
|
return;
|
|
}
|
|
const text = 'Hello B, this is A! (' + new Date().toLocaleTimeString() + ')';
|
|
const result = A.mod.ccall('sim_test_send_msg_to_first_contact', 'number', ['string'], [text]);
|
|
log(`A -> B: sendMessage() result=${result}, text="${text}"`);
|
|
await new Promise(r => setTimeout(r, 1500));
|
|
const ok = B.mod.ccall('sim_test_open_dm_with_first_contact', 'number', [], []);
|
|
log(`B: opened DM screen (ok=${ok}) -- check B's canvas for the received text`);
|
|
log('=== demo sequence complete ===');
|
|
});
|
|
|
|
// 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) => {
|
|
log('FATAL: ' + err);
|
|
console.error(err);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|