Files
JakubandClaude Sonnet 5 b2cf459460 fix(sim): text-width/render bugs, splash version, wasm-fetch error, battery lag
Code-review pass over the buzzer/sim commits turned up several real bugs,
plus two issues found afterward from manual browser testing:

Rendering (SimDisplayDriverCanvas, variants/sim/SimDisplayDriver.h + target.cpp):
- getTextWidth() measured UTF-8 BYTES (strlen()*6), not codepoints. Since
  b067e95b stopped stripping accents, any accented string now measures
  double its real width -- mis-centred titles, premature ellipsis/marquee,
  badges pushed off-screen. Now uses the real MiscFixedRenderer measurement
  (miscFixedTextWidth()), same as SH1106Display/SSD1306Display.
- Added the matching getCodepointWidth() override (O(1) single-glyph
  advance), same pattern as SSD1306Display.
- isSingleFont() was left at the base class's `false`, though this backend
  only ever renders MiscFixed -- UITask.cpp's status-bar indicator height
  keys off this (`lh-2` vs `lh`), so the sim drew it 2px taller than a real
  board.
- print() blitted the full 128x64 canvas on every call (dozens per frame,
  60fps) -- now tracks a dirty bounding box and only clears/blits the
  region actually touched.

Web Audio (buzzer bridge, index.html + mesh.html):
- No AudioContext.resume() -- a context created (or later suspended) in the
  'suspended' state (Safari/Firefox, or any browser backgrounding the tab)
  stayed silent forever. Now resumed on every gesture.
- linearRampToValueAtTime with no anchoring setValueAtTime interpolates
  from the LAST scheduled event, not "now" -- so the anti-click ramps could
  effectively snap instead of fading. Fixed with cancelScheduledValues +
  setValueAtTime(current) before each ramp.
- mesh.html: a gesture only armed the clicked instance's audio. Click A,
  send A->B, and B (the one actually meant to beep on receipt) stayed
  silent. Now any gesture arms both A and B.
- RTTTL rests (freq=0, still "playing") now explicitly hold pitch and drop
  gain instead of it happening to work by coincidence.

Misc: sim_test_get_num_contacts() was missing the g_sim_ready gate every
other sim_test_* hook has, so it could return a bogus negative count before
setup() finishes seeding num_contacts.

Splash screen missing "Solo <version>" bar: variants/sim never defined
FIRMWARE_SOLO_BUILD (every real Solo board does), so SplashScreen silently
skipped that whole line -- the sim looked like a plain non-Solo companion
build. Added -D FIRMWARE_SOLO_BUILD=1 to platformio.ini and build_wasm.sh.
Verified on a real canvas screenshot: "MESHCORE 1.17.1 / 19 Aug 2026 /
Solo v1.27".

Wasm-fetch error message: "failed to start: RuntimeError: Aborted(both
async and sync fetching of the wasm failed)" is Emscripten's own opaque
message for the single most common real cause -- the page opened via
file://...index.html instead of served over http(s) (fetch() on a local
file is blocked by CORS in both Chrome and Safari, confirmed by reproducing
the exact same error/stack via file://). Both harnesses now detect
location.protocol === 'file:' and show an actionable message with the
one-line fix instead of the raw stack trace.

Battery-set latency: SimMainBoard's battery value is an exact, instantaneous
JS-set integer (see sim_battery_set_mv()), but UITask's battery-check code
polls it every 8s and runs it through an EMA (alpha=0.2) meant to smooth a
REAL board's noisy ADC -- so a value typed into the demo UI could take tens
of seconds to visibly settle. SIM_PLATFORM now checks every 250ms and skips
the EMA (nothing to smooth), since the reading is already clean. Measured
on real canvas pixels: indicator update now lands within one screen-refresh
cycle instead of up to 8s+.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 19:32:23 +02:00

718 lines
32 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 &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" 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">&uarr;</button>
<button data-instance="A" data-key="0xB4">&larr;</button>
<button data-instance="A" data-key="13">OK</button>
<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>
<div class="repeater">
<h2>Instance R (simple_repeater)</h2>
<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" 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">&uarr;</button>
<button data-instance="B" data-key="0xB4">&larr;</button>
<button data-instance="B" data-key="13">OK</button>
<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">
<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&rarr;B</button>
<button id="btn-dm-ba">Send DM B&rarr;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&rarr;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; }
// Buzzer: one Web Audio oscillator+gain per companion instance (not R
// -- a headless simple_repeater has no UITask/buzzer at all), same
// mechanism as web/index.html (see that file's own comment). Keyed by
// instance tag rather than closing over A/B directly, since Reset
// (resetInstance() above) replaces those bindings with a fresh module
// -- currentInst(key) below always resolves the live one, so a reset
// instance's sound keeps working against the same AudioContext.
const buzzAudio = {};
// Any gesture anywhere on the page arms BOTH companion instances, not
// just the one that was clicked: the whole point of this demo is that A
// sends and B *beeps on receipt*, so waiting for a separate gesture on
// B's own panel would leave the receiving side silent in exactly the
// scenario the page exists to show.
function ensureAudioAll() { ensureAudio('A'); ensureAudio('B'); }
function ensureAudio(key) {
if (key === 'R') return;
// Also resume, not just create: a context can start suspended
// (Safari/Firefox) or be suspended again when the tab is
// backgrounded, and would then stay silent forever. See index.html.
if (buzzAudio[key]) {
if (buzzAudio[key].ctx.state === 'suspended') buzzAudio[key].ctx.resume();
return;
}
const ctx = new (window.AudioContext || window.webkitAudioContext)();
if (ctx.state === 'suspended') ctx.resume();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
gain.gain.value = 0;
osc.type = 'square';
osc.frequency.value = 440;
osc.connect(gain).connect(ctx.destination);
osc.start();
buzzAudio[key] = { ctx, osc, gain, wasPlaying: false };
}
const BUZZER_GAIN = [0, 0.02, 0.05, 0.09, 0.15];
// See index.html's rampGain(): a linear ramp interpolates from the last
// scheduled event, so it needs an anchor at the current value to
// actually fade rather than snap.
function rampGain(b, target, seconds, now) {
b.gain.gain.cancelScheduledValues(now);
b.gain.gain.setValueAtTime(b.gain.gain.value, now);
b.gain.gain.linearRampToValueAtTime(target, now + seconds);
}
function pollBuzzer(key) {
const b = buzzAudio[key];
const inst = currentInst(key);
if (!b || !inst || !inst.isReady() || !inst.mod._sim_buzzer_is_playing) return;
const playing = inst.mod.ccall('sim_buzzer_is_playing', 'number', [], []) === 1;
const now = b.ctx.currentTime;
if (playing) {
const freq = inst.mod.ccall('sim_buzzer_freq_hz', 'number', [], []);
const vol = inst.mod.ccall('sim_buzzer_get_volume', 'number', [], []);
// freq 0 is a real RTTTL rest ('p'): still playing, just silent.
if (freq > 0) b.osc.frequency.setValueAtTime(freq, now);
rampGain(b, freq > 0 ? (BUZZER_GAIN[vol] || 0) : 0, 0.005, now);
} else if (b.wasPlaying) {
rampGain(b, 0, 0.01, now);
}
b.wasPlaying = playing;
}
setInterval(() => { pollBuzzer('A'); pollBuzzer('B'); }, 20);
// Exposed for console poking / Playwright, same as window.__meshSim below.
window.__buzzAudio = (key) => (buzzAudio[key]
? { state: buzzAudio[key].ctx.state, gain: buzzAudio[key].gain.gain.value, freq: buzzAudio[key].osc.frequency.value }
: null);
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();
ensureAudioAll();
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();
ensureAudioAll();
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) => {
console.error(err);
// Emscripten's "both async and sync fetching of the wasm failed" is
// near-unreadable on its own -- its single most common real-world
// cause is opening this page directly (file://...mesh.html) instead
// of serving it over http(s): `fetch()` on a local file is blocked by
// CORS in both Chrome and Safari, with no server-side fix (it's the
// browser refusing the request, not a missing/misnamed file). See
// index.html's matching reportBootFailure() for the single-instance
// harness's version of this same check.
if (location.protocol === 'file:') {
log('FATAL: this page must be served over http(s), not opened as a file:// URL '
+ '(the browser blocks the wasm fetch either way). Run e.g. '
+ '"cd variants/sim/web && python3 -m http.server 8080" and open http://localhost:8080/mesh.html instead.');
} else {
log('FATAL: ' + err);
}
});
</script>
</body>
</html>