@onekeyfe/hd-transport-usb 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/hd-transport-usb",
3
- "version": "1.1.27",
3
+ "version": "1.2.0-alpha.0",
4
4
  "description": "OneKey hardware wallet direct USB transport plugin (libusb)",
5
5
  "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme",
6
6
  "license": "MIT",
@@ -20,10 +20,10 @@
20
20
  "lint:fix": "eslint . --fix"
21
21
  },
22
22
  "dependencies": {
23
- "@onekeyfe/hd-shared": "1.1.27",
24
- "@onekeyfe/hd-transport": "1.1.27",
23
+ "@onekeyfe/hd-shared": "1.2.0-alpha.0",
24
+ "@onekeyfe/hd-transport": "1.2.0-alpha.0",
25
25
  "bytebuffer": "^5.0.1",
26
26
  "usb": "^2.14.0"
27
27
  },
28
- "gitHead": "f0c80ce0246152fee79f9cae836db5d550bb82f0"
28
+ "gitHead": "de9efe20b83666b641ab9e8d945e6350f6450904"
29
29
  }
package/src/index.ts CHANGED
@@ -1,14 +1,33 @@
1
1
  import ByteBuffer from 'bytebuffer';
2
2
  import * as usb from 'usb';
3
- import transport, { LogBlockCommand } from '@onekeyfe/hd-transport';
3
+ import transport, {
4
+ LogBlockCommand,
5
+ PROTOCOL_V1_CHUNK_PAYLOAD_SIZE,
6
+ PROTOCOL_V1_MESSAGE_HEADER_SIZE,
7
+ PROTOCOL_V1_REPORT_ID,
8
+ PROTOCOL_V1_USB_PACKET_SIZE,
9
+ PROTOCOL_V2_CHANNEL_USB,
10
+ PROTOCOL_V2_FRAME_MAX_BYTES,
11
+ ProtocolV2FrameAssembler,
12
+ ProtocolV2Session,
13
+ probeProtocolV2 as probeProtocolV2Helper,
14
+ } from '@onekeyfe/hd-transport';
4
15
  import { ERRORS, HardwareErrorCode, ONEKEY_WEBUSB_FILTER, wait } from '@onekeyfe/hd-shared';
5
16
 
6
- import { HEADER_LENGTH, PACKET_SIZE, PAYLOAD_SIZE, REPORT_ID } from './constants';
7
-
8
17
  import type EventEmitter from 'events';
9
- import type { AcquireInput, OneKeyDeviceInfo } from '@onekeyfe/hd-transport';
18
+ import type {
19
+ AcquireInput,
20
+ OneKeyDeviceInfo,
21
+ ProtocolType,
22
+ TransportCallOptions,
23
+ } from '@onekeyfe/hd-transport';
24
+
25
+ const { parseConfigure, ProtocolV1, check } = transport;
10
26
 
11
- const { parseConfigure, buildEncodeBuffers, decodeProtocol, receiveOne, check } = transport;
27
+ const PACKET_SIZE = PROTOCOL_V1_USB_PACKET_SIZE;
28
+ const REPORT_ID = PROTOCOL_V1_REPORT_ID;
29
+ const PAYLOAD_SIZE = PROTOCOL_V1_CHUNK_PAYLOAD_SIZE;
30
+ const HEADER_LENGTH = PROTOCOL_V1_MESSAGE_HEADER_SIZE;
12
31
 
13
32
  /** USB interface number for vendor-specific communication */
14
33
  const INTERFACE_NUMBER = 0;
@@ -25,6 +44,7 @@ const SERIAL_READ_TIMEOUT_MS = 5000;
25
44
  /** Packet I/O retry configuration (matches WebUsbTransport) */
26
45
  const PACKET_IO_MAX_RETRIES = 3;
27
46
  const PACKET_IO_RETRY_DELAY = 300;
47
+ const PROTOCOL_PROBE_TIMEOUT = 1000;
28
48
 
29
49
  /**
30
50
  * Opened device state — holds the USB device, claimed interface, and endpoints.
@@ -134,6 +154,32 @@ function transferOutOnce(ep: usb.OutEndpoint, data: Buffer): Promise<void> {
134
154
  });
135
155
  }
136
156
 
157
+ function resetUsbDevice(dev: usb.Device): Promise<void> {
158
+ return new Promise(resolve => {
159
+ const reset = (dev as usb.Device & { reset?: (callback: (err?: Error) => void) => void }).reset;
160
+ if (typeof reset !== 'function') {
161
+ resolve();
162
+ return;
163
+ }
164
+ reset.call(dev, () => resolve());
165
+ });
166
+ }
167
+
168
+ function clearEndpointHalt(ep: usb.InEndpoint | usb.OutEndpoint): Promise<void> {
169
+ return new Promise(resolve => {
170
+ const clearHalt = (
171
+ ep as (usb.InEndpoint | usb.OutEndpoint) & {
172
+ clearHalt?: (callback: (err?: Error) => void) => void;
173
+ }
174
+ ).clearHalt;
175
+ if (typeof clearHalt !== 'function') {
176
+ resolve();
177
+ return;
178
+ }
179
+ clearHalt.call(ep, () => resolve());
180
+ });
181
+ }
182
+
137
183
  /**
138
184
  * Skip the 0x3F protocol marker byte from a USB packet.
139
185
  */
@@ -163,6 +209,9 @@ function toArrayBuffer(buf: Buffer): ArrayBuffer {
163
209
  export default class NodeUsbTransport {
164
210
  messages: ReturnType<typeof transport.parseConfigure> | undefined;
165
211
 
212
+ /** Protobuf schema for Protocol V2 transports. */
213
+ messagesV2: ReturnType<typeof transport.parseConfigure> | undefined;
214
+
166
215
  name = 'NodeUsbTransport';
167
216
 
168
217
  version = '';
@@ -181,6 +230,18 @@ export default class NodeUsbTransport {
181
230
  /** path → opened device state */
182
231
  private openDevices = new Map<string, OpenDevice>();
183
232
 
233
+ /** Per-path protocol type detected by active wire-level probe. */
234
+ private deviceProtocol: Map<string, ProtocolType> = new Map();
235
+
236
+ /** Per-path Protocol V2 frame assembler, preserving buffered frames during reads. */
237
+ private protocolV2Assemblers: Map<string, ProtocolV2FrameAssembler> = new Map();
238
+
239
+ /** Per-path Protocol V2 session, preserving seq across API calls on the same device path. */
240
+ private protocolV2Sessions: Map<string, ProtocolV2Session> = new Map();
241
+
242
+ /** Current Protocol V2 read timeout, consumed by cached session readFrame closures. */
243
+ private protocolV2ReadTimeouts: Map<string, number | undefined> = new Map();
244
+
184
245
  /** per-path reconnect lock to prevent concurrent reconnects */
185
246
  private reconnectLocks = new Map<string, Promise<OpenDevice>>();
186
247
 
@@ -204,6 +265,13 @@ export default class NodeUsbTransport {
204
265
  return Promise.resolve();
205
266
  }
206
267
 
268
+ configureProtocolV2(signedData: any) {
269
+ this.messagesV2 = parseConfigure(signedData);
270
+ this.protocolV2Sessions.clear();
271
+ this.protocolV2ReadTimeouts.clear();
272
+ this.Log?.debug('[NodeUsbTransport] Protocol V2 schema configured');
273
+ }
274
+
207
275
  listen() {
208
276
  // empty — could add hotplug events via usb.on('attach'/'detach')
209
277
  }
@@ -220,7 +288,7 @@ export default class NodeUsbTransport {
220
288
  if (!this.messages) {
221
289
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
222
290
  }
223
- const encodeBuffers = buildEncodeBuffers(this.messages, name, data);
291
+ const encodeBuffers = ProtocolV1.encodeMessageChunks(this.messages, name, data);
224
292
  await this.sendAllChunksWithRetry(path, encodeBuffers);
225
293
  }
226
294
 
@@ -237,7 +305,7 @@ export default class NodeUsbTransport {
237
305
  if (!this.messages) {
238
306
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
239
307
  }
240
- return receiveOne(this.messages, resData);
308
+ return ProtocolV1.decodeMessage(this.messages, resData);
241
309
  }
242
310
 
243
311
  /**
@@ -275,15 +343,19 @@ export default class NodeUsbTransport {
275
343
  /**
276
344
  * Acquire device — open USB device, claim interface, return path (string).
277
345
  */
278
- acquire(input: AcquireInput): Promise<string> {
346
+ async acquire(input: AcquireInput): Promise<string> {
347
+ this.cancelled = false;
348
+
279
349
  const path = input.path ?? '';
280
350
  if (!path) {
281
351
  throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, 'No device path provided');
282
352
  }
283
353
 
284
354
  try {
285
- this.openDevice(path);
286
- return Promise.resolve(path);
355
+ await this.closeOpenDevice(path);
356
+ await this.openDevice(path);
357
+ await this.detectProtocol(path, input.expectedProtocol);
358
+ return path;
287
359
  } catch (error: any) {
288
360
  this.Log?.debug('NodeUsbTransport acquire error: ', error);
289
361
  throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, error.message ?? String(error));
@@ -294,6 +366,12 @@ export default class NodeUsbTransport {
294
366
  * Release device — release interface and close.
295
367
  */
296
368
  async release(path: string, _onclose?: boolean): Promise<void> {
369
+ await this.closeOpenDevice(path);
370
+ this.deviceProtocol.delete(path);
371
+ this.protocolV2Assemblers.delete(path);
372
+ }
373
+
374
+ private async closeOpenDevice(path: string): Promise<void> {
297
375
  const openDev = this.openDevices.get(path);
298
376
  if (!openDev) return;
299
377
 
@@ -322,7 +400,12 @@ export default class NodeUsbTransport {
322
400
  * Call device method — encode protobuf, send packets, receive response.
323
401
  * This is the core method that replaces LowlevelTransport's call + UsbPlugin's send/receive.
324
402
  */
325
- async call(path: string, name: string, data: Record<string, unknown>) {
403
+ async call(
404
+ path: string,
405
+ name: string,
406
+ data: Record<string, unknown>,
407
+ options?: TransportCallOptions
408
+ ) {
326
409
  this.cancelled = false;
327
410
 
328
411
  if (!this.messages) {
@@ -333,26 +416,57 @@ export default class NodeUsbTransport {
333
416
  throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, `Device not acquired: ${path}`);
334
417
  }
335
418
 
336
- const { messages } = this;
419
+ const protocol = this.deviceProtocol.get(path);
420
+ if (!protocol) {
421
+ throw ERRORS.TypedError(
422
+ HardwareErrorCode.RuntimeError,
423
+ `Device protocol has not been detected for ${path}`
424
+ );
425
+ }
337
426
  if (LogBlockCommand.has(name)) {
338
- this.Log?.debug('NodeUsbTransport call-', ' name: ', name);
427
+ this.Log?.debug('NodeUsbTransport call-', ' name: ', name, ' protocol: ', protocol);
339
428
  } else {
340
- this.Log?.debug('NodeUsbTransport call-', ' name: ', name, ' data: ', data);
429
+ this.Log?.debug(
430
+ 'NodeUsbTransport call-',
431
+ ' name: ',
432
+ name,
433
+ ' data: ',
434
+ data,
435
+ ' protocol: ',
436
+ protocol
437
+ );
341
438
  }
342
439
 
440
+ if (protocol === 'V2') {
441
+ return this.callProtocolV2(path, name, data, options);
442
+ }
443
+
444
+ return this.callProtocolV1(path, name, data, options);
445
+ }
446
+
447
+ private async callProtocolV1(
448
+ path: string,
449
+ name: string,
450
+ data: Record<string, unknown>,
451
+ options?: TransportCallOptions
452
+ ) {
453
+ const { messages } = this;
454
+ if (!messages) {
455
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
456
+ }
343
457
  // Encode protobuf message into 63-byte chunks (same as WebUsbTransport)
344
- const encodeBuffers = buildEncodeBuffers(messages, name, data);
458
+ const encodeBuffers = ProtocolV1.encodeMessageChunks(messages, name, data);
345
459
 
346
460
  // Send all chunks with retry — if any chunk fails and reconnects,
347
461
  // restart the entire send sequence from chunk 0 (device resets state on reconnect)
348
462
  await this.sendAllChunksWithRetry(path, encodeBuffers);
349
463
 
350
464
  // Receive response — re-resolve in case reconnect happened during send
351
- const resData = await this.receiveData(path, this.getOpenDevice(path));
465
+ const resData = await this.receiveData(path, this.getOpenDevice(path), options?.timeoutMs);
352
466
  if (typeof resData !== 'string') {
353
467
  throw ERRORS.TypedError(HardwareErrorCode.NetworkError, 'Returning data is not string.');
354
468
  }
355
- const jsonData = receiveOne(messages, resData);
469
+ const jsonData = ProtocolV1.decodeMessage(messages, resData);
356
470
  return check.call(jsonData);
357
471
  }
358
472
 
@@ -401,6 +515,21 @@ export default class NodeUsbTransport {
401
515
  );
402
516
  }
403
517
 
518
+ private getDeviceInterface(dev: usb.Device): usb.Interface {
519
+ const { interfaces } = dev;
520
+ if (!interfaces?.length) {
521
+ throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, 'USB interface not found');
522
+ }
523
+
524
+ const vendorInterface = interfaces.find(iface => iface.descriptor.bInterfaceClass === 0xff);
525
+ const defaultInterface = interfaces.find(
526
+ iface => iface.descriptor.bInterfaceNumber === INTERFACE_NUMBER
527
+ );
528
+ const iface = vendorInterface ?? defaultInterface ?? interfaces[0];
529
+
530
+ return iface;
531
+ }
532
+
404
533
  /**
405
534
  * Reconnect device before retrying a failed transfer (aligned with WebUsbTransport).
406
535
  * Uses per-path lock to prevent concurrent reconnects to the same device.
@@ -423,16 +552,16 @@ export default class NodeUsbTransport {
423
552
  );
424
553
  await wait(attempt * PACKET_IO_RETRY_DELAY);
425
554
 
426
- // Close the existing device
555
+ // Close the existing device without clearing the detected protocol cache.
427
556
  try {
428
- await this.release(path);
557
+ await this.closeOpenDevice(path);
429
558
  } catch (releaseError) {
430
559
  this.Log?.debug('[NodeUsbTransport] release before retry error:', releaseError);
431
560
  }
432
561
 
433
562
  // Re-enumerate to refresh device list, then re-open
434
563
  await this.enumerate();
435
- this.openDevice(path);
564
+ await this.openDevice(path);
436
565
 
437
566
  const openDev = this.openDevices.get(path);
438
567
  if (!openDev) {
@@ -535,7 +664,7 @@ export default class NodeUsbTransport {
535
664
  /**
536
665
  * Open a USB device by path (serial number), claim interface, cache endpoints.
537
666
  */
538
- private openDevice(path: string): void {
667
+ private async openDevice(path: string): Promise<void> {
539
668
  const existing = this.openDevices.get(path);
540
669
  if (existing) return;
541
670
 
@@ -547,12 +676,20 @@ export default class NodeUsbTransport {
547
676
  throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, `USB device not found: ${path}`);
548
677
  }
549
678
 
550
- dev.open();
551
-
552
679
  try {
680
+ dev.open();
553
681
  dev.timeout = TRANSFER_TIMEOUT_MS;
554
682
 
555
- const iface = dev.interface(INTERFACE_NUMBER);
683
+ await resetUsbDevice(dev);
684
+
685
+ try {
686
+ dev.open();
687
+ } catch {
688
+ // libusb keeps some devices open across reset; continue with the current handle.
689
+ }
690
+ dev.timeout = TRANSFER_TIMEOUT_MS;
691
+
692
+ const iface = this.getDeviceInterface(dev);
556
693
 
557
694
  // On Linux, detach kernel driver if active
558
695
  if (process.platform === 'linux') {
@@ -567,12 +704,14 @@ export default class NodeUsbTransport {
567
704
 
568
705
  iface.claim();
569
706
 
570
- const epIn = iface.endpoints.find(
571
- (e): e is usb.InEndpoint => e.direction === 'in' && e.address === ENDPOINT_IN
572
- );
573
- const epOut = iface.endpoints.find(
574
- (e): e is usb.OutEndpoint => e.direction === 'out' && e.address === ENDPOINT_OUT
575
- );
707
+ const epIn =
708
+ iface.endpoints.find(
709
+ (e): e is usb.InEndpoint => e.direction === 'in' && e.address === ENDPOINT_IN
710
+ ) ?? iface.endpoints.find((e): e is usb.InEndpoint => e.direction === 'in');
711
+ const epOut =
712
+ iface.endpoints.find(
713
+ (e): e is usb.OutEndpoint => e.direction === 'out' && e.address === ENDPOINT_OUT
714
+ ) ?? iface.endpoints.find((e): e is usb.OutEndpoint => e.direction === 'out');
576
715
 
577
716
  if (!epIn || !epOut) {
578
717
  throw ERRORS.TypedError(
@@ -584,6 +723,10 @@ export default class NodeUsbTransport {
584
723
  epIn.timeout = TRANSFER_TIMEOUT_MS;
585
724
  epOut.timeout = TRANSFER_TIMEOUT_MS;
586
725
 
726
+ await clearEndpointHalt(epIn);
727
+ await clearEndpointHalt(epOut);
728
+ await this.drainStaleInput(epIn);
729
+
587
730
  this.openDevices.set(path, { device: dev, iface, epIn, epOut });
588
731
  } catch (err) {
589
732
  try {
@@ -595,17 +738,301 @@ export default class NodeUsbTransport {
595
738
  }
596
739
  }
597
740
 
741
+ private async drainStaleInput(epIn: usb.InEndpoint): Promise<void> {
742
+ const originalTimeout = epIn.timeout;
743
+ epIn.timeout = 50;
744
+ try {
745
+ // Drain a small bounded number of packets left by the previous USB session.
746
+ for (let index = 0; index < 16; index += 1) {
747
+ try {
748
+ await transferInOnce(epIn, PACKET_SIZE);
749
+ } catch {
750
+ break;
751
+ }
752
+ }
753
+ } finally {
754
+ epIn.timeout = originalTimeout;
755
+ }
756
+ }
757
+
758
+ private createProtocolMismatchError(expected: ProtocolType) {
759
+ return ERRORS.TypedError(
760
+ HardwareErrorCode.RuntimeError,
761
+ `Device protocol mismatch: expected ${expected}, but device did not respond to expected protocol`
762
+ );
763
+ }
764
+
765
+ private createProtocolDetectionError() {
766
+ return ERRORS.TypedError(
767
+ HardwareErrorCode.RuntimeError,
768
+ 'Unable to detect USB protocol: device did not respond to Protocol V1 Initialize or Protocol V2 Ping'
769
+ );
770
+ }
771
+
772
+ private async detectProtocol(
773
+ path: string,
774
+ expectedProtocol?: ProtocolType
775
+ ): Promise<ProtocolType> {
776
+ if (expectedProtocol === 'V1') {
777
+ if (await this.probeProtocolV1(path)) {
778
+ this.deviceProtocol.set(path, 'V1');
779
+ this.Log?.debug(`[NodeUsbTransport] detectProtocol: path=${path} -> V1 (expected)`);
780
+ return 'V1';
781
+ }
782
+ throw this.createProtocolMismatchError(expectedProtocol);
783
+ }
784
+
785
+ if (expectedProtocol === 'V2') {
786
+ if (await this.probeProtocolV2(path)) {
787
+ this.deviceProtocol.set(path, 'V2');
788
+ this.Log?.debug(`[NodeUsbTransport] detectProtocol: path=${path} -> V2 (expected)`);
789
+ return 'V2';
790
+ }
791
+ throw this.createProtocolMismatchError(expectedProtocol);
792
+ }
793
+
794
+ if (this.deviceProtocol.get(path) === 'V2' && (await this.probeProtocolV2(path))) {
795
+ this.deviceProtocol.set(path, 'V2');
796
+ this.Log?.debug(`[NodeUsbTransport] detectProtocol: path=${path} -> V2 (cached)`);
797
+ return 'V2';
798
+ }
799
+
800
+ if (await this.probeProtocolV1(path)) {
801
+ this.deviceProtocol.set(path, 'V1');
802
+ this.Log?.debug(`[NodeUsbTransport] detectProtocol: path=${path} -> V1`);
803
+ return 'V1';
804
+ }
805
+
806
+ if (await this.probeProtocolV2(path)) {
807
+ this.deviceProtocol.set(path, 'V2');
808
+ this.Log?.debug(`[NodeUsbTransport] detectProtocol: path=${path} -> V2`);
809
+ return 'V2';
810
+ }
811
+
812
+ this.deviceProtocol.delete(path);
813
+ throw this.createProtocolDetectionError();
814
+ }
815
+
816
+ private async resetConnectionAfterProbe(path: string) {
817
+ this.protocolV2Assemblers.get(path)?.reset();
818
+ this.protocolV2Sessions.delete(path);
819
+ this.protocolV2ReadTimeouts.delete(path);
820
+
821
+ try {
822
+ await this.closeOpenDevice(path);
823
+ } catch (error) {
824
+ this.Log?.debug('[NodeUsbTransport] close after protocol probe error:', error);
825
+ }
826
+
827
+ await this.enumerate();
828
+ await this.openDevice(path);
829
+ }
830
+
831
+ private async withProtocolReadTimeout<T>(
832
+ path: string,
833
+ promise: Promise<T>,
834
+ timeoutMs: number,
835
+ protocol: ProtocolType
836
+ ): Promise<T> {
837
+ let timer: ReturnType<typeof setTimeout> | undefined;
838
+ let timedOut = false;
839
+ const waitForeverAfterTimeout = () => new Promise<never>(() => {});
840
+ const guardedPromise = promise.then(
841
+ value => (timedOut ? waitForeverAfterTimeout() : value),
842
+ error => {
843
+ if (timedOut) {
844
+ return waitForeverAfterTimeout();
845
+ }
846
+ throw error;
847
+ }
848
+ );
849
+ try {
850
+ return await Promise.race([
851
+ guardedPromise,
852
+ new Promise<never>((_, reject) => {
853
+ timer = setTimeout(async () => {
854
+ timedOut = true;
855
+ try {
856
+ await this.resetConnectionAfterProbe(path);
857
+ } catch (error) {
858
+ this.Log?.debug(
859
+ `[NodeUsbTransport] reset after Protocol ${protocol} timeout failed:`,
860
+ error
861
+ );
862
+ } finally {
863
+ reject(new Error(`Protocol ${protocol} read timeout after ${timeoutMs}ms`));
864
+ }
865
+ }, timeoutMs);
866
+ }),
867
+ ]);
868
+ } finally {
869
+ if (timer) clearTimeout(timer);
870
+ }
871
+ }
872
+
873
+ private async probeProtocolV1(path: string) {
874
+ if (!this.messages) {
875
+ return false;
876
+ }
877
+
878
+ try {
879
+ await this.callProtocolV1(path, 'Initialize', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT });
880
+ return true;
881
+ } catch (error) {
882
+ this.Log?.debug('[NodeUsbTransport] Protocol V1 Initialize probe failed:', error);
883
+ return false;
884
+ }
885
+ }
886
+
887
+ private async probeProtocolV2(path: string) {
888
+ if (!this.messages || !this.messagesV2) {
889
+ return false;
890
+ }
891
+
892
+ return probeProtocolV2Helper({
893
+ call: (name, data, options) => this.callProtocolV2(path, name, data, options),
894
+ timeoutMs: PROTOCOL_PROBE_TIMEOUT,
895
+ logger: this.Log,
896
+ logPrefix: 'ProtocolV2 NodeUSB',
897
+ onProbeFailed: () => this.resetConnectionAfterProbe(path),
898
+ });
899
+ }
900
+
901
+ private async writeProtocolV2Frame(path: string, frame: Uint8Array) {
902
+ let lastError: unknown;
903
+ for (let attempt = 1; attempt <= PACKET_IO_MAX_RETRIES; attempt++) {
904
+ if (this.cancelled) {
905
+ throw ERRORS.TypedError(HardwareErrorCode.DeviceInterruptedFromOutside, 'Cancelled');
906
+ }
907
+ try {
908
+ await transferOutOnce(this.getOpenDevice(path).epOut, Buffer.from(frame));
909
+ return;
910
+ } catch (error) {
911
+ lastError = error;
912
+ const shouldRetry = attempt < PACKET_IO_MAX_RETRIES && this.isRetryableError(error);
913
+ if (!shouldRetry) {
914
+ throw error;
915
+ }
916
+ try {
917
+ await this.reconnectForRetry(path, 'out', attempt, error);
918
+ } catch (reconnectError) {
919
+ lastError = reconnectError;
920
+ this.Log?.debug(
921
+ `[NodeUsbTransport] Protocol V2 write reconnect failed on retry ${attempt}/${PACKET_IO_MAX_RETRIES}: ${this.getErrorMessage(
922
+ reconnectError
923
+ )}`
924
+ );
925
+ break;
926
+ }
927
+ }
928
+ }
929
+ throw lastError;
930
+ }
931
+
932
+ private async receiveProtocolV2Frame(path: string, timeoutMs?: number): Promise<Uint8Array> {
933
+ let assembler = this.protocolV2Assemblers.get(path);
934
+ if (!assembler) {
935
+ assembler = new ProtocolV2FrameAssembler(PROTOCOL_V2_FRAME_MAX_BYTES);
936
+ this.protocolV2Assemblers.set(path, assembler);
937
+ }
938
+
939
+ let frame: Uint8Array | undefined = assembler.push(new Uint8Array(0));
940
+ const deadline = timeoutMs ? Date.now() + timeoutMs : undefined;
941
+
942
+ while (!frame) {
943
+ const transferIn = this.transferInWithRetry(
944
+ path,
945
+ this.getOpenDevice(path),
946
+ PROTOCOL_V2_FRAME_MAX_BYTES
947
+ );
948
+ const packet = deadline
949
+ ? await this.withProtocolReadTimeout(
950
+ path,
951
+ transferIn,
952
+ Math.max(deadline - Date.now(), 1),
953
+ 'V2'
954
+ )
955
+ : await transferIn;
956
+ const bytes = new Uint8Array(
957
+ packet.buffer.slice(packet.byteOffset, packet.byteOffset + packet.byteLength)
958
+ );
959
+ try {
960
+ frame = assembler.push(bytes);
961
+ } catch (error) {
962
+ throw ERRORS.TypedError(
963
+ HardwareErrorCode.NetworkError,
964
+ error instanceof Error ? error.message : String(error)
965
+ );
966
+ }
967
+ }
968
+ return frame;
969
+ }
970
+
971
+ private async callProtocolV2(
972
+ path: string,
973
+ name: string,
974
+ data: Record<string, unknown>,
975
+ options?: TransportCallOptions
976
+ ) {
977
+ const protocolV1Messages = this.messages;
978
+ if (!this.messagesV2) {
979
+ throw ERRORS.TypedError(
980
+ HardwareErrorCode.TransportNotConfigured,
981
+ 'Protocol V2 schema not configured'
982
+ );
983
+ }
984
+ if (!protocolV1Messages) {
985
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
986
+ }
987
+
988
+ let session = this.protocolV2Sessions.get(path);
989
+ if (!session) {
990
+ session = new ProtocolV2Session({
991
+ schemas: {
992
+ protocolV1: protocolV1Messages,
993
+ protocolV2: this.messagesV2,
994
+ },
995
+ router: PROTOCOL_V2_CHANNEL_USB,
996
+ writeFrame: (frame: Uint8Array) => this.writeProtocolV2Frame(path, frame),
997
+ readFrame: () => this.receiveProtocolV2Frame(path, this.protocolV2ReadTimeouts.get(path)),
998
+ logger: this.Log,
999
+ logPrefix: 'ProtocolV2 NodeUSB',
1000
+ createTimeoutError: (messageName: string, timeoutMs: number) =>
1001
+ new Error(`Protocol V2 response timeout after ${timeoutMs}ms for ${messageName}`),
1002
+ });
1003
+ this.protocolV2Sessions.set(path, session);
1004
+ }
1005
+
1006
+ this.protocolV2ReadTimeouts.set(path, options?.timeoutMs);
1007
+ this.protocolV2Assemblers.get(path)?.reset();
1008
+ try {
1009
+ return await session.call(name, data, options);
1010
+ } finally {
1011
+ this.protocolV2ReadTimeouts.delete(path);
1012
+ }
1013
+ }
1014
+
598
1015
  /**
599
1016
  * Receive a complete protobuf response from the device.
600
1017
  * Reads 64-byte packets, strips 0x3F marker, reassembles into hex string.
601
1018
  */
602
- private async receiveData(path: string, dev: OpenDevice): Promise<string> {
1019
+ private async receiveData(path: string, dev: OpenDevice, timeoutMs?: number): Promise<string> {
1020
+ const deadline = timeoutMs ? Date.now() + timeoutMs : undefined;
1021
+ const readPacket = async () => {
1022
+ const transferIn = this.transferInWithRetry(path, this.getOpenDevice(path), PACKET_SIZE);
1023
+ return deadline
1024
+ ? this.withProtocolReadTimeout(path, transferIn, Math.max(deadline - Date.now(), 1), 'V1')
1025
+ : transferIn;
1026
+ };
1027
+
603
1028
  // Read first packet, skip report byte
604
- const firstPacket = await this.transferInWithRetry(path, dev, PACKET_SIZE);
1029
+ const firstPacket = timeoutMs
1030
+ ? await readPacket()
1031
+ : await this.transferInWithRetry(path, dev, PACKET_SIZE);
605
1032
  const firstData = skipReportByte(firstPacket);
606
1033
 
607
1034
  // Decode header: ## marker → { typeId, length, restBuffer }
608
- const { length, typeId, restBuffer } = decodeProtocol.decodeChunked(toArrayBuffer(firstData));
1035
+ const { length, typeId, restBuffer } = ProtocolV1.decodeFirstChunk(toArrayBuffer(firstData));
609
1036
 
610
1037
  // Allocate result: typeId(2) + length(4) + payload(length)
611
1038
  const lengthWithHeader = Number(length) + HEADER_LENGTH;
@@ -619,7 +1046,7 @@ export default class NodeUsbTransport {
619
1046
  // Read subsequent packets until complete
620
1047
  // Re-resolve device on each iteration so we use a fresh handle after any reconnect
621
1048
  while (decoded.offset < lengthWithHeader) {
622
- const packet = await this.transferInWithRetry(path, this.getOpenDevice(path), PACKET_SIZE);
1049
+ const packet = await readPacket();
623
1050
  const pktData = skipReportByte(packet);
624
1051
  const buf = toArrayBuffer(pktData);
625
1052
  if (lengthWithHeader - decoded.offset >= PAYLOAD_SIZE) {
@@ -633,6 +1060,8 @@ export default class NodeUsbTransport {
633
1060
  const result = decoded.toBuffer();
634
1061
  return Buffer.from(result as unknown as ArrayBuffer).toString('hex');
635
1062
  }
636
- }
637
1063
 
638
- export { PACKET_SIZE } from './constants';
1064
+ getProtocolType(path: string): ProtocolType | undefined {
1065
+ return this.deviceProtocol.get(path);
1066
+ }
1067
+ }