Files
hold-slayer/core/sippy_engine.py
Robert Helewka 204203e3b0 fix(sip): correct five Sippy API mismatches that broke every real call
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>
2026-07-29 06:04:01 -04:00

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