buzz-me-in 2.2.4 → 3.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.
Files changed (2) hide show
  1. package/README.md +185 -83
  2. package/package.json +3 -3
package/README.md CHANGED
@@ -11,13 +11,17 @@
11
11
 
12
12
  Apartment intercom to Apple HomeKit, bridged over Twilio.
13
13
 
14
- 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.
14
+ A Raspberry Pi server that bridges an apartment intercom system into Apple
15
+ HomeKit. When someone presses the intercom buzzer, your iPhone/HomePod receives
16
+ a doorbell notification with a live camera tile. You can hear the caller, speak
17
+ back, and unlock the door — all from the Home app or via Siri.
15
18
 
16
19
  ## Project docs
17
20
 
18
21
  - [Architecture guide](docs/architecture.md)
19
22
  - [Testing guide](docs/testing.md)
20
- - [Contributor guide](AGENTS.md) — git workflow, module format, logging conventions
23
+ - [Contributor guide](AGENTS.md) — git workflow, module format, logging
24
+ conventions
21
25
 
22
26
  ## How it works
23
27
 
@@ -58,11 +62,13 @@ RPi: server.js (HTTP + WebSocket on one port)
58
62
  sent over the active Twilio WebSocket
59
63
  ```
60
64
 
61
- **Single process, single port.** HTTP (TwiML) and WebSocket (media stream) share port 8080. Cloudflare Tunnel exposes one stable public URL for both.
65
+ **Single process, single port.** HTTP (TwiML) and WebSocket (media stream) share
66
+ port 8080. Cloudflare Tunnel exposes one stable public URL for both.
62
67
 
63
68
  ### HTTP endpoints
64
69
 
65
- All endpoints are reachable from the public internet through the tunnel; each enforces its own auth:
70
+ All endpoints are reachable from the public internet through the tunnel; each
71
+ enforces its own auth:
66
72
 
67
73
  | Endpoint | Method | Auth | Purpose |
68
74
  | ----------- | --------- | -------------------------------------------------------------------------------------- | -------------------------------------- |
@@ -73,38 +79,53 @@ All endpoints are reachable from the public internet through the tunnel; each en
73
79
  | `/readyz` | GET | None (by design) | Readiness probe, returns `{"ok":true}` |
74
80
  | `/ringtone` | GET | None | Ring tone WAV for debug/manual checks |
75
81
 
76
- 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}`.
82
+ The health endpoints are deliberately unauthenticated so systemd checks, uptime
83
+ monitors, and the test sequence below can probe them; they expose nothing beyond
84
+ `{"ok":true}`.
77
85
 
78
86
  ## Architecture notes
79
87
 
80
- - **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).
81
- - **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.
82
- - **Video** is a synthetic black H.264 stream generated by ffmpeg (`lavfi color` source). HomeKit requires a video track; there is no real camera.
83
- - **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`.
88
+ - **CallSid** arrives in the WebSocket `start` event (`data.start.callSid`), not
89
+ in HTTP headers. It is stored in the call session manager and retrieved via
90
+ `state.getActiveCall()` for mid-call operations (currently hangup).
91
+ - **Call hold** uses a bidirectional `<Connect><Stream>` in TwiML. The server
92
+ sends ringback and unlock DTMF as mu-law media over the active WebSocket so
93
+ the stream stays attached to the call.
94
+ - **Video** is a synthetic black H.264 stream generated by ffmpeg (`lavfi color`
95
+ source). HomeKit requires a video track; there is no real camera.
96
+ - **Audio codec** is Opus (two-way). AAC-ELD is supported by some older HomeKit
97
+ devices but requires compiling ffmpeg with
98
+ `--enable-libfdk-aac --enable-nonfree`.
84
99
 
85
- ---
100
+ ______________________________________________________________________
86
101
 
87
102
  ## Requirements
88
103
 
89
104
  ### Raspberry Pi
90
105
 
91
106
  - Raspberry Pi 4 (2 GB RAM recommended) running Raspberry Pi OS (64-bit)
92
- - Node.js >= 20
93
- - Runtime is tested on Node 20+.
94
- - Repo tooling/CI currently runs on Node 20, 22, and 24.
107
+ - Node.js >= 22
108
+ - Runtime is tested on every Node line currently in LTS.
109
+ - CI runs on every Node line currently in LTS.
95
110
  - ffmpeg >= 5.1 with libx264 and libopus: `sudo apt install ffmpeg`
96
- - 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.
111
+ - The 5.1 floor is the `-ch_layout` option used on the inbound audio pipeline.
112
+ Debian 12 (bookworm) ships 5.1 and Raspberry Pi OS based on Debian 13 ships
113
+ 7.1, so apt satisfies this on any currently supported release.
97
114
 
98
115
  ### Accounts and services
99
116
 
100
117
  - [Twilio account](https://console.twilio.com) with a Voice-enabled phone number
101
- - [Cloudflare account](https://dash.cloudflare.com) (free) with a domain, or use Cloudflare's free `*.cfargotunnel.com` subdomain
118
+ - [Cloudflare account](https://dash.cloudflare.com) (free) with a domain, or use
119
+ Cloudflare's free `*.cfargotunnel.com` subdomain
102
120
 
103
- ---
121
+ ______________________________________________________________________
104
122
 
105
123
  ## Setup
106
124
 
107
- 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.
125
+ Throughout this guide `pi@raspberrypi.local`, the `pi` user and `~/intercom` are
126
+ the Raspberry Pi OS defaults, used as examples. Any host, user or directory
127
+ works, as long as `WorkingDirectory`, `EnvironmentFile` and `User` in
128
+ `intercom.service` are edited to match.
108
129
 
109
130
  ### 1. Install
110
131
 
@@ -113,11 +134,14 @@ sudo npm install -g buzz-me-in
113
134
  command -v buzz-me-in # sanity check; the bin lands under `npm prefix -g`
114
135
  ```
115
136
 
116
- Contributors work from a clone of the repository instead, with `npm install`. See `AGENTS.md` for the workflow and quality gates.
137
+ Contributors work from a clone of the repository instead, with `npm install`.
138
+ See `AGENTS.md` for the workflow and quality gates.
117
139
 
118
140
  ### 2. Environment variables
119
141
 
120
- 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:
142
+ The server reads `.env` from its working directory and writes HomeKit pairing
143
+ state into `persist/` there, so that state stays separate from the global
144
+ install npm manages:
121
145
 
122
146
  ```bash
123
147
  mkdir -p ~/intercom && cd ~/intercom
@@ -125,11 +149,15 @@ cp "$(npm root -g)/buzz-me-in/.env.example" .env
125
149
  chmod 600 .env
126
150
  ```
127
151
 
128
- 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.
152
+ Edit `.env` with your credentials. Each variable is explained inline in
153
+ `.env.example`. The `chmod 600` keeps the Twilio auth token and other secrets
154
+ readable only by your user.
129
155
 
130
156
  ### 3. Cloudflare Tunnel
131
157
 
132
- Install `cloudflared` on the RPi via the official Cloudflare apt repository (preferred over a manual binary download — gives you automatic updates via `apt upgrade`):
158
+ Install `cloudflared` on the RPi via the official Cloudflare apt repository
159
+ (preferred over a manual binary download — gives you automatic updates via
160
+ `apt upgrade`):
133
161
 
134
162
  ```bash
135
163
  sudo mkdir -p --mode=0755 /usr/share/keyrings
@@ -140,7 +168,10 @@ echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudf
140
168
  sudo apt-get update && sudo apt-get install cloudflared
141
169
  ```
142
170
 
143
- 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:
171
+ Authenticate (do this once per machine). Run on the RPi — it will print a URL;
172
+ open that URL in any browser on any device, log into your Cloudflare account,
173
+ and select your domain. The RPi polls for the auth and writes
174
+ `~/.cloudflared/cert.pem` automatically:
144
175
 
145
176
  ```bash
146
177
  cloudflared tunnel login
@@ -153,13 +184,17 @@ cloudflared tunnel create intercom
153
184
  cloudflared tunnel route dns intercom intercom.yourdomain.com
154
185
  ```
155
186
 
156
- `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`:
187
+ `tunnel create` writes a credentials JSON to `~/.cloudflared/<tunnel-id>.json`.
188
+ The UUID is in the create output, the credentials filename, and
189
+ `cloudflared tunnel info intercom`. Keep these credentials private alongside
190
+ `cert.pem`:
157
191
 
158
192
  ```bash
159
193
  chmod 600 ~/.cloudflared/cert.pem ~/.cloudflared/*.json
160
194
  ```
161
195
 
162
- Create `~/.cloudflared/config.yml` (adjust `/home/pi` if you run as a different user):
196
+ Create `~/.cloudflared/config.yml` (adjust `/home/pi` if you run as a different
197
+ user):
163
198
 
164
199
  ```yaml
165
200
  tunnel: <tunnel-id-from-create-output>
@@ -181,7 +216,8 @@ curl https://intercom.yourdomain.com/healthz
181
216
 
182
217
  ### 4. Configure Twilio
183
218
 
184
- In the [Twilio Console](https://console.twilio.com/us1/develop/phone-numbers/manage/incoming):
219
+ In the
220
+ [Twilio Console](https://console.twilio.com/us1/develop/phone-numbers/manage/incoming):
185
221
 
186
222
  1. Click your phone number.
187
223
  2. Under **Voice Configuration → A call comes in**, set:
@@ -192,10 +228,21 @@ In the [Twilio Console](https://console.twilio.com/us1/develop/phone-numbers/man
192
228
 
193
229
  Security hardening notes:
194
230
 
195
- - `/twiml` enforces `X-Twilio-Signature` verification with your `TWILIO_AUTH_TOKEN`.
196
- - TwiML embeds a short-lived, one-time signed token as a `<Parameter>`, which Twilio sends in the WebSocket `start` event. Connections are rejected unless this token validates.
197
- - `/media` additionally verifies `X-Twilio-Signature` on the WebSocket handshake, before the socket is accepted, so unauthenticated connections cost the Pi nothing. This is defence in depth: the handshake carries no body or query string, so its signature is an HMAC over a constant URL and is the same on every call. Only the one-time token is single-use. Controlled by `TWILIO_MEDIA_SIGNATURE_MODE` (`off` | `log` | `enforce`, default `log`); see `.env.example`.
198
- - If your externally visible webhook base URL differs from `https://{TUNNEL_HOSTNAME}`, set `TWILIO_WEBHOOK_BASE_URL` explicitly in `.env`.
231
+ - `/twiml` enforces `X-Twilio-Signature` verification with your
232
+ `TWILIO_AUTH_TOKEN`.
233
+ - TwiML embeds a short-lived, one-time signed token as a `<Parameter>`, which
234
+ Twilio sends in the WebSocket `start` event. Connections are rejected unless
235
+ this token validates.
236
+ - `/media` additionally verifies `X-Twilio-Signature` on the WebSocket
237
+ handshake, before the socket is accepted, so unauthenticated connections cost
238
+ the Pi nothing. This is defence in depth: the handshake carries no body or
239
+ query string, so its signature is an HMAC over a constant URL and is the same
240
+ on every call. Only the one-time token is single-use. Controlled by
241
+ `TWILIO_MEDIA_SIGNATURE_MODE` (`off` | `log` | `enforce`, default `log`); see
242
+ `.env.example`.
243
+ - If your externally visible webhook base URL differs from
244
+ `https://{TUNNEL_HOSTNAME}`, set `TWILIO_WEBHOOK_BASE_URL` explicitly in
245
+ `.env`.
199
246
 
200
247
  ### 5. Pair with HomeKit
201
248
 
@@ -206,18 +253,22 @@ cd ~/intercom
206
253
  buzz-me-in
207
254
  ```
208
255
 
209
- 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`).
256
+ Open the **Home app** on your iPhone → **+** → **Add Accessory** → **More
257
+ options** → you should see **Apartment Intercom**. Enter the pincode from your
258
+ `.env` file (e.g. `XXX-XX-XXX`).
210
259
 
211
260
  After pairing you will see:
212
261
 
213
262
  - A **doorbell** tile (rings when the intercom calls)
214
- - A **lock** tile (tap to unlock — plays the DTMF unlock sequence, default `w9w`, configurable via `TWILIO_UNLOCK_DIGITS`)
263
+ - A **lock** tile (tap to unlock — plays the DTMF unlock sequence, default
264
+ `w9w`, configurable via `TWILIO_UNLOCK_DIGITS`)
215
265
 
216
266
  #### Re-pairing later, or checking the pairing
217
267
 
218
- Once the server runs under systemd its stdout is not a terminal, so it never prints the setup QR.
219
- Two flags read the pairing state directly instead. Neither starts the server, contacts HomeKit or
220
- signals the running service, so both are safe against a live deployment:
268
+ Once the server runs under systemd its stdout is not a terminal, so it never
269
+ prints the setup QR. Two flags read the pairing state directly instead. Neither
270
+ starts the server, contacts HomeKit or signals the running service, so both are
271
+ safe against a live deployment:
221
272
 
222
273
  ```bash
223
274
  buzz-me-in --check # persist directory, accessory file, and paired client count
@@ -226,16 +277,17 @@ buzz-me-in --qr # the setup QR, for Home app → Add Accessory
226
277
 
227
278
  Three things to know before the QR works over SSH:
228
279
 
229
- - **`ssh -t` is required.** `ssh host "command"` allocates no pty, so stdout is not a TTY and the
230
- command refuses by design: the URI encodes the setup code and must never reach a pipe or a log.
231
- An interactive `ssh host` session already has a pty.
232
- - **Run it from the working directory**, the one holding `.env` and `persist/`. Pairing state is
233
- read relative to the current directory, so elsewhere it reports finding nothing while the service
234
- is perfectly healthy.
235
- - **Use a dark terminal background.** The QR is drawn with block characters that take the terminal's
236
- foreground colour for the _light_ modules, so it scans on a dark background and comes out inverted
237
- on a light one. If your phone will not lock onto it, switch profile before assuming anything is
238
- broken.
280
+ - **`ssh -t` is required.** `ssh host "command"` allocates no pty, so stdout is
281
+ not a TTY and the command refuses by design: the URI encodes the setup code
282
+ and must never reach a pipe or a log. An interactive `ssh host` session
283
+ already has a pty.
284
+ - **Run it from the working directory**, the one holding `.env` and `persist/`.
285
+ Pairing state is read relative to the current directory, so elsewhere it
286
+ reports finding nothing while the service is perfectly healthy.
287
+ - **Use a dark terminal background.** The QR is drawn with block characters that
288
+ take the terminal's foreground colour for the _light_ modules, so it scans on
289
+ a dark background and comes out inverted on a light one. If your phone will
290
+ not lock onto it, switch profile before assuming anything is broken.
239
291
 
240
292
  Putting those together, from the machine you are sitting at:
241
293
 
@@ -243,7 +295,8 @@ Putting those together, from the machine you are sitting at:
243
295
  ssh -t <pi-host> 'cd <working-directory> && buzz-me-in --qr'
244
296
  ```
245
297
 
246
- `--check` has no such constraints, prints no secret, and is safe to pipe or paste:
298
+ `--check` has no such constraints, prints no secret, and is safe to pipe or
299
+ paste:
247
300
 
248
301
  ```bash
249
302
  $ buzz-me-in --check
@@ -254,19 +307,25 @@ setupID : present
254
307
  pairedClients : 4
255
308
  ```
256
309
 
257
- `pairedClients: 0` on an accessory you believe is paired means the pairing is gone, not that the
258
- network is broken. See [docs/testing.md](docs/testing.md) and `tests/homekit-shutdown.test.cjs` for
259
- the failure that used to cause exactly that.
310
+ `pairedClients: 0` on an accessory you believe is paired means the pairing is
311
+ gone, not that the network is broken. See [docs/testing.md](docs/testing.md) and
312
+ `tests/homekit-shutdown.test.cjs` for the failure that used to cause exactly
313
+ that.
260
314
 
261
315
  ### 6. Install systemd units (production)
262
316
 
263
- 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.
317
+ Install the tunnel as a service using cloudflared's built-in installer. It
318
+ copies the config to `/etc/cloudflared/config.yml`, writes its own systemd unit,
319
+ and enables and starts it in one step. The explicit `--config` matters: under
320
+ `sudo`, cloudflared searches root's config locations and would not find files in
321
+ your home directory.
264
322
 
265
323
  ```bash
266
324
  sudo cloudflared --config ~/.cloudflared/config.yml service install
267
325
  ```
268
326
 
269
- Future tunnel config edits go in `/etc/cloudflared/config.yml`, followed by `sudo systemctl restart cloudflared`.
327
+ Future tunnel config edits go in `/etc/cloudflared/config.yml`, followed by
328
+ `sudo systemctl restart cloudflared`.
270
329
 
271
330
  Install the intercom server unit, which ships inside the npm package:
272
331
 
@@ -288,18 +347,23 @@ sudo systemctl status intercom
288
347
 
289
348
  ### Deploying updates
290
349
 
291
- After the initial setup, ship a new release by installing it from npm and restarting:
350
+ After the initial setup, ship a new release by installing it from npm and
351
+ restarting:
292
352
 
293
353
  ```bash
294
354
  sudo npm install -g buzz-me-in@latest
295
355
  sudo systemctl restart intercom
296
356
  ```
297
357
 
298
- Nothing triggers this automatically. Upgrading is a manual step or a cron job you add yourself.
358
+ Nothing triggers this automatically. Upgrading is a manual step or a cron job
359
+ you add yourself.
299
360
 
300
361
  ### Upgrading from a git checkout
301
362
 
302
- 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:
363
+ Releases before 2.0.0 ran `node server.js` out of a working tree on the Pi. To
364
+ move an existing deployment onto the published package, keep the working
365
+ directory exactly as it is (`.env` and `persist/` do not move, so the HomeKit
366
+ pairing survives), install the package globally, and replace the unit file:
303
367
 
304
368
  ```bash
305
369
  sudo systemctl stop intercom
@@ -312,17 +376,21 @@ sudo systemctl daemon-reload
312
376
  sudo systemctl start intercom
313
377
  ```
314
378
 
315
- 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.
379
+ Confirm the Home app still shows the accessory **without re-pairing** before
380
+ deleting the old code from the working directory. Leave `.env` and `persist/` in
381
+ place.
316
382
 
317
- ---
383
+ ______________________________________________________________________
318
384
 
319
385
  ## End-to-end test sequence
320
386
 
321
- Work through these stages in order. Each has a clear pass/fail check before wiring the next.
387
+ Work through these stages in order. Each has a clear pass/fail check before
388
+ wiring the next.
322
389
 
323
390
  ### Stage 1 — Server starts cleanly
324
391
 
325
- Run from the working directory holding `.env`, so config and pairing state resolve:
392
+ Run from the working directory holding `.env`, so config and pairing state
393
+ resolve:
326
394
 
327
395
  ```bash
328
396
  cd ~/intercom
@@ -331,9 +399,10 @@ curl http://localhost:8080/healthz
331
399
  curl http://localhost:8080/readyz
332
400
  ```
333
401
 
334
- **Pass:** `/healthz` and `/readyz` both return `{"ok":true}` with no errors in the log.
402
+ **Pass:** `/healthz` and `/readyz` both return `{"ok":true}` with no errors in
403
+ the log.
335
404
 
336
- ---
405
+ ______________________________________________________________________
337
406
 
338
407
  ### Stage 2 — Tunnel is reachable
339
408
 
@@ -345,15 +414,17 @@ curl https://intercom.yourdomain.com/readyz
345
414
 
346
415
  **Pass:** both endpoints return `{"ok":true}` from the public internet.
347
416
 
348
- ---
417
+ ______________________________________________________________________
349
418
 
350
419
  ### Stage 3 — HomeKit pairing
351
420
 
352
421
  Open Home app → Add Accessory → enter pincode.
353
422
 
354
- **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.
423
+ **Pass:** A doorbell tile and a lock tile appear in the Home app. Tapping the
424
+ lock tile while no call is active should log
425
+ `Unlock requested but no active call` and snap back to Secured.
355
426
 
356
- ---
427
+ ______________________________________________________________________
357
428
 
358
429
  ### Stage 4 — Inbound Twilio call
359
430
 
@@ -373,9 +444,11 @@ curl -H "Authorization: Bearer $STATUS_API_TOKEN" https://intercom.yourdomain.co
373
444
  # → {"active":true,"callSid":"CA..."}
374
445
  ```
375
446
 
376
- 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.
447
+ If the doorbell notification does not appear, check that the RPi and iPhone are
448
+ on the same LAN (HAP uses mDNS) and that port `HAP_PORT` (default 47129) is not
449
+ blocked.
377
450
 
378
- ---
451
+ ______________________________________________________________________
379
452
 
380
453
  ### Stage 5 — Live view opens (inbound audio + video)
381
454
 
@@ -387,15 +460,20 @@ Tap the doorbell notification → open the live camera view.
387
460
  {"ts":"...","level":"info","message":"Bound mulaw stream to active HomeKit session","component":"homekit","event":"mulaw-stream-bound","sessionId":"..."}
388
461
  ```
389
462
 
390
- **Pass (iPhone):** A black video tile appears. You should hear audio from the intercom caller through the iPhone speaker.
463
+ **Pass (iPhone):** A black video tile appears. You should hear audio from the
464
+ intercom caller through the iPhone speaker.
391
465
 
392
466
  **Troubleshooting:**
393
467
 
394
- - `ffin-stderr` log events show libx264 errors → check `ffmpeg -codecs | grep 264`.
395
- - 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).
396
- - Audio missing → check `ffmpeg -codecs | grep opus`. If `libopus` is absent, `sudo apt install ffmpeg` again or compile with `--enable-libopus`.
468
+ - `ffin-stderr` log events show libx264 errors → check
469
+ `ffmpeg -codecs | grep 264`.
470
+ - Video shows "stream unavailable" → try removing the `?rtcpport=…` query
471
+ parameters from the SRTP URLs in `homekit.js` (some HomeKit implementations
472
+ use RTCP-mux on the same port).
473
+ - Audio missing → check `ffmpeg -codecs | grep opus`. If `libopus` is absent,
474
+ `sudo apt install ffmpeg` again or compile with `--enable-libopus`.
397
475
 
398
- ---
476
+ ______________________________________________________________________
399
477
 
400
478
  ### Stage 6 — Two-way audio (outbound)
401
479
 
@@ -403,16 +481,21 @@ Speak into the iPhone microphone while the live view is open.
403
481
 
404
482
  **Pass:** Your voice comes out of the intercom speaker (the caller hears you).
405
483
 
406
- **Troubleshooting (no outbound audio):**
407
- 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:
484
+ **Troubleshooting (no outbound audio):** The SDP file used by ffmpeg is
485
+ generated dynamically using the Opus payload type negotiated by HAP-NodeJS in
486
+ the START request, so a PT mismatch should not occur. If audio is still missing,
487
+ capture the return UDP traffic to verify:
408
488
 
409
489
  ```bash
410
490
  sudo tcpdump -i any -n "udp port <returnAudioPort>" -c 10 -X
411
491
  ```
412
492
 
413
- 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.
493
+ Byte 1 of each RTP packet (bitwise AND with `0x7F`) is the payload type. If it
494
+ does not match the PT in the generated SDP at
495
+ `/tmp/intercom_return_<sessionID>.sdp`, the HAP-NodeJS negotiation and actual
496
+ RTP stream are out of sync — check for a HAP-NodeJS version incompatibility.
414
497
 
415
- ---
498
+ ______________________________________________________________________
416
499
 
417
500
  ### Stage 7 — Unlock
418
501
 
@@ -428,9 +511,15 @@ Tap the lock tile in the Home app → set to Unlocked.
428
511
 
429
512
  The lock tile resets to Secured automatically after 3 seconds.
430
513
 
431
- 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.
514
+ Two routes reach the lock tile, and they behave differently. The grid button at
515
+ the bottom right of the camera view brings the lock up _beside_ the running
516
+ stream, which is the smoother of the two. Backing out to the room view instead
517
+ closes the camera view and tears the stream down; the lock tile is still there
518
+ under **Other**, and it works because the call is held open for
519
+ `HOMEKIT_HANGUP_GRACE_MS` after the view closes. Tapping unlock restarts that
520
+ window, so the digits are never cut off mid-sequence.
432
521
 
433
- ---
522
+ ______________________________________________________________________
434
523
 
435
524
  ### Stage 8 — Caller hangs up
436
525
 
@@ -445,7 +534,7 @@ Have the intercom caller hang up (or wait for them to go away).
445
534
 
446
535
  **Pass (iPhone):** The live view dismisses automatically.
447
536
 
448
- ---
537
+ ______________________________________________________________________
449
538
 
450
539
  ### Stage 9 — HomeKit hangs up
451
540
 
@@ -459,11 +548,16 @@ Open the live view, then dismiss it on the iPhone (tap the X / end button).
459
548
  {"ts":"...","level":"info","message":"Hanging up call","component":"twilio-api","event":"hangup","callSid":"CA..."}
460
549
  ```
461
550
 
462
- The call is not hung up the instant the view closes. It is held open for `HOMEKIT_HANGUP_GRACE_MS` (default 8000) 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.
551
+ The call is not hung up the instant the view closes. It is held open for
552
+ `HOMEKIT_HANGUP_GRACE_MS` (default 8000) so the lock tile still has a call to
553
+ send DTMF to, then hung up. Reopening the view inside that window logs
554
+ `hangup-cancelled` instead and the call continues; if the caller hangs up first,
555
+ the pending hangup logs `hangup-skipped` and does nothing.
463
556
 
464
- **Pass (Twilio):** The call shows as completed in the [Twilio Console call log](https://console.twilio.com/us1/monitor/logs/calls).
557
+ **Pass (Twilio):** The call shows as completed in the
558
+ [Twilio Console call log](https://console.twilio.com/us1/monitor/logs/calls).
465
559
 
466
- ---
560
+ ______________________________________________________________________
467
561
 
468
562
  ### Stage 10 — Production reboot test
469
563
 
@@ -475,9 +569,10 @@ curl https://intercom.yourdomain.com/healthz
475
569
  curl https://intercom.yourdomain.com/readyz
476
570
  ```
477
571
 
478
- **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.
572
+ **Pass:** Both services are `active (running)`. `/healthz` and `/readyz` respond
573
+ with `ok: true`. Make a test call to confirm end-to-end flow survives a reboot.
479
574
 
480
- ---
575
+ ______________________________________________________________________
481
576
 
482
577
  ## File structure
483
578
 
@@ -505,9 +600,16 @@ curl https://intercom.yourdomain.com/readyz
505
600
  └── package.json
506
601
  ```
507
602
 
508
- 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.
603
+ The npm package ships only what the server needs at runtime: `server.js`,
604
+ `homekit.js`, `twilio-api.js`, `src/`, `intercom.service`, `.env.example`, plus
605
+ `README.md`, `LICENSE` and `package.json`. Everything else above, including
606
+ `tests/`, `docs/`, `.github/` and `.claude/`, is repo-only. `package.json`'s
607
+ `files` array is the allowlist; `npm pack --dry-run` prints exactly what would
608
+ be published.
509
609
 
510
- 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.
610
+ Runtime state lives in the working directory rather than the install: `.env` and
611
+ `persist/` (HomeKit pairing, containing long-term keys) are read and written
612
+ relative to `WorkingDirectory`, so a global npm install never touches them.
511
613
 
512
614
  ## Environment variables
513
615
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buzz-me-in",
3
- "version": "2.2.4",
3
+ "version": "3.0.0",
4
4
  "description": "HomeKit-compatible apartment intercom via Twilio media streams",
5
5
  "keywords": [
6
6
  "homekit",
@@ -50,7 +50,7 @@
50
50
  "url": "https://github.com/atdr/buzz-me-in/issues"
51
51
  },
52
52
  "engines": {
53
- "node": ">=20"
53
+ "node": ">=22"
54
54
  },
55
55
  "dependencies": {
56
56
  "dotenv": "^18.0.1",
@@ -78,7 +78,7 @@
78
78
  "eslint --fix",
79
79
  "prettier --write"
80
80
  ],
81
- "*.{md,yml,yaml,json}": [
81
+ "*.{yml,yaml,json}": [
82
82
  "prettier --write"
83
83
  ]
84
84
  }