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

Add 5 new mono panels; generalize packed-mono resolution in the emitter

GxEPD2_213_B72 (SSD1675A), GxEPD2_270 (EK79652), GxEPD2_290_T5D (UC8151D),
GxEPD2_420 (UC8176), GxEPD2_583 (UC8159c) - five controller families with
no prior coverage, captured and wired in as "structural". All compile
clean on esp32-idf; check_descriptors.py still matches every prior panel.

Along the way:

- tools/capture.py: add --sweep/--diag, so a new panel's window-encoder
  sweep and Pattern-A diag fill run generically (run_harness.h /
  run_diag_harness.h are already templates over Driver) with no
  hand-written run_<panel>[_diag].cpp needed.

- emit_descriptor.h: fix the plane-invert/1bpp probe, which drove its
  test fill through writeScreenBuffer on the assumption that it shares a
  command with the real plane-write (writeImage). GxEPD2_270 doesn't
  (clear=0x13, image-plane=0x15), so the probe silently misclassified a
  genuinely 1bpp panel as packed. Probe through writeImage instead, the
  same call that produced the plane-write command in the first place.

- emit_descriptor.h: add make_resolver_bw_packed, a fallback packed
  resolver for a driver with no colour wrapper to drive (unlike the
  4-colour/7-colour resolvers, which observe a GxEPD2_4C/7C wrapper's
  named-colour fills). GxEPD2_583/UC8159c is wrapped GxEPD2_BW (binary)
  but its _send8pixel expands each 1bpp buffer byte into a wider wire
  format using only two codes; the new resolver derives bits_per_pixel
  and both codes by driving plain writeImage with uniform white/black
  1bpp fills and checking the wire burst is internally uniform - no
  source reading needed, and capture.py now emits a complete descriptor
  (palette array included) for this shape.

CONTRIBUTING.md and the harness README updated for both.
ZiB 2 недель назад
Родитель
Сommit
996c71278c

+ 31 - 7
CONTRIBUTING.md

@@ -78,13 +78,31 @@ by *probing with distinctive inputs and reading the transform back out*:
   (`bits_per_pixel` + `palette`), and a native-path variant handles packed
   panels whose re-pack doesn't preserve byte identity.
 - **`plane_invert`** (whether each plane's wire byte is the bit-complement of
-  the engine's buffer byte). The fill-capture probe (`probe_plane_invert.h`,
-  also used inside the emitter) drives white/black/red `writeScreenBuffer`
-  fills and reconciles each plane's wire byte against the engine's fixed buffer
-  convention (plane 0 bit = white, plane 1 bit = red).
+  the engine's buffer byte). The fill-capture probe (in `emit_descriptor.h`,
+  the same shape as the standalone `probe_plane_invert.h`) drives a
+  white/black/red fill through the plane-write path - `writeImage`, not
+  `writeScreenBuffer` - and reconciles each plane's wire byte against the
+  engine's fixed buffer convention (plane 0 bit = white, plane 1 bit = red).
+  It used to drive the fill through `writeScreenBuffer` instead, on the
+  assumption that a driver's screen-buffer clear and its image-plane write
+  share one command; GxEPD2_270 doesn't (clear = `0x13`, image-plane = `0x15`),
+  so the probe searched the clear's trace for a command it never emits, got
+  "not found," and silently misreported a genuinely 1bpp panel as packed.
+  Fixed by probing through the same call that produced the plane command in
+  the first place.
 - **`bits_per_pixel` (1bpp vs packed).** The same probe: a white fill whose
   wire byte is all-bits (`0x00`/`0xFF`) is a 1bpp plane model; anything else
-  (e.g. `0x55`) is packed, and depth/palette come from Pattern B.
+  (e.g. `0x55`) is packed, and depth/palette come from Pattern B *if* the
+  driver exposes a `GxEPD2_4C`/`GxEPD2_7C` colour wrapper to drive. A driver
+  with no colour wrapper at all (still `GxEPD2_BW`) that nonetheless packs
+  internally - GxEPD2_583/UC8159c's `_send8pixel`, a 1bpp buffer expanded to a
+  2-code wire format - has no named colours for Pattern B to read, so
+  `emit_descriptor.h` falls back to `make_resolver_bw_packed`: drive the plain
+  driver's `writeImage` directly with a uniform all-white/all-black 1bpp
+  buffer, and if both wire bursts are internally uniform, the byte-count ratio
+  against the pixel count gives `bits_per_pixel` and each fill's byte gives
+  that colour's code. Folded into the one-command `capture.py` path, same as
+  4-colour/7-colour - no source reading needed for this shape anymore.
 
 ### 4. Capturing *nothing* is sometimes correct
 
@@ -185,13 +203,14 @@ Derived by the sweep (screen coords `x,y,w,h`):
 
 | Controller | Panels | Window encoding |
 |---|---|---|
-| SSD1680 | 213_Z98c, 154_Z90c | `0x11=0x03`; `0x44=[x/8,(x+w-1)/8]` byte-X; `0x45=[y,y+h-1]` 16-bit LE; `0x4E/0x4F` cursors |
+| SSD1680 | 213_Z98c, 154_Z90c, 213_B72 (SSD1675A) | `0x11=0x03`; `0x44=[x/8,(x+w-1)/8]` byte-X; `0x45=[y,y+h-1]` 16-bit LE; `0x4E/0x4F` cursors |
 | SSD1677 | 426_GDEQ0426T82 | `0x11=0x01`; `0x44=[x,x+w-1]` raw-pixel 16-bit LE; `0x45=[H-1-y, H-1-(y+h-1)]` **reversed-Y** 16-bit LE |
-| UC8179 | 583_GDEQ0583T31 | `0x90=[x,x+w-1,y,y+h-1]` raw-pixel 16-bit **big-endian** + flag, bracketed by `0x91`/`0x92` |
+| UC8179 / UC8176 / UC8151D / UC8159c | 583_GDEQ0583T31; 420 (UC8176); 290_T5D (UC8151D); 583 (UC8159c) | `0x90=[x,x+w-1,y,y+h-1]` raw-pixel 16-bit **big-endian** + flag, bracketed by `0x91`/`0x92` — a reference-driver convention shared across four otherwise-unrelated Good Display controllers. 290_T5D's X fields are single bytes (panel width <256 fits one byte; Y is still 16-bit) where the wider panels use 16-bit X too. 420 omits the `0x91`/`0x92` bracket that 290_T5D/583/the UC8179 panel all use. |
 | JD79667 | 290c_GDEY029F51 | `0x83=[x,x+w-1,y,y+h-1]` raw-pixel 16-bit BE + mode byte |
 | JD79676 | 213c_GDEY0213F52 | none — OTP-default frame |
 | HX8717 dual | 579c_GDEY0579F51 | per-region fixed full-frame `0x83` (sweep shows the rect is ignored) |
 | unknown (GDEP073E01) | 730c_GDEP073E01 | `0x83=[x,x+w-1,y,y+h]` raw-pixel 16-bit BE + flag — **asymmetric end**: X is inclusive (`x+w-1`) but Y is exclusive (`y+h`), one more than JD79667's otherwise-identical `0x83` layout |
+| EK79652 (IL91874) | 270 | `0x16=[x,y,w,h]` four raw-pixel 16-bit BE fields, **no window math at all** (literal x,y,w,h, no inclusive-end subtraction, no byte-division) - simplest encoder seen so far. The `(0,0,W,H)` sweep point reads a different, non-literal value because `refresh(0,0,W,H)` takes the driver's full-update path (`_Update_Full`), not the coordinate-driven partial path - expected per window_sweep.h's own cross-check framing, not a math bug. |
 
 ## Supported panels
 
@@ -202,10 +221,15 @@ from it, and a non-verified panel logs a warning in its boot `dump_config`.
 | `model:` | Panel | Controller | Format | Status |
 |---|---|---|---|---|
 | `GxEPD2_154_Z90c` | GDEH0154Z90 | SSD1681 | 3-color | ✅ verified |
+| `GxEPD2_213_B72` | GDEH0213B72 | SSD1675A | mono | ⚪ structural |
 | `GxEPD2_213_Z98C` | GDEY0213Z98 | SSD1680 | 3-color | ✅ verified |
 | `GxEPD2_213c_GDEY0213F51` | GDEY0213F51 | JD79661 | 4-color | ⚪ structural |
 | `GxEPD2_213c_GDEY0213F52` | GDEY0213F52 | JD79676 | 4-color | ✅ verified |
+| `GxEPD2_270` | GDEW027W3 | EK79652 | mono | ⚪ structural |
+| `GxEPD2_290_T5D` | GDEW029T5D | UC8151D | mono | ⚪ structural |
+| `GxEPD2_420` | GDEW042T2 | UC8176 | mono | ⚪ structural |
 | `GxEPD2_426_GDEQ0426T82` | GDEQ0426T82 | SSD1677 | mono | ✅ verified |
+| `GxEPD2_583` | GDEW0583T7 | UC8159c | mono | ⚪ structural |
 | `GxEPD2_583_GDEQ0583T31` | GDEQ0583T31 | UC8179 | mono | ✅ verified |
 | `GxEPD2_730c_GDEP073E01` | GDEP073E01 | unknown | 7-color | ⚪ structural |
 | `GxEPD2_750_GDEY075T7` | GDEY075T7 | UC8179 | mono | ✅ verified |

+ 5 - 0
components/gxepd2/display.py

@@ -60,10 +60,15 @@ Panel = namedtuple("Panel", "descriptor part controller fmt status")
 
 PANELS = {
     "GxEPD2_154_Z90c": Panel("PANEL_154_Z90C", "GDEH0154Z90", "SSD1681", "3-color", "verified"),
+    "GxEPD2_213_B72": Panel("PANEL_213_B72", "GDEH0213B72", "SSD1675A", "mono", "structural"),
     "GxEPD2_213_Z98C": Panel("PANEL_213_Z98C", "GDEY0213Z98", "SSD1680", "3-color", "verified"),
     "GxEPD2_213c_GDEY0213F51": Panel("PANEL_213C_GDEY0213F51", "GDEY0213F51", "JD79661", "4-color", "structural"),
     "GxEPD2_213c_GDEY0213F52": Panel("PANEL_213C_GDEY0213F52", "GDEY0213F52", "JD79676", "4-color", "verified"),
+    "GxEPD2_270": Panel("PANEL_270", "GDEW027W3", "EK79652", "mono", "structural"),
+    "GxEPD2_290_T5D": Panel("PANEL_290_T5D", "GDEW029T5D", "UC8151D", "mono", "structural"),
+    "GxEPD2_420": Panel("PANEL_420", "GDEW042T2", "UC8176", "mono", "structural"),
     "GxEPD2_426_GDEQ0426T82": Panel("PANEL_426_GDEQ0426T82", "GDEQ0426T82", "SSD1677", "mono", "verified"),
+    "GxEPD2_583": Panel("PANEL_583", "GDEW0583T7", "UC8159c", "mono", "structural"),
     "GxEPD2_583_GDEQ0583T31": Panel("PANEL_583_GDEQ0583T31", "GDEQ0583T31", "UC8179", "mono", "verified"),
     "GxEPD2_730c_GDEP073E01": Panel("PANEL_730C_GDEP073E01", "GDEP073E01", "unknown", "7-color", "structural"),
     "GxEPD2_750_GDEY075T7": Panel("PANEL_750_GDEY075T7", "GDEY075T7", "UC8179", "mono", "verified"),

+ 5 - 0
components/gxepd2/gxepd2_display.h

@@ -6,13 +6,18 @@
 #include "esphome/components/spi/spi.h"
 #include "cycle_engine.h"
 #include "panel_154_z90c.h"
+#include "panel_213_b72.h"
 #include "panel_213_z98c.h"
 #include "panel_213c_gdey0213f51.h"
 #include "panel_213c_gdey0213f52.h"
+#include "panel_270.h"
+#include "panel_290_t5d.h"
 #include "panel_290c_gdey029f51.h"
+#include "panel_420.h"
 #include "panel_426_gdeq0426t82.h"
 #include "panel_579c_gdey0579f51.h"
 #include "panel_579c_gdey0579f51_master_only.h"
+#include "panel_583.h"
 #include "panel_583_gdeq0583t31.h"
 #include "panel_730c_gdep073e01.h"
 #include "panel_750_gdey075t7.h"

+ 94 - 0
components/gxepd2/panel_213_b72.h

@@ -0,0 +1,94 @@
+// GENERATED by emit_descriptor from GxEPD2_213_B72 - candidate.
+// Command sequences, plane layout and plane_invert are observed here;
+// for a packed panel bits_per_pixel/palette/blank_code are probe-filled
+// when the colour family is recognised (4-colour/7-colour), else left TODO. A
+// dual-region panel also gets plane_width and a candidate line_order; the
+// exact PAIRED_EDGES row order still wants diag/driver confirmation.
+#pragma once
+#include "epd_command.h"
+
+namespace esphome::gxepd2 {
+
+inline const uint8_t PANEL_213_B72_INIT_0_74[] = {0x54};
+inline const uint8_t PANEL_213_B72_INIT_1_7E[] = {0x3B};
+inline const uint8_t PANEL_213_B72_INIT_2_01[] = {0xF9, 0x00, 0x00};
+inline const uint8_t PANEL_213_B72_INIT_3_3C[] = {0x03};
+inline const uint8_t PANEL_213_B72_INIT_4_2C[] = {0x70};
+inline const uint8_t PANEL_213_B72_INIT_5_03[] = {0x15};
+inline const uint8_t PANEL_213_B72_INIT_6_04[] = {0x41, 0xA8, 0x32};
+inline const uint8_t PANEL_213_B72_INIT_7_3A[] = {0x30};
+inline const uint8_t PANEL_213_B72_INIT_8_3B[] = {0x0A};
+
+inline const EpdCommand PANEL_213_B72_INIT[] = {
+    {0x74, PANEL_213_B72_INIT_0_74, 1},
+    {0x7E, PANEL_213_B72_INIT_1_7E, 1},
+    {0x01, PANEL_213_B72_INIT_2_01, 3},
+    {0x3C, PANEL_213_B72_INIT_3_3C, 1},
+    {0x2C, PANEL_213_B72_INIT_4_2C, 1},
+    {0x03, PANEL_213_B72_INIT_5_03, 1},
+    {0x04, PANEL_213_B72_INIT_6_04, 3},
+    {0x3A, PANEL_213_B72_INIT_7_3A, 1},
+    {0x3B, PANEL_213_B72_INIT_8_3B, 1},
+};
+
+inline const uint8_t PANEL_213_B72_WINDOW_SEQ0_0_11[] = {0x03};
+inline const uint8_t PANEL_213_B72_WINDOW_SEQ0_1_44[] = {0x00, 0x0F};
+inline const uint8_t PANEL_213_B72_WINDOW_SEQ0_2_45[] = {0x00, 0x00, 0xF9, 0x00};
+inline const uint8_t PANEL_213_B72_WINDOW_SEQ0_3_4E[] = {0x00};
+inline const uint8_t PANEL_213_B72_WINDOW_SEQ0_4_4F[] = {0x00, 0x00};
+
+inline const EpdCommand PANEL_213_B72_WINDOW_SEQ0[] = {
+    {0x11, PANEL_213_B72_WINDOW_SEQ0_0_11, 1},
+    {0x44, PANEL_213_B72_WINDOW_SEQ0_1_44, 2},
+    {0x45, PANEL_213_B72_WINDOW_SEQ0_2_45, 4},
+    {0x4E, PANEL_213_B72_WINDOW_SEQ0_3_4E, 1},
+    {0x4F, PANEL_213_B72_WINDOW_SEQ0_4_4F, 2},
+};
+
+inline const uint8_t PANEL_213_B72_REFRESH_0_74[] = {0x54};
+inline const uint8_t PANEL_213_B72_REFRESH_1_7E[] = {0x3B};
+inline const uint8_t PANEL_213_B72_REFRESH_2_01[] = {0xF9, 0x00, 0x00};
+inline const uint8_t PANEL_213_B72_REFRESH_3_3C[] = {0x03};
+inline const uint8_t PANEL_213_B72_REFRESH_4_2C[] = {0x70};
+inline const uint8_t PANEL_213_B72_REFRESH_5_03[] = {0x15};
+inline const uint8_t PANEL_213_B72_REFRESH_6_04[] = {0x41, 0xA8, 0x32};
+inline const uint8_t PANEL_213_B72_REFRESH_7_3A[] = {0x30};
+inline const uint8_t PANEL_213_B72_REFRESH_8_3B[] = {0x0A};
+inline const uint8_t PANEL_213_B72_REFRESH_9_32[] = {0x80, 0x60, 0x40, 0x00, 0x00, 0x00, 0x00, 0x10, 0x60, 0x20, 0x00, 0x00, 0x00, 0x00, 0x80, 0x60, 0x40, 0x00, 0x00, 0x00, 0x00, 0x10, 0x60, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x03, 0x00, 0x00, 0x02, 0x09, 0x09, 0x00, 0x00, 0x02, 0x03, 0x03, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+inline const uint8_t PANEL_213_B72_REFRESH_10_22[] = {0xC4};
+
+inline const EpdCommand PANEL_213_B72_REFRESH[] = {
+    {0x74, PANEL_213_B72_REFRESH_0_74, 1},
+    {0x7E, PANEL_213_B72_REFRESH_1_7E, 1},
+    {0x01, PANEL_213_B72_REFRESH_2_01, 3},
+    {0x3C, PANEL_213_B72_REFRESH_3_3C, 1},
+    {0x2C, PANEL_213_B72_REFRESH_4_2C, 1},
+    {0x03, PANEL_213_B72_REFRESH_5_03, 1},
+    {0x04, PANEL_213_B72_REFRESH_6_04, 3},
+    {0x3A, PANEL_213_B72_REFRESH_7_3A, 1},
+    {0x3B, PANEL_213_B72_REFRESH_8_3B, 1},
+    {0x32, PANEL_213_B72_REFRESH_9_32, 70},
+    {0x22, PANEL_213_B72_REFRESH_10_22, 1},
+    {0x20, nullptr, 0, /*delay_before_ms=*/0, /*delay_after_ms=*/0, /*wait_busy_after=*/true},
+};
+
+inline const EpdPanelDescriptor PANEL_213_B72 = {
+    .width = 128,
+    .height = 250,
+    .busy_active_level = true,
+    .busy_timeout_ms = 15000,  // generous diagnostic ceiling (vendor full_refresh_time 1700ms, 1bpp mono/3-colour class); dev-tunable
+    .init_sequence = PANEL_213_B72_INIT,
+    .init_sequence_len = sizeof(PANEL_213_B72_INIT) / sizeof(PANEL_213_B72_INIT[0]),
+    .window_sequence = {PANEL_213_B72_WINDOW_SEQ0, nullptr},
+    .window_sequence_len = {sizeof(PANEL_213_B72_WINDOW_SEQ0) / sizeof(PANEL_213_B72_WINDOW_SEQ0[0]), 0},
+    .plane_count = 1,
+    .plane_write_cmd = {0x24, 0x00},
+    .plane_invert = {false, false},  // probe-derived
+    .bits_per_pixel = 1,  // probe: white fill => all-bits wire byte 0xFF
+    .palette = nullptr,
+    .palette_len = 0,
+    .refresh_sequence = PANEL_213_B72_REFRESH,
+    .refresh_sequence_len = sizeof(PANEL_213_B72_REFRESH) / sizeof(PANEL_213_B72_REFRESH[0]),
+};
+
+}  // namespace esphome::gxepd2

+ 93 - 0
components/gxepd2/panel_270.h

@@ -0,0 +1,93 @@
+// GENERATED by emit_descriptor from GxEPD2_270 - candidate.
+// Command sequences, plane layout and plane_invert are observed here;
+// for a packed panel bits_per_pixel/palette/blank_code are probe-filled
+// when the colour family is recognised (4-colour/7-colour), else left TODO. A
+// dual-region panel also gets plane_width and a candidate line_order; the
+// exact PAIRED_EDGES row order still wants diag/driver confirmation.
+#pragma once
+#include "epd_command.h"
+
+namespace esphome::gxepd2 {
+
+inline const uint8_t PANEL_270_INIT_0_01[] = {0x03, 0x00, 0x2B, 0x2B};
+inline const uint8_t PANEL_270_INIT_1_06[] = {0x07, 0x07, 0x17};
+inline const uint8_t PANEL_270_INIT_2_16[] = {0x00};
+inline const uint8_t PANEL_270_INIT_3_00[] = {0xBF};
+inline const uint8_t PANEL_270_INIT_4_30[] = {0x3A};
+inline const uint8_t PANEL_270_INIT_5_61[] = {0x00, 0xB0, 0x01, 0x08};
+inline const uint8_t PANEL_270_INIT_6_82[] = {0x08};
+inline const uint8_t PANEL_270_INIT_7_50[] = {0x17};
+inline const uint8_t PANEL_270_INIT_8_20[] = {0x00, 0x00, 0x00, 0x19, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+inline const uint8_t PANEL_270_INIT_9_21[] = {0x00, 0x19, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+inline const uint8_t PANEL_270_INIT_10_22[] = {0x80, 0x19, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+inline const uint8_t PANEL_270_INIT_11_23[] = {0x40, 0x19, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+inline const uint8_t PANEL_270_INIT_12_24[] = {0x00, 0x19, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+
+inline const EpdCommand PANEL_270_INIT[] = {
+    {0x01, PANEL_270_INIT_0_01, 4},
+    {0x06, PANEL_270_INIT_1_06, 3},
+    {0x16, PANEL_270_INIT_2_16, 1},
+    {0x00, PANEL_270_INIT_3_00, 1},
+    {0x30, PANEL_270_INIT_4_30, 1},
+    {0x61, PANEL_270_INIT_5_61, 4},
+    {0x82, PANEL_270_INIT_6_82, 1},
+    {0x50, PANEL_270_INIT_7_50, 1},
+    {0x20, PANEL_270_INIT_8_20, 44},
+    {0x21, PANEL_270_INIT_9_21, 42},
+    {0x22, PANEL_270_INIT_10_22, 42},
+    {0x23, PANEL_270_INIT_11_23, 42},
+    {0x24, PANEL_270_INIT_12_24, 42},
+    {0x04, nullptr, 0, /*delay_before_ms=*/0, /*delay_after_ms=*/0, /*wait_busy_after=*/true},
+};
+
+inline const uint8_t PANEL_270_REFRESH_0_01[] = {0x03, 0x00, 0x2B, 0x2B};
+inline const uint8_t PANEL_270_REFRESH_1_06[] = {0x07, 0x07, 0x17};
+inline const uint8_t PANEL_270_REFRESH_2_16[] = {0x00};
+inline const uint8_t PANEL_270_REFRESH_3_00[] = {0xBF};
+inline const uint8_t PANEL_270_REFRESH_4_30[] = {0x3A};
+inline const uint8_t PANEL_270_REFRESH_5_61[] = {0x00, 0xB0, 0x01, 0x08};
+inline const uint8_t PANEL_270_REFRESH_6_82[] = {0x08};
+inline const uint8_t PANEL_270_REFRESH_7_50[] = {0x97};
+inline const uint8_t PANEL_270_REFRESH_8_20[] = {0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x02, 0x60, 0x28, 0x28, 0x00, 0x00, 0x01, 0x00, 0x14, 0x00, 0x00, 0x00, 0x01, 0x00, 0x12, 0x12, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+inline const uint8_t PANEL_270_REFRESH_9_21[] = {0x40, 0x08, 0x00, 0x00, 0x00, 0x02, 0x90, 0x28, 0x28, 0x00, 0x00, 0x01, 0x40, 0x14, 0x00, 0x00, 0x00, 0x01, 0xA0, 0x12, 0x12, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+inline const uint8_t PANEL_270_REFRESH_10_22[] = {0x40, 0x08, 0x00, 0x00, 0x00, 0x02, 0x90, 0x28, 0x28, 0x00, 0x00, 0x01, 0x40, 0x14, 0x00, 0x00, 0x00, 0x01, 0xA0, 0x12, 0x12, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+inline const uint8_t PANEL_270_REFRESH_11_23[] = {0x80, 0x08, 0x00, 0x00, 0x00, 0x02, 0x90, 0x28, 0x28, 0x00, 0x00, 0x01, 0x80, 0x14, 0x00, 0x00, 0x00, 0x01, 0x50, 0x12, 0x12, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+inline const uint8_t PANEL_270_REFRESH_12_24[] = {0x80, 0x08, 0x00, 0x00, 0x00, 0x02, 0x90, 0x28, 0x28, 0x00, 0x00, 0x01, 0x80, 0x14, 0x00, 0x00, 0x00, 0x01, 0x50, 0x12, 0x12, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+
+inline const EpdCommand PANEL_270_REFRESH[] = {
+    {0x01, PANEL_270_REFRESH_0_01, 4},
+    {0x06, PANEL_270_REFRESH_1_06, 3},
+    {0x16, PANEL_270_REFRESH_2_16, 1},
+    {0x00, PANEL_270_REFRESH_3_00, 1},
+    {0x30, PANEL_270_REFRESH_4_30, 1},
+    {0x61, PANEL_270_REFRESH_5_61, 4},
+    {0x82, PANEL_270_REFRESH_6_82, 1},
+    {0x50, PANEL_270_REFRESH_7_50, 1},
+    {0x20, PANEL_270_REFRESH_8_20, 44},
+    {0x21, PANEL_270_REFRESH_9_21, 42},
+    {0x22, PANEL_270_REFRESH_10_22, 42},
+    {0x23, PANEL_270_REFRESH_11_23, 42},
+    {0x24, PANEL_270_REFRESH_12_24, 42},
+    {0x12, nullptr, 0, /*delay_before_ms=*/0, /*delay_after_ms=*/0, /*wait_busy_after=*/true},
+};
+
+inline const EpdPanelDescriptor PANEL_270 = {
+    .width = 176,
+    .height = 264,
+    .busy_active_level = false,
+    .busy_timeout_ms = 15000,  // generous diagnostic ceiling (vendor full_refresh_time 2000ms, 1bpp mono/3-colour class); dev-tunable
+    .init_sequence = PANEL_270_INIT,
+    .init_sequence_len = sizeof(PANEL_270_INIT) / sizeof(PANEL_270_INIT[0]),
+    .window_sequence = {nullptr, nullptr},  // panel sets no window (OTP-default frame)
+    .window_sequence_len = {0, 0},
+    .plane_count = 1,
+    .plane_write_cmd = {0x15, 0x00},
+    .plane_invert = {true, false},  // probe-derived
+    .bits_per_pixel = 1,  // probe: white fill => all-bits wire byte 0x00
+    .palette = nullptr,
+    .palette_len = 0,
+    .refresh_sequence = PANEL_270_REFRESH,
+    .refresh_sequence_len = sizeof(PANEL_270_REFRESH) / sizeof(PANEL_270_REFRESH[0]),
+};
+
+}  // namespace esphome::gxepd2

+ 77 - 0
components/gxepd2/panel_290_t5d.h

@@ -0,0 +1,77 @@
+// GENERATED by emit_descriptor from GxEPD2_290_T5D - candidate.
+// Command sequences, plane layout and plane_invert are observed here;
+// for a packed panel bits_per_pixel/palette/blank_code are probe-filled
+// when the colour family is recognised (4-colour/7-colour), else left TODO. A
+// dual-region panel also gets plane_width and a candidate line_order; the
+// exact PAIRED_EDGES row order still wants diag/driver confirmation.
+#pragma once
+#include "epd_command.h"
+
+namespace esphome::gxepd2 {
+
+inline const uint8_t PANEL_290_T5D_INIT_0_00[] = {0x1F};
+inline const uint8_t PANEL_290_T5D_INIT_1_61[] = {0x80, 0x01, 0x28};
+inline const uint8_t PANEL_290_T5D_INIT_2_50[] = {0x97};
+inline const uint8_t PANEL_290_T5D_INIT_3_00[] = {0xBF};
+inline const uint8_t PANEL_290_T5D_INIT_4_82[] = {0x08};
+inline const uint8_t PANEL_290_T5D_INIT_5_50[] = {0x17};
+inline const uint8_t PANEL_290_T5D_INIT_6_20[] = {0x00, 0x20, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+inline const uint8_t PANEL_290_T5D_INIT_7_21[] = {0x00, 0x20, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+inline const uint8_t PANEL_290_T5D_INIT_8_22[] = {0x80, 0x20, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+inline const uint8_t PANEL_290_T5D_INIT_9_23[] = {0x40, 0x20, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+inline const uint8_t PANEL_290_T5D_INIT_10_24[] = {0x00, 0x20, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+
+inline const EpdCommand PANEL_290_T5D_INIT[] = {
+    {0x00, PANEL_290_T5D_INIT_0_00, 1},
+    {0x61, PANEL_290_T5D_INIT_1_61, 3},
+    {0x50, PANEL_290_T5D_INIT_2_50, 1},
+    {0x00, PANEL_290_T5D_INIT_3_00, 1},
+    {0x82, PANEL_290_T5D_INIT_4_82, 1},
+    {0x50, PANEL_290_T5D_INIT_5_50, 1},
+    {0x20, PANEL_290_T5D_INIT_6_20, 44},
+    {0x21, PANEL_290_T5D_INIT_7_21, 42},
+    {0x22, PANEL_290_T5D_INIT_8_22, 42},
+    {0x23, PANEL_290_T5D_INIT_9_23, 42},
+    {0x24, PANEL_290_T5D_INIT_10_24, 42},
+    {0x04, nullptr, 0, /*delay_before_ms=*/0, /*delay_after_ms=*/0, /*wait_busy_after=*/true},
+};
+
+inline const uint8_t PANEL_290_T5D_WINDOW_SEQ0_1_90[] = {0x00, 0x7F, 0x00, 0x00, 0x01, 0x27, 0x01};
+
+inline const EpdCommand PANEL_290_T5D_WINDOW_SEQ0[] = {
+    {0x91, nullptr, 0},
+    {0x90, PANEL_290_T5D_WINDOW_SEQ0_1_90, 7},
+};
+
+inline const uint8_t PANEL_290_T5D_REFRESH_1_00[] = {0x1F};
+inline const uint8_t PANEL_290_T5D_REFRESH_2_61[] = {0x80, 0x01, 0x28};
+inline const uint8_t PANEL_290_T5D_REFRESH_3_50[] = {0x97};
+
+inline const EpdCommand PANEL_290_T5D_REFRESH[] = {
+    {0x92, nullptr, 0, /*delay_before_ms=*/0, /*delay_after_ms=*/1, /*wait_busy_after=*/false},
+    {0x00, PANEL_290_T5D_REFRESH_1_00, 1},
+    {0x61, PANEL_290_T5D_REFRESH_2_61, 3},
+    {0x50, PANEL_290_T5D_REFRESH_3_50, 1},
+    {0x12, nullptr, 0, /*delay_before_ms=*/0, /*delay_after_ms=*/0, /*wait_busy_after=*/true},
+};
+
+inline const EpdPanelDescriptor PANEL_290_T5D = {
+    .width = 128,
+    .height = 296,
+    .busy_active_level = false,
+    .busy_timeout_ms = 15000,  // generous diagnostic ceiling (vendor full_refresh_time 3500ms, 1bpp mono/3-colour class); dev-tunable
+    .init_sequence = PANEL_290_T5D_INIT,
+    .init_sequence_len = sizeof(PANEL_290_T5D_INIT) / sizeof(PANEL_290_T5D_INIT[0]),
+    .window_sequence = {PANEL_290_T5D_WINDOW_SEQ0, nullptr},
+    .window_sequence_len = {sizeof(PANEL_290_T5D_WINDOW_SEQ0) / sizeof(PANEL_290_T5D_WINDOW_SEQ0[0]), 0},
+    .plane_count = 1,
+    .plane_write_cmd = {0x13, 0x00},
+    .plane_invert = {false, false},  // probe-derived
+    .bits_per_pixel = 1,  // probe: white fill => all-bits wire byte 0xFF
+    .palette = nullptr,
+    .palette_len = 0,
+    .refresh_sequence = PANEL_290_T5D_REFRESH,
+    .refresh_sequence_len = sizeof(PANEL_290_T5D_REFRESH) / sizeof(PANEL_290_T5D_REFRESH[0]),
+};
+
+}  // namespace esphome::gxepd2

+ 97 - 0
components/gxepd2/panel_420.h

@@ -0,0 +1,97 @@
+// GENERATED by emit_descriptor from GxEPD2_420 - candidate.
+// Command sequences, plane layout and plane_invert are observed here;
+// for a packed panel bits_per_pixel/palette/blank_code are probe-filled
+// when the colour family is recognised (4-colour/7-colour), else left TODO. A
+// dual-region panel also gets plane_width and a candidate line_order; the
+// exact PAIRED_EDGES row order still wants diag/driver confirmation.
+#pragma once
+#include "epd_command.h"
+
+namespace esphome::gxepd2 {
+
+inline const uint8_t PANEL_420_INIT_0_01[] = {0x03, 0x00, 0x2B, 0x2B};
+inline const uint8_t PANEL_420_INIT_1_06[] = {0x17, 0x17, 0x17};
+inline const uint8_t PANEL_420_INIT_2_00[] = {0x3F};
+inline const uint8_t PANEL_420_INIT_3_30[] = {0x3A};
+inline const uint8_t PANEL_420_INIT_4_61[] = {0x01, 0x90, 0x01, 0x2C};
+inline const uint8_t PANEL_420_INIT_5_82[] = {0x12};
+inline const uint8_t PANEL_420_INIT_6_50[] = {0xD7};
+inline const uint8_t PANEL_420_INIT_7_20[] = {0x00, 0x19, 0x01, 0x04, 0x19, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+inline const uint8_t PANEL_420_INIT_8_21[] = {0x18, 0x19, 0x01, 0x04, 0x19, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+inline const uint8_t PANEL_420_INIT_9_22[] = {0x5A, 0x19, 0x01, 0x04, 0x19, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+inline const uint8_t PANEL_420_INIT_10_23[] = {0xA5, 0x19, 0x01, 0x04, 0x19, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+inline const uint8_t PANEL_420_INIT_11_24[] = {0x24, 0x19, 0x01, 0x04, 0x19, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+
+inline const EpdCommand PANEL_420_INIT[] = {
+    {0x01, PANEL_420_INIT_0_01, 4},
+    {0x06, PANEL_420_INIT_1_06, 3},
+    {0x00, PANEL_420_INIT_2_00, 1},
+    {0x30, PANEL_420_INIT_3_30, 1},
+    {0x61, PANEL_420_INIT_4_61, 4},
+    {0x82, PANEL_420_INIT_5_82, 1},
+    {0x50, PANEL_420_INIT_6_50, 1},
+    {0x20, PANEL_420_INIT_7_20, 44},
+    {0x21, PANEL_420_INIT_8_21, 42},
+    {0x22, PANEL_420_INIT_9_22, 42},
+    {0x23, PANEL_420_INIT_10_23, 42},
+    {0x24, PANEL_420_INIT_11_24, 42},
+    {0x04, nullptr, 0, /*delay_before_ms=*/0, /*delay_after_ms=*/0, /*wait_busy_after=*/true},
+};
+
+inline const uint8_t PANEL_420_WINDOW_SEQ0_1_90[] = {0x00, 0x00, 0x01, 0x8F, 0x00, 0x00, 0x01, 0x2B, 0x01};
+
+inline const EpdCommand PANEL_420_WINDOW_SEQ0[] = {
+    {0x91, nullptr, 0},
+    {0x90, PANEL_420_WINDOW_SEQ0_1_90, 9},
+};
+
+inline const uint8_t PANEL_420_REFRESH_1_01[] = {0x03, 0x00, 0x2B, 0x2B};
+inline const uint8_t PANEL_420_REFRESH_2_06[] = {0x17, 0x17, 0x17};
+inline const uint8_t PANEL_420_REFRESH_3_00[] = {0x3F};
+inline const uint8_t PANEL_420_REFRESH_4_30[] = {0x3A};
+inline const uint8_t PANEL_420_REFRESH_5_61[] = {0x01, 0x90, 0x01, 0x2C};
+inline const uint8_t PANEL_420_REFRESH_6_82[] = {0x12};
+inline const uint8_t PANEL_420_REFRESH_7_50[] = {0xD7};
+inline const uint8_t PANEL_420_REFRESH_8_20[] = {0x00, 0x08, 0x00, 0x00, 0x00, 0x02, 0x60, 0x28, 0x28, 0x00, 0x00, 0x01, 0x00, 0x14, 0x00, 0x00, 0x00, 0x01, 0x00, 0x12, 0x12, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+inline const uint8_t PANEL_420_REFRESH_9_21[] = {0x40, 0x08, 0x00, 0x00, 0x00, 0x02, 0x90, 0x28, 0x28, 0x00, 0x00, 0x01, 0x40, 0x14, 0x00, 0x00, 0x00, 0x01, 0xA0, 0x12, 0x12, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+inline const uint8_t PANEL_420_REFRESH_10_22[] = {0x40, 0x08, 0x00, 0x00, 0x00, 0x02, 0x90, 0x28, 0x28, 0x00, 0x00, 0x01, 0x40, 0x14, 0x00, 0x00, 0x00, 0x01, 0xA0, 0x12, 0x12, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+inline const uint8_t PANEL_420_REFRESH_11_23[] = {0x80, 0x08, 0x00, 0x00, 0x00, 0x02, 0x90, 0x28, 0x28, 0x00, 0x00, 0x01, 0x80, 0x14, 0x00, 0x00, 0x00, 0x01, 0x50, 0x12, 0x12, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+inline const uint8_t PANEL_420_REFRESH_12_24[] = {0x80, 0x08, 0x00, 0x00, 0x00, 0x02, 0x90, 0x28, 0x28, 0x00, 0x00, 0x01, 0x80, 0x14, 0x00, 0x00, 0x00, 0x01, 0x50, 0x12, 0x12, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+
+inline const EpdCommand PANEL_420_REFRESH[] = {
+    {0x92, nullptr, 0, /*delay_before_ms=*/0, /*delay_after_ms=*/1, /*wait_busy_after=*/false},
+    {0x01, PANEL_420_REFRESH_1_01, 4},
+    {0x06, PANEL_420_REFRESH_2_06, 3},
+    {0x00, PANEL_420_REFRESH_3_00, 1},
+    {0x30, PANEL_420_REFRESH_4_30, 1},
+    {0x61, PANEL_420_REFRESH_5_61, 4},
+    {0x82, PANEL_420_REFRESH_6_82, 1},
+    {0x50, PANEL_420_REFRESH_7_50, 1},
+    {0x20, PANEL_420_REFRESH_8_20, 44},
+    {0x21, PANEL_420_REFRESH_9_21, 42},
+    {0x22, PANEL_420_REFRESH_10_22, 42},
+    {0x23, PANEL_420_REFRESH_11_23, 42},
+    {0x24, PANEL_420_REFRESH_12_24, 42},
+    {0x12, nullptr, 0, /*delay_before_ms=*/0, /*delay_after_ms=*/0, /*wait_busy_after=*/true},
+};
+
+inline const EpdPanelDescriptor PANEL_420 = {
+    .width = 400,
+    .height = 300,
+    .busy_active_level = false,
+    .busy_timeout_ms = 15000,  // generous diagnostic ceiling (vendor full_refresh_time 1600ms, 1bpp mono/3-colour class); dev-tunable
+    .init_sequence = PANEL_420_INIT,
+    .init_sequence_len = sizeof(PANEL_420_INIT) / sizeof(PANEL_420_INIT[0]),
+    .window_sequence = {PANEL_420_WINDOW_SEQ0, nullptr},
+    .window_sequence_len = {sizeof(PANEL_420_WINDOW_SEQ0) / sizeof(PANEL_420_WINDOW_SEQ0[0]), 0},
+    .plane_count = 1,
+    .plane_write_cmd = {0x13, 0x00},
+    .plane_invert = {false, false},  // probe-derived
+    .bits_per_pixel = 1,  // probe: white fill => all-bits wire byte 0xFF
+    .palette = nullptr,
+    .palette_len = 0,
+    .refresh_sequence = PANEL_420_REFRESH,
+    .refresh_sequence_len = sizeof(PANEL_420_REFRESH) / sizeof(PANEL_420_REFRESH[0]),
+};
+
+}  // namespace esphome::gxepd2

+ 99 - 0
components/gxepd2/panel_583.h

@@ -0,0 +1,99 @@
+// GENERATED by emit_descriptor from GxEPD2_583 - candidate.
+// Command sequences, plane layout and plane_invert are observed here;
+// for a packed panel bits_per_pixel/palette/blank_code are probe-filled
+// when the colour family is recognised (4-colour/7-colour), else left TODO. A
+// dual-region panel also gets plane_width and a candidate line_order; the
+// exact PAIRED_EDGES row order still wants diag/driver confirmation.
+#pragma once
+#include "epd_command.h"
+
+namespace esphome::gxepd2 {
+
+// 2-code packed mono (see .bits_per_pixel below): derived purely by observing
+// raw white/black fills through the plane-write path, no colour wrapper to
+// drive (see make_resolver_bw_packed in emit_descriptor.h).
+inline const EpdPaletteEntry PANEL_583_PALETTE[] = {
+    {0x00, 0x00, 0x00, 0x00},  // black
+    {0xFF, 0xFF, 0xFF, 0x03},  // white
+};
+inline const uint8_t PANEL_583_PALETTE_LEN = sizeof(PANEL_583_PALETTE) / sizeof(PANEL_583_PALETTE[0]);
+
+inline const uint8_t PANEL_583_INIT_0_01[] = {0x37, 0x00};
+inline const uint8_t PANEL_583_INIT_1_00[] = {0xCF, 0x08};
+inline const uint8_t PANEL_583_INIT_2_06[] = {0xC7, 0xCC, 0x28};
+inline const uint8_t PANEL_583_INIT_3_30[] = {0x3A};
+inline const uint8_t PANEL_583_INIT_4_41[] = {0x00};
+inline const uint8_t PANEL_583_INIT_5_50[] = {0x77};
+inline const uint8_t PANEL_583_INIT_6_60[] = {0x22};
+inline const uint8_t PANEL_583_INIT_7_61[] = {0x02, 0x58, 0x01, 0xC0};
+inline const uint8_t PANEL_583_INIT_8_82[] = {0x28};
+inline const uint8_t PANEL_583_INIT_9_E5[] = {0x03};
+
+inline const EpdCommand PANEL_583_INIT[] = {
+    {0x01, PANEL_583_INIT_0_01, 2},
+    {0x00, PANEL_583_INIT_1_00, 2},
+    {0x06, PANEL_583_INIT_2_06, 3},
+    {0x30, PANEL_583_INIT_3_30, 1},
+    {0x41, PANEL_583_INIT_4_41, 1},
+    {0x50, PANEL_583_INIT_5_50, 1},
+    {0x60, PANEL_583_INIT_6_60, 1},
+    {0x61, PANEL_583_INIT_7_61, 4},
+    {0x82, PANEL_583_INIT_8_82, 1},
+    {0xE5, PANEL_583_INIT_9_E5, 1},
+    {0x04, nullptr, 0, /*delay_before_ms=*/0, /*delay_after_ms=*/0, /*wait_busy_after=*/true},
+};
+
+inline const uint8_t PANEL_583_WINDOW_SEQ0_1_90[] = {0x00, 0x00, 0x02, 0x57, 0x00, 0x00, 0x01, 0xBF, 0x01};
+
+inline const EpdCommand PANEL_583_WINDOW_SEQ0[] = {
+    {0x91, nullptr, 0},
+    {0x90, PANEL_583_WINDOW_SEQ0_1_90, 9},
+};
+
+inline const uint8_t PANEL_583_REFRESH_1_01[] = {0x37, 0x00};
+inline const uint8_t PANEL_583_REFRESH_2_00[] = {0xCF, 0x08};
+inline const uint8_t PANEL_583_REFRESH_3_06[] = {0xC7, 0xCC, 0x28};
+inline const uint8_t PANEL_583_REFRESH_4_30[] = {0x3A};
+inline const uint8_t PANEL_583_REFRESH_5_41[] = {0x00};
+inline const uint8_t PANEL_583_REFRESH_6_50[] = {0x77};
+inline const uint8_t PANEL_583_REFRESH_7_60[] = {0x22};
+inline const uint8_t PANEL_583_REFRESH_8_61[] = {0x02, 0x58, 0x01, 0xC0};
+inline const uint8_t PANEL_583_REFRESH_9_82[] = {0x28};
+inline const uint8_t PANEL_583_REFRESH_10_E5[] = {0x03};
+
+inline const EpdCommand PANEL_583_REFRESH[] = {
+    {0x92, nullptr, 0, /*delay_before_ms=*/0, /*delay_after_ms=*/1, /*wait_busy_after=*/false},
+    {0x01, PANEL_583_REFRESH_1_01, 2},
+    {0x00, PANEL_583_REFRESH_2_00, 2},
+    {0x06, PANEL_583_REFRESH_3_06, 3},
+    {0x30, PANEL_583_REFRESH_4_30, 1},
+    {0x41, PANEL_583_REFRESH_5_41, 1},
+    {0x50, PANEL_583_REFRESH_6_50, 1},
+    {0x60, PANEL_583_REFRESH_7_60, 1},
+    {0x61, PANEL_583_REFRESH_8_61, 4},
+    {0x82, PANEL_583_REFRESH_9_82, 1},
+    {0xE5, PANEL_583_REFRESH_10_E5, 1},
+    {0x12, nullptr, 0, /*delay_before_ms=*/0, /*delay_after_ms=*/0, /*wait_busy_after=*/true},
+};
+
+inline const EpdPanelDescriptor PANEL_583 = {
+    .width = 600,
+    .height = 448,
+    .busy_active_level = false,
+    .busy_timeout_ms = 60000,  // generous diagnostic ceiling (vendor full_refresh_time 15000ms, 4bpp 7-colour class); dev-tunable
+    .init_sequence = PANEL_583_INIT,
+    .init_sequence_len = sizeof(PANEL_583_INIT) / sizeof(PANEL_583_INIT[0]),
+    .window_sequence = {PANEL_583_WINDOW_SEQ0, nullptr},
+    .window_sequence_len = {sizeof(PANEL_583_WINDOW_SEQ0) / sizeof(PANEL_583_WINDOW_SEQ0[0]), 0},
+    .plane_count = 1,
+    .plane_write_cmd = {0x10, 0x00},
+    .plane_invert = {false, false},  // unused: packed panel
+    .bits_per_pixel = 4,  // probe: derived from raw 1bpp fills, no colour wrapper: black=0x00 white=0x03, 2 px/wire-byte
+    .palette = PANEL_583_PALETTE,
+    .palette_len = PANEL_583_PALETTE_LEN,
+    .blank_code = 0x03,  // observed white-fill code
+    .refresh_sequence = PANEL_583_REFRESH,
+    .refresh_sequence_len = sizeof(PANEL_583_REFRESH) / sizeof(PANEL_583_REFRESH[0]),
+};
+
+}  // namespace esphome::gxepd2

+ 65 - 0
tools/capture.py

@@ -232,6 +232,58 @@ def capture(model: str, catalog: dict, debug: bool, cxx: str, keep: bool) -> str
     return descriptor
 
 
+# mode -> (harness header to include, template to invoke). Both headers are
+# templates over Driver and take (name) - see run_harness.h / run_diag_harness.h.
+_PROGRAMS = {
+    "sweep": ("run_harness.h", "run_harness"),
+    "diag": ("run_diag_harness.h", "run_diag_harness"),
+}
+
+
+def run_program(model: str, catalog: dict, mode: str, cxx: str) -> str:
+    """Build+run run_harness.h ('sweep': full capture + the window-encoder
+    sweep) or run_diag_harness.h ('diag': Pattern A row/byte-order fill) for
+    `model`, with no per-panel run_<panel>.cpp to hand-write - same resolution
+    `capture()` uses. Previously every panel needed its own 4-line
+    run_<panel>[_diag].cpp; both headers are already templates over Driver, so
+    a single generic main works for any of them."""
+    src_rel, ctor_args, real_class = resolve_source(model, catalog)
+    if src_rel is None:
+        sys.exit(f"unknown model: {model} (no {model}.cpp under {VSRC})")
+    if ctor_args not in (None, 4):
+        sys.exit(f"{model}: non-standard constructor (ctor_args={ctor_args}); "
+                 "the harness only handles the 4-arg GxEPD2_EPD(cs,dc,rst,busy) form.")
+    src = VSRC / src_rel
+    hdr_rel = src_rel[:-4] + ".h"
+    if not (VSRC / hdr_rel).exists():
+        sys.exit(f"driver header not found: {VSRC / hdr_rel}")
+    header, fn = _PROGRAMS[mode]
+    main_src = (
+        f'#include "{header}"\n'
+        f'#include "{hdr_rel}"\n'
+        f'int main() {{ {fn}<{real_class}>("{real_class}"); return 0; }}\n'
+    )
+    with tempfile.TemporaryDirectory() as td:
+        tdp = pathlib.Path(td)
+        main_cpp = tdp / "main.cpp"
+        main_cpp.write_text(main_src)
+        binary = tdp / "main"
+        compile_cmd = [
+            cxx, "-std=c++17",
+            "-I", str(HARNESS / "spy"), "-I", str(VSRC), "-I", str(HARNESS),
+            str(main_cpp), str(VSRC / "GxEPD2_EPD.cpp"), str(src),
+            "-o", str(binary),
+        ]
+        print(f"[{mode}] {model}  <-  third_party/GxEPD2/src/{src_rel}", file=sys.stderr)
+        cc = subprocess.run(compile_cmd, capture_output=True, text=True)
+        if cc.returncode != 0:
+            sys.exit(f"compile failed:\n{cc.stderr}")
+        run = subprocess.run([str(binary)], capture_output=True, text=True)
+    if run.returncode != 0:
+        sys.exit(f"{mode} run failed (exit {run.returncode}):\n{run.stdout}\n{run.stderr}")
+    return run.stdout
+
+
 def main() -> None:
     ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
     ap.add_argument("model", nargs="?", help="GxEPD2 driver class, e.g. GxEPD2_154_Z90c")
@@ -239,6 +291,10 @@ def main() -> None:
     ap.add_argument("--list", action="store_true", help="list capturable driver classes and exit")
     ap.add_argument("--debug", action="store_true", help="dump the image-stage command/burst structure to stderr")
     ap.add_argument("--keep", action="store_true", help="keep the generated emit main under tools/gxepd2_harness/")
+    ap.add_argument("--sweep", action="store_true",
+                     help="run the window-encoder sweep (run_harness.h) instead of emitting a descriptor")
+    ap.add_argument("--diag", action="store_true",
+                     help="run the Pattern-A row/byte-order diagnostic fill (run_diag_harness.h) instead of emitting")
     ap.add_argument("--cxx", default="g++", help="C++ compiler (default: g++)")
     args = ap.parse_args()
 
@@ -248,6 +304,15 @@ def main() -> None:
         return
     if not args.model:
         ap.error("a model is required (or use --list)")
+    if args.sweep or args.diag:
+        mode = "sweep" if args.sweep else "diag"
+        out = run_program(args.model, catalog, mode, args.cxx)
+        if args.out:
+            pathlib.Path(args.out).write_text(out)
+            print(f"[{mode}] wrote {args.out}", file=sys.stderr)
+        else:
+            sys.stdout.write(out)
+        return
     descriptor = capture(args.model, catalog, args.debug, args.cxx, args.keep)
     if args.out:
         pathlib.Path(args.out).write_text(descriptor)

+ 33 - 5
tools/gxepd2_harness/README.md

@@ -44,7 +44,19 @@ way via `emit_palette_7c.h` / the driver's `GxEPD2_7C` wrapper, validated
 against `GXEPD2_PALETTE_7C` (black/white/green/blue/red/yellow/orange, no
 code collisions); a panel with a non-standard 7-colour map (e.g.
 GDEP073E01's rotated codes) stays an explicit TODO with the observed table
-rather than being mislabelled. A **dual-region** panel (per-region select +
+rather than being mislabelled. For a panel with **no colour wrapper at all**
+(still `GxEPD2_BW`/binary) that nonetheless packs >1 bit/pixel on the wire -
+e.g. GxEPD2_583/UC8159c's `_send8pixel`, which expands each 1bpp buffer byte
+into a wider wire format using only two of its codes - `emit_descriptor.h`'s
+`make_resolver_bw_packed` is the fallback resolver (used whenever no
+colour-wrapper resolver applies): it drives the plain driver's own
+`writeImage` with a uniform all-white/all-black 1bpp buffer directly (no
+wrapper to instantiate), and if both wire bursts are internally uniform, the
+byte-count ratio against the pixel count gives `bits_per_pixel` and each
+fill's byte gives that colour's code - no source reading needed, and no
+"two 1bpp planes" primitive required from the engine. A driver whose packing
+depends on more than one bit at a time (dithering, error diffusion) fails the
+uniformity check and stays an honest TODO. 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
@@ -80,12 +92,28 @@ synthetic `MASTER_ONLY` variant) are skipped. Run it after any
 
 ## Other programs
 
-- `run_<panel>.cpp` + `run_harness.h` — bare-driver capture plus the
-  window-encoder sweep (`window_sweep.h`), for deriving the coordinate →
-  window-byte mapping.
+- `tools/capture.py MODEL --sweep` / `--diag` — runs `run_harness.h`'s
+  window-encoder sweep, or `run_diag_harness.h`'s Pattern-A row/byte-order
+  fill, for any capturable model with no `run_<panel>.cpp` to hand-write
+  (both headers are already templates over `Driver`; `capture.py` builds a
+  one-off generic `main()` the same way it does for descriptor emission).
+  Older panels still have a committed `run_<panel>[_diag].cpp` from before
+  this existed - either form works, prefer `--sweep`/`--diag` for anything
+  new. Output is the full byte-by-byte trace (large for a big/packed panel -
+  redirect to a file and grep for `>>> SWEEP` / `[row-tag]` rather than
+  reading it raw).
 - `run_<panel>_probe.cpp` + `probe_plane_invert.h` — print the `plane_invert`
   probe result on its own.
 - `run_*diag*.cpp` — the diagnostic-fill passes that derive pixel packing,
-  palette codes, and row order for packed / multi-region panels.
+  palette codes, and row order for packed / multi-region panels. Pattern A
+  (`run_diag_harness.h`) only covers the 1bpp byte-preserving path (see
+  CONTRIBUTING.md); Pattern B (`run_diagB_harness.h`) drives a driver's own
+  `GxEPD2_4C`/`GxEPD2_7C` wrapper and its named colours, so it doesn't apply
+  to a still-monochrome (`GxEPD2_BW`-wrapped) driver that packs internally
+  (e.g. GxEPD2_583/UC8159c's `_send8pixel` - a 1bpp buffer expanded to a 2-code
+  wire format). That case is covered by `emit_descriptor.h`'s
+  `make_resolver_bw_packed` instead - folded into the one-command
+  `tools/capture.py` capture, same as 4-colour/7-colour - not a separate diag
+  program at all.
 - `spy/` — the host stand-ins for the Arduino/SPI APIs a GxEPD2 driver calls;
   `trace.h` records what the driver does.

+ 142 - 6
tools/gxepd2_harness/emit_descriptor.h

@@ -136,6 +136,14 @@ inline int burst_byte_after(const std::vector<Cmd> &cmds, uint8_t op) {
   return -1;
 }
 
+// The full data burst written right after command `op`, or empty.
+inline std::vector<uint8_t> burst_data_after(const std::vector<Cmd> &cmds, uint8_t op) {
+  for (const auto &c : cmds)
+    if (c.cmd == op && c.data.size() >= PLANE_BURST_MIN)
+      return c.data;
+  return {};
+}
+
 // Detects the two-arg writeScreenBuffer(black_value, color_value) that colour
 // (plane_count > 1) drivers have and mono drivers don't.
 template<typename T, typename = void> struct has_wsb2 : std::false_type {};
@@ -165,6 +173,14 @@ struct PackedResolved {
   int blank_code = 0;               // per-pixel clear code (observed white)
   int color_count = -1;             // actual distinct colours probed; -1 => assume 1<<bits_per_pixel
   std::string note;                 // appended as a comment (observed table, or why unresolved)
+  // Set by make_resolver_bw_packed (below): a still-GxEPD2_BW-wrapped (binary)
+  // driver that nonetheless packs >1 bit/pixel on the wire internally, with no
+  // colour wrapper to drive a la emit_palette_4c.h/7c.h - e.g. GxEPD2_583's
+  // _send8pixel. Only two codes are ever observed (black_code, blank_code=
+  // white code), so the emitter prints a local 2-entry palette array instead
+  // of referencing a shared GXEPD2_PALETTE_* constant.
+  bool two_code_bw = false;
+  int black_code = -1;
 };
 using PackedResolverFn = std::function<PackedResolved(uint8_t clear_fill, const PlaneInvert &pv)>;
 
@@ -196,14 +212,30 @@ template<typename Driver> PlaneInvert probe_plane_invert_values(uint8_t p0cmd, u
   d.selectSPI(SPI, SPISettings());
   d.init(0);
 
+  // plane_bytes sized for the 1bpp hypothesis - same assumption
+  // emit_descriptor_impl's own main capture makes (see its `black`/`color`
+  // buffers). A genuinely packed driver still re-packs this buffer through
+  // its real plane-write path (e.g. GxEPD2_583's _send8pixel), so the wire
+  // byte it reports is the true packed code, not a false 1bpp reading.
+  const uint32_t plane_bytes = (static_cast<uint32_t>(Driver::WIDTH) * Driver::HEIGHT) / 8;
+  auto *fillbuf = new uint8_t[plane_bytes];
+
   PlaneInvert r;
+  // Drive the SAME call path (writeImage) that produced p0cmd/p1cmd, not
+  // writeScreenBuffer: some drivers (e.g. GxEPD2_270, "current" buffer 0x13
+  // vs image-plane 0x15) use a different command for the screen-buffer clear
+  // than for writeImage's own plane write. Probing via writeScreenBuffer then
+  // searches the trace for a command (p0cmd) that call never emits, silently
+  // returning "not found" (-1) and misreporting a true 1bpp panel as packed.
   auto cap0 = [&](uint8_t value) -> int {
+    memset(fillbuf, value, plane_bytes);
     Trace::start_event_record();
-    d.writeScreenBuffer(value);  // 1-arg form all drivers have; colour defaults blank
+    d.writeImage(fillbuf, fillbuf, 0, 0, Driver::WIDTH, Driver::HEIGHT);
     Trace::stop_event_record();
     return burst_byte_after(group(0, Trace::events.size()), p0cmd);
   };
   r.w0_white = cap0(0xFF);
+  delete[] fillbuf;
   r.is_1bpp = (r.w0_white == 0x00 || r.w0_white == 0xFF);
   r.inv0 = (r.w0_white == 0x00);  // engine plane0 bit=white; white wire 0x00 => invert
 
@@ -220,6 +252,89 @@ template<typename Driver> PlaneInvert probe_plane_invert_values(uint8_t p0cmd, u
   return r;
 }
 
+// Generic packed resolver for a driver with NO colour wrapper to drive (unlike
+// emit_palette_4c.h/7c.h, which observe a GxEPD2_4C/7C wrapper's named-colour
+// fills) - the gap GxEPD2_583 (UC8159c) exposed: it's wrapped GxEPD2_BW
+// (binary), but `_send8pixel` expands each 1bpp buffer byte into a wider wire
+// format using only two of its codes. Drives the plain Driver's own
+// writeImage(black,color,...) - the SAME call the main capture already makes
+// - with a uniform all-white (0xFF) and all-black (0x00) 1bpp buffer. If both
+// wire bursts are internally uniform (one repeated byte - true for a per-bit
+// code lookup with no cross-pixel dependency) and the byte count divides the
+// pixel count evenly, that ratio directly gives bits_per_pixel; the MSB field
+// of each uniform byte gives that colour's code. A driver whose packing
+// depends on more than one bit at a time (dithering, error diffusion) fails
+// the uniformity check and stays an honest TODO rather than a wrong guess.
+template<typename Driver> PackedResolverFn make_resolver_bw_packed(uint8_t p0cmd) {
+  return [p0cmd](uint8_t /*clear_fill*/, const PlaneInvert & /*pv*/) -> PackedResolved {
+    PackedResolved r;
+    auto fail = [&](const char *why) {
+      r.resolved = false;
+      r.note = why;
+      return r;
+    };
+
+    constexpr int16_t CS = 10, DC = 11, RST = 12, BUSY = 13;
+    Trace::set_dc_pin(DC);
+    Peek<Driver> d(CS, DC, RST, BUSY);
+    Trace::last_busy_release_value = d.busy_level() == HIGH ? LOW : HIGH;
+    d.selectSPI(SPI, SPISettings());
+    d.init(0);
+
+    const uint16_t W = Driver::WIDTH, H = Driver::HEIGHT;
+    const uint32_t plane_bytes = (uint32_t) W * H / 8;  // 1bpp-sized source buffer
+    auto *buf = new uint8_t[plane_bytes];
+
+    auto burst_for = [&](uint8_t value) -> std::vector<uint8_t> {
+      memset(buf, value, plane_bytes);
+      Trace::start_event_record();
+      d.writeImage(buf, buf, 0, 0, W, H);
+      Trace::stop_event_record();
+      return burst_data_after(group(0, Trace::events.size()), p0cmd);
+    };
+    const std::vector<uint8_t> white = burst_for(0xFF);  // engine convention: bit=1 => white
+    const std::vector<uint8_t> black = burst_for(0x00);
+    delete[] buf;
+
+    if (white.empty() || black.empty())
+      return fail("bw-packed probe: no plane-write burst found");
+    if (white.size() != black.size())
+      return fail("bw-packed probe: white/black burst lengths differ");
+    const uint32_t total_pixels = (uint32_t) W * H;
+    if (white.size() == 0 || total_pixels % white.size() != 0)
+      return fail("bw-packed probe: pixel count not a multiple of the wire-byte count");
+    const int ppb = (int) (total_pixels / white.size());  // pixels per wire byte
+    if (ppb <= 0 || ppb > 8 || (8 % ppb) != 0)
+      return fail("bw-packed probe: implied pixels/byte doesn't divide 8 evenly");
+    const int bpp = 8 / ppb;
+    const uint8_t wbyte = white[0], bbyte = black[0];
+    for (uint8_t v : white)
+      if (v != wbyte)
+        return fail("bw-packed probe: white-fill wire byte not uniform (packing depends on more than one bit)");
+    for (uint8_t v : black)
+      if (v != bbyte)
+        return fail("bw-packed probe: black-fill wire byte not uniform (packing depends on more than one bit)");
+    if (wbyte == bbyte)
+      return fail("bw-packed probe: white and black fills produced the same wire byte");
+
+    const uint8_t mask = (uint8_t) ((1u << bpp) - 1);
+    const uint8_t white_code = (wbyte >> (bpp * (ppb - 1))) & mask;  // MSB field (every field equal, uniform byte)
+    const uint8_t black_code = (bbyte >> (bpp * (ppb - 1))) & mask;
+
+    r.resolved = true;
+    r.bits_per_pixel = bpp;
+    r.color_count = 2;
+    r.two_code_bw = true;
+    r.blank_code = white_code;
+    r.black_code = black_code;
+    char note[96];
+    snprintf(note, sizeof(note), "derived from raw 1bpp fills, no colour wrapper: black=0x%02X white=0x%02X, %d px/wire-byte",
+             black_code, white_code, ppb);
+    r.note = note;
+    return r;
+  };
+}
+
 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;
@@ -438,10 +553,15 @@ void emit_descriptor_impl(const char *class_name, const char *macro, const Packe
 
   // 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{};
+  // panels skip it. A specific colour-wrapper resolver (4-colour/7-colour,
+  // passed in by emit_descriptor_4c/_7c) takes priority; absent one (the
+  // default emit_descriptor<Driver> entry - a plain, possibly still-mono-
+  // wrapped driver), fall back to the generic no-wrapper probe, which catches
+  // a GxEPD2_BW driver that packs internally (see make_resolver_bw_packed).
+  // An unresolved packed panel (neither resolver recognised it) is treated as
+  // the slow class for the timeout floor.
+  const PackedResolverFn effective_resolver = resolve_packed ? resolve_packed : make_resolver_bw_packed<Driver>(p0cmd);
+  const PackedResolved pr = (!pv.is_1bpp && effective_resolver) ? effective_resolver(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),
@@ -483,6 +603,17 @@ void emit_descriptor_impl(const char *class_name, const char *macro, const Packe
   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");
 
+  if (pr.resolved && pr.two_code_bw) {
+    printf("// 2-code packed mono (see .bits_per_pixel below): derived purely by observing\n");
+    printf("// raw white/black fills through the plane-write path, no colour wrapper to\n");
+    printf("// drive (see make_resolver_bw_packed in emit_descriptor.h).\n");
+    printf("inline const EpdPaletteEntry %s_PALETTE[] = {\n", macro);
+    printf("    {0x00, 0x00, 0x00, 0x%02X},  // black\n", pr.black_code & 0xFF);
+    printf("    {0xFF, 0xFF, 0xFF, 0x%02X},  // white\n", pr.blank_code & 0xFF);
+    printf("};\n");
+    printf("inline const uint8_t %s_PALETTE_LEN = sizeof(%s_PALETTE) / sizeof(%s_PALETTE[0]);\n\n", macro, macro, macro);
+  }
+
   auto emit_seq = [&](const char *seqsuffix, std::vector<Cmd> &cmds) {
     for (size_t i = 0; i < cmds.size(); i++)
       if (!cmds[i].data.empty()) {
@@ -577,7 +708,12 @@ void emit_descriptor_impl(const char *class_name, const char *macro, const Packe
     // 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. a packed panel outside the 4-colour/7-colour resolvers).
-    if (pr.resolved) {
+    if (pr.resolved && pr.two_code_bw) {
+      printf("    .bits_per_pixel = %d,  // probe: %s\n", pr.bits_per_pixel, pr.note.c_str());
+      printf("    .palette = %s_PALETTE,\n", macro);
+      printf("    .palette_len = %s_PALETTE_LEN,\n", macro);
+      printf("    .blank_code = 0x%02X,  // observed white-fill code\n", pr.blank_code & 0xFF);
+    } else if (pr.resolved) {
       const int shown_colors = pr.color_count >= 0 ? pr.color_count : (1 << pr.bits_per_pixel);
       printf("    .bits_per_pixel = %d,  // probe: %d-colour packed%s%s\n", pr.bits_per_pixel,
              shown_colors, pr.note.empty() ? "" : " - ", pr.note.c_str());