ESPHome Climate Controller using Cheap Yellow Display (Win10 phone look)

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 :rofl: )
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;