Skip to content

Commit 4fa7932

Browse files
authored
Merge pull request #511 from makermelissa-piclaw/fix/issue-377-ble-autoreconnect
Silently reconnect BLE after autoreload-induced disconnect
2 parents 6bda763 + 4e40848 commit 4fa7932

2 files changed

Lines changed: 161 additions & 6 deletions

File tree

js/common/ble-file-transfer.js

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,53 @@
11
import {FileTransferClient as BLEFileTransferClient} from '@adafruit/ble-file-transfer-js';
22
//import {FileTransferClient as BLEFileTransferClient} from '../../../ble-file-transfer-js/adafruit-ble-file-transfer.js';
33

4-
// Wrapper for BLEFileTransferClient to add additional functionality
4+
// Wrapper that holds mutating-op promises open across the firmware
5+
// autoreload + silent reconnect, so callers see a live GATT on return.
6+
// See circuitpython/web-editor#377.
57
class FileTransferClient extends BLEFileTransferClient {
6-
constructor(bleDevice, bufferSize) {
8+
constructor(bleDevice, bufferSize, workflow = null) {
79
super(bleDevice, bufferSize);
10+
this._workflow = workflow;
11+
}
12+
13+
_signalMutatingOp() {
14+
if (this._workflow && typeof this._workflow.markMutatingOp === 'function') {
15+
this._workflow.markMutatingOp();
16+
}
17+
}
18+
19+
async _awaitReconnectIfNeeded() {
20+
if (this._workflow && typeof this._workflow.awaitPostOpReconnect === 'function') {
21+
await this._workflow.awaitPostOpReconnect();
22+
}
23+
}
24+
25+
async writeFile(path, offset, contents, modificationTime, raw) {
26+
this._signalMutatingOp();
27+
const result = await super.writeFile(path, offset, contents, modificationTime, raw);
28+
await this._awaitReconnectIfNeeded();
29+
return result;
30+
}
31+
32+
async move(oldPath, newPath) {
33+
this._signalMutatingOp();
34+
const result = await super.move(oldPath, newPath);
35+
await this._awaitReconnectIfNeeded();
36+
return result;
37+
}
38+
39+
async delete(path) {
40+
this._signalMutatingOp();
41+
const result = await super.delete(path);
42+
await this._awaitReconnectIfNeeded();
43+
return result;
44+
}
45+
46+
async makeDir(path, modificationTime) {
47+
this._signalMutatingOp();
48+
const result = await super.makeDir(path, modificationTime);
49+
await this._awaitReconnectIfNeeded();
50+
return result;
851
}
952

1053
async readOnly() {

js/workflows/ble.js

Lines changed: 116 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44

55
import {FileTransferClient} from '../common/ble-file-transfer.js';
6-
import {CONNTYPE} from '../constants.js';
6+
import {CONNTYPE, CONNSTATE} from '../constants.js';
77
import {Workflow} from './workflow.js';
88
import {GenericModal, DeviceInfoModal} from '../common/dialogs.js';
99
import {sleep} from '../common/utilities.js';
@@ -14,6 +14,17 @@ const bleNusCharTXUUID = 'adaf0003-4369-7263-7569-74507974686e';
1414

1515
const BYTES_PER_WRITE = 20;
1616

17+
// Tunables for silent auto-reconnect after firmware autoreload.
18+
// CircuitPython's BLE file transfer triggers an autoreload after every
19+
// mutating op (write/move/delete/mkdir), which tears down the GATT
20+
// Silent reconnect after firmware autoreload. See #377.
21+
const RECONNECT_DELAYS_MS = [1500, 2500, 4000];
22+
const POST_OP_RECONNECT_WINDOW_MS = 8000;
23+
// How long to wait for the post-op disconnect to fire (~2s observed).
24+
const POST_OP_DISCONNECT_GRACE_MS = 4000;
25+
// Wait after GATT reconnects so the VM finishes booting before the next op.
26+
const POST_RECONNECT_SETTLE_MS = 2000;
27+
1728
let btnRequestBluetoothDevice, btnReconnect;
1829

1930
class BLEWorkflow extends Workflow {
@@ -34,6 +45,48 @@ class BLEWorkflow extends Workflow {
3445
{reconnect: false, request: true},
3546
{reconnect: true, request: true},
3647
];
48+
// Mutating-op disconnects within this window trigger silent reconnect.
49+
this._lastMutatingOpAt = 0;
50+
this._silentReconnectInFlight = false;
51+
this._silentReconnectPromise = null;
52+
}
53+
54+
// Called by the FileTransferClient wrapper right before any mutating
55+
// BLE-FT op (write/move/delete/mkdir). Marks the moment so that the
56+
// disconnect handler can recognize the next disconnect as an expected
57+
// autoreload and recover silently.
58+
markMutatingOp() {
59+
this._lastMutatingOpAt = Date.now();
60+
}
61+
62+
_wasMutatingOpRecent() {
63+
return (Date.now() - this._lastMutatingOpAt) < POST_OP_RECONNECT_WINDOW_MS;
64+
}
65+
66+
// Awaited by mutating-op wrappers so callers see a live GATT before proceeding.
67+
async awaitPostOpReconnect() {
68+
const startedAt = Date.now();
69+
while (Date.now() - startedAt < POST_OP_DISCONNECT_GRACE_MS) {
70+
// gatt.connected flips false before gattserverdisconnected fires.
71+
if (this.bleDevice && this.bleDevice.gatt && !this.bleDevice.gatt.connected) {
72+
const waitForPromise = Date.now();
73+
while (!this._silentReconnectPromise && Date.now() - waitForPromise < POST_OP_DISCONNECT_GRACE_MS) {
74+
await sleep(25);
75+
}
76+
break;
77+
}
78+
if (this._silentReconnectPromise) {
79+
break;
80+
}
81+
await sleep(25);
82+
}
83+
if (this._silentReconnectPromise) {
84+
try {
85+
await this._silentReconnectPromise;
86+
} catch (e) {
87+
console.log('awaitPostOpReconnect: silent reconnect rejected:', e);
88+
}
89+
}
3790
}
3891

3992
// This is called when a user clicks the main disconnect button
@@ -213,7 +266,7 @@ class BLEWorkflow extends Workflow {
213266
}
214267

215268
console.log('Initializing File Transfer Client...');
216-
this.initFileClient(new FileTransferClient(this.bleDevice, 65536));
269+
this.initFileClient(new FileTransferClient(this.bleDevice, 65536, this));
217270
await this.fileHelper.bond();
218271
await this.connectToSerial();
219272

@@ -248,10 +301,26 @@ class BLEWorkflow extends Workflow {
248301
}
249302

250303
async connect() {
251-
let result;
252-
if (result = await super.connect() instanceof Error) {
304+
const result = await super.connect();
305+
if (result instanceof Error) {
253306
return result;
254307
}
308+
309+
// Disconnect right after a mutating op = firmware autoreload. Reconnect silently.
310+
if (this.bleDevice && this._wasMutatingOpRecent()) {
311+
this._silentReconnectPromise = this._attemptSilentReconnect();
312+
let ok = false;
313+
try {
314+
ok = await this._silentReconnectPromise;
315+
} finally {
316+
this._silentReconnectPromise = null;
317+
}
318+
if (ok) {
319+
return;
320+
}
321+
// Silent reconnect failed; fall through to normal reconnect.
322+
}
323+
255324
// Is this a new connection?
256325
if (!this.bleDevice) {
257326
try {
@@ -266,6 +335,49 @@ class BLEWorkflow extends Workflow {
266335
}
267336
}
268337

338+
// Reconnect to the same paired device after firmware autoreload.
339+
// Reuses the existing FileTransferClient so FileDialog bindings stay live;
340+
// upstream checkConnection() re-fetches characteristics on next op.
341+
async _attemptSilentReconnect() {
342+
if (this._silentReconnectInFlight) {
343+
return false;
344+
}
345+
this._silentReconnectInFlight = true;
346+
try {
347+
for (const delay of RECONNECT_DELAYS_MS) {
348+
await sleep(delay);
349+
try {
350+
console.log(`Silent reconnect: attempting after ${delay}ms…`);
351+
this.bleServer = await this.bleDevice.gatt.connect();
352+
if (this.bleServer && this.bleServer.connected) {
353+
console.log('Silent reconnect: GATT reconnected, rebinding characteristics…');
354+
await this._rebindAfterSilentReconnect();
355+
console.log('Silent reconnect succeeded.');
356+
return true;
357+
}
358+
} catch (error) {
359+
console.log(`Silent reconnect attempt failed: ${error}. Retrying…`);
360+
}
361+
}
362+
console.log('Silent reconnect exhausted; falling back to manual reconnect UI.');
363+
return false;
364+
} finally {
365+
this._silentReconnectInFlight = false;
366+
}
367+
}
368+
369+
// Rebind characteristics after silent reconnect without rebuilding fileHelper.
370+
async _rebindAfterSilentReconnect() {
371+
// Re-attach disconnect listener (idempotent).
372+
this.bleDevice.removeEventListener('gattserverdisconnected', this.onDisconnected.bind(this));
373+
this.bleDevice.addEventListener('gattserverdisconnected', this.onDisconnected.bind(this));
374+
375+
// NUS serial chars need re-fetch; BLE-FT chars re-fetched lazily by checkConnection().
376+
await this.connectToSerial();
377+
378+
this.updateConnected(CONNSTATE.connected);
379+
}
380+
269381
updateConnected(connectionState) {
270382
super.updateConnected(connectionState);
271383
this.connectionStep(2);

0 commit comments

Comments
 (0)