capture.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  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
  12. (plane_width/line_order for dual-region), save it as
  13. components/gxepd2/panel_<model>.h, add it to display.py's MODELS, compile,
  14. and bench-verify before marking it verified.
  15. 4-colour (epd4c/) and 7-colour (epd7c/) panels have
  16. bits_per_pixel/palette/blank_code auto-filled by observing the vendor
  17. GxEPD2_4C/GxEPD2_7C wrapper (see gxepd2_harness/emit_palette_4c.h /
  18. emit_palette_7c.h).
  19. """
  20. import argparse
  21. import difflib
  22. import json
  23. import pathlib
  24. import re
  25. import subprocess
  26. import sys
  27. import tempfile
  28. ROOT = pathlib.Path(__file__).resolve().parent.parent # code/
  29. CATALOG = ROOT / "tools" / "gxepd2_catalog" / "catalog.json"
  30. VSRC = ROOT / "third_party" / "GxEPD2" / "src"
  31. HARNESS = ROOT / "tools" / "gxepd2_harness"
  32. DISPLAY_PY = ROOT / "components" / "gxepd2" / "display.py"
  33. PREFIX = "GxEPD2_"
  34. def macro_of(model: str) -> str:
  35. return "PANEL_" + model[len(PREFIX):].upper()
  36. def filename_of(model: str) -> str:
  37. return "panel_" + model[len(PREFIX):].lower() + ".h"
  38. def _fmt_of(src_rel: str) -> str:
  39. """Panel colour format from the vendored source subdir (epd3c/epd4c/epd7c/
  40. gdem3c/...). Everything else is mono. The directory is GxEPD2's own
  41. classification, so this never guesses."""
  42. sub = src_rel.replace("\\", "/").split("/")[0].lower()
  43. if "7c" in sub:
  44. return "7-color"
  45. if "4c" in sub:
  46. return "4-color"
  47. if "3c" in sub:
  48. return "3-color"
  49. return "mono"
  50. def _header_meta(hdr_path: pathlib.Path) -> tuple:
  51. """(part, controller) scraped from the driver header's `// Panel:` /
  52. `// Controller:` comment lines, or ("TODO", "TODO") when absent/unusable.
  53. `part` is only trusted when it reads as a Good Display GDExNNNN part number
  54. (the silkscreen label); a prose description (e.g. 565c's "5.65inch ACeP")
  55. or a missing line stays TODO for the dev to fill from the physical panel."""
  56. part, controller = "TODO", "TODO"
  57. try:
  58. text = hdr_path.read_text()
  59. except OSError:
  60. return part, controller
  61. m = re.search(r"^//\s*Panel:\s*(\S+)", text, re.M)
  62. if m and re.match(r"GDE[A-Z][0-9A-Z]+$", m.group(1)):
  63. part = m.group(1)
  64. m = re.search(r"^//\s*Controller:\s*([^,:\s]+)", text, re.M)
  65. if m and m.group(1).lower() != "unknown":
  66. controller = m.group(1)
  67. return part, controller
  68. def registry_line(model: str, src_rel: str) -> str:
  69. """The exact `PANELS` entry to paste into display.py - every mechanical
  70. field (key, descriptor macro, format) filled so it can't be mistyped, with
  71. part/controller scraped where the header allows and `status` starting at
  72. "structural" (a human flips it to "verified" after the bench)."""
  73. part, controller = _header_meta(VSRC / (src_rel[:-4] + ".h"))
  74. return (f' "{model}": Panel("{macro_of(model)}", '
  75. f'"{part}", "{controller}", "{_fmt_of(src_rel)}", "structural"),')
  76. def shipped_models() -> set:
  77. """Model keys already wired into display.py's MODELS, to flag in --list."""
  78. try:
  79. text = DISPLAY_PY.read_text()
  80. except OSError:
  81. return set()
  82. return set(re.findall(r'"(GxEPD2_[^"]+)":\s*Panel\(', text))
  83. def load_catalog() -> dict:
  84. if not CATALOG.exists():
  85. sys.exit(f"catalog not found: {CATALOG}\n(run tools/gxepd2_catalog/generate_catalog.py)")
  86. return json.loads(CATALOG.read_text())
  87. def capturable(entry: dict) -> bool:
  88. # The emitter instantiates Driver(cs, dc, rst, busy) - only the standard
  89. # 4-arg constructor works without special-casing.
  90. return entry.get("ctor_args") == 4 and (VSRC / entry["source"]).exists()
  91. def do_list(catalog: dict) -> None:
  92. shipped = shipped_models()
  93. rows = []
  94. for model, entry in sorted(catalog.items()):
  95. if not capturable(entry):
  96. continue
  97. mark = "shipped" if model in shipped else "-"
  98. rows.append((model, mark, entry.get("comment", "")))
  99. w = max(len(m) for m, _, _ in rows)
  100. print(f"{len(rows)} capturable driver classes (ctor_args=4, source present):\n")
  101. for model, mark, comment in rows:
  102. print(f" {model:<{w}} {mark:<7} {comment}")
  103. def resolve_source(model: str, catalog: dict):
  104. """(source relpath, ctor_args|None, real_class_name) or (None, None, None).
  105. Catalog first (it has ctor_args), else glob the vendored tree - so a driver
  106. the fork added but GxEPD2's example headers don't list (e.g. the F52) is
  107. still capturable. Matching is case-insensitive and returns the driver's
  108. REAL class name (the `model:` key may differ in case, e.g. GxEPD2_213_Z98C
  109. vs the class GxEPD2_213_Z98c)."""
  110. low = model.lower()
  111. for k, e in catalog.items():
  112. if k.lower() == low:
  113. return e["source"], e.get("ctor_args"), e.get("driver", k)
  114. hits = [p for p in VSRC.glob("**/GxEPD2_*.cpp") if p.stem.lower() == low]
  115. if len(hits) == 1:
  116. return str(hits[0].relative_to(VSRC)), None, hits[0].stem
  117. if len(hits) > 1:
  118. sys.exit(f"{model}: multiple sources found: {[str(h) for h in hits]}")
  119. return None, None, None
  120. def capture(model: str, catalog: dict, debug: bool, cxx: str, keep: bool) -> str:
  121. src_rel, ctor_args, real_class = resolve_source(model, catalog)
  122. if src_rel is None:
  123. names = set(catalog) | {p.stem for p in VSRC.glob("**/GxEPD2_*.cpp")}
  124. hint = difflib.get_close_matches(model, names, n=3)
  125. msg = f"unknown model: {model} (no {model}.cpp under {VSRC})"
  126. if hint:
  127. msg += "\ndid you mean: " + ", ".join(hint)
  128. sys.exit(msg)
  129. if ctor_args not in (None, 4):
  130. sys.exit(f"{model}: non-standard constructor (ctor_args={ctor_args}); "
  131. "the emitter only handles the 4-arg GxEPD2_EPD(cs,dc,rst,busy) form.")
  132. src = VSRC / src_rel
  133. hdr_rel = src_rel[:-4] + ".h" # epd3c/GxEPD2_154_Z90c.h
  134. if not (VSRC / hdr_rel).exists():
  135. sys.exit(f"driver header not found: {VSRC / hdr_rel}")
  136. macro = macro_of(model)
  137. # 4-colour (epd4c/) panels: emit via the 4C entry, which observes the
  138. # GxEPD2_4C wrapper to fill bits_per_pixel/palette/blank_code instead of
  139. # leaving them as TODO. The wrapper references WIDTH/HEIGHT unqualified, so
  140. # those must be defined before GxEPD2_4C.h (pulled in by emit_palette_4c.h).
  141. is_4c = src_rel.replace("\\", "/").startswith("epd4c/")
  142. # 7-colour (epd7c/) panels: same approach as 4-colour, via the GxEPD2_7C
  143. # wrapper (see gxepd2_harness/emit_palette_7c.h).
  144. is_7c = src_rel.replace("\\", "/").startswith("epd7c/")
  145. if is_4c:
  146. main_src = (
  147. f'#include "{hdr_rel}"\n'
  148. f'constexpr int16_t WIDTH = {real_class}::WIDTH;\n'
  149. f'constexpr int16_t HEIGHT = {real_class}::HEIGHT;\n'
  150. f'#include "emit_palette_4c.h"\n'
  151. f'int main() {{ emit::emit_descriptor_4c<{real_class}>("{real_class}", "{macro}"); return 0; }}\n'
  152. )
  153. elif is_7c:
  154. main_src = (
  155. f'#include "{hdr_rel}"\n'
  156. f'constexpr int16_t WIDTH = {real_class}::WIDTH;\n'
  157. f'constexpr int16_t HEIGHT = {real_class}::HEIGHT;\n'
  158. f'#include "emit_palette_7c.h"\n'
  159. f'int main() {{ emit::emit_descriptor_7c<{real_class}>("{real_class}", "{macro}"); return 0; }}\n'
  160. )
  161. else:
  162. main_src = (
  163. f'#include "emit_descriptor.h"\n'
  164. f'#include "{hdr_rel}"\n'
  165. f'int main() {{ emit::emit_descriptor<{real_class}>("{real_class}", "{macro}"); return 0; }}\n'
  166. )
  167. with tempfile.TemporaryDirectory() as td:
  168. tdp = pathlib.Path(td)
  169. main_cpp = tdp / "emit_main.cpp"
  170. main_cpp.write_text(main_src)
  171. binary = tdp / "emit_main"
  172. compile_cmd = [
  173. cxx, "-std=c++17",
  174. "-I", str(HARNESS / "spy"), "-I", str(VSRC), "-I", str(HARNESS),
  175. str(main_cpp), str(VSRC / "GxEPD2_EPD.cpp"), str(src),
  176. "-o", str(binary),
  177. ]
  178. print(f"[capture] {model} <- third_party/GxEPD2/src/{src_rel}", file=sys.stderr)
  179. cc = subprocess.run(compile_cmd, capture_output=True, text=True)
  180. if cc.returncode != 0:
  181. sys.exit(f"compile failed:\n{cc.stderr}")
  182. env = {"EMIT_DEBUG": "1"} if debug else None
  183. run = subprocess.run([str(binary)], capture_output=True, text=True, env=env)
  184. if keep:
  185. kept = HARNESS / f"run_{model[len(PREFIX):].lower()}_emit.cpp"
  186. kept.write_text(main_cpp.read_text())
  187. print(f"[capture] kept emit main: {kept}", file=sys.stderr)
  188. if debug and run.stderr:
  189. print(run.stderr, file=sys.stderr)
  190. if run.returncode != 0:
  191. sys.exit(f"capture run failed (exit {run.returncode}):\n{run.stdout}\n{run.stderr}")
  192. marker = "// GENERATED by emit_descriptor"
  193. idx = run.stdout.find(marker)
  194. if idx < 0:
  195. sys.exit(f"no descriptor in output:\n{run.stdout}")
  196. descriptor = run.stdout[idx:]
  197. print(f"[capture] ok -> save as components/gxepd2/{filename_of(model)}", file=sys.stderr)
  198. print("[capture] then paste this line into display.py's PANELS. Key, descriptor "
  199. "macro and format are exact; fill any TODO and sanity-check part/controller "
  200. "(scraped from the driver header) against the physical panel:", file=sys.stderr)
  201. print(registry_line(model, src_rel), file=sys.stderr)
  202. return descriptor
  203. # mode -> (harness header to include, template to invoke). Both headers are
  204. # templates over Driver and take (name) - see run_harness.h / run_diag_harness.h.
  205. _PROGRAMS = {
  206. "sweep": ("run_harness.h", "run_harness"),
  207. "diag": ("run_diag_harness.h", "run_diag_harness"),
  208. }
  209. def run_program(model: str, catalog: dict, mode: str, cxx: str) -> str:
  210. """Build+run run_harness.h ('sweep': full capture + the window-encoder
  211. sweep) or run_diag_harness.h ('diag': Pattern A row/byte-order fill) for
  212. `model`, with no per-panel run_<panel>.cpp to hand-write - same resolution
  213. `capture()` uses. Previously every panel needed its own 4-line
  214. run_<panel>[_diag].cpp; both headers are already templates over Driver, so
  215. a single generic main works for any of them."""
  216. src_rel, ctor_args, real_class = resolve_source(model, catalog)
  217. if src_rel is None:
  218. sys.exit(f"unknown model: {model} (no {model}.cpp under {VSRC})")
  219. if ctor_args not in (None, 4):
  220. sys.exit(f"{model}: non-standard constructor (ctor_args={ctor_args}); "
  221. "the harness only handles the 4-arg GxEPD2_EPD(cs,dc,rst,busy) form.")
  222. src = VSRC / src_rel
  223. hdr_rel = src_rel[:-4] + ".h"
  224. if not (VSRC / hdr_rel).exists():
  225. sys.exit(f"driver header not found: {VSRC / hdr_rel}")
  226. if mode == "power":
  227. # emit_descriptor.h's print_power_sequences, namespaced and keyed by
  228. # macro (not name) like emit_descriptor() itself - not run_harness.h/
  229. # run_diag_harness.h's plain-global, name-keyed shape, so it doesn't
  230. # fit _PROGRAMS below.
  231. main_src = (
  232. f'#include "emit_descriptor.h"\n'
  233. f'#include "{hdr_rel}"\n'
  234. f'int main() {{ emit::print_power_sequences<{real_class}>("{macro_of(model)}"); return 0; }}\n'
  235. )
  236. elif mode == "full-refresh":
  237. # emit_descriptor.h's print_full_refresh_extra_write - same splice
  238. # workflow as --power, for plane_write_cmd_for_full_refresh.
  239. main_src = (
  240. f'#include "emit_descriptor.h"\n'
  241. f'#include "{hdr_rel}"\n'
  242. f'int main() {{ emit::print_full_refresh_extra_write<{real_class}>(); return 0; }}\n'
  243. )
  244. else:
  245. header, fn = _PROGRAMS[mode]
  246. main_src = (
  247. f'#include "{header}"\n'
  248. f'#include "{hdr_rel}"\n'
  249. f'int main() {{ {fn}<{real_class}>("{real_class}"); return 0; }}\n'
  250. )
  251. with tempfile.TemporaryDirectory() as td:
  252. tdp = pathlib.Path(td)
  253. main_cpp = tdp / "main.cpp"
  254. main_cpp.write_text(main_src)
  255. binary = tdp / "main"
  256. compile_cmd = [
  257. cxx, "-std=c++17",
  258. "-I", str(HARNESS / "spy"), "-I", str(VSRC), "-I", str(HARNESS),
  259. str(main_cpp), str(VSRC / "GxEPD2_EPD.cpp"), str(src),
  260. "-o", str(binary),
  261. ]
  262. print(f"[{mode}] {model} <- third_party/GxEPD2/src/{src_rel}", file=sys.stderr)
  263. cc = subprocess.run(compile_cmd, capture_output=True, text=True)
  264. if cc.returncode != 0:
  265. sys.exit(f"compile failed:\n{cc.stderr}")
  266. run = subprocess.run([str(binary)], capture_output=True, text=True)
  267. if run.returncode != 0:
  268. sys.exit(f"{mode} run failed (exit {run.returncode}):\n{run.stdout}\n{run.stderr}")
  269. return run.stdout
  270. def main() -> None:
  271. ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
  272. ap.add_argument("model", nargs="?", help="GxEPD2 driver class, e.g. GxEPD2_154_Z90c")
  273. ap.add_argument("-o", "--out", help="write the candidate descriptor to this file (default: stdout)")
  274. ap.add_argument("--list", action="store_true", help="list capturable driver classes and exit")
  275. ap.add_argument("--debug", action="store_true", help="dump the image-stage command/burst structure to stderr")
  276. ap.add_argument("--keep", action="store_true", help="keep the generated emit main under tools/gxepd2_harness/")
  277. ap.add_argument("--sweep", action="store_true",
  278. help="run the window-encoder sweep (run_harness.h) instead of emitting a descriptor")
  279. ap.add_argument("--diag", action="store_true",
  280. help="run the Pattern-A row/byte-order diagnostic fill (run_diag_harness.h) instead of emitting")
  281. ap.add_argument("--power", action="store_true",
  282. help="print just power_off_sequence/hibernate_sequence (for splicing into an existing "
  283. "panel_*.h), instead of emitting a full descriptor")
  284. ap.add_argument("--full-refresh", action="store_true", dest="full_refresh",
  285. help="print just plane_write_cmd_for_full_refresh (for splicing into an existing "
  286. "panel_*.h), instead of emitting a full descriptor")
  287. ap.add_argument("--cxx", default="g++", help="C++ compiler (default: g++)")
  288. args = ap.parse_args()
  289. catalog = load_catalog()
  290. if args.list:
  291. do_list(catalog)
  292. return
  293. if not args.model:
  294. ap.error("a model is required (or use --list)")
  295. if args.sweep or args.diag or args.power or args.full_refresh:
  296. mode = "sweep" if args.sweep else "diag" if args.diag else "power" if args.power else "full-refresh"
  297. out = run_program(args.model, catalog, mode, args.cxx)
  298. if args.out:
  299. pathlib.Path(args.out).write_text(out)
  300. print(f"[{mode}] wrote {args.out}", file=sys.stderr)
  301. else:
  302. sys.stdout.write(out)
  303. return
  304. descriptor = capture(args.model, catalog, args.debug, args.cxx, args.keep)
  305. if args.out:
  306. pathlib.Path(args.out).write_text(descriptor)
  307. print(f"[capture] wrote {args.out}", file=sys.stderr)
  308. else:
  309. sys.stdout.write(descriptor)
  310. if __name__ == "__main__":
  311. main()