@onekeyfe/hd-transport-electron 1.2.0-alpha.176 → 1.2.0-alpha.178

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.
@@ -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-796173e1.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-60f89cbb.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-60f89cbb.js');
4
4
  var hdShared = require('@onekeyfe/hd-shared');
5
5
  var pRetry = require('p-retry');
6
6
 
@@ -116,7 +116,8 @@ const DEVICE_SCAN_TIMEOUT = 5000;
116
116
  const DEVICE_CHECK_INTERVAL = 500;
117
117
  const SERVICE_DISCOVERY_TIMEOUT = 10000;
118
118
  const BLE_CLEANUP_TIMEOUT = 250;
119
- const BLE_IDLE_DISCONNECT_MS = 3 * 60000;
119
+ const BLE_DISCONNECT_CONFIRM_TIMEOUT_MS = 3000;
120
+ const BLE_IDLE_DISCONNECT_MS = 20000;
120
121
  const BLE_BUSY_BACKSTOP_MS = 10 * 60000;
121
122
  const BLE_PACKET_SIZE_FALLBACK = 192;
122
123
  const BLE_PACKET_SIZE_MAXIMUM = 244;
@@ -242,6 +243,7 @@ function initializeNoble() {
242
243
  try {
243
244
  noble = require('@stoprocent/noble');
244
245
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Noble library loaded');
246
+ setupPersistentStateListener();
245
247
  yield new Promise((resolve, reject) => {
246
248
  if (!noble) {
247
249
  reject(hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'Noble not initialized'));
@@ -251,7 +253,6 @@ function initializeNoble() {
251
253
  resolve();
252
254
  return;
253
255
  }
254
- setupPersistentStateListener();
255
256
  const timeout = setTimeout(() => {
256
257
  reject(hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'Bluetooth initialization timeout'));
257
258
  }, BLUETOOTH_INIT_TIMEOUT);
@@ -336,7 +337,14 @@ function armIdleDisconnect(deviceId, ms = BLE_IDLE_DISCONNECT_MS, reason = 'idle
336
337
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Keep-alive timeout, disconnecting device', { deviceId, reason });
337
338
  const peripheral = connectedDevices.get(deviceId);
338
339
  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)
340
+ const pending = unsubscribeNotifications(deviceId)
341
+ .catch(error => {
342
+ logger === null || logger === void 0 ? void 0 : logger.warn('[NobleBLE] Keep-alive unsubscribe failed, disconnecting anyway', {
343
+ deviceId,
344
+ error: error instanceof Error ? error.message : String(error),
345
+ });
346
+ })
347
+ .then(() => disconnectDevice(deviceId))
340
348
  .then(() => {
341
349
  broadcastToAllWebContents(hdShared.EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
342
350
  id: deviceId,
@@ -409,6 +417,7 @@ function handleDeviceDisconnect(deviceId, webContents) {
409
417
  });
410
418
  cleanupDevice(deviceId, webContents, {
411
419
  cleanupConnection: true,
420
+ cleanupDiscoveredCache: true,
412
421
  sendDisconnectEvent: true,
413
422
  cancelOperations: true,
414
423
  reason: 'auto-disconnect',
@@ -536,6 +545,7 @@ function attemptWindowsWriteUntilPaired(deviceId, doGetWriteCharacteristic, payl
536
545
  });
537
546
  }
538
547
  function transmitHexDataToDevice(deviceId, hexData, options) {
548
+ var _a;
539
549
  return index.__awaiter(this, void 0, void 0, function* () {
540
550
  const characteristics = deviceCharacteristics.get(deviceId);
541
551
  const peripheral = connectedDevices.get(deviceId);
@@ -547,6 +557,14 @@ function transmitHexDataToDevice(deviceId, hexData, options) {
547
557
  const doGetWriteCharacteristic = () => { var _a; return (_a = deviceCharacteristics.get(deviceId)) === null || _a === void 0 ? void 0 : _a.write; };
548
558
  const packetCapacity = resolveBlePacketCapacity(peripheral.mtu, BLE_PACKET_SIZE_MAXIMUM, BLE_PACKET_SIZE_FALLBACK);
549
559
  const pacingDelayMs = resolveNobleBleWritePacingDelay(options);
560
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Write frame', {
561
+ deviceId,
562
+ bytes: toBuffer.length,
563
+ head: toBuffer.subarray(0, 8).toString('hex'),
564
+ packetCapacity,
565
+ mtu: (_a = peripheral.mtu) !== null && _a !== void 0 ? _a : null,
566
+ pacingDelayMs,
567
+ });
550
568
  if (!IS_WINDOWS || pairedDevices.has(deviceId)) {
551
569
  const writeCharacteristic = doGetWriteCharacteristic();
552
570
  if (!writeCharacteristic) {
@@ -590,18 +608,37 @@ function transmitHexDataToDevice(deviceId, hexData, options) {
590
608
  }
591
609
  });
592
610
  }
611
+ const BLE_COLD_CONNECT_SCAN_TIMEOUT_MS = 1500;
612
+ const SERVICE_DISCOVERY_MAX_ATTEMPTS = 2;
613
+ const bleNamesById = new Map();
614
+ function rememberBleName(deviceId, name) {
615
+ const trimmed = name === null || name === void 0 ? void 0 : name.trim();
616
+ if (trimmed) {
617
+ bleNamesById.set(deviceId, trimmed);
618
+ }
619
+ }
620
+ function resolveBleName(deviceId) {
621
+ var _a, _b, _c, _d, _e, _f;
622
+ 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()) ||
623
+ ((_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());
624
+ return live || bleNamesById.get(deviceId);
625
+ }
626
+ function shouldConnectByIdFirst(deviceId) {
627
+ return hdShared.isPro2FamilyBleName(resolveBleName(deviceId));
628
+ }
593
629
  function handleDeviceDiscovered(peripheral) {
594
- var _a, _b;
630
+ var _a, _b, _c;
595
631
  if (!isOneKeyPeripheral(peripheral)) {
596
632
  return;
597
633
  }
598
634
  const isNewDevice = !discoveredDevices.has(peripheral.id);
599
635
  discoveredDevices.set(peripheral.id, peripheral);
636
+ rememberBleName(peripheral.id, (_a = peripheral.advertisement) === null || _a === void 0 ? void 0 : _a.localName);
600
637
  if (isNewDevice) {
601
638
  logger === null || logger === void 0 ? void 0 : logger.debug('[NobleBLE] OneKey BLE device discovered', {
602
639
  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) || [],
640
+ name: ((_b = peripheral.advertisement) === null || _b === void 0 ? void 0 : _b.localName) || 'Unknown Device',
641
+ serviceUUIDs: ((_c = peripheral.advertisement) === null || _c === void 0 ? void 0 : _c.serviceUuids) || [],
605
642
  });
606
643
  }
607
644
  }
@@ -627,7 +664,7 @@ function waitForNobleScanStop(nobleInstance) {
627
664
  });
628
665
  });
629
666
  }
630
- function performTargetedScan(targetDeviceId) {
667
+ function performTargetedScan(targetDeviceId, timeoutMs = NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS) {
631
668
  return index.__awaiter(this, void 0, void 0, function* () {
632
669
  if (!noble) {
633
670
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'Noble not available');
@@ -667,7 +704,7 @@ function performTargetedScan(targetDeviceId) {
667
704
  const timeoutId = setTimeout(() => {
668
705
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Targeted scan timeout for device:', targetDeviceId);
669
706
  finish(null).catch(reject);
670
- }, NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS);
707
+ }, timeoutMs);
671
708
  nobleInstance.on('discover', onDiscover);
672
709
  nobleInstance.startScanning([], true, (error) => {
673
710
  if (error) {
@@ -802,7 +839,7 @@ function discoverServicesAndCharacteristics(peripheral) {
802
839
  });
803
840
  const discoveryPromise = (() => index.__awaiter(this, void 0, void 0, function* () {
804
841
  const services = yield new Promise((resolve, reject) => {
805
- peripheral.discoverServices([], (error, svc) => {
842
+ peripheral.discoverServices(ONEKEY_SERVICE_UUIDS, (error, svc) => {
806
843
  if (error) {
807
844
  logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed:', error);
808
845
  reject(hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleServiceNotFound, error.message));
@@ -813,7 +850,7 @@ function discoverServicesAndCharacteristics(peripheral) {
813
850
  });
814
851
  });
815
852
  if (!services || services.length === 0) {
816
- throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleServiceNotFound, 'No services found');
853
+ throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
817
854
  }
818
855
  logger === null || logger === void 0 ? void 0 : logger.debug('[NobleBLE] services discovered', {
819
856
  deviceId: peripheral.id,
@@ -930,11 +967,11 @@ function discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId) {
930
967
  deviceId,
931
968
  peripheralState: peripheral.state,
932
969
  attempt: attemptNumber,
933
- maxRetries: 5,
970
+ maxRetries: SERVICE_DISCOVERY_MAX_ATTEMPTS,
934
971
  targetUUIDs: ONEKEY_SERVICE_UUIDS,
935
972
  });
936
973
  if (attemptNumber > 1) {
937
- logger === null || logger === void 0 ? void 0 : logger.info(`[NobleBLE] Service discovery retry attempt ${attemptNumber}/5`);
974
+ logger === null || logger === void 0 ? void 0 : logger.info(`[NobleBLE] Service discovery retry attempt ${attemptNumber}/${SERVICE_DISCOVERY_MAX_ATTEMPTS}`);
938
975
  }
939
976
  if (peripheral.state !== 'connected') {
940
977
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleConnectedError, `Device not connected: ${peripheral.state}`);
@@ -943,16 +980,16 @@ function discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId) {
943
980
  return yield discoverServicesAndCharacteristics(peripheral);
944
981
  }
945
982
  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)`);
983
+ logger === null || logger === void 0 ? void 0 : logger.error(`[NobleBLE] No services found (attempt ${attemptNumber}/${SERVICE_DISCOVERY_MAX_ATTEMPTS})`);
984
+ if (attemptNumber < SERVICE_DISCOVERY_MAX_ATTEMPTS) {
985
+ logger === null || logger === void 0 ? void 0 : logger.error(`[NobleBLE] Will retry service discovery (attempt ${attemptNumber + 1}/${SERVICE_DISCOVERY_MAX_ATTEMPTS})`);
949
986
  }
950
987
  throw error;
951
988
  }
952
989
  }), {
953
- retries: 4,
990
+ retries: SERVICE_DISCOVERY_MAX_ATTEMPTS - 1,
954
991
  factor: 1.5,
955
- minTimeout: 1000,
992
+ minTimeout: 500,
956
993
  maxTimeout: 3000,
957
994
  onFailedAttempt: error => {
958
995
  logger === null || logger === void 0 ? void 0 : logger.error(`[NobleBLE] Service discovery attempt ${error.attemptNumber} failed:`, {
@@ -966,23 +1003,23 @@ function discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId) {
966
1003
  }
967
1004
  function setupConnectionAndDiscoverServices(peripheral, deviceId, webContents) {
968
1005
  return index.__awaiter(this, void 0, void 0, function* () {
969
- setupDisconnectListener(peripheral, deviceId, webContents);
970
1006
  try {
971
- return yield discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1007
+ yield forceReconnectPeripheral(peripheral, deviceId);
972
1008
  }
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
- });
1009
+ catch (resetError) {
1010
+ logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
978
1011
  }
979
- yield forceReconnectPeripheral(peripheral, deviceId);
980
1012
  setupDisconnectListener(peripheral, deviceId, webContents);
981
1013
  try {
982
1014
  return yield discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
983
1015
  }
984
- catch (reconnectError) {
985
- logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed after reconnect, attempting fresh scan...', reconnectError);
1016
+ catch (discoveryError) {
1017
+ if (peripheral.state === 'connected') {
1018
+ logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed on a live link, dropping it for a cold retry', discoveryError);
1019
+ yield disconnectDevice(deviceId).catch(() => undefined);
1020
+ throw discoveryError;
1021
+ }
1022
+ logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed, attempting fresh scan...', discoveryError);
986
1023
  return freshScanAndDiscover(deviceId, webContents);
987
1024
  }
988
1025
  });
@@ -1042,7 +1079,7 @@ function tryDirectConnectById(deviceId) {
1042
1079
  });
1043
1080
  }
1044
1081
  function connectDevice(deviceId, webContents) {
1045
- var _a;
1082
+ var _a, _b, _c;
1046
1083
  return index.__awaiter(this, void 0, void 0, function* () {
1047
1084
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Connect device request:', {
1048
1085
  deviceId,
@@ -1054,34 +1091,47 @@ function connectDevice(deviceId, webContents) {
1054
1091
  });
1055
1092
  let peripheral = (_a = discoveredDevices.get(deviceId)) !== null && _a !== void 0 ? _a : connectedDevices.get(deviceId);
1056
1093
  if (!peripheral) {
1057
- logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Device not discovered, attempting targeted scan for:', deviceId);
1058
1094
  if (!noble) {
1059
1095
  yield initializeNoble();
1060
1096
  }
1061
1097
  if (!noble) {
1062
1098
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'Noble not available');
1063
1099
  }
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`);
1100
+ const byIdFirst = shouldConnectByIdFirst(deviceId);
1101
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Resolving device for cold connect', {
1102
+ deviceId,
1103
+ name: (_b = resolveBleName(deviceId)) !== null && _b !== void 0 ? _b : 'unknown',
1104
+ strategy: byIdFirst ? 'connect-by-id-first' : 'scan-first',
1105
+ });
1106
+ const scanForPeripheral = () => index.__awaiter(this, void 0, void 0, function* () {
1107
+ var _d;
1108
+ try {
1109
+ return (_d = (yield performTargetedScan(deviceId, BLE_COLD_CONNECT_SCAN_TIMEOUT_MS))) !== null && _d !== void 0 ? _d : undefined;
1074
1110
  }
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;
1111
+ catch (scanError) {
1112
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Targeted scan failed', {
1113
+ deviceId,
1114
+ error: scanError instanceof Error ? scanError.message : String(scanError),
1115
+ });
1116
+ return undefined;
1117
+ }
1118
+ });
1119
+ const connectById = () => index.__awaiter(this, void 0, void 0, function* () {
1120
+ const found = yield tryDirectConnectById(deviceId);
1121
+ if (found) {
1122
+ discoveredDevices.set(deviceId, found);
1123
+ }
1124
+ return found;
1125
+ });
1126
+ peripheral = byIdFirst ? yield connectById() : yield scanForPeripheral();
1127
+ if (!peripheral) {
1128
+ peripheral = byIdFirst ? yield scanForPeripheral() : yield connectById();
1080
1129
  }
1081
1130
  }
1082
1131
  if (!peripheral) {
1083
1132
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found`);
1084
1133
  }
1134
+ rememberBleName(deviceId, (_c = peripheral.advertisement) === null || _c === void 0 ? void 0 : _c.localName);
1085
1135
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Connecting to device:', deviceId);
1086
1136
  if (peripheral.state === 'connected') {
1087
1137
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Device already connected, skipping connection step');
@@ -1176,11 +1226,15 @@ function disconnectDevice(deviceId) {
1176
1226
  deviceDisconnectListeners.delete(deviceId);
1177
1227
  }
1178
1228
  yield runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1179
- timeoutMs: BLE_CLEANUP_TIMEOUT,
1229
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
1180
1230
  timeoutBehavior: 'resolve',
1181
1231
  });
1232
+ if (peripheral.state === 'connected') {
1233
+ logger === null || logger === void 0 ? void 0 : logger.warn('[NobleBLE] Physical disconnect did not confirm in time', { deviceId });
1234
+ }
1182
1235
  cleanupDevice(deviceId, undefined, {
1183
1236
  cleanupConnection: true,
1237
+ cleanupDiscoveredCache: true,
1184
1238
  sendDisconnectEvent: false,
1185
1239
  cancelOperations: true,
1186
1240
  reason: 'manual-disconnect',
@@ -1263,6 +1317,11 @@ function subscribeNotifications(deviceId, callback) {
1263
1317
  timeoutBehavior: 'reject',
1264
1318
  });
1265
1319
  notifyCharacteristic.on('data', (data) => {
1320
+ logger === null || logger === void 0 ? void 0 : logger.debug('[NobleBLE] Notify frame', {
1321
+ deviceId,
1322
+ bytes: data.length,
1323
+ head: data.subarray(0, 8).toString('hex'),
1324
+ });
1266
1325
  if (!pairedDevices.has(deviceId)) {
1267
1326
  pairedDevices.add(deviceId);
1268
1327
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Device paired successfully', { deviceId });
@@ -1271,9 +1330,14 @@ function subscribeNotifications(deviceId, callback) {
1271
1330
  });
1272
1331
  });
1273
1332
  }
1333
+ const subscribeStartedAt = Date.now();
1274
1334
  try {
1275
1335
  yield rebuildAppSubscription(deviceId, notifyCharacteristic);
1276
1336
  subscribedDevices.set(deviceId, true);
1337
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Notification subscription active', {
1338
+ deviceId,
1339
+ ms: Date.now() - subscribeStartedAt,
1340
+ });
1277
1341
  }
1278
1342
  finally {
1279
1343
  subscriptionOperations.set(deviceId, 'idle');
@@ -1391,10 +1455,6 @@ function setupNobleBleHandlers(webContents) {
1391
1455
  yield disconnectDevice(deviceId).catch(() => undefined);
1392
1456
  }
1393
1457
  yield stopScanning().catch(() => undefined);
1394
- if (noble && persistentStateListener) {
1395
- noble.removeListener('stateChange', persistentStateListener);
1396
- persistentStateListener = null;
1397
- }
1398
1458
  cleanupNobleListeners();
1399
1459
  discoveredDevices.clear();
1400
1460
  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;AA8vDD,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.0-alpha.176",
3
+ "version": "1.2.0-alpha.178",
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.0-alpha.176",
29
- "@onekeyfe/hd-shared": "1.2.0-alpha.176",
30
- "@onekeyfe/hd-transport": "1.2.0-alpha.176",
28
+ "@onekeyfe/hd-core": "1.2.0-alpha.178",
29
+ "@onekeyfe/hd-shared": "1.2.0-alpha.178",
30
+ "@onekeyfe/hd-transport": "1.2.0-alpha.178",
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": "a5152148317effe811cde8adecdfe75c28e82657"
39
+ "gitHead": "5df24cf8ab1b0e1909e5d9715b15ab042bba3ac7"
40
40
  }
@@ -16,6 +16,7 @@ import {
16
16
  createKnownBleUuidAliases,
17
17
  hasOnekeyCommunicationService,
18
18
  isOnekeyBluetoothDevice,
19
+ isPro2FamilyBleName,
19
20
  matchesKnownBleUuid,
20
21
  wait,
21
22
  } from '@onekeyfe/hd-shared';
@@ -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',
@@ -703,6 +733,17 @@ async function transmitHexDataToDevice(
703
733
  );
704
734
  const pacingDelayMs = resolveNobleBleWritePacingDelay(options);
705
735
 
736
+ // Diagnostics: the frame header alone (report id + magic + message type +
737
+ // length) identifies which protocol frame went out, without logging payload.
738
+ logger?.info('[NobleBLE] Write frame', {
739
+ deviceId,
740
+ bytes: toBuffer.length,
741
+ head: toBuffer.subarray(0, 8).toString('hex'),
742
+ packetCapacity,
743
+ mtu: peripheral.mtu ?? null,
744
+ pacingDelayMs,
745
+ });
746
+
706
747
  if (!IS_WINDOWS || pairedDevices.has(deviceId)) {
707
748
  // macOS / Linux or already paired on Windows: direct write
708
749
  const writeCharacteristic = doGetWriteCharacteristic();
@@ -761,6 +802,44 @@ async function transmitHexDataToDevice(
761
802
  }
762
803
 
763
804
  // Handle discovered device (for general enumeration only)
805
+ // A scan that finds nothing costs this much before the direct-connect
806
+ // fallback runs. Every advertisement in the 6.5.0 control logs arrived within
807
+ // 631ms, so this leaves ~2x headroom while keeping a miss cheap.
808
+ const BLE_COLD_CONNECT_SCAN_TIMEOUT_MS = 1500;
809
+
810
+ // Attempts before a link is written off as unable to serve the OneKey service.
811
+ const SERVICE_DISCOVERY_MAX_ATTEMPTS = 2;
812
+
813
+ // Advertised names, kept for the life of the process: device caches are purged
814
+ // on every disconnect, but the family a device belongs to never changes and
815
+ // decides which connection strategy is safe for it.
816
+ const bleNamesById = new Map<string, string>();
817
+
818
+ function rememberBleName(deviceId: string, name?: string | null): void {
819
+ const trimmed = name?.trim();
820
+ if (trimmed) {
821
+ bleNamesById.set(deviceId, trimmed);
822
+ }
823
+ }
824
+
825
+ function resolveBleName(deviceId: string): string | undefined {
826
+ const live =
827
+ connectedDevices.get(deviceId)?.advertisement?.localName?.trim() ||
828
+ discoveredDevices.get(deviceId)?.advertisement?.localName?.trim();
829
+ return live || bleNamesById.get(deviceId);
830
+ }
831
+
832
+ /**
833
+ * Pro2/Neo connect by id first: they are unaffected by the stale-session
834
+ * problem and advertise under Find My names a OneKey-filtered scan cannot
835
+ * match. Everything else — Classic family, and any device whose name is not
836
+ * known yet — scans first, because connecting by id can resolve a session the
837
+ * device no longer serves (see setupConnectionAndDiscoverServices).
838
+ */
839
+ function shouldConnectByIdFirst(deviceId: string): boolean {
840
+ return isPro2FamilyBleName(resolveBleName(deviceId));
841
+ }
842
+
764
843
  function handleDeviceDiscovered(peripheral: Peripheral): void {
765
844
  // Only process OneKey candidates for general discovery. Avoid logging every
766
845
  // ambient BLE peripheral; it makes Pro2 debugging hard to read.
@@ -770,6 +849,7 @@ function handleDeviceDiscovered(peripheral: Peripheral): void {
770
849
 
771
850
  const isNewDevice = !discoveredDevices.has(peripheral.id);
772
851
  discoveredDevices.set(peripheral.id, peripheral);
852
+ rememberBleName(peripheral.id, peripheral.advertisement?.localName);
773
853
  if (isNewDevice) {
774
854
  logger?.debug('[NobleBLE] OneKey BLE device discovered', {
775
855
  deviceId: peripheral.id,
@@ -804,7 +884,10 @@ async function waitForNobleScanStop(nobleInstance: NobleModule): Promise<void> {
804
884
 
805
885
  // Perform targeted scan for a specific device ID
806
886
  // Uses self-contained local listener pattern - no global state needed
807
- async function performTargetedScan(targetDeviceId: string): Promise<Peripheral | null> {
887
+ async function performTargetedScan(
888
+ targetDeviceId: string,
889
+ timeoutMs: number = NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS
890
+ ): Promise<Peripheral | null> {
808
891
  if (!noble) {
809
892
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
810
893
  }
@@ -849,7 +932,7 @@ async function performTargetedScan(targetDeviceId: string): Promise<Peripheral |
849
932
  const timeoutId = setTimeout(() => {
850
933
  logger?.info('[NobleBLE] Targeted scan timeout for device:', targetDeviceId);
851
934
  finish(null).catch(reject);
852
- }, NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS);
935
+ }, timeoutMs);
853
936
 
854
937
  // Add local listener for this scan
855
938
  nobleInstance.on('discover', onDiscover);
@@ -1051,9 +1134,17 @@ async function discoverServicesAndCharacteristics(
1051
1134
 
1052
1135
  // Main discovery logic as async function
1053
1136
  const discoveryPromise = (async (): Promise<CharacteristicPair> => {
1054
- // Step 1: Discover ALL services (no filter Pro2 may use different service UUID)
1137
+ // Step 1: Discover the OneKey service by UUID. Filtering is not just a
1138
+ // narrowing: an unfiltered discovery is answered from the OS GATT cache, so
1139
+ // a device whose stack still advertises and accepts links but no longer
1140
+ // serves its application layer still looks healthy — the link comes up, the
1141
+ // cached services resolve, the protocol frame goes out and nothing ever
1142
+ // answers. A targeted query returns nothing in that state, which is what
1143
+ // 6.5.0 relied on to trip the recovery path below (retry, reset, fresh
1144
+ // scan) — the sequence that brings such a device back. Pro2/Neo expose the
1145
+ // same service UUID, and the selection below only ever accepts that one.
1055
1146
  const services = await new Promise<Service[]>((resolve, reject) => {
1056
- peripheral.discoverServices([], (error, svc) => {
1147
+ peripheral.discoverServices(ONEKEY_SERVICE_UUIDS, (error, svc) => {
1057
1148
  if (error) {
1058
1149
  logger?.error('[NobleBLE] Service discovery failed:', error);
1059
1150
  reject(ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, error.message));
@@ -1064,7 +1155,7 @@ async function discoverServicesAndCharacteristics(
1064
1155
  });
1065
1156
 
1066
1157
  if (!services || services.length === 0) {
1067
- throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No services found');
1158
+ throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
1068
1159
  }
1069
1160
 
1070
1161
  logger?.debug('[NobleBLE] services discovered', {
@@ -1248,12 +1339,14 @@ async function discoverServicesAndCharacteristicsWithRetry(
1248
1339
  deviceId,
1249
1340
  peripheralState: peripheral.state,
1250
1341
  attempt: attemptNumber,
1251
- maxRetries: 5,
1342
+ maxRetries: SERVICE_DISCOVERY_MAX_ATTEMPTS,
1252
1343
  targetUUIDs: ONEKEY_SERVICE_UUIDS,
1253
1344
  });
1254
1345
 
1255
1346
  if (attemptNumber > 1) {
1256
- logger?.info(`[NobleBLE] Service discovery retry attempt ${attemptNumber}/5`);
1347
+ logger?.info(
1348
+ `[NobleBLE] Service discovery retry attempt ${attemptNumber}/${SERVICE_DISCOVERY_MAX_ATTEMPTS}`
1349
+ );
1257
1350
  }
1258
1351
 
1259
1352
  // Verify connection state before attempting service discovery
@@ -1267,20 +1360,32 @@ async function discoverServicesAndCharacteristicsWithRetry(
1267
1360
  try {
1268
1361
  return await discoverServicesAndCharacteristics(peripheral);
1269
1362
  } catch (error) {
1270
- logger?.error(`[NobleBLE] No services found (attempt ${attemptNumber}/5)`);
1363
+ logger?.error(
1364
+ `[NobleBLE] No services found (attempt ${attemptNumber}/${SERVICE_DISCOVERY_MAX_ATTEMPTS})`
1365
+ );
1271
1366
 
1272
- if (attemptNumber < 5) {
1273
- logger?.error(`[NobleBLE] Will retry service discovery (attempt ${attemptNumber + 1}/5)`);
1367
+ if (attemptNumber < SERVICE_DISCOVERY_MAX_ATTEMPTS) {
1368
+ logger?.error(
1369
+ `[NobleBLE] Will retry service discovery (attempt ${
1370
+ attemptNumber + 1
1371
+ }/${SERVICE_DISCOVERY_MAX_ATTEMPTS})`
1372
+ );
1274
1373
  }
1275
1374
 
1276
1375
  throw error; // p-retry will handle the retry logic
1277
1376
  }
1278
1377
  },
1279
1378
  {
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
1379
+ // One retry, not four. An empty result here is the signature of a device
1380
+ // whose stack no longer serves its application layer, and retrying the
1381
+ // same link has never recovered it — every observed run failed all the
1382
+ // way through. The recovery is the teardown and cold reconnect the caller
1383
+ // performs afterwards, so reaching it sooner is what shortens the wait.
1384
+ // The single retry stays for a genuinely transient miss.
1385
+ retries: SERVICE_DISCOVERY_MAX_ATTEMPTS - 1,
1386
+ factor: 1.5,
1387
+ minTimeout: 500,
1388
+ maxTimeout: 3000,
1284
1389
  onFailedAttempt: error => {
1285
1390
  // This runs after each failed attempt
1286
1391
  logger?.error(`[NobleBLE] Service discovery attempt ${error.attemptNumber} failed:`, {
@@ -1305,26 +1410,42 @@ async function setupConnectionAndDiscoverServices(
1305
1410
  deviceId: string,
1306
1411
  webContents: WebContents
1307
1412
  ): Promise<CharacteristicPair> {
1308
- setupDisconnectListener(peripheral, deviceId, webContents);
1309
-
1413
+ // Reset the link before any protocol traffic reaches the device. 6.5.0 did
1414
+ // this on every cold setup and never produced a protocol-deaf Classic 1S
1415
+ // (control log 2026-08-20: 16/16 links answered within ~1s, including after
1416
+ // a 15-minute idle). Demoting it to a discovery-failure fallback removed the
1417
+ // reset entirely for this device, because discovery always succeeds: the link
1418
+ // comes up, GATT resolves from cache, and the device then answers nothing on
1419
+ // a session it no longer serves. Costs ~1.3s per cold setup; a kept-alive
1420
+ // link with an intact subscription returns before reaching here, so reuse
1421
+ // inside a workflow is unaffected.
1310
1422
  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
- });
1423
+ await forceReconnectPeripheral(peripheral, deviceId);
1424
+ } catch (resetError) {
1425
+ // A failed reset must not abort the attempt: discovery on the existing
1426
+ // link, then the fresh-scan fallback, still have a chance to recover.
1427
+ logger?.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
1317
1428
  }
1318
-
1319
- await forceReconnectPeripheral(peripheral, deviceId);
1320
1429
  setupDisconnectListener(peripheral, deviceId, webContents);
1430
+
1321
1431
  try {
1322
1432
  return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1323
- } catch (reconnectError) {
1324
- logger?.error(
1325
- '[NobleBLE] Service discovery failed after reconnect, attempting fresh scan...',
1326
- reconnectError
1327
- );
1433
+ } catch (discoveryError) {
1434
+ // A connected peripheral does not advertise, so the fresh scan below can
1435
+ // only run out its timeout. Drop the link instead and let the caller come
1436
+ // back with a cold connect: that reconnect is what recovers a device whose
1437
+ // stack stopped serving its application layer, and it starts seconds
1438
+ // earlier this way. The scan still runs when the link is already down,
1439
+ // which is the case it was written for.
1440
+ if (peripheral.state === 'connected') {
1441
+ logger?.error(
1442
+ '[NobleBLE] Service discovery failed on a live link, dropping it for a cold retry',
1443
+ discoveryError
1444
+ );
1445
+ await disconnectDevice(deviceId).catch(() => undefined);
1446
+ throw discoveryError;
1447
+ }
1448
+ logger?.error('[NobleBLE] Service discovery failed, attempting fresh scan...', discoveryError);
1328
1449
  return freshScanAndDiscover(deviceId, webContents);
1329
1450
  }
1330
1451
  }
@@ -1413,10 +1534,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1413
1534
  // enumerate clears the discovery map; a kept-alive link outlives it.
1414
1535
  let peripheral = discoveredDevices.get(deviceId) ?? connectedDevices.get(deviceId);
1415
1536
 
1416
- // If device not discovered, try a targeted scan for this specific device
1417
1537
  if (!peripheral) {
1418
- logger?.info('[NobleBLE] Device not discovered, attempting targeted scan for:', deviceId);
1419
-
1420
1538
  // Initialize Noble if not already done
1421
1539
  if (!noble) {
1422
1540
  await initializeNoble();
@@ -1426,27 +1544,46 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1426
1544
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
1427
1545
  }
1428
1546
 
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
- }
1547
+ // A live advertisement proves the device holds no link and will open a
1548
+ // fresh session; connecting by id resolves through the OS cache instead
1549
+ // and can hand back a session the device no longer serves (see
1550
+ // setupConnectionAndDiscoverServices). Scanning is also the faster of the
1551
+ // two when the device does advertise: ~96ms median against ~784ms for
1552
+ // connect-by-id across the field logs. Pro2/Neo keep connect-by-id first
1553
+ // because they are unaffected and may advertise under an unmatchable Find
1554
+ // My name.
1555
+ const byIdFirst = shouldConnectByIdFirst(deviceId);
1556
+ logger?.info('[NobleBLE] Resolving device for cold connect', {
1557
+ deviceId,
1558
+ name: resolveBleName(deviceId) ?? 'unknown',
1559
+ strategy: byIdFirst ? 'connect-by-id-first' : 'scan-first',
1560
+ });
1435
1561
 
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
- );
1562
+ const scanForPeripheral = async () => {
1563
+ try {
1564
+ return (await performTargetedScan(deviceId, BLE_COLD_CONNECT_SCAN_TIMEOUT_MS)) ?? undefined;
1565
+ } catch (scanError) {
1566
+ logger?.info('[NobleBLE] Targeted scan failed', {
1567
+ deviceId,
1568
+ error: scanError instanceof Error ? scanError.message : String(scanError),
1569
+ });
1570
+ return undefined;
1571
+ }
1572
+ };
1573
+
1574
+ const connectById = async () => {
1575
+ const found = await tryDirectConnectById(deviceId);
1576
+ if (found) {
1577
+ discoveredDevices.set(deviceId, found);
1445
1578
  }
1446
- peripheral = foundPeripheral;
1447
- } catch (error) {
1448
- logger?.error('[NobleBLE] Targeted scan failed:', error);
1449
- throw error;
1579
+ return found;
1580
+ };
1581
+
1582
+ peripheral = byIdFirst ? await connectById() : await scanForPeripheral();
1583
+ if (!peripheral) {
1584
+ // The preferred route came up empty: not advertising (held elsewhere, or
1585
+ // silent), or not reachable by id. Try the other one before giving up.
1586
+ peripheral = byIdFirst ? await scanForPeripheral() : await connectById();
1450
1587
  }
1451
1588
  }
1452
1589
 
@@ -1455,6 +1592,10 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1455
1592
  throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found`);
1456
1593
  }
1457
1594
 
1595
+ // Also covers the connect-by-id route, whose simulated discovery carries no
1596
+ // service UUIDs and therefore never reaches handleDeviceDiscovered.
1597
+ rememberBleName(deviceId, peripheral.advertisement?.localName);
1598
+
1458
1599
  logger?.info('[NobleBLE] Connecting to device:', deviceId);
1459
1600
 
1460
1601
  // Check if device is already connected
@@ -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',
@@ -1732,6 +1880,11 @@ async function subscribeNotifications(
1732
1880
  });
1733
1881
 
1734
1882
  notifyCharacteristic.on('data', (data: Buffer) => {
1883
+ logger?.debug('[NobleBLE] Notify frame', {
1884
+ deviceId,
1885
+ bytes: data.length,
1886
+ head: data.subarray(0, 8).toString('hex'),
1887
+ });
1735
1888
  // Windows BLE pairing detection: receiving any data means device is paired
1736
1889
  if (!pairedDevices.has(deviceId)) {
1737
1890
  pairedDevices.add(deviceId);
@@ -1742,9 +1895,14 @@ async function subscribeNotifications(
1742
1895
  });
1743
1896
  }
1744
1897
 
1898
+ const subscribeStartedAt = Date.now();
1745
1899
  try {
1746
1900
  await rebuildAppSubscription(deviceId, notifyCharacteristic);
1747
1901
  subscribedDevices.set(deviceId, true);
1902
+ logger?.info('[NobleBLE] Notification subscription active', {
1903
+ deviceId,
1904
+ ms: Date.now() - subscribeStartedAt,
1905
+ });
1748
1906
  } finally {
1749
1907
  // 🔒 CRITICAL: Always clear operation state (even on error)
1750
1908
  subscriptionOperations.set(deviceId, 'idle');
@@ -1927,10 +2085,12 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1927
2085
  }
1928
2086
 
1929
2087
  await stopScanning().catch(() => undefined);
1930
- if (noble && persistentStateListener) {
1931
- noble.removeListener('stateChange', persistentStateListener);
1932
- persistentStateListener = null;
1933
- }
2088
+ // persistentStateListener is process-lifetime, NOT per-window: this
2089
+ // destroy handler also fires on a renderer soft restart, and removing
2090
+ // the listener here permanently killed poweredOff/poweredOn
2091
+ // reconciliation for the rest of the process (nothing re-registers it:
2092
+ // initializeNoble early-returns once noble is loaded). The null-guard
2093
+ // in setupPersistentStateListener keeps it deduped to one instance.
1934
2094
  cleanupNobleListeners();
1935
2095
  discoveredDevices.clear();
1936
2096
  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