// 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; // audioCmd.setMaxLen(240) / audioData.setMaxLen(240) on the firmware. We chunk the // data characteristic conservatively so it fits even a modest negotiated ATT MTU. 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); } 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.'); } 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); 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); await this.stat.startNotifications(); this.stat.addEventListener('characteristicvaluechanged', this._onStat); this._log('Connected.'); this._emit('connected', { name: this.device.name }); return this.device.name; } async disconnect() { if (this.device && this.device.gatt && this.device.gatt.connected) { this.device.gatt.disconnect(); } } _onDisconnect() { this._log('Disconnected.'); 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]); // writeWithoutResponse: await each chunk so the browser's queue provides // back-pressure. The firmware writes each packet to LittleFS synchronously. 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); } // Wait until the device's acknowledged byte count catches up (or times out). await this._waitForAck(total, 8000); await this._writeCmd([CMD_UPLOAD_END]); 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); }); } }