Skip to content

feat(homekit): hold the call open briefly after the live view closes - #88

Merged
atdr merged 3 commits into
mainfrom
feat/hangup-grace-period
Sep 27, 2026
Merged

atdr merged 3 commits into
mainfrom
feat/hangup-grace-period

Conversation

@atdr

@atdr atdr commented Sep 12, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Closing the HomeKit live view no longer hangs up the Twilio call instantly. The call is held open for HOMEKIT_HANGUP_GRACE_MS (default 3000) so the lock tile still has a call to send DTMF to.
  • This fixes a real dead end: one of the two routes to the lock tile in the Home app tears the stream down on the way, and unlock then silently did nothing useful.
  • The STOP path previously logged nothing at all, which made a closed live view indistinguishable from a fault in the journal. All four outcomes are now logged.

The problem

The Home app offers two routes from a doorbell notification to 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. This always worked.
  • Backing out to the room view closes the camera view, which sends a HomeKit STOP. The lock tile is still sitting there under Other, but the stop handler had already hung up the call, so tapping it sent DTMF to nothing.

The second route is the one you land on if you tap the notification rather than long-press it, so it is not an obscure path.

The key detail that makes the fix small: unlock needs the call's WebSocket, not a HomeKit streaming session. sendDtmfSequence writes media frames to the active call, and the lock handler only requires getActiveCall(). So there is no need to keep the session or ffmpeg alive, only the call.

Changes

homekit.js

_stopSession now schedules the hangup instead of performing it. ffmpeg is still torn down immediately, so nothing expensive is held open; only the call lingers, costing the caller that much silence.

Four outcomes, each logged, where the STOP path previously emitted nothing:

Event When
hangup-scheduled live view closed, grace window started
hangup-cancelled view reopened inside the window, caller hung up, or shutdown
hangup-skipped window expired but the call was already gone, or a different call now holds the slot
hangup window expired with the same call still live

Cancellation is wired into _startSession (view reopened), endHapSession (caller hung up) and shutdown().

Unlock restarts the window rather than inheriting the old deadline. Without this the feature only half works: close the view, tap the lock two seconds later, and the hangup fires while the intercom is still acting on the digits. Since unlocking from the room view is the entire point, truncating the DTMF would defeat it.

The pending hangup is pinned to the callSid that scheduled it, so a new caller arriving inside the window is never hung up by the previous call's timer.

src/core/config.js, .env.example

New HOMEKIT_HANGUP_GRACE_MS, default 3000, validated as a positive integer like its neighbours. Named for consistency with SHUTDOWN_GRACE_MS.

Docs

  • README.md — Stage 9's log example now shows hangup-scheduled ahead of the hangup, since AGENTS.md requires those examples to match real output. Stage 7 documents the two routes to the lock tile and why they differ.
  • docs/architecture.md — the deferred hangup added to the call flow.
  • docs/testing.md — the new test file.
  • The debugging playbook gains a triage row for "call ends the moment the live view is closed", which is working as designed and now has a knob.

Unrelated drift fixed in passing

The playbook's log-event inventory was stale: it still listed accessory-destroy-failed, removed back in 2.0.1, and was missing six events that had accumulated since. Regenerated with the command the skill itself documents for the purpose.

Test Plan

  • All five gates pass locally (typecheck, lint, format:check, check, test — 142 tests, up from 134).
  • tests/homekit-hangup-grace.test.cjs — eight cases covering both directions, that the timer fires and that it does not: the call outliving a closed view and still being hung up after it; cancellation on reopen; the restart an unlock buys; the four cases where it must fire against nothing (call already ended, a different call in the slot, superseded by a later schedule, shutdown); and a first case that proves the stub is in force before anything else trusts it.
  • Mutation-checked rather than trusting a green run. Removing the grace fails 2 cases, neutering cancelPendingHangUp fails 8, neutering deferPendingHangUp fails 1, dropping the callSid identity guard fails 2, dropping the supersede cancel fails 1. homekit.js confirmed byte-identical afterwards.
  • npm run coverage passes too, which is the run that caught the bug below. homekit.js rises from 32.21% to 40.09% lines, and the project from 67.28% to 68.58%.
  • Config verified directly: defaults to 3000, honours an override, and rejects 0 with [config] HOMEKIT_HANGUP_GRACE_MS must be a positive integer.
  • Live call after deploy (2026-09-27, default 8000 ms): closed the camera view, tapped the lock from the room view 3.4 s later. Journal: hangup-scheduled (homekit-session-stopped) → hangup-scheduled (unlock-sent) + unlock-requested → hangup 8.0 s after the tap. Made as a Twilio self-test call, so the tone was sent but no door was involved.
  • Next real visitor: the door opens when unlocked from the room view.
  • Live call: reopened the live view inside the window, hangup-cancelled (homekit-session-restarted) logged 3.5 s after hangup-scheduled (2026-09-27 18:54 UTC; also seen twice on 2026-09-17).

The test shipped broken first, and CI caught it

Worth recording, because the failure was invisible in the obvious place. The first version passed npm test and failed npm run coverage. The coverage run preloads homekit.js through --require, so by the time the test file seeded its twilio-api stub into the require cache, homekit.js was already loaded with the real hangUpCall destructured, and the stub was ignored.

The signature is distinctive: the three cases asserting a hangup fires failed, and the four asserting it does not all passed, because with the real client nothing is ever recorded. A test suite that only ever asserted the negative direction would have gone green while measuring nothing.

Fixed by evicting homekit.js from the cache alongside its dependencies. The new first case asserts the stub is actually in force, so this cannot recur silently.

A note on the default

3000 is the value asked for, and it is an env var precisely so it can be tuned without a release. It may prove tight for the real motion of closing the camera view, finding the tile under Other, and tapping it. Raising it costs nothing but silence for a caller you have already finished with, so HOMEKIT_HANGUP_GRACE_MS=8000 is a reasonable thing to try on a deployment that finds 3 seconds rushed.

Testing notes

The test uses real timers with the window turned down by env, not node:test mock timers, whose API shape differs across the Node 20/22/24 range CI runs; mock timers would have been a matrix-dependent flake.

homekit.js exports the three hangup helpers so the timer can be driven directly. Reaching it through the streaming delegate would mean standing up a full SRTP session, which tests nothing about the timer. This follows the precedent set when server.js began exporting its internals, and the export carries a comment saying why it exists.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rq7wnggNTrhwGMADfJ5yvf

@atdr
atdr force-pushed the feat/hangup-grace-period branch from 37a9cfa to ed19015 Compare September 12, 2026 12:00
@codecov

codecov Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.50000% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.48%. Comparing base (f432af1) to head (057ae3e).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
homekit.js 87.36% 12 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #88      +/-   ##
==========================================
+ Coverage   67.19%   68.48%   +1.29%     
==========================================
  Files          14       14              
  Lines        3091     3183      +92     
==========================================
+ Hits         2077     2180     +103     
+ Misses       1014     1003      -11     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

atdr and others added 2 commits September 12, 2026 13:04
Closing the HomeKit live view sends a STOP, and the stop handler hung up
the Twilio call immediately. That made one of the two routes to the lock
tile a dead end: the grid button inside the camera view brings the lock
up beside a running stream, but backing out to the room view tears the
stream down, and by the time the lock tile is tapped there is no call
left to send DTMF to.

Unlock only needs the call's WebSocket, not a HomeKit streaming session,
so the fix is to defer the hangup rather than to keep the session alive.
ffmpeg is still torn down at once; only the call lingers, for
HOMEKIT_HANGUP_GRACE_MS (default 3000).

Reopening the view cancels the pending hangup, the caller hanging up
skips it, and sending unlock restarts the window so digits are never cut
off mid-sequence. Each of those states is logged, where the STOP path
previously logged nothing at all and made a closed view indistinguishable
from a fault.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rq7wnggNTrhwGMADfJ5yvf
Eight cases over the grace window: the call outliving a closed live view
and still being hung up after it, cancellation when the view reopens,
the restart an unlock buys, and the cases where the timer must fire
against nothing (call already ended, a different call now in the slot,
superseded by a later schedule, shutdown).

Behavioural rather than a source assertion, because the failure mode is
a timer that silently stops firing or fires when it should not, which
looks correct in the source. Mutation-checked: removing the grace fails
2, neutering cancel fails 8, neutering the unlock restart fails 1,
dropping the callSid identity guard fails 2, and dropping the supersede
cancel fails 1.

homekit.js has to be evicted from the require cache alongside its
dependencies, not just seeded around. The coverage run preloads it
through --require, so without the eviction the stub is silently ignored
and every assertion measures the real Twilio client. The first case now
proves the stub is in force before anything else trusts it, since that
failure mode is invisible: the three cases expecting a hangup fail while
the four expecting none pass.

Real timers with the window turned down by env rather than node:test
mock timers, whose API shape differs across the Node 20/22/24 range CI
runs. Assertions that expect a hangup poll for it so a loaded runner
makes the suite slow rather than red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rq7wnggNTrhwGMADfJ5yvf
@atdr
atdr force-pushed the feat/hangup-grace-period branch from ed19015 to 285eebf Compare September 12, 2026 12:04
@atdr
atdr deployed to npm-prerelease September 12, 2026 12:05 — with GitHub Actions Active
@atdr
atdr deployed to npm-prerelease September 12, 2026 14:58 — with GitHub Actions Active
@atdr
atdr force-pushed the feat/hangup-grace-period branch from adf3bb0 to 285eebf Compare September 12, 2026 15:18
@atdr
atdr added this pull request to stack #90 September 12, 2026 15:19
Measured on the production Pi rather than guessed. Closing the camera
view and then reaching the lock tile under Other, unhurried and with a
deliberate pause, took 2.92 s. Against the 3 s default that left 78 ms
of margin: the unlock landed, but only because tapping it restarts the
window, and without that the hangup would have fired 78 ms after the
digits went out and truncated them.

A window that passes by 78 ms is not a validated default. The cost of a
longer one is silence for a caller you have already finished with, which
is cheap next to a door that does not open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rq7wnggNTrhwGMADfJ5yvf
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant