@onekeyfe/hd-transport-web-device 1.2.0-alpha.8 → 1.2.0-alpha.80

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,12 +1,14 @@
1
1
  import transport, {
2
- LogBlockCommand,
3
2
  PROTOCOL_V1_MESSAGE_HEADER_SIZE,
3
+ PROTOCOL_V2_BLE_FRAME_MAX_BYTES,
4
4
  PROTOCOL_V2_CHANNEL_BLE_UART,
5
5
  ProtocolV2FrameAssembler,
6
- ProtocolV2Session,
6
+ ProtocolV2LinkManager,
7
+ TRANSPORT_EVENT,
7
8
  bytesToHex,
8
9
  hexToBytes,
9
10
  probeProtocolV2 as probeProtocolV2Helper,
11
+ writeProtocolV2BleFrame,
10
12
  } from '@onekeyfe/hd-transport';
11
13
  import {
12
14
  ERRORS,
@@ -14,25 +16,21 @@ import {
14
16
  HardwareErrorCodeMessage,
15
17
  createDeferred,
16
18
  isHeaderChunk,
17
- wait,
18
19
  } from '@onekeyfe/hd-shared';
19
20
 
21
+ import { createTransportCallLog, shouldSuppressHighVolumeCallLog } from './transportLog';
22
+ import { resolveBlePacketCapacity } from './ble-packet-capacity';
23
+
20
24
  import type { Deferred } from '@onekeyfe/hd-shared';
21
25
  import type { DesktopAPI } from '@onekeyfe/hd-transport-electron';
22
- import type { OneKeyDeviceInfo, ProtocolType, TransportCallOptions } from '@onekeyfe/hd-transport';
26
+ import type {
27
+ OneKeyDeviceInfo,
28
+ ProtocolType,
29
+ ProtocolV2CallContext,
30
+ TransportCallOptions,
31
+ } from '@onekeyfe/hd-transport';
23
32
  import type EventEmitter from 'events';
24
33
 
25
- const FILE_WRITE_LOG_BLOCK_PATTERN = /(?:^|[^a-z])(?:raw)?(?:filesystem|emmc)?filewrite$/i;
26
-
27
- function shouldSuppressHighVolumeCallLog(name: string) {
28
- const normalized = name.replace(/[_\s-]/g, '');
29
- return FILE_WRITE_LOG_BLOCK_PATTERN.test(normalized);
30
- }
31
-
32
- function isLogBlockCommand(name: string) {
33
- return (LogBlockCommand as Set<string> | undefined)?.has?.(name) ?? false;
34
- }
35
-
36
34
  const { parseConfigure, ProtocolV1, check } = transport;
37
35
 
38
36
  declare global {
@@ -45,6 +43,7 @@ export type BleAcquireInput = {
45
43
  uuid: string;
46
44
  forceCleanRunPromise?: boolean;
47
45
  expectedProtocol?: ProtocolType;
46
+ protocolHint?: ProtocolType;
48
47
  };
49
48
 
50
49
  interface PacketProcessResult {
@@ -70,9 +69,9 @@ const toBleDescriptor = (
70
69
  ...(protocolType ? { protocolType } : {}),
71
70
  } as OneKeyDeviceInfo);
72
71
 
73
- const BLE_PACKET_SIZE = 192;
72
+ const BLE_PACKET_SIZE_FALLBACK = 192;
73
+ const BLE_PACKET_SIZE_MAXIMUM = 244;
74
74
  const BLE_WRITE_DELAY_MS = 5;
75
- const BLE_RESPONSE_TIMEOUT_MS = 30_000;
76
75
  const PROTOCOL_PROBE_TIMEOUT_MS = 1000;
77
76
  const PROTOCOL_V2_PROBE_TIMEOUT_MS = 5000;
78
77
 
@@ -80,19 +79,23 @@ const PROTOCOL_V2_PROBE_TIMEOUT_MS = 5000;
80
79
  * Desktop Electron BLE transport with automatic Protocol V1/V2 detection.
81
80
  *
82
81
  * Protocol V1 devices continue using chunked packets. Protocol V2 is detected
83
- * after a Protocol V1 Initialize timeout by probing Protocol V2 Ping.
82
+ * after a Protocol V1 GetFeatures timeout by probing Protocol V2 Ping.
84
83
  */
85
84
  export default class ElectronBleTransport {
86
85
  private _messages: ReturnType<typeof transport.parseConfigure> | undefined;
87
86
 
88
87
  private _messagesV2: ReturnType<typeof transport.parseConfigure> | undefined;
89
88
 
89
+ private protocolV2SchemaConfiguration: string | undefined;
90
+
90
91
  name = 'ElectronBleTransport';
91
92
 
92
93
  configured = false;
93
94
 
94
95
  runPromise: Deferred<Uint8Array | string> | null = null;
95
96
 
97
+ private runPromiseDeviceId: string | null = null;
98
+
96
99
  Log?: any;
97
100
 
98
101
  emitter?: EventEmitter;
@@ -103,6 +106,10 @@ export default class ElectronBleTransport {
103
106
 
104
107
  private deviceProtocolHints: Map<string, ProtocolType> = new Map();
105
108
 
109
+ private deviceMtus: Map<string, number> = new Map();
110
+
111
+ private devicePacketCapacities: Map<string, number> = new Map();
112
+
106
113
  private v1Buffers: Map<string, { buffer: number[]; bufferLength: number }> = new Map();
107
114
 
108
115
  private v2Assemblers: Map<string, ProtocolV2FrameAssembler> = new Map();
@@ -111,12 +118,34 @@ export default class ElectronBleTransport {
111
118
 
112
119
  private v2FramePromises: Map<string, Deferred<Uint8Array>> = new Map();
113
120
 
114
- private activeProtocolV2Call: { uuid: string; token: number } | null = null;
121
+ private protocolV2Links = new ProtocolV2LinkManager<string>({
122
+ getSchemas: () => {
123
+ if (!this._messages || !this._messagesV2) {
124
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
125
+ }
126
+ return {
127
+ protocolV1: this._messages,
128
+ protocolV2: this._messagesV2,
129
+ };
130
+ },
131
+ classifyError: () => 'link-fatal',
132
+ onLinkInvalidated: async (uuid, reason) => {
133
+ this.v2Assemblers.get(uuid)?.reset();
134
+ this.rejectProtocolV2Frames(uuid, new Error(reason));
135
+ this.Log?.debug('[Electron BLE] Protocol V2 link invalidated:', uuid, reason);
136
+ if (reason.startsWith('Protocol V2 link-fatal error:')) {
137
+ await this.releaseNative(uuid);
138
+ }
139
+ },
140
+ });
115
141
 
116
- private nextProtocolV2CallToken = 1;
142
+ /** One-shot guard so a missing preload `release` bridge is reported once. */
143
+ private warnedMissingRelease = false;
117
144
 
118
145
  private notificationCleanups: Map<string, () => void> = new Map();
119
146
 
147
+ private mtuCleanups: Map<string, () => void> = new Map();
148
+
120
149
  private disconnectCleanups: Map<string, () => void> = new Map();
121
150
 
122
151
  private notificationTokens: Map<string, number> = new Map();
@@ -155,17 +184,24 @@ export default class ElectronBleTransport {
155
184
  }
156
185
 
157
186
  private cleanupDeviceState(deviceId: string): void {
187
+ this.protocolV2Links
188
+ .invalidateLink(deviceId, 'Electron BLE device state cleaned')
189
+ .catch(error => this.Log?.debug('[Electron BLE] link cleanup failed:', error));
158
190
  this.connectedDevices.delete(deviceId);
159
191
  this.deviceProtocol.delete(deviceId);
192
+ this.deviceMtus.delete(deviceId);
193
+ this.devicePacketCapacities.delete(deviceId);
160
194
  // Keep deviceProtocolHints — it's inferred from device name (e.g. "Pro 2" → V2)
161
195
  // and doesn't depend on connection state. Preserving it avoids redundant V1 probe on reconnect.
162
196
  this.v1Buffers.delete(deviceId);
163
197
  this.v2Assemblers.delete(deviceId);
164
198
  this.resetProtocolV2Frames(deviceId);
165
- if (this.activeProtocolV2Call?.uuid === deviceId) {
166
- this.activeProtocolV2Call = null;
167
- }
168
199
  this.notificationTokens.delete(deviceId);
200
+ if (this.runPromise && this.runPromiseDeviceId === deviceId) {
201
+ this.runPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected));
202
+ this.runPromise = null;
203
+ this.runPromiseDeviceId = null;
204
+ }
169
205
 
170
206
  const notifyCleanup = this.notificationCleanups.get(deviceId);
171
207
  if (notifyCleanup) {
@@ -173,6 +209,12 @@ export default class ElectronBleTransport {
173
209
  this.notificationCleanups.delete(deviceId);
174
210
  }
175
211
 
212
+ const mtuCleanup = this.mtuCleanups.get(deviceId);
213
+ if (mtuCleanup) {
214
+ mtuCleanup();
215
+ this.mtuCleanups.delete(deviceId);
216
+ }
217
+
176
218
  const disconnectCleanup = this.disconnectCleanups.get(deviceId);
177
219
  if (disconnectCleanup) {
178
220
  disconnectCleanup();
@@ -200,8 +242,19 @@ export default class ElectronBleTransport {
200
242
  }
201
243
 
202
244
  configureProtocolV2(signedData: any) {
245
+ const configuration = typeof signedData === 'string' ? signedData : JSON.stringify(signedData);
246
+ if (this.protocolV2SchemaConfiguration === configuration) {
247
+ return;
248
+ }
249
+
250
+ const isReconfiguration = this.protocolV2SchemaConfiguration !== undefined;
203
251
  this._messagesV2 = parseConfigure(signedData);
204
- this.Log?.debug('[Electron BLE] Protocol V2 schema configured');
252
+ this.protocolV2SchemaConfiguration = configuration;
253
+ if (isReconfiguration) {
254
+ this.protocolV2Links
255
+ .invalidateAllLinks('Protocol V2 schema reconfigured')
256
+ .catch(error => this.Log?.debug('[Electron BLE] schema link cleanup failed:', error));
257
+ }
205
258
  }
206
259
 
207
260
  async listen() {
@@ -236,12 +289,15 @@ export default class ElectronBleTransport {
236
289
  throw ERRORS.TypedError(HardwareErrorCode.BleRequiredUUID);
237
290
  }
238
291
 
292
+ if (this.connectedDevices.has(uuid)) {
293
+ await this.release(uuid);
294
+ }
295
+
239
296
  if (forceCleanRunPromise && this.runPromise) {
240
297
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
241
298
  this.runPromise.reject(error);
242
- this.rejectAllProtocolV2Frames(error);
243
299
  this.runPromise = null;
244
- this.activeProtocolV2Call = null;
300
+ this.runPromiseDeviceId = null;
245
301
  }
246
302
 
247
303
  try {
@@ -255,7 +311,9 @@ export default class ElectronBleTransport {
255
311
  }
256
312
  const protocolHint = expectedProtocol
257
313
  ? undefined
258
- : this.deviceProtocolHints.get(uuid) ?? inferProtocolHintFromDeviceName(device.name);
314
+ : input.protocolHint ??
315
+ this.deviceProtocolHints.get(uuid) ??
316
+ inferProtocolHintFromDeviceName(device.name);
259
317
  if (protocolHint) {
260
318
  this.deviceProtocolHints.set(uuid, protocolHint);
261
319
  }
@@ -267,19 +325,38 @@ export default class ElectronBleTransport {
267
325
  this.handleBluetoothError(error);
268
326
  }
269
327
 
328
+ const mtuCleanup = this.createMtuSubscription(uuid);
329
+ if (mtuCleanup) {
330
+ this.mtuCleanups.set(uuid, mtuCleanup);
331
+ }
332
+
270
333
  this.v1Buffers.set(uuid, { buffer: [], bufferLength: 0 });
271
- this.v2Assemblers.set(uuid, new ProtocolV2FrameAssembler());
334
+ this.v2Assemblers.set(uuid, new ProtocolV2FrameAssembler(PROTOCOL_V2_BLE_FRAME_MAX_BYTES));
272
335
 
273
336
  await window.desktopApi.nobleBle.subscribe(uuid);
337
+ await this.refreshBlePacketCapacity(uuid);
274
338
 
275
339
  const cleanup = this.createNotificationSubscription(uuid);
276
340
  this.notificationCleanups.set(uuid, cleanup);
341
+ const connectionToken = this.notificationTokens.get(uuid);
342
+
343
+ const protocolType = await this.detectProtocol(uuid, expectedProtocol, protocolHint);
344
+ if (protocolType === 'V2') {
345
+ this.Log?.debug('[Electron BLE] Protocol V2 write configured', {
346
+ writeMode: 'withoutResponse',
347
+ negotiatedMtu: this.deviceMtus.get(uuid),
348
+ packetCapacity: this.devicePacketCapacities.get(uuid) ?? BLE_PACKET_SIZE_FALLBACK,
349
+ });
350
+ }
277
351
 
278
352
  const disconnectCleanup = window.desktopApi.nobleBle.onDeviceDisconnected(
279
353
  (disconnectedDevice: any) => {
280
- if (disconnectedDevice.id === uuid) {
354
+ if (
355
+ disconnectedDevice.id === uuid &&
356
+ this.notificationTokens.get(uuid) === connectionToken
357
+ ) {
281
358
  this.cleanupDeviceState(uuid);
282
- this.emitter?.emit('device-disconnect', {
359
+ this.emitter?.emit(TRANSPORT_EVENT.DEVICE_DISCONNECT, {
283
360
  name: disconnectedDevice.name,
284
361
  id: disconnectedDevice.id,
285
362
  connectId: disconnectedDevice.id,
@@ -289,14 +366,6 @@ export default class ElectronBleTransport {
289
366
  );
290
367
  this.disconnectCleanups.set(uuid, disconnectCleanup);
291
368
 
292
- const protocolType = await this.detectProtocol(uuid, expectedProtocol, protocolHint);
293
-
294
- this.emitter?.emit('device-connect', {
295
- name: device.name,
296
- id: device.id,
297
- connectId: device.id,
298
- });
299
-
300
369
  return {
301
370
  ...toBleDescriptor({ id: device.id, name: device.name }, protocolType),
302
371
  uuid,
@@ -316,7 +385,24 @@ export default class ElectronBleTransport {
316
385
  }
317
386
  }
318
387
 
319
- async release(id: string) {
388
+ async release(id: string, _onclose?: boolean, keepSession?: boolean) {
389
+ try {
390
+ await this.protocolV2Links.invalidateLink(id, 'Electron BLE transport released');
391
+ await this.releaseLogical(id, keepSession);
392
+ } catch (error) {
393
+ this.Log?.error('[Electron BLE] release failed:', error);
394
+ this.cleanupDeviceState(id);
395
+ }
396
+ }
397
+
398
+ // DeviceConnector.disconnect feature-detects this; without it REQUIRE_DISCONNECT
399
+ // recovery is a no-op and keep-alive hands the wedged link to every retry.
400
+ async disconnect(id: string) {
401
+ return this.releaseNative(id);
402
+ }
403
+
404
+ // Hard teardown, error paths only: a link presumed dead must not be reused.
405
+ private async releaseNative(id: string) {
320
406
  try {
321
407
  if (this.connectedDevices.has(id)) {
322
408
  if (window.desktopApi?.nobleBle) {
@@ -331,6 +417,32 @@ export default class ElectronBleTransport {
331
417
  }
332
418
  }
333
419
 
420
+ // Logical release: link and subscription stay up for the next call. Renderer
421
+ // listeners are still torn down, or fresh ones would double-process packets.
422
+ private async releaseLogical(id: string, keepSession?: boolean) {
423
+ try {
424
+ if (!this.connectedDevices.has(id)) return;
425
+ this.cleanupDeviceState(id);
426
+
427
+ const release = window.desktopApi?.nobleBle?.release;
428
+ if (!release) {
429
+ // Degraded, not broken: say it once, it just looks like a slow device.
430
+ if (!this.warnedMissingRelease) {
431
+ this.warnedMissingRelease = true;
432
+ this.Log?.error(
433
+ '[Electron BLE] desktopApi.nobleBle.release is missing from the preload bridge; ' +
434
+ 'keep-alive idle countdown will never start — map NOBLE_BLE_RELEASE in the desktop preload'
435
+ );
436
+ }
437
+ return;
438
+ }
439
+ await release(id, keepSession);
440
+ } catch (error) {
441
+ this.Log?.error('[Electron BLE] logical release failed:', error);
442
+ this.cleanupDeviceState(id);
443
+ }
444
+ }
445
+
334
446
  private createProtocolMismatchError(expected: ProtocolType) {
335
447
  return ERRORS.TypedError(
336
448
  HardwareErrorCode.RuntimeError,
@@ -341,7 +453,7 @@ export default class ElectronBleTransport {
341
453
  private createProtocolDetectionError() {
342
454
  return ERRORS.TypedError(
343
455
  HardwareErrorCode.BleTimeoutError,
344
- 'Unable to detect BLE protocol: device did not respond to Protocol V1 Initialize or Protocol V2 Ping'
456
+ 'Unable to detect BLE protocol: device did not respond to Protocol V1 GetFeatures or Protocol V2 Ping'
345
457
  );
346
458
  }
347
459
 
@@ -366,23 +478,23 @@ export default class ElectronBleTransport {
366
478
  }
367
479
 
368
480
  if (expectedProtocol === 'V2') {
369
- // 免探测路径:调用方显式承诺该设备是 V2(例如固件升级重启后的重连场景,
370
- // 上层已经探测过协议并通过 expectedProtocol 传回),这里不再重复探测。
371
- this.deviceProtocol.set(uuid, 'V2');
372
- this.Log?.debug(`[Electron BLE] detectProtocol: uuid=${uuid} -> V2 (expected)`);
373
- return 'V2';
481
+ if (await this.probeProtocolV2(uuid)) {
482
+ this.deviceProtocol.set(uuid, 'V2');
483
+ this.Log?.debug(`[Electron BLE] detectProtocol: uuid=${uuid} -> V2 (expected)`);
484
+ return 'V2';
485
+ }
486
+ throw this.createProtocolMismatchError(expectedProtocol);
374
487
  }
375
488
 
376
- // 项目约束:协议判断必须在连接后主动探测,不能依赖设备名/PID/descriptor。
377
- // 设备名 hint(如 "Pro 2")只用于调整探测顺序:hint=V2 时先探 V2、失败回落 V1,
378
- // 不能作为最终结论。
489
+ // Protocol must be actively probed after connection. Name, PID, and descriptors only
490
+ // influence probe order; a V2 hint probes V2 first and falls back to V1.
379
491
  const probeOrder: ProtocolType[] =
380
492
  protocolHint === 'V2' || this.deviceProtocol.get(uuid) === 'V2' ? ['V2', 'V1'] : ['V1', 'V2'];
381
493
 
382
494
  for (let i = 0; i < probeOrder.length; i += 1) {
383
495
  const protocol = probeOrder[i];
384
496
  if (i > 0) {
385
- // 上一个协议探测失败后,重置订阅与缓冲,避免残留数据干扰下一个协议的探测。
497
+ // Reset subscriptions and buffers after a failed probe before trying another protocol.
386
498
  await this.resetProbeStateAfterProtocolProbe(uuid, probeOrder[i - 1]);
387
499
  }
388
500
  const detected =
@@ -415,16 +527,18 @@ export default class ElectronBleTransport {
415
527
  }
416
528
 
417
529
  private async resetProbeStateAfterProtocolProbe(uuid: string, protocol: ProtocolType) {
530
+ await this.protocolV2Links.invalidateLink(
531
+ uuid,
532
+ `Reset notify state after Protocol ${protocol} probe`
533
+ );
418
534
  this.v1Buffers.set(uuid, { buffer: [], bufferLength: 0 });
419
535
  this.v2Assemblers.get(uuid)?.reset();
420
536
  this.resetProtocolV2Frames(uuid);
421
- if (this.activeProtocolV2Call?.uuid === uuid) {
422
- this.activeProtocolV2Call = null;
423
- }
424
- if (this.runPromise) {
537
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
425
538
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
426
539
  this.runPromise.reject(error);
427
540
  this.runPromise = null;
541
+ this.runPromiseDeviceId = null;
428
542
  }
429
543
 
430
544
  const notifyCleanup = this.notificationCleanups.get(uuid);
@@ -440,9 +554,18 @@ export default class ElectronBleTransport {
440
554
  this.Log?.debug(`[Electron BLE] unsubscribe after Protocol ${protocol} probe failed:`, error);
441
555
  }
442
556
  try {
557
+ await window.desktopApi?.nobleBle?.disconnect(uuid);
558
+ } catch (error) {
559
+ this.Log?.debug(`[Electron BLE] disconnect after Protocol ${protocol} probe failed:`, error);
560
+ }
561
+ this.connectedDevices.delete(uuid);
562
+ try {
563
+ await window.desktopApi?.nobleBle?.connect(uuid);
564
+ this.connectedDevices.add(uuid);
443
565
  await window.desktopApi?.nobleBle?.subscribe(uuid);
566
+ await this.refreshBlePacketCapacity(uuid);
444
567
  } catch (error) {
445
- this.Log?.debug(`[Electron BLE] resubscribe after Protocol ${protocol} probe failed:`, error);
568
+ this.Log?.debug(`[Electron BLE] reconnect after Protocol ${protocol} probe failed:`, error);
446
569
  throw error;
447
570
  }
448
571
 
@@ -457,11 +580,13 @@ export default class ElectronBleTransport {
457
580
 
458
581
  try {
459
582
  this.deviceProtocol.set(uuid, 'V1');
460
- await this.callProtocolV1(uuid, 'Initialize', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
583
+ // GetFeatures identifies Protocol V1 without resetting an existing wallet
584
+ // session before Core has a chance to restore a hidden wallet.
585
+ await this.callProtocolV1(uuid, 'GetFeatures', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
461
586
  return true;
462
587
  } catch (error) {
463
588
  this.clearProbeProtocol(uuid, 'V1');
464
- this.Log?.debug('[Electron BLE] Protocol V1 Initialize probe failed:', error);
589
+ this.Log?.debug('[Electron BLE] Protocol V1 GetFeatures probe failed:', error);
465
590
  return false;
466
591
  }
467
592
  }
@@ -489,44 +614,73 @@ export default class ElectronBleTransport {
489
614
  return detected;
490
615
  }
491
616
 
492
- private async writeWithChunking(uuid: string, hexData: string): Promise<void> {
493
- const totalBytes = hexData.length / 2;
494
-
495
- if (totalBytes <= BLE_PACKET_SIZE) {
496
- await wait(BLE_WRITE_DELAY_MS);
497
- await this.writeOnce(uuid, hexData);
498
- return;
617
+ private async writeOnce(uuid: string, hexData: string): Promise<void> {
618
+ const nobleBle = window.desktopApi?.nobleBle;
619
+ if (!nobleBle) {
620
+ throw new Error('Noble BLE API not available');
499
621
  }
500
622
 
501
- for (let offset = 0; offset < hexData.length; ) {
502
- const chunkHexLen = Math.min(BLE_PACKET_SIZE * 2, hexData.length - offset);
503
- const chunkHex = hexData.substring(offset, offset + chunkHexLen);
504
- offset += chunkHexLen;
623
+ await nobleBle.write(uuid, hexData, { pacingDelayMs: 0 });
624
+ }
505
625
 
506
- await this.writeOnce(uuid, chunkHex);
626
+ private async refreshBlePacketCapacity(uuid: string): Promise<void> {
627
+ const device = await window.desktopApi?.nobleBle?.getDevice(uuid);
628
+ this.updateBlePacketCapacity(uuid, device?.mtu);
629
+ }
507
630
 
508
- if (offset < hexData.length) {
509
- await wait(BLE_WRITE_DELAY_MS);
510
- }
631
+ private updateBlePacketCapacity(uuid: string, mtu?: number): void {
632
+ const packetCapacity = resolveBlePacketCapacity(
633
+ mtu,
634
+ BLE_PACKET_SIZE_MAXIMUM,
635
+ BLE_PACKET_SIZE_FALLBACK
636
+ );
637
+ if (typeof mtu === 'number') {
638
+ this.deviceMtus.set(uuid, mtu);
639
+ } else {
640
+ this.deviceMtus.delete(uuid);
511
641
  }
642
+ this.devicePacketCapacities.set(uuid, packetCapacity);
512
643
  }
513
644
 
514
- private async writeOnce(uuid: string, hexData: string): Promise<void> {
515
- const nobleBle = window.desktopApi?.nobleBle;
516
- if (!nobleBle) {
517
- throw new Error('Noble BLE API not available');
518
- }
645
+ private createMtuSubscription(uuid: string): (() => void) | undefined {
646
+ const onMtuChanged = window.desktopApi?.nobleBle?.onMtuChanged;
647
+ if (!onMtuChanged) return undefined;
648
+ return onMtuChanged(device => {
649
+ if (device.id === uuid) {
650
+ this.updateBlePacketCapacity(uuid, device.mtu);
651
+ }
652
+ });
653
+ }
519
654
 
520
- await nobleBle.write(uuid, hexData);
655
+ private writeProtocolV2Frame(
656
+ uuid: string,
657
+ frame: Uint8Array,
658
+ context: ProtocolV2CallContext,
659
+ assertCurrentGeneration: () => void
660
+ ) {
661
+ const packetCapacity = this.devicePacketCapacities.get(uuid) ?? BLE_PACKET_SIZE_FALLBACK;
662
+ const shouldPace = !context.highThroughput;
663
+ return writeProtocolV2BleFrame({
664
+ frame,
665
+ packetCapacity,
666
+ assertActive: assertCurrentGeneration,
667
+ signal: context.signal,
668
+ abortMessage: `Protocol V2 BLE write aborted for ${context.messageName}`,
669
+ initialDelayMs: shouldPace && frame.length <= packetCapacity ? BLE_WRITE_DELAY_MS : 0,
670
+ burstSize: shouldPace ? 1 : undefined,
671
+ burstPauseMs: shouldPace ? BLE_WRITE_DELAY_MS : 0,
672
+ writePacket: packet => this.writeOnce(uuid, bytesToHex(packet)),
673
+ });
521
674
  }
522
675
 
523
676
  private handleNotification(deviceId: string, hexData: string): void {
524
677
  if (hexData === 'PAIRING_REJECTED') {
525
678
  this.Log?.debug('[Electron BLE] Pairing rejection detected for device:', deviceId);
526
- if (this.runPromise) {
527
- const error = ERRORS.TypedError(HardwareErrorCode.BleDeviceBondedCanceled);
679
+ const error = ERRORS.TypedError(HardwareErrorCode.BleDeviceBondedCanceled);
680
+ if (this.deviceProtocol.get(deviceId) === 'V2') {
681
+ this.rejectProtocolV2Frames(deviceId, error);
682
+ } else if (this.runPromise && this.runPromiseDeviceId === deviceId) {
528
683
  this.runPromise.reject(error);
529
- this.rejectAllProtocolV2Frames(error);
530
684
  }
531
685
  return;
532
686
  }
@@ -545,12 +699,6 @@ export default class ElectronBleTransport {
545
699
 
546
700
  private handleProtocolV2Notification(deviceId: string, hexData: string): void {
547
701
  try {
548
- if (!this.runPromise || this.activeProtocolV2Call?.uuid !== deviceId) {
549
- this.v2Assemblers.get(deviceId)?.reset();
550
- this.resetProtocolV2Frames(deviceId);
551
- return;
552
- }
553
-
554
702
  const bytes = hexToBytes(hexData);
555
703
  if (bytes.length === 0) return;
556
704
 
@@ -562,11 +710,8 @@ export default class ElectronBleTransport {
562
710
  }
563
711
  } catch (error) {
564
712
  this.Log?.error('[Electron BLE] Protocol V2 notification error:', error);
565
- if (this.runPromise) {
566
- const notifyError = ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
567
- this.runPromise.reject(notifyError);
568
- this.rejectAllProtocolV2Frames(notifyError);
569
- }
713
+ const notifyError = ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
714
+ this.rejectProtocolV2Frames(deviceId, notifyError);
570
715
  }
571
716
  }
572
717
 
@@ -589,21 +734,17 @@ export default class ElectronBleTransport {
589
734
  this.getProtocolV2FrameQueue(uuid).push(frame);
590
735
  }
591
736
 
592
- private rejectAllProtocolV2Frames(error: Error) {
593
- this.v2FrameQueues.clear();
594
- for (const framePromise of this.v2FramePromises.values()) {
595
- framePromise.reject(error);
596
- }
597
- this.v2FramePromises.clear();
598
- }
599
-
600
737
  private resetProtocolV2Frames(uuid: string) {
601
- this.v2FrameQueues.delete(uuid);
602
- this.v2FramePromises.delete(uuid);
738
+ this.rejectProtocolV2Frames(uuid, new Error(`Protocol V2 frame state reset for ${uuid}`));
603
739
  }
604
740
 
605
- private isActiveProtocolV2Call(uuid: string, token: number) {
606
- return this.activeProtocolV2Call?.uuid === uuid && this.activeProtocolV2Call.token === token;
741
+ private rejectProtocolV2Frames(uuid: string, error: Error) {
742
+ this.v2FrameQueues.delete(uuid);
743
+ const framePromise = this.v2FramePromises.get(uuid);
744
+ if (framePromise) {
745
+ this.v2FramePromises.delete(uuid);
746
+ framePromise.reject(error);
747
+ }
607
748
  }
608
749
 
609
750
  private async readProtocolV2Frame(uuid: string) {
@@ -628,13 +769,18 @@ export default class ElectronBleTransport {
628
769
 
629
770
  if (result.error) {
630
771
  this.Log?.error('[Electron BLE] Protocol V1 packet processing error:', result.error);
631
- if (this.runPromise) {
772
+ if (this.runPromise && this.runPromiseDeviceId === deviceId) {
632
773
  this.runPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError));
633
774
  }
634
775
  return;
635
776
  }
636
777
 
637
- if (result.isComplete && result.completePacket && this.runPromise) {
778
+ if (
779
+ result.isComplete &&
780
+ result.completePacket &&
781
+ this.runPromise &&
782
+ this.runPromiseDeviceId === deviceId
783
+ ) {
638
784
  this.runPromise.resolve(result.completePacket);
639
785
  }
640
786
  }
@@ -660,12 +806,8 @@ export default class ElectronBleTransport {
660
806
  `Device protocol has not been detected for ${uuid}`
661
807
  );
662
808
  }
663
- if (shouldSuppressHighVolumeCallLog(name)) {
664
- // 高频文件写入不要逐包发 debug 事件,否则调试日志会反向拖慢传输。
665
- } else if (isLogBlockCommand(name)) {
666
- this.Log?.debug('[Electron BLE] call', 'name:', name, 'protocol:', protocol);
667
- } else {
668
- this.Log?.debug('[Electron BLE] call', 'name:', name, 'data:', data, 'protocol:', protocol);
809
+ if (!shouldSuppressHighVolumeCallLog(name)) {
810
+ this.Log?.debug('transport call', createTransportCallLog(name, protocol, data));
669
811
  }
670
812
 
671
813
  if (protocol === 'V2') {
@@ -692,6 +834,7 @@ export default class ElectronBleTransport {
692
834
  const runPromise = createDeferred<Uint8Array | string>();
693
835
  runPromise.promise.catch(() => undefined);
694
836
  this.runPromise = runPromise;
837
+ this.runPromiseDeviceId = uuid;
695
838
  const messages = this._messages;
696
839
  const buffers = ProtocolV1.encodeTransportPackets(messages, name, data);
697
840
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -736,11 +879,24 @@ export default class ElectronBleTransport {
736
879
  return check.call(jsonData);
737
880
  } catch (e) {
738
881
  this.Log?.error('[Electron BLE] Protocol V1 call error:', e);
882
+ const isProbeTimeout =
883
+ name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
884
+ if ((e as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleTimeoutError) {
885
+ this.v1Buffers.set(uuid, { buffer: [], bufferLength: 0 });
886
+ const notifyCleanup = this.notificationCleanups.get(uuid);
887
+ notifyCleanup?.();
888
+ this.notificationCleanups.delete(uuid);
889
+ this.notificationTokens.delete(uuid);
890
+ if (!isProbeTimeout) {
891
+ await this.releaseNative(uuid);
892
+ }
893
+ }
739
894
  throw e;
740
895
  } finally {
741
896
  if (timeout) clearTimeout(timeout);
742
897
  if (this.runPromise === runPromise) {
743
898
  this.runPromise = null;
899
+ this.runPromiseDeviceId = null;
744
900
  }
745
901
  }
746
902
  }
@@ -755,77 +911,61 @@ export default class ElectronBleTransport {
755
911
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
756
912
  }
757
913
 
758
- const forceRun = name === 'Initialize' || name === 'Cancel' || name === 'Ping';
759
- if (this.runPromise) {
760
- if (!forceRun) {
761
- throw ERRORS.TypedError(HardwareErrorCode.TransportCallInProgress);
762
- }
763
- const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
764
- this.runPromise.reject(error);
765
- this.rejectAllProtocolV2Frames(error);
766
- this.runPromise = null;
767
- this.activeProtocolV2Call = null;
914
+ const callOptions = options;
915
+
916
+ try {
917
+ return await this.protocolV2Links.call(
918
+ uuid,
919
+ () => this.createProtocolV2Adapter(uuid),
920
+ name,
921
+ data,
922
+ callOptions
923
+ );
924
+ } catch (e) {
925
+ this.Log?.error('[Electron BLE] Protocol V2 call error:', e);
926
+ throw e;
768
927
  }
928
+ }
769
929
 
770
- const runPromise = createDeferred<Uint8Array | string>();
771
- runPromise.promise.catch(() => undefined);
772
- this.runPromise = runPromise;
773
- const callToken = this.nextProtocolV2CallToken++;
774
- this.activeProtocolV2Call = { uuid, token: callToken };
775
- this.v2Assemblers.get(uuid)?.reset();
776
- this.resetProtocolV2Frames(uuid);
777
- let completed = false;
778
- const callOptions = {
779
- ...options,
780
- timeoutMs: options?.timeoutMs ?? BLE_RESPONSE_TIMEOUT_MS,
930
+ private createProtocolV2Adapter(uuid: string) {
931
+ const generation = this.notificationTokens.get(uuid) ?? 0;
932
+ const assertCurrentGeneration = () => {
933
+ if (this.notificationTokens.get(uuid) !== generation) {
934
+ throw new Error(`Protocol V2 notification generation changed for ${uuid}`);
935
+ }
781
936
  };
782
937
 
783
- try {
784
- const session = new ProtocolV2Session({
785
- schemas: {
786
- protocolV1: this._messages,
787
- protocolV2: this._messagesV2,
788
- },
789
- router: PROTOCOL_V2_CHANNEL_BLE_UART,
790
- writeFrame: (frame: Uint8Array) => this.writeWithChunking(uuid, bytesToHex(frame)),
791
- readFrame: async () => {
792
- const rxFrame = await this.readProtocolV2Frame(uuid);
793
- if (!(rxFrame instanceof Uint8Array)) {
794
- throw new Error('Response is not Uint8Array');
795
- }
796
- return rxFrame;
797
- },
798
- logger: this.Log,
799
- logPrefix: 'ProtocolV2 BLE',
800
- createTimeoutError: (_messageName: string, timeout: number) =>
801
- ERRORS.TypedError(
802
- HardwareErrorCode.BleTimeoutError,
803
- `BLE response timeout after ${timeout}ms for ${name}`
804
- ),
805
- });
806
-
807
- const result = await session.call(name, data, callOptions);
808
- completed = true;
809
- return result;
810
- } catch (e) {
811
- if (this.isActiveProtocolV2Call(uuid, callToken)) {
938
+ return {
939
+ router: PROTOCOL_V2_CHANNEL_BLE_UART,
940
+ maxFrameBytes: PROTOCOL_V2_BLE_FRAME_MAX_BYTES,
941
+ generation,
942
+ prepareCall: () => {
943
+ assertCurrentGeneration();
812
944
  this.v2Assemblers.get(uuid)?.reset();
813
945
  this.resetProtocolV2Frames(uuid);
814
- }
815
- this.Log?.error('[Electron BLE] Protocol V2 call error:', e);
816
- throw e;
817
- } finally {
818
- if (this.isActiveProtocolV2Call(uuid, callToken)) {
819
- if (!completed) {
820
- this.v2Assemblers.get(uuid)?.reset();
946
+ },
947
+ writeFrame: (frame: Uint8Array, context: ProtocolV2CallContext) =>
948
+ this.writeProtocolV2Frame(uuid, frame, context, assertCurrentGeneration),
949
+ readFrame: async () => {
950
+ assertCurrentGeneration();
951
+ const rxFrame = await this.readProtocolV2Frame(uuid);
952
+ if (!(rxFrame instanceof Uint8Array)) {
953
+ throw new Error('Response is not Uint8Array');
821
954
  }
822
- this.resetProtocolV2Frames(uuid);
823
- this.activeProtocolV2Call = null;
824
- }
825
- if (this.runPromise === runPromise) {
826
- this.runPromise = null;
827
- }
828
- }
955
+ return rxFrame;
956
+ },
957
+ reset: (reason: string) => {
958
+ this.v2Assemblers.get(uuid)?.reset();
959
+ this.rejectProtocolV2Frames(uuid, new Error(reason));
960
+ },
961
+ logger: this.Log,
962
+ logPrefix: 'ProtocolV2 BLE',
963
+ createTimeoutError: (messageName: string, timeout: number) =>
964
+ ERRORS.TypedError(
965
+ HardwareErrorCode.BleTimeoutError,
966
+ `BLE response timeout after ${timeout}ms for ${messageName}`
967
+ ),
968
+ };
829
969
  }
830
970
 
831
971
  private processProtocolV1Notification(deviceId: string, hexData: string): PacketProcessResult {
@@ -849,6 +989,12 @@ export default class ElectronBleTransport {
849
989
  bufferState.bufferLength = dataView.getInt32(5, false);
850
990
  bufferState.buffer = [...data.subarray(3)];
851
991
  } else {
992
+ if (bufferState.buffer.length === 0) {
993
+ // Tail of a cancelled call, arriving after the buffer was reset. Not an
994
+ // `error`: the caller rejects the in-flight call on that field.
995
+ this.Log?.debug('[Electron BLE] Orphan continuation chunk discarded');
996
+ return { isComplete: false };
997
+ }
852
998
  bufferState.buffer = bufferState.buffer.concat([...data]);
853
999
  }
854
1000