diff --git a/pyVoIP/SIP/client.py b/pyVoIP/SIP/client.py index f4435f6..1229538 100644 --- a/pyVoIP/SIP/client.py +++ b/pyVoIP/SIP/client.py @@ -30,6 +30,13 @@ debug = pyVoIP.debug +UNAUTORIZED_RESPONSE_CODES = [ + SIPStatus.UNAUTHORIZED, + SIPStatus.PROXY_AUTHENTICATION_REQUIRED, +] +INVITE_OK_RESPONSE_CODES = [SIPStatus.TRYING, SIPStatus.RINGING, SIPStatus.OK] + + class SIPClient: def __init__( self, @@ -185,6 +192,7 @@ def start(self) -> None: self.transport_mode, self.bind_ip, self.bind_port, + self.nat, self.cert_file, self.key_file, self.key_password, @@ -198,9 +206,11 @@ def start(self) -> None: self.s.start() # TODO: Check if we need to register with a server or proxy. self.register() + """ t = Timer(1, self.recv) t.name = "SIP Receive" t.start() + """ def stop(self) -> None: self.NSD = False @@ -533,6 +543,7 @@ def gen_first_request(self, deregister=False) -> str: method, self.user, self.nat.get_host(self.server), + port=self.bind_port, uriparams=f";transport={trans_mode}", params=[f'+sip.instance=""'], ) @@ -568,6 +579,7 @@ def gen_subscribe(self, response: SIPMessage) -> str: method, self.user, self.nat.get_host(self.server), + port=self.bind_port, uriparams=f";transport={trans_mode}", params=[f'+sip.instance=""'], ) @@ -602,6 +614,7 @@ def gen_register(self, request: SIPMessage, deregister=False) -> str: method, self.user, self.nat.get_host(self.server), + port=self.bind_port, uriparams=f";transport={trans_mode}", params=[f'+sip.instance=""'], ) @@ -727,6 +740,7 @@ def gen_answer( method, self.user, self.nat.get_host(self.server), + port=self.bind_port, ) # TODO: Add Supported regRequest += self.__gen_user_agent() @@ -787,6 +801,7 @@ def gen_invite( method, self.user, self.nat.get_host(self.server), + port=self.bind_port, ) invRequest += self.__gen_from_to( "To", number, self.server, port=self.port @@ -831,6 +846,7 @@ def _gen_bye_cancel(self, request: SIPMessage, cmd: str) -> str: method, self.user, self.nat.get_host(self.server), + port=self.bind_port, ) byeRequest += self.__gen_user_agent() byeRequest += f"Allow: {(', '.join(pyVoIP.SIPCompatibleMethods))}\r\n" @@ -905,10 +921,8 @@ def invite( response = SIPMessage(conn.recv(8192)) while ( - response.status != SIPStatus(401) - and response.status != SIPStatus(407) - and response.status != SIPStatus(100) - and response.status != SIPStatus(180) + response.status + not in UNAUTORIZED_RESPONSE_CODES + INVITE_OK_RESPONSE_CODES ) or response.headers["Call-ID"] != call_id: if not self.NSD: break @@ -918,14 +932,16 @@ def invite( debug(f"Received Response: {response.summary()}") - if response.status == SIPStatus(100) or response.status == SIPStatus( - 180 - ): - debug("Invite status OK") - return SIPMessage(invite.encode("utf8")), call_id, sess_id + if response.status in INVITE_OK_RESPONSE_CODES: + debug("Invite Accepted") + if response.status is SIPStatus.OK: + return response, call_id, sess_id, conn + return SIPMessage(invite.encode("utf8")), call_id, sess_id, conn + debug("Invite Requires Authorization") ack = self.gen_ack(response) conn.send(ack) debug("Acknowledged") + conn.close() # End of Dialog auth = self.gen_authorization(response) invite = self.gen_invite( @@ -935,7 +951,7 @@ def invite( "\r\nContent-Length", f"\r\n{auth}Content-Length" ) - conn.send(invite) + conn = self.sendto(invite) return SIPMessage(invite.encode("utf8")), call_id, sess_id, conn diff --git a/pyVoIP/SIP/message.py b/pyVoIP/SIP/message.py index 5ab7368..e14bafe 100644 --- a/pyVoIP/SIP/message.py +++ b/pyVoIP/SIP/message.py @@ -313,7 +313,12 @@ def __init__(self, data: bytes): "v": "Via", } - self.parse(data) + try: + self.parse(data) + except Exception as e: + if type(e) is not SIPParseError: + raise SIPParseError(e) + raise def summary(self) -> str: data = "" diff --git a/pyVoIP/VoIP/call.py b/pyVoIP/VoIP/call.py index be4975f..d250555 100644 --- a/pyVoIP/VoIP/call.py +++ b/pyVoIP/VoIP/call.py @@ -1,7 +1,9 @@ from enum import Enum from pyVoIP import RTP, SIP +from pyVoIP.SIP.error import SIPParseError +from pyVoIP.SIP.message import SIPMessage, SIPMessageType, SIPStatus from pyVoIP.VoIP.error import InvalidStateError -from threading import Lock +from threading import Lock, Timer from typing import Any, Dict, List, Optional, TYPE_CHECKING import audioop import io @@ -37,7 +39,7 @@ def __init__( self, phone: "VoIPPhone", callstate: CallState, - request: SIP.SIPMessage, + request: SIPMessage, session_id: int, bind_ip: str, conn: "VoIPConnection", @@ -51,6 +53,7 @@ def __init__( self.call_id = request.headers["Call-ID"] self.session_id = str(session_id) self.bind_ip = bind_ip + self.conn = conn self.rtp_port_high = self.phone.rtp_port_high self.rtp_port_low = self.phone.rtp_port_low self.sendmode = sendmode @@ -71,103 +74,134 @@ def __init__( self.assignedPorts: Any = {} if callstate == CallState.RINGING: - audio = [] - video = [] - for x in self.request.body["c"]: - self.connections += x["address_count"] - for x in self.request.body["m"]: - if x["type"] == "audio": - self.audioPorts += x["port_count"] - audio.append(x) - elif x["type"] == "video": - self.videoPorts += x["port_count"] - video.append(x) - else: - warnings.warn( - f"Unknown media description: {x['type']}", stacklevel=2 - ) - - # Ports Adjusted is used in case of multiple m tags. - if len(audio) > 0: - audioPortsAdj = self.audioPorts / len(audio) + self.init_incoming_call(request) + elif callstate == CallState.DIALING: + self.init_outgoing_call(ms) + + t = Timer(0, self.receiver) + t.name = f"Call {self.call_id} Receiver" + t.start() + + def receiver(self): + """Receive and handle incoming messages""" + while self.state is not CallState.ENDED and self.phone.NSD: + data = self.conn.recv(8192) + if data is None: + continue + try: + message = SIPMessage(data) + except SIPParseError: + continue + if message.type is SIPMessageType.RESPONSE: + if message.status is SIPStatus.OK: + if self.state in [ + CallState.DIALING, + CallState.RINGING, + CallState.PROGRESS, + ]: + self.answered(message) + elif message.status == SIPStatus.NOT_FOUND: + pass else: - audioPortsAdj = 0 - if len(video) > 0: - videoPortsAdj = self.videoPorts / len(video) + if message.method == "BYE": + self.bye(message) + + def init_outgoing_call(self, ms: Optional[Dict[int, RTP.PayloadType]]): + if ms is None: + raise RuntimeError( + "Media assignments are required when " + "initiating a call" + ) + self.ms = ms + for m in self.ms: + self.port = m + self.assignedPorts[m] = self.ms[m] + + def init_incoming_call(self, request: SIP.SIPMessage): + audio = [] + video = [] + for x in self.request.body["c"]: + self.connections += x["address_count"] + for x in self.request.body["m"]: + if x["type"] == "audio": + self.audioPorts += x["port_count"] + audio.append(x) + elif x["type"] == "video": + self.videoPorts += x["port_count"] + video.append(x) else: - videoPortsAdj = 0 + warnings.warn( + f"Unknown media description: {x['type']}", stacklevel=2 + ) - if not ( - (audioPortsAdj == self.connections or self.audioPorts == 0) - and (videoPortsAdj == self.connections or self.videoPorts == 0) - ): - # TODO: Throw error to PBX in this case - warnings.warn("Unable to assign ports for RTP.", stacklevel=2) - return - - for i in request.body["m"]: - assoc = {} - e = False - for x in i["methods"]: + # Ports Adjusted is used in case of multiple m tags. + if len(audio) > 0: + audioPortsAdj = self.audioPorts / len(audio) + else: + audioPortsAdj = 0 + if len(video) > 0: + videoPortsAdj = self.videoPorts / len(video) + else: + videoPortsAdj = 0 + + if not ( + (audioPortsAdj == self.connections or self.audioPorts == 0) + and (videoPortsAdj == self.connections or self.videoPorts == 0) + ): + # TODO: Throw error to PBX in this case + warnings.warn("Unable to assign ports for RTP.", stacklevel=2) + return + + for i in request.body["m"]: + assoc = {} + e = False + for x in i["methods"]: + try: + p = RTP.PayloadType(int(x)) + assoc[int(x)] = p + except ValueError: try: - p = RTP.PayloadType(int(x)) + p = RTP.PayloadType( + i["attributes"][x]["rtpmap"]["name"] + ) assoc[int(x)] = p except ValueError: - try: - p = RTP.PayloadType( - i["attributes"][x]["rtpmap"]["name"] - ) - assoc[int(x)] = p - except ValueError: - # Sometimes rtpmap raise a KeyError because fmtp - # is set instate - pt = i["attributes"][x]["rtpmap"]["name"] - warnings.warn( - f"RTP Payload type {pt} not found.", - stacklevel=20, - ) - # Resets the warning filter so this warning will - # come up again if it happens. However, this - # also resets all other warnings. - warnings.simplefilter("default") - p = RTP.PayloadType("UNKNOWN") - assoc[int(x)] = p - except KeyError: - # fix issue 42 - # When rtpmap is not found, also set the found - # element to UNKNOWN - warnings.warn( - f"RTP KeyError {x} not found.", stacklevel=20 - ) - p = RTP.PayloadType("UNKNOWN") - assoc[int(x)] = p - - if e: - raise RTP.RTPParseError( - f"RTP Payload type {pt} not found." - ) - - # Make sure codecs are compatible. - codecs = {} - for m in assoc: - if assoc[m] in pyVoIP.RTPCompatibleCodecs: - codecs[m] = assoc[m] - # TODO: If no codecs are compatible then send error to PBX. - - port = self.phone.request_port() - self.create_rtp_clients( - codecs, self.bind_ip, port, request, i["port"] - ) - elif callstate == CallState.DIALING: - if ms is None: - raise RuntimeError( - "Media assignments are required when " - + "initiating a call" - ) - self.ms = ms - for m in self.ms: - self.port = m - self.assignedPorts[m] = self.ms[m] + # Sometimes rtpmap raise a KeyError because fmtp + # is set instate + pt = i["attributes"][x]["rtpmap"]["name"] + warnings.warn( + f"RTP Payload type {pt} not found.", + stacklevel=20, + ) + # Resets the warning filter so this warning will + # come up again if it happens. However, this + # also resets all other warnings. + warnings.simplefilter("default") + p = RTP.PayloadType("UNKNOWN") + assoc[int(x)] = p + except KeyError: + # fix issue 42 + # When rtpmap is not found, also set the found + # element to UNKNOWN + warnings.warn( + f"RTP KeyError {x} not found.", stacklevel=20 + ) + p = RTP.PayloadType("UNKNOWN") + assoc[int(x)] = p + + if e: + raise RTP.RTPParseError(f"RTP Payload type {pt} not found.") + + # Make sure codecs are compatible. + codecs = {} + for m in assoc: + if assoc[m] in pyVoIP.RTPCompatibleCodecs: + codecs[m] = assoc[m] + # TODO: If no codecs are compatible then send error to PBX. + + port = self.phone.request_port() + self.create_rtp_clients( + codecs, self.bind_ip, port, request, i["port"] + ) def create_rtp_clients( self, @@ -280,6 +314,8 @@ def answered(self, request: SIP.SIPMessage) -> None: elif self.state != CallState.PROGRESS: return self.state = CallState.ANSWERED + ack = self.phone.sip.gen_ack(request) + self.conn.send(ack) def progress(self, request: SIP.SIPMessage) -> None: if self.state != CallState.DIALING: @@ -377,7 +413,7 @@ def cancel(self) -> None: self.sip.cancel(self.request) self.state = CallState.CANCELING - def bye(self) -> None: + def bye(self, request: SIPMessage) -> None: if ( self.state == CallState.ANSWERED or self.state == CallState.PROGRESS @@ -385,7 +421,9 @@ def bye(self) -> None: ): for x in self.RTPClients: x.stop() - self.state = CallState.ENDED + self.state = CallState.ENDED + ok = self.phone.sip.gen_ok(request) + self.conn.send(ok) if self.request.headers["Call-ID"] in self.phone.calls: del self.phone.calls[self.request.headers["Call-ID"]] diff --git a/pyVoIP/VoIP/phone.py b/pyVoIP/VoIP/phone.py index 9f5bde3..9833bdc 100644 --- a/pyVoIP/VoIP/phone.py +++ b/pyVoIP/VoIP/phone.py @@ -311,6 +311,7 @@ def stop(self) -> None: except InvalidStateError: pass self.sip.stop() + self.NSD = False self._status = PhoneStatus.INACTIVE def call( diff --git a/pyVoIP/__init__.py b/pyVoIP/__init__.py index 858b3d4..e62e2f7 100644 --- a/pyVoIP/__init__.py +++ b/pyVoIP/__init__.py @@ -50,6 +50,17 @@ # https://docs.python.org/3/library/ssl.html#ssl.SSLContext.verify_mode TLS_VERIFY_MODE = ssl.CERT_REQUIRED +""" +DO NOT CHANGE IN PRODUCTION. + +This variable allows you to save the SIP message state database to a file +instead of storing it in memory which is the default. This is useful for +debugging, however pyVoIP does not delete the database afterwards which will +cause an Exception upon restarting pyVoIP. For this reason, we recommend you +do not change this variable in production. +""" +SIP_STATE_DB_LOCATION = ":memory:" + def set_tls_security(verify_mode: ssl.VerifyMode) -> None: """ diff --git a/pyVoIP/networking/nat.py b/pyVoIP/networking/nat.py index bea9de9..e07470f 100644 --- a/pyVoIP/networking/nat.py +++ b/pyVoIP/networking/nat.py @@ -1,3 +1,4 @@ +from enum import Enum from typing import Optional import ipaddress import socket @@ -7,6 +8,13 @@ class NATError(Exception): pass +class AddressType(Enum): + """Used for determining remote or local tags in SIP messages""" + + REMOTE = 0 + LOCAL = 1 + + class NAT: def __init__( self, @@ -26,7 +34,7 @@ def get_host(self, host: str): ip = ipaddress.ip_address(host) except ValueError: try: - ip = socket.gethostbyname(host) + ip = ipaddress.ip_address(socket.gethostbyname(host)) except socket.gaierror: raise NATError(f"Unable to resolve hostname {host}") @@ -39,3 +47,21 @@ def get_host(self, host: str): "No remote hostname specified, " + "cannot provide a return path for remote hosts." ) + + def check_host(self, host: str) -> AddressType: + """Determine if a host is a remote computer or not.""" + if host in [self.remote_hostname, self.hostname]: + return AddressType.LOCAL + try: + ip = ipaddress.ip_address(host) + if ip == self.bind_ip: + return AddressType.LOCAL + return AddressType.REMOTE + except ValueError: + try: + ip = ipaddress.ip_address(socket.gethostbyname(host)) + if ip == self.bind_ip: + return AddressType.LOCAL + return AddressType.REMOTE + except socket.gaierror: + return AddressType.REMOTE diff --git a/pyVoIP/sock/sock.py b/pyVoIP/sock/sock.py index 958670c..8937c90 100644 --- a/pyVoIP/sock/sock.py +++ b/pyVoIP/sock/sock.py @@ -1,9 +1,13 @@ from typing import List, Optional, Tuple, Union +from pyVoIP import SIP_STATE_DB_LOCATION from pyVoIP.types import KEY_PASSWORD, SOCKETS from pyVoIP.SIP import SIPMessage, SIPMessageType +from pyVoIP.SIP.error import SIPParseError +from pyVoIP.networking.nat import NAT, AddressType from pyVoIP.sock.transport import TransportMode import json import math +import pprint import pyVoIP import socket import sqlite3 @@ -48,12 +52,15 @@ def __init__( def send(self, data: Union[bytes, str]) -> None: if type(data) is str: data = data.encode("utf8") - msg = SIPMessage(data) + try: + msg = SIPMessage(data) + except SIPParseError: + return if not self.conn: # If UDP if msg.type == SIPMessageType.REQUEST: addr = (msg.to["host"], msg.to["port"]) else: - addr = msg.headers["Via"][0] + addr = msg.headers["Via"][0]["address"] self.sock.s.sendto(data, addr) else: self.conn.send(data) @@ -71,7 +78,7 @@ def __find_remote_tag(self) -> None: ) rows = result.fetchall() if rows: - print(f"Found remote: {rows[0][0]}") + # print(f"Found remote: {rows[0][0]}") self.remote_tag = rows[0][0] def recv(self, nbytes: int, timeout=0) -> bytes: @@ -83,7 +90,7 @@ def recv(self, nbytes: int, timeout=0) -> bytes: data = self.conn.recv(nbytes) try: msg = SIPMessage(data) - except Exception as e: + except SIPParseError as e: br = self.sock.gen_bad_request( connection=self, error=e, received=data ) @@ -101,20 +108,34 @@ def recv(self, nbytes: int, timeout=0) -> bytes: conn.row_factory = sqlite3.Row sql = ( 'SELECT * FROM "msgs" WHERE "call_id"=? AND "local_tag"=?' - + (""" AND "remote_tag" = ?""" if self.remote_tag else "") ) + if self.remote_tag: + sql += ( + ' UNION SELECT * FROM "msgs" WHERE "call_id"=? AND ' + + '"local_tag"=? AND "remote_tag"=?' + ) bindings = ( - (self.call_id, self.local_tag, self.remote_tag) + ( + self.call_id, + self.local_tag, + self.call_id, + self.local_tag, + self.remote_tag, + ) if self.remote_tag else (self.call_id, self.local_tag) ) result = conn.execute(sql, bindings) row = result.fetchone() if not row: + conn.close() continue - conn.execute('DELETE FROM "msgs" WHERE "id" = ?', (row["id"],)) try: self.sock.buffer.commit() + conn.execute( + 'DELETE FROM "msgs" WHERE "id" = ?', (row["id"],) + ) + self.sock.buffer.commit() except sqlite3.OperationalError: pass conn.close() @@ -135,6 +156,7 @@ def __init__( mode: TransportMode, bind_ip: str, bind_port: int, + nat: NAT, cert_file: Optional[str] = None, key_file: Optional[str] = None, key_password: KEY_PASSWORD = None, @@ -148,6 +170,7 @@ def __init__( self.s = socket.socket(socket.AF_INET, mode.socket_type) self.bind_ip: str = bind_ip self.bind_port: int = bind_port + self.nat = nat self.server_context: Optional[ssl.SSLContext] = None if mode.tls_mode: self.server_context = ssl.SSLContext( @@ -160,7 +183,9 @@ def __init__( ) self.s = self.server_context.wrap_socket(self.s, server_side=True) - self.buffer = sqlite3.connect(":memory:", check_same_thread=False) + self.buffer = sqlite3.connect( + SIP_STATE_DB_LOCATION, check_same_thread=False + ) """ RFC 3261 Section 12, Paragraph 2 states: "A dialog is identified at each UA with a dialog ID, which consists @@ -319,9 +344,9 @@ def deregister_connection(self, connection: VoIPConnection) -> None: if self.mode is not TransportMode.UDP: return self.conns_lock.acquire() - print(f"Deregistering {connection}") - print(f"{self.conns=}") - print(self.get_database_dump()) + debug(f"Deregistering {connection}") + debug(f"{self.conns=}") + debug(self.get_database_dump()) try: conn = self.buffer.cursor() result = conn.execute( @@ -348,57 +373,38 @@ def deregister_connection(self, connection: VoIPConnection) -> None: pass finally: conn.close() - print("Deregistered") - print(f"{self.conns=}") - print(self.get_database_dump()) self.conns_lock.release() - def get_database_dump(self) -> str: + def get_database_dump(self, pretty=False) -> str: conn = self.buffer.cursor() ret = "" try: result = conn.execute('SELECT * FROM "listening";') - ret += "listening: " + json.dumps(result.fetchall()) + "\n\n" + result1 = result.fetchall() result = conn.execute('SELECT * FROM "msgs";') - ret += "msgs: " + json.dumps(result.fetchall()) + "\n\n" + result2 = result.fetchall() finally: conn.close() - return ret + if pretty: + ret += "listening: " + pprint.pformat(result1) + "\n\n" + ret += "msgs: " + pprint.pformat(result2) + "\n\n" + else: + ret += "listening: " + json.dumps(result1) + "\n\n" + ret += "msgs: " + json.dumps(result2) + "\n\n" + return ret def determine_tags(self, message: SIPMessage) -> Tuple[str, str]: """ Return local_tag, remote_tag """ - # TODO: Eventually NAT will be supported for people who don't have a SIP ALG - # We will need to take that into account when determining the remote tag. to_header = message.headers["To"] from_header = message.headers["From"] to_host = to_header["host"] - to_port = to_header["port"] to_tag = to_header["tag"] if to_header["tag"] else None - from_host = from_header["host"] - from_port = from_header["port"] from_tag = from_header["tag"] if from_header["tag"] else None - if to_host == self.bind_ip and to_port == self.bind_port and to_tag: - return to_tag, from_tag - elif from_host == self.bind_ip and from_port == self.bind_port: - return from_tag, to_tag - # If there is not an exact match, see if the host at least matches. - # (But not if the hosts are the same) as asterisk likes to strip - # ports. - elif to_host != from_host: - if to_host == self.bind_ip: - return to_tag, from_tag - elif from_host == self.bind_ip: - return from_tag, to_tag - # If there is still not a match, guess the to or from tag based - # on if the message. Requests except ACK likely have us as To, - # for everthing else we're likely the From - elif ( - message.type == SIPMessageType.REQUEST and message.method != "ACK" - ): + if self.nat.check_host(to_host) is AddressType.LOCAL: return to_tag, from_tag return from_tag, to_tag @@ -421,7 +427,10 @@ def run(self) -> None: data = self.s.recv(8192) except OSError: continue - message = SIPMessage(data) + try: + message = SIPMessage(data) + except SIPParseError: + continue debug("\n\nReceived UDP Message:") debug(message.summary()) else: @@ -431,7 +440,10 @@ def run(self) -> None: continue debug(f"Received new {self.mode} connection from {addr}.") data = conn.recv(8192) - message = SIPMessage(data) + try: + message = SIPMessage(data) + except SIPParseError: + continue debug("\n\nReceived SIP Message:") debug(message.summary()) diff --git a/tests/test_functionality.py b/tests/test_functionality.py index 7e6f55b..22cebe7 100644 --- a/tests/test_functionality.py +++ b/tests/test_functionality.py @@ -2,22 +2,42 @@ from pyVoIP.VoIP.call import CallState from pyVoIP.VoIP.phone import PhoneStatus, VoIPPhone from pyVoIP.sock.transport import TransportMode +import json +import os import pytest import pyVoIP import ssl +import subprocess import sys import time + +IS_WINDOWS = True if os.name == "nt" else False TEST_CONDITION = ( "--check-functionality" not in sys.argv and "--check-func" not in sys.argv ) + +if not TEST_CONDITION and not IS_WINDOWS: + obj = json.loads( + subprocess.check_output(["docker", "network", "inspect", "bridge"]) + ) + DOCKER_GATEWAY = obj[0]["IPAM"]["Config"][0]["Gateway"] + CONTAINER_ID = list(obj[0]["Containers"].keys())[0] + CONTAINER_IP = obj[0]["Containers"][CONTAINER_ID]["IPv4Address"].split( + "/" + )[0] + REASON = "Not checking functionality" +NT_REASON = "Test always fails on Windows" pyVoIP.set_tls_security(ssl.CERT_NONE) -SERVER_HOST = "127.0.0.1" +SERVER_HOST = "127.0.0.1" if IS_WINDOWS else CONTAINER_IP +BIND_IP = "0.0.0.0" if IS_WINDOWS else DOCKER_GATEWAY UDP_PORT = 5060 TCP_PORT = 5061 TLS_PORT = 5062 +CALL_TIMEOUT = 2 # 2 seconds to answer. + @pytest.fixture def phone(): @@ -29,6 +49,7 @@ def phone(): "pass", cm, hostname="host.docker.internal", + bind_ip=BIND_IP, bind_port=5059, ) phone.start() @@ -44,6 +65,7 @@ def nopass_phone(): "nopass", CredentialsManager(), hostname="host.docker.internal", + bind_ip=BIND_IP, bind_port=5059, ) phone.start() @@ -61,6 +83,7 @@ def test_nopass(): "nopass", CredentialsManager(), hostname="host.docker.internal", + bind_ip=BIND_IP, bind_port=5059, ) assert phone.get_status() == PhoneStatus.INACTIVE @@ -86,6 +109,7 @@ def test_pass(): "pass", cm, hostname="host.docker.internal", + bind_ip=BIND_IP, bind_port=5059, ) assert phone.get_status() == PhoneStatus.INACTIVE @@ -109,6 +133,7 @@ def test_tcp_nopass(): "nopass", CredentialsManager(), hostname="host.docker.internal", + bind_ip=BIND_IP, bind_port=5059, transport_mode=TransportMode.TCP, ) @@ -135,6 +160,7 @@ def test_tcp_pass(): "pass", cm, hostname="host.docker.internal", + bind_ip=BIND_IP, bind_port=5059, transport_mode=TransportMode.TCP, ) @@ -159,6 +185,7 @@ def test_tls_nopass(): "nopass", CredentialsManager(), hostname="host.docker.internal", + bind_ip=BIND_IP, bind_port=5059, transport_mode=TransportMode.TLS, cert_file="certs/cert.crt", @@ -188,6 +215,7 @@ def test_tls_pass(): "pass", cm, hostname="host.docker.internal", + bind_ip=BIND_IP, bind_port=5059, transport_mode=TransportMode.TLS, cert_file="certs/cert.crt", @@ -205,27 +233,33 @@ def test_tls_pass(): assert phone.get_status() == PhoneStatus.INACTIVE -@pytest.mark.skip @pytest.mark.udp @pytest.mark.calling @pytest.mark.skipif(TEST_CONDITION, reason=REASON) +@pytest.mark.skipif(IS_WINDOWS, reason=NT_REASON) def test_make_call(phone): call = phone.call("answerme") + start = time.time() while call.state == CallState.DIALING: time.sleep(0.1) + if start + CALL_TIMEOUT < time.time(): + raise TimeoutError("Call was not answered before the timeout.") assert call.state == CallState.ANSWERED call.hangup() assert call.state == CallState.ENDED -@pytest.mark.skip @pytest.mark.udp @pytest.mark.calling @pytest.mark.skipif(TEST_CONDITION, reason=REASON) +@pytest.mark.skipif(IS_WINDOWS, reason=NT_REASON) def test_make_nopass_call(nopass_phone): call = nopass_phone.call("answerme") + start = time.time() while call.state == CallState.DIALING: time.sleep(0.1) + if start + CALL_TIMEOUT < time.time(): + raise TimeoutError("Call was not answered before the timeout.") assert call.state == CallState.ANSWERED call.hangup() assert call.state == CallState.ENDED @@ -235,10 +269,14 @@ def test_make_nopass_call(nopass_phone): @pytest.mark.udp @pytest.mark.calling @pytest.mark.skipif(TEST_CONDITION, reason=REASON) +@pytest.mark.skipif(IS_WINDOWS, reason=NT_REASON) def test_remote_hangup(phone): call = phone.call("answerme") + start = time.time() while call.state == CallState.DIALING: time.sleep(0.1) + if start + CALL_TIMEOUT < time.time(): + raise TimeoutError("Call was not answered before the timeout.") assert call.state == CallState.ANSWERED time.sleep(5) assert call.state == CallState.ENDED