[EInkDisplay] Single buffer mode (#7)

* Cleanup EInkDisplay

* Add EINK_DISPLAY_SINGLE_BUFFER_MODE build flag and allow for single buffer rendering

* Add SSD1677 E-Ink Display Driver Guide

Copied verbatium from https://github.com/CidVonHighwind/microreader/blob/main/doc/SSD1677_GUIDE.md

* Add a few details in the readme and update the SSD1677_GUIDE
This commit is contained in:
Dave Allie 2025-12-16 22:43:29 +11:00 committed by GitHub
parent 4d0dcd5ff8
commit af965a074b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 969 additions and 39 deletions

View File

@ -0,0 +1,78 @@
# EInkDisplay library
This is a focused low-level interaction library for the X4 display.
It's best paried with a higher-level GFX library to help with rendering shapes, text, and images.
Along with dealing with display orientation.
See the [SSD1677 guide](./doc/SSD1677_GUIDE.md) for details on the implemention.
## Usage
### Setup
```cpp
// Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults)
#define EPD_SCLK 8 // SPI Clock
#define EPD_MOSI 10 // SPI MOSI (Master Out Slave In)
#define EPD_CS 21 // Chip Select
#define EPD_DC 4 // Data/Command
#define EPD_RST 5 // Reset
#define EPD_BUSY 6 // Busy
EInkDisplay display(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY);
display.begin();
```
### Rendering black and white frames
```cpp
// First frame
display.clearScreen();
uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
// ... your drawing code here, writing to frameBuffer, a 0 bit is black, a 1 bit is white ...
display.displayBuffer(FAST_REFRESH);
// Next frame
display.clearScreen();
uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
// ... your drawing code here, writing to frameBuffer, a 0 bit is black, a 1 bit is white ...
// Using fash refresh for fast updates
display.displayBuffer(FAST_REFRESH);
```
### Rendering greyscale frames
```cpp
display.clearScreen();
uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
// ... regular drawing code from before, all gray pixels should also be marked as black ...
display.displayBuffer(FAST_REFRESH);
// Grayscale rendering
// Refetch the frame buffer to ensure it's up to date
frameBuffer = einkDisplay.getFrameBuffer();
// Dark gray rendering
// Clear the screen with 0, all 0 bits are considered out of bounds for grayscale rendering
// Only mark the bits you want to be gray as 1
display.clearScreen(0x00);
// ... exact same screen content as before, but only mark the **dark** grays pixels with `1`, rest leave as `0`
display.copyGrayscaleLsbBuffers(frameBuffer);
display.clearScreen(0x00);
// ... exact same screen content as before, but mark the **light and dark** grays pixels with `1`, rest leave as `0`
display.copyGrayscaleMsbBuffers(frameBuffer);
display.displayGrayBuffer();
// All done :)
```
### Power off
To ensure the display locks the image in, it's important to power off the display before exiting the program.
```cpp
display.deepSleep();
```

View File

@ -0,0 +1,822 @@
# SSD1677 E-Ink Display Driver Guide
## GDEQ0426T82 (4.26" 800×480) on Xteink X4
> **Note:** This document was AI-generated by consolidating multiple reference documents. Please verify critical details against the official SSD1677 datasheet.
Complete reference for programming the SSD1677 e-paper display controller, including initialization, image updates, custom LUT creation, and low-level protocol details.
Based on the GxEPD2_426_GDEQ0426T82 driver implementation.
---
# Table of Contents
- [Hardware Configuration](#hardware-configuration)
- [Buffer Modes](#buffer-modes)
- [SPI Communication](#spi-communication)
- [Initialization](#initialization)
- [RAM Operations](#ram-operations)
- [Writing Image Data](#writing-image-data)
- [Display Updates](#display-updates)
- [Custom LUT Guide](#custom-lut-guide)
- [Complete Workflows](#complete-workflows)
- [Command Reference](#command-reference)
- [Timing & Power Management](#timing--power-management)
---
# Hardware Configuration
- **Controller**: SSD1677
- **Display**: 800×480 pixels (100×60 bytes = 48,000 bytes)
- **SPI Pins**: SCLK=8, MOSI=10, CS=21, DC=4, RST=5, BUSY=6
- **SPI Settings**: 40MHz (spec: 20MHz, but 40MHz works), MSB First, SPI Mode 0
---
# Buffer Modes
The EInkDisplay driver supports two buffering modes, selectable at compile time:
## Dual Buffer Mode (Default)
**Memory usage:** 96KB (two 48KB framebuffers)
- Two framebuffers in ESP32 RAM: `frameBuffer0` and `frameBuffer1`
- Buffers alternate roles as "current" and "previous" using `swapBuffers()`
- On each `displayBuffer()`:
- Current buffer → BW RAM (0x24)
- Previous buffer → RED RAM (0x26)
- Display controller compares them for differential fast refresh
- Buffers swap roles
- **Advantage:** Fast buffer switching with no overhead
- **Disadvantage:** Uses 96KB of precious RAM
## Single Buffer Mode (Memory Optimized)
**Memory usage:** 48KB (one 48KB framebuffer)
Enable by defining `EINK_DISPLAY_SINGLE_BUFFER_MODE` before including EInkDisplay.h:
```cpp
#define EINK_DISPLAY_SINGLE_BUFFER_MODE
#include <EInkDisplay.h>
```
Or in your build system (e.g., platformio.ini):
```ini
build_flags =
-D EINK_DISPLAY_SINGLE_BUFFER_MODE
```
- Single framebuffer in ESP32 RAM: `frameBuffer0`
- Display's internal RED RAM acts as "previous frame" storage
- On each `displayBuffer()`:
- **FAST_REFRESH:**
- New frame → BW RAM (0x24)
- Display refresh (compares BW vs existing RED RAM)
- New frame → RED RAM (0x26) - syncs for next refresh
- **HALF/FULL_REFRESH:**
- New frame → both BW RAM and RED RAM (0x24 and 0x26)
- Display refresh
- Extra RED RAM sync (already contains correct frame)
- **Advantages:**
- Saves 48KB RAM (critical for ESP32-C3 with ~380KB usable)
- Fast refresh still works via differential updates
- **Disadvantages:**
- Extra RED RAM write after each refresh (~100ms overhead)
- Grayscale rendering requires temporary buffer allocation
- `swapBuffers()` not available (not needed)
## Choosing a Mode
**Use Dual Buffer Mode if:**
- RAM is plentiful
- You want absolute minimum latency
- You don't need every KB of RAM
**Use Single Buffer Mode if:**
- Running on memory-constrained hardware (like ESP32-C3)
- 48KB RAM savings is critical for your application
- ~100ms extra per refresh is acceptable
## API Differences Between Modes
Most of the API is identical between modes, but there are a few differences:
| Feature | Dual Buffer | Single Buffer |
|---------|-------------|---------------|
| `getFrameBuffer()` | ✓ Available | ✓ Available |
| `clearScreen()` | ✓ Available | ✓ Available |
| `displayBuffer()` | ✓ Available | ✓ Available |
| `swapBuffers()` | ✓ Available | ✗ Not available |
| `cleanupGrayscaleBuffers()` | ✗ Not needed | ✓ Required after grayscale |
| Memory overhead | 96KB always | 48KB + temp 48KB during grayscale |
**Code that works in both modes:**
```cpp
display.begin();
display.clearScreen();
display.displayBuffer(FAST_REFRESH);
```
**Code specific to single buffer mode:**
(This is not required in dual buffer mode)
```cpp
// Before grayscale
const auto bwBuffer = static_cast<uint8_t *>(malloc(EInkDisplay::BUFFER_SIZE));
memcpy(bwBuffer, display.getFrameBuffer(), EInkDisplay::BUFFER_SIZE);
// ... grayscale rendering ...
// After grayscale
display.cleanupGrayscaleBuffers(bwBuffer);
free(bwBuffer);
```
---
# SPI Communication
## Low-Level Protocol
### Command Format
```
DC=LOW, CS=LOW → Transfer(command_byte) → CS=HIGH, DC=HIGH
```
### Data Format
```
DC=HIGH, CS=LOW → Transfer(data_byte) → CS=HIGH
```
### Bulk Transfer Format
```
CS=LOW → Transfer(byte1) → Transfer(byte2) → ... → CS=HIGH
```
## Reset Sequence
```
RST=HIGH → delay(20ms) → RST=LOW → delay(2ms) → RST=HIGH → delay(20ms)
```
---
# Initialization
## Minimal Initialization Example
```c
void ssd1677_init() {
// 1. Software Reset
epd_cmd(0x12); // SWRESET
epd_wait_busy(); // Wait for busy to clear
// 2. Driver Output Control
epd_cmd(0x01);
epd_data(0xA7); // 680 rows -> 0x2A7, low byte
epd_data(0x02); // high byte
epd_data(0x00); // GD=0, SM=0, TB=0
// 3. Data Entry Mode
epd_cmd(0x11);
epd_data(0x03); // X+, Y+
// 4. RAM X Start/End
epd_cmd(0x44);
epd_data(0x00); // X start = 0
epd_data(0x3B); // X end = 959 / 8 = 0x3B
// 5. RAM Y Start/End
epd_cmd(0x45);
epd_data(0x00); // Y start (low byte)
epd_data(0x00); // Y start (high byte)
epd_data(0xA7); // Y end (low byte)
epd_data(0x02); // Y end (high byte)
// 6. Border Control
epd_cmd(0x3C);
epd_data(0xC0); // Border = Hi-Z
// 7. Temperature Sensor (internal)
epd_cmd(0x18);
epd_data(0x80);
}
```
## Detailed Initialization Sequence
After reset, then:
| Step | Command | Data | Description |
|------|---------|------|-------------|
| 1 | `0x12` | - | Software Reset (SWRESET) |
| - | Wait BUSY | - | Wait for reset to complete |
| 2 | `0x18` | `0x80` | Temperature sensor control (internal) |
| 3 | `0x0C` | `0xAE`, `0xC7`, `0xC3`, `0xC0`, `0x40` | Booster soft start configuration |
| 4 | `0x01` | `0xDF`, `0x01`, `0x02` | Driver output control (479 gates = HEIGHT-1) |
| 5 | `0x3C` | `0x01` | Border waveform control |
| 6 | Set RAM area | See below | Configure full screen area |
| 7 | `0x46` | `0xF7` | Auto write BW RAM (clear to white) |
| 8 | Wait BUSY | - | Wait for auto-write to complete |
| 9 | `0x47` | `0xF7` | Auto write RED RAM (clear to white) |
| 10 | Wait BUSY | - | Wait for auto-write to complete |
### Command Explanations
**Software Reset (0x12)**
Resets the internal registers (except deep sleep). Mandatory after power-up.
**Driver Output Control (0x01)**
Sets display height and scan direction.
- Byte 1: `(HEIGHT - 1) % 256` = `0xDF` (479 & 0xFF)
- Byte 2: `(HEIGHT - 1) / 256` = `0x01` (479 >> 8)
- Byte 3: `0x02` (interlaced/SM mode)
**Data Entry Mode (0x11)**
Controls RAM addressing: `0x03 = X increment, Y increment`.
**Set RAM Window (0x44 & 0x45)**
Defines the region written during RAM writes.
For full 960×680 screen, X=0..0x3B, Y=0..0x2A7.
**Border Waveform (0x3C)**
Controls VBD (border pixel behavior). `0xC0 = Hi-Z`, common default.
**Temperature Sensor (0x18)**
`0x80 = use internal sensor`.
---
# RAM Operations
## RAM Area Configuration
Sets the window for subsequent RAM writes. Y-coordinates are reversed due to hardware gates orientation.
**Important:** X addresses are specified in **pixels**, not bytes. The controller handles the byte conversion internally.
**For coordinates (x, y, w, h):**
1. Calculate reversed Y: `y_rev = HEIGHT - y - h`
2. **Set RAM Entry Mode** (Command `0x11`):
- Data: `0x01` (X increment, Y decrement - Y reversed)
3. **Set RAM X Address** (Command `0x44`) - **In pixels**:
- Data[0]: `x % 256` (X start LSB)
- Data[1]: `x / 256` (X start MSB)
- Data[2]: `(x + w - 1) % 256` (X end LSB)
- Data[3]: `(x + w - 1) / 256` (X end MSB)
4. **Set RAM Y Address** (Command `0x45`):
- Data[0]: `(y_rev + h - 1) % 256` (Y start LSB)
- Data[1]: `(y_rev + h - 1) / 256` (Y start MSB)
- Data[2]: `y_rev % 256` (Y end LSB)
- Data[3]: `y_rev / 256` (Y end MSB)
5. **Set RAM X Counter** (Command `0x4E`):
- Data[0]: `x % 256` (Initial X LSB)
- Data[1]: `x / 256` (Initial X MSB)
6. **Set RAM Y Counter** (Command `0x4F`):
- Data[0]: `(y_rev + h - 1) % 256` (Initial Y LSB)
- Data[1]: `(y_rev + h - 1) / 256` (Initial Y MSB)
---
# Writing Image Data
## Write to Current Buffer (Command 0x24)
```c
void ssd1677_write_bw(uint8_t *buffer, uint32_t size) {
// Set RAM Address Counters
epd_cmd(0x4E); // X counter
epd_data(0x00);
epd_cmd(0x4F); // Y counter
epd_data(0x00);
epd_data(0x00);
// Write BW RAM
epd_cmd(0x24);
for (uint32_t i = 0; i < size; i++)
epd_data(buffer[i]);
}
```
**Process:**
1. Configure RAM area with `_setPartialRamArea(x, y, w, h)`
2. Send command `0x24`
3. Start bulk transfer (CS=LOW)
4. Transfer image data bytes (one bit per pixel, MSB first)
- Total bytes = `(w * h) / 8`
- `0xFF` = white, `0x00` = black
5. End transfer (CS=HIGH)
**Explanation:**
- **0x4E / 0x4F** set starting address for RAM.
- **0x24** selects the BW image buffer.
## Write to Previous Buffer (Command 0x26)
Same as above but use command `0x26` instead of `0x24`. Used for differential updates.
## Full Screen Clear
1. Write to previous buffer: `_setPartialRamArea(0, 0, 800, 480)` → Command `0x26` → 48000 bytes of `0xFF`
2. Write to current buffer: `_setPartialRamArea(0, 0, 800, 480)` → Command `0x24` → 48000 bytes of `0xFF`
3. Perform full refresh
## Full Frame Example
```c
void ssd1677_display_frame(uint8_t *bw, uint8_t *red) {
ssd1677_write_bw(bw, BW_BUFFER_SIZE);
epd_cmd(0x26); // Write RED RAM
for (int i = 0; i < RED_BUFFER_SIZE; i++)
epd_data(red[i]);
ssd1677_update();
}
```
---
# Display Updates
## Power On
| Command | Data | Description |
|---------|------|-------------|
| `0x22` | `0xE0` | Display update control sequence |
| `0x20` | - | Master activation (trigger update) |
| Wait | ~100ms | Wait while BUSY pin is HIGH |
## Full Refresh
```c
void ssd1677_update() {
epd_cmd(0x22);
epd_data(0xC7); // Display mode: load LUT + refresh + power off
epd_cmd(0x20); // Master activation
epd_wait_busy(); // Wait for driving waves to complete
}
```
**Detailed Sequence:**
| Step | Command | Data | Description |
|------|---------|------|-------------|
| 1 | `0x21` | `0x40`, `0x00` | Display update control (bypass RED as 0, single chip) |
| 2a | `0x1A` | `0x5A` | Temperature register (fast mode only) |
| 2b | `0x22` | `0xD7` | Update sequence (fast mode) |
| **OR** | | | |
| 2b | `0x22` | `0xF7` | Update sequence (normal mode, extended temp) |
| 3 | `0x20` | - | Master activation |
| Wait | ~1600ms | Wait while BUSY pin is HIGH |
**Explanation:**
- **0x22 / 0xC7** tells SSD1677 which tasks to run (enable analog, load LUT, drive display).
- **0x20** starts the entire update cycle.
- **epd_wait_busy()** waits until the driver finishes waveform driving.
**Fast vs Normal Mode**: `useFastFullUpdate=true` uses faster refresh but limited temperature range.
### Display Update Control 2 (0x22) Bit Documentation
Based on driver implementation analysis:
| Bit | Hex | Name | Effect |
|-----|-----|------|--------|
| 7 | 0x80 | CLOCK_ON | Start internal oscillator |
| 6 | 0x40 | ANALOG_ON | Enable analog power rails (VGH/VGL drivers) |
| 5 | 0x20 | TEMP_LOAD | Load temperature (internal or external) |
| 4 | 0x10 | LUT_LOAD | Load waveform LUT |
| 3 | 0x08 | MODE_SELECT | Mode 1/2 selection |
| 2 | 0x04 | DISPLAY_START | Run display update |
| 1 | 0x02 | ANALOG_OFF | Analog shutdown phase |
| 0 | 0x01 | CLOCK_OFF | Disable internal oscillator |
**Common Patterns:**
- Full refresh (first power on): `0xC0 | 0x34 = 0xF4` (CLOCK+ANALOG+TEMP+LUT+DISPLAY)
- Full refresh (already on): `0x34` (TEMP+LUT+DISPLAY)
- Half refresh with high temp: `0xD4` (CLOCK+ANALOG+LUT+DISPLAY)
- Fast refresh with custom LUT: `0x0C` (MODE+DISPLAY)
- Fast refresh without custom LUT: `0x1C` (LUT+MODE+DISPLAY)
## Partial Refresh
| Command | Data | Description |
|---------|------|-------------|
| `0x21` | `0x00`, `0x00` | Display update control (RED normal, single chip) |
| `0x22` | `0xFC` | Partial update sequence |
| `0x20` | - | Master activation |
| Wait | ~600ms | Wait while BUSY pin is HIGH |
---
# Custom LUT Guide
## What is a LUT?
The SSD1677 uses a **Look-Up Table (LUT)** to control **pixel waveform driving** during updates.
Each pixel (BW/RED) needs a sequence of voltage phases to switch states correctly.
A LUT controls:
- Voltage level per phase (VSH1, VSH2, VSL, HiZ)
- VCOM toggling pattern
- Duration of each phase (TP0TP7)
- Phase repetitions
- Additional red-pixel handling
## LUT Structure (111 bytes)
Used in the driver implementation for grayscale support:
| Byte Range | Size | Purpose |
|------------|------|---------||
| 049 | 50 | VS waveforms (5 groups × 10 bytes) |
| 5099 | 50 | TP/RP timing groups (10 groups × 5 bytes) |
| 100104 | 5 | Frame rate control |
| 105 | 1 | VGH (Gate voltage) - sent via 0x03 |
| 106 | 1 | VSH1 (Source voltage 1) - sent via 0x04 |
| 107 | 1 | VSH2 (Source voltage 2) - sent via 0x04 |
| 108 | 1 | VSL (Source voltage low) - sent via 0x04 |
| 109 | 1 | VCOM voltage - sent via 0x2C |
| 110 | 1 | Reserved |
**Note:** Bytes 105-109 are sent using separate voltage control commands after loading the main LUT.
## How to Build a Custom LUT
### Step 1 — Define Source Voltage Waveform (WS0WS7)
You choose for each phase:
- VSH1 (medium positive)
- VSH2 (strong positive — drives white)
- VSL (strong negative — drives black)
- HiZ (float)
These define **pixel movement direction** and strength.
### Step 2 — Define VCOM Waveform (WS8WS14)
VCOM biases the entire display.
These bytes define:
- On/off toggling per phase
- Matching with source driver phases
- Ghost reduction
### Step 3 — Phase Timing TP0TP7 (WS15WS23)
Each TPx sets duration of a phase.
Longer = cleaner image, slower refresh.
Shorter = faster, but potential ghosting.
### Step 4 — Repeat Counts & Finalization (WS24WS33)
These adjust:
- How many times each phase repeats
- Red pigment extra driving
- Cleanup phases
## How to Load a Custom LUT
A custom LUT is written using **Command 0x32**:
```
CMD 0x32
DATA WS0
DATA WS1
...
DATA WS33
```
The first **105 bytes** are written to the LUT register (0x32), followed by separate voltage control commands.
```c
// Load LUT (111-byte format with voltage controls)
void ssd1677_load_lut_extended(const uint8_t* lut) {
// Load main LUT (105 bytes: VS + TP/RP + frame rate)
epd_cmd(0x32);
for (int i = 0; i < 105; i++)
epd_data(lut[i]);
// Set voltage values from bytes 105-109
epd_cmd(0x03); // Gate voltage (VGH)
epd_data(lut[105]);
epd_cmd(0x04); // Source voltages (VSH1, VSH2, VSL)
epd_data(lut[106]); // VSH1
epd_data(lut[107]); // VSH2
epd_data(lut[108]); // VSL
epd_cmd(0x2C); // VCOM voltage
epd_data(lut[109]);
}
```
## How to Apply (Use) the Custom LUT
After loading the LUT, tell the display to **use it**.
### 1. Configure Display Update Mode (0x22)
Typical value enabling LUT usage:
```
CMD 0x22
DATA 0xF7
```
### 2. Start Master Activation (0x20)
```
CMD 0x20
WAIT BUSY = LOW
```
While BUSY is high, the LUT waveform is driving the display.
```c
// Apply LUT
void ssd1677_apply_lut() {
epd_cmd(0x22);
epd_data(0xF7); // Use LUT
epd_cmd(0x20); // Master Activation
epd_wait_busy();
}
```
## LUT Summary
**Build a custom LUT**
- Create 111 bytes: 105 for LUT register + 5 voltage values + 1 reserved
**Use a custom LUT**
1. Write with **0x32**
2. Enable with **0x22**
3. Trigger with **0x20**
**Optional**
- Burn to OTP with **0x36**
## Grayscale Rendering with Custom LUTs
The driver implements 4-level grayscale using a multi-pass technique with custom LUTs.
### Grayscale Principle
1. **First pass (Black/White):** Write BW framebuffer to both RAM buffers, perform standard refresh
2. **Second pass (Grayscale):** Write LSB and MSB grayscale buffers, apply custom grayscale LUT, perform fast refresh
3. The custom LUT creates intermediate gray levels by controlling pixel voltage phases
### Grayscale LUT Structure
The driver includes two grayscale LUTs (111 bytes each):
- `lut_grayscale`: Forward grayscale rendering
- `lut_grayscale_revert`: Cleans up grayscale artifacts back to pure BW
Key characteristics:
- Uses different voltage sequences for 4 gray levels (00, 01, 10, 11)
- Frame timing optimized for fast refresh (~500ms)
- VS waveforms: 50 bytes (5 groups × 10 bytes)
- TP/RP timing: 50 bytes (10 groups × 5 bytes)
- Voltages: VGH=0x17, VSH1=0x41, VSH2=0xA8, VSL=0x32, VCOM=0x30
---
# Complete Workflows
## Full Screen Update (Initial or Complete Refresh)
```
1. _InitDisplay() [if not initialized]
2. _setPartialRamArea(0, 0, 800, 480)
3. Write to previous buffer: Command 0x26 + 48000 bytes
4. Write to current buffer: Command 0x24 + 48000 bytes
5. _Update_Full()
```
## Partial Update (Fast Refresh)
```
1. _InitDisplay() [if not initialized]
2. [First time only] Clear screen buffers with full refresh
3. _setPartialRamArea(x, y, w, h)
4. Write image: Command 0x24 + image bytes
5. _Update_Part()
6. Write image again: Command 0x24 + image bytes
7. Write to previous: Command 0x26 + same image bytes
```
**Why write twice?** Partial updates compare current vs previous buffer. Writing to both buffers after refresh prevents ghosting on next update.
## Minimal Usage Example
```c
ssd1677_init();
ssd1677_display_frame(bw_image, red_image);
```
## Complete Example: Fast Refresh with Buffering
The driver supports two buffering modes for fast partial updates:
### Dual Buffer Mode (Default)
**Memory usage:** 96KB (two 48KB buffers)
```cpp
// Initialize display
display.begin();
display.clearScreen(0xFF);
display.displayBuffer(FULL_REFRESH);
// Draw content to framebuffer
uint8_t* fb = display.getFrameBuffer();
// ... draw into fb ...
// Fast refresh (compares with previous frame)
display.displayBuffer(FAST_REFRESH);
// Next frame
// ... modify fb ...
display.displayBuffer(FAST_REFRESH);
```
**How it works:**
1. Two internal buffers (`frameBuffer0` and `frameBuffer1`) alternate as current/previous
2. On `displayBuffer()`, current buffer written to BW RAM (0x24), previous to RED RAM (0x26)
3. Controller compares buffers and only updates changed pixels
4. Buffers swap roles after each display using `swapBuffers()`
### Single Buffer Mode (Memory Optimized)
**Memory usage:** 48KB (one 48KB buffer) - saves 48KB RAM
Enable by defining `EINK_DISPLAY_SINGLE_BUFFER_MODE` before including EInkDisplay.h.
```cpp
// Initialize display (same as dual buffer)
display.begin();
display.clearScreen(0xFF);
display.displayBuffer(FULL_REFRESH);
// Draw content to framebuffer
uint8_t* fb = display.getFrameBuffer();
// ... draw into fb ...
// Fast refresh (compares with previous frame in display's RED RAM)
display.displayBuffer(FAST_REFRESH);
```
**How it works:**
1. Only one internal buffer (`frameBuffer0`)
2. On `displayBuffer()`:
- **FAST_REFRESH:** Write new frame to BW RAM (0x24), RED RAM already contains previous frame from last refresh
- **HALF/FULL_REFRESH:** Write new frame to both BW and RED RAM (0x24 and 0x26)
- After refresh, always sync RED RAM with current frame for next differential update
3. Controller compares BW RAM (new) vs RED RAM (old) for differential updates
4. RED RAM acts as the "previous frame buffer" for fast refresh
**Trade-offs:**
- **Pro:** Saves 48KB of RAM (critical for ESP32-C3 with only ~380KB usable)
- **Con:** Extra RED RAM write after each refresh (~100ms overhead)
- **Con:** Cannot preserve screen content during grayscale rendering without external buffer
### Grayscale Rendering in Single Buffer Mode
Single buffer mode requires special handling for grayscale rendering since the BW framebuffer is overwritten:
```cpp
// Store BW buffer after the BW render but before grayscale render
const auto bwBuffer = static_cast<uint8_t *>(malloc(EInkDisplay::BUFFER_SIZE));
const auto frameBuffer = display.getFrameBuffer();
memcpy(bwBuffer, frameBuffer, EInkDisplay::BUFFER_SIZE);
// Perform grayscale rendering (overwrites BW and RED RAM)
display.clearScreen(0x00);
// ... render grayscale LSB to frameBuffer ...
display.copyGrayscaleMsbBuffers(frameBuffer);
display.clearScreen(0x00);
// ... render grayscale MSB to frameBuffer ...
display.copyGrayscaleLsbBuffers(frameBuffer);
// Display grays
display.displayGrayBuffer();
// After grayscale render
display.cleanupGrayscaleBuffers(bwBuffer);
free(bwBuffer);
```
The `cleanupGrayscaleBuffers()` method restores the BW buffer to both the framebuffer and RED RAM, ensuring proper state
for subsequent fast refreshes
## Auto-Write Commands for Fast Clear
Commands `0x46` and `0x47` allow rapid buffer clearing:
```c
// Clear BW RAM to white pattern
sendCommand(0x46); // Auto write BW RAM
sendData(0xF7); // Fill pattern
waitWhileBusy();
// Clear RED RAM to white pattern
sendCommand(0x47); // Auto write RED RAM
sendData(0xF7); // Fill pattern
waitWhileBusy();
```
This is much faster than writing 48,000 bytes manually during initialization.
---
# Command Reference
| Command | Name | Purpose |
|---------|------|---------|
| `0x01` | Driver Output Control | Set gate scanning (HEIGHT) |
| `0x0C` | Booster Soft Start | Configure boost converter |
| `0x10` | Deep Sleep Mode | Enter low power mode |
| `0x11` | Data Entry Mode | Set X/Y increment direction |
| `0x12` | Software Reset | Reset controller |
| `0x18` | Temperature Sensor | Control temp sensor |
| `0x1A` | Temperature Register | Set temp value (fast mode) |
| `0x20` | Master Activation | Trigger display update |
| `0x21` | Display Update Control | Configure update mode |
| `0x22` | Display Update Sequence | Set update waveform |
| `0x24` | Write RAM (BW) | Write to current buffer |
| `0x26` | Write RAM (RED/OLD) | Write to previous buffer |
| `0x03` | Gate Voltage | Set VGH voltage level |
| `0x04` | Source Voltage | Set VSH1, VSH2, VSL voltages |
| `0x2C` | Write VCOM | Set VCOM voltage |
| `0x32` | Write LUT Register | Load custom 105-byte LUT (part of 111-byte structure) |
| `0x36` | Write OTP | Burn LUT to one-time-programmable memory |
| `0x3C` | Border Waveform | Configure border behavior |
| `0x44` | Set RAM X Address | Define X window (in pixels) |
| `0x45` | Set RAM Y Address | Define Y window (in pixels) |
| `0x46` | Auto Write BW RAM | Fast fill BW RAM with pattern |
| `0x47` | Auto Write RED RAM | Fast fill RED RAM with pattern |
| `0x4E` | Set RAM X Counter | Set initial X position (in pixels) |
| `0x4F` | Set RAM Y Counter | Set initial Y position (in pixels) |
---
# Timing & Power Management
## Timing Specifications
| Operation | Duration | Notes |
|-----------|----------|-------|
| Reset pulse | 10ms | Low duration |
| Power on | ~100ms | BUSY signal duration |
| Power off | ~200ms | BUSY signal duration |
| Full refresh | ~1600ms | Normal mode, wait for BUSY |
| Partial refresh | ~600ms | Wait for BUSY |
| Software reset delay | 10ms | After command 0x12 |
## BUSY Signal Monitoring
- **Pin**: GPIO6 (INPUT)
- **Active level**: HIGH
- **Polling**: Read pin until LOW, with timeout protection
- **Timeout**: 10000ms (10 seconds)
- **Usage**: Wait after commands `0x20` (master activation)
## Power Off
| Command | Data | Description |
|---------|------|-------------|
| `0x22` | `0x83` | Power off sequence |
| `0x20` | - | Master activation |
| Wait | ~200ms | Wait while BUSY pin is HIGH |
## Hibernate (Deep Sleep)
1. Execute Power Off sequence
2. Send command `0x10` (Deep Sleep Mode)
3. Send data `0x01` (Enter deep sleep)
**Wake from Hibernate**: Requires hardware reset via RST pin.
---
# Important Notes
- BUSY pin *must* be polled after reset and update
- All RAM writes auto-increment based on data entry mode
- SSD1677 can display BW-only or RED-only if desired
- All X coordinates and widths must be multiples of 8 (byte boundaries)
- Y coordinates are reversed in hardware (gates bottom-to-top)
- RAM auto-increments after each byte transfer
- Display controller internal RAM: 96,000 bytes (800×480 ÷ 8 * 2 buffers: BW + RED)
- ESP32 buffer memory usage:
- **Dual buffer mode:** 96KB (two 48KB buffers for fast buffer swapping)
- **Single buffer mode:** 48KB (one 48KB buffer, uses display's RED RAM for differential)
- Differential partial updates require RED RAM to contain the previous frame
- First write after init should be full refresh to clear ghost images
- Single buffer mode adds ~100ms overhead per refresh (extra RED RAM sync)
- Grayscale rendering in single buffer mode requires temporary 48KB allocation

View File

@ -8,7 +8,7 @@ class EInkDisplay {
EInkDisplay(int8_t sclk, int8_t mosi, int8_t cs, int8_t dc, int8_t rst, int8_t busy);
// Destructor
~EInkDisplay();
~EInkDisplay() = default;
// Refresh modes (guarded to avoid redefinition in test builds)
enum RefreshMode {
@ -21,21 +21,26 @@ class EInkDisplay {
void begin();
// Display dimensions
static const uint16_t DISPLAY_WIDTH = 800;
static const uint16_t DISPLAY_HEIGHT = 480;
static const uint16_t DISPLAY_WIDTH_BYTES = DISPLAY_WIDTH / 8;
static const uint32_t BUFFER_SIZE = DISPLAY_WIDTH_BYTES * DISPLAY_HEIGHT;
static constexpr uint16_t DISPLAY_WIDTH = 800;
static constexpr uint16_t DISPLAY_HEIGHT = 480;
static constexpr uint16_t DISPLAY_WIDTH_BYTES = DISPLAY_WIDTH / 8;
static constexpr uint32_t BUFFER_SIZE = DISPLAY_WIDTH_BYTES * DISPLAY_HEIGHT;
// Frame buffer operations
void clearScreen(uint8_t color = 0xFF);
void drawImage(const uint8_t* imageData, uint16_t x, uint16_t y, uint16_t w, uint16_t h, bool fromProgmem = false);
void clearScreen(uint8_t color = 0xFF) const;
void drawImage(const uint8_t* imageData, uint16_t x, uint16_t y, uint16_t w, uint16_t h, bool fromProgmem = false) const;
#ifndef EINK_DISPLAY_SINGLE_BUFFER_MODE
void swapBuffers();
void setFramebuffer(const uint8_t* bwBuffer);
#endif
void setFramebuffer(const uint8_t* bwBuffer) const;
void copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer);
void copyGrayscaleLsbBuffers(const uint8_t* lsbBuffer);
void copyGrayscaleMsbBuffers(const uint8_t* msbBuffer);
#ifdef EINK_DISPLAY_SINGLE_BUFFER_MODE
void cleanupGrayscaleBuffers(const uint8_t* bwBuffer);
#endif
void displayBuffer(RefreshMode mode = FAST_REFRESH);
void displayGrayBuffer(bool turnOffScreen = false);
@ -52,7 +57,7 @@ class EInkDisplay {
void deepSleep();
// Access to frame buffer
uint8_t* getFrameBuffer() {
uint8_t* getFrameBuffer() const {
return frameBuffer;
}
@ -65,10 +70,11 @@ class EInkDisplay {
// Frame buffer (statically allocated)
uint8_t frameBuffer0[BUFFER_SIZE];
uint8_t frameBuffer1[BUFFER_SIZE];
uint8_t* frameBuffer;
#ifndef EINK_DISPLAY_SINGLE_BUFFER_MODE
uint8_t frameBuffer1[BUFFER_SIZE];
uint8_t* frameBufferActive;
#endif
// SPI settings
SPISettings spiSettings;

View File

@ -115,27 +115,31 @@ EInkDisplay::EInkDisplay(int8_t sclk, int8_t mosi, int8_t cs, int8_t dc, int8_t
_rst(rst),
_busy(busy),
frameBuffer(nullptr),
#ifndef EINK_DISPLAY_SINGLE_BUFFER_MODE
frameBufferActive(nullptr),
#endif
customLutActive(false) {
Serial.printf("[%lu] EInkDisplay: Constructor called\n", millis());
Serial.printf("[%lu] SCLK=%d, MOSI=%d, CS=%d, DC=%d, RST=%d, BUSY=%d\n", millis(), sclk, mosi, cs, dc, rst, busy);
}
EInkDisplay::~EInkDisplay() {
// No dynamic memory to clean up (buffers are statically allocated)
}
void EInkDisplay::begin() {
Serial.printf("[%lu] EInkDisplay: begin() called\n", millis());
frameBuffer = frameBuffer0;
#ifndef EINK_DISPLAY_SINGLE_BUFFER_MODE
frameBufferActive = frameBuffer1;
#endif
// Initialize to white
memset(frameBuffer0, 0xFF, BUFFER_SIZE);
#ifdef EINK_DISPLAY_SINGLE_BUFFER_MODE
Serial.printf("[%lu] Static frame buffer (%lu bytes = 48KB)\n", millis(), BUFFER_SIZE);
#else
memset(frameBuffer1, 0xFF, BUFFER_SIZE);
Serial.printf("[%lu] Static frame buffers (2 x %lu bytes = 96KB)\n", millis(), BUFFER_SIZE);
#endif
Serial.printf("[%lu] Initializing e-ink display driver...\n", millis());
// Initialize SPI with custom pins
@ -266,13 +270,11 @@ void EInkDisplay::initDisplayController() {
Serial.printf("[%lu] SSD1677 controller initialized\n", millis());
}
void EInkDisplay::setRamArea(uint16_t x, uint16_t y, uint16_t w, uint16_t h) {
const uint16_t WIDTH = 800;
const uint16_t HEIGHT = 480;
const uint8_t DATA_ENTRY_X_INC_Y_DEC = 0x01;
void EInkDisplay::setRamArea(const uint16_t x, uint16_t y, uint16_t w, uint16_t h) {
constexpr uint8_t DATA_ENTRY_X_INC_Y_DEC = 0x01;
// Reverse Y coordinate (gates are reversed on this display)
y = HEIGHT - y - h;
y = DISPLAY_HEIGHT - y - h;
// Set data entry mode (X increment, Y decrement for reversed gates)
sendCommand(CMD_DATA_ENTRY_MODE);
@ -303,28 +305,28 @@ void EInkDisplay::setRamArea(uint16_t x, uint16_t y, uint16_t w, uint16_t h) {
sendData((y + h - 1) / 256); // high byte
}
void EInkDisplay::clearScreen(uint8_t color) {
void EInkDisplay::clearScreen(const uint8_t color) const {
memset(frameBuffer, color, BUFFER_SIZE);
}
void EInkDisplay::drawImage(const uint8_t* imageData, uint16_t x, uint16_t y, uint16_t w, uint16_t h,
bool fromProgmem) {
void EInkDisplay::drawImage(const uint8_t* imageData, const uint16_t x, const uint16_t y, const uint16_t w, const uint16_t h,
const bool fromProgmem) const {
if (!frameBuffer) {
Serial.printf("[%lu] ERROR: Frame buffer not allocated!\n", millis());
return;
}
// Calculate bytes per line for the image
uint16_t imageWidthBytes = w / 8;
const uint16_t imageWidthBytes = w / 8;
// Copy image data to frame buffer
for (uint16_t row = 0; row < h; row++) {
uint16_t destY = y + row;
const uint16_t destY = y + row;
if (destY >= DISPLAY_HEIGHT)
break;
uint16_t destOffset = destY * DISPLAY_WIDTH_BYTES + (x / 8);
uint16_t srcOffset = row * imageWidthBytes;
const uint16_t destOffset = destY * DISPLAY_WIDTH_BYTES + (x / 8);
const uint16_t srcOffset = row * imageWidthBytes;
for (uint16_t col = 0; col < imageWidthBytes; col++) {
if ((x / 8 + col) >= DISPLAY_WIDTH_BYTES)
@ -343,25 +345,27 @@ void EInkDisplay::drawImage(const uint8_t* imageData, uint16_t x, uint16_t y, ui
void EInkDisplay::writeRamBuffer(uint8_t ramBuffer, const uint8_t* data, uint32_t size) {
const char* bufferName = (ramBuffer == CMD_WRITE_RAM_BW) ? "BW" : "RED";
unsigned long startTime = millis();
const unsigned long startTime = millis();
Serial.printf("[%lu] Writing frame buffer to %s RAM (%lu bytes)...\n", startTime, bufferName, size);
sendCommand(ramBuffer);
sendData(data, size);
unsigned long duration = millis() - startTime;
const unsigned long duration = millis() - startTime;
Serial.printf("[%lu] %s RAM write complete (%lu ms)\n", millis(), bufferName, duration);
}
void EInkDisplay::setFramebuffer(const uint8_t* bwBuffer) {
void EInkDisplay::setFramebuffer(const uint8_t* bwBuffer) const {
memcpy(frameBuffer, bwBuffer, BUFFER_SIZE);
}
#ifndef EINK_DISPLAY_SINGLE_BUFFER_MODE
void EInkDisplay::swapBuffers() {
uint8_t* temp = frameBuffer;
frameBuffer = frameBufferActive;
frameBufferActive = temp;
}
#endif
void EInkDisplay::grayscaleRevert() {
if (!inGrayscaleMode) {
@ -372,7 +376,7 @@ void EInkDisplay::grayscaleRevert() {
// Load the revert LUT
setCustomLUT(true, lut_grayscale_revert);
refreshDisplay(FAST_REFRESH, false);
refreshDisplay(FAST_REFRESH);
setCustomLUT(false);
}
@ -392,6 +396,18 @@ void EInkDisplay::copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t*
writeRamBuffer(CMD_WRITE_RAM_RED, msbBuffer, BUFFER_SIZE);
}
#ifdef EINK_DISPLAY_SINGLE_BUFFER_MODE
/**
* In single buffer mode, this should be called with the previously written BW buffer
* to reconstruct the RED buffer for proper differential fast refreshes following a
* grayscale display.
*/
void EInkDisplay::cleanupGrayscaleBuffers(const uint8_t* bwBuffer) {
setRamArea(0, 0, DISPLAY_WIDTH, DISPLAY_HEIGHT);
writeRamBuffer(CMD_WRITE_RAM_RED, bwBuffer, BUFFER_SIZE);
}
#endif
void EInkDisplay::displayBuffer(RefreshMode mode) {
if (!isScreenOn) {
// Force half refresh if screen is off
@ -414,17 +430,25 @@ void EInkDisplay::displayBuffer(RefreshMode mode) {
} else {
// For fast refresh, write to BW buffer only
writeRamBuffer(CMD_WRITE_RAM_BW, frameBuffer, BUFFER_SIZE);
// In single buffer mode, the RED RAM should already contain the previous frame
// In dual buffer mode, we write back frameBufferActive which is the last frame
#ifndef EINK_DISPLAY_SINGLE_BUFFER_MODE
writeRamBuffer(CMD_WRITE_RAM_RED, frameBufferActive, BUFFER_SIZE);
#endif
}
// swap active buffer for next time
swapBuffers();
// Refresh the display
refreshDisplay(mode, false);
refreshDisplay(mode);
#ifdef EINK_DISPLAY_SINGLE_BUFFER_MODE
// In single buffer mode always sync RED RAM after refresh to prepare for next fast refresh
// This ensures RED contains the currently displayed frame for differential comparison
setRamArea(0, 0, DISPLAY_WIDTH, DISPLAY_HEIGHT);
writeRamBuffer(CMD_WRITE_RAM_RED, frameBuffer, BUFFER_SIZE);
#endif
}
void EInkDisplay::displayGrayBuffer(bool turnOffScreen) {
void EInkDisplay::displayGrayBuffer(const bool turnOffScreen) {
drawGrayscale = false;
inGrayscaleMode = true;
@ -434,7 +458,7 @@ void EInkDisplay::displayGrayBuffer(bool turnOffScreen) {
setCustomLUT(false);
}
void EInkDisplay::refreshDisplay(RefreshMode mode, bool turnOffScreen) {
void EInkDisplay::refreshDisplay(const RefreshMode mode, const bool turnOffScreen) {
// Configure Display Update Control 1
sendCommand(CMD_DISPLAY_UPDATE_CTRL1);
sendData((mode == FAST_REFRESH) ? CTRL1_NORMAL : CTRL1_BYPASS_RED); // Configure buffer comparison mode
@ -490,7 +514,7 @@ void EInkDisplay::refreshDisplay(RefreshMode mode, bool turnOffScreen) {
waitWhileBusy(refreshType);
}
void EInkDisplay::setCustomLUT(bool enabled, const unsigned char* lutData) {
void EInkDisplay::setCustomLUT(const bool enabled, const unsigned char* lutData) {
if (enabled) {
Serial.printf("[%lu] Loading custom LUT...\n", millis());