# Waveshare 2.9" E-Paper LVGL Project ## Goal Summary - Waveshare 2.9" Touch ePaper HAT driving LVGL UI via ESPHome on ESP32 - 2-bit grayscale display support implemented via custom ESPHome component - Touchscreen integration in progress ## Hardware - ESP32-WROOM-32 (nodemcu-32s board) - Waveshare 2.9inch Touch ePaper HAT (296x128, SSD1680 display, GT1151 touchscreen) ### Pin Mapping | Function | ESP Pin | HAT Pin | |--- |--- |--- | | Display SCLK | GPIO18 | Pin23/GPIO11/SPI0_SCLK | | Display MOSI | GPIO23 | Pin19/GPIO10/SPI0_MOSI | | Display CS | GPIO19 | Pin24/GPIO8/SPI0_CE0 | | Display DC | GPIO17 | Pin22/GPIO25 | | Display BUSY | GPIO4 | Pin24/GPIO24 | | Display RST | GPIO5 | Pin11/GPIO17/SPI1_CE1 | | Touch SDA | GPIO21 | Pin3/GPIO2/I2C1_SDA | | Touch SCL | GPIO22 | Pin5/GPIO3/I2C1_SCL | | Touch RST | GPIO15 | Pin13/GPIO27 | | Touch INT | GPIO16 | Pin15/GPIO22 | Note: GPIO15 (Touch RST) is an ESP32 strapping pin, but only affects boot log output (LOW silences it). No impact on UART download mode or OTA flashing. ## Software - ESPHome 2026.1.0 - Framework: esp-idf - Project directory: ~/Projects/waveshare-panel ## Current Status - 2-bit grayscale fully working via custom component `waveshare_epaper_2bit` - LVGL rendering working, 4-shade colorspace confirmed - Bayer ordered dithering implemented for gradient smoothing - Touchscreen working: polling mode, mirror_x + mirror_y transforms confirmed correct - LVGL touch input and button state working end-to-end - **Active: LVGL layout work** ## Display Refresh Pattern Two-tier refresh wired to `lvgl.on_draw_end` via two `mode: restart` scripts: - **Partial** (`refresh_display`, 60ms): calls `display_partial()` — fast 1-bit, non-blocking, immediate interaction feedback - **Full** (`full_refresh_display`, 10s): calls `component.update: display0` → `display()` — full 2-bit grayscale quality restore after idle 60ms was empirically tuned: 80ms felt sluggish, 40ms intermittently raced. `component.update: display0` only pushes the frame buffer to hardware and does not re-trigger `on_draw_end`. ## Grayscale Implementation Custom component model name: `2.90inv2-r2-2bpp` Key implementation details: - Gray4 waveform LUT loaded via register 0x32 on every display() call - Activation uses 0xC7 (custom LUT), not 0xF7 (which reloads OTP LUT) - `display()` = full 2-bit grayscale refresh; `display_partial()` = fast 1-bit partial refresh - Second bitplane buffer (`buffer2_`) allocated in `initialize()` Confirmed bitplane table for this panel with Gray4 LUT: | 0x24 | 0x26 | Result | |------|------|------------| | 0 | 0 | White | | 1 | 0 | Light grey | | 0 | 1 | Dark grey | | 1 | 1 | Black | Color convention: 0xFFFFFF=white, 0x000000=black (no inversion). LVGL 4-shade palette: 0xFFFFFF, 0xAAAAAA, 0x555555, 0x000000 Dithering: 4×4 Bayer ordered dither, bias = bayer*2 - 15 (±15 range). Amplitude chosen to keep 0xAAAAAA and 0x555555 as solid fills. ## Touchscreen - Controller: ICNT86X (I2C address 0x48; 0x30 also ACKs, likely DFU/bootloader interface) - Custom component: `custom_components/icnt86x/` (ESPHome has no native ICNT86X driver) - Touch count at register 0x1001, touch data at 0x1002 (7 bytes/point), clear by writing 0x00 to 0x1001 - Waveshare's own driver inverts both axes: X = 295 - raw_x, Y = 127 - raw_y - INT pulse is very brief (sub-ms); component works in polling mode regardless - Coordinate transform: mirror_x + mirror_y confirmed correct; swap_xy not needed ## Relevant Files - Custom component: `custom_components/waveshare_epaper_2bit/` - `__init__.py` — CODEOWNERS only - `display.py` — component schema and model registration - `waveshare_epaper_2bit.cpp` — driver implementation - `waveshare_epaper_2bit.h` — header - Main config: `waveshare-test.yaml` (pin table here is source of truth) - Build cache: `.esphome/build/waveshare-epaper-test/src/esphome/components/` - ESPHome source (venv): ~/Code/esphome-2026.1.0/