Home Assistant – ESP32-S3 Dashboard

Introduction

The Elecrow CrowPanel 5.0″ is a fantastic little ESP32-S3 based touchscreen display — 800×480 resolution, capacitive touch, built-in WiFi and Bluetooth, and 8MB of PSRAM. It’s ideal for a wall-mounted Home Assistant control panel. But getting it running smoothly with ESPHome? That took some work.

This post walks through everything I learned building a fully-featured home control panel with climate control, Sonos/Spotify music playback with album art, lighting controls, and a screensaver — all running on ESPHome with LVGL.

What We’re Building

The finished panel has four pages navigated by a persistent header bar:

  • Climate Control — Current and target temperature display, temperature adjustment buttons, boost toggle with a fire icon, and HVAC mode display from a thermostat entity.
  • Music — Now-playing info with live album art from Sonos, transport controls (previous, play/pause, next), volume controls (up, down, mute), and six Spotify playlist preset buttons.
  • Lighting — Four toggle buttons for different room lights with on/off state indicators and colour changes.
  • Screensaver — After 60 seconds of inactivity, the display dims and shows a large red clock on a black background. Tap to wake and return to the last active page.

The Hardware

The CrowPanel 5.0″ uses an ESP32-S3-WROOM-1-N4R8 module with:

  • Dual-core 240MHz processor
  • 4MB Flash (important — not 16MB as some documentation suggests!)
  • 8MB Octal PSRAM
  • 800×480 RGB parallel TFT display
  • GT911 capacitive touchscreen controller
  • Backlight LED on GPIO2
  • I2C on GPIO19 (SDA) / GPIO20 (SCL)

Important: Before assuming your flash size, verify it with esptool.py --chip esp32s3 --port COMX flash_id. I initially configured for 16MB flash based on documentation, which caused a boot loop that required a full flash erase to recover from.

The Flicker Problem (And How to Fix It)

This is the single biggest issue you’ll encounter with the CrowPanel and ESPHome, and it’s worth addressing first because it affects everything else.

The CrowPanel uses an RGB parallel display interface. Unlike SPI displays where the MCU pushes frames, an RGB parallel display has its own DMA controller that constantly reads the framebuffer from memory — in this case, PSRAM. The problem is that PSRAM has limited bandwidth, and when the ESP32’s CPU is also reading/writing PSRAM for general operations, the display DMA can’t keep up. The result is visible flickering and tearing.

The fix is a set of ESP-IDF SDK configuration options that optimise how the ESP32-S3 uses its memory bus:

esp32:
  board: esp32-s3-devkitc-1
  framework:
    type: esp-idf
    sdkconfig_options:
      CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240: "y"
      CONFIG_ESP32S3_DATA_CACHE_64KB: "y"
      CONFIG_SPIRAM_FETCH_INSTRUCTIONS: y
      CONFIG_SPIRAM_RODATA: y

You also need to set the PSRAM bus mode in the PlatformIO options:

esphome:
  platformio_options:
    board_build.flash_mode: dio
    board_build.esp-idf.memory_type: qio_opi
    build_flags: "-DBOARD_HAS_PSRAM"

What these do:

  • DATA_CACHE_64KB — Doubles the ESP32-S3’s data cache from 32KB to 64KB, reducing how often the CPU needs to access PSRAM directly.
  • SPIRAM_FETCH_INSTRUCTIONS — Allows the CPU to execute code directly from PSRAM, freeing internal SRAM bandwidth.
  • SPIRAM_RODATA — Stores read-only data in PSRAM rather than internal RAM, again freeing bandwidth for the display DMA.
  • qio_opi memory type — Configures the PSRAM bus in octal QPI mode for maximum throughput.

I also found that the 5″ CrowPanel works best when you omit explicit hsync/vsync timing parameters and let ESPHome use its defaults — unlike the 7″ variant which needs specific timings from its display driver datasheet.

Critical: After changing sdkconfig_options, you must do a Clean Build (three-dot menu → Clean Build Files in ESPHome dashboard). These options are baked into the SDK configuration at compile time and won’t take effect from a cached build.

Display Configuration

The display section is fairly standard for the CrowPanel 5″:

display:
  - platform: rpi_dpi_rgb
    id: my_display
    auto_clear_enabled: false
    invert_colors: true
    update_interval: never
    data_pins:
      red: [45, 48, 47, 21, 14]
      green: [5, 6, 7, 15, 16, 4]
      blue: [8, 3, 46, 9, 1]
    de_pin: 40
    hsync_pin: 39
    vsync_pin: 41
    pclk_pin: 0
    pclk_inverted: true
    pclk_frequency: 12MHz
    color_order: RGB
    dimensions:
      width: 800
      height: 480

Key points: invert_colors: true is required for correct colour rendering on this panel. update_interval: never and auto_clear_enabled: false let LVGL manage display updates.

Event-Driven UI Updates (Not Polling)

My first attempt used a 1-second interval timer with a big lambda that polled every sensor and updated every label. This caused a subtle but infuriating problem: when adjusting the thermostat temperature from a browser, the panel would immediately reset it back because the interval lambda was continuously writing the old cached value.

The fix is to use event-driven callbacks — on_value for numeric sensors, on_state for binary sensors, and on_value for text sensors. The UI only updates when Home Assistant actually pushes a state change:

sensor:
  - platform: homeassistant
    id: thermostat_current_temp
    entity_id: climate.thermostat_2
    attribute: current_temperature
    on_value:
      then:
        - lambda: |-
            char buf[32];
            snprintf(buf, sizeof(buf), "%.1f°C", x);
            lv_label_set_text(id(current_temp_label), buf);

This also applies to the temperature adjustment buttons. Instead of using Jinja2 templates that ask HA to evaluate the current state (which can race with browser-based changes), use the locally cached sensor value:

on_click:
  - homeassistant.action:
      action: climate.set_temperature
      data:
        entity_id: climate.thermostat_2
      data_template:
        temperature: !lambda |-
          return to_string(id(thermostat_target_temp).state + 0.5);

LVGL Layout with Navigation

The UI uses LVGL pages with a persistent header bar in the top_layer (which renders above all pages). Three buttons in the header navigate between Climate, Music, and Lights pages:

lvgl:
  log_level: INFO
  color_depth: 16
  bg_color: 0x000000
  text_font: roboto24
  align: center

  top_layer:
    widgets:
      - obj:
          id: header_bar
          width: 800
          height: 60
          bg_color: 0x2196F3
          widgets:
            - label:
                text: "Home Control"
            - button:
                # Climate, Music, Lights buttons...
                on_click:
                  - lvgl.page.show: page_climate

I avoided using grey (0x757575) for “off” state colours on buttons — it caused noticeable flickering on this panel. A dark indigo (0x1A237E) works much better as an off-state colour and looks good against the dark background.

Sonos Music Control with Spotify Playlists

The music page tracks a Sonos speaker (media_player.living_room) with now-playing information, transport controls, and volume controls — all using Material Design Icons for clean button labels.

Six Spotify playlist buttons let you start music directly from the panel:

- button:
    bg_color: 0x1A237E
    radius: 6
    widgets:
      - label:
          text: "Chill Out"
    on_click:
      - homeassistant.action:
          action: media_player.play_media
          data:
            entity_id: media_player.living_room
            media_content_id: "spotify:playlist:4530EUiHb3H3OVxaOGRIan"
            media_content_type: "playlist"

You can grab playlist URIs from Spotify by right-clicking a playlist → Share → Copy link.

Album Art

ESPHome’s online_image component fetches album art dynamically. A text sensor watches the Sonos entity’s entity_picture attribute, and when the track changes, it constructs the full URL and triggers a download:

- platform: homeassistant
    id: media_player_art_url
    entity_id: media_player.living_room
    attribute: entity_picture
    on_value:
      then:
        - online_image.set_url:
            id: album_art
            url: !lambda |-
              std::string url = id(media_player_art_url).state;
              if (url.find("http") != 0) {
                return std::string("https://your-ha-url.com") + url;
              }
              return url;
        - component.update: album_art

Important: The Sonos media proxy returns JPEG images, so configure the online_image with format: JPEG. I initially had it set to PNG which caused “Incorrect PNG signature” errors. You also need an on_download_finished trigger to tell LVGL to re-render the image widget after the download completes.

Volume Mute Toggle

The mute button uses a Jinja2 template evaluated by HA to toggle the current mute state:

on_click:
  - homeassistant.action:
      action: media_player.volume_mute
      data:
        entity_id: media_player.your_speaker
      data_template:
        is_volume_muted: >-
          {{ not state_attr('media_player.your_speaker', 'is_volume_muted') }}

To provide visual feedback, a binary sensor tracks the is_volume_muted attribute and changes the mute button’s colour — red when muted, dark indigo when unmuted:

binary_sensor:
  - platform: homeassistant
    id: media_muted
    entity_id: media_player.your_speaker
    attribute: is_volume_muted
    on_state:
      then:
        - lambda: |-
            lv_obj_set_style_bg_color(id(mute_button),
              lv_color_hex(x ? 0xB71C1C : 0x1A237E), 0);

Background Image

With only 4MB flash, embedding a background image in the firmware isn’t practical (an 800×480 RGB565 image is ~750KB). Instead, the image loads at runtime via online_image into PSRAM:

online_image:
  - url: "https://your-ha-url.com/local/background.jpg"
    id: bg_image
    format: JPEG
    resize: 800x480
    type: RGB565
    update_interval: never
    on_download_finished:
      - lvgl.image.update:
          id: bg_image_climate
          src: bg_image

Place your background image in Home Assistant’s config/www/ folder. The device downloads it about 5 seconds after boot (to allow WiFi to connect first). Each LVGL page has an image widget that gets updated once the download finishes.

The page also sets a dark fallback bg_color so the display isn’t blank while the image loads.

Screensaver with Clock

After 60 seconds of no touch input, the display transitions to a screensaver — a black screen with a large dim red clock. Tapping anywhere wakes it back to the last active page.

This requires several components working together:

A global variable to track state:

globals:
  - id: screensaver_active
    type: bool
    initial_value: "false"
  - id: last_page
    type: int
    initial_value: "0"

Page tracking in the navigation buttons:

on_click:
  - globals.set:
      id: last_page
      value: "1"  # 0=Climate, 1=Music, 2=Lights
  - lvgl.page.show: page_music

Idle detection using LVGL’s built-in idle timer:

interval:
  - interval: 1s
    then:
      # Update clock while screensaver is active
      - if:
          condition:
            lambda: "return id(screensaver_active);"
          then:
            - lambda: |-
                auto time = id(ha_time).now();
                if (time.is_valid()) {
                  char buf[16];
                  snprintf(buf, sizeof(buf), "%02d:%02d", time.hour, time.minute);
                  lv_label_set_text(id(screensaver_clock), buf);
                }
      # Activate screensaver after 60 seconds idle
      - if:
          condition:
            and:
              - lambda: "return !id(screensaver_active);"
              - lvgl.is_idle:
                  timeout: 60s
          then:
            - globals.set:
                id: screensaver_active
                value: "true"
            - lvgl.widget.hide: header_bar
            - light.turn_on:
                id: backlight
                brightness: 30%
                transition_length: 2s
            - lvgl.page.show: page_screensaver

Touch-to-wake with page restoration:

touchscreen:
  - platform: gt911
    on_touch:
      - if:
          condition:
            lambda: "return id(screensaver_active);"
          then:
            - globals.set:
                id: screensaver_active
                value: "false"
            - light.turn_on:
                id: backlight
                brightness: 100%
            - lvgl.widget.show: header_bar
            # Restore last active page
            - if:
                condition:
                  lambda: "return id(last_page) == 0;"
                then:
                  - lvgl.page.show: page_climate
            # ... etc for other pages

The clock uses a dedicated 160px font with only digit and colon glyphs — this keeps the flash footprint minimal while filling about two-thirds of the screen. The header bar is hidden via lvgl.widget.hide when entering the screensaver and restored on wake.

Material Design Icons

Instead of text characters for buttons (“<<“, “>”, “>>”), the panel uses Material Design Icons loaded from the MDI webfont. ESPHome downloads the font at compile time — you only include the specific glyphs you need:

font:
  - file: "https://github.com/Templarian/MaterialDesign-Webfont/raw/master/fonts/materialdesignicons-webfont.ttf"
    id: mdi_transport
    size: 28
    bpp: 4
    glyphs:
      - "\U000F04AE"  # mdi:skip-previous
      - "\U000F040A"  # mdi:play
      - "\U000F03E4"  # mdi:pause
      - "\U000F04AD"  # mdi:skip-next
      - "\U000F075D"  # mdi:volume-plus
      - "\U000F075E"  # mdi:volume-minus
      - "\U000F0581"  # mdi:volume-off

You can find glyph codes at Pictogrammers MDI Library.

Lessons Learned

  1. Verify your flash size. Don’t trust documentation — use esptool.py flash_id. Setting the wrong flash size causes unrecoverable boot loops that require a USB erase.
  2. The PSRAM bandwidth fix is non-negotiable. Without the sdkconfig_options and qio_opi memory type, the display will flicker. This is the single most important configuration detail.
  3. Use event-driven updates, not polling. A 1-second interval timer updating all labels creates race conditions with other HA clients. Use on_value and on_state callbacks instead.
  4. Avoid grey for off-state buttons. Mid-tone greys cause visible flicker on this display. Dark blues and indigos work much better.
  5. online_image format must match the actual file. If the server returns JPEG, configure format: JPEG. The “Incorrect PNG signature” error is a dead giveaway.
  6. LVGL image widgets don’t auto-refresh. After downloading an online image, you must explicitly trigger lvgl.image.update to tell the widget to re-render.
  7. The top_layer persists across pages. If you put a header bar in top_layer, remember to hide it for things like screensavers.
  8. Clean Build after SDK config changes. The sdkconfig_options are baked in at compile time. Cached builds won’t pick up changes.

The Full Configuration

The complete YAML configuration is quite long (1000+ lines), so I’ve made it available as a downloadable file. It includes all the features described above: climate control, Sonos music with Spotify playlists and album art, four-zone lighting control, background image, and the screensaver with clock.

You’ll need to adjust the following for your setup:

  • Home Assistant entity IDs for your thermostat, media player, and lights
  • Your Home Assistant URL (for album art and background image)
  • Your Spotify playlist URIs
  • WiFi credentials in your secrets.yaml
  • API encryption key and OTA password
  • Roboto font files in a fonts/ folder alongside the YAML
  • A background image in your HA config/www/ folder

What’s Next

There’s plenty more that could be added — weather forecasts, calendar events, doorbell camera feeds, or even voice assistant integration using the ESP32-S3’s microphone support. The CrowPanel is a capable little device once you get past the initial configuration hurdles, and ESPHome’s LVGL support makes it genuinely useful as a permanent wall-mounted control panel.

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.