Posting this because I burned a few hours on this board and found nothing online for this specific model — every guide/config I found was for a sibling Waveshare ES8311 board (S3-Audio-Board, S3-RLCD-4.2, S3-Box, …) with different I2S pins. If you have this board and searched for “ESPHome ES8311 use_microphone silent”, “Parent bus is busy forever”, or “SCL is held LOW” and landed here — this is for you.
The board
Waveshare ESP32-S3-ePaper-1.54 / ESP32-S3-Touch-ePaper-1.54 (also sold as ...1.54G with a 4-color panel). ESP32-S3-PICO-1-N8R8, 8MB flash + 8MB PSRAM, 1.54" e-paper, ES8311 audio codec with an analog mic and speaker, SHTC3 temp/humidity, PCF85063 RTC, microSD, battery charging.
Full working config + pin-map notes on devices.esphome.io (search for. Still not online, use this link meanwhile.
“Touch-ePaper-1.54”)
This post is the “why,” not just the “what.”
Gotcha #1: use_microphone: true makes the mic silent, no error, no warning
The ES8311 ESPHome component’s docs describe use_microphone as configuring “the codec’s ADC to use PDM microphone input instead of analog.” I misread that as “turn the mic on” and set it true. Every symptom pointed at a dead mic: wake word never fired, Whisper STT reported 15 seconds of pure silence, raising mic gain to max changed nothing.
The actual ESPHome source (es8311.cpp):
uint8_t reg14 = 0x1A; // analog MIC enabled + max PGA gain
if (this->use_mic_) reg14 |= BIT(6); // switches ADC to PDM DIGITAL mic
This board’s mic is analog, wired into the ES8311’s PGA. The analog path is already the default (0x1A). use_microphone: true points the ADC at a PDM
digital mic this board doesn’t have. Leave it false/absent.
Gotcha #2: “Parent bus is busy” — sometimes transient, sometimes an infinite loop
ESPHome’s i2s_audio bus guards itself with a single mutex (esphome/components/i2s_audio/i2s_audio.h):
bool try_lock() { return this->lock_.try_lock(); }
// speaker: if (!this->parent_->try_lock()) { ESP_LOGE(TAG, "Parent bus is busy"); }
There is no full-duplex mic+speaker on stock ESPHome for a single ES8311/one I2S port — exactly one consumer holds the bus at a time. micro_wake_word
captures continuously, so unless something releases it, the speaker can never start.
voice_assistant’s ownon_tts_stream_start/on_tts_stream_endhooks requirespeaker:and are rejected at config-validation time if you usemedia_player:instead (which you need for HA-initiated playback, not just pipeline TTS).- The fix is
media_player’son_play/on_announcement/on_idle
hooks callingmicro_wake_word.stop/.start— those fire for any playback source, pipeline or manual. - Do not also call
microphone.capture/microphone.stop_captureanywhere.micro_wake_wordalready owns the mic. An explicit capture call creates a second, independent claim thatmicro_wake_word.stopcannot release — the mic keeps the mutex forever and the speaker retries “Parent bus is busy” in an infinite loop, not the harmless one-shot retry you might see during normal handover. This is nasty because a single announcement can work fine, and it only deadlocks after the first full voice-pipeline run adds the extra claim.
Gotcha #3: I2C bus dies on a cold boot (“SCL is held LOW”, “Found no devices”)
Correction (2026-09-04): my original advice here was wrong, and wrong in a way that hides itself. I first wrote that a switch: platform: gpio with restore_mode: ALWAYS_ON gets the ES8311’s power rail up before i2c: initialises. It does not. Setup priorities, from
esphome/core/component.h:
POWER = 1200 // "For power supply components that must be on before buses like i2c can work."
BUS = 1000 // i2c
IO = 900
HARDWARE = 800 // switch: platform: gpio
and gpio_switch.cpp:
float GPIOSwitch::get_setup_priority() const { return setup_priority::HARDWARE; }
800 < 1000, so a GPIO switch is set up after the I2C bus has already scanned. The reason this looks like it works is that every reboot you do while developing is a warm reboot or an OTA reboot, and the rails are still powered from the previous run. The first time you actually pull power, it fails hard:
[I][i2c.idf:205]: Performing bus recovery
[E][i2c.idf:273]: Recovery failed: SCL is held LOW during clock pulse cycle
[C][component:164]: Setup i2c took 11206ms
[C][i2c.idf:115]: Found no devices
No ES8311, no SHTC3, no RTC. The unpowered codec holds SCL low through the scan. On my board that ~11 second stall also delayed boot enough to trip a failed-boot detection and roll back to the previous OTA partition, which sent me chasing the wrong problem for a while.
The right tool is power_supply:, which exists for exactly this. With enable_on_boot: true on an internal pin it reports setup_priority::POWER
(1200), so it genuinely runs before i2c: (power_supply.cpp):
float PowerSupply::get_setup_priority() const {
if (this->pin_->is_internal() && this->enable_on_boot_)
return setup_priority::POWER;
return setup_priority::IO;
}
# NOT `switch: platform: gpio` + restore_mode: ALWAYS_ON -- see above.
power_supply:
- id: audio_power
pin:
number: GPIO42 # ES8311 rail (PA_EN P-FET): LOW = rail ON
inverted: true
enable_on_boot: true
keep_on_time: 0s
- id: vbat_power
pin: GPIO17 # battery power latch; harmless on USB power
enable_on_boot: true
keep_on_time: 0s
- id: epd_power
pin:
number: GPIO6 # e-paper panel rail: LOW = ON
inverted: true
enable_on_boot: true
keep_on_time: 0s
The NS4150B amplifier enable (GPIO46) can stay an ordinary switch: platform: gpio – it is user-facing in HA and is not needed before the bus comes up.
After this change a genuine cold boot enumerates the whole bus:
[C][i2c.idf:103]: Recovery: bus successfully recovered
[C][i2c.idf:119]: Found device at address 0x18 <- ES8311 codec
[C][i2c.idf:119]: Found device at address 0x38 <- capacitive touch controller
[C][i2c.idf:119]: Found device at address 0x51 <- PCF85063 RTC
[C][i2c.idf:119]: Found device at address 0x70 <- SHTC3
Small bonus: 0x38 is the touch controller, which I had never seen in a scan before, because the bus had never once come up successfully on a cold boot.
If you are debugging any “works until I unplug it” I2C problem on an ESP32 board with a software-gated rail, check the setup priority of whatever is driving that rail before you look anywhere else.
Gotcha #4: volume isn’t linear
media_player.volume_set maps straight onto the ES8311’s REG32 (0.5dB per step), where 191/255 = 0dB. So HA’s 0.0-1.0 volume slider is roughly:
- 0.45 → −38dB (inaudible)
- 0.75 → 0dB (correct starting point)
- 1.00 → +32dB (loud and distorted on a small speaker)
Full config
See devices.esphome.io this link for the complete, copy-pasteable config (pin map, base config, audio, sensors, full voice assistant pipeline). Happy to answer questions here if anyone hits the same
wall.