feat: standalone LvScreenshot library v1.0.0

Promoted from DIYStickyNotes/lib/LvScreenshot (byte-identical src) into a
reusable Gitea library. Adds LICENSE, .gitignore, library.properties,
keywords.txt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 14:56:14 +02:00
co-authored by Claude Opus 4.8
commit 80039764fa
11 changed files with 414 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.pio/
.vscode/
.idea/
__pycache__/
*.o
*.d
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 xPablo.cz
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+72
View File
@@ -0,0 +1,72 @@
# 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 1`** in your `lv_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_type` set to a
`*_opi`/`*_qspi` PSRAM variant). Without a working PSRAM heap the two ~300 KB allocations (draw buffer
+ tight-packed copy) will fail and capture returns `false`.
- You do **not** need to enlarge `LV_MEM_SIZE` or switch `LV_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)
```cpp
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)
```cpp
#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
```bash
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.
+3
View File
@@ -0,0 +1,3 @@
LvScreenshot KEYWORD1
lvScreenshotTake KEYWORD2
lvScreenshotAttachHttp KEYWORD2
+11
View File
@@ -0,0 +1,11 @@
{
"name": "LvScreenshot",
"version": "1.0.0",
"description": "Board-independent LVGL 9 screen capture (lv_snapshot_take -> tight RGB565) plus an optional AsyncWebServer /screenshot endpoint and a PC tool that saves a PNG.",
"keywords": "lvgl, screenshot, snapshot, capture, png, esp32",
"frameworks": "arduino",
"platforms": "espressif32",
"headers": "LvScreenshot.hpp",
"license": "MIT",
"build": { "srcDir": "src" }
}
+9
View File
@@ -0,0 +1,9 @@
name=LvScreenshot
version=1.0.0
author=xPablo.cz
maintainer=xPablo.cz <https://git.xpablo.cz/xPablo.cz>
sentence=Board-independent LVGL 9 screen capture with an optional /screenshot web endpoint.
paragraph=lv_snapshot_take -> tight RGB565, an optional AsyncWebServer GET /screenshot endpoint, and a PC tool that saves a PNG.
category=Display
url=https://git.xpablo.cz/xPablo.cz/LvScreenshot
architectures=esp32
+77
View File
@@ -0,0 +1,77 @@
#include "LvScreenshot.hpp"
#include <cstring>
// Prefer PSRAM on ESP32 (a full-frame RGB565 buffer is ~300 KB and won't fit in internal RAM); fall
// back to standard malloc on other platforms so the core stays portable.
#if defined(ESP_PLATFORM)
#include <esp_heap_caps.h>
static void * ssMalloc(size_t n) {
void * p = heap_caps_malloc(n, MALLOC_CAP_SPIRAM);
return p ? p : heap_caps_malloc(n, MALLOC_CAP_8BIT);
}
static void ssFree(void * p) { heap_caps_free(p); }
#else
#include <cstdlib>
static void * ssMalloc(size_t n) { return malloc(n); }
static void ssFree(void * p) { free(p); }
#endif
bool lvScreenshotCapture(lv_obj_t * obj, LvScreenshot * out) {
if (out) { out->data = nullptr; out->width = 0; out->height = 0; out->length = 0; }
if (!obj || !out) return false;
#if LV_USE_SNAPSHOT
// Render into a PSRAM-backed draw buffer supplied by us, rather than lv_snapshot_take()'s own
// allocation: LVGL's builtin malloc pool (LV_STDLIB_BUILTIN) is far too small for a full-frame
// (~300 KB) buffer, so lv_snapshot_take() would fail. lv_snapshot_take_to_draw_buf() reshapes and
// fills a buffer we own. Size it exactly as LVGL does (obj size; +slack rows to cover any
// ext_draw_size), same stride formula LVGL will pick for LV_STRIDE_AUTO.
const int32_t ow = lv_obj_get_width(obj);
const int32_t oh = lv_obj_get_height(obj);
if (ow <= 0 || oh <= 0) return false;
const uint32_t stride = lv_draw_buf_width_to_stride((uint32_t) ow, LV_COLOR_FORMAT_RGB565);
const uint32_t hAlloc = (uint32_t) oh + 16; // slack so a small ext_draw_size still fits
const size_t dataSz = (size_t) stride * hAlloc;
uint8_t * raw = static_cast<uint8_t *>(ssMalloc(dataSz + LV_DRAW_BUF_ALIGN));
if (!raw) return false;
void * data = lv_draw_buf_align(raw, LV_COLOR_FORMAT_RGB565);
lv_draw_buf_t db;
if (lv_draw_buf_init(&db, (uint32_t) ow, (uint32_t) oh, LV_COLOR_FORMAT_RGB565, stride,
data, dataSz) != LV_RESULT_OK) { ssFree(raw); return false; }
if (lv_snapshot_take_to_draw_buf(obj, LV_COLOR_FORMAT_RGB565, &db) != LV_RESULT_OK) {
ssFree(raw); return false;
}
const uint32_t w = db.header.w; // reshape may have grown these by ext_draw_size
const uint32_t h = db.header.h;
const uint32_t rstride = db.header.stride;
const size_t len = (size_t) w * h * 2;
uint8_t * outbuf = static_cast<uint8_t *>(ssMalloc(len));
if (!outbuf) { ssFree(raw); return false; }
const uint8_t * src = static_cast<const uint8_t *>(db.data);
for (uint32_t y = 0; y < h; ++y) // tight-pack: drop any stride padding
std::memcpy(outbuf + (size_t) y * w * 2, src + (size_t) y * rstride, (size_t) w * 2);
ssFree(raw);
out->data = outbuf; out->width = w; out->height = h; out->length = len;
return true;
#else
#warning "LvScreenshot: LV_USE_SNAPSHOT is 0 -- capture always fails. Enable LV_USE_SNAPSHOT in lv_conf.h."
(void) obj;
return false;
#endif
}
bool lvScreenshotCaptureTop(LvScreenshot * out) {
lv_obj_t * top = lv_layer_top();
lv_obj_t * obj = (top && lv_obj_get_child_count(top) > 0) ? top : lv_screen_active();
return lvScreenshotCapture(obj, out);
}
void lvScreenshotFree(LvScreenshot * shot) {
if (!shot) return;
if (shot->data) { ssFree(shot->data); shot->data = nullptr; }
shot->width = 0; shot->height = 0; shot->length = 0;
}
+36
View File
@@ -0,0 +1,36 @@
/**
* @file LvScreenshot.hpp
* @brief Board-independent LVGL screen capture. Renders any lv_obj (typically the active screen)
* into a tightly-packed RGB565 buffer via lv_snapshot_take(). Works with ANY LVGL 9 display
* driver -- partial-flush, full-frame, RGB panel -- because it re-renders the object tree
* rather than reading a panel framebuffer. Requires LV_USE_SNAPSHOT=1 in your lv_conf.h.
*/
#pragma once
#include <lvgl.h>
#include <cstddef>
#include <cstdint>
/** A captured image: tightly-packed RGB565 (2 bytes/pixel, row-major, no stride padding). */
struct LvScreenshot {
uint8_t * data; ///< RGB565 pixels; nullptr if capture failed. Free with lvScreenshotFree().
uint32_t width; ///< pixels
uint32_t height; ///< pixels
size_t length; ///< bytes (== width * height * 2)
};
/**
* @brief Capture @p obj and its subtree as tight RGB565 into @p out.
* @warning The caller MUST hold the LVGL lock (LVGL is single-threaded) for the whole call.
* @return true on success (then free @p out->data with lvScreenshotFree); false on failure (@p out
* is zeroed). Fails and warns at compile time if LV_USE_SNAPSHOT is disabled.
*/
bool lvScreenshotCapture(lv_obj_t * obj, LvScreenshot * out);
/**
* @brief Capture whatever is visible on top: lv_layer_top() when it has children (an active overlay),
* otherwise lv_screen_active(). The caller MUST hold the LVGL lock.
*/
bool lvScreenshotCaptureTop(LvScreenshot * out);
/** @brief Free a buffer from a successful capture and zero the struct. Safe on a zeroed/failed one. */
void lvScreenshotFree(LvScreenshot * shot);
+63
View File
@@ -0,0 +1,63 @@
#include "LvScreenshotHttp.hpp"
// Self-disable if the project has no ESPAsyncWebServer, so the core library stays dependency-free.
#if defined(__has_include)
# if __has_include(<ESPAsyncWebServer.h>)
# define LVSS_HAVE_ASYNC_WEB 1
# endif
#endif
#if defined(LVSS_HAVE_ASYNC_WEB)
#include <ESPAsyncWebServer.h>
#include "LvScreenshot.hpp"
#include <cstring>
namespace {
// One shared capture buffer: the async response streams from it over ~1 s, so it must outlive the
// handler. It is freed and re-taken on the next request. This assumes a single sequential client
// (the intended tools/screenshot.py workflow); two truly concurrent requests could tear the image
// -- just retry. A busy flag was intentionally omitted (an aborted transfer would wedge it).
LvScreenshot g_shot = {nullptr, 0, 0, 0};
LvScreenshotLockFn g_lock = nullptr;
LvScreenshotUnlockFn g_unlock = nullptr;
}
void lvScreenshotAttachHttp(AsyncWebServer * www, LvScreenshotLockFn lock,
LvScreenshotUnlockFn unlock, const char * path) {
if (!www) return;
g_lock = lock;
g_unlock = unlock;
www->on(path ? path : "/screenshot", HTTP_GET, [](AsyncWebServerRequest * req) {
lvScreenshotFree(&g_shot); // release any previous capture
bool ok;
if (g_lock && g_lock(-1)) { // capture under the LVGL lock (not mid-flush)
ok = lvScreenshotCaptureTop(&g_shot);
if (g_unlock) g_unlock();
} else {
ok = lvScreenshotCaptureTop(&g_shot); // no lock hook provided: best-effort
}
if (!ok || !g_shot.data) {
req->send(500, "text/plain", "screenshot: capture failed");
return;
}
// Stream the buffer in chunks; g_shot stays valid until the next request.
AsyncWebServerResponse * resp = req->beginResponse(
"application/octet-stream", g_shot.length,
[](uint8_t * out, size_t maxLen, size_t index) -> size_t {
const size_t remaining = g_shot.length - index;
const size_t n = remaining < maxLen ? remaining : maxLen;
std::memcpy(out, g_shot.data + index, n);
return n;
});
resp->addHeader("X-Width", String(g_shot.width));
resp->addHeader("X-Height", String(g_shot.height));
resp->addHeader("X-Format", "RGB565");
req->send(resp);
});
}
#else
// ESPAsyncWebServer not available in this project: no-op so the core LvScreenshot still builds.
void lvScreenshotAttachHttp(AsyncWebServer *, LvScreenshotLockFn, LvScreenshotUnlockFn, const char *) {}
#endif
+27
View File
@@ -0,0 +1,27 @@
/**
* @file LvScreenshotHttp.hpp
* @brief OPTIONAL AsyncWebServer glue for LvScreenshot: registers a GET route that returns the
* current LVGL screen as raw RGB565 with X-Width/X-Height/X-Format headers. Kept separate from
* the core so a project that does not use ESPAsyncWebServer can ignore it; the implementation
* compiles to a no-op when <ESPAsyncWebServer.h> is unavailable.
*/
#pragma once
class AsyncWebServer;
/** LVGL-lock hooks (e.g. lvgl_port_lock / lvgl_port_unlock); the capture runs while locked. */
typedef bool (*LvScreenshotLockFn)(int timeout_ms);
typedef bool (*LvScreenshotUnlockFn)(void);
/**
* @brief Register @p path (default "/screenshot") on @p www: on GET, capture the topmost LVGL layer
* and stream it as raw RGB565 with X-Width/X-Height/X-Format headers.
* @param www the AsyncWebServer to attach to.
* @param lock LVGL-lock acquire hook (held across the capture); may be nullptr for best-effort.
* @param unlock LVGL-lock release hook; may be nullptr.
* @param path route path (default "/screenshot").
*
* Pair with tools/screenshot.py to save a PNG. No-op if ESPAsyncWebServer is not available.
*/
void lvScreenshotAttachHttp(AsyncWebServer * www, LvScreenshotLockFn lock,
LvScreenshotUnlockFn unlock, const char * path = "/screenshot");
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.9"
# dependencies = [
# "requests",
# "numpy",
# "pillow",
# ]
# ///
"""Fetch an LVGL device's screen via the LvScreenshot HTTP endpoint and save it as a PNG.
The firmware must expose the endpoint (register it with lvScreenshotAttachHttp, e.g. behind a
build flag). The device returns the raw RGB565 framebuffer plus X-Width / X-Height / X-Format
headers; this script converts it to RGB888 and writes a PNG.
Dependencies are declared inline (PEP 723) and installed automatically by uv -- no manual setup.
Usage (via uv):
uv run screenshot.py [--host diystickynotes.local] [--out shot.png] [--swap-rb] [--byteswap]
# the shebang also lets you run it directly: ./screenshot.py ...
--swap-rb : swap red/blue if the panel uses BGR order (colours look inverted).
--byteswap : byte-swap each 16-bit pixel if the panel stores RGB565 big-endian (garbled image).
"""
import argparse
import sys
import numpy as np
import requests
from PIL import Image
def main() -> int:
"""Fetch the screen, convert RGB565 -> RGB888, and write a PNG. Returns a process exit code."""
ap = argparse.ArgumentParser(description="Capture an LVGL device screen as a PNG.")
ap.add_argument("--host", default="diystickynotes.local", help="device hostname or IP")
ap.add_argument("--out", default="shot.png", help="output PNG path")
ap.add_argument("--path", default="/screenshot", help="endpoint path")
ap.add_argument("--swap-rb", action="store_true", help="swap R/B (BGR panel)")
ap.add_argument("--byteswap", action="store_true", help="byte-swap 16-bit pixels")
ap.add_argument("--timeout", type=float, default=15.0, help="HTTP timeout seconds")
args = ap.parse_args()
url = f"http://{args.host}{args.path}"
try:
r = requests.get(url, timeout=args.timeout)
except requests.RequestException as e:
print(f"error: request to {url} failed: {e}", file=sys.stderr)
return 1
if r.status_code != 200:
print(f"error: HTTP {r.status_code}: {r.text[:200]}", file=sys.stderr)
return 1
try:
w = int(r.headers["X-Width"])
h = int(r.headers["X-Height"])
fmt = r.headers.get("X-Format", "RGB565")
except (KeyError, ValueError) as e:
print(f"error: missing/invalid dimension headers: {e}", file=sys.stderr)
return 1
if fmt != "RGB565":
print(f"error: unsupported X-Format '{fmt}' (expected RGB565)", file=sys.stderr)
return 1
data = r.content
expect = w * h * 2
if len(data) != expect:
print(f"error: body is {len(data)} bytes, expected {expect} ({w}x{h} RGB565)", file=sys.stderr)
return 1
dtype = ">u2" if args.byteswap else "<u2"
px = np.frombuffer(data, dtype=dtype).reshape((h, w)).astype(np.uint32)
r5 = (px >> 11) & 0x1F
g6 = (px >> 5) & 0x3F
b5 = px & 0x1F
# Expand to 8-bit with bit replication so full white maps to 255.
r8 = ((r5 << 3) | (r5 >> 2)).astype(np.uint8)
g8 = ((g6 << 2) | (g6 >> 4)).astype(np.uint8)
b8 = ((b5 << 3) | (b5 >> 2)).astype(np.uint8)
if args.swap_rb:
r8, b8 = b8, r8
rgb = np.dstack([r8, g8, b8])
Image.fromarray(rgb, "RGB").save(args.out)
print(f"saved {args.out} ({w}x{h})")
return 0
if __name__ == "__main__":
sys.exit(main())