Waveshare ESP32-S3-(Touch-)ePaper-1.54: working ESPHome Voice Assistant config (analog ES8311 mic gotcha + I2S bus deadlock)

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
“Touch-ePaper-1.54”)
. Still not online, use this link meanwhile.

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 own on_tts_stream_start/on_tts_stream_end hooks require speaker: and are rejected at config-validation time if you use media_player: instead (which you need for HA-initiated playback, not just pipeline TTS).
  • The fix is media_player’s on_play / on_announcement / on_idle
    hooks calling micro_wake_word.stop / .start — those fire for any playback source, pipeline or manual.
  • Do not also call microphone.capture / microphone.stop_capture anywhere. micro_wake_word already owns the mic. An explicit capture call creates a second, independent claim that micro_wake_word.stop cannot 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.

Correcting my own Gotcha #3 above, since it was wrong in a way that hides itself: I said a switch: platform: gpio with restore_mode: ALWAYS_ON brings the ES8311’s power rail up before i2c: initialises. It doesn’t. A GPIO switch is setup_priority::HARDWARE (800) and i2c: is BUS (1000), so the switch is set up after the bus scan has already run.

It appears to work because warm and OTA reboots leave the rails powered from the previous run. The first true cold boot fails hard, with Recovery failed: SCL is held LOW during clock pulse cycle, an ~11 second stall in i2c setup, and Found no devices – no codec, no SHTC3, no RTC. In my case that stall also tripped a failed-boot rollback to the previous OTA partition, which made it look like a completely different bug.

The correct component is power_supply: with enable_on_boot: true, which reports setup_priority::POWER (1200) for an internal pin and therefore really does precede i2c:. I have edited Gotcha #3 with the working config and the
before/after logs. Two side notes: 0x38 turned out to be the capacitive touch controller, which had never appeared in a scan because the bus had never come up cleanly on a cold boot; and while verifying the SHTC3 I found it reads about 8 C high from board self-heating (32.9 C against two independent room sensors at ~25.1 C), so filters: - offset: -8.0 is worth having if you use that sensor for anything real.

Apologies to anyone who copied the original version.

Alternatively you could delay I2C bus adding parameter

i2c:
  ...
  setup_priority: 700

Good call, and it does work here — I checked rather than assuming. Component::get_setup_priority() defaults to DATA (600) and es8311, shtcx and pcf85063 don’t override it, so with the bus at 700 it still comes up before all three. Fewer lines than my version.

Two reasons I’d still reach for power_supply as the general advice:

  • It stops holding as soon as something on the bus sits above 700. I2C IO expanders and muxes do: PCF8574 and PCA9554 are IO (900), TCA9548A is IO, SX1509 is IO/HARDWARE. Add one and it initialises before the bus it talks over — which is the exact failure ESPHome’s own source comment cites when it warns that wrong setup_priority values “break the boot sequence in subtle ways”.
  • It only sequences I2C. On this board the same GPIO42 rail also feeds the ES8311’s I2S side, and GPIO6 feeds the e-paper panel on SPI. power_supply brings every rail up ahead of all buses at one tier; lowering I2C fixes only the I2C symptom.

So yours is the smaller fix and correct for a board whose I2C devices are all default-priority; power_supply degrades more gracefully if someone adds an expander later. Good to have both in the thread — thanks for raising it.

:scream:

Yep, two is more than one. I like your solution, while more complicated.

Hi, i have almost he same version of this board, but without touch screen, just 1.54 ePaper. Trying to connect it to ESPHome and this is my first experience, also my programing skills almost to none :frowning: so i am using Gemini and DeepSeek. The main problem for me right now is that ePaper screen does not working in ESPHome. Do you have any advices about configuration?

Based on your post Gemini made test config for ePaper screen and it is working one.

substitutions:
  name: "esp32-s3-epaper-154"
  friendly_name: "ESP32-S3 ePaper 1.54 V2"

esphome:
  name: "${name}"
  friendly_name: "${friendly_name}"

esp32:
  board: esp32-s3-devkitc-1
  framework:
    type: esp-idf
  flash_size: 8MB

logger:
  hardware_uart: USB_SERIAL_JTAG

wifi:
  ssid: ""
  password: ""
  reboot_timeout: 0s

api:

ota:
  - platform: esphome

power_supply:
  - id: epd_power
    pin:
      number: GPIO6
      inverted: true
    enable_on_boot: true
    keep_on_time: 0s

i2c:
  sda: GPIO47
  scl: GPIO48
  scan: true
  id: bus_a

sensor:
  - platform: shtcx
    address: 0x70
    i2c_id: bus_a
    update_interval: 60s
    temperature:
      name: "Temperature"
    humidity:
      name: "Humidity"

font:
  - file: "gfonts://Roboto"
    id: font_1
    size: 20

spi:
  clk_pin: GPIO12
  mosi_pin: GPIO13

display:
  - platform: waveshare_epaper
    id: my_display
    model: 1.54inv2
    cs_pin: GPIO11
    dc_pin: GPIO10
    reset_pin: GPIO9
    busy_pin:
      number: GPIO8
      inverted: false
    
    update_interval: 60s
    rotation: 0°

    lambda: |-
      it.print(10, 20, id(font_1), "Hello, ESPHome!");
      it.print(10, 50, id(font_1), "Waveshare V2 Exact");
      it.print(10, 80, id(font_1), "Pins Verified!");