commit 80039764fad9b50de971ce0242bb656611dc97e9 Author: Pavel Brychta Date: Wed Jul 15 14:56:14 2026 +0200 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fcecabd --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.pio/ +.vscode/ +.idea/ +__pycache__/ +*.o +*.d diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6e0fe7b --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0953f0d --- /dev/null +++ b/README.md @@ -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 +// 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. diff --git a/keywords.txt b/keywords.txt new file mode 100644 index 0000000..d81ce5f --- /dev/null +++ b/keywords.txt @@ -0,0 +1,3 @@ +LvScreenshot KEYWORD1 +lvScreenshotTake KEYWORD2 +lvScreenshotAttachHttp KEYWORD2 diff --git a/library.json b/library.json new file mode 100644 index 0000000..d368fde --- /dev/null +++ b/library.json @@ -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" } +} diff --git a/library.properties b/library.properties new file mode 100644 index 0000000..c3379c2 --- /dev/null +++ b/library.properties @@ -0,0 +1,9 @@ +name=LvScreenshot +version=1.0.0 +author=xPablo.cz +maintainer=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 diff --git a/src/LvScreenshot.cpp b/src/LvScreenshot.cpp new file mode 100644 index 0000000..eefde6d --- /dev/null +++ b/src/LvScreenshot.cpp @@ -0,0 +1,77 @@ +#include "LvScreenshot.hpp" +#include + +// 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 +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 +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(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(ssMalloc(len)); + if (!outbuf) { ssFree(raw); return false; } + const uint8_t * src = static_cast(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; +} diff --git a/src/LvScreenshot.hpp b/src/LvScreenshot.hpp new file mode 100644 index 0000000..1cfe62c --- /dev/null +++ b/src/LvScreenshot.hpp @@ -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 +#include +#include + +/** 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); diff --git a/src/LvScreenshotHttp.cpp b/src/LvScreenshotHttp.cpp new file mode 100644 index 0000000..684715c --- /dev/null +++ b/src/LvScreenshotHttp.cpp @@ -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() +# define LVSS_HAVE_ASYNC_WEB 1 +# endif +#endif + +#if defined(LVSS_HAVE_ASYNC_WEB) +#include +#include "LvScreenshot.hpp" +#include + +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 diff --git a/src/LvScreenshotHttp.hpp b/src/LvScreenshotHttp.hpp new file mode 100644 index 0000000..439cfe8 --- /dev/null +++ b/src/LvScreenshotHttp.hpp @@ -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 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"); diff --git a/tools/screenshot.py b/tools/screenshot.py new file mode 100644 index 0000000..c1c0cd1 --- /dev/null +++ b/tools/screenshot.py @@ -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 "> 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())