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>
930 lines
35 KiB
C++
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);
|
|
}
|