From de61905e9c3406741aaf8892ca5ba444023143a7 Mon Sep 17 00:00:00 2001 From: blue Date: Thu, 11 Jun 2026 11:57:48 -0400 Subject: [PATCH] Phase 3: BT speaker discovery + selection (no more hardcoded names) The source boards now scan for nearby Bluetooth speakers and connect to a user-chosen one; the hub screen drives it. Hardcoded TARGET_SPEAKER remains only as a first-boot fallback when nothing is saved. - hub_proto.h: status payload -> 7 bytes (adds scanning, scan_count, has_device). New cmds: SCAN_START/STOP, SELECT(index), FORGET, GET_SCANITEM(index)->33-byte item (valid,rssi,mac,namelen,name), GET_CURNAME->24-byte name. - board_source.cpp: set_ssid_callback collects nearby sinks (dedupe by MAC, COD-filtered to audio), set_discovery_mode_callback stops the scan loop, connect_to(mac) + set_auto_reconnect(mac) on SELECT, MAC+name persisted to NVS (spk_mac/spk_name). Boot connects to saved MAC or falls back to the name. onRequest multiplexes status/scanitem/curname by last read command. - hub_s3.cpp: MAIN screen now shows each speaker's selected device name + a "pick" button; SCREEN_SCAN shows the live discovery list (RSSI-sorted, scrollable) with tap-to-select, Rescan, Back. All I2C on Wire1. Built by two parallel sub-agents against hub_proto.h; all three envs build clean first try. Needs the I2C bus wired to test on hardware. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/board_source.cpp | 199 ++++++++++++++- src/hub_proto.h | 26 +- src/hub_s3.cpp | 591 ++++++++++++++++++++++++++++++++++++------- 3 files changed, 709 insertions(+), 107 deletions(-) diff --git a/src/board_source.cpp b/src/board_source.cpp index 9d80ed9..4150ad7 100644 --- a/src/board_source.cpp +++ b/src/board_source.cpp @@ -83,13 +83,79 @@ static volatile bool have_volume = false; static volatile uint8_t pending_volume = 0; // Status snapshot served on HUB_CMD_GET_STATUS reads; refreshed by loop(). -// [0]=connected [1]=delay lo [2]=delay hi [3]=volume -static volatile uint8_t status_buf[HUB_STATUS_LEN] = {0, 0, 0, 100}; +// [0]=connected [1]=delay lo [2]=delay hi [3]=volume +// [4]=scanning [5]=scan_count [6]=has_device (Phase 3) +static volatile uint8_t status_buf[HUB_STATUS_LEN] = {0, 0, 0, 100, 0, 0, 0}; + +// --- Phase 3: BT speaker discovery / selection -------------------------- +// A discovered device collected during an inquiry scan. +struct ScanDev { + char name[HUB_NAME_MAX + 1]; + uint8_t mac[6]; + int8_t rssi; +}; +static ScanDev scan_list[HUB_MAX_SCAN]; +static volatile uint8_t scan_count = 0; +static volatile bool scanning = false; + +// Currently selected / saved speaker. +static char cur_name[HUB_NAME_MAX + 1] = {0}; +static uint8_t cur_mac[6] = {0}; +static bool has_device = false; + +// I2C request flags set by the Wire callbacks, applied in loop(). +static volatile bool req_scan_start = false; +static volatile bool req_scan_stop = false; +static volatile bool req_select = false; +static volatile uint8_t pending_select = 0; +static volatile bool req_forget = false; + +// Which read buffer the next onRequest should serve. The master sets it by +// writing the read command byte just before issuing the read. Multiplexed in +// on_i2c_request() so GET_STATUS / GET_SCANITEM / GET_CURNAME can share the bus. +static volatile uint8_t last_read_cmd = HUB_CMD_GET_STATUS; +static volatile uint8_t pending_scanitem = 0; // index requested via GET_SCANITEM +// Pre-filled in the callback (light memcpy) and handed back on the read. +static volatile uint8_t scanitem_buf[HUB_SCANITEM_LEN] = {0}; + +// Per-device callback fired during a scan (plain C fn ptr — no captures). +// Stash discovered audio sinks into scan_list; never connect (return false). +static bool on_ssid(const char *ssid, esp_bd_addr_t addr, int rssi) { + if (!scanning) return false; + if (scan_count >= HUB_MAX_SCAN) return false; + // Dedupe by MAC. + for (uint8_t i = 0; i < scan_count; i++) { + if (memcmp(scan_list[i].mac, addr, 6) == 0) return false; + } + ScanDev &d = scan_list[scan_count]; + if (ssid) { + strncpy(d.name, ssid, HUB_NAME_MAX); + d.name[HUB_NAME_MAX] = 0; + } else { + d.name[0] = 0; + } + memcpy(d.mac, addr, 6); + if (rssi > 127) rssi = 127; + if (rssi < -128) rssi = -128; + d.rssi = (int8_t)rssi; + scan_count++; + return false; // keep scanning; collect everything +} + +// Discovery state changes. The library auto-restarts discovery after STOPPED if +// nothing matched, so explicitly cancel to halt the loop. +static void on_disc_state(esp_bt_gap_discovery_state_t state) { + if (state == ESP_BT_GAP_DISCOVERY_STOPPED) { + scanning = false; + source.cancel_discovery(); + } +} // Master wrote [cmd][args...]; parse into request flags only. static void on_i2c_receive(int n) { if (n < 1) return; uint8_t cmd = Wire.read(); + last_read_cmd = cmd; // remember for the following onRequest (if any) switch (cmd) { case HUB_CMD_SET_DELAY: if (Wire.available() >= 2) { @@ -105,6 +171,41 @@ static void on_i2c_receive(int n) { have_volume = true; } break; + case HUB_CMD_SCAN_START: + req_scan_start = true; + break; + case HUB_CMD_SCAN_STOP: + req_scan_stop = true; + break; + case HUB_CMD_SELECT: + if (Wire.available() >= 1) { + pending_select = Wire.read(); + req_select = true; + } + break; + case HUB_CMD_FORGET: + req_forget = true; + break; + case HUB_CMD_GET_SCANITEM: { + // Prep scanitem_buf NOW from the list (light memcpy is OK here). + uint8_t idx = Wire.available() >= 1 ? Wire.read() : 0xFF; + pending_scanitem = idx; + uint8_t *b = (uint8_t *)scanitem_buf; + memset(b, 0, HUB_SCANITEM_LEN); + if (idx < scan_count) { + ScanDev &d = scan_list[idx]; + b[0] = 1; // valid + b[1] = (uint8_t)d.rssi; // rssi (int8) + memcpy(&b[2], d.mac, 6); // MAC + uint8_t nl = (uint8_t)strnlen(d.name, HUB_NAME_MAX); + b[8] = nl; // name length + memcpy(&b[9], d.name, nl); // name bytes + } + break; + } + case HUB_CMD_GET_CURNAME: + // Nothing to do: onRequest serves cur_name (NUL-padded). + break; case HUB_CMD_GET_STATUS: // Nothing to do: onRequest serves the pre-filled status_buf. break; @@ -114,9 +215,19 @@ static void on_i2c_receive(int n) { while (Wire.available()) Wire.read(); // drain any extra bytes } -// Master issued a read after GET_STATUS: hand back the current snapshot. +// Master issued a read; pick the buffer keyed by the last command byte. static void on_i2c_request() { - Wire.write((uint8_t *)status_buf, HUB_STATUS_LEN); + if (last_read_cmd == HUB_CMD_GET_SCANITEM) { + Wire.write((uint8_t *)scanitem_buf, HUB_SCANITEM_LEN); + } else if (last_read_cmd == HUB_CMD_GET_CURNAME) { + uint8_t nbuf[HUB_NAME_MAX]; + uint8_t nl = (uint8_t)strnlen(cur_name, HUB_NAME_MAX); + memcpy(nbuf, cur_name, nl); + if (nl < HUB_NAME_MAX) memset(nbuf + nl, 0, HUB_NAME_MAX - nl); + Wire.write(nbuf, HUB_NAME_MAX); + } else { + Wire.write((uint8_t *)status_buf, HUB_STATUS_LEN); + } } // Continuously pull I2S into the FIFO (paced by Board A's master clock). @@ -221,11 +332,31 @@ void setup() { source.set_data_callback_in_frames(read_delayed); source.set_on_connection_state_changed(on_conn_state); - source.set_auto_reconnect(true, 5); source.set_volume(current_volume); - source.start(TARGET_SPEAKER); - Serial.printf("[SRC] Connecting to '%s' — trim %u ms; touch + GPIO4, - GPIO27\n", - TARGET_SPEAKER, delay_ms_current); + + // Load any saved/selected speaker (Phase 3). "spk_mac" is a 6-byte blob. + uint8_t savedMac[6]; + size_t got = prefs.getBytes("spk_mac", savedMac, 6); + if (got == 6) { + memcpy(cur_mac, savedMac, 6); + String sn = prefs.getString("spk_name", ""); + strncpy(cur_name, sn.c_str(), HUB_NAME_MAX); + cur_name[HUB_NAME_MAX] = 0; + has_device = true; + source.set_auto_reconnect(cur_mac); // remember + auto-reconnect to it + source.start(); + Serial.printf("[SRC] Reconnecting to saved '%s' (%02X:%02X:%02X:%02X:%02X:%02X) — trim %u ms\n", + cur_name, cur_mac[0], cur_mac[1], cur_mac[2], + cur_mac[3], cur_mac[4], cur_mac[5], delay_ms_current); + } else { + // No saved device — fall back to the hardcoded target by name. + has_device = false; + cur_name[0] = 0; + source.set_auto_reconnect(true, 5); + source.start(TARGET_SPEAKER); + Serial.printf("[SRC] Connecting to fallback '%s' — trim %u ms; touch + GPIO4, - GPIO27\n", + TARGET_SPEAKER, delay_ms_current); + } } void loop() { @@ -249,11 +380,63 @@ void loop() { } } + // --- Phase 3: apply discovery / selection requests (heavy work here) --- + if (req_scan_start) { + req_scan_start = false; + scan_count = 0; + memset(scan_list, 0, sizeof(scan_list)); + scanning = true; + source.disconnect(); + source.set_auto_reconnect(false); // force a fresh discovery + source.set_ssid_callback(on_ssid); + source.set_discovery_mode_callback(on_disc_state); + source.start(); // begin inquiry scan + Serial.printf("[SRC %s] scan started\n", TARGET_SPEAKER); + } + if (req_scan_stop) { + req_scan_stop = false; + source.cancel_discovery(); + scanning = false; + Serial.printf("[SRC %s] scan stopped (%u found)\n", TARGET_SPEAKER, scan_count); + } + if (req_select) { + req_select = false; + uint8_t idx = pending_select; + if (idx < scan_count) { + source.cancel_discovery(); + scanning = false; + memcpy(cur_mac, scan_list[idx].mac, 6); + strncpy(cur_name, scan_list[idx].name, HUB_NAME_MAX); + cur_name[HUB_NAME_MAX] = 0; + source.connect_to(cur_mac); + source.set_auto_reconnect(cur_mac); + prefs.putBytes("spk_mac", cur_mac, 6); + prefs.putString("spk_name", cur_name); + has_device = true; + Serial.printf("[SRC %s] selected '%s' (%02X:%02X:%02X:%02X:%02X:%02X)\n", + TARGET_SPEAKER, cur_name, cur_mac[0], cur_mac[1], cur_mac[2], + cur_mac[3], cur_mac[4], cur_mac[5]); + } + } + if (req_forget) { + req_forget = false; + source.disconnect(); + prefs.remove("spk_mac"); + prefs.remove("spk_name"); + has_device = false; + cur_name[0] = 0; + memset(cur_mac, 0, 6); + Serial.printf("[SRC %s] forgot saved device\n", TARGET_SPEAKER); + } + // Refresh the status snapshot served on GET_STATUS reads. status_buf[0] = source.is_connected() ? 1 : 0; status_buf[1] = (uint8_t)(delay_ms_current & 0xFF); status_buf[2] = (uint8_t)(delay_ms_current >> 8); status_buf[3] = current_volume; + status_buf[4] = scanning ? 1 : 0; + status_buf[5] = scan_count; + status_buf[6] = has_device ? 1 : 0; static unsigned long last_touch = 0; if (now - last_touch >= TOUCH_REPEAT_MS) { diff --git a/src/hub_proto.h b/src/hub_proto.h index b8a5deb..9d13b4b 100644 --- a/src/hub_proto.h +++ b/src/hub_proto.h @@ -25,4 +25,28 @@ // [1] delay_ms low byte // [2] delay_ms high byte // [3] volume (0..100) -#define HUB_STATUS_LEN 4 +// [4] scanning (0/1) (Phase 3) +// [5] scan device count (Phase 3) +// [6] has a saved/selected device (0/1) (Phase 3) +#define HUB_STATUS_LEN 7 + +// --- Phase 3: speaker discovery / selection ------------------------------- +// The source board runs a BT inquiry scan (audio pauses during a scan), +// collects nearby audio sinks (name+MAC), and connects to a chosen one. +#define HUB_CMD_SCAN_START 0x03 // write: clear list + begin a fresh discovery scan +#define HUB_CMD_SCAN_STOP 0x04 // write: cancel scanning +#define HUB_CMD_SELECT 0x05 // write [uint8 index]: connect to scan item + persist (NVS) +#define HUB_CMD_FORGET 0x06 // write: forget saved device (disconnect + erase) +#define HUB_CMD_GET_SCANITEM 0x14 // write [uint8 index]; THEN read HUB_SCANITEM_LEN bytes +#define HUB_CMD_GET_CURNAME 0x15 // read HUB_NAME_MAX bytes: current/selected device name (NUL-padded) + +#define HUB_MAX_SCAN 12 // max devices reported +#define HUB_NAME_MAX 24 // max device-name length carried over the bus + +// Scan-item payload (fixed length), returned on the read after GET_SCANITEM[index]: +// [0] valid (0/1 — 0 if index >= count) +// [1] rssi (int8) +// [2..7] MAC (6 bytes) +// [8] name length (<= HUB_NAME_MAX) +// [9 ..] name bytes +#define HUB_SCANITEM_LEN (9 + HUB_NAME_MAX) // 33 diff --git a/src/hub_s3.cpp b/src/hub_s3.cpp index 1ccc00f..1f40607 100644 --- a/src/hub_s3.cpp +++ b/src/hub_s3.cpp @@ -1,11 +1,16 @@ /** - * BikeAudio — Hub (ESP32-S3-Touch-LCD-1.28) : PHASE 2 control bring-up + * 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, delay (ms) and - * volume (%), with touch zones to nudge delay / volume +/-. This proves the - * end-to-end control path before we build the polished LVGL GUI. + * 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. + * + * 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. * * Two I2C buses are in use, on separate ports: * - port 0 (Wire1, owned by LovyanGFX): CST816S touch SDA=6 SCL=7 @@ -22,6 +27,7 @@ #define LGFX_USE_V1 #include #include +#include #include #include "hub_proto.h" @@ -94,18 +100,43 @@ LGFX lcd; // transaction itself succeeded (board present on the bus at all). struct Speaker { uint8_t addr; - const char *name; + const char *name; // role label (JBL / Cardo), 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) }; static Speaker speakers[2] = { - { HUB_I2C_ADDR_JBL, "JBL", false, false, 0, 0 }, - { HUB_I2C_ADDR_CARDO, "Cardo", false, false, 0, 0 }, + { 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} }, }; +// 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; + +// --------------------------------------------------------------------------- +// 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 + // --------------------------------------------------------------------------- // I2C helpers // --------------------------------------------------------------------------- @@ -126,14 +157,118 @@ static void pollStatus(Speaker &s) { s.online = false; return; } - uint8_t b0 = Wire1.read(); - uint8_t b1 = Wire1.read(); - uint8_t b2 = Wire1.read(); - uint8_t b3 = Wire1.read(); - s.online = true; - s.connected = (b0 != 0); - s.delay_ms = (uint16_t)(b1 | (b2 << 8)); - s.volume = b3; + 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) { + // 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'; +} + +// 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; + if (scanScroll > scanItemCount) scanScroll = 0; } // Command a new delay (ms) to a speaker. @@ -153,32 +288,40 @@ static void sendVolume(const Speaker &s, uint8_t vol) { Wire1.endTransmission(); } -// --------------------------------------------------------------------------- -// UI layout -// --------------------------------------------------------------------------- +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 +// =========================================================================== // 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 has a -// row of four square buttons: [d-] [d+] [v-] [v+]. Status text sits above. +// 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: -// JBL title ~22, status ~46, buttons ~70..102 -// Cardo title ~138, status ~162, buttons ~186..218 +// 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) -#define BTN_W 34 -#define BTN_H 32 -#define BTN_GAP 6 +#define BTN_W 30 +#define BTN_H 30 +#define BTN_GAP 5 -// 4 buttons centered horizontally: total width = 4*W + 3*GAP -static const int kBtnTotalW = 4 * BTN_W + 3 * BTN_GAP; -static const int kBtnX0 = (240 - kBtnTotalW) / 2; // left edge of first button +// 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" }; -static const int kBtnYTop[2] = { 70, 186 }; // button-row top Y for each speaker +// 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 -// Button kinds, in draw/hit order. -enum BtnKind { B_DELAY_DOWN = 0, B_DELAY_UP, B_VOL_DOWN, B_VOL_UP, B_COUNT }; -static const char *kBtnLabel[B_COUNT] = { "d-", "d+", "v-", "v+" }; +static const int kTitleY[2] = { 18, 136 }; +static const int kNameY[2] = { 38, 156 }; +static const int kStatusY[2] = { 58, 176 }; -// Bounding box of button `k` for speaker index `i`. +// 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]; @@ -186,16 +329,14 @@ static void btnRect(int i, int k, int &x, int &y, int &w, int &h) { h = BTN_H; } -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; -} - -// Draw the four control buttons for speaker `i`. `pressed` highlights one. +// 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 fill = (k == pressed) ? TFT_DARKCYAN : TFT_NAVY; + 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); @@ -204,21 +345,29 @@ static void drawButtons(int i, int pressed = -1) { } } -// Title + status line for speaker `i` (online/connected, delay, volume). +// Title + device name + delay/volume status for speaker `i`. static void drawStatus(int i) { const Speaker &s = speakers[i]; - int titleY = (i == 0) ? 22 : 138; - int statusY = (i == 0) ? 46 : 162; - // Title with a connect dot to its left. + // Title (role label) with a connect dot to its left. lcd.setTextDatum(middle_center); lcd.setTextColor(TFT_CYAN, TFT_BLACK); - lcd.drawString(s.name, 120, titleY, &fonts::Font2); - + 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 - 36, titleY, 5, dot); + lcd.fillCircle(120 - 40, kTitleY[i], 5, dot); - // Status line: either a dash (offline) or "Dnnn Vnn%". + // 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); + } 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 --"); @@ -227,13 +376,12 @@ static void drawStatus(int i) { snprintf(buf, sizeof(buf), "D%ums V%u%%", s.delay_ms, s.volume); lcd.setTextColor(TFT_WHITE, TFT_BLACK); } - // Clear the old line first (full-width band) then draw centered. - lcd.fillRect(0, statusY - 9, 240, 18, TFT_BLACK); - lcd.drawString(buf, 120, statusY, &fonts::Font2); + lcd.fillRect(0, kStatusY[i] - 8, 240, 16, TFT_BLACK); + lcd.drawString(buf, 120, kStatusY[i], &fonts::Font2); } -// Redraw the whole static frame once (background, divider, both halves). -static void drawFrame() { +// 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 @@ -243,58 +391,276 @@ static void drawFrame() { } } -// Handle a touch at (x,y): find the button, apply step, send I2C command and -// optimistically update + redraw the affected speaker. Returns true if a -// button was hit. -static bool handleTouch(int x, int y) { +// 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; + } +} + +// =========================================================================== +// SCREEN_SCAN layout +// =========================================================================== +// 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 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; +} + +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; +} + +// Redraw just the device-list area (header + rows). Used live while scanning. +static void drawScanList() { + const Speaker &s = speakers[cfgSpeaker]; + + // 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); + + // 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); + } + + // "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); + } +} + +// 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 drawScanScreen() { + lcd.fillScreen(TFT_BLACK); + lcd.drawCircle(120, 120, 118, TFT_DARKGREY); + drawScanList(); + drawScanCtls(); +} + +// Enter SCREEN_SCAN for speaker `i`: kick off a scan and draw the screen. +static void enterScan(int i) { + cfgSpeaker = i; + scanScroll = 0; + scanItemCount = 0; + screen = SCREEN_SCAN; + startScan(speakers[i]); + Serial.printf("[scan] start for %s\n", speakers[i].name); + drawScanScreen(); +} + +// 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(); +} + +// ---- touch handlers, per screen ---- + +// 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; - 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); - break; - } - 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); - break; - } - 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); - break; - } - 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); - break; - } + if (k == B_PICK) { + drawButtons(i, k); // brief feedback before redraw + enterScan(i); + return true; } - // Visual feedback + optimistic value update. + applyMainButton(i, k); drawButtons(i, k); drawStatus(i); Serial.printf("[touch] %s %s -> D%ums V%u%%\n", - s.name, kBtnLabel[k], s.delay_ms, s.volume); + speakers[i].name, kBtnLabel[k], + speakers[i].delay_ms, speakers[i].volume); return true; } } 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; +} + void setup() { Serial.begin(115200); delay(300); @@ -309,34 +675,63 @@ void setup() { lcd.init(); lcd.setRotation(0); lcd.setBrightness(200); - drawFrame(); + + // Seed device names + status once so MAIN draws populated. + for (int i = 0; i < 2; i++) { + pollStatus(speakers[i]); + getCurName(speakers[i], speakers[i].cur_name); + } + drawMainScreen(); Serial.println("[LCD] UI drawn"); } void loop() { unsigned long now = millis(); - // ---- touch (debounced) ---- + // ---- 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) { - if (handleTouch(tx, ty)) { - lastTouch = now; - } + bool hit = (screen == SCREEN_MAIN) ? handleTouchMain(tx, ty) + : handleTouchScan(tx, ty); + if (hit) lastTouch = now; } } - // ---- status poll (~1 Hz) ---- + // ---- per-screen periodic work ---- static unsigned long lastPoll = 0; if (now - lastPoll >= POLL_PERIOD_MS) { lastPoll = now; - for (int i = 0; i < 2; i++) { - pollStatus(speakers[i]); - drawStatus(i); + 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) {