@ukeyfe/hardware-transport-electron 1.1.13

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.
@@ -0,0 +1,1679 @@
1
+ /*
2
+ * Noble BLE Handler for Electron Main Process
3
+ * Handles BLE communication using Noble library
4
+ */
5
+
6
+ /* eslint-disable @typescript-eslint/no-var-requires, import/no-extraneous-dependencies */
7
+
8
+ import {
9
+ isUkeyDevice,
10
+ EUKeyBleMessageKeys,
11
+ UKEY_SERVICE_UUID,
12
+ UKEY_WRITE_CHARACTERISTIC_UUID,
13
+ UKEY_NOTIFY_CHARACTERISTIC_UUID,
14
+ isHeaderChunk,
15
+ ERRORS,
16
+ HardwareErrorCode,
17
+ wait,
18
+ } from '@ukeyfe/hardware-shared';
19
+ import { COMMON_HEADER_SIZE } from '@ukeyfe/hardware-transport';
20
+ import type { WebContents, IpcMainInvokeEvent } from 'electron';
21
+ import type { Peripheral, Service, Characteristic } from '@stoprocent/noble';
22
+ import pRetry from 'p-retry';
23
+ import type { NobleModule, Logger, DeviceInfo, CharacteristicPair } from './types/noble-extended';
24
+ import { safeLog } from './types/noble-extended';
25
+ import { softRefreshSubscription } from './ble-ops';
26
+
27
+ // Noble will be dynamically imported to avoid bundlinpissues
28
+ let noble: NobleModule | null = null;
29
+ let logger: Logger | null = null;
30
+
31
+ // Bluetooth state management
32
+ const bluetoothState: {
33
+ available: boolean;
34
+ unsupported: boolean;
35
+ initialized: boolean;
36
+ } = {
37
+ available: false,
38
+ unsupported: false,
39
+ initialized: false,
40
+ };
41
+
42
+ // Global persistent state listener for app layer
43
+ let persistentStateListener: ((state: string) => void) | null = null;
44
+
45
+ // Device cache and connection state
46
+ const discoveredDevices = new Map<string, Peripheral>();
47
+ const connectedDevices = new Map<string, Peripheral>();
48
+ const pairedDevices = new Set<string>(); // Windows BLE 设备配对状态跟踪
49
+ const deviceCharacteristics = new Map<string, CharacteristicPair>();
50
+ const notificationCallbacks = new Map<string, (data: string) => void>();
51
+ const subscribedDevices = new Map<string, boolean>(); // Track subscription status
52
+
53
+ // 🔒 Add subscription operation state tracking to prevent race conditions
54
+ const subscriptionOperations = new Map<string, 'subscribing' | 'unsubscribing' | 'idle'>();
55
+
56
+ // Packet reassembly state for each device
57
+ interface PacketAssemblyState {
58
+ bufferLength: number;
59
+ buffer: number[];
60
+ packetCount: number;
61
+ messageId?: string; // Add message ID to track concurrent requests
62
+ }
63
+ const devicePacketStates = new Map<string, PacketAssemblyState>();
64
+
65
+ // Windows-only response watchdog state moved to utils/windows-ble-recovery
66
+
67
+ // Pairing-related state removed
68
+
69
+ // Device operation history removed
70
+
71
+ // Service UUIDs to scan for - using constants from hd-shared
72
+ const UKEY_SERVICE_UUIDS = [UKEY_SERVICE_UUID];
73
+
74
+ // Pre-normalized characteristic identifiers for fast comparison
75
+ const NORMALIZED_WRITE_UUID = '0002';
76
+ const NORMALIZED_NOTIFY_UUID = '0003';
77
+
78
+ // Timeout and interval constants
79
+ const BLUETOOTH_INIT_TIMEOUT = 10000; // 10 seconds for Bluetooth initialization
80
+ const DEVICE_SCAN_TIMEOUT = 5000; // 5 seconds for device scanning
81
+ const FAST_SCAN_TIMEOUT = 1500; // 1.5 seconds for fast targeted scanning
82
+ const DEVICE_CHECK_INTERVAL = 500; // 500ms interval for periodic device checks
83
+ const CONNECTION_TIMEOUT = 3000; // 3 seconds for device connection
84
+
85
+ // Write-related constants
86
+ const BLE_PACKET_SIZE = 192;
87
+ const UNIFIED_WRITE_DELAY = 5;
88
+ const RETRY_CONFIG = { MAX_ATTEMPTS: 15, WRITE_TIMEOUT: 2000 } as const;
89
+ const IS_WINDOWS = process.platform === 'win32';
90
+ const ABORTABLE_WRITE_ERROR_PATTERNS = [
91
+ /status:\s*3/i, // Windows pairing cancelled / GATT write failed
92
+ ];
93
+
94
+ // Validation limits
95
+ const MIN_HEADER_LENGTH = 9; // Minimum header chunk length
96
+
97
+ // Packet processing result types
98
+ interface PacketProcessResult {
99
+ isComplete: boolean;
100
+ completePacket?: string;
101
+ error?: string;
102
+ }
103
+
104
+ // Process incoming BLE notification data with proper packet reassembly
105
+ function processNotificationData(deviceId: string, data: Buffer): PacketProcessResult {
106
+ // notification telemetry
107
+ logger?.info('[NobleBLE] Notification', {
108
+ deviceId,
109
+ dataLength: data.length,
110
+ });
111
+
112
+ // Get or initialize packet state for this device
113
+ let packetState = devicePacketStates.get(deviceId);
114
+ if (!packetState) {
115
+ packetState = { bufferLength: 0, buffer: [], packetCount: 0 };
116
+ devicePacketStates.set(deviceId, packetState);
117
+ logger?.info('[NobleBLE] Initialized new packet state for device:', deviceId);
118
+ }
119
+
120
+ try {
121
+ if (isHeaderChunk(data)) {
122
+ // Validate header chunk
123
+ if (data.length < MIN_HEADER_LENGTH) {
124
+ return { isComplete: false, error: 'Invalid header chunk: too short' };
125
+ }
126
+
127
+ // Generate message ID for this packet sequence
128
+ const messageId = `${deviceId}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
129
+
130
+ // Reset packet state for new message
131
+ packetState.bufferLength = data.readInt32BE(5);
132
+ packetState.buffer = [...data.subarray(3)];
133
+ packetState.packetCount = 1;
134
+ packetState.messageId = messageId;
135
+
136
+ // Only validate for negative lengths (which would be invalid)
137
+ if (packetState.bufferLength < 0) {
138
+ logger?.error('[NobleBLE] Invalid negative packet length detected:', {
139
+ length: packetState.bufferLength,
140
+ dataLength: data.length,
141
+ rawHeader: data.subarray(0, Math.min(16, data.length)).toString('hex'),
142
+ lengthBytes: data.subarray(5, 9).toString('hex'),
143
+ });
144
+ resetPacketState(packetState);
145
+ return { isComplete: false, error: 'Invalid packet length in header' };
146
+ }
147
+ } else {
148
+ // Validate we have an active packet session
149
+ if (packetState.bufferLength === 0) {
150
+ return { isComplete: false, error: 'Received data chunk without header' };
151
+ }
152
+
153
+ // Increment packet counter and append data
154
+ packetState.packetCount += 1;
155
+ packetState.buffer = packetState.buffer.concat([...data]);
156
+ }
157
+
158
+ // Check if packet is complete
159
+ if (packetState.buffer.length - COMMON_HEADER_SIZE >= packetState.bufferLength) {
160
+ const completeBuffer = Buffer.from(packetState.buffer);
161
+ const hexString = completeBuffer.toString('hex');
162
+
163
+ logger?.info('[NobleBLE] Packet assembled', {
164
+ deviceId,
165
+ totalPackets: packetState.packetCount,
166
+ expectedLength: packetState.bufferLength,
167
+ actualLength: packetState.buffer.length - COMMON_HEADER_SIZE,
168
+ });
169
+
170
+ // Reset packet state for next message
171
+ resetPacketState(packetState);
172
+
173
+ return { isComplete: true, completePacket: hexString };
174
+ }
175
+
176
+ return { isComplete: false };
177
+ } catch (error) {
178
+ resetPacketState(packetState);
179
+ return { isComplete: false, error: `Packet processing error: ${error}` };
180
+ }
181
+ }
182
+
183
+ // Reset packet state to clean state
184
+ function resetPacketState(packetState: PacketAssemblyState): void {
185
+ packetState.bufferLength = 0;
186
+ packetState.buffer = [];
187
+ packetState.packetCount = 0;
188
+ packetState.messageId = undefined;
189
+ }
190
+
191
+ // Check Bluetooth availability - returns detailed state
192
+ async function checkBluetoothAvailability(): Promise<{
193
+ available: boolean;
194
+ state: string;
195
+ unsupported: boolean;
196
+ initialized: boolean;
197
+ }> {
198
+ // Use existing initializeNoble which already handles bluetooth state
199
+ if (!bluetoothState.initialized) {
200
+ await initializeNoble();
201
+ }
202
+
203
+ const currentState = noble?.state || 'unknown';
204
+
205
+ return {
206
+ available: bluetoothState.available,
207
+ state: currentState,
208
+ unsupported: bluetoothState.unsupported,
209
+ initialized: bluetoothState.initialized,
210
+ };
211
+ }
212
+
213
+ // Setup persistent state listener for app layer
214
+ function setupPersistentStateListener(): void {
215
+ if (!noble || persistentStateListener) return;
216
+
217
+ persistentStateListener = (state: string) => {
218
+ logger?.info('[NobleBLE] Persistent state change:', state);
219
+
220
+ // Update global state
221
+ updateBluetoothState(state);
222
+
223
+ // When Bluetooth is powered off, clear all device caches and reset state to avoid stale peripherals
224
+ if (state === 'poweredOff') {
225
+ logger?.info('[NobleBLE] Bluetooth powered off - clearing device caches and resetting state');
226
+
227
+ // Cleanup all connected devices (send disconnect event to renderer)
228
+ const connectedIds = Array.from(connectedDevices.keys());
229
+ for (const deviceId of connectedIds) {
230
+ try {
231
+ cleanupDevice(deviceId, undefined, {
232
+ cleanupConnection: true,
233
+ sendDisconnectEvent: true,
234
+ cancelOperations: true,
235
+ reason: 'bluetooth-poweredOff',
236
+ });
237
+ } catch (e) {
238
+ safeLog(logger, 'error', 'Failed to cleanup device during poweredOff', {
239
+ deviceId,
240
+ error: e instanceof Error ? e.message : String(e),
241
+ });
242
+ }
243
+ }
244
+
245
+ // Clear discovery and subscription-related states to ensure next connect starts from state-1
246
+ discoveredDevices.clear();
247
+ deviceCharacteristics.clear();
248
+ subscribedDevices.clear();
249
+ notificationCallbacks.clear();
250
+ devicePacketStates.clear();
251
+ subscriptionOperations.clear();
252
+ pairedDevices.clear();
253
+
254
+ // Best-effort stop scanning
255
+ if (noble) {
256
+ try {
257
+ noble.stopScanning();
258
+ } catch (e) {
259
+ safeLog(
260
+ logger,
261
+ 'error',
262
+ 'Failed to stop scanning on poweredOff',
263
+ e instanceof Error ? e.message : String(e)
264
+ );
265
+ }
266
+ }
267
+ }
268
+ };
269
+
270
+ noble.on('stateChange', persistentStateListener);
271
+ logger?.info('[NobleBLE] Persistent state listener setup');
272
+
273
+ // Manually check and update initial state
274
+ const currentState = noble.state;
275
+ if (currentState) {
276
+ logger?.info('[NobleBLE] Initial state detected:', currentState);
277
+ updateBluetoothState(currentState);
278
+ }
279
+ }
280
+
281
+ // Update bluetooth state helper
282
+ function updateBluetoothState(state: string): void {
283
+ if (state === 'poweredOn') {
284
+ bluetoothState.available = true;
285
+ bluetoothState.unsupported = false;
286
+ bluetoothState.initialized = true;
287
+ } else if (state === 'unsupported') {
288
+ bluetoothState.available = false;
289
+ bluetoothState.unsupported = true;
290
+ bluetoothState.initialized = true;
291
+ } else if (state === 'poweredOff') {
292
+ bluetoothState.available = false;
293
+ bluetoothState.unsupported = false;
294
+ bluetoothState.initialized = true;
295
+ } else if (state === 'unauthorized') {
296
+ bluetoothState.available = false;
297
+ bluetoothState.unsupported = false;
298
+ bluetoothState.initialized = true;
299
+ }
300
+ }
301
+
302
+ // Initialize Noble
303
+ async function initializeNoble(): Promise<void> {
304
+ if (noble) return;
305
+
306
+ try {
307
+ // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
308
+ noble = require('@stoprocent/noble') as NobleModule;
309
+ logger?.info('[NobleBLE] Noble library loaded');
310
+
311
+ // Wait for Bluetooth to be ready
312
+ await new Promise<void>((resolve, reject) => {
313
+ if (!noble) {
314
+ reject(ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not initialized'));
315
+ return;
316
+ }
317
+
318
+ if (noble.state === 'poweredOn') {
319
+ resolve();
320
+ return;
321
+ }
322
+
323
+ // Setup persistent state listener before initialization
324
+ setupPersistentStateListener();
325
+
326
+ const timeout = setTimeout(() => {
327
+ reject(
328
+ ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Bluetooth initialization timeout')
329
+ );
330
+ }, BLUETOOTH_INIT_TIMEOUT);
331
+
332
+ const cleanup = () => {
333
+ clearTimeout(timeout);
334
+ if (noble) {
335
+ noble.removeListener('stateChange', onStateChange);
336
+ }
337
+ };
338
+
339
+ const onStateChange = (state: string) => {
340
+ logger?.info('[NobleBLE] Bluetooth state:', state);
341
+
342
+ if (state === 'poweredOn') {
343
+ cleanup();
344
+ resolve();
345
+ } else if (state === 'unsupported') {
346
+ cleanup();
347
+ reject(ERRORS.TypedError(HardwareErrorCode.BleUnsupported));
348
+ } else if (state === 'poweredOff') {
349
+ cleanup();
350
+ reject(ERRORS.TypedError(HardwareErrorCode.BlePoweredOff));
351
+ } else if (state === 'unauthorized') {
352
+ cleanup();
353
+ reject(ERRORS.TypedError(HardwareErrorCode.BlePermissionError));
354
+ }
355
+ };
356
+
357
+ noble.on('stateChange', onStateChange);
358
+ });
359
+
360
+ // Set up device discovery
361
+ noble.on('discover', (peripheral: Peripheral) => {
362
+ handleDeviceDiscovered(peripheral);
363
+ });
364
+
365
+ logger?.info('[NobleBLE] Noble initialized successfully');
366
+ } catch (error) {
367
+ logger?.error('[NobleBLE] Failed to initialize Noble:', error);
368
+ bluetoothState.unsupported = true;
369
+ bluetoothState.initialized = true;
370
+ throw error;
371
+ }
372
+ }
373
+
374
+ // (Removed) cancelPairing: pairing is handled automatically during Windows init now
375
+
376
+ // ===== 统一的设备清理系统 =====
377
+
378
+ /**
379
+ * 设备清理选项
380
+ */
381
+ interface DeviceCleanupOptions {
382
+ /** 是否清理 BLE 连接状态 */
383
+ cleanupConnection?: boolean;
384
+ /** 是否发送断开事件 */
385
+ sendDisconnectEvent?: boolean;
386
+ /** 是否取消正在进行的操作 */
387
+ cancelOperations?: boolean;
388
+ /** 清理原因(用于日志) */
389
+ reason?: string;
390
+ }
391
+
392
+ /**
393
+ * 统一的设备清理函数 - 所有清理操作的唯一入口
394
+ */
395
+ function cleanupDevice(
396
+ deviceId: string,
397
+ webContents?: WebContents,
398
+ options: DeviceCleanupOptions = {}
399
+ ): void {
400
+ const {
401
+ cleanupConnection = true,
402
+ sendDisconnectEvent = false,
403
+ cancelOperations = true,
404
+ reason = 'unknown',
405
+ } = options;
406
+
407
+ logger?.info('[NobleBLE] Starting device cleanup', {
408
+ deviceId,
409
+ reason,
410
+ cleanupConnection,
411
+ sendDisconnectEvent,
412
+ cancelOperations,
413
+ });
414
+
415
+ // 获取设备信息(在清理前)
416
+ const peripheral = connectedDevices.get(deviceId);
417
+ const deviceName = peripheral?.advertisement?.localName || 'Unknown Device';
418
+
419
+ // 1. 清理设备状态
420
+ if (cleanupConnection) {
421
+ connectedDevices.delete(deviceId);
422
+ deviceCharacteristics.delete(deviceId);
423
+ notificationCallbacks.delete(deviceId);
424
+ devicePacketStates.delete(deviceId);
425
+ subscribedDevices.delete(deviceId);
426
+ subscriptionOperations.delete(deviceId);
427
+ pairedDevices.delete(deviceId); // 清理windows配对状态
428
+ }
429
+
430
+ // 2. 发送断开事件(如果需要)
431
+ if (sendDisconnectEvent && webContents) {
432
+ webContents.send(EUKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
433
+ id: deviceId,
434
+ name: deviceName,
435
+ });
436
+ }
437
+
438
+ logger?.info('[NobleBLE] Device cleanup completed', { deviceId, reason });
439
+ }
440
+
441
+ /**
442
+ * 处理设备断开 - 自动断开的情况
443
+ */
444
+ function handleDeviceDisconnect(deviceId: string, webContents: WebContents): void {
445
+ logger?.error('[NobleBLE] ⚠️ DEVICE DISCONNECT DETECTED:', {
446
+ deviceId,
447
+ hasPeripheral: connectedDevices.has(deviceId),
448
+ hasCharacteristics: deviceCharacteristics.has(deviceId),
449
+ stackTrace: new Error().stack?.split('\n').slice(1, 5),
450
+ });
451
+
452
+ cleanupDevice(deviceId, webContents, {
453
+ cleanupConnection: true,
454
+ sendDisconnectEvent: true,
455
+ cancelOperations: true,
456
+ reason: 'auto-disconnect',
457
+ });
458
+ }
459
+
460
+ // Set up disconnect listener for a peripheral
461
+ function setupDisconnectListener(
462
+ peripheral: Peripheral,
463
+ deviceId: string,
464
+ webContents: WebContents
465
+ ): void {
466
+ // Remove any existing disconnect listeners to avoid duplicates
467
+ peripheral.removeAllListeners('disconnect');
468
+
469
+ // Set up new disconnect listener
470
+ peripheral.on('disconnect', () => {
471
+ handleDeviceDisconnect(deviceId, webContents);
472
+ });
473
+ }
474
+
475
+ // ===== Write helpers (inline) =====
476
+
477
+ async function writeCharacteristicWithAck(
478
+ deviceId: string,
479
+ writeCharacteristic: Characteristic,
480
+ buffer: Buffer
481
+ ): Promise<void> {
482
+ return new Promise((resolve, reject) => {
483
+ writeCharacteristic.write(buffer, true, (error?: Error) => {
484
+ if (error) {
485
+ logger?.error('[NobleBLE] Write failed', { deviceId, error: String(error) });
486
+ reject(error);
487
+ return;
488
+ }
489
+ resolve();
490
+ });
491
+ });
492
+ }
493
+
494
+ async function attemptWindowsWriteUntilPaired(
495
+ deviceId: string,
496
+ doGetWriteCharacteristic: () => Characteristic | null | undefined,
497
+ payload: Buffer,
498
+ contextLabel: string
499
+ ): Promise<void> {
500
+ const timeoutMs = RETRY_CONFIG.WRITE_TIMEOUT;
501
+ for (let attempt = 1; attempt <= RETRY_CONFIG.MAX_ATTEMPTS; attempt++) {
502
+ // If disconnected, abort
503
+ if (!connectedDevices.has(deviceId)) {
504
+ throw ERRORS.TypedError(
505
+ HardwareErrorCode.BleConnectedError,
506
+ `Device ${deviceId} disconnected during retry`
507
+ );
508
+ }
509
+
510
+ logger?.debug('[BLE-Write] Windows write attempt', {
511
+ deviceId,
512
+ attempt,
513
+ context: contextLabel,
514
+ });
515
+
516
+ const latestWrite = doGetWriteCharacteristic();
517
+ if (!latestWrite) {
518
+ throw ERRORS.TypedError(
519
+ HardwareErrorCode.RuntimeError,
520
+ `Write characteristic not available for ${deviceId}`
521
+ );
522
+ }
523
+
524
+ try {
525
+ await writeCharacteristicWithAck(deviceId, latestWrite, payload);
526
+ } catch (e) {
527
+ const errorMessage = e instanceof Error ? e.message : String(e);
528
+ logger?.error('[BLE-Write] Windows write error', {
529
+ deviceId,
530
+ attempt,
531
+ context: contextLabel,
532
+ error: errorMessage,
533
+ });
534
+ // Abort immediately on known error patterns (e.g., status: 3)
535
+ if (ABORTABLE_WRITE_ERROR_PATTERNS.some(p => p.test(errorMessage))) {
536
+ await unsubscribeNotifications(deviceId).catch(() => {});
537
+ await disconnectDevice(deviceId).catch(() => {});
538
+ discoveredDevices.delete(deviceId);
539
+ subscriptionOperations.set(deviceId, 'idle');
540
+ logger?.info('[NobleBLE] Deep cleanup to reset device state to initial', { deviceId });
541
+ // 置空/重置订阅操作状态,避免后续进入 subscribing 等中间态
542
+ throw ERRORS.TypedError(
543
+ HardwareErrorCode.BleConnectedError,
544
+ `Write failed with abortable error for device ${deviceId}: ${errorMessage}`
545
+ );
546
+ }
547
+ }
548
+
549
+ // Check if paired already
550
+ if (pairedDevices.has(deviceId)) {
551
+ logger?.info('[BLE-Write] Windows write success (paired, exiting loop)', {
552
+ deviceId,
553
+ attempt,
554
+ context: contextLabel,
555
+ });
556
+ return;
557
+ }
558
+
559
+ if (attempt < RETRY_CONFIG.MAX_ATTEMPTS) {
560
+ await wait(timeoutMs);
561
+ }
562
+
563
+ if (pairedDevices.has(deviceId)) {
564
+ logger?.info('[BLE-Write] Notification observed during wait (paired), exiting loop', {
565
+ deviceId,
566
+ attempt,
567
+ context: contextLabel,
568
+ });
569
+ return;
570
+ }
571
+
572
+ // Try soft refresh first
573
+ try {
574
+ const notifyCharacteristic = deviceCharacteristics.get(deviceId)?.notify;
575
+ await softRefreshSubscription({
576
+ deviceId,
577
+ notifyCharacteristic,
578
+ subscriptionOperations,
579
+ subscribedDevices,
580
+ pairedDevices,
581
+ notificationCallbacks,
582
+ processNotificationData,
583
+ logger,
584
+ });
585
+ logger?.info('[BLE-Write] Subscription refresh completed', { deviceId });
586
+ } catch (refreshError) {
587
+ const errMsg = refreshError instanceof Error ? refreshError.message : String(refreshError);
588
+ logger?.error('[BLE-Write] Subscription refresh failed', { deviceId, error: errMsg });
589
+ }
590
+ }
591
+
592
+ throw ERRORS.TypedError(
593
+ HardwareErrorCode.DeviceNotFound,
594
+ `No response observed after ${RETRY_CONFIG.MAX_ATTEMPTS} writes: ${deviceId}`
595
+ );
596
+ }
597
+
598
+ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promise<void> {
599
+ const characteristics = deviceCharacteristics.get(deviceId);
600
+ const peripheral = connectedDevices.get(deviceId);
601
+ if (!peripheral || !characteristics) {
602
+ throw ERRORS.TypedError(
603
+ HardwareErrorCode.BleCharacteristicNotFound,
604
+ `Device ${deviceId} not connected or characteristics not available`
605
+ );
606
+ }
607
+
608
+ const toBuffer = Buffer.from(hexData, 'hex');
609
+ logger?.info('[NobleBLE] Writing data:', {
610
+ deviceId,
611
+ dataLength: toBuffer.length,
612
+ firstBytes: toBuffer.subarray(0, 8).toString('hex'),
613
+ });
614
+
615
+ const doGetWriteCharacteristic = () => deviceCharacteristics.get(deviceId)?.write;
616
+
617
+ if (!IS_WINDOWS || pairedDevices.has(deviceId)) {
618
+ // macOS / Linux or already paired on Windows: direct write
619
+ const writeCharacteristic = doGetWriteCharacteristic();
620
+ if (!writeCharacteristic) {
621
+ throw ERRORS.TypedError(
622
+ HardwareErrorCode.BleCharacteristicNotFound,
623
+ `Write characteristic not available for ${deviceId}`
624
+ );
625
+ }
626
+ if (toBuffer.length <= BLE_PACKET_SIZE) {
627
+ await wait(UNIFIED_WRITE_DELAY);
628
+ await writeCharacteristicWithAck(deviceId, writeCharacteristic, toBuffer);
629
+ return;
630
+ }
631
+ // chunked
632
+ for (let offset = 0, idx = 0; offset < toBuffer.length; idx++) {
633
+ const chunkSize = Math.min(BLE_PACKET_SIZE, toBuffer.length - offset);
634
+ const chunk = toBuffer.subarray(offset, offset + chunkSize);
635
+ offset += chunkSize;
636
+ const latest = doGetWriteCharacteristic();
637
+ if (!latest) {
638
+ throw ERRORS.TypedError(
639
+ HardwareErrorCode.BleCharacteristicNotFound,
640
+ `Write characteristic not available for ${deviceId}`
641
+ );
642
+ }
643
+ await writeCharacteristicWithAck(deviceId, latest, chunk);
644
+ if (offset < toBuffer.length) {
645
+ await wait(UNIFIED_WRITE_DELAY);
646
+ }
647
+ }
648
+ return;
649
+ }
650
+
651
+ // Windows unpaired path: use loop
652
+ if (toBuffer.length <= BLE_PACKET_SIZE) {
653
+ await wait(UNIFIED_WRITE_DELAY);
654
+ await attemptWindowsWriteUntilPaired(deviceId, doGetWriteCharacteristic, toBuffer, 'single');
655
+ return;
656
+ }
657
+ // chunked loop
658
+ for (let offset = 0, idx = 0; offset < toBuffer.length; idx++) {
659
+ const chunkSize = Math.min(BLE_PACKET_SIZE, toBuffer.length - offset);
660
+ const chunk = toBuffer.subarray(offset, offset + chunkSize);
661
+ offset += chunkSize;
662
+ await attemptWindowsWriteUntilPaired(
663
+ deviceId,
664
+ doGetWriteCharacteristic,
665
+ chunk,
666
+ `chunk-${idx + 1}`
667
+ );
668
+ if (offset < toBuffer.length) {
669
+ await wait(UNIFIED_WRITE_DELAY);
670
+ }
671
+ }
672
+ }
673
+
674
+ // Handle discovered device
675
+ function handleDeviceDiscovered(peripheral: Peripheral): void {
676
+ const deviceName = peripheral.advertisement?.localName || 'Unknown Device';
677
+
678
+ // Only process UKey devices
679
+ if (!isUkeyDevice(deviceName)) {
680
+ return;
681
+ }
682
+
683
+ logger?.info('[NobleBLE] Discovered UKey device:', deviceName);
684
+
685
+ // Cache the device in both maps
686
+ discoveredDevices.set(peripheral.id, peripheral);
687
+ }
688
+
689
+ // Ensure discover listener is properly set up
690
+ // This fixes the issue where devices are not found after web-usb communication failures
691
+ function ensureDiscoverListener(): void {
692
+ if (!noble) return;
693
+
694
+ // Check if discover listener exists by checking listener count
695
+ const listenerCount = (noble as any).listenerCount('discover');
696
+
697
+ if (listenerCount === 0) {
698
+ logger?.info('[NobleBLE] Discover listener missing, re-adding it');
699
+ noble.on('discover', (peripheral: Peripheral) => {
700
+ handleDeviceDiscovered(peripheral);
701
+ });
702
+ } else {
703
+ logger?.debug('[NobleBLE] Discover listener already exists, count:', listenerCount);
704
+ }
705
+ }
706
+
707
+ // Perform targeted scan for a specific device ID
708
+ async function performTargetedScan(targetDeviceId: string): Promise<Peripheral | null> {
709
+ if (!noble) {
710
+ throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
711
+ }
712
+
713
+ logger?.info('[NobleBLE] Starting targeted scan for device:', targetDeviceId);
714
+
715
+ // Ensure discover listener is properly set up before targeted scanning
716
+ ensureDiscoverListener();
717
+
718
+ return new Promise((resolve, reject) => {
719
+ const timeout = setTimeout(() => {
720
+ if (noble) {
721
+ noble.stopScanning();
722
+ }
723
+ logger?.info('[NobleBLE] Targeted scan timeout for device:', targetDeviceId);
724
+ resolve(null);
725
+ }, FAST_SCAN_TIMEOUT);
726
+
727
+ // Set up discovery handler for target device
728
+ const onDiscover = (peripheral: Peripheral) => {
729
+ if (peripheral.id === targetDeviceId) {
730
+ clearTimeout(timeout);
731
+ if (noble) {
732
+ noble.stopScanning();
733
+ noble.removeListener('discover', onDiscover);
734
+ }
735
+
736
+ // Cache the found device
737
+ discoveredDevices.set(peripheral.id, peripheral);
738
+
739
+ logger?.info('[NobleBLE] UKey device found during targeted scan:', {
740
+ id: peripheral.id,
741
+ name: peripheral.advertisement?.localName || 'Unknown',
742
+ });
743
+
744
+ resolve(peripheral);
745
+ }
746
+ };
747
+
748
+ // Remove any existing discover listeners to prevent memory leaks
749
+ if (noble) {
750
+ noble.removeListener('discover', onDiscover);
751
+ noble.on('discover', onDiscover);
752
+ }
753
+
754
+ // Start scanning
755
+ if (noble) {
756
+ noble.startScanning(UKEY_SERVICE_UUIDS, false, (error?: Error) => {
757
+ if (error) {
758
+ clearTimeout(timeout);
759
+ if (noble) {
760
+ noble.removeListener('discover', onDiscover);
761
+ }
762
+ logger?.error('[NobleBLE] Failed to start targeted scan:', error);
763
+ reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
764
+ return;
765
+ }
766
+
767
+ logger?.info('[NobleBLE] Targeted scan started for device:', targetDeviceId);
768
+ });
769
+ }
770
+ });
771
+ }
772
+
773
+ // Enumerate devices
774
+ async function enumerateDevices(): Promise<DeviceInfo[]> {
775
+ if (!noble) {
776
+ await initializeNoble();
777
+ }
778
+
779
+ if (!noble) {
780
+ throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
781
+ }
782
+
783
+ logger?.info('[NobleBLE] Starting device enumeration');
784
+
785
+ // Clear previous discoveries
786
+ discoveredDevices.clear();
787
+
788
+ // Ensure discover listener is properly set up before scanning
789
+ // This is crucial to fix the issue where devices are not found after web-usb failures
790
+ ensureDiscoverListener();
791
+
792
+ return new Promise((resolve, reject) => {
793
+ const devices: DeviceInfo[] = [];
794
+
795
+ if (!noble) {
796
+ reject(ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available'));
797
+ return;
798
+ }
799
+
800
+ // Set timeout for scanning
801
+ const timeout = setTimeout(() => {
802
+ if (noble) {
803
+ noble.stopScanning();
804
+ }
805
+ logger?.info('[NobleBLE] Scan completed, found devices:', devices.length);
806
+ resolve(devices);
807
+ }, DEVICE_SCAN_TIMEOUT);
808
+
809
+ // Start scanning for UKey service UUIDs
810
+ noble.startScanning(UKEY_SERVICE_UUIDS, false, (error?: Error) => {
811
+ if (error) {
812
+ clearTimeout(timeout);
813
+ logger?.error('[NobleBLE] Failed to start scanning:', error);
814
+ reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
815
+ return;
816
+ }
817
+
818
+ logger?.info('[NobleBLE] Scanning started for UKey devices');
819
+
820
+ // Collect discovered devices
821
+ const checkDevices = () => {
822
+ discoveredDevices.forEach((peripheral, id) => {
823
+ const existingDevice = devices.find(d => d.id === id);
824
+ if (!existingDevice) {
825
+ const deviceName = peripheral.advertisement?.localName || 'Unknown Device';
826
+ devices.push({
827
+ id,
828
+ name: deviceName,
829
+ state: peripheral.state || 'disconnected',
830
+ });
831
+ }
832
+ });
833
+ };
834
+
835
+ // Check for devices periodically
836
+ const interval = setInterval(checkDevices, DEVICE_CHECK_INTERVAL);
837
+
838
+ // Clean up interval when timeout occurs
839
+ setTimeout(() => {
840
+ clearInterval(interval);
841
+ }, DEVICE_SCAN_TIMEOUT);
842
+ });
843
+ });
844
+ }
845
+
846
+ // Stop scanning
847
+ async function stopScanning(): Promise<void> {
848
+ if (!noble) return;
849
+
850
+ return new Promise<void>(resolve => {
851
+ if (!noble) {
852
+ resolve();
853
+ return;
854
+ }
855
+
856
+ noble.stopScanning(() => {
857
+ logger?.info('[NobleBLE] Scanning stopped');
858
+ resolve();
859
+ });
860
+ });
861
+ }
862
+
863
+ // 清理所有 Noble 监听器(用于应用退出时)
864
+ function cleanupNobleListeners(): void {
865
+ if (!noble) return;
866
+
867
+ // 移除所有监听器以防止内存泄漏
868
+ // Noble 使用 EventEmitter,需要使用 removeAllListeners
869
+ try {
870
+ (noble as any).removeAllListeners('discover');
871
+ (noble as any).removeAllListeners('stateChange');
872
+ logger?.info('[NobleBLE] All Noble listeners cleaned up');
873
+ } catch (error) {
874
+ logger?.error('[NobleBLE] Failed to clean up some listeners:', error);
875
+ }
876
+ }
877
+
878
+ // Get device info - supports both discovered and direct connection modes
879
+ function getDevice(deviceId: string): DeviceInfo | null {
880
+ // First check if device was discovered through scanning
881
+ const peripheral = discoveredDevices.get(deviceId);
882
+ if (peripheral) {
883
+ const deviceName = peripheral.advertisement?.localName || 'Unknown Device';
884
+ return {
885
+ id: peripheral.id,
886
+ name: deviceName,
887
+ state: peripheral.state || 'disconnected',
888
+ };
889
+ }
890
+
891
+ // If not discovered, check if it's already connected (direct connection mode)
892
+ const connectedPeripheral = connectedDevices.get(deviceId);
893
+ if (connectedPeripheral) {
894
+ const deviceName = connectedPeripheral.advertisement?.localName || 'Unknown Device';
895
+ return {
896
+ id: connectedPeripheral.id,
897
+ name: deviceName,
898
+ state: connectedPeripheral.state || 'connected',
899
+ };
900
+ }
901
+
902
+ // For direct connection mode, return a placeholder device info
903
+ // This allows the connection process to proceed without prior discovery
904
+ return {
905
+ id: deviceId,
906
+ name: 'UKey Device',
907
+ state: 'disconnected',
908
+ };
909
+ }
910
+
911
+ // Core service discovery function (single attempt)
912
+ async function discoverServicesAndCharacteristics(
913
+ peripheral: Peripheral
914
+ ): Promise<CharacteristicPair> {
915
+ return new Promise((resolve, reject) => {
916
+ peripheral.discoverServices(
917
+ UKEY_SERVICE_UUIDS,
918
+ (error: Error | undefined, services: Service[]) => {
919
+ if (error) {
920
+ logger?.error('[NobleBLE] Service discovery failed:', error);
921
+ reject(ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, error.message));
922
+ return;
923
+ }
924
+
925
+ if (!services || services.length === 0) {
926
+ reject(
927
+ ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No UKey services found')
928
+ );
929
+ return;
930
+ }
931
+
932
+ const service = services[0]; // Use first found service
933
+ logger?.info('[NobleBLE] Found service:', service.uuid);
934
+
935
+ // Discover characteristics
936
+ service.discoverCharacteristics(
937
+ [UKEY_WRITE_CHARACTERISTIC_UUID, UKEY_NOTIFY_CHARACTERISTIC_UUID],
938
+ (error: Error | undefined, characteristics: Characteristic[]) => {
939
+ if (error) {
940
+ logger?.error('[NobleBLE] Characteristic discovery failed:', error);
941
+ reject(ERRORS.TypedError(HardwareErrorCode.BleCharacteristicNotFound, error.message));
942
+ return;
943
+ }
944
+
945
+ // Log discovered characteristics summary
946
+ logger?.info('[NobleBLE] Discovered characteristics:', {
947
+ count: characteristics?.length || 0,
948
+ uuids: characteristics?.map(c => c.uuid) || [],
949
+ });
950
+
951
+ let writeCharacteristic: Characteristic | null = null;
952
+ let notifyCharacteristic: Characteristic | null = null;
953
+
954
+ // Find characteristics by extracting the distinguishing part of UUID
955
+ for (const characteristic of characteristics) {
956
+ const uuid = characteristic.uuid.replace(/-/g, '').toLowerCase();
957
+ const uuidKey = uuid.length >= 8 ? uuid.substring(4, 8) : uuid;
958
+
959
+ if (uuidKey === NORMALIZED_WRITE_UUID) {
960
+ writeCharacteristic = characteristic;
961
+ } else if (uuidKey === NORMALIZED_NOTIFY_UUID) {
962
+ notifyCharacteristic = characteristic;
963
+ }
964
+ }
965
+
966
+ logger?.info('[NobleBLE] Characteristic discovery result:', {
967
+ writeFound: !!writeCharacteristic,
968
+ notifyFound: !!notifyCharacteristic,
969
+ });
970
+
971
+ if (!writeCharacteristic || !notifyCharacteristic) {
972
+ logger?.error(
973
+ '[NobleBLE] Missing characteristics - write:',
974
+ !!writeCharacteristic,
975
+ 'notify:',
976
+ !!notifyCharacteristic
977
+ );
978
+ reject(
979
+ ERRORS.TypedError(
980
+ HardwareErrorCode.BleCharacteristicNotFound,
981
+ 'Required characteristics not found'
982
+ )
983
+ );
984
+ return;
985
+ }
986
+
987
+ resolve({ write: writeCharacteristic, notify: notifyCharacteristic });
988
+ }
989
+ );
990
+ }
991
+ );
992
+ });
993
+ }
994
+
995
+ // Force reconnect to clear potential connection state issues
996
+ async function forceReconnectPeripheral(peripheral: Peripheral, deviceId: string): Promise<void> {
997
+ logger?.info('[NobleBLE] Forcing connection reset for device:', deviceId);
998
+
999
+ // Step 1: Clean up all device state first
1000
+ cleanupDevice(deviceId, undefined, {
1001
+ cleanupConnection: true,
1002
+ sendDisconnectEvent: false,
1003
+ cancelOperations: true,
1004
+ reason: 'force-reconnect',
1005
+ });
1006
+
1007
+ // Step 2: Force disconnect if connected
1008
+ if (peripheral.state === 'connected') {
1009
+ await new Promise<void>(resolve => {
1010
+ peripheral.disconnect(() => {
1011
+ logger?.info('[NobleBLE] Force disconnect completed');
1012
+ resolve();
1013
+ });
1014
+ });
1015
+
1016
+ // Wait for complete disconnection
1017
+ await wait(1000);
1018
+ }
1019
+
1020
+ // Step 3: Clear any remaining listeners on the peripheral
1021
+ peripheral.removeAllListeners();
1022
+
1023
+ // Step 4: Re-establish connection with longer timeout
1024
+ await new Promise<void>((resolve, reject) => {
1025
+ peripheral.connect((error: Error | undefined) => {
1026
+ if (error) {
1027
+ logger?.error('[NobleBLE] Force reconnect failed:', error);
1028
+ reject(new Error(`Force reconnect failed: ${error.message}`));
1029
+ } else {
1030
+ logger?.info('[NobleBLE] Force reconnect successful');
1031
+ connectedDevices.set(deviceId, peripheral);
1032
+ resolve();
1033
+ }
1034
+ });
1035
+ });
1036
+
1037
+ // Wait for connection to stabilize
1038
+ await wait(500);
1039
+ }
1040
+
1041
+ // Enhanced connection with fresh peripheral rescan as last resort
1042
+ async function connectAndDiscoverWithFreshScan(deviceId: string): Promise<CharacteristicPair> {
1043
+ logger?.info('[NobleBLE] Attempting connection with fresh peripheral scan as fallback');
1044
+
1045
+ const currentPeripheral = discoveredDevices.get(deviceId);
1046
+
1047
+ // First attempt with existing peripheral
1048
+ if (currentPeripheral) {
1049
+ try {
1050
+ return await discoverServicesAndCharacteristicsWithRetry(currentPeripheral, deviceId);
1051
+ } catch (error) {
1052
+ logger?.error(
1053
+ '[NobleBLE] Service discovery failed with existing peripheral, attempting fresh scan...'
1054
+ );
1055
+ }
1056
+ }
1057
+
1058
+ // Last resort: Fresh scan to get new peripheral object
1059
+ logger?.info(
1060
+ '[NobleBLE] Performing fresh scan to get new peripheral object for device:',
1061
+ deviceId
1062
+ );
1063
+
1064
+ try {
1065
+ const freshPeripheral = await performTargetedScan(deviceId);
1066
+ if (!freshPeripheral) {
1067
+ // 深度清理:fresh scan 没有找到设备,强制回到初始状态(状态1)
1068
+ discoveredDevices.delete(deviceId);
1069
+ subscriptionOperations.set(deviceId, 'idle');
1070
+ logger?.info('[NobleBLE] Deep cleanup before throwing DeviceNotFound (fresh scan null)', {
1071
+ deviceId,
1072
+ });
1073
+ throw ERRORS.TypedError(
1074
+ HardwareErrorCode.DeviceNotFound,
1075
+ `Device ${deviceId} not found in fresh scan`
1076
+ );
1077
+ }
1078
+
1079
+ // Update device maps with fresh peripheral
1080
+ discoveredDevices.set(deviceId, freshPeripheral);
1081
+
1082
+ // Connect to fresh peripheral
1083
+ await new Promise<void>((resolve, reject) => {
1084
+ freshPeripheral.connect((error: Error | undefined) => {
1085
+ if (error) {
1086
+ reject(new Error(`Fresh peripheral connection failed: ${error.message}`));
1087
+ } else {
1088
+ connectedDevices.set(deviceId, freshPeripheral);
1089
+ resolve();
1090
+ }
1091
+ });
1092
+ });
1093
+
1094
+ // Attempt service discovery with fresh peripheral (single attempt)
1095
+ logger?.info('[NobleBLE] Attempting service discovery with fresh peripheral');
1096
+ await wait(1000); // Give fresh connection more time to stabilize
1097
+
1098
+ return await discoverServicesAndCharacteristics(freshPeripheral);
1099
+ } catch (error) {
1100
+ logger?.error('[NobleBLE] Fresh scan and connection failed:', error);
1101
+ throw error;
1102
+ }
1103
+ }
1104
+
1105
+ // Enhanced service discovery with p-retry for robust BLE connection
1106
+ async function discoverServicesAndCharacteristicsWithRetry(
1107
+ peripheral: Peripheral,
1108
+ deviceId: string
1109
+ ): Promise<CharacteristicPair> {
1110
+ return pRetry(
1111
+ async attemptNumber => {
1112
+ logger?.info('[NobleBLE] Starting service discovery:', {
1113
+ deviceId,
1114
+ peripheralState: peripheral.state,
1115
+ attempt: attemptNumber,
1116
+ maxRetries: 5,
1117
+ targetUUIDs: UKEY_SERVICE_UUIDS,
1118
+ });
1119
+
1120
+ // Strategy: Force reconnect on 3rd attempt to clear potential state issues
1121
+ if (attemptNumber === 3) {
1122
+ logger?.info('[NobleBLE] Attempting force reconnect to clear connection state...');
1123
+ try {
1124
+ await forceReconnectPeripheral(peripheral, deviceId);
1125
+ } catch (error) {
1126
+ logger?.error('[NobleBLE] Force reconnect failed:', error);
1127
+ throw error;
1128
+ }
1129
+ }
1130
+
1131
+ // Progressive delay strategy - handled by p-retry, but add extra wait for higher attempts
1132
+ if (attemptNumber > 1) {
1133
+ logger?.info(`[NobleBLE] Service discovery retry attempt ${attemptNumber}/5`);
1134
+ }
1135
+
1136
+ // Verify connection state before attempting service discovery
1137
+ if (peripheral.state !== 'connected') {
1138
+ throw new Error(`Device not connected: ${peripheral.state}`);
1139
+ }
1140
+
1141
+ try {
1142
+ return await discoverServicesAndCharacteristics(peripheral);
1143
+ } catch (error) {
1144
+ logger?.error(`[NobleBLE] No services found (attempt ${attemptNumber}/5)`);
1145
+
1146
+ if (attemptNumber < 5) {
1147
+ logger?.error(`[NobleBLE] Will retry service discovery (attempt ${attemptNumber + 1}/5)`);
1148
+ }
1149
+
1150
+ throw error; // p-retry will handle the retry logic
1151
+ }
1152
+ },
1153
+ {
1154
+ retries: 4, // Total 5 attempts (initial + 4 retries)
1155
+ factor: 1.5, // Exponential backoff: 1000ms → 1500ms → 2250ms → 3000ms
1156
+ minTimeout: 1000, // Start with 1 second delay
1157
+ maxTimeout: 3000, // Maximum 3 seconds delay
1158
+ onFailedAttempt: error => {
1159
+ // This runs after each failed attempt
1160
+ logger?.error(`[NobleBLE] Service discovery attempt ${error.attemptNumber} failed:`, {
1161
+ message: error.message,
1162
+ retriesLeft: error.retriesLeft,
1163
+ nextRetryIn: `${Math.min(1000 * 1.5 ** error.attemptNumber, 3000)}ms`,
1164
+ });
1165
+ },
1166
+ }
1167
+ );
1168
+ }
1169
+
1170
+ // Connect to device - supports both discovered and direct connection modes
1171
+ async function connectDevice(deviceId: string, webContents: WebContents): Promise<void> {
1172
+ logger?.info('[NobleBLE] Connect device request:', {
1173
+ deviceId,
1174
+ hasDiscovered: discoveredDevices.has(deviceId),
1175
+ hasConnected: connectedDevices.has(deviceId),
1176
+ hasCharacteristics: deviceCharacteristics.has(deviceId),
1177
+ totalDiscovered: discoveredDevices.size,
1178
+ totalConnected: connectedDevices.size,
1179
+ });
1180
+
1181
+ let peripheral = discoveredDevices.get(deviceId);
1182
+
1183
+ // If device not discovered, try a targeted scan for this specific device
1184
+ if (!peripheral) {
1185
+ logger?.info('[NobleBLE] Device not discovered, attempting targeted scan for:', deviceId);
1186
+
1187
+ // Initialize Noble if not already done
1188
+ if (!noble) {
1189
+ await initializeNoble();
1190
+ }
1191
+
1192
+ if (!noble) {
1193
+ throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
1194
+ }
1195
+
1196
+ // Perform a targeted scan to find the specific device
1197
+ try {
1198
+ const foundPeripheral = await performTargetedScan(deviceId);
1199
+ if (!foundPeripheral) {
1200
+ throw ERRORS.TypedError(
1201
+ HardwareErrorCode.DeviceNotFound,
1202
+ `Device ${deviceId} not found even after targeted scan`
1203
+ );
1204
+ }
1205
+ peripheral = foundPeripheral;
1206
+ } catch (error) {
1207
+ logger?.error('[NobleBLE] Targeted scan failed:', error);
1208
+ throw error;
1209
+ }
1210
+ }
1211
+
1212
+ // At this point, peripheral is guaranteed to be defined
1213
+ if (!peripheral) {
1214
+ throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found`);
1215
+ }
1216
+
1217
+ logger?.info('[NobleBLE] Connecting to device:', deviceId);
1218
+
1219
+ // Check if device is already connected
1220
+ if (peripheral.state === 'connected') {
1221
+ logger?.info('[NobleBLE] Device already connected, skipping connection step');
1222
+
1223
+ // If already connected but not in our connected devices map, add it
1224
+ if (!connectedDevices.has(deviceId)) {
1225
+ connectedDevices.set(deviceId, peripheral);
1226
+ // Set up unified disconnect listener
1227
+ setupDisconnectListener(peripheral, deviceId, webContents);
1228
+ }
1229
+
1230
+ // Check if we already have characteristics for this device
1231
+ if (deviceCharacteristics.has(deviceId)) {
1232
+ logger?.info('[NobleBLE] Device characteristics already available');
1233
+
1234
+ // ⚠️ CRITICAL FIX: Check for ongoing subscription operations to prevent race conditions
1235
+ const ongoingOperation = subscriptionOperations.get(deviceId);
1236
+ if (ongoingOperation && ongoingOperation !== 'idle') {
1237
+ logger?.info(
1238
+ '[NobleBLE] Device has ongoing subscription operation:',
1239
+ ongoingOperation,
1240
+ 'skip reconnect'
1241
+ );
1242
+ // 正在进行订阅操作,避免递归重连造成循环;直接返回,等待订阅流程完成
1243
+ return;
1244
+ }
1245
+
1246
+ // Don't clean up notification state if device is already properly connected
1247
+ // The existing notification subscription is still valid and working
1248
+ const hasActiveSubscription = subscribedDevices.has(deviceId);
1249
+ const hasCallback = notificationCallbacks.has(deviceId);
1250
+
1251
+ if (hasActiveSubscription && hasCallback) {
1252
+ logger?.info(
1253
+ '[NobleBLE] Device already has active notification subscription, reusing connection'
1254
+ );
1255
+ return;
1256
+ }
1257
+
1258
+ // Only clean up if subscription is broken
1259
+ logger?.info(
1260
+ '[NobleBLE] Found orphaned characteristics without active subscription, cleaning up'
1261
+ );
1262
+ const existingCharacteristics = deviceCharacteristics.get(deviceId);
1263
+ if (existingCharacteristics) {
1264
+ existingCharacteristics.notify.removeAllListeners('data');
1265
+ }
1266
+ notificationCallbacks.delete(deviceId);
1267
+ devicePacketStates.delete(deviceId);
1268
+ subscribedDevices.delete(deviceId);
1269
+ // Continue to re-setup the connection properly
1270
+ }
1271
+
1272
+ // Wait for the device to stabilize before proceeding
1273
+ await wait(300);
1274
+
1275
+ // Discover services and characteristics with enhanced retry including fresh scan
1276
+ try {
1277
+ const characteristics = await connectAndDiscoverWithFreshScan(deviceId);
1278
+ deviceCharacteristics.set(deviceId, characteristics);
1279
+ logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1280
+ return;
1281
+ } catch (error) {
1282
+ logger?.error(
1283
+ '[NobleBLE] Service/characteristic discovery failed after all attempts:',
1284
+ error
1285
+ );
1286
+ throw error;
1287
+ }
1288
+ }
1289
+
1290
+ return new Promise((resolve, reject) => {
1291
+ const timeout = setTimeout(() => {
1292
+ reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'Connection timeout'));
1293
+ }, CONNECTION_TIMEOUT);
1294
+
1295
+ // TypeScript type assertion - peripheral is guaranteed to be defined at this point
1296
+ const connectedPeripheral = peripheral as Peripheral;
1297
+ connectedPeripheral.connect(async (error: Error | undefined) => {
1298
+ clearTimeout(timeout);
1299
+
1300
+ if (error) {
1301
+ logger?.error('[NobleBLE] Connection failed:', error);
1302
+ reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
1303
+ return;
1304
+ }
1305
+
1306
+ logger?.info('[NobleBLE] Connected to device:', deviceId);
1307
+ connectedDevices.set(deviceId, connectedPeripheral);
1308
+
1309
+ // Set up unified disconnect listener
1310
+ setupDisconnectListener(connectedPeripheral, deviceId, webContents);
1311
+
1312
+ try {
1313
+ const characteristics = await connectAndDiscoverWithFreshScan(deviceId);
1314
+ deviceCharacteristics.set(deviceId, characteristics);
1315
+ logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1316
+ resolve();
1317
+ } catch (discoveryError) {
1318
+ logger?.error(
1319
+ '[NobleBLE] Service/characteristic discovery failed after all attempts:',
1320
+ discoveryError
1321
+ );
1322
+ // Disconnect on failure
1323
+ connectedPeripheral.disconnect();
1324
+ reject(discoveryError);
1325
+ }
1326
+ });
1327
+ });
1328
+ }
1329
+
1330
+ // Disconnect device
1331
+ async function disconnectDevice(deviceId: string): Promise<void> {
1332
+ const peripheral = connectedDevices.get(deviceId);
1333
+ if (!peripheral) {
1334
+ return;
1335
+ }
1336
+
1337
+ return new Promise<void>(resolve => {
1338
+ // Remove disconnect listener to avoid triggering handleDeviceDisconnect
1339
+ peripheral.removeAllListeners('disconnect');
1340
+
1341
+ peripheral.disconnect(() => {
1342
+ // Clean up device state using unified function
1343
+ cleanupDevice(deviceId, undefined, {
1344
+ cleanupConnection: true,
1345
+ sendDisconnectEvent: false,
1346
+ cancelOperations: true,
1347
+ reason: 'manual-disconnect',
1348
+ });
1349
+ resolve();
1350
+ });
1351
+ });
1352
+ }
1353
+
1354
+ // Unsubscribe from notifications
1355
+ async function unsubscribeNotifications(deviceId: string): Promise<void> {
1356
+ const peripheral = connectedDevices.get(deviceId);
1357
+ const characteristics = deviceCharacteristics.get(deviceId);
1358
+
1359
+ if (!peripheral || !characteristics) {
1360
+ return;
1361
+ }
1362
+
1363
+ const { notify: notifyCharacteristic } = characteristics;
1364
+
1365
+ logger?.info('[NobleBLE] Unsubscribing from notifications for device:', deviceId);
1366
+
1367
+ // 🔒 Set operation state to prevent race conditions
1368
+ subscriptionOperations.set(deviceId, 'unsubscribing');
1369
+
1370
+ return new Promise<void>(resolve => {
1371
+ notifyCharacteristic.unsubscribe((error: Error | undefined) => {
1372
+ if (error) {
1373
+ logger?.error('[NobleBLE] Notification unsubscription failed:', error);
1374
+ } else {
1375
+ logger?.info('[NobleBLE] Notification unsubscription successful');
1376
+ }
1377
+
1378
+ // Remove all listeners and clear subscription status
1379
+ notifyCharacteristic.removeAllListeners('data');
1380
+ notificationCallbacks.delete(deviceId);
1381
+ devicePacketStates.delete(deviceId);
1382
+ subscribedDevices.delete(deviceId);
1383
+
1384
+ // 🔒 Clear operation state
1385
+ subscriptionOperations.set(deviceId, 'idle');
1386
+ resolve();
1387
+ });
1388
+ });
1389
+ }
1390
+
1391
+ // Subscribe to notifications
1392
+ async function subscribeNotifications(
1393
+ deviceId: string,
1394
+ callback: (data: string) => void
1395
+ ): Promise<void> {
1396
+ const peripheral = connectedDevices.get(deviceId);
1397
+ const characteristics = deviceCharacteristics.get(deviceId);
1398
+
1399
+ if (!peripheral || !characteristics) {
1400
+ throw ERRORS.TypedError(
1401
+ HardwareErrorCode.BleCharacteristicNotFound,
1402
+ `Device ${deviceId} not connected or characteristics not available`
1403
+ );
1404
+ }
1405
+
1406
+ const { notify: notifyCharacteristic } = characteristics;
1407
+
1408
+ logger?.info('[NobleBLE] Subscribing to notifications for device:', deviceId);
1409
+ logger?.info('[NobleBLE] Subscribe context', {
1410
+ deviceId,
1411
+ opStateBefore: subscriptionOperations.get(deviceId) || 'idle',
1412
+ paired: false,
1413
+ hasController: false,
1414
+ });
1415
+ // If a subscription is already in progress, dedupe
1416
+ const opState = subscriptionOperations.get(deviceId);
1417
+ if (opState === 'subscribing') {
1418
+ // Subscription in progress; update callback and return
1419
+ notificationCallbacks.set(deviceId, callback);
1420
+ return Promise.resolve();
1421
+ }
1422
+
1423
+ // 🔒 Set operation state to prevent race conditions
1424
+ subscriptionOperations.set(deviceId, 'subscribing');
1425
+
1426
+ // Check if already subscribed at the characteristic level
1427
+ if (subscribedDevices.get(deviceId)) {
1428
+ logger?.info('[NobleBLE] Device already subscribed to characteristic, updating callback only');
1429
+
1430
+ // Just update the callback without re-subscribing
1431
+ notificationCallbacks.set(deviceId, callback);
1432
+
1433
+ // Reset packet state for new session
1434
+ devicePacketStates.set(deviceId, {
1435
+ bufferLength: 0,
1436
+ buffer: [],
1437
+ packetCount: 0,
1438
+ messageId: undefined,
1439
+ });
1440
+
1441
+ // 🔒 Clear operation state
1442
+ subscriptionOperations.set(deviceId, 'idle');
1443
+ return Promise.resolve();
1444
+ }
1445
+
1446
+ // Clean up any existing listeners before subscribing
1447
+ if (notificationCallbacks.has(deviceId)) {
1448
+ logger?.info('[NobleBLE] Cleaning up previous notification listeners');
1449
+ }
1450
+
1451
+ // 统一清理监听器(避免重复调用)
1452
+ notifyCharacteristic.removeAllListeners('data');
1453
+
1454
+ // Store callback for this device
1455
+ notificationCallbacks.set(deviceId, callback);
1456
+
1457
+ // Reset packet state for new subscription session
1458
+ devicePacketStates.set(deviceId, {
1459
+ bufferLength: 0,
1460
+ buffer: [],
1461
+ packetCount: 0,
1462
+ messageId: undefined,
1463
+ });
1464
+
1465
+ // Helper: rebuild a clean application-layer subscription
1466
+ async function rebuildAppSubscription(
1467
+ deviceId: string,
1468
+ notifyCharacteristic: Characteristic
1469
+ ): Promise<void> {
1470
+ // 监听器已在上面清理,这里不需要重复清理
1471
+ await new Promise<void>(resolve => {
1472
+ notifyCharacteristic.unsubscribe(() => {
1473
+ resolve();
1474
+ });
1475
+ });
1476
+ await new Promise<void>((resolve, reject) => {
1477
+ notifyCharacteristic.subscribe((error?: Error) => {
1478
+ if (error) {
1479
+ reject(error);
1480
+ return;
1481
+ }
1482
+ resolve();
1483
+ });
1484
+ });
1485
+
1486
+ notifyCharacteristic.on('data', (data: Buffer) => {
1487
+ // Windows BLE 配对检测:收到任何数据都认为设备已配对
1488
+ if (!pairedDevices.has(deviceId)) {
1489
+ pairedDevices.add(deviceId);
1490
+ logger?.info('[NobleBLE] Device paired successfully', { deviceId });
1491
+ }
1492
+
1493
+ const result = processNotificationData(deviceId, data);
1494
+ if (result.error) {
1495
+ logger?.error('[NobleBLE] Packet processing error:', result.error);
1496
+ return;
1497
+ }
1498
+ if (result.isComplete && result.completePacket) {
1499
+ const appCb = notificationCallbacks.get(deviceId);
1500
+ if (appCb) appCb(result.completePacket);
1501
+ }
1502
+ });
1503
+ }
1504
+
1505
+ await rebuildAppSubscription(deviceId, notifyCharacteristic);
1506
+ subscribedDevices.set(deviceId, true);
1507
+ subscriptionOperations.set(deviceId, 'idle');
1508
+ }
1509
+
1510
+ // (moved unsubscribeNotifications above)
1511
+
1512
+ // Setup IPC handlers
1513
+ export function setupNobleBleHandlers(webContents: WebContents): void {
1514
+ // Use console.log for initial logging as electron-log might not be available yet.
1515
+ console.log('[NobleBLE] Attempting to set up Noble BLE handlers.');
1516
+ try {
1517
+ console.log('[NobleBLE] NOBLE_VERSION_771');
1518
+
1519
+ // @ts-ignore – electron-log is only available at runtime
1520
+ // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
1521
+ logger = require('electron-log') as Logger;
1522
+ console.log('[NobleBLE] electron-log loaded successfully.');
1523
+
1524
+ // @ts-ignore – electron is only available at runtime
1525
+ // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
1526
+ const { ipcMain } = require('electron');
1527
+ console.log('[NobleBLE] electron.ipcMain loaded successfully.');
1528
+
1529
+ safeLog(logger, 'info', 'Setting up Noble BLE IPC handlers');
1530
+
1531
+ // Handle enumerate request
1532
+ console.log(`[NobleBLE] Registering handler for: ${EUKeyBleMessageKeys.NOBLE_BLE_ENUMERATE}`);
1533
+ ipcMain.handle(EUKeyBleMessageKeys.NOBLE_BLE_ENUMERATE, async () => {
1534
+ try {
1535
+ const devices = await enumerateDevices();
1536
+ safeLog(logger, 'info', 'Enumeration completed, devices:', devices);
1537
+ return devices;
1538
+ } catch (error) {
1539
+ safeLog(logger, 'error', 'Enumeration failed:', error);
1540
+ throw error;
1541
+ }
1542
+ });
1543
+
1544
+ // Handle stop scan request
1545
+ ipcMain.handle(EUKeyBleMessageKeys.NOBLE_BLE_STOP_SCAN, async () => {
1546
+ await stopScanning();
1547
+ });
1548
+
1549
+ // Handle get device request
1550
+ ipcMain.handle(
1551
+ EUKeyBleMessageKeys.NOBLE_BLE_GET_DEVICE,
1552
+ (_event: IpcMainInvokeEvent, deviceId: string) => getDevice(deviceId)
1553
+ );
1554
+
1555
+ // Handle connect request
1556
+ ipcMain.handle(
1557
+ EUKeyBleMessageKeys.NOBLE_BLE_CONNECT,
1558
+ async (_event: IpcMainInvokeEvent, deviceId: string) => {
1559
+ logger?.info('[NobleBLE] IPC CONNECT request received:', {
1560
+ deviceId,
1561
+ hasPeripheral: connectedDevices.has(deviceId),
1562
+ hasCharacteristics: deviceCharacteristics.has(deviceId),
1563
+ totalConnectedDevices: connectedDevices.size,
1564
+ });
1565
+ await connectDevice(deviceId, webContents);
1566
+ }
1567
+ );
1568
+
1569
+ // Handle disconnect request
1570
+ ipcMain.handle(
1571
+ EUKeyBleMessageKeys.NOBLE_BLE_DISCONNECT,
1572
+ async (_event: IpcMainInvokeEvent, deviceId: string) => {
1573
+ await disconnectDevice(deviceId);
1574
+ }
1575
+ );
1576
+
1577
+ // Handle write request
1578
+ ipcMain.handle(
1579
+ EUKeyBleMessageKeys.NOBLE_BLE_WRITE,
1580
+ async (_event: IpcMainInvokeEvent, deviceId: string, hexData: string) => {
1581
+ logger?.info('[NobleBLE] IPC WRITE', { deviceId, len: hexData.length });
1582
+ await transmitHexDataToDevice(deviceId, hexData);
1583
+ }
1584
+ );
1585
+
1586
+ // Handle subscribe request
1587
+ ipcMain.handle(
1588
+ EUKeyBleMessageKeys.NOBLE_BLE_SUBSCRIBE,
1589
+ async (_event: IpcMainInvokeEvent, deviceId: string) => {
1590
+ await subscribeNotifications(deviceId, (data: string) => {
1591
+ // Send data back to renderer process
1592
+ webContents.send(EUKeyBleMessageKeys.NOBLE_BLE_NOTIFICATION, deviceId, data);
1593
+ });
1594
+ }
1595
+ );
1596
+
1597
+ // Handle unsubscribe request
1598
+ ipcMain.handle(
1599
+ EUKeyBleMessageKeys.NOBLE_BLE_UNSUBSCRIBE,
1600
+ async (_event: IpcMainInvokeEvent, deviceId: string) => {
1601
+ await unsubscribeNotifications(deviceId);
1602
+ }
1603
+ );
1604
+
1605
+ // Handle cancel pairing: cleanup all connected devices
1606
+ ipcMain.handle(EUKeyBleMessageKeys.NOBLE_BLE_CANCEL_PAIRING, async () => {
1607
+ const deviceIds = Array.from(connectedDevices.keys());
1608
+ logger?.info('[NobleBLE] Cancel pairing invoked', {
1609
+ platform: process.platform,
1610
+ deviceCount: deviceIds.length,
1611
+ });
1612
+
1613
+ for (const deviceId of deviceIds) {
1614
+ try {
1615
+ // 取消订阅和断开连接(disconnectDevice 内部会调用 cleanupDevice)
1616
+ await unsubscribeNotifications(deviceId).catch(() => {});
1617
+ await disconnectDevice(deviceId).catch(() => {});
1618
+
1619
+ // disconnectDevice 已经完成了所有清理工作,无需重复调用 cleanupDevice
1620
+ } catch (e) {
1621
+ logger?.error('[NobleBLE] Cancel pairing cleanup failed', { deviceId, error: e });
1622
+ }
1623
+ }
1624
+ });
1625
+
1626
+ // Handle Bluetooth availability check request
1627
+ ipcMain.handle(EUKeyBleMessageKeys.BLE_AVAILABILITY_CHECK, async () => {
1628
+ try {
1629
+ const bluetoothStatus = await checkBluetoothAvailability();
1630
+ safeLog(logger, 'info', 'Bluetooth availability check completed:', bluetoothStatus);
1631
+ return bluetoothStatus;
1632
+ } catch (error) {
1633
+ safeLog(logger, 'error', 'Bluetooth availability check failed:', error);
1634
+ return {
1635
+ available: false,
1636
+ state: 'error',
1637
+ unsupported: false,
1638
+ initialized: false,
1639
+ };
1640
+ }
1641
+ });
1642
+
1643
+ // Cleanup on app quit
1644
+ webContents.on('destroyed', () => {
1645
+ safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
1646
+
1647
+ // 1. 清理所有连接的设备(统一清理,避免重复)
1648
+ const deviceIds = Array.from(connectedDevices.keys());
1649
+ deviceIds.forEach(deviceId => {
1650
+ cleanupDevice(deviceId, undefined, {
1651
+ cleanupConnection: true,
1652
+ sendDisconnectEvent: false,
1653
+ cancelOperations: true,
1654
+ reason: 'app-quit',
1655
+ });
1656
+ });
1657
+
1658
+ // 2. 停止扫描
1659
+ stopScanning();
1660
+
1661
+ // 3. 清理 Noble 监听器
1662
+ if (noble && persistentStateListener) {
1663
+ noble.removeListener('stateChange', persistentStateListener);
1664
+ persistentStateListener = null;
1665
+ }
1666
+ cleanupNobleListeners();
1667
+
1668
+ // 4. 清理发现的设备缓存
1669
+ discoveredDevices.clear();
1670
+
1671
+ safeLog(logger, 'info', 'Noble BLE cleanup completed');
1672
+ });
1673
+
1674
+ safeLog(logger, 'info', 'Noble BLE IPC handlers setup completed');
1675
+ } catch (error) {
1676
+ console.error('[NobleBLE] Failed to setup IPC handlers:', error);
1677
+ throw error;
1678
+ }
1679
+ }