# 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/
