Custom Component for Waveshare ESP32-S3 4.2" RLCD (ST7305)

I recently picked up the Waveshare ESP32-S3 4.2inch RLCD Development Board. While I initially bought it under the impression it was natively supported, I quickly realized there wasn’t a built-in driver for the ST7305 controller in ESPHome.

After some deep-dive vibe coding, I’ve completed a custom driver component to get this display up and running. I wanted to share it here in case anyone else is looking to use these ultra-low-power reflective displays in their ESPHome projects.

GitHub Repository: ESPHome-ST7305-RLCD

Key Features:

  • Full support for the Waveshare ESP32-S3 4.2" RLCD
  • Should support other panels using the ST7305 (untested)
  • Low-power optimization
  • Easy integration via external_components

Quick Implementation:
To use it in your YAML, just point to the repo:

external_components:
  - source: github://kylehase/ESPHome-ST7305-RLCD
    components: [ st7305_rlcd ]

display:
  - platform: st7305_rlcd
    # Add your pin configuration here

Feel free to submit issues or PRs on GitHub. Hope this helps someone avoid the headache I had!

Cool, nicely done. Planning on submitting it to ESPHome?

Honestly, I’m a little unconfident since it’s vibe coded. It works for me, and supposedly uses the ESPHome coding standards, but I’m hesitant to submit it.

I’ll consider it in the future after I learn more about ESP component coding.

It seems to work, so far.
My first refresh is often garbage, the second then works fine, so I guess it’s probably a race condition at some point.

Other than that, so far so good, haven’t had a look at the code yet… I got the panel 2 weeks ago, and haven’t even had the time to look at it anyways so far.

I have not had any luck getting this to work at all.
My screen just shows static and won’t even show the test screen.

any recommendations?

spi:
  clk_pin: GPIO11
  mosi_pin: GPIO12 #Yes

# External component
external_components:
  - source: github://kylehase/ESPHome-ST7305-RLCD
    components: [ st7305_rlcd ]

font:
  - file: "gfonts://Roboto"
    id: roboto
    size: 40

display:
  - platform: st7305_rlcd
    model: WAVESHARE_400X300
    rotation: 0
    id: my_display
    #width: 400
    #height: 300
    cs_pin: GPIO40 #Yes
    dc_pin: GPIO5 #Yes
    reset_pin: GPIO41 #Yes
    #te_pin: GPIO6 #Yes
    update_interval: never
    #show_test_card: true
    lambda: |-
      // Get dimensions
      int w = it.get_width();
      int h = it.get_height();

      // Print "Hello World!" in the center of the screen
      // Arguments: x, y, font_id, color, alignment, text
      it.printf(w / 2, h / 2, id(roboto), COLOR_ON, TextAlign::CENTER, "Hello World!");
      id(my_display).low_power_mode();

I finally found the fix - I took the hint and actually set an update interval - only 5s, but after setting that everything finally started working!

yeah, something during the init is probably going too fast or something is missing.
Glad you found the hint :wink:

There is probably also something else still not right, because when entering low-power mode, the panel looses a bit of contrast (which it does not do on the demo).

I’ve just received my display and trying to get the display to work, but getting error when the display is booting

[13:12:09.837][C][logger:316]: Logger:
[13:12:09.837][C][logger:316]: Max Level: DEBUG
[13:12:09.837][C][logger:316]: Initial Level: DEBUG
[13:12:09.851][C][logger:322]: Log Baud Rate: 115200
[13:12:09.851][C][logger:322]: Hardware UART: USB_SERIAL_JTAG
[13:12:09.852][C][logger:332]: Task Log Buffer Size: 768 bytes
[13:12:09.862][C][spi:066]: SPI bus:
[13:12:09.865][C][spi:152]: CLK Pin: GPIO39
[13:12:09.865][C][spi:152]: SDI Pin:
[13:12:09.865][C][spi:152]: SDO Pin: GPIO38
[13:12:09.872][C][spi:074]: Using HW SPI: SPI2_HOST
[13:12:09.872][C][st7305_rlcd:107]: ST7305 RLCD
[13:12:09.872][C][st7305_rlcd:107]: Rotations: 0 °
[13:12:09.872][C][st7305_rlcd:107]: Dimensions: 400px x 300px
[13:12:09.872][C][st7305_rlcd:124]: Model: Waveshare 400x300
[13:12:09.877][C][st7305_rlcd:125]: Resolution: 400x300
[13:12:09.878][C][st7305_rlcd:126]: Orientation: Landscape (2x4)
[13:12:09.878][C][st7305_rlcd:128]: Buffer Size: 15000 bytes
[13:12:09.882][C][st7305_rlcd:129]: Rotated Size: 400x300
[13:12:09.883][C][st7305_rlcd:152]: DC Pin: GPIO5
[13:12:09.883][C][st7305_rlcd:152]: Reset Pin: GPIO41
[13:12:09.886][E][component:226]: display is marked FAILED: unspecified

Nothing displays on the screen.

Here is the code

esp32:
  board: esp32s3
  framework:
    type: esp-idf

# External component
external_components:
  - source: 
      type: git
      url: https://github.com/kylehase/ESPHome-ST7305-RLCD
    components: [ st7305_rlcd ]
    refresh: 0s

spi:
  clk_pin: GPIO39
  mosi_pin: GPIO38

font:
  - file: "gfonts://Roboto"
    id: roboto
    size: 40

display:
  - platform: st7305_rlcd
    model: WAVESHARE_400X300
    cs_pin: GPIO40
    dc_pin: GPIO5
    reset_pin: GPIO41
    rotation: 0
    lambda: |-
      it.rectangle(0, 0, it.get_width(), it.get_height(), COLOR_ON);
      it.print(10, 10, id(roboto), "Hello World!");

Am I using the correct board, Is the framework type correct? esp-idf? Or am I doing something else wrong?
Any advice would be great. Thanks

Do you have the fill yaml that’s working for you? I can’t get mine to work.
Thanks

At the moment your refresh is at 0sec, try setting it to 5s.

changed to 1s, and update_interval to 5s, but the main issue is that I get the error display is marked FAILED: unspecified

Try adding the following to your configuration:

psram:
  mode: octal
  speed: 80MHz

Here is a full config that works for me

esphome:
  name: rlcd
  friendly_name: RLCD

esp32:
  board: esp32-s3-devkitc-1
  framework:
    type: esp-idf


psram:
  mode: octal
  speed: 40MHz

# Enable logging
logger:

# Enable Home Assistant API
api:
  encryption:
    key: "XXXXXXX"

ota:
  - platform: esphome
    password: "YYYYYYYYYY"
wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Rlcd Fallback Hotspot"
    password: "ZZZZZZZZ"

captive_portal:

spi:
  clk_pin: GPIO11  
  mosi_pin: GPIO12
  #miso_pin: GPIO8      # Added dummy pin to satisfy SPI initialization

# External component
external_components:
  - source: github://kylehase/ESPHome-ST7305-RLCD
    components: [ st7305_rlcd ]
    refresh: 0s
   

font:
  - file: "gfonts://Roboto"
    id: roboto
    size: 40



display:
  - platform: st7305_rlcd
    model: WAVESHARE_400X300
    rotation: 0
    id: my_display
    width: 400
    height: 300
    cs_pin: GPIO40
    dc_pin: GPIO5
    #te_pin: GPIO6
    reset_pin: GPIO41
    data_rate: 1MHz
    update_interval: never
    show_test_card: false
    lambda: |-
      // Get dimensions
      int w = it.get_width();
      int h = it.get_height();
      id(my_display).display_on();

      // Print "Hello World!" in the center of the screen
      // Arguments: x, y, font_id, color, alignment, text
      it.printf(w / 2, h / 2, id(roboto), COLOR_ON, TextAlign::CENTER, "Hello World!");
      id(my_display).low_power_mode();

(EDIT: changed refresh to 0s and update_interval to never, since it works without these)

Thanks for the reply, I was about to reply back that I got it working by adding

psram:
mode: octal
speed: 80MHz

That is the key to get the error message to clear and the display to actually work.

Kudos to @kylehase for creating this component.

just for info and troubleshooting:
i ordered a few, one of it needed refresh set to 9s and update_interval to set to 15s. These are both random values, but with these the display worked.

Studying the schematic, user key is GPIO18.

I have a fun bug that when you turn the screen on while on battery, it doesn’t initialize the screen right. Probably a weird timing bug. My screen inversion demonstrator fixes this nicely.

Example YAML:

esphome:
  name: rlcd
  friendly_name: RLCD

globals:
  - id: is_inverted
    type: bool
    initial_value: 'false'

esp32:
  board: esp32-s3-devkitc-1
  framework:
    type: esp-idf

# CRITICAL: Enables the 8MB Octal PSRAM required for the display buffer
psram:
  mode: octal
  speed: 40MHz

logger:

api:
  encryption:
    key: !secret api_key

ota:
  - platform: esphome
    password: !secret ota_password

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  ap:
    ssid: "Rlcd Fallback Hotspot"
    password: "fvVLbFqvknK1"

captive_portal:

spi:
  clk_pin: GPIO11  
  mosi_pin: GPIO12

i2c:
  sda: GPIO13
  scl: GPIO14
  scan: true
  id: bus_a

external_components:
  - source: github://kylehase/ESPHome-ST7305-RLCD
    components: [ st7305_rlcd ]
    refresh: 6s # Cache the component unless you are actively developing it

font:
  - file: "gfonts://Roboto"
    id: roboto
    size: 24


sensor:
  - platform: adc
    id: bat_voltage
    name: "Battery Voltage"
    pin: GPIO4   # This is the correct pin for RLCD-4.2
    attenuation: 11db
    update_interval: 60s
    filters:
      # The divider is 100k+100k (1/2) or 200k+100k (1/3)
      # Start with 2.0. If it reads ~2.1V for a full battery, change to 3.0.
      - multiply: 3.0 
      # Smooth out the jitter
      - sliding_window_moving_average:
          window_size: 10
          send_every: 1

      # Optional: Convert voltage to percentage
  - platform: copy
    source_id: bat_voltage
    id: bat_level
    name: "Battery Level"
    unit_of_measurement: "%"
    filters:
      - calibrate_linear:
          - 2.5 -> 0.0
          - 4.2 -> 100.0
      - clamp:
          min_value: 0
          max_value: 100
  - platform: shtcx
    id: my_sensor
    temperature:
      name: "Ambient Temperature"
      id: rlcd_temp
    humidity:
      name: "Ambient Humidity"
      id: rlcd_hum
    address: 0x70  # Standard SHTC3 address
    update_interval: 60s
    i2c_id: bus_a

display:
  - platform: st7305_rlcd
    model: WAVESHARE_400X300
    rotation: 0
    id: my_display
    width: 400
    height: 300
    cs_pin: GPIO40
    dc_pin: GPIO5
    #te_pin: GPIO6
    reset_pin: GPIO41
    data_rate: 1MHz
    update_interval: never
    show_test_card: false
    lambda: |-
      int w = it.get_width();
      int h = it.get_height();

      // 1. Determine Colors based on the global toggle
      auto bg = id(is_inverted) ? COLOR_ON : COLOR_OFF;  // Swap Background
      auto fg = id(is_inverted) ? COLOR_OFF : COLOR_ON;  // Swap Text Color
      
      // 2. Wake up
      id(my_display).display_on();
      it.filled_rectangle(0, 0, w, h, bg); // Clear previous screen

      // 2. Draw "Hello World"
      it.printf(w / 2, h / 2, id(roboto), fg, TextAlign::CENTER, "Hello World!");   

      // 3. Sensors (Temp, Humidity, Battery Level)
      if (id(rlcd_temp).has_state() && id(rlcd_hum).has_state()) {
        it.printf(w / 2, 30, id(roboto), fg, TextAlign::CENTER, 
                  "T %.1f°C  |  H %.1f%% | B %.0f%%", id(rlcd_temp).state, id(rlcd_hum).state, id(bat_level).state);
      } else {
         it.printf(w / 2, 30, id(roboto), fg, TextAlign::CENTER, "Loading Sensors...");
      }
      
      // 4. Power Save
      id(my_display).low_power_mode();

### buttons
#
# left: User Button
#   Bug: GPIO18
# center: Power Button. 
#   Long press to turn off, short press to turn on. 
#   Bug: When turned on while on battery, display not initialized correctly
# right: Boot Button (GPIO0)

binary_sensor:
  # 1. The "BOOT" Button (Top side button)
  - platform: gpio
    name: "Boot Button"
    id: button_boot
    pin:
      number: GPIO0
      inverted: true
      mode: INPUT
    # Example Action: Force a screen refresh when clicked
    on_press:
      - component.update: my_display
  - platform: gpio
    name: "Button Key (GPIO18)"
    pin:
      number: GPIO18
      inverted: true
      mode: INPUT # Try PULLUP. If fails, remove this line (some boards have HW pullup)
    on_press:
      - logger.log: "KEY BUTTON PRESSED"
      # 1. Toggle the variable (true -> false -> true)
      - globals.set:
          id: is_inverted
          value: !lambda 'return !id(is_inverted);'
      
      # 2. Force the display to redraw immediately with new colors
      - component.update: my_display

Thanks for the really useful examples.

How are you finding battery lift, how long does it last for you?

That really depends on how you configure your ESPhome setup. I haven’t yet worked on doing power savings; the humidity and temperature sensors currently update once per minute which likely is detrimental to battery life. Those sensors are heavily imprecise anyways so probably worth disabling.

Audio output now works with the following settings (I had previously confused the GPIOs for speaker and mic):

# 1. I2C
i2c:
  id: bus_a
  sda: GPIO13
  scl: GPIO14
  scan: True
  frequency: 100kHz

# 2. I2S
i2s_audio:
  - id: i2s_bus
    i2s_lrclk_pin: GPIO45
    i2s_bclk_pin: GPIO9
    i2s_mclk_pin: GPIO16

# 3. DAC
audio_dac:
  - platform: es8311
    id: es8311_dac
    i2c_id: bus_a
    address: 0x18
    use_microphone: False
    use_mclk: True
    sample_rate: 48000

# 4. Speaker
speaker:
  - platform: i2s_audio
    id: rlcd_speaker_hw
    i2s_audio_id: i2s_bus
    i2s_dout_pin: GPIO8
    dac_type: external
    audio_dac: es8311_dac    
    sample_rate: 48000
  - platform: resampler
    id: rtttl_resampler
    output_speaker: rlcd_speaker_hw
    sample_rate: 48000
    bits_per_sample: 16

# 5. Media Player
media_player:
  - platform: speaker
    name: "RLCD Speaker"
    id: rlcd_media_player
    announcement_pipeline: 
      speaker: rlcd_speaker_hw

# 6. Power Amplifier Switch
switch:
  - platform: gpio
    pin: GPIO46
    name: "Audio Power"
    id: audio_pa_ctrl
    restore_mode: ALWAYS_ON
    on_turn_off:
      - logger.log: "WARNING: Audio Power was turned OFF unexpectedly!"

# 7. tone generator for testing
rtttl:
  id: rlcd_buzzer
  speaker: rtttl_resampler
  gain: 90%

I have added a sound test to the boot sequence:

esphome:
  name: rlcd
  friendly_name: RLCD
  on_boot:
    priority: 800
    then:
      # turn on PA before initializing es8311
      - switch.turn_on: audio_pa_ctrl
      - delay: 100ms
      - speaker.volume_set:
          id: rlcd_speaker_hw
          volume: 75%
      - rtttl.stop
      - rtttl.play: "scale:d=4,o=5,b=120:c,d,e,f,g,a,b,c6"

and a doorbell sound on GPIO0

binary_sensor:
  - platform: gpio
    name: "Boot Button"
    id: button_boot
    pin:
      number: GPIO0
      inverted: true
      mode: INPUT
    on_press:
      - component.update: my_display                       #refresh screen
      - logger.log: "Playing direct doorbell..."
      - rtttl.stop                                         #interrupt any songs from RTTTL
      - rtttl.play: "doorbell:d=8,o=5,b=120:e6,c6,p".      #sample doorbell chime

Without the resampler for RTTTL, I only get noise. The 48kHz match the TTS output.

EDIT 2: Microphone via ES7210:

# 8. ADC
audio_adc:
  - platform: es7210
    id: es7210_adc
    i2c_id: bus_a
    address: 0x40        # confirmed in your I²C scan
    bits_per_sample: 16bit
    sample_rate: 16000
 
# 9. microphone
microphone:
  - platform: i2s_audio
    id: board_mic
    i2s_audio_id: i2s_bus
    i2s_din_pin: GPIO10
    adc_type: external
    pdm: false
    bits_per_sample: 16bit   # must match ES7210
    sample_rate: 16000       # must match ES7210
    channel: left            # ES7210 default output channel

Tested with voice assistant triggered by pressing User button (GPIO18)

  - platform: gpio
    name: "Button Key (GPIO18)"
    id: button_key
    pin:
      number: GPIO18
      inverted: true
      mode: INPUT 
    on_multi_click:
      # Single press → toggle voice assistant
      - timing:
          - ON for 50ms to 350ms
          - OFF for at least 400ms
        then:
          - if:
              condition:
                voice_assistant.is_running:
              then:
                - voice_assistant.stop:
              else:
                - rtttl.stop
                - rtttl.play: "listen:d=16,o=6,b=180:c,e"  # tone BEFORE mic starts
                - delay: 200ms                             # wait for tone to finish
                - voice_assistant.start:
          - globals.set:
              id: is_inverted
              value: !lambda 'return !id(is_inverted);'
            # 2. Force the display to redraw immediately with new colors
          - component.update: my_display

If you use one button to trigger VA like here, no need to press it again after talking. That will likely stop the pipeline before any voice commands have been recognized, so it’s there to cancel things.

I am inverting the display while the pipeline is active as a visual indicator.

Thanks for the audio code, works a treat. Mine makes a pop noise before playing the doorbell sound, is that normal? Is there a way to get rid of it? Also how do you set to volume? Thanks