// 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; // 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] // 0xFF → list end: [0xFF, num_tracks, 0, 0] // 0xD0 → delete reply: [0xD0, idx, success, 0] const TAG_LIST_END = 0xff; const TAG_DELETE = 0xd0; export const MAX_TRACKS = 32; // 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() { 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_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'); } // 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(sent, total)` is called as // the firmware acknowledges bytes via the status characteristic. async upload(track, wavBytes, onProgress) { this._requireConnected(); const total = wavBytes.byteLength; this._statBytes = 0; let acked = 0; const progressHandler = (ev) => { acked = ev.detail.bytes; if (onProgress) onProgress(Math.min(acked, total), total); }; 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. 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(off + chunk.length, total); } // 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.`); } 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 tick = () => { if (this._statBytes >= target || Date.now() > deadline || !this.connected) { this.removeEventListener('progress', tick); resolve(); } }; this.addEventListener('progress', tick); // Fallback poll in case no further notifications arrive. const poll = setInterval(() => { if (this._statBytes >= target || Date.now() > deadline || !this.connected) { clearInterval(poll); this.removeEventListener('progress', tick); resolve(); } }, 200); }); } }