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.
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 specopendisplay.wifi.server— Async TCP server with mDNS advertisement (_opendisplay._tcp)opendisplay-serveCLI — 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 100BLE 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).
┌─────────────────┐ 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:
- Android app discovers the server via mDNS (
_opendisplay._tcp) - Connects over TCP and sends an image request (battery status, WiFi RSSI)
- Server requests display config
- App announces its capabilities (2880x2160, monochrome)
- Server sends a 1bpp Floyd-Steinberg dithered image (777KB for full resolution)
- App decodes and renders full-screen
- Repeats at the server-specified poll interval
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.
| 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 |