From af965a074b99bdbbf0670d2c284bb34d978f73ed Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Tue, 16 Dec 2025 22:43:29 +1100 Subject: [PATCH] [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 --- libs/display/EInkDisplay/README.md | 78 ++ libs/display/EInkDisplay/doc/SSD1677_GUIDE.md | 822 ++++++++++++++++++ .../display/EInkDisplay/include/EInkDisplay.h | 28 +- libs/display/EInkDisplay/src/EInkDisplay.cpp | 80 +- 4 files changed, 969 insertions(+), 39 deletions(-) create mode 100644 libs/display/EInkDisplay/README.md create mode 100644 libs/display/EInkDisplay/doc/SSD1677_GUIDE.md diff --git a/libs/display/EInkDisplay/README.md b/libs/display/EInkDisplay/README.md new file mode 100644 index 0000000..f3e16ed --- /dev/null +++ b/libs/display/EInkDisplay/README.md @@ -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(); +``` diff --git a/libs/display/EInkDisplay/doc/SSD1677_GUIDE.md b/libs/display/EInkDisplay/doc/SSD1677_GUIDE.md new file mode 100644 index 0000000..b60746c --- /dev/null +++ b/libs/display/EInkDisplay/doc/SSD1677_GUIDE.md @@ -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 +``` + +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(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, Hi‑Z) +- VCOM toggling pattern +- Duration of each phase (TP0–TP7) +- Phase repetitions +- Additional red-pixel handling + +## LUT Structure (111 bytes) + +Used in the driver implementation for grayscale support: + +| Byte Range | Size | Purpose | +|------------|------|---------|| +| 0–49 | 50 | VS waveforms (5 groups × 10 bytes) | +| 50–99 | 50 | TP/RP timing groups (10 groups × 5 bytes) | +| 100–104 | 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 (WS0–WS7) +You choose for each phase: +- VSH1 (medium positive) +- VSH2 (strong positive — drives white) +- VSL (strong negative — drives black) +- Hi‑Z (float) + +These define **pixel movement direction** and strength. + +### Step 2 — Define VCOM Waveform (WS8–WS14) +VCOM biases the entire display. +These bytes define: +- On/off toggling per phase +- Matching with source driver phases +- Ghost reduction + +### Step 3 — Phase Timing TP0–TP7 (WS15–WS23) +Each TPx sets duration of a phase. +Longer = cleaner image, slower refresh. +Shorter = faster, but potential ghosting. + +### Step 4 — Repeat Counts & Finalization (WS24–WS33) +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(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 diff --git a/libs/display/EInkDisplay/include/EInkDisplay.h b/libs/display/EInkDisplay/include/EInkDisplay.h index 104632b..16592af 100644 --- a/libs/display/EInkDisplay/include/EInkDisplay.h +++ b/libs/display/EInkDisplay/include/EInkDisplay.h @@ -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; diff --git a/libs/display/EInkDisplay/src/EInkDisplay.cpp b/libs/display/EInkDisplay/src/EInkDisplay.cpp index a99c35a..a58344a 100644 --- a/libs/display/EInkDisplay/src/EInkDisplay.cpp +++ b/libs/display/EInkDisplay/src/EInkDisplay.cpp @@ -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());