diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 57772f66..d3ab73bf 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -402,6 +402,10 @@ extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_send_msg_to_first_contact(const cha // 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() { + // Same g_sim_ready gate as every other hook here: before setup() runs, + // BaseChatMesh::num_contacts hasn't been seeded to MAX_ANON_CONTACTS yet, + // so getNumContacts() would report a negative count. + if (!g_sim_ready) return 0; return the_mesh.getNumContacts(); } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 16b1a305..fc0af20f 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2456,7 +2456,8 @@ void UITask::loop() { // above, in the #if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) // block) -- this stdin-poll branch was only ever meant for Phase 1's // native terminal target. - // Native terminal input: stdin is put into raw/non-canonical mode by + // + // stdin is put into raw/non-canonical mode by // variants/sim/sim_main.cpp's main(), so keys arrive here one at a time // with no Enter-to-submit line buffering. Non-blocking select() on fd 0 // (VMIN=0/VTIME=0 on the fd itself would also work, but select() keeps @@ -2752,8 +2753,20 @@ void UITask::loop() { if ((int32_t)(millis() - next_batt_chck) >= 0) { uint16_t raw = AbstractUITask::getBattMilliVolts(); if (raw > 0) { +#ifdef SIM_PLATFORM + // SimMainBoard::getBattMilliVolts() returns exactly whatever value the + // host page's JS last set (see sim_battery_set_mv() in + // variants/sim/SimMainBoard.h) -- a clean, instantaneous number, not a + // noisy ADC reading. Real hardware needs the EMA below to smooth a + // voltage divider's jitter under load; applying that same filter here + // just makes a value typed into the demo UI visibly crawl toward its + // target over several 8s samples, which reads as the whole sim being + // laggy for no benefit the sim actually needs. + _batt_mv = raw; +#else // EMA filter: alpha=0.2 (80% old, 20% new) — smooths ADC noise from uneven load _batt_mv = (_batt_mv == 0) ? raw : (uint16_t)((_batt_mv * 4u + raw) / 5u); +#endif } uint16_t low_mv = _node_prefs ? _node_prefs->low_batt_mv : 0; // Don't shut down while on external power (charging) — avoids a shutdown loop. @@ -2775,7 +2788,16 @@ void UITask::loop() { } shutdown(); } +#ifdef SIM_PLATFORM + // A real ADC read has a real cost, worth spacing out 8s apart; the sim's + // "read" is just returning a JS-set integer, so there's no reason to sit + // on a stale value for up to 8s after Set was clicked. 250ms keeps this + // a poll (not a push wired to the input's own event, which would need + // its own plumbing) while feeling immediate. + next_batt_chck = millis() + 250; +#else next_batt_chck = millis() + 8000; +#endif } // GPS duty-cycle hold — tells the sensor manager whether *anything* needs diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 762f23e5..2957bba4 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -230,7 +230,10 @@ public: #ifdef PIN_BUZZER // Lets a host page poll the buzzer's current state every frame to drive a // Web Audio oscillator (see sim_buzzer_is_playing()/sim_buzzer_freq_hz() - // in main.cpp) -- `buzzer` itself is a private member, so a free function + // in UITask.cpp, next to sim_enqueue_key() -- they live there, not in + // main.cpp, because the `g_sim_ui_task_for_js` pointer they dispatch + // through is file-static to that translation unit) -- `buzzer` itself is a + // private member, so a free function // outside this class needs these to reach it, same reason injectSimKey() // above is public. // Not const: genericBuzzer::isPlaying() itself isn't const-qualified (its diff --git a/variants/sim/SimDisplayDriver.h b/variants/sim/SimDisplayDriver.h index 1d92fb77..792746ac 100644 --- a/variants/sim/SimDisplayDriver.h +++ b/variants/sim/SimDisplayDriver.h @@ -250,6 +250,14 @@ public: int getCharWidth() const override { return 6 * _text_sz; } int getLineHeight() const override { return 9 * _text_sz; } + // Misc-fixed 6x9 is this backend's one and only font, exactly like a real + // SSD1306Display/SH1106Display built with OLED_MISC_FIXED_FONT=1 (both + // return true here too). UITask's status-bar indicator height keys off + // this (`ind_h = display.isSingleFont() ? lh - 2 : lh`, UITask.cpp) -- left + // at the base class's false, the sim drew that row 2px taller than the + // real board it's mirroring. + bool isSingleFont() const override { return true; } + // Amber-on-black palette (a common OLED look) for LIGHT/DARK; the other // Color enumerators (RED/GREEN/BLUE/YELLOW/ORANGE) aren't used on the real // monochrome OLED boards this sim mirrors either (DisplayDriver.h's own @@ -332,9 +340,20 @@ public: }, x, y, w, h, bits, (_color != DARK) ? "L" : "D"); } - uint16_t getTextWidth(const char* str) override { - return str ? (uint16_t)(strlen(str) * getCharWidth()) : 0; - } + // Measured off the real MiscFixed glyph table, per CODEPOINT -- not + // strlen() * 6, which counts UTF-8 BYTES. Since translateUTF8ToBlocks() + // above stopped transliterating accents away, strings reaching here really + // do carry multi-byte sequences, and a byte count made every accented + // character measure double: mis-centred titles, text ellipsized/marquee'd + // far too early, right-aligned badges pushed off. Same implementation the + // real single-font OLED drivers use (SH1106Display::getTextWidth() -> + // miscFixedTextWidth()). Defined out-of-line in target.cpp for the same + // reason print() is -- only that TU may include MiscFixedRenderer.h. + uint16_t getTextWidth(const char* str) override; + // O(1) single-glyph advance, mirroring SSD1306Display::getCodepointWidth() + // -> glyphXAdvance(). The base class would otherwise re-encode the + // codepoint and call getTextWidth() on it. + uint16_t getCodepointWidth(uint32_t cp) override; // Every draw call above already lands directly on the visible canvas // (see the class comment) -- nothing left to flush. diff --git a/variants/sim/build_wasm.sh b/variants/sim/build_wasm.sh index e8391702..8662b5ad 100755 --- a/variants/sim/build_wasm.sh +++ b/variants/sim/build_wasm.sh @@ -131,6 +131,9 @@ DEFINES=( # Dummy sentinel (no real pin) -- see platformio.ini's own comment on the # native env's identical flag. -DPIN_BUZZER=0 + # See platformio.ini's native env for the same flag: without it the splash + # screen's "Solo " bar never draws. + -DFIRMWARE_SOLO_BUILD=1 -DMAX_CONTACTS=100 -DMAX_GROUP_CHANNELS=8 ) diff --git a/variants/sim/platformio.ini b/variants/sim/platformio.ini index fb1e917f..58b1f2fe 100644 --- a/variants/sim/platformio.ini +++ b/variants/sim/platformio.ini @@ -33,6 +33,12 @@ build_flags = ; way). genericBuzzer's own #ifdef SIM_PLATFORM branch (src/helpers/ui/ ; buzzer.cpp) never touches a real pin, so the value itself is unused. -D PIN_BUZZER=0 + ; Every real board running this same ui-new/ tree builds as a Solo config + ; (see e.g. solo/heltec_v3/platformio.ini) -- without this, SplashScreen + ; (UITask.cpp) skips the whole "Solo " bar under the big MeshCore + ; version digits, and the splash silently looks like a plain non-Solo + ; companion build instead of what it actually is. + -D FIRMWARE_SOLO_BUILD=1 -D MAX_CONTACTS=100 -D MAX_GROUP_CHANNELS=8 -I variants/sim/arduino diff --git a/variants/sim/target.cpp b/variants/sim/target.cpp index 4a7193bf..38f0dbf8 100644 --- a/variants/sim/target.cpp +++ b/variants/sim/target.cpp @@ -83,17 +83,44 @@ mesh::LocalIdentity radio_new_identity() { class SimGfxCanvas : public Adafruit_GFX { public: uint8_t px[128 * 64]; - SimGfxCanvas() : Adafruit_GFX(128, 64) { memset(px, 0, sizeof(px)); } + // Inclusive bounding box of everything plotted since the last resetDirty(). + // Without it, print() blitted all 8192 cells on every single call -- and a + // busy screen makes dozens of print() calls per frame, at 60fps, so the + // JS-side pixel loop dominated the whole frame budget for what is usually + // one short row of text. + int dx0, dy0, dx1, dy1; + SimGfxCanvas() : Adafruit_GFX(128, 64) { memset(px, 0, sizeof(px)); resetDirty(); } + void resetDirty() { dx0 = 128; dy0 = 64; dx1 = -1; dy1 = -1; } + bool isDirty() const { return dx1 >= dx0 && dy1 >= dy0; } void drawPixel(int16_t x, int16_t y, uint16_t color) override { if ((unsigned)x >= 128 || (unsigned)y >= 64) return; px[y * 128 + x] = (color != 0) ? 1 : 0; + if (x < dx0) dx0 = x; + if (x > dx1) dx1 = x; + if (y < dy0) dy0 = y; + if (y > dy1) dy1 = y; } }; +// Real MiscFixed metrics, same source of truth the glyph plotting above +// uses -- see SimDisplayDriver.h for why these are here and not inline. +uint16_t SimDisplayDriverCanvas::getTextWidth(const char* str) { + return str ? miscFixedTextWidth(str, _text_sz) : 0; +} + +uint16_t SimDisplayDriverCanvas::getCodepointWidth(uint32_t cp) { + return miscFixedXAdvance(cp, _text_sz); +} + void SimDisplayDriverCanvas::print(const char* str) { if (!str) return; static SimGfxCanvas gfx; - memset(gfx.px, 0, sizeof(gfx.px)); + // Only the previously-dirtied region needs clearing, not all 8 KB. + if (gfx.isDirty()) { + for (int y = gfx.dy0; y <= gfx.dy1; y++) + memset(&gfx.px[y * 128 + gfx.dx0], 0, (size_t)(gfx.dx1 - gfx.dx0 + 1)); + } + gfx.resetDirty(); gfx.setCursor(_cursor_x, _cursor_y); // color arg is just our own internal "lit" marker (1) -- the real on-screen // amber/black choice is applied once at blit time below, from _color, same @@ -105,20 +132,23 @@ void SimDisplayDriverCanvas::print(const char* str) { // startFrame() already blanks the whole canvas to black every frame, so // only the lit pixels need drawing here -- unlit buffer cells are already - // correct background. One EM_ASM call blits the whole 128x64 buffer - // (reading it directly out of wasm memory, same pattern as drawXbm() - // below) rather than one call per glyph pixel. - EM_ASM({ - if (!Module.__simCtx) return; - var ctx = Module.__simCtx; - var buf = $0; - ctx.fillStyle = UTF8ToString($1) === 'L' ? '#ffb000' : '#000'; - for (var y = 0; y < 64; y++) { - for (var x = 0; x < 128; x++) { - if (HEAPU8[buf + y * 128 + x]) ctx.fillRect(x, y, 1, 1); + // correct background. One EM_ASM call blits the buffer (reading it directly + // out of wasm memory, same pattern as drawXbm() below) rather than one call + // per glyph pixel, and only over the rows/columns this string actually + // touched rather than the full 128x64. + if (gfx.isDirty()) { + EM_ASM({ + if (!Module.__simCtx) return; + var ctx = Module.__simCtx; + var buf = $0; + ctx.fillStyle = UTF8ToString($5) === 'L' ? '#ffb000' : '#000'; + for (var y = $2; y <= $4; y++) { + for (var x = $1; x <= $3; x++) { + if (HEAPU8[buf + y * 128 + x]) ctx.fillRect(x, y, 1, 1); + } } - } - }, gfx.px, (_color != DARK) ? "L" : "D"); + }, gfx.px, gfx.dx0, gfx.dy0, gfx.dx1, gfx.dy1, (_color != DARK) ? "L" : "D"); + } // Same external contract as every other DisplayDriver backend here (see // SimDisplayDriver's own ASCII print()): only _cursor_x advances by the diff --git a/variants/sim/web/index.html b/variants/sim/web/index.html index 2a42fdf2..6dc315b6 100644 --- a/variants/sim/web/index.html +++ b/variants/sim/web/index.html @@ -189,6 +189,26 @@ console[level] = (...args) => { orig(...args); log('[js] ' + args.join(' ')); }; } + // Emscripten's own "both async and sync fetching of the wasm failed" is + // near-unreadable to anyone who hasn't debugged this exact runtime + // before. Its single most common real-world cause: the page was opened + // directly (double-clicked, file://...index.html) instead of served + // over http -- `fetch()` on a local file is blocked by CORS in both + // Chrome and Safari, with no server-side fix possible (it's the browser + // refusing the request, not a missing/misnamed file). Detect that + // specific case and say so plainly; anything else, show the raw error + // so it's at least visible instead of silently swallowed. + function reportBootFailure(err) { + console.error(err); + if (location.protocol === 'file:') { + statusEl.textContent = 'error: this page must be served over http(s), not opened as a file:// URL ' + + '(the browser blocks the wasm fetch either way). Run e.g. ' + + '"cd variants/sim/web && python3 -m http.server 8080" and open http://localhost:8080/index.html instead.'; + } else { + statusEl.textContent = 'error: failed to start the wasm module -- ' + err + ' (see devtools console for detail).'; + } + } + if (typeof MeshCoreSim !== 'function') { statusEl.textContent = 'error: build/meshcore_sim.js missing or failed to load -- run variants/sim/build_wasm.sh first.'; } else { @@ -226,8 +246,17 @@ // unavoidable limitation any embedded page with boot sound has. let audioCtx = null, buzzOsc = null, buzzGain = null, buzzWasPlaying = false; function ensureAudio() { - if (audioCtx) return; + // Not just "create once": a context can also be created in, or later + // fall back to, the 'suspended' state (Safari/Firefox start it + // suspended even inside a gesture handler; any browser may suspend + // it again when the tab is backgrounded). Without resuming it here + // on every gesture, the oscillator keeps running silently forever. + if (audioCtx) { + if (audioCtx.state === 'suspended') audioCtx.resume(); + return; + } audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + if (audioCtx.state === 'suspended') audioCtx.resume(); buzzOsc = audioCtx.createOscillator(); buzzGain = audioCtx.createGain(); buzzGain.gain.value = 0; @@ -245,6 +274,17 @@ // silence regardless of what's "playing" (mutes without touching the // sim's own note-advance timing). const BUZZER_GAIN = [0, 0.02, 0.05, 0.09, 0.15]; + // A linear ramp interpolates from the PREVIOUS automation event, not + // from "now" -- with no event anchoring the curve's start, a ramp + // scheduled long after the last one effectively snaps to its target + // instead of easing over the requested few ms, which is exactly the + // click the ramp was meant to avoid. Anchoring at the current value + // first makes the short fade real. + function rampGain(target, seconds, now) { + buzzGain.gain.cancelScheduledValues(now); + buzzGain.gain.setValueAtTime(buzzGain.gain.value, now); + buzzGain.gain.linearRampToValueAtTime(target, now + seconds); + } function pollBuzzer() { if (!audioCtx || !Module || !Module._sim_buzzer_is_playing) return; const playing = Module.ccall('sim_buzzer_is_playing', 'number', [], []) === 1; @@ -252,10 +292,12 @@ if (playing) { const freq = Module.ccall('sim_buzzer_freq_hz', 'number', [], []); const vol = Module.ccall('sim_buzzer_get_volume', 'number', [], []); - buzzOsc.frequency.setValueAtTime(freq > 0 ? freq : 440, now); - buzzGain.gain.linearRampToValueAtTime(freq > 0 ? BUZZER_GAIN[vol] || 0 : 0, now + 0.005); + // freq 0 is a real RTTTL rest ('p') -- still "playing", just + // silent, so hold the last pitch and drop the gain instead. + if (freq > 0) buzzOsc.frequency.setValueAtTime(freq, now); + rampGain(freq > 0 ? (BUZZER_GAIN[vol] || 0) : 0, 0.005, now); } else if (buzzWasPlaying) { - buzzGain.gain.linearRampToValueAtTime(0, now + 0.01); + rampGain(0, 0.01, now); } buzzWasPlaying = playing; } @@ -414,10 +456,7 @@ const result = Module.ccall('sim_test_login_first_repeater', 'number', ['string'], [pw]); log(`login request result=${result} (1=sent,0=send failed,-1=no contact yet; actual accept/reject is async -- check the Admin screen)`); }); - }).catch((err) => { - statusEl.textContent = 'failed to start: ' + err; - console.error(err); - }); + }).catch(reportBootFailure); } diff --git a/variants/sim/web/mesh.html b/variants/sim/web/mesh.html index 007648da..71b77ce3 100644 --- a/variants/sim/web/mesh.html +++ b/variants/sim/web/mesh.html @@ -429,9 +429,23 @@ // -- currentInst(key) below always resolves the live one, so a reset // instance's sound keeps working against the same AudioContext. const buzzAudio = {}; + // Any gesture anywhere on the page arms BOTH companion instances, not + // just the one that was clicked: the whole point of this demo is that A + // sends and B *beeps on receipt*, so waiting for a separate gesture on + // B's own panel would leave the receiving side silent in exactly the + // scenario the page exists to show. + function ensureAudioAll() { ensureAudio('A'); ensureAudio('B'); } function ensureAudio(key) { - if (key === 'R' || buzzAudio[key]) return; + if (key === 'R') return; + // Also resume, not just create: a context can start suspended + // (Safari/Firefox) or be suspended again when the tab is + // backgrounded, and would then stay silent forever. See index.html. + if (buzzAudio[key]) { + if (buzzAudio[key].ctx.state === 'suspended') buzzAudio[key].ctx.resume(); + return; + } const ctx = new (window.AudioContext || window.webkitAudioContext)(); + if (ctx.state === 'suspended') ctx.resume(); const osc = ctx.createOscillator(); const gain = ctx.createGain(); gain.gain.value = 0; @@ -442,6 +456,14 @@ buzzAudio[key] = { ctx, osc, gain, wasPlaying: false }; } const BUZZER_GAIN = [0, 0.02, 0.05, 0.09, 0.15]; + // See index.html's rampGain(): a linear ramp interpolates from the last + // scheduled event, so it needs an anchor at the current value to + // actually fade rather than snap. + function rampGain(b, target, seconds, now) { + b.gain.gain.cancelScheduledValues(now); + b.gain.gain.setValueAtTime(b.gain.gain.value, now); + b.gain.gain.linearRampToValueAtTime(target, now + seconds); + } function pollBuzzer(key) { const b = buzzAudio[key]; const inst = currentInst(key); @@ -451,10 +473,11 @@ if (playing) { const freq = inst.mod.ccall('sim_buzzer_freq_hz', 'number', [], []); const vol = inst.mod.ccall('sim_buzzer_get_volume', 'number', [], []); - b.osc.frequency.setValueAtTime(freq > 0 ? freq : 440, now); - b.gain.gain.linearRampToValueAtTime(freq > 0 ? (BUZZER_GAIN[vol] || 0) : 0, now + 0.005); + // freq 0 is a real RTTTL rest ('p'): still playing, just silent. + if (freq > 0) b.osc.frequency.setValueAtTime(freq, now); + rampGain(b, freq > 0 ? (BUZZER_GAIN[vol] || 0) : 0, 0.005, now); } else if (b.wasPlaying) { - b.gain.gain.linearRampToValueAtTime(0, now + 0.01); + rampGain(b, 0, 0.01, now); } b.wasPlaying = playing; } @@ -470,7 +493,7 @@ let timer = null, longFired = false; btn.addEventListener('pointerdown', (e) => { e.preventDefault(); - ensureAudio(key); + ensureAudioAll(); longFired = false; timer = setTimeout(() => { longFired = true; sendKeyLongPress(currentInst(key), code); }, LONG_PRESS_MS); }); @@ -650,7 +673,7 @@ const code = resolveKey(e); if (code === null) return; e.preventDefault(); - ensureAudio(focusedKey); + ensureAudioAll(); if (keyHold.has(e.key)) return; const state = { longFired: false }; state.timer = setTimeout(() => { state.longFired = true; sendKeyLongPress(currentInst(focusedKey), code); }, LONG_PRESS_MS); @@ -672,8 +695,22 @@ } main().catch((err) => { - log('FATAL: ' + err); console.error(err); + // Emscripten's "both async and sync fetching of the wasm failed" is + // near-unreadable on its own -- its single most common real-world + // cause is opening this page directly (file://...mesh.html) instead + // of serving it over http(s): `fetch()` on a local file is blocked by + // CORS in both Chrome and Safari, with no server-side fix (it's the + // browser refusing the request, not a missing/misnamed file). See + // index.html's matching reportBootFailure() for the single-instance + // harness's version of this same check. + if (location.protocol === 'file:') { + log('FATAL: this page must be served over http(s), not opened as a file:// URL ' + + '(the browser blocks the wasm fetch either way). Run e.g. ' + + '"cd variants/sim/web && python3 -m http.server 8080" and open http://localhost:8080/mesh.html instead.'); + } else { + log('FATAL: ' + err); + } });