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
+10
View File
@@ -75,3 +75,13 @@ and reuse `ble.js`'s protocol constants.
local name just shows "Track N".
- **MTU:** data is chunked to 180 bytes to stay within a modest negotiated ATT
MTU (firmware char max is 240).
- **Name shows as "BabyM" (truncated):** the 128-bit service UUID fills the 31-byte
advertisement, leaving room for only 5 name characters. The browser can't read
the full GAP name (that service is blocklisted in Web Bluetooth). Fix it in the
firmware by moving the name to the scan response, which has its own 31 bytes:
replace `Bluefruit.Advertising.addName();` with `Bluefruit.ScanResponse.addName();`.
- **Reconnecting after a disconnect:** a just-disconnected peripheral leaves the
link half-open briefly, so the first reconnect could fail until a page refresh.
`connect()` now retries (3×, 600 ms backoff) after an explicit disconnect, which
recovers without refreshing. Scanning matches on the service UUID first, so a
truncated advertised name no longer breaks the name filter.
+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.`);
+1 -1
View File
@@ -1,5 +1,5 @@
// sw.js — minimal offline cache so the PWA launches without a network.
const CACHE = 'babymobile-v2';
const CACHE = 'babymobile-v3';
const ASSETS = [
'./', './index.html', './styles.css',
'./app.js', './ble.js', './wav.js',