Files
MeshCore-Solo/variants/sim/web/lvgl.html
T
JakubandClaude Opus 5.5 2765ac6d8b feat(ui-lvgl): screenshots (side + top button), lock screen always times out
- Side button held + top button saves the screen, popups and toasts included,
  to /sdcard/screenshots/scr_NNNN.bmp (24-bit BMP). The flush callbacks copy
  one forced full re-render into a PSRAM buffer. A white flash and a toast
  confirm it. The side button's mute now fires on release, so holding it a
  little long before the top button doesn't also mute. In the sim, the page's
  Screenshot button downloads the file.
- The lock screen goes dark after 30 s even with auto-off at Never (a boot
  PIN, a pocket wake-up, a message), and sooner if auto-off is shorter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:03:48 +02:00

335 lines
15 KiB
HTML

<!DOCTYPE html>
<!--
ui-lvgl simulator: the Wio Tracker L2 frontend (320x240 touch LCD) running
the real companion firmware in the browser, plus one ui-new peer device and
one repeater on a shared simulated link, so there is someone to talk to and
something for Nearby > Scan to find.
Build both, then serve this directory (http://, not file://):
variants/sim/build_wasm.sh # peer -> build/meshcore_sim.js
variants/sim/build_wasm_repeater.sh # repeater (optional; page runs without it)
SIM_UI=lvgl variants/sim/build_wasm.sh # L2 -> build/meshcore_sim_lvgl.js
cd variants/sim/web && python3 -m http.server 8080
and open http://localhost:8080/lvgl.html .
Map: tiles in web/maps (tools/maps/fetch_tiles.py --preset krakow --out
variants/sim/web --manifest) are copied into the L2's /sdcard/maps at boot,
and its GPS is put on Kraków's Old Town.
The mouse is the finger: press / drag / release on the LCD. Holding a key
on the on-screen keyboard opens its accent popup, as on the device.
window.sim is the automation surface (headless checks): sim.ready (promise),
sim.touch(x, y, down), sim.tap(x, y), sim.hold(x, y, ms), sim.L / sim.P.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<title>ui-lvgl sim</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0; padding: 24px 16px;
background: #111114; color: #ddd;
font: 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
display: flex; flex-wrap: wrap; gap: 24px; justify-content: center; align-items: flex-start;
}
.dev { display: flex; flex-direction: column; gap: 8px; align-items: center; }
.dev h2 { margin: 0; font-size: 13px; font-weight: 600; color: #aaa; }
#sim-canvas-L {
width: 640px; height: 480px; max-width: calc(100vw - 32px); aspect-ratio: 4 / 3; height: auto;
image-rendering: pixelated; background: #000;
border: 10px solid #26262b; border-radius: 14px; touch-action: none; cursor: crosshair;
}
#sim-canvas-P {
width: 384px; height: 192px; image-rendering: pixelated; background: #000;
border: 6px solid #26262b; border-radius: 8px;
}
.panel { width: 384px; max-width: calc(100vw - 32px); display: flex; flex-direction: column; gap: 6px; }
.row { display: flex; gap: 6px; flex-wrap: wrap; }
button, input {
font: inherit; color: #eee; background: #2a2a30; border: 1px solid #45454d; border-radius: 6px; padding: 6px 10px;
}
button { cursor: pointer; }
button:active { background: #3a3a42; }
input { flex: 1; min-width: 0; }
#log {
height: 180px; overflow-y: auto; background: #18181c; border: 1px solid #2c2c33; border-radius: 6px;
padding: 6px; font: 11px ui-monospace, Menlo, monospace; color: #9a9a9a; white-space: pre-wrap;
}
.status { font-size: 11px; color: #888; }
</style>
</head>
<body>
<div class="dev">
<h2>Wio Tracker L2 &middot; ui-lvgl <span class="status" id="status-L">booting…</span></h2>
<canvas id="sim-canvas-L" width="320" height="240"></canvas>
<div class="row">
<button id="btn-L">User (back; hold: mute)</button>
<button id="wake-L">Wake (screen off / on)</button>
<button id="shot-L">Screenshot (User held + Wake)</button>
<button id="reset-L">Reset L2</button>
</div>
</div>
<div class="dev">
<h2>Peer &middot; ui-new <span class="status" id="status-P">booting…</span></h2>
<canvas id="sim-canvas-P" width="128" height="64"></canvas>
<div class="panel">
<div class="row">
<input id="peer-text" value="Cześć! Привет! Γειά σου!" aria-label="Peer message text">
</div>
<div class="row">
<button id="peer-dm">Peer → DM to L2</button>
<button id="peer-ch">Peer → Public</button>
<button id="advert">Advert all</button>
</div>
<div id="log"></div>
</div>
</div>
<script src="build/meshcore_sim.js"></script>
<script src="build/meshcore_sim_lvgl.js"></script>
<script src="build/repeater/meshcore_sim_repeater.js" onerror="window.MeshCoreSimRepeater = null"></script>
<script src="build/room_server/meshcore_sim_room_server.js" onerror="window.MeshCoreSimRoomServer = null"></script>
<script>
const logEl = document.getElementById('log');
function log(msg) {
const line = document.createElement('div');
line.textContent = new Date().toISOString().substr(11, 8) + ' ' + msg;
logEl.appendChild(line);
logEl.scrollTop = logEl.scrollHeight;
}
const MAX_PKT = 256;
function wrap(mod, label) {
const txBuf = mod._malloc(MAX_PKT);
return {
mod, label,
isReady() { return mod.ccall('sim_is_ready', 'number', [], []) === 1; },
pollTx() {
const out = [];
for (;;) {
const n = mod.ccall('sim_radio_poll_tx', 'number', ['number', 'number'], [txBuf, MAX_PKT]);
if (n <= 0) break;
out.push(mod.HEAPU8.slice(txBuf, txBuf + n));
}
return out;
},
injectRx(bytes) {
const p = mod._malloc(bytes.length);
mod.HEAPU8.set(bytes, p);
mod.ccall('sim_radio_inject_rx', null, ['number', 'number'], [p, bytes.length]);
mod._free(p);
},
call(name, ret, types, args) { return mod.ccall(name, ret, types || [], args || []); },
};
}
let L = null, P = null, R = null, S = null;
// Everyone hears everyone, 50 ms air time.
setInterval(() => {
const nodes = [L, P, R, S].filter(Boolean);
for (const from of nodes) {
for (const pkt of from.pollTx())
for (const to of nodes) if (to !== from) setTimeout(() => to.injectRx(pkt), 50);
}
}, 40);
async function waitReady(inst) {
for (let i = 0; i < 400; i++) {
if (inst.isReady()) return true;
await new Promise(r => setTimeout(r, 25));
}
return false;
}
function prepare(inst) {
inst.call('sim_test_sync_time', 'number', ['number'], [Date.now() / 1000]);
inst.call('sim_test_set_timezone_hours', 'number', ['number'], [-Math.round(new Date().getTimezoneOffset() / 60)]);
inst.call('sim_test_disable_screen_timeout', 'number');
}
// Map tiles listed in maps/manifest.json -> the instance's /sdcard/maps.
async function loadMaps(inst) {
let list;
try { list = await (await fetch('maps/manifest.json')).json(); }
catch (e) { log('no maps/manifest.json (map will be empty)'); return; }
const FS = inst.mod.FS;
await Promise.all(list.map(async (rel) => {
const buf = new Uint8Array(await (await fetch('maps/' + rel)).arrayBuffer());
const path = '/sdcard/maps/' + rel;
FS.mkdirTree(path.substring(0, path.lastIndexOf('/')));
FS.writeFile(path, buf);
}));
log('map: ' + list.length + ' tiles loaded');
}
async function bootL() {
document.getElementById('status-L').textContent = 'booting…';
if (L) L.call('sim_stop_main_loop', null);
L = wrap(await MeshCoreSimLvgl({ simInstanceTag: 'L' }), 'L');
const ok = await waitReady(L);
if (ok) {
prepare(L);
L.call('sim_location_set', null, ['number', 'number', 'number'], [50.0614, 19.9372, 220]);
await loadMaps(L);
}
document.getElementById('status-L').textContent = ok ? 'ready' : 'boot timed out';
return ok;
}
async function bootP() {
P = wrap(await MeshCoreSim({ simInstanceTag: 'P' }), 'P');
const ok = await waitReady(P);
if (ok) { prepare(P); P.call('sim_test_show_all_home_pages', 'number'); }
document.getElementById('status-P').textContent = ok ? 'ready' : 'boot timed out';
return ok;
}
async function bootR() {
if (!window.MeshCoreSimRepeater) { log('repeater build not found (optional)'); return false; }
R = wrap(await MeshCoreSimRepeater({ simInstanceTag: 'R' }), 'R');
const ok = await waitReady(R);
log(ok ? 'repeater up' : 'repeater boot timed out');
return ok;
}
// Room server "Sim Room" (optional, variants/sim/build_wasm_room_server.sh):
// guest password empty, admin "password".
async function bootS() {
if (!window.MeshCoreSimRoomServer) { log('room server build not found (optional)'); return false; }
S = wrap(await MeshCoreSimRoomServer({ simInstanceTag: 'S' }), 'S');
const ok = await waitReady(S);
log(ok ? 'room server up' : 'room server boot timed out');
return ok;
}
function advertBoth() {
L.call('sim_test_advert_flood', 'number');
setTimeout(() => P.call('sim_test_advert_flood', 'number'), 300);
if (R) setTimeout(() => R.call('sim_test_advert_flood', 'number'), 600);
if (S) setTimeout(() => S.call('sim_test_advert_flood', 'number'), 900);
log('advert: all');
}
// ── Touch: mouse / pen / finger on the LCD canvas ─────────────────────
const canvas = document.getElementById('sim-canvas-L');
function touch(x, y, down) {
if (L) L.call('sim_lcd_touch', null, ['number', 'number', 'number'], [x | 0, y | 0, down ? 1 : 0]);
}
function toDevice(e) {
const r = canvas.getBoundingClientRect();
const bx = (r.width - canvas.clientWidth) / 2, by = (r.height - canvas.clientHeight) / 2; // border
const x = (e.clientX - r.left - bx) * 320 / canvas.clientWidth;
const y = (e.clientY - r.top - by) * 240 / canvas.clientHeight;
return [Math.max(0, Math.min(319, x)), Math.max(0, Math.min(239, y))];
}
// Speaker: a Web Audio oscillator polled against L's sim_buzzer_* exports,
// as index.html does for the piezo (see its comments). A sine here: the L2
// plays its melodies through a codec and a speaker, not a buzzer. The
// context can only start on a user gesture, so the first touch creates it.
let audioCtx = null, osc = null, gain = null, wasPlaying = false;
function ensureAudio() {
if (audioCtx) { if (audioCtx.state === 'suspended') audioCtx.resume(); return; }
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === 'suspended') audioCtx.resume();
osc = audioCtx.createOscillator();
gain = audioCtx.createGain();
gain.gain.value = 0;
osc.type = 'sine';
osc.connect(gain).connect(audioCtx.destination);
osc.start();
}
window.__buzzAudio = () => (audioCtx ? { state: audioCtx.state, gain: gain.gain.value, freq: osc.frequency.value } : null);
const SPEAKER_GAIN = [0.02, 0.05, 0.1, 0.18, 0.28]; // the firmware's -24..0 dB steps
function rampGain(target, s, now) {
gain.gain.cancelScheduledValues(now);
gain.gain.setValueAtTime(gain.gain.value, now);
gain.gain.linearRampToValueAtTime(target, now + s);
}
setInterval(() => {
if (!audioCtx || !L || !L.mod._sim_buzzer_is_playing) return;
const playing = L.call('sim_buzzer_is_playing', 'number') === 1;
const now = audioCtx.currentTime;
if (playing) {
const freq = L.call('sim_buzzer_freq_hz', 'number');
if (freq > 0) osc.frequency.setValueAtTime(freq, now);
rampGain(freq > 0 ? (SPEAKER_GAIN[L.call('sim_buzzer_get_volume', 'number')] || 0) : 0, 0.005, now);
} else if (wasPlaying) {
rampGain(0, 0.01, now);
}
wasPlaying = playing;
}, 20);
let pressed = false;
canvas.addEventListener('pointerdown', (e) => { ensureAudio(); pressed = true; canvas.setPointerCapture(e.pointerId); touch(...toDevice(e), true); });
canvas.addEventListener('pointermove', (e) => { if (pressed) touch(...toDevice(e), true); });
const lift = (e) => { if (!pressed) return; pressed = false; touch(...toDevice(e), false); };
canvas.addEventListener('pointerup', lift);
canvas.addEventListener('pointercancel', lift);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
async function tap(x, y) { touch(x, y, true); await sleep(80); touch(x, y, false); await sleep(120); }
async function hold(x, y, ms) { touch(x, y, true); await sleep(ms || 700); } // leaves the finger down
// User button: a click goes back, a 1 s hold mutes (as MomentaryButton's long press).
let btnDownAt = 0;
const btnL = document.getElementById('btn-L');
btnL.addEventListener('pointerdown', () => { btnDownAt = performance.now(); });
btnL.addEventListener('pointerup', () => {
if (!L || !btnDownAt) return;
L.call(performance.now() - btnDownAt >= 1000 ? 'sim_lcd_button_hold' : 'sim_lcd_button', null);
btnDownAt = 0;
});
document.getElementById('wake-L').addEventListener('click', () => L && L.call('sim_wake_button', null));
// The device's own screenshot (the button combo), then the newest file
// from its /sdcard/screenshots handed to the browser as a download.
document.getElementById('shot-L').addEventListener('click', async () => {
if (!L) return;
L.call('sim_screenshot', null);
await sleep(300);
const FS = L.mod.FS, dir = '/sdcard/screenshots';
let names = [];
try { names = FS.readdir(dir).filter(n => n.endsWith('.bmp')).sort(); } catch (e) {}
if (!names.length) { log('screenshot: nothing saved'); return; }
const name = names[names.length - 1];
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([FS.readFile(dir + '/' + name)], { type: 'image/bmp' }));
a.download = name;
a.click();
log('screenshot: ' + name);
});
document.getElementById('reset-L').addEventListener('click', () => bootL());
document.getElementById('advert').addEventListener('click', advertBoth);
document.getElementById('peer-dm').addEventListener('click', () => {
const t = document.getElementById('peer-text').value;
const r = P.call('sim_test_send_msg_to_first_contact', 'number', ['string'], [t]);
log(r < 0 ? 'peer DM: no contact yet (Advert both first)' : 'peer DM sent: ' + t);
});
document.getElementById('peer-ch').addEventListener('click', () => {
const t = document.getElementById('peer-text').value;
const r = P.call('sim_test_send_channel_msg', 'number', ['number', 'string'], [0, t]);
log(r ? 'peer → Public: ' + t : 'peer → Public failed');
});
const ready = (async () => {
await bootR();
await bootS();
const [okP, okL] = [await bootP(), await bootL()];
if (okP && okL) { advertBoth(); log('both devices up'); }
return okP && okL;
})();
window.sim = {
ready, touch, tap, hold, advertBoth,
get L() { return L; }, get P() { return P; },
peerDM(t) { return P.call('sim_test_send_msg_to_first_contact', 'number', ['string'], [t]); },
peerChannel(t) { return P.call('sim_test_send_channel_msg', 'number', ['number', 'string'], [0, t]); },
contacts(inst) { return (inst || L).call('sim_test_get_num_contacts', 'number'); },
};
</script>
</body>
</html>