markqvist___RNode_Firmware/scripts/screenshot.py
GlassOnTin 54618f2f2d Fix custom font rendering, tearing, and scrollbar visibility
Custom 96px font was not rendering because lv_font_conv defaults
to compressed bitmap format (bitmap_format=1) which requires
LV_USE_FONT_COMPRESSED. Regenerated with --no-compress.

Eliminated scroll tearing by switching from partial-strip rendering
to full-frame double buffering (LV_DISPLAY_RENDER_MODE_FULL).
Each buffer is 412KB in PSRAM (824KB total). Removes the need for
a separate byte-swap buffer — using LV_COLOR_FORMAT_RGB565_SWAPPED
so LVGL renders directly in the display's native byte order.

Also: hidden tileview scrollbar, enabled PSRAM in build flags.
2026-03-28 13:59:52 +00:00

108 lines
2.9 KiB
Python
Executable file

#!/usr/bin/env python3
"""
R-Watch Screenshot — capture display via USB serial
Sends trigger bytes, firmware dumps shadow framebuffer as raw RGB565.
Handles KISS protocol data interleaved in the stream.
Usage:
./scripts/screenshot.py # default port + output
./scripts/screenshot.py -p /dev/ttyACM4 # specify port
./scripts/screenshot.py -o /tmp/watch.png # specify output
"""
import argparse
import struct
import sys
import time
WIDTH = 410
HEIGHT = 502
PIXEL_BYTES = WIDTH * HEIGHT * 2 # 411,640 bytes
MAGIC = b"RWSS"
HEADER_SIZE = 8 # RWSS + uint16 width + uint16 height
def capture(port, output_path):
try:
import serial
except ImportError:
sys.exit("pip install pyserial")
try:
from PIL import Image
except ImportError:
sys.exit("pip install Pillow")
s = serial.Serial(port, 115200, timeout=1)
# Drain any pending data
time.sleep(0.2)
s.reset_input_buffer()
# Send trigger
s.write(MAGIC)
s.flush()
# Scan stream for the magic response header
# The firmware may send KISS frames before/after the screenshot data
buf = b""
deadline = time.time() + 10
magic_idx = -1
while time.time() < deadline:
chunk = s.read(max(1, s.in_waiting))
if not chunk:
continue
buf += chunk
magic_idx = buf.find(MAGIC)
if magic_idx >= 0 and len(buf) >= magic_idx + HEADER_SIZE:
break
if magic_idx < 0:
s.close()
sys.exit(f"Magic not found in {len(buf)} bytes of response")
# Parse header
hdr = buf[magic_idx:magic_idx + HEADER_SIZE]
w, h = struct.unpack("<HH", hdr[4:8])
expected = w * h * 2
# Collect pixel data (may already have some in buf)
data = buf[magic_idx + HEADER_SIZE:]
while len(data) < expected and time.time() < deadline:
chunk = s.read(min(expected - len(data), 32768))
if chunk:
data += chunk
s.close()
received = len(data)
if received < expected:
print(f"Warning: got {received}/{expected} bytes ({received*100//expected}%)")
# Convert RGB565 BE (swapped) to PNG
img = Image.new("RGB", (w, h))
pixels = img.load()
npx = min(w * h, received // 2)
for i in range(npx):
pixel = struct.unpack_from(">H", data, i * 2)[0]
r = ((pixel >> 11) & 0x1F) * 255 // 31
g = ((pixel >> 5) & 0x3F) * 255 // 63
b = (pixel & 0x1F) * 255 // 31
pixels[i % w, i // w] = (r, g, b)
img.save(output_path)
print(f"Saved: {output_path} ({w}x{h}, {npx} pixels, {received} bytes)")
def main():
parser = argparse.ArgumentParser(description="R-Watch screenshot")
parser.add_argument("-p", "--port", default="/dev/ttyACM4")
parser.add_argument("-o", "--output", default="/tmp/watch_screenshot.png")
args = parser.parse_args()
capture(args.port, args.output)
if __name__ == "__main__":
main()