// ble.js — Web Bluetooth client for the BabyMobile XIAO firmware. // // Protocol (see baby_mobile_v2.ino): // Service 12340001-… // Cmd char 12340002 (write): 0x02 start upload to slot // 0x03 finish upload + reload tracks // 0x04 play slot // 0x05 stop playback // Data char 12340003 (write-w/o-resp): raw WAV bytes (header included) // Stat char 12340004 (read/notify): uint32 BE = cumulative bytes written // // The firmware stores the WAV intact and parses bit-depth / sample-rate from the // header at load time, so we just stream a conformant WAV file as-is. export const SVC_UUID = '12340001-0000-1000-8000-00805f9b34fb'; export const CMD_UUID = '12340002-0000-1000-8000-00805f9b34fb'; export const DATA_UUID = '12340003-0000-1000-8000-00805f9b34fb'; export const STAT_UUID = '12340004-0000-1000-8000-00805f9b34fb'; export const CMD_UPLOAD_START = 0x02; export const CMD_UPLOAD_END = 0x03; export const CMD_PLAY = 0x04; export const CMD_STOP = 0x05; export const CMD_LIST = 0x06; export const CMD_DELETE = 0x07; export const CMD_SET_VOL = 0x08; export const CMD_GET_VOL = 0x09; // Status-notification tags in byte 0 (see firmware): // 0x00 → upload progress: whole packet is uint32 BE byte count (files <16 MB) // 0x80|idx → list entry: [0x80|idx, bits, rate_kHz, duration_s] // 0xC0 → volume reply: [0xC0, percent, 0, 0] // 0xD0 → delete reply: [0xD0, idx, success, 0] // 0xFF → list end: [0xFF, num_tracks, 0, 0] const TAG_LIST_END = 0xff; const TAG_DELETE = 0xd0; const TAG_VOLUME = 0xc0; export const MAX_TRACKS = 32; // ATT MTU is negotiated to 247 (firmware requests via requestMtuExchange(247)). // Write-without-response payload = MTU - 3 = 244 bytes. const DATA_CHUNK = 244; export function isSupported() { return typeof navigator !== 'undefined' && !!navigator.bluetooth; } export class BabyMobile extends EventTarget { constructor() { super(); this.device = null; this.server = null; this.cmd = null; this.data = null; this.stat = null; this._statBytes = 0; this._onStat = this._onStat.bind(this); this._onDisconnect = this._onDisconnect.bind(this); this._connecting = false; } get connected() { return !!(this.device && this.device.gatt && this.device.gatt.connected); } _log(msg) { this.dispatchEvent(new CustomEvent('log', { detail: msg })); } _emit(type, detail) { this.dispatchEvent(new CustomEvent(type, { detail })); } async connect() { 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.'); } 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('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._connectGatt(3); this._log('Connected.'); this._emit('connected', { name: this.device.name }); return this.device.name; } finally { this._connecting = false; } } // 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() { if (this.device && this.device.gatt && this.device.gatt.connected) { this.device.gatt.disconnect(); } } // 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', {}); } _onStat(ev) { const dv = ev.target.value; if (dv.byteLength < 1) return; const tag = dv.getUint8(0); if (tag === TAG_LIST_END && dv.byteLength >= 2) { this._emit('list-end', { numTracks: dv.getUint8(1) }); } else if (tag === TAG_VOLUME && dv.byteLength >= 2) { this._emit('volume', { percent: dv.getUint8(1) }); } else if (tag === TAG_DELETE && dv.byteLength >= 3) { this._emit('deleted', { idx: dv.getUint8(1), success: dv.getUint8(2) === 1 }); } else if ((tag & 0x80) && (tag & 0x7f) < MAX_TRACKS && dv.byteLength >= 4) { this._emit('list-entry', { idx: tag & 0x7f, bits: dv.getUint8(1), rate: dv.getUint8(2) * 1000, durationSec: dv.getUint8(3), }); } else if (dv.byteLength >= 4) { this._statBytes = dv.getUint32(0, false); // big-endian byte count this._emit('progress', { bytes: this._statBytes }); } } _requireConnected() { if (!this.connected) throw new Error('Not connected.'); } async _writeCmd(bytes) { this._requireConnected(); await this.cmd.writeValue(Uint8Array.from(bytes)); } async play(track) { await this._writeCmd([CMD_PLAY, track & 0xff]); this._log(`▶ Play slot ${track}`); } async stop() { await this._writeCmd([CMD_STOP]); this._log('⏹ Stop'); } // Set the persistent global volume (0–255 % of unity). The device echoes the // applied value via a 'volume' event. async setVolume(percent) { const p = Math.max(0, Math.min(255, Math.round(percent))); await this._writeCmd([CMD_SET_VOL, p]); } // Ask the device for its current global volume; resolves to the percent. async getVolume(timeoutMs = 3000) { this._requireConnected(); return new Promise((resolve, reject) => { const onVol = (e) => { cleanup(); resolve(e.detail.percent); }; const cleanup = () => { clearTimeout(timer); this.removeEventListener('volume', onVol); }; const timer = setTimeout(() => { cleanup(); reject(new Error('Volume read timed out')); }, timeoutMs); this.addEventListener('volume', onVol); this._writeCmd([CMD_GET_VOL]).catch((err) => { cleanup(); reject(err); }); }); } // Returns [{ idx, bits, rate, durationSec }], sorted by slot. async listTracks(timeoutMs = 4000) { this._requireConnected(); return new Promise((resolve, reject) => { const entries = []; const onEntry = (e) => entries.push(e.detail); const finish = () => { cleanup(); resolve(entries.sort((a, b) => a.idx - b.idx)); }; const cleanup = () => { clearTimeout(timer); this.removeEventListener('list-entry', onEntry); this.removeEventListener('list-end', finish); }; const timer = setTimeout(finish, timeoutMs); // resolve with whatever arrived this.addEventListener('list-entry', onEntry); this.addEventListener('list-end', finish); this._writeCmd([CMD_LIST]).catch((err) => { cleanup(); reject(err); }); }); } // Resolves { idx, success }. async deleteTrack(track, timeoutMs = 4000) { this._requireConnected(); return new Promise((resolve, reject) => { const onDel = (e) => { if (e.detail.idx !== (track & 0xff)) return; // ignore replies for other slots cleanup(); resolve(e.detail); }; const cleanup = () => { clearTimeout(timer); this.removeEventListener('deleted', onDel); }; const timer = setTimeout(() => { cleanup(); reject(new Error('Delete timed out')); }, timeoutMs); this.addEventListener('deleted', onDel); this._writeCmd([CMD_DELETE, track & 0xff]).catch((err) => { cleanup(); reject(err); }); }); } // Stream a complete WAV file to a slot. `onProgress(shown, total)` reports a // single monotonic value combining the locally-sent count and the device's // acknowledged count. async upload(track, wavBytes, onProgress) { this._requireConnected(); const total = wavBytes.byteLength; this._statBytes = 0; let acked = 0; // The optimistic local "sent" count races ahead of the device's ack // notifications, and the two arrive interleaved (and acks can lag badly over // a slow mobile link). Reporting both directly makes the bar jump forward then // snap backward. Clamp to a high-water mark so progress only ever advances. let shown = 0; const report = (n) => { const v = Math.min(Math.max(0, n), total); if (v > shown) { shown = v; if (onProgress) onProgress(shown, total); } }; const progressHandler = (ev) => { acked = ev.detail.bytes; report(acked); }; this.addEventListener('progress', progressHandler); try { this._log(`Starting upload of ${total} bytes to slot ${track}…`); await this._writeCmd([CMD_UPLOAD_START, track & 0xff]); // Send all data chunks using write-with-response. Each writeValue() call // blocks until the SoftDevice sends an ATT Write Response, which only happens // after it has delivered the packet to the firmware's write callback. This // provides natural per-packet flow control: CMD_UPLOAD_END cannot be sent until // every data packet has been confirmed received, eliminating all OS TX-queue // race conditions without any byte-counting hacks. for (let off = 0; off < total; off += DATA_CHUNK) { await this.data.writeValue( wavBytes.subarray(off, Math.min(off + DATA_CHUNK, total))); // Cap the optimistic local count at 99%; the final 1% is filled only when // the device's finalize ack confirms the bytes actually landed in flash. report(Math.min(off + DATA_CHUNK, Math.floor(total * 0.99))); } // All data confirmed received — finalize. await this._writeCmd([CMD_UPLOAD_END]); // Wait for that single finalize notification. Allow up to 60 s: at 300 ms/sector // worst-case, a 1.4 MB file has ~350 sectors = 105 s of erase time. In practice // most sectors erase in ~30 ms so finalization completes in well under 60 s. await this._waitForAck(total, 60000); const ok = acked >= total; if (ok) { this._log(`✅ Upload complete: ${acked}/${total} bytes acknowledged.`); } else { this._log(`⚠ Upload finished but device acknowledged only ${acked}/${total} bytes (flash full?).`); } this._emit('uploaded', { track, total, acked, ok }); return { total, acked, ok }; } finally { this.removeEventListener('progress', progressHandler); } } _waitForAck(target, timeoutMs) { return new Promise((resolve) => { if (this._statBytes >= target) return resolve(); const deadline = Date.now() + timeoutMs; const cleanup = () => { clearInterval(poll); this.removeEventListener('progress', tick); this.removeEventListener('disconnected', onDisconnect); }; const tick = () => { if (this._statBytes >= target || Date.now() > deadline) { cleanup(); resolve(); } }; // Resolve on a real disconnect rather than polling .connected, which can // return false momentarily on some BLE stacks even when still connected. const onDisconnect = () => { cleanup(); resolve(); }; this.addEventListener('progress', tick); this.addEventListener('disconnected', onDisconnect); // Fallback poll for timeout and when no further notifications arrive. const poll = setInterval(() => { if (this._statBytes >= target || Date.now() > deadline) { cleanup(); resolve(); } }, 200); }); } }