2026-05-26 21:26:07 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""
|
2026-05-28 01:03:47 +02:00
|
|
|
Screenshot tool for Wio L1 Tracker Pro (OLED SH1106 and e-ink GxEPD2).
|
2026-05-26 21:26:07 +02:00
|
|
|
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
|
2026-05-28 01:03:47 +02:00
|
|
|
|
|
|
|
|
# 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)
|
|
|
|
|
|
2026-05-28 01:19:22 +02:00
|
|
|
# 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.
|
2026-05-26 21:26:07 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_args():
|
|
|
|
|
parser = argparse.ArgumentParser(
|
2026-05-28 01:03:47 +02:00
|
|
|
description="Capture screenshots from Wio L1 Tracker Pro (OLED or e-ink)"
|
2026-05-26 21:26:07 +02:00
|
|
|
)
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-05-28 21:03:05 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-05-26 21:26:07 +02:00
|
|
|
def receive_screenshot(ser, timeout=5):
|
2026-05-28 21:03:05 +02:00
|
|
|
"""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
|
2026-05-26 21:26:07 +02:00
|
|
|
received_chunks = 0
|
|
|
|
|
|
|
|
|
|
while time.time() - start_time < timeout:
|
|
|
|
|
frame = read_frame(ser)
|
|
|
|
|
if frame is None:
|
|
|
|
|
continue
|
2026-05-28 21:03:05 +02:00
|
|
|
if len(frame) < 1:
|
|
|
|
|
continue
|
2026-05-26 21:26:07 +02:00
|
|
|
|
|
|
|
|
resp_code = frame[0]
|
|
|
|
|
|
2026-05-28 21:03:05 +02:00
|
|
|
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
|
2026-05-26 21:26:07 +02:00
|
|
|
|
2026-05-28 21:03:05 +02:00
|
|
|
if resp_code != RESP_CODE_SCREENSHOT:
|
|
|
|
|
continue
|
2026-05-26 21:26:07 +02:00
|
|
|
|
2026-05-28 21:03:05 +02:00
|
|
|
if len(frame) < HEADER_SIZE:
|
|
|
|
|
print(f"Error: Screenshot frame too short: {len(frame)} bytes (need >= {HEADER_SIZE})")
|
|
|
|
|
return None
|
2026-05-26 21:26:07 +02:00
|
|
|
|
2026-05-28 21:03:05 +02:00
|
|
|
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}")
|
2026-05-26 21:26:07 +02:00
|
|
|
return None
|
|
|
|
|
|
2026-05-28 21:03:05 +02:00
|
|
|
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
|
2026-05-26 21:26:07 +02:00
|
|
|
|
|
|
|
|
print(
|
|
|
|
|
f"Error: Timeout waiting for screenshot (received {received_chunks}/{total_chunks or '?'} chunks)"
|
|
|
|
|
)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-05-28 01:03:47 +02:00
|
|
|
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.
|
|
|
|
|
"""
|
2026-05-26 21:26:07 +02:00
|
|
|
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:
|
2026-05-28 01:03:47 +02:00
|
|
|
pixels[col, page + bit] = 1 if (byte_val & (1 << bit)) else 0
|
|
|
|
|
return image.convert("RGB")
|
2026-05-26 21:26:07 +02:00
|
|
|
|
|
|
|
|
|
2026-05-28 08:10:16 +02:00
|
|
|
def eink_buffer_to_image(buffer, log_width, log_height, rotation):
|
|
|
|
|
"""Decode GxEPD2 framebuffer for any panel and rotation (0-3).
|
2026-05-28 07:54:45 +02:00
|
|
|
|
2026-05-28 08:10:16 +02:00
|
|
|
The firmware sends GxEPD2's own width()/height() in the header (uses WIDTH_VISIBLE,
|
|
|
|
|
not the full physical WIDTH) and the GxEPD2 rotation value.
|
2026-05-28 07:54:45 +02:00
|
|
|
|
2026-05-28 08:10:16 +02:00
|
|
|
Buffer layout (always in physical panel coordinates, rotation-independent):
|
|
|
|
|
row-major, stride = GxEPD2_Type::WIDTH / 8 bytes, MSB-first
|
2026-05-28 07:54:45 +02:00
|
|
|
byte_idx = phys_x // 8 + phys_y * stride
|
|
|
|
|
bit = 7 - phys_x % 8
|
|
|
|
|
1 = white (paper), 0 = black (ink)
|
|
|
|
|
|
2026-05-28 08:10:16 +02:00
|
|
|
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.
|
2026-05-28 07:54:45 +02:00
|
|
|
stride = buffer_size // HEIGHT (no panel-specific constants needed).
|
2026-05-28 01:03:47 +02:00
|
|
|
"""
|
2026-05-28 01:19:22 +02:00
|
|
|
if log_width == 0 or log_height == 0:
|
|
|
|
|
return Image.new("RGB", (1, 1), (255, 255, 255))
|
|
|
|
|
|
2026-05-28 08:10:16 +02:00
|
|
|
# Physical HEIGHT is always the longer dimension (panel HEIGHT regardless of rotation).
|
2026-05-28 07:54:45 +02:00
|
|
|
phys_height = max(log_width, log_height)
|
2026-05-28 08:10:16 +02:00
|
|
|
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
|
2026-05-28 01:03:47 +02:00
|
|
|
|
2026-05-28 07:54:45 +02:00
|
|
|
image = Image.new("RGB", (log_width, log_height), (255, 255, 255))
|
2026-05-28 01:03:47 +02:00
|
|
|
pixels = image.load()
|
|
|
|
|
|
2026-05-28 07:54:45 +02:00
|
|
|
for ly in range(log_height):
|
2026-05-28 01:03:47 +02:00
|
|
|
for lx in range(log_width):
|
2026-05-28 08:10:16 +02:00
|
|
|
if rotation == 0:
|
2026-05-28 07:54:45 +02:00
|
|
|
phys_x = lx
|
|
|
|
|
phys_y = ly
|
2026-05-28 08:10:16 +02:00
|
|
|
elif rotation == 1:
|
|
|
|
|
phys_x = vis_w - 1 - ly # = log_height - 1 - ly
|
2026-05-28 07:54:45 +02:00
|
|
|
phys_y = lx
|
2026-05-28 08:10:16 +02:00
|
|
|
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
|
2026-05-28 07:54:45 +02:00
|
|
|
|
2026-05-28 01:19:22 +02:00
|
|
|
byte_idx = phys_x // 8 + phys_y * phys_stride
|
|
|
|
|
bit = 7 - phys_x % 8
|
2026-05-28 01:03:47 +02:00
|
|
|
if byte_idx < len(buffer):
|
2026-05-28 07:54:45 +02:00
|
|
|
raw = (buffer[byte_idx] >> bit) & 1
|
2026-05-28 01:03:47 +02:00
|
|
|
color = (255, 255, 255) if raw else (0, 0, 0)
|
|
|
|
|
pixels[lx, ly] = color
|
|
|
|
|
|
|
|
|
|
return image
|
|
|
|
|
|
|
|
|
|
|
2026-05-28 08:10:16 +02:00
|
|
|
def buffer_to_image(buffer, width, height, display_type, rotation=0, scale=1):
|
2026-05-28 08:12:05 +02:00
|
|
|
"""Convert framebuffer to PIL Image, optionally upscaled."""
|
2026-05-28 01:03:47 +02:00
|
|
|
if display_type == DISPLAY_TYPE_EINK:
|
2026-05-28 08:10:16 +02:00
|
|
|
image_rgb = eink_buffer_to_image(buffer, width, height, rotation)
|
2026-05-28 01:03:47 +02:00
|
|
|
else:
|
|
|
|
|
image_rgb = oled_buffer_to_image(buffer, width, height)
|
|
|
|
|
|
2026-05-26 21:26:07 +02:00
|
|
|
if scale > 1:
|
2026-05-28 08:12:05 +02:00
|
|
|
image_rgb = image_rgb.resize(
|
|
|
|
|
(image_rgb.width * scale, image_rgb.height * scale),
|
2026-05-26 21:26:07 +02:00
|
|
|
Image.Resampling.NEAREST,
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-28 08:12:05 +02:00
|
|
|
return image_rgb
|
2026-05-26 21:26:07 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-05-28 01:03:47 +02:00
|
|
|
print("Screenshot Tool for Wio L1 Tracker Pro (OLED + e-ink)")
|
2026-05-26 21:26:07 +02:00
|
|
|
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:
|
2026-05-28 08:10:16 +02:00
|
|
|
buffer_data, width, height, display_type, disp_rotation = result
|
2026-05-28 01:03:47 +02:00
|
|
|
type_str = "e-ink" if display_type == DISPLAY_TYPE_EINK else "OLED"
|
2026-05-26 21:26:07 +02:00
|
|
|
print(
|
2026-05-28 08:10:16 +02:00
|
|
|
f"Received framebuffer: {width}x{height} {type_str} rot={disp_rotation}, {len(buffer_data)} bytes"
|
2026-05-26 21:26:07 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
image = buffer_to_image(
|
2026-05-28 08:10:16 +02:00
|
|
|
buffer_data, width, height, display_type,
|
|
|
|
|
rotation=disp_rotation, scale=args.scale
|
2026-05-26 21:26:07 +02:00
|
|
|
)
|
|
|
|
|
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()
|