Files
waveshare-panel/CLAUDE.md
T
Christopher Wiebe 7ada8759bd Document the partial-refresh retry pattern in CLAUDE.md
Reflect the bumped 80ms tap-feedback delay and the 350ms retry that
catches HA state syncs landing in the e-paper's busy window.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 18:53:23 -07:00

5.0 KiB
Raw Blame History

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, portrait-orientation transform confirmed correct
  • LVGL touch input and button state working end-to-end
  • Panel now mounted vertically (portrait); 4-row layout for light/fan controls
  • Active: LVGL layout work

Display Refresh Pattern

Two-tier refresh wired to lvgl.on_draw_end via two mode: restart scripts:

  • Partial (refresh_display, 80ms then a 350ms retry): calls display_partial() — fast 1-bit, non-blocking, immediate interaction feedback
  • Full (full_refresh_display, 10s): calls component.update: display0display() — full 2-bit grayscale quality restore after idle

The retry exists because display_partial() silently drops calls when the e-paper is still busy from a previous refresh (waveshare_epaper_2bit.cpp ~line 2003). Without it, HA-triggered widget updates (e.g. fan speed sync arriving 200-500ms after a tap) could land during the busy window of the tap's partial refresh and be lost until the 10s full refresh. The retry fires ~430ms after the last draw, by which point the e-paper's ~300ms partial cycle has cleared. mode: restart cancels the retry if a new draw arrives, so it only fires in the quiet period.

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 (portrait, display rotation 0°): swap_xy: true, mirror_x: true, calibration x_max: 127, y_max: 295. Gotcha: ESPHome's base class applies swap_xy before calibration normalization (see touchscreen.cpp::add_raw_touch_position_), so calibration x_max/y_max must describe the post-swap range — i.e., match the display width/height, not the raw sensor's native axes.

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 venv (Windows): C:\Projects\python_virtual\esphome-2026.1.0 (activate before esphome commands)
  • ESPHome source (WSL venv): ~/Code/esphome-2026.1.0/