blue-repeat/src/hud.cpp
blue b5d1169392 Rename: product -> "Resound"; firmware roles -> hud/sink/broadcaster
Product name BikeAudio -> "Resound" (the device isn't bike-specific — works at
home, backyard, camping, parties, group rides). This is the A2DP advertise name
the phone connects to: sink.start("Resound"). All banners/comments + README
updated. (After reflashing the sink, the phone must forget "BikeAudio" and
connect to "Resound".)

Firmware vocabulary clarified (the old hub/sink/source was confusing —
"source" read backwards since those boards SEND audio):
  hub_s3.cpp / env hub_s3        -> hud.cpp / env hud
  board_sink.cpp                 -> sink.cpp        (env sink)
  board_source.cpp               -> broadcaster.cpp (envs broadcaster_headset
                                    0x11, broadcaster_speaker1 0x10,
                                    broadcaster_guest 0x12)
  hub_proto.h                    -> bus_proto.h
default_envs = sink + the three broadcasters. All envs build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:14:16 -04:00

930 lines
35 KiB
C++

/**
* Resound — Hub (ESP32-S3-Touch-LCD-1.28) : PHASE 3 discovery + control
*
* Round GC9A01 LCD + CST816S touch, wired as an I2C MASTER to the source
* boards. Three brand-agnostic channels are controlled (discovery picks the
* actual device per channel):
* index 0 "Headset" @ 0x11
* index 1 "Speaker 1" @ 0x10
* index 2 "Guest" @ 0x12 (occasional; usually offline)
* The UI is built with LVGL 8.3 (rendered THROUGH the LovyanGFX `lcd` device),
* tuned to be glanceable on a 240x240 ROUND screen for use on a motorcycle
* handlebar: big high-contrast type, large tap targets, bold connection colours.
*
* Screens (separate lv_obj_create(NULL) screens, switched with lv_scr_load):
* HOME : big master-volume arc (drag -> set ALL channels), three labeled
* connection dots (Headset / Speaker 1 / Guest) at top, "SPEAKERS"
* button at the bottom.
* SPEAKERS : three buttons (Headset / Speaker 1 / Guest) showing connect state
* + current device name; tap -> that channel's DETAIL. BACK -> HOME.
* DETAIL : per-speaker. Volume arc (this speaker), Delay -/+ control,
* SCAN button, BACK -> SPEAKERS.
* SCAN : starts a scan on entry; spinner while scanning; an lv_list of
* discovered devices (name + rssi). Tap a row -> SELECT + DETAIL.
* RESCAN re-issues the scan; BACK cancels + returns to DETAIL.
*
* Two I2C buses are in use, on separate ports:
* - port 0 (Wire1's underlying peripheral via LovyanGFX): CST816S touch SDA=6 SCL=7
* - "Wire1" (this code, I2C master): source boards SDA=15 SCL=16
*
* Board pins (Waveshare ESP32-S3-Touch-LCD-1.28):
* GC9A01 SPI: SCLK=10 MOSI=11 MISO=12 CS=9 DC=8 RST=14 backlight=2
* CST816S touch (I2C): SDA=6 SCL=7 (polled; INT left unused)
* Control bus (I2C master): SDA=15 SCL=16
*
* Build: pio run -e hub_s3 | flash: esp32s3, bootloader@0x0
*/
#define LGFX_USE_V1
#include <Arduino.h>
#include <Wire.h>
#include <string.h>
#include <LovyanGFX.hpp>
#include <lvgl.h>
#include "bus_proto.h"
class LGFX : public lgfx::LGFX_Device {
lgfx::Panel_GC9A01 _panel;
lgfx::Bus_SPI _bus;
lgfx::Light_PWM _light;
lgfx::Touch_CST816S _touch;
public:
LGFX() {
{ auto c = _bus.config();
c.spi_host = SPI2_HOST;
c.spi_mode = 0;
c.freq_write = 40000000;
c.pin_sclk = 10;
c.pin_mosi = 11;
c.pin_miso = 12;
c.pin_dc = 8;
_bus.config(c); _panel.setBus(&_bus); }
{ auto c = _panel.config();
c.pin_cs = 9;
c.pin_rst = 14;
c.panel_width = 240;
c.panel_height = 240;
c.offset_x = 0;
c.offset_y = 0;
c.readable = false;
c.invert = true; // GC9A01 typically needs inversion
c.rgb_order = false;
_panel.config(c); }
{ auto c = _light.config();
c.pin_bl = 2;
c.freq = 12000;
c.pwm_channel = 7;
_light.config(c); _panel.setLight(&_light); }
{ auto c = _touch.config();
c.i2c_port = 0;
c.pin_sda = 6;
c.pin_scl = 7;
c.pin_int = -1; // poll over I2C (avoid INT-pin ambiguity)
c.pin_rst = -1;
c.i2c_addr = 0x15;
c.freq = 400000;
c.x_min = 0; c.x_max = 239; c.y_min = 0; c.y_max = 239;
_touch.config(c); _panel.setTouch(&_touch); }
setPanel(&_panel);
}
};
LGFX lcd;
// ---------------------------------------------------------------------------
// Control-bus (I2C master) config
// ---------------------------------------------------------------------------
#define CTRL_I2C_SDA 15
#define CTRL_I2C_SCL 16
#define CTRL_I2C_HZ 100000
#define DELAY_STEP 5 // ms per delay +/- press
#define POLL_PERIOD_MS 1000 // status poll cadence per speaker
// Per-speaker model. delay_ms/volume are the values we display & command;
// "connected" is reported by the board, "online" is whether the I2C
// transaction itself succeeded (board present on the bus at all).
struct Speaker {
uint8_t addr;
const char *name; // role label (Headset / Speaker 1 / Guest), not the BT device name
bool online; // I2C transaction succeeded this poll
bool connected; // board says its BT sink is connected
uint16_t delay_ms;
uint8_t volume;
// Phase 3 discovery state (from the 7-byte status payload).
bool scanning; // board is currently running a BT inquiry scan
uint8_t scan_count; // devices found so far in the current scan
bool has_device; // a device is saved/selected on the board
char cur_name[HUB_NAME_MAX + 1]; // selected BT device name (NUL-term)
};
#define NUM_SPK 3
static Speaker speakers[NUM_SPK] = {
// Brand-agnostic channels; discovery picks the actual device per channel.
{ HUB_I2C_ADDR_CARDO, "Headset", false, false, 0, 0, false, 0, false, {0} },
{ HUB_I2C_ADDR_JBL, "Speaker 1", false, false, 0, 0, false, 0, false, {0} },
{ HUB_I2C_ADDR_GUEST, "Guest", false, false, 0, 0, false, 0, false, {0} },
};
// A discovered scan item, mirrored from the board over GET_SCANITEM.
struct ScanItem {
uint8_t index; // index on the board (passed back in SELECT)
int8_t rssi;
uint8_t mac[6];
char name[HUB_NAME_MAX + 1]; // NUL-terminated
};
// Local mirror of the current scan list (for the speaker being configured).
static ScanItem scanItems[HUB_MAX_SCAN];
static int scanItemCount = 0;
// Which speaker DETAIL / SCAN is operating on (0..NUM_SPK-1).
static int activeSpeaker = 0;
// ===========================================================================
// I2C helpers (DATA LAYER — unchanged transactions, all on Wire1)
// ===========================================================================
// Poll one speaker for status. Updates s.online/connected/delay_ms/volume.
// On any bus failure the speaker is marked offline.
// NOTE: the control bus uses Wire1 (I2C peripheral 1). Peripheral 0 is already
// owned by the LovyanGFX CST816S touch (GPIO6/7), so we must not touch it here.
static void pollStatus(Speaker &s) {
Wire1.beginTransmission(s.addr);
Wire1.write(HUB_CMD_GET_STATUS);
if (Wire1.endTransmission(true) != 0) {
s.online = false;
return;
}
int n = Wire1.requestFrom((int)s.addr, (int)HUB_STATUS_LEN);
if (n < HUB_STATUS_LEN) {
s.online = false;
return;
}
uint8_t b[HUB_STATUS_LEN];
for (int i = 0; i < HUB_STATUS_LEN; i++) b[i] = Wire1.read();
s.online = true;
s.connected = (b[0] != 0);
s.delay_ms = (uint16_t)(b[1] | (b[2] << 8));
s.volume = b[3];
s.scanning = (b[4] != 0);
s.scan_count = b[5];
s.has_device = (b[6] != 0);
}
// Fetch the board's current/selected device name into out (HUB_NAME_MAX+1).
// On failure out is left empty. The name comes back NUL-padded.
static void getCurName(Speaker &s, char *out) {
out[0] = '\0';
Wire1.beginTransmission(s.addr);
Wire1.write(HUB_CMD_GET_CURNAME);
if (Wire1.endTransmission(true) != 0) return;
int n = Wire1.requestFrom((int)s.addr, (int)HUB_NAME_MAX);
if (n < HUB_NAME_MAX) {
while (Wire1.available()) Wire1.read();
return;
}
for (int i = 0; i < HUB_NAME_MAX; i++) {
out[i] = (char)Wire1.read();
}
out[HUB_NAME_MAX] = '\0';
}
// Begin a fresh discovery scan on speaker s.
static void startScan(Speaker &s) {
Wire1.beginTransmission(s.addr);
Wire1.write(HUB_CMD_SCAN_START);
Wire1.endTransmission(true);
}
// Cancel an in-progress scan on speaker s.
static void sendScanStop(Speaker &s) {
Wire1.beginTransmission(s.addr);
Wire1.write(HUB_CMD_SCAN_STOP);
Wire1.endTransmission(true);
}
// Select scan item `index` on speaker s (connect + persist on the board).
static void sendSelect(Speaker &s, uint8_t index) {
Wire1.beginTransmission(s.addr);
Wire1.write(HUB_CMD_SELECT);
Wire1.write(index);
Wire1.endTransmission(true);
}
// Read scan item `index` from speaker s into `out`. Returns true if valid.
static bool getScanItem(Speaker &s, uint8_t index, ScanItem &out) {
Wire1.beginTransmission(s.addr);
Wire1.write(HUB_CMD_GET_SCANITEM);
Wire1.write(index);
if (Wire1.endTransmission(true) != 0) return false;
int n = Wire1.requestFrom((int)s.addr, (int)HUB_SCANITEM_LEN);
if (n < HUB_SCANITEM_LEN) {
while (Wire1.available()) Wire1.read();
return false;
}
uint8_t valid = Wire1.read();
int8_t rssi = (int8_t)Wire1.read();
uint8_t mac[6];
for (int i = 0; i < 6; i++) mac[i] = Wire1.read();
uint8_t namelen = Wire1.read();
if (namelen > HUB_NAME_MAX) namelen = HUB_NAME_MAX;
char name[HUB_NAME_MAX + 1];
for (int i = 0; i < HUB_NAME_MAX; i++) {
char c = (char)Wire1.read();
if (i < namelen) name[i] = c;
}
name[namelen] = '\0';
if (!valid) return false;
out.index = index;
out.rssi = rssi;
memcpy(out.mac, mac, 6);
strncpy(out.name, name, sizeof(out.name));
out.name[HUB_NAME_MAX] = '\0';
return true;
}
// Refresh the local scan-list mirror for speaker `i` from the board. Reads up
// to scan_count items (capped to HUB_MAX_SCAN) and stores the valid ones,
// sorted strongest-RSSI first so the cap keeps the best candidates.
static void pollScanList(int i) {
Speaker &s = speakers[i];
int want = s.scan_count;
if (want > HUB_MAX_SCAN) want = HUB_MAX_SCAN;
int got = 0;
for (int idx = 0; idx < want && got < HUB_MAX_SCAN; idx++) {
ScanItem it;
if (getScanItem(s, (uint8_t)idx, it)) {
scanItems[got++] = it;
}
}
// Strongest RSSI first (simple insertion sort; list is tiny).
for (int a = 1; a < got; a++) {
ScanItem key = scanItems[a];
int b = a - 1;
while (b >= 0 && scanItems[b].rssi < key.rssi) {
scanItems[b + 1] = scanItems[b];
b--;
}
scanItems[b + 1] = key;
}
scanItemCount = got;
}
// Command a new delay (ms) to a speaker.
static void sendDelay(const Speaker &s, uint16_t ms) {
Wire1.beginTransmission(s.addr);
Wire1.write(HUB_CMD_SET_DELAY);
Wire1.write(ms & 0xFF);
Wire1.write((ms >> 8) & 0xFF);
Wire1.endTransmission();
}
// Command a new volume (0..100) to a speaker.
static void sendVolume(const Speaker &s, uint8_t vol) {
Wire1.beginTransmission(s.addr);
Wire1.write(HUB_CMD_SET_VOLUME);
Wire1.write(vol);
Wire1.endTransmission();
}
// ===========================================================================
// LVGL <-> LovyanGFX glue
// ===========================================================================
static lv_color_t lvbuf1[240 * 40];
static lv_color_t lvbuf2[240 * 40];
static lv_disp_draw_buf_t draw_buf;
static lv_disp_drv_t disp_drv;
static lv_indev_drv_t indev_drv;
static void disp_flush(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *px) {
uint32_t w = area->x2 - area->x1 + 1, h = area->y2 - area->y1 + 1;
lcd.startWrite();
lcd.setAddrWindow(area->x1, area->y1, w, h);
lcd.writePixels((uint16_t *)&px->full, w * h);
lcd.endWrite();
lv_disp_flush_ready(drv);
}
static void touch_read(lv_indev_drv_t *drv, lv_indev_data_t *data) {
int32_t x, y;
if (lcd.getTouch(&x, &y)) {
data->state = LV_INDEV_STATE_PR;
data->point.x = x;
data->point.y = y;
} else {
data->state = LV_INDEV_STATE_REL;
}
}
// ===========================================================================
// UI palette / helpers
// ===========================================================================
#define COL_BG lv_color_black()
#define COL_GREEN lv_color_hex(0x00D26A) // connected
#define COL_RED lv_color_hex(0xE0322B) // online but not connected
#define COL_GREY lv_color_hex(0x555555) // offline
#define COL_CYAN lv_color_hex(0x18C0E0)
#define COL_ACCENT lv_color_hex(0x18C0E0) // arc indicator
#define COL_TRACK lv_color_hex(0x303030) // arc background
#define COL_WHITE lv_color_white()
#define COL_BTN lv_color_hex(0x1B2A3A)
#define COL_BACK lv_color_hex(0x4A1414)
// Pick the connection colour for a speaker's state.
static lv_color_t connColor(const Speaker &s) {
if (!s.online) return COL_GREY;
return s.connected ? COL_GREEN : COL_RED;
}
// ===========================================================================
// Screen objects + the widget pointers we update from the poll timer
// ===========================================================================
static lv_obj_t *scrHome;
static lv_obj_t *scrSpeakers;
static lv_obj_t *scrDetail;
static lv_obj_t *scrScan;
// HOME widgets
static lv_obj_t *homeArc;
static lv_obj_t *homePctLabel;
static lv_obj_t *homeDot[NUM_SPK]; // per-channel connection dots (Headset/Speaker 1/Guest)
// SPEAKERS widgets
static lv_obj_t *spkBtnDot[NUM_SPK]; // dot inside each speaker button
static lv_obj_t *spkBtnLabel[NUM_SPK]; // "<role>\n<device>" text inside each button
// DETAIL widgets
static lv_obj_t *detHeading;
static lv_obj_t *detArc;
static lv_obj_t *detArcPct;
static lv_obj_t *detDelayLabel;
// SCAN widgets
static lv_obj_t *scanHeading;
static lv_obj_t *scanSpinner;
static lv_obj_t *scanList;
// Master volume tracked on the hub (drives both speakers from HOME).
static uint8_t masterVolume = 0;
// Forward declarations of builders/refreshers.
static void buildHome();
static void buildSpeakers();
static void buildDetail();
static void buildScan();
static void refreshHome();
static void refreshSpeakers();
static void refreshDetail();
static void refreshScanList();
static void enterScan();
// ===========================================================================
// HOME screen
// ===========================================================================
static void home_arc_cb(lv_event_t *e) {
lv_obj_t *arc = lv_event_get_target(e);
uint8_t v = (uint8_t)lv_arc_get_value(arc);
masterVolume = v;
lv_label_set_text_fmt(homePctLabel, "%d", v);
// Drive ALL channels from the master arc.
for (int i = 0; i < NUM_SPK; i++) {
speakers[i].volume = v;
sendVolume(speakers[i], v);
}
}
static void home_to_speakers_cb(lv_event_t *e) {
refreshSpeakers();
lv_scr_load(scrSpeakers);
}
static void buildHome() {
scrHome = lv_obj_create(NULL);
lv_obj_set_style_bg_color(scrHome, COL_BG, 0);
lv_obj_clear_flag(scrHome, LV_OBJ_FLAG_SCROLLABLE);
// Big master-volume arc nearly filling the screen.
homeArc = lv_arc_create(scrHome);
lv_obj_set_size(homeArc, 220, 220);
lv_obj_center(homeArc);
lv_arc_set_rotation(homeArc, 135);
lv_arc_set_bg_angles(homeArc, 0, 270);
lv_arc_set_range(homeArc, 0, 100);
lv_arc_set_value(homeArc, masterVolume);
lv_obj_set_style_arc_width(homeArc, 16, LV_PART_MAIN);
lv_obj_set_style_arc_color(homeArc, COL_TRACK, LV_PART_MAIN);
lv_obj_set_style_arc_width(homeArc, 16, LV_PART_INDICATOR);
lv_obj_set_style_arc_color(homeArc, COL_ACCENT, LV_PART_INDICATOR);
lv_obj_set_style_bg_color(homeArc, COL_ACCENT, LV_PART_KNOB);
lv_obj_set_style_pad_all(homeArc, 8, LV_PART_KNOB); // larger, glove-friendly knob
lv_obj_add_event_cb(homeArc, home_arc_cb, LV_EVENT_VALUE_CHANGED, NULL);
// Center: big % number + "VOLUME" caption.
homePctLabel = lv_label_create(scrHome);
lv_obj_set_style_text_font(homePctLabel, &lv_font_montserrat_48, 0);
lv_obj_set_style_text_color(homePctLabel, COL_WHITE, 0);
lv_label_set_text_fmt(homePctLabel, "%d", masterVolume);
lv_obj_align(homePctLabel, LV_ALIGN_CENTER, 0, -4);
lv_obj_t *cap = lv_label_create(scrHome);
lv_obj_set_style_text_font(cap, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(cap, COL_GREY, 0);
lv_label_set_text(cap, "VOLUME");
lv_obj_align(cap, LV_ALIGN_CENTER, 0, 34);
// Top: three compact connection indicators (dot + short label), evenly
// spaced in a row near the top. Kept within the round screen's safe area:
// the labels sit at y~28-44 where the chord is wide enough for the text.
const char *roles[NUM_SPK] = { "Headset", "Speaker 1", "Guest" };
const int xoff[NUM_SPK] = { -64, 0, 64 };
for (int i = 0; i < NUM_SPK; i++) {
homeDot[i] = lv_obj_create(scrHome);
lv_obj_set_size(homeDot[i], 14, 14);
lv_obj_set_style_radius(homeDot[i], LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_border_width(homeDot[i], 0, 0);
lv_obj_set_style_bg_color(homeDot[i], COL_GREY, 0);
lv_obj_align(homeDot[i], LV_ALIGN_TOP_MID, xoff[i], 26);
lv_obj_clear_flag(homeDot[i], LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t *l = lv_label_create(scrHome);
lv_obj_set_style_text_font(l, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(l, COL_WHITE, 0);
lv_label_set_text(l, roles[i]);
lv_obj_align_to(l, homeDot[i], LV_ALIGN_OUT_BOTTOM_MID, 0, 2);
}
// Bottom: "SPEAKERS" button.
lv_obj_t *btn = lv_btn_create(scrHome);
lv_obj_set_size(btn, 130, 46);
lv_obj_align(btn, LV_ALIGN_BOTTOM_MID, 0, -22);
lv_obj_set_style_bg_color(btn, COL_BTN, 0);
lv_obj_set_style_radius(btn, 12, 0);
lv_obj_add_event_cb(btn, home_to_speakers_cb, LV_EVENT_CLICKED, NULL);
lv_obj_t *bl = lv_label_create(btn);
lv_obj_set_style_text_font(bl, &lv_font_montserrat_20, 0);
lv_label_set_text(bl, "SPEAKERS");
lv_obj_center(bl);
}
static void refreshHome() {
for (int i = 0; i < NUM_SPK; i++) {
lv_obj_set_style_bg_color(homeDot[i], connColor(speakers[i]), 0);
}
}
// ===========================================================================
// SPEAKERS screen
// ===========================================================================
static void speakers_back_cb(lv_event_t *e) {
refreshHome();
lv_scr_load(scrHome);
}
static void speakers_select_cb(lv_event_t *e) {
activeSpeaker = (int)(intptr_t)lv_event_get_user_data(e);
refreshDetail();
lv_scr_load(scrDetail);
}
static void buildSpeakers() {
scrSpeakers = lv_obj_create(NULL);
lv_obj_set_style_bg_color(scrSpeakers, COL_BG, 0);
lv_obj_clear_flag(scrSpeakers, LV_OBJ_FLAG_SCROLLABLE);
// Three channel buttons stacked in the center column. Heights kept >=48px
// (glove-friendly tap target) and widths narrowed so they stay inside the
// round bezel where the top/bottom buttons hit the narrower chords.
const char *roles[NUM_SPK] = { "Headset", "Speaker 1", "Guest" };
const int ypos[NUM_SPK] = { -58, 0, 58 }; // relative to vertical center
for (int i = 0; i < NUM_SPK; i++) {
lv_obj_t *btn = lv_btn_create(scrSpeakers);
lv_obj_set_size(btn, 168, 50);
lv_obj_align(btn, LV_ALIGN_CENTER, 0, ypos[i]);
lv_obj_set_style_bg_color(btn, COL_BTN, 0);
lv_obj_set_style_radius(btn, 12, 0);
lv_obj_set_style_pad_left(btn, 8, 0);
lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_event_cb(btn, speakers_select_cb, LV_EVENT_CLICKED,
(void *)(intptr_t)i);
// Connection dot inside the button (left of content area).
spkBtnDot[i] = lv_obj_create(btn);
lv_obj_set_size(spkBtnDot[i], 14, 14);
lv_obj_set_style_radius(spkBtnDot[i], LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_border_width(spkBtnDot[i], 0, 0);
lv_obj_set_style_bg_color(spkBtnDot[i], COL_GREY, 0);
lv_obj_align(spkBtnDot[i], LV_ALIGN_LEFT_MID, 0, 0);
lv_obj_clear_flag(spkBtnDot[i], LV_OBJ_FLAG_SCROLLABLE);
// Role + device name (smaller font so two lines fit in 50px).
spkBtnLabel[i] = lv_label_create(btn);
lv_obj_set_style_text_font(spkBtnLabel[i], &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(spkBtnLabel[i], COL_WHITE, 0);
lv_label_set_text_fmt(spkBtnLabel[i], "%s", roles[i]);
lv_obj_align(spkBtnLabel[i], LV_ALIGN_LEFT_MID, 24, 0);
}
// BACK button at the bottom.
lv_obj_t *back = lv_btn_create(scrSpeakers);
lv_obj_set_size(back, 110, 44);
lv_obj_align(back, LV_ALIGN_BOTTOM_MID, 0, -16);
lv_obj_set_style_bg_color(back, COL_BACK, 0);
lv_obj_set_style_radius(back, 12, 0);
lv_obj_add_event_cb(back, speakers_back_cb, LV_EVENT_CLICKED, NULL);
lv_obj_t *bl = lv_label_create(back);
lv_obj_set_style_text_font(bl, &lv_font_montserrat_20, 0);
lv_label_set_text(bl, "BACK");
lv_obj_center(bl);
}
static void refreshSpeakers() {
for (int i = 0; i < NUM_SPK; i++) {
lv_obj_set_style_bg_color(spkBtnDot[i], connColor(speakers[i]), 0);
const char *dev = speakers[i].cur_name[0] ? speakers[i].cur_name
: "(no device)";
lv_label_set_text_fmt(spkBtnLabel[i], "%s\n%s", speakers[i].name, dev);
}
}
// ===========================================================================
// DETAIL screen (per speaker)
// ===========================================================================
static void detail_arc_cb(lv_event_t *e) {
lv_obj_t *arc = lv_event_get_target(e);
uint8_t v = (uint8_t)lv_arc_get_value(arc);
Speaker &s = speakers[activeSpeaker];
s.volume = v;
sendVolume(s, v);
lv_label_set_text_fmt(detArcPct, "%d", v);
}
static void detail_delay_step(int dir) {
Speaker &s = speakers[activeSpeaker];
int d = (int)s.delay_ms + dir * DELAY_STEP;
if (d < 0) d = 0;
if (d > HUB_MAX_DELAY_MS) d = HUB_MAX_DELAY_MS;
s.delay_ms = (uint16_t)d;
sendDelay(s, s.delay_ms);
lv_label_set_text_fmt(detDelayLabel, "Delay %u ms", s.delay_ms);
}
static void detail_delay_minus_cb(lv_event_t *e) { detail_delay_step(-1); }
static void detail_delay_plus_cb(lv_event_t *e) { detail_delay_step(+1); }
static void detail_scan_cb(lv_event_t *e) { enterScan(); }
static void detail_back_cb(lv_event_t *e) {
refreshSpeakers();
lv_scr_load(scrSpeakers);
}
static void buildDetail() {
scrDetail = lv_obj_create(NULL);
lv_obj_set_style_bg_color(scrDetail, COL_BG, 0);
lv_obj_clear_flag(scrDetail, LV_OBJ_FLAG_SCROLLABLE);
// Heading (role + device name).
detHeading = lv_label_create(scrDetail);
lv_obj_set_style_text_font(detHeading, &lv_font_montserrat_20, 0);
lv_obj_set_style_text_color(detHeading, COL_CYAN, 0);
lv_label_set_long_mode(detHeading, LV_LABEL_LONG_DOT);
lv_obj_set_width(detHeading, 170);
lv_obj_set_style_text_align(detHeading, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_text(detHeading, "");
lv_obj_align(detHeading, LV_ALIGN_TOP_MID, 0, 26);
// Per-speaker volume arc.
detArc = lv_arc_create(scrDetail);
lv_obj_set_size(detArc, 150, 150);
lv_obj_align(detArc, LV_ALIGN_CENTER, 0, -8);
lv_arc_set_rotation(detArc, 135);
lv_arc_set_bg_angles(detArc, 0, 270);
lv_arc_set_range(detArc, 0, 100);
lv_obj_set_style_arc_width(detArc, 12, LV_PART_MAIN);
lv_obj_set_style_arc_color(detArc, COL_TRACK, LV_PART_MAIN);
lv_obj_set_style_arc_width(detArc, 12, LV_PART_INDICATOR);
lv_obj_set_style_arc_color(detArc, COL_ACCENT, LV_PART_INDICATOR);
lv_obj_set_style_bg_color(detArc, COL_ACCENT, LV_PART_KNOB);
lv_obj_set_style_pad_all(detArc, 6, LV_PART_KNOB);
lv_obj_add_event_cb(detArc, detail_arc_cb, LV_EVENT_VALUE_CHANGED, NULL);
detArcPct = lv_label_create(scrDetail);
lv_obj_set_style_text_font(detArcPct, &lv_font_montserrat_28, 0);
lv_obj_set_style_text_color(detArcPct, COL_WHITE, 0);
lv_label_set_text(detArcPct, "0");
lv_obj_align(detArcPct, LV_ALIGN_CENTER, 0, -8);
// Delay control: "-" "Delay NNN ms" "+".
lv_obj_t *minus = lv_btn_create(scrDetail);
lv_obj_set_size(minus, 46, 46);
lv_obj_align(minus, LV_ALIGN_CENTER, -70, 54);
lv_obj_set_style_bg_color(minus, COL_BTN, 0);
lv_obj_add_event_cb(minus, detail_delay_minus_cb, LV_EVENT_CLICKED, NULL);
lv_obj_t *ml = lv_label_create(minus);
lv_obj_set_style_text_font(ml, &lv_font_montserrat_28, 0);
lv_label_set_text(ml, "-");
lv_obj_center(ml);
detDelayLabel = lv_label_create(scrDetail);
lv_obj_set_style_text_font(detDelayLabel, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(detDelayLabel, COL_WHITE, 0);
lv_label_set_text(detDelayLabel, "Delay 0 ms");
lv_obj_align(detDelayLabel, LV_ALIGN_CENTER, 0, 54);
lv_obj_t *plus = lv_btn_create(scrDetail);
lv_obj_set_size(plus, 46, 46);
lv_obj_align(plus, LV_ALIGN_CENTER, 70, 54);
lv_obj_set_style_bg_color(plus, COL_BTN, 0);
lv_obj_add_event_cb(plus, detail_delay_plus_cb, LV_EVENT_CLICKED, NULL);
lv_obj_t *pl = lv_label_create(plus);
lv_obj_set_style_text_font(pl, &lv_font_montserrat_28, 0);
lv_label_set_text(pl, "+");
lv_obj_center(pl);
// SCAN (left) and BACK (right) at the bottom.
lv_obj_t *scan = lv_btn_create(scrDetail);
lv_obj_set_size(scan, 90, 44);
lv_obj_align(scan, LV_ALIGN_BOTTOM_MID, -50, -14);
lv_obj_set_style_bg_color(scan, COL_BTN, 0);
lv_obj_add_event_cb(scan, detail_scan_cb, LV_EVENT_CLICKED, NULL);
lv_obj_t *sl = lv_label_create(scan);
lv_obj_set_style_text_font(sl, &lv_font_montserrat_20, 0);
lv_label_set_text(sl, "SCAN");
lv_obj_center(sl);
lv_obj_t *back = lv_btn_create(scrDetail);
lv_obj_set_size(back, 90, 44);
lv_obj_align(back, LV_ALIGN_BOTTOM_MID, 50, -14);
lv_obj_set_style_bg_color(back, COL_BACK, 0);
lv_obj_add_event_cb(back, detail_back_cb, LV_EVENT_CLICKED, NULL);
lv_obj_t *bl = lv_label_create(back);
lv_obj_set_style_text_font(bl, &lv_font_montserrat_20, 0);
lv_label_set_text(bl, "BACK");
lv_obj_center(bl);
}
static void refreshDetail() {
Speaker &s = speakers[activeSpeaker];
const char *dev = s.cur_name[0] ? s.cur_name : "(no device)";
lv_label_set_text_fmt(detHeading, "%s %s", s.name, dev);
// Don't fight the user's drag: only set the arc when not being touched.
if (!lv_obj_has_state(detArc, LV_STATE_PRESSED)) {
lv_arc_set_value(detArc, s.volume);
lv_label_set_text_fmt(detArcPct, "%d", s.volume);
}
lv_label_set_text_fmt(detDelayLabel, "Delay %u ms", s.delay_ms);
}
// ===========================================================================
// SCAN screen
// ===========================================================================
static void scan_row_cb(lv_event_t *e) {
int listIdx = (int)(intptr_t)lv_event_get_user_data(e);
if (listIdx < 0 || listIdx >= scanItemCount) return;
const ScanItem &it = scanItems[listIdx];
Speaker &s = speakers[activeSpeaker];
Serial.printf("[scan] select %s idx=%u for %s\n",
it.name, it.index, s.name);
sendSelect(s, it.index);
// Optimistically show it as the current device.
strncpy(s.cur_name, it.name, sizeof(s.cur_name));
s.cur_name[HUB_NAME_MAX] = '\0';
refreshDetail();
lv_scr_load(scrDetail);
}
static void scan_rescan_cb(lv_event_t *e) {
scanItemCount = 0;
startScan(speakers[activeSpeaker]);
Serial.printf("[scan] rescan for %s\n", speakers[activeSpeaker].name);
refreshScanList();
}
static void scan_back_cb(lv_event_t *e) {
sendScanStop(speakers[activeSpeaker]);
refreshDetail();
lv_scr_load(scrDetail);
}
static void buildScan() {
scrScan = lv_obj_create(NULL);
lv_obj_set_style_bg_color(scrScan, COL_BG, 0);
lv_obj_clear_flag(scrScan, LV_OBJ_FLAG_SCROLLABLE);
scanHeading = lv_label_create(scrScan);
lv_obj_set_style_text_font(scanHeading, &lv_font_montserrat_20, 0);
lv_obj_set_style_text_color(scanHeading, COL_CYAN, 0);
lv_label_set_text(scanHeading, "Scanning");
lv_obj_align(scanHeading, LV_ALIGN_TOP_MID, 0, 24);
// Spinner shown while scanning.
scanSpinner = lv_spinner_create(scrScan, 1000, 60);
lv_obj_set_size(scanSpinner, 44, 44);
lv_obj_align(scanSpinner, LV_ALIGN_TOP_MID, 0, 52);
lv_obj_set_style_arc_color(scanSpinner, COL_TRACK, LV_PART_MAIN);
lv_obj_set_style_arc_color(scanSpinner, COL_ACCENT, LV_PART_INDICATOR);
// Device list (scrolls natively). Kept inside the round safe area.
scanList = lv_list_create(scrScan);
lv_obj_set_size(scanList, 184, 96);
lv_obj_align(scanList, LV_ALIGN_CENTER, 0, 8);
lv_obj_set_style_bg_color(scanList, COL_BG, 0);
lv_obj_set_style_border_width(scanList, 0, 0);
// RESCAN (left) / BACK (right).
lv_obj_t *rescan = lv_btn_create(scrScan);
lv_obj_set_size(rescan, 92, 44);
lv_obj_align(rescan, LV_ALIGN_BOTTOM_MID, -50, -12);
lv_obj_set_style_bg_color(rescan, COL_BTN, 0);
lv_obj_add_event_cb(rescan, scan_rescan_cb, LV_EVENT_CLICKED, NULL);
lv_obj_t *rl = lv_label_create(rescan);
lv_obj_set_style_text_font(rl, &lv_font_montserrat_14, 0);
lv_label_set_text(rl, "RESCAN");
lv_obj_center(rl);
lv_obj_t *back = lv_btn_create(scrScan);
lv_obj_set_size(back, 92, 44);
lv_obj_align(back, LV_ALIGN_BOTTOM_MID, 50, -12);
lv_obj_set_style_bg_color(back, COL_BACK, 0);
lv_obj_add_event_cb(back, scan_back_cb, LV_EVENT_CLICKED, NULL);
lv_obj_t *bl = lv_label_create(back);
lv_obj_set_style_text_font(bl, &lv_font_montserrat_20, 0);
lv_label_set_text(bl, "BACK");
lv_obj_center(bl);
}
// Rebuild the scan list rows from the local mirror; toggle spinner/heading by
// the scanning state. Called from the poll timer while SCAN is active.
static void refreshScanList() {
Speaker &s = speakers[activeSpeaker];
if (s.scanning) {
lv_label_set_text_fmt(scanHeading, "%s: scanning", s.name);
lv_obj_clear_flag(scanSpinner, LV_OBJ_FLAG_HIDDEN);
} else {
lv_label_set_text_fmt(scanHeading, "%s: %d found", s.name, scanItemCount);
lv_obj_add_flag(scanSpinner, LV_OBJ_FLAG_HIDDEN);
}
// Rebuild the rows from scratch (avoids leaking / stale entries).
lv_obj_clean(scanList);
if (scanItemCount == 0) {
if (!s.scanning) {
lv_obj_t *btn = lv_list_add_btn(scanList, NULL, "(none found)");
lv_obj_set_style_text_color(btn, COL_GREY, 0);
}
return;
}
for (int i = 0; i < scanItemCount; i++) {
const ScanItem &it = scanItems[i];
char row[HUB_NAME_MAX + 16];
snprintf(row, sizeof(row), "%s %ddB",
it.name[0] ? it.name : "(unnamed)", it.rssi);
lv_obj_t *btn = lv_list_add_btn(scanList, LV_SYMBOL_BLUETOOTH, row);
lv_obj_set_style_text_font(btn, &lv_font_montserrat_14, 0);
lv_obj_set_style_bg_color(btn, COL_BTN, 0);
lv_obj_set_style_text_color(btn, COL_WHITE, 0);
lv_obj_set_style_min_height(btn, 44, 0); // glove-friendly tap target
lv_obj_add_event_cb(btn, scan_row_cb, LV_EVENT_CLICKED,
(void *)(intptr_t)i);
}
}
// Enter SCAN for the active speaker: kick off a scan + show the screen.
static void enterScan() {
scanItemCount = 0;
startScan(speakers[activeSpeaker]);
Serial.printf("[scan] start for %s\n", speakers[activeSpeaker].name);
refreshScanList();
lv_scr_load(scrScan);
}
// ===========================================================================
// Periodic I2C poll, driven by an lv_timer (~1 Hz, main context — safe).
// ===========================================================================
static void poll_timer_cb(lv_timer_t *t) {
lv_obj_t *cur = lv_scr_act();
if (cur == scrScan) {
// While scanning, poll the active speaker + pull the list as it grows.
Speaker &s = speakers[activeSpeaker];
bool wasScanning = s.scanning;
uint8_t prevCount = s.scan_count;
pollStatus(s);
if ((s.scan_count > 0 && s.scan_count != prevCount) ||
(wasScanning && !s.scanning)) {
pollScanList(activeSpeaker);
}
refreshScanList();
return;
}
// Otherwise poll all channels for status + device name.
for (int i = 0; i < NUM_SPK; i++) {
pollStatus(speakers[i]);
getCurName(speakers[i], speakers[i].cur_name);
}
if (cur == scrHome) {
refreshHome();
} else if (cur == scrSpeakers) {
refreshSpeakers();
} else if (cur == scrDetail) {
refreshDetail();
}
// Heartbeat.
static unsigned long lastBeat = 0;
unsigned long now = millis();
if (now - lastBeat >= 5000) {
lastBeat = now;
Serial.printf("[hub] alive heap=%u", ESP.getFreeHeap());
for (int i = 0; i < NUM_SPK; i++) {
const Speaker &s = speakers[i];
const char *st = s.online ? (s.connected ? "on" : "idle") : "off";
Serial.printf(" %s[%s D%u V%u]", s.name, st, s.delay_ms, s.volume);
}
Serial.println();
}
}
// ===========================================================================
// setup() / loop()
// ===========================================================================
void setup() {
Serial.begin(115200);
delay(300);
Serial.println("=== Resound Hub — S3 LVGL UI ===");
// Control bus: I2C master to the source boards on Wire1 (peripheral 1).
// Peripheral 0 is used by the LovyanGFX CST816S touch (GPIO6/7).
Wire1.begin(CTRL_I2C_SDA, CTRL_I2C_SCL, CTRL_I2C_HZ);
Serial.printf("[i2c] master up SDA=%d SCL=%d @%dHz\n",
CTRL_I2C_SDA, CTRL_I2C_SCL, CTRL_I2C_HZ);
lcd.init();
lcd.setRotation(0);
lcd.setBrightness(200);
// LVGL core + display + touch input.
lv_init();
lv_disp_draw_buf_init(&draw_buf, lvbuf1, lvbuf2, 240 * 40);
lv_disp_drv_init(&disp_drv);
disp_drv.hor_res = 240;
disp_drv.ver_res = 240;
disp_drv.flush_cb = disp_flush;
disp_drv.draw_buf = &draw_buf;
lv_disp_drv_register(&disp_drv);
lv_indev_drv_init(&indev_drv);
indev_drv.type = LV_INDEV_TYPE_POINTER;
indev_drv.read_cb = touch_read;
lv_indev_drv_register(&indev_drv);
// Seed device names + status once so the UI starts populated.
for (int i = 0; i < NUM_SPK; i++) {
pollStatus(speakers[i]);
getCurName(speakers[i], speakers[i].cur_name);
}
// Seed the master arc from the loudest channel that's online (best effort;
// the Guest is usually offline, so don't let it drag the seed).
masterVolume = 0;
for (int i = 0; i < NUM_SPK; i++) {
if (speakers[i].online && speakers[i].volume > masterVolume) {
masterVolume = speakers[i].volume;
}
}
// Build all screens, then show HOME.
buildHome();
buildSpeakers();
buildDetail();
buildScan();
refreshHome();
lv_scr_load(scrHome);
// 1 Hz I2C poll, driven from LVGL's timer (runs in main context).
lv_timer_create(poll_timer_cb, POLL_PERIOD_MS, NULL);
Serial.println("[LCD] LVGL UI up");
}
void loop() {
// Drive LVGL's tick manually (we don't use LV_TICK_CUSTOM — its millis()
// expr can't go through build flags cleanly).
static uint32_t last_tick = 0;
uint32_t now = millis();
lv_tick_inc(now - last_tick);
last_tick = now;
lv_timer_handler();
delay(5);
}