fix(screenshot): send GxEPD2 rotation in header; full rot 0-3 decode in Python

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 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-05-28 08:10:16 +02:00
parent 37432491dd
commit d1a88c8277
4 changed files with 55 additions and 34 deletions

View File

@@ -2098,9 +2098,11 @@ void MyMesh::handleScreenshotRequest() {
} }
void MyMesh::sendScreenshotResponse(DisplayDriver* display, const uint8_t* buffer, uint16_t bufferSize) { 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) // 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; const int MAX_DATA_PER_FRAME = MAX_FRAME_SIZE - HEADER_SIZE;
int totalChunks = (bufferSize + MAX_DATA_PER_FRAME - 1) / MAX_DATA_PER_FRAME; 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)chunkIdx;
out_frame[i++] = (uint8_t)totalChunks; out_frame[i++] = (uint8_t)totalChunks;
out_frame[i++] = display->getDisplayType(); out_frame[i++] = display->getDisplayType();
out_frame[i++] = display->screenshotRotation();
int chunkSize = min(MAX_DATA_PER_FRAME, (int)bufferSize - chunkIdx * MAX_DATA_PER_FRAME); 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); memcpy(&out_frame[i], buffer + chunkIdx * MAX_DATA_PER_FRAME, chunkSize);

View File

@@ -258,7 +258,8 @@ public:
// Override in e-ink drivers to return the GxEPD2-reported dimensions (which use // Override in e-ink drivers to return the GxEPD2-reported dimensions (which use
// WIDTH_VISIBLE instead of the full physical WIDTH used by DisplayDriver). // WIDTH_VISIBLE instead of the full physical WIDTH used by DisplayDriver).
// OLED drivers: width()/height() already reflect the visible canvas, so no override needed. // OLED drivers: width()/height() already reflect the visible canvas, so no override needed.
virtual int screenshotWidth() { return width(); } virtual int screenshotWidth() { return width(); }
virtual int screenshotHeight() { return height(); } virtual int screenshotHeight() { return height(); }
virtual uint8_t screenshotRotation() { return 0; } // 0-3, GxEPD2/GFX rotation value
#endif #endif
}; };

View File

@@ -84,8 +84,9 @@ public:
uint8_t getDisplayType() override { return 1; } // 1 = e-ink uint8_t getDisplayType() override { return 1; } // 1 = e-ink
// Return GxEPD2's own reported dimensions (uses WIDTH_VISIBLE, not the full physical WIDTH // 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. // stored in DisplayDriver). After setRotation(1): width()=HEIGHT, height()=WIDTH_VISIBLE.
int screenshotWidth() override { return (int)display.width(); } int screenshotWidth() override { return (int)display.width(); }
int screenshotHeight() override { return (int)display.height(); } int screenshotHeight() override { return (int)display.height(); }
uint8_t screenshotRotation() override { return (uint8_t)display.getRotation(); }
#endif #endif
// Line height and approx. char width for each font size: // Line height and approx. char width for each font size:

View File

@@ -124,7 +124,7 @@ def receive_screenshot(ser, timeout=5):
if frame is None: if frame is None:
continue continue
if len(frame) < 6: if len(frame) < 7:
print(f"Error: Frame too short: {len(frame)} bytes") print(f"Error: Frame too short: {len(frame)} bytes")
return None return None
@@ -136,12 +136,14 @@ def receive_screenshot(ser, timeout=5):
chunk_idx = frame[3] chunk_idx = frame[3]
num_chunks = frame[4] num_chunks = frame[4]
disp_type = frame[5] disp_type = frame[5]
chunk_data = frame[6:] rotation = frame[6]
chunk_data = frame[7:]
if chunk_idx == 0: if chunk_idx == 0:
width = w width = w
height = h height = h
display_type = disp_type display_type = disp_type
disp_rotation = rotation
total_chunks = num_chunks total_chunks = num_chunks
buffer_data = bytearray() buffer_data = bytearray()
received_chunks = 0 received_chunks = 0
@@ -158,7 +160,7 @@ def receive_screenshot(ser, timeout=5):
received_chunks += 1 received_chunks += 1
if received_chunks >= total_chunks: 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: elif resp_code == RESP_CODE_ERR:
print("Error: Device returned error") print("Error: Device returned error")
@@ -192,47 +194,60 @@ def oled_buffer_to_image(buffer, width, height):
return image.convert("RGB") return image.convert("RGB")
def eink_buffer_to_image(buffer, log_width, log_height): def eink_buffer_to_image(buffer, log_width, log_height, rotation):
"""Decode GxEPD2 framebuffer for any panel/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, The firmware sends GxEPD2's own width()/height() in the header (uses WIDTH_VISIBLE,
not the full physical WIDTH). Rotation is inferred from the aspect ratio: not the full physical WIDTH) and the GxEPD2 rotation value.
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
Buffer layout (physical panel coordinates, rotation-independent): Buffer layout (always in physical panel coordinates, rotation-independent):
row-major, stride bytes per row, MSB-first row-major, stride = GxEPD2_Type::WIDTH / 8 bytes, MSB-first
byte_idx = phys_x // 8 + phys_y * stride byte_idx = phys_x // 8 + phys_y * stride
bit = 7 - phys_x % 8 bit = 7 - phys_x % 8
1 = white (paper), 0 = black (ink) 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). stride = buffer_size // HEIGHT (no panel-specific constants needed).
""" """
if log_width == 0 or log_height == 0: if log_width == 0 or log_height == 0:
return Image.new("RGB", (1, 1), (255, 255, 255)) 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_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)) image = Image.new("RGB", (log_width, log_height), (255, 255, 255))
pixels = image.load() pixels = image.load()
portrait = log_height > log_width # rotation=0 vs rotation=1
for ly in range(log_height): for ly in range(log_height):
for lx in range(log_width): for lx in range(log_width):
if portrait: if rotation == 0:
# rotation=0: logical (lx,ly) maps directly to physical (lx,ly)
phys_x = lx phys_x = lx
phys_y = ly phys_y = ly
else: elif rotation == 1:
# rotation=1: logical (lx,ly) → physical (WIDTH_VISIBLE-1-ly, lx) phys_x = vis_w - 1 - ly # = log_height - 1 - ly
# WIDTH_VISIBLE = log_height (from screenshotHeight() in firmware)
phys_x = log_height - 1 - ly
phys_y = lx 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 byte_idx = phys_x // 8 + phys_y * phys_stride
bit = 7 - phys_x % 8 bit = 7 - phys_x % 8
@@ -244,10 +259,10 @@ def eink_buffer_to_image(buffer, log_width, log_height):
return image 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.""" """Convert framebuffer to PIL Image with a 3-pixel black frame."""
if display_type == DISPLAY_TYPE_EINK: 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: else:
image_rgb = oled_buffer_to_image(buffer, width, height) image_rgb = oled_buffer_to_image(buffer, width, height)
@@ -322,15 +337,16 @@ def main():
result = receive_screenshot(ser) result = receive_screenshot(ser)
if result: 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" type_str = "e-ink" if display_type == DISPLAY_TYPE_EINK else "OLED"
print( 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: try:
image = buffer_to_image( 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() filename = generate_filename()
image.save(filename) image.save(filename)