Merge remote-tracking branch 'origin/master'

This commit is contained in:
2026-05-10 23:44:01 -07:00
2 changed files with 156 additions and 39 deletions
+20 -5
View File
@@ -35,16 +35,26 @@ output (LOW silences it). No impact on UART download mode or OTA flashing.
- 2-bit grayscale fully working via custom component `waveshare_epaper_2bit` - 2-bit grayscale fully working via custom component `waveshare_epaper_2bit`
- LVGL rendering working, 4-shade colorspace confirmed - LVGL rendering working, 4-shade colorspace confirmed
- Bayer ordered dithering implemented for gradient smoothing - 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 - 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** - **Active: LVGL layout work**
## Display Refresh Pattern ## Display Refresh Pattern
Two-tier refresh wired to `lvgl.on_draw_end` via two `mode: restart` scripts: 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 - **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 ## Grayscale Implementation
Custom component model name: `2.90inv2-r2-2bpp` Custom component model name: `2.90inv2-r2-2bpp`
@@ -75,7 +85,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 - 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 - 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 - 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 ## Relevant Files
- Custom component: `custom_components/waveshare_epaper_2bit/` - Custom component: `custom_components/waveshare_epaper_2bit/`
@@ -85,4 +99,5 @@ Amplitude chosen to keep 0xAAAAAA and 0x555555 as solid fills.
- `waveshare_epaper_2bit.h` — header - `waveshare_epaper_2bit.h` — header
- Main config: `waveshare-test.yaml` (pin table here is source of truth) - Main config: `waveshare-test.yaml` (pin table here is source of truth)
- Build cache: `.esphome/build/waveshare-epaper-test/src/esphome/components/` - 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/
+136 -34
View File
@@ -81,7 +81,7 @@ display:
model: 2.90inv2-r2-2bpp model: 2.90inv2-r2-2bpp
# model: 2.90inv2-r2 # model: 2.90inv2-r2
full_update_every: 30 full_update_every: 30
rotation: 270° rotation: 0°
auto_clear_enabled: false auto_clear_enabled: false
update_interval: never update_interval: never
@@ -101,12 +101,12 @@ touchscreen:
update_interval: 50ms update_interval: 50ms
calibration: calibration:
x_min: 0 x_min: 0
x_max: 295 x_max: 127
y_min: 0 y_min: 0
y_max: 127 y_max: 295
transform: transform:
swap_xy: true
mirror_x: true mirror_x: true
mirror_y: true
### Okay, now the Good Stuff ### ### Okay, now the Good Stuff ###
@@ -151,6 +151,8 @@ image:
id: floor_lamp id: floor_lamp
- file: "mdi:home-off" - file: "mdi:home-off"
id: away_button id: away_button
- file: "mdi:thermometer"
id: light_temp
# When the user taps the screen, introduce a small delay for the LVGL framebuffer # 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 # to stabilize, then do a quick, 1-bit B/W selective update to visually indicate
@@ -162,7 +164,12 @@ script:
- id: refresh_display - id: refresh_display
mode: restart mode: restart
then: 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();' - lambda: 'id(display0).display_partial();'
- id: full_refresh_display - id: full_refresh_display
mode: restart mode: restart
@@ -188,8 +195,8 @@ binary_sensor:
- component.update: display0 - component.update: display0
# List the actual home entitites that we want this panel to control here # List the actual home entitites that we want this panel to control here
- platform: homeassistant - platform: homeassistant
id: goat_summit_light id: alpha_bedroom_light
entity_id: light.goat_summit_light entity_id: light.alpha_bedroom_light
publish_initial_state: true publish_initial_state: true
on_state: on_state:
then: then:
@@ -197,6 +204,38 @@ binary_sensor:
id: button_ceiling_fan_light id: button_ceiling_fan_light
state: state:
checked: !lambda return x; 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 # Here's where we put the actual GUI layout
lvgl: lvgl:
@@ -239,50 +278,74 @@ lvgl:
bg_color: 0xFFFFFF bg_color: 0xFFFFFF
layout: layout:
type: GRID type: GRID
grid_rows: [fr(1), fr(1)] grid_rows: [fr(1), fr(1), fr(1), fr(1)]
grid_columns: [fr(1), fr(1), fr(1), fr(1)] grid_columns: [fr(1), fr(1)]
widgets: widgets:
- button: # Row 1: Light on/off (full width)
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
- button: - button:
id: button_ceiling_fan_light id: button_ceiling_fan_light
grid_cell_row_pos: 0 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: on_click:
- logger.log: "ceiling_fan_light" - logger.log: "ceiling_fan_light"
- homeassistant.action: - homeassistant.action:
action: light.toggle action: light.toggle
data: data:
entity_id: light.goat_summit_light entity_id: light.alpha_bedroom_light
checkable: true checkable: true
widgets: widgets:
- image: - image:
src: ceiling_fan_light src: ceiling_fan_light
align: CENTER
# Row 2: Fan on/off (full width)
- button: - button:
id: button_fan_off id: button_ceiling_fan
grid_cell_row_pos: 0 grid_cell_row_pos: 1
grid_cell_column_pos: 2 grid_cell_column_pos: 0
grid_cell_column_span: 2
grid_cell_x_align: STRETCH
width: 124
on_click: 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 checkable: true
widgets: widgets:
- image: - 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: - button:
id: button_fan_1 id: button_fan_1
grid_cell_row_pos: 0 grid_cell_row_pos: 2
grid_cell_column_pos: 3 grid_cell_column_pos: 1
checkable: true checkable: true
on_click: on_click:
- logger.log: "fan_speed_1" - logger.log: "fan_speed_1"
- homeassistant.action:
action: fan.set_percentage
data:
entity_id: fan.alpha_bedroom_ceiling_fan
percentage: '33'
- lvgl.widget.update: - lvgl.widget.update:
id: button_fan_2 id: button_fan_2
state: state:
@@ -294,30 +357,71 @@ lvgl:
widgets: widgets:
- image: - image:
src: fan_speed_1 src: fan_speed_1
# Row 4: Fan medium | Fan high
- button: - button:
id: button_fan_2 id: button_fan_2
grid_cell_row_pos: 1 grid_cell_row_pos: 3
grid_cell_column_pos: 0 grid_cell_column_pos: 0
on_click: on_click:
- logger.log: "fan_speed_2" - 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 checkable: true
widgets: widgets:
- image: - image:
src: fan_speed_2 src: fan_speed_2
- button: - button:
id: button_fan_3 id: button_fan_3
grid_cell_row_pos: 1 grid_cell_row_pos: 3
grid_cell_column_pos: 1 grid_cell_column_pos: 1
on_click: on_click:
- logger.log: "fan_speed_3" - 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 checkable: true
widgets: widgets:
- image: - image:
src: fan_speed_3 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: - button:
id: button_floor_lamp id: button_floor_lamp
grid_cell_row_pos: 1
grid_cell_column_pos: 2
on_click: on_click:
- logger.log: "floor_lamp" - logger.log: "floor_lamp"
checkable: true checkable: true
@@ -326,8 +430,6 @@ lvgl:
src: floor_lamp src: floor_lamp
- button: - button:
id: button_away id: button_away
grid_cell_row_pos: 1
grid_cell_column_pos: 3
on_click: on_click:
- logger.log: "away_button" - logger.log: "away_button"
checkable: true checkable: true