Explorar o código

tools: one-command panel capture (capture.py)

tools/capture.py <GxEPD2_Class> resolves the driver (catalog.json, or by
globbing third_party/GxEPD2/ for one the catalog doesn't list yet), generates
and compiles the emit harness, and prints a candidate panel_*.h - collapsing
the find-source / write-main / build / run steps into one command. --list
shows capturable classes (marking the ones already shipped), --debug dumps the
capture structure, -o writes to a file. Rejects non-4-arg-constructor drivers
with a clear message and suggests close matches on a typo. Validated: regenerates
the 154/583 descriptors byte-for-byte and the OTP F52 (via glob) correctly.
Harness README now leads with it.
ZiB hai 3 semanas
pai
achega
33c0cc7bd2
Modificáronse 2 ficheiros con 192 adicións e 4 borrados
  1. 178 0
      tools/capture.py
  2. 14 4
      tools/gxepd2_harness/README.md

+ 178 - 0
tools/capture.py

@@ -0,0 +1,178 @@
+#!/usr/bin/env python3
+"""One-command panel-descriptor capture.
+
+Resolves a GxEPD2 driver class to its vendored source, builds and runs a host
+harness that *observes* the driver, and prints a candidate EpdPanelDescriptor
+(the body of a `components/gxepd2/panel_<model>.h`). No ESP toolchain needed -
+just a host C++ compiler. See tools/gxepd2_harness/README.md and claude/DESIGN.md.
+
+    tools/capture.py GxEPD2_154_Z90c             # candidate -> stdout
+    tools/capture.py GxEPD2_154_Z90c -o panel.h  # candidate -> file
+    tools/capture.py --list                      # capturable driver classes
+    tools/capture.py GxEPD2_154_Z90c --debug     # also dump the capture structure
+
+The output is a *candidate*: review it, fill any TODO fields the diag passes
+own (bits_per_pixel/palette for packed panels; plane_width/row_interleave for
+dual-region), save it as components/gxepd2/panel_<model>.h, add it to
+display.py's MODELS, compile, and bench-verify before marking it verified.
+"""
+
+import argparse
+import difflib
+import json
+import pathlib
+import re
+import subprocess
+import sys
+import tempfile
+
+ROOT = pathlib.Path(__file__).resolve().parent.parent  # code/
+CATALOG = ROOT / "tools" / "gxepd2_catalog" / "catalog.json"
+VSRC = ROOT / "third_party" / "GxEPD2" / "src"
+HARNESS = ROOT / "tools" / "gxepd2_harness"
+DISPLAY_PY = ROOT / "components" / "gxepd2" / "display.py"
+PREFIX = "GxEPD2_"
+
+
+def macro_of(model: str) -> str:
+    return "PANEL_" + model[len(PREFIX):].upper()
+
+
+def filename_of(model: str) -> str:
+    return "panel_" + model[len(PREFIX):].lower() + ".h"
+
+
+def shipped_models() -> set:
+    """Model keys already wired into display.py's MODELS, to flag in --list."""
+    try:
+        text = DISPLAY_PY.read_text()
+    except OSError:
+        return set()
+    return set(re.findall(r'"(GxEPD2_[^"]+)":\s*\(', text))
+
+
+def load_catalog() -> dict:
+    if not CATALOG.exists():
+        sys.exit(f"catalog not found: {CATALOG}\n(run tools/gxepd2_catalog/generate_catalog.py)")
+    return json.loads(CATALOG.read_text())
+
+
+def capturable(entry: dict) -> bool:
+    # The emitter instantiates Driver(cs, dc, rst, busy) - only the standard
+    # 4-arg constructor works without special-casing.
+    return entry.get("ctor_args") == 4 and (VSRC / entry["source"]).exists()
+
+
+def do_list(catalog: dict) -> None:
+    shipped = shipped_models()
+    rows = []
+    for model, entry in sorted(catalog.items()):
+        if not capturable(entry):
+            continue
+        mark = "shipped" if model in shipped else "-"
+        rows.append((model, mark, entry.get("comment", "")))
+    w = max(len(m) for m, _, _ in rows)
+    print(f"{len(rows)} capturable driver classes (ctor_args=4, source present):\n")
+    for model, mark, comment in rows:
+        print(f"  {model:<{w}}  {mark:<7}  {comment}")
+
+
+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"))
+    if len(hits) == 1:
+        return str(hits[0].relative_to(VSRC)), None
+    if len(hits) > 1:
+        sys.exit(f"{model}: multiple sources found: {[str(h) for h in hits]}")
+    return None, None
+
+
+def capture(model: str, catalog: dict, debug: bool, cxx: str, keep: bool) -> str:
+    src_rel, ctor_args = 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)
+        msg = f"unknown model: {model} (no {model}.cpp under {VSRC})"
+        if hint:
+            msg += "\ndid you mean: " + ", ".join(hint)
+        sys.exit(msg)
+    if ctor_args not in (None, 4):
+        sys.exit(f"{model}: non-standard constructor (ctor_args={ctor_args}); "
+                 "the emitter only handles the 4-arg GxEPD2_EPD(cs,dc,rst,busy) form.")
+    src = VSRC / src_rel
+    hdr_rel = src_rel[:-4] + ".h"  # epd3c/GxEPD2_154_Z90c.h
+    if not (VSRC / hdr_rel).exists():
+        sys.exit(f"driver header not found: {VSRC / hdr_rel}")
+
+    macro = macro_of(model)
+    with tempfile.TemporaryDirectory() as td:
+        tdp = pathlib.Path(td)
+        main_cpp = tdp / "emit_main.cpp"
+        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'
+        )
+        binary = tdp / "emit_main"
+        compile_cmd = [
+            cxx, "-std=c++17",
+            "-I", str(HARNESS / "spy"), "-I", str(VSRC), "-I", str(HARNESS),
+            str(main_cpp), str(VSRC / "GxEPD2_EPD.cpp"), str(src),
+            "-o", str(binary),
+        ]
+        print(f"[capture] {model}  <-  third_party/GxEPD2/src/{src_rel}", file=sys.stderr)
+        cc = subprocess.run(compile_cmd, capture_output=True, text=True)
+        if cc.returncode != 0:
+            sys.exit(f"compile failed:\n{cc.stderr}")
+        env = {"EMIT_DEBUG": "1"} if debug else None
+        run = subprocess.run([str(binary)], capture_output=True, text=True, env=env)
+        if keep:
+            kept = HARNESS / f"run_{model[len(PREFIX):].lower()}_emit.cpp"
+            kept.write_text(main_cpp.read_text())
+            print(f"[capture] kept emit main: {kept}", file=sys.stderr)
+    if debug and run.stderr:
+        print(run.stderr, file=sys.stderr)
+    if run.returncode != 0:
+        sys.exit(f"capture run failed (exit {run.returncode}):\n{run.stdout}\n{run.stderr}")
+
+    marker = "// GENERATED by emit_descriptor"
+    idx = run.stdout.find(marker)
+    if idx < 0:
+        sys.exit(f"no descriptor in output:\n{run.stdout}")
+    descriptor = run.stdout[idx:]
+    print(f"[capture] ok -> save as components/gxepd2/{filename_of(model)}, "
+          f"add \"{model}\" to display.py MODELS", file=sys.stderr)
+    return descriptor
+
+
+def main() -> None:
+    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+    ap.add_argument("model", nargs="?", help="GxEPD2 driver class, e.g. GxEPD2_154_Z90c")
+    ap.add_argument("-o", "--out", help="write the candidate descriptor to this file (default: stdout)")
+    ap.add_argument("--list", action="store_true", help="list capturable driver classes and exit")
+    ap.add_argument("--debug", action="store_true", help="dump the image-stage command/burst structure to stderr")
+    ap.add_argument("--keep", action="store_true", help="keep the generated emit main under tools/gxepd2_harness/")
+    ap.add_argument("--cxx", default="g++", help="C++ compiler (default: g++)")
+    args = ap.parse_args()
+
+    catalog = load_catalog()
+    if args.list:
+        do_list(catalog)
+        return
+    if not args.model:
+        ap.error("a model is required (or use --list)")
+    descriptor = capture(args.model, catalog, args.debug, args.cxx, args.keep)
+    if args.out:
+        pathlib.Path(args.out).write_text(descriptor)
+        print(f"[capture] wrote {args.out}", file=sys.stderr)
+    else:
+        sys.stdout.write(descriptor)
+
+
+if __name__ == "__main__":
+    main()

+ 14 - 4
tools/gxepd2_harness/README.md

@@ -9,16 +9,26 @@ Everything here runs on a dev machine with plain `g++` — no ESP toolchain.
 
 
 ## Emit a descriptor
 ## Emit a descriptor
 
 
-Each panel has a 4-line `run_<panel>_emit.cpp`. Build and run it (from the
-`code/` root):
+One command (from the `code/` root) resolves the driver, builds+runs the
+harness, and prints a candidate descriptor:
+
+```sh
+tools/capture.py --list                      # capturable driver classes
+tools/capture.py GxEPD2_154_Z90c             # candidate -> stdout
+tools/capture.py GxEPD2_154_Z90c -o /tmp/panel_154_z90c.h
+tools/capture.py GxEPD2_154_Z90c --debug     # also dump the capture structure
+```
+
+It finds the driver in `catalog.json` (or by globbing `third_party/GxEPD2/` for
+one the catalog doesn't list), so there's no build line to hand-write. Under
+the hood it does exactly:
 
 
 ```sh
 ```sh
 VSRC=third_party/GxEPD2/src
 VSRC=third_party/GxEPD2/src
 g++ -std=c++17 -I tools/gxepd2_harness/spy -I $VSRC -I tools/gxepd2_harness \
 g++ -std=c++17 -I tools/gxepd2_harness/spy -I $VSRC -I tools/gxepd2_harness \
-    tools/gxepd2_harness/run_<panel>_emit.cpp \
+    <generated emit main>.cpp \
     $VSRC/GxEPD2_EPD.cpp $VSRC/<subdir>/GxEPD2_<Class>.cpp \
     $VSRC/GxEPD2_EPD.cpp $VSRC/<subdir>/GxEPD2_<Class>.cpp \
     -o /tmp/emit_<panel>
     -o /tmp/emit_<panel>
-/tmp/emit_<panel> > /tmp/panel_<panel>.h    # candidate descriptor — review it
 ```
 ```
 
 
 `emit_descriptor.h` phase-segments the captured command stream into the
 `emit_descriptor.h` phase-segments the captured command stream into the