|
|
há 2 semanas atrás | |
|---|---|---|
| .. | ||
| include | há 2 semanas atrás | |
| CMakeLists.txt | há 2 semanas atrás | |
| README.md | há 2 semanas atrás | |
| display.c | há 2 semanas atrás | |
| display_st7789.c | há 2 semanas atrás | |
| display_stub.c | há 2 semanas atrás | |
| idf_component.yml | há 2 semanas atrás | |
| make_background.py | há 2 semanas atrás | |
Supports two display families via a shared display_init() API:
| Driver | Display | Stack |
|---|---|---|
| OLED (u8g2) | SSD1306, SH1106, SSD1309, SH1107 — 128×64 or 128×32 | u8g2 + I2C/SPI |
| ST7789 TFT | 1.9" IPS 320×170 landscape | esp_lcd + LVGL 9 + esp_lvgl_port |
When the display is disabled (CONFIG_DISPLAY_ENABLED=n), only display_stub.c is compiled — zero runtime cost.
Compatible with all supported targets — ESP32, ESP32-S3, WROOM, Wrover, SqueezeAMP, Esparagus Audio Brick. No special memory requirements.
Requires ESP32-S3 with PSRAM. This driver is not viable on ESP32 (original) for the following reasons:
| Constraint | ESP32 (WROOM/Wrover) | ESP32-S3 N16R8 |
|---|---|---|
| Internal SRAM | 520 KB (shared with WiFi + audio) | 512 KB + 8 MB PSRAM |
| PSRAM | None (WROOM) / 4 MB (Wrover) | 8 MB |
| Flash | 4 MB | 16 MB |
LVGL 9 + the AirPlay audio pipeline + WiFi together exceed the internal SRAM budget on ESP32. Even with PSRAM, a Wrover's 4 MB flash is too constrained once the audio stack, SPIFFS partition, and LVGL assets are accounted for.
The idf_component.yml enforces this — LVGL and esp_lvgl_port are only declared as dependencies when target == esp32s3. Non-S3 builds are completely unaffected: the managed components are not downloaded, not compiled, and the OLED driver continues to work unchanged.
Tested on: ESP32-S3 N16R8 (16 MB flash, 8 MB PSRAM) with IDF 5.5.3.
Shows track title, artist, album, progress bar and playback time. Auto-scrolls long text. Compact two-line layout for 128×32 panels.
idf.py menuconfig
# AirPlay Receiver → Display Configuration → Enable display
# Select driver: SSD1306 / SH1106 / SSD1309
# Select bus: I2C or SPI
| OLED Pin | ESP32 GPIO |
|---|---|
| SDA | 21 |
| SCL | 22 |
| VCC | 3.3V |
| GND | GND |
Default I2C address: 0x3C. Change in menuconfig if your display uses 0x3D.
Full-colour display showing track metadata on a bitmap background with a progress bar and elapsed/remaining time. Requires ESP32-S3.
idf.py menuconfig
# AirPlay Receiver → Display Configuration → Enable display
# Select driver: ST7789 TFT (320×170 landscape)
Or add to sdkconfig.defaults.esp32s3:
CONFIG_DISPLAY_ENABLED=y
CONFIG_DISPLAY_DRIVER_ST7789=y
CONFIG_DISPLAY_SPI_CLK=18
CONFIG_DISPLAY_SPI_MOSI=17
CONFIG_DISPLAY_SPI_CS=15
CONFIG_DISPLAY_SPI_DC=16
CONFIG_DISPLAY_SPI_RST=21
CONFIG_DISPLAY_BL_GPIO=38
| Display Pin | ESP32-S3 GPIO | Function |
|---|---|---|
| SCL / CLK | 18 | SPI clock |
| SDA / MOSI | 17 | SPI data |
| CS | 15 | Chip select |
| DC / RS | 16 | Data / command select |
| RES / RST | 21 | Reset |
| BLK / BL | 38 | Backlight |
| VCC | 3.3V | Power |
| GND | GND | Ground |
The ST7789 driver loads a full-screen background image from SPIFFS at startup
(/spiffs/bg/background.bin) and renders it into PSRAM. All UI widgets (text,
progress bar) are drawn on top of it. If no file is present, the screen
defaults to solid black — all widgets still render correctly.
Run the conversion script from the project root:
python3 components/display/make_background.py <source.png> [brightness]
Brightness range: 0.4–0.6. Start at 0.5 — the ST7789 backlight renders
significantly brighter than a monitor. Adjust to taste.
The script writes data/bg/background.bin. Flash it to the device using
one of the two methods below.
Option A — Full SPIFFS flash (serial, first time or after firmware update):
idf.py flash # flashes firmware + SPIFFS image in one step
Option B — OTA upload (WiFi, no USB needed after first flash):
curl -X POST "http://<device-ip>/api/fs/upload?path=/spiffs/bg/background.bin" \
--data-binary @data/bg/background.bin
Then reboot the device — the new background loads on next boot.
Note: There is no web UI for background uploads. The device's web interface is for WiFi setup and device configuration only. Background updates are done via the
curlcommand above.
The ST7789 runs RGB565 (16-bit colour):
| Channel | Source | Display |
|---|---|---|
| Red | 8 bit (256 levels) | 5 bit (32 levels) |
| Green | 8 bit (256 levels) | 6 bit (64 levels) |
| Blue | 8 bit (256 levels) | 5 bit (32 levels) |
This causes visible banding on subtle dark gradients — a fundamental hardware limit with no complete software fix. Design backgrounds accordingly:
The LVGL v9 online converter (lvgl.io/tools/imageconverter) does not expose a
byte-swap option for RGB565. make_background.py gives full control and produces
the correct little-endian output confirmed working on this panel.
These are non-obvious integration requirements discovered through testing. They
apply to any project using this display with ESP-IDF + LVGL 9 + esp_lvgl_port.
lvgl_port_add_disp()esp_lvgl_port resets the ST7789 MADCTL register internally during
lvgl_port_add_disp(), wiping any rotation set beforehand. Always apply
swap_xy, mirror, and set_gap after that call.
s_lvgl_disp = lvgl_port_add_disp(&disp_cfg);
// Rotation AFTER — not before
ESP_ERROR_CHECK(esp_lcd_panel_swap_xy(panel_handle, true));
ESP_ERROR_CHECK(esp_lcd_panel_mirror(panel_handle, true, false));
ESP_ERROR_CHECK(esp_lcd_panel_set_gap(panel_handle, 0, 35));
This applies regardless of ESP32 variant.
The default task_affinity = -1 allows the LVGL task to migrate to Core 1.
On a dual-core build with audio on Core 1 (priority 7), this causes progressive
audio buffer backpressure — latency climbs from ~1800ms to 10000ms without
recovery, eventually causing stream misalignment.
Do not use ESP_LVGL_PORT_INIT_CONFIG() — it defaults to -1. Set explicitly:
const lvgl_port_cfg_t lvgl_cfg = {
.task_priority = 4,
.task_stack = 6144,
.task_affinity = 0, // Core 0 — keep Core 1 free for audio
.task_max_sleep_ms = 500,
.timer_period_ms = 5,
};
Rule: all display tasks on Core 0, all audio tasks on Core 1.
In this version of esp_lvgl_port, the flush callback passes the draw buffer
pointer directly to esp_lcd_panel_draw_bitmap — trans_size has no
effect. If the buffer is in PSRAM (buff_spiram=true), the SPI master falls
back to allocating a private DMA buffer from internal heap at runtime. Under
memory pressure this fails, causing a flush deadlock and watchdog reset.
Symptom: Failed to allocate priv TX buffer in the monitor, followed by a
taskLVGL watchdog.
.flags = {
.buff_dma = true, // internal DMA-capable SRAM
.buff_spiram = false, // NOT PSRAM
.swap_bytes = true,
},
Cost at 10 draw lines (double buffer): 12,800 bytes of internal SRAM.
Set trans_size = 0 — it is unused in this code path.
If the background image colours look wrong (washed out, swapped channels, or psychedelic), run this to confirm byte order before debugging anything else:
from PIL import Image, ImageDraw
bars = [
(255,255,255), (255,255,0), (0,255,255), (0,255,0),
(255,0,255), (255,0,0), (0,0,255),
]
img = Image.new('RGB', (320, 170))
draw = ImageDraw.Draw(img)
bar_w = 320 // len(bars)
for i, color in enumerate(bars):
draw.rectangle([i*bar_w, 0, i*bar_w+bar_w, 170], fill=color)
# Convert using make_background.py or the same byte-conversion loop
Expected on screen (left to right): White → Yellow → Cyan → Green → Magenta → Red → Blue
If this renders correctly, byte order is confirmed. If channels look wrong, the problem is in the conversion, not the display driver.
macOS caches AirPlay device state. If the ESP32 has been crashing on connection (e.g. the DMA watchdog above), macOS sees the device repeatedly disappear and becomes conservative — slower to offer or connect to it.
If AirPlay discovery seems slow or stale after a reflash, flush the macOS mDNS cache as a first diagnostic step:
sudo dscacheutil -flushcache
This rules out macOS holding stale state before assuming the issue is firmware-side. After stabilising the firmware, connections become near-instant.
This component was developed collaboratively with Claude (Anthropic). Claude contributed to the driver implementation, LVGL 9 migration, debugging of the non-obvious integration issues documented above, background image tooling, and this documentation.
All commits made directly by Claude include the following co-author tag:
Co-authored-by: Claude (Anthropic) <claude@anthropic.com>