@onekeyfe/hardware-cli 1.2.0-alpha.8 → 1.2.0-alpha.9

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/cli.d.ts CHANGED
@@ -1 +1,3 @@
1
- export {};
1
+ import { Command } from 'commander';
2
+ declare const program: Command;
3
+ export { program };
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.program = void 0;
4
5
  const node_fs_1 = require("node:fs");
5
6
  const commander_1 = require("commander");
6
7
  const hd_core_1 = require("@onekeyfe/hd-core");
@@ -33,6 +34,7 @@ function extractPassphraseSession(payload) {
33
34
  return { passphraseState, sessionId };
34
35
  }
35
36
  const program = new commander_1.Command();
37
+ exports.program = program;
36
38
  program
37
39
  .name('onekey-hw')
38
40
  .description('OneKey hardware wallet CLI for AI agent integration')
@@ -418,15 +420,15 @@ program
418
420
  .action(() => respondAndExit({
419
421
  success: false,
420
422
  payload: {
421
- error: 'Use `onekey-hw --transport ble firmware-update-v4-debug` for BLE Protocol V2 firmware update debugging.',
422
- code: 'USE_FIRMWARE_UPDATE_V4_DEBUG',
423
+ error: 'Use `onekey-hw --transport ble firmware-update-v4` for BLE Protocol V2 firmware updates.',
424
+ code: 'USE_FIRMWARE_UPDATE_V4',
423
425
  },
424
426
  }));
425
427
  program
426
- .command('firmware-update-v4-debug')
427
- .description('Debug Protocol V2 firmware update through sdk.firmwareUpdateV4')
428
+ .command('firmware-update-v4')
429
+ .description('Run Protocol V2 firmware update through sdk.firmwareUpdateV4')
428
430
  .option('--chunk-size <bytes>', 'Transfer chunk size in bytes')
429
- .option('--resource <path>', 'FW_MGMT_TARGET_CRATE resource package path')
431
+ .option('--resource-bundle <spec...>', 'RESC bundle direct-write spec: <localPath>:<devicePath>, e.g. wallpaper.okpkg:vol0:/bundles/images/wallpaper.okpkg')
430
432
  .option('--romloader <path>', 'FW_MGMT_TARGET_ROMLOADER binary path')
431
433
  .option('--bootloader <path>', 'FW_MGMT_TARGET_BOOTLOADER binary path')
432
434
  .option('--application-p1 <path>', 'FW_MGMT_TARGET_APPLICATION_P1 binary path')
@@ -439,8 +441,8 @@ program
439
441
  .option('--forced-update-res', 'Force resource update')
440
442
  .option('--retries <count>', 'Retry count for transient Protocol V2 USB probe failures')
441
443
  .action(opts => runCommand({}, async ({ sdk, globalOpts }) => {
442
- const params = buildFirmwareUpdateV4DebugParams(opts);
443
- const result = await runFirmwareUpdateV4DebugWithRetry({
444
+ const params = buildFirmwareUpdateV4Params(opts);
445
+ const result = await runFirmwareUpdateV4WithRetry({
444
446
  sdk,
445
447
  globalOpts,
446
448
  params,
@@ -902,9 +904,24 @@ function readBinaryParam(path) {
902
904
  const buffer = (0, node_fs_1.readFileSync)(path);
903
905
  return new Uint8Array(buffer).buffer;
904
906
  }
905
- function getFirmwareUpdateV4DebugTotalBytes(params) {
907
+ function parseResourceBundleParam(spec) {
908
+ const sep = spec.indexOf(':');
909
+ if (sep <= 0 || sep === spec.length - 1) {
910
+ throw new Error(`Invalid --resource-bundle value: "${spec}". Expected <localPath>:<devicePath>`);
911
+ }
912
+ const localPath = spec.slice(0, sep);
913
+ const devicePath = spec.slice(sep + 1);
914
+ if (!devicePath.startsWith('vol')) {
915
+ throw new Error(`Invalid --resource-bundle device path: "${devicePath}". Expected a vol*:/... path`);
916
+ }
917
+ return {
918
+ binary: readBinaryParam(localPath),
919
+ devicePath,
920
+ };
921
+ }
922
+ function getFirmwareUpdateV4TotalBytes(params) {
906
923
  return [
907
- ...(params.resourceBinaries ?? []),
924
+ ...(params.resourceBundleFiles?.map(item => item.binary) ?? []),
908
925
  params.bootloaderBinary,
909
926
  params.applicationP1Binary,
910
927
  params.applicationP2Binary,
@@ -915,17 +932,17 @@ function getFirmwareUpdateV4DebugTotalBytes(params) {
915
932
  params.se04Binary,
916
933
  ].reduce((total, binary) => total + (binary?.byteLength ?? 0), 0);
917
934
  }
918
- function getFirmwareUpdateV4DebugErrorText(result) {
935
+ function getFirmwareUpdateV4ErrorText(result) {
919
936
  if (!result || typeof result !== 'object')
920
937
  return '';
921
- const payload = result.payload;
938
+ const { payload } = result;
922
939
  if (!payload || typeof payload !== 'object')
923
940
  return '';
924
- const error = payload.error;
941
+ const { error } = payload;
925
942
  return typeof error === 'string' ? error : '';
926
943
  }
927
944
  function isProtocolV2UsbProbeTransientResult(result) {
928
- const error = getFirmwareUpdateV4DebugErrorText(result);
945
+ const error = getFirmwareUpdateV4ErrorText(result);
929
946
  return (error.includes('Device protocol mismatch') &&
930
947
  error.includes('expected V2') &&
931
948
  error.includes('did not respond to expected protocol'));
@@ -933,22 +950,22 @@ function isProtocolV2UsbProbeTransientResult(result) {
933
950
  function isSuccessResult(result) {
934
951
  return (!!result && typeof result === 'object' && result.success === true);
935
952
  }
936
- function getFirmwareDebugPayload(message) {
953
+ function getFirmwareUpdatePayload(message) {
937
954
  if (!message || typeof message !== 'object')
938
955
  return undefined;
939
956
  return message.payload;
940
957
  }
941
- function formatFirmwareDebugProgress(progress) {
958
+ function formatFirmwareProgress(progress) {
942
959
  if (!Number.isFinite(progress))
943
960
  return '0%';
944
961
  return `${Math.min(Math.max(Math.round(progress), 0), 100)}%`;
945
962
  }
946
- function formatFirmwareDebugBytes(bytes) {
963
+ function formatFirmwareBytes(bytes) {
947
964
  if (!Number.isFinite(bytes) || bytes <= 0)
948
965
  return '';
949
966
  return `${(bytes / 1024).toFixed(1)} KiB`;
950
967
  }
951
- function maybePrintFirmwareDebugProgress({ progressType, progress, payload, lastPrintedProgress, }) {
968
+ function maybePrintFirmwareProgress({ progressType, progress, payload, lastPrintedProgress, }) {
952
969
  const printableProgress = Math.floor(progress / 10) * 10;
953
970
  if (printableProgress <= lastPrintedProgress && progress < 100) {
954
971
  return lastPrintedProgress;
@@ -957,15 +974,15 @@ function maybePrintFirmwareDebugProgress({ progressType, progress, payload, last
957
974
  const totalBytes = Number(payload.totalBytes);
958
975
  const rateBytesPerSecond = Number(payload.rateBytesPerSecond);
959
976
  const sizeText = Number.isFinite(transferredBytes) && Number.isFinite(totalBytes) && totalBytes > 0
960
- ? ` ${formatFirmwareDebugBytes(transferredBytes)}/${formatFirmwareDebugBytes(totalBytes)}`
977
+ ? ` ${formatFirmwareBytes(transferredBytes)}/${formatFirmwareBytes(totalBytes)}`
961
978
  : '';
962
979
  const speedText = Number.isFinite(rateBytesPerSecond) && rateBytesPerSecond > 0
963
980
  ? ` ${(rateBytesPerSecond / 1024).toFixed(2)} KiB/s`
964
981
  : '';
965
- process.stderr.write(`[onekey-hw] Firmware ${progressType}: ${formatFirmwareDebugProgress(progress)}${sizeText}${speedText}\n`);
982
+ process.stderr.write(`[onekey-hw] Firmware ${progressType}: ${formatFirmwareProgress(progress)}${sizeText}${speedText}\n`);
966
983
  return progress >= 100 ? 100 : printableProgress;
967
984
  }
968
- function buildFirmwareUpdateV4DebugMetrics({ attempt, maxAttempts, totalBytes, totalStartedAt, transferStartedAt, transferEndedAt, installStartedAt, installEndedAt, progressEvents, lastProgress, installProgressEvents, lastInstallProgress, retried, }) {
985
+ function buildFirmwareUpdateV4Metrics({ attempt, maxAttempts, totalBytes, totalStartedAt, transferStartedAt, transferEndedAt, installStartedAt, installEndedAt, progressEvents, lastProgress, installProgressEvents, lastInstallProgress, retried, }) {
969
986
  const totalElapsedMs = Date.now() - totalStartedAt;
970
987
  const transferElapsedMs = transferStartedAt !== undefined && transferEndedAt !== undefined
971
988
  ? transferEndedAt - transferStartedAt
@@ -990,8 +1007,8 @@ function buildFirmwareUpdateV4DebugMetrics({ attempt, maxAttempts, totalBytes, t
990
1007
  totalSeconds: Number((totalElapsedMs / 1000).toFixed(2)),
991
1008
  };
992
1009
  }
993
- async function runFirmwareUpdateV4DebugWithRetry({ sdk, globalOpts, params, retries, }) {
994
- const totalBytes = getFirmwareUpdateV4DebugTotalBytes(params);
1010
+ async function runFirmwareUpdateV4WithRetry({ sdk, globalOpts, params, retries, }) {
1011
+ const totalBytes = getFirmwareUpdateV4TotalBytes(params);
995
1012
  const maxAttempts = Math.max((retries ?? 2) + 1, 1);
996
1013
  let currentSdk = sdk;
997
1014
  let lastResult;
@@ -1015,7 +1032,7 @@ async function runFirmwareUpdateV4DebugWithRetry({ sdk, globalOpts, params, retr
1015
1032
  if (!message || typeof message !== 'object')
1016
1033
  return;
1017
1034
  const messageType = message.type;
1018
- const payload = getFirmwareDebugPayload(message);
1035
+ const payload = getFirmwareUpdatePayload(message);
1019
1036
  if (messageType === hd_core_1.UI_REQUEST.FIRMWARE_TIP) {
1020
1037
  const tipMessage = payload?.data?.message;
1021
1038
  if (typeof tipMessage === 'string') {
@@ -1037,7 +1054,7 @@ async function runFirmwareUpdateV4DebugWithRetry({ sdk, globalOpts, params, retr
1037
1054
  progressEvents += 1;
1038
1055
  lastProgress = Math.max(lastProgress, progress);
1039
1056
  transferStartedAt ?? (transferStartedAt = Date.now());
1040
- lastPrintedTransferProgress = maybePrintFirmwareDebugProgress({
1057
+ lastPrintedTransferProgress = maybePrintFirmwareProgress({
1041
1058
  progressType: 'transfer',
1042
1059
  progress,
1043
1060
  payload,
@@ -1052,7 +1069,7 @@ async function runFirmwareUpdateV4DebugWithRetry({ sdk, globalOpts, params, retr
1052
1069
  installProgressEvents += 1;
1053
1070
  lastInstallProgress = Math.max(lastInstallProgress, progress);
1054
1071
  installStartedAt ?? (installStartedAt = Date.now());
1055
- lastPrintedInstallProgress = maybePrintFirmwareDebugProgress({
1072
+ lastPrintedInstallProgress = maybePrintFirmwareProgress({
1056
1073
  progressType: 'install',
1057
1074
  progress,
1058
1075
  payload,
@@ -1073,7 +1090,7 @@ async function runFirmwareUpdateV4DebugWithRetry({ sdk, globalOpts, params, retr
1073
1090
  if (installStartedAt !== undefined && installEndedAt === undefined) {
1074
1091
  installEndedAt = Date.now();
1075
1092
  }
1076
- const debugMetrics = buildFirmwareUpdateV4DebugMetrics({
1093
+ const metrics = buildFirmwareUpdateV4Metrics({
1077
1094
  attempt,
1078
1095
  maxAttempts,
1079
1096
  totalBytes,
@@ -1094,7 +1111,7 @@ async function runFirmwareUpdateV4DebugWithRetry({ sdk, globalOpts, params, retr
1094
1111
  ...lastResult,
1095
1112
  payload: {
1096
1113
  ...payload,
1097
- _debug: debugMetrics,
1114
+ metrics,
1098
1115
  },
1099
1116
  };
1100
1117
  }
@@ -1109,18 +1126,20 @@ async function runFirmwareUpdateV4DebugWithRetry({ sdk, globalOpts, params, retr
1109
1126
  retried = true;
1110
1127
  process.stderr.write(`[onekey-hw] Protocol V2 USB probe was transient; retrying firmwareUpdateV4 (${attempt}/${maxAttempts})...\n`);
1111
1128
  await (0, sdk_1.disposeSDK)();
1112
- await new Promise(resolve => setTimeout(resolve, 3000));
1129
+ await new Promise(resolve => {
1130
+ setTimeout(resolve, 3000);
1131
+ });
1113
1132
  currentSdk = await (0, sdk_1.createSDK)(globalOpts);
1114
1133
  }
1115
1134
  return lastResult;
1116
1135
  }
1117
- function buildFirmwareUpdateV4DebugParams(opts) {
1136
+ function buildFirmwareUpdateV4Params(opts) {
1118
1137
  const params = {
1119
1138
  platform: 'desktop',
1120
1139
  connectProtocol: 'V2',
1121
1140
  chunkSize: opts.chunkSize ? safeParseInt(opts.chunkSize, '--chunk-size') : undefined,
1122
1141
  forcedUpdateRes: opts.forcedUpdateRes,
1123
- resourceBinaries: opts.resource ? [readBinaryParam(opts.resource)] : undefined,
1142
+ resourceBundleFiles: opts.resourceBundle?.map(parseResourceBundleParam),
1124
1143
  romloaderBinary: opts.romloader ? readBinaryParam(opts.romloader) : undefined,
1125
1144
  bootloaderBinary: opts.bootloader ? readBinaryParam(opts.bootloader) : undefined,
1126
1145
  applicationP1Binary: opts.applicationP1 ? readBinaryParam(opts.applicationP1) : undefined,
@@ -1132,7 +1151,7 @@ function buildFirmwareUpdateV4DebugParams(opts) {
1132
1151
  se04Binary: opts.se04 ? readBinaryParam(opts.se04) : undefined,
1133
1152
  };
1134
1153
  const hasPayload = [
1135
- params.resourceBinaries,
1154
+ params.resourceBundleFiles,
1136
1155
  params.romloaderBinary,
1137
1156
  params.bootloaderBinary,
1138
1157
  params.applicationP1Binary,
@@ -1144,7 +1163,7 @@ function buildFirmwareUpdateV4DebugParams(opts) {
1144
1163
  params.se04Binary,
1145
1164
  ].some(Boolean);
1146
1165
  if (!hasPayload) {
1147
- const err = new Error('firmware-update-v4-debug requires at least one binary path');
1166
+ const err = new Error('firmware-update-v4 requires at least one binary path');
1148
1167
  err.code = 'MISSING_FIRMWARE_BINARY';
1149
1168
  throw err;
1150
1169
  }
@@ -1160,4 +1179,6 @@ function safeParseInt(input, label) {
1160
1179
  }
1161
1180
  return num;
1162
1181
  }
1163
- program.parse();
1182
+ if (require.main === module) {
1183
+ program.parse();
1184
+ }
@@ -14,6 +14,7 @@ const BLUETOOTH_INIT_TIMEOUT = 10000;
14
14
  const DEVICE_SCAN_TIMEOUT = 8000;
15
15
  const CONNECTION_TIMEOUT = 8000;
16
16
  const SERVICE_DISCOVERY_TIMEOUT = 10000;
17
+ const BLE_CLEANUP_TIMEOUT = 100;
17
18
  const BLE_PACKET_SIZE = 192;
18
19
  const BLE_WRITE_DELAY = 5;
19
20
  const BLE_ENCRYPTION_ERROR_PATTERNS = [/encryption is insufficient/i, /insufficient encryption/i];
@@ -22,8 +23,8 @@ let nobleReadyPromise = null;
22
23
  const discoveredDevices = new Map();
23
24
  const connectedDevices = new Map();
24
25
  const deviceCharacteristics = new Map();
25
- const notificationQueue = [];
26
- const pendingReceivers = [];
26
+ const notificationStates = new Map();
27
+ const notificationGenerations = new Map();
27
28
  function getBleUuidKey(uuid) {
28
29
  const normalized = (uuid ?? '').replace(/-/g, '').toLowerCase();
29
30
  return normalized.length >= 8 ? normalized.substring(4, 8) : normalized;
@@ -43,14 +44,63 @@ function isOneKeyPeripheral(peripheral) {
43
44
  const deviceName = peripheral.advertisement?.localName || null;
44
45
  return (0, hd_shared_1.isOnekeyDevice)(deviceName, peripheral.id) || hasOneKeyAdvertisementService(peripheral);
45
46
  }
46
- function enqueueNotification(data) {
47
+ function enqueueNotification(deviceId, generation, data) {
48
+ const state = notificationStates.get(deviceId);
49
+ if (!state || state.generation !== generation)
50
+ return;
47
51
  const hex = data.toString('hex');
48
- const receiver = pendingReceivers.shift();
52
+ const [receiver] = state.pendingReceivers;
49
53
  if (receiver) {
50
- receiver(hex);
54
+ state.pendingReceivers.delete(receiver);
55
+ receiver.resolve(hex);
51
56
  return;
52
57
  }
53
- notificationQueue.push(hex);
58
+ state.queue.push(hex);
59
+ }
60
+ function createNotificationState(deviceId) {
61
+ const existing = notificationStates.get(deviceId);
62
+ if (existing) {
63
+ const error = new Error(`BLE notification state replaced for ${deviceId}`);
64
+ existing.pendingReceivers.forEach(receiver => receiver.reject(error));
65
+ }
66
+ const generation = (notificationGenerations.get(deviceId) ?? 0) + 1;
67
+ notificationGenerations.set(deviceId, generation);
68
+ const state = {
69
+ generation,
70
+ queue: [],
71
+ pendingReceivers: new Set(),
72
+ };
73
+ notificationStates.set(deviceId, state);
74
+ return state;
75
+ }
76
+ function clearNotificationState(deviceId, reason) {
77
+ const state = notificationStates.get(deviceId);
78
+ if (!state)
79
+ return;
80
+ notificationStates.delete(deviceId);
81
+ const error = new Error(reason);
82
+ state.pendingReceivers.forEach(receiver => receiver.reject(error));
83
+ state.pendingReceivers.clear();
84
+ state.queue.length = 0;
85
+ }
86
+ function waitForNobleCleanup(registerCallback) {
87
+ return new Promise(resolve => {
88
+ let completed = false;
89
+ const complete = () => {
90
+ if (completed)
91
+ return;
92
+ completed = true;
93
+ clearTimeout(timeout);
94
+ resolve();
95
+ };
96
+ const timeout = setTimeout(complete, BLE_CLEANUP_TIMEOUT);
97
+ try {
98
+ registerCallback(complete);
99
+ }
100
+ catch {
101
+ complete();
102
+ }
103
+ });
54
104
  }
55
105
  async function initializeNoble() {
56
106
  if (!noble) {
@@ -210,10 +260,8 @@ async function discoverCharacteristics(peripheral) {
210
260
  notify: notifyCharacteristic,
211
261
  };
212
262
  }
213
- function subscribeNotifications(deviceId, notifyCharacteristic) {
214
- return new Promise(resolve => {
215
- notifyCharacteristic.unsubscribe(() => resolve());
216
- })
263
+ function subscribeNotifications(deviceId, generation, notifyCharacteristic) {
264
+ return waitForNobleCleanup(callback => notifyCharacteristic.unsubscribe(callback))
217
265
  .then(() => new Promise((resolve, reject) => {
218
266
  notifyCharacteristic.subscribe((error) => {
219
267
  if (error) {
@@ -230,7 +278,7 @@ function subscribeNotifications(deviceId, notifyCharacteristic) {
230
278
  }))
231
279
  .then(() => {
232
280
  notifyCharacteristic.removeAllListeners('data');
233
- notifyCharacteristic.on('data', enqueueNotification);
281
+ notifyCharacteristic.on('data', data => enqueueNotification(deviceId, generation, data));
234
282
  })
235
283
  .catch(error => {
236
284
  notifyCharacteristic.removeAllListeners('data');
@@ -254,21 +302,16 @@ function writeCharacteristic(characteristic, buffer) {
254
302
  async function disconnectDevice(uuid) {
255
303
  const peripheral = connectedDevices.get(uuid);
256
304
  const characteristics = deviceCharacteristics.get(uuid);
305
+ clearNotificationState(uuid, `BLE device disconnected: ${uuid}`);
257
306
  if (characteristics) {
258
307
  characteristics.notify.removeAllListeners('data');
259
- await new Promise(resolve => {
260
- characteristics.notify.unsubscribe(() => resolve());
261
- });
308
+ await waitForNobleCleanup(callback => characteristics.notify.unsubscribe(callback));
262
309
  }
263
310
  connectedDevices.delete(uuid);
264
311
  deviceCharacteristics.delete(uuid);
265
- notificationQueue.length = 0;
266
- pendingReceivers.splice(0).forEach(resolve => resolve(''));
267
312
  if (!peripheral || peripheral.state === 'disconnected')
268
313
  return;
269
- await new Promise(resolve => {
270
- peripheral.disconnect(() => resolve());
271
- });
314
+ await waitForNobleCleanup(callback => peripheral.disconnect(callback));
272
315
  }
273
316
  function createNobleBlePlugin() {
274
317
  return {
@@ -294,7 +337,14 @@ function createNobleBlePlugin() {
294
337
  }
295
338
  await connectPeripheral(peripheral);
296
339
  const characteristics = await discoverCharacteristics(peripheral);
297
- await subscribeNotifications(uuid, characteristics.notify);
340
+ const notificationState = createNotificationState(uuid);
341
+ try {
342
+ await subscribeNotifications(uuid, notificationState.generation, characteristics.notify);
343
+ }
344
+ catch (error) {
345
+ clearNotificationState(uuid, `BLE notification subscription failed: ${uuid}`);
346
+ throw error;
347
+ }
298
348
  connectedDevices.set(uuid, peripheral);
299
349
  deviceCharacteristics.set(uuid, characteristics);
300
350
  },
@@ -315,12 +365,21 @@ function createNobleBlePlugin() {
315
365
  }
316
366
  }
317
367
  },
318
- async receive() {
319
- const queued = notificationQueue.shift();
368
+ async receive(uuid) {
369
+ const resolvedUuid = uuid ??
370
+ (notificationStates.size === 1 ? notificationStates.keys().next().value : undefined);
371
+ if (!resolvedUuid) {
372
+ throw hd_shared_1.ERRORS.TypedError(hd_shared_1.HardwareErrorCode.RuntimeError, 'BLE receive requires a device UUID when multiple devices are connected');
373
+ }
374
+ const state = notificationStates.get(resolvedUuid);
375
+ if (!state) {
376
+ throw hd_shared_1.ERRORS.TypedError(hd_shared_1.HardwareErrorCode.TransportNotFound, `BLE notification state not found: ${resolvedUuid}`);
377
+ }
378
+ const queued = state.queue.shift();
320
379
  if (queued !== undefined)
321
380
  return queued;
322
- return new Promise(resolve => {
323
- pendingReceivers.push(resolve);
381
+ return new Promise((resolve, reject) => {
382
+ state.pendingReceivers.add({ resolve, reject });
324
383
  });
325
384
  },
326
385
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/hardware-cli",
3
- "version": "1.2.0-alpha.8",
3
+ "version": "1.2.0-alpha.9",
4
4
  "description": "OneKey hardware wallet CLI for testing device communication",
5
5
  "author": "OneKey",
6
6
  "license": "Apache-2.0",
@@ -30,12 +30,12 @@
30
30
  "test": "jest"
31
31
  },
32
32
  "dependencies": {
33
- "@onekeyfe/hd-common-connect-sdk": "1.2.0-alpha.8",
34
- "@onekeyfe/hd-core": "1.2.0-alpha.8",
35
- "@onekeyfe/hd-shared": "1.2.0-alpha.8",
36
- "@onekeyfe/hd-transport-usb": "1.2.0-alpha.8",
33
+ "@onekeyfe/hd-common-connect-sdk": "1.2.0-alpha.9",
34
+ "@onekeyfe/hd-core": "1.2.0-alpha.9",
35
+ "@onekeyfe/hd-shared": "1.2.0-alpha.9",
36
+ "@onekeyfe/hd-transport-usb": "1.2.0-alpha.9",
37
37
  "@stoprocent/noble": "2.3.16",
38
38
  "commander": "^12.0.0"
39
39
  },
40
- "gitHead": "d2b1323e5ad9c373e72d4d092f35c9f274c1fd74"
40
+ "gitHead": "f90ef9921b742f68cce9f4e7a0425e45e8ae7ec9"
41
41
  }
@@ -0,0 +1,19 @@
1
+ import { program } from '../cli';
2
+
3
+ describe('firmware-update-v4 CLI command', () => {
4
+ test('exposes firmware-update-v4 as the formal command', () => {
5
+ const command = program.commands.find(item => item.name() === 'firmware-update-v4');
6
+
7
+ expect(command).toBeDefined();
8
+ expect(command?.description()).toBe(
9
+ 'Run Protocol V2 firmware update through sdk.firmwareUpdateV4'
10
+ );
11
+ });
12
+
13
+ test('does not expose the pre-release firmware-update-v4-debug command', () => {
14
+ expect(program.commands.some(item => item.name() === 'firmware-update-v4-debug')).toBe(false);
15
+ expect(program.commands.some(item => item.aliases().includes('firmware-update-v4-debug'))).toBe(
16
+ false
17
+ );
18
+ });
19
+ });
@@ -0,0 +1,113 @@
1
+ import { EventEmitter } from 'events';
2
+
3
+ type MockCharacteristic = EventEmitter & {
4
+ uuid: string;
5
+ unsubscribe: jest.Mock;
6
+ subscribe: jest.Mock;
7
+ write: jest.Mock;
8
+ removeAllListeners: jest.Mock;
9
+ };
10
+
11
+ const createCharacteristic = (uuid: string): MockCharacteristic => {
12
+ const characteristic = new EventEmitter() as MockCharacteristic;
13
+ characteristic.uuid = uuid;
14
+ characteristic.unsubscribe = jest.fn(callback => callback());
15
+ characteristic.subscribe = jest.fn(callback => callback());
16
+ characteristic.write = jest.fn((_buffer, _withoutResponse, callback) => callback());
17
+ characteristic.removeAllListeners = jest.fn(
18
+ characteristic.removeAllListeners.bind(characteristic)
19
+ );
20
+ return characteristic;
21
+ };
22
+
23
+ const createPeripheral = (id: string) => {
24
+ const write = createCharacteristic('0002');
25
+ const notify = createCharacteristic('0003');
26
+ const service = {
27
+ uuid: '0001',
28
+ discoverCharacteristics: jest.fn((_uuids, callback) => callback(null, [write, notify])),
29
+ };
30
+ return {
31
+ peripheral: {
32
+ id,
33
+ state: 'connected',
34
+ advertisement: {
35
+ localName: `OneKey Pro 2 ${id}`,
36
+ serviceUuids: ['fffd'],
37
+ },
38
+ discoverServices: jest.fn((_uuids, callback) => callback(null, [service])),
39
+ connect: jest.fn(callback => callback()),
40
+ disconnect: jest.fn(callback => callback()),
41
+ },
42
+ notify,
43
+ };
44
+ };
45
+
46
+ describe('Noble BLE plugin notification routing', () => {
47
+ afterEach(() => {
48
+ jest.resetModules();
49
+ jest.clearAllMocks();
50
+ });
51
+
52
+ test('routes notifications to the receiver waiting for the same device', async () => {
53
+ const deviceA = createPeripheral('device-a');
54
+ const deviceB = createPeripheral('device-b');
55
+ const noble = new EventEmitter() as EventEmitter & {
56
+ state: string;
57
+ startScanning: jest.Mock;
58
+ stopScanning: jest.Mock;
59
+ };
60
+ noble.state = 'poweredOn';
61
+ noble.startScanning = jest.fn((_services, _duplicates, callback) => {
62
+ callback?.();
63
+ noble.emit('discover', deviceA.peripheral);
64
+ noble.emit('discover', deviceB.peripheral);
65
+ });
66
+ noble.stopScanning = jest.fn(callback => callback?.());
67
+ jest.doMock('@stoprocent/noble', () => noble);
68
+
69
+ const { createNobleBlePlugin } = await import('../transports/nobleBlePlugin');
70
+ const plugin = createNobleBlePlugin();
71
+ await plugin.init();
72
+ await plugin.connect('device-a');
73
+ await plugin.connect('device-b');
74
+
75
+ const receiveA = plugin.receive('device-a');
76
+ const receiveB = plugin.receive('device-b');
77
+ deviceB.notify.emit('data', Buffer.from('bb', 'hex'));
78
+ deviceA.notify.emit('data', Buffer.from('aa', 'hex'));
79
+
80
+ await expect(Promise.all([receiveA, receiveB])).resolves.toEqual(['aa', 'bb']);
81
+ });
82
+
83
+ test('finishes disconnect cleanup when Noble never calls unsubscribe back', async () => {
84
+ const device = createPeripheral('device-a');
85
+ const noble = new EventEmitter() as EventEmitter & {
86
+ state: string;
87
+ startScanning: jest.Mock;
88
+ stopScanning: jest.Mock;
89
+ };
90
+ noble.state = 'poweredOn';
91
+ noble.startScanning = jest.fn((_services, _duplicates, callback) => {
92
+ callback?.();
93
+ noble.emit('discover', device.peripheral);
94
+ });
95
+ noble.stopScanning = jest.fn(callback => callback?.());
96
+ jest.doMock('@stoprocent/noble', () => noble);
97
+
98
+ const { createNobleBlePlugin } = await import('../transports/nobleBlePlugin');
99
+ const plugin = createNobleBlePlugin();
100
+ await plugin.init();
101
+ await plugin.connect('device-a');
102
+ device.notify.unsubscribe.mockImplementation(() => undefined);
103
+
104
+ const result = await Promise.race([
105
+ plugin.disconnect('device-a').then(() => 'completed'),
106
+ new Promise(resolve => {
107
+ setTimeout(() => resolve('blocked'), 300);
108
+ }),
109
+ ]);
110
+
111
+ expect(result).toBe('completed');
112
+ });
113
+ });
package/src/cli.ts CHANGED
@@ -549,17 +549,20 @@ program
549
549
  success: false,
550
550
  payload: {
551
551
  error:
552
- 'Use `onekey-hw --transport ble firmware-update-v4-debug` for BLE Protocol V2 firmware update debugging.',
553
- code: 'USE_FIRMWARE_UPDATE_V4_DEBUG',
552
+ 'Use `onekey-hw --transport ble firmware-update-v4` for BLE Protocol V2 firmware updates.',
553
+ code: 'USE_FIRMWARE_UPDATE_V4',
554
554
  },
555
555
  })
556
556
  );
557
557
 
558
558
  program
559
- .command('firmware-update-v4-debug')
560
- .description('Debug Protocol V2 firmware update through sdk.firmwareUpdateV4')
559
+ .command('firmware-update-v4')
560
+ .description('Run Protocol V2 firmware update through sdk.firmwareUpdateV4')
561
561
  .option('--chunk-size <bytes>', 'Transfer chunk size in bytes')
562
- .option('--resource <path>', 'FW_MGMT_TARGET_CRATE resource package path')
562
+ .option(
563
+ '--resource-bundle <spec...>',
564
+ 'RESC bundle direct-write spec: <localPath>:<devicePath>, e.g. wallpaper.okpkg:vol0:/bundles/images/wallpaper.okpkg'
565
+ )
563
566
  .option('--romloader <path>', 'FW_MGMT_TARGET_ROMLOADER binary path')
564
567
  .option('--bootloader <path>', 'FW_MGMT_TARGET_BOOTLOADER binary path')
565
568
  .option('--application-p1 <path>', 'FW_MGMT_TARGET_APPLICATION_P1 binary path')
@@ -573,8 +576,8 @@ program
573
576
  .option('--retries <count>', 'Retry count for transient Protocol V2 USB probe failures')
574
577
  .action(opts =>
575
578
  runCommand({}, async ({ sdk, globalOpts }) => {
576
- const params = buildFirmwareUpdateV4DebugParams(opts);
577
- const result = await runFirmwareUpdateV4DebugWithRetry({
579
+ const params = buildFirmwareUpdateV4Params(opts);
580
+ const result = await runFirmwareUpdateV4WithRetry({
578
581
  sdk,
579
582
  globalOpts,
580
583
  params,
@@ -1159,11 +1162,29 @@ function readBinaryParam(path: string): ArrayBuffer {
1159
1162
  return new Uint8Array(buffer).buffer;
1160
1163
  }
1161
1164
 
1162
- function getFirmwareUpdateV4DebugTotalBytes(
1163
- params: ReturnType<typeof buildFirmwareUpdateV4DebugParams>
1164
- ) {
1165
+ function parseResourceBundleParam(spec: string): { binary: ArrayBuffer; devicePath: string } {
1166
+ const sep = spec.indexOf(':');
1167
+ if (sep <= 0 || sep === spec.length - 1) {
1168
+ throw new Error(
1169
+ `Invalid --resource-bundle value: "${spec}". Expected <localPath>:<devicePath>`
1170
+ );
1171
+ }
1172
+ const localPath = spec.slice(0, sep);
1173
+ const devicePath = spec.slice(sep + 1);
1174
+ if (!devicePath.startsWith('vol')) {
1175
+ throw new Error(
1176
+ `Invalid --resource-bundle device path: "${devicePath}". Expected a vol*:/... path`
1177
+ );
1178
+ }
1179
+ return {
1180
+ binary: readBinaryParam(localPath),
1181
+ devicePath,
1182
+ };
1183
+ }
1184
+
1185
+ function getFirmwareUpdateV4TotalBytes(params: ReturnType<typeof buildFirmwareUpdateV4Params>) {
1165
1186
  return [
1166
- ...(params.resourceBinaries ?? []),
1187
+ ...(params.resourceBundleFiles?.map(item => item.binary) ?? []),
1167
1188
  params.bootloaderBinary,
1168
1189
  params.applicationP1Binary,
1169
1190
  params.applicationP2Binary,
@@ -1175,16 +1196,16 @@ function getFirmwareUpdateV4DebugTotalBytes(
1175
1196
  ].reduce((total, binary) => total + (binary?.byteLength ?? 0), 0);
1176
1197
  }
1177
1198
 
1178
- function getFirmwareUpdateV4DebugErrorText(result: unknown) {
1199
+ function getFirmwareUpdateV4ErrorText(result: unknown) {
1179
1200
  if (!result || typeof result !== 'object') return '';
1180
- const payload = (result as { payload?: unknown }).payload;
1201
+ const { payload } = result as { payload?: unknown };
1181
1202
  if (!payload || typeof payload !== 'object') return '';
1182
- const error = (payload as { error?: unknown }).error;
1203
+ const { error } = payload as { error?: unknown };
1183
1204
  return typeof error === 'string' ? error : '';
1184
1205
  }
1185
1206
 
1186
1207
  function isProtocolV2UsbProbeTransientResult(result: unknown) {
1187
- const error = getFirmwareUpdateV4DebugErrorText(result);
1208
+ const error = getFirmwareUpdateV4ErrorText(result);
1188
1209
  return (
1189
1210
  error.includes('Device protocol mismatch') &&
1190
1211
  error.includes('expected V2') &&
@@ -1198,22 +1219,22 @@ function isSuccessResult(result: unknown) {
1198
1219
  );
1199
1220
  }
1200
1221
 
1201
- function getFirmwareDebugPayload(message: unknown) {
1222
+ function getFirmwareUpdatePayload(message: unknown) {
1202
1223
  if (!message || typeof message !== 'object') return undefined;
1203
1224
  return (message as { payload?: Record<string, unknown> }).payload;
1204
1225
  }
1205
1226
 
1206
- function formatFirmwareDebugProgress(progress: number) {
1227
+ function formatFirmwareProgress(progress: number) {
1207
1228
  if (!Number.isFinite(progress)) return '0%';
1208
1229
  return `${Math.min(Math.max(Math.round(progress), 0), 100)}%`;
1209
1230
  }
1210
1231
 
1211
- function formatFirmwareDebugBytes(bytes: number) {
1232
+ function formatFirmwareBytes(bytes: number) {
1212
1233
  if (!Number.isFinite(bytes) || bytes <= 0) return '';
1213
1234
  return `${(bytes / 1024).toFixed(1)} KiB`;
1214
1235
  }
1215
1236
 
1216
- function maybePrintFirmwareDebugProgress({
1237
+ function maybePrintFirmwareProgress({
1217
1238
  progressType,
1218
1239
  progress,
1219
1240
  payload,
@@ -1234,7 +1255,7 @@ function maybePrintFirmwareDebugProgress({
1234
1255
  const rateBytesPerSecond = Number(payload.rateBytesPerSecond);
1235
1256
  const sizeText =
1236
1257
  Number.isFinite(transferredBytes) && Number.isFinite(totalBytes) && totalBytes > 0
1237
- ? ` ${formatFirmwareDebugBytes(transferredBytes)}/${formatFirmwareDebugBytes(totalBytes)}`
1258
+ ? ` ${formatFirmwareBytes(transferredBytes)}/${formatFirmwareBytes(totalBytes)}`
1238
1259
  : '';
1239
1260
  const speedText =
1240
1261
  Number.isFinite(rateBytesPerSecond) && rateBytesPerSecond > 0
@@ -1242,14 +1263,14 @@ function maybePrintFirmwareDebugProgress({
1242
1263
  : '';
1243
1264
 
1244
1265
  process.stderr.write(
1245
- `[onekey-hw] Firmware ${progressType}: ${formatFirmwareDebugProgress(
1266
+ `[onekey-hw] Firmware ${progressType}: ${formatFirmwareProgress(
1246
1267
  progress
1247
1268
  )}${sizeText}${speedText}\n`
1248
1269
  );
1249
1270
  return progress >= 100 ? 100 : printableProgress;
1250
1271
  }
1251
1272
 
1252
- function buildFirmwareUpdateV4DebugMetrics({
1273
+ function buildFirmwareUpdateV4Metrics({
1253
1274
  attempt,
1254
1275
  maxAttempts,
1255
1276
  totalBytes,
@@ -1309,7 +1330,7 @@ function buildFirmwareUpdateV4DebugMetrics({
1309
1330
  };
1310
1331
  }
1311
1332
 
1312
- async function runFirmwareUpdateV4DebugWithRetry({
1333
+ async function runFirmwareUpdateV4WithRetry({
1313
1334
  sdk,
1314
1335
  globalOpts,
1315
1336
  params,
@@ -1317,10 +1338,10 @@ async function runFirmwareUpdateV4DebugWithRetry({
1317
1338
  }: {
1318
1339
  sdk: AnySdk;
1319
1340
  globalOpts: Record<string, any>;
1320
- params: ReturnType<typeof buildFirmwareUpdateV4DebugParams>;
1341
+ params: ReturnType<typeof buildFirmwareUpdateV4Params>;
1321
1342
  retries?: number;
1322
1343
  }) {
1323
- const totalBytes = getFirmwareUpdateV4DebugTotalBytes(params);
1344
+ const totalBytes = getFirmwareUpdateV4TotalBytes(params);
1324
1345
  const maxAttempts = Math.max((retries ?? 2) + 1, 1);
1325
1346
  let currentSdk = sdk;
1326
1347
  let lastResult: unknown;
@@ -1346,7 +1367,7 @@ async function runFirmwareUpdateV4DebugWithRetry({
1346
1367
  const onUiEvent = (message: unknown) => {
1347
1368
  if (!message || typeof message !== 'object') return;
1348
1369
  const messageType = (message as { type?: string }).type;
1349
- const payload = getFirmwareDebugPayload(message);
1370
+ const payload = getFirmwareUpdatePayload(message);
1350
1371
 
1351
1372
  if (messageType === UI_REQUEST.FIRMWARE_TIP) {
1352
1373
  const tipMessage = (payload?.data as { message?: unknown } | undefined)?.message;
@@ -1372,7 +1393,7 @@ async function runFirmwareUpdateV4DebugWithRetry({
1372
1393
  progressEvents += 1;
1373
1394
  lastProgress = Math.max(lastProgress, progress);
1374
1395
  transferStartedAt ??= Date.now();
1375
- lastPrintedTransferProgress = maybePrintFirmwareDebugProgress({
1396
+ lastPrintedTransferProgress = maybePrintFirmwareProgress({
1376
1397
  progressType: 'transfer',
1377
1398
  progress,
1378
1399
  payload,
@@ -1388,7 +1409,7 @@ async function runFirmwareUpdateV4DebugWithRetry({
1388
1409
  installProgressEvents += 1;
1389
1410
  lastInstallProgress = Math.max(lastInstallProgress, progress);
1390
1411
  installStartedAt ??= Date.now();
1391
- lastPrintedInstallProgress = maybePrintFirmwareDebugProgress({
1412
+ lastPrintedInstallProgress = maybePrintFirmwareProgress({
1392
1413
  progressType: 'install',
1393
1414
  progress,
1394
1415
  payload,
@@ -1410,7 +1431,7 @@ async function runFirmwareUpdateV4DebugWithRetry({
1410
1431
  installEndedAt = Date.now();
1411
1432
  }
1412
1433
 
1413
- const debugMetrics = buildFirmwareUpdateV4DebugMetrics({
1434
+ const metrics = buildFirmwareUpdateV4Metrics({
1414
1435
  attempt,
1415
1436
  maxAttempts,
1416
1437
  totalBytes,
@@ -1435,7 +1456,7 @@ async function runFirmwareUpdateV4DebugWithRetry({
1435
1456
  ...(lastResult as Record<string, unknown>),
1436
1457
  payload: {
1437
1458
  ...payload,
1438
- _debug: debugMetrics,
1459
+ metrics,
1439
1460
  },
1440
1461
  };
1441
1462
  }
@@ -1457,16 +1478,18 @@ async function runFirmwareUpdateV4DebugWithRetry({
1457
1478
  `[onekey-hw] Protocol V2 USB probe was transient; retrying firmwareUpdateV4 (${attempt}/${maxAttempts})...\n`
1458
1479
  );
1459
1480
  await disposeSDK();
1460
- await new Promise(resolve => setTimeout(resolve, 3000));
1481
+ await new Promise(resolve => {
1482
+ setTimeout(resolve, 3000);
1483
+ });
1461
1484
  currentSdk = await createSDK(globalOpts);
1462
1485
  }
1463
1486
 
1464
1487
  return lastResult;
1465
1488
  }
1466
1489
 
1467
- function buildFirmwareUpdateV4DebugParams(opts: {
1490
+ function buildFirmwareUpdateV4Params(opts: {
1468
1491
  chunkSize?: string;
1469
- resource?: string;
1492
+ resourceBundle?: string[];
1470
1493
  romloader?: string;
1471
1494
  bootloader?: string;
1472
1495
  applicationP1?: string;
@@ -1483,7 +1506,7 @@ function buildFirmwareUpdateV4DebugParams(opts: {
1483
1506
  connectProtocol: 'V2' as const,
1484
1507
  chunkSize: opts.chunkSize ? safeParseInt(opts.chunkSize, '--chunk-size') : undefined,
1485
1508
  forcedUpdateRes: opts.forcedUpdateRes,
1486
- resourceBinaries: opts.resource ? [readBinaryParam(opts.resource)] : undefined,
1509
+ resourceBundleFiles: opts.resourceBundle?.map(parseResourceBundleParam),
1487
1510
  romloaderBinary: opts.romloader ? readBinaryParam(opts.romloader) : undefined,
1488
1511
  bootloaderBinary: opts.bootloader ? readBinaryParam(opts.bootloader) : undefined,
1489
1512
  applicationP1Binary: opts.applicationP1 ? readBinaryParam(opts.applicationP1) : undefined,
@@ -1496,7 +1519,7 @@ function buildFirmwareUpdateV4DebugParams(opts: {
1496
1519
  };
1497
1520
 
1498
1521
  const hasPayload = [
1499
- params.resourceBinaries,
1522
+ params.resourceBundleFiles,
1500
1523
  params.romloaderBinary,
1501
1524
  params.bootloaderBinary,
1502
1525
  params.applicationP1Binary,
@@ -1509,7 +1532,7 @@ function buildFirmwareUpdateV4DebugParams(opts: {
1509
1532
  ].some(Boolean);
1510
1533
 
1511
1534
  if (!hasPayload) {
1512
- const err = new Error('firmware-update-v4-debug requires at least one binary path');
1535
+ const err = new Error('firmware-update-v4 requires at least one binary path');
1513
1536
  (err as Error & { code?: string }).code = 'MISSING_FIRMWARE_BINARY';
1514
1537
  throw err;
1515
1538
  }
@@ -1528,4 +1551,8 @@ function safeParseInt(input: string, label: string): number {
1528
1551
  return num;
1529
1552
  }
1530
1553
 
1531
- program.parse();
1554
+ export { program };
1555
+
1556
+ if (require.main === module) {
1557
+ program.parse();
1558
+ }
@@ -28,6 +28,17 @@ type CharacteristicPair = {
28
28
  notify: Characteristic;
29
29
  };
30
30
 
31
+ type NoblePendingReceiver = {
32
+ resolve: (data: string) => void;
33
+ reject: (error: Error) => void;
34
+ };
35
+
36
+ type NobleNotificationState = {
37
+ generation: number;
38
+ queue: string[];
39
+ pendingReceivers: Set<NoblePendingReceiver>;
40
+ };
41
+
31
42
  const ONEKEY_SERVICE_UUIDS = [ONEKEY_SERVICE_UUID];
32
43
  const PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS = new Set(['fffd']);
33
44
  const NORMALIZED_WRITE_UUID = '0002';
@@ -41,6 +52,7 @@ 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
57
  const BLE_WRITE_DELAY = 5;
46
58
  const BLE_ENCRYPTION_ERROR_PATTERNS = [/encryption is insufficient/i, /insufficient encryption/i];
@@ -50,8 +62,8 @@ let nobleReadyPromise: Promise<void> | null = null;
50
62
  const discoveredDevices = new Map<string, Peripheral>();
51
63
  const connectedDevices = new Map<string, Peripheral>();
52
64
  const deviceCharacteristics = new Map<string, CharacteristicPair>();
53
- const notificationQueue: string[] = [];
54
- const pendingReceivers: Array<(data: string) => void> = [];
65
+ const notificationStates = new Map<string, NobleNotificationState>();
66
+ const notificationGenerations = new Map<string, number>();
55
67
 
56
68
  function getBleUuidKey(uuid?: string | null) {
57
69
  const normalized = (uuid ?? '').replace(/-/g, '').toLowerCase();
@@ -78,14 +90,65 @@ function isOneKeyPeripheral(peripheral: Peripheral) {
78
90
  return isOnekeyDevice(deviceName, peripheral.id) || hasOneKeyAdvertisementService(peripheral);
79
91
  }
80
92
 
81
- function enqueueNotification(data: Buffer) {
93
+ function enqueueNotification(deviceId: string, generation: number, data: Buffer) {
94
+ const state = notificationStates.get(deviceId);
95
+ if (!state || state.generation !== generation) return;
96
+
82
97
  const hex = data.toString('hex');
83
- const receiver = pendingReceivers.shift();
98
+ const [receiver] = state.pendingReceivers;
84
99
  if (receiver) {
85
- receiver(hex);
100
+ state.pendingReceivers.delete(receiver);
101
+ receiver.resolve(hex);
86
102
  return;
87
103
  }
88
- notificationQueue.push(hex);
104
+ state.queue.push(hex);
105
+ }
106
+
107
+ function createNotificationState(deviceId: string) {
108
+ const existing = notificationStates.get(deviceId);
109
+ if (existing) {
110
+ const error = new Error(`BLE notification state replaced for ${deviceId}`);
111
+ existing.pendingReceivers.forEach(receiver => receiver.reject(error));
112
+ }
113
+
114
+ const generation = (notificationGenerations.get(deviceId) ?? 0) + 1;
115
+ notificationGenerations.set(deviceId, generation);
116
+ const state: NobleNotificationState = {
117
+ generation,
118
+ queue: [],
119
+ pendingReceivers: new Set(),
120
+ };
121
+ notificationStates.set(deviceId, state);
122
+ return state;
123
+ }
124
+
125
+ function clearNotificationState(deviceId: string, reason: string) {
126
+ const state = notificationStates.get(deviceId);
127
+ if (!state) return;
128
+
129
+ notificationStates.delete(deviceId);
130
+ const error = new Error(reason);
131
+ state.pendingReceivers.forEach(receiver => receiver.reject(error));
132
+ state.pendingReceivers.clear();
133
+ state.queue.length = 0;
134
+ }
135
+
136
+ function waitForNobleCleanup(registerCallback: (callback: () => void) => void) {
137
+ return new Promise<void>(resolve => {
138
+ let completed = false;
139
+ const complete = () => {
140
+ if (completed) return;
141
+ completed = true;
142
+ clearTimeout(timeout);
143
+ resolve();
144
+ };
145
+ const timeout = setTimeout(complete, BLE_CLEANUP_TIMEOUT);
146
+ try {
147
+ registerCallback(complete);
148
+ } catch {
149
+ complete();
150
+ }
151
+ });
89
152
  }
90
153
 
91
154
  async function initializeNoble() {
@@ -270,10 +333,12 @@ async function discoverCharacteristics(peripheral: Peripheral): Promise<Characte
270
333
  };
271
334
  }
272
335
 
273
- function subscribeNotifications(deviceId: string, notifyCharacteristic: Characteristic) {
274
- return new Promise<void>(resolve => {
275
- notifyCharacteristic.unsubscribe(() => resolve());
276
- })
336
+ function subscribeNotifications(
337
+ deviceId: string,
338
+ generation: number,
339
+ notifyCharacteristic: Characteristic
340
+ ) {
341
+ return waitForNobleCleanup(callback => notifyCharacteristic.unsubscribe(callback))
277
342
  .then(
278
343
  () =>
279
344
  new Promise<void>((resolve, reject) => {
@@ -303,7 +368,7 @@ function subscribeNotifications(deviceId: string, notifyCharacteristic: Characte
303
368
  )
304
369
  .then(() => {
305
370
  notifyCharacteristic.removeAllListeners('data');
306
- notifyCharacteristic.on('data', enqueueNotification);
371
+ notifyCharacteristic.on('data', data => enqueueNotification(deviceId, generation, data));
307
372
  })
308
373
  .catch(error => {
309
374
  notifyCharacteristic.removeAllListeners('data');
@@ -329,23 +394,18 @@ function writeCharacteristic(characteristic: Characteristic, buffer: Buffer) {
329
394
  async function disconnectDevice(uuid: string) {
330
395
  const peripheral = connectedDevices.get(uuid);
331
396
  const characteristics = deviceCharacteristics.get(uuid);
397
+ clearNotificationState(uuid, `BLE device disconnected: ${uuid}`);
332
398
  if (characteristics) {
333
399
  characteristics.notify.removeAllListeners('data');
334
- await new Promise<void>(resolve => {
335
- characteristics.notify.unsubscribe(() => resolve());
336
- });
400
+ await waitForNobleCleanup(callback => characteristics.notify.unsubscribe(callback));
337
401
  }
338
402
 
339
403
  connectedDevices.delete(uuid);
340
404
  deviceCharacteristics.delete(uuid);
341
- notificationQueue.length = 0;
342
- pendingReceivers.splice(0).forEach(resolve => resolve(''));
343
405
 
344
406
  if (!peripheral || peripheral.state === 'disconnected') return;
345
407
 
346
- await new Promise<void>(resolve => {
347
- peripheral.disconnect(() => resolve());
348
- });
408
+ await waitForNobleCleanup(callback => peripheral.disconnect(callback));
349
409
  }
350
410
 
351
411
  export function createNobleBlePlugin(): LowlevelTransportSharedPlugin {
@@ -376,7 +436,13 @@ export function createNobleBlePlugin(): LowlevelTransportSharedPlugin {
376
436
 
377
437
  await connectPeripheral(peripheral);
378
438
  const characteristics = await discoverCharacteristics(peripheral);
379
- await subscribeNotifications(uuid, characteristics.notify);
439
+ const notificationState = createNotificationState(uuid);
440
+ try {
441
+ await subscribeNotifications(uuid, notificationState.generation, characteristics.notify);
442
+ } catch (error) {
443
+ clearNotificationState(uuid, `BLE notification subscription failed: ${uuid}`);
444
+ throw error;
445
+ }
380
446
  connectedDevices.set(uuid, peripheral);
381
447
  deviceCharacteristics.set(uuid, characteristics);
382
448
  },
@@ -404,11 +470,28 @@ export function createNobleBlePlugin(): LowlevelTransportSharedPlugin {
404
470
  }
405
471
  },
406
472
 
407
- async receive() {
408
- const queued = notificationQueue.shift();
473
+ async receive(uuid?: string) {
474
+ const resolvedUuid =
475
+ uuid ??
476
+ (notificationStates.size === 1 ? notificationStates.keys().next().value : undefined);
477
+ if (!resolvedUuid) {
478
+ throw ERRORS.TypedError(
479
+ HardwareErrorCode.RuntimeError,
480
+ 'BLE receive requires a device UUID when multiple devices are connected'
481
+ );
482
+ }
483
+
484
+ const state = notificationStates.get(resolvedUuid);
485
+ if (!state) {
486
+ throw ERRORS.TypedError(
487
+ HardwareErrorCode.TransportNotFound,
488
+ `BLE notification state not found: ${resolvedUuid}`
489
+ );
490
+ }
491
+ const queued = state.queue.shift();
409
492
  if (queued !== undefined) return queued;
410
- return new Promise<string>(resolve => {
411
- pendingReceivers.push(resolve);
493
+ return new Promise<string>((resolve, reject) => {
494
+ state.pendingReceivers.add({ resolve, reject });
412
495
  });
413
496
  },
414
497
  };