From 5ca5c58e0d08c771d2925fe662c6feb9ed159d17 Mon Sep 17 00:00:00 2001 From: Tayler Porter Date: Sun, 10 Apr 2022 22:00:07 -0500 Subject: [PATCH] [FIX] Fixed #27 [FIX] Fixed #38 [FIX] Fixed improper error raise in SIPClient.start() [FIX] Fixed infinant loop not stopping on shutdown in SIPClient.genTag() [ADD] Added NoPortsAvailableError in VoIP [ADD] Added VoIPPhone.request_port(blocking=True). If blocking is set to false, NoPortsAvailableError may be raised, otherwise will wait. [ADD] Added VoIPPhone.release_ports(call: Optional[VoIPCall] = None). If call is provided, release ports assinged to call, Otherwise release all ports not in use. [ADD] Added VoIPPhone._cleanup_dead_calls(). It handles dead threads. [CHANGE] Changed VoIPCall to not take portRange. It is now pulled from VoIPPhone [CHANGE] Changed all instances of port assignment to VoIPPhone.request_port() [CHANGE] Changed VoIPPhone.start() to except BaseException instead of Exception [CHANGE] Changed version to 1.5.5 [CHANGE] Changed docs to reflect changes. --- docs/VoIP.rst | 20 +++++++-- docs/conf.py | 2 +- pyVoIP/SIP.py | 4 +- pyVoIP/VoIP.py | 102 +++++++++++++++++++++++++++++++++++---------- pyVoIP/__init__.py | 2 +- setup.py | 2 +- 6 files changed, 101 insertions(+), 31 deletions(-) diff --git a/docs/VoIP.rst b/docs/VoIP.rst index 26f1272..7a36d94 100644 --- a/docs/VoIP.rst +++ b/docs/VoIP.rst @@ -15,6 +15,9 @@ There are two errors under ``pyVoIP.VoIP``. *exception* VoIP.\ **InvalidRangeError** This is thrown by :ref:`VoIPPhone` when you define the rtpPort ranges as rtpPortLow > rtpPortHigh. However this is not checked by :ref:`VoIPCall`, so if you are using your own class instead of VoIPPhone, make sure these ranges are correct. + +*exception* VoIP.\ **NoPortsAvailableError** + This is thrown when a call is attempting to be initiated but no ports are available. Enums *********** @@ -52,16 +55,19 @@ VoIPCall The VoIPCall class is used to represent a single VoIP Session, which may be to multiple :term:`clients`. -*class* VoIP.\ **VoIPCall**\ (phone, request, session_id, myIP, rtpPortLow, rtpPortHigh) +*class* VoIP.\ **VoIPCall**\ (phone, callstate, request, session_id, myIP, ms) The *phone* argument is the initating instance of :ref:`VoIPPhone`. + The *callstate* arguement is the initiating :ref:`CallState`. + The *request* argument is the :ref:`SIPMessage` representation of the SIP INVITE request from the VoIP server. The *session_id* argument is a unique code used to identify the session with `SDP `_ when answering the call. The *myIP* argument is the IP address it will pass to :ref:`RTPClient`'s to bind to. - The *rtpPortLow* and *rtpPortHigh* arguments are used to generate random ports to use for audio transfer. Per RFC 4566 Sections `5.7 `_ and `5.14 `_, it can take multiple ports to fully communicate with other :term:`clients`, as such a large range is recommended. + The *ms* arguement is a dictionary with int as the key and a :ref:`PayloadType` as the value. This is only used when originating the call. + **dtmfCallback**\ (code) This method is called by :ref:`RTPClient`'s when a telephone-event DTMF message is received. The *code* argument is a string. It should be an Event in complinace with `RFC 4733 Section 3.2 `_. @@ -126,10 +132,16 @@ The VoIPPhone class is used to manage the :ref:`SIPClient` class and create :ref The *rtpPortLow* and *rtpPortHigh* arguments are used to generate random ports to use for audio transfer. Per RFC 4566 Sections `5.7 `_ and `5.14 `_, it can take multiple ports to fully communicate with other :term:`clients`, as such a large range is recommended. If an invalid range is given, a :ref:`InvalidStateError` will be thrown. **callback**\ (request) - This method is called by the :ref:`SIPClient` when an INVITE or BYE request is received. This function then creates a :ref:`VoIPCall` or terminates it respectively. When a VoIPCall is created, it will then pass it to the *callCallback* function as an argument. If *callCallback* is set to None, this function replies as BUSY. **This function should not be called by the** :term:`user`. + This method is called by the :ref:`SIPClient` when an INVITE or BYE request is received. This method then creates a :ref:`VoIPCall` or terminates it respectively. When a VoIPCall is created, it will then pass it to the *callCallback* function as an argument. If *callCallback* is set to None, this function replies as BUSY. **This function should not be called by the** :term:`user`. + + **request_port**\ (blocking=True) + This method is called when a new port is needed to use in a :ref:`VoIPCall`. If blocking is set to True, this will wait until a port is available. Otherwise, it will raise NoPortsAvailableError. + + **release_ports**\ (call=None) + This method is called when a call ends. If call is provided, it will only release the ports used by that :ref:`VoIPCall`. Otherwise, it will iterate through all active calls, and release all ports that are no longer in use. **start**\ () - This method starts the :ref:`SIPClient` class. + This method starts the :ref:`SIPClient` class. On failure, this will automatically call stop(). **stop**\ () This method ends all currently ongoing calls, then stops the :ref:`SIPClient` class diff --git a/docs/conf.py b/docs/conf.py index 5551ede..6cb4d1b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,7 +24,7 @@ author = 'Tayler J Porter' # The full version, including alpha/beta/rc tags -release = '1.5.4' +release = '1.5.5' master_doc = 'index' diff --git a/pyVoIP/SIP.py b/pyVoIP/SIP.py index 328f9fb..5bd9867 100644 --- a/pyVoIP/SIP.py +++ b/pyVoIP/SIP.py @@ -570,7 +570,7 @@ def parseMessage(self, message): def start(self): if self.NSD == True: - raise RunTimeError("Attempted to start already started SIPClient") + raise RuntimeError("Attempted to start already started SIPClient") self.NSD = True self.s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) #self.out = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) @@ -592,7 +592,7 @@ def genCallID(self): return hashlib.sha256(str(self.callID.next()).encode('utf8')).hexdigest()[0:32]+"@"+self.myIP+":"+str(self.myPort) def genTag(self): - while True: + while self.NSD: tag = hashlib.md5(str(random.randint(1, 4294967296)).encode('utf8')).hexdigest()[0:8] if tag not in self.tags: self.tags.append(tag) diff --git a/pyVoIP/VoIP.py b/pyVoIP/VoIP.py index ffd1429..35910e7 100644 --- a/pyVoIP/VoIP.py +++ b/pyVoIP/VoIP.py @@ -1,13 +1,15 @@ from enum import Enum from pyVoIP import SIP, RTP from threading import Timer, Lock +from typing import Optional import io import pyVoIP import random import socket +import time import warnings -__all__ = ['CallState', 'InvalidRangeError', 'InvalidStateError', 'VoIPCall', 'VoIPPhone'] +__all__ = ['CallState', 'InvalidRangeError', 'InvalidStateError', 'NoPortsAvailableError', 'VoIPCall', 'VoIPPhone'] debug = pyVoIP.debug class InvalidRangeError(Exception): @@ -16,6 +18,9 @@ class InvalidRangeError(Exception): class InvalidStateError(Exception): pass +class NoPortsAvailableError(Exception): + pass + class CallState(Enum): DIALING = "DIALING" RINGING = "RINGING" @@ -26,7 +31,7 @@ class CallState(Enum): For initiating a phone call, try sending the packet and the recieved OK packet will be sent to the VoIPCall request header. ''' class VoIPCall(): - def __init__(self, phone, callstate, request, session_id, myIP, portRange=(10000, 20000), ms = None): + def __init__(self, phone, callstate, request, session_id, myIP, ms = None): self.state = callstate self.phone = phone self.sip = self.phone.sip @@ -34,8 +39,8 @@ def __init__(self, phone, callstate, request, session_id, myIP, portRange=(10000 self.call_id = request.headers['Call-ID'] self.session_id = str(session_id) self.myIP = myIP - self.rtpPortHigh = portRange[1] - self.rtpPortLow = portRange[0] + self.rtpPortHigh = self.phone.rtpPortHigh + self.rtpPortLow = self.phone.rtpPortLow self.dtmfLock = Lock() self.dtmf = io.StringIO() @@ -107,13 +112,7 @@ def __init__(self, phone, callstate, request, session_id, myIP, portRange=(10000 codecs[m] = assoc[m] #TODO: If no codecs are compatible then send error to PBX. - port = None - while port == None: - proposed = random.randint(self.rtpPortLow, self.rtpPortHigh) - if not proposed in self.phone.assignedPorts: - self.phone.assignedPorts.append(proposed) - self.assignedPorts[proposed] = codecs - port = proposed + port = self.phone.request_port() for ii in range(len(request.body['c'])): self.RTPClients.append(RTP.RTPClient(codecs, self.myIP, port, request.body['c'][ii]['address'], i['port']+ii, request.body['a']['transmit_type'], dtmf=self.dtmfCallback)) #TODO: Check IPv4/IPv6 elif callstate == CallState.DIALING: @@ -122,6 +121,9 @@ def __init__(self, phone, callstate, request, session_id, myIP, portRange=(10000 self.port = m self.assignedPorts[m] = self.ms[m] + def __del__(self): + self.phone.release_ports(call=self) + def dtmfCallback(self, code): self.dtmfLock.acquire() bufferloc = self.dtmf.tell() @@ -223,8 +225,10 @@ def deny(self): raise InvalidStateError("Call is not ringing") message = self.sip.genBusy(self.request) self.sip.out.sendto(message.encode('utf8'), (self.phone.server, self.phone.port)) - self.RTPClients = [] + for x in self.RTPClients: + x.stop() self.state = CallState.ENDED + del self.phone.calls[self.request.headers['Call-ID']] def hangup(self): if self.state != CallState.ANSWERED: @@ -265,7 +269,9 @@ def __init__(self, server, port, username, password, callCallback=None, myIP=Non self.rtpPortLow = rtpPortLow self.rtpPortHigh = rtpPortHigh + self.NSD = False + self.portsLock = Lock() self.assignedPorts = [] self.session_ids = [] @@ -280,6 +286,8 @@ def __init__(self, server, port, username, password, callCallback=None, myIP=Non self.callCallback = callCallback self.calls = {} + self.threads = [] + self.threadLookup = {} # Allows you to find call ID based of thread. self.sip = SIP.SIPClient(server, port, username, password, myIP=self.myIP, myPort=sipPort, callCallback=self.callback) def callback(self, request): @@ -312,10 +320,12 @@ def callback(self, request): t = Timer(1, self.callCallback, [self.calls[call_id]]) t.name = "Phone Call: "+call_id t.start() - except Exception as e: + self.threads.append(t) + self.threadLookup[t] = call_id + except Exception: message = self.sip.genBusy(request) self.sip.out.sendto(message.encode('utf8'), (self.server, self.port)) - raise e + raise elif request.method == "BYE": if not call_id in self.calls: return @@ -348,12 +358,65 @@ def callback(self, request): debug("Terminating Call") ack = self.sip.genAck(request) self.sip.out.sendto(ack.encode('utf8'), (self.server, self.port)) - + + def request_port(self, blocking=True) -> int: + ports_available = [port for port in range(self.rtpPortLow, + self.rtpPortHigh + 1) if port not in self.assignedPorts] + + while self.NSD and blocking and len(ports_available) == 0: + ports_available = [port for port in range(self.rtpPortLow, + self.rtpPortHigh + 1) if (port not in + self.assignedPorts)] + time.sleep(.5) + + if len(ports_available) == 0: + raise NoPortsAvailableError("No ports were available to be assigned") + + selection = random.choice(ports_available) + self.assignedPorts.append(selection) + + return selection + + def release_ports(self, call: Optional[VoIPCall] = None) -> None: + self.portsLock.acquire() + self._cleanup_dead_calls() + try: + if isinstance(call, VoIPCall): + ports = list(call.assignedPorts.keys()) + else: + dnr_ports = [] + for call_id in self.calls: + dnr_ports += list(self.calls[call_id].assignedPorts.keys()) + ports = [] + for port in self.assignedPorts: + if port not in dnr_ports: + ports.append(port) + + for port in ports: + index = self.assignedPorts.index(port) + self.assignedPorts.pop(index) + finally: + self.portsLock.release() + + def _cleanup_dead_calls(self) -> None: + to_delete =[] + for thread in self.threads: + if not thread.is_alive(): + call_id = self.threadLookup[thread] + del self.calls[call_id] + del self.threadLookup[thread] + to_delete.append(thread) + for thread in to_delete: + index = self.threads.index(thread) + self.threads.pop(thread) + def start(self): try: self.sip.start() - except Exception: + self.NSD = True + except BaseException: self.sip.stop() + self.NSD = False raise def stop(self): @@ -365,12 +428,7 @@ def stop(self): self.sip.stop() def call(self, number): - port = None - while port == None: - proposed = random.randint(self.rtpPortLow, self.rtpPortHigh) - if not proposed in self.assignedPorts: - self.assignedPorts.append(proposed) - port = proposed + port = self.request_port() medias = {} medias[port] = {0: pyVoIP.RTP.PayloadType.PCMU, 101: pyVoIP.RTP.PayloadType.EVENT} request, call_id, sess_id = self.sip.invite(number, medias, pyVoIP.RTP.TransmitType.SENDRECV) diff --git a/pyVoIP/__init__.py b/pyVoIP/__init__.py index 5fd3d60..12986b9 100644 --- a/pyVoIP/__init__.py +++ b/pyVoIP/__init__.py @@ -1,7 +1,7 @@ __all__ = ['SIP', 'RTP', 'VoIP'] -version_info = (1, 5, 4) +version_info = (1, 5, 5) __version__ = ".".join([str(x) for x in version_info]) diff --git a/setup.py b/setup.py index af07351..44b0e36 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='pyVoIP', - version='1.5.4', + version='1.5.5', description='PyVoIP is a pure python VoIP/SIP/RTP library.', long_description=long_description, long_description_content_type="text/markdown",