- Python 71.3%
- Shell 23.8%
- Makefile 4.9%
|
|
||
|---|---|---|
| .idea | ||
| app/phone | ||
| config | ||
| scripts | ||
| systemd | ||
| tests | ||
| wireguard | ||
| .gitignore | ||
| .pre-commit-config.yaml | ||
| .python-version | ||
| DECISIONS.md | ||
| IDEAS.md | ||
| Makefile | ||
| PLAN.md | ||
| pyproject.toml | ||
| README.md | ||
| SPECS.md | ||
| uv.lock | ||
Two-Node Internet Phone
A point-to-point "internet phone" for two Raspberry Pi 4s (the proof of concept for a system later embedded in rotary phones). One button initiates a call that rings the other Pi; pressing the button on the ringing Pi answers; both sides then have live two-way audio. Either side can hang up.
Both Pis run the identical program — behaviour differs only via a per-device
config file. See SPECS.md and PLAN.md for the full design.
Layout
app/phone/ # the package (installed; run via `uv run phone`)
main.py # wiring + asyncio lifecycle (no call logic)
call_controller.py# the declarative (state, event) -> (action, next) table
state_machine.py # CallState / Event / StateMachine (pure logic)
signaling.py # TCP control channel
audio.py # UDP transport + jitter buffer + duplex stream
codec.py # Raw / Opus codecs (registry)
tones.py # ring / ringback / busy tone generation
led.py # sysfs LED driver + dev-mode logging
button.py # GPIO button / stdin in dev mode
config/ # config.example.yaml + dev configs (config.yaml is gitignored)
tests/ # logic + hardware-free audio-pipeline tests
scripts/ # two-node smoke-test driver
How it works
- Signaling — persistent TCP connection, newline-delimited JSON
(
INVITE/ANSWER/BYE/CANCEL/BUSY/PING). Each peer both listens and connects; a deterministic tiebreaker keeps a single connection.PINGevery 2 s; 6 s of silence (or a TCP close) ⇒ disconnect ⇒ reset toIDLE. - Audio — UDP, one stream each way, 16-bit/mono/48 kHz, 20 ms frames. A
small jitter buffer drops late packets (latency over completeness). Codec is
swappable:
rawPCM oropus. - Call states —
IDLE → CALLING → IN_CALL(caller),IDLE → RINGING → IN_CALL(callee). Every transition is logged with a timestamp.
Requirements
- Python 3.13 and uv.
- System libraries on each Pi:
sudo apt install libportaudio2 libopus0
Install
uv sync # dev (Mac/Linux): base + dev tools
uv sync --extra opus # add Opus support
uv sync --extra pi --extra opus # on the Raspberry Pis (adds gpiozero + lgpio)
Configure
Copy the example and edit it on each device:
cp config/config.example.yaml config/config.yaml
| Key | Meaning |
|---|---|
peer_host |
the other Pi's IP address |
signaling_port / peer_signaling_port |
local TCP listen / peer TCP connect |
audio_port / peer_audio_port |
local UDP listen / peer UDP send |
button_gpio |
button pin (internal pull-up, button to GND) |
led_gpio |
null = on-board OK LED via sysfs (only mode supported) |
audio_input_device / audio_output_device |
null = system default; or a device name/index |
codec |
raw or opus |
dev_mode |
true = stdin button + mocked GPIO |
On the two Pis only peer_host differs; all four ports are equal. For
localhost dev on one machine the two configs swap ports (see
config/config.dev-a.yaml / config/config.dev-b.yaml).
List audio devices to find the USB handset:
uv run python -c "import sounddevice as sd; print(sd.query_devices())"
Run
On each Pi:
uv run phone --config config/config.yaml
In dev mode on your laptop (stdin Enter = button press, GPIO mocked):
# terminal 1
uv run phone --config config/config.dev-a.yaml
# terminal 2
uv run phone --config config/config.dev-b.yaml
--dev forces dev mode regardless of the config's dev_mode. The phone
console script is equivalent to python -m phone. scripts/smoke_two_nodes.sh
drives both nodes through a call automatically.
Note: a real two-way conversation can't be tested on a single dev machine — both processes share the default mic/speaker and feed back. Localhost dev verifies signaling, tones, LED logic, and that audio flows; genuine two-way audio is validated on the two Pis with separate handsets.
Run on boot (systemd)
To have a Pi run the phone automatically on boot (and restart it if it ever exits), install it as a systemd service:
make service PI=phone-a.local # enable + start now
make service PI=phone-b.local
This renders systemd/phone.service for that Pi —
filling in its user, deploy directory and uv path — installs it to
/etc/systemd/system/, and enables it. The unit runs the phone as the normal
deploy user (ALSA audio + the LED udev rule need no root) and orders itself
after wg-quick@wg0 so the overlay is up before it dials the peer.
Pass START=0 to enable it for the next boot without starting it now. Once
installed, manage it with the usual systemctl / journalctl:
sudo systemctl status phone # is it running?
journalctl -u phone -f # follow the log
sudo systemctl restart phone # after a code change + re-sync
make run (foreground, streaming to your terminal) is still the tool for
bring-up and debugging — just stop the service first (sudo systemctl stop phone) so the two don't fight over the audio device and ports.
On-board LED (sysfs)
The phone drives the on-board activity LED via sysfs. Its device name varies by
model and kernel — ACT on Pi 4/5 with recent Pi OS, led0 on older kernels —
so the app auto-detects which is present and disables the LED (with one logged
warning) if neither is.
Writing to sysfs needs root, so scripts/pi_install.sh installs a udev rule
(/etc/udev/rules.d/99-phone-led.rules) that grants the phone's user write
access, and applies it immediately — no manual step or reboot needed. The rule:
ACTION=="add", SUBSYSTEM=="leds", KERNEL=="ACT|led0", \
RUN+="/bin/chmod -R a+w /sys/class/leds/%k"
LED states: off = no peer · solid = idle + peer online · slow blink = calling / in call · double-blip = ringing.
macOS + Opus
To use codec: opus on a Mac, install the native library:
brew install opus
That's all — codec.py adds Homebrew's lib dir (/opt/homebrew/lib, or
/usr/local/lib on Intel) to the ctypes search path automatically, so no
DYLD_FALLBACK_LIBRARY_PATH env var is needed. PortAudio for sounddevice
ships inside the wheel, so no extra install is required for audio.
(None of this applies to the Pis, where libopus0 is on the standard path.
Default dev configs use codec: raw, so Opus only matters when you opt into it.)
Development
uv run pytest # logic + audio-pipeline tests
uv run ruff check . # lint
uv run ruff format . # format
uv run mypy . # strict type-check
uv run pre-commit install # run all of the above on every commit