capture.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  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 CONTRIBUTING.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 still left (a
  12. 7-colour panel's bits_per_pixel/palette, or plane_width/line_order 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. 4-colour (epd4c/) panels have bits_per_pixel/palette/blank_code auto-filled by
  16. observing the GxEPD2_4C wrapper (see gxepd2_harness/emit_palette_4c.h).
  17. """
  18. import argparse
  19. import difflib
  20. import json
  21. import pathlib
  22. import re
  23. import subprocess
  24. import sys
  25. import tempfile
  26. ROOT = pathlib.Path(__file__).resolve().parent.parent # code/
  27. CATALOG = ROOT / "tools" / "gxepd2_catalog" / "catalog.json"
  28. VSRC = ROOT / "third_party" / "GxEPD2" / "src"
  29. HARNESS = ROOT / "tools" / "gxepd2_harness"
  30. DISPLAY_PY = ROOT / "components" / "gxepd2" / "display.py"
  31. PREFIX = "GxEPD2_"
  32. def macro_of(model: str) -> str:
  33. return "PANEL_" + model[len(PREFIX):].upper()
  34. def filename_of(model: str) -> str:
  35. return "panel_" + model[len(PREFIX):].lower() + ".h"
  36. def _fmt_of(src_rel: str) -> str:
  37. """Panel colour format from the vendored source subdir (epd3c/epd4c/epd7c/
  38. gdem3c/...). Everything else is mono. The directory is GxEPD2's own
  39. classification, so this never guesses."""
  40. sub = src_rel.replace("\\", "/").split("/")[0].lower()
  41. if "7c" in sub:
  42. return "7-color"
  43. if "4c" in sub:
  44. return "4-color"
  45. if "3c" in sub:
  46. return "3-color"
  47. return "mono"
  48. def _header_meta(hdr_path: pathlib.Path) -> tuple:
  49. """(part, controller) scraped from the driver header's `// Panel:` /
  50. `// Controller:` comment lines, or ("TODO", "TODO") when absent/unusable.
  51. `part` is only trusted when it reads as a Good Display GDExNNNN part number
  52. (the silkscreen label); a prose description (e.g. 565c's "5.65inch ACeP")
  53. or a missing line stays TODO for the dev to fill from the physical panel."""
  54. part, controller = "TODO", "TODO"
  55. try:
  56. text = hdr_path.read_text()
  57. except OSError:
  58. return part, controller
  59. m = re.search(r"^//\s*Panel:\s*(\S+)", text, re.M)
  60. if m and re.match(r"GDE[A-Z][0-9A-Z]+$", m.group(1)):
  61. part = m.group(1)
  62. m = re.search(r"^//\s*Controller:\s*([^,:\s]+)", text, re.M)
  63. if m and m.group(1).lower() != "unknown":
  64. controller = m.group(1)
  65. return part, controller
  66. def registry_line(model: str, src_rel: str) -> str:
  67. """The exact `PANELS` entry to paste into display.py - every mechanical
  68. field (key, descriptor macro, format) filled so it can't be mistyped, with
  69. part/controller scraped where the header allows and `status` starting at
  70. "structural" (a human flips it to "verified" after the bench)."""
  71. part, controller = _header_meta(VSRC / (src_rel[:-4] + ".h"))
  72. return (f' "{model}": Panel("{macro_of(model)}", '
  73. f'"{part}", "{controller}", "{_fmt_of(src_rel)}", "structural"),')
  74. def shipped_models() -> set:
  75. """Model keys already wired into display.py's MODELS, to flag in --list."""
  76. try:
  77. text = DISPLAY_PY.read_text()
  78. except OSError:
  79. return set()
  80. return set(re.findall(r'"(GxEPD2_[^"]+)":\s*Panel\(', text))
  81. def load_catalog() -> dict:
  82. if not CATALOG.exists():
  83. sys.exit(f"catalog not found: {CATALOG}\n(run tools/gxepd2_catalog/generate_catalog.py)")
  84. return json.loads(CATALOG.read_text())
  85. def capturable(entry: dict) -> bool:
  86. # The emitter instantiates Driver(cs, dc, rst, busy) - only the standard
  87. # 4-arg constructor works without special-casing.
  88. return entry.get("ctor_args") == 4 and (VSRC / entry["source"]).exists()
  89. def do_list(catalog: dict) -> None:
  90. shipped = shipped_models()
  91. rows = []
  92. for model, entry in sorted(catalog.items()):
  93. if not capturable(entry):
  94. continue
  95. mark = "shipped" if model in shipped else "-"
  96. rows.append((model, mark, entry.get("comment", "")))
  97. w = max(len(m) for m, _, _ in rows)
  98. print(f"{len(rows)} capturable driver classes (ctor_args=4, source present):\n")
  99. for model, mark, comment in rows:
  100. print(f" {model:<{w}} {mark:<7} {comment}")
  101. def resolve_source(model: str, catalog: dict):
  102. """(source relpath, ctor_args|None, real_class_name) or (None, None, None).
  103. Catalog first (it has ctor_args), else glob the vendored tree - so a driver
  104. the fork added but GxEPD2's example headers don't list (e.g. the F52) is
  105. still capturable. Matching is case-insensitive and returns the driver's
  106. REAL class name (the `model:` key may differ in case, e.g. GxEPD2_213_Z98C
  107. vs the class GxEPD2_213_Z98c)."""
  108. low = model.lower()
  109. for k, e in catalog.items():
  110. if k.lower() == low:
  111. return e["source"], e.get("ctor_args"), e.get("driver", k)
  112. hits = [p for p in VSRC.glob("**/GxEPD2_*.cpp") if p.stem.lower() == low]
  113. if len(hits) == 1:
  114. return str(hits[0].relative_to(VSRC)), None, hits[0].stem
  115. if len(hits) > 1:
  116. sys.exit(f"{model}: multiple sources found: {[str(h) for h in hits]}")
  117. return None, None, None
  118. def capture(model: str, catalog: dict, debug: bool, cxx: str, keep: bool) -> str:
  119. src_rel, ctor_args, real_class = resolve_source(model, catalog)
  120. if src_rel is None:
  121. names = set(catalog) | {p.stem for p in VSRC.glob("**/GxEPD2_*.cpp")}
  122. hint = difflib.get_close_matches(model, names, n=3)
  123. msg = f"unknown model: {model} (no {model}.cpp under {VSRC})"
  124. if hint:
  125. msg += "\ndid you mean: " + ", ".join(hint)
  126. sys.exit(msg)
  127. if ctor_args not in (None, 4):
  128. sys.exit(f"{model}: non-standard constructor (ctor_args={ctor_args}); "
  129. "the emitter only handles the 4-arg GxEPD2_EPD(cs,dc,rst,busy) form.")
  130. src = VSRC / src_rel
  131. hdr_rel = src_rel[:-4] + ".h" # epd3c/GxEPD2_154_Z90c.h
  132. if not (VSRC / hdr_rel).exists():
  133. sys.exit(f"driver header not found: {VSRC / hdr_rel}")
  134. macro = macro_of(model)
  135. # 4-colour (epd4c/) panels: emit via the 4C entry, which observes the
  136. # GxEPD2_4C wrapper to fill bits_per_pixel/palette/blank_code instead of
  137. # leaving them as TODO. The wrapper references WIDTH/HEIGHT unqualified, so
  138. # those must be defined before GxEPD2_4C.h (pulled in by emit_palette_4c.h).
  139. is_4c = src_rel.replace("\\", "/").startswith("epd4c/")
  140. if is_4c:
  141. main_src = (
  142. f'#include "{hdr_rel}"\n'
  143. f'constexpr int16_t WIDTH = {real_class}::WIDTH;\n'
  144. f'constexpr int16_t HEIGHT = {real_class}::HEIGHT;\n'
  145. f'#include "emit_palette_4c.h"\n'
  146. f'int main() {{ emit::emit_descriptor_4c<{real_class}>("{real_class}", "{macro}"); return 0; }}\n'
  147. )
  148. else:
  149. main_src = (
  150. f'#include "emit_descriptor.h"\n'
  151. f'#include "{hdr_rel}"\n'
  152. f'int main() {{ emit::emit_descriptor<{real_class}>("{real_class}", "{macro}"); return 0; }}\n'
  153. )
  154. with tempfile.TemporaryDirectory() as td:
  155. tdp = pathlib.Path(td)
  156. main_cpp = tdp / "emit_main.cpp"
  157. main_cpp.write_text(main_src)
  158. binary = tdp / "emit_main"
  159. compile_cmd = [
  160. cxx, "-std=c++17",
  161. "-I", str(HARNESS / "spy"), "-I", str(VSRC), "-I", str(HARNESS),
  162. str(main_cpp), str(VSRC / "GxEPD2_EPD.cpp"), str(src),
  163. "-o", str(binary),
  164. ]
  165. print(f"[capture] {model} <- third_party/GxEPD2/src/{src_rel}", file=sys.stderr)
  166. cc = subprocess.run(compile_cmd, capture_output=True, text=True)
  167. if cc.returncode != 0:
  168. sys.exit(f"compile failed:\n{cc.stderr}")
  169. env = {"EMIT_DEBUG": "1"} if debug else None
  170. run = subprocess.run([str(binary)], capture_output=True, text=True, env=env)
  171. if keep:
  172. kept = HARNESS / f"run_{model[len(PREFIX):].lower()}_emit.cpp"
  173. kept.write_text(main_cpp.read_text())
  174. print(f"[capture] kept emit main: {kept}", file=sys.stderr)
  175. if debug and run.stderr:
  176. print(run.stderr, file=sys.stderr)
  177. if run.returncode != 0:
  178. sys.exit(f"capture run failed (exit {run.returncode}):\n{run.stdout}\n{run.stderr}")
  179. marker = "// GENERATED by emit_descriptor"
  180. idx = run.stdout.find(marker)
  181. if idx < 0:
  182. sys.exit(f"no descriptor in output:\n{run.stdout}")
  183. descriptor = run.stdout[idx:]
  184. print(f"[capture] ok -> save as components/gxepd2/{filename_of(model)}", file=sys.stderr)
  185. print("[capture] then paste this line into display.py's PANELS. Key, descriptor "
  186. "macro and format are exact; fill any TODO and sanity-check part/controller "
  187. "(scraped from the driver header) against the physical panel:", file=sys.stderr)
  188. print(registry_line(model, src_rel), file=sys.stderr)
  189. return descriptor
  190. def main() -> None:
  191. ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
  192. ap.add_argument("model", nargs="?", help="GxEPD2 driver class, e.g. GxEPD2_154_Z90c")
  193. ap.add_argument("-o", "--out", help="write the candidate descriptor to this file (default: stdout)")
  194. ap.add_argument("--list", action="store_true", help="list capturable driver classes and exit")
  195. ap.add_argument("--debug", action="store_true", help="dump the image-stage command/burst structure to stderr")
  196. ap.add_argument("--keep", action="store_true", help="keep the generated emit main under tools/gxepd2_harness/")
  197. ap.add_argument("--cxx", default="g++", help="C++ compiler (default: g++)")
  198. args = ap.parse_args()
  199. catalog = load_catalog()
  200. if args.list:
  201. do_list(catalog)
  202. return
  203. if not args.model:
  204. ap.error("a model is required (or use --list)")
  205. descriptor = capture(args.model, catalog, args.debug, args.cxx, args.keep)
  206. if args.out:
  207. pathlib.Path(args.out).write_text(descriptor)
  208. print(f"[capture] wrote {args.out}", file=sys.stderr)
  209. else:
  210. sys.stdout.write(descriptor)
  211. if __name__ == "__main__":
  212. main()