Repository navigation
feat(homekit): hold the call open briefly after the live view closes - #88
Merged
Merged
Conversation
atdr
force-pushed
the
feat/hangup-grace-period
branch
from
September 12, 2026 12:00
37a9cfa to
ed19015
Compare
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
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
force-pushed
the
feat/hangup-grace-period
branch
from
September 12, 2026 12:04
ed19015 to
285eebf
Compare
atdr
force-pushed
the
feat/hangup-grace-period
branch
from
September 12, 2026 15:18
adf3bb0 to
285eebf
Compare
5 tasks done
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
This was referenced Sep 17, 2026
This was referenced Sep 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
HOMEKIT_HANGUP_GRACE_MS(default 3000) so the lock tile still has a call to send DTMF to.The problem
The Home app offers two routes from a doorbell notification to the lock tile, and they behave differently:
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.
sendDtmfSequencewrites media frames to the active call, and the lock handler only requiresgetActiveCall(). So there is no need to keep the session or ffmpeg alive, only the call.Changes
homekit.js_stopSessionnow 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:
hangup-scheduledhangup-cancelledhangup-skippedhangupCancellation is wired into
_startSession(view reopened),endHapSession(caller hung up) andshutdown().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
callSidthat 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.exampleNew
HOMEKIT_HANGUP_GRACE_MS, default 3000, validated as a positive integer like its neighbours. Named for consistency withSHUTDOWN_GRACE_MS.Docs
README.md— Stage 9's log example now showshangup-scheduledahead 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.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
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.cancelPendingHangUpfails 8, neuteringdeferPendingHangUpfails 1, dropping thecallSididentity guard fails 2, dropping the supersede cancel fails 1.homekit.jsconfirmed byte-identical afterwards.npm run coveragepasses too, which is the run that caught the bug below.homekit.jsrises from 32.21% to 40.09% lines, and the project from 67.28% to 68.58%.0with[config] HOMEKIT_HANGUP_GRACE_MS must be a positive integer.hangup-scheduled(homekit-session-stopped) →hangup-scheduled(unlock-sent) +unlock-requested→hangup8.0 s after the tap. Made as a Twilio self-test call, so the tone was sent but no door was involved.hangup-cancelled(homekit-session-restarted) logged 3.5 s afterhangup-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 testand failednpm run coverage. The coverage run preloadshomekit.jsthrough--require, so by the time the test file seeded itstwilio-apistub into the require cache,homekit.jswas already loaded with the realhangUpCalldestructured, 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.jsfrom 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=8000is 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:testmock timers, whose API shape differs across the Node 20/22/24 range CI runs; mock timers would have been a matrix-dependent flake.homekit.jsexports 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 whenserver.jsbegan exporting its internals, and the export carries a comment saying why it exists.🤖 Generated with Claude Code
https://claude.ai/code/session_01Rq7wnggNTrhwGMADfJ5yvf