Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LvScreenshot
Board-independent LVGL 9 screen capture as a reusable PlatformIO library: grab the current screen
into a tight RGB565 buffer via lv_snapshot_take(), optionally serve it over HTTP, and save it as
a PNG on your PC. Because it re-renders the LVGL object tree (not the panel framebuffer), it works with
any display driver — partial-flush (LovyanGFX), full-frame (Arduino_GFX), or RGB panel.
Requirements
- LVGL 9 with
LV_USE_SNAPSHOT 1in yourlv_conf.h(the core warns at compile time otherwise). - PSRAM must be enabled/available on ESP32 — see "Memory / PSRAM" below. This is essential.
- The optional HTTP helper needs
ESPAsyncWebServer; it self-disables (no-op) when that is absent.
Memory / PSRAM (important)
A full-frame snapshot is large — e.g. 320×480 RGB565 = ~300 KB. LVGL's stock lv_snapshot_take()
allocates that from LVGL's own heap, which with LV_USE_STDLIB_MALLOC = LV_STDLIB_BUILTIN (the
default) is a small fixed pool (LV_MEM_SIZE). It cannot fit ~300 KB, so lv_snapshot_take() simply
returns null and the capture fails — the symptom that must be worked around whenever capture goes
through LVGL.
This library avoids that: instead of letting LVGL allocate, it allocates the draw buffer itself from
PSRAM (heap_caps_malloc(MALLOC_CAP_SPIRAM) on ESP32, plain malloc elsewhere) and renders into
it via lv_snapshot_take_to_draw_buf(). Consequences for integrators:
- Enable PSRAM in your build (e.g.
-DBOARD_HAS_PSRAM,board_build.arduino.memory_typeset to a*_opi/*_qspiPSRAM variant). Without a working PSRAM heap the two ~300 KB allocations (draw buffer- tight-packed copy) will fail and capture returns
false.
- tight-packed copy) will fail and capture returns
- You do not need to enlarge
LV_MEM_SIZEor switchLV_USE_STDLIB_MALLOC— the library deliberately bypasses LVGL's allocator for the snapshot buffer for exactly this reason. - Peak transient usage is ~2× the frame (~600 KB) during a capture; both buffers are freed right after (the HTTP helper keeps one until the next request so it can stream it).
Core API (LvScreenshot.hpp, LVGL-only)
LvScreenshot shot;
if (lvgl_port_lock(-1)) { // the caller must hold the LVGL lock
bool ok = lvScreenshotCaptureTop(&shot); // topmost overlay, else the active screen
lvgl_port_unlock();
if (ok) { /* shot.data = RGB565, shot.width/height/length */ lvScreenshotFree(&shot); }
}
lvScreenshotCapture(obj, &shot) captures a specific object. Buffers use PSRAM on ESP32.
Optional HTTP endpoint (LvScreenshotHttp.hpp, needs ESPAsyncWebServer)
#include <LvScreenshotHttp.hpp>
// in your web-server setup:
lvScreenshotAttachHttp(www, lvgl_port_lock, lvgl_port_unlock); // registers GET /screenshot
The route returns the raw RGB565 body plus X-Width / X-Height / X-Format headers.
PC tool
uv run tools/screenshot.py --host diystickynotes.local --out shot.png
# --swap-rb if colours look inverted (BGR panel)
# --byteswap if the image is garbled (big-endian RGB565)
Converts the RGB565 response to a PNG (numpy + Pillow; deps auto-installed via uv / PEP 723).
Gating it to dev builds (recommended)
Keep production lean: flip LV_USE_SNAPSHOT on only under a build flag, and only call
lvScreenshotAttachHttp under the same flag, so normal builds are byte-identical and expose no
endpoint. See the DIYStickyNotes [env:screenshot_*] envs for an example.
License
MIT (code). Screenshots you capture are your own content.