@onekeyfe/hd-transport-react-native 1.2.0-alpha.1 → 1.2.0-alpha.100

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