@onekeyfe/hd-transport-electron 1.2.0-alpha.15 → 1.2.0-alpha.151
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.
- package/dist/ble-ops.d.ts +6 -0
- package/dist/ble-ops.d.ts.map +1 -1
- package/dist/ble-packet-capacity.d.ts +2 -0
- package/dist/ble-packet-capacity.d.ts.map +1 -0
- package/dist/{index-55699e30.js → index-70b4a97d.js} +1 -1
- package/dist/index.d.ts +16 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/{noble-ble-handler-16791965.js → noble-ble-handler-efd3df55.js} +455 -232
- package/dist/noble-ble-handler.d.ts +2 -0
- package/dist/noble-ble-handler.d.ts.map +1 -1
- package/dist/noble-ble-timeouts.d.ts +3 -0
- package/dist/noble-ble-timeouts.d.ts.map +1 -0
- package/dist/types/desktop-api.d.ts +10 -1
- package/dist/types/desktop-api.d.ts.map +1 -1
- package/dist/types/noble-extended.d.ts +1 -0
- package/dist/types/noble-extended.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/ble-ops.test.ts +21 -0
- package/src/__tests__/ble-packet-capacity.test.ts +13 -0
- package/src/__tests__/noble-ble-handler.test.ts +383 -0
- package/src/ble-ops.ts +40 -10
- package/src/ble-packet-capacity.ts +13 -0
- package/src/index.ts +1 -1
- package/src/noble-ble-handler.ts +532 -250
- package/src/noble-ble-timeouts.ts +2 -0
- package/src/types/desktop-api.ts +10 -2
- package/src/types/noble-extended.ts +1 -0
package/src/noble-ble-handler.ts
CHANGED
|
@@ -9,17 +9,28 @@ import {
|
|
|
9
9
|
EOneKeyBleMessageKeys,
|
|
10
10
|
ERRORS,
|
|
11
11
|
HardwareErrorCode,
|
|
12
|
+
ONEKEY_NOTIFY_CHARACTERISTIC_UUID,
|
|
12
13
|
ONEKEY_SERVICE_UUID,
|
|
13
|
-
|
|
14
|
+
ONEKEY_WRITE_CHARACTERISTIC_UUID,
|
|
15
|
+
createKnownBleUuidAliases,
|
|
16
|
+
hasOnekeyCommunicationService,
|
|
17
|
+
isOnekeyBluetoothDevice,
|
|
18
|
+
matchesKnownBleUuid,
|
|
14
19
|
wait,
|
|
15
20
|
} from '@onekeyfe/hd-shared';
|
|
16
21
|
import pRetry from 'p-retry';
|
|
17
22
|
|
|
23
|
+
import { resolveBlePacketCapacity } from './ble-packet-capacity';
|
|
18
24
|
import { safeLog } from './types/noble-extended';
|
|
19
|
-
import { softRefreshSubscription } from './ble-ops';
|
|
25
|
+
import { runBleCallbackOperation, softRefreshSubscription } from './ble-ops';
|
|
26
|
+
import {
|
|
27
|
+
NOBLE_BLE_CONNECTION_TIMEOUT_MS,
|
|
28
|
+
NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS,
|
|
29
|
+
} from './noble-ble-timeouts';
|
|
20
30
|
|
|
21
|
-
import type { IpcMainInvokeEvent, WebContents } from 'electron';
|
|
31
|
+
import type { IpcMain, IpcMainInvokeEvent, WebContents } from 'electron';
|
|
22
32
|
import type { Characteristic, Peripheral, Service } from '@stoprocent/noble';
|
|
33
|
+
import type { NobleBleWriteOptions } from './types/desktop-api';
|
|
23
34
|
import type { CharacteristicPair, DeviceInfo, Logger, NobleModule } from './types/noble-extended';
|
|
24
35
|
|
|
25
36
|
// Noble will be dynamically imported to avoid bundling issues
|
|
@@ -39,6 +50,7 @@ const bluetoothState: {
|
|
|
39
50
|
|
|
40
51
|
// Global persistent state listener for app layer
|
|
41
52
|
let persistentStateListener: ((state: string) => void) | null = null;
|
|
53
|
+
let persistentDiscoverListener: ((peripheral: Peripheral) => void) | null = null;
|
|
42
54
|
|
|
43
55
|
// Device cache and connection state
|
|
44
56
|
const discoveredDevices = new Map<string, Peripheral>();
|
|
@@ -50,6 +62,14 @@ const subscribedDevices = new Map<string, boolean>(); // Track subscription stat
|
|
|
50
62
|
|
|
51
63
|
// 🔒 Subscription operation state tracking to prevent race conditions
|
|
52
64
|
const subscriptionOperations = new Map<string, 'subscribing' | 'unsubscribing' | 'idle'>();
|
|
65
|
+
const deviceDisconnectListeners = new Map<
|
|
66
|
+
string,
|
|
67
|
+
{ peripheral: Peripheral; listener: () => void }
|
|
68
|
+
>();
|
|
69
|
+
const deviceMtuListeners = new Map<
|
|
70
|
+
string,
|
|
71
|
+
{ peripheral: Peripheral; listener: (mtu: number) => void }
|
|
72
|
+
>();
|
|
53
73
|
|
|
54
74
|
// Windows-only response watchdog state moved to utils/windows-ble-recovery
|
|
55
75
|
|
|
@@ -59,57 +79,53 @@ const subscriptionOperations = new Map<string, 'subscribing' | 'unsubscribing' |
|
|
|
59
79
|
|
|
60
80
|
// Service UUIDs to scan for - using constants from hd-shared
|
|
61
81
|
const ONEKEY_SERVICE_UUIDS = [ONEKEY_SERVICE_UUID];
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const NORMALIZED_WRITE_UUID = '0002';
|
|
66
|
-
const NORMALIZED_NOTIFY_UUID = '0003';
|
|
82
|
+
const ONEKEY_SERVICE_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_SERVICE_UUID);
|
|
83
|
+
const ONEKEY_WRITE_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_WRITE_CHARACTERISTIC_UUID);
|
|
84
|
+
const ONEKEY_NOTIFY_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_NOTIFY_CHARACTERISTIC_UUID);
|
|
67
85
|
|
|
68
86
|
// Timeout and interval constants
|
|
69
87
|
const BLUETOOTH_INIT_TIMEOUT = 10000; // 10 seconds for Bluetooth initialization
|
|
70
|
-
const DEVICE_SCAN_TIMEOUT =
|
|
71
|
-
const FAST_SCAN_TIMEOUT = 8000; // 8 seconds for targeted scanning (Pro2 has longer advertising interval)
|
|
88
|
+
const DEVICE_SCAN_TIMEOUT = 5000; // 5 seconds for device scanning
|
|
72
89
|
const DEVICE_CHECK_INTERVAL = 500; // 500ms interval for periodic device checks
|
|
73
|
-
const CONNECTION_TIMEOUT = 8000; // 8 seconds for device connection (BLE reconnect after release can be slow)
|
|
74
90
|
const SERVICE_DISCOVERY_TIMEOUT = 10000; // 10 seconds for service discovery
|
|
91
|
+
const BLE_CLEANUP_TIMEOUT = 250;
|
|
92
|
+
// Renderer release is logical only; this timer physically frees the device.
|
|
93
|
+
const BLE_IDLE_DISCONNECT_MS = 3 * 60_000;
|
|
94
|
+
// Ceiling while a call is in flight: no outstanding write, but not forever.
|
|
95
|
+
const BLE_BUSY_BACKSTOP_MS = 10 * 60_000;
|
|
75
96
|
|
|
76
97
|
// Write-related constants
|
|
77
|
-
const
|
|
78
|
-
const
|
|
98
|
+
const BLE_PACKET_SIZE_FALLBACK = 192;
|
|
99
|
+
const BLE_PACKET_SIZE_MAXIMUM = 244;
|
|
100
|
+
const DEFAULT_WRITE_PACING_DELAY_MS = 5;
|
|
79
101
|
const RETRY_CONFIG = { MAX_ATTEMPTS: 15, WRITE_TIMEOUT: 2000 } as const;
|
|
80
102
|
const IS_WINDOWS = process.platform === 'win32';
|
|
81
103
|
const ABORTABLE_WRITE_ERROR_PATTERNS = [
|
|
82
104
|
/status:\s*3/i, // Windows pairing cancelled / GATT write failed
|
|
83
105
|
];
|
|
84
106
|
|
|
85
|
-
function
|
|
86
|
-
|
|
87
|
-
|
|
107
|
+
export function resolveNobleBleWritePacingDelay(options?: NobleBleWriteOptions) {
|
|
108
|
+
return typeof options?.pacingDelayMs === 'number' && Number.isFinite(options.pacingDelayMs)
|
|
109
|
+
? Math.min(Math.max(Math.floor(options.pacingDelayMs), 0), 1000)
|
|
110
|
+
: DEFAULT_WRITE_PACING_DELAY_MS;
|
|
88
111
|
}
|
|
89
112
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
]);
|
|
94
|
-
|
|
95
|
-
function isGenericBleService(uuid?: string | null) {
|
|
96
|
-
return ['1800', '1801', '180a', '180f'].includes(getBleUuidKey(uuid));
|
|
97
|
-
}
|
|
113
|
+
function isOneKeyPeripheral(peripheral: Peripheral) {
|
|
114
|
+
const serviceUuids = peripheral.advertisement?.serviceUuids;
|
|
115
|
+
const localName = peripheral.advertisement?.localName;
|
|
98
116
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const uuidKey = getBleUuidKey(uuid);
|
|
103
|
-
return (
|
|
104
|
-
NORMALIZED_ONEKEY_SERVICE_UUIDS.has(uuidKey) ||
|
|
105
|
-
PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS.has(uuidKey)
|
|
106
|
-
);
|
|
107
|
-
});
|
|
108
|
-
}
|
|
117
|
+
if (!localName?.trim()) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
109
120
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
121
|
+
return (
|
|
122
|
+
hasOnekeyCommunicationService(serviceUuids) &&
|
|
123
|
+
isOnekeyBluetoothDevice({
|
|
124
|
+
id: peripheral.id,
|
|
125
|
+
localName,
|
|
126
|
+
serviceUuids,
|
|
127
|
+
})
|
|
128
|
+
);
|
|
113
129
|
}
|
|
114
130
|
|
|
115
131
|
/**
|
|
@@ -240,6 +256,11 @@ async function initializeNoble(): Promise<void> {
|
|
|
240
256
|
noble = require('@stoprocent/noble') as NobleModule;
|
|
241
257
|
logger?.info('[NobleBLE] Noble library loaded');
|
|
242
258
|
|
|
259
|
+
// Register the process-lifetime state listener before any early return:
|
|
260
|
+
// the poweredOn fast path below would otherwise skip it for the whole
|
|
261
|
+
// session, leaving poweredOff cache/state reconciliation dead.
|
|
262
|
+
setupPersistentStateListener();
|
|
263
|
+
|
|
243
264
|
// Wait for Bluetooth to be ready
|
|
244
265
|
await new Promise<void>((resolve, reject) => {
|
|
245
266
|
if (!noble) {
|
|
@@ -252,9 +273,6 @@ async function initializeNoble(): Promise<void> {
|
|
|
252
273
|
return;
|
|
253
274
|
}
|
|
254
275
|
|
|
255
|
-
// Setup persistent state listener before initialization
|
|
256
|
-
setupPersistentStateListener();
|
|
257
|
-
|
|
258
276
|
const timeout = setTimeout(() => {
|
|
259
277
|
reject(
|
|
260
278
|
ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Bluetooth initialization timeout')
|
|
@@ -290,9 +308,12 @@ async function initializeNoble(): Promise<void> {
|
|
|
290
308
|
});
|
|
291
309
|
|
|
292
310
|
// Set up device discovery
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
311
|
+
if (!persistentDiscoverListener) {
|
|
312
|
+
persistentDiscoverListener = (peripheral: Peripheral) => {
|
|
313
|
+
handleDeviceDiscovered(peripheral);
|
|
314
|
+
};
|
|
315
|
+
noble.on('discover', persistentDiscoverListener);
|
|
316
|
+
}
|
|
296
317
|
|
|
297
318
|
logger?.info('[NobleBLE] Noble initialized successfully');
|
|
298
319
|
} catch (error) {
|
|
@@ -323,6 +344,76 @@ interface DeviceCleanupOptions {
|
|
|
323
344
|
reason?: string;
|
|
324
345
|
}
|
|
325
346
|
|
|
347
|
+
// One timer per device, in the main process so a reload cannot orphan a link.
|
|
348
|
+
const idleDisconnectTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
349
|
+
|
|
350
|
+
// A fired timer cannot be cancelled; connect awaits its disconnect or the fast
|
|
351
|
+
// path hands back a link torn down a moment later (BleTimeoutError 713).
|
|
352
|
+
const idleDisconnectInFlight = new Map<string, Promise<void>>();
|
|
353
|
+
|
|
354
|
+
function clearIdleDisconnect(deviceId: string): void {
|
|
355
|
+
const timer = idleDisconnectTimers.get(deviceId);
|
|
356
|
+
if (timer) {
|
|
357
|
+
clearTimeout(timer);
|
|
358
|
+
idleDisconnectTimers.delete(deviceId);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Settle any keep-alive disconnect already running for this device. */
|
|
363
|
+
async function awaitIdleDisconnect(deviceId: string): Promise<void> {
|
|
364
|
+
const pending = idleDisconnectInFlight.get(deviceId);
|
|
365
|
+
if (pending) await pending.catch(() => undefined);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function broadcastToAllWebContents(channel: string, payload: unknown): void {
|
|
369
|
+
try {
|
|
370
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require
|
|
371
|
+
const { webContents: electronWebContents } = require('electron') as {
|
|
372
|
+
webContents: { getAllWebContents(): WebContents[] };
|
|
373
|
+
};
|
|
374
|
+
for (const wc of electronWebContents.getAllWebContents()) {
|
|
375
|
+
if (!wc.isDestroyed()) wc.send(channel, payload);
|
|
376
|
+
}
|
|
377
|
+
} catch (error) {
|
|
378
|
+
logger?.error('[NobleBLE] broadcast failed:', { channel, error: String(error) });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function armIdleDisconnect(
|
|
383
|
+
deviceId: string,
|
|
384
|
+
ms: number = BLE_IDLE_DISCONNECT_MS,
|
|
385
|
+
reason: 'idle' | 'busy-backstop' = 'idle'
|
|
386
|
+
): void {
|
|
387
|
+
clearIdleDisconnect(deviceId);
|
|
388
|
+
idleDisconnectTimers.set(
|
|
389
|
+
deviceId,
|
|
390
|
+
setTimeout(() => {
|
|
391
|
+
idleDisconnectTimers.delete(deviceId);
|
|
392
|
+
if (!connectedDevices.has(deviceId)) return;
|
|
393
|
+
logger?.info('[NobleBLE] Keep-alive timeout, disconnecting device', { deviceId, reason });
|
|
394
|
+
const peripheral = connectedDevices.get(deviceId);
|
|
395
|
+
const deviceName = peripheral?.advertisement?.localName || 'Unknown Device';
|
|
396
|
+
const pending = disconnectDevice(deviceId)
|
|
397
|
+
.then(() => {
|
|
398
|
+
// A call is still in flight here; it must reject, not hang.
|
|
399
|
+
broadcastToAllWebContents(EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
|
|
400
|
+
id: deviceId,
|
|
401
|
+
name: deviceName,
|
|
402
|
+
});
|
|
403
|
+
})
|
|
404
|
+
.catch(error => {
|
|
405
|
+
logger?.error('[NobleBLE] Keep-alive disconnect failed', { deviceId, error });
|
|
406
|
+
})
|
|
407
|
+
.finally(() => {
|
|
408
|
+
if (idleDisconnectInFlight.get(deviceId) === pending) {
|
|
409
|
+
idleDisconnectInFlight.delete(deviceId);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
idleDisconnectInFlight.set(deviceId, pending);
|
|
413
|
+
}, ms)
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
326
417
|
/**
|
|
327
418
|
* Unified device cleanup function - single entry point for all cleanup operations
|
|
328
419
|
*/
|
|
@@ -348,12 +439,24 @@ function cleanupDevice(
|
|
|
348
439
|
cancelOperations,
|
|
349
440
|
});
|
|
350
441
|
|
|
442
|
+
clearIdleDisconnect(deviceId);
|
|
443
|
+
|
|
351
444
|
// Get device info before cleanup
|
|
352
445
|
const peripheral = connectedDevices.get(deviceId);
|
|
353
446
|
const deviceName = peripheral?.advertisement?.localName || 'Unknown Device';
|
|
354
447
|
|
|
355
448
|
// 1. Clean up connection state
|
|
356
449
|
if (cleanupConnection) {
|
|
450
|
+
const disconnectEntry = deviceDisconnectListeners.get(deviceId);
|
|
451
|
+
if (disconnectEntry) {
|
|
452
|
+
disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
|
|
453
|
+
deviceDisconnectListeners.delete(deviceId);
|
|
454
|
+
}
|
|
455
|
+
const mtuEntry = deviceMtuListeners.get(deviceId);
|
|
456
|
+
if (mtuEntry) {
|
|
457
|
+
mtuEntry.peripheral.removeListener('mtu', mtuEntry.listener);
|
|
458
|
+
deviceMtuListeners.delete(deviceId);
|
|
459
|
+
}
|
|
357
460
|
connectedDevices.delete(deviceId);
|
|
358
461
|
deviceCharacteristics.delete(deviceId);
|
|
359
462
|
notificationCallbacks.delete(deviceId);
|
|
@@ -367,9 +470,11 @@ function cleanupDevice(
|
|
|
367
470
|
discoveredDevices.delete(deviceId);
|
|
368
471
|
}
|
|
369
472
|
|
|
370
|
-
// 3. Send disconnect event (if needed)
|
|
371
|
-
|
|
372
|
-
|
|
473
|
+
// 3. Send disconnect event (if needed). Broadcast, not the captured
|
|
474
|
+
// webContents: a kept-alive link outlives a renderer soft restart, and the
|
|
475
|
+
// new renderer still needs to hear the device dropped (same as the idle path).
|
|
476
|
+
if (sendDisconnectEvent) {
|
|
477
|
+
broadcastToAllWebContents(EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
|
|
373
478
|
id: deviceId,
|
|
374
479
|
name: deviceName,
|
|
375
480
|
});
|
|
@@ -391,6 +496,9 @@ function handleDeviceDisconnect(deviceId: string, webContents: WebContents): voi
|
|
|
391
496
|
|
|
392
497
|
cleanupDevice(deviceId, webContents, {
|
|
393
498
|
cleanupConnection: true,
|
|
499
|
+
// Same stale-object hazard as manual disconnect: an externally dropped
|
|
500
|
+
// link invalidates the cached peripheral for the next reconnect.
|
|
501
|
+
cleanupDiscoveredCache: true,
|
|
394
502
|
sendDisconnectEvent: true,
|
|
395
503
|
cancelOperations: true,
|
|
396
504
|
reason: 'auto-disconnect',
|
|
@@ -403,18 +511,45 @@ function setupDisconnectListener(
|
|
|
403
511
|
deviceId: string,
|
|
404
512
|
webContents: WebContents
|
|
405
513
|
): void {
|
|
406
|
-
|
|
407
|
-
|
|
514
|
+
const existing = deviceDisconnectListeners.get(deviceId);
|
|
515
|
+
if (existing) {
|
|
516
|
+
existing.peripheral.removeListener('disconnect', existing.listener);
|
|
517
|
+
}
|
|
408
518
|
|
|
409
|
-
|
|
410
|
-
peripheral.on('disconnect', () => {
|
|
519
|
+
const listener = () => {
|
|
411
520
|
handleDeviceDisconnect(deviceId, webContents);
|
|
412
|
-
}
|
|
521
|
+
};
|
|
522
|
+
deviceDisconnectListeners.set(deviceId, { peripheral, listener });
|
|
523
|
+
peripheral.on('disconnect', listener);
|
|
524
|
+
setupMtuListener(peripheral, deviceId, webContents);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function setupMtuListener(
|
|
528
|
+
peripheral: Peripheral,
|
|
529
|
+
deviceId: string,
|
|
530
|
+
webContents: WebContents
|
|
531
|
+
): void {
|
|
532
|
+
const existing = deviceMtuListeners.get(deviceId);
|
|
533
|
+
if (existing) {
|
|
534
|
+
existing.peripheral.removeListener('mtu', existing.listener);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const listener = (mtu: number) => {
|
|
538
|
+
if (!Number.isFinite(mtu) || mtu <= 0) return;
|
|
539
|
+
// A kept-alive link can outlive the renderer this closure captured.
|
|
540
|
+
if (webContents.isDestroyed()) return;
|
|
541
|
+
webContents.send(EOneKeyBleMessageKeys.NOBLE_BLE_MTU_CHANGED, {
|
|
542
|
+
id: deviceId,
|
|
543
|
+
mtu,
|
|
544
|
+
});
|
|
545
|
+
};
|
|
546
|
+
deviceMtuListeners.set(deviceId, { peripheral, listener });
|
|
547
|
+
peripheral.on('mtu', listener);
|
|
413
548
|
}
|
|
414
549
|
|
|
415
550
|
// ===== Write helpers (inline) =====
|
|
416
551
|
|
|
417
|
-
async function
|
|
552
|
+
async function writeCharacteristicWithoutResponse(
|
|
418
553
|
deviceId: string,
|
|
419
554
|
writeCharacteristic: Characteristic,
|
|
420
555
|
buffer: Buffer
|
|
@@ -462,7 +597,7 @@ async function attemptWindowsWriteUntilPaired(
|
|
|
462
597
|
}
|
|
463
598
|
|
|
464
599
|
try {
|
|
465
|
-
await
|
|
600
|
+
await writeCharacteristicWithoutResponse(deviceId, latestWrite, payload);
|
|
466
601
|
} catch (e) {
|
|
467
602
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
468
603
|
logger?.error('[BLE-Write] Windows write error', {
|
|
@@ -534,7 +669,11 @@ async function attemptWindowsWriteUntilPaired(
|
|
|
534
669
|
);
|
|
535
670
|
}
|
|
536
671
|
|
|
537
|
-
async function transmitHexDataToDevice(
|
|
672
|
+
async function transmitHexDataToDevice(
|
|
673
|
+
deviceId: string,
|
|
674
|
+
hexData: string,
|
|
675
|
+
options?: NobleBleWriteOptions
|
|
676
|
+
): Promise<void> {
|
|
538
677
|
const characteristics = deviceCharacteristics.get(deviceId);
|
|
539
678
|
const peripheral = connectedDevices.get(deviceId);
|
|
540
679
|
if (!peripheral || !characteristics) {
|
|
@@ -543,9 +682,17 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
|
|
|
543
682
|
`Device ${deviceId} not connected or characteristics not available`
|
|
544
683
|
);
|
|
545
684
|
}
|
|
685
|
+
// Request outstanding: swap the idle clock for the busy backstop.
|
|
686
|
+
armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
|
|
546
687
|
|
|
547
688
|
const toBuffer = Buffer.from(hexData, 'hex');
|
|
548
689
|
const doGetWriteCharacteristic = () => deviceCharacteristics.get(deviceId)?.write;
|
|
690
|
+
const packetCapacity = resolveBlePacketCapacity(
|
|
691
|
+
peripheral.mtu,
|
|
692
|
+
BLE_PACKET_SIZE_MAXIMUM,
|
|
693
|
+
BLE_PACKET_SIZE_FALLBACK
|
|
694
|
+
);
|
|
695
|
+
const pacingDelayMs = resolveNobleBleWritePacingDelay(options);
|
|
549
696
|
|
|
550
697
|
if (!IS_WINDOWS || pairedDevices.has(deviceId)) {
|
|
551
698
|
// macOS / Linux or already paired on Windows: direct write
|
|
@@ -556,14 +703,14 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
|
|
|
556
703
|
`Write characteristic not available for ${deviceId}`
|
|
557
704
|
);
|
|
558
705
|
}
|
|
559
|
-
if (toBuffer.length <=
|
|
560
|
-
await wait(
|
|
561
|
-
await
|
|
706
|
+
if (toBuffer.length <= packetCapacity) {
|
|
707
|
+
if (pacingDelayMs > 0) await wait(pacingDelayMs);
|
|
708
|
+
await writeCharacteristicWithoutResponse(deviceId, writeCharacteristic, toBuffer);
|
|
562
709
|
return;
|
|
563
710
|
}
|
|
564
711
|
// chunked
|
|
565
712
|
for (let offset = 0; offset < toBuffer.length; ) {
|
|
566
|
-
const chunkSize = Math.min(
|
|
713
|
+
const chunkSize = Math.min(packetCapacity, toBuffer.length - offset);
|
|
567
714
|
const chunk = toBuffer.subarray(offset, offset + chunkSize);
|
|
568
715
|
offset += chunkSize;
|
|
569
716
|
const latest = doGetWriteCharacteristic();
|
|
@@ -573,23 +720,23 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
|
|
|
573
720
|
`Write characteristic not available for ${deviceId}`
|
|
574
721
|
);
|
|
575
722
|
}
|
|
576
|
-
await
|
|
577
|
-
if (offset < toBuffer.length) {
|
|
578
|
-
await wait(
|
|
723
|
+
await writeCharacteristicWithoutResponse(deviceId, latest, chunk);
|
|
724
|
+
if (offset < toBuffer.length && pacingDelayMs > 0) {
|
|
725
|
+
await wait(pacingDelayMs);
|
|
579
726
|
}
|
|
580
727
|
}
|
|
581
728
|
return;
|
|
582
729
|
}
|
|
583
730
|
|
|
584
731
|
// Windows unpaired path: use loop
|
|
585
|
-
if (toBuffer.length <=
|
|
586
|
-
await wait(
|
|
732
|
+
if (toBuffer.length <= packetCapacity) {
|
|
733
|
+
if (pacingDelayMs > 0) await wait(pacingDelayMs);
|
|
587
734
|
await attemptWindowsWriteUntilPaired(deviceId, doGetWriteCharacteristic, toBuffer, 'single');
|
|
588
735
|
return;
|
|
589
736
|
}
|
|
590
737
|
// chunked loop
|
|
591
738
|
for (let offset = 0, idx = 0; offset < toBuffer.length; idx++) {
|
|
592
|
-
const chunkSize = Math.min(
|
|
739
|
+
const chunkSize = Math.min(packetCapacity, toBuffer.length - offset);
|
|
593
740
|
const chunk = toBuffer.subarray(offset, offset + chunkSize);
|
|
594
741
|
offset += chunkSize;
|
|
595
742
|
await attemptWindowsWriteUntilPaired(
|
|
@@ -598,8 +745,8 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
|
|
|
598
745
|
chunk,
|
|
599
746
|
`chunk-${idx + 1}`
|
|
600
747
|
);
|
|
601
|
-
if (offset < toBuffer.length) {
|
|
602
|
-
await wait(
|
|
748
|
+
if (offset < toBuffer.length && pacingDelayMs > 0) {
|
|
749
|
+
await wait(pacingDelayMs);
|
|
603
750
|
}
|
|
604
751
|
}
|
|
605
752
|
}
|
|
@@ -628,19 +775,24 @@ function handleDeviceDiscovered(peripheral: Peripheral): void {
|
|
|
628
775
|
function ensureDiscoverListener(): void {
|
|
629
776
|
if (!noble) return;
|
|
630
777
|
|
|
631
|
-
|
|
632
|
-
const listenerCount = (noble as any).listenerCount('discover');
|
|
633
|
-
|
|
634
|
-
if (listenerCount === 0) {
|
|
778
|
+
if (!persistentDiscoverListener) {
|
|
635
779
|
logger?.info('[NobleBLE] Discover listener missing, re-adding it');
|
|
636
|
-
|
|
780
|
+
persistentDiscoverListener = (peripheral: Peripheral) => {
|
|
637
781
|
handleDeviceDiscovered(peripheral);
|
|
638
|
-
}
|
|
782
|
+
};
|
|
783
|
+
noble.on('discover', persistentDiscoverListener);
|
|
639
784
|
} else {
|
|
640
|
-
logger?.debug('[NobleBLE] Discover listener already
|
|
785
|
+
logger?.debug('[NobleBLE] Discover listener already registered');
|
|
641
786
|
}
|
|
642
787
|
}
|
|
643
788
|
|
|
789
|
+
async function waitForNobleScanStop(nobleInstance: NobleModule): Promise<void> {
|
|
790
|
+
await runBleCallbackOperation(callback => nobleInstance.stopScanning(() => callback()), {
|
|
791
|
+
timeoutMs: BLE_CLEANUP_TIMEOUT,
|
|
792
|
+
timeoutBehavior: 'resolve',
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
|
|
644
796
|
// Perform targeted scan for a specific device ID
|
|
645
797
|
// Uses self-contained local listener pattern - no global state needed
|
|
646
798
|
async function performTargetedScan(targetDeviceId: string): Promise<Peripheral | null> {
|
|
@@ -654,39 +806,50 @@ async function performTargetedScan(targetDeviceId: string): Promise<Peripheral |
|
|
|
654
806
|
logger?.info('[NobleBLE] Starting targeted scan for device:', targetDeviceId);
|
|
655
807
|
|
|
656
808
|
return new Promise((resolve, reject) => {
|
|
809
|
+
let settled = false;
|
|
810
|
+
|
|
811
|
+
const finish = async (peripheral: Peripheral | null, error?: Error) => {
|
|
812
|
+
if (settled) return;
|
|
813
|
+
settled = true;
|
|
814
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
815
|
+
nobleInstance.removeListener('discover', onDiscover);
|
|
816
|
+
await waitForNobleScanStop(nobleInstance);
|
|
817
|
+
|
|
818
|
+
if (error) {
|
|
819
|
+
logger?.error('[NobleBLE] Failed to start targeted scan:', error);
|
|
820
|
+
reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (peripheral) {
|
|
824
|
+
discoveredDevices.set(peripheral.id, peripheral);
|
|
825
|
+
}
|
|
826
|
+
resolve(peripheral);
|
|
827
|
+
};
|
|
828
|
+
|
|
657
829
|
// Local discover listener - only matches target device
|
|
658
830
|
const onDiscover = (peripheral: Peripheral) => {
|
|
659
|
-
if (peripheral.id === targetDeviceId) {
|
|
831
|
+
if (peripheral.id === targetDeviceId && isOneKeyPeripheral(peripheral)) {
|
|
660
832
|
logger?.info('[NobleBLE] Target device found during targeted scan:', {
|
|
661
833
|
id: peripheral.id,
|
|
662
834
|
name: peripheral.advertisement?.localName,
|
|
663
835
|
});
|
|
664
|
-
|
|
665
|
-
nobleInstance.removeListener('discover', onDiscover);
|
|
666
|
-
nobleInstance.stopScanning();
|
|
667
|
-
discoveredDevices.set(peripheral.id, peripheral);
|
|
668
|
-
resolve(peripheral);
|
|
836
|
+
finish(peripheral).catch(reject);
|
|
669
837
|
}
|
|
670
838
|
};
|
|
671
839
|
|
|
672
|
-
// Timeout handler - must be after onDiscover so it can reference it
|
|
673
840
|
const timeoutId = setTimeout(() => {
|
|
674
|
-
nobleInstance.removeListener('discover', onDiscover);
|
|
675
|
-
nobleInstance.stopScanning();
|
|
676
841
|
logger?.info('[NobleBLE] Targeted scan timeout for device:', targetDeviceId);
|
|
677
|
-
|
|
678
|
-
},
|
|
842
|
+
finish(null).catch(reject);
|
|
843
|
+
}, NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS);
|
|
679
844
|
|
|
680
845
|
// Add local listener for this scan
|
|
681
846
|
nobleInstance.on('discover', onDiscover);
|
|
682
847
|
|
|
683
|
-
//
|
|
684
|
-
|
|
848
|
+
// Allow repeated advertisements so a service-only packet can be followed by
|
|
849
|
+
// the named scan response needed to validate the target peripheral.
|
|
850
|
+
nobleInstance.startScanning([], true, (error?: Error) => {
|
|
685
851
|
if (error) {
|
|
686
|
-
|
|
687
|
-
nobleInstance.removeListener('discover', onDiscover);
|
|
688
|
-
logger?.error('[NobleBLE] Failed to start targeted scan:', error);
|
|
689
|
-
reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
|
|
852
|
+
finish(null, error).catch(reject);
|
|
690
853
|
return;
|
|
691
854
|
}
|
|
692
855
|
logger?.info('[NobleBLE] Targeted scan started for device:', targetDeviceId);
|
|
@@ -720,44 +883,47 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
720
883
|
const devices: DeviceInfo[] = [];
|
|
721
884
|
let intervalId: ReturnType<typeof setInterval> | undefined;
|
|
722
885
|
|
|
723
|
-
// Cleanup function: clears
|
|
724
|
-
|
|
886
|
+
// Cleanup function: clears timers and waits until Noble confirms scanning
|
|
887
|
+
// has stopped. Resolving enumerate before this callback creates a race with
|
|
888
|
+
// an immediately-following connection attempt.
|
|
889
|
+
const cleanup = async () => {
|
|
725
890
|
clearTimeout(timeoutId);
|
|
726
891
|
if (intervalId) clearInterval(intervalId);
|
|
727
|
-
nobleInstance
|
|
892
|
+
await waitForNobleScanStop(nobleInstance);
|
|
728
893
|
};
|
|
729
894
|
|
|
730
895
|
// Collect discovered devices into the devices array
|
|
731
|
-
const
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
id,
|
|
739
|
-
name: deviceName,
|
|
740
|
-
state: peripheral.state || 'disconnected',
|
|
741
|
-
});
|
|
742
|
-
}
|
|
896
|
+
const pushDevice = (peripheral: Peripheral, id: string) => {
|
|
897
|
+
if (devices.some(d => d.id === id)) return;
|
|
898
|
+
devices.push({
|
|
899
|
+
commType: 'electron-ble',
|
|
900
|
+
id,
|
|
901
|
+
name: peripheral.advertisement?.localName || 'Unknown Device',
|
|
902
|
+
state: peripheral.state || 'disconnected',
|
|
743
903
|
});
|
|
744
904
|
};
|
|
905
|
+
const checkDevices = () => {
|
|
906
|
+
discoveredDevices.forEach(pushDevice);
|
|
907
|
+
// A linked device stops advertising, so the scan never rediscovers it.
|
|
908
|
+
connectedDevices.forEach(pushDevice);
|
|
909
|
+
};
|
|
745
910
|
|
|
746
911
|
// Set timeout for scanning — use longer timeout to catch slow-advertising devices like Pro2
|
|
747
|
-
const timeoutId = setTimeout(() => {
|
|
912
|
+
const timeoutId = setTimeout(async () => {
|
|
748
913
|
// Final collection before resolving — catches devices discovered near the deadline
|
|
749
914
|
checkDevices();
|
|
750
|
-
cleanup();
|
|
915
|
+
await cleanup();
|
|
751
916
|
logger?.info('[NobleBLE] Scan completed, found devices:', devices.length);
|
|
752
917
|
resolve(devices);
|
|
753
918
|
}, DEVICE_SCAN_TIMEOUT);
|
|
754
919
|
|
|
755
920
|
// Start scanning without a service UUID filter so Pro2 advertisements with
|
|
756
|
-
// short vendor UUIDs can be found
|
|
921
|
+
// short vendor UUIDs can be found. Repeated advertisements are required when
|
|
922
|
+
// the local name arrives in a later scan response; discoveredDevices handles deduplication.
|
|
757
923
|
logger?.info('[NobleBLE] Scanning for OneKey BLE devices');
|
|
758
|
-
nobleInstance.startScanning([],
|
|
924
|
+
nobleInstance.startScanning([], true, async (error?: Error) => {
|
|
759
925
|
if (error) {
|
|
760
|
-
cleanup();
|
|
926
|
+
await cleanup();
|
|
761
927
|
logger?.error('[NobleBLE] Failed to start scanning:', error);
|
|
762
928
|
reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
|
|
763
929
|
return;
|
|
@@ -774,30 +940,21 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
774
940
|
// Stop scanning
|
|
775
941
|
async function stopScanning(): Promise<void> {
|
|
776
942
|
if (!noble) return;
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
resolve();
|
|
781
|
-
return;
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
noble.stopScanning(() => {
|
|
785
|
-
logger?.info('[NobleBLE] Scanning stopped');
|
|
786
|
-
resolve();
|
|
787
|
-
});
|
|
788
|
-
});
|
|
943
|
+
const nobleInstance = noble;
|
|
944
|
+
await waitForNobleScanStop(nobleInstance);
|
|
945
|
+
logger?.info('[NobleBLE] Scanning stopped');
|
|
789
946
|
}
|
|
790
947
|
|
|
791
948
|
// Clean up all Noble listeners (for app exit)
|
|
792
949
|
function cleanupNobleListeners(): void {
|
|
793
950
|
if (!noble) return;
|
|
794
951
|
|
|
795
|
-
// Remove all listeners to prevent memory leaks
|
|
796
|
-
// Noble uses EventEmitter, need to use removeAllListeners
|
|
797
952
|
try {
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
953
|
+
if (persistentDiscoverListener) {
|
|
954
|
+
noble.removeListener('discover', persistentDiscoverListener);
|
|
955
|
+
persistentDiscoverListener = null;
|
|
956
|
+
}
|
|
957
|
+
logger?.info('[NobleBLE] Owned Noble listeners cleaned up');
|
|
801
958
|
} catch (error) {
|
|
802
959
|
logger?.error('[NobleBLE] Failed to clean up some listeners:', error);
|
|
803
960
|
}
|
|
@@ -814,6 +971,7 @@ function getDevice(deviceId: string): DeviceInfo | null {
|
|
|
814
971
|
id: peripheral.id,
|
|
815
972
|
name: deviceName,
|
|
816
973
|
state: peripheral.state || 'disconnected',
|
|
974
|
+
...(typeof peripheral.mtu === 'number' ? { mtu: peripheral.mtu } : {}),
|
|
817
975
|
};
|
|
818
976
|
}
|
|
819
977
|
|
|
@@ -826,6 +984,7 @@ function getDevice(deviceId: string): DeviceInfo | null {
|
|
|
826
984
|
id: connectedPeripheral.id,
|
|
827
985
|
name: deviceName,
|
|
828
986
|
state: connectedPeripheral.state || 'connected',
|
|
987
|
+
...(typeof connectedPeripheral.mtu === 'number' ? { mtu: connectedPeripheral.mtu } : {}),
|
|
829
988
|
};
|
|
830
989
|
}
|
|
831
990
|
|
|
@@ -905,14 +1064,7 @@ async function discoverServicesAndCharacteristics(
|
|
|
905
1064
|
});
|
|
906
1065
|
|
|
907
1066
|
// Find OneKey service — Noble may expose 128-bit UUIDs as short UUID keys.
|
|
908
|
-
|
|
909
|
-
if (!service) {
|
|
910
|
-
logger?.info('[NobleBLE] Known OneKey service UUID not found, trying first vendor service');
|
|
911
|
-
service =
|
|
912
|
-
services.find(s => PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS.has(getBleUuidKey(s.uuid))) ||
|
|
913
|
-
services.find(s => !isGenericBleService(s.uuid)) ||
|
|
914
|
-
services[0];
|
|
915
|
-
}
|
|
1067
|
+
const service = services.find(s => matchesKnownBleUuid(s.uuid, ONEKEY_SERVICE_UUID_ALIASES));
|
|
916
1068
|
if (!service) {
|
|
917
1069
|
throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound);
|
|
918
1070
|
}
|
|
@@ -938,12 +1090,9 @@ async function discoverServicesAndCharacteristics(
|
|
|
938
1090
|
let notifyCharacteristic: Characteristic | null = null;
|
|
939
1091
|
|
|
940
1092
|
for (const characteristic of characteristics) {
|
|
941
|
-
|
|
942
|
-
const uuidKey = uuid.length >= 8 ? uuid.substring(4, 8) : uuid;
|
|
943
|
-
|
|
944
|
-
if (uuidKey === NORMALIZED_WRITE_UUID) {
|
|
1093
|
+
if (matchesKnownBleUuid(characteristic.uuid, ONEKEY_WRITE_UUID_ALIASES)) {
|
|
945
1094
|
writeCharacteristic = characteristic;
|
|
946
|
-
} else if (
|
|
1095
|
+
} else if (matchesKnownBleUuid(characteristic.uuid, ONEKEY_NOTIFY_UUID_ALIASES)) {
|
|
947
1096
|
notifyCharacteristic = characteristic;
|
|
948
1097
|
}
|
|
949
1098
|
}
|
|
@@ -1000,29 +1149,23 @@ async function forceReconnectPeripheral(peripheral: Peripheral, deviceId: string
|
|
|
1000
1149
|
|
|
1001
1150
|
// Step 2: Force disconnect if connected
|
|
1002
1151
|
if (peripheral.state === 'connected') {
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1152
|
+
await runBleCallbackOperation(
|
|
1153
|
+
callback =>
|
|
1154
|
+
peripheral.disconnect(() => {
|
|
1155
|
+
logger?.info('[NobleBLE] Force disconnect completed');
|
|
1156
|
+
callback();
|
|
1157
|
+
}),
|
|
1158
|
+
{ timeoutMs: BLE_CLEANUP_TIMEOUT, timeoutBehavior: 'resolve' }
|
|
1159
|
+
);
|
|
1011
1160
|
}
|
|
1012
1161
|
|
|
1013
1162
|
// Step 3: Re-establish connection
|
|
1014
|
-
await
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
logger?.error('[NobleBLE] Force reconnect failed:', error);
|
|
1018
|
-
reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
|
|
1019
|
-
} else {
|
|
1020
|
-
logger?.info('[NobleBLE] Force reconnect successful');
|
|
1021
|
-
connectedDevices.set(deviceId, peripheral);
|
|
1022
|
-
resolve();
|
|
1023
|
-
}
|
|
1024
|
-
});
|
|
1163
|
+
await runBleCallbackOperation(callback => peripheral.connect(callback), {
|
|
1164
|
+
timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
|
|
1165
|
+
timeoutBehavior: 'reject',
|
|
1025
1166
|
});
|
|
1167
|
+
logger?.info('[NobleBLE] Force reconnect successful');
|
|
1168
|
+
connectedDevices.set(deviceId, peripheral);
|
|
1026
1169
|
|
|
1027
1170
|
// Wait for connection to stabilize
|
|
1028
1171
|
await wait(500);
|
|
@@ -1153,20 +1296,100 @@ async function setupConnectionAndDiscoverServices(
|
|
|
1153
1296
|
deviceId: string,
|
|
1154
1297
|
webContents: WebContents
|
|
1155
1298
|
): Promise<CharacteristicPair> {
|
|
1156
|
-
// Force reconnect to clear GATT cache
|
|
1157
|
-
await forceReconnectPeripheral(peripheral, deviceId);
|
|
1158
1299
|
setupDisconnectListener(peripheral, deviceId, webContents);
|
|
1159
1300
|
|
|
1160
|
-
// Discover services and characteristics with retry
|
|
1161
1301
|
try {
|
|
1162
1302
|
return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
|
|
1163
|
-
} catch (
|
|
1164
|
-
|
|
1165
|
-
|
|
1303
|
+
} catch (initialError) {
|
|
1304
|
+
logger?.info('[NobleBLE] Direct service discovery failed, retrying after reconnect', {
|
|
1305
|
+
deviceId,
|
|
1306
|
+
error: initialError,
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
await forceReconnectPeripheral(peripheral, deviceId);
|
|
1311
|
+
setupDisconnectListener(peripheral, deviceId, webContents);
|
|
1312
|
+
try {
|
|
1313
|
+
return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
|
|
1314
|
+
} catch (reconnectError) {
|
|
1315
|
+
logger?.error(
|
|
1316
|
+
'[NobleBLE] Service discovery failed after reconnect, attempting fresh scan...',
|
|
1317
|
+
reconnectError
|
|
1318
|
+
);
|
|
1166
1319
|
return freshScanAndDiscover(deviceId, webContents);
|
|
1167
1320
|
}
|
|
1168
1321
|
}
|
|
1169
1322
|
|
|
1323
|
+
// noble/mac never resolves for an id CoreBluetooth cannot retrieve.
|
|
1324
|
+
const DIRECT_CONNECT_TIMEOUT_MS = 2000;
|
|
1325
|
+
// Floor after a timeout: do not pay the probe on every attempt.
|
|
1326
|
+
const DIRECT_CONNECT_COOLDOWN_MS = 15_000;
|
|
1327
|
+
const directConnectCooldownUntil = new Map<string, number>();
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* Connect straight by id, skipping the ~650ms targeted scan. Both native
|
|
1331
|
+
* backends support it and emit a `discover` as a side effect: macOS resolves
|
|
1332
|
+
* via `retrievePeripheralsWithIdentifiers`, Windows synthesizes one for an
|
|
1333
|
+
* unknown address. Returns undefined when unavailable so the caller scans.
|
|
1334
|
+
*
|
|
1335
|
+
* Ported from the Trezor connector, where it is field-proven.
|
|
1336
|
+
*/
|
|
1337
|
+
async function tryDirectConnectById(deviceId: string): Promise<Peripheral | undefined> {
|
|
1338
|
+
const nobleInstance = noble as
|
|
1339
|
+
| (typeof noble & {
|
|
1340
|
+
connectAsync?: (id: string) => Promise<Peripheral | undefined>;
|
|
1341
|
+
})
|
|
1342
|
+
| null;
|
|
1343
|
+
if (!nobleInstance?.connectAsync) return undefined;
|
|
1344
|
+
|
|
1345
|
+
const cooldownUntil = directConnectCooldownUntil.get(deviceId) ?? 0;
|
|
1346
|
+
if (Date.now() < cooldownUntil) return undefined;
|
|
1347
|
+
|
|
1348
|
+
try {
|
|
1349
|
+
// The late-orphan guard must attach to THIS pending connect.
|
|
1350
|
+
const directPromise = nobleInstance.connectAsync(deviceId);
|
|
1351
|
+
const raced = await Promise.race([
|
|
1352
|
+
directPromise,
|
|
1353
|
+
new Promise<'timeout'>(resolve => {
|
|
1354
|
+
setTimeout(() => resolve('timeout'), DIRECT_CONNECT_TIMEOUT_MS);
|
|
1355
|
+
}),
|
|
1356
|
+
]);
|
|
1357
|
+
if (raced === 'timeout') {
|
|
1358
|
+
directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
|
|
1359
|
+
logger?.info('[NobleBLE] Direct connect-by-id timed out, falling back to scan', {
|
|
1360
|
+
deviceId,
|
|
1361
|
+
});
|
|
1362
|
+
// Promise.race times out the caller only; a late success orphans the link.
|
|
1363
|
+
directPromise
|
|
1364
|
+
.then(late => {
|
|
1365
|
+
const latePeripheral = late ?? discoveredDevices.get(deviceId);
|
|
1366
|
+
if (
|
|
1367
|
+
latePeripheral &&
|
|
1368
|
+
latePeripheral.state === 'connected' &&
|
|
1369
|
+
!connectedDevices.has(deviceId)
|
|
1370
|
+
) {
|
|
1371
|
+
latePeripheral.removeAllListeners('disconnect');
|
|
1372
|
+
latePeripheral.disconnect(() => undefined);
|
|
1373
|
+
}
|
|
1374
|
+
})
|
|
1375
|
+
.catch(() => undefined);
|
|
1376
|
+
return undefined;
|
|
1377
|
+
}
|
|
1378
|
+
// Backends emit `discover` as a side effect, so the cache may hold it.
|
|
1379
|
+
const peripheral = raced ?? discoveredDevices.get(deviceId);
|
|
1380
|
+
if (!peripheral || peripheral.state !== 'connected') return undefined;
|
|
1381
|
+
logger?.info('[NobleBLE] Direct connect-by-id succeeded', { deviceId });
|
|
1382
|
+
return peripheral;
|
|
1383
|
+
} catch (error) {
|
|
1384
|
+
directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
|
|
1385
|
+
logger?.info('[NobleBLE] Direct connect-by-id failed, falling back to scan', {
|
|
1386
|
+
deviceId,
|
|
1387
|
+
error: String(error),
|
|
1388
|
+
});
|
|
1389
|
+
return undefined;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1170
1393
|
// Connect to device - supports both discovered and direct connection modes
|
|
1171
1394
|
async function connectDevice(deviceId: string, webContents: WebContents): Promise<void> {
|
|
1172
1395
|
logger?.info('[NobleBLE] Connect device request:', {
|
|
@@ -1178,7 +1401,8 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1178
1401
|
totalConnected: connectedDevices.size,
|
|
1179
1402
|
});
|
|
1180
1403
|
|
|
1181
|
-
|
|
1404
|
+
// enumerate clears the discovery map; a kept-alive link outlives it.
|
|
1405
|
+
let peripheral = discoveredDevices.get(deviceId) ?? connectedDevices.get(deviceId);
|
|
1182
1406
|
|
|
1183
1407
|
// If device not discovered, try a targeted scan for this specific device
|
|
1184
1408
|
if (!peripheral) {
|
|
@@ -1193,6 +1417,14 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1193
1417
|
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
|
|
1194
1418
|
}
|
|
1195
1419
|
|
|
1420
|
+
// Reaches a bonded device that is not advertising; else falls to the scan.
|
|
1421
|
+
peripheral = await tryDirectConnectById(deviceId);
|
|
1422
|
+
if (peripheral) {
|
|
1423
|
+
discoveredDevices.set(deviceId, peripheral);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
if (!peripheral) {
|
|
1196
1428
|
// Perform a targeted scan to find the specific device
|
|
1197
1429
|
try {
|
|
1198
1430
|
const foundPeripheral = await performTargetedScan(deviceId);
|
|
@@ -1223,9 +1455,11 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1223
1455
|
// If already connected but not in our connected devices map, add it
|
|
1224
1456
|
if (!connectedDevices.has(deviceId)) {
|
|
1225
1457
|
connectedDevices.set(deviceId, peripheral);
|
|
1226
|
-
// Set up unified disconnect listener
|
|
1227
|
-
setupDisconnectListener(peripheral, deviceId, webContents);
|
|
1228
1458
|
}
|
|
1459
|
+
// Re-bind unconditionally (idempotent): on a kept-alive link the disconnect
|
|
1460
|
+
// and MTU listeners may still hold the webContents of a soft-restarted
|
|
1461
|
+
// renderer, and the reuse fast path below returns before any other setup.
|
|
1462
|
+
setupDisconnectListener(peripheral, deviceId, webContents);
|
|
1229
1463
|
|
|
1230
1464
|
// Check if we already have characteristics for this device
|
|
1231
1465
|
if (deviceCharacteristics.has(deviceId)) {
|
|
@@ -1269,26 +1503,48 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1269
1503
|
}
|
|
1270
1504
|
|
|
1271
1505
|
// Setup connection and discover services
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1506
|
+
try {
|
|
1507
|
+
const characteristics = await setupConnectionAndDiscoverServices(
|
|
1508
|
+
peripheral,
|
|
1509
|
+
deviceId,
|
|
1510
|
+
webContents
|
|
1511
|
+
);
|
|
1512
|
+
deviceCharacteristics.set(deviceId, characteristics);
|
|
1513
|
+
logger?.info('[NobleBLE] Device ready for communication:', deviceId);
|
|
1514
|
+
} catch (setupError) {
|
|
1515
|
+
// The caller re-arms the timer only on success, so a left-up link strands.
|
|
1516
|
+
logger?.error('[NobleBLE] Connection setup failed on kept-alive link:', setupError);
|
|
1517
|
+
await disconnectDevice(deviceId).catch(() => undefined);
|
|
1518
|
+
throw setupError;
|
|
1519
|
+
}
|
|
1279
1520
|
return;
|
|
1280
1521
|
}
|
|
1281
1522
|
|
|
1282
1523
|
return new Promise((resolve, reject) => {
|
|
1524
|
+
let connectionTimedOut = false;
|
|
1283
1525
|
const timeout = setTimeout(() => {
|
|
1526
|
+
connectionTimedOut = true;
|
|
1284
1527
|
reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'Connection timeout'));
|
|
1285
|
-
},
|
|
1528
|
+
}, NOBLE_BLE_CONNECTION_TIMEOUT_MS);
|
|
1286
1529
|
|
|
1287
1530
|
// TypeScript type assertion - peripheral is guaranteed to be defined at this point
|
|
1288
1531
|
const connectedPeripheral = peripheral as Peripheral;
|
|
1289
1532
|
connectedPeripheral.connect(async (error: Error | undefined) => {
|
|
1290
1533
|
clearTimeout(timeout);
|
|
1291
1534
|
|
|
1535
|
+
// Noble may invoke the callback after the SDK timed out and released the request.
|
|
1536
|
+
// Ignore it to avoid initializing disposed commands or leaving an orphaned connection.
|
|
1537
|
+
if (connectionTimedOut) {
|
|
1538
|
+
if (!error) {
|
|
1539
|
+
try {
|
|
1540
|
+
connectedPeripheral.disconnect(() => undefined);
|
|
1541
|
+
} catch {
|
|
1542
|
+
// Best-effort cleanup only.
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1292
1548
|
if (error) {
|
|
1293
1549
|
logger?.error('[NobleBLE] Connection failed:', error);
|
|
1294
1550
|
reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
|
|
@@ -1325,20 +1581,25 @@ async function disconnectDevice(deviceId: string): Promise<void> {
|
|
|
1325
1581
|
return;
|
|
1326
1582
|
}
|
|
1327
1583
|
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
peripheral.
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1584
|
+
const disconnectEntry = deviceDisconnectListeners.get(deviceId);
|
|
1585
|
+
if (disconnectEntry) {
|
|
1586
|
+
disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
|
|
1587
|
+
deviceDisconnectListeners.delete(deviceId);
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
|
|
1591
|
+
timeoutMs: BLE_CLEANUP_TIMEOUT,
|
|
1592
|
+
timeoutBehavior: 'resolve',
|
|
1593
|
+
});
|
|
1594
|
+
cleanupDevice(deviceId, undefined, {
|
|
1595
|
+
cleanupConnection: true,
|
|
1596
|
+
// macOS returns zero GATT services when a previously-disconnected
|
|
1597
|
+
// peripheral object is reconnected; drop the discovery cache so the next
|
|
1598
|
+
// connect resolves a fresh peripheral (direct connect by id, or scan).
|
|
1599
|
+
cleanupDiscoveredCache: true,
|
|
1600
|
+
sendDisconnectEvent: false,
|
|
1601
|
+
cancelOperations: true,
|
|
1602
|
+
reason: 'manual-disconnect',
|
|
1342
1603
|
});
|
|
1343
1604
|
}
|
|
1344
1605
|
|
|
@@ -1359,17 +1620,11 @@ async function unsubscribeNotifications(deviceId: string): Promise<void> {
|
|
|
1359
1620
|
subscriptionOperations.set(deviceId, 'unsubscribing');
|
|
1360
1621
|
|
|
1361
1622
|
try {
|
|
1362
|
-
await
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
logger?.error('[NobleBLE] Notification unsubscription failed:', error);
|
|
1366
|
-
reject(error);
|
|
1367
|
-
} else {
|
|
1368
|
-
logger?.info('[NobleBLE] Notification unsubscription successful');
|
|
1369
|
-
resolve();
|
|
1370
|
-
}
|
|
1371
|
-
});
|
|
1623
|
+
await runBleCallbackOperation(callback => notifyCharacteristic.unsubscribe(callback), {
|
|
1624
|
+
timeoutMs: BLE_CLEANUP_TIMEOUT,
|
|
1625
|
+
timeoutBehavior: 'resolve',
|
|
1372
1626
|
});
|
|
1627
|
+
logger?.info('[NobleBLE] Notification unsubscription completed');
|
|
1373
1628
|
|
|
1374
1629
|
// Remove all listeners and clear subscription status
|
|
1375
1630
|
notifyCharacteristic.removeAllListeners('data');
|
|
@@ -1462,19 +1717,13 @@ async function subscribeNotifications(
|
|
|
1462
1717
|
notifyCharacteristic: Characteristic
|
|
1463
1718
|
): Promise<void> {
|
|
1464
1719
|
// Listeners already cleaned up above, no need to repeat
|
|
1465
|
-
await
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
});
|
|
1720
|
+
await runBleCallbackOperation(callback => notifyCharacteristic.unsubscribe(callback), {
|
|
1721
|
+
timeoutMs: BLE_CLEANUP_TIMEOUT,
|
|
1722
|
+
timeoutBehavior: 'resolve',
|
|
1469
1723
|
});
|
|
1470
|
-
await
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
reject(error);
|
|
1474
|
-
return;
|
|
1475
|
-
}
|
|
1476
|
-
resolve();
|
|
1477
|
-
});
|
|
1724
|
+
await runBleCallbackOperation(callback => notifyCharacteristic.subscribe(callback), {
|
|
1725
|
+
timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
|
|
1726
|
+
timeoutBehavior: 'reject',
|
|
1478
1727
|
});
|
|
1479
1728
|
|
|
1480
1729
|
notifyCharacteristic.on('data', (data: Buffer) => {
|
|
@@ -1506,12 +1755,18 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
1506
1755
|
|
|
1507
1756
|
// @ts-ignore – electron is only available at runtime
|
|
1508
1757
|
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
|
1509
|
-
const { ipcMain } = require('electron');
|
|
1758
|
+
const { ipcMain } = require('electron') as { ipcMain: IpcMain };
|
|
1759
|
+
|
|
1760
|
+
// Electron throws on duplicate channels and setup re-runs on soft restart.
|
|
1761
|
+
const handle: IpcMain['handle'] = (channel, listener) => {
|
|
1762
|
+
ipcMain.removeHandler(channel);
|
|
1763
|
+
ipcMain.handle(channel, listener);
|
|
1764
|
+
};
|
|
1510
1765
|
|
|
1511
1766
|
safeLog(logger, 'info', 'Setting up Noble BLE IPC handlers');
|
|
1512
1767
|
|
|
1513
1768
|
// Handle enumerate request
|
|
1514
|
-
|
|
1769
|
+
handle(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE, async () => {
|
|
1515
1770
|
try {
|
|
1516
1771
|
const devices = await enumerateDevices();
|
|
1517
1772
|
safeLog(logger, 'debug', 'Enumeration completed', {
|
|
@@ -1526,18 +1781,18 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
1526
1781
|
});
|
|
1527
1782
|
|
|
1528
1783
|
// Handle stop scan request
|
|
1529
|
-
|
|
1784
|
+
handle(EOneKeyBleMessageKeys.NOBLE_BLE_STOP_SCAN, async () => {
|
|
1530
1785
|
await stopScanning();
|
|
1531
1786
|
});
|
|
1532
1787
|
|
|
1533
1788
|
// Handle get device request
|
|
1534
|
-
|
|
1789
|
+
handle(
|
|
1535
1790
|
EOneKeyBleMessageKeys.NOBLE_BLE_GET_DEVICE,
|
|
1536
1791
|
(_event: IpcMainInvokeEvent, deviceId: string) => getDevice(deviceId)
|
|
1537
1792
|
);
|
|
1538
1793
|
|
|
1539
1794
|
// Handle connect request
|
|
1540
|
-
|
|
1795
|
+
handle(
|
|
1541
1796
|
EOneKeyBleMessageKeys.NOBLE_BLE_CONNECT,
|
|
1542
1797
|
async (_event: IpcMainInvokeEvent, deviceId: string) => {
|
|
1543
1798
|
logger?.info('[NobleBLE] IPC CONNECT request received:', {
|
|
@@ -1546,12 +1801,37 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
1546
1801
|
hasCharacteristics: deviceCharacteristics.has(deviceId),
|
|
1547
1802
|
totalConnectedDevices: connectedDevices.size,
|
|
1548
1803
|
});
|
|
1549
|
-
|
|
1804
|
+
// Must not fire mid-connect (pairing can take ~30s).
|
|
1805
|
+
clearIdleDisconnect(deviceId);
|
|
1806
|
+
// A fired timer must settle first, or the fast path returns a dying link.
|
|
1807
|
+
await awaitIdleDisconnect(deviceId);
|
|
1808
|
+
try {
|
|
1809
|
+
await connectDevice(deviceId, webContents);
|
|
1810
|
+
} finally {
|
|
1811
|
+
// This timer is the only thing that frees a kept-alive link.
|
|
1812
|
+
if (connectedDevices.has(deviceId)) {
|
|
1813
|
+
armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
);
|
|
1818
|
+
|
|
1819
|
+
// Logical release: start the idle countdown, keep the link for the next call.
|
|
1820
|
+
handle(
|
|
1821
|
+
EOneKeyBleMessageKeys.NOBLE_BLE_RELEASE,
|
|
1822
|
+
(_event: IpcMainInvokeEvent, deviceId: string, keepSession?: boolean) => {
|
|
1823
|
+
if (!connectedDevices.has(deviceId)) return;
|
|
1824
|
+
// Mid-flow caller will be back; the short window would cut an update.
|
|
1825
|
+
if (keepSession) {
|
|
1826
|
+
armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
armIdleDisconnect(deviceId);
|
|
1550
1830
|
}
|
|
1551
1831
|
);
|
|
1552
1832
|
|
|
1553
1833
|
// Handle disconnect request
|
|
1554
|
-
|
|
1834
|
+
handle(
|
|
1555
1835
|
EOneKeyBleMessageKeys.NOBLE_BLE_DISCONNECT,
|
|
1556
1836
|
async (_event: IpcMainInvokeEvent, deviceId: string) => {
|
|
1557
1837
|
await disconnectDevice(deviceId);
|
|
@@ -1559,34 +1839,42 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
1559
1839
|
);
|
|
1560
1840
|
|
|
1561
1841
|
// Handle write request
|
|
1562
|
-
|
|
1842
|
+
handle(
|
|
1563
1843
|
EOneKeyBleMessageKeys.NOBLE_BLE_WRITE,
|
|
1564
|
-
async (
|
|
1565
|
-
|
|
1844
|
+
async (
|
|
1845
|
+
_event: IpcMainInvokeEvent,
|
|
1846
|
+
deviceId: string,
|
|
1847
|
+
hexData: string,
|
|
1848
|
+
options?: NobleBleWriteOptions
|
|
1849
|
+
) => {
|
|
1850
|
+
await transmitHexDataToDevice(deviceId, hexData, options);
|
|
1566
1851
|
}
|
|
1567
1852
|
);
|
|
1568
1853
|
|
|
1569
1854
|
// Handle subscribe request
|
|
1570
|
-
|
|
1855
|
+
handle(
|
|
1571
1856
|
EOneKeyBleMessageKeys.NOBLE_BLE_SUBSCRIBE,
|
|
1572
1857
|
async (_event: IpcMainInvokeEvent, deviceId: string) => {
|
|
1573
1858
|
await subscribeNotifications(deviceId, (data: string) => {
|
|
1574
1859
|
// Send data back to renderer process
|
|
1575
1860
|
webContents.send(EOneKeyBleMessageKeys.NOBLE_BLE_NOTIFICATION, deviceId, data);
|
|
1576
1861
|
});
|
|
1862
|
+
// Still acquiring (in flight) — busy backstop, not the idle clock.
|
|
1863
|
+
armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
|
|
1577
1864
|
}
|
|
1578
1865
|
);
|
|
1579
1866
|
|
|
1580
1867
|
// Handle unsubscribe request
|
|
1581
|
-
|
|
1868
|
+
handle(
|
|
1582
1869
|
EOneKeyBleMessageKeys.NOBLE_BLE_UNSUBSCRIBE,
|
|
1583
1870
|
async (_event: IpcMainInvokeEvent, deviceId: string) => {
|
|
1584
1871
|
await unsubscribeNotifications(deviceId);
|
|
1872
|
+
armIdleDisconnect(deviceId);
|
|
1585
1873
|
}
|
|
1586
1874
|
);
|
|
1587
1875
|
|
|
1588
1876
|
// Handle cancel pairing: cleanup all connected devices
|
|
1589
|
-
|
|
1877
|
+
handle(EOneKeyBleMessageKeys.NOBLE_BLE_CANCEL_PAIRING, async () => {
|
|
1590
1878
|
const deviceIds = Array.from(connectedDevices.keys());
|
|
1591
1879
|
logger?.info('[NobleBLE] Cancel pairing invoked', {
|
|
1592
1880
|
platform: process.platform,
|
|
@@ -1607,7 +1895,7 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
1607
1895
|
});
|
|
1608
1896
|
|
|
1609
1897
|
// Handle Bluetooth availability check request
|
|
1610
|
-
|
|
1898
|
+
handle(EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK, async () => {
|
|
1611
1899
|
try {
|
|
1612
1900
|
const bluetoothStatus = await checkBluetoothAvailability();
|
|
1613
1901
|
safeLog(logger, 'info', 'Bluetooth availability check completed:', bluetoothStatus);
|
|
@@ -1626,32 +1914,26 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
1626
1914
|
// Cleanup on app quit
|
|
1627
1915
|
webContents.on('destroyed', () => {
|
|
1628
1916
|
safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
|
|
1917
|
+
(async () => {
|
|
1918
|
+
const deviceIds = Array.from(connectedDevices.keys());
|
|
1919
|
+
for (const deviceId of deviceIds) {
|
|
1920
|
+
await unsubscribeNotifications(deviceId).catch(() => undefined);
|
|
1921
|
+
await disconnectDevice(deviceId).catch(() => undefined);
|
|
1922
|
+
}
|
|
1629
1923
|
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1924
|
+
await stopScanning().catch(() => undefined);
|
|
1925
|
+
// persistentStateListener is process-lifetime, NOT per-window: this
|
|
1926
|
+
// destroy handler also fires on a renderer soft restart, and removing
|
|
1927
|
+
// the listener here permanently killed poweredOff/poweredOn
|
|
1928
|
+
// reconciliation for the rest of the process (nothing re-registers it:
|
|
1929
|
+
// initializeNoble early-returns once noble is loaded). The null-guard
|
|
1930
|
+
// in setupPersistentStateListener keeps it deduped to one instance.
|
|
1931
|
+
cleanupNobleListeners();
|
|
1932
|
+
discoveredDevices.clear();
|
|
1933
|
+
safeLog(logger, 'info', 'Noble BLE cleanup completed');
|
|
1934
|
+
})().catch(error => {
|
|
1935
|
+
safeLog(logger, 'error', 'Noble BLE cleanup failed', error);
|
|
1639
1936
|
});
|
|
1640
|
-
|
|
1641
|
-
// 2. Stop scanning
|
|
1642
|
-
stopScanning();
|
|
1643
|
-
|
|
1644
|
-
// 3. Clean up Noble listeners
|
|
1645
|
-
if (noble && persistentStateListener) {
|
|
1646
|
-
noble.removeListener('stateChange', persistentStateListener);
|
|
1647
|
-
persistentStateListener = null;
|
|
1648
|
-
}
|
|
1649
|
-
cleanupNobleListeners();
|
|
1650
|
-
|
|
1651
|
-
// 4. Clear discovered devices cache
|
|
1652
|
-
discoveredDevices.clear();
|
|
1653
|
-
|
|
1654
|
-
safeLog(logger, 'info', 'Noble BLE cleanup completed');
|
|
1655
1937
|
});
|
|
1656
1938
|
|
|
1657
1939
|
safeLog(logger, 'info', 'Noble BLE IPC handlers setup completed');
|