4.0 KiB
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): callsdisplay_partial()— fast 1-bit, non-blocking, immediate interaction feedback - Full (
full_refresh_display, 10s): callscomponent.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 ininitialize()
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 onlydisplay.py— component schema and model registrationwaveshare_epaper_2bit.cpp— driver implementationwaveshare_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/