@onekeyfe/hardware-cli 1.2.0-alpha.16 → 1.2.0-alpha.18
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 +14 -0
- package/dist/cli.js +149 -7
- package/dist/deviceSelection.d.ts +3 -0
- package/dist/deviceSelection.js +11 -0
- package/dist/transports/nobleBlePlugin.js +3 -2
- package/package.json +6 -6
- package/src/__tests__/device-selection.test.ts +29 -0
- package/src/__tests__/firmware-update-legacy-command.test.ts +20 -0
- package/src/__tests__/noble-ble-plugin.test.ts +51 -0
- package/src/__tests__/wallpaper-upload-command.test.ts +46 -0
- package/src/cli.ts +208 -6
- package/src/deviceSelection.ts +13 -0
- package/src/transports/nobleBlePlugin.ts +3 -2
package/dist/cli.d.ts
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
declare const program: Command;
|
|
3
|
+
export declare function getLegacyFirmwareConnectTimeout(transport: 'usb' | 'ble'): 90000 | undefined;
|
|
4
|
+
export declare function buildWallpaperUploadMetrics({ totalBytes, transferredBytes, startedAt, endedAt, lastProgress, }: {
|
|
5
|
+
totalBytes: number;
|
|
6
|
+
transferredBytes: number;
|
|
7
|
+
startedAt: number;
|
|
8
|
+
endedAt: number;
|
|
9
|
+
lastProgress: number;
|
|
10
|
+
}): {
|
|
11
|
+
totalBytes: number;
|
|
12
|
+
transferredBytes: number;
|
|
13
|
+
totalSeconds: number;
|
|
14
|
+
transferKiBPerSecond: number | null;
|
|
15
|
+
lastProgress: number;
|
|
16
|
+
};
|
|
3
17
|
export { program };
|
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
-
exports.program = void 0;
|
|
4
|
+
exports.program = exports.buildWallpaperUploadMetrics = exports.getLegacyFirmwareConnectTimeout = void 0;
|
|
5
5
|
const node_fs_1 = require("node:fs");
|
|
6
6
|
const commander_1 = require("commander");
|
|
7
7
|
const hd_core_1 = require("@onekeyfe/hd-core");
|
|
8
8
|
const hd_shared_1 = require("@onekeyfe/hd-shared");
|
|
9
9
|
const chains_1 = require("./chains");
|
|
10
|
+
const deviceSelection_1 = require("./deviceSelection");
|
|
10
11
|
const sdk_1 = require("./sdk");
|
|
11
12
|
const session_1 = require("./session");
|
|
12
13
|
function extractPassphraseSession(payload) {
|
|
@@ -101,6 +102,81 @@ program
|
|
|
101
102
|
const result = await sdk.getFeatures(connectId || '');
|
|
102
103
|
outputResult(globalOpts, result);
|
|
103
104
|
}));
|
|
105
|
+
program
|
|
106
|
+
.command('upload-wallpaper')
|
|
107
|
+
.description('Upload and activate a Pro2 wallpaper')
|
|
108
|
+
.requiredOption('--rgba <path>', '604x1024 raw RGBA file')
|
|
109
|
+
.option('--file-name <name>', 'Device wallpaper file name', 'wallpaper-cli')
|
|
110
|
+
.option('--chunk-size <bytes>', 'Transfer chunk size in bytes')
|
|
111
|
+
.action(opts => runCommand({}, async ({ sdk, globalOpts, params }) => {
|
|
112
|
+
const rgba = readBinaryParam(opts.rgba);
|
|
113
|
+
const expectedBytes = 604 * 1024 * 4;
|
|
114
|
+
if (rgba.byteLength !== expectedBytes) {
|
|
115
|
+
throw new Error(`Invalid RGBA size: expected ${expectedBytes} bytes, received ${rgba.byteLength}`);
|
|
116
|
+
}
|
|
117
|
+
let transferStartedAt;
|
|
118
|
+
let transferEndedAt;
|
|
119
|
+
let lastProgress = -1;
|
|
120
|
+
let lastPrintedProgress = -10;
|
|
121
|
+
let progressTotalBytes = 0;
|
|
122
|
+
let transferredBytes = 0;
|
|
123
|
+
const totalStartedAt = Date.now();
|
|
124
|
+
const onUiEvent = (message) => {
|
|
125
|
+
if (!message || typeof message !== 'object')
|
|
126
|
+
return;
|
|
127
|
+
const event = message;
|
|
128
|
+
if (event.type !== hd_core_1.UI_REQUEST.DEVICE_PROGRESS || !event.payload)
|
|
129
|
+
return;
|
|
130
|
+
const progress = Number(event.payload.progress);
|
|
131
|
+
if (!Number.isFinite(progress))
|
|
132
|
+
return;
|
|
133
|
+
transferStartedAt ?? (transferStartedAt = Date.now());
|
|
134
|
+
lastProgress = Math.max(lastProgress, progress);
|
|
135
|
+
const totalBytes = Number(event.payload.totalBytes);
|
|
136
|
+
if (Number.isFinite(totalBytes) && totalBytes > 0)
|
|
137
|
+
progressTotalBytes = totalBytes;
|
|
138
|
+
const confirmedBytes = Number(event.payload.transferredBytes);
|
|
139
|
+
if (Number.isFinite(confirmedBytes) && confirmedBytes >= 0) {
|
|
140
|
+
transferredBytes = Math.max(transferredBytes, confirmedBytes);
|
|
141
|
+
}
|
|
142
|
+
const printableProgress = Math.floor(progress / 10) * 10;
|
|
143
|
+
if (printableProgress > lastPrintedProgress || progress >= 100) {
|
|
144
|
+
const rate = Number(event.payload.rateBytesPerSecond);
|
|
145
|
+
const rateText = Number.isFinite(rate) && rate > 0 ? ` ${(rate / 1024).toFixed(2)} KiB/s` : '';
|
|
146
|
+
process.stderr.write(`[onekey-hw] Wallpaper transfer: ${Math.round(progress)}%${rateText}\n`);
|
|
147
|
+
lastPrintedProgress = progress >= 100 ? 100 : printableProgress;
|
|
148
|
+
}
|
|
149
|
+
if (progress >= 100)
|
|
150
|
+
transferEndedAt ?? (transferEndedAt = Date.now());
|
|
151
|
+
};
|
|
152
|
+
sdk.on(hd_core_1.UI_EVENT, onUiEvent);
|
|
153
|
+
let result;
|
|
154
|
+
try {
|
|
155
|
+
result = await sdk.deviceUploadWallpaper(globalOpts.connectId, {
|
|
156
|
+
...params,
|
|
157
|
+
width: 604,
|
|
158
|
+
height: 1024,
|
|
159
|
+
rgba,
|
|
160
|
+
fileName: opts.fileName,
|
|
161
|
+
chunkSize: opts.chunkSize ? safeParseInt(opts.chunkSize, '--chunk-size') : undefined,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
sdk.off?.(hd_core_1.UI_EVENT, onUiEvent);
|
|
166
|
+
}
|
|
167
|
+
const endedAt = transferEndedAt ?? Date.now();
|
|
168
|
+
const totalBytes = Number(result?.payload?.size) || progressTotalBytes;
|
|
169
|
+
outputResult(globalOpts, {
|
|
170
|
+
...result,
|
|
171
|
+
metrics: buildWallpaperUploadMetrics({
|
|
172
|
+
totalBytes,
|
|
173
|
+
transferredBytes: result?.success ? totalBytes : transferredBytes,
|
|
174
|
+
startedAt: transferStartedAt ?? totalStartedAt,
|
|
175
|
+
endedAt,
|
|
176
|
+
lastProgress,
|
|
177
|
+
}),
|
|
178
|
+
});
|
|
179
|
+
}));
|
|
104
180
|
// ============================================================
|
|
105
181
|
// Signing Commands
|
|
106
182
|
// ============================================================
|
|
@@ -414,6 +490,30 @@ program
|
|
|
414
490
|
code: 'FIRMWARE_UPDATE_NOT_SUPPORTED',
|
|
415
491
|
},
|
|
416
492
|
}));
|
|
493
|
+
program
|
|
494
|
+
.command('firmware-update-legacy')
|
|
495
|
+
.description('Update Classic/Pure firmware through the legacy protocol')
|
|
496
|
+
.requiredOption('--binary <path>', 'Local firmware binary path')
|
|
497
|
+
.option('--device-name <name>', 'BLE advertising name, for example K1514')
|
|
498
|
+
.option('--update-type <type>', 'Firmware component: firmware or ble', 'firmware')
|
|
499
|
+
.option('--no-reboot', 'Do not reboot the device after a successful update')
|
|
500
|
+
.action(opts => runCommand({}, async ({ sdk, globalOpts }) => {
|
|
501
|
+
if (opts.updateType !== 'firmware' && opts.updateType !== 'ble') {
|
|
502
|
+
throw new Error(`Unsupported --update-type: ${opts.updateType}. Use "firmware" or "ble".`);
|
|
503
|
+
}
|
|
504
|
+
const connectId = await resolveLegacyFirmwareConnectId(sdk, globalOpts.connectId, opts.deviceName);
|
|
505
|
+
const result = await sdk.firmwareUpdate(connectId, {
|
|
506
|
+
binary: readBinaryParam(opts.binary),
|
|
507
|
+
updateType: opts.updateType,
|
|
508
|
+
rebootOnSuccess: opts.reboot,
|
|
509
|
+
timeout: getLegacyFirmwareConnectTimeout(globalOpts.transport),
|
|
510
|
+
});
|
|
511
|
+
outputResult(globalOpts, result);
|
|
512
|
+
}));
|
|
513
|
+
function getLegacyFirmwareConnectTimeout(transport) {
|
|
514
|
+
return transport === 'usb' ? 90000 : undefined;
|
|
515
|
+
}
|
|
516
|
+
exports.getLegacyFirmwareConnectTimeout = getLegacyFirmwareConnectTimeout;
|
|
417
517
|
program
|
|
418
518
|
.command('firmware-update-ble')
|
|
419
519
|
.description('Run Protocol V2 firmware update over BLE')
|
|
@@ -746,18 +846,22 @@ globalOpts) {
|
|
|
746
846
|
searchResult.payload.length === 0) {
|
|
747
847
|
return undefined;
|
|
748
848
|
}
|
|
749
|
-
const device = searchResult.payload
|
|
750
|
-
|
|
849
|
+
const device = (0, deviceSelection_1.selectSearchDevice)(searchResult.payload, globalOpts.connectId);
|
|
850
|
+
if (!device) {
|
|
851
|
+
throw new Error(`未找到指定的 BLE 设备: ${globalOpts.connectId}`);
|
|
852
|
+
}
|
|
853
|
+
const selectedDevice = device;
|
|
854
|
+
const connectId = selectedDevice.connectId || globalOpts.connectId || '';
|
|
751
855
|
if (!globalOpts.connectId && connectId) {
|
|
752
856
|
globalOpts.connectId = connectId;
|
|
753
857
|
}
|
|
754
858
|
// ── Step 2: Get features if searchDevices didn't populate them ──
|
|
755
859
|
// getFeatures failures here are non-fatal — we fall through to Step 3
|
|
756
860
|
// which will fail with a clearer error if the device is truly unreachable.
|
|
757
|
-
let deviceId =
|
|
758
|
-
let deviceType = (0, hd_core_1.getDeviceType)(
|
|
759
|
-
let unlocked =
|
|
760
|
-
let passphraseProtection =
|
|
861
|
+
let deviceId = selectedDevice.features?.deviceId || selectedDevice.deviceId || '';
|
|
862
|
+
let deviceType = (0, hd_core_1.getDeviceType)(selectedDevice.features);
|
|
863
|
+
let unlocked = selectedDevice.features?.unlocked;
|
|
864
|
+
let passphraseProtection = selectedDevice.features?.passphraseProtection;
|
|
761
865
|
if (!deviceId || unlocked == null || passphraseProtection == null) {
|
|
762
866
|
try {
|
|
763
867
|
const featResult = await sdk.getFeatures(connectId);
|
|
@@ -904,6 +1008,31 @@ function readBinaryParam(path) {
|
|
|
904
1008
|
const buffer = (0, node_fs_1.readFileSync)(path);
|
|
905
1009
|
return new Uint8Array(buffer).buffer;
|
|
906
1010
|
}
|
|
1011
|
+
async function resolveLegacyFirmwareConnectId(sdk, explicitConnectId, deviceName) {
|
|
1012
|
+
if (explicitConnectId && !deviceName)
|
|
1013
|
+
return explicitConnectId;
|
|
1014
|
+
const searchResult = await sdk.searchDevices();
|
|
1015
|
+
if (!searchResult?.success || !Array.isArray(searchResult.payload)) {
|
|
1016
|
+
throw new Error('Unable to scan BLE devices');
|
|
1017
|
+
}
|
|
1018
|
+
const devices = searchResult.payload;
|
|
1019
|
+
const normalizedName = deviceName?.trim().toLowerCase();
|
|
1020
|
+
const matches = normalizedName
|
|
1021
|
+
? devices.filter(device => device.name?.trim().toLowerCase() === normalizedName)
|
|
1022
|
+
: devices.filter(device => device.deviceType?.toLowerCase() === 'classic');
|
|
1023
|
+
if (matches.length === 0) {
|
|
1024
|
+
throw new Error(normalizedName ? `BLE device not found by name: ${deviceName}` : 'No Classic/Pure BLE device found');
|
|
1025
|
+
}
|
|
1026
|
+
if (matches.length > 1) {
|
|
1027
|
+
throw new Error(normalizedName
|
|
1028
|
+
? `Multiple BLE devices found by name: ${deviceName}`
|
|
1029
|
+
: 'Multiple Classic/Pure BLE devices found; specify --device-name');
|
|
1030
|
+
}
|
|
1031
|
+
const connectId = matches[0].connectId;
|
|
1032
|
+
if (!connectId)
|
|
1033
|
+
throw new Error(`BLE device has no connect ID: ${matches[0].name}`);
|
|
1034
|
+
return connectId;
|
|
1035
|
+
}
|
|
907
1036
|
function parseResourceBundleParam(spec) {
|
|
908
1037
|
const sep = spec.indexOf(':');
|
|
909
1038
|
if (sep <= 0 || sep === spec.length - 1) {
|
|
@@ -965,6 +1094,19 @@ function formatFirmwareBytes(bytes) {
|
|
|
965
1094
|
return '';
|
|
966
1095
|
return `${(bytes / 1024).toFixed(1)} KiB`;
|
|
967
1096
|
}
|
|
1097
|
+
function buildWallpaperUploadMetrics({ totalBytes, transferredBytes, startedAt, endedAt, lastProgress, }) {
|
|
1098
|
+
const elapsedMs = Math.max(endedAt - startedAt, 0);
|
|
1099
|
+
return {
|
|
1100
|
+
totalBytes,
|
|
1101
|
+
transferredBytes,
|
|
1102
|
+
totalSeconds: Number((elapsedMs / 1000).toFixed(2)),
|
|
1103
|
+
transferKiBPerSecond: elapsedMs > 0
|
|
1104
|
+
? Number((transferredBytes / 1024 / (elapsedMs / 1000)).toFixed(2))
|
|
1105
|
+
: null,
|
|
1106
|
+
lastProgress,
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
exports.buildWallpaperUploadMetrics = buildWallpaperUploadMetrics;
|
|
968
1110
|
function maybePrintFirmwareProgress({ progressType, progress, payload, lastPrintedProgress, }) {
|
|
969
1111
|
const printableProgress = Math.floor(progress / 10) * 10;
|
|
970
1112
|
if (printableProgress <= lastPrintedProgress && progress < 100) {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.selectSearchDevice = void 0;
|
|
4
|
+
function selectSearchDevice(devices, preferredConnectId) {
|
|
5
|
+
if (preferredConnectId) {
|
|
6
|
+
return (devices.find(device => device.connectId === preferredConnectId) ??
|
|
7
|
+
{ connectId: preferredConnectId });
|
|
8
|
+
}
|
|
9
|
+
return devices[0];
|
|
10
|
+
}
|
|
11
|
+
exports.selectSearchDevice = selectSearchDevice;
|
|
@@ -350,15 +350,16 @@ function createNobleBlePlugin() {
|
|
|
350
350
|
async disconnect(uuid) {
|
|
351
351
|
await disconnectDevice(uuid);
|
|
352
352
|
},
|
|
353
|
-
async send(uuid, data) {
|
|
353
|
+
async send(uuid, data, options) {
|
|
354
354
|
const characteristics = deviceCharacteristics.get(uuid);
|
|
355
355
|
if (!characteristics) {
|
|
356
356
|
throw hd_shared_1.ERRORS.TypedError(hd_shared_1.HardwareErrorCode.BleCharacteristicNotFound, `BLE device is not connected: ${uuid}`);
|
|
357
357
|
}
|
|
358
358
|
const buffer = Buffer.from(data, 'hex');
|
|
359
|
+
const withoutResponse = options?.withoutResponse ?? true;
|
|
359
360
|
for (let offset = 0; offset < buffer.length; offset += BLE_PACKET_SIZE) {
|
|
360
361
|
const chunk = buffer.subarray(offset, Math.min(offset + BLE_PACKET_SIZE, buffer.length));
|
|
361
|
-
await writeCharacteristic(characteristics.write, chunk,
|
|
362
|
+
await writeCharacteristic(characteristics.write, chunk, withoutResponse);
|
|
362
363
|
}
|
|
363
364
|
},
|
|
364
365
|
async receive(uuid) {
|
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.18",
|
|
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.
|
|
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.
|
|
33
|
+
"@onekeyfe/hd-common-connect-sdk": "1.2.0-alpha.18",
|
|
34
|
+
"@onekeyfe/hd-core": "1.2.0-alpha.18",
|
|
35
|
+
"@onekeyfe/hd-shared": "1.2.0-alpha.18",
|
|
36
|
+
"@onekeyfe/hd-transport-usb": "1.2.0-alpha.18",
|
|
37
37
|
"@stoprocent/noble": "2.3.16",
|
|
38
38
|
"commander": "^12.0.0"
|
|
39
39
|
},
|
|
40
|
-
"gitHead": "
|
|
40
|
+
"gitHead": "bdbc5bf74e48ca90f5c56f3079b2b974e61f7aa4"
|
|
41
41
|
}
|
|
@@ -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,20 @@
|
|
|
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(
|
|
9
|
+
'Update Classic/Pure firmware through the legacy protocol'
|
|
10
|
+
);
|
|
11
|
+
expect(command?.options.find(option => option.long === '--binary')?.mandatory).toBe(true);
|
|
12
|
+
expect(command?.options.some(option => option.long === '--device-name')).toBe(true);
|
|
13
|
+
expect(command?.options.some(option => option.long === '--update-type')).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('USB Classic 固件升级使用足够的设备探测超时', () => {
|
|
17
|
+
expect(getLegacyFirmwareConnectTimeout('usb')).toBe(90_000);
|
|
18
|
+
expect(getLegacyFirmwareConnectTimeout('ble')).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -141,6 +141,31 @@ describe('Noble BLE plugin notification routing', () => {
|
|
|
141
141
|
]);
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
+
test('uses acknowledged writes when requested by firmware upload', async () => {
|
|
145
|
+
const device = createPeripheral('device-a');
|
|
146
|
+
const noble = new EventEmitter() as EventEmitter & {
|
|
147
|
+
state: string;
|
|
148
|
+
startScanning: jest.Mock;
|
|
149
|
+
stopScanning: jest.Mock;
|
|
150
|
+
};
|
|
151
|
+
noble.state = 'poweredOn';
|
|
152
|
+
noble.startScanning = jest.fn((_services, _duplicates, callback) => {
|
|
153
|
+
callback?.();
|
|
154
|
+
noble.emit('discover', device.peripheral);
|
|
155
|
+
});
|
|
156
|
+
noble.stopScanning = jest.fn(callback => callback?.());
|
|
157
|
+
jest.doMock('@stoprocent/noble', () => noble);
|
|
158
|
+
|
|
159
|
+
const { createNobleBlePlugin } = await import('../transports/nobleBlePlugin');
|
|
160
|
+
const plugin = createNobleBlePlugin();
|
|
161
|
+
await plugin.init();
|
|
162
|
+
await plugin.connect('device-a');
|
|
163
|
+
|
|
164
|
+
await (plugin.send as any)('device-a', 'aa', { withoutResponse: false });
|
|
165
|
+
|
|
166
|
+
expect(device.write.write).toHaveBeenCalledWith(expect.any(Buffer), false, expect.any(Function));
|
|
167
|
+
});
|
|
168
|
+
|
|
144
169
|
test('does not add a fixed delay between 192-byte writes', async () => {
|
|
145
170
|
const device = createPeripheral('device-a');
|
|
146
171
|
const noble = new EventEmitter() as EventEmitter & {
|
|
@@ -171,4 +196,30 @@ describe('Noble BLE plugin notification routing', () => {
|
|
|
171
196
|
expect(device.write.write).toHaveBeenCalledTimes(2);
|
|
172
197
|
expect(wait).not.toHaveBeenCalled();
|
|
173
198
|
});
|
|
199
|
+
|
|
200
|
+
test('preserves a short final BLE packet without padding', async () => {
|
|
201
|
+
const device = createPeripheral('device-a');
|
|
202
|
+
const noble = new EventEmitter() as EventEmitter & {
|
|
203
|
+
state: string;
|
|
204
|
+
startScanning: jest.Mock;
|
|
205
|
+
stopScanning: jest.Mock;
|
|
206
|
+
};
|
|
207
|
+
noble.state = 'poweredOn';
|
|
208
|
+
noble.startScanning = jest.fn((_services, _duplicates, callback) => {
|
|
209
|
+
callback?.();
|
|
210
|
+
noble.emit('discover', device.peripheral);
|
|
211
|
+
});
|
|
212
|
+
noble.stopScanning = jest.fn(callback => callback?.());
|
|
213
|
+
jest.doMock('@stoprocent/noble', () => noble);
|
|
214
|
+
|
|
215
|
+
const { createNobleBlePlugin } = await import('../transports/nobleBlePlugin');
|
|
216
|
+
const plugin = createNobleBlePlugin();
|
|
217
|
+
await plugin.init();
|
|
218
|
+
await plugin.connect('device-a');
|
|
219
|
+
|
|
220
|
+
await plugin.send('device-a', 'aabb');
|
|
221
|
+
|
|
222
|
+
const packet = device.write.write.mock.calls[0][0] as Buffer;
|
|
223
|
+
expect(packet).toEqual(Buffer.from('aabb', 'hex'));
|
|
224
|
+
});
|
|
174
225
|
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { buildWallpaperUploadMetrics, program } from '../cli';
|
|
2
|
+
|
|
3
|
+
describe('upload-wallpaper CLI command', () => {
|
|
4
|
+
test('exposes a command backed by the SDK wallpaper API', () => {
|
|
5
|
+
const command = program.commands.find(item => item.name() === 'upload-wallpaper');
|
|
6
|
+
|
|
7
|
+
expect(command).toBeDefined();
|
|
8
|
+
expect(command?.description()).toBe('Upload and activate a Pro2 wallpaper');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('reports effective transfer speed from encoded bytes and elapsed time', () => {
|
|
12
|
+
expect(
|
|
13
|
+
buildWallpaperUploadMetrics({
|
|
14
|
+
totalBytes: 2_473_984,
|
|
15
|
+
transferredBytes: 2_473_984,
|
|
16
|
+
startedAt: 1_000,
|
|
17
|
+
endedAt: 2_000,
|
|
18
|
+
lastProgress: 100,
|
|
19
|
+
})
|
|
20
|
+
).toEqual({
|
|
21
|
+
totalBytes: 2_473_984,
|
|
22
|
+
transferredBytes: 2_473_984,
|
|
23
|
+
totalSeconds: 1,
|
|
24
|
+
transferKiBPerSecond: 2416,
|
|
25
|
+
lastProgress: 100,
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('uses confirmed bytes for an interrupted transfer rate', () => {
|
|
30
|
+
expect(
|
|
31
|
+
buildWallpaperUploadMetrics({
|
|
32
|
+
totalBytes: 1_855_500,
|
|
33
|
+
transferredBytes: 631_800,
|
|
34
|
+
startedAt: 1_000,
|
|
35
|
+
endedAt: 101_000,
|
|
36
|
+
lastProgress: 34,
|
|
37
|
+
})
|
|
38
|
+
).toEqual({
|
|
39
|
+
totalBytes: 1_855_500,
|
|
40
|
+
transferredBytes: 631_800,
|
|
41
|
+
totalSeconds: 100,
|
|
42
|
+
transferKiBPerSecond: 6.17,
|
|
43
|
+
lastProgress: 34,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
resolveSignMessage,
|
|
11
11
|
resolveSignTransaction,
|
|
12
12
|
} from './chains';
|
|
13
|
+
import { selectSearchDevice } from './deviceSelection';
|
|
13
14
|
import { createSDK, disposeSDK } from './sdk';
|
|
14
15
|
import {
|
|
15
16
|
clearSessionFromKeychain,
|
|
@@ -147,6 +148,94 @@ program
|
|
|
147
148
|
})
|
|
148
149
|
);
|
|
149
150
|
|
|
151
|
+
program
|
|
152
|
+
.command('upload-wallpaper')
|
|
153
|
+
.description('Upload and activate a Pro2 wallpaper')
|
|
154
|
+
.requiredOption('--rgba <path>', '604x1024 raw RGBA file')
|
|
155
|
+
.option('--file-name <name>', 'Device wallpaper file name', 'wallpaper-cli')
|
|
156
|
+
.option('--chunk-size <bytes>', 'Transfer chunk size in bytes')
|
|
157
|
+
.action(opts =>
|
|
158
|
+
runCommand({}, async ({ sdk, globalOpts, params }) => {
|
|
159
|
+
const rgba = readBinaryParam(opts.rgba);
|
|
160
|
+
const expectedBytes = 604 * 1024 * 4;
|
|
161
|
+
if (rgba.byteLength !== expectedBytes) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`Invalid RGBA size: expected ${expectedBytes} bytes, received ${rgba.byteLength}`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let transferStartedAt: number | undefined;
|
|
168
|
+
let transferEndedAt: number | undefined;
|
|
169
|
+
let lastProgress = -1;
|
|
170
|
+
let lastPrintedProgress = -10;
|
|
171
|
+
let progressTotalBytes = 0;
|
|
172
|
+
let transferredBytes = 0;
|
|
173
|
+
const totalStartedAt = Date.now();
|
|
174
|
+
const onUiEvent = (message: unknown) => {
|
|
175
|
+
if (!message || typeof message !== 'object') return;
|
|
176
|
+
const event = message as {
|
|
177
|
+
type?: string;
|
|
178
|
+
payload?: {
|
|
179
|
+
progress?: number;
|
|
180
|
+
transferredBytes?: number;
|
|
181
|
+
totalBytes?: number;
|
|
182
|
+
rateBytesPerSecond?: number;
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
if (event.type !== UI_REQUEST.DEVICE_PROGRESS || !event.payload) return;
|
|
186
|
+
const progress = Number(event.payload.progress);
|
|
187
|
+
if (!Number.isFinite(progress)) return;
|
|
188
|
+
transferStartedAt ??= Date.now();
|
|
189
|
+
lastProgress = Math.max(lastProgress, progress);
|
|
190
|
+
const totalBytes = Number(event.payload.totalBytes);
|
|
191
|
+
if (Number.isFinite(totalBytes) && totalBytes > 0) progressTotalBytes = totalBytes;
|
|
192
|
+
const confirmedBytes = Number(event.payload.transferredBytes);
|
|
193
|
+
if (Number.isFinite(confirmedBytes) && confirmedBytes >= 0) {
|
|
194
|
+
transferredBytes = Math.max(transferredBytes, confirmedBytes);
|
|
195
|
+
}
|
|
196
|
+
const printableProgress = Math.floor(progress / 10) * 10;
|
|
197
|
+
if (printableProgress > lastPrintedProgress || progress >= 100) {
|
|
198
|
+
const rate = Number(event.payload.rateBytesPerSecond);
|
|
199
|
+
const rateText =
|
|
200
|
+
Number.isFinite(rate) && rate > 0 ? ` ${(rate / 1024).toFixed(2)} KiB/s` : '';
|
|
201
|
+
process.stderr.write(
|
|
202
|
+
`[onekey-hw] Wallpaper transfer: ${Math.round(progress)}%${rateText}\n`
|
|
203
|
+
);
|
|
204
|
+
lastPrintedProgress = progress >= 100 ? 100 : printableProgress;
|
|
205
|
+
}
|
|
206
|
+
if (progress >= 100) transferEndedAt ??= Date.now();
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
sdk.on(UI_EVENT, onUiEvent);
|
|
210
|
+
let result: any;
|
|
211
|
+
try {
|
|
212
|
+
result = await sdk.deviceUploadWallpaper(globalOpts.connectId, {
|
|
213
|
+
...params,
|
|
214
|
+
width: 604,
|
|
215
|
+
height: 1024,
|
|
216
|
+
rgba,
|
|
217
|
+
fileName: opts.fileName,
|
|
218
|
+
chunkSize: opts.chunkSize ? safeParseInt(opts.chunkSize, '--chunk-size') : undefined,
|
|
219
|
+
});
|
|
220
|
+
} finally {
|
|
221
|
+
sdk.off?.(UI_EVENT, onUiEvent);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const endedAt = transferEndedAt ?? Date.now();
|
|
225
|
+
const totalBytes = Number(result?.payload?.size) || progressTotalBytes;
|
|
226
|
+
outputResult(globalOpts, {
|
|
227
|
+
...result,
|
|
228
|
+
metrics: buildWallpaperUploadMetrics({
|
|
229
|
+
totalBytes,
|
|
230
|
+
transferredBytes: result?.success ? totalBytes : transferredBytes,
|
|
231
|
+
startedAt: transferStartedAt ?? totalStartedAt,
|
|
232
|
+
endedAt,
|
|
233
|
+
lastProgress,
|
|
234
|
+
}),
|
|
235
|
+
});
|
|
236
|
+
})
|
|
237
|
+
);
|
|
238
|
+
|
|
150
239
|
// ============================================================
|
|
151
240
|
// Signing Commands
|
|
152
241
|
// ============================================================
|
|
@@ -541,6 +630,38 @@ program
|
|
|
541
630
|
})
|
|
542
631
|
);
|
|
543
632
|
|
|
633
|
+
program
|
|
634
|
+
.command('firmware-update-legacy')
|
|
635
|
+
.description('Update Classic/Pure firmware through the legacy protocol')
|
|
636
|
+
.requiredOption('--binary <path>', 'Local firmware binary path')
|
|
637
|
+
.option('--device-name <name>', 'BLE advertising name, for example K1514')
|
|
638
|
+
.option('--update-type <type>', 'Firmware component: firmware or ble', 'firmware')
|
|
639
|
+
.option('--no-reboot', 'Do not reboot the device after a successful update')
|
|
640
|
+
.action(opts =>
|
|
641
|
+
runCommand({}, async ({ sdk, globalOpts }) => {
|
|
642
|
+
if (opts.updateType !== 'firmware' && opts.updateType !== 'ble') {
|
|
643
|
+
throw new Error(`Unsupported --update-type: ${opts.updateType}. Use "firmware" or "ble".`);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const connectId = await resolveLegacyFirmwareConnectId(
|
|
647
|
+
sdk,
|
|
648
|
+
globalOpts.connectId,
|
|
649
|
+
opts.deviceName
|
|
650
|
+
);
|
|
651
|
+
const result = await sdk.firmwareUpdate(connectId, {
|
|
652
|
+
binary: readBinaryParam(opts.binary),
|
|
653
|
+
updateType: opts.updateType,
|
|
654
|
+
rebootOnSuccess: opts.reboot,
|
|
655
|
+
timeout: getLegacyFirmwareConnectTimeout(globalOpts.transport),
|
|
656
|
+
});
|
|
657
|
+
outputResult(globalOpts, result);
|
|
658
|
+
})
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
export function getLegacyFirmwareConnectTimeout(transport: 'usb' | 'ble') {
|
|
662
|
+
return transport === 'usb' ? 90_000 : undefined;
|
|
663
|
+
}
|
|
664
|
+
|
|
544
665
|
program
|
|
545
666
|
.command('firmware-update-ble')
|
|
546
667
|
.description('Run Protocol V2 firmware update over BLE')
|
|
@@ -945,7 +1066,26 @@ async function prepareSession(
|
|
|
945
1066
|
return undefined;
|
|
946
1067
|
}
|
|
947
1068
|
|
|
948
|
-
const device =
|
|
1069
|
+
const device = selectSearchDevice(
|
|
1070
|
+
searchResult.payload as Array<{
|
|
1071
|
+
connectId?: string;
|
|
1072
|
+
deviceId?: string;
|
|
1073
|
+
features?: {
|
|
1074
|
+
deviceId?: string | null;
|
|
1075
|
+
deviceType?: string;
|
|
1076
|
+
sessionId?: string | null;
|
|
1077
|
+
passphraseProtection?: boolean | null;
|
|
1078
|
+
unlocked?: boolean | null;
|
|
1079
|
+
};
|
|
1080
|
+
}>,
|
|
1081
|
+
globalOpts.connectId
|
|
1082
|
+
);
|
|
1083
|
+
|
|
1084
|
+
if (!device) {
|
|
1085
|
+
throw new Error(`未找到指定的 BLE 设备: ${globalOpts.connectId}`);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const selectedDevice = device as {
|
|
949
1089
|
connectId?: string;
|
|
950
1090
|
deviceId?: string;
|
|
951
1091
|
features?: {
|
|
@@ -956,7 +1096,7 @@ async function prepareSession(
|
|
|
956
1096
|
unlocked?: boolean | null;
|
|
957
1097
|
};
|
|
958
1098
|
};
|
|
959
|
-
const connectId =
|
|
1099
|
+
const connectId = selectedDevice.connectId || globalOpts.connectId || '';
|
|
960
1100
|
if (!globalOpts.connectId && connectId) {
|
|
961
1101
|
globalOpts.connectId = connectId;
|
|
962
1102
|
}
|
|
@@ -964,10 +1104,10 @@ async function prepareSession(
|
|
|
964
1104
|
// ── Step 2: Get features if searchDevices didn't populate them ──
|
|
965
1105
|
// getFeatures failures here are non-fatal — we fall through to Step 3
|
|
966
1106
|
// which will fail with a clearer error if the device is truly unreachable.
|
|
967
|
-
let deviceId =
|
|
968
|
-
let deviceType = getDeviceType(
|
|
969
|
-
let unlocked =
|
|
970
|
-
let passphraseProtection =
|
|
1107
|
+
let deviceId = selectedDevice.features?.deviceId || selectedDevice.deviceId || '';
|
|
1108
|
+
let deviceType = getDeviceType(selectedDevice.features as Features | undefined);
|
|
1109
|
+
let unlocked = selectedDevice.features?.unlocked;
|
|
1110
|
+
let passphraseProtection = selectedDevice.features?.passphraseProtection;
|
|
971
1111
|
|
|
972
1112
|
if (!deviceId || unlocked == null || passphraseProtection == null) {
|
|
973
1113
|
try {
|
|
@@ -1162,6 +1302,42 @@ function readBinaryParam(path: string): ArrayBuffer {
|
|
|
1162
1302
|
return new Uint8Array(buffer).buffer;
|
|
1163
1303
|
}
|
|
1164
1304
|
|
|
1305
|
+
async function resolveLegacyFirmwareConnectId(
|
|
1306
|
+
sdk: AnySdk,
|
|
1307
|
+
explicitConnectId?: string,
|
|
1308
|
+
deviceName?: string
|
|
1309
|
+
): Promise<string> {
|
|
1310
|
+
if (explicitConnectId && !deviceName) return explicitConnectId;
|
|
1311
|
+
|
|
1312
|
+
const searchResult = await sdk.searchDevices();
|
|
1313
|
+
if (!searchResult?.success || !Array.isArray(searchResult.payload)) {
|
|
1314
|
+
throw new Error('Unable to scan BLE devices');
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
const devices = searchResult.payload as EnrichedSearchDevice[];
|
|
1318
|
+
const normalizedName = deviceName?.trim().toLowerCase();
|
|
1319
|
+
const matches = normalizedName
|
|
1320
|
+
? devices.filter(device => device.name?.trim().toLowerCase() === normalizedName)
|
|
1321
|
+
: devices.filter(device => device.deviceType?.toLowerCase() === 'classic');
|
|
1322
|
+
|
|
1323
|
+
if (matches.length === 0) {
|
|
1324
|
+
throw new Error(
|
|
1325
|
+
normalizedName ? `BLE device not found by name: ${deviceName}` : 'No Classic/Pure BLE device found'
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
if (matches.length > 1) {
|
|
1329
|
+
throw new Error(
|
|
1330
|
+
normalizedName
|
|
1331
|
+
? `Multiple BLE devices found by name: ${deviceName}`
|
|
1332
|
+
: 'Multiple Classic/Pure BLE devices found; specify --device-name'
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
const connectId = matches[0].connectId;
|
|
1337
|
+
if (!connectId) throw new Error(`BLE device has no connect ID: ${matches[0].name}`);
|
|
1338
|
+
return connectId;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1165
1341
|
function parseResourceBundleParam(spec: string): { binary: ArrayBuffer; devicePath: string } {
|
|
1166
1342
|
const sep = spec.indexOf(':');
|
|
1167
1343
|
if (sep <= 0 || sep === spec.length - 1) {
|
|
@@ -1234,6 +1410,32 @@ function formatFirmwareBytes(bytes: number) {
|
|
|
1234
1410
|
return `${(bytes / 1024).toFixed(1)} KiB`;
|
|
1235
1411
|
}
|
|
1236
1412
|
|
|
1413
|
+
export function buildWallpaperUploadMetrics({
|
|
1414
|
+
totalBytes,
|
|
1415
|
+
transferredBytes,
|
|
1416
|
+
startedAt,
|
|
1417
|
+
endedAt,
|
|
1418
|
+
lastProgress,
|
|
1419
|
+
}: {
|
|
1420
|
+
totalBytes: number;
|
|
1421
|
+
transferredBytes: number;
|
|
1422
|
+
startedAt: number;
|
|
1423
|
+
endedAt: number;
|
|
1424
|
+
lastProgress: number;
|
|
1425
|
+
}) {
|
|
1426
|
+
const elapsedMs = Math.max(endedAt - startedAt, 0);
|
|
1427
|
+
return {
|
|
1428
|
+
totalBytes,
|
|
1429
|
+
transferredBytes,
|
|
1430
|
+
totalSeconds: Number((elapsedMs / 1000).toFixed(2)),
|
|
1431
|
+
transferKiBPerSecond:
|
|
1432
|
+
elapsedMs > 0
|
|
1433
|
+
? Number((transferredBytes / 1024 / (elapsedMs / 1000)).toFixed(2))
|
|
1434
|
+
: null,
|
|
1435
|
+
lastProgress,
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1237
1439
|
function maybePrintFirmwareProgress({
|
|
1238
1440
|
progressType,
|
|
1239
1441
|
progress,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function selectSearchDevice<T extends { connectId?: string }>(
|
|
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
|
+
}
|
|
@@ -453,7 +453,7 @@ export function createNobleBlePlugin(): LowlevelTransportSharedPlugin {
|
|
|
453
453
|
await disconnectDevice(uuid);
|
|
454
454
|
},
|
|
455
455
|
|
|
456
|
-
async send(uuid: string, data: string) {
|
|
456
|
+
async send(uuid: string, data: string, options?: { withoutResponse?: boolean }) {
|
|
457
457
|
const characteristics = deviceCharacteristics.get(uuid);
|
|
458
458
|
if (!characteristics) {
|
|
459
459
|
throw ERRORS.TypedError(
|
|
@@ -463,9 +463,10 @@ export function createNobleBlePlugin(): LowlevelTransportSharedPlugin {
|
|
|
463
463
|
}
|
|
464
464
|
|
|
465
465
|
const buffer = Buffer.from(data, 'hex');
|
|
466
|
+
const withoutResponse = options?.withoutResponse ?? true;
|
|
466
467
|
for (let offset = 0; offset < buffer.length; offset += BLE_PACKET_SIZE) {
|
|
467
468
|
const chunk = buffer.subarray(offset, Math.min(offset + BLE_PACKET_SIZE, buffer.length));
|
|
468
|
-
await writeCharacteristic(characteristics.write, chunk,
|
|
469
|
+
await writeCharacteristic(characteristics.write, chunk, withoutResponse);
|
|
469
470
|
}
|
|
470
471
|
},
|
|
471
472
|
|