@onekeyfe/hardware-cli 1.2.0-alpha.7 → 1.2.0-alpha.71

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,13 @@
1
+ export function selectSearchDevice<T extends { connectId?: string | null }>(
2
+ devices: T[],
3
+ preferredConnectId?: string
4
+ ): T | undefined {
5
+ if (preferredConnectId) {
6
+ return (
7
+ devices.find(device => device.connectId === preferredConnectId) ??
8
+ ({ connectId: preferredConnectId } as T)
9
+ );
10
+ }
11
+
12
+ return devices[0];
13
+ }
@@ -0,0 +1,71 @@
1
+ import type { CoreApi, DeviceStateScope, KnownDevice, SearchDevice } from '@onekeyfe/hd-core';
2
+
3
+ type DeviceStateSdk = Pick<CoreApi, 'searchDevices' | 'getDeviceState' | 'getFeatures'>;
4
+
5
+ type DiscoveredDevice = SearchDevice & Partial<Pick<KnownDevice, 'features' | 'state'>>;
6
+
7
+ const createDeviceNotFoundResult = (connectId?: string) => ({
8
+ success: false as const,
9
+ payload: {
10
+ code: 'DEVICE_NOT_FOUND',
11
+ error: connectId ? `Device not found: ${connectId}` : 'No device found',
12
+ },
13
+ });
14
+
15
+ const resolveSearchDevice = async (sdk: DeviceStateSdk, connectId?: string) => {
16
+ const searchResult = await sdk.searchDevices();
17
+ if (!searchResult.success) return searchResult;
18
+
19
+ const devices = searchResult.payload as DiscoveredDevice[];
20
+ const device = connectId ? devices.find(item => item.connectId === connectId) : devices[0];
21
+
22
+ if (!device?.connectId) return createDeviceNotFoundResult(connectId);
23
+ return { success: true as const, payload: device };
24
+ };
25
+
26
+ /**
27
+ * Unified state entry for the new CLI. Resolve the user-facing connectId through
28
+ * discovery so V1 serial IDs map to the process-local USB path.
29
+ */
30
+ export const getCanonicalDeviceState = async (
31
+ sdk: DeviceStateSdk,
32
+ connectId: string | undefined,
33
+ scope: DeviceStateScope
34
+ ) => {
35
+ const deviceResult = await resolveSearchDevice(sdk, connectId);
36
+ if (!deviceResult.success) return deviceResult;
37
+
38
+ if (scope === 'runtime' && deviceResult.payload.state) {
39
+ return { success: true as const, payload: deviceResult.payload.state };
40
+ }
41
+
42
+ const resolvedConnectId = deviceResult.payload.connectId ?? undefined;
43
+ if (!resolvedConnectId) return createDeviceNotFoundResult();
44
+ return sdk.getDeviceState(resolvedConnectId, { scope });
45
+ };
46
+
47
+ /**
48
+ * Legacy CLI only: retain getFeatures for V1 and reuse the discovery projection for V2.
49
+ * Public SDK.getFeatures remains V1-only.
50
+ */
51
+ export const getCompatibleFeatures = async (sdk: DeviceStateSdk, connectId?: string) => {
52
+ const deviceResult = await resolveSearchDevice(sdk, connectId);
53
+ if (!deviceResult.success) return deviceResult;
54
+
55
+ const device = deviceResult.payload;
56
+ const protocol = device.state?.protocol ?? device.features?.protocol;
57
+ if (protocol === 'V2') {
58
+ if (!device.features) {
59
+ return {
60
+ success: false as const,
61
+ payload: {
62
+ code: 'DEVICE_FEATURES_UNAVAILABLE',
63
+ error: 'Protocol V2 compatibility features are unavailable',
64
+ },
65
+ };
66
+ }
67
+ return { success: true as const, payload: device.features };
68
+ }
69
+
70
+ return sdk.getFeatures(device.connectId ?? '');
71
+ };
package/src/pinentry.ts CHANGED
@@ -73,6 +73,7 @@ export function findPinentry(): string | null {
73
73
  export interface PinentryResult {
74
74
  value: string;
75
75
  passphraseOnDevice: boolean;
76
+ attachPinOnDevice?: boolean;
76
77
  }
77
78
 
78
79
  // CLI-variant policy differs from app-monorepo: we fall back to on-device
package/src/sdk.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * Passphrase flow aligns with app-monorepo CLI:
6
6
  * - Standard wallet: --use-empty-passphrase, auto-respond
7
7
  * - Hidden wallet: interactive 1/2/3 selection (standard / pinentry / on-device)
8
- * - Session caching: passphraseState + sessionId stored in OS keychain,
9
- * preloaded via preloadSessionCache on next invocation
8
+ * - Legacy session caching: existing OS keychain entries may still be preloaded,
9
+ * but public SDK responses never expose new device session ids
10
10
  */
11
11
 
12
12
  import * as readline from 'node:readline';
@@ -16,7 +16,7 @@ import { DEVICE, UI_EVENT, UI_REQUEST, UI_RESPONSE } from '@onekeyfe/hd-core';
16
16
  import { promptPassphraseViaPinentry } from './pinentry';
17
17
  import { createNobleBlePlugin } from './transports/nobleBlePlugin';
18
18
 
19
- import type { ConnectSettings } from '@onekeyfe/hd-core';
19
+ import type { ConnectSettings, KnownDevice } from '@onekeyfe/hd-core';
20
20
  import type { PinentryResult } from './pinentry';
21
21
 
22
22
  export interface SDKOptions {
@@ -59,9 +59,15 @@ let sdkReadyPromise: Promise<typeof HardwareSDK> | null = null;
59
59
  * 2. Hidden wallet — enter passphrase via pinentry (secure OS dialog)
60
60
  * 3. Hidden wallet — enter passphrase on device screen
61
61
  */
62
- function resolvePassphraseByChoice(choice: '1' | '2' | '3'): Promise<PinentryResult> {
62
+ export function resolvePassphraseByChoice(choice: '1' | '2' | '3' | '4'): Promise<PinentryResult> {
63
63
  if (choice === '1') return Promise.resolve({ value: '', passphraseOnDevice: false });
64
64
  if (choice === '2') return promptPassphraseViaPinentry();
65
+ if (choice === '4')
66
+ return Promise.resolve({
67
+ value: '',
68
+ passphraseOnDevice: false,
69
+ attachPinOnDevice: true,
70
+ });
65
71
  return Promise.resolve({ value: '', passphraseOnDevice: true });
66
72
  }
67
73
 
@@ -84,18 +90,19 @@ function promptPassphraseMode(): Promise<PinentryResult> {
84
90
  ' 1. Standard wallet (no passphrase)',
85
91
  ' 2. Hidden wallet — enter passphrase on this computer (pinentry)',
86
92
  ' 3. Hidden wallet — enter passphrase on device screen',
93
+ ' 4. Attach PIN wallet — enter Attach PIN on device screen',
87
94
  '',
88
95
  ].join('\n')
89
96
  );
90
97
 
91
98
  rl.question('Enter selection [1/2/3]: ', answer => {
92
- const n = answer.trim() as '1' | '2' | '3';
93
- if (n === '1' || n === '2' || n === '3') {
99
+ const n = answer.trim() as '1' | '2' | '3' | '4';
100
+ if (n === '1' || n === '2' || n === '3' || n === '4') {
94
101
  rl.close();
95
102
  resolvePassphraseByChoice(n).then(resolve);
96
103
  return;
97
104
  }
98
- process.stderr.write('Invalid selection. Enter 1, 2, or 3.\n');
105
+ process.stderr.write('Invalid selection. Enter 1, 2, 3, or 4.\n');
99
106
  prompt();
100
107
  });
101
108
  };
@@ -143,6 +150,7 @@ function registerEventHandlers(sdk: typeof HardwareSDK): void {
143
150
  payload: {
144
151
  value: result.value,
145
152
  passphraseOnDevice: result.passphraseOnDevice,
153
+ attachPinOnDevice: result.attachPinOnDevice,
146
154
  save: false,
147
155
  },
148
156
  });
@@ -169,12 +177,12 @@ function registerEventHandlers(sdk: typeof HardwareSDK): void {
169
177
  }
170
178
  });
171
179
 
172
- sdk.on(DEVICE.CONNECT, (device: any) => {
180
+ sdk.on(DEVICE.CONNECT, ({ device }: { device: KnownDevice }) => {
173
181
  const name = device?.label || device?.name;
174
182
  if (name) process.stderr.write(`[onekey-hw] Device connected: ${name}\n`);
175
183
  });
176
184
 
177
- sdk.on(DEVICE.DISCONNECT, (device: any) => {
185
+ sdk.on(DEVICE.DISCONNECT, ({ device }: { device: KnownDevice }) => {
178
186
  const name = device?.label || device?.name;
179
187
  if (name) process.stderr.write(`[onekey-hw] Device disconnected: ${name}\n`);
180
188
  });
@@ -231,7 +239,7 @@ export async function disposeSDK(): Promise<void> {
231
239
  if (!sdkReadyPromise) return;
232
240
  try {
233
241
  const sdk = await sdkReadyPromise;
234
- sdk.dispose();
242
+ await Promise.resolve(sdk.dispose());
235
243
  } catch {
236
244
  // ignore errors during cleanup
237
245
  } finally {
package/src/session.ts CHANGED
@@ -1,11 +1,8 @@
1
1
  /**
2
2
  * Passphrase session management for hd-cli.
3
3
  *
4
- * Aligns with app-monorepo's CLI pattern:
5
- * Login: getPassphraseState passphraseState + sessionId keychain
6
- * Command: keychain → preloadSessionCache → SDK call (no passphrase prompt)
7
- * Stale: error 112 → clear keychain → re-prompt → retry
8
- * Logout: keychain delete
4
+ * Existing keychain entries remain readable for compatibility, but the public
5
+ * SDK no longer exposes new device session ids for persistence.
9
6
  */
10
7
 
11
8
  import { preloadSessionCache } from '@onekeyfe/hd-core';
@@ -55,25 +52,6 @@ export async function preloadSessionFromKeychain(deviceId: string): Promise<stri
55
52
  return undefined;
56
53
  }
57
54
 
58
- /**
59
- * Save passphraseState + sessionId to keychain for next CLI invocation.
60
- */
61
- export async function saveSessionToKeychain(
62
- deviceId: string,
63
- passphraseState: string,
64
- sessionId: string
65
- ): Promise<void> {
66
- try {
67
- const storage = getStorage();
68
- await Promise.all([
69
- storage.set(psKey(deviceId), Buffer.from(passphraseState, 'utf-8')),
70
- storage.set(sidKey(deviceId), Buffer.from(sessionId, 'utf-8')),
71
- ]);
72
- } catch {
73
- // Non-fatal — session still works in-memory for this invocation
74
- }
75
- }
76
-
77
55
  /**
78
56
  * Clear cached session from keychain.
79
57
  */
@@ -1,9 +1,13 @@
1
1
  import {
2
2
  ERRORS,
3
3
  HardwareErrorCode,
4
+ ONEKEY_NOTIFY_CHARACTERISTIC_UUID,
4
5
  ONEKEY_SERVICE_UUID,
5
- isOnekeyDevice,
6
- wait,
6
+ ONEKEY_WRITE_CHARACTERISTIC_UUID,
7
+ createKnownBleUuidAliases,
8
+ hasOnekeyCommunicationService,
9
+ isOnekeyBluetoothDevice,
10
+ matchesKnownBleUuid,
7
11
  } from '@onekeyfe/hd-shared';
8
12
 
9
13
  import type { LowLevelDevice, LowlevelTransportSharedPlugin } from '@onekeyfe/hd-transport';
@@ -28,21 +32,28 @@ type CharacteristicPair = {
28
32
  notify: Characteristic;
29
33
  };
30
34
 
35
+ type NoblePendingReceiver = {
36
+ resolve: (data: string) => void;
37
+ reject: (error: Error) => void;
38
+ };
39
+
40
+ type NobleNotificationState = {
41
+ generation: number;
42
+ queue: string[];
43
+ pendingReceivers: Set<NoblePendingReceiver>;
44
+ };
45
+
31
46
  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
- ]);
47
+ const ONEKEY_SERVICE_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_SERVICE_UUID);
48
+ const ONEKEY_WRITE_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_WRITE_CHARACTERISTIC_UUID);
49
+ const ONEKEY_NOTIFY_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_NOTIFY_CHARACTERISTIC_UUID);
39
50
 
40
51
  const BLUETOOTH_INIT_TIMEOUT = 10_000;
41
52
  const DEVICE_SCAN_TIMEOUT = 8_000;
42
53
  const CONNECTION_TIMEOUT = 8_000;
43
54
  const SERVICE_DISCOVERY_TIMEOUT = 10_000;
55
+ const BLE_CLEANUP_TIMEOUT = 100;
44
56
  const BLE_PACKET_SIZE = 192;
45
- const BLE_WRITE_DELAY = 5;
46
57
  const BLE_ENCRYPTION_ERROR_PATTERNS = [/encryption is insufficient/i, /insufficient encryption/i];
47
58
 
48
59
  let noble: NobleModule | null = null;
@@ -50,42 +61,80 @@ let nobleReadyPromise: Promise<void> | null = null;
50
61
  const discoveredDevices = new Map<string, Peripheral>();
51
62
  const connectedDevices = new Map<string, Peripheral>();
52
63
  const deviceCharacteristics = new Map<string, CharacteristicPair>();
53
- const notificationQueue: string[] = [];
54
- const pendingReceivers: Array<(data: string) => void> = [];
64
+ const notificationStates = new Map<string, NobleNotificationState>();
65
+ const notificationGenerations = new Map<string, number>();
55
66
 
56
- function getBleUuidKey(uuid?: string | null) {
57
- const normalized = (uuid ?? '').replace(/-/g, '').toLowerCase();
58
- return normalized.length >= 8 ? normalized.substring(4, 8) : normalized;
67
+ function isOneKeyPeripheral(peripheral: Peripheral) {
68
+ const serviceUuids = peripheral.advertisement?.serviceUuids;
69
+ return (
70
+ hasOnekeyCommunicationService(serviceUuids) &&
71
+ isOnekeyBluetoothDevice({
72
+ id: peripheral.id,
73
+ localName: peripheral.advertisement?.localName,
74
+ serviceUuids,
75
+ })
76
+ );
59
77
  }
60
78
 
61
- function isGenericBleService(uuid?: string | null) {
62
- return ['1800', '1801', '180a', '180f'].includes(getBleUuidKey(uuid));
79
+ function enqueueNotification(deviceId: string, generation: number, data: Buffer) {
80
+ const state = notificationStates.get(deviceId);
81
+ if (!state || state.generation !== generation) return;
82
+
83
+ const hex = data.toString('hex');
84
+ const [receiver] = state.pendingReceivers;
85
+ if (receiver) {
86
+ state.pendingReceivers.delete(receiver);
87
+ receiver.resolve(hex);
88
+ return;
89
+ }
90
+ state.queue.push(hex);
63
91
  }
64
92
 
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
- });
93
+ function createNotificationState(deviceId: string) {
94
+ const existing = notificationStates.get(deviceId);
95
+ if (existing) {
96
+ const error = new Error(`BLE notification state replaced for ${deviceId}`);
97
+ existing.pendingReceivers.forEach(receiver => receiver.reject(error));
98
+ }
99
+
100
+ const generation = (notificationGenerations.get(deviceId) ?? 0) + 1;
101
+ notificationGenerations.set(deviceId, generation);
102
+ const state: NobleNotificationState = {
103
+ generation,
104
+ queue: [],
105
+ pendingReceivers: new Set(),
106
+ };
107
+ notificationStates.set(deviceId, state);
108
+ return state;
74
109
  }
75
110
 
76
- function isOneKeyPeripheral(peripheral: Peripheral) {
77
- const deviceName = peripheral.advertisement?.localName || null;
78
- return isOnekeyDevice(deviceName, peripheral.id) || hasOneKeyAdvertisementService(peripheral);
111
+ function clearNotificationState(deviceId: string, reason: string) {
112
+ const state = notificationStates.get(deviceId);
113
+ if (!state) return;
114
+
115
+ notificationStates.delete(deviceId);
116
+ const error = new Error(reason);
117
+ state.pendingReceivers.forEach(receiver => receiver.reject(error));
118
+ state.pendingReceivers.clear();
119
+ state.queue.length = 0;
79
120
  }
80
121
 
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);
122
+ function waitForNobleCleanup(registerCallback: (callback: () => void) => void) {
123
+ return new Promise<void>(resolve => {
124
+ let completed = false;
125
+ const complete = () => {
126
+ if (completed) return;
127
+ completed = true;
128
+ clearTimeout(timeout);
129
+ resolve();
130
+ };
131
+ const timeout = setTimeout(complete, BLE_CLEANUP_TIMEOUT);
132
+ try {
133
+ registerCallback(complete);
134
+ } catch {
135
+ complete();
136
+ }
137
+ });
89
138
  }
90
139
 
91
140
  async function initializeNoble() {
@@ -169,7 +218,7 @@ async function scanDevices(targetDeviceId?: string) {
169
218
 
170
219
  const onDiscover = (peripheral: Peripheral) => {
171
220
  if (targetDeviceId && peripheral.id !== targetDeviceId) return;
172
- if (!targetDeviceId && !isOneKeyPeripheral(peripheral)) return;
221
+ if (!isOneKeyPeripheral(peripheral)) return;
173
222
 
174
223
  discoveredDevices.set(peripheral.id, peripheral);
175
224
  found.set(peripheral.id, peripheral);
@@ -224,13 +273,7 @@ async function discoverCharacteristics(peripheral: Peripheral): Promise<Characte
224
273
  });
225
274
  });
226
275
 
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
- }
276
+ const service = services.find(s => matchesKnownBleUuid(s.uuid, ONEKEY_SERVICE_UUID_ALIASES));
234
277
  if (!service) {
235
278
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No BLE service found');
236
279
  }
@@ -249,10 +292,9 @@ async function discoverCharacteristics(peripheral: Peripheral): Promise<Characte
249
292
  let writeCharacteristic: Characteristic | undefined;
250
293
  let notifyCharacteristic: Characteristic | undefined;
251
294
  for (const characteristic of characteristics) {
252
- const uuidKey = getBleUuidKey(characteristic.uuid);
253
- if (uuidKey === NORMALIZED_WRITE_UUID) {
295
+ if (matchesKnownBleUuid(characteristic.uuid, ONEKEY_WRITE_UUID_ALIASES)) {
254
296
  writeCharacteristic = characteristic;
255
- } else if (uuidKey === NORMALIZED_NOTIFY_UUID) {
297
+ } else if (matchesKnownBleUuid(characteristic.uuid, ONEKEY_NOTIFY_UUID_ALIASES)) {
256
298
  notifyCharacteristic = characteristic;
257
299
  }
258
300
  }
@@ -270,10 +312,12 @@ async function discoverCharacteristics(peripheral: Peripheral): Promise<Characte
270
312
  };
271
313
  }
272
314
 
273
- function subscribeNotifications(deviceId: string, notifyCharacteristic: Characteristic) {
274
- return new Promise<void>(resolve => {
275
- notifyCharacteristic.unsubscribe(() => resolve());
276
- })
315
+ function subscribeNotifications(
316
+ deviceId: string,
317
+ generation: number,
318
+ notifyCharacteristic: Characteristic
319
+ ) {
320
+ return waitForNobleCleanup(callback => notifyCharacteristic.unsubscribe(callback))
277
321
  .then(
278
322
  () =>
279
323
  new Promise<void>((resolve, reject) => {
@@ -303,7 +347,7 @@ function subscribeNotifications(deviceId: string, notifyCharacteristic: Characte
303
347
  )
304
348
  .then(() => {
305
349
  notifyCharacteristic.removeAllListeners('data');
306
- notifyCharacteristic.on('data', enqueueNotification);
350
+ notifyCharacteristic.on('data', data => enqueueNotification(deviceId, generation, data));
307
351
  })
308
352
  .catch(error => {
309
353
  notifyCharacteristic.removeAllListeners('data');
@@ -314,9 +358,13 @@ function subscribeNotifications(deviceId: string, notifyCharacteristic: Characte
314
358
  });
315
359
  }
316
360
 
317
- function writeCharacteristic(characteristic: Characteristic, buffer: Buffer) {
361
+ function writeCharacteristic(
362
+ characteristic: Characteristic,
363
+ buffer: Buffer,
364
+ withoutResponse: boolean
365
+ ) {
318
366
  return new Promise<void>((resolve, reject) => {
319
- characteristic.write(buffer, true, (error?: Error) => {
367
+ characteristic.write(buffer, withoutResponse, (error?: Error) => {
320
368
  if (error) {
321
369
  reject(error);
322
370
  return;
@@ -329,23 +377,18 @@ function writeCharacteristic(characteristic: Characteristic, buffer: Buffer) {
329
377
  async function disconnectDevice(uuid: string) {
330
378
  const peripheral = connectedDevices.get(uuid);
331
379
  const characteristics = deviceCharacteristics.get(uuid);
380
+ clearNotificationState(uuid, `BLE device disconnected: ${uuid}`);
332
381
  if (characteristics) {
333
382
  characteristics.notify.removeAllListeners('data');
334
- await new Promise<void>(resolve => {
335
- characteristics.notify.unsubscribe(() => resolve());
336
- });
383
+ await waitForNobleCleanup(callback => characteristics.notify.unsubscribe(callback));
337
384
  }
338
385
 
339
386
  connectedDevices.delete(uuid);
340
387
  deviceCharacteristics.delete(uuid);
341
- notificationQueue.length = 0;
342
- pendingReceivers.splice(0).forEach(resolve => resolve(''));
343
388
 
344
389
  if (!peripheral || peripheral.state === 'disconnected') return;
345
390
 
346
- await new Promise<void>(resolve => {
347
- peripheral.disconnect(() => resolve());
348
- });
391
+ await waitForNobleCleanup(callback => peripheral.disconnect(callback));
349
392
  }
350
393
 
351
394
  export function createNobleBlePlugin(): LowlevelTransportSharedPlugin {
@@ -375,17 +418,31 @@ export function createNobleBlePlugin(): LowlevelTransportSharedPlugin {
375
418
  }
376
419
 
377
420
  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);
421
+ let characteristics: CharacteristicPair | undefined;
422
+ try {
423
+ characteristics = await discoverCharacteristics(peripheral);
424
+ const notificationState = createNotificationState(uuid);
425
+ await subscribeNotifications(uuid, notificationState.generation, characteristics.notify);
426
+ connectedDevices.set(uuid, peripheral);
427
+ deviceCharacteristics.set(uuid, characteristics);
428
+ } catch (error) {
429
+ clearNotificationState(uuid, `BLE notification subscription failed: ${uuid}`);
430
+ if (characteristics) {
431
+ characteristics.notify.removeAllListeners('data');
432
+ await waitForNobleCleanup(callback => characteristics?.notify.unsubscribe(callback));
433
+ }
434
+ if (peripheral.state !== 'disconnected') {
435
+ await waitForNobleCleanup(callback => peripheral?.disconnect(callback));
436
+ }
437
+ throw error;
438
+ }
382
439
  },
383
440
 
384
441
  async disconnect(uuid: string) {
385
442
  await disconnectDevice(uuid);
386
443
  },
387
444
 
388
- async send(uuid: string, data: string) {
445
+ async send(uuid: string, data: string, options?: { withoutResponse?: boolean }) {
389
446
  const characteristics = deviceCharacteristics.get(uuid);
390
447
  if (!characteristics) {
391
448
  throw ERRORS.TypedError(
@@ -395,20 +452,35 @@ export function createNobleBlePlugin(): LowlevelTransportSharedPlugin {
395
452
  }
396
453
 
397
454
  const buffer = Buffer.from(data, 'hex');
455
+ const withoutResponse = options?.withoutResponse ?? true;
398
456
  for (let offset = 0; offset < buffer.length; offset += BLE_PACKET_SIZE) {
399
457
  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
- }
458
+ await writeCharacteristic(characteristics.write, chunk, withoutResponse);
404
459
  }
405
460
  },
406
461
 
407
- async receive() {
408
- const queued = notificationQueue.shift();
462
+ async receive(uuid?: string) {
463
+ const resolvedUuid =
464
+ uuid ??
465
+ (notificationStates.size === 1 ? notificationStates.keys().next().value : undefined);
466
+ if (!resolvedUuid) {
467
+ throw ERRORS.TypedError(
468
+ HardwareErrorCode.RuntimeError,
469
+ 'BLE receive requires a device UUID when multiple devices are connected'
470
+ );
471
+ }
472
+
473
+ const state = notificationStates.get(resolvedUuid);
474
+ if (!state) {
475
+ throw ERRORS.TypedError(
476
+ HardwareErrorCode.TransportNotFound,
477
+ `BLE notification state not found: ${resolvedUuid}`
478
+ );
479
+ }
480
+ const queued = state.queue.shift();
409
481
  if (queued !== undefined) return queued;
410
- return new Promise<string>(resolve => {
411
- pendingReceivers.push(resolve);
482
+ return new Promise<string>((resolve, reject) => {
483
+ state.pendingReceivers.add({ resolve, reject });
412
484
  });
413
485
  },
414
486
  };