@onekeyfe/hd-transport-electron 1.2.2-alpha.100 → 1.2.2-alpha.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,4 @@
1
+ /// <reference types="node" />
2
+ export declare function resolveNobleAttMtu(reportedMtu: number | null | undefined, platform?: NodeJS.Platform): number | undefined;
1
3
  export declare function resolveBlePacketCapacity(mtu: number | null | undefined, maximumPacketCapacity: number, fallbackPacketCapacity: number): number;
2
4
  //# sourceMappingURL=ble-packet-capacity.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ble-packet-capacity.d.ts","sourceRoot":"","sources":["../src/ble-packet-capacity.ts"],"names":[],"mappings":"AAEA,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAC9B,qBAAqB,EAAE,MAAM,EAC7B,sBAAsB,EAAE,MAAM,GAC7B,MAAM,CAMR"}
1
+ {"version":3,"file":"ble-packet-capacity.d.ts","sourceRoot":"","sources":["../src/ble-packet-capacity.ts"],"names":[],"mappings":";AAEA,wBAAgB,kBAAkB,CAChC,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACtC,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,MAAM,GAAG,SAAS,CASpB;AAED,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAC9B,qBAAqB,EAAE,MAAM,EAC7B,sBAAsB,EAAE,MAAM,GAC7B,MAAM,CAMR"}
@@ -32,7 +32,7 @@ typeof SuppressedError === "function" ? SuppressedError : function (error, suppr
32
32
 
33
33
  function initNobleBleSupport(webContents) {
34
34
  return __awaiter(this, void 0, void 0, function* () {
35
- const { setupNobleBleHandlers } = yield Promise.resolve().then(function () { return require('./noble-ble-handler-e8b87173.js'); });
35
+ const { setupNobleBleHandlers } = yield Promise.resolve().then(function () { return require('./noble-ble-handler-95c5cf0d.js'); });
36
36
  setupNobleBleHandlers(webContents);
37
37
  });
38
38
  }
package/dist/index.d.ts CHANGED
@@ -25,6 +25,7 @@ interface NobleModule {
25
25
  interface Logger {
26
26
  info(message: string, ...args: any[]): void;
27
27
  debug(message: string, ...args: any[]): void;
28
+ warn(message: string, ...args: any[]): void;
28
29
  error(message: string, ...args: any[]): void;
29
30
  }
30
31
 
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
- var index = require('./index-b37d926c.js');
5
+ var index = require('./index-00297050.js');
6
6
 
7
7
 
8
8
 
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var index = require('./index-b37d926c.js');
3
+ var index = require('./index-00297050.js');
4
4
  var hdShared = require('@onekeyfe/hd-shared');
5
5
  var pRetry = require('p-retry');
6
6
 
@@ -9,6 +9,15 @@ function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'defau
9
9
  var pRetry__default = /*#__PURE__*/_interopDefaultLegacy(pRetry);
10
10
 
11
11
  const BLE_ATT_HEADER_BYTES = 3;
12
+ function resolveNobleAttMtu(reportedMtu, platform = process.platform) {
13
+ if (typeof reportedMtu !== 'number' || !Number.isFinite(reportedMtu) || reportedMtu <= 0) {
14
+ return undefined;
15
+ }
16
+ const normalizedMtu = Math.floor(reportedMtu);
17
+ return platform === 'darwin' || platform === 'win32'
18
+ ? normalizedMtu + BLE_ATT_HEADER_BYTES
19
+ : normalizedMtu;
20
+ }
12
21
  function resolveBlePacketCapacity(mtu, maximumPacketCapacity, fallbackPacketCapacity) {
13
22
  if (typeof mtu !== 'number' || !Number.isFinite(mtu) || mtu <= BLE_ATT_HEADER_BYTES) {
14
23
  return fallbackPacketCapacity;
@@ -116,7 +125,8 @@ const DEVICE_SCAN_TIMEOUT = 5000;
116
125
  const DEVICE_CHECK_INTERVAL = 500;
117
126
  const SERVICE_DISCOVERY_TIMEOUT = 10000;
118
127
  const BLE_CLEANUP_TIMEOUT = 250;
119
- const BLE_IDLE_DISCONNECT_MS = 3 * 60000;
128
+ const BLE_DISCONNECT_CONFIRM_TIMEOUT_MS = 3000;
129
+ const BLE_IDLE_DISCONNECT_MS = 20000;
120
130
  const BLE_BUSY_BACKSTOP_MS = 10 * 60000;
121
131
  const BLE_PACKET_SIZE_FALLBACK = 192;
122
132
  const BLE_PACKET_SIZE_MAXIMUM = 244;
@@ -242,6 +252,7 @@ function initializeNoble() {
242
252
  try {
243
253
  noble = require('@stoprocent/noble');
244
254
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Noble library loaded');
255
+ setupPersistentStateListener();
245
256
  yield new Promise((resolve, reject) => {
246
257
  if (!noble) {
247
258
  reject(hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'Noble not initialized'));
@@ -251,7 +262,6 @@ function initializeNoble() {
251
262
  resolve();
252
263
  return;
253
264
  }
254
- setupPersistentStateListener();
255
265
  const timeout = setTimeout(() => {
256
266
  reject(hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'Bluetooth initialization timeout'));
257
267
  }, BLUETOOTH_INIT_TIMEOUT);
@@ -336,7 +346,14 @@ function armIdleDisconnect(deviceId, ms = BLE_IDLE_DISCONNECT_MS, reason = 'idle
336
346
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Keep-alive timeout, disconnecting device', { deviceId, reason });
337
347
  const peripheral = connectedDevices.get(deviceId);
338
348
  const deviceName = ((_a = peripheral === null || peripheral === void 0 ? void 0 : peripheral.advertisement) === null || _a === void 0 ? void 0 : _a.localName) || 'Unknown Device';
339
- const pending = disconnectDevice(deviceId)
349
+ const pending = unsubscribeNotifications(deviceId)
350
+ .catch(error => {
351
+ logger === null || logger === void 0 ? void 0 : logger.warn('[NobleBLE] Keep-alive unsubscribe failed, disconnecting anyway', {
352
+ deviceId,
353
+ error: error instanceof Error ? error.message : String(error),
354
+ });
355
+ })
356
+ .then(() => disconnectDevice(deviceId))
340
357
  .then(() => {
341
358
  broadcastToAllWebContents(hdShared.EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
342
359
  id: deviceId,
@@ -409,6 +426,7 @@ function handleDeviceDisconnect(deviceId, webContents) {
409
426
  });
410
427
  cleanupDevice(deviceId, webContents, {
411
428
  cleanupConnection: true,
429
+ cleanupDiscoveredCache: true,
412
430
  sendDisconnectEvent: true,
413
431
  cancelOperations: true,
414
432
  reason: 'auto-disconnect',
@@ -432,13 +450,14 @@ function setupMtuListener(peripheral, deviceId, webContents) {
432
450
  existing.peripheral.removeListener('mtu', existing.listener);
433
451
  }
434
452
  const listener = (mtu) => {
435
- if (!Number.isFinite(mtu) || mtu <= 0)
453
+ const normalizedMtu = resolveNobleAttMtu(mtu);
454
+ if (normalizedMtu === undefined)
436
455
  return;
437
456
  if (webContents.isDestroyed())
438
457
  return;
439
458
  webContents.send(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_MTU_CHANGED, {
440
459
  id: deviceId,
441
- mtu,
460
+ mtu: normalizedMtu,
442
461
  });
443
462
  };
444
463
  deviceMtuListeners.set(deviceId, { peripheral, listener });
@@ -545,7 +564,7 @@ function transmitHexDataToDevice(deviceId, hexData, options) {
545
564
  armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
546
565
  const toBuffer = Buffer.from(hexData, 'hex');
547
566
  const doGetWriteCharacteristic = () => { var _a; return (_a = deviceCharacteristics.get(deviceId)) === null || _a === void 0 ? void 0 : _a.write; };
548
- const packetCapacity = resolveBlePacketCapacity(peripheral.mtu, BLE_PACKET_SIZE_MAXIMUM, BLE_PACKET_SIZE_FALLBACK);
567
+ const packetCapacity = resolveBlePacketCapacity(resolveNobleAttMtu(peripheral.mtu), BLE_PACKET_SIZE_MAXIMUM, BLE_PACKET_SIZE_FALLBACK);
549
568
  const pacingDelayMs = resolveNobleBleWritePacingDelay(options);
550
569
  if (!IS_WINDOWS || pairedDevices.has(deviceId)) {
551
570
  const writeCharacteristic = doGetWriteCharacteristic();
@@ -590,18 +609,37 @@ function transmitHexDataToDevice(deviceId, hexData, options) {
590
609
  }
591
610
  });
592
611
  }
612
+ const BLE_COLD_CONNECT_SCAN_TIMEOUT_MS = 1500;
613
+ const SERVICE_DISCOVERY_MAX_ATTEMPTS = 2;
614
+ const bleNamesById = new Map();
615
+ function rememberBleName(deviceId, name) {
616
+ const trimmed = name === null || name === void 0 ? void 0 : name.trim();
617
+ if (trimmed) {
618
+ bleNamesById.set(deviceId, trimmed);
619
+ }
620
+ }
621
+ function resolveBleName(deviceId) {
622
+ var _a, _b, _c, _d, _e, _f;
623
+ const live = ((_c = (_b = (_a = connectedDevices.get(deviceId)) === null || _a === void 0 ? void 0 : _a.advertisement) === null || _b === void 0 ? void 0 : _b.localName) === null || _c === void 0 ? void 0 : _c.trim()) ||
624
+ ((_f = (_e = (_d = discoveredDevices.get(deviceId)) === null || _d === void 0 ? void 0 : _d.advertisement) === null || _e === void 0 ? void 0 : _e.localName) === null || _f === void 0 ? void 0 : _f.trim());
625
+ return live || bleNamesById.get(deviceId);
626
+ }
627
+ function shouldConnectByIdFirst(deviceId) {
628
+ return hdShared.isPro2FamilyBleName(resolveBleName(deviceId));
629
+ }
593
630
  function handleDeviceDiscovered(peripheral) {
594
- var _a, _b;
631
+ var _a, _b, _c;
595
632
  if (!isOneKeyPeripheral(peripheral)) {
596
633
  return;
597
634
  }
598
635
  const isNewDevice = !discoveredDevices.has(peripheral.id);
599
636
  discoveredDevices.set(peripheral.id, peripheral);
637
+ rememberBleName(peripheral.id, (_a = peripheral.advertisement) === null || _a === void 0 ? void 0 : _a.localName);
600
638
  if (isNewDevice) {
601
639
  logger === null || logger === void 0 ? void 0 : logger.debug('[NobleBLE] OneKey BLE device discovered', {
602
640
  deviceId: peripheral.id,
603
- name: ((_a = peripheral.advertisement) === null || _a === void 0 ? void 0 : _a.localName) || 'Unknown Device',
604
- serviceUUIDs: ((_b = peripheral.advertisement) === null || _b === void 0 ? void 0 : _b.serviceUuids) || [],
641
+ name: ((_b = peripheral.advertisement) === null || _b === void 0 ? void 0 : _b.localName) || 'Unknown Device',
642
+ serviceUUIDs: ((_c = peripheral.advertisement) === null || _c === void 0 ? void 0 : _c.serviceUuids) || [],
605
643
  });
606
644
  }
607
645
  }
@@ -627,7 +665,7 @@ function waitForNobleScanStop(nobleInstance) {
627
665
  });
628
666
  });
629
667
  }
630
- function performTargetedScan(targetDeviceId) {
668
+ function performTargetedScan(targetDeviceId, timeoutMs = NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS) {
631
669
  return index.__awaiter(this, void 0, void 0, function* () {
632
670
  if (!noble) {
633
671
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'Noble not available');
@@ -667,7 +705,7 @@ function performTargetedScan(targetDeviceId) {
667
705
  const timeoutId = setTimeout(() => {
668
706
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Targeted scan timeout for device:', targetDeviceId);
669
707
  finish(null).catch(reject);
670
- }, NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS);
708
+ }, timeoutMs);
671
709
  nobleInstance.on('discover', onDiscover);
672
710
  nobleInstance.startScanning([], true, (error) => {
673
711
  if (error) {
@@ -763,12 +801,14 @@ function getDevice(deviceId) {
763
801
  const peripheral = discoveredDevices.get(deviceId);
764
802
  if (peripheral) {
765
803
  const deviceName = ((_a = peripheral.advertisement) === null || _a === void 0 ? void 0 : _a.localName) || 'Unknown Device';
766
- return Object.assign({ commType: 'electron-ble', id: peripheral.id, name: deviceName, state: peripheral.state || 'disconnected' }, (typeof peripheral.mtu === 'number' ? { mtu: peripheral.mtu } : {}));
804
+ const mtu = resolveNobleAttMtu(peripheral.mtu);
805
+ return Object.assign({ commType: 'electron-ble', id: peripheral.id, name: deviceName, state: peripheral.state || 'disconnected' }, (mtu === undefined ? {} : { mtu }));
767
806
  }
768
807
  const connectedPeripheral = connectedDevices.get(deviceId);
769
808
  if (connectedPeripheral) {
770
809
  const deviceName = ((_b = connectedPeripheral.advertisement) === null || _b === void 0 ? void 0 : _b.localName) || 'Unknown Device';
771
- return Object.assign({ commType: 'electron-ble', id: connectedPeripheral.id, name: deviceName, state: connectedPeripheral.state || 'connected' }, (typeof connectedPeripheral.mtu === 'number' ? { mtu: connectedPeripheral.mtu } : {}));
810
+ const mtu = resolveNobleAttMtu(connectedPeripheral.mtu);
811
+ return Object.assign({ commType: 'electron-ble', id: connectedPeripheral.id, name: deviceName, state: connectedPeripheral.state || 'connected' }, (mtu === undefined ? {} : { mtu }));
772
812
  }
773
813
  return {
774
814
  commType: 'electron-ble',
@@ -802,7 +842,7 @@ function discoverServicesAndCharacteristics(peripheral) {
802
842
  });
803
843
  const discoveryPromise = (() => index.__awaiter(this, void 0, void 0, function* () {
804
844
  const services = yield new Promise((resolve, reject) => {
805
- peripheral.discoverServices([], (error, svc) => {
845
+ peripheral.discoverServices(ONEKEY_SERVICE_UUIDS, (error, svc) => {
806
846
  if (error) {
807
847
  logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed:', error);
808
848
  reject(hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleServiceNotFound, error.message));
@@ -813,7 +853,7 @@ function discoverServicesAndCharacteristics(peripheral) {
813
853
  });
814
854
  });
815
855
  if (!services || services.length === 0) {
816
- throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleServiceNotFound, 'No services found');
856
+ throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
817
857
  }
818
858
  logger === null || logger === void 0 ? void 0 : logger.debug('[NobleBLE] services discovered', {
819
859
  deviceId: peripheral.id,
@@ -930,11 +970,11 @@ function discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId) {
930
970
  deviceId,
931
971
  peripheralState: peripheral.state,
932
972
  attempt: attemptNumber,
933
- maxRetries: 5,
973
+ maxRetries: SERVICE_DISCOVERY_MAX_ATTEMPTS,
934
974
  targetUUIDs: ONEKEY_SERVICE_UUIDS,
935
975
  });
936
976
  if (attemptNumber > 1) {
937
- logger === null || logger === void 0 ? void 0 : logger.info(`[NobleBLE] Service discovery retry attempt ${attemptNumber}/5`);
977
+ logger === null || logger === void 0 ? void 0 : logger.info(`[NobleBLE] Service discovery retry attempt ${attemptNumber}/${SERVICE_DISCOVERY_MAX_ATTEMPTS}`);
938
978
  }
939
979
  if (peripheral.state !== 'connected') {
940
980
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleConnectedError, `Device not connected: ${peripheral.state}`);
@@ -943,16 +983,16 @@ function discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId) {
943
983
  return yield discoverServicesAndCharacteristics(peripheral);
944
984
  }
945
985
  catch (error) {
946
- logger === null || logger === void 0 ? void 0 : logger.error(`[NobleBLE] No services found (attempt ${attemptNumber}/5)`);
947
- if (attemptNumber < 5) {
948
- logger === null || logger === void 0 ? void 0 : logger.error(`[NobleBLE] Will retry service discovery (attempt ${attemptNumber + 1}/5)`);
986
+ logger === null || logger === void 0 ? void 0 : logger.error(`[NobleBLE] No services found (attempt ${attemptNumber}/${SERVICE_DISCOVERY_MAX_ATTEMPTS})`);
987
+ if (attemptNumber < SERVICE_DISCOVERY_MAX_ATTEMPTS) {
988
+ logger === null || logger === void 0 ? void 0 : logger.error(`[NobleBLE] Will retry service discovery (attempt ${attemptNumber + 1}/${SERVICE_DISCOVERY_MAX_ATTEMPTS})`);
949
989
  }
950
990
  throw error;
951
991
  }
952
992
  }), {
953
- retries: 4,
993
+ retries: SERVICE_DISCOVERY_MAX_ATTEMPTS - 1,
954
994
  factor: 1.5,
955
- minTimeout: 1000,
995
+ minTimeout: 500,
956
996
  maxTimeout: 3000,
957
997
  onFailedAttempt: error => {
958
998
  logger === null || logger === void 0 ? void 0 : logger.error(`[NobleBLE] Service discovery attempt ${error.attemptNumber} failed:`, {
@@ -966,23 +1006,24 @@ function discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId) {
966
1006
  }
967
1007
  function setupConnectionAndDiscoverServices(peripheral, deviceId, webContents) {
968
1008
  return index.__awaiter(this, void 0, void 0, function* () {
969
- setupDisconnectListener(peripheral, deviceId, webContents);
970
1009
  try {
971
- return yield discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1010
+ yield forceReconnectPeripheral(peripheral, deviceId);
972
1011
  }
973
- catch (initialError) {
974
- logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Direct service discovery failed, retrying after reconnect', {
975
- deviceId,
976
- error: initialError,
977
- });
1012
+ catch (resetError) {
1013
+ logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
978
1014
  }
979
- yield forceReconnectPeripheral(peripheral, deviceId);
980
1015
  setupDisconnectListener(peripheral, deviceId, webContents);
981
1016
  try {
982
1017
  return yield discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
983
1018
  }
984
- catch (reconnectError) {
985
- logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed after reconnect, attempting fresh scan...', reconnectError);
1019
+ catch (discoveryError) {
1020
+ if (peripheral.state === 'connected') {
1021
+ logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed on a live link, dropping it before the fresh scan', discoveryError);
1022
+ yield disconnectDevice(deviceId).catch(() => undefined);
1023
+ }
1024
+ else {
1025
+ logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed, attempting fresh scan...', discoveryError);
1026
+ }
986
1027
  return freshScanAndDiscover(deviceId, webContents);
987
1028
  }
988
1029
  });
@@ -1042,7 +1083,7 @@ function tryDirectConnectById(deviceId) {
1042
1083
  });
1043
1084
  }
1044
1085
  function connectDevice(deviceId, webContents) {
1045
- var _a;
1086
+ var _a, _b, _c;
1046
1087
  return index.__awaiter(this, void 0, void 0, function* () {
1047
1088
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Connect device request:', {
1048
1089
  deviceId,
@@ -1054,34 +1095,47 @@ function connectDevice(deviceId, webContents) {
1054
1095
  });
1055
1096
  let peripheral = (_a = discoveredDevices.get(deviceId)) !== null && _a !== void 0 ? _a : connectedDevices.get(deviceId);
1056
1097
  if (!peripheral) {
1057
- logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Device not discovered, attempting targeted scan for:', deviceId);
1058
1098
  if (!noble) {
1059
1099
  yield initializeNoble();
1060
1100
  }
1061
1101
  if (!noble) {
1062
1102
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'Noble not available');
1063
1103
  }
1064
- peripheral = yield tryDirectConnectById(deviceId);
1065
- if (peripheral) {
1066
- discoveredDevices.set(deviceId, peripheral);
1067
- }
1068
- }
1069
- if (!peripheral) {
1070
- try {
1071
- const foundPeripheral = yield performTargetedScan(deviceId);
1072
- if (!foundPeripheral) {
1073
- throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found even after targeted scan`);
1104
+ const byIdFirst = shouldConnectByIdFirst(deviceId);
1105
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Resolving device for cold connect', {
1106
+ deviceId,
1107
+ name: (_b = resolveBleName(deviceId)) !== null && _b !== void 0 ? _b : 'unknown',
1108
+ strategy: byIdFirst ? 'connect-by-id-first' : 'scan-first',
1109
+ });
1110
+ const scanForPeripheral = () => index.__awaiter(this, void 0, void 0, function* () {
1111
+ var _d;
1112
+ try {
1113
+ return (_d = (yield performTargetedScan(deviceId, BLE_COLD_CONNECT_SCAN_TIMEOUT_MS))) !== null && _d !== void 0 ? _d : undefined;
1074
1114
  }
1075
- peripheral = foundPeripheral;
1076
- }
1077
- catch (error) {
1078
- logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Targeted scan failed:', error);
1079
- throw error;
1115
+ catch (scanError) {
1116
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Targeted scan failed', {
1117
+ deviceId,
1118
+ error: scanError instanceof Error ? scanError.message : String(scanError),
1119
+ });
1120
+ return undefined;
1121
+ }
1122
+ });
1123
+ const connectById = () => index.__awaiter(this, void 0, void 0, function* () {
1124
+ const found = yield tryDirectConnectById(deviceId);
1125
+ if (found) {
1126
+ discoveredDevices.set(deviceId, found);
1127
+ }
1128
+ return found;
1129
+ });
1130
+ peripheral = byIdFirst ? yield connectById() : yield scanForPeripheral();
1131
+ if (!peripheral) {
1132
+ peripheral = byIdFirst ? yield scanForPeripheral() : yield connectById();
1080
1133
  }
1081
1134
  }
1082
1135
  if (!peripheral) {
1083
1136
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found`);
1084
1137
  }
1138
+ rememberBleName(deviceId, (_c = peripheral.advertisement) === null || _c === void 0 ? void 0 : _c.localName);
1085
1139
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Connecting to device:', deviceId);
1086
1140
  if (peripheral.state === 'connected') {
1087
1141
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Device already connected, skipping connection step');
@@ -1156,9 +1210,9 @@ function connectDevice(deviceId, webContents) {
1156
1210
  }
1157
1211
  catch (setupError) {
1158
1212
  logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Connection setup failed:', setupError);
1159
- connectedPeripheral.disconnect(() => {
1160
- reject(setupError);
1161
- });
1213
+ disconnectDevice(deviceId)
1214
+ .catch(() => undefined)
1215
+ .then(() => reject(setupError));
1162
1216
  }
1163
1217
  }));
1164
1218
  });
@@ -1176,11 +1230,15 @@ function disconnectDevice(deviceId) {
1176
1230
  deviceDisconnectListeners.delete(deviceId);
1177
1231
  }
1178
1232
  yield runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1179
- timeoutMs: BLE_CLEANUP_TIMEOUT,
1233
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
1180
1234
  timeoutBehavior: 'resolve',
1181
1235
  });
1236
+ if (peripheral.state === 'connected') {
1237
+ logger === null || logger === void 0 ? void 0 : logger.warn('[NobleBLE] Physical disconnect did not confirm in time', { deviceId });
1238
+ }
1182
1239
  cleanupDevice(deviceId, undefined, {
1183
1240
  cleanupConnection: true,
1241
+ cleanupDiscoveredCache: true,
1184
1242
  sendDisconnectEvent: false,
1185
1243
  cancelOperations: true,
1186
1244
  reason: 'manual-disconnect',
@@ -1271,9 +1329,14 @@ function subscribeNotifications(deviceId, callback) {
1271
1329
  });
1272
1330
  });
1273
1331
  }
1332
+ const subscribeStartedAt = Date.now();
1274
1333
  try {
1275
1334
  yield rebuildAppSubscription(deviceId, notifyCharacteristic);
1276
1335
  subscribedDevices.set(deviceId, true);
1336
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Notification subscription active', {
1337
+ deviceId,
1338
+ ms: Date.now() - subscribeStartedAt,
1339
+ });
1277
1340
  }
1278
1341
  finally {
1279
1342
  subscriptionOperations.set(deviceId, 'idle');
@@ -1391,10 +1454,6 @@ function setupNobleBleHandlers(webContents) {
1391
1454
  yield disconnectDevice(deviceId).catch(() => undefined);
1392
1455
  }
1393
1456
  yield stopScanning().catch(() => undefined);
1394
- if (noble && persistentStateListener) {
1395
- noble.removeListener('stateChange', persistentStateListener);
1396
- persistentStateListener = null;
1397
- }
1398
1457
  cleanupNobleListeners();
1399
1458
  discoveredDevices.clear();
1400
1459
  safeLog(logger, 'info', 'Noble BLE cleanup completed');
@@ -1 +1 @@
1
- {"version":3,"file":"noble-ble-handler.d.ts","sourceRoot":"","sources":["../src/noble-ble-handler.ts"],"names":[],"mappings":"AA+BA,OAAO,KAAK,EAA+B,WAAW,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AA0EhE,wBAAgB,+BAA+B,CAAC,OAAO,CAAC,EAAE,oBAAoB,UAI7E;AA2mDD,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,WAAW,GAAG,IAAI,CAgMpE"}
1
+ {"version":3,"file":"noble-ble-handler.d.ts","sourceRoot":"","sources":["../src/noble-ble-handler.ts"],"names":[],"mappings":"AAgCA,OAAO,KAAK,EAA+B,WAAW,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAoFhE,wBAAgB,+BAA+B,CAAC,OAAO,CAAC,EAAE,oBAAoB,UAI7E;AAyvDD,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,WAAW,GAAG,IAAI,CAkMpE"}
@@ -22,6 +22,7 @@ export interface NobleModule {
22
22
  export interface Logger {
23
23
  info(message: string, ...args: any[]): void;
24
24
  debug(message: string, ...args: any[]): void;
25
+ warn(message: string, ...args: any[]): void;
25
26
  error(message: string, ...args: any[]): void;
26
27
  }
27
28
  export declare function safeLog(logger: Logger | null, level: 'info' | 'debug' | 'error', message: string, ...args: any[]): void;
@@ -1 +1 @@
1
- {"version":3,"file":"noble-extended.d.ts","sourceRoot":"","sources":["../../src/types/noble-extended.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAGnE,MAAM,WAAW,UAAW,SAAQ,oBAAoB;IACtD,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAGD,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,cAAc,CAAC;IACtB,MAAM,EAAE,cAAc,CAAC;CACxB;AAGD,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,CACX,YAAY,EAAE,MAAM,EAAE,EACtB,eAAe,EAAE,OAAO,EACxB,QAAQ,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,KAAK,KAAK,IAAI,GACjC,IAAI,CAAC;IACR,YAAY,CAAC,QAAQ,CAAC,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;IAC1C,EAAE,CAAC,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAAC;IAClE,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,IAAI,GAAG,IAAI,CAAC;IACxE,cAAc,CAAC,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAAC;IAC9E,cAAc,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,IAAI,GAAG,IAAI,CAAC;CACrF;AAGD,MAAM,WAAW,MAAM;IACrB,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;IAC5C,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;IAC7C,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;CAC9C;AAGD,wBAAgB,OAAO,CACrB,MAAM,EAAE,MAAM,GAAG,IAAI,EACrB,KAAK,EAAE,MAAM,GAAG,OAAO,GAAG,OAAO,EACjC,OAAO,EAAE,MAAM,EACf,GAAG,IAAI,EAAE,GAAG,EAAE,GACb,IAAI,CAMN"}
1
+ {"version":3,"file":"noble-extended.d.ts","sourceRoot":"","sources":["../../src/types/noble-extended.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAGnE,MAAM,WAAW,UAAW,SAAQ,oBAAoB;IACtD,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAGD,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,cAAc,CAAC;IACtB,MAAM,EAAE,cAAc,CAAC;CACxB;AAGD,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,CACX,YAAY,EAAE,MAAM,EAAE,EACtB,eAAe,EAAE,OAAO,EACxB,QAAQ,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,KAAK,KAAK,IAAI,GACjC,IAAI,CAAC;IACR,YAAY,CAAC,QAAQ,CAAC,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;IAC1C,EAAE,CAAC,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAAC;IAClE,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,IAAI,GAAG,IAAI,CAAC;IACxE,cAAc,CAAC,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAAC;IAC9E,cAAc,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,IAAI,GAAG,IAAI,CAAC;CACrF;AAGD,MAAM,WAAW,MAAM;IACrB,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;IAC5C,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;IAC7C,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;IAC5C,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;CAC9C;AAGD,wBAAgB,OAAO,CACrB,MAAM,EAAE,MAAM,GAAG,IAAI,EACrB,KAAK,EAAE,MAAM,GAAG,OAAO,GAAG,OAAO,EACjC,OAAO,EAAE,MAAM,EACf,GAAG,IAAI,EAAE,GAAG,EAAE,GACb,IAAI,CAMN"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/hd-transport-electron",
3
- "version": "1.2.2-alpha.100",
3
+ "version": "1.2.2-alpha.11",
4
4
  "author": "OneKey",
5
5
  "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme",
6
6
  "license": "MIT",
@@ -25,9 +25,9 @@
25
25
  "electron-log": ">=4.0.0"
26
26
  },
27
27
  "dependencies": {
28
- "@onekeyfe/hd-core": "1.2.2-alpha.100",
29
- "@onekeyfe/hd-shared": "1.2.2-alpha.100",
30
- "@onekeyfe/hd-transport": "1.2.2-alpha.100",
28
+ "@onekeyfe/hd-core": "1.2.2-alpha.11",
29
+ "@onekeyfe/hd-shared": "1.2.2-alpha.11",
30
+ "@onekeyfe/hd-transport": "1.2.2-alpha.11",
31
31
  "@stoprocent/noble": "2.3.16",
32
32
  "p-retry": "^4.6.2"
33
33
  },
@@ -36,5 +36,5 @@
36
36
  "electron": "^25.0.0",
37
37
  "typescript": "^5.3.3"
38
38
  },
39
- "gitHead": "c40dad085297b0e3cc2020bd218c146dff6ed3e1"
39
+ "gitHead": "8928100acb893d72361eb74422d5e2a0f7af77d8"
40
40
  }
@@ -1,6 +1,13 @@
1
- import { resolveBlePacketCapacity } from '../ble-packet-capacity';
1
+ import { resolveBlePacketCapacity, resolveNobleAttMtu } from '../ble-packet-capacity';
2
2
 
3
3
  describe('resolveBlePacketCapacity', () => {
4
+ test('normalizes Noble platform reports to ATT MTU', () => {
5
+ expect(resolveNobleAttMtu(244, 'darwin')).toBe(247);
6
+ expect(resolveNobleAttMtu(244, 'win32')).toBe(247);
7
+ expect(resolveNobleAttMtu(247, 'linux')).toBe(247);
8
+ expect(resolveNobleAttMtu(null, 'darwin')).toBeUndefined();
9
+ });
10
+
4
11
  test('uses ATT MTU payload capacity with an upper bound', () => {
5
12
  expect(resolveBlePacketCapacity(247, 244, 192)).toBe(244);
6
13
  expect(resolveBlePacketCapacity(185, 244, 192)).toBe(182);
@@ -1,5 +1,19 @@
1
1
  const BLE_ATT_HEADER_BYTES = 3;
2
2
 
3
+ export function resolveNobleAttMtu(
4
+ reportedMtu: number | null | undefined,
5
+ platform: NodeJS.Platform = process.platform
6
+ ): number | undefined {
7
+ if (typeof reportedMtu !== 'number' || !Number.isFinite(reportedMtu) || reportedMtu <= 0) {
8
+ return undefined;
9
+ }
10
+
11
+ const normalizedMtu = Math.floor(reportedMtu);
12
+ return platform === 'darwin' || platform === 'win32'
13
+ ? normalizedMtu + BLE_ATT_HEADER_BYTES
14
+ : normalizedMtu;
15
+ }
16
+
3
17
  export function resolveBlePacketCapacity(
4
18
  mtu: number | null | undefined,
5
19
  maximumPacketCapacity: number,
@@ -16,12 +16,13 @@ import {
16
16
  createKnownBleUuidAliases,
17
17
  hasOnekeyCommunicationService,
18
18
  isOnekeyBluetoothDevice,
19
+ isPro2FamilyBleName,
19
20
  matchesKnownBleUuid,
20
21
  wait,
21
22
  } from '@onekeyfe/hd-shared';
22
23
  import pRetry from 'p-retry';
23
24
 
24
- import { resolveBlePacketCapacity } from './ble-packet-capacity';
25
+ import { resolveBlePacketCapacity, resolveNobleAttMtu } from './ble-packet-capacity';
25
26
  import { safeLog } from './types/noble-extended';
26
27
  import { runBleCallbackOperation, softRefreshSubscription } from './ble-ops';
27
28
  import {
@@ -90,8 +91,18 @@ const DEVICE_SCAN_TIMEOUT = 5000; // 5 seconds for device scanning
90
91
  const DEVICE_CHECK_INTERVAL = 500; // 500ms interval for periodic device checks
91
92
  const SERVICE_DISCOVERY_TIMEOUT = 10000; // 10 seconds for service discovery
92
93
  const BLE_CLEANUP_TIMEOUT = 250;
94
+ // A physical teardown must actually reach the OS before the device can return
95
+ // to a clean state; 250ms routinely declared success while CoreBluetooth was
96
+ // still disconnecting, leaving no trace when the teardown never completed.
97
+ const BLE_DISCONNECT_CONFIRM_TIMEOUT_MS = 3000;
93
98
  // Renderer release is logical only; this timer physically frees the device.
94
- const BLE_IDLE_DISCONNECT_MS = 3 * 60_000;
99
+ // Keep it SHORT: the Classic 1S goes protocol-deaf when a link is dropped
100
+ // after sitting idle for minutes (field logs 2026-08-19: every deaf window
101
+ // followed a 3-minute-idle disconnect, while disconnects right after traffic
102
+ // have never produced one across 6.5.0's per-call teardown history). A hot
103
+ // disconnect ~20s after the last operation stays inside the proven-safe
104
+ // pattern and also shrinks the window in which phones cannot see the device.
105
+ const BLE_IDLE_DISCONNECT_MS = 20_000;
95
106
  // Ceiling while a call is in flight: no outstanding write, but not forever.
96
107
  const BLE_BUSY_BACKSTOP_MS = 10 * 60_000;
97
108
 
@@ -257,6 +268,11 @@ async function initializeNoble(): Promise<void> {
257
268
  noble = require('@stoprocent/noble') as NobleModule;
258
269
  logger?.info('[NobleBLE] Noble library loaded');
259
270
 
271
+ // Register the process-lifetime state listener before any early return:
272
+ // the poweredOn fast path below would otherwise skip it for the whole
273
+ // session, leaving poweredOff cache/state reconciliation dead.
274
+ setupPersistentStateListener();
275
+
260
276
  // Wait for Bluetooth to be ready
261
277
  await new Promise<void>((resolve, reject) => {
262
278
  if (!noble) {
@@ -269,9 +285,6 @@ async function initializeNoble(): Promise<void> {
269
285
  return;
270
286
  }
271
287
 
272
- // Setup persistent state listener before initialization
273
- setupPersistentStateListener();
274
-
275
288
  const timeout = setTimeout(() => {
276
289
  reject(
277
290
  ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Bluetooth initialization timeout')
@@ -398,7 +411,21 @@ function armIdleDisconnect(
398
411
  logger?.info('[NobleBLE] Keep-alive timeout, disconnecting device', { deviceId, reason });
399
412
  const peripheral = connectedDevices.get(deviceId);
400
413
  const deviceName = peripheral?.advertisement?.localName || 'Unknown Device';
401
- const pending = disconnectDevice(deviceId)
414
+ // Unsubscribe CCCD before dropping the link, matching every other
415
+ // teardown path: the 1S leaves its notify session half-open when the
416
+ // link drops without an unsubscribe and then ignores application
417
+ // protocol traffic on NEW links for tens of minutes (field log
418
+ // 2026-08-19: the lone unsubscribe-skipping idle teardown caused a
419
+ // 6-attempt reconnect loop; unsubscribe-first teardowns reconnected
420
+ // instantly).
421
+ const pending = unsubscribeNotifications(deviceId)
422
+ .catch(error => {
423
+ logger?.warn('[NobleBLE] Keep-alive unsubscribe failed, disconnecting anyway', {
424
+ deviceId,
425
+ error: error instanceof Error ? error.message : String(error),
426
+ });
427
+ })
428
+ .then(() => disconnectDevice(deviceId))
402
429
  .then(() => {
403
430
  // A call is still in flight here; it must reject, not hang.
404
431
  // Both 'idle' and 'busy-backstop' report IdleKeepAlive on purpose:
@@ -508,6 +535,9 @@ function handleDeviceDisconnect(deviceId: string, webContents: WebContents): voi
508
535
 
509
536
  cleanupDevice(deviceId, webContents, {
510
537
  cleanupConnection: true,
538
+ // Same stale-object hazard as manual disconnect: an externally dropped
539
+ // link invalidates the cached peripheral for the next reconnect.
540
+ cleanupDiscoveredCache: true,
511
541
  sendDisconnectEvent: true,
512
542
  cancelOperations: true,
513
543
  reason: 'auto-disconnect',
@@ -544,12 +574,13 @@ function setupMtuListener(
544
574
  }
545
575
 
546
576
  const listener = (mtu: number) => {
547
- if (!Number.isFinite(mtu) || mtu <= 0) return;
577
+ const normalizedMtu = resolveNobleAttMtu(mtu);
578
+ if (normalizedMtu === undefined) return;
548
579
  // A kept-alive link can outlive the renderer this closure captured.
549
580
  if (webContents.isDestroyed()) return;
550
581
  webContents.send(EOneKeyBleMessageKeys.NOBLE_BLE_MTU_CHANGED, {
551
582
  id: deviceId,
552
- mtu,
583
+ mtu: normalizedMtu,
553
584
  });
554
585
  };
555
586
  deviceMtuListeners.set(deviceId, { peripheral, listener });
@@ -697,7 +728,7 @@ async function transmitHexDataToDevice(
697
728
  const toBuffer = Buffer.from(hexData, 'hex');
698
729
  const doGetWriteCharacteristic = () => deviceCharacteristics.get(deviceId)?.write;
699
730
  const packetCapacity = resolveBlePacketCapacity(
700
- peripheral.mtu,
731
+ resolveNobleAttMtu(peripheral.mtu),
701
732
  BLE_PACKET_SIZE_MAXIMUM,
702
733
  BLE_PACKET_SIZE_FALLBACK
703
734
  );
@@ -761,6 +792,44 @@ async function transmitHexDataToDevice(
761
792
  }
762
793
 
763
794
  // Handle discovered device (for general enumeration only)
795
+ // A scan that finds nothing costs this much before the direct-connect
796
+ // fallback runs. Every advertisement in the 6.5.0 control logs arrived within
797
+ // 631ms, so this leaves ~2x headroom while keeping a miss cheap.
798
+ const BLE_COLD_CONNECT_SCAN_TIMEOUT_MS = 1500;
799
+
800
+ // Attempts before a link is written off as unable to serve the OneKey service.
801
+ const SERVICE_DISCOVERY_MAX_ATTEMPTS = 2;
802
+
803
+ // Advertised names, kept for the life of the process: device caches are purged
804
+ // on every disconnect, but the family a device belongs to never changes and
805
+ // decides which connection strategy is safe for it.
806
+ const bleNamesById = new Map<string, string>();
807
+
808
+ function rememberBleName(deviceId: string, name?: string | null): void {
809
+ const trimmed = name?.trim();
810
+ if (trimmed) {
811
+ bleNamesById.set(deviceId, trimmed);
812
+ }
813
+ }
814
+
815
+ function resolveBleName(deviceId: string): string | undefined {
816
+ const live =
817
+ connectedDevices.get(deviceId)?.advertisement?.localName?.trim() ||
818
+ discoveredDevices.get(deviceId)?.advertisement?.localName?.trim();
819
+ return live || bleNamesById.get(deviceId);
820
+ }
821
+
822
+ /**
823
+ * Pro2/Neo connect by id first: they are unaffected by the stale-session
824
+ * problem and advertise under Find My names a OneKey-filtered scan cannot
825
+ * match. Everything else — Classic family, and any device whose name is not
826
+ * known yet — scans first, because connecting by id can resolve a session the
827
+ * device no longer serves (see setupConnectionAndDiscoverServices).
828
+ */
829
+ function shouldConnectByIdFirst(deviceId: string): boolean {
830
+ return isPro2FamilyBleName(resolveBleName(deviceId));
831
+ }
832
+
764
833
  function handleDeviceDiscovered(peripheral: Peripheral): void {
765
834
  // Only process OneKey candidates for general discovery. Avoid logging every
766
835
  // ambient BLE peripheral; it makes Pro2 debugging hard to read.
@@ -770,6 +839,7 @@ function handleDeviceDiscovered(peripheral: Peripheral): void {
770
839
 
771
840
  const isNewDevice = !discoveredDevices.has(peripheral.id);
772
841
  discoveredDevices.set(peripheral.id, peripheral);
842
+ rememberBleName(peripheral.id, peripheral.advertisement?.localName);
773
843
  if (isNewDevice) {
774
844
  logger?.debug('[NobleBLE] OneKey BLE device discovered', {
775
845
  deviceId: peripheral.id,
@@ -804,7 +874,10 @@ async function waitForNobleScanStop(nobleInstance: NobleModule): Promise<void> {
804
874
 
805
875
  // Perform targeted scan for a specific device ID
806
876
  // Uses self-contained local listener pattern - no global state needed
807
- async function performTargetedScan(targetDeviceId: string): Promise<Peripheral | null> {
877
+ async function performTargetedScan(
878
+ targetDeviceId: string,
879
+ timeoutMs: number = NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS
880
+ ): Promise<Peripheral | null> {
808
881
  if (!noble) {
809
882
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
810
883
  }
@@ -849,7 +922,7 @@ async function performTargetedScan(targetDeviceId: string): Promise<Peripheral |
849
922
  const timeoutId = setTimeout(() => {
850
923
  logger?.info('[NobleBLE] Targeted scan timeout for device:', targetDeviceId);
851
924
  finish(null).catch(reject);
852
- }, NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS);
925
+ }, timeoutMs);
853
926
 
854
927
  // Add local listener for this scan
855
928
  nobleInstance.on('discover', onDiscover);
@@ -975,12 +1048,13 @@ function getDevice(deviceId: string): DeviceInfo | null {
975
1048
  const peripheral = discoveredDevices.get(deviceId);
976
1049
  if (peripheral) {
977
1050
  const deviceName = peripheral.advertisement?.localName || 'Unknown Device';
1051
+ const mtu = resolveNobleAttMtu(peripheral.mtu);
978
1052
  return {
979
1053
  commType: 'electron-ble',
980
1054
  id: peripheral.id,
981
1055
  name: deviceName,
982
1056
  state: peripheral.state || 'disconnected',
983
- ...(typeof peripheral.mtu === 'number' ? { mtu: peripheral.mtu } : {}),
1057
+ ...(mtu === undefined ? {} : { mtu }),
984
1058
  };
985
1059
  }
986
1060
 
@@ -988,12 +1062,13 @@ function getDevice(deviceId: string): DeviceInfo | null {
988
1062
  const connectedPeripheral = connectedDevices.get(deviceId);
989
1063
  if (connectedPeripheral) {
990
1064
  const deviceName = connectedPeripheral.advertisement?.localName || 'Unknown Device';
1065
+ const mtu = resolveNobleAttMtu(connectedPeripheral.mtu);
991
1066
  return {
992
1067
  commType: 'electron-ble',
993
1068
  id: connectedPeripheral.id,
994
1069
  name: deviceName,
995
1070
  state: connectedPeripheral.state || 'connected',
996
- ...(typeof connectedPeripheral.mtu === 'number' ? { mtu: connectedPeripheral.mtu } : {}),
1071
+ ...(mtu === undefined ? {} : { mtu }),
997
1072
  };
998
1073
  }
999
1074
 
@@ -1051,9 +1126,17 @@ async function discoverServicesAndCharacteristics(
1051
1126
 
1052
1127
  // Main discovery logic as async function
1053
1128
  const discoveryPromise = (async (): Promise<CharacteristicPair> => {
1054
- // Step 1: Discover ALL services (no filter Pro2 may use different service UUID)
1129
+ // Step 1: Discover the OneKey service by UUID. Filtering is not just a
1130
+ // narrowing: an unfiltered discovery is answered from the OS GATT cache, so
1131
+ // a device whose stack still advertises and accepts links but no longer
1132
+ // serves its application layer still looks healthy — the link comes up, the
1133
+ // cached services resolve, the protocol frame goes out and nothing ever
1134
+ // answers. A targeted query returns nothing in that state, which is what
1135
+ // 6.5.0 relied on to trip the recovery path below (retry, reset, fresh
1136
+ // scan) — the sequence that brings such a device back. Pro2/Neo expose the
1137
+ // same service UUID, and the selection below only ever accepts that one.
1055
1138
  const services = await new Promise<Service[]>((resolve, reject) => {
1056
- peripheral.discoverServices([], (error, svc) => {
1139
+ peripheral.discoverServices(ONEKEY_SERVICE_UUIDS, (error, svc) => {
1057
1140
  if (error) {
1058
1141
  logger?.error('[NobleBLE] Service discovery failed:', error);
1059
1142
  reject(ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, error.message));
@@ -1064,7 +1147,7 @@ async function discoverServicesAndCharacteristics(
1064
1147
  });
1065
1148
 
1066
1149
  if (!services || services.length === 0) {
1067
- throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No services found');
1150
+ throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
1068
1151
  }
1069
1152
 
1070
1153
  logger?.debug('[NobleBLE] services discovered', {
@@ -1248,12 +1331,14 @@ async function discoverServicesAndCharacteristicsWithRetry(
1248
1331
  deviceId,
1249
1332
  peripheralState: peripheral.state,
1250
1333
  attempt: attemptNumber,
1251
- maxRetries: 5,
1334
+ maxRetries: SERVICE_DISCOVERY_MAX_ATTEMPTS,
1252
1335
  targetUUIDs: ONEKEY_SERVICE_UUIDS,
1253
1336
  });
1254
1337
 
1255
1338
  if (attemptNumber > 1) {
1256
- logger?.info(`[NobleBLE] Service discovery retry attempt ${attemptNumber}/5`);
1339
+ logger?.info(
1340
+ `[NobleBLE] Service discovery retry attempt ${attemptNumber}/${SERVICE_DISCOVERY_MAX_ATTEMPTS}`
1341
+ );
1257
1342
  }
1258
1343
 
1259
1344
  // Verify connection state before attempting service discovery
@@ -1267,20 +1352,32 @@ async function discoverServicesAndCharacteristicsWithRetry(
1267
1352
  try {
1268
1353
  return await discoverServicesAndCharacteristics(peripheral);
1269
1354
  } catch (error) {
1270
- logger?.error(`[NobleBLE] No services found (attempt ${attemptNumber}/5)`);
1355
+ logger?.error(
1356
+ `[NobleBLE] No services found (attempt ${attemptNumber}/${SERVICE_DISCOVERY_MAX_ATTEMPTS})`
1357
+ );
1271
1358
 
1272
- if (attemptNumber < 5) {
1273
- logger?.error(`[NobleBLE] Will retry service discovery (attempt ${attemptNumber + 1}/5)`);
1359
+ if (attemptNumber < SERVICE_DISCOVERY_MAX_ATTEMPTS) {
1360
+ logger?.error(
1361
+ `[NobleBLE] Will retry service discovery (attempt ${
1362
+ attemptNumber + 1
1363
+ }/${SERVICE_DISCOVERY_MAX_ATTEMPTS})`
1364
+ );
1274
1365
  }
1275
1366
 
1276
1367
  throw error; // p-retry will handle the retry logic
1277
1368
  }
1278
1369
  },
1279
1370
  {
1280
- retries: 4, // Total 5 attempts (initial + 4 retries)
1281
- factor: 1.5, // Exponential backoff: 1000ms 1500ms 2250ms 3000ms
1282
- minTimeout: 1000, // Start with 1 second delay
1283
- maxTimeout: 3000, // Maximum 3 seconds delay
1371
+ // One retry, not four. An empty result here is the signature of a device
1372
+ // whose stack no longer serves its application layer, and retrying the
1373
+ // same link has never recovered it — every observed run failed all the
1374
+ // way through. The recovery is the teardown and cold reconnect the caller
1375
+ // performs afterwards, so reaching it sooner is what shortens the wait.
1376
+ // The single retry stays for a genuinely transient miss.
1377
+ retries: SERVICE_DISCOVERY_MAX_ATTEMPTS - 1,
1378
+ factor: 1.5,
1379
+ minTimeout: 500,
1380
+ maxTimeout: 3000,
1284
1381
  onFailedAttempt: error => {
1285
1382
  // This runs after each failed attempt
1286
1383
  logger?.error(`[NobleBLE] Service discovery attempt ${error.attemptNumber} failed:`, {
@@ -1305,26 +1402,45 @@ async function setupConnectionAndDiscoverServices(
1305
1402
  deviceId: string,
1306
1403
  webContents: WebContents
1307
1404
  ): Promise<CharacteristicPair> {
1308
- setupDisconnectListener(peripheral, deviceId, webContents);
1309
-
1405
+ // Reset the link before any protocol traffic reaches the device. 6.5.0 did
1406
+ // this on every cold setup and never produced a protocol-deaf Classic 1S
1407
+ // (control log 2026-08-20: 16/16 links answered within ~1s, including after
1408
+ // a 15-minute idle). Demoting it to a discovery-failure fallback removed the
1409
+ // reset entirely for this device, because discovery always succeeds: the link
1410
+ // comes up, GATT resolves from cache, and the device then answers nothing on
1411
+ // a session it no longer serves. Costs ~1.3s per cold setup; a kept-alive
1412
+ // link with an intact subscription returns before reaching here, so reuse
1413
+ // inside a workflow is unaffected.
1310
1414
  try {
1311
- return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1312
- } catch (initialError) {
1313
- logger?.info('[NobleBLE] Direct service discovery failed, retrying after reconnect', {
1314
- deviceId,
1315
- error: initialError,
1316
- });
1415
+ await forceReconnectPeripheral(peripheral, deviceId);
1416
+ } catch (resetError) {
1417
+ // A failed reset must not abort the attempt: discovery on the existing
1418
+ // link, then the fresh-scan fallback, still have a chance to recover.
1419
+ logger?.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
1317
1420
  }
1318
-
1319
- await forceReconnectPeripheral(peripheral, deviceId);
1320
1421
  setupDisconnectListener(peripheral, deviceId, webContents);
1422
+
1321
1423
  try {
1322
1424
  return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1323
- } catch (reconnectError) {
1324
- logger?.error(
1325
- '[NobleBLE] Service discovery failed after reconnect, attempting fresh scan...',
1326
- reconnectError
1327
- );
1425
+ } catch (discoveryError) {
1426
+ // A connected peripheral does not advertise, so the fresh scan below would
1427
+ // only run out its timeout. Drop the link first, then rescan: the cold
1428
+ // reconnect is what recovers a device whose stack stopped serving its
1429
+ // application layer. Recover here rather than throwing to the caller —
1430
+ // one acquire pays ~2s for the rescan, while a thrown error costs the
1431
+ // renderer a full retry round trip.
1432
+ if (peripheral.state === 'connected') {
1433
+ logger?.error(
1434
+ '[NobleBLE] Service discovery failed on a live link, dropping it before the fresh scan',
1435
+ discoveryError
1436
+ );
1437
+ await disconnectDevice(deviceId).catch(() => undefined);
1438
+ } else {
1439
+ logger?.error(
1440
+ '[NobleBLE] Service discovery failed, attempting fresh scan...',
1441
+ discoveryError
1442
+ );
1443
+ }
1328
1444
  return freshScanAndDiscover(deviceId, webContents);
1329
1445
  }
1330
1446
  }
@@ -1413,10 +1529,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1413
1529
  // enumerate clears the discovery map; a kept-alive link outlives it.
1414
1530
  let peripheral = discoveredDevices.get(deviceId) ?? connectedDevices.get(deviceId);
1415
1531
 
1416
- // If device not discovered, try a targeted scan for this specific device
1417
1532
  if (!peripheral) {
1418
- logger?.info('[NobleBLE] Device not discovered, attempting targeted scan for:', deviceId);
1419
-
1420
1533
  // Initialize Noble if not already done
1421
1534
  if (!noble) {
1422
1535
  await initializeNoble();
@@ -1426,27 +1539,46 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1426
1539
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
1427
1540
  }
1428
1541
 
1429
- // Reaches a bonded device that is not advertising; else falls to the scan.
1430
- peripheral = await tryDirectConnectById(deviceId);
1431
- if (peripheral) {
1432
- discoveredDevices.set(deviceId, peripheral);
1433
- }
1434
- }
1542
+ // A live advertisement proves the device holds no link and will open a
1543
+ // fresh session; connecting by id resolves through the OS cache instead
1544
+ // and can hand back a session the device no longer serves (see
1545
+ // setupConnectionAndDiscoverServices). Scanning is also the faster of the
1546
+ // two when the device does advertise: ~96ms median against ~784ms for
1547
+ // connect-by-id across the field logs. Pro2/Neo keep connect-by-id first
1548
+ // because they are unaffected and may advertise under an unmatchable Find
1549
+ // My name.
1550
+ const byIdFirst = shouldConnectByIdFirst(deviceId);
1551
+ logger?.info('[NobleBLE] Resolving device for cold connect', {
1552
+ deviceId,
1553
+ name: resolveBleName(deviceId) ?? 'unknown',
1554
+ strategy: byIdFirst ? 'connect-by-id-first' : 'scan-first',
1555
+ });
1435
1556
 
1436
- if (!peripheral) {
1437
- // Perform a targeted scan to find the specific device
1438
- try {
1439
- const foundPeripheral = await performTargetedScan(deviceId);
1440
- if (!foundPeripheral) {
1441
- throw ERRORS.TypedError(
1442
- HardwareErrorCode.DeviceNotFound,
1443
- `Device ${deviceId} not found even after targeted scan`
1444
- );
1557
+ const scanForPeripheral = async () => {
1558
+ try {
1559
+ return (await performTargetedScan(deviceId, BLE_COLD_CONNECT_SCAN_TIMEOUT_MS)) ?? undefined;
1560
+ } catch (scanError) {
1561
+ logger?.info('[NobleBLE] Targeted scan failed', {
1562
+ deviceId,
1563
+ error: scanError instanceof Error ? scanError.message : String(scanError),
1564
+ });
1565
+ return undefined;
1566
+ }
1567
+ };
1568
+
1569
+ const connectById = async () => {
1570
+ const found = await tryDirectConnectById(deviceId);
1571
+ if (found) {
1572
+ discoveredDevices.set(deviceId, found);
1445
1573
  }
1446
- peripheral = foundPeripheral;
1447
- } catch (error) {
1448
- logger?.error('[NobleBLE] Targeted scan failed:', error);
1449
- throw error;
1574
+ return found;
1575
+ };
1576
+
1577
+ peripheral = byIdFirst ? await connectById() : await scanForPeripheral();
1578
+ if (!peripheral) {
1579
+ // The preferred route came up empty: not advertising (held elsewhere, or
1580
+ // silent), or not reachable by id. Try the other one before giving up.
1581
+ peripheral = byIdFirst ? await scanForPeripheral() : await connectById();
1450
1582
  }
1451
1583
  }
1452
1584
 
@@ -1455,6 +1587,10 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1455
1587
  throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found`);
1456
1588
  }
1457
1589
 
1590
+ // Also covers the connect-by-id route, whose simulated discovery carries no
1591
+ // service UUIDs and therefore never reaches handleDeviceDiscovered.
1592
+ rememberBleName(deviceId, peripheral.advertisement?.localName);
1593
+
1458
1594
  logger?.info('[NobleBLE] Connecting to device:', deviceId);
1459
1595
 
1460
1596
  // Check if device is already connected
@@ -1575,9 +1711,14 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1575
1711
  resolve();
1576
1712
  } catch (setupError) {
1577
1713
  logger?.error('[NobleBLE] Connection setup failed:', setupError);
1578
- connectedPeripheral.disconnect(() => {
1579
- reject(setupError);
1580
- });
1714
+ // Never reject from inside a raw disconnect callback: noble only fires
1715
+ // it on a real 'disconnect' event, so a peripheral that is already
1716
+ // down leaves this promise — and the renderer acquire awaiting it —
1717
+ // pending forever. disconnectDevice always settles (it no-ops on an
1718
+ // unknown peripheral and caps the confirm wait).
1719
+ disconnectDevice(deviceId)
1720
+ .catch(() => undefined)
1721
+ .then(() => reject(setupError));
1581
1722
  }
1582
1723
  });
1583
1724
  });
@@ -1597,11 +1738,18 @@ async function disconnectDevice(deviceId: string): Promise<void> {
1597
1738
  }
1598
1739
 
1599
1740
  await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1600
- timeoutMs: BLE_CLEANUP_TIMEOUT,
1741
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
1601
1742
  timeoutBehavior: 'resolve',
1602
1743
  });
1744
+ if (peripheral.state === 'connected') {
1745
+ logger?.warn('[NobleBLE] Physical disconnect did not confirm in time', { deviceId });
1746
+ }
1603
1747
  cleanupDevice(deviceId, undefined, {
1604
1748
  cleanupConnection: true,
1749
+ // macOS returns zero GATT services when a previously-disconnected
1750
+ // peripheral object is reconnected; drop the discovery cache so the next
1751
+ // connect resolves a fresh peripheral (direct connect by id, or scan).
1752
+ cleanupDiscoveredCache: true,
1605
1753
  sendDisconnectEvent: false,
1606
1754
  cancelOperations: true,
1607
1755
  reason: 'manual-disconnect',
@@ -1742,9 +1890,14 @@ async function subscribeNotifications(
1742
1890
  });
1743
1891
  }
1744
1892
 
1893
+ const subscribeStartedAt = Date.now();
1745
1894
  try {
1746
1895
  await rebuildAppSubscription(deviceId, notifyCharacteristic);
1747
1896
  subscribedDevices.set(deviceId, true);
1897
+ logger?.info('[NobleBLE] Notification subscription active', {
1898
+ deviceId,
1899
+ ms: Date.now() - subscribeStartedAt,
1900
+ });
1748
1901
  } finally {
1749
1902
  // 🔒 CRITICAL: Always clear operation state (even on error)
1750
1903
  subscriptionOperations.set(deviceId, 'idle');
@@ -1927,10 +2080,12 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1927
2080
  }
1928
2081
 
1929
2082
  await stopScanning().catch(() => undefined);
1930
- if (noble && persistentStateListener) {
1931
- noble.removeListener('stateChange', persistentStateListener);
1932
- persistentStateListener = null;
1933
- }
2083
+ // persistentStateListener is process-lifetime, NOT per-window: this
2084
+ // destroy handler also fires on a renderer soft restart, and removing
2085
+ // the listener here permanently killed poweredOff/poweredOn
2086
+ // reconciliation for the rest of the process (nothing re-registers it:
2087
+ // initializeNoble early-returns once noble is loaded). The null-guard
2088
+ // in setupPersistentStateListener keeps it deduped to one instance.
1934
2089
  cleanupNobleListeners();
1935
2090
  discoveredDevices.clear();
1936
2091
  safeLog(logger, 'info', 'Noble BLE cleanup completed');
@@ -39,6 +39,7 @@ export interface NobleModule {
39
39
  export interface Logger {
40
40
  info(message: string, ...args: any[]): void;
41
41
  debug(message: string, ...args: any[]): void;
42
+ warn(message: string, ...args: any[]): void;
42
43
  error(message: string, ...args: any[]): void;
43
44
  }
44
45