improved web ui, upload is sorta better, 5sec advertise btn

This commit is contained in:
zyphlar
2026-07-04 03:28:14 -07:00
parent 8cba579780
commit d3bd7b71b9
8 changed files with 458 additions and 79 deletions
+49 -29
View File
@@ -23,14 +23,18 @@ 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]
// 0xFFlist end: [0xFF, num_tracks, 0, 0]
// 0xC0volume 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;
@@ -159,6 +163,8 @@ export class BabyMobile extends EventTarget {
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) {
@@ -180,7 +186,7 @@ export class BabyMobile extends EventTarget {
async _writeCmd(bytes) {
this._requireConnected();
await this.cmd.writeValue(Uint8Array.from(bytes));
await this.cmd.writeValueWithoutResponse(Uint8Array.from(bytes));
}
async play(track) {
@@ -193,6 +199,25 @@ export class BabyMobile extends EventTarget {
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();
@@ -257,24 +282,17 @@ export class BabyMobile extends EventTarget {
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);
// 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 * PIPE, Math.floor(total * 0.99)));
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
@@ -302,20 +320,22 @@ export class BabyMobile extends EventTarget {
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();
}
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);
// Fallback poll in case no further notifications arrive.
this.addEventListener('disconnected', onDisconnect);
// Fallback poll for timeout and when no further notifications arrive.
const poll = setInterval(() => {
if (this._statBytes >= target || Date.now() > deadline || !this.connected) {
clearInterval(poll);
this.removeEventListener('progress', tick);
resolve();
}
if (this._statBytes >= target || Date.now() > deadline) { cleanup(); resolve(); }
}, 200);
});
}