""" 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"" 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"" # ================================================================ # 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