@onekeyfe/hd-transport 1.2.0-alpha.9 → 1.2.0-alpha.91

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.
Files changed (64) hide show
  1. package/__tests__/messages.test.js +169 -11
  2. package/__tests__/protocol-v2-ble-frame-writer.test.js +119 -0
  3. package/__tests__/protocol-v2-link-manager.test.js +120 -9
  4. package/__tests__/protocol-v2-usb-transport-base.test.js +36 -2
  5. package/__tests__/protocol-v2.test.js +438 -64
  6. package/__tests__/transport-log.test.js +79 -0
  7. package/dist/constants.d.ts +3 -1
  8. package/dist/constants.d.ts.map +1 -1
  9. package/dist/index.d.ts +707 -242
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +597 -296
  12. package/dist/protocols/index.d.ts +13 -6
  13. package/dist/protocols/index.d.ts.map +1 -1
  14. package/dist/protocols/v2/ble-frame-writer.d.ts +15 -0
  15. package/dist/protocols/v2/ble-frame-writer.d.ts.map +1 -0
  16. package/dist/protocols/v2/decode.d.ts +12 -2
  17. package/dist/protocols/v2/decode.d.ts.map +1 -1
  18. package/dist/protocols/v2/encode.d.ts +2 -3
  19. package/dist/protocols/v2/encode.d.ts.map +1 -1
  20. package/dist/protocols/v2/errors.d.ts +8 -0
  21. package/dist/protocols/v2/errors.d.ts.map +1 -0
  22. package/dist/protocols/v2/frame-assembler.d.ts.map +1 -1
  23. package/dist/protocols/v2/index.d.ts +2 -1
  24. package/dist/protocols/v2/index.d.ts.map +1 -1
  25. package/dist/protocols/v2/link-manager.d.ts +4 -0
  26. package/dist/protocols/v2/link-manager.d.ts.map +1 -1
  27. package/dist/protocols/v2/session.d.ts +10 -4
  28. package/dist/protocols/v2/session.d.ts.map +1 -1
  29. package/dist/protocols/v2/usb-transport-base.d.ts +1 -0
  30. package/dist/protocols/v2/usb-transport-base.d.ts.map +1 -1
  31. package/dist/serialization/protobuf/decode.d.ts.map +1 -1
  32. package/dist/serialization/protobuf/encode.d.ts.map +1 -1
  33. package/dist/types/messages.d.ts +370 -144
  34. package/dist/types/messages.d.ts.map +1 -1
  35. package/dist/types/transport.d.ts +20 -3
  36. package/dist/types/transport.d.ts.map +1 -1
  37. package/dist/utils/transportLog.d.ts +9 -0
  38. package/dist/utils/transportLog.d.ts.map +1 -0
  39. package/messages-protocol-v2.json +1113 -547
  40. package/package.json +3 -3
  41. package/scripts/protobuf-build.sh +85 -253
  42. package/scripts/protobuf-patches/index.js +1 -0
  43. package/scripts/protobuf-types.js +19 -1
  44. package/src/constants.ts +30 -16
  45. package/src/index.ts +5 -1
  46. package/src/protocols/index.ts +37 -40
  47. package/src/protocols/v2/ble-frame-writer.ts +77 -0
  48. package/src/protocols/v2/crc8.ts +1 -1
  49. package/src/protocols/v2/decode.ts +50 -35
  50. package/src/protocols/v2/encode.ts +2 -28
  51. package/src/protocols/v2/errors.ts +25 -0
  52. package/src/protocols/v2/frame-assembler.ts +6 -4
  53. package/src/protocols/v2/index.ts +2 -1
  54. package/src/protocols/v2/link-manager.ts +54 -5
  55. package/src/protocols/v2/session.ts +151 -200
  56. package/src/protocols/v2/usb-transport-base.ts +29 -10
  57. package/src/serialization/protobuf/decode.ts +4 -1
  58. package/src/serialization/protobuf/encode.ts +5 -1
  59. package/src/types/messages.ts +450 -185
  60. package/src/types/transport.ts +28 -3
  61. package/src/utils/transportLog.ts +102 -0
  62. package/dist/protocols/v2/debug.d.ts +0 -13
  63. package/dist/protocols/v2/debug.d.ts.map +0 -1
  64. package/src/protocols/v2/debug.ts +0 -26
@@ -1,12 +1,18 @@
1
- import { PROTOCOL_V2_PACKET_SRC_COMMAND } from '../../constants';
1
+ import {
2
+ PROTOCOL_V2_DEFAULT_RESPONSE_TIMEOUT_MS,
3
+ PROTOCOL_V2_PACKET_SRC_COMMAND,
4
+ } from '../../constants';
2
5
  import { ProtocolV2FrameAssembler, concatUint8Arrays } from './frame-assembler';
3
6
  import { ProtocolV2SequenceCursor } from './sequence-cursor';
7
+ import { ProtocolV2LinkError } from './errors';
4
8
  import { ProtocolV2 } from '..';
5
9
  import * as check from '../../utils/highlevel-checks';
6
10
  import { LogBlockCommand } from '../../utils/logBlockCommand';
7
11
 
8
12
  import type { Root } from 'protobufjs/light';
9
- import type { MessageFromOneKey } from '../../types';
13
+ import type { MessageFromOneKey, TransportWriteMetrics } from '../../types';
14
+
15
+ export * from './errors';
10
16
 
11
17
  export type ProtocolV2Schemas = {
12
18
  protocolV1: Root;
@@ -16,8 +22,10 @@ export type ProtocolV2Schemas = {
16
22
  export type ProtocolV2CallContext = {
17
23
  messageName: string;
18
24
  timeoutMs?: number;
19
- highVolume: boolean;
25
+ highThroughput: boolean;
26
+ writeWithResponse?: boolean;
20
27
  generation: number;
28
+ signal: AbortSignal;
21
29
  };
22
30
 
23
31
  type ProtocolLogger = {
@@ -45,6 +53,8 @@ export type ProtocolV2CallOptions = {
45
53
  expectedTypes?: string[];
46
54
  intermediateTypes?: string[];
47
55
  onIntermediateResponse?: (response: MessageFromOneKey) => void;
56
+ onWriteCompleted?: (metrics: TransportWriteMetrics) => void;
57
+ writeWithResponse?: boolean;
48
58
  };
49
59
 
50
60
  export { concatUint8Arrays, ProtocolV2FrameAssembler };
@@ -71,16 +81,7 @@ export function bytesToHex(bytes: Uint8Array): string {
71
81
  .join('');
72
82
  }
73
83
 
74
- // Frame header bytes worth logging: SOF, len lo/hi, header CRC, router, attr, seq.
75
- // The rest of the frame is protobuf payload and may contain sensitive fields
76
- // (mnemonic words via WordAck, PINs, seeds via LoadDevice, ...), so raw frame
77
- // hex logs must never include it.
78
- const PROTOCOL_V2_DEBUG_HEADER_BYTES = 7;
79
- const PROTOCOL_V2_DEBUG_ARRAY_ITEMS_LIMIT = 20;
80
- const PROTOCOL_V2_DEBUG_OBJECT_KEYS_LIMIT = 40;
81
- const PROTOCOL_V2_DEBUG_STRING_LIMIT = 512;
82
- const PROTOCOL_V2_DEBUG_DEPTH_LIMIT = 4;
83
- const HIGH_VOLUME_PROTOCOL_V2_CALLS = new Set([
84
+ const REDUCED_DEBUG_PROTOCOL_V2_CALLS = new Set([
84
85
  ...LogBlockCommand,
85
86
  'FilesystemFileRead',
86
87
  'FileRead',
@@ -88,103 +89,22 @@ const HIGH_VOLUME_PROTOCOL_V2_CALLS = new Set([
88
89
  ]);
89
90
 
90
91
  function shouldReduceProtocolV2Debug(name: string) {
91
- return HIGH_VOLUME_PROTOCOL_V2_CALLS.has(name);
92
- }
93
-
94
- function frameHeaderDebugHex(frame: Uint8Array): string {
95
- // Only the frame header is dumped as hex; the payload is logged separately
96
- // in sanitized/structured form (sanitizeProtocolV2DebugPayload).
97
- return bytesToHex(frame.slice(0, PROTOCOL_V2_DEBUG_HEADER_BYTES));
92
+ return REDUCED_DEBUG_PROTOCOL_V2_CALLS.has(name);
98
93
  }
99
94
 
100
- function getBinaryByteLength(value: unknown): number | undefined {
101
- if (value instanceof ArrayBuffer) {
102
- return value.byteLength;
103
- }
104
-
105
- if (ArrayBuffer.isView(value)) {
106
- return value.byteLength;
107
- }
108
-
109
- if (typeof Blob !== 'undefined' && value instanceof Blob) {
110
- return value.size;
111
- }
112
-
113
- return undefined;
114
- }
115
-
116
- function summarizeRedactedData(value: unknown): string {
117
- const byteLength = getBinaryByteLength(value);
118
- if (byteLength !== undefined) {
119
- return `[redacted data: ${byteLength} bytes]`;
120
- }
121
-
122
- if (typeof value === 'string') {
123
- return `[redacted data: string length=${value.length}]`;
124
- }
125
-
126
- if (Array.isArray(value)) {
127
- return `[redacted data: array length=${value.length}]`;
128
- }
129
-
130
- if (value && typeof value === 'object') {
131
- return `[redacted data: object keys=${Object.keys(value).length}]`;
132
- }
133
-
134
- return `[redacted data: ${typeof value}]`;
135
- }
136
-
137
- function sanitizeProtocolV2DebugPayload(value: unknown, key = '', depth = 0): unknown {
138
- if (/^(data|payload)$/i.test(key) && value !== null && value !== undefined) {
139
- return summarizeRedactedData(value);
140
- }
141
-
142
- if (/(passphrase|pin|mnemonic|seed|private)/i.test(key)) {
143
- return '[redacted sensitive value]';
144
- }
145
-
146
- const byteLength = getBinaryByteLength(value);
147
- if (byteLength !== undefined) {
148
- return `[binary: ${byteLength} bytes]`;
149
- }
150
-
151
- if (typeof value === 'string') {
152
- return value.length > PROTOCOL_V2_DEBUG_STRING_LIMIT
153
- ? `${value.slice(0, PROTOCOL_V2_DEBUG_STRING_LIMIT)}... (len=${value.length})`
154
- : value;
155
- }
156
-
157
- if (!value || typeof value !== 'object') {
158
- return value;
159
- }
160
-
161
- if (depth >= PROTOCOL_V2_DEBUG_DEPTH_LIMIT) {
162
- return Array.isArray(value)
163
- ? `[array length=${value.length}]`
164
- : `[object keys=${Object.keys(value).length}]`;
165
- }
166
-
167
- if (Array.isArray(value)) {
168
- const items = value
169
- .slice(0, PROTOCOL_V2_DEBUG_ARRAY_ITEMS_LIMIT)
170
- .map(item => sanitizeProtocolV2DebugPayload(item, key, depth + 1));
171
- if (value.length > PROTOCOL_V2_DEBUG_ARRAY_ITEMS_LIMIT) {
172
- items.push(`... (${value.length - PROTOCOL_V2_DEBUG_ARRAY_ITEMS_LIMIT} more)`);
173
- }
174
- return items;
175
- }
95
+ const HIGH_THROUGHPUT_PROTOCOL_V2_CALLS = new Set([
96
+ 'FilesystemFileWrite',
97
+ 'FileWrite',
98
+ 'EmmcFileWrite',
99
+ 'FirmwareUpload',
100
+ 'ResourceAck',
101
+ 'FilesystemFileRead',
102
+ 'FileRead',
103
+ 'EmmcFileRead',
104
+ ]);
176
105
 
177
- const entries = Object.entries(value).slice(0, PROTOCOL_V2_DEBUG_OBJECT_KEYS_LIMIT);
178
- const sanitized: Record<string, unknown> = {};
179
- entries.forEach(([entryKey, entryValue]) => {
180
- sanitized[entryKey] = sanitizeProtocolV2DebugPayload(entryValue, entryKey, depth + 1);
181
- });
182
- if (Object.keys(value).length > PROTOCOL_V2_DEBUG_OBJECT_KEYS_LIMIT) {
183
- sanitized.__truncated__ = `${
184
- Object.keys(value).length - PROTOCOL_V2_DEBUG_OBJECT_KEYS_LIMIT
185
- } more keys`;
186
- }
187
- return sanitized;
106
+ export function isProtocolV2HighThroughputCall(name: string) {
107
+ return HIGH_THROUGHPUT_PROTOCOL_V2_CALLS.has(name);
188
108
  }
189
109
 
190
110
  const COMMON_TERMINAL_RESPONSE_TYPES = new Set([
@@ -219,11 +139,13 @@ export async function withProtocolTimeout<T>(
219
139
  promise: Promise<T>,
220
140
  timeoutMs: number | undefined,
221
141
  createTimeoutError: () => Error,
222
- onTimeout?: () => void
142
+ onTimeout?: () => void,
143
+ abortSignal?: AbortSignal
223
144
  ): Promise<T> {
224
145
  if (!timeoutMs) return promise;
225
146
 
226
147
  let timer: ReturnType<typeof setTimeout> | undefined;
148
+ let abortHandler: (() => void) | undefined;
227
149
  try {
228
150
  return await Promise.race([
229
151
  promise,
@@ -235,17 +157,29 @@ export async function withProtocolTimeout<T>(
235
157
  reject(createTimeoutError());
236
158
  }, timeoutMs);
237
159
  }),
160
+ ...(abortSignal
161
+ ? [
162
+ new Promise<never>((_, reject) => {
163
+ abortHandler = () => {
164
+ reject(new Error('Protocol V2 operation aborted'));
165
+ };
166
+ if (abortSignal.aborted) {
167
+ abortHandler();
168
+ } else {
169
+ abortSignal.addEventListener('abort', abortHandler, { once: true });
170
+ }
171
+ }),
172
+ ]
173
+ : []),
238
174
  ]);
239
175
  } finally {
240
176
  if (timer) clearTimeout(timer);
177
+ if (abortHandler) {
178
+ abortSignal?.removeEventListener('abort', abortHandler);
179
+ }
241
180
  }
242
181
  }
243
182
 
244
- // Write completion is owned by the concrete transport. A Promise.race watchdog
245
- // here can return while libusb/WebUSB is still flushing a large Protocol V2
246
- // frame, which desynchronizes later request/response pairs.
247
- export const PROTOCOL_V2_WRITE_WATCHDOG_TIMEOUT_MS = 0;
248
-
249
183
  export class ProtocolV2Session {
250
184
  private readonly options: ProtocolV2SessionOptions;
251
185
 
@@ -255,6 +189,8 @@ export class ProtocolV2Session {
255
189
  // in-flight calls on the same session would steal each other's responses.
256
190
  private pendingCall: Promise<unknown> = Promise.resolve();
257
191
 
192
+ private lastResponseSequence?: number;
193
+
258
194
  constructor(options: ProtocolV2SessionOptions) {
259
195
  this.options = options;
260
196
  this.sequenceCursor = options.sequenceCursor ?? new ProtocolV2SequenceCursor();
@@ -273,7 +209,7 @@ export class ProtocolV2Session {
273
209
  return result;
274
210
  }
275
211
 
276
- private async executeCall(
212
+ private executeCall(
277
213
  name: string,
278
214
  data: Record<string, unknown>,
279
215
  callOptions: ProtocolV2CallOptions
@@ -291,104 +227,117 @@ export class ProtocolV2Session {
291
227
  createTimeoutError,
292
228
  generation = 0,
293
229
  } = this.options;
230
+ const timeoutMs =
231
+ callOptions.timeoutMs && callOptions.timeoutMs > 0
232
+ ? callOptions.timeoutMs
233
+ : PROTOCOL_V2_DEFAULT_RESPONSE_TIMEOUT_MS;
234
+ const abortController = new AbortController();
294
235
 
295
236
  const shouldReduceDebug = shouldReduceProtocolV2Debug(name);
296
- const callContext: ProtocolV2CallContext = {
237
+ const baseCallContext: ProtocolV2CallContext = {
297
238
  messageName: name,
298
- timeoutMs: callOptions.timeoutMs,
299
- highVolume: shouldReduceDebug,
239
+ timeoutMs,
240
+ highThroughput: isProtocolV2HighThroughputCall(name),
241
+ ...(callOptions.writeWithResponse === undefined
242
+ ? {}
243
+ : { writeWithResponse: callOptions.writeWithResponse }),
300
244
  generation,
245
+ signal: abortController.signal,
301
246
  };
302
- await prepareCall?.(callContext);
303
- const protoSeq = this.sequenceCursor.next();
304
- const frame = ProtocolV2.encodeFrame(schemas, name, data, {
305
- packetSrc,
306
- router,
307
- seq: protoSeq,
308
- logger: shouldReduceDebug ? undefined : logger,
309
- logPrefix,
310
- context: `tx:${name}`,
311
- });
312
- const expectedSeq = frame[6];
313
-
314
- if (maxFrameBytes !== undefined && frame.length > maxFrameBytes) {
315
- throw new Error(
316
- `Protocol V2 frame too large for transport: ${frame.length} > ${maxFrameBytes}`
317
- );
318
- }
319
247
 
320
- if (!shouldReduceDebug) {
321
- logger?.debug?.(
322
- `[${logPrefix}] TX payload name=${name}`,
323
- sanitizeProtocolV2DebugPayload(data)
324
- );
325
- logger?.debug?.(
326
- `[${logPrefix}] TX frame name=${name} len=${frame.length} router=${frame[4]} attr=${
327
- frame[5]
328
- } seq=${expectedSeq} headerHex=${frameHeaderDebugHex(frame)}`
329
- );
330
- }
331
-
332
- // Lenient watchdog on the write phase only — see
333
- // PROTOCOL_V2_WRITE_WATCHDOG_TIMEOUT_MS for the rationale.
334
- await withProtocolTimeout(
335
- writeFrame(frame, callContext),
336
- PROTOCOL_V2_WRITE_WATCHDOG_TIMEOUT_MS,
337
- () =>
338
- new Error(
339
- `Protocol V2 write timeout after ${PROTOCOL_V2_WRITE_WATCHDOG_TIMEOUT_MS}ms for ${name}`
340
- )
341
- );
248
+ const runCall = async (): Promise<MessageFromOneKey> => {
249
+ await prepareCall?.(baseCallContext);
250
+ const protoSeq = this.sequenceCursor.next();
251
+ const frame = ProtocolV2.encodeFrame(schemas, name, data, {
252
+ packetSrc,
253
+ router,
254
+ seq: protoSeq,
255
+ });
256
+
257
+ if (maxFrameBytes !== undefined && frame.length > maxFrameBytes) {
258
+ throw new Error(
259
+ `Protocol V2 frame too large for transport: ${frame.length} > ${maxFrameBytes}`
260
+ );
261
+ }
342
262
 
343
- // Cancellation flag for the read loop: when the response timeout fires,
344
- // Promise.race alone would leave this loop running as a zombie that keeps
345
- // consuming frames meant for the next call. The timeout callback flips the
346
- // flag so the loop exits and discards any late frame.
347
- const cancellation = { cancelled: false };
263
+ const writeStartedAt = Date.now();
264
+ await writeFrame(frame, baseCallContext);
265
+ try {
266
+ callOptions.onWriteCompleted?.({
267
+ elapsedMs: Math.max(Date.now() - writeStartedAt, 0),
268
+ frameBytes: frame.byteLength,
269
+ });
270
+ } catch (error) {
271
+ logger?.error?.(`${logPrefix} write metrics callback failed: ${String(error)}`);
272
+ }
348
273
 
349
- const readResponse = async (): Promise<MessageFromOneKey> => {
350
274
  // Some Protocol V2 operations emit progress notifications before the
351
275
  // terminal response. Consume those frames here so callers still see a
352
276
  // request/terminal-response shaped API.
353
- while (!cancellation.cancelled) {
354
- const rxFrame = await readFrame(callContext);
355
- if (cancellation.cancelled) {
356
- // Timed out while waiting: drop the late frame and stop reading.
357
- break;
277
+ while (!abortController.signal.aborted) {
278
+ const rxFrame = await readFrame(baseCallContext);
279
+ if (abortController.signal.aborted) break;
280
+
281
+ let header: ReturnType<typeof ProtocolV2.inspectFrameHeader>;
282
+ try {
283
+ header = ProtocolV2.inspectFrameHeader(rxFrame);
284
+ } catch (cause) {
285
+ throw new ProtocolV2LinkError(
286
+ 'frame',
287
+ `Protocol V2 frame validation failed: ${getErrorMessage(cause)}`,
288
+ cause
289
+ );
358
290
  }
359
- if (!shouldReduceDebug) {
360
- logger?.debug?.(
361
- `[${logPrefix}] RX frame len=${rxFrame.length} router=${rxFrame[4]} attr=${
362
- rxFrame[5]
363
- } seq=${rxFrame[6]} headerHex=${frameHeaderDebugHex(rxFrame)}`
291
+ if (header.router !== router) {
292
+ throw new ProtocolV2LinkError(
293
+ 'router',
294
+ `Protocol V2 router mismatch: expected ${router}, got ${header.router}`
364
295
  );
365
296
  }
366
- const isAck = ProtocolV2.isAckFrame(rxFrame);
367
- if (isAck) {
368
- if (!shouldReduceDebug) {
369
- logger?.debug?.(`[${logPrefix}] skip Proto Link ACK seq=${rxFrame[6]}`);
370
- }
297
+ if (header.packetSrc !== packetSrc) {
298
+ throw new ProtocolV2LinkError(
299
+ 'packet-source',
300
+ `Protocol V2 packet source mismatch: expected ${packetSrc}, got ${header.packetSrc}`
301
+ );
371
302
  }
372
- if (!isAck) {
373
- const decoded = ProtocolV2.decodeFrame(schemas, rxFrame, {
374
- logger: shouldReduceDebug ? undefined : logger,
375
- logPrefix,
376
- context: `rx:${name}`,
377
- });
378
- if (!shouldReduceDebug && decoded.seq !== expectedSeq) {
379
- logger?.debug?.(
380
- `[${logPrefix}] seq differs for ${name}: tx=${expectedSeq}, rx=${decoded.seq}`
303
+
304
+ let isAck: boolean;
305
+ try {
306
+ isAck = ProtocolV2.isAckFrame(rxFrame);
307
+ } catch (cause) {
308
+ throw new ProtocolV2LinkError(
309
+ 'frame',
310
+ `Protocol V2 ACK validation failed: ${getErrorMessage(cause)}`,
311
+ cause
312
+ );
313
+ }
314
+ if (isAck) {
315
+ if (header.seq !== protoSeq) {
316
+ throw new ProtocolV2LinkError(
317
+ 'ack-sequence',
318
+ `Protocol V2 ACK sequence mismatch: expected ${protoSeq}, got ${header.seq}`
381
319
  );
382
320
  }
383
- if (!shouldReduceDebug) {
384
- logger?.debug?.(
385
- `[${logPrefix}] TX name=${name} seq=${expectedSeq} | RX seq=${decoded.seq} messageTypeId=${decoded.messageTypeId} pbPayload=${decoded.pbPayload.length}B`
321
+ } else {
322
+ let decoded: ReturnType<typeof ProtocolV2.decodeFrame>;
323
+ try {
324
+ decoded = ProtocolV2.decodeFrame(schemas, rxFrame);
325
+ } catch (cause) {
326
+ throw new ProtocolV2LinkError(
327
+ 'frame',
328
+ `Protocol V2 frame decode failed: ${getErrorMessage(cause)}`,
329
+ cause
386
330
  );
387
- logger?.debug?.(
388
- `[${logPrefix}] RX payload type=${decoded.type} messageTypeId=${decoded.messageTypeId}`,
389
- sanitizeProtocolV2DebugPayload(decoded.message)
331
+ }
332
+ // Firmware owns one TX sequence across all channels and packet sources,
333
+ // so frames routed elsewhere create valid gaps in this session.
334
+ if (this.lastResponseSequence === decoded.seq) {
335
+ throw new ProtocolV2LinkError(
336
+ 'response-sequence',
337
+ `Protocol V2 duplicate response sequence: ${decoded.seq}`
390
338
  );
391
339
  }
340
+ this.lastResponseSequence = decoded.seq;
392
341
 
393
342
  const response = check.call(decoded);
394
343
  if (callOptions.intermediateTypes?.includes(response.type)) {
@@ -404,20 +353,19 @@ export class ProtocolV2Session {
404
353
  }
405
354
  }
406
355
  }
407
- // Only reachable after cancellation; the outer promise has already been
408
- // rejected by the timeout, so this rejection is consumed by the race.
356
+
409
357
  throw new Error(`Protocol V2 read loop cancelled after timeout for ${name}`);
410
358
  };
411
359
 
412
360
  return withProtocolTimeout(
413
- readResponse(),
414
- callOptions.timeoutMs,
361
+ runCall(),
362
+ timeoutMs,
415
363
  () =>
416
364
  createTimeoutError
417
- ? createTimeoutError(name, callOptions.timeoutMs ?? 0)
418
- : new Error(`Protocol V2 response timeout after ${callOptions.timeoutMs}ms for ${name}`),
365
+ ? createTimeoutError(name, timeoutMs)
366
+ : new Error(`Protocol V2 response timeout after ${timeoutMs}ms for ${name}`),
419
367
  () => {
420
- cancellation.cancelled = true;
368
+ abortController.abort();
421
369
  }
422
370
  );
423
371
  }
@@ -448,7 +396,10 @@ export async function probeProtocolV2({
448
396
  const response = await call(
449
397
  'Ping',
450
398
  { message: 'protocol-v2-probe' },
451
- { timeoutMs, expectedTypes: ['Success'] }
399
+ {
400
+ timeoutMs,
401
+ expectedTypes: ['Success'],
402
+ }
452
403
  );
453
404
  if (response.type === 'Success') {
454
405
  return true;
@@ -1,5 +1,7 @@
1
1
  import { ProtocolV2FrameAssembler } from './frame-assembler';
2
+ import { ProtocolV2LinkError, isProtocolV2LinkError } from './errors';
2
3
  import { ProtocolV2LinkManager } from './link-manager';
4
+ import { getErrorMessage } from './session';
3
5
 
4
6
  import type { MessageFromOneKey, TransportCallOptions } from '../../types';
5
7
  import type { ProtocolV2CallContext, ProtocolV2Schemas, ProtocolV2SessionOptions } from './session';
@@ -32,7 +34,7 @@ export abstract class ProtocolV2UsbTransportBase<Key> {
32
34
  this.protocolV2UsbOptions = options;
33
35
  this.protocolV2UsbLinks = new ProtocolV2LinkManager<Key>({
34
36
  getSchemas: () => this.getProtocolV2UsbSchemas(),
35
- classifyError: () => 'link-fatal',
37
+ classifyError: error => (isProtocolV2LinkError(error) ? 'link-fatal' : 'recoverable'),
36
38
  onLinkInvalidated: async (key, reason) => {
37
39
  this.protocolV2UsbAssemblers.get(key)?.reset();
38
40
  await this.resetProtocolV2UsbNativeLink(key, reason);
@@ -61,7 +63,7 @@ export abstract class ProtocolV2UsbTransportBase<Key> {
61
63
  protected abstract createProtocolV2UsbTimeoutError(name: string, timeoutMs: number): Error;
62
64
 
63
65
  protected onProtocolV2UsbLinkInvalidated(_key: Key, _reason: string): Promise<void> | void {
64
- // 子类按需清理协议缓存或记录平台日志。
66
+ // Subclasses may clear protocol caches or emit platform-specific diagnostics.
65
67
  }
66
68
 
67
69
  protected async rotateProtocolV2UsbGeneration(key: Key, reason: string): Promise<number> {
@@ -118,10 +120,13 @@ export abstract class ProtocolV2UsbTransportBase<Key> {
118
120
 
119
121
  const assertCurrentGeneration = () => {
120
122
  if (cancellation.reason) {
121
- throw new Error(cancellation.reason);
123
+ throw new ProtocolV2LinkError('generation', cancellation.reason);
122
124
  }
123
125
  if (this.protocolV2UsbGenerations.get(key) !== generation) {
124
- throw new Error('Protocol V2 USB connection generation changed');
126
+ throw new ProtocolV2LinkError(
127
+ 'generation',
128
+ 'Protocol V2 USB connection generation changed'
129
+ );
125
130
  }
126
131
  };
127
132
 
@@ -131,11 +136,14 @@ export abstract class ProtocolV2UsbTransportBase<Key> {
131
136
  generation,
132
137
  prepareCall: () => {
133
138
  assertCurrentGeneration();
134
- this.getProtocolV2UsbAssembler(key).reset();
135
139
  },
136
140
  writeFrame: async (frame: Uint8Array, context: ProtocolV2CallContext) => {
137
141
  assertCurrentGeneration();
138
- await this.writeProtocolV2UsbPacket(key, frame, context);
142
+ try {
143
+ await this.writeProtocolV2UsbPacket(key, frame, context);
144
+ } catch (error) {
145
+ throw this.createProtocolV2UsbIoError('write', error);
146
+ }
139
147
  assertCurrentGeneration();
140
148
  },
141
149
  readFrame: async (context: ProtocolV2CallContext) => {
@@ -143,13 +151,15 @@ export abstract class ProtocolV2UsbTransportBase<Key> {
143
151
  const assembler = this.getProtocolV2UsbAssembler(key);
144
152
  let frame = assembler.push(new Uint8Array(0));
145
153
  while (!frame) {
146
- const packetRead = this.readProtocolV2UsbPacket(key, context).then(packet => ({
147
- packet,
148
- }));
154
+ const packetRead = this.readProtocolV2UsbPacket(key, context)
155
+ .then(packet => ({ packet }))
156
+ .catch(error => {
157
+ throw this.createProtocolV2UsbIoError('read', error);
158
+ });
149
159
  const cancelled = cancellation.promise.then(reason => ({ reason }));
150
160
  const result = await Promise.race([packetRead, cancelled]);
151
161
  if ('reason' in result) {
152
- throw new Error(result.reason);
162
+ throw new ProtocolV2LinkError('generation', result.reason);
153
163
  }
154
164
  assertCurrentGeneration();
155
165
  frame = assembler.push(result.packet);
@@ -191,4 +201,13 @@ export abstract class ProtocolV2UsbTransportBase<Key> {
191
201
  };
192
202
  return cancellation;
193
203
  }
204
+
205
+ private createProtocolV2UsbIoError(operation: 'read' | 'write', error: unknown) {
206
+ if (isProtocolV2LinkError(error)) return error;
207
+ return new ProtocolV2LinkError(
208
+ 'io',
209
+ `Protocol V2 USB ${operation} failed: ${getErrorMessage(error) || 'unknown error'}`,
210
+ error
211
+ );
212
+ }
194
213
  }
@@ -70,7 +70,10 @@ function messageToJSON(Message: Message<Record<string, unknown>>, fields: Type['
70
70
  }
71
71
  // message type
72
72
  else if (field.resolvedType!.fields) {
73
- res[key] = messageToJSON(value, field.resolvedType!.fields);
73
+ // [compatibility]: absent optional sub-messages are not own properties
74
+ // of the decoded instance, so value is undefined here; decode them to
75
+ // null, matching the optional primitive-field convention above.
76
+ res[key] = value == null ? null : messageToJSON(value, (field.resolvedType as Type).fields);
74
77
  } else {
75
78
  throw new Error(`case not handled: ${key}`);
76
79
  }
@@ -1,5 +1,6 @@
1
1
  import { Buffer } from 'buffer';
2
2
  import ByteBuffer from 'bytebuffer';
3
+ import { Enum } from 'protobufjs/light';
3
4
 
4
5
  import { isPrimitiveField } from '../../utils/protobuf';
5
6
 
@@ -49,7 +50,10 @@ export function patch(Message: Type, payload: any) {
49
50
  // repeated
50
51
  if (field.repeated) {
51
52
  const RefMessage = Message.lookupTypeOrEnum(field.type);
52
- patched[key] = value.map((v: any) => patch(RefMessage, v));
53
+ patched[key] =
54
+ RefMessage instanceof Enum
55
+ ? value.map((v: any) => transform(field.type, v))
56
+ : value.map((v: any) => patch(RefMessage, v));
53
57
  }
54
58
  // message type
55
59
  else if (typeof value === 'object' && value !== null) {