Async-first GSM/LTE modem telephony framework for Raspberry Pi.
Callstack provides a high-level Python API for managing GSM/LTE modem connections on Raspberry Pi. Built on asyncio with proper state machines, typed events, and clean separation of concerns — it handles voice calls, SMS, and raw AT commands without the thread-per-feature sprawl.
| Feature | Status | Notes |
|---|---|---|
| Voice Calls | ✅ Ready | Inbound/outbound, recording, tone playback, IVR menus, DTMF send/collect |
| SMS | ✅ Ready | Send/receive/subscribe, SQLite persistence, delivery reports, multipart UDH metadata; full multipart reassembly is planned |
| SIM + Network | ✅ Ready | SIM PIN unlock, registration/signal snapshots, BER descriptions |
| USSD | ✅ Ready | AT+CUSD balance checks/carrier menus via service + HTTP endpoint |
| Raw AT Commands | ✅ Ready | Direct modem control via Modem.execute() |
| HTTP Server | ✅ Ready | API-key auth, rate limiting, SMS/USSD/delivery-report endpoints, authenticated /ws, /healthz, and PII-safe /metrics |
| CLI | ✅ Partial | callstack status, callstack send, safe callstack doctor with opt-in scan/config preview, PII-safe callstack monitor, and packaged callstack serve |
| Auto-reconnect | ✅ Ready | Handles USB disconnect/reconnect gracefully; conservative audio-port assignment and multi-modem orchestration are planned |
- Raspberry Pi (3B+/4/5 recommended)
- GSM/LTE modem with USB serial (tested with SIMCOM SIM868)
- Active SIM card with SMS capability
- USB ports for modem (typically creates
/dev/ttyUSB2and/dev/ttyUSB4)
git clone https://github.com/Justinabox/Callstack.git
cd Callstack
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[sqlite]"SMS examples below use 5551234 as a dummy local test recipient; replace it only with a controlled test number when running against real hardware, and never publish real SIM or customer numbers in docs, logs, issues, or PRs.
import asyncio
from callstack import Modem, ModemConfig
async def main():
async with Modem(ModemConfig()) as modem:
# Send an SMS
sms = await modem.sms.send("5551234", "Hello from Callstack!")
print(f"Sent! Reference: {sms.reference}")
# Subscribe to incoming messages
@modem.sms.on_message
async def on_sms(msg):
print(f"From {msg.sender}: {msg.body}")
# Keep running
await modem.run_forever()
asyncio.run(main())Install the HTTP server runtime dependencies with the server extra:
pip install -e ".[server,sqlite]"Run the packaged HTTP server entrypoint for external integrations; callstack serve is available anywhere the package console script is installed:
callstack serve --host 127.0.0.1 --port 8080 --api-key-file /etc/callstack/api-keysFor legacy source-tree workflows, python server.py remains a compatibility wrapper.
Create the API-key file locally and keep it off GitHub, shell transcripts, logs, issues, and PRs:
pip install -e ".[server,sqlite]"
install -d -m 700 /etc/callstack
install -d -m 700 /var/lib/callstack
install -m 600 /dev/null /etc/callstack/api-keys
# Add one locally generated API key to /etc/callstack/api-keys; never paste it into docs or logs.Then run the server with explicit modem and durable SMS-store settings. The CALLSTACK_* variables shown here are parsed by the same redacted config loader used by the CLI, and the key file path may also be supplied with CALLSTACK_API_KEY_FILE:
CALLSTACK_AT_PORT=/dev/ttyUSB2 \
CALLSTACK_AUDIO_PORT=/dev/ttyUSB4 \
CALLSTACK_SMS_DB_PATH=/var/lib/callstack/sms.sqlite3 \
callstack serve --host 127.0.0.1 --port 8080 --api-key-file /etc/callstack/api-keysUse public-safe readiness and metrics smoke checks before wiring SMS or USSD automation. When the server is configured with API keys, read a local key into a shell variable instead of printing it:
CALLSTACK_BEARER_HEADER="$(awk 'NF {print "Authorization: Bearer " $0; exit}' /etc/callstack/api-keys)"
test -n "$CALLSTACK_BEARER_HEADER"
curl -H "$CALLSTACK_BEARER_HEADER" http://127.0.0.1:8080/healthz
curl -H "$CALLSTACK_BEARER_HEADER" http://127.0.0.1:8080/metricsFor an intentional network-bound server, keep API keys enabled and place the service behind a trusted network boundary:
callstack serve --host 0.0.0.0 --port 8080 --api-key-file /etc/callstack/api-keysThe loopback-only unauthenticated override (--allow-unauthenticated-loopback or CALLSTACK_HTTP_ALLOW_UNAUTHENTICATED_LOOPBACK=1) is development-only and is rejected for non-loopback hosts. Never expose SMS or USSD endpoints without API keys or an equivalent trusted network boundary.
Endpoints:
POST /sms/send— Send SMS ({"to": "5551234", "body": "..."});tomust be an optional leading+followed by 3-15 digits in real requests (use redacted values only in public docs/logs)POST /sms/subscribe— Register webhook for incoming SMSGET /sms/messages— List received messagesGET /sms/delivery-reports— List delivery status reportsPOST /ussd/send— Send USSD short codes ({"code": "*123#"})GET /ws— authenticated WebSocket realtime feed for PII-safe typed eventsGET /healthz— Return a public-safe readiness payload with modem connectivity, uptime, and SMS-store readinessGET /metrics— Return Prometheus text metrics with aggregate counters/gauges only; labels and values intentionally avoid phone numbers, SMS bodies, USSD payloads, SIM identifiers, API keys, and raw modem identifiers
If create_app(..., api_keys=[...]) is configured, HTTP requests must include an Authorization header containing the configured bearer token, and requests are rate-limited per key. Do not expose the HTTP server beyond localhost without API keys or an equivalent trusted network boundary; deployment-safe auth defaults remain tracked separately in issue #4.
GET /ws uses the same bearer-token protection as the HTTP endpoints when API keys are configured. It is intended for PII-safe typed events, not raw AT/modem traffic, raw SMS body streaming, raw USSD responses, or durable replay.
The connection starts with a public-safe hello envelope that lists supported event types and the names selected for that socket. With no events query, the selected list defaults to every supported event:
{"type": "hello", "version": 1, "events": ["sms.received", "sms.delivery_report", "sms.sent", "call.state", "call.ring", "call.caller_id", "call.dtmf", "modem.state", "signal.quality", "ussd.response"], "selected_events": ["sms.received", "sms.delivery_report", "sms.sent", "call.state", "call.ring", "call.caller_id", "call.dtmf", "modem.state", "signal.quality", "ussd.response"]}Focused integrations can request a comma-separated subset, for example GET /ws?events=sms.received,sms.delivery_report. Unsupported names fail closed before subscription, and valid filters are normalized by trimming whitespace, dropping empty segments, and de-duplicating names in request order.
Representative event envelopes redact private payloads while preserving useful metadata for dashboards and integrations:
{"type": "sms.received", "timestamp": "2026-01-01T00:00:00Z", "data": {"sender": "+***0100", "body": "[redacted]", "body_length": 23}}Use a local WebSocket client that can set headers, and read the bearer token into a shell variable without printing it before connecting. The implementation does not publish raw AT lines, full phone numbers, SIM identifiers, modem serials, SMS bodies, USSD text, or API keys.
The package exposes a callstack command for local Raspberry Pi workflows:
callstack status --json
callstack send --to 5551234 --body "Hello from Callstack"
callstack doctor --ports /dev/ttyUSB2,/dev/ttyUSB3 --json
callstack doctor --scan --patterns '/dev/ttyUSB*,/dev/ttyACM*' --json
callstack monitor --events sms.received,sms.delivery_report --json
callstack serve --host 127.0.0.1 --port 8080 --api-key-file /etc/callstack/api-keyscallstack statusconnects to the configured modem and prints registration, operator, and signal details.callstack sendsends one SMS through the configured modem and prints only the modem reference.callstack doctoris the safest first hardware bring-up command. By default it probes only the configured modem port; pass--portsfor explicit candidates, or opt in to--scan --patterns ...when you want Callstack to enumerate matching serial devices. Every doctor mode uses only non-mutating identity/attention commands and avoids SMS, USSD, call, SIM unlock, storage, IMEI, IMSI, ICCID, or SIM-number commands. The output includes a config preview forCALLSTACK_AT_PORTandCALLSTACK_AUDIO_PORT; audio-port detection remains conservative and may stayunknownuntil you configure it explicitly.callstack monitortails selected typed events as sanitized human text or one JSON object per event. It uses PII-safe event serializers by default and reports queue overflow without printing phone numbers, SMS bodies, USSD payloads, webhook URLs, SIM identifiers, API keys, modem serials, or raw AT lines.callstack serveruns the packaged HTTP server with API-key file loading, loopback-only development override,CALLSTACK_HTTP_HOST/CALLSTACK_HTTP_PORT, and the redacted modem/SMS-store config flags shared by the other CLI commands.
Planned CLI follow-ups include richer environment/config helpers for server and CLI deployments, production deployment examples beyond the minimal smoke checks above, and conservative audio-port assignment once hardware profiles provide enough evidence.
Voice-call DTMF sends use AT+VTS; CallSession.send_dtmf(..., duration_ms=...) encodes non-zero tone durations in 100 ms increments (for example, 300 ms becomes an AT+VTS duration of 3). Use inter_digit_delay_ms separately when a modem or remote IVR needs spacing between tones.
┌─────────────────────────────────────────────────────────┐
│ Application Layer │
│ User code, IVR scripts, webhook integrations │
├─────────────────────────────────────────────────────────┤
│ Service Layer │
│ CallService │ SMSService │ NetworkService │
├─────────────────────────────────────────────────────────┤
│ Protocol Layer │
│ ATCommandExecutor │ ATResponseParser │ URC │
├─────────────────────────────────────────────────────────┤
│ Transport Layer │
│ SerialTransport │ MockTransport (asyncio streams) │
├─────────────────────────────────────────────────────────┤
│ Hardware / OS │
│ /dev/ttyUSB2 │ /dev/ttyUSB4 │ USB modeswitch │
└─────────────────────────────────────────────────────────┘
from callstack import Modem, ModemConfig
config = ModemConfig(
at_port="/dev/ttyUSB2", # AT command port
audio_port="/dev/ttyUSB4", # PCM audio port (voice calls)
baudrate=115200,
command_timeout=5.0, # Base AT command timeout
sms_prompt_timeout=10.0, # Wait for AT+CMGS ">" prompt
sms_submit_timeout=30.0, # Wait for carrier +CMGS/OK after body
sim_pin=None, # Optional: unlock SIMs that boot PIN-locked
auto_reconnect=True,
reconnect_interval=5.0,
)
async with Modem(config) as modem:
...pytest tests/Mock transport included for testing without hardware:
from callstack.transport.mock import MockTransportFor external services that need to consume SMS (like MFA automation):
import requests
import re
class CallstackSMSClient:
def __init__(self, api_url: str):
self.api_url = api_url
def wait_for_code(self, timeout: int = 60) -> str | None:
"""Poll for 6-8 digit passcode."""
# Implementation: GET /sms/messages, extract \d{6,8}
...- ARCHITECTURE.md — Deep dive into design patterns
- server.py — HTTP API reference implementation
Start with the safe doctor probe before checking live status. It only sends
non-mutating identity/attention commands (AT, ATI, AT+GMI, AT+GMM,
AT+GMR) and does not send SMS, USSD, call, SIM unlock, storage, IMEI, IMSI,
ICCID, or SIM-number commands. Default callstack doctor checks the configured
AT port; --ports checks only the explicit candidates you provide; --scan is
an opt-in host scan over the supplied glob patterns.
callstack doctor
callstack doctor --ports /dev/ttyUSB2,/dev/ttyUSB3
callstack doctor --scan --patterns '/dev/ttyUSB*,/dev/ttyACM*'
callstack doctor --ports /dev/ttyUSB2 --jsonReview the reported AT port, confidence, manufacturer/model, capabilities, and
config preview before running callstack status. Treat identity output such as
ATI as potentially PII-bearing in support logs; redact serial-like values
before sharing transcripts.
- Check USB ports:
ls /dev/ttyUSB* - Verify dialout group:
groups $USER - Try minicom:
minicom -D /dev/ttyUSB2
sudo usermod -a -G dialout $USER
# Log out and back inMIT License — see LICENSE file.
Built for automating the annoying parts of academic life. If this saves you from manually entering Duo codes 50 times, it was worth it.
Made with ❤️ by Justinabox