@onekeyfe/hd-transport-web-device 1.2.0-alpha.15 → 1.2.0-alpha.151

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,14 @@
1
1
  import transport, {
2
2
  PROTOCOL_V1_MESSAGE_HEADER_SIZE,
3
+ PROTOCOL_V2_BLE_FRAME_MAX_BYTES,
3
4
  PROTOCOL_V2_CHANNEL_BLE_UART,
4
5
  ProtocolV2FrameAssembler,
5
6
  ProtocolV2LinkManager,
7
+ TRANSPORT_EVENT,
6
8
  bytesToHex,
7
9
  hexToBytes,
8
10
  probeProtocolV2 as probeProtocolV2Helper,
11
+ writeProtocolV2BleFrame,
9
12
  } from '@onekeyfe/hd-transport';
10
13
  import {
11
14
  ERRORS,
@@ -13,14 +16,18 @@ import {
13
16
  HardwareErrorCodeMessage,
14
17
  createDeferred,
15
18
  isHeaderChunk,
16
- wait,
17
19
  } from '@onekeyfe/hd-shared';
18
20
 
19
- import { createTransportCallLog, shouldSuppressHighVolumeCallLog } from './transportLog';
21
+ import { resolveBlePacketCapacity } from './ble-packet-capacity';
20
22
 
21
23
  import type { Deferred } from '@onekeyfe/hd-shared';
22
24
  import type { DesktopAPI } from '@onekeyfe/hd-transport-electron';
23
- import type { OneKeyDeviceInfo, ProtocolType, TransportCallOptions } from '@onekeyfe/hd-transport';
25
+ import type {
26
+ OneKeyDeviceInfo,
27
+ ProtocolType,
28
+ ProtocolV2CallContext,
29
+ TransportCallOptions,
30
+ } from '@onekeyfe/hd-transport';
24
31
  import type EventEmitter from 'events';
25
32
 
26
33
  const { parseConfigure, ProtocolV1, check } = transport;
@@ -35,6 +42,7 @@ export type BleAcquireInput = {
35
42
  uuid: string;
36
43
  forceCleanRunPromise?: boolean;
37
44
  expectedProtocol?: ProtocolType;
45
+ protocolHint?: ProtocolType;
38
46
  };
39
47
 
40
48
  interface PacketProcessResult {
@@ -43,10 +51,6 @@ interface PacketProcessResult {
43
51
  error?: string;
44
52
  }
45
53
 
46
- function inferProtocolHintFromDeviceName(name?: string | null): ProtocolType | undefined {
47
- return /\bpro\s*2\b/i.test(name ?? '') ? 'V2' : undefined;
48
- }
49
-
50
54
  const toBleDescriptor = (
51
55
  device: { id: string; name: string | null },
52
56
  protocolType?: ProtocolType
@@ -60,29 +64,33 @@ const toBleDescriptor = (
60
64
  ...(protocolType ? { protocolType } : {}),
61
65
  } as OneKeyDeviceInfo);
62
66
 
63
- const BLE_PACKET_SIZE = 192;
67
+ const BLE_PACKET_SIZE_FALLBACK = 192;
68
+ const BLE_PACKET_SIZE_MAXIMUM = 244;
64
69
  const BLE_WRITE_DELAY_MS = 5;
65
- const BLE_RESPONSE_TIMEOUT_MS = 30_000;
66
- const PROTOCOL_PROBE_TIMEOUT_MS = 1000;
70
+ const PROTOCOL_PROBE_TIMEOUT_MS = 3000;
67
71
  const PROTOCOL_V2_PROBE_TIMEOUT_MS = 5000;
68
72
 
69
73
  /**
70
74
  * Desktop Electron BLE transport with automatic Protocol V1/V2 detection.
71
75
  *
72
76
  * Protocol V1 devices continue using chunked packets. Protocol V2 is detected
73
- * after a Protocol V1 Initialize timeout by probing Protocol V2 Ping.
77
+ * after a Protocol V1 GetFeatures timeout by probing Protocol V2 Ping.
74
78
  */
75
79
  export default class ElectronBleTransport {
76
80
  private _messages: ReturnType<typeof transport.parseConfigure> | undefined;
77
81
 
78
82
  private _messagesV2: ReturnType<typeof transport.parseConfigure> | undefined;
79
83
 
84
+ private protocolV2SchemaConfiguration: string | undefined;
85
+
80
86
  name = 'ElectronBleTransport';
81
87
 
82
88
  configured = false;
83
89
 
84
90
  runPromise: Deferred<Uint8Array | string> | null = null;
85
91
 
92
+ private runPromiseDeviceId: string | null = null;
93
+
86
94
  Log?: any;
87
95
 
88
96
  emitter?: EventEmitter;
@@ -93,6 +101,13 @@ export default class ElectronBleTransport {
93
101
 
94
102
  private deviceProtocolHints: Map<string, ProtocolType> = new Map();
95
103
 
104
+ /** Endpoints that answered a V2 probe in this transport lifetime. Survives disconnect. */
105
+ private confirmedProtocolV2 = new Set<string>();
106
+
107
+ private deviceMtus: Map<string, number> = new Map();
108
+
109
+ private devicePacketCapacities: Map<string, number> = new Map();
110
+
96
111
  private v1Buffers: Map<string, { buffer: number[]; bufferLength: number }> = new Map();
97
112
 
98
113
  private v2Assemblers: Map<string, ProtocolV2FrameAssembler> = new Map();
@@ -117,13 +132,18 @@ export default class ElectronBleTransport {
117
132
  this.rejectProtocolV2Frames(uuid, new Error(reason));
118
133
  this.Log?.debug('[Electron BLE] Protocol V2 link invalidated:', uuid, reason);
119
134
  if (reason.startsWith('Protocol V2 link-fatal error:')) {
120
- await this.release(uuid);
135
+ await this.releaseNative(uuid);
121
136
  }
122
137
  },
123
138
  });
124
139
 
140
+ /** One-shot guard so a missing preload `release` bridge is reported once. */
141
+ private warnedMissingRelease = false;
142
+
125
143
  private notificationCleanups: Map<string, () => void> = new Map();
126
144
 
145
+ private mtuCleanups: Map<string, () => void> = new Map();
146
+
127
147
  private disconnectCleanups: Map<string, () => void> = new Map();
128
148
 
129
149
  private notificationTokens: Map<string, number> = new Map();
@@ -167,12 +187,19 @@ export default class ElectronBleTransport {
167
187
  .catch(error => this.Log?.debug('[Electron BLE] link cleanup failed:', error));
168
188
  this.connectedDevices.delete(deviceId);
169
189
  this.deviceProtocol.delete(deviceId);
190
+ this.deviceMtus.delete(deviceId);
191
+ this.devicePacketCapacities.delete(deviceId);
170
192
  // Keep deviceProtocolHints — it's inferred from device name (e.g. "Pro 2" → V2)
171
193
  // and doesn't depend on connection state. Preserving it avoids redundant V1 probe on reconnect.
172
194
  this.v1Buffers.delete(deviceId);
173
195
  this.v2Assemblers.delete(deviceId);
174
196
  this.resetProtocolV2Frames(deviceId);
175
197
  this.notificationTokens.delete(deviceId);
198
+ if (this.runPromise && this.runPromiseDeviceId === deviceId) {
199
+ this.runPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected));
200
+ this.runPromise = null;
201
+ this.runPromiseDeviceId = null;
202
+ }
176
203
 
177
204
  const notifyCleanup = this.notificationCleanups.get(deviceId);
178
205
  if (notifyCleanup) {
@@ -180,6 +207,12 @@ export default class ElectronBleTransport {
180
207
  this.notificationCleanups.delete(deviceId);
181
208
  }
182
209
 
210
+ const mtuCleanup = this.mtuCleanups.get(deviceId);
211
+ if (mtuCleanup) {
212
+ mtuCleanup();
213
+ this.mtuCleanups.delete(deviceId);
214
+ }
215
+
183
216
  const disconnectCleanup = this.disconnectCleanups.get(deviceId);
184
217
  if (disconnectCleanup) {
185
218
  disconnectCleanup();
@@ -207,10 +240,19 @@ export default class ElectronBleTransport {
207
240
  }
208
241
 
209
242
  configureProtocolV2(signedData: any) {
243
+ const configuration = typeof signedData === 'string' ? signedData : JSON.stringify(signedData);
244
+ if (this.protocolV2SchemaConfiguration === configuration) {
245
+ return;
246
+ }
247
+
248
+ const isReconfiguration = this.protocolV2SchemaConfiguration !== undefined;
210
249
  this._messagesV2 = parseConfigure(signedData);
211
- this.protocolV2Links
212
- .invalidateAllLinks('Protocol V2 schema reconfigured')
213
- .catch(error => this.Log?.debug('[Electron BLE] schema link cleanup failed:', error));
250
+ this.protocolV2SchemaConfiguration = configuration;
251
+ if (isReconfiguration) {
252
+ this.protocolV2Links
253
+ .invalidateAllLinks('Protocol V2 schema reconfigured')
254
+ .catch(error => this.Log?.debug('[Electron BLE] schema link cleanup failed:', error));
255
+ }
214
256
  }
215
257
 
216
258
  async listen() {
@@ -226,10 +268,6 @@ export default class ElectronBleTransport {
226
268
  this.Log?.debug(`[Electron BLE] enumerate found ${devices.length} device(s):`);
227
269
  for (const dev of devices) {
228
270
  this.Log?.debug(`[Electron BLE] id="${dev.id}" name="${dev.name}"`);
229
- const protocolHint = inferProtocolHintFromDeviceName(dev.name);
230
- if (protocolHint) {
231
- this.deviceProtocolHints.set(dev.id, protocolHint);
232
- }
233
271
  }
234
272
  return devices.map(device => toBleDescriptor(device));
235
273
  } catch (error) {
@@ -252,8 +290,8 @@ export default class ElectronBleTransport {
252
290
  if (forceCleanRunPromise && this.runPromise) {
253
291
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
254
292
  this.runPromise.reject(error);
255
- this.rejectAllProtocolV2Frames(error);
256
293
  this.runPromise = null;
294
+ this.runPromiseDeviceId = null;
257
295
  }
258
296
 
259
297
  try {
@@ -267,7 +305,7 @@ export default class ElectronBleTransport {
267
305
  }
268
306
  const protocolHint = expectedProtocol
269
307
  ? undefined
270
- : this.deviceProtocolHints.get(uuid) ?? inferProtocolHintFromDeviceName(device.name);
308
+ : input.protocolHint ?? this.deviceProtocolHints.get(uuid);
271
309
  if (protocolHint) {
272
310
  this.deviceProtocolHints.set(uuid, protocolHint);
273
311
  }
@@ -279,19 +317,38 @@ export default class ElectronBleTransport {
279
317
  this.handleBluetoothError(error);
280
318
  }
281
319
 
320
+ const mtuCleanup = this.createMtuSubscription(uuid);
321
+ if (mtuCleanup) {
322
+ this.mtuCleanups.set(uuid, mtuCleanup);
323
+ }
324
+
282
325
  this.v1Buffers.set(uuid, { buffer: [], bufferLength: 0 });
283
- this.v2Assemblers.set(uuid, new ProtocolV2FrameAssembler());
326
+ this.v2Assemblers.set(uuid, new ProtocolV2FrameAssembler(PROTOCOL_V2_BLE_FRAME_MAX_BYTES));
284
327
 
285
328
  await window.desktopApi.nobleBle.subscribe(uuid);
329
+ await this.refreshBlePacketCapacity(uuid);
286
330
 
287
331
  const cleanup = this.createNotificationSubscription(uuid);
288
332
  this.notificationCleanups.set(uuid, cleanup);
333
+ const connectionToken = this.notificationTokens.get(uuid);
334
+
335
+ const protocolType = await this.detectProtocol(uuid, expectedProtocol, protocolHint);
336
+ if (protocolType === 'V2') {
337
+ this.Log?.debug('[Electron BLE] Protocol V2 write configured', {
338
+ writeMode: 'withoutResponse',
339
+ negotiatedMtu: this.deviceMtus.get(uuid),
340
+ packetCapacity: this.devicePacketCapacities.get(uuid) ?? BLE_PACKET_SIZE_FALLBACK,
341
+ });
342
+ }
289
343
 
290
344
  const disconnectCleanup = window.desktopApi.nobleBle.onDeviceDisconnected(
291
345
  (disconnectedDevice: any) => {
292
- if (disconnectedDevice.id === uuid) {
346
+ if (
347
+ disconnectedDevice.id === uuid &&
348
+ this.notificationTokens.get(uuid) === connectionToken
349
+ ) {
293
350
  this.cleanupDeviceState(uuid);
294
- this.emitter?.emit('device-disconnect', {
351
+ this.emitter?.emit(TRANSPORT_EVENT.DEVICE_DISCONNECT, {
295
352
  name: disconnectedDevice.name,
296
353
  id: disconnectedDevice.id,
297
354
  connectId: disconnectedDevice.id,
@@ -301,14 +358,6 @@ export default class ElectronBleTransport {
301
358
  );
302
359
  this.disconnectCleanups.set(uuid, disconnectCleanup);
303
360
 
304
- const protocolType = await this.detectProtocol(uuid, expectedProtocol, protocolHint);
305
-
306
- this.emitter?.emit('device-connect', {
307
- name: device.name,
308
- id: device.id,
309
- connectId: device.id,
310
- });
311
-
312
361
  return {
313
362
  ...toBleDescriptor({ id: device.id, name: device.name }, protocolType),
314
363
  uuid,
@@ -328,9 +377,25 @@ export default class ElectronBleTransport {
328
377
  }
329
378
  }
330
379
 
331
- async release(id: string) {
380
+ async release(id: string, _onclose?: boolean, keepSession?: boolean) {
332
381
  try {
333
382
  await this.protocolV2Links.invalidateLink(id, 'Electron BLE transport released');
383
+ await this.releaseLogical(id, keepSession);
384
+ } catch (error) {
385
+ this.Log?.error('[Electron BLE] release failed:', error);
386
+ this.cleanupDeviceState(id);
387
+ }
388
+ }
389
+
390
+ // DeviceConnector.disconnect feature-detects this; without it REQUIRE_DISCONNECT
391
+ // recovery is a no-op and keep-alive hands the wedged link to every retry.
392
+ async disconnect(id: string) {
393
+ return this.releaseNative(id);
394
+ }
395
+
396
+ // Hard teardown, error paths only: a link presumed dead must not be reused.
397
+ private async releaseNative(id: string) {
398
+ try {
334
399
  if (this.connectedDevices.has(id)) {
335
400
  if (window.desktopApi?.nobleBle) {
336
401
  await window.desktopApi.nobleBle.unsubscribe(id);
@@ -344,9 +409,38 @@ export default class ElectronBleTransport {
344
409
  }
345
410
  }
346
411
 
347
- private createProtocolMismatchError(expected: ProtocolType) {
412
+ // Logical release: link and subscription stay up for the next call. Renderer
413
+ // listeners are still torn down, or fresh ones would double-process packets.
414
+ private async releaseLogical(id: string, keepSession?: boolean) {
415
+ try {
416
+ if (!this.connectedDevices.has(id)) return;
417
+ this.cleanupDeviceState(id);
418
+
419
+ const release = window.desktopApi?.nobleBle?.release;
420
+ if (!release) {
421
+ // Degraded, not broken: say it once, it just looks like a slow device.
422
+ if (!this.warnedMissingRelease) {
423
+ this.warnedMissingRelease = true;
424
+ this.Log?.error(
425
+ '[Electron BLE] desktopApi.nobleBle.release is missing from the preload bridge; ' +
426
+ 'keep-alive idle countdown will never start — map NOBLE_BLE_RELEASE in the desktop preload'
427
+ );
428
+ }
429
+ return;
430
+ }
431
+ await release(id, keepSession);
432
+ } catch (error) {
433
+ this.Log?.error('[Electron BLE] logical release failed:', error);
434
+ this.cleanupDeviceState(id);
435
+ }
436
+ }
437
+
438
+ private createProtocolMismatchError(expected: ProtocolType, uuid: string) {
439
+ // A generic Ping miss is not a bond failure. Only a later miss after this
440
+ // endpoint already answered V2, or a native encryption/pairing error, is.
441
+ const isStaleV2Bond = expected === 'V2' && this.confirmedProtocolV2.has(uuid);
348
442
  return ERRORS.TypedError(
349
- HardwareErrorCode.RuntimeError,
443
+ isStaleV2Bond ? HardwareErrorCode.BleDeviceBondError : HardwareErrorCode.RuntimeError,
350
444
  `Device protocol mismatch: expected ${expected}, but device did not respond to expected protocol`
351
445
  );
352
446
  }
@@ -354,7 +448,7 @@ export default class ElectronBleTransport {
354
448
  private createProtocolDetectionError() {
355
449
  return ERRORS.TypedError(
356
450
  HardwareErrorCode.BleTimeoutError,
357
- 'Unable to detect BLE protocol: device did not respond to Protocol V1 Initialize or Protocol V2 Ping'
451
+ 'Unable to detect BLE protocol: device did not respond to Protocol V1 GetFeatures or Protocol V2 Ping'
358
452
  );
359
453
  }
360
454
 
@@ -375,33 +469,38 @@ export default class ElectronBleTransport {
375
469
  this.Log?.debug(`[Electron BLE] detectProtocol: uuid=${uuid} -> V1 (expected)`);
376
470
  return 'V1';
377
471
  }
378
- throw this.createProtocolMismatchError(expectedProtocol);
472
+ throw this.createProtocolMismatchError(expectedProtocol, uuid);
379
473
  }
380
474
 
381
475
  if (expectedProtocol === 'V2') {
382
- // 免探测路径:调用方显式承诺该设备是 V2(例如固件升级重启后的重连场景,
383
- // 上层已经探测过协议并通过 expectedProtocol 传回),这里不再重复探测。
384
- this.deviceProtocol.set(uuid, 'V2');
385
- this.Log?.debug(`[Electron BLE] detectProtocol: uuid=${uuid} -> V2 (expected)`);
386
- return 'V2';
476
+ if (await this.probeProtocolV2(uuid)) {
477
+ this.deviceProtocol.set(uuid, 'V2');
478
+ this.confirmedProtocolV2.add(uuid);
479
+ this.Log?.debug(`[Electron BLE] detectProtocol: uuid=${uuid} -> V2 (expected)`);
480
+ return 'V2';
481
+ }
482
+ throw this.createProtocolMismatchError(expectedProtocol, uuid);
387
483
  }
388
484
 
389
- // 项目约束:协议判断必须在连接后主动探测,不能依赖设备名/PID/descriptor。
390
- // 设备名 hint(如 "Pro 2")只用于调整探测顺序:hint=V2 时先探 V2、失败回落 V1,
391
- // 不能作为最终结论。
485
+ // Protocol must be actively probed after connection. Name, PID, and descriptors only
486
+ // influence probe order; a V2 hint probes V2 first and falls back to V1.
392
487
  const probeOrder: ProtocolType[] =
393
488
  protocolHint === 'V2' || this.deviceProtocol.get(uuid) === 'V2' ? ['V2', 'V1'] : ['V1', 'V2'];
394
489
 
395
490
  for (let i = 0; i < probeOrder.length; i += 1) {
396
491
  const protocol = probeOrder[i];
397
492
  if (i > 0) {
398
- // 上一个协议探测失败后,重置订阅与缓冲,避免残留数据干扰下一个协议的探测。
493
+ // Keep the physical BLE link while switching probes. Reconnecting here can
494
+ // summon a second OS pairing prompt for a device that is still onboarding.
399
495
  await this.resetProbeStateAfterProtocolProbe(uuid, probeOrder[i - 1]);
400
496
  }
401
497
  const detected =
402
498
  protocol === 'V1' ? await this.probeProtocolV1(uuid) : await this.probeProtocolV2(uuid);
403
499
  if (detected) {
404
500
  this.deviceProtocol.set(uuid, protocol);
501
+ if (protocol === 'V2') {
502
+ this.confirmedProtocolV2.add(uuid);
503
+ }
405
504
  this.Log?.debug(`[Electron BLE] detectProtocol: uuid=${uuid} -> ${protocol}`);
406
505
  return protocol;
407
506
  }
@@ -435,33 +534,20 @@ export default class ElectronBleTransport {
435
534
  this.v1Buffers.set(uuid, { buffer: [], bufferLength: 0 });
436
535
  this.v2Assemblers.get(uuid)?.reset();
437
536
  this.resetProtocolV2Frames(uuid);
438
- if (this.runPromise) {
537
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
439
538
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
440
539
  this.runPromise.reject(error);
441
540
  this.runPromise = null;
541
+ this.runPromiseDeviceId = null;
442
542
  }
443
543
 
444
- const notifyCleanup = this.notificationCleanups.get(uuid);
445
- if (notifyCleanup) {
446
- notifyCleanup();
447
- this.notificationCleanups.delete(uuid);
448
- }
449
- this.notificationTokens.delete(uuid);
450
-
451
- try {
452
- await window.desktopApi?.nobleBle?.unsubscribe(uuid);
453
- } catch (error) {
454
- this.Log?.debug(`[Electron BLE] unsubscribe after Protocol ${protocol} probe failed:`, error);
455
- }
456
- try {
457
- await window.desktopApi?.nobleBle?.subscribe(uuid);
458
- } catch (error) {
459
- this.Log?.debug(`[Electron BLE] resubscribe after Protocol ${protocol} probe failed:`, error);
460
- throw error;
544
+ // A timed-out V1 probe retires its renderer notification token so a late V1
545
+ // response cannot satisfy the V2 probe. Install a fresh listener without
546
+ // touching the native GATT subscription or physical connection.
547
+ if (!this.notificationCleanups.has(uuid)) {
548
+ const cleanup = this.createNotificationSubscription(uuid);
549
+ this.notificationCleanups.set(uuid, cleanup);
461
550
  }
462
-
463
- const cleanup = this.createNotificationSubscription(uuid);
464
- this.notificationCleanups.set(uuid, cleanup);
465
551
  }
466
552
 
467
553
  private async probeProtocolV1(uuid: string) {
@@ -471,11 +557,13 @@ export default class ElectronBleTransport {
471
557
 
472
558
  try {
473
559
  this.deviceProtocol.set(uuid, 'V1');
474
- await this.callProtocolV1(uuid, 'Initialize', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
560
+ // GetFeatures identifies Protocol V1 without resetting an existing wallet
561
+ // session before Core has a chance to restore a hidden wallet.
562
+ await this.callProtocolV1(uuid, 'GetFeatures', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
475
563
  return true;
476
564
  } catch (error) {
477
565
  this.clearProbeProtocol(uuid, 'V1');
478
- this.Log?.debug('[Electron BLE] Protocol V1 Initialize probe failed:', error);
566
+ this.Log?.debug('[Electron BLE] Protocol V1 GetFeatures probe failed:', error);
479
567
  return false;
480
568
  }
481
569
  }
@@ -503,35 +591,63 @@ export default class ElectronBleTransport {
503
591
  return detected;
504
592
  }
505
593
 
506
- private async writeWithChunking(uuid: string, hexData: string): Promise<void> {
507
- const totalBytes = hexData.length / 2;
508
-
509
- if (totalBytes <= BLE_PACKET_SIZE) {
510
- await wait(BLE_WRITE_DELAY_MS);
511
- await this.writeOnce(uuid, hexData);
512
- return;
594
+ private async writeOnce(uuid: string, hexData: string): Promise<void> {
595
+ const nobleBle = window.desktopApi?.nobleBle;
596
+ if (!nobleBle) {
597
+ throw new Error('Noble BLE API not available');
513
598
  }
514
599
 
515
- for (let offset = 0; offset < hexData.length; ) {
516
- const chunkHexLen = Math.min(BLE_PACKET_SIZE * 2, hexData.length - offset);
517
- const chunkHex = hexData.substring(offset, offset + chunkHexLen);
518
- offset += chunkHexLen;
600
+ await nobleBle.write(uuid, hexData, { pacingDelayMs: 0 });
601
+ }
519
602
 
520
- await this.writeOnce(uuid, chunkHex);
603
+ private async refreshBlePacketCapacity(uuid: string): Promise<void> {
604
+ const device = await window.desktopApi?.nobleBle?.getDevice(uuid);
605
+ this.updateBlePacketCapacity(uuid, device?.mtu);
606
+ }
521
607
 
522
- if (offset < hexData.length) {
523
- await wait(BLE_WRITE_DELAY_MS);
524
- }
608
+ private updateBlePacketCapacity(uuid: string, mtu?: number): void {
609
+ const packetCapacity = resolveBlePacketCapacity(
610
+ mtu,
611
+ BLE_PACKET_SIZE_MAXIMUM,
612
+ BLE_PACKET_SIZE_FALLBACK
613
+ );
614
+ if (typeof mtu === 'number') {
615
+ this.deviceMtus.set(uuid, mtu);
616
+ } else {
617
+ this.deviceMtus.delete(uuid);
525
618
  }
619
+ this.devicePacketCapacities.set(uuid, packetCapacity);
526
620
  }
527
621
 
528
- private async writeOnce(uuid: string, hexData: string): Promise<void> {
529
- const nobleBle = window.desktopApi?.nobleBle;
530
- if (!nobleBle) {
531
- throw new Error('Noble BLE API not available');
532
- }
622
+ private createMtuSubscription(uuid: string): (() => void) | undefined {
623
+ const onMtuChanged = window.desktopApi?.nobleBle?.onMtuChanged;
624
+ if (!onMtuChanged) return undefined;
625
+ return onMtuChanged(device => {
626
+ if (device.id === uuid) {
627
+ this.updateBlePacketCapacity(uuid, device.mtu);
628
+ }
629
+ });
630
+ }
533
631
 
534
- await nobleBle.write(uuid, hexData);
632
+ private writeProtocolV2Frame(
633
+ uuid: string,
634
+ frame: Uint8Array,
635
+ context: ProtocolV2CallContext,
636
+ assertCurrentGeneration: () => void
637
+ ) {
638
+ const packetCapacity = this.devicePacketCapacities.get(uuid) ?? BLE_PACKET_SIZE_FALLBACK;
639
+ const shouldPace = !context.highThroughput;
640
+ return writeProtocolV2BleFrame({
641
+ frame,
642
+ packetCapacity,
643
+ assertActive: assertCurrentGeneration,
644
+ signal: context.signal,
645
+ abortMessage: `Protocol V2 BLE write aborted for ${context.messageName}`,
646
+ initialDelayMs: shouldPace && frame.length <= packetCapacity ? BLE_WRITE_DELAY_MS : 0,
647
+ burstSize: shouldPace ? 1 : undefined,
648
+ burstPauseMs: shouldPace ? BLE_WRITE_DELAY_MS : 0,
649
+ writePacket: packet => this.writeOnce(uuid, bytesToHex(packet)),
650
+ });
535
651
  }
536
652
 
537
653
  private handleNotification(deviceId: string, hexData: string): void {
@@ -540,7 +656,7 @@ export default class ElectronBleTransport {
540
656
  const error = ERRORS.TypedError(HardwareErrorCode.BleDeviceBondedCanceled);
541
657
  if (this.deviceProtocol.get(deviceId) === 'V2') {
542
658
  this.rejectProtocolV2Frames(deviceId, error);
543
- } else if (this.runPromise) {
659
+ } else if (this.runPromise && this.runPromiseDeviceId === deviceId) {
544
660
  this.runPromise.reject(error);
545
661
  }
546
662
  return;
@@ -595,17 +711,8 @@ export default class ElectronBleTransport {
595
711
  this.getProtocolV2FrameQueue(uuid).push(frame);
596
712
  }
597
713
 
598
- private rejectAllProtocolV2Frames(error: Error) {
599
- this.v2FrameQueues.clear();
600
- for (const framePromise of this.v2FramePromises.values()) {
601
- framePromise.reject(error);
602
- }
603
- this.v2FramePromises.clear();
604
- }
605
-
606
714
  private resetProtocolV2Frames(uuid: string) {
607
- this.v2FrameQueues.delete(uuid);
608
- this.v2FramePromises.delete(uuid);
715
+ this.rejectProtocolV2Frames(uuid, new Error(`Protocol V2 frame state reset for ${uuid}`));
609
716
  }
610
717
 
611
718
  private rejectProtocolV2Frames(uuid: string, error: Error) {
@@ -639,13 +746,18 @@ export default class ElectronBleTransport {
639
746
 
640
747
  if (result.error) {
641
748
  this.Log?.error('[Electron BLE] Protocol V1 packet processing error:', result.error);
642
- if (this.runPromise) {
749
+ if (this.runPromise && this.runPromiseDeviceId === deviceId) {
643
750
  this.runPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError));
644
751
  }
645
752
  return;
646
753
  }
647
754
 
648
- if (result.isComplete && result.completePacket && this.runPromise) {
755
+ if (
756
+ result.isComplete &&
757
+ result.completePacket &&
758
+ this.runPromise &&
759
+ this.runPromiseDeviceId === deviceId
760
+ ) {
649
761
  this.runPromise.resolve(result.completePacket);
650
762
  }
651
763
  }
@@ -671,16 +783,25 @@ export default class ElectronBleTransport {
671
783
  `Device protocol has not been detected for ${uuid}`
672
784
  );
673
785
  }
674
- if (!shouldSuppressHighVolumeCallLog(name)) {
675
- this.Log?.debug('transport call', createTransportCallLog(name, protocol));
676
- }
677
-
678
786
  if (protocol === 'V2') {
679
787
  return this.callProtocolV2(uuid, name, data, options);
680
788
  }
681
789
  return this.callProtocolV1(uuid, name, data, options);
682
790
  }
683
791
 
792
+ async post(uuid: string, name: string, data: Record<string, unknown>) {
793
+ if (this.deviceProtocol.get(uuid) === 'V2') {
794
+ await this.protocolV2Links.sendFlowControl(
795
+ uuid,
796
+ () => this.createProtocolV2Adapter(uuid),
797
+ name,
798
+ data
799
+ );
800
+ return;
801
+ }
802
+ await this.callProtocolV1(uuid, name, data);
803
+ }
804
+
684
805
  private async callProtocolV1(
685
806
  uuid: string,
686
807
  name: string,
@@ -699,6 +820,7 @@ export default class ElectronBleTransport {
699
820
  const runPromise = createDeferred<Uint8Array | string>();
700
821
  runPromise.promise.catch(() => undefined);
701
822
  this.runPromise = runPromise;
823
+ this.runPromiseDeviceId = uuid;
702
824
  const messages = this._messages;
703
825
  const buffers = ProtocolV1.encodeTransportPackets(messages, name, data);
704
826
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -743,11 +865,24 @@ export default class ElectronBleTransport {
743
865
  return check.call(jsonData);
744
866
  } catch (e) {
745
867
  this.Log?.error('[Electron BLE] Protocol V1 call error:', e);
868
+ const isProbeTimeout =
869
+ name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
870
+ if ((e as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleTimeoutError) {
871
+ this.v1Buffers.set(uuid, { buffer: [], bufferLength: 0 });
872
+ const notifyCleanup = this.notificationCleanups.get(uuid);
873
+ notifyCleanup?.();
874
+ this.notificationCleanups.delete(uuid);
875
+ this.notificationTokens.delete(uuid);
876
+ if (!isProbeTimeout) {
877
+ await this.releaseNative(uuid);
878
+ }
879
+ }
746
880
  throw e;
747
881
  } finally {
748
882
  if (timeout) clearTimeout(timeout);
749
883
  if (this.runPromise === runPromise) {
750
884
  this.runPromise = null;
885
+ this.runPromiseDeviceId = null;
751
886
  }
752
887
  }
753
888
  }
@@ -762,10 +897,7 @@ export default class ElectronBleTransport {
762
897
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
763
898
  }
764
899
 
765
- const callOptions = {
766
- ...options,
767
- timeoutMs: options?.timeoutMs ?? BLE_RESPONSE_TIMEOUT_MS,
768
- };
900
+ const callOptions = options;
769
901
 
770
902
  try {
771
903
  return await this.protocolV2Links.call(
@@ -791,16 +923,15 @@ export default class ElectronBleTransport {
791
923
 
792
924
  return {
793
925
  router: PROTOCOL_V2_CHANNEL_BLE_UART,
926
+ maxFrameBytes: PROTOCOL_V2_BLE_FRAME_MAX_BYTES,
794
927
  generation,
795
928
  prepareCall: () => {
796
929
  assertCurrentGeneration();
797
930
  this.v2Assemblers.get(uuid)?.reset();
798
931
  this.resetProtocolV2Frames(uuid);
799
932
  },
800
- writeFrame: (frame: Uint8Array) => {
801
- assertCurrentGeneration();
802
- return this.writeWithChunking(uuid, bytesToHex(frame));
803
- },
933
+ writeFrame: (frame: Uint8Array, context: ProtocolV2CallContext) =>
934
+ this.writeProtocolV2Frame(uuid, frame, context, assertCurrentGeneration),
804
935
  readFrame: async () => {
805
936
  assertCurrentGeneration();
806
937
  const rxFrame = await this.readProtocolV2Frame(uuid);
@@ -844,6 +975,12 @@ export default class ElectronBleTransport {
844
975
  bufferState.bufferLength = dataView.getInt32(5, false);
845
976
  bufferState.buffer = [...data.subarray(3)];
846
977
  } else {
978
+ if (bufferState.buffer.length === 0) {
979
+ // Tail of a cancelled call, arriving after the buffer was reset. Not an
980
+ // `error`: the caller rejects the in-flight call on that field.
981
+ this.Log?.debug('[Electron BLE] Orphan continuation chunk discarded');
982
+ return { isComplete: false };
983
+ }
847
984
  bufferState.buffer = bufferState.buffer.concat([...data]);
848
985
  }
849
986