فهرست منبع

engine: generalize row order to LineOrder; make 579c a faithful GxEPD2 replay

The 579 only rendered via two compensating descriptor hacks (PSR
scan-direction swap + master/slave window-order swap). Root cause: our
engine emitted ONE wire line per source row (26928 B/region) while
GxEPD2's writeNative emits TWO (53856 B/region, 544 lines) in paired
top/bottom-from-edges order to fill the controller's 2x-height RAM. The
user confirmed GxEPD2 drives this panel correctly when run natively, so a
byte-faithful replay is the right fix - not the hacks.

Engine:
  - Replace the `bool row_interleave` 1:1 row permutation with a
    `LineOrder` enum {SEQUENTIAL, PAIRED_EDGES}. SEQUENTIAL is unchanged
    (identity, line count == height). PAIRED_EDGES emits 2*height lines,
    line 2i = row (H-1-i), line 2i+1 = row i.
  - Decouple wire length (wire_bytes_) from buffer size (plane_bytes_), so
    a plane can send more lines than it has rows. WRITE_PLANE now iterates
    wire_bytes_ and maps each wire byte through line_src_index_.

579c descriptor:
  - line_order = PAIRED_EDGES; PSR back to stock (master {0x07,0x29} /
    slave {0x03,0x29}); window order back to master-then-slave. Only
    busy_timeout_ms stays tuned (40s; engine hard-aborts where GxEPD2
    polls BUSY past its soft 21s estimate).

Validated against GxEPD2 ground truth: run_579c_gdey0579f51_diagN reports
both regions as 544 lines, order 271 0 270 1 ... 0 271; the engine's
line_src_index_ reproduces that sequence and line count exactly.
check_descriptors: 579c XFAIL (dual-region init still hand-assembled),
all others MATCH.
ZiB 3 هفته پیش
والد
کامیت
bb09adc601

+ 39 - 20
components/gxepd2/cycle_engine.h

@@ -55,6 +55,13 @@ class CycleEngine {
     // width*height*bpp/8: /8 at 1bpp, /4 at 2bpp, /2 at 4bpp.
     // width*height*bpp/8: /8 at 1bpp, /4 at 2bpp, /2 at 4bpp.
     this->plane_bytes_ = (uint32_t) w * this->desc_->height * this->desc_->bits_per_pixel / 8;
     this->plane_bytes_ = (uint32_t) w * this->desc_->height * this->desc_->bits_per_pixel / 8;
     this->bytes_per_row_ = this->plane_bytes_ / this->desc_->height;
     this->bytes_per_row_ = this->plane_bytes_ / this->desc_->height;
+    // Wire length can exceed the buffer: PAIRED_EDGES sends each row twice
+    // (2x-height RAM). plane_bytes_ stays the real buffer size; wire_bytes_
+    // is what actually goes out on 0x10/0x24/... per plane.
+    uint32_t wire_lines = this->desc_->height;
+    if (this->desc_->line_order == LineOrder::PAIRED_EDGES)
+      wire_lines *= 2;
+    this->wire_bytes_ = wire_lines * this->bytes_per_row_;
     this->cursor_ = 0;
     this->cursor_ = 0;
     this->plane_ = 0;
     this->plane_ = 0;
     this->cmd_phase_ = CmdPhase::BEFORE_DELAY;
     this->cmd_phase_ = CmdPhase::BEFORE_DELAY;
@@ -276,9 +283,9 @@ class CycleEngine {
             histo[2]++;
             histo[2]++;
         }
         }
       }
       }
-      ESP_LOGD("gxepd2", "plane %u: cmd 0x%02X, %u bytes, invert=%d, buf[0]=0x%02X buf[last]=0x%02X, histo(00/FF/other)=%u/%u/%u",
-               this->plane_, this->desc_->plane_write_cmd[this->plane_], (unsigned) this->plane_bytes_,
-               this->desc_->plane_invert[this->plane_], buf != nullptr ? buf[0] : 0,
+      ESP_LOGD("gxepd2", "plane %u: cmd 0x%02X, %u wire bytes (buf %u), invert=%d, buf[0]=0x%02X buf[last]=0x%02X, histo(00/FF/other)=%u/%u/%u",
+               this->plane_, this->desc_->plane_write_cmd[this->plane_], (unsigned) this->wire_bytes_,
+               (unsigned) this->plane_bytes_, this->desc_->plane_invert[this->plane_], buf != nullptr ? buf[0] : 0,
                buf != nullptr ? buf[this->plane_bytes_ - 1] : 0, (unsigned) histo[0], (unsigned) histo[1],
                buf != nullptr ? buf[this->plane_bytes_ - 1] : 0, (unsigned) histo[0], (unsigned) histo[1],
                (unsigned) histo[2]);
                (unsigned) histo[2]);
       this->write_byte_(this->desc_->plane_write_cmd[this->plane_], false);
       this->write_byte_(this->desc_->plane_write_cmd[this->plane_], false);
@@ -286,14 +293,14 @@ class CycleEngine {
     const uint8_t *buf = this->planes_[this->plane_];
     const uint8_t *buf = this->planes_[this->plane_];
     bool invert = this->desc_->plane_invert[this->plane_];
     bool invert = this->desc_->plane_invert[this->plane_];
     uint32_t end = this->cursor_ + BYTE_BUDGET;
     uint32_t end = this->cursor_ + BYTE_BUDGET;
-    if (end > this->plane_bytes_)
-      end = this->plane_bytes_;
+    if (end > this->wire_bytes_)
+      end = this->wire_bytes_;
     for (; this->cursor_ < end; this->cursor_++) {
     for (; this->cursor_ < end; this->cursor_++) {
-      uint32_t src = this->desc_->row_interleave ? this->interleaved_index_(this->cursor_) : this->cursor_;
+      uint32_t src = this->line_src_index_(this->cursor_);
       uint8_t b = buf != nullptr ? buf[src] : 0xFF;
       uint8_t b = buf != nullptr ? buf[src] : 0xFF;
       this->write_byte_(invert ? static_cast<uint8_t>(~b) : b, true);
       this->write_byte_(invert ? static_cast<uint8_t>(~b) : b, true);
     }
     }
-    if (this->cursor_ >= this->plane_bytes_) {
+    if (this->cursor_ >= this->wire_bytes_) {
       this->cursor_ = 0;
       this->cursor_ = 0;
       this->plane_++;
       this->plane_++;
       this->cmd_phase_ = CmdPhase::BEFORE_DELAY;
       this->cmd_phase_ = CmdPhase::BEFORE_DELAY;
@@ -313,18 +320,29 @@ class CycleEngine {
     }
     }
   }
   }
 
 
-  // Maps a wire-transmission-order byte position to the buffer's actual
-  // (plain top-to-bottom) byte index, for panels whose controller expects
-  // rows in interleaved top/bottom pairs (row 0, row H-1, row 1, row H-2,
-  // ...) instead of sequential order - see EpdPanelDescriptor::row_interleave.
-  // wire_pos counts up linearly (0..plane_bytes_-1) the same way it always
-  // has; only which buffer byte it reads changes.
-  uint32_t interleaved_index_(uint32_t wire_pos) const {
-    uint32_t wire_row = wire_pos / this->bytes_per_row_;
-    uint32_t byte_in_row = wire_pos % this->bytes_per_row_;
-    uint32_t pair = wire_row / 2;
-    uint32_t buf_row = (wire_row % 2 == 0) ? pair : (this->desc_->height - 1 - pair);
-    return buf_row * this->bytes_per_row_ + byte_in_row;
+  // Maps a wire-transmission-order byte position (0..wire_bytes_-1) to the
+  // buffer byte it reads. The wire is a sequence of lines, each bytes_per_row_
+  // bytes from one buffer row; line_order picks which buffer row each wire
+  // line carries (and, for PAIRED_EDGES, there are 2x as many wire lines as
+  // buffer rows). SEQUENTIAL is the identity (wire line i -> row i).
+  uint32_t line_src_index_(uint32_t wire_pos) const {
+    uint32_t wire_line = wire_pos / this->bytes_per_row_;
+    uint32_t byte_in_line = wire_pos % this->bytes_per_row_;
+    uint32_t buf_row;
+    switch (this->desc_->line_order) {
+      case LineOrder::PAIRED_EDGES: {
+        // wire lines come in pairs: for i in [0,H), line 2i = row (H-1-i),
+        // line 2i+1 = row i. Matches GxEPD2 writeNative's emission exactly.
+        uint32_t i = wire_line / 2;
+        buf_row = (wire_line % 2 == 0) ? (this->desc_->height - 1 - i) : i;
+        break;
+      }
+      case LineOrder::SEQUENTIAL:
+      default:
+        buf_row = wire_line;
+        break;
+    }
+    return buf_row * this->bytes_per_row_ + byte_in_line;
   }
   }
 
 
   void start_delay_(uint16_t delay_ms, State return_to) {
   void start_delay_(uint16_t delay_ms, State return_to) {
@@ -410,7 +428,8 @@ class CycleEngine {
   CmdPhase cmd_phase_{CmdPhase::BEFORE_DELAY};
   CmdPhase cmd_phase_{CmdPhase::BEFORE_DELAY};
   uint8_t plane_{0};
   uint8_t plane_{0};
   const uint8_t *planes_[2]{nullptr, nullptr};
   const uint8_t *planes_[2]{nullptr, nullptr};
-  uint32_t plane_bytes_{0};
+  uint32_t plane_bytes_{0};   // real buffer size per plane
+  uint32_t wire_bytes_{0};    // bytes actually sent per plane (>= plane_bytes_)
   uint32_t bytes_per_row_{0};
   uint32_t bytes_per_row_{0};
   uint32_t deadline_{0};
   uint32_t deadline_{0};
   State delay_return_state_{State::DONE};
   State delay_return_state_{State::DONE};

+ 20 - 11
components/gxepd2/epd_command.h

@@ -68,6 +68,21 @@ inline const uint8_t GXEPD2_PALETTE_7C_LEN = sizeof(GXEPD2_PALETTE_7C) / sizeof(
 // hardcoded in CycleEngine itself rather than made part of this struct;
 // hardcoded in CycleEngine itself rather than made part of this struct;
 // revisit if/when the extraction-tooling phase finds a controller family
 // revisit if/when the extraction-tooling phase finds a controller family
 // that lays it out differently.
 // that lays it out differently.
+// Order in which the engine emits a plane's wire lines, derived by the diag
+// native-path tool (CONTRIBUTING.md). The buffer always holds `height` rows
+// of real pixels; this controls only which buffer row each wire line carries
+// and how many wire lines there are, so wire length can exceed buffer size.
+//   SEQUENTIAL   - wire line i carries buffer row i; line count == height.
+//                  Every single-region panel captured so far.
+//   PAIRED_EDGES - the controller fills a 2x-height RAM scanned from both
+//                  edges toward the middle: for i in [0,height) it wants
+//                  buffer row (height-1-i) then row i, so line count ==
+//                  2*height and each row is sent twice. GDEY0579F51 / HX8717.
+//                  This is exactly what GxEPD2's writeNative emits for that
+//                  panel (99 bytes/line x 544 lines/region), so a faithful
+//                  replay needs it - nothing panel-specific in the engine.
+enum class LineOrder : uint8_t { SEQUENTIAL, PAIRED_EDGES };
+
 struct EpdPanelDescriptor {
 struct EpdPanelDescriptor {
   uint16_t width;
   uint16_t width;
   uint16_t height;
   uint16_t height;
@@ -113,17 +128,11 @@ struct EpdPanelDescriptor {
   // own, not the full 792x272. Unused otherwise.
   // own, not the full 792x272. Unused otherwise.
   uint16_t plane_width{0};
   uint16_t plane_width{0};
 
 
-  // true if rows must be sent in interleaved top/bottom-pair order (row 0,
-  // row H-1, row 1, row H-2, ...) instead of plain sequential top-to-
-  // bottom - GDEY0579F51's HX8717 pair apparently scans each half from
-  // both edges toward the middle, per Waveshare's own epd5in79g.cpp
-  // reference driver (confirmed 2026-09-19: a bar/ladder/diagonal test
-  // pattern came out vertically scrambled with a broken diagonal - the
-  // documented signature of a row-reordering bug - sent in plain
-  // sequential order). Not assumed for any other packed (bits_per_pixel > 1)
-  // panel (e.g. GDEY029F51, a single-chip panel with no master/slave split
-  // at all) without the same kind of hardware confirmation.
-  bool row_interleave{false};
+  // Wire-line emission order for this panel's plane(s) - see LineOrder above.
+  // Default SEQUENTIAL. GDEY0579F51 uses PAIRED_EDGES to match GxEPD2's own
+  // doubled, both-edges-to-middle line order byte-for-byte; not assumed for
+  // any other panel without the diag native-path tool deriving it.
+  LineOrder line_order{LineOrder::SEQUENTIAL};
 
 
   // Pixel bit-depth, which also selects the buffer model:
   // Pixel bit-depth, which also selects the buffer model:
   //   1 = one-bit-per-pixel plane model (mono, or BW + accent as two
   //   1 = one-bit-per-pixel plane model (mono, or BW + accent as two

+ 44 - 37
components/gxepd2/panel_579c_gdey0579f51.h

@@ -3,22 +3,28 @@
 // GDEY0579F51 - HX8717 dual-chip, 792x272, 4-color, 2bpp packed (same
 // GDEY0579F51 - HX8717 dual-chip, 792x272, 4-color, 2bpp packed (same
 // JD79667-family packing as GDEY029F51, see panel_290c_gdey029f51.h).
 // JD79667-family packing as GDEY029F51, see panel_290c_gdey029f51.h).
 //
 //
-// Captured from the STOCK vendored driver
-// (third_party/GxEPD2/src/epd4c/GxEPD2_579c_GDEY0579F51.{h,cpp}) and kept
-// byte-for-byte stock EXCEPT the PSR source-scan bytes (see PSR_MASTER/
-// PSR_SLAVE below) - those are swapped to Waveshare's orientation because
-// the stock values render this panel left-right mirrored on hardware.
+// Faithful replay of the STOCK vendored driver
+// (third_party/GxEPD2/src/epd4c/GxEPD2_579c_GDEY0579F51.{h,cpp}): init, PSR,
+// BTST, window, refresh, master/slave split and plane order all match
+// GxEPD2's own on-wire behaviour. The only non-default engine setting is
+// line_order = PAIRED_EDGES, which reproduces GxEPD2 writeNative's doubled,
+// both-edges-to-middle line emission (544 lines/region) - a general engine
+// mechanism, not a panel hack. The user confirmed GxEPD2 itself drives this
+// panel correctly when run natively, so a byte-faithful replay is correct by
+// construction.
 //
 //
-// History worth not repeating: an earlier revision carried a pile of
-// hand-"fixes" from Waveshare's epd5in79g.cpp - swapped PSR, power-on under
-// select=Both, a changed BTST - all introduced together to chase mirrored
-// text AND a slave region that never converged. The bench (2026-10-18)
-// separated the two: the non-convergence was DRIVE-CURRENT STARVATION on
-// the bench MCU (not a driver bug - the same panel + firmware renders on an
-// adequately-powered board), so power-on-select and BTST were red herrings
-// and are back to stock here; only the PSR scan-direction swap was doing
-// real work (fixing the mirror) and is kept. Window + refresh are the fresh
-// capture, unchanged.
+// History worth not repeating: an earlier revision carried hand-"fixes" from
+// Waveshare's epd5in79g.cpp (swapped PSR, power-on under select=Both, changed
+// BTST) plus, on 2026-10-18, a PSR scan-direction swap + window-order swap to
+// chase a left-right mirror and swapped halves. The bench showed those were
+// ALL compensating for two separate real issues, neither a driver bug:
+//   - the "slave never converges" symptom was DRIVE-CURRENT STARVATION on the
+//     bench MCU (same panel + firmware renders on an adequately-powered
+//     board), so power-on-select and BTST are back to stock;
+//   - the mirror + swapped halves were the engine sending ONE line per row
+//     where GxEPD2 sends TWO (line_order below), so the PSR/window swaps are
+//     gone too.
+// Only busy_timeout_ms is tuned (engine hard-aborts where GxEPD2 polls on).
 //
 //
 // Dual-region layer assembled by hand from the driver source, per
 // Dual-region layer assembled by hand from the driver source, per
 // CONTRIBUTING.md "Limits - True dual-region command structure": the
 // CONTRIBUTING.md "Limits - True dual-region command structure": the
@@ -42,16 +48,14 @@ namespace esphome::gxepd2 {
 inline const uint8_t PANEL_579C_GDEY0579F51_SELECT_MASTER[] = {0x01};
 inline const uint8_t PANEL_579C_GDEY0579F51_SELECT_MASTER[] = {0x01};
 inline const uint8_t PANEL_579C_GDEY0579F51_SELECT_SLAVE[] = {0x02};
 inline const uint8_t PANEL_579C_GDEY0579F51_SELECT_SLAVE[] = {0x02};
 inline const uint8_t PANEL_579C_GDEY0579F51_SELECT_BOTH[] = {0x00};
 inline const uint8_t PANEL_579C_GDEY0579F51_SELECT_BOTH[] = {0x00};
-// PSR source-scan (SHL) bytes are the ONE deviation from the stock driver:
-// stock is master {0x07,0x29} / slave {0x03,0x29}, which renders this panel
-// left-right MIRRORED (text backwards) on hardware. The two values differ
-// only in the source-scan-direction bit; swapping which half gets it (the
-// value Waveshare's epd5in79g.cpp uses) un-mirrors it - confirmed on the
-// bench 2026-10-18. This is a real per-panel orientation fix and is
-// unrelated to the drive-current issue that explained the old "slave won't
-// converge" symptom; everything else here stays stock.
-inline const uint8_t PANEL_579C_GDEY0579F51_PSR_MASTER[] = {0x03, 0x29};
-inline const uint8_t PANEL_579C_GDEY0579F51_PSR_SLAVE[] = {0x07, 0x29};
+// Stock PSR values, matching GxEPD2's _InitDisplay() exactly: master
+// {0x07,0x29}, slave {0x03,0x29}. (A brief detour on 2026-10-18 swapped
+// these to "fix" a left-right mirror, but the mirror was an artifact of the
+// engine sending one line per row instead of GxEPD2's two - see line_order
+// below. With the line order faithful, the stock PSR is correct and the
+// swap is gone.)
+inline const uint8_t PANEL_579C_GDEY0579F51_PSR_MASTER[] = {0x07, 0x29};
+inline const uint8_t PANEL_579C_GDEY0579F51_PSR_SLAVE[] = {0x03, 0x29};
 inline const uint8_t PANEL_579C_GDEY0579F51_BTST[] = {0x32, 0x32, 0x32, 0x32};
 inline const uint8_t PANEL_579C_GDEY0579F51_BTST[] = {0x32, 0x32, 0x32, 0x32};
 inline const uint8_t PANEL_579C_GDEY0579F51_TRES[] = {0x01, 0x8C, 0x01, 0x10};  // 396x272 - one region
 inline const uint8_t PANEL_579C_GDEY0579F51_TRES[] = {0x01, 0x8C, 0x01, 0x10};  // 396x272 - one region
 inline const uint8_t PANEL_579C_GDEY0579F51_CDI_INIT[] = {0x97};  // border white
 inline const uint8_t PANEL_579C_GDEY0579F51_CDI_INIT[] = {0x97};  // border white
@@ -119,22 +123,25 @@ inline const EpdPanelDescriptor PANEL_579C_GDEY0579F51 = {
                                  // the engine hard-aborts
                                  // the engine hard-aborts
     .init_sequence = PANEL_579C_GDEY0579F51_INIT,
     .init_sequence = PANEL_579C_GDEY0579F51_INIT,
     .init_sequence_len = sizeof(PANEL_579C_GDEY0579F51_INIT) / sizeof(PANEL_579C_GDEY0579F51_INIT[0]),
     .init_sequence_len = sizeof(PANEL_579C_GDEY0579F51_INIT) / sizeof(PANEL_579C_GDEY0579F51_INIT[0]),
-    // plane 0 is the LEFT half of the canvas (draw_packed_ splits at
-    // plane_width), plane 1 the RIGHT half. With the swapped PSR scan
-    // direction, chip 0x02 (slave) drives the left of the glass and chip
-    // 0x01 (master) the right, so route plane 0 -> slave-select window and
-    // plane 1 -> master-select. Sending them in physical-chip order instead
-    // put the left half on the right of the panel and vice versa
-    // (bench-observed 2026-10-18).
-    .window_sequence = {PANEL_579C_GDEY0579F51_WINDOW_SLAVE, PANEL_579C_GDEY0579F51_WINDOW_MASTER},
-    .window_sequence_len = {sizeof(PANEL_579C_GDEY0579F51_WINDOW_SLAVE) / sizeof(PANEL_579C_GDEY0579F51_WINDOW_SLAVE[0]),
-                             sizeof(PANEL_579C_GDEY0579F51_WINDOW_MASTER) / sizeof(PANEL_579C_GDEY0579F51_WINDOW_MASTER[0])},
+    // plane 0 = LEFT canvas half -> master (chip 0x01), plane 1 = RIGHT half
+    // -> slave (chip 0x02), matching GxEPD2 writeNative's MasterSide/SlaveSide
+    // split (left<WIDTH/2 -> master). (The 2026-10-18 detour swapped this to
+    // compensate for the one-line-per-row bug; with line_order faithful it's
+    // back to GxEPD2's own order.)
+    .window_sequence = {PANEL_579C_GDEY0579F51_WINDOW_MASTER, PANEL_579C_GDEY0579F51_WINDOW_SLAVE},
+    .window_sequence_len = {sizeof(PANEL_579C_GDEY0579F51_WINDOW_MASTER) / sizeof(PANEL_579C_GDEY0579F51_WINDOW_MASTER[0]),
+                             sizeof(PANEL_579C_GDEY0579F51_WINDOW_SLAVE) / sizeof(PANEL_579C_GDEY0579F51_WINDOW_SLAVE[0])},
     .window_per_plane = true,
     .window_per_plane = true,
-    .plane_count = 2,  // plane 0 = left half -> chip 0x02; plane 1 = right half -> chip 0x01
+    .plane_count = 2,  // plane 0 = left half -> chip 0x01 (master); plane 1 = right half -> chip 0x02 (slave)
     .plane_write_cmd = {0x10, 0x10},
     .plane_write_cmd = {0x10, 0x10},
     .plane_invert = {false, false},
     .plane_invert = {false, false},
     .plane_width = 396,  // each region's own local width
     .plane_width = 396,  // each region's own local width
-    .row_interleave = true,
+    // GxEPD2 writeNative sends each region as 544 lines (2x272) in paired
+    // top/bottom-from-edges order, filling a 2x-height RAM - reproduce that
+    // exactly rather than one line per row. This is what makes the stock PSR
+    // and window order render correctly; the earlier swaps were compensating
+    // for not doing it.
+    .line_order = LineOrder::PAIRED_EDGES,
     .bits_per_pixel = 2,
     .bits_per_pixel = 2,
     .palette = GXEPD2_PALETTE_4C,
     .palette = GXEPD2_PALETTE_4C,
     .palette_len = GXEPD2_PALETTE_4C_LEN,
     .palette_len = GXEPD2_PALETTE_4C_LEN,

+ 1 - 1
components/gxepd2/panel_579c_gdey0579f51_master_only.h

@@ -115,7 +115,7 @@ inline const EpdPanelDescriptor PANEL_579C_GDEY0579F51_MASTER_ONLY = {
     .plane_count = 1,
     .plane_count = 1,
     .plane_write_cmd = {0x10, 0x00},
     .plane_write_cmd = {0x10, 0x00},
     .plane_invert = {false, false},
     .plane_invert = {false, false},
-    .row_interleave = true,
+    .line_order = LineOrder::PAIRED_EDGES,
     .bits_per_pixel = 2,
     .bits_per_pixel = 2,
     .palette = GXEPD2_PALETTE_4C,
     .palette = GXEPD2_PALETTE_4C,
     .palette_len = GXEPD2_PALETTE_4C_LEN,
     .palette_len = GXEPD2_PALETTE_4C_LEN,