Il Display Definitivo per Home Assistant: Waveshare ESP32-S3 Touch AMOLED

Home Assistant

Scritto da Vincenzo Caputo

Ammettiamolo: tutti noi, almeno una volta, abbiamo guardato Iron Man e pensato: "Voglio quella roba lì in casa mia". Non Alexa, non Google Nest. Voglio Jarvis. Voglio un'interfaccia olografica che pulsa mentre mi risponde e una voce che capisce cosa dico anche se sto ascoltando gli AC/DC al massimo volume.

Così, armati di speranza e saldatore, siamo partiti con il Waveshare ESP32-S3 Touch 1.75inch AMOLED. Bello, bellissimo. Un display IPS, touch capacitivo, processore potente. "Ha anche i microfoni integrati!", abbiamo esclamato.

Spoiler: È stato un disastro!

I microfoni integrati nel Waveshare sono lì più per figura che per funzionalità. Senza un DSP (Digital Signal Processor) dedicato e senza cancellazione dell'eco (AEC), tentare di parlarci mentre rispondeva era come urlare in una galleria del vento. Inoltre, gestire l'elaborazione grafica a 30fps e la pipeline vocale sullo stesso chip portava l'ESP32 a chiedere pietà (e a crashare spesso).

La soluzione? Divide et impera. Abbiamo deciso di operare una chirurgia hardware: separare il "Cervello" (Audio/Logica) dalla "Faccia" (Grafica).

In questa guida vedremo come creare un sistema distribuito usando:

  1. Il Cervello: ReSpeaker Lite (per un riconoscimento vocale impeccabile).

  2. La Faccia: Waveshare ESP32-S3 1.75" (dedicato puramente all'animazione sci-fi).

  3. Il Ponte: Una comunicazione HTTP diretta tra i due dispositivi.

I due dispositivi utilizzati li potete acquistare su Amazon ai seguenti link:

Waveshare ESP32-S3 1.75inch AMOLED Round Touch Display Development Board(with Case, No GPS), 32-Bit LX7 2-Core Processor, 466x466, QSPI Interface, Dual Digital Microphones Array, ESP32 With Display
Prezzo intero: 0,00€
Prezzo scontato: 45,99€

ReSpeaker Lite Voice Assistant Kit con XIAO ESP32S3
Prezzo intero: 0,00€
Prezzo scontato: 58,83€

Oppure sui siti dei rispettivi produttori:

https://www.waveshare.com/product/arduino/displays/amoled/esp32-s3-touch-amoled-1.75.htm

https://www.seeedstudio.com/ReSpeaker-Lite-p-5928.html?srsltid=AfmBOor3_RRwc0hYIPauAork1MATtk0F4OlJotcZyy96_Kd5QBeWNavJ

I Prerequisiti

Prima di iniziare a compilare codice come se non ci fosse un domani, assicurati di avere:

  • Una configurazione funzionante di Home Assistant con Assist (Pipeline configurata con Whisper e Piper, o meglio ancora con LLM integrato).

  • ESPHome installato e aggiornato.

  • Aver letto il nostro precedente articolo sul ReSpeaker Lite, perché daremo per scontato che abbiate già flashato la base.

L'Hardware: Perché questa accoppiata?

1. Il ReSpeaker Lite (Le Orecchie)

Questo non è un semplice microfono. Monta un chip XMOS XU-316. In termini tecnici, significa che ha un array di microfoni con cancellazione dell'eco hardware e riduzione del rumore on-board. Può "sentire" la wake-word anche mentre l'altoparlante sta suonando musica a tutto volume (Barge-in support). L'ESP32 integrato qui si occupa solo di gestire l'audio e la connessione con Home Assistant.

2. Il Waveshare ESP32-S3 1.75" (Gli Occhi)

Questo bestione deve gestire un pannello RGB 466×466. L'interfaccia RGB richiede molta RAM e cicli di clock. Liberandolo dal compito di ascoltare ed elaborare l'audio, possiamo usare tutta la potenza del dual-core Xtensa LX7 per disegnare vettori, animazioni fluide e interfacce stile "Arc Reactor" senza lag.

Passo 1: La "Faccia" (Configurazione Waveshare)

L'idea è semplice: il Waveshare non deve "sapere" nulla di Home Assistant o di voice pipeline. Lui deve essere uno "schiavo" che riceve un numero da 0 a 4 e cambia faccia di conseguenza.

Usiamo un'entità number esposta via web_server. Questo ci permette di comandarlo con una semplice richiesta HTTP POST.

Il Codice YAML (Waveshare):

Per la grafica, abbiamo optato per un design "Cyberpunk Geometrico" ottimizzato per non uccidere la CPU (niente immagini pesanti, solo pura matematica vettoriale).

Ricordatevi di aggiornare i campi SSID e Password della vostra rete WiFi

substitutions:
  name: "jarvis-face"
  friendly_name: "Jarvis Face Display"

esphome:
  name: "${name}"
  friendly_name: "${friendly_name}"
  # Opzioni specifiche per la board Waveshare S3
  platformio_options:
    board_build.flash_mode: dio
    board_build.f_flash: 80000000L
    board_build.f_cpu: 240000000L

esp32:
  variant: esp32s3
  flash_size: 16MB
  framework:
    type: esp-idf

psram:
  mode: octal
  speed: 80MHz

wifi:
  ssid: "tuossid" #qui metti all'interno delle virgolette la tua rete WiFi
  password: "tuapasswordwifi" #qui metti all'interno delle virgolette la tua password WiFi

api:
  reboot_timeout: 0s

ota:
  platform: esphome

logger:

globals:
  - id: jarvis_state
    type: int
    initial_value: '0'
    restore_value: false

# =========================
# WEB SERVER (Ricevitore Comandi)
# =========================
web_server:
  port: 80

number:
  - platform: template
    id: jarvis_state_control
    name: "jstate" # <--- Nome corto e semplice
    optimistic: true
    min_value: 0
    max_value: 4
    step: 1
    on_value:
      - lambda: |
          id(jarvis_state) = (int)x;
          ESP_LOGI("HTTP", "Stato ricevuto: %d", (int)x);
# =========================
# BUS CONFIGURATION (QSPI)
# =========================
spi:
  - id: display_qspi
    type: quad
    clk_pin: GPIO38
    data_pins:
      - GPIO4
      - GPIO5
      - GPIO6
      - GPIO7

# =========================
# DISPLAY (Piattaforma MIPI SPI)
# =========================
display:
  - platform: mipi_spi
    id: disp1
    model: CO5300
    bus_mode: quad
    reset_pin: GPIO39
    cs_pin: GPIO12
    dimensions:
      height: 466
      width: 466
    update_interval: 33ms
    lambda: |-
      it.fill(Color(0,0,0));

      int s = id(jarvis_state);
      long t = millis();
      int cx = 233; int cy = 233;

      // --- COLORI BASE ---
      Color ciano = Color(0, 255, 240);
      Color viola = Color(160, 30, 255);
      Color rosso = Color(255, 50, 50);
      Color bianco = Color(255, 255, 255);
      Color verde = Color(0, 255, 100);

      Color main_color = ciano;
      if (s == 2) main_color = viola;
      if (s == 3) main_color = verde;
      if (s == 4) main_color = rosso;

      // --- 1. HUD CORNICE (Nuovo!) ---
      // Disegna 4 angolari agli estremi per dare struttura "Visore"
      int d = 10; // Distanza dal bordo
      int l = 40; // Lunghezza linea
      // Alto Sx
      it.line(d, d, d+l, d, ciano); it.line(d, d, d, d+l, ciano);
      // Alto Dx
      it.line(466-d, d, 466-d-l, d, ciano); it.line(466-d, d, 466-d, d+l, ciano);
      // Basso Sx
      it.line(d, 466-d, d+l, 466-d, ciano); it.line(d, 466-d, d, 466-d-l, ciano);
      // Basso Dx
      it.line(466-d, 466-d, 466-d-l, 466-d, ciano); it.line(466-d, 466-d, 466-d, 466-d-l, ciano);

      // --- 2. ANELLI ESTERNI ---
      it.circle(cx, cy, 220, main_color); // Ora cambia colore con lo stato!
      it.circle(cx, cy, 190, main_color);

      // Decorazioni superiori (Segmenti)
      for(int i = -30; i < 30; i+=3) {
          float rad = (i - 90) * 0.01745;
          int x1 = cx + cos(rad) * 200;
          int y1 = cy + sin(rad) * 200;
          int x2 = cx + cos(rad) * 215;
          int y2 = cy + sin(rad) * 215;
          it.line(x1, y1, x2, y2, main_color);
      }

      // --- 3. IL CUORE GEOMETRICO (Rombo) ---
      float rot = 0;
      if (s == 2) rot = t / 400.0; // Rotazione veloce se pensa

      int size = 70;
      if (s == 1 || s == 3) size = 70 + (sin(t/150.0)*8); // Battito

      // Calcolo Rombo
      int x_top = cx + cos(rot - 1.57) * size;
      int y_top = cy + sin(rot - 1.57) * size;
      int x_right = cx + cos(rot) * size;
      int y_right = cy + sin(rot) * size;
      int x_bot = cx + cos(rot + 1.57) * size;
      int y_bot = cy + sin(rot + 1.57) * size;
      int x_left = cx + cos(rot + 3.14) * size;
      int y_left = cy + sin(rot + 3.14) * size;

      it.line(x_top, y_top, x_right, y_right, main_color);
      it.line(x_right, y_right, x_bot, y_bot, main_color);
      it.line(x_bot, y_bot, x_left, y_left, main_color);
      it.line(x_left, y_left, x_top, y_top, main_color);

      // Quadrato interno fisso
      it.rectangle(cx - 35, cy - 35, 70, 70, main_color);

      // --- 4. EFFETTI SPECIALI PER STATO ---

      // IDLE: Scansione Laser
      if (s == 0) {
        int scan_y = cy + (sin(t/1000.0) * 60); // Va su e giù sopra il rombo
        it.line(cx - 50, scan_y, cx + 50, scan_y, ciano);

        it.print(cx, 160, id(font_ui), ciano, TextAlign::CENTER, "SYSTEM READY");
        it.print(cx, 300, id(font_ui), bianco, TextAlign::CENTER, "WAITING...");
      }

      // LISTENING: Onde verso l'interno
      else if (s == 1) {
        int r_wave = 100 - ((t % 1000) / 10); // Cerchio che si restringe
        it.circle(cx, cy, r_wave, bianco);
        it.print(cx, 160, id(font_ui), bianco, TextAlign::CENTER, "LISTENING");
      }

      // PARLO: Equalizzatore Grafico (Nuovo!)
      else if (s == 3) {
        it.print(cx, 160, id(font_ui), verde, TextAlign::CENTER, "JARVIS VOICE");

        // Disegna 5 barre sotto al rombo
        for (int i = -2; i <= 2; i++) {
            // Altezza casuale "simulata" con seni sfasati
            int h = 10 + (sin((t/100.0) + i) * 20) + 20;
            int x_bar = cx + (i * 20);
            int y_base = 320;
            it.filled_rectangle(x_bar - 5, y_base - h, 10, h, verde);
        }
      }

      // PENSO: Glitch Text
      else if (s == 2) {
         if ((t/200)%2 == 0) it.print(cx, 320, id(font_ui), viola, TextAlign::CENTER, "PROCESSING_");
         else it.print(cx, 320, id(font_ui), viola, TextAlign::CENTER, "PROCESSING ");
      }

      // --- 5. DATA STREAM LATERALE (Fake Data) ---
      // Una colonna di numeri che cambia a destra
      it.print(400, 200, id(font_ui), ciano, TextAlign::CENTER, "DAT");
      int val = (t / 100) % 99;
      char buffer[10];
      sprintf(buffer, "%02d", val);
      it.print(400, 230, id(font_ui), bianco, TextAlign::CENTER, buffer);

      sprintf(buffer, "%02d", (val * 3) % 99);
      it.print(400, 260, id(font_ui), bianco, TextAlign::CENTER, buffer);

      // --- ICONE STATICHE (Migliorate) ---
      it.circle(100, 100, 15, ciano); // Mic
      if (s == 1) it.filled_circle(100, 100, 10, bianco); // Accendi mic se ascolta

      it.circle(366, 100, 15, verde); // Speaker
      if (s == 3) it.filled_circle(366, 100, 10, verde); // Accendi speaker se parla
# =========================
# LUMINOSITÀ & FONT
# =========================
light:
  - platform: monochromatic
    id: display_backlight
    name: "Backlight"
    output: backlight_brightness
    restore_mode: ALWAYS_ON

output:
  - platform: template
    id: backlight_brightness
    type: float
    write_action:
      then:
        - lambda: |-
            id(disp1).set_brightness(state*255);

font:
  - file: "gfonts://Roboto"
    id: font_ui
    size: 36

Nota tecnica: Usiamo update_interval: 33ms per garantire un refresh a 30Hz, fondamentale per l'effetto "film".

A questo punto annotatevi l'indirizzo IP che il vostro router assegna al Waveshare ed eventualmente prenotatelo (ip reservation) nel DHCP.

Se volete osservare il risultato della programmazione potete aprire un browser (chrome o quello che preferite) e digitare l'indirizzo ip del Waveshare nella barra degli indirizzi. Dovreste accedere alla pagine web del dispositivo dove c'è un selettore che vi permette di impostare manualmente i vari stati (e grafiche) del display.

Passo 2: Il "Cervello" (Configurazione ReSpeaker)

Qui le cose si fanno interessanti. Dobbiamo dire al ReSpeaker di inviare un comando HTTP al Waveshare ogni volta che cambia stato.

Il problema della sincronizzazione: Inizialmente usavamo i trigger del voice_assistant (on_tts_start, on_tts_end). Risultato? Un disastro. L'animazione "parlato" finiva prima che l'audio smettesse di uscire dalla cassa, oppure lampeggiava verde quando il sistema faceva un semplice "BIP" di wake-word.

La Soluzione: Monitorare direttamente lo stato fisico del media_player e sincronizzarlo con la fase dell'assistente vocale.

Ecco il blocco integrale per il ReSpeaker lite  compreso il media_player "blindato" da inserire nel YAML del ReSpeaker:

Ricordatevi di personalizzare ssid e password della vostra rete WiFi e di intervenire sugli url http modificando l'indirizzo ip in base a quello che ha il Waveshare nella vostra rete.

substitutions:
  # Phases of the Voice Assistant
  # The voice assistant is ready to be triggered by a wake word
  voice_assist_idle_phase_id: '1'
  # The voice assistant is waiting for a voice command (after being triggered by the wake word)
  voice_assist_waiting_for_command_phase_id: '2'
  # The voice assistant is listening for a voice command
  voice_assist_listening_for_command_phase_id: '3'
  # The voice assistant is currently processing the command
  voice_assist_thinking_phase_id: '4'
  # The voice assistant is replying to the command
  voice_assist_replying_phase_id: '5'
  # The voice assistant is not ready
  voice_assist_not_ready_phase_id: '10'
  # The voice assistant encountered an error
  voice_assist_error_phase_id: '11'
  # Change this to true in case you ahve a hidden SSID at home.
  hidden_ssid: "false"
  # Substitutions for audio files
  mute_switch_on_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/mute_switch_on.flac
  mute_switch_off_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/mute_switch_off.flac
  timer_finished_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/timer_finished.flac
  wake_word_triggered_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/wake_word_triggered.flac
  center_button_press_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/center_button_press.flac
  center_button_double_press_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/center_button_double_press.flac
  center_button_triple_press_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/center_button_triple_press.flac
  center_button_long_press_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/center_button_long_press.flac

esphome:
  name: resp_new
  friendly_name: resp_new
  project:
    name: formatbce.Respeaker Lite Satellite
    version: 2025.10.0
  min_version: 2025.9.3
  on_boot:
    - priority: 375
      then:
        - sensor.template.publish:
            id: next_timer
            state: -1
        # Run the script to refresh the LED status
        - script.execute: control_leds
        # If after 10 minutes, the device is still initializing (It did not yet connect to Home Assistant), turn off the init_in_progress variable and run the script to refresh the LED status
        - delay: 10min
        - if:
            condition:
              lambda: return id(init_in_progress);
            then:
              - lambda: id(init_in_progress) = false;
              - script.execute: control_leds
    - priority: -100
      then:
        - lambda: |-
            id(alarm_time).publish_state(id(saved_alarm_time));
        - lambda: |-
            auto call = id(alarm_action).make_call();
            call.set_option(id(saved_alarm_action));
            call.perform();
        - lambda: |-
            setenv("TZ", id(saved_time_zone).c_str(), 1);
            tzset();
  on_shutdown:
    then:
      # Prevent loud noise on software restart
      - lambda: id(respeaker).mute_speaker();

esp32:
  board: esp32-s3-devkitc-1
  cpu_frequency: 240MHz
  variant: esp32s3
  flash_size: 8MB
  framework:
    type: esp-idf
    version: recommended
    sdkconfig_options:
      CONFIG_ESP32S3_DATA_CACHE_64KB: "y"
      CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y"
      CONFIG_ESP32S3_INSTRUCTION_CACHE_32KB: "y"

      # Moves instructions and read only data from flash into PSRAM on boot.
      # Both enabled allows instructions to execute while a flash operation is in progress without needing to be placed in IRAM.
      # Considerably speeds up mWW at the cost of using more PSRAM.
      CONFIG_SPIRAM_RODATA: "y"
      CONFIG_SPIRAM_FETCH_INSTRUCTIONS: "y"

      CONFIG_BT_ALLOCATION_FROM_SPIRAM_FIRST: "y"
      CONFIG_BT_BLE_DYNAMIC_ENV_MEMORY: "y"

      CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC: "y"
      CONFIG_MBEDTLS_SSL_PROTO_TLS1_3: "y"  # TLS1.3 support isn't enabled by default in IDF 5.1.5

http_request:
  useragent: esphome/jarvis-brain
  timeout: 3s

wifi:
  id: wifi_id
  fast_connect: ${hidden_ssid}
  on_connect:
    - lambda: id(improv_ble_in_progress) = false;
    - script.execute: control_leds
  on_disconnect:
    - script.execute: control_leds
  ssid: "tuossid" #personalizza con il nome del tuo WiFi
  password: "tuapasswordwifi" #personalizza con la password del tuo WiFi

network:
  enable_ipv6: true

logger:
  level: debug
  initial_level: debug
  logs:
    sensor: WARN  # avoids logging debug sensor updates
#  hardware_uart: uart0  #Uncomment to see serial logs via USB connection. Comment out after debufgging - this line introduces noise on speaker...

api:
  id: api_id
  actions:
    - action: start_va
      then:
        - voice_assistant.start
    - action: stop_va
      then:
        - voice_assistant.stop
    - action: set_alarm_time
      variables:
        alarm_time_hh_mm: string
      then:
        - lambda: |-
            if (alarm_time_hh_mm.length() == 5 &&
              isdigit(alarm_time_hh_mm[0]) && isdigit(alarm_time_hh_mm[1]) &&
              isdigit(alarm_time_hh_mm[3]) && isdigit(alarm_time_hh_mm[4])) {
                id(alarm_time).publish_state(alarm_time_hh_mm);
                id(saved_alarm_time) = alarm_time_hh_mm;
              }
    - action: set_time_zone
      variables:
        posix_time_zone: string
      then:
        - lambda: |-
            setenv("TZ", posix_time_zone.c_str(), 1);
            tzset();
            id(saved_time_zone) = posix_time_zone;
            id(publish_current_time).execute();

  on_client_connected:
    - script.execute: control_leds
  on_client_disconnected:
    - script.execute: control_leds
  encryption:

# Uncomment this, if you have problems with text-to-speech because of Home Assistant HTTPS internal URL
# http_request:
#   verify_ssl: false

#ota:
#  - platform: esphome
#    id: ota_esphome
#    password: !secret ota_password

i2c:
  - id: internal_i2c
    sda: GPIO5
    scl: GPIO6
    frequency: 400kHz

psram:
  mode: octal
  speed: 80MHz

globals:
  - id: init_in_progress
    type: bool
    restore_value: no
    initial_value: 'true'
  # Global variable storing the state of ImprovBLE. Used to draw different LED animations
  - id: improv_ble_in_progress
    type: bool
    restore_value: no
    initial_value: 'false'
  # Global variable tracking the phase of the voice assistant (defined above). Initialized to not_ready
  - id: voice_assistant_phase
    type: int
    restore_value: no
    initial_value: ${voice_assist_not_ready_phase_id}
  - id: saved_alarm_time
    type: std::string
    restore_value: yes
    initial_value: '"Unknown"'
  - id: saved_time_zone
    type: std::string
    restore_value: yes
    initial_value: '"UTC0"'
  - id: saved_alarm_action
    type: std::string
    restore_value: yes
    initial_value: '"Play sound"'
  # Global variable storing the first active timer
  - id: first_active_timer
    type: voice_assistant::Timer
    restore_value: no
  # Global variable storing if a timer is active
  - id: is_timer_active
    type: bool
    restore_value: no
  # Global variable storing if a factory reset was requested. If it is set to true, the device will factory reset once the center button is released
  - id: factory_reset_requested
    type: bool
    restore_value: no
    initial_value: 'false'

# Time sync from Home Assistant
time:
  - platform: homeassistant
    id: homeassistant_time
    on_time:
      # Every 1 minute
      - seconds: 0
        minutes: /1
        then:
          - script.execute: check_alarm
    on_time_sync:
      - script.execute: publish_current_time


switch:
  # Hardware speaker mute
  - platform: template
    id: speaker_mute_switch
    name: Speaker mute
    icon: mdi:volume-mute
    internal: true
    optimistic: true
    turn_on_action:
      - lambda: id(respeaker).mute_speaker();
    turn_off_action:
      - lambda: id(respeaker).unmute_speaker();
  # stateless momentary mic mute switch
  - platform: gpio
    internal: true
    pin:
      number: GPIO4 # D3
      inverted: true
    id: mute_toggle
    on_turn_on:
      - delay: 300ms
      - switch.turn_off: mute_toggle
  # stateful user facing mic mute switch
  - platform: template
    id: mic_mute_switch
    name: Mic mute
    icon: mdi:microphone-off
    optimistic: false
    lambda: |-
      if (id(mute_state).state) {
        return true;
      } else {
        return false;
      }
    on_turn_on:
      - if:
          condition:
            and:
              - lambda: return !id(init_in_progress);
              - switch.is_on: mute_sound
          then:
            - script.execute:
                id: play_sound
                priority: false
                sound_file: !lambda return id(mute_switch_on_sound);
    on_turn_off:
      - if:
          condition:
            and:
              - lambda: return !id(init_in_progress);
              - switch.is_on: mute_sound
          then:
            - script.execute:
                id: play_sound
                priority: false
                sound_file: !lambda return id(mute_switch_off_sound);
    turn_on_action:
      - if:
          condition:
            lambda: return !id(mute_state).state;
          then:
            - switch.turn_on: mute_toggle
    turn_off_action:
      - if:
          condition:
            lambda: return id(mute_state).state;
          then:
            - switch.turn_on: mute_toggle
  # Button click Sounds Switch.
  - platform: template
    id: button_sound
    name: Button click sounds
    icon: "mdi:bullhorn"
    entity_category: config
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON
  # Mute Sound Switch.
  - platform: template
    id: mute_sound
    name: Mute/unmute sound
    icon: "mdi:bullhorn"
    entity_category: config
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON
  # Wake Word Sound Switch.
  - platform: template
    id: wake_sound
    name: Wake sound
    icon: "mdi:bullhorn"
    entity_category: config
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON
  # Internal switch to track when a timer is ringing on the device.
  - platform: template
    id: timer_ringing
    optimistic: true
    internal: true
    restore_mode: ALWAYS_OFF
    on_turn_off:
      # Disable stop wake word
      - micro_wake_word.disable_model: stop
      - script.execute: disable_repeat
      # Stop any current annoucement (ie: stop the timer ring mid playback)
      - if:
          condition:
            media_player.is_announcing:
          then:
            media_player.stop:
              announcement: true
      # Set back ducking ratio to zero
      - mixer_speaker.apply_ducking:
          id: media_mixing_input
          decibel_reduction: 0
          duration: 1.0s
      # Refresh the LED ring
      - script.execute: control_leds
    on_turn_on:
      # Duck audio
      - mixer_speaker.apply_ducking:
          id: media_mixing_input
          decibel_reduction: 20
          duration: 0.0s
      # Enable stop wake word
      - micro_wake_word.enable_model: stop
      # Ring timer
      - script.execute: ring_timer
      # Refresh LED
      - script.execute: control_leds
      # If 15 minutes have passed and the timer is still ringing, stop it.
      - delay: 15min
      - switch.turn_off: timer_ringing
  # Defines if alarm is active
  - platform: template
    optimistic: true
    restore_mode: RESTORE_DEFAULT_OFF
    id: alarm_on
    icon: mdi:bell-badge
    name: "Alarm on"
    on_turn_on:
      - script.execute: control_leds
    on_turn_off:
      - script.execute: control_leds

binary_sensor:
  # User Button. Used for many things (See on_multi_click)
  - platform: gpio
    id: user_button
    pin:
      number: GPIO3 # D2
      inverted: true
    name: "User button"
    on_press:
      - script.execute: control_leds
    on_release:
      - script.execute: control_leds
      # If a factory reset is requested, factory reset on release
      - if:
          condition:
            lambda: return id(factory_reset_requested);
          then:
            - button.press: factory_reset_button
    on_multi_click:
      # Simple Click:
      #   - Abort "things" in order
      #     - Timer
      #     - Announcements
      #     - Voice Assistant Pipeline run
      #     - Music
      #   - Starts the voice assistant if it is not yet running and if the device is not muted.
      - timing:
          - ON for at most 1s
          - OFF for at least 0.25s
        then:
          - if:
              condition:
                lambda: return !id(init_in_progress);
              then:
                - if:
                    condition:
                      switch.is_on: timer_ringing
                    then:
                      - switch.turn_off: timer_ringing
                    else:
                      - if:
                          condition:
                            voice_assistant.is_running:
                          then:
                            - voice_assistant.stop:
                          else:
                            - if:
                                condition:
                                  media_player.is_announcing:
                                then:
                                  media_player.stop:
                                    announcement: true
                                else:
                                  - if:
                                      condition:
                                        media_player.is_playing:
                                      then:
                                        - media_player.pause:
                                      else:
                                        - if:
                                            condition:
                                              and:
                                                - switch.is_off: mic_mute_switch
                                                - not:
                                                    voice_assistant.is_running
                                            then:
                                              - if:
                                                  condition:
                                                    switch.is_on: button_sound
                                                  then:
                                                    - script.execute:
                                                        id: play_sound
                                                        priority: true
                                                        sound_file: !lambda return id(center_button_press_sound);
                                                    - delay: 300ms
                                              - voice_assistant.start:
      # Double Click
      #  . Exposed as an event entity. To be used in automations inside Home Assistant
      - timing:
          - ON for at most 1s
          - OFF for at most 0.25s
          - ON for at most 1s
          - OFF for at least 0.25s
        then:
          - if:
              condition:
                lambda: return !id(init_in_progress);
              then:
                - if:
                    condition:
                      switch.is_on: button_sound
                    then:
                      - script.execute:
                          id: play_sound
                          priority: false
                          sound_file: !lambda return id(center_button_double_press_sound);
                - event.trigger:
                    id: button_press_event
                    event_type: "double_press"
      # Triple Click
      #  . Exposed as an event entity. To be used in automations inside Home Assistant
      - timing:
          - ON for at most 1s
          - OFF for at most 0.25s
          - ON for at most 1s
          - OFF for at most 0.25s
          - ON for at most 1s
          - OFF for at least 0.25s
        then:
          - if:
              condition:
                lambda: return !id(init_in_progress);
              then:
                - if:
                    condition:
                      switch.is_on: button_sound
                    then:
                      - script.execute:
                          id: play_sound
                          priority: false
                          sound_file: !lambda return id(center_button_triple_press_sound);
                - event.trigger:
                    id: button_press_event
                    event_type: "triple_press"
      # Long Press
      #  . Exposed as an event entity. To be used in automations inside Home Assistant
      - timing:
          - ON for at least 1s
        then:
          - if:
              condition:
                lambda: return !id(init_in_progress);
              then:
                - if:
                    condition:
                      switch.is_on: button_sound
                    then:
                      - script.execute:
                          id: play_sound
                          priority: false
                          sound_file: !lambda return id(center_button_long_press_sound);
                - light.turn_off: led_internal
                - event.trigger:
                    id: button_press_event
                    event_type: "long_press"
      # Factory Reset Warning
      #  . Audible and Visible warning.
      - timing:
          - ON for at least 10s
        then:
          - light.turn_on:
              brightness: 100%
              id: led_internal
              effect: "Factory Reset Coming Up"
          - script.execute:
              id: play_sound
              priority: true
              sound_file: !lambda return id(factory_reset_initiated_sound);
          - wait_until:
              binary_sensor.is_off: user_button
          - if:
              condition:
                lambda: return !id(factory_reset_requested);
              then:
                - light.turn_off: led_internal
                - script.execute:
                    id: play_sound
                    priority: true
                    sound_file: !lambda return id(factory_reset_cancelled_sound);
      # Factory Reset Confirmed.
      #  . Audible warning to prompt user to release the button
      #  . Set factory_reset_requested to true
      - timing:
          - ON for at least 22s
        then:
          - script.execute:
              id: play_sound
              priority: true
              sound_file: !lambda return id(factory_reset_confirmed_sound);
          - light.turn_on:
              brightness: 100%
              red: 100%
              green: 0%
              blue: 0%
              id: led_internal
              effect: "none"
          - lambda: id(factory_reset_requested) = true;

light:
  - platform: esp32_rmt_led_strip
    id: led_internal
    internal: true
    rgb_order: GRB
    pin: GPIO1
    num_leds: 1
    rmt_symbols: 192
    chipset: ws2812
    default_transition_length: 0s
    effects:
      - addressable_lambda:
          name: "Fast Pulse"
          update_interval: 10ms
          lambda: |-
            static float fraction = 0.0;
            static float step = 0.05;
            static bool increasing = true;

            auto values = id(led_internal)->current_values;
            Color color(values.get_red() * 255, values.get_green() * 255, values.get_blue() * 255);
            it[0].set_rgb(color.red * fraction,
                             color.green * fraction,
                             color.blue * fraction);

            fraction += (step * (increasing ? 1 : -1));
            if (fraction > 1.0) {
              fraction = 1.0;
              increasing = !increasing;
            } else if (fraction < 0.0) {
              fraction = 0.0;
              increasing = !increasing;
            }

      - addressable_lambda:
          name: "Slow Pulse"
          update_interval: 30ms
          lambda: |-
            static float fraction = 0.0;
            static float step = 0.05;
            static bool increasing = true;

            auto values = id(led_internal)->current_values;
            Color color(values.get_red() * 255, values.get_green() * 255, values.get_blue() * 255);
            it[0].set_rgb(color.red * fraction,
                             color.green * fraction,
                             color.blue * fraction);

            fraction += (step * (increasing ? 1 : -1));
            if (fraction > 1.0) {
              fraction = 1.0;
              increasing = !increasing;
            } else if (fraction < 0.0) {
              fraction = 0.0;
              increasing = !increasing;
            }
  # User facing LED.
  # Exposed to be used by the user.
  - platform: partition
    id: led_respeaker_onboard
    name: LED Respeaker onboard
    entity_category: config
    icon: "mdi:circle-outline"
    default_transition_length: 0ms
    restore_mode: RESTORE_DEFAULT_OFF
    on_turn_off:
      - script.execute: control_leds
    initial_state:
      color_mode: rgb
      brightness: 100%
      red: 9.4%
      green: 73.3%
      blue: 94.9%
    segments:
      - id: led_internal
        from: 0
        to: 0
    effects:
      - addressable_lambda:
          name: "Fast Pulse"
          update_interval: 10ms
          lambda: |-
            static float fraction = 0.0;
            static float step = 0.05;
            static bool increasing = true;

            auto values = id(led_respeaker_onboard)->current_values;
            Color color(values.get_red() * 255, values.get_green() * 255, values.get_blue() * 255);
            it[0].set_rgb(color.red * fraction,
                             color.green * fraction,
                             color.blue * fraction);

            fraction += (step * (increasing ? 1 : -1));
            if (fraction > 1.0) {
              fraction = 1.0;
              increasing = !increasing;
            } else if (fraction < 0.0) {
              fraction = 0.0;
              increasing = !increasing;
            }

      - addressable_lambda:
          name: "Slow Pulse"
          update_interval: 30ms
          lambda: |-
            static float fraction = 0.0;
            static float step = 0.05;
            static bool increasing = true;

            auto values = id(led_respeaker_onboard)->current_values;
            Color color(values.get_red() * 255, values.get_green() * 255, values.get_blue() * 255);
            it[0].set_rgb(color.red * fraction,
                             color.green * fraction,
                             color.blue * fraction);

            fraction += (step * (increasing ? 1 : -1));
            if (fraction > 1.0) {
              fraction = 1.0;
              increasing = !increasing;
            } else if (fraction < 0.0) {
              fraction = 0.0;
              increasing = !increasing;
            }

sensor:
  - platform: template
    id: next_timer
    name: "Next timer"
    update_interval: never
    disabled_by_default: true
    device_class: duration
    unit_of_measurement: s
    icon: "mdi:timer"

text_sensor:
  - platform: template
    id: next_timer_name
    name: "Next timer name"
    icon: "mdi:timer"
    disabled_by_default: true
  - platform: template
    name: "Alarm time"
    id: alarm_time
    icon: mdi:bell-ring
  - platform: template
    name: "Current device time"
    id: current_time
    icon: mdi:clock

  - id: text_request
    platform: template
    name: "Text Request"
    on_value:
      lambda: |-
        if(id(text_request).state.length()>245) {
          std::string name = id(text_request).state.c_str();
          std::string truncated = esphome::str_truncate(name.c_str(),245);
          id(text_request).state = (truncated+" ...").c_str();
        }

  - id: text_response
    platform: template
    name: "Text Response"
    on_value:
      lambda: |-
        if(id(text_response).state.length()>245) {
          std::string name = id(text_response).state.c_str();
          std::string truncated = esphome::str_truncate(name.c_str(),245);
          id(text_response).state = (truncated+" ...").c_str();
        }
  - platform: template
    id: voice_assistant_phase_sensor
    name: "Voice Assistant Phase ID"
    update_interval: never

event:
  # Event entity exposed to the user to automate on complex center button presses.
  # The simple press is not exposed as it is used to control the device itself.
  - platform: template
    id: button_press_event
    name: "Button press"
    icon: mdi:button-pointer
    device_class: button
    event_types:
      - double_press
      - triple_press
      - long_press

script:
  # Master script controlling the LEDs, based on different conditions : initialization in progress, wifi and api connected and voice assistant phase.
  # For the sake of simplicity and re-usability, the script calls child scripts defined below.
  # This script will be called every time one of these conditions is changing.
  - id: control_leds
    then:
      - lambda: |
          id(check_if_timers_active).execute();
          if (id(is_timer_active)){
            id(fetch_first_active_timer).execute();
          }
          if (id(improv_ble_in_progress)) {
            id(control_leds_improv_ble_state).execute();
          } else if (id(init_in_progress)) {
            id(control_leds_init_state).execute();
          } else if (!id(wifi_id).is_connected() || !id(api_id).is_connected()){
            id(control_leds_no_ha_connection_state).execute();
          } else if (id(user_button).state) {
            id(control_leds_center_button_touched).execute();
          } else if (id(timer_ringing).state) {
            id(control_leds_timer_ringing).execute();
          } else if (id(voice_assistant_phase) == ${voice_assist_waiting_for_command_phase_id}) {
            id(control_leds_voice_assistant_waiting_for_command_phase).execute();
          } else if (id(voice_assistant_phase) == ${voice_assist_listening_for_command_phase_id}) {
            id(control_leds_voice_assistant_listening_for_command_phase).execute();
          } else if (id(voice_assistant_phase) == ${voice_assist_thinking_phase_id}) {
            id(control_leds_voice_assistant_thinking_phase).execute();
          } else if (id(voice_assistant_phase) == ${voice_assist_replying_phase_id}) {
            id(control_leds_voice_assistant_replying_phase).execute();
          } else if (id(voice_assistant_phase) == ${voice_assist_error_phase_id}) {
            id(control_leds_voice_assistant_error_phase).execute();
          } else if (id(voice_assistant_phase) == ${voice_assist_not_ready_phase_id}) {
            id(control_leds_voice_assistant_not_ready_phase).execute();
          } else if (id(is_timer_active)) {
            id(control_leds_timer_ticking).execute();
          } else if (id(alarm_on).state && !id(led_respeaker_onboard).remote_values.is_on()) {
            id(control_leds_alarm_active).execute();
          } else if (id(voice_assistant_phase) == ${voice_assist_idle_phase_id}) {
            id(control_leds_voice_assistant_idle_phase).execute();
          }

  # Script executed during Improv BLE
  # Warm White slow pulse
  - id: control_leds_improv_ble_state
    then:
      - light.turn_on:
          brightness: !lambda return max( id(led_respeaker_onboard).current_values.get_brightness() , 0.2f );
          red: 100%
          green: 89%
          blue: 71%
          id: led_internal
          effect: "Slow Pulse"

  # Script executed during initialization
  # Fast Blue pulse if Wifi is connected, Else slow blue pulse
  - id: control_leds_init_state
    then:
      - if:
          condition:
            wifi.connected:
          then:
            - light.turn_on:
                brightness: !lambda return max( id(led_respeaker_onboard).current_values.get_brightness() , 0.2f );
                red: 9%
                green: 73%
                blue: 95%
                id: led_internal
                effect: "Fast Pulse"
          else:
            - light.turn_on:
                brightness: !lambda return max( id(led_respeaker_onboard).current_values.get_brightness() , 0.2f );
                red: 9%
                green: 73%
                blue: 95%
                id: led_internal
                effect: "Slow Pulse"
  # Script executed when the device has no connection to Home Assistant
  # Red slow pulse (This will be visible during HA updates for example)
  - id: control_leds_no_ha_connection_state
    then:
      - light.turn_on:
          brightness: !lambda return max( id(led_respeaker_onboard).current_values.get_brightness() , 0.2f );
          red: 1
          green: 0
          blue: 0
          id: led_internal
          effect: "Slow Pulse"

  # Script executed when the voice assistant is idle (waiting for a wake word)
  # Nothing
  - id: control_leds_voice_assistant_idle_phase
    then:
      - light.turn_off: led_internal
      - if:
          condition:
            light.is_on: led_respeaker_onboard
          then:
            light.turn_on: led_respeaker_onboard

  # Script executed when the voice assistant is waiting for a command (After the wake word)
  # Slow purple pulse
  - id: control_leds_voice_assistant_waiting_for_command_phase
    then:
      - light.turn_on:
          brightness: !lambda return max( id(led_respeaker_onboard).current_values.get_brightness() , 0.2f );
          red: 1
          green: 0.2
          blue: 1
          id: led_internal
          effect: "Slow Pulse"

  # Script executed when the voice assistant is listening to a command
  # Slow purple pulse
  - id: control_leds_voice_assistant_listening_for_command_phase
    then:
      - light.turn_on:
          brightness: !lambda return max( id(led_respeaker_onboard).current_values.get_brightness() , 0.2f );
          red: 1
          green: 0.2
          blue: 1
          id: led_internal
          effect: "Slow Pulse"

  # Script executed when the voice assistant is thinking to a command
  # Fast purple pulse
  - id: control_leds_voice_assistant_thinking_phase
    then:
      - light.turn_on:
          brightness: !lambda return max( id(led_respeaker_onboard).current_values.get_brightness() , 0.2f );
          red: 1
          green: 0.2
          blue: 1
          id: led_internal
          effect: "Fast Pulse"

  # Script executed when the voice assistant is replying to a command
  # Slow cyan pulse
  - id: control_leds_voice_assistant_replying_phase
    then:
      - light.turn_on:
          brightness: !lambda return max( id(led_respeaker_onboard).current_values.get_brightness() , 0.2f );
          red: 0.2
          green: 1
          blue: 1
          id: led_internal
          effect: "Slow Pulse"

  # Script executed when the voice assistant is in error
  # Fast Red Pulse
  - id: control_leds_voice_assistant_error_phase
    then:
      - light.turn_on:
          brightness: !lambda return max( id(led_respeaker_onboard).current_values.get_brightness() , 0.2f );
          red: 1
          green: 0
          blue: 0
          id: led_internal
          effect: "Fast Pulse"

  # Script executed when the voice assistant is not ready
  - id: control_leds_voice_assistant_not_ready_phase
    then:
      - light.turn_on:
          brightness: !lambda return max( id(led_respeaker_onboard).current_values.get_brightness() , 0.2f );
          red: 1
          green: 0
          blue: 0
          id: led_internal
          effect: "Slow Pulse"

  # Script executed when the center button is touched
  # The LED turns on blue
  - id: control_leds_center_button_touched
    then:
      - light.turn_on:
          brightness: !lambda return min ( max( id(led_respeaker_onboard).current_values.get_brightness() , 0.2f ) + 0.1f , 1.0f );
          red: 0
          green: 0
          blue: 1
          id: led_internal
          effect: "None"

  # Script executed when the timer is ringing, to control the LEDs
  # The LED blinks green.
  - id: control_leds_timer_ringing
    then:
      - light.turn_on:
          brightness: !lambda return min ( max( id(led_respeaker_onboard).current_values.get_brightness() , 0.2f ) + 0.1f , 1.0f );
          red: 0
          green: 1
          blue: 0
          id: led_internal
          effect: "Fast Pulse"

  # Script executed when the timer is ticking, to control the LEDs
  # Slow dim while pulse.
  - id: control_leds_timer_ticking
    then:
      - light.turn_on:
          brightness: !lambda return max( id(led_respeaker_onboard).current_values.get_brightness() , 0.2f );
          red: 0.3
          green: 0.3
          blue: 0.3
          id: led_internal
          effect: "Slow Pulse"

  # Script executed when the alarm is active
  # The LED turns on dim green
  - id: control_leds_alarm_active
    then:
      - light.turn_on:
          brightness: !lambda return 0.3f;
          red: 0
          green: 1
          blue: 0
          id: led_internal
          effect: "None"


  # Script executed when the timer is ringing, to playback sounds.
  - id: ring_timer
    then:
      - script.execute: enable_repeat_one
      - script.execute:
          id: play_sound
          priority: true
          sound_file: !lambda return id(timer_finished_sound);

  # Script executed when the timer is ringing, to repeat the timer finished sound.
  - id: enable_repeat_one
    then:
      # Turn on the repeat mode and pause for 500 ms between playlist items/repeats
      - lambda: |-
            id(external_media_player)
              ->make_call()
              .set_command(media_player::MediaPlayerCommand::MEDIA_PLAYER_COMMAND_REPEAT_ONE)
              .set_announcement(true)
              .perform();
            id(external_media_player)->set_playlist_delay_ms(speaker::AudioPipelineType::ANNOUNCEMENT, 500);

  # Script execute when the timer is done ringing, to disable repeat mode.
  - id: disable_repeat
    then:
      # Turn off the repeat mode and pause for 0 ms between playlist items/repeats
      - lambda: |-
            id(external_media_player)
              ->make_call()
              .set_command(media_player::MediaPlayerCommand::MEDIA_PLAYER_COMMAND_REPEAT_OFF)
              .set_announcement(true)
              .perform();
            id(external_media_player)->set_playlist_delay_ms(speaker::AudioPipelineType::ANNOUNCEMENT, 0);

  # Script executed when we want to play sounds on the device.
  - id: play_sound
    parameters:
      priority: bool
      sound_file: "audio::AudioFile*"
    then:
      - lambda: |-
          if (priority) {
            id(external_media_player)
              ->make_call()
              .set_command(media_player::MediaPlayerCommand::MEDIA_PLAYER_COMMAND_STOP)
              .set_announcement(true)
              .perform();
          }
          if ( (id(external_media_player).state != media_player::MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING ) || priority) {
            id(external_media_player)
              ->play_file(sound_file, true, false);
          }

  # Script used to fetch the first active timer (Stored in global first_active_timer)
  - id: fetch_first_active_timer
    then:
      - lambda: |
          const auto timers = id(va).get_timers();
          auto output_timer = timers.begin()->second;
          for (auto &iterable_timer : timers) {
            if (iterable_timer.second.is_active && iterable_timer.second.seconds_left <= output_timer.seconds_left) {
              output_timer = iterable_timer.second;
            }
          }
          id(first_active_timer) = output_timer;

  # Script used to check if a timer is active (Stored in global is_timer_active)
  - id: check_if_timers_active
    then:
      - lambda: |
          const auto timers = id(va).get_timers();
          bool output = false;
          if (timers.size() > 0) {
            for (auto &iterable_timer : timers) {
              if(iterable_timer.second.is_active) {
                output = true;
              }
            }
          }
          id(is_timer_active) = output;

  # Script used activate the stop word if the TTS step is long.
  # Why is this wrapped on a script?
  #   Becasue we want to stop the sequence if the TTS step is faster than that.
  #   This allows us to prevent having the deactivation of the stop word before its own activation.
  - id: activate_stop_word_once
    then:
      - delay: 1s
      # Enable stop wake word
      - if:
          condition:
            switch.is_off: timer_ringing
          then:
            - micro_wake_word.enable_model: stop
            - wait_until:
                not:
                  media_player.is_announcing:
            - if:
                condition:
                  switch.is_off: timer_ringing
                then:
                  - micro_wake_word.disable_model: stop

  - id: check_alarm
    then:
      - lambda: |-
          id(publish_current_time).execute();
          // Check alarm
          if (id(alarm_on).state && id(alarm_time).has_state()) {
            // Get the stored alarm time from the sensor
            auto set_alarm_time = id(alarm_time).state;
            if (set_alarm_time.length() == 5 &&
              isdigit(set_alarm_time[0]) && isdigit(set_alarm_time[1]) &&
              isdigit(set_alarm_time[3]) && isdigit(set_alarm_time[4])) {
              auto alarm_hour = std::stoi(set_alarm_time.substr(0, 2));
              auto alarm_minute = std::stoi(set_alarm_time.substr(3, 2));

              // Trigger action if current time matches alarm time
              auto time_now = id(homeassistant_time).now();
              if (time_now.hour == alarm_hour && time_now.minute == alarm_minute) {
                auto action = id(alarm_action).state;
                if (action == "Play sound") {
                  id(timer_ringing).turn_on();
                } else if (action == "Send event") {
                  id(send_alarm_event).execute();
                } else if (action == "Sound and event") {
                  id(timer_ringing).turn_on();
                  id(send_alarm_event).execute();
                }
              }
            } else {
              ESP_LOGW("alarm", "Incorrect alarm time setting");
            }
          }
  - id: send_alarm_event
    then:
      - homeassistant.event:
          event: esphome.alarm_ringing
  - id: send_tts_uri_event
    parameters:
      tts_uri: string
    then:
      - homeassistant.event:
          event: esphome.tts_uri
          data:
            uri: !lambda return tts_uri;
  - id: send_stt_text_event
    parameters:
      stt_text: string
    then:
      - homeassistant.event:
          event: esphome.stt_text
          data:
            text: !lambda return stt_text;
  - id: publish_current_time
    then:
      - lambda: |-
          // Publish current time
          auto time_now = id(homeassistant_time).now();
          id(current_time).publish_state(time_now.strftime("%H:%M"));

  - id: set_phase
    parameters:
      phase: int
    then:
      - lambda: id(voice_assistant_phase) = phase;
      - text_sensor.template.publish:
          id: voice_assistant_phase_sensor
          state: !lambda |-
            char buf[8];
            snprintf(buf, sizeof(buf), "%d", phase);
            return std::string(buf);

  - id: handle_screen_tap
    then:
      - if:
          condition:
            lambda: return !id(init_in_progress);
          then:
            - if:
                condition:
                  switch.is_on: timer_ringing
                then:
                  - switch.turn_off: timer_ringing
                else:
                  - if:
                      condition:
                        voice_assistant.is_running:
                      then:
                        - voice_assistant.stop:
                      else:
                        - if:
                            condition:
                              media_player.is_announcing:
                            then:
                              media_player.stop:
                                announcement: true
                            else:
                              - if:
                                  condition:
                                    media_player.is_playing:
                                  then:
                                    - media_player.pause:
                                  else:
                                    - if:
                                        condition:
                                          and:
                                            - switch.is_off: mic_mute_switch
                                            - not:
                                                voice_assistant.is_running
                                        then:
                                          - if:
                                              condition:
                                                switch.is_on: button_sound
                                              then:
                                                - script.execute:
                                                    id: play_sound
                                                    priority: true
                                                    sound_file: !lambda return id(center_button_press_sound);
                                                - delay: 300ms
                                          - voice_assistant.start:

i2s_audio:
  - id: i2s_output
    i2s_lrclk_pin:
      number: GPIO7
      allow_other_uses: true
    i2s_bclk_pin:
      number: GPIO8
      allow_other_uses: true
    i2s_mclk_pin:
      number: GPIO9
      allow_other_uses: true

  - id: i2s_input
    i2s_lrclk_pin:
      number: GPIO7
      allow_other_uses: true
    i2s_bclk_pin:
      number: GPIO8
      allow_other_uses: true
    i2s_mclk_pin:
      number: GPIO9
      allow_other_uses: true

microphone:
  - platform: i2s_audio
    id: i2s_mics
    i2s_din_pin: GPIO44
    adc_type: external
    pdm: false
    sample_rate: 48000
    bits_per_sample: 32bit
    i2s_mode: secondary
    i2s_audio_id: i2s_input
    channel: stereo

speaker:
  # Hardware speaker output
  - platform: i2s_audio
    id: i2s_audio_speaker
    sample_rate: 48000
    i2s_mode: secondary
    i2s_dout_pin: GPIO43
    bits_per_sample: 32bit
    i2s_audio_id: i2s_output
    dac_type: external
    channel: stereo
    timeout: never
    buffer_duration: 100ms
    audio_dac: aic3204_dac

  # Virtual speakers to combine the announcement and media streams together into one output
  - platform: mixer
    id: mixing_speaker
    output_speaker: i2s_audio_speaker
    num_channels: 2
    source_speakers:
      - id: announcement_mixing_input
        timeout: never
      - id: media_mixing_input
        timeout: never

  # Vritual speakers to resample each pipelines' audio, if necessary, as the mixer speaker requires the same sample rate
  - platform: resampler
    id: announcement_resampling_speaker
    output_speaker: announcement_mixing_input
    sample_rate: 48000
    bits_per_sample: 16
  - platform: resampler
    id: media_resampling_speaker
    output_speaker: media_mixing_input
    sample_rate: 48000
    bits_per_sample: 16

media_player:
  - platform: speaker
    id: external_media_player
    name: None
    internal: False
    volume_increment: 0.05
    volume_min: 0.4
    volume_max: 0.85
    announcement_pipeline:
      speaker: announcement_resampling_speaker
      format: FLAC
      num_channels: 1
      sample_rate: 48000
    media_pipeline:
      speaker: media_resampling_speaker
      format: FLAC
      num_channels: 2
      sample_rate: 48000
    on_announcement:
      - mixer_speaker.apply_ducking:
          id: media_mixing_input
          decibel_reduction: 20
          duration: 0.0s

    on_state:
      # 1. Ducking audio (Originale)
      - if:
          condition:
            and:
              - switch.is_off: timer_ringing
              - not:
                  voice_assistant.is_running:
              - not:
                  media_player.is_announcing:
          then:
            - mixer_speaker.apply_ducking:
                id: media_mixing_input
                decibel_reduction: 0
                duration: 1.0s

      # 2. INIZIO PARLATO (Verde) - CON FILTRO
      - if:
          condition:
            and:
              - media_player.is_announcing:
              # TRUCCO: Controlla se siamo davvero nella fase di risposta!
              - lambda: |-
                  return id(voice_assistant_phase) == ${voice_assist_replying_phase_id};
          then:
            - http_request.post: "http://192.168.1.99/number/jstate/set?value=3"

      # 3. FINE PARLATO (Blu) - CON FILTRO
      - if:
          condition:
            and:
              - not:
                  media_player.is_announcing: # <--- CORRETTO QUI (A capo)
              # TRUCCO: Torna blu solo se eravamo in fase di risposta
              - lambda: |-
                  return id(voice_assistant_phase) == ${voice_assist_replying_phase_id};
          then:
            - http_request.post: "http://192.168.1.99/number/jstate/set?value=0"

    files:
      - id: center_button_press_sound
        file: ${center_button_press_sound_file}
      - id: center_button_double_press_sound
        file: ${center_button_double_press_sound_file}
      - id: center_button_triple_press_sound
        file: ${center_button_triple_press_sound_file}
      - id: center_button_long_press_sound
        file: ${center_button_long_press_sound_file}
      - id: factory_reset_initiated_sound
        file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/factory_reset_initiated.mp3
      - id: factory_reset_cancelled_sound
        file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/factory_reset_cancelled.mp3
      - id: factory_reset_confirmed_sound
        file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/factory_reset_confirmed.mp3
      - id: mute_switch_on_sound
        file: ${mute_switch_on_sound_file}
      - id: mute_switch_off_sound
        file: ${mute_switch_off_sound_file}
      - id: timer_finished_sound
        file: ${timer_finished_sound_file}
      - id: wake_word_triggered_sound
        file: ${wake_word_triggered_sound_file}
      - id: error_cloud_expired
        file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/error_cloud_expired.mp3

respeaker_lite:
  id: respeaker
  reset_pin: GPIO2
  mute_state:
    internal: true
    id: mute_state
  firmware_version:
    icon: mdi:application-cog
    name: XMOS firmware version
    internal: false
    id: firmware_version
  firmware:
    url: https://github.com/formatBCE/Respeaker-Lite-ESPHome-integration/raw/refs/heads/main/respeaker_lite_i2s_dfu_firmware_48k_v1.1.0.bin
    version: "1.1.0"
    md5: 9297155d1bf3eb21a9d4db52a89ea0c6
    on_begin:
      - light.turn_on:
          brightness: !lambda return max( id(led_respeaker_onboard).current_values.get_brightness() , 0.2f );
          red: 50%
          green: 50%
          blue: 50%
          id: led_internal
          effect: "Slow Pulse"
    on_end:
      - light.turn_on:
          brightness: !lambda return max( id(led_respeaker_onboard).current_values.get_brightness() , 0.2f );
          red: 0%
          green: 100%
          blue: 0%
          id: led_internal
          effect: "Fast Pulse"
      - delay: 3s
      - light.turn_off:
          id: led_internal
    on_error:
      - light.turn_on:
          brightness: !lambda return max( id(led_respeaker_onboard).current_values.get_brightness() , 0.2f );
          red: 100%
          green: 0%
          blue: 0%
          id: led_internal
          effect: "Fast Pulse"
      - delay: 3s
      - light.turn_off:
          id: led_internal

external_components:
  - source:
      type: git
      url: https://github.com/formatBCE/esphome
      ref: respeaker_microphone
    components:
      - i2s_audio
    refresh: 0s
  - source:
      type: git
      url: https://github.com/formatBCE/Respeaker-Lite-ESPHome-integration
      ref: main
    components:
      - respeaker_lite
    refresh: 0s

audio_dac:
  - platform: aic3204
    id: aic3204_dac
    i2c_id: internal_i2c

micro_wake_word:
  id: mww
  microphone:
    microphone: i2s_mics
    channels: 1
    gain_factor: 4
  stop_after_detection: false
  models:
    - model: https://github.com/kahrendt/microWakeWord/releases/download/okay_nabu_20241226.3/okay_nabu.json
      # probability_cutoff: 0.8
      id: okay_nabu
    - model: https://raw.githubusercontent.com/formatBCE/Respeaker-Lite-ESPHome-integration/refs/heads/main/microwakeword/models/v2/kenobi.json
      id: kenobi
    - model: hey_jarvis
      id: hey_jarvis
    - model: hey_mycroft
      id: hey_mycroft
    - model: https://github.com/kahrendt/microWakeWord/releases/download/stop/stop.json
      id: stop
      internal: true
  vad:
    probability_cutoff: 0.05
  on_wake_word_detected:
    # If the wake word is detected when the device is muted (Possible with the software mute switch): Do nothing
    - if:
        condition:
          switch.is_off: mic_mute_switch
        then:
          # If a timer is ringing: Stop it, do not start the voice assistant (We can stop timer from voice!)
          - if:
              condition:
                switch.is_on: timer_ringing
              then:
                - switch.turn_off: timer_ringing
              # Stop voice assistant if running
              else:
                - if:
                    condition:
                      voice_assistant.is_running:
                    then:
                      voice_assistant.stop:
                    # Stop any other media player announcement
                    else:
                      - if:
                          condition:
                            media_player.is_announcing:
                          then:
                            - media_player.stop:
                                announcement: true
                          # Start the voice assistant and play the wake sound, if enabled
                          else:
                            - if:
                                condition:
                                  switch.is_on: wake_sound
                                then:
                                  - script.execute:
                                      id: play_sound
                                      priority: true
                                      sound_file: !lambda return id(wake_word_triggered_sound);
                                  - delay: 300ms
                            - voice_assistant.start:
                                wake_word: !lambda return wake_word;

select:
  - platform: template
    name: "Wake word sensitivity"
    optimistic: true
    initial_option: Slightly sensitive
    restore_value: true
    entity_category: config
    options:
      - Slightly sensitive
      - Moderately sensitive
      - Very sensitive
    on_value:
      # Sets specific wake word probabilities computed for each particular model
      # Note probability cutoffs are set as a quantized uint8 value, each comment has the corresponding floating point cutoff
      # False Accepts per Hour values are tested against all units and channels from the Dinner Party Corpus.
      # These cutoffs apply only to the specific models included in the firmware: okay_nabu@20241226.3, hey_jarvis@v2, hey_mycroft@v2
      lambda: |-
        if (x == "Slightly sensitive") {
          id(okay_nabu).set_probability_cutoff(217);    // 0.85 -> 0.000 FAPH on DipCo (Manifest's default)
          id(hey_jarvis).set_probability_cutoff(247);   // 0.97 -> 0.563 FAPH on DipCo (Manifest's default)
          id(hey_mycroft).set_probability_cutoff(253);  // 0.99 -> 0.567 FAPH on DipCo
        } else if (x == "Moderately sensitive") {
          id(okay_nabu).set_probability_cutoff(176);    // 0.69 -> 0.376 FAPH on DipCo
          id(hey_jarvis).set_probability_cutoff(235);   // 0.92 -> 0.939 FAPH on DipCo
          id(hey_mycroft).set_probability_cutoff(242);  // 0.95 -> 1.502 FAPH on DipCo (Manifest's default)
        } else if (x == "Very sensitive") {
          id(okay_nabu).set_probability_cutoff(143);    // 0.56 -> 0.751 FAPH on DipCo
          id(hey_jarvis).set_probability_cutoff(212);   // 0.83 -> 1.502 FAPH on DipCo
          id(hey_mycroft).set_probability_cutoff(237);  // 0.93 -> 1.878 FAPH on DipCo
        }
  - platform: logger
    id: logger_select
    name: Logger Level
    disabled_by_default: true
  - platform: template
    optimistic: true
    name: "Alarm action"
    id: alarm_action
    icon: mdi:bell-plus
    options:
      - "Play sound"
      - "Send event"
      - "Sound and event"
    initial_option: "Play sound"
    on_value:
      then:
        - lambda: |-
            id(saved_alarm_action) = x;

voice_assistant:
  id: va
  microphone:
    microphone: i2s_mics
    channels: 0
  media_player: external_media_player
  micro_wake_word: mww
  use_wake_word: false
  noise_suppression_level: 0
  auto_gain: 0 dbfs
  volume_multiplier: 1

  on_client_connected:
    - if:
        condition:
          - lambda: return id(init_in_progress);
          - switch.is_on: mic_mute_switch
        then:
          - switch.turn_off: mic_mute_switch
    - lambda: id(init_in_progress) = false;
    - micro_wake_word.start:
    - script.execute:
        id: set_phase
        phase: ${voice_assist_idle_phase_id}
    - script.execute: control_leds

  on_client_disconnected:
    - voice_assistant.stop:
    - script.execute:
        id: set_phase
        phase: ${voice_assist_not_ready_phase_id}
    - script.execute: control_leds

  on_error:
    - http_request.post: "http://192.168.1.99/number/jstate/set?value=4" # RITIENE ERRORE (Rosso)
    - if:
        condition:
          and:
            - lambda: return !id(init_in_progress);
            - lambda: return code != "duplicate_wake_up_detected";
            - lambda: return code != "stt-no-text-recognized";
        then:
          - script.execute:
              id: set_phase
              phase: ${voice_assist_error_phase_id}
          - script.execute: control_leds
    - if:
        condition:
          - lambda: return code == "cloud-auth-failed";
        then:
          - script.execute:
              id: play_sound
              priority: true
              sound_file: !lambda return id(error_cloud_expired);

  on_start:
    - mixer_speaker.apply_ducking:
        id: media_mixing_input
        decibel_reduction: 20
        duration: 0.0s

  on_listening:
    - http_request.post: "http://192.168.1.99/number/jstate/set?value=1" # RITIENE ASCOLTO (Bianco/Rombo)
    - script.execute:
        id: set_phase
        phase: ${voice_assist_waiting_for_command_phase_id}
    - script.execute: control_leds
    - text_sensor.template.publish:
        id: text_request
        state: "..."
    - text_sensor.template.publish:
        id: text_response
        state: "..."

  on_stt_vad_start:
    - script.execute:
        id: set_phase
        phase: ${voice_assist_listening_for_command_phase_id}
    - script.execute: control_leds

  on_stt_vad_end:
    - http_request.post: "http://192.168.1.99/number/jstate/set?value=2" # RITIENE PENSIERO (Viola)
    - script.execute:
        id: set_phase
        phase: ${voice_assist_thinking_phase_id}
    - script.execute: control_leds

  on_intent_progress:
    - if:
        condition:
          lambda: 'return !x.empty();'
        then:
          - script.execute:
              id: set_phase
              phase: ${voice_assist_replying_phase_id}
          - script.execute: control_leds
          - script.execute: activate_stop_word_once

  on_tts_start:
    # QUI ABBIAMO TOLTO IL VALUE=3 (Gestito ora da media_player)
    - if:
        condition:
          lambda: 'return id(voice_assistant_phase) != ${voice_assist_replying_phase_id};'
        then:
          - text_sensor.template.publish:
              id: text_response
              state: !lambda return x;
          - script.execute:
              id: set_phase
              phase: ${voice_assist_replying_phase_id}
          - script.execute: control_leds
          - script.execute: activate_stop_word_once

  on_tts_end:
    # QUI ABBIAMO TOLTO IL VALUE=0 (Gestito ora da media_player)
    - script.execute:
        id: send_tts_uri_event
        tts_uri: !lambda 'return x;'

  on_stt_end:
    - script.execute:
        id: send_stt_text_event
        stt_text: !lambda 'return x;'
    - text_sensor.template.publish:
        id: text_request
        state: !lambda return x;

  on_end:
    - wait_until:
        not:
          voice_assistant.is_running:
    - mixer_speaker.apply_ducking:
        id: media_mixing_input
        decibel_reduction: 0
        duration: 1.0s
    - if:
        condition:
          lambda: return id(voice_assistant_phase) == ${voice_assist_error_phase_id};
        then:
          - delay: 1s
    - script.execute:
        id: set_phase
        phase: ${voice_assist_idle_phase_id}
    - script.execute: control_leds

  on_timer_finished:
    - switch.turn_on: timer_ringing
    - lambda: |
        id(next_timer).publish_state(-1);
        id(next_timer_name).publish_state("-");
  on_timer_started:
    - script.execute: control_leds
    - lambda: |
        id(next_timer).publish_state(id(first_active_timer).seconds_left);
        id(next_timer_name).publish_state(id(first_active_timer).name);
  on_timer_cancelled:
    - script.execute: control_leds
    - lambda: |
        id(next_timer).publish_state(id(first_active_timer).seconds_left);
        id(next_timer_name).publish_state(id(first_active_timer).name);
  on_timer_updated:
    - script.execute: control_leds
    - lambda: |
        id(next_timer).publish_state(id(first_active_timer).seconds_left);
        id(next_timer_name).publish_state(id(first_active_timer).name);
  on_timer_tick:
    - script.execute: control_leds
    - lambda: |
        int seconds_left = id(first_active_timer).seconds_left;
        if (std::abs(seconds_left) % 5 == 0) {
          id(next_timer).publish_state(seconds_left);
        }
button:
  - platform: factory_reset
    id: factory_reset_button
    name: "Factory Reset"
    entity_category: diagnostic
    internal: true
  - platform: restart
    id: restart_button
    name: "Restart"
    entity_category: config
    disabled_by_default: true
    icon: "mdi:restart"
  - platform: template
    name: "Virtual Touch"
    id: virtual_touch
    on_press:
      - script.execute: handle_screen_tap

debug:
  update_interval: 5s

Nel voice_assistant, invece, gestiamo solo gli stati di "Input":

  • on_listening -> Invia 1 (Ascolto)

  • on_stt_vad_end -> Invia 2 (Elaborazione/Pensiero)

  • on_error -> Invia 4 (Errore)

Il Risultato Finale

Dopo circa 15 minuti di compilazione (sì, l'ESP32-S3 ci mette un po' a compilare le librerie audio, approfittatene per un caffè), il risultato è magico.

  1. Dici "Hey Jarvis": Il Waveshare passa istantaneamente da una scansione blu a un occhio bianco pulsante.

  2. Chiedi "Che tempo fa?": Mentre Home Assistant elabora (e il ReSpeaker invia l'audio al server), il display mostra un anello viola che ruota vorticosamente (stato Pensiero).

  3. La Risposta: Appena la voce inizia a uscire dallo speaker, sul display appare un equalizzatore verde sincronizzato. Niente lag, niente falsi positivi.

  4. Silenzio: Tutto torna blu, pronto per il prossimo comando.

Conclusione

Abbiamo imparato che l'hardware "all-in-one" è comodo, ma dividere i compiti è professionale. Il ReSpeaker Lite offre un audio cristallino che il Waveshare si sogna, e il Waveshare offre una grafica che il ReSpeaker non può gestire. Insieme, collegati da un filo invisibile di richieste HTTP, creano l'assistente DIY definitivo.

E voi? Siete pronti a costruire il vostro reattore Arc casalingo? Fateci sapere nei commenti se avete provato questa configurazione!

Restiamo Connessi

Se volete ricevere una notifica istantanea ogni volta che pubblico una nuova guida o un aggiornamento su queste integrazioni AI (e altri interessanti contenuti), vi invito a iscrivervi al mio Canale Telegram ufficiale:

Iscriviti al Canale Telegram di Vincenzo Caputo

Guarda il video su MissingTech

Volete vedere il Waveshare in azione? Nel video dedicato sul mio canale YouTube MissingTech, vi mostrerò il test dal vivo e una prova completa con la stupenda grafica di questo display touch!

Produrre e aggiornare contenuti su vincenzocaputo.com richiede molto tempo e lavoro. Se il contenuto che hai appena letto è di tuo gradimento e vuoi supportarmi, clicca uno dei link qui sotto per fare una donazione.

Vincenzo Caputo

Nato a Matera, il 1° novembre 1977. Sono da sempre appassionato di tecnologia e ho un'esperienza lavorativa ventennale nel settore IT. Mi piace sperimentare e cercare sempre nuove soluzioni e soprattutto mi piace comunicare le mie esperienze agli altri.


Vai ai commenti