display_st7789.c 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764
  1. /**
  2. * @file display_st7789.c
  3. * @brief ST7789 TFT display driver using esp_lcd + LVGL 9 (esp_lvgl_port)
  4. *
  5. * Implements the display_init() API for ST7789-based TFT displays.
  6. * Display: 320x170 pixels, landscape orientation, SPI interface.
  7. *
  8. * Background image is loaded at startup from SPIFFS (/spiffs/bg/background.bin)
  9. * — a raw RGB565 little-endian binary file. If the file is absent, the display
  10. * initialises normally with a blank (black) background. The background can be
  11. * updated without reflashing by uploading a new file via the HTTP file API.
  12. *
  13. * GPIO assignments (configured via sdkconfig / menuconfig):
  14. * CLK -> CONFIG_DISPLAY_SPI_CLK (default 18)
  15. * MOSI -> CONFIG_DISPLAY_SPI_MOSI (default 17)
  16. * CS -> CONFIG_DISPLAY_SPI_CS (default 15)
  17. * DC -> CONFIG_DISPLAY_SPI_DC (default 16)
  18. * RST -> CONFIG_DISPLAY_SPI_RST (default 21)
  19. * BL -> CONFIG_DISPLAY_BL_GPIO (default 38)
  20. */
  21. #include "display.h"
  22. #include "audio_output.h"
  23. #include "board_common.h"
  24. #include "playback_control.h"
  25. #include "rtsp_events.h"
  26. #include "esp_lcd_panel_io.h"
  27. #include "esp_lcd_panel_vendor.h"
  28. #include "esp_lcd_panel_ops.h"
  29. #include "esp_lvgl_port.h"
  30. #include "driver/spi_master.h"
  31. #include "driver/gpio.h"
  32. #include "esp_heap_caps.h"
  33. #include "esp_log.h"
  34. #include "esp_timer.h"
  35. #include "freertos/FreeRTOS.h"
  36. #include "freertos/task.h"
  37. #include "freertos/semphr.h"
  38. #include "lvgl.h"
  39. #include <stdio.h>
  40. #include <string.h>
  41. static const char *TAG = "display_st7789";
  42. // ============================================================================
  43. // Hardware configuration
  44. // ============================================================================
  45. #define DISPLAY_WIDTH CONFIG_DISPLAY_ST7789_WIDTH
  46. #define DISPLAY_HEIGHT CONFIG_DISPLAY_ST7789_HEIGHT
  47. #define LCD_HOST SPI2_HOST
  48. #define LCD_PIXEL_CLOCK_HZ (40 * 1000 * 1000)
  49. #define DRAW_BUF_LINES 10
  50. // Background image path on SPIFFS
  51. #define BG_SPIFFS_PATH "/spiffs/bg/background.bin"
  52. #define BG_EXPECTED_SIZE ((long)DISPLAY_WIDTH * DISPLAY_HEIGHT * 2) // RGB565
  53. // ============================================================================
  54. // Layout constants
  55. // ============================================================================
  56. #define X_MARGIN 22
  57. #define X_MARGIN_R (-22)
  58. #define Y_TITLE 10
  59. #define Y_ARTIST 44
  60. #define Y_ALBUM 69
  61. // Keep the status controls on-screen for both the original 320x170 ST7789
  62. // layout and the Waveshare 240x240 panel.
  63. #define Y_PROGRESS ((DISPLAY_HEIGHT >= 220) ? 148 : 114)
  64. #define Y_TIME (Y_PROGRESS + 18)
  65. #define Y_STATUS ((DISPLAY_HEIGHT >= 220) ? 188 : (DISPLAY_HEIGHT - 18))
  66. #define BAR_HEIGHT 12
  67. // ============================================================================
  68. // Display state
  69. // ============================================================================
  70. typedef enum {
  71. DISPLAY_STATE_STANDBY,
  72. DISPLAY_STATE_CONNECTED,
  73. DISPLAY_STATE_PLAYING,
  74. DISPLAY_STATE_PAUSED,
  75. } display_state_t;
  76. static struct {
  77. char title[METADATA_STRING_MAX];
  78. char artist[METADATA_STRING_MAX];
  79. char album[METADATA_STRING_MAX];
  80. uint32_t duration_secs;
  81. uint32_t position_secs;
  82. display_state_t state;
  83. volatile bool dirty; // written by RTSP callback, polled by display_task
  84. int64_t sync_time_us; // 64-bit — torn reads would cause position jumps
  85. } s_display;
  86. // Protects s_display against concurrent access from the RTSP event callback
  87. // thread and the display_task. Must be held around any multi-field read or
  88. // write (strings, sync_time_us, state+position together). Dirty is volatile
  89. // so the polling loop picks up the flag without holding the mutex.
  90. static SemaphoreHandle_t s_state_mutex = NULL;
  91. #define STATE_LOCK() xSemaphoreTake(s_state_mutex, portMAX_DELAY)
  92. #define STATE_UNLOCK() xSemaphoreGive(s_state_mutex)
  93. // ============================================================================
  94. // LVGL handles and widgets
  95. // ============================================================================
  96. static lv_display_t *s_lvgl_disp = NULL;
  97. static uint8_t *s_bg_buf = NULL;
  98. static lv_image_dsc_t s_bg_dsc;
  99. static lv_obj_t *s_label_title = NULL;
  100. static lv_obj_t *s_label_artist = NULL;
  101. static lv_obj_t *s_label_album = NULL;
  102. static lv_obj_t *s_label_muted = NULL;
  103. static lv_obj_t *s_label_status = NULL;
  104. static lv_obj_t *s_bar_progress = NULL;
  105. static lv_obj_t *s_label_time_elapsed = NULL;
  106. static lv_obj_t *s_label_time_remaining = NULL;
  107. static lv_obj_t *s_label_battery = NULL;
  108. static lv_obj_t *s_label_volume = NULL;
  109. // ============================================================================
  110. // Background loading
  111. // ============================================================================
  112. static bool bg_load_from_spiffs(void) {
  113. FILE *f = fopen(BG_SPIFFS_PATH, "rb");
  114. if (!f) {
  115. ESP_LOGI(TAG, "No background file at %s — using blank background",
  116. BG_SPIFFS_PATH);
  117. return false;
  118. }
  119. fseek(f, 0, SEEK_END);
  120. long size = ftell(f);
  121. fseek(f, 0, SEEK_SET);
  122. if (size != BG_EXPECTED_SIZE) {
  123. ESP_LOGW(TAG, "Background file wrong size: %ld (expected %d) — skipping",
  124. size, BG_EXPECTED_SIZE);
  125. fclose(f);
  126. return false;
  127. }
  128. s_bg_buf = heap_caps_malloc(size, MALLOC_CAP_SPIRAM);
  129. if (!s_bg_buf) {
  130. ESP_LOGE(TAG, "Failed to allocate %ld bytes in PSRAM for background", size);
  131. fclose(f);
  132. return false;
  133. }
  134. if (fread(s_bg_buf, 1, size, f) != (size_t)size) {
  135. ESP_LOGE(TAG, "Failed to read background file");
  136. fclose(f);
  137. heap_caps_free(s_bg_buf);
  138. s_bg_buf = NULL;
  139. return false;
  140. }
  141. fclose(f);
  142. memset(&s_bg_dsc, 0, sizeof(s_bg_dsc));
  143. s_bg_dsc.header.cf = LV_COLOR_FORMAT_RGB565;
  144. s_bg_dsc.header.magic = LV_IMAGE_HEADER_MAGIC;
  145. s_bg_dsc.header.w = DISPLAY_WIDTH;
  146. s_bg_dsc.header.h = DISPLAY_HEIGHT;
  147. s_bg_dsc.data_size = BG_EXPECTED_SIZE;
  148. s_bg_dsc.data = s_bg_buf;
  149. ESP_LOGI(TAG, "Background loaded from SPIFFS (%ld bytes, PSRAM)", size);
  150. return true;
  151. }
  152. // ============================================================================
  153. // Helpers
  154. // ============================================================================
  155. static uint32_t get_estimated_position(void) {
  156. uint32_t pos = s_display.position_secs;
  157. if (s_display.state == DISPLAY_STATE_PLAYING && s_display.sync_time_us > 0) {
  158. int64_t elapsed_us = esp_timer_get_time() - s_display.sync_time_us;
  159. uint32_t elapsed_secs = (uint32_t)(elapsed_us / 1000000);
  160. pos += elapsed_secs;
  161. if (s_display.duration_secs > 0 && pos > s_display.duration_secs) {
  162. pos = s_display.duration_secs;
  163. }
  164. }
  165. return pos;
  166. }
  167. static void format_time(uint32_t secs, char *buf, size_t len) {
  168. snprintf(buf, len, "%lu:%02lu", secs / 60, secs % 60);
  169. }
  170. static void format_remaining(uint32_t remaining_secs, char *buf, size_t len) {
  171. snprintf(buf, len, "-%lu:%02lu", remaining_secs / 60, remaining_secs % 60);
  172. }
  173. // ============================================================================
  174. // UI creation - called once after LVGL init, with lock held
  175. // ============================================================================
  176. static void ui_create(void) {
  177. lv_obj_t *scr = lv_screen_active();
  178. // Always set a solid black background — ensures a clean screen whether or
  179. // not a background image was loaded from SPIFFS. Without this, LVGL's
  180. // default theme leaves the screen white with unrendered display RAM noise.
  181. lv_obj_set_style_bg_color(scr, lv_color_black(), 0);
  182. lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
  183. // Background image — loaded from SPIFFS at display_init() time.
  184. // If no file was found, s_bg_buf is NULL and we skip the image widget,
  185. // leaving the screen black. All other widgets render normally on top.
  186. if (s_bg_buf) {
  187. lv_obj_t *bg = lv_image_create(scr);
  188. lv_image_set_src(bg, &s_bg_dsc);
  189. lv_obj_align(bg, LV_ALIGN_TOP_LEFT, 0, 0);
  190. }
  191. // Muted indicator — top-right corner, red, hidden by default
  192. s_label_muted = lv_label_create(scr);
  193. lv_obj_set_style_text_font(s_label_muted, &lv_font_montserrat_14, 0);
  194. lv_obj_set_style_text_color(s_label_muted, lv_color_make(255, 50, 50), 0);
  195. lv_obj_align(s_label_muted, LV_ALIGN_TOP_RIGHT, X_MARGIN_R, Y_TITLE);
  196. lv_obj_add_flag(s_label_muted, LV_OBJ_FLAG_HIDDEN);
  197. lv_label_set_text(s_label_muted, "MUTED");
  198. // Title — largest font, white, scrolling
  199. const lv_font_t *title_font =
  200. (DISPLAY_HEIGHT >= 220) ? &lv_font_montserrat_14 : &lv_font_montserrat_24;
  201. const lv_font_t *artist_font =
  202. (DISPLAY_HEIGHT >= 220) ? &lv_font_montserrat_14 : &lv_font_montserrat_16;
  203. s_label_title = lv_label_create(scr);
  204. lv_obj_set_width(s_label_title, DISPLAY_WIDTH - (X_MARGIN * 2));
  205. lv_label_set_long_mode(s_label_title, LV_LABEL_LONG_SCROLL_CIRCULAR);
  206. lv_obj_set_style_text_font(s_label_title, title_font, 0);
  207. lv_obj_set_style_text_color(s_label_title, lv_color_white(), 0);
  208. lv_obj_align(s_label_title, LV_ALIGN_TOP_LEFT, X_MARGIN, Y_TITLE);
  209. lv_label_set_text(s_label_title, "AirPlay Ready");
  210. // Artist — medium font, light grey, scrolling
  211. s_label_artist = lv_label_create(scr);
  212. lv_obj_set_width(s_label_artist, DISPLAY_WIDTH - (X_MARGIN * 2));
  213. lv_label_set_long_mode(s_label_artist, LV_LABEL_LONG_SCROLL_CIRCULAR);
  214. lv_obj_set_style_text_font(s_label_artist, artist_font, 0);
  215. lv_obj_set_style_text_color(s_label_artist, lv_color_make(180, 180, 180), 0);
  216. lv_obj_align(s_label_artist, LV_ALIGN_TOP_LEFT, X_MARGIN, Y_ARTIST);
  217. lv_label_set_text(s_label_artist, "");
  218. // Album — small font, dimmer grey, scrolling
  219. s_label_album = lv_label_create(scr);
  220. lv_obj_set_width(s_label_album, DISPLAY_WIDTH - (X_MARGIN * 2) - 60);
  221. lv_label_set_long_mode(s_label_album, LV_LABEL_LONG_SCROLL_CIRCULAR);
  222. lv_obj_set_style_text_font(s_label_album, &lv_font_montserrat_14, 0);
  223. lv_obj_set_style_text_color(s_label_album, lv_color_make(140, 140, 140), 0);
  224. lv_obj_align(s_label_album, LV_ALIGN_TOP_LEFT, X_MARGIN, Y_ALBUM);
  225. lv_label_set_text(s_label_album, "");
  226. // Paused status indicator — right side at album row, amber
  227. s_label_status = lv_label_create(scr);
  228. lv_obj_set_style_text_font(s_label_status, &lv_font_montserrat_14, 0);
  229. lv_obj_set_style_text_color(s_label_status, lv_color_make(255, 200, 0), 0);
  230. lv_obj_align(s_label_status, LV_ALIGN_TOP_RIGHT, X_MARGIN_R, Y_ALBUM);
  231. lv_label_set_text(s_label_status, "");
  232. // Progress bar — inset from border on both sides, rounded
  233. s_bar_progress = lv_bar_create(scr);
  234. lv_obj_set_size(s_bar_progress, DISPLAY_WIDTH - (X_MARGIN * 2), BAR_HEIGHT);
  235. lv_obj_align(s_bar_progress, LV_ALIGN_TOP_LEFT, X_MARGIN, Y_PROGRESS);
  236. lv_bar_set_range(s_bar_progress, 0, 100);
  237. lv_bar_set_value(s_bar_progress, 0, LV_ANIM_OFF);
  238. lv_obj_set_style_bg_color(s_bar_progress, lv_color_make(20, 20, 40), 0);
  239. lv_obj_set_style_bg_opa(s_bar_progress, LV_OPA_80, 0);
  240. lv_obj_set_style_bg_color(s_bar_progress, lv_color_make(30, 144, 255),
  241. LV_PART_INDICATOR);
  242. lv_obj_set_style_bg_opa(s_bar_progress, LV_OPA_COVER, LV_PART_INDICATOR);
  243. lv_obj_set_style_radius(s_bar_progress, 3, 0);
  244. lv_obj_set_style_radius(s_bar_progress, 3, LV_PART_INDICATOR);
  245. // Elapsed time — below bar, left aligned
  246. s_label_time_elapsed = lv_label_create(scr);
  247. lv_obj_set_style_text_font(s_label_time_elapsed, &lv_font_montserrat_14, 0);
  248. lv_obj_set_style_text_color(s_label_time_elapsed,
  249. lv_color_make(150, 150, 150), 0);
  250. lv_obj_align(s_label_time_elapsed, LV_ALIGN_TOP_LEFT, X_MARGIN, Y_TIME);
  251. lv_label_set_text(s_label_time_elapsed, "");
  252. // Remaining time — below bar, right aligned
  253. s_label_time_remaining = lv_label_create(scr);
  254. lv_obj_set_style_text_font(s_label_time_remaining, &lv_font_montserrat_14, 0);
  255. lv_obj_set_style_text_color(s_label_time_remaining,
  256. lv_color_make(150, 150, 150), 0);
  257. lv_obj_align(s_label_time_remaining, LV_ALIGN_TOP_RIGHT, X_MARGIN_R, Y_TIME);
  258. lv_label_set_text(s_label_time_remaining, "");
  259. // Volume — status row, bottom-left
  260. s_label_volume = lv_label_create(scr);
  261. lv_obj_set_style_text_font(s_label_volume, &lv_font_montserrat_14, 0);
  262. lv_obj_set_style_text_color(s_label_volume, lv_color_make(150, 150, 150), 0);
  263. lv_obj_align(s_label_volume, LV_ALIGN_TOP_LEFT, X_MARGIN, Y_STATUS);
  264. lv_label_set_text(s_label_volume, "");
  265. // Battery — status row, bottom-right
  266. s_label_battery = lv_label_create(scr);
  267. lv_obj_set_style_text_font(s_label_battery, &lv_font_montserrat_14, 0);
  268. lv_obj_set_style_text_color(s_label_battery, lv_color_make(150, 150, 150), 0);
  269. lv_obj_align(s_label_battery, LV_ALIGN_TOP_RIGHT, X_MARGIN_R, Y_STATUS);
  270. lv_label_set_text(s_label_battery, "");
  271. }
  272. // Update the battery + volume status row. Called from ui_update() with the
  273. // LVGL port lock already held.
  274. static void ui_update_status_row(void) {
  275. // Volume — show as percentage, with the channel mode (L+R / L / R)
  276. const char *chan;
  277. switch (audio_output_get_channel_mode()) {
  278. case AUDIO_CHANNEL_LEFT:
  279. chan = "L";
  280. break;
  281. case AUDIO_CHANNEL_RIGHT:
  282. chan = "R";
  283. break;
  284. default:
  285. chan = "L+R";
  286. break;
  287. }
  288. char vol_str[24];
  289. snprintf(vol_str, sizeof(vol_str), LV_SYMBOL_VOLUME_MAX " %d%% %s",
  290. playback_control_get_volume_percent(), chan);
  291. lv_label_set_text(s_label_volume, vol_str);
  292. // Battery — only if the board reports one
  293. int pct = 0;
  294. bool charging = false;
  295. if (board_battery_read(&pct, &charging)) {
  296. const char *icon;
  297. if (charging) {
  298. icon = LV_SYMBOL_USB; // plugged into USB / charging
  299. } else if (pct >= 80) {
  300. icon = LV_SYMBOL_BATTERY_FULL;
  301. } else if (pct >= 60) {
  302. icon = LV_SYMBOL_BATTERY_3;
  303. } else if (pct >= 40) {
  304. icon = LV_SYMBOL_BATTERY_2;
  305. } else if (pct >= 20) {
  306. icon = LV_SYMBOL_BATTERY_1;
  307. } else {
  308. icon = LV_SYMBOL_BATTERY_EMPTY;
  309. }
  310. char bat_str[16];
  311. snprintf(bat_str, sizeof(bat_str), "%s %d%%", icon, pct);
  312. lv_label_set_text(s_label_battery, bat_str);
  313. // Tint red when low and not charging
  314. lv_color_t color = (!charging && pct <= 20) ? lv_color_make(255, 60, 60)
  315. : lv_color_make(150, 150, 150);
  316. lv_obj_set_style_text_color(s_label_battery, color, 0);
  317. } else {
  318. lv_label_set_text(s_label_battery, "");
  319. }
  320. }
  321. // ============================================================================
  322. // UI update
  323. // ============================================================================
  324. static void ui_update(void) {
  325. // Snapshot shared state under s_state_mutex. This avoids torn reads of
  326. // sync_time_us (int64, two-word on Xtensa) and guarantees consistent
  327. // strings vs. state. The snapshot is then rendered without the state
  328. // mutex held, so the RTSP callback can't be blocked by LVGL rendering.
  329. char title[METADATA_STRING_MAX];
  330. char artist[METADATA_STRING_MAX];
  331. char album[METADATA_STRING_MAX];
  332. uint32_t duration_secs;
  333. uint32_t position_secs;
  334. int64_t sync_time_us;
  335. display_state_t state;
  336. STATE_LOCK();
  337. memcpy(title, s_display.title, sizeof(title));
  338. memcpy(artist, s_display.artist, sizeof(artist));
  339. memcpy(album, s_display.album, sizeof(album));
  340. duration_secs = s_display.duration_secs;
  341. position_secs = s_display.position_secs;
  342. sync_time_us = s_display.sync_time_us;
  343. state = s_display.state;
  344. STATE_UNLOCK();
  345. // Defensive NUL termination — if the RTSP producer ever fills all
  346. // METADATA_STRING_MAX bytes without a terminator, lv_label_set_text
  347. // would read off the end.
  348. title[METADATA_STRING_MAX - 1] = '\0';
  349. artist[METADATA_STRING_MAX - 1] = '\0';
  350. album[METADATA_STRING_MAX - 1] = '\0';
  351. if (!lvgl_port_lock(100)) {
  352. ESP_LOGW(TAG, "ui_update: lock timeout");
  353. return;
  354. }
  355. switch (state) {
  356. case DISPLAY_STATE_STANDBY:
  357. lv_label_set_text(s_label_title, "AirPlay Ready");
  358. lv_label_set_text(s_label_artist, "");
  359. lv_label_set_text(s_label_album, "");
  360. lv_label_set_text(s_label_status, "");
  361. lv_label_set_text(s_label_time_elapsed, "");
  362. lv_label_set_text(s_label_time_remaining, "");
  363. lv_bar_set_value(s_bar_progress, 0, LV_ANIM_OFF);
  364. lv_obj_add_flag(s_label_muted, LV_OBJ_FLAG_HIDDEN);
  365. break;
  366. case DISPLAY_STATE_CONNECTED:
  367. lv_label_set_text(s_label_title, "Connected");
  368. lv_label_set_text(s_label_artist, "");
  369. lv_label_set_text(s_label_album, "");
  370. lv_label_set_text(s_label_status, "");
  371. lv_label_set_text(s_label_time_elapsed, "");
  372. lv_label_set_text(s_label_time_remaining, "");
  373. lv_bar_set_value(s_bar_progress, 0, LV_ANIM_OFF);
  374. lv_obj_add_flag(s_label_muted, LV_OBJ_FLAG_HIDDEN);
  375. break;
  376. case DISPLAY_STATE_PLAYING:
  377. case DISPLAY_STATE_PAUSED: {
  378. lv_label_set_text(s_label_title, title[0] ? title : "---");
  379. lv_label_set_text(s_label_artist, artist[0] ? artist : "");
  380. lv_label_set_text(s_label_album, album[0] ? album : "");
  381. lv_label_set_text(s_label_status,
  382. state == DISPLAY_STATE_PAUSED ? "|| " : "");
  383. // Muted indicator
  384. if (playback_control_is_muted()) {
  385. lv_obj_clear_flag(s_label_muted, LV_OBJ_FLAG_HIDDEN);
  386. } else {
  387. lv_obj_add_flag(s_label_muted, LV_OBJ_FLAG_HIDDEN);
  388. }
  389. uint32_t pos = position_secs;
  390. if (state == DISPLAY_STATE_PLAYING && sync_time_us > 0) {
  391. int64_t elapsed_us = esp_timer_get_time() - sync_time_us;
  392. uint32_t elapsed_secs = (uint32_t)(elapsed_us / 1000000);
  393. pos += elapsed_secs;
  394. if (duration_secs > 0 && pos > duration_secs) {
  395. pos = duration_secs;
  396. }
  397. }
  398. if (duration_secs > 0) {
  399. int pct = (int)((uint64_t)pos * 100 / duration_secs);
  400. lv_bar_set_value(s_bar_progress, pct, LV_ANIM_OFF);
  401. char elapsed_str[12];
  402. format_time(pos, elapsed_str, sizeof(elapsed_str));
  403. lv_label_set_text(s_label_time_elapsed, elapsed_str);
  404. uint32_t remaining = (pos <= duration_secs) ? duration_secs - pos : 0;
  405. char remaining_str[16];
  406. format_remaining(remaining, remaining_str, sizeof(remaining_str));
  407. lv_label_set_text(s_label_time_remaining, remaining_str);
  408. } else {
  409. lv_bar_set_value(s_bar_progress, 0, LV_ANIM_OFF);
  410. lv_label_set_text(s_label_time_elapsed, "");
  411. lv_label_set_text(s_label_time_remaining, "");
  412. }
  413. break;
  414. }
  415. }
  416. ui_update_status_row();
  417. lvgl_port_unlock();
  418. }
  419. // ============================================================================
  420. // RTSP event callback
  421. // ============================================================================
  422. static void on_rtsp_event(rtsp_event_t event, const rtsp_event_data_t *data,
  423. void *user_data) {
  424. (void)user_data;
  425. // All mutations of s_display happen under the state mutex so reads in
  426. // display_task see a consistent snapshot. The callback runs in the RTSP
  427. // event thread (not an ISR), so blocking on a FreeRTOS mutex is safe.
  428. STATE_LOCK();
  429. switch (event) {
  430. case RTSP_EVENT_CLIENT_CONNECTED:
  431. s_display.state = DISPLAY_STATE_CONNECTED;
  432. memset(s_display.title, 0, sizeof(s_display.title));
  433. memset(s_display.artist, 0, sizeof(s_display.artist));
  434. memset(s_display.album, 0, sizeof(s_display.album));
  435. s_display.duration_secs = 0;
  436. s_display.position_secs = 0;
  437. s_display.sync_time_us = 0;
  438. s_display.dirty = true;
  439. break;
  440. case RTSP_EVENT_PLAYING:
  441. s_display.state = DISPLAY_STATE_PLAYING;
  442. s_display.sync_time_us = esp_timer_get_time();
  443. s_display.dirty = true;
  444. break;
  445. case RTSP_EVENT_PAUSED:
  446. s_display.position_secs = get_estimated_position();
  447. s_display.sync_time_us = 0;
  448. s_display.state = DISPLAY_STATE_PAUSED;
  449. s_display.dirty = true;
  450. break;
  451. case RTSP_EVENT_DISCONNECTED:
  452. s_display.state = DISPLAY_STATE_STANDBY;
  453. memset(s_display.title, 0, sizeof(s_display.title));
  454. memset(s_display.artist, 0, sizeof(s_display.artist));
  455. memset(s_display.album, 0, sizeof(s_display.album));
  456. s_display.duration_secs = 0;
  457. s_display.position_secs = 0;
  458. s_display.sync_time_us = 0;
  459. s_display.dirty = true;
  460. break;
  461. case RTSP_EVENT_METADATA:
  462. if (data) {
  463. bool track_changed = data->metadata.title[0] &&
  464. strcmp(data->metadata.title, s_display.title) != 0;
  465. if (data->metadata.title[0]) {
  466. memcpy(s_display.title, data->metadata.title, METADATA_STRING_MAX);
  467. s_display.title[METADATA_STRING_MAX - 1] = '\0';
  468. }
  469. if (data->metadata.artist[0]) {
  470. memcpy(s_display.artist, data->metadata.artist, METADATA_STRING_MAX);
  471. s_display.artist[METADATA_STRING_MAX - 1] = '\0';
  472. }
  473. if (data->metadata.album[0]) {
  474. memcpy(s_display.album, data->metadata.album, METADATA_STRING_MAX);
  475. s_display.album[METADATA_STRING_MAX - 1] = '\0';
  476. }
  477. if (data->metadata.duration_secs) {
  478. s_display.duration_secs = data->metadata.duration_secs;
  479. }
  480. if (track_changed || data->metadata.position_secs ||
  481. s_display.position_secs == 0) {
  482. s_display.position_secs = data->metadata.position_secs;
  483. }
  484. s_display.sync_time_us = esp_timer_get_time();
  485. s_display.dirty = true;
  486. }
  487. break;
  488. }
  489. STATE_UNLOCK();
  490. }
  491. // ============================================================================
  492. // Display task
  493. // ============================================================================
  494. static void display_task(void *pvParameters) {
  495. (void)pvParameters;
  496. while (1) {
  497. // Consume dirty under the state mutex so a concurrent set in the
  498. // RTSP callback is never lost (clear-after-set ordering).
  499. bool need_update = false;
  500. display_state_t state;
  501. STATE_LOCK();
  502. if (s_display.dirty) {
  503. s_display.dirty = false;
  504. need_update = true;
  505. }
  506. state = s_display.state;
  507. STATE_UNLOCK();
  508. if (need_update) {
  509. ui_update();
  510. }
  511. static TickType_t last_progress_update = 0;
  512. TickType_t now = xTaskGetTickCount();
  513. if (state == DISPLAY_STATE_PLAYING &&
  514. (now - last_progress_update) >= pdMS_TO_TICKS(1000)) {
  515. last_progress_update = now;
  516. ui_update();
  517. }
  518. // Refresh battery + volume + channel mode periodically even when idle.
  519. // These are not tied to RTSP events; poll at 1s so an interactive change
  520. // (volume, double-click channel toggle) shows up promptly.
  521. static TickType_t last_status_update = 0;
  522. if (!need_update && (now - last_status_update) >= pdMS_TO_TICKS(1000)) {
  523. last_status_update = now;
  524. if (lvgl_port_lock(100)) {
  525. ui_update_status_row();
  526. lvgl_port_unlock();
  527. }
  528. }
  529. vTaskDelay(pdMS_TO_TICKS(30));
  530. }
  531. }
  532. // ============================================================================
  533. // Initialization
  534. // ============================================================================
  535. void display_init(void *bus) {
  536. s_state_mutex = xSemaphoreCreateMutex();
  537. assert(s_state_mutex != NULL);
  538. // The main/main.c contract (from PR #59) is:
  539. // bus != NULL → a pre-initialised spi_host_device_t passed as
  540. // (void*)(intptr_t)host. Use it and skip our own
  541. // spi_bus_initialize() to share the board's SPI bus.
  542. // bus == NULL → fall back to initialising our own SPI bus from the
  543. // GPIO pins in Kconfig. Used by boards that don't
  544. // expose a shared SPI bus for the display.
  545. spi_host_device_t spi_host =
  546. (bus != NULL) ? (spi_host_device_t)(intptr_t)bus : LCD_HOST;
  547. ESP_LOGI(TAG,
  548. "Initializing ST7789 (%dx%d landscape) host=%d "
  549. "CLK=%d MOSI=%d CS=%d DC=%d RST=%d BL=%d",
  550. DISPLAY_WIDTH, DISPLAY_HEIGHT, (int)spi_host, CONFIG_DISPLAY_SPI_CLK,
  551. CONFIG_DISPLAY_SPI_MOSI, CONFIG_DISPLAY_SPI_CS,
  552. CONFIG_DISPLAY_SPI_DC, CONFIG_DISPLAY_SPI_RST,
  553. CONFIG_DISPLAY_BL_GPIO);
  554. // Backlight GPIO is optional — a value of -1 means "not wired / always on",
  555. // which matches the Kconfig default. BIT64(-1) is undefined, and
  556. // gpio_set_level(-1, ...) returns ESP_ERR_INVALID_ARG, so skip the config
  557. // entirely when the pin is not set.
  558. if (CONFIG_DISPLAY_BL_GPIO >= 0) {
  559. gpio_config_t bl_cfg = {
  560. .pin_bit_mask = BIT64(CONFIG_DISPLAY_BL_GPIO),
  561. .mode = GPIO_MODE_OUTPUT,
  562. };
  563. ESP_ERROR_CHECK(gpio_config(&bl_cfg));
  564. gpio_set_level(CONFIG_DISPLAY_BL_GPIO, 0);
  565. }
  566. bg_load_from_spiffs();
  567. // Only initialise the SPI bus ourselves if the caller didn't pass a
  568. // pre-initialised one. Calling spi_bus_initialize() on a host that is
  569. // already initialised returns ESP_ERR_INVALID_STATE.
  570. if (bus == NULL) {
  571. spi_bus_config_t buscfg = {
  572. .mosi_io_num = CONFIG_DISPLAY_SPI_MOSI,
  573. .miso_io_num = -1,
  574. .sclk_io_num = CONFIG_DISPLAY_SPI_CLK,
  575. .quadwp_io_num = -1,
  576. .quadhd_io_num = -1,
  577. .max_transfer_sz = DISPLAY_WIDTH * DRAW_BUF_LINES * sizeof(uint16_t),
  578. };
  579. ESP_ERROR_CHECK(spi_bus_initialize(spi_host, &buscfg, SPI_DMA_CH_AUTO));
  580. }
  581. esp_lcd_panel_io_handle_t io_handle = NULL;
  582. esp_lcd_panel_io_spi_config_t io_cfg = {
  583. .dc_gpio_num = CONFIG_DISPLAY_SPI_DC,
  584. .cs_gpio_num = CONFIG_DISPLAY_SPI_CS,
  585. .pclk_hz = LCD_PIXEL_CLOCK_HZ,
  586. .lcd_cmd_bits = 8,
  587. .lcd_param_bits = 8,
  588. .spi_mode = 0,
  589. .trans_queue_depth = 4,
  590. };
  591. ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)spi_host,
  592. &io_cfg, &io_handle));
  593. esp_lcd_panel_handle_t panel_handle = NULL;
  594. esp_lcd_panel_dev_config_t panel_cfg = {
  595. .reset_gpio_num = CONFIG_DISPLAY_SPI_RST,
  596. .rgb_endian = LCD_RGB_ENDIAN_RGB,
  597. .bits_per_pixel = 16,
  598. };
  599. ESP_ERROR_CHECK(
  600. esp_lcd_new_panel_st7789(io_handle, &panel_cfg, &panel_handle));
  601. ESP_ERROR_CHECK(esp_lcd_panel_reset(panel_handle));
  602. ESP_ERROR_CHECK(esp_lcd_panel_init(panel_handle));
  603. ESP_ERROR_CHECK(esp_lcd_panel_invert_color(panel_handle, true));
  604. ESP_ERROR_CHECK(esp_lcd_panel_disp_on_off(panel_handle, true));
  605. // Pin the LVGL task to Core 0. Default task_affinity=-1 allows migration
  606. // to Core 1 where it interferes with the audio task (priority 7).
  607. const lvgl_port_cfg_t lvgl_cfg = {
  608. .task_priority = 4,
  609. .task_stack = 6144,
  610. .task_affinity = 0,
  611. .task_max_sleep_ms = 500,
  612. .timer_period_ms = 5,
  613. };
  614. ESP_ERROR_CHECK(lvgl_port_init(&lvgl_cfg));
  615. // buff_dma=true, buff_spiram=false: draw buffers in DMA-capable internal
  616. // SRAM. esp_lvgl_port passes the buffer pointer directly to
  617. // esp_lcd_panel_draw_bitmap — PSRAM buffers cause SPI master to allocate
  618. // a private DMA buffer at runtime, which fails under memory pressure.
  619. const lvgl_port_display_cfg_t disp_cfg = {
  620. .io_handle = io_handle,
  621. .panel_handle = panel_handle,
  622. .buffer_size = DISPLAY_WIDTH * DRAW_BUF_LINES,
  623. .double_buffer = true,
  624. .trans_size = 0,
  625. .hres = DISPLAY_WIDTH,
  626. .vres = DISPLAY_HEIGHT,
  627. .monochrome = false,
  628. .flags =
  629. {
  630. .buff_dma = true,
  631. .buff_spiram = false,
  632. .swap_bytes = true,
  633. },
  634. };
  635. s_lvgl_disp = lvgl_port_add_disp(&disp_cfg);
  636. assert(s_lvgl_disp != NULL);
  637. // Rotation MUST be applied after lvgl_port_add_disp() — the port resets
  638. // the ST7789 MADCTL register during display registration.
  639. ESP_ERROR_CHECK(esp_lcd_panel_swap_xy(panel_handle, true));
  640. ESP_ERROR_CHECK(esp_lcd_panel_mirror(panel_handle, true, false));
  641. ESP_ERROR_CHECK(esp_lcd_panel_set_gap(
  642. panel_handle, CONFIG_DISPLAY_ST7789_GAP_X, CONFIG_DISPLAY_ST7789_GAP_Y));
  643. // Acquire the LVGL port mutex before touching LVGL widgets. The port task
  644. // is already running at this point, so we must wait on the lock rather
  645. // than pass 0 (non-waiting try). If the lock cannot be acquired within a
  646. // generous timeout, init has gone wrong — abort rather than corrupt LVGL
  647. // state by calling ui_create() unlocked.
  648. if (lvgl_port_lock(1000)) {
  649. ui_create();
  650. lvgl_port_unlock();
  651. } else {
  652. ESP_LOGE(TAG, "Failed to acquire LVGL lock during init — UI not built");
  653. abort();
  654. }
  655. if (CONFIG_DISPLAY_BL_GPIO >= 0) {
  656. gpio_set_level(CONFIG_DISPLAY_BL_GPIO, 1);
  657. }
  658. s_display.state = DISPLAY_STATE_STANDBY;
  659. s_display.dirty = true;
  660. rtsp_events_register(on_rtsp_event, NULL);
  661. // Pinned to Core 0 — audio runs on Core 1
  662. xTaskCreatePinnedToCore(display_task, "display", 4096, NULL, 3, NULL, 0);
  663. ESP_LOGI(TAG, "ST7789 display initialized (LVGL 9 + esp_lvgl_port)");
  664. }