A Glanceable Bathroom Dashboard: ESPHome on the LilyGo T-Display S3 AMOLED

I have a growing number of Home Assistant entities I care about, and none of them live where I actually spend my mornings. My phone has the official app, my laptop has Grafana, but the place I reliably stand still for two minutes every day is the bathroom sink. What I really wanted was a small screen above that sink that shows me useful things at a glance while I brush my teeth, without me having to unlock anything. A few weeks ago I picked up a LilyGo T-Display S3 AMOLED and spent a weekend wiring it into my ESPHome setup. It is now one of my favorite bits of hardware in the house.

The board

The T-Display S3 AMOLED is a compact dev board with a 1.91 inch AMOLED panel (536x240), driven by an ESP32-S3. The interesting bits:

  • True AMOLED, not the usual cheap IPS. Blacks are actually black, which matters a lot when the thing lives on a bathroom wall and has to not look obnoxious at 2 AM.
  • Quad SPI display, so refreshes are fast enough to animate without tearing.
  • ESP32-S3 with 16 MB flash and octal PSRAM (yes, you need PSRAM for the framebuffer of a display this wide).
  • Two user buttons, USB-C, a small case, and a price that is hard to argue with.

On paper this is a dev toy, but it turns out to be a very competent little Home Assistant panel once you get ESPHome talking to it.

Why ESPHome

I already run everything off Home Assistant, and I already have a small fleet of ESPHome nodes (temperature, CO2, presence, a few reed switches on doors). Pulling the panel into the same fleet means:

  • No custom firmware pipeline. ESPHome builds and OTA-flashes everything.
  • Home Assistant entities for free. Every sensor and button on the board shows up automatically.
  • Lambdas for layout. I can draw the display with the same C++ lambda syntax I use elsewhere, no LVGL project to maintain separately.
  • Bluetooth proxy as a bonus. The ESP32-S3 runs a BLE proxy for Home Assistant on the side, so it also extends BLE coverage into a part of the apartment that was previously a dead zone for BLE devices.

The trade-off is that I do not get a full GUI framework. I am drawing rectangles, text, and PNGs directly. For a glanceable dashboard that is fine, and arguably better: less to go wrong, and the frame time stays predictable.

The wiring: display and sensors

The ESPHome config uses the built-in mipi_spi display driver with the T-DISPLAY-S3-AMOLED model preset. That takes care of the quad-SPI timing and the panel init sequence, both of which used to be a nightmare on this hardware. The relevant block:

spi:
  id: quad_spi
  type: quad
  clk_pin: 47
  data_pins: [18, 7, 48, 5]

display:
  - platform: mipi_spi
    model: T-DISPLAY-S3-AMOLED
    id: main_lcd
    rotation: 90
    brightness: 242
    dimensions:
      width: 536
      height: 240

The stock board actually gives me two I2C buses with zero soldering:

  • Internal bus on GPIO2/3 for board-level stuff.
  • STEMMA QT / Qwiic port on the side of the board, which exposes GPIO43/44 as SDA/SCL on a standard JST-SH 1 mm connector. That port is the one I hang the real sensors off: a Sensirion SHT40 (temperature and humidity) and a Sensirion STCC4 (CO2 plus an onboard SHT45). STEMMA QT sensors daisy-chain, so one cable goes from the board to the first sensor, another from the first to the second, done. No breadboard, no soldering iron, no voltage level worries.

The STCC4 is worth a note: at the time of writing its ESPHome component is still a pending PR (esphome/esphome#14037), so I pull it from a fork via external_components. ESPHome’s Git-based external component loader makes that trivial:

external_components:
  - source:
      type: git
      url: https://github.com/will-tm/esphome
      ref: feature/stcc4-sensor
    components: [stcc4]

Same mechanism brings in the DFRobot C4002 driver for the 24 GHz mmWave presence sensor sitting next to the panel. That sensor is the secret ingredient, and I will come back to it in a second.

Three pages you flip through with the buttons

The board has two buttons (GPIO0 and GPIO21 on this variant). I mapped them to page navigation:

binary_sensor:
  - platform: gpio
    pin: { number: GPIO0, mode: INPUT_PULLUP, inverted: true }
    name: "Button Left"
    on_press:
      - display.page.show_previous: main_lcd
  - platform: gpio
    pin: { number: GPIO21, mode: INPUT_PULLUP, inverted: true }
    name: "Button Right"
    on_press:
      - display.page.show_next: main_lcd

Three pages, each drawn by a lambda:

Page 1: Weather

The default page. A 72 pixel temperature hero on the left, a 100x100 weather icon, a “feels like” line from a wind chill sensor, and a row of secondary info (wind, humidity, high/low, chance of precipitation). Below that, a UV index strip with a colored scale and a tick mark at the current value. If UV goes above 5, the whole row turns into an alert banner, which is a useful nudge in summer.

The weather icons are Meteocons by Bas Milius (MIT licensed). ESPHome can pull images straight from a URL at build time:

image:
  - file: "https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/png/128/clear-day.png"
    id: img_sunny
    type: RGB
    transparency: alpha_channel
    resize: 100x100

Alpha transparency over the black AMOLED looks genuinely nice. The 128 pixel source images downsize cleanly to 100x100 (or 36x36 for the small indicators).

The bottom strip of page 1 is the “indoor” row: temperature, humidity, and CO2 from the SHT40/STCC4, with the CO2 value color-coded (green under 800 ppm, yellow up to 1000, orange up to 1500, red above). Bathrooms are rough on these numbers (showers spike the humidity, closed door spikes the CO2) and watching them climb in real time turned out to be a decent nudge to crack a window after a shower.

Page 2: Energy

Current power draw (from my whole-home energy meter, via Home Assistant) in a giant colored number that turns yellow above 2 kW and red above 5 kW, plus total energy consumption and a one-hour trailing graph of watts. ESPHome’s built-in graph component does the heavy lifting:

graph:
  - id: watt_history
    duration: 1h
    width: 536
    height: 120
    traces:
      - sensor: current_watt
        line_type: SOLID
        line_thickness: 2
        continuous: true

Glanceable, and surprisingly useful. In Canada the two big loads that move the needle are the heat pump in winter and the AC compressor in summer, both of which cycle on and off on their own. A quick look at the panel tells me whether the current spike is “oh the heat just kicked in” or “something is genuinely wrong”, without having to open Home Assistant.

Page 3: Toothbrush

Yes, really. I have an Oral-B Genius X. For years, the premium Oral-B brushes shipped with a small separate LCD (sometimes called the “SmartGuide”) that sat on a shelf in the bathroom and showed you live brushing feedback during a session: mode, which quadrant you were working on, pressure, and a timer. It was a nice piece of hardware: wake it by starting the brush, ignore it the rest of the time, no batteries to worry about on your end, no phone required.

By the time the Genius series shipped, that separate LCD was gone. The “smart” feedback moved to Bluetooth-only via the phone app. Which sounds fine on a spec sheet and is terrible every morning. Unlocking a phone, opening an app, and propping it up against the sink with toothpaste hands is not something anyone is going to do consistently for two minutes of brushing. I stopped using it within a week.

So the toothbrush page on the T-Display is essentially me rebuilding the external LCD Oral-B removed. The brush exposes its live state to Home Assistant via a community integration (battery, current mode, which quadrant it thinks I am brushing, pressure feedback, timer). The panel page shows a colored battery gauge, a big mm:ss timer, four colored sector tiles that light up as I work through my mouth, and a pressure indicator at the bottom. Exactly the feature the old separate LCD used to provide, just now running on a panel that also shows me the weather, my power draw, and whether I should put sunscreen on.

The fun part is that the page automatically activates when I pick up the brush:

- platform: homeassistant
  id: brush_state
  entity_id: sensor.genius_x_f34b
  on_value:
    then:
      - if:
          condition:
            lambda: 'return x == "running";'
          then:
            - globals.set: { id: brush_was_active, value: "true" }
            - display.page.show: page_brush
          else:
            - if:
                condition:
                  lambda: "return id(brush_was_active);"
                then:
                  - globals.set: { id: brush_was_active, value: "false" }
                  - delay: 10s
                  - display.page.show: page_weather

Start brushing, the panel snaps to the toothbrush page. Stop brushing, it waits 10 seconds and goes back to weather, which is the right page to see while I finish getting ready. That one tiny piece of automation has genuinely improved my brushing habits, which is an absurd sentence to write but here we are.

mmWave presence: screen on when I am there, bathroom lights while we are at it

AMOLED looks great, but a weather page that stays lit all day is pointless: nobody is looking at it when nobody is in the room, and it is also a slow way to eat pixel lifetime for no reason. The panel should be on when I am standing in front of it and off the rest of the time.

The fix is the DFRobot C4002 mmWave sensor, and once I had it sitting on the wall for the panel, I realized it could do a second job at the same time: drive the bathroom lights. Motion-sensor lights in bathrooms are a classic pain point because the standard PIR approach turns the lights off on you the moment you sit still for more than 30 seconds. 24 GHz mmWave does not have that problem: it sees breathing-scale motion, so as long as someone is in the room, it reports presence. The same sensor I bought for the panel became the input to the bathroom lights automation in Home Assistant, and the old PIR came out of the ceiling.

The sensor gives me a clean “someone is nearby” binary, plus motion direction, motion speed, and distance. On the panel side, I wired it so that the screen brightness tracks presence:

sensor:
  - platform: dfrobot_c4002
    target_status:
      id: target_status_sensor
      internal: true
      on_value:
        then:
          - if:
              condition: { switch.is_on: screen_presence_control }
              then:
                - if:
                    condition:
                      lambda: 'return x > 0;'
                    then:
                      - lambda: 'id(main_lcd).set_brightness((uint8_t)(id(screen_brightness_target).state * 2.55f));'
                    else:
                      - lambda: 'id(main_lcd).set_brightness(0);'

Walk into the bathroom, the panel lights up at the brightness I set via the slider (exposed as a Home Assistant number entity) and the ceiling lights come on via a separate HA automation pointed at the same target_status entity. Walk out, brightness goes to zero and the lights fade off after a short delay. No wasted light in an empty room, no PIR-style latency (mmWave is basically instant and ignores passive heat, so warm things moving just outside the door do not falsely trigger anything).

There is also a switch in Home Assistant to disable presence control on the panel and force the screen on, for the rare times I want it as an always-on clock. The lights automation has its own override for the same reason.

One more trick: the sunscreen reminder LED

The board has a green LED on GPIO38 that normally controls the display enable pin. I do not need that pin for the display (the mipi_spi driver handles enable internally), so I freed it and repurposed it as a sunscreen reminder. The rule is embarrassingly simple: if today’s UV index is above 4, the LED is on. If the LED is on when I am already at the bathroom mirror getting ready in the morning, the sunscreen is right there in the cabinet. Perfect location for the nudge.

sensor:
  - platform: homeassistant
    id: uv_index
    entity_id: sensor.sainte_catherine_uv_index
    on_value:
      then:
        - if:
            condition:
              lambda: 'return x > 4;'
            then: { light.turn_on: uv_led }
            else: { light.turn_off: uv_led }

Dumb, visible, physical. I do not have to open an app, I do not have to read a number. Green light on, put on sunscreen. Works surprisingly well, and the LED gets switched off by the same off branch the moment the UV drops below the threshold, so it does not become wallpaper.

What I like about this setup

A few observations after living with it for a while:

  • Build and flash iteration is fast. ESPHome OTA on the S3 takes 20 seconds or so, and lambda edits are almost as quick as editing HTML.
  • The AMOLED matters. I thought it would be a gimmick. It is not. Standing at the sink in a dim bathroom with a black background and a few colored glyphs, it looks premium, not like a hobby gadget.
  • Quad SPI is the right default. I had tried a previous T-Display with a slower bus and the refresh rate was a dealbreaker. Not on this one.
  • Lambdas beat LVGL for this use case. I do not need scroll views or touch gestures. I need to draw static info fast and clearly. Lambdas do that in 40 lines per page.
  • External components are load-bearing. Two of the most useful sensors in this build (C4002 and STCC4) are not in the ESPHome core tree. Being able to git clone a component into the build is a quiet superpower.

What I would add

  • A dedicated brightness override button. Right now brightness is a slider in Home Assistant. Handy on the phone, awkward in person (and especially awkward with wet hands at the sink). I will probably wire one of the existing buttons as a long-press brightness cycle.
  • Dark-aware dimming, driven by sensors I already have. I have several lux sensors scattered around the house for other automations. I can feed those into Home Assistant logic to detect when the ambient light is genuinely low (middle of the night, not just “the bathroom is dim because the door is closed”) and, when it is, both drop the panel brightness to a very low value and cap the bathroom ceiling lights so a 3 AM presence trigger does not blind me. No new hardware needed on the panel side, just smarter automations on top of the sensors already in the house.

If you want to build one

The full ESPHome YAML is below, with secrets (WiFi, API encryption key, OTA password) moved to !secret references and personal Home Assistant entity IDs replaced with generic placeholders. Point the homeassistant sensors at your own weather, energy, and toothbrush entities, adjust the I2C pins if you wired them differently, and you should be close.

Full anonymised ESPHome YAML (click to expand)
esphome:
  name: bathroom-display
  friendly_name: Bathroom Display
  platformio_options:
    build_unflags: -Werror=all
    board_build.flash_mode: dio

logger:
  level: INFO

esp32:
  board: esp32-s3-devkitc-1
  variant: esp32s3
  framework:
    type: esp-idf
  flash_size: 16MB

psram:
  mode: octal
  speed: 80MHz

uart:
  id: uart_bus
  tx_pin: GPIO40
  rx_pin: GPIO41
  baud_rate: 115200

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

api:
  encryption:
    key: !secret api_encryption_key

ota:
  - platform: esphome
    password: !secret ota_password

# External components
external_components:
  - source:
      type: git
      url: https://github.com/cdjq/esphome.git
      ref: dev
    components:
      dfrobot_c4002
  # STCC4 CO2 sensor (Sensirion), pending PR esphome/esphome#14037
  - source:
      type: git
      url: https://github.com/will-tm/esphome
      ref: feature/stcc4-sensor
    components: [stcc4]

dfrobot_c4002:
  id: my_c4002

# --- Hardware ---
spi:
  id: quad_spi
  type: quad
  clk_pin: 47
  data_pins: [18, 7, 48, 5]

i2c:
  - id: bus_internal
    sda: 3
    scl: 2
    scan: true
  - id: bus_sensors
    sda: 43
    scl: 44
    frequency: 100kHz
    scan: true

esp32_ble_tracker:

bluetooth_proxy:
  active: true

# --- UV alert LED on GPIO38 ---
output:
  - platform: gpio
    pin: GPIO38
    id: led_output

light:
  - platform: binary
    name: "UV Alert LED"
    id: uv_led
    output: led_output

time:
  - platform: homeassistant
    id: ha_time

globals:
  - id: brush_was_active
    type: bool
    initial_value: "false"

# --- Buttons ---
binary_sensor:
  - platform: gpio
    pin:
      number: GPIO0
      mode: INPUT_PULLUP
      inverted: true
    name: "Button Left"
    id: btn_left
    on_press:
      - display.page.show_previous: main_lcd
      - component.update: main_lcd

  - platform: gpio
    pin:
      number: GPIO21
      mode: INPUT_PULLUP
      inverted: true
    name: "Button Right"
    id: btn_right
    on_press:
      - display.page.show_next: main_lcd
      - component.update: main_lcd

# --- Home Assistant text sensors ---
text_sensor:
  - platform: template
    name: "Movement Direction"
    id: movement_direction_text
    icon: "mdi:directions"
    lambda: |-
      int d = id(movement_direction_sensor).state;
      if (d == 0) return {"Approaching"};
      else if (d == 1) return {"No Direction"};
      else if (d == 2) return {"Away"};
      else return {"Unknown"};
    update_interval: 1s

  - platform: template
    name: "Target Status"
    id: target_status_text
    icon: "mdi:human-greeting"
    lambda: |-
      int d = id(target_status_sensor).state;
      if (d == 0) return {"No Target"};
      else if (d == 1) return {"Static Presence"};
      else if (d == 2) return {"Motion"};
      else return {"Unknown"};
    update_interval: 0.5s

  - platform: dfrobot_c4002
    c4002_id: my_c4002
    c4002_text_sensor:
      name: "C4002 log"
      icon: "mdi:message-text-outline"

  - platform: homeassistant
    id: weather_condition
    entity_id: weather.home
    on_value:
      - component.update: main_lcd

  - platform: homeassistant
    id: brush_state
    entity_id: sensor.toothbrush_state
    on_value:
      then:
        - if:
            condition:
              lambda: 'return x == "running";'
            then:
              - globals.set:
                  id: brush_was_active
                  value: "true"
              - display.page.show: page_brush
              - component.update: main_lcd
            else:
              - if:
                  condition:
                    lambda: "return id(brush_was_active);"
                  then:
                    - globals.set:
                        id: brush_was_active
                        value: "false"
                    - delay: 10s
                    - display.page.show: page_weather
                    - component.update: main_lcd

  - platform: homeassistant
    id: brush_mode
    entity_id: sensor.toothbrush_mode
    on_value:
      - component.update: main_lcd

  - platform: homeassistant
    id: brush_sector
    entity_id: sensor.toothbrush_sector
    on_value:
      - component.update: main_lcd

  - platform: homeassistant
    id: brush_pressure
    entity_id: sensor.toothbrush_pressure
    on_value:
      - component.update: main_lcd

select:
  - platform: dfrobot_c4002
    c4002_id: my_c4002
    operating_mode:
      name: "OUT Mode"
      options:
        - "Mode_1"
        - "Mode_2"
        - "Mode_3"

number:
  - platform: dfrobot_c4002
    max_range:
      name: "Max detection distance"
    min_range:
      name: "Min detection distance"
    light_threshold:
      name: "Light Threshold"
    target_disappeard_delay_time:
      name: "Target Disappear Delay Time"

  - platform: template
    name: "Screen Brightness"
    id: screen_brightness_target
    icon: "mdi:brightness-6"
    optimistic: true
    restore_value: true
    min_value: 0
    max_value: 100
    step: 1
    initial_value: 95
    unit_of_measurement: "%"
    mode: slider
    on_value:
      then:
        - if:
            condition:
              or:
                - switch.is_off: screen_presence_control
                - lambda: 'return id(target_status_sensor).state > 0;'
            then:
              - lambda: 'id(main_lcd).set_brightness(x / 100.0f);'

switch:
  - platform: dfrobot_c4002
    switch_out_led:
      name: "Out LED Switch"
    switch_run_led:
      name: "Run LED Switch"
    switch_factory_reset:
      name: "Factory Reset"
    switch_environmental_calibration:
      name: "Sensor Calibration"

  - platform: template
    name: "Screen Presence Control"
    id: screen_presence_control
    icon: "mdi:motion-sensor"
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON
    turn_on_action:
      - if:
          condition:
            lambda: 'return id(target_status_sensor).state > 0;'
          then:
            - lambda: 'id(main_lcd).set_brightness((uint8_t)(id(screen_brightness_target).state * 2.55f));'
          else:
            - lambda: 'id(main_lcd).set_brightness(0);'
    turn_off_action:
      - lambda: 'id(main_lcd).set_brightness((uint8_t)(id(screen_brightness_target).state * 2.55f));'

sensor:
  - platform: dfrobot_c4002
    c4002_id: my_c4002
    movement_distance:
      name: "Motion Distance"
      id: movement_distance_sensor
    existing_distance:
      name: "Presence Distance"
      id: existing_distance_sensor
    movement_speed:
      name: "Motion Speed"
      id: movement_speed_sensor
    movement_direction:
      name: "Motion Direction"
      id: movement_direction_sensor
      internal: true
    target_status:
      name: "Target Status"
      id: target_status_sensor
      internal: true
      on_value:
        then:
          - if:
              condition:
                switch.is_on: screen_presence_control
              then:
                - if:
                    condition:
                      lambda: 'return x > 0;'
                    then:
                      - lambda: 'id(main_lcd).set_brightness((uint8_t)(id(screen_brightness_target).state * 2.55f));'
                    else:
                      - lambda: 'id(main_lcd).set_brightness(0);'

  # Weather (from Home Assistant)
  - platform: homeassistant
    id: weather_temp
    entity_id: weather.home
    attribute: temperature

  - platform: homeassistant
    id: weather_humidity
    entity_id: weather.home
    attribute: humidity

  - platform: homeassistant
    id: weather_wind_speed
    entity_id: weather.home
    attribute: wind_speed

  - platform: homeassistant
    id: uv_index
    entity_id: sensor.home_uv_index
    on_value:
      then:
        - if:
            condition:
              lambda: 'return x > 4;'
            then:
              - light.turn_on: uv_led
            else:
              - light.turn_off: uv_led

  - platform: homeassistant
    id: precip_chance
    entity_id: sensor.home_precipitation

  - platform: homeassistant
    id: wind_chill
    entity_id: sensor.home_wind_chill

  # SHT40 (on external I2C bus: SDA=43, SCL=44)
  - platform: sht4x
    i2c_id: bus_sensors
    temperature:
      name: "Temperature"
      id: sdb_temp
    humidity:
      name: "Humidity"
      id: sdb_humidity
    update_interval: 30s

  # STCC4 CO2 + onboard SHT45 (on external I2C bus)
  - platform: stcc4
    i2c_id: bus_sensors
    co2:
      name: "CO2"
      id: sdb_co2
    temperature:
      name: "STCC4 Temperature"
      id: sdb_temp_stcc4
    humidity:
      name: "STCC4 Humidity"
      id: sdb_humidity_stcc4
    measurement_mode: continuous
    update_interval: 30s

  - platform: homeassistant
    id: temp_high
    entity_id: sensor.home_high_temperature

  - platform: homeassistant
    id: temp_low
    entity_id: sensor.home_low_temperature

  # Energy (from Home Assistant)
  - platform: homeassistant
    name: "Current Watt"
    id: current_watt
    entity_id: sensor.home_power

  - platform: homeassistant
    name: "Total Conso"
    id: current_conso
    entity_id: sensor.home_energy_total

  # Toothbrush (from Home Assistant)
  - platform: homeassistant
    id: brush_battery
    entity_id: sensor.toothbrush_battery

  - platform: homeassistant
    id: brush_duration
    entity_id: sensor.toothbrush_duration

  - platform: homeassistant
    id: brush_sectors_total
    entity_id: sensor.toothbrush_total_sectors

# --- Weather PNG icons (Meteocons by Bas Milius, MIT) ---
image:
  # Large icons for current weather (100x100)
  - file: "https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/png/128/clear-day.png"
    id: img_sunny
    type: RGB
    transparency: alpha_channel
    resize: 100x100

  - file: "https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/png/128/clear-night.png"
    id: img_clear_night
    type: RGB
    transparency: alpha_channel
    resize: 100x100

  - file: "https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/png/128/cloudy.png"
    id: img_cloudy
    type: RGB
    transparency: alpha_channel
    resize: 100x100

  - file: "https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/png/128/partly-cloudy-day.png"
    id: img_partlycloudy_day
    type: RGB
    transparency: alpha_channel
    resize: 100x100

  - file: "https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/png/128/partly-cloudy-night.png"
    id: img_partlycloudy_night
    type: RGB
    transparency: alpha_channel
    resize: 100x100

  - file: "https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/png/128/rain.png"
    id: img_rainy
    type: RGB
    transparency: alpha_channel
    resize: 100x100

  - file: "https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/png/128/extreme-rain.png"
    id: img_pouring
    type: RGB
    transparency: alpha_channel
    resize: 100x100

  - file: "https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/png/128/snow.png"
    id: img_snowy
    type: RGB
    transparency: alpha_channel
    resize: 100x100

  - file: "https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/png/128/fog.png"
    id: img_fog
    type: RGB
    transparency: alpha_channel
    resize: 100x100

  - file: "https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/png/128/thunderstorms.png"
    id: img_lightning
    type: RGB
    transparency: alpha_channel
    resize: 100x100

  - file: "https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/png/128/thunderstorms-rain.png"
    id: img_lightning_rainy
    type: RGB
    transparency: alpha_channel
    resize: 100x100

  - file: "https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/png/128/wind.png"
    id: img_windy
    type: RGB
    transparency: alpha_channel
    resize: 100x100

  - file: "https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/png/128/hail.png"
    id: img_hail
    type: RGB
    transparency: alpha_channel
    resize: 100x100

  # Small indicator icons (36x36)
  - file: "https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/png/128/uv-index.png"
    id: img_uv
    type: RGB
    transparency: alpha_channel
    resize: 36x36

  - file: "https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/png/128/raindrops.png"
    id: img_precip
    type: RGB
    transparency: alpha_channel
    resize: 36x36

  - file: "https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/png/128/thermometer-colder.png"
    id: img_wind_chill
    type: RGB
    transparency: alpha_channel
    resize: 36x36

  - file: "https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/png/128/humidity.png"
    id: img_humidity
    type: RGB
    transparency: alpha_channel
    resize: 36x36

  - file: "https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/png/128/windsock.png"
    id: img_wind
    type: RGB
    transparency: alpha_channel
    resize: 36x36

# --- Fonts ---
font:
  - file:
      type: gfonts
      family: Roboto
      weight: 700
    id: font_huge
    size: 72
    glyphs: ' 0123456789°-.:'

  - file:
      type: gfonts
      family: Roboto
      weight: 700
    id: font_xlarge
    size: 48
    glyphs: ' 0123456789°:.,Wkh-'

  - file:
      type: gfonts
      family: Roboto
      weight: 500
    id: font_large
    size: 26
    glyphs: ' %()+,-./0123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz°'

  - file:
      type: gfonts
      family: Roboto
    id: font_medium
    size: 16
    glyphs: ' %()+,-./0123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz°_'

  - file:
      type: gfonts
      family: Roboto
    id: font_small
    size: 12
    glyphs: ' %()+,-./0123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz°_'

  - file:
      type: gfonts
      family: Roboto
      weight: 300
    id: font_tiny
    size: 10
    glyphs: ' %()+,-./0123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz°_'

color:
  - id: color_green
    red: 0%
    green: 82%
    blue: 35%

graph:
  - id: watt_history
    duration: 1h
    width: 536
    height: 120
    x_grid: 10min
    y_grid: 1000.0
    min_value: 0.0
    traces:
      - sensor: current_watt
        line_type: SOLID
        line_thickness: 2
        continuous: true
        color: color_green

# --- Display pages (see post for walk-through) ---
display:
  - platform: mipi_spi
    model: T-DISPLAY-S3-AMOLED
    id: main_lcd
    enable_pin: []
    rotation: 90
    brightness: 242
    dimensions:
      width: 536
      height: 240
    pages:

      # ========== PAGE 1: WEATHER ==========
      - id: page_weather
        lambda: |-
          it.fill(Color::BLACK);

          // Top bar
          it.filled_rectangle(0, 0, 536, 26, Color(18, 18, 22));
          auto now = id(ha_time).now();
          if (now.is_valid()) {
            it.strftime(8, 13, id(font_medium), Color(255, 255, 255), TextAlign::CENTER_LEFT, "%H:%M", now);
            it.strftime(528, 13, id(font_small), Color(90, 90, 100), TextAlign::CENTER_RIGHT, "%a %d %b", now);
          }
          it.filled_circle(243, 13, 3, Color(0, 132, 255));
          it.filled_circle(257, 13, 3, Color(45, 45, 52));
          it.filled_circle(271, 13, 3, Color(45, 45, 52));

          // Weather icon
          std::string cond = id(weather_condition).state;
          int ix = 8, iy = 32;
          bool night = now.is_valid() && (now.hour < 7 || now.hour >= 21);

          if (cond == "sunny") it.image(ix, iy, id(img_sunny));
          else if (cond == "clear-night") it.image(ix, iy, id(img_clear_night));
          else if (cond == "cloudy") it.image(ix, iy, id(img_cloudy));
          else if (cond == "partlycloudy") {
            if (night) it.image(ix, iy, id(img_partlycloudy_night));
            else it.image(ix, iy, id(img_partlycloudy_day));
          }
          else if (cond == "rainy") it.image(ix, iy, id(img_rainy));
          else if (cond == "pouring") it.image(ix, iy, id(img_pouring));
          else if (cond == "snowy") it.image(ix, iy, id(img_snowy));
          else if (cond == "fog") it.image(ix, iy, id(img_fog));
          else if (cond == "lightning") it.image(ix, iy, id(img_lightning));
          else if (cond == "lightning-rainy") it.image(ix, iy, id(img_lightning_rainy));
          else if (cond == "windy") it.image(ix, iy, id(img_windy));
          else if (cond == "hail") it.image(ix, iy, id(img_hail));
          else it.image(ix, iy, id(img_cloudy));

          // Hero temperature (72px)
          if (!isnan(id(weather_temp).state)) {
            it.printf(120, 28, id(font_huge), Color(255, 255, 255), TextAlign::TOP_LEFT, "%.0f°", id(weather_temp).state);
          }

          // Feels-like + condition text
          if (!isnan(id(wind_chill).state)) {
            it.printf(310, 38, id(font_large), Color(150, 180, 220), TextAlign::TOP_LEFT, "Feels %.0f°", id(wind_chill).state);
          }
          {
            std::string cl = cond;
            if (cond == "partlycloudy") cl = "Partly Cloudy";
            else if (cond == "clear-night") cl = "Clear Night";
            else if (cond == "lightning-rainy") cl = "Thunderstorm";
            else if (cl.length() > 0) cl[0] = toupper(cl[0]);
            it.printf(310, 70, id(font_large), Color(100, 100, 115), TextAlign::TOP_LEFT, "%s", cl.c_str());
          }

          // Info row
          if (!isnan(id(weather_wind_speed).state))
            it.printf(10, 112, id(font_large), Color(160, 210, 255), TextAlign::TOP_LEFT, "%.0f km/h", id(weather_wind_speed).state);
          if (!isnan(id(weather_humidity).state))
            it.printf(160, 112, id(font_large), Color(100, 200, 255), TextAlign::TOP_LEFT, "%.0f%%", id(weather_humidity).state);
          if (!isnan(id(temp_high).state))
            it.printf(260, 112, id(font_large), Color(255, 140, 90), TextAlign::TOP_LEFT, "%.0f°", id(temp_high).state);
          if (!isnan(id(temp_low).state))
            it.printf(320, 112, id(font_large), Color(100, 170, 255), TextAlign::TOP_LEFT, "%.0f°", id(temp_low).state);

          if (!isnan(id(precip_chance).state)) {
            float pr = id(precip_chance).state;
            Color prc = pr > 70 ? Color(50, 130, 255) : pr > 40 ? Color(100, 180, 255) : Color(160, 210, 255);
            it.printf(420, 112, id(font_large), prc, TextAlign::TOP_LEFT, "%.0f%%", pr);
            it.image(490, 110, id(img_precip));
          }

          // UV row
          if (!isnan(id(uv_index).state)) {
            float uv = id(uv_index).state;
            Color uvc;
            const char *uvl;
            if (uv <= 2) { uvc = Color(48, 209, 88); uvl = "Low"; }
            else if (uv <= 5) { uvc = Color(255, 214, 10); uvl = "Moderate"; }
            else if (uv <= 7) { uvc = Color(255, 149, 0); uvl = "High"; }
            else if (uv <= 10) { uvc = Color(255, 59, 48); uvl = "Very High"; }
            else { uvc = Color(191, 90, 242); uvl = "Extreme"; }

            if (uv > 5) {
              Color bg = uv > 10 ? Color(80, 0, 100) : uv > 7 ? Color(100, 20, 20) : Color(100, 60, 0);
              it.filled_rectangle(0, 146, 536, 42, bg);
              it.printf(268, 155, id(font_large), Color(255, 255, 255), TextAlign::TOP_CENTER, "UV %.0f  -  %s", uv, uvl);
              int bx = 10, by = 182, bw = 516, bh = 4;
              int seg = bw / 5;
              it.filled_rectangle(bx, by, seg, bh, Color(48, 209, 88));
              it.filled_rectangle(bx+seg, by, seg, bh, Color(255, 214, 10));
              it.filled_rectangle(bx+2*seg, by, seg, bh, Color(255, 149, 0));
              it.filled_rectangle(bx+3*seg, by, seg, bh, Color(255, 59, 48));
              it.filled_rectangle(bx+4*seg, by, bw-4*seg, bh, Color(191, 90, 242));
              int mx = bx + (int)(uv / 12.0f * bw);
              if (mx > bx + bw - 2) mx = bx + bw - 2;
              it.filled_rectangle(mx, by - 2, 3, bh + 4, Color(255, 255, 255));
            } else {
              it.image(10, 148, id(img_uv));
              it.printf(50, 150, id(font_large), uvc, TextAlign::TOP_LEFT, "UV %.0f", uv);
              it.printf(140, 150, id(font_large), uvc, TextAlign::TOP_LEFT, "%s", uvl);
              int bx = 10, by = 182, bw = 516, bh = 4;
              int seg = bw / 5;
              it.filled_rectangle(bx, by, seg, bh, Color(48, 209, 88));
              it.filled_rectangle(bx+seg, by, seg, bh, Color(255, 214, 10));
              it.filled_rectangle(bx+2*seg, by, seg, bh, Color(255, 149, 0));
              it.filled_rectangle(bx+3*seg, by, seg, bh, Color(255, 59, 48));
              it.filled_rectangle(bx+4*seg, by, bw-4*seg, bh, Color(191, 90, 242));
              int mx = bx + (int)(uv / 12.0f * bw);
              if (mx > bx + bw - 2) mx = bx + bw - 2;
              it.filled_rectangle(mx, by - 2, 3, bh + 4, Color(255, 255, 255));
            }
          }

          // Indoor row
          it.filled_rectangle(0, 192, 536, 1, Color(35, 35, 42));
          it.printf(10, 196, id(font_medium), Color(70, 70, 85), TextAlign::TOP_LEFT, "Indoor");
          if (!isnan(id(sdb_temp).state))
            it.printf(10, 214, id(font_large), Color(255, 200, 150), TextAlign::TOP_LEFT, "%.1f°", id(sdb_temp).state);
          if (!isnan(id(sdb_humidity).state))
            it.printf(120, 214, id(font_large), Color(130, 190, 255), TextAlign::TOP_LEFT, "%.0f%%", id(sdb_humidity).state);
          if (!isnan(id(sdb_co2).state)) {
            float co2 = id(sdb_co2).state;
            Color cc = co2 > 1500 ? Color(255, 59, 48)
                     : co2 > 1000 ? Color(255, 200, 0)
                     : co2 > 800  ? Color(255, 214, 10)
                                  : Color(48, 209, 88);
            it.printf(250, 214, id(font_large), cc, TextAlign::TOP_LEFT, "%.0f ppm", co2);
          }

      # ========== PAGE 2: ENERGY ==========
      - id: page_energy
        lambda: |-
          it.fill(Color::BLACK);

          it.filled_rectangle(0, 0, 536, 26, Color(18, 18, 22));
          auto now = id(ha_time).now();
          if (now.is_valid()) {
            it.strftime(8, 13, id(font_medium), Color(255, 255, 255), TextAlign::CENTER_LEFT, "%H:%M", now);
            it.strftime(528, 13, id(font_small), Color(90, 90, 100), TextAlign::CENTER_RIGHT, "%a %d %b", now);
          }
          it.filled_circle(243, 13, 3, Color(45, 45, 52));
          it.filled_circle(257, 13, 3, Color(0, 132, 255));
          it.filled_circle(271, 13, 3, Color(45, 45, 52));

          if (!isnan(id(current_watt).state)) {
            float w = id(current_watt).state;
            Color wc = w > 5000 ? Color(255, 59, 48) : w > 2000 ? Color(255, 200, 0) : Color(48, 209, 88);
            if (w >= 10000) {
              it.printf(268, 34, id(font_large), wc, TextAlign::TOP_CENTER, "%.1f kW", w / 1000.0f);
            } else {
              it.printf(268, 32, id(font_xlarge), wc, TextAlign::TOP_CENTER, "%.0f W", w);
            }
          }

          if (!isnan(id(current_conso).state)) {
            it.printf(268, 82, id(font_medium), Color(100, 100, 110), TextAlign::TOP_CENTER, "Total: %.1f kWh", id(current_conso).state);
          }

          it.filled_rectangle(20, 104, 496, 1, Color(35, 35, 40));
          it.graph(0, 110, id(watt_history));

      # ========== PAGE 3: TOOTHBRUSH ==========
      - id: page_brush
        lambda: |-
          it.fill(Color::BLACK);

          it.filled_rectangle(0, 0, 536, 26, Color(18, 18, 22));
          auto now = id(ha_time).now();
          if (now.is_valid()) {
            it.strftime(8, 13, id(font_medium), Color(255, 255, 255), TextAlign::CENTER_LEFT, "%H:%M", now);
            it.strftime(528, 13, id(font_small), Color(90, 90, 100), TextAlign::CENTER_RIGHT, "%a %d %b", now);
          }
          it.filled_circle(243, 13, 3, Color(45, 45, 52));
          it.filled_circle(257, 13, 3, Color(45, 45, 52));
          it.filled_circle(271, 13, 3, Color(0, 132, 255));

          std::string state = id(brush_state).state;
          std::string mode = id(brush_mode).state;
          std::string sector = id(brush_sector).state;
          std::string pressure = id(brush_pressure).state;
          float battery = id(brush_battery).state;
          float duration = id(brush_duration).state;
          bool running = (state == "running");

          it.printf(10, 38, id(font_large), Color(255, 255, 255), TextAlign::TOP_LEFT, "Genius X");
          Color stc(80, 80, 90);
          if (running) stc = Color(48, 209, 88);
          else if (state == "charging") stc = Color(255, 200, 0);
          it.printf(155, 42, id(font_medium), stc, TextAlign::TOP_LEFT, "%s", state.c_str());

          float batt = isnan(battery) ? 0 : battery;
          it.rectangle(400, 38, 126, 22, Color(60, 60, 65));
          it.filled_rectangle(526, 44, 4, 10, Color(60, 60, 65));
          int fw = (int)(batt * 1.22f);
          if (fw > 122) fw = 122;
          Color bc = batt > 50 ? Color(48, 209, 88) : batt > 20 ? Color(255, 200, 0) : Color(255, 59, 48);
          if (fw > 0) it.filled_rectangle(402, 40, fw, 18, bc);
          it.printf(463, 49, id(font_small), Color(255, 255, 255), TextAlign::CENTER, "%.0f%%", batt);

          std::string md = mode;
          for (auto &c : md) if (c == '_') c = ' ';
          if (md.length() > 0) md[0] = toupper(md[0]);
          it.printf(10, 72, id(font_small), Color(70, 70, 90), TextAlign::TOP_LEFT, "Mode");
          it.printf(10, 88, id(font_large), Color(100, 210, 255), TextAlign::TOP_LEFT, "%s", md.c_str());

          int dur = (int)(isnan(duration) ? 0 : duration);
          it.printf(380, 72, id(font_small), Color(70, 70, 90), TextAlign::TOP_LEFT, "Duration");
          Color tc = running ? Color(48, 209, 88) : Color(160, 160, 170);
          it.printf(380, 84, id(font_xlarge), tc, TextAlign::TOP_LEFT, "%d:%02d", dur / 60, dur % 60);

          int ns = (int)(isnan(id(brush_sectors_total).state) ? 4 : id(brush_sectors_total).state);
          if (ns < 1) ns = 4;
          int cur = 0;
          bool success = (sector == "success");
          if (sector == "sector_1") cur = 1;
          else if (sector == "sector_2") cur = 2;
          else if (sector == "sector_3") cur = 3;
          else if (sector == "sector_4") cur = 4;

          int sw = 124, sg = 8;
          int sx0 = (536 - ns * sw - (ns - 1) * sg) / 2;
          for (int i = 1; i <= ns; i++) {
            int sx = sx0 + (i - 1) * (sw + sg);
            Color sc;
            if (success || (cur > 0 && i < cur)) sc = Color(48, 209, 88);
            else if (i == cur) sc = Color(0, 132, 255);
            else sc = Color(30, 30, 35);
            it.filled_rectangle(sx, 142, sw, 34, sc);
            it.printf(sx + sw / 2, 159, id(font_medium),
              (success || (cur > 0 && i <= cur)) ? Color(255, 255, 255) : Color(70, 70, 80),
              TextAlign::CENTER, "S%d", i);
          }

          Color pc = Color(48, 209, 88);
          if (pressure == "high") pc = Color(255, 59, 48);
          else if (pressure == "low") pc = Color(255, 200, 0);
          else if (pressure == "button_pressed" || pressure == "power_button_pressed") pc = Color(0, 132, 255);
          it.filled_circle(20, 205, 8, pc);
          it.printf(36, 205, id(font_medium), Color(160, 160, 170), TextAlign::CENTER_LEFT, "Pressure: %s", pressure.c_str());

The short version of the setup:

  1. Grab a LilyGo T-Display S3 AMOLED.
  2. Use ESPHome 2024.10 or newer (you need the mipi_spi platform with the AMOLED model preset).
  3. For PSRAM, set mode: octal and speed: 80MHz. The config will compile without this but the display will behave strangely when you load PNG icons.
  4. Use the board’s STEMMA QT / Qwiic port for external sensors instead of soldering to the internal I2C pins. It exposes GPIO43/44 on a JST-SH connector, so STEMMA QT or Qwiic sensors plug straight in and daisy-chain. The config above uses bus_sensors for those.
  5. Start with one page and get the display lighting up before adding more. Debugging a three-page lambda is painful if the bus is not right in the first place.

It is a satisfying project: small hardware, about 900 lines of YAML, a real piece of ambient information on the bathroom wall, and a mmWave sensor doing double duty for the lights while it is at it. Exactly the kind of thing self-hosting is for.

Antoine Weill--Duflos
Antoine Weill--Duflos
Head of Technology and Applications

My research interests include haptic, mechatronics, micro-robotic and hci.