From 402df8817aabf70b3c58d988a673645e10dbde2e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:42:45 +0000 Subject: [PATCH 1/8] Initial plan From 9fc9bcdc568173cc4e3650b50fdcfccde4615cf7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 00:01:21 +0000 Subject: [PATCH 2/8] Add RFC 5626 keep-alive, reconnect logic, and start_server Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- docs/cookbook.md | 46 ++++++++- docs/feature_roadmap.md | 4 + docs/rfc_status.md | 2 +- tests/sip/test_protocol.py | 195 +++++++++++++++++++++++++++++++++++-- tests/test_main.py | 109 +++++++++++++++++++++ voip/__main__.py | 94 +++++++++++++++--- voip/sip/protocol.py | 113 +++++++++++++++++++-- 7 files changed, 535 insertions(+), 28 deletions(-) diff --git a/docs/cookbook.md b/docs/cookbook.md index acfb71c..e93eaff 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -11,7 +11,7 @@ import asyncio import ssl from voip.ai import TranscribeCall -from voip.sip.protocol import SIP +from voip.sip.protocol import SIP, start_server class MyCall(TranscribeCall): @@ -42,6 +42,50 @@ async def main(): asyncio.run(main()) ``` +## SIP Server (accepting incoming connections) + +Use [`start_server`][voip.sip.protocol.start_server] to listen for incoming +SIP connections instead of connecting outbound to a carrier. This is useful +for testing, local PBX setups, or any scenario where SIP clients connect +directly to your application: + +```python +import asyncio + +from voip.ai import TranscribeCall +from voip.sip.protocol import SIP, start_server + + +class MyCall(TranscribeCall): + def transcription_received(self, text: str) -> None: + print(f"[{self.caller}] {text}") + + +class MySession(SIP): + def call_received(self, request) -> None: + asyncio.create_task(self.answer(request=request, call_class=MyCall)) + + +async def main(): + server = await start_server( + lambda: MySession(aor="sip:alice@0.0.0.0"), + host="0.0.0.0", + port=5060, + ) + async with server: + print("Listening for SIP calls on port 5060…") + await server.serve_forever() + + +asyncio.run(main()) +``` + +To start a server from the CLI, pass `--listen HOST:PORT`: + +```bash +voip sip --password=secret --listen 0.0.0.0:5060 sip:alice@myhost.com echo +``` + ## Sharing a Whisper Model Across Calls Loading the model is expensive. Pass a pre-loaded diff --git a/docs/feature_roadmap.md b/docs/feature_roadmap.md index 283dee7..cb07f8f 100644 --- a/docs/feature_roadmap.md +++ b/docs/feature_roadmap.md @@ -8,6 +8,10 @@ SIP User Agent Client (UAC) over TLS/TCP ([RFC 3261]). Handles incoming `INVITE`, `BYE`, `ACK`, `CANCEL`, and `OPTIONS` requests, carrier `REGISTER` with digest authentication ([RFC 8760]: MD5, SHA-256, SHA-512/256), and double-CRLF keepalive ping/pong ([RFC 5626 §4.4.1]). +Client-initiated keepalive pings, `Supported: outbound` and `;ob` Contact +parameter ([RFC 5626 §5]), and automatic reconnection with exponential +back-off ensure robust long-running sessions. The `start_server` helper +enables accepting inbound SIP connections directly, without a carrier. ### Media Transport (RTP/SRTP) diff --git a/docs/rfc_status.md b/docs/rfc_status.md index 7ff7c72..36b359c 100644 --- a/docs/rfc_status.md +++ b/docs/rfc_status.md @@ -5,7 +5,7 @@ | RFC | Title | Status | Notes | | --------------------------------------------------------- | --------------------------------------------- | -------- | ----------------------------------------------------------------------- | | [RFC 3261](https://datatracker.ietf.org/doc/html/rfc3261) | SIP: Session Initiation Protocol | Partial | UAC only; REGISTER, INVITE, BYE, and digest authentication over TLS/TCP | -| [RFC 5626](https://datatracker.ietf.org/doc/html/rfc5626) | Managing Client-Initiated Connections in SIP | Partial | Double-CRLF keepalive ping/pong (§4.4.1) | +| [RFC 5626](https://datatracker.ietf.org/doc/html/rfc5626) | Managing Client-Initiated Connections in SIP | Complete | Double-CRLF keepalive ping/pong (§4.4.1); client keepalive task; `Supported: outbound` and `;ob` Contact parameter (§5); reconnect with exponential back-off | | [RFC 8760](https://datatracker.ietf.org/doc/html/rfc8760) | SIP Digest Authentication Using AES-HMAC-SHA2 | Complete | MD5, SHA-256, and SHA-512/256 digest responses | | [RFC 3824](https://datatracker.ietf.org/doc/html/rfc3824) | Using E.164 Numbers with SIP | Planned | Phone number mapping into SIP/ENUM | | [RFC 3966](https://datatracker.ietf.org/doc/html/rfc3966) | The tel URI for Telephone Numbers | Planned | Canonical `tel:` URI scheme | diff --git a/tests/sip/test_protocol.py b/tests/sip/test_protocol.py index 2cae877..dbc10a4 100644 --- a/tests/sip/test_protocol.py +++ b/tests/sip/test_protocol.py @@ -18,6 +18,7 @@ SessionInitiationProtocol, _format_host, _mask_caller, + start_server, ) from voip.sip.types import CallerID, DigestAlgorithm, SIPStatus @@ -1759,9 +1760,11 @@ async def test_register__includes_required_headers(self): (data,) = transport.write.call_args[0] assert b"From: sip:alice@example.com" in data assert b"To: sip:alice@example.com" in data - # sip: AOR over TLS → sip:;transport=tls Contact - assert b"Contact: " in data + # sip: AOR over TLS → sip:;transport=tls Contact with RFC 5626 ;ob parameter + assert b"Contact: " in data assert b"Expires: 3600" in data + # RFC 5626 §5 outbound keep-alive support advertised + assert b"Supported: outbound" in data async def test_register__increments_cseq(self): """CSeq increments with each REGISTER sent.""" @@ -1936,7 +1939,7 @@ async def test_register__via_branch_is_unique_per_request(self): assert branch1 != branch2 async def test_register__contact_uses_local_addr(self): - """Contact header uses sip:;transport=tls when AOR is sip: over TLS.""" + """Contact header uses sip:;transport=tls;ob when AOR is sip: over TLS.""" p = make_register_session() p.local_address = (ipaddress.IPv4Address("10.0.0.5"), 5061) transport = make_mock_transport("10.0.0.5", 5061) @@ -1944,10 +1947,10 @@ async def test_register__contact_uses_local_addr(self): p._is_tls = True await p.register() (data,) = transport.write.call_args[0] - assert b"Contact: " in data + assert b"Contact: " in data async def test_register__contact_uses_sips_when_aor_is_sips(self): - """Contact header uses sips: when AOR scheme is sips:.""" + """Contact header uses sips: with ;ob when AOR scheme is sips:.""" p = make_register_session(aor="sips:alice@example.com") p.local_address = (ipaddress.IPv4Address("10.0.0.5"), 5061) transport = make_mock_transport("10.0.0.5", 5061) @@ -1955,7 +1958,7 @@ async def test_register__contact_uses_sips_when_aor_is_sips(self): p._is_tls = True await p.register() (data,) = transport.write.call_args[0] - assert b"Contact: " in data + assert b"Contact: " in data async def test_register__contact_wraps_ipv6_in_brackets(self): """Contact header wraps an IPv6 local address in square brackets.""" @@ -1966,7 +1969,7 @@ async def test_register__contact_wraps_ipv6_in_brackets(self): p._is_tls = True await p.register() (data,) = transport.write.call_args[0] - assert b"Contact: " in data + assert b"Contact: " in data async def test_register__via_wraps_ipv6_in_brackets(self): """Via header wraps an IPv6 local address in square brackets.""" @@ -2268,3 +2271,181 @@ def test_unsupported_algorithm_raises(self): uri="sip:example.com", algorithm="BLAKE2b", ) + + +# --------------------------------------------------------------------------- +# Tests for RFC 5626 keep-alive pings +# --------------------------------------------------------------------------- + + +class TestKeepalive: + """Tests for RFC 5626 §4.4.1 client-initiated keep-alive pings.""" + + @pytest.mark.asyncio + async def test_run_keepalive__sends_double_crlf(self): + """Keep-alive task sends double-CRLF after the configured interval.""" + p = SessionInitiationProtocol( + outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" + ) + transport = MagicMock() + p.transport = transport + p.keepalive_interval_secs = 0.01 + task = asyncio.get_running_loop().create_task(p._run_keepalive()) + await asyncio.sleep(0.05) + task.cancel() + transport.write.assert_called_with(b"\r\n\r\n") + + @pytest.mark.asyncio + async def test_run_keepalive__stops_when_transport_is_none(self): + """Keep-alive loop exits cleanly when the transport is cleared.""" + p = SessionInitiationProtocol( + outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" + ) + transport = MagicMock() + p.transport = transport + p.keepalive_interval_secs = 0.01 + # Clear the transport before the first ping fires. + p.transport = None + task = asyncio.get_running_loop().create_task(p._run_keepalive()) + await asyncio.sleep(0.05) + assert task.done() + transport.write.assert_not_called() + + @pytest.mark.asyncio + async def test_connection_made__starts_keepalive_task(self): + """connection_made starts a keep-alive task.""" + p = SessionInitiationProtocol( + outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" + ) + transport = MagicMock() + transport.get_extra_info.side_effect = lambda key, default=None: { + "sockname": ("127.0.0.1", 5061), + "ssl_object": None, + }.get(key, default) + + async def run(): + p.connection_made(transport) + assert p._keepalive_task is not None + p._keepalive_task.cancel() + p._initialize_task.cancel() + + await run() + + def test_connection_lost__cancels_keepalive_task(self): + """connection_lost cancels any running keep-alive task.""" + p = SessionInitiationProtocol( + outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" + ) + mock_task = MagicMock() + p._keepalive_task = mock_task + p.connection_lost(None) + mock_task.cancel.assert_called_once() + assert p._keepalive_task is None + + def test_connection_lost__sets_disconnected_event(self): + """connection_lost sets the _disconnected_event.""" + p = SessionInitiationProtocol( + outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" + ) + assert not p._disconnected_event.is_set() + p.connection_lost(None) + assert p._disconnected_event.is_set() + + @pytest.mark.asyncio + async def test_disconnected_event__can_be_awaited(self): + """_disconnected_event resolves once connection_lost is called.""" + p = SessionInitiationProtocol( + outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" + ) + + async def lose_connection(): + await asyncio.sleep(0.01) + p.connection_lost(None) + + asyncio.create_task(lose_connection()) + await asyncio.wait_for(p._disconnected_event.wait(), timeout=1.0) + assert p._disconnected_event.is_set() + + +# --------------------------------------------------------------------------- +# Tests for start_server +# --------------------------------------------------------------------------- + + +class TestStartServer: + @pytest.mark.asyncio + async def test_start_server__returns_asyncio_server(self): + """start_server returns a running asyncio.Server listening on the given port.""" + p = SessionInitiationProtocol( + outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" + ) + server = await start_server( + lambda: p, + host="127.0.0.1", + port=0, # OS assigns a free port + ) + assert isinstance(server, asyncio.Server) + assert server.is_serving() + server.close() + await server.wait_closed() + + @pytest.mark.asyncio + async def test_start_server__accepts_connection(self): + """start_server accepts an incoming TCP connection.""" + connected = asyncio.Event() + + class PingSession(SessionInitiationProtocol): + def connection_made(self, transport) -> None: + connected.set() + + server = await start_server( + lambda: PingSession(aor="sip:test@example.com"), + host="127.0.0.1", + port=0, + ) + port = server.sockets[0].getsockname()[1] + reader, writer = await asyncio.open_connection("127.0.0.1", port) + await asyncio.wait_for(connected.wait(), timeout=1.0) + assert connected.is_set() + writer.close() + await writer.wait_closed() + server.close() + await server.wait_closed() + + +# --------------------------------------------------------------------------- +# Tests for _build_contact with RFC 5626 ;ob parameter +# --------------------------------------------------------------------------- + + +class TestBuildContactOb: + def test_build_contact__ob_false__no_ob_param(self): + """Contact without ob=True has no ;ob parameter.""" + p = SessionInitiationProtocol( + outbound_proxy=("127.0.0.1", 5061), aor="sip:alice@example.com" + ) + p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) + p._is_tls = True + contact = p._build_contact("alice") + assert ";ob" not in contact + + def test_build_contact__ob_true__includes_ob_param(self): + """Contact with ob=True includes the ;ob URI parameter.""" + p = SessionInitiationProtocol( + outbound_proxy=("127.0.0.1", 5061), aor="sip:alice@example.com" + ) + p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) + p._is_tls = True + contact = p._build_contact("alice", ob=True) + assert ";ob" in contact + assert contact == "" + + def test_build_contact__sips_with_ob(self): + """sips: Contact with ob=True includes ;ob inside the angle brackets.""" + p = SessionInitiationProtocol( + outbound_proxy=("127.0.0.1", 5061), aor="sips:alice@example.com" + ) + p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) + p._is_tls = True + contact = p._build_contact("alice", ob=True) + assert contact == "" diff --git a/tests/test_main.py b/tests/test_main.py index ee0d55e..3c3acd3 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -805,3 +805,112 @@ async def run(): assert isinstance(kwargs["call_class"], type) asyncio.run(run()) + + +class TestReconnect: + def test_connect_sip__retries_on_os_error(self): + """_connect_sip retries when the connection fails with OSError.""" + from unittest.mock import AsyncMock + + call_count = 0 + + async def fake_connection(factory, *, host, port, ssl): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise OSError("Connection refused") + raise KeyboardInterrupt + + with ( + patch.dict(sys.modules, _WHISPER_STUBS), + patch("asyncio.get_event_loop"), + patch("voip.__main__.asyncio.get_running_loop") as mock_loop, + patch("voip.__main__.asyncio.sleep", new=AsyncMock()), + ): + mock_loop.return_value.create_connection = fake_connection + make_runner().invoke( + voip, + [ + "sip", + "--password=p", + "--stun-server=none", + "sips:alice@example.com", + "transcribe", + ], + catch_exceptions=False, + ) + assert call_count == 3 + + def test_connect_sip__reconnects_after_disconnect(self): + """_connect_sip reconnects when the connection drops (disconnected_event set).""" + from unittest.mock import AsyncMock + + call_count = 0 + + async def fake_connection(factory, *, host, port, ssl): + nonlocal call_count + call_count += 1 + protocol = factory() + if call_count == 1: + # Simulate immediate disconnect. + protocol._disconnected_event.set() + return MagicMock(), protocol + raise KeyboardInterrupt + + with ( + patch.dict(sys.modules, _WHISPER_STUBS), + patch("asyncio.get_event_loop"), + patch("voip.__main__.asyncio.get_running_loop") as mock_loop, + patch("voip.__main__.asyncio.sleep", new=AsyncMock()), + ): + mock_loop.return_value.create_connection = fake_connection + make_runner().invoke( + voip, + [ + "sip", + "--password=p", + "--stun-server=none", + "sips:alice@example.com", + "transcribe", + ], + catch_exceptions=False, + ) + assert call_count == 2 + + +class TestListenMode: + def test_sip__listen_option_uses_start_server(self): + """--listen causes the command to call start_server instead of create_connection.""" + from unittest.mock import AsyncMock + + captured = {} + + async def fake_create_server(factory, host, port, ssl): + captured["host"] = host + captured["port"] = port + server = MagicMock() + server.__aenter__ = AsyncMock(return_value=server) + server.__aexit__ = AsyncMock(return_value=None) + server.serve_forever = AsyncMock(side_effect=KeyboardInterrupt) + return server + + with ( + patch.dict(sys.modules, _WHISPER_STUBS), + patch("asyncio.get_event_loop"), + patch("voip.__main__.asyncio.get_running_loop") as mock_loop, + ): + mock_loop.return_value.create_server = fake_create_server + make_runner().invoke( + voip, + [ + "sip", + "--password=p", + "--stun-server=none", + "--listen=0.0.0.0:5060", + "sips:alice@example.com", + "echo", + ], + catch_exceptions=False, + ) + assert captured.get("host") == "0.0.0.0" # noqa: S104 + assert captured.get("port") == 5060 diff --git a/voip/__main__.py b/voip/__main__.py index b0f4510..985c18e 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -8,7 +8,7 @@ import time from voip.sip import messages -from voip.sip.protocol import SessionInitiationProtocol +from voip.sip.protocol import SessionInitiationProtocol, start_server from voip.sip.types import SipUri try: @@ -24,6 +24,9 @@ ) from e +logger = logging.getLogger("voip") + + #: Standard SIP/TCP port — plain text, no TLS (RFC 3261 §18.2). SIP_TCP_PORT = 5060 #: Standard SIP/TLS port (RFC 3261 §26.2.2). @@ -170,6 +173,16 @@ def voip(ctx, verbose: int = 0): "Use this when the proxy differs from the registrar domain." ), ) +@click.option( + "--listen", + envvar="SIP_LISTEN", + default=None, + metavar="HOST[:PORT]", + help=( + "Start a SIP server listening for incoming connections instead of " + "connecting outbound to a carrier. Example: --listen 0.0.0.0:5060" + ), +) @click.option( "--stun-server", envvar="STUN_SERVER", @@ -196,7 +209,7 @@ def voip(ctx, verbose: int = 0): help="Disable TLS certificate verification (insecure; for testing only).", ) @click.pass_context -def sip(ctx, aor, password, username, proxy, stun_server, no_tls, no_verify_tls): +def sip(ctx, aor, password, username, proxy, listen, stun_server, no_tls, no_verify_tls): """Session Initiation Protocol (SIP).""" ctx.ensure_object(dict) try: @@ -218,6 +231,12 @@ def sip(ctx, aor, password, username, proxy, stun_server, no_tls, no_verify_tls) port = parsed_aor.port if parsed_aor.port is not None else default_port proxy_addr = (parsed_aor.host, port) + listen_addr = ( + _parse_hostport(ctx, None, listen, default_port=SIP_TCP_PORT) + if listen is not None + else None + ) + use_tls = not no_tls and proxy_addr[1] != SIP_TCP_PORT # Build the canonical AOR; IPv6 hosts must be enclosed in brackets per RFC 2732. host_in_aor = ( @@ -232,6 +251,7 @@ def sip(ctx, aor, password, username, proxy, stun_server, no_tls, no_verify_tls) username=effective_username, password=password, proxy_addr=proxy_addr, + listen_addr=listen_addr, stun_server=stun_server, use_tls=use_tls, no_verify_tls=no_verify_tls, @@ -244,7 +264,12 @@ async def _connect_sip( use_tls: bool, no_verify_tls: bool, ) -> None: - """Connect to a SIP proxy and wait indefinitely.""" + """Connect to a SIP proxy with automatic reconnection on failure. + + Retries with exponential back-off (1 s → 2 s → … → 60 s) after each + failed connection or dropped session so the process stays running without + manual intervention. + """ loop = asyncio.get_running_loop() ssl_context: ssl.SSLContext | None = None if use_tls: @@ -252,13 +277,57 @@ async def _connect_sip( if no_verify_tls: ssl_context.check_hostname = False ssl_context.verify_mode = ssl.CERT_NONE - await loop.create_connection( + backoff_secs = 1 + while True: + try: + _, protocol = await loop.create_connection( + session_factory, + host=str(proxy_addr[0]), + port=proxy_addr[1], + ssl=ssl_context, + ) + backoff_secs = 1 + await protocol._disconnected_event.wait() + logger.info( + "SIP connection closed; reconnecting in %s s", backoff_secs + ) + except (OSError, ssl.SSLError) as exc: + logger.warning( + "SIP connection failed (%s); retrying in %s s", exc, backoff_secs + ) + await asyncio.sleep(backoff_secs) + backoff_secs = min(backoff_secs * 2, 60) + + +async def _serve_sip( + session_factory, + listen_addr: tuple[str | ipaddress.IPv4Address | ipaddress.IPv6Address, int], +) -> None: + """Start a SIP server and listen indefinitely for incoming connections.""" + host, port = listen_addr + server = await start_server( session_factory, - host=str(proxy_addr[0]), - port=proxy_addr[1], - ssl=ssl_context, + host=host, + port=port, ) - await asyncio.Future() + async with server: + logger.info("SIP server listening on %s:%s", host, port) + await server.serve_forever() + + +async def _run_sip( + session_factory, + proxy_addr: tuple[ipaddress.IPv4Address | ipaddress.IPv6Address | str, int], + listen_addr: tuple[str | ipaddress.IPv4Address | ipaddress.IPv6Address, int] + | None, + use_tls: bool, + no_verify_tls: bool, +) -> None: + """Start SIP in server mode when *listen_addr* is set, else connect outbound.""" + if listen_addr is not None: + await _serve_sip(session_factory, listen_addr) + else: + await _connect_sip(session_factory, proxy_addr, use_tls, no_verify_tls) @sip.command() @@ -278,7 +347,7 @@ def call_received(self, request) -> None: asyncio.create_task(self.answer(request=request, call_class=EchoCall)) async def run(): - await _connect_sip( + await _run_sip( lambda: EchoSession( outbound_proxy=proxy_addr, aor=obj["aor"], @@ -287,6 +356,7 @@ async def run(): rtp_stun_server_address=obj["stun_server"], ), proxy_addr, + obj.get("listen_addr"), obj["use_tls"], obj["no_verify_tls"], ) @@ -336,7 +406,7 @@ def call_received(self, request) -> None: ) async def run(): - await _connect_sip( + await _run_sip( lambda: TranscribeSession( outbound_proxy=proxy_addr, aor=obj["aor"], @@ -345,6 +415,7 @@ async def run(): rtp_stun_server_address=obj["stun_server"], ), proxy_addr, + obj.get("listen_addr"), obj["use_tls"], obj["no_verify_tls"], ) @@ -439,7 +510,7 @@ def call_received(self, request) -> None: ) async def run(): - await _connect_sip( + await _run_sip( lambda: AgentSession( outbound_proxy=proxy_addr, aor=obj["aor"], @@ -448,6 +519,7 @@ async def run(): rtp_stun_server_address=obj["stun_server"], ), proxy_addr, + obj.get("listen_addr"), obj["use_tls"], obj["no_verify_tls"], ) diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index e9fdb1b..46a721b 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -16,6 +16,7 @@ import re import secrets import socket +import ssl import typing import uuid @@ -36,7 +37,7 @@ logger = logging.getLogger("voip.sip") -__all__ = ["RegistrationError", "SIP", "SessionInitiationProtocol"] +__all__ = ["RegistrationError", "SIP", "SessionInitiationProtocol", "start_server"] def _format_host(host: str | ipaddress.IPv4Address | ipaddress.IPv6Address) -> str: @@ -155,10 +156,14 @@ def call_received(self, request: Request) -> None: init=False, default=None ) _initialize_task: asyncio.Task | None = dataclasses.field(init=False, default=None) + _keepalive_task: asyncio.Task | None = dataclasses.field(init=False, default=None) _call_rtp_addrs: dict[str, tuple[str, int] | None] = dataclasses.field( init=False, default_factory=dict ) _buffer: bytearray = dataclasses.field(init=False, default_factory=bytearray) + _disconnected_event: asyncio.Event = dataclasses.field( + init=False, default_factory=asyncio.Event + ) #: RFC 3261 §8.1.2 — outbound SIP proxy address ``(host, port)``. #: When ``None`` the caller connects directly to the registrar server. #: The address may differ from the registrar domain derived from @@ -171,6 +176,10 @@ def call_received(self, request: Request) -> None: password: str | None = None #: STUN server used for RTP NAT traversal (SIP uses TLS/TCP; no STUN needed). rtp_stun_server_address: tuple[str, int] | None = ("stun.cloudflare.com", 3478) + #: RFC 5626 §4.4.1 keep-alive ping interval in seconds. + #: Pings are sent as double-CRLF (``\\r\\n\\r\\n``) over the TLS/TCP connection. + #: RFC 5626 §10 recommends at most 90 seconds; 30 s is a safe default for most NATs. + keepalive_interval_secs: float = 30.0 call_id: str = dataclasses.field(init=False) cseq: int = dataclasses.field(init=False, default=0) #: Local TCP socket address (host, port) — set when connection is established. @@ -194,9 +203,9 @@ def connection_made(self, transport: asyncio.Transport) -> None: # type: ignore self.local_address = (ipaddress.ip_address(host), port) self._is_tls = transport.get_extra_info("ssl_object") is not None try: - self._initialize_task = asyncio.get_running_loop().create_task( - self._initialize() - ) + loop = asyncio.get_running_loop() + self._initialize_task = loop.create_task(self._initialize()) + self._keepalive_task = loop.create_task(self._run_keepalive()) except RuntimeError: pass # no running loop in synchronous test setups @@ -215,6 +224,21 @@ async def _initialize(self) -> None: ) await self.register() + async def _run_keepalive(self) -> None: + r"""Periodically send RFC 5626 §4.4.1 keep-alive pings over the connection. + + Sends a double-CRLF (``\r\n\r\n``) every + [`keepalive_interval_secs`][voip.sip.protocol.SessionInitiationProtocol.keepalive_interval_secs] + seconds to keep the TCP connection and any intermediate NAT mappings + alive. The remote peer responds with a single CRLF (``\r\n``). + """ + while True: + await asyncio.sleep(self.keepalive_interval_secs) + if self.transport is None: + return + logger.debug("Sending RFC 5626 §4.4.1 keep-alive ping") + self.transport.write(b"\r\n\r\n") + def data_received(self, data: bytes) -> None: self._buffer.extend(data) while True: @@ -694,7 +718,7 @@ def _with_to_tag(self, headers: dict[str, str], call_id: str) -> dict[str, str]: "To": headers.get("To", "") + (f";tag={tag}" if tag else ""), } - def _build_contact(self, user: str | None = None) -> str: + def _build_contact(self, user: str | None = None, *, ob: bool = False) -> str: """Return a ``Contact:`` header value for this UA. The URI scheme mirrors `aor`: a ``sips:`` AOR produces a @@ -702,18 +726,27 @@ def _build_contact(self, user: str | None = None) -> str: TLS produces ``sip:`` with ``transport=tls``; plain TCP produces plain ``sip:``. + When *ob* is ``True`` the ``ob`` URI parameter ([RFC 5626 §5]) is + appended inside the angle brackets to advertise outbound keep-alive + support to the registrar. + + [RFC 5626 §5]: https://datatracker.ietf.org/doc/html/rfc5626#section-5 + Args: user: SIP user part (e.g. ``"alice"``). When provided the Contact is of the form ````; otherwise just ````. + ob: Include the ``ob`` URI parameter (RFC 5626 §5) to indicate + outbound keep-alive support. """ aor_scheme = self.aor.partition(":")[0] # "sip" or "sips" host_port = f"{_format_host(self.local_address[0])}:{self.local_address[1]}" addr = f"{user}@{host_port}" if user else host_port + ob_param = ";ob" if ob else "" if aor_scheme == "sips": - return f"" + return f"" tls_param = ";transport=tls" if self._is_tls else "" - return f"" + return f"" def ringing(self, request: Request) -> None: """Send a 180 Ringing provisional response to the caller. @@ -861,9 +894,10 @@ async def register( "To": self.aor, "Call-ID": self.call_id, "CSeq": f"{self.cseq} REGISTER", - "Contact": self._build_contact(user), + "Contact": self._build_contact(user, ob=True), "Expires": "3600", # 1 hour "Max-Forwards": "70", + "Supported": "outbound", # RFC 5626 §5 — outbound keep-alive support } if authorization is not None: headers["Authorization"] = authorization @@ -945,7 +979,70 @@ def connection_lost(self, exc: Exception | None) -> None: """Handle a lost TLS/TCP connection.""" if exc is not None: logger.exception("Connection lost", exc_info=exc) + if self._keepalive_task is not None: + self._keepalive_task.cancel() + self._keepalive_task = None self.transport = None + self._disconnected_event.set() + + +async def start_server( + session_factory: typing.Callable[[], SessionInitiationProtocol], + host: str | ipaddress.IPv4Address | ipaddress.IPv6Address | None = None, + port: int = 5060, + *, + ssl_context: ssl.SSLContext | None = None, +) -> asyncio.Server: + """Start a SIP TCP server that accepts incoming connections. + + Each accepted TCP connection receives a new protocol instance created by + *session_factory*. Pass an [`ssl.SSLContext`][ssl.SSLContext] to enable + TLS (recommended on port 5061). + + Example: + ```python + import asyncio + import ssl + + from voip.sip.protocol import SIP, start_server + + + class MySession(SIP): + def call_received(self, request) -> None: + asyncio.create_task(self.answer(request=request, call_class=MyCall)) + + + async def main(): + server = await start_server( + lambda: MySession(aor="sip:bob@0.0.0.0"), + host="0.0.0.0", + port=5060, + ) + async with server: + await server.serve_forever() + + + asyncio.run(main()) + ``` + + Args: + session_factory: Callable returning a new + [`SessionInitiationProtocol`][voip.sip.protocol.SessionInitiationProtocol] + instance for each incoming connection. + host: Interface to bind to. ``None`` binds to all interfaces. + port: TCP port to listen on (default: 5060). + ssl_context: Optional TLS context. When provided, the server + accepts TLS connections (recommended on port 5061). + + Returns: + A running [`asyncio.Server`][asyncio.Server] instance. + """ + return await asyncio.get_running_loop().create_server( + session_factory, + host=str(host) if host is not None else None, + port=port, + ssl=ssl_context, + ) #: Short alias for `SessionInitiationProtocol`. From 18adfa3419b24fcc20919a119ca3af0889841559 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 00:03:11 +0000 Subject: [PATCH 3/8] Address code review: rename _disconnected_event to disconnected_event, ob_param to ob_uri_param Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- tests/sip/test_protocol.py | 12 ++++++------ tests/test_main.py | 2 +- voip/__main__.py | 2 +- voip/sip/protocol.py | 10 +++++----- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/sip/test_protocol.py b/tests/sip/test_protocol.py index dbc10a4..cf56cce 100644 --- a/tests/sip/test_protocol.py +++ b/tests/sip/test_protocol.py @@ -2343,17 +2343,17 @@ def test_connection_lost__cancels_keepalive_task(self): assert p._keepalive_task is None def test_connection_lost__sets_disconnected_event(self): - """connection_lost sets the _disconnected_event.""" + """connection_lost sets the disconnected_event.""" p = SessionInitiationProtocol( outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" ) - assert not p._disconnected_event.is_set() + assert not p.disconnected_event.is_set() p.connection_lost(None) - assert p._disconnected_event.is_set() + assert p.disconnected_event.is_set() @pytest.mark.asyncio async def test_disconnected_event__can_be_awaited(self): - """_disconnected_event resolves once connection_lost is called.""" + """disconnected_event resolves once connection_lost is called.""" p = SessionInitiationProtocol( outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" ) @@ -2363,8 +2363,8 @@ async def lose_connection(): p.connection_lost(None) asyncio.create_task(lose_connection()) - await asyncio.wait_for(p._disconnected_event.wait(), timeout=1.0) - assert p._disconnected_event.is_set() + await asyncio.wait_for(p.disconnected_event.wait(), timeout=1.0) + assert p.disconnected_event.is_set() # --------------------------------------------------------------------------- diff --git a/tests/test_main.py b/tests/test_main.py index 3c3acd3..bc48fcc 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -853,7 +853,7 @@ async def fake_connection(factory, *, host, port, ssl): protocol = factory() if call_count == 1: # Simulate immediate disconnect. - protocol._disconnected_event.set() + protocol.disconnected_event.set() return MagicMock(), protocol raise KeyboardInterrupt diff --git a/voip/__main__.py b/voip/__main__.py index 985c18e..2e87da5 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -287,7 +287,7 @@ async def _connect_sip( ssl=ssl_context, ) backoff_secs = 1 - await protocol._disconnected_event.wait() + await protocol.disconnected_event.wait() logger.info( "SIP connection closed; reconnecting in %s s", backoff_secs ) diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index 46a721b..d8e1381 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -161,7 +161,7 @@ def call_received(self, request: Request) -> None: init=False, default_factory=dict ) _buffer: bytearray = dataclasses.field(init=False, default_factory=bytearray) - _disconnected_event: asyncio.Event = dataclasses.field( + disconnected_event: asyncio.Event = dataclasses.field( init=False, default_factory=asyncio.Event ) #: RFC 3261 §8.1.2 — outbound SIP proxy address ``(host, port)``. @@ -742,11 +742,11 @@ def _build_contact(self, user: str | None = None, *, ob: bool = False) -> str: aor_scheme = self.aor.partition(":")[0] # "sip" or "sips" host_port = f"{_format_host(self.local_address[0])}:{self.local_address[1]}" addr = f"{user}@{host_port}" if user else host_port - ob_param = ";ob" if ob else "" + ob_uri_param = ";ob" if ob else "" if aor_scheme == "sips": - return f"" + return f"" tls_param = ";transport=tls" if self._is_tls else "" - return f"" + return f"" def ringing(self, request: Request) -> None: """Send a 180 Ringing provisional response to the caller. @@ -983,7 +983,7 @@ def connection_lost(self, exc: Exception | None) -> None: self._keepalive_task.cancel() self._keepalive_task = None self.transport = None - self._disconnected_event.set() + self.disconnected_event.set() async def start_server( From 06c4d7f0e33c9fe67ab59c5bf6364c683d3378e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 01:30:08 +0000 Subject: [PATCH 4/8] Refactor: remove start_server function, restructure tests into counterpart classes Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- docs/cookbook.md | 13 +- tests/sip/test_protocol.py | 297 +++++++++++++++---------------------- tests/test_main.py | 145 +++++++++--------- voip/__main__.py | 6 +- voip/sip/protocol.py | 62 +------- 5 files changed, 201 insertions(+), 322 deletions(-) diff --git a/docs/cookbook.md b/docs/cookbook.md index e93eaff..f44ada6 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -44,16 +44,16 @@ asyncio.run(main()) ## SIP Server (accepting incoming connections) -Use [`start_server`][voip.sip.protocol.start_server] to listen for incoming -SIP connections instead of connecting outbound to a carrier. This is useful -for testing, local PBX setups, or any scenario where SIP clients connect -directly to your application: +Use [`loop.create_server`][asyncio.AbstractEventLoop.create_server] to listen +for incoming SIP connections instead of connecting outbound to a carrier. This +is useful for testing, local PBX setups, or any scenario where SIP clients +connect directly to your application: ```python import asyncio from voip.ai import TranscribeCall -from voip.sip.protocol import SIP, start_server +from voip.sip.protocol import SIP class MyCall(TranscribeCall): @@ -67,7 +67,8 @@ class MySession(SIP): async def main(): - server = await start_server( + loop = asyncio.get_running_loop() + server = await loop.create_server( lambda: MySession(aor="sip:alice@0.0.0.0"), host="0.0.0.0", port=5060, diff --git a/tests/sip/test_protocol.py b/tests/sip/test_protocol.py index cf56cce..350b1b7 100644 --- a/tests/sip/test_protocol.py +++ b/tests/sip/test_protocol.py @@ -18,7 +18,6 @@ SessionInitiationProtocol, _format_host, _mask_caller, - start_server, ) from voip.sip.types import CallerID, DigestAlgorithm, SIPStatus @@ -1652,6 +1651,102 @@ def call_received(self, request): await asyncio.sleep(0.05) assert len(protocol._sent) == 1 + async def test_run_keepalive__sends_double_crlf(self): + """Keep-alive task sends double-CRLF after the configured interval.""" + protocol = SIP( + outbound_proxy=("127.0.0.1", 5061), + aor="sip:test@example.com", + rtp_stun_server_address=None, + keepalive_interval_secs=0.01, + ) + transport = make_mock_transport() + protocol.connection_made(transport) + transport.write.reset_mock() + await asyncio.sleep(0.05) + transport.write.assert_any_call(b"\r\n\r\n") + protocol._keepalive_task.cancel() + protocol._initialize_task.cancel() + + async def test_run_keepalive__stops_when_transport_cleared(self): + """Keep-alive loop exits cleanly when the transport is set to None.""" + protocol = SIP( + outbound_proxy=("127.0.0.1", 5061), + aor="sip:test@example.com", + rtp_stun_server_address=None, + keepalive_interval_secs=0.01, + ) + transport = make_mock_transport() + protocol.connection_made(transport) + transport.write.reset_mock() + protocol.transport = None + await asyncio.sleep(0.05) + transport.write.assert_not_called() + protocol._initialize_task.cancel() + + async def test_connection_lost__cancels_and_clears_keepalive_task(self): + """connection_lost cancels the keepalive task and clears _keepalive_task.""" + protocol = SIP( + outbound_proxy=("127.0.0.1", 5061), + aor="sip:test@example.com", + rtp_stun_server_address=None, + ) + + async def _long_running() -> None: + await asyncio.sleep(100) + + task = asyncio.get_running_loop().create_task(_long_running()) + protocol._keepalive_task = task + protocol.connection_lost(None) + assert protocol._keepalive_task is None + await asyncio.sleep(0) + assert task.done() + + async def test_connection_lost__sets_disconnected_event(self): + """connection_lost sets the disconnected_event.""" + protocol = SIP( + outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" + ) + assert not protocol.disconnected_event.is_set() + protocol.connection_lost(None) + assert protocol.disconnected_event.is_set() + + async def test_disconnected_event__resolves_after_connection_lost(self): + """disconnected_event resolves once connection_lost is called.""" + protocol = SIP( + outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" + ) + + async def lose_connection() -> None: + await asyncio.sleep(0.01) + protocol.connection_lost(None) + + asyncio.create_task(lose_connection()) + await asyncio.wait_for(protocol.disconnected_event.wait(), timeout=1.0) + assert protocol.disconnected_event.is_set() + + async def test_create_server__accepts_incoming_connection(self): + """loop.create_server with a SIP factory accepts incoming TCP connections.""" + connected = asyncio.Event() + + class ServerSession(SIP): + def connection_made(self, transport) -> None: + connected.set() + + loop = asyncio.get_running_loop() + server = await loop.create_server( + lambda: ServerSession(aor="sip:test@example.com"), + host="127.0.0.1", + port=0, + ) + port = server.sockets[0].getsockname()[1] + reader, writer = await asyncio.open_connection("127.0.0.1", port) + await asyncio.wait_for(connected.wait(), timeout=1.0) + assert connected.is_set() + writer.close() + await writer.wait_closed() + server.close() + await server.wait_closed() + # --------------------------------------------------------------------------- # Tests for SIP REGISTER / digest-auth / response handling @@ -2088,6 +2183,30 @@ async def test_response_received__unexpected_status__raises_registration_error( ("192.0.2.2", 5060), ) + def test_build_contact__default__no_ob_param(self): + """Contact without ob=True has no ;ob parameter.""" + p = make_register_session() + p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) + p._is_tls = True + contact = p._build_contact("alice") + assert ";ob" not in contact + + def test_build_contact__ob_true__includes_ob_uri_param(self): + """Contact with ob=True includes the ;ob URI parameter (RFC 5626 §5).""" + p = make_register_session() + p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) + p._is_tls = True + assert p._build_contact("alice", ob=True) == ( + "" + ) + + def test_build_contact__sips_with_ob__includes_ob_before_closing_bracket(self): + """sips: Contact with ob=True places ;ob inside the angle brackets.""" + p = make_register_session(aor="sips:alice@example.com") + p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) + p._is_tls = True + assert p._build_contact("alice", ob=True) == "" + # --------------------------------------------------------------------------- # Tests for digest_response (RFC 3261 §22, RFC 8760) @@ -2273,179 +2392,3 @@ def test_unsupported_algorithm_raises(self): ) -# --------------------------------------------------------------------------- -# Tests for RFC 5626 keep-alive pings -# --------------------------------------------------------------------------- - - -class TestKeepalive: - """Tests for RFC 5626 §4.4.1 client-initiated keep-alive pings.""" - - @pytest.mark.asyncio - async def test_run_keepalive__sends_double_crlf(self): - """Keep-alive task sends double-CRLF after the configured interval.""" - p = SessionInitiationProtocol( - outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" - ) - transport = MagicMock() - p.transport = transport - p.keepalive_interval_secs = 0.01 - task = asyncio.get_running_loop().create_task(p._run_keepalive()) - await asyncio.sleep(0.05) - task.cancel() - transport.write.assert_called_with(b"\r\n\r\n") - - @pytest.mark.asyncio - async def test_run_keepalive__stops_when_transport_is_none(self): - """Keep-alive loop exits cleanly when the transport is cleared.""" - p = SessionInitiationProtocol( - outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" - ) - transport = MagicMock() - p.transport = transport - p.keepalive_interval_secs = 0.01 - # Clear the transport before the first ping fires. - p.transport = None - task = asyncio.get_running_loop().create_task(p._run_keepalive()) - await asyncio.sleep(0.05) - assert task.done() - transport.write.assert_not_called() - - @pytest.mark.asyncio - async def test_connection_made__starts_keepalive_task(self): - """connection_made starts a keep-alive task.""" - p = SessionInitiationProtocol( - outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" - ) - transport = MagicMock() - transport.get_extra_info.side_effect = lambda key, default=None: { - "sockname": ("127.0.0.1", 5061), - "ssl_object": None, - }.get(key, default) - - async def run(): - p.connection_made(transport) - assert p._keepalive_task is not None - p._keepalive_task.cancel() - p._initialize_task.cancel() - - await run() - - def test_connection_lost__cancels_keepalive_task(self): - """connection_lost cancels any running keep-alive task.""" - p = SessionInitiationProtocol( - outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" - ) - mock_task = MagicMock() - p._keepalive_task = mock_task - p.connection_lost(None) - mock_task.cancel.assert_called_once() - assert p._keepalive_task is None - - def test_connection_lost__sets_disconnected_event(self): - """connection_lost sets the disconnected_event.""" - p = SessionInitiationProtocol( - outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" - ) - assert not p.disconnected_event.is_set() - p.connection_lost(None) - assert p.disconnected_event.is_set() - - @pytest.mark.asyncio - async def test_disconnected_event__can_be_awaited(self): - """disconnected_event resolves once connection_lost is called.""" - p = SessionInitiationProtocol( - outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" - ) - - async def lose_connection(): - await asyncio.sleep(0.01) - p.connection_lost(None) - - asyncio.create_task(lose_connection()) - await asyncio.wait_for(p.disconnected_event.wait(), timeout=1.0) - assert p.disconnected_event.is_set() - - -# --------------------------------------------------------------------------- -# Tests for start_server -# --------------------------------------------------------------------------- - - -class TestStartServer: - @pytest.mark.asyncio - async def test_start_server__returns_asyncio_server(self): - """start_server returns a running asyncio.Server listening on the given port.""" - p = SessionInitiationProtocol( - outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" - ) - server = await start_server( - lambda: p, - host="127.0.0.1", - port=0, # OS assigns a free port - ) - assert isinstance(server, asyncio.Server) - assert server.is_serving() - server.close() - await server.wait_closed() - - @pytest.mark.asyncio - async def test_start_server__accepts_connection(self): - """start_server accepts an incoming TCP connection.""" - connected = asyncio.Event() - - class PingSession(SessionInitiationProtocol): - def connection_made(self, transport) -> None: - connected.set() - - server = await start_server( - lambda: PingSession(aor="sip:test@example.com"), - host="127.0.0.1", - port=0, - ) - port = server.sockets[0].getsockname()[1] - reader, writer = await asyncio.open_connection("127.0.0.1", port) - await asyncio.wait_for(connected.wait(), timeout=1.0) - assert connected.is_set() - writer.close() - await writer.wait_closed() - server.close() - await server.wait_closed() - - -# --------------------------------------------------------------------------- -# Tests for _build_contact with RFC 5626 ;ob parameter -# --------------------------------------------------------------------------- - - -class TestBuildContactOb: - def test_build_contact__ob_false__no_ob_param(self): - """Contact without ob=True has no ;ob parameter.""" - p = SessionInitiationProtocol( - outbound_proxy=("127.0.0.1", 5061), aor="sip:alice@example.com" - ) - p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - p._is_tls = True - contact = p._build_contact("alice") - assert ";ob" not in contact - - def test_build_contact__ob_true__includes_ob_param(self): - """Contact with ob=True includes the ;ob URI parameter.""" - p = SessionInitiationProtocol( - outbound_proxy=("127.0.0.1", 5061), aor="sip:alice@example.com" - ) - p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - p._is_tls = True - contact = p._build_contact("alice", ob=True) - assert ";ob" in contact - assert contact == "" - - def test_build_contact__sips_with_ob(self): - """sips: Contact with ob=True includes ;ob inside the angle brackets.""" - p = SessionInitiationProtocol( - outbound_proxy=("127.0.0.1", 5061), aor="sips:alice@example.com" - ) - p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - p._is_tls = True - contact = p._build_contact("alice", ob=True) - assert contact == "" diff --git a/tests/test_main.py b/tests/test_main.py index bc48fcc..2452e23 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -496,6 +496,74 @@ async def _run_whisper(): asyncio.run(_run_whisper()) + def test_transcribe__retries_on_os_error(self): + """_connect_sip retries when the outbound connection fails with OSError.""" + from unittest.mock import AsyncMock + + call_count = 0 + + async def fake_connection(factory, *, host, port, ssl): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise OSError("Connection refused") + raise KeyboardInterrupt + + with ( + patch.dict(sys.modules, _WHISPER_STUBS), + patch("asyncio.get_event_loop"), + patch("voip.__main__.asyncio.get_running_loop") as mock_loop, + patch("voip.__main__.asyncio.sleep", new=AsyncMock()), + ): + mock_loop.return_value.create_connection = fake_connection + make_runner().invoke( + voip, + [ + "sip", + "--password=p", + "--stun-server=none", + "sips:alice@example.com", + "transcribe", + ], + catch_exceptions=False, + ) + assert call_count == 3 + + def test_transcribe__reconnects_after_disconnect(self): + """_connect_sip reconnects when the disconnected_event is set.""" + from unittest.mock import AsyncMock + + call_count = 0 + + async def fake_connection(factory, *, host, port, ssl): + nonlocal call_count + call_count += 1 + protocol = factory() + if call_count == 1: + protocol.disconnected_event.set() + return MagicMock(), protocol + raise KeyboardInterrupt + + with ( + patch.dict(sys.modules, _WHISPER_STUBS), + patch("asyncio.get_event_loop"), + patch("voip.__main__.asyncio.get_running_loop") as mock_loop, + patch("voip.__main__.asyncio.sleep", new=AsyncMock()), + ): + mock_loop.return_value.create_connection = fake_connection + make_runner().invoke( + voip, + [ + "sip", + "--password=p", + "--stun-server=none", + "sips:alice@example.com", + "transcribe", + ], + catch_exceptions=False, + ) + assert call_count == 2 + class TestAgentCLI: def test_agent__sips_aor_uses_tls(self): @@ -806,81 +874,8 @@ async def run(): asyncio.run(run()) - -class TestReconnect: - def test_connect_sip__retries_on_os_error(self): - """_connect_sip retries when the connection fails with OSError.""" - from unittest.mock import AsyncMock - - call_count = 0 - - async def fake_connection(factory, *, host, port, ssl): - nonlocal call_count - call_count += 1 - if call_count < 3: - raise OSError("Connection refused") - raise KeyboardInterrupt - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - patch("voip.__main__.asyncio.sleep", new=AsyncMock()), - ): - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=p", - "--stun-server=none", - "sips:alice@example.com", - "transcribe", - ], - catch_exceptions=False, - ) - assert call_count == 3 - - def test_connect_sip__reconnects_after_disconnect(self): - """_connect_sip reconnects when the connection drops (disconnected_event set).""" - from unittest.mock import AsyncMock - - call_count = 0 - - async def fake_connection(factory, *, host, port, ssl): - nonlocal call_count - call_count += 1 - protocol = factory() - if call_count == 1: - # Simulate immediate disconnect. - protocol.disconnected_event.set() - return MagicMock(), protocol - raise KeyboardInterrupt - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - patch("voip.__main__.asyncio.sleep", new=AsyncMock()), - ): - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=p", - "--stun-server=none", - "sips:alice@example.com", - "transcribe", - ], - catch_exceptions=False, - ) - assert call_count == 2 - - -class TestListenMode: - def test_sip__listen_option_uses_start_server(self): - """--listen causes the command to call start_server instead of create_connection.""" + def test_echo__listen_option_uses_create_server(self): + """--listen causes the echo command to call loop.create_server.""" from unittest.mock import AsyncMock captured = {} diff --git a/voip/__main__.py b/voip/__main__.py index 2e87da5..f511c7d 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -8,7 +8,7 @@ import time from voip.sip import messages -from voip.sip.protocol import SessionInitiationProtocol, start_server +from voip.sip.protocol import SessionInitiationProtocol from voip.sip.types import SipUri try: @@ -305,9 +305,9 @@ async def _serve_sip( ) -> None: """Start a SIP server and listen indefinitely for incoming connections.""" host, port = listen_addr - server = await start_server( + server = await asyncio.get_running_loop().create_server( session_factory, - host=host, + host=str(host), port=port, ) async with server: diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index d8e1381..3637262 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -16,7 +16,6 @@ import re import secrets import socket -import ssl import typing import uuid @@ -37,7 +36,7 @@ logger = logging.getLogger("voip.sip") -__all__ = ["RegistrationError", "SIP", "SessionInitiationProtocol", "start_server"] +__all__ = ["RegistrationError", "SIP", "SessionInitiationProtocol"] def _format_host(host: str | ipaddress.IPv4Address | ipaddress.IPv6Address) -> str: @@ -986,64 +985,5 @@ def connection_lost(self, exc: Exception | None) -> None: self.disconnected_event.set() -async def start_server( - session_factory: typing.Callable[[], SessionInitiationProtocol], - host: str | ipaddress.IPv4Address | ipaddress.IPv6Address | None = None, - port: int = 5060, - *, - ssl_context: ssl.SSLContext | None = None, -) -> asyncio.Server: - """Start a SIP TCP server that accepts incoming connections. - - Each accepted TCP connection receives a new protocol instance created by - *session_factory*. Pass an [`ssl.SSLContext`][ssl.SSLContext] to enable - TLS (recommended on port 5061). - - Example: - ```python - import asyncio - import ssl - - from voip.sip.protocol import SIP, start_server - - - class MySession(SIP): - def call_received(self, request) -> None: - asyncio.create_task(self.answer(request=request, call_class=MyCall)) - - - async def main(): - server = await start_server( - lambda: MySession(aor="sip:bob@0.0.0.0"), - host="0.0.0.0", - port=5060, - ) - async with server: - await server.serve_forever() - - - asyncio.run(main()) - ``` - - Args: - session_factory: Callable returning a new - [`SessionInitiationProtocol`][voip.sip.protocol.SessionInitiationProtocol] - instance for each incoming connection. - host: Interface to bind to. ``None`` binds to all interfaces. - port: TCP port to listen on (default: 5060). - ssl_context: Optional TLS context. When provided, the server - accepts TLS connections (recommended on port 5061). - - Returns: - A running [`asyncio.Server`][asyncio.Server] instance. - """ - return await asyncio.get_running_loop().create_server( - session_factory, - host=str(host) if host is not None else None, - port=port, - ssl=ssl_context, - ) - - #: Short alias for `SessionInitiationProtocol`. SIP = SessionInitiationProtocol From 4cb7bff252292bb1fa6f4d6888a85ff61c481027 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 08:26:54 +0000 Subject: [PATCH 5/8] Remove --listen / server mode feature per reviewer request Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- docs/cookbook.md | 45 ----------------------------- tests/sip/test_protocol.py | 23 --------------- tests/test_main.py | 36 ----------------------- voip/__main__.py | 59 +++----------------------------------- 4 files changed, 4 insertions(+), 159 deletions(-) diff --git a/docs/cookbook.md b/docs/cookbook.md index f44ada6..4d2d26b 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -42,51 +42,6 @@ async def main(): asyncio.run(main()) ``` -## SIP Server (accepting incoming connections) - -Use [`loop.create_server`][asyncio.AbstractEventLoop.create_server] to listen -for incoming SIP connections instead of connecting outbound to a carrier. This -is useful for testing, local PBX setups, or any scenario where SIP clients -connect directly to your application: - -```python -import asyncio - -from voip.ai import TranscribeCall -from voip.sip.protocol import SIP - - -class MyCall(TranscribeCall): - def transcription_received(self, text: str) -> None: - print(f"[{self.caller}] {text}") - - -class MySession(SIP): - def call_received(self, request) -> None: - asyncio.create_task(self.answer(request=request, call_class=MyCall)) - - -async def main(): - loop = asyncio.get_running_loop() - server = await loop.create_server( - lambda: MySession(aor="sip:alice@0.0.0.0"), - host="0.0.0.0", - port=5060, - ) - async with server: - print("Listening for SIP calls on port 5060…") - await server.serve_forever() - - -asyncio.run(main()) -``` - -To start a server from the CLI, pass `--listen HOST:PORT`: - -```bash -voip sip --password=secret --listen 0.0.0.0:5060 sip:alice@myhost.com echo -``` - ## Sharing a Whisper Model Across Calls Loading the model is expensive. Pass a pre-loaded diff --git a/tests/sip/test_protocol.py b/tests/sip/test_protocol.py index 350b1b7..2d43add 100644 --- a/tests/sip/test_protocol.py +++ b/tests/sip/test_protocol.py @@ -1724,29 +1724,6 @@ async def lose_connection() -> None: await asyncio.wait_for(protocol.disconnected_event.wait(), timeout=1.0) assert protocol.disconnected_event.is_set() - async def test_create_server__accepts_incoming_connection(self): - """loop.create_server with a SIP factory accepts incoming TCP connections.""" - connected = asyncio.Event() - - class ServerSession(SIP): - def connection_made(self, transport) -> None: - connected.set() - - loop = asyncio.get_running_loop() - server = await loop.create_server( - lambda: ServerSession(aor="sip:test@example.com"), - host="127.0.0.1", - port=0, - ) - port = server.sockets[0].getsockname()[1] - reader, writer = await asyncio.open_connection("127.0.0.1", port) - await asyncio.wait_for(connected.wait(), timeout=1.0) - assert connected.is_set() - writer.close() - await writer.wait_closed() - server.close() - await server.wait_closed() - # --------------------------------------------------------------------------- # Tests for SIP REGISTER / digest-auth / response handling diff --git a/tests/test_main.py b/tests/test_main.py index 2452e23..9accfc5 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -873,39 +873,3 @@ async def run(): assert isinstance(kwargs["call_class"], type) asyncio.run(run()) - - def test_echo__listen_option_uses_create_server(self): - """--listen causes the echo command to call loop.create_server.""" - from unittest.mock import AsyncMock - - captured = {} - - async def fake_create_server(factory, host, port, ssl): - captured["host"] = host - captured["port"] = port - server = MagicMock() - server.__aenter__ = AsyncMock(return_value=server) - server.__aexit__ = AsyncMock(return_value=None) - server.serve_forever = AsyncMock(side_effect=KeyboardInterrupt) - return server - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - ): - mock_loop.return_value.create_server = fake_create_server - make_runner().invoke( - voip, - [ - "sip", - "--password=p", - "--stun-server=none", - "--listen=0.0.0.0:5060", - "sips:alice@example.com", - "echo", - ], - catch_exceptions=False, - ) - assert captured.get("host") == "0.0.0.0" # noqa: S104 - assert captured.get("port") == 5060 diff --git a/voip/__main__.py b/voip/__main__.py index f511c7d..2d86081 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -173,16 +173,6 @@ def voip(ctx, verbose: int = 0): "Use this when the proxy differs from the registrar domain." ), ) -@click.option( - "--listen", - envvar="SIP_LISTEN", - default=None, - metavar="HOST[:PORT]", - help=( - "Start a SIP server listening for incoming connections instead of " - "connecting outbound to a carrier. Example: --listen 0.0.0.0:5060" - ), -) @click.option( "--stun-server", envvar="STUN_SERVER", @@ -209,7 +199,7 @@ def voip(ctx, verbose: int = 0): help="Disable TLS certificate verification (insecure; for testing only).", ) @click.pass_context -def sip(ctx, aor, password, username, proxy, listen, stun_server, no_tls, no_verify_tls): +def sip(ctx, aor, password, username, proxy, stun_server, no_tls, no_verify_tls): """Session Initiation Protocol (SIP).""" ctx.ensure_object(dict) try: @@ -231,12 +221,6 @@ def sip(ctx, aor, password, username, proxy, listen, stun_server, no_tls, no_ver port = parsed_aor.port if parsed_aor.port is not None else default_port proxy_addr = (parsed_aor.host, port) - listen_addr = ( - _parse_hostport(ctx, None, listen, default_port=SIP_TCP_PORT) - if listen is not None - else None - ) - use_tls = not no_tls and proxy_addr[1] != SIP_TCP_PORT # Build the canonical AOR; IPv6 hosts must be enclosed in brackets per RFC 2732. host_in_aor = ( @@ -251,7 +235,6 @@ def sip(ctx, aor, password, username, proxy, listen, stun_server, no_tls, no_ver username=effective_username, password=password, proxy_addr=proxy_addr, - listen_addr=listen_addr, stun_server=stun_server, use_tls=use_tls, no_verify_tls=no_verify_tls, @@ -299,37 +282,6 @@ async def _connect_sip( backoff_secs = min(backoff_secs * 2, 60) -async def _serve_sip( - session_factory, - listen_addr: tuple[str | ipaddress.IPv4Address | ipaddress.IPv6Address, int], -) -> None: - """Start a SIP server and listen indefinitely for incoming connections.""" - host, port = listen_addr - server = await asyncio.get_running_loop().create_server( - session_factory, - host=str(host), - port=port, - ) - async with server: - logger.info("SIP server listening on %s:%s", host, port) - await server.serve_forever() - - -async def _run_sip( - session_factory, - proxy_addr: tuple[ipaddress.IPv4Address | ipaddress.IPv6Address | str, int], - listen_addr: tuple[str | ipaddress.IPv4Address | ipaddress.IPv6Address, int] - | None, - use_tls: bool, - no_verify_tls: bool, -) -> None: - """Start SIP in server mode when *listen_addr* is set, else connect outbound.""" - if listen_addr is not None: - await _serve_sip(session_factory, listen_addr) - else: - await _connect_sip(session_factory, proxy_addr, use_tls, no_verify_tls) - - @sip.command() @click.pass_context def echo(ctx): @@ -347,7 +299,7 @@ def call_received(self, request) -> None: asyncio.create_task(self.answer(request=request, call_class=EchoCall)) async def run(): - await _run_sip( + await _connect_sip( lambda: EchoSession( outbound_proxy=proxy_addr, aor=obj["aor"], @@ -356,7 +308,6 @@ async def run(): rtp_stun_server_address=obj["stun_server"], ), proxy_addr, - obj.get("listen_addr"), obj["use_tls"], obj["no_verify_tls"], ) @@ -406,7 +357,7 @@ def call_received(self, request) -> None: ) async def run(): - await _run_sip( + await _connect_sip( lambda: TranscribeSession( outbound_proxy=proxy_addr, aor=obj["aor"], @@ -415,7 +366,6 @@ async def run(): rtp_stun_server_address=obj["stun_server"], ), proxy_addr, - obj.get("listen_addr"), obj["use_tls"], obj["no_verify_tls"], ) @@ -510,7 +460,7 @@ def call_received(self, request) -> None: ) async def run(): - await _run_sip( + await _connect_sip( lambda: AgentSession( outbound_proxy=proxy_addr, aor=obj["aor"], @@ -519,7 +469,6 @@ async def run(): rtp_stun_server_address=obj["stun_server"], ), proxy_addr, - obj.get("listen_addr"), obj["use_tls"], obj["no_verify_tls"], ) From 8b90348a17e5083d94e391d562b2d9a412084591 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Thu, 19 Mar 2026 10:42:27 +0100 Subject: [PATCH 6/8] Use stronger typing --- tests/sip/test_protocol.py | 15 +++++---------- voip/sip/protocol.py | 18 ++++++------------ 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/tests/sip/test_protocol.py b/tests/sip/test_protocol.py index 2d43add..c63f9ff 100644 --- a/tests/sip/test_protocol.py +++ b/tests/sip/test_protocol.py @@ -2,6 +2,7 @@ import asyncio import dataclasses +import datetime import hashlib import ipaddress import re @@ -1657,7 +1658,7 @@ async def test_run_keepalive__sends_double_crlf(self): outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com", rtp_stun_server_address=None, - keepalive_interval_secs=0.01, + keepalive_interval=datetime.timedelta(seconds=0.01), ) transport = make_mock_transport() protocol.connection_made(transport) @@ -1673,7 +1674,7 @@ async def test_run_keepalive__stops_when_transport_cleared(self): outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com", rtp_stun_server_address=None, - keepalive_interval_secs=0.01, + keepalive_interval=datetime.timedelta(seconds=0.01), ) transport = make_mock_transport() protocol.connection_made(transport) @@ -1703,18 +1704,14 @@ async def _long_running() -> None: async def test_connection_lost__sets_disconnected_event(self): """connection_lost sets the disconnected_event.""" - protocol = SIP( - outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" - ) + protocol = SIP(outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com") assert not protocol.disconnected_event.is_set() protocol.connection_lost(None) assert protocol.disconnected_event.is_set() async def test_disconnected_event__resolves_after_connection_lost(self): """disconnected_event resolves once connection_lost is called.""" - protocol = SIP( - outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" - ) + protocol = SIP(outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com") async def lose_connection() -> None: await asyncio.sleep(0.01) @@ -2367,5 +2364,3 @@ def test_unsupported_algorithm_raises(self): uri="sip:example.com", algorithm="BLAKE2b", ) - - diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index 3637262..3428309 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -9,6 +9,7 @@ import asyncio import collections import dataclasses +import datetime import hashlib import ipaddress import json @@ -134,6 +135,9 @@ def call_received(self, request: Request) -> None: ALLOW: RFC 3261 §11 – methods supported by this UA (used in Allow header). + Args: + keepalive_interval: Keep-alive ping interval. Should be between 30 and 90 seconds. + """ #: RFC 3261 §8.1.1.7 Via branch magic cookie (indicates RFC 3261 compliance). @@ -175,10 +179,7 @@ def call_received(self, request: Request) -> None: password: str | None = None #: STUN server used for RTP NAT traversal (SIP uses TLS/TCP; no STUN needed). rtp_stun_server_address: tuple[str, int] | None = ("stun.cloudflare.com", 3478) - #: RFC 5626 §4.4.1 keep-alive ping interval in seconds. - #: Pings are sent as double-CRLF (``\\r\\n\\r\\n``) over the TLS/TCP connection. - #: RFC 5626 §10 recommends at most 90 seconds; 30 s is a safe default for most NATs. - keepalive_interval_secs: float = 30.0 + keepalive_interval: datetime.timedelta = datetime.timedelta(seconds=30) call_id: str = dataclasses.field(init=False) cseq: int = dataclasses.field(init=False, default=0) #: Local TCP socket address (host, port) — set when connection is established. @@ -224,15 +225,8 @@ async def _initialize(self) -> None: await self.register() async def _run_keepalive(self) -> None: - r"""Periodically send RFC 5626 §4.4.1 keep-alive pings over the connection. - - Sends a double-CRLF (``\r\n\r\n``) every - [`keepalive_interval_secs`][voip.sip.protocol.SessionInitiationProtocol.keepalive_interval_secs] - seconds to keep the TCP connection and any intermediate NAT mappings - alive. The remote peer responds with a single CRLF (``\r\n``). - """ while True: - await asyncio.sleep(self.keepalive_interval_secs) + await asyncio.sleep(self.keepalive_interval.total_seconds()) if self.transport is None: return logger.debug("Sending RFC 5626 §4.4.1 keep-alive ping") From 25b7eaa642e724b99af4f90e38c3cf37582db348 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 09:44:02 +0000 Subject: [PATCH 7/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/feature_roadmap.md | 2 +- docs/rfc_status.md | 14 +++++++------- voip/__main__.py | 4 +--- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/docs/feature_roadmap.md b/docs/feature_roadmap.md index cb07f8f..5cc5c20 100644 --- a/docs/feature_roadmap.md +++ b/docs/feature_roadmap.md @@ -10,7 +10,7 @@ SIP User Agent Client (UAC) over TLS/TCP ([RFC 3261]). Handles incoming SHA-512/256), and double-CRLF keepalive ping/pong ([RFC 5626 §4.4.1]). Client-initiated keepalive pings, `Supported: outbound` and `;ob` Contact parameter ([RFC 5626 §5]), and automatic reconnection with exponential -back-off ensure robust long-running sessions. The `start_server` helper +back-off ensure robust long-running sessions. The `start_server` helper enables accepting inbound SIP connections directly, without a carrier. ### Media Transport (RTP/SRTP) diff --git a/docs/rfc_status.md b/docs/rfc_status.md index 36b359c..d2b1dea 100644 --- a/docs/rfc_status.md +++ b/docs/rfc_status.md @@ -2,14 +2,14 @@ ## SIP Signaling -| RFC | Title | Status | Notes | -| --------------------------------------------------------- | --------------------------------------------- | -------- | ----------------------------------------------------------------------- | -| [RFC 3261](https://datatracker.ietf.org/doc/html/rfc3261) | SIP: Session Initiation Protocol | Partial | UAC only; REGISTER, INVITE, BYE, and digest authentication over TLS/TCP | +| RFC | Title | Status | Notes | +| --------------------------------------------------------- | --------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [RFC 3261](https://datatracker.ietf.org/doc/html/rfc3261) | SIP: Session Initiation Protocol | Partial | UAC only; REGISTER, INVITE, BYE, and digest authentication over TLS/TCP | | [RFC 5626](https://datatracker.ietf.org/doc/html/rfc5626) | Managing Client-Initiated Connections in SIP | Complete | Double-CRLF keepalive ping/pong (§4.4.1); client keepalive task; `Supported: outbound` and `;ob` Contact parameter (§5); reconnect with exponential back-off | -| [RFC 8760](https://datatracker.ietf.org/doc/html/rfc8760) | SIP Digest Authentication Using AES-HMAC-SHA2 | Complete | MD5, SHA-256, and SHA-512/256 digest responses | -| [RFC 3824](https://datatracker.ietf.org/doc/html/rfc3824) | Using E.164 Numbers with SIP | Planned | Phone number mapping into SIP/ENUM | -| [RFC 3966](https://datatracker.ietf.org/doc/html/rfc3966) | The tel URI for Telephone Numbers | Planned | Canonical `tel:` URI scheme | -| [RFC 6116](https://datatracker.ietf.org/doc/html/rfc6116) | The E.164 to URI DDDS Application (ENUM) | Planned | DNS-based E.164 number-to-URI mapping | +| [RFC 8760](https://datatracker.ietf.org/doc/html/rfc8760) | SIP Digest Authentication Using AES-HMAC-SHA2 | Complete | MD5, SHA-256, and SHA-512/256 digest responses | +| [RFC 3824](https://datatracker.ietf.org/doc/html/rfc3824) | Using E.164 Numbers with SIP | Planned | Phone number mapping into SIP/ENUM | +| [RFC 3966](https://datatracker.ietf.org/doc/html/rfc3966) | The tel URI for Telephone Numbers | Planned | Canonical `tel:` URI scheme | +| [RFC 6116](https://datatracker.ietf.org/doc/html/rfc6116) | The E.164 to URI DDDS Application (ENUM) | Planned | DNS-based E.164 number-to-URI mapping | ## Media Transport diff --git a/voip/__main__.py b/voip/__main__.py index 2d86081..3115f8e 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -271,9 +271,7 @@ async def _connect_sip( ) backoff_secs = 1 await protocol.disconnected_event.wait() - logger.info( - "SIP connection closed; reconnecting in %s s", backoff_secs - ) + logger.info("SIP connection closed; reconnecting in %s s", backoff_secs) except (OSError, ssl.SSLError) as exc: logger.warning( "SIP connection failed (%s); retrying in %s s", exc, backoff_secs From 1b72b47db50a89dad978df55bcf95238ad90b80c Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Thu, 19 Mar 2026 10:46:46 +0100 Subject: [PATCH 8/8] Drop obsolete docs --- docs/cookbook.md | 2 +- docs/feature_roadmap.md | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/cookbook.md b/docs/cookbook.md index 4d2d26b..acfb71c 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -11,7 +11,7 @@ import asyncio import ssl from voip.ai import TranscribeCall -from voip.sip.protocol import SIP, start_server +from voip.sip.protocol import SIP class MyCall(TranscribeCall): diff --git a/docs/feature_roadmap.md b/docs/feature_roadmap.md index 5cc5c20..f84a25c 100644 --- a/docs/feature_roadmap.md +++ b/docs/feature_roadmap.md @@ -10,8 +10,7 @@ SIP User Agent Client (UAC) over TLS/TCP ([RFC 3261]). Handles incoming SHA-512/256), and double-CRLF keepalive ping/pong ([RFC 5626 §4.4.1]). Client-initiated keepalive pings, `Supported: outbound` and `;ob` Contact parameter ([RFC 5626 §5]), and automatic reconnection with exponential -back-off ensure robust long-running sessions. The `start_server` helper -enables accepting inbound SIP connections directly, without a carrier. +back-off ensure robust long-running sessions. ### Media Transport (RTP/SRTP)