feat(sim): web viewer /sim, touch injection, fast screenshot, SIM_DISPLAY_W/H
This commit is contained in:
@@ -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 · 640x480 logical</div>"
|
||||
"<div class=bar>click/tap the screen to touch · <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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user