diff --git a/platformio.ini b/platformio.ini index 07be91b..cddb2a0 100644 --- a/platformio.ini +++ b/platformio.ini @@ -38,6 +38,13 @@ build_flags = '-DTARGET_SPEAKER="JBL Charge 5"' -DHUB_I2C_ADDR=0x10 build_src_filter = + build_flags = '-DTARGET_SPEAKER="Tangerine EDGE"' -DHUB_I2C_ADDR=0x11 +; --- Board D: I2S slave -> A2DP source -> GUEST speaker (antenna board) ------- +; Occasional passenger speaker, furthest away (hence the onboard antenna). +; No hardcoded name — pick the speaker via the hub's scan UI (Phase 3). +[env:source_guest] +build_src_filter = + +build_flags = '-DTARGET_SPEAKER="Guest"' -DHUB_I2C_ADDR=0x12 + ; --- Hub: ESP32-S3-Touch-LCD-1.28 (round GC9A01 LCD + CST816S touch) ---------- ; Different chip (esp32s3) from the relay boards. Drives the UI; later the ; wired control bus to Boards B/C. NOT in default_envs — build with -e hub_s3. @@ -49,4 +56,22 @@ board_upload.flash_size = 4MB board_build.partitions = default.csv monitor_speed = 115200 build_src_filter = + -lib_deps = lovyan03/LovyanGFX@^1.1.16 +lib_deps = + lovyan03/LovyanGFX@^1.1.16 + lvgl/lvgl@^8.3.11 +; LVGL configured via build flags (LV_CONF_SKIP -> defaults + overrides), so +; there is no separate lv_conf.h to maintain. 16-bit colour, byte-swapped for +; the SPI panel; millis() tick; the big Montserrat fonts for glanceable text. +; NOTE: keep flag values free of shell-special chars (parens/spaces) — they go +; through a shell. LV_MEM_SIZE as a plain int; tick driven via lv_tick_inc() in +; loop() (no LV_TICK_CUSTOM, which needs a parenthesised millis() expr). +build_flags = + -DLV_CONF_SKIP=1 + -DLV_COLOR_DEPTH=16 + -DLV_COLOR_16_SWAP=1 + -DLV_MEM_SIZE=49152 + -DLV_FONT_MONTSERRAT_14=1 + -DLV_FONT_MONTSERRAT_20=1 + -DLV_FONT_MONTSERRAT_28=1 + -DLV_FONT_MONTSERRAT_48=1 + -DLV_USE_LOG=0 diff --git a/src/hub_proto.h b/src/hub_proto.h index 9d13b4b..f36b66a 100644 --- a/src/hub_proto.h +++ b/src/hub_proto.h @@ -12,6 +12,7 @@ #define HUB_I2C_ADDR_JBL 0x10 #define HUB_I2C_ADDR_CARDO 0x11 +#define HUB_I2C_ADDR_GUEST 0x12 // antenna board: source for an occasional guest speaker // Master -> slave WRITE: first byte = command, then args (little-endian). #define HUB_CMD_SET_DELAY 0x01 // arg: uint16 delay_ms (0..HUB_MAX_DELAY_MS) diff --git a/src/hub_s3.cpp b/src/hub_s3.cpp index 1f40607..b3b6bc9 100644 --- a/src/hub_s3.cpp +++ b/src/hub_s3.cpp @@ -1,20 +1,31 @@ /** * BikeAudio — Hub (ESP32-S3-Touch-LCD-1.28) : PHASE 3 discovery + control * - * Round GC9A01 LCD + CST816S touch (kept from phase 1 first-light), now wired - * up as an I2C MASTER to the two source boards (JBL, Cardo) and showing a - * simple functional touch UI: each speaker's connect state, current device - * name, delay (ms) and volume (%), with touch zones to nudge delay / volume - * +/- and a "pick" button to scan for and select a nearby BT speaker. + * Round GC9A01 LCD + CST816S touch, wired as an I2C MASTER to the source + * boards. Three brand-agnostic channels are controlled (discovery picks the + * actual device per channel): + * index 0 "Headset" @ 0x11 + * index 1 "Speaker 1" @ 0x10 + * index 2 "Guest" @ 0x12 (occasional; usually offline) + * The UI is built with LVGL 8.3 (rendered THROUGH the LovyanGFX `lcd` device), + * tuned to be glanceable on a 240x240 ROUND screen for use on a motorcycle + * handlebar: big high-contrast type, large tap targets, bold connection colours. * - * Two screens, dispatched in loop() by `screen`: - * SCREEN_MAIN : per-speaker name + connect dot + [d-][d+][v-][v+] + [pick] - * SCREEN_SCAN : live discovery list for one speaker (tap a row to select), - * with [Rescan] and [Back] controls. + * Screens (separate lv_obj_create(NULL) screens, switched with lv_scr_load): + * HOME : big master-volume arc (drag -> set ALL channels), three labeled + * connection dots (Headset / Speaker 1 / Guest) at top, "SPEAKERS" + * button at the bottom. + * SPEAKERS : three buttons (Headset / Speaker 1 / Guest) showing connect state + * + current device name; tap -> that channel's DETAIL. BACK -> HOME. + * DETAIL : per-speaker. Volume arc (this speaker), Delay -/+ control, + * SCAN button, BACK -> SPEAKERS. + * SCAN : starts a scan on entry; spinner while scanning; an lv_list of + * discovered devices (name + rssi). Tap a row -> SELECT + DETAIL. + * RESCAN re-issues the scan; BACK cancels + returns to DETAIL. * * Two I2C buses are in use, on separate ports: - * - port 0 (Wire1, owned by LovyanGFX): CST816S touch SDA=6 SCL=7 - * - port "Wire" (this code, I2C master): source boards SDA=15 SCL=16 + * - port 0 (Wire1's underlying peripheral via LovyanGFX): CST816S touch SDA=6 SCL=7 + * - "Wire1" (this code, I2C master): source boards SDA=15 SCL=16 * * Board pins (Waveshare ESP32-S3-Touch-LCD-1.28): * GC9A01 SPI: SCLK=10 MOSI=11 MISO=12 CS=9 DC=8 RST=14 backlight=2 @@ -29,6 +40,7 @@ #include #include #include +#include #include "hub_proto.h" class LGFX : public lgfx::LGFX_Device { @@ -91,16 +103,14 @@ LGFX lcd; #define CTRL_I2C_HZ 100000 #define DELAY_STEP 5 // ms per delay +/- press -#define VOL_STEP 5 // % per volume +/- press #define POLL_PERIOD_MS 1000 // status poll cadence per speaker -#define TOUCH_DEBOUNCE 250 // ms; ignore repeat presses inside this window // Per-speaker model. delay_ms/volume are the values we display & command; // "connected" is reported by the board, "online" is whether the I2C // transaction itself succeeded (board present on the bus at all). struct Speaker { uint8_t addr; - const char *name; // role label (JBL / Cardo), not the BT device name + const char *name; // role label (Headset / Speaker 1 / Guest), not the BT device name bool online; // I2C transaction succeeded this poll bool connected; // board says its BT sink is connected uint16_t delay_ms; @@ -112,9 +122,13 @@ struct Speaker { char cur_name[HUB_NAME_MAX + 1]; // selected BT device name (NUL-term) }; -static Speaker speakers[2] = { - { HUB_I2C_ADDR_JBL, "JBL", false, false, 0, 0, false, 0, false, {0} }, - { HUB_I2C_ADDR_CARDO, "Cardo", false, false, 0, 0, false, 0, false, {0} }, +#define NUM_SPK 3 + +static Speaker speakers[NUM_SPK] = { + // Brand-agnostic channels; discovery picks the actual device per channel. + { HUB_I2C_ADDR_CARDO, "Headset", false, false, 0, 0, false, 0, false, {0} }, + { HUB_I2C_ADDR_JBL, "Speaker 1", false, false, 0, 0, false, 0, false, {0} }, + { HUB_I2C_ADDR_GUEST, "Guest", false, false, 0, 0, false, 0, false, {0} }, }; // A discovered scan item, mirrored from the board over GET_SCANITEM. @@ -129,20 +143,15 @@ struct ScanItem { static ScanItem scanItems[HUB_MAX_SCAN]; static int scanItemCount = 0; -// --------------------------------------------------------------------------- -// Screen state machine -// --------------------------------------------------------------------------- -enum Screen { SCREEN_MAIN, SCREEN_SCAN }; -static Screen screen = SCREEN_MAIN; -static int cfgSpeaker = 0; // which speaker SCREEN_SCAN is configuring -static int scanScroll = 0; // first visible row in the scan list +// Which speaker DETAIL / SCAN is operating on (0..NUM_SPK-1). +static int activeSpeaker = 0; -// --------------------------------------------------------------------------- -// I2C helpers -// --------------------------------------------------------------------------- +// =========================================================================== +// I2C helpers (DATA LAYER — unchanged transactions, all on Wire1) +// =========================================================================== // Poll one speaker for status. Updates s.online/connected/delay_ms/volume. -// On any bus failure the speaker is marked offline (displayed as a dash). +// On any bus failure the speaker is marked offline. // NOTE: the control bus uses Wire1 (I2C peripheral 1). Peripheral 0 is already // owned by the LovyanGFX CST816S touch (GPIO6/7), so we must not touch it here. static void pollStatus(Speaker &s) { @@ -177,15 +186,12 @@ static void getCurName(Speaker &s, char *out) { if (Wire1.endTransmission(true) != 0) return; int n = Wire1.requestFrom((int)s.addr, (int)HUB_NAME_MAX); if (n < HUB_NAME_MAX) { - // drain whatever arrived to keep the bus sane while (Wire1.available()) Wire1.read(); return; } for (int i = 0; i < HUB_NAME_MAX; i++) { out[i] = (char)Wire1.read(); } - // Payload is NUL-padded; force a terminator at the end of the buffer in - // case the board sent a full HUB_NAME_MAX-length name with no NUL. out[HUB_NAME_MAX] = '\0'; } @@ -268,7 +274,6 @@ static void pollScanList(int i) { scanItems[b + 1] = key; } scanItemCount = got; - if (scanScroll > scanItemCount) scanScroll = 0; } // Command a new delay (ms) to a speaker. @@ -288,386 +293,578 @@ static void sendVolume(const Speaker &s, uint8_t vol) { Wire1.endTransmission(); } -static bool inRect(int px, int py, int x, int y, int w, int h) { - return px >= x && px < x + w && py >= y && py < y + h; -} - // =========================================================================== -// SCREEN_MAIN layout +// LVGL <-> LovyanGFX glue // =========================================================================== -// 240x240 round screen split top (JBL) / bottom (Cardo). Controls live in the -// safe center column so they stay clear of the round bezel. Each half stacks: -// title (role + connect dot), current device name, "Dnnn Vnn%" status, -// a row of five buttons: [d-] [d+] [v-] [v+] [pick]. -// -// y rows (per speaker half): -// JBL title 20, name 38, status 56, buttons 74..106 -// Cardo title 138, name 156, status 174, buttons 134... (mirrored below) +static lv_color_t lvbuf1[240 * 40]; +static lv_color_t lvbuf2[240 * 40]; +static lv_disp_draw_buf_t draw_buf; +static lv_disp_drv_t disp_drv; +static lv_indev_drv_t indev_drv; -#define BTN_W 30 -#define BTN_H 30 -#define BTN_GAP 5 - -// Main-screen button kinds, in draw/hit order. -enum BtnKind { B_DELAY_DOWN = 0, B_DELAY_UP, B_VOL_DOWN, B_VOL_UP, B_PICK, B_COUNT }; -static const char *kBtnLabel[B_COUNT] = { "d-", "d+", "v-", "v+", "pk" }; - -// 5 buttons centered horizontally. -static const int kBtnTotalW = B_COUNT * BTN_W + (B_COUNT - 1) * BTN_GAP; -static const int kBtnX0 = (240 - kBtnTotalW) / 2; -static const int kBtnYTop[2] = { 78, 188 }; // button-row top Y per speaker - -static const int kTitleY[2] = { 18, 136 }; -static const int kNameY[2] = { 38, 156 }; -static const int kStatusY[2] = { 58, 176 }; - -// Bounding box of main-screen button `k` for speaker index `i`. -static void btnRect(int i, int k, int &x, int &y, int &w, int &h) { - x = kBtnX0 + k * (BTN_W + BTN_GAP); - y = kBtnYTop[i]; - w = BTN_W; - h = BTN_H; +static void disp_flush(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *px) { + uint32_t w = area->x2 - area->x1 + 1, h = area->y2 - area->y1 + 1; + lcd.startWrite(); + lcd.setAddrWindow(area->x1, area->y1, w, h); + lcd.writePixels((uint16_t *)&px->full, w * h); + lcd.endWrite(); + lv_disp_flush_ready(drv); } -// Draw the control buttons for speaker `i`. `pressed` highlights one. The -// pick button is tinted differently so it reads as a mode switch, not a nudge. -static void drawButtons(int i, int pressed = -1) { - for (int k = 0; k < B_COUNT; k++) { - int x, y, w, h; - btnRect(i, k, x, y, w, h); - uint16_t base = (k == B_PICK) ? TFT_PURPLE : TFT_NAVY; - uint16_t fill = (k == pressed) ? TFT_DARKCYAN : base; - lcd.fillRoundRect(x, y, w, h, 5, fill); - lcd.drawRoundRect(x, y, w, h, 5, TFT_DARKGREY); - lcd.setTextColor(TFT_WHITE, fill); - lcd.setTextDatum(middle_center); - lcd.drawString(kBtnLabel[k], x + w / 2, y + h / 2, &fonts::Font2); - } -} - -// Title + device name + delay/volume status for speaker `i`. -static void drawStatus(int i) { - const Speaker &s = speakers[i]; - - // Title (role label) with a connect dot to its left. - lcd.setTextDatum(middle_center); - lcd.setTextColor(TFT_CYAN, TFT_BLACK); - lcd.fillRect(0, kTitleY[i] - 9, 240, 18, TFT_BLACK); - lcd.drawString(s.name, 120, kTitleY[i], &fonts::Font2); - uint16_t dot = !s.online ? TFT_DARKGREY : (s.connected ? TFT_GREEN : TFT_RED); - lcd.fillCircle(120 - 40, kTitleY[i], 5, dot); - - // Current device name (or a hint to pick one). - lcd.fillRect(0, kNameY[i] - 8, 240, 16, TFT_BLACK); - if (s.cur_name[0]) { - lcd.setTextColor(TFT_YELLOW, TFT_BLACK); - lcd.drawString(s.cur_name, 120, kNameY[i], &fonts::Font2); +static void touch_read(lv_indev_drv_t *drv, lv_indev_data_t *data) { + int32_t x, y; + if (lcd.getTouch(&x, &y)) { + data->state = LV_INDEV_STATE_PR; + data->point.x = x; + data->point.y = y; } else { - lcd.setTextColor(TFT_DARKGREY, TFT_BLACK); - lcd.drawString("(no device)", 120, kNameY[i], &fonts::Font2); - } - - // Delay/volume (or offline dash). - char buf[40]; - if (!s.online) { - snprintf(buf, sizeof(buf), "-- offline --"); - lcd.setTextColor(TFT_DARKGREY, TFT_BLACK); - } else { - snprintf(buf, sizeof(buf), "D%ums V%u%%", s.delay_ms, s.volume); - lcd.setTextColor(TFT_WHITE, TFT_BLACK); - } - lcd.fillRect(0, kStatusY[i] - 8, 240, 16, TFT_BLACK); - lcd.drawString(buf, 120, kStatusY[i], &fonts::Font2); -} - -// Redraw the whole MAIN frame (background, divider, both halves). -static void drawMainScreen() { - lcd.fillScreen(TFT_BLACK); - lcd.drawCircle(120, 120, 118, TFT_DARKGREY); - lcd.drawFastHLine(20, 120, 200, TFT_DARKGREY); // top/bottom divider - for (int i = 0; i < 2; i++) { - drawStatus(i); - drawButtons(i); - } -} - -// Apply a delay/volume nudge for button `k` on speaker `i` and send it. The -// pick button is handled by the caller (it switches screens). Returns true if -// a value button was handled. -static bool applyMainButton(int i, int k) { - Speaker &s = speakers[i]; - switch (k) { - case B_DELAY_DOWN: { - int d = (int)s.delay_ms - DELAY_STEP; - if (d < 0) d = 0; - s.delay_ms = (uint16_t)d; - sendDelay(s, s.delay_ms); - return true; - } - case B_DELAY_UP: { - int d = (int)s.delay_ms + DELAY_STEP; - if (d > HUB_MAX_DELAY_MS) d = HUB_MAX_DELAY_MS; - s.delay_ms = (uint16_t)d; - sendDelay(s, s.delay_ms); - return true; - } - case B_VOL_DOWN: { - int v = (int)s.volume - VOL_STEP; - if (v < 0) v = 0; - s.volume = (uint8_t)v; - sendVolume(s, s.volume); - return true; - } - case B_VOL_UP: { - int v = (int)s.volume + VOL_STEP; - if (v > 100) v = 100; - s.volume = (uint8_t)v; - sendVolume(s, s.volume); - return true; - } - default: - return false; + data->state = LV_INDEV_STATE_REL; } } // =========================================================================== -// SCREEN_SCAN layout +// UI palette / helpers // =========================================================================== -// One speaker's discovery list. Header at top, up to SCAN_ROWS device rows in -// the safe center column, and a bottom control row: [<] [Rescan] [>] [Back]. -// - device rows are tappable -> SELECT that item -// - [<]/[>] scroll when there are more devices than fit -// - [Rescan] re-issues SCAN_START; [Back] cancels + returns to MAIN +#define COL_BG lv_color_black() +#define COL_GREEN lv_color_hex(0x00D26A) // connected +#define COL_RED lv_color_hex(0xE0322B) // online but not connected +#define COL_GREY lv_color_hex(0x555555) // offline +#define COL_CYAN lv_color_hex(0x18C0E0) +#define COL_ACCENT lv_color_hex(0x18C0E0) // arc indicator +#define COL_TRACK lv_color_hex(0x303030) // arc background +#define COL_WHITE lv_color_white() +#define COL_BTN lv_color_hex(0x1B2A3A) +#define COL_BACK lv_color_hex(0x4A1414) -#define SCAN_ROWS 4 // device rows visible at once -#define SCAN_ROW_H 26 -#define SCAN_LIST_X 30 -#define SCAN_LIST_W 180 -static const int kScanListY0 = 56; // top of first device row - -// Bottom control row. -#define SCAN_CTL_Y 200 -#define SCAN_CTL_H 32 -enum ScanCtl { SC_SCROLL_UP = 0, SC_RESCAN, SC_SCROLL_DOWN, SC_BACK, SC_COUNT }; -static const char *kScanCtlLabel[SC_COUNT] = { "^", "Rscn", "v", "Back" }; -#define SCAN_CTL_W 40 -#define SCAN_CTL_GAP 6 -static const int kScanCtlTotalW = SC_COUNT * SCAN_CTL_W + (SC_COUNT - 1) * SCAN_CTL_GAP; -static const int kScanCtlX0 = (240 - kScanCtlTotalW) / 2; - -static void scanRowRect(int row, int &x, int &y, int &w, int &h) { - x = SCAN_LIST_X; - y = kScanListY0 + row * SCAN_ROW_H; - w = SCAN_LIST_W; - h = SCAN_ROW_H - 3; +// Pick the connection colour for a speaker's state. +static lv_color_t connColor(const Speaker &s) { + if (!s.online) return COL_GREY; + return s.connected ? COL_GREEN : COL_RED; } -static void scanCtlRect(int c, int &x, int &y, int &w, int &h) { - x = kScanCtlX0 + c * (SCAN_CTL_W + SCAN_CTL_GAP); - y = SCAN_CTL_Y; - w = SCAN_CTL_W; - h = SCAN_CTL_H; -} +// =========================================================================== +// Screen objects + the widget pointers we update from the poll timer +// =========================================================================== +static lv_obj_t *scrHome; +static lv_obj_t *scrSpeakers; +static lv_obj_t *scrDetail; +static lv_obj_t *scrScan; -// Redraw just the device-list area (header + rows). Used live while scanning. -static void drawScanList() { - const Speaker &s = speakers[cfgSpeaker]; +// HOME widgets +static lv_obj_t *homeArc; +static lv_obj_t *homePctLabel; +static lv_obj_t *homeDot[NUM_SPK]; // per-channel connection dots (Headset/Speaker 1/Guest) - // Header: ": scanning" with a spinner, or " found". - lcd.fillRect(0, 24, 240, 22, TFT_BLACK); - lcd.setTextDatum(middle_center); - char hdr[40]; - if (s.scanning) { - static const char spin[] = { '|', '/', '-', '\\' }; - char c = spin[(millis() / 250) % 4]; - snprintf(hdr, sizeof(hdr), "%s: scan %c", s.name, c); - lcd.setTextColor(TFT_ORANGE, TFT_BLACK); - } else { - snprintf(hdr, sizeof(hdr), "%s: %d found", s.name, scanItemCount); - lcd.setTextColor(TFT_CYAN, TFT_BLACK); - } - lcd.drawString(hdr, 120, 34, &fonts::Font2); +// SPEAKERS widgets +static lv_obj_t *spkBtnDot[NUM_SPK]; // dot inside each speaker button +static lv_obj_t *spkBtnLabel[NUM_SPK]; // "\n" text inside each button - // Device rows. - lcd.fillRect(0, kScanListY0 - 2, 240, SCAN_ROWS * SCAN_ROW_H + 4, TFT_BLACK); - lcd.setTextDatum(middle_left); - for (int row = 0; row < SCAN_ROWS; row++) { - int idx = scanScroll + row; - int x, y, w, h; - scanRowRect(row, x, y, w, h); - if (idx >= scanItemCount) { - if (idx == 0 && !s.scanning) { - // empty result - lcd.setTextColor(TFT_DARKGREY, TFT_BLACK); - lcd.setTextDatum(middle_center); - if (row == 0) - lcd.drawString("(none)", 120, y + h / 2, &fonts::Font2); - lcd.setTextDatum(middle_left); - } - continue; - } - const ScanItem &it = scanItems[idx]; - lcd.fillRoundRect(x, y, w, h, 4, TFT_NAVY); - lcd.drawRoundRect(x, y, w, h, 4, TFT_DARKGREY); - // RSSI on the right, name on the left (clipped by the box width). - char rb[8]; - snprintf(rb, sizeof(rb), "%d", it.rssi); - lcd.setTextColor(TFT_GREENYELLOW, TFT_NAVY); - lcd.setTextDatum(middle_right); - lcd.drawString(rb, x + w - 6, y + h / 2, &fonts::Font2); - lcd.setTextColor(TFT_WHITE, TFT_NAVY); - lcd.setTextDatum(middle_left); - // Truncate name to keep it from colliding with the RSSI. - char nm[HUB_NAME_MAX + 1]; - strncpy(nm, it.name, sizeof(nm)); - nm[HUB_NAME_MAX] = '\0'; - if (strlen(nm) > 14) nm[14] = '\0'; - lcd.drawString(nm[0] ? nm : "(unnamed)", x + 6, y + h / 2, &fonts::Font2); - } +// DETAIL widgets +static lv_obj_t *detHeading; +static lv_obj_t *detArc; +static lv_obj_t *detArcPct; +static lv_obj_t *detDelayLabel; - // "more below/above" hint when the list overflows. - if (scanItemCount > SCAN_ROWS) { - lcd.setTextColor(TFT_DARKGREY, TFT_BLACK); - lcd.setTextDatum(middle_center); - char mb[24]; - snprintf(mb, sizeof(mb), "%d-%d / %d", - scanScroll + 1, - min(scanScroll + SCAN_ROWS, scanItemCount), - scanItemCount); - lcd.drawString(mb, 120, kScanListY0 + SCAN_ROWS * SCAN_ROW_H + 6, - &fonts::Font2); +// SCAN widgets +static lv_obj_t *scanHeading; +static lv_obj_t *scanSpinner; +static lv_obj_t *scanList; + +// Master volume tracked on the hub (drives both speakers from HOME). +static uint8_t masterVolume = 0; + +// Forward declarations of builders/refreshers. +static void buildHome(); +static void buildSpeakers(); +static void buildDetail(); +static void buildScan(); +static void refreshHome(); +static void refreshSpeakers(); +static void refreshDetail(); +static void refreshScanList(); +static void enterScan(); + +// =========================================================================== +// HOME screen +// =========================================================================== +static void home_arc_cb(lv_event_t *e) { + lv_obj_t *arc = lv_event_get_target(e); + uint8_t v = (uint8_t)lv_arc_get_value(arc); + masterVolume = v; + lv_label_set_text_fmt(homePctLabel, "%d", v); + // Drive ALL channels from the master arc. + for (int i = 0; i < NUM_SPK; i++) { + speakers[i].volume = v; + sendVolume(speakers[i], v); } } -// Draw the bottom control row. -static void drawScanCtls(int pressed = -1) { - for (int c = 0; c < SC_COUNT; c++) { - int x, y, w, h; - scanCtlRect(c, x, y, w, h); - uint16_t base = (c == SC_BACK) ? TFT_MAROON : TFT_NAVY; - uint16_t fill = (c == pressed) ? TFT_DARKCYAN : base; - lcd.fillRoundRect(x, y, w, h, 5, fill); - lcd.drawRoundRect(x, y, w, h, 5, TFT_DARKGREY); - lcd.setTextColor(TFT_WHITE, fill); - lcd.setTextDatum(middle_center); - lcd.drawString(kScanCtlLabel[c], x + w / 2, y + h / 2, &fonts::Font2); +static void home_to_speakers_cb(lv_event_t *e) { + refreshSpeakers(); + lv_scr_load(scrSpeakers); +} + +static void buildHome() { + scrHome = lv_obj_create(NULL); + lv_obj_set_style_bg_color(scrHome, COL_BG, 0); + lv_obj_clear_flag(scrHome, LV_OBJ_FLAG_SCROLLABLE); + + // Big master-volume arc nearly filling the screen. + homeArc = lv_arc_create(scrHome); + lv_obj_set_size(homeArc, 220, 220); + lv_obj_center(homeArc); + lv_arc_set_rotation(homeArc, 135); + lv_arc_set_bg_angles(homeArc, 0, 270); + lv_arc_set_range(homeArc, 0, 100); + lv_arc_set_value(homeArc, masterVolume); + lv_obj_set_style_arc_width(homeArc, 16, LV_PART_MAIN); + lv_obj_set_style_arc_color(homeArc, COL_TRACK, LV_PART_MAIN); + lv_obj_set_style_arc_width(homeArc, 16, LV_PART_INDICATOR); + lv_obj_set_style_arc_color(homeArc, COL_ACCENT, LV_PART_INDICATOR); + lv_obj_set_style_bg_color(homeArc, COL_ACCENT, LV_PART_KNOB); + lv_obj_set_style_pad_all(homeArc, 8, LV_PART_KNOB); // larger, glove-friendly knob + lv_obj_add_event_cb(homeArc, home_arc_cb, LV_EVENT_VALUE_CHANGED, NULL); + + // Center: big % number + "VOLUME" caption. + homePctLabel = lv_label_create(scrHome); + lv_obj_set_style_text_font(homePctLabel, &lv_font_montserrat_48, 0); + lv_obj_set_style_text_color(homePctLabel, COL_WHITE, 0); + lv_label_set_text_fmt(homePctLabel, "%d", masterVolume); + lv_obj_align(homePctLabel, LV_ALIGN_CENTER, 0, -4); + + lv_obj_t *cap = lv_label_create(scrHome); + lv_obj_set_style_text_font(cap, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(cap, COL_GREY, 0); + lv_label_set_text(cap, "VOLUME"); + lv_obj_align(cap, LV_ALIGN_CENTER, 0, 34); + + // Top: three compact connection indicators (dot + short label), evenly + // spaced in a row near the top. Kept within the round screen's safe area: + // the labels sit at y~28-44 where the chord is wide enough for the text. + const char *roles[NUM_SPK] = { "Headset", "Speaker 1", "Guest" }; + const int xoff[NUM_SPK] = { -64, 0, 64 }; + for (int i = 0; i < NUM_SPK; i++) { + homeDot[i] = lv_obj_create(scrHome); + lv_obj_set_size(homeDot[i], 14, 14); + lv_obj_set_style_radius(homeDot[i], LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(homeDot[i], 0, 0); + lv_obj_set_style_bg_color(homeDot[i], COL_GREY, 0); + lv_obj_align(homeDot[i], LV_ALIGN_TOP_MID, xoff[i], 26); + lv_obj_clear_flag(homeDot[i], LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t *l = lv_label_create(scrHome); + lv_obj_set_style_text_font(l, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(l, COL_WHITE, 0); + lv_label_set_text(l, roles[i]); + lv_obj_align_to(l, homeDot[i], LV_ALIGN_OUT_BOTTOM_MID, 0, 2); + } + + // Bottom: "SPEAKERS" button. + lv_obj_t *btn = lv_btn_create(scrHome); + lv_obj_set_size(btn, 130, 46); + lv_obj_align(btn, LV_ALIGN_BOTTOM_MID, 0, -22); + lv_obj_set_style_bg_color(btn, COL_BTN, 0); + lv_obj_set_style_radius(btn, 12, 0); + lv_obj_add_event_cb(btn, home_to_speakers_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t *bl = lv_label_create(btn); + lv_obj_set_style_text_font(bl, &lv_font_montserrat_20, 0); + lv_label_set_text(bl, "SPEAKERS"); + lv_obj_center(bl); +} + +static void refreshHome() { + for (int i = 0; i < NUM_SPK; i++) { + lv_obj_set_style_bg_color(homeDot[i], connColor(speakers[i]), 0); } } -static void drawScanScreen() { - lcd.fillScreen(TFT_BLACK); - lcd.drawCircle(120, 120, 118, TFT_DARKGREY); - drawScanList(); - drawScanCtls(); +// =========================================================================== +// SPEAKERS screen +// =========================================================================== +static void speakers_back_cb(lv_event_t *e) { + refreshHome(); + lv_scr_load(scrHome); } -// Enter SCREEN_SCAN for speaker `i`: kick off a scan and draw the screen. -static void enterScan(int i) { - cfgSpeaker = i; - scanScroll = 0; +static void speakers_select_cb(lv_event_t *e) { + activeSpeaker = (int)(intptr_t)lv_event_get_user_data(e); + refreshDetail(); + lv_scr_load(scrDetail); +} + +static void buildSpeakers() { + scrSpeakers = lv_obj_create(NULL); + lv_obj_set_style_bg_color(scrSpeakers, COL_BG, 0); + lv_obj_clear_flag(scrSpeakers, LV_OBJ_FLAG_SCROLLABLE); + + // Three channel buttons stacked in the center column. Heights kept >=48px + // (glove-friendly tap target) and widths narrowed so they stay inside the + // round bezel where the top/bottom buttons hit the narrower chords. + const char *roles[NUM_SPK] = { "Headset", "Speaker 1", "Guest" }; + const int ypos[NUM_SPK] = { -58, 0, 58 }; // relative to vertical center + for (int i = 0; i < NUM_SPK; i++) { + lv_obj_t *btn = lv_btn_create(scrSpeakers); + lv_obj_set_size(btn, 168, 50); + lv_obj_align(btn, LV_ALIGN_CENTER, 0, ypos[i]); + lv_obj_set_style_bg_color(btn, COL_BTN, 0); + lv_obj_set_style_radius(btn, 12, 0); + lv_obj_set_style_pad_left(btn, 8, 0); + lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_event_cb(btn, speakers_select_cb, LV_EVENT_CLICKED, + (void *)(intptr_t)i); + + // Connection dot inside the button (left of content area). + spkBtnDot[i] = lv_obj_create(btn); + lv_obj_set_size(spkBtnDot[i], 14, 14); + lv_obj_set_style_radius(spkBtnDot[i], LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(spkBtnDot[i], 0, 0); + lv_obj_set_style_bg_color(spkBtnDot[i], COL_GREY, 0); + lv_obj_align(spkBtnDot[i], LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_clear_flag(spkBtnDot[i], LV_OBJ_FLAG_SCROLLABLE); + + // Role + device name (smaller font so two lines fit in 50px). + spkBtnLabel[i] = lv_label_create(btn); + lv_obj_set_style_text_font(spkBtnLabel[i], &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(spkBtnLabel[i], COL_WHITE, 0); + lv_label_set_text_fmt(spkBtnLabel[i], "%s", roles[i]); + lv_obj_align(spkBtnLabel[i], LV_ALIGN_LEFT_MID, 24, 0); + } + + // BACK button at the bottom. + lv_obj_t *back = lv_btn_create(scrSpeakers); + lv_obj_set_size(back, 110, 44); + lv_obj_align(back, LV_ALIGN_BOTTOM_MID, 0, -16); + lv_obj_set_style_bg_color(back, COL_BACK, 0); + lv_obj_set_style_radius(back, 12, 0); + lv_obj_add_event_cb(back, speakers_back_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t *bl = lv_label_create(back); + lv_obj_set_style_text_font(bl, &lv_font_montserrat_20, 0); + lv_label_set_text(bl, "BACK"); + lv_obj_center(bl); +} + +static void refreshSpeakers() { + for (int i = 0; i < NUM_SPK; i++) { + lv_obj_set_style_bg_color(spkBtnDot[i], connColor(speakers[i]), 0); + const char *dev = speakers[i].cur_name[0] ? speakers[i].cur_name + : "(no device)"; + lv_label_set_text_fmt(spkBtnLabel[i], "%s\n%s", speakers[i].name, dev); + } +} + +// =========================================================================== +// DETAIL screen (per speaker) +// =========================================================================== +static void detail_arc_cb(lv_event_t *e) { + lv_obj_t *arc = lv_event_get_target(e); + uint8_t v = (uint8_t)lv_arc_get_value(arc); + Speaker &s = speakers[activeSpeaker]; + s.volume = v; + sendVolume(s, v); + lv_label_set_text_fmt(detArcPct, "%d", v); +} + +static void detail_delay_step(int dir) { + Speaker &s = speakers[activeSpeaker]; + int d = (int)s.delay_ms + dir * DELAY_STEP; + if (d < 0) d = 0; + if (d > HUB_MAX_DELAY_MS) d = HUB_MAX_DELAY_MS; + s.delay_ms = (uint16_t)d; + sendDelay(s, s.delay_ms); + lv_label_set_text_fmt(detDelayLabel, "Delay %u ms", s.delay_ms); +} +static void detail_delay_minus_cb(lv_event_t *e) { detail_delay_step(-1); } +static void detail_delay_plus_cb(lv_event_t *e) { detail_delay_step(+1); } + +static void detail_scan_cb(lv_event_t *e) { enterScan(); } + +static void detail_back_cb(lv_event_t *e) { + refreshSpeakers(); + lv_scr_load(scrSpeakers); +} + +static void buildDetail() { + scrDetail = lv_obj_create(NULL); + lv_obj_set_style_bg_color(scrDetail, COL_BG, 0); + lv_obj_clear_flag(scrDetail, LV_OBJ_FLAG_SCROLLABLE); + + // Heading (role + device name). + detHeading = lv_label_create(scrDetail); + lv_obj_set_style_text_font(detHeading, &lv_font_montserrat_20, 0); + lv_obj_set_style_text_color(detHeading, COL_CYAN, 0); + lv_label_set_long_mode(detHeading, LV_LABEL_LONG_DOT); + lv_obj_set_width(detHeading, 170); + lv_obj_set_style_text_align(detHeading, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_text(detHeading, ""); + lv_obj_align(detHeading, LV_ALIGN_TOP_MID, 0, 26); + + // Per-speaker volume arc. + detArc = lv_arc_create(scrDetail); + lv_obj_set_size(detArc, 150, 150); + lv_obj_align(detArc, LV_ALIGN_CENTER, 0, -8); + lv_arc_set_rotation(detArc, 135); + lv_arc_set_bg_angles(detArc, 0, 270); + lv_arc_set_range(detArc, 0, 100); + lv_obj_set_style_arc_width(detArc, 12, LV_PART_MAIN); + lv_obj_set_style_arc_color(detArc, COL_TRACK, LV_PART_MAIN); + lv_obj_set_style_arc_width(detArc, 12, LV_PART_INDICATOR); + lv_obj_set_style_arc_color(detArc, COL_ACCENT, LV_PART_INDICATOR); + lv_obj_set_style_bg_color(detArc, COL_ACCENT, LV_PART_KNOB); + lv_obj_set_style_pad_all(detArc, 6, LV_PART_KNOB); + lv_obj_add_event_cb(detArc, detail_arc_cb, LV_EVENT_VALUE_CHANGED, NULL); + + detArcPct = lv_label_create(scrDetail); + lv_obj_set_style_text_font(detArcPct, &lv_font_montserrat_28, 0); + lv_obj_set_style_text_color(detArcPct, COL_WHITE, 0); + lv_label_set_text(detArcPct, "0"); + lv_obj_align(detArcPct, LV_ALIGN_CENTER, 0, -8); + + // Delay control: "-" "Delay NNN ms" "+". + lv_obj_t *minus = lv_btn_create(scrDetail); + lv_obj_set_size(minus, 46, 46); + lv_obj_align(minus, LV_ALIGN_CENTER, -70, 54); + lv_obj_set_style_bg_color(minus, COL_BTN, 0); + lv_obj_add_event_cb(minus, detail_delay_minus_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t *ml = lv_label_create(minus); + lv_obj_set_style_text_font(ml, &lv_font_montserrat_28, 0); + lv_label_set_text(ml, "-"); + lv_obj_center(ml); + + detDelayLabel = lv_label_create(scrDetail); + lv_obj_set_style_text_font(detDelayLabel, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(detDelayLabel, COL_WHITE, 0); + lv_label_set_text(detDelayLabel, "Delay 0 ms"); + lv_obj_align(detDelayLabel, LV_ALIGN_CENTER, 0, 54); + + lv_obj_t *plus = lv_btn_create(scrDetail); + lv_obj_set_size(plus, 46, 46); + lv_obj_align(plus, LV_ALIGN_CENTER, 70, 54); + lv_obj_set_style_bg_color(plus, COL_BTN, 0); + lv_obj_add_event_cb(plus, detail_delay_plus_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t *pl = lv_label_create(plus); + lv_obj_set_style_text_font(pl, &lv_font_montserrat_28, 0); + lv_label_set_text(pl, "+"); + lv_obj_center(pl); + + // SCAN (left) and BACK (right) at the bottom. + lv_obj_t *scan = lv_btn_create(scrDetail); + lv_obj_set_size(scan, 90, 44); + lv_obj_align(scan, LV_ALIGN_BOTTOM_MID, -50, -14); + lv_obj_set_style_bg_color(scan, COL_BTN, 0); + lv_obj_add_event_cb(scan, detail_scan_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t *sl = lv_label_create(scan); + lv_obj_set_style_text_font(sl, &lv_font_montserrat_20, 0); + lv_label_set_text(sl, "SCAN"); + lv_obj_center(sl); + + lv_obj_t *back = lv_btn_create(scrDetail); + lv_obj_set_size(back, 90, 44); + lv_obj_align(back, LV_ALIGN_BOTTOM_MID, 50, -14); + lv_obj_set_style_bg_color(back, COL_BACK, 0); + lv_obj_add_event_cb(back, detail_back_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t *bl = lv_label_create(back); + lv_obj_set_style_text_font(bl, &lv_font_montserrat_20, 0); + lv_label_set_text(bl, "BACK"); + lv_obj_center(bl); +} + +static void refreshDetail() { + Speaker &s = speakers[activeSpeaker]; + const char *dev = s.cur_name[0] ? s.cur_name : "(no device)"; + lv_label_set_text_fmt(detHeading, "%s %s", s.name, dev); + // Don't fight the user's drag: only set the arc when not being touched. + if (!lv_obj_has_state(detArc, LV_STATE_PRESSED)) { + lv_arc_set_value(detArc, s.volume); + lv_label_set_text_fmt(detArcPct, "%d", s.volume); + } + lv_label_set_text_fmt(detDelayLabel, "Delay %u ms", s.delay_ms); +} + +// =========================================================================== +// SCAN screen +// =========================================================================== +static void scan_row_cb(lv_event_t *e) { + int listIdx = (int)(intptr_t)lv_event_get_user_data(e); + if (listIdx < 0 || listIdx >= scanItemCount) return; + const ScanItem &it = scanItems[listIdx]; + Speaker &s = speakers[activeSpeaker]; + Serial.printf("[scan] select %s idx=%u for %s\n", + it.name, it.index, s.name); + sendSelect(s, it.index); + // Optimistically show it as the current device. + strncpy(s.cur_name, it.name, sizeof(s.cur_name)); + s.cur_name[HUB_NAME_MAX] = '\0'; + refreshDetail(); + lv_scr_load(scrDetail); +} + +static void scan_rescan_cb(lv_event_t *e) { scanItemCount = 0; - screen = SCREEN_SCAN; - startScan(speakers[i]); - Serial.printf("[scan] start for %s\n", speakers[i].name); - drawScanScreen(); + startScan(speakers[activeSpeaker]); + Serial.printf("[scan] rescan for %s\n", speakers[activeSpeaker].name); + refreshScanList(); } -// Leave SCREEN_SCAN back to MAIN (optionally cancelling an active scan). -static void leaveScan(bool cancel) { - if (cancel) sendScanStop(speakers[cfgSpeaker]); - screen = SCREEN_MAIN; - drawMainScreen(); +static void scan_back_cb(lv_event_t *e) { + sendScanStop(speakers[activeSpeaker]); + refreshDetail(); + lv_scr_load(scrDetail); } -// ---- touch handlers, per screen ---- +static void buildScan() { + scrScan = lv_obj_create(NULL); + lv_obj_set_style_bg_color(scrScan, COL_BG, 0); + lv_obj_clear_flag(scrScan, LV_OBJ_FLAG_SCROLLABLE); -// Returns true if a control was hit (consumes the touch for debounce). -static bool handleTouchMain(int x, int y) { - for (int i = 0; i < 2; i++) { - for (int k = 0; k < B_COUNT; k++) { - int bx, by, bw, bh; - btnRect(i, k, bx, by, bw, bh); - if (!inRect(x, y, bx, by, bw, bh)) continue; + scanHeading = lv_label_create(scrScan); + lv_obj_set_style_text_font(scanHeading, &lv_font_montserrat_20, 0); + lv_obj_set_style_text_color(scanHeading, COL_CYAN, 0); + lv_label_set_text(scanHeading, "Scanning"); + lv_obj_align(scanHeading, LV_ALIGN_TOP_MID, 0, 24); - if (k == B_PICK) { - drawButtons(i, k); // brief feedback before redraw - enterScan(i); - return true; - } - applyMainButton(i, k); - drawButtons(i, k); - drawStatus(i); - Serial.printf("[touch] %s %s -> D%ums V%u%%\n", - speakers[i].name, kBtnLabel[k], - speakers[i].delay_ms, speakers[i].volume); - return true; - } + // Spinner shown while scanning. + scanSpinner = lv_spinner_create(scrScan, 1000, 60); + lv_obj_set_size(scanSpinner, 44, 44); + lv_obj_align(scanSpinner, LV_ALIGN_TOP_MID, 0, 52); + lv_obj_set_style_arc_color(scanSpinner, COL_TRACK, LV_PART_MAIN); + lv_obj_set_style_arc_color(scanSpinner, COL_ACCENT, LV_PART_INDICATOR); + + // Device list (scrolls natively). Kept inside the round safe area. + scanList = lv_list_create(scrScan); + lv_obj_set_size(scanList, 184, 96); + lv_obj_align(scanList, LV_ALIGN_CENTER, 0, 8); + lv_obj_set_style_bg_color(scanList, COL_BG, 0); + lv_obj_set_style_border_width(scanList, 0, 0); + + // RESCAN (left) / BACK (right). + lv_obj_t *rescan = lv_btn_create(scrScan); + lv_obj_set_size(rescan, 92, 44); + lv_obj_align(rescan, LV_ALIGN_BOTTOM_MID, -50, -12); + lv_obj_set_style_bg_color(rescan, COL_BTN, 0); + lv_obj_add_event_cb(rescan, scan_rescan_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t *rl = lv_label_create(rescan); + lv_obj_set_style_text_font(rl, &lv_font_montserrat_14, 0); + lv_label_set_text(rl, "RESCAN"); + lv_obj_center(rl); + + lv_obj_t *back = lv_btn_create(scrScan); + lv_obj_set_size(back, 92, 44); + lv_obj_align(back, LV_ALIGN_BOTTOM_MID, 50, -12); + lv_obj_set_style_bg_color(back, COL_BACK, 0); + lv_obj_add_event_cb(back, scan_back_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t *bl = lv_label_create(back); + lv_obj_set_style_text_font(bl, &lv_font_montserrat_20, 0); + lv_label_set_text(bl, "BACK"); + lv_obj_center(bl); +} + +// Rebuild the scan list rows from the local mirror; toggle spinner/heading by +// the scanning state. Called from the poll timer while SCAN is active. +static void refreshScanList() { + Speaker &s = speakers[activeSpeaker]; + + if (s.scanning) { + lv_label_set_text_fmt(scanHeading, "%s: scanning", s.name); + lv_obj_clear_flag(scanSpinner, LV_OBJ_FLAG_HIDDEN); + } else { + lv_label_set_text_fmt(scanHeading, "%s: %d found", s.name, scanItemCount); + lv_obj_add_flag(scanSpinner, LV_OBJ_FLAG_HIDDEN); + } + + // Rebuild the rows from scratch (avoids leaking / stale entries). + lv_obj_clean(scanList); + + if (scanItemCount == 0) { + if (!s.scanning) { + lv_obj_t *btn = lv_list_add_btn(scanList, NULL, "(none found)"); + lv_obj_set_style_text_color(btn, COL_GREY, 0); + } + return; + } + + for (int i = 0; i < scanItemCount; i++) { + const ScanItem &it = scanItems[i]; + char row[HUB_NAME_MAX + 16]; + snprintf(row, sizeof(row), "%s %ddB", + it.name[0] ? it.name : "(unnamed)", it.rssi); + lv_obj_t *btn = lv_list_add_btn(scanList, LV_SYMBOL_BLUETOOTH, row); + lv_obj_set_style_text_font(btn, &lv_font_montserrat_14, 0); + lv_obj_set_style_bg_color(btn, COL_BTN, 0); + lv_obj_set_style_text_color(btn, COL_WHITE, 0); + lv_obj_set_style_min_height(btn, 44, 0); // glove-friendly tap target + lv_obj_add_event_cb(btn, scan_row_cb, LV_EVENT_CLICKED, + (void *)(intptr_t)i); } - return false; } -static bool handleTouchScan(int x, int y) { - // Device rows first. - for (int row = 0; row < SCAN_ROWS; row++) { - int idx = scanScroll + row; - if (idx >= scanItemCount) continue; - int rx, ry, rw, rh; - scanRowRect(row, rx, ry, rw, rh); - if (inRect(x, y, rx, ry, rw, rh)) { - const ScanItem &it = scanItems[idx]; - Serial.printf("[scan] select %s idx=%u for %s\n", - it.name, it.index, speakers[cfgSpeaker].name); - sendSelect(speakers[cfgSpeaker], it.index); - // Optimistically show it as the current device on MAIN. - strncpy(speakers[cfgSpeaker].cur_name, it.name, - sizeof(speakers[cfgSpeaker].cur_name)); - speakers[cfgSpeaker].cur_name[HUB_NAME_MAX] = '\0'; - leaveScan(false); - return true; - } - } - // Bottom controls. - for (int c = 0; c < SC_COUNT; c++) { - int cx, cy, cw, ch; - scanCtlRect(c, cx, cy, cw, ch); - if (!inRect(x, y, cx, cy, cw, ch)) continue; - drawScanCtls(c); - switch (c) { - case SC_SCROLL_UP: - if (scanScroll > 0) { scanScroll--; drawScanList(); } - drawScanCtls(); - break; - case SC_SCROLL_DOWN: - if (scanScroll + SCAN_ROWS < scanItemCount) { - scanScroll++; - drawScanList(); - } - drawScanCtls(); - break; - case SC_RESCAN: - scanScroll = 0; - scanItemCount = 0; - startScan(speakers[cfgSpeaker]); - Serial.printf("[scan] rescan for %s\n", - speakers[cfgSpeaker].name); - drawScanScreen(); - break; - case SC_BACK: - leaveScan(true); - break; - } - return true; - } - return false; +// Enter SCAN for the active speaker: kick off a scan + show the screen. +static void enterScan() { + scanItemCount = 0; + startScan(speakers[activeSpeaker]); + Serial.printf("[scan] start for %s\n", speakers[activeSpeaker].name); + refreshScanList(); + lv_scr_load(scrScan); } +// =========================================================================== +// Periodic I2C poll, driven by an lv_timer (~1 Hz, main context — safe). +// =========================================================================== +static void poll_timer_cb(lv_timer_t *t) { + lv_obj_t *cur = lv_scr_act(); + + if (cur == scrScan) { + // While scanning, poll the active speaker + pull the list as it grows. + Speaker &s = speakers[activeSpeaker]; + bool wasScanning = s.scanning; + uint8_t prevCount = s.scan_count; + pollStatus(s); + if ((s.scan_count > 0 && s.scan_count != prevCount) || + (wasScanning && !s.scanning)) { + pollScanList(activeSpeaker); + } + refreshScanList(); + return; + } + + // Otherwise poll all channels for status + device name. + for (int i = 0; i < NUM_SPK; i++) { + pollStatus(speakers[i]); + getCurName(speakers[i], speakers[i].cur_name); + } + + if (cur == scrHome) { + refreshHome(); + } else if (cur == scrSpeakers) { + refreshSpeakers(); + } else if (cur == scrDetail) { + refreshDetail(); + } + + // Heartbeat. + static unsigned long lastBeat = 0; + unsigned long now = millis(); + if (now - lastBeat >= 5000) { + lastBeat = now; + Serial.printf("[hub] alive heap=%u", ESP.getFreeHeap()); + for (int i = 0; i < NUM_SPK; i++) { + const Speaker &s = speakers[i]; + const char *st = s.online ? (s.connected ? "on" : "idle") : "off"; + Serial.printf(" %s[%s D%u V%u]", s.name, st, s.delay_ms, s.volume); + } + Serial.println(); + } +} + +// =========================================================================== +// setup() / loop() +// =========================================================================== void setup() { Serial.begin(115200); delay(300); - Serial.println("=== BikeAudio Hub — S3 control bring-up ==="); + Serial.println("=== BikeAudio Hub — S3 LVGL UI ==="); // Control bus: I2C master to the source boards on Wire1 (peripheral 1). - // Peripheral 0 (Wire) is used by the LovyanGFX CST816S touch (GPIO6/7). + // Peripheral 0 is used by the LovyanGFX CST816S touch (GPIO6/7). Wire1.begin(CTRL_I2C_SDA, CTRL_I2C_SCL, CTRL_I2C_HZ); Serial.printf("[i2c] master up SDA=%d SCL=%d @%dHz\n", CTRL_I2C_SDA, CTRL_I2C_SCL, CTRL_I2C_HZ); @@ -676,73 +873,57 @@ void setup() { lcd.setRotation(0); lcd.setBrightness(200); - // Seed device names + status once so MAIN draws populated. - for (int i = 0; i < 2; i++) { + // LVGL core + display + touch input. + lv_init(); + lv_disp_draw_buf_init(&draw_buf, lvbuf1, lvbuf2, 240 * 40); + lv_disp_drv_init(&disp_drv); + disp_drv.hor_res = 240; + disp_drv.ver_res = 240; + disp_drv.flush_cb = disp_flush; + disp_drv.draw_buf = &draw_buf; + lv_disp_drv_register(&disp_drv); + + lv_indev_drv_init(&indev_drv); + indev_drv.type = LV_INDEV_TYPE_POINTER; + indev_drv.read_cb = touch_read; + lv_indev_drv_register(&indev_drv); + + // Seed device names + status once so the UI starts populated. + for (int i = 0; i < NUM_SPK; i++) { pollStatus(speakers[i]); getCurName(speakers[i], speakers[i].cur_name); } - drawMainScreen(); - Serial.println("[LCD] UI drawn"); + // Seed the master arc from the loudest channel that's online (best effort; + // the Guest is usually offline, so don't let it drag the seed). + masterVolume = 0; + for (int i = 0; i < NUM_SPK; i++) { + if (speakers[i].online && speakers[i].volume > masterVolume) { + masterVolume = speakers[i].volume; + } + } + + // Build all screens, then show HOME. + buildHome(); + buildSpeakers(); + buildDetail(); + buildScan(); + refreshHome(); + lv_scr_load(scrHome); + + // 1 Hz I2C poll, driven from LVGL's timer (runs in main context). + lv_timer_create(poll_timer_cb, POLL_PERIOD_MS, NULL); + + Serial.println("[LCD] LVGL UI up"); } void loop() { - unsigned long now = millis(); + // Drive LVGL's tick manually (we don't use LV_TICK_CUSTOM — its millis() + // expr can't go through build flags cleanly). + static uint32_t last_tick = 0; + uint32_t now = millis(); + lv_tick_inc(now - last_tick); + last_tick = now; - // ---- touch (debounced), dispatched by current screen ---- - static unsigned long lastTouch = 0; - int32_t tx, ty; - if (lcd.getTouch(&tx, &ty)) { - if (now - lastTouch >= TOUCH_DEBOUNCE) { - bool hit = (screen == SCREEN_MAIN) ? handleTouchMain(tx, ty) - : handleTouchScan(tx, ty); - if (hit) lastTouch = now; - } - } - - // ---- per-screen periodic work ---- - static unsigned long lastPoll = 0; - if (now - lastPoll >= POLL_PERIOD_MS) { - lastPoll = now; - if (screen == SCREEN_MAIN) { - // Status + occasional device-name refresh for both speakers. - for (int i = 0; i < 2; i++) { - pollStatus(speakers[i]); - getCurName(speakers[i], speakers[i].cur_name); - drawStatus(i); - } - } else { // SCREEN_SCAN - Speaker &s = speakers[cfgSpeaker]; - bool wasScanning = s.scanning; - uint8_t prevCount = s.scan_count; - pollStatus(s); - // Fetch the list when devices appear or when the scan finishes. - if ((s.scan_count > 0 && s.scan_count != prevCount) || - (wasScanning && !s.scanning)) { - pollScanList(cfgSpeaker); - } - drawScanList(); // also animates the scanning spinner - } - } - - // While scanning, animate the spinner a bit faster than the 1 Hz poll. - static unsigned long lastSpin = 0; - if (screen == SCREEN_SCAN && speakers[cfgSpeaker].scanning && - now - lastSpin >= 250) { - lastSpin = now; - drawScanList(); - } - - // ---- heartbeat ---- - static unsigned long lastBeat = 0; - if (now - lastBeat >= 5000) { - lastBeat = now; - Serial.printf("[hub] alive heap=%u JBL[%s D%u V%u] Cardo[%s D%u V%u]\n", - ESP.getFreeHeap(), - speakers[0].online ? (speakers[0].connected ? "on" : "idle") : "off", - speakers[0].delay_ms, speakers[0].volume, - speakers[1].online ? (speakers[1].connected ? "on" : "idle") : "off", - speakers[1].delay_ms, speakers[1].volume); - } - - delay(15); + lv_timer_handler(); + delay(5); }