feat(sim): web viewer /sim, touch injection, fast screenshot, SIM_DISPLAY_W/H

This commit is contained in:
Adolfo Reyna
2026-09-14 21:43:41 -04:00
parent 2060095028
commit 89e8baf517
3 changed files with 271 additions and 18 deletions
@@ -26,6 +26,11 @@
#if TT_FEATURE_SCREENSHOT_ENABLED
#include <lv_screenshot.h>
// lodepng.h enables its C++ overloads (std::vector/std::string) when compiled
// as C++; save_png.c compiles it as C. WebServerService.cpp is C++, so opt out
// of the C++ wrapper to get the same C API save_png.c uses.
#define LODEPNG_NO_COMPILE_CPP
#include "src/libs/lodepng/lodepng.h"
#endif
#include "app/install.h"
@@ -536,6 +541,23 @@ bool WebServerService::startServer() {
.callback = handleApiPut,
.user_ctx = ctx
},
#ifndef ESP_PLATFORM
// Simulator viewer aliases: the /sim page resolves api/ against its
// own directory (/sim/api/...), so mirror the viewer-needed handlers
// here. Same callbacks, auth enforced inside each handler.
{
.uri = "/sim/api/*",
.method = HTTP_METHOD_GET,
.callback = handleApiGet,
.user_ctx = ctx
},
{
.uri = "/sim/api/*",
.method = HTTP_METHOD_POST,
.callback = handleApiPost,
.user_ctx = ctx
},
#endif
{
.uri = "/*", // Catch-all for dynamic assets
.method = HTTP_METHOD_GET,
@@ -1053,9 +1075,17 @@ error_t WebServerService::handleAdminPost(HttpServerRequest* request, void* user
// API GET dispatcher - returns JSON system information
// Note: /api/sysinfo is intentionally public for monitoring use cases
// The /sim/api/* alias routes (sim viewer page) strip the /sim prefix here
// so both /api/... and /sim/api/... share one dispatch table.
static const char* api_path_suffix(HttpServerRequest* request, char* uri, size_t uri_size) {
http_server_request_get_uri(request, uri, uri_size);
if (strncmp(uri, "/sim/api/", 9) == 0) return uri + 4; // -> /api/...
return uri;
}
error_t WebServerService::handleApiGet(HttpServerRequest* request, void* user_ctx) {
char uri[256];
http_server_request_get_uri(request, uri, sizeof(uri));
char uri_buf[256];
const char* uri = api_path_suffix(request, uri_buf, sizeof(uri_buf));
// Public endpoint: sysinfo (basic device info for monitoring)
if (strncmp(uri, "/api/sysinfo", 12) == 0) {
@@ -1096,8 +1126,8 @@ error_t WebServerService::handleApiPost(HttpServerRequest* request, void* user_c
return authResult;
}
char uri[256];
http_server_request_get_uri(request, uri, sizeof(uri));
char uri_buf[256];
const char* uri = api_path_suffix(request, uri_buf, sizeof(uri_buf));
if (strncmp(uri, "/api/apps/run", 13) == 0) {
return handleApiAppsRun(request, user_ctx);
}
@@ -1539,11 +1569,76 @@ error_t WebServerService::handleApiWifi(HttpServerRequest* request, void*) {
}
// GET /api/screenshot - Capture and return screenshot as PNG
// Screenshots are saved to SD card root (if available) or /data with incrementing numbers
// Fast path (no query or ?fast=1): snapshot LVGL to a memory buffer, PNG-encode
// to memory with lodepng, stream directly. No filesystem touch, no slot scan.
// Legacy path (?fast=0): previous save-to-webscreenshotN.png behavior.
error_t WebServerService::handleApiScreenshot(HttpServerRequest* request, void*) {
LOG_I(TAG, "GET /api/screenshot");
#if TT_FEATURE_SCREENSHOT_ENABLED
std::string sfast;
bool fast = true;
if (getQueryParam(request, "fast", sfast)) fast = (sfast != "0");
if (fast) {
// Hold the LVGL lock across snapshot + encode: both must see a stable
// framebuffer. lv_snapshot_take renders synchronously under the lock.
if (!lvgl_try_lock(pdMS_TO_TICKS(500))) {
LOG_E(TAG, "Could not acquire LVGL lock within 500ms");
http_server_request_send_error(request, 500, "could not acquire LVGL lock");
return ERROR_UNDEFINED;
}
lv_draw_buf_t* snapshot = lv_snapshot_take(lv_scr_act(), LV_COLOR_FORMAT_RGB888);
if (snapshot == nullptr) {
lvgl_unlock();
LOG_E(TAG, "lv_snapshot_take failed");
http_server_request_send_error(request, 500, "snapshot failed");
return ERROR_UNDEFINED;
}
// lodepng wants RGB triplets; snapshot is RGB888 = 3 bytes/px already,
// but in BGR order on little-endian — swap R and B in place.
uint32_t px_count = snapshot->header.w * snapshot->header.h;
uint8_t* px = snapshot->data;
for (uint32_t i = 0; i < px_count; i++) {
uint8_t tmp = px[0];
px[0] = px[2];
px[2] = tmp;
px += 3;
}
unsigned char* png = nullptr;
size_t png_size = 0;
unsigned enc_err = lodepng_encode24(&png, &png_size, snapshot->data,
snapshot->header.w, snapshot->header.h);
uint32_t w = snapshot->header.w, h = snapshot->header.h;
lv_draw_buf_destroy(snapshot);
lvgl_unlock();
if (enc_err != 0 || png == nullptr) {
LOG_E(TAG, "lodepng_encode24 failed: %u", enc_err);
http_server_request_send_error(request, 500, "png encode failed");
return ERROR_UNDEFINED;
}
LOG_I(TAG, "Screenshot %lux%lu %d bytes (memory path)", (unsigned long)w, (unsigned long)h, (int)png_size);
http_server_request_set_content_type(request, "image/png");
error_t result = ERROR_NONE;
if (http_server_request_send_chunk_start(request) != ERROR_NONE) {
result = ERROR_UNDEFINED;
} else {
size_t sent = 0;
while (sent < png_size) {
size_t n = png_size - sent > 8192 ? 8192 : png_size - sent;
if (http_server_request_send_chunk(request, png + sent, n) != ERROR_NONE) {
result = ERROR_UNDEFINED;
break;
}
sent += n;
}
http_server_request_send_chunk_end(request);
}
free(png);
LOG_I(TAG, "[200] /api/screenshot fast %d bytes", (int)png_size);
return result;
}
// Determine save location: prefer SD card root if mounted, otherwise /data
std::string save_path = getDataPath();
@@ -1787,9 +1882,9 @@ error_t WebServerService::handleReboot(HttpServerRequest* request, void*) {
// POST /api/sim/touch?x=123&y=456[&down=0|1] - inject touch into simulator.
// Simulator-only: on ESP32 there is no sdl-pointer backend, so this 404s.
// x/y are LVGL logical pixels (sim display is 640x480). down=1 (default)
// presses and auto-releases after ~1.5s (long enough for LVGL indev polls to
// register a click); down=0 releases immediately.
// x/y are LVGL logical pixels (see viewer footer for current WxH).
// down=1 (default) presses and auto-releases after ~1.5s (long enough for LVGL
// indev polls to register a click); down=0 releases immediately.
error_t WebServerService::handleApiSimTouch(HttpServerRequest* request, void*) {
#ifdef ESP_PLATFORM
http_server_request_send_error(request, 404, "simulator only");
@@ -1820,6 +1915,13 @@ error_t WebServerService::handleSimViewer(HttpServerRequest* request, void*) {
return authResult;
}
http_server_request_set_content_type(request, "text/html");
// NOTE: the page resolves api/ against its own directory (new URL(u,base)).
// Locally that is /sim -> /sim/api/...; behind tailscale
// (--set-path=/simagent -> /) it is /simagent/sim -> /simagent/api/...
// Both need handler aliases, registered in startServer() below:
// /sim/api/* mirrors /api/* (screenshot, sim/touch). The page itself must
// therefore be served with a trailing-slash-insensitive /sim match so the
// browser treats /sim as a directory (see handleAssets /sim route).
static const char* page =
"<!doctype html><html><head><meta charset=utf-8><meta name=viewport "
"content='width=device-width,initial-scale=1'>"
@@ -1834,18 +1936,22 @@ error_t WebServerService::handleSimViewer(HttpServerRequest* request, void*) {
"<div class=bar><b>Tactility Sim</b><span id=st>live</span>"
"<button onclick='snap()'>refresh</button></div>"
"<img id=scr alt='sim screen'>"
"<div class=bar>click/tap the screen to touch &middot; 640x480 logical</div>"
"<div class=bar>click/tap the screen to touch &middot; <span id=res></span> logical</div>"
"<script>"
"const img=document.getElementById('scr'),st=document.getElementById('st');"
"function snap(){img.src='/api/screenshot?ts='+Date.now();}"
"setInterval(snap,2000);snap();"
"const img=document.getElementById('scr'),st=document.getElementById('st'),"
"res=document.getElementById('res');"
"const base=new URL('.',location.href).href;"
"const api=u=>new URL(u,base).href;"
"function snap(){img.src=api('api/screenshot?fast=1')+'&ts='+Date.now();}"
"setInterval(snap,500);snap();"
"img.addEventListener('error',()=>{st.textContent='reconnecting…';});"
"img.addEventListener('load',()=>{st.textContent='live';});"
"img.addEventListener('load',()=>{st.textContent='live';"
"res.textContent=img.naturalWidth+'x'+img.naturalHeight;});"
"img.addEventListener('pointerdown',e=>{"
"const r=img.getBoundingClientRect();"
"const x=Math.round((e.clientX-r.left)/r.width*640);"
"const y=Math.round((e.clientY-r.top)/r.height*480);"
"fetch('/api/sim/touch?x='+x+'&y='+y,{method:'POST'});});"
"const x=Math.round((e.clientX-r.left)/r.width*img.naturalWidth);"
"const y=Math.round((e.clientY-r.top)/r.height*img.naturalHeight);"
"fetch(api('api/sim/touch')+'?x='+x+'&y='+y,{method:'POST'});});"
"</script></body></html>";
http_server_request_send_string(request, page);
return ERROR_NONE;
@@ -1863,8 +1969,29 @@ error_t WebServerService::handleAssets(HttpServerRequest* request, void*) {
http_server_request_get_uri(request, uri, sizeof(uri));
LOG_I(TAG, "GET %s", uri);
// Simulator live viewer (no auth bypass: checked inside handler)
// Simulator live viewer (no auth bypass: checked inside handler).
// Served with redirect-to-slash so relative api/ URLs resolve under /sim/
// (browsers treat /sim as a file, /sim/ as a directory for URL purposes).
// NOTE: /sim/api/* does NOT need special-casing here: the /sim/api/*
// handler aliases registered in startServer() route straight into
// handleApiGet/handleApiPost, which strip the /sim prefix via
// api_path_suffix(). This block only serves the viewer page itself.
if (strncmp(uri, "/sim", 4) == 0 && (uri[4] == '\0' || uri[4] == '?' || uri[4] == '/')) {
// Redirect bare /sim -> /sim/ so relative api/ resolves correctly.
if (uri[4] == '\0' || uri[4] == '?') {
std::string loc = "/sim/";
const char* q = strchr(uri, '?');
if (q != nullptr) { loc += q; }
http_server_request_set_header(request, "Location", loc.c_str());
http_server_request_send_error(request, 301, "see /sim/");
return ERROR_NONE;
}
if (strncmp(uri, "/sim/api/", 9) == 0) {
// Should have matched the /sim/api/* alias in startServer(); if we
// get here the method has no alias (e.g. PUT) — 404 it.
http_server_request_send_error(request, 404, "not found");
return ERROR_UNDEFINED;
}
return handleSimViewer(request, nullptr);
}