Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

147 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Callstack 🎙️📡

Async-first GSM/LTE modem telephony framework for Raspberry Pi.

Python 3.11+ License: MIT

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.


✨ Features

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

🚀 Quick Start

Hardware Requirements

  • 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/ttyUSB2 and /dev/ttyUSB4)

Installation

git clone https://github.com/Justinabox/Callstack.git
cd Callstack
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[sqlite]"

Basic Usage

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())

HTTP Server Mode

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

For legacy source-tree workflows, python server.py remains a compatibility wrapper.

Deployment-safe Raspberry Pi server example

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

Use 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/metrics

For 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-keys

The 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": "..."}); to must 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 SMS
  • GET /sms/messages — List received messages
  • GET /sms/delivery-reports — List delivery status reports
  • POST /ussd/send — Send USSD short codes ({"code": "*123#"})
  • GET /ws — authenticated WebSocket realtime feed for PII-safe typed events
  • GET /healthz — Return a public-safe readiness payload with modem connectivity, uptime, and SMS-store readiness
  • GET /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.

WebSocket realtime feed

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.

CLI

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-keys
  • callstack status connects to the configured modem and prints registration, operator, and signal details.
  • callstack send sends one SMS through the configured modem and prints only the modem reference.
  • callstack doctor is the safest first hardware bring-up command. By default it probes only the configured modem port; pass --ports for 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 for CALLSTACK_AT_PORT and CALLSTACK_AUDIO_PORT; audio-port detection remains conservative and may stay unknown until you configure it explicitly.
  • callstack monitor tails 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 serve runs 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.


🏗️ Architecture

┌─────────────────────────────────────────────────────────┐
│                    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      │
└─────────────────────────────────────────────────────────┘

🛠️ Configuration

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

🧪 Testing

pytest tests/

Mock transport included for testing without hardware:

from callstack.transport.mock import MockTransport

🤝 Integration Pattern: HTTP API Polling

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

📚 Documentation


🔧 Troubleshooting

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

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

Modem not responding

  • Check USB ports: ls /dev/ttyUSB*
  • Verify dialout group: groups $USER
  • Try minicom: minicom -D /dev/ttyUSB2

Port permissions

sudo usermod -a -G dialout $USER
# Log out and back in

📜 License

MIT License — see LICENSE file.


🙏 Acknowledgments

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

About

Async-first GSM/LTE modem telephony framework for Raspberry Pi.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages