Thought I share this with you all.
My good buddy Grok and I put together the code to turn one of those Cheap Yellow Displays into a Air Conditioner controller using the HA Climate integration.
I wanted it to look like the live tiles of a Window 10 phone. (Great phone BTW, but that’s a story for another night
)
The board used is an ESP32-2432S028 with built in touchscreen. They are renown for being tricky due to the shared SPI bus.
It works pretty good. A couple of things that could be improved are the slight lag of the post/get display of the updated value. Also button press feedback would be good.
# ============================================================================
# TFT AC Controller - Final Version
# ESP32 + ST7796 display + XPT2046 touchscreen
# Controls Home Assistant climate.lounge_ac entity
# Features: Power toggle, temp ±1°C, cycling mode/fan/swing with instant feedback
# ============================================================================
esphome:
name: control-screen
friendly_name: "AC Controller"
esp32:
board: esp32dev
framework:
type: arduino
# Logging level (can be changed to DEBUG for troubleshooting)
logger:
# Secure API connection to Home Assistant
api:
encryption:
key: "Yours goes here"
# Over-the-Air updates (keep password secure!)
ota:
- platform: esphome
password: "Yours goes here"
# Wi-Fi connection (secrets stored in separate file)
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
# Fallback hotspot if Wi-Fi fails
ap:
ssid: "AC-Controller Fallback"
password: "Yours goes here"
# Captive portal for initial Wi-Fi setup
captive_portal:
# Fonts used throughout the interface
font:
- file: "gfonts://Roboto@700"
id: big_font
size: 48
- file: "gfonts://Roboto"
id: med_font
size: 28
- file: "gfonts://Roboto"
id: small_font
size: 20
# SPI buses for display and touchscreen
spi:
- id: display_bus
clk_pin: GPIO14
mosi_pin: GPIO13
- id: touch_bus
clk_pin: GPIO25
mosi_pin: GPIO32
miso_pin: GPIO39
# Main display configuration (ST7796 320×240)
display:
- platform: mipi_spi
id: main_display
model: ST7796
spi_id: display_bus
dc_pin: GPIO2
cs_pin: GPIO15
invert_colors: false
color_order: RGB
color_depth: 16BIT
transform:
swap_xy: true
mirror_x: true
mirror_y: false
dimensions:
height: 240
width: 320
data_rate: 20000000.0
update_interval: 1s # Frequent updates for responsive feel
pages:
- id: page_ac
lambda: !lambda |-
// === COLOR PALETTE ===
auto bg_black = Color(0,0,0);
auto tile_blue = Color(40,157,190);
auto tile_red = Color(221,65,50);
auto tile_green = Color(75,109,65);
auto tile_lightgreen = Color(164,174,119);
auto tile_orange = Color(213,98,49);
auto tile_grey = Color(111,108,112);
auto tile_mustard = Color(216,174,72);
auto tile_brown = Color(152,89,75);
auto white = Color(255, 255, 255);
it.fill(bg_black);
// === TOP ROW - Status tiles ===
bool is_on = false;
if (id(ac_climate_state).has_state()) {
is_on = (id(ac_climate_state).state != "off");
}
it.filled_rectangle(5, 5, 100, 75, is_on ? tile_red : tile_grey);
it.print(55, 42, id(med_font), white, TextAlign::CENTER, "PWR");
it.filled_rectangle(110, 5, 100, 75, tile_mustard);
it.print(160, 15, id(small_font), white, TextAlign::CENTER, "TARGET");
if (id(ac_current_setpoint).has_state()) {
char buf[5];
sprintf(buf, "%.0f", id(ac_current_setpoint).state);
it.print(160, 45, id(big_font), white, TextAlign::CENTER, buf);
}
it.filled_rectangle(215, 5, 100, 75, tile_green);
it.print(265, 15, id(small_font), white, TextAlign::CENTER, "ROOM");
if (id(ac_current_temperature).has_state()) {
char buf[5];
sprintf(buf, "%.0f", id(ac_current_temperature).state);
it.print(265, 45, id(big_font), white, TextAlign::CENTER, buf);
}
// === MIDDLE ROW - Temperature controls ===
it.filled_rectangle(5, 85, 100, 75, tile_blue);
it.print(55, 122, id(big_font), white, TextAlign::CENTER, "-");
it.filled_rectangle(110, 85, 100, 75, tile_grey);
it.print(160, 122, id(small_font), white, TextAlign::CENTER, "ACTIVE");
it.filled_rectangle(215, 85, 100, 75, tile_red);
it.print(265, 122, id(big_font), white, TextAlign::CENTER, "+");
// === BOTTOM ROW - Mode/Fan/Swing status ===
std::string mode_str = id(ac_climate_state).state;
std::transform(mode_str.begin(), mode_str.end(), mode_str.begin(), ::toupper);
it.filled_rectangle(5, 165, 100, 75, tile_orange);
it.print(55, 175, id(small_font), white, TextAlign::CENTER, "MODE");
it.print(55, 205, id(small_font), white, TextAlign::CENTER, mode_str.c_str());
std::string fan_str = id(ac_fan_mode).state;
std::transform(fan_str.begin(), fan_str.end(), fan_str.begin(), ::toupper);
it.filled_rectangle(110, 165, 100, 75, tile_lightgreen);
it.print(160, 175, id(small_font), white, TextAlign::CENTER, "FAN");
it.print(160, 205, id(small_font), white, TextAlign::CENTER, fan_str.c_str());
std::string swing_str = id(ac_swing_mode).state;
std::transform(swing_str.begin(), swing_str.end(), swing_str.begin(), ::toupper);
it.filled_rectangle(215, 165, 100, 75, tile_brown);
it.print(265, 175, id(small_font), white, TextAlign::CENTER, "SWING");
it.print(265, 205, id(small_font), white, TextAlign::CENTER, swing_str.c_str());
# Light for backlight control (always on)
light:
- platform: monochromatic
output: backlight_pwm
restore_mode: ALWAYS_ON
id: backlight
output:
- platform: ledc
pin: GPIO21
id: backlight_pwm
# I²C bus (unused in current config, but kept for future expansion)
i2c:
id: i2c_bus1
sda: GPIO22
scl: GPIO27
# Touchscreen configuration
touchscreen:
platform: xpt2046
id: my_touchscreen
spi_id: touch_bus
cs_pin: GPIO33
interrupt_pin: GPIO36
update_interval: 30ms
threshold: 1000
transform:
swap_xy: True
mirror_x: False
mirror_y: False
calibration:
x_min: 450
x_max: 3700
y_min: 350
y_max: 3800
on_touch:
# === Power toggle (top-left tile) ===
- if:
condition:
and:
- lambda: 'return touch.x >= 0 && touch.x <= 105;'
- lambda: 'return touch.y >= 0 && touch.y <= 80;'
then:
- homeassistant.service:
service: climate.toggle
data: {entity_id: climate.lounge_ac}
- component.update: main_display
# === Temperature decrease (- button) ===
- if:
condition:
and:
- lambda: 'return touch.x >= 0 && touch.x <= 105;'
- lambda: 'return touch.y >= 85 && touch.y <= 160;'
then:
- component.update: temp_minus_one_unique
- homeassistant.service:
service: climate.set_temperature
data:
entity_id: climate.lounge_ac
temperature: !lambda 'return id(temp_minus_one_unique).state;'
- component.update: main_display
# === Temperature increase (+ button) ===
- if:
condition:
and:
- lambda: 'return touch.x >= 215 && touch.x <= 320;'
- lambda: 'return touch.y >= 85 && touch.y <= 160;'
then:
- component.update: temp_plus_one_unique
- homeassistant.service:
service: climate.set_temperature
data:
entity_id: climate.lounge_ac
temperature: !lambda 'return id(temp_plus_one_unique).state;'
- component.update: main_display
# === MODE cycling (bottom-left tile) ===
- if:
condition:
and:
- lambda: 'return touch.x >= 0 && touch.x <= 105;'
- lambda: 'return touch.y >= 165 && touch.y <= 240;'
then:
- homeassistant.service:
service: climate.set_hvac_mode
data:
entity_id: climate.lounge_ac
hvac_mode: !lambda |-
std::string current = id(ac_climate_state).state;
std::transform(current.begin(), current.end(), current.begin(), ::tolower);
if (current == "off") return "cool";
else if (current == "cool") return "heat";
else if (current == "heat") return "dry";
else if (current == "dry") return "fan_only";
else if (current == "fan_only") return "heat_cool";
else return "off";
- component.update: main_display
# === FAN cycling (bottom-middle tile) ===
- if:
condition:
and:
- lambda: 'return touch.x >= 110 && touch.x <= 210;'
- lambda: 'return touch.y >= 165 && touch.y <= 240;'
then:
- homeassistant.service:
service: climate.set_fan_mode
data:
entity_id: climate.lounge_ac
fan_mode: !lambda |-
std::string current = id(ac_fan_mode).state;
std::transform(current.begin(), current.end(), current.begin(), ::tolower);
if (current == "auto") return "quiet";
else if (current == "quiet") return "low";
else if (current == "low") return "medium";
else if (current == "medium") return "high";
else return "auto";
- component.update: main_display
# === SWING cycling (bottom-right tile) ===
- if:
condition:
and:
- lambda: 'return touch.x >= 215 && touch.x <= 320;'
- lambda: 'return touch.y >= 165 && touch.y <= 240;'
then:
- homeassistant.service:
service: climate.set_swing_mode
data:
entity_id: climate.lounge_ac
swing_mode: !lambda |-
std::string current = id(ac_swing_mode).state;
std::transform(current.begin(), current.end(), current.begin(), ::tolower);
if (current == "off") return "vertical";
else if (current == "vertical") return "horizontal";
else if (current == "horizontal") return "both";
else return "off";
- component.update: main_display
# Sensors from Home Assistant
text_sensor:
- platform: homeassistant
id: ac_climate_state
entity_id: climate.lounge_ac
internal: true
- platform: homeassistant
id: ac_fan_mode
entity_id: climate.lounge_ac
attribute: fan_mode
internal: true
- platform: homeassistant
id: ac_swing_mode
entity_id: climate.lounge_ac
attribute: swing_mode
internal: true
sensor:
- platform: homeassistant
id: ac_current_setpoint
entity_id: climate.lounge_ac
attribute: temperature
internal: true
unit_of_measurement: "°C"
accuracy_decimals: 0
- platform: homeassistant
id: ac_current_temperature
entity_id: climate.lounge_ac
attribute: current_temperature
internal: true
unit_of_measurement: "°C"
accuracy_decimals: 1
# Template sensor for -1°C from target
- platform: template
id: temp_minus_one_unique
unit_of_measurement: "°C"
accuracy_decimals: 0
lambda: |-
if (id(ac_current_setpoint).has_state()) {
return std::max(id(ac_current_setpoint).state - 1.0f, 16.0f);
}
return 22.0f;
# Template sensor for +1°C from target
- platform: template
id: temp_plus_one_unique
unit_of_measurement: "°C"
accuracy_decimals: 0
lambda: |-
if (id(ac_current_setpoint).has_state()) {
return std::min(id(ac_current_setpoint).state + 1.0f, 30.0f);
}
return 24.0f;
