1929 lines
72 KiB
Arduino
1929 lines
72 KiB
Arduino
/*
|
||
* Baby Mobile Audio Board v2 — nRF52840 Firmware
|
||
*
|
||
* Platform: Seeed XIAO nRF52840 (or MDBT50Q module)
|
||
* Framework: Adafruit nRF52 Arduino Core
|
||
*
|
||
* Features:
|
||
* - USB Mass Storage: drag-and-drop .raw audio files
|
||
* - BLE: wireless audio upload from phone/Web Bluetooth
|
||
* - 8-bit 8kHz PWM audio playback from IS25LP128F SPI flash
|
||
* - DC motor PWM speed control
|
||
* - 8 button inputs with debounce
|
||
* - Deep sleep with button wake (~10µA)
|
||
* - Auto-shutoff timer
|
||
*
|
||
* Audio format: WAV (8 or 16-bit PCM, 8/16/32 kHz, mono) stored in LittleFS.
|
||
* Upload via upload_track.py — ffmpeg handles any source format.
|
||
*
|
||
* Board setup in Arduino IDE:
|
||
* Board: "Seeed XIAO nRF52840" (or "Adafruit Feather nRF52840")
|
||
* Install: Seeed nRF52 Boards via Board Manager
|
||
*
|
||
* Wiring (XIAO pin names):
|
||
* D0 (P0.02) - Button 1
|
||
* D1 (P0.03) - Button 2
|
||
* D2 (P0.28) - Button 3
|
||
* D3 (P0.29) - Button 4
|
||
* D4 (P0.04) - Button 5
|
||
* D5 (P0.05) - Button 6
|
||
* D6 (P1.11) - Button 7
|
||
* D7 (P1.12) - Button 8
|
||
* D4 (P0.04) - Flash ~CS (PIN_FLASH_CS)
|
||
* D8 (P0.07) - SPI SCK → Flash CLK
|
||
* D9 (P0.06) - SPI MISO → Flash DO
|
||
* D10 (P0.05) - SPI MOSI → Flash DI
|
||
* A0 (P0.02) - Audio PWM output → R+C LPF → PAM8302A
|
||
* A1 (P0.03) - Amp ~SD (HIGH=on)
|
||
* A2 (P0.28) - Motor PWM → MOSFET gate
|
||
* A3 (P0.29) - LED output
|
||
* A4 (P0.04) - Flash ~CS
|
||
* A5 (P0.05) - (spare)
|
||
*
|
||
* NOTE: Pin assignments above are illustrative. Adjust to your actual
|
||
* XIAO pinout and PCB routing. The XIAO has 11 usable GPIOs on the
|
||
* edge pins plus additional pads on the bottom.
|
||
*/
|
||
|
||
#include <SPI.h>
|
||
#include <Adafruit_TinyUSB.h>
|
||
#include <bluefruit.h>
|
||
|
||
// ============================================================
|
||
// When defined, a wake from deep sleep (button press) plays a random track
|
||
// immediately instead of waiting for user input.
|
||
#define WAKE_PLAY_RANDOM
|
||
|
||
// ============================================================
|
||
// Button playback mode:
|
||
// Undefined — short press toggles play/stop (press again to stop).
|
||
// Defined — every press always starts a track; never stops mid-play.
|
||
// If WAKE_PLAY_RANDOM is also defined, picks a random track;
|
||
// otherwise advances to the next track (wrapping around).
|
||
#define BTN_ALWAYS_START
|
||
|
||
// ============================================================
|
||
// POC MODE: stream audio from internal LittleFS instead of SPI flash.
|
||
// Upload tracks via upload_track.py (serial) or BLE (CMD 0x02).
|
||
// WAV files are stored intact; header is parsed at load time for
|
||
// dynamic bit-depth (8/16-bit) and sample-rate support.
|
||
// Disable this define to revert to full SPI flash mode.
|
||
// ============================================================
|
||
//#define POC_INTERNAL_FLASH
|
||
|
||
#ifdef POC_INTERNAL_FLASH
|
||
#include <Adafruit_LittleFS.h>
|
||
#include <InternalFileSystem.h>
|
||
using namespace Adafruit_LittleFS_Namespace;
|
||
#endif
|
||
|
||
// ============================================================
|
||
// PIN DEFINITIONS — adjust for your PCB
|
||
// ============================================================
|
||
|
||
// Using raw nRF52840 GPIO numbers for flexibility.
|
||
// Map these to your actual PCB connections.
|
||
|
||
// Audio
|
||
#define PIN_AUDIO_PWM 0 // Any PWM-capable pin
|
||
#define PIN_AMP_SD 6 // PAM8302A shutdown (HIGH=enabled)
|
||
|
||
// Motor
|
||
#define PIN_MOTOR_PWM 15 // PWM to MOSFET gate
|
||
|
||
// SPI Flash
|
||
#define PIN_FLASH_CS 4 // Flash chip select
|
||
// SPI MOSI/MISO/SCK use default SPI pins (8/9/10?)
|
||
|
||
// Buttons (directly to GPIO, active LOW with internal pull-up)
|
||
#define PIN_BTN1 1
|
||
/*
|
||
#define PIN_BTN2 1
|
||
#define PIN_BTN3 1
|
||
#define PIN_BTN4 1
|
||
#define PIN_BTN5 4
|
||
#define PIN_BTN6 5
|
||
#define PIN_BTN7 3
|
||
#define PIN_BTN8 4
|
||
*/
|
||
|
||
// PIN_LED is 11 (LED_R)
|
||
// LED_B is 13/12
|
||
// LED_G is 12/13
|
||
|
||
const uint8_t BTN_PINS[] = {
|
||
PIN_BTN1 /*, PIN_BTN2, PIN_BTN3, PIN_BTN4,*/
|
||
/*PIN_BTN5, PIN_BTN6, PIN_BTN7, PIN_BTN8*/
|
||
};
|
||
#define NUM_BUTTONS 1
|
||
|
||
// ============================================================
|
||
// FLASH CONSTANTS
|
||
// ============================================================
|
||
|
||
#define FLASH_SIZE (16UL * 1024UL * 1024UL) // 16MB
|
||
#define FLASH_SECTOR 4096
|
||
#define FLASH_PAGE 256
|
||
#define FLASH_BLOCK_64K (64UL * 1024UL)
|
||
|
||
// IS25LP128F / W25Q128 compatible commands
|
||
#define CMD_READ 0x03
|
||
#define CMD_FAST_READ 0x0B
|
||
#define CMD_WRITE_EN 0x06
|
||
#define CMD_PAGE_PROG 0x02
|
||
#define CMD_SECT_ERASE 0x20
|
||
#define CMD_BLOCK_ERASE 0xD8
|
||
#define CMD_CHIP_ERASE 0xC7
|
||
#define CMD_READ_SR1 0x05
|
||
#define CMD_JEDEC_ID 0x9F
|
||
#define CMD_POWER_DOWN 0xB9
|
||
#define CMD_WAKE 0xAB
|
||
|
||
// ============================================================
|
||
// TRACK TABLE FORMAT
|
||
// ============================================================
|
||
// Sector 0 (first 4KB) holds the track index:
|
||
// [0] uint8_t magic = 0xBB (indicates valid table)
|
||
// [1] uint8_t num_tracks
|
||
// [2..3] reserved
|
||
// [4..] Track entries, 12 bytes each:
|
||
// uint32_t start_addr (big-endian)
|
||
// uint32_t byte_length (big-endian)
|
||
// uint8_t sample_rate_khz (8 = 8kHz)
|
||
// uint8_t bits (8 = 8-bit unsigned)
|
||
// uint16_t reserved
|
||
// Audio data starts at AUDIO_START (sector 1 = 0x1000)
|
||
|
||
#define TRACK_TABLE_ADDR 0x000000
|
||
#define AUDIO_START_ADDR 0x001000
|
||
#define TABLE_MAGIC 0xBB
|
||
#define MAX_TRACKS 32
|
||
#define TRACK_ENTRY_SIZE 12
|
||
|
||
// ============================================================
|
||
// AUDIO CONFIG
|
||
// ============================================================
|
||
|
||
#define SAMPLE_RATE 16000
|
||
#define AUDIO_BUF_SIZE 512 // larger buffer = fewer SPI transactions
|
||
#define SILENCE 128 // 0x80 = center for unsigned 8-bit
|
||
|
||
// ============================================================
|
||
// USB MASS STORAGE
|
||
// ============================================================
|
||
|
||
Adafruit_USBD_MSC usb_msc;
|
||
|
||
// Simple FAT12 filesystem on the SPI flash
|
||
// We present the entire 16MB flash as a USB drive.
|
||
// The host can write raw files; firmware scans for audio on boot.
|
||
|
||
// MSC callbacks
|
||
int32_t msc_read_cb(uint32_t lba, void* buffer, uint32_t bufsize);
|
||
int32_t msc_write_cb(uint32_t lba, uint8_t* buffer, uint32_t bufsize);
|
||
void msc_flush_cb(void);
|
||
bool msc_start_stop_cb(uint8_t power_condition, bool start, bool load_eject);
|
||
|
||
#define MSC_BLOCK_SIZE 512
|
||
#define MSC_BLOCK_COUNT (FLASH_SIZE / MSC_BLOCK_SIZE)
|
||
|
||
// ============================================================
|
||
// BLE SERVICE
|
||
// ============================================================
|
||
|
||
BLEService audioSvc = BLEService("12340001-0000-1000-8000-00805f9b34fb");
|
||
BLECharacteristic audioCmd = BLECharacteristic("12340002-0000-1000-8000-00805f9b34fb");
|
||
BLECharacteristic audioData = BLECharacteristic("12340003-0000-1000-8000-00805f9b34fb");
|
||
BLECharacteristic audioStat = BLECharacteristic("12340004-0000-1000-8000-00805f9b34fb");
|
||
|
||
// BLE connection / upload state
|
||
volatile bool g_bleConnected = false; // true while a GATT connection is active
|
||
volatile bool g_bleUploading = false;
|
||
volatile uint32_t g_bleWriteLen = 0;
|
||
|
||
#ifdef POC_INTERNAL_FLASH
|
||
File g_pocFile(InternalFS); // open file handle (read or write)
|
||
uint8_t g_pocWriteTrack = 0; // track slot being written via BLE
|
||
#else
|
||
// Ring buffer between BLE callbacks and loop() flash writes.
|
||
// Callbacks return immediately; flash I/O happens in loop() so the SoftDevice
|
||
// receive buffer never stalls. Sector erase is async (non-blocking): bleFlashTick()
|
||
// kicks off the erase and returns; loop() polls completion each iteration. This
|
||
// prevents the ring buffer from filling during the ~30–300 ms erase window.
|
||
// Buffer: 64 KB handles worst-case 300 ms erase at up to ~200 KB/s BLE throughput.
|
||
#define BLE_FLASH_BUF 65536u
|
||
static uint8_t g_bleBuf[BLE_FLASH_BUF];
|
||
static volatile uint32_t g_bleBufHead = 0; // advanced by BLE callback
|
||
static volatile uint32_t g_bleBufTail = 0; // advanced by loop()
|
||
static uint32_t g_bleFlashAddr = 0; // current flash write head
|
||
static uint32_t g_bleFlashStart = 0; // address where this upload began
|
||
static uint32_t g_bleFlashErased = 0; // upper boundary of erased flash
|
||
static bool g_bleErasing = false; // async sector erase in progress
|
||
static uint32_t g_bleNotifyThresh = 0; // next addr to send progress notify
|
||
static uint8_t g_bleWriteTrack = 0;
|
||
static volatile bool g_bleFinalizing = false;
|
||
|
||
// Shared next-free flash pointer — used by both BLE and serial upload paths.
|
||
// Reset to AUDIO_START_ADDR whenever track 0 is uploaded.
|
||
static uint32_t g_flashNextFree = AUDIO_START_ADDR;
|
||
#endif
|
||
|
||
// ============================================================
|
||
// GLOBAL STATE
|
||
// ============================================================
|
||
|
||
volatile bool g_playing = false;
|
||
volatile bool g_motorOn = false;
|
||
uint8_t g_motorSpeed = 160;
|
||
|
||
uint8_t g_numTracks = 0;
|
||
uint32_t g_trackStart[MAX_TRACKS];
|
||
uint32_t g_trackLen[MAX_TRACKS]; // total file size in bytes (including WAV header)
|
||
uint8_t g_trackBits[MAX_TRACKS]; // bits per sample: 8 or 16
|
||
uint32_t g_trackRate[MAX_TRACKS]; // sample rate in Hz
|
||
uint32_t g_trackDataOff[MAX_TRACKS]; // byte offset of audio data within file (44 for WAV, 0 for raw)
|
||
uint8_t g_currentTrack = 0;
|
||
bool g_loopTracks = false; // false = play once and stop; true = auto-advance through all tracks
|
||
|
||
// Audio DMA buffers — uint16_t PWM duty-cycle values for EasyDMA
|
||
static uint16_t g_pwmBuf[2][AUDIO_BUF_SIZE];
|
||
volatile bool g_bufReady[2] = {false, false};
|
||
uint32_t g_trackDoneMs = 0; // nonzero = millis() deadline after which we stop
|
||
uint32_t g_nextReadAddr = 0;
|
||
uint32_t g_playEnd = 0;
|
||
|
||
// Timers
|
||
unsigned long g_lastActivity = 0;
|
||
#define AUTO_OFF_MS (30UL * 60UL * 1000UL) // 30 min auto-shutoff
|
||
#define IDLE_SLEEP_MS (10UL * 60UL * 1000UL) // 10 min idle → sleep
|
||
|
||
// USB connected flag
|
||
volatile bool g_usbConnected = false;
|
||
|
||
// Wake-from-sleep detection. Read before SoftDevice starts (setupBLE), stored here.
|
||
static bool g_wakeFromSleep = false;
|
||
static uint8_t g_wakeTrack = 0;
|
||
static uint32_t g_debugResetreas = 0; // printed 10 s after boot for post-sleep diagnosis
|
||
|
||
#ifdef POC_INTERNAL_FLASH
|
||
static void pocFilename(uint8_t n, char *buf) { // buf must be >=16 bytes
|
||
snprintf(buf, 16, "/track%d.wav", n);
|
||
}
|
||
#endif
|
||
|
||
// ============================================================
|
||
// SPI FLASH DRIVER
|
||
// ============================================================
|
||
|
||
SPISettings flashSPI(8000000, MSBFIRST, SPI_MODE0); // 8MHz
|
||
|
||
void flashSelect() { digitalWrite(PIN_FLASH_CS, LOW); }
|
||
void flashDeselect() { digitalWrite(PIN_FLASH_CS, HIGH); }
|
||
|
||
void flashWake() {
|
||
flashSelect();
|
||
SPI.transfer(CMD_WAKE);
|
||
flashDeselect();
|
||
delayMicroseconds(5);
|
||
}
|
||
|
||
void flashSleep() {
|
||
flashSelect();
|
||
SPI.transfer(CMD_POWER_DOWN);
|
||
flashDeselect();
|
||
}
|
||
|
||
uint32_t flashReadJEDEC() {
|
||
SPI.beginTransaction(flashSPI);
|
||
flashSelect();
|
||
SPI.transfer(CMD_JEDEC_ID);
|
||
uint32_t id = (uint32_t)SPI.transfer(0) << 16;
|
||
id |= (uint32_t)SPI.transfer(0) << 8;
|
||
id |= SPI.transfer(0);
|
||
flashDeselect();
|
||
SPI.endTransaction();
|
||
return id;
|
||
}
|
||
|
||
void flashReadBytes(uint32_t addr, uint8_t *buf, uint32_t len) {
|
||
SPI.beginTransaction(flashSPI);
|
||
flashSelect();
|
||
SPI.transfer(CMD_READ);
|
||
SPI.transfer((addr >> 16) & 0xFF);
|
||
SPI.transfer((addr >> 8) & 0xFF);
|
||
SPI.transfer(addr & 0xFF);
|
||
for (uint32_t i = 0; i < len; i++) {
|
||
buf[i] = SPI.transfer(0);
|
||
}
|
||
flashDeselect();
|
||
SPI.endTransaction();
|
||
}
|
||
|
||
void flashWaitBusy() {
|
||
SPI.beginTransaction(flashSPI);
|
||
flashSelect();
|
||
SPI.transfer(CMD_READ_SR1);
|
||
while (SPI.transfer(0) & 0x01) { /* spin */ }
|
||
flashDeselect();
|
||
SPI.endTransaction();
|
||
}
|
||
|
||
// Non-blocking busy check: reads the WIP bit without spinning.
|
||
bool flashIsBusy() {
|
||
SPI.beginTransaction(flashSPI);
|
||
flashSelect();
|
||
SPI.transfer(CMD_READ_SR1);
|
||
uint8_t sr = SPI.transfer(0);
|
||
flashDeselect();
|
||
SPI.endTransaction();
|
||
return (sr & 0x01) != 0;
|
||
}
|
||
|
||
void flashWriteEnable() {
|
||
SPI.beginTransaction(flashSPI);
|
||
flashSelect();
|
||
SPI.transfer(CMD_WRITE_EN);
|
||
flashDeselect();
|
||
SPI.endTransaction();
|
||
}
|
||
|
||
void flashEraseSector(uint32_t addr) {
|
||
flashWriteEnable();
|
||
SPI.beginTransaction(flashSPI);
|
||
flashSelect();
|
||
SPI.transfer(CMD_SECT_ERASE);
|
||
SPI.transfer((addr >> 16) & 0xFF);
|
||
SPI.transfer((addr >> 8) & 0xFF);
|
||
SPI.transfer(addr & 0xFF);
|
||
flashDeselect();
|
||
SPI.endTransaction();
|
||
flashWaitBusy();
|
||
}
|
||
|
||
void flashEraseBlock64K(uint32_t addr) {
|
||
flashWriteEnable();
|
||
SPI.beginTransaction(flashSPI);
|
||
flashSelect();
|
||
SPI.transfer(CMD_BLOCK_ERASE);
|
||
SPI.transfer((addr >> 16) & 0xFF);
|
||
SPI.transfer((addr >> 8) & 0xFF);
|
||
SPI.transfer(addr & 0xFF);
|
||
flashDeselect();
|
||
SPI.endTransaction();
|
||
flashWaitBusy();
|
||
}
|
||
|
||
void flashPageProgram(uint32_t addr, const uint8_t *data, uint16_t len) {
|
||
flashWriteEnable();
|
||
SPI.beginTransaction(flashSPI);
|
||
flashSelect();
|
||
SPI.transfer(CMD_PAGE_PROG);
|
||
SPI.transfer((addr >> 16) & 0xFF);
|
||
SPI.transfer((addr >> 8) & 0xFF);
|
||
SPI.transfer(addr & 0xFF);
|
||
for (uint16_t i = 0; i < len; i++) {
|
||
SPI.transfer(data[i]);
|
||
}
|
||
flashDeselect();
|
||
SPI.endTransaction();
|
||
flashWaitBusy();
|
||
}
|
||
|
||
|
||
// ============================================================
|
||
// TRACK TABLE
|
||
// ============================================================
|
||
|
||
void loadTrackTable() {
|
||
#ifdef POC_INTERNAL_FLASH
|
||
g_numTracks = 0;
|
||
char fname[16];
|
||
for (uint8_t i = 0; i < MAX_TRACKS; i++) {
|
||
pocFilename(i, fname);
|
||
File f(InternalFS);
|
||
if (!f.open(fname, FILE_O_READ)) break;
|
||
g_trackLen[i] = f.size();
|
||
g_trackBits[i] = 8;
|
||
g_trackRate[i] = SAMPLE_RATE;
|
||
g_trackDataOff[i] = 0;
|
||
|
||
// Parse WAV header to extract format metadata
|
||
if (g_trackLen[i] >= 44) {
|
||
uint8_t hdr[44];
|
||
f.seek(0);
|
||
f.read(hdr, 44);
|
||
if (hdr[0]=='R' && hdr[1]=='I' && hdr[2]=='F' && hdr[3]=='F') {
|
||
uint16_t bits = (uint16_t)hdr[34] | ((uint16_t)hdr[35] << 8);
|
||
uint32_t rate = (uint32_t)hdr[24] | ((uint32_t)hdr[25] << 8)
|
||
| ((uint32_t)hdr[26] << 16) | ((uint32_t)hdr[27] << 24);
|
||
if ((bits == 8 || bits == 16) && rate > 0) {
|
||
g_trackBits[i] = (uint8_t)bits;
|
||
g_trackRate[i] = rate;
|
||
g_trackDataOff[i] = 44;
|
||
}
|
||
}
|
||
}
|
||
f.close();
|
||
|
||
if (g_trackLen[i] == 0) break; // stop at first empty file
|
||
g_numTracks = i + 1;
|
||
|
||
Serial.print(" track"); Serial.print(i);
|
||
Serial.print(": "); Serial.print(g_trackBits[i]); Serial.print("bit ");
|
||
Serial.print(g_trackRate[i] / 1000); Serial.print("kHz ");
|
||
uint32_t audioBytes = g_trackLen[i] - g_trackDataOff[i];
|
||
uint32_t bytesPerSample = g_trackBits[i] / 8;
|
||
Serial.print((audioBytes / bytesPerSample) / g_trackRate[i]);
|
||
Serial.println("s");
|
||
}
|
||
Serial.print("Found ");
|
||
Serial.print(g_numTracks);
|
||
Serial.println(" tracks in internal flash");
|
||
#else
|
||
uint8_t header[4];
|
||
flashReadBytes(TRACK_TABLE_ADDR, header, 4);
|
||
|
||
if (header[0] != TABLE_MAGIC) {
|
||
g_numTracks = 0;
|
||
Serial.println("No track table found (flash may be empty)");
|
||
return;
|
||
}
|
||
|
||
g_numTracks = header[1];
|
||
if (g_numTracks > MAX_TRACKS) g_numTracks = MAX_TRACKS;
|
||
|
||
uint8_t entry[TRACK_ENTRY_SIZE];
|
||
for (uint8_t i = 0; i < g_numTracks; i++) {
|
||
flashReadBytes(TRACK_TABLE_ADDR + 4 + i * TRACK_ENTRY_SIZE, entry, TRACK_ENTRY_SIZE);
|
||
g_trackStart[i] = ((uint32_t)entry[0] << 24) | ((uint32_t)entry[1] << 16) |
|
||
((uint32_t)entry[2] << 8) | entry[3];
|
||
g_trackLen[i] = ((uint32_t)entry[4] << 24) | ((uint32_t)entry[5] << 16) |
|
||
((uint32_t)entry[6] << 8) | entry[7];
|
||
|
||
// Parse WAV header from flash to get format metadata
|
||
g_trackBits[i] = 8;
|
||
g_trackRate[i] = SAMPLE_RATE;
|
||
g_trackDataOff[i] = 0;
|
||
if (g_trackLen[i] >= 44) {
|
||
uint8_t hdr[44];
|
||
flashReadBytes(g_trackStart[i], hdr, 44);
|
||
if (hdr[0]=='R' && hdr[1]=='I' && hdr[2]=='F' && hdr[3]=='F') {
|
||
uint16_t bits = (uint16_t)hdr[34] | ((uint16_t)hdr[35] << 8);
|
||
uint32_t rate = (uint32_t)hdr[24] | ((uint32_t)hdr[25] << 8)
|
||
| ((uint32_t)hdr[26] << 16) | ((uint32_t)hdr[27] << 24);
|
||
if ((bits == 8 || bits == 16) && rate > 0) {
|
||
g_trackBits[i] = (uint8_t)bits;
|
||
g_trackRate[i] = rate;
|
||
g_trackDataOff[i] = 44;
|
||
}
|
||
}
|
||
}
|
||
|
||
Serial.print(" track"); Serial.print(i);
|
||
Serial.print(": "); Serial.print(g_trackBits[i]); Serial.print("bit ");
|
||
Serial.print(g_trackRate[i] / 1000); Serial.print("kHz ");
|
||
uint32_t audioBytes = g_trackLen[i] - g_trackDataOff[i];
|
||
uint32_t bps = g_trackBits[i] / 8;
|
||
Serial.print((audioBytes / bps) / g_trackRate[i]);
|
||
Serial.println("s");
|
||
}
|
||
Serial.print("Loaded ");
|
||
Serial.print(g_numTracks);
|
||
Serial.println(" tracks from flash");
|
||
|
||
// Advance g_flashNextFree past all existing tracks so BLE/serial uploads
|
||
// to non-zero slots don't overwrite data from a previous session.
|
||
if (g_numTracks > 0) {
|
||
uint32_t hiWater = 0;
|
||
for (uint8_t i = 0; i < g_numTracks; i++) {
|
||
uint32_t trackEnd = g_trackStart[i] + g_trackLen[i];
|
||
if (trackEnd > hiWater) hiWater = trackEnd;
|
||
}
|
||
g_flashNextFree = ((hiWater + FLASH_SECTOR - 1) / FLASH_SECTOR) * FLASH_SECTOR;
|
||
}
|
||
#endif
|
||
}
|
||
|
||
void writeTrackTable() {
|
||
#ifdef POC_INTERNAL_FLASH
|
||
return; // no table needed; LittleFS files are the store
|
||
#else
|
||
// Erase sector 0
|
||
flashEraseSector(0);
|
||
|
||
// Build table in RAM
|
||
uint8_t table[4 + MAX_TRACKS * TRACK_ENTRY_SIZE];
|
||
memset(table, 0xFF, sizeof(table));
|
||
table[0] = TABLE_MAGIC;
|
||
table[1] = g_numTracks;
|
||
table[2] = 0;
|
||
table[3] = 0;
|
||
|
||
for (uint8_t i = 0; i < g_numTracks; i++) {
|
||
uint8_t *e = &table[4 + i * TRACK_ENTRY_SIZE];
|
||
e[0] = (g_trackStart[i] >> 24) & 0xFF;
|
||
e[1] = (g_trackStart[i] >> 16) & 0xFF;
|
||
e[2] = (g_trackStart[i] >> 8) & 0xFF;
|
||
e[3] = g_trackStart[i] & 0xFF;
|
||
e[4] = (g_trackLen[i] >> 24) & 0xFF;
|
||
e[5] = (g_trackLen[i] >> 16) & 0xFF;
|
||
e[6] = (g_trackLen[i] >> 8) & 0xFF;
|
||
e[7] = g_trackLen[i] & 0xFF;
|
||
e[8] = (uint8_t)(g_trackRate[i] / 1000);
|
||
e[9] = g_trackBits[i];
|
||
e[10] = 0;
|
||
e[11] = 0;
|
||
}
|
||
|
||
// Write in pages
|
||
uint16_t total = 4 + g_numTracks * TRACK_ENTRY_SIZE;
|
||
for (uint16_t offset = 0; offset < total; offset += FLASH_PAGE) {
|
||
uint16_t chunk = min((uint16_t)FLASH_PAGE, (uint16_t)(total - offset));
|
||
flashPageProgram(offset, &table[offset], chunk);
|
||
}
|
||
#endif
|
||
}
|
||
|
||
|
||
// ============================================================
|
||
// USB MASS STORAGE CALLBACKS
|
||
// ============================================================
|
||
|
||
int32_t msc_read_cb(uint32_t lba, void* buffer, uint32_t bufsize) {
|
||
uint32_t addr = lba * MSC_BLOCK_SIZE;
|
||
flashReadBytes(addr, (uint8_t*)buffer, bufsize);
|
||
return bufsize;
|
||
}
|
||
|
||
int32_t msc_write_cb(uint32_t lba, uint8_t* buffer, uint32_t bufsize) {
|
||
uint32_t addr = lba * MSC_BLOCK_SIZE;
|
||
|
||
// Erase sector if we're at a sector boundary
|
||
if ((addr % FLASH_SECTOR) == 0) {
|
||
flashEraseSector(addr);
|
||
}
|
||
|
||
// Write in page-sized chunks
|
||
uint32_t written = 0;
|
||
while (written < bufsize) {
|
||
uint16_t pageOffset = (addr + written) % FLASH_PAGE;
|
||
uint16_t chunk = min((uint16_t)(FLASH_PAGE - pageOffset), (uint16_t)(bufsize - written));
|
||
flashPageProgram(addr + written, buffer + written, chunk);
|
||
written += chunk;
|
||
}
|
||
|
||
return bufsize;
|
||
}
|
||
|
||
void msc_flush_cb(void) {
|
||
// After USB write completes, reload the track table
|
||
// (host may have written a new FAT filesystem)
|
||
// We scan for audio data on eject instead
|
||
}
|
||
|
||
bool msc_start_stop_cb(uint8_t power_condition, bool start, bool load_eject) {
|
||
if (!start && load_eject) {
|
||
// Host ejected the drive — rescan for tracks
|
||
Serial.println("USB ejected, rescanning tracks...");
|
||
loadTrackTable();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
|
||
// ============================================================
|
||
// BLE SETUP
|
||
// ============================================================
|
||
|
||
void ble_connect_cb(uint16_t conn_handle) {
|
||
Serial.println("BLE connected");
|
||
g_bleConnected = true;
|
||
g_lastActivity = millis();
|
||
|
||
// Request fast connection parameters and maximum throughput features.
|
||
// The central may accept, renegotiate, or ignore these — all safe.
|
||
// NOTE: do NOT call requestMtuExchange() here. The GATT client (browser)
|
||
// initiates MTU exchange automatically during connection setup. A second
|
||
// ATT_EXCHANGE_MTU_REQ from the peripheral is a protocol violation (only
|
||
// one exchange per connection) and leaves Chrome's ATT layer in a stale
|
||
// "operation in progress" state, blocking all subsequent GATT writes.
|
||
BLEConnection* conn = Bluefruit.Connection(conn_handle);
|
||
if (conn) {
|
||
conn->requestConnectionParameter(6); // 6×1.25ms = 7.5ms interval
|
||
conn->requestDataLengthUpdate(); // LE Data Length Extension
|
||
conn->requestPHY(BLE_GAP_PHY_2MBPS); // 2M PHY if supported
|
||
}
|
||
}
|
||
|
||
void ble_disconnect_cb(uint16_t conn_handle, uint8_t reason) {
|
||
Serial.println("BLE disconnected");
|
||
g_bleConnected = false;
|
||
g_bleUploading = false;
|
||
g_bleFinalizing = false;
|
||
g_bleErasing = false; // stop tracking the in-progress erase; it will finish in HW
|
||
}
|
||
|
||
// ============================================================
|
||
// GLOBAL VOLUME
|
||
// ============================================================
|
||
// Software gain in Q8 fixed point: 256 = unity (1.0x), 128 = half (-6 dB),
|
||
// 512 = 2x. Lets us ATTENUATE below unity (smaller PWM swing → lower amp output
|
||
// → less supply current, which avoids the regulator brown-out at high volume).
|
||
// Default below unity because the fixed-gain amp is loud at full scale.
|
||
// Set via BLE CMD 0x08 (persisted) or 'u'/'d' / "VOL <pct>" over serial.
|
||
#define GAIN_UNITY 256
|
||
#define GAIN_MIN 8 // ~0.03x
|
||
#define GAIN_MAX 1024 // 4x
|
||
#define VOL_FILE "/volume"
|
||
static uint16_t g_audioGainQ8 = 128; // 0.5x (overridden by saved value on boot)
|
||
|
||
static uint8_t gainToPercent() {
|
||
uint32_t p = ((uint32_t)g_audioGainQ8 * 100u) / GAIN_UNITY;
|
||
return (uint8_t)min(p, (uint32_t)255);
|
||
}
|
||
|
||
static void setGainPercent(uint8_t pct) {
|
||
uint32_t q = ((uint32_t)pct * GAIN_UNITY) / 100u;
|
||
if (q < GAIN_MIN) q = GAIN_MIN;
|
||
if (q > GAIN_MAX) q = GAIN_MAX;
|
||
g_audioGainQ8 = (uint16_t)q;
|
||
}
|
||
|
||
static void saveVolume() {
|
||
#ifdef POC_INTERNAL_FLASH
|
||
InternalFS.remove(VOL_FILE); // FILE_O_WRITE has no truncate
|
||
File f(InternalFS);
|
||
if (f.open(VOL_FILE, FILE_O_WRITE)) {
|
||
uint8_t b[2] = { (uint8_t)(g_audioGainQ8 & 0xFF), (uint8_t)(g_audioGainQ8 >> 8) };
|
||
f.write(b, 2);
|
||
f.close();
|
||
}
|
||
#endif
|
||
}
|
||
|
||
static void loadVolume() {
|
||
#ifdef POC_INTERNAL_FLASH
|
||
File f(InternalFS);
|
||
if (f.open(VOL_FILE, FILE_O_READ)) {
|
||
uint8_t b[2];
|
||
if (f.read(b, 2) == 2) {
|
||
uint16_t v = (uint16_t)b[0] | ((uint16_t)b[1] << 8);
|
||
if (v >= GAIN_MIN && v <= GAIN_MAX) g_audioGainQ8 = v;
|
||
}
|
||
f.close();
|
||
}
|
||
Serial.print("Volume: "); Serial.print(gainToPercent()); Serial.println("%");
|
||
#endif
|
||
}
|
||
|
||
// Report current volume over BLE: [0xC0, percent, 0, 0]. Tag 0xC0 has bit7 set
|
||
// but (0xC0 & 0x7F)=64 ≥ MAX_TRACKS, so the host won't mistake it for a list entry.
|
||
static void notifyVolume() {
|
||
uint8_t pkt[4] = { 0xC0, gainToPercent(), 0, 0 };
|
||
audioStat.write(pkt, 4);
|
||
audioStat.notify(pkt, 4);
|
||
}
|
||
|
||
// BLE command characteristic: receives commands
|
||
// CMD 0x01 [num_tracks] [track_entries...] = write track table
|
||
// CMD 0x02 [track_num] = start writing audio to track slot
|
||
// CMD 0x03 = finish upload, reload tracks
|
||
// CMD 0x04 [track_num] = play track
|
||
// CMD 0x05 = stop playback
|
||
// CMD 0x06 = list tracks → audioStat notifications:
|
||
// [0x80|idx, bits, rate_kHz, dur_s] per track, then [0xFF, count, 0, 0]
|
||
// CMD 0x07 [track_num] = delete track → audioStat: [0xD0, idx, ok, 0]
|
||
// CMD 0x08 [percent] = set global volume (persisted) → audioStat: [0xC0, pct, 0, 0]
|
||
// CMD 0x09 = get global volume → audioStat: [0xC0, pct, 0, 0]
|
||
void audioCmd_write_cb(uint16_t conn_handle, BLECharacteristic* chr,
|
||
uint8_t* data, uint16_t len) {
|
||
if (len < 1) return;
|
||
g_lastActivity = millis();
|
||
|
||
switch (data[0]) {
|
||
case 0x01: // Write track table
|
||
if (len >= 2) {
|
||
g_numTracks = data[1];
|
||
if (g_numTracks > MAX_TRACKS) g_numTracks = MAX_TRACKS;
|
||
// Parse track entries from data[2..]
|
||
for (uint8_t i = 0; i < g_numTracks && (2 + i * 8 + 7) < len; i++) {
|
||
uint8_t *e = &data[2 + i * 8];
|
||
g_trackStart[i] = ((uint32_t)e[0] << 24) | ((uint32_t)e[1] << 16) |
|
||
((uint32_t)e[2] << 8) | e[3];
|
||
g_trackLen[i] = ((uint32_t)e[4] << 24) | ((uint32_t)e[5] << 16) |
|
||
((uint32_t)e[6] << 8) | e[7];
|
||
}
|
||
writeTrackTable();
|
||
Serial.println("BLE: Track table updated");
|
||
}
|
||
break;
|
||
|
||
case 0x02: // Start audio write
|
||
#ifdef POC_INTERNAL_FLASH
|
||
if (len >= 2) {
|
||
g_pocWriteTrack = data[1];
|
||
if (g_pocFile) g_pocFile.close();
|
||
char fname[16];
|
||
pocFilename(g_pocWriteTrack, fname);
|
||
if (!g_pocFile.open(fname, FILE_O_WRITE)) {
|
||
Serial.print("BLE: cannot open ");
|
||
Serial.println(fname);
|
||
break;
|
||
}
|
||
g_bleUploading = true;
|
||
g_bleWriteLen = 0;
|
||
Serial.print("BLE: Start write track ");
|
||
Serial.println(g_pocWriteTrack);
|
||
}
|
||
#else
|
||
if (len >= 2) {
|
||
g_bleWriteTrack = data[1];
|
||
// Track 0 resets allocation; subsequent tracks append
|
||
if (g_bleWriteTrack == 0) g_flashNextFree = AUDIO_START_ADDR;
|
||
g_bleFlashAddr = g_flashNextFree;
|
||
g_bleFlashStart = g_bleFlashAddr;
|
||
g_bleFlashErased = g_bleFlashAddr; // nothing erased yet
|
||
g_bleErasing = false;
|
||
g_bleNotifyThresh = g_bleFlashAddr;
|
||
g_bleBufHead = g_bleBufTail = 0;
|
||
g_bleFinalizing = false;
|
||
g_bleUploading = true;
|
||
g_bleWriteLen = 0;
|
||
Serial.print("BLE: Start write track ");
|
||
Serial.print(g_bleWriteTrack);
|
||
Serial.print(" at 0x");
|
||
Serial.println(g_bleFlashAddr, HEX);
|
||
}
|
||
#endif
|
||
break;
|
||
|
||
case 0x03: // Finish upload
|
||
#ifdef POC_INTERNAL_FLASH
|
||
if (g_pocFile) g_pocFile.close();
|
||
g_bleUploading = false;
|
||
loadTrackTable();
|
||
Serial.println("BLE: Upload complete");
|
||
#else
|
||
// Signal loop() to finalize once the ring buffer drains.
|
||
// Because the DATA characteristic uses write-with-response, every data
|
||
// packet has been ATT-acknowledged before the client sends this command,
|
||
// so all bytes are already in the ring buffer by the time we get here.
|
||
if (g_bleUploading) {
|
||
g_bleFinalizing = true;
|
||
Serial.println("BLE: Finalizing upload...");
|
||
}
|
||
#endif
|
||
break;
|
||
|
||
case 0x04: // Play track
|
||
if (len >= 2 && data[1] < g_numTracks) {
|
||
audioStart(data[1]);
|
||
}
|
||
break;
|
||
|
||
case 0x05: // Stop
|
||
audioStop();
|
||
motorStop();
|
||
break;
|
||
|
||
case 0x06: // List tracks
|
||
// Responds via audioStat notifications (one per track + end marker).
|
||
// Per-track packet: [0x80|idx, bits, rate_kHz, duration_s]
|
||
// End packet: [0xFF, num_tracks, 0, 0]
|
||
// Byte 0 >= 0x80 distinguishes list responses from upload-progress
|
||
// packets (which always have byte 0 == 0x00 for files < 16 MB).
|
||
for (uint8_t i = 0; i < g_numTracks; i++) {
|
||
uint32_t audioBytes = g_trackLen[i] - g_trackDataOff[i];
|
||
uint32_t bps = g_trackBits[i] / 8;
|
||
uint32_t durS = (bps > 0 && g_trackRate[i] > 0)
|
||
? (audioBytes / bps) / g_trackRate[i] : 0;
|
||
uint8_t pkt[4] = {
|
||
(uint8_t)(0x80 | i),
|
||
g_trackBits[i],
|
||
(uint8_t)(g_trackRate[i] / 1000),
|
||
(uint8_t)min(durS, (uint32_t)255)
|
||
};
|
||
audioStat.write(pkt, 4);
|
||
audioStat.notify(pkt, 4);
|
||
delay(20);
|
||
}
|
||
{
|
||
uint8_t end[4] = {0xFF, g_numTracks, 0, 0};
|
||
audioStat.write(end, 4);
|
||
audioStat.notify(end, 4);
|
||
}
|
||
Serial.print("BLE: Listed "); Serial.print(g_numTracks); Serial.println(" tracks");
|
||
break;
|
||
|
||
case 0x07: // Delete track
|
||
// data[1] = track index to delete.
|
||
// Responds via audioStat: [0xD0, track_idx, success(0/1), 0]
|
||
if (len >= 2) {
|
||
uint8_t trkNum = data[1];
|
||
bool ok = false;
|
||
#ifdef POC_INTERNAL_FLASH
|
||
char fname[16];
|
||
pocFilename(trkNum, fname);
|
||
ok = InternalFS.remove(fname);
|
||
if (ok) loadTrackTable();
|
||
#else
|
||
if (trkNum < g_numTracks) {
|
||
// Shift entries down to close the gap
|
||
for (uint8_t j = trkNum; j < g_numTracks - 1; j++) {
|
||
g_trackStart[j] = g_trackStart[j+1];
|
||
g_trackLen[j] = g_trackLen[j+1];
|
||
g_trackBits[j] = g_trackBits[j+1];
|
||
g_trackRate[j] = g_trackRate[j+1];
|
||
g_trackDataOff[j] = g_trackDataOff[j+1];
|
||
}
|
||
g_numTracks--;
|
||
writeTrackTable();
|
||
loadTrackTable();
|
||
ok = true;
|
||
}
|
||
#endif
|
||
uint8_t resp[4] = {0xD0, trkNum, (uint8_t)(ok ? 1 : 0), 0};
|
||
audioStat.write(resp, 4);
|
||
audioStat.notify(resp, 4);
|
||
Serial.print("BLE: Delete track ");
|
||
Serial.print(trkNum);
|
||
Serial.println(ok ? " OK" : " FAILED");
|
||
}
|
||
break;
|
||
|
||
case 0x08: // Set global volume (percent of unity), persist, and echo back
|
||
if (len >= 2) {
|
||
setGainPercent(data[1]);
|
||
saveVolume();
|
||
notifyVolume();
|
||
Serial.print("BLE: Volume "); Serial.print(gainToPercent()); Serial.println("%");
|
||
}
|
||
break;
|
||
|
||
case 0x09: // Get global volume
|
||
notifyVolume();
|
||
break;
|
||
}
|
||
}
|
||
|
||
// BLE data characteristic: receives raw audio bytes for upload
|
||
void audioData_write_cb(uint16_t conn_handle, BLECharacteristic* chr,
|
||
uint8_t* data, uint16_t len) {
|
||
if (!g_bleUploading) return;
|
||
g_lastActivity = millis();
|
||
|
||
#ifdef POC_INTERNAL_FLASH
|
||
g_pocFile.write(data, len);
|
||
g_bleWriteLen += len;
|
||
#else
|
||
// Push into ring buffer; flash I/O happens in bleFlashTick() from loop()
|
||
// so this callback returns immediately without blocking the SoftDevice.
|
||
for (uint16_t i = 0; i < len; i++) {
|
||
uint32_t nextHead = (g_bleBufHead + 1) % BLE_FLASH_BUF;
|
||
if (nextHead == g_bleBufTail) break; // full — drop tail (shouldn't happen at 16kHz)
|
||
g_bleBuf[g_bleBufHead] = data[i];
|
||
g_bleBufHead = nextHead;
|
||
}
|
||
g_bleWriteLen += len;
|
||
#endif
|
||
// Progress notifications are intentionally omitted here: sending one BLE
|
||
// notification per 180-byte packet floods the SoftDevice TX queue (~7 k
|
||
// packets for a 1.3 MB file) and causes the last notifications to be
|
||
// dropped. A single definitive notification is sent by bleFlashTick()
|
||
// when finalization completes (after CMD_UPLOAD_END drains the ring buffer
|
||
// and writes the track table).
|
||
}
|
||
|
||
// Drain the BLE ring buffer to SPI flash.
|
||
// Sector erases are asynchronous: we issue the erase command and return immediately
|
||
// so loop() keeps running (and the ring buffer keeps draining from BLE callbacks).
|
||
// On the next call we poll the WIP bit to confirm completion before writing.
|
||
// This prevents the 30–300 ms erase window from filling the ring buffer.
|
||
// A pre-erase is also kicked off as soon as we start writing each sector so
|
||
// the next sector is ready before we reach it. Writes stop while any erase is
|
||
// in progress because the flash ignores page-program when WIP=1.
|
||
#ifndef POC_INTERNAL_FLASH
|
||
static void bleFlashTick() {
|
||
if (!g_bleUploading && !g_bleFinalizing) return;
|
||
|
||
// Poll async sector erase completion.
|
||
if (g_bleErasing && !flashIsBusy()) {
|
||
g_bleErasing = false;
|
||
g_bleFlashErased += FLASH_SECTOR;
|
||
}
|
||
|
||
// Drain all available ring-buffer data into flash, one page per iteration.
|
||
while (g_bleBufHead != g_bleBufTail) {
|
||
if (g_bleErasing) break; // never write while an erase is in progress
|
||
uint32_t avail = (g_bleBufHead - g_bleBufTail + BLE_FLASH_BUF) % BLE_FLASH_BUF;
|
||
uint16_t pageOff = (uint16_t)(g_bleFlashAddr % FLASH_PAGE);
|
||
uint16_t chunk = (uint16_t)min((uint32_t)(FLASH_PAGE - pageOff), avail);
|
||
if (chunk == 0) break;
|
||
|
||
if (g_bleFlashAddr + chunk > g_bleFlashErased) {
|
||
// Write pointer has reached the erased boundary — need another sector erased.
|
||
if (!g_bleErasing) {
|
||
flashWriteEnable();
|
||
SPI.beginTransaction(flashSPI);
|
||
flashSelect();
|
||
SPI.transfer(CMD_SECT_ERASE);
|
||
SPI.transfer((g_bleFlashErased >> 16) & 0xFF);
|
||
SPI.transfer((g_bleFlashErased >> 8) & 0xFF);
|
||
SPI.transfer( g_bleFlashErased & 0xFF);
|
||
flashDeselect();
|
||
SPI.endTransaction();
|
||
g_bleErasing = true;
|
||
// Do NOT advance g_bleFlashErased — wait for WIP confirmation next call.
|
||
}
|
||
break; // return to loop(); erase runs in HW, ring buffer fills freely
|
||
}
|
||
|
||
// Erase boundary is ahead — safe to program this page.
|
||
uint8_t tmp[FLASH_PAGE];
|
||
for (uint16_t i = 0; i < chunk; i++) {
|
||
tmp[i] = g_bleBuf[(g_bleBufTail + i) % BLE_FLASH_BUF];
|
||
}
|
||
flashPageProgram(g_bleFlashAddr, tmp, chunk); // ~0.5 ms blocking
|
||
g_bleBufTail = (g_bleBufTail + chunk) % BLE_FLASH_BUF;
|
||
g_bleFlashAddr += chunk;
|
||
|
||
// Pre-erase: kick off the next sector erase as soon as we start writing
|
||
// the current sector so the erase (~30 ms typ) completes before we need it.
|
||
// Break immediately — never write while an async erase is in progress since
|
||
// the flash chip silently ignores page-program commands when WIP=1.
|
||
if (!g_bleErasing && g_bleFlashAddr > g_bleFlashErased - FLASH_SECTOR) {
|
||
flashWriteEnable();
|
||
SPI.beginTransaction(flashSPI);
|
||
flashSelect();
|
||
SPI.transfer(CMD_SECT_ERASE);
|
||
SPI.transfer((g_bleFlashErased >> 16) & 0xFF);
|
||
SPI.transfer((g_bleFlashErased >> 8) & 0xFF);
|
||
SPI.transfer( g_bleFlashErased & 0xFF);
|
||
flashDeselect();
|
||
SPI.endTransaction();
|
||
g_bleErasing = true;
|
||
break; // wait for erase; ring buffer fills freely in the meantime
|
||
}
|
||
|
||
// Periodic progress notification — keeps the Windows BLE stack from
|
||
// dropping the connection during long silent uploads, and gives the
|
||
// client real flash-write progress (not just BLE-send progress).
|
||
if (g_bleConnected && g_bleFlashAddr - g_bleNotifyThresh >= 4096u) {
|
||
g_bleNotifyThresh = g_bleFlashAddr;
|
||
uint32_t written = g_bleFlashAddr - g_bleFlashStart;
|
||
uint8_t stat[4] = {
|
||
(uint8_t)(written >> 24), (uint8_t)(written >> 16),
|
||
(uint8_t)(written >> 8), (uint8_t)(written)
|
||
};
|
||
audioStat.notify(stat, 4);
|
||
}
|
||
}
|
||
|
||
// Finalize when CMD 0x03 received and ring buffer is fully drained.
|
||
if (g_bleFinalizing && g_bleBufHead == g_bleBufTail) {
|
||
uint32_t startAddr = g_bleFlashStart;
|
||
uint32_t bytesStored = g_bleFlashAddr - g_bleFlashStart;
|
||
g_trackStart[g_bleWriteTrack] = startAddr;
|
||
g_trackLen[g_bleWriteTrack] = bytesStored;
|
||
if (g_bleWriteTrack >= g_numTracks) g_numTracks = g_bleWriteTrack + 1;
|
||
|
||
// Advance shared free pointer to next sector boundary
|
||
g_flashNextFree = ((g_bleFlashAddr + FLASH_SECTOR - 1) / FLASH_SECTOR) * FLASH_SECTOR;
|
||
|
||
g_bleUploading = false;
|
||
g_bleFinalizing = false;
|
||
g_bleErasing = false; // pre-erase of unused sector, if any, will finish in HW
|
||
writeTrackTable();
|
||
loadTrackTable();
|
||
Serial.printf("BLE: Upload finalized — start=0x%08lX stored=%lu bytes\n", startAddr, bytesStored);
|
||
|
||
// Confirm to client: send final stat with bytes actually stored
|
||
uint8_t stat[4];
|
||
stat[0] = (bytesStored >> 24) & 0xFF;
|
||
stat[1] = (bytesStored >> 16) & 0xFF;
|
||
stat[2] = (bytesStored >> 8) & 0xFF;
|
||
stat[3] = bytesStored & 0xFF;
|
||
audioStat.write(stat, 4);
|
||
audioStat.notify(stat, 4);
|
||
}
|
||
}
|
||
#endif
|
||
|
||
void setupBLE() {
|
||
/* Note BLE requires a custom app to communicate, not regular bluetooth */
|
||
// Configure for maximum bandwidth so the SoftDevice allocates buffers large
|
||
// enough to accept 180-byte ATT payloads (MTU 183). Must be called before begin().
|
||
Bluefruit.configPrphBandwidth(BANDWIDTH_MAX);
|
||
Bluefruit.begin();
|
||
Bluefruit.setName("BabyMobile");
|
||
Bluefruit.setTxPower(0); // 0 dBm — save power, short range is fine
|
||
Bluefruit.Periph.setConnectCallback(ble_connect_cb);
|
||
Bluefruit.Periph.setDisconnectCallback(ble_disconnect_cb);
|
||
|
||
// Audio service
|
||
audioSvc.begin();
|
||
|
||
// Command characteristic: write-with-response so JS can await full OS-level
|
||
// completion before issuing the next operation on any characteristic.
|
||
audioCmd.setProperties(CHR_PROPS_WRITE);
|
||
audioCmd.setPermission(SECMODE_OPEN, SECMODE_OPEN);
|
||
audioCmd.setMaxLen(240);
|
||
audioCmd.setWriteCallback(audioCmd_write_cb);
|
||
audioCmd.begin();
|
||
|
||
// Data characteristic (write with response — ATT flow control ensures the
|
||
// SoftDevice has acknowledged every packet before the client sends the next,
|
||
// so CMD_UPLOAD_END can never race ahead of the data stream)
|
||
audioData.setProperties(CHR_PROPS_WRITE);
|
||
audioData.setPermission(SECMODE_OPEN, SECMODE_OPEN);
|
||
audioData.setMaxLen(244); // must match DATA_CHUNK in ble.js (MTU 247 - 3 = 244)
|
||
audioData.setWriteCallback(audioData_write_cb);
|
||
audioData.begin();
|
||
|
||
// Status characteristic (read + notify)
|
||
audioStat.setProperties(CHR_PROPS_READ | CHR_PROPS_NOTIFY);
|
||
audioStat.setPermission(SECMODE_OPEN, SECMODE_NO_ACCESS);
|
||
audioStat.setMaxLen(4);
|
||
audioStat.begin();
|
||
|
||
// Configure advertising packet.
|
||
// The 128-bit service UUID (18 bytes) plus flags + TxPower nearly fills the
|
||
// 31-byte advertisement, leaving room for only ~5 name characters — which
|
||
// truncated the name to "BabyM" over the air. Put the full name in the scan
|
||
// response (its own separate 31 bytes) so scanners see "BabyMobile" intact.
|
||
Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE);
|
||
Bluefruit.Advertising.addTxPower();
|
||
Bluefruit.Advertising.addService(audioSvc);
|
||
Bluefruit.ScanResponse.addName(); // full name here, not in the ad packet
|
||
Bluefruit.Advertising.restartOnDisconnect(false); // don't auto-re-advertise after disconnect
|
||
Bluefruit.Advertising.setInterval(160, 320); // 100-200ms
|
||
// Do NOT start advertising here — user must hold BTN1 for 5 s to enable it.
|
||
|
||
Serial.println("BLE ready (hold BTN1 for 5 s to advertise)");
|
||
}
|
||
|
||
|
||
// Start BLE advertising for up to 60 seconds.
|
||
// Called when the user holds BTN1 for 5 s. Advertising stops automatically
|
||
// after the timeout, or immediately when a connection is made.
|
||
#define BLE_ADV_TIMEOUT_S 60
|
||
static void startBLEAdvertising() {
|
||
if (Bluefruit.Advertising.isRunning()) {
|
||
Serial.println("BLE already advertising");
|
||
return;
|
||
}
|
||
Bluefruit.Advertising.start(BLE_ADV_TIMEOUT_S);
|
||
Serial.println("BLE advertising started (60 s)");
|
||
}
|
||
|
||
|
||
// ============================================================
|
||
// AUDIO PLAYBACK — nRF52840 PWM EasyDMA
|
||
// ============================================================
|
||
//
|
||
// NRF_PWM0 drives the audio pin directly via DMA — no per-sample ISR.
|
||
// PRESCALER = 0 → 16 MHz base clock
|
||
// COUNTERTOP = 500 → PWM carrier = 16 MHz / 500 = 32 kHz
|
||
// SEQ REFRESH = 1 → each sample plays 2 carrier cycles → 16 kHz sample rate
|
||
//
|
||
// Double-buffer: SEQ[0] / SEQ[1] auto-chain via SHORTS.
|
||
// ISR fires only on SEQEND (every 512 samples = 32 ms), sets g_bufReady[b]=false.
|
||
// loop() calls audioFillBuf() to reload the finished buffer.
|
||
|
||
#define PWM_COUNTERTOP 500 // 16 MHz / 500 = 32 kHz carrier
|
||
#define PWM_SILENCE 250 // midpoint of 0–500 duty-cycle range
|
||
|
||
// Audio gain (g_audioGainQ8 + GAIN_* + volume persistence) is defined earlier,
|
||
// before the BLE command callback that sets it. See "GLOBAL VOLUME" section.
|
||
|
||
// Read PCM into g_pwmBuf[b], pad tail with silence.
|
||
// Supports 8-bit unsigned and 16-bit signed WAV; format read from g_trackBits[].
|
||
// On the first pure-silence fill (track exhausted), arms the stop timer.
|
||
static void audioFillBuf(uint8_t b) {
|
||
bool is16 = (g_trackBits[g_currentTrack] == 16);
|
||
uint32_t bytesPerSample = is16 ? 2 : 1;
|
||
uint32_t toReadBytes = 0;
|
||
|
||
if (g_nextReadAddr < g_playEnd) {
|
||
uint32_t remaining = g_playEnd - g_nextReadAddr;
|
||
toReadBytes = min((uint32_t)(AUDIO_BUF_SIZE * bytesPerSample), remaining);
|
||
if (is16) toReadBytes &= ~1u; // keep sample-aligned
|
||
}
|
||
|
||
uint32_t samples = toReadBytes / bytesPerSample;
|
||
|
||
if (is16) {
|
||
uint8_t raw[AUDIO_BUF_SIZE * 2];
|
||
if (toReadBytes) {
|
||
#ifdef POC_INTERNAL_FLASH
|
||
g_pocFile.read(raw, toReadBytes);
|
||
#else
|
||
flashReadBytes(g_nextReadAddr, raw, toReadBytes);
|
||
#endif
|
||
g_nextReadAddr += toReadBytes;
|
||
}
|
||
for (uint32_t i = 0; i < samples; i++) {
|
||
int16_t s = (int16_t)((uint16_t)raw[i * 2] | ((uint16_t)raw[i * 2 + 1] << 8));
|
||
int32_t sv = ((int32_t)s * g_audioGainQ8) >> 8;
|
||
if (sv > 32767) sv = 32767;
|
||
if (sv < -32768) sv = -32768;
|
||
g_pwmBuf[b][i] = (uint16_t)(((uint32_t)(sv + 32768)) * PWM_COUNTERTOP / 65536);
|
||
}
|
||
} else {
|
||
uint8_t pcm[AUDIO_BUF_SIZE];
|
||
if (toReadBytes) {
|
||
#ifdef POC_INTERNAL_FLASH
|
||
g_pocFile.read(pcm, toReadBytes);
|
||
#else
|
||
flashReadBytes(g_nextReadAddr, pcm, toReadBytes);
|
||
#endif
|
||
g_nextReadAddr += toReadBytes;
|
||
}
|
||
for (uint32_t i = 0; i < samples; i++) {
|
||
int32_t s = (((int32_t)pcm[i] - 128) * g_audioGainQ8) >> 8;
|
||
if (s > 127) s = 127;
|
||
if (s < -128) s = -128;
|
||
g_pwmBuf[b][i] = (uint16_t)((uint8_t)(s + 128)) * PWM_COUNTERTOP / 256;
|
||
}
|
||
}
|
||
|
||
for (uint32_t i = samples; i < AUDIO_BUF_SIZE; i++) {
|
||
g_pwmBuf[b][i] = PWM_SILENCE;
|
||
}
|
||
g_bufReady[b] = true;
|
||
// Arm stop timer on first pure-silence fill (all audio already sent to DMA)
|
||
if (toReadBytes == 0 && g_trackDoneMs == 0 && g_playing) {
|
||
g_trackDoneMs = millis() + 100; // 100ms > 3 buffer lengths (3 × 32ms)
|
||
}
|
||
}
|
||
|
||
// PWM0 ISR — fires every 32 ms (512 samples at 16 kHz).
|
||
// Just signals which buffer needs refilling; loop() does the actual I/O.
|
||
// ISR just chains buffers — end-of-track is handled entirely in loop()
|
||
extern "C" void PWM0_IRQHandler() {
|
||
if (NRF_PWM0->EVENTS_SEQEND[0]) {
|
||
NRF_PWM0->EVENTS_SEQEND[0] = 0;
|
||
if (g_playing) { g_bufReady[0] = false; NRF_PWM0->TASKS_SEQSTART[1] = 1; }
|
||
}
|
||
if (NRF_PWM0->EVENTS_SEQEND[1]) {
|
||
NRF_PWM0->EVENTS_SEQEND[1] = 0;
|
||
if (g_playing) { g_bufReady[1] = false; NRF_PWM0->TASKS_SEQSTART[0] = 1; }
|
||
}
|
||
}
|
||
|
||
void audioInit() {
|
||
// Silence both DMA buffers
|
||
for (int i = 0; i < AUDIO_BUF_SIZE; i++) {
|
||
g_pwmBuf[0][i] = PWM_SILENCE;
|
||
g_pwmBuf[1][i] = PWM_SILENCE;
|
||
}
|
||
|
||
// Configure NRF_PWM0 for EasyDMA sequence mode
|
||
NRF_PWM0->PSEL.OUT[0] = g_ADigitalPinMap[PIN_AUDIO_PWM];
|
||
NRF_PWM0->PSEL.OUT[1] = 0x80000000UL; // disconnected
|
||
NRF_PWM0->PSEL.OUT[2] = 0x80000000UL;
|
||
NRF_PWM0->PSEL.OUT[3] = 0x80000000UL;
|
||
NRF_PWM0->ENABLE = PWM_ENABLE_ENABLE_Enabled;
|
||
NRF_PWM0->MODE = PWM_MODE_UPDOWN_Up;
|
||
NRF_PWM0->PRESCALER = PWM_PRESCALER_PRESCALER_DIV_1; // 16 MHz
|
||
NRF_PWM0->COUNTERTOP = PWM_COUNTERTOP;
|
||
NRF_PWM0->LOOP = 0;
|
||
NRF_PWM0->DECODER = (PWM_DECODER_LOAD_Common << PWM_DECODER_LOAD_Pos) |
|
||
(PWM_DECODER_MODE_RefreshCount << PWM_DECODER_MODE_Pos);
|
||
|
||
NRF_PWM0->SEQ[0].PTR = (uint32_t)g_pwmBuf[0];
|
||
NRF_PWM0->SEQ[0].CNT = AUDIO_BUF_SIZE;
|
||
NRF_PWM0->SEQ[0].REFRESH = 1; // each value plays for 2 PWM periods
|
||
NRF_PWM0->SEQ[0].ENDDELAY = 0;
|
||
NRF_PWM0->SEQ[1].PTR = (uint32_t)g_pwmBuf[1];
|
||
NRF_PWM0->SEQ[1].CNT = AUDIO_BUF_SIZE;
|
||
NRF_PWM0->SEQ[1].REFRESH = 1;
|
||
NRF_PWM0->SEQ[1].ENDDELAY = 0;
|
||
|
||
NRF_PWM0->SHORTS = 0; // no auto-shorts; ISR handles sequence chaining
|
||
NRF_PWM0->INTENSET = PWM_INTENSET_SEQEND0_Msk | PWM_INTENSET_SEQEND1_Msk;
|
||
NVIC_SetPriority(PWM0_IRQn, 7);
|
||
NVIC_EnableIRQ(PWM0_IRQn);
|
||
|
||
// Start PWM outputting silence (amp is off, so this is silent)
|
||
NRF_PWM0->TASKS_SEQSTART[0] = 1;
|
||
}
|
||
|
||
void audioStart(uint8_t trackNum) {
|
||
Serial.print("Playing track ");
|
||
Serial.print(trackNum + 1);
|
||
Serial.print(" of ");
|
||
Serial.println(g_numTracks);
|
||
if (trackNum >= g_numTracks) return;
|
||
|
||
g_currentTrack = trackNum;
|
||
|
||
#ifdef POC_INTERNAL_FLASH
|
||
if (g_pocFile) g_pocFile.close();
|
||
char fname[16];
|
||
pocFilename(trackNum, fname);
|
||
if (!g_pocFile.open(fname, FILE_O_READ)) {
|
||
Serial.print("audioStart: cannot open "); Serial.println(fname);
|
||
return;
|
||
}
|
||
g_pocFile.seek(g_trackDataOff[trackNum]);
|
||
g_nextReadAddr = g_trackDataOff[trackNum];
|
||
g_playEnd = g_trackLen[trackNum];
|
||
#else
|
||
g_nextReadAddr = g_trackStart[trackNum] + g_trackDataOff[trackNum];
|
||
g_playEnd = g_trackStart[trackNum] + g_trackLen[trackNum];
|
||
#endif
|
||
|
||
// Set PWM REFRESH for this track's sample rate.
|
||
// carrier = 32kHz; effective_rate = 32000 / (REFRESH + 1)
|
||
// 16kHz → REFRESH=1, 8kHz → REFRESH=3, 32kHz → REFRESH=0
|
||
{
|
||
uint32_t rate = g_trackRate[trackNum];
|
||
if (rate == 0) rate = SAMPLE_RATE;
|
||
uint8_t refresh = (uint8_t)((32000u / rate) - 1);
|
||
NRF_PWM0->SEQ[0].REFRESH = refresh;
|
||
NRF_PWM0->SEQ[1].REFRESH = refresh;
|
||
}
|
||
|
||
g_trackDoneMs = 0;
|
||
g_bufReady[0] = false;
|
||
g_bufReady[1] = false;
|
||
|
||
// Stop DMA cleanly, fill both buffers, restart
|
||
NRF_PWM0->TASKS_STOP = 1;
|
||
uint32_t t = millis();
|
||
while (!NRF_PWM0->EVENTS_STOPPED && millis() - t < 10) {}
|
||
NRF_PWM0->EVENTS_STOPPED = 0;
|
||
|
||
audioFillBuf(0);
|
||
audioFillBuf(1);
|
||
|
||
NRF_PWM0->SEQ[0].PTR = (uint32_t)g_pwmBuf[0];
|
||
NRF_PWM0->SEQ[1].PTR = (uint32_t)g_pwmBuf[1];
|
||
|
||
// Enable amplifier
|
||
digitalWrite(PIN_AMP_SD, HIGH);
|
||
delay(10);
|
||
|
||
g_playing = true;
|
||
NRF_PWM0->TASKS_SEQSTART[0] = 1;
|
||
}
|
||
|
||
void audioStop() {
|
||
g_playing = false;
|
||
g_trackDoneMs = 0; // cancel any pending auto-advance
|
||
NRF_PWM0->TASKS_STOP = 1;
|
||
|
||
#ifdef POC_INTERNAL_FLASH
|
||
if (g_pocFile) g_pocFile.close();
|
||
#endif
|
||
|
||
// Disable amp
|
||
digitalWrite(PIN_AMP_SD, LOW);
|
||
Serial.println("Playback stopped");
|
||
}
|
||
|
||
|
||
// ============================================================
|
||
// MOTOR
|
||
// ============================================================
|
||
|
||
void motorInit() {
|
||
pinMode(PIN_MOTOR_PWM, OUTPUT);
|
||
analogWrite(PIN_MOTOR_PWM, 0);
|
||
}
|
||
|
||
void motorStart(uint8_t speed) {
|
||
g_motorSpeed = speed;
|
||
g_motorOn = true;
|
||
analogWrite(PIN_MOTOR_PWM, speed);
|
||
}
|
||
|
||
void motorStop() {
|
||
g_motorOn = false;
|
||
analogWrite(PIN_MOTOR_PWM, 0);
|
||
}
|
||
|
||
|
||
// ============================================================
|
||
// BUTTONS
|
||
// ============================================================
|
||
|
||
void buttonsInit() {
|
||
for (uint8_t i = 0; i < NUM_BUTTONS; i++) {
|
||
pinMode(BTN_PINS[i], INPUT_PULLUP);
|
||
}
|
||
}
|
||
|
||
// Returns 1-4, or 0 if none pressed. No blocking delay — safe to call every loop().
|
||
uint8_t buttonRead() {
|
||
for (uint8_t i = 0; i < NUM_BUTTONS; i++) {
|
||
if (digitalRead(BTN_PINS[i]) == LOW) return i + 1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// Wait until the button is physically released. No timeout — prevents the 800ms
|
||
// cooldown from expiring while the button is still held and re-triggering.
|
||
void waitButtonRelease(uint8_t btn) {
|
||
while (buttonRead() == btn) {
|
||
if (g_playing) {
|
||
// Keep DMA buffers fed while waiting
|
||
for (uint8_t b = 0; b < 2; b++) {
|
||
if (!g_bufReady[b]) audioFillBuf(b);
|
||
}
|
||
// Stop amp as soon as track ends — don't wait for loop() to resume
|
||
if (g_trackDoneMs != 0 && millis() >= g_trackDoneMs) {
|
||
g_trackDoneMs = 0;
|
||
audioStop();
|
||
}
|
||
}
|
||
}
|
||
delay(20); // debounce after release
|
||
}
|
||
|
||
|
||
// ============================================================
|
||
// SLEEP / WAKE
|
||
// ============================================================
|
||
|
||
// One byte from the nRF52840 hardware true random number generator.
|
||
static uint8_t hwRandom() {
|
||
NRF_RNG->CONFIG = RNG_CONFIG_DERCEN_Enabled << RNG_CONFIG_DERCEN_Pos; // bias correction on
|
||
NRF_RNG->EVENTS_VALRDY = 0;
|
||
NRF_RNG->TASKS_START = 1;
|
||
while (!NRF_RNG->EVENTS_VALRDY) {}
|
||
uint8_t v = (uint8_t)NRF_RNG->VALUE;
|
||
NRF_RNG->TASKS_STOP = 1;
|
||
return v;
|
||
}
|
||
|
||
void enterDeepSleep() {
|
||
// Don't sleep if any button is currently LOW — would wake instantly.
|
||
// Also catches floating pins that the internal pull-up isn't winning against.
|
||
for (uint8_t i = 0; i < NUM_BUTTONS; i++) {
|
||
if (digitalRead(BTN_PINS[i]) == LOW) {
|
||
g_lastActivity = millis(); // postpone
|
||
return;
|
||
}
|
||
}
|
||
|
||
Serial.println("Entering deep sleep...");
|
||
audioStop();
|
||
motorStop();
|
||
#ifndef POC_INTERNAL_FLASH
|
||
flashSleep();
|
||
#endif
|
||
digitalWrite(PIN_LED, LOW);
|
||
|
||
// Stop BLE advertising to save power
|
||
Bluefruit.Advertising.stop();
|
||
|
||
// Configure buttons as wake sources.
|
||
// nrf_gpio_cfg_sense_input takes nRF GPIO numbers, not Arduino pin numbers —
|
||
// use g_ADigitalPinMap[] to convert.
|
||
for (uint8_t i = 0; i < NUM_BUTTONS; i++) {
|
||
nrf_gpio_cfg_sense_input(g_ADigitalPinMap[BTN_PINS[i]],
|
||
NRF_GPIO_PIN_PULLUP,
|
||
NRF_GPIO_PIN_SENSE_LOW);
|
||
}
|
||
|
||
// System OFF — lowest power, wakes via GPIO sense or reset
|
||
sd_power_system_off();
|
||
|
||
// Execution stops here. Device resets on wake.
|
||
// (This line is never reached)
|
||
}
|
||
|
||
|
||
// ============================================================
|
||
// SETUP
|
||
// ============================================================
|
||
|
||
void setup() {
|
||
// LED early — visual feedback
|
||
pinMode(PIN_LED, OUTPUT);
|
||
digitalWrite(PIN_LED, HIGH);
|
||
|
||
Serial.begin(115200);
|
||
Serial.println("=== Baby Mobile v2 ===");
|
||
Serial.println("nRF52840 + IS25LP128F + PAM8302A");
|
||
|
||
// Pin setup
|
||
pinMode(PIN_AMP_SD, OUTPUT);
|
||
digitalWrite(PIN_AMP_SD, LOW);
|
||
|
||
#ifdef POC_INTERNAL_FLASH
|
||
InternalFS.begin();
|
||
Serial.println("InternalFS mounted");
|
||
loadVolume(); // restore persisted global volume
|
||
#else
|
||
pinMode(PIN_FLASH_CS, OUTPUT);
|
||
digitalWrite(PIN_FLASH_CS, HIGH);
|
||
|
||
// SPI
|
||
SPI.begin();
|
||
|
||
// Flash
|
||
flashWake();
|
||
delay(1);
|
||
uint32_t jedec = flashReadJEDEC();
|
||
Serial.print("Flash JEDEC: 0x");
|
||
Serial.println(jedec, HEX);
|
||
|
||
if (jedec == 0x9D6018 || jedec == 0x9D6017 || // IS25LP128F / IS25LP064
|
||
jedec == 0xEF4018 || jedec == 0xEF4017) { // W25Q128 / W25Q064
|
||
Serial.println("Flash detected OK");
|
||
} else if (jedec == 0x000000 || jedec == 0xFFFFFF) {
|
||
Serial.println("WARNING: No flash detected! Check SPI wiring.");
|
||
}
|
||
#endif
|
||
|
||
// Load tracks
|
||
loadTrackTable();
|
||
|
||
// Buttons
|
||
buttonsInit();
|
||
|
||
// Motor
|
||
motorInit();
|
||
|
||
// Audio
|
||
audioInit();
|
||
|
||
// USB Mass Storage
|
||
#ifndef POC_INTERNAL_FLASH
|
||
usb_msc.setID("BabyMobile", "Audio Drive", "2.0");
|
||
usb_msc.setReadWriteCallback(msc_read_cb, msc_write_cb, msc_flush_cb);
|
||
//usb_msc.setStartStopCallback(msc_start_stop_cb); // unused in nrf?
|
||
usb_msc.setCapacity(MSC_BLOCK_COUNT, MSC_BLOCK_SIZE);
|
||
//usb_msc.setReadOnly(false);
|
||
usb_msc.setUnitReady(true);
|
||
usb_msc.begin();
|
||
#endif
|
||
|
||
// Read RESETREAS and collect a random byte BEFORE starting the SoftDevice.
|
||
// Once Bluefruit.begin() enables S140, the SoftDevice owns NRF_RNG and
|
||
// NRF_POWER — direct register access after that point causes a hard fault.
|
||
uint32_t resetreas = NRF_POWER->RESETREAS;
|
||
NRF_POWER->RESETREAS = 0xFFFFFFFFUL; // write-1-to-clear
|
||
g_wakeFromSleep = (resetreas & POWER_RESETREAS_OFF_Msk) != 0;
|
||
g_debugResetreas = resetreas; // printed 10 s after boot once serial reconnects
|
||
// Seed Arduino random() from hardware RNG now, before Bluefruit.begin().
|
||
// After the SoftDevice starts it owns NRF_RNG — hwRandom() must not be
|
||
// called again after that point. Use random() everywhere in loop().
|
||
randomSeed(hwRandom());
|
||
#ifdef WAKE_PLAY_RANDOM
|
||
if (g_wakeFromSleep && g_numTracks > 0) {
|
||
g_wakeTrack = (uint8_t)random(g_numTracks);
|
||
}
|
||
#endif
|
||
|
||
// BLE
|
||
setupBLE();
|
||
|
||
g_lastActivity = millis();
|
||
|
||
#ifdef WAKE_PLAY_RANDOM
|
||
if (g_wakeFromSleep && g_numTracks > 0) {
|
||
Serial.print("Auto-play random track "); Serial.println(g_wakeTrack);
|
||
audioStart(g_wakeTrack);
|
||
}
|
||
#endif
|
||
|
||
digitalWrite(PIN_LED, HIGH);
|
||
Serial.println("Ready! Plug in USB to upload audio, or press a button.");
|
||
}
|
||
|
||
|
||
// ============================================================
|
||
// MAIN LOOP
|
||
// ============================================================
|
||
|
||
// ============================================================
|
||
// SERIAL UPLOAD STATE MACHINE
|
||
// Works in both POC_INTERNAL_FLASH and SPI flash modes.
|
||
// Protocol: UPLOAD <track> <total_wav_bytes> (full WAV file, header included)
|
||
// DUMP <track> DUMP <track>
|
||
// p / l / u / d / r
|
||
// DELETE <track> (POC only)
|
||
// FORMAT (POC only)
|
||
// ============================================================
|
||
enum SerUploadState { SER_IDLE, SER_RECEIVING };
|
||
static SerUploadState g_serState = SER_IDLE;
|
||
static char g_serLineBuf[64] = {0};
|
||
static uint8_t g_serLineLen = 0;
|
||
static uint8_t g_serTrack = 0;
|
||
static uint32_t g_serBytesExpected = 0;
|
||
static uint32_t g_serBytesReceived = 0;
|
||
|
||
#ifdef POC_INTERNAL_FLASH
|
||
static File g_serFile(InternalFS);
|
||
#else
|
||
// SPI flash upload: tracks are packed sequentially starting at AUDIO_START_ADDR.
|
||
// Uploading track 0 resets g_flashNextFree (shared with BLE upload path).
|
||
static uint32_t g_serFlashCurAddr = 0; // current write head
|
||
static uint32_t g_serFlashErasedThru = 0; // highest erased byte address (exclusive)
|
||
#endif
|
||
|
||
static void serUploadTick() {
|
||
if (g_serState == SER_IDLE) {
|
||
while (Serial.available()) {
|
||
char c = (char)Serial.read();
|
||
if (c == '\n' || c == '\r') {
|
||
g_serLineBuf[g_serLineLen] = '\0';
|
||
if (g_serLineLen == 0) { g_serLineLen = 0; break; }
|
||
|
||
unsigned int utrk = 0, ulen = 0;
|
||
if (sscanf(g_serLineBuf, "UPLOAD %u %u", &utrk, &ulen) == 2) {
|
||
g_serTrack = (uint8_t)utrk;
|
||
g_serBytesExpected = (uint32_t)ulen;
|
||
g_serBytesReceived = 0;
|
||
|
||
#ifdef POC_INTERNAL_FLASH
|
||
char fname[16];
|
||
pocFilename(g_serTrack, fname);
|
||
if (g_serFile) g_serFile.close();
|
||
InternalFS.remove(fname);
|
||
if (!g_serFile.open(fname, FILE_O_WRITE)) {
|
||
Serial.print("ERR cannot open "); Serial.println(fname);
|
||
} else {
|
||
g_serState = SER_RECEIVING;
|
||
g_usbConnected = true;
|
||
Serial.println("READY");
|
||
}
|
||
#else
|
||
// Track 0 resets flash allocation
|
||
if (g_serTrack == 0) g_flashNextFree = AUDIO_START_ADDR;
|
||
g_serFlashCurAddr = g_flashNextFree;
|
||
g_trackStart[g_serTrack] = g_serFlashCurAddr;
|
||
|
||
// Erase first sector now; subsequent sectors erased lazily during receive
|
||
uint32_t firstSector = (g_serFlashCurAddr / FLASH_SECTOR) * FLASH_SECTOR;
|
||
flashEraseSector(firstSector);
|
||
g_serFlashErasedThru = firstSector + FLASH_SECTOR - 1;
|
||
|
||
g_serState = SER_RECEIVING;
|
||
g_usbConnected = true;
|
||
Serial.println("READY");
|
||
#endif
|
||
} else if (sscanf(g_serLineBuf, "DUMP %u", &utrk) == 1) {
|
||
#ifdef POC_INTERNAL_FLASH
|
||
char fname[16];
|
||
pocFilename((uint8_t)utrk, fname);
|
||
File df(InternalFS);
|
||
if (df.open(fname, FILE_O_READ)) {
|
||
uint32_t fsz = df.size();
|
||
Serial.print("SIZE "); Serial.println(fsz);
|
||
uint8_t dbuf[16];
|
||
uint32_t limit = min(fsz, (uint32_t)256);
|
||
uint32_t off = 0;
|
||
while (off < limit) {
|
||
int rd = df.read(dbuf, min((uint32_t)sizeof(dbuf), limit - off));
|
||
if (rd <= 0) break;
|
||
for (int j = 0; j < rd; j++) {
|
||
if (dbuf[j] < 0x10) Serial.print("0");
|
||
Serial.print(dbuf[j], HEX);
|
||
Serial.print(j % 16 == 15 || (off + j + 1) == limit ? "\n" : " ");
|
||
}
|
||
off += rd;
|
||
}
|
||
df.close();
|
||
Serial.println("END");
|
||
} else {
|
||
Serial.println("NO FILE");
|
||
}
|
||
#else
|
||
if ((uint8_t)utrk < g_numTracks) {
|
||
Serial.print("SIZE "); Serial.println(g_trackLen[utrk]);
|
||
uint8_t dbuf[16];
|
||
uint32_t limit = min(g_trackLen[utrk], (uint32_t)256);
|
||
for (uint32_t off = 0; off < limit; ) {
|
||
uint32_t rd = min((uint32_t)sizeof(dbuf), limit - off);
|
||
flashReadBytes(g_trackStart[utrk] + off, dbuf, rd);
|
||
for (uint32_t j = 0; j < rd; j++) {
|
||
if (dbuf[j] < 0x10) Serial.print("0");
|
||
Serial.print(dbuf[j], HEX);
|
||
Serial.print(j % 16 == 15 || (off + j + 1) == limit ? "\n" : " ");
|
||
}
|
||
off += rd;
|
||
}
|
||
Serial.println("END");
|
||
} else {
|
||
Serial.println("NO FILE");
|
||
}
|
||
#endif
|
||
} else if (g_serLineLen == 1 && (g_serLineBuf[0] == 'u' || g_serLineBuf[0] == 'd')) {
|
||
// Nudge gain by 0.125 (32 in Q8), clamped to [GAIN_MIN, GAIN_MAX].
|
||
if (g_serLineBuf[0] == 'u') {
|
||
g_audioGainQ8 = (g_audioGainQ8 + 32 > GAIN_MAX) ? GAIN_MAX : g_audioGainQ8 + 32;
|
||
} else {
|
||
g_audioGainQ8 = (g_audioGainQ8 < GAIN_MIN + 32) ? GAIN_MIN : g_audioGainQ8 - 32;
|
||
}
|
||
Serial.print("GAIN "); Serial.print((g_audioGainQ8 * 100) / GAIN_UNITY);
|
||
Serial.println("%");
|
||
} else if (sscanf(g_serLineBuf, "VOL %u", &utrk) == 1) {
|
||
// Set absolute volume as a percent of unity (0–400%).
|
||
uint32_t q = ((uint32_t)utrk * GAIN_UNITY) / 100u;
|
||
if (q < GAIN_MIN) q = GAIN_MIN;
|
||
if (q > GAIN_MAX) q = GAIN_MAX;
|
||
g_audioGainQ8 = (uint16_t)q;
|
||
Serial.print("GAIN "); Serial.print((g_audioGainQ8 * 100) / GAIN_UNITY);
|
||
Serial.println("%");
|
||
} else if (g_serLineLen == 1 && g_serLineBuf[0] == 'p') {
|
||
if (g_playing) { audioStop(); Serial.println("STOP"); }
|
||
else if (g_numTracks > 0) { audioStart(g_currentTrack); Serial.println("PLAY"); }
|
||
else Serial.println("ERR no tracks");
|
||
} else if (g_serLineLen == 1 && g_serLineBuf[0] == 'l') {
|
||
g_loopTracks = !g_loopTracks;
|
||
Serial.print("LOOP "); Serial.println(g_loopTracks ? "ON" : "OFF");
|
||
} else if (g_serLineLen == 1 && g_serLineBuf[0] == 'r') {
|
||
Serial.println("REBOOT");
|
||
delay(10);
|
||
NVIC_SystemReset();
|
||
} else if (strcmp(g_serLineBuf, "FORMAT") == 0) {
|
||
audioStop();
|
||
#ifdef POC_INTERNAL_FLASH
|
||
Serial.println("Formatting LittleFS...");
|
||
InternalFS.format();
|
||
g_numTracks = 0;
|
||
#else
|
||
Serial.println("Clearing track table...");
|
||
g_numTracks = 0;
|
||
g_flashNextFree = AUDIO_START_ADDR;
|
||
writeTrackTable();
|
||
loadTrackTable();
|
||
#endif
|
||
Serial.println("FORMAT OK");
|
||
} else if (sscanf(g_serLineBuf, "DELETE %u", &utrk) == 1) {
|
||
#ifdef POC_INTERNAL_FLASH
|
||
char fname[16];
|
||
pocFilename((uint8_t)utrk, fname);
|
||
if (InternalFS.remove(fname)) {
|
||
Serial.print("DELETED "); Serial.println(utrk);
|
||
} else {
|
||
Serial.print("ERR no file "); Serial.println(utrk);
|
||
}
|
||
loadTrackTable();
|
||
#else
|
||
if ((uint8_t)utrk < g_numTracks) {
|
||
for (uint8_t j = (uint8_t)utrk; j < g_numTracks - 1; j++) {
|
||
g_trackStart[j] = g_trackStart[j+1];
|
||
g_trackLen[j] = g_trackLen[j+1];
|
||
g_trackBits[j] = g_trackBits[j+1];
|
||
g_trackRate[j] = g_trackRate[j+1];
|
||
g_trackDataOff[j] = g_trackDataOff[j+1];
|
||
}
|
||
g_numTracks--;
|
||
writeTrackTable();
|
||
loadTrackTable();
|
||
Serial.print("DELETED "); Serial.println(utrk);
|
||
} else {
|
||
Serial.print("ERR no track "); Serial.println(utrk);
|
||
}
|
||
#endif
|
||
} else {
|
||
Serial.print("ERR bad cmd: ");
|
||
Serial.println(g_serLineBuf);
|
||
}
|
||
g_serLineLen = 0;
|
||
} else {
|
||
if (g_serLineLen < (sizeof(g_serLineBuf) - 1)) {
|
||
g_serLineBuf[g_serLineLen++] = c;
|
||
}
|
||
}
|
||
}
|
||
} else { // SER_RECEIVING
|
||
g_lastActivity = millis(); // keep device awake during long transfers
|
||
uint8_t chunk[64];
|
||
while (Serial.available() && g_serBytesReceived < g_serBytesExpected) {
|
||
int n = Serial.readBytes(chunk, min((int)sizeof(chunk),
|
||
(int)(g_serBytesExpected - g_serBytesReceived)));
|
||
if (n <= 0) break;
|
||
|
||
#ifdef POC_INTERNAL_FLASH
|
||
int32_t wr = g_serFile.write(chunk, (uint16_t)n);
|
||
if (wr != n) {
|
||
g_serFile.close();
|
||
g_usbConnected = false;
|
||
g_serState = SER_IDLE;
|
||
Serial.print("ERR WRITE_FAIL at=");
|
||
Serial.println(g_serBytesReceived);
|
||
break;
|
||
}
|
||
#else
|
||
// Lazily erase the next sector as we reach it
|
||
uint32_t chunkEnd = g_serFlashCurAddr + (uint32_t)n - 1;
|
||
if (chunkEnd > g_serFlashErasedThru) {
|
||
uint32_t nextSector = (g_serFlashErasedThru + 1) / FLASH_SECTOR * FLASH_SECTOR;
|
||
flashEraseSector(nextSector);
|
||
g_serFlashErasedThru = nextSector + FLASH_SECTOR - 1;
|
||
}
|
||
// Write to flash in page-aligned chunks
|
||
uint16_t written = 0;
|
||
while (written < (uint16_t)n) {
|
||
uint16_t pageOff = (uint16_t)((g_serFlashCurAddr + written) % FLASH_PAGE);
|
||
uint16_t pageChunk = min((uint16_t)(FLASH_PAGE - pageOff), (uint16_t)(n - written));
|
||
flashPageProgram(g_serFlashCurAddr + written, chunk + written, pageChunk);
|
||
written += pageChunk;
|
||
}
|
||
g_serFlashCurAddr += (uint32_t)n;
|
||
#endif
|
||
g_serBytesReceived += (uint32_t)n;
|
||
}
|
||
|
||
if (g_serState != SER_RECEIVING) return;
|
||
|
||
if (g_serBytesReceived >= g_serBytesExpected) {
|
||
#ifdef POC_INTERNAL_FLASH
|
||
g_serFile.close();
|
||
char fname2[16]; pocFilename(g_serTrack, fname2);
|
||
File tmp(InternalFS);
|
||
uint32_t fsz = 0;
|
||
if (tmp.open(fname2, FILE_O_READ)) { fsz = tmp.size(); tmp.close(); }
|
||
g_usbConnected = false;
|
||
g_serState = SER_IDLE;
|
||
loadTrackTable();
|
||
Serial.print("OK "); Serial.print(fsz);
|
||
Serial.print("/"); Serial.println(g_serBytesReceived);
|
||
#else
|
||
// Advance shared free pointer to next sector boundary
|
||
g_flashNextFree = ((g_serFlashCurAddr + FLASH_SECTOR - 1) / FLASH_SECTOR) * FLASH_SECTOR;
|
||
g_trackLen[g_serTrack] = g_serBytesReceived;
|
||
if (g_serTrack >= g_numTracks) g_numTracks = g_serTrack + 1;
|
||
writeTrackTable();
|
||
loadTrackTable();
|
||
g_usbConnected = false;
|
||
g_serState = SER_IDLE;
|
||
Serial.print("OK "); Serial.print(g_serBytesReceived);
|
||
Serial.print("/"); Serial.println(g_serBytesExpected);
|
||
#endif
|
||
}
|
||
}
|
||
}
|
||
|
||
void loop() {
|
||
if (g_playing || g_bleUploading) g_lastActivity = millis();
|
||
|
||
// Print RESETREAS once, 10 s after boot, so serial has time to reconnect
|
||
// after a wake-from-sleep reset.
|
||
static bool resetreasLogged = false;
|
||
if (!resetreasLogged && millis() > 10000) {
|
||
resetreasLogged = true;
|
||
Serial.print("RESETREAS: 0x"); Serial.println(g_debugResetreas, HEX);
|
||
Serial.print("g_wakeFromSleep: "); Serial.println(g_wakeFromSleep);
|
||
Serial.print("g_numTracks: "); Serial.println(g_numTracks);
|
||
}
|
||
|
||
// ---- Serial upload ----
|
||
serUploadTick();
|
||
|
||
#ifndef POC_INTERNAL_FLASH
|
||
// ---- BLE flash drain ----
|
||
bleFlashTick();
|
||
#endif
|
||
|
||
// ---- Refill audio buffers ----
|
||
if (g_playing) {
|
||
for (uint8_t b = 0; b < 2; b++) {
|
||
if (!g_bufReady[b]) audioFillBuf(b);
|
||
}
|
||
}
|
||
|
||
// ---- End of track: timer-based stop ----
|
||
// g_trackDoneMs is armed 100ms after first silence fill.
|
||
// By then both DMA buffers have definitely cycled through silence.
|
||
if (g_playing && g_trackDoneMs != 0 && millis() >= g_trackDoneMs) {
|
||
g_trackDoneMs = 0;
|
||
audioStop();
|
||
if (g_loopTracks && g_numTracks > 0) {
|
||
g_currentTrack = (g_currentTrack + 1) % g_numTracks;
|
||
audioStart(g_currentTrack);
|
||
}
|
||
}
|
||
|
||
// ---- Handle buttons ----
|
||
// Per-button cooldown prevents phantom/stuck pins from re-firing
|
||
static unsigned long btnLastMs[NUM_BUTTONS + 1] = {0};
|
||
uint8_t btn = buttonRead();
|
||
if (btn > 0 && millis() - btnLastMs[btn] > 800) {
|
||
btnLastMs[btn] = millis();
|
||
Serial.print("BTN"); Serial.println(btn);
|
||
g_lastActivity = millis();
|
||
|
||
switch (btn) {
|
||
case 1: { // Short press: Play/Pause. Hold 5 s: start BLE advertising.
|
||
unsigned long pressStart = millis();
|
||
bool longPress = false;
|
||
while (buttonRead() == 1) {
|
||
if (millis() - pressStart >= 5000UL) { longPress = true; break; }
|
||
if (g_playing) {
|
||
for (uint8_t b = 0; b < 2; b++) if (!g_bufReady[b]) audioFillBuf(b);
|
||
if (g_trackDoneMs != 0 && millis() >= g_trackDoneMs) {
|
||
g_trackDoneMs = 0; audioStop();
|
||
}
|
||
} else {
|
||
delay(1); // yield to RTOS idle task so the watchdog gets fed
|
||
}
|
||
}
|
||
if (longPress) {
|
||
startBLEAdvertising();
|
||
waitButtonRelease(1);
|
||
} else {
|
||
#ifdef BTN_ALWAYS_START
|
||
if (g_numTracks > 0) {
|
||
if (g_playing) audioStop();
|
||
#ifdef WAKE_PLAY_RANDOM
|
||
audioStart((uint8_t)random(g_numTracks));
|
||
#else
|
||
audioStart((g_currentTrack + 1) % g_numTracks);
|
||
#endif
|
||
}
|
||
#else
|
||
if (g_playing) { audioStop(); }
|
||
else if (g_numTracks > 0) { audioStart(g_currentTrack); }
|
||
#endif
|
||
}
|
||
break;
|
||
}
|
||
case 2:
|
||
case 3:
|
||
case 4:
|
||
case 5:
|
||
#ifdef BTN_ALWAYS_START
|
||
if (g_numTracks > 0) {
|
||
if (g_playing) audioStop();
|
||
#ifdef WAKE_PLAY_RANDOM
|
||
audioStart((uint8_t)random(g_numTracks));
|
||
#else
|
||
audioStart((g_currentTrack + 1) % g_numTracks);
|
||
#endif
|
||
}
|
||
#else
|
||
if (g_playing) {
|
||
audioStop();
|
||
} else if (g_numTracks > 0) {
|
||
audioStart(g_currentTrack);
|
||
}
|
||
#endif
|
||
break;
|
||
/*
|
||
case 4: // Next track
|
||
if (g_numTracks > 0) {
|
||
g_currentTrack = (g_currentTrack + 1) % g_numTracks;
|
||
if (g_playing) { audioStop(); audioStart(g_currentTrack); }
|
||
}
|
||
break;
|
||
|
||
case 5: // Previous track
|
||
if (g_numTracks > 0) {
|
||
g_currentTrack = (g_currentTrack == 0) ? g_numTracks - 1 : g_currentTrack - 1;
|
||
if (g_playing) { audioStop(); audioStart(g_currentTrack); }
|
||
}
|
||
break;
|
||
*/
|
||
case 6: // Motor toggle
|
||
if (g_motorOn) motorStop(); else motorStart(g_motorSpeed);
|
||
break;
|
||
|
||
case 7: // Motor faster
|
||
if (g_motorSpeed < 240) g_motorSpeed += 20;
|
||
if (g_motorOn) analogWrite(PIN_MOTOR_PWM, g_motorSpeed);
|
||
break;
|
||
|
||
case 8: // Motor slower
|
||
if (g_motorSpeed > 60) g_motorSpeed -= 20;
|
||
if (g_motorOn) analogWrite(PIN_MOTOR_PWM, g_motorSpeed);
|
||
break;
|
||
|
||
case 9: // Play all (music + motor)
|
||
motorStart(g_motorSpeed);
|
||
if (!g_playing && g_numTracks > 0) audioStart(0);
|
||
break;
|
||
|
||
case 10: // Stop all
|
||
audioStop();
|
||
motorStop();
|
||
break;
|
||
}
|
||
|
||
waitButtonRelease(btn);
|
||
}
|
||
|
||
// ---- Auto shutoff / idle sleep ----
|
||
// Never sleep while BLE is connected — upload may be in progress or about
|
||
// to begin, and the CMD callback sets g_bleUploading asynchronously.
|
||
if (!g_bleConnected) {
|
||
if (millis() - g_lastActivity > AUTO_OFF_MS) {
|
||
enterDeepSleep();
|
||
}
|
||
|
||
if (!g_playing && !g_motorOn && !g_usbConnected) {
|
||
if (millis() - g_lastActivity > IDLE_SLEEP_MS) {
|
||
enterDeepSleep();
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|