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

test pattern: bit-depth aware, dithered ramp, on-screen panel name

Move the bench test pattern out of the display lambda and into
GxEPD2Display::test_pattern(), where it can read the panel descriptor
directly instead of hardcoding 4 colors:

- Color count, ladder color cycle, and corner markers now scale to the
  colors the panel can actually show (test_colors_): 2 for mono, 3 for
  BW+accent, 4 for packed 4C, 7 for ACeP. The drawn pattern doubles as a
  statement of what the driver expects of the panel.
- Added a dithered grey ramp (ordered 4x4 Bayer) so a 1bpp panel shows a
  graded sweep instead of two flat blocks - the mono panels' color bars
  collapse to black|white otherwise.
- Optional name_font labels the panel with its model trailer (e.g.
  GDEQ0583T31), skipped when the panel is too short to spare the room, so a
  tested panel says what it is on the shelf. gfonts:// supplies the font.

The canonical test fixture now just calls id(gxepd2_test).test_pattern().
ZiB 3 недель назад
Родитель
Сommit
b9452ae2d7

+ 14 - 1
components/gxepd2/display.py

@@ -10,7 +10,7 @@ tree as reference material for panels not yet ported to this engine.
 import esphome.codegen as cg
 import esphome.config_validation as cv
 from esphome import pins
-from esphome.components import display, spi
+from esphome.components import display, font, spi
 from esphome.const import (
     CONF_BUSY_PIN,
     CONF_CS_PIN,
@@ -40,6 +40,11 @@ DEPENDENCIES = ["spi"]
 # No effect on 2bpp/4bpp packed panels - there the palette maps literal RGB.
 CONF_INVERT_MONO = "invert_mono"
 
+# Optional font for the on-screen panel label test_pattern() draws (the model
+# trailer, e.g. "GDEQ0583T31"). A normal ESPHome font:, so the builder can
+# pull one from gfonts:// - no font, no label. See GxEPD2Display::test_pattern.
+CONF_NAME_FONT = "name_font"
+
 gxepd2_ns = cg.esphome_ns.namespace("gxepd2")
 GxEPD2Display = gxepd2_ns.class_("GxEPD2Display", display.DisplayBuffer)
 
@@ -72,6 +77,7 @@ CONFIG_SCHEMA = cv.All(
             # to_code() (just False today; true for a waveshare alias once that
             # lands), while an explicit value always wins.
             cv.Optional(CONF_INVERT_MONO): cv.boolean,
+            cv.Optional(CONF_NAME_FONT): cv.use_id(font.Font),
         }
     ),
     # IDF-first, but the runtime C++ uses only framework-portable ESPHome
@@ -102,6 +108,13 @@ async def to_code(config: ConfigType) -> None:
     # will compute it from the (aliased) model. An explicit invert_mono wins.
     cg.add(var.set_invert_mono(config.get(CONF_INVERT_MONO, False)))
 
+    # Panel label for test_pattern(): the model trailer only (everything
+    # after the GxEPD2_<size> prefix, e.g. GxEPD2_583_GDEQ0583T31 ->
+    # GDEQ0583T31), so a tested panel shows what it is, not the C++ class id.
+    cg.add(var.set_model_name(config[CONF_MODEL].split("_", 2)[-1]))
+    if name_font := config.get(CONF_NAME_FONT):
+        cg.add(var.set_name_font(await cg.get_variable(name_font)))
+
     # display.register_display() only wires up `pages:` - a bare top-level
     # `lambda:` (what our CONFIG_SCHEMA actually supports) has to be
     # processed and attached via set_writer() by the platform itself, same

+ 149 - 0
components/gxepd2/gxepd2_display.h

@@ -30,6 +30,124 @@ class GxEPD2Display : public display::DisplayBuffer {
   void set_spi_parent(spi::SPIComponent *parent) { this->spi_.set_spi_parent(parent); }
   void set_descriptor(const EpdPanelDescriptor *desc) { this->desc_ = desc; }
   void set_invert_mono(bool invert) { this->invert_mono_ = invert; }
+  // Model trailer (e.g. "GDEQ0583T31", not the full "GxEPD2_583_..." id) and
+  // an optional font, both for test_pattern()'s on-screen panel label. Set
+  // from display.py: the name is derived from model:, the font is a normal
+  // ESPHome font: the builder can pull from gfonts://. See test_pattern().
+  void set_model_name(const char *name) { this->model_name_ = name; }
+  void set_name_font(display::BaseFont *font) { this->name_font_ = font; }
+
+  // Bench visual-check pattern, bit-depth aware. Everything scales to what
+  // THIS panel can actually show (see test_colors_): the color-bar count,
+  // the ladder color cycle and the corner markers all follow the panel's
+  // real color set instead of a hardcoded 4, so the drawn pattern doubles as
+  // a statement of what the driver expects of the panel. Adds a dithered
+  // grey ramp (so a 1bpp panel shows a graded sweep, not just two flat
+  // blocks) and, when a name font is set and the panel can spare the room, a
+  // model-trailer label so a tested panel says what it is on the shelf.
+  // Call it from the display lambda: `id(<display_id>).test_pattern();`.
+  void test_pattern() {
+    const int w = this->get_width();
+    const int h = this->get_height();
+    Color colors[8];
+    const int n = this->test_colors_(colors, 8);
+
+    this->fill(Color(0xFF, 0xFF, 0xFF));
+
+    const int smin = w < h ? w : h;
+    const int border = smin / 100 > 1 ? smin / 100 : 1;
+    const int x0 = border;
+    int y0 = border;
+    const int iw = w - 2 * border;
+    int ih = h - 2 * border;
+
+    // Name band, above every test zone. Drawn only when a font is set and
+    // the panel is tall enough to give it a fifth of the interior without
+    // crowding the zones below - the "skip the name on panels too short to
+    // spare ~10px above the bar" rule, sized to the actual font.
+    if (this->name_font_ != nullptr && this->model_name_ != nullptr && this->model_name_[0] != '\0') {
+      int tw, xoff, baseline, th;
+      this->name_font_->measure(this->model_name_, &tw, &xoff, &baseline, &th);
+      const int band = th + 4;
+      if (band <= ih / 5) {
+        this->print(w / 2, y0 + band / 2, this->name_font_, Color(0x00, 0x00, 0x00),
+                    display::TextAlign::CENTER, this->model_name_);
+        y0 += band;
+        ih -= band;
+      }
+    }
+
+    // Vertical budget for the four zones below any name band.
+    const int bars_h = ih * 34 / 100;
+    int dither_h = ih * 12 / 100;
+    if (dither_h < 8) dither_h = 8;
+    const int row_h = ih * 18 / 100 > 2 ? ih * 18 / 100 : 2;
+    const int dy = y0 + bars_h;
+    const int ry = dy + dither_h;
+    const int col_y = ry + row_h;
+    const int col_h = (y0 + ih) - col_y;
+
+    // Color bars: one solid block per available color, in palette/wire-code
+    // order (black, white, then the accents). A wrong or missing block
+    // points straight at the color - or the wire code - it stands for.
+    const int bar_w = iw / n;
+    for (int i = 0; i < n; i++) {
+      const int bx = x0 + i * bar_w;
+      const int bw = (i == n - 1) ? (x0 + iw - bx) : bar_w;
+      this->filled_rectangle(bx, y0, bw, bars_h, colors[i]);
+    }
+
+    // Dithered grey ramp: brightness 255 (left) -> 0 (right) via an ordered
+    // 4x4 Bayer threshold. On a 1bpp panel this is the only zone that shows
+    // a graded sweep; on deeper panels it's a quick mono/uniformity check.
+    static const uint8_t BAYER4[4][4] = {
+        {0, 8, 2, 10}, {12, 4, 14, 6}, {3, 11, 1, 9}, {15, 7, 13, 5}};
+    for (int yy = 0; yy < dither_h; yy++) {
+      for (int xx = 0; xx < iw; xx++) {
+        const int bright = (iw <= 1) ? 255 : 255 - xx * 255 / (iw - 1);
+        const int thr = (BAYER4[yy & 3][xx & 3] * 2 + 1) * 255 / 32;
+        if (bright <= thr)
+          this->draw_pixel_at(x0 + xx, dy + yy, Color(0x00, 0x00, 0x00));
+      }
+    }
+
+    // Row ladder: 1px rows cycling the full color set - a dropped, doubled
+    // or reordered row breaks the N-row cadence.
+    for (int yy = 0; yy < row_h; yy++)
+      this->horizontal_line(x0, ry + yy, iw, colors[yy % n]);
+
+    // Column ladder: the same rotated 90 degrees, catching nibble/column
+    // order bugs the row ladder can't (packed formats put several pixels per
+    // byte, so a column-order fault can leave every row looking fine).
+    for (int xx = 0; xx < iw; xx++)
+      this->vertical_line(x0 + xx, col_y, col_h, colors[xx % n]);
+
+    // Corner markers for orientation, using the panel's ink colors (every
+    // color but white). BL is always black; TL/TR take the next inks when
+    // the panel has them; BR is left blank as the distinct "no marker"
+    // state. A 90/180 rotation or mirror can't survive this even though the
+    // bars and ladders are otherwise symmetric.
+    Color ink[8];
+    int ni = 0;
+    for (int i = 0; i < n; i++) {
+      if (colors[i].red == 0xFF && colors[i].green == 0xFF && colors[i].blue == 0xFF)
+        continue;  // skip white - a white marker is invisible
+      ink[ni++] = colors[i];
+    }
+    const int mk = smin / 12 > 3 ? smin / 12 : 3;
+    if (ni > 0) this->filled_rectangle(x0, y0 + ih - mk, mk, mk, ink[0]);        // BL
+    if (ni > 1) this->filled_rectangle(x0, y0, mk, mk, ink[1]);                  // TL
+    if (ni > 2) this->filled_rectangle(x0 + iw - mk, y0, mk, mk, ink[2]);        // TR
+
+    // Corner-to-corner diagonals over everything: the cheapest coordinate-
+    // transform check - a clean line means no row/column reordering.
+    this->line(0, 0, w - 1, h - 1, Color(0x00, 0x00, 0x00));
+    this->line(w - 1, 0, 0, h - 1, Color(0x00, 0x00, 0x00));
+
+    // Border frame last, so it stays an unbroken rectangle on all four edges
+    // even where a zone fills to the edge - catches a clipped/offset origin.
+    this->rectangle(0, 0, w, h, Color(0x00, 0x00, 0x00));
+  }
 
   void setup() override {
     const auto *desc = this->desc_;
@@ -200,7 +318,38 @@ class GxEPD2Display : public display::DisplayBuffer {
   int get_width_internal() override { return this->desc_->width; }
   int get_height_internal() override { return this->desc_->height; }
 
+  // The colors this panel can actually show, each as a representative RGB, in
+  // a stable order (black, white, then accents). Packed panels (>1bpp): one
+  // entry per distinct palette wire code, first RGB seen for each. 1bpp:
+  // black + white, plus red when a second plane exists for the accent (only
+  // red-classified pixels reach it - see draw_absolute_pixel_internal). This
+  // is what makes test_pattern() bit-depth aware. Returns the count.
+  int test_colors_(Color *out, int max) {
+    int n = 0;
+    if (this->desc_->bits_per_pixel > 1 && this->desc_->palette != nullptr) {
+      uint8_t seen[16];
+      int seen_n = 0;
+      for (int i = 0; i < this->desc_->palette_len && n < max; i++) {
+        const uint8_t code = this->desc_->palette[i].code;
+        bool dup = false;
+        for (int j = 0; j < seen_n; j++)
+          if (seen[j] == code) { dup = true; break; }
+        if (dup) continue;
+        if (seen_n < (int) sizeof(seen)) seen[seen_n++] = code;
+        const auto &e = this->desc_->palette[i];
+        out[n++] = Color(e.r, e.g, e.b);
+      }
+    } else {
+      if (n < max) out[n++] = Color(0x00, 0x00, 0x00);  // black
+      if (n < max) out[n++] = Color(0xFF, 0xFF, 0xFF);  // white
+      if (this->desc_->plane_count >= 2 && n < max) out[n++] = Color(0xFF, 0x00, 0x00);  // accent
+    }
+    return n;
+  }
+
   const EpdPanelDescriptor *desc_{nullptr};
+  const char *model_name_{nullptr};
+  display::BaseFont *name_font_{nullptr};
   GPIOPin *cs_{nullptr};
   GPIOPin *dc_{nullptr};
   GPIOPin *rst_{nullptr};

+ 14 - 54
tests/test_pattern_bar_ladder_diagonal.esp32-idf.yaml

@@ -26,66 +26,26 @@ spi:
   clk_pin: 18
   mosi_pin: 23
 logger:
+# gfonts:// is fetched by the builder at compile time - no local font file to
+# vendor. Used only for test_pattern()'s model-trailer label.
+font:
+  - file: "gfonts://Roboto"
+    id: panel_label_font
+    size: 24
 display:
   - platform: gxepd2
+    id: gxepd2_test
     model: GxEPD2_579c_GDEY0579F51
     cs_pin: 5
     dc_pin: 17
     reset_pin: 16
     busy_pin: 4
+    name_font: panel_label_font
     update_interval: 60s
+    # The whole pattern now lives in GxEPD2Display::test_pattern() (C++), so
+    # it can read this panel's descriptor directly and scale the bar count,
+    # ladder cycle and corner markers to the colors it can actually show -
+    # the lambda just invokes it. See gxepd2_display.h for the zone rationale
+    # that used to live in this block.
     lambda: |-
-      const int w = it.get_width();
-      const int h = it.get_height();
-      const int n = 4;
-      // Wire-code order (see gxepd2_display.h's packed-2bpp classification):
-      // black=0x00, white=0x01, yellow=0x02, red=0x03 - matched here so a
-      // wrong bar points straight at the wire code it corresponds to.
-      Color colors[n] = {Color::BLACK, Color::WHITE, Color(0xFF, 0xFF, 0x00), Color(0xFF, 0x00, 0x00)};
-
-      it.fill(Color::WHITE);
-
-      // Row ladder: 1px-tall rows cycling the full palette, full width.
-      // The row-interleave detector - a dropped/duplicated/reordered row
-      // breaks the regular N-row cadence.
-      const int ladder_h = h / 3;
-      for (int y = 0; y < ladder_h; y++)
-        it.horizontal_line(0, y, w, colors[y % n]);
-
-      // Column ladder: same idea rotated 90 degrees, directly below.
-      // Catches nibble-order/byte-stride bugs the row ladder can't (packed
-      // 2bpp stores 4 pixels/byte, so a column-order bug can leave every
-      // row looking individually correct).
-      for (int x = 0; x < w; x++)
-        it.vertical_line(x, ladder_h, ladder_h, colors[x % n]);
-
-      // Color bars: one solid block per available color, in wire-code
-      // order (not perceptual brightness order).
-      const int bars_y = ladder_h * 2;
-      const int bars_h = h - bars_y;
-      const int bar_w = w / n;
-      for (int i = 0; i < n; i++) {
-        int bw = (i == n - 1) ? (w - i * bar_w) : bar_w;
-        it.filled_rectangle(i * bar_w, bars_y, bw, bars_h, colors[i]);
-      }
-
-      // Corner markers: three distinct corners, fourth left blank. The
-      // bars/ladders above are symmetric enough that a 90/180-degree
-      // rotation or mirror could otherwise still "look right" at a glance.
-      const int mark = (w < h ? w : h) / 8;
-      it.filled_rectangle(0, 0, mark, mark, colors[2]);          // top-left: yellow
-      it.filled_rectangle(w - mark, 0, mark, mark, colors[3]);   // top-right: red
-      it.filled_rectangle(0, h - mark, mark, mark, colors[0]);   // bottom-left: black
-      // bottom-right intentionally left blank - the "no marker" 4th state.
-
-      // Corner-to-corner diagonal, drawn on top of everything: the
-      // cheapest possible check for a coordinate-transform bug. A real
-      // diagonal stays a clean line; any row/column reordering breaks it
-      // into visible steps exactly where the reordering happens.
-      it.line(0, 0, w - 1, h - 1, Color::BLACK);
-      it.line(w - 1, 0, 0, h - 1, Color::BLACK);
-
-      // Border frame, drawn last so it stays a clean unbroken rectangle
-      // touching all four edges even where bars/ladders fill to the edge.
-      // Catches a clipped or offset draw origin.
-      it.rectangle(0, 0, w, h, Color::BLACK);
+      id(gxepd2_test).test_pattern();