@onekeyfe/hd-transport 1.2.0-alpha.8 → 1.2.0-alpha.80

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 (70) hide show
  1. package/__tests__/messages.test.js +220 -0
  2. package/__tests__/protocol-v2-ble-frame-writer.test.js +119 -0
  3. package/__tests__/protocol-v2-link-manager.test.js +437 -0
  4. package/__tests__/protocol-v2-usb-transport-base.test.js +330 -0
  5. package/__tests__/protocol-v2.test.js +535 -53
  6. package/__tests__/transport-log.test.js +79 -0
  7. package/dist/constants.d.ts +5 -1
  8. package/dist/constants.d.ts.map +1 -1
  9. package/dist/index.d.ts +822 -199
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +853 -229
  12. package/dist/protocols/index.d.ts +14 -5
  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/constants.d.ts +1 -0
  17. package/dist/protocols/v2/constants.d.ts.map +1 -1
  18. package/dist/protocols/v2/decode.d.ts +13 -2
  19. package/dist/protocols/v2/decode.d.ts.map +1 -1
  20. package/dist/protocols/v2/encode.d.ts +2 -3
  21. package/dist/protocols/v2/encode.d.ts.map +1 -1
  22. package/dist/protocols/v2/errors.d.ts +8 -0
  23. package/dist/protocols/v2/errors.d.ts.map +1 -0
  24. package/dist/protocols/v2/frame-assembler.d.ts.map +1 -1
  25. package/dist/protocols/v2/index.d.ts +2 -1
  26. package/dist/protocols/v2/index.d.ts.map +1 -1
  27. package/dist/protocols/v2/link-manager.d.ts +39 -0
  28. package/dist/protocols/v2/link-manager.d.ts.map +1 -0
  29. package/dist/protocols/v2/sequence-cursor.d.ts +5 -0
  30. package/dist/protocols/v2/sequence-cursor.d.ts.map +1 -0
  31. package/dist/protocols/v2/session.d.ts +24 -6
  32. package/dist/protocols/v2/session.d.ts.map +1 -1
  33. package/dist/protocols/v2/usb-transport-base.d.ts +32 -0
  34. package/dist/protocols/v2/usb-transport-base.d.ts.map +1 -0
  35. package/dist/serialization/protobuf/decode.d.ts.map +1 -1
  36. package/dist/serialization/protobuf/encode.d.ts.map +1 -1
  37. package/dist/types/messages.d.ts +386 -110
  38. package/dist/types/messages.d.ts.map +1 -1
  39. package/dist/types/transport.d.ts +21 -4
  40. package/dist/types/transport.d.ts.map +1 -1
  41. package/dist/utils/transportLog.d.ts +9 -0
  42. package/dist/utils/transportLog.d.ts.map +1 -0
  43. package/messages-protocol-v2.json +1138 -441
  44. package/package.json +3 -3
  45. package/scripts/protobuf-build.sh +86 -208
  46. package/scripts/protobuf-patches/index.js +1 -0
  47. package/scripts/protobuf-types.js +19 -1
  48. package/src/constants.ts +34 -14
  49. package/src/index.ts +13 -1
  50. package/src/protocols/index.ts +39 -40
  51. package/src/protocols/v2/ble-frame-writer.ts +77 -0
  52. package/src/protocols/v2/constants.ts +1 -0
  53. package/src/protocols/v2/crc8.ts +1 -1
  54. package/src/protocols/v2/decode.ts +71 -37
  55. package/src/protocols/v2/encode.ts +2 -28
  56. package/src/protocols/v2/errors.ts +25 -0
  57. package/src/protocols/v2/frame-assembler.ts +6 -4
  58. package/src/protocols/v2/index.ts +2 -1
  59. package/src/protocols/v2/link-manager.ts +209 -0
  60. package/src/protocols/v2/sequence-cursor.ts +10 -0
  61. package/src/protocols/v2/session.ts +187 -200
  62. package/src/protocols/v2/usb-transport-base.ts +213 -0
  63. package/src/serialization/protobuf/decode.ts +4 -1
  64. package/src/serialization/protobuf/encode.ts +5 -1
  65. package/src/types/messages.ts +471 -142
  66. package/src/types/transport.ts +29 -4
  67. package/src/utils/transportLog.ts +102 -0
  68. package/dist/protocols/v2/debug.d.ts +0 -13
  69. package/dist/protocols/v2/debug.d.ts.map +0 -1
  70. package/src/protocols/v2/debug.ts +0 -26
@@ -1,18 +1,33 @@
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
- import { nextProtoSeq } from './encode';
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;
13
19
  protocolV2: Root;
14
20
  };
15
21
 
22
+ export type ProtocolV2CallContext = {
23
+ messageName: string;
24
+ timeoutMs?: number;
25
+ highThroughput: boolean;
26
+ writeWithResponse?: boolean;
27
+ generation: number;
28
+ signal: AbortSignal;
29
+ };
30
+
16
31
  type ProtocolLogger = {
17
32
  debug?: (...args: any[]) => void;
18
33
  error?: (...args: any[]) => void;
@@ -22,11 +37,15 @@ export type ProtocolV2SessionOptions = {
22
37
  schemas: ProtocolV2Schemas;
23
38
  router: number;
24
39
  packetSrc?: number;
25
- writeFrame: (frame: Uint8Array) => Promise<void>;
26
- readFrame: () => Promise<Uint8Array>;
40
+ maxFrameBytes?: number;
41
+ prepareCall?: (context: ProtocolV2CallContext) => Promise<void> | void;
42
+ writeFrame: (frame: Uint8Array, context: ProtocolV2CallContext) => Promise<void>;
43
+ readFrame: (context: ProtocolV2CallContext) => Promise<Uint8Array>;
27
44
  logger?: ProtocolLogger;
28
45
  logPrefix?: string;
29
46
  createTimeoutError?: (name: string, timeoutMs: number) => Error;
47
+ sequenceCursor?: ProtocolV2SequenceCursor;
48
+ generation?: number;
30
49
  };
31
50
 
32
51
  export type ProtocolV2CallOptions = {
@@ -34,9 +53,12 @@ export type ProtocolV2CallOptions = {
34
53
  expectedTypes?: string[];
35
54
  intermediateTypes?: string[];
36
55
  onIntermediateResponse?: (response: MessageFromOneKey) => void;
56
+ onWriteCompleted?: (metrics: TransportWriteMetrics) => void;
57
+ writeWithResponse?: boolean;
37
58
  };
38
59
 
39
60
  export { concatUint8Arrays, ProtocolV2FrameAssembler };
61
+ export { ProtocolV2SequenceCursor };
40
62
 
41
63
  export function hexToBytes(hex: string): Uint8Array {
42
64
  const clean = hex.replace(/\s+/g, '');
@@ -59,16 +81,7 @@ export function bytesToHex(bytes: Uint8Array): string {
59
81
  .join('');
60
82
  }
61
83
 
62
- // Frame header bytes worth logging: SOF, len lo/hi, header CRC, router, attr, seq.
63
- // The rest of the frame is protobuf payload and may contain sensitive fields
64
- // (mnemonic words via WordAck, PINs, seeds via LoadDevice, ...), so raw frame
65
- // hex logs must never include it.
66
- const PROTOCOL_V2_DEBUG_HEADER_BYTES = 7;
67
- const PROTOCOL_V2_DEBUG_ARRAY_ITEMS_LIMIT = 20;
68
- const PROTOCOL_V2_DEBUG_OBJECT_KEYS_LIMIT = 40;
69
- const PROTOCOL_V2_DEBUG_STRING_LIMIT = 512;
70
- const PROTOCOL_V2_DEBUG_DEPTH_LIMIT = 4;
71
- const HIGH_VOLUME_PROTOCOL_V2_CALLS = new Set([
84
+ const REDUCED_DEBUG_PROTOCOL_V2_CALLS = new Set([
72
85
  ...LogBlockCommand,
73
86
  'FilesystemFileRead',
74
87
  'FileRead',
@@ -76,103 +89,22 @@ const HIGH_VOLUME_PROTOCOL_V2_CALLS = new Set([
76
89
  ]);
77
90
 
78
91
  function shouldReduceProtocolV2Debug(name: string) {
79
- return HIGH_VOLUME_PROTOCOL_V2_CALLS.has(name);
80
- }
81
-
82
- function frameHeaderDebugHex(frame: Uint8Array): string {
83
- // Only the frame header is dumped as hex; the payload is logged separately
84
- // in sanitized/structured form (sanitizeProtocolV2DebugPayload).
85
- return bytesToHex(frame.slice(0, PROTOCOL_V2_DEBUG_HEADER_BYTES));
86
- }
87
-
88
- function getBinaryByteLength(value: unknown): number | undefined {
89
- if (value instanceof ArrayBuffer) {
90
- return value.byteLength;
91
- }
92
-
93
- if (ArrayBuffer.isView(value)) {
94
- return value.byteLength;
95
- }
96
-
97
- if (typeof Blob !== 'undefined' && value instanceof Blob) {
98
- return value.size;
99
- }
100
-
101
- return undefined;
102
- }
103
-
104
- function summarizeRedactedData(value: unknown): string {
105
- const byteLength = getBinaryByteLength(value);
106
- if (byteLength !== undefined) {
107
- return `[redacted data: ${byteLength} bytes]`;
108
- }
109
-
110
- if (typeof value === 'string') {
111
- return `[redacted data: string length=${value.length}]`;
112
- }
113
-
114
- if (Array.isArray(value)) {
115
- return `[redacted data: array length=${value.length}]`;
116
- }
117
-
118
- if (value && typeof value === 'object') {
119
- return `[redacted data: object keys=${Object.keys(value).length}]`;
120
- }
121
-
122
- return `[redacted data: ${typeof value}]`;
92
+ return REDUCED_DEBUG_PROTOCOL_V2_CALLS.has(name);
123
93
  }
124
94
 
125
- function sanitizeProtocolV2DebugPayload(value: unknown, key = '', depth = 0): unknown {
126
- if (/^(data|payload)$/i.test(key) && value !== null && value !== undefined) {
127
- return summarizeRedactedData(value);
128
- }
129
-
130
- if (/(passphrase|pin|mnemonic|seed|private)/i.test(key)) {
131
- return '[redacted sensitive value]';
132
- }
133
-
134
- const byteLength = getBinaryByteLength(value);
135
- if (byteLength !== undefined) {
136
- return `[binary: ${byteLength} bytes]`;
137
- }
138
-
139
- if (typeof value === 'string') {
140
- return value.length > PROTOCOL_V2_DEBUG_STRING_LIMIT
141
- ? `${value.slice(0, PROTOCOL_V2_DEBUG_STRING_LIMIT)}... (len=${value.length})`
142
- : value;
143
- }
144
-
145
- if (!value || typeof value !== 'object') {
146
- return value;
147
- }
148
-
149
- if (depth >= PROTOCOL_V2_DEBUG_DEPTH_LIMIT) {
150
- return Array.isArray(value)
151
- ? `[array length=${value.length}]`
152
- : `[object keys=${Object.keys(value).length}]`;
153
- }
154
-
155
- if (Array.isArray(value)) {
156
- const items = value
157
- .slice(0, PROTOCOL_V2_DEBUG_ARRAY_ITEMS_LIMIT)
158
- .map(item => sanitizeProtocolV2DebugPayload(item, key, depth + 1));
159
- if (value.length > PROTOCOL_V2_DEBUG_ARRAY_ITEMS_LIMIT) {
160
- items.push(`... (${value.length - PROTOCOL_V2_DEBUG_ARRAY_ITEMS_LIMIT} more)`);
161
- }
162
- return items;
163
- }
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
+ ]);
164
105
 
165
- const entries = Object.entries(value).slice(0, PROTOCOL_V2_DEBUG_OBJECT_KEYS_LIMIT);
166
- const sanitized: Record<string, unknown> = {};
167
- entries.forEach(([entryKey, entryValue]) => {
168
- sanitized[entryKey] = sanitizeProtocolV2DebugPayload(entryValue, entryKey, depth + 1);
169
- });
170
- if (Object.keys(value).length > PROTOCOL_V2_DEBUG_OBJECT_KEYS_LIMIT) {
171
- sanitized.__truncated__ = `${
172
- Object.keys(value).length - PROTOCOL_V2_DEBUG_OBJECT_KEYS_LIMIT
173
- } more keys`;
174
- }
175
- return sanitized;
106
+ export function isProtocolV2HighThroughputCall(name: string) {
107
+ return HIGH_THROUGHPUT_PROTOCOL_V2_CALLS.has(name);
176
108
  }
177
109
 
178
110
  const COMMON_TERMINAL_RESPONSE_TYPES = new Set([
@@ -207,11 +139,13 @@ export async function withProtocolTimeout<T>(
207
139
  promise: Promise<T>,
208
140
  timeoutMs: number | undefined,
209
141
  createTimeoutError: () => Error,
210
- onTimeout?: () => void
142
+ onTimeout?: () => void,
143
+ abortSignal?: AbortSignal
211
144
  ): Promise<T> {
212
145
  if (!timeoutMs) return promise;
213
146
 
214
147
  let timer: ReturnType<typeof setTimeout> | undefined;
148
+ let abortHandler: (() => void) | undefined;
215
149
  try {
216
150
  return await Promise.race([
217
151
  promise,
@@ -223,29 +157,43 @@ export async function withProtocolTimeout<T>(
223
157
  reject(createTimeoutError());
224
158
  }, timeoutMs);
225
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
+ : []),
226
174
  ]);
227
175
  } finally {
228
176
  if (timer) clearTimeout(timer);
177
+ if (abortHandler) {
178
+ abortSignal?.removeEventListener('abort', abortHandler);
179
+ }
229
180
  }
230
181
  }
231
182
 
232
- // Write completion is owned by the concrete transport. A Promise.race watchdog
233
- // here can return while libusb/WebUSB is still flushing a large Protocol V2
234
- // frame, which desynchronizes later request/response pairs.
235
- export const PROTOCOL_V2_WRITE_WATCHDOG_TIMEOUT_MS = 0;
236
-
237
183
  export class ProtocolV2Session {
238
184
  private readonly options: ProtocolV2SessionOptions;
239
185
 
186
+ private readonly sequenceCursor: ProtocolV2SequenceCursor;
187
+
240
188
  // Serializes call() invocations: responses are matched only by type, so two
241
189
  // in-flight calls on the same session would steal each other's responses.
242
190
  private pendingCall: Promise<unknown> = Promise.resolve();
243
191
 
244
- // Per-session sequence counter (1-255, wraps skipping 0).
245
- private protoSeq = 0;
192
+ private lastResponseSequence?: number;
246
193
 
247
194
  constructor(options: ProtocolV2SessionOptions) {
248
195
  this.options = options;
196
+ this.sequenceCursor = options.sequenceCursor ?? new ProtocolV2SequenceCursor();
249
197
  }
250
198
 
251
199
  call(
@@ -261,7 +209,7 @@ export class ProtocolV2Session {
261
209
  return result;
262
210
  }
263
211
 
264
- private async executeCall(
212
+ private executeCall(
265
213
  name: string,
266
214
  data: Record<string, unknown>,
267
215
  callOptions: ProtocolV2CallOptions
@@ -270,118 +218,154 @@ export class ProtocolV2Session {
270
218
  schemas,
271
219
  router,
272
220
  packetSrc = PROTOCOL_V2_PACKET_SRC_COMMAND,
221
+ maxFrameBytes,
222
+ prepareCall,
273
223
  writeFrame,
274
224
  readFrame,
275
225
  logger,
276
226
  logPrefix = 'ProtocolV2',
277
227
  createTimeoutError,
228
+ generation = 0,
278
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();
279
235
 
280
236
  const shouldReduceDebug = shouldReduceProtocolV2Debug(name);
281
- this.protoSeq = nextProtoSeq(this.protoSeq);
282
- const frame = ProtocolV2.encodeFrame(schemas, name, data, {
283
- packetSrc,
284
- router,
285
- seq: this.protoSeq,
286
- logger: shouldReduceDebug ? undefined : logger,
287
- logPrefix,
288
- context: `tx:${name}`,
289
- });
290
- const expectedSeq = frame[6];
291
-
292
- if (!shouldReduceDebug) {
293
- logger?.debug?.(
294
- `[${logPrefix}] TX payload name=${name}`,
295
- sanitizeProtocolV2DebugPayload(data)
296
- );
297
- logger?.debug?.(
298
- `[${logPrefix}] TX frame name=${name} len=${frame.length} router=${frame[4]} attr=${
299
- frame[5]
300
- } seq=${expectedSeq} headerHex=${frameHeaderDebugHex(frame)}`
301
- );
302
- }
237
+ const baseCallContext: ProtocolV2CallContext = {
238
+ messageName: name,
239
+ timeoutMs,
240
+ highThroughput: isProtocolV2HighThroughputCall(name),
241
+ ...(callOptions.writeWithResponse === undefined
242
+ ? {}
243
+ : { writeWithResponse: callOptions.writeWithResponse }),
244
+ generation,
245
+ signal: abortController.signal,
246
+ };
303
247
 
304
- // Lenient watchdog on the write phase only — see
305
- // PROTOCOL_V2_WRITE_WATCHDOG_TIMEOUT_MS for the rationale.
306
- await withProtocolTimeout(
307
- writeFrame(frame),
308
- PROTOCOL_V2_WRITE_WATCHDOG_TIMEOUT_MS,
309
- () =>
310
- new Error(
311
- `Protocol V2 write timeout after ${PROTOCOL_V2_WRITE_WATCHDOG_TIMEOUT_MS}ms for ${name}`
312
- )
313
- );
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
+ }
314
262
 
315
- // Cancellation flag for the read loop: when the response timeout fires,
316
- // Promise.race alone would leave this loop running as a zombie that keeps
317
- // consuming frames meant for the next call. The timeout callback flips the
318
- // flag so the loop exits and discards any late frame.
319
- 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
+ }
320
273
 
321
- const readResponse = async (): Promise<MessageFromOneKey> => {
322
274
  // Some Protocol V2 operations emit progress notifications before the
323
275
  // terminal response. Consume those frames here so callers still see a
324
276
  // request/terminal-response shaped API.
325
- while (!cancellation.cancelled) {
326
- const rxFrame = await readFrame();
327
- if (cancellation.cancelled) {
328
- // Timed out while waiting: drop the late frame and stop reading.
329
- break;
330
- }
331
- if (!shouldReduceDebug) {
332
- logger?.debug?.(
333
- `[${logPrefix}] RX frame len=${rxFrame.length} router=${rxFrame[4]} attr=${
334
- rxFrame[5]
335
- } seq=${rxFrame[6]} headerHex=${frameHeaderDebugHex(rxFrame)}`
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
336
289
  );
337
290
  }
338
- const decoded = ProtocolV2.decodeFrame(schemas, rxFrame, {
339
- logger: shouldReduceDebug ? undefined : logger,
340
- logPrefix,
341
- context: `rx:${name}`,
342
- });
343
- if (!shouldReduceDebug && decoded.seq !== expectedSeq) {
344
- logger?.debug?.(
345
- `[${logPrefix}] seq differs for ${name}: tx=${expectedSeq}, rx=${decoded.seq}`
291
+ if (header.router !== router) {
292
+ throw new ProtocolV2LinkError(
293
+ 'router',
294
+ `Protocol V2 router mismatch: expected ${router}, got ${header.router}`
346
295
  );
347
296
  }
348
- if (!shouldReduceDebug) {
349
- logger?.debug?.(
350
- `[${logPrefix}] TX name=${name} seq=${expectedSeq} | RX seq=${decoded.seq} messageTypeId=${decoded.messageTypeId} pbPayload=${decoded.pbPayload.length}B`
351
- );
352
- logger?.debug?.(
353
- `[${logPrefix}] RX payload type=${decoded.type} messageTypeId=${decoded.messageTypeId}`,
354
- sanitizeProtocolV2DebugPayload(decoded.message)
297
+ if (header.packetSrc !== packetSrc) {
298
+ throw new ProtocolV2LinkError(
299
+ 'packet-source',
300
+ `Protocol V2 packet source mismatch: expected ${packetSrc}, got ${header.packetSrc}`
355
301
  );
356
302
  }
357
303
 
358
- const response = check.call(decoded);
359
- if (callOptions.intermediateTypes?.includes(response.type)) {
360
- callOptions.onIntermediateResponse?.(response);
361
- } else if (isExpectedTerminalResponse(response, callOptions.expectedTypes)) {
362
- return response;
363
- } else if (!shouldReduceDebug) {
364
- logger?.debug?.(
365
- `[${logPrefix}] skip unexpected response for ${name}: expected=${callOptions.expectedTypes?.join(
366
- '|'
367
- )} got=${response.type}`
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
368
312
  );
369
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}`
319
+ );
320
+ }
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
330
+ );
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}`
338
+ );
339
+ }
340
+ this.lastResponseSequence = decoded.seq;
341
+
342
+ const response = check.call(decoded);
343
+ if (callOptions.intermediateTypes?.includes(response.type)) {
344
+ callOptions.onIntermediateResponse?.(response);
345
+ } else if (isExpectedTerminalResponse(response, callOptions.expectedTypes)) {
346
+ return response;
347
+ } else if (!shouldReduceDebug) {
348
+ logger?.debug?.(
349
+ `[${logPrefix}] skip unexpected response for ${name}: expected=${callOptions.expectedTypes?.join(
350
+ '|'
351
+ )} got=${response.type}`
352
+ );
353
+ }
354
+ }
370
355
  }
371
- // Only reachable after cancellation; the outer promise has already been
372
- // rejected by the timeout, so this rejection is consumed by the race.
356
+
373
357
  throw new Error(`Protocol V2 read loop cancelled after timeout for ${name}`);
374
358
  };
375
359
 
376
360
  return withProtocolTimeout(
377
- readResponse(),
378
- callOptions.timeoutMs,
361
+ runCall(),
362
+ timeoutMs,
379
363
  () =>
380
364
  createTimeoutError
381
- ? createTimeoutError(name, callOptions.timeoutMs ?? 0)
382
- : 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}`),
383
367
  () => {
384
- cancellation.cancelled = true;
368
+ abortController.abort();
385
369
  }
386
370
  );
387
371
  }
@@ -412,7 +396,10 @@ export async function probeProtocolV2({
412
396
  const response = await call(
413
397
  'Ping',
414
398
  { message: 'protocol-v2-probe' },
415
- { timeoutMs, expectedTypes: ['Success'] }
399
+ {
400
+ timeoutMs,
401
+ expectedTypes: ['Success'],
402
+ }
416
403
  );
417
404
  if (response.type === 'Success') {
418
405
  return true;