Bedroom Climate Controller
✅ CompletedA compact climate controller with round LCD display and capacitive touch buttons for monitoring weather, controlling heating, and displaying indoor conditions
Required Devices
Adafruit NeoPixel
Addressable RGB LED strips and modules with WS2812/WS2812B chips
lightBeetle ESP32-C6
DFRobot Beetle ESP32-C6 Development Board
board1.28" Round TFT LCD Display
Round RGB TFT LCD display (240x240) with GC9A01 driver
displayTouch pHAT
Touch pHAT with 6 touch pads and 6 LEDs by Pimoroni
inputUsed Components (18)
Native API
api Communication protocol for connecting ESPHome to Home Assistant and other clients
MIPI SPI Display
mipi_spi Display driver for MIPI-compliant SPI displays like ST7789V
External Components
external_components Load custom or third-party ESPHome components
Global Variables
globals Store persistent values that can be accessed across automations
CAP1166
cap1166 Capacitive touch controller for 6-channel touch inputs with LED control
Font
font Load and use TrueType fonts for display text rendering
Monochromatic Light
monochromatic Single-channel dimmable light control
Image
image Display bitmap images on screens and displays
I2C Bus
i2c Inter-Integrated Circuit communication bus for connecting multiple devices
Template Number
template Create custom number entities with computed or user-adjustable values
Over-The-Air Updates (OTA)
ota Update ESPHome firmware wirelessly over the network
LEDC Output
ledc PWM output using ESP32's LED Control peripheral
Home Assistant Sensor
homeassistant Import sensor values from Home Assistant entities
Script
script Create reusable automation sequences
SPI Bus
spi Serial Peripheral Interface communication bus for high-speed device connections
SNTP Time
sntp Synchronize time using Simple Network Time Protocol
Home Assistant Text Sensor
homeassistant Import text and string values from Home Assistant entities
WiFi
wifi Configure WiFi connectivity for ESP devices
Project Overview

This advanced project creates a compact bedroom climate controller with a round color display and capacitive touch interface that provides:
- 🕐 Real-time clock display with date
- 🌤️ Weather information (current temperature, forecast icon, and description)
- 🌡️ Indoor climate monitoring (temperature and humidity)
- 🔥 Thermostat control with multiple preset modes (frost, eco, comfort, boost)
- 🎯 Fine-grained temperature adjustment for boost mode
- 🚗 EV battery level monitoring
- 💡 Interactive LED feedback on touch buttons
- 📄 Multi-page interface with heating control page
- 🎨 Context-aware heating status visualization
- 🌈 Status indicator (and mood light?) with NeoPixel LED
This project is for a quick overview of important info on one page and then managing heating in the room. When you get up you can quickly see time/date, outside temperature, forecast and EV battery level (super important, right?).
On the other page are details of the temperature and humidity in the room and control of heating presets. One of the presets can also be modified to set a different temperature.
The project is complete in terms of hardware. The rest are software improvements.
Progress
- ✅ Main Configuration - fully functional
- ✅ 3D Enclosure
- Further improvements
- ✅ Display Auto Sleep
- Add Colors
- ✅ Use NeoPixel for notifications
- Use LEDs on the touch board for quick status
Future improvement ideas
- Added dimmed start-up as a quick fix for the device rebooting at night
- It’s good to dim the display at night time for example 🌙😴 so maybe make it configurable
- Add Colors
- This was ported from Mono OLED so everything is monochromatic - it looks pretty but why not add some colors to our lives 😉
- Use LEDs on the touch board for quick status
- EV Battery level? Or something else? Just throwing out ideas
- Adding more pages?
Reusability Note
Substitutions are put at the top of the config file, so replacing them for your own sensor entity IDs and climate IDs will get it working for you. Some extra work needed if not all the same component used. Some of the parts are obsolete (Touch Phat) so a different options might be required.
What You’ll Need
Hardware
- 1x DFRobot Beetle ESP32-C6 - Compact ESP32-C6 development board
- 1x GC9A01 based Round LCD - SPI Color Round Display breakout
- 1x Pimoroni Touch pHAT - 6 capacitive touch buttons with RGB LEDs (I2C)
- 1x Adafruit NeoPixel - RGB LED for ambient/notification
- Jumper wires for connections (for testing it)
- USB-C cable (data capable)
- Power supply (USB charger, 5V/1A minimum)
- This will require soldering if you want it properly assembled
- Optional: 3D printer for custom enclosure
Wiring Connections
The OLED display uses SPI bus, Touch breakout uses I2C and Neopixel uses GPIO.
| Component | ESP32-C6 GPIO | Notes |
|---|---|---|
| SPI Display (GC9A01) | ||
| SCK | GPIO23 (SCK) | SPI Clock |
| MOSI | GPIO22 (MOSI) | SPI Master Out |
| CS | GPIO16 | Chip Select |
| DC | GPIO05 | Data/Command |
| RESET | GPIO04 | Display Reset Pin |
| BL | GPIO17 | Backlight - it can be used to set brightness |
| I2C Touch pHAT (CAP1166) | ||
| 3 (GPIO 2) | GPIO06 (SDA) | I2C Data - requires external pull-up resistor |
| 5 (GPIO 3) | GPIO07 (SCL) | I2C Clock - requires external pull-up resistor |
| ESP32-C6 Built-in | ||
| LED | GPIO15 | Built-in LED - not used |
| BOOT | GPIO09 | Boot button - not used |
| NeoPixel (Future) | ||
| DIN | GPIO21 | Data pin for the LED driver |
WARNING ⚠️: I used 3.3K external pull-up resistors to 3.3V for I2C bus - on GPIO06 and GPIO07. The Touch Phat does not have external pull-ups and internal pull-ups on the MCU are too weak. If you don’t do this you’ll be getting weird I2C errors in the logs, phantom touches, etc.
Power Notes:
- All components use 3.3V logic level
- Touch pHAT requires both 3.3V and 5V pins to be connected to power supply. It does not provide power supply
it requires power supply
- 3.3V power input is for power supply for the CAP1166 controller and logic
- 5V power input is for powering LEDs. It works with 3.3V and I connected it to 3.3V. Since this is for driving LEDs it will not affect overal functionality. LEDs might not be as bright. If you are using some other input device, make sure you check documentation for it. Connecting 5V supply to 3.3V device might ☠️ kill it.
- NeoPixel uses 5V supply, but it supports 3.3V logic level for communication
Software
- ESPHome installed
- Home Assistant with configured weather integration
- Met.no used, which is used by 82.4% of all users so this should work out of the box.
- Home Assistant entities:
- Indoor temperature sensor
- Indoor humidity sensor
- Climate entity (thermostat/heater)
- EV Car integration and sensor for battery level
- USB-to-Serial drivers (ESP32-C6 implements USB 2.0 CDC so should work out of the box)
Required Home Assistant Entities
This project requires the following entities in Home Assistant. You’ll need to update the substitutions with your own entity IDs:
- Climate Entity: Thermostat/TRV with preset modes (frost, eco, comfort, boost)
- Example:
climate.bedroom_heater(using Versatile Thermostat integration) - Must support
preset_modeattribute andclimate.set_preset_modeaction
- Example:
- Temperature Sensor: Indoor temperature reading
- Example:
sensor.bedroom_temperature
- Example:
- Humidity Sensor: Indoor humidity reading
- Example:
sensor.bedroom_humidity
- Example:
- Weather Integration: Home Assistant weather entity
- Example:
weather.forecast_home - Must provide
temperatureandtemperature_unitattributes
- Example:
- EV Battery Sensor (optional): Electric vehicle battery level
- Example:
sensor.car_battery_level
- Example:
- Number Entity: Boost preset temperature setting (if using Versatile Thermostat)
- Example:
number.bedroom_heater_preset_boost_temp
- Example:
- Template Select: To control the notification LED
- Example:
select.open_window_rain_status- see Rain Notification - I could have controlled LED from Home Assistant directly I suppose when I got through all the trouble configuring everything there 🙂 in the first place
- Example:
Additional Files
- Material Design Icons font file - see font component for more comprehensive explanation.
weather_icon_map.hheader file see Info Panel with ESP32 2.8” Display project for details.- CAP1166 custom component is pulled from github repository so no additional files for this are needed
Project Photos
- Info

- Starting up

- Heating

- Set Heating Preset

- Set Boost Temperature

- Neopixel Blue

Detailed wiring diagram showing all connections will be added here.
-
Soldered controller - front

-
Soldered controller - back

3D Printed Enclosure
Ready to assemble:

The enclosure houses:
- ESP32-C6 board
- Round LCD display (front-facing)
- Touch pHAT buttons (front-facing)
- NeoPixel LED (for ambient/notification lighting)
- USB-C access for programming and power
Ready to print STLs:

Download STL files:
ESPHome Configuration
Prerequisites
Before using this configuration, ensure you have:
- Home Assistant with the following configured:
- Weather integration (provides
weather.forecast_homeentity) - Climate entity with preset mode support
- Indoor temperature and humidity sensors (Zigbee or other)
- A climate entity for heating control
- EV battery sensor integration
- Weather integration (provides
- Material Design Icons font file saved in your ESPHome directory as
assets/materialdesignicons-webfont.ttf - The
weather_icon_map.hheader file
Secrets File Setup
Create a secrets.yaml file in your ESPHome directory with your credentials:
wifi_ssid: "Your_WiFi_SSID"
wifi_password: "Your_WiFi_Password"
bedroom2_controller_api: "your generated api key"
bedroom2_controller_ota: "your ota password"
For more information on using secrets in ESPHome, refer to the ESPHome documentation.
Main Configuration File
If you’re using
ESPHome Device Builder
create your New Device. Or if you’re using
command line create your yaml file
(e.g. bedroom-controller.yaml)
Then use the following file as a guide (details on how to customize it are below).
Download the full configuration: bedroom-controller.yaml
esphome:
name: bedroom2-controller
friendly_name: bedroom2-controller
includes:
- weather_icon_map.h
on_boot:
- priority: 1
then:
- light.control:
id: backlight
brightness: 20%
- delay: 5s
- globals.set:
id: my_boot_in_progress
value: !lambda |-
return id(my_boot_in_progress) >= 2 ? 2 : id(my_boot_in_progress);
esp32:
variant: esp32c6
# Enable logging
logger:
level: WARN
# Enable Home Assistant API
api:
encryption:
key: !secret bedroom2_controller_api
ota:
- platform: esphome
password: !secret bedroom2_controller_ota
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
on_connect:
- delay: 5s
- globals.set:
id: my_boot_in_progress
value: !lambda |-
return id(my_boot_in_progress) >= 1 ? 1 : 0;
substitutions:
heater: bedroom_heater # Climate entity (without climate. prefix)
car_battery_level: ev_battery_level # EV battery sensor (without sensor. prefix)
indoor_temp: bedroom_temperature # Indoor temp sensor (without sensor. prefix)
indoor_humid: bedroom_humidity # Indoor humidity sensor (without sensor. prefix)
rain_alert: select.open_window_rain_status # status of rain alert (or any other alert)
clk_pin: GPIO23
mosi_pin: GPIO22
#low power i2c
sda_pin: GPIO06
scl_pin: GPIO07
disp_cs_pin: GPIO16
disp_dc_pin: GPIO05
disp_bl_pin: GPIO17
disp_reset_pin: GPIO04
neopixel_pin: GPIO21
external_components:
- source: github://barbarachbc/esphomecomponents
components: [ cap1166 ]
refresh: 5min
globals:
- id: my_boot_in_progress
type: int
restore_value: no
initial_value: '3'
- id: my_forecast_desc
type: std::string
restore_value: no
initial_value: '"Updating ..."'
- id: my_forecast_icon
type: std::string
restore_value: no
initial_value: '""'
- id: my_outside_temp
type: std::string
restore_value: no
initial_value: '"_.__C"'
- id: selecting_heating_preset
type: std::string
restore_value: no
initial_value: '""'
- id: changing_heating_temp
type: float
restore_value: no
initial_value: '0'
script:
- id: update_weather
then:
- globals.set:
id: my_boot_in_progress
#finally connected to Home Assistant
value: '0'
- globals.set:
id: my_forecast_icon
value: !lambda return weather_icon_map[id(forecast_desc).state.c_str()].c_str();
- globals.set:
id: my_forecast_desc
value: !lambda return weather_desc_map[id(forecast_desc).state.c_str()].c_str();
- id: update_outside_temp
then:
- globals.set:
id: my_outside_temp
value: !lambda return str_sprintf("%.1f%s", id(outside_temperature).state, id(outside_temperature_unit).state.c_str());
- id: a_button_click
then:
- if:
condition:
- lambda: return id(changing_heating_temp) > 0;
then:
- globals.set:
id: changing_heating_temp
value: !lambda |-
if(id(changing_heating_temp) <= 15){
return 15.0;
} else {
return id(changing_heating_temp) - 0.5;
}
else:
- script.execute:
id: change_preset
preset: "frost"
- id: d_button_click
then:
- if:
condition:
- lambda: return id(changing_heating_temp) > 0;
then:
- globals.set:
id: changing_heating_temp
value: !lambda |-
if(id(changing_heating_temp) >= 28){
return 28.0;
} else {
return id(changing_heating_temp) + 0.5;
}
else:
- script.execute:
id: change_preset
preset: "boost"
- id: change_preset
parameters:
preset: string
then:
- if:
condition:
and:
- display.is_displaying_page: page_heating
- lambda: return id(selecting_heating_preset) == "" && id(changing_heating_temp) <= 0;
- not:
text_sensor.state:
id: indoor_temp_preset
state: !lambda return preset;
then:
- globals.set:
id: selecting_heating_preset
value: !lambda return preset;
- component.update: my_display
- light.turn_on: my_light_forward
- light.turn_on: my_light_back
- id: accept_button_click
then:
- if:
condition:
- lambda: return id(selecting_heating_preset) == "" && id(changing_heating_temp) <= 0;
then:
- display.page.show_next: my_display
- component.update: my_display
- if:
condition:
- lambda: return id(selecting_heating_preset) != "";
then:
- script.execute:
id: set_indoor_preset
preset: !lambda return id(selecting_heating_preset);
- globals.set:
id: selecting_heating_preset
value: '""'
- component.update: my_display
- light.turn_off: my_light_forward
- light.turn_off: my_light_back
- if:
condition:
- lambda: return id(changing_heating_temp) > 0;
then:
- script.execute:
id: change_preset_amount
preset: "boost"
set_to: !lambda return id(changing_heating_temp);
- globals.set:
id: changing_heating_temp
value: '0'
- component.update: my_display
- light.turn_off: my_light_forward
- light.turn_off: my_light_back
- light.turn_off: my_light_a
- light.turn_off: my_light_d
- id: clear_globals_and_lights
then:
- globals.set:
id: selecting_heating_preset
value: '""'
- globals.set:
id: changing_heating_temp
value: '0'
- light.turn_off: my_light_forward
- light.turn_off: my_light_back
- light.turn_off: my_light_a
- light.turn_off: my_light_d
- id: cancel_button_click
then:
- if:
condition:
- lambda: return id(selecting_heating_preset) == "" && id(changing_heating_temp) <= 0;
then:
- display.page.show_previous: my_display
- component.update: my_display
else:
- script.execute: clear_globals_and_lights
- component.update: my_display
- id: set_indoor_preset
parameters:
preset: string
then:
- homeassistant.action:
action: climate.set_preset_mode
data:
entity_id: climate.${heater}
data_template:
preset_mode: !lambda return preset;
- id: change_preset_amount
parameters:
preset: string
set_to: float
then:
- if:
condition:
- lambda: return preset == "boost";
then:
- number.set:
id: indoor_temp_preset_boost
value: !lambda return set_to;
- id: touch_d_long_click
then:
- if:
condition:
- lambda: return id(selecting_heating_preset) == "" && id(changing_heating_temp) <= 0;
then:
- globals.set:
id: changing_heating_temp
#round to 0.5
value: !lambda return round(id(indoor_temp_preset_boost).state * 2.0)/2.0;
- light.turn_on: my_light_forward
- light.turn_on: my_light_back
- light.turn_on: my_light_a
- light.turn_on: my_light_d
- id: idle_screen_reset
mode: restart
then:
- if:
condition:
number.in_range:
id: display_timeout
below: 1
then:
- light.turn_on: backlight
else:
- light.turn_on: backlight
- delay: !lambda return id(display_timeout).state * 1000;
# cancel any operation first then switch off the screen
- script.execute: clear_globals_and_lights
- display.page.show: page_info
- light.turn_off: backlight
- id: update_rain_alert
parameters:
status: string
then:
- if:
condition:
- lambda: return status == "ALLGOOD";
then:
- light.turn_off: my_alert_light
- if:
condition:
- lambda: return status == "RAINING";
then:
- light.control:
id: my_alert_light
effect: e_pulse_rain
state: on
red: 100%
green: 12%
blue: 12%
- if:
condition:
- lambda: return status == "RAINSOON" || status == "RAINCOMING";
then:
- light.control:
id: my_alert_light
effect: e_pulse_rain_coming
state: on
red: 100%
green: 12%
blue: 12%
- id: on_touch_release
then:
- light.control:
id: backlight
brightness: 100%
- script.execute: idle_screen_reset
text_sensor:
- platform: homeassistant
id: outside_temperature_unit
entity_id: weather.forecast_home
attribute: temperature_unit
internal: true
on_value:
then:
- script.execute: update_outside_temp
- platform: homeassistant
id: forecast_desc
entity_id: weather.forecast_home
internal: true
on_value:
then:
script.execute: update_weather
- platform: homeassistant
id: indoor_hvac_action
attribute: hvac_action
entity_id: climate.${heater}
internal: true
- platform: homeassistant
id: indoor_temp_preset
attribute: preset_mode
entity_id: climate.${heater}
internal: true
- platform: homeassistant
id: open_window_rain_alert
entity_id: ${rain_alert}
internal: true
on_value:
then:
- script.execute:
id: update_rain_alert
status: !lambda return id(open_window_rain_alert).state;
number:
- platform: template
name: Screen timeout
optimistic: true
id: display_timeout
unit_of_measurement: "s"
initial_value: 45
restore_value: true
min_value: 0
max_value: 7200
step: 5
mode: box
on_value:
then:
script.execute: idle_screen_reset
- platform: template
name: LED Brightness
optimistic: true
id: led_brightness
unit_of_measurement: "%"
initial_value: 40
restore_value: true
min_value: 0
max_value: 100
step: 5
icon: mdi:brightness-percent
on_value:
then:
- lambda: |-
id(touch_phat).update_all_brightness(0, id(led_brightness).state);
- platform: homeassistant
id: battery_level
entity_id: sensor.${car_battery_level}
internal: true
- platform: homeassistant
id: indoor_temp_preset_boost
entity_id: number.${heater}_preset_boost_temp
internal: true
sensor:
- platform: homeassistant
id: outside_temperature
attribute: temperature
entity_id: weather.forecast_home
internal: true
on_value:
then:
script.execute: update_outside_temp
- platform: homeassistant
id: indoor_temperature
entity_id: sensor.${indoor_temp}
internal: true
- platform: homeassistant
id: indoor_humidity
entity_id: sensor.${indoor_humid}
internal: true
- platform: homeassistant
id: indoor_temp_setting
attribute: temperature
entity_id: climate.${heater}
internal: true
time:
- platform: sntp
id: my_time
timezone: Europe/Dublin
spi:
clk_pin: ${clk_pin}
mosi_pin: ${mosi_pin}
i2c:
id: i2c_bus
sda: ${sda_pin}
scl: ${scl_pin}
cap1166:
- id: touch_phat
address: 0x2C
touch_threshold: 0x40
allow_multiple_touches: false
brightness_configs:
- led_behavior: DIRECT
max_brightness: 40%
- led_behavior: PULSE1
max_brightness: 40%
- led_behavior: PULSE2
max_brightness: 40%
- led_behavior: BREATHE
max_brightness: 40%
binary_sensor:
- platform: cap1166
id: touch_back
channel: 0
on_click:
min_length: 50ms
max_length: 350ms
then:
- script.execute:
id: cancel_button_click
on_release:
then:
script.execute: on_touch_release
- platform: cap1166
id: touch_A
channel: 1
on_click:
- min_length: 50ms
max_length: 350ms
then:
- script.execute:
id: a_button_click
on_release:
then:
script.execute: on_touch_release
- platform: cap1166
id: touch_B
channel: 2
on_click:
min_length: 50ms
max_length: 350ms
then:
- script.execute:
id: change_preset
preset: "eco"
on_release:
then:
script.execute: on_touch_release
- platform: cap1166
id: touch_C
channel: 3
on_click:
min_length: 50ms
max_length: 350ms
then:
- script.execute:
id: change_preset
preset: "comfort"
on_release:
then:
script.execute: on_touch_release
- platform: cap1166
id: touch_D
channel: 4
on_click:
- min_length: 50ms
max_length: 350ms
then:
- script.execute:
id: d_button_click
- min_length: 350ms
max_length: 2000ms
then:
- script.execute:
id: touch_d_long_click
on_release:
then:
script.execute: on_touch_release
- platform: cap1166
id: touch_forward
channel: 5
on_click:
min_length: 50ms
max_length: 350ms
then:
- script.execute:
id: accept_button_click
on_release:
then:
script.execute: on_touch_release
output:
- platform: ledc
pin: ${disp_bl_pin}
id: backlight_pwm
light:
- platform: esp32_rmt_led_strip
id: my_alert_light
chipset: ws2812
num_leds: 1
rgb_order: GRB
name: "Notification Light"
restore_mode: ALWAYS_OFF
pin: ${neopixel_pin}
effects:
- pulse:
name: "e_pulse_rain"
transition_length: 500ms
update_interval: 500ms
max_brightness: 70%
- pulse:
name: "e_pulse_rain_coming"
transition_length: 500ms
update_interval: 2s
max_brightness: 70%
- platform: monochromatic
output: backlight_pwm
name: "Display Backlight"
id: backlight
restore_mode: ALWAYS_ON
# if it's done through Home Assistant (manually or through automation)
on_turn_on:
then:
script.execute: idle_screen_reset
#NOTE: touch phat has them connected inversly
- id: my_light_forward
platform: cap1166
internal: true
channel: 0
led_behavior: PULSE2
- id: my_light_d
platform: cap1166
name: "Light D"
channel: 1
led_behavior: DIRECT
- id: my_light_c
name: "Light C"
platform: cap1166
channel: 2
led_behavior: DIRECT
- id: my_light_b
platform: cap1166
name: "Light B"
channel: 3
led_behavior: DIRECT
- id: my_light_a
platform: cap1166
name: "Light A"
channel: 4
led_behavior: DIRECT
- id: my_light_back
platform: cap1166
internal: true
channel: 5
led_behavior: PULSE2
font:
- id: value_med
file:
type: gfonts
family: Montserrat
size: 20
bpp: 4
- id: value_small
file:
type: gfonts
family: Montserrat
size: 14
bpp: 2
- id: value_large
file:
type: gfonts
family: Montserrat
weight: bold
size: 28
bpp: 4
- id: mdi_small
file: assets/materialdesignicons-webfont.ttf
size: 32
bpp: 4
glyphs: [
"\U000F1A71", # snowflake-thermometer
"\U000F032A", # leaf
"\U000F04B9", # sofa
"\U000F14DE", # rocket-launch
"\U000F0C52", # checkbox-outline
"\U000F0158", # close-box-outline
"\U000F0704", # plus-box-outline
"\U000F06F2", # minus-box-outline
]
- id: mdi_med
file: assets/materialdesignicons-webfont.ttf
size: 64
bpp: 4
glyphs: [
"\U000F1807", # mdi-fire-circle
"\U000F0E1B", # mdi-car-back
]
- id: mdi_large
file: assets/materialdesignicons-webfont.ttf
size: 96
bpp: 4
glyphs: [
"\U000F0594", # clear-night
"\U000F0590", # cloudy
"\U000F0F2F", # exceptional
"\U000F0591", # fog
"\U000F0592", # hail
"\U000F0593", # lightning
"\U000F067E", # lightning-rainy
"\U000F0595", # partlycloudy
"\U000F0596", # pouring
"\U000F0597", # rainy
"\U000F0598", # snowy
"\U000F067F", # snowy-rainy
"\U000F0599", # sunny
"\U000F059D", # windy
"\U000F059E", # windy-variant
"\U000F14E4", # sunny-off
"\U000F1A71", # snowflake-thermometer
"\U000F032A", # leaf
"\U000F04B9", # sofa
"\U000F14DE", # rocket-launch
]
image:
- id: boot_logo
type: BINARY
file: mdi:home-automation
resize: 80x80
display:
# - platform: ssd1306_spi
# id: my_display
# model: "SH1107 128x128"
# cs_pin: ${disp_cs_pin}
# dc_pin: ${disp_dc_pin}
# rotation: 180
- platform: mipi_spi
id: my_display
model: GC9A01A
cs_pin: ${disp_cs_pin}
dc_pin: ${disp_dc_pin}
reset_pin: ${disp_reset_pin}
show_test_card: true
invert_colors: true
color_order: BGR
buffer_size: 25%
update_interval: 1s
rotation: 180
pages:
- id: page_info
lambda: |-
if (id(my_boot_in_progress) > 0) {
if(id(my_boot_in_progress)>1)
{
it.printf(it.get_width()/2, 114, id(value_large), COLOR_ON, TextAlign::CENTER, "Starting ...");
}else {
it.printf(it.get_width()/2, 114, id(value_large), COLOR_ON, TextAlign::CENTER, "Connecting");
}
it.image(it.get_width()/2, 50, id(boot_logo), ImageAlign::CENTER);
return;
}
//print time and date
if (id(my_time).now().is_valid()) {
it.strftime(it.get_width()/2, 32, id(value_large), COLOR_ON, TextAlign::CENTER, "%I:%M %p", id(my_time).now());
it.strftime(it.get_width()/2, 194, id(value_med), COLOR_ON, TextAlign::CENTER, "%a, %e %b", id(my_time).now());
}
it.printf(24, 42, id(mdi_large), COLOR_ON, id(my_forecast_icon).c_str());
it.printf(160, 64, id(value_small), COLOR_ON, TextAlign::TOP_CENTER, id(my_forecast_desc).c_str());
it.printf(24, 144, id(value_large), COLOR_ON, id(my_outside_temp).c_str());
it.printf(180, 92, id(mdi_med), COLOR_ON, TextAlign::TOP_CENTER, "\U000F0E1B");
it.printf(180, 152, id(value_med), COLOR_ON, TextAlign::TOP_CENTER, "%.0f%%", id(battery_level).state);
- id: page_heating
lambda: |-
if (id(my_boot_in_progress) > 0) {
it.printf(it.get_width()/2, 114, id(value_large), COLOR_ON, TextAlign::CENTER, "Connecting");
it.image(it.get_width()/2, 50, id(boot_logo), ImageAlign::CENTER);
return;
}
const std::string presets[] = {
"frost", "eco", "comfort", "boost"
};
const std::string icons[] = {
"\U000F1A71", "\U000F032A", "\U000F04B9", "\U000F14DE"
};
if(id(selecting_heating_preset) == "" && id(changing_heating_temp) <= 0){
//print time and date
if (id(my_time).now().is_valid()) {
it.strftime(it.get_width()/2, 32, id(value_large), COLOR_ON, TextAlign::CENTER, "%I:%M %p", id(my_time).now());
}
auto heating_is_on = id(indoor_hvac_action).state == "heating";
auto current_temp_preset = id(indoor_temp_preset).state;
if(heating_is_on){
it.printf(24, 42, id(mdi_med), COLOR_ON, "\U000F1807");
}
it.printf(32, 104, id(value_large), COLOR_ON, "%.1f%s", id(indoor_temperature).state, id(outside_temperature_unit).state.c_str());
it.printf(32, 136, id(value_med), COLOR_ON, "%.0f%%", id(indoor_humidity).state);
it.printf(it.get_width() - 48, 110, id(value_med), COLOR_ON, TextAlign::CENTER, "%.1f%s", id(indoor_temp_setting).state, id(outside_temperature_unit).state.c_str());
it.printf(it.get_width() - 48, 80, id(value_med), COLOR_ON, TextAlign::CENTER, current_temp_preset.c_str());
//show icons
const int icon_size = 32;
auto y = it.get_height() - 2*icon_size;
for(auto i = 0; i<4; i++){
auto is_selected = current_temp_preset == presets[i];
auto x = 48 + icon_size*i + 9*i; auto invert_icon = false;
if(is_selected){
if(heating_is_on){
invert_icon = true;
it.filled_rectangle(x, y-1, icon_size+1, icon_size+1);
}else{
it.rectangle(x, y-1, icon_size+1, icon_size+1);
}
}
it.printf(x, y, id(mdi_small), invert_icon ? COLOR_OFF : COLOR_ON, icons[i].c_str());
}
} else if(id(selecting_heating_preset) != ""){
bool is_selected = false;
auto y_last_line = it.get_height()/2 + 44;
for(auto i = 0; i<4; i++){
if(id(selecting_heating_preset) != presets[i]){
continue;
}
is_selected = true;
it.printf(it.get_width()/2, it.get_height()/2, id(mdi_large), COLOR_ON, TextAlign::BOTTOM_CENTER, icons[i].c_str());
it.printf(it.get_width()/2, it.get_height()/2 + 20, id(value_med), COLOR_ON, TextAlign::TOP_CENTER, "Set the mode to:");
it.printf(it.get_width()/2, y_last_line, id(value_med), COLOR_ON, TextAlign::TOP_CENTER, "%s ?", id(selecting_heating_preset).c_str());
break;
}
if(is_selected){
//apply
it.printf(32, y_last_line, id(mdi_small), COLOR_ON, TextAlign::TOP_LEFT, "\U000F0158");
//cancel
it.printf(it.get_width()-32, y_last_line, id(mdi_small), COLOR_ON, TextAlign::TOP_RIGHT, "\U000F0C52");
}
if(!is_selected){
id(selecting_heating_preset) = "";
}
} else if(id(changing_heating_temp) > 0){
it.printf(it.get_width()/2, it.get_height()/2, id(mdi_large), COLOR_ON, TextAlign::BOTTOM_CENTER, "\U000F14DE");
it.printf(it.get_width()/2, it.get_height()/2+12, id(value_large), COLOR_ON, TextAlign::TOP_CENTER, "%.1f", id(changing_heating_temp));
auto y_last_line = it.get_height()/2 + 44;
//apply
it.printf(32, y_last_line, id(mdi_small), COLOR_ON, TextAlign::TOP_LEFT, "\U000F0158");
//cancel
it.printf(it.get_width()-32, y_last_line, id(mdi_small), COLOR_ON, TextAlign::TOP_RIGHT, "\U000F0C52");
//minus
it.printf(80, y_last_line, id(mdi_small), COLOR_ON, TextAlign::TOP_LEFT, "\U000F06F2");
//plus
it.printf(it.get_width() - 80, y_last_line, id(mdi_small), COLOR_ON, TextAlign::TOP_RIGHT, "\U000F0704");
}
Configuration Breakdown
Substitutions
The configuration uses substitutions to make it easy to adapt to your Home Assistant setup. Update these with your own entity IDs:
substitutions:
heater: bedroom_heater # Climate entity (without climate. prefix)
car_battery_level: ev_battery_level # EV battery sensor (without sensor. prefix)
indoor_temp: bedroom_temperature # Indoor temp sensor (without sensor. prefix)
indoor_humid: bedroom_humidity # Indoor humidity sensor (without sensor. prefix)
rain_alert: select.open_window_rain_status # status of rain alert (or any other alert)
Boot Progress Tracking
The my_boot_in_progress global variable tracks the device startup stages:
- 3: ESP32 starting up
- 2: Boot Finished
- 1: Wifi Connected
- 0: Connected to Home Assistant (Fully connected and ready)
This is controlling boot display. If you look at the code I wasn’t 100% sure of the ordering of events or whether there might be some racing conditions. The boot screen is shown all the way until device connects to Home Assistant.
Climate Control Integration
This configuration is designed to work with the Versatile Thermostat integration custom integration for Home Assistant. Versatile Thermostat provides:
- Multiple preset modes (frost, eco, comfort, boost)
- Individual temperature settings per preset
- Advanced heating strategies
- Comprehensive automation support
Important: If you’re using a different climate integration, you may need to adjust:
- Preset mode names in scripts
- The
indoor_temp_preset_boostnumber entity reference - Preset mode icons
Touch Button Functions
Information Page (page_info)
- FORWARD: Navigates to the heating page
- BACK: Navigates to the heating page (same as forward, only 2 pages)
Heating Page (page_heating)
Normal Mode:
- FORWARD: Navigate to info page
- BACK: Navigate to info page (same as forward, only 2 pages)
- Button A: Select “frost” preset
- Button B: Select “eco” preset
- Button C: Select “comfort” preset
- Button D (short press): Select “boost” preset
- Button D (long press): Enter temperature adjustment mode for boost
Preset Confirmation Mode:
- FORWARD: Confirm preset change
- BACK: Cancel preset change
- Buttons A-D: No function
Temperature Adjustment Mode:
- FORWARD: Confirm new temperature
- BACK: Cancel temperature change
- Button A: Decrease by 0.5°C (min 15°C)
- Button D: Increase by 0.5°C (max 28°C)
- Buttons B, C: No function
LED Feedback
The Touch pHAT LEDs provide visual feedback:
- Normal mode: LEDs off
- Preset confirmation: Forward and Back LEDs pulsing
- Temperature adjustment: Forward, Back, A, and D LEDs lit
LED brightness can be adjusted via the “LED Brightness” number entity (0-100%).
Display Pages
Page 1: Information Display
- Current time (12-hour format with AM/PM)
- Current date (day, date, month)
- Weather icon and description
- Outside temperature
- EV battery level with car icon
Page 2: Heating Control
- Current time (top-right)
- Heating active indicator (top-left, only when heating)
- Indoor temperature (large, left side)
- Indoor humidity (below temperature)
- Target temperature setting (right side)
- Current preset mode name (below target)
- Preset mode icons at bottom (highlighted when active)
Weather Integration
This project assumes you have a working weather integration in Home Assistant, typically configured as
weather.forecast_home. The configuration retrieves:
- Current temperature (from
temperatureattribute) - Temperature unit (C or F)
- Weather condition (for icon mapping)
Idle Screen Timeout
Screen will be turned off if the device is idle for more than configured timeout - Screen Timeout - display_timeout.
By default it is configured to 45s but it can go from 0 - 7200 (2hrs). If set to 0, the screen won’t go to sleep.
The configuration is in seconds.
Implementation Details
The script used is: idle_screen_reset and it resets the timer when called. It is a very simple script:
- it is configured in
restartmode so previus execution is cancelled when called and the new one is initiated - it checks if
display_timeoutis turned off and just keeps the backlight on in that case - otherwise
- it turns on the backlight (making sure it’s ON)
- waits for the configured timeout. Lambda needs to returns value in ms - hence multiplying the configuration by 1000
- and it turnes off the backlight
- note that if the script is called before it finishes this, the current execution will be cancelled and the new will be started
NOTE: The delay operation is smart and it is async so it does not block anything
The script is executed in the following cases:
- when
dispay_timeoutis set - either the timeout is changed or, on restart, when display timeout is set from Home Assistant - when any of the touch buttons is pressed (
on_release) - when the
Display Backlightis manually (or through automation) turned on from Home Assistant
On boot - the backlight is set to 20% brightness. The brightness is set to 100% only when one of the touch buttons is used.
Rain Notification
NeoPixel is going to be flashing red if it is raining or the rain is coming soon and windows are open. It will flash quickly if it’s raining and windows are open, and it will flash slower if the rain is upcoming.
I have an automation setup that runs every hour (5th minute of every hour) and checks the weather forecast. It gets forecast data and sets 3 variables based on the hourly forecast.
- is_raining: The first record is current precipitation.
- about_to_rain:
batch(2) | firstsplits the forecast information in lists of two and takes the first list (which is the forecast for the first 2 hours) and counts how many items have precipitation greater than 0. Essentially it is true if it is currently raining or it will rain in the next hour. - rain_soon: same as previous, it is true if it is currently raining or it will rain in next 4 hours.
alias: Rain Soon?
description: Setting variables that are related to checking rain
triggers:
- trigger: time_pattern
hours: "*"
minutes: "5"
conditions: []
actions:
- action: weather.get_forecasts
target:
entity_id: weather.forecast_home
data:
type: hourly
response_variable: forecast_data
- variables:
is_raining: >-
{{(forecast_data['weather.forecast_home']['forecast'] |
first).precipitation > 0 }}
about_to_rain: >-
{{forecast_data['weather.forecast_home']['forecast'] | batch(2) | first
| selectattr('precipitation', '>', 0) | list | count > 0 }}
rain_soon: >-
{{forecast_data['weather.forecast_home']['forecast'] | batch(5) | first
| selectattr('precipitation', '>', 0) | list | count > 0 }}
This automation also sets or resets 3 helper toggle buttons depending on the values of the variables.
I grouped a couple of binary sensors that indicate whether any of the roof windows are open and I created
a “template select”
with select.open_window_rain_status id and with following state:
{% if is_state("binary_sensor.attic_window_open", "off") -%}
ALLGOOD
{%- elif is_state("input_boolean.rainingnow", "on") %}
RAINING
{%- elif is_state("input_boolean.rainimminent", "on") %}
RAINSOON
{%- elif is_state("input_boolean.rainexpected", "on") %}
RAINCOMING
{%- else %}
ALLGOOD
{%- endif %}
and available options:
{{['ALLGOOD', 'RAINING', 'RAINSOON', 'RAINCOMING']}}
Installation Steps
Prepare Hardware
- Wire all components according to the wiring table above
- Double-check all connections before powering on
- NOTE: Touch pHAT - I connected both 5V and 3.3V pins to the 3.3V power suppy pin on the board
- Ensure all I2C and SPI connections are correct
- Make sure all the other pins for the display are correct. If any of them is incorrect the display won’t show
- NeoPixel - connect VCC to 5V (3.3V will work but some of the colors won’t show well)
Prepare Files
- Download the Material Design Icons - details in font component.
- Create the
weather_icon_map.hheader file (content here) - Create your
secrets.yamlfile with your WiFi credentials - Update the API encryption key (generate a new one for security)
- Set secure OTA passwords (I did not configure fallback WiFi endpoint but I did configure OTA)
NOTE: no need to do anything for the external component, the CAP1166 component code downloaded from github automatically.
Update Configuration
- Modify the
substitutionssection with your Home Assistant entity IDs - Adjust timezone in
timecomponent if not in Europe/Dublin
More info on how to manage secrets.
Flash ESPHome
Use ESPHome Device Builder, that’s simplest. After the first flash, you can use OTA updates.
Add to Home Assistant
The device should be automatically discovered in Home Assistant:
- Go to Settings → Devices & Services
- Look for the discovered ESPHome device
- Click Configure and enter your API encryption key
- ⚠️ By default new ESPHome device is not allowed to perform any Home Assistant Actions
- In order to:
- change heating presets
- change “boost” preset configuration
- You need to go to device settings in Home Assistant and select:
- Allow the device to perform Home Assistant actions
- In order to:
Verify Operation
Check that:
- Display shows boot screen and then connects
- Time and weather information appear on info page
- Indoor temperature and humidity display on heating page
- Page navigation works (forward/back buttons)
- Preset icons show current heating mode
Troubleshooting
Stuck at Boot Screen
If you’re stuck at boot screen that usually means the device cannot connect to WiFi.
Check your secrets.yaml that you have correct WiFi ssid and password. If this is correct, the best is to connect the device to your computer, open up ESPHome Web, connect to the device and open up logs. Reboot the device to see the logs from starting up and hopefully that will reveal any issues.
If no issues show up change logging level:
logger:
level: DEBUG
Note that the boot screen will be on until connection is established with Home Assistant, so the issue might be that the device cannot connect to WiFi or it can connect to WiFi but not to Home Assistant afterwards.
Entity Status Not Updated
Check substitutions making sure that you have entered correct entity ids. For temperature/humidity sensor this should work just fine and a typo or incorrect entity id is most likely issue. Any climate entity should work for controling heating but see below.
Heating Control Not Working
If the correct preset and temperature setting is not updated - verify that climate entity ID is correct.
If the heater button doesn’t control thermostat - did you: Allow the device to perform Home Assistant actions?
While this should work with any climate control, check that preset modes (frost, boost, eco)
are supported. If they are not, you might need to change config slightly to support your
settings. Maybe your setting doesn’t have a boost preset - just replace boost in the
configuration with what your desired setting is. The same goes for frost, eco and comfort.
Random Reboots or Crashes
Problem: Device restarts unexpectedly
Solutions:
- Use adequate power supply (1A minimum)
- Reduce display buffer size if running out of memory
- Lower logger level to WARN or ERROR
- Disable unnecessary components temporarily
- Unplug your Electric Toothbrush charger if it uses the same USB charger … yes … the device was rebooting randomly. I used USB charger with 2 ports. While electric toothbrush charger was plugged in, it just kept on rebooting randomly. I unpluged the electric brush charger and reboots stopped - dead givaway! 🤦♂️
Display Not Working
Problem: Screen stays blank or shows no output
Solutions:
- Verify SPI wiring (CLK, MOSI, CS pins)
- Check display is using mipi_spi and model GC9A01A … or if you’re modifying this - whatever your model is
- Verify CS, DC and RESET pin assignments match your wiring - note that not all displays have RESET, but if yours have it you should connect and configure it correctly
- Try different
rotationvalues (0, 90, 180, 270) - Check display power supply (3.3V)
- Make sure that you have buffer configured to 25% - ESP32C6 board I have has limited memory. Unless your board has external PSRAM go with 25%
- Test with simpler display code first - go to the list of devices and filter by
displaycategory. If your display is in the list it will have information how to set it up and will have simple display configuration.
Touch Buttons Not Responding
Problem: Touch pHAT doesn’t register touches
Solutions:
- Verify I2C wiring (SDA: GPIO19, SCL: GPIO20)
- Check Touch pHAT has 5V power
- Verify I2C address is 0x2C (check with I2C scanner)
- Adjust
touch_thresholdvalue (generally: 0x01 is most sensitive, 0x80 is least sensitive. My configuration is 0x40 - Medium. Try 0x20 first and see how it goes) - Look for I2C errors in ESPHome logs
- Make sure you have external pull-up resistors!
LEDs Not Lighting
Problem: Touch pHAT LEDs don’t respond
Solutions:
- Verify I2C communication is working (touch should work first)
- Check LED brightness setting (increase to 100% for testing)
- Test individual lights from Home Assistant
Temperature Adjustment Not Working
Problem: Long-press on Button D doesn’t enter adjustment mode
Solutions:
- Verify you’re on the heating page (page 2)
- Are you pressing it not too short and not too long? Press should be between 350ms and 2000ms long 👀
- Check
number.${heater}_preset_boost_tempnumber entity exists. You might not have this in Home Assistant if you’re not using Versatile Thermostat - you might need to replace this with what you want to change. - Ensure you’re not already in selection mode
- Increase long-press duration threshold if needed
- Check lambda logic in
touch_d_long_clickscript
Future Enhancements
This project documentation will be updated with:
- NeoPixel Integration - RGB LED for ambient lighting and notifications is there and can be used from Home Assistant but it does nothing else at the moment. An option is to configure it to light up with automation.
Other Images
- Home Assistant Control

You can actually turn on and off LEDs behind buttons A-D.
- NeoPixel

And you can play with the NeoPixel.
![]()