Quellcode durchsuchen

tools: descriptor regression check (emitter + GxEPD2-drift guard)

tools/check_descriptors.py re-emits every shipped panel from its GxEPD2 driver
and structurally diffs against the committed panel_*.h (INIT/WINDOW/REFRESH
sequences, plane layout, window_per_plane, plane_invert - not the hand/diag
fields). Exits non-zero on an unexpected mismatch, so it drops into CI as a
plain step. A DIFF is an emitter regression or GxEPD2 upstream drift; the
dual-region 579 the emitter doesn't model yet is an expected XFAIL, and the
synthetic MASTER_ONLY variant (no driver source) is skipped. Currently: 7
reproduced, 1 known-diff, 1 skipped.

Also makes capture.py case-insensitive and class-name-correct: the model: key
can differ in case from the GxEPD2 class (GxEPD2_213_Z98C vs GxEPD2_213_Z98c),
so resolve the real class from the catalog/source and instantiate that. (The
regression check surfaced this.)
ZiB vor 3 Wochen
Ursprung
Commit
8ceaf9ee74
3 geänderte Dateien mit 174 neuen und 11 gelöschten Zeilen
  1. 16 11
      tools/capture.py
  2. 141 0
      tools/check_descriptors.py
  3. 17 0
      tools/gxepd2_harness/README.md

+ 16 - 11
tools/capture.py

@@ -78,22 +78,27 @@ def do_list(catalog: dict) -> None:
 
 
 def resolve_source(model: str, catalog: dict):
-    """(source relpath, ctor_args|None). Catalog first (it has ctor_args), else
-    glob the vendored tree - so a driver the fork added but GxEPD2's example
-    headers don't list (e.g. the F52) is still capturable."""
-    if model in catalog:
-        e = catalog[model]
-        return e["source"], e.get("ctor_args")
-    hits = list(VSRC.glob(f"**/{model}.cpp"))
+    """(source relpath, ctor_args|None, real_class_name) or (None, None, None).
+
+    Catalog first (it has ctor_args), else glob the vendored tree - so a driver
+    the fork added but GxEPD2's example headers don't list (e.g. the F52) is
+    still capturable. Matching is case-insensitive and returns the driver's
+    REAL class name (the `model:` key may differ in case, e.g. GxEPD2_213_Z98C
+    vs the class GxEPD2_213_Z98c)."""
+    low = model.lower()
+    for k, e in catalog.items():
+        if k.lower() == low:
+            return e["source"], e.get("ctor_args"), e.get("driver", k)
+    hits = [p for p in VSRC.glob("**/GxEPD2_*.cpp") if p.stem.lower() == low]
     if len(hits) == 1:
-        return str(hits[0].relative_to(VSRC)), None
+        return str(hits[0].relative_to(VSRC)), None, hits[0].stem
     if len(hits) > 1:
         sys.exit(f"{model}: multiple sources found: {[str(h) for h in hits]}")
-    return None, None
+    return None, None, None
 
 
 def capture(model: str, catalog: dict, debug: bool, cxx: str, keep: bool) -> str:
-    src_rel, ctor_args = resolve_source(model, catalog)
+    src_rel, ctor_args, real_class = resolve_source(model, catalog)
     if src_rel is None:
         names = set(catalog) | {p.stem for p in VSRC.glob("**/GxEPD2_*.cpp")}
         hint = difflib.get_close_matches(model, names, n=3)
@@ -116,7 +121,7 @@ def capture(model: str, catalog: dict, debug: bool, cxx: str, keep: bool) -> str
         main_cpp.write_text(
             f'#include "emit_descriptor.h"\n'
             f'#include "{hdr_rel}"\n'
-            f'int main() {{ emit::emit_descriptor<{model}>("{model}", "{macro}"); return 0; }}\n'
+            f'int main() {{ emit::emit_descriptor<{real_class}>("{real_class}", "{macro}"); return 0; }}\n'
         )
         binary = tdp / "emit_main"
         compile_cmd = [

+ 141 - 0
tools/check_descriptors.py

@@ -0,0 +1,141 @@
+#!/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/row_interleave), 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
+
+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 (e.g. the synthetic MASTER_ONLY variant) are skipped.
+"""
+
+import argparse
+import ast
+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"
+
+# Dual-region panels the emitter's single/shared-region segmentation doesn't
+# model yet (its init/refresh region-select layer). Expected to differ.
+KNOWN_DIFF = {"GxEPD2_579c_GDEY0579F51"}
+
+
+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("-v", "--verbose", action="store_true", help="show the fields that differ on a mismatch")
+    args = ap.parse_args()
+
+    catalog = cap.load_catalog()
+    results = {}
+    for model in registry():
+        # 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()

+ 17 - 0
tools/gxepd2_harness/README.md

@@ -41,6 +41,23 @@ as `components/gxepd2/panel_<panel>.h` and wire it into `display.py`'s `MODELS`.
 
 `EMIT_DEBUG=1 /tmp/emit_<panel>` dumps the image-stage command/burst structure.
 
+## Regression check (CI)
+
+`tools/check_descriptors.py` re-emits every shipped panel from its GxEPD2
+driver and structurally diffs the result against the committed `panel_*.h`
+(command sequences + plane layout only, not the hand/diag-filled fields). It
+exits non-zero on any unexpected mismatch, so wire it into CI as a plain step:
+
+```sh
+tools/check_descriptors.py        # all panels; -v shows the diff on a mismatch
+```
+
+A `DIFF` means either an emitter regression or **GxEPD2 upstream drift** (a
+vendor bump changed that driver — review the panel). Dual-region panels the
+emitter doesn't fully model yet are listed in its `KNOWN_DIFF` and reported as
+`XFAIL`; panels with no GxEPD2 driver source (the synthetic `MASTER_ONLY`
+variant) are skipped. Run it after any `third_party/GxEPD2/` update.
+
 ## Other programs
 
 - `run_<panel>.cpp` + `run_harness.h` — bare-driver capture plus the