@matter/protocol 0.17.2-alpha.0-20260609-97109a2d3 → 0.17.2

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 (40) hide show
  1. package/dist/cjs/ble/BleConsts.d.ts +3 -12
  2. package/dist/cjs/ble/BleConsts.d.ts.map +1 -1
  3. package/dist/cjs/ble/BleConsts.js.map +1 -1
  4. package/dist/cjs/ble/BtpSessionHandler.d.ts +4 -18
  5. package/dist/cjs/ble/BtpSessionHandler.d.ts.map +1 -1
  6. package/dist/cjs/ble/BtpSessionHandler.js +60 -57
  7. package/dist/cjs/ble/BtpSessionHandler.js.map +1 -1
  8. package/dist/cjs/codec/BtpCodec.d.ts.map +1 -1
  9. package/dist/cjs/codec/BtpCodec.js +1 -5
  10. package/dist/cjs/codec/BtpCodec.js.map +1 -1
  11. package/dist/cjs/peer/PeerCommunicationError.d.ts +11 -0
  12. package/dist/cjs/peer/PeerCommunicationError.d.ts.map +1 -1
  13. package/dist/cjs/peer/PeerCommunicationError.js +3 -0
  14. package/dist/cjs/peer/PeerCommunicationError.js.map +1 -1
  15. package/dist/cjs/protocol/MessageExchange.d.ts.map +1 -1
  16. package/dist/cjs/protocol/MessageExchange.js +1 -1
  17. package/dist/cjs/protocol/MessageExchange.js.map +1 -1
  18. package/dist/esm/ble/BleConsts.d.ts +3 -12
  19. package/dist/esm/ble/BleConsts.d.ts.map +1 -1
  20. package/dist/esm/ble/BleConsts.js.map +1 -1
  21. package/dist/esm/ble/BtpSessionHandler.d.ts +4 -18
  22. package/dist/esm/ble/BtpSessionHandler.d.ts.map +1 -1
  23. package/dist/esm/ble/BtpSessionHandler.js +60 -57
  24. package/dist/esm/ble/BtpSessionHandler.js.map +1 -1
  25. package/dist/esm/codec/BtpCodec.d.ts.map +1 -1
  26. package/dist/esm/codec/BtpCodec.js +1 -5
  27. package/dist/esm/codec/BtpCodec.js.map +1 -1
  28. package/dist/esm/peer/PeerCommunicationError.d.ts +11 -0
  29. package/dist/esm/peer/PeerCommunicationError.d.ts.map +1 -1
  30. package/dist/esm/peer/PeerCommunicationError.js +3 -0
  31. package/dist/esm/peer/PeerCommunicationError.js.map +1 -1
  32. package/dist/esm/protocol/MessageExchange.d.ts.map +1 -1
  33. package/dist/esm/protocol/MessageExchange.js +6 -2
  34. package/dist/esm/protocol/MessageExchange.js.map +1 -1
  35. package/package.json +5 -5
  36. package/src/ble/BleConsts.ts +3 -12
  37. package/src/ble/BtpSessionHandler.ts +85 -69
  38. package/src/codec/BtpCodec.ts +1 -2
  39. package/src/peer/PeerCommunicationError.ts +11 -0
  40. package/src/protocol/MessageExchange.ts +6 -2
@@ -112,6 +112,9 @@ export class BtpSessionHandler {
112
112
 
113
113
  await writeBleCallback(handshakeResponse);
114
114
 
115
+ // Start awaiting the ack for the just-sent handshake response only now that it is on the wire.
116
+ btpSession.ackReceiveTimer.start();
117
+
115
118
  return btpSession;
116
119
  }
117
120
 
@@ -163,10 +166,9 @@ export class BtpSessionHandler {
163
166
  throw new BtpProtocolError(`Unsupported BTP version ${btpVersion}`);
164
167
  }
165
168
  if (role === "peripheral") {
166
- // No sequenced packet has been received yet (the handshake request is unsequenced), so there is
167
- // nothing to acknowledge until the first fragment arrives.
169
+ // The handshake request is unsequenced, so nothing is pending to acknowledge yet.
168
170
  this.prevAckedSequenceNumber = this.prevIncomingSequenceNumber;
169
- this.ackReceiveTimer.start();
171
+ // ackReceiveTimer is started once the handshake response has actually been written (see factory).
170
172
  } else {
171
173
  this.sendAckTimer.start();
172
174
  this.prevIncomingSequenceNumber = 0;
@@ -304,11 +306,9 @@ export class BtpSessionHandler {
304
306
  await this.handleMatterMessagePayload(payloadToProcess);
305
307
  }
306
308
 
307
- // A received ack may have reopened the remote window, so flush any queued fragments that were
308
- // previously held back; the ack pending for this packet piggybacks on them.
309
+ // A received ack may have reopened the remote window; flush held-back fragments (they piggyback the ack).
309
310
  await this.processSendQueue();
310
-
311
- // Otherwise, if nothing is queued to piggyback on and our receive window has run low, ack proactively.
311
+ // If nothing was queued to piggyback on, ack proactively when our receive window is low.
312
312
  await this.sendImmediateAckIfWindowLow();
313
313
  } catch (error) {
314
314
  logger.warn(`Error while handling incoming BTP data:`, error);
@@ -396,10 +396,11 @@ export class BtpSessionHandler {
396
396
 
397
397
  const segmentPayload = currentProcessedMessage.readByteArray(this.fragmentSize - btpHeaderLength);
398
398
 
399
+ const ackNumberToSend = hasAckNumber ? this.prevIncomingSequenceNumber : undefined;
399
400
  const btpPacket = {
400
401
  header: packetHeader,
401
402
  payload: {
402
- ackNumber: hasAckNumber ? this.prevIncomingSequenceNumber : undefined,
403
+ ackNumber: ackNumberToSend,
403
404
  sequenceNumber: this.getNextSequenceNumber(),
404
405
  messageLength: packetHeader.isBeginningSegment ? remainingMessageLength : undefined, // remainingMessageLength if the fill length on beginning packet
405
406
  segmentPayload,
@@ -410,22 +411,28 @@ export class BtpSessionHandler {
410
411
  const packet = BtpCodec.encodeBtpPacket(btpPacket);
411
412
  logger.debug(`Sending BTP packet raw: ${Bytes.toHex(packet)}`);
412
413
 
414
+ // Commit the ack before the await so a concurrent send can't observe the stale value and ack the same
415
+ // sequence twice (spec-compliant peers reject a duplicate ack).
416
+ const previousAckedSequenceNumber = this.prevAckedSequenceNumber;
417
+ if (ackNumberToSend !== undefined) {
418
+ this.prevAckedSequenceNumber = ackNumberToSend;
419
+ }
420
+
413
421
  try {
414
422
  await this.writeBleCallback(packet);
415
423
  } catch (error) {
416
- // Only silently absorb BleDisconnectedError (expected during peripheral disconnect).
417
- // Clear the queue to avoid malformed state from partially-consumed DataReaders.
418
- // Any other error is unexpected and is rethrown so the session can handle it.
419
- BleDisconnectedError.accept(error);
420
- logger.debug("BTP packet send failed because BLE is disconnected", Diagnostic.errorMessage(error));
424
+ // Roll back before re-raising so every error type (not just an absorbed disconnect) leaves clean
425
+ // state; the queue is cleared to drop partially-consumed DataReaders.
426
+ this.prevAckedSequenceNumber = previousAckedSequenceNumber;
421
427
  this.queuedOutgoingMatterMessages.length = 0;
422
428
  this.sendInProgress = false;
429
+ BleDisconnectedError.accept(error);
430
+ logger.debug(
431
+ `BTP packet (seq ${btpPacket.payload.sequenceNumber}) send failed because BLE is disconnected`,
432
+ Diagnostic.errorMessage(error),
433
+ );
423
434
  return;
424
435
  }
425
- // Update ACK bookkeeping only after the packet was sent successfully
426
- if (hasAckNumber) {
427
- this.prevAckedSequenceNumber = this.prevIncomingSequenceNumber;
428
- }
429
436
 
430
437
  if (!this.ackReceiveTimer.isRunning) {
431
438
  this.ackReceiveTimer.start(); // starts the timer
@@ -444,6 +451,11 @@ export class BtpSessionHandler {
444
451
  }
445
452
  }
446
453
  this.sendInProgress = false;
454
+
455
+ // A pending ack that could not be piggybacked (e.g. an incoming packet arrived mid-send) would otherwise
456
+ // be stranded until the send-ack timer, since the timer is one-shot and may have already fired while a
457
+ // send held the lock; flush it now that the send completed.
458
+ await this.sendStandaloneAck();
447
459
  }
448
460
 
449
461
  /**
@@ -475,11 +487,7 @@ export class BtpSessionHandler {
475
487
  await this.sendStandaloneAck();
476
488
  }
477
489
 
478
- /**
479
- * §4.19.4.8: send a pending acknowledgement immediately, ahead of the send-ack timer, once our receive window
480
- * has shrunk to the immediate-ack threshold. Skipped when an outgoing message is queued, since that fragment
481
- * will piggyback the acknowledgement.
482
- */
490
+ /** §4.19.4.8: ack proactively once our receive window runs low; skipped if queued data will piggyback it. */
483
491
  private async sendImmediateAckIfWindowLow() {
484
492
  if (this.queuedOutgoingMatterMessages.length > 0 || this.sendInProgress) return;
485
493
  if (this.localWindowFreeSlots() > MatterBle.BTP_IMMEDIATE_ACK_WINDOW_THRESHOLD) return;
@@ -488,37 +496,59 @@ export class BtpSessionHandler {
488
496
 
489
497
  /** Send a stand-alone acknowledgement packet if one is pending. */
490
498
  private async sendStandaloneAck() {
491
- if (this.prevIncomingSequenceNumber === this.prevAckedSequenceNumber) return;
492
- logger.debug(`Sending BTP ACK for sequence number ${this.prevIncomingSequenceNumber}`);
493
- const btpPacket = {
494
- header: {
495
- isHandshakeRequest: false,
496
- hasManagementOpcode: false,
497
- hasAckNumber: true,
498
- isBeginningSegment: false,
499
- isContinuingSegment: false,
500
- isEndingSegment: false,
501
- },
502
- payload: {
503
- ackNumber: this.prevIncomingSequenceNumber,
504
- sequenceNumber: this.getNextSequenceNumber(),
505
- },
506
- };
507
- const packet = BtpCodec.encodeBtpPacket(btpPacket);
499
+ // Mutually exclusive with processSendQueue (both directions): if a send is in progress it will piggyback
500
+ // the pending ack, and two concurrent writes could otherwise reach the wire out of order.
501
+ if (this.sendInProgress) return;
502
+ const ackNumberToSend = this.prevIncomingSequenceNumber;
503
+ if (ackNumberToSend === this.prevAckedSequenceNumber) return;
504
+ // §4.19.4.7: an ack still consumes a remote-window slot, so never send into a full window.
505
+ if (!this.canSend(true)) return;
506
+
507
+ this.sendInProgress = true;
508
508
  try {
509
- await this.writeBleCallback(packet);
510
- } catch (error) {
511
- // Only silently absorb BleDisconnectedError (expected during peripheral disconnect).
512
- // Any other error is unexpected and is rethrown so the session can handle it.
513
- BleDisconnectedError.accept(error);
514
- logger.debug("BTP ACK send failed because BLE is disconnected", Diagnostic.errorMessage(error));
515
- return;
516
- }
517
- this.sendAckTimer.stop();
518
- this.prevAckedSequenceNumber = this.prevIncomingSequenceNumber;
519
- if (!this.ackReceiveTimer.isRunning) {
520
- this.ackReceiveTimer.start(); // starts the timer
509
+ logger.debug(`Sending BTP ACK for sequence number ${ackNumberToSend}`);
510
+ const btpPacket = {
511
+ header: {
512
+ isHandshakeRequest: false,
513
+ hasManagementOpcode: false,
514
+ hasAckNumber: true,
515
+ isBeginningSegment: false,
516
+ isContinuingSegment: false,
517
+ isEndingSegment: false,
518
+ },
519
+ payload: {
520
+ ackNumber: ackNumberToSend,
521
+ sequenceNumber: this.getNextSequenceNumber(),
522
+ },
523
+ };
524
+ const packet = BtpCodec.encodeBtpPacket(btpPacket);
525
+
526
+ // Commit the ack before the await so an interleaved send can't re-ack the same sequence
527
+ // (spec-compliant peers reject a duplicate ack).
528
+ const previousAckedSequenceNumber = this.prevAckedSequenceNumber;
529
+ this.prevAckedSequenceNumber = ackNumberToSend;
530
+ try {
531
+ await this.writeBleCallback(packet);
532
+ } catch (error) {
533
+ // Roll back before re-raising so a failed write never leaves the ack marked as sent.
534
+ this.prevAckedSequenceNumber = previousAckedSequenceNumber;
535
+ BleDisconnectedError.accept(error);
536
+ logger.debug(
537
+ `BTP ACK (seq ${btpPacket.payload.sequenceNumber}, ack ${ackNumberToSend}) send failed because BLE is disconnected`,
538
+ Diagnostic.errorMessage(error),
539
+ );
540
+ return;
541
+ }
542
+ this.sendAckTimer.stop();
543
+ if (!this.ackReceiveTimer.isRunning) {
544
+ this.ackReceiveTimer.start(); // starts the timer
545
+ }
546
+ } finally {
547
+ this.sendInProgress = false;
521
548
  }
549
+
550
+ // Flush any Matter message that was queued while the ack held the send lock.
551
+ await this.processSendQueue();
522
552
  }
523
553
 
524
554
  /**
@@ -543,38 +573,24 @@ export class BtpSessionHandler {
543
573
  return this.sequenceNumber;
544
574
  }
545
575
 
546
- /**
547
- * Wrap-safe forward distance between two sequence numbers (modulo 256). The -1 "nothing yet" sentinels map
548
- * to 255, so callers must keep those sentinels distinct from a real sequence number 255 (true today because
549
- * sequence numbers start at 0 and a session never has 256 outstanding packets).
550
- */
576
+ /** Wrap-safe forward distance between two sequence numbers (modulo 256); the -1 sentinels read as 255. */
551
577
  private modularDistance(from: number, to: number): number {
552
578
  return (((to - from) % 256) + 256) % 256;
553
579
  }
554
580
 
555
- /**
556
- * Free slots remaining in the remote peer's receive window: the negotiated window size minus the number of
557
- * sent-but-unacknowledged packets. Gates our sending (§4.19.4.7). Mirrors CHIP's mRemoteReceiveWindowSize.
558
- */
581
+ /** Free slots in the remote peer's receive window; gates our sending (§4.19.4.7). */
559
582
  private remoteWindowFreeSlots(): number {
560
583
  return this.clientWindowSize - this.modularDistance(this.prevIncomingAckNumber, this.sequenceNumber);
561
584
  }
562
585
 
563
- /**
564
- * Free slots remaining in our own receive window: the negotiated window size minus the number of
565
- * received-but-unacknowledged packets. Drives proactive acknowledgement (§4.19.4.8). Mirrors CHIP's
566
- * mLocalReceiveWindowSize.
567
- */
586
+ /** Free slots in our own receive window; drives proactive acknowledgement (§4.19.4.8). */
568
587
  private localWindowFreeSlots(): number {
569
588
  return (
570
589
  this.clientWindowSize - this.modularDistance(this.prevAckedSequenceNumber, this.prevIncomingSequenceNumber)
571
590
  );
572
591
  }
573
592
 
574
- /**
575
- * Whether a packet may be sent now without violating the remote receive window rules. A packet carrying an
576
- * acknowledgement may use the last open slot; a data-only packet must leave it free (§4.19.4.7).
577
- */
593
+ /** §4.19.4.7: a data-only packet must leave the last window slot free; an ack-bearing packet may use it. */
578
594
  private canSend(carriesAck: boolean): boolean {
579
595
  const free = this.remoteWindowFreeSlots();
580
596
  if (free <= 0) return false;
@@ -266,9 +266,8 @@ export class BtpCodec {
266
266
  throw new BtpProtocolError("Please use the specific methods to encode a Handshake packet");
267
267
  }
268
268
 
269
+ // The handshake and management-opcode bits are rejected above, so only the segment/ack bits remain.
269
270
  const header =
270
- // (isHandshakeRequest ? BtpHeaderBits.HandshakeBit : 0) | ... but always false here
271
- // (hasManagementOpcode ? BtpHeaderBits.ManagementMsg : 0) | ... but alw<ys false here
272
271
  (hasAckNumber ? BtpHeaderBits.AckMsg : 0) |
273
272
  (isEndingSegment ? BtpHeaderBits.EndSegment : 0) |
274
273
  (isContinuingSegment ? BtpHeaderBits.ContinuingSegment : 0) |
@@ -45,6 +45,17 @@ export class PeerUnresponsiveError extends TransientPeerCommunicationError {
45
45
  }
46
46
  }
47
47
 
48
+ /**
49
+ * Thrown when an expected message did not arrive on an active exchange before the timeout.
50
+ *
51
+ * Subtype of {@link PeerUnresponsiveError}, distinguished from the bare error which is raised when our own outbound
52
+ * message was never acknowledged (the peer may never have received it). This subtype means the exchange was live but
53
+ * the awaited message — a command response or a subscription/server report — never came. It does not by itself imply
54
+ * our request was delivered; a caller that reads strictly after a successful (acked) send may additionally infer
55
+ * delivery from that ordering.
56
+ */
57
+ export class PeerMessageMissingError extends PeerUnresponsiveError {}
58
+
48
59
  /**
49
60
  * Thrown when a session is closed due to peer shutdown.
50
61
  */
@@ -7,7 +7,11 @@
7
7
  import { Message, PacketHeader, SessionType } from "#codec/MessageCodec.js";
8
8
  import { Mark } from "#common/Mark.js";
9
9
  import { NetworkProfile } from "#peer/NetworkProfile.js";
10
- import { PeerUnresponsiveError, TransientPeerCommunicationError } from "#peer/PeerCommunicationError.js";
10
+ import {
11
+ PeerMessageMissingError,
12
+ PeerUnresponsiveError,
13
+ TransientPeerCommunicationError,
14
+ } from "#peer/PeerCommunicationError.js";
11
15
  import { GroupSession } from "#session/GroupSession.js";
12
16
  import type { NodeSession } from "#session/NodeSession.js";
13
17
  import { Session } from "#session/Session.js";
@@ -696,7 +700,7 @@ export class MessageExchange {
696
700
  abort: options?.abort,
697
701
 
698
702
  timeoutHandler: () => {
699
- throw new PeerUnresponsiveError(timeout!);
703
+ throw new PeerMessageMissingError(timeout!);
700
704
  },
701
705
  });
702
706