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

104 lines
5.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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: display0``display()` — 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/