mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-09-14 15:16:40 +00:00
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>
363 lines
15 KiB
C++
363 lines
15 KiB
C++
#pragma once
|
|
|
|
#include <helpers/ui/DisplayDriver.h>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
|
|
#ifdef __EMSCRIPTEN__
|
|
#include <emscripten.h>
|
|
#endif
|
|
|
|
// DisplayDriver implementation for the native sim build. Shaped like
|
|
// src/helpers/ui/NullDisplayDriver.h (same pure-virtual overrides -- start
|
|
// from that file, per the Phase-1 plan) but instead of no-ops, maintains an
|
|
// in-memory framebuffer and prints it to stdout as ASCII/block-art on
|
|
// endFrame(), so the real UITask/menu system's actual draw calls are
|
|
// visible in a terminal.
|
|
//
|
|
// Logical canvas is 128x64 (matches NullDisplayDriver / a typical SSD1306
|
|
// OLED, so layout math in the real screens behaves exactly as on that
|
|
// hardware). For terminal rendering it's downsampled onto a coarser
|
|
// CELL_W x CELL_H-pixel grid:
|
|
// - fillRect()/drawRect()/drawXbm() mark the cells they cover as "filled"
|
|
// (drawXbm -- icons -- has no real bitmap to rasterize in ASCII, so it's
|
|
// approximated as a solid block, same as a fillRect over that area).
|
|
// - print() does NOT rasterize a bitmap font -- it places the real
|
|
// characters of the real string into the grid at the (approximate)
|
|
// cursor cell, which is what actually makes the output legible. Real
|
|
// text always wins over a "filled" block in the same cell.
|
|
class SimDisplayDriver : public DisplayDriver {
|
|
static const int CELL_W = 2; // pixels per terminal column
|
|
static const int CELL_H = 2; // pixels per terminal row
|
|
static const int COLS = 128 / CELL_W; // 64
|
|
static const int ROWS = 64 / CELL_H; // 32
|
|
|
|
bool _on = false;
|
|
int _cursor_x = 0, _cursor_y = 0;
|
|
Color _color = LIGHT;
|
|
bool _filled[ROWS][COLS];
|
|
char _text[ROWS][COLS]; // 0 = no character placed
|
|
bool _dirty = false;
|
|
int _frame_no = 0;
|
|
|
|
void cellOf(int px, int py, int& cx, int& cy) const {
|
|
cx = px / CELL_W; cy = py / CELL_H;
|
|
}
|
|
|
|
public:
|
|
SimDisplayDriver() : DisplayDriver(128, 64) { clearBuffers(); }
|
|
|
|
bool begin() { _on = true; return true; }
|
|
|
|
void clearBuffers() {
|
|
memset(_filled, 0, sizeof(_filled));
|
|
memset(_text, 0, sizeof(_text));
|
|
}
|
|
|
|
bool isOn() override { return _on; }
|
|
void turnOn() override { _on = true; }
|
|
void turnOff() override { _on = false; }
|
|
void clear() override { clearBuffers(); }
|
|
|
|
void startFrame(Color bkg = DARK) override {
|
|
clearBuffers();
|
|
_color = LIGHT;
|
|
}
|
|
|
|
void setTextSize(int sz) override { /* one fixed size in ASCII output */ }
|
|
|
|
void setColor(Color c) override { _color = c; }
|
|
|
|
void setCursor(int x, int y) override { _cursor_x = x; _cursor_y = y; }
|
|
|
|
void print(const char* str) override {
|
|
if (!str) return;
|
|
int cx, cy;
|
|
cellOf(_cursor_x, _cursor_y, cx, cy);
|
|
int col = cx;
|
|
for (const char* p = str; *p; p++) {
|
|
if (*p == '\n') { cy++; col = cx; continue; }
|
|
if (col >= 0 && col < COLS && cy >= 0 && cy < ROWS) {
|
|
_text[cy][col] = (*p >= 32 && *p < 127) ? *p : '?';
|
|
}
|
|
col++;
|
|
}
|
|
// advance cursor horizontally by the printed width, like a real display
|
|
_cursor_x += getTextWidth(str);
|
|
}
|
|
|
|
void fillRect(int x, int y, int w, int h) override { markRect(x, y, w, h); }
|
|
void drawRect(int x, int y, int w, int h) override {
|
|
markRect(x, y, w, 1);
|
|
markRect(x, y + h - 1, w, 1);
|
|
markRect(x, y, 1, h);
|
|
markRect(x + w - 1, y, 1, h);
|
|
}
|
|
void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override {
|
|
markRect(x, y, w, h); // icon placeholder: solid block (see class comment)
|
|
}
|
|
|
|
uint16_t getTextWidth(const char* str) override {
|
|
return str ? (uint16_t)(strlen(str) * getCharWidth()) : 0;
|
|
}
|
|
|
|
void endFrame() {
|
|
printf("\n===== SimDisplayDriver frame #%d =====\n", _frame_no++);
|
|
printf("+");
|
|
for (int c = 0; c < COLS; c++) printf("-");
|
|
printf("+\n");
|
|
for (int r = 0; r < ROWS; r++) {
|
|
printf("|");
|
|
for (int c = 0; c < COLS; c++) {
|
|
char ch = _text[r][c];
|
|
if (ch) putchar(ch);
|
|
else if (_filled[r][c]) putchar('#');
|
|
else putchar(' ');
|
|
}
|
|
printf("|\n");
|
|
}
|
|
printf("+");
|
|
for (int c = 0; c < COLS; c++) printf("-");
|
|
printf("+\n");
|
|
fflush(stdout);
|
|
}
|
|
|
|
private:
|
|
void markRect(int x, int y, int w, int h) {
|
|
bool lit = (_color != DARK);
|
|
int cx0, cy0, cx1, cy1;
|
|
cellOf(x, y, cx0, cy0);
|
|
cellOf(x + w - 1, y + h - 1, cx1, cy1);
|
|
for (int r = cy0; r <= cy1; r++) {
|
|
if (r < 0 || r >= ROWS) continue;
|
|
for (int c = cx0; c <= cx1; c++) {
|
|
if (c < 0 || c >= COLS) continue;
|
|
_filled[r][c] = lit;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
#ifdef __EMSCRIPTEN__
|
|
// ---------------------------------------------------------------------
|
|
// Phase 2 (Emscripten): DisplayDriver backend that draws to a real
|
|
// HTML5 <canvas> instead of dumping ASCII art to stdout. SimDisplayDriver
|
|
// above is left completely untouched -- the native build still links that
|
|
// class (see variants/sim/platformio.ini's DISPLAY_CLASS=SimDisplayDriver);
|
|
// this class is only selected when DISPLAY_CLASS=SimDisplayDriverCanvas is
|
|
// set by the Emscripten build (variants/sim/build_wasm.sh).
|
|
//
|
|
// Design choice: draw straight through the browser's canvas 2D API on every
|
|
// draw call (fillRect/strokeRect/fillText), rather than building an offscreen
|
|
// RGBA framebuffer in linear memory and blitting it with putImageData(). The
|
|
// canvas 2D approach was simpler and more robust for this app's actual draw
|
|
// call shape:
|
|
// - print() needs real text rendering (variable glyphs, marquee/ellipsis
|
|
// logic in DisplayDriver.h measures via getTextWidth()) -- letting the
|
|
// browser's own font rasterizer draw it is both less code and crisper
|
|
// than hand-rolling a bitmap font + blit.
|
|
// - Every draw happens synchronously within one call to startFrame()..
|
|
// endFrame() inside a single JS "tick" (called from the Emscripten main
|
|
// loop -- see sim_main.cpp) -- the browser never paints a partial canvas
|
|
// mid-tick, so there's no tearing/flicker risk from not double-buffering
|
|
/// in C++ first.
|
|
// - putImageData() would still need *something* to rasterize text and
|
|
// icons into an RGBA buffer first -- it doesn't remove that work, it
|
|
/// only relocates it into C++ for no real benefit here.
|
|
//
|
|
// 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 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 <canvas> element either.
|
|
class SimDisplayDriverCanvas : public DisplayDriver {
|
|
bool _on = false;
|
|
int _cursor_x = 0, _cursor_y = 0;
|
|
Color _color = LIGHT;
|
|
int _text_sz = 1;
|
|
|
|
public:
|
|
SimDisplayDriverCanvas() : DisplayDriver(128, 64) { }
|
|
|
|
bool begin() {
|
|
_on = true;
|
|
EM_ASM({
|
|
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;
|
|
}
|
|
|
|
bool isOn() override { return _on; }
|
|
void turnOn() override { _on = true; }
|
|
void turnOff() override {
|
|
_on = false;
|
|
EM_ASM({
|
|
if (!Module.__simCtx) return;
|
|
Module.__simCtx.fillStyle = '#000';
|
|
Module.__simCtx.fillRect(0, 0, 128, 64);
|
|
});
|
|
}
|
|
void clear() override { turnOff(); _on = true; }
|
|
|
|
void startFrame(Color bkg = DARK) override {
|
|
_color = LIGHT;
|
|
EM_ASM({
|
|
if (!Module.__simCtx) return;
|
|
Module.__simCtx.fillStyle = '#000';
|
|
Module.__simCtx.fillRect(0, 0, 128, 64);
|
|
});
|
|
}
|
|
|
|
void setTextSize(int sz) override { _text_sz = sz; }
|
|
void setColor(Color c) override { _color = c; }
|
|
void setCursor(int x, int y) override { _cursor_x = x; _cursor_y = y; }
|
|
|
|
// MiscFixed's real metrics (6px advance, 9px row height -- see
|
|
// src/helpers/ui/MiscFixedFont.h), scaled by the current text size, same
|
|
// as a real board's SSD1306Display::getCharWidth()/getLineHeight() do.
|
|
// DisplayDriver.h's own defaults (6/8, unscaled) would make the Clock
|
|
// screen's setTextSize(2)/(4) big-digit layout math (drawBig()'s width
|
|
// centring, line spacing) come out wrong -- half-size digits crowded on
|
|
// top of each other -- even though print() itself renders them at the
|
|
// right size once _text_sz reaches it (see target.cpp).
|
|
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
|
|
// comment: "on b/w screen, colors will be !=0 synonym of light").
|
|
static const char* jsColor(Color c) { return c == DARK ? "#000" : "#ffb000"; }
|
|
|
|
// Real bitmap-font rendering (byte-identical glyphs to a real MeshCore-Solo
|
|
// board with OLED_MISC_FIXED_FONT=1 -- see solo/heltec_v3/platformio.ini),
|
|
// not the browser's own system font -- defined out-of-line in target.cpp,
|
|
// the one Emscripten-only translation unit allowed to pull in
|
|
// MiscFixedRenderer.h (its own header comment: including it from more than
|
|
// one .cpp would duplicate the font's static const tables in each).
|
|
void print(const char* str) override;
|
|
|
|
// Real MiscFixedFont covers the full glyph set print() draws above, so
|
|
// unlike DisplayDriver's base assumption ("no extended glyphs -- fall back
|
|
// to transliterateCodepoint()'s ASCII substitution"), this driver never
|
|
// needs that. Matches a real board's SH1106Display/SSD1306Display, whose
|
|
// own translateUTF8ToBlocks() override (gated on _single_font) does the
|
|
// same plain passthrough once OLED_MISC_FIXED_FONT is enabled. Without
|
|
// this override here, anything routed through translateUTF8ToBlocks()
|
|
// (KeyboardWidget's live-typing preview line, screen titles, ...) would
|
|
// inherit the base class's ASCII transliteration and silently strip every
|
|
// accented character down to its plain-Latin base -- e.g. typing "ó" via
|
|
// the accent-picker popup would show "o" in the text-entry preview even
|
|
// though the popup itself (which prints its own variants directly, not
|
|
// through this path) and the underlying buffer both had it right.
|
|
void translateUTF8ToBlocks(char* dest, const char* src, size_t dest_size) override {
|
|
size_t n = strlen(src);
|
|
if (n >= dest_size) n = dest_size - 1;
|
|
memcpy(dest, src, n);
|
|
dest[n] = '\0';
|
|
}
|
|
|
|
void fillRect(int x, int y, int w, int h) override {
|
|
EM_ASM({
|
|
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 (!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);
|
|
}, x, y, w, h, (_color != DARK) ? "L" : "D");
|
|
}
|
|
|
|
// Real XBM bit-unpacking (row-major, MSB-first, rows padded to whole
|
|
// bytes) -- same convention every real DisplayDriver's drawXbm() already
|
|
// assumes (see e.g. src/helpers/ui/ST7789Display.cpp's own drawXbm(),
|
|
// `0x80 >> (bx & 7)` against `widthInBytes = (w+7)/8`). A canvas can
|
|
// afford to rasterize the real icon pixels cheaply, unlike the native
|
|
// ASCII backend's solid-block placeholder (no ASCII resolution for that).
|
|
// `bits` is a pointer into wasm linear memory; EM_ASM passes it through as
|
|
// 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 (!Module.__simCtx) return;
|
|
var ctx = Module.__simCtx;
|
|
var x0 = $0;
|
|
var y0 = $1;
|
|
var w = $2;
|
|
var h = $3;
|
|
var bits = $4;
|
|
var lit = UTF8ToString($5) === 'L';
|
|
var widthInBytes = (w + 7) >> 3;
|
|
ctx.fillStyle = lit ? '#ffb000' : '#000';
|
|
for (var ry = 0; ry < h; ry++) {
|
|
for (var rx = 0; rx < w; rx++) {
|
|
var byteOff = bits + ry * widthInBytes + (rx >> 3);
|
|
var mask = 0x80 >> (rx & 7);
|
|
if (HEAPU8[byteOff] & mask) ctx.fillRect(x0 + rx, y0 + ry, 1, 1);
|
|
}
|
|
}
|
|
}, x, y, w, h, bits, (_color != DARK) ? "L" : "D");
|
|
}
|
|
|
|
// 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.
|
|
void endFrame() override { }
|
|
};
|
|
#endif // __EMSCRIPTEN__
|