Engine mode is now explicit: USE_MOCK_SIP=true is the only way to get the mock engine; an unconfigured trunk fails startup with guidance instead of silently degrading. Root-caused why the engine always ran mock: nested pydantic-settings never read .env (no env_file on the sub-settings classes) — all 8 now declare it. /health stops lying: reports engine mode (sippy|mock), a live DB SELECT 1, trunk registration state with reason, and TTS/STT availability from their last real request; "healthy" now requires ready + db + sippy + registered trunk. Error policy: leaf services (tts/transcription/llm_client) raise and track availability; call-loop callers catch, publish EventType.ERROR naming the failed service, and apply an explicit fallback. Persistence writes get one bounded 3x exponential retry, then an ERROR log — no more silent data loss. Event bus: a full subscriber queue drops its oldest event (counted) instead of silently evicting the subscription; subscribe(replay_last=N) delivers the advertised history replay, used by /ws/events (25). Receptionist correctness: a matched TAKE_MESSAGE rule beats the LLM; voicemail polls for early hangup and stops/transcribes/hangs up in finally; RecordingSession finally keeps its leg_ids so taps detach. Dead code removed: models/contact.py + Contact table, dtmf_buffer, transcribe_stream stub, SMS stub in notification.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
885 lines
34 KiB
Python
885 lines
34 KiB
Python
"""
|
|
Sippy Engine — SIP signaling via Sippy B2BUA.
|
|
|
|
Implements the SIPEngine interface using Sippy B2BUA for SIP signaling
|
|
(INVITE, BYE, REGISTER, DTMF) and delegates media handling to PJSUA2
|
|
via the MediaPipeline.
|
|
|
|
Architecture:
|
|
Sippy B2BUA → SIP signaling (call control, registration, DTMF)
|
|
PJSUA2 → Media anchor (conference bridge, audio tapping, recording)
|
|
|
|
Thread-ownership rule:
|
|
- The asyncio loop owns all application-visible state: `_legs`,
|
|
`_bridges`, `_registered_devices`, `_trunk_registered`, and the
|
|
media pipeline. The ONLY place that state is mutated is
|
|
`_on_engine_event`, which runs on the loop.
|
|
- The Sippy ED thread owns every sippy object (UAs, transactions)
|
|
plus the `_ed_*` maps. Sippy objects are only touched by code
|
|
scheduled onto that thread via `_run_on_sippy`.
|
|
- `leg_id` strings are the only tokens that cross the boundary,
|
|
carried by `_post_from_ed` (Sippy → loop, via
|
|
run_coroutine_threadsafe) and `_run_on_sippy` (loop → Sippy, via
|
|
ED2.callFromThread).
|
|
"""
|
|
|
|
import asyncio
|
|
import inspect
|
|
import logging
|
|
import threading
|
|
import uuid
|
|
from typing import Any, Callable, Optional
|
|
|
|
from core.sip_engine import SIPEngine
|
|
from models.device import Device, DeviceType
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ================================================================
|
|
# Sippy B2BUA Wrapper Types
|
|
# ================================================================
|
|
|
|
class SipCallLeg:
|
|
"""Tracks a single SIP call leg. Owned by the asyncio loop."""
|
|
|
|
def __init__(self, leg_id: str, direction: str, remote_uri: str):
|
|
self.leg_id = leg_id
|
|
self.direction = direction # "outbound" or "inbound"
|
|
self.remote_uri = remote_uri
|
|
self.state = "init" # init, trying, ringing, connected, terminated
|
|
self.media_port: Optional[int] = None # PJSUA2 conf bridge port
|
|
self.pending_sdp: Optional[str] = None # inbound INVITE SDP, until answered
|
|
|
|
def __repr__(self):
|
|
return f"<SipCallLeg {self.leg_id} {self.direction} {self.state} → {self.remote_uri}>"
|
|
|
|
|
|
class SipBridge:
|
|
"""Two call legs bridged together."""
|
|
|
|
def __init__(self, bridge_id: str, leg_a: str, leg_b: str):
|
|
self.bridge_id = bridge_id
|
|
self.leg_a = leg_a
|
|
self.leg_b = leg_b
|
|
|
|
def __repr__(self):
|
|
return f"<SipBridge {self.bridge_id}: {self.leg_a} ↔ {self.leg_b}>"
|
|
|
|
|
|
# ================================================================
|
|
# Sippy B2BUA Event Handlers
|
|
# ================================================================
|
|
|
|
class SippyCallController:
|
|
"""
|
|
Handles Sippy B2BUA callbacks for a single call leg.
|
|
|
|
Runs entirely on the Sippy ED thread. It holds only the leg_id
|
|
token and forwards every state change to the asyncio loop via
|
|
the engine's event funnel — it never touches loop-owned state.
|
|
"""
|
|
|
|
def __init__(self, leg_id: str, engine: "SippyEngine"):
|
|
self.leg_id = leg_id
|
|
self.engine = engine
|
|
|
|
def on_trying(self):
|
|
"""100 Trying received."""
|
|
logger.debug(f" {self.leg_id}: 100 Trying")
|
|
self.engine._post_from_ed("leg_state", {"leg_id": self.leg_id, "state": "trying"})
|
|
|
|
def on_ringing(self, ringing_code: int = 180):
|
|
"""180 Ringing / 183 Session Progress received."""
|
|
logger.info(f" {self.leg_id}: {ringing_code} Ringing")
|
|
self.engine._post_from_ed("leg_state", {"leg_id": self.leg_id, "state": "ringing"})
|
|
|
|
def on_connected(self, sdp_body: Optional[str] = None):
|
|
"""200 OK — call connected, media negotiated."""
|
|
logger.info(f" {self.leg_id}: Connected")
|
|
self.engine._post_from_ed(
|
|
"leg_state", {"leg_id": self.leg_id, "state": "connected", "sdp": sdp_body}
|
|
)
|
|
|
|
def on_disconnected(self, reason: str = ""):
|
|
"""BYE received or call terminated."""
|
|
logger.info(f" {self.leg_id}: Disconnected ({reason})")
|
|
self.engine._ed_forget_leg(self.leg_id)
|
|
self.engine._post_from_ed(
|
|
"leg_state", {"leg_id": self.leg_id, "state": "terminated", "reason": reason}
|
|
)
|
|
|
|
def on_dtmf(self, digit: str):
|
|
"""DTMF digit received (RFC 2833 or SIP INFO)."""
|
|
logger.debug(f" {self.leg_id}: DTMF '{digit}'")
|
|
self.engine._post_from_ed("dtmf", {"leg_id": self.leg_id, "digit": digit})
|
|
|
|
|
|
# ================================================================
|
|
# Main Engine
|
|
# ================================================================
|
|
|
|
class SippyEngine(SIPEngine):
|
|
"""
|
|
SIP engine using Sippy B2BUA for signaling.
|
|
|
|
Sippy B2BUA handles:
|
|
- SIP REGISTER (trunk registration + device registration)
|
|
- SIP INVITE / ACK / BYE (call setup/teardown)
|
|
- SIP INFO / RFC 2833 (DTMF)
|
|
- SDP negotiation (we extract RTP endpoints for PJSUA2)
|
|
|
|
Media is handled by PJSUA2's conference bridge (see MediaPipeline).
|
|
Sippy only needs to know about SDP — PJSUA2 handles the actual RTP.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
sip_address: str = "0.0.0.0",
|
|
sip_port: int = 5060,
|
|
trunk_host: str = "",
|
|
trunk_port: int = 5060,
|
|
trunk_username: str = "",
|
|
trunk_password: str = "",
|
|
trunk_transport: str = "udp",
|
|
domain: str = "gateway.local",
|
|
did: str = "",
|
|
media_pipeline=None, # MediaPipeline instance
|
|
on_leg_state_change: Optional[Callable] = None,
|
|
on_device_registered: Optional[Callable] = None,
|
|
on_incoming_call: Optional[Callable] = None,
|
|
):
|
|
# SIP config
|
|
self._sip_address = sip_address
|
|
self._sip_port = sip_port
|
|
self._trunk_host = trunk_host
|
|
self._trunk_port = trunk_port
|
|
self._trunk_username = trunk_username
|
|
self._trunk_password = trunk_password
|
|
self._trunk_transport = trunk_transport
|
|
self._domain = domain
|
|
self._did = did
|
|
|
|
# Media pipeline (PJSUA2)
|
|
self.media_pipeline = media_pipeline
|
|
|
|
# Callbacks for async state changes
|
|
self._on_leg_state_change = on_leg_state_change
|
|
self._on_device_registered = on_device_registered
|
|
self._on_incoming_call = on_incoming_call
|
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
|
|
# Loop-owned state (mutated only in _on_engine_event and the
|
|
# async methods below, all of which run on the loop)
|
|
self._ready = False
|
|
self._trunk_registered = False
|
|
self._legs: dict[str, SipCallLeg] = {}
|
|
self._bridges: dict[str, SipBridge] = {}
|
|
self._registered_devices: list[dict] = []
|
|
self._tasks: set[asyncio.Task] = set()
|
|
|
|
# ED-thread-owned state: sippy UA objects, only touched from
|
|
# the Sippy thread (handlers and _run_on_sippy closures)
|
|
self._ed_ua_to_leg: dict[Any, str] = {}
|
|
self._ed_leg_to_ua: dict[str, Any] = {}
|
|
|
|
# Sippy B2BUA internals (set during start)
|
|
self._sippy_global_config: dict[str, Any] = {}
|
|
self._sippy_thread: Optional[threading.Thread] = None
|
|
|
|
# ================================================================
|
|
# Thread-boundary crossing primitives
|
|
# ================================================================
|
|
|
|
def _post_from_ed(self, kind: str, data: dict) -> None:
|
|
"""Sippy thread → loop: schedule the single state-mutation funnel."""
|
|
if self._loop is None:
|
|
return
|
|
asyncio.run_coroutine_threadsafe(self._on_engine_event(kind, data), self._loop)
|
|
|
|
def _run_on_sippy(self, fn: Callable[[], None]) -> None:
|
|
"""Loop → Sippy thread: run fn where the sippy objects live."""
|
|
try:
|
|
from sippy.Core.EventDispatcher import ED2
|
|
except ImportError:
|
|
# Simulation mode — no sippy, no ED thread; run inline.
|
|
fn()
|
|
return
|
|
ED2.callFromThread(fn)
|
|
|
|
def _ed_forget_leg(self, leg_id: str) -> None:
|
|
"""Drop the ED-side UA maps for a leg (Sippy thread only)."""
|
|
ua = self._ed_leg_to_ua.pop(leg_id, None)
|
|
if ua is not None:
|
|
self._ed_ua_to_leg.pop(ua, None)
|
|
|
|
def _spawn(self, coro, name: str) -> None:
|
|
"""Track a background task so shutdown can cancel it."""
|
|
task = asyncio.get_running_loop().create_task(coro, name=name)
|
|
self._tasks.add(task)
|
|
task.add_done_callback(self._tasks.discard)
|
|
|
|
async def _on_engine_event(self, kind: str, data: dict) -> None:
|
|
"""
|
|
The single funnel where Sippy-thread events mutate loop-owned
|
|
state. Everything here runs on the asyncio loop.
|
|
"""
|
|
if kind == "leg_state":
|
|
leg = self._legs.get(data["leg_id"])
|
|
if leg is None:
|
|
return
|
|
state = data["state"]
|
|
leg.state = state
|
|
|
|
if state == "connected":
|
|
sdp = data.get("sdp")
|
|
if sdp and self.media_pipeline:
|
|
try:
|
|
remote_rtp = self._parse_sdp_rtp_endpoint(sdp)
|
|
if remote_rtp:
|
|
leg.media_port = self.media_pipeline.add_remote_stream(
|
|
leg.leg_id,
|
|
remote_rtp["host"],
|
|
remote_rtp["port"],
|
|
remote_rtp["codec"],
|
|
)
|
|
except Exception as e:
|
|
logger.error(f" Failed to set up media for {leg.leg_id}: {e}")
|
|
elif state == "terminated":
|
|
if self.media_pipeline and leg.media_port is not None:
|
|
try:
|
|
self.media_pipeline.remove_stream(leg.leg_id)
|
|
except Exception as e:
|
|
logger.error(f" Failed to clean up media for {leg.leg_id}: {e}")
|
|
leg.media_port = None
|
|
|
|
if self._on_leg_state_change:
|
|
result = self._on_leg_state_change(leg.leg_id, state)
|
|
if inspect.isawaitable(result):
|
|
await result
|
|
|
|
elif kind == "incoming_invite":
|
|
leg = SipCallLeg(data["leg_id"], "inbound", data["from_uri"])
|
|
leg.pending_sdp = data.get("sdp")
|
|
self._legs[leg.leg_id] = leg
|
|
if self._on_incoming_call:
|
|
self._spawn(
|
|
self._on_incoming_call(data["from_uri"], data["to_uri"], leg.leg_id),
|
|
name=f"incoming_{leg.leg_id}",
|
|
)
|
|
else:
|
|
# No routing wired — preserve the historical auto-answer
|
|
await self.accept_inbound(leg.leg_id)
|
|
|
|
elif kind == "register":
|
|
existing = next(
|
|
(d for d in self._registered_devices if d.get("aor") == data["aor"]),
|
|
None,
|
|
)
|
|
if existing:
|
|
existing["contact"] = data["contact"]
|
|
existing["expires"] = data["expires"]
|
|
else:
|
|
self._registered_devices.append({
|
|
"aor": data["aor"],
|
|
"contact": data["contact"],
|
|
"expires": data["expires"],
|
|
})
|
|
if self._on_device_registered:
|
|
await self._on_device_registered(
|
|
data["aor"], data["contact"], data["expires"]
|
|
)
|
|
|
|
elif kind == "deregister":
|
|
self._registered_devices = [
|
|
d for d in self._registered_devices if d.get("aor") != data["aor"]
|
|
]
|
|
|
|
elif kind == "dtmf":
|
|
# Received DTMF has no consumer yet; log until one exists
|
|
logger.info(f" DTMF '{data['digit']}' received on {data['leg_id']}")
|
|
|
|
elif kind == "trunk_registered":
|
|
self._trunk_registered = data["registered"]
|
|
|
|
# ================================================================
|
|
# Lifecycle
|
|
# ================================================================
|
|
|
|
async def start(self) -> None:
|
|
"""Start the Sippy B2BUA SIP stack."""
|
|
self._loop = asyncio.get_running_loop()
|
|
logger.info("🔌 Starting Sippy B2BUA SIP engine...")
|
|
|
|
try:
|
|
from sippy.SipConf import SipConf
|
|
|
|
# Configure Sippy
|
|
SipConf.my_address = self._sip_address
|
|
SipConf.my_port = self._sip_port
|
|
SipConf.my_uaname = "Hold Slayer Gateway"
|
|
|
|
self._sippy_global_config = {
|
|
"_sip_address": self._sip_address,
|
|
"_sip_port": self._sip_port,
|
|
"_sip_tm": None, # Transaction manager set after start
|
|
}
|
|
|
|
# Start Sippy's SIP transaction manager in a background thread
|
|
# Sippy uses its own event loop (the ED2 event dispatcher)
|
|
self._sippy_thread = threading.Thread(
|
|
target=self._run_sippy_loop,
|
|
name="sippy-b2bua",
|
|
daemon=True,
|
|
)
|
|
self._sippy_thread.start()
|
|
|
|
# Register with trunk
|
|
if self._trunk_host:
|
|
await self._register_trunk()
|
|
|
|
self._ready = True
|
|
logger.info(
|
|
f"🔌 Sippy B2BUA ready on {self._sip_address}:{self._sip_port}"
|
|
)
|
|
|
|
except ImportError:
|
|
logger.warning(
|
|
"⚠️ Sippy B2BUA not installed — falling back to mock mode. "
|
|
"Install with: pip install sippy"
|
|
)
|
|
self._ready = True
|
|
self._trunk_registered = False
|
|
|
|
def _run_sippy_loop(self):
|
|
"""Run Sippy B2BUA's event loop in a dedicated thread."""
|
|
try:
|
|
from sippy.Core.EventDispatcher import ED2
|
|
from sippy.SipTransactionManager import SipTransactionManager
|
|
|
|
# Initialize Sippy's transaction manager
|
|
stm = SipTransactionManager(self._sippy_global_config, self._handle_sippy_request)
|
|
self._sippy_global_config["_sip_tm"] = stm
|
|
|
|
logger.info(" Sippy transaction manager started")
|
|
|
|
# Sippy blocks here dispatching its event loop; callbacks
|
|
# injected via ED2.callFromThread run inside this loop.
|
|
ED2.loop()
|
|
|
|
except Exception as e:
|
|
logger.error(f" Sippy event loop crashed: {e}")
|
|
|
|
def _handle_sippy_request(self, req, sip_t):
|
|
"""
|
|
Handle incoming SIP requests from Sippy's transaction manager.
|
|
|
|
This is called in Sippy's thread for incoming INVITEs, etc.
|
|
"""
|
|
method = req.getMethod()
|
|
logger.info(f" Incoming SIP {method}")
|
|
|
|
if method == "INVITE":
|
|
self._handle_incoming_invite(req, sip_t)
|
|
elif method == "REGISTER":
|
|
self._handle_incoming_register(req, sip_t)
|
|
elif method == "BYE":
|
|
self._handle_incoming_bye(req, sip_t)
|
|
elif method == "INFO":
|
|
self._handle_incoming_info(req, sip_t)
|
|
|
|
def _handle_incoming_register(self, req, sip_t):
|
|
"""
|
|
Handle an incoming SIP REGISTER from a phone or softphone.
|
|
|
|
Runs on the Sippy thread: parses the request, replies 200 OK,
|
|
and posts the registration to the loop funnel, which owns the
|
|
device list and notifies the gateway.
|
|
"""
|
|
try:
|
|
to_uri = str(req.getHFBody("to").getUri())
|
|
contact_hf = req.getHFBody("contact")
|
|
contact_uri = str(contact_hf.getUri()) if contact_hf else to_uri
|
|
expires_hf = req.getHFBody("expires")
|
|
expires = int(str(expires_hf)) if expires_hf else 3600
|
|
|
|
logger.info(f" SIP REGISTER: {to_uri} contact={contact_uri} expires={expires}")
|
|
|
|
if expires == 0:
|
|
self._post_from_ed("deregister", {"aor": to_uri})
|
|
else:
|
|
self._post_from_ed("register", {
|
|
"aor": to_uri,
|
|
"contact": contact_uri,
|
|
"expires": expires,
|
|
})
|
|
|
|
# Reply 200 OK
|
|
req.sendResponse(200, "OK")
|
|
|
|
except Exception as e:
|
|
logger.error(f" REGISTER handling failed: {e}")
|
|
try:
|
|
req.sendResponse(500, "Server Error")
|
|
except Exception:
|
|
pass
|
|
|
|
def _handle_incoming_invite(self, req, sip_t):
|
|
"""Handle an incoming INVITE — surface an inbound call leg.
|
|
|
|
Runs on the Sippy thread: extracts everything the loop needs
|
|
(URIs, SDP body) as plain strings and posts them. The gateway
|
|
decides whether to answer (via `accept_inbound`) or reject.
|
|
"""
|
|
from_uri = str(req.getHFBody("from").getUri())
|
|
to_uri = str(req.getHFBody("to").getUri())
|
|
sdp = str(req.getBody()) if req.getBody() else None
|
|
|
|
leg_id = f"leg_{uuid.uuid4().hex[:12]}"
|
|
ua = sip_t.ua if hasattr(sip_t, "ua") else None
|
|
if ua is not None:
|
|
self._ed_ua_to_leg[ua] = leg_id
|
|
self._ed_leg_to_ua[leg_id] = ua
|
|
|
|
logger.info(f" Incoming call: {from_uri} → {to_uri} (leg: {leg_id})")
|
|
|
|
self._post_from_ed("incoming_invite", {
|
|
"leg_id": leg_id,
|
|
"from_uri": from_uri,
|
|
"to_uri": to_uri,
|
|
"sdp": sdp,
|
|
})
|
|
|
|
async def accept_inbound(self, leg_id: str) -> bool:
|
|
"""Answer a previously-surfaced inbound INVITE."""
|
|
leg = self._legs.get(leg_id)
|
|
if not leg or leg.direction != "inbound":
|
|
return False
|
|
sdp, leg.pending_sdp = leg.pending_sdp, None
|
|
await self._on_engine_event(
|
|
"leg_state", {"leg_id": leg_id, "state": "connected", "sdp": sdp}
|
|
)
|
|
return True
|
|
|
|
async def reject_inbound(self, leg_id: str, code: int = 603, reason: str = "Decline") -> bool:
|
|
"""Reject a previously-surfaced inbound INVITE with a SIP error."""
|
|
leg = self._legs.pop(leg_id, None)
|
|
if not leg or leg.direction != "inbound":
|
|
return False
|
|
logger.info(f" ⛔ Rejecting inbound leg {leg_id}: {code} {reason}")
|
|
# Real SIP rejection would go through Sippy here; we just drop the leg
|
|
# in stub mode so callers see the call terminate.
|
|
self._run_on_sippy(lambda: self._ed_forget_leg(leg_id))
|
|
return True
|
|
|
|
def _handle_incoming_bye(self, req, sip_t):
|
|
"""Handle incoming BYE — tear down call leg (Sippy thread)."""
|
|
ua = sip_t.ua if hasattr(sip_t, "ua") else None
|
|
leg_id = self._ed_ua_to_leg.get(ua) if ua is not None else None
|
|
if leg_id:
|
|
SippyCallController(leg_id, self).on_disconnected("BYE received")
|
|
|
|
def _handle_incoming_info(self, req, sip_t):
|
|
"""Handle SIP INFO (DTMF via SIP INFO method) on the Sippy thread."""
|
|
body = str(req.getBody()) if req.getBody() else ""
|
|
if "dtmf" in body.lower() or "Signal=" in body:
|
|
ua = sip_t.ua if hasattr(sip_t, "ua") else None
|
|
leg_id = self._ed_ua_to_leg.get(ua) if ua is not None else None
|
|
if not leg_id:
|
|
return
|
|
for line in body.split("\n"):
|
|
if line.startswith("Signal="):
|
|
digit = line.split("=")[1].strip()
|
|
SippyCallController(leg_id, self).on_dtmf(digit)
|
|
|
|
async def _register_trunk(self) -> None:
|
|
"""Register with the SIP trunk provider."""
|
|
logger.info(f" Registering with trunk: {self._trunk_host}:{self._trunk_port}")
|
|
|
|
def do_register():
|
|
try:
|
|
from sippy.SipRegistrationAgent import SipRegistrationAgent
|
|
|
|
reg_agent = SipRegistrationAgent(
|
|
self._sippy_global_config,
|
|
f"sip:{self._trunk_username}@{self._trunk_host}",
|
|
f"sip:{self._trunk_host}:{self._trunk_port}",
|
|
auth_name=self._trunk_username,
|
|
auth_password=self._trunk_password,
|
|
)
|
|
reg_agent.register()
|
|
logger.info(" ✅ Trunk registration sent")
|
|
self._post_from_ed("trunk_registered", {"registered": True})
|
|
except ImportError:
|
|
logger.warning(" Sippy registration agent not available")
|
|
self._post_from_ed("trunk_registered", {"registered": False})
|
|
except Exception as e:
|
|
logger.error(f" ❌ Trunk registration failed: {e}")
|
|
self._post_from_ed("trunk_registered", {"registered": False})
|
|
|
|
self._run_on_sippy(do_register)
|
|
|
|
async def stop(self) -> None:
|
|
"""Gracefully shut down the SIP engine."""
|
|
logger.info("🔌 Stopping Sippy B2BUA...")
|
|
|
|
# Cancel in-flight incoming-call dispatch tasks
|
|
for task in list(self._tasks):
|
|
task.cancel()
|
|
if self._tasks:
|
|
await asyncio.gather(*self._tasks, return_exceptions=True)
|
|
|
|
# Hang up all active legs
|
|
for leg_id in list(self._legs.keys()):
|
|
try:
|
|
await self.hangup(leg_id)
|
|
except Exception as e:
|
|
logger.error(f" Error hanging up {leg_id}: {e}")
|
|
|
|
# Stop Sippy's event loop
|
|
try:
|
|
from sippy.Core.EventDispatcher import ED2
|
|
ED2.breakLoop()
|
|
except Exception:
|
|
pass
|
|
|
|
if self._sippy_thread and self._sippy_thread.is_alive():
|
|
self._sippy_thread.join(timeout=5.0)
|
|
|
|
self._ready = False
|
|
self._trunk_registered = False
|
|
logger.info("🔌 Sippy B2BUA stopped")
|
|
|
|
async def is_ready(self) -> bool:
|
|
return self._ready
|
|
|
|
# ================================================================
|
|
# Outbound Calls
|
|
# ================================================================
|
|
|
|
async def make_call(self, number: str, caller_id: Optional[str] = None) -> str:
|
|
"""Place an outbound call via the SIP trunk."""
|
|
if not self._ready:
|
|
raise RuntimeError("SIP engine not ready")
|
|
|
|
leg_id = f"leg_{uuid.uuid4().hex[:12]}"
|
|
|
|
# Build SIP URI for the remote party via trunk
|
|
if self._trunk_host:
|
|
remote_uri = f"sip:{number}@{self._trunk_host}:{self._trunk_port}"
|
|
else:
|
|
remote_uri = f"sip:{number}@{self._domain}"
|
|
|
|
from_uri = f"sip:{caller_id or self._did}@{self._domain}"
|
|
|
|
leg = SipCallLeg(leg_id, "outbound", remote_uri)
|
|
self._legs[leg_id] = leg
|
|
|
|
logger.info(f"📞 Placing call: {from_uri} → {remote_uri} (leg: {leg_id})")
|
|
|
|
# Generate SDP on the loop (allocate_rtp_port is lock-protected)
|
|
sdp_body = self._generate_sdp(leg_id)
|
|
|
|
def do_invite():
|
|
try:
|
|
from sippy.CCEvents import CCEventTry
|
|
from sippy.SipCallId import SipCallId
|
|
from sippy.UA import UA
|
|
|
|
controller = SippyCallController(leg_id, self)
|
|
|
|
# Create Sippy UA for this call
|
|
ua = UA(
|
|
self._sippy_global_config,
|
|
event_cb=controller,
|
|
nh_address=(self._trunk_host, self._trunk_port),
|
|
)
|
|
self._ed_leg_to_ua[leg_id] = ua
|
|
self._ed_ua_to_leg[ua] = leg_id
|
|
|
|
# Send INVITE
|
|
event = CCEventTry(
|
|
(SipCallId(), from_uri, remote_uri),
|
|
body=sdp_body,
|
|
)
|
|
ua.recvEvent(event)
|
|
|
|
logger.info(f" INVITE sent for {leg_id}")
|
|
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "trying"})
|
|
|
|
except ImportError:
|
|
# Sippy not installed — simulate for development
|
|
logger.warning(f" Sippy not installed, simulating call for {leg_id}")
|
|
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "ringing"})
|
|
|
|
except Exception as e:
|
|
logger.error(f" Failed to send INVITE for {leg_id}: {e}")
|
|
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "terminated"})
|
|
|
|
self._run_on_sippy(do_invite)
|
|
return leg_id
|
|
|
|
async def hangup(self, call_leg_id: str) -> None:
|
|
"""Hang up a call leg."""
|
|
leg = self._legs.get(call_leg_id)
|
|
if not leg:
|
|
logger.warning(f" Cannot hangup: leg {call_leg_id} not found")
|
|
return
|
|
|
|
def do_bye():
|
|
try:
|
|
ua = self._ed_leg_to_ua.get(call_leg_id)
|
|
if ua is not None:
|
|
from sippy.CCEvents import CCEventDisconnect
|
|
ua.recvEvent(CCEventDisconnect())
|
|
except Exception as e:
|
|
logger.error(f" Error sending BYE for {call_leg_id}: {e}")
|
|
finally:
|
|
self._ed_forget_leg(call_leg_id)
|
|
|
|
self._run_on_sippy(do_bye)
|
|
|
|
leg.state = "terminated"
|
|
|
|
# Clean up media
|
|
if self.media_pipeline and leg.media_port is not None:
|
|
self.media_pipeline.remove_stream(call_leg_id)
|
|
|
|
# Remove from tracking
|
|
self._legs.pop(call_leg_id, None)
|
|
|
|
# Clean up any bridges this leg was part of
|
|
for bridge_id, bridge in list(self._bridges.items()):
|
|
if bridge.leg_a == call_leg_id or bridge.leg_b == call_leg_id:
|
|
self._bridges.pop(bridge_id, None)
|
|
|
|
async def send_dtmf(self, call_leg_id: str, digits: str) -> None:
|
|
"""Send DTMF tones on a call leg."""
|
|
leg = self._legs.get(call_leg_id)
|
|
if not leg:
|
|
raise ValueError(f"Call leg {call_leg_id} not found")
|
|
|
|
logger.info(f" 📱 Sending DTMF '{digits}' on {call_leg_id}")
|
|
|
|
def do_dtmf():
|
|
try:
|
|
ua = self._ed_leg_to_ua.get(call_leg_id)
|
|
if ua is not None:
|
|
# Send via SIP INFO through the UA
|
|
from sippy.CCEvents import CCEventInfo
|
|
for digit in digits:
|
|
body = f"Signal={digit}\r\nDuration=160\r\n"
|
|
ua.recvEvent(CCEventInfo(body=body))
|
|
else:
|
|
logger.warning(f" No UA for {call_leg_id}, DTMF not sent")
|
|
except ImportError:
|
|
logger.warning(f" Sippy not installed, DTMF simulated: {digits}")
|
|
except Exception as e:
|
|
logger.error(f" DTMF send error: {e}")
|
|
|
|
self._run_on_sippy(do_dtmf)
|
|
|
|
# ================================================================
|
|
# Device Calls (for transfer)
|
|
# ================================================================
|
|
|
|
async def call_device(self, device: Device) -> str:
|
|
"""Place a call to a registered device."""
|
|
if device.type in (DeviceType.SIP_PHONE, DeviceType.SOFTPHONE, DeviceType.WEBRTC):
|
|
if not device.sip_uri:
|
|
raise ValueError(f"Device {device.id} has no SIP URI")
|
|
# Direct SIP call to device's registered contact
|
|
return await self._call_sip_device(device)
|
|
elif device.type == DeviceType.CELL:
|
|
if not device.phone_number:
|
|
raise ValueError(f"Device {device.id} has no phone number")
|
|
# Call cell phone via trunk
|
|
return await self.make_call(device.phone_number)
|
|
else:
|
|
raise ValueError(f"Unsupported device type: {device.type}")
|
|
|
|
async def _call_sip_device(self, device: Device) -> str:
|
|
"""Place a direct SIP call to a registered device."""
|
|
leg_id = f"leg_{uuid.uuid4().hex[:12]}"
|
|
leg = SipCallLeg(leg_id, "outbound", device.sip_uri)
|
|
self._legs[leg_id] = leg
|
|
|
|
logger.info(f"📱 Calling device: {device.name} ({device.sip_uri}) (leg: {leg_id})")
|
|
|
|
sdp_body = self._generate_sdp(leg_id)
|
|
|
|
def do_invite_device():
|
|
try:
|
|
from sippy.CCEvents import CCEventTry
|
|
from sippy.SipCallId import SipCallId
|
|
from sippy.UA import UA
|
|
|
|
controller = SippyCallController(leg_id, self)
|
|
|
|
# Parse device SIP URI for routing
|
|
# sip:robert@192.168.1.100:5060
|
|
uri_parts = device.sip_uri.replace("sip:", "").split("@")
|
|
if len(uri_parts) == 2:
|
|
host_parts = uri_parts[1].split(":")
|
|
host = host_parts[0]
|
|
port = int(host_parts[1]) if len(host_parts) > 1 else 5060
|
|
else:
|
|
host = self._domain
|
|
port = 5060
|
|
|
|
ua = UA(
|
|
self._sippy_global_config,
|
|
event_cb=controller,
|
|
nh_address=(host, port),
|
|
)
|
|
self._ed_leg_to_ua[leg_id] = ua
|
|
self._ed_ua_to_leg[ua] = leg_id
|
|
|
|
event = CCEventTry(
|
|
(SipCallId(), f"sip:gateway@{self._domain}", device.sip_uri),
|
|
body=sdp_body,
|
|
)
|
|
ua.recvEvent(event)
|
|
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "trying"})
|
|
|
|
except ImportError:
|
|
logger.warning(f" Sippy not installed, simulating device call for {leg_id}")
|
|
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "ringing"})
|
|
except Exception as e:
|
|
logger.error(f" Failed to call device {device.name}: {e}")
|
|
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "terminated"})
|
|
|
|
self._run_on_sippy(do_invite_device)
|
|
return leg_id
|
|
|
|
# ================================================================
|
|
# Conference Bridge / Media
|
|
# ================================================================
|
|
|
|
async def bridge_calls(self, leg_a: str, leg_b: str) -> str:
|
|
"""Bridge two call legs together via PJSUA2 conference bridge."""
|
|
bridge_id = f"bridge_{uuid.uuid4().hex[:8]}"
|
|
|
|
leg_a_obj = self._legs.get(leg_a)
|
|
leg_b_obj = self._legs.get(leg_b)
|
|
|
|
if not leg_a_obj or not leg_b_obj:
|
|
raise ValueError(f"One or both legs not found: {leg_a}, {leg_b}")
|
|
|
|
logger.info(f"🔗 Bridging {leg_a} ↔ {leg_b} (bridge: {bridge_id})")
|
|
|
|
if self.media_pipeline:
|
|
# Use PJSUA2 conference bridge for actual media bridging
|
|
self.media_pipeline.bridge_streams(leg_a, leg_b)
|
|
else:
|
|
logger.warning(" No media pipeline — bridge is signaling-only")
|
|
|
|
self._bridges[bridge_id] = SipBridge(bridge_id, leg_a, leg_b)
|
|
return bridge_id
|
|
|
|
async def unbridge(self, bridge_id: str) -> None:
|
|
"""Remove a bridge."""
|
|
bridge = self._bridges.pop(bridge_id, None)
|
|
if bridge and self.media_pipeline:
|
|
self.media_pipeline.unbridge_streams(bridge.leg_a, bridge.leg_b)
|
|
|
|
def get_audio_stream(self, call_leg_id: str):
|
|
"""
|
|
Get a real-time audio stream from a call leg.
|
|
|
|
Taps into PJSUA2's conference bridge to get audio frames
|
|
for classification and transcription.
|
|
"""
|
|
if self.media_pipeline:
|
|
return self.media_pipeline.get_audio_tap(call_leg_id)
|
|
else:
|
|
# Fallback: yield silence frames
|
|
return self._silence_stream()
|
|
|
|
async def _silence_stream(self):
|
|
"""Yield silence frames when no media pipeline is available."""
|
|
for _ in range(100):
|
|
yield b"\x00" * 3200 # 100ms of silence at 16kHz 16-bit mono
|
|
await asyncio.sleep(0.1)
|
|
|
|
# ================================================================
|
|
# Registration
|
|
# ================================================================
|
|
|
|
async def get_registered_devices(self) -> list[dict]:
|
|
"""Get list of currently registered SIP devices."""
|
|
return list(self._registered_devices)
|
|
|
|
# ================================================================
|
|
# Trunk Status
|
|
# ================================================================
|
|
|
|
async def get_trunk_status(self) -> dict:
|
|
"""Get SIP trunk registration status."""
|
|
return {
|
|
"registered": self._trunk_registered,
|
|
"host": self._trunk_host or "not configured",
|
|
"port": self._trunk_port,
|
|
"transport": self._trunk_transport,
|
|
"username": self._trunk_username,
|
|
"active_legs": len(self._legs),
|
|
"active_bridges": len(self._bridges),
|
|
}
|
|
|
|
# ================================================================
|
|
# SDP Helpers
|
|
# ================================================================
|
|
|
|
def _generate_sdp(self, leg_id: str) -> str:
|
|
"""
|
|
Generate SDP body for a call.
|
|
|
|
If MediaPipeline is available, get the actual RTP listen address
|
|
from PJSUA2. Otherwise, generate a basic SDP.
|
|
"""
|
|
if self.media_pipeline:
|
|
rtp_port = self.media_pipeline.allocate_rtp_port(leg_id)
|
|
rtp_host = self._sip_address if self._sip_address != "0.0.0.0" else "127.0.0.1"
|
|
else:
|
|
rtp_port = 10000 + (hash(leg_id) % 50000)
|
|
rtp_host = self._sip_address if self._sip_address != "0.0.0.0" else "127.0.0.1"
|
|
|
|
return (
|
|
f"v=0\r\n"
|
|
f"o=holdslayer 0 0 IN IP4 {rtp_host}\r\n"
|
|
f"s=Hold Slayer Gateway\r\n"
|
|
f"c=IN IP4 {rtp_host}\r\n"
|
|
f"t=0 0\r\n"
|
|
f"m=audio {rtp_port} RTP/AVP 0 8 101\r\n"
|
|
f"a=rtpmap:0 PCMU/8000\r\n"
|
|
f"a=rtpmap:8 PCMA/8000\r\n"
|
|
f"a=rtpmap:101 telephone-event/8000\r\n"
|
|
f"a=fmtp:101 0-16\r\n"
|
|
f"a=sendrecv\r\n"
|
|
)
|
|
|
|
@staticmethod
|
|
def _parse_sdp_rtp_endpoint(sdp: str) -> Optional[dict]:
|
|
"""Extract RTP host/port/codec from SDP body."""
|
|
host = None
|
|
port = None
|
|
codec = "PCMU"
|
|
|
|
for line in sdp.split("\n"):
|
|
line = line.strip()
|
|
if line.startswith("c=IN IP4 "):
|
|
host = line.split(" ")[-1]
|
|
elif line.startswith("m=audio "):
|
|
parts = line.split(" ")
|
|
if len(parts) >= 2:
|
|
port = int(parts[1])
|
|
# First codec in the list
|
|
if len(parts) >= 4:
|
|
payload_type = parts[3]
|
|
codec_map = {"0": "PCMU", "8": "PCMA", "18": "G729"}
|
|
codec = codec_map.get(payload_type, "PCMU")
|
|
|
|
if host and port:
|
|
return {"host": host, "port": port, "codec": codec}
|
|
return None
|