Преглед изворни кода

emitter: segment dual-region panels (GDEY0579F51 reproduces end to end)

The descriptor emitter now models the region-select layer of a dual-chip
master/slave panel, so GxEPD2_579c_GDEY0579F51 is reproduced from its driver
instead of hand-assembled. Four additions on the existing interleaved
multi-plane path, all observation-based:

- INIT recovery. A dual-region panel runs its whole _InitDisplay during the
  writeScreenBuffer clear, and that init reuses the region-select opcode (0xA2
  select per region for PSR), so the single-plane "cut at first window opcode"
  rule fails. Instead take init from the clear stage and drop only the
  contiguous trailing run of window-set commands before the first clear burst.

- plane_width, decoded from the region window-set payload (x_end-x+1 = 396).

- line_order. A region whose burst is twice its pixel bytes is sending two wire
  lines per row (GxEPD2 writeNative's doubled emission) -> candidate
  PAIRED_EDGES. The doubling is observable; the exact edges-to-middle row order
  is not (a uniform fill can't show which buffer row each line carries), so it's
  emitted as a candidate for the diag native-path pass / driver source to
  confirm.

- refresh keeps its distinct both-region window (dropped for a shared-region
  panel as a duplicate), and trims the window GxEPD2 re-sets after the trigger
  to prime its next writeImage (dead in our per-cycle replay).

The emitted 579 is structurally identical to the committed hand-derived header
(INIT/WINDOW/REFRESH, plane layout, plane_width, line_order, blank_code all
match; only the dev-tunable busy_timeout differs). Promoted out of
check_descriptors' KNOWN_DIFF -> all 9 panels reproduce, 0 known-diff. The
shipped panel_579c header is unchanged (keeps its tuned timeout and history).

What still needs hand work: a panel with more than two regions, or asymmetric
regions, and the exact PAIRED_EDGES row order confirmation.
ZiB пре 3 недеља
родитељ
комит
924158f1dc
4 измењених фајлова са 119 додато и 19 уклоњено
  1. 8 4
      CONTRIBUTING.md
  2. 6 3
      tools/check_descriptors.py
  3. 15 7
      tools/gxepd2_harness/README.md
  4. 90 5
      tools/gxepd2_harness/emit_descriptor.h

+ 8 - 4
CONTRIBUTING.md

@@ -113,10 +113,14 @@ These are hardware facts no command trace contains:
   confirmable on hardware. The engine always does a busy wait after the reset
   confirmable on hardware. The engine always does a busy wait after the reset
   pulse, because controllers that load from OTP hold BUSY during that load and
   pulse, because controllers that load from OTP hold BUSY during that load and
   silently drop any command sent before it clears.
   silently drop any command sent before it clears.
-- **True dual-region command structure.** Dual-chip panels (master/slave) carry
-  `0xA2`-style region selects in init and refresh that the emitter's
-  single/shared-region segmentation doesn't yet model; those descriptors still
-  take manual assembly for the region layer.
+- **Paired-edges row order.** The emitter now segments dual-chip (master/slave)
+  panels end to end — the `0xA2`-style region selects in init / window / refresh,
+  per-region `plane_width`, and a candidate `line_order` (it flags `PAIRED_EDGES`
+  when a region's burst is twice its pixel bytes). What a plain capture still
+  can't see is *which* buffer row each wire line carries, so for a `PAIRED_EDGES`
+  panel the exact edges-to-middle order is confirmed from the diag native-path
+  pass or the driver source, not the capture alone. A panel with more than two
+  regions, or asymmetric regions, would still want manual review.
 
 
 ## Execution engine (`cycle_engine.h`)
 ## Execution engine (`cycle_engine.h`)
 
 

+ 6 - 3
tools/check_descriptors.py

@@ -33,9 +33,12 @@ import capture as cap  # noqa: E402  (resolution + emit, reused)
 
 
 PANEL_DIR = ROOT / "components" / "gxepd2"
 PANEL_DIR = ROOT / "components" / "gxepd2"
 
 
-# Dual-region panels the emitter's single/shared-region segmentation doesn't
-# model yet (its init/refresh region-select layer). Expected to differ.
-KNOWN_DIFF = {"GxEPD2_579c_GDEY0579F51"}
+# Panels the emitter's segmentation doesn't reproduce yet. The dual-region
+# GDEY0579F51 used to live here; the emitter now models the region-select layer
+# (per-region init/window/refresh, plane_width, paired-edges line doubling), so
+# it reproduces and has been promoted out. A future panel the emitter can't yet
+# segment goes here with a one-line why.
+KNOWN_DIFF: set = set()
 
 
 
 
 def registry():
 def registry():

+ 15 - 7
tools/gxepd2_harness/README.md

@@ -39,9 +39,14 @@ resolution / busy-polarity / timeout from the driver, and derives
 `GxEPD2_4C` wrapper, reads the wire code for each named colour, validates it
 `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` /
 against the engine's `GXEPD2_PALETTE_4C`, and fills `bits_per_pixel` /
 `palette` / `blank_code` automatically (an unexpected code map stays a TODO
 `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
+carrying the observed table). A **dual-region** panel (per-region select +
+window, e.g. GDEY0579F51 / HX8717) is segmented too: its per-region init /
+window / refresh, `plane_width` (decoded from the region window-set) and a
+candidate `line_order` (`PAIRED_EDGES` when a region's burst is 2x its pixel
+bytes) all come out, with only the exact paired-edges row order left for the
+diag native-path pass to confirm. Review the result, add any packed-format
+fields still left as TODO — `bits_per_pixel` / `palette` for a 7-colour panel
+(no resolver yet) — then save it
 as `components/gxepd2/panel_<panel>.h`. `capture.py` also prints the exact
 as `components/gxepd2/panel_<panel>.h`. `capture.py` also prints the exact
 `PANELS` line to paste into `display.py` (key / descriptor macro / format
 `PANELS` line to paste into `display.py` (key / descriptor macro / format
 filled from the capture; `part` / `controller` scraped from the driver header
 filled from the capture; `part` / `controller` scraped from the driver header
@@ -61,10 +66,13 @@ tools/check_descriptors.py        # all panels; -v shows the diff on a mismatch
 ```
 ```
 
 
 A `DIFF` means either an emitter regression or **GxEPD2 upstream drift** (a
 A `DIFF` means either an emitter regression or **GxEPD2 upstream drift** (a
-vendor bump changed that driver — review the panel). Dual-region panels the
-emitter doesn't fully model yet are listed in its `KNOWN_DIFF` and reported as
-`XFAIL`; panels with no GxEPD2 driver source (the synthetic `MASTER_ONLY`
-variant) are skipped. Run it after any `third_party/GxEPD2/` update.
+vendor bump changed that driver — review the panel). Any panel the emitter
+can't yet segment is listed in its `KNOWN_DIFF` and reported as `XFAIL` (none
+at present — the dual-region GDEY0579F51 was promoted out once the emitter
+modelled the region-select layer; an `XPASS` means a `KNOWN_DIFF` entry now
+reproduces and should be removed). Panels with no GxEPD2 driver source (the
+synthetic `MASTER_ONLY` variant) are skipped. Run it after any
+`third_party/GxEPD2/` update.
 
 
 ## Other programs
 ## Other programs
 
 

+ 90 - 5
tools/gxepd2_harness/emit_descriptor.h

@@ -293,6 +293,9 @@ void emit_descriptor_impl(const char *class_name, const char *macro, const Packe
   std::vector<Cmd> init_seq, window0, window1;
   std::vector<Cmd> init_seq, window0, window1;
   std::vector<uint8_t> plane_cmds;
   std::vector<uint8_t> plane_cmds;
   bool per_plane = false;
   bool per_plane = false;
+  bool dual_region = false;        // two physical regions (per-region select + window)
+  int plane_width_val = 0;         // decoded per-region width (dual_region only)
+  uint32_t region0_burst = 0;      // region 0's image burst length, for line-order detection
 
 
   if (bursts.size() >= 2) {
   if (bursts.size() >= 2) {
     // Multi-plane. Two observed layouts:
     // Multi-plane. Two observed layouts:
@@ -359,6 +362,50 @@ void emit_descriptor_impl(const char *class_name, const char *macro, const Packe
     for (size_t k = 1; k < windows.size(); k++)
     for (size_t k = 1; k < windows.size(); k++)
       if (!cmds_equal(windows[0], windows[k]))
       if (!cmds_equal(windows[0], windows[k]))
         per_plane = true;  // per-plane windows differ => separate regions
         per_plane = true;  // per-plane windows differ => separate regions
+
+    // Dual-region refinement (e.g. GDEY0579F51 / HX8717: a two-chip panel whose
+    // "planes" are two physical half-width regions, each gated by a region-
+    // select command before its own window + burst). The generic interleaved
+    // path above already recovers the per-region windows and plane commands;
+    // three things it can't are filled here.
+    if (interleaved && per_plane) {
+      dual_region = true;
+      std::set<uint8_t> wops;  // region-select + window-set opcodes
+      for (const auto &c : window0)
+        wops.insert(c.cmd);
+      for (const auto &c : window1)
+        wops.insert(c.cmd);
+
+      // (1) INIT. A dual-region panel runs its whole _InitDisplay during the
+      // earlier writeScreenBuffer clear, and that init REUSES the region-select
+      // opcode (select master/slave for each region's PSR), so the single-plane
+      // "cut at the first window opcode" rule would cut at the very first init
+      // command. Instead take init from the clear stage and drop only the
+      // CONTIGUOUS trailing run of window-set commands before the first clear
+      // burst (the both-/first-region window-sets); the select/PSR pairs earlier
+      // in init survive because a non-window command (BTST, power-on, ...)
+      // separates them from that trailing run.
+      const size_t cb = plane_index(init_stage);  // first clear burst
+      size_t cut = cb;
+      while (cut > 0 && wops.count(init_stage[cut - 1].cmd))
+        cut--;
+      init_seq.assign(init_stage.begin(), init_stage.begin() + cut);
+
+      // (2) plane_width: each region addresses its own half. Decode it from the
+      // window-set command's payload - first two big-endian 16-bit fields are
+      // x and x_end, as every window encoder observed so far lays them out.
+      for (const auto &c : window0)
+        if (c.data.size() >= 4) {
+          int x = (c.data[0] << 8) | c.data[1];
+          int xe = (c.data[2] << 8) | c.data[3];
+          plane_width_val = xe - x + 1;
+          break;
+        }
+
+      // (3) line doubling: region 0's burst length, compared in the emit stage
+      // against plane_width*height*bpp/8 to flag PAIRED_EDGES.
+      region0_burst = (uint32_t) image_stage[bursts[0]].data.size();
+    }
   } else {
   } else {
     // Single plane: window = the image call's pre-burst block; init comes
     // Single plane: window = the image call's pre-burst block; init comes
     // from the writeScreenBuffer stage, cut at the first window-set command
     // from the writeScreenBuffer stage, cut at the first window-set command
@@ -407,16 +454,32 @@ void emit_descriptor_impl(const char *class_name, const char *macro, const Packe
   if (!bursts.empty())
   if (!bursts.empty())
     for (size_t i = bursts.back() + 1; i < image_stage.size() && image_stage[i].data.size() < PLANE_BURST_MIN; i++)
     for (size_t i = bursts.back() + 1; i < image_stage.size() && image_stage[i].data.size() < PLANE_BURST_MIN; i++)
       refresh_seq.push_back(image_stage[i]);
       refresh_seq.push_back(image_stage[i]);
+  // A shared-region panel re-sets its one window in refresh() and we drop that
+  // duplicate; a dual-region panel's refresh instead sets a DISTINCT both-region
+  // window (its own select payload), which is part of the refresh - keep it.
   for (const auto &c : refresh_stage)
   for (const auto &c : refresh_stage)
-    if (!all_wops.count(c.cmd))
+    if (dual_region || !all_wops.count(c.cmd))
       refresh_seq.push_back(c);
       refresh_seq.push_back(c);
+  // The refresh ends at its trigger (the command we busy-wait on). A dual-region
+  // driver re-sets the window AFTER the trigger to prime its own next writeImage;
+  // our engine always sets its own window per cycle, so that trailing window is
+  // dead in replay - trim anything past the last busy-wait.
+  if (dual_region) {
+    size_t last_wait = refresh_seq.size();
+    for (size_t i = 0; i < refresh_seq.size(); i++)
+      if (refresh_seq[i].wait_busy)
+        last_wait = i;
+    if (last_wait < refresh_seq.size())
+      refresh_seq.resize(last_wait + 1);
+  }
 
 
   // --- emit ---
   // --- emit ---
   printf("// GENERATED by emit_descriptor from %s - candidate.\n", class_name);
   printf("// GENERATED by emit_descriptor from %s - candidate.\n", class_name);
   printf("// Command sequences, plane layout and plane_invert are observed here;\n");
   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("// 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("// when the colour family is recognised (4-colour), else left TODO. A\n");
+  printf("// dual-region panel also gets plane_width and a candidate line_order; the\n");
+  printf("// exact PAIRED_EDGES row order still wants diag/driver confirmation.\n");
   printf("#pragma once\n#include \"epd_command.h\"\n\nnamespace esphome::gxepd2 {\n\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) {
   auto emit_seq = [&](const char *seqsuffix, std::vector<Cmd> &cmds) {
@@ -478,8 +541,30 @@ void emit_descriptor_impl(const char *class_name, const char *macro, const Packe
   } else {
   } else {
     printf("    .plane_invert = {false, false},  // unused: packed panel\n");
     printf("    .plane_invert = {false, false},  // unused: packed panel\n");
   }
   }
-  if (per_plane)
-    printf("    .plane_width = 0,  // TODO: diag pass (separate-region width)\n");
+  if (per_plane) {
+    if (dual_region && plane_width_val > 0)
+      printf("    .plane_width = %d,  // decoded from the region window-set (x_end-x+1); sanity-check\n",
+             plane_width_val);
+    else
+      printf("    .plane_width = 0,  // TODO: diag pass (separate-region width)\n");
+  }
+  // line_order: a dual-region panel whose per-region burst is twice the region's
+  // own pixel bytes is sending two wire lines per row (GxEPD2 writeNative's
+  // doubled, both-edges-to-middle emission). The doubling is observable here;
+  // the exact row order that PAIRED_EDGES encodes is NOT (a uniform fill can't
+  // show which buffer row each line carries), so emit it as a candidate the
+  // diag native-path pass / driver source confirms.
+  if (dual_region && plane_width_val > 0 && depth_bpp > 0) {
+    uint32_t expect = (uint32_t) plane_width_val * Driver::HEIGHT * depth_bpp / 8;
+    if (expect && region0_burst == 2u * expect)
+      printf("    .line_order = LineOrder::PAIRED_EDGES,  // region burst %u B = 2x %u B (2 lines/row); "
+             "confirm edges-to-middle order via the diag native-path pass\n",
+             region0_burst, expect);
+    else if (expect && region0_burst != expect)
+      printf("    // NOTE: region burst %u B vs expected %u B (1 line/row) - line order unclear, "
+             "check with the diag native-path pass\n",
+             region0_burst, expect);
+  }
   if (pv.is_1bpp) {
   if (pv.is_1bpp) {
     // 1bpp plane model: depth confirmed by the probe; palette/blank_code unused.
     // 1bpp plane model: depth confirmed by the probe; palette/blank_code unused.
     printf("    .bits_per_pixel = 1,  // probe: white fill => all-bits wire byte 0x%02X\n", pv.w0_white & 0xFF);
     printf("    .bits_per_pixel = 1,  // probe: white fill => all-bits wire byte 0x%02X\n", pv.w0_white & 0xFF);