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.
- M5Stack Atom Echo
— this is the whole build. The config’s
board: m5stack-atomkey, the PDM microphone and all six GPIO numbers below are that specific device; nothing else drops in unchanged. - USB-A to USB-C cable, 15 ft — a satellite wants to be where you talk, not where the outlet is. If it is going right beside one, the right-angle 0.5/1 ft 4-pack keeps the cube flat against the wall instead of standing off it.
- Dual-port USB charger block, 3-pack — any phone charger runs it. If the satellite is permanent, an in-wall USB receptacle removes the brick entirely.
- 3M VHB tape to stick it to a wall or the underside of a shelf, or industrial hook-and-loop if you want to take it down again.
- A Home Assistant server, which you presumably have. A Raspberry Pi 4 kit and a fast A2 microSD card will run this, though local speech-to-text is the part that wants a real CPU — see The pipeline below.
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.
| Function | GPIO | Config detail |
|---|---|---|
| I²S word select (LRCLK) | 33 | Microphone and speaker share one i2s_audio bus |
| I²S bit clock (BCLK) | 19 | Same bus |
| Microphone data in | 23 | adc_type: external, pdm: true |
| Speaker data out | 22 | dac_type: external |
| RGB LED | 27 | SK6812, one pixel, rgb_order: grb |
| Button | 39 | inverted: 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.
| LED | State |
|---|---|
| Dim warm white | Idle, wake word armed, waiting |
| Blue, slow pulse | Heard the wake word — it is listening to you now |
| Blue, fast pulse | You stopped talking; it is transcribing and thinking |
| Solid blue | Speaking the reply |
| Red for one second | The pipeline threw an error |
| Dark | Wake 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.
-
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. -
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 -
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. -
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.
- ESP32 DevKit, 38-pin, 2-pack
- Adafruit SPH0645LM4H I²S MEMS microphone
- MAX98357 I²S Class-D amplifier, 2-pack
- 2 in 4 Ω 3 W full-range speaker, 2-pack
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
# 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