feat(dev): device screenshot over USB

Adds a way to capture the live LVGL screen off the device and rebuild it
as a PNG on the host, so UI changes can be verified remotely without a
camera. A watcher task polls the USB-serial-JTAG RX for a trigger byte and
streams the current screen as raw RGB565 straight to the USB FIFO (framed
by ###SHOT_BEGIN/END### with a CRC); firmware/tools/device_shot.py and the
device-screenshot skill drive it from the host.

Writing straight to the USB FIFO bypasses the primary UART console, which
at 115200 baud would take ~37s per frame. The whole capability is behind
DESKLOCK_DEVMODE (off by default, enable at deploy time with
-DDESKLOCK_DEVMODE=ON) so production spends no internal RAM on the watcher
and nothing extra runs on the render path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
This commit is contained in:
2026-07-15 20:24:41 +02:00
co-authored by Claude Opus 4.8
parent 23f001ceb5
commit 9521deddc5
7 changed files with 306 additions and 0 deletions
+11
View File
@@ -17,3 +17,14 @@ idf_component_register(
)
target_compile_definitions(${COMPONENT_LIB} PRIVATE LV_LVGL_H_INCLUDE_SIMPLE)
# Dev-only features (screenshot watcher). OFF in production so nothing extra runs
# on the render path / spends internal RAM. Deploy dev with:
# idf.py -DDESKLOCK_DEVMODE=ON build flash
# and back to production with -DDESKLOCK_DEVMODE=OFF (the value sticks in the
# CMake cache until you flip it).
option(DESKLOCK_DEVMODE "Enable dev-only features (screenshot watcher)" OFF)
if(DESKLOCK_DEVMODE)
target_compile_definitions(${COMPONENT_LIB} PRIVATE DESKLOCK_DEVMODE=1)
message(STATUS "DeskLock: DEVMODE ON — screenshot watcher enabled")
endif()
+1
View File
@@ -22,6 +22,7 @@ void face_set(face_state_t state); /* safe from any task */
face_state_t face_get(void);
void face_activity(void);
void face_status(const char *text); /* bottom status line (diag/boot) */ /* reset the power ladder to active */
void face_screenshot_dump(bool overlay); /* dev: raw RGB565 screen dump over USB */
/* audio.c */
void audio_init(void);
+37
View File
@@ -30,6 +30,16 @@
* 0 = normal app. */
#define FACE_LOADTEST 0
/* Dev mode: enabled at deploy time with `idf.py -DDESKLOCK_DEVMODE=ON build flash`
* (see main/CMakeLists.txt), OFF by default in production. Currently gates only the
* screenshot watcher — a task that watches the USB-serial-JTAG RX for a trigger
* byte and dumps the current screen (see the device-screenshot skill /
* firmware/tools/device_shot.py). Off by default so production spends no internal
* RAM on it and nothing extra runs on the render path. */
#ifndef DESKLOCK_DEVMODE
#define DESKLOCK_DEVMODE 0
#endif
static const char *TAG = "desklock";
/* All utterance framing (blocking WS sends, chime, face) runs on app_task, never
@@ -232,6 +242,29 @@ void sdio_tx_selftest_kick(void)
}
#endif
#if DESKLOCK_DEVMODE
#include "hal/usb_serial_jtag_ll.h"
/* Watch the USB-serial-JTAG RX FIFO (LL reads, no driver install -> no conflict
* with the secondary console's TX) for a trigger byte and dump one frame. */
static void screenshot_task(void *arg)
{
(void)arg;
vTaskDelay(pdMS_TO_TICKS(6000)); /* let boot + first render settle */
ESP_LOGI(TAG, "screenshot: ready — send 's' on USB serial to capture a frame");
uint8_t rx[16];
for (;;) {
if (usb_serial_jtag_ll_rxfifo_data_available()) {
int n = usb_serial_jtag_ll_read_rxfifo(rx, sizeof(rx));
for (int i = 0; i < n; i++) {
if (rx[i] == 's') { face_screenshot_dump(false); break; } /* current screen */
if (rx[i] == 'o') { face_screenshot_dump(true); break; } /* force overlay */
}
}
vTaskDelay(pdMS_TO_TICKS(150));
}
}
#endif
void app_main(void)
{
ESP_LOGI(TAG, "DeskLock starting");
@@ -287,4 +320,8 @@ void app_main(void)
#if FACE_LOADTEST
xTaskCreate(loadtest_task, "loadtest", 4096, NULL, 4, NULL);
#endif
#if DESKLOCK_DEVMODE
xTaskCreate(screenshot_task, "shot", 5120, NULL, 4, NULL);
#endif
}
+70
View File
@@ -7,8 +7,12 @@
#include <string.h>
#include <time.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_random.h"
#include "esp_heap_caps.h"
#include "esp_rom_crc.h"
#include "hal/usb_serial_jtag_ll.h"
#include "bsp/esp-bsp.h"
#include "lvgl.h"
@@ -701,3 +705,69 @@ void face_activity(void)
bsp_display_unlock();
}
}
/* --- dev screenshot over USB (see the device-screenshot skill) --------------- */
/* Blocking write straight to the USB-serial-JTAG TX FIFO. Bypasses stdout/printf
* — the primary console is UART at 115200 baud (~11 KB/s), far too slow for a
* frame; the USB FIFO runs at USB speed. Waits for FIFO space so nothing drops. */
static void shot_usb_write(const uint8_t *data, size_t len)
{
size_t sent = 0;
while (sent < len) {
if (!usb_serial_jtag_ll_txfifo_writable()) { vTaskDelay(1); continue; }
sent += usb_serial_jtag_ll_write_txfifo(data + sent, len - sent);
usb_serial_jtag_ll_txfifo_flush();
}
}
/* Render the live screen to an RGB565 buffer, 2x-downscale it, and stream it as
* RAW BINARY straight over the USB-serial-JTAG, framed by a ###SHOT_BEGIN ...###
* text header (carrying byte count + CRC) and ###SHOT_END###, so the host can
* rebuild a PNG. `overlay` forces the tap controls in-frame. Called only from the
* DESKLOCK_DEVMODE screenshot watcher; not part of normal operation. */
#define SHOT_DS 2 /* downscale factor (2 -> 400x400) */
void face_screenshot_dump(bool overlay)
{
/* Snapshot into our OWN PSRAM buffer — the default lv_snapshot_take() allocs
* the 1.28 MB frame from the small LVGL heap and returns NULL. */
const int W = SCREEN, H = SCREEN;
const uint32_t stride = lv_draw_buf_width_to_stride(W, LV_COLOR_FORMAT_RGB565);
const size_t buf_size = (size_t)stride * H;
uint8_t *mem = heap_caps_malloc(buf_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!mem) { printf("###SHOT_FAIL mem###\n"); return; }
lv_draw_buf_t dbuf;
lv_draw_buf_init(&dbuf, W, H, LV_COLOR_FORMAT_RGB565, stride, mem, buf_size);
bsp_display_lock(UINT32_MAX);
if (overlay) { /* force the tap overlay in-frame */
controls_show();
lv_timer_pause(F.ctl_timer); /* don't let it auto-hide mid-capture */
}
lv_result_t rc = lv_snapshot_take_to_draw_buf(lv_screen_active(), LV_COLOR_FORMAT_RGB565, &dbuf);
bsp_display_unlock();
if (rc != LV_RESULT_OK) { printf("###SHOT_FAIL take=%d###\n", (int)rc); heap_caps_free(mem); return; }
const int ow = W / SHOT_DS, oh = H / SHOT_DS;
const size_t olen = (size_t)ow * oh * 2;
uint8_t *out = heap_caps_malloc(olen, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!out) { printf("###SHOT_FAIL malloc###\n"); heap_caps_free(mem); return; }
for (int y = 0; y < oh; y++) {
const uint16_t *src = (const uint16_t *)(dbuf.data + (size_t)(y * SHOT_DS) * dbuf.header.stride);
uint16_t *dst = (uint16_t *)(out + (size_t)y * ow * 2);
for (int x = 0; x < ow; x++) dst[x] = src[x * SHOT_DS];
}
heap_caps_free(mem);
const uint32_t crc = esp_rom_crc32_le(0, out, olen);
char hdr[128];
int hlen = snprintf(hdr, sizeof(hdr),
"\n###SHOT_BEGIN w=%d h=%d fmt=rgb565le bytes=%u crc=0x%08x bin=1###\n",
ow, oh, (unsigned)olen, (unsigned)crc);
shot_usb_write((const uint8_t *)hdr, hlen);
shot_usb_write(out, olen); /* raw RGB565-LE, exactly `bytes` long */
static const char end[] = "\n###SHOT_END###\n";
shot_usb_write((const uint8_t *)end, sizeof(end) - 1);
heap_caps_free(out);
}