feat(sim): e-ink simulator builds

SIM_EINK=landscape|portrait builds the e-ink firmware for the browser,
drawn by the same EinkGfxDisplay as the panel. index.html and mesh.html
pick a build from the URL. The simulated sensors get a GPS switch, so the
UI's GPS toggle works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-02 19:24:36 +02:00
co-authored by Claude Opus 5.5
parent b0e24bb076
commit 2589fe95ce
6 changed files with 157 additions and 5 deletions
+26
View File
@@ -462,6 +462,32 @@ public:
void endFrame() override { }
};
#ifdef SIM_EINK_PANEL_W
// An e-ink panel (the Wio Tracker L1 E-ink's 2.13", 122x250 before rotation):
// EinkGfxDisplay draws, exactly as GxEPDDisplay does on the board, into a 1-bit
// canvas, and a frame that changed is put on the page in paper and ink. The
// host page counts refreshes from Module.__einkRefreshes. Bodies in target.cpp,
// the one TU that pulls in the font tables.
#include <helpers/ui/EinkGfxDisplay.h>
#ifndef DISPLAY_ROTATION
#define DISPLAY_ROTATION 0
#endif
class SimEinkDisplay : public EinkGfxDisplay {
bool _on = false;
static Adafruit_GFX& canvas();
public:
static const int PANEL_W = SIM_EINK_PANEL_W, PANEL_H = SIM_EINK_PANEL_H;
SimEinkDisplay();
bool begin();
bool isOn() override { return _on; }
void turnOn() override { _on = true; }
void turnOff() override { _on = false; }
void clear() override;
void setDisplayRotation(uint8_t rot) override;
void endFrame() override;
};
#endif
extern "C" EMSCRIPTEN_KEEPALIVE inline void sim_lcd_touch(int x, int y, int down) {
SimLcdDisplay::Touch& t = SimLcdDisplay::touchState();
t.x = x; t.y = y; t.down = down != 0;
+11
View File
@@ -1,4 +1,5 @@
#pragma once
#include <string.h>
#include <helpers/SensorManager.h>
@@ -35,11 +36,21 @@
// type turns out to matter for a demo.
class SimSensorManager : public SensorManager {
static float& envTemperatureRef() { static float t = 21.0f; return t; }
bool _gps = false; // the "gps" setting, as a real board's GPS switch: the UI's toggle needs it
public:
LocationProvider* getLocationProvider() override;
bool querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) override;
int getNumSettings() const override { return 1; }
const char* getSettingName(int i) const override { return i == 0 ? "gps" : NULL; }
const char* getSettingValue(int i) const override { return i == 0 ? (_gps ? "1" : "0") : NULL; }
bool setSettingValue(const char* name, const char* value) override {
if (strcmp(name, "gps") != 0) return false;
_gps = strcmp(value, "0") != 0;
return true;
}
static void setEnvTemperature(float celsius) { envTemperatureRef() = celsius; }
};
+24
View File
@@ -24,6 +24,8 @@
# Usage:
# variants/sim/build_wasm.sh # release-ish build (-O2)
# variants/sim/build_wasm.sh debug # -O0 -g, easier to debug in devtools
# SIM_UI=lvgl variants/sim/build_wasm.sh # the L2 (web/lvgl.html)
# SIM_EINK=landscape variants/sim/build_wasm.sh # the L1 E-ink, also portrait
#
# Requires emsdk 6.0.9 (pinned; see variants/sim/tools/emsdk/ -- installed by
# this same task, see the Phase 2 report for the exact activation command).
@@ -192,6 +194,28 @@ if [ "$SIM_UI" = "lvgl" ]; then
LINK_EXTRA=(-sFETCH=1) # map downloads (LvglPort.h) go through emscripten_fetch
fi
# SIM_EINK=landscape|portrait: ui-new on the Wio Tracker L1 E-ink's 2.13" panel
# (122x250, solo/wio-tracker-l1-eink's two builds) instead of the OLED ->
# web/index.html?build=meshcore_sim_eink_landscape (or _portrait).
SIM_EINK="${SIM_EINK:-}"
if [ -n "$SIM_EINK" ]; then
case "$SIM_EINK" in
landscape) EINK_DEFS=(-DDISPLAY_ROTATION=1 -DEINK_LARGE_FONT=1) ;;
portrait) EINK_DEFS=(-DDISPLAY_ROTATION=0) ;;
*) echo "error: SIM_EINK must be landscape or portrait" >&2; exit 1 ;;
esac
OUT_NAME="meshcore_sim_eink_$SIM_EINK"
EXPORT_NAME=MeshCoreSimEink # distinct factory: web/mesh.html?a=... loads it beside MeshCoreSim
kept=()
for d in "${DEFINES[@]}"; do
case "$d" in -DDISPLAY_CLASS=*|-DMAX_GROUP_CHANNELS=*) ;; *) kept+=("$d") ;; esac
done
# EINK_DISPLAY_MODEL is only tested for presence outside GxEPDDisplay
# (Features.h: the e-ink settings and timings), which the sim doesn't build.
DEFINES=("${kept[@]}" -DDISPLAY_CLASS=SimEinkDisplay -DSIM_EINK_PANEL_W=122 -DSIM_EINK_PANEL_H=250
-DEINK_DISPLAY_MODEL=SimEinkPanel -DMAX_GROUP_CHANNELS=40 "${EINK_DEFS[@]}")
fi
# -funsigned-char: carried over from Phase 1 verbatim -- real ARM cores
# default `char` to unsigned; em++'s target (wasm32) defaults it to signed,
# same mismatch Phase 1 hit on a native x86/ARM64 host, for the same reason
+78
View File
@@ -157,4 +157,82 @@ void SimDisplayDriverCanvas::print(const char* str) {
// real y position across embedded '\n's internally.
_cursor_x += getTextWidth(str);
}
#ifdef SIM_EINK_PANEL_W
#include <helpers/ui/EinkGfxDisplayImpl.h>
// The panel's RAM: 1 bit a pixel, ink = 1. GFXcanvas1 rotates like GxEPD2 does.
Adafruit_GFX& SimEinkDisplay::canvas() {
static GFXcanvas1 c(PANEL_W, PANEL_H);
return c;
}
SimEinkDisplay::SimEinkDisplay()
: EinkGfxDisplay((DISPLAY_ROTATION & 1) ? PANEL_H : PANEL_W,
(DISPLAY_ROTATION & 1) ? PANEL_W : PANEL_H, canvas(), 1, 0) {
canvas().setRotation(DISPLAY_ROTATION);
}
// Sizes the page's canvas to the panel as it's turned, at Module.simEinkScale
// (default 3) CSS pixels a pixel.
static void simEinkFitCanvas(int w, int h) {
EM_ASM({
var tag = (typeof Module !== 'undefined' && Module['simInstanceTag']) ? Module['simInstanceTag'] : '';
var c = document.getElementById(tag ? ('sim-canvas-' + tag) : 'sim-canvas');
if (!c) { console.error('[sim] e-ink canvas not found in the host page'); return; }
var k = Module.simEinkScale || 3;
c.width = $0; c.height = $1;
c.style.width = ($0 * k) + 'px'; c.style.height = ($1 * k) + 'px';
Module.__simCtx = c.getContext('2d');
Module.__simCtx.imageSmoothingEnabled = false;
Module.__einkRefreshes = Module.__einkRefreshes || 0;
}, w, h);
}
bool SimEinkDisplay::begin() {
_on = true;
simEinkFitCanvas(width(), height());
return true;
}
void SimEinkDisplay::clear() {
canvas().fillScreen(0);
forceRedraw();
}
void SimEinkDisplay::setDisplayRotation(uint8_t rot) {
canvas().setRotation(rot & 3);
setDimensions(canvas().width(), canvas().height());
updateLayout();
simEinkFitCanvas(width(), height());
}
// Only a frame that differs reaches the page, as only that one costs the
// board a refresh. Read back through getPixel(), which applies the rotation.
void SimEinkDisplay::endFrame() {
if (!frameChanged()) return;
const int w = width(), h = height();
static uint8_t px[SIM_EINK_PANEL_W * SIM_EINK_PANEL_H];
const GFXcanvas1& c = (const GFXcanvas1&)canvas();
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++) px[y * w + x] = c.getPixel(x, y);
EM_ASM({
if (!Module.__simCtx) return;
var w = $1;
var h = $2;
var img = Module.__simCtx.createImageData(w, h);
var d = img.data;
for (var i = 0; i < w * h; i++) {
var ink = HEAPU8[$0 + i];
var o = i * 4;
d[o] = ink ? 0x1a : 0xdc;
d[o + 1] = ink ? 0x1a : 0xdc;
d[o + 2] = ink ? 0x1a : 0xd2;
d[o + 3] = 255;
}
Module.__simCtx.putImageData(img, 0, 0);
Module.__einkRefreshes++;
}, px, w, h);
}
#endif // SIM_EINK_PANEL_W
#endif // __EMSCRIPTEN__
+10 -3
View File
@@ -158,7 +158,13 @@
<div id="log"></div>
<script src="build/meshcore_sim.js"></script>
<!-- ?build=meshcore_sim_eink_landscape (or _portrait) loads another build of
the same UI; the e-ink driver sizes the canvas itself. -->
<script>
document.write('<script src="build/' +
(new URLSearchParams(location.search).get('build') || 'meshcore_sim').replace(/[^\w]/g, '') +
'.js"><\/script>');
</script>
<script>
const statusEl = document.getElementById('status');
const logEl = document.getElementById('log');
@@ -209,11 +215,12 @@
}
}
if (typeof MeshCoreSim !== 'function') {
const SimFactory = window.MeshCoreSimEink || window.MeshCoreSim;
if (typeof SimFactory !== 'function') {
statusEl.textContent = 'error: build/meshcore_sim.js missing or failed to load -- run variants/sim/build_wasm.sh first.';
} else {
statusEl.textContent = 'booting wasm module...';
MeshCoreSim().then((Module) => {
SimFactory().then((Module) => {
window.Module = Module; // exposed for console poking while testing
statusEl.textContent = 'running -- IDBFS-backed identity/prefs persist across a real reload.';
log('module ready; sim_enqueue_key = ' + (typeof Module._sim_enqueue_key));
+8 -2
View File
@@ -231,8 +231,14 @@
<script src="build/meshcore_sim.js"></script>
<script src="build/repeater/meshcore_sim_repeater.js"></script>
<!-- ?a=meshcore_sim_eink_landscape (or _portrait): A is that e-ink build. -->
<script>
const A_BUILD = (new URLSearchParams(location.search).get('a') || '').replace(/[^\w]/g, '');
if (A_BUILD) document.write('<script src="build/' + A_BUILD + '.js"><\/script>');
</script>
<script>
const logEl = document.getElementById('log');
const FactoryA = (opts) => (A_BUILD ? MeshCoreSimEink : MeshCoreSim)(opts);
function log(msg) {
const line = document.createElement('div');
line.textContent = '[' + new Date().toISOString().substr(11, 12) + '] ' + msg;
@@ -384,7 +390,7 @@
async function resetInstance(key) {
log(`resetting instance ${key}...`);
document.getElementById('status-' + key).textContent = 'resetting...';
if (key === 'A') A = wrapInstance(await MeshCoreSim({ simInstanceTag: 'A' }), 'A');
if (key === 'A') A = wrapInstance(await FactoryA({ simInstanceTag: 'A' }), 'A');
else if (key === 'B') B = wrapInstance(await MeshCoreSim({ simInstanceTag: 'B' }), 'B');
else if (key === 'R') R = wrapInstance(await MeshCoreSimRepeater({ simInstanceTag: 'R' }), 'R');
await waitReady(key === 'A' ? A : key === 'B' ? B : R, 'status-' + key);
@@ -394,7 +400,7 @@
async function main() {
log('booting instance A (companion_radio)...');
const modA = await MeshCoreSim({ simInstanceTag: 'A' });
const modA = await FactoryA({ simInstanceTag: 'A' });
log('booting instance B (companion_radio)...');
const modB = await MeshCoreSim({ simInstanceTag: 'B' });
log('booting instance R (simple_repeater)...');