@onekeyfe/hardware-cli 1.2.0-alpha.1 → 1.2.0-alpha.100
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 +83 -1
- package/dist/cli.js +501 -142
- package/dist/deviceSelection.d.ts +3 -0
- package/dist/deviceSelection.js +11 -0
- package/dist/deviceStateCommands.d.ts +19 -0
- package/dist/deviceStateCommands.js +62 -0
- package/dist/pinentry.d.ts +1 -0
- package/dist/sdk.d.ts +12 -2
- package/dist/sdk.js +22 -11
- package/dist/session.d.ts +2 -9
- package/dist/session.js +3 -22
- package/dist/transports/nobleBlePlugin.d.ts +2 -0
- package/dist/transports/nobleBlePlugin.js +371 -0
- package/package.json +8 -6
- package/src/__tests__/cli-version.test.ts +8 -0
- package/src/__tests__/device-selection.test.ts +29 -0
- package/src/__tests__/device-state-commands.test.ts +118 -0
- package/src/__tests__/firmware-update-legacy-command.test.ts +18 -0
- package/src/__tests__/firmware-update-v4-command.test.ts +101 -0
- package/src/__tests__/noble-ble-plugin.test.ts +370 -0
- package/src/__tests__/wallet-session.test.ts +64 -0
- package/src/__tests__/wallpaper-upload-command.test.ts +46 -0
- package/src/cli.ts +687 -173
- package/src/deviceSelection.ts +13 -0
- package/src/deviceStateCommands.ts +71 -0
- package/src/pinentry.ts +1 -0
- package/src/sdk.ts +29 -13
- package/src/session.ts +2 -24
- package/src/transports/nobleBlePlugin.ts +487 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createNobleBlePlugin = void 0;
|
|
4
|
+
const hd_shared_1 = require("@onekeyfe/hd-shared");
|
|
5
|
+
const ONEKEY_SERVICE_UUIDS = [hd_shared_1.ONEKEY_SERVICE_UUID];
|
|
6
|
+
const ONEKEY_SERVICE_UUID_ALIASES = (0, hd_shared_1.createKnownBleUuidAliases)(hd_shared_1.ONEKEY_SERVICE_UUID);
|
|
7
|
+
const ONEKEY_WRITE_UUID_ALIASES = (0, hd_shared_1.createKnownBleUuidAliases)(hd_shared_1.ONEKEY_WRITE_CHARACTERISTIC_UUID);
|
|
8
|
+
const ONEKEY_NOTIFY_UUID_ALIASES = (0, hd_shared_1.createKnownBleUuidAliases)(hd_shared_1.ONEKEY_NOTIFY_CHARACTERISTIC_UUID);
|
|
9
|
+
const BLUETOOTH_INIT_TIMEOUT = 10000;
|
|
10
|
+
const DEVICE_SCAN_TIMEOUT = 8000;
|
|
11
|
+
const CONNECTION_TIMEOUT = 8000;
|
|
12
|
+
const SERVICE_DISCOVERY_TIMEOUT = 10000;
|
|
13
|
+
const BLE_CLEANUP_TIMEOUT = 100;
|
|
14
|
+
const BLE_PACKET_SIZE = 192;
|
|
15
|
+
const BLE_ENCRYPTION_ERROR_PATTERNS = [/encryption is insufficient/i, /insufficient encryption/i];
|
|
16
|
+
let noble = null;
|
|
17
|
+
let nobleReadyPromise = null;
|
|
18
|
+
const discoveredDevices = new Map();
|
|
19
|
+
const connectedDevices = new Map();
|
|
20
|
+
const deviceCharacteristics = new Map();
|
|
21
|
+
const notificationStates = new Map();
|
|
22
|
+
const notificationGenerations = new Map();
|
|
23
|
+
function isOneKeyPeripheral(peripheral) {
|
|
24
|
+
const serviceUuids = peripheral.advertisement?.serviceUuids;
|
|
25
|
+
return ((0, hd_shared_1.hasOnekeyCommunicationService)(serviceUuids) &&
|
|
26
|
+
(0, hd_shared_1.isOnekeyBluetoothDevice)({
|
|
27
|
+
id: peripheral.id,
|
|
28
|
+
localName: peripheral.advertisement?.localName,
|
|
29
|
+
serviceUuids,
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
function enqueueNotification(deviceId, generation, data) {
|
|
33
|
+
const state = notificationStates.get(deviceId);
|
|
34
|
+
if (!state || state.generation !== generation)
|
|
35
|
+
return;
|
|
36
|
+
const hex = data.toString('hex');
|
|
37
|
+
const [receiver] = state.pendingReceivers;
|
|
38
|
+
if (receiver) {
|
|
39
|
+
state.pendingReceivers.delete(receiver);
|
|
40
|
+
receiver.resolve(hex);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
state.queue.push(hex);
|
|
44
|
+
}
|
|
45
|
+
function createNotificationState(deviceId) {
|
|
46
|
+
const existing = notificationStates.get(deviceId);
|
|
47
|
+
if (existing) {
|
|
48
|
+
const error = new Error(`BLE notification state replaced for ${deviceId}`);
|
|
49
|
+
existing.pendingReceivers.forEach(receiver => receiver.reject(error));
|
|
50
|
+
}
|
|
51
|
+
const generation = (notificationGenerations.get(deviceId) ?? 0) + 1;
|
|
52
|
+
notificationGenerations.set(deviceId, generation);
|
|
53
|
+
const state = {
|
|
54
|
+
generation,
|
|
55
|
+
queue: [],
|
|
56
|
+
pendingReceivers: new Set(),
|
|
57
|
+
};
|
|
58
|
+
notificationStates.set(deviceId, state);
|
|
59
|
+
return state;
|
|
60
|
+
}
|
|
61
|
+
function clearNotificationState(deviceId, reason) {
|
|
62
|
+
const state = notificationStates.get(deviceId);
|
|
63
|
+
if (!state)
|
|
64
|
+
return;
|
|
65
|
+
notificationStates.delete(deviceId);
|
|
66
|
+
const error = new Error(reason);
|
|
67
|
+
state.pendingReceivers.forEach(receiver => receiver.reject(error));
|
|
68
|
+
state.pendingReceivers.clear();
|
|
69
|
+
state.queue.length = 0;
|
|
70
|
+
}
|
|
71
|
+
function waitForNobleCleanup(registerCallback) {
|
|
72
|
+
return new Promise(resolve => {
|
|
73
|
+
let completed = false;
|
|
74
|
+
const complete = () => {
|
|
75
|
+
if (completed)
|
|
76
|
+
return;
|
|
77
|
+
completed = true;
|
|
78
|
+
clearTimeout(timeout);
|
|
79
|
+
resolve();
|
|
80
|
+
};
|
|
81
|
+
const timeout = setTimeout(complete, BLE_CLEANUP_TIMEOUT);
|
|
82
|
+
try {
|
|
83
|
+
registerCallback(complete);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
complete();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
async function initializeNoble() {
|
|
91
|
+
if (!noble) {
|
|
92
|
+
try {
|
|
93
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
|
94
|
+
noble = require('@stoprocent/noble');
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
throw hd_shared_1.ERRORS.TypedError(hd_shared_1.HardwareErrorCode.BleUnsupported, error instanceof Error ? error.message : String(error));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (noble.state === 'poweredOn')
|
|
101
|
+
return;
|
|
102
|
+
if (nobleReadyPromise) {
|
|
103
|
+
await nobleReadyPromise;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
nobleReadyPromise = new Promise((resolve, reject) => {
|
|
107
|
+
const timeout = setTimeout(() => {
|
|
108
|
+
noble?.removeListener('stateChange', onStateChange);
|
|
109
|
+
reject(hd_shared_1.ERRORS.TypedError(hd_shared_1.HardwareErrorCode.BlePoweredOff, 'Bluetooth is not powered on'));
|
|
110
|
+
}, BLUETOOTH_INIT_TIMEOUT);
|
|
111
|
+
const onStateChange = (state) => {
|
|
112
|
+
if (state === 'poweredOn') {
|
|
113
|
+
clearTimeout(timeout);
|
|
114
|
+
noble?.removeListener('stateChange', onStateChange);
|
|
115
|
+
resolve();
|
|
116
|
+
}
|
|
117
|
+
else if (state === 'unsupported') {
|
|
118
|
+
clearTimeout(timeout);
|
|
119
|
+
noble?.removeListener('stateChange', onStateChange);
|
|
120
|
+
reject(hd_shared_1.ERRORS.TypedError(hd_shared_1.HardwareErrorCode.BleUnsupported));
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
noble?.on('stateChange', onStateChange);
|
|
124
|
+
}).finally(() => {
|
|
125
|
+
nobleReadyPromise = null;
|
|
126
|
+
});
|
|
127
|
+
await nobleReadyPromise;
|
|
128
|
+
}
|
|
129
|
+
function stopScanning() {
|
|
130
|
+
try {
|
|
131
|
+
noble?.stopScanning();
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// ignore best-effort scan cleanup
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async function scanDevices(targetDeviceId) {
|
|
138
|
+
await initializeNoble();
|
|
139
|
+
if (!noble) {
|
|
140
|
+
throw hd_shared_1.ERRORS.TypedError(hd_shared_1.HardwareErrorCode.RuntimeError, 'Noble not initialized');
|
|
141
|
+
}
|
|
142
|
+
if (!targetDeviceId) {
|
|
143
|
+
discoveredDevices.clear();
|
|
144
|
+
}
|
|
145
|
+
const nobleInstance = noble;
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
const found = new Map();
|
|
148
|
+
const cleanup = () => {
|
|
149
|
+
clearTimeout(timeout);
|
|
150
|
+
nobleInstance.removeListener('discover', onDiscover);
|
|
151
|
+
stopScanning();
|
|
152
|
+
};
|
|
153
|
+
const finish = () => {
|
|
154
|
+
cleanup();
|
|
155
|
+
resolve([...found.values()]);
|
|
156
|
+
};
|
|
157
|
+
const onDiscover = (peripheral) => {
|
|
158
|
+
if (targetDeviceId && peripheral.id !== targetDeviceId)
|
|
159
|
+
return;
|
|
160
|
+
if (!isOneKeyPeripheral(peripheral))
|
|
161
|
+
return;
|
|
162
|
+
discoveredDevices.set(peripheral.id, peripheral);
|
|
163
|
+
found.set(peripheral.id, peripheral);
|
|
164
|
+
if (targetDeviceId) {
|
|
165
|
+
finish();
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
const timeout = setTimeout(finish, DEVICE_SCAN_TIMEOUT);
|
|
169
|
+
nobleInstance.on('discover', onDiscover);
|
|
170
|
+
nobleInstance.startScanning([], false, (error) => {
|
|
171
|
+
if (error) {
|
|
172
|
+
cleanup();
|
|
173
|
+
reject(hd_shared_1.ERRORS.TypedError(hd_shared_1.HardwareErrorCode.BleScanError, error.message));
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
function connectPeripheral(peripheral) {
|
|
179
|
+
if (peripheral.state === 'connected')
|
|
180
|
+
return Promise.resolve();
|
|
181
|
+
return new Promise((resolve, reject) => {
|
|
182
|
+
const timeout = setTimeout(() => {
|
|
183
|
+
reject(hd_shared_1.ERRORS.TypedError(hd_shared_1.HardwareErrorCode.BleConnectedError, 'Connection timeout'));
|
|
184
|
+
}, CONNECTION_TIMEOUT);
|
|
185
|
+
peripheral.connect((error) => {
|
|
186
|
+
clearTimeout(timeout);
|
|
187
|
+
if (error) {
|
|
188
|
+
reject(hd_shared_1.ERRORS.TypedError(hd_shared_1.HardwareErrorCode.BleConnectedError, error.message));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
resolve();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
async function discoverCharacteristics(peripheral) {
|
|
196
|
+
const services = await new Promise((resolve, reject) => {
|
|
197
|
+
const timeout = setTimeout(() => {
|
|
198
|
+
reject(hd_shared_1.ERRORS.TypedError(hd_shared_1.HardwareErrorCode.BleServiceNotFound, 'Service discovery timeout'));
|
|
199
|
+
}, SERVICE_DISCOVERY_TIMEOUT);
|
|
200
|
+
peripheral.discoverServices([], (error, discoveredServices) => {
|
|
201
|
+
clearTimeout(timeout);
|
|
202
|
+
if (error) {
|
|
203
|
+
reject(hd_shared_1.ERRORS.TypedError(hd_shared_1.HardwareErrorCode.BleServiceNotFound, error.message));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
resolve(discoveredServices);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
const service = services.find(s => (0, hd_shared_1.matchesKnownBleUuid)(s.uuid, ONEKEY_SERVICE_UUID_ALIASES));
|
|
210
|
+
if (!service) {
|
|
211
|
+
throw hd_shared_1.ERRORS.TypedError(hd_shared_1.HardwareErrorCode.BleServiceNotFound, 'No BLE service found');
|
|
212
|
+
}
|
|
213
|
+
const selectedService = service;
|
|
214
|
+
const characteristics = await new Promise((resolve, reject) => {
|
|
215
|
+
selectedService.discoverCharacteristics([], (error, discoveredCharacteristics) => {
|
|
216
|
+
if (error) {
|
|
217
|
+
reject(hd_shared_1.ERRORS.TypedError(hd_shared_1.HardwareErrorCode.BleCharacteristicNotFound, error.message));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
resolve(discoveredCharacteristics);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
let writeCharacteristic;
|
|
224
|
+
let notifyCharacteristic;
|
|
225
|
+
for (const characteristic of characteristics) {
|
|
226
|
+
if ((0, hd_shared_1.matchesKnownBleUuid)(characteristic.uuid, ONEKEY_WRITE_UUID_ALIASES)) {
|
|
227
|
+
writeCharacteristic = characteristic;
|
|
228
|
+
}
|
|
229
|
+
else if ((0, hd_shared_1.matchesKnownBleUuid)(characteristic.uuid, ONEKEY_NOTIFY_UUID_ALIASES)) {
|
|
230
|
+
notifyCharacteristic = characteristic;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (!writeCharacteristic || !notifyCharacteristic) {
|
|
234
|
+
throw hd_shared_1.ERRORS.TypedError(hd_shared_1.HardwareErrorCode.BleCharacteristicNotFound, 'Required OneKey BLE characteristics not found');
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
write: writeCharacteristic,
|
|
238
|
+
notify: notifyCharacteristic,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function subscribeNotifications(deviceId, generation, notifyCharacteristic) {
|
|
242
|
+
return waitForNobleCleanup(callback => notifyCharacteristic.unsubscribe(callback))
|
|
243
|
+
.then(() => new Promise((resolve, reject) => {
|
|
244
|
+
notifyCharacteristic.subscribe((error) => {
|
|
245
|
+
if (error) {
|
|
246
|
+
const errorMessage = error.message || String(error);
|
|
247
|
+
if (BLE_ENCRYPTION_ERROR_PATTERNS.some(pattern => pattern.test(errorMessage))) {
|
|
248
|
+
reject(hd_shared_1.ERRORS.TypedError(hd_shared_1.HardwareErrorCode.BleDeviceNotBonded, `BLE device ${deviceId} is not paired or the encrypted link is not ready: ${errorMessage}`));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
reject(hd_shared_1.ERRORS.TypedError(hd_shared_1.HardwareErrorCode.BleCharacteristicNotifyChangeFailure, `Failed to subscribe notifications for ${deviceId}: ${errorMessage}`));
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
resolve();
|
|
255
|
+
});
|
|
256
|
+
}))
|
|
257
|
+
.then(() => {
|
|
258
|
+
notifyCharacteristic.removeAllListeners('data');
|
|
259
|
+
notifyCharacteristic.on('data', data => enqueueNotification(deviceId, generation, data));
|
|
260
|
+
})
|
|
261
|
+
.catch(error => {
|
|
262
|
+
notifyCharacteristic.removeAllListeners('data');
|
|
263
|
+
if (error) {
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
throw hd_shared_1.ERRORS.TypedError(hd_shared_1.HardwareErrorCode.BleCharacteristicNotifyChangeFailure);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
function writeCharacteristic(characteristic, buffer, withoutResponse) {
|
|
270
|
+
return new Promise((resolve, reject) => {
|
|
271
|
+
characteristic.write(buffer, withoutResponse, (error) => {
|
|
272
|
+
if (error) {
|
|
273
|
+
reject(error);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
resolve();
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
async function disconnectDevice(uuid) {
|
|
281
|
+
const peripheral = connectedDevices.get(uuid);
|
|
282
|
+
const characteristics = deviceCharacteristics.get(uuid);
|
|
283
|
+
clearNotificationState(uuid, `BLE device disconnected: ${uuid}`);
|
|
284
|
+
if (characteristics) {
|
|
285
|
+
characteristics.notify.removeAllListeners('data');
|
|
286
|
+
await waitForNobleCleanup(callback => characteristics.notify.unsubscribe(callback));
|
|
287
|
+
}
|
|
288
|
+
connectedDevices.delete(uuid);
|
|
289
|
+
deviceCharacteristics.delete(uuid);
|
|
290
|
+
if (!peripheral || peripheral.state === 'disconnected')
|
|
291
|
+
return;
|
|
292
|
+
await waitForNobleCleanup(callback => peripheral.disconnect(callback));
|
|
293
|
+
}
|
|
294
|
+
function createNobleBlePlugin() {
|
|
295
|
+
return {
|
|
296
|
+
version: 'OneKey-CLI-Noble-1.0',
|
|
297
|
+
async init() {
|
|
298
|
+
await initializeNoble();
|
|
299
|
+
},
|
|
300
|
+
async enumerate() {
|
|
301
|
+
const devices = await scanDevices();
|
|
302
|
+
return devices.map(device => ({
|
|
303
|
+
commType: 'ble',
|
|
304
|
+
id: device.id,
|
|
305
|
+
name: device.advertisement?.localName || 'Unknown BLE Device',
|
|
306
|
+
}));
|
|
307
|
+
},
|
|
308
|
+
async connect(uuid) {
|
|
309
|
+
let peripheral = discoveredDevices.get(uuid);
|
|
310
|
+
if (!peripheral) {
|
|
311
|
+
[peripheral] = await scanDevices(uuid);
|
|
312
|
+
}
|
|
313
|
+
if (!peripheral) {
|
|
314
|
+
throw hd_shared_1.ERRORS.TypedError(hd_shared_1.HardwareErrorCode.DeviceNotFound, `BLE device not found: ${uuid}`);
|
|
315
|
+
}
|
|
316
|
+
await connectPeripheral(peripheral);
|
|
317
|
+
let characteristics;
|
|
318
|
+
try {
|
|
319
|
+
characteristics = await discoverCharacteristics(peripheral);
|
|
320
|
+
const notificationState = createNotificationState(uuid);
|
|
321
|
+
await subscribeNotifications(uuid, notificationState.generation, characteristics.notify);
|
|
322
|
+
connectedDevices.set(uuid, peripheral);
|
|
323
|
+
deviceCharacteristics.set(uuid, characteristics);
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
clearNotificationState(uuid, `BLE notification subscription failed: ${uuid}`);
|
|
327
|
+
if (characteristics) {
|
|
328
|
+
characteristics.notify.removeAllListeners('data');
|
|
329
|
+
await waitForNobleCleanup(callback => characteristics?.notify.unsubscribe(callback));
|
|
330
|
+
}
|
|
331
|
+
if (peripheral.state !== 'disconnected') {
|
|
332
|
+
await waitForNobleCleanup(callback => peripheral?.disconnect(callback));
|
|
333
|
+
}
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
async disconnect(uuid) {
|
|
338
|
+
await disconnectDevice(uuid);
|
|
339
|
+
},
|
|
340
|
+
async send(uuid, data, options) {
|
|
341
|
+
const characteristics = deviceCharacteristics.get(uuid);
|
|
342
|
+
if (!characteristics) {
|
|
343
|
+
throw hd_shared_1.ERRORS.TypedError(hd_shared_1.HardwareErrorCode.BleCharacteristicNotFound, `BLE device is not connected: ${uuid}`);
|
|
344
|
+
}
|
|
345
|
+
const buffer = Buffer.from(data, 'hex');
|
|
346
|
+
const withoutResponse = options?.withoutResponse ?? true;
|
|
347
|
+
for (let offset = 0; offset < buffer.length; offset += BLE_PACKET_SIZE) {
|
|
348
|
+
const chunk = buffer.subarray(offset, Math.min(offset + BLE_PACKET_SIZE, buffer.length));
|
|
349
|
+
await writeCharacteristic(characteristics.write, chunk, withoutResponse);
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
async receive(uuid) {
|
|
353
|
+
const resolvedUuid = uuid ??
|
|
354
|
+
(notificationStates.size === 1 ? notificationStates.keys().next().value : undefined);
|
|
355
|
+
if (!resolvedUuid) {
|
|
356
|
+
throw hd_shared_1.ERRORS.TypedError(hd_shared_1.HardwareErrorCode.RuntimeError, 'BLE receive requires a device UUID when multiple devices are connected');
|
|
357
|
+
}
|
|
358
|
+
const state = notificationStates.get(resolvedUuid);
|
|
359
|
+
if (!state) {
|
|
360
|
+
throw hd_shared_1.ERRORS.TypedError(hd_shared_1.HardwareErrorCode.TransportNotFound, `BLE notification state not found: ${resolvedUuid}`);
|
|
361
|
+
}
|
|
362
|
+
const queued = state.queue.shift();
|
|
363
|
+
if (queued !== undefined)
|
|
364
|
+
return queued;
|
|
365
|
+
return new Promise((resolve, reject) => {
|
|
366
|
+
state.pendingReceivers.add({ resolve, reject });
|
|
367
|
+
});
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
exports.createNobleBlePlugin = createNobleBlePlugin;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onekeyfe/hardware-cli",
|
|
3
|
-
"version": "1.2.0-alpha.
|
|
3
|
+
"version": "1.2.0-alpha.100",
|
|
4
4
|
"description": "OneKey hardware wallet CLI for testing device communication",
|
|
5
5
|
"author": "OneKey",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"start": "node dist/cli.js",
|
|
24
24
|
"search": "node dist/cli.js search",
|
|
25
25
|
"get-features": "node dist/cli.js get-features",
|
|
26
|
+
"get-state": "node dist/cli.js get-state",
|
|
26
27
|
"get-address": "node dist/cli.js get-address",
|
|
27
28
|
"ping": "node dist/cli.js ping",
|
|
28
29
|
"lint": "eslint .",
|
|
@@ -30,11 +31,12 @@
|
|
|
30
31
|
"test": "jest"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@onekeyfe/hd-common-connect-sdk": "1.2.0-alpha.
|
|
34
|
-
"@onekeyfe/hd-core": "1.2.0-alpha.
|
|
35
|
-
"@onekeyfe/hd-shared": "1.2.0-alpha.
|
|
36
|
-
"@onekeyfe/hd-transport-usb": "1.2.0-alpha.
|
|
34
|
+
"@onekeyfe/hd-common-connect-sdk": "1.2.0-alpha.100",
|
|
35
|
+
"@onekeyfe/hd-core": "1.2.0-alpha.100",
|
|
36
|
+
"@onekeyfe/hd-shared": "1.2.0-alpha.100",
|
|
37
|
+
"@onekeyfe/hd-transport-usb": "1.2.0-alpha.100",
|
|
38
|
+
"@stoprocent/noble": "2.3.16",
|
|
37
39
|
"commander": "^12.0.0"
|
|
38
40
|
},
|
|
39
|
-
"gitHead": "
|
|
41
|
+
"gitHead": "4e70f0a01da0c3c2dab687b8c4bf4f18cf4ad0c0"
|
|
40
42
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { selectSearchDevice } from '../deviceSelection';
|
|
2
|
+
|
|
3
|
+
describe('selectSearchDevice', () => {
|
|
4
|
+
it('多设备环境优先选择显式 connectId 对应的设备', () => {
|
|
5
|
+
const devices = [
|
|
6
|
+
{ connectId: 'first-device', name: 'Pro A9CA' },
|
|
7
|
+
{ connectId: 'target-device', name: 'Pro2 6136' },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
expect(selectSearchDevice(devices, 'target-device')).toEqual(devices[1]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('未指定 connectId 时保持选择第一台设备的兼容行为', () => {
|
|
14
|
+
const devices = [
|
|
15
|
+
{ connectId: 'first-device', name: 'Pro2 6136' },
|
|
16
|
+
{ connectId: 'second-device', name: 'Pro2 C445' },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
expect(selectSearchDevice(devices)).toEqual(devices[0]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('显式 connectId 暂未出现在扫描结果时仍保留该目标', () => {
|
|
23
|
+
const devices = [{ connectId: 'other-device', name: 'Pro2 C445' }];
|
|
24
|
+
|
|
25
|
+
expect(selectSearchDevice(devices, 'target-device')).toEqual({
|
|
26
|
+
connectId: 'target-device',
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { getCanonicalDeviceState, getCompatibleFeatures } from '../deviceStateCommands';
|
|
2
|
+
|
|
3
|
+
const createSdkMock = () => ({
|
|
4
|
+
searchDevices: jest.fn(),
|
|
5
|
+
getDeviceState: jest.fn(),
|
|
6
|
+
getFeatures: jest.fn(),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe('设备状态 CLI 兼容层', () => {
|
|
10
|
+
test('Protocol V2 直接返回 SDK 搜索结果中的兼容 features', async () => {
|
|
11
|
+
const sdk = createSdkMock();
|
|
12
|
+
const features = {
|
|
13
|
+
protocol: 'V2',
|
|
14
|
+
deviceType: 'pro2',
|
|
15
|
+
deviceId: 'device-id',
|
|
16
|
+
};
|
|
17
|
+
sdk.searchDevices.mockResolvedValue({
|
|
18
|
+
success: true,
|
|
19
|
+
payload: [
|
|
20
|
+
{
|
|
21
|
+
connectId: 'pro2-connect-id',
|
|
22
|
+
state: { protocol: 'V2' },
|
|
23
|
+
features,
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
await expect(getCompatibleFeatures(sdk as never, 'pro2-connect-id')).resolves.toEqual({
|
|
29
|
+
success: true,
|
|
30
|
+
payload: features,
|
|
31
|
+
});
|
|
32
|
+
expect(sdk.getFeatures).not.toHaveBeenCalled();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('Protocol V1 继续调用公共 getFeatures 保持旧行为', async () => {
|
|
36
|
+
const sdk = createSdkMock();
|
|
37
|
+
const response = {
|
|
38
|
+
success: true,
|
|
39
|
+
payload: { protocol: 'V1', label: 'Classic' },
|
|
40
|
+
};
|
|
41
|
+
sdk.searchDevices.mockResolvedValue({
|
|
42
|
+
success: true,
|
|
43
|
+
payload: [
|
|
44
|
+
{
|
|
45
|
+
connectId: 'classic-connect-id',
|
|
46
|
+
state: { protocol: 'V1' },
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
sdk.getFeatures.mockResolvedValue(response);
|
|
51
|
+
|
|
52
|
+
await expect(getCompatibleFeatures(sdk as never, 'classic-connect-id')).resolves.toBe(response);
|
|
53
|
+
expect(sdk.getFeatures).toHaveBeenCalledWith('classic-connect-id');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('未显式指定设备时选择搜索结果中的第一台设备', async () => {
|
|
57
|
+
const sdk = createSdkMock();
|
|
58
|
+
const response = { success: true, payload: { protocol: 'V1' } };
|
|
59
|
+
sdk.searchDevices.mockResolvedValue({
|
|
60
|
+
success: true,
|
|
61
|
+
payload: [{ connectId: 'first-device', state: { protocol: 'V1' } }],
|
|
62
|
+
});
|
|
63
|
+
sdk.getFeatures.mockResolvedValue(response);
|
|
64
|
+
|
|
65
|
+
await expect(getCompatibleFeatures(sdk as never)).resolves.toBe(response);
|
|
66
|
+
expect(sdk.getFeatures).toHaveBeenCalledWith('first-device');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('显式 connectId 不在搜索结果时返回结构化错误', async () => {
|
|
70
|
+
const sdk = createSdkMock();
|
|
71
|
+
sdk.searchDevices.mockResolvedValue({
|
|
72
|
+
success: true,
|
|
73
|
+
payload: [{ connectId: 'another-device', state: { protocol: 'V1' } }],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await expect(getCompatibleFeatures(sdk as never, 'missing-device')).resolves.toEqual({
|
|
77
|
+
success: false,
|
|
78
|
+
payload: {
|
|
79
|
+
code: 'DEVICE_NOT_FOUND',
|
|
80
|
+
error: 'Device not found: missing-device',
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
expect(sdk.getFeatures).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('get-state 将 firmware scope 传给 SDK', async () => {
|
|
87
|
+
const sdk = createSdkMock();
|
|
88
|
+
const response = { success: true, payload: { protocol: 'V2' } };
|
|
89
|
+
sdk.searchDevices.mockResolvedValue({
|
|
90
|
+
success: true,
|
|
91
|
+
payload: [{ connectId: 'pro2-connect-id', state: { protocol: 'V2' } }],
|
|
92
|
+
});
|
|
93
|
+
sdk.getDeviceState.mockResolvedValue(response);
|
|
94
|
+
|
|
95
|
+
await expect(
|
|
96
|
+
getCanonicalDeviceState(sdk as never, 'pro2-connect-id', 'firmware')
|
|
97
|
+
).resolves.toBe(response);
|
|
98
|
+
expect(sdk.getDeviceState).toHaveBeenCalledWith('pro2-connect-id', {
|
|
99
|
+
scope: 'firmware',
|
|
100
|
+
});
|
|
101
|
+
expect(sdk.searchDevices).toHaveBeenCalledTimes(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('get-state 未指定 connectId 时搜索并选择第一台设备', async () => {
|
|
105
|
+
const sdk = createSdkMock();
|
|
106
|
+
const state = { protocol: 'V2', revision: 2 };
|
|
107
|
+
sdk.searchDevices.mockResolvedValue({
|
|
108
|
+
success: true,
|
|
109
|
+
payload: [{ connectId: 'pro2-connect-id', state }],
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await expect(getCanonicalDeviceState(sdk as never, undefined, 'runtime')).resolves.toEqual({
|
|
113
|
+
success: true,
|
|
114
|
+
payload: state,
|
|
115
|
+
});
|
|
116
|
+
expect(sdk.getDeviceState).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { getLegacyFirmwareConnectTimeout, program } from '../cli';
|
|
2
|
+
|
|
3
|
+
describe('firmware-update-legacy CLI command', () => {
|
|
4
|
+
test('提供 Classic/Pure 的本地固件升级命令', () => {
|
|
5
|
+
const command = program.commands.find(item => item.name() === 'firmware-update-legacy');
|
|
6
|
+
|
|
7
|
+
expect(command).toBeDefined();
|
|
8
|
+
expect(command?.description()).toBe('Update Classic/Pure firmware through the legacy protocol');
|
|
9
|
+
expect(command?.options.find(option => option.long === '--binary')?.mandatory).toBe(true);
|
|
10
|
+
expect(command?.options.some(option => option.long === '--device-name')).toBe(true);
|
|
11
|
+
expect(command?.options.some(option => option.long === '--update-type')).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('USB Classic 固件升级使用足够的设备探测超时', () => {
|
|
15
|
+
expect(getLegacyFirmwareConnectTimeout('usb')).toBe(90_000);
|
|
16
|
+
expect(getLegacyFirmwareConnectTimeout('ble')).toBeUndefined();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { createSDK, disposeSDK } from '../sdk';
|
|
2
|
+
import { program, runFirmwareUpdateV4WithRetry } from '../cli';
|
|
3
|
+
|
|
4
|
+
jest.mock('../sdk', () => ({
|
|
5
|
+
createSDK: jest.fn(),
|
|
6
|
+
disposeSDK: jest.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
const transientProbeFailure = {
|
|
10
|
+
success: false,
|
|
11
|
+
payload: {
|
|
12
|
+
error: 'Device protocol mismatch: expected V2; device did not respond to expected protocol',
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const createSdkMock = () => ({
|
|
17
|
+
getDeviceState: jest.fn(),
|
|
18
|
+
firmwareUpdateV4: jest.fn(),
|
|
19
|
+
on: jest.fn(),
|
|
20
|
+
off: jest.fn(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('firmware-update-v4 CLI command', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
jest.clearAllMocks();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
jest.restoreAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('exposes firmware-update-v4 as the formal command', () => {
|
|
33
|
+
const command = program.commands.find(item => item.name() === 'firmware-update-v4');
|
|
34
|
+
|
|
35
|
+
expect(command).toBeDefined();
|
|
36
|
+
expect(command?.description()).toBe(
|
|
37
|
+
'Run Protocol V2 firmware update through sdk.firmwareUpdateV4'
|
|
38
|
+
);
|
|
39
|
+
expect(command?.options.some(option => option.long === '--resource-archive')).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('does not expose the pre-release firmware-update-v4-debug command', () => {
|
|
43
|
+
expect(program.commands.some(item => item.name() === 'firmware-update-v4-debug')).toBe(false);
|
|
44
|
+
expect(program.commands.some(item => item.aliases().includes('firmware-update-v4-debug'))).toBe(
|
|
45
|
+
false
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('retries only the read-only USB probe before starting the firmware update', async () => {
|
|
50
|
+
const firstSdk = createSdkMock();
|
|
51
|
+
const retrySdk = createSdkMock();
|
|
52
|
+
firstSdk.getDeviceState.mockResolvedValue(transientProbeFailure);
|
|
53
|
+
retrySdk.getDeviceState.mockResolvedValue({ success: true, payload: { protocol: 'V2' } });
|
|
54
|
+
retrySdk.firmwareUpdateV4.mockResolvedValue({ success: true, payload: {} });
|
|
55
|
+
jest.mocked(createSDK).mockResolvedValue(retrySdk as never);
|
|
56
|
+
jest.mocked(disposeSDK).mockResolvedValue(undefined);
|
|
57
|
+
jest.spyOn(global, 'setTimeout').mockImplementation(callback => {
|
|
58
|
+
callback();
|
|
59
|
+
return 0 as never;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const result = await runFirmwareUpdateV4WithRetry({
|
|
63
|
+
sdk: firstSdk as never,
|
|
64
|
+
globalOpts: { transport: 'usb', connectId: 'stale-connect-id' },
|
|
65
|
+
params: { applicationP1Binary: new ArrayBuffer(1) } as never,
|
|
66
|
+
retries: 1,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(firstSdk.firmwareUpdateV4).not.toHaveBeenCalled();
|
|
70
|
+
expect(disposeSDK).toHaveBeenCalledTimes(1);
|
|
71
|
+
expect(retrySdk.getDeviceState).toHaveBeenCalledWith(undefined, {
|
|
72
|
+
scope: 'runtime',
|
|
73
|
+
connectProtocol: 'V2',
|
|
74
|
+
retryCount: 0,
|
|
75
|
+
});
|
|
76
|
+
expect(retrySdk.firmwareUpdateV4).toHaveBeenCalledTimes(1);
|
|
77
|
+
expect(result).toMatchObject({ success: true, payload: { metrics: { attempt: 2 } } });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('does not replay firmwareUpdateV4 after the read-only probe succeeds', async () => {
|
|
81
|
+
const sdk = createSdkMock();
|
|
82
|
+
sdk.getDeviceState.mockResolvedValue({ success: true, payload: { protocol: 'V2' } });
|
|
83
|
+
sdk.firmwareUpdateV4.mockResolvedValue(transientProbeFailure);
|
|
84
|
+
|
|
85
|
+
const result = await runFirmwareUpdateV4WithRetry({
|
|
86
|
+
sdk: sdk as never,
|
|
87
|
+
globalOpts: { transport: 'usb', connectId: 'pro2-connect-id' },
|
|
88
|
+
params: { applicationP1Binary: new ArrayBuffer(1) } as never,
|
|
89
|
+
retries: 2,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(sdk.getDeviceState).toHaveBeenCalledTimes(1);
|
|
93
|
+
expect(sdk.firmwareUpdateV4).toHaveBeenCalledTimes(1);
|
|
94
|
+
expect(disposeSDK).not.toHaveBeenCalled();
|
|
95
|
+
expect(createSDK).not.toHaveBeenCalled();
|
|
96
|
+
expect(result).toMatchObject({
|
|
97
|
+
success: false,
|
|
98
|
+
payload: { error: transientProbeFailure.payload.error, metrics: { attempt: 1 } },
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|