/** * Resound — Hub (ESP32-S3-Touch-LCD-1.28) : PHASE 3 discovery + control * * 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. * * 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'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 * CST816S touch (I2C): SDA=6 SCL=7 (polled; INT left unused) * Control bus (I2C master): SDA=15 SCL=16 * * Build: pio run -e hub_s3 | flash: esp32s3, bootloader@0x0 */ #define LGFX_USE_V1 #include #include #include #include #include #include "bus_proto.h" class LGFX : public lgfx::LGFX_Device { lgfx::Panel_GC9A01 _panel; lgfx::Bus_SPI _bus; lgfx::Light_PWM _light; lgfx::Touch_CST816S _touch; public: LGFX() { { auto c = _bus.config(); c.spi_host = SPI2_HOST; c.spi_mode = 0; c.freq_write = 40000000; c.pin_sclk = 10; c.pin_mosi = 11; c.pin_miso = 12; c.pin_dc = 8; _bus.config(c); _panel.setBus(&_bus); } { auto c = _panel.config(); c.pin_cs = 9; c.pin_rst = 14; c.panel_width = 240; c.panel_height = 240; c.offset_x = 0; c.offset_y = 0; c.readable = false; c.invert = true; // GC9A01 typically needs inversion c.rgb_order = false; _panel.config(c); } { auto c = _light.config(); c.pin_bl = 2; c.freq = 12000; c.pwm_channel = 7; _light.config(c); _panel.setLight(&_light); } { auto c = _touch.config(); c.i2c_port = 0; c.pin_sda = 6; c.pin_scl = 7; c.pin_int = -1; // poll over I2C (avoid INT-pin ambiguity) c.pin_rst = -1; c.i2c_addr = 0x15; c.freq = 400000; c.x_min = 0; c.x_max = 239; c.y_min = 0; c.y_max = 239; _touch.config(c); _panel.setTouch(&_touch); } setPanel(&_panel); } }; LGFX lcd; // --------------------------------------------------------------------------- // Control-bus (I2C master) config // --------------------------------------------------------------------------- #define CTRL_I2C_SDA 15 #define CTRL_I2C_SCL 16 #define CTRL_I2C_HZ 100000 #define DELAY_STEP 5 // ms per delay +/- press #define POLL_PERIOD_MS 1000 // status poll cadence per speaker // 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 (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; uint8_t volume; // Phase 3 discovery state (from the 7-byte status payload). bool scanning; // board is currently running a BT inquiry scan uint8_t scan_count; // devices found so far in the current scan bool has_device; // a device is saved/selected on the board char cur_name[HUB_NAME_MAX + 1]; // selected BT device name (NUL-term) }; #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. struct ScanItem { uint8_t index; // index on the board (passed back in SELECT) int8_t rssi; uint8_t mac[6]; char name[HUB_NAME_MAX + 1]; // NUL-terminated }; // Local mirror of the current scan list (for the speaker being configured). static ScanItem scanItems[HUB_MAX_SCAN]; static int scanItemCount = 0; // Which speaker DETAIL / SCAN is operating on (0..NUM_SPK-1). static int activeSpeaker = 0; // =========================================================================== // 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. // 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) { Wire1.beginTransmission(s.addr); Wire1.write(HUB_CMD_GET_STATUS); if (Wire1.endTransmission(true) != 0) { s.online = false; return; } int n = Wire1.requestFrom((int)s.addr, (int)HUB_STATUS_LEN); if (n < HUB_STATUS_LEN) { s.online = false; return; } uint8_t b[HUB_STATUS_LEN]; for (int i = 0; i < HUB_STATUS_LEN; i++) b[i] = Wire1.read(); s.online = true; s.connected = (b[0] != 0); s.delay_ms = (uint16_t)(b[1] | (b[2] << 8)); s.volume = b[3]; s.scanning = (b[4] != 0); s.scan_count = b[5]; s.has_device = (b[6] != 0); } // Fetch the board's current/selected device name into out (HUB_NAME_MAX+1). // On failure out is left empty. The name comes back NUL-padded. static void getCurName(Speaker &s, char *out) { out[0] = '\0'; Wire1.beginTransmission(s.addr); Wire1.write(HUB_CMD_GET_CURNAME); if (Wire1.endTransmission(true) != 0) return; int n = Wire1.requestFrom((int)s.addr, (int)HUB_NAME_MAX); if (n < HUB_NAME_MAX) { while (Wire1.available()) Wire1.read(); return; } for (int i = 0; i < HUB_NAME_MAX; i++) { out[i] = (char)Wire1.read(); } out[HUB_NAME_MAX] = '\0'; } // Begin a fresh discovery scan on speaker s. static void startScan(Speaker &s) { Wire1.beginTransmission(s.addr); Wire1.write(HUB_CMD_SCAN_START); Wire1.endTransmission(true); } // Cancel an in-progress scan on speaker s. static void sendScanStop(Speaker &s) { Wire1.beginTransmission(s.addr); Wire1.write(HUB_CMD_SCAN_STOP); Wire1.endTransmission(true); } // Select scan item `index` on speaker s (connect + persist on the board). static void sendSelect(Speaker &s, uint8_t index) { Wire1.beginTransmission(s.addr); Wire1.write(HUB_CMD_SELECT); Wire1.write(index); Wire1.endTransmission(true); } // Read scan item `index` from speaker s into `out`. Returns true if valid. static bool getScanItem(Speaker &s, uint8_t index, ScanItem &out) { Wire1.beginTransmission(s.addr); Wire1.write(HUB_CMD_GET_SCANITEM); Wire1.write(index); if (Wire1.endTransmission(true) != 0) return false; int n = Wire1.requestFrom((int)s.addr, (int)HUB_SCANITEM_LEN); if (n < HUB_SCANITEM_LEN) { while (Wire1.available()) Wire1.read(); return false; } uint8_t valid = Wire1.read(); int8_t rssi = (int8_t)Wire1.read(); uint8_t mac[6]; for (int i = 0; i < 6; i++) mac[i] = Wire1.read(); uint8_t namelen = Wire1.read(); if (namelen > HUB_NAME_MAX) namelen = HUB_NAME_MAX; char name[HUB_NAME_MAX + 1]; for (int i = 0; i < HUB_NAME_MAX; i++) { char c = (char)Wire1.read(); if (i < namelen) name[i] = c; } name[namelen] = '\0'; if (!valid) return false; out.index = index; out.rssi = rssi; memcpy(out.mac, mac, 6); strncpy(out.name, name, sizeof(out.name)); out.name[HUB_NAME_MAX] = '\0'; return true; } // Refresh the local scan-list mirror for speaker `i` from the board. Reads up // to scan_count items (capped to HUB_MAX_SCAN) and stores the valid ones, // sorted strongest-RSSI first so the cap keeps the best candidates. static void pollScanList(int i) { Speaker &s = speakers[i]; int want = s.scan_count; if (want > HUB_MAX_SCAN) want = HUB_MAX_SCAN; int got = 0; for (int idx = 0; idx < want && got < HUB_MAX_SCAN; idx++) { ScanItem it; if (getScanItem(s, (uint8_t)idx, it)) { scanItems[got++] = it; } } // Strongest RSSI first (simple insertion sort; list is tiny). for (int a = 1; a < got; a++) { ScanItem key = scanItems[a]; int b = a - 1; while (b >= 0 && scanItems[b].rssi < key.rssi) { scanItems[b + 1] = scanItems[b]; b--; } scanItems[b + 1] = key; } scanItemCount = got; } // Command a new delay (ms) to a speaker. static void sendDelay(const Speaker &s, uint16_t ms) { Wire1.beginTransmission(s.addr); Wire1.write(HUB_CMD_SET_DELAY); Wire1.write(ms & 0xFF); Wire1.write((ms >> 8) & 0xFF); Wire1.endTransmission(); } // Command a new volume (0..100) to a speaker. static void sendVolume(const Speaker &s, uint8_t vol) { Wire1.beginTransmission(s.addr); Wire1.write(HUB_CMD_SET_VOLUME); Wire1.write(vol); Wire1.endTransmission(); } // =========================================================================== // LVGL <-> LovyanGFX glue // =========================================================================== 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; 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); } 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 { data->state = LV_INDEV_STATE_REL; } } // =========================================================================== // UI palette / helpers // =========================================================================== #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) // 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; } // =========================================================================== // 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; // 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) // 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 // DETAIL widgets static lv_obj_t *detHeading; static lv_obj_t *detArc; static lv_obj_t *detArcPct; static lv_obj_t *detDelayLabel; // 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); } } 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); } } // =========================================================================== // SPEAKERS screen // =========================================================================== static void speakers_back_cb(lv_event_t *e) { refreshHome(); lv_scr_load(scrHome); } 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; startScan(speakers[activeSpeaker]); Serial.printf("[scan] rescan for %s\n", speakers[activeSpeaker].name); refreshScanList(); } static void scan_back_cb(lv_event_t *e) { sendScanStop(speakers[activeSpeaker]); refreshDetail(); lv_scr_load(scrDetail); } 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); 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); // 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); } } // 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("=== Resound Hub — S3 LVGL UI ==="); // Control bus: I2C master to the source boards on Wire1 (peripheral 1). // 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); lcd.init(); lcd.setRotation(0); lcd.setBrightness(200); // 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); } // 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() { // 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; lv_timer_handler(); delay(5); }