SippyEngine had never successfully placed a call or registered a trunk. Every failure was masked by broad exception handlers that logged and marked the leg terminated, so the gateway reported "ringing" and then ended the call rather than surfacing the fault. None of it was visible to the test suite, which runs exclusively on MockSIPEngine. Found by pointing the gateway at a local Asterisk instance (tests/lab) — each fix uncovered the next. 1. Trunk registration used kwargs the installed sippy (2.3.0) does not accept (auth_name/auth_password → user/passw), called register() instead of doregister(), and passed aor/contact as strings where SipRegistrationAgent calls .getCopy() and mutates .username/.port, so SipURL objects are required. It also posted registered=True at *send* time. Registration is asynchronous, so a rejected REGISTER would still have reported success — and /health treats a registered trunk as a condition for "healthy". Now wired to sippy's rok_cb/rfail_cb, so the rejection status line (typically a bad trunk password) reaches the operator. The Contact also fell back to loopback when the SIP bind is 0.0.0.0; a wildcard address is not somewhere a trunk can send an INVITE. 2. The INVITE passed SDP as a `body` kwarg. CCEventTry takes no such argument: UacStateIdle unpacks exactly six fields from the data tuple and expects the SDP as a MsgBody in position four. callingID/calledID are bare usernames — sippy builds the URIs itself from nh_address. 3. _sip_logger was absent from the global config. SipTransactionManager dereferences it on every message, so the first SIP packet in either direction raised KeyError inside the ED thread. 4. SippyCallController was not callable. Sippy invokes event_cb(event, ua) with CCEvent objects; the class only exposed on_* methods that nothing called. Added __call__ to dispatch CCEventRing/Connect/Disconnect/Fail to the existing handlers, guarding the body because an exception escaping into the ED dispatcher would hang the leg silently. 5. The UA was constructed without credentials, so sippy could not answer the 401/407 challenge that any authenticating trunk sends. Every outbound call died on the challenge. Verified end to end against Asterisk 22.10.1: 180 Ringing → Connected → 23s of audio → clean teardown, with the dialplan executing and audio playing in real time. Not fixed here, and still blocking media: MediaPipeline.create_tap is a stub that logs success and returns a tap nothing ever feeds, so the classifier receives no audio on a live call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
982 lines
39 KiB
Python
982 lines
39 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 __call__(self, event, ua) -> None:
|
|
"""Sippy's ``event_cb`` — invoked as ``event_cb(event, ua)``.
|
|
|
|
Sippy delivers call progress as CCEvent objects through this one
|
|
entry point; it never calls the ``on_*`` methods directly. This
|
|
dispatches to them so each SIP fact still has a named handler.
|
|
"""
|
|
from sippy.CCEvents import (
|
|
CCEventConnect,
|
|
CCEventDisconnect,
|
|
CCEventFail,
|
|
CCEventPreConnect,
|
|
CCEventRing,
|
|
)
|
|
|
|
try:
|
|
if isinstance(event, CCEventRing):
|
|
self.on_ringing()
|
|
elif isinstance(event, (CCEventConnect, CCEventPreConnect)):
|
|
# data is (code, reason, body) — the body carries the
|
|
# negotiated SDP that tells the media pipeline where to
|
|
# send RTP.
|
|
data = event.getData()
|
|
body = data[2] if isinstance(data, tuple) and len(data) > 2 else None
|
|
self.on_connected(str(body) if body is not None else None)
|
|
elif isinstance(event, CCEventDisconnect):
|
|
self.on_disconnected("remote hangup")
|
|
elif isinstance(event, CCEventFail):
|
|
data = event.getData()
|
|
reason = " ".join(str(d) for d in data[:2]) if data else "call failed"
|
|
self.on_disconnected(reason)
|
|
# DTMF is not handled here: SIP INFO arrives as a request and is
|
|
# picked up by _handle_incoming_info, and RFC 2833 DTMF rides in
|
|
# the RTP stream, which is the media pipeline's business.
|
|
except Exception as e:
|
|
# This runs on the Sippy ED thread: an escaping exception is
|
|
# swallowed by the dispatcher and the leg would hang silently.
|
|
logger.error(
|
|
f" {self.leg_id}: error handling {type(event).__name__}: {e}",
|
|
exc_info=True,
|
|
)
|
|
|
|
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"
|
|
|
|
# SipTransactionManager dereferences _sip_logger unconditionally on
|
|
# every message, so it must exist before any SIP traffic. It
|
|
# defaults to the stderr backend (SIPLOG_BEND), not the
|
|
# /var/log/sip.log path in its signature — nothing to create.
|
|
from sippy.SipLogger import SipLogger
|
|
|
|
self._sippy_global_config = {
|
|
"_sip_address": self._sip_address,
|
|
"_sip_port": self._sip_port,
|
|
"_sip_tm": None, # Transaction manager set after start
|
|
"_sip_logger": SipLogger("hold-slayer"),
|
|
}
|
|
|
|
# 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
|
|
from sippy.SipURL import SipURL
|
|
|
|
def on_registered(_rtime, _contact, _cb_arg):
|
|
logger.info(" ✅ Trunk registration accepted")
|
|
self._post_from_ed("trunk_registered", {"registered": True})
|
|
|
|
def on_register_failed(status_line, _cb_arg):
|
|
# status_line is the response's status line (e.g. "403
|
|
# Forbidden") — surface it; a bad trunk password is the
|
|
# most common cause and is otherwise invisible.
|
|
logger.error(f" ❌ Trunk registration rejected: {status_line}")
|
|
self._post_from_ed(
|
|
"trunk_registered",
|
|
{"registered": False, "reason": str(status_line)},
|
|
)
|
|
|
|
# A wildcard bind is not a routable Contact — the trunk would
|
|
# have nowhere to send the inbound INVITE. Fall back to
|
|
# loopback, matching _generate_sdp's handling.
|
|
contact_host = (
|
|
self._sip_address if self._sip_address != "0.0.0.0" else "127.0.0.1"
|
|
)
|
|
|
|
# aor/contact must be SipURL objects: the agent calls
|
|
# .getCopy() and mutates .username/.port on them.
|
|
reg_agent = SipRegistrationAgent(
|
|
self._sippy_global_config,
|
|
SipURL(f"sip:{self._trunk_username}@{self._trunk_host}"),
|
|
SipURL(f"sip:{self._trunk_username}@{contact_host}:{self._sip_port}"),
|
|
user=self._trunk_username,
|
|
passw=self._trunk_password,
|
|
rok_cb=on_registered,
|
|
rfail_cb=on_register_failed,
|
|
)
|
|
# Registration is asynchronous: success is reported by the
|
|
# callbacks above, not here. Reporting "registered" at send
|
|
# time would let /health go green on a rejected REGISTER.
|
|
reg_agent.doregister()
|
|
logger.info(" Trunk REGISTER sent, awaiting response")
|
|
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}", exc_info=True)
|
|
self._post_from_ed(
|
|
"trunk_registered", {"registered": False, "reason": str(e)}
|
|
)
|
|
|
|
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}"
|
|
|
|
caller_number = caller_id or self._did
|
|
from_uri = f"sip:{caller_number}@{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.MsgBody import MsgBody
|
|
from sippy.SipCallId import SipCallId
|
|
from sippy.UA import UA
|
|
|
|
controller = SippyCallController(leg_id, self)
|
|
|
|
# Create Sippy UA for this call. The credentials are required:
|
|
# a trunk answers the first INVITE with 401/407, and sippy
|
|
# only retries with a digest response when they are set —
|
|
# without them every outbound call dies on the challenge.
|
|
ua = UA(
|
|
self._sippy_global_config,
|
|
event_cb=controller,
|
|
username=self._trunk_username or None,
|
|
password=self._trunk_password or None,
|
|
nh_address=(self._trunk_host, self._trunk_port),
|
|
)
|
|
self._ed_leg_to_ua[leg_id] = ua
|
|
self._ed_ua_to_leg[ua] = leg_id
|
|
|
|
# SDP travels inside the event's data tuple as a MsgBody, not
|
|
# as a kwarg. needs_update=False marks it final: with it set,
|
|
# sippy would call ua.on_local_sdp_change (unset here) before
|
|
# sending, and the INVITE would never go out.
|
|
body = MsgBody(sdp_body, mtype="application/sdp")
|
|
body.needs_update = False
|
|
|
|
# UacStateIdle unpacks exactly six fields and builds the SIP
|
|
# URIs itself from nh_address — callingID/calledID are bare
|
|
# usernames, not full URIs.
|
|
event = CCEventTry(
|
|
(SipCallId(), caller_number, number, body, None, None)
|
|
)
|
|
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}", exc_info=True)
|
|
self._post_from_ed(
|
|
"leg_state",
|
|
{"leg_id": leg_id, "state": "terminated", "error": str(e)},
|
|
)
|
|
|
|
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
|