#!/usr/bin/env python3 """Regression check: re-emit each shipped panel from its GxEPD2 driver and diff the result against the committed components/gxepd2/panel_*.h. Compares only the structure the emitter authoritatively produces - the INIT / WINDOW / REFRESH command sequences, plane count, plane-write command(s), window_per_plane, and plane_invert - not the hand/diag-filled fields (bits_per_pixel/palette/blank_code/plane_width/line_order), which carry comments and names the emitter doesn't reproduce. Catches two things: an emitter regression (reproduction breaks), and GxEPD2 upstream drift (a vendor bump changes a driver -> its panel now differs -> review it). Exit non-zero on any unexpected mismatch. tools/check_descriptors.py # check all tools/check_descriptors.py -v # show the diff on a mismatch tools/check_descriptors.py GxEPD2_154_Z90c # check just one (or more) models tools/check_descriptors.py GxEPD2_154_Z90c GxEPD2_270 Panels the emitter does not yet fully model (dual-region, see KNOWN_DIFF) are expected to differ and don't fail; if one of them starts MATCHING, that's an emitter improvement - remove it from KNOWN_DIFF. Panels with no GxEPD2 driver source (a synthetic model with no third_party/GxEPD2/ .cpp of its own) are skipped. """ import argparse import ast import difflib import pathlib import re import sys ROOT = pathlib.Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "tools")) import capture as cap # noqa: E402 (resolution + emit, reused) PANEL_DIR = ROOT / "components" / "gxepd2" # Panels the emitter's segmentation doesn't reproduce yet. The dual-region # GDEY0579F51 used to live here; the emitter now models the region-select layer # (per-region init/window/refresh, plane_width, paired-edges line doubling), so # it reproduces and has been promoted out. A future panel the emitter can't yet # segment goes here with a one-line why. KNOWN_DIFF: set = set() def registry(): """(model, part) for every entry in display.py's PANELS.""" tree = ast.parse((PANEL_DIR / "display.py").read_text()) for node in ast.walk(tree): if isinstance(node, ast.Assign) and any(getattr(t, "id", None) == "PANELS" for t in node.targets): for key, val in zip(node.value.keys, node.value.values): yield key.value return def parse_descriptor(text: str) -> dict: """Normalize a panel_*.h into the fields the emitter owns. Comments and array names are stripped, so only the command bytes / flags are compared.""" text = re.sub(r"//.*", "", text) text = re.sub(r"/\*.*?\*/", "", text, flags=re.S) arrs = {name: [int(x, 16) for x in re.findall(r"0x[0-9A-Fa-f]+", body)] for name, body in re.findall(r"uint8_t\s+(\w+)\[\]\s*=\s*\{([^}]*)\}", text)} seqs = {} for seqname, body in re.findall(r"EpdCommand\s+(\w+)\[\]\s*=\s*\{(.*?)\};", text, flags=re.S): cmds = [] for line in re.findall(r"\{([^}]*)\}", body): parts = [p.strip() for p in line.split(",")] op = int(parts[0], 16) data = tuple(arrs.get(parts[1], [])) if parts[1] != "nullptr" else () cmds.append((op, data, "true" in line)) role = ("INIT" if "INIT" in seqname else "WINDOW" if "WINDOW" in seqname else "REFRESH" if "REFRESH" in seqname else seqname) seqs.setdefault(role, cmds) # first WINDOW_SEQ wins (window_sequence[0]) def field(name, default="?"): m = re.search(rf"\.{name}\s*=\s*([^,\n]+)", text) return m.group(1).strip() if m else default return { "INIT": seqs.get("INIT"), "WINDOW": seqs.get("WINDOW", []), "REFRESH": seqs.get("REFRESH"), "plane_count": field("plane_count"), "plane_write_cmd": field("plane_write_cmd"), "window_per_plane": field("window_per_plane", "false"), "plane_invert": field("plane_invert"), } def check(model: str, catalog: dict, verbose: bool) -> str: """Returns 'MATCH', 'DIFF', 'XFAIL', 'XPASS', 'SKIP', or 'ERROR'.""" panel_h = PANEL_DIR / cap.filename_of(model) if not panel_h.exists(): return "SKIP" src_rel, _, _ = cap.resolve_source(model, catalog) if src_rel is None: return "SKIP" # no GxEPD2 driver to re-emit from (e.g. synthetic variant) try: generated = cap.capture(model, catalog, debug=False, cxx="g++", keep=False) except SystemExit as e: print(f" {e}", file=sys.stderr) return "ERROR" g, h = parse_descriptor(generated), parse_descriptor(panel_h.read_text()) same = g == h expected_diff = model in KNOWN_DIFF if same: return "XPASS" if expected_diff else "MATCH" if expected_diff: return "XFAIL" if verbose: for k in g: if g[k] != h[k]: print(f" {k}:\n emitted : {g[k]}\n committed: {h[k]}", file=sys.stderr) return "DIFF" def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("models", nargs="*", help="only check these registry keys (default: check every registered panel)") ap.add_argument("-v", "--verbose", action="store_true", help="show the fields that differ on a mismatch") args = ap.parse_args() all_models = list(registry()) if args.models: unknown = [m for m in args.models if m not in all_models] if unknown: for m in unknown: hint = difflib.get_close_matches(m, all_models, n=3) msg = f"not in display.py's PANELS: {m}" if hint: msg += "\ndid you mean: " + ", ".join(hint) print(msg, file=sys.stderr) sys.exit(1) models = args.models else: models = all_models catalog = cap.load_catalog() results = {} for model in models: # capture.py prints its own [capture] line to stderr; keep output tidy. res = check(model, catalog, args.verbose) results[model] = res print(f" {res:<6} {model}") bad = [m for m, r in results.items() if r in ("DIFF", "ERROR", "XPASS")] n_match = sum(1 for r in results.values() if r == "MATCH") print(f"\n{n_match} reproduced, {sum(1 for r in results.values() if r=='XFAIL')} known-diff, " f"{sum(1 for r in results.values() if r=='SKIP')} skipped.") if bad: print("\nFAILURES:") for m in bad: why = {"DIFF": "command structure changed (emitter regression or GxEPD2 drift) - review", "ERROR": "capture failed to build/run", "XPASS": "now reproduces - remove from KNOWN_DIFF"}[results[m]] print(f" {results[m]:<6} {m}: {why}") sys.exit(1) print("OK") if __name__ == "__main__": main()