Mini weather display using ESPHome and a TFT screen

Sharing a bedside current weather conditions display that uses ESPHome and a small TFT screen, importing most sensor readings from HA.

This provides at-a-glance access to important questions like “is it raining?”, “what’s the temp outside?”, “what is today’s forecast?”, “Is the room hot, or is it just me?”, without having to lift a finger or access your phone etc. You can now check conditions before getting out of bed and selecting an outfit for the day :slight_smile: .


Looks better in real life!

Hardware:

  • A small SPI TFT display (1.8" / 47mm); a bigger display would be better
  • ESP32 C3 supermini; any ESP32 (preferably with PSRAM) would do
  • Light sensor (VEML7700); can remove and rely on time-of-day / PIR (etc) for brightness control

Details on screen (L>R, T>B):

  • Current outside temperature (SHT31 sensor, via HA)
  • Current outside humidity (SHT31 sensor, via HA)
  • Current “feels like” temperature (via calculations in HA)
  • Rain / no rain indicator (custom sensor, via HA)
  • Rainfall today (custom tipping-bucket sensor, via HA)
  • Forecast max/min/synopsis for today & tomorrow (from Aust BoM, via HA)
  • Current time (via HA)
  • Current day / date (via HA)
  • Current room temperature (from existing room sensor, via HA)

Other features of note:

  • “Retro” 7-segment display font in use for main readings, with custom spacing for better readibility - and an old-school flashing colon in the time!
  • Rain detail display is dynamic and only displayed if relevant.
  • Forecast details change from for Min - Max (today) & Min - Max (tmrw) to Max - Min (today) and Max (tmrw) as BoM updates details.
  • I use Aust BoM feels-like calculations plus my PWS temp/wind/humidity readings to generate a truly local “feels like”.
  • Custom scrolling text function is used for synopsis details to ensure the full “mini synopsis” sourced from BoM is displayed.
  • Display is dimmed at night / low-light to prevent sleep interruption.

This project was just to build a gadget to display existing information…most heavy lifting is done by existing sensors and logic in HA. The most challenging parts of this project (yet to build a nice case!) were:

  • Finding suitable fonts & sizes to fit everything I wanted on a low-resolution screen
  • Building a screen layout that is sensible / easy-to-read
  • Getting the display to work on an ESP32-C3 supermini

Code below. There’s plenty of room for improvement, and I’ll probably move to a larger screen shortly. Note all HA sensors are custom and need replacement with local sensors.

# ESP32-C3SuperMini, bedside weather display, Britespark 02/2026
esphome:
  name: $devicename
  friendly_name: $upper_devicename
  min_version: 2025.5.0
  name_add_mac_suffix: false
  on_boot:
    priority: -100    # Lowest priority is -100, this ensuree all other boot tasks are completed first
    then:
      # Logic to set the initial display brightness based on time of day
      - lambda: |-
          auto time_now = id(homeassistant_time).now();
          auto call = id(back_light).turn_on();
          if (time_now.is_valid()) {
            if ((time_now.hour >= 23) || (time_now.hour < 6))
              call.set_brightness(0.3);
            else
              call.set_brightness(1.0);
            call.perform();
          }

esp32:
  board: esp32-c3-devkitm-1
  framework:
    type: esp-idf

# Enable logging
logger:

# Enable Home Assistant API
api:

# Allow Over-The-Air updates
ota:
- platform: esphome

substitutions:
  devicename: esp32-c3mini-01
  upper_devicename: ESP32-C3mini-01
  pin_C3_clk: "4"       #GPIO4 - SPI CLK
  pin_C3_mosi: "6"      #GPIO6 - SPI MOSI
  pin_C3_sda: "20"      #GPIO20 - I2C SDA
  pin_C3_scl: "21"      #GPIO21 - I2C SCL
  pin_C3_led: "8"       #GPIO8 - ESP C3 onboard LED
  pin_lcd_reset: "1"    #GPIO1 - LCD reset
  pin_lcd_cs: "2"       #GPIO2 - LCD CS
  pin_lcd_dc: "0"       #GPIO0 - LCD DC
  pin_lcd_bklit: "3"    #GPIO3 - LCD backlight

# FONTS for LCD screen
  font_2r: "fonts/RobotoCondensed-Light.ttf"
  font_3: "fonts/DigitalDisplay.ttf"
  font_5r: "fonts/RobotoCondensed-Medium.ttf"

# ===== Networking =====

wifi:
  networks:
  - ssid: !secret wifi_ssid
    password: !secret wifi_password
  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: $devicename AP
    password: !secret fallback_hotspot_pswd

captive_portal:

time:
  - platform: homeassistant
    id: homeassistant_time
    on_time: 
      # dim display at 23:00 and full brightness at 06:00
      - seconds: 0
        minutes: 0
        hours: 23
        then:
          - light.turn_on:
              id: back_light
              brightness: 30%
      - seconds: 0
        minutes: 0
        hours: 6
        then:
          - light.turn_on:
              id: back_light
              brightness: 100%


# ===== Hardware interfaces =====

spi:
  clk_pin: GPIO${pin_C3_clk}
  mosi_pin: GPIO${pin_C3_mosi}

i2c:
  sda: GPIO${pin_C3_sda} # Non-default
  scl: GPIO${pin_C3_scl} # Non-default

# ===== Components & Devices =====

light:
  - platform: status_led
    # Onboard status LED allocation & control
    name: $upper_devicename Status LED
    id: esp32_c3mini_01_status_led
    pin: 
      number: GPIO${pin_C3_led}
      inverted: true
  - platform: monochromatic
    # Define a monochromatic, dimmable light for the backlight
    output: backlight_pwm
    name: $upper_devicename Display Backlight
    id: back_light
    restore_mode: ALWAYS_ON

sensor:
  - platform: wifi_signal
    # Platform parameters
    name: $upper_devicename WiFi Signal strength
    update_interval: 60s
    accuracy_decimals: 0
  - platform: uptime
    name: $upper_devicename Uptime
    unit_of_measurement: d
    update_interval: 300s
    accuracy_decimals: 1
    filters:
      - multiply: 0.000011574

  - platform: veml7700
  # Ambient light sensor VEML 7700.  4-wire connection. Black is 0 lx, very bright light > 300,000 lx.
    address: 0x10
    update_interval: 5s
    ambient_light: 
      name: $upper_devicename Ambient light
      id: C3_01_ambient
      # This value check lowers backlight brightness to 50% when room is dark (<11 lx)
      on_value_range: 
        - below: 9
          then:
            - light.turn_on:
                id: back_light
                brightness: 30%
        - above: 9
          then:
            - light.turn_on:
                id: back_light
                brightness: 100%
    actual_gain:
      name: $upper_devicename Actual gain

    # Homeassistant sensor imports
  - platform: homeassistant
    name: "Master bedroom"
    id: Temp_BedM
    entity_id: sensor.tz3000_bjawzodf_ty0201_temperature
  - platform: homeassistant
    entity_id: sensor.esp32_tank_Temp_SHT3X
    id: Temp_outside
  - platform: homeassistant
    entity_id: sensor.esp32_tank_humidity_sht3x
    id: Hum_outside
  - platform: homeassistant
    entity_id: sensor.rainfall_today_v2
    id: Today_rain
  - platform: homeassistant
    entity_id: sensor.BOM_local_temp_max_0
    id: Temp_max
  - platform: homeassistant
    entity_id: sensor.BOM_local_temp_min_0
    id: Temp_min 
  - platform: homeassistant
    entity_id: sensor.BOM_local_temp_max_1
    id: Temp_max1
  - platform: homeassistant
    entity_id: sensor.BOM_local_temp_min_1
    id: Temp_min1
  - platform: homeassistant
    entity_id: sensor.feels_like_temp
    id: Temp_feels

binary_sensor:
  - platform: homeassistant
    entity_id: binary_sensor.esp32_tank_raining
    # Import current raining check sensor from HA
    id: Rain_check

output:
  - platform: ledc
    # Define a PWM output on the ESP32 for screen backlight control
    pin: GPIO${pin_lcd_bklit}
    id: backlight_pwm

text_sensor:
  - platform: wifi_info
    # Add WiFi parameters
    ip_address:
      name: $upper_devicename IP Address
    ssid:
      name: $upper_devicename Connected SSID
    mac_address:
      name: $upper_devicename Mac Wifi Address
    scan_results:
      name: $upper_devicename Latest Scan Results

  - platform: homeassistant
    entity_id: sensor.BOM_local_short_text_0
    id: Weather_today
  - platform: homeassistant
    entity_id: sensor.BOM_local_short_text_1
    id: Weather_tmrw

# ===== Fonts & display configuration =====

font:
  - file: $font_2r
    id: font_2r_s1
    size: 15
  - file: $font_2r
    id: font_2r_s2
    size: 13
  - file: $font_3
    id: font_3r_s1
    size: 46
  - file: $font_3
    id: font_3r_s2
    size: 22
  - file: $font_3
    id: font_3r_s3
    size: 30
  - file: $font_5r
    id: font_5r_s1
    size: 15

# ===== Display settings =====

display:
  - platform: ili9xxx
    model: ST7735
    # 1.8" TFT display (red board) 128x160
    dimensions:
      height: 160
      width: 128
    invert_colors: false
    show_test_card: false
    reset_pin: GPIO${pin_lcd_reset}
    cs_pin: GPIO${pin_lcd_cs}
    dc_pin: GPIO${pin_lcd_dc}
    rotation: 180°
    update_interval: 500ms   # was 500ms for non-scrolling
    lambda: |-
      auto red = Color(255, 0, 0);
      auto green = Color(0, 255, 0);
      auto green2 = Color(0, 192, 0);   // Was 0, 224, 0 - dulling it down a bit
      auto blue = Color(0, 0, 255);
      auto blue2 = Color(224, 224, 255);
      auto yellow = Color(255, 255, 0);
      auto yellow2 = Color (215, 215, 0);
      auto amber = Color(255, 200, 0);      // Originally 255,170,0
      auto amber2 = Color(224, 184, 0);     // Was 172, 127, 0
      auto white = Color(255, 255, 255);

      unsigned long current_time = millis();
      static bool show_text = true;             // Static variable to toggle text visibility
      static unsigned long last_toggle_time = 0;
      int disp_width = it.get_width();

      // TFT display row settings
      int r_time = 122;
      int r_BOM1 = 54;
      int r_BOM2 = r_BOM1 + 16;
      int r_BOM3 = r_BOM2 + 16;
      int r_BOM4 = r_BOM3 + 16;

      // Variables below required for scrolling text only (Synopsis - today)
      static int sct_pos = 0;
      int sct_width = 0;
      int sct_ht = 0;
      int x1 = 0;
      int y1 = 0;

      // Variables below required for scrolling text only (Synopsis - tmrw)
      static int sct2_pos = 0;
      int sct2_width = 0;
      int sct2_ht = 0;
      int x2 = 0;
      int y2 = 0;

      // Variables below required for scrolling text only (TEST)
      static int test_pos = 0;
      std::string test_text = "Warm and sunny, then cloudy with a chance of meatballs. ";   // About 326 pixels using font_2r_s1
      int test_width = 0;
      int test_ht = 0;
      int x0 = 0;
      int y0 = 0;


      // Toggle text visibility every 500ms (adjust as needed)
      if (current_time - last_toggle_time >= 500) {
        show_text = !show_text;
        last_toggle_time = current_time;
      }

      // ===== Display time with seconds and flashing colon separator
      // To compress text around colon with monospaced font the H, M, : are written individually.
      //  : is written first to prevent over-write on H and M
      // Flashing colon
      if (show_text) {
        it.printf(24, r_time, id(font_3r_s3), red, ":");
      } else {
        it.printf(24, r_time, id(font_3r_s3), red, " ");
      }
      it.strftime(2, r_time, id(font_3r_s3), red, "%H", id(homeassistant_time).now());
      it.strftime(34, r_time, id(font_3r_s3), red, "%M", id(homeassistant_time).now());
      it.strftime(62, r_time, id(font_3r_s2), red, "%S", id(homeassistant_time).now());

      // ===== Current temperature readings
      float temp_out = id(Temp_outside).state;
      int temp_out_int = (int)std::floor(temp_out);
      it.printf(2, 5, id(font_3r_s1), yellow, "%.0f", floor(id(Temp_outside).state));
      it.printf(39, 18, id(font_3r_s2), yellow, ".%.0f", floor(10 * (temp_out - temp_out_int)));
      it.printf(43, 1, id(font_2r_s1), yellow, "°C");
      it.printf(64, 18, id(font_3r_s2), amber2, "%.0f", id(Hum_outside).state);
      it.printf(84, 18, id(font_2r_s1), amber2, "%%");
      it.printf(66, 1, id(font_2r_s2), amber, "Feels:");
      it.printf(it.get_width(), 1, id(font_2r_s1), amber, TextAlign::RIGHT, "°");
      it.printf(it.get_width()-5, 3, id(font_3r_s3), amber, TextAlign::RIGHT, "%.0f", id(Temp_feels).state);

      // ===== Indoor (room) temperature
      it.printf(it.get_width(), 118, id(font_2r_s2), green, TextAlign::RIGHT, "Room: ");
      it.printf(it.get_width(), 132, id(font_2r_s1), green, TextAlign::RIGHT, "°");
      it.printf(it.get_width()-5, 136, id(font_3r_s3), green, TextAlign::RIGHT, "%.0f", id(Temp_BedM).state);

      // ===== Raining indicator
      if (id(Rain_check).state) {
        it.printf(2, 36, id(font_2r_s1), white, "Rain!");
      }
      if (id(Today_rain).state) {
        it.printf(it.get_width(), 36, id(font_2r_s1), blue2, TextAlign::RIGHT, "%.1f mm today", id(Today_rain).state);
      }

      //===== Add BOM forecast details
      it.printf(2, r_BOM1, id(font_5r_s1), amber, "Today:");
      it.printf(2, r_BOM3, id(font_5r_s1), amber, "Tmrw:");
      // Display Min > Max OR Max > Min plus tomorrow details
      if (isnan(id(Temp_min).state)) {
        it.printf(47, r_BOM1, id(font_2r_s1), amber, "%.0f° - %.0f°", id(Temp_max).state, id(Temp_min1).state);
        it.printf(47, r_BOM3, id(font_2r_s1), amber, "%.0f°", id(Temp_max1).state);
      }
      else {
        it.printf(47, r_BOM1, id(font_2r_s1), amber, "%.0f° - %.0f°", id(Temp_min).state, id(Temp_max).state);
        it.printf(47, r_BOM3, id(font_2r_s1), amber, "%.0f° - %.0f°", id(Temp_min1).state, id(Temp_max1).state);
      }
      // Display synopsis (scrolling) for today
      it.get_text_bounds(0, 0, id(Weather_today).state.c_str(), id(font_2r_s1), TextAlign::TOP_LEFT, &x1, &y1, &sct_width, &sct_ht);  // Determine the pixel length of text line
      if (sct_width > disp_width) {
        it.printf(sct_pos, r_BOM2, id(font_2r_s1), amber, "%s", id(Weather_today).state.c_str());  // Redraw text at the current scroll position
        sct_pos -= 2; // Update the scroll position for the next update_interval by moving left 2 pixels
        if (sct_pos < -sct_width) {   // If the text has scrolled completely off-screen, reset its position
          sct_pos = 2;    // Set scroll start position to start of line.
        }
      }
      else {      // If text fits, just display it statically
        it.printf(2, r_BOM2, id(font_2r_s1), amber, id(Weather_today).state.c_str());
        sct_pos = 0; // Reset global variable if no scrolling needed
      }

      // Display synopsis (scrolling) for tomorrow
      it.get_text_bounds(0, 0, id(Weather_tmrw).state.c_str(), id(font_2r_s1), TextAlign::TOP_LEFT, &x2, &y2, &sct2_width, &sct2_ht);  // Determine the pixel length of text line
      if (sct2_width > disp_width) {
        it.printf(sct2_pos, r_BOM4, id(font_2r_s1), amber, "%s", id(Weather_tmrw).state.c_str());  // Redraw text at the current scroll position
        sct2_pos -= 2; // Update the scroll position for the next update_interval by moving left 2 pixels
        if (sct2_pos < -sct_width) {   // If the text has scrolled completely off-screen, reset its position
          sct2_pos = 2;    // Set scroll start position to start of line.
        }
      }
      else {      // If text fits, just display it statically
        it.printf(2, r_BOM4, id(font_2r_s1), amber, id(Weather_tmrw).state.c_str());
        sct2_pos = 0; // Reset global variable if no scrolling needed
      }

      //===== Day / date
      it.strftime(2, 142, id(font_2r_s2), red, "%a %d %b %Y", id(homeassistant_time).now());

3 Likes