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

Add model: selection and two more panel descriptors

Moves RAM-window-set from a hardcoded SSD1680-shaped CycleEngine method
into per-panel descriptor data (window_sequence), same pattern already
used for init/refresh - GxEPD2_426_GDEQ0426T82 (SSD1677) addresses in raw
pixels with reversed Y, and GxEPD2_583_GDEQ0583T31 (UC8179) uses one 0x90
command bracketed by 0x91/0x92 instead of SSD1680's 0x44/0x45/0x4E/0x4F, so
the single hardcoded layout stopped being universal as soon as a second
controller family showed up.

display.py now takes a required model: key mapping to one of the three
descriptors, replacing the single hardcoded PANEL_213_Z98C. Both new
descriptors compile clean under esphome compile (esp32dev placeholder
pins; real bench wiring differs per device).
ZiB пре 1 месец
родитељ
комит
838a4b137d

+ 8 - 16
components/gxepd2/cycle_engine.h

@@ -102,7 +102,11 @@ class CycleEngine {
         return;
 
       case State::SET_WINDOW:
-        this->set_full_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);
@@ -143,6 +147,7 @@ class CycleEngine {
     RESET_SETTLE,
     INIT_CMD,
     SET_WINDOW,
+    WRITE_PLANE_START,
     WRITE_PLANE,
     REFRESH_CMD,
     POST_DELAY,
@@ -215,21 +220,6 @@ class CycleEngine {
     // else: stays in self_state; the next step() call processes the next entry.
   }
 
-  // GxEPD2_EPD::_setPartialRamArea(0, 0, width, height) - full frame only,
-  // this slice doesn't do partial windows yet.
-  void set_full_window_() {
-    uint16_t w = this->desc_->width, h = this->desc_->height;
-    uint16_t xe = w - 1, ye = h - 1;
-    const uint8_t x44[] = {0, static_cast<uint8_t>(xe / 8)};
-    this->write_command_(0x44, x44, 2);
-    const uint8_t x45[] = {0, 0, static_cast<uint8_t>(ye % 256), static_cast<uint8_t>(ye / 256)};
-    this->write_command_(0x45, x45, 4);
-    const uint8_t x4e[] = {0};
-    this->write_command_(0x4E, x4e, 1);
-    const uint8_t x4f[] = {0, 0};
-    this->write_command_(0x4F, x4f, 2);
-  }
-
   // Writes up to BYTE_BUDGET bytes of the current plane per call, then
   // returns - this bound is what keeps a full-frame write (thousands of
   // bytes) from ever making one loop() tick run long, replacing the old
@@ -334,6 +324,8 @@ 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:

+ 17 - 5
components/gxepd2/display.py

@@ -1,10 +1,10 @@
-"""ESPHome display platform driving GxEPD2_213_Z98c via a native chunked
+"""ESPHome display platform driving a GxEPD2 panel via a native chunked
 refresh engine (see cycle_engine.h) instead of GxEPD2's own C++ classes.
 
-Only this one panel is wired up so far - this is the first slice of
-replacing the old GxEPD2-template/FreeRTOS-task component; see the plan
-this was implemented from. catalog.json/vendor/arduino_compat stay in the
-tree as reference material for later panels but aren't used by this file.
+Panels are selected via `model:`, each backed by a hand-written (later,
+extracted) EpdPanelDescriptor - see panel_213_z98c.h / panel_426_gdeq0426t82.h
+/ panel_583_gdeq0583t31.h. catalog.json/vendor/arduino_compat stay in the
+tree as reference material for panels not yet ported to this engine.
 """
 
 import esphome.codegen as cg
@@ -17,6 +17,7 @@ from esphome.const import (
     CONF_DC_PIN,
     CONF_ID,
     CONF_LAMBDA,
+    CONF_MODEL,
     CONF_RESET_PIN,
     CONF_SPI_ID,
     Framework,
@@ -29,11 +30,21 @@ DEPENDENCIES = ["spi"]
 gxepd2_ns = cg.esphome_ns.namespace("gxepd2")
 GxEPD2Display = gxepd2_ns.class_("GxEPD2Display", display.DisplayBuffer)
 
+# model name -> C++ descriptor global (EpdPanelDescriptor) declared in the
+# matching panel_*.h header, which gxepd2_display.h includes unconditionally
+# - so any of these names can be selected without extra include wiring here.
+MODELS = {
+    "GxEPD2_213_Z98C": "PANEL_213_Z98C",
+    "GxEPD2_426_GDEQ0426T82": "PANEL_426_GDEQ0426T82",
+    "GxEPD2_583_GDEQ0583T31": "PANEL_583_GDEQ0583T31",
+}
+
 CONFIG_SCHEMA = cv.All(
     display.FULL_DISPLAY_SCHEMA.extend(
         {
             cv.GenerateID(): cv.declare_id(GxEPD2Display),
             cv.GenerateID(CONF_SPI_ID): cv.use_id(spi.SPIComponent),
+            cv.Required(CONF_MODEL): cv.one_of(*MODELS),
             cv.Required(CONF_CS_PIN): pins.gpio_output_pin_schema,
             cv.Required(CONF_DC_PIN): pins.gpio_output_pin_schema,
             cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema,
@@ -47,6 +58,7 @@ CONFIG_SCHEMA = cv.All(
 async def to_code(config: ConfigType) -> None:
     var = cg.new_Pvariable(config[CONF_ID])
 
+    cg.add(var.set_descriptor(cg.RawExpression(f"&esphome::gxepd2::{MODELS[config[CONF_MODEL]]}")))
     cg.add(var.set_cs_pin(await cg.gpio_pin_expression(config[CONF_CS_PIN])))
     cg.add(var.set_dc_pin(await cg.gpio_pin_expression(config[CONF_DC_PIN])))
     if reset_pin := config.get(CONF_RESET_PIN):

+ 12 - 0
components/gxepd2/epd_command.h

@@ -37,6 +37,18 @@ struct EpdPanelDescriptor {
   const EpdCommand *init_sequence;  // run once, after the reset pulse
   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;
+
   uint8_t plane_count;          // 1 (BW) or 2 (BW + one accent color)
   uint8_t plane_write_cmd[2];   // e.g. {0x24, 0x26}
   bool plane_invert[2];         // true if the byte must be bit-inverted on write

+ 22 - 21
components/gxepd2/gxepd2_display.h

@@ -6,6 +6,8 @@
 #include "esphome/components/spi/spi.h"
 #include "cycle_engine.h"
 #include "panel_213_z98c.h"
+#include "panel_426_gdeq0426t82.h"
+#include "panel_583_gdeq0583t31.h"
 
 namespace esphome::gxepd2 {
 
@@ -13,8 +15,8 @@ static const char *const TAG = "gxepd2";
 
 // Native chunked-engine replacement for the old GxEPD2-template/FreeRTOS-task
 // component - see the plan this was built from (and project memory entry
-// gxepd2_task_watchdog_status) for why. Only GxEPD2_213_Z98c is wired up so
-// far; more panels arrive as hand-written (later, extracted) descriptors,
+// gxepd2_task_watchdog_status) for why. Panels arrive as hand-written
+// (later, extracted) descriptors selected at runtime via set_descriptor(),
 // not by templating this class over GxEPD2's own driver classes.
 class GxEPD2Display : public display::DisplayBuffer {
  public:
@@ -23,9 +25,10 @@ class GxEPD2Display : public display::DisplayBuffer {
   void set_reset_pin(GPIOPin *pin) { this->rst_ = pin; }
   void set_busy_pin(GPIOPin *pin) { this->busy_ = pin; }
   void set_spi_parent(spi::SPIComponent *parent) { this->spi_.set_spi_parent(parent); }
+  void set_descriptor(const EpdPanelDescriptor *desc) { this->desc_ = desc; }
 
   void setup() override {
-    const auto *desc = &PANEL_213_Z98C;
+    const auto *desc = this->desc_;
     this->plane_bytes_ = (static_cast<uint32_t>(desc->width) * desc->height) / 8;
     RAMAllocator<uint8_t> allocator;
     this->plane0_ = allocator.allocate(this->plane_bytes_);
@@ -66,32 +69,29 @@ class GxEPD2Display : public display::DisplayBuffer {
 
  protected:
   void draw_absolute_pixel_internal(int x, int y, Color color) override {
-    if (x < 0 || y < 0 || x >= PANEL_213_Z98C.width || y >= PANEL_213_Z98C.height)
+    if (x < 0 || y < 0 || x >= this->desc_->width || y >= this->desc_->height)
       return;
-    uint32_t idx = (static_cast<uint32_t>(y) * PANEL_213_Z98C.width + x) / 8;
+    uint32_t idx = (static_cast<uint32_t>(y) * this->desc_->width + x) / 8;
     uint8_t mask = 0x80 >> (x % 8);
     // Same classification the old task-based component used: red/accent
-    // pixels go to the color plane, everything else is black/white on the
-    // black plane. get_display_type() reports DISPLAY_TYPE_COLOR, so
-    // callers draw literal RGB colors, not the on/off is_on() convention.
+    // pixels go to the color plane (when the panel has one), everything
+    // else is black/white on the black plane. get_display_type() reports
+    // DISPLAY_TYPE_COLOR, so callers draw literal RGB colors, not the
+    // on/off is_on() convention, even on a plane_count==1 (BW-only) panel -
+    // there just is no color plane for a "red" classification to land on.
     //
     // Buffer convention here is 1=red/0=not-red (matching the reference
     // epaper_spi driver's EPaperWeAct3C internal convention), transformed
-    // to the wire value by PANEL_213_Z98C's plane_invert[1]=true - this
-    // panel (waveshare-2.13in-bv4) puts "red" on the wire as 0, not 1 (see
-    // EpaperWaveshareB::transform_red_byte in the working epaper_spi
-    // config's driver: "Waveshare uses 0=red, 1=no-red"). Getting this
-    // backwards was the actual bug behind the solid-red/black hardware
-    // runs: with zero red pixels in a frame, the whole color plane comes
-    // out as one uniform value either way - so whichever convention is
-    // backwards paints the entire panel that color instead of the intended
-    // "no red anywhere".
+    // to the wire value by plane_invert[1] - see PANEL_213_Z98C's comment
+    // for why that inversion flag and this buffer convention are coupled,
+    // not independent facts.
     bool red = color.red > color.green && color.red > color.blue && color.red > 0x7f;
     bool bright = color.red > 0x7f || color.green > 0x7f || color.blue > 0x7f;
-    if (red) {
+    if (red && this->plane1_ != nullptr) {
       this->plane1_[idx] |= mask;
     } else {
-      this->plane1_[idx] &= ~mask;
+      if (this->plane1_ != nullptr)
+        this->plane1_[idx] &= ~mask;
       if (bright) {
         this->plane0_[idx] |= mask;
       } else {
@@ -99,9 +99,10 @@ class GxEPD2Display : public display::DisplayBuffer {
       }
     }
   }
-  int get_width_internal() override { return PANEL_213_Z98C.width; }
-  int get_height_internal() override { return PANEL_213_Z98C.height; }
+  int get_width_internal() override { return this->desc_->width; }
+  int get_height_internal() override { return this->desc_->height; }
 
+  const EpdPanelDescriptor *desc_{nullptr};
   GPIOPin *cs_{nullptr};
   GPIOPin *dc_{nullptr};
   GPIOPin *rst_{nullptr};

+ 20 - 0
components/gxepd2/panel_213_z98c.h

@@ -53,6 +53,24 @@ inline const EpdCommand PANEL_213_Z98C_REFRESH[] = {
     {0x20, nullptr, 0, /*delay_before_ms=*/0, /*delay_after_ms=*/0, /*wait_busy_after=*/true},
 };
 
+// GxEPD2_EPD::_setPartialRamArea(0, 0, WIDTH, HEIGHT) - SSD1680's
+// byte-column addressing (0x44's operands are RAM byte columns, not raw
+// pixels), no Y-reversal. Was hardcoded into CycleEngine as the assumed-
+// universal window layout until GxEPD2_426_GDEQ0426T82/GxEPD2_583_GDEQ0583T31
+// showed two other controllers do this differently - moved here as plain
+// descriptor data, computed for this panel's fixed 128x250 RAM window.
+inline const uint8_t PANEL_213_Z98C_WINDOW_X[] = {0x00, 0x0F};              // xe/8 = 127/8
+inline const uint8_t PANEL_213_Z98C_WINDOW_Y[] = {0x00, 0x00, 0xF9, 0x00};  // ye = 249
+inline const uint8_t PANEL_213_Z98C_WINDOW_XC[] = {0x00};
+inline const uint8_t PANEL_213_Z98C_WINDOW_YC[] = {0x00, 0x00};
+
+inline const EpdCommand PANEL_213_Z98C_WINDOW[] = {
+    {0x44, PANEL_213_Z98C_WINDOW_X, 2},
+    {0x45, PANEL_213_Z98C_WINDOW_Y, 4},
+    {0x4E, PANEL_213_Z98C_WINDOW_XC, 1},
+    {0x4F, PANEL_213_Z98C_WINDOW_YC, 2},
+};
+
 // full_refresh_time/partial_refresh_time from the vendor header are both
 // 15000ms; used as the ceiling for every busy wait in this slice, not just
 // the refresh one it was measured for - simpler than carrying a separate
@@ -65,6 +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]),
     .plane_count = 2,
     .plane_write_cmd = {0x24, 0x26},
     // Stock GxEPD2 wire convention for the color plane, matched to

+ 83 - 0
components/gxepd2/panel_426_gdeq0426t82.h

@@ -0,0 +1,83 @@
+#pragma once
+
+// Hand-derived from vendor/src/gdeq/GxEPD2_426_GDEQ0426T82.{h,cpp}
+// (GDEQ0426T82, SSD1677 controller, 800x480, monochrome). First panel with
+// a genuine current/previous differential RAM (0x24/0x26) - the vendor
+// driver's writeImageAgain()/writeImageForFullRefresh() keep both buffers
+// in sync purely so a *future* fast-partial update diffs correctly; this
+// engine never does partial refresh (see gxepd2_extraction_next_steps in
+// project memory for that scope decision), so only the current-buffer
+// command (0x24) is ever written here - 0x26 is dropped entirely, same as
+// GxEPD2's own _initial_write priming pass (a dummy full-buffer clear
+// before the real content write) is dropped, since this engine always
+// writes real content directly and re-initializes fully every cycle.
+//
+// Also the first panel whose RAM-window-set (_setPartialRamArea) layout
+// differs from GxEPD2_213_Z98c's: SSD1677 addresses 0x44/0x45 in raw
+// pixels (not byte columns), reverses Y, and re-issues the entry-mode
+// command (0x11) on every window set - see window_sequence below.
+
+#include "epd_command.h"
+
+namespace esphome::gxepd2 {
+
+inline const uint8_t PANEL_426_GDEQ0426T82_TEMP_SENSOR[] = {0x80};
+inline const uint8_t PANEL_426_GDEQ0426T82_ANALOG_BLOCK[] = {0xAE, 0xC7, 0xC3, 0xC0, 0x80};
+// Driver output control: (HEIGHT-1) low/high byte, then SM=0x02 (interlaced).
+inline const uint8_t PANEL_426_GDEQ0426T82_DRIVER_OUTPUT_CONTROL[] = {0xDF, 0x01, 0x02};
+inline const uint8_t PANEL_426_GDEQ0426T82_BORDER_WAVEFORM[] = {0x01};
+
+inline const EpdCommand PANEL_426_GDEQ0426T82_INIT[] = {
+    {0x12, nullptr, 0, /*delay_before_ms=*/10, /*delay_after_ms=*/10, /*wait_busy_after=*/false},  // SWRESET
+    {0x18, PANEL_426_GDEQ0426T82_TEMP_SENSOR, 1},
+    {0x0C, PANEL_426_GDEQ0426T82_ANALOG_BLOCK, 5},
+    {0x01, PANEL_426_GDEQ0426T82_DRIVER_OUTPUT_CONTROL, 3},
+    {0x3C, PANEL_426_GDEQ0426T82_BORDER_WAVEFORM, 1},
+};
+
+// _setPartialRamArea(0, 0, 800, 480). y is stored as HEIGHT-y-h, which is
+// 0 for a full-height window regardless of the reversal - only matters for
+// a real partial window, which this engine never does.
+inline const uint8_t PANEL_426_GDEQ0426T82_ENTRY_MODE[] = {0x01};
+inline const uint8_t PANEL_426_GDEQ0426T82_WINDOW_X[] = {0x00, 0x00, 0x1F, 0x03};  // xs=0, xe=799
+inline const uint8_t PANEL_426_GDEQ0426T82_WINDOW_Y[] = {0xDF, 0x01, 0x00, 0x00};  // ye=479, ys=0
+inline const uint8_t PANEL_426_GDEQ0426T82_WINDOW_XC[] = {0x00, 0x00};
+inline const uint8_t PANEL_426_GDEQ0426T82_WINDOW_YC[] = {0xDF, 0x01};
+
+inline const EpdCommand PANEL_426_GDEQ0426T82_WINDOW[] = {
+    {0x11, PANEL_426_GDEQ0426T82_ENTRY_MODE, 1},
+    {0x44, PANEL_426_GDEQ0426T82_WINDOW_X, 4},
+    {0x45, PANEL_426_GDEQ0426T82_WINDOW_Y, 4},
+    {0x4E, PANEL_426_GDEQ0426T82_WINDOW_XC, 2},
+    {0x4F, PANEL_426_GDEQ0426T82_WINDOW_YC, 2},
+};
+
+// _Update_Full(), useFastFullUpdate=true path (this panel's vendor default).
+inline const uint8_t PANEL_426_GDEQ0426T82_DISPLAY_UPDATE_CONTROL[] = {0x40, 0x00};  // bypass RED as 0, single chip
+inline const uint8_t PANEL_426_GDEQ0426T82_TEMP_REGISTER[] = {0x5A};
+inline const uint8_t PANEL_426_GDEQ0426T82_UPDATE_MODE[] = {0xD7};
+
+inline const EpdCommand PANEL_426_GDEQ0426T82_REFRESH[] = {
+    {0x21, PANEL_426_GDEQ0426T82_DISPLAY_UPDATE_CONTROL, 2},
+    {0x1A, PANEL_426_GDEQ0426T82_TEMP_REGISTER, 1},
+    {0x22, PANEL_426_GDEQ0426T82_UPDATE_MODE, 1},
+    {0x20, nullptr, 0, /*delay_before_ms=*/0, /*delay_after_ms=*/0, /*wait_busy_after=*/true},
+};
+
+inline const EpdPanelDescriptor PANEL_426_GDEQ0426T82 = {
+    .width = 800,
+    .height = 480,
+    .busy_active_level = true,  // GxEPD2_EPD(..., /*busy_level=*/HIGH, ...)
+    .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]),
+    .plane_count = 1,
+    .plane_write_cmd = {0x24, 0x00},
+    .plane_invert = {false, false},
+    .refresh_sequence = PANEL_426_GDEQ0426T82_REFRESH,
+    .refresh_sequence_len = sizeof(PANEL_426_GDEQ0426T82_REFRESH) / sizeof(PANEL_426_GDEQ0426T82_REFRESH[0]),
+};
+
+}  // namespace esphome::gxepd2

+ 93 - 0
components/gxepd2/panel_583_gdeq0583t31.h

@@ -0,0 +1,93 @@
+#pragma once
+
+// Hand-derived from vendor/src/gdeq/GxEPD2_583_GDEQ0583T31.{h,cpp}
+// (GDEQ0583T31, UC8179 controller, 648x480, monochrome).
+//
+// Structurally the oddest of the panels covered so far: UC8179 has no
+// SSD1680/1677-style 0x44/0x45/0x4E/0x4F window registers at all - a single
+// 0x90 command carries the whole region, bracketed by 0x91 "partial in" /
+// 0x92 "partial out" markers that live *around* the plane write, not just
+// before it. 0x91+0x90 fits this engine's existing window_sequence slot
+// fine; 0x92 has nowhere else to go but the front of refresh_sequence,
+// since it must fire after the plane byte burst and before the refresh
+// trigger, and those are the only two sequence slots this engine has.
+//
+// Also the only panel here where _InitDisplay() (0x00 panel setting + 0x50
+// VCOM/data-interval) genuinely runs twice in one real update cycle even
+// with GxEPD2's own priming dropped: the vendor driver's _using_partial_mode
+// flag flips between _Init_Part() (before the write, TSSET=0x6E) and
+// _Init_Full() (before the refresh trigger, TSSET=0x5A) on *every* call, not
+// just the first - confirmed by reading refresh()/writeImage() themselves,
+// not just the traced first call. This engine has no persistent
+// _using_partial_mode to skip either pass, and resets the panel via a real
+// hardware reset pulse every cycle anyway (see CycleEngine's
+// request_full_update()), so re-running both init variants every time
+// matches the vendor driver's own behavior under that same "always reset,
+// never assume prior state" model, not a shortcut around it.
+
+#include "epd_command.h"
+
+namespace esphome::gxepd2 {
+
+inline const uint8_t PANEL_583_GDEQ0583T31_PANEL_SETTING[] = {0x1F};
+inline const uint8_t PANEL_583_GDEQ0583T31_VCOM_DATA_INTERVAL[] = {0x29, 0x07};
+inline const uint8_t PANEL_583_GDEQ0583T31_CASCADE_SETTING[] = {0x02};
+inline const uint8_t PANEL_583_GDEQ0583T31_TEMP_PARTIAL[] = {0x6E};  // TSSET, _Init_Part's value (110)
+
+inline const EpdCommand PANEL_583_GDEQ0583T31_INIT[] = {
+    // _Init_Part(): _InitDisplay() + cascade/temp-force + _PowerOn().
+    {0x00, PANEL_583_GDEQ0583T31_PANEL_SETTING, 1},
+    {0x50, PANEL_583_GDEQ0583T31_VCOM_DATA_INTERVAL, 2},
+    {0xE0, PANEL_583_GDEQ0583T31_CASCADE_SETTING, 1},
+    {0xE5, PANEL_583_GDEQ0583T31_TEMP_PARTIAL, 1},
+    {0x04, nullptr, 0, /*delay_before_ms=*/0, /*delay_after_ms=*/0, /*wait_busy_after=*/true},  // power on
+};
+
+// _setPartialRamArea(0, 0, 648, 480): xe = (0+648-1)|0x0007 = 655 (byte-
+// boundary inclusive), ye = 479; x masked to byte boundary is 0 either way.
+inline const uint8_t PANEL_583_GDEQ0583T31_WINDOW_REGION[] = {
+    0x00, 0x00,  // x/256, x%256           = 0, 0
+    0x02, 0x8F,  // xe/256, xe%256         = 655
+    0x00, 0x00,  // y/256, y%256           = 0, 0
+    0x01, 0xDF,  // ye/256, ye%256         = 479
+    0x01,
+};
+
+inline const EpdCommand PANEL_583_GDEQ0583T31_WINDOW[] = {
+    {0x91, nullptr, 0},  // partial in
+    {0x90, PANEL_583_GDEQ0583T31_WINDOW_REGION, 9},
+};
+
+// _Init_Full() + _Update_Full(): cascade/temp-force (full-context value)
+// re-run before the trigger, matching what the vendor driver actually does
+// on every call (see file header comment) - then 0x92 "partial out" closes
+// the window this engine's window_sequence opened, since it has to fire
+// after the plane write and there's no other slot for it.
+inline const uint8_t PANEL_583_GDEQ0583T31_TEMP_FULL[] = {0x5A};  // TSSET, _Init_Full's value (90)
+
+inline const EpdCommand PANEL_583_GDEQ0583T31_REFRESH[] = {
+    {0x92, nullptr, 0},  // partial out
+    {0x00, PANEL_583_GDEQ0583T31_PANEL_SETTING, 1},
+    {0x50, PANEL_583_GDEQ0583T31_VCOM_DATA_INTERVAL, 2},
+    {0xE0, PANEL_583_GDEQ0583T31_CASCADE_SETTING, 1},
+    {0xE5, PANEL_583_GDEQ0583T31_TEMP_FULL, 1},
+    {0x12, nullptr, 0, /*delay_before_ms=*/0, /*delay_after_ms=*/0, /*wait_busy_after=*/true},  // display refresh
+};
+
+inline const EpdPanelDescriptor PANEL_583_GDEQ0583T31 = {
+    .width = 648,
+    .height = 480,
+    .busy_active_level = false,  // GxEPD2_EPD(..., /*busy_level=*/LOW, ...) - inverted vs. the SSD1677/1680 panels
+    .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]),
+    .plane_count = 1,
+    .plane_write_cmd = {0x13, 0x00},
+    .plane_invert = {false, false},
+    .refresh_sequence = PANEL_583_GDEQ0583T31_REFRESH,
+    .refresh_sequence_len = sizeof(PANEL_583_GDEQ0583T31_REFRESH) / sizeof(PANEL_583_GDEQ0583T31_REFRESH[0]),
+};
+
+}  // namespace esphome::gxepd2

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

@@ -0,0 +1,25 @@
+esphome:
+  name: gxepd2-test-426-gdeq0426t82
+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_426_GDEQ0426T82
+    cs_pin: 5
+    dc_pin: 17
+    reset_pin: 16
+    busy_pin: 4
+    update_interval: 60s
+    lambda: |-
+      it.fill(Color::WHITE);

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

@@ -0,0 +1,25 @@
+esphome:
+  name: gxepd2-test-583-gdeq0583t31
+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_583_GDEQ0583T31
+    cs_pin: 5
+    dc_pin: 17
+    reset_pin: 16
+    busy_pin: 4
+    update_interval: 60s
+    lambda: |-
+      it.fill(Color::WHITE);