buzz-me-in 2.2.0-beta.1 → 2.2.0-beta.3

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/.env.example CHANGED
@@ -55,6 +55,13 @@ TWILIO_MEDIA_PAYLOAD_MAX_BYTES=512
55
55
  TWILIO_UNLOCK_DIGITS=w9w
56
56
  # Grace period for SIGINT/SIGTERM graceful shutdown before forced exit.
57
57
  SHUTDOWN_GRACE_MS=10000
58
+ # How long the Twilio call is held open after the HomeKit live view closes,
59
+ # before the call is hung up. Backing out of the camera view to the room view
60
+ # tears the stream down, and the lock tile there needs the call to still be up
61
+ # to send its DTMF. Raise this if you find yourself reaching the lock too late;
62
+ # the only cost is that much silence for the caller after you are done.
63
+ # Tapping unlock restarts the window, so the digits are never cut short.
64
+ HOMEKIT_HANGUP_GRACE_MS=3000
58
65
 
59
66
  # Logging
60
67
  # LOG_LEVEL controls minimum emitted level: debug | info | warn | error
package/README.md CHANGED
@@ -422,6 +422,8 @@ Tap the lock tile in the Home app → set to Unlocked.
422
422
 
423
423
  The lock tile resets to Secured automatically after 3 seconds.
424
424
 
425
+ Two routes reach the lock tile, and they behave differently. The grid button at the bottom right of the camera view brings the lock up _beside_ the running stream, which is the smoother of the two. Backing out to the room view instead closes the camera view and tears the stream down; the lock tile is still there under **Other**, and it works because the call is held open for `HOMEKIT_HANGUP_GRACE_MS` after the view closes. Tapping unlock restarts that window, so the digits are never cut off mid-sequence.
426
+
425
427
  ---
426
428
 
427
429
  ### Stage 8 — Caller hangs up
@@ -446,10 +448,13 @@ Open the live view, then dismiss it on the iPhone (tap the X / end button).
446
448
  **Pass (server log):**
447
449
 
448
450
  ```text
451
+ {"ts":"...","level":"info","message":"Hangup scheduled after grace period","component":"homekit","event":"hangup-scheduled","reason":"homekit-session-stopped","callSid":"CA...","sessionId":"...","graceMs":3000}
449
452
  {"ts":"...","level":"info","message":"Inbound ffmpeg exited","component":"homekit","event":"ffin-exit","reason":"nonzero-exit","exitCode":null,"sessionId":"..."}
450
453
  {"ts":"...","level":"info","message":"Hanging up call","component":"twilio-api","event":"hangup","callSid":"CA..."}
451
454
  ```
452
455
 
456
+ The call is not hung up the instant the view closes. It is held open for `HOMEKIT_HANGUP_GRACE_MS` (default 3000) so the lock tile still has a call to send DTMF to, then hung up. Reopening the view inside that window logs `hangup-cancelled` instead and the call continues; if the caller hangs up first, the pending hangup logs `hangup-skipped` and does nothing.
457
+
453
458
  **Pass (Twilio):** The call shows as completed in the [Twilio Console call log](https://console.twilio.com/us1/monitor/logs/calls).
454
459
 
455
460
  ---
package/homekit.js CHANGED
@@ -277,6 +277,9 @@ const streamingDelegate = {
277
277
  // ---------------------------------------------------------------------------
278
278
 
279
279
  function _startSession(sessionID, s, request, callback) {
280
+ // Reopening the live view inside the grace window means the user is not
281
+ // finished after all.
282
+ cancelPendingHangUp('homekit-session-restarted');
280
283
  const videoParams = srtpParams(s.hkVideoKey, s.hkVideoSalt);
281
284
  const audioParams = srtpParams(s.hkAudioKey, s.hkAudioSalt);
282
285
  const video = request.video;
@@ -555,15 +558,93 @@ function _stopSession(sessionID, hangUp) {
555
558
  if (s.ffOut) killFfmpeg(s.ffOut);
556
559
  if (s.sdpPath) removeReturnAudioSdp(s.sdpPath);
557
560
 
561
+ // Only the last session ending means the user is finished. HomeKit runs up
562
+ // to cameraStreamCount sessions at once, and moving between the room view's
563
+ // camera tile and the full camera view starts a second one, so a STOP here
564
+ // routinely arrives while another session is still streaming. Hanging up on
565
+ // it kills a live call: seen on 2026-09-12, where session ac5c8c21 stopping
566
+ // ended the call under session d332e46a three seconds into the stream.
558
567
  const activeCall = getActiveCall();
559
- if (hangUp && activeCall) {
560
- hangUpCall(activeCall.callSid)
568
+ if (hangUp && activeCall && activeSessions.size === 0) {
569
+ scheduleHangUp(activeCall.callSid, sessionID);
570
+ }
571
+ }
572
+
573
+ // ---------------------------------------------------------------------------
574
+ // Deferred hangup
575
+ //
576
+ // Closing the live view is not the same as being finished with the caller. The
577
+ // Home app reaches the lock tile two different ways: the grid button inside the
578
+ // camera view keeps the stream up, but backing out to the room view tears it
579
+ // down, and tapping the lock from there used to find no call left to send DTMF
580
+ // to. Holding the call open briefly after STOP makes both routes work, because
581
+ // unlock needs the call's WebSocket, not a HomeKit streaming session.
582
+ //
583
+ // The call is still hung up when the user really has finished; it just costs
584
+ // config.homekitHangupGraceMs of silence first, ffmpeg having already been
585
+ // killed above.
586
+ // ---------------------------------------------------------------------------
587
+
588
+ const HANGUP_GRACE_MS = config.homekitHangupGraceMs;
589
+
590
+ /** @type {{ timer: NodeJS.Timeout, callSid: string, sessionID: string } | null} */
591
+ let pendingHangUp = null;
592
+
593
+ function cancelPendingHangUp(reason) {
594
+ if (!pendingHangUp) return;
595
+ clearTimeout(pendingHangUp.timer);
596
+ logger.info('Pending hangup cancelled', {
597
+ event: 'hangup-cancelled',
598
+ reason,
599
+ callSid: pendingHangUp.callSid,
600
+ sessionId: pendingHangUp.sessionID,
601
+ });
602
+ pendingHangUp = null;
603
+ }
604
+
605
+ /**
606
+ * Restart the grace window, so a hangup cannot land on top of an action the
607
+ * user has just taken. Without this an unlock tapped late in the window could
608
+ * be cut off before the intercom acts on the digits.
609
+ */
610
+ function deferPendingHangUp(reason) {
611
+ if (!pendingHangUp) return;
612
+ const { callSid, sessionID } = pendingHangUp;
613
+ clearTimeout(pendingHangUp.timer);
614
+ pendingHangUp = null;
615
+ scheduleHangUp(callSid, sessionID, reason);
616
+ }
617
+
618
+ function scheduleHangUp(callSid, sessionID, reason = 'homekit-session-stopped') {
619
+ cancelPendingHangUp('superseded');
620
+ logger.info('Hangup scheduled after grace period', {
621
+ event: 'hangup-scheduled',
622
+ reason,
623
+ callSid,
624
+ sessionId: sessionID,
625
+ graceMs: HANGUP_GRACE_MS,
626
+ });
627
+ const timer = setTimeout(() => {
628
+ pendingHangUp = null;
629
+ const activeCall = getActiveCall();
630
+ // The caller may have hung up during the window, and the slot may already
631
+ // hold a different call. Either way this one is no longer ours to end.
632
+ if (!activeCall || activeCall.callSid !== callSid) {
633
+ logger.info('Pending hangup skipped; call already gone', {
634
+ event: 'hangup-skipped',
635
+ reason: 'call-no-longer-active',
636
+ callSid,
637
+ sessionId: sessionID,
638
+ });
639
+ return;
640
+ }
641
+ hangUpCall(callSid)
561
642
  .then((result) => {
562
643
  if (result && result.alreadyEnded) {
563
644
  logger.info('Twilio call already ended while stopping HomeKit session', {
564
645
  event: 'hangup-call-already-ended',
565
646
  reason: 'twilio-call-not-in-progress',
566
- callSid: activeCall.callSid,
647
+ callSid,
567
648
  sessionId: sessionID,
568
649
  });
569
650
  }
@@ -572,12 +653,14 @@ function _stopSession(sessionID, hangUp) {
572
653
  logger.error('Failed to hang up call while stopping HomeKit session', {
573
654
  event: 'hangup-call-failed',
574
655
  reason: 'twilio-hangup-failed',
575
- callSid: activeCall.callSid,
656
+ callSid,
576
657
  sessionId: sessionID,
577
658
  error,
578
659
  });
579
660
  });
580
- }
661
+ }, HANGUP_GRACE_MS);
662
+ timer.unref();
663
+ pendingHangUp = { timer, callSid, sessionID };
581
664
  }
582
665
 
583
666
  // ---------------------------------------------------------------------------
@@ -611,6 +694,10 @@ lockService
611
694
  try {
612
695
  await sendDtmfSequence(activeCall, config.twilioUnlockDigits);
613
696
  state.markActivity(activeCall.callSid, 'unlock-dtmf');
697
+ // Unlocking from the room view is the case this grace window exists
698
+ // for, so give the intercom a full window to act on the digits rather
699
+ // than whatever was left of the previous one.
700
+ deferPendingHangUp('unlock-sent');
614
701
  logger.info('Sent DTMF unlock over active media stream', {
615
702
  event: 'unlock-requested',
616
703
  callSid: activeCall.callSid,
@@ -789,6 +876,9 @@ function clearMulawPassthrough(stream) {
789
876
  * Does NOT call hangUpCall — the call is already gone.
790
877
  */
791
878
  function endHapSession() {
879
+ // The caller is already gone, so a hangup left over from a closed live view
880
+ // has nothing to end.
881
+ cancelPendingHangUp('call-ended');
792
882
  for (const sessionID of activeSessions.keys()) {
793
883
  _stopSession(sessionID, /* hangUp= */ false);
794
884
  }
@@ -811,6 +901,7 @@ function setOnHapSessionStarted(handler) {
811
901
  * clean stop, restart and reboot.
812
902
  */
813
903
  function shutdown() {
904
+ cancelPendingHangUp('shutdown');
814
905
  endHapSession();
815
906
  removeSdpDir();
816
907
  // Nothing to tear down if start() never ran: unpublish() on an accessory
@@ -833,4 +924,14 @@ module.exports = {
833
924
  endHapSession,
834
925
  setOnHapSessionStarted,
835
926
  shutdown,
927
+ // Exported for tests/homekit-hangup-grace.test.cjs. Reaching the deferred
928
+ // hangup through the streaming delegate would mean standing up a full SRTP
929
+ // session, which tests nothing about the timer. _stopSession and the session
930
+ // map go with them: driving only scheduleHangUp tests when the timer fires
931
+ // but not whether it should have been set, which is where the live bug was.
932
+ scheduleHangUp,
933
+ cancelPendingHangUp,
934
+ deferPendingHangUp,
935
+ _stopSession,
936
+ activeSessions,
836
937
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buzz-me-in",
3
- "version": "2.2.0-beta.1",
3
+ "version": "2.2.0-beta.3",
4
4
  "description": "HomeKit-compatible apartment intercom via Twilio media streams",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -138,6 +138,7 @@ module.exports = {
138
138
  streamStartTimeoutMs: parsePositiveInt('STREAM_START_TIMEOUT_MS', '3000'),
139
139
  twilioMediaPayloadMaxBytes: parsePositiveInt('TWILIO_MEDIA_PAYLOAD_MAX_BYTES', '512'),
140
140
  shutdownGraceMs: parsePositiveInt('SHUTDOWN_GRACE_MS', '10000'),
141
+ homekitHangupGraceMs: parsePositiveInt('HOMEKIT_HANGUP_GRACE_MS', '3000'),
141
142
  streamAuthSecret,
142
143
  streamAuthTtlSec: 90,
143
144
  statusApiToken,