From 80378b1d950f383330a5732ccec1e89cbc274892 Mon Sep 17 00:00:00 2001 From: Christopher Wiebe Date: Sun, 10 May 2026 18:00:10 -0700 Subject: [PATCH 1/3] Portrait UI redesign + HA wire-ups for bedroom fan/light MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rotate display to portrait (rotation: 0°), 4-row LVGL grid - Fix touchscreen calibration/transform for portrait orientation - Wire buttons to alpha_bedroom_{light,ceiling_fan,color_temp} - Sync button checked state from HA (light/fan on-off + fan percentage) - Document new orientation and Windows venv path in CLAUDE.md Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 12 +++- waveshare-test.yaml | 163 +++++++++++++++++++++++++++++++++++--------- 2 files changed, 139 insertions(+), 36 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 44d8978..6610530 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,8 +35,9 @@ output (LOW silences it). No impact on UART download mode or OTA flashing. - 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 +- 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 @@ -75,7 +76,11 @@ Amplitude chosen to keep 0xAAAAAA and 0x555555 as solid fills. - 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 +- 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/` @@ -85,4 +90,5 @@ Amplitude chosen to keep 0xAAAAAA and 0x555555 as solid fills. - `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/ +- ESPHome venv (Windows): `C:\Projects\python_virtual\esphome-2026.1.0` (activate before `esphome` commands) +- ESPHome source (WSL venv): ~/Code/esphome-2026.1.0/ diff --git a/waveshare-test.yaml b/waveshare-test.yaml index 584fe7a..708353d 100644 --- a/waveshare-test.yaml +++ b/waveshare-test.yaml @@ -76,7 +76,7 @@ display: model: 2.90inv2-r2-2bpp # model: 2.90inv2-r2 full_update_every: 30 - rotation: 270° + rotation: 0° auto_clear_enabled: false update_interval: never @@ -96,12 +96,12 @@ touchscreen: update_interval: 50ms calibration: x_min: 0 - x_max: 295 + x_max: 127 y_min: 0 - y_max: 127 + y_max: 295 transform: + swap_xy: true mirror_x: true - mirror_y: true ### Okay, now the Good Stuff ### @@ -146,6 +146,8 @@ image: id: floor_lamp - file: "mdi:home-off" id: away_button + - file: "mdi:thermometer" + id: light_temp # When the user taps the screen, introduce a small delay for the LVGL framebuffer # to stabilize, then do a quick, 1-bit B/W selective update to visually indicate @@ -183,8 +185,8 @@ binary_sensor: - component.update: display0 # List the actual home entitites that we want this panel to control here - platform: homeassistant - id: goat_summit_light - entity_id: light.goat_summit_light + id: alpha_bedroom_light + entity_id: light.alpha_bedroom_light publish_initial_state: true on_state: then: @@ -192,6 +194,38 @@ binary_sensor: id: button_ceiling_fan_light state: checked: !lambda return x; + - platform: homeassistant + id: alpha_bedroom_ceiling_fan + entity_id: fan.alpha_bedroom_ceiling_fan + publish_initial_state: true + on_state: + then: + - lvgl.widget.update: + id: button_ceiling_fan + state: + checked: !lambda return x; + +sensor: + # Track the fan's current percentage so the speed buttons reflect the + # actual HA state (including changes made via other controls). + - platform: homeassistant + id: alpha_bedroom_ceiling_fan_pct + entity_id: fan.alpha_bedroom_ceiling_fan + attribute: percentage + on_value: + then: + - lvgl.widget.update: + id: button_fan_1 + state: + checked: !lambda 'return x >= 1 && x < 50;' + - lvgl.widget.update: + id: button_fan_2 + state: + checked: !lambda 'return x >= 50 && x < 84;' + - lvgl.widget.update: + id: button_fan_3 + state: + checked: !lambda 'return x >= 84;' # Here's where we put the actual GUI layout lvgl: @@ -234,50 +268,74 @@ lvgl: bg_color: 0xFFFFFF layout: type: GRID - grid_rows: [fr(1), fr(1)] - grid_columns: [fr(1), fr(1), fr(1), fr(1)] + grid_rows: [fr(1), fr(1), fr(1), fr(1)] + grid_columns: [fr(1), fr(1)] widgets: - - button: - id: button_ceiling_fan - grid_cell_row_pos: 0 - grid_cell_column_pos: 0 - on_click: - - logger.log: "ceiling_fan" - widgets: - - image: - src: ceiling_fan - align: CENTER + # Row 1: Light on/off (full width) - button: id: button_ceiling_fan_light grid_cell_row_pos: 0 - grid_cell_column_pos: 1 + grid_cell_column_pos: 0 + grid_cell_column_span: 2 + grid_cell_x_align: STRETCH + width: 124 on_click: - logger.log: "ceiling_fan_light" - homeassistant.action: action: light.toggle data: - entity_id: light.goat_summit_light + entity_id: light.alpha_bedroom_light checkable: true widgets: - image: src: ceiling_fan_light + align: CENTER + # Row 2: Fan on/off (full width) - button: - id: button_fan_off - grid_cell_row_pos: 0 - grid_cell_column_pos: 2 + id: button_ceiling_fan + grid_cell_row_pos: 1 + grid_cell_column_pos: 0 + grid_cell_column_span: 2 + grid_cell_x_align: STRETCH + width: 124 on_click: - - logger.log: "fan_off" + - logger.log: "ceiling_fan" + - homeassistant.action: + action: fan.toggle + data: + entity_id: fan.alpha_bedroom_ceiling_fan checkable: true widgets: - image: - src: fan_off + src: ceiling_fan + align: CENTER + # Row 3: Light temperature | Fan low + - button: + id: button_light_temp + grid_cell_row_pos: 2 + grid_cell_column_pos: 0 + on_click: + - logger.log: "light_temp" + - homeassistant.action: + action: button.press + data: + entity_id: button.alpha_bedroom_color_temp + widgets: + - image: + src: light_temp + align: CENTER - button: id: button_fan_1 - grid_cell_row_pos: 0 - grid_cell_column_pos: 3 + grid_cell_row_pos: 2 + grid_cell_column_pos: 1 checkable: true on_click: - logger.log: "fan_speed_1" + - homeassistant.action: + action: fan.set_percentage + data: + entity_id: fan.alpha_bedroom_ceiling_fan + percentage: '33' - lvgl.widget.update: id: button_fan_2 state: @@ -289,30 +347,71 @@ lvgl: widgets: - image: src: fan_speed_1 + # Row 4: Fan medium | Fan high - button: id: button_fan_2 - grid_cell_row_pos: 1 + grid_cell_row_pos: 3 grid_cell_column_pos: 0 on_click: - logger.log: "fan_speed_2" + - homeassistant.action: + action: fan.set_percentage + data: + entity_id: fan.alpha_bedroom_ceiling_fan + percentage: '66' + - lvgl.widget.update: + id: button_fan_1 + state: + checked: false + - lvgl.widget.update: + id: button_fan_3 + state: + checked: false checkable: true widgets: - image: src: fan_speed_2 - button: id: button_fan_3 - grid_cell_row_pos: 1 + grid_cell_row_pos: 3 grid_cell_column_pos: 1 on_click: - logger.log: "fan_speed_3" + - homeassistant.action: + action: fan.set_percentage + data: + entity_id: fan.alpha_bedroom_ceiling_fan + percentage: '100' + - lvgl.widget.update: + id: button_fan_1 + state: + checked: false + - lvgl.widget.update: + id: button_fan_2 + state: + checked: false checkable: true widgets: - image: src: fan_speed_3 + # Unused buttons — kept defined but hidden for future use + - obj: + hidden: true + width: 1 + height: 1 + border_width: 0 + pad_all: 0 + widgets: + - button: + id: button_fan_off + on_click: + - logger.log: "fan_off" + checkable: true + widgets: + - image: + src: fan_off - button: id: button_floor_lamp - grid_cell_row_pos: 1 - grid_cell_column_pos: 2 on_click: - logger.log: "floor_lamp" checkable: true @@ -321,8 +420,6 @@ lvgl: src: floor_lamp - button: id: button_away - grid_cell_row_pos: 1 - grid_cell_column_pos: 3 on_click: - logger.log: "away_button" checkable: true From 7295048c32c65d4c9c94d68a86d4bd2a276a8f6e Mon Sep 17 00:00:00 2001 From: Christopher Wiebe Date: Sun, 10 May 2026 18:52:34 -0700 Subject: [PATCH 2/3] Bump partial refresh to 80ms, add retry to catch HA state syncs display_partial() drops calls when the e-paper is still busy, which silently lost HA-triggered widget updates (e.g. fan speed) that landed during the previous refresh's busy window. The retry fires once the busy window has cleared so the state-synced state makes it to the panel. Co-Authored-By: Claude Opus 4.7 --- waveshare-test.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/waveshare-test.yaml b/waveshare-test.yaml index 708353d..e53677f 100644 --- a/waveshare-test.yaml +++ b/waveshare-test.yaml @@ -159,7 +159,12 @@ script: - id: refresh_display mode: restart then: - - delay: 60ms + - delay: 80ms + - lambda: 'id(display0).display_partial();' + # Retry once the e-paper's busy window has cleared, to catch HA-synced + # widget updates that landed while the first partial refresh was in flight + # (display_partial drops calls when busy). + - delay: 350ms - lambda: 'id(display0).display_partial();' - id: full_refresh_display mode: restart From 7ada8759bdfcf21f4503c84df31c5fd30c22a3b2 Mon Sep 17 00:00:00 2001 From: Christopher Wiebe Date: Sun, 10 May 2026 18:53:23 -0700 Subject: [PATCH 3/3] 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 --- CLAUDE.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6610530..9c04d81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,10 +42,19 @@ output (LOW silences it). No impact on UART download mode or OTA flashing. ## 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 +- **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 -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`. +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`