From 087ba69169649ceb073f909fc29388f51322c150 Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 18 Jul 2020 23:06:40 +0900 Subject: [PATCH] Initial push of documentation and pyVoIP 0.5.0 Beta --- docs/Makefile | 20 ++ docs/make.bat | 35 ++ docs/source/Examples.rst | 126 +++++++ docs/source/RTP.rst | 226 ++++++++++++ docs/source/SIP.rst | 188 ++++++++++ docs/source/VoIP.rst | 132 +++++++ docs/source/conf.py | 57 +++ docs/source/index.rst | 32 ++ pyVoIP/RTP.py | 352 +++++++++++++++++++ pyVoIP/SIP.py | 737 +++++++++++++++++++++++++++++++++++++++ pyVoIP/VoIP.py | 230 ++++++++++++ pyVoIP/__init__.py | 10 + 12 files changed, 2145 insertions(+) create mode 100644 docs/Makefile create mode 100644 docs/make.bat create mode 100644 docs/source/Examples.rst create mode 100644 docs/source/RTP.rst create mode 100644 docs/source/SIP.rst create mode 100644 docs/source/VoIP.rst create mode 100644 docs/source/conf.py create mode 100644 docs/source/index.rst create mode 100644 pyVoIP/RTP.py create mode 100644 pyVoIP/SIP.py create mode 100644 pyVoIP/VoIP.py create mode 100644 pyVoIP/__init__.py diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..9534b01 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/Examples.rst b/docs/source/Examples.rst new file mode 100644 index 0000000..78e30ac --- /dev/null +++ b/docs/source/Examples.rst @@ -0,0 +1,126 @@ +Examples +######## + +Here we will go over a few basic phone setups. + +Setup +***** + +PyVoIP uses callback functions to initiate phone calls. In the example below, our callback function is named answer. The callback takes one argument, which is a :ref:`VoIPCall` class. + +We are also importing :ref:`VoIPPhone` and :ref:`InvalidStateError`. VoIPPhone is the main class for our `softphone `_. An InvalidStateError is thrown when you try to perform an impossible command. For example, denying the call when the phone is already answered, answering when it's already answered, etc. + +The following will create a phone that answers and automatically hangs up: + +.. code-block:: python + + from pyVoIP.VoIP import VoIPPhone, InvalidStateError + + def answer(call): + try: + call.answer() + call.hangup() + except InvalidStateError: + pass + + if __name__=='__main__': + phone=VoIPPhone(, , , , callCallback=answer, myIP=) + phone.start() + input('Press enter to disable the phone') + phone.stop() + +Announcement Board +****************** + +Let's say you want to make a phone that when you call it, it plays an announcement message, then hangs up. We can accomplish this with the builtin libraries `wave `_, `audioop `_, `time `_ and by importing :ref:`CallState`. + +.. 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') + frames = f.getnframes() + data = f.readframes(frames) + f.close() + + call.answer() + call.writeAudio(data) #This writes the audio data to the transmit buffer, this must be bytes. + + stop = time.time()+(frames/8000) #The number of frames/8000 is the length of the audio in seconds. + + while time.time() <= stop and call.state == CallState.ANSWERED: + time.sleep(1) + call.hangup() + except InvalidStateError: + pass + except: + call.hangup() + + if __name__=='__main__': + phone=VoIPPhone(, , , , callCallback=answer, myIP=) + phone.start() + input('Press enter to disable the phone') + phone.stop() + +Something important to note is our wait function. We are currently using: + +.. code-block:: python + + stop = time.time()+(frames/8000) #The number of frames/8000 is the length of the audio in seconds. + + while time.time() <= stop and call.state == CallState.ANSWERED: + time.sleep(1) + +This could be replaced with ``time.sleep(frames/8000)``. However, doing so will not cause the thread to automatically close if the user hangs up, or if ``VoIPPhone().stop()`` is called; using the while loop method will fix this issue. The ``time.sleep(1)`` inside the while loop is also important. Supplementing ``time.sleep(1)`` for ``pass`` will cause your CPU to ramp up while running the loop, making the RTP (audio being sent out) lag. This can make the voice audibly slow or choppy. + +*Note: Audio must be in 8 bit, 8000Hz, and Mono/1 channel. You can accomplish this in a free program called* `Audacity `_. *To make an audio recording Mono, go to Tracks > Mix > Mix Stereo Down to Mono. To make an audio recording 8000 Hz, go to Tracks > Resample... and select 8000, then ensure that your 'Project Rate' in the bottom left is also set to 8000. To make an audio recording 8 bit, go to File > Export > Export as WAV, then change 'Save as type:' to 'Other uncompressed files', then set 'Header:' to 'WAV (Microsoft)', then set the 'Encoding:' to 'Unsigned 8-bit PCM'* + +IVR/Phone Menus +**************** + +We can use the above code to create `IVR Menus `_. Currently, we cannot make 'breaking' IVR menus. Breaking IVR menus in this context means, a user selecting an option mid-prompt will cancel the prompt, and start the next action. Support for breaking IVR's will be made in the future. For now, here is the code for a non-breaking IVR: + +.. code-block:: python + + from pyVoIP.VoIP import VoIPPhone, InvalidStateError, CallState + import audioop + import time + import wave + + def answer(call): + try: + f=wave.open('prompt.wav', 'rb') + frames = f.getnframes() + data = f.readframes(frames) + f.close() + + call.answer() + call.writeAudio(data) + + while call.state == CallState.ANSWERED: + dtmf = call.getDTMF() + if dtmf == "1": + #Do Something + call.hangup() + elif dtmf == "2": + #Do Something Else + call.hangup() + time.sleep(1) + except InvalidStateError: + pass + except: + call.hangup() + + if __name__=='__main__': + phone=VoIPPhone(, , , , callCallback=answer, myIP=) + phone.start() + input('Press enter to disable the phone') + phone.stop() + +Please note that ``getDTMF()`` is actually ``getDTMF(length=1)``, and as it is technically a ``io.StringBuffer()``, it will return ``''`` instead of ``None``. This may be important if you wanted an 'if anything else, do that' clause. Lastly, VoIPCall stores all DTMF keys pressed since the call was established; meaning, users can technically press any key they want before the prompt even finishes, or may press a wrong key before the prompt even starts. + diff --git a/docs/source/RTP.rst b/docs/source/RTP.rst new file mode 100644 index 0000000..d11b1c4 --- /dev/null +++ b/docs/source/RTP.rst @@ -0,0 +1,226 @@ +RTP - Real-time Transport Protocol +################################### + +The RTP module recives and transmits, sound and phone-event data for a particular phone call. + +The RTP module has two methods that are used by various classes for packet parsing. + +RTP.\ **byte_to_bits**\ (byte) + This method converts a single byte into an eight character string of ones and zeros. The *byte* argument must be a single byte. + +RTP.\ **add_bytes**\ (bytes) + This method takes multiple bytes and adds them together into an integer. + +Errors +******* + +*exception* RTP.\ **DynamicPayloadType** + This may be thrown when you try to int cast a dynamic PayloadType. Most PayloadTypes have a number assigned in `RFC 3551 Section 6 `_. However, some are considered to be 'dynamic' meaning the PBX/VoIP server will pick an available number, and define it. + +*exception* RTP.\ **RTPParseError** + This is thrown by :ref:`RTPMessage` is unable to parse a RTP message. It may also be thrown by `RTPClient` when it's unable to encode or decode the RTP packet payload. + +Enums +******* + +RTP.\ **RTPProtocol** + RTPProtocol is an Enum with three attributes. It defines the method that packets are to be sent with. Currently, only AVP is supported. + + RTPProtocol.\ **UDP** + This means the audio should be sent with pure UDP. Returns ``'udp'`` when string casted. + + RTPProtocol.\ **AVP** + This means the audio should be sent with RTP Audio/Video Protocol described in :rfc:3551. Returns ``'RTP/AVP'`` when string casted. + + RTPProtocol.\ **SAVP** + This means the audio should be sent with RTP Secure Audio/Video Protocol described in :rfc:3711. Returns ``'RTP/SAVP'`` when string casted. + +.. _transmittype: + +RTP.\ **TransmitType** + TransmitType is an Enum with four attributes. It describes how the :ref:`RTPClient` should act. + + TransmitType.\ **RECVONLY** + This means the RTPClient should only recive audio, not transmit it. Returns ``'recvonly'`` when string casted. + + TransmitType.\ **SENDRECV** + This means the RTPClient should send and receive audio. Returns ``'sendrecv'`` when string casted. + + TransmitType.\ **SENDONLY** + This means the RTPClient should only send audio, not receive it. Returns ``'sendonly'`` when string casted. + + TransmitType.\ **INACTIVE** + This means the RTP client should not send or receive audio, and instead wait to be activated. Returns ``'inactive'`` when string casted. + +.. _payload-type: + +RTP.\ **PayloadType** + PayloadType is an Enum with multiple attributes. It described the list of attributes in `RFC 3551 Section 6 `_. Currently, only one dynamic event is assigned: telephone-event. Telephone-event is used for sending and recieving DTMF codes. There are a few conflicing names in the RFC as they're the same codec with varrying options so we will go over the conflicts here. PayloadType has the following attributes: + + type.\ **value** + This is either the number assigned as PT in the RFC 3551 Section 6 chart, or it is the encoding name if it is dynamic. Int casting the PayloadType will return this number, or raise a DynamicPayloadType error if the protocol is dynamic. + + type.\ **rate** + This will return the clock rate of the codec. + + type.\ **channel** + This will return the number of channels the used in the codec, or for Non-codecs like telephone-event, it will return zero. + + type.\ **description** + This will return the encoding name of the payload. String casting the PayloadType will return this value. + + PayloadType.\ **DVI4_8000** + This variation of the DVI4 Codec has the attributes: value 5, rate 8000, channel 1, description "DVI4" + + PayloadType.\ **DVI4_16000** + This variation of the DVI4 Codec has the attributes: value 6, rate 16000, channel 1, description "DVI4" + + PayloadType.\ **DVI4_11025** + This variation of the DVI4 Codec has the attributes: value 16, rate 11025, channel 1, description "DVI4" + + PayloadType.\ **DVI4_22050** + This variation of the DVI4 Codec has the attributes: value 17, rate 22050, channel 1, description "DVI4" + + PayloadType.\ **L16** + This variation of the L16 Codec has the attributes: value 11, rate 44100, channel 1, description "L16" + + PayloadType.\ **L16_2** + This variation of the L16 Codec has the attributes: value 11, rate 44100, channel 2, description "L16" + + PayloadType.\ **EVENT** + This is the dynamic non-codec 'telephone-event'. Telephone-event is used for sending and recieving DTMF codes. + +Classes +********* + +.. _RTPPacketManager: + +RTPPacketManager +================ + +The RTPPacketManager class utilizes an ``io.ByteIO`` that stores either received payloads, or raw audio data waiting to be transmitted. + +RTP.\ **RTPPacketManager**\ () + + **read**\ (length=160) + Reads *length* bytes from the ByteIO. This will always return the length requested, and will append ``b'\x00'``'s onto the end of the available bytes to achieve this length. + + **rebuild**\ (reset, offset=0, data=b'') + This rebuilds the ByteIO if packets are sent out of order. Setting the argument *reset* to true will wipe all data in the ByteIO and insert in the data in the argument *data* at the position in the argument *offset*. + + **write**\ (offset, data) + Writes the data in the argument *data* to the ByteIO at the position in the argument *offset*. RTP data comes with a timestamp that is passed as the offset in this case. This makes it so a whole left by delayed packets can be filled later. If a packet with a timestamp sooner than any other timestamp received, it will rebuild the ByteIO with the new data. If this new position is over 100,000 bytes before the earliest byte, the ByteIO is completely wiped and starts over. This is to prevent Overflow errors. + +.. _RTPMessage: + +RTPMessage +=========== + +The RTPMessage class is used to parse RTP packets and makes them easily processed by the :ref:`RTPClient`. + +RTP.\ **RTPMessage**\ (data, assoc) + + The *data* argument is the received RTP packet in bytes. + + The *assoc* argument is a dictionary, using the payload number as a key and a :ref:`PayloadType` as the value. This way RTPMessage can determine what a number a dynamic payload is. This association dictionary is generated by :ref:`VoIPCall`. + + RTPMessage has attributes that come from `RFC 3550 Section 5.1 `_. RTPMessage has the following attributes: + + RTPMessage.\ **version** + This attribute is the RTP packet version, represented as an integer. + + RTPMessage.\ **padding** + If this attribute is set to True the payload has padding. + + RTPMessage.\ **extension** + If this attribute is set to True the packet has a header extension. + + RTPMessage.\ **CC** + This attribute is the CSRC Count, represented as an integer. + + RTPMessage.\ **marker** + This attribute is set to True if the marker bit is set. + + RTPMessage.\ **payload_type** + This attribute is set to the :ref:`PayloadType` that corresponds to the payload codec. + + RTPMessage.\ **sequence** + This attribute is set to the sequence number of the RTP packet, represented as an integer. + + RTPMessage.\ **timestamp** + This attribute is set to the timestamp of the RTP packet, represented as an integer. + + RTPMessage.\ **SSRC** + This attribute is set to the synchronization source of the RTP packet, represented as an integer. + + RTPMessage.\ **payload** + This attribute is the payload data of the RTP packet, represented as bytes. + + RTPMessage.\ **raw** + This attribute is the unparsed version of the *data* argument, in bytes. + + **summary**\ () + This method returns a string representation of the RTP packet excluding the payload. + + **parse**\ (data) + This method is called by the initialization of the class. It determins the RTP version, whether the packet has padding, has a header extension, and other information about the backet. + +.. _RTPClient: + +RTPClient +========= + +The RTPClient is used to send and receive RTP packets and encode/decode the audio codecs. + +*class* RTP.\ **RTPClient**\ (assoc, inIP, inPort, outIP, outPort, sendrecv, dtmf = None): + + The *assoc* argument is a dictionary, using the payload number as a key and a :ref:`PayloadType` as the value. This way, RTPMessage can determine what a number a dynamic payload is. This association dictionary is generated by :ref:`VoIPCall`. + + The *inIP* argument is used to receive incoming RTP message. + + The *inPort* argument is the port RTPClient will bind to to receive incoming RTP messages. + + The *outIP* argument is used to transmit RTP messages. + + The *outPort* argument is used to transmit RTP messages. + + The *sendrecv* argument describes how the RTPClient should act. Please reference `TransmitType` for more details. + + The *dtmf* argument is set to the callback :ref:`VoIPCall`.dtmfCallback(). + + **start**\ () + This method is called by :ref:`VoIPCall`.answer(). It starts the recv() and trans() threads. It is also what initiates the bound port. **This should not be called by the** :term:`user`. + + **stop**\ () + This method is called by :ref:`VoIPCall`.hangup() and :ref:`VoIPCall`.bye(). It stops the recv() and trans() threads. It will also close the bound port. **This should not be called by the** :term:`user`. + + **read**\ (length=160) + This method is called by :ref:`VoIPCall`.readAudio(). It reads linear/raw audio data from the received buffer. Returns *length* amount of bytes. Default length is 160 as that is the amount of bytes sent per PCMU/PCMA packet. + + **recv**\ () + This method is called by RTPClient.start() and is responsible for receiving and parsing through RTP packets. **This should not be called by the** :term:`user`. + + **trans**\ () + This method is called by RTPClient.start() and is responsible for transmitting RTP packets. **This should not be called by the** :term:`user`. + + **parsePacket**\ (packet) + This method is called by the recv() thread. It convertes the argument *packet* into a :ref:`RTPMessage`, then sends it to the proper parse function depending on the :ref:`PayloadType`. + + **encodePacket**\ (payload) + This method is called by the trans() thread. It encoded the argument *payload* into the prefered codec. Currently, PCMU is the hardcoded prefered codec. The trans() thread will use the payload to create the RTP packet before transmitting. + + **parsePCMU**\ (packet) + This method is called by parsePacket(). It will decode the *packet*'s payload from PCMU to linear/raw audio and write it to the incoming :ref:`RTPPacketManager`. + + **encodePCMU**\ (payload) + This method is called by encodePacket(). It will encode the *payload* into the PCMU audio codec. + + **parsePCMA**\ (packet) + This method is called by parsePacket(). It will decode the *packet*'s payload from PCMA to linear/raw audio and write it to the incoming :ref:`RTPPacketManager`. + + **encodePCMA**\ (payload) + This method is called by encodePacket(). It will encode the *payload* into the PCMA audio codec. + + **parseTelephoneEvent**\ (packet) + This method is called by parsePacket(). It will decode the *packet*'s payload from the telephone-event non-codec to the string representation of the event. It will then call :ref:`VoIPCall`.dtmfCallback(). + \ No newline at end of file diff --git a/docs/source/SIP.rst b/docs/source/SIP.rst new file mode 100644 index 0000000..0ae9e31 --- /dev/null +++ b/docs/source/SIP.rst @@ -0,0 +1,188 @@ +SIP - Session Initiation Protocol +################################## + +The SIP module receives, parses, and responds to all incoming SIP requests/messages. If appropriate, it then forwards them to the *callback* method of :ref:`VoIPPhone`. + +Errors +******* + +There are two errors under ``pyVoIP.SIP``. + +.. _InvalidAccountInfoError: + +*exception* SIP.\ **InvalidAccountInfoError** + This is thrown when :ref:`SIPClient` gets a bad response when trying to register with the PBX/VoIP server. This error also kills the SIP REGISTER thread, so you will need to call SIPClient.stop() then SIPClient.start(). + +.. _sip-parse-error: + +*exception* SIP.\ **SIPParseError** + This is thrown when :ref:`SIPMessage` is unable to parse a SIP message/request. + +.. _Enums: + +Enums +****** + +SIP.\ **SIPMessageType** + SIPMessageType is an IntEnum with two attributes. It's stored in ``SIPMessage.type`` to effectivly parse the message. + + SIPMessageType.\ **MESSAGE** + This SIPMessageType is used to signify the message was a SIP request. + + SIPMessageType.\ **RESPONSE** + This SIPMessageType is used to signify the message was a SIP response. + +SIP.\ **SIPStatus** + SIPStatus is used for :ref:`SIPMessage`'s with SIPMessageType.RESPONSE. They will not all be listed here, but a complete list can be found on `Wikipedia `_. SIPStatus has the following attributes: + + status.\ **value** + This is the integer value of the status. For example, ``SIPStatus.OK.value`` is equal to ``int(200)``. + + status.\ **phrase** + This is the string value of the status, usually written next to the number in a SIP response. For example, ``SIPStatus.TRYING.phrase`` is equal to ``'Trying'``. + + status.\ **description** + This is the string value of the description of the status, it can be useful for debugging. For example, ``SIPStatus.OK.description`` is equal to ``'Request successful'`` Not all responses have a description. + + Here are a few common SIPStatus' and their attributes in the order of value, phrase, description: + + SIPStatus.\ **TRYING** + 100, 'Trying', 'Extended search being performed, may take a significant time' + + SIPStatus.\ **RINGING** + 180, 'Ringing', 'Destination user agent received INVITE, and is alerting user of call' + + SIPStatus.\ **OK** + 200, 'OK', 'Request successful' + + SIPStatus.\ **BUSY_HERE** + 486, 'Busy Here', 'Callee is busy' + +Classes +******** + +.. _SIPClient: + +SIPClient +========== + +The SIPClient class is used to communicate with the PBX/VoIP server. It is responsible for registering with the server, and receiving phone calls. + +*class* SIP.\ **SIPClient**\ (server, port, username, password, myIP=None, myPort=5060, callCallback=None) + The *server* argument is your PBX/VoIP server’s IP, represented as a string. + + The *port* argument is your PBX/VoIP server’s port, represented as an integer. + + The *username* argument is your SIP account username on the PBX/VoIP server, represented as a string. + + The *password* argument is your SIP account password on the PBX/VoIP server, represented as a string. + + The *myIP* argument is used to receive incoming SIP requests and responses. If left as None, the SIPClient will bind to 0.0.0.0. + + The *myPort* argument is the port SIPClient will bind to, to receive incoming SIP requests and responses. The default for this protocol is port 5060, but any port can be used. + + The *callCallback* argument is the callback function for :ref:`VoIPPhone`. VoIPPhone will process the SIP request, and perform the appropriate actions. + + **recv**\ () + This method is called by SIPClient.start() and is responsible for receiving and parsing through SIP requests. **This should not be called by the** :term:`user`. + + **start**\ () + This method is called by :ref:`VoIPPhone`.start(). It starts the REGISTER and recv() threads. It is also what initiates the bound port. **This should not be called by the** :term:`user`. + + **stop**\ () + This method is called by :ref:`VoIPPhone`.stop(). It stops the REGISTER and recv() threads. It will also close the bound port. **This should not be called by the** :term:`user`. + + **genCallID**\ () + This method is called by other 'gen' methods when a new Call-ID header is needed. See `RFC 3261 Section 20.8 `_. **This should not be called by the** :term:`user`. + + **getSIPVersoinNotSupported**\ () + This method is called by the recv() thread when it has received a SIP message that is not SIP version 2.0. + + **genAuthorization**\ (request): + This calculates the authroization hash in response to the WWW-Authenticate header. See `RFC 3261 Section 20.7 `_. The *request* argument should be a 401 Unauthorized response. **This should not be called by the** :term:`user`. + + **genRegister**\ (request) + This method generates a SIP REGISTER request. The *request* argument should be a 401 Unauthorized response. **This should not be called by the** :term:`user`. + + **genBusy**\ (request) + This method generates a SIP 486 'Busy Here' response. The *request* argument should be a SIP INVITE request. + + **genRinging**\ (request) + This method generates a SIP 180 'Ringing' response. The *request* argument should be a SIP INVITE request. + + **genAnswer**\ (request, sess_id, ms, sendtype) + This method generates a SIP 200 'OK' response. Which, when in reply to an INVITE request, tells the server the :term:`user` has answered. **This should not be called by the** :term:`user`. + + The *request* argument should be a SIP INVITE request. + + The *sess_id* argument should be a string casted integer. This will be used for the SDP o tag. See `RFC 4566 Section 5.2 `_. The *sess_id* argument will also server as the ** argument in the SDP o tag. + + The *ms* argument should be a list of parsed SDP m tags, found in the :ref:`SIPMessage`.body attribute. This is used to generate the response SDP m tags. See `RFC 4566 Section 5.14 `_. + + The *sendtype* argument should be a RTP.\ :ref:`TransmitType` enum. This will be used to generate the SDP a tag. See `RFC 4566 Section 6 `_. + + **genBye**\ (request) + This method generates a SIP BYE request. This is used to end a call. The *request* argument should be a SIP INVITE request. **This should not be called by the** :term:`user`. + + **bye**\ (request) + This method is called by :ref:`VoIPCall`.hangup(). It calls genBye(), and then transmits the generated request. **This should not be called by the** :term:`user`. + + **deregister**\ () + This method is called by SIPClient.stop() after the REGISTER thread is stopped. It will generate and transmit a REGISTER request with an Expiration of zero. Telling the PBX/VoIP server it is turning off. **This should not be called by the** :term:`user`. + + **register**\ () + This method is called by the REGISTER thread. It will generate and transmit a REGISTER request telling the PBX/VoIP server that it will be online for at least 300 seconds. The REGISTER thread will call this function every 295 seconds. **This should not be called by the** :term:`user`. + +.. _SIPMessage: + +SIPMessage +========== + +The SIPMessage class is used to parse SIP requests and responses and makes them easily processed by other classes. + +*class* SIP.\ **SIPMessage**\ (data) + The *data* argument is the SIP message in bytes. It is then passed to SIPMessage.parse(). + + SIPMessage has the following attributes: + + SIPMessage.\ **heading** + This attribute is the first line of the SIP message as a string. It contains the SIP Version, and the method/response code. + + SIPMessage.\ **type** + This attribute will be a :ref:`SIPMessageType`. + + SIPMessage.\ **status** + This attribute will be a :ref:`SIPStatus`. It will be set to ``int(0)`` if the message is a request. + + SIPMessage.\ **method** + This attribute will be a string representation of the method. It will be set to None if the message is a response. + + SIPMessage.\ **headers** + This attribute is a dictionary of all the headers in the request, and their parsed values. + + SIPMessage.\ **body** + This attribute is a dictionary of all the SDP tags in the request, and their parsed values. + + SIPMessage.\ **authentication** + This attribute is a dictionary of a parsed Authentication header. There are two authentication headers: Authorization, and WWW-Authenticate. See RFC 3261 Sections `20.7 `_ and `20.44 `_ respectively. + + SIPMessage.\ **raw** + This attribute is an unparsed version of the *data* argument, in bytes. + + **summary**\ () + This method returns a string representation of the SIP request. + + **parse**\ (data) + This method is called by the initialization of the class. It decides the SIPMessageType, and sends it to the corresponding parse function. *Data* is the original *data* argument in the initialization of the class. **This should not be called by the** :term:`user`. + + **parseSIPResponse**\ (data) + This method is called by parse(). It sets the *header*, *version*, and *status* attributes and may raise a :ref:`SIPParseError` if the SIP response is an unsupported SIP version. It then calls parseHeader() for each header in the request. *Data* is the original *data* argument in the initialization of the class. **This should not be called by the** :term:`user`. + + **parseSIPMessage**\ (data) + This method is called by parse(). It sets the *header*, *version*, and *method* attributes and may raise a :ref:`SIPParseError` if the SIP request is an unsupported SIP version. It then calls parseHeader() and parseBody() for each header or tag in the request respectively. *Data* is the original *data* argument in the initialization of the class. **This should not be called by the** :term:`user`. + + **parseHeader**\ (header, data) + This method is called by parseSIPResponse() and parseSIPMessage(). The *header* argument is the name of the header, i.e. 'Call-ID' or 'CSeq', represented as a string. The *data* argument is the value of the header, i.e. 'Ogq-T7iBmNozoUu3GL9Lvg..' or '1 INVITE', represented as a string. **This should not be called by the** :term:`user`. + + **parseBody**\ (header, data) + This method is called by parseSIPResponse() and parseSIPMessage(). The *header* argument is the name of the SDP tag, i.e. 'm' or 'a', represented as a string. The *data* argument is the value of the header, i.e. 'audio 56704 RTP/AVP 0' or 'sendrecv', represented as a string. **This should not be called by the** :term:`user`. diff --git a/docs/source/VoIP.rst b/docs/source/VoIP.rst new file mode 100644 index 0000000..244a1c9 --- /dev/null +++ b/docs/source/VoIP.rst @@ -0,0 +1,132 @@ +VoIP - The Bridge Between SIP and RTP +##################################### + +The VoIP module coordinates between the SIP and RTP modules in order to create an effective Voice over Internet Protocol system. The VoIP system is made for your convenience, and if you have a particularly intricate situation, you can use the SIP and RTP modules independently and create your own version of the VoIP module. If you choose to use the VoIP module, this section will explain how. + +Errors +******** + +There are two errors under ``pyVoIP.VoIP``. + +.. _invalidstateerror: + +*exception* VoIP.\ **InvalidStateError** + This is thrown when you try to run :ref:`VoIPCall` when you try to perform an action that cannot be performed during the current :ref:`CallState`. For example denying a call that has already been answered, hanging up a call that hasn't been answered yet, or has already been ended. + +*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. + +Enums +*********** + +.. _callstate: + +VoIP.\ **CallState** + CallState is an Enum with three attributes. + + CallState.\ **RINGING** + This CallState is used to describe when a :term:`client` is calling, but the call has yet to be answered. + + In this state, you can use ``VoIPCall.answer()`` or ``VoIPCall.deny()``. + + CallState.\ **ANSWRED** + This CallState is used to describe when a call has been answered and is active. + + In this state, you can use ``VoIPCall.hangup()``. + + CallState.\ **ENDED** + This CallState is used to describe when a call has been terminated. + + In this state, you can not use any functions. + +Classes +******** + +.. _VoIPCall: + +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) + The *phone* argument is the initating instance of :ref:`VoIPPhone`. + + 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. + + **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 `_. + + **getDTMF**\ (length=1) + This method can be called get the next pressed DTMF key. DTMF's are stored in an ``io.StringIO`` and act as a stack. Meaning if the :term:`client` presses the numbers 1-9-5 you'll have the following output: + + .. code-block:: python + + VoIPCall.getDTMF() + >>> '1' + VoIPCall.getDTMF(length=2) + >>> '95' + VoIPCall.getDTMF() + >>> '' + + As you can see, calling this method when there a key has not been pressed returns an empty string. + + **answer**\ () + Answers the call if the phone's state is CallState.RINGING. + + **deny**\ () + Denies the call if the phone's state is CallState.RINGING. + + **hangup**\ () + Ends the call if the phone's state is CallState.ANSWRED. + + **bye**\ () + Ends the call but does not send a SIP BYE message to the SIP server. This function is used to end the call on the server side when the client ended the call. **THE** :term:`USER` **SHOUND NOT CALL THIS FUNCTION OR THE** :term:`CLIENT` **WILL BE LEFT ON THE LINE WITH NO RESPONSE. CALL HANGUP() INSTEAD.** + + **writeAudio**\ (data) + Writes linear/raw audio data to the transmit buffer before being encoded and sent. The *data* argument MUST be bytes. **This audio must be linear/not encoded,** :ref:`RTPClient` **will encode it before transmitting.** + + **readAudio**\ (length=160) + Reads linear/raw audio data from the received buffer. Returns *length* amount of bytes. Default length is 160 as that is the amount of bytes sent per PCMU/PCMA packet. + +.. _VoIPPhone: + +VoIPPhone +========= + +The VoIPPhone class is used to manage the :ref:`SIPClient` class and create :ref:`VoIPCall`'s when there is an incoming call. It then passes the VoIPCall as the argument in the callback. + +*class* VoIP.\ **VoIPPhone**\ (server, port, username, password, callCallback=None, myIP=None, sipPort=5060, rtpPortLow=10000, rtpPortHigh=20000) + The *server* argument is your PBX/VoIP server's IP, represented as a string. + + The *port* argument is your PBX/VoIP server's port, represented as an integer. + + The *username* argument is your SIP account username on the PBX/VoIP server, represented as a string. + + The *password* argument is your SIP account password on the PBX/VoIP server, represented as a string. + + The *callCallback* argument is your callback function that VoIPPhone will run when you receive a call. The callback must take one argument, which will be a :ref:`VoIPCall`. If left as None, the VoIPPhone will automatically respond to all incoming calls as Busy. + + The *myIP* argument is used to bind SIP and RTP ports to receive incoming calls. If left as None, the VoIPPhone will bind to 0.0.0.0. + + The *sipPort* argument is the port SIP will bind to to receive SIP requests. The default for this protocol is port 5060, but any port can be used. + + 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`. + + **start**\ () + This method starts the :ref:`SIPClient` class. + + **stop**\ () + This method ends all currently ongoing calls, then stops the :ref:`SIPClient` class + + + \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..2be3190 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,57 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- + +project = 'pyVoIP' +copyright = '2020, Sorta Strange Productions' +author = 'Tayler J Porter' + +# The full version, including alpha/beta/rc tags +release = '0.5.0 Beta' + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'alabaster' + +#pygments_style = 'sphinx' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..daedae1 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,32 @@ +.. pyVoIP documentation master file, created by + sphinx-quickstart on Fri Jul 17 19:40:26 2020. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to pyVoIP's documentation! +================================== + +PyVoIP is a pure python VoIP/SIP/RTP library. Currently, it supports PCMA, PCMU, and telephone-event. + +Please note this is a beta version and is currently only able to recieve calls, and transmit PCMU. In future, it will be able to initiate calls in PCMA as well. + +This library does not depend on a sound library, i.e. you can use any sound library that can handle linear sound data such as pyaudio or even wave. Keep in mind PCMU only supports 8000Hz, 1 channel, 8 bit audio. + +In this documentation we will use the following terms: + +.. glossary:: + + client + For the purposes of this documentation, the term *client* will be defined as the person calling this library. + + user + For the purposes of this documentation, the term *user* will be defined as the programmer, i.e. the 'server-side' if using the `Client-Server model `_. + +.. toctree:: + :maxdepth: 3 + :caption: Contents: + + Examples + VoIP + SIP + RTP diff --git a/pyVoIP/RTP.py b/pyVoIP/RTP.py new file mode 100644 index 0000000..c68adaa --- /dev/null +++ b/pyVoIP/RTP.py @@ -0,0 +1,352 @@ +from enum import Enum, IntEnum +from threading import Timer +import audioop +import io +import pyVoIP +import random +import socket +import threading +import time + +__all__ = ['add_bytes', 'byte_to_bits', 'DynamicPayloadType', 'PayloadType', 'RTPParseError', 'RTPProtocol', 'RTPPacketManager', 'RTPClient', 'TransmitType'] + +def byte_to_bits(byte): + byte = bin(ord(byte)).lstrip('-0b') + byte = ("0"*(8-len(byte)))+byte + return byte + +def add_bytes(byte_string): + binary = "" + for byte in byte_string: + byte = bin(byte).lstrip('-0b') + byte = ("0"*(8-len(byte)))+byte + binary += byte + return int(binary, 2) + +class DynamicPayloadType(Exception): + pass + +class RTPParseError(Exception): + pass + +class RTPProtocol(Enum): + UDP = 'udp' + AVP = 'RTP/AVP' + SAVP = 'RTP/SAVP' + +class TransmitType(Enum): + RECVONLY = 'recvonly' + SENDRECV = 'sendrecv' + SENDONLY = 'sendonly' + INACTIVE = 'inactive' + + def __str__(self): + return self.value + +class PayloadType(Enum): + def __new__(cls, value, clock, channel, description): + obj = object.__new__(cls) + obj._value_ = value + obj.rate = clock + obj.channel = channel + obj.description = description + return obj + + def __int__(self): + try: + return int(self.value) + except ValueError: + pass + raise DynamicPayloadType(self.description + " is a dynamically assigned payload") + + def __str__(self): + if type(self.value)==int: + return self.description + return str(self.value) + + #Audio + PCMU = 0, 8000, 1, "PCMU" + GSM = 3, 8000, 1, "GSM" + G723 = 4, 8000, 1, "G723" + DVI4_8000 = 5, 8000, 1, "DVI4" + DVI4_16000 = 6, 16000, 1, "DVI4" + LPC = 7, 8000, 1, "LPC" + PCMA = 8, 8000, 1, "PCMA" + G722 = 9, 8000, 1, "G722" + L16_2 = 10, 44100, 2, "L16" + L16 = 11, 44100, 1, "L16" + QCELP = 12, 8000, 1, "QCELP" + CN = 13, 8000, 1, "CN" + MPA = 14, 90000, 0, "MPA" #MPA channel varries, should be defined in the RTP packet. + G728 = 15, 8000, 1, "G728" + DVI4_11025 = 16, 11025, 1, "DVI4" + DVI4_22050 = 17, 22050, 1, "DVI4" + G729 = 18, 8000, 1, "G729" + + #Video + CELB = 25, 90000, 0, "CelB" + JPEG = 26, 90000, 0, "JPEG" + NV = 28, 90000, 0, "nv" + H261 = 31, 90000, 0, "H261" + MPV = 32, 90000, 0, "MPV" + MP2T = 33, 90000, 1, "MP2T" #MP2T is both audio and video per RFC 3551 July 2003 5.7 + H263 = 34, 90000, 0, "H263" + + #Non-codec + EVENT = "telephone-event", 8000, 0, "telephone-event" + +''' +Note to self. + +Probably should set the packet manager back to ByteIO to prevent out of order packets. +I'm thinking before write, log the cursor position, then go to the timestamp position, +write, then go to the logged possition. Again this will stop out of sync packet issues. +''' +class RTPPacketManager(): + def __init__(self): + self.offset = 4294967296 #The largest number storable in 4 bytes + 1. This will ensure the offset adjustment in self.write(offset, data) works. + self.buffer = io.BytesIO() + self.bufferLock = threading.Lock() + self.log = {} + self.rebuilding = False + + def read(self, length=160): + while self.rebuilding: #This acts functionally as a lock while the buffer is being rebuilt. + print("Rebuilding") + self.bufferLock.acquire() + packet = self.buffer.read(length) + if len(packet)=100000) #If the new timestamp is over 100,000 bytes before the earliest, erase the buffer. This will stop memory errors. + self.offset = offset + self.bufferLock.release() + self.rebuild(reset, offset, data) #Rebuilds the buffer if something before the earliest timestamp comes in, this will stop overwritting. + return + offset = offset - self.offset + self.buffer.seek(offset, 0) + self.buffer.write(data) + self.buffer.seek(bufferloc, 0) + self.bufferLock.release() + +class RTPMessage(): + def __init__(self, data, assoc): + self.RTPCompatibleVersions = pyVoIP.RTPCompatibleVersions + self.assoc = assoc + + self.parse(data) + + def summary(self): + data = "" + data += "Version: "+str(self.version)+"\n" + data += "Padding: "+str(self.padding)+"\n" + data += "Extension: "+str(self.extension)+"\n" + data += "CC: "+str(self.CC)+"\n" + data += "Marker: "+str(self.marker)+"\n" + data += "Payload Type: "+str(self.payload_type)+" ("+str(self.payload_type.value)+")"+"\n" + data += "Sequence Number: "+str(self.sequence)+"\n" + data += "Timestamp: "+str(self.timestamp)+"\n" + data += "SSRC: "+str(self.SSRC)+"\n" + return data + + def parse(self, packet): + byte = byte_to_bits(packet[0:1]) + self.version = int(byte[0:2], 2) + if not self.version in self.RTPCompatibleVersions: + raise RTPParseError("RTP Version {} not compatible.".format(version)) + self.padding = bool(int(byte[2], 2)) + self.extension = bool(int(byte[3], 2)) + self.CC = int(byte[4:], 2) + + byte = byte_to_bits(packet[1:2]) + self.marker = bool(int(byte[0], 2)) + + pt = int(byte[1:], 2) + if pt in self.assoc: + self.payload_type = self.assoc[pt] + else: + try: + self.payload_type = PayloadType(pt) + e = False + except ValueError: + e = True + if e: + raise RTPParseError("RTP Payload type {} not found.".format(str(pt))) + + self.sequence = add_bytes(packet[2:4]) + self.timestamp = add_bytes(packet[4:8]) + self.SSRC = add_bytes(packet[8:12]) + + self.CSRC = [] + + i = 12 + for x in range(self.CC): + self.CSRC.append(packet[i:i+4]) + i += 4 + + if self.extension: + pass + + + self.payload = packet[i:] + +class RTPClient(): + def __init__(self, assoc, inIP, inPort, outIP, outPort, sendrecv, dtmf = None): + self.NSD = True + self.assoc = assoc # Example: {0: PayloadType.PCMU, 101: PayloadType.EVENT} + self.preference = PayloadType.PCMU + self.inIP = inIP + self.inPort = inPort + self.outIP = outIP + self.outPort = outPort + + self.dtmf = dtmf + + self.pmout = RTPPacketManager() #To Send + self.pmin = RTPPacketManager() #Received + self.outOffset = random.randint(1,5000) + + self.outSequence = random.randint(1,100) + self.outTimestamp = random.randint(1,10000) + self.outSSRC = random.randint(1000,65530) + + def start(self): + self.sin = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.sout = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.sin.bind((self.inIP, self.inPort)) + self.sin.setblocking(False) + + r = Timer(1, self.recv) + r.name = "RTP Receiver" + r.start() + t = Timer(1, self.trans) + t.name = "RTP Transmitter" + t.start() + + def stop(self): + self.NSD = False + self.sin.close() + self.sout.close() + + def read(self, length=160): + return self.pmin.read(length) + + def write(self, data): + self.pmout.write(self.outOffset, data) + self.outOffset += len(data) + + def recv(self): + while self.NSD: + try: + packet = self.sin.recv(8192) + self.parsePacket(packet) + except BlockingIOError: + pass + except RTPParseError as e: + print(str(e)) + except OSError: + pass + + def trans(self): + while self.NSD: + payload = self.pmout.read() + payload = self.encodePacket(payload) + packet = b"\x80" #RFC 1889 V2 No Padding Extension or CC. + packet += chr(int(self.preference)).encode('utf8') + try: + packet += self.outSequence.to_bytes(2, byteorder='big') + except OverflowError: + self.outSequence = 0 + try: + packet += self.outTimestamp.to_bytes(4, byteorder='big') + except OverflowError: + self.outTimestamp = 0 + packet += self.outSSRC.to_bytes(4, byteorder='big') + packet += payload + + #print(payload) + + try: + self.sout.sendto(packet, (self.outIP, self.outPort)) + except OSError: + pass + + self.outSequence += 1 + self.outTimestamp += len(payload) + time.sleep((1/self.preference.rate)*160) #1/8000 *160 + + def parsePacket(self, packet): + packet = RTPMessage(packet, self.assoc) + if packet.payload_type == PayloadType.PCMU: + self.parsePCMU(packet) + elif packet.payload_type == PayloadType.PCMA: + self.parsePCMA(packet) + elif packet.payload_type == PayloadType.EVENT: + self.parseTelephoneEvent(packet) + else: + raise RTPParseError("Unsupported codec (parse): "+str(packet.payload_type)) + + def encodePacket(self, payload): + if self.preference == PayloadType.PCMU: + return self.encodePCMU(payload) + elif self.preference == PayloadType.PCMA: + return self.encodePCMA(payload) + else: + raise RTPParseError("Unsupported codec (encode): "+str(self.preference)) + + def parsePCMU(self, packet): + data = audioop.ulaw2lin(packet.payload, 1) + data = audioop.bias(data, 1, 128) + self.pmin.write(packet.timestamp, data) + + def encodePCMU(self, packet): + packet = audioop.bias(packet, 1, -128) + packet = audioop.lin2ulaw(packet, 1) + return packet + + def parsePCMA(self, packet): + data = audioop.alaw2lin(packet.payload, 1) + data = audioop.bias(data, 1, 128) + self.pmin.write(packet.timestamp, data) + + def encodePCMA(self, packet): + packet = audioop.bias(packet, 1, -128) + packet = audioop.lin2alaw(packet, 1) + return packet + + def parseTelephoneEvent(self, packet): + key = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#', 'A', 'B', 'C', 'D'] + end = False + + payload = packet.payload + event = key[payload[0]] + byte = byte_to_bits(payload[1:2]) + if byte[0]=='1': + end = True + volume = int(byte[2:], 2) + + if packet.marker: + if self.dtmf != None: + self.dtmf(event) + diff --git a/pyVoIP/SIP.py b/pyVoIP/SIP.py new file mode 100644 index 0000000..76c9675 --- /dev/null +++ b/pyVoIP/SIP.py @@ -0,0 +1,737 @@ +from enum import IntEnum +from threading import Timer, Lock +import pyVoIP +import hashlib +import socket +import random +import re +import time + +__all__ = ['Counter', 'InvalidAccountInfoError', 'SIPClient', 'SIPMessage', 'SIPMessageType', 'SIPParseError', 'SIPStatus'] + +class InvalidAccountInfoError(Exception): + pass + +class SIPParseError(Exception): + pass + +class Counter(): + def __init__(self, start=1): + self.x = start + + def count(self): + x = self.x + self.x += 1 + return x + + def next(self): + return self.count() + +class SIPStatus(IntEnum): + def __new__(cls, value, phrase, description=''): + obj = int.__new__(cls, value) + obj._value_ = value + + obj.phrase = phrase + obj.description = description + return obj + + # informational + TRYING = 100, 'Trying', 'Extended search being performed, may take a significant time' + RINGING = (180, 'Ringing', + 'Destination user agent received INVITE, and is alerting user of call') + FORWARDED = 181, 'Call is Being Forwarded' + QUEUED = 182, 'Queued' + SESSION_PROGRESS = 183, 'Session Progress' + TERMINATED = 199, 'Early Dialog Terminated' + + # success + OK = 200, 'OK', 'Request successful' + ACCEPTED = (202, 'Accepted', + 'Request accepted, processing continues (Deprecated.)') + NO_NOTIFICATION = 204, 'No Notification', 'Request fulfilled, nothing follows' + + # redirection + MULTIPLE_CHOICES = (300, 'Multiple Choices', + 'Object has several resources -- see URI list') + MOVED_PERMANENTLY = (301, 'Moved Permanently', + 'Object moved permanently -- see URI list') + MOVED_TEMPORARILY = 302, 'Moved Temporarily', 'Object moved temporarily -- see URI list' + USE_PROXY = (305, 'Use Proxy', + 'You must use proxy specified in Location to access this resource') + ALTERNATE_SERVICE = (380, 'Alternate Service', + 'The call failed, but alternatives are available -- see URI list') + + # client error + BAD_REQUEST = (400, 'Bad Request', + 'Bad request syntax or unsupported method') + UNAUTHORIZED = (401, 'Unauthorized', + 'No permission -- see authorization schemes') + PAYMENT_REQUIRED = (402, 'Payment Required', + 'No payment -- see charging schemes') + FORBIDDEN = (403, 'Forbidden', + 'Request forbidden -- authorization will not help') + NOT_FOUND = (404, 'Not Found', + 'Nothing matches the given URI') + METHOD_NOT_ALLOWED = (405, 'Method Not Allowed', + 'Specified method is invalid for this resource') + NOT_ACCEPTABLE = (406, 'Not Acceptable', + 'URI not available in preferred format') + PROXY_AUTHENTICATION_REQUIRED = (407, + 'Proxy Authentication Required', + 'You must authenticate with this proxy before proceeding') + REQUEST_TIMEOUT = (408, 'Request Timeout', + 'Request timed out; try again later') + CONFLICT = 409, 'Conflict', 'Request conflict' + GONE = (410, 'Gone', + 'URI no longer exists and has been permanently removed') + LENGTH_REQUIRED = (411, 'Length Required', + 'Client must specify Content-Length') + CONDITIONAL_REQUEST_FAILED = (412, 'Conditional Request Failed') + REQUEST_ENTITY_TOO_LARGE = (413, 'Request Entity Too Large', + 'Entity is too large') + REQUEST_URI_TOO_LONG = (414, 'Request-URI Too Long', + 'URI is too long') + UNSUPPORTED_MEDIA_TYPE = (415, 'Unsupported Media Type', + 'Entity body in unsupported format') + UNSUPPORTED_URI_SCHEME = (416, + 'Unsupported URI Scheme', + 'Cannot satisfy request') + UNKOWN_RESOURCE_PRIORITY = (417, 'Unkown Resource-Priority', + 'There was a resource-priority option tag, but no Resource-Priority header') + BAD_EXTENSION = (420, 'Bad Extension', + 'Bad SIP Protocol Extension used, not understood by the server.') + EXTENSION_REQUIRED = (421, 'Extension Required', + 'Server requeires a specific extension to be listed in the Supported header.') + SESSION_INTERVAL_TOO_SMALL = 422, 'Session Interval Too Small' + SESSION_INTERVAL_TOO_BRIEF = 423, 'Session Interval Too Breif' + BAD_LOCATION_INFORMATION = 424, 'Bad Location Information' + USE_IDENTITY_HEADER = (428, 'Use Identity Header', + 'The server requires an Identity header, and one has not been provided.') + PROVIDE_REFERRER_IDENTITY = (429, 'Provide Referrer Identity') + FLOW_FAILED = (430, 'Flow Failed', + 'A specific flow to a user agent has failed, although other flows may succeed.') #This response is intended for use between proxy devices, and should not be seen by an endpoint (and if it is seen by one, should be treated as a 400 Bad Request response). + ANONYMITY_DISALLOWED = (433, + 'Anonymity Disallowed') + BAD_IDENTITY_INFO = (436, 'Bad Identity-Info') + UNSUPPORTED_CERTIFICATE = (437, 'Unsupported Certificate') + INVALID_IDENTITY_HEADER = (438, 'Invalid Identity Header') + FIRST_HOP_LACKS_OUTBOUND_SUPPORT = (439, 'First Hop Lacks Outbound Support') + MAX_BREADTH_EXCEEDED = (440, 'Max-Breadth Exceeded') + BAD_INFO_PACKAGE = (469, 'Bad Info Package') + CONSENT_NEEDED = (470, 'Consent Needed') + TEMPORARILY_UNAVAILABLE = (480, 'Temporarily Unavailable') + CALL_OR_TRANSACTION_DOESNT_EXIST = (481, 'Call/Transaction Does Not Exist') + LOOP_DETECTED = 482, 'Loop Detected' + TOO_MANY_HOPS = (483, 'Too Many Hops') + ADDRESS_INCOMPLETE = (484, 'Address Incomplete') + AMBIGUOUS = (485, 'Ambiguous') + BUSY_HERE = (486, 'Busy Here', 'Callee is busy') + REQUEST_TERMINATED = (487, 'Request Terminated') + NOT_ACCEPTABLE_HERE = (488, 'Not Acceptable Here') + BAD_EVENT = (489, 'Bad Event') + REQUEST_PENDING = (491, 'Request Pending') + UNDECIPHERABLE = (493, 'Undecipherable') + SECURITY_AGREEMENT_REQUIRED = (494, 'Security Agreement Required') + + # server errors + INTERNAL_SERVER_ERROR = (500, 'Internal Server Error', + 'Server got itself in trouble') + NOT_IMPLEMENTED = (501, 'Not Implemented', + 'Server does not support this operation') + BAD_GATEWAY = (502, 'Bad Gateway', + 'Invalid responses from another server/proxy') + SERVICE_UNAVAILABLE = (503, 'Service Unavailable', + 'The server cannot process the request due to a high load') + GATEWAY_TIMEOUT = (504, 'Server Timeout', + 'The server did not receive a timely response') + SIP_VERSION_NOT_SUPPORTED = (505, 'SIP Version Not Supported', + 'Cannot fulfill request') + MESSAGE_TOO_LONG = (513, 'Message Too Long') + PUSH_NOTIFICATION_SERVICE_NOT_SUPPORTED = (555, 'Push Notification Service Not Supported') + PRECONDITION_FAILURE = (580, 'Precondition Failure') + + #Global Failure Responses + BUSY_EVERYWHERE = 600, 'Busy Everywhere' + DECLINE = 603, 'Decline' + DOES_NOT_EXIST_ANYWHERE = 604, 'Does Not Exist Anywhere' + GLOBAL_NOT_ACCEPTABLE = 606, 'Not Acceptable' + UNWANTED = 607, 'Unwanted' + REJECTED = 608, 'Rejected' + +class SIPMessageType(IntEnum): + def __new__(cls, value): + obj = int.__new__(cls, value) + obj._value_ = value + return obj + + + MESSAGE = 1 + RESPONSE = 0 + +class SIPMessage(): + def __init__(self, data): + self.SIPCompatibleVersions = pyVoIP.SIPCompatibleVersions + self.SIPCompatibleMethods = pyVoIP.SIPCompatibleMethods + self.heading = "" + self.type = None + self.status = 0 + self.headers = {} + self.body = {} + self.authentication = {} + self.raw = data + self.parse(data) + + def summary(self): + data = "" + if self.type == SIPMessageType.RESPONSE: + data += "Status: "+str(int(self.status))+" "+str(self.status.phrase)+"\n\n" + else: + data += "Method: "+str(self.method)+"\n\n" + data += "Headers:\n" + for x in self.headers: + data += x+": "+str(self.headers[x])+"\n" + data += "\n" + data += "Body:\n" + for x in self.body: + data += x+": "+str(self.body[x])+"\n" + + return data + + def parse(self, data): + headers = data.split(b'\r\n\r\n')[0] + body = data.split(b'\r\n\r\n')[1] + + headers_raw = headers.split(b'\r\n') + heading = headers_raw.pop(0) + check = str(heading.split(b" ")[0], 'utf8') + + if check in self.SIPCompatibleVersions: + self.type = SIPMessageType.RESPONSE + self.parseSIPResponce(data) + elif check in self.SIPCompatibleMethods: + self.type = SIPMessageType.MESSAGE + self.parseSIPMessage(data) + else: + raise SIPParseError("Unable to decipher SIP request: "+str(heading, 'utf8')) + + def parseHeader(self, header, data): + if header=="Via": + info = re.split(" |;", data) + self.headers['Via'] = {'type': info[0], 'address':(info[1].split(':')[0], info[1].split(':')[1]), 'branch': info[2].split('=')[1]} + elif header=="From" or header=="To": + info = data.split(';tag=') + tag = '' + if len(info) >= 2: + tag = info[1] + raw = info[0] + contact = raw.split('') + number = address.split('@')[0] + host = address.split('@')[1] + + self.headers[header] = {'raw': raw, 'tag': tag, 'address': address, 'number': number, 'caller': contact[0], 'host': host} + elif header=="CSeq": + self.headers[header] = {'check': data.split(" ")[0], 'method': data.split(" ")[1]} + elif header=="Allow" or header=="Supported": + self.headers[header] = data.split(", ") + elif header=="Content-Length": + self.headers[header] = int(data) + elif header=='WWW-Authenticate' or header=="Authorization": + info = data.split(", ") + header_data = {} + for x in info: + header_data[x.split('=')[0]] = x.split('=')[1].strip('"') + self.headers[header] = header_data + self.authentication = header_data + else: + self.headers[header] = data + + def parseBody(self, header, data): + if 'Content-Encoding' in self.headers: + raise SIPParseError("Unable to parse encoded content.") + if self.headers['Content-Type'] == 'application/sdp': + #Referenced RFC 4566 July 2006 + if header == "v": + #SDP 5.1 Version + self.body[header] = int(data) + elif header == "o": + #SDP 5.2 Origin + #o= + data = data.split(' ') + self.body[header] = {'username': data[0], 'id': data[1], 'version': data[2], 'network_type': data[3], 'address_type': data[4], 'address': data[5]} + elif header == "s": + #SDP 5.3 Session Name + #s= + self.body[header] = data + elif header == "i": + #SDP 5.4 Session Information + #i= + self.body[header] = data + elif header == "u": + #SDP 5.5 URI + #u= + self.body[header] = data + elif header == "e" or header == "p": + #SDP 5.6 Email Address and Phone Number of person responsible for the conference + #e= + #p= + self.body[header] = data + elif header == "c": + #SDP 5.7 Connection Data + #c= + if not 'c' in self.body: + self.body['c'] = [] + data = data.split(' ') + #TTL Data and Multicast addresses may be specified. + #For IPv4 its listed as addr/ttl/number of addresses. + #c=IN IP4 224.2.1.1/127/3 means: + #c=IN IP4 224.2.1.1/127 + #c=IN IP4 224.2.1.2/127 + #c=IN IP4 224.2.1.3/127 + #With the TTL being 127. IPv6 does not support time to live so you will only see a / for multicast addresses. + if '/' in data[2]: + if data[1] == "IP6": + self.body[header].append({'network_type': data[0], 'address_type': data[1], 'address': data[2].split('/')[0], 'ttl': None, 'address_count': int(data[2].split('/')[1])}) + else: + address_data=data[2].split('/') + if len(address_data) == 2: + self.body[header].append({'network_type': data[0], 'address_type': data[1], 'address': address_data[0], 'ttl': int(address_data[1]), 'address_count': 1}) + else: + self.body[header].append({'network_type': data[0], 'address_type': data[1], 'address': address_data[0], 'ttl': int(address_data[1]), 'address_count': int(address_data[2])}) + else: + self.body[header].append({'network_type': data[0], 'address_type': data[1], 'address': data[2], 'ttl': None, 'address_count': 1}) + elif header == "b": + #SDP 5.8 Bandwidth + #b=: + #A bwtype of CT means Conference Total between all medias and all devices in the conference. + #A bwtype of AS means Applicaton Specific total for this media and this device. + #The bandwidth is given in kilobits per second. As this was written in 2006, this could be Kibibits. + #TODO: Implement Bandwidth restrictions + data = data.split(':') + self.body[header] = {'type': data[0], 'bandwidth': data[1]} + elif header == "t": + #SDP 5.9 Timing + #t= + data = data.split(' ') + self.body[header] = {'start': data[0], 'stop': data[1]} + elif header == "r": + #SDP 5.10 Repeat Times + #r= + data = data.split(' ') + self.body[header] = {'repeat': data[0], 'duration': data[1], 'offset1': data[2], 'offset2': data[3]} + elif header == "z": + #SDP 5.11 Time Zones + #z= .... + #Used for change in timezones such as day light savings time. + data = data.split(0) + amount = len(data)/2 + self.body[header] = {} + for x in range(amount): + self.body[header]['adjustment-time'+str(x)] = data[x*2] + self.body[header]['offset'+str(x)] = data[x*2+1] + elif header == "k": + #SDP 5.12 Encryption Keys + #k= + #k=: + if ':' in data: + data = data.split(':') + self.body[header] = {'method': data[0], 'key': data[1]} + else: + self.body[header] = {'method': data} + elif header == "m": + #SDP 5.14 Media Descriptions + #m= / ... + # should be even, and +1 should be the RTCP port. + # should coinside with number of addresses in SDP 5.7 c= + if not 'm' in self.body: + self.body['m'] = [] + data = data.split(' ') + + if '/' in data[1]: + ports_raw = data[1].split('/') + port = ports_raw[0] + count = ports_raw[1] + else: + port = data[1] + count = 1 + methods = data[3:] + + self.body['m'].append({'type': data[0], 'port': int(port), 'port_count': int(count), 'protocol': pyVoIP.RTP.RTPProtocol(data[2]), 'methods': methods, 'attributes': {}}) + for x in self.body['m'][-1]['methods']: + self.body['m'][-1]['attributes'][x] = {} + elif header == "a": + #SDP 5.13 Attributes & 6.0 SDP Attributes + #a= + #a=: + + if not "a" in self.body: + self.body['a'] = {} + + if ':' in data: + data = data.split(':') + attribute = data[0] + value = data[1] + else: + attribute = data + value = None + + if value != None: + if attribute == "rtpmap": + #a=rtpmap: / [/] + value = re.split(" |/", value) + for x in self.body['m']: + if value[0] in x['methods']: + index = self.body['m'].index(x) + break + if len(value) == 4: + encoding = value[3] + else: + encoding = None + self.body['m'][int(index)]['attributes'][value[0]]['rtpmap'] = {'id': value[0], 'name': value[1], 'frequency': value[2], 'encoding': encoding} + elif attribute == "fmtp": + #a=fmtp: + value = value.split(' ') + for x in self.body['m']: + if value[0] in x['methods']: + index = self.body['m'].index(x) + break + + self.body['m'][int(index)]['attributes'][value[0]]['fmtp'] = {'id': value[0], 'settings': value[1:]} + else: + self.body['a'][attribute] = value + else: + if attribute == "recvonly" or attribute == "sendrecv" or attribute == "sendonly" or attribute == "inactive": + self.body['a']['transmit_type'] = pyVoIP.RTP.TransmitType(attribute) + else: + self.body[header] = data + + else: + self.body[header] = data + + def parseSIPResponce(self, data): + headers = data.split(b'\r\n\r\n')[0] + body = data.split(b'\r\n\r\n')[1] + + headers_raw = headers.split(b'\r\n') + self.heading = headers_raw.pop(0) + self.version = str(self.heading.split(b" ")[0], 'utf8') + if self.version not in self.SIPCompatibleVersions: + raise SIPParseError("SIP Version {} not compatible.".format(self.version)) + + self.status = SIPStatus(int(self.heading.split(b" ")[1])) + + headers = {} + + for x in headers_raw: + i = str(x, 'utf8').split(': ') + headers[i[0]] = i[1] + + for x in headers: + self.parseHeader(x, headers[x]) + + def parseSIPMessage(self, data): + headers = data.split(b'\r\n\r\n')[0] + body = data.split(b'\r\n\r\n')[1] + + headers_raw = headers.split(b'\r\n') + self.heading = headers_raw.pop(0) + self.version = str(self.heading.split(b" ")[2], 'utf8') + if self.version not in self.SIPCompatibleVersions: + raise SIPParseError("SIP Version {} not compatible.".format(self.version)) + + self.method = str(self.heading.split(b" ")[0], 'utf8') + + headers = {} + + for x in headers_raw: + i = str(x, 'utf8').split(': ') + headers[i[0]] = i[1] + + for x in headers: + self.parseHeader(x, headers[x]) + + if len(body)>0: + body_raw = body.split(b'\r\n') + body_tags={} + for x in body_raw: + i = str(x, 'utf8').split('=') + if i != ['']: + self.parseBody(i[0], i[1]) + +class SIPClient(): + def __init__(self, server, port, username, password, myIP=None, myPort=5060, callCallback=None): + self.NSD = True + self.server = server + self.port = port + self.hostname = socket.gethostname() + self.myIP = socket.gethostbyname(self.hostname) + if myIP!=None: + self.myIP = myIP + self.username = username + self.password = password + + self.callCallback=callCallback + + self.tag=hashlib.md5(str(random.randint(1, 10000)).encode('utf8')).hexdigest()[0:8] + + self.myPort = myPort + + self.inviteCounter = Counter() + self.registerCounter = Counter() + self.byeCounter = Counter() + self.callID = Counter() + + 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()) + if message.type != SIPMessageType.MESSAGE: + if message.status == SIPStatus.OK: + pass + else: + print("TODO: Add 500 Error on Receiving SIP Response") + self.s.setblocking(True) + self.recvLock.release() + continue + if message.method == "INVITE": + if self.callCallback == None: + 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) + elif message.method == "ACK": + pass + else: + print("TODO: Add 400 Error on non processable request") + except BlockingIOError: + pass + except SIPParseError as e: + if "SIP Version" in str(e): + 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)) + self.s.setblocking(True) + self.recvLock.release() + + def start(self): + self.s = 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)) + register = self.register() + t = Timer(1, self.recv) + t.name = "SIP Recieve" + t.start() + + def stop(self): + self.NSD = False + self.registerThread.cancel() + self.deregister() + self.s.close() + self.out.close() + + def genCallID(self): + return hashlib.sha256(str(self.callID.next()).encode('utf8')).hexdigest() + + def genSIPVersionNotSupported(self, request): + regRequest = "SIP/2.0 505 SIP Version Not Supported\r\n" + regRequest += "Via: SIP/2.0/UDP "+request.headers['Via']['address'][0]+":"+request.headers['Via']['address'][1]+";branch="+request.headers['Via']['branch']+"\r\n" + regRequest += "From: "+request.headers['From']['raw']+";tag="+request.headers['From']['tag']+"\r\n" + regRequest += "To: "+request.headers['To']['raw']+";tag="+self.tag+"\r\n" + regRequest += "Call-ID: "+request.headers['Call-ID']+"\r\n" + regRequest += "CSeq: "+request.headers['CSeq']['check']+" "+request.headers['CSeq']['method']+"\r\n" + regRequest += "Contact: "+request.headers['Contact']+"\r\n" #TODO: Add Supported + regRequest += "User-Agent: pyVoIP """+pyVoIP.__version__+"\r\n" + regRequest += "Warning: 399 GS \"Unable to accept call\"\r\n" + regRequest += "Allow: "+(", ".join(pyVoIP.SIPCompatibleMethods))+"\r\n" + regRequest += "Content-Length: 0\r\n\r\n" + + def genAuthorization(self, request): + HA1 = hashlib.md5(self.username.encode('utf8')+b':'+request.authentication['realm'].encode('utf8')+b':'+self.password.encode('utf8')).hexdigest().encode('utf8') + HA2 = hashlib.md5(b'REGISTER:sip:'+self.server.encode('utf8')+b';transport=UDP').hexdigest().encode('utf8') + nonce = request.authentication['nonce'].encode('utf8') + response = hashlib.md5(HA1+b':'+nonce+b':'+HA2).hexdigest().encode('utf8') + + return response + + def genRegister(self, request): + response = self.genAuthorization(request) + nonce = request.authentication['nonce'] + + regRequest = "REGISTER sip:"+self.server + regRequest += " SIP/2.0\r\nVia: SIP/2.0/UDP "+self.myIP+":"+str(self.myPort) + regRequest += "\r\nMax-Forwards: 70\r\nContact: \r\nFrom: RTP/AVP \r\n + for x in ms: + for m in ms[x]: + body += "a=rtpmap:"+str(m)+" "+str(ms[x][m])+"/"+str(ms[x][m].rate)+"\r\n" + if str(ms[x][m]) == "telephone-event": + body += "a=fmtp:"+str(m)+" 0-15\r\n" + body += "a=ptime:20\r\n" + body += "a=maxptime:150\r\n" + body += "a="+str(sendtype)+"\r\n" + + regRequest = "SIP/2.0 200 OK\r\n" + regRequest += "Via: SIP/2.0/UDP "+request.headers['Via']['address'][0]+":"+request.headers['Via']['address'][1]+";branch="+request.headers['Via']['branch']+"\r\n" + regRequest += "From: "+request.headers['From']['raw']+";tag="+request.headers['From']['tag']+"\r\n" + regRequest += "To: "+request.headers['To']['raw']+";tag="+self.tag+"\r\n" + regRequest += "Call-ID: "+request.headers['Call-ID']+"\r\n" + regRequest += "CSeq: "+request.headers['CSeq']['check']+" "+request.headers['CSeq']['method']+"\r\n" + regRequest += "Contact: \r\n" #TODO: Add Supported + regRequest += "User-Agent: pyVoIP """+pyVoIP.__version__+"\r\n" + regRequest += "Allow: "+(", ".join(pyVoIP.SIPCompatibleMethods))+"\r\n" + regRequest += "Content-Type: application/sdp\r\n" + regRequest += "Content-Length: "+str(len(body))+"\r\n\r\n" + regRequest += body + + return regRequest + + def genBye(self, request): + byeRequest = "BYE "+request.headers['Contact'].strip('<').strip('>')+" SIP/2.0\r\n" + byeRequest += "Via: SIP/2.0/UDP "+self.myIP+":"+str(self.myPort)+";branch="+request.headers['Via']['branch']+"\r\n" + byeRequest += "To: "+request.headers['From']['raw']+";tag="+request.headers['From']['tag']+"\r\n" + byeRequest += "From: "+request.headers['To']['raw']+";tag="+self.tag+"\r\n" + byeRequest += "Call-ID: "+request.headers['Call-ID']+"\r\n" + byeRequest += "CSeq: "+str(self.byeCounter.next())+" BYE\r\n" + byeRequest += "Contact: \r\n" + byeRequest += "User-Agent: pyVoIP """+pyVoIP.__version__+"\r\n" + byeRequest += "Allow: "+(", ".join(pyVoIP.SIPCompatibleMethods))+"\r\n" + byeRequest += "Content-Length: 0\r\n\r\n" + + return byeRequest + + def bye(self, request): + message = self.genBye(request) + self.out.sendto(message.encode('utf8'), (self.server, self.port)) + + def deregister(self): + self.recvLock.acquire() + fake = SIPMessage(b'SIP/2.0 401 Unauthorized\r\nVia: SIP/2.0/UDP 192.168.0.64:5060;received=192.168.0.64\r\nFrom: ;tag=b4dbea69\r\nTo: ;tag=as6845844a\r\nCall-ID: '+self.genCallID().encode('utf8')+b'\r\nCSeq: 25273 REGISTER\r\nServer: Asterisk PBX 16.2.1~dfsg-1+deb10u1\r\nAllow: INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, SUBSCRIBE, NOTIFY, INFO, PUBLISH, MESSAGE\r\nSupported: replaces, timer\r\nWWW-Authenticate: Digest algorithm=MD5, realm="asterisk", nonce="7140386d"\r\nContent-Length: 0\r\n\r\n') + + regRequest = self.genRegister(fake).replace('Expires: 300', 'Expires: 0') + + self.out.sendto(regRequest.encode('utf8'), (self.server, self.port)) + + response = SIPMessage(self.s.recv(8192)) + + + while response.status != SIPStatus(401): + if response.status == SIPStatus(500): + self.recvLock.release() + time.sleep(5) + return self.deregister() + response = SIPMessage(self.s.recv(8192)) + + regRequest = self.genRegister(response).replace('Expires: 300', 'Expires: 0') + + self.out.sendto(regRequest.encode('utf8'), (self.server, self.port)) + + response = SIPMessage(self.s.recv(8192)) + self.recvLock.release() + if response.status==SIPStatus.OK: + return True + self.recvLock.release() + + def register(self): + self.recvLock.acquire() + fake = SIPMessage(b'SIP/2.0 401 Unauthorized\r\nVia: SIP/2.0/UDP 192.168.0.64:5060;received=192.168.0.64\r\nFrom: ;tag=b4dbea69\r\nTo: ;tag=as6845844a\r\nCall-ID: '+self.genCallID().encode('utf8')+b'\r\nCSeq: 25273 REGISTER\r\nServer: Asterisk PBX 16.2.1~dfsg-1+deb10u1\r\nAllow: INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, SUBSCRIBE, NOTIFY, INFO, PUBLISH, MESSAGE\r\nSupported: replaces, timer\r\nWWW-Authenticate: Digest algorithm=MD5, realm="asterisk", nonce="7140386d"\r\nContent-Length: 0\r\n\r\n') + + regRequest = self.genRegister(fake) + + + self.out.sendto(regRequest.encode('utf8'), (self.server, self.port)) + + response = SIPMessage(self.s.recv(8192)) + + if response.status != SIPStatus(401): + if response.status == SIPStatus(500): + self.recvLock.release() + time.sleep(5) + return self.register() + + regRequest = self.genRegister(response) + + self.out.sendto(regRequest.encode('utf8'), (self.server, self.port)) + + response = SIPMessage(self.s.recv(8192)) + self.recvLock.release() + if response.status==SIPStatus.OK: + if self.NSD: + self.registerThread=Timer(295, self.register) + self.registerThread.start() + return True + else: + raise InvalidAccountInfoError("Invalid Username or Password for SIP server "+self.server+':'+str(self.myPort)) + + + \ No newline at end of file diff --git a/pyVoIP/VoIP.py b/pyVoIP/VoIP.py new file mode 100644 index 0000000..5258b5a --- /dev/null +++ b/pyVoIP/VoIP.py @@ -0,0 +1,230 @@ +from enum import Enum +from pyVoIP import SIP, RTP +from threading import Timer, Lock +import io +import pyVoIP +import random +import socket + +__all__ = ['CallState', 'InvalidRangeError', 'InvalidStateError', 'VoIPCall', 'VoIPPhone'] + +class InvalidRangeError(Exception): + pass + +class InvalidStateError(Exception): + pass + +class CallState(Enum): + RINGING = 0 + ANSWERED = 1 + ENDED = 2 + +class VoIPCall(): + def __init__(self, phone, request, session_id, myIP, rtpPortLow, rtpPortHigh): + self.state = CallState.RINGING + 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 = rtpPortHigh + self.rtpPortLow = rtpPortLow + + self.dtmfLock = Lock() + self.dtmf = io.StringIO() + + self.connections = 0 + self.audioPorts = 0 + self.videoPorts = 0 + + self.assignedPorts = {} + + 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: + print("Unknown media description: "+x['type']) + + #Ports Adjusted is used in case of multiple m=audio or m=video 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)): + print("Unable to assign ports for RTP.") + return + + self.RTPClients = [] + + 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(i['attributes'][x]['rtpmap']['name']) + assoc[int(x)] = p + except ValueError: + e = True + + if e: + raise RTP.ParseError("RTP Payload type {} not found.".format(str(pt))) + + port = None + while port == None: + proposed = random.randint(rtpPortLow, rtpPortHigh) + if not proposed in self.phone.assignedPorts: + self.phone.assignedPorts.append(proposed) + self.assignedPorts[proposed] = assoc + 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 + + def dtmfCallback(self, code): + self.dtmfLock.acquire() + bufferloc = self.dtmf.tell() + self.dtmf.seek(0, 2) + self.dtmf.write(code) + self.dtmf.seek(bufferloc, 0) + self.dtmfLock.release() + + def getDTMF(self, length=1): + self.dtmfLock.acquire() + packet = self.dtmf.read(length) + self.dtmfLock.release() + return packet + + + def answer(self): + if self.state != CallState.RINGING: + raise InvalidStateError("Call is not ringing") + m = {} + for x in self.RTPClients: + x.start() + m[x.inPort] = x.assoc + 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 + + def deny(self): + if self.state != CallState.RINGING: + 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 = [] + self.state = CallState.ENDED + + def hangup(self): + if self.state != CallState.ANSWERED: + raise InvalidStateError("Call is not answered") + for x in self.RTPClients: + x.stop() + self.sip.bye(self.request) + self.state = CallState.ENDED + del self.phone.calls[self.request.headers['Call-ID']] + + def bye(self): + if self.state == CallState.ANSWERED: + for x in self.RTPClients: + x.stop() + self.state = CallState.ENDED + del self.phone.calls[self.request.headers['Call-ID']] + + def writeAudio(self, data): + for x in self.RTPClients: + x.write(data) + + def readAudio(self, length = 160): + data = [] + for x in self.RTPClients: + data.append(x.read(length)) + if len(data) == 1: + return data[0] + else: + nd = audioop.add(data.pop(0), data.pop(0), 1) #Mix audio from different sources before returning + for d in data: + nd = audioop.add(nd, d, 1) + return nd + + +class VoIPPhone(): + def __init__(self, server, port, username, password, callCallback=None, myIP=None, sipPort=5060, rtpPortLow=10000, rtpPortHigh=20000): + if rtpPortLow > rtpPortHigh: + raise InvalidRangeError("'rtpPortHigh' must be >= 'rtpPortLow'") + + self.rtpPortLow = rtpPortLow + self.rtpPortHigh = rtpPortHigh + + self.assignedPorts = [] + self.session_ids = [] + + self.server = server + self.port = port + self.hostname = socket.gethostname() + self.myIP = socket.gethostbyname(self.hostname) + if myIP!=None: + self.myIP = myIP + self.username = username + self.password = password + self.callCallback = callCallback + + self.calls = {} + self.sip = SIP.SIPClient(server, port, username, password, myIP=self.myIP, myPort=sipPort, callCallback=self.callback) + + def callback(self, request): + call_id = request.headers['Call-ID'] + if request.method == "INVITE": + if call_id in self.calls: + return #Raise Error + if self.callCallback == None: + message = self.sip.genBusy(request) + self.sip.out.sendto(message.encode('utf8'), (self.server, self.port)) + else: + 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 + self.calls[call_id] = VoIPCall(self, request, sess_id, self.myIP, self.rtpPortLow, self.rtpPortHigh) + try: + t = Timer(1, self.callCallback, [self.calls[call_id]]) + t.name = "Phone Call: "+call_id + t.start() + except Exception as e: + message = self.sip.genBusy(request) + self.sip.out.sendto(message.encode('utf8'), (self.server, self.port)) + raise e + elif request.method == "BYE": + if not call_id in self.calls: + return + self.calls[call_id].bye() + + def start(self): + self.sip.start() + + def stop(self): + for x in self.calls.copy(): + try: + self.calls[x].hangup() + except InvalidStateError: + pass + self.sip.stop() \ No newline at end of file diff --git a/pyVoIP/__init__.py b/pyVoIP/__init__.py new file mode 100644 index 0000000..a7f523d --- /dev/null +++ b/pyVoIP/__init__.py @@ -0,0 +1,10 @@ +__all__ = ['SIP', 'RTP', 'VoIP'] + +version_info = (0, 5, 0, 'Beta') + +__version__ = ".".join([str(x) for x in version_info]) + +SIPCompatibleMethods = ['INVITE', 'ACK', 'BYE'] +SIPCompatibleVersions = ['SIP/2.0'] + +RTPCompatibleVersions = [2] \ No newline at end of file