@onekeyfe/hd-transport-electron 1.2.0-alpha.162 → 1.2.0-alpha.163

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-8b4037fe.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-b18020df.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-b18020df.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',
@@ -966,23 +975,18 @@ function discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId) {
966
975
  }
967
976
  function setupConnectionAndDiscoverServices(peripheral, deviceId, webContents) {
968
977
  return index.__awaiter(this, void 0, void 0, function* () {
969
- setupDisconnectListener(peripheral, deviceId, webContents);
970
978
  try {
971
- return yield discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
979
+ yield forceReconnectPeripheral(peripheral, deviceId);
972
980
  }
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
- });
981
+ catch (resetError) {
982
+ logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
978
983
  }
979
- yield forceReconnectPeripheral(peripheral, deviceId);
980
984
  setupDisconnectListener(peripheral, deviceId, webContents);
981
985
  try {
982
986
  return yield discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
983
987
  }
984
- catch (reconnectError) {
985
- logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed after reconnect, attempting fresh scan...', reconnectError);
988
+ catch (discoveryError) {
989
+ logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed, attempting fresh scan...', discoveryError);
986
990
  return freshScanAndDiscover(deviceId, webContents);
987
991
  }
988
992
  });
@@ -1042,7 +1046,7 @@ function tryDirectConnectById(deviceId) {
1042
1046
  });
1043
1047
  }
1044
1048
  function connectDevice(deviceId, webContents) {
1045
- var _a;
1049
+ var _a, _b;
1046
1050
  return index.__awaiter(this, void 0, void 0, function* () {
1047
1051
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Connect device request:', {
1048
1052
  deviceId,
@@ -1054,29 +1058,28 @@ function connectDevice(deviceId, webContents) {
1054
1058
  });
1055
1059
  let peripheral = (_a = discoveredDevices.get(deviceId)) !== null && _a !== void 0 ? _a : connectedDevices.get(deviceId);
1056
1060
  if (!peripheral) {
1057
- logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Device not discovered, attempting targeted scan for:', deviceId);
1058
1061
  if (!noble) {
1059
1062
  yield initializeNoble();
1060
1063
  }
1061
1064
  if (!noble) {
1062
1065
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'Noble not available');
1063
1066
  }
1064
- peripheral = yield tryDirectConnectById(deviceId);
1065
- if (peripheral) {
1066
- discoveredDevices.set(deviceId, peripheral);
1067
- }
1068
- }
1069
- if (!peripheral) {
1067
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Device not cached, scanning for:', deviceId);
1070
1068
  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`);
1074
- }
1075
- peripheral = foundPeripheral;
1069
+ peripheral = (_b = (yield performTargetedScan(deviceId))) !== null && _b !== void 0 ? _b : undefined;
1076
1070
  }
1077
- catch (error) {
1078
- logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Targeted scan failed:', error);
1079
- throw error;
1071
+ catch (scanError) {
1072
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Targeted scan failed, falling back to direct connect', {
1073
+ deviceId,
1074
+ error: scanError instanceof Error ? scanError.message : String(scanError),
1075
+ });
1076
+ }
1077
+ if (!peripheral) {
1078
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Device not advertising, attempting direct connect by id:', deviceId);
1079
+ peripheral = yield tryDirectConnectById(deviceId);
1080
+ if (peripheral) {
1081
+ discoveredDevices.set(deviceId, peripheral);
1082
+ }
1080
1083
  }
1081
1084
  }
1082
1085
  if (!peripheral) {
@@ -1176,11 +1179,15 @@ function disconnectDevice(deviceId) {
1176
1179
  deviceDisconnectListeners.delete(deviceId);
1177
1180
  }
1178
1181
  yield runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1179
- timeoutMs: BLE_CLEANUP_TIMEOUT,
1182
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
1180
1183
  timeoutBehavior: 'resolve',
1181
1184
  });
1185
+ if (peripheral.state === 'connected') {
1186
+ logger === null || logger === void 0 ? void 0 : logger.warn('[NobleBLE] Physical disconnect did not confirm in time', { deviceId });
1187
+ }
1182
1188
  cleanupDevice(deviceId, undefined, {
1183
1189
  cleanupConnection: true,
1190
+ cleanupDiscoveredCache: true,
1184
1191
  sendDisconnectEvent: false,
1185
1192
  cancelOperations: true,
1186
1193
  reason: 'manual-disconnect',
@@ -1391,10 +1398,6 @@ function setupNobleBleHandlers(webContents) {
1391
1398
  yield disconnectDevice(deviceId).catch(() => undefined);
1392
1399
  }
1393
1400
  yield stopScanning().catch(() => undefined);
1394
- if (noble && persistentStateListener) {
1395
- noble.removeListener('stateChange', persistentStateListener);
1396
- persistentStateListener = null;
1397
- }
1398
1401
  cleanupNobleListeners();
1399
1402
  discoveredDevices.clear();
1400
1403
  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":"AA+BA,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;AAwoDD,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.162",
3
+ "version": "1.2.0-alpha.163",
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.162",
29
- "@onekeyfe/hd-shared": "1.2.0-alpha.162",
30
- "@onekeyfe/hd-transport": "1.2.0-alpha.162",
28
+ "@onekeyfe/hd-core": "1.2.0-alpha.163",
29
+ "@onekeyfe/hd-shared": "1.2.0-alpha.163",
30
+ "@onekeyfe/hd-transport": "1.2.0-alpha.163",
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": "f4aa654b350931d68ed9ba3f77c4cc8faa161218"
39
+ "gitHead": "afed1c82345fc277cadf49677ab8371e6c6f3f9a"
40
40
  }
@@ -90,8 +90,18 @@ const DEVICE_SCAN_TIMEOUT = 5000; // 5 seconds for device scanning
90
90
  const DEVICE_CHECK_INTERVAL = 500; // 500ms interval for periodic device checks
91
91
  const SERVICE_DISCOVERY_TIMEOUT = 10000; // 10 seconds for service discovery
92
92
  const BLE_CLEANUP_TIMEOUT = 250;
93
+ // A physical teardown must actually reach the OS before the device can return
94
+ // to a clean state; 250ms routinely declared success while CoreBluetooth was
95
+ // still disconnecting, leaving no trace when the teardown never completed.
96
+ const BLE_DISCONNECT_CONFIRM_TIMEOUT_MS = 3000;
93
97
  // Renderer release is logical only; this timer physically frees the device.
94
- const BLE_IDLE_DISCONNECT_MS = 3 * 60_000;
98
+ // Keep it SHORT: the Classic 1S goes protocol-deaf when a link is dropped
99
+ // after sitting idle for minutes (field logs 2026-08-19: every deaf window
100
+ // followed a 3-minute-idle disconnect, while disconnects right after traffic
101
+ // have never produced one across 6.5.0's per-call teardown history). A hot
102
+ // disconnect ~20s after the last operation stays inside the proven-safe
103
+ // pattern and also shrinks the window in which phones cannot see the device.
104
+ const BLE_IDLE_DISCONNECT_MS = 20_000;
95
105
  // Ceiling while a call is in flight: no outstanding write, but not forever.
96
106
  const BLE_BUSY_BACKSTOP_MS = 10 * 60_000;
97
107
 
@@ -257,6 +267,11 @@ async function initializeNoble(): Promise<void> {
257
267
  noble = require('@stoprocent/noble') as NobleModule;
258
268
  logger?.info('[NobleBLE] Noble library loaded');
259
269
 
270
+ // Register the process-lifetime state listener before any early return:
271
+ // the poweredOn fast path below would otherwise skip it for the whole
272
+ // session, leaving poweredOff cache/state reconciliation dead.
273
+ setupPersistentStateListener();
274
+
260
275
  // Wait for Bluetooth to be ready
261
276
  await new Promise<void>((resolve, reject) => {
262
277
  if (!noble) {
@@ -269,9 +284,6 @@ async function initializeNoble(): Promise<void> {
269
284
  return;
270
285
  }
271
286
 
272
- // Setup persistent state listener before initialization
273
- setupPersistentStateListener();
274
-
275
287
  const timeout = setTimeout(() => {
276
288
  reject(
277
289
  ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Bluetooth initialization timeout')
@@ -398,7 +410,21 @@ function armIdleDisconnect(
398
410
  logger?.info('[NobleBLE] Keep-alive timeout, disconnecting device', { deviceId, reason });
399
411
  const peripheral = connectedDevices.get(deviceId);
400
412
  const deviceName = peripheral?.advertisement?.localName || 'Unknown Device';
401
- const pending = disconnectDevice(deviceId)
413
+ // Unsubscribe CCCD before dropping the link, matching every other
414
+ // teardown path: the 1S leaves its notify session half-open when the
415
+ // link drops without an unsubscribe and then ignores application
416
+ // protocol traffic on NEW links for tens of minutes (field log
417
+ // 2026-08-19: the lone unsubscribe-skipping idle teardown caused a
418
+ // 6-attempt reconnect loop; unsubscribe-first teardowns reconnected
419
+ // instantly).
420
+ const pending = unsubscribeNotifications(deviceId)
421
+ .catch(error => {
422
+ logger?.warn('[NobleBLE] Keep-alive unsubscribe failed, disconnecting anyway', {
423
+ deviceId,
424
+ error: error instanceof Error ? error.message : String(error),
425
+ });
426
+ })
427
+ .then(() => disconnectDevice(deviceId))
402
428
  .then(() => {
403
429
  // A call is still in flight here; it must reject, not hang.
404
430
  // Both 'idle' and 'busy-backstop' report IdleKeepAlive on purpose:
@@ -508,6 +534,9 @@ function handleDeviceDisconnect(deviceId: string, webContents: WebContents): voi
508
534
 
509
535
  cleanupDevice(deviceId, webContents, {
510
536
  cleanupConnection: true,
537
+ // Same stale-object hazard as manual disconnect: an externally dropped
538
+ // link invalidates the cached peripheral for the next reconnect.
539
+ cleanupDiscoveredCache: true,
511
540
  sendDisconnectEvent: true,
512
541
  cancelOperations: true,
513
542
  reason: 'auto-disconnect',
@@ -1305,26 +1334,28 @@ async function setupConnectionAndDiscoverServices(
1305
1334
  deviceId: string,
1306
1335
  webContents: WebContents
1307
1336
  ): Promise<CharacteristicPair> {
1308
- setupDisconnectListener(peripheral, deviceId, webContents);
1309
-
1337
+ // Reset the link before any protocol traffic reaches the device. 6.5.0 did
1338
+ // this on every cold setup and never produced a protocol-deaf Classic 1S
1339
+ // (control log 2026-08-20: 16/16 links answered within ~1s, including after
1340
+ // a 15-minute idle). Demoting it to a discovery-failure fallback removed the
1341
+ // reset entirely for this device, because discovery always succeeds: the link
1342
+ // comes up, GATT resolves from cache, and the device then answers nothing on
1343
+ // a session it no longer serves. Costs ~1.3s per cold setup; a kept-alive
1344
+ // link with an intact subscription returns before reaching here, so reuse
1345
+ // inside a workflow is unaffected.
1310
1346
  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
- });
1347
+ await forceReconnectPeripheral(peripheral, deviceId);
1348
+ } catch (resetError) {
1349
+ // A failed reset must not abort the attempt: discovery on the existing
1350
+ // link, then the fresh-scan fallback, still have a chance to recover.
1351
+ logger?.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
1317
1352
  }
1318
-
1319
- await forceReconnectPeripheral(peripheral, deviceId);
1320
1353
  setupDisconnectListener(peripheral, deviceId, webContents);
1354
+
1321
1355
  try {
1322
1356
  return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1323
- } catch (reconnectError) {
1324
- logger?.error(
1325
- '[NobleBLE] Service discovery failed after reconnect, attempting fresh scan...',
1326
- reconnectError
1327
- );
1357
+ } catch (discoveryError) {
1358
+ logger?.error('[NobleBLE] Service discovery failed, attempting fresh scan...', discoveryError);
1328
1359
  return freshScanAndDiscover(deviceId, webContents);
1329
1360
  }
1330
1361
  }
@@ -1413,10 +1444,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1413
1444
  // enumerate clears the discovery map; a kept-alive link outlives it.
1414
1445
  let peripheral = discoveredDevices.get(deviceId) ?? connectedDevices.get(deviceId);
1415
1446
 
1416
- // If device not discovered, try a targeted scan for this specific device
1417
1447
  if (!peripheral) {
1418
- logger?.info('[NobleBLE] Device not discovered, attempting targeted scan for:', deviceId);
1419
-
1420
1448
  // Initialize Noble if not already done
1421
1449
  if (!noble) {
1422
1450
  await initializeNoble();
@@ -1426,27 +1454,31 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1426
1454
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
1427
1455
  }
1428
1456
 
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);
1457
+ // Scan before connecting by id. A live advertisement proves the device
1458
+ // holds no link and will open a fresh session, and it is how 6.5.0 reached
1459
+ // every cold connect (control logs: found in ~52ms, 16/16 sessions
1460
+ // answered within ~1s). Connecting by id resolves through the OS cache
1461
+ // instead: on a Classic 1S that can hand back a session the device no
1462
+ // longer serves, where the link comes up and GATT resolves from cache but
1463
+ // the device answers no protocol traffic at all.
1464
+ logger?.info('[NobleBLE] Device not cached, scanning for:', deviceId);
1465
+ try {
1466
+ peripheral = (await performTargetedScan(deviceId)) ?? undefined;
1467
+ } catch (scanError) {
1468
+ logger?.info('[NobleBLE] Targeted scan failed, falling back to direct connect', {
1469
+ deviceId,
1470
+ error: scanError instanceof Error ? scanError.message : String(scanError),
1471
+ });
1433
1472
  }
1434
- }
1435
1473
 
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
- );
1474
+ if (!peripheral) {
1475
+ // Not advertising: held by another host, or bonded and silent. Connect
1476
+ // by id is then the only way in.
1477
+ logger?.info('[NobleBLE] Device not advertising, attempting direct connect by id:', deviceId);
1478
+ peripheral = await tryDirectConnectById(deviceId);
1479
+ if (peripheral) {
1480
+ discoveredDevices.set(deviceId, peripheral);
1445
1481
  }
1446
- peripheral = foundPeripheral;
1447
- } catch (error) {
1448
- logger?.error('[NobleBLE] Targeted scan failed:', error);
1449
- throw error;
1450
1482
  }
1451
1483
  }
1452
1484
 
@@ -1597,11 +1629,18 @@ async function disconnectDevice(deviceId: string): Promise<void> {
1597
1629
  }
1598
1630
 
1599
1631
  await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1600
- timeoutMs: BLE_CLEANUP_TIMEOUT,
1632
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
1601
1633
  timeoutBehavior: 'resolve',
1602
1634
  });
1635
+ if (peripheral.state === 'connected') {
1636
+ logger?.warn('[NobleBLE] Physical disconnect did not confirm in time', { deviceId });
1637
+ }
1603
1638
  cleanupDevice(deviceId, undefined, {
1604
1639
  cleanupConnection: true,
1640
+ // macOS returns zero GATT services when a previously-disconnected
1641
+ // peripheral object is reconnected; drop the discovery cache so the next
1642
+ // connect resolves a fresh peripheral (direct connect by id, or scan).
1643
+ cleanupDiscoveredCache: true,
1605
1644
  sendDisconnectEvent: false,
1606
1645
  cancelOperations: true,
1607
1646
  reason: 'manual-disconnect',
@@ -1927,10 +1966,12 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1927
1966
  }
1928
1967
 
1929
1968
  await stopScanning().catch(() => undefined);
1930
- if (noble && persistentStateListener) {
1931
- noble.removeListener('stateChange', persistentStateListener);
1932
- persistentStateListener = null;
1933
- }
1969
+ // persistentStateListener is process-lifetime, NOT per-window: this
1970
+ // destroy handler also fires on a renderer soft restart, and removing
1971
+ // the listener here permanently killed poweredOff/poweredOn
1972
+ // reconciliation for the rest of the process (nothing re-registers it:
1973
+ // initializeNoble early-returns once noble is loaded). The null-guard
1974
+ // in setupPersistentStateListener keeps it deduped to one instance.
1934
1975
  cleanupNobleListeners();
1935
1976
  discoveredDevices.clear();
1936
1977
  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