Waveshare ESP32-S3-Touch-ePaper-1.54
Product Description
ESP32-S3 (PICO-1-N8R8, 8MB flash + 8MB PSRAM) AIoT board with a 1.54” 200x200
e-paper display (monochrome on the -EN/base variant, 4-color BWRY on the
...1.54G variant), an ES8311 audio codec driving an onboard analog
microphone and speaker, a SHTC3 temperature/humidity sensor, a PCF85063 RTC,
a microSD slot, and lithium battery charging/monitoring. The Touch variant
additionally has a capacitive touch panel.
This guide focuses on using the board as a fully local Home Assistant Voice Assistant satellite (wake word + STT + TTS) while keeping the SHTC3 sensor. The e-paper display is intentionally not covered in the config below — a full refresh takes ~15-20s, so it cannot show live listening/thinking/replying states and competes with the audio pipeline for SPI/CPU. See the Waveshare ESPHome climate-display writeup for a separate display-only firmware for this board family.
Important: the pins below are specific to this board. Every visually similar Waveshare ES8311 board (S3-Audio-Board, S3-RLCD-4.2, S3-Box, Korvo, …) uses different I2S pins for its codec. Copying a sibling board’s audio config compiles fine but leaves the mic/speaker producing no usable audio — this is the single biggest time sink with this board family.
Product Images


GPIO Pinout
| GPIO | Function |
|---|---|
| GPIO0 | BOOT button (active low) |
| GPIO4 | Battery voltage ADC (200K/200K divider) |
| GPIO6 | e-paper panel power (inverted: LOW = on) |
| GPIO8 | e-paper BUSY (HIGH = busy, LOW = idle; NOT inverted) |
| GPIO9 | e-paper RESET |
| GPIO10 | e-paper DC |
| GPIO11 | e-paper CS |
| GPIO12 | SPI CLK (e-paper) |
| GPIO13 | SPI MOSI (e-paper) |
| GPIO14 | I2S MCLK |
| GPIO15 | I2S BCLK |
| GPIO16 | I2S DIN (microphone, ES8311 ADC) |
| GPIO17 | Battery power latch (must be HIGH on battery) |
| GPIO18 | PWR button (active low, also deep-sleep wake) |
| GPIO38 | I2S LRCLK / WS |
| GPIO42 | Audio power rail (PA_EN, active LOW) |
| GPIO45 | I2S DOUT (speaker, ES8311 DAC), strapping pin |
| GPIO46 | Speaker amplifier enable (PA_CTRL, NS4150B), strapping pin |
| GPIO47 | I2C SDA (shared: ES8311 @0x18, SHTC3 @0x70, RTC @0x51) |
| GPIO48 | I2C SCL |
Pin map verified against Waveshare’s own official ESP-IDF firmware
(Example/ESP-IDF_5.5.1/07_Audio_Test/components/codec_board/board_cfg.txt
and main/user_config.h in the
waveshareteam/ESP32-S3-ePaper-1.54G
repo, Apache-2.0) and against the board schematic PDF. GPIO45/46 are ESP32-S3
strapping pins; ESPHome will warn about this at compile time, it is expected
and harmless on this board.
Basic Configuration
Includes Wi-Fi, the shared I2C bus, the ES8311 mic/speaker audio path, the
SHTC3 sensor, and the boot button. No api:/ota: yet — add your own (or
see the Voice Assistant config below, which includes them).
substitutions: name: "waveshare-touch-epaper-154" friendly_name: "Waveshare Touch ePaper 1.54"
esphome: name: "${name}" friendly_name: "${friendly_name}"
esp32: board: esp32-s3-devkitc-1 framework: type: esp-idf flash_size: 8MB
psram: mode: octal speed: 80MHz
logger: hardware_uart: USB_SERIAL_JTAG # this board's only USB is native USB-JTAG
# Add your own wifi: block with your credentials.
# Shared by the ES8311 codec (0x18), SHTC3 sensor (0x70), and PCF85063 RTC (0x51).i2c: sda: GPIO47 scl: GPIO48 scan: true id: bus_a
# Single shared I2S bus. The ES8311 does both DAC (speaker) and ADC# (microphone) -- there is no separate ADC chip on this board, unlike the# RLCD-4.2 / S3-Audio-Board which pair ES8311 with an ES7210.i2s_audio: - id: i2s_shared i2s_lrclk_pin: GPIO38 i2s_bclk_pin: GPIO15 i2s_mclk_pin: GPIO14
audio_dac: - platform: es8311 id: es8311_dac address: 0x18 i2c_id: bus_a bits_per_sample: 16bit sample_rate: 16000 # DO NOT set use_microphone: true on this board. It switches the ES8311's # ADC input from analog (this board's mic) to PDM digital (a mic this # board does not have), giving total silence with no error anywhere. # The analog mic path is already enabled by default. use_microphone: false mic_gain: 24dB # 30dB+ clipped into garbled STT in testing; 24dB was accurate.
speaker: - platform: i2s_audio id: i2s_audio_speaker i2s_audio_id: i2s_shared i2s_dout_pin: GPIO45 dac_type: external audio_dac: es8311_dac sample_rate: 16000 bits_per_sample: 16bit
microphone: - platform: i2s_audio id: va_mic i2s_audio_id: i2s_shared i2s_din_pin: GPIO16 adc_type: external pdm: false sample_rate: 16000 bits_per_sample: 16bit
# Audio power rail (GPIO42, active LOW) and the battery latch (GPIO17).## These MUST be `power_supply:`, not `switch: platform: gpio`. ESPHome setup# priorities (esphome/core/component.h):# POWER = 1200 <- power_supply with enable_on_boot on an internal pin# BUS = 1000 <- i2c# IO = 900# HARDWARE = 800 <- switch: platform: gpio# A gpio switch is therefore set up AFTER i2c, so restore_mode: ALWAYS_ON does# NOT get the rail up before the bus scan. On a warm/OTA reboot you never notice,# because the rails are still powered from the previous run. On a true cold boot# the unpowered ES8311 holds SCL low and the whole bus dies:# "Recovery failed: SCL is held LOW during clock pulse cycle"# "Found no devices" (plus an ~11s stall in i2c setup)# power_supply with enable_on_boot: true reports setup_priority::POWER, the tier# the core documents as "must be on before buses like i2c can work".power_supply: - id: audio_power pin: number: GPIO42 inverted: true # PA_EN P-FET: driving LOW turns the rail ON 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 - platform: gpio name: "Speaker Amp" pin: GPIO46 restore_mode: RESTORE_DEFAULT_ON
sensor: - platform: shtcx address: 0x70 i2c_id: bus_a update_interval: 60s temperature: name: "Temperature" humidity: name: "Humidity"
binary_sensor: - platform: gpio name: "Boot Button" pin: number: GPIO0 inverted: true mode: INPUT_PULLUPKey points, all reverse-engineered from ESPHome’s own source
(esphome/components/es8311/es8311.cpp,
esphome/components/i2s_audio/i2s_audio.h) after several rounds of
plausible-looking-but-wrong config:
-
use_microphone: falseon thees8311audio_dac— do not set thistrue. The ES8311 component’s own docstring says it “configures the codec’s ADC to use PDM microphone input instead of analog,” which reads like “turn the mic on.” In the actual source:uint8_t reg14 = 0x1A; // analog MIC enabled + max PGA gainif (this->use_mic_) reg14 |= BIT(6); // switches ADC to PDM DIGITAL micThis board’s mic is analog, wired into the ES8311’s PGA — the analog path is already the default (
0x1A).use_microphone: truepoints the ADC at a PDM digital mic this board doesn’t have: total silence, no error, no warning anywhere. Wake word never fires, STT gets pure silence, and raising mic gain changes nothing (because there is no signal to gain). -
mic_gain: 24dB— 30dB and above clipped into garbled STT results in testing; 24dB gave accurate transcriptions. -
The GPIO42/GPIO17 rails use
power_supply:, NOTswitch: platform: gpio. This is easy to get wrong, and the failure only shows up on a cold boot. ESPHome’s setup priorities (esphome/core/component.h) are:POWER = 1200 // "must be on before buses like i2c can work"BUS = 1000 // i2cIO = 900HARDWARE = 800 // switch: platform: gpioA
gpioswitch is set up at 800 — afteri2c:at 1000 — sorestore_mode: ALWAYS_ONdoes not guarantee the ES8311’s rail is up before the bus scan. You will not notice on warm or OTA reboots, because the rails are still powered from the previous run. On a true cold boot the unpowered codec holds SCL low and the entire bus fails:[E][i2c.idf]: Recovery failed: SCL is held LOW during clock pulse cycle[C][component]: Setup i2c took 11206ms[C][i2c.idf]: Found no devices(no ES8311, no SHTC3, no RTC — and that ~11 s stall can itself cause a failed boot and an OTA rollback).
power_supply:withenable_on_boot: truereportssetup_priority::POWERon an internal pin, which genuinely precedesi2c:. With it, a cold boot enumerates0x18(ES8311),0x38(touch controller),0x51(PCF85063 RTC) and0x70(SHTC3).
Voice Assistant Configuration
Extends the basic configuration above into a full local Voice Assistant satellite: on-device wake word, STT/TTS through Home Assistant’s Assist pipeline, and spoken responses through the onboard speaker. Requires a Home Assistant instance with an Assist pipeline configured — a local STT engine such as Wyoming Whisper works well here and keeps everything off-cloud.
substitutions: name: "waveshare-touch-epaper-154" friendly_name: "Waveshare Touch ePaper 1.54"
esphome: name: "${name}" friendly_name: "${friendly_name}"
esp32: board: esp32-s3-devkitc-1 framework: type: esp-idf flash_size: 8MB
psram: mode: octal speed: 80MHz
logger: hardware_uart: USB_SERIAL_JTAG # this board's only USB is native USB-JTAG
# Add your own wifi: block with your credentials.
api:ota: - platform: esphome
# Shared by the ES8311 codec (0x18), SHTC3 sensor (0x70), and PCF85063 RTC (0x51).i2c: sda: GPIO47 scl: GPIO48 scan: true id: bus_a
# Single shared I2S bus. The ES8311 does both DAC (speaker) and ADC# (microphone) -- there is no separate ADC chip on this board, unlike the# RLCD-4.2 / S3-Audio-Board which pair ES8311 with an ES7210.i2s_audio: - id: i2s_shared i2s_lrclk_pin: GPIO38 i2s_bclk_pin: GPIO15 i2s_mclk_pin: GPIO14
audio_dac: - platform: es8311 id: es8311_dac address: 0x18 i2c_id: bus_a bits_per_sample: 16bit sample_rate: 16000 # DO NOT set use_microphone: true on this board. It switches the ES8311's # ADC input from analog (this board's mic) to PDM digital (a mic this # board does not have), giving total silence with no error anywhere. # The analog mic path is already enabled by default. use_microphone: false mic_gain: 24dB # 30dB+ clipped into garbled STT in testing; 24dB was accurate.
speaker: - platform: i2s_audio id: i2s_audio_speaker i2s_audio_id: i2s_shared i2s_dout_pin: GPIO45 dac_type: external audio_dac: es8311_dac sample_rate: 16000 bits_per_sample: 16bit
microphone: - platform: i2s_audio id: va_mic i2s_audio_id: i2s_shared i2s_din_pin: GPIO16 adc_type: external pdm: false sample_rate: 16000 bits_per_sample: 16bit
# Audio power rail (GPIO42, active LOW) and the battery latch (GPIO17).## These MUST be `power_supply:`, not `switch: platform: gpio`. ESPHome setup# priorities (esphome/core/component.h):# POWER = 1200 <- power_supply with enable_on_boot on an internal pin# BUS = 1000 <- i2c# IO = 900# HARDWARE = 800 <- switch: platform: gpio# A gpio switch is therefore set up AFTER i2c, so restore_mode: ALWAYS_ON does# NOT get the rail up before the bus scan. On a warm/OTA reboot you never notice,# because the rails are still powered from the previous run. On a true cold boot# the unpowered ES8311 holds SCL low and the whole bus dies:# "Recovery failed: SCL is held LOW during clock pulse cycle"# "Found no devices" (plus an ~11s stall in i2c setup)# power_supply with enable_on_boot: true reports setup_priority::POWER, the tier# the core documents as "must be on before buses like i2c can work".power_supply: - id: audio_power pin: number: GPIO42 inverted: true # PA_EN P-FET: driving LOW turns the rail ON 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
media_player: - platform: speaker id: spk_player name: None announcement_pipeline: speaker: i2s_audio_speaker format: FLAC sample_rate: 16000 num_channels: 1 media_pipeline: speaker: i2s_audio_speaker format: FLAC sample_rate: 16000 num_channels: 1 # micro_wake_word OWNS the microphone. Hand the shared I2S bus over with # micro_wake_word.stop / .start ONLY. Do not add microphone.capture / # microphone.stop_capture actions anywhere in this config: an explicit # capture call creates a SECOND, independent claim on the mic that # micro_wake_word.stop cannot release, which pins the bus open and makes # every later playback attempt fail forever with "Parent bus is busy" # (not the harmless one-off retry -- an infinite retry loop). This is # easy to reproduce: a single announcement works, then the first full # voice pipeline run adds the extra claim and all subsequent playback # deadlocks. on_play: - micro_wake_word.stop: on_announcement: - micro_wake_word.stop: on_idle: - script.execute: restart_mww
micro_wake_word: id: mww microphone: microphone: va_mic channels: 0 gain_factor: 4 # default of 1 leaves the model too quiet to ever hit its cutoff models: - model: hey_jarvis on_wake_word_detected: - voice_assistant.start:
voice_assistant: id: va microphone: va_mic media_player: spk_player use_wake_word: false # micro_wake_word handles the wake word instead noise_suppression_level: 2 auto_gain: 31dBFS volume_multiplier: 2.0 on_client_connected: - micro_wake_word.start: on_client_disconnected: - micro_wake_word.stop: on_error: - micro_wake_word.start: # on_end fires BEFORE the TTS response actually plays -- it must NOT touch # the mic/bus here. media_player's on_idle (above) is the sole place that # reacquires the bus, once the speaker has actually finished.
script: - id: restart_mww then: - wait_until: not: media_player.is_announcing: - wait_until: not: speaker.is_playing: id: i2s_audio_speaker - delay: 100ms - micro_wake_word.start:
sensor: - platform: shtcx address: 0x70 i2c_id: bus_a update_interval: 60s temperature: name: "Temperature" humidity: name: "Humidity"
binary_sensor: - platform: gpio name: "Boot Button" pin: number: GPIO0 inverted: true mode: INPUT_PULLUPKey points:
-
gain_factor: 4onmicro_wake_word’s microphone source. The default of 1 leaves the wake-word model too quiet to ever cross its probability cutoff (0.97 by default) — the wake word never fires with the default gain even though the mic itself is working fine. -
The mic/speaker handover is done with
micro_wake_word.stop/.startONLY, driven frommedia_player’son_play/on_announcement/on_idlehooks — never fromvoice_assistant’s own hooks, and never with explicitmicrophone.capture/microphone.stop_captureactions. ESPHome’si2s_audiobus 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_wordcaptures continuously and already owns the microphone, so:voice_assistant’s ownon_tts_stream_start/on_tts_stream_endhooks requirespeaker:and are rejected at config-validation time when usingmedia_player:instead (which you need for HA-initiated playback, not just pipeline TTS) — usemedia_player’s hooks instead.voice_assistant’son_endfires before the response actually plays, so it must not touch the mic/bus; onlymedia_player’son_idle(viascript.restart_mww, which explicitly waits formedia_player.is_announcingandspeaker.is_playingto clear) should reacquire it.- Adding an explicit
microphone.capture/microphone.stop_captureanywhere creates a second, independent claim on the mic thatmicro_wake_word.stopcannot release. The mic then keeps the bus mutex forever and the speaker retries “Parent bus is busy” in an infinite loop — not the harmless one-off retry you may see during a normal handover. This is easy to miss: a single announcement can work fine, and it only deadlocks after the first full voice-pipeline run.
-
Volume is not linear.
media_player.volume_setmaps onto the ES8311’s REG32 (0.5dB per step), where 191/255 = 0dB:- 0.45 → −38dB (inaudible)
- 0.75 → 0dB (good starting point)
- 1.00 → +32dB (loud and distorted on a small speaker)
Notes
- The touch panel (on the
Touchvariant) is not yet covered in this guide. - The e-paper display is deliberately omitted from this voice-focused config; see the intro above.
- Every board in this family (S3-Audio-Board, S3-RLCD-4.2, S3-Box, Korvo, this board, …) wires its ES8311 to different GPIOs. If you have a visually similar Waveshare board that isn’t this one, don’t reuse these pin numbers — check that board’s own schematic/example firmware first.