A programmable home phone. Known callers ring the whole house; everyone else meets the bouncer. Runs happily on a spare Raspberry Pi.
The home phone my kids never saw coming.
A programmable home phone. A Raspberry Pi is the obvious host — cheap, silent, and it only has to do one thing — but anything that runs Asterisk will do.
Known callers hear "Welcome, I'll connect you" and the whole house rings. Everyone else meets the lobby: dial a 6-digit extension, or the bouncer says "Good day" and hangs up.
No port forwarding. No inbound firewall rules. The Pi registers outbound to a VoIP provider and the trunk rides that registration back in.
┌─ known caller ──→ "Welcome, I'll connect you" ──→ 🔔 whole house
inbound call ──→ ┤
└─ unknown ───────→ "Welcome to the phone lobby.
Please dial an extension." ──→ ✓ that handset rings
└─→ ✗ "Good day." *click*
Repo metadata — description: A programmable home phone. Known callers
ring the whole house; everyone else meets the bouncer. Runs happily on a spare
Raspberry Pi. · topics: asterisk voip sip raspberry-pi pbx homelab
self-hosted home-automation golang telephony
Asterisk does SIP, RTP, and DTMF detection. It is very good at those and there is no reason to reimplement them. Everything about who gets in lives in doorman, a single static Go binary that talks to Asterisk over ARI on loopback — one call is one goroutine, and the caller hanging up cancels a context that unwinds everything.
graph LR
subgraph provider["VoIP.ms"]
T["SIP trunk<br/>(registration-based)"]
end
subgraph pi["Raspberry Pi"]
A["Asterisk<br/>SIP · RTP · DTMF"]
D["doorman<br/>one static Go binary"]
P[("policy.toml<br/>allow-list · PINs")]
W["prompts/*.wav<br/>pre-rendered"]
A <-->|"ARI · ws + REST<br/>127.0.0.1:8088"| D
D --> P
A --> W
end
subgraph lan["LAN"]
H["Grandstream handsets<br/>register inward"]
end
T <-->|"outbound REGISTER<br/>+ 30s keepalive"| A
A <--> H
The only connection that crosses the WAN is one the Pi opens itself.
Beyond the lobby, the handsets get the classic PBX toolkit — paging/intercom
(500), call parking (700), a family conference (600), BLF busy lamps, hold
music, voicemail with email delivery (*97), ringer ladders that escalate
kids → adults → voicemail, per-extension afterhours windows, an outbound
console (*4) for calling out as another of your numbers, and an optional
dial-555 hook into Home Assistant's Assist. There is also an optional webhook
that fires when the house starts ringing and again when a call ends, so Home
Assistant can announce "call from Grandma" on a speaker — doorman sends an
event and knows nothing about speakers, so every target HA supports comes
free. Details in docs/RUNBOOK.md.
sequenceDiagram
autonumber
participant C as Caller
participant A as Asterisk
participant D as doorman
participant H as Handsets
C->>A: INVITE (via trunk)
A->>A: Answer()
A->>D: StasisStart
D->>D: normalise caller ID → E.164
D->>D: look up in allow-list
alt Known caller
D->>C: ▶ "Welcome, I'll connect you"
D->>A: create bridge + ring indication
D->>H: originate all house endpoints
H-->>D: StasisStart (first to answer wins)
D->>A: bridge winner, hang up the rest
Note over C,H: 🔊 talking
else Unknown caller
D->>C: ▶ "Welcome to the phone lobby…"
loop 10s for first digit, 3s between
C->>A: DTMF
A->>D: ChannelDtmfReceived
end
alt Valid 6-digit extension
D->>H: ring that handset/group
else Timeout — whatever [line] on_no_input says
D->>C: ▶ "Good day." / take a message / ring the house
else PIN attempts exhausted
D->>C: ▶ "Good day."
D->>A: hangup
end
end
stateDiagram-v2
[*] --> starting
starting --> greeting_known: on allow-list
starting --> greeting_lobby: unknown
starting --> dismissing: rate limited
greeting_known --> ringing: greeting ends
greeting_known --> collecting: caller barges in
greeting_lobby --> collecting: greeting ends
greeting_lobby --> collecting: caller barges in
collecting --> ringing: valid PIN
collecting --> collecting: wrong PIN, attempts left
collecting --> ringing: silence, known caller
collecting --> ringing: silence, on_no_input = ring-house
collecting --> voicemail: silence, on_no_input = voicemail
collecting --> dismissing: silence, on_no_input = dismiss
collecting --> ringing: attempts exhausted, known caller
collecting --> dismissing: attempts exhausted
ringing --> bridged: handset answers
ringing --> voicemail: nobody home, mailbox configured
ringing --> dismissing: nobody home
bridged --> [*]: hangup
voicemail --> [*]: released to the dialplan
dismissing --> [*]: "Good day"
Two edges are worth reading twice. The known-caller greeting is a dial
window — press a digit over it to reach one room rather than the whole
house — and pressing nothing rings the house the moment it ends, with no
silence added. And nothing at the keypad can cost an allow-listed caller
the house: every one of their exits from collecting rings it.
Which of the three silence edges applies is [line] on_no_input, and it
defaults to dismiss — what the lobby has always done.
call-me-maybe/
├── CLAUDE.md project memory — invariants, conventions, commands
├── .claude/skills/ /diagnose-call, /add-person, /ship
├── cmd/doorman/ entrypoint, subcommands, ARI event router
├── internal/render/ handsets.toml + trunks.toml → Asterisk config
├── internal/lobby/ the state machine — lobby, bouncer, ring groups + fake-ARI tests
├── internal/policy/ allow-list, PINs, E.164, hot reload, PIN rotation
├── internal/ari/ thin typed ARI client (REST + reconnecting WebSocket)
├── internal/lsp/ language server — same validator, as you type
├── internal/config/ env parsing, names match examples/.env.example
├── examples/ templates to copy, plus worked scenarios/ (see its README)
├── asterisk/ pjsip / extensions / ari / http / rtp config
├── prompts/ prompt text + piper build script
├── scripts/ systemd unit, smoke.sh, coverage floors
├── tools/ nested module: custom analyzers (not linked into doorman)
├── .githooks/ pre-push gate, installed by `make hooks`
├── .github/workflows/ CI and tag-driven releases
├── install.sh release installer — detects host, verifies checksums
├── site/ callmemaybe.cc — the public site
├── ecomm/ store.callmemaybe.cc — pack storefront
└── docs/ RUNBOOK · TASKS · architecture · roadmap
lobby and bouncer aren't separate services — they're two branches of one state machine, so splitting them would add a network hop and no seam. speech isn't a service either: prompts are rendered once with piper and committed as WAVs, so the Pi never does TTS and a broken speech service can never make the phone unreachable. And doorman ships as one statically linked binary (make cross covers both 64-bit and 32-bit Pi OS) — nothing to install on the Pi but the file itself.
Do this first; the values feed straight into pjsip.conf.
- Sub Accounts → Create Sub Account. Don't put your main login on the Pi. Auth type User/Password, device type generic ATA/IP phone, NAT: yes. You'll get a username like
123456_home. - Pick the POP nearest you and use that exact hostname everywhere.
- DIDs → Manage DID → route it to that sub account.
- Codecs: ulaw first, g722 second, disable the rest. A Pi has no business transcoding.
- Enable E911 on the DID. It's a couple of dollars a month and it's the one line item worth not skipping on a house phone.
sudo apt install asterisk
cd /etc/asterisk
sudo cp /opt/call-me-maybe/asterisk/*.conf .
sudo cp /opt/call-me-maybe/asterisk/pjsip.conf.example pjsip.conf
sudo cp /opt/call-me-maybe/asterisk/ari.conf.example ari.conf
# edit pjsip.conf (sub account, POP, handset passwords) and ari.conf (password)
sudo systemctl restart asteriskConfirm the trunk came up:
sudo asterisk -rx "pjsip show registrations" # want: Registered
sudo asterisk -rx "pjsip show endpoints"On a workstation with piper and ffmpeg (not the Pi):
bash prompts/build.sh
rsync -av prompts/build/ pi@raspberrypi:/tmp/cmm-prompts/Then on the Pi:
sudo mkdir -p /var/lib/asterisk/sounds/call-me-maybe
sudo cp /tmp/cmm-prompts/* /var/lib/asterisk/sounds/call-me-maybe/
sudo chown -R asterisk:asterisk /var/lib/asterisk/sounds/call-me-maybeConfiguration is three files with three jobs: .env holds secrets,
handsets.toml holds the hardware (and doorman render generates the
per-handset Asterisk config from it, so inventory and dialplan can't drift),
and policy.toml holds the rules — allow-list, extensions, ladders, and
named [[schedules]] referenced as afterhours = "school-night".
There is an optional fourth, trunks.toml, and not having it is the
normal state: one provider fits comfortably in the hand-written
asterisk/pjsip.conf this repo ships. Declare providers there when you have
more than one, and doorman render generates their registrations, one inbound
dialplan context each, and the route emergency calls take. A line then names
its provider with [line] trunk, calls placed as that line leave by it, and
911 leaves by a designated trunk with a fallback to the others. See the
runbook's "Add a second provider", and read "Which trunk carries 911" before
you do — this is a supplementary phone and should never be a household's
only route to emergency services.
A trunk there can also carry API credentials, and then doorman balance says
what is left on each prepaid account and exits non-zero when one is running
low, so cron is a one-liner. It is worth having because a prepaid trunk that
reaches zero does not error — inbound calls just stop arriving, and "nobody
called today" is indistinguishable from a quiet Tuesday. A provider that
invoices instead is reported as postpaid rather than as a zero. That key is
higher-privilege than the SIP password, so the daemon deliberately never reads
it and the check belongs wherever your alerting already runs.
There is an optional fifth, contacts.toml, and not having it is likewise
the normal state. It names vCard exports to read — your contacts, your
spouse's, and one marked kind = "block" for the nuisance list — so the phone
can see the address book your household already curates. Every number is
classified from the card alone, with no lookups, on one rule: if a stranger
can look the number up, it must not be automatic admission. An ORG or a
work number or an 800 number reads as published and hears the lobby; a named
card with a mobile and no organisation reads as personal. Anything ambiguous
is published, because wrong-closed costs a plumber ten seconds and wrong-open
rings every phone in the house at 3am. The lobby walks that as a ladder, first
match wins: a block source is dismissed without hearing the lobby at all, then
[[people]], then a personal contact — both straight through — and everybody
else, published contacts included, dials an extension like any stranger.
[[people]] stays the deliberate list and beats the classifier, so writing
somebody down is the override; a number in both a block source and [[people]]
is a contradiction doorman check fails on rather than ranks. Delete every
source and the phone works exactly as it did.
Editing those files gets IDE support: doorman lsp is a language server
whose diagnostics come from the same validator that guards the daemon —
unknown handset ids, bad schedule references, and duplicate PINs get
squiggles as you type, with completions for every cross-reference. Works in
Neovim over SSH on the Pi with zero extra installation. See
docs/editor.md.
On a workstation:
make cross
scp bin/doorman-linux-arm64 pi@raspberrypi:/opt/call-me-maybe/bin/doorman
# (uname -m says armv7l on the Pi? use bin/doorman-linux-armv7 instead)On the Pi:
./bin/doorman init # interview, generate every secret, write the three files
./bin/doorman check # confirm it resolvesinit exists because the examples cannot work: their PINs are a sentinel
that no keypress can produce, so doorman check refuses to load them. A
placeholder that merely looks wrong — 4242 — works silently forever. This one
can only fail, loudly, until it is replaced with values from crypto/rand.
PINs print to stdout once and are never logged.
Then sudo cp scripts/doorman.service /etc/systemd/system/ and enable it.
Or skip the build and take a release binary:
curl -fsSL https://raw.githubusercontent.com/ericdmoore/call-me-maybe/main/install.sh | bashIt detects your OS and architecture, verifies the SHA-256 against the
published checksums, and installs to /usr/local/bin (or ~/.local/bin when
that isn't writable). --version, --prefix, and --force all work.
doorman has no opinion about brands. Anything that registers to Asterisk as
a SIP endpoint works — handsets.toml only needs a PJSIP/<id>, and
doorman render generates the rest. So buy on ergonomics and price, not
compatibility.
Asterisk plus one Go binary is a light load; the phone plant is idle almost all the time. A Pi 4 (2 GB) or Pi 5 is plenty, and a 3B+ is fine — this is a genuinely good use for the one already in your drawer.
- Pi 5 on Amazon — most headroom, wants active cooling and the 5 V/5 A supply
- Pi 4 on Amazon — the sweet spot; 2 GB is ample, and a PoE HAT fits
- Pi 3 on Amazon — cheapest that still has wired Ethernet
What actually matters more than the model:
- Use wired Ethernet for the Pi itself. WiFi jitter on the box doing RTP is the difference between clear audio and someone sounding underwater. This is the one hardware choice worth being rigid about. (A Pi Zero 2 W has no Ethernet port — it needs a USB adapter, at which point buy a 4.)
- Boot from a USB SSD if you can, or use an A2 microSD. A 24/7 service writing logs and voicemail is the classic way to wear out a cheap card, and the failure looks like a phone that mysteriously stopped answering.
- A PoE HAT on a Pi 4 gets you power and network over one cable, which is what you want if it lives in a closet next to the switch.
- Put it on a small UPS. A house phone that dies in a power cut is worse than no house phone, because you thought you had one. (VoIP still dies with your internet — which is exactly why the E911 note above matters.)
Grandstream's Wi-Fi handsets are what this was built and tested against:
Every one of these registers over SIP and drops into handsets.toml
unchanged — the type column is the decision that matters, not the brand.
| Model | Type | Notes |
|---|---|---|
| Grandstream HT812 V2 | ATA · 2× FXS | Puts analog phones you already own on the lobby — an existing cordless base, or a 1950s rotary. Two ports, so two [[handsets]] entries. Usually the cheapest way to cover rooms, and the most fun. |
| Grandstream DP752 | DECT base | Carries several DP7xx handsets, each registering as its own SIP account — one [[handsets]] entry per handset, exactly like a desk phone. |
| Grandstream DP730 | DECT handset | Pairs to the DP752. Buy one per room. |
| Yealink W73P | DECT bundle | W70B base + W73H handset. The straightforward place to start. |
| Yealink W73P + extra W73H | DECT bundle | Same, second handset in the box — cheaper than adding one later. |
| Yealink W79P | DECT bundle | W70B base + W59R, the ruggedised handset — the one to pick if it's going to get dropped. |
| Yealink W79P + 1 extra W59R | DECT bundle | Two rugged handsets. |
| Yealink W79P + 2 extra W59R | DECT bundle | Three handsets — cheapest route to a whole-house cordless set. |
| Grandstream GRP2601P | Desk · PoE | Entry desk phone. P = PoE, so one cable carries power and network. |
| Grandstream GRP2602P | Desk · PoE | Same family, the higher tier of the two. |
| Grandstream GRP2602W | Desk · Wi-Fi | W = built-in Wi-Fi, for a room with no Ethernet drop. Prefer the P anywhere you have a cable. |
| Yealink T31P | Desk · PoE | Very common, well built, cheap. A safe default. |
| Fanvil X3U | Desk · PoE | About as cheap as this gets while still being pleasant to use. |
| Cisco SPA504 | Desk · PoE | The classic four-line workhorse, and abundant secondhand. Factory-reset anything used — it may still be provisioned to its last owner. |
Wi-Fi vs DECT vs wired, honestly: Wi-Fi handsets are convenient and give you one less base station, but they hand off between access points poorly — walk from the kitchen to the garage mid-call and you may lose it. DECT is the technically better answer for cordless and its batteries last far longer. Wired PoE desk phones never surprise you at all. A sensible house is usually a couple of wired phones in fixed spots plus one cordless system.
Two options with nothing to buy: used Poly VVX and Cisco SPA units flood the market from office decommissions at a fraction of new, and a softphone (Linphone is free; Groundwire on a kid's phone) is a real SIP endpoint — the easiest way to test a ringer ladder before spending anything.
Disclosure: the Amazon links on this page are affiliate links — if you
buy through one, the project earns a commission at no cost to you. Nothing
here is sponsored and no vendor has been paid for placement. Be aware of the
obvious bias anyway: everything with a link earns something, so the honest
guidance is the part that doesn't — doorman works with any SIP endpoint,
the ATA row means the phones already in your house may need nothing bought at
all, and a free softphone will test your whole ladder before you spend a
cent.
Ten seconds is for the first digit, not all six. A stranger reading a PIN off a card needs longer than a stopwatch allows. The window is 10s to the first digit, then 3s between digits — so a confident caller is through in two seconds and a fumbling one still gets a fair shot. Both are tunable in .env.
Extensions are PINs, so treat them like passwords. A 6-digit keypad exposed to the PSTN is a million combinations — plenty against a human, not much against a machine that can redial. After three failed calls in an hour, a number skips the greeting entirely and goes straight to "Good day": no prompt, no dial window, nothing to brute-force against. And don't pick PINs at all: doorman rotate generates them with crypto/rand, rewrites policy.toml in place (comments intact), and the running daemon picks the change up within a second.
Caller ID is normalised before anything looks at it. VoIP.ms passes the calling number roughly as the originating carrier hands it over, so the same person shows up as 5125550100, 15125550100, and +15125550100 on different days. Compare raw strings and Grandma meets the bouncer about a third of the time. Everything folds to E.164 first — probe any value with doorman e164 "(512) 555-0100".
A bad policy.toml can't take the phone down. Edits are picked up live, but a file that fails validation is logged and discarded — the last good policy stays in service. Run doorman check before you rely on it anyway.
Withheld caller ID always meets the bouncer. Anonymous callers share a single rate-limit bucket, which means one persistent blocked caller can dismiss others faster. That's the intended trade.
CLAUDE.md is loaded automatically by Claude Code and carries the invariants —
the things that, if broken, fail in ways that look like working software.
docs/RUNBOOK.md has provisioning, a bottom-up verification
ladder, a symptom-to-cause troubleshooting table, and the raw ARI calls for
probing by hand. docs/TASKS.md is the backlog with acceptance
criteria.
Three skills ship with the repo: /diagnose-call, /add-person, /ship.
make hooks # once per clone — installs the pre-push gate
make check # gofmt + vet + lint + test + build
make cover # -race, plus per-package coverage floors
make lint # nilness + the no-secrets-in-logs analyzer
./scripts/smoke.sh # on the Pi — verifies the whole chainThe invariant that caller IDs and PINs never reach the logs is enforced by a
custom go/analysis pass rather than by review: tools/nologsecrets rejects
a PIN or caller number passed to any slog call, while still allowing
len(digits) and tail(number). It lives in a nested module because a linter
has no business in the binary that ships to an appliance — nothing about that
separation is a comment on dependencies generally.
doorman schema prints the whole configuration surface as JSON Schema — every
key, type, pattern, default, and cross-file reference across policy.toml,
handsets.toml, trunks.toml, and the environment:
doorman schema # all of them, as one bundle
doorman schema policy # or just oneIt exists because policy.toml is the real interface to this system, and the
only machine-readable knowledge of its shape used to live inside the
validator — which will tell you whether a file is valid but not what a valid
file looks like. Handy for editor integration, for generating config, and for
handing a language model the rules instead of hoping it infers them.
It describes shape, not validity. JSON Schema cannot express "this ladder
step names a handset that must exist in handsets.toml", handsets XOR
steps, or the roughly thirty semantic checks behind doorman check; those
are carried as x-cross-references and x-rules annotations, and the
document says so about itself. doorman check is still the authority.
Because it is hand-authored, internal/schema's tests fail if a toml tag or
an environment variable exists in Go without a matching schema entry — the
schema cannot quietly fall behind the code.
There is a man page at docs/doorman.1 (make man to read
it from the working tree; install.sh installs it), and
llms.txt orients a language model arriving with no other
context. Both lead with doorman schema.
The pre-push hook (.githooks/pre-push, versioned with the code) blocks a
push on unformatted code, vet findings, failing tests, an example config that
stopped validating, or a secret force-added past .gitignore. CI runs the
same checks on every push and pull request, cross-compiles both Pi targets,
and shellchecks the shipped scripts. Tagging with make release-tag TAG=x.y.z
builds all five targets, stamps the version into each binary, and publishes a
release with SHA-256 checksums that install.sh verifies.
The state machine in internal/lobby/session.go is fully covered through the
fake-ARI harness in internal/lobby/fake_ari_test.go — known callers,
valid/invalid PINs, timeouts, barge-in, ring-group races, and mid-call hangups
all run without an Asterisk.
Voicemail is sketched but not wired: record on the caller channel → whisper on a beefier box → email with the transcript and the original audio attached. The .env block for it is parsed and ignored so the shape is settled. See docs/roadmap.md.
The bouncer's personality is a swappable folder of audio. PROMPT_MEDIA_PREFIX
points at a pack; drop in another and the lobby greets strangers as a Victorian
doorman, a bureaucrat, or a ship's computer instead. The format is specified
and CC0 — see docs/PACKS.md to build one.
The line this project holds: mechanisms are free, content is optional.
Every mechanism — lobby, bouncer, ladders, hold, sound board — is Apache 2.0
and works completely with the bundled pack. Packs make it more fun; nothing
requires buying one. See docs/SUSTAINABILITY.md
for how that squares with paying for the project's upkeep.
Code is Apache 2.0. Bundled audio and the pack format are licensed
separately — see LICENSES.md for the full split, and
CONTRIBUTING.md (no CLA required).
