fix BLE upload: use g_bleFlashStart for correct track address and byte count on finalize

This commit is contained in:
zyphlar
2026-07-04 03:28:14 -07:00
parent 24de5d8d39
commit 12140b7ff6
4 changed files with 247 additions and 75 deletions
+76 -25
View File
@@ -34,8 +34,8 @@ 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.
// Keep below ATT MTU - 3. The firmware calls configPrphBandwidth(BANDWIDTH_MAX)
// so Chrome typically negotiates MTU 247 (244-byte payload). 180 stays well clear.
const DATA_CHUNK = 180;
export function isSupported() {
@@ -53,6 +53,7 @@ export class BabyMobile extends EventTarget {
this._statBytes = 0;
this._onStat = this._onStat.bind(this);
this._onDisconnect = this._onDisconnect.bind(this);
this._connecting = false;
}
get connected() {
@@ -71,26 +72,57 @@ export class BabyMobile extends EventTarget {
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);
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(`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);
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.stat.startNotifications();
this.stat.addEventListener('characteristicvaluechanged', this._onStat);
await this._connectGatt(3);
this._log('Connected.');
this._emit('connected', { name: this.device.name });
return this.device.name;
} finally {
this._connecting = false;
}
}
this._log('Connected.');
this._emit('connected', { name: this.device.name });
return this.device.name;
// 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() {
@@ -99,8 +131,23 @@ export class BabyMobile extends EventTarget {
}
}
// 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', {});
}
@@ -202,18 +249,22 @@ export class BabyMobile extends EventTarget {
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.
// Send all data chunks. Progress is tracked client-side (bytes sent).
// The firmware queues bytes into a ring buffer; actual flash writes happen
// in the background. We do NOT poll per-packet notifications here — that
// caused BLE notification-buffer overflow for large files.
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);
if (onProgress) onProgress(off + chunk.length, total);
}
// Wait until the device's acknowledged byte count catches up (or times out).
await this._waitForAck(total, 8000);
// 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 (up to 20 s to allow flash writes).
await this._waitForAck(total, 20000);
const ok = acked >= total;
if (ok) {
this._log(`✅ Upload complete: ${acked}/${total} bytes acknowledged.`);