diff --git a/app/README.md b/app/README.md index 85e1177..1bbbd03 100644 --- a/app/README.md +++ b/app/README.md @@ -75,3 +75,13 @@ and reuse `ble.js`'s protocol constants. local name just shows "Track N". - **MTU:** data is chunked to 180 bytes to stay within a modest negotiated ATT MTU (firmware char max is 240). +- **Name shows as "BabyM" (truncated):** the 128-bit service UUID fills the 31-byte + advertisement, leaving room for only 5 name characters. The browser can't read + the full GAP name (that service is blocklisted in Web Bluetooth). Fix it in the + firmware by moving the name to the scan response, which has its own 31 bytes: + replace `Bluefruit.Advertising.addName();` with `Bluefruit.ScanResponse.addName();`. +- **Reconnecting after a disconnect:** a just-disconnected peripheral leaves the + link half-open briefly, so the first reconnect could fail until a page refresh. + `connect()` now retries (3×, 600 ms backoff) after an explicit disconnect, which + recovers without refreshing. Scanning matches on the service UUID first, so a + truncated advertised name no longer breaks the name filter. diff --git a/app/ble.js b/app/ble.js index 1feb3c3..01109e1 100644 --- a/app/ble.js +++ b/app/ble.js @@ -34,8 +34,8 @@ const TAG_DELETE = 0xd0; export const MAX_TRACKS = 32; -// audioCmd.setMaxLen(240) / audioData.setMaxLen(240) on the firmware. We chunk the -// data characteristic conservatively so it fits even a modest negotiated ATT MTU. +// Keep below ATT MTU - 3. The firmware calls configPrphBandwidth(BANDWIDTH_MAX) +// so Chrome typically negotiates MTU 247 (244-byte payload). 180 stays well clear. const DATA_CHUNK = 180; export function isSupported() { @@ -53,6 +53,7 @@ export class BabyMobile extends EventTarget { this._statBytes = 0; this._onStat = this._onStat.bind(this); this._onDisconnect = this._onDisconnect.bind(this); + this._connecting = false; } get connected() { @@ -71,26 +72,57 @@ export class BabyMobile extends EventTarget { if (!isSupported()) { throw new Error('Web Bluetooth is not available in this browser. On iOS use the Bluefy browser; on desktop/Android use Chrome or Edge.'); } - this._log('Requesting device…'); - this.device = await navigator.bluetooth.requestDevice({ - filters: [{ namePrefix: 'BabyMobile' }, { services: [SVC_UUID] }], - optionalServices: [SVC_UUID], - }); - this.device.addEventListener('gattserverdisconnected', this._onDisconnect); + if (this._connecting) throw new Error('Already connecting…'); + this._connecting = true; + try { + // Tear down any stale device/handle from a previous session so reconnects + // don't trip over a half-open GATT or duplicate event listeners. + this._teardown(); - this._log(`Connecting to ${this.device.name || 'device'}…`); - this.server = await this.device.gatt.connect(); - const svc = await this.server.getPrimaryService(SVC_UUID); - this.cmd = await svc.getCharacteristic(CMD_UUID); - this.data = await svc.getCharacteristic(DATA_UUID); - this.stat = await svc.getCharacteristic(STAT_UUID); + this._log('Requesting device…'); + // Primary match is the 128-bit service UUID (rock solid). The name filter is + // a loose prefix because the advertised name may be radio-truncated. + this.device = await navigator.bluetooth.requestDevice({ + filters: [{ services: [SVC_UUID] }, { namePrefix: 'Baby' }], + optionalServices: [SVC_UUID], + }); + this.device.addEventListener('gattserverdisconnected', this._onDisconnect); - await this.stat.startNotifications(); - this.stat.addEventListener('characteristicvaluechanged', this._onStat); + await this._connectGatt(3); + this._log('Connected.'); + this._emit('connected', { name: this.device.name }); + return this.device.name; + } finally { + this._connecting = false; + } + } - this._log('Connected.'); - this._emit('connected', { name: this.device.name }); - return this.device.name; + // Connect + discover with retries. A freshly-disconnected peripheral often + // leaves the link half-open for a moment; the first attempt then fails with + // "Connection failed"/"GATT operation failed" until a browser refresh. Retrying + // after an explicit disconnect + short backoff recovers without a refresh. + async _connectGatt(attempts) { + let lastErr; + for (let i = 0; i < attempts; i++) { + try { + if (this.device.gatt.connected) this.device.gatt.disconnect(); + this._log(`Connecting${i ? ` (retry ${i})` : ''} to ${this.device.name || 'device'}…`); + this.server = await this.device.gatt.connect(); + const svc = await this.server.getPrimaryService(SVC_UUID); + this.cmd = await svc.getCharacteristic(CMD_UUID); + this.data = await svc.getCharacteristic(DATA_UUID); + this.stat = await svc.getCharacteristic(STAT_UUID); + await this.stat.startNotifications(); + this.stat.addEventListener('characteristicvaluechanged', this._onStat); + return; + } catch (err) { + lastErr = err; + this._log(`Connect attempt ${i + 1} failed: ${err.message || err}`); + try { this.device.gatt.disconnect(); } catch {} + if (i < attempts - 1) await new Promise((r) => setTimeout(r, 600)); + } + } + throw lastErr; } async disconnect() { @@ -99,8 +131,23 @@ export class BabyMobile extends EventTarget { } } + // Fully detach the current device + listeners (used before a fresh connect). + _teardown() { + if (this.stat) { + try { this.stat.removeEventListener('characteristicvaluechanged', this._onStat); } catch {} + } + if (this.device) { + try { this.device.removeEventListener('gattserverdisconnected', this._onDisconnect); } catch {} + try { if (this.device.gatt.connected) this.device.gatt.disconnect(); } catch {} + } + this.device = this.server = this.cmd = this.data = this.stat = null; + } + _onDisconnect() { this._log('Disconnected.'); + if (this.stat) { + try { this.stat.removeEventListener('characteristicvaluechanged', this._onStat); } catch {} + } this.server = this.cmd = this.data = this.stat = null; this._emit('disconnected', {}); } @@ -202,18 +249,22 @@ export class BabyMobile extends EventTarget { this._log(`Starting upload of ${total} bytes to slot ${track}…`); await this._writeCmd([CMD_UPLOAD_START, track & 0xff]); - // writeWithoutResponse: await each chunk so the browser's queue provides - // back-pressure. The firmware writes each packet to LittleFS synchronously. + // Send all data chunks. Progress is tracked client-side (bytes sent). + // The firmware queues bytes into a ring buffer; actual flash writes happen + // in the background. We do NOT poll per-packet notifications here — that + // caused BLE notification-buffer overflow for large files. for (let off = 0; off < total; off += DATA_CHUNK) { const chunk = wavBytes.subarray(off, Math.min(off + DATA_CHUNK, total)); await this.data.writeValueWithoutResponse(chunk); - if (onProgress) onProgress(Math.min(off + chunk.length, total), total); + if (onProgress) onProgress(off + chunk.length, total); } - // Wait until the device's acknowledged byte count catches up (or times out). - await this._waitForAck(total, 8000); - + // Tell the firmware we're done. It will drain its ring buffer, write the + // track table, then send ONE finalize notification with the total byte count. await this._writeCmd([CMD_UPLOAD_END]); + + // Wait for that single finalize notification (up to 20 s to allow flash writes). + await this._waitForAck(total, 20000); const ok = acked >= total; if (ok) { this._log(`✅ Upload complete: ${acked}/${total} bytes acknowledged.`); diff --git a/app/sw.js b/app/sw.js index 037d89d..a92e1cb 100644 --- a/app/sw.js +++ b/app/sw.js @@ -1,5 +1,5 @@ // sw.js — minimal offline cache so the PWA launches without a network. -const CACHE = 'babymobile-v2'; +const CACHE = 'babymobile-v3'; const ASSETS = [ './', './index.html', './styles.css', './app.js', './ble.js', './wav.js', diff --git a/baby_mobile_v2/baby_mobile_v2.ino b/baby_mobile_v2/baby_mobile_v2.ino index adc5322..77cba04 100644 --- a/baby_mobile_v2/baby_mobile_v2.ino +++ b/baby_mobile_v2/baby_mobile_v2.ino @@ -185,12 +185,30 @@ BLECharacteristic audioStat = BLECharacteristic("12340004-0000-1000-8000-00805f9 // BLE upload state volatile bool g_bleUploading = false; -volatile uint32_t g_bleWriteAddr = 0; -volatile uint32_t g_bleWriteLen = 0; +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 (especially sector erase ~100ms) +// happens in loop() so the SoftDevice receive buffer never stalls. +// Size: IS25LP128F sector erase takes up to 300 ms; at ~56 KB/s upload rate +// that is ~17 KB of incoming data. 32 KB covers it with margin. +#define BLE_FLASH_BUF 32768u +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; // end of last erased sector +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 // ============================================================ @@ -439,6 +457,17 @@ void loadTrackTable() { 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 } @@ -593,13 +622,21 @@ void audioCmd_write_cb(uint16_t conn_handle, BLECharacteristic* chr, Serial.println(g_pocWriteTrack); } #else - if (len >= 4) { - g_bleWriteAddr = ((uint32_t)data[1] << 16) | ((uint32_t)data[2] << 8) | data[3]; - g_bleUploading = true; - // Erase 64K block at target - flashEraseBlock64K(g_bleWriteAddr & ~(FLASH_BLOCK_64K - 1)); - Serial.print("BLE: Start write at 0x"); - Serial.println(g_bleWriteAddr, HEX); + 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; // save start for finalize + g_bleFlashErased = g_bleFlashAddr; // nothing erased yet + 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; @@ -607,10 +644,17 @@ void audioCmd_write_cb(uint16_t conn_handle, BLECharacteristic* chr, case 0x03: // Finish upload #ifdef POC_INTERNAL_FLASH if (g_pocFile) g_pocFile.close(); -#endif g_bleUploading = false; loadTrackTable(); Serial.println("BLE: Upload complete"); +#else + // Signal loop() to finalize after ring buffer drains. + // Don't call loadTrackTable() here — we're in a BLE callback. + if (g_bleUploading) { + g_bleFinalizing = true; + Serial.println("BLE: Finalizing upload..."); + } +#endif break; case 0x04: // Play track @@ -630,7 +674,6 @@ void audioCmd_write_cb(uint16_t conn_handle, BLECharacteristic* chr, // 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). -#ifdef POC_INTERNAL_FLASH 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; @@ -652,19 +695,35 @@ void audioCmd_write_cb(uint16_t conn_handle, BLECharacteristic* chr, audioStat.notify(end, 4); } Serial.print("BLE: Listed "); Serial.print(g_numTracks); Serial.println(" tracks"); -#endif break; case 0x07: // Delete track // data[1] = track index to delete. // Responds via audioStat: [0xD0, track_idx, success(0/1), 0] -#ifdef POC_INTERNAL_FLASH if (len >= 2) { uint8_t trkNum = data[1]; + bool ok = false; +#ifdef POC_INTERNAL_FLASH char fname[16]; pocFilename(trkNum, fname); - bool ok = InternalFS.remove(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); @@ -672,7 +731,6 @@ void audioCmd_write_cb(uint16_t conn_handle, BLECharacteristic* chr, Serial.print(trkNum); Serial.println(ok ? " OK" : " FAILED"); } -#endif break; } } @@ -687,39 +745,88 @@ void audioData_write_cb(uint16_t conn_handle, BLECharacteristic* chr, g_pocFile.write(data, len); g_bleWriteLen += len; #else - // Erase new sectors as we cross boundaries - uint32_t endAddr = g_bleWriteAddr + len; - uint32_t currentSector = g_bleWriteAddr / FLASH_SECTOR; - uint32_t endSector = (endAddr - 1) / FLASH_SECTOR; - for (uint32_t s = currentSector + 1; s <= endSector; s++) { - flashEraseSector(s * FLASH_SECTOR); + // 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; } - - // Write data page by page - uint16_t written = 0; - while (written < len) { - uint16_t pageOff = (g_bleWriteAddr + written) % FLASH_PAGE; - uint16_t chunk = min((uint16_t)(FLASH_PAGE - pageOff), (uint16_t)(len - written)); - flashPageProgram(g_bleWriteAddr + written, data + written, chunk); - written += chunk; - } - - g_bleWriteAddr += len; g_bleWriteLen += len; #endif - - // Update status characteristic with bytes written - uint8_t stat[4]; - stat[0] = (g_bleWriteLen >> 24) & 0xFF; - stat[1] = (g_bleWriteLen >> 16) & 0xFF; - stat[2] = (g_bleWriteLen >> 8) & 0xFF; - stat[3] = g_bleWriteLen & 0xFF; - audioStat.write(stat, 4); - audioStat.notify(stat, 4); + // 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 one page at a time. +// Called from loop() to keep flash I/O off the BLE callback thread. +#ifndef POC_INTERNAL_FLASH +static void bleFlashTick() { + // Drain as much as we can without blocking too long + while (g_bleBufHead != g_bleBufTail) { + 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; + + // Lazy erase: erase the next sector just before we write into it + while (g_bleFlashAddr + chunk > g_bleFlashErased) { + flashEraseSector(g_bleFlashErased); + g_bleFlashErased += FLASH_SECTOR; + } + + // Copy chunk from ring buffer into a temp page buffer + 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); + g_bleBufTail = (g_bleBufTail + chunk) % BLE_FLASH_BUF; + g_bleFlashAddr += chunk; + + // Yield after each page so the rest of loop() stays responsive + break; + } + + // Finalize when CMD 0x03 received and ring buffer is empty + 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; + 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 @@ -1187,10 +1294,9 @@ static uint32_t g_serBytesReceived = 0; static File g_serFile(InternalFS); #else // SPI flash upload: tracks are packed sequentially starting at AUDIO_START_ADDR. -// Uploading track 0 resets the allocation pointer. -static uint32_t g_serFlashCurAddr = 0; // current write head -static uint32_t g_serFlashNextFree = AUDIO_START_ADDR; -static uint32_t g_serFlashErasedThru = 0; // highest erased byte address +// 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() { @@ -1221,8 +1327,8 @@ static void serUploadTick() { } #else // Track 0 resets flash allocation - if (g_serTrack == 0) g_serFlashNextFree = AUDIO_START_ADDR; - g_serFlashCurAddr = g_serFlashNextFree; + 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 @@ -1376,8 +1482,8 @@ static void serUploadTick() { Serial.print("OK "); Serial.print(fsz); Serial.print("/"); Serial.println(g_serBytesReceived); #else - // Advance free pointer to next sector boundary - g_serFlashNextFree = ((g_serFlashCurAddr + FLASH_SECTOR - 1) / FLASH_SECTOR) * FLASH_SECTOR; + // 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(); @@ -1397,6 +1503,11 @@ void loop() { // ---- 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++) {