@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/dist/index.d.ts +24 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +396 -68
- package/package.json +4 -4
- package/src/index.ts +465 -36
- package/dist/constants.d.ts +0 -5
- package/dist/constants.d.ts.map +0 -1
- package/src/constants.ts +0 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onekeyfe/hd-transport-usb",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
24
|
-
"@onekeyfe/hd-transport": "1.
|
|
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": "
|
|
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, {
|
|
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 {
|
|
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
|
|
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 =
|
|
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
|
|
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.
|
|
286
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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 =
|
|
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 } =
|
|
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
|
|
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
|
-
|
|
1064
|
+
getProtocolType(path: string): ProtocolType | undefined {
|
|
1065
|
+
return this.deviceProtocol.get(path);
|
|
1066
|
+
}
|
|
1067
|
+
}
|