Skip to content

Instantly share code, notes, and snippets.

@andywarburton
Created April 11, 2026 15:20
Show Gist options
  • Select an option

  • Save andywarburton/42f076288e7828f821cd5985cf06cb2e to your computer and use it in GitHub Desktop.

Select an option

Save andywarburton/42f076288e7828f821cd5985cf06cb2e to your computer and use it in GitHub Desktop.
external_components:
- source: github://n-serrette/esphome_sd_card
components: [sd_mmc_card]
substitutions:
name: waveshare-s3-touch
friendly_name: Waveshare S3 4.3
esphome:
name: "${name}"
includes:
- dirent_wrap.h
platformio_options:
board_build.flash_mode: dio
on_boot:
priority: -100
then:
- script.execute: load_random_background
- lvgl.page.show: main_page
- delay: 5s
- script.execute: sync_light_states
logger:
level: DEBUG
logs:
mpu6050: WARN
api:
encryption:
key: "XXX"
ota:
- platform: esphome
password: "XXX"
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
manual_ip:
static_ip: 192.168.86.92
gateway: 192.168.86.1
subnet: 255.255.255.0
dns1: 192.168.86.1
web_server:
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
flash_size: 16MB
framework:
type: esp-idf
advanced:
include_builtin_idf_components: ["fatfs", "spiffs"]
disable_vfs_support_termios: false
disable_vfs_support_select: false
disable_vfs_support_dir: false
sdkconfig_options:
CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240: "y"
CONFIG_ESP32S3_DATA_CACHE_64KB: "y"
CONFIG_SPIRAM_FETCH_INSTRUCTIONS: y
CONFIG_SPIRAM_RODATA: y
CONFIG_ESPTOOLPY_FLASHSIZE_16MB: "y"
CONFIG_FATFS_LFN_STACK: "y"
partitions: partitions_16mb_large.csv
ch422g:
- id: ch422g_hub
switch:
- platform: gpio
id: sd_cs
internal: true
pin:
ch422g: ch422g_hub
number: 4
mode:
output: true
inverted: false
restore_mode: ALWAYS_ON # Hold CS HIGH (deasserted) so SD card stays in SD/MMC mode
- platform: gpio
name: backlight
id: display_backlight
pin:
ch422g: ch422g_hub
number: 2
mode:
output: true
inverted: False
restore_mode: ALWAYS_ON
psram:
mode: octal
speed: 80MHz
i2c:
sda: 8
scl: 9
# SD card
sd_mmc_card:
id: sd_card
clk_pin: GPIO12
cmd_pin: GPIO11
data0_pin: GPIO13
mode_1bit: true
touchscreen:
- platform: gt911
id: my_touchscreen
interrupt_pin: 4
reset_pin:
ch422g:
number: 1
on_release:
- script.execute: wake_screen
display:
- platform: rpi_dpi_rgb
id: rpi_disp
update_interval: never
auto_clear_enabled: false
color_order: RGB
pclk_frequency: 16MHz
dimensions:
width: 800
height: 480
reset_pin:
ch422g:
number: 3
enable_pin:
ch422g:
number: 2
de_pin:
number: 5
hsync_pin:
number: 46
ignore_strapping_warning: true
vsync_pin:
number: 3
ignore_strapping_warning: true
pclk_pin: 7
hsync_back_porch: 30
hsync_front_porch: 210
hsync_pulse_width: 30
vsync_back_porch: 4
vsync_front_porch: 4
vsync_pulse_width: 4
data_pins:
red: [1, 2, 42, 41, 40]
blue: [14, 38, 18, 17, 10]
green: [39, 0, 45, 48, 47, 21]
number:
- platform: template
name: "Temperature Offset"
id: temperature_offset_ui
unit_of_measurement: "°C"
min_value: -20
max_value: 20
step: 0.1
mode: box
update_interval: never
optimistic: true
restore_value: true
initial_value: 0
icon: "mdi:thermometer"
entity_category: config
on_value:
- lambda: 'id(mpu6050_sensor).update();'
- platform: template
name: LVGL Screen timeout
optimistic: true
id: display_timeout
unit_of_measurement: "s"
initial_value: 60
restore_value: true
min_value: 60
max_value: 600
step: 5
mode: box
globals:
- id: bg_buffer
type: uint8_t*
restore_value: no
initial_value: 'nullptr'
- id: clock_manually_shown
type: bool
restore_value: no
initial_value: 'false'
script:
# Loads a random raw RGB565 binary (800×480×2 bytes) from /images on the SD card.
# Create images with: ffmpeg -i input.jpg -vf scale=800:480 -f rawvideo -pix_fmt rgb565le output.bin
- id: load_random_background
then:
- lambda: |-
static lv_img_dsc_t img_dsc = {};
// Allocate permanent PSRAM buffer once (800×480 RGB565 = 768 KB)
if (id(bg_buffer) == nullptr) {
id(bg_buffer) = (uint8_t*) heap_caps_malloc(800 * 480 * 2, MALLOC_CAP_SPIRAM);
if (id(bg_buffer) == nullptr) {
ESP_LOGE("bg", "PSRAM allocation failed");
return;
}
}
// List files in /sdcard/images
DIR* dir = opendir("/sdcard/images");
if (!dir) {
ESP_LOGW("bg", "Cannot open /sdcard/images — create this folder on the SD card");
return;
}
std::vector<std::string> files;
struct dirent* ent;
while ((ent = readdir(dir)) != nullptr) {
if (ent->d_type == DT_REG) {
files.push_back(std::string("/sdcard/images/") + ent->d_name);
}
}
closedir(dir);
if (files.empty()) {
ESP_LOGW("bg", "No files found in /sdcard/images");
return;
}
// Pick a random file
std::string path = files[esp_random() % files.size()];
ESP_LOGI("bg", "Loading background: %s", path.c_str());
// Read directly into PSRAM buffer — avoids large heap allocation
FILE* f = fopen(path.c_str(), "rb");
if (!f) {
ESP_LOGE("bg", "Cannot open %s", path.c_str());
return;
}
size_t n = fread(id(bg_buffer), 1, 800 * 480 * 2, f);
fclose(f);
if (n != 800 * 480 * 2) {
ESP_LOGE("bg", "Read %d bytes, expected %d — wrong image size?", (int)n, 800*480*2);
return;
}
// Swap bytes to match display colour order (RGB565 big-endian)
uint16_t* pixels = (uint16_t*) id(bg_buffer);
for (int i = 0; i < 800 * 480; i++) {
pixels[i] = (pixels[i] >> 8) | (pixels[i] << 8);
}
// Configure LVGL image descriptor pointing at our PSRAM buffer
img_dsc.header.cf = LV_IMG_CF_TRUE_COLOR;
img_dsc.header.always_zero = 0;
img_dsc.header.w = 800;
img_dsc.header.h = 480;
img_dsc.data_size = 800 * 480 * 2;
img_dsc.data = id(bg_buffer);
// Apply as background of the LVGL page
lv_obj_set_style_bg_img_src(id(main_page)->obj, &img_dsc, LV_PART_MAIN);
lv_obj_set_style_bg_img_opa(id(main_page)->obj, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_invalidate(id(main_page)->obj);
# Syncs all light button colours to current HA state.
- id: sync_light_states
then:
- lambda: |-
auto update = [](lv_obj_t* btn, lv_obj_t* lbl, bool on) {
lv_obj_set_style_bg_color(btn, on ? lv_color_hex(0xFFAA00) : lv_color_hex(0xFFFFFF), LV_PART_MAIN);
lv_label_set_text(lbl, on ? "ON" : "OFF");
};
update(id(hall_btn), id(hall_state_lbl), id(light_hall_state).state);
update(id(kitchen_btn), id(kitchen_state_lbl), id(light_kitchen_state).state);
update(id(living_room_btn), id(living_room_state_lbl), id(light_living_room_state).state);
update(id(extension_btn), id(extension_state_lbl), id(light_extension_state).state);
update(id(toilet_btn), id(toilet_state_lbl), id(light_toilet_state).state);
update(id(dining_room_btn), id(dining_room_state_lbl), id(light_dining_room_state).state);
# Turns off the screen after 10 min of screensaver.
- id: screen_off_timer
mode: restart
then:
- delay: 10min
- logger.log: "Screen off after screensaver timeout"
- switch.turn_off: display_backlight
- lvgl.pause:
# Wakes the screen: loads a fresh background then resumes LVGL.
# mode: single prevents simultaneous triggers from multiple accel axes.
- id: wake_screen
mode: single
then:
- if:
condition: lvgl.is_paused
then:
- script.stop: screen_off_timer
- script.execute: load_random_background
- lvgl.resume:
- lvgl.widget.redraw:
- switch.turn_on: display_backlight
- globals.set:
id: clock_manually_shown
value: 'false'
- lvgl.widget.show: main_content
- lvgl.widget.hide: big_time_label
- script.execute: sync_light_states
else:
- if:
condition:
lambda: "return id(clock_manually_shown);"
then:
- script.stop: screen_off_timer
- script.execute: load_random_background
- globals.set:
id: clock_manually_shown
value: 'false'
- lvgl.widget.show: main_content
- lvgl.widget.hide: big_time_label
- script.execute: sync_light_states
time:
- platform: homeassistant
id: ha_time
on_time_sync:
then:
- lvgl.label.update:
id: time_label
text: !lambda |-
char buf[8];
auto t = id(ha_time).now();
snprintf(buf, sizeof(buf), "%02d:%02d", t.hour, t.minute);
return std::string(buf);
- lvgl.label.update:
id: big_time_label
text: !lambda |-
char buf[8];
auto t = id(ha_time).now();
snprintf(buf, sizeof(buf), "%02d:%02d", t.hour, t.minute);
return std::string(buf);
on_time:
- seconds: 0
then:
- lvgl.label.update:
id: time_label
text: !lambda |-
char buf[8];
auto t = id(ha_time).now();
snprintf(buf, sizeof(buf), "%02d:%02d", t.hour, t.minute);
return std::string(buf);
- lvgl.label.update:
id: big_time_label
text: !lambda |-
char buf[8];
auto t = id(ha_time).now();
snprintf(buf, sizeof(buf), "%02d:%02d", t.hour, t.minute);
return std::string(buf);
interval:
- interval: 1min
then:
- if:
condition:
lambda: "return id(clock_manually_shown);"
then:
- script.execute: load_random_background
font:
- file: "gfonts://Noto+Sans"
id: roboto_24
size: 24
glyphs: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.!: °%"
- file: "gfonts://Noto+Sans@700"
id: roboto_bold_24
size: 24
glyphs: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.!: °%"
- file: "gfonts://Noto+Sans"
id: roboto_18
size: 18
glyphs: "OFN"
- file: "gfonts://Noto+Sans"
id: noto_96
size: 96
glyphs: "0123456789:"
- file: "gfonts://Noto+Sans@700"
id: noto_240
size: 240
glyphs: "0123456789:-"
- file: "gfonts://Noto+Sans@700"
id: noto_300
size: 300
glyphs: "0123456789:"
sensor:
- platform: mpu6050
id: mpu6050_sensor
address: 0x68
update_interval: 200ms
temperature:
name: "Temperature"
id: mpu_temperature
unit_of_measurement: "°C"
accuracy_decimals: 1
filters:
- throttle_average: 60s
- lambda: "return x + id(temperature_offset_ui).state;"
accel_x:
name: "Accel X"
internal: true
filters:
- delta: 0.5
on_value:
then:
- script.execute: wake_screen
accel_y:
name: "Accel Y"
internal: true
filters:
- delta: 0.5
on_value:
then:
- script.execute: wake_screen
accel_z:
name: "Accel Z"
internal: true
filters:
- delta: 0.5
on_value:
then:
- script.execute: wake_screen
- platform: homeassistant
id: ha_temperature
entity_id: sensor.average_home_temperature
on_value:
- lvgl.label.update:
id: temp_label
text: !lambda |-
char buf[20];
snprintf(buf, sizeof(buf), "%.1f °C", x);
return std::string(buf);
- platform: homeassistant
id: ha_humidity
entity_id: sensor.average_home_humidity
on_value:
- lvgl.label.update:
id: humidity_label
text: !lambda |-
char buf[20];
snprintf(buf, sizeof(buf), "%.0f%%", x);
return std::string(buf);
binary_sensor:
- platform: homeassistant
id: light_hall_state
entity_id: light.nabu_casa_skyconnect_v1_0_hall
on_state:
- if:
condition:
lambda: "return x;"
then:
- lvgl.widget.update:
id: hall_btn
bg_color: 0xFFAA00
- lvgl.label.update:
id: hall_state_lbl
text: "ON"
else:
- lvgl.widget.update:
id: hall_btn
bg_color: 0xFFFFFF
- lvgl.label.update:
id: hall_state_lbl
text: "OFF"
- platform: homeassistant
id: light_kitchen_state
entity_id: light.nabu_casa_skyconnect_v1_0_kitchen
on_state:
- if:
condition:
lambda: "return x;"
then:
- lvgl.widget.update:
id: kitchen_btn
bg_color: 0xFFAA00
- lvgl.label.update:
id: kitchen_state_lbl
text: "ON"
else:
- lvgl.widget.update:
id: kitchen_btn
bg_color: 0xFFFFFF
- lvgl.label.update:
id: kitchen_state_lbl
text: "OFF"
- platform: homeassistant
id: light_living_room_state
entity_id: light.living_room_lights
on_state:
- if:
condition:
lambda: "return x;"
then:
- lvgl.widget.update:
id: living_room_btn
bg_color: 0xFFAA00
- lvgl.label.update:
id: living_room_state_lbl
text: "ON"
else:
- lvgl.widget.update:
id: living_room_btn
bg_color: 0xFFFFFF
- lvgl.label.update:
id: living_room_state_lbl
text: "OFF"
- platform: homeassistant
id: light_extension_state
entity_id: light.nabu_casa_skyconnect_v1_0_extension
on_state:
- if:
condition:
lambda: "return x;"
then:
- lvgl.widget.update:
id: extension_btn
bg_color: 0xFFAA00
- lvgl.label.update:
id: extension_state_lbl
text: "ON"
else:
- lvgl.widget.update:
id: extension_btn
bg_color: 0xFFFFFF
- lvgl.label.update:
id: extension_state_lbl
text: "OFF"
- platform: homeassistant
id: light_toilet_state
entity_id: light.toilet_bulb_light
on_state:
- if:
condition:
lambda: "return x;"
then:
- lvgl.widget.update:
id: toilet_btn
bg_color: 0xFFAA00
- lvgl.label.update:
id: toilet_state_lbl
text: "ON"
else:
- lvgl.widget.update:
id: toilet_btn
bg_color: 0xFFFFFF
- lvgl.label.update:
id: toilet_state_lbl
text: "OFF"
- platform: homeassistant
id: light_dining_room_state
entity_id: light.dining_room_bulb_light
on_state:
- if:
condition:
lambda: "return x;"
then:
- lvgl.widget.update:
id: dining_room_btn
bg_color: 0xFFAA00
- lvgl.label.update:
id: dining_room_state_lbl
text: "ON"
else:
- lvgl.widget.update:
id: dining_room_btn
bg_color: 0xFFFFFF
- lvgl.label.update:
id: dining_room_state_lbl
text: "OFF"
lvgl:
on_idle:
timeout: 2min
then:
- logger.log: "Screensaver activated"
- globals.set:
id: clock_manually_shown
value: 'true'
- lvgl.widget.hide: main_content
- lvgl.widget.show: big_time_label
- script.execute: screen_off_timer
pages:
- id: main_page
bg_color: 0x000000
widgets:
# ── Main content (hidden in clock/screensaver mode) ──────────────────
- obj:
id: main_content
x: 0
y: 0
width: 800
height: 480
bg_opa: 0%
border_width: 0
pad_all: 0
scrollable: false
widgets:
# ── Header bar: temp + humidity (left) | time + charge (right)
- obj:
x: 0
y: 0
width: 800
height: 60
bg_color: 0x111111
bg_opa: 82%
border_width: 0
radius: 0
pad_all: 0
widgets:
- label:
id: temp_label
x: 20
y: 14
text: "-- °C"
text_font: roboto_24
text_color: 0xFFFFFF
- label:
id: humidity_label
x: 185
y: 14
text: "--%"
text_font: roboto_24
text_color: 0xFFFFFF
- label:
id: time_label
align: RIGHT_MID
x: -25
y: 0
text: "--:--"
text_font: roboto_24
text_color: 0xFFFFFF
# ── Blind column (left) ─────────────────────────────────────────
- button:
id: blind_up_btn
x: 20
y: 95
width: 180
height: 100
bg_color: 0x000000
bg_opa: 40%
border_color: 0x000000
border_width: 2
radius: 15
shadow_color: 0x000000
shadow_width: 8
shadow_ofs_x: 2
shadow_ofs_y: 4
shadow_opa: 60%
on_click:
- homeassistant.service:
service: cover.open_cover
data:
entity_id: cover.blind_button_basher_living_room_blinds
widgets:
- label:
text: "UP"
align: CENTER
text_font: roboto_bold_24
text_color: 0xFFFFFF
- button:
id: blind_stop_btn
x: 20
y: 215
width: 180
height: 100
bg_color: 0x000000
bg_opa: 40%
border_color: 0x000000
border_width: 2
radius: 15
shadow_color: 0x000000
shadow_width: 8
shadow_ofs_x: 2
shadow_ofs_y: 4
shadow_opa: 60%
on_click:
- homeassistant.service:
service: cover.stop_cover
data:
entity_id: cover.blind_button_basher_living_room_blinds
widgets:
- label:
text: "STOP"
align: CENTER
text_font: roboto_bold_24
text_color: 0xFFFFFF
- button:
id: blind_down_btn
x: 20
y: 335
width: 180
height: 100
bg_color: 0x000000
bg_opa: 40%
border_color: 0x000000
border_width: 2
radius: 15
shadow_color: 0x000000
shadow_width: 8
shadow_ofs_x: 2
shadow_ofs_y: 4
shadow_opa: 60%
on_click:
- homeassistant.service:
service: cover.close_cover
data:
entity_id: cover.blind_button_basher_living_room_blinds
widgets:
- label:
text: "DOWN"
align: CENTER
text_font: roboto_bold_24
text_color: 0xFFFFFF
# ── Light buttons: two columns (right) ──────────────────────────
- button:
id: hall_btn
x: 275
y: 95
width: 245
height: 105
bg_color: 0xFFFFFF
bg_opa: 30%
border_color: 0xFFFFFF
border_width: 2
radius: 15
shadow_color: 0x000000
shadow_width: 8
shadow_ofs_x: 2
shadow_ofs_y: 4
shadow_opa: 60%
on_click:
- homeassistant.service:
service: light.toggle
data:
entity_id: light.nabu_casa_skyconnect_v1_0_hall
widgets:
- label:
text: "HALL"
x: 12
y: 12
text_font: roboto_bold_24
text_color: 0x000000
- label:
id: hall_state_lbl
text: "OFF"
x: 12
y: 40
text_font: roboto_18
text_color: 0x000000
- button:
id: kitchen_btn
x: 535
y: 95
width: 245
height: 105
bg_color: 0xFFFFFF
bg_opa: 30%
border_color: 0xFFFFFF
border_width: 2
radius: 15
shadow_color: 0x000000
shadow_width: 8
shadow_ofs_x: 2
shadow_ofs_y: 4
shadow_opa: 60%
on_click:
- homeassistant.service:
service: light.toggle
data:
entity_id: light.nabu_casa_skyconnect_v1_0_kitchen
widgets:
- label:
text: "KITCHEN"
x: 12
y: 12
text_font: roboto_bold_24
text_color: 0x000000
- label:
id: kitchen_state_lbl
text: "OFF"
x: 12
y: 40
text_font: roboto_18
text_color: 0x000000
- button:
id: living_room_btn
x: 275
y: 215
width: 245
height: 105
bg_color: 0xFFFFFF
bg_opa: 30%
border_color: 0xFFFFFF
border_width: 2
radius: 15
shadow_color: 0x000000
shadow_width: 8
shadow_ofs_x: 2
shadow_ofs_y: 4
shadow_opa: 60%
on_click:
- homeassistant.service:
service: light.toggle
data:
entity_id: light.living_room_lights
widgets:
- label:
text: "LIVING ROOM"
x: 12
y: 12
text_font: roboto_bold_24
text_color: 0x000000
- label:
id: living_room_state_lbl
text: "OFF"
x: 12
y: 40
text_font: roboto_18
text_color: 0x000000
- button:
id: extension_btn
x: 535
y: 215
width: 245
height: 105
bg_color: 0xFFFFFF
bg_opa: 30%
border_color: 0xFFFFFF
border_width: 2
radius: 15
shadow_color: 0x000000
shadow_width: 8
shadow_ofs_x: 2
shadow_ofs_y: 4
shadow_opa: 60%
on_click:
- homeassistant.service:
service: light.toggle
data:
entity_id: light.nabu_casa_skyconnect_v1_0_extension
widgets:
- label:
text: "EXTENSION"
x: 12
y: 12
text_font: roboto_bold_24
text_color: 0x000000
- label:
id: extension_state_lbl
text: "OFF"
x: 12
y: 40
text_font: roboto_18
text_color: 0x000000
- button:
id: toilet_btn
x: 275
y: 335
width: 245
height: 105
bg_color: 0xFFFFFF
bg_opa: 30%
border_color: 0xFFFFFF
border_width: 2
radius: 15
shadow_color: 0x000000
shadow_width: 8
shadow_ofs_x: 2
shadow_ofs_y: 4
shadow_opa: 60%
on_click:
- homeassistant.service:
service: light.toggle
data:
entity_id: light.toilet_bulb_light
widgets:
- label:
text: "TOILET"
x: 12
y: 12
text_font: roboto_bold_24
text_color: 0x000000
- label:
id: toilet_state_lbl
text: "OFF"
x: 12
y: 40
text_font: roboto_18
text_color: 0x000000
- button:
id: dining_room_btn
x: 535
y: 335
width: 245
height: 105
bg_color: 0xFFFFFF
bg_opa: 30%
border_color: 0xFFFFFF
border_width: 2
radius: 15
shadow_color: 0x000000
shadow_width: 8
shadow_ofs_x: 2
shadow_ofs_y: 4
shadow_opa: 60%
on_click:
- homeassistant.service:
service: light.toggle
data:
entity_id: light.dining_room_bulb_light
widgets:
- label:
text: "DINING ROOM"
x: 12
y: 12
text_font: roboto_bold_24
text_color: 0x000000
- label:
id: dining_room_state_lbl
text: "OFF"
x: 12
y: 40
text_font: roboto_18
text_color: 0x000000
# ── Big clock (screensaver/clock mode) ──────────────────────────────
- label:
id: big_time_label
text: "--:--"
align: BOTTOM_RIGHT
x: 30
y: 90
text_font: noto_300
text_color: 0xFFFFFF
text_opa: 80%
hidden: true
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment