ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
ereader_golden.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""ereader_golden.py -- golden-image regression gate for the e-reader chrome.
5
6The ``ereader_ui`` example (issue #80) paints its Library and Reading screens
7into the GLCDC framebuffer. ``tools/ra8_emulator`` renders that firmware
8framebuffer deterministically, so we can pin the chrome with checked-in golden
9images and fail CI (or the local ``just apps::emulator::golden`` recipe) when an unrelated
10change shifts a pixel.
11
12ra8_emulator's ``--ppm`` snapshot is the panel framebuffer PLUS a fixed-width debug
13sidebar on the right (LED / USB / IRQ state). The chrome golden must test the
14*firmware* output, not ra8_emulator's overlay, so every snapshot is cropped to the
15panel region (total width minus the sidebar) before it is hashed or stored. The
16golden bytes are gzipped (the flat 16-level-grayscale chrome compresses ~30x).
17
18Usage
19-----
20 ereader_golden.py check --elf E --emulator B --golden-dir D [--out-dir O]
21 ereader_golden.py update --elf E --emulator B --golden-dir D
22
23``check`` renders each screen, crops it, and compares against
24``<golden-dir>/<name>.ppm.gz``; mismatches are reported (with the actual image
25written to ``--out-dir`` for inspection) and exit status is non-zero.
26``update`` regenerates the goldens in place.
27
28The script is stdlib-only (gzip / subprocess / argparse) so it runs anywhere the
29ra8_emulator binary and the cross-built ``.elf`` exist.
30"""
31
32from __future__ import annotations
33
34import argparse
35import gzip
36import subprocess
37import sys
38import tempfile
39from pathlib import Path
40
41# Number of integer tokens in a PPM (P6) header: width, height, maxval.
42PPM_HEADER_TOKEN_COUNT = 3
43
44# Width ra8_emulator appends on the right of the panel for its status sidebar.
45# Mirrors ``k_ovl_sidebar_w`` in tools/ra8_emulator/src/display/board_overlay.c; the crop
46# removes it so the golden depends only on the firmware chrome.
47SIDEBAR_W = 520
48
49# The screens to capture: (golden name, ra8_emulator extra args). Reading is
50# reached by tapping a Library book card; keyboard by tapping the toolbar
51# Search field -- both via the genuine touch path. battery_low drives the
52# modelled fuel gauge below the critical threshold (--battery 8) so the
53# low-battery nag banner overlay is captured over the Library chrome.
54SCREENS: tuple[tuple[str, tuple[str, ...]], ...] = (
55 ("library", ()),
56 ("reading", ("--click", "250", "250")),
57 ("keyboard", ("--click", "200", "100")),
58 ("battery_low", ("--battery", "8")),
59)
60
61
62def read_ppm(path: Path) -> tuple[int, int, int, bytes]:
63 """Parse a binary (P6) PPM into (width, height, maxval, pixel-bytes)."""
64 data = path.read_bytes()
65 if data[:2] != b"P6":
66 msg = f"{path}: not a P6 PPM"
67 raise ValueError(msg)
68 idx = 2
69 tokens: list[int] = []
70 while len(tokens) < PPM_HEADER_TOKEN_COUNT:
71 while idx < len(data) and data[idx] in b" \t\n\r":
72 idx += 1
73 start = idx
74 while idx < len(data) and data[idx] not in b" \t\n\r":
75 idx += 1
76 tokens.append(int(data[start:idx]))
77 idx += 1 # single whitespace byte separating the header from the raster
78 width, height, maxval = tokens
79 pixels = data[idx : idx + width * height * 3]
80 if len(pixels) != width * height * 3:
81 msg = f"{path}: truncated raster"
82 raise ValueError(msg)
83 return width, height, maxval, pixels
84
85
86def crop_panel(width: int, height: int, maxval: int, pixels: bytes) -> bytes:
87 """Return a P6 PPM of the panel region (left ``width - SIDEBAR_W`` columns)."""
88 panel_w = width - SIDEBAR_W
89 if panel_w <= 0:
90 msg = f"width {width} <= sidebar {SIDEBAR_W}"
91 raise ValueError(msg)
92 out = bytearray()
93 for row in range(height):
94 off = row * width * 3
95 out += pixels[off : off + panel_w * 3]
96 header = b"P6\n%d %d\n%d\n" % (panel_w, height, maxval)
97 return header + bytes(out)
98
99
100def render_panel(emulator: Path, elf: Path, extra: tuple[str, ...]) -> bytes:
101 """Run ra8_emulator for one screen and return its cropped-panel PPM bytes."""
102 with tempfile.NamedTemporaryFile(suffix=".ppm") as tmp:
103 cmd = [str(emulator), str(elf), *extra, "--ppm", tmp.name]
104 # Capture as bytes, not text: ra8_emulator's diagnostic stream can carry
105 # raw bytes (e.g. 0xFF SPI idle bytes from an SD bring-up with no card),
106 # which would crash a UTF-8 text decode.
107 proc = subprocess.run(cmd, capture_output=True, check=False) # noqa: S603 # trusted: fixed ra8_emulator argv built from caller-supplied paths
108 if proc.returncode != 0:
109 err = proc.stderr.decode("utf-8", "replace")
110 msg = f"ra8_emulator failed: {' '.join(cmd)}\n{err}"
111 raise RuntimeError(msg)
112 return crop_panel(*read_ppm(Path(tmp.name)))
113
114
115def golden_path(golden_dir: Path, name: str) -> Path:
116 """Path of one screen's golden image, gzip-compressed.
117
118 One naming rule shared by the update and check paths, so the two can never
119 disagree about which file a screen owns.
120 """
121 return golden_dir / f"{name}.ppm.gz"
122
123
124def do_update(args: argparse.Namespace) -> int:
125 """Re-render every screen and overwrite its golden image.
126
127 Accepts whatever the emulator currently produces as correct, so it must
128 only be run when the change in output is understood and intended -- this
129 is the operation that can silently bless a rendering regression.
130
131 Returns 0.
132 """
133 args.golden_dir.mkdir(parents=True, exist_ok=True)
134 for name, extra in SCREENS:
135 panel = render_panel(args.emulator, args.elf, extra)
136 golden_path(args.golden_dir, name).write_bytes(gzip.compress(panel, 9))
137 print(f" updated {name}.ppm.gz ({len(panel)} bytes -> gz)")
138 return 0
139
140
141def do_check(args: argparse.Namespace) -> int:
142 """Re-render every screen and compare it against its golden image.
143
144 A MISSING golden counts as a failure rather than being created on the fly;
145 silently generating it would make the first run of a new screen pass
146 against no reference at all.
147
148 Returns the number of failing screens (0 when every screen matches).
149 """
150 failures = 0
151 for name, extra in SCREENS:
152 gpath = golden_path(args.golden_dir, name)
153 if not gpath.exists():
154 print(f" [FAIL] {name}: golden missing ({gpath}); run 'update'")
155 failures += 1
156 continue
157 actual = render_panel(args.emulator, args.elf, extra)
158 expected = gzip.decompress(gpath.read_bytes())
159 if actual == expected:
160 print(f" [PASS] {name}: chrome matches golden")
161 continue
162 failures += 1
163 msg = "size differs" if len(actual) != len(expected) else "pixels differ"
164 print(f" [FAIL] {name}: {msg} (actual {len(actual)} vs golden {len(expected)})")
165 if args.out_dir is not None:
166 args.out_dir.mkdir(parents=True, exist_ok=True)
167 out = args.out_dir / f"{name}.actual.ppm"
168 out.write_bytes(actual)
169 print(f" wrote {out} for inspection")
170 if failures != 0:
171 print(f"[FAIL] ereader chrome golden: {failures} screen(s) drifted.")
172 return 1
173 print("[PASS] ereader chrome golden: all screens match.")
174 return 0
175
176
177def main() -> int:
178 """Dispatch to the golden-image check or update mode.
179
180 The mode is a required positional with no default, deliberately: defaulting
181 to ``update`` would let a careless invocation overwrite the references it
182 was meant to test against.
183 """
184 parser = argparse.ArgumentParser(description=__doc__)
185 parser.add_argument("mode", choices=("check", "update"))
186 parser.add_argument("--elf", type=Path, required=True, help="cross-built ereader_ui.elf")
187 parser.add_argument("--emulator", type=Path, required=True, help="ra8_emulator binary")
188 parser.add_argument("--golden-dir", type=Path, required=True, help="golden image directory")
189 parser.add_argument("--out-dir", type=Path, default=None, help="where to dump mismatches")
190 args = parser.parse_args()
191
192 if not args.elf.exists():
193 print(f"error: elf not found: {args.elf}", file=sys.stderr)
194 return 2
195 if not args.emulator.exists():
196 print(f"error: ra8_emulator not found: {args.emulator}", file=sys.stderr)
197 return 2
198
199 return do_update(args) if args.mode == "update" else do_check(args)
200
201
202if __name__ == "__main__":
203 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298