@onekeyfe/hd-transport-electron 1.2.0-alpha.172 → 1.2.0-alpha.174

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-0fb76826.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-2b9e4f42.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-2b9e4f42.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,36 @@ function transmitHexDataToDevice(deviceId, hexData, options) {
590
608
  }
591
609
  });
592
610
  }
611
+ const BLE_COLD_CONNECT_SCAN_TIMEOUT_MS = 1500;
612
+ const bleNamesById = new Map();
613
+ function rememberBleName(deviceId, name) {
614
+ const trimmed = name === null || name === void 0 ? void 0 : name.trim();
615
+ if (trimmed) {
616
+ bleNamesById.set(deviceId, trimmed);
617
+ }
618
+ }
619
+ function resolveBleName(deviceId) {
620
+ var _a, _b, _c, _d, _e, _f;
621
+ 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()) ||
622
+ ((_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());
623
+ return live || bleNamesById.get(deviceId);
624
+ }
625
+ function shouldConnectByIdFirst(deviceId) {
626
+ return hdShared.isPro2FamilyBleName(resolveBleName(deviceId));
627
+ }
593
628
  function handleDeviceDiscovered(peripheral) {
594
- var _a, _b;
629
+ var _a, _b, _c;
595
630
  if (!isOneKeyPeripheral(peripheral)) {
596
631
  return;
597
632
  }
598
633
  const isNewDevice = !discoveredDevices.has(peripheral.id);
599
634
  discoveredDevices.set(peripheral.id, peripheral);
635
+ rememberBleName(peripheral.id, (_a = peripheral.advertisement) === null || _a === void 0 ? void 0 : _a.localName);
600
636
  if (isNewDevice) {
601
637
  logger === null || logger === void 0 ? void 0 : logger.debug('[NobleBLE] OneKey BLE device discovered', {
602
638
  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) || [],
639
+ name: ((_b = peripheral.advertisement) === null || _b === void 0 ? void 0 : _b.localName) || 'Unknown Device',
640
+ serviceUUIDs: ((_c = peripheral.advertisement) === null || _c === void 0 ? void 0 : _c.serviceUuids) || [],
605
641
  });
606
642
  }
607
643
  }
@@ -627,7 +663,7 @@ function waitForNobleScanStop(nobleInstance) {
627
663
  });
628
664
  });
629
665
  }
630
- function performTargetedScan(targetDeviceId) {
666
+ function performTargetedScan(targetDeviceId, timeoutMs = NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS) {
631
667
  return index.__awaiter(this, void 0, void 0, function* () {
632
668
  if (!noble) {
633
669
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'Noble not available');
@@ -667,7 +703,7 @@ function performTargetedScan(targetDeviceId) {
667
703
  const timeoutId = setTimeout(() => {
668
704
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Targeted scan timeout for device:', targetDeviceId);
669
705
  finish(null).catch(reject);
670
- }, NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS);
706
+ }, timeoutMs);
671
707
  nobleInstance.on('discover', onDiscover);
672
708
  nobleInstance.startScanning([], true, (error) => {
673
709
  if (error) {
@@ -802,7 +838,7 @@ function discoverServicesAndCharacteristics(peripheral) {
802
838
  });
803
839
  const discoveryPromise = (() => index.__awaiter(this, void 0, void 0, function* () {
804
840
  const services = yield new Promise((resolve, reject) => {
805
- peripheral.discoverServices([], (error, svc) => {
841
+ peripheral.discoverServices(ONEKEY_SERVICE_UUIDS, (error, svc) => {
806
842
  if (error) {
807
843
  logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed:', error);
808
844
  reject(hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleServiceNotFound, error.message));
@@ -813,7 +849,7 @@ function discoverServicesAndCharacteristics(peripheral) {
813
849
  });
814
850
  });
815
851
  if (!services || services.length === 0) {
816
- throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleServiceNotFound, 'No services found');
852
+ throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
817
853
  }
818
854
  logger === null || logger === void 0 ? void 0 : logger.debug('[NobleBLE] services discovered', {
819
855
  deviceId: peripheral.id,
@@ -966,23 +1002,18 @@ function discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId) {
966
1002
  }
967
1003
  function setupConnectionAndDiscoverServices(peripheral, deviceId, webContents) {
968
1004
  return index.__awaiter(this, void 0, void 0, function* () {
969
- setupDisconnectListener(peripheral, deviceId, webContents);
970
1005
  try {
971
- return yield discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1006
+ yield forceReconnectPeripheral(peripheral, deviceId);
972
1007
  }
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
- });
1008
+ catch (resetError) {
1009
+ logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
978
1010
  }
979
- yield forceReconnectPeripheral(peripheral, deviceId);
980
1011
  setupDisconnectListener(peripheral, deviceId, webContents);
981
1012
  try {
982
1013
  return yield discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
983
1014
  }
984
- catch (reconnectError) {
985
- logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed after reconnect, attempting fresh scan...', reconnectError);
1015
+ catch (discoveryError) {
1016
+ logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed, attempting fresh scan...', discoveryError);
986
1017
  return freshScanAndDiscover(deviceId, webContents);
987
1018
  }
988
1019
  });
@@ -1042,7 +1073,7 @@ function tryDirectConnectById(deviceId) {
1042
1073
  });
1043
1074
  }
1044
1075
  function connectDevice(deviceId, webContents) {
1045
- var _a;
1076
+ var _a, _b, _c;
1046
1077
  return index.__awaiter(this, void 0, void 0, function* () {
1047
1078
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Connect device request:', {
1048
1079
  deviceId,
@@ -1054,34 +1085,47 @@ function connectDevice(deviceId, webContents) {
1054
1085
  });
1055
1086
  let peripheral = (_a = discoveredDevices.get(deviceId)) !== null && _a !== void 0 ? _a : connectedDevices.get(deviceId);
1056
1087
  if (!peripheral) {
1057
- logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Device not discovered, attempting targeted scan for:', deviceId);
1058
1088
  if (!noble) {
1059
1089
  yield initializeNoble();
1060
1090
  }
1061
1091
  if (!noble) {
1062
1092
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'Noble not available');
1063
1093
  }
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`);
1094
+ const byIdFirst = shouldConnectByIdFirst(deviceId);
1095
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Resolving device for cold connect', {
1096
+ deviceId,
1097
+ name: (_b = resolveBleName(deviceId)) !== null && _b !== void 0 ? _b : 'unknown',
1098
+ strategy: byIdFirst ? 'connect-by-id-first' : 'scan-first',
1099
+ });
1100
+ const scanForPeripheral = () => index.__awaiter(this, void 0, void 0, function* () {
1101
+ var _d;
1102
+ try {
1103
+ return (_d = (yield performTargetedScan(deviceId, BLE_COLD_CONNECT_SCAN_TIMEOUT_MS))) !== null && _d !== void 0 ? _d : undefined;
1074
1104
  }
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;
1105
+ catch (scanError) {
1106
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Targeted scan failed', {
1107
+ deviceId,
1108
+ error: scanError instanceof Error ? scanError.message : String(scanError),
1109
+ });
1110
+ return undefined;
1111
+ }
1112
+ });
1113
+ const connectById = () => index.__awaiter(this, void 0, void 0, function* () {
1114
+ const found = yield tryDirectConnectById(deviceId);
1115
+ if (found) {
1116
+ discoveredDevices.set(deviceId, found);
1117
+ }
1118
+ return found;
1119
+ });
1120
+ peripheral = byIdFirst ? yield connectById() : yield scanForPeripheral();
1121
+ if (!peripheral) {
1122
+ peripheral = byIdFirst ? yield scanForPeripheral() : yield connectById();
1080
1123
  }
1081
1124
  }
1082
1125
  if (!peripheral) {
1083
1126
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found`);
1084
1127
  }
1128
+ rememberBleName(deviceId, (_c = peripheral.advertisement) === null || _c === void 0 ? void 0 : _c.localName);
1085
1129
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Connecting to device:', deviceId);
1086
1130
  if (peripheral.state === 'connected') {
1087
1131
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Device already connected, skipping connection step');
@@ -1176,11 +1220,15 @@ function disconnectDevice(deviceId) {
1176
1220
  deviceDisconnectListeners.delete(deviceId);
1177
1221
  }
1178
1222
  yield runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1179
- timeoutMs: BLE_CLEANUP_TIMEOUT,
1223
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
1180
1224
  timeoutBehavior: 'resolve',
1181
1225
  });
1226
+ if (peripheral.state === 'connected') {
1227
+ logger === null || logger === void 0 ? void 0 : logger.warn('[NobleBLE] Physical disconnect did not confirm in time', { deviceId });
1228
+ }
1182
1229
  cleanupDevice(deviceId, undefined, {
1183
1230
  cleanupConnection: true,
1231
+ cleanupDiscoveredCache: true,
1184
1232
  sendDisconnectEvent: false,
1185
1233
  cancelOperations: true,
1186
1234
  reason: 'manual-disconnect',
@@ -1263,6 +1311,11 @@ function subscribeNotifications(deviceId, callback) {
1263
1311
  timeoutBehavior: 'reject',
1264
1312
  });
1265
1313
  notifyCharacteristic.on('data', (data) => {
1314
+ logger === null || logger === void 0 ? void 0 : logger.debug('[NobleBLE] Notify frame', {
1315
+ deviceId,
1316
+ bytes: data.length,
1317
+ head: data.subarray(0, 8).toString('hex'),
1318
+ });
1266
1319
  if (!pairedDevices.has(deviceId)) {
1267
1320
  pairedDevices.add(deviceId);
1268
1321
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Device paired successfully', { deviceId });
@@ -1271,9 +1324,14 @@ function subscribeNotifications(deviceId, callback) {
1271
1324
  });
1272
1325
  });
1273
1326
  }
1327
+ const subscribeStartedAt = Date.now();
1274
1328
  try {
1275
1329
  yield rebuildAppSubscription(deviceId, notifyCharacteristic);
1276
1330
  subscribedDevices.set(deviceId, true);
1331
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Notification subscription active', {
1332
+ deviceId,
1333
+ ms: Date.now() - subscribeStartedAt,
1334
+ });
1277
1335
  }
1278
1336
  finally {
1279
1337
  subscriptionOperations.set(deviceId, 'idle');
@@ -1391,10 +1449,6 @@ function setupNobleBleHandlers(webContents) {
1391
1449
  yield disconnectDevice(deviceId).catch(() => undefined);
1392
1450
  }
1393
1451
  yield stopScanning().catch(() => undefined);
1394
- if (noble && persistentStateListener) {
1395
- noble.removeListener('stateChange', persistentStateListener);
1396
- persistentStateListener = null;
1397
- }
1398
1452
  cleanupNobleListeners();
1399
1453
  discoveredDevices.clear();
1400
1454
  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;AA+tDD,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.172",
3
+ "version": "1.2.0-alpha.174",
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.172",
29
- "@onekeyfe/hd-shared": "1.2.0-alpha.172",
30
- "@onekeyfe/hd-transport": "1.2.0-alpha.172",
28
+ "@onekeyfe/hd-core": "1.2.0-alpha.174",
29
+ "@onekeyfe/hd-shared": "1.2.0-alpha.174",
30
+ "@onekeyfe/hd-transport": "1.2.0-alpha.174",
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": "4c6c4a4fcc52bcdd0b3ec9c36454b2074fa17a8c"
39
+ "gitHead": "ea906ff2ef191338cbc98d083e8217ddfb23c92c"
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,41 @@ 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
+ // Advertised names, kept for the life of the process: device caches are purged
811
+ // on every disconnect, but the family a device belongs to never changes and
812
+ // decides which connection strategy is safe for it.
813
+ const bleNamesById = new Map<string, string>();
814
+
815
+ function rememberBleName(deviceId: string, name?: string | null): void {
816
+ const trimmed = name?.trim();
817
+ if (trimmed) {
818
+ bleNamesById.set(deviceId, trimmed);
819
+ }
820
+ }
821
+
822
+ function resolveBleName(deviceId: string): string | undefined {
823
+ const live =
824
+ connectedDevices.get(deviceId)?.advertisement?.localName?.trim() ||
825
+ discoveredDevices.get(deviceId)?.advertisement?.localName?.trim();
826
+ return live || bleNamesById.get(deviceId);
827
+ }
828
+
829
+ /**
830
+ * Pro2/Neo connect by id first: they are unaffected by the stale-session
831
+ * problem and advertise under Find My names a OneKey-filtered scan cannot
832
+ * match. Everything else — Classic family, and any device whose name is not
833
+ * known yet — scans first, because connecting by id can resolve a session the
834
+ * device no longer serves (see setupConnectionAndDiscoverServices).
835
+ */
836
+ function shouldConnectByIdFirst(deviceId: string): boolean {
837
+ return isPro2FamilyBleName(resolveBleName(deviceId));
838
+ }
839
+
764
840
  function handleDeviceDiscovered(peripheral: Peripheral): void {
765
841
  // Only process OneKey candidates for general discovery. Avoid logging every
766
842
  // ambient BLE peripheral; it makes Pro2 debugging hard to read.
@@ -770,6 +846,7 @@ function handleDeviceDiscovered(peripheral: Peripheral): void {
770
846
 
771
847
  const isNewDevice = !discoveredDevices.has(peripheral.id);
772
848
  discoveredDevices.set(peripheral.id, peripheral);
849
+ rememberBleName(peripheral.id, peripheral.advertisement?.localName);
773
850
  if (isNewDevice) {
774
851
  logger?.debug('[NobleBLE] OneKey BLE device discovered', {
775
852
  deviceId: peripheral.id,
@@ -804,7 +881,10 @@ async function waitForNobleScanStop(nobleInstance: NobleModule): Promise<void> {
804
881
 
805
882
  // Perform targeted scan for a specific device ID
806
883
  // Uses self-contained local listener pattern - no global state needed
807
- async function performTargetedScan(targetDeviceId: string): Promise<Peripheral | null> {
884
+ async function performTargetedScan(
885
+ targetDeviceId: string,
886
+ timeoutMs: number = NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS
887
+ ): Promise<Peripheral | null> {
808
888
  if (!noble) {
809
889
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
810
890
  }
@@ -849,7 +929,7 @@ async function performTargetedScan(targetDeviceId: string): Promise<Peripheral |
849
929
  const timeoutId = setTimeout(() => {
850
930
  logger?.info('[NobleBLE] Targeted scan timeout for device:', targetDeviceId);
851
931
  finish(null).catch(reject);
852
- }, NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS);
932
+ }, timeoutMs);
853
933
 
854
934
  // Add local listener for this scan
855
935
  nobleInstance.on('discover', onDiscover);
@@ -1051,9 +1131,17 @@ async function discoverServicesAndCharacteristics(
1051
1131
 
1052
1132
  // Main discovery logic as async function
1053
1133
  const discoveryPromise = (async (): Promise<CharacteristicPair> => {
1054
- // Step 1: Discover ALL services (no filter Pro2 may use different service UUID)
1134
+ // Step 1: Discover the OneKey service by UUID. Filtering is not just a
1135
+ // narrowing: an unfiltered discovery is answered from the OS GATT cache, so
1136
+ // a device whose stack still advertises and accepts links but no longer
1137
+ // serves its application layer still looks healthy — the link comes up, the
1138
+ // cached services resolve, the protocol frame goes out and nothing ever
1139
+ // answers. A targeted query returns nothing in that state, which is what
1140
+ // 6.5.0 relied on to trip the recovery path below (retry, reset, fresh
1141
+ // scan) — the sequence that brings such a device back. Pro2/Neo expose the
1142
+ // same service UUID, and the selection below only ever accepts that one.
1055
1143
  const services = await new Promise<Service[]>((resolve, reject) => {
1056
- peripheral.discoverServices([], (error, svc) => {
1144
+ peripheral.discoverServices(ONEKEY_SERVICE_UUIDS, (error, svc) => {
1057
1145
  if (error) {
1058
1146
  logger?.error('[NobleBLE] Service discovery failed:', error);
1059
1147
  reject(ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, error.message));
@@ -1064,7 +1152,7 @@ async function discoverServicesAndCharacteristics(
1064
1152
  });
1065
1153
 
1066
1154
  if (!services || services.length === 0) {
1067
- throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No services found');
1155
+ throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
1068
1156
  }
1069
1157
 
1070
1158
  logger?.debug('[NobleBLE] services discovered', {
@@ -1305,26 +1393,28 @@ async function setupConnectionAndDiscoverServices(
1305
1393
  deviceId: string,
1306
1394
  webContents: WebContents
1307
1395
  ): Promise<CharacteristicPair> {
1308
- setupDisconnectListener(peripheral, deviceId, webContents);
1309
-
1396
+ // Reset the link before any protocol traffic reaches the device. 6.5.0 did
1397
+ // this on every cold setup and never produced a protocol-deaf Classic 1S
1398
+ // (control log 2026-08-20: 16/16 links answered within ~1s, including after
1399
+ // a 15-minute idle). Demoting it to a discovery-failure fallback removed the
1400
+ // reset entirely for this device, because discovery always succeeds: the link
1401
+ // comes up, GATT resolves from cache, and the device then answers nothing on
1402
+ // a session it no longer serves. Costs ~1.3s per cold setup; a kept-alive
1403
+ // link with an intact subscription returns before reaching here, so reuse
1404
+ // inside a workflow is unaffected.
1310
1405
  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
- });
1406
+ await forceReconnectPeripheral(peripheral, deviceId);
1407
+ } catch (resetError) {
1408
+ // A failed reset must not abort the attempt: discovery on the existing
1409
+ // link, then the fresh-scan fallback, still have a chance to recover.
1410
+ logger?.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
1317
1411
  }
1318
-
1319
- await forceReconnectPeripheral(peripheral, deviceId);
1320
1412
  setupDisconnectListener(peripheral, deviceId, webContents);
1413
+
1321
1414
  try {
1322
1415
  return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1323
- } catch (reconnectError) {
1324
- logger?.error(
1325
- '[NobleBLE] Service discovery failed after reconnect, attempting fresh scan...',
1326
- reconnectError
1327
- );
1416
+ } catch (discoveryError) {
1417
+ logger?.error('[NobleBLE] Service discovery failed, attempting fresh scan...', discoveryError);
1328
1418
  return freshScanAndDiscover(deviceId, webContents);
1329
1419
  }
1330
1420
  }
@@ -1413,10 +1503,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1413
1503
  // enumerate clears the discovery map; a kept-alive link outlives it.
1414
1504
  let peripheral = discoveredDevices.get(deviceId) ?? connectedDevices.get(deviceId);
1415
1505
 
1416
- // If device not discovered, try a targeted scan for this specific device
1417
1506
  if (!peripheral) {
1418
- logger?.info('[NobleBLE] Device not discovered, attempting targeted scan for:', deviceId);
1419
-
1420
1507
  // Initialize Noble if not already done
1421
1508
  if (!noble) {
1422
1509
  await initializeNoble();
@@ -1426,27 +1513,46 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1426
1513
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
1427
1514
  }
1428
1515
 
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
- }
1516
+ // A live advertisement proves the device holds no link and will open a
1517
+ // fresh session; connecting by id resolves through the OS cache instead
1518
+ // and can hand back a session the device no longer serves (see
1519
+ // setupConnectionAndDiscoverServices). Scanning is also the faster of the
1520
+ // two when the device does advertise: ~96ms median against ~784ms for
1521
+ // connect-by-id across the field logs. Pro2/Neo keep connect-by-id first
1522
+ // because they are unaffected and may advertise under an unmatchable Find
1523
+ // My name.
1524
+ const byIdFirst = shouldConnectByIdFirst(deviceId);
1525
+ logger?.info('[NobleBLE] Resolving device for cold connect', {
1526
+ deviceId,
1527
+ name: resolveBleName(deviceId) ?? 'unknown',
1528
+ strategy: byIdFirst ? 'connect-by-id-first' : 'scan-first',
1529
+ });
1435
1530
 
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
- );
1531
+ const scanForPeripheral = async () => {
1532
+ try {
1533
+ return (await performTargetedScan(deviceId, BLE_COLD_CONNECT_SCAN_TIMEOUT_MS)) ?? undefined;
1534
+ } catch (scanError) {
1535
+ logger?.info('[NobleBLE] Targeted scan failed', {
1536
+ deviceId,
1537
+ error: scanError instanceof Error ? scanError.message : String(scanError),
1538
+ });
1539
+ return undefined;
1540
+ }
1541
+ };
1542
+
1543
+ const connectById = async () => {
1544
+ const found = await tryDirectConnectById(deviceId);
1545
+ if (found) {
1546
+ discoveredDevices.set(deviceId, found);
1445
1547
  }
1446
- peripheral = foundPeripheral;
1447
- } catch (error) {
1448
- logger?.error('[NobleBLE] Targeted scan failed:', error);
1449
- throw error;
1548
+ return found;
1549
+ };
1550
+
1551
+ peripheral = byIdFirst ? await connectById() : await scanForPeripheral();
1552
+ if (!peripheral) {
1553
+ // The preferred route came up empty: not advertising (held elsewhere, or
1554
+ // silent), or not reachable by id. Try the other one before giving up.
1555
+ peripheral = byIdFirst ? await scanForPeripheral() : await connectById();
1450
1556
  }
1451
1557
  }
1452
1558
 
@@ -1455,6 +1561,10 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1455
1561
  throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found`);
1456
1562
  }
1457
1563
 
1564
+ // Also covers the connect-by-id route, whose simulated discovery carries no
1565
+ // service UUIDs and therefore never reaches handleDeviceDiscovered.
1566
+ rememberBleName(deviceId, peripheral.advertisement?.localName);
1567
+
1458
1568
  logger?.info('[NobleBLE] Connecting to device:', deviceId);
1459
1569
 
1460
1570
  // Check if device is already connected
@@ -1597,11 +1707,18 @@ async function disconnectDevice(deviceId: string): Promise<void> {
1597
1707
  }
1598
1708
 
1599
1709
  await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1600
- timeoutMs: BLE_CLEANUP_TIMEOUT,
1710
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
1601
1711
  timeoutBehavior: 'resolve',
1602
1712
  });
1713
+ if (peripheral.state === 'connected') {
1714
+ logger?.warn('[NobleBLE] Physical disconnect did not confirm in time', { deviceId });
1715
+ }
1603
1716
  cleanupDevice(deviceId, undefined, {
1604
1717
  cleanupConnection: true,
1718
+ // macOS returns zero GATT services when a previously-disconnected
1719
+ // peripheral object is reconnected; drop the discovery cache so the next
1720
+ // connect resolves a fresh peripheral (direct connect by id, or scan).
1721
+ cleanupDiscoveredCache: true,
1605
1722
  sendDisconnectEvent: false,
1606
1723
  cancelOperations: true,
1607
1724
  reason: 'manual-disconnect',
@@ -1732,6 +1849,11 @@ async function subscribeNotifications(
1732
1849
  });
1733
1850
 
1734
1851
  notifyCharacteristic.on('data', (data: Buffer) => {
1852
+ logger?.debug('[NobleBLE] Notify frame', {
1853
+ deviceId,
1854
+ bytes: data.length,
1855
+ head: data.subarray(0, 8).toString('hex'),
1856
+ });
1735
1857
  // Windows BLE pairing detection: receiving any data means device is paired
1736
1858
  if (!pairedDevices.has(deviceId)) {
1737
1859
  pairedDevices.add(deviceId);
@@ -1742,9 +1864,14 @@ async function subscribeNotifications(
1742
1864
  });
1743
1865
  }
1744
1866
 
1867
+ const subscribeStartedAt = Date.now();
1745
1868
  try {
1746
1869
  await rebuildAppSubscription(deviceId, notifyCharacteristic);
1747
1870
  subscribedDevices.set(deviceId, true);
1871
+ logger?.info('[NobleBLE] Notification subscription active', {
1872
+ deviceId,
1873
+ ms: Date.now() - subscribeStartedAt,
1874
+ });
1748
1875
  } finally {
1749
1876
  // 🔒 CRITICAL: Always clear operation state (even on error)
1750
1877
  subscriptionOperations.set(deviceId, 'idle');
@@ -1927,10 +2054,12 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1927
2054
  }
1928
2055
 
1929
2056
  await stopScanning().catch(() => undefined);
1930
- if (noble && persistentStateListener) {
1931
- noble.removeListener('stateChange', persistentStateListener);
1932
- persistentStateListener = null;
1933
- }
2057
+ // persistentStateListener is process-lifetime, NOT per-window: this
2058
+ // destroy handler also fires on a renderer soft restart, and removing
2059
+ // the listener here permanently killed poweredOff/poweredOn
2060
+ // reconciliation for the rest of the process (nothing re-registers it:
2061
+ // initializeNoble early-returns once noble is loaded). The null-guard
2062
+ // in setupPersistentStateListener keeps it deduped to one instance.
1934
2063
  cleanupNobleListeners();
1935
2064
  discoveredDevices.clear();
1936
2065
  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