@onekeyfe/hd-transport-web-device 1.2.0-alpha.16 → 1.2.0-alpha.161

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
@@ -6,25 +6,29 @@ import transport, {
6
6
  PROTOCOL_V1_USB_PACKET_SIZE,
7
7
  PROTOCOL_V2_CHANNEL_USB,
8
8
  PROTOCOL_V2_FRAME_MAX_BYTES,
9
- ProtocolV2FrameAssembler,
10
- ProtocolV2Session,
9
+ ProtocolV2LinkError,
10
+ ProtocolV2UsbTransportBase,
11
+ TRANSPORT_EVENT,
11
12
  probeProtocolV2 as probeProtocolV2Helper,
12
13
  } from '@onekeyfe/hd-transport';
13
14
  import {
14
15
  ERRORS,
15
16
  HardwareErrorCode,
16
17
  ONEKEY_WEBUSB_FILTER,
18
+ inferProtocolHintFromUsbId,
17
19
  isKnownTrezorWebUsbDevice,
20
+ resolveOneKeyUsbDevicePath,
18
21
  wait,
19
22
  } from '@onekeyfe/hd-shared';
20
23
  import ByteBuffer from 'bytebuffer';
21
24
 
22
- import { createTransportCallLog, shouldSuppressHighVolumeCallLog } from './transportLog';
23
-
25
+ import type EventEmitter from 'events';
24
26
  import type {
25
27
  AcquireInput,
26
28
  OneKeyDeviceInfoBase,
27
29
  ProtocolType,
30
+ ProtocolV2CallContext,
31
+ ProtocolV2Schemas,
28
32
  TransportCallOptions,
29
33
  } from '@onekeyfe/hd-transport';
30
34
 
@@ -39,10 +43,12 @@ const REPORT_ID = PROTOCOL_V1_REPORT_ID;
39
43
  const HEADER_LENGTH = PROTOCOL_V1_MESSAGE_HEADER_SIZE;
40
44
  const PACKET_IO_MAX_RETRIES = 3;
41
45
  const PACKET_IO_RETRY_DELAY = 300;
42
- const PROTOCOL_PROBE_TIMEOUT = 1000;
43
- function inferProtocolHintFromDeviceName(name?: string | null): ProtocolType | undefined {
44
- return /\bpro\s*2\b/i.test(name ?? '') ? 'V2' : undefined;
45
- }
46
+ // Legacy devices can take longer to answer Initialize after a WebUSB reset.
47
+ // Keep this aligned with Node USB so a slow Protocol V1 response is not
48
+ // misreported as a protocol mismatch during acquire.
49
+ const PROTOCOL_V1_PROBE_TIMEOUT = 5000;
50
+ const PROTOCOL_V2_PROBE_TIMEOUT = 1000;
51
+ const EXPECTED_PROTOCOL_V2_PROBE_ATTEMPTS = 2;
46
52
 
47
53
  /**
48
54
  * Device information with path and WebUSB device instance
@@ -64,37 +70,65 @@ interface TransferCancelToken {
64
70
  cancelled: boolean;
65
71
  }
66
72
 
67
- export default class WebUsbTransport {
73
+ /**
74
+ * The navigator.usb disconnect listener is module-scoped and attached at most
75
+ * once: listeners on navigator.usb are global and never garbage collected, so a
76
+ * per-instance listener would retain every transport instance created across
77
+ * SDK re-initializations. Events are routed to the most recently initialized
78
+ * transport instance instead.
79
+ */
80
+ let activeWebUsbTransport: WebUsbTransport | undefined;
81
+ let usbDisconnectListenerAttached = false;
82
+
83
+ function registerActiveWebUsbTransport(instance: WebUsbTransport, usb: USB) {
84
+ activeWebUsbTransport = instance;
85
+ if (usbDisconnectListenerAttached) return;
86
+ usbDisconnectListenerAttached = true;
87
+ usb.addEventListener('disconnect', event => {
88
+ if (!event.device) return;
89
+ const path = resolveOneKeyUsbDevicePath(event.device);
90
+ if (!path) return;
91
+ activeWebUsbTransport?.markProtocolStale(path);
92
+ // WebUSB has no device-list poller behind it, so this event is the only
93
+ // signal that the device is gone. Without it consumers never see a
94
+ // DEVICE.DISCONNECT for USB (OK-60486).
95
+ activeWebUsbTransport?.emitDeviceDisconnect(path, event.device);
96
+ });
97
+ }
98
+
99
+ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string> {
68
100
  messages: ReturnType<typeof transport.parseConfigure> | undefined;
69
101
 
70
102
  /** Protobuf schema for Protocol V2 transports. */
71
103
  messagesV2: ReturnType<typeof transport.parseConfigure> | undefined;
72
104
 
105
+ private protocolV2SchemaSource: string | undefined;
106
+
73
107
  /** Per-path protocol type detected by active wire-level probe. */
74
108
  private deviceProtocol: Map<string, ProtocolType> = new Map();
75
109
 
76
110
  private deviceProtocolHints: Map<string, ProtocolType> = new Map();
77
111
 
78
- /** 按设备缓存 Protocol V2 frame assembler,保留同一次读取里多出来的后续 frame。 */
79
- private protocolV2Assemblers: Map<string, ProtocolV2FrameAssembler> = new Map();
80
-
81
- /** 按设备缓存 Protocol V2 session,保持 seq 与设备会话一致递增。 */
82
- private protocolV2Sessions: Map<string, ProtocolV2Session> = new Map();
83
-
84
- /** 当前 Protocol V2 调用的读取超时,由缓存 session 的 readFrame 闭包读取。 */
85
- private protocolV2ReadTimeouts: Map<string, number | undefined> = new Map();
112
+ /**
113
+ * Paths whose cached protocol must be re-probed on the next acquire (a USB
114
+ * disconnect was seen, or a transfer-level reconnect happened mid-call).
115
+ */
116
+ private staleProtocolPaths: Set<string> = new Set();
86
117
 
87
- /** Per-path USB endpoint / interface numbers (discovered from USB descriptors) */
88
- private deviceEndpoints: Map<string, DeviceEndpoints> = new Map();
118
+ /** Paths currently acquired (between a successful acquire() and its release()). */
119
+ private acquiredPaths: Set<string> = new Set();
89
120
 
90
121
  /**
91
- * 早期 Pro2 工程板 USB descriptor 没有烧录 serial number。
92
- * 为这类设备生成会话内稳定的 mock path(同一 USBDevice 实例复用同一 path),
93
- * 避免设备因为空 serial 被发现流程整体丢弃。重新插拔后实例变化,path 会重新生成。
122
+ * The exact USBDevice object each cached protocol was probed against. The
123
+ * browser returns the same object identity for a device as long as it stays
124
+ * connected, and a replug/reboot always yields a new object — so an identity
125
+ * mismatch proves the device was re-enumerated since the probe, even when the
126
+ * disconnect event itself was delayed or missed.
94
127
  */
95
- private mockSerialPaths: WeakMap<USBDevice, string> = new WeakMap();
128
+ private probedDeviceObjects: Map<string, USBDevice> = new Map();
96
129
 
97
- private mockSerialCounter = 0;
130
+ /** Per-path USB endpoint / interface numbers (discovered from USB descriptors) */
131
+ private deviceEndpoints: Map<string, DeviceEndpoints> = new Map();
98
132
 
99
133
  name = 'WebUsbTransport';
100
134
 
@@ -106,6 +140,8 @@ export default class WebUsbTransport {
106
140
 
107
141
  usb?: USB;
108
142
 
143
+ emitter?: EventEmitter;
144
+
109
145
  /**
110
146
  * Cached list of connected devices
111
147
  * This is essential for maintaining device references between operations
@@ -118,11 +154,20 @@ export default class WebUsbTransport {
118
154
 
119
155
  interfaceId = INTERFACE_ID;
120
156
 
157
+ constructor() {
158
+ super({
159
+ router: PROTOCOL_V2_CHANNEL_USB,
160
+ maxFrameBytes: PROTOCOL_V2_FRAME_MAX_BYTES,
161
+ logPrefix: 'ProtocolV2 WebUSB',
162
+ });
163
+ }
164
+
121
165
  /**
122
166
  * Initialize WebUSB transport
123
167
  */
124
- init(logger: any) {
168
+ init(logger: any, emitter?: EventEmitter) {
125
169
  this.Log = logger;
170
+ this.emitter = emitter;
126
171
 
127
172
  const { usb } = navigator;
128
173
  if (!usb) {
@@ -132,6 +177,34 @@ export default class WebUsbTransport {
132
177
  );
133
178
  }
134
179
  this.usb = usb;
180
+ registerActiveWebUsbTransport(this, usb);
181
+ }
182
+
183
+ /**
184
+ * Announce that a USB device left. Called from the module-scoped disconnect
185
+ * listener, which is why it is public rather than inlined.
186
+ */
187
+ emitDeviceDisconnect(path: string, device?: USBDevice) {
188
+ this.emitter?.emit(TRANSPORT_EVENT.DEVICE_DISCONNECT, {
189
+ name: device?.productName ?? '',
190
+ id: path,
191
+ connectId: path,
192
+ });
193
+ }
194
+
195
+ /**
196
+ * Protocol type is a property of the physical device keyed by USB serial number.
197
+ * It can only change across a device reboot (e.g. normal ↔ bootloader mode), and
198
+ * a reboot always surfaces as a USB disconnect. Disconnects (and transfer-level
199
+ * reconnects, which cover a missed disconnect event) only MARK the cached probe
200
+ * result stale instead of deleting it: an in-flight session keeps using the old
201
+ * value so the transfer-level reconnect retries can absorb a transient
202
+ * re-enumeration exactly as they did before the cache existed, while the next
203
+ * acquire re-probes from scratch.
204
+ */
205
+ markProtocolStale(path: string) {
206
+ this.staleProtocolPaths.add(path);
207
+ this.probedDeviceObjects.delete(path);
135
208
  }
136
209
 
137
210
  /**
@@ -147,9 +220,20 @@ export default class WebUsbTransport {
147
220
  * Cache the Protocol V2 protobuf schema.
148
221
  */
149
222
  configureProtocolV2(signedData: any) {
223
+ const schemaSource =
224
+ typeof signedData === 'string'
225
+ ? signedData
226
+ : JSON.stringify(signedData) ?? String(signedData);
227
+ if (schemaSource === this.protocolV2SchemaSource) return;
228
+
229
+ const hadProtocolV2Schema = this.protocolV2SchemaSource !== undefined;
150
230
  this.messagesV2 = parseConfigure(signedData);
151
- this.protocolV2Sessions.clear();
152
- this.protocolV2ReadTimeouts.clear();
231
+ this.protocolV2SchemaSource = schemaSource;
232
+ if (!hadProtocolV2Schema) return;
233
+
234
+ this.invalidateAllProtocolV2UsbLinks('Protocol V2 schema reconfigured').catch(error =>
235
+ this.Log?.debug('[WebUsbTransport] schema link cleanup failed:', error)
236
+ );
153
237
  }
154
238
 
155
239
  /**
@@ -181,26 +265,6 @@ export default class WebUsbTransport {
181
265
  return this.deviceList;
182
266
  }
183
267
 
184
- /**
185
- * 设备 path:正常设备直接用 USB serial number;
186
- * 空 serial(早期工程板)回退到会话内稳定的 mock path。
187
- */
188
- private getDevicePath(device: USBDevice): string {
189
- if (typeof device.serialNumber === 'string' && device.serialNumber.length > 0) {
190
- return device.serialNumber;
191
- }
192
- let path = this.mockSerialPaths.get(device);
193
- if (!path) {
194
- this.mockSerialCounter += 1;
195
- path = `mock-serial:${device.vendorId.toString(16)}:${device.productId.toString(16)}:${
196
- this.mockSerialCounter
197
- }`;
198
- this.mockSerialPaths.set(device, path);
199
- this.Log?.debug(`[WebUSB] device has no serial number, using mock path: ${path}`);
200
- }
201
- return path;
202
- }
203
-
204
268
  /**
205
269
  * Get list of connected devices
206
270
  */
@@ -216,18 +280,21 @@ export default class WebUsbTransport {
216
280
  return isOneKey && !isKnownTrezorWebUsbDevice(dev);
217
281
  });
218
282
 
219
- this.deviceList = onekeyDevices.map(device => {
220
- const path = this.getDevicePath(device);
221
- const protocolHint = inferProtocolHintFromDeviceName(device.productName);
283
+ this.deviceList = onekeyDevices.flatMap(device => {
284
+ const path = resolveOneKeyUsbDevicePath(device);
285
+ if (!path) return [];
286
+ const protocolHint = inferProtocolHintFromUsbId(device.vendorId, device.productId);
222
287
  if (protocolHint) {
223
288
  this.deviceProtocolHints.set(path, protocolHint);
224
289
  }
225
290
 
226
- return {
227
- path,
228
- device,
229
- commType: 'webusb',
230
- };
291
+ return [
292
+ {
293
+ path,
294
+ device,
295
+ commType: 'webusb',
296
+ },
297
+ ];
231
298
  });
232
299
 
233
300
  return this.deviceList;
@@ -239,20 +306,60 @@ export default class WebUsbTransport {
239
306
  async acquire(input: AcquireInput) {
240
307
  if (!input.path) return;
241
308
  try {
309
+ await this.rotateProtocolV2UsbGeneration(input.path, 'WebUSB transport acquired');
242
310
  await this.closeOpenDevice(input.path);
243
311
  await this.connect(input.path ?? '', true);
244
- const deviceName = this.deviceList.find(device => device.path === input.path)?.device
245
- .productName;
246
- const protocolHint = input.expectedProtocol
247
- ? undefined
248
- : this.deviceProtocolHints.get(input.path) ?? inferProtocolHintFromDeviceName(deviceName);
249
- if (protocolHint) {
250
- this.deviceProtocolHints.set(input.path, protocolHint);
312
+ if (input.forceProtocolDetection) {
313
+ // Explicit recovery/discovery (e.g. detectDeviceConnectProtocol) must
314
+ // probe on the wire regardless of any cached result — the cached
315
+ // binding may be exactly what the caller is trying to recover from.
316
+ this.staleProtocolPaths.delete(input.path);
317
+ this.deviceProtocol.delete(input.path);
318
+ this.deviceProtocolHints.delete(input.path);
319
+ }
320
+ if (this.staleProtocolPaths.has(input.path)) {
321
+ // The device disconnected (possibly rebooting into another mode) since
322
+ // the protocol was probed — drop the cache so it is re-probed below.
323
+ this.staleProtocolPaths.delete(input.path);
324
+ this.deviceProtocol.delete(input.path);
325
+ }
326
+ if (this.deviceProtocol.has(input.path)) {
327
+ const currentDevice = this.deviceList.find(d => d.path === input.path)?.device;
328
+ if (!currentDevice || currentDevice !== this.probedDeviceObjects.get(input.path)) {
329
+ // The OS re-enumerated the device since the probe (a replug/reboot we
330
+ // may not have seen a disconnect event for) — the cached protocol can
331
+ // no longer be trusted; re-probe on the wire below.
332
+ this.deviceProtocol.delete(input.path);
333
+ }
251
334
  }
252
- await this.detectProtocol(input.path, input.expectedProtocol, protocolHint);
335
+ const cachedProtocol = this.deviceProtocol.get(input.path);
336
+ if (cachedProtocol && input.expectedProtocol && cachedProtocol !== input.expectedProtocol) {
337
+ // The caller expects a different protocol than the cached probe result;
338
+ // the cache is stale — drop it and re-probe on the wire below.
339
+ this.deviceProtocol.delete(input.path);
340
+ }
341
+ if (!this.deviceProtocol.has(input.path)) {
342
+ const usbDevice = this.deviceList.find(device => device.path === input.path)?.device;
343
+ const protocolHint = input.expectedProtocol
344
+ ? undefined
345
+ : input.protocolHint ??
346
+ this.deviceProtocolHints.get(input.path) ??
347
+ inferProtocolHintFromUsbId(usbDevice?.vendorId, usbDevice?.productId);
348
+ if (protocolHint) {
349
+ this.deviceProtocolHints.set(input.path, protocolHint);
350
+ }
351
+ await this.detectProtocol(input.path, input.expectedProtocol, protocolHint);
352
+ const probedDevice = this.deviceList.find(d => d.path === input.path)?.device;
353
+ if (probedDevice) {
354
+ this.probedDeviceObjects.set(input.path, probedDevice);
355
+ }
356
+ }
357
+ this.acquiredPaths.add(input.path);
253
358
  return await Promise.resolve(input.path);
254
359
  } catch (e) {
255
360
  this.Log.debug('acquire error: ', e instanceof Error ? `${e.name}: ${e.message}` : String(e));
361
+ this.acquiredPaths.delete(input.path);
362
+ await this.closeOpenDevice(input.path);
256
363
  throw e;
257
364
  }
258
365
  }
@@ -269,13 +376,17 @@ export default class WebUsbTransport {
269
376
  );
270
377
  }
271
378
 
272
- private createProtocolDetectionError() {
379
+ private createProtocolProbeTimeoutError(expected: ProtocolType, attempts: number) {
273
380
  return ERRORS.TypedError(
274
- HardwareErrorCode.RuntimeError,
275
- 'Unable to detect USB protocol: device did not respond to Protocol V1 Initialize or Protocol V2 Ping'
381
+ HardwareErrorCode.DeviceInitializeFailed,
382
+ `Protocol ${expected} probe timeout after ${attempts} attempts`
276
383
  );
277
384
  }
278
385
 
386
+ private createProtocolDetectionError() {
387
+ return ERRORS.TypedError(HardwareErrorCode.DeviceNotFound);
388
+ }
389
+
279
390
  private async detectProtocol(
280
391
  path: string,
281
392
  expectedProtocol?: ProtocolType,
@@ -286,29 +397,54 @@ export default class WebUsbTransport {
286
397
  this.deviceProtocol.set(path, 'V1');
287
398
  return 'V1';
288
399
  }
400
+ await this.closeConnectionAfterProbe(path);
289
401
  throw this.createProtocolMismatchError(expectedProtocol);
290
402
  }
291
403
 
292
404
  if (expectedProtocol === 'V2') {
293
- // 免探测路径:调用方显式承诺该设备是 V2(例如固件升级重启后的重连场景,
294
- // 上层已经探测过协议并通过 expectedProtocol 传回),这里不再重复探测。
295
- this.deviceProtocol.set(path, 'V2');
296
- return 'V2';
405
+ for (let attempt = 1; attempt <= EXPECTED_PROTOCOL_V2_PROBE_ATTEMPTS; attempt += 1) {
406
+ if (await this.probeProtocolV2(path)) {
407
+ this.deviceProtocol.set(path, 'V2');
408
+ return 'V2';
409
+ }
410
+ if (attempt < EXPECTED_PROTOCOL_V2_PROBE_ATTEMPTS) {
411
+ await this.resetConnectionAfterProbe(path);
412
+ this.Log?.debug(
413
+ `[WebUsbTransport] Protocol V2 probe timed out, retrying ${
414
+ attempt + 1
415
+ }/${EXPECTED_PROTOCOL_V2_PROBE_ATTEMPTS}`
416
+ );
417
+ } else {
418
+ await this.closeConnectionAfterProbe(path);
419
+ }
420
+ }
421
+ this.deviceProtocol.delete(path);
422
+ throw this.createProtocolProbeTimeoutError(
423
+ expectedProtocol,
424
+ EXPECTED_PROTOCOL_V2_PROBE_ATTEMPTS
425
+ );
297
426
  }
298
427
 
299
- // 项目约束:协议判断必须在连接后主动探测,不能依赖设备名/PID/descriptor。
300
- // 设备名 hint(如 "Pro 2")只用于调整探测顺序:hint=V2 时先探 V2、失败回落 V1,
301
- // 不能作为最终结论。
428
+ // Protocol must be actively probed after connection. Name, PID, and descriptors only
429
+ // influence probe order; a V2 hint probes V2 first and falls back to V1.
302
430
  const probeOrder: ProtocolType[] =
303
431
  protocolHint === 'V2' || this.deviceProtocol.get(path) === 'V2' ? ['V2', 'V1'] : ['V1', 'V2'];
304
432
 
305
- for (const protocol of probeOrder) {
433
+ for (const [index, protocol] of probeOrder.entries()) {
306
434
  const detected =
307
435
  protocol === 'V1' ? await this.probeProtocolV1(path) : await this.probeProtocolV2(path);
308
436
  if (detected) {
309
437
  this.deviceProtocol.set(path, protocol);
310
438
  return protocol;
311
439
  }
440
+ if (index < probeOrder.length - 1) {
441
+ // A timed-out WebUSB transferIn cannot be cancelled in place. Closing and
442
+ // reopening the device guarantees the next protocol probe cannot consume a
443
+ // late response from the previous protocol generation.
444
+ await this.resetConnectionAfterProbe(path);
445
+ } else {
446
+ await this.closeConnectionAfterProbe(path);
447
+ }
312
448
  }
313
449
 
314
450
  this.deviceProtocol.delete(path);
@@ -420,16 +556,12 @@ export default class WebUsbTransport {
420
556
  // Discover endpoints from USB descriptors; descriptors are not used for protocol selection.
421
557
  const endpoints = this.discoverEndpoints(device);
422
558
  this.deviceEndpoints.set(path, endpoints);
423
- this.protocolV2Assemblers.get(path)?.reset();
424
- this.protocolV2Assemblers.set(path, new ProtocolV2FrameAssembler(PROTOCOL_V2_FRAME_MAX_BYTES));
425
-
426
559
  await device.claimInterface(endpoints.interfaceNumber);
427
560
  await this.clearEndpointHalt(device, 'in', endpoints.endpointIn);
428
561
  await this.clearEndpointHalt(device, 'out', endpoints.endpointOut);
429
562
  }
430
563
 
431
564
  private async closeOpenDevice(path: string) {
432
- this.protocolV2Assemblers.get(path)?.reset();
433
565
  const current = this.deviceList.find(device => device.path === path)?.device;
434
566
  if (!current?.opened) return;
435
567
 
@@ -462,7 +594,25 @@ export default class WebUsbTransport {
462
594
  }
463
595
  }
464
596
 
597
+ /**
598
+ * With the protocol cache surviving release(), deviceProtocol presence no
599
+ * longer implies an active session. Guard call()/post() explicitly so a
600
+ * post-release straggler fails fast instead of silently reopening the device
601
+ * and driving it outside any session (pre-cache behavior: the deleted
602
+ * protocol entry produced the same fail-fast).
603
+ */
604
+ private assertAcquired(path: string) {
605
+ if (!this.acquiredPaths.has(path)) {
606
+ throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, `Device is not acquired for ${path}`);
607
+ }
608
+ }
609
+
465
610
  async post(session: string, name: string, data: Record<string, unknown>) {
611
+ this.assertAcquired(session);
612
+ if (this.deviceProtocol.get(session) === 'V2') {
613
+ await this.sendProtocolV2UsbFlowControl(session, name, data);
614
+ return;
615
+ }
466
616
  await this.call(session, name, data);
467
617
  }
468
618
 
@@ -500,6 +650,11 @@ export default class WebUsbTransport {
500
650
  error
501
651
  )}`
502
652
  );
653
+ // The device dropped off the bus mid-call and may have rebooted into a
654
+ // different mode. The in-flight retry keeps the known protocol, but the
655
+ // next acquire must re-probe — this also self-heals a stale cache when the
656
+ // USB disconnect event itself was missed.
657
+ this.staleProtocolPaths.add(path);
503
658
  await wait(attempt * PACKET_IO_RETRY_DELAY);
504
659
 
505
660
  try {
@@ -624,33 +779,30 @@ export default class WebUsbTransport {
624
779
  throw lastError;
625
780
  }
626
781
 
627
- private async resetConnectionAfterProbe(path: string) {
628
- this.protocolV2Assemblers.get(path)?.reset();
629
- this.protocolV2Sessions.delete(path);
630
- this.protocolV2ReadTimeouts.delete(path);
631
-
632
- try {
633
- const device = await this.findDevice(path);
634
- if (device.opened) {
635
- const endpoints = this.deviceEndpoints.get(path);
636
- const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
637
- try {
638
- await device.releaseInterface(ifaceNum);
639
- } catch (error) {
640
- this.Log.debug('[WebUsbTransport] releaseInterface after protocol probe error:', error);
641
- }
642
- await device.close();
643
- }
644
- } catch (error) {
645
- this.Log.debug('[WebUsbTransport] close after protocol probe error:', error);
782
+ private async transferInOnce(path: string, length: number): Promise<DataView> {
783
+ const device = await this.findDevice(path);
784
+ if (!device.opened) {
785
+ throw new Error('USBDevice is not open for transferIn');
646
786
  }
787
+ const endpoints = this.deviceEndpoints.get(path);
788
+ const endpointIn = endpoints?.endpointIn ?? this.endpointId;
789
+ const result = await device.transferIn(endpointIn, length);
790
+ return this.getTransferInData(result);
791
+ }
647
792
 
793
+ private async closeConnectionAfterProbe(path: string) {
794
+ await this.rotateProtocolV2UsbGeneration(path, 'WebUSB protocol probe reset');
795
+ await this.closeOpenDevice(path);
796
+ }
797
+
798
+ private async resetConnectionAfterProbe(path: string) {
799
+ await this.closeConnectionAfterProbe(path);
648
800
  await this.getConnectedDevices();
649
801
  await this.connect(path, false);
650
802
  }
651
803
 
652
804
  private async withProtocolReadTimeout<T>(
653
- _path: string,
805
+ path: string,
654
806
  promise: Promise<T>,
655
807
  timeoutMs: number,
656
808
  protocol: ProtocolType,
@@ -672,9 +824,16 @@ export default class WebUsbTransport {
672
824
  return await Promise.race([
673
825
  guardedPromise,
674
826
  new Promise<never>((_, reject) => {
675
- timer = setTimeout(() => {
827
+ timer = setTimeout(async () => {
676
828
  timedOut = true;
677
829
  onTimeout?.();
830
+ if (protocol === 'V1') {
831
+ try {
832
+ await this.resetConnectionAfterProbe(path);
833
+ } catch (error) {
834
+ this.Log.debug('[WebUsbTransport] reset after Protocol V1 timeout failed:', error);
835
+ }
836
+ }
678
837
  reject(new Error(`Protocol ${protocol} read timeout after ${timeoutMs}ms`));
679
838
  }, timeoutMs);
680
839
  }),
@@ -690,7 +849,14 @@ export default class WebUsbTransport {
690
849
  }
691
850
 
692
851
  try {
693
- await this.callProtocolV1(path, 'Initialize', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT });
852
+ await this.callProtocolV1(
853
+ path,
854
+ 'Initialize',
855
+ {},
856
+ {
857
+ timeoutMs: PROTOCOL_V1_PROBE_TIMEOUT,
858
+ }
859
+ );
694
860
  return true;
695
861
  } catch (_error) {
696
862
  return false;
@@ -704,10 +870,9 @@ export default class WebUsbTransport {
704
870
 
705
871
  return probeProtocolV2Helper({
706
872
  call: (name, data, options) => this.callProtocolV2(path, name, data, options),
707
- timeoutMs: PROTOCOL_PROBE_TIMEOUT,
873
+ timeoutMs: PROTOCOL_V2_PROBE_TIMEOUT,
708
874
  logger: this.Log,
709
875
  logPrefix: 'ProtocolV2 WebUSB',
710
- onProbeFailed: () => this.resetConnectionAfterProbe(path),
711
876
  });
712
877
  }
713
878
 
@@ -723,6 +888,7 @@ export default class WebUsbTransport {
723
888
  if (this.messages == null) {
724
889
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
725
890
  }
891
+ this.assertAcquired(path);
726
892
 
727
893
  const device = await this.findDevice(path);
728
894
  if (!device) {
@@ -737,10 +903,6 @@ export default class WebUsbTransport {
737
903
  );
738
904
  }
739
905
 
740
- if (!shouldSuppressHighVolumeCallLog(name)) {
741
- this.Log.debug('transport call', createTransportCallLog(name, protocol));
742
- }
743
-
744
906
  if (protocol === 'V2') {
745
907
  return this.callProtocolV2(path, name, data, options);
746
908
  }
@@ -788,94 +950,7 @@ export default class WebUsbTransport {
788
950
  data: Record<string, unknown>,
789
951
  options?: TransportCallOptions
790
952
  ) {
791
- const protocolV1Messages = this.messages;
792
- if (!this.messagesV2) {
793
- throw ERRORS.TypedError(
794
- HardwareErrorCode.TransportNotConfigured,
795
- 'Protocol V2 schema not configured'
796
- );
797
- }
798
- if (!protocolV1Messages) {
799
- throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
800
- }
801
-
802
- let session = this.protocolV2Sessions.get(path);
803
- if (!session) {
804
- session = new ProtocolV2Session({
805
- schemas: {
806
- protocolV1: protocolV1Messages,
807
- protocolV2: this.messagesV2,
808
- },
809
- router: PROTOCOL_V2_CHANNEL_USB,
810
- writeFrame: (frame: Uint8Array) => this.transferOutOnce(path, frame),
811
- readFrame: () => this.receiveProtocolV2Frame(path, this.protocolV2ReadTimeouts.get(path)),
812
- logger: this.Log,
813
- logPrefix: 'ProtocolV2 WebUSB',
814
- createTimeoutError: (messageName: string, timeoutMs: number) =>
815
- new Error(`Protocol V2 response timeout after ${timeoutMs}ms for ${messageName}`),
816
- });
817
- this.protocolV2Sessions.set(path, session);
818
- }
819
-
820
- this.protocolV2ReadTimeouts.set(path, options?.timeoutMs);
821
- this.protocolV2Assemblers.get(path)?.reset();
822
- try {
823
- return await session.call(name, data, options);
824
- } catch (error) {
825
- const message =
826
- error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
827
- if (message.includes('protocol v2 read timeout') || message.includes('response timeout')) {
828
- try {
829
- await this.resetConnectionAfterProbe(path);
830
- } catch (resetError) {
831
- this.Log.debug('[WebUsbTransport] Protocol V2 timeout reset failed:', resetError);
832
- }
833
- }
834
- throw error;
835
- } finally {
836
- this.protocolV2ReadTimeouts.delete(path);
837
- }
838
- }
839
-
840
- private async receiveProtocolV2Frame(path: string, timeoutMs?: number): Promise<Uint8Array> {
841
- let assembler = this.protocolV2Assemblers.get(path);
842
- if (!assembler) {
843
- assembler = new ProtocolV2FrameAssembler(PROTOCOL_V2_FRAME_MAX_BYTES);
844
- this.protocolV2Assemblers.set(path, assembler);
845
- }
846
-
847
- let frame: Uint8Array | undefined = assembler.push(new Uint8Array(0));
848
- const deadline = timeoutMs ? Date.now() + timeoutMs : undefined;
849
-
850
- while (!frame) {
851
- const cancelToken = { cancelled: false };
852
- const transferIn = this.transferInWithRetry(path, PROTOCOL_V2_FRAME_MAX_BYTES, cancelToken);
853
- const dataView = deadline
854
- ? await this.withProtocolReadTimeout(
855
- path,
856
- transferIn,
857
- Math.max(deadline - Date.now(), 1),
858
- 'V2',
859
- () => {
860
- cancelToken.cancelled = true;
861
- }
862
- )
863
- : await transferIn;
864
- const bytes = new Uint8Array(
865
- this.toArrayBuffer(
866
- dataView.buffer.slice(dataView.byteOffset, dataView.byteOffset + dataView.byteLength)
867
- )
868
- );
869
- try {
870
- frame = assembler.push(bytes);
871
- } catch (error) {
872
- throw ERRORS.TypedError(
873
- HardwareErrorCode.NetworkError,
874
- error instanceof Error ? error.message : String(error)
875
- );
876
- }
877
- }
878
- return frame;
953
+ return this.callProtocolV2Usb(path, name, data, options);
879
954
  }
880
955
 
881
956
  /**
@@ -930,20 +1005,67 @@ export default class WebUsbTransport {
930
1005
  * Release device
931
1006
  */
932
1007
  async release(path: string) {
933
- const device: USBDevice = await this.findDevice(path);
934
- const endpoints = this.deviceEndpoints.get(path);
935
- const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
936
- await device.releaseInterface(ifaceNum);
937
- await device.close();
938
- this.deviceProtocol.delete(path);
939
- this.deviceProtocolHints.delete(path);
940
- this.protocolV2Assemblers.get(path)?.reset();
941
- this.protocolV2Assemblers.delete(path);
942
- this.protocolV2Sessions.delete(path);
943
- this.protocolV2ReadTimeouts.delete(path);
1008
+ this.acquiredPaths.delete(path);
1009
+ await this.invalidateProtocolV2UsbLink(path, 'WebUSB transport released');
1010
+ await this.closeOpenDevice(path);
1011
+ // Keep deviceProtocol/deviceProtocolHints across release: the probe result is a
1012
+ // physical-device property, so the next acquire can skip the wire-level probe.
1013
+ // V2 entries are still dropped by onProtocolV2UsbLinkInvalidated via the link
1014
+ // invalidation above, and any entry is re-probed after a USB disconnect or a
1015
+ // transfer-level reconnect (see markProtocolStale).
944
1016
  this.deviceEndpoints.delete(path);
945
1017
  }
946
1018
 
1019
+ protected getProtocolV2UsbSchemas(): ProtocolV2Schemas {
1020
+ if (!this.messages || !this.messagesV2) {
1021
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
1022
+ }
1023
+ return {
1024
+ protocolV1: this.messages,
1025
+ protocolV2: this.messagesV2,
1026
+ };
1027
+ }
1028
+
1029
+ protected getProtocolV2UsbLogger() {
1030
+ return this.Log;
1031
+ }
1032
+
1033
+ protected async writeProtocolV2UsbPacket(
1034
+ path: string,
1035
+ frame: Uint8Array,
1036
+ _context: ProtocolV2CallContext
1037
+ ): Promise<void> {
1038
+ await this.transferOutOnce(path, frame);
1039
+ }
1040
+
1041
+ protected async readProtocolV2UsbPacket(
1042
+ path: string,
1043
+ _context: ProtocolV2CallContext
1044
+ ): Promise<Uint8Array> {
1045
+ const dataView = await this.transferInOnce(path, PROTOCOL_V2_FRAME_MAX_BYTES);
1046
+ return new Uint8Array(
1047
+ this.toArrayBuffer(
1048
+ dataView.buffer.slice(dataView.byteOffset, dataView.byteOffset + dataView.byteLength)
1049
+ )
1050
+ );
1051
+ }
1052
+
1053
+ protected async resetProtocolV2UsbNativeLink(path: string, _reason: string): Promise<void> {
1054
+ await this.closeOpenDevice(path);
1055
+ }
1056
+
1057
+ protected onProtocolV2UsbLinkInvalidated(path: string, reason: string) {
1058
+ this.deviceProtocol.delete(path);
1059
+ this.Log?.debug(`[WebUsbTransport] Protocol V2 link invalidated: ${path}`, reason);
1060
+ }
1061
+
1062
+ protected createProtocolV2UsbTimeoutError(name: string, timeoutMs: number): Error {
1063
+ return new ProtocolV2LinkError(
1064
+ 'response-timeout',
1065
+ `Protocol V2 response timeout after ${timeoutMs}ms for ${name}`
1066
+ );
1067
+ }
1068
+
947
1069
  /**
948
1070
  * Expose the detected protocol type for a given device path.
949
1071
  * Used by upper layers (e.g. TransportManager) to select the correct schema.