Compare commits

..

No commits in common. "blue/s3-hub" and "main" have entirely different histories.

11 changed files with 252 additions and 2370 deletions

252
BikeAudio.ino Normal file
View File

@ -0,0 +1,252 @@
/**
* BikeAudio ESP32 DevKitC v4 Bluetooth Audio Relay
*
* iPhone --> [ESP32 A2DP SINK] --> [A2DP SOURCE x2] --> JBL Charge 5 + Cardo Packtalk Edge
*
* Libraries required (install via Arduino IDE Library Manager or .zip):
* - ESP32-A2DP by Phil Schatzmann https://github.com/pschatzmann/ESP32-A2DP
* - arduino-audio-tools by Phil Schatzmann https://github.com/pschatzmann/arduino-audio-tools
*
* Board: ESP32 Dev Module
* Partition scheme: Huge APP (3MB No OTA/1MB SPIFFS) <-- required for BT stack size
* ESP32 Arduino core: 2.0.17 (do NOT use 3.x BT stack regression)
*
* HOW IT WORKS:
* 1. ESP32 boots and connects to JBL Charge 5 and Cardo as A2DP sources
* 2. Then advertises itself as "BikeAudio" for the iPhone to connect to
* 3. Audio received from iPhone is forwarded to both speakers in real time
* 4. Auto-reconnect on power cycle just turn everything on and it finds each other
*
* FIRST TIME SETUP:
* - Forget JBL and Cardo from your iPhone
* - Put JBL in pairing mode (hold Bluetooth button)
* - Put Cardo in pairing mode (check Cardo manual usually hold phone button)
* - Flash this sketch, open Serial Monitor at 115200
* - ESP32 will find and pair with both devices on first boot
* - On iPhone, go to Bluetooth settings and connect to "BikeAudio"
* - Done play audio, both speakers output simultaneously
*/
#include "AudioTools.h"
#include "BluetoothA2DPSink.h"
#include "BluetoothA2DPSource.h"
#include "BluetoothA2DPCommon.h"
// ─── CONFIGURATION ────────────────────────────────────────────────────────────
// Name this device shows to iPhone
#define SINK_NAME "BikeAudio"
// Exact Bluetooth names of your speakers (must match exactly, case sensitive)
#define JBL_NAME "JBL Charge 5"
#define CARDO_NAME "Tangerine EDGE"
// Retry interval if a speaker disconnects (ms)
#define RECONNECT_MS 5000
// Audio buffer size — larger = more stable, slightly more latency
#define BUFFER_SIZE (4 * 1024)
// ─── GLOBALS ──────────────────────────────────────────────────────────────────
BluetoothA2DPSink sink; // receives audio FROM iPhone
BluetoothA2DPSource src_jbl; // sends audio TO JBL
BluetoothA2DPSource src_cardo; // sends audio TO Cardo
// Shared ring buffer — sink writes, sources read
RingBuffer<uint8_t> ring_buf(BUFFER_SIZE * 2);
// Connection state
volatile bool jbl_connected = false;
volatile bool cardo_connected = false;
volatile bool iphone_connected = false;
unsigned long last_reconnect_jbl = 0;
unsigned long last_reconnect_cardo = 0;
// ─── AUDIO CALLBACK (iPhone → buffer) ────────────────────────────────────────
/**
* Called by the A2DP sink every time a new audio frame arrives from iPhone.
* We write raw PCM into the shared ring buffer.
* Both sources pull from this buffer simultaneously.
*/
void audio_received_cb(const uint8_t *data, uint32_t len) {
// Write to ring buffer — non-blocking, drop if full (prevents deadlock)
for (uint32_t i = 0; i < len; i++) {
if (!ring_buf.isFull()) {
ring_buf.write(data[i]);
}
}
}
// ─── SOURCE DATA CALLBACK (buffer → JBL / Cardo) ─────────────────────────────
/**
* Called by each A2DP source when it needs audio data to send.
* Both JBL and Cardo call this they share the same buffer read pointer
* via a duplicated/mirrored buffer approach.
*
* We use a simple approach: one primary reader (JBL) drains the buffer,
* Cardo gets the same data via a mirrored write in audio_received_cb.
*/
// Second ring buffer mirroring data for Cardo
RingBuffer<uint8_t> ring_buf_cardo(BUFFER_SIZE * 2);
void audio_received_mirror_cb(const uint8_t *data, uint32_t len) {
// Write to BOTH ring buffers — JBL gets ring_buf, Cardo gets ring_buf_cardo
for (uint32_t i = 0; i < len; i++) {
if (!ring_buf.isFull()) ring_buf.write(data[i]);
if (!ring_buf_cardo.isFull()) ring_buf_cardo.write(data[i]);
}
}
int32_t get_audio_for_jbl(uint8_t *data, int32_t len) {
int32_t bytes_read = 0;
while (bytes_read < len && !ring_buf.isEmpty()) {
data[bytes_read++] = ring_buf.read();
}
// Pad with silence if buffer underrun
if (bytes_read < len) {
memset(data + bytes_read, 0, len - bytes_read);
}
return len;
}
int32_t get_audio_for_cardo(uint8_t *data, int32_t len) {
int32_t bytes_read = 0;
while (bytes_read < len && !ring_buf_cardo.isEmpty()) {
data[bytes_read++] = ring_buf_cardo.read();
}
if (bytes_read < len) {
memset(data + bytes_read, 0, len - bytes_read);
}
return len;
}
// ─── CONNECTION CALLBACKS ─────────────────────────────────────────────────────
void sink_connected_cb(esp_bd_addr_t addr, esp_a2d_connection_state_t state, void *obj) {
if (state == ESP_A2D_CONNECTION_STATE_CONNECTED) {
Serial.println("[SINK] iPhone connected");
iphone_connected = true;
} else {
Serial.println("[SINK] iPhone disconnected");
iphone_connected = false;
}
}
void jbl_connected_cb(esp_bd_addr_t addr, esp_a2d_connection_state_t state, void *obj) {
if (state == ESP_A2D_CONNECTION_STATE_CONNECTED) {
Serial.println("[JBL] Connected");
jbl_connected = true;
} else {
Serial.println("[JBL] Disconnected — will retry");
jbl_connected = false;
last_reconnect_jbl = millis();
}
}
void cardo_connected_cb(esp_bd_addr_t addr, esp_a2d_connection_state_t state, void *obj) {
if (state == ESP_A2D_CONNECTION_STATE_CONNECTED) {
Serial.println("[CARDO] Connected");
cardo_connected = true;
} else {
Serial.println("[CARDO] Disconnected — will retry");
cardo_connected = false;
last_reconnect_cardo = millis();
}
}
// ─── SETUP ────────────────────────────────────────────────────────────────────
void setup() {
Serial.begin(115200);
delay(500);
Serial.println("=== BikeAudio Booting ===");
// ── Step 1: Connect to JBL as A2DP source ─────────────────────────────────
Serial.println("[JBL] Connecting...");
src_jbl.set_data_callback(get_audio_for_jbl);
src_jbl.set_on_connection_state_changed(jbl_connected_cb);
src_jbl.set_auto_reconnect(true);
src_jbl.start(JBL_NAME, true); // true = reconnect if known device
// Give it time to connect before starting second source
// (Bluedroid needs sequential connection setup)
uint32_t t = millis();
while (!jbl_connected && millis() - t < 10000) {
delay(100);
}
if (jbl_connected) {
Serial.println("[JBL] Ready");
} else {
Serial.println("[JBL] Not found yet — will retry in background");
}
// ── Step 2: Connect to Cardo as A2DP source ────────────────────────────────
Serial.println("[CARDO] Connecting...");
src_cardo.set_data_callback(get_audio_for_cardo);
src_cardo.set_on_connection_state_changed(cardo_connected_cb);
src_cardo.set_auto_reconnect(true);
src_cardo.start(CARDO_NAME, true);
t = millis();
while (!cardo_connected && millis() - t < 10000) {
delay(100);
}
if (cardo_connected) {
Serial.println("[CARDO] Ready");
} else {
Serial.println("[CARDO] Not found yet — will retry in background");
}
// ── Step 3: Start sink — advertise "BikeAudio" to iPhone ──────────────────
Serial.println("[SINK] Advertising as '" SINK_NAME "' ...");
sink.set_stream_reader(audio_received_mirror_cb);
sink.set_on_connection_state_changed(sink_connected_cb);
sink.start(SINK_NAME);
Serial.println("[SINK] Ready — connect iPhone to 'BikeAudio'");
Serial.println("=== BikeAudio Ready ===");
print_status();
}
// ─── LOOP ─────────────────────────────────────────────────────────────────────
void loop() {
// Auto-reconnect JBL if lost
if (!jbl_connected && millis() - last_reconnect_jbl > RECONNECT_MS) {
Serial.println("[JBL] Retrying connection...");
src_jbl.start(JBL_NAME, true);
last_reconnect_jbl = millis();
}
// Auto-reconnect Cardo if lost
if (!cardo_connected && millis() - last_reconnect_cardo > RECONNECT_MS) {
Serial.println("[CARDO] Retrying connection...");
src_cardo.start(CARDO_NAME, true);
last_reconnect_cardo = millis();
}
// Print status every 10 seconds
static unsigned long last_status = 0;
if (millis() - last_status > 10000) {
print_status();
last_status = millis();
}
delay(100);
}
// ─── HELPERS ──────────────────────────────────────────────────────────────────
void print_status() {
Serial.println("--- Status ---");
Serial.printf(" iPhone : %s\n", iphone_connected ? "CONNECTED" : "waiting...");
Serial.printf(" JBL : %s\n", jbl_connected ? "CONNECTED" : "waiting...");
Serial.printf(" Cardo : %s\n", cardo_connected ? "CONNECTED" : "waiting...");
Serial.printf(" Heap : %d bytes free\n", ESP.getFreeHeap());
Serial.println("--------------");
}

View File

@ -1,103 +0,0 @@
# Resound — 3-board Bluetooth relay
Relays iPhone audio to **two** Bluetooth speakers (JBL Charge 5 + Cardo "Tangerine EDGE")
at the same time.
## Why three boards?
One ESP32 **cannot** do this alone:
1. **Sink + source can't coexist.** To receive from the iPhone the ESP32 must be an
A2DP *sink*; to play through a speaker it must be an A2DP *source*. The ESP32's
classic-Bluetooth stack registers only one A2DP role at a time (the ESP32-A2DP
library keeps a single global instance that each role overwrites). Running a sink
and a source together orphans the sink — the iPhone can't even see it.
2. **A source reaches only one speaker.** An A2DP source holds a single outgoing
link, so one board can drive one speaker, not two.
So the job is split: one board receives, one board per speaker sends, and they pass
audio between them over a short digital **I2S** wire bus.
```
iPhone ))BT)) ┌──────────────┐ I2S bus (BCK/WS/DATA + GND)
│ Board A │═══════════════╦═══════════════╗
│ A2DP SINK │ ║ ║
│ I2S MASTER │ ▼ ▼
└──────────────┘ ┌────────────┐ ┌────────────┐
│ Board B │ │ Board C │
│ A2DP SOURCE│ │ A2DP SOURCE│
│ I2S SLAVE │ │ I2S SLAVE │
└─────┬──────┘ └─────┬──────┘
))BT)) ))BT))
JBL Charge 5 Tangerine EDGE (Cardo)
```
## Wiring
Board A is the I2S **master** (it generates the clocks). Boards B and C are
**slaves** that listen to A's bus in parallel. Tie the three signals from A to the
matching input pins on **both** B and C, and tie **all grounds together**.
| Signal | Board A (master, out) | Board B (slave, in) | Board C (slave, in) |
|-------------|-----------------------|---------------------|---------------------|
| Bit clock | **GPIO5** (BCK) | GPIO19 | GPIO19 |
| Word select | **GPIO25** (WS/LRCK) | GPIO18 | GPIO18 |
| Data | **GPIO23** (DATA out) | GPIO22 (in) | GPIO22 (in) |
| Ground | **GND** | GND | GND |
- A·GPIO5 → B·GPIO19 **and** C·GPIO19
- A·GPIO25 → B·GPIO18 **and** C·GPIO18
- A·GPIO23 → B·GPIO22 **and** C·GPIO22
- A·GND → B·GND **and** C·GND (mandatory — shared clock reference)
Each board can be powered from its own USB/5V; only the grounds must be common.
### Sync (touch pads on each source board)
JBL and Cardo have independent Bluetooth buffering, so one lags the other. Each
source board has an **adjustable delay** (0200 ms) you trim live, by ear, with two
capacitive-touch pads — raise the delay on whichever speaker is *early* until they
line up. The value is saved to flash (survives power-cycles).
| Touch pad | Pin | Action |
|-----------|-------|--------------------------------|
| **+** | GPIO4 | tap = +5 ms, hold = ramp up |
| **** | GPIO27 | tap = 5 ms, hold = ramp down |
Attach a short wire or a bit of foil to GPIO4 and GPIO27 on each source board and
touch the end. (There is intentionally **no Wi-Fi/phone UI**: Wi-Fi + Bluetooth +
the audio buffer don't fit in RAM on the classic ESP32 — it starves the Bluetooth
stack — so the control is local touch.)
## Build & flash
```
pio run # builds all three
pio run -e sink # Board A
pio run -e source_jbl # Board B (target "JBL Charge 5")
pio run -e source_cardo # Board C (target "Tangerine EDGE")
```
Flash each board with the matching environment's artifacts
(`.pio/build/<env>/{bootloader,partitions,firmware}.bin`).
**Power-on order:** bring up **Board A first** so the I2S bus is clocking before
B and C start reading it.
## First-time pairing
1. Put the **JBL** and **Cardo** in pairing mode.
2. Power Board B and Board C — each connects to its speaker by name
(auto-reconnects on later power-ups).
3. Power Board A; on the iPhone, connect to **"Resound"**.
4. Play audio — both speakers should output together.
## Known limitations
- **The two speakers are not sample-synchronized.** JBL and Cardo each have their
own Bluetooth buffering, so one lags the other. The per-board touch delay (above)
lets you trim a fixed offset to line them up by ear; it is not continuous
sample-lock, so slow drift over long sessions is possible. Fine for music/intercom;
not suitable for tight stereo L/R separation.
- Audio is fixed at the SBC standard **44.1 kHz / 16-bit / stereo**.
- If Board A reboots, the slave boards' audio pauses until A is clocking again.

View File

@ -1,45 +0,0 @@
Ref,Qty,Value,Footprint,MPN/JLCPCB part,DNP(y/n),Notes
U1,1,ESP32-WROOM-32E,RF_Module:ESP32-WROOM-32,ESP32-WROOM-32E / JLCPCB C701343,n,SMD castellated 38-pad module; antenna overhangs board edge (see keep-out)
J1,1,Stack_2x13,Connector_PinHeader_2.54mm:PinHeader_2x13_P2.54mm_Vertical,Generic 2x13 2.54mm,n,Stacking pass-through bus; mirror header on bottom shares same nets
J2,1,PROG_1x6,Connector_PinHeader_2.54mm:PinHeader_1x06_P2.54mm_Vertical,Generic 1x6 2.54mm,n,Programming/serial: GND +5V RX(IO3) TX(IO1) DTR RTS
J3,1,HUD_SH1.0_1x6,Connector_JST:JST_SH_BM06B-SRSS-TB_1x06-1MP_P1.00mm_Vertical,JST BM06B-SRSS-TB,n,TOP board only: 1=+5V 2=GND 3=SDA 4=SCL 5=SPARE 6=NC. Verify pin order vs HUD cable
Q1,1,MMBT3904,Package_TO_SOT_SMD:SOT-23,MMBT3904 / JLCPCB C20526,n,Auto-reset (DTR->EN)
Q2,1,MMBT3904,Package_TO_SOT_SMD:SOT-23,MMBT3904 / JLCPCB C20526,n,Auto-boot (RTS->IO0)
SW1,1,EN/RESET,Button_Switch_SMD:SW_SPST_PTS645,PTS645 series,n,Tactile EN->GND
SW2,1,BOOT/IO0,Button_Switch_SMD:SW_SPST_PTS645,PTS645 series,n,Tactile IO0->GND
JP1,1,0R,Resistor_SMD:R_0603_1608Metric,0R 0603,n,I2S_BCK=GPIO5 (SINK column)
JP2,1,0R,Resistor_SMD:R_0603_1608Metric,0R 0603,n,I2S_WS=GPIO25 (SINK column)
JP3,1,0R,Resistor_SMD:R_0603_1608Metric,0R 0603,n,I2S_DATA=GPIO23 (SINK column)
JP4,1,0R,Resistor_SMD:R_0603_1608Metric,0R 0603,n,I2S_BCK=GPIO19 (BROADCASTER column)
JP5,1,0R,Resistor_SMD:R_0603_1608Metric,0R 0603,n,I2S_WS=GPIO18 (BROADCASTER column)
JP6,1,0R,Resistor_SMD:R_0603_1608Metric,0R 0603,n,I2S_DATA=GPIO22 (BROADCASTER column)
R1,1,33R,Resistor_SMD:R_0402_1005Metric,33R 0402,y,I2S_BCK series term (optional; DNP default)
R2,1,33R,Resistor_SMD:R_0402_1005Metric,33R 0402,y,I2S_WS series term (optional; DNP default)
R3,1,33R,Resistor_SMD:R_0402_1005Metric,33R 0402,y,I2S_DATA series term (optional; DNP default = 0R jumper if not fitted)
R4,1,4.7k,Resistor_SMD:R_0402_1005Metric,4.7k 0402,y,SDA pull-up to 3V3. Populate on SINK only (single pull-up on bus)
R5,1,4.7k,Resistor_SMD:R_0402_1005Metric,4.7k 0402,y,SCL pull-up to 3V3. Populate on SINK only (single pull-up on bus)
R6,1,10k,Resistor_SMD:R_0402_1005Metric,10k 0402,n,EN pull-up to 3V3
R7,1,10k,Resistor_SMD:R_0402_1005Metric,10k 0402,n,IO0 pull-up to 3V3
R8,1,10k,Resistor_SMD:R_0402_1005Metric,10k 0402,n,Q1 base resistor (auto-reset)
R9,1,10k,Resistor_SMD:R_0402_1005Metric,10k 0402,n,Q2 base resistor (auto-boot)
R10,1,10k,Resistor_SMD:R_0402_1005Metric,10k 0402,n,ADDR0/GPIO13 strap to 3V3 (populate this OR R11)
R11,1,0R,Resistor_SMD:R_0402_1005Metric,0R 0402,y,ADDR0/GPIO13 strap to GND (populate this OR R10)
R12,1,10k,Resistor_SMD:R_0402_1005Metric,10k 0402,n,ADDR1/GPIO14 strap to 3V3 (populate this OR R13)
R13,1,0R,Resistor_SMD:R_0402_1005Metric,0R 0402,y,ADDR1/GPIO14 strap to GND (populate this OR R12)
R14,1,10k,Resistor_SMD:R_0402_1005Metric,10k 0402,n,ADDR2/GPIO27 strap to 3V3 (populate this OR R15)
R15,1,0R,Resistor_SMD:R_0402_1005Metric,0R 0402,y,ADDR2/GPIO27 strap to GND (populate this OR R14)
R16,1,10k,Resistor_SMD:R_0402_1005Metric,10k 0402,n,ADDR3/GPIO26 strap to 3V3 (populate this OR R17)
R17,1,0R,Resistor_SMD:R_0402_1005Metric,0R 0402,y,ADDR3/GPIO26 strap to GND (populate this OR R16)
C1,1,1uF,Capacitor_SMD:C_0603_1608Metric,1uF 0603,n,EN to GND (reset delay)
C2,1,100nF,Capacitor_SMD:C_0402_1005Metric,100nF 0402,n,IO0 to GND (boot debounce)
C3,1,100nF,Capacitor_SMD:C_0402_1005Metric,100nF 0402,n,DTR coupling cap to Q1
C4,1,100nF,Capacitor_SMD:C_0402_1005Metric,100nF 0402,n,RTS coupling cap to Q2
C5,1,10uF,Capacitor_SMD:C_0805_2012Metric,10uF 0805,n,3V3 bulk at WROOM 3V3 pad
C6,1,100nF,Capacitor_SMD:C_0402_1005Metric,100nF 0402,n,3V3 decap at WROOM 3V3 pad
C7,1,100nF,Capacitor_SMD:C_0402_1005Metric,100nF 0402,n,3V3 decap at WROOM 3V3 pad
C8,1,10uF,Capacitor_SMD:C_0805_2012Metric,10uF 0805,n,+5V bulk at stacking connector
C9,1,22uF,Capacitor_SMD:C_0805_2012Metric,22uF 0805,n,3V3 bulk rail
H1,1,MountingHole_M3,MountingHole:MountingHole_3.2mm_M3,-,n,Corner mount, 38x38mm pattern, GND-tied
H2,1,MountingHole_M3,MountingHole:MountingHole_3.2mm_M3,-,n,Corner mount, GND-tied
H3,1,MountingHole_M3,MountingHole:MountingHole_3.2mm_M3,-,n,Corner mount, GND-tied
H4,1,MountingHole_M3,MountingHole:MountingHole_3.2mm_M3,-,n,Corner mount, GND-tied
Can't render this file because it has a wrong number of fields in line 42.

View File

@ -1,129 +0,0 @@
# Resound Small Build — Carrier PCB Layout & Fab Spec
This document is the layout intent that pairs with `resound-carrier.net`. The netlist
defines connectivity; this defines board outline, stackup, placement, and design rules.
An engineer imports the netlist into KiCad, then lays out per the constraints here.
## 1. Board outline
- **Size:** 45.0 mm x 45.0 mm.
- **Corners:** rounded, R = 3.0 mm (4x).
- **Edge.Cuts** layer defines outline. Keep all copper >= 0.3 mm inside the edge except
the deliberate antenna keep-out region (see section 4).
## 2. Stackup (4-layer)
| Layer | Name | Use |
|-------|-------------|--------------------------------------------------|
| L1 | Sig (top) | Components, signal routing, WROOM, connectors |
| L2 | GND | Solid ground plane (reference for all signals) |
| L3 | PWR | +3V3 / +5V power pours |
| L4 | Sig (bottom)| Signal routing, bottom stacking header, fanout |
- Standard JLCPCB 4-layer: 1.6 mm finished, 0.5 oz inner / 1 oz outer, FR-4 TG155 OK.
- Stitch GND plane to top/bottom ground pours with vias around board perimeter and under
the WROOM ground pad array. Place a dense via field under U1's exposed/edge GND pads.
## 3. Power distribution
- +5V enters on stacking connector J1 pins 1,2. C8 (10uF) bulk at connector.
- +3V3 rail on J1 pins 5,6 (module/regulator assumed upstream on the +3V3 supplying board).
C9 (22uF) bulk on 3V3. C5 (10uF) + C6/C7 (100nF) directly at the WROOM 3V3 pad (pad 2),
shortest possible loop to nearest GND via.
- Power pour widths: 5V and 3V3 main feeds >= 0.6 mm or plane on L3.
## 4. Antenna keep-out (CRITICAL)
- U1 (ESP32-WROOM-32E) placed at one board edge with the **PCB antenna overhanging the
board edge** — the antenna end of the module must sit flush with / past the edge cut.
- **15 mm copper keep-out, ALL LAYERS**, around the antenna: no copper (no traces, no
pours, no plane fill, no ground) within 15 mm of the antenna trace footprint, including
L2 GND and L3 PWR planes. Add a keep-out zone on every layer.
- No mounting hardware, no metal, no components in the antenna keep-out.
- This is why U1 lives at the edge and the antenna overhangs: maximize RF clearance.
## 5. Placement plan
```
+-----------------------------------------------+
| (H1) (H2) |
| [ U1 ESP32-WROOM-32E ] <- antenna overhang| <- this edge: antenna keep-out 15mm
| 3V3 decap C5/C6/C7 hugging pad 2 |
| |
| [ J1 2x13 stacking connector ] | <- centered, vertical, top+bottom
| I2S jumper field JP1..JP6 near J1 |
| |
| [J2 prog 1x6] Q1 Q2 C3 C4 [SW1] [SW2] | <- opposite edge: prog + buttons
| (H3) [J3 HUD SH1.0 - TOP only] (H4) |
+-----------------------------------------------+
```
- **U1:** top edge, antenna overhanging, keep-out enforced.
- **J1 stacking connector:** centered on the board so the stack aligns mechanically.
Top header + mirrored bottom header on the SAME footprint position share nets
(pass-through). Verify pin 1 indexing matches between top and bottom of the stack.
- **I2S role jumpers JP1..JP6:** group as two visible columns (SINK | BROADCASTER) next
to J1, silkscreen-label each column and net.
- **Address straps R10..R17:** group as a labelled field; silkscreen "3V3 / GND" per strap.
- **Prog header J2, transistors Q1/Q2, caps C3/C4, buttons SW1/SW2:** opposite edge from
the antenna, accessible.
- **J3 HUD SH1.0:** near a board edge, TOP board assembly only.
- **Mounting holes H1..H4:** 38 x 38 mm square pattern (centered on 45x45 board => holes
at +/-19 mm from center X and Y), M3 (3.2 mm), keep clear of antenna keep-out (move the
two antenna-edge holes inward if they fall inside the 15 mm keep-out).
## 6. Design rules (JLCPCB 4-layer, standard)
- Min trace width: 0.127 mm (5 mil); use 0.2 mm default signal, 0.15 mm only in fanout.
- Min spacing: 0.127 mm (5 mil); 0.2 mm default.
- Min via: 0.3 mm drill / 0.6 mm pad; 0.2/0.4 only where needed.
- Min annular ring: 0.13 mm.
- Edge clearance: copper >= 0.3 mm from Edge.Cuts (except antenna keep-out which is larger).
- **I2S series termination:** R1/R2/R3 (33R) footprints sit inline on I2S_BCK/WS/DATA
between the jumper field and J1. Default DNP — if I2S edges ring on the scope across the
stack, populate 33R and remove the equivalent direct short. Keep I2S traces short, equal
length within the pair set, referenced to L2 GND, away from the antenna.
- **Single pull-up rule:** I2C SDA/SCL pull-ups (R4/R5) have footprints on EVERY board but
are DNP except on the SINK board, so the bus has exactly ONE pull-up pair across the stack.
- Decoupling caps: place on the same layer as U1, vias to L2/L3 directly under the cap pad.
## 7. Assembly variants (ONE board, two BOMs)
| Item / Net | SINK board | BROADCASTER board |
|-----------------------|----------------------------|----------------------------|
| I2S BCK jumper | JP1 (GPIO5) populated | JP4 (GPIO19) populated |
| I2S WS jumper | JP2 (GPIO25) populated | JP5 (GPIO18) populated |
| I2S DATA jumper | JP3 (GPIO23) populated | JP6 (GPIO22) populated |
| Opposite I2S column | JP4/JP5/JP6 DNP | JP1/JP2/JP3 DNP |
| I2C pull-up R4 (SDA) | **populate 4.7k** | DNP |
| I2C pull-up R5 (SCL) | **populate 4.7k** | DNP |
| I2S series term R1-R3 | DNP (unless ringing) | DNP (unless ringing) |
| HUD connector J3 | per mechanical (TOP only) | per mechanical (TOP only) |
| Address straps | per node address (see below)| per node address |
| Reset/boot/prog (R6-9, C1-4, Q1-2, SW1-2, J2) | populate | populate |
| Decoupling C5-C9 | populate | populate |
**Address straps (R10..R17):** for each of ADDR0..ADDR3, populate EXACTLY ONE of the pair:
- "1" / high => populate the 10k-to-3V3 resistor (R10/R12/R14/R16), leave the 0R-to-GND DNP.
- "0" / low => populate the 0R-to-GND resistor (R11/R13/R15/R17), leave the 10k DNP.
This is independent of SINK/BROADCASTER — it sets each board's bus address in the stack.
## 8. Connector pinouts (reference)
### J1 — 26-pin (2x13) stacking, 2.54 mm, pass-through (top + mirrored bottom)
| Pin | Net | Pin | Net |
|-----|------------|-----|------------|
| 1 | +5V | 2 | +5V |
| 3 | GND | 4 | GND |
| 5 | +3V3 | 6 | +3V3 |
| 7 | GND | 8 | I2S_BCK |
| 9 | GND | 10 | I2S_WS |
| 11 | GND | 12 | I2S_DATA |
| 13 | GND | 14 | GND |
| 15 | I2C_SDA | 16 | I2C_SCL |
| 17 | UART_TX | 18 | UART_RX |
| 19 | UART2_TX | 20 | UART2_RX |
| 21 | LED_DAT | 22 | LED_CLK |
| 23 | ADDR_CHAIN_IN | 24 | ADDR_CHAIN_OUT |
| 25 | SPARE | 26 | GND |
### J2 — programming 1x6, 2.54 mm
1=GND, 2=+5V, 3=RX (IO3 / GPIO3), 4=TX (IO1 / GPIO1), 5=DTR, 6=RTS
### J3 — HUD SH1.0 1x6 (TOP board only)
1=+5V (VSYS), 2=GND, 3=I2C_SDA, 4=I2C_SCL, 5=SPARE, 6=NC
(Verify physical pin order against the HUD cable before fab — see README caveat.)

View File

@ -1,84 +0,0 @@
# Resound Small Build — Carrier PCB (KiCad bootstrap)
A netlist-first hardware bootstrap for the **Resound** carrier PCB: one 45 x 45 mm
4-layer board built around an **ESP32-WROOM-32E**, with a 26-pin stacking bus, that
ships in two assembly variants — **SINK** and **BROADCASTER** — from the same layout.
## What's here
| File | Purpose |
|------|---------|
| `resound-carrier.net` | KiCad S-expression netlist: every component (ref/value/**real KiCad footprint**) and every net with pin connections. The importable artifact. |
| `BOM.csv` | Bill of materials: Ref, Qty, Value, Footprint, MPN/JLCPCB, DNP, Notes. |
| `LAYOUT.md` | Board outline, 4-layer stackup, antenna keep-out, placement plan, mounting holes, JLCPCB design rules, and the SINK vs BROADCASTER variant table. |
| `README.md` | This file. |
## Honest scope
A text agent cannot emit a guaranteed-openable `.kicad_pcb` with real geometry. So the
deliverable is a **complete, accurate, importable netlist + BOM + layout spec**. The
schematic is **not yet drawn** — this is a netlist-first workflow. An engineer imports
the netlist, lays out the board per `LAYOUT.md`, and (optionally) back-annotates a
schematic afterwards.
## How to import into KiCad (8.x)
### Option A — straight into the PCB editor (fastest, matches netlist-first)
1. New KiCad project. Open the **PCB Editor** (Pcbnew).
2. **File -> Import -> Netlist...** Select `resound-carrier.net`.
3. KiCad places all footprints (it resolves the library footprint names like
`RF_Module:ESP32-WROOM-32`, `Connector_PinHeader_2.54mm:PinHeader_2x13_P2.54mm_Vertical`,
`Resistor_SMD:R_0402_1005Metric`, `Package_TO_SOT_SMD:SOT-23`, etc. from the standard
KiCad footprint libraries — make sure those libs are installed/enabled).
4. Build the board outline on **Edge.Cuts** (45x45, rounded corners) and lay out per
`LAYOUT.md`. Add the antenna keep-out zones, GND/PWR plane pours, and mounting holes.
5. Run **DRC** with JLCPCB rules from `LAYOUT.md` section 6.
### Option B — schematic-first (if you prefer drawing the schematic)
1. Use the netlist as the connectivity spec and draw the schematic in Eeschema, matching
refs/nets exactly, then **Update PCB from Schematic** (F8). The provided `.net` is the
single source of truth for which pin connects to which net.
> Note: the `.net` uses KiCad's S-expression `(export ...)` netlist format with
> `(comp ...)` + `(net ...)` sections and per-component `(footprint ...)`. This is what
> "Import Netlist" consumes. The `(libparts)`/`(libraries)` blocks are intentionally empty
> (footprints are assigned directly on each component, which is sufficient for PCB import).
## ESP32-WROOM-32E pad mapping used
The netlist references U1 by the standard KiCad `RF_Module:ESP32-WROOM-32` footprint pad
numbers 1..38 (+ EP on pad 38). Key assignments:
- GND = pads 1, 15, 38(EP) | 3V3 = pad 2 | EN = pad 3
- I2C: SDA=GPIO32 (pad 8), SCL=GPIO33 (pad 9)
- I2S SINK: BCK=GPIO5 (pad 29), WS=GPIO25 (pad 10), DATA=GPIO23 (pad 37)
- I2S BCAST: BCK=GPIO19 (pad 31), WS=GPIO18 (pad 30), DATA=GPIO22 (pad 36)
- ADDR: GPIO13 (pad 16), GPIO14 (pad 13), GPIO27 (pad 12), GPIO26 (pad 11)
- UART bus: TX=GPIO17 (pad 28), RX=GPIO16 (pad 27)
- LED: DAT=GPIO21 (pad 33), CLK=GPIO4 (pad 26) *(reassigned — see caveats)*
- Prog UART0: RX/IO3=GPIO3 (pad 34), TX/IO1=GPIO1 (pad 35), IO0=pad 25
**Verify this pad mapping against the exact footprint variant you load** before committing
copper, since some WROOM footprint variants renumber pads.
## Known caveats / open items for the EE
1. **LED pin reuse (RESOLVE THIS).** The original spec note put the APA102 LED DATA on
GPIO13, but GPIO13 is already an **address strap** (ADDR0). To avoid the conflict the
netlist reassigns LED to spare pins: **LED_DAT = GPIO21, LED_CLK = GPIO4**. Confirm
these spares are truly free for your firmware and that GPIO4/GPIO21 have no strapping or
boot side-effects in your build. Adjust if needed — flagged intentionally.
2. **Antenna coupling across the stack.** Boards stack vertically; the WROOM antenna of a
lower board sits near the copper/components of the board above. Enforce the 15 mm
all-layer keep-out (LAYOUT.md §4) and consider stack spacing / antenna orientation so
the antenna of any board does not sit under metal of the neighbor.
3. **Verify HUD SH1.0 pin order.** J3 is keyed/genderable; confirm pin 1 of the chosen
SH1.0 connector and the HUD cable match the `1=+5V 2=GND 3=SDA 4=SCL 5=SPARE 6=NC`
order before fab. Easy to mirror.
4. **UART2 bus pins (J1 19/20)** are connector pass-through only on this rev — no MCU tap
is routed (the ESP32 second hardware UART pins are not broken out here). If a board
needs to drive UART2, the EE must route MCU spares to those connector pins.
5. **Auto-reset cross-bias.** The DTR/RTS auto-reset/boot circuit follows the NodeMCU
2-transistor pattern; the base-resistor cross-bias nets (R8_BIAS->RTS, R9_BIAS->DTR)
prevent EN+IO0 asserting simultaneously. Sanity-check against your USB-serial adapter's
DTR/RTS polarity.
6. **I2S series terminators R1/R2/R3** are DNP by default (direct connection). Populate
33R only if I2S signals ring across the stack.
## Variants in one line
SINK = I2S jumpers JP1/JP2/JP3 + I2C pull-ups R4/R5 populated. BROADCASTER = I2S jumpers
JP4/JP5/JP6, pull-ups DNP. Address straps set per board. Full table in `LAYOUT.md` §7.

View File

@ -1,387 +0,0 @@
(export (version "E")
(design
(source "resound-carrier")
(date "2026-06-11")
(tool "blue-agent netlist-first bootstrap")
(sheet (number "1") (name "/") (tstamps "/")
(title_block
(title "Resound Small Build Carrier PCB")
(company "Resound")
(rev "A")
(date "2026-06-11")
(comment (number "1") (value "ONE board, two assembly variants: SINK / BROADCASTER"))
(comment (number "2") (value "ESP32-WROOM-32E carrier with 26-pin stacking bus"))
(comment (number "3") (value "Netlist-first: schematic still to be drawn. See README.md"))
(comment (number "4") (value "CAVEAT: LED_DAT/LED_CLK pin assignment to be confirmed by EE")))))
(components
;; ---- MCU ----
(comp (ref "U1")
(value "ESP32-WROOM-32E")
(footprint "RF_Module:ESP32-WROOM-32")
(datasheet "https://www.espressif.com/sites/default/files/documentation/esp32-wroom-32e_esp32-wroom-32ue_datasheet_en.pdf")
(fields
(field (name "MPN") "ESP32-WROOM-32E")
(field (name "JLCPCB") "C701343"))
(tstamps "00000001"))
;; ---- Stacking connector (2x13 pass-through, top + bottom mirrored = same net per position) ----
(comp (ref "J1")
(value "Stack_2x13")
(footprint "Connector_PinHeader_2.54mm:PinHeader_2x13_P2.54mm_Vertical")
(fields (field (name "Notes") "2.54mm 2x13 pass-through; mirror header on bottom shares nets"))
(tstamps "00000002"))
;; ---- Programming header 1x6 ----
(comp (ref "J2")
(value "PROG_1x6")
(footprint "Connector_PinHeader_2.54mm:PinHeader_1x06_P2.54mm_Vertical")
(fields (field (name "Pinout") "1=GND 2=+5V 3=RX/IO3 4=TX/IO1 5=DTR 6=RTS"))
(tstamps "00000003"))
;; ---- HUD connector SH1.0 1x6 (TOP board only) ----
(comp (ref "J3")
(value "HUD_SH1.0_1x6")
(footprint "Connector_JST:JST_SH_BM06B-SRSS-TB_1x06-1MP_P1.00mm_Vertical")
(fields
(field (name "Pinout") "1=+5V 2=GND 3=SDA 4=SCL 5=SPARE 6=NC")
(field (name "MPN") "BM06B-SRSS-TB")
(field (name "Notes") "Populate on TOP board only"))
(tstamps "00000004"))
;; ---- Auto reset/boot transistors ----
(comp (ref "Q1") (value "MMBT3904") (footprint "Package_TO_SOT_SMD:SOT-23")
(fields (field (name "Func") "DTR->EN reset") (field (name "JLCPCB") "C20526")) (tstamps "00000005"))
(comp (ref "Q2") (value "MMBT3904") (footprint "Package_TO_SOT_SMD:SOT-23")
(fields (field (name "Func") "RTS->IO0 boot") (field (name "JLCPCB") "C20526")) (tstamps "00000006"))
;; ---- Tactile buttons ----
(comp (ref "SW1") (value "EN/RESET") (footprint "Button_Switch_SMD:SW_SPST_PTS645")
(fields (field (name "Notes") "EN to GND")) (tstamps "00000007"))
(comp (ref "SW2") (value "BOOT/IO0") (footprint "Button_Switch_SMD:SW_SPST_PTS645")
(fields (field (name "Notes") "IO0 to GND")) (tstamps "00000008"))
;; ---- I2S role jumpers (0R, two columns) ----
;; SINK column
(comp (ref "JP1") (value "0R") (footprint "Resistor_SMD:R_0603_1608Metric")
(fields (field (name "Func") "I2S_BCK = GPIO5 (SINK)")) (tstamps "00000010"))
(comp (ref "JP2") (value "0R") (footprint "Resistor_SMD:R_0603_1608Metric")
(fields (field (name "Func") "I2S_WS = GPIO25 (SINK)")) (tstamps "00000011"))
(comp (ref "JP3") (value "0R") (footprint "Resistor_SMD:R_0603_1608Metric")
(fields (field (name "Func") "I2S_DATA = GPIO23 (SINK)")) (tstamps "00000012"))
;; BROADCASTER column
(comp (ref "JP4") (value "0R") (footprint "Resistor_SMD:R_0603_1608Metric")
(fields (field (name "Func") "I2S_BCK = GPIO19 (BROADCASTER)")) (tstamps "00000013"))
(comp (ref "JP5") (value "0R") (footprint "Resistor_SMD:R_0603_1608Metric")
(fields (field (name "Func") "I2S_WS = GPIO18 (BROADCASTER)")) (tstamps "00000014"))
(comp (ref "JP6") (value "0R") (footprint "Resistor_SMD:R_0603_1608Metric")
(fields (field (name "Func") "I2S_DATA = GPIO22 (BROADCASTER)")) (tstamps "00000015"))
;; ---- I2S series termination (option) ----
(comp (ref "R1") (value "33R") (footprint "Resistor_SMD:R_0402_1005Metric")
(fields (field (name "Func") "I2S_BCK series term") (field (name "DNP") "y")) (tstamps "00000020"))
(comp (ref "R2") (value "33R") (footprint "Resistor_SMD:R_0402_1005Metric")
(fields (field (name "Func") "I2S_WS series term") (field (name "DNP") "y")) (tstamps "00000021"))
(comp (ref "R3") (value "33R") (footprint "Resistor_SMD:R_0402_1005Metric")
(fields (field (name "Func") "I2S_DATA series term") (field (name "DNP") "y")) (tstamps "00000022"))
;; ---- I2C pull-ups (on every board, DNP except SINK) ----
(comp (ref "R4") (value "4.7k") (footprint "Resistor_SMD:R_0402_1005Metric")
(fields (field (name "Func") "SDA pull-up") (field (name "DNP") "y (populate on SINK only)")) (tstamps "00000023"))
(comp (ref "R5") (value "4.7k") (footprint "Resistor_SMD:R_0402_1005Metric")
(fields (field (name "Func") "SCL pull-up") (field (name "DNP") "y (populate on SINK only)")) (tstamps "00000024"))
;; ---- Reset / boot RC + pull-ups ----
(comp (ref "R6") (value "10k") (footprint "Resistor_SMD:R_0402_1005Metric")
(fields (field (name "Func") "EN pull-up to 3V3")) (tstamps "00000025"))
(comp (ref "C1") (value "1uF") (footprint "Capacitor_SMD:C_0603_1608Metric")
(fields (field (name "Func") "EN to GND")) (tstamps "00000026"))
(comp (ref "R7") (value "10k") (footprint "Resistor_SMD:R_0402_1005Metric")
(fields (field (name "Func") "IO0 pull-up to 3V3")) (tstamps "00000027"))
(comp (ref "C2") (value "100nF") (footprint "Capacitor_SMD:C_0402_1005Metric")
(fields (field (name "Func") "IO0 to GND")) (tstamps "00000028"))
;; ---- Auto reset/boot coupling caps + base resistors ----
(comp (ref "C3") (value "100nF") (footprint "Capacitor_SMD:C_0402_1005Metric")
(fields (field (name "Func") "DTR coupling to Q1")) (tstamps "00000029"))
(comp (ref "C4") (value "100nF") (footprint "Capacitor_SMD:C_0402_1005Metric")
(fields (field (name "Func") "RTS coupling to Q2")) (tstamps "0000002A"))
(comp (ref "R8") (value "10k") (footprint "Resistor_SMD:R_0402_1005Metric")
(fields (field (name "Func") "Q1 base series")) (tstamps "0000002B"))
(comp (ref "R9") (value "10k") (footprint "Resistor_SMD:R_0402_1005Metric")
(fields (field (name "Func") "Q2 base series")) (tstamps "0000002C"))
;; ---- Address straps (GPIO13/14/27/26): 10k to 3V3 OR 0R to GND ----
(comp (ref "R10") (value "10k") (footprint "Resistor_SMD:R_0402_1005Metric")
(fields (field (name "Func") "ADDR0/GPIO13 -> 3V3 strap")) (tstamps "00000030"))
(comp (ref "R11") (value "0R") (footprint "Resistor_SMD:R_0402_1005Metric")
(fields (field (name "Func") "ADDR0/GPIO13 -> GND strap") (field (name "DNP") "y (pick one)")) (tstamps "00000031"))
(comp (ref "R12") (value "10k") (footprint "Resistor_SMD:R_0402_1005Metric")
(fields (field (name "Func") "ADDR1/GPIO14 -> 3V3 strap")) (tstamps "00000032"))
(comp (ref "R13") (value "0R") (footprint "Resistor_SMD:R_0402_1005Metric")
(fields (field (name "Func") "ADDR1/GPIO14 -> GND strap") (field (name "DNP") "y (pick one)")) (tstamps "00000033"))
(comp (ref "R14") (value "10k") (footprint "Resistor_SMD:R_0402_1005Metric")
(fields (field (name "Func") "ADDR2/GPIO27 -> 3V3 strap")) (tstamps "00000034"))
(comp (ref "R15") (value "0R") (footprint "Resistor_SMD:R_0402_1005Metric")
(fields (field (name "Func") "ADDR2/GPIO27 -> GND strap") (field (name "DNP") "y (pick one)")) (tstamps "00000035"))
(comp (ref "R16") (value "10k") (footprint "Resistor_SMD:R_0402_1005Metric")
(fields (field (name "Func") "ADDR3/GPIO26 -> 3V3 strap")) (tstamps "00000036"))
(comp (ref "R17") (value "0R") (footprint "Resistor_SMD:R_0402_1005Metric")
(fields (field (name "Func") "ADDR3/GPIO26 -> GND strap") (field (name "DNP") "y (pick one)")) (tstamps "00000037"))
;; ---- Decoupling ----
(comp (ref "C5") (value "10uF") (footprint "Capacitor_SMD:C_0805_2012Metric")
(fields (field (name "Func") "3V3 bulk at WROOM pad")) (tstamps "00000040"))
(comp (ref "C6") (value "100nF") (footprint "Capacitor_SMD:C_0402_1005Metric")
(fields (field (name "Func") "3V3 decap at WROOM pad")) (tstamps "00000041"))
(comp (ref "C7") (value "100nF") (footprint "Capacitor_SMD:C_0402_1005Metric")
(fields (field (name "Func") "3V3 decap at WROOM pad")) (tstamps "00000042"))
(comp (ref "C8") (value "10uF") (footprint "Capacitor_SMD:C_0805_2012Metric")
(fields (field (name "Func") "+5V bulk at connector")) (tstamps "00000043"))
(comp (ref "C9") (value "22uF") (footprint "Capacitor_SMD:C_0805_2012Metric")
(fields (field (name "Func") "3V3 bulk rail")) (tstamps "00000044"))
;; ---- Mounting holes (M3) ----
(comp (ref "H1") (value "MountingHole_M3") (footprint "MountingHole:MountingHole_3.2mm_M3") (tstamps "00000050"))
(comp (ref "H2") (value "MountingHole_M3") (footprint "MountingHole:MountingHole_3.2mm_M3") (tstamps "00000051"))
(comp (ref "H3") (value "MountingHole_M3") (footprint "MountingHole:MountingHole_3.2mm_M3") (tstamps "00000052"))
(comp (ref "H4") (value "MountingHole_M3") (footprint "MountingHole:MountingHole_3.2mm_M3") (tstamps "00000053")))
(libparts)
(libraries)
(nets
;; ================= POWER =================
(net (code "1") (name "+5V")
(node (ref "J1") (pin "1"))
(node (ref "J1") (pin "2"))
(node (ref "J2") (pin "2"))
(node (ref "J3") (pin "1"))
(node (ref "C8") (pin "1")))
(net (code "2") (name "+3V3")
(node (ref "U1") (pin "2"))
(node (ref "J1") (pin "5"))
(node (ref "J1") (pin "6"))
(node (ref "R4") (pin "1"))
(node (ref "R5") (pin "1"))
(node (ref "R6") (pin "1"))
(node (ref "R7") (pin "1"))
(node (ref "R10") (pin "1"))
(node (ref "R12") (pin "1"))
(node (ref "R14") (pin "1"))
(node (ref "R16") (pin "1"))
(node (ref "C5") (pin "1"))
(node (ref "C6") (pin "1"))
(node (ref "C7") (pin "1"))
(node (ref "C9") (pin "1")))
(net (code "3") (name "GND")
(node (ref "U1") (pin "1"))
(node (ref "U1") (pin "15"))
(node (ref "U1") (pin "38"))
(node (ref "J1") (pin "3"))
(node (ref "J1") (pin "4"))
(node (ref "J1") (pin "7"))
(node (ref "J1") (pin "9"))
(node (ref "J1") (pin "11"))
(node (ref "J1") (pin "13"))
(node (ref "J1") (pin "14"))
(node (ref "J1") (pin "26"))
(node (ref "J2") (pin "1"))
(node (ref "J3") (pin "2"))
(node (ref "C1") (pin "2"))
(node (ref "C2") (pin "2"))
(node (ref "C5") (pin "2"))
(node (ref "C6") (pin "2"))
(node (ref "C7") (pin "2"))
(node (ref "C8") (pin "2"))
(node (ref "C9") (pin "2"))
(node (ref "R11") (pin "2"))
(node (ref "R13") (pin "2"))
(node (ref "R15") (pin "2"))
(node (ref "R17") (pin "2"))
(node (ref "Q1") (pin "2"))
(node (ref "Q2") (pin "2"))
(node (ref "SW1") (pin "2"))
(node (ref "SW2") (pin "2"))
(node (ref "H1") (pin "1"))
(node (ref "H2") (pin "1"))
(node (ref "H3") (pin "1"))
(node (ref "H4") (pin "1")))
;; ================= RESET / BOOT =================
;; EN: WROOM pin3, pull-up R6, cap C1, button SW1, reset transistor Q1 collector
(net (code "4") (name "EN")
(node (ref "U1") (pin "3"))
(node (ref "R6") (pin "2"))
(node (ref "C1") (pin "1"))
(node (ref "SW1") (pin "1"))
(node (ref "Q1") (pin "3")))
;; IO0: WROOM pin25 (GPIO0), pull-up R7, cap C2, button SW2, boot transistor Q2 collector
(net (code "5") (name "IO0")
(node (ref "U1") (pin "25"))
(node (ref "R7") (pin "2"))
(node (ref "C2") (pin "1"))
(node (ref "SW2") (pin "1"))
(node (ref "Q2") (pin "3")))
;; ================= PROGRAMMING UART0 + AUTO RESET/BOOT =================
;; UART0 RX into ESP = GPIO3 (pad34); prog header pin3 labelled RX (host TX -> ESP RX/IO3)
(net (code "6") (name "IO3_RXD0")
(node (ref "U1") (pin "34"))
(node (ref "J2") (pin "3")))
;; UART0 TX out of ESP = GPIO1 (pad35); prog header pin4 labelled TX (ESP TX/IO1 -> host RX)
(net (code "7") (name "IO1_TXD0")
(node (ref "U1") (pin "35"))
(node (ref "J2") (pin "4")))
;; DTR -> C3 -> Q1 base via R8 (reset)
(net (code "8") (name "DTR")
(node (ref "J2") (pin "5"))
(node (ref "C3") (pin "1")))
(net (code "9") (name "Q1_BASE")
(node (ref "C3") (pin "2"))
(node (ref "R8") (pin "1"))
(node (ref "Q1") (pin "1")))
;; RTS -> C4 -> Q2 base via R9 (boot)
(net (code "10") (name "RTS")
(node (ref "J2") (pin "6"))
(node (ref "C4") (pin "1")))
(net (code "11") (name "Q2_BASE")
(node (ref "C4") (pin "2"))
(node (ref "R9") (pin "1"))
(node (ref "Q2") (pin "1")))
;; Base resistors pull the bases; classic NodeMCU auto-reset cross-couples R8 to RTS-side and
;; R9 to DTR-side so simultaneous DTR+RTS does not assert. Implemented as cross bias below.
(net (code "12") (name "R8_BIAS")
(node (ref "R8") (pin "2"))
(node (ref "J2") (pin "6")))
(net (code "13") (name "R9_BIAS")
(node (ref "R9") (pin "2"))
(node (ref "J2") (pin "5")))
;; ================= I2S ROLE JUMPER FIELD =================
;; ESP source pins feed jumper input side; jumper output side ties to the I2S bus net.
;; SINK col: GPIO5=BCK(pad29), GPIO25=WS(pad10), GPIO23=DATA(pad37)
;; BCAST col: GPIO19=BCK(pad31), GPIO18=WS(pad30), GPIO22=DATA(pad36)
(net (code "20") (name "GPIO5")
(node (ref "U1") (pin "29"))
(node (ref "JP1") (pin "1")))
(net (code "21") (name "GPIO25")
(node (ref "U1") (pin "10"))
(node (ref "JP2") (pin "1")))
(net (code "22") (name "GPIO23")
(node (ref "U1") (pin "37"))
(node (ref "JP3") (pin "1")))
(net (code "23") (name "GPIO19")
(node (ref "U1") (pin "31"))
(node (ref "JP4") (pin "1")))
(net (code "24") (name "GPIO18")
(node (ref "U1") (pin "30"))
(node (ref "JP5") (pin "1")))
(net (code "25") (name "GPIO22")
(node (ref "U1") (pin "36"))
(node (ref "JP6") (pin "1")))
;; I2S bus nets: jumper outputs from BOTH columns land on the same bus node,
;; then through optional series term R1/R2/R3 to the stacking connector.
(net (code "26") (name "I2S_BCK_PRE")
(node (ref "JP1") (pin "2"))
(node (ref "JP4") (pin "2"))
(node (ref "R1") (pin "1")))
(net (code "27") (name "I2S_BCK")
(node (ref "R1") (pin "2"))
(node (ref "J1") (pin "8")))
(net (code "28") (name "I2S_WS_PRE")
(node (ref "JP2") (pin "2"))
(node (ref "JP5") (pin "2"))
(node (ref "R2") (pin "1")))
(net (code "29") (name "I2S_WS")
(node (ref "R2") (pin "2"))
(node (ref "J1") (pin "10")))
(net (code "30") (name "I2S_DATA_PRE")
(node (ref "JP3") (pin "2"))
(node (ref "JP6") (pin "2"))
(node (ref "R3") (pin "1")))
(net (code "31") (name "I2S_DATA")
(node (ref "R3") (pin "2"))
(node (ref "J1") (pin "12")))
;; ================= I2C =================
;; GPIO32=SDA(pad8), GPIO33=SCL(pad9)
(net (code "40") (name "I2C_SDA")
(node (ref "U1") (pin "8"))
(node (ref "R4") (pin "2"))
(node (ref "J1") (pin "15"))
(node (ref "J3") (pin "3")))
(net (code "41") (name "I2C_SCL")
(node (ref "U1") (pin "9"))
(node (ref "R5") (pin "2"))
(node (ref "J1") (pin "16"))
(node (ref "J3") (pin "4")))
;; ================= UART (bus, GPIO17=TX pad28 / GPIO16=RX pad27) =================
(net (code "50") (name "UART_TX")
(node (ref "U1") (pin "28"))
(node (ref "J1") (pin "17")))
(net (code "51") (name "UART_RX")
(node (ref "U1") (pin "27"))
(node (ref "J1") (pin "18")))
;; ================= UART2 second bus pair (19,20 on connector) =================
;; No dedicated second hardware UART pins routed from MCU on this rev; bus pass-through
;; for daisy-chained boards. Left as connector-only nets (flag for EE if MCU tap needed).
(net (code "52") (name "UART2_TX")
(node (ref "J1") (pin "19")))
(net (code "53") (name "UART2_RX")
(node (ref "J1") (pin "20")))
;; ================= LED (APA102) =================
;; CAVEAT: original note said GPIO13 but GPIO13 is an ADDR strap. Reassigned to spare GPIOs:
;; LED_DAT = GPIO21 (pad33), LED_CLK = GPIO4 (pad26). EE to confirm.
(net (code "60") (name "LED_DAT")
(node (ref "U1") (pin "33"))
(node (ref "J1") (pin "21")))
(net (code "61") (name "LED_CLK")
(node (ref "U1") (pin "26"))
(node (ref "J1") (pin "22")))
;; ================= ADDRESS STRAPS =================
;; Each GPIO -> two strap pads: R(10k) to 3V3 OR R(0R) to GND. Populate exactly one.
;; ADDR0 GPIO13 = pad16
(net (code "70") (name "ADDR0_GPIO13")
(node (ref "U1") (pin "16"))
(node (ref "R10") (pin "2"))
(node (ref "R11") (pin "1")))
;; ADDR1 GPIO14 = pad13
(net (code "71") (name "ADDR1_GPIO14")
(node (ref "U1") (pin "13"))
(node (ref "R12") (pin "2"))
(node (ref "R13") (pin "1")))
;; ADDR2 GPIO27 = pad12
(net (code "72") (name "ADDR2_GPIO27")
(node (ref "U1") (pin "12"))
(node (ref "R14") (pin "2"))
(node (ref "R15") (pin "1")))
;; ADDR3 GPIO26 = pad11
(net (code "73") (name "ADDR3_GPIO26")
(node (ref "U1") (pin "11"))
(node (ref "R16") (pin "2"))
(node (ref "R17") (pin "1")))
;; ================= ADDR CHAIN (connector pass-through 23/24) =================
(net (code "74") (name "ADDR_CHAIN_IN")
(node (ref "J1") (pin "23")))
(net (code "75") (name "ADDR_CHAIN_OUT")
(node (ref "J1") (pin "24")))
;; ================= SPARE =================
(net (code "80") (name "SPARE_CONN25")
(node (ref "J1") (pin "25"))
(node (ref "J3") (pin "5")))
;; J3 pin6 = NC (HUD), intentionally unconnected
)
)

View File

@ -1,75 +0,0 @@
; BikeAudio — split-board Bluetooth audio relay.
;
; Roles (one ESP32 can't be A2DP sink+source, and a source reaches one speaker,
; so the work is split across boards sharing an I2S audio bus + an I2C control bus):
; SINK - iPhone connects here (A2DP in) -> drives the I2S bus as master
; BROADCASTER - reads I2S -> A2DP-streams to ONE speaker (one per channel)
; HUD - ESP32-S3 round touch LCD; control panel over I2C
;
; Channels are brand-agnostic: Headset / Speaker 1 / Guest (discovery picks the
; real device per channel). Build one: pio run -e sink | -e broadcaster_headset
; | -e broadcaster_speaker1 | -e broadcaster_guest | -e hud
;
; Audio boards: ESP32 core 2.0.x via espressif32 ~6.6.0, esp32dev, huge_app (BT stack).
[platformio]
default_envs = sink, broadcaster_headset, broadcaster_speaker1, broadcaster_guest
[env]
platform = espressif32 @ ~6.6.0
board = esp32dev
framework = arduino
board_build.partitions = huge_app.csv
monitor_speed = 115200
; pschatzmann libs pinned to exact commits (ESP32-A2DP 1.8.11, audio-tools 1.2.4)
lib_deps =
https://github.com/pschatzmann/ESP32-A2DP#42601717cd70d5300c9b519f3c2bf1d64d77ea2b
https://github.com/pschatzmann/arduino-audio-tools#64b64dcb9bde18a0a17766eeb6529c3a53d920a8
; --- SINK: A2DP sink (iPhone) -> I2S master ----------------------------------
[env:sink]
build_src_filter = +<sink.cpp>
; --- BROADCASTER "Headset" -> Cardo (I2C 0x11) -------------------------------
[env:broadcaster_headset]
build_src_filter = +<broadcaster.cpp>
build_flags = '-DTARGET_SPEAKER="Tangerine EDGE"' -DHUB_I2C_ADDR=0x11
; --- BROADCASTER "Speaker 1" -> JBL (I2C 0x10) -------------------------------
[env:broadcaster_speaker1]
build_src_filter = +<broadcaster.cpp>
build_flags = '-DTARGET_SPEAKER="JBL Charge 5"' -DHUB_I2C_ADDR=0x10
; --- BROADCASTER "Guest" -> antenna board, occasional passenger (I2C 0x12) ---
; No hardcoded device — pick the speaker via the HUD scan UI (discovery).
[env:broadcaster_guest]
build_src_filter = +<broadcaster.cpp>
build_flags = '-DTARGET_SPEAKER="Guest"' -DHUB_I2C_ADDR=0x12
; --- HUD: ESP32-S3-Touch-LCD-1.28 (round GC9A01 LCD + CST816S touch) ----------
; Different chip (esp32s3); LVGL UI + I2C master to the audio boards.
; NOT in default_envs — build with: pio run -e hud
[env:hud]
platform = espressif32 @ ~6.6.0
board = esp32-s3-devkitc-1
framework = arduino
board_upload.flash_size = 4MB
board_build.partitions = default.csv
monitor_speed = 115200
build_src_filter = +<hud.cpp>
lib_deps =
lovyan03/LovyanGFX@^1.1.16
lvgl/lvgl@^8.3.11
; LVGL via build flags (LV_CONF_SKIP -> defaults + overrides); no lv_conf.h.
; Keep flag values free of shell-special chars (parens/spaces). Tick via
; lv_tick_inc() in loop() (no LV_TICK_CUSTOM, which needs a parenthesised expr).
build_flags =
-DLV_CONF_SKIP=1
-DLV_COLOR_DEPTH=16
-DLV_COLOR_16_SWAP=1
-DLV_MEM_SIZE=49152
-DLV_FONT_MONTSERRAT_14=1
-DLV_FONT_MONTSERRAT_20=1
-DLV_FONT_MONTSERRAT_28=1
-DLV_FONT_MONTSERRAT_48=1
-DLV_USE_LOG=0

View File

@ -1,469 +0,0 @@
/**
* Resound Boards B & C : I2S SLAVE -> [FIFO delay] -> A2DP SOURCE
*
* Reads PCM from the shared I2S bus (clocked by Board A) into a FIFO, and an
* A2DP source drains the FIFO to one Bluetooth speaker. The FIFO sits a fixed
* jitter cushion (BASE_DELAY_MS) plus an adjustable trim behind the I2S write
* head; the trim (0..MAX_DELAY_MS) is set live BY EAR with two capacitive-touch
* pads to align this speaker against the other one, and is saved to flash.
*
* No Wi-Fi: Wi-Fi + Bluetooth + buffer don't fit in RAM on the classic ESP32.
*
* Per-env build flag: TARGET_SPEAKER ("JBL Charge 5" / "Tangerine EDGE").
*
* Wiring:
* I2S in (from Board A): BCK=GPIO19 WS=GPIO18 DATA=GPIO22 + GND
* Touch "+" : GPIO4 (T0) Touch "-" : GPIO27 (T7) (attach a wire/pad)
*/
#include <Arduino.h>
#include <string.h>
#include "AudioTools.h"
#include "BluetoothA2DPSource.h"
#include <Preferences.h>
#include <Wire.h>
#include "bus_proto.h"
#ifndef TARGET_SPEAKER
#define TARGET_SPEAKER "Resound-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
#define TOUCH_PLUS T0 // GPIO4
#define TOUCH_MINUS T7 // GPIO27
#define TOUCH_THRESH 40 // touchRead below this = touched
#define SR_HZ 44100
#define BASE_DELAY_MS 40 // fixed jitter cushion (applied to both speakers)
#define MAX_DELAY_MS 200 // adjustable trim on top of the cushion
#define DELAY_STEP_MS 5
#define TOUCH_REPEAT_MS 150
#define RING_MS (BASE_DELAY_MS + MAX_DELAY_MS + 40) // + headroom
#define RING_FRAMES ((uint32_t)SR_HZ * RING_MS / 1000)
#define BASE_FRAMES ((uint32_t)SR_HZ * BASE_DELAY_MS / 1000)
I2SStream i2s;
BluetoothA2DPSource source;
Preferences prefs;
// FIFO of interleaved L,R int16. Producer = i2s_task, consumer = A2DP callback.
static int16_t ring[RING_FRAMES * 2];
static volatile uint32_t write_frames = 0; // producer position (monotonic)
static volatile uint32_t read_frames = 0; // consumer position (monotonic)
static volatile uint32_t trim_frames = 0; // adjustable delay (frames)
static volatile bool primed = false; // FIFO has reached target fill
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
// [4]=scanning [5]=scan_count [6]=has_device (Phase 3)
static volatile uint8_t status_buf[HUB_STATUS_LEN] = {0, 0, 0, 100, 0, 0, 0};
// --- Phase 3: BT speaker discovery / selection --------------------------
// A discovered device collected during an inquiry scan.
struct ScanDev {
char name[HUB_NAME_MAX + 1];
uint8_t mac[6];
int8_t rssi;
};
static ScanDev scan_list[HUB_MAX_SCAN];
static volatile uint8_t scan_count = 0;
static volatile bool scanning = false;
// Currently selected / saved speaker.
static char cur_name[HUB_NAME_MAX + 1] = {0};
static uint8_t cur_mac[6] = {0};
static bool has_device = false;
// I2C request flags set by the Wire callbacks, applied in loop().
static volatile bool req_scan_start = false;
static volatile bool req_scan_stop = false;
static volatile bool req_select = false;
static volatile uint8_t pending_select = 0;
static volatile bool req_forget = false;
// Which read buffer the next onRequest should serve. The master sets it by
// writing the read command byte just before issuing the read. Multiplexed in
// on_i2c_request() so GET_STATUS / GET_SCANITEM / GET_CURNAME can share the bus.
static volatile uint8_t last_read_cmd = HUB_CMD_GET_STATUS;
static volatile uint8_t pending_scanitem = 0; // index requested via GET_SCANITEM
// Pre-filled in the callback (light memcpy) and handed back on the read.
static volatile uint8_t scanitem_buf[HUB_SCANITEM_LEN] = {0};
// Per-device callback fired during a scan (plain C fn ptr — no captures).
// Stash discovered audio sinks into scan_list; never connect (return false).
static bool on_ssid(const char *ssid, esp_bd_addr_t addr, int rssi) {
if (!scanning) return false;
if (scan_count >= HUB_MAX_SCAN) return false;
// Dedupe by MAC.
for (uint8_t i = 0; i < scan_count; i++) {
if (memcmp(scan_list[i].mac, addr, 6) == 0) return false;
}
ScanDev &d = scan_list[scan_count];
if (ssid) {
strncpy(d.name, ssid, HUB_NAME_MAX);
d.name[HUB_NAME_MAX] = 0;
} else {
d.name[0] = 0;
}
memcpy(d.mac, addr, 6);
if (rssi > 127) rssi = 127;
if (rssi < -128) rssi = -128;
d.rssi = (int8_t)rssi;
scan_count++;
return false; // keep scanning; collect everything
}
// Discovery state changes. The library auto-restarts discovery after STOPPED if
// nothing matched, so explicitly cancel to halt the loop.
static void on_disc_state(esp_bt_gap_discovery_state_t state) {
if (state == ESP_BT_GAP_DISCOVERY_STOPPED) {
scanning = false;
source.cancel_discovery();
}
}
// Master wrote [cmd][args...]; parse into request flags only.
static void on_i2c_receive(int n) {
if (n < 1) return;
uint8_t cmd = Wire.read();
last_read_cmd = cmd; // remember for the following onRequest (if any)
switch (cmd) {
case HUB_CMD_SET_DELAY:
if (Wire.available() >= 2) {
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_SCAN_START:
req_scan_start = true;
break;
case HUB_CMD_SCAN_STOP:
req_scan_stop = true;
break;
case HUB_CMD_SELECT:
if (Wire.available() >= 1) {
pending_select = Wire.read();
req_select = true;
}
break;
case HUB_CMD_FORGET:
req_forget = true;
break;
case HUB_CMD_GET_SCANITEM: {
// Prep scanitem_buf NOW from the list (light memcpy is OK here).
uint8_t idx = Wire.available() >= 1 ? Wire.read() : 0xFF;
pending_scanitem = idx;
uint8_t *b = (uint8_t *)scanitem_buf;
memset(b, 0, HUB_SCANITEM_LEN);
if (idx < scan_count) {
ScanDev &d = scan_list[idx];
b[0] = 1; // valid
b[1] = (uint8_t)d.rssi; // rssi (int8)
memcpy(&b[2], d.mac, 6); // MAC
uint8_t nl = (uint8_t)strnlen(d.name, HUB_NAME_MAX);
b[8] = nl; // name length
memcpy(&b[9], d.name, nl); // name bytes
}
break;
}
case HUB_CMD_GET_CURNAME:
// Nothing to do: onRequest serves cur_name (NUL-padded).
break;
case HUB_CMD_GET_STATUS:
// Nothing to do: onRequest serves the pre-filled status_buf.
break;
default:
break;
}
while (Wire.available()) Wire.read(); // drain any extra bytes
}
// Master issued a read; pick the buffer keyed by the last command byte.
static void on_i2c_request() {
if (last_read_cmd == HUB_CMD_GET_SCANITEM) {
Wire.write((uint8_t *)scanitem_buf, HUB_SCANITEM_LEN);
} else if (last_read_cmd == HUB_CMD_GET_CURNAME) {
uint8_t nbuf[HUB_NAME_MAX];
uint8_t nl = (uint8_t)strnlen(cur_name, HUB_NAME_MAX);
memcpy(nbuf, cur_name, nl);
if (nl < HUB_NAME_MAX) memset(nbuf + nl, 0, HUB_NAME_MAX - nl);
Wire.write(nbuf, HUB_NAME_MAX);
} else {
Wire.write((uint8_t *)status_buf, HUB_STATUS_LEN);
}
}
// Continuously pull I2S into the FIFO (paced by Board A's master clock).
// Carry any partial frame across reads so L/R never slips out of alignment.
static void i2s_task(void *arg) {
static uint8_t buf[1024];
static int rem = 0;
for (;;) {
size_t got = i2s.readBytes(buf + rem, sizeof(buf) - rem);
int total = rem + (int)got;
int frames = total / 4;
int16_t *s = (int16_t *)buf;
for (int i = 0; i < frames; i++) {
uint32_t w = write_frames % RING_FRAMES;
ring[w * 2] = s[i * 2];
ring[w * 2 + 1] = s[i * 2 + 1];
write_frames++;
}
rem = total - frames * 4;
if (rem > 0) memmove(buf, buf + frames * 4, rem);
if (got == 0) vTaskDelay(1); // no clock yet (Board A down) — don't spin
}
}
// A2DP drains the FIFO sequentially, kept (BASE_FRAMES + trim) behind the write head.
int32_t read_delayed(Frame *data, int32_t fc) {
uint32_t w = write_frames;
uint32_t target = BASE_FRAMES + trim_frames; // desired gap behind write head
if (!primed) {
if (w < target + (uint32_t)fc) { // not buffered enough yet -> silence
for (int32_t i = 0; i < fc; i++) { data[i].channel1 = 0; data[i].channel2 = 0; }
return fc;
}
read_frames = w - target;
primed = true;
}
uint32_t avail = w - read_frames; // frames available to read
if (avail > RING_FRAMES) { // producer lapped us (big drift) -> resync
read_frames = (w > target) ? (w - target) : 0;
avail = w - read_frames;
}
int32_t n = ((uint32_t)fc <= avail) ? fc : (int32_t)avail;
for (int32_t i = 0; i < n; i++) {
uint32_t idx = (read_frames + i) % RING_FRAMES;
data[i].channel1 = ring[idx * 2];
data[i].channel2 = ring[idx * 2 + 1];
}
read_frames += n;
for (int32_t i = n; i < fc; i++) { data[i].channel1 = 0; data[i].channel2 = 0; } // pad underrun
return fc;
}
static void set_delay(int ms) {
if (ms < 0) ms = 0;
if (ms > MAX_DELAY_MS) ms = MAX_DELAY_MS;
if ((uint16_t)ms == delay_ms_current) return;
delay_ms_current = (uint16_t)ms;
trim_frames = ((uint32_t)ms * SR_HZ) / 1000;
primed = false; // re-establish the FIFO gap at the new delay
save_pending = true;
last_change_ms = millis();
Serial.printf("[SRC %s] delay = %d ms\n", TARGET_SPEAKER, ms);
}
void on_conn_state(esp_a2d_connection_state_t state, void *obj) {
if (state == ESP_A2D_CONNECTION_STATE_CONNECTED)
Serial.printf("[SRC %s] CONNECTED\n", TARGET_SPEAKER);
else if (state == ESP_A2D_CONNECTION_STATE_DISCONNECTED)
Serial.printf("[SRC %s] disconnected — will retry\n", TARGET_SPEAKER);
}
void setup() {
Serial.begin(115200);
delay(500);
Serial.printf("=== Resound Source -> '%s' (FIFO delay, %ums cushion) ===\n",
TARGET_SPEAKER, BASE_DELAY_MS);
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;
cfg.sample_rate = SR_HZ; cfg.channels = 2; cfg.bits_per_sample = 16;
cfg.is_master = false; cfg.buffer_count = 8; cfg.buffer_size = 512;
i2s.begin(cfg);
xTaskCreatePinnedToCore(i2s_task, "i2s_reader", 4096, nullptr, 5, nullptr, 0);
source.set_data_callback_in_frames(read_delayed);
source.set_on_connection_state_changed(on_conn_state);
source.set_volume(current_volume);
// Load any saved/selected speaker (Phase 3). "spk_mac" is a 6-byte blob.
uint8_t savedMac[6];
size_t got = prefs.getBytes("spk_mac", savedMac, 6);
if (got == 6) {
memcpy(cur_mac, savedMac, 6);
String sn = prefs.getString("spk_name", "");
strncpy(cur_name, sn.c_str(), HUB_NAME_MAX);
cur_name[HUB_NAME_MAX] = 0;
has_device = true;
source.set_auto_reconnect(cur_mac); // remember + auto-reconnect to it
source.start();
Serial.printf("[SRC] Reconnecting to saved '%s' (%02X:%02X:%02X:%02X:%02X:%02X) — trim %u ms\n",
cur_name, cur_mac[0], cur_mac[1], cur_mac[2],
cur_mac[3], cur_mac[4], cur_mac[5], delay_ms_current);
} else {
// No saved device — fall back to the hardcoded target by name.
has_device = false;
cur_name[0] = 0;
source.set_auto_reconnect(true, 5);
source.start(TARGET_SPEAKER);
Serial.printf("[SRC] Connecting to fallback '%s' — trim %u ms; touch + GPIO4, - GPIO27\n",
TARGET_SPEAKER, delay_ms_current);
}
}
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);
}
}
// --- Phase 3: apply discovery / selection requests (heavy work here) ---
if (req_scan_start) {
req_scan_start = false;
scan_count = 0;
memset(scan_list, 0, sizeof(scan_list));
scanning = true;
source.disconnect();
source.set_auto_reconnect(false); // force a fresh discovery
source.set_ssid_callback(on_ssid);
source.set_discovery_mode_callback(on_disc_state);
source.start(); // begin inquiry scan
Serial.printf("[SRC %s] scan started\n", TARGET_SPEAKER);
}
if (req_scan_stop) {
req_scan_stop = false;
source.cancel_discovery();
scanning = false;
Serial.printf("[SRC %s] scan stopped (%u found)\n", TARGET_SPEAKER, scan_count);
}
if (req_select) {
req_select = false;
uint8_t idx = pending_select;
if (idx < scan_count) {
source.cancel_discovery();
scanning = false;
memcpy(cur_mac, scan_list[idx].mac, 6);
strncpy(cur_name, scan_list[idx].name, HUB_NAME_MAX);
cur_name[HUB_NAME_MAX] = 0;
source.connect_to(cur_mac);
source.set_auto_reconnect(cur_mac);
prefs.putBytes("spk_mac", cur_mac, 6);
prefs.putString("spk_name", cur_name);
has_device = true;
Serial.printf("[SRC %s] selected '%s' (%02X:%02X:%02X:%02X:%02X:%02X)\n",
TARGET_SPEAKER, cur_name, cur_mac[0], cur_mac[1], cur_mac[2],
cur_mac[3], cur_mac[4], cur_mac[5]);
}
}
if (req_forget) {
req_forget = false;
source.disconnect();
prefs.remove("spk_mac");
prefs.remove("spk_name");
has_device = false;
cur_name[0] = 0;
memset(cur_mac, 0, 6);
Serial.printf("[SRC %s] forgot saved device\n", TARGET_SPEAKER);
}
// Refresh the status snapshot served on GET_STATUS reads.
status_buf[0] = source.is_connected() ? 1 : 0;
status_buf[1] = (uint8_t)(delay_ms_current & 0xFF);
status_buf[2] = (uint8_t)(delay_ms_current >> 8);
status_buf[3] = current_volume;
status_buf[4] = scanning ? 1 : 0;
status_buf[5] = scan_count;
status_buf[6] = has_device ? 1 : 0;
static unsigned long last_touch = 0;
if (now - last_touch >= TOUCH_REPEAT_MS) {
bool plus = touchRead(TOUCH_PLUS) < TOUCH_THRESH;
bool minus = touchRead(TOUCH_MINUS) < TOUCH_THRESH;
if (plus && !minus) { set_delay(delay_ms_current + DELAY_STEP_MS); last_touch = now; }
else if (minus && !plus) { set_delay(delay_ms_current - DELAY_STEP_MS); last_touch = now; }
}
if (save_pending && now - last_change_ms > 1500) {
prefs.putUShort("delay_ms", delay_ms_current);
save_pending = false;
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",
TARGET_SPEAKER, source.is_connected() ? "YES" : "no", delay_ms_current,
(unsigned)(write_frames - read_frames), ESP.getFreeHeap());
last_st = now;
}
delay(20);
}

View File

@ -1,53 +0,0 @@
/**
* Resound 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 <stdint.h>
#define HUB_I2C_ADDR_JBL 0x10
#define HUB_I2C_ADDR_CARDO 0x11
#define HUB_I2C_ADDR_GUEST 0x12 // antenna board: source for an occasional guest speaker
// 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)
// [4] scanning (0/1) (Phase 3)
// [5] scan device count (Phase 3)
// [6] has a saved/selected device (0/1) (Phase 3)
#define HUB_STATUS_LEN 7
// --- Phase 3: speaker discovery / selection -------------------------------
// The source board runs a BT inquiry scan (audio pauses during a scan),
// collects nearby audio sinks (name+MAC), and connects to a chosen one.
#define HUB_CMD_SCAN_START 0x03 // write: clear list + begin a fresh discovery scan
#define HUB_CMD_SCAN_STOP 0x04 // write: cancel scanning
#define HUB_CMD_SELECT 0x05 // write [uint8 index]: connect to scan item + persist (NVS)
#define HUB_CMD_FORGET 0x06 // write: forget saved device (disconnect + erase)
#define HUB_CMD_GET_SCANITEM 0x14 // write [uint8 index]; THEN read HUB_SCANITEM_LEN bytes
#define HUB_CMD_GET_CURNAME 0x15 // read HUB_NAME_MAX bytes: current/selected device name (NUL-padded)
#define HUB_MAX_SCAN 12 // max devices reported
#define HUB_NAME_MAX 24 // max device-name length carried over the bus
// Scan-item payload (fixed length), returned on the read after GET_SCANITEM[index]:
// [0] valid (0/1 — 0 if index >= count)
// [1] rssi (int8)
// [2..7] MAC (6 bytes)
// [8] name length (<= HUB_NAME_MAX)
// [9 ..] name bytes
#define HUB_SCANITEM_LEN (9 + HUB_NAME_MAX) // 33

View File

@ -1,929 +0,0 @@
/**
* 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);
}

View File

@ -1,96 +0,0 @@
/**
* Resound Board A : A2DP SINK -> I2S MASTER
*
* Part of the 3-board relay. The iPhone connects to this board over Bluetooth
* (A2DP name "Resound"). This board decodes the audio to PCM and clocks it
* out on a shared I2S bus as the MASTER. Boards B and C (A2DP sources) listen
* to this same bus as slaves and stream it to the JBL / Cardo speakers.
*
* iPhone ))BT)) [Board A: sink] ==I2S==> [Board B: src] ))BT)) JBL
* \====> [Board C: src] ))BT)) Cardo
*
* Why a separate board: one ESP32 cannot be an A2DP sink and source at once
* (single Bluedroid A2DP role), and an A2DP source can hold only one outgoing
* link so the sink and each speaker need their own chip. See README.
*
* I2S OUTPUT pins (this board DRIVES the bus wire these to B and C):
* BCK = GPIO5 WS/LRCK = GPIO25 DATA(out) = GPIO23 + common GND
*
* Build: pio run -e sink (compiled via build_src_filter in platformio.ini)
*/
#include <Arduino.h>
#include "AudioTools.h"
#include "BluetoothA2DPSink.h"
#define I2S_BCK_PIN 5
#define I2S_WS_PIN 25
#define I2S_DATA_PIN 23
I2SStream i2s;
BluetoothA2DPSink sink;
static uint16_t current_sample_rate = 0;
// Configure / reconfigure the I2S bus as master TX at the given rate.
static void start_i2s(uint16_t rate) {
if (rate == 0) rate = 44100; // SBC default before negotiation
auto cfg = i2s.defaultConfig(TX_MODE);
cfg.pin_bck = I2S_BCK_PIN;
cfg.pin_ws = I2S_WS_PIN;
cfg.pin_data = I2S_DATA_PIN;
cfg.sample_rate = rate;
cfg.channels = 2;
cfg.bits_per_sample = 16;
cfg.is_master = true; // Board A clocks the whole bus
cfg.buffer_count = 8;
cfg.buffer_size = 512;
i2s.begin(cfg);
current_sample_rate = rate;
Serial.printf("[SINK] I2S master @ %u Hz / 16-bit / stereo\n", rate);
}
// Called from the BT task with decoded PCM. Keep it cheap — just push to I2S.
void write_pcm_to_i2s(const uint8_t *data, uint32_t len) {
i2s.write(data, len);
}
void on_conn_state(esp_a2d_connection_state_t state, void *obj) {
if (state == ESP_A2D_CONNECTION_STATE_CONNECTED) Serial.println("[SINK] iPhone CONNECTED");
else if (state == ESP_A2D_CONNECTION_STATE_DISCONNECTED) Serial.println("[SINK] iPhone disconnected");
}
void setup() {
Serial.begin(115200);
delay(500);
Serial.println("=== Resound Board A — A2DP SINK -> I2S master ===");
start_i2s(44100);
// false => the sink does NOT run its own I2S; we forward PCM ourselves.
sink.set_stream_reader(write_pcm_to_i2s, false);
sink.set_on_connection_state_changed(on_conn_state);
sink.set_auto_reconnect(true);
sink.start("Resound");
Serial.println("[SINK] Advertising 'Resound' — connect from iPhone");
}
void loop() {
// Follow the negotiated sample rate (iPhone usually 44100; reconfigure if not).
uint16_t sr = sink.sample_rate();
if (sr != 0 && sr != current_sample_rate) {
Serial.printf("[SINK] sample rate changed %u -> %u, reconfiguring I2S\n",
current_sample_rate, sr);
start_i2s(sr);
}
static unsigned long last = 0;
if (millis() - last > 5000) {
Serial.printf("[SINK] iPhone=%s heap=%u\n",
sink.is_connected() ? "YES" : "no", ESP.getFreeHeap());
last = millis();
}
delay(100);
}