Эх сурвалжийг харах

emitter: auto-fill 4-colour fields, class-aware busy timeout, paste-ready PANELS line

Three steps toward a mechanical single-region capture->codegen:

- 4-colour packed panels now come out of capture.py complete. emit_palette_4c.h
  folds the diag Pattern-B colour->code derivation into the capture: it drives
  the driver's own GxEPD2_4C wrapper, reads each named colour's wire code,
  validates the table against the engine's GXEPD2_PALETTE_4C, and fills
  bits_per_pixel/palette/blank_code. A non-standard map stays unresolved and
  emits the old TODO plus the observed table, so a surprise is never silently
  mislabelled. emit_descriptor gained a PackedResolver seam; the generic path
  (mono/3-colour/7-colour) is unchanged.

- busy_timeout_ms is now a generous, class-aware default instead of a flat
  full_refresh_time+5000: max(2x vendor estimate, per-depth floor) where the
  floor is 15s (1bpp), 45s (2bpp 4-colour), 60s (4bpp 7-colour). It's only a
  diagnostic ceiling, so erring long is free; the emitted comment says it's
  dev-tunable. Shipped bench-verified panels keep their tuned values.

- capture.py prints the exact PANELS line to paste into display.py. Key,
  descriptor macro and format are derived from the model name (correct by
  construction, can't be mistyped); part/controller are scraped from the driver
  header to sanity-check; status starts at "structural".

check_descriptors stays green (8 reproduced, 579 known-diff).
ZiB 3 долоо хоног өмнө
parent
commit
65918c7bdb

+ 72 - 8
tools/capture.py

@@ -11,10 +11,12 @@ just a host C++ compiler. See tools/gxepd2_harness/README.md and CONTRIBUTING.md
     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/line_order for
+The output is a *candidate*: review it, fill any TODO fields still left (a
+7-colour panel's bits_per_pixel/palette, or plane_width/line_order 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.
+4-colour (epd4c/) panels have bits_per_pixel/palette/blank_code auto-filled by
+observing the GxEPD2_4C wrapper (see gxepd2_harness/emit_palette_4c.h).
 """
 
 import argparse
@@ -42,6 +44,50 @@ def filename_of(model: str) -> str:
     return "panel_" + model[len(PREFIX):].lower() + ".h"
 
 
+def _fmt_of(src_rel: str) -> str:
+    """Panel colour format from the vendored source subdir (epd3c/epd4c/epd7c/
+    gdem3c/...). Everything else is mono. The directory is GxEPD2's own
+    classification, so this never guesses."""
+    sub = src_rel.replace("\\", "/").split("/")[0].lower()
+    if "7c" in sub:
+        return "7-color"
+    if "4c" in sub:
+        return "4-color"
+    if "3c" in sub:
+        return "3-color"
+    return "mono"
+
+
+def _header_meta(hdr_path: pathlib.Path) -> tuple:
+    """(part, controller) scraped from the driver header's `// Panel:` /
+    `// Controller:` comment lines, or ("TODO", "TODO") when absent/unusable.
+    `part` is only trusted when it reads as a Good Display GDExNNNN part number
+    (the silkscreen label); a prose description (e.g. 565c's "5.65inch ACeP")
+    or a missing line stays TODO for the dev to fill from the physical panel."""
+    part, controller = "TODO", "TODO"
+    try:
+        text = hdr_path.read_text()
+    except OSError:
+        return part, controller
+    m = re.search(r"^//\s*Panel:\s*(\S+)", text, re.M)
+    if m and re.match(r"GDE[A-Z][0-9A-Z]+$", m.group(1)):
+        part = m.group(1)
+    m = re.search(r"^//\s*Controller:\s*([^,:\s]+)", text, re.M)
+    if m and m.group(1).lower() != "unknown":
+        controller = m.group(1)
+    return part, controller
+
+
+def registry_line(model: str, src_rel: str) -> str:
+    """The exact `PANELS` entry to paste into display.py - every mechanical
+    field (key, descriptor macro, format) filled so it can't be mistyped, with
+    part/controller scraped where the header allows and `status` starting at
+    "structural" (a human flips it to "verified" after the bench)."""
+    part, controller = _header_meta(VSRC / (src_rel[:-4] + ".h"))
+    return (f'    "{model}": Panel("{macro_of(model)}", '
+            f'"{part}", "{controller}", "{_fmt_of(src_rel)}", "structural"),')
+
+
 def shipped_models() -> set:
     """Model keys already wired into display.py's MODELS, to flag in --list."""
     try:
@@ -115,14 +161,29 @@ def capture(model: str, catalog: dict, debug: bool, cxx: str, keep: bool) -> str
         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(
+    # 4-colour (epd4c/) panels: emit via the 4C entry, which observes the
+    # GxEPD2_4C wrapper to fill bits_per_pixel/palette/blank_code instead of
+    # leaving them as TODO. The wrapper references WIDTH/HEIGHT unqualified, so
+    # those must be defined before GxEPD2_4C.h (pulled in by emit_palette_4c.h).
+    is_4c = src_rel.replace("\\", "/").startswith("epd4c/")
+    if is_4c:
+        main_src = (
+            f'#include "{hdr_rel}"\n'
+            f'constexpr int16_t WIDTH = {real_class}::WIDTH;\n'
+            f'constexpr int16_t HEIGHT = {real_class}::HEIGHT;\n'
+            f'#include "emit_palette_4c.h"\n'
+            f'int main() {{ emit::emit_descriptor_4c<{real_class}>("{real_class}", "{macro}"); return 0; }}\n'
+        )
+    else:
+        main_src = (
             f'#include "emit_descriptor.h"\n'
             f'#include "{hdr_rel}"\n'
             f'int main() {{ emit::emit_descriptor<{real_class}>("{real_class}", "{macro}"); return 0; }}\n'
         )
+    with tempfile.TemporaryDirectory() as td:
+        tdp = pathlib.Path(td)
+        main_cpp = tdp / "emit_main.cpp"
+        main_cpp.write_text(main_src)
         binary = tdp / "emit_main"
         compile_cmd = [
             cxx, "-std=c++17",
@@ -150,8 +211,11 @@ def capture(model: str, catalog: dict, debug: bool, cxx: str, keep: bool) -> str
     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)
+    print(f"[capture] ok -> save as components/gxepd2/{filename_of(model)}", file=sys.stderr)
+    print("[capture] then paste this line into display.py's PANELS. Key, descriptor "
+          "macro and format are exact; fill any TODO and sanity-check part/controller "
+          "(scraped from the driver header) against the physical panel:", file=sys.stderr)
+    print(registry_line(model, src_rel), file=sys.stderr)
     return descriptor
 
 

+ 12 - 4
tools/gxepd2_harness/README.md

@@ -34,10 +34,18 @@ g++ -std=c++17 -I tools/gxepd2_harness/spy -I $VSRC -I tools/gxepd2_harness \
 `emit_descriptor.h` phase-segments the captured command stream into the
 init / window / refresh sequences + plane-write command(s), reads
 resolution / busy-polarity / timeout from the driver, and derives
-`plane_invert` and 1bpp-vs-packed from a fill probe. Review the result, add any
-packed-format fields the diag passes own (`bits_per_pixel` / `palette` for
-packed panels; `plane_width` / `line_order` for dual-region), then save it
-as `components/gxepd2/panel_<panel>.h` and wire it into `display.py`'s `MODELS`.
+`plane_invert` and 1bpp-vs-packed from a fill probe. For a **4-colour**
+(`epd4c/`) panel it goes further: `emit_palette_4c.h` drives the driver's own
+`GxEPD2_4C` wrapper, reads the wire code for each named colour, validates it
+against the engine's `GXEPD2_PALETTE_4C`, and fills `bits_per_pixel` /
+`palette` / `blank_code` automatically (an unexpected code map stays a TODO
+carrying the observed table). Review the result, add any packed-format fields
+still left as TODO — `bits_per_pixel` / `palette` for a 7-colour panel (no
+resolver yet) and `plane_width` / `line_order` for dual-region — then save it
+as `components/gxepd2/panel_<panel>.h`. `capture.py` also prints the exact
+`PANELS` line to paste into `display.py` (key / descriptor macro / format
+filled from the capture; `part` / `controller` scraped from the driver header
+to sanity-check, `status` starting at `structural`).
 
 `EMIT_DEBUG=1 /tmp/emit_<panel>` dumps the image-stage command/burst structure.
 

+ 78 - 11
tools/gxepd2_harness/emit_descriptor.h

@@ -24,7 +24,9 @@
 #include <cstdint>
 #include <cstdio>
 #include <cstdlib>
+#include <functional>
 #include <set>
+#include <string>
 #include <type_traits>
 #include <utility>
 #include <vector>
@@ -148,6 +150,37 @@ struct PlaneInvert {
   int w0_white = -1, w1_red = -1;
 };
 
+// A packed panel's depth / palette / blank code, resolved by observation for
+// the panel families the emitter knows how to probe (currently 4-colour, via
+// emit_palette_4c.h). `emit_descriptor_impl` calls a resolver of this shape
+// only for a packed panel (the plane-invert probe said white fill isn't an
+// all-bits wire byte); a null or unresolved result keeps the old TODO fields,
+// so an unknown packed family still emits a reviewable placeholder rather than
+// a wrong palette.
+struct PackedResolved {
+  bool resolved = false;            // false => emit TODO placeholders
+  int bits_per_pixel = 2;           // 2 (4-colour) or 4 (7-colour)
+  const char *palette_expr = "nullptr";      // e.g. "GXEPD2_PALETTE_4C"
+  const char *palette_len_expr = "0";        // e.g. "GXEPD2_PALETTE_4C_LEN"
+  int blank_code = 0;               // per-pixel clear code (observed white)
+  std::string note;                 // appended as a comment (observed table, or why unresolved)
+};
+using PackedResolverFn = std::function<PackedResolved(uint8_t clear_fill, const PlaneInvert &pv)>;
+
+// A generous, class-aware default for busy_timeout_ms. The timeout is only a
+// diagnostic ceiling - the engine hard-aborts a busy-wait that runs past it and
+// logs a failure, where GxEPD2 itself just keeps polling - so erring long costs
+// nothing on a healthy panel and the exact number is meant to be dev-tuned. The
+// vendor full_refresh_time is a soft estimate, so we take 2x it, then clamp up
+// to a per-depth floor so a panel that under-reports (or whose colour pass is
+// genuinely slow) still gets room: mono/3-colour fail fast, 4-colour and
+// 7-colour (ACeP) get progressively more headroom.
+inline uint32_t default_busy_timeout_ms(unsigned full_refresh_time, int bits_per_pixel) {
+  uint32_t headroom = static_cast<uint32_t>(full_refresh_time) * 2;
+  uint32_t floor = bits_per_pixel >= 4 ? 60000u : bits_per_pixel == 2 ? 45000u : 15000u;
+  return headroom > floor ? headroom : floor;
+}
+
 // plane_invert by capture (see probe_plane_invert.h for the rationale): drive
 // known full-screen fills and read each plane's wire byte, reconciling against
 // the engine's fixed buffer convention (plane0 bit=white, plane1 bit=red). A
@@ -186,7 +219,8 @@ template<typename Driver> PlaneInvert probe_plane_invert_values(uint8_t p0cmd, u
   return r;
 }
 
-template<typename Driver> void emit_descriptor(const char *class_name, const char *macro) {
+template<typename Driver>
+void emit_descriptor_impl(const char *class_name, const char *macro, const PackedResolverFn &resolve_packed) {
   constexpr int16_t CS = 10, DC = 11, RST = 12, BUSY = 13;
   Trace::set_dc_pin(DC);
   Peek<Driver> driver(CS, DC, RST, BUSY);
@@ -354,6 +388,14 @@ template<typename Driver> void emit_descriptor(const char *class_name, const cha
   const uint8_t p1cmd = plane_cmds.size() > 1 ? plane_cmds[1] : 0x00;
   const PlaneInvert pv = probe_plane_invert_values<Driver>(p0cmd, p1cmd, nplanes);
 
+  // Packed-family resolution (4-colour palette etc.), run once here so both the
+  // busy-timeout class and the packed fields below use the same answer. 1bpp
+  // panels skip it. An unresolved packed panel (no family resolver, e.g.
+  // 7-colour) is treated as the slow class for the timeout floor.
+  const PackedResolved pr =
+      (!pv.is_1bpp && resolve_packed) ? resolve_packed(clear_fill, pv) : PackedResolved{};
+  const int depth_bpp = pv.is_1bpp ? 1 : (pr.resolved ? pr.bits_per_pixel : 4);
+
   // Refresh = post-write commands after the LAST burst (e.g. UC8179's 0x92),
   // then the refresh call minus any window-set it repeats.
   std::set<uint8_t> all_wops;
@@ -370,9 +412,11 @@ template<typename Driver> void emit_descriptor(const char *class_name, const cha
       refresh_seq.push_back(c);
 
   // --- emit ---
-  printf("// GENERATED by emit_descriptor from %s - candidate, segmentation only.\n", class_name);
-  printf("// bits_per_pixel/palette/plane_invert/plane_width/line_order still\n");
-  printf("// come from the diag passes.\n");
+  printf("// GENERATED by emit_descriptor from %s - candidate.\n", class_name);
+  printf("// Command sequences, plane layout and plane_invert are observed here;\n");
+  printf("// for a packed panel bits_per_pixel/palette/blank_code are probe-filled\n");
+  printf("// when the colour family is recognised (4-colour), else left TODO.\n");
+  printf("// plane_width / line_order (dual-region) still come from the diag passes.\n");
   printf("#pragma once\n#include \"epd_command.h\"\n\nnamespace esphome::gxepd2 {\n\n");
 
   auto emit_seq = [&](const char *seqsuffix, std::vector<Cmd> &cmds) {
@@ -403,7 +447,11 @@ template<typename Driver> void emit_descriptor(const char *class_name, const cha
   printf("    .width = %u,\n", (unsigned) Driver::WIDTH);
   printf("    .height = %u,\n", (unsigned) Driver::HEIGHT);
   printf("    .busy_active_level = %s,\n", bl == HIGH ? "true" : "false");
-  printf("    .busy_timeout_ms = %u,\n", (unsigned) Driver::full_refresh_time + 5000u);
+  const char *depth_class = depth_bpp >= 4 ? "4bpp 7-colour" : depth_bpp == 2 ? "2bpp 4-colour" : "1bpp mono/3-colour";
+  printf("    .busy_timeout_ms = %u,  // generous diagnostic ceiling (vendor full_refresh_time %ums, "
+         "%s class); dev-tunable\n",
+         (unsigned) default_busy_timeout_ms(Driver::full_refresh_time, depth_bpp),
+         (unsigned) Driver::full_refresh_time, depth_class);
   printf("    .init_sequence = %s_INIT,\n", macro);
   printf("    .init_sequence_len = sizeof(%s_INIT) / sizeof(%s_INIT[0]),\n", macro, macro);
   if (per_plane) {
@@ -438,16 +486,35 @@ template<typename Driver> void emit_descriptor(const char *class_name, const cha
     printf("    .palette = nullptr,\n");
     printf("    .palette_len = 0,\n");
   } else {
-    // Packed panel: depth/palette/blank_code are the diag pass's job.
-    printf("    .bits_per_pixel = 2,  // TODO: diag pass - PACKED (white fill wire 0x%02X, not 1bpp)\n",
-           pv.w0_white & 0xFF);
-    printf("    .palette = nullptr,  // TODO: diag pass\n");
-    printf("    .palette_len = 0,\n");
-    printf("    .blank_code = 0x00,  // TODO: diag pass (clear fill observed 0x%02X)\n", clear_fill);
+    // Packed panel. If a family resolver recognised it (4-colour, via
+    // emit_palette_4c.h), it derived depth/palette/blank_code by observing the
+    // driver's own colour fills - emit those (computed once, above). Otherwise
+    // fall back to a TODO the diag pass fills in by hand (an unknown packed
+    // family, e.g. 7-colour before its resolver exists).
+    if (pr.resolved) {
+      printf("    .bits_per_pixel = %d,  // probe: %d-colour packed%s%s\n", pr.bits_per_pixel,
+             1 << pr.bits_per_pixel, pr.note.empty() ? "" : " - ", pr.note.c_str());
+      printf("    .palette = %s,\n", pr.palette_expr);
+      printf("    .palette_len = %s,\n", pr.palette_len_expr);
+      printf("    .blank_code = 0x%02X,  // observed white fill code\n", pr.blank_code & 0xFF);
+    } else {
+      printf("    .bits_per_pixel = 2,  // TODO: diag pass - PACKED (white fill wire 0x%02X, not 1bpp)%s%s\n",
+             pv.w0_white & 0xFF, pr.note.empty() ? "" : " - ", pr.note.c_str());
+      printf("    .palette = nullptr,  // TODO: diag pass\n");
+      printf("    .palette_len = 0,\n");
+      printf("    .blank_code = 0x00,  // TODO: diag pass (clear fill observed 0x%02X)\n", clear_fill);
+    }
   }
   printf("    .refresh_sequence = %s_REFRESH,\n", macro);
   printf("    .refresh_sequence_len = sizeof(%s_REFRESH) / sizeof(%s_REFRESH[0]),\n", macro, macro);
   printf("};\n\n}  // namespace esphome::gxepd2\n");
 }
 
+// Generic entry: no packed-family resolver, so a packed panel's
+// depth/palette/blank_code emit as TODO placeholders (the pre-resolver
+// behaviour). The 4-colour entry is emit_descriptor_4c() in emit_palette_4c.h.
+template<typename Driver> void emit_descriptor(const char *class_name, const char *macro) {
+  emit_descriptor_impl<Driver>(class_name, macro, nullptr);
+}
+
 }  // namespace emit

+ 147 - 0
tools/gxepd2_harness/emit_palette_4c.h

@@ -0,0 +1,147 @@
+#pragma once
+
+// 4-colour (BWRY, 2bpp packed) palette/blank-code resolver for the descriptor
+// emitter. This is the diag Pattern-B colour->code derivation (run_diagB_
+// harness.h) folded into the one-command capture, so a 4-colour single-region
+// panel comes out of tools/capture.py complete - bits_per_pixel / palette /
+// blank_code filled - instead of carrying TODOs a human pastes in afterwards.
+//
+// It stays observation-based, not a hardcode: it drives the vendor driver's
+// own GxEPD2_4C wrapper (fillScreen of each named colour) and reads the wire
+// code the driver emits for each, then CONFIRMS that table matches the engine's
+// GXEPD2_PALETTE_4C codes (epd_command.h) before referencing it. A panel whose
+// codes don't match - a non-standard 4-colour map, or something that isn't
+// really 2bpp - stays unresolved and emits the old TODO plus the observed
+// table, so a surprise is visible, never silently mislabelled.
+//
+// Include order (tools/capture.py's generated 4C main handles this): the driver
+// header and GxEPD2_4C.h first (they define GxEPD_* and the WIDTH/HEIGHT the
+// wrapper references unqualified), then emit_descriptor.h, then this file.
+
+#include <cstdint>
+#include <cstdio>
+#include <string>
+#include <vector>
+
+#include "GxEPD2_4C.h"
+#include "emit_descriptor.h"
+#include "trace.h"
+
+namespace emit {
+
+// The engine's canonical 4-colour map (mirrors epd_command.h GXEPD2_PALETTE_4C
+// / GxEPD2_4C.h color4()): the wire code each named colour must resolve to for
+// the standard palette to be the right answer.
+struct Expect4C {
+  uint16_t color;
+  uint8_t code;
+  const char *label;
+};
+inline const Expect4C EXPECT_4C[] = {
+    {GxEPD_BLACK, 0x00, "black"},  {GxEPD_WHITE, 0x01, "white"},  {GxEPD_YELLOW, 0x02, "yellow"},
+    {GxEPD_GREEN, 0x02, "green"},  {GxEPD_ORANGE, 0x02, "orange"}, {GxEPD_RED, 0x03, "red"},
+    {GxEPD_BLUE, 0x00, "blue"},
+};
+
+// First run of exactly plane_bytes data bytes in the current Trace::record
+// (one fillScreen flush), or empty. Mirrors run_diagB_harness.h.
+inline std::vector<uint8_t> first_plane_burst(uint32_t plane_bytes) {
+  std::vector<uint8_t> run, best;
+  auto flush = [&]() {
+    if (run.size() == plane_bytes && best.empty())
+      best = run;
+    run.clear();
+  };
+  for (const auto &ev : Trace::record) {
+    if (ev.first)  // command byte: ends the current data run
+      flush();
+    else
+      run.push_back(ev.second);
+  }
+  flush();
+  return best;
+}
+
+// Build the 4-colour packed resolver for `Driver`. It instantiates the vendor
+// GxEPD2_4C<Peek<Driver>, HEIGHT> wrapper (single full-height page) and reads
+// each colour's code off the wire.
+template<typename Driver> PackedResolverFn make_resolver_4c() {
+  return [](uint8_t /*clear_fill*/, const PlaneInvert & /*pv*/) -> PackedResolved {
+    constexpr int bpp = 2, ppb = 4;  // 4-colour: 2 bits/pixel, 4 pixels/byte, MSB-first
+    const uint16_t W = Driver::WIDTH, H = Driver::HEIGHT;
+    const uint32_t plane_bytes = (uint32_t) W * H * bpp / 8;
+
+    using Wrapper = GxEPD2_4C<Peek<Driver>, Driver::HEIGHT>;  // page_height=HEIGHT => one page
+    Wrapper *dp = new Wrapper(Peek<Driver>(10, 11, 12, 13));
+    Wrapper &display = *dp;
+    Trace::set_dc_pin(11);
+    Trace::last_busy_release_value = display.epd2.busy_level() == HIGH ? LOW : HIGH;
+    display.epd2.selectSPI(SPI, SPISettings());
+    display.init(0);
+    display.setFullWindow();
+
+    // Prime (untracked) so the first-page buffer prime isn't read as a fill.
+    display.firstPage();
+    do {
+      display.fillScreen(GxEPD_WHITE);
+    } while (display.nextPage());
+
+    auto code_of = [&](uint16_t color, bool &uniform) -> int {
+      Trace::start_record();
+      display.firstPage();
+      do {
+        display.fillScreen(color);
+      } while (display.nextPage());
+      Trace::stop_record();
+      auto b = first_plane_burst(plane_bytes);
+      if (b.empty())
+        return -1;
+      uint8_t byte0 = b[0];
+      uint8_t f0 = (byte0 >> ((ppb - 1) * bpp)) & 0x3;  // MSB field
+      uniform = true;
+      for (int j = 1; j < ppb; j++)
+        if (((byte0 >> ((ppb - 1 - j) * bpp)) & 0x3) != f0)
+          uniform = false;
+      return f0;
+    };
+
+    const int ncol = (int) (sizeof(EXPECT_4C) / sizeof(EXPECT_4C[0]));
+    bool matches = true;
+    int white_code = -1;
+    std::string observed;
+    for (int k = 0; k < ncol; k++) {
+      bool uniform = false;
+      int code = code_of(EXPECT_4C[k].color, uniform);
+      char buf[48];
+      snprintf(buf, sizeof(buf), "%s%s=%d", k ? " " : "", EXPECT_4C[k].label, code);
+      observed += buf;
+      if (EXPECT_4C[k].color == GxEPD_WHITE)
+        white_code = code;
+      if (!uniform || code != (int) EXPECT_4C[k].code)
+        matches = false;
+    }
+    delete dp;
+
+    PackedResolved r;
+    if (matches) {
+      r.resolved = true;
+      r.bits_per_pixel = bpp;
+      r.palette_expr = "GXEPD2_PALETTE_4C";
+      r.palette_len_expr = "GXEPD2_PALETTE_4C_LEN";
+      r.blank_code = white_code;  // panel's white/clear code (0x01 for GxEPD2 4C)
+    } else {
+      r.resolved = false;
+      r.note = "4C probe mismatch, observed [" + observed + "] (expected black=0 white=1 yellow/green/orange=2 red=3 blue=0)";
+    }
+    return r;
+  };
+}
+
+// 4-colour capture entry: like emit_descriptor(), but resolves the packed
+// fields by observing the GxEPD2_4C wrapper. tools/capture.py calls this for an
+// epd4c/ driver.
+template<typename Driver> void emit_descriptor_4c(const char *class_name, const char *macro) {
+  emit_descriptor_impl<Driver>(class_name, macro, make_resolver_4c<Driver>());
+}
+
+}  // namespace emit

+ 4 - 7
tools/gxepd2_harness/run_213c_gdey0213f52_emit.cpp

@@ -1,8 +1,5 @@
-#include "emit_descriptor.h"
-
 #include "epd4c/GxEPD2_213c_GDEY0213F52.h"
-
-int main() {
-  emit::emit_descriptor<GxEPD2_213c_GDEY0213F52>("GxEPD2_213c_GDEY0213F52", "PANEL_213C_GDEY0213F52");
-  return 0;
-}
+constexpr int16_t WIDTH = GxEPD2_213c_GDEY0213F52::WIDTH;
+constexpr int16_t HEIGHT = GxEPD2_213c_GDEY0213F52::HEIGHT;
+#include "emit_palette_4c.h"
+int main() { emit::emit_descriptor_4c<GxEPD2_213c_GDEY0213F52>("GxEPD2_213c_GDEY0213F52", "PANEL_213C_GDEY0213F52"); return 0; }

+ 4 - 7
tools/gxepd2_harness/run_290c_gdey029f51_emit.cpp

@@ -1,8 +1,5 @@
-#include "emit_descriptor.h"
-
 #include "epd4c/GxEPD2_290c_GDEY029F51.h"
-
-int main() {
-  emit::emit_descriptor<GxEPD2_290c_GDEY029F51>("GxEPD2_290c_GDEY029F51", "PANEL_290C_GDEY029F51");
-  return 0;
-}
+constexpr int16_t WIDTH = GxEPD2_290c_GDEY029F51::WIDTH;
+constexpr int16_t HEIGHT = GxEPD2_290c_GDEY029F51::HEIGHT;
+#include "emit_palette_4c.h"
+int main() { emit::emit_descriptor_4c<GxEPD2_290c_GDEY029F51>("GxEPD2_290c_GDEY029F51", "PANEL_290C_GDEY029F51"); return 0; }