A standalone Go server that drives a Joan 6 e-ink display from a TRMNL (BYOS) server — with no Visionect cloud (VSS) dependency.
The Joan 6 is a 13" 1024×758 4-bit grayscale e-ink panel with a capacitive touchscreen. Out of the box it only talks to Visionect's hosted software. This shim reimplements the device-side wire protocol so the panel can be pointed at a server you control and show anything you can render to an image — a Home Assistant dashboard, a TRMNL plugin, a clock, whatever.
┌─────────┐ PV3 / TCP:11112 ┌───────────────────┐ HTTP /api/display ┌──────────┐
│ Joan 6 │ ◀─────────────────▶ │ trmnl-joan-bridge │ ◀───────────────────▶ │ TRMNL │
│ e-ink │ image frames │ (this repo) │ image_url + rate │ (BYOS) │
└─────────┘ └───────────────────┘ └──────────┘
- Polls TRMNL on the standard TRMNL device protocol:
GET /api/displaywithID(the device MAC) andAccess-Tokenheaders. TRMNL replies with animage_urland arefresh_rate. - Fetches and encodes the image into a Visionect PV3 frame: resize to 1024×758, convert to 4-bit grayscale, pack 2 px/byte, split into 80 LZ4-compressed blocks, and wrap with the device descriptor and headers.
- Serves the panel over raw TCP on port 11112. Joan opens a connection and
sends a status "hello" roughly every 3 minutes; the shim replies with a
session ACK and, when the image is new, the frame. When only part of the
screen changed it sends a partial update — just the changed rectangle, so
the panel does a fast, flicker-free local refresh instead of a full repaint; a
whole-screen change (or the first push after a (re)connect) sends a full frame.
See
docs/partial-updates.md. - Reports device health. The status hello also carries battery voltage and
WiFi RSSI; the shim parses them and forwards them to TRMNL as the standard
Battery-VoltageandRSSIheaders, so battery and signal appear in the TRMNL device dashboard. Seedocs/status-hello.md.
The PV3 wire format, block layout, and session handshake were reverse-engineered
from captured device traffic; see docs/ for the protocol notes.
The image is published multi-arch (arm64 + amd64) to the GitHub Container
Registry by CI on every push to main.
docker run -d --name trmnl-joan-bridge \
-p 11112:11112 \
-e TRMNL_SERVER="http://your-trmnl-host:2300" \
-e DEVICE_ID="AA:BB:CC:DD:EE:FF" \
-e ACCESS_TOKEN="your-trmnl-device-token" \
ghcr.io/mrfyda/trmnl-joan-bridge:latestservices:
trmnl-joan-bridge:
image: ghcr.io/mrfyda/trmnl-joan-bridge:latest
restart: unless-stopped
ports:
- "11112:11112"
environment:
TRMNL_SERVER: http://your-trmnl-host:2300
DEVICE_ID: AA:BB:CC:DD:EE:FF # Joan MAC, UPPERCASE
ACCESS_TOKEN: your-trmnl-device-tokenThen point the panel at the shim with the Joan Configurator app: set the
server address to your-shim-host:11112.
All configuration is via environment variables (or the equivalent flags).
| Variable | Required | Default | Description |
|---|---|---|---|
TRMNL_SERVER |
yes | — | TRMNL base URL, e.g. http://192.168.1.10:2300 |
DEVICE_ID |
yes | — | Joan MAC address, uppercase, e.g. AA:BB:CC:DD:EE:FF |
ACCESS_TOKEN |
yes | — | TRMNL device access token |
REFRESH_INTERVAL |
no | 60s |
Fallback re-fetch interval when TRMNL omits refresh_rate |
LISTEN_ADDR |
no | :11112 |
TCP address the panel connects to |
The
DEVICE_IDmust be uppercase — TRMNL rejects lowercase MACs withInvalid device ID.
# Local binary (requires Go 1.22+)
go build -o bin/trmnl-joan-bridge .
# Or via the Makefile, using a container toolchain
make build # native dev binary → bin/trmnl-joan-bridge-local
make build-arm # static linux/arm64 binary → bin/trmnl-joan-bridge
# Docker image
docker build -t trmnl-joan-bridge .- Panel: Joan 6 — 1024×758, 4-bit grayscale e-ink, capacitive touch.
- Transport: Visionect PV3 over TCP port 11112.
- Rotation: the encoder applies a fixed 180° rotation to match this panel's scan orientation.
main.go TCP server, TRMNL polling, frame store, heartbeat loop
pv3/ PV3 wire protocol: framing + decode, full & partial frame encode, session ACK
docs/ reverse-engineered protocol notes
Dockerfile multi-stage build → scratch runtime image
.github/ CI: gofmt/vet/test + multi-arch build & push to ghcr.io
Built by reverse-engineering the Visionect device protocol purely for interoperability, to keep a perfectly good display out of a landfill.