add app, fix ble, use flash
This commit is contained in:
+251
@@ -0,0 +1,251 @@
|
||||
// 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;
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user