@onekeyfe/hd-transport-electron 1.2.0-alpha.13 → 1.2.0-alpha.131
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-fe6d3919.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-c88e98df.js} +456 -231
- 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 +518 -247
- 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
|
/**
|
|
@@ -290,9 +306,12 @@ async function initializeNoble(): Promise<void> {
|
|
|
290
306
|
});
|
|
291
307
|
|
|
292
308
|
// Set up device discovery
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
309
|
+
if (!persistentDiscoverListener) {
|
|
310
|
+
persistentDiscoverListener = (peripheral: Peripheral) => {
|
|
311
|
+
handleDeviceDiscovered(peripheral);
|
|
312
|
+
};
|
|
313
|
+
noble.on('discover', persistentDiscoverListener);
|
|
314
|
+
}
|
|
296
315
|
|
|
297
316
|
logger?.info('[NobleBLE] Noble initialized successfully');
|
|
298
317
|
} catch (error) {
|
|
@@ -323,6 +342,76 @@ interface DeviceCleanupOptions {
|
|
|
323
342
|
reason?: string;
|
|
324
343
|
}
|
|
325
344
|
|
|
345
|
+
// One timer per device, in the main process so a reload cannot orphan a link.
|
|
346
|
+
const idleDisconnectTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
347
|
+
|
|
348
|
+
// A fired timer cannot be cancelled; connect awaits its disconnect or the fast
|
|
349
|
+
// path hands back a link torn down a moment later (BleTimeoutError 713).
|
|
350
|
+
const idleDisconnectInFlight = new Map<string, Promise<void>>();
|
|
351
|
+
|
|
352
|
+
function clearIdleDisconnect(deviceId: string): void {
|
|
353
|
+
const timer = idleDisconnectTimers.get(deviceId);
|
|
354
|
+
if (timer) {
|
|
355
|
+
clearTimeout(timer);
|
|
356
|
+
idleDisconnectTimers.delete(deviceId);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Settle any keep-alive disconnect already running for this device. */
|
|
361
|
+
async function awaitIdleDisconnect(deviceId: string): Promise<void> {
|
|
362
|
+
const pending = idleDisconnectInFlight.get(deviceId);
|
|
363
|
+
if (pending) await pending.catch(() => undefined);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function broadcastToAllWebContents(channel: string, payload: unknown): void {
|
|
367
|
+
try {
|
|
368
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require
|
|
369
|
+
const { webContents: electronWebContents } = require('electron') as {
|
|
370
|
+
webContents: { getAllWebContents(): WebContents[] };
|
|
371
|
+
};
|
|
372
|
+
for (const wc of electronWebContents.getAllWebContents()) {
|
|
373
|
+
if (!wc.isDestroyed()) wc.send(channel, payload);
|
|
374
|
+
}
|
|
375
|
+
} catch (error) {
|
|
376
|
+
logger?.error('[NobleBLE] broadcast failed:', { channel, error: String(error) });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function armIdleDisconnect(
|
|
381
|
+
deviceId: string,
|
|
382
|
+
ms: number = BLE_IDLE_DISCONNECT_MS,
|
|
383
|
+
reason: 'idle' | 'busy-backstop' = 'idle'
|
|
384
|
+
): void {
|
|
385
|
+
clearIdleDisconnect(deviceId);
|
|
386
|
+
idleDisconnectTimers.set(
|
|
387
|
+
deviceId,
|
|
388
|
+
setTimeout(() => {
|
|
389
|
+
idleDisconnectTimers.delete(deviceId);
|
|
390
|
+
if (!connectedDevices.has(deviceId)) return;
|
|
391
|
+
logger?.info('[NobleBLE] Keep-alive timeout, disconnecting device', { deviceId, reason });
|
|
392
|
+
const peripheral = connectedDevices.get(deviceId);
|
|
393
|
+
const deviceName = peripheral?.advertisement?.localName || 'Unknown Device';
|
|
394
|
+
const pending = disconnectDevice(deviceId)
|
|
395
|
+
.then(() => {
|
|
396
|
+
// A call is still in flight here; it must reject, not hang.
|
|
397
|
+
broadcastToAllWebContents(EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
|
|
398
|
+
id: deviceId,
|
|
399
|
+
name: deviceName,
|
|
400
|
+
});
|
|
401
|
+
})
|
|
402
|
+
.catch(error => {
|
|
403
|
+
logger?.error('[NobleBLE] Keep-alive disconnect failed', { deviceId, error });
|
|
404
|
+
})
|
|
405
|
+
.finally(() => {
|
|
406
|
+
if (idleDisconnectInFlight.get(deviceId) === pending) {
|
|
407
|
+
idleDisconnectInFlight.delete(deviceId);
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
idleDisconnectInFlight.set(deviceId, pending);
|
|
411
|
+
}, ms)
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
326
415
|
/**
|
|
327
416
|
* Unified device cleanup function - single entry point for all cleanup operations
|
|
328
417
|
*/
|
|
@@ -348,12 +437,24 @@ function cleanupDevice(
|
|
|
348
437
|
cancelOperations,
|
|
349
438
|
});
|
|
350
439
|
|
|
440
|
+
clearIdleDisconnect(deviceId);
|
|
441
|
+
|
|
351
442
|
// Get device info before cleanup
|
|
352
443
|
const peripheral = connectedDevices.get(deviceId);
|
|
353
444
|
const deviceName = peripheral?.advertisement?.localName || 'Unknown Device';
|
|
354
445
|
|
|
355
446
|
// 1. Clean up connection state
|
|
356
447
|
if (cleanupConnection) {
|
|
448
|
+
const disconnectEntry = deviceDisconnectListeners.get(deviceId);
|
|
449
|
+
if (disconnectEntry) {
|
|
450
|
+
disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
|
|
451
|
+
deviceDisconnectListeners.delete(deviceId);
|
|
452
|
+
}
|
|
453
|
+
const mtuEntry = deviceMtuListeners.get(deviceId);
|
|
454
|
+
if (mtuEntry) {
|
|
455
|
+
mtuEntry.peripheral.removeListener('mtu', mtuEntry.listener);
|
|
456
|
+
deviceMtuListeners.delete(deviceId);
|
|
457
|
+
}
|
|
357
458
|
connectedDevices.delete(deviceId);
|
|
358
459
|
deviceCharacteristics.delete(deviceId);
|
|
359
460
|
notificationCallbacks.delete(deviceId);
|
|
@@ -367,9 +468,11 @@ function cleanupDevice(
|
|
|
367
468
|
discoveredDevices.delete(deviceId);
|
|
368
469
|
}
|
|
369
470
|
|
|
370
|
-
// 3. Send disconnect event (if needed)
|
|
371
|
-
|
|
372
|
-
|
|
471
|
+
// 3. Send disconnect event (if needed). Broadcast, not the captured
|
|
472
|
+
// webContents: a kept-alive link outlives a renderer soft restart, and the
|
|
473
|
+
// new renderer still needs to hear the device dropped (same as the idle path).
|
|
474
|
+
if (sendDisconnectEvent) {
|
|
475
|
+
broadcastToAllWebContents(EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
|
|
373
476
|
id: deviceId,
|
|
374
477
|
name: deviceName,
|
|
375
478
|
});
|
|
@@ -403,18 +506,45 @@ function setupDisconnectListener(
|
|
|
403
506
|
deviceId: string,
|
|
404
507
|
webContents: WebContents
|
|
405
508
|
): void {
|
|
406
|
-
|
|
407
|
-
|
|
509
|
+
const existing = deviceDisconnectListeners.get(deviceId);
|
|
510
|
+
if (existing) {
|
|
511
|
+
existing.peripheral.removeListener('disconnect', existing.listener);
|
|
512
|
+
}
|
|
408
513
|
|
|
409
|
-
|
|
410
|
-
peripheral.on('disconnect', () => {
|
|
514
|
+
const listener = () => {
|
|
411
515
|
handleDeviceDisconnect(deviceId, webContents);
|
|
412
|
-
}
|
|
516
|
+
};
|
|
517
|
+
deviceDisconnectListeners.set(deviceId, { peripheral, listener });
|
|
518
|
+
peripheral.on('disconnect', listener);
|
|
519
|
+
setupMtuListener(peripheral, deviceId, webContents);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function setupMtuListener(
|
|
523
|
+
peripheral: Peripheral,
|
|
524
|
+
deviceId: string,
|
|
525
|
+
webContents: WebContents
|
|
526
|
+
): void {
|
|
527
|
+
const existing = deviceMtuListeners.get(deviceId);
|
|
528
|
+
if (existing) {
|
|
529
|
+
existing.peripheral.removeListener('mtu', existing.listener);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const listener = (mtu: number) => {
|
|
533
|
+
if (!Number.isFinite(mtu) || mtu <= 0) return;
|
|
534
|
+
// A kept-alive link can outlive the renderer this closure captured.
|
|
535
|
+
if (webContents.isDestroyed()) return;
|
|
536
|
+
webContents.send(EOneKeyBleMessageKeys.NOBLE_BLE_MTU_CHANGED, {
|
|
537
|
+
id: deviceId,
|
|
538
|
+
mtu,
|
|
539
|
+
});
|
|
540
|
+
};
|
|
541
|
+
deviceMtuListeners.set(deviceId, { peripheral, listener });
|
|
542
|
+
peripheral.on('mtu', listener);
|
|
413
543
|
}
|
|
414
544
|
|
|
415
545
|
// ===== Write helpers (inline) =====
|
|
416
546
|
|
|
417
|
-
async function
|
|
547
|
+
async function writeCharacteristicWithoutResponse(
|
|
418
548
|
deviceId: string,
|
|
419
549
|
writeCharacteristic: Characteristic,
|
|
420
550
|
buffer: Buffer
|
|
@@ -462,7 +592,7 @@ async function attemptWindowsWriteUntilPaired(
|
|
|
462
592
|
}
|
|
463
593
|
|
|
464
594
|
try {
|
|
465
|
-
await
|
|
595
|
+
await writeCharacteristicWithoutResponse(deviceId, latestWrite, payload);
|
|
466
596
|
} catch (e) {
|
|
467
597
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
468
598
|
logger?.error('[BLE-Write] Windows write error', {
|
|
@@ -534,7 +664,11 @@ async function attemptWindowsWriteUntilPaired(
|
|
|
534
664
|
);
|
|
535
665
|
}
|
|
536
666
|
|
|
537
|
-
async function transmitHexDataToDevice(
|
|
667
|
+
async function transmitHexDataToDevice(
|
|
668
|
+
deviceId: string,
|
|
669
|
+
hexData: string,
|
|
670
|
+
options?: NobleBleWriteOptions
|
|
671
|
+
): Promise<void> {
|
|
538
672
|
const characteristics = deviceCharacteristics.get(deviceId);
|
|
539
673
|
const peripheral = connectedDevices.get(deviceId);
|
|
540
674
|
if (!peripheral || !characteristics) {
|
|
@@ -543,9 +677,17 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
|
|
|
543
677
|
`Device ${deviceId} not connected or characteristics not available`
|
|
544
678
|
);
|
|
545
679
|
}
|
|
680
|
+
// Request outstanding: swap the idle clock for the busy backstop.
|
|
681
|
+
armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
|
|
546
682
|
|
|
547
683
|
const toBuffer = Buffer.from(hexData, 'hex');
|
|
548
684
|
const doGetWriteCharacteristic = () => deviceCharacteristics.get(deviceId)?.write;
|
|
685
|
+
const packetCapacity = resolveBlePacketCapacity(
|
|
686
|
+
peripheral.mtu,
|
|
687
|
+
BLE_PACKET_SIZE_MAXIMUM,
|
|
688
|
+
BLE_PACKET_SIZE_FALLBACK
|
|
689
|
+
);
|
|
690
|
+
const pacingDelayMs = resolveNobleBleWritePacingDelay(options);
|
|
549
691
|
|
|
550
692
|
if (!IS_WINDOWS || pairedDevices.has(deviceId)) {
|
|
551
693
|
// macOS / Linux or already paired on Windows: direct write
|
|
@@ -556,14 +698,14 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
|
|
|
556
698
|
`Write characteristic not available for ${deviceId}`
|
|
557
699
|
);
|
|
558
700
|
}
|
|
559
|
-
if (toBuffer.length <=
|
|
560
|
-
await wait(
|
|
561
|
-
await
|
|
701
|
+
if (toBuffer.length <= packetCapacity) {
|
|
702
|
+
if (pacingDelayMs > 0) await wait(pacingDelayMs);
|
|
703
|
+
await writeCharacteristicWithoutResponse(deviceId, writeCharacteristic, toBuffer);
|
|
562
704
|
return;
|
|
563
705
|
}
|
|
564
706
|
// chunked
|
|
565
707
|
for (let offset = 0; offset < toBuffer.length; ) {
|
|
566
|
-
const chunkSize = Math.min(
|
|
708
|
+
const chunkSize = Math.min(packetCapacity, toBuffer.length - offset);
|
|
567
709
|
const chunk = toBuffer.subarray(offset, offset + chunkSize);
|
|
568
710
|
offset += chunkSize;
|
|
569
711
|
const latest = doGetWriteCharacteristic();
|
|
@@ -573,23 +715,23 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
|
|
|
573
715
|
`Write characteristic not available for ${deviceId}`
|
|
574
716
|
);
|
|
575
717
|
}
|
|
576
|
-
await
|
|
577
|
-
if (offset < toBuffer.length) {
|
|
578
|
-
await wait(
|
|
718
|
+
await writeCharacteristicWithoutResponse(deviceId, latest, chunk);
|
|
719
|
+
if (offset < toBuffer.length && pacingDelayMs > 0) {
|
|
720
|
+
await wait(pacingDelayMs);
|
|
579
721
|
}
|
|
580
722
|
}
|
|
581
723
|
return;
|
|
582
724
|
}
|
|
583
725
|
|
|
584
726
|
// Windows unpaired path: use loop
|
|
585
|
-
if (toBuffer.length <=
|
|
586
|
-
await wait(
|
|
727
|
+
if (toBuffer.length <= packetCapacity) {
|
|
728
|
+
if (pacingDelayMs > 0) await wait(pacingDelayMs);
|
|
587
729
|
await attemptWindowsWriteUntilPaired(deviceId, doGetWriteCharacteristic, toBuffer, 'single');
|
|
588
730
|
return;
|
|
589
731
|
}
|
|
590
732
|
// chunked loop
|
|
591
733
|
for (let offset = 0, idx = 0; offset < toBuffer.length; idx++) {
|
|
592
|
-
const chunkSize = Math.min(
|
|
734
|
+
const chunkSize = Math.min(packetCapacity, toBuffer.length - offset);
|
|
593
735
|
const chunk = toBuffer.subarray(offset, offset + chunkSize);
|
|
594
736
|
offset += chunkSize;
|
|
595
737
|
await attemptWindowsWriteUntilPaired(
|
|
@@ -598,8 +740,8 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
|
|
|
598
740
|
chunk,
|
|
599
741
|
`chunk-${idx + 1}`
|
|
600
742
|
);
|
|
601
|
-
if (offset < toBuffer.length) {
|
|
602
|
-
await wait(
|
|
743
|
+
if (offset < toBuffer.length && pacingDelayMs > 0) {
|
|
744
|
+
await wait(pacingDelayMs);
|
|
603
745
|
}
|
|
604
746
|
}
|
|
605
747
|
}
|
|
@@ -628,19 +770,24 @@ function handleDeviceDiscovered(peripheral: Peripheral): void {
|
|
|
628
770
|
function ensureDiscoverListener(): void {
|
|
629
771
|
if (!noble) return;
|
|
630
772
|
|
|
631
|
-
|
|
632
|
-
const listenerCount = (noble as any).listenerCount('discover');
|
|
633
|
-
|
|
634
|
-
if (listenerCount === 0) {
|
|
773
|
+
if (!persistentDiscoverListener) {
|
|
635
774
|
logger?.info('[NobleBLE] Discover listener missing, re-adding it');
|
|
636
|
-
|
|
775
|
+
persistentDiscoverListener = (peripheral: Peripheral) => {
|
|
637
776
|
handleDeviceDiscovered(peripheral);
|
|
638
|
-
}
|
|
777
|
+
};
|
|
778
|
+
noble.on('discover', persistentDiscoverListener);
|
|
639
779
|
} else {
|
|
640
|
-
logger?.debug('[NobleBLE] Discover listener already
|
|
780
|
+
logger?.debug('[NobleBLE] Discover listener already registered');
|
|
641
781
|
}
|
|
642
782
|
}
|
|
643
783
|
|
|
784
|
+
async function waitForNobleScanStop(nobleInstance: NobleModule): Promise<void> {
|
|
785
|
+
await runBleCallbackOperation(callback => nobleInstance.stopScanning(() => callback()), {
|
|
786
|
+
timeoutMs: BLE_CLEANUP_TIMEOUT,
|
|
787
|
+
timeoutBehavior: 'resolve',
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
|
|
644
791
|
// Perform targeted scan for a specific device ID
|
|
645
792
|
// Uses self-contained local listener pattern - no global state needed
|
|
646
793
|
async function performTargetedScan(targetDeviceId: string): Promise<Peripheral | null> {
|
|
@@ -654,39 +801,50 @@ async function performTargetedScan(targetDeviceId: string): Promise<Peripheral |
|
|
|
654
801
|
logger?.info('[NobleBLE] Starting targeted scan for device:', targetDeviceId);
|
|
655
802
|
|
|
656
803
|
return new Promise((resolve, reject) => {
|
|
804
|
+
let settled = false;
|
|
805
|
+
|
|
806
|
+
const finish = async (peripheral: Peripheral | null, error?: Error) => {
|
|
807
|
+
if (settled) return;
|
|
808
|
+
settled = true;
|
|
809
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
810
|
+
nobleInstance.removeListener('discover', onDiscover);
|
|
811
|
+
await waitForNobleScanStop(nobleInstance);
|
|
812
|
+
|
|
813
|
+
if (error) {
|
|
814
|
+
logger?.error('[NobleBLE] Failed to start targeted scan:', error);
|
|
815
|
+
reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
if (peripheral) {
|
|
819
|
+
discoveredDevices.set(peripheral.id, peripheral);
|
|
820
|
+
}
|
|
821
|
+
resolve(peripheral);
|
|
822
|
+
};
|
|
823
|
+
|
|
657
824
|
// Local discover listener - only matches target device
|
|
658
825
|
const onDiscover = (peripheral: Peripheral) => {
|
|
659
|
-
if (peripheral.id === targetDeviceId) {
|
|
826
|
+
if (peripheral.id === targetDeviceId && isOneKeyPeripheral(peripheral)) {
|
|
660
827
|
logger?.info('[NobleBLE] Target device found during targeted scan:', {
|
|
661
828
|
id: peripheral.id,
|
|
662
829
|
name: peripheral.advertisement?.localName,
|
|
663
830
|
});
|
|
664
|
-
|
|
665
|
-
nobleInstance.removeListener('discover', onDiscover);
|
|
666
|
-
nobleInstance.stopScanning();
|
|
667
|
-
discoveredDevices.set(peripheral.id, peripheral);
|
|
668
|
-
resolve(peripheral);
|
|
831
|
+
finish(peripheral).catch(reject);
|
|
669
832
|
}
|
|
670
833
|
};
|
|
671
834
|
|
|
672
|
-
// Timeout handler - must be after onDiscover so it can reference it
|
|
673
835
|
const timeoutId = setTimeout(() => {
|
|
674
|
-
nobleInstance.removeListener('discover', onDiscover);
|
|
675
|
-
nobleInstance.stopScanning();
|
|
676
836
|
logger?.info('[NobleBLE] Targeted scan timeout for device:', targetDeviceId);
|
|
677
|
-
|
|
678
|
-
},
|
|
837
|
+
finish(null).catch(reject);
|
|
838
|
+
}, NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS);
|
|
679
839
|
|
|
680
840
|
// Add local listener for this scan
|
|
681
841
|
nobleInstance.on('discover', onDiscover);
|
|
682
842
|
|
|
683
|
-
//
|
|
684
|
-
|
|
843
|
+
// Allow repeated advertisements so a service-only packet can be followed by
|
|
844
|
+
// the named scan response needed to validate the target peripheral.
|
|
845
|
+
nobleInstance.startScanning([], true, (error?: Error) => {
|
|
685
846
|
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));
|
|
847
|
+
finish(null, error).catch(reject);
|
|
690
848
|
return;
|
|
691
849
|
}
|
|
692
850
|
logger?.info('[NobleBLE] Targeted scan started for device:', targetDeviceId);
|
|
@@ -720,44 +878,47 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
720
878
|
const devices: DeviceInfo[] = [];
|
|
721
879
|
let intervalId: ReturnType<typeof setInterval> | undefined;
|
|
722
880
|
|
|
723
|
-
// Cleanup function: clears
|
|
724
|
-
|
|
881
|
+
// Cleanup function: clears timers and waits until Noble confirms scanning
|
|
882
|
+
// has stopped. Resolving enumerate before this callback creates a race with
|
|
883
|
+
// an immediately-following connection attempt.
|
|
884
|
+
const cleanup = async () => {
|
|
725
885
|
clearTimeout(timeoutId);
|
|
726
886
|
if (intervalId) clearInterval(intervalId);
|
|
727
|
-
nobleInstance
|
|
887
|
+
await waitForNobleScanStop(nobleInstance);
|
|
728
888
|
};
|
|
729
889
|
|
|
730
890
|
// 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
|
-
}
|
|
891
|
+
const pushDevice = (peripheral: Peripheral, id: string) => {
|
|
892
|
+
if (devices.some(d => d.id === id)) return;
|
|
893
|
+
devices.push({
|
|
894
|
+
commType: 'electron-ble',
|
|
895
|
+
id,
|
|
896
|
+
name: peripheral.advertisement?.localName || 'Unknown Device',
|
|
897
|
+
state: peripheral.state || 'disconnected',
|
|
743
898
|
});
|
|
744
899
|
};
|
|
900
|
+
const checkDevices = () => {
|
|
901
|
+
discoveredDevices.forEach(pushDevice);
|
|
902
|
+
// A linked device stops advertising, so the scan never rediscovers it.
|
|
903
|
+
connectedDevices.forEach(pushDevice);
|
|
904
|
+
};
|
|
745
905
|
|
|
746
906
|
// Set timeout for scanning — use longer timeout to catch slow-advertising devices like Pro2
|
|
747
|
-
const timeoutId = setTimeout(() => {
|
|
907
|
+
const timeoutId = setTimeout(async () => {
|
|
748
908
|
// Final collection before resolving — catches devices discovered near the deadline
|
|
749
909
|
checkDevices();
|
|
750
|
-
cleanup();
|
|
910
|
+
await cleanup();
|
|
751
911
|
logger?.info('[NobleBLE] Scan completed, found devices:', devices.length);
|
|
752
912
|
resolve(devices);
|
|
753
913
|
}, DEVICE_SCAN_TIMEOUT);
|
|
754
914
|
|
|
755
915
|
// Start scanning without a service UUID filter so Pro2 advertisements with
|
|
756
|
-
// short vendor UUIDs can be found
|
|
916
|
+
// short vendor UUIDs can be found. Repeated advertisements are required when
|
|
917
|
+
// the local name arrives in a later scan response; discoveredDevices handles deduplication.
|
|
757
918
|
logger?.info('[NobleBLE] Scanning for OneKey BLE devices');
|
|
758
|
-
nobleInstance.startScanning([],
|
|
919
|
+
nobleInstance.startScanning([], true, async (error?: Error) => {
|
|
759
920
|
if (error) {
|
|
760
|
-
cleanup();
|
|
921
|
+
await cleanup();
|
|
761
922
|
logger?.error('[NobleBLE] Failed to start scanning:', error);
|
|
762
923
|
reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
|
|
763
924
|
return;
|
|
@@ -774,30 +935,21 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
774
935
|
// Stop scanning
|
|
775
936
|
async function stopScanning(): Promise<void> {
|
|
776
937
|
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
|
-
});
|
|
938
|
+
const nobleInstance = noble;
|
|
939
|
+
await waitForNobleScanStop(nobleInstance);
|
|
940
|
+
logger?.info('[NobleBLE] Scanning stopped');
|
|
789
941
|
}
|
|
790
942
|
|
|
791
943
|
// Clean up all Noble listeners (for app exit)
|
|
792
944
|
function cleanupNobleListeners(): void {
|
|
793
945
|
if (!noble) return;
|
|
794
946
|
|
|
795
|
-
// Remove all listeners to prevent memory leaks
|
|
796
|
-
// Noble uses EventEmitter, need to use removeAllListeners
|
|
797
947
|
try {
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
948
|
+
if (persistentDiscoverListener) {
|
|
949
|
+
noble.removeListener('discover', persistentDiscoverListener);
|
|
950
|
+
persistentDiscoverListener = null;
|
|
951
|
+
}
|
|
952
|
+
logger?.info('[NobleBLE] Owned Noble listeners cleaned up');
|
|
801
953
|
} catch (error) {
|
|
802
954
|
logger?.error('[NobleBLE] Failed to clean up some listeners:', error);
|
|
803
955
|
}
|
|
@@ -814,6 +966,7 @@ function getDevice(deviceId: string): DeviceInfo | null {
|
|
|
814
966
|
id: peripheral.id,
|
|
815
967
|
name: deviceName,
|
|
816
968
|
state: peripheral.state || 'disconnected',
|
|
969
|
+
...(typeof peripheral.mtu === 'number' ? { mtu: peripheral.mtu } : {}),
|
|
817
970
|
};
|
|
818
971
|
}
|
|
819
972
|
|
|
@@ -826,6 +979,7 @@ function getDevice(deviceId: string): DeviceInfo | null {
|
|
|
826
979
|
id: connectedPeripheral.id,
|
|
827
980
|
name: deviceName,
|
|
828
981
|
state: connectedPeripheral.state || 'connected',
|
|
982
|
+
...(typeof connectedPeripheral.mtu === 'number' ? { mtu: connectedPeripheral.mtu } : {}),
|
|
829
983
|
};
|
|
830
984
|
}
|
|
831
985
|
|
|
@@ -905,14 +1059,7 @@ async function discoverServicesAndCharacteristics(
|
|
|
905
1059
|
});
|
|
906
1060
|
|
|
907
1061
|
// 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
|
-
}
|
|
1062
|
+
const service = services.find(s => matchesKnownBleUuid(s.uuid, ONEKEY_SERVICE_UUID_ALIASES));
|
|
916
1063
|
if (!service) {
|
|
917
1064
|
throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound);
|
|
918
1065
|
}
|
|
@@ -938,12 +1085,9 @@ async function discoverServicesAndCharacteristics(
|
|
|
938
1085
|
let notifyCharacteristic: Characteristic | null = null;
|
|
939
1086
|
|
|
940
1087
|
for (const characteristic of characteristics) {
|
|
941
|
-
|
|
942
|
-
const uuidKey = uuid.length >= 8 ? uuid.substring(4, 8) : uuid;
|
|
943
|
-
|
|
944
|
-
if (uuidKey === NORMALIZED_WRITE_UUID) {
|
|
1088
|
+
if (matchesKnownBleUuid(characteristic.uuid, ONEKEY_WRITE_UUID_ALIASES)) {
|
|
945
1089
|
writeCharacteristic = characteristic;
|
|
946
|
-
} else if (
|
|
1090
|
+
} else if (matchesKnownBleUuid(characteristic.uuid, ONEKEY_NOTIFY_UUID_ALIASES)) {
|
|
947
1091
|
notifyCharacteristic = characteristic;
|
|
948
1092
|
}
|
|
949
1093
|
}
|
|
@@ -1000,29 +1144,23 @@ async function forceReconnectPeripheral(peripheral: Peripheral, deviceId: string
|
|
|
1000
1144
|
|
|
1001
1145
|
// Step 2: Force disconnect if connected
|
|
1002
1146
|
if (peripheral.state === 'connected') {
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1147
|
+
await runBleCallbackOperation(
|
|
1148
|
+
callback =>
|
|
1149
|
+
peripheral.disconnect(() => {
|
|
1150
|
+
logger?.info('[NobleBLE] Force disconnect completed');
|
|
1151
|
+
callback();
|
|
1152
|
+
}),
|
|
1153
|
+
{ timeoutMs: BLE_CLEANUP_TIMEOUT, timeoutBehavior: 'resolve' }
|
|
1154
|
+
);
|
|
1011
1155
|
}
|
|
1012
1156
|
|
|
1013
1157
|
// 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
|
-
});
|
|
1158
|
+
await runBleCallbackOperation(callback => peripheral.connect(callback), {
|
|
1159
|
+
timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
|
|
1160
|
+
timeoutBehavior: 'reject',
|
|
1025
1161
|
});
|
|
1162
|
+
logger?.info('[NobleBLE] Force reconnect successful');
|
|
1163
|
+
connectedDevices.set(deviceId, peripheral);
|
|
1026
1164
|
|
|
1027
1165
|
// Wait for connection to stabilize
|
|
1028
1166
|
await wait(500);
|
|
@@ -1153,20 +1291,100 @@ async function setupConnectionAndDiscoverServices(
|
|
|
1153
1291
|
deviceId: string,
|
|
1154
1292
|
webContents: WebContents
|
|
1155
1293
|
): Promise<CharacteristicPair> {
|
|
1156
|
-
// Force reconnect to clear GATT cache
|
|
1157
|
-
await forceReconnectPeripheral(peripheral, deviceId);
|
|
1158
1294
|
setupDisconnectListener(peripheral, deviceId, webContents);
|
|
1159
1295
|
|
|
1160
|
-
// Discover services and characteristics with retry
|
|
1161
1296
|
try {
|
|
1162
1297
|
return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
|
|
1163
|
-
} catch (
|
|
1164
|
-
|
|
1165
|
-
|
|
1298
|
+
} catch (initialError) {
|
|
1299
|
+
logger?.info('[NobleBLE] Direct service discovery failed, retrying after reconnect', {
|
|
1300
|
+
deviceId,
|
|
1301
|
+
error: initialError,
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
await forceReconnectPeripheral(peripheral, deviceId);
|
|
1306
|
+
setupDisconnectListener(peripheral, deviceId, webContents);
|
|
1307
|
+
try {
|
|
1308
|
+
return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
|
|
1309
|
+
} catch (reconnectError) {
|
|
1310
|
+
logger?.error(
|
|
1311
|
+
'[NobleBLE] Service discovery failed after reconnect, attempting fresh scan...',
|
|
1312
|
+
reconnectError
|
|
1313
|
+
);
|
|
1166
1314
|
return freshScanAndDiscover(deviceId, webContents);
|
|
1167
1315
|
}
|
|
1168
1316
|
}
|
|
1169
1317
|
|
|
1318
|
+
// noble/mac never resolves for an id CoreBluetooth cannot retrieve.
|
|
1319
|
+
const DIRECT_CONNECT_TIMEOUT_MS = 2000;
|
|
1320
|
+
// Floor after a timeout: do not pay the probe on every attempt.
|
|
1321
|
+
const DIRECT_CONNECT_COOLDOWN_MS = 15_000;
|
|
1322
|
+
const directConnectCooldownUntil = new Map<string, number>();
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Connect straight by id, skipping the ~650ms targeted scan. Both native
|
|
1326
|
+
* backends support it and emit a `discover` as a side effect: macOS resolves
|
|
1327
|
+
* via `retrievePeripheralsWithIdentifiers`, Windows synthesizes one for an
|
|
1328
|
+
* unknown address. Returns undefined when unavailable so the caller scans.
|
|
1329
|
+
*
|
|
1330
|
+
* Ported from the Trezor connector, where it is field-proven.
|
|
1331
|
+
*/
|
|
1332
|
+
async function tryDirectConnectById(deviceId: string): Promise<Peripheral | undefined> {
|
|
1333
|
+
const nobleInstance = noble as
|
|
1334
|
+
| (typeof noble & {
|
|
1335
|
+
connectAsync?: (id: string) => Promise<Peripheral | undefined>;
|
|
1336
|
+
})
|
|
1337
|
+
| null;
|
|
1338
|
+
if (!nobleInstance?.connectAsync) return undefined;
|
|
1339
|
+
|
|
1340
|
+
const cooldownUntil = directConnectCooldownUntil.get(deviceId) ?? 0;
|
|
1341
|
+
if (Date.now() < cooldownUntil) return undefined;
|
|
1342
|
+
|
|
1343
|
+
try {
|
|
1344
|
+
// The late-orphan guard must attach to THIS pending connect.
|
|
1345
|
+
const directPromise = nobleInstance.connectAsync(deviceId);
|
|
1346
|
+
const raced = await Promise.race([
|
|
1347
|
+
directPromise,
|
|
1348
|
+
new Promise<'timeout'>(resolve => {
|
|
1349
|
+
setTimeout(() => resolve('timeout'), DIRECT_CONNECT_TIMEOUT_MS);
|
|
1350
|
+
}),
|
|
1351
|
+
]);
|
|
1352
|
+
if (raced === 'timeout') {
|
|
1353
|
+
directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
|
|
1354
|
+
logger?.info('[NobleBLE] Direct connect-by-id timed out, falling back to scan', {
|
|
1355
|
+
deviceId,
|
|
1356
|
+
});
|
|
1357
|
+
// Promise.race times out the caller only; a late success orphans the link.
|
|
1358
|
+
directPromise
|
|
1359
|
+
.then(late => {
|
|
1360
|
+
const latePeripheral = late ?? discoveredDevices.get(deviceId);
|
|
1361
|
+
if (
|
|
1362
|
+
latePeripheral &&
|
|
1363
|
+
latePeripheral.state === 'connected' &&
|
|
1364
|
+
!connectedDevices.has(deviceId)
|
|
1365
|
+
) {
|
|
1366
|
+
latePeripheral.removeAllListeners('disconnect');
|
|
1367
|
+
latePeripheral.disconnect(() => undefined);
|
|
1368
|
+
}
|
|
1369
|
+
})
|
|
1370
|
+
.catch(() => undefined);
|
|
1371
|
+
return undefined;
|
|
1372
|
+
}
|
|
1373
|
+
// Backends emit `discover` as a side effect, so the cache may hold it.
|
|
1374
|
+
const peripheral = raced ?? discoveredDevices.get(deviceId);
|
|
1375
|
+
if (!peripheral || peripheral.state !== 'connected') return undefined;
|
|
1376
|
+
logger?.info('[NobleBLE] Direct connect-by-id succeeded', { deviceId });
|
|
1377
|
+
return peripheral;
|
|
1378
|
+
} catch (error) {
|
|
1379
|
+
directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
|
|
1380
|
+
logger?.info('[NobleBLE] Direct connect-by-id failed, falling back to scan', {
|
|
1381
|
+
deviceId,
|
|
1382
|
+
error: String(error),
|
|
1383
|
+
});
|
|
1384
|
+
return undefined;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1170
1388
|
// Connect to device - supports both discovered and direct connection modes
|
|
1171
1389
|
async function connectDevice(deviceId: string, webContents: WebContents): Promise<void> {
|
|
1172
1390
|
logger?.info('[NobleBLE] Connect device request:', {
|
|
@@ -1178,7 +1396,8 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1178
1396
|
totalConnected: connectedDevices.size,
|
|
1179
1397
|
});
|
|
1180
1398
|
|
|
1181
|
-
|
|
1399
|
+
// enumerate clears the discovery map; a kept-alive link outlives it.
|
|
1400
|
+
let peripheral = discoveredDevices.get(deviceId) ?? connectedDevices.get(deviceId);
|
|
1182
1401
|
|
|
1183
1402
|
// If device not discovered, try a targeted scan for this specific device
|
|
1184
1403
|
if (!peripheral) {
|
|
@@ -1193,6 +1412,14 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1193
1412
|
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
|
|
1194
1413
|
}
|
|
1195
1414
|
|
|
1415
|
+
// Reaches a bonded device that is not advertising; else falls to the scan.
|
|
1416
|
+
peripheral = await tryDirectConnectById(deviceId);
|
|
1417
|
+
if (peripheral) {
|
|
1418
|
+
discoveredDevices.set(deviceId, peripheral);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
if (!peripheral) {
|
|
1196
1423
|
// Perform a targeted scan to find the specific device
|
|
1197
1424
|
try {
|
|
1198
1425
|
const foundPeripheral = await performTargetedScan(deviceId);
|
|
@@ -1223,9 +1450,11 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1223
1450
|
// If already connected but not in our connected devices map, add it
|
|
1224
1451
|
if (!connectedDevices.has(deviceId)) {
|
|
1225
1452
|
connectedDevices.set(deviceId, peripheral);
|
|
1226
|
-
// Set up unified disconnect listener
|
|
1227
|
-
setupDisconnectListener(peripheral, deviceId, webContents);
|
|
1228
1453
|
}
|
|
1454
|
+
// Re-bind unconditionally (idempotent): on a kept-alive link the disconnect
|
|
1455
|
+
// and MTU listeners may still hold the webContents of a soft-restarted
|
|
1456
|
+
// renderer, and the reuse fast path below returns before any other setup.
|
|
1457
|
+
setupDisconnectListener(peripheral, deviceId, webContents);
|
|
1229
1458
|
|
|
1230
1459
|
// Check if we already have characteristics for this device
|
|
1231
1460
|
if (deviceCharacteristics.has(deviceId)) {
|
|
@@ -1269,26 +1498,48 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1269
1498
|
}
|
|
1270
1499
|
|
|
1271
1500
|
// Setup connection and discover services
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1501
|
+
try {
|
|
1502
|
+
const characteristics = await setupConnectionAndDiscoverServices(
|
|
1503
|
+
peripheral,
|
|
1504
|
+
deviceId,
|
|
1505
|
+
webContents
|
|
1506
|
+
);
|
|
1507
|
+
deviceCharacteristics.set(deviceId, characteristics);
|
|
1508
|
+
logger?.info('[NobleBLE] Device ready for communication:', deviceId);
|
|
1509
|
+
} catch (setupError) {
|
|
1510
|
+
// The caller re-arms the timer only on success, so a left-up link strands.
|
|
1511
|
+
logger?.error('[NobleBLE] Connection setup failed on kept-alive link:', setupError);
|
|
1512
|
+
await disconnectDevice(deviceId).catch(() => undefined);
|
|
1513
|
+
throw setupError;
|
|
1514
|
+
}
|
|
1279
1515
|
return;
|
|
1280
1516
|
}
|
|
1281
1517
|
|
|
1282
1518
|
return new Promise((resolve, reject) => {
|
|
1519
|
+
let connectionTimedOut = false;
|
|
1283
1520
|
const timeout = setTimeout(() => {
|
|
1521
|
+
connectionTimedOut = true;
|
|
1284
1522
|
reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'Connection timeout'));
|
|
1285
|
-
},
|
|
1523
|
+
}, NOBLE_BLE_CONNECTION_TIMEOUT_MS);
|
|
1286
1524
|
|
|
1287
1525
|
// TypeScript type assertion - peripheral is guaranteed to be defined at this point
|
|
1288
1526
|
const connectedPeripheral = peripheral as Peripheral;
|
|
1289
1527
|
connectedPeripheral.connect(async (error: Error | undefined) => {
|
|
1290
1528
|
clearTimeout(timeout);
|
|
1291
1529
|
|
|
1530
|
+
// Noble may invoke the callback after the SDK timed out and released the request.
|
|
1531
|
+
// Ignore it to avoid initializing disposed commands or leaving an orphaned connection.
|
|
1532
|
+
if (connectionTimedOut) {
|
|
1533
|
+
if (!error) {
|
|
1534
|
+
try {
|
|
1535
|
+
connectedPeripheral.disconnect(() => undefined);
|
|
1536
|
+
} catch {
|
|
1537
|
+
// Best-effort cleanup only.
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1292
1543
|
if (error) {
|
|
1293
1544
|
logger?.error('[NobleBLE] Connection failed:', error);
|
|
1294
1545
|
reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
|
|
@@ -1325,20 +1576,21 @@ async function disconnectDevice(deviceId: string): Promise<void> {
|
|
|
1325
1576
|
return;
|
|
1326
1577
|
}
|
|
1327
1578
|
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
peripheral.
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1579
|
+
const disconnectEntry = deviceDisconnectListeners.get(deviceId);
|
|
1580
|
+
if (disconnectEntry) {
|
|
1581
|
+
disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
|
|
1582
|
+
deviceDisconnectListeners.delete(deviceId);
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
|
|
1586
|
+
timeoutMs: BLE_CLEANUP_TIMEOUT,
|
|
1587
|
+
timeoutBehavior: 'resolve',
|
|
1588
|
+
});
|
|
1589
|
+
cleanupDevice(deviceId, undefined, {
|
|
1590
|
+
cleanupConnection: true,
|
|
1591
|
+
sendDisconnectEvent: false,
|
|
1592
|
+
cancelOperations: true,
|
|
1593
|
+
reason: 'manual-disconnect',
|
|
1342
1594
|
});
|
|
1343
1595
|
}
|
|
1344
1596
|
|
|
@@ -1359,17 +1611,11 @@ async function unsubscribeNotifications(deviceId: string): Promise<void> {
|
|
|
1359
1611
|
subscriptionOperations.set(deviceId, 'unsubscribing');
|
|
1360
1612
|
|
|
1361
1613
|
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
|
-
});
|
|
1614
|
+
await runBleCallbackOperation(callback => notifyCharacteristic.unsubscribe(callback), {
|
|
1615
|
+
timeoutMs: BLE_CLEANUP_TIMEOUT,
|
|
1616
|
+
timeoutBehavior: 'resolve',
|
|
1372
1617
|
});
|
|
1618
|
+
logger?.info('[NobleBLE] Notification unsubscription completed');
|
|
1373
1619
|
|
|
1374
1620
|
// Remove all listeners and clear subscription status
|
|
1375
1621
|
notifyCharacteristic.removeAllListeners('data');
|
|
@@ -1462,19 +1708,13 @@ async function subscribeNotifications(
|
|
|
1462
1708
|
notifyCharacteristic: Characteristic
|
|
1463
1709
|
): Promise<void> {
|
|
1464
1710
|
// Listeners already cleaned up above, no need to repeat
|
|
1465
|
-
await
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
});
|
|
1711
|
+
await runBleCallbackOperation(callback => notifyCharacteristic.unsubscribe(callback), {
|
|
1712
|
+
timeoutMs: BLE_CLEANUP_TIMEOUT,
|
|
1713
|
+
timeoutBehavior: 'resolve',
|
|
1469
1714
|
});
|
|
1470
|
-
await
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
reject(error);
|
|
1474
|
-
return;
|
|
1475
|
-
}
|
|
1476
|
-
resolve();
|
|
1477
|
-
});
|
|
1715
|
+
await runBleCallbackOperation(callback => notifyCharacteristic.subscribe(callback), {
|
|
1716
|
+
timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
|
|
1717
|
+
timeoutBehavior: 'reject',
|
|
1478
1718
|
});
|
|
1479
1719
|
|
|
1480
1720
|
notifyCharacteristic.on('data', (data: Buffer) => {
|
|
@@ -1506,12 +1746,18 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
1506
1746
|
|
|
1507
1747
|
// @ts-ignore – electron is only available at runtime
|
|
1508
1748
|
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
|
1509
|
-
const { ipcMain } = require('electron');
|
|
1749
|
+
const { ipcMain } = require('electron') as { ipcMain: IpcMain };
|
|
1750
|
+
|
|
1751
|
+
// Electron throws on duplicate channels and setup re-runs on soft restart.
|
|
1752
|
+
const handle: IpcMain['handle'] = (channel, listener) => {
|
|
1753
|
+
ipcMain.removeHandler(channel);
|
|
1754
|
+
ipcMain.handle(channel, listener);
|
|
1755
|
+
};
|
|
1510
1756
|
|
|
1511
1757
|
safeLog(logger, 'info', 'Setting up Noble BLE IPC handlers');
|
|
1512
1758
|
|
|
1513
1759
|
// Handle enumerate request
|
|
1514
|
-
|
|
1760
|
+
handle(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE, async () => {
|
|
1515
1761
|
try {
|
|
1516
1762
|
const devices = await enumerateDevices();
|
|
1517
1763
|
safeLog(logger, 'debug', 'Enumeration completed', {
|
|
@@ -1526,18 +1772,18 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
1526
1772
|
});
|
|
1527
1773
|
|
|
1528
1774
|
// Handle stop scan request
|
|
1529
|
-
|
|
1775
|
+
handle(EOneKeyBleMessageKeys.NOBLE_BLE_STOP_SCAN, async () => {
|
|
1530
1776
|
await stopScanning();
|
|
1531
1777
|
});
|
|
1532
1778
|
|
|
1533
1779
|
// Handle get device request
|
|
1534
|
-
|
|
1780
|
+
handle(
|
|
1535
1781
|
EOneKeyBleMessageKeys.NOBLE_BLE_GET_DEVICE,
|
|
1536
1782
|
(_event: IpcMainInvokeEvent, deviceId: string) => getDevice(deviceId)
|
|
1537
1783
|
);
|
|
1538
1784
|
|
|
1539
1785
|
// Handle connect request
|
|
1540
|
-
|
|
1786
|
+
handle(
|
|
1541
1787
|
EOneKeyBleMessageKeys.NOBLE_BLE_CONNECT,
|
|
1542
1788
|
async (_event: IpcMainInvokeEvent, deviceId: string) => {
|
|
1543
1789
|
logger?.info('[NobleBLE] IPC CONNECT request received:', {
|
|
@@ -1546,12 +1792,37 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
1546
1792
|
hasCharacteristics: deviceCharacteristics.has(deviceId),
|
|
1547
1793
|
totalConnectedDevices: connectedDevices.size,
|
|
1548
1794
|
});
|
|
1549
|
-
|
|
1795
|
+
// Must not fire mid-connect (pairing can take ~30s).
|
|
1796
|
+
clearIdleDisconnect(deviceId);
|
|
1797
|
+
// A fired timer must settle first, or the fast path returns a dying link.
|
|
1798
|
+
await awaitIdleDisconnect(deviceId);
|
|
1799
|
+
try {
|
|
1800
|
+
await connectDevice(deviceId, webContents);
|
|
1801
|
+
} finally {
|
|
1802
|
+
// This timer is the only thing that frees a kept-alive link.
|
|
1803
|
+
if (connectedDevices.has(deviceId)) {
|
|
1804
|
+
armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
);
|
|
1809
|
+
|
|
1810
|
+
// Logical release: start the idle countdown, keep the link for the next call.
|
|
1811
|
+
handle(
|
|
1812
|
+
EOneKeyBleMessageKeys.NOBLE_BLE_RELEASE,
|
|
1813
|
+
(_event: IpcMainInvokeEvent, deviceId: string, keepSession?: boolean) => {
|
|
1814
|
+
if (!connectedDevices.has(deviceId)) return;
|
|
1815
|
+
// Mid-flow caller will be back; the short window would cut an update.
|
|
1816
|
+
if (keepSession) {
|
|
1817
|
+
armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
armIdleDisconnect(deviceId);
|
|
1550
1821
|
}
|
|
1551
1822
|
);
|
|
1552
1823
|
|
|
1553
1824
|
// Handle disconnect request
|
|
1554
|
-
|
|
1825
|
+
handle(
|
|
1555
1826
|
EOneKeyBleMessageKeys.NOBLE_BLE_DISCONNECT,
|
|
1556
1827
|
async (_event: IpcMainInvokeEvent, deviceId: string) => {
|
|
1557
1828
|
await disconnectDevice(deviceId);
|
|
@@ -1559,34 +1830,42 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
1559
1830
|
);
|
|
1560
1831
|
|
|
1561
1832
|
// Handle write request
|
|
1562
|
-
|
|
1833
|
+
handle(
|
|
1563
1834
|
EOneKeyBleMessageKeys.NOBLE_BLE_WRITE,
|
|
1564
|
-
async (
|
|
1565
|
-
|
|
1835
|
+
async (
|
|
1836
|
+
_event: IpcMainInvokeEvent,
|
|
1837
|
+
deviceId: string,
|
|
1838
|
+
hexData: string,
|
|
1839
|
+
options?: NobleBleWriteOptions
|
|
1840
|
+
) => {
|
|
1841
|
+
await transmitHexDataToDevice(deviceId, hexData, options);
|
|
1566
1842
|
}
|
|
1567
1843
|
);
|
|
1568
1844
|
|
|
1569
1845
|
// Handle subscribe request
|
|
1570
|
-
|
|
1846
|
+
handle(
|
|
1571
1847
|
EOneKeyBleMessageKeys.NOBLE_BLE_SUBSCRIBE,
|
|
1572
1848
|
async (_event: IpcMainInvokeEvent, deviceId: string) => {
|
|
1573
1849
|
await subscribeNotifications(deviceId, (data: string) => {
|
|
1574
1850
|
// Send data back to renderer process
|
|
1575
1851
|
webContents.send(EOneKeyBleMessageKeys.NOBLE_BLE_NOTIFICATION, deviceId, data);
|
|
1576
1852
|
});
|
|
1853
|
+
// Still acquiring (in flight) — busy backstop, not the idle clock.
|
|
1854
|
+
armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
|
|
1577
1855
|
}
|
|
1578
1856
|
);
|
|
1579
1857
|
|
|
1580
1858
|
// Handle unsubscribe request
|
|
1581
|
-
|
|
1859
|
+
handle(
|
|
1582
1860
|
EOneKeyBleMessageKeys.NOBLE_BLE_UNSUBSCRIBE,
|
|
1583
1861
|
async (_event: IpcMainInvokeEvent, deviceId: string) => {
|
|
1584
1862
|
await unsubscribeNotifications(deviceId);
|
|
1863
|
+
armIdleDisconnect(deviceId);
|
|
1585
1864
|
}
|
|
1586
1865
|
);
|
|
1587
1866
|
|
|
1588
1867
|
// Handle cancel pairing: cleanup all connected devices
|
|
1589
|
-
|
|
1868
|
+
handle(EOneKeyBleMessageKeys.NOBLE_BLE_CANCEL_PAIRING, async () => {
|
|
1590
1869
|
const deviceIds = Array.from(connectedDevices.keys());
|
|
1591
1870
|
logger?.info('[NobleBLE] Cancel pairing invoked', {
|
|
1592
1871
|
platform: process.platform,
|
|
@@ -1607,7 +1886,7 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
1607
1886
|
});
|
|
1608
1887
|
|
|
1609
1888
|
// Handle Bluetooth availability check request
|
|
1610
|
-
|
|
1889
|
+
handle(EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK, async () => {
|
|
1611
1890
|
try {
|
|
1612
1891
|
const bluetoothStatus = await checkBluetoothAvailability();
|
|
1613
1892
|
safeLog(logger, 'info', 'Bluetooth availability check completed:', bluetoothStatus);
|
|
@@ -1626,32 +1905,24 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
1626
1905
|
// Cleanup on app quit
|
|
1627
1906
|
webContents.on('destroyed', () => {
|
|
1628
1907
|
safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
|
|
1908
|
+
(async () => {
|
|
1909
|
+
const deviceIds = Array.from(connectedDevices.keys());
|
|
1910
|
+
for (const deviceId of deviceIds) {
|
|
1911
|
+
await unsubscribeNotifications(deviceId).catch(() => undefined);
|
|
1912
|
+
await disconnectDevice(deviceId).catch(() => undefined);
|
|
1913
|
+
}
|
|
1629
1914
|
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1915
|
+
await stopScanning().catch(() => undefined);
|
|
1916
|
+
if (noble && persistentStateListener) {
|
|
1917
|
+
noble.removeListener('stateChange', persistentStateListener);
|
|
1918
|
+
persistentStateListener = null;
|
|
1919
|
+
}
|
|
1920
|
+
cleanupNobleListeners();
|
|
1921
|
+
discoveredDevices.clear();
|
|
1922
|
+
safeLog(logger, 'info', 'Noble BLE cleanup completed');
|
|
1923
|
+
})().catch(error => {
|
|
1924
|
+
safeLog(logger, 'error', 'Noble BLE cleanup failed', error);
|
|
1639
1925
|
});
|
|
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
1926
|
});
|
|
1656
1927
|
|
|
1657
1928
|
safeLog(logger, 'info', 'Noble BLE IPC handlers setup completed');
|