Skip to content

Commit

Permalink
Merge pull request tayler6000#39 from tayler6000/bugfix/issue_27
Browse files Browse the repository at this point in the history
  • Loading branch information
Input-BDF authored Apr 16, 2022
2 parents 0541220 + b0ac959 commit 2c2059f
Show file tree
Hide file tree
Showing 6 changed files with 120 additions and 36 deletions.
20 changes: 16 additions & 4 deletions docs/VoIP.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
***********
Expand Down Expand Up @@ -52,16 +55,19 @@ VoIPCall

The VoIPCall class is used to represent a single VoIP Session, which may be to multiple :term:`clients<client>`.

*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<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 <https://tools.ietf.org/html/rfc4566#section-5.2>`_ 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 <https://tools.ietf.org/html/rfc4566#section-5.7>`_ and `5.14 <https://tools.ietf.org/html/rfc4566#section-5.14>`_, it can take multiple ports to fully communicate with other :term:`clients<client>`, as such a large range is recommended.
The *ms* arguement is a dictionary with int as the key and a :ref:`PayloadType<payload-type>` 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 <https://tools.ietf.org/html/rfc4733#section-3.2>`_.
Expand Down Expand Up @@ -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 <https://tools.ietf.org/html/rfc4566#section-5.7>`_ and `5.14 <https://tools.ietf.org/html/rfc4566#section-5.14>`_, it can take multiple ports to fully communicate with other :term:`clients<client>`, as such a large range is recommended. If an invalid range is given, a :ref:`InvalidStateError<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
Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
18 changes: 12 additions & 6 deletions pyVoIP/SIP.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -583,16 +583,22 @@ def start(self):

def stop(self):
self.NSD = False
self.registerThread.cancel()
self.deregister()
self.s.close()
self.out.close()
if self.registerThread:
self.registerThread.cancel()
self.deregister()
self._close_sockets()

def _close_sockets(self):
if hasattr(self, 's') and self.s:
self.s.close()
if hasattr(self, 'out') and self.out:
self.out.close()

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 True: # Keep as True instead of NSD so it can generate a tag on deregister.
tag = hashlib.md5(str(random.randint(1, 4294967296)).encode('utf8')).hexdigest()[0:8]
if tag not in self.tags:
self.tags.append(tag)
Expand Down
112 changes: 89 additions & 23 deletions pyVoIP/VoIP.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -16,6 +18,9 @@ class InvalidRangeError(Exception):
class InvalidStateError(Exception):
pass

class NoPortsAvailableError(Exception):
pass

class CallState(Enum):
DIALING = "DIALING"
RINGING = "RINGING"
Expand All @@ -26,16 +31,16 @@ 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
self.request = request
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()
Expand Down Expand Up @@ -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:
Expand All @@ -122,6 +121,10 @@ def __init__(self, phone, callstate, request, session_id, myIP, portRange=(10000
self.port = m
self.assignedPorts[m] = self.ms[m]

def __del__(self):
if hasattr(self, 'phone'):
self.phone.release_ports(call=self)

def dtmfCallback(self, code):
self.dtmfLock.acquire()
bufferloc = self.dtmf.tell()
Expand Down Expand Up @@ -223,8 +226,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:
Expand Down Expand Up @@ -265,7 +270,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 = []

Expand All @@ -280,6 +287,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):
Expand Down Expand Up @@ -307,15 +316,17 @@ def callback(self, request):
sess_id = proposed
message = self.sip.genRinging(request)
self.sip.out.sendto(message.encode('utf8'), (self.server, self.port))
self.calls[call_id] = VoIPCall(self, CallState.RINGING, request, sess_id, self.myIP, portRange=(self.rtpPortLow, self.rtpPortHigh))
self.calls[call_id] = VoIPCall(self, CallState.RINGING, request, sess_id, self.myIP))
try:
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
Expand Down Expand Up @@ -348,12 +359,72 @@ 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]
if len(ports_available) == 0:
# If no ports are available attempt to cleanup any missed calls.
self.release_ports()
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)
self.release_ports()

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):
Expand All @@ -365,12 +436,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)
Expand Down
2 changes: 1 addition & 1 deletion pyVoIP/__init__.py
Original file line number Diff line number Diff line change
@@ -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])

Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down

0 comments on commit 2c2059f

Please sign in to comment.