Compare commits
12 Commits
main
...
blue/s3-hu
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c6e7e8762 | |||
| b5d1169392 | |||
| dca7d3ba46 | |||
| de61905e9c | |||
| 4b432c6123 | |||
| 9ed1899285 | |||
| 0b1c34074f | |||
| 66f56f1e09 | |||
| 2a34ed5abe | |||
| 0b474b172b | |||
| baa3ef7690 | |||
| 04e7f20430 |
252
BikeAudio.ino
252
BikeAudio.ino
@ -1,252 +0,0 @@
|
||||
/**
|
||||
* 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("--------------");
|
||||
}
|
||||
103
README_RELAY.md
Normal file
103
README_RELAY.md
Normal file
@ -0,0 +1,103 @@
|
||||
# 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** (0–200 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.
|
||||
45
hardware/carrier/BOM.csv
Normal file
45
hardware/carrier/BOM.csv
Normal file
@ -0,0 +1,45 @@
|
||||
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.
|
129
hardware/carrier/LAYOUT.md
Normal file
129
hardware/carrier/LAYOUT.md
Normal file
@ -0,0 +1,129 @@
|
||||
# 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.)
|
||||
84
hardware/carrier/README.md
Normal file
84
hardware/carrier/README.md
Normal file
@ -0,0 +1,84 @@
|
||||
# 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.
|
||||
387
hardware/carrier/resound-carrier.net
Normal file
387
hardware/carrier/resound-carrier.net
Normal file
@ -0,0 +1,387 @@
|
||||
(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
|
||||
)
|
||||
)
|
||||
75
platformio.ini
Normal file
75
platformio.ini
Normal file
@ -0,0 +1,75 @@
|
||||
; 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
|
||||
469
src/broadcaster.cpp
Normal file
469
src/broadcaster.cpp
Normal file
@ -0,0 +1,469 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
53
src/bus_proto.h
Normal file
53
src/bus_proto.h
Normal file
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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
|
||||
929
src/hud.cpp
Normal file
929
src/hud.cpp
Normal file
@ -0,0 +1,929 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
96
src/sink.cpp
Normal file
96
src/sink.cpp
Normal file
@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user