From 1332358ee71560c4f7bc18e6b0c077b834bead3f Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 28 May 2026 01:19:22 +0200 Subject: [PATCH] fix(screenshot): use GxEPD2 visible dimensions; fix phys_x formula MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firmware: add screenshotWidth()/screenshotHeight() virtual pair to DisplayDriver (default: width()/height()). GxEPDDisplay overrides to return display.width()/display.height() — the GxEPD2-reported values that use WIDTH_VISIBLE (e.g. 122 for GxEPD2_213_B74), not the full physical WIDTH stored in DisplayDriver (128). MyMesh uses these instead of display->width()/height() for the screenshot header bytes. Result for GxEPD2_213_B74 + DISPLAY_ROTATION=1: header was 250×128 → now 250×122 (correct visible canvas) Python decoder (tools/screenshot.py): - Remove hardcoded EINK_PHYS_WIDTH=128 / EINK_VISIBLE_W=122 constants. - Derive phys_stride from buffer_size / log_width (works for any panel). - Fix phys_x formula: was (EINK_PHYS_WIDTH-1-ly = 127-ly), now (vis_w-1-ly = log_height-1-ly = 121-ly for 213_B74). Old formula addressed invisible columns 122-127; new formula correctly maps logical rows 0..121 to visible physical columns 121..0. - vis_w = log_height (no hardcoded trim; header now carries correct value). This also works for square panels (e.g. 128×128 where WIDTH=WIDTH_VISIBLE): header will carry 128×128, decoder produces a correct 128×128 image. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.cpp | 4 +-- src/helpers/ui/DisplayDriver.h | 6 ++++ src/helpers/ui/GxEPDDisplay.h | 4 +++ tools/screenshot.py | 47 +++++++++++++++++------------ 4 files changed, 39 insertions(+), 22 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 0a1e5c16..fbb7fb91 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2107,8 +2107,8 @@ void MyMesh::sendScreenshotResponse(DisplayDriver* display, const uint8_t* buffe for (int chunkIdx = 0; chunkIdx < totalChunks; chunkIdx++) { int i = 0; out_frame[i++] = RESP_CODE_SCREENSHOT; - out_frame[i++] = (uint8_t)display->width(); - out_frame[i++] = (uint8_t)display->height(); + out_frame[i++] = (uint8_t)display->screenshotWidth(); + out_frame[i++] = (uint8_t)display->screenshotHeight(); out_frame[i++] = (uint8_t)chunkIdx; out_frame[i++] = (uint8_t)totalChunks; out_frame[i++] = display->getDisplayType(); diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 25a6b871..8829643a 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -254,5 +254,11 @@ public: virtual const uint8_t* getBuffer() { return nullptr; } virtual uint16_t getBufferSize() { return 0; } virtual uint8_t getDisplayType() { return 0; } + // Visible pixel dimensions to embed in the screenshot header. + // Override in e-ink drivers to return the GxEPD2-reported dimensions (which use + // WIDTH_VISIBLE instead of the full physical WIDTH used by DisplayDriver). + // OLED drivers: width()/height() already reflect the visible canvas, so no override needed. + virtual int screenshotWidth() { return width(); } + virtual int screenshotHeight() { return height(); } #endif }; diff --git a/src/helpers/ui/GxEPDDisplay.h b/src/helpers/ui/GxEPDDisplay.h index 86439463..eb1c7d43 100644 --- a/src/helpers/ui/GxEPDDisplay.h +++ b/src/helpers/ui/GxEPDDisplay.h @@ -82,6 +82,10 @@ public: const uint8_t* getBuffer() override { return display.getBuffer(); } uint16_t getBufferSize() override { return display.getBufferSize(); } uint8_t getDisplayType() override { return 1; } // 1 = e-ink + // Return GxEPD2's own reported dimensions (uses WIDTH_VISIBLE, not the full physical WIDTH + // stored in DisplayDriver). After setRotation(1): width()=HEIGHT, height()=WIDTH_VISIBLE. + int screenshotWidth() override { return (int)display.width(); } + int screenshotHeight() override { return (int)display.height(); } #endif // Line height and approx. char width for each font size: diff --git a/tools/screenshot.py b/tools/screenshot.py index 3de1e811..51da14dc 100755 --- a/tools/screenshot.py +++ b/tools/screenshot.py @@ -31,9 +31,9 @@ RESP_CODE_ERR = 1 DISPLAY_TYPE_OLED = 0 # page-based, column-major (SH1106/SSD1306) DISPLAY_TYPE_EINK = 1 # row-major, MSB-first, 1=white/0=black (GxEPD2) -# E-ink panel constants for GxEPD2_213_B74 / GxEPD2_213_BN -EINK_PHYS_WIDTH = 128 # full hardware width including invisible columns -EINK_VISIBLE_W = 122 # WIDTH_VISIBLE — columns actually shown on panel +# No panel-specific constants needed — the firmware now sends GxEPD2's own reported +# dimensions (which use WIDTH_VISIBLE, not the full physical WIDTH), so the header +# already carries the correct visible height and width for any panel/rotation. def parse_args(): @@ -193,36 +193,43 @@ def oled_buffer_to_image(buffer, width, height): def eink_buffer_to_image(buffer, log_width, log_height): - """Decode GxEPD2 framebuffer for GxEPD2_213_B74 with DISPLAY_ROTATION=1 (landscape). + """Decode GxEPD2 framebuffer for any panel with DISPLAY_ROTATION=1 (landscape). - Physical panel: PHYS_WIDTH=128, HEIGHT=250. - Buffer layout: row-major, stride=PHYS_WIDTH/8=16 bytes, MSB-first. - byte index = phys_y * 16 + phys_x // 8 + The firmware now sends GxEPD2's own width()/height() in the header, which already + reflect WIDTH_VISIBLE (not the full physical WIDTH). So log_width = HEIGHT and + log_height = WIDTH_VISIBLE for this rotation. + + Buffer layout (always in physical panel coordinates, regardless of rotation): + row-major, stride = ceil(phys_width/8) bytes, MSB-first + byte index = phys_x // 8 + phys_y * stride bit = 7 - phys_x % 8 value 1 = white (paper), 0 = black (ink) - With setRotation(1), GxEPD2 maps logical (lx, ly) to physical: - phys_x = PHYS_WIDTH - 1 - ly (= 127 - ly) + With setRotation(1), GxEPD2 maps logical (lx, ly) → physical: + phys_x = WIDTH_VISIBLE - 1 - ly (= log_height - 1 - ly) phys_y = lx - Visible logical height = EINK_VISIBLE_W = 122 (PHYS_WIDTH_VISIBLE). - We trim to visible_h rows so the 6 invisible physical columns don't appear. + phys_stride is derived from buffer size so no panel-specific constants are needed: + phys_stride = buffer_size / phys_height = buffer_size / log_width """ - phys_stride = EINK_PHYS_WIDTH // 8 # 16 bytes per physical row - visible_h = min(log_height, EINK_VISIBLE_W) # trim to 122 + if log_width == 0 or log_height == 0: + return Image.new("RGB", (1, 1), (255, 255, 255)) - image = Image.new("RGB", (log_width, visible_h), (255, 255, 255)) + phys_stride = len(buffer) // log_width # bytes per physical row + vis_w = log_height # WIDTH_VISIBLE — from header after firmware fix + + image = Image.new("RGB", (log_width, vis_w), (255, 255, 255)) pixels = image.load() - for ly in range(visible_h): - phys_x = EINK_PHYS_WIDTH - 1 - ly # 127 - ly + for ly in range(vis_w): + phys_x = vis_w - 1 - ly # WIDTH_VISIBLE - 1 - ly for lx in range(log_width): - phys_y = lx - byte_idx = phys_y * phys_stride + phys_x // 8 - bit = 7 - phys_x % 8 + phys_y = lx + byte_idx = phys_x // 8 + phys_y * phys_stride + bit = 7 - phys_x % 8 if byte_idx < len(buffer): raw = (buffer[byte_idx] >> bit) & 1 - # 1=white (paper), 0=black (ink) + # 1 = white (paper), 0 = black (ink) color = (255, 255, 255) if raw else (0, 0, 0) pixels[lx, ly] = color