Files
MeshTerm/scripts/cardputer-zero/screenshot.py
T
Jean-Pierre MartineauandClaude Opus 5.5 cc626160d4 The store listing has its six screenshots, taken on the Cardputer
scripts/cardputer-zero/screenshot.py reads the display's framebuffer
over SSH and writes it as a PNG of 320x170, the size the store wants.
It only reads, so it does not disturb the app. JP set up each screen
on the device: the splash, the menu, the Public channel, a contact's
routes, the map, and a packet from the live feed. The listing passes
the store's own metadata check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 03:00:19 -04:00

109 lines
4.1 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""Save the display of a Cardputer Zero as a PNG, from another computer.
The script reads the framebuffer of the display over SSH and writes it as a PNG of the
display's own size (320×170). The image is exactly what the app wrote, pixel for pixel:
there is no window, no frame, and no scale. This is the image that M5Stack's app store asks
for as a screenshot.
python scripts/cardputer-zero/screenshot.py --host pi@192.168.0.165 menu.png
The script reads the size, the stride, and the depth of the framebuffer from sysfs. Thus it
does not assume the layout. It reads only the framebuffer. It does not press a key, and the
app does not know that a picture was taken.
Necessary: ``ssh`` on this computer, and a Cardputer user in the ``video`` group (the
default user ``pi`` is in this group).
"""
from __future__ import annotations
import argparse
import struct
import subprocess
import sys
import zlib
from pathlib import Path
#: The program that runs on the Cardputer: the geometry from sysfs on the first line, then
#: the raw bytes of the visible part of the framebuffer.
REMOTE = r"""
set -e
dev=$1
name=$(basename "$dev")
sys=/sys/class/graphics/$name
size=$(cat "$sys/virtual_size")
stride=$(cat "$sys/stride")
bpp=$(cat "$sys/bits_per_pixel")
height=${size#*,}
echo "$size $stride $bpp"
head -c $((stride * height)) "$dev"
"""
def rgb565_to_rgb(raw: bytes, width: int, height: int, stride: int) -> bytes:
"""Change little-endian RGB565 rows into 24-bit RGB rows of ``width`` pixels."""
out = bytearray()
for y in range(height):
row = raw[y * stride : y * stride + width * 2]
for (value,) in struct.iter_unpack("<H", row):
r, g, b = value >> 11, (value >> 5) & 0x3F, value & 0x1F
# Copy the high bits into the low bits, so that full intensity stays 255.
out += bytes(((r << 3) | (r >> 2), (g << 2) | (g >> 4), (b << 3) | (b >> 2)))
return bytes(out)
def png(rgb: bytes, width: int, height: int) -> bytes:
"""Encode 24-bit RGB rows as a PNG, with no library."""
def chunk(kind: bytes, data: bytes) -> bytes:
body = kind + data
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body))
rows = b"".join(b"\x00" + rgb[y * width * 3 : (y + 1) * width * 3] for y in range(height))
header = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
return (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", header)
+ chunk(b"IDAT", zlib.compress(rows, 9))
+ chunk(b"IEND", b"")
)
def main() -> int:
"""Read the framebuffer over SSH, and write the PNG."""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("output", type=Path, help="the PNG file to write")
parser.add_argument("--host", required=True, help="the Cardputer, as user@host for ssh")
parser.add_argument("-i", "--identity", help="the private key for ssh")
parser.add_argument("--fb", default="/dev/fb0", help="the framebuffer (default /dev/fb0)")
args = parser.parse_args()
command = ["ssh", "-o", "BatchMode=yes"]
if args.identity:
command += ["-i", str(Path(args.identity).expanduser())]
command += [args.host, "sh", "-s", "--", args.fb]
done = subprocess.run(command, input=REMOTE.encode(), capture_output=True, check=False)
if done.returncode != 0:
sys.stderr.write(done.stderr.decode(errors="replace"))
return 1
head, _, raw = done.stdout.partition(b"\n")
size, stride, bpp = head.decode().split()
width, height = (int(n) for n in size.split(","))
if bpp != "16":
sys.stderr.write(f"screenshot: the framebuffer has {bpp} bits a pixel, not 16\n")
return 1
if len(raw) < int(stride) * height:
sys.stderr.write(f"screenshot: got {len(raw)} bytes, not {int(stride) * height}\n")
return 1
rgb = rgb565_to_rgb(raw, width, height, int(stride))
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_bytes(png(rgb, width, height))
print(f"wrote {args.output} ({width}x{height})")
return 0
if __name__ == "__main__":
sys.exit(main())