Files
MeshCore-Solo/variants/sim/web/mesh.html
T

395 lines
16 KiB
HTML
Raw Normal View History

<!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; }
</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">
<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>
</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>
</div>
<div class="device">
<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>
</div>
</div>
</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;
}
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;
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' });
const A = wrapInstance(modA, 'A');
const B = wrapInstance(modB, 'B');
const R = wrapInstance(modR, 'R');
// Wait for each instance's real setup() to finish (see sim_is_ready()
// in examples/companion_radio/main.cpp + examples/simple_repeater/main.cpp)
// -- the MODULARIZE factory promise resolves once the wasm module is
// instantiated, well before the async IDBFS-ready callback actually
// invokes setup().
async function waitReady(inst, statusElId) {
for (let i = 0; i < 200; i++) {
if (inst.isReady()) {
document.getElementById(statusElId).textContent = 'ready';
return;
}
await new Promise(r => setTimeout(r, 50));
}
document.getElementById(statusElId).textContent = 'TIMED OUT waiting for boot';
log(`WARNING: instance ${inst.label} never became ready`);
}
await Promise.all([
waitReady(A, 'status-A'),
waitReady(B, 'status-B'),
waitReady(R, 'status-R'),
]);
log('all instances ready.');
// Topology: A <-> R <-> B. No direct A<->B link -- see file header.
etherLinks = [
{ from: A, to: [R] },
{ from: B, to: [R] },
{ from: R, to: [A, B] },
];
startEther();
setInterval(() => pollRelay(R), 100);
document.querySelectorAll('button[data-instance]').forEach((btn) => {
const inst = btn.dataset.instance === 'A' ? A : B;
const code = Number(btn.dataset.key);
let timer = null, longFired = false;
btn.addEventListener('pointerdown', (e) => {
e.preventDefault();
longFired = false;
timer = setTimeout(() => { longFired = true; sendKeyLongPress(inst, code); }, LONG_PRESS_MS);
});
btn.addEventListener('pointerup', () => {
if (timer) { clearTimeout(timer); timer = null; }
if (!longFired) sendKey(inst, 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 ===');
});
window.__meshSim = { A, B, R }; // exposed for console poking / Playwright
}
main().catch((err) => {
log('FATAL: ' + err);
console.error(err);
});
</script>
</body>
</html>