buzz-me-in 2.2.4 → 3.0.1
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/README.md +185 -83
- package/package.json +3 -3
- package/src/core/stream-auth.js +4 -4
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
- **
|
|
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 >=
|
|
93
|
-
- Runtime is tested on Node
|
|
94
|
-
-
|
|
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.
|
|
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
|
|
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
|
|
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`.
|
|
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
|
|
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
|
|
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
|
|
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;
|
|
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`.
|
|
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
|
|
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
|
|
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
|
|
196
|
-
|
|
197
|
-
-
|
|
198
|
-
|
|
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
|
|
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
|
|
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
|
|
219
|
-
Two flags read the pairing state directly instead. Neither
|
|
220
|
-
signals the running service, so both are
|
|
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
|
|
230
|
-
command refuses by design: the URI encodes the setup code
|
|
231
|
-
An interactive `ssh host` session
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
is
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
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
|
|
258
|
-
network is broken. See [docs/testing.md](docs/testing.md) and
|
|
259
|
-
the failure that used to cause exactly
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
395
|
-
|
|
396
|
-
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`,
|
|
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
|
|
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": "
|
|
3
|
+
"version": "3.0.1",
|
|
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": ">=
|
|
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
|
-
"*.{
|
|
81
|
+
"*.{yml,yaml,json}": [
|
|
82
82
|
"prettier --write"
|
|
83
83
|
]
|
|
84
84
|
}
|
package/src/core/stream-auth.js
CHANGED
|
@@ -10,14 +10,14 @@ const STREAM_TOKEN_VERSION = 1;
|
|
|
10
10
|
const STREAM_PATH = '/media';
|
|
11
11
|
const pendingStreamNonces = new Map();
|
|
12
12
|
|
|
13
|
+
// Node's native base64url is unpadded RFC 4648 §5, the same format the old
|
|
14
|
+
// replace() chain produced, so tokens issued before this change still verify.
|
|
13
15
|
function toBase64Url(buffer) {
|
|
14
|
-
return buffer.toString('
|
|
16
|
+
return buffer.toString('base64url');
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
function fromBase64Url(value) {
|
|
18
|
-
|
|
19
|
-
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
|
|
20
|
-
return Buffer.from(padded, 'base64');
|
|
20
|
+
return Buffer.from(value, 'base64url');
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
function pruneExpiredNonces() {
|