diff --git a/platformio.ini b/platformio.ini index fa10c3d..07be91b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -31,12 +31,12 @@ build_src_filter = + ; --- Board B: I2S slave -> A2DP source -> JBL Charge 5 ------------------------ [env:source_jbl] build_src_filter = + -build_flags = '-DTARGET_SPEAKER="JBL Charge 5"' +build_flags = '-DTARGET_SPEAKER="JBL Charge 5"' -DHUB_I2C_ADDR=0x10 ; --- Board C: I2S slave -> A2DP source -> Cardo (Tangerine EDGE) -------------- [env:source_cardo] build_src_filter = + -build_flags = '-DTARGET_SPEAKER="Tangerine EDGE"' +build_flags = '-DTARGET_SPEAKER="Tangerine EDGE"' -DHUB_I2C_ADDR=0x11 ; --- Hub: ESP32-S3-Touch-LCD-1.28 (round GC9A01 LCD + CST816S touch) ---------- ; Different chip (esp32s3) from the relay boards. Drives the UI; later the diff --git a/src/board_source.cpp b/src/board_source.cpp index f85d850..9d80ed9 100644 --- a/src/board_source.cpp +++ b/src/board_source.cpp @@ -22,11 +22,23 @@ #include "AudioTools.h" #include "BluetoothA2DPSource.h" #include +#include + +#include "hub_proto.h" #ifndef TARGET_SPEAKER #define TARGET_SPEAKER "BikeAudio-Speaker" #endif +// I2C slave address (which speaker this board controls). Set per-env via build +// flag; default to the JBL address if unspecified. +#ifndef HUB_I2C_ADDR +#define HUB_I2C_ADDR HUB_I2C_ADDR_JBL +#endif + +#define I2C_SDA_PIN 32 +#define I2C_SCL_PIN 33 + #define I2S_BCK_PIN 19 #define I2S_WS_PIN 18 #define I2S_DATA_PIN 22 @@ -58,6 +70,55 @@ static volatile uint16_t delay_ms_current = 0; static bool save_pending = false; static unsigned long last_change_ms = 0; +// Current playback volume (0..100). Mirrored into status_buf for the hub. +static uint8_t current_volume = 100; +static bool vol_save_pending = false; + +// --- I2C slave control bus (hub = master) ------------------------------- +// Callbacks run in ISR/Wire context: they must be LIGHT — no Serial, NVS, or +// blocking calls. They only stash requests into volatile globals; loop() acts. +static volatile bool have_delay = false; +static volatile uint16_t pending_delay = 0; +static volatile bool have_volume = false; +static volatile uint8_t pending_volume = 0; + +// Status snapshot served on HUB_CMD_GET_STATUS reads; refreshed by loop(). +// [0]=connected [1]=delay lo [2]=delay hi [3]=volume +static volatile uint8_t status_buf[HUB_STATUS_LEN] = {0, 0, 0, 100}; + +// Master wrote [cmd][args...]; parse into request flags only. +static void on_i2c_receive(int n) { + if (n < 1) return; + uint8_t cmd = Wire.read(); + switch (cmd) { + case HUB_CMD_SET_DELAY: + if (Wire.available() >= 2) { + uint8_t lo = Wire.read(); + uint8_t hi = Wire.read(); + pending_delay = (uint16_t)lo | ((uint16_t)hi << 8); + have_delay = true; + } + break; + case HUB_CMD_SET_VOLUME: + if (Wire.available() >= 1) { + pending_volume = Wire.read(); + have_volume = true; + } + break; + case HUB_CMD_GET_STATUS: + // Nothing to do: onRequest serves the pre-filled status_buf. + break; + default: + break; + } + while (Wire.available()) Wire.read(); // drain any extra bytes +} + +// Master issued a read after GET_STATUS: hand back the current snapshot. +static void on_i2c_request() { + Wire.write((uint8_t *)status_buf, HUB_STATUS_LEN); +} + // Continuously pull I2S into the FIFO (paced by Board A's master clock). // Carry any partial frame across reads so L/R never slips out of alignment. static void i2s_task(void *arg) { @@ -139,6 +200,16 @@ void setup() { prefs.begin("bikeaudio", false); set_delay(prefs.getUShort("delay_ms", 0)); save_pending = false; + current_volume = (uint8_t)prefs.getUShort("vol", 100); + if (current_volume > 100) current_volume = 100; + + // I2C control bus: slave at HUB_I2C_ADDR on SDA=32 / SCL=33. + Wire.begin((uint8_t)HUB_I2C_ADDR, I2C_SDA_PIN, I2C_SCL_PIN, 100000); + Wire.onReceive(on_i2c_receive); + Wire.onRequest(on_i2c_request); + Serial.printf("[SRC %s] I2C slave @ 0x%02X (SDA=%d SCL=%d), vol=%u\n", + TARGET_SPEAKER, (unsigned)HUB_I2C_ADDR, I2C_SDA_PIN, I2C_SCL_PIN, + current_volume); auto cfg = i2s.defaultConfig(RX_MODE); cfg.pin_bck = I2S_BCK_PIN; cfg.pin_ws = I2S_WS_PIN; cfg.pin_data = I2S_DATA_PIN; @@ -151,7 +222,7 @@ void setup() { source.set_data_callback_in_frames(read_delayed); source.set_on_connection_state_changed(on_conn_state); source.set_auto_reconnect(true, 5); - source.set_volume(100); + source.set_volume(current_volume); source.start(TARGET_SPEAKER); Serial.printf("[SRC] Connecting to '%s' — trim %u ms; touch + GPIO4, - GPIO27\n", TARGET_SPEAKER, delay_ms_current); @@ -160,6 +231,30 @@ void setup() { void loop() { unsigned long now = millis(); + // Apply I2C requests stashed by the Wire callbacks (heavy work runs here). + if (have_delay) { + have_delay = false; + set_delay((int)pending_delay); + } + if (have_volume) { + have_volume = false; + uint8_t v = pending_volume; + if (v > 100) v = 100; + if (v != current_volume) { + current_volume = v; + source.set_volume(current_volume); + vol_save_pending = true; + last_change_ms = now; + Serial.printf("[SRC %s] volume = %u\n", TARGET_SPEAKER, current_volume); + } + } + + // Refresh the status snapshot served on GET_STATUS reads. + status_buf[0] = source.is_connected() ? 1 : 0; + status_buf[1] = (uint8_t)(delay_ms_current & 0xFF); + status_buf[2] = (uint8_t)(delay_ms_current >> 8); + status_buf[3] = current_volume; + static unsigned long last_touch = 0; if (now - last_touch >= TOUCH_REPEAT_MS) { bool plus = touchRead(TOUCH_PLUS) < TOUCH_THRESH; @@ -174,6 +269,12 @@ void loop() { Serial.printf("[SRC %s] saved %u ms to flash\n", TARGET_SPEAKER, delay_ms_current); } + if (vol_save_pending && now - last_change_ms > 1500) { + prefs.putUShort("vol", current_volume); + vol_save_pending = false; + Serial.printf("[SRC %s] saved vol %u to flash\n", TARGET_SPEAKER, current_volume); + } + static unsigned long last_st = 0; if (now - last_st > 5000) { Serial.printf("[SRC %s] connected=%s trim=%ums fill=%u heap=%u\n", diff --git a/src/hub_proto.h b/src/hub_proto.h new file mode 100644 index 0000000..b8a5deb --- /dev/null +++ b/src/hub_proto.h @@ -0,0 +1,28 @@ +/** + * BikeAudio — control-bus protocol shared by the S3 hub (I2C master) and the + * source Boards B/C (I2C slaves). Included by both hub_s3.cpp and board_source.cpp. + * + * Bus: I2C. Hub = master. Each source board = a slave at a fixed address. + * S3: SDA=GPIO15 SCL=GPIO16 (board expansion header) + * B/C: SDA=GPIO32 SCL=GPIO33 (free pins; I2S=19/18/22, touch=4/27) + * Shared GND + two ~4.7k pull-ups (SDA->3V3, SCL->3V3). + */ +#pragma once +#include + +#define HUB_I2C_ADDR_JBL 0x10 +#define HUB_I2C_ADDR_CARDO 0x11 + +// Master -> slave WRITE: first byte = command, then args (little-endian). +#define HUB_CMD_SET_DELAY 0x01 // arg: uint16 delay_ms (0..HUB_MAX_DELAY_MS) +#define HUB_CMD_SET_VOLUME 0x02 // arg: uint8 volume (0..100) +#define HUB_CMD_GET_STATUS 0x10 // master then issues a READ of HUB_STATUS_LEN bytes + +#define HUB_MAX_DELAY_MS 200 + +// Slave -> master status payload (returned on the read after HUB_CMD_GET_STATUS): +// [0] connected (0/1) +// [1] delay_ms low byte +// [2] delay_ms high byte +// [3] volume (0..100) +#define HUB_STATUS_LEN 4 diff --git a/src/hub_s3.cpp b/src/hub_s3.cpp index 8adff75..1ccc00f 100644 --- a/src/hub_s3.cpp +++ b/src/hub_s3.cpp @@ -1,19 +1,29 @@ /** - * BikeAudio — Hub (ESP32-S3-Touch-LCD-1.28) : PHASE 1 bring-up + * BikeAudio — Hub (ESP32-S3-Touch-LCD-1.28) : PHASE 2 control bring-up * - * First light: drive the round GC9A01 LCD + backlight, and read the CST816S - * touch, to prove the board before building the GUI. No control bus / GUI yet. + * Round GC9A01 LCD + CST816S touch (kept from phase 1 first-light), now wired + * up as an I2C MASTER to the two source boards (JBL, Cardo) and showing a + * simple functional touch UI: each speaker's connect state, delay (ms) and + * volume (%), with touch zones to nudge delay / volume +/-. This proves the + * end-to-end control path before we build the polished LVGL GUI. + * + * Two I2C buses are in use, on separate ports: + * - port 0 (Wire1, owned by LovyanGFX): CST816S touch SDA=6 SCL=7 + * - port "Wire" (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 +#include #include +#include "hub_proto.h" class LGFX : public lgfx::LGFX_Device { lgfx::Panel_GC9A01 _panel; @@ -67,36 +77,277 @@ public: 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 VOL_STEP 5 // % per volume +/- press +#define POLL_PERIOD_MS 1000 // status poll cadence per speaker +#define TOUCH_DEBOUNCE 250 // ms; ignore repeat presses inside this window + +// 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; + bool online; // I2C transaction succeeded this poll + bool connected; // board says its BT sink is connected + uint16_t delay_ms; + uint8_t volume; +}; + +static Speaker speakers[2] = { + { HUB_I2C_ADDR_JBL, "JBL", false, false, 0, 0 }, + { HUB_I2C_ADDR_CARDO, "Cardo", false, false, 0, 0 }, +}; + +// --------------------------------------------------------------------------- +// I2C helpers +// --------------------------------------------------------------------------- + +// Poll one speaker for status. Updates s.online/connected/delay_ms/volume. +// On any bus failure the speaker is marked offline (displayed as a dash). +// 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 b0 = Wire1.read(); + uint8_t b1 = Wire1.read(); + uint8_t b2 = Wire1.read(); + uint8_t b3 = Wire1.read(); + s.online = true; + s.connected = (b0 != 0); + s.delay_ms = (uint16_t)(b1 | (b2 << 8)); + s.volume = b3; +} + +// 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(); +} + +// --------------------------------------------------------------------------- +// UI layout +// --------------------------------------------------------------------------- +// 240x240 round screen split top (JBL) / bottom (Cardo). Controls live in the +// safe center column so they stay clear of the round bezel. Each half has a +// row of four square buttons: [d-] [d+] [v-] [v+]. Status text sits above. +// +// y rows: +// JBL title ~22, status ~46, buttons ~70..102 +// Cardo title ~138, status ~162, buttons ~186..218 + +#define BTN_W 34 +#define BTN_H 32 +#define BTN_GAP 6 + +// 4 buttons centered horizontally: total width = 4*W + 3*GAP +static const int kBtnTotalW = 4 * BTN_W + 3 * BTN_GAP; +static const int kBtnX0 = (240 - kBtnTotalW) / 2; // left edge of first button + +static const int kBtnYTop[2] = { 70, 186 }; // button-row top Y for each speaker + +// Button kinds, in draw/hit order. +enum BtnKind { B_DELAY_DOWN = 0, B_DELAY_UP, B_VOL_DOWN, B_VOL_UP, B_COUNT }; +static const char *kBtnLabel[B_COUNT] = { "d-", "d+", "v-", "v+" }; + +// Bounding box of button `k` for speaker index `i`. +static void btnRect(int i, int k, int &x, int &y, int &w, int &h) { + x = kBtnX0 + k * (BTN_W + BTN_GAP); + y = kBtnYTop[i]; + w = BTN_W; + h = BTN_H; +} + +static bool inRect(int px, int py, int x, int y, int w, int h) { + return px >= x && px < x + w && py >= y && py < y + h; +} + +// Draw the four control buttons for speaker `i`. `pressed` highlights one. +static void drawButtons(int i, int pressed = -1) { + for (int k = 0; k < B_COUNT; k++) { + int x, y, w, h; + btnRect(i, k, x, y, w, h); + uint16_t fill = (k == pressed) ? TFT_DARKCYAN : TFT_NAVY; + 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(kBtnLabel[k], x + w / 2, y + h / 2, &fonts::Font2); + } +} + +// Title + status line for speaker `i` (online/connected, delay, volume). +static void drawStatus(int i) { + const Speaker &s = speakers[i]; + int titleY = (i == 0) ? 22 : 138; + int statusY = (i == 0) ? 46 : 162; + + // Title with a connect dot to its left. + lcd.setTextDatum(middle_center); + lcd.setTextColor(TFT_CYAN, TFT_BLACK); + lcd.drawString(s.name, 120, titleY, &fonts::Font2); + + uint16_t dot = !s.online ? TFT_DARKGREY : (s.connected ? TFT_GREEN : TFT_RED); + lcd.fillCircle(120 - 36, titleY, 5, dot); + + // Status line: either a dash (offline) or "Dnnn Vnn%". + char buf[40]; + if (!s.online) { + snprintf(buf, sizeof(buf), "-- offline --"); + lcd.setTextColor(TFT_DARKGREY, TFT_BLACK); + } else { + snprintf(buf, sizeof(buf), "D%ums V%u%%", s.delay_ms, s.volume); + lcd.setTextColor(TFT_WHITE, TFT_BLACK); + } + // Clear the old line first (full-width band) then draw centered. + lcd.fillRect(0, statusY - 9, 240, 18, TFT_BLACK); + lcd.drawString(buf, 120, statusY, &fonts::Font2); +} + +// Redraw the whole static frame once (background, divider, both halves). +static void drawFrame() { + lcd.fillScreen(TFT_BLACK); + lcd.drawCircle(120, 120, 118, TFT_DARKGREY); + lcd.drawFastHLine(20, 120, 200, TFT_DARKGREY); // top/bottom divider + for (int i = 0; i < 2; i++) { + drawStatus(i); + drawButtons(i); + } +} + +// Handle a touch at (x,y): find the button, apply step, send I2C command and +// optimistically update + redraw the affected speaker. Returns true if a +// button was hit. +static bool handleTouch(int x, int y) { + for (int i = 0; i < 2; i++) { + for (int k = 0; k < B_COUNT; k++) { + int bx, by, bw, bh; + btnRect(i, k, bx, by, bw, bh); + if (!inRect(x, y, bx, by, bw, bh)) continue; + + Speaker &s = speakers[i]; + switch (k) { + case B_DELAY_DOWN: { + int d = (int)s.delay_ms - DELAY_STEP; + if (d < 0) d = 0; + s.delay_ms = (uint16_t)d; + sendDelay(s, s.delay_ms); + break; + } + case B_DELAY_UP: { + int d = (int)s.delay_ms + DELAY_STEP; + if (d > HUB_MAX_DELAY_MS) d = HUB_MAX_DELAY_MS; + s.delay_ms = (uint16_t)d; + sendDelay(s, s.delay_ms); + break; + } + case B_VOL_DOWN: { + int v = (int)s.volume - VOL_STEP; + if (v < 0) v = 0; + s.volume = (uint8_t)v; + sendVolume(s, s.volume); + break; + } + case B_VOL_UP: { + int v = (int)s.volume + VOL_STEP; + if (v > 100) v = 100; + s.volume = (uint8_t)v; + sendVolume(s, s.volume); + break; + } + } + // Visual feedback + optimistic value update. + drawButtons(i, k); + drawStatus(i); + Serial.printf("[touch] %s %s -> D%ums V%u%%\n", + s.name, kBtnLabel[k], s.delay_ms, s.volume); + return true; + } + } + return false; +} + void setup() { Serial.begin(115200); delay(300); - Serial.println("=== BikeAudio Hub — S3 round LCD first light ==="); + Serial.println("=== BikeAudio Hub — S3 control bring-up ==="); + + // Control bus: I2C master to the source boards on Wire1 (peripheral 1). + // Peripheral 0 (Wire) 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); - lcd.fillScreen(TFT_BLACK); - lcd.drawCircle(120, 120, 118, TFT_DARKGREY); - lcd.setTextColor(TFT_CYAN, TFT_BLACK); - lcd.setTextDatum(middle_center); - lcd.drawString("BikeAudio", 120, 100, &fonts::FreeSansBold18pt7b); - lcd.setTextColor(TFT_WHITE, TFT_BLACK); - lcd.drawString("hub: first light", 120, 140, &fonts::Font2); - lcd.drawString("touch me", 120, 160, &fonts::Font2); - Serial.println("[LCD] drawn; touch to draw dots"); + drawFrame(); + Serial.println("[LCD] UI drawn"); } void loop() { - int32_t x, y; - if (lcd.getTouch(&x, &y)) { - lcd.fillCircle(x, y, 5, TFT_RED); - Serial.printf("[TOUCH] %d, %d\n", x, y); + unsigned long now = millis(); + + // ---- touch (debounced) ---- + static unsigned long lastTouch = 0; + int32_t tx, ty; + if (lcd.getTouch(&tx, &ty)) { + if (now - lastTouch >= TOUCH_DEBOUNCE) { + if (handleTouch(tx, ty)) { + lastTouch = now; + } + } } - static unsigned long last = 0; - if (millis() - last > 5000) { - Serial.printf("[hub] alive heap=%u psram=%u\n", ESP.getFreeHeap(), ESP.getFreePsram()); - last = millis(); + // ---- status poll (~1 Hz) ---- + static unsigned long lastPoll = 0; + if (now - lastPoll >= POLL_PERIOD_MS) { + lastPoll = now; + for (int i = 0; i < 2; i++) { + pollStatus(speakers[i]); + drawStatus(i); + } } + + // ---- heartbeat ---- + static unsigned long lastBeat = 0; + if (now - lastBeat >= 5000) { + lastBeat = now; + Serial.printf("[hub] alive heap=%u JBL[%s D%u V%u] Cardo[%s D%u V%u]\n", + ESP.getFreeHeap(), + speakers[0].online ? (speakers[0].connected ? "on" : "idle") : "off", + speakers[0].delay_ms, speakers[0].volume, + speakers[1].online ? (speakers[1].connected ? "on" : "idle") : "off", + speakers[1].delay_ms, speakers[1].volume); + } + delay(15); }