check_descriptors.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. #!/usr/bin/env python3
  2. """Regression check: re-emit each shipped panel from its GxEPD2 driver and diff
  3. the result against the committed components/gxepd2/panel_*.h.
  4. Compares only the structure the emitter authoritatively produces - the INIT /
  5. WINDOW / REFRESH command sequences, plane count, plane-write command(s),
  6. window_per_plane, and plane_invert - not the hand/diag-filled fields
  7. (bits_per_pixel/palette/blank_code/plane_width/line_order), which carry
  8. comments and names the emitter doesn't reproduce.
  9. Catches two things: an emitter regression (reproduction breaks), and GxEPD2
  10. upstream drift (a vendor bump changes a driver -> its panel now differs ->
  11. review it). Exit non-zero on any unexpected mismatch.
  12. tools/check_descriptors.py # check all
  13. tools/check_descriptors.py -v # show the diff on a mismatch
  14. tools/check_descriptors.py GxEPD2_154_Z90c # check just one (or more) models
  15. tools/check_descriptors.py GxEPD2_154_Z90c GxEPD2_270
  16. Panels the emitter does not yet fully model (dual-region, see KNOWN_DIFF) are
  17. expected to differ and don't fail; if one of them starts MATCHING, that's an
  18. emitter improvement - remove it from KNOWN_DIFF. Panels with no GxEPD2 driver
  19. source (a synthetic model with no third_party/GxEPD2/ .cpp of its own) are
  20. skipped.
  21. """
  22. import argparse
  23. import ast
  24. import difflib
  25. import pathlib
  26. import re
  27. import sys
  28. ROOT = pathlib.Path(__file__).resolve().parent.parent
  29. sys.path.insert(0, str(ROOT / "tools"))
  30. import capture as cap # noqa: E402 (resolution + emit, reused)
  31. PANEL_DIR = ROOT / "components" / "gxepd2"
  32. # Panels the emitter's segmentation doesn't reproduce yet. The dual-region
  33. # GDEY0579F51 used to live here; the emitter now models the region-select layer
  34. # (per-region init/window/refresh, plane_width, paired-edges line doubling), so
  35. # it reproduces and has been promoted out. A future panel the emitter can't yet
  36. # segment goes here with a one-line why.
  37. KNOWN_DIFF: set = set()
  38. def registry():
  39. """(model, part) for every entry in display.py's PANELS."""
  40. tree = ast.parse((PANEL_DIR / "display.py").read_text())
  41. for node in ast.walk(tree):
  42. if isinstance(node, ast.Assign) and any(getattr(t, "id", None) == "PANELS" for t in node.targets):
  43. for key, val in zip(node.value.keys, node.value.values):
  44. yield key.value
  45. return
  46. def parse_descriptor(text: str) -> dict:
  47. """Normalize a panel_*.h into the fields the emitter owns. Comments and
  48. array names are stripped, so only the command bytes / flags are compared."""
  49. text = re.sub(r"//.*", "", text)
  50. text = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
  51. arrs = {name: [int(x, 16) for x in re.findall(r"0x[0-9A-Fa-f]+", body)]
  52. for name, body in re.findall(r"uint8_t\s+(\w+)\[\]\s*=\s*\{([^}]*)\}", text)}
  53. seqs = {}
  54. for seqname, body in re.findall(r"EpdCommand\s+(\w+)\[\]\s*=\s*\{(.*?)\};", text, flags=re.S):
  55. cmds = []
  56. for line in re.findall(r"\{([^}]*)\}", body):
  57. parts = [p.strip() for p in line.split(",")]
  58. op = int(parts[0], 16)
  59. data = tuple(arrs.get(parts[1], [])) if parts[1] != "nullptr" else ()
  60. cmds.append((op, data, "true" in line))
  61. role = ("INIT" if "INIT" in seqname else "WINDOW" if "WINDOW" in seqname
  62. else "REFRESH" if "REFRESH" in seqname else seqname)
  63. seqs.setdefault(role, cmds) # first WINDOW_SEQ wins (window_sequence[0])
  64. def field(name, default="?"):
  65. m = re.search(rf"\.{name}\s*=\s*([^,\n]+)", text)
  66. return m.group(1).strip() if m else default
  67. return {
  68. "INIT": seqs.get("INIT"),
  69. "WINDOW": seqs.get("WINDOW", []),
  70. "REFRESH": seqs.get("REFRESH"),
  71. "plane_count": field("plane_count"),
  72. "plane_write_cmd": field("plane_write_cmd"),
  73. "window_per_plane": field("window_per_plane", "false"),
  74. "plane_invert": field("plane_invert"),
  75. }
  76. def check(model: str, catalog: dict, verbose: bool) -> str:
  77. """Returns 'MATCH', 'DIFF', 'XFAIL', 'XPASS', 'SKIP', or 'ERROR'."""
  78. panel_h = PANEL_DIR / cap.filename_of(model)
  79. if not panel_h.exists():
  80. return "SKIP"
  81. src_rel, _, _ = cap.resolve_source(model, catalog)
  82. if src_rel is None:
  83. return "SKIP" # no GxEPD2 driver to re-emit from (e.g. synthetic variant)
  84. try:
  85. generated = cap.capture(model, catalog, debug=False, cxx="g++", keep=False)
  86. except SystemExit as e:
  87. print(f" {e}", file=sys.stderr)
  88. return "ERROR"
  89. g, h = parse_descriptor(generated), parse_descriptor(panel_h.read_text())
  90. same = g == h
  91. expected_diff = model in KNOWN_DIFF
  92. if same:
  93. return "XPASS" if expected_diff else "MATCH"
  94. if expected_diff:
  95. return "XFAIL"
  96. if verbose:
  97. for k in g:
  98. if g[k] != h[k]:
  99. print(f" {k}:\n emitted : {g[k]}\n committed: {h[k]}", file=sys.stderr)
  100. return "DIFF"
  101. def main() -> None:
  102. ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
  103. ap.add_argument("models", nargs="*", help="only check these registry keys (default: check every registered panel)")
  104. ap.add_argument("-v", "--verbose", action="store_true", help="show the fields that differ on a mismatch")
  105. args = ap.parse_args()
  106. all_models = list(registry())
  107. if args.models:
  108. unknown = [m for m in args.models if m not in all_models]
  109. if unknown:
  110. for m in unknown:
  111. hint = difflib.get_close_matches(m, all_models, n=3)
  112. msg = f"not in display.py's PANELS: {m}"
  113. if hint:
  114. msg += "\ndid you mean: " + ", ".join(hint)
  115. print(msg, file=sys.stderr)
  116. sys.exit(1)
  117. models = args.models
  118. else:
  119. models = all_models
  120. catalog = cap.load_catalog()
  121. results = {}
  122. for model in models:
  123. # capture.py prints its own [capture] line to stderr; keep output tidy.
  124. res = check(model, catalog, args.verbose)
  125. results[model] = res
  126. print(f" {res:<6} {model}")
  127. bad = [m for m, r in results.items() if r in ("DIFF", "ERROR", "XPASS")]
  128. n_match = sum(1 for r in results.values() if r == "MATCH")
  129. print(f"\n{n_match} reproduced, {sum(1 for r in results.values() if r=='XFAIL')} known-diff, "
  130. f"{sum(1 for r in results.values() if r=='SKIP')} skipped.")
  131. if bad:
  132. print("\nFAILURES:")
  133. for m in bad:
  134. why = {"DIFF": "command structure changed (emitter regression or GxEPD2 drift) - review",
  135. "ERROR": "capture failed to build/run",
  136. "XPASS": "now reproduces - remove from KNOWN_DIFF"}[results[m]]
  137. print(f" {results[m]:<6} {m}: {why}")
  138. sys.exit(1)
  139. print("OK")
  140. if __name__ == "__main__":
  141. main()