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:
parent
4b432c6123
commit
de61905e9c
@ -83,13 +83,79 @@ static volatile bool have_volume = false;
|
|||||||
static volatile uint8_t pending_volume = 0;
|
static volatile uint8_t pending_volume = 0;
|
||||||
|
|
||||||
// Status snapshot served on HUB_CMD_GET_STATUS reads; refreshed by loop().
|
// Status snapshot served on HUB_CMD_GET_STATUS reads; refreshed by loop().
|
||||||
// [0]=connected [1]=delay lo [2]=delay hi [3]=volume
|
// [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.
|
// Master wrote [cmd][args...]; parse into request flags only.
|
||||||
static void on_i2c_receive(int n) {
|
static void on_i2c_receive(int n) {
|
||||||
if (n < 1) return;
|
if (n < 1) return;
|
||||||
uint8_t cmd = Wire.read();
|
uint8_t cmd = Wire.read();
|
||||||
|
last_read_cmd = cmd; // remember for the following onRequest (if any)
|
||||||
switch (cmd) {
|
switch (cmd) {
|
||||||
case HUB_CMD_SET_DELAY:
|
case HUB_CMD_SET_DELAY:
|
||||||
if (Wire.available() >= 2) {
|
if (Wire.available() >= 2) {
|
||||||
@ -105,6 +171,41 @@ static void on_i2c_receive(int n) {
|
|||||||
have_volume = true;
|
have_volume = true;
|
||||||
}
|
}
|
||||||
break;
|
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:
|
case HUB_CMD_GET_STATUS:
|
||||||
// Nothing to do: onRequest serves the pre-filled status_buf.
|
// Nothing to do: onRequest serves the pre-filled status_buf.
|
||||||
break;
|
break;
|
||||||
@ -114,9 +215,19 @@ static void on_i2c_receive(int n) {
|
|||||||
while (Wire.available()) Wire.read(); // drain any extra bytes
|
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() {
|
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).
|
// 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_data_callback_in_frames(read_delayed);
|
||||||
source.set_on_connection_state_changed(on_conn_state);
|
source.set_on_connection_state_changed(on_conn_state);
|
||||||
source.set_auto_reconnect(true, 5);
|
|
||||||
source.set_volume(current_volume);
|
source.set_volume(current_volume);
|
||||||
source.start(TARGET_SPEAKER);
|
|
||||||
Serial.printf("[SRC] Connecting to '%s' — trim %u ms; touch + GPIO4, - GPIO27\n",
|
// Load any saved/selected speaker (Phase 3). "spk_mac" is a 6-byte blob.
|
||||||
TARGET_SPEAKER, delay_ms_current);
|
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() {
|
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.
|
// Refresh the status snapshot served on GET_STATUS reads.
|
||||||
status_buf[0] = source.is_connected() ? 1 : 0;
|
status_buf[0] = source.is_connected() ? 1 : 0;
|
||||||
status_buf[1] = (uint8_t)(delay_ms_current & 0xFF);
|
status_buf[1] = (uint8_t)(delay_ms_current & 0xFF);
|
||||||
status_buf[2] = (uint8_t)(delay_ms_current >> 8);
|
status_buf[2] = (uint8_t)(delay_ms_current >> 8);
|
||||||
status_buf[3] = current_volume;
|
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;
|
static unsigned long last_touch = 0;
|
||||||
if (now - last_touch >= TOUCH_REPEAT_MS) {
|
if (now - last_touch >= TOUCH_REPEAT_MS) {
|
||||||
|
|||||||
@ -25,4 +25,28 @@
|
|||||||
// [1] delay_ms low byte
|
// [1] delay_ms low byte
|
||||||
// [2] delay_ms high byte
|
// [2] delay_ms high byte
|
||||||
// [3] volume (0..100)
|
// [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
|
||||||
|
|||||||
591
src/hub_s3.cpp
591
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
|
* 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
|
* 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
|
* simple functional touch UI: each speaker's connect state, current device
|
||||||
* volume (%), with touch zones to nudge delay / volume +/-. This proves the
|
* name, delay (ms) and volume (%), with touch zones to nudge delay / volume
|
||||||
* end-to-end control path before we build the polished LVGL GUI.
|
* +/- 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:
|
* Two I2C buses are in use, on separate ports:
|
||||||
* - port 0 (Wire1, owned by LovyanGFX): CST816S touch SDA=6 SCL=7
|
* - port 0 (Wire1, owned by LovyanGFX): CST816S touch SDA=6 SCL=7
|
||||||
@ -22,6 +27,7 @@
|
|||||||
#define LGFX_USE_V1
|
#define LGFX_USE_V1
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <Wire.h>
|
#include <Wire.h>
|
||||||
|
#include <string.h>
|
||||||
#include <LovyanGFX.hpp>
|
#include <LovyanGFX.hpp>
|
||||||
#include "hub_proto.h"
|
#include "hub_proto.h"
|
||||||
|
|
||||||
@ -94,18 +100,43 @@ LGFX lcd;
|
|||||||
// transaction itself succeeded (board present on the bus at all).
|
// transaction itself succeeded (board present on the bus at all).
|
||||||
struct Speaker {
|
struct Speaker {
|
||||||
uint8_t addr;
|
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 online; // I2C transaction succeeded this poll
|
||||||
bool connected; // board says its BT sink is connected
|
bool connected; // board says its BT sink is connected
|
||||||
uint16_t delay_ms;
|
uint16_t delay_ms;
|
||||||
uint8_t volume;
|
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] = {
|
static Speaker speakers[2] = {
|
||||||
{ HUB_I2C_ADDR_JBL, "JBL", 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 },
|
{ 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
|
// I2C helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -126,14 +157,118 @@ static void pollStatus(Speaker &s) {
|
|||||||
s.online = false;
|
s.online = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
uint8_t b0 = Wire1.read();
|
uint8_t b[HUB_STATUS_LEN];
|
||||||
uint8_t b1 = Wire1.read();
|
for (int i = 0; i < HUB_STATUS_LEN; i++) b[i] = Wire1.read();
|
||||||
uint8_t b2 = Wire1.read();
|
s.online = true;
|
||||||
uint8_t b3 = Wire1.read();
|
s.connected = (b[0] != 0);
|
||||||
s.online = true;
|
s.delay_ms = (uint16_t)(b[1] | (b[2] << 8));
|
||||||
s.connected = (b0 != 0);
|
s.volume = b[3];
|
||||||
s.delay_ms = (uint16_t)(b1 | (b2 << 8));
|
s.scanning = (b[4] != 0);
|
||||||
s.volume = b3;
|
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.
|
// Command a new delay (ms) to a speaker.
|
||||||
@ -153,32 +288,40 @@ static void sendVolume(const Speaker &s, uint8_t vol) {
|
|||||||
Wire1.endTransmission();
|
Wire1.endTransmission();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
static bool inRect(int px, int py, int x, int y, int w, int h) {
|
||||||
// UI layout
|
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
|
// 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
|
// safe center column so they stay clear of the round bezel. Each half stacks:
|
||||||
// row of four square buttons: [d-] [d+] [v-] [v+]. Status text sits above.
|
// title (role + connect dot), current device name, "Dnnn Vnn%" status,
|
||||||
|
// a row of five buttons: [d-] [d+] [v-] [v+] [pick].
|
||||||
//
|
//
|
||||||
// y rows:
|
// y rows (per speaker half):
|
||||||
// JBL title ~22, status ~46, buttons ~70..102
|
// JBL title 20, name 38, status 56, buttons 74..106
|
||||||
// Cardo title ~138, status ~162, buttons ~186..218
|
// Cardo title 138, name 156, status 174, buttons 134... (mirrored below)
|
||||||
|
|
||||||
#define BTN_W 34
|
#define BTN_W 30
|
||||||
#define BTN_H 32
|
#define BTN_H 30
|
||||||
#define BTN_GAP 6
|
#define BTN_GAP 5
|
||||||
|
|
||||||
// 4 buttons centered horizontally: total width = 4*W + 3*GAP
|
// Main-screen button kinds, in draw/hit order.
|
||||||
static const int kBtnTotalW = 4 * BTN_W + 3 * BTN_GAP;
|
enum BtnKind { B_DELAY_DOWN = 0, B_DELAY_UP, B_VOL_DOWN, B_VOL_UP, B_PICK, B_COUNT };
|
||||||
static const int kBtnX0 = (240 - kBtnTotalW) / 2; // left edge of first button
|
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.
|
static const int kTitleY[2] = { 18, 136 };
|
||||||
enum BtnKind { B_DELAY_DOWN = 0, B_DELAY_UP, B_VOL_DOWN, B_VOL_UP, B_COUNT };
|
static const int kNameY[2] = { 38, 156 };
|
||||||
static const char *kBtnLabel[B_COUNT] = { "d-", "d+", "v-", "v+" };
|
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) {
|
static void btnRect(int i, int k, int &x, int &y, int &w, int &h) {
|
||||||
x = kBtnX0 + k * (BTN_W + BTN_GAP);
|
x = kBtnX0 + k * (BTN_W + BTN_GAP);
|
||||||
y = kBtnYTop[i];
|
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;
|
h = BTN_H;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool inRect(int px, int py, int x, int y, int w, int h) {
|
// Draw the control buttons for speaker `i`. `pressed` highlights one. The
|
||||||
return px >= x && px < x + w && py >= y && py < y + h;
|
// pick button is tinted differently so it reads as a mode switch, not a nudge.
|
||||||
}
|
|
||||||
|
|
||||||
// Draw the four control buttons for speaker `i`. `pressed` highlights one.
|
|
||||||
static void drawButtons(int i, int pressed = -1) {
|
static void drawButtons(int i, int pressed = -1) {
|
||||||
for (int k = 0; k < B_COUNT; k++) {
|
for (int k = 0; k < B_COUNT; k++) {
|
||||||
int x, y, w, h;
|
int x, y, w, h;
|
||||||
btnRect(i, k, 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.fillRoundRect(x, y, w, h, 5, fill);
|
||||||
lcd.drawRoundRect(x, y, w, h, 5, TFT_DARKGREY);
|
lcd.drawRoundRect(x, y, w, h, 5, TFT_DARKGREY);
|
||||||
lcd.setTextColor(TFT_WHITE, fill);
|
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) {
|
static void drawStatus(int i) {
|
||||||
const Speaker &s = speakers[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.setTextDatum(middle_center);
|
||||||
lcd.setTextColor(TFT_CYAN, TFT_BLACK);
|
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);
|
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];
|
char buf[40];
|
||||||
if (!s.online) {
|
if (!s.online) {
|
||||||
snprintf(buf, sizeof(buf), "-- offline --");
|
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);
|
snprintf(buf, sizeof(buf), "D%ums V%u%%", s.delay_ms, s.volume);
|
||||||
lcd.setTextColor(TFT_WHITE, TFT_BLACK);
|
lcd.setTextColor(TFT_WHITE, TFT_BLACK);
|
||||||
}
|
}
|
||||||
// Clear the old line first (full-width band) then draw centered.
|
lcd.fillRect(0, kStatusY[i] - 8, 240, 16, TFT_BLACK);
|
||||||
lcd.fillRect(0, statusY - 9, 240, 18, TFT_BLACK);
|
lcd.drawString(buf, 120, kStatusY[i], &fonts::Font2);
|
||||||
lcd.drawString(buf, 120, statusY, &fonts::Font2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redraw the whole static frame once (background, divider, both halves).
|
// Redraw the whole MAIN frame (background, divider, both halves).
|
||||||
static void drawFrame() {
|
static void drawMainScreen() {
|
||||||
lcd.fillScreen(TFT_BLACK);
|
lcd.fillScreen(TFT_BLACK);
|
||||||
lcd.drawCircle(120, 120, 118, TFT_DARKGREY);
|
lcd.drawCircle(120, 120, 118, TFT_DARKGREY);
|
||||||
lcd.drawFastHLine(20, 120, 200, TFT_DARKGREY); // top/bottom divider
|
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
|
// Apply a delay/volume nudge for button `k` on speaker `i` and send it. The
|
||||||
// optimistically update + redraw the affected speaker. Returns true if a
|
// pick button is handled by the caller (it switches screens). Returns true if
|
||||||
// button was hit.
|
// a value button was handled.
|
||||||
static bool handleTouch(int x, int y) {
|
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: "<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 i = 0; i < 2; i++) {
|
||||||
for (int k = 0; k < B_COUNT; k++) {
|
for (int k = 0; k < B_COUNT; k++) {
|
||||||
int bx, by, bw, bh;
|
int bx, by, bw, bh;
|
||||||
btnRect(i, k, bx, by, bw, bh);
|
btnRect(i, k, bx, by, bw, bh);
|
||||||
if (!inRect(x, y, bx, by, bw, bh)) continue;
|
if (!inRect(x, y, bx, by, bw, bh)) continue;
|
||||||
|
|
||||||
Speaker &s = speakers[i];
|
if (k == B_PICK) {
|
||||||
switch (k) {
|
drawButtons(i, k); // brief feedback before redraw
|
||||||
case B_DELAY_DOWN: {
|
enterScan(i);
|
||||||
int d = (int)s.delay_ms - DELAY_STEP;
|
return true;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Visual feedback + optimistic value update.
|
applyMainButton(i, k);
|
||||||
drawButtons(i, k);
|
drawButtons(i, k);
|
||||||
drawStatus(i);
|
drawStatus(i);
|
||||||
Serial.printf("[touch] %s %s -> D%ums V%u%%\n",
|
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 true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
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() {
|
void setup() {
|
||||||
Serial.begin(115200);
|
Serial.begin(115200);
|
||||||
delay(300);
|
delay(300);
|
||||||
@ -309,34 +675,63 @@ void setup() {
|
|||||||
lcd.init();
|
lcd.init();
|
||||||
lcd.setRotation(0);
|
lcd.setRotation(0);
|
||||||
lcd.setBrightness(200);
|
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");
|
Serial.println("[LCD] UI drawn");
|
||||||
}
|
}
|
||||||
|
|
||||||
void loop() {
|
void loop() {
|
||||||
unsigned long now = millis();
|
unsigned long now = millis();
|
||||||
|
|
||||||
// ---- touch (debounced) ----
|
// ---- touch (debounced), dispatched by current screen ----
|
||||||
static unsigned long lastTouch = 0;
|
static unsigned long lastTouch = 0;
|
||||||
int32_t tx, ty;
|
int32_t tx, ty;
|
||||||
if (lcd.getTouch(&tx, &ty)) {
|
if (lcd.getTouch(&tx, &ty)) {
|
||||||
if (now - lastTouch >= TOUCH_DEBOUNCE) {
|
if (now - lastTouch >= TOUCH_DEBOUNCE) {
|
||||||
if (handleTouch(tx, ty)) {
|
bool hit = (screen == SCREEN_MAIN) ? handleTouchMain(tx, ty)
|
||||||
lastTouch = now;
|
: handleTouchScan(tx, ty);
|
||||||
}
|
if (hit) lastTouch = now;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- status poll (~1 Hz) ----
|
// ---- per-screen periodic work ----
|
||||||
static unsigned long lastPoll = 0;
|
static unsigned long lastPoll = 0;
|
||||||
if (now - lastPoll >= POLL_PERIOD_MS) {
|
if (now - lastPoll >= POLL_PERIOD_MS) {
|
||||||
lastPoll = now;
|
lastPoll = now;
|
||||||
for (int i = 0; i < 2; i++) {
|
if (screen == SCREEN_MAIN) {
|
||||||
pollStatus(speakers[i]);
|
// Status + occasional device-name refresh for both speakers.
|
||||||
drawStatus(i);
|
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 ----
|
// ---- heartbeat ----
|
||||||
static unsigned long lastBeat = 0;
|
static unsigned long lastBeat = 0;
|
||||||
if (now - lastBeat >= 5000) {
|
if (now - lastBeat >= 5000) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user