Files
baby-mobile/app/ble.js
T

343 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
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.writeValueWithoutResponse(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 (0255 % 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 sequentially. Chrome on Windows (WinRT BLE) only
// allows one writeValueWithoutResponse in flight at a time; concurrent calls
// fail with "GATT operation already in progress". Sequential writes are
// reliable and throughput is governed by the BLE connection interval, not
// by how many calls are in flight.
for (let off = 0; off < total; off += DATA_CHUNK) {
await this.data.writeValueWithoutResponse(
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)));
}
// 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 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);
});
}
}