From 9cfb58a60b60db45e57bef68c75f104ffab627f5 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:14:15 +0200 Subject: [PATCH] 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 --- examples/companion_radio/MyMesh.cpp | 23 ++ examples/companion_radio/MyMesh.h | 11 + examples/companion_radio/main.cpp | 100 +++++++ examples/simple_repeater/MyMesh.cpp | 4 + examples/simple_repeater/main.cpp | 51 ++++ src/helpers/sensors/LocationProvider.h | 13 +- variants/sim/SimDisplayDriver.h | 67 +++-- variants/sim/SimFS.h | 33 ++- variants/sim/SimInstance.h | 56 ++++ variants/sim/SimLocationProvider.h | 77 ++++- variants/sim/SimRNG.h | 11 +- variants/sim/SimRadio.h | 149 +++++++++- variants/sim/build_wasm.sh | 15 +- variants/sim/build_wasm_repeater.sh | 164 +++++++++++ variants/sim/platformio.ini | 76 +++++ variants/sim/sim_main.cpp | 22 +- variants/sim/target.h | 6 + variants/sim/web/mesh.html | 375 +++++++++++++++++++++++++ 18 files changed, 1199 insertions(+), 54 deletions(-) create mode 100644 variants/sim/SimInstance.h create mode 100755 variants/sim/build_wasm_repeater.sh create mode 100644 variants/sim/web/mesh.html diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 23d96a19..c7220e13 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -3294,6 +3294,29 @@ bool MyMesh::advert() { } } +#ifdef SIM_PLATFORM +bool MyMesh::advertFlood() { + // Mirrors the CMD_SEND_SELF_ADVERT handler's flood=1 branch above + // (createSelfAdvert() + sendFloodScoped() with the default transport + // scope key) -- real protocol logic, just reached from a test-only entry + // point instead of a parsed serial command frame. + mesh::Packet* pkt; + if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) { + pkt = createSelfAdvert(_prefs.node_name); + } else { + pkt = createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon); + } + if (pkt) { + TransportKey default_scope; + memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key)); + sendFloodScoped(default_scope, pkt, 0); + return true; + } else { + return false; + } +} +#endif + // To check if there is pending work bool MyMesh::hasPendingWork() const { return _mgr->getOutboundTotal() > 0 || dirty_contacts_expiry != 0; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 6ac917a0..8a4cdd09 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -108,6 +108,17 @@ public: void loop(); void handleCmdFrame(size_t len); bool advert(); +#ifdef SIM_PLATFORM + // Phase 3 sim-only test hook: same as advert() but FLOOD-routed (like the + // real phone-app CMD_SEND_SELF_ADVERT command's flood=1 branch) instead of + // zero-hop, so a JS test harness can make an instance's identity actually + // propagate through an intermediate repeater instance -- advert() alone + // (zero-hop) never leaves the immediate ether link. Not reachable from any + // real hardware build (no phone app exists in the sim to send the real + // CMD_SEND_SELF_ADVERT command over), so this is additive/dead code + // everywhere else, not a behavior change. + bool advertFlood(); +#endif void sendNodeDiscoverReq(); void enterCLIRescue(); diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 008cb8e2..61bbab53 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -2,6 +2,19 @@ #include #include "MyMesh.h" +#if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) +// Phase 3: true only once setup() has fully finished (set at the very end +// of setup(), below) -- lets a JS test harness poll "has this instance +// actually booted" instead of guessing a fixed delay after the MODULARIZE +// factory promise resolves, which resolves once the wasm module is +// instantiated, well before sim_idbfs_ready()'s async IDBFS callback ever +// invokes setup() (see variants/sim/sim_main.cpp). Calling any of the other +// sim_test_*() hooks below before this is true would run against a +// the_mesh that exists (global C++ construction already ran) but hasn't +// had begin()/an identity loaded yet. +static bool g_sim_ready = false; +#endif + // Believe it or not, this std C function is busted on some platforms! static uint32_t _atoi(const char* sp) { uint32_t n = 0; @@ -289,6 +302,9 @@ void setup() { NRF_WDT->TASKS_START = 1; #endif board.onBootComplete(); +#if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) + g_sim_ready = true; +#endif } void loop() { @@ -323,3 +339,87 @@ void loop() { } #endif } + +#if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) +#include +// Phase 3 sim test hooks -- a browser test harness (no real phone app, no +// on-device keyboard-driven compose flow in this sim yet) needs SOME way +// to trigger "send a flood advert" / "send a DM" from JS. Every one of +// these calls straight into the exact same real BaseChatMesh/MyMesh +// functions the real phone-app serial protocol (CMD_SEND_SELF_ADVERT, +// CMD_SEND_TXT_MSG in this same file) or the on-device UI compose flow +// (MessagesScreen::afterSend) already use -- real crypto, real routing, +// real contact table, nothing about the mesh/message logic is faked here, +// only "what UI gesture triggers it" is short-circuited. See the Phase 3 +// report for why: scripting the on-device virtual keyboard widget +// key-by-key to compose free text was judged not worth the fragility for +// an automated test, versus this ~20-line, obviously-inert-on-real-hardware +// addition. +extern "C" EMSCRIPTEN_KEEPALIVE int sim_is_ready() { + return g_sim_ready ? 1 : 0; +} + +extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_advert_flood() { + if (!g_sim_ready) return 0; + return the_mesh.advertFlood() ? 1 : 0; +} + +// getContactByIdx(idx, ...) indexes DIRECTLY into the raw contacts[] array +// (src/helpers/BaseChatMesh.cpp) with NO offset applied -- getNumContacts() +// only SUBTRACTS MAX_ANON_CONTACTS from the count to hide the reserved +// anon-request slots at the front of that array, it doesn't shift where +// index 0 points. The real UI/CLI code never hits this because it always +// walks contacts via startContactsIterator() (BaseChatMesh.cpp), which +// already begins at MAX_ANON_CONTACTS -- this small helper mirrors that +// same offset for these test-only hooks instead of duplicating an iterator. +static bool findFirstChatContact(ContactInfo& out) { + int n = the_mesh.getNumContacts(); + for (int i = 0; i < n; i++) { + ContactInfo ci; + if (the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, ci) && ci.type == ADV_TYPE_CHAT) { + out = ci; + return true; + } + } + return false; +} + +// Finds the first known contact of type ADV_TYPE_CHAT (i.e. another +// companion_radio instance, not a repeater/room) and sends it a real DM via +// BaseChatMesh::sendMessage() -- the exact function CMD_SEND_TXT_MSG calls. +// Returns MSG_SEND_SENT_FLOOD/MSG_SEND_SENT_DIRECT/MSG_SEND_FAILED (see +// src/helpers/BaseChatMesh.h), or -1 if no chat contact is known yet. +extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_send_msg_to_first_contact(const char* text) { + if (!g_sim_ready) return -1; + ContactInfo ci; + if (!findFirstChatContact(ci)) return -1; + uint32_t expected_ack, est_timeout; + uint32_t ts = rtc_clock.getCurrentTimeUnique(); + return the_mesh.sendMessage(ci, ts, 0, text, expected_ack, est_timeout); +} + +// How many contacts this instance has discovered so far (any type) -- lets +// the JS ether-tick loop poll "has advert propagation finished yet" without +// guessing a fixed timeout. +extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_get_num_contacts() { + return the_mesh.getNumContacts(); +} + +#ifdef DISPLAY_CLASS +// Jumps the on-device UI straight to the DM thread with the first known +// ADV_TYPE_CHAT contact (UITask::openContactDM() -- the exact same real +// function NearbyScreen's contact-list "select" action calls) so a test +// harness can screenshot the canvas and see the actual received message +// text, rendered by the real MessagesScreen/DisplayDriver code, without +// having to script the on-device contact-list navigation key-by-key. +// Returns 1 if a chat contact was found and the screen switched, 0 if not +// (e.g. advert propagation hasn't reached this instance yet). +extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_open_dm_with_first_contact() { + if (!g_sim_ready) return 0; + ContactInfo ci; + if (!findFirstChatContact(ci)) return 0; + ui_task.openContactDM(ci); + return 1; +} +#endif +#endif diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 7d0179f3..cb338da1 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1022,6 +1022,8 @@ bool MyMesh::formatFileSystem() { return LittleFS.format(); #elif defined(ESP32) return SPIFFS.format(); +#elif defined(SIM_PLATFORM) + return _fs->format(); #else #error "need to implement file system erase" return false; @@ -1181,6 +1183,8 @@ void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) { IdentityStore store(*_fs, "/identity"); #elif defined(RP2040_PLATFORM) IdentityStore store(*_fs, "/identity"); +#elif defined(SIM_PLATFORM) + IdentityStore store(*_fs, "/identity"); #else #error "need to define saveIdentity()" #endif diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index ea2e8585..9ef6bcc2 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -22,6 +22,34 @@ void halt() { while (1) ; } +#if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) +#include +// Phase 3 sim ether: a JS-visible "did this repeater just relay a packet" +// hook. MyMesh (src/Mesh.h's Mesh base class) already keeps an exact count +// of packets actually re-transmitted in the repeater/transport role -- +// n_forwarded, incremented at the real ACTION_RETRANSMIT decision points in +// src/Mesh.cpp (routeRecvPacket()) and decremented in onRetransmitCancelled() +// if an overhear cancels a queued retransmit before it goes out -- and +// already exposes it publicly as getNumForwarded() (the same number +// DiagnosticsScreen.h prints on a real companion_radio's screen). Rather +// than re-deriving "was this a relay" from TX/RX byte timing (fragile, +// and duplicates logic the real Mesh class already gets right, including +// the overhear-cancellation edge case), this just exports that exact +// counter for JS to poll and diff on its own ether tick -- zero changes +// to any shared/platform-agnostic src/ file. +extern "C" EMSCRIPTEN_KEEPALIVE uint32_t sim_repeater_get_relay_count() { + return the_mesh.getNumForwarded(); +} + +// Same "has setup() actually finished" readiness flag as +// examples/companion_radio/main.cpp -- see that file's comment for why +// this is needed instead of a fixed post-ready delay. +static bool g_sim_ready = false; +extern "C" EMSCRIPTEN_KEEPALIVE int sim_is_ready() { + return g_sim_ready ? 1 : 0; +} +#endif + static char command[160]; #ifdef ETHERNET_ENABLED static char ethernet_command[160]; @@ -37,7 +65,14 @@ static unsigned long userBtnDownAt = 0; void setup() { Serial.begin(115200); +#ifndef SIM_PLATFORM + // Skip this one-shot boot pause in the sim: under Emscripten it would + // synchronously block the browser's single JS thread for a full second + // (Arduino.h's sim delay() is a real std::this_thread::sleep_for(), and + // this runs before emscripten_set_main_loop ever hands control back) -- + // harmless on a real board's own thread, unnecessary UX friction here. delay(1000); +#endif board.begin(); @@ -81,6 +116,19 @@ void setup() { fs = &LittleFS; IdentityStore store(LittleFS, "/identity"); store.begin(); +#elif defined(SIM_PLATFORM) + // Real files under ./sim_data_repeater/ (relative to the process's cwd, + // native; or the Emscripten virtual FS, wasm) -- deliberately a DIFFERENT + // root than examples/companion_radio/main.cpp's "./sim_data" so a + // repeater instance's identity can never collide with a companion + // instance's, even if both happened to run from the same cwd (native) or + // the same page (wasm, see variants/sim/sim_main.cpp's SIM_FS_ROOT). + // "static" (not a plain local) so sim_fs outlives setup() -- IdentityStore + // only stores a pointer to it, same reasoning as the ui_task static above. + static SimFS sim_fs("./sim_data_repeater"); + fs = &sim_fs; + IdentityStore store(sim_fs, "/identity"); + store.begin(); #else #error "need to define filesystem" #endif @@ -120,6 +168,9 @@ void setup() { #endif board.onBootComplete(); +#if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) + g_sim_ready = true; +#endif } void loop() { diff --git a/src/helpers/sensors/LocationProvider.h b/src/helpers/sensors/LocationProvider.h index 08d531a8..7e09c79f 100644 --- a/src/helpers/sensors/LocationProvider.h +++ b/src/helpers/sensors/LocationProvider.h @@ -22,7 +22,18 @@ public: virtual long getHDOP() { return -1; } virtual bool isValid() = 0; virtual long getTimestamp() = 0; - virtual void sendSentence(const char * sentence); + // Default no-op body (every existing subclass overrides this anyway -- + // MicroNMEALocationProvider.h forwards to the NMEA lib, + // EnvironmentSensorManager.cpp's GPS classes no-op it explicitly) -- + // previously declared with NO definition anywhere in the codebase + // (confirmed by repo-wide grep), which is latently a hard link error + // ("undefined symbol: typeinfo for LocationProvider") for ANY subclass + // that doesn't override it, since base-subobject construction of any + // LocationProvider-derived object needs this class's own vtable/RTTI to + // exist as a real linkable symbol. Surfaced by variants/sim's + // SimLocationProvider (Phase 3 of the sim plan) being the first + // consumer to construct one outside of code that always overrides it. + virtual void sendSentence(const char * sentence) { } virtual void reset() = 0; virtual void begin() = 0; virtual void stop() = 0; diff --git a/variants/sim/SimDisplayDriver.h b/variants/sim/SimDisplayDriver.h index 41083ee1..8b8e736d 100644 --- a/variants/sim/SimDisplayDriver.h +++ b/variants/sim/SimDisplayDriver.h @@ -167,8 +167,31 @@ private: // // Each call reaches into the DOM via EM_ASM (synchronous, main-thread JS -- // fine since this build has no pthreads/proxying). The canvas is looked up -// by id once in begin() and cached on a JS global (window.__simCtx) so every -// later call is one property read, not a fresh getElementById(). +// by id once in begin() and cached on a per-instance JS property (see below) +// so every later call is one property read, not a fresh getElementById(). +// +// Phase 3 addendum: cached on Module.__simCtx, NOT window.__simCtx as this +// class originally did in Phase 2. Phase 2 only ever ran one instance on a +// page, so a plain `window` global was invisible/harmless as a design smell; +// Phase 3 loads multiple MeshCoreSim()/MeshCoreSimRepeater() instances on +// ONE page, and `window` is the single real browser global shared by every +// one of them (MODULARIZE isolates each instance's own Module/wasm linear +// memory, but NOT the DOM/window) -- two instances' begin() calls would +// stomp the same window.__simCtx in turn, and both would end up drawing +// through whichever one won. `Module` itself, by contrast, IS a distinct +// object per instance (that's the whole point of MODULARIZE) and is already +// reachable from inside EM_ASM here as the current instance's own Module +// (same access pattern SimFS.h's sim_fs_mount_idbfs()/SimInstance.h's +// sim_instance_salt() already rely on for Module['simInstanceTag']), so +// storing it there instead scopes it correctly per instance for free. +// +// The canvas element id is ALSO made per-instance the same way: an untagged +// instance (no Module['simInstanceTag'], e.g. Phase 2's original +// single-instance web/index.html harness) still looks for plain +// "sim-canvas", byte-for-byte the pre-Phase-3 behavior; a tagged instance +// (Module['simInstanceTag'] = 'A', from a Phase 3 multi-instance host page +// like web/mesh.html) looks for "sim-canvas-A" instead, so two instances on +// one page never fight over the same element either. class SimDisplayDriverCanvas : public DisplayDriver { bool _on = false; int _cursor_x = 0, _cursor_y = 0; @@ -180,10 +203,12 @@ public: bool begin() { _on = true; EM_ASM({ - var c = document.getElementById('sim-canvas'); - if (!c) { console.error('[sim] #sim-canvas not found in the host page'); return; } - window.__simCtx = c.getContext('2d'); - window.__simCtx.imageSmoothingEnabled = false; + var tag = (typeof Module !== 'undefined' && Module['simInstanceTag']) ? Module['simInstanceTag'] : ''; + var id = tag ? ('sim-canvas-' + tag) : 'sim-canvas'; + var c = document.getElementById(id); + if (!c) { console.error('[sim] #' + id + ' not found in the host page'); return; } + Module.__simCtx = c.getContext('2d'); + Module.__simCtx.imageSmoothingEnabled = false; }); return true; } @@ -193,9 +218,9 @@ public: void turnOff() override { _on = false; EM_ASM({ - if (!window.__simCtx) return; - window.__simCtx.fillStyle = '#000'; - window.__simCtx.fillRect(0, 0, 128, 64); + if (!Module.__simCtx) return; + Module.__simCtx.fillStyle = '#000'; + Module.__simCtx.fillRect(0, 0, 128, 64); }); } void clear() override { turnOff(); _on = true; } @@ -203,9 +228,9 @@ public: void startFrame(Color bkg = DARK) override { _color = LIGHT; EM_ASM({ - if (!window.__simCtx) return; - window.__simCtx.fillStyle = '#000'; - window.__simCtx.fillRect(0, 0, 128, 64); + if (!Module.__simCtx) return; + Module.__simCtx.fillStyle = '#000'; + Module.__simCtx.fillRect(0, 0, 128, 64); }); } @@ -222,8 +247,8 @@ public: void print(const char* str) override { if (!str) return; EM_ASM({ - if (!window.__simCtx) return; - var ctx = window.__simCtx; + if (!Module.__simCtx) return; + var ctx = Module.__simCtx; ctx.fillStyle = UTF8ToString($3) === 'L' ? '#ffb000' : '#000'; ctx.font = '8px monospace'; ctx.textBaseline = 'top'; @@ -248,16 +273,16 @@ public: void fillRect(int x, int y, int w, int h) override { EM_ASM({ - if (!window.__simCtx) return; - window.__simCtx.fillStyle = UTF8ToString($4) === 'L' ? '#ffb000' : '#000'; - window.__simCtx.fillRect($0, $1, $2, $3); + if (!Module.__simCtx) return; + Module.__simCtx.fillStyle = UTF8ToString($4) === 'L' ? '#ffb000' : '#000'; + Module.__simCtx.fillRect($0, $1, $2, $3); }, x, y, w, h, (_color != DARK) ? "L" : "D"); } void drawRect(int x, int y, int w, int h) override { EM_ASM({ - if (!window.__simCtx) return; - var ctx = window.__simCtx; + if (!Module.__simCtx) return; + var ctx = Module.__simCtx; ctx.strokeStyle = UTF8ToString($4) === 'L' ? '#ffb000' : '#000'; ctx.lineWidth = 1; ctx.strokeRect($0 + 0.5, $1 + 0.5, $2 - 1, $3 - 1); @@ -274,8 +299,8 @@ public: // a plain integer and the JS side indexes HEAPU8 with it directly. void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override { EM_ASM({ - if (!window.__simCtx) return; - var ctx = window.__simCtx; + if (!Module.__simCtx) return; + var ctx = Module.__simCtx; var x0 = $0; var y0 = $1; var w = $2; diff --git a/variants/sim/SimFS.h b/variants/sim/SimFS.h index 54d33bd2..6cb7b948 100644 --- a/variants/sim/SimFS.h +++ b/variants/sim/SimFS.h @@ -283,14 +283,45 @@ public: // main() before sim_idbfs_ready() ever ran -- so this needs its own // try/catch, unlike Emscripten's own test (which never pre-creates the dir // through a second path first). +// Phase 3 addendum: when two module instances mount IDBFS at the exact same +// path (both companion_radio instances always do -- SimFS's own root is a +// hardcoded literal, "./sim_data", chosen once at static-init time long +// before any JS-supplied per-instance config is reachable -- see +// SimInstance.h's header comment for why runtime differentiation had to +// happen at a different layer), they'd naively share ONE real IndexedDB +// database: this SDK's IDBFS (src/lib/libidbfs.js, IDBFS.getDB) keys the +// actual browser-level database by the mount path string ITSELF, with no +// mount-option override for that. Since both instances run in the same +// browser tab (same origin), that's a real collision, not a hypothetical +// one -- confirmed by reading the vendored emsdk's own libidbfs.js during +// this phase. +// +// Fix: monkey-patch IDBFS.getDB (only when the host page opted in via +// Module['simInstanceTag'], e.g. MeshCoreSim({simInstanceTag: 'A'})) so the +// REAL database name gets the tag appended, while the mount PATH stays +// "/sim_data" for every instance -- it has to, since that's the exact path +// SimFS's own fopen()-shaped calls resolve to, and only files actually +// living under the mounted path get persisted at all. This only touches +// this module instance's own IDBFS global (each MeshCoreSim() call has its +// own independent copy, per MODULARIZE), never the vendored emsdk source +// itself. Untagged instances (Module['simInstanceTag'] unset, e.g. the +// original Phase 2 single-instance web/index.html harness) get IDBFS.getDB +// completely unpatched -- byte-for-byte the pre-Phase-3 behavior. inline void sim_fs_mount_idbfs(const char* root) { EM_ASM({ var root = UTF8ToString($0); + var tag = (typeof Module !== 'undefined' && Module['simInstanceTag']) ? Module['simInstanceTag'] : ''; + if (tag) { + var origGetDB = IDBFS.getDB; + IDBFS.getDB = function(name, callback) { + return origGetDB.call(IDBFS, name + '#' + tag, callback); + }; + } try { FS.mkdir(root); } catch (e) { /* already exists -- see comment above */ } FS.mount(IDBFS, { autoPersist: true }, root); FS.syncfs(true, function(err) { if (err) console.error('[sim] IDBFS initial syncfs(true) failed:', err); - else console.log('[sim] IDBFS pull from IndexedDB complete, booting app...'); + else console.log('[sim] IDBFS pull from IndexedDB complete, booting app...' + (tag ? (' (instance ' + tag + ')') : '')); _sim_idbfs_ready(); }); }, root); diff --git a/variants/sim/SimInstance.h b/variants/sim/SimInstance.h new file mode 100644 index 00000000..7f5a0283 --- /dev/null +++ b/variants/sim/SimInstance.h @@ -0,0 +1,56 @@ +#pragma once + +#include + +// Phase 3: per-instance "salt" for the sim build. +// +// The two-companion-radio + one-repeater browser demo loads the SAME +// compiled companion_radio.wasm/js twice (see build_wasm.sh -- there is no +// separate "instance A" vs "instance B" build, by design: MODULARIZE gives +// each MeshCoreSim() call its own independent globals/linear memory, so one +// binary genuinely serves both roles). That means nothing can be baked in +// at compile time to tell the two apart -- any per-instance difference has +// to come from the host page, passed in as a plain JS value on the Module +// config object *before* that instance's factory promise resolves (e.g. +// MeshCoreSim({ simInstanceTag: 'A' })), and read back from C++ only at +// *runtime* (never at static-init time -- global C++ constructors run +// before any JS-supplied Module config is reachable from generated code, +// confirmed by a standalone getenv()-via-Module.ENV experiment during this +// phase that came back null; Module.arguments/argv has the same timing +// problem for the same reason). Every call site below only ever runs from +// setup()-time code (never a global/static initializer), so this is safe. +// +// Used for two unrelated purposes that both care about the two instances +// NOT looking identical to each other: +// 1. SimRNG::begin() / SimRadio::getRngSeed() -- without this, two +// instances started in the same browser tick could produce the exact +// same seed (same wall-clock second, same `rand()` state, and often +// the same `this` pointer value across independent-but-identically- +// laid-out linear memories), which would hand both simulated devices +// the same Ed25519 identity. Mixing in a per-instance tag makes that +// collision a non-issue regardless of whether the timing/address +// coincidence happens. +// 2. SimFS.h's sim_fs_mount_idbfs() namespacing the real IndexedDB +// database per instance so instance A/B don't silently share +// persisted storage on a page reload (see the long comment there). +// +// Native (and any wasm build that never sets simInstanceTag) gets salt 0, +// i.e. exactly the pre-Phase-3 behavior -- single-instance builds are +// unaffected. +#ifdef __EMSCRIPTEN__ +#include + +inline uint32_t sim_instance_salt() { + return (uint32_t)EM_ASM_INT({ + var t = (typeof Module !== 'undefined' && Module['simInstanceTag']) ? Module['simInstanceTag'] : ''; + var h = 2166136261; // FNV-1a, plain JS numbers (>>> 0 keeps it unsigned 32-bit) + for (var i = 0; i < t.length; i++) { + h = h ^ t.charCodeAt(i); + h = (h * 16777619) >>> 0; + } + return h >>> 0; + }); +} +#else +inline uint32_t sim_instance_salt() { return 0; } +#endif diff --git a/variants/sim/SimLocationProvider.h b/variants/sim/SimLocationProvider.h index 56879fea..3afe77b2 100644 --- a/variants/sim/SimLocationProvider.h +++ b/variants/sim/SimLocationProvider.h @@ -1,20 +1,77 @@ #pragma once #include +#include +#include -// LocationProvider stub for the native sim build: no real GPS, always -// reports "no fix". A settable lat/lon (JS-driven) is a Phase-2/3 concern. +// LocationProvider stub for the sim build. Phase 1/2: no real GPS, always +// reports "no fix" (default-constructed state below reproduces that +// exactly). Phase 3: JS-settable per instance via sim_location_set(), +// declared at the bottom of this file. +// +// Note on how this actually reaches the advertised location: companion_radio +// itself never calls a LocationProvider through this class -- it reads +// `sensors.node_lat`/`node_lon` directly (see e.g. +// examples/companion_radio/MyMesh.cpp's createSelfAdvert() call sites), and +// `sensors` (declared in target.h) is a plain base `SensorManager`, not a +// subclass wired to any LocationProvider -- there was never a real GPS +// object plugged in here to begin with (see SensorManager.h: "modify +// node_lat/node_lon directly, if you want to affect Advert location"). So +// sim_location_set() below writes BOTH this class's own state (satisfying +// the LocationProvider interface faithfully, in case future sim code wants +// a real one) AND `sensors.node_lat/node_lon` directly (the field +// companion_radio's advert path actually reads) -- belt and suspenders, +// same underlying values either way. class SimLocationProvider : public LocationProvider { + long _lat_e6 = 0, _lon_e6 = 0, _alt_mm = 0; + bool _valid = false; + public: - long getLatitude() override { return 0; } - long getLongitude() override { return 0; } - long getAltitude() override { return 0; } - long satellitesCount() override { return 0; } - bool isValid() override { return false; } - long getTimestamp() override { return 0; } - void reset() override { } + long getLatitude() override { return _lat_e6; } + long getLongitude() override { return _lon_e6; } + long getAltitude() override { return _alt_mm; } + long satellitesCount() override { return _valid ? 8 : 0; } + bool isValid() override { return _valid; } + long getTimestamp() override { return _valid ? (long)time(NULL) : 0; } + void reset() override { _valid = false; } void begin() override { } void stop() override { } void loop() override { } - bool isEnabled() override { return false; } + bool isEnabled() override { return _valid; } + void sendSentence(const char* sentence) override { } + + void set(float lat_deg, float lon_deg, float alt_m = 0.0f) { + _lat_e6 = (long)(lat_deg * 1000000.0f); + _lon_e6 = (long)(lon_deg * 1000000.0f); + _alt_mm = (long)(alt_m * 1000.0f); + _valid = true; + } }; + +// Single instance, mirroring the pattern of every other Sim* global +// (board/radio_driver/rtc_clock/sensors) declared in target.h/target.cpp -- +// this one isn't wired into target.h/.cpp itself since nothing on the +// SIM_PLATFORM boot path currently constructs a LocationProvider at all +// (see the class comment above), so it lives here instead as a +// self-contained, opt-in addition. +extern SensorManager sensors; // defined in variants/sim/target.cpp +inline SimLocationProvider& sim_location_provider() { + static SimLocationProvider instance; + return instance; +} + +inline void sim_location_apply(float lat_deg, float lon_deg, float alt_m = 0.0f) { + sim_location_provider().set(lat_deg, lon_deg, alt_m); + sensors.node_lat = lat_deg; + sensors.node_lon = lon_deg; + sensors.node_altitude = alt_m; +} + +#ifdef __EMSCRIPTEN__ +#include +// C linkage (no default args, unlike sim_location_apply() above) so JS's +// ccall('sim_location_set', ...) can find it by its exact, unmangled name. +extern "C" inline EMSCRIPTEN_KEEPALIVE void sim_location_set(float lat_deg, float lon_deg, float alt_m) { + sim_location_apply(lat_deg, lon_deg, alt_m); +} +#endif diff --git a/variants/sim/SimRNG.h b/variants/sim/SimRNG.h index 88cd4228..7133e298 100644 --- a/variants/sim/SimRNG.h +++ b/variants/sim/SimRNG.h @@ -3,16 +3,19 @@ #include #include #include +#include "SimInstance.h" // mesh::RNG implementation for the native sim build. Not cryptographically -// strong (rand() under the hood) -- fine for a terminal demo; a real -// two-device-messaging phase (Phase 3 of the sim plan) may want to swap -// this for something seeded from the OS CSPRNG. +// strong (rand() under the hood) -- fine for a terminal demo. Phase 3 mixes +// in sim_instance_salt() so that two module instances of the SAME compiled +// binary (the browser's two companion_radio instances) can't end up with +// the same seed -- see SimInstance.h for why that's a real risk here, not +// a hypothetical one. class SimRNG : public mesh::RNG { public: SimRNG() { } void begin() { - unsigned seed = (unsigned)time(NULL) ^ (unsigned)(uintptr_t)this; + unsigned seed = (unsigned)time(NULL) ^ (unsigned)(uintptr_t)this ^ sim_instance_salt(); srand(seed); } void random(uint8_t* dest, size_t sz) override { diff --git a/variants/sim/SimRadio.h b/variants/sim/SimRadio.h index 3a4b8899..1d29d090 100644 --- a/variants/sim/SimRadio.h +++ b/variants/sim/SimRadio.h @@ -1,32 +1,65 @@ #pragma once #include +#include // MAX_TRANS_UNIT #include #include +#include +#include "SimInstance.h" -// mesh::Radio implementation for the native sim build (Phase 1). Mirrors -// the FakeRadio in test/test_kiss_modem/test_tx_backpressure.cpp in spirit +// mesh::Radio implementation for the native sim build. Mirrors the +// FakeRadio in test/test_kiss_modem/test_tx_backpressure.cpp in spirit // (always-succeed send, no real RF) but is written directly against the // REAL mesh::Radio interface in src/Dispatcher.h -- that test mock is for a // different, out-of-date mocked Mesh.h (see the Phase-1 plan) and must not // be copied. // -// Phase 1 has exactly one logical device, so there is nothing to actually -// exchange packets with: recvRaw() always reports "nothing received", -// startSendRaw()/isSendComplete() always report success instantly. Phase 3 -// of the sim plan (two simulated devices + a repeater) is where this class -// grows a real in-memory "ether" so two instances can actually talk. +// Phase 1/2 had exactly one logical device, so there was nothing to +// actually exchange packets with: recvRaw() always reported "nothing +// received", startSendRaw()/isSendComplete() always reported success +// instantly. Phase 3 adds a real in-memory "ether": a bounded FIFO of whole +// raw packets in each direction, drained/filled by the JS-facing functions +// at the bottom of this file. Dispatcher::checkRecv()/checkSend() only ever +// deal in whole packets (recvRaw() returns 0-or-a-whole-packet in one call; +// startSendRaw() is handed one whole packet to send) -- see +// src/Dispatcher.cpp -- so queueing whole packets (not a byte stream) +// matches that contract exactly, no framing/reassembly needed on either side. class SimRadio : public mesh::Radio { uint32_t n_recv = 0, n_sent = 0, n_recv_errors = 0; bool _power_save = false; bool _rx_boosted_gain = false; int8_t _tx_dbm = 0; + // A "clean, high-quality" fake link by default -- packetScore() below is + // already a flat 100.0, these back getLastRSSI()/getLastSNR() (read by + // Dispatcher for scoring/logging and by MyMesh for the advert path's SNR + // display) with plausible non-zero numbers instead of the base class's + // default 0/0. + float _last_snr = 40.0f; // Packet::_snr stores this * 4 as an int8_t (see Dispatcher.cpp) + float _last_rssi = -60.0f; + + struct QueuedPacket { + uint8_t data[MAX_TRANS_UNIT]; + int len = 0; + }; + static const int QUEUE_CAP = 16; + QueuedPacket _tx_queue[QUEUE_CAP]; + int _tx_head = 0, _tx_count = 0; + QueuedPacket _rx_queue[QUEUE_CAP]; + int _rx_head = 0, _rx_count = 0; + public: void begin() override { } int recvRaw(uint8_t* bytes, int sz) override { - return 0; // never any incoming data yet (Phase 3: real ether) + if (_rx_count == 0) return 0; + QueuedPacket& p = _rx_queue[_rx_head]; + int n = p.len < sz ? p.len : sz; + memcpy(bytes, p.data, n); + _rx_head = (_rx_head + 1) % QUEUE_CAP; + _rx_count--; + n_recv++; + return n; } uint32_t getEstAirtimeFor(int len_bytes) override { @@ -41,7 +74,23 @@ public: bool startSendRaw(const uint8_t* bytes, int len) override { n_sent++; - return true; // instantly "succeeds" -- nothing is actually transmitted yet + if (len > 0) { + int n = len > MAX_TRANS_UNIT ? MAX_TRANS_UNIT : len; + if (_tx_count == QUEUE_CAP) { + // Nobody (no JS ether tick) is draining the outbox -- true for the + // Phase 1/2 single-instance builds, since nothing there ever polls + // sim_radio_poll_tx(). Drop the oldest queued TX rather than growing + // unboundedly; a long-running single-instance sim just silently + // "transmits into the void" exactly as it always did pre-Phase-3. + _tx_head = (_tx_head + 1) % QUEUE_CAP; + _tx_count--; + } + int idx = (_tx_head + _tx_count) % QUEUE_CAP; + memcpy(_tx_queue[idx].data, bytes, n); + _tx_queue[idx].len = n; + _tx_count++; + } + return true; // instantly "succeeds" -- matches every real RadioLib wrapper's fire-and-forget startSendRaw() } bool isSendComplete() override { return true; } @@ -49,6 +98,9 @@ public: bool isInRecvMode() const override { return true; } + float getLastRSSI() const override { return _last_rssi; } + float getLastSNR() const override { return _last_snr; } + // --- Extra methods below (not part of mesh::Radio) ------------------- // MyMesh.cpp/DataStore.cpp/the Settings/Diagnostics UI screens call these // directly on the concrete radio_driver object on every real board, the @@ -58,7 +110,13 @@ public: // static/no-op values. uint32_t getRngSeed() { - return (uint32_t)time(NULL) ^ (uint32_t)(uintptr_t)this ^ (uint32_t)rand(); + // sim_instance_salt(): see SimInstance.h -- without it, two module + // instances of the same compiled binary started in the same browser + // tick could plausibly compute the exact same seed here (same + // time(NULL) second, same `rand()` process state, often the same + // `this` address across independent-but-identically-laid-out linear + // memories) and end up with correlated "random" behaviour. + return (uint32_t)time(NULL) ^ (uint32_t)(uintptr_t)this ^ (uint32_t)rand() ^ sim_instance_salt(); } void getFreqBounds(float& min_mhz, float& max_mhz) const { @@ -89,4 +147,75 @@ public: if (sf < 7) sf = 7; else if (sf > 12) sf = 12; return -7.5f - 2.5f * (float)(sf - 7); } + + // --- Ether hooks (Phase 3) --------------------------------------------- + // Called from the JS-facing extern "C" wrappers below (and reusable from + // a native test harness, since neither depends on Emscripten). These are + // the ONLY way bytes cross between two SimRadio instances -- there is no + // shared C++ state between module instances, on purpose (see the plan's + // "never run two logical devices in one process" decision). + + // Pop one queued outbound packet (FIFO) into `out`, truncated to + // `max_len`. Returns bytes written, or 0 if nothing is queued. A JS ether + // tick calls this once per instance per tick to drain whatever this + // device tried to transmit since the last tick. + int pollTx(uint8_t* out, int max_len) { + if (_tx_count == 0) return 0; + QueuedPacket& p = _tx_queue[_tx_head]; + int n = p.len < max_len ? p.len : max_len; + memcpy(out, p.data, n); + _tx_head = (_tx_head + 1) % QUEUE_CAP; + _tx_count--; + return n; + } + + // Push one raw packet into this device's inbox for recvRaw() to pick up + // on Dispatcher's next checkRecv() poll. Returns false (no-op) if `len` + // is out of range or the inbox is already full (oldest entry dropped to + // make room rather than blocking -- a real radio would just drop an + // over-the-air packet it couldn't buffer either). + bool injectRx(const uint8_t* data, int len) { + if (len <= 0 || len > MAX_TRANS_UNIT) return false; + if (_rx_count == QUEUE_CAP) { + _rx_head = (_rx_head + 1) % QUEUE_CAP; + _rx_count--; + n_recv_errors++; + } + int idx = (_rx_head + _rx_count) % QUEUE_CAP; + memcpy(_rx_queue[idx].data, data, len); + _rx_queue[idx].len = len; + _rx_count++; + return true; + } }; + +#ifdef __EMSCRIPTEN__ +#include + +// JS-facing ether bridge. `radio_driver` is a file-scope global defined in +// variants/sim/target.cpp (one instance per compiled module -- see +// target.h's `extern SimRadio radio_driver;`), so these two functions +// always operate on THIS module instance's own radio, never any other's. +// Because -sMODULARIZE=1 -sEXPORT_NAME=MeshCoreSim gives every +// MeshCoreSim() call its own independent Module/globals/linear memory +// (verified empirically for this phase, not just assumed from the build +// flags -- see the Phase 3 report), calling instanceA.ccall('sim_radio_poll_tx', ...) +// and instanceB.ccall('sim_radio_poll_tx', ...) really do reach two +// separate SimRadio objects with no way to cross-talk except through +// whatever the host page's ether loop explicitly wires together by +// shuttling bytes from one instance's poll_tx into another's inject_rx. +// +// `inline` (not just EMSCRIPTEN_KEEPALIVE'd) because this header is +// included from several .cpp translation units (via target.h) -- without +// it, each would emit its own non-inline definition and the link would +// fail with duplicate symbols, same reasoning as sim_fs_mount_idbfs() in +// SimFS.h. +extern SimRadio radio_driver; + +extern "C" inline EMSCRIPTEN_KEEPALIVE int sim_radio_poll_tx(uint8_t* out_buf, int max_len) { + return radio_driver.pollTx(out_buf, max_len); +} +extern "C" inline EMSCRIPTEN_KEEPALIVE void sim_radio_inject_rx(const uint8_t* data, int len) { + radio_driver.injectRx(data, len); +} +#endif diff --git a/variants/sim/build_wasm.sh b/variants/sim/build_wasm.sh index 2308350b..5331ffc0 100755 --- a/variants/sim/build_wasm.sh +++ b/variants/sim/build_wasm.sh @@ -167,6 +167,18 @@ done # Module.FS.readFile('/sim_data/identity/_main.id') -- to prove IDBFS # persistence with a real file-content comparison across a reload, not just # "the app didn't crash". Not required for the app itself. +# +# EXPORTED_FUNCTIONS=_main,_malloc,_free (Phase 3 addition): every +# EMSCRIPTEN_KEEPALIVE-attributed function (all the sim_*() hooks across +# variants/sim/ and examples/companion_radio/) is exported regardless of +# this list -- that's what the attribute is FOR -- so this only adds +# malloc()/free() themselves, needed by web/mesh.html's JS "ether" to +# allocate a scratch buffer per instance for sim_radio_poll_tx()/ +# sim_radio_inject_rx() (see variants/sim/SimRadio.h). Without this, +# Module._malloc() would abort at runtime with "malloc() called but not +# included in the build" -- confirmed by hitting exactly that during Phase +# 3. Purely additive: nothing Phase 2's web/index.html already does +# (sim_enqueue_key() with a plain number, no buffer marshaling) is affected. "$EMXX" \ "${OBJS[@]}" \ -lidbfs.js \ @@ -176,7 +188,8 @@ done -sEXPORT_NAME=MeshCoreSim \ -sENVIRONMENT=web \ -sEXIT_RUNTIME=0 \ - -sEXPORTED_RUNTIME_METHODS=FS,ccall,cwrap \ + -sEXPORTED_RUNTIME_METHODS=FS,ccall,cwrap,HEAPU8 \ + -sEXPORTED_FUNCTIONS=_main,_malloc,_free \ -o "$OUT_DIR/meshcore_sim.js" echo "" diff --git a/variants/sim/build_wasm_repeater.sh b/variants/sim/build_wasm_repeater.sh new file mode 100755 index 00000000..c0a2f53c --- /dev/null +++ b/variants/sim/build_wasm_repeater.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# Phase 3 (Emscripten) build script for the examples/simple_repeater sim -- +# sibling of build_wasm.sh (the companion_radio one), NOT a modification of +# it, so the existing companion_radio build stays byte-for-byte untouched +# (see the "hard constraints" in the Phase 3 plan: build_wasm.sh must keep +# working exactly as before). +# +# Differences from build_wasm.sh, all deliberate: +# - examples/simple_repeater/{main,MyMesh}.cpp instead of +# examples/companion_radio/{main,MyMesh,DataStore}.cpp + +# ui-new/UITask.cpp -- no UI/display/BLE in this app at all, and +# UITask.cpp specifically does NOT compile cleanly with no display +# defined (real Arduino GPIO constants used unconditionally -- see +# variants/sim/platformio.ini's [env:sim_simple_repeater] comment for +# the same issue hit there), so it's excluded, matching that env. +# - No -DDISPLAY_CLASS -- headless, matching every real hardware +# repeater env's convention when no display is fitted. +# - -DSIM_FS_ROOT=\"/sim_data_repeater\" -- must match the +# "./sim_data_repeater" SimFS root examples/simple_repeater/main.cpp's +# own SIM_PLATFORM branch constructs (see variants/sim/sim_main.cpp), +# kept DIFFERENT from companion_radio's "/sim_data" so a repeater +# instance's IDBFS-backed identity storage can never collide with a +# companion instance's, even on the same page/origin. +# - -sEXPORT_NAME=MeshCoreSimRepeater (not MeshCoreSim) -- the host page +# loads both build/meshcore_sim.js (build/) and +# build/repeater/meshcore_sim_repeater.js side by side; each is its own +# MODULARIZE factory function under a distinct global name so loading +# both on one page can't collide. +# - Output lands under web/build/repeater/, not web/build/, so the two +# builds' .wasm/.js/obj/ trees never share a directory. +# +# Usage: +# variants/sim/build_wasm_repeater.sh # release-ish build (-O2) +# variants/sim/build_wasm_repeater.sh debug # -O0 -g +# +# Same emsdk 6.0.9 requirement as build_wasm.sh -- see that script's header +# comment for the install command if variants/sim/tools/emsdk/ is missing. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +EMSDK_DIR="$SCRIPT_DIR/tools/emsdk" +EMXX="$EMSDK_DIR/upstream/emscripten/em++" +OUT_DIR="$SCRIPT_DIR/web/build/repeater" + +if [ ! -x "$EMXX" ]; then + echo "error: em++ not found at $EMXX" >&2 + echo "Install it first:" >&2 + echo " cd $EMSDK_DIR && python3 ./emsdk.py install 6.0.9 && python3 ./emsdk.py activate 6.0.9" >&2 + exit 1 +fi + +BUILD_MODE="${1:-release}" +if [ "$BUILD_MODE" = "debug" ]; then + OPT_FLAGS=(-O0 -g) +else + OPT_FLAGS=(-O2) +fi + +mkdir -p "$OUT_DIR" +cd "$REPO_ROOT" + +# Same list as variants/sim/platformio.ini's [env:sim_simple_repeater] +# build_src_filter, spelled as real paths. Keep in sync with that file by +# hand (same convention build_wasm.sh already established for the +# companion_radio side). +SRCS=( + src/Dispatcher.cpp + src/Identity.cpp + src/Mesh.cpp + src/Packet.cpp + src/Utils.cpp + src/helpers/AdvertDataHelpers.cpp + src/helpers/BaseChatMesh.cpp + src/helpers/ClientACL.cpp + src/helpers/CommonCLI.cpp + src/helpers/ConfigSerializer.cpp + src/helpers/DeviceDiag.cpp + src/helpers/IdentityStore.cpp + src/helpers/RegionMap.cpp + src/helpers/StaticPoolPacketManager.cpp + src/helpers/TransportKeyStore.cpp + src/helpers/TxtDataHelpers.cpp + lib/ed25519/add_scalar.c + lib/ed25519/fe.c + lib/ed25519/ge.c + lib/ed25519/key_exchange.c + lib/ed25519/keypair.c + lib/ed25519/sc.c + lib/ed25519/seed.c + lib/ed25519/sha512.c + lib/ed25519/sign.c + lib/ed25519/verify.c + variants/sim/sim_main.cpp + variants/sim/target.cpp + variants/sim/thirdparty/crypto/AES128.cpp + variants/sim/thirdparty/crypto/AESCommon.cpp + variants/sim/thirdparty/crypto/BigNumberUtil.cpp + variants/sim/thirdparty/crypto/BlockCipher.cpp + variants/sim/thirdparty/crypto/Crypto.cpp + variants/sim/thirdparty/crypto/Curve25519.cpp + variants/sim/thirdparty/crypto/Ed25519.cpp + variants/sim/thirdparty/crypto/Hash.cpp + variants/sim/thirdparty/crypto/rng_stub.cpp + variants/sim/thirdparty/crypto/SHA256.cpp + variants/sim/thirdparty/crypto/SHA512.cpp + variants/sim/thirdparty/cayennelpp/CayenneLPP.cpp + variants/sim/thirdparty/cayennelpp/CayenneLPPPolyline.cpp + examples/simple_repeater/main.cpp + examples/simple_repeater/MyMesh.cpp +) + +INCLUDES=( + -Ivariants/sim/arduino + -Ivariants/sim + -Ivariants/sim/thirdparty/crypto + -Ivariants/sim/thirdparty/cayennelpp + -Ivariants/sim/thirdparty/arduinojson + -Ilib/ed25519 + -Isrc + -Iexamples/simple_repeater +) + +DEFINES=( + -DSIM_PLATFORM + -DMESH_DEBUG=0 + -DENABLE_ADVERT_ON_BOOT=1 + -DSIM_FS_ROOT="\"/sim_data_repeater\"" +) + +# -funsigned-char: same reasoning as build_wasm.sh (KEY_* codes aren't +# actually used by this app at all -- no UI -- but every other sim TU is +# compiled with this flag and mixing char signedness across TUs that share +# struct layouts/bitfields is asking for trouble, so keep it uniform). +COMMON_FLAGS=(-std=c++17 -funsigned-char "${OPT_FLAGS[@]}" "${DEFINES[@]}" "${INCLUDES[@]}") + +# Mirror each source's own directory under obj/ (see build_wasm.sh's own +# comment on this -- the sha512.o/SHA512.o macOS case-collision reason +# applies identically here since it's the same source tree). +OBJ_DIR="$OUT_DIR/obj" +rm -rf "$OBJ_DIR" +OBJS=() +for src in "${SRCS[@]}"; do + obj="$OBJ_DIR/${src%.*}.o" + mkdir -p "$(dirname "$obj")" + "$EMXX" -c "${COMMON_FLAGS[@]}" "$src" -o "$obj" + OBJS+=("$obj") +done + +"$EMXX" \ + "${OBJS[@]}" \ + -lidbfs.js \ + -sALLOW_MEMORY_GROWTH=1 \ + -sFORCE_FILESYSTEM=1 \ + -sMODULARIZE=1 \ + -sEXPORT_NAME=MeshCoreSimRepeater \ + -sENVIRONMENT=web \ + -sEXIT_RUNTIME=0 \ + -sEXPORTED_RUNTIME_METHODS=FS,ccall,cwrap,HEAPU8 \ + -sEXPORTED_FUNCTIONS=_main,_malloc,_free \ + -o "$OUT_DIR/meshcore_sim_repeater.js" + +echo "" +echo "Built: $OUT_DIR/meshcore_sim_repeater.js (+ .wasm alongside it)" diff --git a/variants/sim/platformio.ini b/variants/sim/platformio.ini index 07ba08eb..580b3d7c 100644 --- a/variants/sim/platformio.ini +++ b/variants/sim/platformio.ini @@ -62,3 +62,79 @@ build_src_filter = +<../variants/sim/thirdparty/cayennelpp/*.cpp> +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> + +; Phase 3: native build of the real examples/simple_repeater app logic +; (MyMesh/no UI) against the same variants/sim/ interfaces as +; [env:sim_companion_radio] above. No DISPLAY_CLASS defined -- a repeater +; doesn't need one (examples/simple_repeater/main.cpp already gates its +; UITask/display use behind #ifdef DISPLAY_CLASS, same as every real +; headless-repeater hardware env does), which also means UITask.cpp/.h +; never need to be ported/compiled for this target at all. +; +; Everything else mirrors [env:sim_companion_radio] deliberately closely +; (same src/*.cpp list, same real hardware envs' own convention of sharing +; one broad src/helpers/*.cpp filter across companion_radio/simple_repeater/ +; room_server -- see e.g. variants/ebyte_eora_s3/platformio.ini's +; [env:Ebyte_EoRa-S3] base + its "_Repeater" env only adding +; "+<../examples/simple_repeater>" on top) -- a few of those .cpp files +; (BaseChatMesh.cpp, ConfigSerializer.cpp, DeviceDiag.cpp) aren't actually +; reachable from simple_repeater's own code, but compiling them in unused is +; harmless and keeps this env a straightforward diff against the one above +; rather than a hand-pruned, easy-to-drift-out-of-sync list. +[env:sim_simple_repeater] +platform = native +build_type = debug +build_flags = + -std=c++17 + -g + -funsigned-char + -D SIM_PLATFORM + -D MESH_DEBUG=0 + ; Real hardware repeater envs get this from [arduino_base] (which this + ; from-scratch platform=native env deliberately doesn't extend -- see the + ; header comment on [env:sim_companion_radio] above); set it explicitly so + ; the repeater actually broadcasts its own identity on boot, same as a + ; real one would. + -D ENABLE_ADVERT_ON_BOOT=1 + -I variants/sim/arduino + -I variants/sim + -I variants/sim/thirdparty/crypto + -I variants/sim/thirdparty/cayennelpp + -I variants/sim/thirdparty/arduinojson + -I lib/ed25519 + -I src + -I examples/simple_repeater +build_src_filter = + -<*> + +<../src/Dispatcher.cpp> + +<../src/Identity.cpp> + +<../src/Mesh.cpp> + +<../src/Packet.cpp> + +<../src/Utils.cpp> + +<../src/helpers/AdvertDataHelpers.cpp> + +<../src/helpers/BaseChatMesh.cpp> + +<../src/helpers/ClientACL.cpp> + +<../src/helpers/CommonCLI.cpp> + +<../src/helpers/ConfigSerializer.cpp> + +<../src/helpers/DeviceDiag.cpp> + +<../src/helpers/IdentityStore.cpp> + +<../src/helpers/RegionMap.cpp> + +<../src/helpers/StaticPoolPacketManager.cpp> + +<../src/helpers/TransportKeyStore.cpp> + +<../src/helpers/TxtDataHelpers.cpp> + +<../lib/ed25519/*.c> + +<../variants/sim/*.cpp> + +<../variants/sim/thirdparty/crypto/*.cpp> + +<../variants/sim/thirdparty/cayennelpp/*.cpp> + +<../examples/simple_repeater/*.cpp> + ; UITask.cpp uses real Arduino GPIO constants (HIGH/LOW/digitalRead(), + ; gated on real #if PIN_USER_BTN/UI_HAS_JOYSTICK defines this env never + ; sets) directly at file scope in its button-polling code, unconditionally + ; -- unlike companion_radio's ui-new/UITask.cpp, it isn't written to + ; compile cleanly with no display at all. Since DISPLAY_CLASS is + ; deliberately undefined for this headless repeater env (main.cpp's own + ; `#ifdef DISPLAY_CLASS` already skips constructing a UITask instance + ; entirely), this translation unit is simply never needed -- exclude it + ; rather than porting GPIO stubs into variants/sim/arduino/Arduino.h for + ; code that would never run. + -<../examples/simple_repeater/UITask.cpp> diff --git a/variants/sim/sim_main.cpp b/variants/sim/sim_main.cpp index e9274b04..96d43f43 100644 --- a/variants/sim/sim_main.cpp +++ b/variants/sim/sim_main.cpp @@ -48,13 +48,23 @@ extern "C" EMSCRIPTEN_KEEPALIVE void sim_idbfs_ready() { emscripten_set_main_loop(sim_main_loop_tick, 0, 1); } +// The app-level SimFS root to mount IDBFS at -- must match whatever +// relative "./sim_data..." path that app's own main.cpp constructs its +// SimFS with (see the long comment on sim_fs_mount_idbfs() in SimFS.h for +// why those are the same filesystem node under Emscripten's default cwd, +// "/"). Defaults to companion_radio's root, unchanged from Phase 2 -- +// Phase 3's simple_repeater build (variants/sim/build_wasm_repeater.sh) +// overrides this via -DSIM_FS_ROOT so its identity storage lands under a +// DIFFERENT IDBFS-backed root than a companion instance's, same reasoning +// as examples/simple_repeater/main.cpp's SIM_PLATFORM branch using +// "./sim_data_repeater" instead of "./sim_data" for its SimFS. +#ifndef SIM_FS_ROOT +#define SIM_FS_ROOT "/sim_data" +#endif + int main() { - printf("MeshCore sim (wasm) starting -- mounting IDBFS at /sim_data...\n"); - // "/sim_data" must match the relative "./sim_data" DataStore store(sim_fs, - // ...) in examples/companion_radio/main.cpp resolves to -- see the long - // comment on sim_fs_mount_idbfs() in SimFS.h for why those are the same - // filesystem node under Emscripten's default cwd ("/"). - sim_fs_mount_idbfs("/sim_data"); + printf("MeshCore sim (wasm) starting -- mounting IDBFS at " SIM_FS_ROOT "...\n"); + sim_fs_mount_idbfs(SIM_FS_ROOT); // Keep the runtime alive after main() returns instead of tearing it down // (the default for a `main()` that returns under Emscripten) -- the real // boot sequence hasn't happened yet, it's waiting on sim_idbfs_ready() diff --git a/variants/sim/target.h b/variants/sim/target.h index 7300f40e..819721a6 100644 --- a/variants/sim/target.h +++ b/variants/sim/target.h @@ -11,6 +11,12 @@ #include "SimMainBoard.h" #include "SimRTCClock.h" #include +// Included here (rather than only where it's used) so its JS-facing +// sim_location_set() EMSCRIPTEN_KEEPALIVE export actually gets compiled +// into every SIM_PLATFORM target that includes target.h (companion_radio +// AND simple_repeater) -- an inline function nobody #includes never gets +// emitted at all, KEEPALIVE or not. +#include "SimLocationProvider.h" #ifdef DISPLAY_CLASS #include "SimDisplayDriver.h" diff --git a/variants/sim/web/mesh.html b/variants/sim/web/mesh.html new file mode 100644 index 00000000..e42a4fa3 --- /dev/null +++ b/variants/sim/web/mesh.html @@ -0,0 +1,375 @@ + + + + + +MeshCore sim -- two devices + repeater (Phase 3) + + + +

MeshCore sim -- two devices + repeater, bridged by a JS "ether"

+
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.
+ +
+
+

Instance A (companion_radio)

+ +
loading...
+
+ + + + + + +
+
+ +
+

Instance R (simple_repeater)

+
📡
+
relayed: 0
+
loading...
+
+ +
+

Instance B (companion_radio)

+ +
loading...
+
+ + + + + + +
+
+
+ +
+
+ + + ether: stopped +
+
+ + + + + + + +
+
+ +
+ + + + + +