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 +68 -0
- package/LICENSE +21 -0
- package/README.md +452 -0
- package/homekit.js +789 -0
- package/intercom.service +48 -0
- package/package.json +73 -0
- package/server.js +420 -0
- package/src/core/config.js +133 -0
- package/src/core/log.js +132 -0
- package/src/core/media-stream.js +335 -0
- package/src/core/mulaw-audio.js +233 -0
- package/src/core/return-audio-sdp.js +95 -0
- package/src/core/safe-equal.js +22 -0
- package/src/core/state.js +149 -0
- package/src/core/stream-auth.js +130 -0
- package/src/core/twilio-ids.js +27 -0
- package/src/core/types.js +145 -0
- package/src/core/ws-events-schema.js +110 -0
- package/twilio-api.js +53 -0
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.
|