@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.
- package/dist/ble-ops.d.ts +19 -0
- package/dist/ble-ops.d.ts.map +1 -0
- package/dist/index-60176982.js +41 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/noble-ble-handler-37d3b074.js +1214 -0
- package/dist/noble-ble-handler.d.ts +3 -0
- package/dist/noble-ble-handler.d.ts.map +1 -0
- package/dist/types/desktop-api.d.ts +30 -0
- package/dist/types/desktop-api.d.ts.map +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/noble-extended.d.ts +26 -0
- package/dist/types/noble-extended.d.ts.map +1 -0
- package/package.json +39 -0
- package/src/ble-ops.ts +77 -0
- package/src/index.ts +11 -0
- package/src/noble-ble-handler.ts +1679 -0
- package/src/types/desktop-api.ts +28 -0
- package/src/types/index.ts +2 -0
- package/src/types/noble-extended.ts +55 -0
- package/tsconfig.json +11 -0
|
@@ -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
|
+
}
|