mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-09-14 15:16:40 +00:00
Ports examples/simple_repeater to variants/sim/ (new sim_simple_repeater native env + build_wasm_repeater.sh) and adds a JS "ether" (variants/sim/web/mesh.html) that bridges two real companion_radio WASM instances through a real simple_repeater instance in a strict A<->R<->B topology (no direct A-B link), proving genuine relay routing rather than a shortcut. Also fixes multi-instance issues Phase 2's single-instance design never surfaced: SimDisplayDriver's canvas context/id caching was keyed on a single global instead of per-instance, and both wasm builds were missing _malloc/_free/HEAPU8 runtime exports needed for the ether to poke bytes into an instance's memory. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
376 lines
15 KiB
HTML
376 lines
15 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; }
|
|
</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">
|
|
<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>
|
|
</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>
|
|
</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">↑</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>
|
|
</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→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;
|
|
}
|
|
|
|
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]);
|
|
}
|
|
|
|
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) => {
|
|
btn.addEventListener('click', () => {
|
|
const inst = btn.dataset.instance === 'A' ? A : B;
|
|
sendKey(inst, Number(btn.dataset.key));
|
|
});
|
|
});
|
|
|
|
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>
|