PT EN

DIY & firmware

Exemplos prontos de ESPHome

Configurações completas de ESPHome prontas para copiar — Bluetooth Proxy, receptor de IR, dispositivos Thread e telas — para o seu Home Assistant.

Configurações completas de ESPHome, prontas para copiar e adaptar ao seu Home Assistant. Cada exemplo já vem com os dados sensíveis redigidos (MACs, IPs, chaves de API e datasets Thread como exemplos) — troque pelos seus antes de gravar.

Novo no ESPHome? Comece pelo artigo Sensores com ESPHome e ESP32.

Índice#

Exemplo pronto: Bluetooth Proxy com ATOM-S3#

Um Bluetooth Proxy estende o alcance Bluetooth do Home Assistant: o ESP32 repassa, pela rede, os sinais BLE de dispositivos próximos (sensores, lâmpadas, etiquetas). Aqui, num M5Stack ATOM-S3 (ESP32-S3), ele também traz um LED que muda de cor conforme o Wi-Fi e um pequeno sniffer opcional que registra no log os anúncios BLE de lâmpadas Philips Hue — útil para diagnóstico.

Troque pelos seus dados antes de gravar. A chave de criptografia da API, a senha de OTA, o IP fixo e as chaves !secret abaixo são apenas exemplos. Nunca publique chaves, senhas, MACs ou IPs reais — gere os seus.

# Bluetooth Proxy em M5Stack ATOM-S3 (ESP32-S3), com LED de status Wi-Fi
# e um sniffer opcional de anúncios BLE da Philips Hue (diagnóstico).
# Troque a chave da API, a senha de OTA, o IP fixo e as chaves !secret.

esphome:
  name: atom-s3-bt-proxy
  friendly_name: "ATOM-S3 BT PROXY"
  on_boot:
    priority: -10
    then:
      - script.execute: atualizar_led_wifi

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

logger:
  level: DEBUG

api:
  encryption:
    key: !secret api_encryption_key   # gere a sua no ESPHome Builder

ota:
  - platform: esphome
    password: !secret ota_password

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  manual_ip:                    # ajuste para a sua rede (ou remova para usar DHCP)
    static_ip: 192.168.1.51
    gateway: 192.168.1.1
    subnet: 255.255.255.0
  power_save_mode: NONE
  on_connect:
    then:
      - script.execute: atualizar_led_wifi
  on_disconnect:
    then:
      - light.turn_on:
          id: led_rgb
          red: 100%
          green: 0%
          blue: 0%
          brightness: 100%
  ap:
    ssid: atom-s3-bt-proxy

captive_portal:

light:
  - platform: esp32_rmt_led_strip
    id: led_rgb
    name: "LED RGB Interno"
    pin: GPIO35
    num_leds: 1
    chipset: WS2812
    channel_colors: GRB
    restore_mode: ALWAYS_OFF

esp32_ble_tracker:
  scan_parameters:
    interval: 1100ms
    window: 1100ms
    active: true
  # Filtro opcional em C++ (lambda): registra no log apenas anúncios da Philips Hue
  on_ble_advertise:
    then:
      - lambda: |-
          // 1. Filtrar pelo UUID de serviço da Signify (0xFE0F), se disponível
          bool is_hue = false;

          for (auto data : x.get_service_uuids()) {
              if (data.to_string() == "0000fe0f-0000-1000-8000-00805f9b34fb" || data.to_string() == "0XFE0F") {
                  is_hue = true;
              }
          }

          // 2. Filtrar pelos dados de fabricante (Manufacturer Data ID 0xFE0F)
          for (auto data : x.get_manufacturer_datas()) {
              if (data.uuid.to_string() == "0XFE0F" || data.uuid.to_string() == "0000fe0f-0000-1000-8000-00805f9b34fb") {
                  is_hue = true;
              }
          }

          // Se for uma lâmpada Hue, imprime as informações brutas no log
          if (is_hue) {
              ESP_LOGI("HUE_SNIFFER", "Lampada Hue encontrada!");
              ESP_LOGI("HUE_SNIFFER", "  Endereco MAC: %s", x.address_str().c_str());
              ESP_LOGI("HUE_SNIFFER", "  Nome BLE: %s", x.get_name().c_str());
              ESP_LOGI("HUE_SNIFFER", "  Sinal (RSSI): %d dBm", x.get_rssi());

              for (auto data : x.get_manufacturer_datas()) {
                  std::string hex_data = "";
                  for (uint8_t byte : data.data) {
                      char buf[4];
                      sprintf(buf, "%02X ", byte);
                      hex_data += buf;
                  }
                  ESP_LOGI("HUE_SNIFFER", "  Manufacturer Data [%s]: %s", data.uuid.to_string().c_str(), hex_data.c_str());
              }
          }

bluetooth_proxy:
  active: true

script:
  - id: atualizar_led_wifi
    mode: restart
    then:
      - if:
          condition:
            wifi.connected:
          then:
            - light.turn_on:
                id: led_rgb
                red: 0%
                green: 100%
                blue: 0%
                brightness: 100%
          else:
            - light.turn_on:
                id: led_rgb
                red: 100%
                green: 0%
                blue: 0%
                brightness: 100%

button:
  - platform: restart
    name: "Reiniciar"
  - platform: safe_mode
    name: "Safe Mode"

binary_sensor:
  - platform: status
    name: "Status ESPHome"
    entity_category: diagnostic

sensor:
  - platform: wifi_signal
    name: "Sinal Wi-Fi"
    update_interval: 60s
    entity_category: diagnostic
  - platform: uptime
    name: "Tempo Ligado"
    update_interval: 60s
    entity_category: diagnostic

text_sensor:
  - platform: wifi_info
    ip_address:
      name: "WI-FI IP Address"
      entity_category: diagnostic
    dns_address:
      name: "DNS Address"
      entity_category: diagnostic
    mac_address:
      name: "WI-FI MAC Address"
      entity_category: diagnostic
    ssid:
      name: "SSID"
      entity_category: diagnostic
    bssid:
      name: "BSSID"
      entity_category: diagnostic

safe_mode:

Como funciona, em resumo:

  • bluetooth_proxy + esp32_ble_tracker fazem o ESP32 repassar ao Home Assistant os sinais BLE que ele enxerga — ampliando a cobertura Bluetooth da casa.
  • O LED fica verde quando o Wi-Fi conecta e vermelho quando cai, controlado pelo script.
  • O bloco lambda é um filtro opcional: identifica anúncios da Philips Hue (UUID de serviço 0xFE0F, da Signify) e imprime no log o MAC, o RSSI e os bytes do fabricante — apenas para diagnóstico.
  • Sensores de diagnóstico (sinal Wi-Fi, uptime, IP, MAC, SSID) ajudam a monitorar o dispositivo; os botões Reiniciar e Safe Mode facilitam a recuperação.

Valide no ESPHome Device Builder (Validate) e teste um componente por vez.

Exemplo pronto: receptor de infravermelho (IR) no Home Assistant#

Quer que o Home Assistant "aprenda" os códigos do controle remoto da TV, do ar-condicionado ou do som? Um receptor de infravermelho ligado a um ESP32 captura esses sinais. Com remote_receiver e dump: all, cada botão pressionado aparece no log do ESPHome com o protocolo e o código — pronto para reutilizar numa automação ou num controle virtual.

Troque pelos seus dados antes de gravar. A chave de criptografia da API, o IP fixo, o DNS e as chaves !secret abaixo são apenas exemplos. Nunca publique chaves, senhas ou IPs reais — gere os seus.

# Receptor de infravermelho (IR) em ESP32, exposto ao Home Assistant.
# Troque a chave da API, o IP fixo, o DNS e as chaves !secret pelos seus.

esphome:
  name: esp32-ir-receiver-escritorio
  friendly_name: "ESP32-IR-RECEIVER-ESCRITORIO"

esp32:
  board: esp32-devkit-v1
  variant: esp32
  framework:
    type: esp-idf

logger:
  level: DEBUG

api:
  encryption:
    key: !secret api_encryption_key   # gere a sua no ESPHome Builder

ota:
  - platform: esphome
    encryption:

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  power_save_mode: NONE
  manual_ip:                    # ajuste para a sua rede (ou remova para usar DHCP)
    static_ip: 192.168.1.52
    gateway: 192.168.1.1
    subnet: 255.255.255.0
    dns1: 1.1.1.1
    dns2: 8.8.8.8

remote_receiver:
  id: ir_rx
  pin:
    number: GPIO27
    inverted: true
    mode:
      input: true
      pullup: true
  dump: all          # registra no log os códigos de todos os protocolos
  tolerance: 25%

# Expõe o receptor à integração de infravermelho do Home Assistant.
infrared:
  - platform: ir_rf_proxy
    id: receptor_lg_escritorio
    name: "Receptor IR LG ESCRITORIO"
    receiver_frequency: 38kHz
    remote_receiver_id: ir_rx
    icon: "mdi:remote"

button:
  - platform: restart
    name: "Reiniciar"
  - platform: safe_mode
    name: "Safe Mode"

binary_sensor:
  - platform: status
    name: "Status ESPHome"
    entity_category: diagnostic

text_sensor:
  - platform: wifi_info
    ip_address:
      name: "WI-FI IP Address"
      entity_category: diagnostic
    dns_address:
      name: "DNS Address"
      entity_category: diagnostic
    mac_address:
      name: "WI-FI MAC Address"
      entity_category: diagnostic

Como funciona, em resumo:

  • Um sensor de IR (por exemplo VS1838B/TSOP, 38 kHz) é ligado ao pino GPIO27, com pullup.
  • remote_receiver com dump: all decodifica e registra no log os sinais de vários protocolos — é assim que você descobre o código de cada botão.
  • O bloco infrared expõe o receptor à integração de infravermelho do Home Assistant.
  • Sensores de diagnóstico (IP, DNS, MAC) e os botões Reiniciar e Safe Mode ajudam na manutenção.

Confira a pinagem do seu receptor (VCC, GND e sinal) e valide no ESPHome Device Builder antes de gravar.

Exemplo pronto: dispositivo Thread com M5Stack NanoC6#

Thread é uma rede sem fio de malha (mesh), de baixo consumo, que serve de base para o Matter. O M5Stack NanoC6 (ESP32-C6) tem o rádio 802.15.4 necessário e, com o componente openthread, entra na sua rede Thread como um dispositivo FTD (roteador). O LED RGB sinaliza o estado: pisca vermelho sem conexão com o Home Assistant e fica azul quando conecta.

Dado MUITO sensível. O campo tlv (o dataset) do Thread contém a chave da sua rede — quem tiver esse valor entra na sua malha. Nunca o publique. A chave da API e os demais dados abaixo também são exemplos; gere os seus.

# M5Stack NanoC6 (ESP32-C6) como dispositivo Thread (OpenThread).
# Troque a chave da API e, principalmente, o dataset TLV da sua rede Thread.

esphome:
  name: nanoc6-thread
  friendly_name: "NanoC6 Thread"
  on_boot:
    # Prioridade -10: executa só depois que luzes e switches já existem.
    priority: -10
    then:
      - switch.turn_on: pwr_g19   # garante a energia do LED RGB
      - delay: 1s
      - light.turn_on:
          id: rgb_led
          effect: "NanoC6 Thread Blink_Red"

esp32:
  board: esp32-c6-devkitc-1
  framework:
    type: esp-idf
    version: recommended
    sdkconfig_options:
      CONFIG_ESP_TASK_WDT_TIMEOUT_S: "60"   # watchdog maior p/ FTD (init pode demorar)
      CONFIG_PM_ENABLE: "n"
      CONFIG_ESP_INT_WDT_TIMEOUT_MS: "2000"

logger:
  level: DEBUG

api:
  encryption:
    key: !secret api_encryption_key   # gere a sua no ESPHome Builder
  on_client_connected:
    - light.turn_on:
        id: rgb_led
        red: 0.0
        green: 0.0
        blue: 1.0
        brightness: 1.0
        effect: none
  on_client_disconnected:
    - light.turn_on:
        id: rgb_led
        effect: "NanoC6 Thread Blink_Red"

ota:
  - platform: esphome
    encryption:

# IPv6 é necessário para o Thread
network:
  enable_ipv6: true

# Rádio Thread. ATENÇÃO: o 'tlv' (dataset) contém a CHAVE DA SUA REDE THREAD.
# Nunca publique o seu; cole o dataset da sua rede aqui.
openthread:
  device_type: FTD
  tlv: "COLE_AQUI_O_DATASET_TLV_DA_SUA_REDE_THREAD"
  force_dataset: true
  output_power: 20dBm

# ===== Periféricos do M5 Nano-C6 =====

# LED de diagnóstico nativo do ESPHome no pino 7
status_led:
  pin: GPIO7

switch:
  - platform: gpio
    pin: GPIO19
    id: pwr_g19
    name: "NanoC6 Thread Energia Periféricos"
    restore_mode: ALWAYS_ON
  - platform: restart
    id: restart_switch
    name: "NanoC6 Thread Restart Switch"

# LED RGB (GPIO20)
light:
  - platform: esp32_rmt_led_strip
    id: rgb_led
    name: "Nano C6 RGB"
    pin: GPIO20
    num_leds: 1
    chipset: WS2812
    channel_colors: GRB
    effects:
      - strobe:
          name: "NanoC6 Thread Blink_Red"
          colors:
            - state: true
              red: 1.0
              green: 0.0
              blue: 0.0
              brightness: 1.0
              duration: 500ms
            - state: false
              duration: 500ms

button:
  - platform: restart
    id: restart_device
    name: "Reiniciar Nano C6"

# Botão físico (GPIO9): segurar por 5s reinicia
binary_sensor:
  - platform: gpio
    id: btn_gpio9
    name: "Botão M5Stack"
    pin:
      number: GPIO9
      inverted: true
      mode:
        input: true
        pullup: true
    filters:
      - delayed_on: 5s
    on_press:
      - button.press: restart_device

  - platform: status
    name: "NanoC6 Thread Status de Conexão"
    id: status_conexao

# ===== Sensores de diagnóstico =====
sensor:
  - platform: uptime
    name: "NanoC6 Thread Tempo de Atividade"
    id: uptime_sensor
    update_interval: 60s
    filters:
      - lambda: return x / 60.0;
    unit_of_measurement: "min"
    icon: mdi:clock-outline

text_sensor:
  - platform: openthread_info
    ip_address:
      name: "Thread IP Address"
    channel:
      name: "Thread Channel"
    role:
      name: "Thread Device Role"
    rloc16:
      name: "Thread RLOC16"
    ext_addr:
      name: "Thread Extended Address"
    eui64:
      name: "Thread EUI64"
    network_name:
      name: "Thread Network Name"
    network_key:
      name: "Thread Network Key"
    pan_id:
      name: "Thread PAN ID"
    ext_pan_id:
      name: "Thread Extended PAN ID"

Como funciona, em resumo:

  • openthread com device_type: FTD faz o NanoC6 participar da rede Thread; o tlv é o dataset que descreve a rede (canal, PAN ID e a chave) — por isso é sigiloso. O network: enable_ipv6 é obrigatório.
  • O LED RGB dá feedback visual: azul com o HA conectado, vermelho piscando sem conexão.
  • O switch em GPIO19 liga a energia dos periféricos do NanoC6 (incluindo o LED); o botão físico em GPIO9, segurado por 5 s, reinicia.
  • Os text_sensor do openthread_info expõem o diagnóstico da rede (IP, canal, papel, PAN ID etc.) no Home Assistant.

Obtenha o dataset TLV da sua rede Thread (pelo Home Assistant ou pelo seu Border Router) e valide no ESPHome Device Builder antes de gravar.

Exemplo pronto: painel de toque (clima, cortinas e ar)#

Uma telinha de toque na parede que mostra relógio, data e clima e controla cortinas, ventilador e ar-condicionado direto no Home Assistant. Este exemplo usa uma placa Guition JC3248W535 (ESP32-S3, 3,5" 320×480) com a biblioteca de interface LVGL do ESPHome, em três páginas e uma barra de navegação inferior.

Personalize antes de gravar. No topo, o bloco substitutions define as entidades do seu Home Assistant (clima, ventilador, ar) e a cidade — ajuste para as suas. A chave da API é um exemplo; gere a sua. Os nomes cover.cortina_1..3 também são exemplos.

substitutions:
  device_name: tela-clima
  friendly_name: "Tela Clima"
  city_name: "Fortaleza"
  ha_weather_entity: "weather.home"
  ha_fan_entity: "fan.ventilador_sala"
  ha_ac_entity: "climate.ar_sala"
  tz: "America/Fortaleza"

esphome:
  name: ${device_name}
  friendly_name: ${friendly_name}
  platformio_options:
    upload_speed: 921600
    board_build.flash_mode: dio
    board_build.f_flash: 80000000L
    board_build.f_cpu: 240000000L

# Placa: Guition JC3248W535C_I_Y — ESP32-S3, tela QSPI 3.5" 320x480 (AXS15231B),
# toque capacitivo no mesmo chip via I2C. Confirme flash/PSRAM da sua unidade
# (16MB / PSRAM octal é o padrão dessa família; se a compilação/boot falhar,
# tente psram.mode: quad).
esp32:
  board: esp32-s3-devkitc-1
  variant: esp32s3
  flash_size: 16MB
  framework:
    type: esp-idf
    sdkconfig_options:
      COMPILER_OPTIMIZATION_SIZE: y
      CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240: y
      CONFIG_ESP32S3_DATA_CACHE_64KB: y
      CONFIG_ESP32S3_DATA_CACHE_LINE_64B: y
      CONFIG_SPIRAM_FETCH_INSTRUCTIONS: y
      CONFIG_SPIRAM_RODATA: y

psram:
  mode: octal
  speed: 80MHz

logger:
  level: INFO

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  on_connect:
    - lvgl.label.update:
        id: wifi_icon
        text: "\U000F0928"
  on_disconnect:
    - lvgl.label.update:
        id: wifi_icon
        text: "\U000F092E"

api:
  encryption:
    key: !secret api_encryption_key   # gere a sua no ESPHome Builder

ota:
  - platform: esphome
    encryption:

# Necessário para a Home Assistant "empurrar" a entidade de clima para o
# dispositivo E para o dispositivo chamar serviços de volta (cortinas/ar/vent).
# Só funciona depois que o device for adicionado à integração ESPHome no HA.

time:
  - platform: sntp
    id: sntp_time
    timezone: "${tz}"
    servers:
      - time.google.com
      - time1.google.com
      - pool.ntp.org

# --- Barramentos ---------------------------------------------------------
spi:
  - type: quad
    clk_pin: 47
    data_pins: [21, 48, 40, 39]

i2c:
  - sda: 4
    scl: 8

# --- Backlight -------------------------------------------------------------
output:
  - id: gpio_backlight_pwm
    platform: ledc
    pin: 1

light:
  - id: backlight
    name: "Backlight"
    platform: monochromatic
    output: gpio_backlight_pwm
    restore_mode: ALWAYS_ON

# --- Tela + toque (AXS15231B, driver nativo mipi_spi) -----------------------
display:
  - id: main_display
    platform: mipi_spi
    model: JC3248W535
    rotation: 0°   # 0° = retrato 320x480 nesta placa; troque p/ 90°/270° se vier deitada
    data_rate: 40MHz
    update_interval: never
    auto_clear_enabled: false

touchscreen:
  - id: main_touchscreen
    platform: axs15231

# --- Fontes ------------------------------------------------------------
font:
  - file:
      type: gfonts
      family: Roboto
      weight: bold
    id: font_time
    size: 72
    bpp: 4
    glyphs: "0123456789:"

  - file: "gfonts://Roboto"
    id: font_text_md
    size: 22
    bpp: 4
    glyphsets: GF_Latin_Core
    glyphs: ["°", "•"]

  - file:
      type: gfonts
      family: Roboto
      weight: bold
    id: font_num_big
    size: 48
    bpp: 4
    glyphs: "0123456789°C+- "

  # Ícones grandes do clima (sol / nuvem / chuva)
  - file:
      type: web
      url: "https://github.com/Templarian/MaterialDesign-Webfont/raw/master/fonts/materialdesignicons-webfont.ttf"
    id: font_icon_weather
    size: 150
    bpp: 4
    glyphs:
      - "\U000F0599"  # weather-sunny
      - "\U000F0594"  # weather-night (clear-night)
      - "\U000F0595"  # weather-partly-cloudy
      - "\U000F0590"  # weather-cloudy
      - "\U000F0596"  # weather-pouring (chuva)

  # Ícone de WiFi (pequeno)
  - file:
      type: web
      url: "https://github.com/Templarian/MaterialDesign-Webfont/raw/master/fonts/materialdesignicons-webfont.ttf"
    id: font_icon_wifi
    size: 22
    bpp: 4
    glyphs:
      - "\U000F0928"  # wifi-strength-4
      - "\U000F0925"  # wifi-strength-3
      - "\U000F0922"  # wifi-strength-2
      - "\U000F091F"  # wifi-strength-1
      - "\U000F092E"  # wifi-strength-off-outline

  # Ícones da barra de navegação inferior
  - file:
      type: web
      url: "https://github.com/Templarian/MaterialDesign-Webfont/raw/master/fonts/materialdesignicons-webfont.ttf"
    id: font_nav
    size: 30
    bpp: 4
    glyphs:
      - "\U000F02DC"  # home
      - "\U000F111C"  # window-shutter (cortinas)
      - "\U000F001B"  # air-conditioner

  # Ícones dos botões de controle
  - file:
      type: web
      url: "https://github.com/Templarian/MaterialDesign-Webfont/raw/master/fonts/materialdesignicons-webfont.ttf"
    id: font_icon
    size: 40
    bpp: 4
    glyphs:
      - "\U000F0737"  # arrow-up-bold (abrir)
      - "\U000F072E"  # arrow-down-bold (fechar)
      - "\U000F04DB"  # stop (parar)
      - "\U000F0425"  # power
      - "\U000F0210"  # fan
      - "\U000F001B"  # air-conditioner
      - "\U000F111C"  # window-shutter

# --- Sensores ------------------------------------------------------------
sensor:
  - platform: wifi_signal
    id: wifi_signal_dbm
    update_interval: 30s
    internal: true
    on_value:
      - lvgl.label.update:
          id: wifi_icon
          text: !lambda |-
            if (x >= -60) return std::string("\U000F0928");
            else if (x >= -67) return std::string("\U000F0925");
            else if (x >= -75) return std::string("\U000F0922");
            else return std::string("\U000F091F");

  - platform: homeassistant
    id: ha_weather_temp
    entity_id: ${ha_weather_entity}
    attribute: temperature
    internal: true
    on_value:
      - lvgl.label.update:
          id: temp_city_label
          text:
            format: "${city_name} • %.0f°C"
            args: [x]
            if_nan: "${city_name} • --°C"

  # Ar-condicionado: temperatura atual da sala
  - platform: homeassistant
    id: ha_ac_current
    entity_id: ${ha_ac_entity}
    attribute: current_temperature
    internal: true
    on_value:
      - lvgl.label.update:
          id: ac_current_label
          text:
            format: "Atual: %.1f°C"
            args: [x]
            if_nan: "Atual: --"

  # Ar-condicionado: temperatura alvo (setpoint)
  - platform: homeassistant
    id: ha_ac_target
    entity_id: ${ha_ac_entity}
    attribute: temperature
    internal: true
    on_value:
      - lvgl.label.update:
          id: ac_target_label
          text:
            format: "%.0f°C"
            args: [x]
            if_nan: "--"

  # Ventilador: velocidade atual (%)
  - platform: homeassistant
    id: ha_fan_pct
    entity_id: ${ha_fan_entity}
    attribute: percentage
    internal: true
    on_value:
      - lvgl.label.update:
          id: fan_speed_label
          text:
            format: "Velocidade: %.0f%%"
            args: [x]
            if_nan: "Velocidade: --"

text_sensor:
  - platform: homeassistant
    id: ha_weather_condition
    entity_id: ${ha_weather_entity}
    internal: true
    on_value:
      - lvgl.label.update:
          id: weather_icon
          text: !lambda |-
            std::string c = x;
            if (c == "sunny") return std::string("\U000F0599");
            if (c == "clear-night") return std::string("\U000F0594");
            if (c == "partlycloudy") return std::string("\U000F0595");
            if (c == "rainy" || c == "pouring" || c == "lightning" ||
                c == "lightning-rainy" || c == "hail" || c == "snowy" ||
                c == "snowy-rainy") return std::string("\U000F0596");
            return std::string("\U000F0590");
      - lvgl.label.update:
          id: forecast_label
          text: !lambda |-
            std::string c = x;
            if (c == "sunny") return std::string("Ensolarado");
            if (c == "clear-night") return std::string("Céu limpo");
            if (c == "partlycloudy") return std::string("Parcialmente nublado");
            if (c == "cloudy") return std::string("Nublado");
            if (c == "fog") return std::string("Neblina");
            if (c == "windy" || c == "windy-variant") return std::string("Ventania");
            if (c == "rainy") return std::string("Chuva");
            if (c == "pouring") return std::string("Chuva forte");
            if (c == "lightning") return std::string("Tempestade");
            if (c == "lightning-rainy") return std::string("Tempestade com chuva");
            if (c == "hail") return std::string("Granizo");
            if (c == "snowy") return std::string("Neve");
            if (c == "snowy-rainy") return std::string("Chuva com neve");
            return std::string("Indisponível");

  # Ar-condicionado: modo atual (off/cool/dry/...) — atualiza rótulo e cor do power
  - platform: homeassistant
    id: ha_ac_mode
    entity_id: ${ha_ac_entity}
    internal: true
    on_value:
      - lvgl.label.update:
          id: ac_mode_label
          text: !lambda |-
            std::string m = x;
            if (m == "off") return std::string("Desligado");
            if (m == "cool") return std::string("Refrigerar");
            if (m == "dry") return std::string("Desumidificar");
            if (m == "fan_only") return std::string("Ventilar");
            if (m == "heat") return std::string("Aquecer");
            if (m == "heat_cool") return std::string("Automático");
            return m;
      - if:
          condition:
            lambda: 'return x == "off";'
          then:
            - lvgl.widget.update:
                id: btn_ac_power
                bg_color: 0x263238
          else:
            - lvgl.widget.update:
                id: btn_ac_power
                bg_color: 0x2196F3

# --- Relógio / data (atualizados localmente, não dependem do HA) -----------
interval:
  - interval: 1s
    then:
      - lvgl.label.update:
          id: time_label
          text: !lambda |-
            auto t = id(sntp_time).now();
            if (!t.is_valid()) return std::string("--:--");
            char buf[8];
            snprintf(buf, sizeof(buf), "%02d:%02d", t.hour, t.minute);
            return std::string(buf);

  - interval: 30s
    then:
      - lvgl.label.update:
          id: date_label
          text: !lambda |-
            auto t = id(sntp_time).now();
            if (!t.is_valid()) return std::string("Sincronizando...");
            static const char *dias[] = {"", "Domingo", "Segunda-feira", "Terça-feira",
                "Quarta-feira", "Quinta-feira", "Sexta-feira", "Sábado"};
            static const char *meses[] = {"", "Janeiro", "Fevereiro", "Março", "Abril",
                "Maio", "Junho", "Julho", "Agosto", "Setembro", "Outubro", "Novembro", "Dezembro"};
            char buf[48];
            snprintf(buf, sizeof(buf), "%s, %d de %s de %d",
                dias[t.day_of_week], t.day_of_month, meses[t.month], t.year);
            return std::string(buf);

# --- Interface LVGL ----------------------------------------------------
lvgl:
  displays:
    - main_display
  touchscreens:
    - main_touchscreen
  bg_color: 0x000000

  # Barra de navegação inferior — sempre visível sobre todas as páginas
  top_layer:
    widgets:
      - obj:
          width: 320
          height: 56
          align: BOTTOM_MID
          y: 0
          bg_color: 0x11151A
          bg_opa: COVER
          border_width: 0
          radius: 0
          pad_all: 0

      - button:
          id: nav_clima
          width: 100
          height: 48
          align: BOTTOM_LEFT
          x: 4
          y: -4
          bg_color: 0x2196F3
          radius: 10
          border_width: 0
          widgets:
            - label:
                text: "\U000F02DC"
                text_font: font_nav
                text_color: 0xFFFFFF
                align: CENTER
          on_click:
            - lvgl.page.show: page_clima
            - lvgl.widget.update: {id: nav_clima, bg_color: 0x2196F3}
            - lvgl.widget.update: {id: nav_cortinas, bg_color: 0x263238}
            - lvgl.widget.update: {id: nav_ar, bg_color: 0x263238}

      - button:
          id: nav_cortinas
          width: 100
          height: 48
          align: BOTTOM_LEFT
          x: 110
          y: -4
          bg_color: 0x263238
          radius: 10
          border_width: 0
          widgets:
            - label:
                text: "\U000F111C"
                text_font: font_nav
                text_color: 0xFFFFFF
                align: CENTER
          on_click:
            - lvgl.page.show: page_cortinas
            - lvgl.widget.update: {id: nav_clima, bg_color: 0x263238}
            - lvgl.widget.update: {id: nav_cortinas, bg_color: 0x2196F3}
            - lvgl.widget.update: {id: nav_ar, bg_color: 0x263238}

      - button:
          id: nav_ar
          width: 100
          height: 48
          align: BOTTOM_LEFT
          x: 216
          y: -4
          bg_color: 0x263238
          radius: 10
          border_width: 0
          widgets:
            - label:
                text: "\U000F001B"
                text_font: font_nav
                text_color: 0xFFFFFF
                align: CENTER
          on_click:
            - lvgl.page.show: page_ar
            - lvgl.widget.update: {id: nav_clima, bg_color: 0x263238}
            - lvgl.widget.update: {id: nav_cortinas, bg_color: 0x263238}
            - lvgl.widget.update: {id: nav_ar, bg_color: 0x2196F3}

  pages:
    # ===================== PÁGINA 1: CLIMA / RELÓGIO =====================
    - id: page_clima
      bg_color: 0x000000
      widgets:
        - label:
            id: wifi_icon
            text: "\U000F092E"
            text_font: font_icon_wifi
            text_color: 0xB0BEC5
            align: TOP_LEFT
            x: 8
            y: 6

        - label:
            id: time_label
            text: "--:--"
            text_font: font_time
            text_color: 0xFFFFFF
            align: TOP_MID
            y: 4

        - label:
            id: date_label
            text: "Sincronizando..."
            text_font: font_text_md
            text_color: 0xB0BEC5
            align: TOP_MID
            y: 98

        - label:
            id: weather_icon
            text: "\U000F0595"
            text_font: font_icon_weather
            text_color: 0x90A4AE
            align: CENTER
            y: -20

        - label:
            id: forecast_label
            text: "Aguardando dados..."
            text_font: font_text_md
            text_color: 0xFFFFFF
            align: CENTER
            y: 120

        - label:
            id: temp_city_label
            text: "--"
            text_font: font_text_md
            text_color: 0xB0BEC5
            align: CENTER
            y: 155

    # ===================== PÁGINA 2: CORTINAS =====================
    - id: page_cortinas
      bg_color: 0x000000
      widgets:
        - label:
            text: "Cortinas"
            text_font: font_text_md
            text_color: 0xFFFFFF
            align: TOP_MID
            y: 14

        # ---- Linha: Todas ----
        - label:
            text: "Todas"
            text_font: font_text_md
            text_color: 0xB0BEC5
            align: TOP_LEFT
            x: 12
            y: 78
        - button:
            width: 60
            height: 60
            align: TOP_LEFT
            x: 118
            y: 60
            bg_color: 0x263238
            radius: 10
            border_width: 0
            widgets:
              - label: {text: "\U000F0737", text_font: font_icon, text_color: 0x4CAF50, align: CENTER}
            on_click:
              - homeassistant.action:
                  action: cover.open_cover
                  data: {entity_id: "cover.cortina_1, cover.cortina_2, cover.cortina_3"}
        - button:
            width: 60
            height: 60
            align: TOP_LEFT
            x: 186
            y: 60
            bg_color: 0x263238
            radius: 10
            border_width: 0
            widgets:
              - label: {text: "\U000F04DB", text_font: font_icon, text_color: 0xFFB300, align: CENTER}
            on_click:
              - homeassistant.action:
                  action: cover.stop_cover
                  data: {entity_id: "cover.cortina_1, cover.cortina_2, cover.cortina_3"}
        - button:
            width: 60
            height: 60
            align: TOP_LEFT
            x: 254
            y: 60
            bg_color: 0x263238
            radius: 10
            border_width: 0
            widgets:
              - label: {text: "\U000F072E", text_font: font_icon, text_color: 0xEF5350, align: CENTER}
            on_click:
              - homeassistant.action:
                  action: cover.close_cover
                  data: {entity_id: "cover.cortina_1, cover.cortina_2, cover.cortina_3"}

        # ---- Linha: Cortina 1 ----
        - label:
            text: "Cortina 1"
            text_font: font_text_md
            text_color: 0xFFFFFF
            align: TOP_LEFT
            x: 12
            y: 170
        - button:
            width: 60
            height: 60
            align: TOP_LEFT
            x: 118
            y: 152
            bg_color: 0x263238
            radius: 10
            border_width: 0
            widgets:
              - label: {text: "\U000F0737", text_font: font_icon, text_color: 0x4CAF50, align: CENTER}
            on_click:
              - homeassistant.action:
                  action: cover.open_cover
                  data: {entity_id: cover.cortina_1}
        - button:
            width: 60
            height: 60
            align: TOP_LEFT
            x: 186
            y: 152
            bg_color: 0x263238
            radius: 10
            border_width: 0
            widgets:
              - label: {text: "\U000F04DB", text_font: font_icon, text_color: 0xFFB300, align: CENTER}
            on_click:
              - homeassistant.action:
                  action: cover.stop_cover
                  data: {entity_id: cover.cortina_1}
        - button:
            width: 60
            height: 60
            align: TOP_LEFT
            x: 254
            y: 152
            bg_color: 0x263238
            radius: 10
            border_width: 0
            widgets:
              - label: {text: "\U000F072E", text_font: font_icon, text_color: 0xEF5350, align: CENTER}
            on_click:
              - homeassistant.action:
                  action: cover.close_cover
                  data: {entity_id: cover.cortina_1}

        # ---- Linha: Cortina 2 ----
        - label:
            text: "Cortina 2"
            text_font: font_text_md
            text_color: 0xFFFFFF
            align: TOP_LEFT
            x: 12
            y: 262
        - button:
            width: 60
            height: 60
            align: TOP_LEFT
            x: 118
            y: 244
            bg_color: 0x263238
            radius: 10
            border_width: 0
            widgets:
              - label: {text: "\U000F0737", text_font: font_icon, text_color: 0x4CAF50, align: CENTER}
            on_click:
              - homeassistant.action:
                  action: cover.open_cover
                  data: {entity_id: cover.cortina_2}
        - button:
            width: 60
            height: 60
            align: TOP_LEFT
            x: 186
            y: 244
            bg_color: 0x263238
            radius: 10
            border_width: 0
            widgets:
              - label: {text: "\U000F04DB", text_font: font_icon, text_color: 0xFFB300, align: CENTER}
            on_click:
              - homeassistant.action:
                  action: cover.stop_cover
                  data: {entity_id: cover.cortina_2}
        - button:
            width: 60
            height: 60
            align: TOP_LEFT
            x: 254
            y: 244
            bg_color: 0x263238
            radius: 10
            border_width: 0
            widgets:
              - label: {text: "\U000F072E", text_font: font_icon, text_color: 0xEF5350, align: CENTER}
            on_click:
              - homeassistant.action:
                  action: cover.close_cover
                  data: {entity_id: cover.cortina_2}

        # ---- Linha: Cortina 3 ----
        - label:
            text: "Cortina 3"
            text_font: font_text_md
            text_color: 0xFFFFFF
            align: TOP_LEFT
            x: 12
            y: 354
        - button:
            width: 60
            height: 60
            align: TOP_LEFT
            x: 118
            y: 336
            bg_color: 0x263238
            radius: 10
            border_width: 0
            widgets:
              - label: {text: "\U000F0737", text_font: font_icon, text_color: 0x4CAF50, align: CENTER}
            on_click:
              - homeassistant.action:
                  action: cover.open_cover
                  data: {entity_id: cover.cortina_3}
        - button:
            width: 60
            height: 60
            align: TOP_LEFT
            x: 186
            y: 336
            bg_color: 0x263238
            radius: 10
            border_width: 0
            widgets:
              - label: {text: "\U000F04DB", text_font: font_icon, text_color: 0xFFB300, align: CENTER}
            on_click:
              - homeassistant.action:
                  action: cover.stop_cover
                  data: {entity_id: cover.cortina_3}
        - button:
            width: 60
            height: 60
            align: TOP_LEFT
            x: 254
            y: 336
            bg_color: 0x263238
            radius: 10
            border_width: 0
            widgets:
              - label: {text: "\U000F072E", text_font: font_icon, text_color: 0xEF5350, align: CENTER}
            on_click:
              - homeassistant.action:
                  action: cover.close_cover
                  data: {entity_id: cover.cortina_3}

    # ===================== PÁGINA 3: VENTILADOR + AR =====================
    - id: page_ar
      bg_color: 0x000000
      widgets:
        # ---------- Ventilador ----------
        - label:
            text: "\U000F0210"
            text_font: font_icon
            text_color: 0x2196F3
            align: TOP_LEFT
            x: 14
            y: 10
        - label:
            text: "Ventilador"
            text_font: font_text_md
            text_color: 0xFFFFFF
            align: TOP_LEFT
            x: 62
            y: 20

        - button:
            width: 72
            height: 52
            align: TOP_LEFT
            x: 8
            y: 62
            bg_color: 0x37474F
            radius: 10
            border_width: 0
            widgets:
              - label: {text: "Desl", text_font: font_text_md, text_color: 0xFFFFFF, align: CENTER}
            on_click:
              - homeassistant.action:
                  action: fan.turn_off
                  data: {entity_id: "${ha_fan_entity}"}
        - button:
            width: 72
            height: 52
            align: TOP_LEFT
            x: 84
            y: 62
            bg_color: 0x263238
            radius: 10
            border_width: 0
            widgets:
              - label: {text: "Baixa", text_font: font_text_md, text_color: 0xFFFFFF, align: CENTER}
            on_click:
              - homeassistant.action:
                  action: fan.set_percentage
                  data: {entity_id: "${ha_fan_entity}", percentage: "33"}
        - button:
            width: 72
            height: 52
            align: TOP_LEFT
            x: 160
            y: 62
            bg_color: 0x263238
            radius: 10
            border_width: 0
            widgets:
              - label: {text: "Média", text_font: font_text_md, text_color: 0xFFFFFF, align: CENTER}
            on_click:
              - homeassistant.action:
                  action: fan.set_percentage
                  data: {entity_id: "${ha_fan_entity}", percentage: "66"}
        - button:
            width: 72
            height: 52
            align: TOP_LEFT
            x: 236
            y: 62
            bg_color: 0x263238
            radius: 10
            border_width: 0
            widgets:
              - label: {text: "Alta", text_font: font_text_md, text_color: 0xFFFFFF, align: CENTER}
            on_click:
              - homeassistant.action:
                  action: fan.set_percentage
                  data: {entity_id: "${ha_fan_entity}", percentage: "100"}

        - label:
            id: fan_speed_label
            text: "Velocidade: --"
            text_font: font_text_md
            text_color: 0x90A4AE
            align: TOP_MID
            y: 124

        # ---------- Ar-condicionado ----------
        - label:
            text: "\U000F001B"
            text_font: font_icon
            text_color: 0x2196F3
            align: TOP_LEFT
            x: 14
            y: 166
        - label:
            text: "Ar Sala"
            text_font: font_text_md
            text_color: 0xFFFFFF
            align: TOP_LEFT
            x: 62
            y: 176

        - button:
            id: btn_ac_power
            width: 60
            height: 52
            align: TOP_RIGHT
            x: -10
            y: 166
            bg_color: 0x263238
            radius: 10
            border_width: 0
            widgets:
              - label: {text: "\U000F0425", text_font: font_icon, text_color: 0xFFFFFF, align: CENTER}
            on_click:
              - if:
                  condition:
                    lambda: 'return id(ha_ac_mode).state == "off";'
                  then:
                    - homeassistant.action:
                        action: climate.set_hvac_mode
                        data: {entity_id: "${ha_ac_entity}", hvac_mode: cool}
                  else:
                    - homeassistant.action:
                        action: climate.set_hvac_mode
                        data: {entity_id: "${ha_ac_entity}", hvac_mode: "off"}

        - button:
            width: 66
            height: 72
            align: TOP_LEFT
            x: 16
            y: 232
            bg_color: 0x263238
            radius: 12
            border_width: 0
            widgets:
              - label: {text: "-", text_font: font_num_big, text_color: 0xFFFFFF, align: CENTER}
            on_click:
              - homeassistant.action:
                  action: climate.set_temperature
                  data: {entity_id: "${ha_ac_entity}"}
                  data_template:
                    temperature: "{{ [ (state_attr('${ha_ac_entity}','temperature') | float(21)) - 1, 16 ] | max }}"

        - label:
            id: ac_target_label
            text: "--"
            text_font: font_num_big
            text_color: 0xFFFFFF
            align: TOP_MID
            y: 244

        - button:
            width: 66
            height: 72
            align: TOP_RIGHT
            x: -16
            y: 232
            bg_color: 0x263238
            radius: 12
            border_width: 0
            widgets:
              - label: {text: "+", text_font: font_num_big, text_color: 0xFFFFFF, align: CENTER}
            on_click:
              - homeassistant.action:
                  action: climate.set_temperature
                  data: {entity_id: "${ha_ac_entity}"}
                  data_template:
                    temperature: "{{ [ (state_attr('${ha_ac_entity}','temperature') | float(21)) + 1, 30 ] | min }}"

        - label:
            id: ac_current_label
            text: "Atual: --"
            text_font: font_text_md
            text_color: 0x90A4AE
            align: TOP_MID
            y: 322

        - label:
            id: ac_mode_label
            text: "—"
            text_font: font_text_md
            text_color: 0x64B5F6
            align: TOP_MID
            y: 352

Como funciona, em resumo:

  • substitutions no topo centraliza o que você troca: nome do dispositivo, entidades do HA (weather, fan, climate), cidade e fuso — sem procurar no meio do arquivo.
  • display (driver mipi_spi, modelo JC3248W535) + touchscreen (axs15231) ativam a tela e o toque; lvgl desenha a interface em 3 páginas com barra inferior.
  • Sensores platform: homeassistant puxam dados do HA (temperatura do clima, modo do ar, velocidade do ventilador) e atualizam os rótulos; os botões chamam serviços de volta (homeassistant.action) para abrir/fechar cortinas, ligar o ar e mudar a velocidade.
  • O relógio e a data são gerados localmente (via sntp), sem depender do HA.
  • Hardware: confirme a flash/PSRAM da sua unidade; se o boot falhar, teste psram.mode: quad. A rotação da tela pode precisar de 90°/270°.

Ajuste as substitutions para as suas entidades e valide no ESPHome Device Builder antes de gravar.

Exemplo pronto: tela Thread com temperatura e umidade (Waveshare C6-GEEK)#

A placa Waveshare ESP32-C6-GEEK junta três coisas num dispositivo só: conexão Thread, uma telinha ST7789V (240×135) e um sensor DHT (AM2302/DHT11) de temperatura e umidade. A tela mostra relógio, data e as leituras do sensor, enquanto o dispositivo entra na sua rede Thread.

Dado MUITO sensível. O campo tlv (o dataset) do Thread contém a chave da sua rede — nunca o publique. A chave da API também é um exemplo; gere a sua.

# Waveshare ESP32-C6-GEEK: dispositivo Thread com tela ST7789V e sensor DHT.
# Troque a chave da API e, principalmente, o dataset TLV da sua rede Thread.

esphome:
  name: waveshare-esp32-c6-geek
  friendly_name: "Waveshare ESP32-C6-GEEK"
  min_version: 2026.9.0

esp32:
  board: esp32-c6-devkitc-1
  variant: esp32c6
  flash_size: 16MB
  framework:
    type: esp-idf
    version: recommended
    sdkconfig_options:
      CONFIG_ESP_TASK_WDT_TIMEOUT_S: "60"   # watchdog maior p/ FTD (init pode demorar)
      CONFIG_PM_ENABLE: "n"
      CONFIG_ESP_INT_WDT_TIMEOUT_MS: "2000"

logger:
  level: DEBUG

api:
  encryption:
    key: !secret api_encryption_key   # gere a sua no ESPHome Builder
  # Mantém a tela funcionando mesmo sem conexão com o HA.
  reboot_timeout: 0s

ota:
  - platform: esphome
    encryption:

# Thread usa IPv6.
network:
  enable_ipv6: true

# Rádio Thread. ATENÇÃO: o 'tlv' (dataset) contém a CHAVE DA SUA REDE THREAD.
# Nunca publique o seu; cole o dataset da sua rede aqui.
openthread:
  device_type: FTD
  force_dataset: true
  tlv: "COLE_AQUI_O_DATASET_TLV_DA_SUA_REDE_THREAD"
  output_power: 20dBm

# Horário do Google, exibido no fuso de Fortaleza.
time:
  - platform: sntp
    id: horario_google
    timezone: America/Fortaleza
    servers:
      - time1.google.com
      - time2.google.com
      - time3.google.com
    update_interval: 1h

spi:
  clk_pin: GPIO1
  mosi_pin: GPIO2

# As fontes são baixadas durante a preparação do firmware.
font:
  - file: "gfonts://Roboto"
    id: fonte_pequena
    size: 14

  - file: "gfonts://Roboto"
    id: fonte_valores
    size: 26
    glyphs: ["0123456789 .-°C%"]

  - file: "gfonts://Roboto"
    id: fonte_relogio
    size: 28

sensor:
  - platform: dht
    id: sensor_dht_1
    pin: GPIO11
    model: AM2302
    update_interval: 10s
    temperature:
      name: Temperature
      id: sensor_dht_1_temperature
      icon: "mdi:thermometer"
      accuracy_decimals: 0
    humidity:
      name: Humidity
      id: sensor_dht_1_humidity
      icon: "mdi:water-percent"
      accuracy_decimals: 0

  - platform: uptime
    name: "Waveshare ESP32-C6-GEEK Tempo de Atividade"
    id: uptime_sensor
    update_interval: 60s
    filters:
      - lambda: return x / 60.0;
    unit_of_measurement: "min"
    icon: mdi:clock-outline

display:
  - platform: st7789v
    id: tela
    model: CUSTOM
    cs_pin: GPIO5
    dc_pin: GPIO3
    reset_pin: GPIO4
    backlight_pin: GPIO6
    # Geometria nativa; a rotação resulta em 240 × 135.
    width: 135
    height: 240
    offset_width: 52
    offset_height: 40
    rotation: 270
    update_interval: 1s
    lambda: |-
      const Color branco(255, 255, 255);
      const Color cinza(160, 160, 160);
      const Color azul(80, 190, 255);
      const Color laranja(255, 180, 70);

      it.fill(Color(0, 0, 0));

      // Hora e data.
      auto agora = id(horario_google).now();

      if (agora.is_valid()) {
        it.strftime(
          120, 0, id(fonte_relogio), branco,
          TextAlign::TOP_CENTER, "%H:%M:%S", agora
        );
        it.strftime(
          120, 34, id(fonte_pequena), cinza,
          TextAlign::TOP_CENTER, "%d/%m/%Y", agora
        );
      } else {
        it.print(
          120, 0, id(fonte_relogio), branco,
          TextAlign::TOP_CENTER, "--:--:--"
        );
        it.print(
          120, 34, id(fonte_pequena), cinza,
          TextAlign::TOP_CENTER, "Sincronizando hora..."
        );
      }

      // Separadores e títulos.
      it.line(8, 57, 231, 57, cinza);
      it.line(120, 67, 120, 125, cinza);

      it.print(
        60, 65, id(fonte_pequena), cinza,
        TextAlign::TOP_CENTER, "Temperatura"
      );
      it.print(
        180, 65, id(fonte_pequena), cinza,
        TextAlign::TOP_CENTER, "Umidade"
      );

      // Leituras do DHT.
      const float temperatura =
        id(sensor_dht_1_temperature).state;
      const float umidade =
        id(sensor_dht_1_humidity).state;

      if (!isnan(temperatura)) {
        it.printf(
          60, 88, id(fonte_valores), laranja,
          TextAlign::TOP_CENTER, "%.0f °C", temperatura
        );
      } else {
        it.print(
          60, 88, id(fonte_valores), laranja,
          TextAlign::TOP_CENTER, "-- °C"
        );
      }

      if (!isnan(umidade)) {
        it.printf(
          180, 88, id(fonte_valores), azul,
          TextAlign::TOP_CENTER, "%.0f %%", umidade
        );
      } else {
        it.print(
          180, 88, id(fonte_valores), azul,
          TextAlign::TOP_CENTER, "-- %"
        );
      }

switch:
  - platform: restart
    id: restart_switch
    name: "Waveshare ESP32-C6-GEEK Restart Switch"

binary_sensor:
  - platform: status
    name: "Waveshare ESP32-C6-GEEK Status de Conexão"
    id: status_conexao

text_sensor:
  - platform: openthread_info
    ip_address:
      name: "Thread IP Address"
    channel:
      name: "Thread Channel"
    role:
      name: "Thread Device Role"
    rloc16:
      name: "Thread RLOC16"
    ext_addr:
      name: "Thread Extended Address"
    eui64:
      name: "Thread EUI64"
    network_name:
      name: "Thread Network Name"
    network_key:
      name: "Thread Network Key"
    pan_id:
      name: "Thread PAN ID"
    ext_pan_id:
      name: "Thread Extended PAN ID"

Como funciona, em resumo:

  • openthread (FTD) conecta a placa à rede Thread; o tlv é o dataset sigiloso (contém a chave da rede). O network: enable_ipv6 é obrigatório.
  • Um sensor DHT no GPIO11 lê temperatura e umidade a cada 10 s.
  • O display ST7789V desenha, no lambda, o relógio (via sntp), a data e as duas leituras — tudo localmente, sem depender do HA para a tela (reboot_timeout: 0s mantém a tela viva mesmo sem o HA).
  • Sensores de diagnóstico (uptime, status, dados do Thread) ajudam a monitorar.

Confira o sensor e a pinagem da sua placa, e valide no ESPHome Device Builder antes de gravar.

Exemplo de referência: painel de parede 7" para a casa toda (Waveshare 7B)#

Um painel de parede de 7" (Waveshare ESP32-S3-Touch-LCD-7B, 1024×600) que vira o controle central da casa: mostra relógio, clima com previsão de 5 dias, cotação do Bitcoin (com mini-gráfico) e uma página por cômodo com luzes (liga/desliga + brilho), ar-condicionado, ventilador e cortinas — tudo via Home Assistant, com LVGL v9.

Por ser grande e depender de arquivos próprios, vai aqui como referência comentada (trechos-chave), não o arquivo inteiro.

Este exemplo NÃO é autossuficiente. Ele precisa acompanhar: - waveshare_7b/panel_data.h — um header C++ com a lógica do Bitcoin (parsing da Coinbase, mini-gráfico) e a função condition_pt(), que traduz as condições do clima; - as imagens dos cômodos (waveshare_7b/assets/*.jpg).

Requer ESPHome 2026.9+ e, no HA, a opção "Permitir que o dispositivo realize ações do Home Assistant" ativada. A chave da API e o IP fixo são apenas exemplos — gere/ajuste os seus.

1. Base (placa, PSRAM, API e Wi-Fi):

substitutions:
  name: waveshare-esp32-s3-touch-lcd-7b
  friendly_name: Waveshare ESP32-S3-Touch-LCD-7B
  weather_entity: weather.home

esphome:
  name: ${name}
  friendly_name: ${friendly_name}
  min_version: 2026.9.0
  includes:
    - waveshare_7b/panel_data.h   # header C++ (lógica do Bitcoin, condition_pt) — precisa acompanhar
  on_boot:
    - priority: -100
      then:
        - light.turn_on: { id: screen_backlight, brightness: 75% }
        - script.execute: refresh_clock
        - delay: 8s
        - script.execute: fetch_market
        - script.execute: fetch_forecast

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

psram:
  mode: octal
  speed: 80MHz

api:
  encryption:
    key: !secret api_encryption_key   # gere a sua no ESPHome Builder
  reboot_timeout: 0s                   # mantém a tela viva mesmo sem o HA

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  # manual_ip opcional — ajuste à sua rede ou remova para usar DHCP

2. Tela RGB + toque (a parte mais sensível do hardware):

# Tela RGB 1024x600 (driver nativo mipi_rgb) + toque GT911 no barramento I2C.
# Um expansor I2C (waveshare_io_ch32v003) controla reset/enable/backlight.
display:
  - platform: mipi_rgb
    id: disp1
    model: RPI
    dimensions: { width: 1024, height: 600 }
    color_order: RGB
    pclk_frequency: 30MHz
    pclk_inverted: true
    # pinos de dados e sincronismo conforme a placa (ver config completa)
    update_interval: never
    auto_clear_enabled: false

touchscreen:
  - platform: gt911
    id: touch_panel
    display: disp1
    i2c_id: touch_bus
    address: 93

3. Cotação do Bitcoin (requisição HTTP à Coinbase, sem depender do HA):

http_request:
  id: market_http
  verify_ssl: true

script:
  - id: fetch_market
    then:
      - http_request.get:
          url: https://api.exchange.coinbase.com/products/BTC-USD/ticker
          capture_response: true
          on_response:
            - lambda: |-
                waveshare_panel::bitcoin().parse_quote(body, id(panel_time).now().strftime("%H:%M"));
            - script.execute: refresh_bitcoin

4. Padrão de controle por entidade (repetido para cada luz/cômodo):

# Um sensor lê o brilho da luz no HA e move o slider da tela:
sensor:
  - platform: homeassistant
    id: level_light_led_sala
    entity_id: light.led_sala
    attribute: brightness
    on_value:
      - lvgl.slider.update:
          id: slider_light_led_sala
          value: !lambda "return std::isfinite(x) ? (int)lroundf(x * 100.0f / 255.0f) : 0;"

# E, dentro da página do cômodo, o botão/slider chama o serviço de volta:
#   button (toggle) + slider (brilho) -> homeassistant.action: light.toggle / light.turn_on

Como funciona, em resumo:

  • A tela usa o driver nativo mipi_rgb (1024×600) e o toque gt911; um expansor I²C (waveshare_io_ch32v003) cuida de reset, enable e backlight.
  • Dezenas de sensores platform: homeassistant espelham o estado das entidades (brilho, modo do ar, estado das cortinas) e atualizam os widgets LVGL; os botões e sliders chamam serviços de volta com homeassistant.action.
  • O relógio, a previsão (via weather.get_forecasts do HA) e o Bitcoin (API da Coinbase) são atualizados por interval/script.
  • A interface tem uma página principal (clima + BTC + atalhos por cômodo) e uma página por cômodo, com rolagem.

Trate este exemplo como um ponto de partida avançado: o arquivo completo (com panel_data.h e as imagens) é extenso e específico da casa.

Exemplo pronto: assistente de voz com tela (M5Stack AtomS3R + Echo Base)#

Um assistente de voz com telinha de status, feito com o M5Stack AtomS3R + Echo Base. Ele detecta palavras de ativação (Okay Nabu, Hey Jarvis, Hey Mycroft) com wake word no próprio dispositivo (ou pelo Home Assistant) e conversa com o Assist do HA; a tela mostra cada fase — ouvindo, pensando, respondendo — com as ilustrações do Casita.

Troque pelos seus dados antes de gravar. A chave da API e o IP fixo são exemplos; gere/ajuste os seus. As ilustrações, sons e modelos de wake word são baixados de URLs públicas durante a compilação — este exemplo não precisa de arquivos locais. Requer ESPHome 2026.7+ e o hardware de áudio do Echo Base (microfone + alto-falante).

substitutions:
  name: atom-lcd-echo
  friendly_name: "ATOM LCD ECHO"

  # Imagens do Casita
  loading_illustration_file: https://github.com/esphome/wake-word-voice-assistants/raw/main/casita/loading_320_240.png
  idle_illustration_file: https://github.com/esphome/wake-word-voice-assistants/raw/main/casita/idle_320_240.png
  listening_illustration_file: https://github.com/esphome/wake-word-voice-assistants/raw/main/casita/listening_320_240.png
  thinking_illustration_file: https://github.com/esphome/wake-word-voice-assistants/raw/main/casita/thinking_320_240.png
  replying_illustration_file: https://github.com/esphome/wake-word-voice-assistants/raw/main/casita/replying_320_240.png
  error_illustration_file: https://github.com/esphome/wake-word-voice-assistants/raw/main/casita/error_320_240.png
  error_no_wifi_illustration_file: https://github.com/esphome/wake-word-voice-assistants/raw/main/error_box_illustrations/error-no-wifi.png
  error_no_ha_illustration_file: https://github.com/esphome/wake-word-voice-assistants/raw/main/error_box_illustrations/error-no-ha.png

  # Fontes
  mdi_webfont_file: https://raw.githubusercontent.com/Templarian/MaterialDesign-Webfont/master/fonts/materialdesignicons-webfont.ttf

  # Arquivos de áudio
  wake_word_trigger_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/wake_word_triggered.flac
  timer_finished_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/timer_finished.flac
  error_cloud_expired_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/error_cloud_expired.mp3

  # Modelos Micro Wake Word
  stop_model_file: https://github.com/kahrendt/microWakeWord/releases/download/stop/stop.json

  # Cores de fundo
  loading_illustration_background_color: "000000"
  idle_illustration_background_color: "000000"
  listening_illustration_background_color: "FFFFFF"
  thinking_illustration_background_color: "FFFFFF"
  replying_illustration_background_color: "FFFFFF"
  error_illustration_background_color: "000000"

  # Fases do assistente de voz
  voice_assist_idle_phase_id: "1"
  voice_assist_listening_phase_id: "2"
  voice_assist_thinking_phase_id: "3"
  voice_assist_replying_phase_id: "4"
  voice_assist_not_ready_phase_id: "10"
  voice_assist_error_phase_id: "11"
  voice_assist_muted_phase_id: "12"
  voice_assist_timer_finished_phase_id: "20"

esphome:
  name: ${name}
  friendly_name: ${friendly_name}
  min_version: 2026.7.0
  on_boot:
    priority: 200
    then:
      - light.turn_on:
          id: lcd_backlight
          brightness: 100%
      - delay: 500ms
      - component.update: atoms3r_lcd
      - script.execute: draw_display
      - delay: 30s
      - if:
          condition:
            lambda: return id(init_in_progress);
          then:
            - lambda: id(init_in_progress) = false;
            - script.execute: draw_display

esp32:
  variant: ESP32S3
  board: esp32-s3-devkitc-1
  flash_size: 8MB
  cpu_frequency: 240MHz
  framework:
    type: esp-idf
    sdkconfig_options:
      CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240: "y"
      CONFIG_ESP32S3_DATA_CACHE_64KB: "y"
      CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y"

psram:
  mode: octal
  speed: 80MHz

api:
  encryption:
    key: !secret api_encryption_key   # gere a sua no ESPHome Builder
  on_client_connected:
    - script.execute: draw_display
  on_client_disconnected:
    - script.execute: draw_display

ota:
  - platform: esphome
    id: ota_esphome
    encryption:

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  manual_ip:                    # ajuste à sua rede ou remova para DHCP
    static_ip: 192.168.1.60
    gateway: 192.168.1.1
    subnet: 255.255.255.0
    dns1: 8.8.8.8
    dns2: 8.8.4.4
  power_save_mode: NONE
  on_connect:
    - script.execute: draw_display
  on_disconnect:
    then:
      - script.execute: draw_display
logger:
  level: DEBUG
  hardware_uart: USB_SERIAL_JTAG

button:
  - platform: factory_reset
    id: factory_reset_btn
    internal: true

  - platform: template
    name: "Testar alto-falante"
    entity_category: diagnostic
    on_press:
      - switch.turn_on: speaker_enable
      - script.execute:
          id: play_sound
          priority: true
          sound_file: !lambda return id(wake_word_triggered_sound);

binary_sensor:
  - platform: gpio
    pin:
      number: GPIO41
      mode: INPUT_PULLUP
      inverted: true
    id: user_button
    internal: true
    on_multi_click:
      # Clique rápido alterna o mudo
      - timing:
          - ON for at least 50ms
          - OFF for at least 50ms
        then:
          - switch.toggle: mute
          - switch.turn_off: timer_ringing

      # Pressionar por 10 segundos restaura os padrões de fábrica
      - timing:
          - ON for at least 10s
        then:
          - button.press: factory_reset_btn

external_components:
  - source: github://m5stack/esphome-yaml/components@main
    components:
      - lp5562
      - es8311
    refresh: 1d

i2c:
  - id: bsp_bus
    sda:
      number: GPIO45
      ignore_strapping_warning: true
    scl:
      number: GPIO0
      ignore_strapping_warning: true
    scan: true

  - id: ext_bus
    sda: GPIO38
    scl: GPIO39
    scan: true

pi4ioe5v6408:
  - id: pi4ioe5v6408_hub
    i2c_id: ext_bus
    address: 0x43

i2s_audio:
  - id: i2s_audio_bus
    i2s_lrclk_pin: GPIO6
    i2s_bclk_pin: GPIO8

spi:
  id: spi_bus
  clk_pin: GPIO15
  mosi_pin: GPIO21

audio_dac:
  - platform: es8311
    id: es8311_dac
    i2c_id: ext_bus
    bits_per_sample: 16bit
    sample_rate: 48000
    use_microphone: false
    use_mclk: false

microphone:
  - platform: i2s_audio
    id: i2s_mic
    sample_rate: 16000
    i2s_din_pin: GPIO7
    bits_per_sample: 16bit
    adc_type: external

speaker:
  - platform: i2s_audio
    id: i2s_speaker
    i2s_dout_pin: GPIO5
    dac_type: external
    sample_rate: 48000
    audio_dac: es8311_dac
    channel: left

media_player:
  - platform: speaker
    name: "Echo Base Player"
    id: echo_base_player
    volume_min: 0.5
    volume_max: 0.8

    announcement_pipeline:
      speaker: i2s_speaker
      format: FLAC
      sample_rate: 48000
      num_channels: 1

    files:
      - id: wake_word_triggered_sound
        file: ${wake_word_trigger_sound_file}

      - id: timer_finished_sound
        file: ${timer_finished_sound_file}

      - id: error_cloud_expired
        file: ${error_cloud_expired_file}

    on_state:
      - logger.log: "Estado do media player atualizado"

    on_play:
      - logger.log: "Reprodução iniciada"

    on_announcement:
      then:
        - logger.log:
            format: "Reproduzindo anúncio"
            level: DEBUG

        - if:
            condition:
              microphone.is_capturing:
            then:
              - script.execute: stop_wake_word

              - if:
                  condition:
                    lambda: |-
                      return id(wake_word_engine_location).current_option() ==
                             "In Home Assistant";
                  then:
                    - wait_until:
                        not:
                          voice_assistant.is_running:

        - if:
            condition:
              not:
                voice_assistant.is_running:
            then:
              - lambda: |-
                  id(voice_assistant_phase) =
                    ${voice_assist_muted_phase_id};
              - script.execute: draw_display

    on_idle:
      - if:
          condition:
            not:
              voice_assistant.is_running:
          then:
            - script.execute: start_wake_word
            - script.execute: set_idle_or_mute_phase
            - script.execute: draw_display

switch:
  # Habilitação do amplificador NS4150B
  - platform: gpio
    name: "Speaker Enable"
    id: speaker_enable
    pin:
      pi4ioe5v6408: pi4ioe5v6408_hub
      number: 0
      mode:
        output: true
    icon: "mdi:volume-high"
    restore_mode: ALWAYS_ON

  - platform: template
    name: "Mute Microphone"
    id: mute
    icon: "mdi:microphone-off"
    optimistic: true
    restore_mode: RESTORE_DEFAULT_OFF
    entity_category: config

    on_turn_off:
      - microphone.unmute:
      - lambda: |-
          id(voice_assistant_phase) =
            ${voice_assist_idle_phase_id};
      - script.execute: draw_display

    on_turn_on:
      - microphone.mute:
      - lambda: |-
          id(voice_assistant_phase) =
            ${voice_assist_muted_phase_id};
      - script.execute: draw_display

  - platform: template
    id: timer_ringing
    optimistic: true
    internal: true
    restore_mode: ALWAYS_OFF

    on_turn_off:
      - lambda: |-
          id(echo_base_player)
            ->make_call()
            .set_command(
              media_player::MediaPlayerCommand::
                MEDIA_PLAYER_COMMAND_REPEAT_OFF
            )
            .set_announcement(true)
            .perform();

          id(echo_base_player)->set_playlist_delay_ms(
            speaker::AudioPipelineType::ANNOUNCEMENT,
            0
          );

      - media_player.stop:
          id: echo_base_player
          announcement: true

      - script.execute: start_wake_word

    on_turn_on:
      - script.execute: stop_wake_word

      - lambda: |-
          id(echo_base_player)
            ->make_call()
            .set_command(
              media_player::MediaPlayerCommand::
                MEDIA_PLAYER_COMMAND_REPEAT_ONE
            )
            .set_announcement(true)
            .perform();

          id(echo_base_player)->set_playlist_delay_ms(
            speaker::AudioPipelineType::ANNOUNCEMENT,
            1000
          );

      - script.execute:
          id: play_sound
          priority: true
          sound_file: !lambda return id(timer_finished_sound);

      - delay: 15min
      - switch.turn_off: timer_ringing

select:
  - platform: template
    entity_category: config
    name: "Wake word engine location"
    id: wake_word_engine_location
    icon: "mdi:account-voice"
    optimistic: true
    restore_value: true

    options:
      - In Home Assistant
      - On device

    initial_option: On device

    on_value:
      - if:
          condition:
            lambda: return !id(init_in_progress);
          then:
            - wait_until:
                lambda: |-
                  return
                    id(voice_assistant_phase) ==
                      ${voice_assist_muted_phase_id} ||
                    id(voice_assistant_phase) ==
                      ${voice_assist_idle_phase_id};

            - if:
                condition:
                  lambda: return x == "In Home Assistant";
                then:
                  - micro_wake_word.stop:
                  - delay: 500ms

                  - if:
                      condition:
                        switch.is_off: mute
                      then:
                        - lambda: id(va).set_use_wake_word(true);
                        - voice_assistant.start_continuous:

            - if:
                condition:
                  lambda: return x == "On device";
                then:
                  - lambda: id(va).set_use_wake_word(false);
                  - voice_assistant.stop:
                  - delay: 500ms

                  - if:
                      condition:
                        switch.is_off: mute
                      then:
                        - micro_wake_word.start:

  - platform: template
    name: "Wake word sensitivity"
    id: wake_word_sensitivity
    optimistic: true
    initial_option: Slightly sensitive
    restore_value: true
    entity_category: config

    options:
      - Slightly sensitive
      - Moderately sensitive
      - Very sensitive

    on_value:
      - lambda: |-
          if (x == "Slightly sensitive") {
            id(okay_nabu).set_probability_cutoff(217);
            id(hey_jarvis).set_probability_cutoff(247);
            id(hey_mycroft).set_probability_cutoff(253);
          } else if (x == "Moderately sensitive") {
            id(okay_nabu).set_probability_cutoff(176);
            id(hey_jarvis).set_probability_cutoff(235);
            id(hey_mycroft).set_probability_cutoff(242);
          } else if (x == "Very sensitive") {
            id(okay_nabu).set_probability_cutoff(143);
            id(hey_jarvis).set_probability_cutoff(212);
            id(hey_mycroft).set_probability_cutoff(237);
          }

micro_wake_word:
  id: mww
  microphone: i2s_mic

  models:
    - model: okay_nabu
      id: okay_nabu

    - model: hey_jarvis
      id: hey_jarvis

    - model: hey_mycroft
      id: hey_mycroft

    - model: ${stop_model_file}
      id: stop
      internal: true

  vad:

  on_wake_word_detected:
    - script.execute:
        id: play_sound
        priority: true
        sound_file: !lambda return id(wake_word_triggered_sound);

    - wait_until:
        condition:
          media_player.is_announcing: echo_base_player
        timeout: 500ms

    - wait_until:
        condition:
          and:
            - not:
                media_player.is_announcing: echo_base_player
            - not:
                speaker.is_playing: i2s_speaker
        timeout: 10s

    - voice_assistant.start:
        wake_word: !lambda return wake_word;

voice_assistant:
  id: va
  microphone: i2s_mic
  media_player: echo_base_player
  micro_wake_word: mww
  noise_suppression_level: 2
  auto_gain: 31dBFS
  volume_multiplier: 2.0

  on_listening:
    - lambda: |-
        id(voice_assistant_phase) =
          ${voice_assist_listening_phase_id};
    - script.execute: draw_display

  on_stt_vad_end:
    - lambda: |-
        id(voice_assistant_phase) =
          ${voice_assist_thinking_phase_id};
    - script.execute: draw_display

  on_tts_start:
    - lambda: |-
        id(voice_assistant_phase) =
          ${voice_assist_replying_phase_id};
    - script.execute: draw_display

  on_end:
    - wait_until:
        condition:
          media_player.is_announcing: echo_base_player
        timeout: 500ms

    - wait_until:
        condition:
          and:
            - not:
                media_player.is_announcing: echo_base_player
            - not:
                speaker.is_playing: i2s_speaker
        timeout: 30s

    - if:
        condition:
          lambda: |-
            return id(wake_word_engine_location).current_option() ==
                   "On device";
        then:
          - lambda: id(va).set_use_wake_word(false);
          - micro_wake_word.start:

    - script.execute: set_idle_or_mute_phase
    - script.execute: draw_display

  on_error:
    - if:
        condition:
          and:
            - lambda: return !id(init_in_progress);
            - lambda: return code != "duplicate_wake_up_detected";
            - lambda: return code != "stt-no-text-recognized";
        then:
          - lambda: |-
              id(voice_assistant_phase) =
                ${voice_assist_error_phase_id};
          - script.execute: draw_display
          - delay: 1s
          - script.execute: set_idle_or_mute_phase
          - script.execute: draw_display

    - if:
        condition:
          lambda: return code == "cloud-auth-failed";
        then:
          - script.execute:
              id: play_sound
              priority: true
              sound_file: !lambda return id(error_cloud_expired);
          - script.execute: draw_display

  on_client_connected:
    - lambda: id(init_in_progress) = false;
    - script.execute: start_wake_word
    - script.execute: set_idle_or_mute_phase
    - script.execute: draw_display

  on_client_disconnected:
    - script.execute: stop_wake_word
    - lambda: |-
        id(voice_assistant_phase) =
          ${voice_assist_not_ready_phase_id};
    - script.execute: draw_display

  on_timer_finished:
    - switch.turn_on: timer_ringing

    - wait_until:
        condition:
          media_player.is_announcing: echo_base_player
        timeout: 5s

    - lambda: |-
        id(voice_assistant_phase) =
          ${voice_assist_timer_finished_phase_id};
    - script.execute: draw_display

globals:
  - id: init_in_progress
    type: bool
    restore_value: false
    initial_value: "true"

  - id: voice_assistant_phase
    type: int
    restore_value: false
    initial_value: ${voice_assist_not_ready_phase_id}

lp5562:
  id: lp5562_led
  i2c_id: bsp_bus
  use_internal_clk: true
  white_current: 17.5

output:
  - platform: lp5562
    id: lp5562_white_channel
    lp5562_id: lp5562_led
    channel: white

light:
  - platform: monochromatic
    name: "LCD Backlight"
    id: lcd_backlight
    output: lp5562_white_channel
    icon: "mdi:television"
    restore_mode: ALWAYS_ON

time:
  - platform: sntp
    id: sntp_time
    servers:
      - time.google.com
    timezone: "America/Fortaleza"
    on_time_sync:
      - script.execute: draw_display

font:
  - file: "gfonts://Roboto"
    id: time_font
    size: 18

  - file: "gfonts://Roboto"
    id: status_font
    size: 16

  - file: "gfonts://Roboto@700"
    id: time_bold_font
    size: 23

  - file: "gfonts://Roboto@400"
    id: date_font
    size: 14

  - file: "gfonts://Roboto@700"
    id: pronto_font
    size: 19

  - file: ${mdi_webfont_file}
    id: mdi_icon_128
    size: 128
    bpp: 4
    glyphs:
      - "\U000F036D" # mdi:mic-mute

color:
  - id: idle_color
    hex: ${idle_illustration_background_color}

  - id: listening_color
    hex: ${listening_illustration_background_color}

  - id: thinking_color
    hex: ${thinking_illustration_background_color}

  - id: replying_color
    hex: ${replying_illustration_background_color}

  - id: loading_color
    hex: ${loading_illustration_background_color}

  - id: error_color
    hex: ${error_illustration_background_color}

  - id: text_yellow
    red: 50%
    green: 50%
    blue: 0%

  - id: text_cyan
    red: 0%
    green: 50%
    blue: 50%

  - id: text_green
    red: 0%
    green: 100%
    blue: 0%

  - id: text_red
    red: 100%
    green: 0%
    blue: 0%

  - id: light_gray
    hex: "CCCCCC"

  - id: text_blue
    hex: "2196F3"

  - id: color_white
    hex: "FFFFFF"

  - id: color_black
    hex: "000000"

image:
  - file: ${error_illustration_file}
    id: casita_error
    resize: 160x120
    type: RGB
    transparency: alpha_channel

  - file: ${idle_illustration_file}
    id: casita_idle
    resize: 160x120
    type: RGB
    transparency: alpha_channel

  - file: ${listening_illustration_file}
    id: casita_listening
    resize: 160x120
    type: RGB
    transparency: alpha_channel

  - file: ${thinking_illustration_file}
    id: casita_thinking
    resize: 160x120
    type: RGB
    transparency: alpha_channel

  - file: ${replying_illustration_file}
    id: casita_replying
    resize: 160x120
    type: RGB
    transparency: alpha_channel

  - file: ${loading_illustration_file}
    id: casita_initializing
    resize: 160x120
    type: RGB
    transparency: alpha_channel

  - file: ${error_no_wifi_illustration_file}
    id: error_no_wifi
    resize: 160x120
    type: RGB
    transparency: alpha_channel

  - file: ${error_no_ha_illustration_file}
    id: error_no_ha
    resize: 160x120
    type: RGB
    transparency: alpha_channel

display:
  - platform: mipi_spi
    id: atoms3r_lcd
    model: M5STACK-ATOMS3R-GC9107
    spi_id: spi_bus
    rotation: 180°
    update_interval: never

    pages:
      - id: idle_page
        lambda: |-
          it.fill(id(idle_color));
          it.image(64, 64, id(casita_idle), ImageAlign::CENTER);
          it.strftime(64, 6, id(time_bold_font), id(color_white), TextAlign::CENTER, "%H:%M", id(sntp_time).now());
          it.strftime(64, 32, id(date_font), id(light_gray), TextAlign::CENTER, "%d/%m/%Y", id(sntp_time).now());
          it.printf(64, 96, id(pronto_font), id(text_blue), TextAlign::CENTER, "PRONTO!");

      - id: listening_page
        lambda: |-
          it.fill(id(listening_color));
          it.image(64, 64, id(casita_listening), ImageAlign::CENTER);
          it.strftime(64, 8, id(time_font), id(color_white), TextAlign::CENTER, "%H:%M", id(sntp_time).now());
          it.strftime(64, 26, id(time_font), id(color_white), TextAlign::CENTER, "%d/%m/%Y", id(sntp_time).now());
          it.printf(64, 92, id(status_font), id(text_yellow), TextAlign::CENTER, "Ouvindo...");

      - id: thinking_page
        lambda: |-
          it.fill(id(thinking_color));
          it.image(64, 64, id(casita_thinking), ImageAlign::CENTER);
          it.strftime(64, 8, id(time_font), id(color_white), TextAlign::CENTER, "%H:%M", id(sntp_time).now());
          it.strftime(64, 26, id(time_font), id(color_white), TextAlign::CENTER, "%d/%m/%Y", id(sntp_time).now());
          it.printf(64, 92, id(status_font), id(text_cyan), TextAlign::CENTER, "Enviando p/ HA...");

      - id: replying_page
        lambda: |-
          it.fill(id(replying_color));
          it.image(64, 64, id(casita_replying), ImageAlign::CENTER);
          it.strftime(64, 8, id(time_font), id(color_white), TextAlign::CENTER, "%H:%M", id(sntp_time).now());
          it.strftime(64, 26, id(time_font), id(color_white), TextAlign::CENTER, "%d/%m/%Y", id(sntp_time).now());
          it.printf(64, 92, id(status_font), id(text_green), TextAlign::CENTER, "Respondendo...");

      - id: error_page
        lambda: |-
          it.fill(id(error_color));
          it.image(64, 64, id(casita_error), ImageAlign::CENTER);
          it.strftime(64, 8, id(time_font), id(color_white), TextAlign::CENTER, "%H:%M", id(sntp_time).now());
          it.strftime(64, 26, id(time_font), id(color_white), TextAlign::CENTER, "%d/%m/%Y", id(sntp_time).now());
          it.printf(64, 92, id(status_font), id(text_red), TextAlign::CENTER, "Erro");

      - id: no_ha_page
        lambda: |-
          it.fill(id(error_color));
          it.image(64, 64, id(error_no_ha), ImageAlign::CENTER);
          it.strftime(64, 8, id(time_font), id(color_white), TextAlign::CENTER, "%H:%M", id(sntp_time).now());
          it.strftime(64, 26, id(time_font), id(color_white), TextAlign::CENTER, "%d/%m/%Y", id(sntp_time).now());
          it.printf(64, 100, id(status_font), id(text_red), TextAlign::CENTER, "Sem conexão HA");

      - id: no_wifi_page
        lambda: |-
          it.fill(id(error_color));
          it.image(64, 64, id(error_no_wifi), ImageAlign::CENTER);
          it.strftime(64, 8, id(time_font), id(color_white), TextAlign::CENTER, "%H:%M", id(sntp_time).now());
          it.strftime(64, 26, id(time_font), id(color_white), TextAlign::CENTER, "%d/%m/%Y", id(sntp_time).now());
          it.printf(64, 100, id(status_font), id(text_red), TextAlign::CENTER, "Sem Wi-Fi");

      - id: initializing_page
        lambda: |-
          it.fill(id(loading_color));
          it.image(64, 64, id(casita_initializing), ImageAlign::CENTER);
          it.strftime(64, 8, id(time_font), id(color_white), TextAlign::CENTER, "%H:%M", id(sntp_time).now());
          it.strftime(64, 26, id(time_font), id(color_white), TextAlign::CENTER, "%d/%m/%Y", id(sntp_time).now());
          it.printf(64, 92, id(status_font), id(color_white), TextAlign::CENTER, "Inicializando...");

      - id: muted_page
        lambda: |-
          it.fill(id(color_black));
          it.image(64, 64, id(casita_idle), ImageAlign::CENTER);
          it.strftime(64, 8, id(time_font), id(color_white), TextAlign::CENTER, "%H:%M", id(sntp_time).now());
          it.strftime(64, 26, id(time_font), id(color_white), TextAlign::CENTER, "%d/%m/%Y", id(sntp_time).now());
          it.printf(64, 92, id(status_font), id(text_red), TextAlign::CENTER, "Microfone mudo");

script:
  - id: start_wake_word
    mode: restart
    then:
      - if:
          condition:
            and:
              - switch.is_off: mute
              - not:
                  voice_assistant.is_running:
              - lambda: |-
                  return
                    id(wake_word_engine_location).current_option() ==
                    "On device";
          then:
            - lambda: id(va).set_use_wake_word(false);
            - micro_wake_word.start:

      - if:
          condition:
            and:
              - switch.is_off: mute
              - not:
                  voice_assistant.is_running:
              - lambda: |-
                  return
                    id(wake_word_engine_location).current_option() ==
                    "In Home Assistant";
          then:
            - lambda: id(va).set_use_wake_word(true);
            - voice_assistant.start_continuous:

  - id: stop_wake_word
    mode: restart
    then:
      - if:
          condition:
            lambda: |-
              return
                id(wake_word_engine_location).current_option() ==
                "In Home Assistant";
          then:
            - lambda: id(va).set_use_wake_word(false);
            - voice_assistant.stop:

      - if:
          condition:
            lambda: |-
              return
                id(wake_word_engine_location).current_option() ==
                "On device";
          then:
            - micro_wake_word.stop:

  - id: set_idle_or_mute_phase
    mode: restart
    then:
      - if:
          condition:
            switch.is_off: mute
          then:
            - lambda: |-
                id(voice_assistant_phase) =
                  ${voice_assist_idle_phase_id};
          else:
            - lambda: |-
                id(voice_assistant_phase) =
                  ${voice_assist_muted_phase_id};

  - id: play_sound
    mode: restart
    parameters:
      priority: bool
      sound_file: "audio::AudioFile*"
    then:
      - lambda: |-
          if (priority) {
            id(echo_base_player)
              ->make_call()
              .set_command(
                media_player::MediaPlayerCommand::
                  MEDIA_PLAYER_COMMAND_STOP
              )
              .set_announcement(true)
              .perform();
          }

          if (
            id(echo_base_player).state !=
              media_player::MediaPlayerState::
                MEDIA_PLAYER_STATE_ANNOUNCING ||
            priority
          ) {
            id(echo_base_player)->play_file(
              sound_file,
              true,
              false
            );
          }

  - id: draw_display
    mode: restart
    then:
      - if:
          condition:
            lambda: return !id(init_in_progress);
          then:
            - if:
                condition:
                  wifi.connected:
                then:
                  - if:
                      condition:
                        api.connected:
                      then:
                        - lambda: |-
                            switch (id(voice_assistant_phase)) {
                              case ${voice_assist_listening_phase_id}:
                                id(atoms3r_lcd).show_page(listening_page);
                                break;
                              case ${voice_assist_thinking_phase_id}:
                                id(atoms3r_lcd).show_page(thinking_page);
                                break;
                              case ${voice_assist_replying_phase_id}:
                                id(atoms3r_lcd).show_page(replying_page);
                                break;
                              case ${voice_assist_error_phase_id}:
                                id(atoms3r_lcd).show_page(error_page);
                                break;
                              case ${voice_assist_muted_phase_id}:
                                id(atoms3r_lcd).show_page(muted_page);
                                break;
                              case ${voice_assist_not_ready_phase_id}:
                                id(atoms3r_lcd).show_page(no_ha_page);
                                break;
                              default:
                                id(atoms3r_lcd).show_page(idle_page);
                                break;
                            }
                            id(atoms3r_lcd).update();
                      else:
                        - display.page.show: no_ha_page
                        - component.update: atoms3r_lcd
                else:
                  - display.page.show: no_wifi_page
                  - component.update: atoms3r_lcd
          else:
            - display.page.show: initializing_page
            - component.update: atoms3r_lcd

interval:
  # Atualiza o relógio da tela sem redesenhar excessivamente
  - interval: 30s
    then:
      - if:
          condition:
            lambda: |-
              return
                !id(init_in_progress) &&
                id(voice_assistant_phase) ==
                  ${voice_assist_idle_phase_id};
          then:
            - script.execute: draw_display

text_sensor:
  - platform: wifi_info
    ip_address:
      name: "IP"
    dns_address:
      name: "DNS"
    mac_address:
      name: "MAC"

Como funciona, em resumo:

  • O micro_wake_word roda os modelos localmente (okay_nabu, hey_jarvis, hey_mycroft). Ao detectar a palavra, o voice_assistant inicia e conversa com o Assist do Home Assistant.
  • A tela (mipi_spi, AtomS3R GC9107) tem uma página por fase (ocioso, ouvindo, pensando, respondendo, erro, mudo, sem Wi-Fi, sem HA); o script draw_display escolhe a página conforme o estado.
  • O botão físico: clique curto muda/desmuta o microfone; segurar 10 s faz factory reset.
  • Áudio por I²S (microfone + alto-falante com DAC es8311); componentes externos do M5Stack (lp5562 para o LED/retroiluminação e es8311).
  • Dá para escolher o motor de wake word (no dispositivo ou no HA) e a sensibilidade, além de um timer com som ao terminar.

Ative o Assist no Home Assistant, valide no ESPHome Device Builder e teste as palavras de ativação.

Exemplo pronto: leitor RFID/NFC com tela (M5Stack AtomS3R + RFID 2)#

Um leitor de RFID/NFC com telinha, feito com o M5Stack AtomS3R + unidade RFID 2 (WS1850S). Ao aproximar uma tag, ele mostra o UID na tela, conta as leituras e envia a tag ao Home Assistant (tag_scanned + um evento), onde você dispara automações — abrir a porta, ativar um cenário, identificar quem chegou.

Troque pelos seus dados antes de gravar. A chave da API e o IP fixo são exemplos; gere/ajuste os seus. Para mostrar um nome amigável da tag na tela, crie no HA um helper input_text.rfid_tag_nome (opcional). Requer ESPHome 2026.7+.

substitutions:
  name: atom-s3r-rfid
  friendly_name: "ATOM S3R RFID"

esphome:
  name: ${name}
  friendly_name: ${friendly_name}
  min_version: 2026.7.0
  platformio_options:
    board_build.flash_mode: opi

  on_boot:
    priority: -100
    then:
      - light.turn_on:
          id: lcd_backlight
          brightness: 100%
      - delay: 500ms
      - lambda: |-
          id(scan_total).publish_state(id(scan_count));
          if (!id(last_uid).empty()) {
            id(last_uid_text).publish_state(id(last_uid));
            id(uid_length).publish_state(
              (float)((id(last_uid).size() + 1) / 3)
            );
          }
      - component.update: meu_display

esp32:
  variant: esp32s3
  flash_size: 8MB
  cpu_frequency: 240MHz
  framework:
    type: esp-idf
    sdkconfig_options:
      CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240: "y"
      CONFIG_ESP32S3_DATA_CACHE_64KB: "y"
      CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y"

psram:
  mode: octal
  speed: 80MHz
  enable_ecc: true

logger:
  level: DEBUG
  hardware_uart: USB_SERIAL_JTAG

api:
  encryption:
    key: !secret api_encryption_key   # gere a sua no ESPHome Builder

ota:
  - platform: esphome
    encryption:

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  manual_ip:                    # ajuste à sua rede ou remova para DHCP
    static_ip: 192.168.1.61
    gateway: 192.168.1.1
    subnet: 255.255.255.0
    dns1: 8.8.8.8
    dns2: 8.8.4.4
  power_save_mode: NONE

external_components:
  - source: github://m5stack/esphome-yaml/components@main
    components:
      - lp5562
    refresh: 1d

globals:
  - id: status_leitura
    type: bool
    restore_value: no
    initial_value: "false"

  - id: last_uid
    type: std::string
    restore_value: yes
    initial_value: '""'

  - id: scan_count
    type: int
    restore_value: yes
    initial_value: "0"

  - id: last_scan_ts
    type: time_t
    restore_value: yes
    initial_value: "0"

i2c:
  # Barramento interno: backlight LP5562
  - id: bsp_bus
    sda:
      number: GPIO45
      ignore_strapping_warning: true
    scl:
      number: GPIO0
      ignore_strapping_warning: true
    scan: true

  # Port A: RFID 2 / WS1850S
  - id: bus_grove
    sda: GPIO2
    scl: GPIO1
    scan: true

spi:
  id: spi_bus
  clk_pin: GPIO15
  mosi_pin: GPIO21

lp5562:
  id: lp5562_led
  i2c_id: bsp_bus
  use_internal_clk: true
  white_current: 17.5

output:
  - platform: lp5562
    id: lp5562_white_channel
    lp5562_id: lp5562_led
    channel: white

light:
  - platform: monochromatic
    name: "LCD Backlight"
    id: lcd_backlight
    output: lp5562_white_channel
    icon: mdi:television
    restore_mode: ALWAYS_ON

time:
  - platform: sntp
    id: sntp_time
    servers:
      - time.google.com
    timezone: "America/Fortaleza"

rc522_i2c:
  i2c_id: bus_grove
  address: 0x28
  update_interval: 0.5s

  on_tag:
    then:
      - lambda: |-
          id(last_uid) = x;
          id(scan_count) += 1;
          id(status_leitura) = true;

          auto now = id(sntp_time).now();

          if (now.is_valid()) {
            id(last_scan_ts) = now.timestamp;

            char buf[32];
            now.strftime(
              buf,
              sizeof(buf),
              "%d/%m/%Y %H:%M:%S"
            );

            id(last_scan_text).publish_state(buf);
          }

          id(last_uid_text).publish_state(x);
          id(uid_length).publish_state(
            (float)((x.size() + 1) / 3)
          );
          id(scan_total).publish_state(id(scan_count));
          id(tag_present_bs).publish_state(true);

      - homeassistant.tag_scanned: !lambda "return x;"

      - homeassistant.event:
          event: esphome.rfid_tag_scanned
          data:
            device: ${name}
          data_template:
            uid: "{{ uid }}"
          variables:
            uid: !lambda "return x;"

      - component.update: meu_display
      - delay: 3s

      - lambda: |-
          id(status_leitura) = false;
          id(tag_present_bs).publish_state(false);

      - component.update: meu_display

  on_tag_removed:
    then:
      - lambda: |-
          id(tag_present_bs).publish_state(false);

font:
  - file: "gfonts://Roboto@700"
    id: fonte_hora
    size: 28
    glyphsets:
      - GF_Latin_Core

  - file: "gfonts://Roboto"
    id: fonte_texto
    size: 16
    glyphsets:
      - GF_Latin_Core

  - file: "gfonts://Roboto"
    id: fonte_mini
    size: 12
    glyphsets:
      - GF_Latin_Core

display:
  - platform: mipi_spi
    id: meu_display
    spi_id: spi_bus
    model: M5STACK-ATOMS3R-GC9107
    rotation: 180°
    update_interval: 1s

    lambda: |-
      it.fill(Color::BLACK);

      Color white  = Color(255, 255, 255);
      Color gray   = Color(150, 150, 150);
      Color green  = Color(0, 255, 0);
      Color yellow = Color(255, 255, 0);

      auto now = id(sntp_time).now();

      if (now.is_valid()) {
        it.strftime(64, 2, id(fonte_hora), white, TextAlign::TOP_CENTER, "%H:%M", now);
        it.strftime(64, 38, id(fonte_texto), white, TextAlign::TOP_CENTER, "%d/%m/%Y", now);
      } else {
        it.print(64, 2, id(fonte_hora), white, TextAlign::TOP_CENTER, "--:--");
      }

      if (id(status_leitura)) {
        it.print(64, 70, id(fonte_hora), yellow, TextAlign::CENTER, "READING!");
      } else {
        it.print(64, 70, id(fonte_hora), green, TextAlign::CENTER, "READY!");
      }

      std::string label = id(ha_tag_name).state;

      if (label.empty() || label == "unknown" || label == "unavailable") {
        label = id(last_uid);
      }

      if (!label.empty()) {
        if (label.size() > 20) {
          label = label.substr(0, 19) + "...";
        }
        it.printf(64, 110, id(fonte_mini), gray, TextAlign::CENTER, "%s", label.c_str());
      }

text_sensor:
  - platform: template
    name: "Última TAG (UID)"
    id: last_uid_text
    icon: mdi:nfc-variant
    update_interval: never

  - platform: template
    name: "Última leitura (data/hora)"
    id: last_scan_text
    icon: mdi:clock-outline
    update_interval: never

  - platform: homeassistant
    id: ha_tag_name
    entity_id: input_text.rfid_tag_nome
    internal: true

  - platform: wifi_info
    ip_address:
      name: "IP"
    dns_address:
      name: "DNS"
    mac_address:
      name: "MAC"

  - platform: version
    name: "Versão ESPHome"
    entity_category: diagnostic

sensor:
  - platform: template
    name: "Total de leituras"
    id: scan_total
    icon: mdi:counter
    accuracy_decimals: 0
    state_class: total_increasing
    update_interval: never

  - platform: template
    name: "Tamanho do UID (bytes)"
    id: uid_length
    icon: mdi:ruler
    accuracy_decimals: 0
    update_interval: never

  - platform: template
    name: "Tempo desde última leitura"
    id: time_since_scan
    icon: mdi:timer-sand
    unit_of_measurement: s
    device_class: duration
    accuracy_decimals: 0
    update_interval: 10s
    lambda: |-
      if (id(last_scan_ts) == 0) {
        return NAN;
      }

      auto now = id(sntp_time).now();

      if (!now.is_valid()) {
        return NAN;
      }

      return (float)(now.timestamp - id(last_scan_ts));

  - platform: wifi_signal
    name: "Sinal WiFi"
    update_interval: 60s
    entity_category: diagnostic

  - platform: uptime
    name: "Uptime"
    entity_category: diagnostic

binary_sensor:
  - platform: template
    name: "TAG presente"
    id: tag_present_bs
    device_class: presence

  - platform: status
    name: "Status"
    entity_category: diagnostic

button:
  - platform: template
    name: "RFID: Zerar contador"
    icon: mdi:counter
    entity_category: config
    on_press:
      - lambda: |-
          id(scan_count) = 0;
          id(scan_total).publish_state(0);

  - platform: template
    name: "RFID: Limpar última TAG"
    icon: mdi:nfc-variant-off
    entity_category: config
    on_press:
      - lambda: |-
          id(last_uid) = "";
          id(last_scan_ts) = 0;
          id(last_uid_text).publish_state("");
          id(last_scan_text).publish_state("");

      - component.update: meu_display

  - platform: restart
    name: "RFID: Reiniciar"
    entity_category: config

web_server:
  port: 80
  include_internal: true

Como funciona, em resumo:

  • O rc522_i2c lê as tags no Port A (Grove I²C). No on_tag, o UID é guardado, o contador incrementa e a tag é enviada ao HA com homeassistant.tag_scanned e um evento (esphome.rfid_tag_scanned).
  • A tela (mipi_spi, AtomS3R GC9107) mostra hora e data e o estado — READY! (ocioso) ou READING! (lendo) — além do nome/UID da última tag.
  • Sensores expõem ao HA: UID, data da última leitura, total de leituras, tamanho do UID, tempo desde a última leitura e presença de tag.
  • Botões de configuração: zerar o contador, limpar a última tag e reiniciar.

No Home Assistant, use o gatilho “Tag” (ou o evento) para criar automações. Valide no ESPHome Device Builder antes de gravar.

Voltar ao Home Assistant.

Fontes: Bluetooth Proxy, remote_receiver (IR), OpenThread, LVGL, display ST7789V, http_request, touchscreen GT911, voice_assistant, micro_wake_word, RC522 (RFID) e Nabu Casa e Open Home Foundation.

  • ESPHome
  • ESP32
  • YAML
  • Exemplos
  • Thread