Skip to content

Voice Assistant

Home Assistant has a voice pipeline of its own — wake word, speech-to-text, an agent that can actually call your services, text-to-speech — and it needs something in the room with a microphone in it. That something is a voice satellite, and the cheapest one worth owning is an M5Stack Atom Echo: a small cube with a PDM microphone, a speaker, a button and one RGB LED. Flash it with the config in this folder and it appears in Home Assistant as a device you can talk to.

The satellite is a microphone and a status light. Everything that thinks — wake word, transcription, the conversation agent, the voice that answers — runs on your Home Assistant box, which is the whole point: nothing here is tied to an account you can be locked out of.

Parts

Some links below are Amazon affiliate links. As an Amazon Associate I earn from qualifying purchases — at no extra cost to you. It helps keep these guides free. Outside the US, they should send you to your own Amazon store; if one doesn't, searching the part number there will find it.

Put it at head height and away from the TV. The microphone is one PDM MEMS part in a plastic cube, not a seven-mic beamforming array. The difference between “it never hears me” and “it always hears me” is usually two feet of height and not pointing it at a speaker.

The pin map

You don’t wire any of this — the Atom Echo is a finished device and the cable is the only thing you plug in. The table is here because these are the numbers in the config, and they are what you’d have to change to build the same thing on other hardware.

FunctionGPIOConfig detail
I²S word select (LRCLK)33Microphone and speaker share one i2s_audio bus
I²S bit clock (BCLK)19Same bus
Microphone data in23adc_type: external, pdm: true
Speaker data out22dac_type: external
RGB LED27SK6812, one pixel, rgb_order: grb
Button39inverted: true — the LED is the button

The microphone is PDM, not standard I²S. That single line is what most “I swapped in a different mic breakout” attempts trip over.

What the LED is telling you

The whole conversation is on one pixel, so it is worth learning. Every colour below comes from the voice_assistant: triggers and the reset_led script in the config.

LEDState
Dim warm whiteIdle, wake word armed, waiting
Blue, slow pulseHeard the wake word — it is listening to you now
Blue, fast pulseYou stopped talking; it is transcribing and thinking
Solid blueSpeaking the reply
Red for one secondThe pipeline threw an error
DarkWake word off, listen light off — or Home Assistant is unreachable

That last row is the useful one. The device turns the LED off when the API client disconnects, so a satellite sitting in the dark that ignores its button has usually lost Home Assistant, not broken its microphone.

Configuration

Copy bruh-voice-assistant.yaml out of the download section into your ESPHome folder, alongside a secrets.yaml containing wifi_ssid, wifi_password, ota_password and api_key. There is a secrets.yaml.example in the repo with every key these configs use.

  1. Rename it. The top of the file is two substitutions:

    substitutions:
    name: "bruh-voice-assistant"
    friendly_name: "BRUH Voice Assistant"

    Change both per device — kitchen-echo, office-echo. This matters more here than on most configs; see the caution below.

  2. Password the fallback hotspot. ap: is declared with nothing under it, which puts up an open access point whenever the device can’t reach your WiFi. Add a password:

    wifi:
    ap:
    password: !secret ap_password
  3. Flash it over USB the first time. It builds with esp-idf, not the Arduino framework, so the first compile takes a while — that is normal and it is cached afterwards. Every flash after the first goes over WiFi.

  4. Adopt it in Home Assistant. ESPHome devices are discovered; you enter the API encryption key and it appears. No YAML on the Home Assistant side.

name_add_mac_suffix: false means the second one collides with the first. The hostname comes straight from the substitution, so two Atom Echos flashed with the file as shipped both try to be bruh-voice-assistant on your network, and mDNS picks a winner at random. Rename before you flash the second unit, not after.

BLE provisioning is unauthenticated. esp32_improv is configured with authorizer: none, so anything in Bluetooth range can hand the device WiFi credentials without anybody pressing the button. The config closes the window itself — on_connect waits 5 s and calls ble.disable — but it is open from power-on until it joins your network, and it reopens on on_disconnect every time WiFi drops. That is a convenience feature working as designed. If it is not a trade you want, delete the esp32_improv: block and provision over USB with improv_serial.

The pipeline

There is no micro_wake_word: block in this config, which tells you where the work happens: the satellite streams audio, and the wake word is detected on your Home Assistant server, along with everything after it. Build the pipeline under Settings → Voice assistants, then assign it to the device.

The choice that decides how this feels is speech-to-text. Home Assistant Cloud does it remotely and is fast from anywhere. Running Whisper locally keeps everything in the house and turns a Raspberry Pi into the bottleneck — a Pi 4 will do it, slowly, with the smallest model. A spare mini PC is the honest answer if you want local and quick.

The config already tunes the audio it sends: noise_suppression_level: 2, auto gain at 31 dBFS, and volume_multiplier: 2.0. Those are sensible starting values. Raise the noise suppression if it triggers on the television; raise the volume multiplier if it hears you but transcribes gibberish.

The two switches

The device exposes two template switches to Home Assistant, both of which restore to on after a reboot:

Use wake word. On, the satellite listens continuously and starts a conversation when it hears the phrase. Off, it does nothing until you press the button — which is the setting for a satellite in a bedroom, or one sitting beside a TV that keeps setting it off.

Use listen light. Whether the LED glows warm white while idle. Turn it off for anything in a bedroom; the conversation colours still work, so you keep the feedback and lose the nightlight.

The button

The Atom Echo’s whole top face is the button, and it does two things.

One press starts or stops a conversation. With the wake word switch off, that is your push-to-talk. With it on, a press stops whatever is happening, waits a second, and restarts continuous listening — the reset for a satellite stuck mid-answer.

Hold it for ten seconds and it presses the factory reset button. That wipes the stored WiFi credentials and you are back to provisioning it over USB or BLE. It is deliberate, it is in the config as an on_multi_click timing, and it is worth knowing before you hand the cube to a curious four-year-old.

Troubleshooting

It answers but you can barely hear it. The speaker is a few millimetres across and the config is already compensating with volume_multiplier: 2.0. This device is a microphone with a confirmation beep attached. If you want audible replies, point the pipeline’s text-to-speech at a real media player and let the Echo be the ears.

The wake word never fires, but the button works. Either the Use wake word switch is off, or the pipeline assigned to this device has no wake-word engine selected. The satellite has no wake word of its own to fall back on.

The colours are wrong — red where blue should be. The LED is declared rgb_order: grb. A different pixel revision wants grb swapped for rgb, and that is the only line to change.

It cuts you off mid-sentence. That is voice activity detection deciding you finished, which is a pipeline setting on the Home Assistant side rather than anything in this file.

Home Assistant can’t connect after a reflash. The api: block is encrypted with !secret api_key. Change that key and the existing device entry stops working until you re-enter it.

Building one out of parts

You can put the same pipeline on a bare ESP32 with an I²S microphone and a Class-D amplifier, and it is a better-sounding device when you do — a real 2 in driver instead of a buzzer. Be clear about what you are taking on, though: this config does not fit that hardware. All six GPIO numbers change, and pdm: true comes out, because an SPH0645 is standard I²S rather than PDM.

The Atom Echo is the one to build first. Get the pipeline working against hardware that is known good, then go rebuild it in a box you like.

Files & downloads

ESPHome configuration

Copy this into your ESPHome directory and adjust the substitutions at the top. Secrets are referenced by name — see secrets.yaml.example .

bruh-voice-assistant.yaml 245 lines
bruh-voice-assistant.yaml
# BRUH Voice Assistant
# ────────────────────────────────────────────────────────────
# M5Stack Atom Echo voice assistant with I2S microphone and
# speaker support. Integrates with Home Assistant for voice
# control and automation triggers.
#
# Hardware:
# - Board: M5Stack Atom Echo (ESP32)
# - Audio: I2S microphone and speaker
# ────────────────────────────────────────────────────────────
substitutions:
name: "bruh-voice-assistant"
friendly_name: "BRUH Voice Assistant"
esphome:
name: ${name}
name_add_mac_suffix: false
friendly_name: ${friendly_name}
api:
encryption:
key: !secret api_key
esp32:
board: m5stack-atom
framework:
type: esp-idf
logger:
ota:
- platform: esphome
password: !secret ota_password
wifi:
on_connect:
- delay: 5s
- ble.disable:
on_disconnect:
- ble.enable:
ap:
ssid: !secret wifi_ssid
password: !secret wifi_password
improv_serial:
esp32_improv:
authorizer: none
button:
- platform: factory_reset
id: factory_reset_btn
name: Factory reset
i2s_audio:
- id: i2s_bus
i2s_lrclk_pin: GPIO33
i2s_bclk_pin: GPIO19
microphone:
- platform: i2s_audio
id: echo_microphone
i2s_audio_id: i2s_bus
i2s_din_pin: GPIO23
adc_type: external
pdm: true
speaker:
- platform: i2s_audio
id: echo_speaker
i2s_audio_id: i2s_bus
i2s_dout_pin: GPIO22
dac_type: external
voice_assistant:
id: va
microphone: echo_microphone
speaker: echo_speaker
noise_suppression_level: 2
auto_gain: 31dBFS
volume_multiplier: 2.0
on_listening:
- light.turn_on:
id: led
blue: 100%
red: 0%
green: 0%
effect: "Slow Pulse"
on_stt_vad_end:
- light.turn_on:
id: led
blue: 100%
red: 0%
green: 0%
effect: "Fast Pulse"
on_tts_start:
- light.turn_on:
id: led
blue: 100%
red: 0%
green: 0%
brightness: 100%
effect: none
on_end:
- delay: 100ms
- wait_until:
not:
speaker.is_playing:
- script.execute: reset_led
on_error:
- light.turn_on:
id: led
red: 100%
green: 0%
blue: 0%
brightness: 100%
effect: none
- delay: 1s
- script.execute: reset_led
on_client_connected:
- if:
condition:
switch.is_on: use_wake_word
then:
- voice_assistant.start_continuous:
- script.execute: reset_led
on_client_disconnected:
- if:
condition:
switch.is_on: use_wake_word
then:
- voice_assistant.stop:
- light.turn_off: led
binary_sensor:
- platform: gpio
pin:
number: GPIO39
inverted: true
name: Button
disabled_by_default: true
entity_category: diagnostic
id: echo_button
on_multi_click:
- timing:
- ON for at least 250ms
- OFF for at least 50ms
then:
- if:
condition:
switch.is_off: use_wake_word
then:
- if:
condition: voice_assistant.is_running
then:
- voice_assistant.stop:
- script.execute: reset_led
else:
- voice_assistant.start:
else:
- voice_assistant.stop
- delay: 1s
- script.execute: reset_led
- script.wait: reset_led
- voice_assistant.start_continuous:
- timing:
- ON for at least 10s
then:
- button.press: factory_reset_btn
light:
- platform: esp32_rmt_led_strip
id: led
name: RGB LED
disabled_by_default: false
entity_category: config
pin: GPIO27
default_transition_length: 0s
chipset: SK6812
num_leds: 1
rgb_order: grb
effects:
- pulse:
name: "Slow Pulse"
transition_length: 250ms
update_interval: 250ms
min_brightness: 50%
max_brightness: 100%
- pulse:
name: "Fast Pulse"
transition_length: 100ms
update_interval: 100ms
min_brightness: 50%
max_brightness: 100%
script:
- id: reset_led
then:
- if:
condition:
- switch.is_on: use_wake_word
- switch.is_on: use_listen_light
then:
- light.turn_on:
id: led
red: 100%
green: 89%
blue: 71%
brightness: 60%
effect: none
else:
- light.turn_off: led
switch:
- platform: template
name: Use wake word
id: use_wake_word
optimistic: true
restore_mode: RESTORE_DEFAULT_ON
entity_category: config
on_turn_on:
- lambda: id(va).set_use_wake_word(true);
- if:
condition:
not:
- voice_assistant.is_running
then:
- voice_assistant.start_continuous
- script.execute: reset_led
on_turn_off:
- voice_assistant.stop
- lambda: id(va).set_use_wake_word(false);
- script.execute: reset_led
- platform: template
name: Use listen light
id: use_listen_light
optimistic: true
restore_mode: RESTORE_DEFAULT_ON
entity_category: config
on_turn_on:
- script.execute: reset_led
on_turn_off:
- script.execute: reset_led

View on GitHub · Download raw

Every file for this project on GitHub →