@onekeyfe/hd-transport-react-native 1.2.0-alpha.11 → 1.2.0-alpha.111

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.
package/src/index.ts CHANGED
@@ -5,34 +5,46 @@ import {
5
5
  BleError,
6
6
  BleErrorCode,
7
7
  BleManager as BlePlxManager,
8
+ ConnectionPriority,
8
9
  ScanMode,
9
10
  } from 'react-native-ble-plx';
10
11
  import ByteBuffer from 'bytebuffer';
11
12
  import transport, {
12
- LogBlockCommand,
13
13
  type OneKeyDeviceInfoBase,
14
14
  PROTOCOL_V1_MESSAGE_HEADER_SIZE,
15
15
  PROTOCOL_V2_BLE_FRAME_MAX_BYTES,
16
16
  PROTOCOL_V2_CHANNEL_BLE_UART,
17
17
  type ProtocolType,
18
+ type ProtocolV2CallContext,
18
19
  ProtocolV2FrameAssembler,
19
20
  ProtocolV2LinkManager,
21
+ TRANSPORT_EVENT,
20
22
  type TransportCallOptions,
23
+ isProtocolV2HighThroughputCall,
21
24
  probeProtocolV2 as probeProtocolV2Helper,
25
+ writeProtocolV2BleFrame,
22
26
  } from '@onekeyfe/hd-transport';
23
- import { ERRORS, HardwareErrorCode, createDeferred, isOnekeyDevice } from '@onekeyfe/hd-shared';
27
+ import {
28
+ ERRORS,
29
+ HardwareErrorCode,
30
+ createDeferred,
31
+ isOnekeyBluetoothDevice,
32
+ isPro2FindMyAdvertisementName,
33
+ } from '@onekeyfe/hd-shared';
24
34
 
25
35
  import { getConnectedDeviceIds, onDeviceBondState, pairDevice } from './BleManager';
26
36
  import {
27
37
  hasWritableCapability,
28
- resolveBleWriteMode,
29
38
  resolveProtocolV2PacketCapacity,
39
+ shouldRefreshNegotiatedMtu,
40
+ shouldWriteProtocolV2WithResponse,
30
41
  } from './bleStrategy';
31
42
  import { subscribeBleOn } from './subscribeBleOn';
32
43
  import {
33
44
  ANDROID_PACKET_LENGTH,
45
+ ANDROID_PROTOCOL_V2_PACKET_LENGTH,
34
46
  IOS_PACKET_LENGTH,
35
- getBleUuidKey,
47
+ IOS_PROTOCOL_V2_PACKET_LENGTH,
36
48
  getBluetoothServiceUuids,
37
49
  getInfosForServiceUuid,
38
50
  isSameBleUuid,
@@ -56,24 +68,52 @@ const FIRMWARE_UPLOAD_WRITE_BURST_SIZE = Platform.OS === 'ios' ? 4 : 5;
56
68
  const FIRMWARE_UPLOAD_WRITE_PAUSE_MS = Platform.OS === 'ios' ? 8 : 10;
57
69
  const FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS = Platform.OS === 'ios' ? 24 : 30;
58
70
  const FIRMWARE_UPLOAD_WRITE_MAX_RETRIES = 8;
59
- const FIRMWARE_UPLOAD_RECONNECT_RETRY_DELAY_MS = 2000;
60
71
  const ANDROID_FIRMWARE_UPLOAD_PACKET_LENGTH = 192;
61
72
  const FIRMWARE_UPLOAD_WRITE_PACKET_CAPACITY =
62
73
  Platform.OS === 'ios' ? IOS_PACKET_LENGTH : ANDROID_FIRMWARE_UPLOAD_PACKET_LENGTH;
63
74
  const ANDROID_GATT_CONGESTED_STATUS = 143;
64
75
 
65
- type FirmwareUploadWriteRetryType = 'congested' | 'reconnectable';
76
+ type FirmwareUploadWriteRetryType = 'congested';
66
77
  type ResolvedBleCharacteristics = {
67
78
  writeCharacteristic: Characteristic;
68
79
  notifyCharacteristic: Characteristic;
69
80
  };
70
81
 
82
+ const isAsciiWhitespace = (code: number) =>
83
+ code === 0x09 ||
84
+ code === 0x0a ||
85
+ code === 0x0b ||
86
+ code === 0x0c ||
87
+ code === 0x0d ||
88
+ code === 0x20;
89
+
90
+ const hasGattCongestedStatus = (text: string) => {
91
+ let searchFrom = 0;
92
+ while (searchFrom < text.length) {
93
+ const statusIndex = text.indexOf('status', searchFrom);
94
+ if (statusIndex < 0) return false;
95
+
96
+ let cursor = statusIndex + 'status'.length;
97
+ while (cursor < text.length && isAsciiWhitespace(text.charCodeAt(cursor))) cursor += 1;
98
+ if (text[cursor] === ':' || text[cursor] === '=') {
99
+ cursor += 1;
100
+ while (cursor < text.length && isAsciiWhitespace(text.charCodeAt(cursor))) cursor += 1;
101
+ }
102
+ if (text.startsWith(String(ANDROID_GATT_CONGESTED_STATUS), cursor)) return true;
103
+
104
+ searchFrom = statusIndex + 'status'.length;
105
+ }
106
+ return false;
107
+ };
108
+
71
109
  const delay = (ms: number) =>
72
110
  new Promise<void>(resolve => {
73
111
  setTimeout(resolve, ms);
74
112
  });
75
113
 
76
- const getFirmwareUploadWriteRetryType = (error: unknown): FirmwareUploadWriteRetryType | null => {
114
+ export const getFirmwareUploadWriteRetryType = (
115
+ error: unknown
116
+ ): FirmwareUploadWriteRetryType | null => {
77
117
  if (!error || typeof error !== 'object') return null;
78
118
  const bleWriteError = error as {
79
119
  androidErrorCode?: unknown;
@@ -84,13 +124,6 @@ const getFirmwareUploadWriteRetryType = (error: unknown): FirmwareUploadWriteRet
84
124
  name?: unknown;
85
125
  };
86
126
 
87
- if (
88
- bleWriteError.errorCode === BleErrorCode.DeviceDisconnected ||
89
- bleWriteError.errorCode === BleErrorCode.CharacteristicNotFound
90
- ) {
91
- return 'reconnectable';
92
- }
93
-
94
127
  if (
95
128
  bleWriteError.androidErrorCode === ANDROID_GATT_CONGESTED_STATUS ||
96
129
  bleWriteError.status === ANDROID_GATT_CONGESTED_STATUS
@@ -101,39 +134,42 @@ const getFirmwareUploadWriteRetryType = (error: unknown): FirmwareUploadWriteRet
101
134
  const text = [bleWriteError.reason, bleWriteError.message, bleWriteError.name]
102
135
  .filter(value => typeof value === 'string')
103
136
  .join(' ');
104
- return /GATT_CONGESTED|status\s*[:=]?\s*143/.test(text) ? 'congested' : null;
137
+ return text.includes('GATT_CONGESTED') || hasGattCongestedStatus(text) ? 'congested' : null;
105
138
  };
106
139
 
107
140
  const resolveFirmwareUploadRetryDelay = (attempt: number, baseDelayMs = 200, maxDelayMs = 1200) =>
108
141
  Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
109
- const BLE_RESPONSE_TIMEOUT_MS = 30_000;
110
142
  const PROTOCOL_PROBE_TIMEOUT_MS = 1000;
111
143
  const PROTOCOL_V2_PROBE_TIMEOUT_MS = 10_000;
112
- const DEVICE_SCAN_TIMEOUT_MS = 8000;
144
+ /**
145
+ * Per-packet write budget. iOS only resolves writeWithoutResponse once CoreBluetooth
146
+ * reports the peripheral ready again; a peripheral wedged by its own firmware reboot
147
+ * stops reporting ready while staying connected, so the write promise never settles.
148
+ * Response timeouts cannot cover that — they are armed after the writes complete —
149
+ * and an unbounded write leaves the whole transport unusable until the process dies.
150
+ * A healthy packet completes in milliseconds, so this only fires on a dead link.
151
+ */
152
+ export const BLE_WRITE_PACKET_TIMEOUT_MS = 10_000;
153
+ const WEDGED_WRITE_MESSAGE = 'BLE write timeout after';
154
+ const isWedgedWriteError = (error: unknown): boolean =>
155
+ (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleWriteCharacteristicError &&
156
+ typeof (error as { message?: unknown })?.message === 'string' &&
157
+ (error as { message: string }).message.startsWith(WEDGED_WRITE_MESSAGE);
158
+ /** Consecutive wedged writes on one device before the BLE manager itself is recreated. */
159
+ export const BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
160
+ const DEVICE_SCAN_TIMEOUT_MS = 3000;
113
161
  const IOS_NOTIFY_READY_DELAY_MS = 150;
114
162
  const ANDROID_NOTIFY_READY_DELAY_MS = 300;
115
- const HIGH_VOLUME_WRITE_BURST_SIZE = Platform.OS === 'ios' ? 4 : 6;
116
- const HIGH_VOLUME_WRITE_PAUSE_MS = Platform.OS === 'ios' ? 6 : 2;
117
- const HIGH_VOLUME_WRITE_FLUSH_DELAY_MS = Platform.OS === 'ios' ? 20 : 8;
118
-
119
163
  export type ProtocolV2BleTuning = {
120
164
  iosPacketLength?: number;
121
165
  androidPacketLength?: number;
122
- highVolumeWriteBurstSize?: number;
123
- highVolumeWritePauseMs?: number;
124
- highVolumeWriteFlushDelayMs?: number;
125
- highVolumeWriteWithResponse?: boolean;
126
166
  };
127
167
 
128
168
  type ResolvedProtocolV2BleTuning = Required<ProtocolV2BleTuning>;
129
169
 
130
170
  const DEFAULT_PROTOCOL_V2_BLE_TUNING: ResolvedProtocolV2BleTuning = {
131
- iosPacketLength: IOS_PACKET_LENGTH,
132
- androidPacketLength: ANDROID_PACKET_LENGTH,
133
- highVolumeWriteBurstSize: HIGH_VOLUME_WRITE_BURST_SIZE,
134
- highVolumeWritePauseMs: HIGH_VOLUME_WRITE_PAUSE_MS,
135
- highVolumeWriteFlushDelayMs: HIGH_VOLUME_WRITE_FLUSH_DELAY_MS,
136
- highVolumeWriteWithResponse: false,
171
+ iosPacketLength: IOS_PROTOCOL_V2_PACKET_LENGTH,
172
+ androidPacketLength: ANDROID_PROTOCOL_V2_PACKET_LENGTH,
137
173
  };
138
174
 
139
175
  let protocolV2BleTuning: ResolvedProtocolV2BleTuning = { ...DEFAULT_PROTOCOL_V2_BLE_TUNING };
@@ -154,27 +190,13 @@ export function configureProtocolV2BleTuning(tuning: ProtocolV2BleTuning = {}) {
154
190
  tuning.androidPacketLength,
155
191
  protocolV2BleTuning.androidPacketLength
156
192
  ),
157
- highVolumeWriteBurstSize: normalizePositiveInteger(
158
- tuning.highVolumeWriteBurstSize,
159
- protocolV2BleTuning.highVolumeWriteBurstSize
160
- ),
161
- highVolumeWritePauseMs: normalizePositiveInteger(
162
- tuning.highVolumeWritePauseMs,
163
- protocolV2BleTuning.highVolumeWritePauseMs
164
- ),
165
- highVolumeWriteFlushDelayMs: normalizePositiveInteger(
166
- tuning.highVolumeWriteFlushDelayMs,
167
- protocolV2BleTuning.highVolumeWriteFlushDelayMs
168
- ),
169
- highVolumeWriteWithResponse:
170
- tuning.highVolumeWriteWithResponse ?? protocolV2BleTuning.highVolumeWriteWithResponse,
171
193
  };
172
- Log?.debug('[ReactNativeBleTransport] Protocol V2 BLE tuning configured:', protocolV2BleTuning);
194
+ Log?.debug('[ReactNativeBleTransport] BLE tuning configured', protocolV2BleTuning);
173
195
  }
174
196
 
175
197
  export function resetProtocolV2BleTuning() {
176
198
  protocolV2BleTuning = { ...DEFAULT_PROTOCOL_V2_BLE_TUNING };
177
- Log?.debug('[ReactNativeBleTransport] Protocol V2 BLE tuning reset:', protocolV2BleTuning);
199
+ Log?.debug('[ReactNativeBleTransport] BLE tuning reset', protocolV2BleTuning);
178
200
  }
179
201
 
180
202
  export function getProtocolV2BleTuning() {
@@ -189,24 +211,60 @@ function getDeviceDisplayName(device?: Device | null) {
189
211
  return device?.name || device?.localName || null;
190
212
  }
191
213
 
192
- function isGenericBleService(uuid?: string | null) {
193
- return ['1800', '1801', '180a'].includes(getBleUuidKey(uuid));
194
- }
214
+ const IOS_REQUEST_MTU = 247;
215
+ const ANDROID_REQUEST_MTU = 517;
216
+ const BLE_MTU_REFRESH_RETRY_DELAY_MS = 200;
217
+ const ANDROID_HIGH_PRIORITY_IDLE_MS = 1000;
195
218
 
196
- function hasKnownOneKeyService(device?: Device | null) {
197
- return (device?.serviceUUIDs ?? []).some(serviceUuid =>
198
- getInfosForServiceUuid(serviceUuid, 'classic')
199
- );
200
- }
219
+ const getRequestedBleMtu = () =>
220
+ Platform.OS === 'android' ? ANDROID_REQUEST_MTU : IOS_REQUEST_MTU;
201
221
 
202
- const ANDROID_REQUEST_MTU = 256;
222
+ const BLE_NATIVE_CONNECT_TIMEOUT_MS = 3000;
203
223
 
204
224
  const connectOptions: Record<string, unknown> = {
205
- requestMTU: ANDROID_REQUEST_MTU,
206
- timeout: 3000,
225
+ requestMTU: getRequestedBleMtu(),
226
+ timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
207
227
  refreshGatt: 'OnConnected',
208
228
  };
209
229
 
230
+ /** Fallback connect options: drops requestMTU (the thing being worked around) but keeps the native budget. */
231
+ const fallbackConnectOptions: Record<string, unknown> = {
232
+ timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
233
+ };
234
+
235
+ /**
236
+ * JS backstop for connect. The native adapter applies its own 3s budget, but it
237
+ * schedules that timeout on its serial queue, so a busy queue (e.g. right after a
238
+ * firmware install tears the link down) can leave the promise unsettled — observed
239
+ * blocking a reconnect for 61s until the app-level timeout. Healthy connects finish
240
+ * inside the native budget, so this only fires when the native timeout did not.
241
+ */
242
+ export const BLE_CONNECT_TIMEOUT_MS = BLE_NATIVE_CONNECT_TIMEOUT_MS * 2 + 2000;
243
+ /**
244
+ * Service discovery and characteristic resolution run after connect() succeeds, but
245
+ * CoreBluetooth schedules them on the same serial queue. If that queue is wedged by a
246
+ * device reboot, these calls can remain pending forever unless they have their own
247
+ * budget.
248
+ */
249
+ export const BLE_GATT_SETUP_TIMEOUT_MS = 10_000;
250
+ /**
251
+ * How many times a known device may fail its own protocol before we probe the others
252
+ * again. Reconnect polling during a device reboot repeats this every few seconds, and
253
+ * probing Protocol V2 costs a 10s Ping timeout, so paying it on every attempt for a
254
+ * device we just spoke V1 to dominates the wait. A firmware update can legitimately
255
+ * change a device's protocol, so the shortcut has to expire rather than stick.
256
+ */
257
+ export const PROTOCOL_REPROBE_FALLBACK_ATTEMPTS = 3;
258
+ /** BLE setup timeouts since the last successful setup before the manager is recreated. */
259
+ export const BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
260
+ const CONNECT_TIMEOUT_MESSAGE = 'BLE connect timeout after';
261
+ const isConnectTimeoutError = (error: unknown): boolean =>
262
+ (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleConnectedError &&
263
+ typeof (error as { message?: unknown })?.message === 'string' &&
264
+ (error as { message: string }).message.startsWith(CONNECT_TIMEOUT_MESSAGE);
265
+ const isNativeOperationTimeoutError = (error: unknown): boolean =>
266
+ (error as { errorCode?: unknown })?.errorCode === BleErrorCode.OperationTimedOut;
267
+
210
268
  export type IOneKeyDevice = OneKeyDeviceInfoBase & Device;
211
269
 
212
270
  const tryToGetConfiguration = (device: Device) => {
@@ -218,22 +276,32 @@ const tryToGetConfiguration = (device: Device) => {
218
276
  return infos;
219
277
  };
220
278
 
221
- const requestAndroidMtu = async (device: Device) => {
222
- if (Platform.OS !== 'android') return device;
279
+ const requestNegotiatedMtu = async (
280
+ device: Device,
281
+ stage: 'connected' | 'servicesAndNotifyReady' | 'highThroughput',
282
+ attempt: number
283
+ ) => {
284
+ if (Platform.OS !== 'ios' && Platform.OS !== 'android') return device;
223
285
 
224
286
  try {
225
- const mtuDevice = await device.requestMTU(ANDROID_REQUEST_MTU);
226
- Log?.debug('[ReactNativeBleTransport] Android MTU requested:', {
227
- requested: ANDROID_REQUEST_MTU,
228
- mtu: mtuDevice.mtu,
229
- });
287
+ // iOS ignores the requested value but react-native-ble-plx returns a fresh
288
+ // Device snapshot whose MTU is derived from CoreBluetooth's maximum write length.
289
+ const mtuDevice = await device.requestMTU(getRequestedBleMtu());
230
290
  return mtuDevice;
231
291
  } catch (error) {
232
- Log?.debug('[ReactNativeBleTransport] Android MTU request failed:', error);
292
+ Log?.debug('[ReactNativeBleTransport] MTU refresh failed, continuing with current value', {
293
+ platform: Platform.OS,
294
+ stage,
295
+ attempt,
296
+ actual: device.mtu,
297
+ error: error instanceof Error ? error.message : String(error),
298
+ });
233
299
  return device;
234
300
  }
235
301
  };
236
302
 
303
+ const resolveNegotiatedMtu = (device: Device) => requestNegotiatedMtu(device, 'connected', 0);
304
+
237
305
  type IOBleErrorRemap = Error | BleError | null | undefined;
238
306
 
239
307
  function remapError(error: IOBleErrorRemap) {
@@ -273,6 +341,8 @@ export default class ReactNativeBleTransport {
273
341
 
274
342
  _messagesV2: ReturnType<typeof transport.parseConfigure> | undefined;
275
343
 
344
+ private protocolV2SchemaConfiguration: string | undefined;
345
+
276
346
  name = 'ReactNativeBleTransport';
277
347
 
278
348
  configured = false;
@@ -283,6 +353,8 @@ export default class ReactNativeBleTransport {
283
353
 
284
354
  runPromise: Deferred<any> | null = null;
285
355
 
356
+ private runPromiseDeviceId: string | null = null;
357
+
286
358
  emitter?: EventEmitter;
287
359
 
288
360
  firmwareUploadWriteRecoveryIds = new Set<string>();
@@ -290,8 +362,28 @@ export default class ReactNativeBleTransport {
290
362
  /** Per-device protocol type detected by active wire-level probe after connect. */
291
363
  private deviceProtocol: Map<string, ProtocolType> = new Map();
292
364
 
365
+ /**
366
+ * Protocol a probe is currently trying, before the device has confirmed it. Calls
367
+ * must route with it, but acquire() must not treat it as a detected protocol: a
368
+ * probe that never answers would otherwise leave the reuse fast path handing out a
369
+ * transport that was never validated.
370
+ */
371
+ private probingProtocols: Map<string, ProtocolType> = new Map();
372
+
373
+ /** Consecutive write timeouts per device; reset by any write that completes. */
374
+ private writeTimeoutCounts: Map<string, number> = new Map();
375
+
376
+ /** BLE setup timeouts per device since the last complete characteristic resolution. */
377
+ private connectionSetupTimeoutCounts: Map<string, number> = new Map();
378
+
293
379
  private deviceProtocolHints: Map<string, ProtocolType> = new Map();
294
380
 
381
+ /** Protocol this device actually answered on, kept across reconnects of one session. */
382
+ private sessionProtocols: Map<string, ProtocolType> = new Map();
383
+
384
+ /** Consecutive detections that failed while trusting sessionProtocols. */
385
+ private protocolReprobeFailures: Map<string, number> = new Map();
386
+
295
387
  private protocolV2Assemblers: Map<string, ProtocolV2FrameAssembler> = new Map();
296
388
 
297
389
  private protocolV2FrameQueues: Map<string, Uint8Array[]> = new Map();
@@ -314,13 +406,21 @@ export default class ReactNativeBleTransport {
314
406
  this.rejectProtocolV2Frames(uuid, new Error(reason));
315
407
  Log?.debug('[ReactNativeBleTransport] Protocol V2 link invalidated:', uuid, reason);
316
408
  if (reason.startsWith('Protocol V2 link-fatal error:')) {
317
- await this.release(uuid, true);
409
+ await this.releaseNative(uuid, true);
318
410
  }
319
411
  },
320
412
  });
321
413
 
322
414
  private monitorTokens: Map<string, number> = new Map();
323
415
 
416
+ private disconnectEventTokens: Map<string, number> = new Map();
417
+
418
+ private protocolV2HighVolumeLogSignatures: Map<string, Set<string>> = new Map();
419
+
420
+ private androidHighPriorityDevices: Set<string> = new Set();
421
+
422
+ private androidPriorityResetTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
423
+
324
424
  private nextMonitorToken = 1;
325
425
 
326
426
  constructor(options: TransportOptions) {
@@ -339,11 +439,19 @@ export default class ReactNativeBleTransport {
339
439
  }
340
440
 
341
441
  configureProtocolV2(signedData: any) {
442
+ const configuration = typeof signedData === 'string' ? signedData : JSON.stringify(signedData);
443
+ if (this.protocolV2SchemaConfiguration === configuration) {
444
+ return;
445
+ }
446
+
447
+ const isReconfiguration = this.protocolV2SchemaConfiguration !== undefined;
342
448
  this._messagesV2 = parseConfigure(signedData);
343
- this.protocolV2Links
344
- .invalidateAllLinks('Protocol V2 schema reconfigured')
345
- .catch(error => Log?.debug('Protocol V2 schema link cleanup failed:', error));
346
- Log?.debug('[ReactNativeBleTransport] Protocol V2 schema configured');
449
+ this.protocolV2SchemaConfiguration = configuration;
450
+ if (isReconfiguration) {
451
+ this.protocolV2Links
452
+ .invalidateAllLinks('Protocol V2 schema reconfigured')
453
+ .catch(error => Log?.debug('Protocol V2 schema link cleanup failed:', error));
454
+ }
347
455
  }
348
456
 
349
457
  listen() {
@@ -373,29 +481,15 @@ export default class ReactNativeBleTransport {
373
481
  }
374
482
  }
375
483
 
376
- let fallbackServiceUuid: string | undefined;
377
-
378
484
  if (!infos) {
379
485
  const services = await device.services();
380
486
  Log?.debug(
381
487
  '[ReactNativeBleTransport] Known OneKey service UUID not found, discovered services:',
382
488
  services?.map(service => service.uuid)
383
489
  );
384
-
385
- const knownService = services.find(service =>
386
- getInfosForServiceUuid(service.uuid, 'classic')
387
- );
388
- const fallbackService =
389
- knownService ?? services.find(service => !isGenericBleService(service.uuid)) ?? services[0];
390
-
391
- if (fallbackService) {
392
- fallbackServiceUuid = fallbackService.uuid;
393
- characteristics = await device.characteristicsForService(fallbackService.uuid);
394
- Log?.debug('[ReactNativeBleTransport] Using fallback BLE service:', fallbackService.uuid);
395
- }
396
490
  }
397
491
 
398
- if (!infos && !fallbackServiceUuid) {
492
+ if (!infos) {
399
493
  try {
400
494
  Log?.debug('cancel connection when service not found');
401
495
  await device.cancelConnection();
@@ -405,9 +499,7 @@ export default class ReactNativeBleTransport {
405
499
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound);
406
500
  }
407
501
 
408
- const serviceUuid = infos?.serviceUuid ?? fallbackServiceUuid;
409
- const writeUuid = infos?.writeUuid ?? '00000002-0000-1000-8000-00805f9b34fb';
410
- const notifyUuid = infos?.notifyUuid ?? '00000003-0000-1000-8000-00805f9b34fb';
502
+ const { serviceUuid, writeUuid, notifyUuid } = infos;
411
503
 
412
504
  if (!serviceUuid) {
413
505
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound);
@@ -457,6 +549,7 @@ export default class ReactNativeBleTransport {
457
549
 
458
550
  attachDisconnectSubscription(transport: BleTransport, device: Device, uuid: string) {
459
551
  transport.disconnectSubscription?.remove();
552
+ const { monitorToken } = transport;
460
553
  transport.disconnectSubscription = device.onDisconnected(() => {
461
554
  if (this.firmwareUploadWriteRecoveryIds.has(uuid)) {
462
555
  Log?.debug('device disconnect ignored during FirmwareUpload write recovery: ', uuid);
@@ -466,18 +559,17 @@ export default class ReactNativeBleTransport {
466
559
  Log?.debug('device disconnect ignored for stale transport: ', device?.id);
467
560
  return;
468
561
  }
562
+ if (this.monitorTokens.get(uuid) !== monitorToken) {
563
+ Log?.debug('device disconnect ignored for stale generation: ', device?.id);
564
+ return;
565
+ }
469
566
 
470
567
  try {
471
568
  Log?.debug('device disconnect: ', device?.id);
472
- this.emitter?.emit('device-disconnect', {
473
- name: device?.name,
474
- id: device?.id,
475
- connectId: device?.id,
476
- });
477
- if (this.runPromise) {
569
+ this.emitDeviceDisconnect(uuid, device?.name, monitorToken);
570
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
478
571
  const error = ERRORS.TypedError(HardwareErrorCode.BleConnectedError);
479
572
  this.runPromise.reject(error);
480
- this.rejectAllProtocolV2Frames(error);
481
573
  }
482
574
  } catch (e) {
483
575
  Log?.debug('device disconnect error: ', e);
@@ -487,6 +579,22 @@ export default class ReactNativeBleTransport {
487
579
  });
488
580
  }
489
581
 
582
+ private emitDeviceDisconnect(uuid: string, name: string | null | undefined, token?: number) {
583
+ if (token === undefined || this.disconnectEventTokens.get(uuid) === token) {
584
+ return;
585
+ }
586
+ if (this.monitorTokens.get(uuid) !== token) {
587
+ Log?.debug('device disconnect event ignored for stale generation: ', uuid);
588
+ return;
589
+ }
590
+ this.disconnectEventTokens.set(uuid, token);
591
+ this.emitter?.emit(TRANSPORT_EVENT.DEVICE_DISCONNECT, {
592
+ name,
593
+ id: uuid,
594
+ connectId: uuid,
595
+ });
596
+ }
597
+
490
598
  async reconnectFirmwareUploadTransport(uuid: string, transport: BleTransport) {
491
599
  this.firmwareUploadWriteRecoveryIds.add(uuid);
492
600
  try {
@@ -499,22 +607,21 @@ export default class ReactNativeBleTransport {
499
607
  const isConnected = await device.isConnected().catch(() => false);
500
608
  if (!isConnected) {
501
609
  try {
502
- device = await device.connect(connectOptions);
610
+ device = await this.connectWithTimeout(uuid, () => device.connect(connectOptions));
503
611
  } catch (e) {
504
612
  if (
505
613
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
506
614
  e.errorCode === BleErrorCode.OperationCancelled
507
615
  ) {
508
- device = await device.connect();
616
+ device = await this.connectWithTimeout(uuid, () => device.connect());
509
617
  } else if (e.errorCode !== BleErrorCode.DeviceAlreadyConnected) {
510
618
  throw e;
511
619
  }
512
620
  }
513
621
  }
514
622
 
515
- const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(
516
- device
517
- );
623
+ const { writeCharacteristic, notifyCharacteristic } =
624
+ await this.resolveCharacteristicsWithTimeout(uuid, device);
518
625
 
519
626
  transport.device = device;
520
627
  transport.writeCharacteristic = writeCharacteristic;
@@ -574,14 +681,13 @@ export default class ReactNativeBleTransport {
574
681
  }
575
682
 
576
683
  blePlxManager.startDeviceScan(
577
- null,
684
+ getBluetoothServiceUuids(),
578
685
  {
579
686
  allowDuplicates: true,
580
687
  scanMode: ScanMode.LowLatency,
581
688
  },
582
689
  (error, device) => {
583
690
  if (error) {
584
- Log?.debug('ble scan manager: ', blePlxManager);
585
691
  Log?.debug('ble scan error: ', error);
586
692
  if (
587
693
  [BleErrorCode.BluetoothPoweredOff, BleErrorCode.BluetoothInUnknownState].includes(
@@ -605,33 +711,23 @@ export default class ReactNativeBleTransport {
605
711
  }
606
712
 
607
713
  const displayName = getDeviceDisplayName(device);
714
+ // iOS may report a service-only advertisement before the named scan response.
715
+ // Do not cache that incomplete advertisement as an unknown device.
716
+ const isUnnamedIOSPeripheral = Platform.OS === 'ios' && !displayName?.trim();
717
+ const isFindMyPeripheral =
718
+ isPro2FindMyAdvertisementName(device?.name) ||
719
+ isPro2FindMyAdvertisementName(device?.localName);
608
720
  const isOneKey =
609
- isOnekeyDevice(device?.name ?? null, device?.id) ||
610
- isOnekeyDevice(device?.localName ?? null, device?.id) ||
611
- hasKnownOneKeyService(device);
612
- const shouldTraceCandidate =
613
- !!displayName && /onekey|bixinkey|pro\s*2|pro\b|touch|^k\d|^t\d/i.test(displayName);
614
-
615
- if (shouldTraceCandidate) {
616
- Log?.debug('[ReactNativeBleTransport] scan candidate', {
721
+ !isUnnamedIOSPeripheral &&
722
+ !isFindMyPeripheral &&
723
+ isOnekeyBluetoothDevice({
724
+ id: device?.id,
617
725
  name: device?.name,
618
726
  localName: device?.localName,
619
- id: device?.id,
620
- serviceUUIDs: device?.serviceUUIDs,
621
- accepted: isOneKey,
727
+ serviceUuids: device?.serviceUUIDs,
622
728
  });
623
- }
624
-
625
729
  if (isOneKey) {
626
- Log?.debug('search device start ======================');
627
- const { name, localName, id, serviceUUIDs } = device ?? {};
628
- Log?.debug(
629
- `device name: ${name ?? ''}\nlocalName: ${localName ?? ''}\nid: ${
630
- id ?? ''
631
- }\nserviceUUIDs: ${(serviceUUIDs ?? []).join(',')}`
632
- );
633
730
  addDevice(device as unknown as Device);
634
- Log?.debug('search device end ======================\n');
635
731
  } else if (displayName && /\bpro\s*2\b/i.test(displayName)) {
636
732
  Log?.debug('[ReactNativeBleTransport] Pro2-like BLE device was not accepted:', {
637
733
  name: device?.name,
@@ -643,12 +739,32 @@ export default class ReactNativeBleTransport {
643
739
  }
644
740
  );
645
741
 
646
- getConnectedDeviceIds(getBluetoothServiceUuids()).then(devices => {
647
- for (const device of devices) {
648
- Log?.debug('search connected peripheral: ', device.id);
649
- addDevice(device as unknown as Device);
742
+ getConnectedDeviceIds(Platform.OS === 'ios' ? getBluetoothServiceUuids() : []).then(
743
+ devices => {
744
+ for (const device of devices) {
745
+ const localName =
746
+ 'localName' in device && typeof device.localName === 'string'
747
+ ? device.localName
748
+ : null;
749
+ const isFindMyPeripheral =
750
+ isPro2FindMyAdvertisementName(device.name) ||
751
+ isPro2FindMyAdvertisementName(localName);
752
+
753
+ if (
754
+ !isFindMyPeripheral &&
755
+ isOnekeyBluetoothDevice({
756
+ id: device.id,
757
+ name: device.name,
758
+ localName,
759
+ serviceUuids: device.serviceUUIDs,
760
+ })
761
+ ) {
762
+ Log?.debug('search connected peripheral: ', device.id);
763
+ addDevice(device as unknown as Device);
764
+ }
765
+ }
650
766
  }
651
- });
767
+ );
652
768
 
653
769
  const addDevice = (device: Device) => {
654
770
  if (deviceList.every(d => d.id !== device.id)) {
@@ -662,6 +778,12 @@ export default class ReactNativeBleTransport {
662
778
  name: displayName,
663
779
  commType: 'ble',
664
780
  } as IOneKeyDevice);
781
+ Log?.debug('[ReactNativeBleTransport] OneKey BLE device discovered', {
782
+ deviceId: device.id,
783
+ name: displayName,
784
+ serviceUUIDs: device.serviceUUIDs,
785
+ protocolHint,
786
+ });
665
787
  }
666
788
  };
667
789
 
@@ -672,6 +794,79 @@ export default class ReactNativeBleTransport {
672
794
  });
673
795
  }
674
796
 
797
+ private async installTransportForAcquire(
798
+ uuid: string,
799
+ device: Device,
800
+ characteristics?: ResolvedBleCharacteristics
801
+ ) {
802
+ const { writeCharacteristic, notifyCharacteristic } =
803
+ characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, device));
804
+ const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
805
+ transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : undefined;
806
+ const monitorToken = this.nextMonitorToken;
807
+ this.nextMonitorToken += 1;
808
+ const notifyTransactionId = `${uuid}:notify:${monitorToken}`;
809
+ transport.monitorToken = monitorToken;
810
+ transport.notifyTransactionId = notifyTransactionId;
811
+ this.monitorTokens.set(uuid, monitorToken);
812
+ transport.notifySubscription = this._monitorCharacteristic(
813
+ transport.notifyCharacteristic,
814
+ uuid,
815
+ monitorToken,
816
+ notifyTransactionId
817
+ );
818
+ transportCache[uuid] = transport;
819
+ this.protocolV2HighVolumeLogSignatures.set(uuid, new Set());
820
+ this.protocolV2Assemblers.set(
821
+ uuid,
822
+ new ProtocolV2FrameAssembler(PROTOCOL_V2_BLE_FRAME_MAX_BYTES)
823
+ );
824
+
825
+ if (Platform.OS === 'ios') {
826
+ await new Promise<void>(resolve => {
827
+ setTimeout(resolve, IOS_NOTIFY_READY_DELAY_MS);
828
+ });
829
+ } else if (Platform.OS === 'android') {
830
+ await delay(ANDROID_NOTIFY_READY_DELAY_MS);
831
+ }
832
+
833
+ const initialMtu = transport.mtuSize;
834
+ let refreshAttempts = 0;
835
+ if (
836
+ (Platform.OS === 'ios' || Platform.OS === 'android') &&
837
+ shouldRefreshNegotiatedMtu(transport.mtuSize)
838
+ ) {
839
+ refreshAttempts += 1;
840
+ let refreshedDevice = await requestNegotiatedMtu(
841
+ transport.device,
842
+ 'servicesAndNotifyReady',
843
+ 1
844
+ );
845
+ transport.device = refreshedDevice;
846
+ transport.mtuSize =
847
+ typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
848
+
849
+ if (shouldRefreshNegotiatedMtu(transport.mtuSize)) {
850
+ await delay(BLE_MTU_REFRESH_RETRY_DELAY_MS);
851
+ refreshAttempts += 1;
852
+ refreshedDevice = await requestNegotiatedMtu(transport.device, 'servicesAndNotifyReady', 2);
853
+ transport.device = refreshedDevice;
854
+ transport.mtuSize =
855
+ typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
856
+ }
857
+ }
858
+
859
+ Log?.debug('[ReactNativeBleTransport] BLE MTU ready', {
860
+ platform: Platform.OS,
861
+ requested: getRequestedBleMtu(),
862
+ initial: initialMtu,
863
+ actual: transport.mtuSize,
864
+ refreshAttempts,
865
+ });
866
+
867
+ return transport;
868
+ }
869
+
675
870
  async acquire(input: BleAcquireInput) {
676
871
  const { uuid, forceCleanRunPromise, expectedProtocol } = input;
677
872
 
@@ -705,8 +900,8 @@ export default class ReactNativeBleTransport {
705
900
  if (forceCleanRunPromise && this.runPromise) {
706
901
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
707
902
  this.runPromise.reject(error);
708
- this.rejectAllProtocolV2Frames(error);
709
903
  this.runPromise = null;
904
+ this.runPromiseDeviceId = null;
710
905
  Log?.debug('Force clean Bluetooth run promise, forceCleanRunPromise: ', forceCleanRunPromise);
711
906
  }
712
907
 
@@ -742,15 +937,22 @@ export default class ReactNativeBleTransport {
742
937
  if (!device) {
743
938
  Log?.debug('try to connect to device: ', uuid);
744
939
  try {
745
- device = await blePlxManager.connectToDevice(uuid, connectOptions);
940
+ device = await this.connectWithTimeout(uuid, () =>
941
+ blePlxManager.connectToDevice(uuid, connectOptions)
942
+ );
746
943
  } catch (e) {
747
944
  Log?.debug('try to connect to device has error: ', e);
945
+ if (isConnectTimeoutError(e)) {
946
+ throw e;
947
+ }
748
948
  if (
749
949
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
750
950
  e.errorCode === BleErrorCode.OperationCancelled
751
951
  ) {
752
952
  Log?.debug('first try to reconnect without params');
753
- device = await blePlxManager.connectToDevice(uuid);
953
+ device = await this.connectWithTimeout(uuid, () =>
954
+ blePlxManager.connectToDevice(uuid, fallbackConnectOptions)
955
+ );
754
956
  } else if (e.errorCode === BleErrorCode.DeviceAlreadyConnected) {
755
957
  Log?.debug('device already connected');
756
958
  throw ERRORS.TypedError(HardwareErrorCode.BleAlreadyConnected);
@@ -766,26 +968,36 @@ export default class ReactNativeBleTransport {
766
968
 
767
969
  if (!(await device.isConnected())) {
768
970
  Log?.debug('not connected, try to connect to device: ', uuid);
971
+ const disconnectedDevice = device;
769
972
 
770
973
  try {
771
- device = await device.connect(connectOptions);
974
+ device = await this.connectWithTimeout(uuid, () =>
975
+ disconnectedDevice.connect(connectOptions)
976
+ );
772
977
  } catch (e) {
773
978
  Log?.debug('not connected, try to connect to device has error: ', e);
979
+ if (isConnectTimeoutError(e)) {
980
+ throw e;
981
+ }
774
982
  if (
775
983
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
776
984
  e.errorCode === BleErrorCode.OperationCancelled
777
985
  ) {
778
986
  Log?.debug('second try to reconnect without params');
779
987
  try {
780
- device = await device.connect();
988
+ device = await this.connectWithTimeout(uuid, () =>
989
+ disconnectedDevice.connect(fallbackConnectOptions)
990
+ );
781
991
  } catch (e) {
782
992
  Log?.debug('last try to reconnect error: ', e);
783
993
  // last try to reconnect device if this issue exists
784
994
  // https://github.com/dotintent/react-native-ble-plx/issues/426
785
995
  if (e.errorCode === BleErrorCode.OperationCancelled) {
786
996
  Log?.debug('last try to reconnect');
787
- await device.cancelConnection();
788
- device = await device.connect();
997
+ await disconnectedDevice.cancelConnection();
998
+ device = await this.connectWithTimeout(uuid, () =>
999
+ disconnectedDevice.connect(fallbackConnectOptions)
1000
+ );
789
1001
  }
790
1002
  }
791
1003
  } else {
@@ -794,13 +1006,16 @@ export default class ReactNativeBleTransport {
794
1006
  }
795
1007
  }
796
1008
 
797
- device = await requestAndroidMtu(device);
798
- const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(device);
1009
+ device = await resolveNegotiatedMtu(device);
1010
+ const acquiredDevice = device;
1011
+ const { writeCharacteristic, notifyCharacteristic } =
1012
+ await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice);
799
1013
 
800
1014
  const protocolHint = expectedProtocol
801
1015
  ? undefined
802
- : this.deviceProtocolHints.get(uuid) ??
803
- inferProtocolHintFromDeviceName(getDeviceDisplayName(device));
1016
+ : input.protocolHint ??
1017
+ this.deviceProtocolHints.get(uuid) ??
1018
+ inferProtocolHintFromDeviceName(getDeviceDisplayName(acquiredDevice));
804
1019
 
805
1020
  // release transport before new transport instance
806
1021
  await this.release(uuid, true);
@@ -808,45 +1023,30 @@ export default class ReactNativeBleTransport {
808
1023
  this.deviceProtocolHints.set(uuid, protocolHint);
809
1024
  }
810
1025
 
811
- const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
812
- if (Platform.OS === 'android') {
813
- transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : transport.mtuSize;
814
- }
815
- const monitorToken = this.nextMonitorToken;
816
- this.nextMonitorToken += 1;
817
- const notifyTransactionId = `${uuid}:notify:${monitorToken}`;
818
- transport.monitorToken = monitorToken;
819
- transport.notifyTransactionId = notifyTransactionId;
820
- this.monitorTokens.set(uuid, monitorToken);
821
- transport.notifySubscription = this._monitorCharacteristic(
822
- transport.notifyCharacteristic,
823
- uuid,
824
- monitorToken,
825
- notifyTransactionId
826
- );
827
- transportCache[uuid] = transport;
828
-
829
- this.protocolV2Assemblers.set(uuid, new ProtocolV2FrameAssembler());
830
-
831
- if (Platform.OS === 'ios') {
832
- await new Promise<void>(resolve => {
833
- setTimeout(resolve, IOS_NOTIFY_READY_DELAY_MS);
834
- });
835
- } else if (Platform.OS === 'android') {
836
- await delay(ANDROID_NOTIFY_READY_DELAY_MS);
837
- }
838
-
839
- const protocolType = await this.detectProtocol(uuid, expectedProtocol, protocolHint);
840
-
841
- this.emitter?.emit('device-connect', {
842
- name: device.name,
843
- id: device.id,
844
- connectId: device.id,
1026
+ await this.installTransportForAcquire(uuid, acquiredDevice, {
1027
+ writeCharacteristic,
1028
+ notifyCharacteristic,
845
1029
  });
846
1030
 
847
- this.attachDisconnectSubscription(transport, device, uuid);
848
-
849
- return { uuid, protocolType };
1031
+ try {
1032
+ const protocolType = await this.detectProtocol(
1033
+ uuid,
1034
+ expectedProtocol,
1035
+ protocolHint,
1036
+ async () => {
1037
+ await this.installTransportForAcquire(uuid, acquiredDevice);
1038
+ }
1039
+ );
1040
+ const currentTransport = transportCache[uuid];
1041
+ if (!currentTransport) {
1042
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
1043
+ }
1044
+ this.attachDisconnectSubscription(currentTransport, currentTransport.device, uuid);
1045
+ return { uuid, protocolType };
1046
+ } catch (error) {
1047
+ await this.release(uuid, true);
1048
+ throw error;
1049
+ }
850
1050
  }
851
1051
 
852
1052
  _monitorCharacteristic(
@@ -873,7 +1073,7 @@ export default class ReactNativeBleTransport {
873
1073
  Log?.debug('monitor error ignored for stale transport: ', uuid, notifyTransactionId);
874
1074
  return;
875
1075
  }
876
- if (this.deviceProtocol.get(uuid) === 'V2') {
1076
+ if (this.getActiveProtocol(uuid) === 'V2') {
877
1077
  let errorCode:
878
1078
  | typeof HardwareErrorCode.BleDeviceBondError
879
1079
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -896,7 +1096,7 @@ export default class ReactNativeBleTransport {
896
1096
  this.rejectProtocolV2Frames(uuid, ERRORS.TypedError(errorCode));
897
1097
  return;
898
1098
  }
899
- if (this.runPromise) {
1099
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
900
1100
  let ERROR:
901
1101
  | typeof HardwareErrorCode.BleDeviceBondError
902
1102
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -919,7 +1119,6 @@ export default class ReactNativeBleTransport {
919
1119
  HardwareErrorCode.BleCharacteristicNotifyChangeFailure
920
1120
  );
921
1121
  this.runPromise.reject(notifyError);
922
- this.rejectAllProtocolV2Frames(notifyError);
923
1122
  Log?.debug(
924
1123
  `${HardwareErrorCode.BleCharacteristicNotifyChangeFailure} ${error.message} ${error.reason}`
925
1124
  );
@@ -927,7 +1126,6 @@ export default class ReactNativeBleTransport {
927
1126
  }
928
1127
  const notifyError = ERRORS.TypedError(ERROR);
929
1128
  this.runPromise.reject(notifyError);
930
- this.rejectAllProtocolV2Frames(notifyError);
931
1129
  Log?.debug(': monitor notify error, and has unreleased Promise', Error);
932
1130
  }
933
1131
 
@@ -945,7 +1143,7 @@ export default class ReactNativeBleTransport {
945
1143
 
946
1144
  try {
947
1145
  const data = Buffer.from(c.value as string, 'base64');
948
- const protocol = this.deviceProtocol.get(uuid);
1146
+ const protocol = this.getActiveProtocol(uuid);
949
1147
  if (!protocol) {
950
1148
  Log?.debug('monitor data ignored before protocol detection: ', uuid);
951
1149
  return;
@@ -972,14 +1170,16 @@ export default class ReactNativeBleTransport {
972
1170
  // );
973
1171
  bufferLength = 0;
974
1172
  buffer = [];
975
- this.runPromise?.resolve(value.toString('hex'));
1173
+ if (this.runPromiseDeviceId === uuid) {
1174
+ this.runPromise?.resolve(value.toString('hex'));
1175
+ }
976
1176
  }
977
1177
  } catch (error) {
978
1178
  Log?.debug('monitor data error: ', error);
979
1179
  const notifyError = ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
980
- if (this.deviceProtocol.get(uuid) === 'V2') {
1180
+ if (this.getActiveProtocol(uuid) === 'V2') {
981
1181
  this.rejectProtocolV2Frames(uuid, notifyError);
982
- } else {
1182
+ } else if (this.runPromiseDeviceId === uuid) {
983
1183
  this.runPromise?.reject(notifyError);
984
1184
  }
985
1185
  }
@@ -989,13 +1189,18 @@ export default class ReactNativeBleTransport {
989
1189
  }
990
1190
 
991
1191
  async release(uuid: string, onclose = false) {
992
- const transport = transportCache[uuid];
993
1192
  await this.protocolV2Links.invalidateLink(uuid, 'React Native BLE transport released');
994
- if (this.runPromise) {
1193
+ return this.releaseNative(uuid, onclose);
1194
+ }
1195
+
1196
+ private async releaseNative(uuid: string, onclose = false) {
1197
+ const transport = transportCache[uuid];
1198
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
995
1199
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
996
1200
  this.runPromise.reject(error);
997
1201
  this.runPromise = null;
998
- this.rejectAllProtocolV2Frames(error);
1202
+ this.runPromiseDeviceId = null;
1203
+ this.rejectProtocolV2Frames(uuid, error);
999
1204
  } else {
1000
1205
  this.resetProtocolV2Frames(uuid);
1001
1206
  }
@@ -1006,6 +1211,8 @@ export default class ReactNativeBleTransport {
1006
1211
  return Promise.resolve(true);
1007
1212
  }
1008
1213
 
1214
+ await this.restoreAndroidConnectionPriority(uuid, transport);
1215
+
1009
1216
  if (transport) {
1010
1217
  if (this.monitorTokens.get(uuid) === transport.monitorToken) {
1011
1218
  this.monitorTokens.delete(uuid);
@@ -1035,8 +1242,11 @@ export default class ReactNativeBleTransport {
1035
1242
  delete transportCache[uuid];
1036
1243
  }
1037
1244
 
1245
+ this.protocolV2HighVolumeLogSignatures.delete(uuid);
1246
+
1038
1247
  this.deviceProtocol.delete(uuid);
1039
- // 设备名称提示不依赖当前连接;保留它可让重连优先探测 V2。
1248
+ this.probingProtocols.delete(uuid);
1249
+ // Preserve a name-derived hint across disconnects so reconnect can probe V2 first.
1040
1250
  this.protocolV2Assemblers.get(uuid)?.reset();
1041
1251
  this.protocolV2Assemblers.delete(uuid);
1042
1252
  this.resetProtocolV2Frames(uuid);
@@ -1075,33 +1285,11 @@ export default class ReactNativeBleTransport {
1075
1285
  `Device protocol has not been detected for ${uuid}`
1076
1286
  );
1077
1287
  }
1078
- // Upload resources on low-end phones may OOM
1079
- if (name === 'ResourceUpdate' || name === 'ResourceAck') {
1080
- Log?.debug('transport-react-native', 'call-', ' name: ', name, ' data: ', {
1081
- file_name: data?.file_name,
1082
- hash: data?.hash,
1083
- });
1084
- } else if (LogBlockCommand.has(name)) {
1085
- Log?.debug('transport-react-native', 'call-', ' name: ', name, ' protocol: ', protocol);
1086
- } else {
1087
- Log?.debug(
1088
- 'transport-react-native',
1089
- 'call-',
1090
- ' name: ',
1091
- name,
1092
- ' data: ',
1093
- data,
1094
- ' protocol: ',
1095
- protocol
1096
- );
1097
- }
1098
-
1099
1288
  if (protocol === 'V2') {
1100
1289
  return this.callProtocolV2(uuid, name, data, options);
1101
1290
  }
1102
1291
 
1103
1292
  const forceRun = name === 'Initialize' || name === 'Cancel';
1104
- Log?.debug('transport-react-native call this.runPromise', this.runPromise);
1105
1293
  if (this.runPromise && !forceRun) {
1106
1294
  throw ERRORS.TypedError(HardwareErrorCode.TransportCallInProgress);
1107
1295
  }
@@ -1122,7 +1310,25 @@ export default class ReactNativeBleTransport {
1122
1310
  const transport = this.getCachedTransport(uuid);
1123
1311
  const runPromise = createDeferred<string>();
1124
1312
  runPromise.promise.catch(() => undefined);
1313
+ const supersededRunPromise = this.runPromise;
1314
+ if (supersededRunPromise) {
1315
+ // Only forceRun calls (Initialize/Cancel) reach here with a pending call. Settle
1316
+ // the superseded deferred now so its response race resolves and its finally block
1317
+ // clears its timeout timer; an orphaned timer would otherwise fire much later and
1318
+ // tear down the shared connection while another call is using it.
1319
+ supersededRunPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise));
1320
+ }
1125
1321
  this.runPromise = runPromise;
1322
+ this.runPromiseDeviceId = uuid;
1323
+ // A superseded call's late write failure must not clear the successor's ownership;
1324
+ // only the call that still owns the slot may release it.
1325
+ const releaseOwnershipIfCurrent = () => {
1326
+ if (this.runPromise === runPromise) {
1327
+ this.runPromise = null;
1328
+ this.runPromiseDeviceId = null;
1329
+ }
1330
+ };
1331
+ const isCurrentOwner = () => this.runPromise === runPromise;
1126
1332
  const messages = this._messages;
1127
1333
  const buffers = ProtocolV1.encodeTransportPackets(messages, name, data);
1128
1334
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -1148,6 +1354,9 @@ export default class ReactNativeBleTransport {
1148
1354
  chunk = ByteBuffer.allocate(packetCapacity);
1149
1355
  } catch (e) {
1150
1356
  onError(e);
1357
+ if (isWedgedWriteError(e)) {
1358
+ throw e;
1359
+ }
1151
1360
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1152
1361
  }
1153
1362
  }
@@ -1179,6 +1388,9 @@ export default class ReactNativeBleTransport {
1179
1388
  }
1180
1389
  } catch (e) {
1181
1390
  onError(e);
1391
+ if (isWedgedWriteError(e)) {
1392
+ throw e;
1393
+ }
1182
1394
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1183
1395
  }
1184
1396
  }
@@ -1192,21 +1404,26 @@ export default class ReactNativeBleTransport {
1192
1404
  if (name === 'EmmcFileWrite') {
1193
1405
  await writeChunkedData(
1194
1406
  buffers,
1195
- data => transport.writeWithRetry(data),
1407
+ data =>
1408
+ this.writeBlePacket(
1409
+ uuid,
1410
+ data,
1411
+ payload => transport.writeWithRetry(payload),
1412
+ isCurrentOwner
1413
+ ),
1196
1414
  e => {
1197
- this.runPromise = null;
1415
+ releaseOwnershipIfCurrent();
1198
1416
  Log?.error('writeCharacteristic write error: ', e);
1199
1417
  }
1200
1418
  );
1201
1419
  } else if (name === 'FirmwareUpload') {
1202
- Log?.debug('[ReactNativeBleTransport] FirmwareUpload write uses throttled BLE packets:', {
1420
+ Log?.debug('[ReactNativeBleTransport] Firmware upload transport configured', {
1203
1421
  packetCapacity: FIRMWARE_UPLOAD_WRITE_PACKET_CAPACITY,
1204
1422
  burstSize: FIRMWARE_UPLOAD_WRITE_BURST_SIZE,
1205
1423
  pauseMs: FIRMWARE_UPLOAD_WRITE_PAUSE_MS,
1206
1424
  flushDelayMs: FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS,
1207
1425
  maxRetries: FIRMWARE_UPLOAD_WRITE_MAX_RETRIES,
1208
1426
  });
1209
-
1210
1427
  await writeFirmwareUploadChunkedData(
1211
1428
  buffers,
1212
1429
  async data => {
@@ -1216,43 +1433,31 @@ export default class ReactNativeBleTransport {
1216
1433
  // eslint-disable-next-line no-constant-condition
1217
1434
  while (true) {
1218
1435
  try {
1219
- await transport.writeCharacteristic.writeWithoutResponse(data);
1436
+ await this.writeBlePacket(
1437
+ uuid,
1438
+ data,
1439
+ payload => transport.writeWithRetry(payload),
1440
+ isCurrentOwner
1441
+ );
1220
1442
  return;
1221
1443
  } catch (error) {
1222
1444
  const retryType = getFirmwareUploadWriteRetryType(error);
1223
1445
  if (!retryType || attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES) {
1224
1446
  throw error;
1225
1447
  }
1226
- const shouldReconnect = retryType === 'reconnectable';
1227
- const delayMs = shouldReconnect
1228
- ? FIRMWARE_UPLOAD_RECONNECT_RETRY_DELAY_MS
1229
- : resolveFirmwareUploadRetryDelay(attempt);
1448
+ const delayMs = resolveFirmwareUploadRetryDelay(attempt);
1230
1449
  Log?.debug('[ReactNativeBleTransport] FirmwareUpload write retry:', {
1231
1450
  attempt: attempt + 1,
1232
1451
  delayMs,
1233
- reconnect: shouldReconnect,
1234
1452
  error,
1235
1453
  });
1236
- if (shouldReconnect) {
1237
- this.firmwareUploadWriteRecoveryIds.add(uuid);
1238
- }
1239
1454
  await delay(delayMs);
1240
1455
  attempt += 1;
1241
- if (shouldReconnect) {
1242
- try {
1243
- await this.reconnectFirmwareUploadTransport(uuid, transport);
1244
- } catch (e) {
1245
- Log?.debug('[ReactNativeBleTransport] FirmwareUpload reconnect error:', e);
1246
- if (attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES) {
1247
- throw e;
1248
- }
1249
- }
1250
- }
1251
1456
  }
1252
1457
  }
1253
1458
  },
1254
1459
  e => {
1255
- this.runPromise = null;
1460
+ releaseOwnershipIfCurrent();
1256
1461
  Log?.error('writeCharacteristic write error: ', e);
1257
1462
  }
1258
1463
  );
@@ -1260,12 +1465,24 @@ export default class ReactNativeBleTransport {
1260
1465
  for (const o of buffers) {
1261
1466
  const outData = o.toString('base64');
1262
1467
  // Upload resources on low-end phones may OOM
1263
- // this.Log.debug('send hex strting: ', o.toString('hex'));
1264
1468
  try {
1265
- await transport.writeCharacteristic.writeWithoutResponse(outData);
1469
+ const shouldUseWriteWithResponse =
1470
+ Platform.OS === 'ios' && transport.writeCharacteristic.isWritableWithResponse;
1471
+ await this.writeBlePacket(
1472
+ uuid,
1473
+ outData,
1474
+ payload =>
1475
+ shouldUseWriteWithResponse
1476
+ ? transport.writeCharacteristic.writeWithResponse(payload)
1477
+ : transport.writeCharacteristic.writeWithoutResponse(payload),
1478
+ isCurrentOwner
1479
+ );
1266
1480
  } catch (e) {
1267
1481
  Log?.debug('writeCharacteristic write error: ', e);
1268
- this.runPromise = null;
1482
+ releaseOwnershipIfCurrent();
1483
+ if (isWedgedWriteError(e)) {
1484
+ throw e;
1485
+ }
1269
1486
  if (e.errorCode === BleErrorCode.DeviceDisconnected) {
1270
1487
  throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded);
1271
1488
  } else if (e.errorCode === BleErrorCode.OperationStartFailed) {
@@ -1298,7 +1515,6 @@ export default class ReactNativeBleTransport {
1298
1515
  throw new Error('Returning data is not string.');
1299
1516
  }
1300
1517
 
1301
- Log?.debug('receive data: ', response);
1302
1518
  const jsonData = ProtocolV1.decodeMessage(messages, response);
1303
1519
  return check.call(jsonData);
1304
1520
  } catch (e) {
@@ -1307,11 +1523,25 @@ export default class ReactNativeBleTransport {
1307
1523
  } else {
1308
1524
  Log?.error('call error: ', e);
1309
1525
  }
1526
+ const isProbeTimeout =
1527
+ name === 'Initialize' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
1528
+ // A call that has been superseded (forceRun) or cleaned up no longer owns the
1529
+ // transport; its late timeout must not tear down the connection the current
1530
+ // call is actively using.
1531
+ const isStaleCall = this.runPromise !== runPromise;
1532
+ if (
1533
+ !isProbeTimeout &&
1534
+ !isStaleCall &&
1535
+ (e as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleTimeoutError
1536
+ ) {
1537
+ await this.disconnect(uuid);
1538
+ }
1310
1539
  throw e;
1311
1540
  } finally {
1312
1541
  if (timeout) clearTimeout(timeout);
1313
1542
  if (this.runPromise === runPromise) {
1314
1543
  this.runPromise = null;
1544
+ this.runPromiseDeviceId = null;
1315
1545
  }
1316
1546
  }
1317
1547
  }
@@ -1321,9 +1551,9 @@ export default class ReactNativeBleTransport {
1321
1551
  }
1322
1552
 
1323
1553
  async disconnect(session: string) {
1324
- Log?.debug('transport-react-native transport resetSession: ', session);
1325
1554
  await this.protocolV2Links.invalidateLink(session, 'React Native BLE transport disconnected');
1326
1555
  const transport = transportCache[session];
1556
+ const monitorToken = transport?.monitorToken ?? this.monitorTokens.get(session);
1327
1557
 
1328
1558
  // Clean up disconnect subscription first to prevent onDisconnected callback
1329
1559
  // from being triggered when we cancel the connection below
@@ -1381,20 +1611,22 @@ export default class ReactNativeBleTransport {
1381
1611
  delete transportCache[session];
1382
1612
  }
1383
1613
  this.deviceProtocol.delete(session);
1614
+ this.probingProtocols.delete(session);
1384
1615
  this.deviceProtocolHints.delete(session);
1616
+ this.sessionProtocols.delete(session);
1617
+ this.protocolReprobeFailures.delete(session);
1385
1618
  this.protocolV2Assemblers.delete(session);
1386
1619
  this.resetProtocolV2Frames(session);
1387
1620
 
1388
1621
  // emit the disconnect event
1389
1622
  try {
1390
- this.emitter?.emit('device-disconnect', {
1391
- name: transport?.device?.name,
1392
- id: session,
1393
- connectId: session,
1394
- });
1623
+ this.emitDeviceDisconnect(session, transport?.device?.name, monitorToken);
1395
1624
  } catch (e) {
1396
1625
  Log?.error('resetSession: emit disconnect event error: ', e);
1397
1626
  }
1627
+ if (monitorToken !== undefined && this.monitorTokens.get(session) === monitorToken) {
1628
+ this.monitorTokens.delete(session);
1629
+ }
1398
1630
  // eslint-disable-next-line no-promise-executor-return
1399
1631
  await new Promise<void>(resolve => setTimeout(() => resolve(), 100));
1400
1632
  }
@@ -1405,6 +1637,114 @@ export default class ReactNativeBleTransport {
1405
1637
  // this.runPromise.reject(new Error('Transport_CallCanceled'));
1406
1638
  }
1407
1639
  this.runPromise = null;
1640
+ this.runPromiseDeviceId = null;
1641
+ }
1642
+
1643
+ /** Run a native connect under the JS backstop budget. */
1644
+ private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
1645
+ let timer: ReturnType<typeof setTimeout> | undefined;
1646
+ let timedOut = false;
1647
+ const pending = connect();
1648
+ // The abandoned attempt keeps running; swallow its late outcome so it cannot
1649
+ // surface as an unhandled rejection after we have already given up on it.
1650
+ pending.catch(() => undefined);
1651
+ try {
1652
+ const result = await Promise.race([
1653
+ pending,
1654
+ new Promise<never>((_, reject) => {
1655
+ timer = setTimeout(() => {
1656
+ timedOut = true;
1657
+ reject(
1658
+ ERRORS.TypedError(
1659
+ HardwareErrorCode.BleConnectedError,
1660
+ `BLE connect timeout after ${BLE_CONNECT_TIMEOUT_MS}ms for ${uuid}`
1661
+ )
1662
+ );
1663
+ }, BLE_CONNECT_TIMEOUT_MS);
1664
+ }),
1665
+ ]);
1666
+ return result;
1667
+ } catch (error) {
1668
+ if (timedOut || isNativeOperationTimeoutError(error)) {
1669
+ this.abandonStalledConnection(uuid, timedOut ? 'connect-backstop' : 'connect-native');
1670
+ }
1671
+ throw error;
1672
+ } finally {
1673
+ if (timer) clearTimeout(timer);
1674
+ }
1675
+ }
1676
+
1677
+ /** Resolve the complete GATT shape under a budget so acquire() always settles. */
1678
+ private async resolveCharacteristicsWithTimeout(
1679
+ uuid: string,
1680
+ device: Device
1681
+ ): Promise<ResolvedBleCharacteristics> {
1682
+ let timer: ReturnType<typeof setTimeout> | undefined;
1683
+ let timedOut = false;
1684
+ const pending = this.resolveCharacteristics(device);
1685
+ pending.catch(() => undefined);
1686
+ try {
1687
+ const result = await Promise.race([
1688
+ pending,
1689
+ new Promise<never>((_, reject) => {
1690
+ timer = setTimeout(() => {
1691
+ timedOut = true;
1692
+ reject(
1693
+ ERRORS.TypedError(
1694
+ HardwareErrorCode.BleConnectedError,
1695
+ `BLE GATT setup timeout after ${BLE_GATT_SETUP_TIMEOUT_MS}ms for ${uuid}`
1696
+ )
1697
+ );
1698
+ }, BLE_GATT_SETUP_TIMEOUT_MS);
1699
+ }),
1700
+ ]);
1701
+ this.connectionSetupTimeoutCounts.delete(uuid);
1702
+ return result;
1703
+ } catch (error) {
1704
+ if (timedOut || isNativeOperationTimeoutError(error)) {
1705
+ this.abandonStalledConnection(uuid, timedOut ? 'gatt-backstop' : 'gatt-native');
1706
+ }
1707
+ throw error;
1708
+ } finally {
1709
+ if (timer) clearTimeout(timer);
1710
+ }
1711
+ }
1712
+
1713
+ /**
1714
+ * Give up on a BLE setup operation the native layer did not settle. The abandoned
1715
+ * operation still owns native connection/GATT state that can poison the next attempt,
1716
+ * so it is cleared here without awaiting the same queue that stopped responding.
1717
+ */
1718
+ private abandonStalledConnection(
1719
+ uuid: string,
1720
+ stage: 'connect-backstop' | 'connect-native' | 'gatt-backstop' | 'gatt-native'
1721
+ ) {
1722
+ const timeouts = (this.connectionSetupTimeoutCounts.get(uuid) ?? 0) + 1;
1723
+ this.connectionSetupTimeoutCounts.set(uuid, timeouts);
1724
+ Log?.error('[ReactNativeBleTransport] BLE setup timed out:', uuid, {
1725
+ stage,
1726
+ setupTimeoutsSinceSuccess: timeouts,
1727
+ });
1728
+
1729
+ this.blePlxManager?.cancelDeviceConnection(uuid).catch(() => {
1730
+ // Rejects with "Operation was cancelled" while merely connecting — expected.
1731
+ });
1732
+ const stalled = transportCache[uuid];
1733
+ if (stalled) {
1734
+ delete transportCache[uuid];
1735
+ }
1736
+ this.deviceProtocol.delete(uuid);
1737
+ this.probingProtocols.delete(uuid);
1738
+ this.protocolV2Assemblers.delete(uuid);
1739
+ this.resetProtocolV2Frames(uuid);
1740
+
1741
+ if (timeouts >= BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1742
+ // BleManager.destroy() force-rejects every promise the native queue abandoned —
1743
+ // the only JS-reachable way to settle them — and drops all cached peripherals.
1744
+ Log?.error('[ReactNativeBleTransport] BLE setup wedged repeatedly, resetting BLE manager');
1745
+ this.resetPlxManager();
1746
+ this.connectionSetupTimeoutCounts.delete(uuid);
1747
+ }
1408
1748
  }
1409
1749
 
1410
1750
  private getCachedTransport(uuid: string) {
@@ -1415,6 +1755,109 @@ export default class ReactNativeBleTransport {
1415
1755
  return transport;
1416
1756
  }
1417
1757
 
1758
+ /**
1759
+ * Write one packet under a bounded budget. A write that never settles means the
1760
+ * peripheral is wedged even though the GATT link still reports connected, so the
1761
+ * link is torn down: releasing JS state alone would leave the poisoned peripheral
1762
+ * cached and every later call would hang on it again.
1763
+ */
1764
+ private async writeBlePacket(
1765
+ uuid: string,
1766
+ data: string,
1767
+ write: (payload: string) => Promise<unknown>,
1768
+ isCurrentOwner?: () => boolean
1769
+ ) {
1770
+ let timer: ReturnType<typeof setTimeout> | undefined;
1771
+ let timedOut = false;
1772
+ try {
1773
+ await Promise.race([
1774
+ write(data),
1775
+ new Promise<never>((_, reject) => {
1776
+ timer = setTimeout(() => {
1777
+ timedOut = true;
1778
+ reject(
1779
+ ERRORS.TypedError(
1780
+ HardwareErrorCode.BleWriteCharacteristicError,
1781
+ `BLE write timeout after ${BLE_WRITE_PACKET_TIMEOUT_MS}ms`
1782
+ )
1783
+ );
1784
+ }, BLE_WRITE_PACKET_TIMEOUT_MS);
1785
+ }),
1786
+ ]);
1787
+ this.writeTimeoutCounts.delete(uuid);
1788
+ } catch (error) {
1789
+ if (timedOut) {
1790
+ // A superseded call's late write must not tear down the link the current
1791
+ // call is using; only the owner of the transport may declare it dead.
1792
+ if (isCurrentOwner && !isCurrentOwner()) {
1793
+ Log?.debug('[ReactNativeBleTransport] stale BLE write timed out, link kept:', uuid);
1794
+ } else {
1795
+ this.tearDownWedgedLink(uuid);
1796
+ }
1797
+ }
1798
+ throw error;
1799
+ } finally {
1800
+ if (timer) clearTimeout(timer);
1801
+ }
1802
+ }
1803
+
1804
+ /**
1805
+ * Drop a link whose writes stopped completing. The JS state is purged synchronously
1806
+ * so the next acquire() cannot reuse the dead transport, while the native teardown is
1807
+ * intentionally NOT awaited: it talks to the very layer that just stopped settling
1808
+ * promises, so awaiting it could hang exactly like the write it is recovering from.
1809
+ */
1810
+ private tearDownWedgedLink(uuid: string) {
1811
+ const timeouts = (this.writeTimeoutCounts.get(uuid) ?? 0) + 1;
1812
+ this.writeTimeoutCounts.set(uuid, timeouts);
1813
+ Log?.error('[ReactNativeBleTransport] BLE write timed out, tearing down link:', uuid, {
1814
+ consecutiveWriteTimeouts: timeouts,
1815
+ });
1816
+
1817
+ const wedged = transportCache[uuid];
1818
+ this.disconnect(uuid).catch(error => {
1819
+ Log?.debug('[ReactNativeBleTransport] wedged link teardown failed (ignored):', error);
1820
+ });
1821
+ if (wedged && transportCache[uuid] === wedged) {
1822
+ delete transportCache[uuid];
1823
+ }
1824
+ this.deviceProtocol.delete(uuid);
1825
+ this.probingProtocols.delete(uuid);
1826
+ this.protocolV2Assemblers.delete(uuid);
1827
+ this.resetProtocolV2Frames(uuid);
1828
+
1829
+ if (timeouts >= BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1830
+ // Reconnecting reuses the same native peripheral object. When it stays wedged
1831
+ // across attempts the poison lives in the BLE manager itself, and only a fresh
1832
+ // manager drops every cached peripheral — the JS equivalent of restarting the app.
1833
+ Log?.error('[ReactNativeBleTransport] BLE writes wedged repeatedly, resetting BLE manager');
1834
+ this.resetPlxManager();
1835
+ this.writeTimeoutCounts.delete(uuid);
1836
+ }
1837
+ }
1838
+
1839
+ private resetPlxManager() {
1840
+ const manager = this.blePlxManager;
1841
+ this.blePlxManager = undefined;
1842
+ // Every cached transport belongs to the destroyed manager's peripherals.
1843
+ Object.keys(transportCache).forEach(key => {
1844
+ delete transportCache[key];
1845
+ });
1846
+ this.deviceProtocol.clear();
1847
+ this.probingProtocols.clear();
1848
+ this.sessionProtocols.clear();
1849
+ this.protocolReprobeFailures.clear();
1850
+ this.writeTimeoutCounts.clear();
1851
+ this.connectionSetupTimeoutCounts.clear();
1852
+ this.monitorTokens.clear();
1853
+ this.protocolV2Assemblers.clear();
1854
+ try {
1855
+ manager?.destroy();
1856
+ } catch (error) {
1857
+ Log?.debug('[ReactNativeBleTransport] BLE manager destroy failed (ignored):', error);
1858
+ }
1859
+ }
1860
+
1418
1861
  private createProtocolMismatchError(expected: ProtocolType) {
1419
1862
  return ERRORS.TypedError(
1420
1863
  HardwareErrorCode.RuntimeError,
@@ -1430,55 +1873,115 @@ export default class ReactNativeBleTransport {
1430
1873
  }
1431
1874
 
1432
1875
  private clearProbeProtocol(uuid: string, protocol: ProtocolType) {
1876
+ if (this.probingProtocols.get(uuid) === protocol) {
1877
+ this.probingProtocols.delete(uuid);
1878
+ }
1433
1879
  if (this.deviceProtocol.get(uuid) === protocol) {
1434
1880
  this.deviceProtocol.delete(uuid);
1435
1881
  }
1436
1882
  }
1437
1883
 
1884
+ /** Protocol to route a call with: confirmed if known, otherwise the one being probed. */
1885
+ private getActiveProtocol(uuid: string): ProtocolType | undefined {
1886
+ return this.deviceProtocol.get(uuid) ?? this.probingProtocols.get(uuid);
1887
+ }
1888
+
1438
1889
  private async detectProtocol(
1439
1890
  uuid: string,
1440
1891
  expectedProtocol?: ProtocolType,
1441
- protocolHint?: ProtocolType
1892
+ protocolHint?: ProtocolType,
1893
+ rebuildTransport?: () => Promise<void>
1442
1894
  ): Promise<ProtocolType> {
1895
+ if (Platform.OS === 'ios' && expectedProtocol) {
1896
+ this.deviceProtocol.set(uuid, expectedProtocol);
1897
+ Log?.debug('[ReactNativeBleTransport] protocol selected', {
1898
+ deviceId: uuid,
1899
+ protocol: expectedProtocol,
1900
+ source: 'expected',
1901
+ });
1902
+ return expectedProtocol;
1903
+ }
1904
+
1443
1905
  if (expectedProtocol === 'V1') {
1444
1906
  if (await this.probeProtocolV1(uuid)) {
1445
1907
  this.deviceProtocol.set(uuid, 'V1');
1446
- Log?.debug(`[ReactNativeBleTransport] detectProtocol: uuid=${uuid} -> V1 (expected)`);
1908
+ this.sessionProtocols.set(uuid, 'V1');
1909
+ Log?.debug('[ReactNativeBleTransport] protocol detected', {
1910
+ deviceId: uuid,
1911
+ protocol: 'V1',
1912
+ source: 'expected',
1913
+ });
1447
1914
  return 'V1';
1448
1915
  }
1449
1916
  throw this.createProtocolMismatchError(expectedProtocol);
1450
1917
  }
1451
1918
 
1452
1919
  if (expectedProtocol === 'V2') {
1453
- // 免探测路径:调用方显式承诺该设备是 V2(例如固件升级重启后的重连场景,
1454
- // 上层已经探测过协议并通过 expectedProtocol 传回),这里不再重复探测。
1455
- this.deviceProtocol.set(uuid, 'V2');
1456
- Log?.debug(`[ReactNativeBleTransport] detectProtocol: uuid=${uuid} -> V2 (expected)`);
1457
- return 'V2';
1920
+ if (await this.probeProtocolV2(uuid)) {
1921
+ this.deviceProtocol.set(uuid, 'V2');
1922
+ this.sessionProtocols.set(uuid, 'V2');
1923
+ Log?.debug('[ReactNativeBleTransport] protocol detected', {
1924
+ deviceId: uuid,
1925
+ protocol: 'V2',
1926
+ source: 'expected',
1927
+ });
1928
+ return 'V2';
1929
+ }
1930
+ throw this.createProtocolMismatchError(expectedProtocol);
1458
1931
  }
1459
1932
 
1460
- // 项目约束:协议判断必须在连接后主动探测,不能依赖设备名/PID/descriptor。
1461
- // 设备名 hint(如 "Pro 2")只用于调整探测顺序:hint=V2 时先探 V2、失败回落 V1
1462
- // 不能作为最终结论。
1463
- const probeOrder: ProtocolType[] =
1933
+ // Protocol must be actively probed after connection. Name, PID, and descriptors only
1934
+ // influence probe order; a V2 hint probes V2 first and falls back to V1.
1935
+ const sessionProtocol = this.sessionProtocols.get(uuid);
1936
+ const reprobeFailures = this.protocolReprobeFailures.get(uuid) ?? 0;
1937
+ const fullProbeOrder: ProtocolType[] =
1464
1938
  protocolHint === 'V2' || this.deviceProtocol.get(uuid) === 'V2' ? ['V2', 'V1'] : ['V1', 'V2'];
1939
+ // A device that already answered on a protocol in this session keeps answering on
1940
+ // it; while it is rebooting nothing answers at all, so probing the other protocol
1941
+ // only adds its timeout to every poll.
1942
+ const trustSessionProtocol =
1943
+ sessionProtocol !== undefined &&
1944
+ !protocolHint &&
1945
+ reprobeFailures < PROTOCOL_REPROBE_FALLBACK_ATTEMPTS;
1946
+ const probeOrder: ProtocolType[] = trustSessionProtocol ? [sessionProtocol] : fullProbeOrder;
1465
1947
 
1466
1948
  for (let i = 0; i < probeOrder.length; i += 1) {
1467
1949
  const protocol = probeOrder[i];
1468
1950
  if (i > 0) {
1469
- // 上一个协议探测失败后,重置订阅与缓冲,避免残留数据干扰下一个协议的探测。
1951
+ // Reset subscriptions and buffers after a failed probe before trying another protocol.
1470
1952
  await this.resetProbeStateAfterProtocolProbe(uuid, probeOrder[i - 1]);
1953
+ if (!transportCache[uuid]) {
1954
+ if (!rebuildTransport) {
1955
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
1956
+ }
1957
+ await rebuildTransport();
1958
+ }
1471
1959
  }
1472
1960
  const detected =
1473
1961
  protocol === 'V1' ? await this.probeProtocolV1(uuid) : await this.probeProtocolV2(uuid);
1474
1962
  if (detected) {
1475
1963
  this.deviceProtocol.set(uuid, protocol);
1476
- Log?.debug(`[ReactNativeBleTransport] detectProtocol: uuid=${uuid} -> ${protocol}`);
1964
+ this.sessionProtocols.set(uuid, protocol);
1965
+ this.protocolReprobeFailures.delete(uuid);
1966
+ Log?.debug('[ReactNativeBleTransport] protocol detected', {
1967
+ deviceId: uuid,
1968
+ protocol,
1969
+ source: 'probe',
1970
+ });
1477
1971
  return protocol;
1478
1972
  }
1479
1973
  }
1480
1974
 
1975
+ if (trustSessionProtocol) {
1976
+ // Still silent on its own protocol: count it, and let the streak expire the
1977
+ // shortcut so a device that genuinely switched protocols is found again.
1978
+ this.protocolReprobeFailures.set(uuid, reprobeFailures + 1);
1979
+ } else {
1980
+ this.protocolReprobeFailures.delete(uuid);
1981
+ }
1982
+
1481
1983
  this.deviceProtocol.delete(uuid);
1984
+ this.probingProtocols.delete(uuid);
1482
1985
  throw this.createProtocolDetectionError();
1483
1986
  }
1484
1987
 
@@ -1540,12 +2043,18 @@ export default class ReactNativeBleTransport {
1540
2043
  }
1541
2044
 
1542
2045
  try {
1543
- this.deviceProtocol.set(uuid, 'V1');
2046
+ this.probingProtocols.set(uuid, 'V1');
1544
2047
  await this.callProtocolV1(uuid, 'Initialize', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
2048
+ this.probingProtocols.delete(uuid);
1545
2049
  return true;
1546
2050
  } catch (error) {
1547
2051
  this.clearProbeProtocol(uuid, 'V1');
1548
2052
  Log?.debug('[ReactNativeBleTransport] Protocol V1 Initialize probe failed:', error);
2053
+ // A wedged write already dropped the link, so probing another protocol on it
2054
+ // would only fail against a torn-down transport: surface the real cause.
2055
+ if (isWedgedWriteError(error)) {
2056
+ throw error;
2057
+ }
1549
2058
  return false;
1550
2059
  }
1551
2060
  }
@@ -1555,7 +2064,7 @@ export default class ReactNativeBleTransport {
1555
2064
  return false;
1556
2065
  }
1557
2066
 
1558
- this.deviceProtocol.set(uuid, 'V2');
2067
+ this.probingProtocols.set(uuid, 'V2');
1559
2068
  this.protocolV2Assemblers.get(uuid)?.reset();
1560
2069
  const detected = await probeProtocolV2Helper({
1561
2070
  call: (name: string, data: Record<string, unknown>, options?: TransportCallOptions) =>
@@ -1570,6 +2079,8 @@ export default class ReactNativeBleTransport {
1570
2079
  });
1571
2080
  if (!detected) {
1572
2081
  this.clearProbeProtocol(uuid, 'V2');
2082
+ } else {
2083
+ this.probingProtocols.delete(uuid);
1573
2084
  }
1574
2085
  return detected;
1575
2086
  }
@@ -1620,17 +2131,8 @@ export default class ReactNativeBleTransport {
1620
2131
  this.getProtocolV2FrameQueue(uuid).push(frame);
1621
2132
  }
1622
2133
 
1623
- private rejectAllProtocolV2Frames(error: Error) {
1624
- this.protocolV2FrameQueues.clear();
1625
- for (const framePromise of this.protocolV2FramePromises.values()) {
1626
- framePromise.reject(error);
1627
- }
1628
- this.protocolV2FramePromises.clear();
1629
- }
1630
-
1631
2134
  private resetProtocolV2Frames(uuid: string) {
1632
- this.protocolV2FrameQueues.delete(uuid);
1633
- this.protocolV2FramePromises.delete(uuid);
2135
+ this.rejectProtocolV2Frames(uuid, new Error(`Protocol V2 frame state reset for ${uuid}`));
1634
2136
  }
1635
2137
 
1636
2138
  private rejectProtocolV2Frames(uuid: string, error: Error) {
@@ -1659,49 +2161,95 @@ export default class ReactNativeBleTransport {
1659
2161
  }
1660
2162
  }
1661
2163
 
2164
+ private async writeProtocolV2Packet(
2165
+ uuid: string,
2166
+ transport: BleTransport,
2167
+ base64: string,
2168
+ context: ProtocolV2CallContext,
2169
+ assertCurrentGeneration: () => void
2170
+ ) {
2171
+ const shouldUseWriteWithResponse = shouldWriteProtocolV2WithResponse({
2172
+ platform: Platform.OS,
2173
+ highThroughput: context.highThroughput,
2174
+ requestedWithResponse: context.writeWithResponse,
2175
+ characteristic: transport.writeCharacteristic,
2176
+ });
2177
+ let attempt = 0;
2178
+ for (;;) {
2179
+ assertCurrentGeneration();
2180
+ if (context.signal.aborted) {
2181
+ throw new Error(`Protocol V2 BLE write aborted for ${context.messageName}`);
2182
+ }
2183
+ try {
2184
+ await this.writeBlePacket(
2185
+ uuid,
2186
+ base64,
2187
+ payload =>
2188
+ shouldUseWriteWithResponse
2189
+ ? transport.writeCharacteristic.writeWithResponse(payload)
2190
+ : transport.writeCharacteristic.writeWithoutResponse(payload),
2191
+ // Same rule as Protocol V1: a write from a superseded generation must not
2192
+ // tear down the link that the current generation is using.
2193
+ () => {
2194
+ try {
2195
+ assertCurrentGeneration();
2196
+ return !context.signal.aborted;
2197
+ } catch {
2198
+ return false;
2199
+ }
2200
+ }
2201
+ );
2202
+ assertCurrentGeneration();
2203
+ return;
2204
+ } catch (error) {
2205
+ if (
2206
+ getFirmwareUploadWriteRetryType(error) !== 'congested' ||
2207
+ attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES
2208
+ ) {
2209
+ throw error;
2210
+ }
2211
+ const delayMs = resolveFirmwareUploadRetryDelay(attempt);
2212
+ attempt += 1;
2213
+ Log?.debug('[ReactNativeBleTransport] Protocol V2 congested write retry:', {
2214
+ name: context.messageName,
2215
+ attempt,
2216
+ delayMs,
2217
+ });
2218
+ await delay(delayMs);
2219
+ }
2220
+ }
2221
+ }
2222
+
1662
2223
  private async writeProtocolV2Frame(
2224
+ uuid: string,
1663
2225
  transport: BleTransport,
1664
2226
  frame: Uint8Array,
1665
- options?: { highVolume?: boolean; writeWithResponse?: boolean }
2227
+ context: ProtocolV2CallContext,
2228
+ assertCurrentGeneration: () => void
1666
2229
  ) {
1667
2230
  const tuning = getProtocolV2BleTuning();
1668
2231
  const packetCapacity = resolveProtocolV2PacketCapacity({
1669
2232
  platform: Platform.OS,
1670
2233
  iosPacketLength: tuning.iosPacketLength,
1671
2234
  androidPacketLength: tuning.androidPacketLength,
1672
- mtu: Platform.OS === 'android' ? transport.mtuSize : undefined,
2235
+ mtu: transport.mtuSize,
2236
+ });
2237
+ await writeProtocolV2BleFrame({
2238
+ frame,
2239
+ packetCapacity,
2240
+ assertActive: assertCurrentGeneration,
2241
+ signal: context.signal,
2242
+ abortMessage: `Protocol V2 BLE write aborted for ${context.messageName}`,
2243
+ wait: delay,
2244
+ writePacket: packet =>
2245
+ this.writeProtocolV2Packet(
2246
+ uuid,
2247
+ transport,
2248
+ Buffer.from(packet).toString('base64'),
2249
+ context,
2250
+ assertCurrentGeneration
2251
+ ),
1673
2252
  });
1674
- const writeWithResponse =
1675
- !!options?.writeWithResponse || (!!options?.highVolume && tuning.highVolumeWriteWithResponse);
1676
- const writeMode = resolveBleWriteMode(
1677
- transport.writeCharacteristic,
1678
- writeWithResponse ? 'withResponse' : 'withoutResponse'
1679
- );
1680
- const shouldThrottle = !!options?.highVolume && writeMode === 'withoutResponse';
1681
- let packetsWritten = 0;
1682
-
1683
- for (let offset = 0; offset < frame.length; offset += packetCapacity) {
1684
- const chunk = frame.slice(offset, offset + packetCapacity);
1685
- const base64 = Buffer.from(chunk).toString('base64');
1686
- if (writeMode === 'withResponse') {
1687
- await transport.writeCharacteristic.writeWithResponse(base64);
1688
- } else {
1689
- await transport.writeCharacteristic.writeWithoutResponse(base64);
1690
- }
1691
- packetsWritten += 1;
1692
-
1693
- if (
1694
- shouldThrottle &&
1695
- packetsWritten % tuning.highVolumeWriteBurstSize === 0 &&
1696
- offset + packetCapacity < frame.length
1697
- ) {
1698
- await delay(tuning.highVolumeWritePauseMs);
1699
- }
1700
- }
1701
-
1702
- if (shouldThrottle) {
1703
- await delay(tuning.highVolumeWriteFlushDelayMs);
1704
- }
1705
2253
  }
1706
2254
 
1707
2255
  private async callProtocolV2(
@@ -1714,26 +2262,45 @@ export default class ReactNativeBleTransport {
1714
2262
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
1715
2263
  }
1716
2264
 
1717
- const callOptions = {
1718
- ...options,
1719
- timeoutMs: options?.timeoutMs ?? BLE_RESPONSE_TIMEOUT_MS,
1720
- };
1721
- const highVolumeWrite = LogBlockCommand.has(name);
2265
+ const callOptions = options;
2266
+ const highThroughputWrite = isProtocolV2HighThroughputCall(name);
1722
2267
 
1723
- if (highVolumeWrite) {
2268
+ if (highThroughputWrite) {
2269
+ await this.ensureProtocolV2HighThroughputMtu(uuid);
1724
2270
  const tuning = getProtocolV2BleTuning();
1725
- Log?.debug(
1726
- '[ReactNativeBleTransport] Protocol V2 high-volume write uses throttled writeWithoutResponse:',
1727
- name,
1728
- {
1729
- packetCapacity:
1730
- Platform.OS === 'ios' ? tuning.iosPacketLength : tuning.androidPacketLength,
1731
- burstSize: tuning.highVolumeWriteBurstSize,
1732
- pauseMs: tuning.highVolumeWritePauseMs,
1733
- flushDelayMs: tuning.highVolumeWriteFlushDelayMs,
1734
- writeWithResponse: tuning.highVolumeWriteWithResponse,
1735
- }
1736
- );
2271
+ const currentTransport = this.getCachedTransport(uuid);
2272
+ const writeWithResponse = shouldWriteProtocolV2WithResponse({
2273
+ platform: Platform.OS,
2274
+ highThroughput: true,
2275
+ requestedWithResponse: options?.writeWithResponse,
2276
+ characteristic: currentTransport.writeCharacteristic,
2277
+ });
2278
+ const packetCapacity = resolveProtocolV2PacketCapacity({
2279
+ platform: Platform.OS,
2280
+ iosPacketLength: tuning.iosPacketLength,
2281
+ androidPacketLength: tuning.androidPacketLength,
2282
+ mtu: currentTransport.mtuSize,
2283
+ });
2284
+ const writeMode = writeWithResponse ? 'withResponse' : 'withoutResponse';
2285
+ const logSignature = `${name}:${writeMode}:${String(
2286
+ currentTransport.mtuSize
2287
+ )}:${packetCapacity}`;
2288
+ const loggedSignatures =
2289
+ this.protocolV2HighVolumeLogSignatures.get(uuid) ?? new Set<string>();
2290
+ if (!loggedSignatures.has(logSignature)) {
2291
+ loggedSignatures.add(logSignature);
2292
+ this.protocolV2HighVolumeLogSignatures.set(uuid, loggedSignatures);
2293
+ Log?.debug('[ReactNativeBleTransport] Protocol V2 high-volume write configured', {
2294
+ name,
2295
+ writeMode,
2296
+ reportedMtu: currentTransport.mtuSize,
2297
+ packetCapacity,
2298
+ });
2299
+ }
2300
+ }
2301
+
2302
+ if (highThroughputWrite) {
2303
+ await this.enableAndroidHighConnectionPriority(uuid);
1737
2304
  }
1738
2305
 
1739
2306
  try {
@@ -1747,6 +2314,90 @@ export default class ReactNativeBleTransport {
1747
2314
  } catch (e) {
1748
2315
  Log?.error('[ReactNativeBleTransport] Protocol V2 call error:', e);
1749
2316
  throw e;
2317
+ } finally {
2318
+ if (highThroughputWrite) {
2319
+ this.scheduleAndroidBalancedConnectionPriority(uuid);
2320
+ }
2321
+ }
2322
+ }
2323
+
2324
+ private async ensureProtocolV2HighThroughputMtu(uuid: string) {
2325
+ const transport = this.getCachedTransport(uuid);
2326
+ if (!shouldRefreshNegotiatedMtu(transport.mtuSize)) return;
2327
+
2328
+ const refreshedDevice = await requestNegotiatedMtu(transport.device, 'highThroughput', 1);
2329
+ transport.device = refreshedDevice;
2330
+ transport.mtuSize =
2331
+ typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
2332
+
2333
+ if (shouldRefreshNegotiatedMtu(transport.mtuSize)) {
2334
+ throw ERRORS.TypedError(
2335
+ HardwareErrorCode.BleConnectedError,
2336
+ `Protocol V2 high-throughput BLE MTU unavailable: ${String(transport.mtuSize)}`
2337
+ );
2338
+ }
2339
+ }
2340
+
2341
+ private clearAndroidPriorityResetTimer(uuid: string) {
2342
+ const timerId = this.androidPriorityResetTimers.get(uuid);
2343
+ if (timerId !== undefined) {
2344
+ clearTimeout(timerId);
2345
+ this.androidPriorityResetTimers.delete(uuid);
2346
+ }
2347
+ }
2348
+
2349
+ private async enableAndroidHighConnectionPriority(uuid: string) {
2350
+ if (Platform.OS !== 'android') return;
2351
+
2352
+ this.clearAndroidPriorityResetTimer(uuid);
2353
+ if (this.androidHighPriorityDevices.has(uuid)) return;
2354
+
2355
+ const transport = transportCache[uuid];
2356
+ if (!transport) return;
2357
+
2358
+ try {
2359
+ transport.device = await transport.device.requestConnectionPriority(ConnectionPriority.High);
2360
+ this.androidHighPriorityDevices.add(uuid);
2361
+ Log?.debug('[ReactNativeBleTransport] Android BLE connection priority changed', {
2362
+ priority: 'high',
2363
+ });
2364
+ } catch (error) {
2365
+ Log?.debug('[ReactNativeBleTransport] Android BLE high priority request failed', {
2366
+ error: error instanceof Error ? error.message : String(error),
2367
+ });
2368
+ }
2369
+ }
2370
+
2371
+ private scheduleAndroidBalancedConnectionPriority(uuid: string) {
2372
+ if (Platform.OS !== 'android' || !this.androidHighPriorityDevices.has(uuid)) return;
2373
+
2374
+ this.clearAndroidPriorityResetTimer(uuid);
2375
+ const timerId = setTimeout(() => {
2376
+ this.androidPriorityResetTimers.delete(uuid);
2377
+ this.restoreAndroidConnectionPriority(uuid, transportCache[uuid]).catch(error =>
2378
+ Log?.debug('[ReactNativeBleTransport] Android BLE priority restore failed', error)
2379
+ );
2380
+ }, ANDROID_HIGH_PRIORITY_IDLE_MS);
2381
+ this.androidPriorityResetTimers.set(uuid, timerId);
2382
+ }
2383
+
2384
+ private async restoreAndroidConnectionPriority(uuid: string, transport?: BleTransport) {
2385
+ this.clearAndroidPriorityResetTimer(uuid);
2386
+ if (Platform.OS !== 'android' || !this.androidHighPriorityDevices.delete(uuid) || !transport) {
2387
+ return;
2388
+ }
2389
+
2390
+ try {
2391
+ transport.device = await transport.device.requestConnectionPriority(
2392
+ ConnectionPriority.Balanced
2393
+ );
2394
+ Log?.debug('[ReactNativeBleTransport] Android BLE connection priority changed', {
2395
+ priority: 'balanced',
2396
+ });
2397
+ } catch (error) {
2398
+ Log?.debug('[ReactNativeBleTransport] Android BLE balanced priority request failed', {
2399
+ error: error instanceof Error ? error.message : String(error),
2400
+ });
1750
2401
  }
1751
2402
  }
1752
2403
 
@@ -1767,12 +2418,16 @@ export default class ReactNativeBleTransport {
1767
2418
  this.protocolV2Assemblers.get(uuid)?.reset();
1768
2419
  this.resetProtocolV2Frames(uuid);
1769
2420
  },
1770
- writeFrame: async (frame: Uint8Array, context: { highVolume: boolean }) => {
2421
+ writeFrame: async (frame: Uint8Array, context: ProtocolV2CallContext) => {
1771
2422
  assertCurrentGeneration();
1772
2423
  const currentTransport = this.getCachedTransport(uuid);
1773
- await this.writeProtocolV2Frame(currentTransport, frame, {
1774
- highVolume: context.highVolume,
1775
- });
2424
+ await this.writeProtocolV2Frame(
2425
+ uuid,
2426
+ currentTransport,
2427
+ frame,
2428
+ context,
2429
+ assertCurrentGeneration
2430
+ );
1776
2431
  },
1777
2432
  readFrame: async () => {
1778
2433
  assertCurrentGeneration();
@@ -1797,6 +2452,6 @@ export default class ReactNativeBleTransport {
1797
2452
  }
1798
2453
 
1799
2454
  getProtocolType(path: string): ProtocolType | undefined {
1800
- return this.deviceProtocol.get(path);
2455
+ return this.getActiveProtocol(path);
1801
2456
  }
1802
2457
  }