| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764 |
- /**
- * @file display_st7789.c
- * @brief ST7789 TFT display driver using esp_lcd + LVGL 9 (esp_lvgl_port)
- *
- * Implements the display_init() API for ST7789-based TFT displays.
- * Display: 320x170 pixels, landscape orientation, SPI interface.
- *
- * Background image is loaded at startup from SPIFFS (/spiffs/bg/background.bin)
- * — a raw RGB565 little-endian binary file. If the file is absent, the display
- * initialises normally with a blank (black) background. The background can be
- * updated without reflashing by uploading a new file via the HTTP file API.
- *
- * GPIO assignments (configured via sdkconfig / menuconfig):
- * CLK -> CONFIG_DISPLAY_SPI_CLK (default 18)
- * MOSI -> CONFIG_DISPLAY_SPI_MOSI (default 17)
- * CS -> CONFIG_DISPLAY_SPI_CS (default 15)
- * DC -> CONFIG_DISPLAY_SPI_DC (default 16)
- * RST -> CONFIG_DISPLAY_SPI_RST (default 21)
- * BL -> CONFIG_DISPLAY_BL_GPIO (default 38)
- */
- #include "display.h"
- #include "audio_output.h"
- #include "board_common.h"
- #include "playback_control.h"
- #include "rtsp_events.h"
- #include "esp_lcd_panel_io.h"
- #include "esp_lcd_panel_vendor.h"
- #include "esp_lcd_panel_ops.h"
- #include "esp_lvgl_port.h"
- #include "driver/spi_master.h"
- #include "driver/gpio.h"
- #include "esp_heap_caps.h"
- #include "esp_log.h"
- #include "esp_timer.h"
- #include "freertos/FreeRTOS.h"
- #include "freertos/task.h"
- #include "freertos/semphr.h"
- #include "lvgl.h"
- #include <stdio.h>
- #include <string.h>
- static const char *TAG = "display_st7789";
- // ============================================================================
- // Hardware configuration
- // ============================================================================
- #define DISPLAY_WIDTH CONFIG_DISPLAY_ST7789_WIDTH
- #define DISPLAY_HEIGHT CONFIG_DISPLAY_ST7789_HEIGHT
- #define LCD_HOST SPI2_HOST
- #define LCD_PIXEL_CLOCK_HZ (40 * 1000 * 1000)
- #define DRAW_BUF_LINES 10
- // Background image path on SPIFFS
- #define BG_SPIFFS_PATH "/spiffs/bg/background.bin"
- #define BG_EXPECTED_SIZE ((long)DISPLAY_WIDTH * DISPLAY_HEIGHT * 2) // RGB565
- // ============================================================================
- // Layout constants
- // ============================================================================
- #define X_MARGIN 22
- #define X_MARGIN_R (-22)
- #define Y_TITLE 10
- #define Y_ARTIST 44
- #define Y_ALBUM 69
- // Keep the status controls on-screen for both the original 320x170 ST7789
- // layout and the Waveshare 240x240 panel.
- #define Y_PROGRESS ((DISPLAY_HEIGHT >= 220) ? 148 : 114)
- #define Y_TIME (Y_PROGRESS + 18)
- #define Y_STATUS ((DISPLAY_HEIGHT >= 220) ? 188 : (DISPLAY_HEIGHT - 18))
- #define BAR_HEIGHT 12
- // ============================================================================
- // Display state
- // ============================================================================
- typedef enum {
- DISPLAY_STATE_STANDBY,
- DISPLAY_STATE_CONNECTED,
- DISPLAY_STATE_PLAYING,
- DISPLAY_STATE_PAUSED,
- } display_state_t;
- static struct {
- char title[METADATA_STRING_MAX];
- char artist[METADATA_STRING_MAX];
- char album[METADATA_STRING_MAX];
- uint32_t duration_secs;
- uint32_t position_secs;
- display_state_t state;
- volatile bool dirty; // written by RTSP callback, polled by display_task
- int64_t sync_time_us; // 64-bit — torn reads would cause position jumps
- } s_display;
- // Protects s_display against concurrent access from the RTSP event callback
- // thread and the display_task. Must be held around any multi-field read or
- // write (strings, sync_time_us, state+position together). Dirty is volatile
- // so the polling loop picks up the flag without holding the mutex.
- static SemaphoreHandle_t s_state_mutex = NULL;
- #define STATE_LOCK() xSemaphoreTake(s_state_mutex, portMAX_DELAY)
- #define STATE_UNLOCK() xSemaphoreGive(s_state_mutex)
- // ============================================================================
- // LVGL handles and widgets
- // ============================================================================
- static lv_display_t *s_lvgl_disp = NULL;
- static uint8_t *s_bg_buf = NULL;
- static lv_image_dsc_t s_bg_dsc;
- static lv_obj_t *s_label_title = NULL;
- static lv_obj_t *s_label_artist = NULL;
- static lv_obj_t *s_label_album = NULL;
- static lv_obj_t *s_label_muted = NULL;
- static lv_obj_t *s_label_status = NULL;
- static lv_obj_t *s_bar_progress = NULL;
- static lv_obj_t *s_label_time_elapsed = NULL;
- static lv_obj_t *s_label_time_remaining = NULL;
- static lv_obj_t *s_label_battery = NULL;
- static lv_obj_t *s_label_volume = NULL;
- // ============================================================================
- // Background loading
- // ============================================================================
- static bool bg_load_from_spiffs(void) {
- FILE *f = fopen(BG_SPIFFS_PATH, "rb");
- if (!f) {
- ESP_LOGI(TAG, "No background file at %s — using blank background",
- BG_SPIFFS_PATH);
- return false;
- }
- fseek(f, 0, SEEK_END);
- long size = ftell(f);
- fseek(f, 0, SEEK_SET);
- if (size != BG_EXPECTED_SIZE) {
- ESP_LOGW(TAG, "Background file wrong size: %ld (expected %d) — skipping",
- size, BG_EXPECTED_SIZE);
- fclose(f);
- return false;
- }
- s_bg_buf = heap_caps_malloc(size, MALLOC_CAP_SPIRAM);
- if (!s_bg_buf) {
- ESP_LOGE(TAG, "Failed to allocate %ld bytes in PSRAM for background", size);
- fclose(f);
- return false;
- }
- if (fread(s_bg_buf, 1, size, f) != (size_t)size) {
- ESP_LOGE(TAG, "Failed to read background file");
- fclose(f);
- heap_caps_free(s_bg_buf);
- s_bg_buf = NULL;
- return false;
- }
- fclose(f);
- memset(&s_bg_dsc, 0, sizeof(s_bg_dsc));
- s_bg_dsc.header.cf = LV_COLOR_FORMAT_RGB565;
- s_bg_dsc.header.magic = LV_IMAGE_HEADER_MAGIC;
- s_bg_dsc.header.w = DISPLAY_WIDTH;
- s_bg_dsc.header.h = DISPLAY_HEIGHT;
- s_bg_dsc.data_size = BG_EXPECTED_SIZE;
- s_bg_dsc.data = s_bg_buf;
- ESP_LOGI(TAG, "Background loaded from SPIFFS (%ld bytes, PSRAM)", size);
- return true;
- }
- // ============================================================================
- // Helpers
- // ============================================================================
- static uint32_t get_estimated_position(void) {
- uint32_t pos = s_display.position_secs;
- if (s_display.state == DISPLAY_STATE_PLAYING && s_display.sync_time_us > 0) {
- int64_t elapsed_us = esp_timer_get_time() - s_display.sync_time_us;
- uint32_t elapsed_secs = (uint32_t)(elapsed_us / 1000000);
- pos += elapsed_secs;
- if (s_display.duration_secs > 0 && pos > s_display.duration_secs) {
- pos = s_display.duration_secs;
- }
- }
- return pos;
- }
- static void format_time(uint32_t secs, char *buf, size_t len) {
- snprintf(buf, len, "%lu:%02lu", secs / 60, secs % 60);
- }
- static void format_remaining(uint32_t remaining_secs, char *buf, size_t len) {
- snprintf(buf, len, "-%lu:%02lu", remaining_secs / 60, remaining_secs % 60);
- }
- // ============================================================================
- // UI creation - called once after LVGL init, with lock held
- // ============================================================================
- static void ui_create(void) {
- lv_obj_t *scr = lv_screen_active();
- // Always set a solid black background — ensures a clean screen whether or
- // not a background image was loaded from SPIFFS. Without this, LVGL's
- // default theme leaves the screen white with unrendered display RAM noise.
- lv_obj_set_style_bg_color(scr, lv_color_black(), 0);
- lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
- // Background image — loaded from SPIFFS at display_init() time.
- // If no file was found, s_bg_buf is NULL and we skip the image widget,
- // leaving the screen black. All other widgets render normally on top.
- if (s_bg_buf) {
- lv_obj_t *bg = lv_image_create(scr);
- lv_image_set_src(bg, &s_bg_dsc);
- lv_obj_align(bg, LV_ALIGN_TOP_LEFT, 0, 0);
- }
- // Muted indicator — top-right corner, red, hidden by default
- s_label_muted = lv_label_create(scr);
- lv_obj_set_style_text_font(s_label_muted, &lv_font_montserrat_14, 0);
- lv_obj_set_style_text_color(s_label_muted, lv_color_make(255, 50, 50), 0);
- lv_obj_align(s_label_muted, LV_ALIGN_TOP_RIGHT, X_MARGIN_R, Y_TITLE);
- lv_obj_add_flag(s_label_muted, LV_OBJ_FLAG_HIDDEN);
- lv_label_set_text(s_label_muted, "MUTED");
- // Title — largest font, white, scrolling
- const lv_font_t *title_font =
- (DISPLAY_HEIGHT >= 220) ? &lv_font_montserrat_14 : &lv_font_montserrat_24;
- const lv_font_t *artist_font =
- (DISPLAY_HEIGHT >= 220) ? &lv_font_montserrat_14 : &lv_font_montserrat_16;
- s_label_title = lv_label_create(scr);
- lv_obj_set_width(s_label_title, DISPLAY_WIDTH - (X_MARGIN * 2));
- lv_label_set_long_mode(s_label_title, LV_LABEL_LONG_SCROLL_CIRCULAR);
- lv_obj_set_style_text_font(s_label_title, title_font, 0);
- lv_obj_set_style_text_color(s_label_title, lv_color_white(), 0);
- lv_obj_align(s_label_title, LV_ALIGN_TOP_LEFT, X_MARGIN, Y_TITLE);
- lv_label_set_text(s_label_title, "AirPlay Ready");
- // Artist — medium font, light grey, scrolling
- s_label_artist = lv_label_create(scr);
- lv_obj_set_width(s_label_artist, DISPLAY_WIDTH - (X_MARGIN * 2));
- lv_label_set_long_mode(s_label_artist, LV_LABEL_LONG_SCROLL_CIRCULAR);
- lv_obj_set_style_text_font(s_label_artist, artist_font, 0);
- lv_obj_set_style_text_color(s_label_artist, lv_color_make(180, 180, 180), 0);
- lv_obj_align(s_label_artist, LV_ALIGN_TOP_LEFT, X_MARGIN, Y_ARTIST);
- lv_label_set_text(s_label_artist, "");
- // Album — small font, dimmer grey, scrolling
- s_label_album = lv_label_create(scr);
- lv_obj_set_width(s_label_album, DISPLAY_WIDTH - (X_MARGIN * 2) - 60);
- lv_label_set_long_mode(s_label_album, LV_LABEL_LONG_SCROLL_CIRCULAR);
- lv_obj_set_style_text_font(s_label_album, &lv_font_montserrat_14, 0);
- lv_obj_set_style_text_color(s_label_album, lv_color_make(140, 140, 140), 0);
- lv_obj_align(s_label_album, LV_ALIGN_TOP_LEFT, X_MARGIN, Y_ALBUM);
- lv_label_set_text(s_label_album, "");
- // Paused status indicator — right side at album row, amber
- s_label_status = lv_label_create(scr);
- lv_obj_set_style_text_font(s_label_status, &lv_font_montserrat_14, 0);
- lv_obj_set_style_text_color(s_label_status, lv_color_make(255, 200, 0), 0);
- lv_obj_align(s_label_status, LV_ALIGN_TOP_RIGHT, X_MARGIN_R, Y_ALBUM);
- lv_label_set_text(s_label_status, "");
- // Progress bar — inset from border on both sides, rounded
- s_bar_progress = lv_bar_create(scr);
- lv_obj_set_size(s_bar_progress, DISPLAY_WIDTH - (X_MARGIN * 2), BAR_HEIGHT);
- lv_obj_align(s_bar_progress, LV_ALIGN_TOP_LEFT, X_MARGIN, Y_PROGRESS);
- lv_bar_set_range(s_bar_progress, 0, 100);
- lv_bar_set_value(s_bar_progress, 0, LV_ANIM_OFF);
- lv_obj_set_style_bg_color(s_bar_progress, lv_color_make(20, 20, 40), 0);
- lv_obj_set_style_bg_opa(s_bar_progress, LV_OPA_80, 0);
- lv_obj_set_style_bg_color(s_bar_progress, lv_color_make(30, 144, 255),
- LV_PART_INDICATOR);
- lv_obj_set_style_bg_opa(s_bar_progress, LV_OPA_COVER, LV_PART_INDICATOR);
- lv_obj_set_style_radius(s_bar_progress, 3, 0);
- lv_obj_set_style_radius(s_bar_progress, 3, LV_PART_INDICATOR);
- // Elapsed time — below bar, left aligned
- s_label_time_elapsed = lv_label_create(scr);
- lv_obj_set_style_text_font(s_label_time_elapsed, &lv_font_montserrat_14, 0);
- lv_obj_set_style_text_color(s_label_time_elapsed,
- lv_color_make(150, 150, 150), 0);
- lv_obj_align(s_label_time_elapsed, LV_ALIGN_TOP_LEFT, X_MARGIN, Y_TIME);
- lv_label_set_text(s_label_time_elapsed, "");
- // Remaining time — below bar, right aligned
- s_label_time_remaining = lv_label_create(scr);
- lv_obj_set_style_text_font(s_label_time_remaining, &lv_font_montserrat_14, 0);
- lv_obj_set_style_text_color(s_label_time_remaining,
- lv_color_make(150, 150, 150), 0);
- lv_obj_align(s_label_time_remaining, LV_ALIGN_TOP_RIGHT, X_MARGIN_R, Y_TIME);
- lv_label_set_text(s_label_time_remaining, "");
- // Volume — status row, bottom-left
- s_label_volume = lv_label_create(scr);
- lv_obj_set_style_text_font(s_label_volume, &lv_font_montserrat_14, 0);
- lv_obj_set_style_text_color(s_label_volume, lv_color_make(150, 150, 150), 0);
- lv_obj_align(s_label_volume, LV_ALIGN_TOP_LEFT, X_MARGIN, Y_STATUS);
- lv_label_set_text(s_label_volume, "");
- // Battery — status row, bottom-right
- s_label_battery = lv_label_create(scr);
- lv_obj_set_style_text_font(s_label_battery, &lv_font_montserrat_14, 0);
- lv_obj_set_style_text_color(s_label_battery, lv_color_make(150, 150, 150), 0);
- lv_obj_align(s_label_battery, LV_ALIGN_TOP_RIGHT, X_MARGIN_R, Y_STATUS);
- lv_label_set_text(s_label_battery, "");
- }
- // Update the battery + volume status row. Called from ui_update() with the
- // LVGL port lock already held.
- static void ui_update_status_row(void) {
- // Volume — show as percentage, with the channel mode (L+R / L / R)
- const char *chan;
- switch (audio_output_get_channel_mode()) {
- case AUDIO_CHANNEL_LEFT:
- chan = "L";
- break;
- case AUDIO_CHANNEL_RIGHT:
- chan = "R";
- break;
- default:
- chan = "L+R";
- break;
- }
- char vol_str[24];
- snprintf(vol_str, sizeof(vol_str), LV_SYMBOL_VOLUME_MAX " %d%% %s",
- playback_control_get_volume_percent(), chan);
- lv_label_set_text(s_label_volume, vol_str);
- // Battery — only if the board reports one
- int pct = 0;
- bool charging = false;
- if (board_battery_read(&pct, &charging)) {
- const char *icon;
- if (charging) {
- icon = LV_SYMBOL_USB; // plugged into USB / charging
- } else if (pct >= 80) {
- icon = LV_SYMBOL_BATTERY_FULL;
- } else if (pct >= 60) {
- icon = LV_SYMBOL_BATTERY_3;
- } else if (pct >= 40) {
- icon = LV_SYMBOL_BATTERY_2;
- } else if (pct >= 20) {
- icon = LV_SYMBOL_BATTERY_1;
- } else {
- icon = LV_SYMBOL_BATTERY_EMPTY;
- }
- char bat_str[16];
- snprintf(bat_str, sizeof(bat_str), "%s %d%%", icon, pct);
- lv_label_set_text(s_label_battery, bat_str);
- // Tint red when low and not charging
- lv_color_t color = (!charging && pct <= 20) ? lv_color_make(255, 60, 60)
- : lv_color_make(150, 150, 150);
- lv_obj_set_style_text_color(s_label_battery, color, 0);
- } else {
- lv_label_set_text(s_label_battery, "");
- }
- }
- // ============================================================================
- // UI update
- // ============================================================================
- static void ui_update(void) {
- // Snapshot shared state under s_state_mutex. This avoids torn reads of
- // sync_time_us (int64, two-word on Xtensa) and guarantees consistent
- // strings vs. state. The snapshot is then rendered without the state
- // mutex held, so the RTSP callback can't be blocked by LVGL rendering.
- char title[METADATA_STRING_MAX];
- char artist[METADATA_STRING_MAX];
- char album[METADATA_STRING_MAX];
- uint32_t duration_secs;
- uint32_t position_secs;
- int64_t sync_time_us;
- display_state_t state;
- STATE_LOCK();
- memcpy(title, s_display.title, sizeof(title));
- memcpy(artist, s_display.artist, sizeof(artist));
- memcpy(album, s_display.album, sizeof(album));
- duration_secs = s_display.duration_secs;
- position_secs = s_display.position_secs;
- sync_time_us = s_display.sync_time_us;
- state = s_display.state;
- STATE_UNLOCK();
- // Defensive NUL termination — if the RTSP producer ever fills all
- // METADATA_STRING_MAX bytes without a terminator, lv_label_set_text
- // would read off the end.
- title[METADATA_STRING_MAX - 1] = '\0';
- artist[METADATA_STRING_MAX - 1] = '\0';
- album[METADATA_STRING_MAX - 1] = '\0';
- if (!lvgl_port_lock(100)) {
- ESP_LOGW(TAG, "ui_update: lock timeout");
- return;
- }
- switch (state) {
- case DISPLAY_STATE_STANDBY:
- lv_label_set_text(s_label_title, "AirPlay Ready");
- lv_label_set_text(s_label_artist, "");
- lv_label_set_text(s_label_album, "");
- lv_label_set_text(s_label_status, "");
- lv_label_set_text(s_label_time_elapsed, "");
- lv_label_set_text(s_label_time_remaining, "");
- lv_bar_set_value(s_bar_progress, 0, LV_ANIM_OFF);
- lv_obj_add_flag(s_label_muted, LV_OBJ_FLAG_HIDDEN);
- break;
- case DISPLAY_STATE_CONNECTED:
- lv_label_set_text(s_label_title, "Connected");
- lv_label_set_text(s_label_artist, "");
- lv_label_set_text(s_label_album, "");
- lv_label_set_text(s_label_status, "");
- lv_label_set_text(s_label_time_elapsed, "");
- lv_label_set_text(s_label_time_remaining, "");
- lv_bar_set_value(s_bar_progress, 0, LV_ANIM_OFF);
- lv_obj_add_flag(s_label_muted, LV_OBJ_FLAG_HIDDEN);
- break;
- case DISPLAY_STATE_PLAYING:
- case DISPLAY_STATE_PAUSED: {
- lv_label_set_text(s_label_title, title[0] ? title : "---");
- lv_label_set_text(s_label_artist, artist[0] ? artist : "");
- lv_label_set_text(s_label_album, album[0] ? album : "");
- lv_label_set_text(s_label_status,
- state == DISPLAY_STATE_PAUSED ? "|| " : "");
- // Muted indicator
- if (playback_control_is_muted()) {
- lv_obj_clear_flag(s_label_muted, LV_OBJ_FLAG_HIDDEN);
- } else {
- lv_obj_add_flag(s_label_muted, LV_OBJ_FLAG_HIDDEN);
- }
- uint32_t pos = position_secs;
- if (state == DISPLAY_STATE_PLAYING && sync_time_us > 0) {
- int64_t elapsed_us = esp_timer_get_time() - sync_time_us;
- uint32_t elapsed_secs = (uint32_t)(elapsed_us / 1000000);
- pos += elapsed_secs;
- if (duration_secs > 0 && pos > duration_secs) {
- pos = duration_secs;
- }
- }
- if (duration_secs > 0) {
- int pct = (int)((uint64_t)pos * 100 / duration_secs);
- lv_bar_set_value(s_bar_progress, pct, LV_ANIM_OFF);
- char elapsed_str[12];
- format_time(pos, elapsed_str, sizeof(elapsed_str));
- lv_label_set_text(s_label_time_elapsed, elapsed_str);
- uint32_t remaining = (pos <= duration_secs) ? duration_secs - pos : 0;
- char remaining_str[16];
- format_remaining(remaining, remaining_str, sizeof(remaining_str));
- lv_label_set_text(s_label_time_remaining, remaining_str);
- } else {
- lv_bar_set_value(s_bar_progress, 0, LV_ANIM_OFF);
- lv_label_set_text(s_label_time_elapsed, "");
- lv_label_set_text(s_label_time_remaining, "");
- }
- break;
- }
- }
- ui_update_status_row();
- lvgl_port_unlock();
- }
- // ============================================================================
- // RTSP event callback
- // ============================================================================
- static void on_rtsp_event(rtsp_event_t event, const rtsp_event_data_t *data,
- void *user_data) {
- (void)user_data;
- // All mutations of s_display happen under the state mutex so reads in
- // display_task see a consistent snapshot. The callback runs in the RTSP
- // event thread (not an ISR), so blocking on a FreeRTOS mutex is safe.
- STATE_LOCK();
- switch (event) {
- case RTSP_EVENT_CLIENT_CONNECTED:
- s_display.state = DISPLAY_STATE_CONNECTED;
- memset(s_display.title, 0, sizeof(s_display.title));
- memset(s_display.artist, 0, sizeof(s_display.artist));
- memset(s_display.album, 0, sizeof(s_display.album));
- s_display.duration_secs = 0;
- s_display.position_secs = 0;
- s_display.sync_time_us = 0;
- s_display.dirty = true;
- break;
- case RTSP_EVENT_PLAYING:
- s_display.state = DISPLAY_STATE_PLAYING;
- s_display.sync_time_us = esp_timer_get_time();
- s_display.dirty = true;
- break;
- case RTSP_EVENT_PAUSED:
- s_display.position_secs = get_estimated_position();
- s_display.sync_time_us = 0;
- s_display.state = DISPLAY_STATE_PAUSED;
- s_display.dirty = true;
- break;
- case RTSP_EVENT_DISCONNECTED:
- s_display.state = DISPLAY_STATE_STANDBY;
- memset(s_display.title, 0, sizeof(s_display.title));
- memset(s_display.artist, 0, sizeof(s_display.artist));
- memset(s_display.album, 0, sizeof(s_display.album));
- s_display.duration_secs = 0;
- s_display.position_secs = 0;
- s_display.sync_time_us = 0;
- s_display.dirty = true;
- break;
- case RTSP_EVENT_METADATA:
- if (data) {
- bool track_changed = data->metadata.title[0] &&
- strcmp(data->metadata.title, s_display.title) != 0;
- if (data->metadata.title[0]) {
- memcpy(s_display.title, data->metadata.title, METADATA_STRING_MAX);
- s_display.title[METADATA_STRING_MAX - 1] = '\0';
- }
- if (data->metadata.artist[0]) {
- memcpy(s_display.artist, data->metadata.artist, METADATA_STRING_MAX);
- s_display.artist[METADATA_STRING_MAX - 1] = '\0';
- }
- if (data->metadata.album[0]) {
- memcpy(s_display.album, data->metadata.album, METADATA_STRING_MAX);
- s_display.album[METADATA_STRING_MAX - 1] = '\0';
- }
- if (data->metadata.duration_secs) {
- s_display.duration_secs = data->metadata.duration_secs;
- }
- if (track_changed || data->metadata.position_secs ||
- s_display.position_secs == 0) {
- s_display.position_secs = data->metadata.position_secs;
- }
- s_display.sync_time_us = esp_timer_get_time();
- s_display.dirty = true;
- }
- break;
- }
- STATE_UNLOCK();
- }
- // ============================================================================
- // Display task
- // ============================================================================
- static void display_task(void *pvParameters) {
- (void)pvParameters;
- while (1) {
- // Consume dirty under the state mutex so a concurrent set in the
- // RTSP callback is never lost (clear-after-set ordering).
- bool need_update = false;
- display_state_t state;
- STATE_LOCK();
- if (s_display.dirty) {
- s_display.dirty = false;
- need_update = true;
- }
- state = s_display.state;
- STATE_UNLOCK();
- if (need_update) {
- ui_update();
- }
- static TickType_t last_progress_update = 0;
- TickType_t now = xTaskGetTickCount();
- if (state == DISPLAY_STATE_PLAYING &&
- (now - last_progress_update) >= pdMS_TO_TICKS(1000)) {
- last_progress_update = now;
- ui_update();
- }
- // Refresh battery + volume + channel mode periodically even when idle.
- // These are not tied to RTSP events; poll at 1s so an interactive change
- // (volume, double-click channel toggle) shows up promptly.
- static TickType_t last_status_update = 0;
- if (!need_update && (now - last_status_update) >= pdMS_TO_TICKS(1000)) {
- last_status_update = now;
- if (lvgl_port_lock(100)) {
- ui_update_status_row();
- lvgl_port_unlock();
- }
- }
- vTaskDelay(pdMS_TO_TICKS(30));
- }
- }
- // ============================================================================
- // Initialization
- // ============================================================================
- void display_init(void *bus) {
- s_state_mutex = xSemaphoreCreateMutex();
- assert(s_state_mutex != NULL);
- // The main/main.c contract (from PR #59) is:
- // bus != NULL → a pre-initialised spi_host_device_t passed as
- // (void*)(intptr_t)host. Use it and skip our own
- // spi_bus_initialize() to share the board's SPI bus.
- // bus == NULL → fall back to initialising our own SPI bus from the
- // GPIO pins in Kconfig. Used by boards that don't
- // expose a shared SPI bus for the display.
- spi_host_device_t spi_host =
- (bus != NULL) ? (spi_host_device_t)(intptr_t)bus : LCD_HOST;
- ESP_LOGI(TAG,
- "Initializing ST7789 (%dx%d landscape) host=%d "
- "CLK=%d MOSI=%d CS=%d DC=%d RST=%d BL=%d",
- DISPLAY_WIDTH, DISPLAY_HEIGHT, (int)spi_host, CONFIG_DISPLAY_SPI_CLK,
- CONFIG_DISPLAY_SPI_MOSI, CONFIG_DISPLAY_SPI_CS,
- CONFIG_DISPLAY_SPI_DC, CONFIG_DISPLAY_SPI_RST,
- CONFIG_DISPLAY_BL_GPIO);
- // Backlight GPIO is optional — a value of -1 means "not wired / always on",
- // which matches the Kconfig default. BIT64(-1) is undefined, and
- // gpio_set_level(-1, ...) returns ESP_ERR_INVALID_ARG, so skip the config
- // entirely when the pin is not set.
- if (CONFIG_DISPLAY_BL_GPIO >= 0) {
- gpio_config_t bl_cfg = {
- .pin_bit_mask = BIT64(CONFIG_DISPLAY_BL_GPIO),
- .mode = GPIO_MODE_OUTPUT,
- };
- ESP_ERROR_CHECK(gpio_config(&bl_cfg));
- gpio_set_level(CONFIG_DISPLAY_BL_GPIO, 0);
- }
- bg_load_from_spiffs();
- // Only initialise the SPI bus ourselves if the caller didn't pass a
- // pre-initialised one. Calling spi_bus_initialize() on a host that is
- // already initialised returns ESP_ERR_INVALID_STATE.
- if (bus == NULL) {
- spi_bus_config_t buscfg = {
- .mosi_io_num = CONFIG_DISPLAY_SPI_MOSI,
- .miso_io_num = -1,
- .sclk_io_num = CONFIG_DISPLAY_SPI_CLK,
- .quadwp_io_num = -1,
- .quadhd_io_num = -1,
- .max_transfer_sz = DISPLAY_WIDTH * DRAW_BUF_LINES * sizeof(uint16_t),
- };
- ESP_ERROR_CHECK(spi_bus_initialize(spi_host, &buscfg, SPI_DMA_CH_AUTO));
- }
- esp_lcd_panel_io_handle_t io_handle = NULL;
- esp_lcd_panel_io_spi_config_t io_cfg = {
- .dc_gpio_num = CONFIG_DISPLAY_SPI_DC,
- .cs_gpio_num = CONFIG_DISPLAY_SPI_CS,
- .pclk_hz = LCD_PIXEL_CLOCK_HZ,
- .lcd_cmd_bits = 8,
- .lcd_param_bits = 8,
- .spi_mode = 0,
- .trans_queue_depth = 4,
- };
- ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)spi_host,
- &io_cfg, &io_handle));
- esp_lcd_panel_handle_t panel_handle = NULL;
- esp_lcd_panel_dev_config_t panel_cfg = {
- .reset_gpio_num = CONFIG_DISPLAY_SPI_RST,
- .rgb_endian = LCD_RGB_ENDIAN_RGB,
- .bits_per_pixel = 16,
- };
- ESP_ERROR_CHECK(
- esp_lcd_new_panel_st7789(io_handle, &panel_cfg, &panel_handle));
- ESP_ERROR_CHECK(esp_lcd_panel_reset(panel_handle));
- ESP_ERROR_CHECK(esp_lcd_panel_init(panel_handle));
- ESP_ERROR_CHECK(esp_lcd_panel_invert_color(panel_handle, true));
- ESP_ERROR_CHECK(esp_lcd_panel_disp_on_off(panel_handle, true));
- // Pin the LVGL task to Core 0. Default task_affinity=-1 allows migration
- // to Core 1 where it interferes with the audio task (priority 7).
- const lvgl_port_cfg_t lvgl_cfg = {
- .task_priority = 4,
- .task_stack = 6144,
- .task_affinity = 0,
- .task_max_sleep_ms = 500,
- .timer_period_ms = 5,
- };
- ESP_ERROR_CHECK(lvgl_port_init(&lvgl_cfg));
- // buff_dma=true, buff_spiram=false: draw buffers in DMA-capable internal
- // SRAM. esp_lvgl_port passes the buffer pointer directly to
- // esp_lcd_panel_draw_bitmap — PSRAM buffers cause SPI master to allocate
- // a private DMA buffer at runtime, which fails under memory pressure.
- const lvgl_port_display_cfg_t disp_cfg = {
- .io_handle = io_handle,
- .panel_handle = panel_handle,
- .buffer_size = DISPLAY_WIDTH * DRAW_BUF_LINES,
- .double_buffer = true,
- .trans_size = 0,
- .hres = DISPLAY_WIDTH,
- .vres = DISPLAY_HEIGHT,
- .monochrome = false,
- .flags =
- {
- .buff_dma = true,
- .buff_spiram = false,
- .swap_bytes = true,
- },
- };
- s_lvgl_disp = lvgl_port_add_disp(&disp_cfg);
- assert(s_lvgl_disp != NULL);
- // Rotation MUST be applied after lvgl_port_add_disp() — the port resets
- // the ST7789 MADCTL register during display registration.
- 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, CONFIG_DISPLAY_ST7789_GAP_X, CONFIG_DISPLAY_ST7789_GAP_Y));
- // Acquire the LVGL port mutex before touching LVGL widgets. The port task
- // is already running at this point, so we must wait on the lock rather
- // than pass 0 (non-waiting try). If the lock cannot be acquired within a
- // generous timeout, init has gone wrong — abort rather than corrupt LVGL
- // state by calling ui_create() unlocked.
- if (lvgl_port_lock(1000)) {
- ui_create();
- lvgl_port_unlock();
- } else {
- ESP_LOGE(TAG, "Failed to acquire LVGL lock during init — UI not built");
- abort();
- }
- if (CONFIG_DISPLAY_BL_GPIO >= 0) {
- gpio_set_level(CONFIG_DISPLAY_BL_GPIO, 1);
- }
- s_display.state = DISPLAY_STATE_STANDBY;
- s_display.dirty = true;
- rtsp_events_register(on_rtsp_event, NULL);
- // Pinned to Core 0 — audio runs on Core 1
- xTaskCreatePinnedToCore(display_task, "display", 4096, NULL, 3, NULL, 0);
- ESP_LOGI(TAG, "ST7789 display initialized (LVGL 9 + esp_lvgl_port)");
- }
|