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
4.0 KiB
name, description
| name | description |
|---|---|
| device-screenshot | Capture a screenshot of the live DeskLock ESP32-P4 display over USB and view it as a PNG. Use whenever you need to SEE what's on the device screen — verify a face/UI change, check the tap controls overlay, confirm a state (idle, listening, effort), or debug layout remotely without the user's camera. Triggers: "screenshot the device", "what's on the screen", "capture the display", "show me the face", "did the UI change land". |
DeskLock device screenshot over USB
Grabs the current LVGL screen off the device and rebuilds it as a PNG on the host. No camera, no gateway — it rides the USB serial that's already attached for flashing.
How it works (so you can debug it)
The firmware (main/face.c face_screenshot_dump, gated by DESKLOCK_DEVMODE
in desklock_main.c) runs a task that watches the USB-serial-JTAG RX FIFO
for a trigger byte, renders the active screen with lv_snapshot_take_to_draw_buf
into a PSRAM buffer, 2×-downscales to 400×400, and streams it as raw binary
straight to the USB-serial-JTAG TX FIFO — framed by a text header
###SHOT_BEGIN … bytes=N crc=0x… bin=1### + N bytes + ###SHOT_END###. The host
tool firmware/tools/device_shot.py sends the trigger, reads N bytes, checks the
CRC (retrying on the rare dropped frame), and writes a PNG.
Two things this design is deliberately built around, learned the hard way:
- Don't use
printf. The primary console is UART at 115200 baud (~11 KB/s) — a frame would take ~37 s. Writing straight to the USB FIFO runs at USB speed (~1 s). That's why the dump usesusb_serial_jtag_ll_write_txfifo, not stdout. - Opening the port does NOT reset the P4 (unlike esptool), and there's no USB stdin, so the trigger is a byte the firmware polls for — on-demand, no reboot, captures whatever state is currently on screen.
Prerequisites
- Firmware flashed with dev mode ON — it's OFF by default (production spends
no internal RAM on the watcher and nothing extra runs on the render path). Flash
a dev build with:
If screenshots return "no frame", the flashed build is production — reflash with
cd firmware && sg dialout -c "bash -c 'source ~/esp-idf/export.sh && idf.py -DDESKLOCK_DEVMODE=ON -p /dev/ttyACM0 flash'"-DDESKLOCK_DEVMODE=ON. Back to production:-DDESKLOCK_DEVMODE=OFF(the value sticks in the CMake cache until you flip it). Leave it ON for a UI-dev session (you're reflashing for UI changes anyway); flip OFF for the final/production flash. - Device on
/dev/ttyACM0. The port needs thedialoutgroup, so run the tool undersg dialout -c '…'(this login session predates dialout membership). - Nothing else holding the port (no
idf.py monitorrunning) — one owner only.
Take a shot
From the desklock repo root (/mnt/media/Projects/desklock):
# current screen (whatever state the device is in right now)
sg dialout -c "python3 firmware/tools/device_shot.py /tmp/shot.png"
# force the tap controls overlay in-frame (mic + volume) — for verifying it
# remotely since you can't physically tap
sg dialout -c "python3 firmware/tools/device_shot.py /tmp/shot.png --overlay"
Then Read /tmp/shot.png to view it. A clean run prints
[try 1] 400x400 320000B crc … OK and takes ~5 s.
Trigger bytes: s = current screen, o = force overlay. The tool retries up to
4× on a CRC mismatch (occasional console contention), so a transient bad frame
self-heals.
Notes / caveats
- RAM / render path: the watcher costs one ~5 KB internal-RAM task, so it's
behind
DESKLOCK_DEVMODE(off by default). Internal RAM is the scarce resource on this board (it caps the rain sprite pool). Never ship a production build with it on. - 400×400 is plenty for layout/UI checks. To change resolution, adjust
SHOT_DSinface.c(the downscale factor) — the host reads the size from the header. - If you just reflashed, wait ~7 s for boot before the first shot.
- Only the USB console path is used; this does not touch the device↔gateway WebSocket protocol.