mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-09-14 15:16:40 +00:00
feat(sim): two-device messaging + repeater relay over a JS ether
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>
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user