Files
LvScreenshot/tools/screenshot.py
T
pablo2048andClaude Opus 4.8 80039764fa 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>
2026-07-15 14:56:14 +02:00

90 lines
3.3 KiB
Python

#!/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())