@onekeyfe/hardware-cli 1.2.0-alpha.15 → 1.2.0-alpha.17

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,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[0];
750
- const connectId = device.connectId || globalOpts.connectId || '';
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 = device.features?.deviceId || device.deviceId || '';
758
- let deviceType = (0, hd_core_1.getDeviceType)(device.features);
759
- let unlocked = device.features?.unlocked;
760
- let passphraseProtection = device.features?.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,3 @@
1
+ export declare function selectSearchDevice<T extends {
2
+ connectId?: string;
3
+ }>(devices: T[], preferredConnectId?: string): T | undefined;
@@ -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, true);
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.15",
3
+ "version": "1.2.0-alpha.17",
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.15",
34
- "@onekeyfe/hd-core": "1.2.0-alpha.15",
35
- "@onekeyfe/hd-shared": "1.2.0-alpha.15",
36
- "@onekeyfe/hd-transport-usb": "1.2.0-alpha.15",
33
+ "@onekeyfe/hd-common-connect-sdk": "1.2.0-alpha.17",
34
+ "@onekeyfe/hd-core": "1.2.0-alpha.17",
35
+ "@onekeyfe/hd-shared": "1.2.0-alpha.17",
36
+ "@onekeyfe/hd-transport-usb": "1.2.0-alpha.17",
37
37
  "@stoprocent/noble": "2.3.16",
38
38
  "commander": "^12.0.0"
39
39
  },
40
- "gitHead": "06dd8f9cba892bb66242e5a51038a7103fb05f09"
40
+ "gitHead": "9027b743e4f73beb42e1a7950397504b4191f2a3"
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 = searchResult.payload[0] as {
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 = device.connectId || globalOpts.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 = device.features?.deviceId || device.deviceId || '';
968
- let deviceType = getDeviceType(device.features as Features | undefined);
969
- let unlocked = device.features?.unlocked;
970
- let passphraseProtection = device.features?.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, true);
469
+ await writeCharacteristic(characteristics.write, chunk, withoutResponse);
469
470
  }
470
471
  },
471
472