Files
baby-mobile/app/ble.js
T
2026-07-04 03:28:14 -07:00

323 lines
12 KiB
JavaScript

// ble.js — Web Bluetooth client for the BabyMobile XIAO firmware.
//
// Protocol (see baby_mobile_v2.ino):
// Service 12340001-…
// Cmd char 12340002 (write): 0x02 <track> start upload to slot
// 0x03 finish upload + reload tracks
// 0x04 <track> 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;
// 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_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(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 in pipelined batches. Chrome on Windows serialises
// individually-awaited writeValueWithoutResponse calls (one Promise per OS
// event loop tick), which limits throughput to ~one packet per ~50 ms and
// turns a 1.4 MB upload into a 6-minute ordeal. Firing PIPE writes before
// awaiting them all fills multiple BLE connection events per await, saturating
// the link. The firmware's 32 KB ring buffer absorbs bursts comfortably.
const PIPE = 6; // packets in flight before each await (~1 KB per batch)
for (let off = 0; off < total; off += DATA_CHUNK * PIPE) {
const batch = [];
for (let i = 0; i < PIPE && off + i * DATA_CHUNK < total; i++) {
const s = off + i * DATA_CHUNK;
batch.push(this.data.writeValueWithoutResponse(
wavBytes.subarray(s, Math.min(s + DATA_CHUNK, total))));
}
await Promise.all(batch);
// 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 * PIPE, Math.floor(total * 0.99)));
}
// 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. 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 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);
});
}
}