Usings ESP32-S3 as Standalone Bluetooth speaker or casting speaker

Hi There,

I’m currently running a project on creating a display for my bath as the current control panel needed to be replaced, but there are no spareparts available.

I almost finished the project, the only part I’m stuck with is how to use the ESP as:

  • Bluetooth Audio speaker so whem I’m bathing I can connect my phone to it and stream audio.
    or
  • Make it possible to use the esp32 as a casting device to cast audio to

When using bluetooth it would be nice to be able to use A2DP to be able to control the Bluetooth audio and display the played sing just like when in car, but that’s just a nice to have. My first goal is to make audio streaming possible.

My case limitation
As we’re planning to sell the house the ESP must be fully stand alone (so no audio streams from HA or Music Assistant etc.).

Investigation
I also spend hours investigating the possibilities, here’s what I found out:

  • It looks like I’m the only one with this desire as no other threads can be found for this specific subject.
  • squeezelite is no option as the bluetooth audio must be part of my current config and squeezelite is standalone.
  • Gnumpi’s wrapper framework that offers access to Espressif ADF looks promising but I dont know how to translate this to a working config for my situation. After reading I’m lost on these questions:
    • How do I make a bluetooth connection happen in general and use this implementation to output audio?
    • How do I use this to turn on/off bluetooth via a lvgl button?
    • How do I control the volume using a slider in lvgl for this?
    • How do I display A2DP information (which is supported by the Espressif ADF) to the LVGl display?
    • How do I add media controls to lvgl (Previous,play/pause, Next)

The hardware
Controller: ESP32-S3 N16R8
Display: 3.5" SPI 480x320 ST7796 with CPT FT6336
Relayboard: Openjumper 4 ch relay board (ordered in back in 2010 while automating my home by CMD scripts :rofl:)
Audio
AMP: MAX98357

The Wirings

Possbile or not?
What are your toughts on this? Is this a non realistic expectation, or are there any possibilities to make this happen?

My current code:

esphome:
  name: bath
  friendly_name: Bath
  platformio_options:
    build_flags: "-DBOARD_HAS_PSRAM"
    board_build.arduino.memory_type: qio_opi

esp32:
  board: esp32-s3-devkitc-1
  variant: esp32s3
  flash_size: 16MB
  
  #partitions: "/config/esphome/custom_16MB.csv"
  framework:
    type: arduino
    #version: latest

psram:
  mode: octal
  speed: 120MHz

# Enable logging
logger:
  level: DEBUG

# Enable Home Assistant API
api:
  encryption:
    key: "<hidden>"

ota:
  - platform: esphome
    password: "<hidden>"

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "BathDisplay"
    password: "123456abcd"

captive_portal:
web_server:
  port: 80

## Configuring push buttons
binary_sensor:
  - platform: gpio
    pin: GPIO3  # The GPIO pin connected to the push button
    name: "Push Button"
    id: push_button
    on_press:
      then:
        - switch.toggle: relay_s1


## Configuring the Relay Switch
switch:
  - platform: gpio
    pin: 7
    name: "S1"
    id: relay_s1
  - platform: gpio
    pin: 6
    name: "S2"
    id: relay_s2
  - platform: gpio
    pin: 5
    name: "S3"
    id: relay_s3
  - platform: gpio
    pin: 4
    name: "S4"
    id: relay_s4

# Remove the first 3 #'s to make the display work
# Configuring the SPI Pins
spi:
  clk_pin: GPIO12
  mosi_pin: GPIO11
  # miso_pin: GPIO13
  

# Assigning the i2c pins that can be used for i2c communication
i2c:
  sda: GPIO38 # Probably the boot issue due to reserved for USB_D+ --> From 20 to 38
  scl: GPIO9
  scan: True
  frequency: 400kHz

### Configuring the Display Part
display:
  - platform: ili9xxx
    model: ST7796
    dimensions: 
      height: 320
      width: 480
      offset_height: 0
      offset_width: 0
    transform:
      swap_xy: true
      mirror_x: false
      mirror_y: false
    auto_clear_enabled: false # Disabling autoclear to be compatible with LVGL
    update_interval: never
    color_order: BGR # RGB or BGR
    # color_palette: IMAGE_ADAPTIVE
    # color_palette_images: 
    #   - 'bath/bg/BackgroundNightWater480x320.png'
    invert_colors: true
    #init_sequence:
    data_rate: 80Mhz
    # spi_mode: 0
    cs_pin: GPIO1
    dc_pin: GPIO42
    reset_pin: GPIO2
    # spi_mode: MODE3
    # show_test_card: true
    # rotation: 90
    #lambda: |-
    #  it.print(0, 0, id(my_font), "Hello, World!");
## Configuring things for ontrolling the display's ledled
# Define the LED pin as a PWM output
output:
  - platform: ledc
    pin: GPIO39  
    id: gpio_backlight_pwm
light:
  - platform: monochromatic
    output: gpio_backlight_pwm
    name: "Display Backlight"
    id: display_backlight
    restore_mode: ALWAYS_ON



## Configuring the touchscreen part
touchscreen:
  - platform: ft63x6
    id: my_touchscreen
    reset_pin: GPIO40
    interrupt_pin: GPIO21 # boot issue due to reserved for USB_D- --> From 19 to 21
    transform:
      mirror_x: true
      mirror_y: false
      swap_xy: true
## If LVGl needs to be enabled remove hashes START
    on_release:
      - if:
          condition: lvgl.is_paused
          then:
            - logger.log: "LVGL resuming"
            - lvgl.resume:
            - lvgl.widget.redraw:
            - light.turn_on: display_backlight
## If LVGl needs to be enabled remove hashes END

## Configuring the SD card Slot (based on assumption if it 'll be supported ever by ESPHOME')
# sd_card:
#  cs_pin: GPIO48 (This is the real connected GPIO port in preparation of potential support in the future)
#  spi_id: spi_bus

font:
  - file: "fonts/calibri.ttf"
    id: my_font
    size: 30
    bpp: 4
    extras:
      - file: "fonts/materialdesignicons-webfont.ttf"
        glyphs: [
          "\U000F0142", # mdi:chevron-right
          "\U000F0141", # mdi:chevron-left
          "\U000F02DC", # mdi:home
          ]

### LVGL START
lvgl:
  buffer_size: 100%
  disp_bg_image: wallpaper
  bg_opa: 0%

### START Theme Part
  theme:
    label:
      text_font: my_font # set all your labels to use your custom defined font
    button:
      bg_color: 0x2F8CD8
      bg_grad_color: 0x005782
      bg_grad_dir: VER
      bg_opa: COVER
      border_color: 0x0077b3
      border_width: 1
      text_color: 0xFFFFFF
      pressed: # set some button colors to be different in pressed state
        bg_color: 0x006699
        bg_grad_color: 0x00334d
      checked: # set some button colors to be different in checked state
        bg_color: 0x1d5f96
        bg_grad_color: 0x03324A
        text_color: 0xfff300
    buttonmatrix:
      bg_opa: TRANSP
      border_color: 0x0077b3
      border_width: 0
      text_color: 0xFFFFFF
      pad_all: 0
      items: # set all your buttonmatrix buttons to use your custom defined styles and font
        bg_color: 0x2F8CD8
        bg_grad_color: 0x005782
        bg_grad_dir: VER
        bg_opa: COVER
        border_color: 0x0077b3
        border_width: 1
        text_color: 0xFFFFFF
        text_font: my_font
        pressed:
          bg_color: 0x006699
          bg_grad_color: 0x00334d
        checked:
          bg_color: 0x1d5f96
          bg_grad_color: 0x03324A
          text_color: 0x005580
    switch:
      bg_color: 0xC0C0C0
      bg_grad_color: 0xb0b0b0
      bg_grad_dir: VER
      bg_opa: COVER
      checked:
        bg_color: 0x1d5f96
        bg_grad_color: 0x03324A
        bg_grad_dir: VER
        bg_opa: COVER
      knob:
        bg_color: 0xFFFFFF
        bg_grad_color: 0xC0C0C0
        bg_grad_dir: VER
        bg_opa: COVER
    slider:
      border_width: 1
      border_opa: 80%
      bg_color: 0xcccaca
      bg_opa: 80%
      indicator:
        bg_color: 0x1d5f96
        bg_grad_color: 0x03324A
        bg_grad_dir: VER
        bg_opa: COVER
      knob:
        bg_color: 0x2F8CD8
        bg_grad_color: 0x005782
        bg_grad_dir: VER
        bg_opa: COVER
        border_color: 0x0077b3
        border_width: 1
        text_color: 0xFFFFFF
  style_definitions:
    - id: header_footer
      bg_color: 0x2F8CD8
      bg_grad_color: 0x005782
      bg_grad_dir: VER
      bg_opa: TRANSP
      border_opa: TRANSP
      radius: 0
      pad_all: 0
      pad_row: 0
      pad_column: 0
      border_color: 0x0077b3
      text_color: 0xFFFFFF
      width: 100%
      height: 30
### END Theme Part
### START Page navigation footer
  top_layer:
      widgets:
        - buttonmatrix:
            align: bottom_mid
            styles: header_footer
            pad_all: 0
            outline_width: 0
            id: top_layer
            items:
              styles: header_footer
            rows:
              - buttons:
                - id: page_prev
                  text: "\U000F0141" #"\uF053"
                  on_press:
                    then:
                      - lvgl.page.previous:
                          animation: OUT_RIGHT
                          time: 300ms
                - id: page_home
                  text: "\U000F02DC" # "\uF015"
                  on_press:
                    then:
                      - lvgl.page.show: 
                          id: main_page
                          animation: OUT_BOTTOM
                          time: 300ms
                - id: page_next
                  text: "\U000F0142" # "\uF054"
                  on_press:
                    then:
                      - lvgl.page.next:
                          animation: OUT_LEFT
                          time: 300ms
### END Page navigation footer

  pages:
    - id: main_page
      #bg_image_src: wallpaper
      widgets:
        - obj:
            align: TOP_MID
            styles: header_footer
            widgets:
              - label:
                  text: Dashboard
                  align: CENTER
                  text_align: CENTER
                  text_color: 0xFFFFFF
        - button:
            x: 10
            y: 30
            width: 175
            height: 120
            id: jetsbutton
            checkable: false
            #bg_image_src: iconjetwhitethick
            bg_opa: 40%
            border_width: 1
            widgets:
              - image:
                  src: iconjetwhitethick
                  align: TOP_LEFT
              - label:
                  text: "Jets"
                  align: BOTTOM_MID
            on_release:
              then:
                - switch.toggle: relay_s1
        - button:
            x: 10
            y: 165
            width: 175
            height: 120
            id: airmassagebutton
            checkable: false
            bg_opa: 40%
            border_width: 1
            widgets:
              - image:
                  src: iconairmassagewhitethick
                  align: TOP_LEFT
              - label:
                  text: "Air massage"
                  align: BOTTOM_MID
            on_release:
              then:
                - switch.toggle: relay_s2
        #- button:
        #    x: 10
        #    y: 105
        #    width: 200
        #    height: 70
        #    id: air_massage
        #    checkable: false
        #    widgets:
        #      - label:
        #          align: center
        #          text: "Air massage"
        #    on_release:
        #      then:
        #        - switch.toggle: relay_s2


    - id: led_control
      #bg_image_src: wallpaper
      widgets:
      - button:
          #x: 150
          #y: 150
          #width: 100
          #height: 100
          id: led_controlbtn
          checkable: false
          align: center
          widgets:
            - label:
                align: center
                text: "Relay_S2"
          on_release:
            then:
              - switch.toggle: relay_s2
              - lvgl.update:
                  disp_bg_image: wallpaper1
    - id: settings
      #bg_image_src: wallpaper
      widgets:
        - obj:
            align: TOP_MID
            styles: header_footer
            widgets:
              - label:
                  text: Configuration
                  align: CENTER
                  text_align: CENTER
                  text_color: 0xFFFFFF
        - label:
            text: Display Brightness
            x: 20
            y: 25
            #width: 220
            height: 30
            text_color: 0xFFFFFF
        - slider:
            id: backlight_pwm_slider
            x: 10
            y: 60
            width: 250
            height: 30
            pad_all: 8
            min_value: 30
            max_value: 100
            value: 100
            on_value:
              then:
                - light.turn_on:
                    id: display_backlight # ID of the to be controlled light source
                    brightness: !lambda 'return x / 100.0;'
        - button:
            x: 10
            y: 100
            width: 250
            height: 50
            bg_opa: 40%
            id: choosewalpaper
            checkable: false
            widgets:
              - label:
                  text: Choose wallpaper
            on_release:
              then:
                - lvgl.page.show:
                    id: bgpicker
                    animation: MOVE_BOTTOM
                    time: 400ms
    - id: bgpicker
      skip: true
      widgets:
        - obj:
            align: TOP_MID
            styles: header_footer
            widgets:
              - label:
                  text: Pick Wallpaper
                  align: CENTER
                  text_align: CENTER
                  text_color: 0xFFFFFF
        # ROW 1
        - image:
            src: wallpaper_150x100
            x: 5
            y: 25
            on_press:
              then:
                - lvgl.update:
                    disp_bg_image: wallpaper
        - image:
            src: wallpaper1_150x100
            x: 160
            y: 25
            on_press:
              then:
                - lvgl.update:
                    disp_bg_image: wallpaper1

        - image:
            src: wallpaper2_150x100
            x: 315
            y: 25
            on_press:
              then:
                - lvgl.update:
                    disp_bg_image: wallpaper2
        # ROW 2
        - image:
            src: wallpaper3_150x100
            x: 5
            y: 130
            on_press:
              then:
                - lvgl.update:
                    disp_bg_image: wallpaper3
    - id: connectwifi
      widgets:
        - obj:
            align: TOP_MID
            styles: header_footer
            widgets:
              - label:
                  text: Dashboard
                  align: CENTER
                  text_align: CENTER
                  text_color: 0xFFFFFF
        - tabview:
            id: tabview_wificonnect
            position: top
            size: 15%
            tabs:
              - name: "NFC chip"
                id: tv_nfc
                widgets:
                  #- label:
                  #    id: nfcintro
                  #    text: "Step 1. Connect your phone to BathDisplay wifi.\nGrab your NFC supported phone and\n scan the left side next to this screen.\n\nStep 2. Open the BatDisplay webpage.\nNow scan the right side next to this screen,\na webpage will be opened up.\n\nStep 3. Follow the onscreen instructions to connect you wifi network."
                  #    # text: "This tab will show you how to connect your display to wifi using NFC when your display became disconnected from the wifi network. This is the easiest way to connect, but if you don't have NFC you can connect by using a QR code or completely manually. To switch choose one of the other tabs above"
                  #    text_align: CENTER
                  - image:
                      src: bdnfcmanual
                      align: TOP_MID
              - name: "QR code"
                id: tv_qr
                widgets:
                  - image:
                      src: bdqrmanual
                      align: TOP_MID
              #- name: "Manually"
              #  id: tv_manually
              #  widgets:
              #    - label:
              #        text: "As the manual steps can vary a lot between different devices, only the general steps will be given. This makes this manual the most difficult way of the 3 but it’s definately worth trying if the other 2 ways won’t work."
              #        long_mode: wrap
              #    - image:
              #        src: bdmanually
              #        align: TOP_MID
  on_idle:
    timeout: !lambda "return (id(display_timeout).state * 1000);" # was 1000 for 10s
    then:
      - logger.log: "LVGL is idle"
      - light.turn_off: display_backlight
      - lvgl.pause:
### LVGL END

# Number component
number:
  # Used for the screen timeout
  - platform: template
    name: LVGL Screen timeout
    optimistic: true
    id: display_timeout
    unit_of_measurement: "s"
    initial_value: 20
    restore_value: true
    min_value: 1
    max_value: 180
    step: 5
    mode: box

#Setting a wall paper for use in lvgl (Stopped working after new display)
image:
  # BackgroundWaterdrop
  - file: 'bath/bg/BackgroundWaterdrop480x320.png'
    id: wallpaper
    type: RGB565
    use_transparency: false 
  - file: 'bath/bg/BackgroundWaterdrop480x320.png'
    id: wallpaper_150x100
    resize: 150x100
    type: RGB565
    use_transparency: false 
  # BackgroundNightWater
  - file: 'bath/bg/BackgroundNightWater480x320.png'
    id: wallpaper1
    type: RGB565
    use_transparency: False
  - file: 'bath/bg/BackgroundNightWater480x320.png'
    id: wallpaper1_150x100
    resize: 150x100
    type: RGB565
    use_transparency: false
  # lightwater
  - file: 'bath/bg/480x320lightwater.png'
    id: wallpaper2
    type: RGB565
    use_transparency: false
  - file: 'bath/bg/480x320lightwater.png'
    id: wallpaper2_150x100
    resize: 150x100
    type: RGB565
    use_transparency: false
  # lightwater
  - file: 'bath/bg/BackgroundKibibit480x320.png'
    id: wallpaper3
    type: RGB565
    use_transparency: false
  - file: 'bath/bg/BackgroundKibibit480x320.png'
    id: wallpaper3_150x100
    resize: 150x100
    type: RGB565
    use_transparency: false
  - file: 'bath/icon/JetWhite.png'
    id: iconjetwhite
    #resize: 480x320
    type: RGB565
    use_transparency: true
  - file: 'bath/icon/JetWhiteThick.png'
    id: iconjetwhitethick
    resize: 93x60
    type: RGB565
    use_transparency: true
  - file: 'bath/icon/JetBlack.png'
    id: iconjetblack
    #resize: 480x320
    type: RGB565
    use_transparency: true
  - file: 'bath/icon/JetBlackThick.png'
    id: iconjetblackthick
    #resize: 480x320
    type: RGB565
    use_transparency: true
  - file: 'bath/icon/AirMassageWhiteThick.png'
    id: iconairmassagewhitethick
    resize: 93x60
    type: RGB565
    use_transparency: true
  - file: 'bath/manual/BDChooseNetwork.png'
    id: bdchoosenetwork
    type: RGB565
    use_transparency: false
  - file: 'bath/manual/BDSaveNetwork.png'
    id: bdsavenetwork
    type: RGB565
    use_transparency: false
  - file: 'bath/manual/BDConfirmWifi.png'
    id: bdconfirmwifi
    type: RGB565
    use_transparency: false
  - file: 'bath/manual/BDNFCManual.png'
    id: bdnfcmanual
    type: RGB565
    use_transparency: true
  - file: 'bath/manual/BDQRManual.png'
    id: bdqrmanual
    type: RGB565
    use_transparency: true
#  - file: 'bath/manual/BDManually.png'
#    id: bdmanually
#    type: RGB565
#    use_transparency: true
# Temporary fix for displaying a background images (will be patched in 2025 after new year)
external_components:
  - source: github://pr#8005
    components: [lvgl]    

In the ESPHome world, I don’t think this is possible. You can make it act as a media player within HA (which isn’t what you want), but even then I’ve had relatively mixed results. It seems to be very finicky on the audio file requirements of what works and even then, I somewhat frequently experienced stuttering or other playback issues (and this was just using it to play a handful of announcement type audio files). I ultimately stopped using it.

As seen by the overwhelming response to this, I’m afraid you’re right.

Any suggestion on how to workaround this? Perhaps some kind of solution by installing squeezebox to a second ESP and have it controlled by the first ESP or so.

My worst case scenario will be a relay that switches a standard bluetooth speaker module on/of :joy: