From d1a88c82774c2f20bb8af2d4b4e40a09e5cb6ba7 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 28 May 2026 08:10:16 +0200 Subject: [PATCH] fix(screenshot): send GxEPD2 rotation in header; full rot 0-3 decode in Python MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firmware (7-byte header): Byte 7 = display->screenshotRotation() — GxEPD2/GFX rotation value (0-3). DisplayDriver::screenshotRotation() defaults to 0 (OLED). GxEPDDisplay::screenshotRotation() returns display.getRotation(), the live GxEPD2 value (reflects runtime setDisplayRotation() calls, not just the compile-time DISPLAY_ROTATION macro). Python decoder: - Parse 7-byte header; pass rotation to eink_buffer_to_image(). - Implement all four GxEPD2 drawPixel coordinate transforms: rot 0: phys_x=lx, phys_y=ly rot 1: phys_x=vis_w-1-ly, phys_y=lx rot 2: phys_x=vis_w-1-lx, phys_y=vis_h-1-ly (180° flip) rot 3: phys_x=ly, phys_y=vis_h-1-lx - vis_w/vis_h derived from log dimensions + rotation parity (no constants). - Print rot= in the status line so the user sees which rotation was used. This fixes the 180° rotated image seen with rotation=2 (portrait inverted) without hardcoding a flip — correct for any rotation value. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.cpp | 7 ++- src/helpers/ui/DisplayDriver.h | 5 +- src/helpers/ui/GxEPDDisplay.h | 5 +- tools/screenshot.py | 72 ++++++++++++++++++----------- 4 files changed, 55 insertions(+), 34 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index fbb7fb91..4c983e27 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2098,9 +2098,11 @@ void MyMesh::handleScreenshotRequest() { } void MyMesh::sendScreenshotResponse(DisplayDriver* display, const uint8_t* buffer, uint16_t bufferSize) { - // Frame format: RESP_CODE_SCREENSHOT, width, height, chunk_idx, total_chunks, display_type, [data] + // Frame format: RESP_CODE_SCREENSHOT, width, height, chunk_idx, total_chunks, + // display_type, rotation, [data] // display_type: 0=OLED (page-based), 1=e-ink (row-major, MSB-first, 1=white/0=black) - const int HEADER_SIZE = 6; + // rotation: 0-3, Adafruit_GFX/GxEPD2 rotation value (only meaningful for e-ink) + const int HEADER_SIZE = 7; const int MAX_DATA_PER_FRAME = MAX_FRAME_SIZE - HEADER_SIZE; int totalChunks = (bufferSize + MAX_DATA_PER_FRAME - 1) / MAX_DATA_PER_FRAME; @@ -2112,6 +2114,7 @@ void MyMesh::sendScreenshotResponse(DisplayDriver* display, const uint8_t* buffe out_frame[i++] = (uint8_t)chunkIdx; out_frame[i++] = (uint8_t)totalChunks; out_frame[i++] = display->getDisplayType(); + out_frame[i++] = display->screenshotRotation(); int chunkSize = min(MAX_DATA_PER_FRAME, (int)bufferSize - chunkIdx * MAX_DATA_PER_FRAME); memcpy(&out_frame[i], buffer + chunkIdx * MAX_DATA_PER_FRAME, chunkSize); diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 8829643a..d83a6edd 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -258,7 +258,8 @@ public: // 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(); } + virtual int screenshotWidth() { return width(); } + virtual int screenshotHeight() { return height(); } + virtual uint8_t screenshotRotation() { return 0; } // 0-3, GxEPD2/GFX rotation value #endif }; diff --git a/src/helpers/ui/GxEPDDisplay.h b/src/helpers/ui/GxEPDDisplay.h index eb1c7d43..90d729be 100644 --- a/src/helpers/ui/GxEPDDisplay.h +++ b/src/helpers/ui/GxEPDDisplay.h @@ -84,8 +84,9 @@ public: 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(); } + int screenshotWidth() override { return (int)display.width(); } + int screenshotHeight() override { return (int)display.height(); } + uint8_t screenshotRotation() override { return (uint8_t)display.getRotation(); } #endif // Line height and approx. char width for each font size: diff --git a/tools/screenshot.py b/tools/screenshot.py index 7959280c..097cedf8 100755 --- a/tools/screenshot.py +++ b/tools/screenshot.py @@ -124,7 +124,7 @@ def receive_screenshot(ser, timeout=5): if frame is None: continue - if len(frame) < 6: + if len(frame) < 7: print(f"Error: Frame too short: {len(frame)} bytes") return None @@ -136,12 +136,14 @@ def receive_screenshot(ser, timeout=5): chunk_idx = frame[3] num_chunks = frame[4] disp_type = frame[5] - chunk_data = frame[6:] + rotation = frame[6] + chunk_data = frame[7:] if chunk_idx == 0: width = w height = h display_type = disp_type + disp_rotation = rotation total_chunks = num_chunks buffer_data = bytearray() received_chunks = 0 @@ -158,7 +160,7 @@ def receive_screenshot(ser, timeout=5): received_chunks += 1 if received_chunks >= total_chunks: - return bytes(buffer_data), width, height, display_type + return bytes(buffer_data), width, height, display_type, disp_rotation elif resp_code == RESP_CODE_ERR: print("Error: Device returned error") @@ -192,47 +194,60 @@ def oled_buffer_to_image(buffer, width, height): return image.convert("RGB") -def eink_buffer_to_image(buffer, log_width, log_height): - """Decode GxEPD2 framebuffer for any panel/rotation. +def eink_buffer_to_image(buffer, log_width, log_height, rotation): + """Decode GxEPD2 framebuffer for any panel and rotation (0-3). - The firmware sends GxEPD2's own width()/height() in the header (using WIDTH_VISIBLE, - not the full physical WIDTH). Rotation is inferred from the aspect ratio: - portrait (log_height > log_width) → rotation 0: direct mapping - landscape (log_width > log_height) → rotation 1: phys_x = WIDTH_VISIBLE-1-ly - square (log_width == log_height) → assumed rotation 1 + The firmware sends GxEPD2's own width()/height() in the header (uses WIDTH_VISIBLE, + not the full physical WIDTH) and the GxEPD2 rotation value. - Buffer layout (physical panel coordinates, rotation-independent): - row-major, stride bytes per row, MSB-first + Buffer layout (always in physical panel coordinates, rotation-independent): + row-major, stride = GxEPD2_Type::WIDTH / 8 bytes, MSB-first byte_idx = phys_x // 8 + phys_y * stride bit = 7 - phys_x % 8 1 = white (paper), 0 = black (ink) - Physical HEIGHT = max(log_width, log_height) for any rotation on a non-square panel. + Coordinate mapping from GxEPD2 drawPixel (WIDTH = WIDTH_VISIBLE from GFX init): + rot 0: phys_x = lx, phys_y = ly + rot 1: phys_x = WIDTH-1-ly, phys_y = lx (WIDTH = log_height) + rot 2: phys_x = WIDTH-1-lx, phys_y = HEIGHT-1-ly (WIDTH = log_width, HEIGHT = log_height) + rot 3: phys_x = ly, phys_y = HEIGHT-1-lx (HEIGHT = log_width) + + Physical HEIGHT (panel's longer dimension) = max(log_w, log_h) for any rotation. stride = buffer_size // HEIGHT (no panel-specific constants needed). """ if log_width == 0 or log_height == 0: return Image.new("RGB", (1, 1), (255, 255, 255)) - # Physical panel HEIGHT is always the longer dimension. + # Physical HEIGHT is always the longer dimension (panel HEIGHT regardless of rotation). phys_height = max(log_width, log_height) - phys_stride = len(buffer) // phys_height # bytes per physical row (e.g. 128/8 = 16) + phys_stride = len(buffer) // phys_height # bytes per physical row (e.g. 16 for 128-wide panel) + + # vis_w / vis_h are the GFX WIDTH / HEIGHT constants (from GxEPD2's Adafruit_GFX init). + # For odd rotations GFX swaps them, so we reverse-swap to get the physical sizes. + if rotation & 1: # odd: GFX was initialized (WIDTH_VIS, HEIGHT) but swapped + vis_w = log_height # = GFX WIDTH = WIDTH_VISIBLE (e.g. 122) + vis_h = log_width # = GFX HEIGHT = physical HEIGHT (e.g. 250) + else: # even: no swap + vis_w = log_width # = GFX WIDTH = WIDTH_VISIBLE + vis_h = log_height # = GFX HEIGHT = physical HEIGHT image = Image.new("RGB", (log_width, log_height), (255, 255, 255)) pixels = image.load() - portrait = log_height > log_width # rotation=0 vs rotation=1 - for ly in range(log_height): for lx in range(log_width): - if portrait: - # rotation=0: logical (lx,ly) maps directly to physical (lx,ly) + if rotation == 0: phys_x = lx phys_y = ly - else: - # rotation=1: logical (lx,ly) → physical (WIDTH_VISIBLE-1-ly, lx) - # WIDTH_VISIBLE = log_height (from screenshotHeight() in firmware) - phys_x = log_height - 1 - ly + elif rotation == 1: + phys_x = vis_w - 1 - ly # = log_height - 1 - ly phys_y = lx + elif rotation == 2: + phys_x = vis_w - 1 - lx # = log_width - 1 - lx + phys_y = vis_h - 1 - ly # = log_height - 1 - ly + else: # rotation == 3 + phys_x = ly + phys_y = vis_h - 1 - lx # = log_width - 1 - lx byte_idx = phys_x // 8 + phys_y * phys_stride bit = 7 - phys_x % 8 @@ -244,10 +259,10 @@ def eink_buffer_to_image(buffer, log_width, log_height): return image -def buffer_to_image(buffer, width, height, display_type, scale=1): +def buffer_to_image(buffer, width, height, display_type, rotation=0, scale=1): """Convert framebuffer to PIL Image with a 3-pixel black frame.""" if display_type == DISPLAY_TYPE_EINK: - image_rgb = eink_buffer_to_image(buffer, width, height) + image_rgb = eink_buffer_to_image(buffer, width, height, rotation) else: image_rgb = oled_buffer_to_image(buffer, width, height) @@ -322,15 +337,16 @@ def main(): result = receive_screenshot(ser) if result: - buffer_data, width, height, display_type = result + buffer_data, width, height, display_type, disp_rotation = result type_str = "e-ink" if display_type == DISPLAY_TYPE_EINK else "OLED" print( - f"Received framebuffer: {width}x{height} {type_str}, {len(buffer_data)} bytes" + f"Received framebuffer: {width}x{height} {type_str} rot={disp_rotation}, {len(buffer_data)} bytes" ) try: image = buffer_to_image( - buffer_data, width, height, display_type, scale=args.scale + buffer_data, width, height, display_type, + rotation=disp_rotation, scale=args.scale ) filename = generate_filename() image.save(filename)