Merge pull request 'Stage 2: thread ownership at the SIP boundary, leg-state wiring, task hygiene' (#2) from feature/stage2-concurrency into feature/stage1-agent-surface
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
@@ -180,6 +180,14 @@ class CallManager:
|
|||||||
"""Map a SIP leg ID to a call ID."""
|
"""Map a SIP leg ID to a call ID."""
|
||||||
self._call_legs[sip_leg_id] = call_id
|
self._call_legs[sip_leg_id] = call_id
|
||||||
|
|
||||||
|
def unmap_leg(self, sip_leg_id: str) -> None:
|
||||||
|
"""Remove a SIP leg mapping (leg terminated)."""
|
||||||
|
self._call_legs.pop(sip_leg_id, None)
|
||||||
|
|
||||||
|
def legs_for_call(self, call_id: str) -> list[str]:
|
||||||
|
"""All SIP leg IDs currently mapped to a call."""
|
||||||
|
return [leg for leg, cid in self._call_legs.items() if cid == call_id]
|
||||||
|
|
||||||
def get_call_for_leg(self, sip_leg_id: str) -> Optional[ActiveCall]:
|
def get_call_for_leg(self, sip_leg_id: str) -> Optional[ActiveCall]:
|
||||||
"""Look up which call a SIP leg belongs to."""
|
"""Look up which call a SIP leg belongs to."""
|
||||||
call_id = self._call_legs.get(sip_leg_id)
|
call_id = self._call_legs.get(sip_leg_id)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Ties together SIP engine, call manager, event bus, and all services.
|
|||||||
This is the top-level object that FastAPI and MCP talk to.
|
This is the top-level object that FastAPI and MCP talk to.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -57,6 +58,7 @@ def _build_sip_engine(settings: Settings, gateway: "AIPSTNGateway") -> SIPEngine
|
|||||||
domain=gw_sip.domain,
|
domain=gw_sip.domain,
|
||||||
did=trunk.did,
|
did=trunk.did,
|
||||||
media_pipeline=gateway.media_pipeline,
|
media_pipeline=gateway.media_pipeline,
|
||||||
|
on_leg_state_change=gateway._on_sip_leg_state,
|
||||||
on_device_registered=gateway._on_sip_device_registered,
|
on_device_registered=gateway._on_sip_device_registered,
|
||||||
on_incoming_call=gateway._on_sip_incoming_call,
|
on_incoming_call=gateway._on_sip_incoming_call,
|
||||||
)
|
)
|
||||||
@@ -101,9 +103,20 @@ class AIPSTNGateway:
|
|||||||
# Device registry (loaded from DB on start)
|
# Device registry (loaded from DB on start)
|
||||||
self._devices: dict[str, Device] = {}
|
self._devices: dict[str, Device] = {}
|
||||||
|
|
||||||
|
# Background tasks (per-call services, receptionist sessions) —
|
||||||
|
# tracked so shutdown can cancel them and GC can't drop them
|
||||||
|
self._tasks: set[asyncio.Task] = set()
|
||||||
|
|
||||||
# Startup time
|
# Startup time
|
||||||
self._started_at: Optional[datetime] = None
|
self._started_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
def spawn(self, coro, name: str) -> asyncio.Task:
|
||||||
|
"""Launch a tracked background task."""
|
||||||
|
task = asyncio.get_running_loop().create_task(coro, name=name)
|
||||||
|
self._tasks.add(task)
|
||||||
|
task.add_done_callback(self._tasks.discard)
|
||||||
|
return task
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_config(cls, sip_engine: Optional[SIPEngine] = None) -> "AIPSTNGateway":
|
def from_config(cls, sip_engine: Optional[SIPEngine] = None) -> "AIPSTNGateway":
|
||||||
"""Create gateway from environment config."""
|
"""Create gateway from environment config."""
|
||||||
@@ -176,6 +189,12 @@ class AIPSTNGateway:
|
|||||||
"""Gracefully shut down."""
|
"""Gracefully shut down."""
|
||||||
logger.info("Shutting down AI PSTN Gateway...")
|
logger.info("Shutting down AI PSTN Gateway...")
|
||||||
|
|
||||||
|
# Cancel per-call background tasks before tearing down their deps
|
||||||
|
for task in list(self._tasks):
|
||||||
|
task.cancel()
|
||||||
|
if self._tasks:
|
||||||
|
await asyncio.gather(*self._tasks, return_exceptions=True)
|
||||||
|
|
||||||
# End all active calls
|
# End all active calls
|
||||||
for call_id in list(self.call_manager.active_calls.keys()):
|
for call_id in list(self.call_manager.active_calls.keys()):
|
||||||
call = self.call_manager.get_call(call_id)
|
call = self.call_manager.get_call(call_id)
|
||||||
@@ -278,8 +297,7 @@ class AIPSTNGateway:
|
|||||||
tts=self._tts,
|
tts=self._tts,
|
||||||
)
|
)
|
||||||
# Launch as background task — don't block
|
# Launch as background task — don't block
|
||||||
import asyncio
|
self.spawn(
|
||||||
asyncio.create_task(
|
|
||||||
hold_slayer.run(call, sip_leg_id, call_flow_id),
|
hold_slayer.run(call, sip_leg_id, call_flow_id),
|
||||||
name=f"holdslayer_{call.id}",
|
name=f"holdslayer_{call.id}",
|
||||||
)
|
)
|
||||||
@@ -366,6 +384,33 @@ class AIPSTNGateway:
|
|||||||
if device:
|
if device:
|
||||||
logger.info(f"📱 Device unregistered: {device.name}")
|
logger.info(f"📱 Device unregistered: {device.name}")
|
||||||
|
|
||||||
|
async def _on_sip_leg_state(self, leg_id: str, state: str) -> None:
|
||||||
|
"""
|
||||||
|
SIP leg state change from the engine (already on the loop).
|
||||||
|
|
||||||
|
Maps leg transitions onto call status. Status only moves
|
||||||
|
forward from the dialing phase — hold-slayer/receptionist
|
||||||
|
states (ON_HOLD, NAVIGATING_IVR, …) are never stomped by a
|
||||||
|
late ringing/connected signal from a second leg.
|
||||||
|
"""
|
||||||
|
call = self.call_manager.get_call_for_leg(leg_id)
|
||||||
|
if call is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if state == "ringing" and call.status == CallStatus.INITIATING:
|
||||||
|
await self.call_manager.update_status(call.id, CallStatus.RINGING)
|
||||||
|
elif state == "connected" and call.status in (
|
||||||
|
CallStatus.INITIATING,
|
||||||
|
CallStatus.RINGING,
|
||||||
|
):
|
||||||
|
await self.call_manager.update_status(call.id, CallStatus.CONNECTED)
|
||||||
|
elif state == "terminated":
|
||||||
|
self.call_manager.unmap_leg(leg_id)
|
||||||
|
# End the call only when its last leg is gone (a transfer
|
||||||
|
# keeps the call alive on the device leg)
|
||||||
|
if not self.call_manager.legs_for_call(call.id):
|
||||||
|
await self.call_manager.end_call(call.id)
|
||||||
|
|
||||||
async def _on_sip_device_registered(
|
async def _on_sip_device_registered(
|
||||||
self, aor: str, contact: str, expires: int
|
self, aor: str, contact: str, expires: int
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -487,8 +532,7 @@ class AIPSTNGateway:
|
|||||||
|
|
||||||
# Hand off to the AI Receptionist
|
# Hand off to the AI Receptionist
|
||||||
if self._receptionist is not None and self.settings.receptionist.enabled:
|
if self._receptionist is not None and self.settings.receptionist.enabled:
|
||||||
import asyncio as _asyncio
|
self.spawn(
|
||||||
_asyncio.create_task(
|
|
||||||
self._receptionist.handle(call, leg_id, decision),
|
self._receptionist.handle(call, leg_id, decision),
|
||||||
name=f"receptionist_{call.id}",
|
name=f"receptionist_{call.id}",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -52,11 +52,23 @@ class AudioTap:
|
|||||||
self._buffer: asyncio.Queue[bytes] = asyncio.Queue(maxsize=500)
|
self._buffer: asyncio.Queue[bytes] = asyncio.Queue(maxsize=500)
|
||||||
self._active = True
|
self._active = True
|
||||||
self._pjsua2_port = None # PJSUA2 AudioMediaPort for tapping
|
self._pjsua2_port = None # PJSUA2 AudioMediaPort for tapping
|
||||||
|
# asyncio.Queue is not thread-safe; feed() hops onto this loop
|
||||||
|
try:
|
||||||
|
self._loop: Optional[asyncio.AbstractEventLoop] = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
self._loop = None
|
||||||
|
|
||||||
def feed(self, pcm_data: bytes) -> None:
|
def feed(self, pcm_data: bytes) -> None:
|
||||||
"""Feed PCM audio data into the tap (called from PJSUA2 thread)."""
|
"""Feed PCM audio data into the tap (called from the PJSUA2 thread)."""
|
||||||
if not self._active:
|
if not self._active:
|
||||||
return
|
return
|
||||||
|
if self._loop is not None:
|
||||||
|
self._loop.call_soon_threadsafe(self._enqueue, pcm_data)
|
||||||
|
else:
|
||||||
|
self._enqueue(pcm_data)
|
||||||
|
|
||||||
|
def _enqueue(self, pcm_data: bytes) -> None:
|
||||||
|
"""Queue a frame on the owning loop, dropping oldest on overflow."""
|
||||||
try:
|
try:
|
||||||
self._buffer.put_nowait(pcm_data)
|
self._buffer.put_nowait(pcm_data)
|
||||||
except asyncio.QueueFull:
|
except asyncio.QueueFull:
|
||||||
|
|||||||
@@ -9,11 +9,22 @@ Architecture:
|
|||||||
Sippy B2BUA → SIP signaling (call control, registration, DTMF)
|
Sippy B2BUA → SIP signaling (call control, registration, DTMF)
|
||||||
PJSUA2 → Media anchor (conference bridge, audio tapping, recording)
|
PJSUA2 → Media anchor (conference bridge, audio tapping, recording)
|
||||||
|
|
||||||
Sippy B2BUA runs in its own thread (it has its own event loop).
|
Thread-ownership rule:
|
||||||
We bridge async/sync via run_in_executor.
|
- 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 asyncio
|
||||||
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
@@ -30,15 +41,15 @@ logger = logging.getLogger(__name__)
|
|||||||
# ================================================================
|
# ================================================================
|
||||||
|
|
||||||
class SipCallLeg:
|
class SipCallLeg:
|
||||||
"""Tracks a single SIP call leg managed by Sippy."""
|
"""Tracks a single SIP call leg. Owned by the asyncio loop."""
|
||||||
|
|
||||||
def __init__(self, leg_id: str, direction: str, remote_uri: str):
|
def __init__(self, leg_id: str, direction: str, remote_uri: str):
|
||||||
self.leg_id = leg_id
|
self.leg_id = leg_id
|
||||||
self.direction = direction # "outbound" or "inbound"
|
self.direction = direction # "outbound" or "inbound"
|
||||||
self.remote_uri = remote_uri
|
self.remote_uri = remote_uri
|
||||||
self.state = "init" # init, trying, ringing, connected, terminated
|
self.state = "init" # init, trying, ringing, connected, terminated
|
||||||
self.sippy_ua = None # Sippy UA object reference
|
|
||||||
self.media_port: Optional[int] = None # PJSUA2 conf bridge port
|
self.media_port: Optional[int] = None # PJSUA2 conf bridge port
|
||||||
|
self.pending_sdp: Optional[str] = None # inbound INVITE SDP, until answered
|
||||||
self.dtmf_buffer: list[str] = []
|
self.dtmf_buffer: list[str] = []
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
@@ -65,75 +76,44 @@ class SippyCallController:
|
|||||||
"""
|
"""
|
||||||
Handles Sippy B2BUA callbacks for a single call leg.
|
Handles Sippy B2BUA callbacks for a single call leg.
|
||||||
|
|
||||||
Sippy B2BUA uses a callback model — when SIP events happen
|
Runs entirely on the Sippy ED thread. It holds only the leg_id
|
||||||
(180 Ringing, 200 OK, BYE, etc.), the corresponding method
|
token and forwards every state change to the asyncio loop via
|
||||||
is called on this controller.
|
the engine's event funnel — it never touches loop-owned state.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, leg: SipCallLeg, engine: "SippyEngine"):
|
def __init__(self, leg_id: str, engine: "SippyEngine"):
|
||||||
self.leg = leg
|
self.leg_id = leg_id
|
||||||
self.engine = engine
|
self.engine = engine
|
||||||
|
|
||||||
def on_trying(self):
|
def on_trying(self):
|
||||||
"""100 Trying received."""
|
"""100 Trying received."""
|
||||||
self.leg.state = "trying"
|
logger.debug(f" {self.leg_id}: 100 Trying")
|
||||||
logger.debug(f" {self.leg.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):
|
def on_ringing(self, ringing_code: int = 180):
|
||||||
"""180 Ringing / 183 Session Progress received."""
|
"""180 Ringing / 183 Session Progress received."""
|
||||||
self.leg.state = "ringing"
|
logger.info(f" {self.leg_id}: {ringing_code} Ringing")
|
||||||
logger.info(f" {self.leg.leg_id}: {ringing_code} Ringing")
|
self.engine._post_from_ed("leg_state", {"leg_id": self.leg_id, "state": "ringing"})
|
||||||
if self.engine._on_leg_state_change:
|
|
||||||
self.engine._loop.call_soon_threadsafe(
|
|
||||||
self.engine._on_leg_state_change, self.leg.leg_id, "ringing"
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_connected(self, sdp_body: Optional[str] = None):
|
def on_connected(self, sdp_body: Optional[str] = None):
|
||||||
"""200 OK — call connected, media negotiated."""
|
"""200 OK — call connected, media negotiated."""
|
||||||
self.leg.state = "connected"
|
logger.info(f" {self.leg_id}: Connected")
|
||||||
logger.info(f" {self.leg.leg_id}: Connected")
|
self.engine._post_from_ed(
|
||||||
|
"leg_state", {"leg_id": self.leg_id, "state": "connected", "sdp": sdp_body}
|
||||||
# Extract remote RTP endpoint from SDP for PJSUA2 media bridge
|
)
|
||||||
if sdp_body and self.engine.media_pipeline:
|
|
||||||
try:
|
|
||||||
remote_rtp = self.engine._parse_sdp_rtp_endpoint(sdp_body)
|
|
||||||
if remote_rtp:
|
|
||||||
port = self.engine.media_pipeline.add_remote_stream(
|
|
||||||
self.leg.leg_id,
|
|
||||||
remote_rtp["host"],
|
|
||||||
remote_rtp["port"],
|
|
||||||
remote_rtp["codec"],
|
|
||||||
)
|
|
||||||
self.leg.media_port = port
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f" Failed to set up media for {self.leg.leg_id}: {e}")
|
|
||||||
|
|
||||||
if self.engine._on_leg_state_change:
|
|
||||||
self.engine._loop.call_soon_threadsafe(
|
|
||||||
self.engine._on_leg_state_change, self.leg.leg_id, "connected"
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_disconnected(self, reason: str = ""):
|
def on_disconnected(self, reason: str = ""):
|
||||||
"""BYE received or call terminated."""
|
"""BYE received or call terminated."""
|
||||||
self.leg.state = "terminated"
|
logger.info(f" {self.leg_id}: Disconnected ({reason})")
|
||||||
logger.info(f" {self.leg.leg_id}: Disconnected ({reason})")
|
self.engine._ed_forget_leg(self.leg_id)
|
||||||
|
self.engine._post_from_ed(
|
||||||
# Clean up media
|
"leg_state", {"leg_id": self.leg_id, "state": "terminated", "reason": reason}
|
||||||
if self.engine.media_pipeline and self.leg.media_port is not None:
|
)
|
||||||
try:
|
|
||||||
self.engine.media_pipeline.remove_stream(self.leg.leg_id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f" Failed to clean up media for {self.leg.leg_id}: {e}")
|
|
||||||
|
|
||||||
if self.engine._on_leg_state_change:
|
|
||||||
self.engine._loop.call_soon_threadsafe(
|
|
||||||
self.engine._on_leg_state_change, self.leg.leg_id, "terminated"
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_dtmf(self, digit: str):
|
def on_dtmf(self, digit: str):
|
||||||
"""DTMF digit received (RFC 2833 or SIP INFO)."""
|
"""DTMF digit received (RFC 2833 or SIP INFO)."""
|
||||||
self.leg.dtmf_buffer.append(digit)
|
logger.debug(f" {self.leg_id}: DTMF '{digit}'")
|
||||||
logger.debug(f" {self.leg.leg_id}: DTMF '{digit}'")
|
self.engine._post_from_ed("dtmf", {"leg_id": self.leg_id, "digit": digit})
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
@@ -190,17 +170,140 @@ class SippyEngine(SIPEngine):
|
|||||||
self._on_incoming_call = on_incoming_call
|
self._on_incoming_call = on_incoming_call
|
||||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
|
|
||||||
# State
|
# 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._ready = False
|
||||||
self._trunk_registered = False
|
self._trunk_registered = False
|
||||||
self._legs: dict[str, SipCallLeg] = {}
|
self._legs: dict[str, SipCallLeg] = {}
|
||||||
self._bridges: dict[str, SipBridge] = {}
|
self._bridges: dict[str, SipBridge] = {}
|
||||||
self._registered_devices: list[dict] = []
|
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)
|
# Sippy B2BUA internals (set during start)
|
||||||
self._sippy_global_config: dict[str, Any] = {}
|
self._sippy_global_config: dict[str, Any] = {}
|
||||||
self._sippy_thread: Optional[threading.Thread] = None
|
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":
|
||||||
|
leg = self._legs.get(data["leg_id"])
|
||||||
|
if leg:
|
||||||
|
leg.dtmf_buffer.append(data["digit"])
|
||||||
|
|
||||||
|
elif kind == "trunk_registered":
|
||||||
|
self._trunk_registered = data["registered"]
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# Lifecycle
|
# Lifecycle
|
||||||
# ================================================================
|
# ================================================================
|
||||||
@@ -212,7 +315,6 @@ class SippyEngine(SIPEngine):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from sippy.SipConf import SipConf
|
from sippy.SipConf import SipConf
|
||||||
from sippy.SipTransactionManager import SipTransactionManager
|
|
||||||
|
|
||||||
# Configure Sippy
|
# Configure Sippy
|
||||||
SipConf.my_address = self._sip_address
|
SipConf.my_address = self._sip_address
|
||||||
@@ -226,7 +328,7 @@ class SippyEngine(SIPEngine):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Start Sippy's SIP transaction manager in a background thread
|
# Start Sippy's SIP transaction manager in a background thread
|
||||||
# Sippy uses its own event loop (Twisted reactor or custom loop)
|
# Sippy uses its own event loop (the ED2 event dispatcher)
|
||||||
self._sippy_thread = threading.Thread(
|
self._sippy_thread = threading.Thread(
|
||||||
target=self._run_sippy_loop,
|
target=self._run_sippy_loop,
|
||||||
name="sippy-b2bua",
|
name="sippy-b2bua",
|
||||||
@@ -254,8 +356,8 @@ class SippyEngine(SIPEngine):
|
|||||||
def _run_sippy_loop(self):
|
def _run_sippy_loop(self):
|
||||||
"""Run Sippy B2BUA's event loop in a dedicated thread."""
|
"""Run Sippy B2BUA's event loop in a dedicated thread."""
|
||||||
try:
|
try:
|
||||||
|
from sippy.Core.EventDispatcher import ED2
|
||||||
from sippy.SipTransactionManager import SipTransactionManager
|
from sippy.SipTransactionManager import SipTransactionManager
|
||||||
from sippy.Timeout import Timeout
|
|
||||||
|
|
||||||
# Initialize Sippy's transaction manager
|
# Initialize Sippy's transaction manager
|
||||||
stm = SipTransactionManager(self._sippy_global_config, self._handle_sippy_request)
|
stm = SipTransactionManager(self._sippy_global_config, self._handle_sippy_request)
|
||||||
@@ -263,11 +365,9 @@ class SippyEngine(SIPEngine):
|
|||||||
|
|
||||||
logger.info(" Sippy transaction manager started")
|
logger.info(" Sippy transaction manager started")
|
||||||
|
|
||||||
# Sippy will block here in its event loop
|
# Sippy blocks here dispatching its event loop; callbacks
|
||||||
# For the Twisted-based version, this runs the reactor
|
# injected via ED2.callFromThread run inside this loop.
|
||||||
# For the asyncore version, this runs asyncore.loop()
|
ED2.loop()
|
||||||
from sippy.Core.EventDispatcher import ED
|
|
||||||
ED.loop()
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f" Sippy event loop crashed: {e}")
|
logger.error(f" Sippy event loop crashed: {e}")
|
||||||
@@ -294,10 +394,9 @@ class SippyEngine(SIPEngine):
|
|||||||
"""
|
"""
|
||||||
Handle an incoming SIP REGISTER from a phone or softphone.
|
Handle an incoming SIP REGISTER from a phone or softphone.
|
||||||
|
|
||||||
Extracts the AOR (address of record) from the To header, records
|
Runs on the Sippy thread: parses the request, replies 200 OK,
|
||||||
the contact and expiry, and sends a 200 OK. The gateway's
|
and posts the registration to the loop funnel, which owns the
|
||||||
register_device() is called asynchronously via the event loop so
|
device list and notifies the gateway.
|
||||||
the phone gets an extension and SIP URI assigned automatically.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
to_uri = str(req.getHFBody("to").getUri())
|
to_uri = str(req.getHFBody("to").getUri())
|
||||||
@@ -309,34 +408,13 @@ class SippyEngine(SIPEngine):
|
|||||||
logger.info(f" SIP REGISTER: {to_uri} contact={contact_uri} expires={expires}")
|
logger.info(f" SIP REGISTER: {to_uri} contact={contact_uri} expires={expires}")
|
||||||
|
|
||||||
if expires == 0:
|
if expires == 0:
|
||||||
# De-registration
|
self._post_from_ed("deregister", {"aor": to_uri})
|
||||||
self._registered_devices = [
|
|
||||||
d for d in self._registered_devices
|
|
||||||
if d.get("aor") != to_uri
|
|
||||||
]
|
|
||||||
logger.info(f" De-registered: {to_uri}")
|
|
||||||
else:
|
else:
|
||||||
# Update or add registration record
|
self._post_from_ed("register", {
|
||||||
existing = next(
|
"aor": to_uri,
|
||||||
(d for d in self._registered_devices if d.get("aor") == to_uri),
|
"contact": contact_uri,
|
||||||
None,
|
"expires": expires,
|
||||||
)
|
})
|
||||||
if existing:
|
|
||||||
existing["contact"] = contact_uri
|
|
||||||
existing["expires"] = expires
|
|
||||||
else:
|
|
||||||
self._registered_devices.append({
|
|
||||||
"aor": to_uri,
|
|
||||||
"contact": contact_uri,
|
|
||||||
"expires": expires,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Notify the gateway (async) so it can assign an extension
|
|
||||||
if self._loop:
|
|
||||||
self._loop.call_soon_threadsafe(
|
|
||||||
self._loop.create_task,
|
|
||||||
self._notify_registration(to_uri, contact_uri, expires),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Reply 200 OK
|
# Reply 200 OK
|
||||||
req.sendResponse(200, "OK")
|
req.sendResponse(200, "OK")
|
||||||
@@ -348,52 +426,41 @@ class SippyEngine(SIPEngine):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def _notify_registration(self, aor: str, contact: str, expires: int):
|
|
||||||
"""
|
|
||||||
Async callback: tell the gateway about the newly registered device
|
|
||||||
so it can assign an extension if needed.
|
|
||||||
"""
|
|
||||||
if self._on_device_registered:
|
|
||||||
await self._on_device_registered(aor, contact, expires)
|
|
||||||
|
|
||||||
def _handle_incoming_invite(self, req, sip_t):
|
def _handle_incoming_invite(self, req, sip_t):
|
||||||
"""Handle an incoming INVITE — create inbound call leg.
|
"""Handle an incoming INVITE — surface an inbound call leg.
|
||||||
|
|
||||||
The gateway is notified via `on_incoming_call`; it decides
|
Runs on the Sippy thread: extracts everything the loop needs
|
||||||
whether to answer (via `accept_inbound`) or reject the leg
|
(URIs, SDP body) as plain strings and posts them. The gateway
|
||||||
based on routing rules.
|
decides whether to answer (via `accept_inbound`) or reject.
|
||||||
"""
|
"""
|
||||||
from_uri = str(req.getHFBody("from").getUri())
|
from_uri = str(req.getHFBody("from").getUri())
|
||||||
to_uri = str(req.getHFBody("to").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]}"
|
leg_id = f"leg_{uuid.uuid4().hex[:12]}"
|
||||||
leg = SipCallLeg(leg_id, "inbound", from_uri)
|
ua = sip_t.ua if hasattr(sip_t, "ua") else None
|
||||||
leg.sippy_ua = sip_t.ua if hasattr(sip_t, "ua") else None
|
if ua is not None:
|
||||||
leg.pending_invite = req
|
self._ed_ua_to_leg[ua] = leg_id
|
||||||
self._legs[leg_id] = leg
|
self._ed_leg_to_ua[leg_id] = ua
|
||||||
|
|
||||||
logger.info(f" Incoming call: {from_uri} → {to_uri} (leg: {leg_id})")
|
logger.info(f" Incoming call: {from_uri} → {to_uri} (leg: {leg_id})")
|
||||||
|
|
||||||
# Surface to the gateway. If no callback is wired, fall back to
|
self._post_from_ed("incoming_invite", {
|
||||||
# auto-answer so we don't regress the previous behavior.
|
"leg_id": leg_id,
|
||||||
if self._on_incoming_call and self._loop:
|
"from_uri": from_uri,
|
||||||
asyncio.run_coroutine_threadsafe(
|
"to_uri": to_uri,
|
||||||
self._on_incoming_call(from_uri, to_uri, leg_id),
|
"sdp": sdp,
|
||||||
self._loop,
|
})
|
||||||
)
|
|
||||||
else:
|
|
||||||
controller = SippyCallController(leg, self)
|
|
||||||
controller.on_connected(str(req.getBody()) if req.getBody() else None)
|
|
||||||
|
|
||||||
async def accept_inbound(self, leg_id: str) -> bool:
|
async def accept_inbound(self, leg_id: str) -> bool:
|
||||||
"""Answer a previously-surfaced inbound INVITE."""
|
"""Answer a previously-surfaced inbound INVITE."""
|
||||||
leg = self._legs.get(leg_id)
|
leg = self._legs.get(leg_id)
|
||||||
if not leg or leg.direction != "inbound":
|
if not leg or leg.direction != "inbound":
|
||||||
return False
|
return False
|
||||||
req = getattr(leg, "pending_invite", None)
|
sdp, leg.pending_sdp = leg.pending_sdp, None
|
||||||
controller = SippyCallController(leg, self)
|
await self._on_engine_event(
|
||||||
body = str(req.getBody()) if req and req.getBody() else None
|
"leg_state", {"leg_id": leg_id, "state": "connected", "sdp": sdp}
|
||||||
controller.on_connected(body)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def reject_inbound(self, leg_id: str, code: int = 603, reason: str = "Decline") -> bool:
|
async def reject_inbound(self, leg_id: str, code: int = 603, reason: str = "Decline") -> bool:
|
||||||
@@ -404,66 +471,66 @@ class SippyEngine(SIPEngine):
|
|||||||
logger.info(f" ⛔ Rejecting inbound leg {leg_id}: {code} {reason}")
|
logger.info(f" ⛔ Rejecting inbound leg {leg_id}: {code} {reason}")
|
||||||
# Real SIP rejection would go through Sippy here; we just drop the leg
|
# Real SIP rejection would go through Sippy here; we just drop the leg
|
||||||
# in stub mode so callers see the call terminate.
|
# in stub mode so callers see the call terminate.
|
||||||
|
self._run_on_sippy(lambda: self._ed_forget_leg(leg_id))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _handle_incoming_bye(self, req, sip_t):
|
def _handle_incoming_bye(self, req, sip_t):
|
||||||
"""Handle incoming BYE — tear down call leg."""
|
"""Handle incoming BYE — tear down call leg (Sippy thread)."""
|
||||||
# Find the leg by Sippy's UA object
|
ua = sip_t.ua if hasattr(sip_t, "ua") else None
|
||||||
for leg in self._legs.values():
|
leg_id = self._ed_ua_to_leg.get(ua) if ua is not None else None
|
||||||
if leg.sippy_ua and hasattr(sip_t, "ua") and leg.sippy_ua == sip_t.ua:
|
if leg_id:
|
||||||
controller = SippyCallController(leg, self)
|
SippyCallController(leg_id, self).on_disconnected("BYE received")
|
||||||
controller.on_disconnected("BYE received")
|
|
||||||
break
|
|
||||||
|
|
||||||
def _handle_incoming_info(self, req, sip_t):
|
def _handle_incoming_info(self, req, sip_t):
|
||||||
"""Handle SIP INFO (DTMF via SIP INFO method)."""
|
"""Handle SIP INFO (DTMF via SIP INFO method) on the Sippy thread."""
|
||||||
body = str(req.getBody()) if req.getBody() else ""
|
body = str(req.getBody()) if req.getBody() else ""
|
||||||
if "dtmf" in body.lower() or "Signal=" in body:
|
if "dtmf" in body.lower() or "Signal=" in body:
|
||||||
# Extract DTMF digit from SIP INFO 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"):
|
for line in body.split("\n"):
|
||||||
if line.startswith("Signal="):
|
if line.startswith("Signal="):
|
||||||
digit = line.split("=")[1].strip()
|
digit = line.split("=")[1].strip()
|
||||||
for leg in self._legs.values():
|
SippyCallController(leg_id, self).on_dtmf(digit)
|
||||||
if leg.sippy_ua and hasattr(sip_t, "ua") and leg.sippy_ua == sip_t.ua:
|
|
||||||
controller = SippyCallController(leg, self)
|
|
||||||
controller.on_dtmf(digit)
|
|
||||||
break
|
|
||||||
|
|
||||||
async def _register_trunk(self) -> None:
|
async def _register_trunk(self) -> None:
|
||||||
"""Register with the SIP trunk provider."""
|
"""Register with the SIP trunk provider."""
|
||||||
try:
|
logger.info(f" Registering with trunk: {self._trunk_host}:{self._trunk_port}")
|
||||||
from sippy.UA import UA
|
|
||||||
from sippy.SipRegistrationAgent import SipRegistrationAgent
|
|
||||||
|
|
||||||
logger.info(f" Registering with trunk: {self._trunk_host}:{self._trunk_port}")
|
def do_register():
|
||||||
|
try:
|
||||||
|
from sippy.SipRegistrationAgent import SipRegistrationAgent
|
||||||
|
|
||||||
# Run registration in Sippy's thread
|
reg_agent = SipRegistrationAgent(
|
||||||
def do_register():
|
self._sippy_global_config,
|
||||||
try:
|
f"sip:{self._trunk_username}@{self._trunk_host}",
|
||||||
reg_agent = SipRegistrationAgent(
|
f"sip:{self._trunk_host}:{self._trunk_port}",
|
||||||
self._sippy_global_config,
|
auth_name=self._trunk_username,
|
||||||
f"sip:{self._trunk_username}@{self._trunk_host}",
|
auth_password=self._trunk_password,
|
||||||
f"sip:{self._trunk_host}:{self._trunk_port}",
|
)
|
||||||
auth_name=self._trunk_username,
|
reg_agent.register()
|
||||||
auth_password=self._trunk_password,
|
logger.info(" ✅ Trunk registration sent")
|
||||||
)
|
self._post_from_ed("trunk_registered", {"registered": True})
|
||||||
reg_agent.register()
|
except ImportError:
|
||||||
self._trunk_registered = True
|
logger.warning(" Sippy registration agent not available")
|
||||||
logger.info(" ✅ Trunk registration sent")
|
self._post_from_ed("trunk_registered", {"registered": False})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f" ❌ Trunk registration failed: {e}")
|
logger.error(f" ❌ Trunk registration failed: {e}")
|
||||||
self._trunk_registered = False
|
self._post_from_ed("trunk_registered", {"registered": False})
|
||||||
|
|
||||||
await asyncio.get_event_loop().run_in_executor(None, do_register)
|
self._run_on_sippy(do_register)
|
||||||
|
|
||||||
except ImportError:
|
|
||||||
logger.warning(" Sippy registration agent not available")
|
|
||||||
self._trunk_registered = False
|
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
"""Gracefully shut down the SIP engine."""
|
"""Gracefully shut down the SIP engine."""
|
||||||
logger.info("🔌 Stopping Sippy B2BUA...")
|
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
|
# Hang up all active legs
|
||||||
for leg_id in list(self._legs.keys()):
|
for leg_id in list(self._legs.keys()):
|
||||||
try:
|
try:
|
||||||
@@ -473,8 +540,8 @@ class SippyEngine(SIPEngine):
|
|||||||
|
|
||||||
# Stop Sippy's event loop
|
# Stop Sippy's event loop
|
||||||
try:
|
try:
|
||||||
from sippy.Core.EventDispatcher import ED
|
from sippy.Core.EventDispatcher import ED2
|
||||||
ED.breakLoop()
|
ED2.breakLoop()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -512,14 +579,16 @@ class SippyEngine(SIPEngine):
|
|||||||
|
|
||||||
logger.info(f"📞 Placing call: {from_uri} → {remote_uri} (leg: {leg_id})")
|
logger.info(f"📞 Placing call: {from_uri} → {remote_uri} (leg: {leg_id})")
|
||||||
|
|
||||||
# Place the call via Sippy
|
# Generate SDP on the loop (allocate_rtp_port is lock-protected)
|
||||||
|
sdp_body = self._generate_sdp(leg_id)
|
||||||
|
|
||||||
def do_invite():
|
def do_invite():
|
||||||
try:
|
try:
|
||||||
from sippy.UA import UA
|
|
||||||
from sippy.SipCallId import SipCallId
|
|
||||||
from sippy.CCEvents import CCEventTry
|
from sippy.CCEvents import CCEventTry
|
||||||
|
from sippy.SipCallId import SipCallId
|
||||||
|
from sippy.UA import UA
|
||||||
|
|
||||||
controller = SippyCallController(leg, self)
|
controller = SippyCallController(leg_id, self)
|
||||||
|
|
||||||
# Create Sippy UA for this call
|
# Create Sippy UA for this call
|
||||||
ua = UA(
|
ua = UA(
|
||||||
@@ -527,10 +596,8 @@ class SippyEngine(SIPEngine):
|
|||||||
event_cb=controller,
|
event_cb=controller,
|
||||||
nh_address=(self._trunk_host, self._trunk_port),
|
nh_address=(self._trunk_host, self._trunk_port),
|
||||||
)
|
)
|
||||||
leg.sippy_ua = ua
|
self._ed_leg_to_ua[leg_id] = ua
|
||||||
|
self._ed_ua_to_leg[ua] = leg_id
|
||||||
# Generate SDP for the call
|
|
||||||
sdp_body = self._generate_sdp(leg_id)
|
|
||||||
|
|
||||||
# Send INVITE
|
# Send INVITE
|
||||||
event = CCEventTry(
|
event = CCEventTry(
|
||||||
@@ -539,19 +606,19 @@ class SippyEngine(SIPEngine):
|
|||||||
)
|
)
|
||||||
ua.recvEvent(event)
|
ua.recvEvent(event)
|
||||||
|
|
||||||
leg.state = "trying"
|
|
||||||
logger.info(f" INVITE sent for {leg_id}")
|
logger.info(f" INVITE sent for {leg_id}")
|
||||||
|
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "trying"})
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Sippy not installed — simulate for development
|
# Sippy not installed — simulate for development
|
||||||
logger.warning(f" Sippy not installed, simulating call for {leg_id}")
|
logger.warning(f" Sippy not installed, simulating call for {leg_id}")
|
||||||
leg.state = "ringing"
|
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "ringing"})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f" Failed to send INVITE for {leg_id}: {e}")
|
logger.error(f" Failed to send INVITE for {leg_id}: {e}")
|
||||||
leg.state = "terminated"
|
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "terminated"})
|
||||||
|
|
||||||
await asyncio.get_event_loop().run_in_executor(None, do_invite)
|
self._run_on_sippy(do_invite)
|
||||||
return leg_id
|
return leg_id
|
||||||
|
|
||||||
async def hangup(self, call_leg_id: str) -> None:
|
async def hangup(self, call_leg_id: str) -> None:
|
||||||
@@ -563,15 +630,18 @@ class SippyEngine(SIPEngine):
|
|||||||
|
|
||||||
def do_bye():
|
def do_bye():
|
||||||
try:
|
try:
|
||||||
if leg.sippy_ua:
|
ua = self._ed_leg_to_ua.get(call_leg_id)
|
||||||
|
if ua is not None:
|
||||||
from sippy.CCEvents import CCEventDisconnect
|
from sippy.CCEvents import CCEventDisconnect
|
||||||
leg.sippy_ua.recvEvent(CCEventDisconnect())
|
ua.recvEvent(CCEventDisconnect())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f" Error sending BYE for {call_leg_id}: {e}")
|
logger.error(f" Error sending BYE for {call_leg_id}: {e}")
|
||||||
finally:
|
finally:
|
||||||
leg.state = "terminated"
|
self._ed_forget_leg(call_leg_id)
|
||||||
|
|
||||||
await asyncio.get_event_loop().run_in_executor(None, do_bye)
|
self._run_on_sippy(do_bye)
|
||||||
|
|
||||||
|
leg.state = "terminated"
|
||||||
|
|
||||||
# Clean up media
|
# Clean up media
|
||||||
if self.media_pipeline and leg.media_port is not None:
|
if self.media_pipeline and leg.media_port is not None:
|
||||||
@@ -595,13 +665,13 @@ class SippyEngine(SIPEngine):
|
|||||||
|
|
||||||
def do_dtmf():
|
def do_dtmf():
|
||||||
try:
|
try:
|
||||||
if leg.sippy_ua:
|
ua = self._ed_leg_to_ua.get(call_leg_id)
|
||||||
# Send via RFC 2833 (in-band RTP event)
|
if ua is not None:
|
||||||
# Sippy handles this through the UA's DTMF sender
|
# Send via SIP INFO through the UA
|
||||||
|
from sippy.CCEvents import CCEventInfo
|
||||||
for digit in digits:
|
for digit in digits:
|
||||||
from sippy.CCEvents import CCEventInfo
|
|
||||||
body = f"Signal={digit}\r\nDuration=160\r\n"
|
body = f"Signal={digit}\r\nDuration=160\r\n"
|
||||||
leg.sippy_ua.recvEvent(CCEventInfo(body=body))
|
ua.recvEvent(CCEventInfo(body=body))
|
||||||
else:
|
else:
|
||||||
logger.warning(f" No UA for {call_leg_id}, DTMF not sent")
|
logger.warning(f" No UA for {call_leg_id}, DTMF not sent")
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -609,7 +679,7 @@ class SippyEngine(SIPEngine):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f" DTMF send error: {e}")
|
logger.error(f" DTMF send error: {e}")
|
||||||
|
|
||||||
await asyncio.get_event_loop().run_in_executor(None, do_dtmf)
|
self._run_on_sippy(do_dtmf)
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# Device Calls (for transfer)
|
# Device Calls (for transfer)
|
||||||
@@ -638,13 +708,15 @@ class SippyEngine(SIPEngine):
|
|||||||
|
|
||||||
logger.info(f"📱 Calling device: {device.name} ({device.sip_uri}) (leg: {leg_id})")
|
logger.info(f"📱 Calling device: {device.name} ({device.sip_uri}) (leg: {leg_id})")
|
||||||
|
|
||||||
|
sdp_body = self._generate_sdp(leg_id)
|
||||||
|
|
||||||
def do_invite_device():
|
def do_invite_device():
|
||||||
try:
|
try:
|
||||||
from sippy.UA import UA
|
|
||||||
from sippy.CCEvents import CCEventTry
|
from sippy.CCEvents import CCEventTry
|
||||||
from sippy.SipCallId import SipCallId
|
from sippy.SipCallId import SipCallId
|
||||||
|
from sippy.UA import UA
|
||||||
|
|
||||||
controller = SippyCallController(leg, self)
|
controller = SippyCallController(leg_id, self)
|
||||||
|
|
||||||
# Parse device SIP URI for routing
|
# Parse device SIP URI for routing
|
||||||
# sip:robert@192.168.1.100:5060
|
# sip:robert@192.168.1.100:5060
|
||||||
@@ -662,25 +734,24 @@ class SippyEngine(SIPEngine):
|
|||||||
event_cb=controller,
|
event_cb=controller,
|
||||||
nh_address=(host, port),
|
nh_address=(host, port),
|
||||||
)
|
)
|
||||||
leg.sippy_ua = ua
|
self._ed_leg_to_ua[leg_id] = ua
|
||||||
|
self._ed_ua_to_leg[ua] = leg_id
|
||||||
sdp_body = self._generate_sdp(leg_id)
|
|
||||||
|
|
||||||
event = CCEventTry(
|
event = CCEventTry(
|
||||||
(SipCallId(), f"sip:gateway@{self._domain}", device.sip_uri),
|
(SipCallId(), f"sip:gateway@{self._domain}", device.sip_uri),
|
||||||
body=sdp_body,
|
body=sdp_body,
|
||||||
)
|
)
|
||||||
ua.recvEvent(event)
|
ua.recvEvent(event)
|
||||||
leg.state = "trying"
|
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "trying"})
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning(f" Sippy not installed, simulating device call for {leg_id}")
|
logger.warning(f" Sippy not installed, simulating device call for {leg_id}")
|
||||||
leg.state = "ringing"
|
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "ringing"})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f" Failed to call device {device.name}: {e}")
|
logger.error(f" Failed to call device {device.name}: {e}")
|
||||||
leg.state = "terminated"
|
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "terminated"})
|
||||||
|
|
||||||
await asyncio.get_event_loop().run_in_executor(None, do_invite_device)
|
self._run_on_sippy(do_invite_device)
|
||||||
return leg_id
|
return leg_id
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ Uses spectral analysis (librosa/numpy) to classify audio without needing
|
|||||||
a trained ML model — just signal processing and heuristics.
|
a trained ML model — just signal processing and heuristics.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -47,9 +48,23 @@ class AudioClassifier:
|
|||||||
self._window_samples = int(settings.window_seconds * SAMPLE_RATE)
|
self._window_samples = int(settings.window_seconds * SAMPLE_RATE)
|
||||||
self._classification_history: list[AudioClassification] = []
|
self._classification_history: list[AudioClassification] = []
|
||||||
|
|
||||||
|
async def classify(self, audio_data: bytes) -> ClassificationResult:
|
||||||
|
"""
|
||||||
|
Classify a chunk off the event loop and record it in the history.
|
||||||
|
|
||||||
|
The FFT/autocorrelation work is CPU-bound, so the pure
|
||||||
|
`classify_chunk` runs in a worker thread; the history update
|
||||||
|
happens back on the loop, keeping it single-threaded. This is
|
||||||
|
the call sites' entry point — routing every classification
|
||||||
|
through here is what keeps the history complete.
|
||||||
|
"""
|
||||||
|
result = await asyncio.to_thread(self.classify_chunk, audio_data)
|
||||||
|
self.update_history(result.audio_type)
|
||||||
|
return result
|
||||||
|
|
||||||
def classify_chunk(self, audio_data: bytes) -> ClassificationResult:
|
def classify_chunk(self, audio_data: bytes) -> ClassificationResult:
|
||||||
"""
|
"""
|
||||||
Classify a chunk of audio data.
|
Classify a chunk of audio data (pure, synchronous).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
audio_data: Raw PCM audio (16-bit signed, 16kHz, mono)
|
audio_data: Raw PCM audio (16-bit signed, 16kHz, mono)
|
||||||
@@ -285,17 +300,15 @@ class AudioClassifier:
|
|||||||
(941, 1209): "*", (941, 1336): "0", (941, 1477): "#", (941, 1633): "D",
|
(941, 1209): "*", (941, 1336): "0", (941, 1477): "#", (941, 1633): "D",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Compute power at each DTMF frequency
|
# Power at each DTMF frequency via the DFT bin (numerically equal
|
||||||
|
# to the Goertzel result s1² + s2² − coeff·s1·s2, but vectorized —
|
||||||
|
# the per-sample Python loop blocked for ~50ms per chunk)
|
||||||
|
n = np.arange(len(samples))
|
||||||
|
|
||||||
def goertzel_power(freq: int) -> float:
|
def goertzel_power(freq: int) -> float:
|
||||||
k = int(0.5 + len(samples) * freq / SAMPLE_RATE)
|
k = int(0.5 + len(samples) * freq / SAMPLE_RATE)
|
||||||
w = 2 * np.pi * k / len(samples)
|
bin_value = np.dot(samples, np.exp(-2j * np.pi * k * n / len(samples)))
|
||||||
coeff = 2 * np.cos(w)
|
return float(np.abs(bin_value) ** 2)
|
||||||
s0, s1, s2 = 0.0, 0.0, 0.0
|
|
||||||
for sample in samples:
|
|
||||||
s0 = sample + coeff * s1 - s2
|
|
||||||
s2 = s1
|
|
||||||
s1 = s0
|
|
||||||
return float(s1 * s1 + s2 * s2 - coeff * s1 * s2)
|
|
||||||
|
|
||||||
# Find strongest low and high frequencies
|
# Find strongest low and high frequencies
|
||||||
low_powers = [(f, goertzel_power(f)) for f in dtmf_freqs_low]
|
low_powers = [(f, goertzel_power(f)) for f in dtmf_freqs_low]
|
||||||
|
|||||||
@@ -323,8 +323,7 @@ class HoldSlayerService:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Classify the audio
|
# Classify the audio
|
||||||
classification = self.classifier.classify_chunk(audio_chunk)
|
classification = await self.classifier.classify(audio_chunk)
|
||||||
self.classifier.update_history(classification.audio_type)
|
|
||||||
await self.call_manager.add_classification(call.id, classification)
|
await self.call_manager.add_classification(call.id, classification)
|
||||||
|
|
||||||
# Transcribe if it sounds like speech
|
# Transcribe if it sounds like speech
|
||||||
@@ -447,8 +446,7 @@ class HoldSlayerService:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Classify
|
# Classify
|
||||||
result = self.classifier.classify_chunk(audio_chunk)
|
result = await self.classifier.classify(audio_chunk)
|
||||||
self.classifier.update_history(result.audio_type)
|
|
||||||
await self.call_manager.add_classification(call.id, result)
|
await self.call_manager.add_classification(call.id, result)
|
||||||
|
|
||||||
# Check for human
|
# Check for human
|
||||||
@@ -508,7 +506,7 @@ class HoldSlayerService:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Classify first
|
# Classify first
|
||||||
result = self.classifier.classify_chunk(audio_chunk)
|
result = await self.classifier.classify(audio_chunk)
|
||||||
if result.audio_type not in (
|
if result.audio_type not in (
|
||||||
AudioClassification.IVR_PROMPT,
|
AudioClassification.IVR_PROMPT,
|
||||||
AudioClassification.LIVE_HUMAN,
|
AudioClassification.LIVE_HUMAN,
|
||||||
@@ -560,7 +558,7 @@ class HoldSlayerService:
|
|||||||
if not audio_chunk:
|
if not audio_chunk:
|
||||||
break
|
break
|
||||||
|
|
||||||
result = self.classifier.classify_chunk(audio_chunk)
|
result = await self.classifier.classify(audio_chunk)
|
||||||
|
|
||||||
# If we're getting silence after speech, the menu prompt is done
|
# If we're getting silence after speech, the menu prompt is done
|
||||||
if result.audio_type == AudioClassification.SILENCE and transcript_parts:
|
if result.audio_type == AudioClassification.SILENCE and transcript_parts:
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class RecordingService:
|
|||||||
self._max_recording_seconds = max_recording_seconds
|
self._max_recording_seconds = max_recording_seconds
|
||||||
self._sample_rate = sample_rate
|
self._sample_rate = sample_rate
|
||||||
self._active_recordings: dict[str, RecordingSession] = {}
|
self._active_recordings: dict[str, RecordingSession] = {}
|
||||||
|
self._timeout_tasks: dict[str, asyncio.Task] = {}
|
||||||
self._metadata: list[dict] = []
|
self._metadata: list[dict] = []
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
@@ -101,8 +102,8 @@ class RecordingService:
|
|||||||
self._active_recordings[call_id] = session
|
self._active_recordings[call_id] = session
|
||||||
logger.info(f"🔴 Recording started: {call_id} → {filepath_mixed}")
|
logger.info(f"🔴 Recording started: {call_id} → {filepath_mixed}")
|
||||||
|
|
||||||
# Safety timeout
|
# Safety timeout — tracked so it can be cancelled and isn't GC'd
|
||||||
asyncio.create_task(
|
self._timeout_tasks[call_id] = asyncio.create_task(
|
||||||
self._recording_timeout(call_id),
|
self._recording_timeout(call_id),
|
||||||
name=f"rec_timeout_{call_id}",
|
name=f"rec_timeout_{call_id}",
|
||||||
)
|
)
|
||||||
@@ -115,6 +116,14 @@ class RecordingService:
|
|||||||
media_pipeline=None,
|
media_pipeline=None,
|
||||||
) -> Optional["RecordingSession"]:
|
) -> Optional["RecordingSession"]:
|
||||||
"""Stop recording a call and finalize the WAV file."""
|
"""Stop recording a call and finalize the WAV file."""
|
||||||
|
timeout_task = self._timeout_tasks.pop(call_id, None)
|
||||||
|
if (
|
||||||
|
timeout_task is not None
|
||||||
|
and timeout_task is not asyncio.current_task()
|
||||||
|
and not timeout_task.done()
|
||||||
|
):
|
||||||
|
timeout_task.cancel()
|
||||||
|
|
||||||
session = self._active_recordings.pop(call_id, None)
|
session = self._active_recordings.pop(call_id, None)
|
||||||
if not session:
|
if not session:
|
||||||
logger.warning(f" No active recording for {call_id}")
|
logger.warning(f" No active recording for {call_id}")
|
||||||
|
|||||||
173
tests/test_concurrency.py
Normal file
173
tests/test_concurrency.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"""
|
||||||
|
Thread-ownership and task-hygiene tests.
|
||||||
|
|
||||||
|
Covers the Sippy→loop event funnel (events posted from a foreign
|
||||||
|
thread mutate loop-owned state), the AudioTap thread-safe feed, the
|
||||||
|
off-loop classifier entry point, leg-state propagation into the
|
||||||
|
CallManager, and background-task cancellation on gateway stop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from config import ClassifierSettings, Settings
|
||||||
|
from core.gateway import AIPSTNGateway
|
||||||
|
from core.media_pipeline import AudioTap
|
||||||
|
from core.sippy_engine import SippyEngine
|
||||||
|
from models.call import CallStatus
|
||||||
|
from services.audio_classifier import AudioClassifier
|
||||||
|
|
||||||
|
|
||||||
|
def _engine_on_loop() -> SippyEngine:
|
||||||
|
"""Engine wired to the running loop without starting the SIP stack."""
|
||||||
|
engine = SippyEngine()
|
||||||
|
engine._loop = asyncio.get_running_loop()
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
def _post_from_thread(engine: SippyEngine, kind: str, data: dict) -> None:
|
||||||
|
"""Post a funnel event from a foreign thread, like the Sippy ED thread."""
|
||||||
|
t = threading.Thread(target=engine._post_from_ed, args=(kind, data))
|
||||||
|
t.start()
|
||||||
|
t.join()
|
||||||
|
|
||||||
|
|
||||||
|
class TestEngineEventFunnel:
|
||||||
|
async def test_register_and_deregister_from_foreign_thread(self):
|
||||||
|
engine = _engine_on_loop()
|
||||||
|
|
||||||
|
_post_from_thread(engine, "register", {
|
||||||
|
"aor": "sip:alice@gw", "contact": "sip:alice@10.0.0.5", "expires": 3600,
|
||||||
|
})
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert engine._registered_devices == [
|
||||||
|
{"aor": "sip:alice@gw", "contact": "sip:alice@10.0.0.5", "expires": 3600}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Re-register updates in place instead of duplicating
|
||||||
|
_post_from_thread(engine, "register", {
|
||||||
|
"aor": "sip:alice@gw", "contact": "sip:alice@10.0.0.9", "expires": 60,
|
||||||
|
})
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert len(engine._registered_devices) == 1
|
||||||
|
assert engine._registered_devices[0]["contact"] == "sip:alice@10.0.0.9"
|
||||||
|
|
||||||
|
_post_from_thread(engine, "deregister", {"aor": "sip:alice@gw"})
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert engine._registered_devices == []
|
||||||
|
|
||||||
|
async def test_incoming_invite_auto_answers_without_callback(self):
|
||||||
|
engine = _engine_on_loop()
|
||||||
|
_post_from_thread(engine, "incoming_invite", {
|
||||||
|
"leg_id": "leg_test1",
|
||||||
|
"from_uri": "sip:caller@pstn",
|
||||||
|
"to_uri": "sip:+15551234567@gw",
|
||||||
|
"sdp": None,
|
||||||
|
})
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
leg = engine._legs["leg_test1"]
|
||||||
|
assert leg.direction == "inbound"
|
||||||
|
assert leg.state == "connected"
|
||||||
|
|
||||||
|
async def test_incoming_call_callback_runs_as_tracked_task(self):
|
||||||
|
engine = _engine_on_loop()
|
||||||
|
seen = asyncio.Event()
|
||||||
|
|
||||||
|
async def on_incoming(from_uri, to_uri, leg_id):
|
||||||
|
seen.set()
|
||||||
|
|
||||||
|
engine._on_incoming_call = on_incoming
|
||||||
|
_post_from_thread(engine, "incoming_invite", {
|
||||||
|
"leg_id": "leg_test2", "from_uri": "a", "to_uri": "b", "sdp": None,
|
||||||
|
})
|
||||||
|
await asyncio.wait_for(seen.wait(), timeout=1.0)
|
||||||
|
assert engine._legs["leg_test2"].state == "init" # not auto-answered
|
||||||
|
|
||||||
|
async def test_bye_terminates_leg_and_notifies(self):
|
||||||
|
engine = _engine_on_loop()
|
||||||
|
states: list[tuple[str, str]] = []
|
||||||
|
engine._on_leg_state_change = lambda leg_id, state: states.append((leg_id, state))
|
||||||
|
|
||||||
|
_post_from_thread(engine, "incoming_invite", {
|
||||||
|
"leg_id": "leg_test3", "from_uri": "a", "to_uri": "b", "sdp": None,
|
||||||
|
})
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
_post_from_thread(engine, "leg_state", {"leg_id": "leg_test3", "state": "terminated"})
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
assert engine._legs["leg_test3"].state == "terminated"
|
||||||
|
assert ("leg_test3", "terminated") in states
|
||||||
|
|
||||||
|
async def test_dtmf_and_trunk_events(self):
|
||||||
|
engine = _engine_on_loop()
|
||||||
|
_post_from_thread(engine, "incoming_invite", {
|
||||||
|
"leg_id": "leg_test4", "from_uri": "a", "to_uri": "b", "sdp": None,
|
||||||
|
})
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
_post_from_thread(engine, "dtmf", {"leg_id": "leg_test4", "digit": "5"})
|
||||||
|
_post_from_thread(engine, "trunk_registered", {"registered": True})
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert engine._legs["leg_test4"].dtmf_buffer == ["5"]
|
||||||
|
assert engine._trunk_registered is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudioTapThreadSafety:
|
||||||
|
async def test_feed_from_foreign_thread_reaches_reader(self):
|
||||||
|
tap = AudioTap("leg_x")
|
||||||
|
frame = b"\x01\x02" * 320
|
||||||
|
|
||||||
|
t = threading.Thread(target=tap.feed, args=(frame,))
|
||||||
|
t.start()
|
||||||
|
t.join()
|
||||||
|
|
||||||
|
received = await tap.read_frame(timeout=1.0)
|
||||||
|
assert received == frame
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassifierOffLoop:
|
||||||
|
async def test_classify_runs_and_records_history(self):
|
||||||
|
classifier = AudioClassifier(ClassifierSettings())
|
||||||
|
silence = np.zeros(16000, dtype=np.int16).tobytes()
|
||||||
|
|
||||||
|
result = await classifier.classify(silence)
|
||||||
|
|
||||||
|
assert result.audio_type.value == "silence"
|
||||||
|
assert classifier._classification_history == [result.audio_type]
|
||||||
|
|
||||||
|
|
||||||
|
class TestLegStatePropagation:
|
||||||
|
async def test_leg_lifecycle_drives_call_status(self):
|
||||||
|
gateway = AIPSTNGateway(settings=Settings(max_concurrent_calls=4))
|
||||||
|
call = await gateway.make_call("+15551234567")
|
||||||
|
assert call.status == CallStatus.RINGING
|
||||||
|
|
||||||
|
(leg_id,) = gateway.call_manager.legs_for_call(call.id)
|
||||||
|
|
||||||
|
await gateway._on_sip_leg_state(leg_id, "connected")
|
||||||
|
assert gateway.get_call(call.id).status == CallStatus.CONNECTED
|
||||||
|
|
||||||
|
await gateway._on_sip_leg_state(leg_id, "terminated")
|
||||||
|
assert gateway.get_call(call.id) is None
|
||||||
|
assert gateway.call_manager.legs_for_call(call.id) == []
|
||||||
|
|
||||||
|
async def test_late_signals_do_not_stomp_service_states(self):
|
||||||
|
gateway = AIPSTNGateway(settings=Settings(max_concurrent_calls=4))
|
||||||
|
call = await gateway.make_call("+15551234567")
|
||||||
|
(leg_id,) = gateway.call_manager.legs_for_call(call.id)
|
||||||
|
|
||||||
|
await gateway.call_manager.update_status(call.id, CallStatus.ON_HOLD)
|
||||||
|
await gateway._on_sip_leg_state(leg_id, "connected")
|
||||||
|
assert gateway.get_call(call.id).status == CallStatus.ON_HOLD
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskHygiene:
|
||||||
|
async def test_gateway_stop_cancels_spawned_tasks(self):
|
||||||
|
gateway = AIPSTNGateway(settings=Settings(max_concurrent_calls=4))
|
||||||
|
task = gateway.spawn(asyncio.sleep(60), name="test_sleeper")
|
||||||
|
|
||||||
|
await gateway.stop()
|
||||||
|
|
||||||
|
assert task.cancelled()
|
||||||
|
assert gateway._tasks == set()
|
||||||
Reference in New Issue
Block a user