buzz-me-in 2.2.0-beta.0 → 2.2.0-beta.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.
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
@@ -86,7 +86,8 @@ The health endpoints are deliberately unauthenticated so systemd checks, uptime
86
86
  - Node.js >= 20
87
87
  - Runtime is tested on Node 20+.
88
88
  - Repo tooling/CI currently runs on Node 20, 22, and 24.
89
- - ffmpeg with libx264 and libopus: `sudo apt install ffmpeg`
89
+ - ffmpeg >= 5.1 with libx264 and libopus: `sudo apt install ffmpeg`
90
+ - The 5.1 floor is the `-ch_layout` option used on the inbound audio pipeline. Debian 12 (bookworm) ships 5.1 and Raspberry Pi OS based on Debian 13 ships 7.1, so apt satisfies this on any currently supported release.
90
91
 
91
92
  ### Accounts and services
92
93
 
@@ -421,6 +422,8 @@ Tap the lock tile in the Home app → set to Unlocked.
421
422
 
422
423
  The lock tile resets to Secured automatically after 3 seconds.
423
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
+
424
427
  ---
425
428
 
426
429
  ### Stage 8 — Caller hangs up
@@ -445,10 +448,13 @@ Open the live view, then dismiss it on the iPhone (tap the X / end button).
445
448
  **Pass (server log):**
446
449
 
447
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}
448
452
  {"ts":"...","level":"info","message":"Inbound ffmpeg exited","component":"homekit","event":"ffin-exit","reason":"nonzero-exit","exitCode":null,"sessionId":"..."}
449
453
  {"ts":"...","level":"info","message":"Hanging up call","component":"twilio-api","event":"hangup","callSid":"CA..."}
450
454
  ```
451
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
+
452
458
  **Pass (Twilio):** The call shows as completed in the [Twilio Console call log](https://console.twilio.com/us1/monitor/logs/calls).
453
459
 
454
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;
@@ -291,6 +294,10 @@ function _startSession(sessionID, s, request, callback) {
291
294
  // Input 0 – raw mulaw/8kHz from Twilio via stdin
292
295
  // Let the raw audio demuxer derive PTS from sample count. Using wall-clock
293
296
  // timestamps breaks when ffmpeg drains the buffered startup audio burst.
297
+ // -ch_layout declares mono rather than -ac counting one channel: a count
298
+ // leaves the layout unset, so the decoder guesses it and logs "Guessed
299
+ // Channel Layout: mono" on every call. Our stderr handler is a warn, so
300
+ // that guess looked like a fault in the journal. Needs ffmpeg >= 5.1.
294
301
  //
295
302
  // Input 1 – synthetic black video (lavfi color source)
296
303
  // Generates H.264 Baseline/3.1 frames at 15 fps.
@@ -313,8 +320,8 @@ function _startSession(sessionID, s, request, callback) {
313
320
  'mulaw',
314
321
  '-ar',
315
322
  '8000',
316
- '-ac',
317
- '1',
323
+ '-ch_layout',
324
+ 'mono',
318
325
  '-i',
319
326
  'pipe:0',
320
327
 
@@ -553,13 +560,85 @@ function _stopSession(sessionID, hangUp) {
553
560
 
554
561
  const activeCall = getActiveCall();
555
562
  if (hangUp && activeCall) {
556
- hangUpCall(activeCall.callSid)
563
+ scheduleHangUp(activeCall.callSid, sessionID);
564
+ }
565
+ }
566
+
567
+ // ---------------------------------------------------------------------------
568
+ // Deferred hangup
569
+ //
570
+ // Closing the live view is not the same as being finished with the caller. The
571
+ // Home app reaches the lock tile two different ways: the grid button inside the
572
+ // camera view keeps the stream up, but backing out to the room view tears it
573
+ // down, and tapping the lock from there used to find no call left to send DTMF
574
+ // to. Holding the call open briefly after STOP makes both routes work, because
575
+ // unlock needs the call's WebSocket, not a HomeKit streaming session.
576
+ //
577
+ // The call is still hung up when the user really has finished; it just costs
578
+ // config.homekitHangupGraceMs of silence first, ffmpeg having already been
579
+ // killed above.
580
+ // ---------------------------------------------------------------------------
581
+
582
+ const HANGUP_GRACE_MS = config.homekitHangupGraceMs;
583
+
584
+ /** @type {{ timer: NodeJS.Timeout, callSid: string, sessionID: string } | null} */
585
+ let pendingHangUp = null;
586
+
587
+ function cancelPendingHangUp(reason) {
588
+ if (!pendingHangUp) return;
589
+ clearTimeout(pendingHangUp.timer);
590
+ logger.info('Pending hangup cancelled', {
591
+ event: 'hangup-cancelled',
592
+ reason,
593
+ callSid: pendingHangUp.callSid,
594
+ sessionId: pendingHangUp.sessionID,
595
+ });
596
+ pendingHangUp = null;
597
+ }
598
+
599
+ /**
600
+ * Restart the grace window, so a hangup cannot land on top of an action the
601
+ * user has just taken. Without this an unlock tapped late in the window could
602
+ * be cut off before the intercom acts on the digits.
603
+ */
604
+ function deferPendingHangUp(reason) {
605
+ if (!pendingHangUp) return;
606
+ const { callSid, sessionID } = pendingHangUp;
607
+ clearTimeout(pendingHangUp.timer);
608
+ pendingHangUp = null;
609
+ scheduleHangUp(callSid, sessionID, reason);
610
+ }
611
+
612
+ function scheduleHangUp(callSid, sessionID, reason = 'homekit-session-stopped') {
613
+ cancelPendingHangUp('superseded');
614
+ logger.info('Hangup scheduled after grace period', {
615
+ event: 'hangup-scheduled',
616
+ reason,
617
+ callSid,
618
+ sessionId: sessionID,
619
+ graceMs: HANGUP_GRACE_MS,
620
+ });
621
+ const timer = setTimeout(() => {
622
+ pendingHangUp = null;
623
+ const activeCall = getActiveCall();
624
+ // The caller may have hung up during the window, and the slot may already
625
+ // hold a different call. Either way this one is no longer ours to end.
626
+ if (!activeCall || activeCall.callSid !== callSid) {
627
+ logger.info('Pending hangup skipped; call already gone', {
628
+ event: 'hangup-skipped',
629
+ reason: 'call-no-longer-active',
630
+ callSid,
631
+ sessionId: sessionID,
632
+ });
633
+ return;
634
+ }
635
+ hangUpCall(callSid)
557
636
  .then((result) => {
558
637
  if (result && result.alreadyEnded) {
559
638
  logger.info('Twilio call already ended while stopping HomeKit session', {
560
639
  event: 'hangup-call-already-ended',
561
640
  reason: 'twilio-call-not-in-progress',
562
- callSid: activeCall.callSid,
641
+ callSid,
563
642
  sessionId: sessionID,
564
643
  });
565
644
  }
@@ -568,12 +647,14 @@ function _stopSession(sessionID, hangUp) {
568
647
  logger.error('Failed to hang up call while stopping HomeKit session', {
569
648
  event: 'hangup-call-failed',
570
649
  reason: 'twilio-hangup-failed',
571
- callSid: activeCall.callSid,
650
+ callSid,
572
651
  sessionId: sessionID,
573
652
  error,
574
653
  });
575
654
  });
576
- }
655
+ }, HANGUP_GRACE_MS);
656
+ timer.unref();
657
+ pendingHangUp = { timer, callSid, sessionID };
577
658
  }
578
659
 
579
660
  // ---------------------------------------------------------------------------
@@ -607,6 +688,10 @@ lockService
607
688
  try {
608
689
  await sendDtmfSequence(activeCall, config.twilioUnlockDigits);
609
690
  state.markActivity(activeCall.callSid, 'unlock-dtmf');
691
+ // Unlocking from the room view is the case this grace window exists
692
+ // for, so give the intercom a full window to act on the digits rather
693
+ // than whatever was left of the previous one.
694
+ deferPendingHangUp('unlock-sent');
610
695
  logger.info('Sent DTMF unlock over active media stream', {
611
696
  event: 'unlock-requested',
612
697
  callSid: activeCall.callSid,
@@ -785,6 +870,9 @@ function clearMulawPassthrough(stream) {
785
870
  * Does NOT call hangUpCall — the call is already gone.
786
871
  */
787
872
  function endHapSession() {
873
+ // The caller is already gone, so a hangup left over from a closed live view
874
+ // has nothing to end.
875
+ cancelPendingHangUp('call-ended');
788
876
  for (const sessionID of activeSessions.keys()) {
789
877
  _stopSession(sessionID, /* hangUp= */ false);
790
878
  }
@@ -807,6 +895,7 @@ function setOnHapSessionStarted(handler) {
807
895
  * clean stop, restart and reboot.
808
896
  */
809
897
  function shutdown() {
898
+ cancelPendingHangUp('shutdown');
810
899
  endHapSession();
811
900
  removeSdpDir();
812
901
  // Nothing to tear down if start() never ran: unpublish() on an accessory
@@ -829,4 +918,10 @@ module.exports = {
829
918
  endHapSession,
830
919
  setOnHapSessionStarted,
831
920
  shutdown,
921
+ // Exported for tests/homekit-hangup-grace.test.cjs. Reaching the deferred
922
+ // hangup through the streaming delegate would mean standing up a full SRTP
923
+ // session, which tests nothing about the timer.
924
+ scheduleHangUp,
925
+ cancelPendingHangUp,
926
+ deferPendingHangUp,
832
927
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buzz-me-in",
3
- "version": "2.2.0-beta.0",
3
+ "version": "2.2.0-beta.2",
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,