Просмотр исходного кода

Add a real dual-region write path to CycleEngine for GDEY0579F51

window_sequence becomes per-plane (window_sequence[plane index]), gated by
a new window_per_plane flag - when true, finishing one plane's byte burst
re-enters SET_WINDOW for the next plane's own window-set instead of
falling straight through to the next burst (the existing BW+red
behavior, one shared window). Removes the WRITE_PLANE_START state (no
longer needed: plane_ is reset once in request_full_update(), not
between planes).

'Plane' here does double duty as 'physical region' for GDEY0579F51:
master and slave are CycleEngine's plane 0/1, each with its own
window_sequence entry and its own plane_width-sized buffer
(GxEPD2Display allocates/fills/routes two buffers instead of one when
window_per_plane && plane_count>1).

panel_579c_gdey0579f51.h supersedes the master-only descriptor as the
real model - writes master then slave before a single shared select=Both
refresh trigger, matching GxEPD2's own real (both-region) update flow.
Region physical placement (master = away from FPC connector) confirmed
two independent ways: GxEPD2_579_GDEY0579T93.cpp's own comments, and the
user's own prior test of this exact panel with the GDEY0213F51 driver
(same chip). Master-only kept as a debug model, not deleted - the
duplication artifact it showed is not yet explained and this descriptor
may or may not resolve it.
ZiB 1 месяц назад
Родитель
Сommit
3025c371f3

+ 22 - 16
components/gxepd2/cycle_engine.h

@@ -56,10 +56,11 @@ class CycleEngine {
   void request_full_update(const uint8_t *plane0, const uint8_t *plane1) {
     this->planes_[0] = plane0;
     this->planes_[1] = plane1;
-    this->plane_bytes_ = this->desc_->packed_2bpp_plane
-                              ? (static_cast<uint32_t>(this->desc_->width) * this->desc_->height) / 4
-                              : (static_cast<uint32_t>(this->desc_->width) * this->desc_->height) / 8;
+    uint32_t w = this->desc_->window_per_plane ? this->desc_->plane_width : this->desc_->width;
+    this->plane_bytes_ = this->desc_->packed_2bpp_plane ? (w * this->desc_->height) / 4
+                                                         : (w * this->desc_->height) / 8;
     this->cursor_ = 0;
+    this->plane_ = 0;
     this->cmd_phase_ = CmdPhase::BEFORE_DELAY;
     this->set_state_(this->rst_ != nullptr ? State::RESET_LOW : State::INIT_CMD);
   }
@@ -104,14 +105,14 @@ class CycleEngine {
         return;
 
       case State::SET_WINDOW:
-        this->step_sequence_(this->desc_->window_sequence, this->desc_->window_sequence_len, State::SET_WINDOW,
-                              State::WRITE_PLANE_START);
-        return;
-
-      case State::WRITE_PLANE_START:
-        this->cursor_ = 0;
-        this->plane_ = 0;
-        this->set_state_(State::WRITE_PLANE);
+        // window_sequence[this->plane_]: index 0 on the first entry
+        // (plane_ reset to 0 in request_full_update()); when
+        // window_per_plane is true, step_write_plane_() re-enters this
+        // state with plane_ already advanced to 1 before the next plane's
+        // byte burst, picking window_sequence[1] instead - see
+        // panel_579c_gdey0579f51.h for why a panel needs that.
+        this->step_sequence_(this->desc_->window_sequence[this->plane_], this->desc_->window_sequence_len[this->plane_],
+                              State::SET_WINDOW, State::WRITE_PLANE);
         return;
 
       case State::WRITE_PLANE:
@@ -163,7 +164,6 @@ class CycleEngine {
     RESET_SETTLE,
     INIT_CMD,
     SET_WINDOW,
-    WRITE_PLANE_START,
     WRITE_PLANE,
     REFRESH_CMD,
     POST_DELAY,
@@ -277,12 +277,20 @@ class CycleEngine {
     if (this->cursor_ >= this->plane_bytes_) {
       this->cursor_ = 0;
       this->plane_++;
+      this->cmd_phase_ = CmdPhase::BEFORE_DELAY;
       if (this->plane_ >= this->desc_->plane_count) {
         this->plane_ = 0;
-        this->cursor_ = 0;
-        this->cmd_phase_ = CmdPhase::BEFORE_DELAY;
         this->set_state_(State::REFRESH_CMD);
+      } else if (this->desc_->window_per_plane) {
+        // Another plane to go and it needs its own window-set first (a
+        // genuinely separate physical RAM region, not just another color
+        // plane sharing the window already set) - re-enter SET_WINDOW,
+        // which will pick window_sequence[this->plane_] now that plane_
+        // has already advanced.
+        this->set_state_(State::SET_WINDOW);
       }
+      // else: more planes, sharing one window already set - stay in
+      // WRITE_PLANE, next step() call starts the next plane's byte burst.
     }
   }
 
@@ -341,8 +349,6 @@ class CycleEngine {
         return "INIT_CMD";
       case State::SET_WINDOW:
         return "SET_WINDOW";
-      case State::WRITE_PLANE_START:
-        return "WRITE_PLANE_START";
       case State::WRITE_PLANE:
         return "WRITE_PLANE";
       case State::REFRESH_CMD:

+ 4 - 3
components/gxepd2/display.py

@@ -38,9 +38,10 @@ MODELS = {
     "GxEPD2_426_GDEQ0426T82": "PANEL_426_GDEQ0426T82",
     "GxEPD2_583_GDEQ0583T31": "PANEL_583_GDEQ0583T31",
     "GxEPD2_290c_GDEY029F51": "PANEL_290C_GDEY029F51",
-    # Master half only, as if it were a self-contained 396x272 panel - see
-    # panel_579c_gdey0579f51_master_only.h for why the master+slave split
-    # isn't wired up yet.
+    "GxEPD2_579c_GDEY0579F51": "PANEL_579C_GDEY0579F51",
+    # Debug variant: master half only, as if it were a self-contained
+    # 396x272 panel - see panel_579c_gdey0579f51_master_only.h. Kept
+    # around as a diagnostic tool, not the panel's real mode.
     "GxEPD2_579c_GDEY0579F51_MASTER_ONLY": "PANEL_579C_GDEY0579F51_MASTER_ONLY",
 }
 

+ 35 - 16
components/gxepd2/epd_command.h

@@ -38,29 +38,48 @@ struct EpdPanelDescriptor {
   uint8_t init_sequence_len;
 
   // Sets the (always full-frame, since this engine has no partial-window
-  // support) RAM addressing window, run once after init_sequence and before
-  // the first plane byte burst. Was hardcoded into CycleEngine assuming
-  // every controller shares SSD1680's 0x44/0x45/0x4E/0x4F byte-column
-  // layout - GxEPD2_426_GDEQ0426T82 (SSD1677) turned out to address in raw
-  // pixels with reversed Y and re-issues its entry-mode command here, and
-  // GxEPD2_583_GDEQ0583T31 (UC8179) uses one 0x90 command bracketed by
-  // 0x91/0x92 markers instead. Made per-panel data, same as init/refresh,
-  // rather than special-cased in the engine.
-  const EpdCommand *window_sequence;
-  uint8_t window_sequence_len;
+  // support) RAM addressing window. window_sequence[0] runs once after
+  // init_sequence and before the first plane's byte burst; index 1 is only
+  // used when window_per_plane is true (see below). Was hardcoded into
+  // CycleEngine assuming every controller shares SSD1680's
+  // 0x44/0x45/0x4E/0x4F byte-column layout - GxEPD2_426_GDEQ0426T82
+  // (SSD1677) turned out to address in raw pixels with reversed Y and
+  // re-issues its entry-mode command here, and GxEPD2_583_GDEQ0583T31
+  // (UC8179) uses one 0x90 command bracketed by 0x91/0x92 markers instead.
+  // Made per-panel data, same as init/refresh, rather than special-cased
+  // in the engine.
+  const EpdCommand *window_sequence[2];
+  uint8_t window_sequence_len[2];
 
-  uint8_t plane_count;          // 1 (BW) or 2 (BW + one accent color)
+  // true if each plane needs window_sequence[plane index] re-run before
+  // its own byte burst, instead of one window_sequence[0] covering every
+  // plane's burst (the default, e.g. a BW+red panel's shared full-frame
+  // window). This is how GxEPD2_579c_GDEY0579F51's real master/slave
+  // regions are handled: two genuinely separate physical RAM windows, not
+  // just two color planes sharing one - "plane" here just means "one more
+  // buffer with its own byte burst," which happens to fit a two-physical-
+  // region panel as well as a two-color-plane one. See
+  // panel_579c_gdey0579f51.h.
+  bool window_per_plane{false};
+
+  uint8_t plane_count;          // 1 (BW), 2 (BW + accent color), or 2 physical regions
   uint8_t plane_write_cmd[2];   // e.g. {0x24, 0x26}
   bool plane_invert[2];         // true if the byte must be bit-inverted on write
 
+  // When window_per_plane is true, each plane's own buffer is sized for
+  // this width, not the descriptor's overall `width` (which is the full
+  // logical canvas ESPHome draws into and get_width_internal() reports) -
+  // e.g. 396 for GDEY0579F51's master/slave halves, each 396x272 on its
+  // own, not the full 792x272. Unused otherwise.
+  uint16_t plane_width{0};
+
   // true for the JD79667/HX8717-family 4-color panels (GDEY029F51,
   // GDEY0579F51): pixels are 2 bits each, 4 per byte, built from a
-  // white/black/red classification at draw time - a fundamentally
+  // white/black/yellow/red classification at draw time - a fundamentally
   // different buffer shape than the 1-bit-per-pixel plane(s) above, not
-  // just a different invert/command byte. When true, plane_count/
-  // plane_invert are unused (always effectively 1 packed plane,
-  // plane_write_cmd[0] still names the write command) and
-  // GxEPD2Display's buffer is width*height/4 bytes instead of /8.
+  // just a different invert/command byte. When true, plane_invert is
+  // unused and GxEPD2Display's buffer(s) are width*height/4 bytes instead
+  // of /8 (per-plane width*height/4 when window_per_plane is also true).
   bool packed_2bpp_plane{false};
 
   const EpdCommand *refresh_sequence;  // e.g. 0x22/0xF7 then trigger 0x20

+ 30 - 7
components/gxepd2/gxepd2_display.h

@@ -8,6 +8,7 @@
 #include "panel_213_z98c.h"
 #include "panel_290c_gdey029f51.h"
 #include "panel_426_gdeq0426t82.h"
+#include "panel_579c_gdey0579f51.h"
 #include "panel_579c_gdey0579f51_master_only.h"
 #include "panel_583_gdeq0583t31.h"
 
@@ -31,11 +32,16 @@ class GxEPD2Display : public display::DisplayBuffer {
 
   void setup() override {
     const auto *desc = this->desc_;
-    this->plane_bytes_ = desc->packed_2bpp_plane ? (static_cast<uint32_t>(desc->width) * desc->height) / 4
-                                                  : (static_cast<uint32_t>(desc->width) * desc->height) / 8;
+    // window_per_plane panels (GDEY0579F51's master+slave) size each
+    // plane's own buffer from plane_width (each region's own local
+    // width), not the descriptor's overall width (the full logical canvas
+    // ESPHome draws into) - both regions are the same size here, so one
+    // plane_bytes_ value covers both buffers.
+    uint32_t w = desc->window_per_plane ? desc->plane_width : desc->width;
+    this->plane_bytes_ = desc->packed_2bpp_plane ? (w * desc->height) / 4 : (w * desc->height) / 8;
     RAMAllocator<uint8_t> allocator;
     this->plane0_ = allocator.allocate(this->plane_bytes_);
-    bool need_plane1 = !desc->packed_2bpp_plane && desc->plane_count > 1;
+    bool need_plane1 = desc->plane_count > 1;
     this->plane1_ = need_plane1 ? allocator.allocate(this->plane_bytes_) : nullptr;
     if (this->plane0_ == nullptr || (need_plane1 && this->plane1_ == nullptr)) {
       ESP_LOGE(TAG, "Failed to allocate %u-byte plane buffer(s)", (unsigned) this->plane_bytes_);
@@ -46,8 +52,12 @@ class GxEPD2Display : public display::DisplayBuffer {
     // GxEPD2's own writeScreenBuffer()'s white fill value exactly - not
     // 0xFF (that's the 1bpp planes' white) or 0x00 (that's black here).
     memset(this->plane0_, desc->packed_2bpp_plane ? 0x55 : 0xFF, this->plane_bytes_);
-    if (this->plane1_ != nullptr)
-      memset(this->plane1_, 0x00, this->plane_bytes_);  // 0 = not-red
+    if (this->plane1_ != nullptr) {
+      // plane1_ is a second packed region (master/slave) for a
+      // window_per_plane panel - also defaults to white (0x55), not the
+      // 1bpp "red" plane's 0=not-red convention.
+      memset(this->plane1_, desc->packed_2bpp_plane ? 0x55 : 0x00, this->plane_bytes_);
+    }
 
     this->spi_.spi_setup();
     this->engine_.begin(desc, this->cs_, this->dc_, this->rst_, this->busy_, &this->spi_);
@@ -98,10 +108,23 @@ class GxEPD2Display : public display::DisplayBuffer {
         code = 0x01;
       else
         code = 0x00;
-      uint32_t px = static_cast<uint32_t>(y) * this->desc_->width + x;
+      // window_per_plane (GDEY0579F51's master+slave): each region is its
+      // own local 396-wide buffer, not one 792-wide one - route by which
+      // half of the global x range this pixel falls in.
+      uint16_t stride = this->desc_->width;
+      uint8_t *buf = this->plane0_;
+      int local_x = x;
+      if (this->desc_->window_per_plane && this->desc_->plane_count > 1) {
+        stride = this->desc_->plane_width;
+        if (x >= stride) {
+          buf = this->plane1_;
+          local_x = x - stride;
+        }
+      }
+      uint32_t px = static_cast<uint32_t>(y) * stride + local_x;
       uint32_t idx = px / 4;
       uint8_t shift = static_cast<uint8_t>((3 - (px % 4)) * 2);
-      this->plane0_[idx] = static_cast<uint8_t>((this->plane0_[idx] & ~(0x03 << shift)) | (code << shift));
+      buf[idx] = static_cast<uint8_t>((buf[idx] & ~(0x03 << shift)) | (code << shift));
       return;
     }
     uint32_t idx = (static_cast<uint32_t>(y) * this->desc_->width + x) / 8;

+ 2 - 2
components/gxepd2/panel_213_z98c.h

@@ -83,8 +83,8 @@ inline const EpdPanelDescriptor PANEL_213_Z98C = {
     .busy_timeout_ms = 60000,  // temporarily generous - diagnosing a busy-never-clears hang, see commit message
     .init_sequence = PANEL_213_Z98C_INIT,
     .init_sequence_len = sizeof(PANEL_213_Z98C_INIT) / sizeof(PANEL_213_Z98C_INIT[0]),
-    .window_sequence = PANEL_213_Z98C_WINDOW,
-    .window_sequence_len = sizeof(PANEL_213_Z98C_WINDOW) / sizeof(PANEL_213_Z98C_WINDOW[0]),
+    .window_sequence = {PANEL_213_Z98C_WINDOW, nullptr},
+    .window_sequence_len = {sizeof(PANEL_213_Z98C_WINDOW) / sizeof(PANEL_213_Z98C_WINDOW[0]), 0},
     .plane_count = 2,
     .plane_write_cmd = {0x24, 0x26},
     // Stock GxEPD2 wire convention for the color plane, matched to

+ 2 - 2
components/gxepd2/panel_290c_gdey029f51.h

@@ -86,8 +86,8 @@ inline const EpdPanelDescriptor PANEL_290C_GDEY029F51 = {
     .busy_timeout_ms = 30000,    // full_refresh_time is 25000ms
     .init_sequence = PANEL_290C_GDEY029F51_INIT,
     .init_sequence_len = sizeof(PANEL_290C_GDEY029F51_INIT) / sizeof(PANEL_290C_GDEY029F51_INIT[0]),
-    .window_sequence = PANEL_290C_GDEY029F51_WINDOW_SEQ,
-    .window_sequence_len = sizeof(PANEL_290C_GDEY029F51_WINDOW_SEQ) / sizeof(PANEL_290C_GDEY029F51_WINDOW_SEQ[0]),
+    .window_sequence = {PANEL_290C_GDEY029F51_WINDOW_SEQ, nullptr},
+    .window_sequence_len = {sizeof(PANEL_290C_GDEY029F51_WINDOW_SEQ) / sizeof(PANEL_290C_GDEY029F51_WINDOW_SEQ[0]), 0},
     .plane_count = 1,
     .plane_write_cmd = {0x10, 0x00},
     .plane_invert = {false, false},

+ 2 - 2
components/gxepd2/panel_426_gdeq0426t82.h

@@ -71,8 +71,8 @@ inline const EpdPanelDescriptor PANEL_426_GDEQ0426T82 = {
     .busy_timeout_ms = 20000,   // full_refresh_time is 1600ms; generous ceiling, matches PANEL_213_Z98C's style
     .init_sequence = PANEL_426_GDEQ0426T82_INIT,
     .init_sequence_len = sizeof(PANEL_426_GDEQ0426T82_INIT) / sizeof(PANEL_426_GDEQ0426T82_INIT[0]),
-    .window_sequence = PANEL_426_GDEQ0426T82_WINDOW,
-    .window_sequence_len = sizeof(PANEL_426_GDEQ0426T82_WINDOW) / sizeof(PANEL_426_GDEQ0426T82_WINDOW[0]),
+    .window_sequence = {PANEL_426_GDEQ0426T82_WINDOW, nullptr},
+    .window_sequence_len = {sizeof(PANEL_426_GDEQ0426T82_WINDOW) / sizeof(PANEL_426_GDEQ0426T82_WINDOW[0]), 0},
     .plane_count = 1,
     .plane_write_cmd = {0x24, 0x00},
     .plane_invert = {false, false},

+ 122 - 0
components/gxepd2/panel_579c_gdey0579f51.h

@@ -0,0 +1,122 @@
+#pragma once
+
+// Hand-derived from vendor/src/epd4c/GxEPD2_579c_GDEY0579F51.{h,cpp}
+// (GDEY0579F51, HX8717 controller, 792x272, same JD79667-family 2bpp/4-color
+// packing as GDEY029F51 - see panel_290c_gdey029f51.h). The real dual-
+// region version, superseding panel_579c_gdey0579f51_master_only.h's
+// deliberate single-region simplification - uses CycleEngine's
+// window_per_plane mechanism to give "master" and "slave" (0xA2-select
+// logical regions, not separate physical CS pins) their own window-set
+// before each one's own 0x10 byte burst, "plane 0"/"plane 1" in the
+// engine's terms even though they're physical regions here, not black/red
+// color planes - see EpdPanelDescriptor::window_per_plane.
+//
+// Region physical placement (2026-09-19, confirmed two independent ways):
+// GxEPD2_579_GDEY0579T93.cpp (SSD1683, same 792x272 panel family) comments
+// its own master/slave split explicitly - "near connector" = slave,
+// "opposite to connector" = master. User independently confirmed the same
+// physical mapping driving this actual panel with the GDEY0213F51 driver
+// (same underlying chip): master = away from the FPC connector. So master
+// is region 0 here (window_sequence[0], written first, right after init
+// leaves the controller select=master already) and slave is region 1.
+//
+// The master-only descriptor's single-region run showed real, correctly-
+// decoded content but compressed/duplicated within half the driven
+// region's own width, plus noise/black bands on the untouched slave half.
+// The noise was explained and fixed there (0x12's refresh trigger fires a
+// real electrical refresh across the WHOLE panel regardless of 0xA2
+// select, so refreshing with slave's RAM never written "developed" its
+// stale/undefined content) - this descriptor writes both regions before
+// refreshing, which should eliminate that on its own. The duplication
+// artifact's cause is still unconfirmed; writing both regions may or may
+// not resolve it - that's what this descriptor is for finding out.
+
+#include "epd_command.h"
+
+namespace esphome::gxepd2 {
+
+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_BOTH[] = {0x00};
+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_TRES[] = {0x01, 0x8C, 0x01, 0x10};  // 396x272 - one region's resolution
+inline const uint8_t PANEL_579C_GDEY0579F51_CDI_INIT[] = {0x97};
+inline const uint8_t PANEL_579C_GDEY0579F51_E9[] = {0x01};
+inline const uint8_t PANEL_579C_GDEY0579F51_E0[] = {0x01};
+
+// _InitDisplay(): PSR written to each side individually (master and slave
+// scan in mirrored directions, hence different first PSR byte - 0x07 vs
+// 0x03), then BTST/TRES/CDI/E9/E0 written once under select=Both since
+// they're genuinely shared/global registers, then re-select master ready
+// for the first region's window-set.
+inline const EpdCommand PANEL_579C_GDEY0579F51_INIT[] = {
+    {0xA2, PANEL_579C_GDEY0579F51_SELECT_MASTER, 1},
+    {0x00, PANEL_579C_GDEY0579F51_PSR_MASTER, 2},
+    {0xA2, PANEL_579C_GDEY0579F51_SELECT_SLAVE, 1},
+    {0x00, PANEL_579C_GDEY0579F51_PSR_SLAVE, 2},
+    {0xA2, PANEL_579C_GDEY0579F51_SELECT_BOTH, 1},
+    {0x06, PANEL_579C_GDEY0579F51_BTST, 4},
+    {0x61, PANEL_579C_GDEY0579F51_TRES, 4},
+    {0x50, PANEL_579C_GDEY0579F51_CDI_INIT, 1},
+    {0xE9, PANEL_579C_GDEY0579F51_E9, 1},
+    {0xE0, PANEL_579C_GDEY0579F51_E0, 1},
+    {0xA2, PANEL_579C_GDEY0579F51_SELECT_MASTER, 1},
+    {0x04, nullptr, 0, /*delay_before_ms=*/0, /*delay_after_ms=*/0, /*wait_busy_after=*/true},  // power on
+};
+
+// _setPartialRamAreaMaster/Slave(0, 0, 396, 272), default partial_mode=true
+// - both regions are the same shape, just a different select byte.
+inline const uint8_t PANEL_579C_GDEY0579F51_WINDOW_REGION[] = {
+    0x00, 0x00,  // x/256, x%256   = 0
+    0x01, 0x8B,  // xe/256, xe%256 = 395
+    0x00, 0x00,  // y/256, y%256   = 0
+    0x01, 0x0F,  // ye/256, ye%256 = 271
+    0x01,
+};
+
+inline const EpdCommand PANEL_579C_GDEY0579F51_WINDOW_MASTER[] = {
+    {0xA2, PANEL_579C_GDEY0579F51_SELECT_MASTER, 1},
+    {0x83, PANEL_579C_GDEY0579F51_WINDOW_REGION, 9},
+};
+inline const EpdCommand PANEL_579C_GDEY0579F51_WINDOW_SLAVE[] = {
+    {0xA2, PANEL_579C_GDEY0579F51_SELECT_SLAVE, 1},
+    {0x83, PANEL_579C_GDEY0579F51_WINDOW_REGION, 9},
+};
+
+// refresh(false): vendor's own _setPartialRamAreaBoth(0,0,396,272) then
+// _refresh(false) - select=Both is correct here (unlike the master-only
+// descriptor) since both regions' RAM genuinely holds real content now.
+inline const uint8_t PANEL_579C_GDEY0579F51_CDI_REFRESH[] = {0x37};
+inline const uint8_t PANEL_579C_GDEY0579F51_REFRESH_TRIGGER[] = {0x00};
+
+inline const EpdCommand PANEL_579C_GDEY0579F51_REFRESH[] = {
+    {0xA2, PANEL_579C_GDEY0579F51_SELECT_BOTH, 1},
+    {0x83, PANEL_579C_GDEY0579F51_WINDOW_REGION, 9},
+    {0x50, PANEL_579C_GDEY0579F51_CDI_REFRESH, 1},
+    {0x12, PANEL_579C_GDEY0579F51_REFRESH_TRIGGER, 1, /*delay_before_ms=*/0, /*delay_after_ms=*/0,
+     /*wait_busy_after=*/true},
+};
+
+inline const EpdPanelDescriptor PANEL_579C_GDEY0579F51 = {
+    .width = 792,   // full logical panel - the canvas ESPHome draws into
+    .height = 272,
+    .busy_active_level = false,  // GxEPD2_EPD(..., /*busy_level=*/LOW, ...)
+    .busy_timeout_ms = 25000,    // full_refresh_time is 21000ms
+    .init_sequence = PANEL_579C_GDEY0579F51_INIT,
+    .init_sequence_len = sizeof(PANEL_579C_GDEY0579F51_INIT) / sizeof(PANEL_579C_GDEY0579F51_INIT[0]),
+    .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,
+    .plane_count = 2,  // "plane" 0 = master region, "plane" 1 = slave region
+    .plane_write_cmd = {0x10, 0x10},
+    .plane_invert = {false, false},
+    .plane_width = 396,  // each region's own local width
+    .packed_2bpp_plane = true,
+    .refresh_sequence = PANEL_579C_GDEY0579F51_REFRESH,
+    .refresh_sequence_len = sizeof(PANEL_579C_GDEY0579F51_REFRESH) / sizeof(PANEL_579C_GDEY0579F51_REFRESH[0]),
+};
+
+}  // namespace esphome::gxepd2

+ 35 - 17
components/gxepd2/panel_579c_gdey0579f51_master_only.h

@@ -11,12 +11,32 @@
 // Deliberate scope cut (2026-09-19, per user): CycleEngine's state machine
 // only knows one window-set + one plane-write per update cycle, and
 // extending it to loop over two regions is real work, not just descriptor
-// data - so this descriptor addresses the master half ONLY, as if it were
-// a self-contained 396x272 single-region panel, to validate the 2bpp
+// data - so this descriptor addresses the "master" half ONLY, as if it
+// were a self-contained 396x272 single-region panel, to validate the 2bpp
 // packing and this controller family's init/refresh shape before building
-// the master+slave loop. Driving only the left half of the physical
-// screen is the expected/correct result of this descriptor, not a bug.
-// The slave half (right half) is simply never written or refreshed here.
+// the master+slave loop.
+//
+// First hardware run (2026-09-19) showed real content, correctly decoded,
+// but compressed/duplicated within roughly half the driven region, plus
+// noise/black bands on the untouched half - not yet fully explained. One
+// finding so far: don't assume "master" = the physically-left/near-
+// connector half. GxEPD2_579_GDEY0579T93.cpp (SSD1683, same 792x272
+// panel family, comments explicit) maps "near connector" -> SLAVE and
+// "opposite to connector" -> MASTER - the reverse of what might be
+// guessed. GDEY0579F51's own driver has no such comment, so which
+// physical side "master" actually addresses here is unverified, not
+// assumed-correct. A slave-only variant of this same descriptor (swap
+// every SELECT_MASTER for SELECT_MASTER->0x02) would empirically test
+// whether content moves to the other half - not yet done.
+//
+// The refresh sequence originally used select=Both (0x00) before the
+// 0x12 trigger, matching the vendor's own refresh(bool)'s
+// _setPartialRamAreaBoth() call - but 0x12 appears to fire a real
+// electrical refresh across the WHOLE physical panel regardless of which
+// side's RAM was ever written, which explains the noise/black bands as
+// slave's never-initialized RAM being "developed" onto glass. Changed to
+// select=Master only, since we only ever want to touch the side we wrote.
+// Does not yet explain the duplication/compression artifact.
 //
 // _InitDisplay()'s reset pulse is gated the same way GDEY029F51's is
 // (`_hibernating || _initial_write`) - this engine always does a real
@@ -63,19 +83,17 @@ inline const EpdCommand PANEL_579C_GDEY0579F51_MO_WINDOW_SEQ[] = {
     {0x83, PANEL_579C_GDEY0579F51_MO_WINDOW, 9},
 };
 
-// refresh(false): _setPartialRamAreaBoth(0,0,396,272) (select=Both, same
-// region, harmless even though only master was ever written), then
-// _refresh(false): 0x50=0x37, 0x12+delay(1)+busywait collapsed into one
-// wait_busy_after.
-inline const uint8_t PANEL_579C_GDEY0579F51_MO_WINDOW_BOTH[] = {
-    0x00, 0x00, 0x01, 0x8B, 0x00, 0x00, 0x01, 0x0F, 0x01,
-};
+// refresh(false): vendor's own refresh(bool) uses _setPartialRamAreaBoth()
+// here, but see file header - deliberately using select=Master instead so
+// the 0x12 trigger's real electrical refresh never touches slave's
+// never-written RAM (that Both selection is what produced the noise/black
+// bands on the untouched half in the first hardware run).
 inline const uint8_t PANEL_579C_GDEY0579F51_MO_CDI_REFRESH[] = {0x37};
 inline const uint8_t PANEL_579C_GDEY0579F51_MO_REFRESH_TRIGGER[] = {0x00};
 
 inline const EpdCommand PANEL_579C_GDEY0579F51_MO_REFRESH[] = {
-    {0xA2, PANEL_579C_GDEY0579F51_MO_SELECT_BOTH, 1},
-    {0x83, PANEL_579C_GDEY0579F51_MO_WINDOW_BOTH, 9},
+    {0xA2, PANEL_579C_GDEY0579F51_MO_SELECT_MASTER, 1},
+    {0x83, PANEL_579C_GDEY0579F51_MO_WINDOW, 9},
     {0x50, PANEL_579C_GDEY0579F51_MO_CDI_REFRESH, 1},
     {0x12, PANEL_579C_GDEY0579F51_MO_REFRESH_TRIGGER, 1, /*delay_before_ms=*/0, /*delay_after_ms=*/0,
      /*wait_busy_after=*/true},
@@ -88,9 +106,9 @@ inline const EpdPanelDescriptor PANEL_579C_GDEY0579F51_MASTER_ONLY = {
     .busy_timeout_ms = 25000,    // full_refresh_time is 21000ms
     .init_sequence = PANEL_579C_GDEY0579F51_MO_INIT,
     .init_sequence_len = sizeof(PANEL_579C_GDEY0579F51_MO_INIT) / sizeof(PANEL_579C_GDEY0579F51_MO_INIT[0]),
-    .window_sequence = PANEL_579C_GDEY0579F51_MO_WINDOW_SEQ,
-    .window_sequence_len =
-        sizeof(PANEL_579C_GDEY0579F51_MO_WINDOW_SEQ) / sizeof(PANEL_579C_GDEY0579F51_MO_WINDOW_SEQ[0]),
+    .window_sequence = {PANEL_579C_GDEY0579F51_MO_WINDOW_SEQ, nullptr},
+    .window_sequence_len = {sizeof(PANEL_579C_GDEY0579F51_MO_WINDOW_SEQ) / sizeof(PANEL_579C_GDEY0579F51_MO_WINDOW_SEQ[0]),
+                             0},
     .plane_count = 1,
     .plane_write_cmd = {0x10, 0x00},
     .plane_invert = {false, false},

+ 2 - 2
components/gxepd2/panel_583_gdeq0583t31.h

@@ -85,8 +85,8 @@ inline const EpdPanelDescriptor PANEL_583_GDEQ0583T31 = {
     .busy_timeout_ms = 20000,    // full_refresh_time is 1200ms; generous ceiling, matches PANEL_213_Z98C's style
     .init_sequence = PANEL_583_GDEQ0583T31_INIT,
     .init_sequence_len = sizeof(PANEL_583_GDEQ0583T31_INIT) / sizeof(PANEL_583_GDEQ0583T31_INIT[0]),
-    .window_sequence = PANEL_583_GDEQ0583T31_WINDOW,
-    .window_sequence_len = sizeof(PANEL_583_GDEQ0583T31_WINDOW) / sizeof(PANEL_583_GDEQ0583T31_WINDOW[0]),
+    .window_sequence = {PANEL_583_GDEQ0583T31_WINDOW, nullptr},
+    .window_sequence_len = {sizeof(PANEL_583_GDEQ0583T31_WINDOW) / sizeof(PANEL_583_GDEQ0583T31_WINDOW[0]), 0},
     .plane_count = 1,
     .plane_write_cmd = {0x13, 0x00},
     .plane_invert = {false, false},

+ 25 - 0
tests/test_gxepd2_579c_gdey0579f51.esp32-idf.yaml

@@ -0,0 +1,25 @@
+esphome:
+  name: gxepd2-test-579c-gdey0579f51
+esp32:
+  board: esp32dev
+  framework:
+    type: esp-idf
+external_components:
+  - source:
+      type: local
+      path: ../components
+    components: [gxepd2]
+spi:
+  clk_pin: 18
+  mosi_pin: 23
+logger:
+display:
+  - platform: gxepd2
+    model: GxEPD2_579c_GDEY0579F51
+    cs_pin: 5
+    dc_pin: 17
+    reset_pin: 16
+    busy_pin: 4
+    update_interval: 60s
+    lambda: |-
+      it.fill(Color::WHITE);