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 clonea 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:
- Grab a LilyGo T-Display S3 AMOLED.
- Use ESPHome 2024.10 or newer (you need the
mipi_spiplatform with the AMOLED model preset). - For PSRAM, set
mode: octalandspeed: 80MHz. The config will compile without this but the display will behave strangely when you load PNG icons. - 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_sensorsfor those. - 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.