Apartment intercom to Apple HomeKit, bridged over Twilio.
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.
- Architecture guide
- Testing guide
- Contributor guide — git workflow, module format, logging conventions
Intercom box
│ PSTN call
▼
Twilio phone number
│ POST /twiml → <Connect><Stream>
│ Bidirectional WebSocket (mulaw/8kHz audio)
▼
Cloudflare Tunnel
│
▼
RPi: server.js (HTTP + WebSocket on one port)
│
├─ inbound ──► PassThrough stream
│ │ pipe
│ ▼
│ ffmpeg: mulaw/8kHz → Opus/16kHz + blank H.264
│ │ SRTP
│ ▼
│ HAP-NodeJS CameraController
│ │ HomeKit SRTP
│ ▼
│ iPhone / HomePod
│
├─ outbound ◄─ iPhone mic → HAP-NodeJS SRTP → local UDP
│ │
│ ffmpeg: Opus → mulaw/8kHz
│ │ stdout
│ server.js wraps in Twilio media JSON envelope
│ │ WebSocket
│ ▼
│ Twilio → intercom speaker
│
└─ unlock ────► HomeKit Lock tile → generated DTMF media
sent over the active Twilio WebSocket
Single process, single port. HTTP (TwiML) and WebSocket (media stream) share port 8080. Cloudflare Tunnel exposes one stable public URL for both.
All endpoints are reachable from the public internet through the tunnel; each enforces its own auth:
| Endpoint | Method | Auth | Purpose |
|---|---|---|---|
/twiml |
POST | Twilio signature (X-Twilio-Signature) |
Returns TwiML for incoming calls |
/media |
WebSocket | One-time HMAC stream token (issued in TwiML) | Bidirectional call audio |
/status |
GET | Bearer token (STATUS_API_TOKEN) |
Active call info |
/healthz |
GET | None (by design) | Liveness probe, returns {"ok":true} |
/readyz |
GET | None (by design) | Readiness probe, returns {"ok":true} |
/ringtone |
GET | None | Ring tone WAV for debug/manual checks |
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}.
- CallSid arrives in the WebSocket
startevent (data.start.callSid), not in HTTP headers. It is stored in the call session manager and retrieved viastate.getActiveCall()for mid-call operations (currently hangup). - 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. - Video is a synthetic black H.264 stream generated by ffmpeg (
lavfi colorsource). HomeKit requires a video track; there is no real camera. - 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.
- Raspberry Pi 4 (2 GB RAM recommended) running Raspberry Pi OS (64-bit)
- Node.js >= 20
- Runtime is tested on Node 20+.
- Repo tooling/CI currently runs on Node 20, 22, and 24.
- ffmpeg with libx264 and libopus:
sudo apt install ffmpeg
- Twilio account with a Voice-enabled phone number
- Cloudflare account (free) with a domain, or use Cloudflare's free
*.cfargotunnel.comsubdomain
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.
sudo npm install -g buzz-me-in
command -v buzz-me-in # sanity check; the bin lands under `npm prefix -g`Contributors work from a clone of the repository instead, with npm install. See AGENTS.md for the workflow and quality gates.
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:
mkdir -p ~/intercom && cd ~/intercom
cp "$(npm root -g)/buzz-me-in/.env.example" .env
chmod 600 .envEdit .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.
Install cloudflared on the RPi via the official Cloudflare apt repository (preferred over a manual binary download — gives you automatic updates via apt upgrade):
sudo mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
| sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' \
| sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt-get update && sudo apt-get install cloudflaredAuthenticate (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:
cloudflared tunnel loginCreate a named tunnel and route it to a hostname:
cloudflared tunnel create intercom
cloudflared tunnel route dns intercom intercom.yourdomain.comtunnel 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:
chmod 600 ~/.cloudflared/cert.pem ~/.cloudflared/*.jsonCreate ~/.cloudflared/config.yml (adjust /home/pi if you run as a different user):
tunnel: <tunnel-id-from-create-output>
credentials-file: /home/pi/.cloudflared/<tunnel-id>.json
ingress:
- hostname: intercom.yourdomain.com
service: http://localhost:8080
# anything else routed to this tunnel gets a 404 instead of the intercom
- service: http_status:404Validate the config, then test the tunnel manually:
cloudflared tunnel ingress validate
cloudflared tunnel run intercom
curl https://intercom.yourdomain.com/healthzIn the Twilio Console:
- Click your phone number.
- Under Voice Configuration → A call comes in, set:
- Type: Webhook
- URL:
https://intercom.yourdomain.com/twiml - Method: HTTP POST
- Save.
Security hardening notes:
/twimlenforcesX-Twilio-Signatureverification with yourTWILIO_AUTH_TOKEN.- TwiML embeds a short-lived, one-time signed token in the media stream URL. WebSocket connections are rejected unless this token validates.
- If your externally visible webhook base URL differs from
https://{TUNNEL_HOSTNAME}, setTWILIO_WEBHOOK_BASE_URLexplicitly in.env.
Start the server from the directory holding .env:
cd ~/intercom
buzz-me-inOpen 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).
After pairing you will see:
- A doorbell tile (rings when the intercom calls)
- A lock tile (tap to unlock — plays the DTMF unlock sequence, default
w9w, configurable viaTWILIO_UNLOCK_DIGITS)
Once the server runs under systemd its stdout is not a terminal, so it never prints the setup QR. Two flags read the pairing state directly instead. Neither starts the server, contacts HomeKit or signals the running service, so both are safe against a live deployment:
buzz-me-in --check # persist directory, accessory file, and paired client count
buzz-me-in --qr # the setup QR, for Home app → Add AccessoryThree things to know before the QR works over SSH:
ssh -tis required.ssh host "command"allocates no pty, so stdout is not a TTY and the command refuses by design: the URI encodes the setup code and must never reach a pipe or a log. An interactivessh hostsession already has a pty.- Run it from the working directory, the one holding
.envandpersist/. Pairing state is read relative to the current directory, so elsewhere it reports finding nothing while the service is perfectly healthy. - Use a dark terminal background. The QR is drawn with block characters that take the terminal's foreground colour for the light modules, so it scans on a dark background and comes out inverted on a light one. If your phone will not lock onto it, switch profile before assuming anything is broken.
Putting those together, from the machine you are sitting at:
ssh -t <pi-host> 'cd <working-directory> && buzz-me-in --qr'--check has no such constraints, prints no secret, and is safe to pipe or paste:
$ buzz-me-in --check
persistDir : /home/<user>/intercom/persist
accessoryInfo : AccessoryInfo.XXXXXXXXXXXX.json
category : 18 (video doorbell)
setupID : present
pairedClients : 4pairedClients: 0 on an accessory you believe is paired means the pairing is gone, not that the
network is broken. See docs/testing.md and tests/homekit-shutdown.test.cjs for
the failure that used to cause exactly that.
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.
sudo cloudflared --config ~/.cloudflared/config.yml service installFuture tunnel config edits go in /etc/cloudflared/config.yml, followed by sudo systemctl restart cloudflared.
Install the intercom server unit, which ships inside the npm package:
sudo cp "$(npm root -g)/buzz-me-in/intercom.service" /etc/systemd/system/
# Edit WorkingDirectory, EnvironmentFile and User if your paths differ.
# ExecStart needs no edit: it resolves buzz-me-in from systemd's PATH,
# which covers both common npm global prefixes.
sudo nano /etc/systemd/system/intercom.service
sudo systemctl daemon-reload
sudo systemctl enable --now intercom
# Check status
sudo systemctl status cloudflared
sudo systemctl status intercomAfter the initial setup, ship a new release by installing it from npm and restarting:
sudo npm install -g buzz-me-in@latest
sudo systemctl restart intercomNothing triggers this automatically. Upgrading is a manual step or a cron job you add yourself.
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:
sudo systemctl stop intercom
tar czf ~/intercom-backup-$(date +%F).tgz -C ~ intercom --exclude=node_modules
chmod 600 ~/intercom-backup-$(date +%F).tgz # the archive contains .env
sudo npm install -g buzz-me-in
sudo cp "$(npm root -g)/buzz-me-in/intercom.service" /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl start intercomConfirm 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.
Work through these stages in order. Each has a clear pass/fail check before wiring the next.
Run from the working directory holding .env, so config and pairing state resolve:
cd ~/intercom
buzz-me-in
curl http://localhost:8080/healthz
curl http://localhost:8080/readyzPass: /healthz and /readyz both return {"ok":true} with no errors in the log.
cloudflared tunnel run intercom # or check systemctl status
curl https://intercom.yourdomain.com/healthz
curl https://intercom.yourdomain.com/readyzPass: both endpoints return {"ok":true} from the public internet.
Open Home app → Add Accessory → enter pincode.
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.
Call your Twilio number from any phone (simulate the intercom caller).
Pass (server log):
{"ts":"...","level":"info","message":"Media websocket start accepted","component":"media-ws","callSid":"CA...","event":"start","streamSid":"MZ..."}
{"ts":"...","level":"info","message":"Doorbell triggered","component":"homekit","event":"doorbell-triggered"}
Pass (phone): A doorbell notification appears on your iPhone.
curl -H "Authorization: Bearer $STATUS_API_TOKEN" https://intercom.yourdomain.com/status
# → {"active":true,"callSid":"CA..."}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.
Tap the doorbell notification → open the live camera view.
Pass (server log):
{"ts":"...","level":"info","message":"Bound mulaw stream to active HomeKit session","component":"homekit","event":"mulaw-stream-bound","sessionId":"..."}
Pass (iPhone): A black video tile appears. You should hear audio from the intercom caller through the iPhone speaker.
Troubleshooting:
ffin-stderrlog events show libx264 errors → checkffmpeg -codecs | grep 264.- Video shows "stream unavailable" → try removing the
?rtcpport=…query parameters from the SRTP URLs inhomekit.js(some HomeKit implementations use RTCP-mux on the same port). - Audio missing → check
ffmpeg -codecs | grep opus. Iflibopusis absent,sudo apt install ffmpegagain or compile with--enable-libopus.
Speak into the iPhone microphone while the live view is open.
Pass: Your voice comes out of the intercom speaker (the caller hears you).
Troubleshooting (no outbound audio): 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:
sudo tcpdump -i any -n "udp port <returnAudioPort>" -c 10 -XByte 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.
Tap the lock tile in the Home app → set to Unlocked.
Pass (server log):
{"ts":"...","level":"info","message":"Sent DTMF unlock over active media stream","component":"homekit","event":"unlock-requested","callSid":"CA...","digits":"w9w"}
Pass (intercom): The door lock buzzes / the intercom plays digit 9.
The lock tile resets to Secured automatically after 3 seconds.
Have the intercom caller hang up (or wait for them to go away).
Pass (server log):
{"ts":"...","level":"info","message":"Media websocket stop event","component":"media-ws","callSid":"CA...","event":"stop"}
{"ts":"...","level":"info","message":"Media websocket session ended","component":"media-ws","callSid":"CA...","event":"session-ended","reason":"twilio-stop","messageCount":42}
Pass (iPhone): The live view dismisses automatically.
Open the live view, then dismiss it on the iPhone (tap the X / end button).
Pass (server log):
{"ts":"...","level":"info","message":"Inbound ffmpeg exited","component":"homekit","event":"ffin-exit","reason":"nonzero-exit","exitCode":null,"sessionId":"..."}
{"ts":"...","level":"info","message":"Hanging up call","component":"twilio-api","event":"hangup","callSid":"CA..."}
Pass (Twilio): The call shows as completed in the Twilio Console call log.
sudo reboot
# After ~30 s:
sudo systemctl status cloudflared intercom
curl https://intercom.yourdomain.com/healthz
curl https://intercom.yourdomain.com/readyzPass: 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.
.
├── server.js # CLI flag dispatch; HTTP routes + WebSocket server; graceful shutdown
├── homekit.js # HAP-NodeJS camera+doorbell accessory; ffmpeg pipelines
├── twilio-api.js # Twilio REST API helpers (hangup)
├── src/core/
│ ├── cli.js # --qr/--check/--help/--version, dispatched before any other require
│ ├── config.js # validated env/config loading
│ ├── log.js # structured JSON-lines logger
│ ├── media-stream.js # Twilio media stream protocol (start/media/stop)
│ ├── mulaw-audio.js # mu-law audio generation (DTMF, ringback, ringtone WAV)
│ ├── safe-equal.js # constant-time string comparison
│ ├── state.js # call session management and stale-session reaping
│ ├── stream-auth.js # one-time stream token sign/verify
│ ├── types.js # JSDoc typedefs (no runtime exports)
│ └── ws-events-schema.js # Twilio WS envelope validation and media payload parsing
├── tests/ # node --test suites (*.test.cjs)
├── docs/ # architecture and testing guides
├── intercom.service # systemd unit for the Node.js server
├── commitlint.config.js # conventional commit rules (enforced by hooks + CI)
├── .env.example # environment variable template
└── package.json
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.
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.
See .env.example for the full list with descriptions and default values.