2026-09-03 09:14:15 +02:00
<!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 : 20 px ;
}
h1 { font-size : 16 px ; font-weight : 600 ; margin : 0 0 4 px ; }
. sub { font-size : 12 px ; color : #999 ; margin-bottom : 16 px ; }
. devices { display : flex ; gap : 24 px ; align-items : flex-start ; flex-wrap : wrap ; }
. device { display : flex ; flex-direction : column ; align-items : center ; gap : 8 px ; }
. device h2 { font-size : 13 px ; margin : 0 ; color : #ffb000 ; }
canvas . sim-canvas {
background : #000 ;
border : 2 px solid #444 ;
image-rendering : pixelated ;
width : 384 px ;
height : 192 px ;
}
. status { font-size : 11 px ; color : #999 ; min-height : 1.2 em ; text-align : center ; }
. row { display : flex ; gap : 8 px ; flex-wrap : wrap ; justify-content : center ; }
button {
background : #2a2a2a ;
color : #eee ;
border : 1 px solid #555 ;
border-radius : 6 px ;
font-size : 12 px ;
padding : 6 px 10 px ;
cursor : pointer ;
}
button : active { background : #444 ; }
button : disabled { opacity : 0.4 ; cursor : default ; }
. repeater {
display : flex ;
flex-direction : column ;
align-items : center ;
gap : 8 px ;
padding : 12 px 18 px ;
border : 2 px solid #444 ;
border-radius : 8 px ;
min-width : 160 px ;
justify-content : center ;
}
. repeater h2 { font-size : 13 px ; margin : 0 ; color : #7fd0ff ; }
. tower {
font-size : 32 px ;
line-height : 1 ;
filter : grayscale ( 1 ) brightness ( 0.6 );
transition : filter 0.15 s , transform 0.15 s ;
}
. tower . relaying {
filter : grayscale ( 0 ) brightness ( 1.3 );
transform : scale ( 1.15 );
}
. relay-count { font-size : 12 px ; color : #7fd0ff ; }
# controls { margin-top : 20 px ; display : flex ; flex-direction : column ; gap : 10 px ; }
# controls . row { justify-content : flex-start ; }
# log {
margin-top : 16 px ;
width : 100 % ;
max-width : 900 px ;
height : 220 px ;
overflow-y : auto ;
background : #111 ;
border : 1 px solid #333 ;
font : 11 px / 1.5 ui-monospace , monospace ;
color : #7a7 ;
padding : 8 px 10 px ;
white-space : pre-wrap ;
}
. ether-config { font-size : 11 px ; color : #999 ; display : flex ; gap : 14 px ; align-items : center ; }
. ether-config input [ type = number ] { width : 56 px ; }
2026-09-03 11:31:42 +02:00
. device . focused canvas . sim-canvas { border-color : #ffb000 ; }
. device-panel {
width : 100 % ;
box-sizing : border-box ;
background : #222 ;
border : 1 px solid #333 ;
border-radius : 6 px ;
padding : 6 px 8 px ;
display : flex ;
flex-wrap : wrap ;
align-items : center ;
gap : 5 px ;
font-size : 11 px ;
}
. device-panel input {
background : #111 ;
color : #eee ;
border : 1 px solid #444 ;
border-radius : 4 px ;
padding : 3 px 5 px ;
font-size : 11 px ;
}
. device-panel input [ type = number ] { width : 58 px ; }
. device-panel input [ type = password ] { width : 90 px ; }
. device-panel button { padding : 4 px 8 px ; font-size : 11 px ; }
. reset-btn { background : #4a2222 !important ; border-color : #733 !important ; }
. reset-btn : active { background : #622 !important ; }
2026-09-03 09:14:15 +02:00
</ 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" >
2026-09-03 11:31:42 +02:00
< div class = "device" id = "device-A" data-instance = "A" >
2026-09-03 09:14:15 +02:00
< 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 >
2026-09-03 11:31:42 +02:00
< 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 >
2026-09-03 09:14:15 +02:00
</ 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 >
2026-09-03 11:31:42 +02:00
< button class = "reset-btn" data-reset = "R" > Reset</ button >
2026-09-03 09:14:15 +02:00
</ div >
2026-09-03 11:31:42 +02:00
< div class = "device" id = "device-B" data-instance = "B" >
2026-09-03 09:14:15 +02:00
< 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 >
2026-09-03 11:31:42 +02:00
< 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 >
2026-09-03 09:14:15 +02:00
</ div >
</ div >
</ div >
2026-09-03 11:31:42 +02:00
< 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 >
2026-09-03 09:14:15 +02:00
< 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 ;
}
2026-09-03 11:31:42 +02:00
// 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 ());
});
2026-09-03 09:14:15 +02:00
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 ]);
}
2026-09-03 10:06:44 +02:00
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 ;
2026-09-03 09:14:15 +02:00
2026-09-03 11:31:42 +02:00
// 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.` );
}
2026-09-03 09:14:15 +02:00
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' });
2026-09-03 11:31:42 +02:00
A = wrapInstance ( modA , 'A' );
B = wrapInstance ( modB , 'B' );
R = wrapInstance ( modR , 'R' );
2026-09-03 09:14:15 +02:00
await Promise . all ([
waitReady ( A , 'status-A' ),
waitReady ( B , 'status-B' ),
waitReady ( R , 'status-R' ),
]);
log ( 'all instances ready.' );
2026-09-03 11:31:42 +02:00
rewireEther ();
2026-09-03 09:14:15 +02:00
startEther ();
setInterval (() => pollRelay ( R ), 100 );
2026-09-03 11:31:42 +02:00
// 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 ; }
2026-09-03 13:59:26 +02:00
// 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 = {};
2026-09-03 19:32:23 +02:00
// 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' ); }
2026-09-03 13:59:26 +02:00
function ensureAudio ( key ) {
2026-09-03 19:32:23 +02:00
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 ;
}
2026-09-03 13:59:26 +02:00
const ctx = new ( window . AudioContext || window . webkitAudioContext )();
2026-09-03 19:32:23 +02:00
if ( ctx . state === 'suspended' ) ctx . resume ();
2026-09-03 13:59:26 +02:00
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 ];
2026-09-03 19:32:23 +02:00
// 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 );
}
2026-09-03 13:59:26 +02:00
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' , [], []);
2026-09-03 19:32:23 +02:00
// 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 );
2026-09-03 13:59:26 +02:00
} else if ( b . wasPlaying ) {
2026-09-03 19:32:23 +02:00
rampGain ( b , 0 , 0.01 , now );
2026-09-03 13:59:26 +02:00
}
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 );
2026-09-03 09:14:15 +02:00
document . querySelectorAll ( 'button[data-instance]' ). forEach (( btn ) => {
2026-09-03 11:31:42 +02:00
const key = btn . dataset . instance ;
2026-09-03 10:06:44 +02:00
const code = Number ( btn . dataset . key );
let timer = null , longFired = false ;
btn . addEventListener ( 'pointerdown' , ( e ) => {
e . preventDefault ();
2026-09-03 19:32:23 +02:00
ensureAudioAll ();
2026-09-03 10:06:44 +02:00
longFired = false ;
2026-09-03 11:31:42 +02:00
timer = setTimeout (() => { longFired = true ; sendKeyLongPress ( currentInst ( key ), code ); }, LONG_PRESS_MS );
2026-09-03 09:14:15 +02:00
});
2026-09-03 10:06:44 +02:00
btn . addEventListener ( 'pointerup' , () => {
if ( timer ) { clearTimeout ( timer ); timer = null ; }
2026-09-03 11:31:42 +02:00
if ( ! longFired ) sendKey ( currentInst ( key ), code );
2026-09-03 10:06:44 +02:00
});
const cancel = () => { if ( timer ) { clearTimeout ( timer ); timer = null ; } };
btn . addEventListener ( 'pointerleave' , cancel );
btn . addEventListener ( 'pointercancel' , cancel );
2026-09-03 09:14:15 +02:00
});
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 ===' );
});
2026-09-03 11:31:42 +02:00
// 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 ();
2026-09-03 19:32:23 +02:00
ensureAudioAll ();
2026-09-03 11:31:42 +02:00
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.
2026-09-03 09:14:15 +02:00
}
main (). catch (( err ) => {
console . error ( err );
2026-09-03 19:32:23 +02:00
// 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 );
}
2026-09-03 09:14:15 +02:00
});
</ script >
</ body >
</ html >