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 clone un 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 :

  1. Prenez un LilyGo T-Display S3 AMOLED.
  2. Utilisez ESPHome 2024.10 ou plus récent (il faut la platform mipi_spi avec le preset de modèle AMOLED).
  3. Pour le PSRAM, mettez mode: octal et speed: 80MHz. La config compilera sans ça, mais l’affichage aura un comportement bizarre quand vous chargerez des icônes PNG.
  4. 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_sensors pour ceux-là.
  5. 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.

Antoine Weill--Duflos
Antoine Weill--Duflos
Responsable Technologie et Applications

Je m’intéresse à l’haptique, la mécatronique, la micro-robotique…