mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-09-14 15:16:40 +00:00
fix(sim): text-width/render bugs, splash version, wasm-fetch error, battery lag
Code-review pass over the buzzer/sim commits turned up several real bugs,
plus two issues found afterward from manual browser testing:
Rendering (SimDisplayDriverCanvas, variants/sim/SimDisplayDriver.h + target.cpp):
- getTextWidth() measured UTF-8 BYTES (strlen()*6), not codepoints. Since
b067e95b stopped stripping accents, any accented string now measures
double its real width -- mis-centred titles, premature ellipsis/marquee,
badges pushed off-screen. Now uses the real MiscFixedRenderer measurement
(miscFixedTextWidth()), same as SH1106Display/SSD1306Display.
- Added the matching getCodepointWidth() override (O(1) single-glyph
advance), same pattern as SSD1306Display.
- isSingleFont() was left at the base class's `false`, though this backend
only ever renders MiscFixed -- UITask.cpp's status-bar indicator height
keys off this (`lh-2` vs `lh`), so the sim drew it 2px taller than a real
board.
- print() blitted the full 128x64 canvas on every call (dozens per frame,
60fps) -- now tracks a dirty bounding box and only clears/blits the
region actually touched.
Web Audio (buzzer bridge, index.html + mesh.html):
- No AudioContext.resume() -- a context created (or later suspended) in the
'suspended' state (Safari/Firefox, or any browser backgrounding the tab)
stayed silent forever. Now resumed on every gesture.
- linearRampToValueAtTime with no anchoring setValueAtTime interpolates
from the LAST scheduled event, not "now" -- so the anti-click ramps could
effectively snap instead of fading. Fixed with cancelScheduledValues +
setValueAtTime(current) before each ramp.
- mesh.html: a gesture only armed the clicked instance's audio. Click A,
send A->B, and B (the one actually meant to beep on receipt) stayed
silent. Now any gesture arms both A and B.
- RTTTL rests (freq=0, still "playing") now explicitly hold pitch and drop
gain instead of it happening to work by coincidence.
Misc: sim_test_get_num_contacts() was missing the g_sim_ready gate every
other sim_test_* hook has, so it could return a bogus negative count before
setup() finishes seeding num_contacts.
Splash screen missing "Solo <version>" bar: variants/sim never defined
FIRMWARE_SOLO_BUILD (every real Solo board does), so SplashScreen silently
skipped that whole line -- the sim looked like a plain non-Solo companion
build. Added -D FIRMWARE_SOLO_BUILD=1 to platformio.ini and build_wasm.sh.
Verified on a real canvas screenshot: "MESHCORE 1.17.1 / 19 Aug 2026 /
Solo v1.27".
Wasm-fetch error message: "failed to start: RuntimeError: Aborted(both
async and sync fetching of the wasm failed)" is Emscripten's own opaque
message for the single most common real cause -- the page opened via
file://...index.html instead of served over http(s) (fetch() on a local
file is blocked by CORS in both Chrome and Safari, confirmed by reproducing
the exact same error/stack via file://). Both harnesses now detect
location.protocol === 'file:' and show an actionable message with the
one-line fix instead of the raw stack trace.
Battery-set latency: SimMainBoard's battery value is an exact, instantaneous
JS-set integer (see sim_battery_set_mv()), but UITask's battery-check code
polls it every 8s and runs it through an EMA (alpha=0.2) meant to smooth a
REAL board's noisy ADC -- so a value typed into the demo UI could take tens
of seconds to visibly settle. SIM_PLATFORM now checks every 250ms and skips
the EMA (nothing to smooth), since the reading is already clean. Measured
on real canvas pixels: indicator update now lands within one screen-refresh
cycle instead of up to 8s+.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 <version>" bar never draws.
|
||||
-DFIRMWARE_SOLO_BUILD=1
|
||||
-DMAX_CONTACTS=100
|
||||
-DMAX_GROUP_CHANNELS=8
|
||||
)
|
||||
|
||||
@@ -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 <version>" 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
|
||||
|
||||
+45
-15
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user