ESP32-S3 Local Voice Assistant for Home Assistant

I built an ESP32-S3 voice satellite for the office. It worked. Then I set a timer, the timer went off, and I discovered I couldn’t turn it off by voice — because the alarm was so loud the microphone couldn’t hear me over it.

Fixing that properly meant ripping out the entire audio layer and replacing it with acoustic echo cancellation. This post covers how, plus the boot loop that cost me an hour on the way there.

Parts

ESP32-S3 N16R8

WS2812E LEDs

USB-C Female power jack

Microphone

Amplifier

Speaker

Optional Speaker Cloth for front grill

3d Printed Enclosure

TL;DR

Skip to the botton for the code

The build

The starting point was Tristam’s ESP32-S3 voice assistant: an ESP32-S3-WROOM-1, an INMP441 MEMS microphone, a MAX98357A I²S amplifier driving a 45mm full-range driver, and an eight-LED WS2812 strip for status. Home Assistant Assist for the voice pipeline, Music Assistant for playback.

Two corrections before anyone follows that wiring diagram. The LED data wire in the published image goes to DOUT on the LED board. It needs to go to DIN. WS2812s are a daisy chain — data enters at DIN, passes through each LED, exits at DOUT to feed the next strip. Wired to DOUT, nothing lights up. This was flagged in the comments back in 2024 and never fixed in the image. The below is the correct wiring

The second one looks like a mistake but isn’t: the microphone’s L/R pin goes to ground as well as the GND pin. L/R isn’t a second ground, it’s channel select. Tied low the INMP441 transmits in the left timeslot, tied high it transmits in the right. Your ESPHome config has to agree with the wire. Don’t leave it floating — it’s a digital input with no defined default, and a floating pin gives you audio that works on the bench and dies in the enclosure.

The boot loop with no error message

First flash, and the serial console gave me this on repeat:

ESP-ROM:esp32s3-20210327
Build:Mar 27 2021
rst:0x8 (TG1WDT_SYS_RST),boot:0x8 (SPI_FAST_FLASH_BOOT)
Saved PC:0x4038aab4
SPIWP:0xee
mode:DIO, clock div:1
load:0x3fce2820,len:0x1510
entry 0x403c8914

ROM loader output, then nothing. No second-stage bootloader, no ESPHome banner, no panic handler, no backtrace. Just TG1WDT_SYS_RST — the Timer Group 1 watchdog — killing the chip a few hundred milliseconds in, over and over.

The complete absence of output is the diagnostic. The firmware was dying before the logger existed. That narrows it enormously: something in very early init.

It was PSRAM. The ESP32-S3-WROOM-1 ships in variants, and the two you’ll meet are N8R2 (8MB flash, 2MB quad-SPI PSRAM) and N16R8 (16MB flash, 8MB octal-SPI PSRAM). They need different config:

Module markingpsram: mode:esp32: flash_size:
N8R2quad8MB
N16R8octal16MB

I had assumed N8R2 because that’s what the commonly-linked Amazon boards ship. Mine was N16R8. The marking is printed on the shielding can — read it, don’t guess.

What made it silent rather than merely broken is this pair of sdkconfig options, which most voice assistant configs set because they speed up wake word inference considerably:

CONFIG_SPIRAM_RODATA: "y"
CONFIG_SPIRAM_FETCH_INSTRUCTIONS: "y"

Those move executable code and read-only data into PSRAM at boot. So when PSRAM init fails, the chip isn’t just short of memory — it’s trying to execute from a region that doesn’t work. Hence the hang with nothing on the wire.

If you hit this and the module marking looks right, comment those two lines out temporarily. That stops code executing from PSRAM, the boot survives long enough to reach the logger, and you get an actual error like PSRAM ID read error instead of a silent watchdog reset. They’re a performance optimisation, not a requirement.

The real problem: it can’t hear you over itself

With the thing booting, adopted into Home Assistant and playing music through Music Assistant, the actual limitation showed up.

Set a timer. Timer goes off. Alarm plays through the speaker at full volume, roughly ten centimetres from the microphone. Say “stop” — nothing. Say it louder — nothing. The only way to silence it is to open the app.

Same story with music playing. Wake word detection that’s rock solid in a quiet room becomes useless the moment the device is making noise.

Before assuming the cause, isolate the variable. I muted the speaker output while leaving everything else running — LEDs still flashing along with the (now silent) alarm — and said “stop”. Instant detection.

That single test rules out an entire category of wrong answers. The wake word model is fine. The sensitivity threshold is fine. The phase logic that’s supposed to catch a wake word during a ringing timer is fine. The microphone is fine. The problem is purely that the speaker is drowning the microphone.

Which also tells you what won’t help, and this is worth internalising because it’s where people burn a weekend. Turning up gain_factor doesn’t help. Raising wake word sensitivity doesn’t help. Both amplify the alarm and your voice by exactly the same amount. The signal-to-noise ratio is unchanged and all you’ve bought is false accepts.

Only three things change the ratio: make the interfering sound quieter, put physical distance or damping between speaker and mic, or subtract the speaker signal from the microphone input in software. That last one is acoustic echo cancellation, and it’s the only option that solves the general case.

Two cheap fixes first

Both worth trying before the rebuild, because they take minutes.

Widen the gap between alarm repeats. The timer sound loops with a configurable pause between repeats — commonly 500ms. Wake word inference needs roughly a second of unmasked audio to get a detection. Push the gap to 1500ms and you get a rhythmic alarm with real silent windows for “stop” to land in:

id(external_media_player)->set_playlist_delay_ms(
    speaker::AudioPipelineType::ANNOUNCEMENT, 1500);

This fixes the timer case specifically and does nothing for music.

Decouple the speaker from the case. If the driver is screwed hard into the enclosure, it’s exciting the whole shell, and a microphone bolted to that same shell picks it up as structure-borne vibration — not airborne sound. Stuffing between them does nothing about that; plastic doesn’t care about wadding. A foam or rubber gasket between the driver flange and its mounting face, or nylon screws with rubber washers, breaks that path for everything in the box at once.

Adding acoustic echo cancellation

AEC works by using the speaker output as a reference signal. The device knows exactly what it’s playing, so it can model how that sound reaches the microphone and subtract it, leaving your voice. It’s the same technique that stops conference calls echoing, and it’s why a commercial smart speaker can hear you over its own music.

ESPHome doesn’t ship it. As of 2026.7.0 there’s no AEC component in the official docs — not under the microphone domain, not as an audio preprocessor, nowhere. The ESP32-S3 is perfectly capable of it (Espressif’s ESP-SR library does exactly this, and it’s what the official Home Assistant Voice PE hardware uses) but it isn’t exposed in mainline ESPHome YAML.

What does exist is a pair of external components from the esphome-audio-stack project: esp_audio_stack and esp_aec. They’re built for a VoIP project but explicitly usable standalone.

Understand the scope before you start

esp_aec does nothing on its own. It needs a phase-coherent copy of what the speaker is playing, frame by frame, and that’s what esp_audio_stack provides. The audio stack takes ownership of both I²S buses, captures the speaker reference on the way out, handles rate conversion, and then exposes ordinary ESPHome microphone and speaker platforms on top.

So this is not a component you bolt on. It replaces your entire I²S layer. The good news is that everything above that layer — mixer, resamplers, media player, wake word, voice assistant, all your LED scripts and timer logic — carries over untouched. The microphone it hands you is post-AEC, so wake word detection runs on cleaned audio automatically.

The configuration

Pull the components in:

external_components:
  - source: github://n-IA-hane/esphome-audio-stack@main
    components: [esp_audio_stack, esp_aec]

Then replace the whole i2s_audio: block and your platform: i2s_audio microphone with this:

esp_audio_stack:
  id: audio_stack
  rx_bus:
    i2s_num: 0
    i2s_bclk_pin: GPIO7
    i2s_lrclk_pin: GPIO6
    i2s_din_pin: GPIO4
  tx_bus:
    i2s_num: 1
    i2s_bclk_pin: GPIO9
    i2s_lrclk_pin: GPIO46
    i2s_dout_pin: GPIO8
  sample_rate: 48000          # speaker bus rate
  output_sample_rate: 16000   # mic / AEC / wake word / VA rate
  bits_per_sample: 32
  slot_bit_width: 32
  mic_channel: left           # INMP441 L/R tied to GND = LEFT slot
  rx_slot_mode: stereo
  processor_id: aec_processor
  aec_reference: ring_buffer
  aec_reference_buffer_ms: 80
  buffers_in_psram: true
  aec_ref_ring_in_psram: true
  audio_task_stack_in_psram: true

esp_aec:
  id: aec_processor
  sample_rate: 16000
  filter_length: 4
  mode: sr_low_cost

microphone:
  - platform: esp_audio_stack
    id: mic_main
    esp_audio_stack_id: audio_stack

Swap your hardware speaker over, keeping whatever mixer and resampler chain you already had — just point the mixer’s output_speaker at this instead:

speaker:
  - platform: esp_audio_stack
    id: hw_speaker
    esp_audio_stack_id: audio_stack
    sample_rate: 48000
    bits_per_sample: 16

Finally, point micro_wake_word and voice_assistant at mic_main.

Four settings that matter more than the rest

mic_channel must match your wiring. L/R grounded means left. Get this wrong and you get total silence — no error, no warning, just a microphone that returns nothing. It’s the most likely reason a first flash appears dead.

mode: sr_low_cost — not one of the FD or VOIP modes. The component offers several AEC engines. SR modes use linear echo cancellation that preserves the spectral features the neural wake word model depends on; FD and VOIP modes suppress residual echo more aggressively at the cost of those features. If your goal is being heard over playback, SR is the one. Avoid sr_high_perf entirely — it wants a large contiguous DMA-capable block and fails preflight on the S3.

aec_reference: ring_buffer for a discrete microphone plus a separate I²S amplifier. There’s a lighter previous_frame mode that reuses the prior transmit frame, but the ring buffer gives better frame alignment on exactly this kind of no-codec board. Fall back to previous_frame only if you’re short on RAM.

Drop your old gain_factor. Whatever you’d set to compensate for a quiet raw microphone is now compensating for the wrong thing. Start at unity on the clean stream and add gain back only if detection is weak.

Housekeeping before you flash

  • Set your logger to INFO. AEC eats roughly 7ms of a 16ms frame budget, on top of wake word inference and audio decode. DEBUG logging was merely wasteful before; now it’s likely to cause actual audio glitches and API disconnects.
  • Trim your wake word models. Every loaded model costs RAM you now need elsewhere. Keep the one you actually use.
  • Check your ESPHome version. These components need 2026.6.4 or newer.
  • Keep your working config in a separate file. Don’t edit in place. If this goes wrong you want a 60-second path back to a device that functions.
  • No Bluetooth. BLE components and audio pipelines on the same ESP32 are a well-documented crash generator. Don’t add one to find out.

The result

It works. The wake word is detected while the alarm is ringing at full volume, and while music is playing. AEC is gated automatically — it only engages when the speaker has produced audio in the last 250ms, so during silence the microphone passes through unprocessed and the adaptive filter can’t drift and start suppressing your voice.

Once it’s up, test in this order, because “it boots and hears me in a quiet room” is not the same as “AEC is working”: timer alarm at full volume, then music at high volume, then interrupting a long TTS reply mid-sentence. If the first two pass, revert the 1500ms repeat gap — that workaround is for a problem you no longer have.

The catch

This is an external component from a single-maintainer project under active development, and its last major release was explicitly breaking. Pointing @main at it means a rebuild in six months could pull something quite different.

Pin to a tag or a specific commit once you’re happy with the behaviour. And watch it for a few days under real use before declaring victory — the failure mode with this stack isn’t a dramatic boot loop, it’s occasional audio glitches or API disconnects under sustained playback.

Lessons learned

  • Read the marking on the module before you flash. The ESP32-S3-WROOM-1 N8R2 and N16R8 need different PSRAM and flash settings, and getting it wrong produces a boot loop with no error output at all.
  • Silence in the logs is itself a diagnostic. Firmware that dies before the logger initialises narrows the search to early init — PSRAM, flash layout, partition table.
  • Isolate the variable before you theorise. Muting the speaker and retrying the wake word took ten seconds and eliminated four wrong explanations at once.
  • Gain is not signal-to-noise ratio. Amplifying the input amplifies the interference equally. If the speaker is drowning the microphone, no sensitivity setting will save you.
  • Structure-borne coupling ignores acoustic damping. Foam between speaker and mic does nothing about vibration travelling through a shared enclosure. Isolate the driver from the case instead.
  • Echo cancellation is the difference between a demo and a device. Everything else on a DIY voice satellite can be very good and it will still feel broken if it goes deaf whenever it makes a sound.

The Full Code

# ESP32-S3 voice satellite with acoustic echo cancellation
# INMP441 mic + MAX98357A amp on separate I2S buses, WS2812 status bar.
#
# Audio layer: esp_audio_stack + esp_aec (external components).
# Everything above the audio layer (mixer, media player, LEDs, timers) is
# adapted from AndreyShpilevoy/Home-Assistant-Smart-Speaker.
#
# BEFORE FLASHING:
#   1. Read the marking on the module's shielding can:
#        N8R2  -> psramMode: quad,  flash_size: 8MB
#        N16R8 -> psramMode: octal, flash_size: 16MB
#      Wrong values = boot loop with no error output.
#   2. mic_channel must match your wiring. INMP441 L/R tied to GND = left.
#      Wrong value = total silence, no error.
#   3. Fill in the secrets below (see secrets.yaml example at the bottom).
#
# WIRING
#   MAX98357A:  LRC=GPIO46  BCLK=GPIO9  DIN=GPIO8  Vin=5V  GAIN=float
#   INMP441:    WS=GPIO6    SCK=GPIO7   SD=GPIO4   L/R=GND  VDD=3V3
#   WS2812 bar: DIN=GPIO16 (data goes to DIN, not DOUT), 5V
#   No series resistor on the speaker -- volume is handled in software.

substitutions:
  name: smart-speaker
  friendly_name: Smart Speaker
  device_description: "ESP32-S3 Voice Assistant Media Speaker"

  # Credentials are referenced with !secret directly in the api:/ota:/wifi:
  # blocks below — ESPHome does not support !secret inside substitutions.

  #pins
  # I2S Audio SPK (MAX98357 — its own bus)
  i2s_lrclk_pin_out: GPIO46
  i2s_bclk_pin_out: GPIO9
  i2s_dout_pin_spk: GPIO8

  # I2S Audio MIC (INMP441 — its own bus)
  i2s_lrclk_pin_in: GPIO6
  i2s_bclk_pin_in: GPIO7
  i2s_din_pin_mic: GPIO4

  #Led's
  led_pin: GPIO48 # on-board LED
  led_bar_pin: GPIO16 # Light Bar LED (wire to DIN on the stick)

  min_brightness_night: '0.11'
  max_brightness_night: '0.21'
  min_brightness_day: '0.60'
  max_brightness_day: '0.80'

  min_brightness_night_percent: 11%
  max_brightness_night_percent: 21%
  min_brightness_day_percent: 60%
  max_brightness_day_percent: 80%

  timezone: "Europe/London"

  psramMode: octal # N16R8 (8MB PSRAM) = octal. N8R2 (2MB PSRAM) = quad.

  # 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'
  # # MUTED: The voice assistant is muted and will not reply to a wake-word
  # voice_assist_muted_phase_id: '12'
  # Change this to true in case you have a hidden SSID at home.
  hidden_ssid: "false"

esphome:
  name: ${name}
  friendly_name: ${friendly_name}
  comment: ${device_description}
  min_version: 2026.6.4
  on_boot:
    priority: 375
    then:
      # Run the script to refresh the LED status
      - script.execute: control_leds
      - delay: 1s
      # 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

external_components:
  # Pinned to the commit this config was validated against (2026-07-17).
  # Track @main only if you want to follow upstream changes.
  - source: github://n-IA-hane/esphome-audio-stack@d03a546051d80bf263ba85c465e2db7224448abc
    components: [esp_audio_stack, esp_aec]

esp32:
  board: esp32-s3-devkitc-1
  cpu_frequency: 240MHz
  variant: esp32s3
  flash_size: 16MB # N16R8 = 16MB. N8R2 = 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

wifi:
  id: wifi_id
  fast_connect: ${hidden_ssid}
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  ap:
    password: !secret fallback_ap_password
  power_save_mode: none
  on_connect:
    - script.execute: control_leds
  on_disconnect:
    - script.execute: control_leds

captive_portal:

network:
  enable_ipv6: true

logger:
  # INFO, not DEBUG. AEC uses ~7ms of a 16ms frame budget; verbose logging
  # on top of that causes audio glitches and API disconnects.
  level: INFO
  logs:
    sensor: WARN

api:
  encryption:
    key: !secret api_encryption_key
  id: api_id
  on_client_connected:
    - script.execute: control_leds
  on_client_disconnected:
    - script.execute: control_leds

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

psram:
  mode: ${psramMode}
  speed: 80MHz

time:
  - platform: homeassistant
    id: ha_time
    timezone: ${timezone}
interval:
  - interval: 60sec
    then:
      - if:
          condition:
            lambda: return (id(ha_time).now().hour > int(id(day_switch_time).state) && id(ha_time).now().hour < int(id(night_switch_time).state));
          then:
            - lambda: id(min_brightness) = ${min_brightness_day};
            - lambda: id(max_brightness) = ${max_brightness_day};
          else:
            - lambda: id(min_brightness) = ${min_brightness_night};
            - lambda: id(max_brightness) = ${max_brightness_night};

globals:
  # Global variable tracking if the volume was recently changed. Used to draw LED animations with specific brightness
  - id: volume_changed
    type: bool
    restore_value: no
    initial_value: 'false'

  # Global variable storing the min level of brightness. Used to draw LED animations with specific brightness
  - id: min_brightness
    type: double
    restore_value: no
    initial_value: ${min_brightness_night}

  # Global variable storing the max level of brightness. Used to draw LED animations with specific brightness
  - id: max_brightness
    type: double
    restore_value: no
    initial_value: ${max_brightness_night}

  # Global initialization variable. Initialized to true and set to false once everything is connected. Only used to have a smooth "plugging" experience
  - id: init_in_progress
    type: bool
    restore_value: no
    initial_value: 'true'

  # 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}

  # Global variable storing the first active timer
  - id: first_active_timer
    type: voice_assistant::Timer
    restore_value: false

  # Global variable storing if a timer is active
  - id: is_timer_active
    type: bool
    restore_value: false

switch:
  # This is the mute switch that mute mic and set volume to 0%. It is exposed to Home Assistant.
  - platform: template
    id: mute_switch
    restore_mode: RESTORE_DEFAULT_OFF
    icon: "mdi:volume-mute"
    name: Mute
    entity_category: config
    optimistic: True
    turn_on_action:
      # - lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id};
      - script.execute:
          id: play_sound
          priority: true
          sound_file: !lambda return id(mute_switch_on_sound);
      - delay: 500ms
      - microphone.mute:        
      - media_player.volume_set: 0%
    turn_off_action:
      # - lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id};
      - media_player.volume_set: 50%
      - microphone.unmute:
      - script.execute:
          id: play_sound
          priority: true
          sound_file: !lambda return id(mute_switch_off_sound);
    on_turn_on:
      - script.execute: control_leds
    on_turn_off:
      - script.execute: control_leds

  # This is the microphone mute switch. It is exposed to Home Assistant.
  - platform: template
    id: mic_mute_switch
    restore_mode: RESTORE_DEFAULT_OFF
    icon: "mdi:microphone-off"
    name: "Mute microphone"
    entity_category: config
    optimistic: True
    turn_on_action:
      - microphone.mute:
    turn_off_action:
      - if:
          condition:
            switch.is_on: mute_switch
          then:
            - switch.turn_off: mute_switch
          else:
            - microphone.unmute:
    on_turn_on:
      - script.execute: control_leds
    on_turn_off:
      - script.execute: control_leds

  # 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

light:
  - platform: esp32_rmt_led_strip
    rgb_order: GRB
    chipset: ws2812
    pin: ${led_bar_pin}
    num_leds: 8
    name: "Status LED Bar"
    id: led_bar
    icon: mdi:led-on
    default_transition_length: 0s
  - platform: partition
    id: start_led
    segments:
      - id: led_bar
        from: 0
        to: 0
    default_transition_length: 100ms
  - platform: partition
    id: middle_led
    segments:
      - id: led_bar
        from: 1
        to: 6
        reversed: false #set false if you want to chage direction of the led bar on change volume
    default_transition_length: 100ms
    effects:
      - pulse:
          name: "Slow Pulse Night"
          transition_length: 250ms
          update_interval: 250ms
          min_brightness: ${min_brightness_night_percent}
          max_brightness: ${max_brightness_night_percent}
      - pulse:
          name: "Fast Pulse Night"
          transition_length: 100ms
          update_interval: 100ms
          min_brightness: ${min_brightness_night_percent}
          max_brightness: ${max_brightness_night_percent}
      - pulse:
          name: "Slow Pulse Day"
          transition_length: 250ms
          update_interval: 250ms
          min_brightness: ${min_brightness_day_percent}
          max_brightness: ${max_brightness_day_percent}
      - pulse:
          name: "Fast Pulse Day"
          transition_length: 100ms
          update_interval: 100ms
          min_brightness: ${min_brightness_day_percent}
          max_brightness: ${max_brightness_day_percent}
      - addressable_lambda:
          name: "Show Volume"
          update_interval: 50ms
          lambda: |-
            int int_volume = int(id(external_media_player).volume * 100 * it.size());
            int full_leds = int_volume / 100;
            int last_brightness = int_volume % 100;
            int i = 0;
            for(; i < full_leds; i++) {
              it[i] = Color::WHITE;
            }
            if(i < 4) {
              it[i++] = Color(64, 64, 64).fade_to_white(last_brightness*256/100);
            }
            for(; i < it.size(); i++) {
              it[i] = Color(64, 64, 64);
            }
  - platform: partition
    id: end_led
    segments:
      - id: led_bar
        from: 7
        to: 7
    default_transition_length: 100ms

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(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(volume_changed)) {
            id(show_volume).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(mute_switch).state) {
            id(control_leds_muted_or_silent).execute();
          } else if (id(external_media_player).volume == 0.0f || id(external_media_player).is_muted()) {
            id(control_leds_muted_or_silent).execute();
          } else if (id(voice_assistant_phase) == ${voice_assist_idle_phase_id}) {
            id(control_leds_voice_assistant_idle_phase).execute();
          }

  # # Warm White color. Dont want to lost.
  #         brightness: 66%
  #         red: 100%
  #         green: 89%
  #         blue: 71%
  
  # Script executed when the volume is increased/decreased from the игеещты
  - id: show_volume
    mode: restart
    then:
      - light.turn_on:
          id: middle_led
          effect: "Show Volume"          
          brightness: !lambda 'return id(max_brightness);'
      - delay: 1s
      - lambda: id(volume_changed) = false;
      - script.execute: control_leds

  # Script executed during initialisation: In this example: Turn the LED in green with a fast pulse 🟢
  - id: control_leds_init_state
    then:
      - light.turn_on:
          id: middle_led
          blue: 0%
          red: 0%
          green: 100%
          brightness: !lambda 'return id(max_brightness);'
          effect: !lambda |-
            if (id(ha_time).now().hour > int(id(day_switch_time).state) && id(ha_time).now().hour < int(id(night_switch_time).state)) {
              return "Fast Pulse Day";
            } else {
              return "Fast Pulse Night";
            }

  # Script executed when the device has no connection to Home Assistant
  # Turn the LED in yellow, solid (slow pulse) 🟡 (This will be visible during HA updates for example)
  - id: control_leds_no_ha_connection_state
    then:
      - light.turn_on:
          id: middle_led
          blue: 0%
          red: 100%
          green: 100%
          brightness: !lambda 'return id(max_brightness);'
          effect: !lambda |-
            if (id(ha_time).now().hour > int(id(day_switch_time).state) && id(ha_time).now().hour < int(id(night_switch_time).state)) {
              return "Slow Pulse Day";
            } else {
              return "Slow Pulse Night";
            }

  # Script executed when the voice assistant is idle (waiting for a wake word)
  # Nothing (Turn off the LED)
  - id: control_leds_voice_assistant_idle_phase
    then:
      - light.turn_off:
          id: middle_led

  # Script executed when the voice assistant is waiting for a command (After the wake word)
  # Turn the LED in blue with a slow pulse 🔵
  - id: control_leds_voice_assistant_waiting_for_command_phase
    then:
      - light.turn_on:
          id: middle_led
          blue: 100%
          red: 0%
          green: 0%
          brightness: !lambda 'return id(max_brightness);'
          effect: !lambda |-
            if (id(ha_time).now().hour > int(id(day_switch_time).state) && id(ha_time).now().hour < int(id(night_switch_time).state)) {
              return "Slow Pulse Day";
            } else {
              return "Slow Pulse Night";
            }

  # Script executed when the voice assistant is listening to a command
  # Turn the LED in blue with a slow pulse 🔵
  - id: control_leds_voice_assistant_listening_for_command_phase
    then:
      - light.turn_on:
          id: middle_led
          blue: 100%
          red: 0%
          green: 0%
          brightness: !lambda 'return id(max_brightness);'
          effect: !lambda |-
            if (id(ha_time).now().hour > int(id(day_switch_time).state) && id(ha_time).now().hour < int(id(night_switch_time).state)) {
              return "Slow Pulse Day";
            } else {
              return "Slow Pulse Night";
            }

  # Script executed when the voice assistant is thinking to a command
  # Turn the LED in blue, solid (no pulse) 🔵
  - id: control_leds_voice_assistant_thinking_phase
    then:
      - light.turn_on:
          id: middle_led
          blue: 100%
          red: 0%
          green: 0%
          brightness: !lambda 'return id(max_brightness);'
          effect: "none"

  # Script executed when the voice assistant is thinking to a command
  # Turn the LED in blue, solid (no pulse) 🔵
  - id: control_leds_voice_assistant_replying_phase
    then:
      - light.turn_on:
          id: middle_led
          blue: 100%
          red: 0%
          green: 0%
          brightness: !lambda 'return id(max_brightness);'
          effect: "none"

  # Script executed when the voice assistant is in error
  # Turn the LED in red with a fast pulse 🔴
  - id: control_leds_voice_assistant_error_phase
    then:
      - light.turn_on:
          id: middle_led
          blue: 0%
          red: 100%
          green: 0%
          brightness: !lambda 'return id(max_brightness);'
          effect: !lambda |-
            if (id(ha_time).now().hour > int(id(day_switch_time).state) && id(ha_time).now().hour < int(id(night_switch_time).state)) {
              return "Fast Pulse Day";
            } else {
              return "Fast Pulse Night";
            }

  # Script executed when the voice assistant is muted or silent
  # Turn the LED in red, solid (no pulse) 🔴
  - id: control_leds_muted_or_silent
    then:
      - light.turn_on:
          id: middle_led
          blue: 0%
          red: 100%
          green: 0%
          brightness: !lambda 'return id(max_brightness);'
          effect: "none"

  # Script executed when the voice assistant is not ready
  # Turn the LED in yellow, solid (slow pulse) 🟡 (This will be visible during HA updates for example)
  - id: control_leds_voice_assistant_not_ready_phase
    then:
      - light.turn_on:
          id: middle_led
          blue: 0%
          red: 100%
          green: 100%
          brightness: !lambda 'return id(max_brightness);'
          effect: !lambda |-
            if (id(ha_time).now().hour > int(id(day_switch_time).state) && id(ha_time).now().hour < int(id(night_switch_time).state)) {
              return "Slow Pulse Day";
            } else {
              return "Slow Pulse Night";
            }

  # # Script executed when the timer is ringing, to control the LEDs
  # Turn the LED in white with a fast pulse ⚪
  - id: control_leds_timer_ringing
    then:
      - light.turn_on:
          id: middle_led
          blue: 100%
          red: 100%
          green: 100%
          brightness: !lambda 'return id(max_brightness);'
          effect: !lambda |-
            if (id(ha_time).now().hour > int(id(day_switch_time).state) && id(ha_time).now().hour < int(id(night_switch_time).state)) {
              return "Fast Pulse Day";
            } else {
              return "Fast Pulse Night";
            }

  # Script executed when the timer is ticking, to control the LEDs
  # Turn the LED in white with a slow pulse ⚪
  - id: control_leds_timer_ticking
    then:
      - light.turn_on:
          id: middle_led
          blue: 100%
          red: 100%
          green: 100%
          brightness: !lambda 'return id(max_brightness);'
          effect: !lambda |-
            if (id(ha_time).now().hour > int(id(day_switch_time).state) && id(ha_time).now().hour < int(id(night_switch_time).state)) {
              return "Slow Pulse Day";
            } else {
              return "Slow Pulse Night";
            }

  # 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();
          if (timers.empty()) return;
          auto output_timer = timers.front();
          for (auto &iterable_timer : timers) {
            if (iterable_timer.is_active && iterable_timer.seconds_left <= output_timer.seconds_left) {
              output_timer = iterable_timer;
            }
          }
          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;
          for (auto &iterable_timer : timers) {
            if (iterable_timer.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

# Owns BOTH I2S buses, the AEC reference capture and rate conversion.
# Replaces the old i2s_audio: / microphone: platform i2s_audio blocks.
esp_audio_stack:
  id: audio_stack
  rx_bus:
    i2s_num: 0
    i2s_bclk_pin: ${i2s_bclk_pin_in}
    i2s_lrclk_pin: ${i2s_lrclk_pin_in}
    i2s_din_pin: ${i2s_din_pin_mic}
  tx_bus:
    i2s_num: 1
    i2s_bclk_pin: ${i2s_bclk_pin_out}
    i2s_lrclk_pin: ${i2s_lrclk_pin_out}
    i2s_dout_pin: ${i2s_dout_pin_spk}
  sample_rate: 48000          # speaker bus rate
  output_sample_rate: 16000   # mic / AEC / MWW / VA rate
  bits_per_sample: 32
  slot_bit_width: 32
  mic_channel: left           # INMP441 L/R is tied to GND = LEFT slot
  rx_slot_mode: stereo        # read both slots, select mic_channel in software
  processor_id: aec_processor
  aec_reference: ring_buffer  # better frame alignment on discrete mic + I2S amp
  aec_reference_buffer_ms: 80
  buffers_in_psram: true      # required
  aec_ref_ring_in_psram: true
  audio_task_stack_in_psram: true

esp_aec:
  id: aec_processor
  sample_rate: 16000
  filter_length: 4
  mode: sr_low_cost   # linear AEC — preserves spectral features MWW relies on

# Post-AEC microphone. MWW and Voice Assistant both consume this.
microphone:
  - platform: esp_audio_stack
    id: mic_main
    esp_audio_stack_id: audio_stack

speaker:
  # Hardware speaker output (now via the audio stack, so TX is captured
  # as the AEC reference before it reaches the amp)
  - platform: esp_audio_stack
    id: hw_speaker
    esp_audio_stack_id: audio_stack
    sample_rate: 48000
    bits_per_sample: 16

  # Virtual speakers to combine the announcement and media streams together into one output
  - platform: mixer
    id: mixing_speaker
    output_speaker: hw_speaker
    num_channels: 1
    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: Media Player
    internal: False
    volume_increment: 0.05
    volume_min: 0.4
    volume_max: 0.85
    announcement_pipeline:
      speaker: announcement_resampling_speaker
      format: FLAC     # FLAC is the least processor intensive codec
      num_channels: 1  # Stereo audio is unnecessary for announcements
      sample_rate: 48000
    media_pipeline:
      speaker: media_resampling_speaker
      format: FLAC     # FLAC is the least processor intensive codec
      num_channels: 1
      sample_rate: 48000
    on_mute:
      - script.execute: control_leds
    on_unmute:
      - script.execute: control_leds
    on_volume:
      - lambda: id(volume_changed) = true;
      - script.execute: control_leds
    on_announcement:
      - mixer_speaker.apply_ducking:
          id: media_mixing_input
          decibel_reduction: 20
          duration: 0.0s
    on_state:
      - 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
    files:
      - id: mute_switch_on_sound
        file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/mute_switch_on.flac
      - id: mute_switch_off_sound
        file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/mute_switch_off.flac
      - id: timer_finished_sound
        file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/timer_finished.flac
      - id: wake_word_triggered_sound
        file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/wake_word_triggered.flac
      - id: error_cloud_expired
        file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/error_cloud_expired.mp3

micro_wake_word:
  id: mww
  microphone: mic_main
  stop_after_detection: false
  models:
    # Each loaded model costs RAM. Comment out the ones you don't use.
    - model: https://github.com/kahrendt/microWakeWord/releases/download/okay_nabu_20241226.3/okay_nabu.json
      id: okay_nabu
    - 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:
  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 #master_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: Moderately 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: [email protected], 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
        }

voice_assistant:
  id: va
  microphone: mic_main
  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:
    - lambda: id(init_in_progress) = false;
    - micro_wake_word.start:
    - lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id};
    - script.execute: control_leds
  on_client_disconnected:
    - voice_assistant.stop:
    - lambda: id(voice_assistant_phase) = ${voice_assist_not_ready_phase_id};
    - script.execute: control_leds
  on_error:
    # Only set the error phase if the error code is different than duplicate_wake_up_detected or stt-no-text-recognized
    # These two are ignored for a better user experience
    - 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: control_leds
    # If the error code is cloud-auth-failed, serve a local audio file guiding the user.
    - if:
        condition:
          - lambda: return code == "cloud-auth-failed";
        then:
          - script.execute:
              id: play_sound
              priority: true
              sound_file: !lambda return id(error_cloud_expired);
  # When the voice assistant starts: Play a wake up sound, duck audio.
  on_start:
    - mixer_speaker.apply_ducking:
        id: media_mixing_input
        decibel_reduction: 20  # Number of dB quieter; higher implies more quiet, 0 implies full volume
        duration: 0.0s         # The duration of the transition (default is no transition)
  on_listening:
    - lambda: id(voice_assistant_phase) = ${voice_assist_waiting_for_command_phase_id};
    - script.execute: control_leds
  on_stt_vad_start:
    - lambda: id(voice_assistant_phase) = ${voice_assist_listening_for_command_phase_id};
    - script.execute: control_leds
  on_stt_vad_end:
    - lambda: id(voice_assistant_phase) = ${voice_assist_thinking_phase_id};
    - script.execute: control_leds
  on_tts_start:
    - lambda: id(voice_assistant_phase) = ${voice_assist_replying_phase_id};
    - script.execute: control_leds
    # Start a script that would potentially enable the stop word if the response is longer than a second
    - script.execute: activate_stop_word_once
  # When the voice assistant ends ...
  on_end:
    - wait_until:
        not:
          voice_assistant.is_running:
    # Stop ducking audio.
    - mixer_speaker.apply_ducking:
        id: media_mixing_input
        decibel_reduction: 0
        duration: 1.0s
    # If the end happened because of an error, let the error phase on for a second
    - if:
        condition:
          lambda: return id(voice_assistant_phase) == ${voice_assist_error_phase_id};
        then:
          - delay: 1s
    # Reset the voice assistant phase id and reset the LED animations.
    - lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id};
    - script.execute: control_leds
  on_timer_finished:
    - switch.turn_on: timer_ringing
  on_timer_started:
    - script.execute: control_leds
  on_timer_cancelled:
    - script.execute: control_leds
  on_timer_updated:
    - script.execute: control_leds
  on_timer_tick:
    - script.execute: control_leds

button:
  - platform: restart
    id: restart_button
    name: "Restart"
    entity_category: config
    disabled_by_default: true
    icon: "mdi:restart"

number:
  - platform: template
    id: night_switch_time
    name: "Night switch time"
    optimistic: true
    min_value: 0
    max_value: 23
    step: 1
    initial_value: 22
    restore_value: true
  - platform: template
    id: day_switch_time
    name: "Day switch time"
    optimistic: true
    min_value: 0
    max_value: 23
    step: 1
    initial_value: 7
    restore_value: true
# ---------------------------------------------------------------------------
# secrets.yaml needs these five entries:
#
#   wifi_ssid: "your-ssid"
#   wifi_password: "your-wifi-password"
#   fallback_ap_password: "at-least-8-chars"
#   api_encryption_key: "base64-key-from-esphome.io/components/api"
#   ota_password: "your-ota-password"
# ---------------------------------------------------------------------------

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.