Custom firmware for the Good-Display 13.3-inch E-Ink Spectra 6 panel (GDEP133C02) paired with the ESP32-133C02 driver board. Displays full-color (6-colour) images uploaded via a built-in web portal, then enters deep sleep to preserve the image indefinitely.
Originally adapted from shi-314/esp32-spectra-e6 (MIT License), which targeted a smaller display. This fork adds full support for the 13.3" panel's dual-IC QSPI interface, image upload via a web server, multiple dithering algorithms, JPEG/PNG/BMP decoding, automatic image scaling, HTTP folder cycling, pre-encoded .spectra6 format support, quiet hours, and deep sleep management.
- Features
- Hardware
- Pin Mapping
- SD Card Pins
- Architecture Overview
- Boot Flow
- Image Processing Pipeline
- Pre-Encoded Spectra6 Format
- Setup & Deployment
- Configuration
- Quiet Hours
- Flash Partition Layout
- Dependencies & Libraries
- Source Code Structure
- Memory Management
- License
- 6-colour rendering — Black, White, Yellow, Red, Blue, Green via the Spectra 6 palette.
- Web-based image upload — Upload JPEG, PNG, or BMP images directly through a browser.
- HTTP image folder cycling — Point the device at an HTTP directory (nginx autoindex, Python
http.server, NAS share, etc.) and it cycles through images in alphabetical order on each wake. - Pre-encoded
.spectra6format — Serve pre-dithered images from a companion converter tool. The device skips all on-device decoding — just a direct memory copy into the display. Dramatically reduces PSRAM usage and boot time. - User-selectable dithering — Choose between Floyd-Steinberg, Atkinson, Ordered (Bayer), or Nearest Neighbour. Dithering controls are hidden in the web portal when a
.spectra6URL is configured. - Automatic image scaling — Images of any size are nearest-neighbour scaled in-place to fit 1200×1600, with aspect ratio preserved via automatic letterboxing/pillarboxing.
- Quiet hours — Configure a daily time window (e.g. 11 pm–8 am) during which the device skips its noisy e-ink refresh and sleeps directly until the window ends. Requires WiFi for NTP time sync.
- Timed deep sleep — Wake at configurable intervals (15 min, 30 min, 1 h, etc.) to cycle images. Deep sleep preserves the displayed image with zero power draw.
- PSRAM-optimised — All large buffers are allocated in the ESP32-S3's 8 MB PSRAM.
- Dual-IC QSPI — Custom
DisplayAdapterbridges the manufacturer's C driver into anAdafruit_GFX-compatible API, handling the split framebuffer across two driver ICs. - WiFi configuration portal — First-boot Access Point mode with a web UI for entering WiFi credentials, image URL, and all display settings.
- NVS persistent storage — All configuration survives deep sleep and power cycles.
- LittleFS image storage — Uploaded images are stored on the internal flash filesystem (~5.6 MB partition).
| Component | Details |
|---|---|
| Driver Board | ESP32-133C02 (Good-Display) |
| MCU | ESP32-S3-WROOM-1 (N16R8) — 16 MB Flash, 8 MB PSRAM |
| Display Panel | GDEP133C02 — 13.3" E-Ink Spectra 6 |
| Resolution | 1200 × 1600 pixels |
| Colours | 6 (Black, White, Yellow, Red, Blue, Green) |
| Interface | QSPI with dual driver ICs (CS0 + CS1) |
| Power | USB-C or battery (with brownout detection) |
The display uses a Quad-SPI interface with two chip-select lines (one per driver IC — each IC handles half the display width).
| Function | GPIO | Direction | Notes |
|---|---|---|---|
SPI_CLK |
9 | Output | SPI clock |
SPI_Data0 |
41 | Bidirectional | QSPI data line 0 |
SPI_Data1 |
40 | Bidirectional | QSPI data line 1 |
SPI_Data2 |
39 | Bidirectional | QSPI data line 2 |
SPI_Data3 |
38 | Bidirectional | QSPI data line 3 |
SPI_CS0 |
18 | Output | Chip select — left half (driver IC 0) |
SPI_CS1 |
17 | Output | Chip select — right half (driver IC 1) |
EPD_BUSY |
7 | Input | Display busy signal |
EPD_RST |
6 | Output | Display hardware reset |
LOAD_SW |
45 | Output | Load switch (power to display) |
| Function | GPIO | Notes |
|---|---|---|
SW_2 |
13 | External pull-down on board |
SW_4 |
21 | User button |
Important: The SD card slot on the ESP32-133C02 board shares the same SPI data and clock lines as the E-Ink display. This is a critical design detail.
| Function | GPIO | Shared With |
|---|---|---|
| SD_CLK (SCK) | 9 | SPI_CLK (display) |
| SD_CMD (MOSI) | 41 | SPI_Data0 (display) |
| SD_D0 (MISO) | 40 | SPI_Data1 (display) |
| SD_CS | 21 | SW_4 (user button) |
The display and SD card cannot be used simultaneously. The firmware accesses the SD card first (before display initialisation), copies any found image to LittleFS, then releases the bus for the display driver.
┌─────────────────────────────────────────────────────────────────┐
│ main.cpp (setup) │
│ Boot → WiFi → NTP → Quiet Hours → Display → Web Server → Sleep│
├─────────────┬───────────────┬───────────────┬──────────────────┤
│ ImageScreen │ ConfigScreen │ ConfigServer │ WiFiConnection │
│ (render) │ (AP mode) │ (web portal) │ (STA connect) │
├─────────────┴───────────────┴───────────────┴──────────────────┤
│ DisplayAdapter │
│ Adafruit_GFX subclass → PSRAM framebuffer │
│ Handles rotation, colour packing, dual-IC split │
├─────────────────────────────────────────────────────────────────┤
│ Manufacturer C Driver Layer │
│ GDEP133C02.c + comm.c + pindefine.h │
│ QSPI init, GPIO config, EPD commands, power sequencing │
├─────────────────────────────────────────────────────────────────┤
│ ESP32-S3 Hardware │
│ SPI3_HOST • 8MB PSRAM • 16MB Flash │
└─────────────────────────────────────────────────────────────────┘
Power On / Timer Wake
│
▼
Load config from NVS
│
├── Timer wake + quiet hours configured?
│ │
│ ├── Connect WiFi → sync NTP
│ │ ├── In quiet window → sleep until end of window
│ │ └── Outside window → proceed
│ └── NTP unavailable → sleep and retry
│
▼
SD Card: copy image to LittleFS (if present)
│
▼
Connect to WiFi
│
├── Has credentials?
│ │
│ ├── YES → Connect
│ │ ▼
│ │ Advance image index (if timer wake + folder configured)
│ │ ▼
│ │ Display image (LittleFS → Folder → Single URL, in priority order)
│ │ ▼
│ │ Web server runs for 10 minutes [skipped on timer wake]
│ │ ▼
│ │ Enter deep sleep (timer or permanent)
│ │
│ └── NO → Display stored image
│ Start Access Point ("Framey-Config")
│ Run config portal for 10 minutes
│ Enter deep sleep
When multiple image sources are configured, the device uses this priority order:
- Pinned folder image — a specific folder image pinned via the web portal
- Local LittleFS image — uploaded via the web portal
- HTTP folder — cycles through images in alphabetical order
- Single image URL — downloads a single image each wake
Raw Image File (JPEG / PNG / BMP)
│
▼
Format Detection (magic bytes: FFD8=JPEG, 89PNG=PNG, BM=BMP)
│
├── JPEG: JPEGDEC → RGB565 buffer (PSRAM)
├── PNG: PNGdec → RGB565 buffer (streaming or in-memory)
└── BMP: Manual 24-bit parser → RGB565 buffer
│
▼
Scale-to-Fit (nearest-neighbour, aspect-ratio preserved)
White letterbox/pillarbox bars on mismatched aspect ratios
│
▼
Dithering (user-selectable):
├── Floyd-Steinberg (smooth gradients, default)
├── Atkinson (higher contrast)
├── Ordered/Bayer (structured pattern)
└── None (nearest colour, for vector art)
│
▼
5 colour bitmaps (1-bit each):
Black, Yellow, Red, Blue, Green (White = no bits set)
│
▼
Render via DisplayAdapter::drawBitmap() → 4-bit PSRAM framebuffer
│
▼
Send to display via QSPI (dual-IC split) → ~20 second refresh
Pre-encoded .spectra6 file (served over HTTP)
│
▼
Magic header detection ("SPECTRA6")
│
▼
Direct memcpy into 5 colour bitmaps — no decode, no dithering
│
▼
Render via DisplayAdapter::drawBitmap() → 4-bit PSRAM framebuffer
│
▼
Send to display via QSPI → ~20 second refresh
| Format | Notes |
|---|---|
| JPEG | Any size; pre-scaled by JPEGDEC before full decode when very large |
| PNG | Any size; supports alpha channel |
| BMP | 24-bit uncompressed |
.spectra6 |
Pre-encoded binary format — zero on-device dithering or decoding |
| Property | Value |
|---|---|
| Physical screen | 13.3 inches diagonal |
| Native resolution | 1200 × 1600 pixels |
| Orientation | Portrait |
| Aspect ratio | 3:4 |
| Colour depth | 6 colours |
| Refresh time | ~20 seconds |
The ideal source image is 1200 × 1600 px portrait. Any other size is scaled automatically.
The .spectra6 format lets you do all image processing on a PC and serve the result directly to the device — the device just copies the data into the display pipeline with no CPU or memory overhead.
| Standard (JPEG/PNG) | Pre-encoded (.spectra6) |
|
|---|---|---|
| On-device dithering | Yes (~seconds) | None |
| Peak PSRAM | ~5 MB | ~2.4 MB |
| Download size | 1–8 MB | ~1.17 MB (fixed) |
| Dithering quality | Good | Best (full floating-point on PC) |
Bytes 0–7: ASCII magic "SPECTRA6" (no null terminator)
Bytes 8–11: uint32_t width (little-endian)
Bytes 12–15: uint32_t height (little-endian)
Bytes 16+: 5 × planeSize bytes (black, yellow, red, blue, green)
planeSize = ((width + 7) / 8) × height
Total size for 1200×1600: 1,200,016 bytes (~1.17 MB)
Each plane is a 1-bpp MSB-first row-major bitmap. White pixels have no bit set in any plane.
Use the companion Python converter to produce .spectra6 files from standard images:
👉 PhotoPainter-E-Ink-Spectra-6-image-converter — companion converter that produces .spectra6 files from standard images
python ConvertTo6ColorsForEInkSpectra6.py image.jpg --format spectra6 --dither 3--dither 3— Floyd-Steinberg (recommended; produces fuller, more saturated output than the Atkinson default)--dither 1— Atkinson- Target resolution: 1200×1600 (portrait)
The device auto-detects .spectra6 files by their magic header — no configuration needed. When a .spectra6 URL is entered in the web portal, the dithering selector is automatically hidden.
- PlatformIO installed (e.g., VSCode extension)
- ESP32-133C02 board connected via USB-C
# Clone the repository
git clone https://github.com/dandwhelan/esp32-spectra-e6-13inch.git
cd esp32-spectra-e6-13inch
# Build and upload firmware
pio run --target upload
# Upload the LittleFS filesystem (web portal HTML)
pio run --target uploadfs
# Monitor serial output
pio device monitor -b 115200Create src/config_dev.h (gitignored) to set default WiFi credentials for development:
#ifndef CONFIG_DEV_H
#define CONFIG_DEV_H
const char DEFAULT_WIFI_SSID[] = "YourNetwork";
const char DEFAULT_WIFI_PASSWORD[] = "YourPassword";
const char DEFAULT_IMAGE_URL[] = "https://example.com/image.png";
#endifNote: Adding new fields to
ApplicationConfigchanges the NVS blob size. On first boot after a firmware update that adds fields, stored settings are automatically cleared and the device uses defaults. Re-enter your settings via the web portal.
- The device creates a WiFi Access Point:
Framey-Config(password:configure123) - Connect with your phone or laptop
- Navigate to
http://192.168.4.1 - Enter your WiFi SSID, password, and image URL
- Save — settings are stored in NVS and survive reboots and deep sleep
Once configured, the device's web portal is available at http://<device-ip> during the 10-minute server window after each boot.
| Setting | Description |
|---|---|
| Single Image URL | Direct URL to a JPEG, PNG, BMP, or .spectra6 file |
| Image Folder URL | HTTP directory URL; device cycles images alphabetically |
| Dithering Algorithm | Floyd-Steinberg / Atkinson / Ordered / None (hidden for .spectra6 URLs) |
| Scaling Mode | Fill (crop to cover) or Fit (letterbox) |
| Change Image Every | How often to advance to the next folder image |
| Wake From Sleep Every | How often the device wakes from deep sleep |
| UTC Offset (hours) | Your timezone offset from UTC, used for quiet hours |
| Quiet From / Until | Hour range (0–23) during which the display will not refresh |
Set Image Folder URL to an HTTP directory that serves image files. The device:
- Fetches the directory listing (supports nginx autoindex, Apache, Python
http.server, JSON arrays) - Sorts filenames alphabetically for deterministic ordering
- Advances to the next image on each wake (subject to Change Image Every interval)
You can also browse the folder from the web portal and pin a specific image to display it indefinitely.
Quiet hours prevent the noisy e-ink refresh during set times — useful for bedrooms.
- Set UTC Offset, Quiet From (start hour, 0–23), and Quiet Until (end hour, 0–23)
- Midnight-spanning ranges work: e.g. start=23, end=8 means 11 pm to 8 am
- Set both to the same value to disable
- Requires WiFi — if the device cannot reach NTP, it sleeps for the normal sleep interval and retries on the next wake
- On a quiet-hours wake, the device sleeps precisely until the end of the quiet window (e.g. if it wakes at 3 am with quiet hours until 8 am, it sleeps exactly 5 hours)
| Partition | Type | Offset | Size | Purpose |
|---|---|---|---|---|
nvs |
data (nvs) | 0x9000 | 20 KB | WiFi credentials, config, image index |
otadata |
data (ota) | 0xE000 | 8 KB | OTA metadata |
app0 |
app (ota_0) | 0x10000 | 2.3 MB | Firmware |
spiffs |
data (spiffs) | 0x260000 | 5.6 MB | LittleFS (uploaded images + web portal HTML) |
All dependencies are managed by PlatformIO and declared in platformio.ini.
| Library | Purpose |
|---|---|
| Adafruit GFX | Base graphics primitives (inherited by DisplayAdapter) |
| U8g2 | Font rendering engine |
| U8g2_for_Adafruit_GFX | Bridge between U8g2 fonts and Adafruit_GFX |
| Library | Purpose |
|---|---|
| JPEGDEC | JPEG decoding with hardware scaling |
| PNGdec | PNG decoding (streaming from file or memory) |
| Library | Purpose |
|---|---|
| ESPAsyncWebServer | Async HTTP server for config portal and image uploads |
| AsyncTCP | Async TCP layer (required by ESPAsyncWebServer) |
| WiFi | WiFi STA and AP mode |
| HTTPClient | Image download from URL |
| DNSServer | Captive portal in AP mode |
| Library | Purpose |
|---|---|
| qrcode | QR code generation for config screen |
| FS / LittleFS | Filesystem for image and HTML storage |
| Repository | Relationship |
|---|---|
| shi-314/esp32-spectra-e6 | Original project — firmware for smaller Spectra 6 displays. This repo forked the image processing, dithering, WiFi setup, and config portal logic. |
| Good-Display example code | Manufacturer C driver — GDEP133C02.c, comm.c, pindefine.h are adapted from Good-Display's official ESP-IDF example. These handle QSPI initialisation, EPD commands, and dual-IC communication. |
src/
├── main.cpp # Boot flow, WiFi, NTP, quiet hours gate, web server, deep sleep
│
├── DisplayAdapter.cpp/.h # Adafruit_GFX subclass wrapping the QSPI driver
│ PSRAM framebuffer, dual-IC split transfer
│
├── ImageScreen.cpp/.h # Image loading pipeline:
│ JPEG/PNG/BMP/Spectra6 decode, scaling, dithering,
│ bitmap rendering; LittleFS + HTTP folder + URL sources
│
├── FolderImageSource.cpp/.h # HTTP directory listing parser + image downloader
│ Supports HTML autoindex and JSON array formats
│
├── ConfigurationServer.cpp/.h # Async web server: config portal, image upload, folder browse
├── ConfigurationScreen.cpp/.h # AP mode display (QR code + connection info)
│
├── WiFiConnection.cpp/.h # WiFi STA connection manager
├── HttpDownloader.cpp/.h # HTTP/HTTPS image downloader with ETag caching
├── SDCardManager.cpp/.h # SD card → LittleFS image copy (runs before display init)
│
├── ApplicationConfig.h # Runtime config struct (all settings)
├── ApplicationConfigStorage # NVS read/write for persistent config + image index
├── config_default.h # Default empty credentials (safe to commit)
│
├── GDEP133C02.c/.h # [Manufacturer] EPD init, command sequences, refresh
├── comm.c/.h # [Manufacturer] SPI bus init, GPIO, transactions
├── pindefine.h # [Manufacturer] GPIO pin assignments
├── status.h # [Manufacturer] Debug flag
│
├── battery.cpp/.h # Battery voltage ADC (currently disabled)
└── Screen.h # Abstract screen interface
data/
└── config.html # Web portal HTML (uploaded to LittleFS via uploadfs)
platformio.ini # Build config, library dependencies, partition table
partitions.csv # Custom flash partition layout
The ESP32-S3's 8 MB PSRAM is the critical resource.
| Buffer | Size | Notes |
|---|---|---|
| Display framebuffer | 960 KB | 4-bit packed, 2 pixels/byte, always allocated |
| RGB565 decode buffer | 3.84 MB | Allocated during decode, freed before dithering |
| Colour bitmaps (×5) | 1.2 MB | 1-bit per pixel per colour |
| Peak concurrent | ~6 MB | RGB565 + bitmaps + framebuffer |
For large JPEGs the raw source data is freed immediately after decode — before dithering begins — to avoid exceeding 8 MB.
| Buffer | Size | Notes |
|---|---|---|
| Display framebuffer | 960 KB | Always allocated |
| Download buffer | ~1.14 MB | The .spectra6 file itself |
| Colour bitmaps (×5) | 1.2 MB | Direct memcpy from download buffer |
| Peak concurrent | ~3.3 MB | ~45% less than standard path |
MIT License. Original base logic by shi-314. Adapted and extended by dandwhelan.