Blog
Feb 17, 2026 - 12 MIN READ
Mirroring a Wearable OLED Display to a Projector for Live Demos

Mirroring a Wearable OLED Display to a Projector for Live Demos

How to stream a tiny OLED screen from an embedded device to a laptop and projector in real-time using UART and Python. A practical guide for hardware demos and presentations.

Mariusz Smenzyk

Mariusz Smenzyk

AI Developer ✨ MusicTech ✨ SportTech

If you've ever tried to demo a wearable device on stage, you know the problem: the screen is tiny. A 128x64 OLED might be perfectly readable on your wrist, but it's invisible to an audience sitting more than a meter away.

While building BeatBuddy — a wearable fitness device powered by the nRF52832 — I needed a way to show the OLED display on a projector during live presentations. No extra hardware, no cameras pointed at a tiny screen, no complicated setups. Just a USB cable and a Python script.

In this article, I'll walk through how I built a real-time OLED display mirror that streams the device's framebuffer over UART to a Python app running on a laptop, which then renders it fullscreen for a projector — plus virtual button control so you can navigate the device entirely from the laptop.


The Problem

BeatBuddy uses an SH1106 OLED display (128x64 pixels, monochrome) connected via I2C. During meetups, workshops, and investor demos, the audience needs to see what the user sees on the device — in real time.

The obvious solutions all had drawbacks:

ApproachProblem
Document camera / webcamPoor image quality, glare, parallax, needs physical setup
Screen recording via BLEHigh latency (~100ms+), complex pairing
HDMI output from deviceRequires additional hardware (HDMI driver board), adds cost and bulk

I needed something zero-hardware, low-latency, and dead simple.


The Solution: UART Framebuffer Streaming

The idea is straightforward:

  1. The firmware already maintains a framebuffer in RAM (1,024 bytes for 128x64 pixels)
  2. After each display refresh, send that buffer over UART to a connected laptop
  3. A Python script reads the buffer and renders it scaled up in a window
  4. The laptop sends button commands back over the same UART connection
  5. Fullscreen that window and connect the laptop to a projector via HDMI
BeatBuddy (nRF52832)  ◄──── UART 921600 baud ────►  Laptop
┌──────────────────┐                                ┌──────────────────────────┐
│                  │                                │                          │
│  Framebuffer     │  ── framebuffer frames ──────► │  Python OLED Simulator   │
│  (1024 bytes)    │                                │                          │
│       │          │  ◄── btnsim commands ────────  │  pygame window (1024x512)│
│       ├── I2C → OLED (device)                    │  fullscreen → HDMI       │
│       └── UART → TX pin                          │                          │
│                  │                                │   [<]   [OK]   [>]       │
│  Shell (btnsim)  │                                │  Keyboard: ← ↵ →        │
└──────────────────┘                                └──────────────────────────┘

Understanding the Framebuffer

The SH1106 OLED uses a page-based memory layout. The 64-pixel height is divided into 8 pages of 8 rows each. Within each page, one byte represents a vertical column of 8 pixels:

Page 0:  byte[0]   byte[1]   ...  byte[127]
Page 1:  byte[128] byte[129] ...  byte[255]
  ...
Page 7:  byte[896] byte[897] ...  byte[1023]

Each byte encodes 8 vertical pixels: bit 0 is the top pixel, bit 7 is the bottom pixel in that 8-pixel column. This layout is standard for SSD1306/SH1106 controllers and is important to get right on the Python side.

for page in range(8):
    for x in range(128):
        byte = framebuffer[page * 128 + x]
        for bit in range(8):
            y = page * 8 + bit
            pixel_on = bool(byte & (1 << bit))

The Protocol

Version 1: Too Simple

My first version used a 2-byte sync header:

FieldSizeValue
Sync header2 bytes0xAA 0x55
Framebuffer1,024 bytesRaw pixel data

No checksums, no framing, no ACKs. Simple, right?

It worked — until certain pixel patterns appeared on screen. 0xAA is 10101010 in binary (alternating pixels), and 0x55 is 01010101. These patterns naturally occur in UI elements like dashed lines, progress bars, and battery icons. When the parser found a false sync header inside the framebuffer data, it desynchronized and displayed garbage for several frames.

Version 2: Robust

The fix was straightforward — a longer sync header and a checksum:

FieldSizeValue
Sync header4 bytes0xAA 0x55 0xBE 0xEF
Framebuffer1,024 bytesRaw page-based pixel data
Checksum1 byteXOR of all 1,024 FB bytes
Total1,029 bytes per frame

The 4-byte header reduces the false-match probability from 1/65,536 to 1/4,294,967,296 per byte position — virtually zero. The XOR checksum catches any frames corrupted by serial errors (which do happen at 921,600 baud over cheap USB-UART adapters).

Bandwidth Check

At 921,600 baud, each frame takes:

1,029 bytes × 10 bits/byte ÷ 921,600 baud ≈ 11ms per frame
FPSBandwidth% of UART capacity
1010.3 KB/s11%
2020.6 KB/s22%
3030.9 KB/s33%

Since the OLED itself refreshes at ~20 FPS (limited by I2C throughput), UART is never the bottleneck.


Firmware Side (Rust + Embassy)

The firmware runs on Embassy, an async runtime for embedded Rust. The display driver already copies the framebuffer before flushing it to I2C. I hook into that same point.

Shared State

Three statics coordinate between the display task and the shell task:

pub static MIRROR_ENABLED: AtomicBool = AtomicBool::new(false);

pub static MIRROR_FB: Mutex<CriticalSectionRawMutex, [u8; 1024]> =
    Mutex::new([0u8; 1024]);

pub static MIRROR_SIGNAL: Signal<CriticalSectionRawMutex, ()> =
    Signal::new();

Hook in refresh()

After writing all 8 pages to the OLED via I2C:

if MIRROR_ENABLED.load(Ordering::Relaxed) {
    {
        let mut mirror = MIRROR_FB.lock().await;
        mirror.copy_from_slice(&fb_copy);
    }
    MIRROR_SIGNAL.signal(());
}

Multiplexing UART with select

The tricky part: the nRF52832 has one UARTE peripheral, already owned by the shell task for command input/output. I can't create a second UART instance. Instead, I use Embassy's select to multiplex UART reads (shell input) and mirror frame writes:

loop {
    match select(uart.read(&mut byte_buf), MIRROR_SIGNAL.wait()).await {
        Either::First(result) => {
            // Process shell input byte (echo, command parsing, etc.)
        }
        Either::Second(()) => {
            // Copy FB out of mutex quickly, then write frame
            let mut fb_buf = [0u8; 1024];
            {
                let fb = MIRROR_FB.lock().await;
                fb_buf.copy_from_slice(&*fb);
            }
            let sync: [u8; 4] = [0xAA, 0x55, 0xBE, 0xEF];
            let _ = uart.write_all(&sync).await;
            let _ = uart.write_all(&fb_buf).await;
            let checksum = fb_buf.iter().fold(0u8, |acc, &b| acc ^ b);
            let _ = uart.write_all(&[checksum]).await;
        }
    }
}

Critical detail: I copy the framebuffer data out of the mutex before writing to UART. The write takes ~11ms at 921,600 baud. Holding the mutex during that time would block the display task's refresh() calls — a subtle async deadlock that only manifests as occasional frame drops.


Virtual Button Control

During a live demo, pressing tiny physical buttons on a waterproof wearable is awkward. The simulator sends shell commands back over the same UART connection:

KeyboardSimulator ButtonShell Command
(Left arrow)[<]btnsim left\r
(Enter)[OK]btnsim ok\r
(Right arrow)[>]btnsim right\r

The firmware shell parser processes these exactly like typed commands, calling handle_left_button(), handle_middle_button(), and handle_right_button() on the UI state directly. The framebuffer parser on the Python side ignores shell echo/responses — it only looks for the 4-byte sync header. Shell data is ASCII (0x20-0x7E), so it can never contain 0xAA 0x55 0xBE 0xEF.

Long-press support: Holding a key sends repeated commands at 10 Hz, with an initial 400ms delay before repeat starts — matching the feel of a physical button.


Python Simulator

The host-side app is a ~470-line Python script using pygame and pyserial.

Frame Decoding

SYNC_HEADER = bytes([0xAA, 0x55, 0xBE, 0xEF])

def decode_framebuffer(data: bytes) -> list[list[bool]]:
    pixels = [[False] * 128 for _ in range(64)]
    for page in range(8):
        for x in range(128):
            byte = data[page * 128 + x]
            for bit in range(8):
                y = page * 8 + bit
                pixels[y][x] = bool(byte & (1 << bit))
    return pixels

The serial reader runs in a background thread, scanning for sync headers and validating checksums:

# Validate XOR checksum before accepting frame
computed_checksum = 0
for b in frame_data:
    computed_checksum ^= b

if computed_checksum == received_checksum:
    new_pixels = decode_framebuffer(bytes(frame_data))
    with self.lock:
        self.pixels = new_pixels
# else: corrupted frame, silently skip

macOS Serial Lessons Learned

We spent more time debugging serial issues on macOS than writing the actual mirror code. Here's what we learned:

1. Disable DTR/RTS or your device will reset on every connect

Some USB-UART adapters wire DTR to the target's reset line. Opening the serial port toggles DTR, resetting your nRF52832 mid-demo. The fix:

ser = serial.Serial(port, 921600, dsrdtr=False, rtscts=False)
ser.dtr = False
ser.rts = False

2. Don't reconnect on every error

At 921,600 baud, transient SerialException is normal — USB scheduling delays, driver quirks, etc. Each reconnect toggles DTR again (see above), potentially resetting the device. We retry 5 times before reconnecting:

except serial.SerialException as e:
    error_count += 1
    if error_count >= 5:
        self.connected = False  # reconnect
    else:
        time.sleep(0.05)  # brief retry

3. Check lsof before debugging "disconnections"

We spent an embarrassing amount of time debugging phantom disconnections. The error message "device reports readiness to read but returned no data (device disconnected or multiple access on port?)" turned out to mean exactly what it says — minicom was still holding the port open in another terminal. macOS allows multiple processes to open the same serial port, but only one gets the data.

lsof /dev/tty.usbserial-* # ← check this FIRST

4. Read large chunks

serial.read(8192) instead of serial.read(1024). At 92 KB/s, small reads can't keep up and the OS buffer overflows, causing data loss and frame corruption.

UI Design

The simulator renders the display scaled up (default 8x = 1024×512) with:

  • Yellow-on-black pixel rendering with thick white border
  • Circular virtual buttons on a dark gray panel: [<] [OK] [>]
  • Button flash feedback on press
  • Long-press key repeat at 10 Hz
  • FPS counter and connection status in the title bar
  • Fullscreen toggle (F11) for projector output
  • Resizable window maintaining aspect ratio

Usage

cd tools/mirror
poetry install
poetry run oled-mirror --port /dev/tty.usbserial-XXXX

Press F11 to toggle fullscreen. Connect your laptop's HDMI to the projector, and the audience sees exactly what's on the BeatBuddy screen — in real time, scaled up to any size. Press arrow keys or click the virtual buttons to navigate the device menus without touching the hardware.


Alternative: RTT for Development

During development, you might prefer RTT (Real-Time Transfer) over UART. RTT uses the JTAG/SWD debug interface (via a J-Link probe) and requires no additional pins or cables beyond the debugger you're already using.

UARTRTT
Speed921,600 baud~1 MB/s
Extra hardwareUSB-UART adapterJ-Link (already connected)
Use caseLive demosDevelopment & debugging
Shares withDebug shelldefmt logs (separate channel)

RTT supports multiple channels — you can use channel 0 for logs and channel 1 for framebuffer data, keeping them separate. However, for on-stage demos, UART is more practical since you only need a USB cable.


Key Takeaways

  1. Simple protocols win — 1,029 bytes per frame, no handshake, no state machine. Lost frames are simply skipped — at 20 FPS, nobody notices.
  2. False sync headers are real — A 2-byte sync pattern will eventually appear in your payload data. Use 4+ bytes for binary protocols. We learned this the hard way when checkerboard pixel patterns triggered phantom frame boundaries.
  3. Mutex discipline matters in async — Holding an Embassy mutex across an await point blocks other tasks. Copy data out first, release the lock, then do slow I/O.
  4. Embassy's select is the single-peripheral escape hatch — When your MCU has one UART and two jobs, select lets you multiplex without a second peripheral or interrupt-driven state machines.
  5. USB serial on macOS is quirky — DTR pin toggling, phantom "ready" signals, and multi-process port access are all real issues. Defensive serial handling isn't optional.
  6. Virtual buttons transform a debug tool into a demo tool — Being able to navigate the device from the laptop keyboard while presenting is a game-changer. The audience sees the display AND the navigation, without the presenter fumbling with a tiny device.

Resources