@onekeyfe/hardware-cli 1.2.0-alpha.3 → 1.2.0-alpha.4

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,415 @@
1
+ import {
2
+ ERRORS,
3
+ HardwareErrorCode,
4
+ ONEKEY_SERVICE_UUID,
5
+ isOnekeyDevice,
6
+ wait,
7
+ } from '@onekeyfe/hd-shared';
8
+
9
+ import type { LowLevelDevice, LowlevelTransportSharedPlugin } from '@onekeyfe/hd-transport';
10
+ import type { Characteristic, Peripheral, Service } from '@stoprocent/noble';
11
+
12
+ type NobleModule = {
13
+ state: string;
14
+ startScanning(
15
+ serviceUUIDs: string[],
16
+ allowDuplicates: boolean,
17
+ callback?: (error?: Error) => void
18
+ ): void;
19
+ stopScanning(callback?: () => void): void;
20
+ on(event: 'stateChange', listener: (state: string) => void): void;
21
+ on(event: 'discover', listener: (peripheral: Peripheral) => void): void;
22
+ removeListener(event: 'stateChange', listener: (state: string) => void): void;
23
+ removeListener(event: 'discover', listener: (peripheral: Peripheral) => void): void;
24
+ };
25
+
26
+ type CharacteristicPair = {
27
+ write: Characteristic;
28
+ notify: Characteristic;
29
+ };
30
+
31
+ const ONEKEY_SERVICE_UUIDS = [ONEKEY_SERVICE_UUID];
32
+ const PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS = new Set(['fffd']);
33
+ const NORMALIZED_WRITE_UUID = '0002';
34
+ const NORMALIZED_NOTIFY_UUID = '0003';
35
+ const NORMALIZED_ONEKEY_SERVICE_UUIDS = new Set([
36
+ ...ONEKEY_SERVICE_UUIDS.map(uuid => getBleUuidKey(uuid)),
37
+ '0001',
38
+ ]);
39
+
40
+ const BLUETOOTH_INIT_TIMEOUT = 10_000;
41
+ const DEVICE_SCAN_TIMEOUT = 8_000;
42
+ const CONNECTION_TIMEOUT = 8_000;
43
+ const SERVICE_DISCOVERY_TIMEOUT = 10_000;
44
+ const BLE_PACKET_SIZE = 192;
45
+ const BLE_WRITE_DELAY = 5;
46
+ const BLE_ENCRYPTION_ERROR_PATTERNS = [/encryption is insufficient/i, /insufficient encryption/i];
47
+
48
+ let noble: NobleModule | null = null;
49
+ let nobleReadyPromise: Promise<void> | null = null;
50
+ const discoveredDevices = new Map<string, Peripheral>();
51
+ const connectedDevices = new Map<string, Peripheral>();
52
+ const deviceCharacteristics = new Map<string, CharacteristicPair>();
53
+ const notificationQueue: string[] = [];
54
+ const pendingReceivers: Array<(data: string) => void> = [];
55
+
56
+ function getBleUuidKey(uuid?: string | null) {
57
+ const normalized = (uuid ?? '').replace(/-/g, '').toLowerCase();
58
+ return normalized.length >= 8 ? normalized.substring(4, 8) : normalized;
59
+ }
60
+
61
+ function isGenericBleService(uuid?: string | null) {
62
+ return ['1800', '1801', '180a', '180f'].includes(getBleUuidKey(uuid));
63
+ }
64
+
65
+ function hasOneKeyAdvertisementService(peripheral: Peripheral) {
66
+ const serviceUuids = peripheral.advertisement?.serviceUuids ?? [];
67
+ return serviceUuids.some(uuid => {
68
+ const uuidKey = getBleUuidKey(uuid);
69
+ return (
70
+ NORMALIZED_ONEKEY_SERVICE_UUIDS.has(uuidKey) ||
71
+ PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS.has(uuidKey)
72
+ );
73
+ });
74
+ }
75
+
76
+ function isOneKeyPeripheral(peripheral: Peripheral) {
77
+ const deviceName = peripheral.advertisement?.localName || null;
78
+ return isOnekeyDevice(deviceName, peripheral.id) || hasOneKeyAdvertisementService(peripheral);
79
+ }
80
+
81
+ function enqueueNotification(data: Buffer) {
82
+ const hex = data.toString('hex');
83
+ const receiver = pendingReceivers.shift();
84
+ if (receiver) {
85
+ receiver(hex);
86
+ return;
87
+ }
88
+ notificationQueue.push(hex);
89
+ }
90
+
91
+ async function initializeNoble() {
92
+ if (!noble) {
93
+ try {
94
+ // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
95
+ noble = require('@stoprocent/noble') as NobleModule;
96
+ } catch (error) {
97
+ throw ERRORS.TypedError(
98
+ HardwareErrorCode.BleUnsupported,
99
+ error instanceof Error ? error.message : String(error)
100
+ );
101
+ }
102
+ }
103
+
104
+ if (noble.state === 'poweredOn') return;
105
+
106
+ if (nobleReadyPromise) {
107
+ await nobleReadyPromise;
108
+ return;
109
+ }
110
+
111
+ nobleReadyPromise = new Promise<void>((resolve, reject) => {
112
+ const timeout = setTimeout(() => {
113
+ noble?.removeListener('stateChange', onStateChange);
114
+ reject(ERRORS.TypedError(HardwareErrorCode.BlePoweredOff, 'Bluetooth is not powered on'));
115
+ }, BLUETOOTH_INIT_TIMEOUT);
116
+
117
+ const onStateChange = (state: string) => {
118
+ if (state === 'poweredOn') {
119
+ clearTimeout(timeout);
120
+ noble?.removeListener('stateChange', onStateChange);
121
+ resolve();
122
+ } else if (state === 'unsupported') {
123
+ clearTimeout(timeout);
124
+ noble?.removeListener('stateChange', onStateChange);
125
+ reject(ERRORS.TypedError(HardwareErrorCode.BleUnsupported));
126
+ }
127
+ };
128
+
129
+ noble?.on('stateChange', onStateChange);
130
+ }).finally(() => {
131
+ nobleReadyPromise = null;
132
+ });
133
+
134
+ await nobleReadyPromise;
135
+ }
136
+
137
+ function stopScanning() {
138
+ try {
139
+ noble?.stopScanning();
140
+ } catch {
141
+ // ignore best-effort scan cleanup
142
+ }
143
+ }
144
+
145
+ async function scanDevices(targetDeviceId?: string) {
146
+ await initializeNoble();
147
+ if (!noble) {
148
+ throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not initialized');
149
+ }
150
+
151
+ if (!targetDeviceId) {
152
+ discoveredDevices.clear();
153
+ }
154
+
155
+ const nobleInstance = noble;
156
+ return new Promise<Peripheral[]>((resolve, reject) => {
157
+ const found = new Map<string, Peripheral>();
158
+
159
+ const cleanup = () => {
160
+ clearTimeout(timeout);
161
+ nobleInstance.removeListener('discover', onDiscover);
162
+ stopScanning();
163
+ };
164
+
165
+ const finish = () => {
166
+ cleanup();
167
+ resolve([...found.values()]);
168
+ };
169
+
170
+ const onDiscover = (peripheral: Peripheral) => {
171
+ if (targetDeviceId && peripheral.id !== targetDeviceId) return;
172
+ if (!targetDeviceId && !isOneKeyPeripheral(peripheral)) return;
173
+
174
+ discoveredDevices.set(peripheral.id, peripheral);
175
+ found.set(peripheral.id, peripheral);
176
+ if (targetDeviceId) {
177
+ finish();
178
+ }
179
+ };
180
+
181
+ const timeout = setTimeout(finish, DEVICE_SCAN_TIMEOUT);
182
+ nobleInstance.on('discover', onDiscover);
183
+ nobleInstance.startScanning([], false, (error?: Error) => {
184
+ if (error) {
185
+ cleanup();
186
+ reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
187
+ }
188
+ });
189
+ });
190
+ }
191
+
192
+ function connectPeripheral(peripheral: Peripheral) {
193
+ if (peripheral.state === 'connected') return Promise.resolve();
194
+
195
+ return new Promise<void>((resolve, reject) => {
196
+ const timeout = setTimeout(() => {
197
+ reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'Connection timeout'));
198
+ }, CONNECTION_TIMEOUT);
199
+
200
+ peripheral.connect((error?: Error) => {
201
+ clearTimeout(timeout);
202
+ if (error) {
203
+ reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
204
+ return;
205
+ }
206
+ resolve();
207
+ });
208
+ });
209
+ }
210
+
211
+ async function discoverCharacteristics(peripheral: Peripheral): Promise<CharacteristicPair> {
212
+ const services = await new Promise<Service[]>((resolve, reject) => {
213
+ const timeout = setTimeout(() => {
214
+ reject(ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'Service discovery timeout'));
215
+ }, SERVICE_DISCOVERY_TIMEOUT);
216
+
217
+ peripheral.discoverServices([], (error, discoveredServices) => {
218
+ clearTimeout(timeout);
219
+ if (error) {
220
+ reject(ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, error.message));
221
+ return;
222
+ }
223
+ resolve(discoveredServices);
224
+ });
225
+ });
226
+
227
+ let service = services.find(s => NORMALIZED_ONEKEY_SERVICE_UUIDS.has(getBleUuidKey(s.uuid)));
228
+ if (!service) {
229
+ service =
230
+ services.find(s => PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS.has(getBleUuidKey(s.uuid))) ||
231
+ services.find(s => !isGenericBleService(s.uuid)) ||
232
+ services[0];
233
+ }
234
+ if (!service) {
235
+ throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No BLE service found');
236
+ }
237
+ const selectedService = service;
238
+
239
+ const characteristics = await new Promise<Characteristic[]>((resolve, reject) => {
240
+ selectedService.discoverCharacteristics([], (error, discoveredCharacteristics) => {
241
+ if (error) {
242
+ reject(ERRORS.TypedError(HardwareErrorCode.BleCharacteristicNotFound, error.message));
243
+ return;
244
+ }
245
+ resolve(discoveredCharacteristics);
246
+ });
247
+ });
248
+
249
+ let writeCharacteristic: Characteristic | undefined;
250
+ let notifyCharacteristic: Characteristic | undefined;
251
+ for (const characteristic of characteristics) {
252
+ const uuidKey = getBleUuidKey(characteristic.uuid);
253
+ if (uuidKey === NORMALIZED_WRITE_UUID) {
254
+ writeCharacteristic = characteristic;
255
+ } else if (uuidKey === NORMALIZED_NOTIFY_UUID) {
256
+ notifyCharacteristic = characteristic;
257
+ }
258
+ }
259
+
260
+ if (!writeCharacteristic || !notifyCharacteristic) {
261
+ throw ERRORS.TypedError(
262
+ HardwareErrorCode.BleCharacteristicNotFound,
263
+ 'Required OneKey BLE characteristics not found'
264
+ );
265
+ }
266
+
267
+ return {
268
+ write: writeCharacteristic,
269
+ notify: notifyCharacteristic,
270
+ };
271
+ }
272
+
273
+ function subscribeNotifications(deviceId: string, notifyCharacteristic: Characteristic) {
274
+ return new Promise<void>(resolve => {
275
+ notifyCharacteristic.unsubscribe(() => resolve());
276
+ })
277
+ .then(
278
+ () =>
279
+ new Promise<void>((resolve, reject) => {
280
+ notifyCharacteristic.subscribe((error?: Error) => {
281
+ if (error) {
282
+ const errorMessage = error.message || String(error);
283
+ if (BLE_ENCRYPTION_ERROR_PATTERNS.some(pattern => pattern.test(errorMessage))) {
284
+ reject(
285
+ ERRORS.TypedError(
286
+ HardwareErrorCode.BleDeviceNotBonded,
287
+ `BLE device ${deviceId} is not paired or the encrypted link is not ready: ${errorMessage}`
288
+ )
289
+ );
290
+ return;
291
+ }
292
+ reject(
293
+ ERRORS.TypedError(
294
+ HardwareErrorCode.BleCharacteristicNotifyChangeFailure,
295
+ `Failed to subscribe notifications for ${deviceId}: ${errorMessage}`
296
+ )
297
+ );
298
+ return;
299
+ }
300
+ resolve();
301
+ });
302
+ })
303
+ )
304
+ .then(() => {
305
+ notifyCharacteristic.removeAllListeners('data');
306
+ notifyCharacteristic.on('data', enqueueNotification);
307
+ })
308
+ .catch(error => {
309
+ notifyCharacteristic.removeAllListeners('data');
310
+ if (error) {
311
+ throw error;
312
+ }
313
+ throw ERRORS.TypedError(HardwareErrorCode.BleCharacteristicNotifyChangeFailure);
314
+ });
315
+ }
316
+
317
+ function writeCharacteristic(characteristic: Characteristic, buffer: Buffer) {
318
+ return new Promise<void>((resolve, reject) => {
319
+ characteristic.write(buffer, true, (error?: Error) => {
320
+ if (error) {
321
+ reject(error);
322
+ return;
323
+ }
324
+ resolve();
325
+ });
326
+ });
327
+ }
328
+
329
+ async function disconnectDevice(uuid: string) {
330
+ const peripheral = connectedDevices.get(uuid);
331
+ const characteristics = deviceCharacteristics.get(uuid);
332
+ if (characteristics) {
333
+ characteristics.notify.removeAllListeners('data');
334
+ await new Promise<void>(resolve => {
335
+ characteristics.notify.unsubscribe(() => resolve());
336
+ });
337
+ }
338
+
339
+ connectedDevices.delete(uuid);
340
+ deviceCharacteristics.delete(uuid);
341
+ notificationQueue.length = 0;
342
+ pendingReceivers.splice(0).forEach(resolve => resolve(''));
343
+
344
+ if (!peripheral || peripheral.state === 'disconnected') return;
345
+
346
+ await new Promise<void>(resolve => {
347
+ peripheral.disconnect(() => resolve());
348
+ });
349
+ }
350
+
351
+ export function createNobleBlePlugin(): LowlevelTransportSharedPlugin {
352
+ return {
353
+ version: 'OneKey-CLI-Noble-1.0',
354
+
355
+ async init() {
356
+ await initializeNoble();
357
+ },
358
+
359
+ async enumerate(): Promise<LowLevelDevice[]> {
360
+ const devices = await scanDevices();
361
+ return devices.map(device => ({
362
+ commType: 'ble',
363
+ id: device.id,
364
+ name: device.advertisement?.localName || 'Unknown BLE Device',
365
+ }));
366
+ },
367
+
368
+ async connect(uuid: string) {
369
+ let peripheral = discoveredDevices.get(uuid);
370
+ if (!peripheral) {
371
+ [peripheral] = await scanDevices(uuid);
372
+ }
373
+ if (!peripheral) {
374
+ throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, `BLE device not found: ${uuid}`);
375
+ }
376
+
377
+ await connectPeripheral(peripheral);
378
+ const characteristics = await discoverCharacteristics(peripheral);
379
+ await subscribeNotifications(uuid, characteristics.notify);
380
+ connectedDevices.set(uuid, peripheral);
381
+ deviceCharacteristics.set(uuid, characteristics);
382
+ },
383
+
384
+ async disconnect(uuid: string) {
385
+ await disconnectDevice(uuid);
386
+ },
387
+
388
+ async send(uuid: string, data: string) {
389
+ const characteristics = deviceCharacteristics.get(uuid);
390
+ if (!characteristics) {
391
+ throw ERRORS.TypedError(
392
+ HardwareErrorCode.BleCharacteristicNotFound,
393
+ `BLE device is not connected: ${uuid}`
394
+ );
395
+ }
396
+
397
+ const buffer = Buffer.from(data, 'hex');
398
+ for (let offset = 0; offset < buffer.length; offset += BLE_PACKET_SIZE) {
399
+ const chunk = buffer.subarray(offset, Math.min(offset + BLE_PACKET_SIZE, buffer.length));
400
+ await writeCharacteristic(characteristics.write, chunk);
401
+ if (offset + BLE_PACKET_SIZE < buffer.length) {
402
+ await wait(BLE_WRITE_DELAY);
403
+ }
404
+ }
405
+ },
406
+
407
+ async receive() {
408
+ const queued = notificationQueue.shift();
409
+ if (queued !== undefined) return queued;
410
+ return new Promise<string>(resolve => {
411
+ pendingReceivers.push(resolve);
412
+ });
413
+ },
414
+ };
415
+ }