@onekeyfe/hd-transport-web-device 1.1.27 → 1.2.0-alpha.0

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/src/webusb.ts CHANGED
@@ -1,19 +1,57 @@
1
1
  /* eslint-disable no-undef */
2
- import transport, { LogBlockCommand } from '@onekeyfe/hd-transport';
2
+ import transport, {
3
+ LogBlockCommand,
4
+ PROTOCOL_V1_CHUNK_PAYLOAD_SIZE,
5
+ PROTOCOL_V1_MESSAGE_HEADER_SIZE,
6
+ PROTOCOL_V1_REPORT_ID,
7
+ PROTOCOL_V1_USB_PACKET_SIZE,
8
+ PROTOCOL_V2_CHANNEL_USB,
9
+ PROTOCOL_V2_FRAME_MAX_BYTES,
10
+ ProtocolV2FrameAssembler,
11
+ ProtocolV2Session,
12
+ probeProtocolV2 as probeProtocolV2Helper,
13
+ } from '@onekeyfe/hd-transport';
3
14
  import { ERRORS, HardwareErrorCode, ONEKEY_WEBUSB_FILTER, wait } from '@onekeyfe/hd-shared';
4
15
  import ByteBuffer from 'bytebuffer';
5
16
 
6
- import type { AcquireInput, OneKeyDeviceInfoBase } from '@onekeyfe/hd-transport';
17
+ import type {
18
+ AcquireInput,
19
+ OneKeyDeviceInfoBase,
20
+ ProtocolType,
21
+ TransportCallOptions,
22
+ } from '@onekeyfe/hd-transport';
7
23
 
8
- const { parseConfigure, buildEncodeBuffers, decodeProtocol, receiveOne, check } = transport;
24
+ const { parseConfigure, check, ProtocolV1 } = transport;
9
25
 
10
26
  const CONFIGURATION_ID = 1;
11
27
  const INTERFACE_ID = 0;
12
28
  const ENDPOINT_ID = 1;
13
- const PACKET_SIZE = 64;
14
- const HEADER_LENGTH = 6;
29
+ const PACKET_SIZE = PROTOCOL_V1_USB_PACKET_SIZE;
30
+ const PAYLOAD_SIZE = PROTOCOL_V1_CHUNK_PAYLOAD_SIZE;
31
+ const REPORT_ID = PROTOCOL_V1_REPORT_ID;
32
+ const HEADER_LENGTH = PROTOCOL_V1_MESSAGE_HEADER_SIZE;
15
33
  const PACKET_IO_MAX_RETRIES = 3;
16
34
  const PACKET_IO_RETRY_DELAY = 300;
35
+ const PROTOCOL_PROBE_TIMEOUT = 1000;
36
+ const WEBUSB_FILE_WRITE_LOG_BLOCK_PATTERN = /(?:^|[^a-z])(?:raw)?(?:filesystem|emmc)?filewrite$/i;
37
+
38
+ function shouldSuppressWebUsbCallLog(name: string) {
39
+ const normalized = name.replace(/[_\s-]/g, '');
40
+ return WEBUSB_FILE_WRITE_LOG_BLOCK_PATTERN.test(normalized);
41
+ }
42
+
43
+ function isLogBlockCommand(name: string) {
44
+ return (LogBlockCommand as Set<string> | undefined)?.has?.(name) ?? false;
45
+ }
46
+
47
+ function shouldBlockWebUsbCallDataLog(name: string) {
48
+ const normalized = name.replace(/[_\s-]/g, '');
49
+ return isLogBlockCommand(name) || WEBUSB_FILE_WRITE_LOG_BLOCK_PATTERN.test(normalized);
50
+ }
51
+
52
+ function inferProtocolHintFromDeviceName(name?: string | null): ProtocolType | undefined {
53
+ return /\bpro\s*2\b/i.test(name ?? '') ? 'V2' : undefined;
54
+ }
17
55
 
18
56
  /**
19
57
  * Device information with path and WebUSB device instance
@@ -21,11 +59,52 @@ const PACKET_IO_RETRY_DELAY = 300;
21
59
  export interface DeviceInfo extends OneKeyDeviceInfoBase {
22
60
  path: string;
23
61
  device: USBDevice;
62
+ protocolType?: ProtocolType;
63
+ }
64
+
65
+ /** USB endpoint pair discovered at connect time */
66
+ interface DeviceEndpoints {
67
+ interfaceNumber: number;
68
+ endpointIn: number;
69
+ endpointOut: number;
70
+ }
71
+
72
+ interface TransferCancelToken {
73
+ cancelled: boolean;
24
74
  }
25
75
 
26
76
  export default class WebUsbTransport {
27
77
  messages: ReturnType<typeof transport.parseConfigure> | undefined;
28
78
 
79
+ /** Protobuf schema for Protocol V2 transports. */
80
+ messagesV2: ReturnType<typeof transport.parseConfigure> | undefined;
81
+
82
+ /** Per-path protocol type detected by active wire-level probe. */
83
+ private deviceProtocol: Map<string, ProtocolType> = new Map();
84
+
85
+ private deviceProtocolHints: Map<string, ProtocolType> = new Map();
86
+
87
+ /** 按设备缓存 Protocol V2 frame assembler,保留同一次读取里多出来的后续 frame。 */
88
+ private protocolV2Assemblers: Map<string, ProtocolV2FrameAssembler> = new Map();
89
+
90
+ /** 按设备缓存 Protocol V2 session,保持 seq 与设备会话一致递增。 */
91
+ private protocolV2Sessions: Map<string, ProtocolV2Session> = new Map();
92
+
93
+ /** 当前 Protocol V2 调用的读取超时,由缓存 session 的 readFrame 闭包读取。 */
94
+ private protocolV2ReadTimeouts: Map<string, number | undefined> = new Map();
95
+
96
+ /** Per-path USB endpoint / interface numbers (discovered from USB descriptors) */
97
+ private deviceEndpoints: Map<string, DeviceEndpoints> = new Map();
98
+
99
+ /**
100
+ * 早期 Pro2 工程板 USB descriptor 没有烧录 serial number。
101
+ * 为这类设备生成会话内稳定的 mock path(同一 USBDevice 实例复用同一 path),
102
+ * 避免设备因为空 serial 被发现流程整体丢弃。重新插拔后实例变化,path 会重新生成。
103
+ */
104
+ private mockSerialPaths: WeakMap<USBDevice, string> = new WeakMap();
105
+
106
+ private mockSerialCounter = 0;
107
+
29
108
  name = 'WebUsbTransport';
30
109
 
31
110
  stopped = false;
@@ -65,7 +144,7 @@ export default class WebUsbTransport {
65
144
  }
66
145
 
67
146
  /**
68
- * Configure transport protocol
147
+ * Configure Protocol V1 protobuf schema (legacy chunked 0x3F framing).
69
148
  */
70
149
  configure(signedData: any) {
71
150
  const messages = parseConfigure(signedData);
@@ -73,6 +152,16 @@ export default class WebUsbTransport {
73
152
  this.messages = messages;
74
153
  }
75
154
 
155
+ /**
156
+ * Cache the Protocol V2 protobuf schema.
157
+ */
158
+ configureProtocolV2(signedData: any) {
159
+ this.messagesV2 = parseConfigure(signedData);
160
+ this.protocolV2Sessions.clear();
161
+ this.protocolV2ReadTimeouts.clear();
162
+ this.Log?.debug('[WebUsbTransport] Protocol V2 schema configured');
163
+ }
164
+
76
165
  /**
77
166
  * Request user to select a device
78
167
  * This method must be called in response to a user action
@@ -102,6 +191,26 @@ export default class WebUsbTransport {
102
191
  return this.deviceList;
103
192
  }
104
193
 
194
+ /**
195
+ * 设备 path:正常设备直接用 USB serial number;
196
+ * 空 serial(早期工程板)回退到会话内稳定的 mock path。
197
+ */
198
+ private getDevicePath(device: USBDevice): string {
199
+ if (typeof device.serialNumber === 'string' && device.serialNumber.length > 0) {
200
+ return device.serialNumber;
201
+ }
202
+ let path = this.mockSerialPaths.get(device);
203
+ if (!path) {
204
+ this.mockSerialCounter += 1;
205
+ path = `mock-serial:${device.vendorId.toString(16)}:${device.productId.toString(16)}:${
206
+ this.mockSerialCounter
207
+ }`;
208
+ this.mockSerialPaths.set(device, path);
209
+ this.Log?.debug(`[WebUSB] device has no serial number, using mock path: ${path}`);
210
+ }
211
+ return path;
212
+ }
213
+
105
214
  /**
106
215
  * Get list of connected devices
107
216
  */
@@ -109,19 +218,33 @@ export default class WebUsbTransport {
109
218
  if (!this.usb) return [];
110
219
 
111
220
  const devices = await this.usb.getDevices();
112
- const onekeyDevices = devices.filter(dev => {
113
- const isOneKey = ONEKEY_WEBUSB_FILTER.some(
221
+ const onekeyDevices = devices.filter(dev =>
222
+ ONEKEY_WEBUSB_FILTER.some(
114
223
  desc => dev.vendorId === desc.vendorId && dev.productId === desc.productId
115
- );
116
- const hasSerialNumber = typeof dev.serialNumber === 'string' && dev.serialNumber.length > 0;
117
- return isOneKey && hasSerialNumber;
224
+ )
225
+ );
226
+
227
+ this.deviceList = onekeyDevices.map(device => {
228
+ const path = this.getDevicePath(device);
229
+ const protocolHint = inferProtocolHintFromDeviceName(device.productName);
230
+ if (protocolHint) {
231
+ this.deviceProtocolHints.set(path, protocolHint);
232
+ }
233
+
234
+ return {
235
+ path,
236
+ device,
237
+ commType: 'webusb',
238
+ };
118
239
  });
119
240
 
120
- this.deviceList = onekeyDevices.map(device => ({
121
- path: device.serialNumber as string,
122
- device,
123
- commType: 'webusb',
124
- }));
241
+ // Debug: log all discovered devices. Protocol is detected after acquire via wire probe.
242
+ for (const dev of onekeyDevices) {
243
+ this.Log.debug(
244
+ `[WebUSB] Device: name="${dev.productName}" serial="${dev.serialNumber}" ` +
245
+ `VID=0x${dev.vendorId.toString(16)} PID=0x${dev.productId.toString(16)}`
246
+ );
247
+ }
125
248
 
126
249
  return this.deviceList;
127
250
  }
@@ -132,7 +255,17 @@ export default class WebUsbTransport {
132
255
  async acquire(input: AcquireInput) {
133
256
  if (!input.path) return;
134
257
  try {
258
+ await this.closeOpenDevice(input.path);
135
259
  await this.connect(input.path ?? '', true);
260
+ const deviceName = this.deviceList.find(device => device.path === input.path)?.device
261
+ .productName;
262
+ const protocolHint = input.expectedProtocol
263
+ ? undefined
264
+ : this.deviceProtocolHints.get(input.path) ?? inferProtocolHintFromDeviceName(deviceName);
265
+ if (protocolHint) {
266
+ this.deviceProtocolHints.set(input.path, protocolHint);
267
+ }
268
+ await this.detectProtocol(input.path, input.expectedProtocol, protocolHint);
136
269
  return await Promise.resolve(input.path);
137
270
  } catch (e) {
138
271
  this.Log.debug('acquire error: ', e instanceof Error ? `${e.name}: ${e.message}` : String(e));
@@ -140,6 +273,67 @@ export default class WebUsbTransport {
140
273
  }
141
274
  }
142
275
 
276
+ /**
277
+ * Determine protocol type after connect.
278
+ * Probe Protocol V1 first with Initialize. If it does not answer in time,
279
+ * fall back to a Protocol V2 Ping probe.
280
+ */
281
+ private createProtocolMismatchError(expected: ProtocolType) {
282
+ return ERRORS.TypedError(
283
+ HardwareErrorCode.RuntimeError,
284
+ `Device protocol mismatch: expected ${expected}, but device did not respond to expected protocol`
285
+ );
286
+ }
287
+
288
+ private createProtocolDetectionError() {
289
+ return ERRORS.TypedError(
290
+ HardwareErrorCode.RuntimeError,
291
+ 'Unable to detect USB protocol: device did not respond to Protocol V1 Initialize or Protocol V2 Ping'
292
+ );
293
+ }
294
+
295
+ private async detectProtocol(
296
+ path: string,
297
+ expectedProtocol?: ProtocolType,
298
+ protocolHint?: ProtocolType
299
+ ): Promise<ProtocolType> {
300
+ if (expectedProtocol === 'V1') {
301
+ if (await this.probeProtocolV1(path)) {
302
+ this.deviceProtocol.set(path, 'V1');
303
+ this.Log.debug(`[WebUsbTransport] detectProtocol: path=${path} -> V1 (expected)`);
304
+ return 'V1';
305
+ }
306
+ throw this.createProtocolMismatchError(expectedProtocol);
307
+ }
308
+
309
+ if (expectedProtocol === 'V2') {
310
+ // 免探测路径:调用方显式承诺该设备是 V2(例如固件升级重启后的重连场景,
311
+ // 上层已经探测过协议并通过 expectedProtocol 传回),这里不再重复探测。
312
+ this.deviceProtocol.set(path, 'V2');
313
+ this.Log.debug(`[WebUsbTransport] detectProtocol: path=${path} -> V2 (expected)`);
314
+ return 'V2';
315
+ }
316
+
317
+ // 项目约束:协议判断必须在连接后主动探测,不能依赖设备名/PID/descriptor。
318
+ // 设备名 hint(如 "Pro 2")只用于调整探测顺序:hint=V2 时先探 V2、失败回落 V1,
319
+ // 不能作为最终结论。
320
+ const probeOrder: ProtocolType[] =
321
+ protocolHint === 'V2' || this.deviceProtocol.get(path) === 'V2' ? ['V2', 'V1'] : ['V1', 'V2'];
322
+
323
+ for (const protocol of probeOrder) {
324
+ const detected =
325
+ protocol === 'V1' ? await this.probeProtocolV1(path) : await this.probeProtocolV2(path);
326
+ if (detected) {
327
+ this.deviceProtocol.set(path, protocol);
328
+ this.Log.debug(`[WebUsbTransport] detectProtocol: path=${path} -> ${protocol}`);
329
+ return protocol;
330
+ }
331
+ }
332
+
333
+ this.deviceProtocol.delete(path);
334
+ throw this.createProtocolDetectionError();
335
+ }
336
+
143
337
  /**
144
338
  * Find device by path
145
339
  */
@@ -182,22 +376,116 @@ export default class WebUsbTransport {
182
376
  }
183
377
 
184
378
  /**
185
- * Connect to specific device
379
+ * Discover vendor-class (0xFF) interface and its IN/OUT endpoint numbers from USB descriptors.
380
+ * Falls back to legacy hardcoded values if no vendor interface is found.
381
+ */
382
+ private discoverEndpoints(device: USBDevice): DeviceEndpoints {
383
+ for (const config of device.configurations) {
384
+ for (const iface of config.interfaces) {
385
+ for (const alt of iface.alternates) {
386
+ if (alt.interfaceClass === 0xff) {
387
+ let endpointIn = this.endpointId;
388
+ let endpointOut = this.endpointId;
389
+ for (const ep of alt.endpoints) {
390
+ if (ep.direction === 'in') endpointIn = ep.endpointNumber;
391
+ else endpointOut = ep.endpointNumber;
392
+ }
393
+ this.Log?.debug(
394
+ `[WebUsbTransport] discovered vendor interface ${iface.interfaceNumber}, ` +
395
+ `endpointIn=${endpointIn}, endpointOut=${endpointOut}`
396
+ );
397
+ return { interfaceNumber: iface.interfaceNumber, endpointIn, endpointOut };
398
+ }
399
+ }
400
+ }
401
+ }
402
+ // Fallback: legacy hardcoded values
403
+ this.Log?.debug('[WebUsbTransport] no vendor interface found, using defaults');
404
+ return {
405
+ interfaceNumber: this.interfaceId,
406
+ endpointIn: this.endpointId,
407
+ endpointOut: this.endpointId,
408
+ };
409
+ }
410
+
411
+ /**
412
+ * Connect to specific device.
413
+ * Discovers interface/endpoint numbers from USB descriptors on first connection.
186
414
  */
187
415
  async connectToDevice(path: string, first: boolean) {
188
- const device: USBDevice = await this.findDevice(path);
189
- await device.open();
416
+ let device: USBDevice = await this.findDevice(path);
417
+ this.Log.debug(
418
+ '[WebUsbTransport] connecting to device:',
419
+ device.productName,
420
+ 'PID:',
421
+ device.productId
422
+ );
190
423
 
191
- if (first) {
424
+ if (!device.opened) {
425
+ await device.open();
426
+ }
427
+ try {
428
+ await device.reset();
429
+ } catch (error) {
430
+ this.Log?.debug('[WebUsbTransport] reset before claim failed, continuing:', error);
431
+ }
432
+ await this.getConnectedDevices();
433
+ device = await this.findDevice(path);
434
+ if (!device.opened) {
435
+ await device.open();
436
+ }
437
+
438
+ if (
439
+ first ||
440
+ !device.configuration ||
441
+ device.configuration.configurationValue !== this.configurationId
442
+ ) {
192
443
  await device.selectConfiguration(this.configurationId);
193
- try {
194
- await device.reset();
195
- } catch (error) {
196
- // Ignore reset errors
197
- }
198
444
  }
199
445
 
200
- await device.claimInterface(this.interfaceId);
446
+ // Discover endpoints from USB descriptors; descriptors are not used for protocol selection.
447
+ const endpoints = this.discoverEndpoints(device);
448
+ this.deviceEndpoints.set(path, endpoints);
449
+ this.protocolV2Assemblers.get(path)?.reset();
450
+ this.protocolV2Assemblers.set(path, new ProtocolV2FrameAssembler(PROTOCOL_V2_FRAME_MAX_BYTES));
451
+
452
+ await device.claimInterface(endpoints.interfaceNumber);
453
+ await this.clearEndpointHalt(device, 'in', endpoints.endpointIn);
454
+ await this.clearEndpointHalt(device, 'out', endpoints.endpointOut);
455
+ }
456
+
457
+ private async closeOpenDevice(path: string) {
458
+ this.protocolV2Assemblers.get(path)?.reset();
459
+ const current = this.deviceList.find(device => device.path === path)?.device;
460
+ if (!current?.opened) return;
461
+
462
+ const endpoints = this.deviceEndpoints.get(path);
463
+ const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
464
+ try {
465
+ await current.releaseInterface(ifaceNum);
466
+ } catch (error) {
467
+ this.Log?.debug('[WebUsbTransport] releaseInterface before reconnect failed:', error);
468
+ }
469
+ try {
470
+ await current.close();
471
+ } catch (error) {
472
+ this.Log?.debug('[WebUsbTransport] close before reconnect failed:', error);
473
+ }
474
+ }
475
+
476
+ private async clearEndpointHalt(
477
+ device: USBDevice,
478
+ direction: USBDirection,
479
+ endpointNumber: number
480
+ ) {
481
+ try {
482
+ await device.clearHalt(direction, endpointNumber);
483
+ } catch (error) {
484
+ this.Log?.debug(
485
+ `[WebUsbTransport] clearHalt ${direction} endpoint ${endpointNumber} failed, continuing:`,
486
+ error
487
+ );
488
+ }
201
489
  }
202
490
 
203
491
  async post(session: string, name: string, data: Record<string, unknown>) {
@@ -243,8 +531,10 @@ export default class WebUsbTransport {
243
531
  try {
244
532
  const currentDevice = await this.findDevice(path);
245
533
  if (currentDevice.opened) {
534
+ const endpoints = this.deviceEndpoints.get(path);
535
+ const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
246
536
  try {
247
- await currentDevice.releaseInterface(this.interfaceId);
537
+ await currentDevice.releaseInterface(ifaceNum);
248
538
  } catch (releaseError) {
249
539
  this.Log.debug('[WebUsbTransport] releaseInterface before retry error:', releaseError);
250
540
  }
@@ -285,10 +575,12 @@ export default class WebUsbTransport {
285
575
  if (!device.opened) {
286
576
  await this.connect(path, false);
287
577
  }
578
+ const endpoints = this.deviceEndpoints.get(path);
579
+ const endpointOut = endpoints?.endpointOut ?? this.endpointId;
288
580
  const transferBuffer = this.toArrayBuffer(
289
581
  packet.buffer.slice(packet.byteOffset, packet.byteOffset + packet.byteLength)
290
582
  );
291
- await device.transferOut(this.endpointId, transferBuffer);
583
+ await device.transferOut(endpointOut, transferBuffer);
292
584
  return;
293
585
  } catch (error) {
294
586
  lastError = error;
@@ -311,18 +603,30 @@ export default class WebUsbTransport {
311
603
  throw lastError;
312
604
  }
313
605
 
314
- private async transferInWithRetry(path: string, length: number): Promise<DataView> {
606
+ private async transferInWithRetry(
607
+ path: string,
608
+ length: number,
609
+ cancelToken?: TransferCancelToken
610
+ ): Promise<DataView> {
315
611
  let lastError: unknown;
316
612
  for (let attempt = 1; attempt <= PACKET_IO_MAX_RETRIES; attempt += 1) {
613
+ if (cancelToken?.cancelled) {
614
+ throw new Error('transferIn cancelled');
615
+ }
317
616
  try {
318
617
  const device = await this.findDevice(path);
319
618
  if (!device.opened) {
320
619
  await this.connect(path, false);
321
620
  }
322
- const result = await device.transferIn(this.endpointId, length);
621
+ const endpoints = this.deviceEndpoints.get(path);
622
+ const endpointIn = endpoints?.endpointIn ?? this.endpointId;
623
+ const result = await device.transferIn(endpointIn, length);
323
624
  return this.getTransferInData(result);
324
625
  } catch (error) {
325
626
  lastError = error;
627
+ if (cancelToken?.cancelled) {
628
+ throw error;
629
+ }
326
630
  const shouldRetry = attempt < PACKET_IO_MAX_RETRIES && this.isRetryablePacketIoError(error);
327
631
  if (!shouldRetry) {
328
632
  throw error;
@@ -342,10 +646,104 @@ export default class WebUsbTransport {
342
646
  throw lastError;
343
647
  }
344
648
 
649
+ private async resetConnectionAfterProbe(path: string) {
650
+ this.protocolV2Assemblers.get(path)?.reset();
651
+ this.protocolV2Sessions.delete(path);
652
+ this.protocolV2ReadTimeouts.delete(path);
653
+
654
+ try {
655
+ const device = await this.findDevice(path);
656
+ if (device.opened) {
657
+ const endpoints = this.deviceEndpoints.get(path);
658
+ const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
659
+ try {
660
+ await device.releaseInterface(ifaceNum);
661
+ } catch (error) {
662
+ this.Log.debug('[WebUsbTransport] releaseInterface after protocol probe error:', error);
663
+ }
664
+ await device.close();
665
+ }
666
+ } catch (error) {
667
+ this.Log.debug('[WebUsbTransport] close after protocol probe error:', error);
668
+ }
669
+
670
+ await this.getConnectedDevices();
671
+ await this.connect(path, false);
672
+ }
673
+
674
+ private async withProtocolReadTimeout<T>(
675
+ path: string,
676
+ promise: Promise<T>,
677
+ timeoutMs: number,
678
+ protocol: ProtocolType,
679
+ onTimeout?: () => void
680
+ ): Promise<T> {
681
+ void path;
682
+ let timer: ReturnType<typeof setTimeout> | undefined;
683
+ let timedOut = false;
684
+ const waitForeverAfterTimeout = () => new Promise<never>(() => {});
685
+ const guardedPromise = promise.then(
686
+ value => (timedOut ? waitForeverAfterTimeout() : value),
687
+ error => {
688
+ if (timedOut) {
689
+ return waitForeverAfterTimeout();
690
+ }
691
+ throw error;
692
+ }
693
+ );
694
+ try {
695
+ return await Promise.race([
696
+ guardedPromise,
697
+ new Promise<never>((_, reject) => {
698
+ timer = setTimeout(async () => {
699
+ timedOut = true;
700
+ onTimeout?.();
701
+ reject(new Error(`Protocol ${protocol} read timeout after ${timeoutMs}ms`));
702
+ }, timeoutMs);
703
+ }),
704
+ ]);
705
+ } finally {
706
+ if (timer) clearTimeout(timer);
707
+ }
708
+ }
709
+
710
+ private async probeProtocolV1(path: string) {
711
+ if (!this.messages) {
712
+ return false;
713
+ }
714
+
715
+ try {
716
+ await this.callProtocolV1(path, 'Initialize', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT });
717
+ return true;
718
+ } catch (error) {
719
+ this.Log.debug('[WebUsbTransport] Protocol V1 Initialize probe failed:', error);
720
+ return false;
721
+ }
722
+ }
723
+
724
+ private async probeProtocolV2(path: string) {
725
+ if (!this.messages || !this.messagesV2) {
726
+ return false;
727
+ }
728
+
729
+ return probeProtocolV2Helper({
730
+ call: (name, data, options) => this.callProtocolV2(path, name, data, options),
731
+ timeoutMs: PROTOCOL_PROBE_TIMEOUT,
732
+ logger: this.Log,
733
+ logPrefix: 'ProtocolV2 WebUSB',
734
+ onProbeFailed: () => this.resetConnectionAfterProbe(path),
735
+ });
736
+ }
737
+
345
738
  /**
346
- * Call device method
739
+ * Call device method — branches to Protocol V1 or Protocol V2 based on active probe.
347
740
  */
348
- async call(path: string, name: string, data: Record<string, unknown>) {
741
+ async call(
742
+ path: string,
743
+ name: string,
744
+ data: Record<string, unknown>,
745
+ options?: TransportCallOptions
746
+ ) {
349
747
  if (this.messages == null) {
350
748
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
351
749
  }
@@ -355,37 +753,172 @@ export default class WebUsbTransport {
355
753
  throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound);
356
754
  }
357
755
 
358
- const { messages } = this;
359
- if (LogBlockCommand.has(name)) {
360
- this.Log.debug('call-', ' name: ', name);
756
+ const protocol = this.deviceProtocol.get(path);
757
+ if (!protocol) {
758
+ throw ERRORS.TypedError(
759
+ HardwareErrorCode.RuntimeError,
760
+ `Device protocol has not been detected for ${path}`
761
+ );
762
+ }
763
+
764
+ if (shouldSuppressWebUsbCallLog(name)) {
765
+ // 高频文件写入不要逐包发 debug 事件,否则浏览器侧会被日志处理拖慢。
766
+ } else if (shouldBlockWebUsbCallDataLog(name)) {
767
+ this.Log.debug('call-', ' name: ', name, ' protocol: ', protocol);
361
768
  } else {
362
- this.Log.debug('call-', ' name: ', name, ' data: ', data);
769
+ this.Log.debug('call-', ' name: ', name, ' data: ', data, ' protocol: ', protocol);
770
+ }
771
+
772
+ if (protocol === 'V2') {
773
+ return this.callProtocolV2(path, name, data, options);
774
+ }
775
+
776
+ return this.callProtocolV1(path, name, data, options);
777
+ }
778
+
779
+ private async callProtocolV1(
780
+ path: string,
781
+ name: string,
782
+ data: Record<string, unknown>,
783
+ options?: TransportCallOptions
784
+ ) {
785
+ const { messages } = this;
786
+ if (!messages) {
787
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
363
788
  }
364
- const encodeBuffers = buildEncodeBuffers(messages, name, data);
789
+
790
+ const encodeBuffers = ProtocolV1.encodeMessageChunks(messages, name, data);
365
791
 
366
792
  for (const buffer of encodeBuffers) {
367
793
  const newArray: Uint8Array = new Uint8Array(PACKET_SIZE);
368
- newArray[0] = 63;
794
+ newArray[0] = REPORT_ID;
369
795
  newArray.set(new Uint8Array(buffer), 1);
370
- // console.log('send packet: ', newArray);
371
796
  await this.transferOutWithRetry(path, newArray);
372
797
  }
373
798
 
374
- const resData = await this.receiveData(path);
799
+ const resData = await this.receiveData(path, options?.timeoutMs);
375
800
  if (typeof resData !== 'string') {
376
801
  throw ERRORS.TypedError(HardwareErrorCode.NetworkError, 'Returning data is not string.');
377
802
  }
378
- const jsonData = receiveOne(messages, resData);
803
+ const jsonData = ProtocolV1.decodeMessage(messages, resData);
379
804
  return check.call(jsonData);
380
805
  }
381
806
 
807
+ /**
808
+ * Send/receive a single call over Protocol V2 (0x5A framing).
809
+ *
810
+ * Encoding: protobuf message → 2-byte LE messageTypeId + pb bytes → Protocol V2 frame
811
+ * Decoding: Protocol V2 frame → messageTypeId + pb bytes → protobuf message
812
+ */
813
+ private async callProtocolV2(
814
+ path: string,
815
+ name: string,
816
+ data: Record<string, unknown>,
817
+ options?: TransportCallOptions
818
+ ) {
819
+ const protocolV1Messages = this.messages;
820
+ if (!this.messagesV2) {
821
+ throw ERRORS.TypedError(
822
+ HardwareErrorCode.TransportNotConfigured,
823
+ 'Protocol V2 schema not configured'
824
+ );
825
+ }
826
+ if (!protocolV1Messages) {
827
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
828
+ }
829
+
830
+ let session = this.protocolV2Sessions.get(path);
831
+ if (!session) {
832
+ session = new ProtocolV2Session({
833
+ schemas: {
834
+ protocolV1: protocolV1Messages,
835
+ protocolV2: this.messagesV2,
836
+ },
837
+ router: PROTOCOL_V2_CHANNEL_USB,
838
+ writeFrame: (frame: Uint8Array) => this.transferOutWithRetry(path, frame),
839
+ readFrame: () => this.receiveProtocolV2Frame(path, this.protocolV2ReadTimeouts.get(path)),
840
+ logger: this.Log,
841
+ logPrefix: 'ProtocolV2 WebUSB',
842
+ createTimeoutError: (messageName: string, timeoutMs: number) =>
843
+ new Error(`Protocol V2 response timeout after ${timeoutMs}ms for ${messageName}`),
844
+ });
845
+ this.protocolV2Sessions.set(path, session);
846
+ }
847
+
848
+ this.protocolV2ReadTimeouts.set(path, options?.timeoutMs);
849
+ this.protocolV2Assemblers.get(path)?.reset();
850
+ try {
851
+ return await session.call(name, data, options);
852
+ } finally {
853
+ this.protocolV2ReadTimeouts.delete(path);
854
+ }
855
+ }
856
+
857
+ private async receiveProtocolV2Frame(path: string, timeoutMs?: number): Promise<Uint8Array> {
858
+ let assembler = this.protocolV2Assemblers.get(path);
859
+ if (!assembler) {
860
+ assembler = new ProtocolV2FrameAssembler(PROTOCOL_V2_FRAME_MAX_BYTES);
861
+ this.protocolV2Assemblers.set(path, assembler);
862
+ }
863
+
864
+ let frame: Uint8Array | undefined = assembler.push(new Uint8Array(0));
865
+ const deadline = timeoutMs ? Date.now() + timeoutMs : undefined;
866
+
867
+ while (!frame) {
868
+ const cancelToken = { cancelled: false };
869
+ const transferIn = this.transferInWithRetry(path, PROTOCOL_V2_FRAME_MAX_BYTES, cancelToken);
870
+ const dataView = deadline
871
+ ? await this.withProtocolReadTimeout(
872
+ path,
873
+ transferIn,
874
+ Math.max(deadline - Date.now(), 1),
875
+ 'V2',
876
+ () => {
877
+ cancelToken.cancelled = true;
878
+ }
879
+ )
880
+ : await transferIn;
881
+ const bytes = new Uint8Array(
882
+ this.toArrayBuffer(
883
+ dataView.buffer.slice(dataView.byteOffset, dataView.byteOffset + dataView.byteLength)
884
+ )
885
+ );
886
+ try {
887
+ frame = assembler.push(bytes);
888
+ } catch (error) {
889
+ throw ERRORS.TypedError(
890
+ HardwareErrorCode.NetworkError,
891
+ error instanceof Error ? error.message : String(error)
892
+ );
893
+ }
894
+ }
895
+ return frame;
896
+ }
897
+
382
898
  /**
383
899
  * Receive data from device
384
900
  */
385
- async receiveData(path: string) {
386
- const firstPacketData = await this.transferInWithRetry(path, PACKET_SIZE);
901
+ async receiveData(path: string, timeoutMs?: number) {
902
+ const deadline = timeoutMs ? Date.now() + timeoutMs : undefined;
903
+ const readPacket = async () => {
904
+ const cancelToken = { cancelled: false };
905
+ const transferIn = this.transferInWithRetry(path, PACKET_SIZE, cancelToken);
906
+ return deadline
907
+ ? this.withProtocolReadTimeout(
908
+ path,
909
+ transferIn,
910
+ Math.max(deadline - Date.now(), 1),
911
+ 'V1',
912
+ () => {
913
+ cancelToken.cancelled = true;
914
+ }
915
+ )
916
+ : transferIn;
917
+ };
918
+
919
+ const firstPacketData = await readPacket();
387
920
  const firstData = this.toArrayBuffer(firstPacketData.buffer.slice(1));
388
- const { length, typeId, restBuffer } = decodeProtocol.decodeChunked(firstData);
921
+ const { length, typeId, restBuffer } = ProtocolV1.decodeFirstChunk(firstData);
389
922
 
390
923
  // eslint-disable-next-line @typescript-eslint/restrict-plus-operands
391
924
  const lengthWithHeader = Number(length + HEADER_LENGTH);
@@ -397,9 +930,9 @@ export default class WebUsbTransport {
397
930
  }
398
931
 
399
932
  while (decoded.offset < lengthWithHeader) {
400
- const packetData = await this.transferInWithRetry(path, PACKET_SIZE);
933
+ const packetData = await readPacket();
401
934
  const buffer = this.toArrayBuffer(packetData.buffer.slice(1));
402
- if (lengthWithHeader - decoded.offset >= PACKET_SIZE) {
935
+ if (lengthWithHeader - decoded.offset >= PAYLOAD_SIZE) {
403
936
  decoded.append(buffer);
404
937
  } else {
405
938
  decoded.append(buffer.slice(0, lengthWithHeader - decoded.offset));
@@ -415,7 +948,22 @@ export default class WebUsbTransport {
415
948
  */
416
949
  async release(path: string) {
417
950
  const device: USBDevice = await this.findDevice(path);
418
- await device.releaseInterface(this.interfaceId);
951
+ const endpoints = this.deviceEndpoints.get(path);
952
+ const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
953
+ await device.releaseInterface(ifaceNum);
419
954
  await device.close();
955
+ this.deviceProtocol.delete(path);
956
+ this.deviceProtocolHints.delete(path);
957
+ this.protocolV2Assemblers.get(path)?.reset();
958
+ this.protocolV2Assemblers.delete(path);
959
+ this.deviceEndpoints.delete(path);
960
+ }
961
+
962
+ /**
963
+ * Expose the detected protocol type for a given device path.
964
+ * Used by upper layers (e.g. TransportManager) to select the correct schema.
965
+ */
966
+ getProtocolType(path: string): ProtocolType | undefined {
967
+ return this.deviceProtocol.get(path);
420
968
  }
421
969
  }