buzz-me-in 2.0.0

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 ADDED
@@ -0,0 +1,68 @@
1
+ # ── Twilio ────────────────────────────────────────────────────────────────────
2
+ # From https://console.twilio.com → Account Info
3
+ TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
4
+ TWILIO_AUTH_TOKEN=your_auth_token_here
5
+
6
+ # The Twilio phone number that receives intercom calls (E.164 format)
7
+ TWILIO_PHONE_NUMBER=+1xxxxxxxxxx
8
+
9
+ # ── Server ────────────────────────────────────────────────────────────────────
10
+ PORT=8080
11
+
12
+ # Your Cloudflare Tunnel hostname — used to build the wss:// URL in TwiML
13
+ # e.g. intercom.yourdomain.com
14
+ TUNNEL_HOSTNAME=intercom.yourdomain.com
15
+
16
+ # Base URL Twilio signs against for /twiml webhook validation.
17
+ # Optional: defaults to https://{TUNNEL_HOSTNAME}
18
+ # Set this if your externally visible webhook URL differs.
19
+ #TWILIO_WEBHOOK_BASE_URL=https://intercom.yourdomain.com
20
+
21
+ # HMAC secret used to sign one-time WebSocket stream tokens embedded in TwiML.
22
+ # Must be at least 32 chars.
23
+ STREAM_AUTH_SECRET=replace_with_a_long_random_secret_min_32_chars
24
+
25
+ # Bearer token required for GET /status.
26
+ # Must be at least 16 chars.
27
+ STATUS_API_TOKEN=replace_with_a_long_random_token_min_16_chars
28
+
29
+ # Session/stream reliability tuning
30
+ # Reap an active call if no activity is seen for this many seconds.
31
+ CALL_SESSION_STALE_SEC=900
32
+ # Reject incoming WS JSON frames larger than this many bytes.
33
+ WS_MAX_MESSAGE_BYTES=4096
34
+ # Memory backstop: max concurrent /media WebSocket connections. Only one call
35
+ # is ever authenticated at a time; this just bounds a connection flood.
36
+ # Per-source rate-limiting is handled at the Cloudflare edge.
37
+ WS_MAX_CONNECTIONS=64
38
+ # Unauthenticated window: drop a /media socket that hasn't sent a valid Twilio
39
+ # `start` (with the stream token) within this many milliseconds.
40
+ STREAM_START_TIMEOUT_MS=3000
41
+ # Reject decoded Twilio media payload chunks larger than this many bytes.
42
+ TWILIO_MEDIA_PAYLOAD_MAX_BYTES=512
43
+ # DTMF sequence the server sends over the active Twilio media stream when HomeKit unlocks.
44
+ # "w" inserts a 0.5 s pause.
45
+ # Default is w9w: wait briefly, play 9, then wait briefly.
46
+ TWILIO_UNLOCK_DIGITS=w9w
47
+ # Grace period for SIGINT/SIGTERM graceful shutdown before forced exit.
48
+ SHUTDOWN_GRACE_MS=10000
49
+
50
+ # Logging
51
+ # LOG_LEVEL controls minimum emitted level: debug | info | warn | error
52
+ # Default is info when unset/invalid.
53
+ #LOG_LEVEL=info
54
+ # Set to 1 for indented JSON logs during local development.
55
+ #LOG_PRETTY=1
56
+
57
+ # ── HAP-NodeJS ────────────────────────────────────────────────────────────────
58
+ # MAC-style address — must be unique on your LAN.
59
+ # Generate once: node -e "const c=require('crypto');const b=c.randomBytes(6);console.log([...b].map(x=>x.toString(16).padStart(2,'0').toUpperCase()).join(':'))"
60
+ HAP_USERNAME=AA:BB:CC:DD:EE:FF
61
+
62
+ # 8-digit HomeKit pairing code (shown in Home app "Add Accessory" flow)
63
+ # Format: XXX-XX-XXX (digits only, dashes included)
64
+ # Generate once: node -e "const r=()=>Math.floor(Math.random()*1000).toString().padStart(3,'0');console.log(r()+'-'+Math.floor(Math.random()*100).toString().padStart(2,'0')+'-'+r())"
65
+ HAP_PINCODE=XXX-XX-XXX
66
+
67
+ # UDP port HAP-NodeJS advertises over mDNS — must be open in your firewall
68
+ HAP_PORT=47129
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andreas Richardson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,452 @@
1
+ # buzz-me-in
2
+
3
+ Apartment intercom to Apple HomeKit, bridged over Twilio.
4
+
5
+ A Raspberry Pi server that bridges an apartment intercom system into Apple HomeKit. When someone presses the intercom buzzer, your iPhone/HomePod receives a doorbell notification with a live camera tile. You can hear the caller, speak back, and unlock the door — all from the Home app or via Siri.
6
+
7
+ ## Project docs
8
+
9
+ - [Architecture guide](docs/architecture.md)
10
+ - [Testing guide](docs/testing.md)
11
+ - [Contributor guide](AGENTS.md) — git workflow, module format, logging conventions
12
+
13
+ ## How it works
14
+
15
+ ```text
16
+ Intercom box
17
+ │ PSTN call
18
+ ▼
19
+ Twilio phone number
20
+ │ POST /twiml → <Connect><Stream>
21
+ │ Bidirectional WebSocket (mulaw/8kHz audio)
22
+ ▼
23
+ Cloudflare Tunnel
24
+ │
25
+ ▼
26
+ RPi: server.js (HTTP + WebSocket on one port)
27
+ │
28
+ ├─ inbound ──► PassThrough stream
29
+ │ │ pipe
30
+ │ ▼
31
+ │ ffmpeg: mulaw/8kHz → Opus/16kHz + blank H.264
32
+ │ │ SRTP
33
+ │ ▼
34
+ │ HAP-NodeJS CameraController
35
+ │ │ HomeKit SRTP
36
+ │ ▼
37
+ │ iPhone / HomePod
38
+ │
39
+ ├─ outbound ◄─ iPhone mic → HAP-NodeJS SRTP → local UDP
40
+ │ │
41
+ │ ffmpeg: Opus → mulaw/8kHz
42
+ │ │ stdout
43
+ │ server.js wraps in Twilio media JSON envelope
44
+ │ │ WebSocket
45
+ │ ▼
46
+ │ Twilio → intercom speaker
47
+ │
48
+ └─ unlock ────► HomeKit Lock tile → generated DTMF media
49
+ sent over the active Twilio WebSocket
50
+ ```
51
+
52
+ **Single process, single port.** HTTP (TwiML) and WebSocket (media stream) share port 8080. Cloudflare Tunnel exposes one stable public URL for both.
53
+
54
+ ### HTTP endpoints
55
+
56
+ All endpoints are reachable from the public internet through the tunnel; each enforces its own auth:
57
+
58
+ | Endpoint | Method | Auth | Purpose |
59
+ | ----------- | --------- | -------------------------------------------- | -------------------------------------- |
60
+ | `/twiml` | POST | Twilio signature (`X-Twilio-Signature`) | Returns TwiML for incoming calls |
61
+ | `/media` | WebSocket | One-time HMAC stream token (issued in TwiML) | Bidirectional call audio |
62
+ | `/status` | GET | Bearer token (`STATUS_API_TOKEN`) | Active call info |
63
+ | `/healthz` | GET | None (by design) | Liveness probe, returns `{"ok":true}` |
64
+ | `/readyz` | GET | None (by design) | Readiness probe, returns `{"ok":true}` |
65
+ | `/ringtone` | GET | None | Ring tone WAV for debug/manual checks |
66
+
67
+ The health endpoints are deliberately unauthenticated so systemd checks, uptime monitors, and the test sequence below can probe them; they expose nothing beyond `{"ok":true}`.
68
+
69
+ ## Architecture notes
70
+
71
+ - **CallSid** arrives in the WebSocket `start` event (`data.start.callSid`), not in HTTP headers. It is stored in the call session manager and retrieved via `state.getActiveCall()` for mid-call operations (currently hangup).
72
+ - **Call hold** uses a bidirectional `<Connect><Stream>` in TwiML. The server sends ringback and unlock DTMF as mu-law media over the active WebSocket so the stream stays attached to the call.
73
+ - **Video** is a synthetic black H.264 stream generated by ffmpeg (`lavfi color` source). HomeKit requires a video track; there is no real camera.
74
+ - **Audio codec** is Opus (two-way). AAC-ELD is supported by some older HomeKit devices but requires compiling ffmpeg with `--enable-libfdk-aac --enable-nonfree`.
75
+
76
+ ---
77
+
78
+ ## Requirements
79
+
80
+ ### Raspberry Pi
81
+
82
+ - Raspberry Pi 4 (2 GB RAM recommended) running Raspberry Pi OS (64-bit)
83
+ - Node.js >= 20
84
+ - Runtime is tested on Node 20+.
85
+ - Repo tooling/CI currently runs on Node 20, 22, and 24.
86
+ - ffmpeg with libx264 and libopus: `sudo apt install ffmpeg`
87
+
88
+ ### Accounts and services
89
+
90
+ - [Twilio account](https://console.twilio.com) with a Voice-enabled phone number
91
+ - [Cloudflare account](https://dash.cloudflare.com) (free) with a domain, or use Cloudflare's free `*.cfargotunnel.com` subdomain
92
+
93
+ ---
94
+
95
+ ## Setup
96
+
97
+ Throughout this guide `pi@raspberrypi.local`, the `pi` user and `~/intercom` are the Raspberry Pi OS defaults, used as examples. Any host, user or directory works, as long as `WorkingDirectory`, `EnvironmentFile` and `User` in `intercom.service` are edited to match.
98
+
99
+ ### 1. Install
100
+
101
+ ```bash
102
+ sudo npm install -g buzz-me-in
103
+ command -v buzz-me-in # sanity check; the bin lands under `npm prefix -g`
104
+ ```
105
+
106
+ Contributors work from a clone of the repository instead, with `npm install`. See `AGENTS.md` for the workflow and quality gates.
107
+
108
+ ### 2. Environment variables
109
+
110
+ The server reads `.env` from its working directory and writes HomeKit pairing state into `persist/` there, so that state stays separate from the global install npm manages:
111
+
112
+ ```bash
113
+ mkdir -p ~/intercom && cd ~/intercom
114
+ cp "$(npm root -g)/buzz-me-in/.env.example" .env
115
+ chmod 600 .env
116
+ ```
117
+
118
+ Edit `.env` with your credentials. Each variable is explained inline in `.env.example`. The `chmod 600` keeps the Twilio auth token and other secrets readable only by your user.
119
+
120
+ ### 3. Cloudflare Tunnel
121
+
122
+ Install `cloudflared` on the RPi via the official Cloudflare apt repository (preferred over a manual binary download — gives you automatic updates via `apt upgrade`):
123
+
124
+ ```bash
125
+ sudo mkdir -p --mode=0755 /usr/share/keyrings
126
+ curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
127
+ | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
128
+ echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' \
129
+ | sudo tee /etc/apt/sources.list.d/cloudflared.list
130
+ sudo apt-get update && sudo apt-get install cloudflared
131
+ ```
132
+
133
+ Authenticate (do this once per machine). Run on the RPi — it will print a URL; open that URL in any browser on any device, log into your Cloudflare account, and select your domain. The RPi polls for the auth and writes `~/.cloudflared/cert.pem` automatically:
134
+
135
+ ```bash
136
+ cloudflared tunnel login
137
+ ```
138
+
139
+ Create a named tunnel and route it to a hostname:
140
+
141
+ ```bash
142
+ cloudflared tunnel create intercom
143
+ cloudflared tunnel route dns intercom intercom.yourdomain.com
144
+ ```
145
+
146
+ `tunnel create` writes a credentials JSON to `~/.cloudflared/<tunnel-id>.json`. The UUID is in the create output, the credentials filename, and `cloudflared tunnel info intercom`. Keep these credentials private alongside `cert.pem`:
147
+
148
+ ```bash
149
+ chmod 600 ~/.cloudflared/cert.pem ~/.cloudflared/*.json
150
+ ```
151
+
152
+ Create `~/.cloudflared/config.yml` (adjust `/home/pi` if you run as a different user):
153
+
154
+ ```yaml
155
+ tunnel: <tunnel-id-from-create-output>
156
+ credentials-file: /home/pi/.cloudflared/<tunnel-id>.json
157
+ ingress:
158
+ - hostname: intercom.yourdomain.com
159
+ service: http://localhost:8080
160
+ # anything else routed to this tunnel gets a 404 instead of the intercom
161
+ - service: http_status:404
162
+ ```
163
+
164
+ Validate the config, then test the tunnel manually:
165
+
166
+ ```bash
167
+ cloudflared tunnel ingress validate
168
+ cloudflared tunnel run intercom
169
+ curl https://intercom.yourdomain.com/healthz
170
+ ```
171
+
172
+ ### 4. Configure Twilio
173
+
174
+ In the [Twilio Console](https://console.twilio.com/us1/develop/phone-numbers/manage/incoming):
175
+
176
+ 1. Click your phone number.
177
+ 2. Under **Voice Configuration → A call comes in**, set:
178
+ - Type: **Webhook**
179
+ - URL: `https://intercom.yourdomain.com/twiml`
180
+ - Method: **HTTP POST**
181
+ 3. Save.
182
+
183
+ Security hardening notes:
184
+
185
+ - `/twiml` enforces `X-Twilio-Signature` verification with your `TWILIO_AUTH_TOKEN`.
186
+ - TwiML embeds a short-lived, one-time signed token in the media stream URL. WebSocket connections are rejected unless this token validates.
187
+ - If your externally visible webhook base URL differs from `https://{TUNNEL_HOSTNAME}`, set `TWILIO_WEBHOOK_BASE_URL` explicitly in `.env`.
188
+
189
+ ### 5. Pair with HomeKit
190
+
191
+ Start the server from the directory holding `.env`:
192
+
193
+ ```bash
194
+ cd ~/intercom
195
+ buzz-me-in
196
+ ```
197
+
198
+ Open the **Home app** on your iPhone → **+** → **Add Accessory** → **More options** → you should see **Apartment Intercom**. Enter the pincode from your `.env` file (e.g. `XXX-XX-XXX`).
199
+
200
+ After pairing you will see:
201
+
202
+ - A **doorbell** tile (rings when the intercom calls)
203
+ - A **lock** tile (tap to unlock — plays the DTMF unlock sequence, default `w9w`, configurable via `TWILIO_UNLOCK_DIGITS`)
204
+
205
+ ### 6. Install systemd units (production)
206
+
207
+ Install the tunnel as a service using cloudflared's built-in installer. It copies the config to `/etc/cloudflared/config.yml`, writes its own systemd unit, and enables and starts it in one step. The explicit `--config` matters: under `sudo`, cloudflared searches root's config locations and would not find files in your home directory.
208
+
209
+ ```bash
210
+ sudo cloudflared --config ~/.cloudflared/config.yml service install
211
+ ```
212
+
213
+ Future tunnel config edits go in `/etc/cloudflared/config.yml`, followed by `sudo systemctl restart cloudflared`.
214
+
215
+ Install the intercom server unit, which ships inside the npm package:
216
+
217
+ ```bash
218
+ sudo cp "$(npm root -g)/buzz-me-in/intercom.service" /etc/systemd/system/
219
+
220
+ # Edit WorkingDirectory, EnvironmentFile and User if your paths differ.
221
+ # ExecStart needs no edit: it resolves buzz-me-in from systemd's PATH,
222
+ # which covers both common npm global prefixes.
223
+ sudo nano /etc/systemd/system/intercom.service
224
+
225
+ sudo systemctl daemon-reload
226
+ sudo systemctl enable --now intercom
227
+
228
+ # Check status
229
+ sudo systemctl status cloudflared
230
+ sudo systemctl status intercom
231
+ ```
232
+
233
+ ### Deploying updates
234
+
235
+ After the initial setup, ship a new release by installing it from npm and restarting:
236
+
237
+ ```bash
238
+ sudo npm install -g buzz-me-in@latest
239
+ sudo systemctl restart intercom
240
+ ```
241
+
242
+ Nothing triggers this automatically. Upgrading is a manual step or a cron job you add yourself.
243
+
244
+ ### Upgrading from a git checkout
245
+
246
+ Releases before 2.0.0 ran `node server.js` out of a working tree on the Pi. To move an existing deployment onto the published package, keep the working directory exactly as it is (`.env` and `persist/` do not move, so the HomeKit pairing survives), install the package globally, and replace the unit file:
247
+
248
+ ```bash
249
+ sudo systemctl stop intercom
250
+ tar czf ~/intercom-backup-$(date +%F).tgz -C ~ intercom --exclude=node_modules
251
+ chmod 600 ~/intercom-backup-$(date +%F).tgz # the archive contains .env
252
+
253
+ sudo npm install -g buzz-me-in
254
+ sudo cp "$(npm root -g)/buzz-me-in/intercom.service" /etc/systemd/system/
255
+ sudo systemctl daemon-reload
256
+ sudo systemctl start intercom
257
+ ```
258
+
259
+ Confirm the Home app still shows the accessory **without re-pairing** before deleting the old code from the working directory. Leave `.env` and `persist/` in place.
260
+
261
+ ---
262
+
263
+ ## End-to-end test sequence
264
+
265
+ Work through these stages in order. Each has a clear pass/fail check before wiring the next.
266
+
267
+ ### Stage 1 — Server starts cleanly
268
+
269
+ Run from the working directory holding `.env`, so config and pairing state resolve:
270
+
271
+ ```bash
272
+ cd ~/intercom
273
+ buzz-me-in
274
+ curl http://localhost:8080/healthz
275
+ curl http://localhost:8080/readyz
276
+ ```
277
+
278
+ **Pass:** `/healthz` and `/readyz` both return `{"ok":true}` with no errors in the log.
279
+
280
+ ---
281
+
282
+ ### Stage 2 — Tunnel is reachable
283
+
284
+ ```bash
285
+ cloudflared tunnel run intercom # or check systemctl status
286
+ curl https://intercom.yourdomain.com/healthz
287
+ curl https://intercom.yourdomain.com/readyz
288
+ ```
289
+
290
+ **Pass:** both endpoints return `{"ok":true}` from the public internet.
291
+
292
+ ---
293
+
294
+ ### Stage 3 — HomeKit pairing
295
+
296
+ Open Home app → Add Accessory → enter pincode.
297
+
298
+ **Pass:** A doorbell tile and a lock tile appear in the Home app. Tapping the lock tile while no call is active should log `Unlock requested but no active call` and snap back to Secured.
299
+
300
+ ---
301
+
302
+ ### Stage 4 — Inbound Twilio call
303
+
304
+ Call your Twilio number from any phone (simulate the intercom caller).
305
+
306
+ **Pass (server log):**
307
+
308
+ ```text
309
+ {"ts":"...","level":"info","message":"Media websocket start accepted","component":"media-ws","callSid":"CA...","event":"start","streamSid":"MZ..."}
310
+ {"ts":"...","level":"info","message":"Doorbell triggered","component":"homekit","event":"doorbell-triggered"}
311
+ ```
312
+
313
+ **Pass (phone):** A doorbell notification appears on your iPhone.
314
+
315
+ ```bash
316
+ curl -H "Authorization: Bearer $STATUS_API_TOKEN" https://intercom.yourdomain.com/status
317
+ # → {"active":true,"callSid":"CA..."}
318
+ ```
319
+
320
+ If the doorbell notification does not appear, check that the RPi and iPhone are on the same LAN (HAP uses mDNS) and that port `HAP_PORT` (default 47129) is not blocked.
321
+
322
+ ---
323
+
324
+ ### Stage 5 — Live view opens (inbound audio + video)
325
+
326
+ Tap the doorbell notification → open the live camera view.
327
+
328
+ **Pass (server log):**
329
+
330
+ ```text
331
+ {"ts":"...","level":"info","message":"Bound mulaw stream to active HomeKit session","component":"homekit","event":"mulaw-stream-bound","sessionId":"..."}
332
+ ```
333
+
334
+ **Pass (iPhone):** A black video tile appears. You should hear audio from the intercom caller through the iPhone speaker.
335
+
336
+ **Troubleshooting:**
337
+
338
+ - `ffin-stderr` log events show libx264 errors → check `ffmpeg -codecs | grep 264`.
339
+ - Video shows "stream unavailable" → try removing the `?rtcpport=…` query parameters from the SRTP URLs in `homekit.js` (some HomeKit implementations use RTCP-mux on the same port).
340
+ - Audio missing → check `ffmpeg -codecs | grep opus`. If `libopus` is absent, `sudo apt install ffmpeg` again or compile with `--enable-libopus`.
341
+
342
+ ---
343
+
344
+ ### Stage 6 — Two-way audio (outbound)
345
+
346
+ Speak into the iPhone microphone while the live view is open.
347
+
348
+ **Pass:** Your voice comes out of the intercom speaker (the caller hears you).
349
+
350
+ **Troubleshooting (no outbound audio):**
351
+ The SDP file used by ffmpeg is generated dynamically using the Opus payload type negotiated by HAP-NodeJS in the START request, so a PT mismatch should not occur. If audio is still missing, capture the return UDP traffic to verify:
352
+
353
+ ```bash
354
+ sudo tcpdump -i any -n "udp port <returnAudioPort>" -c 10 -X
355
+ ```
356
+
357
+ Byte 1 of each RTP packet (bitwise AND with `0x7F`) is the payload type. If it does not match the PT in the generated SDP at `/tmp/intercom_return_<sessionID>.sdp`, the HAP-NodeJS negotiation and actual RTP stream are out of sync — check for a HAP-NodeJS version incompatibility.
358
+
359
+ ---
360
+
361
+ ### Stage 7 — Unlock
362
+
363
+ Tap the lock tile in the Home app → set to Unlocked.
364
+
365
+ **Pass (server log):**
366
+
367
+ ```text
368
+ {"ts":"...","level":"info","message":"Sent DTMF unlock over active media stream","component":"homekit","event":"unlock-requested","callSid":"CA...","digits":"w9w"}
369
+ ```
370
+
371
+ **Pass (intercom):** The door lock buzzes / the intercom plays digit 9.
372
+
373
+ The lock tile resets to Secured automatically after 3 seconds.
374
+
375
+ ---
376
+
377
+ ### Stage 8 — Caller hangs up
378
+
379
+ Have the intercom caller hang up (or wait for them to go away).
380
+
381
+ **Pass (server log):**
382
+
383
+ ```text
384
+ {"ts":"...","level":"info","message":"Media websocket stop event","component":"media-ws","callSid":"CA...","event":"stop"}
385
+ {"ts":"...","level":"info","message":"Media websocket session ended","component":"media-ws","callSid":"CA...","event":"session-ended","reason":"twilio-stop","messageCount":42}
386
+ ```
387
+
388
+ **Pass (iPhone):** The live view dismisses automatically.
389
+
390
+ ---
391
+
392
+ ### Stage 9 — HomeKit hangs up
393
+
394
+ Open the live view, then dismiss it on the iPhone (tap the X / end button).
395
+
396
+ **Pass (server log):**
397
+
398
+ ```text
399
+ {"ts":"...","level":"info","message":"Inbound ffmpeg exited","component":"homekit","event":"ffin-exit","reason":"nonzero-exit","exitCode":null,"sessionId":"..."}
400
+ {"ts":"...","level":"info","message":"Hanging up call","component":"twilio-api","event":"hangup","callSid":"CA..."}
401
+ ```
402
+
403
+ **Pass (Twilio):** The call shows as completed in the [Twilio Console call log](https://console.twilio.com/us1/monitor/logs/calls).
404
+
405
+ ---
406
+
407
+ ### Stage 10 — Production reboot test
408
+
409
+ ```bash
410
+ sudo reboot
411
+ # After ~30 s:
412
+ sudo systemctl status cloudflared intercom
413
+ curl https://intercom.yourdomain.com/healthz
414
+ curl https://intercom.yourdomain.com/readyz
415
+ ```
416
+
417
+ **Pass:** Both services are `active (running)`. `/healthz` and `/readyz` respond with `ok: true`. Make a test call to confirm end-to-end flow survives a reboot.
418
+
419
+ ---
420
+
421
+ ## File structure
422
+
423
+ ```text
424
+ .
425
+ ├── server.js # HTTP routes + WebSocket server; graceful shutdown
426
+ ├── homekit.js # HAP-NodeJS camera+doorbell accessory; ffmpeg pipelines
427
+ ├── twilio-api.js # Twilio REST API helpers (hangup)
428
+ ├── src/core/
429
+ │ ├── config.js # validated env/config loading
430
+ │ ├── log.js # structured JSON-lines logger
431
+ │ ├── media-stream.js # Twilio media stream protocol (start/media/stop)
432
+ │ ├── mulaw-audio.js # mu-law audio generation (DTMF, ringback, ringtone WAV)
433
+ │ ├── safe-equal.js # constant-time string comparison
434
+ │ ├── state.js # call session management and stale-session reaping
435
+ │ ├── stream-auth.js # one-time stream token sign/verify
436
+ │ ├── types.js # JSDoc typedefs (no runtime exports)
437
+ │ └── ws-events-schema.js # Twilio WS envelope validation and media payload parsing
438
+ ├── tests/ # node --test suites (*.test.cjs)
439
+ ├── docs/ # architecture and testing guides
440
+ ├── intercom.service # systemd unit for the Node.js server
441
+ ├── commitlint.config.js # conventional commit rules (enforced by hooks + CI)
442
+ ├── .env.example # environment variable template
443
+ └── package.json
444
+ ```
445
+
446
+ The npm package ships only what the server needs at runtime: `server.js`, `homekit.js`, `twilio-api.js`, `src/`, `intercom.service`, `.env.example`, plus `README.md`, `LICENSE` and `package.json`. Everything else above, including `tests/`, `docs/`, `.github/` and `.claude/`, is repo-only. `package.json`'s `files` array is the allowlist; `npm pack --dry-run` prints exactly what would be published.
447
+
448
+ Runtime state lives in the working directory rather than the install: `.env` and `persist/` (HomeKit pairing, containing long-term keys) are read and written relative to `WorkingDirectory`, so a global npm install never touches them.
449
+
450
+ ## Environment variables
451
+
452
+ See `.env.example` for the full list with descriptions and default values.