fix(screenshot): use GxEPD2 visible dimensions; fix phys_x formula

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 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-05-28 01:19:22 +02:00
parent 7d9d5f847d
commit 1332358ee7
4 changed files with 39 additions and 22 deletions

View File

@@ -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();

View File

@@ -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
};

View File

@@ -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:

View File

@@ -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,32 +193,39 @@ 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
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