Skip to content

Repository files navigation

buzz-me-in

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.

Project docs

How it works

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.

HTTP endpoints

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}.

Architecture notes

  • 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).
  • 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 color source). 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.

Requirements

Raspberry Pi

  • 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

Accounts and services


Setup

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.

1. Install

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.

2. Environment variables

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 .env

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.

3. Cloudflare Tunnel

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 cloudflared

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:

cloudflared tunnel login

Create a named tunnel and route it to a hostname:

cloudflared tunnel create intercom
cloudflared tunnel route dns intercom intercom.yourdomain.com

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:

chmod 600 ~/.cloudflared/cert.pem ~/.cloudflared/*.json

Create ~/.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:404

Validate the config, then test the tunnel manually:

cloudflared tunnel ingress validate
cloudflared tunnel run intercom
curl https://intercom.yourdomain.com/healthz

4. Configure Twilio

In the Twilio Console:

  1. Click your phone number.
  2. Under Voice Configuration → A call comes in, set:
    • Type: Webhook
    • URL: https://intercom.yourdomain.com/twiml
    • Method: HTTP POST
  3. Save.

Security hardening notes:

  • /twiml enforces X-Twilio-Signature verification with your TWILIO_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}, set TWILIO_WEBHOOK_BASE_URL explicitly in .env.

5. Pair with HomeKit

Start the server from the directory holding .env:

cd ~/intercom
buzz-me-in

Open the Home app on your iPhone → +Add AccessoryMore 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 via TWILIO_UNLOCK_DIGITS)

Re-pairing later, or checking the pairing

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 Accessory

Three things to know before the QR works over SSH:

  • ssh -t is 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 interactive ssh host session already has a pty.
  • Run it from the working directory, the one holding .env and persist/. 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 : 4

pairedClients: 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.

6. Install systemd units (production)

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 install

Future 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 intercom

Deploying updates

After 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 intercom

Nothing triggers this automatically. Upgrading is a manual step or a cron job you add yourself.

Upgrading from a git checkout

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 intercom

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.


End-to-end test sequence

Work through these stages in order. Each has a clear pass/fail check before wiring the next.

Stage 1 — Server starts cleanly

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/readyz

Pass: /healthz and /readyz both return {"ok":true} with no errors in the log.


Stage 2 — Tunnel is reachable

cloudflared tunnel run intercom   # or check systemctl status
curl https://intercom.yourdomain.com/healthz
curl https://intercom.yourdomain.com/readyz

Pass: both endpoints return {"ok":true} from the public internet.


Stage 3 — HomeKit pairing

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.


Stage 4 — Inbound Twilio call

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.


Stage 5 — Live view opens (inbound audio + video)

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-stderr log events show libx264 errors → check ffmpeg -codecs | grep 264.
  • 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).
  • Audio missing → check ffmpeg -codecs | grep opus. If libopus is absent, sudo apt install ffmpeg again or compile with --enable-libopus.

Stage 6 — Two-way audio (outbound)

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 -X

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.


Stage 7 — Unlock

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.


Stage 8 — Caller hangs up

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.


Stage 9 — HomeKit hangs up

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.


Stage 10 — Production reboot test

sudo reboot
# After ~30 s:
sudo systemctl status cloudflared intercom
curl https://intercom.yourdomain.com/healthz
curl https://intercom.yourdomain.com/readyz

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.


File structure

.
├── 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.

Environment variables

See .env.example for the full list with descriptions and default values.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages