feat(sim): web viewer at /sim + touch injection at /api/sim/touch

- GET /sim: live viewer page, 2s screenshot refresh, click-to-touch
- POST /api/sim/touch?x,y,down: injects into sdl-pointer backend,
  auto-releases after 1.5s; simulator-only (404 on ESP32)
- sdl_input: file-scope touch override state with extern C setters
This commit is contained in:
Adolfo Reyna
2026-09-14 21:02:54 -04:00
parent 620d56e19a
commit 2060095028
4 changed files with 144 additions and 1 deletions
+40 -1
View File
@@ -14,6 +14,34 @@ constexpr size_t KEY_QUEUE_CAPACITY = 32;
SdlPointerState pointer_state = { 0, 0, false }; SdlPointerState pointer_state = { 0, 0, false };
} // namespace
// Web touch-injection override (headless sim viewer). Guarded by tick count so
// a press auto-releases even if the viewer never sends the release event.
// File-scope (not anonymous namespace): the extern "C" setters below must be
// visible to the linker for WebServerService's touch endpoint.
bool touch_override_active = false;
SdlPointerState touch_override = { 0, 0, false };
uint32_t touch_override_until_tick = 0;
#define SIM_TOUCH_HOLD_MS 1500
extern "C" void sdl_input_set_touch_override(int32_t x, int32_t y, bool pressed) {
touch_override.x = x;
touch_override.y = y;
touch_override.pressed = pressed;
touch_override_active = pressed;
if (pressed) {
touch_override_until_tick = SDL_GetTicks() + SIM_TOUCH_HOLD_MS;
}
}
extern "C" void sdl_input_clear_touch_override(void) {
touch_override_active = false;
touch_override.pressed = false;
}
namespace {
uint32_t key_queue[KEY_QUEUE_CAPACITY]; uint32_t key_queue[KEY_QUEUE_CAPACITY];
size_t key_queue_head = 0; size_t key_queue_head = 0;
size_t key_queue_count = 0; size_t key_queue_count = 0;
@@ -70,7 +98,7 @@ uint32_t keycode_to_key(SDL_Keycode sdl_key, bool shift) {
} }
} }
} } // namespace
void sdl_input_pump() { void sdl_input_pump() {
if (!text_input_started) { if (!text_input_started) {
@@ -125,6 +153,17 @@ void sdl_input_pump() {
} }
void sdl_input_get_pointer_state(SdlPointerState* out_state) { void sdl_input_get_pointer_state(SdlPointerState* out_state) {
if (touch_override_active) {
// Auto-release: viewer sends press only; LVGL needs press then release
// to register a click. Hold long enough for several indev polls.
if ((int32_t)(SDL_GetTicks() - touch_override_until_tick) >= 0) {
touch_override_active = false;
touch_override.pressed = false;
} else {
*out_state = touch_override;
return;
}
}
*out_state = pointer_state; *out_state = pointer_state;
} }
@@ -42,6 +42,16 @@ bool sdl_input_pop_key(uint32_t* out_key);
*/ */
bool sdl_input_has_queued_key(void); bool sdl_input_has_queued_key(void);
/**
* @brief Web-injected touch override (for headless sim viewer without SDL window).
* When active, sdl_pointer_get_touched_points() reports this state instead of
* the SDL mouse state. Coordinates are in LVGL logical pixels (e.g. 640x480).
* Pass pressed=false to release. Auto-releases after SIM_TOUCH_HOLD_MS unless
* refreshed (tap = press, wait, release handled by the web viewer).
*/
void sdl_input_set_touch_override(int32_t x, int32_t y, bool pressed);
void sdl_input_clear_touch_override(void);
#ifdef __cplusplus #ifdef __cplusplus
} }
#endif #endif
@@ -81,6 +81,8 @@ private:
static error_t handleApiAppsInstall(struct HttpServerRequest* request, void* user_ctx); static error_t handleApiAppsInstall(struct HttpServerRequest* request, void* user_ctx);
static error_t handleApiWifi(struct HttpServerRequest* request, void* user_ctx); static error_t handleApiWifi(struct HttpServerRequest* request, void* user_ctx);
static error_t handleApiScreenshot(struct HttpServerRequest* request, void* user_ctx); static error_t handleApiScreenshot(struct HttpServerRequest* request, void* user_ctx);
static error_t handleApiSimTouch(struct HttpServerRequest* request, void* user_ctx);
static error_t handleSimViewer(struct HttpServerRequest* request, void* user_ctx);
#ifdef ESP_PLATFORM #ifdef ESP_PLATFORM
static error_t handleApiMcp(struct HttpServerRequest* request, void* user_ctx); static error_t handleApiMcp(struct HttpServerRequest* request, void* user_ctx);
static error_t handleApiScreenRaw(struct HttpServerRequest* request, void* user_ctx); static error_t handleApiScreenRaw(struct HttpServerRequest* request, void* user_ctx);
@@ -31,6 +31,13 @@
#include "app/install.h" #include "app/install.h"
#include "app/manager.h" #include "app/manager.h"
#ifndef ESP_PLATFORM
// Simulator touch injection backend (Devices/simulator sdl-pointer driver).
// Declared extern "C" here instead of including the device header: Tactility
// must not depend upward on a device implementation.
extern "C" void sdl_input_set_touch_override(int32_t x, int32_t y, bool pressed);
#endif
#ifdef ESP_PLATFORM #ifdef ESP_PLATFORM
#include <Tactility/mcp/McpSystem.h> #include <Tactility/mcp/McpSystem.h>
#include <Tactility/settings/McpSettings.h> #include <Tactility/settings/McpSettings.h>
@@ -215,7 +222,11 @@ bool WebServerService::onStart(ServiceContext& service) {
lock.lock(); lock.lock();
g_cachedSettings = settings::webserver::loadOrGetDefault(); g_cachedSettings = settings::webserver::loadOrGetDefault();
g_settingsCached = true; g_settingsCached = true;
#ifdef ESP_PLATFORM
serverEnabled = g_cachedSettings.webServerEnabled || settings::mcp::loadOrGetDefault().mcpEnabled; serverEnabled = g_cachedSettings.webServerEnabled || settings::mcp::loadOrGetDefault().mcpEnabled;
#else
serverEnabled = g_cachedSettings.webServerEnabled;
#endif
} }
// Subscribe to settings change events to refresh cache // Subscribe to settings change events to refresh cache
settingsEventSubscription = pubsub->subscribe([](WebServerEvent event) { settingsEventSubscription = pubsub->subscribe([](WebServerEvent event) {
@@ -224,7 +235,11 @@ bool WebServerService::onStart(ServiceContext& service) {
lock.lock(); lock.lock();
g_cachedSettings = settings::webserver::loadOrGetDefault(); g_cachedSettings = settings::webserver::loadOrGetDefault();
g_settingsCached = true; g_settingsCached = true;
#ifdef ESP_PLATFORM
const bool enabled = g_cachedSettings.webServerEnabled || settings::mcp::loadOrGetDefault().mcpEnabled; const bool enabled = g_cachedSettings.webServerEnabled || settings::mcp::loadOrGetDefault().mcpEnabled;
#else
const bool enabled = g_cachedSettings.webServerEnabled;
#endif
if (g_webServerInstance.load() != nullptr) { if (g_webServerInstance.load() != nullptr) {
g_webServerInstance.load()->setEnabled(enabled); g_webServerInstance.load()->setEnabled(enabled);
} }
@@ -1058,6 +1073,9 @@ error_t WebServerService::handleApiGet(HttpServerRequest* request, void* user_ct
if (strncmp(uri, "/api/apps", 9) == 0) { if (strncmp(uri, "/api/apps", 9) == 0) {
return handleApiApps(request, user_ctx); return handleApiApps(request, user_ctx);
} }
if (strncmp(uri, "/api/sim/touch", 14) == 0) {
return handleApiSimTouch(request, user_ctx);
}
if (strncmp(uri, "/api/wifi", 9) == 0) { if (strncmp(uri, "/api/wifi", 9) == 0) {
return handleApiWifi(request, user_ctx); return handleApiWifi(request, user_ctx);
} }
@@ -1083,6 +1101,9 @@ error_t WebServerService::handleApiPost(HttpServerRequest* request, void* user_c
if (strncmp(uri, "/api/apps/run", 13) == 0) { if (strncmp(uri, "/api/apps/run", 13) == 0) {
return handleApiAppsRun(request, user_ctx); return handleApiAppsRun(request, user_ctx);
} }
if (strncmp(uri, "/api/sim/touch", 14) == 0) {
return handleApiSimTouch(request, user_ctx);
}
if (strncmp(uri, "/api/apps/uninstall", 19) == 0) { if (strncmp(uri, "/api/apps/uninstall", 19) == 0) {
return handleApiAppsUninstall(request, user_ctx); return handleApiAppsUninstall(request, user_ctx);
} }
@@ -1764,6 +1785,72 @@ error_t WebServerService::handleReboot(HttpServerRequest* request, void*) {
return ERROR_NONE; // Unreachable on ESP_PLATFORM, but satisfies function signature return ERROR_NONE; // Unreachable on ESP_PLATFORM, but satisfies function signature
} }
// 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.
error_t WebServerService::handleApiSimTouch(HttpServerRequest* request, void*) {
#ifdef ESP_PLATFORM
http_server_request_send_error(request, 404, "simulator only");
return ERROR_UNDEFINED;
#else
std::string sx, sy, sdown;
int x = -1, y = -1;
if (getQueryParam(request, "x", sx)) x = atoi(sx.c_str());
if (getQueryParam(request, "y", sy)) y = atoi(sy.c_str());
bool down = true;
if (getQueryParam(request, "down", sdown)) down = (sdown != "0");
if (x < 0 || y < 0 || x > 4096 || y > 4096) {
http_server_request_send_error(request, 400, "x and y params required (0..4096)");
return ERROR_UNDEFINED;
}
sdl_input_set_touch_override((int32_t)x, (int32_t)y, down);
LOG_I(TAG, "[200] /api/sim/touch x=%d y=%d down=%d", x, y, (int)down);
http_server_request_send_string(request, "ok");
return ERROR_NONE;
#endif
}
// GET /sim - live simulator viewer: MJPEG-style screenshot refresh + click-to-touch.
error_t WebServerService::handleSimViewer(HttpServerRequest* request, void*) {
bool authPassed = false;
error_t authResult = validateRequestAuth(request, authPassed);
if (!authPassed) {
return authResult;
}
http_server_request_set_content_type(request, "text/html");
static const char* page =
"<!doctype html><html><head><meta charset=utf-8><meta name=viewport "
"content='width=device-width,initial-scale=1'>"
"<title>Tactility Sim</title><style>"
"body{margin:0;background:#111;color:#eee;font-family:sans-serif;"
"display:flex;flex-direction:column;align-items:center;gap:8px;padding:12px}"
"img{width:min(96vw,720px);image-rendering:pixelated;border:1px solid #333;"
"border-radius:8px;touch-action:none;cursor:crosshair;background:#000}"
".bar{display:flex;gap:8px;align-items:center;font-size:13px;color:#aaa}"
"button{background:#313244;color:#fff;border:0;border-radius:8px;padding:6px 12px}"
"</style></head><body>"
"<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>"
"<script>"
"const img=document.getElementById('scr'),st=document.getElementById('st');"
"function snap(){img.src='/api/screenshot?ts='+Date.now();}"
"setInterval(snap,2000);snap();"
"img.addEventListener('error',()=>{st.textContent='reconnecting…';});"
"img.addEventListener('load',()=>{st.textContent='live';});"
"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'});});"
"</script></body></html>";
http_server_request_send_string(request, page);
return ERROR_NONE;
}
error_t WebServerService::handleAssets(HttpServerRequest* request, void*) { error_t WebServerService::handleAssets(HttpServerRequest* request, void*) {
// Auth check for UI access control // Auth check for UI access control
bool authPassed = false; bool authPassed = false;
@@ -1776,6 +1863,11 @@ error_t WebServerService::handleAssets(HttpServerRequest* request, void*) {
http_server_request_get_uri(request, uri, sizeof(uri)); http_server_request_get_uri(request, uri, sizeof(uri));
LOG_I(TAG, "GET %s", uri); LOG_I(TAG, "GET %s", uri);
// Simulator live viewer (no auth bypass: checked inside handler)
if (strncmp(uri, "/sim", 4) == 0 && (uri[4] == '\0' || uri[4] == '?' || uri[4] == '/')) {
return handleSimViewer(request, nullptr);
}
// Special case: serve favicon from system assets // Special case: serve favicon from system assets
if (strcmp(uri, "/favicon.ico") == 0) { if (strcmp(uri, "/favicon.ico") == 0) {
std::string faviconPathStr = std::string(file::MOUNT_POINT_SYSTEM) + "/spinner.png"; std::string faviconPathStr = std::string(file::MOUNT_POINT_SYSTEM) + "/spinner.png";