#!/usr/bin/env python3 """ Screenshot tool for Wio L1 Tracker Pro (OLED SH1106 and e-ink GxEPD2). Connects via USB serial using mesh companion protocol, captures framebuffer, saves as PNG. Usage: python screenshot.py [--port PORT] [--scale SCALE] Options: --port PORT Serial port to use (default: auto-detect) --scale SCALE Upscale factor: 1=no upscale, 2=2x, 3=3x, 4=4x, etc. (default: 1) """ import argparse import serial import serial.tools.list_ports from datetime import datetime from PIL import Image, ImageDraw import sys import time import os # Mesh protocol constants FRAME_START_OUT = 0x3C # '<' - used when sending TO device FRAME_START_IN = 0x3E # '>' - used when receiving FROM device CMD_GET_SCREENSHOT = 66 RESP_CODE_SCREENSHOT = 29 RESP_CODE_ERR = 1 # display_type values (byte [5] in response frame) DISPLAY_TYPE_OLED = 0 # page-based, column-major (SH1106/SSD1306) DISPLAY_TYPE_EINK = 1 # row-major, MSB-first, 1=white/0=black (GxEPD2) # 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(): parser = argparse.ArgumentParser( description="Capture screenshots from Wio L1 Tracker Pro (OLED or e-ink)" ) parser.add_argument( "--port", "-p", help="Serial port to use (default: auto-detect)" ) parser.add_argument( "--scale", "-s", type=int, default=1, help="Upscale factor: 1=no upscale, 2=2x, 3=3x, 4=4x, etc. (default: 1)", ) return parser.parse_args() def find_wio_port(): """Find Wio Tracker L1 serial port""" ports = serial.tools.list_ports.comports() for port in ports: desc = str(port.description).lower() if "wio" in desc or "seeed" in desc: return port.device # Fallback: check common port names for port in ports: if "ACM" in port.device or port.device.startswith("COM"): return port.device return None def read_frame(ser): """Read a complete frame from the mesh protocol (from device)""" header = ser.read(3) if len(header) != 3: return None if header[0] != FRAME_START_IN: print( f"Error: Invalid frame start byte: {header[0]:02x} (expected {FRAME_START_IN:02x})" ) return None frame_len = header[1] | (header[2] << 8) if frame_len > 0: data = bytearray() while len(data) < frame_len: chunk = ser.read(frame_len - len(data)) if not chunk: print(f"Error: Timeout waiting for frame data") return None data.extend(chunk) return bytes(data) else: return b"" def send_command(ser, command, data=b""): """Send a command frame using mesh protocol (to device)""" frame_len = len(data) + 1 header = bytes([FRAME_START_OUT, frame_len & 0xFF, (frame_len >> 8) & 0xFF]) frame = header + bytes([command]) + data ser.write(frame) ser.flush() return frame_len + 3 HEADER_SIZE = 11 # see firmware sendScreenshotResponse() for layout def _u16le(buf, off): return buf[off] | (buf[off + 1] << 8) def _expected_buffer_size(width, height, display_type): """Bytes the firmware should send for given dims+type.""" if display_type == DISPLAY_TYPE_EINK: # GxEPD2 buffer is in physical panel coordinates: row-major, # stride = ceil(WIDTH_VISIBLE / 8) bytes, HEIGHT rows. # WIDTH_VISIBLE = shorter visible dim regardless of rotation. visible_short = min(width, height) phys_stride = (visible_short + 7) // 8 phys_height = max(width, height) return phys_stride * phys_height # OLED: packed bits, no padding return (width * height) // 8 def receive_screenshot(ser, timeout=5): """Receive a multi-chunk screenshot response. Returns (buf, w, h, type, rot) or None.""" start_time = time.time() buffer_data = bytearray() width = None height = None display_type = None disp_rotation = 0 total_chunks = None received_chunks = 0 while time.time() - start_time < timeout: frame = read_frame(ser) if frame is None: continue if len(frame) < 1: continue resp_code = frame[0] if resp_code == RESP_CODE_ERR: err = frame[1] if len(frame) > 1 else 0xFF print(f"Error: Device returned error code 0x{err:02x}") return None if resp_code != RESP_CODE_SCREENSHOT: continue if len(frame) < HEADER_SIZE: print(f"Error: Screenshot frame too short: {len(frame)} bytes (need >= {HEADER_SIZE})") return None disp_type = frame[1] rotation = frame[2] w = _u16le(frame, 3) h = _u16le(frame, 5) chunk_idx = _u16le(frame, 7) num_chunks = _u16le(frame, 9) chunk_data = frame[HEADER_SIZE:] 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 if width is None: print(f"Error: Missed chunk 0 (first chunk seen: {chunk_idx})") return None if width != w or height != h: print(f"Error: Inconsistent dimensions in chunk {chunk_idx}") return None if total_chunks != num_chunks: print(f"Error: Inconsistent chunk count in chunk {chunk_idx}") return None buffer_data.extend(chunk_data) received_chunks += 1 if received_chunks >= total_chunks: expected = _expected_buffer_size(width, height, display_type) if len(buffer_data) != expected: print(f"Error: Expected {expected} bytes, got {len(buffer_data)}") return None return bytes(buffer_data), width, height, display_type, disp_rotation print( f"Error: Timeout waiting for screenshot (received {received_chunks}/{total_chunks or '?'} chunks)" ) return None def oled_buffer_to_image(buffer, width, height): """Decode SH1106/SSD1306 framebuffer (page-based, column-major). Each byte covers 8 vertical pixels; bit 0 = top pixel of the byte. """ image = Image.new("1", (width, height), 0) pixels = image.load() bytes_per_page = width for page in range(0, height, 8): page_start = (page // 8) * bytes_per_page for col in range(width): byte_idx = page_start + col if byte_idx < len(buffer): byte_val = buffer[byte_idx] for bit in range(8): if page + bit < height: pixels[col, page + bit] = 1 if (byte_val & (1 << bit)) else 0 return image.convert("RGB") 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 (uses WIDTH_VISIBLE, not the full physical WIDTH) and the GxEPD2 rotation value. 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) 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 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. 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() for ly in range(log_height): for lx in range(log_width): if rotation == 0: phys_x = lx phys_y = 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 if byte_idx < len(buffer): raw = (buffer[byte_idx] >> bit) & 1 color = (255, 255, 255) if raw else (0, 0, 0) pixels[lx, ly] = color return image def buffer_to_image(buffer, width, height, display_type, rotation=0, scale=1): """Convert framebuffer to PIL Image, optionally upscaled.""" if display_type == DISPLAY_TYPE_EINK: image_rgb = eink_buffer_to_image(buffer, width, height, rotation) else: image_rgb = oled_buffer_to_image(buffer, width, height) if scale > 1: image_rgb = image_rgb.resize( (image_rgb.width * scale, image_rgb.height * scale), Image.Resampling.NEAREST, ) return image_rgb def generate_filename(): """Generate timestamp filename in local time with seconds""" now = datetime.now() os.makedirs("tools/pngs", exist_ok=True) return os.path.join("tools/pngs", now.strftime("screenshot_%Y-%m-%d_%H-%M-%S.png")) def main(): args = parse_args() port = args.port if not port: port = find_wio_port() if not port: print("Error: Could not find Wio Tracker L1 serial port") print("\nAvailable ports:") for p in serial.tools.list_ports.comports(): print(f" {p.device}: {p.description}") port = input("\nEnter serial port manually: ") print(f"\nConnecting to {port} at 115200 baud (scale: {args.scale}x)...") try: ser = serial.Serial(port, 115200, timeout=3) print("Connected!\n") except serial.SerialException as e: print(f"Error opening serial port: {e}") sys.exit(1) try: print("Screenshot Tool for Wio L1 Tracker Pro (OLED + e-ink)") print("Press 'S' to capture screenshot, 'Q' to quit\n") while True: try: key = input("> ").strip().upper() except (KeyboardInterrupt, EOFError): print("\nExiting...") break if key == "Q": print("Exiting...") break if key == "S": print("Requesting screenshot...") ser.reset_input_buffer() send_command(ser, CMD_GET_SCREENSHOT) time.sleep(0.1) result = receive_screenshot(ser) if 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} rot={disp_rotation}, {len(buffer_data)} bytes" ) try: image = buffer_to_image( buffer_data, width, height, display_type, rotation=disp_rotation, scale=args.scale ) filename = generate_filename() image.save(filename) print(f"Saved as {filename} ({image.width}x{image.height})") except Exception as e: print(f"Error saving image: {e}") else: print("Error: Failed to receive screenshot") else: print("Unknown command. Press 'S' for screenshot, 'Q' to quit") except KeyboardInterrupt: print("\nExiting...") finally: ser.close() print("Connection closed.") if __name__ == "__main__": main()