Un tableau de bord d'un coup d'œil dans la salle de bain : ESPHome sur le LilyGo T-Display S3 AMOLED
J’ai de plus en plus d’entités Home Assistant auxquelles je tiens, et aucune d’entre elles ne vit là où je passe mes matinées. Mon téléphone a l’appli officielle, mon laptop a Grafana, mais l’endroit où je reste immobile deux minutes tous les jours, c’est le lavabo de la salle de bain. Ce que je voulais vraiment, c’était un petit écran au-dessus du lavabo qui me montre des infos utiles d’un coup d’œil pendant que je me brosse les dents, sans que j’aie à déverrouiller quoi que ce soit. Il y a quelques semaines j’ai acheté un LilyGo T-Display S3 AMOLED et j’ai passé un week-end à l’intégrer dans mon setup ESPHome. C’est maintenant un de mes objets préférés à la maison.
La carte
Le T-Display S3 AMOLED est une carte de dev compacte avec une dalle AMOLED 1,91 pouce (536x240), pilotée par un ESP32-S3. Les points intéressants :
- Vrai AMOLED, pas l’IPS bas de gamme habituel. Les noirs sont vraiment noirs, ce qui compte beaucoup quand l’objet vit sur un mur de salle de bain et ne doit pas être agressif à 2h du matin.
- Affichage en Quad SPI, donc les rafraîchissements sont assez rapides pour animer sans tearing.
- ESP32-S3 avec 16 Mo de flash et PSRAM octale (oui, il faut du PSRAM pour le framebuffer d’un écran aussi large).
- Deux boutons utilisateur, USB-C, un petit boîtier, et un prix difficile à contester.
Sur le papier c’est un jouet de dev, mais en pratique ça fait un panneau Home Assistant très compétent une fois qu’ESPHome arrive à lui parler.
Pourquoi ESPHome
Je fais déjà tout tourner sur Home Assistant, et j’ai déjà une petite flotte de nœuds ESPHome (température, CO2, présence, quelques contacts reed sur des portes). Intégrer le panneau à la même flotte permet :
- Pas de pipeline de firmware custom. ESPHome compile et flashe tout en OTA.
- Des entités Home Assistant gratuites. Chaque capteur et bouton de la carte apparaît automatiquement.
- Des lambdas pour le layout. Je peux dessiner l’affichage avec la même syntaxe C++ en lambda que j’utilise ailleurs, pas de projet LVGL à maintenir à côté.
- Un proxy Bluetooth en bonus. L’ESP32-S3 fait tourner un proxy BLE pour Home Assistant en parallèle, donc il étend aussi la couverture BLE dans une partie de l’appartement qui était jusqu’ici une zone morte pour les appareils BLE.
Le compromis, c’est que je n’ai pas de vrai framework d’interface. Je dessine des rectangles, du texte et des PNG directement. Pour un tableau de bord d’un coup d’œil, ça va très bien, et c’est sans doute mieux : moins de choses qui peuvent mal tourner, et le frame time reste prévisible.
Le câblage : écran et capteurs
La config ESPHome utilise le pilote d’affichage intégré mipi_spi avec le preset de modèle T-DISPLAY-S3-AMOLED. Ça s’occupe du timing Quad SPI et de la séquence d’init de la dalle, deux choses qui étaient un cauchemar sur ce matériel. Le bloc en question :
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
La carte d’origine offre en fait deux bus I2C sans la moindre soudure :
- Bus interne sur GPIO2/3 pour les trucs au niveau de la carte.
- Port STEMMA QT / Qwiic sur le côté de la carte, qui expose GPIO43/44 en SDA/SCL sur un connecteur JST-SH 1 mm standard. C’est là que je branche les vrais capteurs : un Sensirion SHT40 (température et humidité) et un Sensirion STCC4 (CO2 plus un SHT45 embarqué). Les capteurs STEMMA QT se chaînent, donc un câble va de la carte au premier capteur, un autre du premier au deuxième, et voilà. Pas de breadboard, pas de fer à souder, pas de soucis de niveau de tension.
Le STCC4 mérite une note : au moment où j’écris, son composant ESPHome est encore une PR en attente (esphome/esphome#14037), donc je le récupère depuis un fork via external_components. Le chargeur de composants externes basé sur Git d’ESPHome rend ça trivial :
external_components:
- source:
type: git
url: https://github.com/will-tm/esphome
ref: feature/stcc4-sensor
components: [stcc4]
Le même mécanisme charge le pilote du DFRobot C4002, le capteur de présence mmWave 24 GHz posé à côté du panneau. C’est l’ingrédient secret, et j’y reviens dans un instant.
Trois pages qu’on parcourt avec les boutons
La carte a deux boutons (GPIO0 et GPIO21 sur cette variante). Je les ai mappés à la navigation entre pages :
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
Trois pages, chacune dessinée par une lambda :
Page 1 : Météo
La page par défaut. Un héro de température de 72 pixels à gauche, une icône météo 100x100, une ligne « ressenti » depuis un capteur de wind chill, et une rangée d’infos secondaires (vent, humidité, max/min, chance de précipitation). En dessous, une barre UV avec une échelle colorée et un trait au niveau de la valeur actuelle. Si l’UV passe au-dessus de 5, toute la rangée devient une bannière d’alerte, ce qui est un rappel utile l’été.
Les icônes météo sont les Meteocons de Bas Milius (sous licence MIT). ESPHome peut télécharger des images directement depuis une URL au moment du build :
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
L’alpha par-dessus le noir de l’AMOLED rend vraiment bien. Les sources en 128 pixels se redimensionnent proprement en 100x100 (ou 36x36 pour les petits indicateurs).
La bande du bas de la page 1 est la rangée « intérieur » : température, humidité et CO2 depuis le SHT40 / STCC4, avec la valeur de CO2 colorée (vert sous 800 ppm, jaune jusqu’à 1000, orange jusqu’à 1500, rouge au-dessus). Les salles de bain font souffrir ces chiffres (les douches font exploser l’humidité, porte fermée ça fait exploser le CO2) et les voir grimper en temps réel s’est révélé être un bon rappel pour entrouvrir une fenêtre après la douche.
Page 2 : Consommation électrique
La puissance actuelle (depuis mon compteur d’énergie maison, via Home Assistant) en un grand chiffre coloré qui passe au jaune au-dessus de 2 kW et au rouge au-dessus de 5 kW, plus la consommation totale et un graphique des watts sur la dernière heure. Le composant graph intégré à ESPHome fait tout le travail :
graph:
- id: watt_history
duration: 1h
width: 536
height: 120
traces:
- sensor: current_watt
line_type: SOLID
line_thickness: 2
continuous: true
Lisible d’un coup d’œil, et étonnamment utile. Au Canada, les deux gros postes qui bougent vraiment l’aiguille, c’est la thermopompe l’hiver et le compresseur de la clim l’été, qui démarrent et s’arrêtent tout seuls. Un coup d’œil au panneau me dit tout de suite si le pic de consommation, c’est « ah, le chauffage vient de repartir » ou « il y a vraiment un problème », sans avoir à ouvrir Home Assistant.
Page 3 : Brosse à dents
Oui, vraiment. J’ai une Oral-B Genius X. Pendant des années, les brosses Oral-B haut de gamme étaient vendues avec un petit écran LCD séparé (parfois appelé « SmartGuide ») qui se posait sur une étagère de la salle de bain et affichait en direct le retour de brossage pendant une session : mode, quadrant en cours, pression, minuteur. C’était un joli petit objet : il se réveillait quand on démarrait la brosse, on l’oubliait le reste du temps, pas de batterie à gérer de notre côté, pas de téléphone nécessaire.
Au moment où la série Genius est sortie, cet écran séparé avait disparu. Le retour « smart » est passé en Bluetooth uniquement via l’appli du téléphone. Ce qui sonne très bien sur une fiche produit et s’avère insupportable tous les matins. Déverrouiller un téléphone, ouvrir une appli et la caler contre le lavabo avec des mains pleines de dentifrice, personne ne va le faire de façon régulière pour deux minutes de brossage. J’ai arrêté d’utiliser l’appli au bout d’une semaine.
La page brosse à dents du T-Display, c’est donc essentiellement moi en train de reconstruire l’écran externe qu’Oral-B a retiré. La brosse expose son état en direct à Home Assistant via une intégration communautaire (batterie, mode actuel, quadrant qu’elle pense être en train de brosser, retour de pression, minuteur). La page du panneau montre une jauge de batterie colorée, un gros minuteur mm:ss, quatre tuiles de secteur colorées qui s’allument au fur et à mesure, et un indicateur de pression en bas. Exactement la fonction que l’ancien écran séparé rendait, sauf que maintenant ça tourne sur un panneau qui me montre aussi la météo, ma consommation électrique, et si je dois me mettre de la crème solaire.
Le côté amusant, c’est que la page s’active automatiquement dès que je prends la brosse :
- 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
Je commence à brosser, le panneau bascule sur la page brosse. J’arrête, il attend 10 secondes et revient à la météo, qui est la bonne page à voir pendant que je finis de me préparer. Cette petite automatisation a réellement amélioré mes habitudes de brossage, ce qui est une phrase absurde à écrire, mais voilà.
mmWave : écran allumé quand je suis là, éclairage de la salle de bain au passage
L’AMOLED est superbe, mais une page météo qui reste allumée toute la journée ne sert à rien : personne ne la regarde quand personne n’est dans la pièce, et c’est aussi une façon lente de grignoter la durée de vie des pixels pour rien. Le panneau doit être allumé quand je suis devant et éteint le reste du temps.
La parade, c’est le capteur mmWave DFRobot C4002. Et une fois que je l’avais posé au mur pour le panneau, j’ai réalisé qu’il pouvait faire un deuxième boulot en même temps : piloter l’éclairage de la salle de bain. Les lumières à détecteur de mouvement dans les salles de bain, c’est un grief classique parce que l’approche PIR habituelle éteint la lumière dès qu’on reste immobile plus de 30 secondes. Le mmWave 24 GHz n’a pas ce problème : il capte le mouvement de la respiration, donc tant que quelqu’un est dans la pièce, il signale une présence. Le capteur que j’avais acheté pour le panneau est devenu l’entrée de l’automatisation des lumières de la salle de bain dans Home Assistant, et le vieux PIR est sorti du plafond.
Le capteur me donne un booléen propre « quelqu’un est à proximité », plus la direction du mouvement, la vitesse et la distance. Côté panneau, je l’ai câblé pour que la luminosité de l’écran suive la présence :
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);'
J’entre dans la salle de bain, le panneau s’allume à la luminosité que j’ai fixée via le slider (exposé comme une entité number dans Home Assistant) et les lumières du plafond s’allument via une automatisation HA séparée pointée sur la même entité target_status. Je sors, la luminosité tombe à zéro et les lumières s’éteignent avec un court délai. Pas de lumière gaspillée dans une pièce vide, pas de latence façon PIR (le mmWave est quasi instantané et ignore la chaleur passive, donc les objets chauds qui bougent juste derrière la porte ne déclenchent pas de faux positifs).
Il y a aussi un switch dans Home Assistant pour désactiver le contrôle par présence sur le panneau et forcer l’écran allumé, pour les rares fois où je veux le panneau en horloge permanente. L’automatisation des lumières a son propre override pour la même raison.
Un dernier truc : la LED rappel de crème solaire
La carte a une LED verte sur GPIO38 qui pilote normalement la pin « enable » de l’écran. Je n’ai pas besoin de cette pin pour l’affichage (le pilote mipi_spi gère l’enable en interne), donc je l’ai libérée et recyclée en rappel de crème solaire. La règle est d’une simplicité embarrassante : si l’index UV du jour est au-dessus de 4, la LED s’allume. Si la LED est allumée quand je suis déjà devant le miroir en train de me préparer le matin, la crème solaire est juste là dans le placard. Endroit parfait pour le rappel.
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 }
Bête, visible, physique. Je n’ai pas à ouvrir une appli, je n’ai pas à lire un chiffre. Lumière verte allumée, crème solaire. Ça marche étonnamment bien, et la LED s’éteint sur la même branche off dès que l’UV repasse sous le seuil, donc elle ne finit pas par faire partie du décor.
Ce que j’aime dans ce setup
Quelques observations après plusieurs semaines d’utilisation :
- L’itération build + flash est rapide. L’OTA ESPHome sur le S3 prend une vingtaine de secondes, et éditer des lambdas est presque aussi rapide qu’éditer du HTML.
- L’AMOLED compte. Je pensais que ce serait un gadget. Pas du tout. Debout au lavabo dans une salle de bain peu éclairée, avec un fond noir et quelques glyphes colorés, ça a l’air premium, pas un gadget d’amateur.
- Le Quad SPI est le bon défaut. J’avais essayé un T-Display précédent avec un bus plus lent, et le taux de rafraîchissement était rédhibitoire. Pas sur celui-ci.
- Les lambdas battent LVGL pour ce cas d’usage. Je n’ai pas besoin de vues scrollables ni de gestes tactiles. J’ai besoin de dessiner des infos statiques vite et clairement. Les lambdas le font en 40 lignes par page.
- Les composants externes sont cruciaux. Deux des capteurs les plus utiles de ce build (C4002 et STCC4) ne sont pas dans l’arbre principal d’ESPHome. Pouvoir
git cloneun composant dans le build, c’est un superpouvoir discret.
Ce que j’ajouterais
- Un bouton dédié pour surcharger la luminosité. Pour l’instant, la luminosité est un slider dans Home Assistant. Pratique sur le téléphone, pénible en personne (et particulièrement pénible avec les mains mouillées au lavabo). Je vais sans doute câbler un des boutons existants en appui long pour cycler la luminosité.
- Un variateur sensible à l’obscurité, piloté par des capteurs que j’ai déjà. J’ai plusieurs capteurs de lux disséminés dans la maison pour d’autres automatisations. Je peux les injecter dans la logique de Home Assistant pour détecter quand la lumière ambiante est vraiment basse (en pleine nuit, pas juste « la salle de bain est sombre parce que la porte est fermée ») et, quand c’est le cas, à la fois réduire la luminosité du panneau à une valeur très faible et plafonner celle du plafonnier, pour qu’une détection de présence à 3h du matin ne m’aveugle pas. Pas besoin de matériel supplémentaire côté panneau, juste des automatisations plus malignes par-dessus les capteurs déjà installés.
Si vous voulez en construire un
Le YAML ESPHome complet est ci-dessous, avec les secrets (WiFi, clé de chiffrement API, mot de passe OTA) déplacés dans des références !secret et les entity_id Home Assistant personnels remplacés par des placeholders génériques. Pointez les capteurs homeassistant sur vos propres entités météo / énergie / brosse à dents, ajustez les broches I2C si vous les avez câblées autrement, et vous devriez y être.
YAML ESPHome complet anonymisé (cliquer pour dérouler)
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), PR en cours 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
# --- LED d'alerte UV sur 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"
# --- Boutons ---
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
# --- Text sensors Home Assistant ---
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);'
# Météo (depuis 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 sur le bus I2C externe (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 + SHT45 embarqué (même bus externe)
- 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
# Énergie (depuis 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
# Brosse à dents (depuis 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
# --- Icônes météo PNG (Meteocons par Bas Milius, MIT) ---
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
- 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
- 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
# --- Polices ---
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
# --- Pages de l'écran (voir le texte de l'article pour la 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 : MÉTÉO ==========
- id: page_weather
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(0, 132, 255));
it.filled_circle(257, 13, 3, Color(45, 45, 52));
it.filled_circle(271, 13, 3, Color(45, 45, 52));
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));
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);
}
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());
}
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));
}
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));
}
}
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 : ÉNERGIE ==========
- 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 : BROSSE À DENTS ==========
- 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());
La version courte du setup :
- Prenez un LilyGo T-Display S3 AMOLED.
- Utilisez ESPHome 2024.10 ou plus récent (il faut la platform
mipi_spiavec le preset de modèle AMOLED). - Pour le PSRAM, mettez
mode: octaletspeed: 80MHz. La config compilera sans ça, mais l’affichage aura un comportement bizarre quand vous chargerez des icônes PNG. - Utilisez le port STEMMA QT / Qwiic de la carte pour les capteurs externes plutôt que de souder sur les broches I2C internes. Il expose GPIO43/44 sur un connecteur JST-SH, donc les capteurs STEMMA QT ou Qwiic se branchent direct et se chaînent. La config ci-dessus utilise
bus_sensorspour ceux-là. - Commencez par une seule page et assurez-vous que l’affichage s’allume avant d’en ajouter d’autres. Déboguer une lambda à trois pages est pénible si le bus n’est pas correct dès le départ.
Projet satisfaisant : petit matériel, environ 900 lignes de YAML, un vrai morceau d’information d’ambiance sur le mur de la salle de bain, et un capteur mmWave qui fait double emploi pour l’éclairage au passage. Exactement le genre de chose à quoi sert l’auto-hébergement.