Skip to content

Instantly share code, notes, and snippets.

@balloob
Created March 28, 2026 01:14
Show Gist options
  • Select an option

  • Save balloob/d9fb430469b3c8d18f80b8f0b2725b54 to your computer and use it in GitHub Desktop.

Select an option

Save balloob/d9fb430469b3c8d18f80b8f0b2725b54 to your computer and use it in GitHub Desktop.
OpenDisplay on a 42" E-Ink Android Screen — WiFi protocol server + Android client

OpenDisplay on a 42" E-Ink Android Screen

I got my hands on an AValue EPD-42S — a 42-inch e-ink display running Android 5.1 on an NXP i.MX 7Dual. The display is 2880x2160, 16-level grayscale, no backlight, and holds its image at zero power. Perfect for a wall-mounted dashboard or photo frame.

I wanted to use the OpenDisplay protocol to push images to it over WiFi from any server on my network. OpenDisplay had a Python library for BLE communication, but no WiFi support. And there was no Android client at all.

So I built both.

What I built

opendisplay-android — Android client + Java library

An Android app that turns any Android device into an OpenDisplay-compatible display:

  • Standalone Java library (opendisplay-java/) — pure Java, no Android dependencies. Implements the OpenDisplay WiFi protocol: TCP client, CRC16-CCITT, packet framing, image decoding for all 5 color schemes.
  • Android app — discovers OpenDisplay servers via mDNS, connects, receives images, renders full-screen. Auto-detects screen resolution.
  • Targets Android 5.0+ (API 22) with Java 7 for maximum compatibility with older embedded devices.
  • Integration tested against the py-opendisplay WiFi server.

py-opendisplay WiFi branch — WiFi server + CLI

Added WiFi protocol support to py-opendisplay:

  • opendisplay.wifi.protocol — Frame building/parsing matching the OpenDisplay WiFi spec
  • opendisplay.wifi.server — Async TCP server with mDNS advertisement (_opendisplay._tcp)
  • opendisplay-serve CLI — Convert any image to 1bpp monochrome (Floyd-Steinberg dithered) and serve it
# Serve an image to any OpenDisplay device on the network
uv run opendisplay-serve photo.png

# Serve with specific display dimensions
uv run opendisplay-serve photo.png --width 2880 --height 2160

# Serve a test pattern
uv run opendisplay-serve --checkerboard --width 100 --height 100

BLE dependencies are now optional (pip install py-opendisplay[ble]) so the WiFi server runs with just pillow + zeroconf.

19 tests covering the WiFi protocol and server (protocol round-trips, CRC validation, multi-client, config handshake flow).

How it works

┌─────────────────┐         mDNS discovery          ┌──────────────────┐
│                 │  ◄──── _opendisplay._tcp ────    │                  │
│  py-opendisplay │                                  │  Android device  │
│  WiFi server    │         TCP connection           │  (OpenDisplay    │
│                 │  ◄──── port 2446 ───────────     │   Android app)   │
│  Serves images  │                                  │                  │
│  via OpenDisplay│   1. Display sends image request  │  Discovers via   │
│  WiFi protocol  │   2. Server requests config       │  mDNS, connects, │
│                 │   3. Display announces 2880x2160  │  renders images  │
│                 │   4. Server sends dithered image   │  full-screen     │
└─────────────────┘                                  └──────────────────┘

The protocol flow:

  1. Android app discovers the server via mDNS (_opendisplay._tcp)
  2. Connects over TCP and sends an image request (battery status, WiFi RSSI)
  3. Server requests display config
  4. App announces its capabilities (2880x2160, monochrome)
  5. Server sends a 1bpp Floyd-Steinberg dithered image (777KB for full resolution)
  6. App decodes and renders full-screen
  7. Repeats at the server-specified poll interval

Protocol extensions

The OpenDisplay spec uses uint16 for frame length and image_length fields, limiting payloads to 64KB. That's fine for typical BLE e-ink tags (200x200 pixels = 5KB) but not for a 2880x2160 display (777KB monochrome).

Our WiFi implementation uses uint32 for these fields, allowing images up to 4GB. This only affects the WiFi transport — BLE framing is unchanged.

The stack

Layer Technology
Image serving Python, Pillow (dithering), asyncio
WiFi protocol OpenDisplay Basic Standard over TCP
Discovery mDNS via system dns-sd (macOS) / zeroconf
Java library Pure Java 7, zero dependencies
Android app Android 5.0+, NsdManager for mDNS
Display AValue EPD-42S, 42" e-ink, 2880x2160, Android 5.1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment