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

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-e4eaea92.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-eea100c1.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-eea100c1.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;
@@ -91,6 +100,19 @@ const NOBLE_BLE_CONNECTION_TIMEOUT_MS = 10000;
91
100
 
92
101
  let noble = null;
93
102
  let logger = null;
103
+ function createNobleBleConnectionError(error, messagePrefix = '') {
104
+ const errorMessage = error.message;
105
+ const normalizedErrorMessage = errorMessage.toLowerCase();
106
+ let errorCode = hdShared.HardwareErrorCode.BleConnectedError;
107
+ if (hdShared.isBleStaleBondErrorText(errorMessage)) {
108
+ errorCode =
109
+ normalizedErrorMessage.includes('peer removed pairing information') ||
110
+ normalizedErrorMessage.includes('cberrordomain:14')
111
+ ? hdShared.HardwareErrorCode.BlePeerRemovedPairingInformation
112
+ : hdShared.HardwareErrorCode.BleDeviceBondError;
113
+ }
114
+ return hdShared.ERRORS.TypedError(errorCode, `${messagePrefix}${errorMessage}`);
115
+ }
94
116
  const bluetoothState = {
95
117
  available: false,
96
118
  unsupported: false,
@@ -116,7 +138,8 @@ const DEVICE_SCAN_TIMEOUT = 5000;
116
138
  const DEVICE_CHECK_INTERVAL = 500;
117
139
  const SERVICE_DISCOVERY_TIMEOUT = 10000;
118
140
  const BLE_CLEANUP_TIMEOUT = 250;
119
- const BLE_IDLE_DISCONNECT_MS = 3 * 60000;
141
+ const BLE_DISCONNECT_CONFIRM_TIMEOUT_MS = 3000;
142
+ const BLE_IDLE_DISCONNECT_MS = 20000;
120
143
  const BLE_BUSY_BACKSTOP_MS = 10 * 60000;
121
144
  const BLE_PACKET_SIZE_FALLBACK = 192;
122
145
  const BLE_PACKET_SIZE_MAXIMUM = 244;
@@ -242,6 +265,7 @@ function initializeNoble() {
242
265
  try {
243
266
  noble = require('@stoprocent/noble');
244
267
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Noble library loaded');
268
+ setupPersistentStateListener();
245
269
  yield new Promise((resolve, reject) => {
246
270
  if (!noble) {
247
271
  reject(hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'Noble not initialized'));
@@ -251,7 +275,6 @@ function initializeNoble() {
251
275
  resolve();
252
276
  return;
253
277
  }
254
- setupPersistentStateListener();
255
278
  const timeout = setTimeout(() => {
256
279
  reject(hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'Bluetooth initialization timeout'));
257
280
  }, BLUETOOTH_INIT_TIMEOUT);
@@ -336,7 +359,14 @@ function armIdleDisconnect(deviceId, ms = BLE_IDLE_DISCONNECT_MS, reason = 'idle
336
359
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Keep-alive timeout, disconnecting device', { deviceId, reason });
337
360
  const peripheral = connectedDevices.get(deviceId);
338
361
  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)
362
+ const pending = unsubscribeNotifications(deviceId)
363
+ .catch(error => {
364
+ logger === null || logger === void 0 ? void 0 : logger.warn('[NobleBLE] Keep-alive unsubscribe failed, disconnecting anyway', {
365
+ deviceId,
366
+ error: error instanceof Error ? error.message : String(error),
367
+ });
368
+ })
369
+ .then(() => disconnectDevice(deviceId))
340
370
  .then(() => {
341
371
  broadcastToAllWebContents(hdShared.EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
342
372
  id: deviceId,
@@ -409,6 +439,7 @@ function handleDeviceDisconnect(deviceId, webContents) {
409
439
  });
410
440
  cleanupDevice(deviceId, webContents, {
411
441
  cleanupConnection: true,
442
+ cleanupDiscoveredCache: true,
412
443
  sendDisconnectEvent: true,
413
444
  cancelOperations: true,
414
445
  reason: 'auto-disconnect',
@@ -432,13 +463,14 @@ function setupMtuListener(peripheral, deviceId, webContents) {
432
463
  existing.peripheral.removeListener('mtu', existing.listener);
433
464
  }
434
465
  const listener = (mtu) => {
435
- if (!Number.isFinite(mtu) || mtu <= 0)
466
+ const normalizedMtu = resolveNobleAttMtu(mtu);
467
+ if (normalizedMtu === undefined)
436
468
  return;
437
469
  if (webContents.isDestroyed())
438
470
  return;
439
471
  webContents.send(hdShared.EOneKeyBleMessageKeys.NOBLE_BLE_MTU_CHANGED, {
440
472
  id: deviceId,
441
- mtu,
473
+ mtu: normalizedMtu,
442
474
  });
443
475
  };
444
476
  deviceMtuListeners.set(deviceId, { peripheral, listener });
@@ -545,7 +577,7 @@ function transmitHexDataToDevice(deviceId, hexData, options) {
545
577
  armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
546
578
  const toBuffer = Buffer.from(hexData, 'hex');
547
579
  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);
580
+ const packetCapacity = resolveBlePacketCapacity(resolveNobleAttMtu(peripheral.mtu), BLE_PACKET_SIZE_MAXIMUM, BLE_PACKET_SIZE_FALLBACK);
549
581
  const pacingDelayMs = resolveNobleBleWritePacingDelay(options);
550
582
  if (!IS_WINDOWS || pairedDevices.has(deviceId)) {
551
583
  const writeCharacteristic = doGetWriteCharacteristic();
@@ -590,18 +622,37 @@ function transmitHexDataToDevice(deviceId, hexData, options) {
590
622
  }
591
623
  });
592
624
  }
625
+ const BLE_COLD_CONNECT_SCAN_TIMEOUT_MS = 1500;
626
+ const SERVICE_DISCOVERY_MAX_ATTEMPTS = 2;
627
+ const bleNamesById = new Map();
628
+ function rememberBleName(deviceId, name) {
629
+ const trimmed = name === null || name === void 0 ? void 0 : name.trim();
630
+ if (trimmed) {
631
+ bleNamesById.set(deviceId, trimmed);
632
+ }
633
+ }
634
+ function resolveBleName(deviceId) {
635
+ var _a, _b, _c, _d, _e, _f;
636
+ 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()) ||
637
+ ((_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());
638
+ return live || bleNamesById.get(deviceId);
639
+ }
640
+ function shouldConnectByIdFirst(deviceId) {
641
+ return hdShared.isPro2FamilyBleName(resolveBleName(deviceId));
642
+ }
593
643
  function handleDeviceDiscovered(peripheral) {
594
- var _a, _b;
644
+ var _a, _b, _c;
595
645
  if (!isOneKeyPeripheral(peripheral)) {
596
646
  return;
597
647
  }
598
648
  const isNewDevice = !discoveredDevices.has(peripheral.id);
599
649
  discoveredDevices.set(peripheral.id, peripheral);
650
+ rememberBleName(peripheral.id, (_a = peripheral.advertisement) === null || _a === void 0 ? void 0 : _a.localName);
600
651
  if (isNewDevice) {
601
652
  logger === null || logger === void 0 ? void 0 : logger.debug('[NobleBLE] OneKey BLE device discovered', {
602
653
  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) || [],
654
+ name: ((_b = peripheral.advertisement) === null || _b === void 0 ? void 0 : _b.localName) || 'Unknown Device',
655
+ serviceUUIDs: ((_c = peripheral.advertisement) === null || _c === void 0 ? void 0 : _c.serviceUuids) || [],
605
656
  });
606
657
  }
607
658
  }
@@ -627,7 +678,7 @@ function waitForNobleScanStop(nobleInstance) {
627
678
  });
628
679
  });
629
680
  }
630
- function performTargetedScan(targetDeviceId) {
681
+ function performTargetedScan(targetDeviceId, timeoutMs = NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS) {
631
682
  return index.__awaiter(this, void 0, void 0, function* () {
632
683
  if (!noble) {
633
684
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'Noble not available');
@@ -667,7 +718,7 @@ function performTargetedScan(targetDeviceId) {
667
718
  const timeoutId = setTimeout(() => {
668
719
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Targeted scan timeout for device:', targetDeviceId);
669
720
  finish(null).catch(reject);
670
- }, NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS);
721
+ }, timeoutMs);
671
722
  nobleInstance.on('discover', onDiscover);
672
723
  nobleInstance.startScanning([], true, (error) => {
673
724
  if (error) {
@@ -763,12 +814,14 @@ function getDevice(deviceId) {
763
814
  const peripheral = discoveredDevices.get(deviceId);
764
815
  if (peripheral) {
765
816
  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 } : {}));
817
+ const mtu = resolveNobleAttMtu(peripheral.mtu);
818
+ return Object.assign({ commType: 'electron-ble', id: peripheral.id, name: deviceName, state: peripheral.state || 'disconnected' }, (mtu === undefined ? {} : { mtu }));
767
819
  }
768
820
  const connectedPeripheral = connectedDevices.get(deviceId);
769
821
  if (connectedPeripheral) {
770
822
  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 } : {}));
823
+ const mtu = resolveNobleAttMtu(connectedPeripheral.mtu);
824
+ return Object.assign({ commType: 'electron-ble', id: connectedPeripheral.id, name: deviceName, state: connectedPeripheral.state || 'connected' }, (mtu === undefined ? {} : { mtu }));
772
825
  }
773
826
  return {
774
827
  commType: 'electron-ble',
@@ -802,7 +855,7 @@ function discoverServicesAndCharacteristics(peripheral) {
802
855
  });
803
856
  const discoveryPromise = (() => index.__awaiter(this, void 0, void 0, function* () {
804
857
  const services = yield new Promise((resolve, reject) => {
805
- peripheral.discoverServices([], (error, svc) => {
858
+ peripheral.discoverServices(ONEKEY_SERVICE_UUIDS, (error, svc) => {
806
859
  if (error) {
807
860
  logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed:', error);
808
861
  reject(hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleServiceNotFound, error.message));
@@ -813,7 +866,7 @@ function discoverServicesAndCharacteristics(peripheral) {
813
866
  });
814
867
  });
815
868
  if (!services || services.length === 0) {
816
- throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleServiceNotFound, 'No services found');
869
+ throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
817
870
  }
818
871
  logger === null || logger === void 0 ? void 0 : logger.debug('[NobleBLE] services discovered', {
819
872
  deviceId: peripheral.id,
@@ -909,7 +962,7 @@ function freshScanAndDiscover(deviceId, webContents) {
909
962
  yield new Promise((resolve, reject) => {
910
963
  freshPeripheral.connect((error) => {
911
964
  if (error) {
912
- reject(hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleConnectedError, `Fresh peripheral connection failed: ${error.message}`));
965
+ reject(createNobleBleConnectionError(error, 'Fresh peripheral connection failed: '));
913
966
  }
914
967
  else {
915
968
  connectedDevices.set(deviceId, freshPeripheral);
@@ -930,11 +983,11 @@ function discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId) {
930
983
  deviceId,
931
984
  peripheralState: peripheral.state,
932
985
  attempt: attemptNumber,
933
- maxRetries: 5,
986
+ maxRetries: SERVICE_DISCOVERY_MAX_ATTEMPTS,
934
987
  targetUUIDs: ONEKEY_SERVICE_UUIDS,
935
988
  });
936
989
  if (attemptNumber > 1) {
937
- logger === null || logger === void 0 ? void 0 : logger.info(`[NobleBLE] Service discovery retry attempt ${attemptNumber}/5`);
990
+ logger === null || logger === void 0 ? void 0 : logger.info(`[NobleBLE] Service discovery retry attempt ${attemptNumber}/${SERVICE_DISCOVERY_MAX_ATTEMPTS}`);
938
991
  }
939
992
  if (peripheral.state !== 'connected') {
940
993
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleConnectedError, `Device not connected: ${peripheral.state}`);
@@ -943,16 +996,16 @@ function discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId) {
943
996
  return yield discoverServicesAndCharacteristics(peripheral);
944
997
  }
945
998
  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)`);
999
+ logger === null || logger === void 0 ? void 0 : logger.error(`[NobleBLE] No services found (attempt ${attemptNumber}/${SERVICE_DISCOVERY_MAX_ATTEMPTS})`);
1000
+ if (attemptNumber < SERVICE_DISCOVERY_MAX_ATTEMPTS) {
1001
+ logger === null || logger === void 0 ? void 0 : logger.error(`[NobleBLE] Will retry service discovery (attempt ${attemptNumber + 1}/${SERVICE_DISCOVERY_MAX_ATTEMPTS})`);
949
1002
  }
950
1003
  throw error;
951
1004
  }
952
1005
  }), {
953
- retries: 4,
1006
+ retries: SERVICE_DISCOVERY_MAX_ATTEMPTS - 1,
954
1007
  factor: 1.5,
955
- minTimeout: 1000,
1008
+ minTimeout: 500,
956
1009
  maxTimeout: 3000,
957
1010
  onFailedAttempt: error => {
958
1011
  logger === null || logger === void 0 ? void 0 : logger.error(`[NobleBLE] Service discovery attempt ${error.attemptNumber} failed:`, {
@@ -966,23 +1019,24 @@ function discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId) {
966
1019
  }
967
1020
  function setupConnectionAndDiscoverServices(peripheral, deviceId, webContents) {
968
1021
  return index.__awaiter(this, void 0, void 0, function* () {
969
- setupDisconnectListener(peripheral, deviceId, webContents);
970
1022
  try {
971
- return yield discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1023
+ yield forceReconnectPeripheral(peripheral, deviceId);
972
1024
  }
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
- });
1025
+ catch (resetError) {
1026
+ logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
978
1027
  }
979
- yield forceReconnectPeripheral(peripheral, deviceId);
980
1028
  setupDisconnectListener(peripheral, deviceId, webContents);
981
1029
  try {
982
1030
  return yield discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
983
1031
  }
984
- catch (reconnectError) {
985
- logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed after reconnect, attempting fresh scan...', reconnectError);
1032
+ catch (discoveryError) {
1033
+ if (peripheral.state === 'connected') {
1034
+ logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed on a live link, dropping it before the fresh scan', discoveryError);
1035
+ yield disconnectDevice(deviceId).catch(() => undefined);
1036
+ }
1037
+ else {
1038
+ logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed, attempting fresh scan...', discoveryError);
1039
+ }
986
1040
  return freshScanAndDiscover(deviceId, webContents);
987
1041
  }
988
1042
  });
@@ -1042,7 +1096,7 @@ function tryDirectConnectById(deviceId) {
1042
1096
  });
1043
1097
  }
1044
1098
  function connectDevice(deviceId, webContents) {
1045
- var _a;
1099
+ var _a, _b, _c;
1046
1100
  return index.__awaiter(this, void 0, void 0, function* () {
1047
1101
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Connect device request:', {
1048
1102
  deviceId,
@@ -1054,34 +1108,47 @@ function connectDevice(deviceId, webContents) {
1054
1108
  });
1055
1109
  let peripheral = (_a = discoveredDevices.get(deviceId)) !== null && _a !== void 0 ? _a : connectedDevices.get(deviceId);
1056
1110
  if (!peripheral) {
1057
- logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Device not discovered, attempting targeted scan for:', deviceId);
1058
1111
  if (!noble) {
1059
1112
  yield initializeNoble();
1060
1113
  }
1061
1114
  if (!noble) {
1062
1115
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'Noble not available');
1063
1116
  }
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`);
1117
+ const byIdFirst = shouldConnectByIdFirst(deviceId);
1118
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Resolving device for cold connect', {
1119
+ deviceId,
1120
+ name: (_b = resolveBleName(deviceId)) !== null && _b !== void 0 ? _b : 'unknown',
1121
+ strategy: byIdFirst ? 'connect-by-id-first' : 'scan-first',
1122
+ });
1123
+ const scanForPeripheral = () => index.__awaiter(this, void 0, void 0, function* () {
1124
+ var _d;
1125
+ try {
1126
+ return (_d = (yield performTargetedScan(deviceId, BLE_COLD_CONNECT_SCAN_TIMEOUT_MS))) !== null && _d !== void 0 ? _d : undefined;
1074
1127
  }
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;
1128
+ catch (scanError) {
1129
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Targeted scan failed', {
1130
+ deviceId,
1131
+ error: scanError instanceof Error ? scanError.message : String(scanError),
1132
+ });
1133
+ return undefined;
1134
+ }
1135
+ });
1136
+ const connectById = () => index.__awaiter(this, void 0, void 0, function* () {
1137
+ const found = yield tryDirectConnectById(deviceId);
1138
+ if (found) {
1139
+ discoveredDevices.set(deviceId, found);
1140
+ }
1141
+ return found;
1142
+ });
1143
+ peripheral = byIdFirst ? yield connectById() : yield scanForPeripheral();
1144
+ if (!peripheral) {
1145
+ peripheral = byIdFirst ? yield scanForPeripheral() : yield connectById();
1080
1146
  }
1081
1147
  }
1082
1148
  if (!peripheral) {
1083
1149
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found`);
1084
1150
  }
1151
+ rememberBleName(deviceId, (_c = peripheral.advertisement) === null || _c === void 0 ? void 0 : _c.localName);
1085
1152
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Connecting to device:', deviceId);
1086
1153
  if (peripheral.state === 'connected') {
1087
1154
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Device already connected, skipping connection step');
@@ -1143,7 +1210,7 @@ function connectDevice(deviceId, webContents) {
1143
1210
  }
1144
1211
  if (error) {
1145
1212
  logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Connection failed:', error);
1146
- reject(hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleConnectedError, error.message));
1213
+ reject(createNobleBleConnectionError(error));
1147
1214
  return;
1148
1215
  }
1149
1216
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Connected to device:', deviceId);
@@ -1156,9 +1223,9 @@ function connectDevice(deviceId, webContents) {
1156
1223
  }
1157
1224
  catch (setupError) {
1158
1225
  logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Connection setup failed:', setupError);
1159
- connectedPeripheral.disconnect(() => {
1160
- reject(setupError);
1161
- });
1226
+ disconnectDevice(deviceId)
1227
+ .catch(() => undefined)
1228
+ .then(() => reject(setupError));
1162
1229
  }
1163
1230
  }));
1164
1231
  });
@@ -1176,11 +1243,15 @@ function disconnectDevice(deviceId) {
1176
1243
  deviceDisconnectListeners.delete(deviceId);
1177
1244
  }
1178
1245
  yield runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1179
- timeoutMs: BLE_CLEANUP_TIMEOUT,
1246
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
1180
1247
  timeoutBehavior: 'resolve',
1181
1248
  });
1249
+ if (peripheral.state === 'connected') {
1250
+ logger === null || logger === void 0 ? void 0 : logger.warn('[NobleBLE] Physical disconnect did not confirm in time', { deviceId });
1251
+ }
1182
1252
  cleanupDevice(deviceId, undefined, {
1183
1253
  cleanupConnection: true,
1254
+ cleanupDiscoveredCache: true,
1184
1255
  sendDisconnectEvent: false,
1185
1256
  cancelOperations: true,
1186
1257
  reason: 'manual-disconnect',
@@ -1271,9 +1342,14 @@ function subscribeNotifications(deviceId, callback) {
1271
1342
  });
1272
1343
  });
1273
1344
  }
1345
+ const subscribeStartedAt = Date.now();
1274
1346
  try {
1275
1347
  yield rebuildAppSubscription(deviceId, notifyCharacteristic);
1276
1348
  subscribedDevices.set(deviceId, true);
1349
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Notification subscription active', {
1350
+ deviceId,
1351
+ ms: Date.now() - subscribeStartedAt,
1352
+ });
1277
1353
  }
1278
1354
  finally {
1279
1355
  subscriptionOperations.set(deviceId, 'idle');
@@ -1391,10 +1467,6 @@ function setupNobleBleHandlers(webContents) {
1391
1467
  yield disconnectDevice(deviceId).catch(() => undefined);
1392
1468
  }
1393
1469
  yield stopScanning().catch(() => undefined);
1394
- if (noble && persistentStateListener) {
1395
- noble.removeListener('stateChange', persistentStateListener);
1396
- persistentStateListener = null;
1397
- }
1398
1470
  cleanupNobleListeners();
1399
1471
  discoveredDevices.clear();
1400
1472
  safeLog(logger, 'info', 'Noble BLE cleanup completed');
@@ -1410,5 +1482,6 @@ function setupNobleBleHandlers(webContents) {
1410
1482
  }
1411
1483
  }
1412
1484
 
1485
+ exports.createNobleBleConnectionError = createNobleBleConnectionError;
1413
1486
  exports.resolveNobleBleWritePacingDelay = resolveNobleBleWritePacingDelay;
1414
1487
  exports.setupNobleBleHandlers = setupNobleBleHandlers;
@@ -1,5 +1,6 @@
1
1
  import type { WebContents } from 'electron';
2
2
  import type { NobleBleWriteOptions } from './types/desktop-api';
3
+ export declare function createNobleBleConnectionError(error: Error, messagePrefix?: string): import("@onekeyfe/hd-shared").HardwareError;
3
4
  export declare function resolveNobleBleWritePacingDelay(options?: NobleBleWriteOptions): number;
4
5
  export declare function setupNobleBleHandlers(webContents: WebContents): void;
5
6
  //# sourceMappingURL=noble-ble-handler.d.ts.map
@@ -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":"AAiCA,OAAO,KAAK,EAA+B,WAAW,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAOhE,wBAAgB,6BAA6B,CAAC,KAAK,EAAE,KAAK,EAAE,aAAa,SAAK,+CAa7E;AA+ED,wBAAgB,+BAA+B,CAAC,OAAO,CAAC,EAAE,oBAAoB,UAI7E;AAovDD,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.12",
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.12",
29
+ "@onekeyfe/hd-shared": "1.2.2-alpha.12",
30
+ "@onekeyfe/hd-transport": "1.2.2-alpha.12",
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": "9b5a45bc55819d22ed752899b4c40aad70de4527"
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,5 @@
1
1
  import { EventEmitter } from 'events';
2
- import { EOneKeyBleMessageKeys } from '@onekeyfe/hd-shared';
2
+ import { EOneKeyBleMessageKeys, HardwareErrorCode } from '@onekeyfe/hd-shared';
3
3
 
4
4
  import {
5
5
  NOBLE_BLE_CONNECTION_TIMEOUT_MS,
@@ -31,6 +31,24 @@ describe('Electron Noble BLE device discovery', () => {
31
31
  expect(NOBLE_BLE_CONNECTION_TIMEOUT_MS).toBe(10_000);
32
32
  });
33
33
 
34
+ test('maps macOS stale pairing failures without reclassifying generic connection errors', async () => {
35
+ const { createNobleBleConnectionError } = await import('../noble-ble-handler');
36
+
37
+ expect(
38
+ createNobleBleConnectionError(
39
+ new Error('CBErrorDomain:14 Peer removed pairing information on the device side')
40
+ )
41
+ ).toMatchObject({
42
+ errorCode: HardwareErrorCode.BlePeerRemovedPairingInformation,
43
+ });
44
+ expect(createNobleBleConnectionError(new Error('Encryption is insufficient'))).toMatchObject({
45
+ errorCode: HardwareErrorCode.BleDeviceBondError,
46
+ });
47
+ expect(createNobleBleConnectionError(new Error('connection failed'))).toMatchObject({
48
+ errorCode: HardwareErrorCode.BleConnectedError,
49
+ });
50
+ });
51
+
34
52
  test('keeps safe pacing by default and allows an explicit high-throughput bypass', async () => {
35
53
  const { resolveNobleBleWritePacingDelay } = await import('../noble-ble-handler');
36
54
 
@@ -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,
@@ -15,13 +15,15 @@ import {
15
15
  ONEKEY_WRITE_CHARACTERISTIC_UUID,
16
16
  createKnownBleUuidAliases,
17
17
  hasOnekeyCommunicationService,
18
+ isBleStaleBondErrorText,
18
19
  isOnekeyBluetoothDevice,
20
+ isPro2FamilyBleName,
19
21
  matchesKnownBleUuid,
20
22
  wait,
21
23
  } from '@onekeyfe/hd-shared';
22
24
  import pRetry from 'p-retry';
23
25
 
24
- import { resolveBlePacketCapacity } from './ble-packet-capacity';
26
+ import { resolveBlePacketCapacity, resolveNobleAttMtu } from './ble-packet-capacity';
25
27
  import { safeLog } from './types/noble-extended';
26
28
  import { runBleCallbackOperation, softRefreshSubscription } from './ble-ops';
27
29
  import {
@@ -38,6 +40,21 @@ import type { CharacteristicPair, DeviceInfo, Logger, NobleModule } from './type
38
40
  let noble: NobleModule | null = null;
39
41
  let logger: Logger | null = null;
40
42
 
43
+ export function createNobleBleConnectionError(error: Error, messagePrefix = '') {
44
+ const errorMessage = error.message;
45
+ const normalizedErrorMessage = errorMessage.toLowerCase();
46
+ let errorCode = HardwareErrorCode.BleConnectedError;
47
+ if (isBleStaleBondErrorText(errorMessage)) {
48
+ errorCode =
49
+ normalizedErrorMessage.includes('peer removed pairing information') ||
50
+ normalizedErrorMessage.includes('cberrordomain:14')
51
+ ? HardwareErrorCode.BlePeerRemovedPairingInformation
52
+ : HardwareErrorCode.BleDeviceBondError;
53
+ }
54
+
55
+ return ERRORS.TypedError(errorCode, `${messagePrefix}${errorMessage}`);
56
+ }
57
+
41
58
  // Bluetooth state management
42
59
  const bluetoothState: {
43
60
  available: boolean;
@@ -90,8 +107,18 @@ const DEVICE_SCAN_TIMEOUT = 5000; // 5 seconds for device scanning
90
107
  const DEVICE_CHECK_INTERVAL = 500; // 500ms interval for periodic device checks
91
108
  const SERVICE_DISCOVERY_TIMEOUT = 10000; // 10 seconds for service discovery
92
109
  const BLE_CLEANUP_TIMEOUT = 250;
110
+ // A physical teardown must actually reach the OS before the device can return
111
+ // to a clean state; 250ms routinely declared success while CoreBluetooth was
112
+ // still disconnecting, leaving no trace when the teardown never completed.
113
+ const BLE_DISCONNECT_CONFIRM_TIMEOUT_MS = 3000;
93
114
  // Renderer release is logical only; this timer physically frees the device.
94
- const BLE_IDLE_DISCONNECT_MS = 3 * 60_000;
115
+ // Keep it SHORT: the Classic 1S goes protocol-deaf when a link is dropped
116
+ // after sitting idle for minutes (field logs 2026-08-19: every deaf window
117
+ // followed a 3-minute-idle disconnect, while disconnects right after traffic
118
+ // have never produced one across 6.5.0's per-call teardown history). A hot
119
+ // disconnect ~20s after the last operation stays inside the proven-safe
120
+ // pattern and also shrinks the window in which phones cannot see the device.
121
+ const BLE_IDLE_DISCONNECT_MS = 20_000;
95
122
  // Ceiling while a call is in flight: no outstanding write, but not forever.
96
123
  const BLE_BUSY_BACKSTOP_MS = 10 * 60_000;
97
124
 
@@ -257,6 +284,11 @@ async function initializeNoble(): Promise<void> {
257
284
  noble = require('@stoprocent/noble') as NobleModule;
258
285
  logger?.info('[NobleBLE] Noble library loaded');
259
286
 
287
+ // Register the process-lifetime state listener before any early return:
288
+ // the poweredOn fast path below would otherwise skip it for the whole
289
+ // session, leaving poweredOff cache/state reconciliation dead.
290
+ setupPersistentStateListener();
291
+
260
292
  // Wait for Bluetooth to be ready
261
293
  await new Promise<void>((resolve, reject) => {
262
294
  if (!noble) {
@@ -269,9 +301,6 @@ async function initializeNoble(): Promise<void> {
269
301
  return;
270
302
  }
271
303
 
272
- // Setup persistent state listener before initialization
273
- setupPersistentStateListener();
274
-
275
304
  const timeout = setTimeout(() => {
276
305
  reject(
277
306
  ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Bluetooth initialization timeout')
@@ -398,7 +427,21 @@ function armIdleDisconnect(
398
427
  logger?.info('[NobleBLE] Keep-alive timeout, disconnecting device', { deviceId, reason });
399
428
  const peripheral = connectedDevices.get(deviceId);
400
429
  const deviceName = peripheral?.advertisement?.localName || 'Unknown Device';
401
- const pending = disconnectDevice(deviceId)
430
+ // Unsubscribe CCCD before dropping the link, matching every other
431
+ // teardown path: the 1S leaves its notify session half-open when the
432
+ // link drops without an unsubscribe and then ignores application
433
+ // protocol traffic on NEW links for tens of minutes (field log
434
+ // 2026-08-19: the lone unsubscribe-skipping idle teardown caused a
435
+ // 6-attempt reconnect loop; unsubscribe-first teardowns reconnected
436
+ // instantly).
437
+ const pending = unsubscribeNotifications(deviceId)
438
+ .catch(error => {
439
+ logger?.warn('[NobleBLE] Keep-alive unsubscribe failed, disconnecting anyway', {
440
+ deviceId,
441
+ error: error instanceof Error ? error.message : String(error),
442
+ });
443
+ })
444
+ .then(() => disconnectDevice(deviceId))
402
445
  .then(() => {
403
446
  // A call is still in flight here; it must reject, not hang.
404
447
  // Both 'idle' and 'busy-backstop' report IdleKeepAlive on purpose:
@@ -508,6 +551,9 @@ function handleDeviceDisconnect(deviceId: string, webContents: WebContents): voi
508
551
 
509
552
  cleanupDevice(deviceId, webContents, {
510
553
  cleanupConnection: true,
554
+ // Same stale-object hazard as manual disconnect: an externally dropped
555
+ // link invalidates the cached peripheral for the next reconnect.
556
+ cleanupDiscoveredCache: true,
511
557
  sendDisconnectEvent: true,
512
558
  cancelOperations: true,
513
559
  reason: 'auto-disconnect',
@@ -544,12 +590,13 @@ function setupMtuListener(
544
590
  }
545
591
 
546
592
  const listener = (mtu: number) => {
547
- if (!Number.isFinite(mtu) || mtu <= 0) return;
593
+ const normalizedMtu = resolveNobleAttMtu(mtu);
594
+ if (normalizedMtu === undefined) return;
548
595
  // A kept-alive link can outlive the renderer this closure captured.
549
596
  if (webContents.isDestroyed()) return;
550
597
  webContents.send(EOneKeyBleMessageKeys.NOBLE_BLE_MTU_CHANGED, {
551
598
  id: deviceId,
552
- mtu,
599
+ mtu: normalizedMtu,
553
600
  });
554
601
  };
555
602
  deviceMtuListeners.set(deviceId, { peripheral, listener });
@@ -697,7 +744,7 @@ async function transmitHexDataToDevice(
697
744
  const toBuffer = Buffer.from(hexData, 'hex');
698
745
  const doGetWriteCharacteristic = () => deviceCharacteristics.get(deviceId)?.write;
699
746
  const packetCapacity = resolveBlePacketCapacity(
700
- peripheral.mtu,
747
+ resolveNobleAttMtu(peripheral.mtu),
701
748
  BLE_PACKET_SIZE_MAXIMUM,
702
749
  BLE_PACKET_SIZE_FALLBACK
703
750
  );
@@ -761,6 +808,44 @@ async function transmitHexDataToDevice(
761
808
  }
762
809
 
763
810
  // Handle discovered device (for general enumeration only)
811
+ // A scan that finds nothing costs this much before the direct-connect
812
+ // fallback runs. Every advertisement in the 6.5.0 control logs arrived within
813
+ // 631ms, so this leaves ~2x headroom while keeping a miss cheap.
814
+ const BLE_COLD_CONNECT_SCAN_TIMEOUT_MS = 1500;
815
+
816
+ // Attempts before a link is written off as unable to serve the OneKey service.
817
+ const SERVICE_DISCOVERY_MAX_ATTEMPTS = 2;
818
+
819
+ // Advertised names, kept for the life of the process: device caches are purged
820
+ // on every disconnect, but the family a device belongs to never changes and
821
+ // decides which connection strategy is safe for it.
822
+ const bleNamesById = new Map<string, string>();
823
+
824
+ function rememberBleName(deviceId: string, name?: string | null): void {
825
+ const trimmed = name?.trim();
826
+ if (trimmed) {
827
+ bleNamesById.set(deviceId, trimmed);
828
+ }
829
+ }
830
+
831
+ function resolveBleName(deviceId: string): string | undefined {
832
+ const live =
833
+ connectedDevices.get(deviceId)?.advertisement?.localName?.trim() ||
834
+ discoveredDevices.get(deviceId)?.advertisement?.localName?.trim();
835
+ return live || bleNamesById.get(deviceId);
836
+ }
837
+
838
+ /**
839
+ * Pro2/Neo connect by id first: they are unaffected by the stale-session
840
+ * problem and advertise under Find My names a OneKey-filtered scan cannot
841
+ * match. Everything else — Classic family, and any device whose name is not
842
+ * known yet — scans first, because connecting by id can resolve a session the
843
+ * device no longer serves (see setupConnectionAndDiscoverServices).
844
+ */
845
+ function shouldConnectByIdFirst(deviceId: string): boolean {
846
+ return isPro2FamilyBleName(resolveBleName(deviceId));
847
+ }
848
+
764
849
  function handleDeviceDiscovered(peripheral: Peripheral): void {
765
850
  // Only process OneKey candidates for general discovery. Avoid logging every
766
851
  // ambient BLE peripheral; it makes Pro2 debugging hard to read.
@@ -770,6 +855,7 @@ function handleDeviceDiscovered(peripheral: Peripheral): void {
770
855
 
771
856
  const isNewDevice = !discoveredDevices.has(peripheral.id);
772
857
  discoveredDevices.set(peripheral.id, peripheral);
858
+ rememberBleName(peripheral.id, peripheral.advertisement?.localName);
773
859
  if (isNewDevice) {
774
860
  logger?.debug('[NobleBLE] OneKey BLE device discovered', {
775
861
  deviceId: peripheral.id,
@@ -804,7 +890,10 @@ async function waitForNobleScanStop(nobleInstance: NobleModule): Promise<void> {
804
890
 
805
891
  // Perform targeted scan for a specific device ID
806
892
  // Uses self-contained local listener pattern - no global state needed
807
- async function performTargetedScan(targetDeviceId: string): Promise<Peripheral | null> {
893
+ async function performTargetedScan(
894
+ targetDeviceId: string,
895
+ timeoutMs: number = NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS
896
+ ): Promise<Peripheral | null> {
808
897
  if (!noble) {
809
898
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
810
899
  }
@@ -849,7 +938,7 @@ async function performTargetedScan(targetDeviceId: string): Promise<Peripheral |
849
938
  const timeoutId = setTimeout(() => {
850
939
  logger?.info('[NobleBLE] Targeted scan timeout for device:', targetDeviceId);
851
940
  finish(null).catch(reject);
852
- }, NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS);
941
+ }, timeoutMs);
853
942
 
854
943
  // Add local listener for this scan
855
944
  nobleInstance.on('discover', onDiscover);
@@ -975,12 +1064,13 @@ function getDevice(deviceId: string): DeviceInfo | null {
975
1064
  const peripheral = discoveredDevices.get(deviceId);
976
1065
  if (peripheral) {
977
1066
  const deviceName = peripheral.advertisement?.localName || 'Unknown Device';
1067
+ const mtu = resolveNobleAttMtu(peripheral.mtu);
978
1068
  return {
979
1069
  commType: 'electron-ble',
980
1070
  id: peripheral.id,
981
1071
  name: deviceName,
982
1072
  state: peripheral.state || 'disconnected',
983
- ...(typeof peripheral.mtu === 'number' ? { mtu: peripheral.mtu } : {}),
1073
+ ...(mtu === undefined ? {} : { mtu }),
984
1074
  };
985
1075
  }
986
1076
 
@@ -988,12 +1078,13 @@ function getDevice(deviceId: string): DeviceInfo | null {
988
1078
  const connectedPeripheral = connectedDevices.get(deviceId);
989
1079
  if (connectedPeripheral) {
990
1080
  const deviceName = connectedPeripheral.advertisement?.localName || 'Unknown Device';
1081
+ const mtu = resolveNobleAttMtu(connectedPeripheral.mtu);
991
1082
  return {
992
1083
  commType: 'electron-ble',
993
1084
  id: connectedPeripheral.id,
994
1085
  name: deviceName,
995
1086
  state: connectedPeripheral.state || 'connected',
996
- ...(typeof connectedPeripheral.mtu === 'number' ? { mtu: connectedPeripheral.mtu } : {}),
1087
+ ...(mtu === undefined ? {} : { mtu }),
997
1088
  };
998
1089
  }
999
1090
 
@@ -1051,9 +1142,17 @@ async function discoverServicesAndCharacteristics(
1051
1142
 
1052
1143
  // Main discovery logic as async function
1053
1144
  const discoveryPromise = (async (): Promise<CharacteristicPair> => {
1054
- // Step 1: Discover ALL services (no filter Pro2 may use different service UUID)
1145
+ // Step 1: Discover the OneKey service by UUID. Filtering is not just a
1146
+ // narrowing: an unfiltered discovery is answered from the OS GATT cache, so
1147
+ // a device whose stack still advertises and accepts links but no longer
1148
+ // serves its application layer still looks healthy — the link comes up, the
1149
+ // cached services resolve, the protocol frame goes out and nothing ever
1150
+ // answers. A targeted query returns nothing in that state, which is what
1151
+ // 6.5.0 relied on to trip the recovery path below (retry, reset, fresh
1152
+ // scan) — the sequence that brings such a device back. Pro2/Neo expose the
1153
+ // same service UUID, and the selection below only ever accepts that one.
1055
1154
  const services = await new Promise<Service[]>((resolve, reject) => {
1056
- peripheral.discoverServices([], (error, svc) => {
1155
+ peripheral.discoverServices(ONEKEY_SERVICE_UUIDS, (error, svc) => {
1057
1156
  if (error) {
1058
1157
  logger?.error('[NobleBLE] Service discovery failed:', error);
1059
1158
  reject(ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, error.message));
@@ -1064,7 +1163,7 @@ async function discoverServicesAndCharacteristics(
1064
1163
  });
1065
1164
 
1066
1165
  if (!services || services.length === 0) {
1067
- throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No services found');
1166
+ throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
1068
1167
  }
1069
1168
 
1070
1169
  logger?.debug('[NobleBLE] services discovered', {
@@ -1213,12 +1312,7 @@ async function freshScanAndDiscover(
1213
1312
  await new Promise<void>((resolve, reject) => {
1214
1313
  freshPeripheral.connect((error: Error | undefined) => {
1215
1314
  if (error) {
1216
- reject(
1217
- ERRORS.TypedError(
1218
- HardwareErrorCode.BleConnectedError,
1219
- `Fresh peripheral connection failed: ${error.message}`
1220
- )
1221
- );
1315
+ reject(createNobleBleConnectionError(error, 'Fresh peripheral connection failed: '));
1222
1316
  } else {
1223
1317
  connectedDevices.set(deviceId, freshPeripheral);
1224
1318
  resolve();
@@ -1248,12 +1342,14 @@ async function discoverServicesAndCharacteristicsWithRetry(
1248
1342
  deviceId,
1249
1343
  peripheralState: peripheral.state,
1250
1344
  attempt: attemptNumber,
1251
- maxRetries: 5,
1345
+ maxRetries: SERVICE_DISCOVERY_MAX_ATTEMPTS,
1252
1346
  targetUUIDs: ONEKEY_SERVICE_UUIDS,
1253
1347
  });
1254
1348
 
1255
1349
  if (attemptNumber > 1) {
1256
- logger?.info(`[NobleBLE] Service discovery retry attempt ${attemptNumber}/5`);
1350
+ logger?.info(
1351
+ `[NobleBLE] Service discovery retry attempt ${attemptNumber}/${SERVICE_DISCOVERY_MAX_ATTEMPTS}`
1352
+ );
1257
1353
  }
1258
1354
 
1259
1355
  // Verify connection state before attempting service discovery
@@ -1267,20 +1363,32 @@ async function discoverServicesAndCharacteristicsWithRetry(
1267
1363
  try {
1268
1364
  return await discoverServicesAndCharacteristics(peripheral);
1269
1365
  } catch (error) {
1270
- logger?.error(`[NobleBLE] No services found (attempt ${attemptNumber}/5)`);
1366
+ logger?.error(
1367
+ `[NobleBLE] No services found (attempt ${attemptNumber}/${SERVICE_DISCOVERY_MAX_ATTEMPTS})`
1368
+ );
1271
1369
 
1272
- if (attemptNumber < 5) {
1273
- logger?.error(`[NobleBLE] Will retry service discovery (attempt ${attemptNumber + 1}/5)`);
1370
+ if (attemptNumber < SERVICE_DISCOVERY_MAX_ATTEMPTS) {
1371
+ logger?.error(
1372
+ `[NobleBLE] Will retry service discovery (attempt ${
1373
+ attemptNumber + 1
1374
+ }/${SERVICE_DISCOVERY_MAX_ATTEMPTS})`
1375
+ );
1274
1376
  }
1275
1377
 
1276
1378
  throw error; // p-retry will handle the retry logic
1277
1379
  }
1278
1380
  },
1279
1381
  {
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
1382
+ // One retry, not four. An empty result here is the signature of a device
1383
+ // whose stack no longer serves its application layer, and retrying the
1384
+ // same link has never recovered it — every observed run failed all the
1385
+ // way through. The recovery is the teardown and cold reconnect the caller
1386
+ // performs afterwards, so reaching it sooner is what shortens the wait.
1387
+ // The single retry stays for a genuinely transient miss.
1388
+ retries: SERVICE_DISCOVERY_MAX_ATTEMPTS - 1,
1389
+ factor: 1.5,
1390
+ minTimeout: 500,
1391
+ maxTimeout: 3000,
1284
1392
  onFailedAttempt: error => {
1285
1393
  // This runs after each failed attempt
1286
1394
  logger?.error(`[NobleBLE] Service discovery attempt ${error.attemptNumber} failed:`, {
@@ -1305,26 +1413,45 @@ async function setupConnectionAndDiscoverServices(
1305
1413
  deviceId: string,
1306
1414
  webContents: WebContents
1307
1415
  ): Promise<CharacteristicPair> {
1308
- setupDisconnectListener(peripheral, deviceId, webContents);
1309
-
1416
+ // Reset the link before any protocol traffic reaches the device. 6.5.0 did
1417
+ // this on every cold setup and never produced a protocol-deaf Classic 1S
1418
+ // (control log 2026-08-20: 16/16 links answered within ~1s, including after
1419
+ // a 15-minute idle). Demoting it to a discovery-failure fallback removed the
1420
+ // reset entirely for this device, because discovery always succeeds: the link
1421
+ // comes up, GATT resolves from cache, and the device then answers nothing on
1422
+ // a session it no longer serves. Costs ~1.3s per cold setup; a kept-alive
1423
+ // link with an intact subscription returns before reaching here, so reuse
1424
+ // inside a workflow is unaffected.
1310
1425
  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
- });
1426
+ await forceReconnectPeripheral(peripheral, deviceId);
1427
+ } catch (resetError) {
1428
+ // A failed reset must not abort the attempt: discovery on the existing
1429
+ // link, then the fresh-scan fallback, still have a chance to recover.
1430
+ logger?.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
1317
1431
  }
1318
-
1319
- await forceReconnectPeripheral(peripheral, deviceId);
1320
1432
  setupDisconnectListener(peripheral, deviceId, webContents);
1433
+
1321
1434
  try {
1322
1435
  return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1323
- } catch (reconnectError) {
1324
- logger?.error(
1325
- '[NobleBLE] Service discovery failed after reconnect, attempting fresh scan...',
1326
- reconnectError
1327
- );
1436
+ } catch (discoveryError) {
1437
+ // A connected peripheral does not advertise, so the fresh scan below would
1438
+ // only run out its timeout. Drop the link first, then rescan: the cold
1439
+ // reconnect is what recovers a device whose stack stopped serving its
1440
+ // application layer. Recover here rather than throwing to the caller —
1441
+ // one acquire pays ~2s for the rescan, while a thrown error costs the
1442
+ // renderer a full retry round trip.
1443
+ if (peripheral.state === 'connected') {
1444
+ logger?.error(
1445
+ '[NobleBLE] Service discovery failed on a live link, dropping it before the fresh scan',
1446
+ discoveryError
1447
+ );
1448
+ await disconnectDevice(deviceId).catch(() => undefined);
1449
+ } else {
1450
+ logger?.error(
1451
+ '[NobleBLE] Service discovery failed, attempting fresh scan...',
1452
+ discoveryError
1453
+ );
1454
+ }
1328
1455
  return freshScanAndDiscover(deviceId, webContents);
1329
1456
  }
1330
1457
  }
@@ -1413,10 +1540,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1413
1540
  // enumerate clears the discovery map; a kept-alive link outlives it.
1414
1541
  let peripheral = discoveredDevices.get(deviceId) ?? connectedDevices.get(deviceId);
1415
1542
 
1416
- // If device not discovered, try a targeted scan for this specific device
1417
1543
  if (!peripheral) {
1418
- logger?.info('[NobleBLE] Device not discovered, attempting targeted scan for:', deviceId);
1419
-
1420
1544
  // Initialize Noble if not already done
1421
1545
  if (!noble) {
1422
1546
  await initializeNoble();
@@ -1426,27 +1550,46 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1426
1550
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
1427
1551
  }
1428
1552
 
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
- }
1553
+ // A live advertisement proves the device holds no link and will open a
1554
+ // fresh session; connecting by id resolves through the OS cache instead
1555
+ // and can hand back a session the device no longer serves (see
1556
+ // setupConnectionAndDiscoverServices). Scanning is also the faster of the
1557
+ // two when the device does advertise: ~96ms median against ~784ms for
1558
+ // connect-by-id across the field logs. Pro2/Neo keep connect-by-id first
1559
+ // because they are unaffected and may advertise under an unmatchable Find
1560
+ // My name.
1561
+ const byIdFirst = shouldConnectByIdFirst(deviceId);
1562
+ logger?.info('[NobleBLE] Resolving device for cold connect', {
1563
+ deviceId,
1564
+ name: resolveBleName(deviceId) ?? 'unknown',
1565
+ strategy: byIdFirst ? 'connect-by-id-first' : 'scan-first',
1566
+ });
1435
1567
 
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
- );
1568
+ const scanForPeripheral = async () => {
1569
+ try {
1570
+ return (await performTargetedScan(deviceId, BLE_COLD_CONNECT_SCAN_TIMEOUT_MS)) ?? undefined;
1571
+ } catch (scanError) {
1572
+ logger?.info('[NobleBLE] Targeted scan failed', {
1573
+ deviceId,
1574
+ error: scanError instanceof Error ? scanError.message : String(scanError),
1575
+ });
1576
+ return undefined;
1577
+ }
1578
+ };
1579
+
1580
+ const connectById = async () => {
1581
+ const found = await tryDirectConnectById(deviceId);
1582
+ if (found) {
1583
+ discoveredDevices.set(deviceId, found);
1445
1584
  }
1446
- peripheral = foundPeripheral;
1447
- } catch (error) {
1448
- logger?.error('[NobleBLE] Targeted scan failed:', error);
1449
- throw error;
1585
+ return found;
1586
+ };
1587
+
1588
+ peripheral = byIdFirst ? await connectById() : await scanForPeripheral();
1589
+ if (!peripheral) {
1590
+ // The preferred route came up empty: not advertising (held elsewhere, or
1591
+ // silent), or not reachable by id. Try the other one before giving up.
1592
+ peripheral = byIdFirst ? await scanForPeripheral() : await connectById();
1450
1593
  }
1451
1594
  }
1452
1595
 
@@ -1455,6 +1598,10 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1455
1598
  throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found`);
1456
1599
  }
1457
1600
 
1601
+ // Also covers the connect-by-id route, whose simulated discovery carries no
1602
+ // service UUIDs and therefore never reaches handleDeviceDiscovered.
1603
+ rememberBleName(deviceId, peripheral.advertisement?.localName);
1604
+
1458
1605
  logger?.info('[NobleBLE] Connecting to device:', deviceId);
1459
1606
 
1460
1607
  // Check if device is already connected
@@ -1556,7 +1703,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1556
1703
 
1557
1704
  if (error) {
1558
1705
  logger?.error('[NobleBLE] Connection failed:', error);
1559
- reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
1706
+ reject(createNobleBleConnectionError(error));
1560
1707
  return;
1561
1708
  }
1562
1709
 
@@ -1575,9 +1722,14 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1575
1722
  resolve();
1576
1723
  } catch (setupError) {
1577
1724
  logger?.error('[NobleBLE] Connection setup failed:', setupError);
1578
- connectedPeripheral.disconnect(() => {
1579
- reject(setupError);
1580
- });
1725
+ // Never reject from inside a raw disconnect callback: noble only fires
1726
+ // it on a real 'disconnect' event, so a peripheral that is already
1727
+ // down leaves this promise — and the renderer acquire awaiting it —
1728
+ // pending forever. disconnectDevice always settles (it no-ops on an
1729
+ // unknown peripheral and caps the confirm wait).
1730
+ disconnectDevice(deviceId)
1731
+ .catch(() => undefined)
1732
+ .then(() => reject(setupError));
1581
1733
  }
1582
1734
  });
1583
1735
  });
@@ -1597,11 +1749,18 @@ async function disconnectDevice(deviceId: string): Promise<void> {
1597
1749
  }
1598
1750
 
1599
1751
  await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1600
- timeoutMs: BLE_CLEANUP_TIMEOUT,
1752
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
1601
1753
  timeoutBehavior: 'resolve',
1602
1754
  });
1755
+ if (peripheral.state === 'connected') {
1756
+ logger?.warn('[NobleBLE] Physical disconnect did not confirm in time', { deviceId });
1757
+ }
1603
1758
  cleanupDevice(deviceId, undefined, {
1604
1759
  cleanupConnection: true,
1760
+ // macOS returns zero GATT services when a previously-disconnected
1761
+ // peripheral object is reconnected; drop the discovery cache so the next
1762
+ // connect resolves a fresh peripheral (direct connect by id, or scan).
1763
+ cleanupDiscoveredCache: true,
1605
1764
  sendDisconnectEvent: false,
1606
1765
  cancelOperations: true,
1607
1766
  reason: 'manual-disconnect',
@@ -1742,9 +1901,14 @@ async function subscribeNotifications(
1742
1901
  });
1743
1902
  }
1744
1903
 
1904
+ const subscribeStartedAt = Date.now();
1745
1905
  try {
1746
1906
  await rebuildAppSubscription(deviceId, notifyCharacteristic);
1747
1907
  subscribedDevices.set(deviceId, true);
1908
+ logger?.info('[NobleBLE] Notification subscription active', {
1909
+ deviceId,
1910
+ ms: Date.now() - subscribeStartedAt,
1911
+ });
1748
1912
  } finally {
1749
1913
  // 🔒 CRITICAL: Always clear operation state (even on error)
1750
1914
  subscriptionOperations.set(deviceId, 'idle');
@@ -1927,10 +2091,12 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1927
2091
  }
1928
2092
 
1929
2093
  await stopScanning().catch(() => undefined);
1930
- if (noble && persistentStateListener) {
1931
- noble.removeListener('stateChange', persistentStateListener);
1932
- persistentStateListener = null;
1933
- }
2094
+ // persistentStateListener is process-lifetime, NOT per-window: this
2095
+ // destroy handler also fires on a renderer soft restart, and removing
2096
+ // the listener here permanently killed poweredOff/poweredOn
2097
+ // reconciliation for the rest of the process (nothing re-registers it:
2098
+ // initializeNoble early-returns once noble is loaded). The null-guard
2099
+ // in setupPersistentStateListener keeps it deduped to one instance.
1934
2100
  cleanupNobleListeners();
1935
2101
  discoveredDevices.clear();
1936
2102
  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