diff --git a/NOTES b/NOTES index 543a662..6b5d2b0 100644 --- a/NOTES +++ b/NOTES @@ -1,12 +1,14 @@ -New in v1.0.0: -Originate Calls -Added blocking feature to readAudio, this makes readAudio not return until there is data to be returned. If blocking is off and data is not available, bytes(length) will be returned. -Now properly generating SIP tags to comply with the RFC. -Other bug fixes +New in v1.5.0: +Fixed bug where pyVoIP would accept all codecs proposed by the server even if not compatible. Will now only accept PCMU, PCMA, and telephone-event. +Added handling of Native Bridging tested with Asterisk 16 SIP re-invite (External RTP bridge), this seems to still have issues with Asterisk 18, but unsure if it's my hardphone. +Changed the audio read function in RTP to return b'\x80'*length instead of bytes(length), doing so stops the popping on the client side when no audio is being written. +Fixed issue with ending phone calls originated by user. +Added handling of 404 Not Found and 503 Service Unavailable errors. +Added compatiblity with Asterisk PJSIP. +Fixed bug with multithreaded calling. Currently Known Issues: -BYE request on originated calls causes a 500 Error on Asterisk 13 (other versions not tested). Unsure what causes this, reach out if you have a fix. -Currently does not work with PJSIP (Only tested with Asterisk 18) +Some issues with bridiging with Asterisk 18, and possible other versions. Bridging is not supported by all phones so it's unclear if it's supported by the softphone and hardphone I use to do my tests. Upcoming patches/changes: -Adjust code to be compatible with Asterisk PJSIP. +Add support for CANCEL requests. diff --git a/README.md b/README.md index 8a416c7..0616a2d 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # pyVoIP PyVoIP is a pure python VoIP/SIP/RTP library. Currently, it supports PCMA, PCMU, and telephone-event. -Please note this is is still in development and can only originate calls with PCMU. In future, it will be able to initiate calls in PCMA as well. +Please note this is still in development. -This library does not depend on a sound library, i.e. you can use any sound library that can handle linear sound data i.e. pyaudio or even wave. Keep in mind PCMU only supports 8000Hz, 1 channel, 8 bit, audio. +This library does not depend on a sound library, i.e. you can use any sound library that can handle linear sound data i.e. pyaudio or even wave. Keep in mind PCMU/PCMA only supports 8000Hz, 1 channel, 8 bit, audio. ## Getting Started Simply put pyVoIP into your site-packages folder. diff --git a/docs/Examples.rst b/docs/Examples.rst index 78e30ac..03a2d40 100644 --- a/docs/Examples.rst +++ b/docs/Examples.rst @@ -23,8 +23,8 @@ The following will create a phone that answers and automatically hangs up: except InvalidStateError: pass - if __name__=='__main__': - phone=VoIPPhone(, , , , callCallback=answer, myIP=) + if __name__ == '__main__': + phone = VoIPPhone(, , , , callCallback=answer, myIP=) phone.start() input('Press enter to disable the phone') phone.stop() @@ -37,13 +37,12 @@ Let's say you want to make a phone that when you call it, it plays an announceme .. code-block:: python from pyVoIP.VoIP import VoIPPhone, InvalidStateError, CallState - import audioop import time import wave def answer(call): try: - f=wave.open('announcment.wav', 'rb') + f = wave.open('announcment.wav', 'rb') frames = f.getnframes() data = f.readframes(frames) f.close() @@ -61,7 +60,7 @@ Let's say you want to make a phone that when you call it, it plays an announceme except: call.hangup() - if __name__=='__main__': + if __name__ == '__main__': phone=VoIPPhone(, , , , callCallback=answer, myIP=) phone.start() input('Press enter to disable the phone') @@ -88,13 +87,12 @@ We can use the above code to create `IVR Menus , , , , callCallback=answer, myIP=) phone.start() input('Press enter to disable the phone') diff --git a/pyVoIP/RTP.py b/pyVoIP/RTP.py index 2b1c1dc..78e9028 100644 --- a/pyVoIP/RTP.py +++ b/pyVoIP/RTP.py @@ -9,6 +9,7 @@ import time __all__ = ['add_bytes', 'byte_to_bits', 'DynamicPayloadType', 'PayloadType', 'RTPParseError', 'RTPProtocol', 'RTPPacketManager', 'RTPClient', 'TransmitType'] +debug = pyVoIP.debug def byte_to_bits(byte): byte = bin(ord(byte)).lstrip('-0b') @@ -94,6 +95,7 @@ def __str__(self): #Non-codec EVENT = "telephone-event", 8000, 0, "telephone-event" + UNKOWN = "UNKOWN", 0, 0, "UNKOWN CODEC" class RTPPacketManager(): def __init__(self): @@ -109,7 +111,7 @@ def read(self, length=160): self.bufferLock.acquire() packet = self.buffer.read(length) if len(packet)') - number = address.split('@')[0] - host = address.split('@')[1] + if len(address.split('@')) == 2: + number = address.split('@')[0] + host = address.split('@')[1] + else: + number = None + host = address self.headers[header] = {'raw': raw, 'tag': tag, 'address': address, 'number': number, 'caller': contact[0], 'host': host} elif header=="CSeq": @@ -243,9 +248,11 @@ def parseHeader(self, header, data): elif header=="Content-Length": self.headers[header] = int(data) elif header=='WWW-Authenticate' or header=="Authorization": - info = data.split(", ") + data = data.replace("Digest", "") + info = data.split(",") header_data = {} for x in info: + x = x.strip() header_data[x.split('=')[0]] = x.split('=')[1].strip('"') self.headers[header] = header_data self.authentication = header_data @@ -499,15 +506,15 @@ def __init__(self, server, port, username, password, myIP=None, myPort=5060, cal self.registerThread = None self.recvLock = Lock() - def recv(self): while self.NSD: self.recvLock.acquire() self.s.setblocking(False) try: - message = SIPMessage(self.s.recv(8192)) - #print(message.summary()) + raw = self.s.recv(8192) + message = SIPMessage(raw) + debug(message.summary()) self.parseMessage(message) except BlockingIOError: self.s.setblocking(True) @@ -519,9 +526,13 @@ def recv(self): request = self.genSIPVersionNotSupported(message) self.out.sendto(request.encode('utf8'), (self.server, self.port)) else: - print(str(e)) - #except Exception as e: - #print("SIP recv error: "+str(e)) + debug(f"SIPParseError in SIP.recv: {type(e)}, {e}") + except Exception as e: + debug(f"SIP.recv error: {type(e)}, {e}\n\n{str(raw, 'utf8')}") + if pyVoIP.DEBUG: + self.s.setblocking(True) + self.recvLock.release() + raise self.s.setblocking(True) self.recvLock.release() @@ -530,8 +541,16 @@ def parseMessage(self, message): if message.status == SIPStatus.OK: if self.callCallback != None: self.callCallback(message) + elif message.status == SIPStatus.NOT_FOUND: + if self.callCallback != None: + self.callCallback(message) + elif message.status == SIPStatus.SERVICE_UNAVAILABLE: + if self.callCallback != None: + self.callCallback(message) + elif message.status == SIPStatus.TRYING or message.status == SIPStatus.RINGING: + pass else: - print("TODO: Add 500 Error on Receiving SIP Response")#:\r\n"+message.summary()) + debug("TODO: Add 500 Error on Receiving SIP Response:\r\n"+message.summary(), "TODO: Add 500 Error on Receiving SIP Response") self.s.setblocking(True) return elif message.method == "INVITE": @@ -539,8 +558,6 @@ def parseMessage(self, message): request = self.genBusy(message) self.out.sendto(request.encode('utf8'), (self.server, self.port)) else: - request = self.genRinging(message) - self.out.sendto(request.encode('utf8'), (self.server, self.port)) self.callCallback(message) elif message.method == "BYE": self.callCallback(message) @@ -549,12 +566,13 @@ def parseMessage(self, message): elif message.method == "ACK": return else: - print("TODO: Add 400 Error on non processable request") + debug("TODO: Add 400 Error on non processable request") def start(self): self.s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - self.out = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + #self.out = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.s.bind((self.myIP, self.myPort)) + self.out = self.s register = self.register() t = Timer(1, self.recv) t.name = "SIP Recieve" @@ -668,7 +686,7 @@ def genRinging(self, request): def genAnswer(self, request, sess_id, ms, sendtype): #Generate body first for content length body = "v=0\r\n" - body += "o=pyVoIP "+sess_id+" "+sess_id+" IN IP4 "+self.myIP+"\r\n" #TODO: Check IPv4/IPv6 + body += "o=pyVoIP "+sess_id+" "+str(int(sess_id)+2)+" IN IP4 "+self.myIP+"\r\n" #TODO: Check IPv4/IPv6 body += "s=pyVoIP """+pyVoIP.__version__+"\r\n" body += "c=IN IP4 "+self.myIP+"\r\n" #TODO: Check IPv4/IPv6 body += "t=0 0\r\n" @@ -706,7 +724,7 @@ def genAnswer(self, request, sess_id, ms, sendtype): def genInvite(self, number, sess_id, ms, sendtype, branch, call_id): #Generate body first for content length body = "v=0\r\n" - body += "o=pyVoIP "+sess_id+" "+sess_id+" IN IP4 "+self.myIP+"\r\n" #TODO: Check IPv4/IPv6 + body += "o=pyVoIP "+sess_id+" "+str(int(sess_id)+2)+" IN IP4 "+self.myIP+"\r\n" #TODO: Check IPv4/IPv6 body += "s=pyVoIP """+pyVoIP.__version__+"\r\n" body += "c=IN IP4 "+self.myIP+"\r\n" #TODO: Check IPv4/IPv6 body += "t=0 0\r\n" @@ -754,7 +772,7 @@ def genBye(self, request): byeRequest += "To: "+request.headers['From']['raw']+";tag="+request.headers['From']['tag']+"\r\n" byeRequest += "From: "+request.headers['To']['raw']+";tag="+tag+"\r\n" byeRequest += "Call-ID: "+request.headers['Call-ID']+"\r\n" - byeRequest += "CSeq: "+str(self.byeCounter.next())+" BYE\r\n" + byeRequest += "CSeq: "+str(int(request.headers['CSeq']['check'])+1)+" BYE\r\n" byeRequest += "Contact: \r\n" byeRequest += "User-Agent: pyVoIP """+pyVoIP.__version__+"\r\n" byeRequest += "Allow: "+(", ".join(pyVoIP.SIPCompatibleMethods))+"\r\n" @@ -785,22 +803,21 @@ def invite(self, number, ms, sendtype): self.recvLock.acquire() #print("Locked") self.out.sendto(invite.encode('utf8'), (self.server, self.port)) - #print('Invited') + debug('Invited') response = SIPMessage(self.s.recv(8192)) - while response.status != SIPStatus(401) and response.status != SIPStatus(100) and response.status != SIPStatus(180): + while (response.status != SIPStatus(401) and response.status != SIPStatus(100) and response.status != SIPStatus(180)) or response.headers['Call-ID'] != call_id: if not self.NSD: break - if response.status == SIPStatus(100) or response.status == SIPStatus(180): - return SIPMessage(invite.encode('utf8')), call_id, sess_id self.parseMessage(response) response = SIPMessage(self.s.recv(8192)) - #print("Received Response: "+response.summary()) - + if response.status == SIPStatus(100) or response.status == SIPStatus(180): + return SIPMessage(invite.encode('utf8')), call_id, sess_id + debug("Received Response: "+response.summary()) ack = self.genAck(response) self.out.sendto(ack.encode('utf8'), (self.server, self.port)) - #print("Acknowledged") + debug("Acknowledged") authhash = self.genAuthorization(response) nonce = response.authentication['nonce'] auth = 'Authorization: Digest username="'+self.username @@ -815,6 +832,7 @@ def invite(self, number, ms, sendtype): self.out.sendto(invite.encode('utf8'), (self.server, self.port)) self.recvLock.release() + #print("Released") return SIPMessage(invite.encode('utf8')), call_id, sess_id def bye(self, request): @@ -869,6 +887,9 @@ def register(self): else: self.parseMessage(response) + #debug(response.summary()) + #debug(response.raw) + regRequest = self.genRegister(response) self.out.sendto(regRequest.encode('utf8'), (self.server, self.port)) diff --git a/pyVoIP/VoIP.py b/pyVoIP/VoIP.py index cb06510..5852c15 100644 --- a/pyVoIP/VoIP.py +++ b/pyVoIP/VoIP.py @@ -5,8 +5,10 @@ import pyVoIP import random import socket +import warnings __all__ = ['CallState', 'InvalidRangeError', 'InvalidStateError', 'VoIPCall', 'VoIPPhone'] +debug = pyVoIP.debug class InvalidRangeError(Exception): pass @@ -60,7 +62,7 @@ def __init__(self, phone, callstate, request, session_id, myIP, portRange=(10000 self.videoPorts += x['port_count'] video.append(x) else: - print("Unknown media description: "+x['type']) + warnings.warn("Unknown media description: "+x['type'], stacklevel=2) #Ports Adjusted is used in case of multiple m=audio or m=video tags. if len(audio) > 0: @@ -73,7 +75,7 @@ def __init__(self, phone, callstate, request, session_id, myIP, portRange=(10000 videoPortsAdj = 0 if not ((audioPortsAdj == self.connections or self.audioPorts == 0) and (videoPortsAdj == self.connections or self.videoPorts == 0)): - print("Unable to assign ports for RTP.") + warnings.warn("Unable to assign ports for RTP.", stacklevel=2) #TODO: Throw error to PBX in this case return for i in request.body['m']: @@ -88,21 +90,32 @@ def __init__(self, phone, callstate, request, session_id, myIP, portRange=(10000 p = RTP.PayloadType(i['attributes'][x]['rtpmap']['name']) assoc[int(x)] = p except ValueError: - e = True + #e = True + pt = i['attributes'][x]['rtpmap']['name'] + warnings.warn(f"RTP Payload type {pt} not found.", stacklevel=20) + warnings.simplefilter("default") #Resets the warning filter so this warning will come up again if it happens. However, this also resets all other warnings as well. + p = RTP.PayloadType("UNKOWN") + assoc[int(x)] = p if e: - raise RTP.ParseError("RTP Payload type {} not found.".format(str(pt))) + raise RTP.RTPParseError("RTP Payload type {} not found.".format(str(pt))) + + #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 = 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] = assoc + self.assignedPorts[proposed] = codecs port = proposed for ii in range(len(request.body['c'])): - offset = ii * 2 - self.RTPClients.append(RTP.RTPClient(assoc, self.myIP, port, request.body['c'][ii]['address'], i['port']+ii, request.body['a']['transmit_type'], dtmf=self.dtmfCallback)) #TODO: Check IPv4/IPv6 + 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: self.ms = ms for m in self.ms: @@ -123,13 +136,27 @@ def getDTMF(self, length=1): self.dtmfLock.release() return packet - def answer(self): - if self.state != CallState.RINGING: - raise InvalidStateError("Call is not ringing") + def genMs(self): #For answering originally and for re-negotiations m = {} for x in self.RTPClients: x.start() m[x.inPort] = x.assoc + + return m + + def renegotiate(self, request): + m = self.genMs() + message = self.sip.genAnswer(request, self.session_id, m, request.body['a']['transmit_type']) + self.sip.out.sendto(message.encode('utf8'), (self.phone.server, self.phone.port)) + for i in request.body['m']: + for ii, client in zip(range(len(request.body['c'])), self.RTPClients): + client.outIP = request.body['c'][ii]['address'] + client.outPort = i['port']+ii #TODO: Check IPv4/IPv6 + + def answer(self): + if self.state != CallState.RINGING: + raise InvalidStateError("Call is not ringing") + m = self.genMs() message = self.sip.genAnswer(self.request, self.session_id, m, self.request.body['a']['transmit_type']) self.sip.out.sendto(message.encode('utf8'), (self.phone.server, self.phone.port)) self.state = CallState.ANSWERED @@ -157,7 +184,6 @@ def answered(self, request): for ii in range(len(request.body['c'])): - offset = ii * 2 self.RTPClients.append(RTP.RTPClient(assoc, self.myIP, self.port, request.body['c'][ii]['address'], i['port']+ii, request.body['a']['transmit_type'], dtmf=self.dtmfCallback)) #TODO: Check IPv4/IPv6 for x in self.RTPClients: @@ -165,7 +191,33 @@ def answered(self, request): self.request.headers['Contact'] = request.headers['Contact'] self.request.headers['To']['tag'] = request.headers['To']['tag'] self.state = CallState.ANSWERED - + + def notFound(self, request): + if self.state != CallState.DIALING: + debug(f"TODO: 500 Error, received a not found response for a call not in the dailing state. Call: {self.call_id}, Call State: {self.state}") + return + + for x in self.RTPClients: + x.stop() + self.state = CallState.ENDED + del self.phone.calls[self.request.headers['Call-ID']] + debug("Call not found and terminated") + warnings.warn(f"The number '{request.headers['To']['number']}' was not found. Did you call the wrong number? CallState set to CallState.ENDED.", stacklevel=20) + warnings.simplefilter("default") #Resets the warning filter so this warning will come up again if it happens. However, this also resets all other warnings as well. + + def unavailable(self, request): + if self.state != CallState.DIALING: + debug(f"TODO: 500 Error, received an unavailable response for a call not in the dailing state. Call: {self.call_id}, Call State: {self.state}") + return + + for x in self.RTPClients: + x.stop() + self.state = CallState.ENDED + del self.phone.calls[self.request.headers['Call-ID']] + debug("Call unavailable and terminated") + warnings.warn(f"The number '{request.headers['To']['number']}' was unavailable. CallState set to CallState.ENDED.", stacklevel=20) + warnings.simplefilter("default") #Resets the warning filter so this warning will come up again if it happens. However, this also resets all other warnings as well. + def deny(self): if self.state != CallState.RINGING: raise InvalidStateError("Call is not ringing") @@ -232,22 +284,29 @@ def __init__(self, server, port, username, password, callCallback=None, myIP=Non def callback(self, request): call_id = request.headers['Call-ID'] - #print("Callback: "+request.summary()) + #debug("Callback: "+request.summary()) if request.type == pyVoIP.SIP.SIPMessageType.MESSAGE: - #print("This is a message") + #debug("This is a message") if request.method == "INVITE": if call_id in self.calls: + debug("Re-negotiation detected!") + self.calls[call_id].renegotiate(request) + #message = self.sip.genAnswer(request, self.calls[call_id].session_id, self.calls[call_id].genMs(), request.body['a']['transmit_type']) + #self.sip.out.sendto(message.encode('utf8'), (self.server, self.port)) return #Raise Error if self.callCallback == None: message = self.sip.genBusy(request) self.sip.out.sendto(message.encode('utf8'), (self.server, self.port)) else: + debug("New call!") sess_id = None while sess_id == None: proposed = random.randint(1, 100000) if not proposed in self.session_ids: self.session_ids.append(proposed) 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)) try: t = Timer(1, self.callCallback, [self.calls[call_id]]) @@ -263,12 +322,30 @@ def callback(self, request): self.calls[call_id].bye() else: if request.status == SIP.SIPStatus.OK: - #print("OK recieved") + debug("OK recieved") if not call_id in self.calls: - #print("Unknown call") + debug("Unknown call") return self.calls[call_id].answered(request) - #print("Answered") + debug("Answered") + ack = self.sip.genAck(request) + self.sip.out.sendto(ack.encode('utf8'), (self.server, self.port)) + elif request.status == SIP.SIPStatus.NOT_FOUND: + debug("Not Found recieved, invalid number called?") + if not call_id in self.calls: + debug("Unkown call") + debug("TODO: Add 481 here as server is probably waiting for an ACK") + self.calls[call_id].notFound(request) + debug("Terminating Call") + ack = self.sip.genAck(request) + self.sip.out.sendto(ack.encode('utf8'), (self.server, self.port)) + elif request.status == SIP.SIPStatus.SERVICE_UNAVAILABLE: + debug("Service Unavailable recieved") + if not call_id in self.calls: + debug("Unkown call") + debug("TODO: Add 481 here as server is probably waiting for an ACK") + self.calls[call_id].unavailable(request) + debug("Terminating Call") ack = self.sip.genAck(request) self.sip.out.sendto(ack.encode('utf8'), (self.server, self.port)) diff --git a/pyVoIP/__init__.py b/pyVoIP/__init__.py index d85c1bc..636f9bd 100644 --- a/pyVoIP/__init__.py +++ b/pyVoIP/__init__.py @@ -1,10 +1,22 @@ + __all__ = ['SIP', 'RTP', 'VoIP'] -version_info = (1, 0, 0) +version_info = (1, 5, 0) __version__ = ".".join([str(x) for x in version_info]) +DEBUG = False + +def debug(s, e=None): + if DEBUG: + print(s) + elif e is not None: + print(e) + +from pyVoIP.RTP import PayloadType + SIPCompatibleMethods = ['INVITE', 'ACK', 'BYE'] SIPCompatibleVersions = ['SIP/2.0'] -RTPCompatibleVersions = [2] \ No newline at end of file +RTPCompatibleVersions = [2] +RTPCompatibleCodecs = [PayloadType.PCMU, PayloadType.PCMA, PayloadType.EVENT] \ No newline at end of file