From 52654d37bac2dec0216aa05ba337592ef3cc47cb Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Thu, 1 Oct 2026 18:13:00 -0400 Subject: [PATCH] A skill takes a screenshot of the PicoCalc's screen over SSH It reads the device's framebuffer, so the picture is exactly what the panel shows, whatever is on the console, and saves it as a PNG in the Downloads folder (2x by default, so it stays crisp when shared). The device half asks the framebuffer driver for its size and colour layout instead of assuming them; the desktop half needs only Python's standard library. Connection details come from .dev.env. Written while the PicoCalc was offline and tested against a simulated framebuffer; the skill says so until a real run confirms it. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit fea0875210d00de43acf245e8f27ad4a50491385) --- .claude/skills/screenshot-picocalc/SKILL.md | 85 +++++++ .../scripts/picocalc_shot.py | 226 ++++++++++++++++++ 2 files changed, 311 insertions(+) create mode 100644 .claude/skills/screenshot-picocalc/SKILL.md create mode 100644 .claude/skills/screenshot-picocalc/scripts/picocalc_shot.py diff --git a/.claude/skills/screenshot-picocalc/SKILL.md b/.claude/skills/screenshot-picocalc/SKILL.md new file mode 100644 index 0000000..4e3f287 --- /dev/null +++ b/.claude/skills/screenshot-picocalc/SKILL.md @@ -0,0 +1,85 @@ +--- +name: screenshot-picocalc +description: Take a screenshot of the PicoCalc's screen remotely over SSH and save it as a PNG in the Downloads folder. Use when JP asks to screenshot, grab, capture, or snap the PicoCalc (or "the pico") screen, wants a picture of what the device is showing for docs, Discord, or an issue, or asks what is on the PicoCalc's screen right now. +--- + +# Screenshot the PicoCalc + +Grabs exactly what the PicoCalc's panel is showing and saves it as a PNG in JP's Downloads +folder. It reads the device's framebuffer (`/dev/fb0`) over SSH, so it captures whatever is +on the console (MeshTerm, a login prompt, a shell) without installing anything on the +device or disturbing what is running there. + +## Taking the shot + +From the repo root (any Python 3.8+ works; the script uses only the standard library): + +```sh +python .claude/skills/screenshot-picocalc/scripts/picocalc_shot.py +``` + +The last line it prints is the saved file, for example +`C:\Users\…\Downloads\picocalc-2026-10-01-153012.png`. **Then Read that PNG** so you can +see it: confirm it shows what JP expected before reporting back, and tell JP the path. + +Options: + +- `--scale N`: whole-pixel upscale. The default is 2 (640×640), which stays crisp when + shared; `--scale 1` keeps the native 320×320. +- `--out PATH`: save somewhere else. A `.png` path is used as the file name; anything + else is treated as a folder. +- `--rotate 90|180|270`: turn the image clockwise, in case the framebuffer turns out to be + mounted sideways relative to the panel. + +The screenshot is of whatever is on screen *now*, so if JP wants a particular MeshTerm +screen, they navigate to it on the device first. + +## Reaching the device + +The host, user, key, and password come from `.dev.env` at the repo root (gitignored; +`.dev.env.example` shows the shape): `DEV_PICOCALC_HOST`, `DEV_PICOCALC_USER`, +`DEV_PICOCALC_SSH_KEY`, `DEV_PICOCALC_PASSWORD`. A `DEV_*` environment variable of the same +name wins over the file. Never paste these values into the skill or a commit. + +## When it goes wrong + +- **"can't reach … over SSH"**: the PicoCalc is off, asleep, or off Wi-Fi. Its Wi-Fi + takes about 36 seconds after boot to come up and can drop for minutes at a time, so a + single failure is not a verdict. Ask JP to check the device, then try again. +- **The device side fails with a `sudo` error**: the login user can't read `/dev/fb0`, so + the script fell back to `sudo` with `DEV_PICOCALC_PASSWORD`, and that failed. The + permanent fix is a one-time membership in the `video` group, made by editing + `/etc/group` (BusyBox's `addgroup` misparses), run on the device by JP: + + ```sh + echo "$DEV_PICOCALC_PASSWORD" | sudo -S sed -i '/^video:/{/:$/!s/$/,/;s/$/'"$DEV_PICOCALC_USER"'/}' /etc/group + ``` + + That appends the user to the `video` line, with a comma only if it already has members. + Check it with `grep ^video: /etc/group`. It takes effect at the next SSH login. +- **"the frame is entirely black"**: the console has blanked. Ask JP to press a key on the + device, then take the shot again. +- **The colours look wrong**: the script reads the colour layout from the framebuffer + driver rather than assuming it, so this would be a driver reporting the wrong layout. + Compare against the panel before changing anything. + +The checkerboard flicker in the panel's top two rows will not appear in the screenshot. +That is an electrical artefact of the panel, not something in the framebuffer, so the +screenshot is cleaner than the device itself. That is expected; don't try to reproduce it. + +**Not yet run against the device**: the script was written while the PicoCalc was +offline and tested only against a simulated framebuffer. On the first real run, check the +orientation and the colours against the panel, then delete this note. + +## How it works + +`scripts/picocalc_shot.py` sends a short Python program to the device, base64-encoded so +no shell quoting can mangle it. On the device, that program asks the framebuffer driver for +the visible resolution, the panning offset, and where each colour channel sits in a pixel +(`FBIOGET_VSCREENINFO`), then reads only the visible rows and writes back one JSON header +line followed by the raw pixels. On the desktop, the script turns those into an RGB PNG +and saves it to the real Downloads folder (asked from Windows, since that folder can be +moved). + +For a *text* snapshot of the console instead, which is useful for diffing two renders, +the `profile-picocalc` skill's `drive_console.py` reads `/dev/vcs1`. diff --git a/.claude/skills/screenshot-picocalc/scripts/picocalc_shot.py b/.claude/skills/screenshot-picocalc/scripts/picocalc_shot.py new file mode 100644 index 0000000..0f18f98 --- /dev/null +++ b/.claude/skills/screenshot-picocalc/scripts/picocalc_shot.py @@ -0,0 +1,226 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Take a screenshot of the PicoCalc's panel over SSH and save it as a PNG. + +The panel shows whatever the Linux framebuffer (`/dev/fb0`) holds, so reading that device +is a pixel-exact screenshot of whatever is on the console: MeshTerm, a login prompt, a +shell. Nothing is installed on the device and nothing running there is disturbed. + +The device half is a short Python program sent inline (base64, so no shell quoting can +mangle it). It asks the driver for the visible resolution, the panning offset and where +each colour channel sits in a pixel (`FBIOGET_VSCREENINFO`), reads only the visible +rows, and writes one JSON header line followed by the raw pixels. The desktop half turns +those into an RGB PNG with nothing but the standard library, so any Python 3.8+ runs it. + +Connection details come from `.dev.env` at the repo root (or `DEV_*` environment +variables, which win): `DEV_PICOCALC_HOST`, `DEV_PICOCALC_USER`, `DEV_PICOCALC_SSH_KEY`, +and `DEV_PICOCALC_PASSWORD` for the `sudo` fallback when the login user cannot read the +framebuffer itself. + + python picocalc_shot.py # -> ~/Downloads/picocalc-2026-10-01-153012.png + python picocalc_shot.py --scale 1 # native 320x320 + python picocalc_shot.py --out shot.png # somewhere else + +Prints the saved path as its last line of stdout; everything else goes to stderr. +""" + +from __future__ import annotations + +import argparse +import base64 +import datetime +import json +import os +import shlex +import struct +import subprocess +import sys +import zlib +from pathlib import Path + +REPO = Path(__file__).resolve().parents[4] + +# Runs on the device. `%(fb)s` is the framebuffer's name (fb0). The pixel channel layout +# is read from the driver rather than assumed, so a BGR panel or a 32-bit mode comes out +# in the right colours without a flag. +DEVICE = r""" +import fcntl, json, struct, sys +name = %(fb)r +with open("/sys/class/graphics/%%s/stride" %% name) as s: + stride = int(s.read()) +with open("/dev/" + name, "rb") as f: + var = fcntl.ioctl(f, 0x4600, bytes(160)) # FBIOGET_VSCREENINFO + xres, yres, _xv, _yv, xoff, yoff, bpp, _gray = struct.unpack_from("8I", var, 0) + chans = struct.unpack_from("9I", var, 32) # red, green, blue: offset, length, msb_right + f.seek(yoff * stride) + raw = f.read(yres * stride) +size = bpp // 8 +left, right = xoff * size, (xoff + xres) * size +rows = b"".join(raw[r * stride + left : r * stride + right] for r in range(yres)) +head = {"w": xres, "h": yres, "bpp": bpp, + "red": chans[0:2], "green": chans[3:5], "blue": chans[6:8]} +out = sys.stdout.buffer +out.write(json.dumps(head).encode() + b"\n") +out.write(rows) +out.flush() +""" + + +def load_env() -> dict[str, str]: + """`.dev.env`'s values, overridden by any `DEV_*` variable already in the environment.""" + env: dict[str, str] = {} + path = REPO / ".dev.env" + if path.exists(): + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + env[key.strip()] = value.strip().strip("'\"") + env.update({k: v for k, v in os.environ.items() if k.startswith("DEV_")}) + return env + + +def remote_command(fb: str, password: str | None) -> str: + """The shell line run on the device: read directly if allowed, else through sudo.""" + code = base64.b64encode((DEVICE % {"fb": fb}).encode()).decode() + boot = f"import base64;exec(base64.b64decode('{code}'))" + direct = 'python3 -c "$C"' + if password: + fallback = f"printf '%s\\n' {shlex.quote(password)} | sudo -S -p '' {direct}" + else: + fallback = f"sudo -n {direct}" + return f"C={shlex.quote(boot)}; if [ -r /dev/{fb} ]; then {direct}; else {fallback}; fi" + + +def grab(env: dict[str, str], fb: str) -> bytes: + host = env.get("DEV_PICOCALC_HOST") + user = env.get("DEV_PICOCALC_USER") + if not host or not user: + sys.exit("picocalc_shot: DEV_PICOCALC_HOST and DEV_PICOCALC_USER must be set in .dev.env") + args = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10"] + key = env.get("DEV_PICOCALC_SSH_KEY") + if key and Path(os.path.expanduser(key)).exists(): + args += ["-i", os.path.expanduser(key)] + args += [f"{user}@{host}", remote_command(fb, env.get("DEV_PICOCALC_PASSWORD"))] + try: + done = subprocess.run(args, capture_output=True, timeout=60) + except subprocess.TimeoutExpired: + sys.exit(f"picocalc_shot: {host} stopped answering mid-capture") + if done.returncode == 255: + sys.exit(f"picocalc_shot: can't reach {user}@{host} over SSH — is the PicoCalc on and " + f"on Wi-Fi?\n{done.stderr.decode(errors='replace').strip()}") + if done.returncode != 0: + sys.exit(f"picocalc_shot: the device side failed (exit {done.returncode}):\n" + f"{done.stderr.decode(errors='replace').strip()}") + return done.stdout + + +def decode(payload: bytes) -> tuple[int, int, list[bytes]]: + """Header + raw framebuffer pixels -> (width, height, one 3-byte RGB value per pixel).""" + header, _, body = payload.partition(b"\n") + head = json.loads(header) + w, h, bpp = head["w"], head["h"], head["bpp"] + size = bpp // 8 + if bpp not in (16, 24, 32) or len(body) != w * h * size: + sys.exit(f"picocalc_shot: unexpected framebuffer ({w}x{h} at {bpp} bpp, " + f"{len(body)} bytes)") + channels = [head["red"], head["green"], head["blue"]] + + def rgb(value: int) -> bytes: + out = bytearray(3) + for i, (offset, length) in enumerate(channels): + top = (1 << length) - 1 + out[i] = ((value >> offset) & top) * 255 // top if top else 0 + return bytes(out) + + if bpp == 16: + lut = [rgb(v) for v in range(1 << 16)] + pixels = [lut[v] for v in struct.unpack(f"<{w * h}H", body)] + elif bpp == 32: + pixels = [rgb(v) for v in struct.unpack(f"<{w * h}I", body)] + else: + pixels = [rgb(int.from_bytes(body[i:i + 3], "little")) for i in range(0, len(body), 3)] + return w, h, pixels + + +def arrange(w: int, h: int, px: list[bytes], rotate: int, scale: int) -> tuple[int, int, list[bytes]]: + """Rotate clockwise by `rotate` degrees, then scale up by whole pixels. Returns rows.""" + if rotate == 90: + grid = [[px[(h - 1 - c) * w + r] for c in range(h)] for r in range(w)] + elif rotate == 180: + flat = px[::-1] + grid = [flat[r * w:(r + 1) * w] for r in range(h)] + elif rotate == 270: + grid = [[px[c * w + (w - 1 - r)] for c in range(h)] for r in range(w)] + else: + grid = [px[r * w:(r + 1) * w] for r in range(h)] + rows = [] + for row in grid: + line = b"".join(p * scale for p in row) + rows.extend([line] * scale) + return len(grid[0]) * scale, len(grid) * scale, rows + + +def png(w: int, h: int, rows: list[bytes]) -> bytes: + def chunk(kind: bytes, data: bytes) -> bytes: + body = kind + data + return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF) + + raw = b"".join(b"\x00" + row for row in rows) + return (b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw, 9)) + + chunk(b"IEND", b"")) + + +def downloads() -> Path: + """The user's real Downloads folder — Windows lets it be moved, so ask the shell.""" + if sys.platform == "win32": + try: + import ctypes + import uuid + + folder = (ctypes.c_ubyte * 16).from_buffer_copy( + uuid.UUID("374DE290-123F-4565-9164-39C4925E467B").bytes_le) + path = ctypes.c_wchar_p() + if ctypes.windll.shell32.SHGetKnownFolderPath( + ctypes.byref(folder), 0, None, ctypes.byref(path)) == 0: + found = Path(path.value) + ctypes.windll.ole32.CoTaskMemFree(path) + return found + except (OSError, AttributeError): + pass + return Path.home() / "Downloads" + + +def main() -> None: + parser = argparse.ArgumentParser(description="Screenshot the PicoCalc's panel over SSH.") + parser.add_argument("--out", type=Path, + help="file or folder to save to (default: your Downloads folder)") + parser.add_argument("--scale", type=int, default=2, + help="whole-pixel upscale, 1 for native size (default 2)") + parser.add_argument("--rotate", type=int, choices=(0, 90, 180, 270), default=0, + help="rotate clockwise, if the framebuffer is mounted sideways") + parser.add_argument("--fb", default="fb0", help="framebuffer device (default fb0)") + opts = parser.parse_args() + if opts.scale < 1: + parser.error("--scale must be at least 1") + + w, h, pixels = decode(grab(load_env(), opts.fb)) + w, h, rows = arrange(w, h, pixels, opts.rotate, opts.scale) + + name = datetime.datetime.now().strftime("picocalc-%Y-%m-%d-%H%M%S.png") + target = opts.out or downloads() + if target.suffix.lower() != ".png": + target = target / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(png(w, h, rows)) + if not any(p != b"\x00\x00\x00" for p in pixels): + print("picocalc_shot: the frame is entirely black — the console may be blanked", + file=sys.stderr) + print(f"{w}x{h} from the panel", file=sys.stderr) + print(target) + + +if __name__ == "__main__": + main()