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) <noreply@anthropic.com>
This commit is contained in:
blue — ESP32/PlatformIO firmware 2026-06-11 11:57:48 -04:00
parent 4b432c6123
commit de61905e9c
3 changed files with 709 additions and 107 deletions

View File

@ -84,12 +84,78 @@ 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};
// [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,10 +215,20 @@ 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() {
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).
// Carry any partial frame across reads so L/R never slips out of alignment.
@ -221,12 +332,32 @@ 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);
// 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 '%s' — trim %u ms; touch + GPIO4, - GPIO27\n",
Serial.printf("[SRC] Connecting to fallback '%s' — trim %u ms; touch + GPIO4, - GPIO27\n",
TARGET_SPEAKER, delay_ms_current);
}
}
void loop() {
unsigned long now = millis();
@ -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) {

View File

@ -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

View File

@ -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 <Arduino.h>
#include <Wire.h>
#include <string.h>
#include <LovyanGFX.hpp>
#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();
uint8_t b[HUB_STATUS_LEN];
for (int i = 0; i < HUB_STATUS_LEN; i++) b[i] = Wire1.read();
s.online = true;
s.connected = (b0 != 0);
s.delay_ms = (uint16_t)(b1 | (b2 << 8));
s.volume = b3;
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,16 +391,10 @@ 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) {
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;
// 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: {
@ -260,41 +402,265 @@ static bool handleTouch(int x, int y) {
if (d < 0) d = 0;
s.delay_ms = (uint16_t)d;
sendDelay(s, s.delay_ms);
break;
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);
break;
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);
break;
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);
break;
return true;
}
default:
return false;
}
}
// Visual feedback + optimistic value update.
// ===========================================================================
// 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: "<role>: scanning" with a spinner, or "<n> 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;
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",
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,32 +675,61 @@ 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;
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 ----