capture.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. #!/usr/bin/env python3
  2. """One-command panel-descriptor capture.
  3. Resolves a GxEPD2 driver class to its vendored source, builds and runs a host
  4. harness that *observes* the driver, and prints a candidate EpdPanelDescriptor
  5. (the body of a `components/gxepd2/panel_<model>.h`). No ESP toolchain needed -
  6. just a host C++ compiler. See tools/gxepd2_harness/README.md and claude/DESIGN.md.
  7. tools/capture.py GxEPD2_154_Z90c # candidate -> stdout
  8. tools/capture.py GxEPD2_154_Z90c -o panel.h # candidate -> file
  9. tools/capture.py --list # capturable driver classes
  10. tools/capture.py GxEPD2_154_Z90c --debug # also dump the capture structure
  11. The output is a *candidate*: review it, fill any TODO fields the diag passes
  12. own (bits_per_pixel/palette for packed panels; plane_width/row_interleave for
  13. dual-region), save it as components/gxepd2/panel_<model>.h, add it to
  14. display.py's MODELS, compile, and bench-verify before marking it verified.
  15. """
  16. import argparse
  17. import difflib
  18. import json
  19. import pathlib
  20. import re
  21. import subprocess
  22. import sys
  23. import tempfile
  24. ROOT = pathlib.Path(__file__).resolve().parent.parent # code/
  25. CATALOG = ROOT / "tools" / "gxepd2_catalog" / "catalog.json"
  26. VSRC = ROOT / "third_party" / "GxEPD2" / "src"
  27. HARNESS = ROOT / "tools" / "gxepd2_harness"
  28. DISPLAY_PY = ROOT / "components" / "gxepd2" / "display.py"
  29. PREFIX = "GxEPD2_"
  30. def macro_of(model: str) -> str:
  31. return "PANEL_" + model[len(PREFIX):].upper()
  32. def filename_of(model: str) -> str:
  33. return "panel_" + model[len(PREFIX):].lower() + ".h"
  34. def shipped_models() -> set:
  35. """Model keys already wired into display.py's MODELS, to flag in --list."""
  36. try:
  37. text = DISPLAY_PY.read_text()
  38. except OSError:
  39. return set()
  40. return set(re.findall(r'"(GxEPD2_[^"]+)":\s*\(', text))
  41. def load_catalog() -> dict:
  42. if not CATALOG.exists():
  43. sys.exit(f"catalog not found: {CATALOG}\n(run tools/gxepd2_catalog/generate_catalog.py)")
  44. return json.loads(CATALOG.read_text())
  45. def capturable(entry: dict) -> bool:
  46. # The emitter instantiates Driver(cs, dc, rst, busy) - only the standard
  47. # 4-arg constructor works without special-casing.
  48. return entry.get("ctor_args") == 4 and (VSRC / entry["source"]).exists()
  49. def do_list(catalog: dict) -> None:
  50. shipped = shipped_models()
  51. rows = []
  52. for model, entry in sorted(catalog.items()):
  53. if not capturable(entry):
  54. continue
  55. mark = "shipped" if model in shipped else "-"
  56. rows.append((model, mark, entry.get("comment", "")))
  57. w = max(len(m) for m, _, _ in rows)
  58. print(f"{len(rows)} capturable driver classes (ctor_args=4, source present):\n")
  59. for model, mark, comment in rows:
  60. print(f" {model:<{w}} {mark:<7} {comment}")
  61. def resolve_source(model: str, catalog: dict):
  62. """(source relpath, ctor_args|None). Catalog first (it has ctor_args), else
  63. glob the vendored tree - so a driver the fork added but GxEPD2's example
  64. headers don't list (e.g. the F52) is still capturable."""
  65. if model in catalog:
  66. e = catalog[model]
  67. return e["source"], e.get("ctor_args")
  68. hits = list(VSRC.glob(f"**/{model}.cpp"))
  69. if len(hits) == 1:
  70. return str(hits[0].relative_to(VSRC)), None
  71. if len(hits) > 1:
  72. sys.exit(f"{model}: multiple sources found: {[str(h) for h in hits]}")
  73. return None, None
  74. def capture(model: str, catalog: dict, debug: bool, cxx: str, keep: bool) -> str:
  75. src_rel, ctor_args = resolve_source(model, catalog)
  76. if src_rel is None:
  77. names = set(catalog) | {p.stem for p in VSRC.glob("**/GxEPD2_*.cpp")}
  78. hint = difflib.get_close_matches(model, names, n=3)
  79. msg = f"unknown model: {model} (no {model}.cpp under {VSRC})"
  80. if hint:
  81. msg += "\ndid you mean: " + ", ".join(hint)
  82. sys.exit(msg)
  83. if ctor_args not in (None, 4):
  84. sys.exit(f"{model}: non-standard constructor (ctor_args={ctor_args}); "
  85. "the emitter only handles the 4-arg GxEPD2_EPD(cs,dc,rst,busy) form.")
  86. src = VSRC / src_rel
  87. hdr_rel = src_rel[:-4] + ".h" # epd3c/GxEPD2_154_Z90c.h
  88. if not (VSRC / hdr_rel).exists():
  89. sys.exit(f"driver header not found: {VSRC / hdr_rel}")
  90. macro = macro_of(model)
  91. with tempfile.TemporaryDirectory() as td:
  92. tdp = pathlib.Path(td)
  93. main_cpp = tdp / "emit_main.cpp"
  94. main_cpp.write_text(
  95. f'#include "emit_descriptor.h"\n'
  96. f'#include "{hdr_rel}"\n'
  97. f'int main() {{ emit::emit_descriptor<{model}>("{model}", "{macro}"); return 0; }}\n'
  98. )
  99. binary = tdp / "emit_main"
  100. compile_cmd = [
  101. cxx, "-std=c++17",
  102. "-I", str(HARNESS / "spy"), "-I", str(VSRC), "-I", str(HARNESS),
  103. str(main_cpp), str(VSRC / "GxEPD2_EPD.cpp"), str(src),
  104. "-o", str(binary),
  105. ]
  106. print(f"[capture] {model} <- third_party/GxEPD2/src/{src_rel}", file=sys.stderr)
  107. cc = subprocess.run(compile_cmd, capture_output=True, text=True)
  108. if cc.returncode != 0:
  109. sys.exit(f"compile failed:\n{cc.stderr}")
  110. env = {"EMIT_DEBUG": "1"} if debug else None
  111. run = subprocess.run([str(binary)], capture_output=True, text=True, env=env)
  112. if keep:
  113. kept = HARNESS / f"run_{model[len(PREFIX):].lower()}_emit.cpp"
  114. kept.write_text(main_cpp.read_text())
  115. print(f"[capture] kept emit main: {kept}", file=sys.stderr)
  116. if debug and run.stderr:
  117. print(run.stderr, file=sys.stderr)
  118. if run.returncode != 0:
  119. sys.exit(f"capture run failed (exit {run.returncode}):\n{run.stdout}\n{run.stderr}")
  120. marker = "// GENERATED by emit_descriptor"
  121. idx = run.stdout.find(marker)
  122. if idx < 0:
  123. sys.exit(f"no descriptor in output:\n{run.stdout}")
  124. descriptor = run.stdout[idx:]
  125. print(f"[capture] ok -> save as components/gxepd2/{filename_of(model)}, "
  126. f"add \"{model}\" to display.py MODELS", file=sys.stderr)
  127. return descriptor
  128. def main() -> None:
  129. ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
  130. ap.add_argument("model", nargs="?", help="GxEPD2 driver class, e.g. GxEPD2_154_Z90c")
  131. ap.add_argument("-o", "--out", help="write the candidate descriptor to this file (default: stdout)")
  132. ap.add_argument("--list", action="store_true", help="list capturable driver classes and exit")
  133. ap.add_argument("--debug", action="store_true", help="dump the image-stage command/burst structure to stderr")
  134. ap.add_argument("--keep", action="store_true", help="keep the generated emit main under tools/gxepd2_harness/")
  135. ap.add_argument("--cxx", default="g++", help="C++ compiler (default: g++)")
  136. args = ap.parse_args()
  137. catalog = load_catalog()
  138. if args.list:
  139. do_list(catalog)
  140. return
  141. if not args.model:
  142. ap.error("a model is required (or use --list)")
  143. descriptor = capture(args.model, catalog, args.debug, args.cxx, args.keep)
  144. if args.out:
  145. pathlib.Path(args.out).write_text(descriptor)
  146. print(f"[capture] wrote {args.out}", file=sys.stderr)
  147. else:
  148. sys.stdout.write(descriptor)
  149. if __name__ == "__main__":
  150. main()