@onekeyfe/hd-transport-electron 1.2.0-alpha.7 → 1.2.0-alpha.71

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.
@@ -9,14 +9,25 @@ import {
9
9
  EOneKeyBleMessageKeys,
10
10
  ERRORS,
11
11
  HardwareErrorCode,
12
+ ONEKEY_NOTIFY_CHARACTERISTIC_UUID,
12
13
  ONEKEY_SERVICE_UUID,
13
- isOnekeyDevice,
14
+ ONEKEY_WRITE_CHARACTERISTIC_UUID,
15
+ createKnownBleUuidAliases,
16
+ hasOnekeyCommunicationService,
17
+ isOnekeyBluetoothDevice,
18
+ isPro2FindMyAdvertisementName,
19
+ matchesKnownBleUuid,
14
20
  wait,
15
21
  } from '@onekeyfe/hd-shared';
16
22
  import pRetry from 'p-retry';
17
23
 
24
+ import { resolveBlePacketCapacity } from './ble-packet-capacity';
18
25
  import { safeLog } from './types/noble-extended';
19
- import { softRefreshSubscription } from './ble-ops';
26
+ import { runBleCallbackOperation, softRefreshSubscription } from './ble-ops';
27
+ import {
28
+ NOBLE_BLE_CONNECTION_TIMEOUT_MS,
29
+ NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS,
30
+ } from './noble-ble-timeouts';
20
31
 
21
32
  import type { IpcMainInvokeEvent, WebContents } from 'electron';
22
33
  import type { Characteristic, Peripheral, Service } from '@stoprocent/noble';
@@ -39,6 +50,7 @@ const bluetoothState: {
39
50
 
40
51
  // Global persistent state listener for app layer
41
52
  let persistentStateListener: ((state: string) => void) | null = null;
53
+ let persistentDiscoverListener: ((peripheral: Peripheral) => void) | null = null;
42
54
 
43
55
  // Device cache and connection state
44
56
  const discoveredDevices = new Map<string, Peripheral>();
@@ -50,6 +62,14 @@ const subscribedDevices = new Map<string, boolean>(); // Track subscription stat
50
62
 
51
63
  // 🔒 Subscription operation state tracking to prevent race conditions
52
64
  const subscriptionOperations = new Map<string, 'subscribing' | 'unsubscribing' | 'idle'>();
65
+ const deviceDisconnectListeners = new Map<
66
+ string,
67
+ { peripheral: Peripheral; listener: () => void }
68
+ >();
69
+ const deviceMtuListeners = new Map<
70
+ string,
71
+ { peripheral: Peripheral; listener: (mtu: number) => void }
72
+ >();
53
73
 
54
74
  // Windows-only response watchdog state moved to utils/windows-ble-recovery
55
75
 
@@ -59,57 +79,44 @@ const subscriptionOperations = new Map<string, 'subscribing' | 'unsubscribing' |
59
79
 
60
80
  // Service UUIDs to scan for - using constants from hd-shared
61
81
  const ONEKEY_SERVICE_UUIDS = [ONEKEY_SERVICE_UUID];
62
- const PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS = new Set(['fffd']);
63
-
64
- // Pre-normalized characteristic identifiers for fast comparison
65
- const NORMALIZED_WRITE_UUID = '0002';
66
- const NORMALIZED_NOTIFY_UUID = '0003';
82
+ const ONEKEY_SERVICE_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_SERVICE_UUID);
83
+ const ONEKEY_WRITE_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_WRITE_CHARACTERISTIC_UUID);
84
+ const ONEKEY_NOTIFY_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_NOTIFY_CHARACTERISTIC_UUID);
67
85
 
68
86
  // Timeout and interval constants
69
87
  const BLUETOOTH_INIT_TIMEOUT = 10000; // 10 seconds for Bluetooth initialization
70
- const DEVICE_SCAN_TIMEOUT = 8000; // 8 seconds for device scanning (Pro2 has longer advertising interval)
71
- const FAST_SCAN_TIMEOUT = 8000; // 8 seconds for targeted scanning (Pro2 has longer advertising interval)
88
+ const DEVICE_SCAN_TIMEOUT = 5000; // 5 seconds for device scanning
72
89
  const DEVICE_CHECK_INTERVAL = 500; // 500ms interval for periodic device checks
73
- const CONNECTION_TIMEOUT = 8000; // 8 seconds for device connection (BLE reconnect after release can be slow)
74
90
  const SERVICE_DISCOVERY_TIMEOUT = 10000; // 10 seconds for service discovery
91
+ const BLE_CLEANUP_TIMEOUT = 250;
75
92
 
76
93
  // Write-related constants
77
- const BLE_PACKET_SIZE = 192;
78
- const UNIFIED_WRITE_DELAY = 5;
94
+ const BLE_PACKET_SIZE_FALLBACK = 192;
95
+ const BLE_PACKET_SIZE_MAXIMUM = 244;
79
96
  const RETRY_CONFIG = { MAX_ATTEMPTS: 15, WRITE_TIMEOUT: 2000 } as const;
80
97
  const IS_WINDOWS = process.platform === 'win32';
81
98
  const ABORTABLE_WRITE_ERROR_PATTERNS = [
82
99
  /status:\s*3/i, // Windows pairing cancelled / GATT write failed
83
100
  ];
84
101
 
85
- function getBleUuidKey(uuid?: string | null) {
86
- const normalized = (uuid ?? '').replace(/-/g, '').toLowerCase();
87
- return normalized.length >= 8 ? normalized.substring(4, 8) : normalized;
88
- }
89
-
90
- const NORMALIZED_ONEKEY_SERVICE_UUIDS = new Set([
91
- ...ONEKEY_SERVICE_UUIDS.map(uuid => getBleUuidKey(uuid)),
92
- '0001',
93
- ]);
94
-
95
- function isGenericBleService(uuid?: string | null) {
96
- return ['1800', '1801', '180a', '180f'].includes(getBleUuidKey(uuid));
97
- }
102
+ function isOneKeyPeripheral(peripheral: Peripheral) {
103
+ const serviceUuids = peripheral.advertisement?.serviceUuids;
104
+ const localName = peripheral.advertisement?.localName;
98
105
 
99
- function hasOneKeyAdvertisementService(peripheral: Peripheral) {
100
- const serviceUuids = peripheral.advertisement?.serviceUuids ?? [];
101
- return serviceUuids.some(uuid => {
102
- const uuidKey = getBleUuidKey(uuid);
103
- return (
104
- NORMALIZED_ONEKEY_SERVICE_UUIDS.has(uuidKey) ||
105
- PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS.has(uuidKey)
106
- );
107
- });
108
- }
106
+ // Noble localName is the current advertisement name, so reject the Pro2
107
+ // Find My endpoint before the communication-service fast path accepts it.
108
+ if (!localName?.trim() || isPro2FindMyAdvertisementName(localName)) {
109
+ return false;
110
+ }
109
111
 
110
- function isOneKeyPeripheral(peripheral: Peripheral) {
111
- const deviceName = peripheral.advertisement?.localName || null;
112
- return isOnekeyDevice(deviceName, peripheral.id) || hasOneKeyAdvertisementService(peripheral);
112
+ return (
113
+ hasOnekeyCommunicationService(serviceUuids) &&
114
+ isOnekeyBluetoothDevice({
115
+ id: peripheral.id,
116
+ localName,
117
+ serviceUuids,
118
+ })
119
+ );
113
120
  }
114
121
 
115
122
  /**
@@ -117,12 +124,6 @@ function isOneKeyPeripheral(peripheral: Peripheral) {
117
124
  * renderer-side transport. Packet reassembly is handled by ElectronBleTransport.
118
125
  */
119
126
  function emitRawNotification(deviceId: string, data: Buffer): void {
120
- logger?.info('[NobleBLE] Raw notification', {
121
- deviceId,
122
- dataLength: data.length,
123
- firstBytes: data.subarray(0, 8).toString('hex'),
124
- });
125
-
126
127
  const appCb = notificationCallbacks.get(deviceId);
127
128
  if (appCb) appCb(data.toString('hex'));
128
129
  }
@@ -296,9 +297,12 @@ async function initializeNoble(): Promise<void> {
296
297
  });
297
298
 
298
299
  // Set up device discovery
299
- noble.on('discover', (peripheral: Peripheral) => {
300
- handleDeviceDiscovered(peripheral);
301
- });
300
+ if (!persistentDiscoverListener) {
301
+ persistentDiscoverListener = (peripheral: Peripheral) => {
302
+ handleDeviceDiscovered(peripheral);
303
+ };
304
+ noble.on('discover', persistentDiscoverListener);
305
+ }
302
306
 
303
307
  logger?.info('[NobleBLE] Noble initialized successfully');
304
308
  } catch (error) {
@@ -360,6 +364,16 @@ function cleanupDevice(
360
364
 
361
365
  // 1. Clean up connection state
362
366
  if (cleanupConnection) {
367
+ const disconnectEntry = deviceDisconnectListeners.get(deviceId);
368
+ if (disconnectEntry) {
369
+ disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
370
+ deviceDisconnectListeners.delete(deviceId);
371
+ }
372
+ const mtuEntry = deviceMtuListeners.get(deviceId);
373
+ if (mtuEntry) {
374
+ mtuEntry.peripheral.removeListener('mtu', mtuEntry.listener);
375
+ deviceMtuListeners.delete(deviceId);
376
+ }
363
377
  connectedDevices.delete(deviceId);
364
378
  deviceCharacteristics.delete(deviceId);
365
379
  notificationCallbacks.delete(deviceId);
@@ -409,18 +423,43 @@ function setupDisconnectListener(
409
423
  deviceId: string,
410
424
  webContents: WebContents
411
425
  ): void {
412
- // Remove any existing disconnect listeners to avoid duplicates
413
- peripheral.removeAllListeners('disconnect');
426
+ const existing = deviceDisconnectListeners.get(deviceId);
427
+ if (existing) {
428
+ existing.peripheral.removeListener('disconnect', existing.listener);
429
+ }
414
430
 
415
- // Set up new disconnect listener
416
- peripheral.on('disconnect', () => {
431
+ const listener = () => {
417
432
  handleDeviceDisconnect(deviceId, webContents);
418
- });
433
+ };
434
+ deviceDisconnectListeners.set(deviceId, { peripheral, listener });
435
+ peripheral.on('disconnect', listener);
436
+ setupMtuListener(peripheral, deviceId, webContents);
437
+ }
438
+
439
+ function setupMtuListener(
440
+ peripheral: Peripheral,
441
+ deviceId: string,
442
+ webContents: WebContents
443
+ ): void {
444
+ const existing = deviceMtuListeners.get(deviceId);
445
+ if (existing) {
446
+ existing.peripheral.removeListener('mtu', existing.listener);
447
+ }
448
+
449
+ const listener = (mtu: number) => {
450
+ if (!Number.isFinite(mtu) || mtu <= 0) return;
451
+ webContents.send(EOneKeyBleMessageKeys.NOBLE_BLE_MTU_CHANGED, {
452
+ id: deviceId,
453
+ mtu,
454
+ });
455
+ };
456
+ deviceMtuListeners.set(deviceId, { peripheral, listener });
457
+ peripheral.on('mtu', listener);
419
458
  }
420
459
 
421
460
  // ===== Write helpers (inline) =====
422
461
 
423
- async function writeCharacteristicWithAck(
462
+ async function writeCharacteristicWithoutResponse(
424
463
  deviceId: string,
425
464
  writeCharacteristic: Characteristic,
426
465
  buffer: Buffer
@@ -468,7 +507,7 @@ async function attemptWindowsWriteUntilPaired(
468
507
  }
469
508
 
470
509
  try {
471
- await writeCharacteristicWithAck(deviceId, latestWrite, payload);
510
+ await writeCharacteristicWithoutResponse(deviceId, latestWrite, payload);
472
511
  } catch (e) {
473
512
  const errorMessage = e instanceof Error ? e.message : String(e);
474
513
  logger?.error('[BLE-Write] Windows write error', {
@@ -551,13 +590,12 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
551
590
  }
552
591
 
553
592
  const toBuffer = Buffer.from(hexData, 'hex');
554
- logger?.info('[NobleBLE] Writing data:', {
555
- deviceId,
556
- dataLength: toBuffer.length,
557
- firstBytes: toBuffer.subarray(0, 8).toString('hex'),
558
- });
559
-
560
593
  const doGetWriteCharacteristic = () => deviceCharacteristics.get(deviceId)?.write;
594
+ const packetCapacity = resolveBlePacketCapacity(
595
+ peripheral.mtu,
596
+ BLE_PACKET_SIZE_MAXIMUM,
597
+ BLE_PACKET_SIZE_FALLBACK
598
+ );
561
599
 
562
600
  if (!IS_WINDOWS || pairedDevices.has(deviceId)) {
563
601
  // macOS / Linux or already paired on Windows: direct write
@@ -568,14 +606,13 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
568
606
  `Write characteristic not available for ${deviceId}`
569
607
  );
570
608
  }
571
- if (toBuffer.length <= BLE_PACKET_SIZE) {
572
- await wait(UNIFIED_WRITE_DELAY);
573
- await writeCharacteristicWithAck(deviceId, writeCharacteristic, toBuffer);
609
+ if (toBuffer.length <= packetCapacity) {
610
+ await writeCharacteristicWithoutResponse(deviceId, writeCharacteristic, toBuffer);
574
611
  return;
575
612
  }
576
613
  // chunked
577
614
  for (let offset = 0; offset < toBuffer.length; ) {
578
- const chunkSize = Math.min(BLE_PACKET_SIZE, toBuffer.length - offset);
615
+ const chunkSize = Math.min(packetCapacity, toBuffer.length - offset);
579
616
  const chunk = toBuffer.subarray(offset, offset + chunkSize);
580
617
  offset += chunkSize;
581
618
  const latest = doGetWriteCharacteristic();
@@ -585,23 +622,19 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
585
622
  `Write characteristic not available for ${deviceId}`
586
623
  );
587
624
  }
588
- await writeCharacteristicWithAck(deviceId, latest, chunk);
589
- if (offset < toBuffer.length) {
590
- await wait(UNIFIED_WRITE_DELAY);
591
- }
625
+ await writeCharacteristicWithoutResponse(deviceId, latest, chunk);
592
626
  }
593
627
  return;
594
628
  }
595
629
 
596
630
  // Windows unpaired path: use loop
597
- if (toBuffer.length <= BLE_PACKET_SIZE) {
598
- await wait(UNIFIED_WRITE_DELAY);
631
+ if (toBuffer.length <= packetCapacity) {
599
632
  await attemptWindowsWriteUntilPaired(deviceId, doGetWriteCharacteristic, toBuffer, 'single');
600
633
  return;
601
634
  }
602
635
  // chunked loop
603
636
  for (let offset = 0, idx = 0; offset < toBuffer.length; idx++) {
604
- const chunkSize = Math.min(BLE_PACKET_SIZE, toBuffer.length - offset);
637
+ const chunkSize = Math.min(packetCapacity, toBuffer.length - offset);
605
638
  const chunk = toBuffer.subarray(offset, offset + chunkSize);
606
639
  offset += chunkSize;
607
640
  await attemptWindowsWriteUntilPaired(
@@ -610,17 +643,11 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
610
643
  chunk,
611
644
  `chunk-${idx + 1}`
612
645
  );
613
- if (offset < toBuffer.length) {
614
- await wait(UNIFIED_WRITE_DELAY);
615
- }
616
646
  }
617
647
  }
618
648
 
619
649
  // Handle discovered device (for general enumeration only)
620
650
  function handleDeviceDiscovered(peripheral: Peripheral): void {
621
- const deviceName = peripheral.advertisement?.localName || 'Unknown Device';
622
- const serviceUuids = peripheral.advertisement?.serviceUuids || [];
623
-
624
651
  // Only process OneKey candidates for general discovery. Avoid logging every
625
652
  // ambient BLE peripheral; it makes Pro2 debugging hard to read.
626
653
  if (!isOneKeyPeripheral(peripheral)) {
@@ -630,10 +657,10 @@ function handleDeviceDiscovered(peripheral: Peripheral): void {
630
657
  const isNewDevice = !discoveredDevices.has(peripheral.id);
631
658
  discoveredDevices.set(peripheral.id, peripheral);
632
659
  if (isNewDevice) {
633
- logger?.info('[NobleBLE] Discovered OneKey BLE device:', {
634
- name: deviceName,
635
- id: peripheral.id,
636
- serviceUUIDs: serviceUuids,
660
+ logger?.debug('[NobleBLE] OneKey BLE device discovered', {
661
+ deviceId: peripheral.id,
662
+ name: peripheral.advertisement?.localName || 'Unknown Device',
663
+ serviceUUIDs: peripheral.advertisement?.serviceUuids || [],
637
664
  });
638
665
  }
639
666
  }
@@ -643,19 +670,24 @@ function handleDeviceDiscovered(peripheral: Peripheral): void {
643
670
  function ensureDiscoverListener(): void {
644
671
  if (!noble) return;
645
672
 
646
- // Check if discover listener exists by checking listener count
647
- const listenerCount = (noble as any).listenerCount('discover');
648
-
649
- if (listenerCount === 0) {
673
+ if (!persistentDiscoverListener) {
650
674
  logger?.info('[NobleBLE] Discover listener missing, re-adding it');
651
- noble.on('discover', (peripheral: Peripheral) => {
675
+ persistentDiscoverListener = (peripheral: Peripheral) => {
652
676
  handleDeviceDiscovered(peripheral);
653
- });
677
+ };
678
+ noble.on('discover', persistentDiscoverListener);
654
679
  } else {
655
- logger?.debug('[NobleBLE] Discover listener already exists, count:', listenerCount);
680
+ logger?.debug('[NobleBLE] Discover listener already registered');
656
681
  }
657
682
  }
658
683
 
684
+ async function waitForNobleScanStop(nobleInstance: NobleModule): Promise<void> {
685
+ await runBleCallbackOperation(callback => nobleInstance.stopScanning(() => callback()), {
686
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
687
+ timeoutBehavior: 'resolve',
688
+ });
689
+ }
690
+
659
691
  // Perform targeted scan for a specific device ID
660
692
  // Uses self-contained local listener pattern - no global state needed
661
693
  async function performTargetedScan(targetDeviceId: string): Promise<Peripheral | null> {
@@ -669,28 +701,41 @@ async function performTargetedScan(targetDeviceId: string): Promise<Peripheral |
669
701
  logger?.info('[NobleBLE] Starting targeted scan for device:', targetDeviceId);
670
702
 
671
703
  return new Promise((resolve, reject) => {
704
+ let settled = false;
705
+
706
+ const finish = async (peripheral: Peripheral | null, error?: Error) => {
707
+ if (settled) return;
708
+ settled = true;
709
+ if (timeoutId) clearTimeout(timeoutId);
710
+ nobleInstance.removeListener('discover', onDiscover);
711
+ await waitForNobleScanStop(nobleInstance);
712
+
713
+ if (error) {
714
+ logger?.error('[NobleBLE] Failed to start targeted scan:', error);
715
+ reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
716
+ return;
717
+ }
718
+ if (peripheral) {
719
+ discoveredDevices.set(peripheral.id, peripheral);
720
+ }
721
+ resolve(peripheral);
722
+ };
723
+
672
724
  // Local discover listener - only matches target device
673
725
  const onDiscover = (peripheral: Peripheral) => {
674
- if (peripheral.id === targetDeviceId) {
726
+ if (peripheral.id === targetDeviceId && isOneKeyPeripheral(peripheral)) {
675
727
  logger?.info('[NobleBLE] Target device found during targeted scan:', {
676
728
  id: peripheral.id,
677
729
  name: peripheral.advertisement?.localName,
678
730
  });
679
- clearTimeout(timeoutId);
680
- nobleInstance.removeListener('discover', onDiscover);
681
- nobleInstance.stopScanning();
682
- discoveredDevices.set(peripheral.id, peripheral);
683
- resolve(peripheral);
731
+ finish(peripheral).catch(reject);
684
732
  }
685
733
  };
686
734
 
687
- // Timeout handler - must be after onDiscover so it can reference it
688
735
  const timeoutId = setTimeout(() => {
689
- nobleInstance.removeListener('discover', onDiscover);
690
- nobleInstance.stopScanning();
691
736
  logger?.info('[NobleBLE] Targeted scan timeout for device:', targetDeviceId);
692
- resolve(null);
693
- }, FAST_SCAN_TIMEOUT);
737
+ finish(null).catch(reject);
738
+ }, NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS);
694
739
 
695
740
  // Add local listener for this scan
696
741
  nobleInstance.on('discover', onDiscover);
@@ -698,10 +743,7 @@ async function performTargetedScan(targetDeviceId: string): Promise<Peripheral |
698
743
  // Start scanning — no service UUID filter (Pro2 may use different service UUID)
699
744
  nobleInstance.startScanning([], false, (error?: Error) => {
700
745
  if (error) {
701
- clearTimeout(timeoutId);
702
- nobleInstance.removeListener('discover', onDiscover);
703
- logger?.error('[NobleBLE] Failed to start targeted scan:', error);
704
- reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
746
+ finish(null, error).catch(reject);
705
747
  return;
706
748
  }
707
749
  logger?.info('[NobleBLE] Targeted scan started for device:', targetDeviceId);
@@ -735,11 +777,13 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
735
777
  const devices: DeviceInfo[] = [];
736
778
  let intervalId: ReturnType<typeof setInterval> | undefined;
737
779
 
738
- // Cleanup function: clears both timeout and interval
739
- const cleanup = () => {
780
+ // Cleanup function: clears timers and waits until Noble confirms scanning
781
+ // has stopped. Resolving enumerate before this callback creates a race with
782
+ // an immediately-following connection attempt.
783
+ const cleanup = async () => {
740
784
  clearTimeout(timeoutId);
741
785
  if (intervalId) clearInterval(intervalId);
742
- nobleInstance.stopScanning();
786
+ await waitForNobleScanStop(nobleInstance);
743
787
  };
744
788
 
745
789
  // Collect discovered devices into the devices array
@@ -759,10 +803,10 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
759
803
  };
760
804
 
761
805
  // Set timeout for scanning — use longer timeout to catch slow-advertising devices like Pro2
762
- const timeoutId = setTimeout(() => {
806
+ const timeoutId = setTimeout(async () => {
763
807
  // Final collection before resolving — catches devices discovered near the deadline
764
808
  checkDevices();
765
- cleanup();
809
+ await cleanup();
766
810
  logger?.info('[NobleBLE] Scan completed, found devices:', devices.length);
767
811
  resolve(devices);
768
812
  }, DEVICE_SCAN_TIMEOUT);
@@ -770,9 +814,9 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
770
814
  // Start scanning without a service UUID filter so Pro2 advertisements with
771
815
  // short vendor UUIDs can be found, but only OneKey candidates are logged/returned.
772
816
  logger?.info('[NobleBLE] Scanning for OneKey BLE devices');
773
- nobleInstance.startScanning([], false, (error?: Error) => {
817
+ nobleInstance.startScanning([], false, async (error?: Error) => {
774
818
  if (error) {
775
- cleanup();
819
+ await cleanup();
776
820
  logger?.error('[NobleBLE] Failed to start scanning:', error);
777
821
  reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
778
822
  return;
@@ -789,30 +833,21 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
789
833
  // Stop scanning
790
834
  async function stopScanning(): Promise<void> {
791
835
  if (!noble) return;
792
-
793
- return new Promise<void>(resolve => {
794
- if (!noble) {
795
- resolve();
796
- return;
797
- }
798
-
799
- noble.stopScanning(() => {
800
- logger?.info('[NobleBLE] Scanning stopped');
801
- resolve();
802
- });
803
- });
836
+ const nobleInstance = noble;
837
+ await waitForNobleScanStop(nobleInstance);
838
+ logger?.info('[NobleBLE] Scanning stopped');
804
839
  }
805
840
 
806
841
  // Clean up all Noble listeners (for app exit)
807
842
  function cleanupNobleListeners(): void {
808
843
  if (!noble) return;
809
844
 
810
- // Remove all listeners to prevent memory leaks
811
- // Noble uses EventEmitter, need to use removeAllListeners
812
845
  try {
813
- (noble as any).removeAllListeners('discover');
814
- (noble as any).removeAllListeners('stateChange');
815
- logger?.info('[NobleBLE] All Noble listeners cleaned up');
846
+ if (persistentDiscoverListener) {
847
+ noble.removeListener('discover', persistentDiscoverListener);
848
+ persistentDiscoverListener = null;
849
+ }
850
+ logger?.info('[NobleBLE] Owned Noble listeners cleaned up');
816
851
  } catch (error) {
817
852
  logger?.error('[NobleBLE] Failed to clean up some listeners:', error);
818
853
  }
@@ -829,6 +864,7 @@ function getDevice(deviceId: string): DeviceInfo | null {
829
864
  id: peripheral.id,
830
865
  name: deviceName,
831
866
  state: peripheral.state || 'disconnected',
867
+ ...(typeof peripheral.mtu === 'number' ? { mtu: peripheral.mtu } : {}),
832
868
  };
833
869
  }
834
870
 
@@ -841,6 +877,7 @@ function getDevice(deviceId: string): DeviceInfo | null {
841
877
  id: connectedPeripheral.id,
842
878
  name: deviceName,
843
879
  state: connectedPeripheral.state || 'connected',
880
+ ...(typeof connectedPeripheral.mtu === 'number' ? { mtu: connectedPeripheral.mtu } : {}),
844
881
  };
845
882
  }
846
883
 
@@ -910,31 +947,25 @@ async function discoverServicesAndCharacteristics(
910
947
  });
911
948
  });
912
949
 
913
- // Log all discovered services
914
- logger?.info(
915
- '[NobleBLE] All services:',
916
- services?.map(s => s.uuid)
917
- );
918
-
919
950
  if (!services || services.length === 0) {
920
951
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No services found');
921
952
  }
922
953
 
954
+ logger?.debug('[NobleBLE] services discovered', {
955
+ deviceId: peripheral.id,
956
+ serviceUUIDs: services.map(service => service.uuid),
957
+ });
958
+
923
959
  // Find OneKey service — Noble may expose 128-bit UUIDs as short UUID keys.
924
- let service = services.find(s => NORMALIZED_ONEKEY_SERVICE_UUIDS.has(getBleUuidKey(s.uuid)));
925
- if (!service) {
926
- logger?.info('[NobleBLE] Known OneKey service UUID not found, trying first vendor service');
927
- service =
928
- services.find(s => PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS.has(getBleUuidKey(s.uuid))) ||
929
- services.find(s => !isGenericBleService(s.uuid)) ||
930
- services[0];
931
- }
960
+ const service = services.find(s => matchesKnownBleUuid(s.uuid, ONEKEY_SERVICE_UUID_ALIASES));
932
961
  if (!service) {
933
962
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound);
934
963
  }
935
964
  const selectedService = service;
936
- logger?.info('[NobleBLE] Using service:', selectedService.uuid);
937
-
965
+ logger?.debug('[NobleBLE] service selected', {
966
+ deviceId: peripheral.id,
967
+ serviceUuid: selectedService.uuid,
968
+ });
938
969
  // Step 2: Discover ALL characteristics (no filter)
939
970
  const characteristics = await new Promise<Characteristic[]>((resolve, reject) => {
940
971
  selectedService.discoverCharacteristics([], (error, chars) => {
@@ -948,30 +979,17 @@ async function discoverServicesAndCharacteristics(
948
979
  });
949
980
 
950
981
  // Step 3: Find required characteristics
951
- logger?.info('[NobleBLE] Discovered characteristics:', {
952
- count: characteristics?.length || 0,
953
- uuids: characteristics?.map(c => c.uuid) || [],
954
- });
955
-
956
982
  let writeCharacteristic: Characteristic | null = null;
957
983
  let notifyCharacteristic: Characteristic | null = null;
958
984
 
959
985
  for (const characteristic of characteristics) {
960
- const uuid = characteristic.uuid.replace(/-/g, '').toLowerCase();
961
- const uuidKey = uuid.length >= 8 ? uuid.substring(4, 8) : uuid;
962
-
963
- if (uuidKey === NORMALIZED_WRITE_UUID) {
986
+ if (matchesKnownBleUuid(characteristic.uuid, ONEKEY_WRITE_UUID_ALIASES)) {
964
987
  writeCharacteristic = characteristic;
965
- } else if (uuidKey === NORMALIZED_NOTIFY_UUID) {
988
+ } else if (matchesKnownBleUuid(characteristic.uuid, ONEKEY_NOTIFY_UUID_ALIASES)) {
966
989
  notifyCharacteristic = characteristic;
967
990
  }
968
991
  }
969
992
 
970
- logger?.info('[NobleBLE] Characteristic discovery result:', {
971
- writeFound: !!writeCharacteristic,
972
- notifyFound: !!notifyCharacteristic,
973
- });
974
-
975
993
  if (!writeCharacteristic || !notifyCharacteristic) {
976
994
  logger?.error(
977
995
  '[NobleBLE] Missing characteristics - write:',
@@ -985,6 +1003,13 @@ async function discoverServicesAndCharacteristics(
985
1003
  );
986
1004
  }
987
1005
 
1006
+ logger?.debug('[NobleBLE] characteristics selected', {
1007
+ deviceId: peripheral.id,
1008
+ serviceUuid: selectedService.uuid,
1009
+ writeUuid: writeCharacteristic.uuid,
1010
+ notifyUuid: notifyCharacteristic.uuid,
1011
+ });
1012
+
988
1013
  return { write: writeCharacteristic, notify: notifyCharacteristic };
989
1014
  })();
990
1015
 
@@ -1017,29 +1042,23 @@ async function forceReconnectPeripheral(peripheral: Peripheral, deviceId: string
1017
1042
 
1018
1043
  // Step 2: Force disconnect if connected
1019
1044
  if (peripheral.state === 'connected') {
1020
- // Remove disconnect listener before intentional disconnect to avoid spurious events
1021
- peripheral.removeAllListeners('disconnect');
1022
- await new Promise<void>(resolve => {
1023
- peripheral.disconnect(() => {
1024
- logger?.info('[NobleBLE] Force disconnect completed');
1025
- resolve();
1026
- });
1027
- });
1045
+ await runBleCallbackOperation(
1046
+ callback =>
1047
+ peripheral.disconnect(() => {
1048
+ logger?.info('[NobleBLE] Force disconnect completed');
1049
+ callback();
1050
+ }),
1051
+ { timeoutMs: BLE_CLEANUP_TIMEOUT, timeoutBehavior: 'resolve' }
1052
+ );
1028
1053
  }
1029
1054
 
1030
1055
  // Step 3: Re-establish connection
1031
- await new Promise<void>((resolve, reject) => {
1032
- peripheral.connect((error: Error | undefined) => {
1033
- if (error) {
1034
- logger?.error('[NobleBLE] Force reconnect failed:', error);
1035
- reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
1036
- } else {
1037
- logger?.info('[NobleBLE] Force reconnect successful');
1038
- connectedDevices.set(deviceId, peripheral);
1039
- resolve();
1040
- }
1041
- });
1056
+ await runBleCallbackOperation(callback => peripheral.connect(callback), {
1057
+ timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
1058
+ timeoutBehavior: 'reject',
1042
1059
  });
1060
+ logger?.info('[NobleBLE] Force reconnect successful');
1061
+ connectedDevices.set(deviceId, peripheral);
1043
1062
 
1044
1063
  // Wait for connection to stabilize
1045
1064
  await wait(500);
@@ -1170,16 +1189,26 @@ async function setupConnectionAndDiscoverServices(
1170
1189
  deviceId: string,
1171
1190
  webContents: WebContents
1172
1191
  ): Promise<CharacteristicPair> {
1173
- // Force reconnect to clear GATT cache
1174
- await forceReconnectPeripheral(peripheral, deviceId);
1175
1192
  setupDisconnectListener(peripheral, deviceId, webContents);
1176
1193
 
1177
- // Discover services and characteristics with retry
1178
1194
  try {
1179
1195
  return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1180
- } catch (error) {
1181
- // Last resort: fresh scan to get new peripheral object
1182
- logger?.error('[NobleBLE] Service discovery failed, attempting fresh scan...', error);
1196
+ } catch (initialError) {
1197
+ logger?.info('[NobleBLE] Direct service discovery failed, retrying after reconnect', {
1198
+ deviceId,
1199
+ error: initialError,
1200
+ });
1201
+ }
1202
+
1203
+ await forceReconnectPeripheral(peripheral, deviceId);
1204
+ setupDisconnectListener(peripheral, deviceId, webContents);
1205
+ try {
1206
+ return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1207
+ } catch (reconnectError) {
1208
+ logger?.error(
1209
+ '[NobleBLE] Service discovery failed after reconnect, attempting fresh scan...',
1210
+ reconnectError
1211
+ );
1183
1212
  return freshScanAndDiscover(deviceId, webContents);
1184
1213
  }
1185
1214
  }
@@ -1297,15 +1326,30 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1297
1326
  }
1298
1327
 
1299
1328
  return new Promise((resolve, reject) => {
1329
+ let connectionTimedOut = false;
1300
1330
  const timeout = setTimeout(() => {
1331
+ connectionTimedOut = true;
1301
1332
  reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'Connection timeout'));
1302
- }, CONNECTION_TIMEOUT);
1333
+ }, NOBLE_BLE_CONNECTION_TIMEOUT_MS);
1303
1334
 
1304
1335
  // TypeScript type assertion - peripheral is guaranteed to be defined at this point
1305
1336
  const connectedPeripheral = peripheral as Peripheral;
1306
1337
  connectedPeripheral.connect(async (error: Error | undefined) => {
1307
1338
  clearTimeout(timeout);
1308
1339
 
1340
+ // Noble may invoke the callback after the SDK timed out and released the request.
1341
+ // Ignore it to avoid initializing disposed commands or leaving an orphaned connection.
1342
+ if (connectionTimedOut) {
1343
+ if (!error) {
1344
+ try {
1345
+ connectedPeripheral.disconnect(() => undefined);
1346
+ } catch {
1347
+ // Best-effort cleanup only.
1348
+ }
1349
+ }
1350
+ return;
1351
+ }
1352
+
1309
1353
  if (error) {
1310
1354
  logger?.error('[NobleBLE] Connection failed:', error);
1311
1355
  reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
@@ -1342,20 +1386,21 @@ async function disconnectDevice(deviceId: string): Promise<void> {
1342
1386
  return;
1343
1387
  }
1344
1388
 
1345
- return new Promise<void>(resolve => {
1346
- // Remove disconnect listener to avoid triggering handleDeviceDisconnect
1347
- peripheral.removeAllListeners('disconnect');
1348
-
1349
- peripheral.disconnect(() => {
1350
- // Clean up device state using unified function
1351
- cleanupDevice(deviceId, undefined, {
1352
- cleanupConnection: true,
1353
- sendDisconnectEvent: false,
1354
- cancelOperations: true,
1355
- reason: 'manual-disconnect',
1356
- });
1357
- resolve();
1358
- });
1389
+ const disconnectEntry = deviceDisconnectListeners.get(deviceId);
1390
+ if (disconnectEntry) {
1391
+ disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
1392
+ deviceDisconnectListeners.delete(deviceId);
1393
+ }
1394
+
1395
+ await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1396
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
1397
+ timeoutBehavior: 'resolve',
1398
+ });
1399
+ cleanupDevice(deviceId, undefined, {
1400
+ cleanupConnection: true,
1401
+ sendDisconnectEvent: false,
1402
+ cancelOperations: true,
1403
+ reason: 'manual-disconnect',
1359
1404
  });
1360
1405
  }
1361
1406
 
@@ -1376,17 +1421,11 @@ async function unsubscribeNotifications(deviceId: string): Promise<void> {
1376
1421
  subscriptionOperations.set(deviceId, 'unsubscribing');
1377
1422
 
1378
1423
  try {
1379
- await new Promise<void>((resolve, reject) => {
1380
- notifyCharacteristic.unsubscribe((error: Error | undefined) => {
1381
- if (error) {
1382
- logger?.error('[NobleBLE] Notification unsubscription failed:', error);
1383
- reject(error);
1384
- } else {
1385
- logger?.info('[NobleBLE] Notification unsubscription successful');
1386
- resolve();
1387
- }
1388
- });
1424
+ await runBleCallbackOperation(callback => notifyCharacteristic.unsubscribe(callback), {
1425
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
1426
+ timeoutBehavior: 'resolve',
1389
1427
  });
1428
+ logger?.info('[NobleBLE] Notification unsubscription completed');
1390
1429
 
1391
1430
  // Remove all listeners and clear subscription status
1392
1431
  notifyCharacteristic.removeAllListeners('data');
@@ -1479,19 +1518,13 @@ async function subscribeNotifications(
1479
1518
  notifyCharacteristic: Characteristic
1480
1519
  ): Promise<void> {
1481
1520
  // Listeners already cleaned up above, no need to repeat
1482
- await new Promise<void>(resolve => {
1483
- notifyCharacteristic.unsubscribe(() => {
1484
- resolve();
1485
- });
1521
+ await runBleCallbackOperation(callback => notifyCharacteristic.unsubscribe(callback), {
1522
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
1523
+ timeoutBehavior: 'resolve',
1486
1524
  });
1487
- await new Promise<void>((resolve, reject) => {
1488
- notifyCharacteristic.subscribe((error?: Error) => {
1489
- if (error) {
1490
- reject(error);
1491
- return;
1492
- }
1493
- resolve();
1494
- });
1525
+ await runBleCallbackOperation(callback => notifyCharacteristic.subscribe(callback), {
1526
+ timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
1527
+ timeoutBehavior: 'reject',
1495
1528
  });
1496
1529
 
1497
1530
  notifyCharacteristic.on('data', (data: Buffer) => {
@@ -1516,29 +1549,25 @@ async function subscribeNotifications(
1516
1549
 
1517
1550
  // Setup IPC handlers
1518
1551
  export function setupNobleBleHandlers(webContents: WebContents): void {
1519
- // Use console.log for initial logging as electron-log might not be available yet.
1520
- console.log('[NobleBLE] Attempting to set up Noble BLE handlers.');
1521
1552
  try {
1522
- console.log('[NobleBLE] NOBLE_VERSION_771');
1523
-
1524
1553
  // @ts-ignore – electron-log is only available at runtime
1525
1554
  // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
1526
1555
  logger = require('electron-log') as Logger;
1527
- console.log('[NobleBLE] electron-log loaded successfully.');
1528
1556
 
1529
1557
  // @ts-ignore – electron is only available at runtime
1530
1558
  // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
1531
1559
  const { ipcMain } = require('electron');
1532
- console.log('[NobleBLE] electron.ipcMain loaded successfully.');
1533
1560
 
1534
1561
  safeLog(logger, 'info', 'Setting up Noble BLE IPC handlers');
1535
1562
 
1536
1563
  // Handle enumerate request
1537
- console.log(`[NobleBLE] Registering handler for: ${EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE}`);
1538
1564
  ipcMain.handle(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE, async () => {
1539
1565
  try {
1540
1566
  const devices = await enumerateDevices();
1541
- safeLog(logger, 'info', 'Enumeration completed, devices:', devices);
1567
+ safeLog(logger, 'debug', 'Enumeration completed', {
1568
+ count: devices.length,
1569
+ devices: devices.map(device => ({ id: device.id, name: device.name })),
1570
+ });
1542
1571
  return devices;
1543
1572
  } catch (error) {
1544
1573
  safeLog(logger, 'error', 'Enumeration failed:', error);
@@ -1583,7 +1612,6 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1583
1612
  ipcMain.handle(
1584
1613
  EOneKeyBleMessageKeys.NOBLE_BLE_WRITE,
1585
1614
  async (_event: IpcMainInvokeEvent, deviceId: string, hexData: string) => {
1586
- logger?.info('[NobleBLE] IPC WRITE', { deviceId, len: hexData.length });
1587
1615
  await transmitHexDataToDevice(deviceId, hexData);
1588
1616
  }
1589
1617
  );
@@ -1648,32 +1676,24 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1648
1676
  // Cleanup on app quit
1649
1677
  webContents.on('destroyed', () => {
1650
1678
  safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
1679
+ (async () => {
1680
+ const deviceIds = Array.from(connectedDevices.keys());
1681
+ for (const deviceId of deviceIds) {
1682
+ await unsubscribeNotifications(deviceId).catch(() => undefined);
1683
+ await disconnectDevice(deviceId).catch(() => undefined);
1684
+ }
1651
1685
 
1652
- // 1. Clean up all connected devices (unified cleanup, avoid duplicates)
1653
- const deviceIds = Array.from(connectedDevices.keys());
1654
- deviceIds.forEach(deviceId => {
1655
- cleanupDevice(deviceId, undefined, {
1656
- cleanupConnection: true,
1657
- sendDisconnectEvent: false,
1658
- cancelOperations: true,
1659
- reason: 'app-quit',
1660
- });
1686
+ await stopScanning().catch(() => undefined);
1687
+ if (noble && persistentStateListener) {
1688
+ noble.removeListener('stateChange', persistentStateListener);
1689
+ persistentStateListener = null;
1690
+ }
1691
+ cleanupNobleListeners();
1692
+ discoveredDevices.clear();
1693
+ safeLog(logger, 'info', 'Noble BLE cleanup completed');
1694
+ })().catch(error => {
1695
+ safeLog(logger, 'error', 'Noble BLE cleanup failed', error);
1661
1696
  });
1662
-
1663
- // 2. Stop scanning
1664
- stopScanning();
1665
-
1666
- // 3. Clean up Noble listeners
1667
- if (noble && persistentStateListener) {
1668
- noble.removeListener('stateChange', persistentStateListener);
1669
- persistentStateListener = null;
1670
- }
1671
- cleanupNobleListeners();
1672
-
1673
- // 4. Clear discovered devices cache
1674
- discoveredDevices.clear();
1675
-
1676
- safeLog(logger, 'info', 'Noble BLE cleanup completed');
1677
1697
  });
1678
1698
 
1679
1699
  safeLog(logger, 'info', 'Noble BLE IPC handlers setup completed');