├── .github └── workflows │ └── python-app.yml ├── .gitignore ├── .travis.yml ├── CHANGELOG.rst ├── LICENSE ├── README.rst ├── conf ├── ci-minimal │ ├── README.md │ ├── autoload_configs │ │ ├── acl.conf.xml │ │ ├── conference.conf.xml │ │ ├── console.conf.xml │ │ ├── db.conf.xml │ │ ├── event_socket.conf.xml │ │ ├── logfile.conf.xml │ │ ├── modules.conf.xml │ │ ├── sofia.conf.xml │ │ ├── switch.conf.xml │ │ └── timezones.conf.xml │ ├── dialplan │ │ └── switchydp.xml │ ├── freeswitch.serial │ ├── freeswitch.xml │ ├── modules.conf │ ├── sip_profiles │ │ ├── external.xml │ │ └── internal.xml │ ├── tls │ │ ├── dtls-srtp.pem │ │ └── wss.pem │ └── vars.xml └── switchiodp.xml ├── docs ├── Makefile ├── TODO.txt ├── api.rst ├── api │ ├── apps.rst │ ├── commands.rst │ ├── connection.rst │ ├── distribute.rst │ ├── models.rst │ ├── observe.rst │ ├── sync.rst │ └── utils.rst ├── apps.rst ├── callgen.rst ├── cmdline.rst ├── conf.py ├── fsconfig.rst ├── index.rst ├── quickstart.rst ├── services.rst ├── sessionapi.rst ├── testing.rst └── usage.rst ├── freeswitch-sounds └── soundfiles_present.txt ├── requirements-doc.txt ├── requirements-test.txt ├── setup.cfg ├── setup.py ├── switchio ├── __init__.py ├── api.py ├── apps │ ├── __init__.py │ ├── bert.py │ ├── blockers.py │ ├── call_gen.py │ ├── dtmf.py │ ├── measure │ │ ├── __init__.py │ │ ├── cdr.py │ │ ├── mpl_helpers.py │ │ ├── shmarray.py │ │ ├── storage.py │ │ └── sys.py │ ├── players.py │ └── routers.py ├── cli.py ├── commands.py ├── connection.py ├── distribute.py ├── handlers.py ├── loop.py ├── marks.py ├── models.py ├── protocol.py ├── serve.py ├── sync.py └── utils.py ├── tests ├── __init__.py ├── apps │ ├── conftest.py │ ├── test_measure.py │ └── test_originator.py ├── conftest.py ├── data │ ├── eventstream.txt │ └── eventstream2.txt ├── test_connection.py ├── test_console.py ├── test_core.py ├── test_coroutines.py ├── test_distributed.py ├── test_routing.py └── test_sync_call.py └── tox.ini /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Test Switchio 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | test: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python_version: ['3.6', '3.7', '3.8'] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: setup-docker 23 | uses: docker-practice/actions-setup-docker@v1 24 | with: 25 | docker_version: 19.03 26 | - name: Set up Python ${{ matrix.python_version }} 27 | uses: actions/setup-python@v2 28 | with: 29 | python-version: ${{ matrix.python_version }} 30 | - name: Install system dependencies 31 | run: | 32 | sudo apt install -y libpcap-dev libsctp-dev libncurses5-dev libssl-dev libgsl0-dev sip-tester 33 | - name: Install app dependencies 34 | run: | 35 | pip install . -r requirements-test.txt 36 | - name: Test with pytest 37 | run: | 38 | pytest --use-docker tests/ -vv 39 | 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | *.pot 46 | 47 | # Django stuff: 48 | *.log 49 | 50 | # Sphinx documentation 51 | docs/_build/ 52 | 53 | # PyBuilder 54 | target/ 55 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | services: 3 | - docker 4 | 5 | language: python 6 | python: 7 | - '3.5' 8 | - '3.6' 9 | # - pypy 10 | - nightly 11 | 12 | addons: 13 | apt: 14 | packages: 15 | - libpcap-dev 16 | - libsctp-dev 17 | - libncurses5-dev 18 | - libssl-dev 19 | - libgsl0-dev 20 | 21 | before_install: 22 | # build and install SIPp 23 | - git clone https://github.com/SIPp/sipp.git 24 | - cd sipp 25 | - ./build.sh --full 26 | - export PATH="$PWD:$PATH" 27 | - cd .. 28 | 29 | # pull and start up freeswitch container bound to 'lo' 30 | # - docker pull safarov/freeswitch 31 | # - docker run -d --net=host -e SOUND_RATES=8000:16000 -e SOUND_TYPES=music:en-us-callie -v freeswitch-sounds:/usr/share/freeswitch/sounds -v $TRAVIS_BUILD_DIR/conf/ci-minimal/:/etc/freeswitch safarov/freeswitch 32 | # - docker ps -a 33 | 34 | install: 35 | # - pip install -U tox 36 | - cd $TRAVIS_BUILD_DIR 37 | - pip install . -r requirements-test.txt 38 | 39 | # NOTE: no support for pandas/pytables yet 40 | script: 41 | - pytest --use-docker tests/ 42 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Change Log 2 | ========== 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on `Keep a Changelog`_ and this project adheres to 6 | `Semantic Versioning`_. 7 | 8 | .. _Keep a Changelog: http://keepachangelog.com/en 9 | .. _Semantic Versioning: http://semver.org/ 10 | 11 | [Unreleased] 12 | ------------ 13 | 14 | 15 | [0.1.0.alpha1] - 2017-11-15 16 | --------------------------- 17 | Changed 18 | ******* 19 | - Coroutine support to the ``switchio.apps.routers.Router`` app 20 | which enforces full async routing handlers. 21 | - New readme showcasing the py3.5+ only API 22 | - Making return values from ``Session`` methods awaitable 23 | 24 | Fixed 25 | ***** 26 | - Fix up some CLI bugs introduced during the hard fork / core rewrite 27 | - Add proper coroutine-task teardown handling and logging 28 | 29 | Removed 30 | ******* 31 | - SWIG and py2.7 support 32 | 33 | 34 | [0.1.0.alpha0] - 2017-10-31 35 | --------------------------- 36 | Added 37 | ***** 38 | ``switchio`` is a hard fork of Sangoma's `switchy`_ project which seeks 39 | to leverage features in modern Python (3.5+) including the language's 40 | new native `coroutine` syntax and supporting event loop backend(s) such 41 | as the standard library's `asyncio`_. The change history prior to 42 | this fork can be found in the original projects's log. Python 2.7 43 | support will be dropped likely for the first non-alpha release. 44 | 45 | - Full (self-contained) CI using a production FreeSWITCH ``docker`` image 46 | and runs on ``TravisCI``. 47 | - ESL inbound protocol implementation written in pure Python using an 48 | ``asyncio.Protocol``. 49 | - Event loop core rewritten to support Python 3.5 coroutines and `asyncio`_ 50 | engine. 51 | - Coroutine app support using a ``@coroutine`` decorator and an extended 52 | ``Session`` API which allows for awaiting particular (sets) of events. 53 | 54 | Removed 55 | ******* 56 | - Legacy IVR example(s) 57 | 58 | .. _switchy: https://github.com/sangoma/switchy 59 | .. _asyncio: https://docs.python.org/3.6/library/asyncio.html 60 | .. _coroutine: https://docs.python.org/3.6/library/asyncio-task.html 61 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | switchio 2 | ======== 3 | asyncio_ powered FreeSWITCH_ cluster control using pure Python_ 3.6+ 4 | 5 | |pypi| |github_actions| |versions| |license| |docs| 6 | 7 | .. |versions| image:: https://img.shields.io/pypi/pyversions/switchio.svg 8 | :target: https://pypi.org/project/switchio 9 | .. |pypi| image:: https://img.shields.io/pypi/v/switchio.svg 10 | :target: https://pypi.org/project/switchio 11 | .. |github_actions| image:: https://github.com/friends-of-freeswitch/switchio/actions/workflows/python-app.yml/badge.svg 12 | :target: https://github.com/friends-of-freeswitch/switchio/actions/workflows/python-app.yml 13 | .. |license| image:: https://img.shields.io/pypi/l/switchio.svg 14 | :target: https://pypi.org/project/switchio 15 | .. |docs| image:: https://readthedocs.org/projects/switchio/badge/?version=latest 16 | :target: http://switchio.readthedocs.io 17 | 18 | ``switchio`` (pronounced *Switch Ee OoH*) is the next evolution of `switchy`_ 19 | (think *Bulbasaur* -> *Ivysaur*) which leverages modern Python's new native 20 | coroutine_ syntax and, for now, asyncio_. 21 | 22 | API-wise the project intends to be the flask_ for VoIP but with a focus on 23 | performance and scalability more like sanic_. 24 | 25 | .. _asyncio: https://docs.python.org/3.6/library/asyncio.html 26 | .. _FreeSWITCH: https://freeswitch.org/ 27 | .. _Python: https://www.python.org/ 28 | .. _switchy: https://github.com/sangoma/switchy 29 | .. _coroutine: https://docs.python.org/3.6/library/asyncio-task.html 30 | .. _flask: http://flask.pocoo.org/ 31 | .. _sanic: https://github.com/channelcat/sanic 32 | .. _docs: https://switchio.readthedocs.org/ 33 | 34 | 35 | Use the power of ``async`` and ``await``! 36 | ----------------------------------------- 37 | Build a routing system using Python's new coroutine_ syntax: 38 | 39 | .. code:: python 40 | 41 | from switchio.apps.routers import Router 42 | 43 | router = Router( 44 | guards={ 45 | 'Call-Direction': 'inbound', 46 | 'variable_sofia_profile': 'external'}, 47 | subscribe=('PLAYBACK_START', 'PLAYBACK_STOP'), 48 | ) 49 | 50 | @router.route('(.*)') 51 | async def welcome(sess, match, router): 52 | """Say hello to inbound calls. 53 | """ 54 | await sess.answer() # resumes once call has been fully answered 55 | sess.log.info("Answered call to {}".format(match.groups(0))) 56 | 57 | sess.playback( # non-blocking 58 | 'en/us/callie/ivr/8000/ivr-founder_of_freesource.wav') 59 | await sess.recv("PLAYBACK_START") 60 | sess.log.info("Playing welcome message") 61 | 62 | await sess.recv("PLAYBACK_STOP") 63 | await sess.hangup() # resumes once call has been fully hungup 64 | 65 | Run this app (assuming it's in ``dialplan.py``) from the shell:: 66 | 67 | $ switchio serve fs-host1 fs-host2 fs-host3 --app ./dialplan.py:router 68 | 69 | You can also run it from your own script: 70 | 71 | .. code:: python 72 | 73 | if __name__ == '__main__': 74 | from switchio import Service 75 | service = Service(['fs-host1', 'fs-host2', 'fs-host3']) 76 | service.apps.load_app(router, app_id='default') 77 | service.run() 78 | 79 | 80 | Spin up an auto-dialer 81 | ---------------------- 82 | Run thousands of call flows to stress test your service system using 83 | the built-in auto-dialer_:: 84 | 85 | $ switchio dial fs-tester1 fs-tester2 --profile external --proxy myproxy.com --rate 100 --limit 3000 86 | 87 | .. _auto-dialer: http://switchio.readthedocs.io/en/latest/callgen.html 88 | 89 | 90 | Install 91 | ------- 92 | :: 93 | 94 | pip install switchio 95 | 96 | 97 | Docs 98 | ---- 99 | Oh we've got them docs_! 100 | 101 | How do I deploy my FreeSWITCH cluster? 102 | -------------------------------------- 103 | - Enable `inbound ESL`_ connections 104 | - Add a park-only_ dialplan (Hint: we include one here_) 105 | 106 | See the docs_ for the deats! 107 | 108 | .. _inbound ESL: https://freeswitch.org/confluence/display/FREESWITCH/mod_event_socket#mod_event_socket-Configuration 109 | .. _park-only: https://freeswitch.org/confluence/display/FREESWITCH/mod_dptools%3A+park 110 | .. _here: https://github.com/friends-of-freeswitch/switchio/blob/master/conf/switchiodp.xml 111 | 112 | 113 | What's included? 114 | ---------------- 115 | - A slew of `built-in apps`_ 116 | - A full blown `auto-dialer`_ originally built for stress testing VoIP service systems 117 | - Super detailed ESL event logging 118 | 119 | .. _built-in apps: http://switchio.readthedocs.io/en/latest/apps.html 120 | .. _auto-dialer: http://switchio.readthedocs.io/en/latest/callgen.html 121 | 122 | 123 | How can I contribute? 124 | --------------------- 125 | Have an idea for a general purpose ``switchio`` app or helper? 126 | Make a PR here on GitHub! 127 | 128 | Also, if you like ``switchio`` let us know on Riot_! 129 | 130 | .. _Riot: https://riot.im/app/#/room/#freeswitch:matrix.org 131 | 132 | 133 | Wait, how is ``switchio`` different from other ESL clients? 134 | ----------------------------------------------------------- 135 | ``switchio`` differentiates itself by supporting FreeSWITCH 136 | *process cluster control* as well as focusing on leveraging the 137 | most modern Python language features. ``switchio`` takes pride 138 | in being a *batteries included* framework that tries to make all 139 | the tricky things about FreeSWITCH a cinch. 140 | 141 | 142 | What if I'm stuck on Python 2? 143 | ------------------------------ 144 | Check out these other great projects: 145 | 146 | - greenswitch_ 147 | - eventsocket_ 148 | - pySWITCH_ 149 | - python-ESL_ 150 | 151 | .. _greenswitch: https://github.com/EvoluxBR/greenswitch 152 | .. _eventsocket: https://github.com/fiorix/eventsocket 153 | .. _pySWITCH: http://pyswitch.sourceforge.net/ 154 | .. _python-ESL: https://github.com/sangoma/python-ESL 155 | 156 | 157 | Performance monitoring 158 | ---------------------- 159 | If you'd like to record performance measurements using the 160 | CDR_ app, some optional numerical packages can be used: 161 | 162 | .. _CDR: http://switchio.readthedocs.io/en/latest/apps.html#cdr 163 | 164 | =============== ================ ================================ 165 | Feature Dependency Installation 166 | =============== ================ ================================ 167 | Metrics Capture `pandas`_ ``pip install switchio[metrics]`` 168 | Graphing `matplotlib`_ ``pip install switchio[graphing]`` 169 | HDF5 `pytables`_ [#]_ ``pip install switchio[hdf5]`` 170 | =============== ================ ================================ 171 | 172 | .. [#] ``pytables`` support is a bit shaky and not recommended unless 173 | you intend to locally process massive data sets worth of CDRs. 174 | The default CSV backend is usually sufficient on a modern file 175 | system. 176 | 177 | .. _pandas: http://pandas.pydata.org/ 178 | .. _matplotlib: http://matplotlib.org/ 179 | .. _pytables: http://www.pytables.org/ 180 | 181 | 182 | License 183 | ------- 184 | All files that are part of this project are covered by the following 185 | license, except where explicitly noted. 186 | 187 | This Source Code Form is subject to the terms of the Mozilla Public 188 | License, v. 2.0. If a copy of the MPL was not distributed with this 189 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 190 | -------------------------------------------------------------------------------- /conf/ci-minimal/README.md: -------------------------------------------------------------------------------- 1 | ## Minimal FreeSWITCH Configuration 2 | 3 | The default "vanilla" configuration that comes with FreeSWITCH has 4 | been designed as a showcase of the configurability of the myriad of 5 | features that FreeSWITCH comes with out of the box. While it is very 6 | helpful in tinkering with FreeSWITCH, it has a lot of extraneous stuff 7 | enabled/configured for use in a production system. This configuration 8 | aims to take the reverse stance -- it attempts to be a starting point 9 | for configuring a new system by "adding" required features (instead of 10 | removing them as one would do if one starts with the default 11 | configuration). 12 | 13 | This folder also includes the corresponding `modules.conf` that lists 14 | the modules that are required to get this configuration working. 15 | 16 | ### Test 17 | 18 | This configuration was tested by sending an INVITE (without 19 | registration) using the `siprtp` example program that comes with 20 | PJSIP, and verifying that the info dump is produced on the FreeSWITCH 21 | console. 22 | 23 | $ ./siprtp -q -p 1234 "sip:stub@$(my_ip):5080" 24 | 25 | ### Upstream 26 | 27 | The configuration in this folder comes from 28 | [mx4492/freeswitch-minimal-conf](https://github.com/mx4492/freeswitch-minimal-conf/commit/270941d6f2dca279f1bb8762d072940273d5ae11). 29 | 30 | ### Other Minimal Configurations 31 | 32 | * [voxserv/freeswitch_conf_minimal](https://github.com/voxserv/freeswitch_conf_minimal) 33 | -------------------------------------------------------------------------------- /conf/ci-minimal/autoload_configs/acl.conf.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /conf/ci-minimal/autoload_configs/conference.conf.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /conf/ci-minimal/autoload_configs/console.conf.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /conf/ci-minimal/autoload_configs/db.conf.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /conf/ci-minimal/autoload_configs/event_socket.conf.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /conf/ci-minimal/autoload_configs/logfile.conf.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /conf/ci-minimal/autoload_configs/modules.conf.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /conf/ci-minimal/autoload_configs/sofia.conf.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /conf/ci-minimal/autoload_configs/switch.conf.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /conf/ci-minimal/dialplan/switchydp.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /conf/ci-minimal/freeswitch.serial: -------------------------------------------------------------------------------- 1 | ac110002d6cc -------------------------------------------------------------------------------- /conf/ci-minimal/freeswitch.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 7 |
8 | 9 |
10 | 11 |
12 |
13 | -------------------------------------------------------------------------------- /conf/ci-minimal/modules.conf: -------------------------------------------------------------------------------- 1 | applications/mod_commands 2 | applications/mod_conference 3 | applications/mod_db 4 | applications/mod_dptools 5 | applications/mod_expr 6 | applications/mod_hash 7 | dialplans/mod_dialplan_xml 8 | endpoints/mod_loopback 9 | endpoints/mod_sofia 10 | event_handlers/mod_event_socket 11 | formats/mod_native_file 12 | formats/mod_sndfile 13 | loggers/mod_console 14 | loggers/mod_logfile 15 | -------------------------------------------------------------------------------- /conf/ci-minimal/sip_profiles/external.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /conf/ci-minimal/sip_profiles/internal.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /conf/ci-minimal/tls/dtls-srtp.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQC0bUzILEWwYMnR 3 | y2omoSyaQWja5hS3aPihZSwcCG3+mKg/N3AqklOw3ngO5rfhNinNeDDbSAuv5Kcz 4 | E+LwJO2FZyhdAo72d1/rV8rKnDgS/1RJv7q9qTWm6s3Hw800yaZ4pN/E3rZuaefD 5 | 9ZKvUig1dy9PmMcUAchs2bMDn8zDgfCoWT86tvDODwoBrxf0DXnR2u1RGyTTNuz3 6 | UNNdpacJJYj93CoRyvRkURFOSgsMUeIVBsVmNNn/oadTyoEIp2Z2Y5DxxR+xC9TZ 7 | jORrpeWSILEgQGxX05rIe6rOH+6GlLXL0L/rIHQCdJljH082A8jwXJ+cse1qgEpd 8 | hjTWRmNzXqzchJPshNdvzCU1jLf4B5YTmXaU/GPreLJnmLxOdX4Q/SIfKajUjPOb 9 | c9jsWN6sVqV8lT6ilThLRbXKhR8GjkfwpZ3swxKfXvTvWCYqH7eZ6DZx/raODKdU 10 | fSm7UYt95V5WJllwx9HR8YIDUb5V0QQTM8rqtCGGPwQ6Ynezm5cDpp8S3W1rfWvn 11 | aj5ZnuqKjdCHt4BwKyhJqq+iVTH3t1IY/6yYNZM9g+zMo+XPcCm9R02eA9fZSPf9 12 | 3kKQrmrvfLCr1MIWoJnFTcE5GJZz/q0X5LFRj0B7R8egbATLJKYA8w2mJQVdlkH/ 13 | K6qE/u9x5mCeEsbQtv+m7udazuyKbwIDAQABAoICAQCxQhiHKIemytA8Xr4BCaOK 14 | QOzE8fo0Xtq1pXH6cIWv9UaGJO77xBqYz1fgO+c3SUE0bfqB1Hw26EsjsUvpZj48 15 | K6bKCfNuTMVdrzi6aVPlxheHBOhv4MenH1PgCIuYauwz5rc48R0FyOI78Q2VVP/P 16 | 1zIR3yTmkQHZft+SlfJTuVs36cZm5sgZiUjpcp3z1TFzbMRuRF6fa3zPVQbzIAXE 17 | xK2byitCo8QKsmJwKepExkV5JtfUm+P4c1ayyWaPm/bcJOGHxYRnKhqJSRQ4dhne 18 | wGOELgbDGk8c2/Jo45IxgfRBMur6Ez4mE2uGqIHnrTxCeJ0PEvcPBM8KpwlN4oVc 19 | cgX7NQRHUCtxq4j31qwJQC56Uit5T3by8TXkrdE+dEqSu00yhhdsxHfoM+pFSwKW 20 | +UkzMPqZqc3/GeEGcmVM/FAxjZif4dL0dvdDfXpyNK+DsO2dP2N/IiLsNU05wZXl 21 | Bwpyy1PdAzAtrw9fkOd7s2G6iy/GACkwq5f+2Eh8LlsbBPGuVnFGpjVNDVLW4+lV 22 | TuKA98E3sujJerwXzSTcp3kBSQ1XoIPzLeBL2C4QKvRvDULkdJ+Rh0/pzPlEKQbU 23 | u3Spqb017hlgHDhJ1Pje+FrBJWwUKSE/W6dJjuDLpbHO5uOLXwGOxJysDBuQ1MIL 24 | mnkdj9EAwVWyeA3s6jz9sQKCAQEA4/2O4bV9DnR39mNFxTjiA1XzLlhIl13dHRZH 25 | ewdcxkjB5JsWGgBO7XrOYb6wK/IFMAZTYgINB8gYBxTMdj52DJwMfeX5erYzeGYD 26 | rlX1HW2svKhbkNqpa7w5hIoLHo2dLeF2baswAaCowQU2MWTgwCkjKER7gK2+d9xg 27 | NWPtax+j/iUy65V7kPtWtAJmm9wrtoy890esBo3gr1GGYmph4wHTDYqj0hVgvU6I 28 | rnBdPoFnqpBCb6a0sprHASp9hCQugiTyvLQKey+iBcAWsyWMyG027DJv14QOQNbn 29 | 9eL7+es2HJ0926aKK9ZaNZVhVk/2vmZQ0l4H05YD4E0g1ZjHOwKCAQEAypfXAPOW 30 | JbfVOUx0qMc4IUbJ142c9gETlOq5TiakKW1PpR6sbDhg/wt1NZcuPxJKPV8vVJim 31 | fR01hwIBiRPDHWZFi2unD4IxSVTmkjItbRf+fOg7MgRTvm+mCRpH2WvcKhpE75Q1 32 | cDfB83uzjzh2PUL9iz/hEQXtd64rHF9neB/9vpR4IV3OMW6KWw54f/U6rp1fT1Fl 33 | VI9a7rvINV4L7oRTAsQL2w1fwzV5f32afaknSO7wmBSWJ6Zsn7VAF87MW19KYEmo 34 | QWBxheK3tOkxo1xNJkdSjWbYFvwmmLbN9M0UZCiXaojgizmRWTLF1tg9xbYnE/6z 35 | xjoTmm32KIXeXQKCAQAxysmuv71NL+CXPf0Z4p9xzQ6sO3m3HTfSR3BbDTOU1oUK 36 | EVjVWuXh9aUnMcc2AchiQa9qQzpnTar5uPAijuenP1l3EYfX5fz9uYHqTDmZML++ 37 | ACLnUuoXbmc9bAyboqAGSixcLTvxzw2sAiBgz0BKl3FnNPWoF8n2UXntjyJl9+YP 38 | 9j3Vt7Lh6hJ4g+G2/nHJj5khhFSspcXBZFOuIL+6HUbjuTioBCU5kvJE7qNeqFJk 39 | rNblnYnvS+BUf9wjxOcnYzxkPAnh93gyO05516SUjU2mbimA7wVR4d2NFQKlBVqv 40 | CyRcWVXp1wmilDpK6HHiCWRzXTfmXOgBz7ZxD8nLAoIBAFqlQ4rmcjjgHuQrREs8 41 | D+47qRXsA93CL4vC1jSUb4ElqqwbpFQriaKz8raOtR99RIBfNWMphdyXFBsbF6rI 42 | j9V4rAcsnKwAuaKw/RVOpCqawMAMfGftrbaYZ/bMrncmnnSsGkoDy0ExgXM61uVv 43 | AuB4N891PnOKbmzNHfbs7PO/hJ4f+fwb56UQa5FAUUQXajE0sq9foPILzkjg9jyC 44 | nt4SkL29D/zr5/wE0h7sCRLOe4hTeIzjMSf+e72dsFa2rZL4eOPKMSFHUKPyA/ZL 45 | HG2WX+KPqO0hpe/q1C4iJNayZ7xEuTLumWFR2anKYOC3EjSDQsrcfH1mAN1o8+m/ 46 | s/UCggEAFEi+abM3hn6h6MZRCZEIswNGCqh8sgrm9/I05EnJXetRpfPWwfD3tzd1 47 | ZdBmb7MtoUbk8RJUCfEC0J+hUpP7z5/BQnLNwbYmFMWeGzyiK0pCAlLOX/bQPMq7 48 | SDG66CKgNLk9xb4CjS/K/eAu+tUXPSlo5sdudEp/6b8gHYvHfnAF6inJ1xCozwiY 49 | cm3gHyWhWjqiG6/Lyn0MEtPwpaXcabJUnDAxT4otcty9nBFv6kfpPfwDG/H/jac9 50 | j5HPfe7V4nIXccpdTaaTFkfWSmls1mngTNIPSUGF1zAkPv+4q5KTTjKZOQ/RA195 51 | s/ccRcUrdAZcYnCG3PkKU1cn/drXGw== 52 | -----END PRIVATE KEY----- 53 | -----BEGIN CERTIFICATE----- 54 | MIIEvzCCAqegAwIBAAIBADANBgkqhkiG9w0BAQUFADAiMQswCQYDVQQGEwJVUzET 55 | MBEGA1UEAwwKRnJlZVNXSVRDSDAgFw0xNzA1MTMwMzI2NDBaGA8yMTE3MDQyNjAz 56 | MjY0MFowIjELMAkGA1UEBhMCVVMxEzARBgNVBAMMCkZyZWVTV0lUQ0gwggIiMA0G 57 | CSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC0bUzILEWwYMnRy2omoSyaQWja5hS3 58 | aPihZSwcCG3+mKg/N3AqklOw3ngO5rfhNinNeDDbSAuv5KczE+LwJO2FZyhdAo72 59 | d1/rV8rKnDgS/1RJv7q9qTWm6s3Hw800yaZ4pN/E3rZuaefD9ZKvUig1dy9PmMcU 60 | Achs2bMDn8zDgfCoWT86tvDODwoBrxf0DXnR2u1RGyTTNuz3UNNdpacJJYj93CoR 61 | yvRkURFOSgsMUeIVBsVmNNn/oadTyoEIp2Z2Y5DxxR+xC9TZjORrpeWSILEgQGxX 62 | 05rIe6rOH+6GlLXL0L/rIHQCdJljH082A8jwXJ+cse1qgEpdhjTWRmNzXqzchJPs 63 | hNdvzCU1jLf4B5YTmXaU/GPreLJnmLxOdX4Q/SIfKajUjPObc9jsWN6sVqV8lT6i 64 | lThLRbXKhR8GjkfwpZ3swxKfXvTvWCYqH7eZ6DZx/raODKdUfSm7UYt95V5WJllw 65 | x9HR8YIDUb5V0QQTM8rqtCGGPwQ6Ynezm5cDpp8S3W1rfWvnaj5ZnuqKjdCHt4Bw 66 | KyhJqq+iVTH3t1IY/6yYNZM9g+zMo+XPcCm9R02eA9fZSPf93kKQrmrvfLCr1MIW 67 | oJnFTcE5GJZz/q0X5LFRj0B7R8egbATLJKYA8w2mJQVdlkH/K6qE/u9x5mCeEsbQ 68 | tv+m7udazuyKbwIDAQABMA0GCSqGSIb3DQEBBQUAA4ICAQArkU9T+lvxlfETMb0C 69 | C6nlMBr8vaD5Z98xzLe0cG8e8Q5aFWMpFAKOX6Z/RnwOFRKMdP8NPMH+8YVExbE1 70 | jSa70UIp5VU21kvmMOMNJjn0dVpRcv+rwNLdL5EbxKY+XCl41OWNuq6vsLTogff7 71 | u0fp69MJJOshuJ2mImLdeUky7xqdNIkUwoOTomYaAoj0YSQiBqSBWi+WQuHrY2hn 72 | g0b8hyLyXNtKsNlOnNXWBOmzDHnE7GUxljqnJ3Mh/GBdAQ3aLyunH6E42zp36aQM 73 | PWjBGxv3UjqHJSS1XBpa+rVw8I732t3UF2J1n3TrXZDWMiikWdWLGi1cuvXU1OMZ 74 | BWeyMkZEU400fY5CAgFnpznp+5MnLu+Go7LojdhLArnGTfP/snp/I8xfISS0TuKG 75 | wcBBYwN1qzrrZ0qQrep15fCB2RPd5yFozuL5fbDjrMbVd5xnQl/s7sLNFX8+rSUN 76 | jCz5Hu+oVTtyaa+1OzvioY5mhsLRmYqw6Q9Mi2Fbm+7rDPAZ649KdodsmQg8764O 77 | S8iN3qmhbUEsw07Ni/ikviSvNruonhsxAlI2v02UNb2EwTDjko2OofKWwf5tOclC 78 | VA3CwYR0jWyl8DrRkoGLpTIgraSlMP2/ZwIhcC2LwtDOgpZCYjIOWfd5leuO4q0K 79 | JqqF6b6cEyiquN9BcCHKJ+inSw== 80 | -----END CERTIFICATE----- 81 | -------------------------------------------------------------------------------- /conf/ci-minimal/tls/wss.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQDVaTt+a+SlFx9S 3 | O2YIibk14ISVzo25ceNw2KaXJB4GmWofseG0nSZofJDUGJG/QooLUFSHYZHEfjaG 4 | 95xVfX9I5PSVsdYViEFe7PPVyOstq/vFBkpCjtuQoV1uczAkUGE2GbreY2h6UCTO 5 | geaMy/rARYL4KIkGFG8AFr37CW5mojjV3YOvQGG++M99SSB2j9cRwlU6x8SWRqWH 6 | NzNBLiPv1DVx4mhZHeVyCX/RlzylnGOrsGoW1PdYWFVw3GvWKOg4QSkF2DU+pjTy 7 | mjdZjwuc5OhtUDrv9ZDk+a+PltEcbZnV1ksdU6xt2cn7MmPeJ746mRAUgMbRM+0C 8 | lMMN34OTXi9wL4UR1HrSL09KtLzxzk9uKA6lFJ04gt3mBJdTpAgeV4iqJpIgzAfN 9 | T8E/NvOR70reUIY5gnsUs/VyOvtgZuGjbeIJ2LFwSHU47qmxlGaSMzI85pVJ//93 10 | lr4SNHPq52FGccxF/N2ZbhE4jZ1lI6jgjICPBmYLsr6SXjAe10arQ665EbFm0NWE 11 | nn0dEP3pI6nWBfzlwZGV30Lud9htVFA+Gv9TImS7fw4gTH9dDYEYgeAQeOByQqDN 12 | K2Fa6WtULp/bII+XxGyfbZONgpvggc01vl94F4cyTQLgAFnAjV6MOYrK69vqFgdU 13 | aI1fMWqjTygBWwd4bndeX6L6B6kCJQIDAQABAoICAHapRINOyptg9/FpRZqJuYnT 14 | hOUnLHZodOZSEI8JbgiNVQy6g51BpBGE8aJ85s1J2ifgSp/cCIkRBJCXLT37wcJu 15 | S/DQKRFf6bk2V34PcXS7pAoxLSlE9aC9mI3JToNijires6Dj9TvNRCnu4Jp48Lvn 16 | 6qLBJy9SPUX3XzsuS0yV9rQzrvzK946QGg63km1aHWOqgmlkmlJUhcVhVAZx+Vlo 17 | zS69jPfAP8vgrEmFphsPkfvs45A+aimdNCTE6tDxfe2JjZ3KUhC7qcUbKpuJhhN+ 18 | 1B2z9pFmIWKv0oYsfako59iWhr2PwNhzuHML2au0vXknkmFUGThJfwdoX+xe03Eh 19 | gt+008QYPNYAy8R3pEpTFmPVcXPbOAph1iYzXvAfTENHfP5Wm2sgkp97i8naouaL 20 | CiSm1DRLDDoGwZgZHk0+DyH/7ATVexTr8j0sZ2pOnhaY90pEQ8Hijv8TjXXmJHIa 21 | iylV6+HLWxfbLkRkKMdK/ZsG84AQe1nzReumDWLBb8m6e762mcv/IrNSDFFyozN2 22 | Lc86+zPeS+ZpQlgctYc89UPLlXCwg4CYZnkV2Nuhot/c/HW39EDaaocYYXyn8aZZ 23 | Yq+tf3L0gaCE4yNi9Xls1UMla+XDC1UG1iP4me+kKrJLqUwUszBe6hwNQkMhh9An 24 | nWzQCnmT3iIiMdPhOMAhAoIBAQDse0MgD7EizximiIdossvNODKOif8qUiCr+kCQ 25 | ofm+zFAQ9GsKJlIJKxtnfjkHMv0YrBC8FqHkZ+dsCOU4u3cpYfupgllIXzZzjOEd 26 | Q1VXTKQZMkmf2G4Ugdw7eET6d/cnv9B7QLI6D77Jb/exrAZFAMZEx7dQf7seC/3Q 27 | p6LiSr6O5no5BLwJadLKZfYXVFDKj7EYqXsgZUjL4CNjs6KFvyWX0/gqVKPABBq6 28 | z0OD5o5O1T/RmXqQwbmcf9nOTgZKH0ywBoo20hL+IoqlqJo5/1ohF0WNsJ7fISKS 29 | O7T9wsrWf7617/naSXZvzyMu62JcC4RH+Cc8tB4sYSrYFX8PAoIBAQDnBoDcEMUK 30 | 6bpWdpT2cE9RLG20dT1YwlB6UiOdn8mGY9wTNWSSi5sVYeSrpzHBDqM9k9iISk70 31 | HdHRwxTxO1Ble9VMHZARh7abXNeEtUblT59ehlza2ASCcWZIsbMC1WVXAHiY0eNK 32 | x0/kfQ/yRmFCjKJBxmTrCvD9rwwRCdsAtmFr9lKc7Z5LrE9F5ugJT2d/tDZJtG3g 33 | 9sODZCHq2hJya4wrirM4cN1hlPFOWCIeb9875Ew152sknLD1nS78GlZp+X7ez01e 34 | 7IMPXdgpt1UaAjz+WXw6BhDpuowmXLVqqtQOtwcN2tFAVTK6ihOEhxwylI+jj2Pd 35 | djZACxUGAquLAoIBAQDS0K/ujQ6ksWqQS3YrZ/k3YbnwSCUpXT2zEs0sIrm51foq 36 | ozopOjA37C3p/SRpvpLSw9HHW4XHULmGHgf6o2R/h9IKMyHU2sx4BkdJBxW1VaWU 37 | sLfhv9eigIIMohMcFoZG8UCyH+LCz/aNLTvrMCgGq8IU3tYU9UmiZ55FvKwAgBGQ 38 | 4vZijk3zNlusA2l3MrszWRXPnocdQi27Un06DJH+GYEx8M1zLzErpH0PvGW72HVU 39 | 4daay+/vzEPjuDY7LGN/AJgk9C+S34P++lwPgla7DWETzuM6hRGcaLWhF4kqD9Uz 40 | k344Q07BwmvLESWUVlLBK6MpZZfVDd04Qwcmy0MRAoIBAQCjY5gTC9EvrKnaqLcl 41 | q7yM/k0AScJ/Wb4tJJaHzhBq+YkWFLbyZcXi0380X2Ty0vVkgYGsslEwmT5higTv 42 | rzFm9LOfx3Cy1TaynnDWLJbdavidk41sQUa/MpBSbUrbgCzR4UVpX1fO9TNtt31l 43 | rZzvwiKnPjb1fKF/6SMxkbTPxi0Ue/tlfTBs+IqAN5R3sS7TxJJ4giEL77nK3TCX 44 | tm9BeQqDaO7jxf7sGrim3fITcK+C3i2AGhFOrXsB2o72QXJINOojjp17uu3eGlLc 45 | /go0AM0+Xe9b4hpoE0U3hG3R6MtdNNjKVa2UQC1GfIzdN+kbZLJJzQmmAQrRNIVD 46 | 4PONAoIBAA0pzgKuJ1JRHT6wBp52+gjqOMeMPciTn0oc9ylgAyomvNUYJr/Ek8yl 47 | HcAUcUYH1E0B04Uk+ZbayM81m+k7tJYzscy9C5mSIxk+BfjmweuZcky5+9TwQmBE 48 | bjLF4uh69+Exnr1tpnoUJk02flehm7f55cmgvsBrytKQTDG83MNMwRNiU0wDTnHi 49 | 1Nkm3jHOHMjsEDJkV0jJ+srOELKqx8yp2yErrGIzzOBlz8Ajmr6uxycohFpFXeAz 50 | X1ZfuQTjuy6f3OjvZzn1z3QZN3R+aoOFF551kI5wO4LE1S1w/hJrQEWIciOnW+mF 51 | LDWLlPcD4CCGz2pDTjqdte6ctdRNDHw= 52 | -----END PRIVATE KEY----- 53 | -----BEGIN CERTIFICATE----- 54 | MIIEvzCCAqegAwIBAAIBADANBgkqhkiG9w0BAQUFADAiMQswCQYDVQQGEwJVUzET 55 | MBEGA1UEAwwKRnJlZVNXSVRDSDAgFw0xNzA1MTMwMzI2NDFaGA8yMTE3MDQyNjAz 56 | MjY0MVowIjELMAkGA1UEBhMCVVMxEzARBgNVBAMMCkZyZWVTV0lUQ0gwggIiMA0G 57 | CSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVaTt+a+SlFx9SO2YIibk14ISVzo25 58 | ceNw2KaXJB4GmWofseG0nSZofJDUGJG/QooLUFSHYZHEfjaG95xVfX9I5PSVsdYV 59 | iEFe7PPVyOstq/vFBkpCjtuQoV1uczAkUGE2GbreY2h6UCTOgeaMy/rARYL4KIkG 60 | FG8AFr37CW5mojjV3YOvQGG++M99SSB2j9cRwlU6x8SWRqWHNzNBLiPv1DVx4mhZ 61 | HeVyCX/RlzylnGOrsGoW1PdYWFVw3GvWKOg4QSkF2DU+pjTymjdZjwuc5OhtUDrv 62 | 9ZDk+a+PltEcbZnV1ksdU6xt2cn7MmPeJ746mRAUgMbRM+0ClMMN34OTXi9wL4UR 63 | 1HrSL09KtLzxzk9uKA6lFJ04gt3mBJdTpAgeV4iqJpIgzAfNT8E/NvOR70reUIY5 64 | gnsUs/VyOvtgZuGjbeIJ2LFwSHU47qmxlGaSMzI85pVJ//93lr4SNHPq52FGccxF 65 | /N2ZbhE4jZ1lI6jgjICPBmYLsr6SXjAe10arQ665EbFm0NWEnn0dEP3pI6nWBfzl 66 | wZGV30Lud9htVFA+Gv9TImS7fw4gTH9dDYEYgeAQeOByQqDNK2Fa6WtULp/bII+X 67 | xGyfbZONgpvggc01vl94F4cyTQLgAFnAjV6MOYrK69vqFgdUaI1fMWqjTygBWwd4 68 | bndeX6L6B6kCJQIDAQABMA0GCSqGSIb3DQEBBQUAA4ICAQARM8kiHS1fGYbLU4jq 69 | W3BlZUD2hjubCv3CtBPGUG5PmFk9iVt0mPYF3kcSydXaggDnv2gX0DQU04260C4U 70 | 6X+TffHwz8OMZuNBciRBX6ThSxptcXMeuGrqGFj5UREpnInZRQ+lYfjZT7k6Uoia 71 | jKvtARhKGHz0BWltxxU2c3a2UHSJKtucCpZTMGZl2XULmn5dMPNXfv9E+Q6JBAN5 72 | vmGS9MXOsKH93822JPyWO8FDtyhPVL6f0/rbnQ8TAxuvaulIxacTOgNJMfM+ILJZ 73 | o9b4n8YxcHC2IfcKyvuRRX+/JZGZiy+Ww8iSb7mXJ+ClYAFrSSu//3yYdZY0Ekb3 74 | /Z8Rmv/80SgpGVroC7mLw3PJKIZ25IO/a6CId+qeVs/QD0L7fhIFTmAL9h1OXJq/ 75 | 0NyV4XrbLMCLdHR0bTZcxjinRiDFQQbHzVPIWSX8NZN5qnGrZyLf6eDs+zZ6sA+A 76 | PQDcgzNEYYn4gFSP8JiC0HuV8VYh5/xgdRvFAz96Rmqalc2Btt/j+KzoEUIrlbuo 77 | vnTjYVilwK8CoJQT8qtqLOP54G57vqtEn8HjDRi9nLgwW8n7I0jrC/o3Rso+0s9a 78 | L9qfNeV9pJvVP4Gb3nvWcSBzvgcomeOxbWzAHjcXeagO/uLMOVViPXpjUw8969aV 79 | UiBHotkwccWasRbPWAgctT/j+g== 80 | -----END CERTIFICATE----- 81 | -------------------------------------------------------------------------------- /conf/ci-minimal/vars.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /conf/switchiodp.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build2 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build2 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/switchy.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/switchy.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/switchy" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/switchy" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/TODO.txt: -------------------------------------------------------------------------------- 1 | TODO: 2 | - Consider tearing down the last N calls when limit is descreased by N? 3 | - Move all fs looking logging into this package? 4 | - consider adding a 'register_observer/poller' interface where objects can 5 | ask to only receive events they care about from the listener? 6 | (listener might return an itr of callables for each consumer request?) 7 | - registration of clients for events associated with their id (i.e. 8 | events not pertaining to that id should trigger invocation of that 9 | client's callbacks) 10 | - connections collection so that any registered connections will be 11 | reconnected on server restart 12 | - register handlers/callbacks using decorator mechanism 13 | 14 | Useful FreeSWITCH debugging commands: 15 | 16 | /events plain CHANNEL_CREATE CHANNEL_ORIGINATE CHANNEL_HANGUP CHANNEL_ANSWER 17 | SERVER_DISCONNECTED SOCKET_DATA BACKGROUND_JOB 18 | 19 | uuid_dump 20 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | .. toctree:: 2 | :maxdepth: 1 3 | :hidden: 4 | 5 | api/connection 6 | api/observe 7 | api/models 8 | api/distribute 9 | api/sync 10 | api/apps 11 | api/commands 12 | api/apps 13 | api/utils 14 | 15 | 16 | API Reference 17 | ============= 18 | .. note:: 19 | This reference is not entirely comprehensive and is expected to change. 20 | 21 | 22 | Connection wrapper 23 | ------------------ 24 | A thread safe (plus more) wrapper around the ESL SWIG module's 25 | `ESLConnection` type is found in 26 | :doc:`connection.py `. 27 | 28 | 29 | Observer components 30 | ------------------- 31 | The core API :py:class:`~switchio.api.Client` 32 | interface can be found in :doc:`api.py `. 33 | There are also some synchronous helpers hidden within. 34 | 35 | 36 | Call Control Apps 37 | ----------------- 38 | All the :doc:`built in apps ` can be found in the 39 | :py:mod:`switchio.apps` subpackage. 40 | 41 | 42 | .. _modelapi: 43 | 44 | Model types 45 | ----------- 46 | | The :doc:`api/models` api holds automated wrappers for interacting with different 47 | *FreeSWITCH* channel and session objects as if they were local 48 | instances. 49 | 50 | * :py:class:`~switchio.models.Session` - represents a *FreeSWITCH* 51 | `session` entity and provides a rich method api for control using 52 | `call management commands`_. 53 | * :py:class:`~switchio.models.Job` - provides a synchronous interface for 54 | background job handling. 55 | 56 | .. _call management commands: 57 | https://freeswitch.org/confluence/display/FREESWITCH/mod_commands#mod_commands-CallManagementCommands 58 | 59 | 60 | .. _clustertools: 61 | 62 | Cluster tooling 63 | --------------- 64 | Extra helpers for managing a *FreeSWITCH* process cluster. 65 | 66 | * :py:class:`~switchio.distribute.MultiEval` - Invoke arbitrary python 67 | expressions on a collection of objects. 68 | * :py:class:`~switchio.distribute.SlavePool` - a subclass which adds 69 | oberver component helper methods. 70 | -------------------------------------------------------------------------------- /docs/api/apps.rst: -------------------------------------------------------------------------------- 1 | Built-in Apps 2 | ------------- 3 | .. automodule:: switchio.apps 4 | :members: 5 | 6 | Load testing 7 | ============ 8 | .. automodule:: switchio.apps.call_gen 9 | :members: 10 | 11 | Measurement Collection 12 | ====================== 13 | .. automodule:: switchio.apps.measure.cdr 14 | :members: 15 | 16 | .. automodule:: switchio.apps.measure.sys 17 | :members: 18 | 19 | Media testing 20 | ============= 21 | .. automodule:: switchio.apps.players 22 | :members: 23 | 24 | .. automodule:: switchio.apps.dtmf 25 | :members: 26 | 27 | .. automodule:: switchio.apps.bert 28 | :members: 29 | -------------------------------------------------------------------------------- /docs/api/commands.rst: -------------------------------------------------------------------------------- 1 | Command Builders 2 | ---------------- 3 | .. automodule:: switchio.commands 4 | :members: 5 | -------------------------------------------------------------------------------- /docs/api/connection.rst: -------------------------------------------------------------------------------- 1 | Connection wrappers 2 | ------------------- 3 | .. automodule:: switchio.connection 4 | :members: 5 | -------------------------------------------------------------------------------- /docs/api/distribute.rst: -------------------------------------------------------------------------------- 1 | Distributed cluster tools 2 | ------------------------- 3 | .. automodule:: switchio.distribute 4 | :members: 5 | -------------------------------------------------------------------------------- /docs/api/models.rst: -------------------------------------------------------------------------------- 1 | Models 2 | ------ 3 | .. automodule:: switchio.models 4 | :members: 5 | -------------------------------------------------------------------------------- /docs/api/observe.rst: -------------------------------------------------------------------------------- 1 | API components 2 | -------------- 3 | .. automodule:: switchio.api 4 | :members: 5 | -------------------------------------------------------------------------------- /docs/api/sync.rst: -------------------------------------------------------------------------------- 1 | Synchronous Calling 2 | =================== 3 | .. automodule:: switchio.sync 4 | :members: 5 | -------------------------------------------------------------------------------- /docs/api/utils.rst: -------------------------------------------------------------------------------- 1 | Utils 2 | ----- 3 | .. automodule:: switchio.utils 4 | :members: 5 | -------------------------------------------------------------------------------- /docs/apps.rst: -------------------------------------------------------------------------------- 1 | .. toctree:: 2 | :maxdepth: 2 3 | :hidden: 4 | 5 | api 6 | 7 | 8 | Call Applications 9 | ================= 10 | *switchio* supports writing and composing call control apps written in 11 | pure Python. An *app* is simply a `namespace`_ which defines **a set of event 12 | processing (async) functions** [#]_. 13 | 14 | Apps are somewhat analogous to `extensions`_ in *FreeSWITCH*'s 15 | `XML dialplan`_ interface and can similarly be activated using any 16 | `event header`_ *or* `channel variable`_ value of your choosing. 17 | 18 | *Apps* can be implemented each as a standalone Python `namespace`_ which can 19 | hold state and be mutated at runtime. This allows for all sorts of dynamic call 20 | processing logic. *Apps* can also be shared across a *FreeSWITCH* process cluster 21 | allowing for centralized call processing overtop a scalable multi-process service system. 22 | Processing functions are implemented either as regular or async (i.e. coroutine) function 23 | callbacks and are selected to be invoked depending on the recieved `event type`_. 24 | 25 | Applications are :ref:`loaded ` either directly using the low level 26 | :py:class:`~switchio.api.Client` or, in the case of a *switchio* cluster 27 | :doc:`Service `, using a :py:class:`~switchio.apps.AppManager`. 28 | 29 | API 30 | --- 31 | Apps are usually implemented as plain old Python `classes`_ which contain 32 | methods decorated using the :py:mod:`switchio.marks` module. 33 | 34 | Currently the marks supported would be one of:: 35 | 36 | @coroutine("EVENT_NAME") # session oriented async function 37 | @callback("EVENT_NAME") # session oriented callback function 38 | @handler("EVENT_NAME") # low level event oriented callback function 39 | 40 | Where `EVENT_NAME` is any of the strings supported by the ESL `event type`_ 41 | list. 42 | 43 | Additionally, app types can support a :py:func:`prepost` callable which serves 44 | as a setup/teardown fixture mechanism for the app to do pre/post app loading 45 | execution. It can be either of a function or single-yield generator. 46 | 47 | .. note:: 48 | For examples using :py:func:`prepost` see the extensive set of built-in 49 | apps under :py:mod:`switchio.apps`. 50 | 51 | 52 | Event Callbacks and Coroutines 53 | ****************************** 54 | Session oriented *event processors* are methods which typically receive a 55 | type from :py:mod:`switchio.models` as their first (and only) argument. This 56 | type is most often a :py:class:`~switchio.models.Session`. 57 | 58 | .. note:: 59 | Technically the method will receive whatever is returned as the 2nd 60 | value from the preceeding event `handler` looked up in the event 61 | processing loop, but this is an implementation detail and may change 62 | in the future. 63 | 64 | Here is a simple callback which counts the number of answered sessions in 65 | a global:: 66 | 67 | import switchio 68 | 69 | num_calls = 0 70 | 71 | @switchio.callback('CHANNEL_ANSWER') 72 | def counter(session): 73 | global num_calls 74 | num_calls += 1 75 | 76 | .. note:: 77 | This is meant to be a simple example and not actually 78 | implemented for practical use. 79 | :py:meth:`switchio.handlers.EventListener.count_calls` exists 80 | for this very purpose. 81 | 82 | 83 | Event Handlers 84 | ************** 85 | An event handler is any callable marked by :py:meth:`handler` which 86 | is expected to handle an ESL *event* packet and process it within the 87 | :py:class:`~switchio.handlers.EventListener` event loop. 88 | It's function signature should expect a single argument - the received 89 | event packaged in a ``dict`` . 90 | 91 | Example handlers can be found in the :py:class:`~switchio.handlers.EventListener` 92 | such as the default `CHANNEL_ANSWER` handler 93 | 94 | .. literalinclude:: ../switchio/handlers.py 95 | :pyobject: EventListener._handle_answer 96 | 97 | As you can see a knowledge of the underlying `ESL event list`_ 98 | usually is required for `handler` implementations. 99 | 100 | 101 | Examples 102 | -------- 103 | .. _toneplayapp: 104 | 105 | TonePlay 106 | ******** 107 | As a first example here is the :py:class:`~switchio.apps.players.TonePlay` 108 | app which is provided as a built-in for ``switchio`` 109 | 110 | .. literalinclude:: ../switchio/apps/players.py 111 | :pyobject: TonePlay 112 | 113 | 114 | :py:class:`Clients ` who load this app will originate 115 | calls wherein a simple tone is played infinitely and echoed back to 116 | the caller until each call is hung up. 117 | 118 | .. _proxyapp: 119 | 120 | Proxier 121 | ******* 122 | An example of the :ref:`proxy dialplan ` can be 123 | implemented quite trivially:: 124 | 125 | import switchio 126 | 127 | class Proxier(object): 128 | @switchio.coroutine('CHANNEL_PARK') 129 | async def on_park(self, sess): 130 | if sess.is_inbound(): 131 | sess.bridge(dest_url="${sip_req_user}@${sip_req_host}:${sip_req_port}") 132 | await sess.recv("CHANNEL_ANSWER") 133 | 134 | .. _cdrapp: 135 | 136 | CDR 137 | *** 138 | The measurement application used by the 139 | :py:class:`~switchio.apps.call_gen.Originator` to gather stress testing 140 | performance metrics from call detail records: 141 | 142 | .. literalinclude:: ../switchio/apps/measure/cdr.py 143 | :pyobject: CDR 144 | 145 | It simply inserts the call record data on hangup once for each *call*. 146 | 147 | PlayRec 148 | ******* 149 | This more involved application demonstrates *FreeSWITCH*'s ability to play 150 | and record rtp streams locally which can be used in tandem with MOS to do 151 | audio quality checking: 152 | 153 | .. literalinclude:: ../switchio/apps/players.py 154 | :pyobject: PlayRec 155 | 156 | For further examples check out the :py:mod:`~switchio.apps` 157 | sub-package which also includes the very notorious 158 | :py:class:`switchio.apps.call_gen.Originator`. 159 | 160 | .. [#] Although this may change in the future with the introduction of native 161 | `asyncio`_ coroutines in Python 3.5. 162 | 163 | .. hyperlinks 164 | .. _extensions: 165 | https://freeswitch.org/confluence/display/FREESWITCH/XML+Dialplan#XMLDialplan-Extensions 166 | .. _channel variable: 167 | https://freeswitch.org/confluence/display/FREESWITCH/Channel+Variables 168 | .. _event header: 169 | https://freeswitch.org/confluence/display/FREESWITCH/Event+List#EventList-Eventfields 170 | .. _event type: 171 | https://freeswitch.org/confluence/display/FREESWITCH/Event+List 172 | .. _XML dialplan: 173 | https://freeswitch.org/confluence/display/FREESWITCH/XML+Dialplan 174 | .. _namespace: 175 | https://docs.python.org/3/tutorial/classes.html#python-scopes-and-namespaces 176 | .. _ESL: 177 | https://freeswitch.org/confluence/display/FREESWITCH/Event+Socket+Library 178 | .. _classes: 179 | https://docs.python.org/3/tutorial/classes.html#a-first-look-at-classes 180 | .. _ESL event list: 181 | https://freeswitch.org/confluence/display/FREESWITCH/Event+List 182 | .. _asyncio: 183 | https://docs.python.org/3/library/asyncio.html 184 | -------------------------------------------------------------------------------- /docs/cmdline.rst: -------------------------------------------------------------------------------- 1 | .. _cli_client: 2 | 3 | Command line client 4 | =================== 5 | ``switchio`` provides a convenient cli to initiate auto-dialers and call control services with 6 | the help of click_. The program is installed as binary ``switchio``:: 7 | 8 | $ switchio 9 | Usage: switchio [OPTIONS] COMMAND [ARGS]... 10 | 11 | Options: 12 | --help Show this message and exit. 13 | 14 | Commands: 15 | list-apps 16 | plot 17 | dial 18 | serve 19 | 20 | A few sub-commands are provided. 21 | 22 | Listing apps 23 | ------------ 24 | For example you can list the applications available (:doc:`apps` determine call flows):: 25 | 26 | $ switchio list-apps 27 | Collected 5 built-in apps from 7 modules: 28 | 29 | switchio.apps.bert: 30 | 31 | `Bert`: Call application which runs the bert test application on both legs of a call 32 | 33 | See the docs for `mod_bert`_ and discussion by the author `here`_. 34 | 35 | .. _mod_bert: 36 | https://freeswitch.org/confluence/display/FREESWITCH/mod_bert 37 | .. _here: 38 | https://github.com/moises-silva/freeswitch/issues/1 39 | 40 | switchio.apps.players: 41 | 42 | `TonePlay`: Play a 'milli-watt' tone on the outbound leg and echo it back on the inbound 43 | 44 | `PlayRec`: Play a recording to the callee and record it onto the local file system 45 | 46 | This app can be used in tandem with MOS scoring to verify audio quality. 47 | The filename provided must exist in the FreeSWITCH sounds directory such that 48 | ${FS_CONFIG_ROOT}/${sound_prefix}// points to a valid wave file. 49 | 50 | switchio.apps.dtmf: 51 | 52 | `DtmfChecker`: Play dtmf tones as defined by the iterable attr `sequence` with tone `duration`. 53 | Verify the rx sequence matches what was transmitted. For each session which is answered start 54 | a sequence check. For any session that fails digit matching store it locally in the `failed` attribute. 55 | 56 | switchio.apps.routers: 57 | 58 | `Bridger`: Bridge sessions within a call an arbitrary number of times. 59 | 60 | 61 | Spawning the auto-dialer 62 | ------------------------ 63 | The applications listed can be used with the `app` option to the `dial` sub-command. 64 | `dial` is the main sub-command used to start a load test. Here is the help:: 65 | 66 | $ switchio dial --help 67 | Usage: switchio dial [OPTIONS] HOSTS... 68 | 69 | Options: 70 | --proxy TEXT Hostname or IP address of the proxy device 71 | (this is usually the device you are testing) 72 | [required] 73 | --profile TEXT Profile to use for outbound calls in the 74 | load slaves 75 | --rate TEXT Call rate 76 | --limit TEXT Maximum number of concurrent calls 77 | --max-offered TEXT Maximum number of calls to place before 78 | stopping the program 79 | --duration TEXT Duration of calls in seconds 80 | --interactive / --non-interactive 81 | Whether to jump into an interactive session 82 | after setting up the call originator 83 | --debug / --no-debug Whether to enable debugging 84 | --app TEXT ``switchio`` application to execute (see list- 85 | apps command to list available apps) 86 | --metrics-file TEXT Store metrics at the given file location 87 | --help Show this message and exit. 88 | 89 | 90 | The `HOSTS` argument can be one or more IP's or hostnames for each configured FreeSWITCH process 91 | used to originate traffic. The `proxy` option is required and must be the hostname of the first hop; 92 | all hosts will direct traffic to this proxy. 93 | 94 | The other options are not strictly required but typically you will want to at least specify a given call rate 95 | using the `rate` option, max number of concurrent calls (erlangs) with `limit` and possibly max number of 96 | calls offered with `max-offered`. 97 | 98 | For example, to start a test using an slave located at `1.1.1.1` to test device at `2.2.2.2` with a maximum of 99 | `2000` calls at `30` calls per second and stopping after placing `100,000` calls you can do:: 100 | 101 | $ switchio dial 1.1.1.1 --profile external --proxy 2.2.2.2 --rate 30 --limit 2000 --max-offered 100000 102 | 103 | Slave 1.1.1.1 SIP address is at 1.1.1.1:5080 104 | Starting load test for server 2.2.2.2 at 30cps using 1 slaves 105 | ... 106 | 107 | Note that the `profile` option is also important and the profile must already exist. 108 | 109 | In this case the call duration would be automatically calculated to sustain that call 110 | rate and that max calls exactly, but you can tweak the call duration in seconds using 111 | the `duration` option. 112 | 113 | Additionally you can use the `metrics-file` option to store call metrics in a file. 114 | You can then use the `plot` sub-command to generate graphs of the collected data using 115 | `matplotlib` if installed. 116 | 117 | Launching a cluster routing service 118 | ----------------------------------- 119 | You can also launch cluster controllers using ``switchio serve``. 120 | See :ref:`services` for more details. 121 | 122 | .. _click: http://click.pocoo.org/5/ 123 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # switchio documentation build configuration file, created by 4 | # sphinx-quickstart2 on Tue Feb 17 13:32:45 2015. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | # work around ESL.so and optional numpy dependencies 19 | import mock 20 | MOCK_MODS = ['ESL', 'pandas', 'numpy'] 21 | for modname in MOCK_MODS: 22 | sys.modules[modname] = mock.Mock() 23 | 24 | # If extensions (or modules to document with autodoc) are in another directory, 25 | # add these directories to sys.path here. If the directory is relative to the 26 | # documentation root, use os.path.abspath to make it absolute, like shown here. 27 | # sys.path.insert(0, os.path.abspath('.')) 28 | 29 | # -- General configuration ------------------------------------------------ 30 | 31 | # If your documentation needs a minimal Sphinx version, state it here. 32 | #needs_sphinx = '1.0' 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be 35 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 36 | # ones. 37 | extensions = [ 38 | 'sphinx.ext.autodoc', 39 | 'sphinx.ext.intersphinx', 40 | 'sphinx.ext.todo', 41 | ] 42 | 43 | # Add any paths that contain templates here, relative to this directory. 44 | templates_path = ['_templates'] 45 | 46 | # The suffix of source filenames. 47 | source_suffix = '.rst' 48 | 49 | # The encoding of source files. 50 | #source_encoding = 'utf-8-sig' 51 | 52 | # The master toctree document. 53 | master_doc = 'index' 54 | 55 | # General information about the project. 56 | project = u'switchio' 57 | copyright = u'2015, Sangoma Technologies' 58 | 59 | # The version info for the project you're documenting, acts as replacement for 60 | # |version| and |release|, also used in various other places throughout the 61 | # built documents. 62 | # 63 | # The short X.Y version. 64 | version = '0.1' 65 | # The full version, including alpha/beta/rc tags. 66 | release = '0.1.alpha' 67 | 68 | # The language for content autogenerated by Sphinx. Refer to documentation 69 | # for a list of supported languages. 70 | #language = None 71 | 72 | # There are two options for replacing |today|: either, you set today to some 73 | # non-false value, then it is used: 74 | #today = '' 75 | # Else, today_fmt is used as the format for a strftime call. 76 | #today_fmt = '%B %d, %Y' 77 | 78 | # List of patterns, relative to source directory, that match files and 79 | # directories to ignore when looking for source files. 80 | exclude_patterns = ['_build'] 81 | 82 | # The reST default role (used for this markup: `text`) to use for all 83 | # documents. 84 | #default_role = None 85 | 86 | # If true, '()' will be appended to :func: etc. cross-reference text. 87 | #add_function_parentheses = True 88 | 89 | # If true, the current module name will be prepended to all description 90 | # unit titles (such as .. function::). 91 | #add_module_names = True 92 | 93 | # If true, sectionauthor and moduleauthor directives will be shown in the 94 | # output. They are ignored by default. 95 | #show_authors = False 96 | 97 | # The name of the Pygments (syntax highlighting) style to use. 98 | pygments_style = 'sphinx' 99 | 100 | # A list of ignored prefixes for module index sorting. 101 | #modindex_common_prefix = [] 102 | 103 | # If true, keep warnings as "system message" paragraphs in the built documents. 104 | #keep_warnings = False 105 | 106 | 107 | # -- Options for HTML output ---------------------------------------------- 108 | 109 | # The theme to use for HTML and HTML Help pages. See the documentation for 110 | # a list of builtin themes. 111 | html_theme = 'alabaster' 112 | 113 | # Theme options are theme-specific and customize the look and feel of a theme 114 | # further. For a list of options available for each theme, see the 115 | # documentation. 116 | html_theme_options = { 117 | 'github_user': 'friends-of-freeswitch', 118 | 'github_repo': 'switchio', 119 | 'github_button': 'true', 120 | 'github_banner': 'true', 121 | 'page_width': '1080px', 122 | 'fixed_sidebar': 'true', 123 | } 124 | 125 | # Add any paths that contain custom themes here, relative to this directory. 126 | #html_theme_path = [] 127 | 128 | # Add any paths that contain custom themes here, relative to this directory. 129 | #html_theme_path = [] 130 | 131 | # The name for this set of Sphinx documents. If None, it defaults to 132 | # " v documentation". 133 | #html_title = None 134 | 135 | # A shorter title for the navigation bar. Default is the same as html_title. 136 | #html_short_title = None 137 | 138 | # The name of an image file (relative to this directory) to place at the top 139 | # of the sidebar. 140 | #html_logo = None 141 | 142 | # The name of an image file (within the static path) to use as favicon of the 143 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 144 | # pixels large. 145 | #html_favicon = None 146 | 147 | # Add any paths that contain custom static files (such as style sheets) here, 148 | # relative to this directory. They are copied after the builtin static files, 149 | # so a file named "default.css" will overwrite the builtin "default.css". 150 | 151 | # Changed to prevent readthedocs from looking inside the empty _static folder. 152 | # https://github.com/rtfd/readthedocs.org/issues/1776#issuecomment-149684640 153 | html_static_path = [] # ['_static'] 154 | 155 | # Add any extra paths that contain custom files (such as robots.txt or 156 | # .htaccess) here, relative to this directory. These files are copied 157 | # directly to the root of the documentation. 158 | #html_extra_path = [] 159 | 160 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 161 | # using the given strftime format. 162 | #html_last_updated_fmt = '%b %d, %Y' 163 | 164 | # If true, SmartyPants will be used to convert quotes and dashes to 165 | # typographically correct entities. 166 | #html_use_smartypants = True 167 | 168 | # Custom sidebar templates, maps document names to template names. 169 | #html_sidebars = {} 170 | 171 | # Additional templates that should be rendered to pages, maps page names to 172 | # template names. 173 | #html_additional_pages = {} 174 | 175 | # If false, no module index is generated. 176 | #html_domain_indices = True 177 | 178 | # If false, no index is generated. 179 | #html_use_index = True 180 | 181 | # If true, the index is split into individual pages for each letter. 182 | #html_split_index = False 183 | 184 | # If true, links to the reST sources are added to the pages. 185 | #html_show_sourcelink = True 186 | 187 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 188 | #html_show_sphinx = True 189 | 190 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 191 | #html_show_copyright = True 192 | 193 | # If true, an OpenSearch description file will be output, and all pages will 194 | # contain a tag referring to it. The value of this option must be the 195 | # base URL from which the finished HTML is served. 196 | #html_use_opensearch = '' 197 | 198 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 199 | #html_file_suffix = None 200 | 201 | # Output file base name for HTML help builder. 202 | htmlhelp_basename = 'switchiodoc' 203 | 204 | 205 | # -- Options for LaTeX output --------------------------------------------- 206 | 207 | latex_elements = { 208 | # The paper size ('letterpaper' or 'a4paper'). 209 | #'papersize': 'letterpaper', 210 | 211 | # The font size ('10pt', '11pt' or '12pt'). 212 | #'pointsize': '10pt', 213 | 214 | # Additional stuff for the LaTeX preamble. 215 | #'preamble': '', 216 | } 217 | 218 | # Grouping the document tree into LaTeX files. List of tuples 219 | # (source start file, target name, title, 220 | # author, documentclass [howto, manual, or own class]). 221 | latex_documents = [ 222 | ('index', 'switchio.tex', u'switchio Documentation', 223 | u'Tyler Goodlet', 'manual'), 224 | ] 225 | 226 | # The name of an image file (relative to this directory) to place at the top of 227 | # the title page. 228 | #latex_logo = None 229 | 230 | # For "manual" documents, if this is true, then toplevel headings are parts, 231 | # not chapters. 232 | #latex_use_parts = False 233 | 234 | # If true, show page references after internal links. 235 | #latex_show_pagerefs = False 236 | 237 | # If true, show URL addresses after external links. 238 | #latex_show_urls = False 239 | 240 | # Documents to append as an appendix to all manuals. 241 | #latex_appendices = [] 242 | 243 | # If false, no module index is generated. 244 | #latex_domain_indices = True 245 | 246 | 247 | # -- Options for manual page output --------------------------------------- 248 | 249 | # One entry per manual page. List of tuples 250 | # (source start file, name, description, authors, manual section). 251 | man_pages = [ 252 | ('index', 'switchio', u'switchio Documentation', 253 | [u'Tyler Goodlet'], 1) 254 | ] 255 | 256 | # If true, show URL addresses after external links. 257 | #man_show_urls = False 258 | 259 | 260 | # -- Options for Texinfo output ------------------------------------------- 261 | 262 | # Grouping the document tree into Texinfo files. List of tuples 263 | # (source start file, target name, title, author, 264 | # dir menu entry, description, category) 265 | texinfo_documents = [ 266 | ('index', 'switchio', u'switchio Documentation', 267 | u'Tyler Goodlet', 'switchio', 'One line description of project.', 268 | 'Miscellaneous'), 269 | ] 270 | 271 | # Documents to append as an appendix to all manuals. 272 | #texinfo_appendices = [] 273 | 274 | # If false, no module index is generated. 275 | #texinfo_domain_indices = True 276 | 277 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 278 | #texinfo_show_urls = 'footnote' 279 | 280 | # If true, do not generate a @detailmenu in the "Top" node's menu. 281 | #texinfo_no_detailmenu = False 282 | 283 | 284 | # Example configuration for intersphinx: refer to the Python standard library. 285 | intersphinx_mapping = { 286 | 'python': ('http://docs.python.org/', None), 287 | 'numpy': ('http://docs.scipy.org/doc/numpy/', None), 288 | 'pytest': ('http://pytest.org/latest', None), 289 | } 290 | -------------------------------------------------------------------------------- /docs/fsconfig.rst: -------------------------------------------------------------------------------- 1 | .. _fsconfig: 2 | 3 | *FreeSWITCH* configuration and deployment 4 | ----------------------------------------- 5 | *switchio* relies on some basic *FreeSWITCH* configuration steps in order to enable 6 | remote control via the `ESL inbound method`_. 7 | Most importantly, the ESL configuration file must be modified to listen 8 | on a known socket of choice and a *park-only* extension must be added to 9 | *FreeSWITCH*'s `XML dialplan`_. *switchio* comes packaged with an example 10 | :ref:`park only dialplan ` which you can copy-paste into your 11 | existing server(s). 12 | 13 | 14 | Event Socket 15 | ++++++++++++ 16 | In order for *switchio* to talk to *FreeSWITCH* you must `enable ESL`_ to listen on all 17 | IP addrs at port `8021`. This can configured by simply making the following change to 18 | the ``${FS_CONF_ROOT}/conf/autoload_configs/event_socket.conf.xml`` configuration file:: 19 | 20 | -- 21 | ++ 22 | 23 | Depending on your FS version, additional `acl configuration`_ may be required. 24 | 25 | 26 | .. _parkonly: 27 | 28 | Park only dialplan 29 | ++++++++++++++++++ 30 | An XML dialplan `extension`_ which places all *inbound* sessions into the 31 | `park`_ state should be added to all target *FreeSWITCH* servers you wish to control with 32 | *switchio*. An example `context`_ (``switchiodp.xml``) is included in the `conf`_ directory 33 | of the source code. If using this file you can enable *switchio* to control all calls 34 | received by a particular *FreeSWITCH* `SIP profile`_ by setting the ``"switchio"`` context. 35 | 36 | As an example you can modify *FreeSWITCH*'s default `external`_ profile found 37 | at ``${FS_CONF_ROOT}/conf/sip_profiles/external.xml``:: 38 | 39 | 40 | -- 41 | ++ 42 | 43 | .. note:: 44 | You can also add a park extension to your existing dialplan such that 45 | only a subset of calls relinquish control to *switchio* (especially 46 | useful if you'd like to test on an extant production system). 47 | 48 | 49 | Configuring software under test 50 | +++++++++++++++++++++++++++++++ 51 | For (stress) testing, the system under test should be configured to route calls back 52 | to the originating *FreeSWITCH* (cluster) such that the originator hosts both the 53 | *caller* and *callee* user agents (potentially using the same `SIP profile`_):: 54 | 55 | FreeSWITCH cluster Target test network or 56 | device 57 | 58 | -------------- outbound sessions --------------------- 59 | | Originator | --------------------> | Device under test | 60 | | | <-------------------- | (in loopback) | 61 | -------------- inbound sessions --------------------- 62 | 63 | 64 | This allows *switchio* to perform *call tracking* (associate *outbound* with *inbound* 65 | SIP sessions) and thus assume full control of call flow as well as measure signalling 66 | latency and other teletraffic metrics. 67 | 68 | 69 | .. _proxydp: 70 | 71 | Example *proxy* dialplan 72 | ======================== 73 | If your system to test is simply another *FreeSWITCH* instance then it is 74 | highly recommended to use a *"proxy"* dialplan to route SIP sessions back 75 | to the originator (caller):: 76 | 77 | 78 | 79 | 80 | 81 | 82 | .. note:: 83 | This could have alternatively be implemented as a *switchio* :ref:`app `. 84 | 85 | 86 | Configuring FreeSWITCH for stress testing 87 | ========================================= 88 | Before attempting to stress test *FreeSWITCH* itself be sure you've read the 89 | `performance`_ and `dialplans`_ sections of the wiki. 90 | 91 | You'll typically want to raise the `max-sessions` and `sessions-per-second` 92 | parameters in `autoload_configs/switch.conf.xml`:: 93 | 94 | 95 | 96 | 97 | 98 | This prevents *FreeSWITCH* from rejecting calls at high loads. However, if your intention 99 | is to see how *FreeSWITCH* behaves at those parameters limits, you can always set values 100 | that suit those purposes. 101 | 102 | In order to reduce load due to logging it's recommended you reduce your core logging level. 103 | This is also done in `autoload_configs/switch.conf.xml`:: 104 | 105 | 106 | 107 | 108 | You will also probably want to `raise the file descriptor count`_. 109 | 110 | .. note:: 111 | You have to run `ulimit` in the same shell where you start a *FreeSWITCH* 112 | process. 113 | 114 | 115 | .. _ESL inbound method: 116 | https://freeswitch.org/confluence/display/FREESWITCH/mod_event_socket#mod_event_socket-Inbound 117 | .. _XML dialplan: 118 | https://freeswitch.org/confluence/display/FREESWITCH/XML+Dialplan 119 | .. _extension: 120 | https://freeswitch.org/confluence/display/FREESWITCH/XML+Dialplan#XMLDialplan-Extensions 121 | .. _context: 122 | https://freeswitch.org/confluence/display/FREESWITCH/XML+Dialplan#XMLDialplan-Context 123 | .. _park: 124 | https://freeswitch.org/confluence/display/FREESWITCH/mod_dptools:+park 125 | .. _SIP profile: 126 | https://freeswitch.org/confluence/display/FREESWITCH/Configuring+FreeSWITCH#ConfiguringFreeSWITCH-SIPProfiles 127 | .. _dialplans: 128 | https://freeswitch.org/confluence/display/FREESWITCH/Configuring+FreeSWITCH#ConfiguringFreeSWITCH-Dialplan 129 | .. _performance: 130 | https://freeswitch.org/confluence/display/FREESWITCH/Performance+Testing+and+Configurations 131 | .. _conf: 132 | https://github.com/friends-of-freeswitch/switchio/tree/master/conf 133 | .. _external: 134 | https://freeswitch.org/confluence/display/FREESWITCH/Configuring+FreeSWITCH#ConfiguringFreeSWITCH-External 135 | .. _enable ESL: 136 | https://freeswitch.org/confluence/display/FREESWITCH/mod_event_socket#mod_event_socket-Configuration 137 | .. _acl configuration: 138 | https://freeswitch.org/confluence/display/FREESWITCH/mod_event_socket#mod_event_socket-ACL 139 | .. _raise the file descriptor count: 140 | https://freeswitch.org/confluence/display/FREESWITCH/Performance+Testing+and+Configurations#PerformanceTestingandConfigurations-RecommendedULIMITsettings 141 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | switchio 2 | ======== 3 | ``asyncio`` powered `FreeSWITCH`_ cluster control purpose-built on 4 | `traffic theory`_ and `stress testing`_. 5 | 6 | ``switchio`` is a *fast* asynchronous control system for managing *FreeSWITCH* clusters. 7 | It uses the *FreeSWITCH* ESL `inbound`_ protocol and was originally built for generating 8 | traffic to stress test telephony service systems. 9 | 10 | 11 | Installation 12 | ------------ 13 | :: 14 | pip install switchio 15 | 16 | Features 17 | -------- 18 | 19 | - drive multiple *FreeSWITCH* processes (a cluster) from a single Python program 20 | - build dialplan systems using a :ref:`flask-like` API and native `coroutines`_ 21 | - create cluster controllers using ``switchio`` :doc:`services ` 22 | - generate traffic using the built-in :ref:`auto-dialer ` 23 | - record, display and export CDR and performance metrics captured during stress tests 24 | - use the internal ``asyncio`` inbound ESL `protocol`_ for lower level control 25 | 26 | *FreeSWITCH* Configuration 27 | ************************** 28 | ``switchio`` relies on some simple :ref:`deployment ` steps for 29 | import-and-go usage. 30 | 31 | 32 | .. hyperlinks 33 | .. _inbound: 34 | https://freeswitch.org/confluence/display/FREESWITCH/mod_event_socket#mod_event_socket-Inbound 35 | .. _FreeSWITCH: 36 | https://freeswitch.org/confluence/display/FREESWITCH 37 | .. _stress testing: 38 | https://en.wikipedia.org/wiki/Stress_testing 39 | .. _traffic theory: 40 | https://en.wikipedia.org/wiki/Teletraffic_engineering 41 | .. _protocol: 42 | https://github.com/friends-of-freeswitch/switchio/blob/master/switchio/protocol.py 43 | .. _coroutines: 44 | https://docs.python.org/3/library/asyncio-task.html 45 | .. _pandas: 46 | http://pandas.pydata.org/ 47 | .. _matplotlib: 48 | http://matplotlib.org/ 49 | 50 | 51 | User Guide 52 | ---------- 53 | .. toctree:: 54 | :maxdepth: 1 55 | 56 | fsconfig 57 | quickstart 58 | services 59 | callgen 60 | apps 61 | cmdline 62 | sessionapi 63 | usage 64 | api 65 | testing 66 | 67 | 68 | .. Indices and tables 69 | ================== 70 | * :ref:`genindex` 71 | * :ref:`modindex` 72 | * :ref:`search` 73 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | .. toctree:: 2 | :maxdepth: 2 3 | :hidden: 4 | 5 | api 6 | 7 | 8 | Quick-Start - Originating a single call 9 | ======================================= 10 | Assuming you've gone through the required :doc:`deployment steps 11 | ` to setup at least one slave, initiating a call becomes 12 | very simple using the ``switchio`` command line:: 13 | 14 | $ switchio dial vm-host sip-cannon --profile external --proxy myproxy.com --rate 1 --limit 1 --max-offered 1 15 | 16 | ... 17 | 18 | Aug 26 21:59:01 [INFO] switchio cli.py:114 : Slave sip-cannon.qa.sangoma.local SIP address is at 10.10.8.19:5080 19 | Aug 26 21:59:01 [INFO] switchio cli.py:114 : Slave vm-host.qa.sangoma.local SIP address is at 10.10.8.21:5080 20 | Aug 26 21:59:01 [INFO] switchio cli.py:120 : Starting load test for server dut-008.qa.sangoma.local at 1cps using 2 slaves 21 | 22 | 23 | ... 24 | 25 | 26 | Waiting on 1 active calls to finish 27 | Waiting on 1 active calls to finish 28 | Waiting on 1 active calls to finish 29 | Waiting on 1 active calls to finish 30 | Dialing session completed! 31 | 32 | 33 | The ``switchio`` `dial` sub-command takes several options and a list of minion IP addresses 34 | or hostnames. In this example ``switchio`` connected to the specified hosts, found the 35 | requested SIP profile and initiated a single call with a duration of 5 seconds to the 36 | device under test (set with the `proxy` option). 37 | 38 | For more information on the switchio command line see :doc:`here `. 39 | 40 | 41 | Originating a single call programatically from Python 42 | ----------------------------------------------------- 43 | Making a call with switchio is quite simple using the built-in 44 | :py:func:`~switchio.sync.sync_caller` context manager. 45 | Again, if you've gone through the required :doc:`deployment steps 46 | `, initiating a call becomes as simple as a few lines of python 47 | code 48 | 49 | .. code-block:: python 50 | :linenos: 51 | 52 | from switchio import sync_caller 53 | from switchio.apps.players import TonePlay 54 | 55 | # here '192.168.0.10' would be the address of the server running a 56 | # FS process to be used as the call generator 57 | with sync_caller('192.168.0.10', apps={"tone": TonePlay}) as caller: 58 | 59 | # initiates a call to the originating profile on port 5080 using 60 | # the `TonePlay` app and block until answered / the originate job completes 61 | sess, waitfor = caller('Fred@{}:{}'.format(caller.client.host, 5080), "tone") 62 | # let the tone play a bit 63 | time.sleep(5) 64 | # tear down the call 65 | sess.hangup() 66 | 67 | 68 | The most important lines are the `with` statement and line 10. 69 | What happens behind the scenes here is the following: 70 | 71 | * at the `with`, necessary internal ``switchio`` components are instantiated in memory 72 | and connected to a *FreeSWITCH* process listening on the `fsip` ESL ip address. 73 | * at the `caller()`, an :py:meth:`~switchio.api.Client.originate` command is 74 | invoked asynchronously via a :py:meth:`~switchio.api.Client.bgapi` call. 75 | * the background :py:class:`~switchio.models.Job` returned by that command is handled 76 | to completion **synchronously** wherein the call blocks until the originating session has 77 | reached the connected state. 78 | * the corresponding origininating :py:class:`~switchio.models.Session` is returned along with 79 | a reference to a :py:meth:`switchio.handlers.EventListener.waitfor` blocker method. 80 | * the call is kept up for 1 second and then :py:meth:`hungup `. 81 | * internal ``switchio`` components are disconnected from the *FreeSWITCH* process at the close of the 82 | `with` block. 83 | 84 | Note that the `sync_caller` api is not normally used for :doc:`stress testing ` 85 | as it used to initiate calls *synchronously*. It becomes far more useful when using 86 | *FreeSWITCH* for functional testing using your own custom call flow :doc:`apps `. 87 | 88 | 89 | Example source code 90 | ------------------- 91 | Some more extensive examples are found in the unit tests sources : 92 | 93 | .. literalinclude:: ../tests/test_sync_call.py 94 | :caption: test_sync_call.py 95 | :linenos: 96 | 97 | 98 | Run manually 99 | ************ 100 | You can run this code from the unit test directory quite simply:: 101 | 102 | >>> from tests.test_sync_call import test_toneplay 103 | >>> test_toneplay('fs_slave_hostname') 104 | 105 | 106 | Run with pytest 107 | *************** 108 | If you have ``pytest`` installed you can run this test like so:: 109 | 110 | $ py.test --fshost='fs_slave_hostname' tests/test_sync_caller 111 | 112 | 113 | Implementation details 114 | ********************** 115 | The implementation of :py:func:`~switchio.sync.sync_caller` is shown 116 | below and can be referenced alongside the :doc:`usage` to gain a better 117 | understanding of the inner workings of ``switchio``'s api: 118 | 119 | .. literalinclude:: ../switchio/sync.py 120 | :linenos: 121 | -------------------------------------------------------------------------------- /docs/sessionapi.rst: -------------------------------------------------------------------------------- 1 | .. toctree:: 2 | :maxdepth: 2 3 | :hidden: 4 | 5 | api 6 | apps 7 | 8 | Session API 9 | =========== 10 | *switchio* wraps *FreeSWITCH*'s `event header fields`_ and `call management commands`_ 11 | inside the :py:class:`switchio.models.Session` type. 12 | 13 | There is already slew of supported commands and we encourage you to 14 | add any more you might require via a pull request on `github`_. 15 | 16 | Accessing *FreeSWITCH* variables 17 | -------------------------------- 18 | Every ``Session`` instance has access to all it's latest received *event 19 | headers* via standard python ``__getitem__`` access: 20 | 21 | .. code-block:: python 22 | 23 | sess['Caller-Direction'] 24 | 25 | All chronological event data is kept until a ``Session`` is destroyed. 26 | If you'd like to access older state you can use the underlying 27 | :py:class:`~switchio.models.Events` instance: 28 | 29 | .. code-block:: python 30 | 31 | # access the first value of my_var 32 | sess.events[-1]['variable_my_var'] 33 | 34 | Note that there are some distinctions to be made between different types 35 | of `variable access`_ and in particular it would seem that 36 | *FreeSWITCH*'s event headers follow the `info app names`_: 37 | 38 | .. code-block:: python 39 | 40 | # standard headers require no prefix 41 | sess['FreeSWITCH-IPv6'] 42 | sess['Channel-State'] 43 | sess['Unique-ID'] 44 | 45 | # channel variables require a 'variable_' prefix 46 | sess['variable_sip_req_uri'] 47 | sess['variable_sip_contact_user'] 48 | sess['variable_read_codec'] 49 | sess['sip_h_X-switchio_app'] 50 | 51 | 52 | .. _event header fields: 53 | https://freeswitch.org/confluence/display/FREESWITCH/Event+List 54 | .. _call management commands: 55 | https://freeswitch.org/confluence/display/FREESWITCH/mod_commands#mod_commands-CallManagementCommands 56 | .. _github: 57 | https://github.com/friends-of-freeswitch/switchio 58 | .. _variable access: 59 | https://freeswitch.org/confluence/display/FREESWITCH/XML+Dialplan#XMLDialplan-AccessingVariables 60 | .. _info app names: 61 | https://freeswitch.org/confluence/display/FREESWITCH/Channel+Variables#ChannelVariables-InfoApplicationVariableNames(variable_xxxx) 62 | -------------------------------------------------------------------------------- /docs/testing.rst: -------------------------------------------------------------------------------- 1 | Running Unit Tests 2 | ================== 3 | ``switchio``'s unit test set relies on `pytest`_ and `tox`_. Tests require a 4 | *FreeSWITCH* slave process which has been :doc:`deployed ` 5 | with the required baseline config and can be accessed by hostname. 6 | 7 | To run all tests invoke `tox` from the source dir and pass the FS hostname:: 8 | 9 | tox -- --fshost=hostname.fs.com 10 | 11 | `SIPp`_ and `pysipp`_ are required to be installed locally in order to run call/load tests. 12 | 13 | To run multi-slave tests at least two slave hostnames are required:: 14 | 15 | tox -- --fsslaves=fs.slave.hostname1,fs.slave.hostname2 16 | 17 | 18 | .. hyperlinks 19 | .. _pytest: 20 | http://pytest.org 21 | .. _tox: 22 | http://tox.readthedocs.io 23 | .. _SIPp: 24 | https://github.com/SIPp/sipp 25 | .. _pysipp: 26 | https://github.com/SIPp/pysipp 27 | -------------------------------------------------------------------------------- /freeswitch-sounds/soundfiles_present.txt: -------------------------------------------------------------------------------- 1 | freeswitch-sounds-music-8000-1.0.52.tar.gz 2 | freeswitch-sounds-music-16000-1.0.52.tar.gz 3 | freeswitch-sounds-en-us-callie-8000-1.0.51.tar.gz 4 | freeswitch-sounds-en-us-callie-16000-1.0.51.tar.gz 5 | -------------------------------------------------------------------------------- /requirements-doc.txt: -------------------------------------------------------------------------------- 1 | # docs gen without requiring ESL.py 2 | mock 3 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | pytest>=3.4.2 2 | numpy 3 | pdbpp 4 | 5 | # pysipp for call tracking testing 6 | -e git+git://github.com/SIPp/pysipp.git#egg=pysipp 7 | 8 | # pytest-dockerctl for container mgmt 9 | -e git+git://github.com/tgoodlet/pytest-dockerctl.git#egg=pytest-dockerctl 10 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/friends-of-freeswitch/switchio/dee6e9addcf881b2b411ec1dbb397b0acfbb78cf/setup.cfg -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright 2014 Sangoma Technologies Inc. 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | from setuptools import setup 9 | 10 | with open('README.rst', encoding='utf-8') as f: 11 | readme = f.read() 12 | 13 | 14 | setup( 15 | name="switchio", 16 | version='0.1.0.alpha1', 17 | description='asyncio powered FreeSWITCH cluster control', 18 | long_description=readme, 19 | license='Mozilla', 20 | author='Sangoma Technologies', 21 | maintainer='Tyler Goodlet', 22 | maintainer_email='tgoodlet@gmail.com', 23 | url='https://github.com/friends-of-freeswitch/switchio', 24 | platforms=['linux'], 25 | packages=[ 26 | 'switchio', 27 | 'switchio.apps', 28 | 'switchio.apps.measure', 29 | ], 30 | entry_points={ 31 | 'console_scripts': [ 32 | 'switchio = switchio.cli:cli', 33 | ] 34 | }, 35 | python_requires='>=3.6', 36 | install_requires=['click', 'colorlog'], 37 | package_data={ 38 | 'switchio': ['../conf/switchiodp.xml'] 39 | }, 40 | extras_require={ 41 | 'metrics': ['pandas>=0.18'], 42 | 'hdf5': ['tables==3.2.1.1'], 43 | 'graphing': ['matplotlib', 'pandas>=0.18'], 44 | }, 45 | tests_require=['pytest'], 46 | classifiers=[ 47 | 'Development Status :: 3 - Alpha', 48 | 'License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)', 49 | 'Operating System :: POSIX :: Linux', 50 | 'Programming Language :: Python :: 3.6', 51 | 'Programming Language :: Python :: 3.7', 52 | 'Programming Language :: Python :: 3.8', 53 | 'Intended Audience :: Telecommunications Industry', 54 | 'Intended Audience :: Developers', 55 | 'Topic :: Communications :: Telephony', 56 | 'Topic :: Software Development :: Testing :: Traffic Generation', 57 | 'Topic :: System :: Clustering', 58 | 'Environment :: Console', 59 | ], 60 | ) 61 | -------------------------------------------------------------------------------- /switchio/__init__.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | """ 5 | switchio: ``asyncio`` powered FreeSWITCH cluster control 6 | 7 | Licensed under the MPL 2.0 license (see `LICENSE` file) 8 | """ 9 | from os import path 10 | from .utils import get_logger, ESLError 11 | from .api import get_client, Client 12 | from .loop import get_event_loop 13 | from .handlers import get_listener 14 | from .apps.call_gen import get_originator 15 | from .distribute import SlavePool, MultiEval 16 | from .marks import event_callback, callback, handler, coroutine 17 | from .connection import get_connection, ConnectionError 18 | from .sync import sync_caller 19 | from .serve import Service 20 | 21 | __package__ = 'switchio' 22 | __version__ = '0.1.0.alpha1' 23 | __author__ = ('Sangoma Technologies', 'qa@eng.sangoma.com') 24 | __all__ = [ 25 | 'get_logger', 26 | 'ESLError', 27 | 'get_client', 28 | 'get_event_loop', 29 | 'get_listener', 30 | 'get_originator', 31 | 'SlavePool', 32 | 'MultiEval', 33 | 'callback', 34 | 'handler', 35 | 'coroutine', 36 | 'get_connection', 37 | 'ConnectionError', 38 | 'sync_caller', 39 | 'Service', 40 | ] 41 | 42 | PARK_DP = path.join(path.dirname(__file__), '../conf', 'switchiodp.xml') 43 | -------------------------------------------------------------------------------- /switchio/apps/__init__.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | ''' 5 | Built-in applications 6 | ''' 7 | from .. import utils, marks 8 | from collections import OrderedDict 9 | import itertools 10 | import operator 11 | from .measure import Measurers 12 | 13 | # registry 14 | _apps = OrderedDict() 15 | 16 | 17 | def app(*args, **kwargs): 18 | '''Decorator to register switchio application classes. 19 | Example usage: 20 | 21 | .. code-block:: python 22 | 23 | @app 24 | class CoolAppController(object): 25 | pass 26 | 27 | # This will register the class as a switchio app. 28 | # The name of the app defaults to `class.__name__`. 29 | # The help for the app is taken from `class.__doc__`. 30 | 31 | # You can also provide an alternative name via a 32 | # decorator argument: 33 | 34 | @app('CoolName') 35 | class CoolAppController(object): 36 | pass 37 | 38 | # or with a keyword arg: 39 | 40 | @app(name='CoolName') 41 | class CoolAppController(object): 42 | pass 43 | ''' 44 | name = kwargs.get('name') 45 | if len(args) >= 1: 46 | arg0 = args[0] 47 | if type(arg0) is type: 48 | return register(arg0, None) 49 | name = arg0 50 | # if len(args) > 1: 51 | # tail = args[1:] 52 | 53 | def inner(cls): 54 | return register(cls, name=name) 55 | return inner 56 | 57 | 58 | def register(cls, name=None): 59 | """Register an app in the global registry 60 | """ 61 | if not marks.has_callbacks(cls): 62 | raise ValueError( 63 | "{} contains no defined handlers or callbacks?".format(cls) 64 | ) 65 | app = _apps.setdefault(name or cls.__name__, cls) 66 | if cls is not app: 67 | raise ValueError("An app '{}' already exists with name '{}'" 68 | .format(app, name)) 69 | return cls 70 | 71 | 72 | def iterapps(): 73 | """Iterable over all registered apps. 74 | """ 75 | return itertools.chain(_apps.values()) 76 | 77 | 78 | def groupbymod(): 79 | """Return an iterable which delivers tuples (, ) 80 | """ 81 | return itertools.groupby( 82 | _apps.items(), 83 | utils.compose( 84 | operator.attrgetter('__module__'), 85 | operator.itemgetter(1) 86 | ) 87 | ) 88 | 89 | 90 | def get(name): 91 | """Get a registered app by name or None if one isn't registered. 92 | """ 93 | return _apps.get(name) 94 | 95 | 96 | def load(packages=(), imp_excs=('pandas',)): 97 | """Load by importing all built-in apps along with any apps found in the 98 | provided `packages` list. 99 | 100 | :param packages: package (names or actual modules) 101 | :type packages: str | module 102 | :rtype: dict[str, types.ModuleType] 103 | """ 104 | apps_map = {} 105 | # load built-ins + extras 106 | for path, app in utils.iter_import_submods( 107 | (__name__,) + packages, 108 | imp_excs=imp_excs, 109 | ): 110 | if isinstance(app, ImportError): 111 | utils.log_to_stderr().warning("'{}' failed to load - {}\n".format( 112 | path, app.message)) 113 | else: 114 | apps_map[path] = app 115 | return apps_map 116 | 117 | 118 | class AppManager(object): 119 | """Manage apps over a cluster/slavepool. 120 | """ 121 | def __init__(self, pool, ppfuncargs=None, **kwargs): 122 | self.pool = pool 123 | self.ppfuncargs = ppfuncargs or {'pool': self.pool} 124 | self.measurers = Measurers(**kwargs) 125 | 126 | def load_multi_app(self, apps_iter, app_id=None, **kwargs): 127 | """Load a "composed" app (multiple apps using a single app name/id) 128 | by providing an iterable of (app, prepost_kwargs) tuples. Whenever the 129 | app is triggered from and event loop all callbacks from all apps will 130 | be invoked in the order then were loaded here. 131 | """ 132 | for app in apps_iter: 133 | try: 134 | app, ppkwargs = app # user can optionally pass doubles 135 | except TypeError: 136 | ppkwargs = {} 137 | 138 | # load each app under a common id (i.e. rebind with the return val) 139 | app_id = self.load_app(app, app_id=app_id, ppkwargs=ppkwargs, 140 | **kwargs) 141 | 142 | return app_id 143 | 144 | def load_app(self, app, app_id=None, ppkwargs=None, with_measurers=()): 145 | """Load and activate an app for use across all slaves in the cluster. 146 | """ 147 | ppkwargs = ppkwargs or {} 148 | app_id = self.pool.evals( 149 | 'client.load_app(app, on_value=appid, funcargsmap=fargs, **ppkws)', 150 | app=app, appid=app_id, ppkws=ppkwargs, fargs=self.ppfuncargs)[0] 151 | 152 | if self.measurers and with_measurers: 153 | # measurers are loaded in reverse order such that those which were 154 | # added first take the highest precendence in the event loop 155 | # callback chain. see `Measurers.items()` 156 | for name, m in self.measurers.items(): 157 | for client in self.pool.clients: 158 | if name not in client._apps[app_id]: 159 | client.load_app( 160 | m.app, 161 | on_value=app_id, 162 | # use a common storer across app instances 163 | # (since each measurer are keyed by name) 164 | storer=m.storer, 165 | prepend=True, # give measurers highest priority 166 | **m.ppkwargs 167 | ) 168 | return app_id 169 | 170 | def iterapps(self): 171 | """Iterable over all unique contained subapps 172 | """ 173 | return set( 174 | app for app_map in itertools.chain.from_iterable( 175 | self.pool.evals('client._apps.values()') 176 | ) 177 | for app in app_map.values() 178 | ) 179 | -------------------------------------------------------------------------------- /switchio/apps/bert.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | """ 5 | Bert testing 6 | """ 7 | from collections import deque 8 | from ..apps import app 9 | from ..marks import event_callback 10 | from ..utils import get_logger, APIError 11 | 12 | 13 | @app 14 | class Bert(object): 15 | """Call application which runs the bert test application on both 16 | legs of a call 17 | 18 | See the docs for `mod_bert`_ and discussion by the author `here`_. 19 | 20 | .. _mod_bert: 21 | https://freeswitch.org/confluence/display/FREESWITCH/mod_bert 22 | .. _here: 23 | https://github.com/moises-silva/freeswitch/issues/1 24 | """ 25 | bert_sync_lost_var = 'bert_stats_sync_lost' 26 | 27 | def prepost(self, client, listener, ring_response=None, pdd=None, 28 | prd=None, **opts): 29 | self.log = get_logger(self.__class__.__name__) 30 | self.ring_response = ring_response 31 | self.pdd = pdd # post dial delay 32 | self.prd = prd # post response delay 33 | self._two_sided = False # toggle whether to run bert on both ends 34 | self.opts = { 35 | 'bert_timer_name': 'soft', 36 | 'bert_max_err': '30', 37 | 'bert_timeout_ms': '3000', 38 | 'bert_hangup_on_error': 'yes', 39 | "jitterbuffer_msec": "100:200:40", 40 | 'absolute_codec_string': 'PCMU', 41 | # "bert_debug_io_file": "/tmp/bert_debug_${uuid}", 42 | } 43 | self.opts.update(opts) 44 | self.log.debug("using mod_bert config: {}".format(self.opts)) 45 | 46 | # make sure the module is loaded 47 | try: 48 | client.api('reload mod_bert') 49 | except APIError: 50 | self.log.debug("mod_bert already loaded") 51 | 52 | # collections of failed sessions 53 | self.lost_sync = deque(maxlen=1000) 54 | self.timed_out = deque(maxlen=1000) 55 | yield 56 | 57 | @property 58 | def hangup_on_error(self): 59 | """Toggle whether to hangup calls when a bert test fails 60 | """ 61 | return { 62 | 'yes': True, 63 | 'no': False 64 | }[self.opts.get('bert_hangup_on_error', 'no')] 65 | 66 | @hangup_on_error.setter 67 | def hangup_on_error(self, val): 68 | self.opts['bert_hangup_on_error'] = { 69 | True: 'yes', 70 | False: 'no' 71 | }[val] 72 | 73 | @property 74 | def two_sided(self): 75 | '''Toggle whether to run the `bert_test` application 76 | on all sessions of the call. Leaving this `False` means 77 | all other legs will simply run the `echo` application. 78 | ''' 79 | return self._two_sided 80 | 81 | @two_sided.setter 82 | def two_sided(self, enable): 83 | assert isinstance(enable, bool) 84 | self._two_sided = enable 85 | 86 | @event_callback('CHANNEL_PARK') 87 | def on_park(self, sess): 88 | '''Knows how to get us riled up 89 | ''' 90 | # assumption is that inbound calls will be parked immediately 91 | if sess.is_inbound(): 92 | pdd = self.pdd or 0 93 | if self.ring_response: 94 | sess.broadcast( 95 | "{}::".format(self.ring_response), delay=pdd) 96 | 97 | prd = self.prd or 0 98 | # next step will be in answer handler 99 | if prd or pdd: 100 | sess.broadcast("answer::", delay=prd) 101 | else: 102 | sess.answer() 103 | sess.setvars(self.opts) 104 | return 105 | 106 | # for outbound calls the park event comes AFTER the answer initiated by 107 | # the inbound leg given that the originate command specified the `park` 108 | # application as its argument 109 | if sess.is_outbound(): 110 | sess.setvars(self.opts) 111 | sess.broadcast('bert_test::') 112 | 113 | @event_callback("CHANNEL_ANSWER") 114 | def on_answer(self, sess): 115 | if sess.is_inbound(): 116 | if self._two_sided: # bert run on both sides 117 | sess.broadcast('bert_test::') 118 | else: # one-sided looping audio back to source 119 | sess.broadcast('echo::') 120 | 121 | desync_stats = ( 122 | "sync_lost_percent", 123 | "sync_lost_count", 124 | "cng_count", 125 | "err_samples" 126 | ) 127 | 128 | # custom event handling 129 | @event_callback('mod_bert::lost_sync') 130 | def on_lost_sync(self, sess): 131 | """Increment counters on synchronization failure 132 | 133 | The following stats can be retrieved using the latest version of 134 | mod_bert: 135 | 136 | sync_lost_percent - Error percentage within the analysis window 137 | sync_lost_count - How many times sync has been lost 138 | cng_count - Counter of comfort noise packets 139 | err_samples - Number of samples that did not match the sequence 140 | """ 141 | partner = sess.call.sessions[-1] # partner is the final callee UA 142 | self.log.error( 143 | 'BERT Lost Sync on session {} with stats:\n{}'.format( 144 | sess.uuid, "\n".join( 145 | "{}: {}".format(name, sess.get(name, 'n/a')) 146 | for name in self.desync_stats) 147 | ) 148 | ) 149 | # only set vars on the first de-sync 150 | if not hasattr(sess, 'bert_lost_sync_cnt'): 151 | sess.vars['bert_lost_sync_cnt'] = 0 152 | # mod_bert does not know about the peer session 153 | sess.setvar(self.bert_sync_lost_var, 'true') 154 | partner.setvar(self.bert_sync_lost_var, 'true') 155 | self.lost_sync.append(sess) 156 | # count de-syncs 157 | sess.vars['bert_lost_sync_cnt'] += 1 158 | sess.vars['bert_sync'] = False 159 | 160 | @event_callback('mod_bert::timeout') 161 | def on_timeout(self, sess): 162 | """Mark session as bert time out 163 | """ 164 | sess.vars['bert_timeout'] = True 165 | self.log.error('BERT timeout on session {}'.format(sess.uuid)) 166 | self.timed_out.append(sess) 167 | 168 | @event_callback('mod_bert::in_sync') 169 | def on_synced(self, sess): 170 | sess.vars['bert_sync'] = True 171 | self.log.debug('BERT sync on session {}'.format(sess.uuid)) 172 | -------------------------------------------------------------------------------- /switchio/apps/blockers.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | """ 5 | Simulate different types of blocking behaviour such as might be seen by a high 6 | frequency auto-dialler. 7 | """ 8 | from functools import partial 9 | from ..models import Session 10 | from ..marks import callback 11 | from ..utils import get_logger 12 | from ..apps import app 13 | 14 | 15 | @app 16 | class CalleeBlockOnInvite(object): 17 | """Reject all inbound INVITES immediately 18 | 19 | If `response` is not specified respond with with 480 Temporarily 20 | Unavailable, else, use the provided response. 21 | """ 22 | def prepost(self, response='NORMAL_CLEARING'): 23 | self._response = None 24 | self.action = None 25 | self.response = response 26 | 27 | @property 28 | def response(self): 29 | return self._response 30 | 31 | @response.setter 32 | def response(self, value): 33 | if isinstance(value, int): 34 | self.action = partial(Session.respond, response=value) 35 | else: # str case 36 | self.action = partial(Session.hangup, cause=value) 37 | 38 | @callback("CHANNEL_CREATE") 39 | def on_invite(self, sess): 40 | if sess.is_inbound(): 41 | self.action(sess) 42 | 43 | 44 | @app 45 | class CalleeRingback(object): 46 | """Simulate calls which reach early media but never connect. 47 | 48 | `ringback` determines the argument to `Session.playback` which is by 49 | default: 'tone_stream://%(2000, 4000, 440, 480)' i.e. US tones. 50 | 51 | `ring_response` determines the provisionary response 52 | ( eg. for SIP 'pre_answer' -> 183 , 'ring_ready' -> 180 ). 53 | 54 | `calle[er]_hup_after` determines the time in seconds before a hangup is 55 | triggered by either of the caller/callee respectively. 56 | """ 57 | def prepost( 58 | self, 59 | ringback='tone_stream://%(2000, 4000, 440, 480)', 60 | callee_hup_after=float('inf'), 61 | caller_hup_after=float('inf'), 62 | ring_response='pre_answer', # or could be 'ring_ready' for 180 63 | auto_duration=False, 64 | ): 65 | self.ringback = ringback 66 | self.callee_hup_after = callee_hup_after 67 | self.auto_duration = auto_duration, 68 | self.caller_hup_after = caller_hup_after 69 | self.ring_response = ring_response 70 | self.log = get_logger(self.__class__.__name__) 71 | 72 | def __setduration__(self, value): 73 | if self.auto_duration: 74 | self.callee_hup_after = value 75 | 76 | @callback("CHANNEL_CALLSTATE") 77 | def on_cs(self, sess): 78 | self.log.debug("'{}' sess CS is '{}'".format( 79 | sess['Call-Direction'], sess['Channel-Call-State'])) 80 | 81 | if sess['Channel-Call-State'] == 'RINGING': 82 | if sess.is_inbound(): 83 | # send ring response 84 | sess.broadcast("{}::".format(self.ring_response)) 85 | 86 | # playback US ring tones 87 | if self.ringback: 88 | sess.playback(self.ringback) 89 | 90 | # hangup after a delay 91 | if self.callee_hup_after < float('inf'): 92 | sess.sched_hangup(self.callee_hup_after - sess.uptime) 93 | 94 | # hangup caller after a delay 95 | if sess.is_outbound() and self.caller_hup_after < float('inf'): 96 | sess.sched_hangup(self.caller_hup_after) 97 | -------------------------------------------------------------------------------- /switchio/apps/dtmf.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | """ 5 | Dtmf tools 6 | """ 7 | from collections import deque, OrderedDict 8 | from ..apps import app 9 | from ..marks import event_callback 10 | from ..utils import get_logger 11 | 12 | 13 | @app 14 | class DtmfChecker(object): 15 | '''Play dtmf tones as defined by the iterable attr `sequence` with 16 | tone `duration`. Verify the rx sequence matches what was transmitted. 17 | 18 | For each session which is answered start a sequence check. For any session 19 | that fails digit matching store it locally in the `failed` attribute. 20 | ''' 21 | def prepost(self): 22 | self.log = get_logger(self.__class__.__name__) 23 | self.sequence = range(1, 10) 24 | self.duration = 200 # ms 25 | self.total_time = len(self.sequence) * self.duration / 1000.0 26 | self.incomplete = OrderedDict() 27 | self.failed = [] 28 | 29 | @event_callback('CHANNEL_PARK') 30 | def on_park(self, sess): 31 | if sess.is_inbound(): 32 | sess.answer() 33 | self.incomplete[sess] = deque(self.sequence) 34 | sess.vars['dtmf_checked'] = False 35 | 36 | if sess.is_outbound(): 37 | self.log.info( 38 | "Transmitting DTMF seq '{}' for session '{}'" 39 | .format(self.sequence, sess.uuid) 40 | ) 41 | sess.broadcast('playback::silence_stream://0') 42 | sess.send_dtmf(''.join(map(str, self.sequence)), self.duration) 43 | 44 | @event_callback('DTMF') 45 | def on_digit(self, sess): 46 | digit = int(sess['DTMF-Digit']) 47 | self.log.info( 48 | "rx dtmf digit '{}' for session '{}'". 49 | format(digit, sess.uuid) 50 | ) 51 | # verify expected digit 52 | remaining = self.incomplete[sess] 53 | expected = remaining.popleft() 54 | if expected != digit: 55 | self.log.warning("Expected digit '{}', instead received '{}' for" 56 | " session '{}'".format(expected, digit)) 57 | self.failed.append(sess) 58 | if not remaining: # all digits have now arrived 59 | self.log.debug("session '{}' completed dtmf sequence match" 60 | .format(sess.uuid)) 61 | self.incomplete.pop(sess) # sequence match success 62 | sess.vars['dtmf_checked'] = True 63 | -------------------------------------------------------------------------------- /switchio/apps/measure/__init__.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | import os 5 | import pickle 6 | from functools import partial 7 | from collections import OrderedDict, namedtuple 8 | from switchio import utils 9 | from .storage import * 10 | 11 | # re-export(s) 12 | from .cdr import CDR 13 | 14 | log = utils.get_logger(__name__) 15 | 16 | def plot_df(df, figspec, **kwargs): 17 | """Plot a pandas data frame according to the provided `figspec` 18 | """ 19 | from .mpl_helpers import multiplot 20 | return multiplot(df, figspec=figspec, **kwargs) 21 | 22 | 23 | Measurer = namedtuple("Measurer", 'app ppkwargs storer ops') 24 | 25 | 26 | class Measurers(object): 27 | """A dict-like collection of measurement apps with 28 | sub-references to each app's `DataStorer` and optional metrics 29 | computing callables. 30 | 31 | The purpose of this type is two-fold: 32 | 1) provide micro-management of apps which collect data/measurements 33 | (measurers) such that they can be loaded and referenced as a group under 34 | different scopes (eg. per call control app). 35 | 2) provide an interface for adding operator functions which process 36 | a single pandas.DataFrame and provide a new frame output for analysis. 37 | 38 | Each `Measurer` tuple can be accessed using dict-like subscript syntax. 39 | """ 40 | def __init__(self, storetype=None): 41 | self._apps = OrderedDict() 42 | self.storetype = storetype 43 | # delegate to `_apps` for subscript access 44 | for meth in '__getitem__ __contains__'.split(): 45 | setattr(self.__class__, meth, getattr(self._apps, meth)) 46 | 47 | # add attr access for references to data frame operators 48 | self._ops = OrderedDict() 49 | if storage.pd: 50 | self.ops = utils.DictProxy(self._ops) 51 | 52 | # do the same for data stores 53 | self._stores = OrderedDict() 54 | self.stores = utils.DictProxy(self._stores) 55 | # same for figspecs 56 | self._figspecs = OrderedDict() 57 | self.figspecs = utils.DictProxy(self._figspecs) 58 | 59 | def __repr__(self): 60 | return repr(self._apps).replace( 61 | type(self._apps).__name__, type(self).__name__) 62 | 63 | def add(self, app, name=None, operators={}, **kwargs): 64 | name = name or utils.get_name(app) 65 | prepost = getattr(app, 'prepost', None) 66 | if not prepost: 67 | raise AttributeError( 68 | "'{}' must define a `prepost` method".format(name)) 69 | args, ppkwargs = utils.get_args(app.prepost) 70 | if 'storer' not in ppkwargs: 71 | raise TypeError("'{}' must define a 'storer' kwarg" 72 | .format(app.prepost)) 73 | ppkwargs = {key: kwargs.pop(key) for key in ppkwargs if key in kwargs} 74 | 75 | # acquire storer factory 76 | factory = getattr(app, 'new_storer', None) 77 | storer_kwargs = getattr(app, 'storer_kwargs', {}) 78 | storer_kwargs.update(kwargs) 79 | storer_kwargs.setdefault('storetype', self.storetype) 80 | # app may not define a storer factory method 81 | storer = storage.DataStorer( 82 | name, dtype=app.fields, **storer_kwargs 83 | ) if not factory else factory() 84 | 85 | self._apps[name] = Measurer(app, ppkwargs, storer, {}) 86 | # provide attr access off `self.stores` 87 | self._stores[name] = storer 88 | setattr( 89 | self.stores.__class__, 90 | name, 91 | # make instance lookups access the `data` attr 92 | property(partial(storer.__class__.data.__get__, storer)) 93 | ) 94 | # add any app defined operator functions 95 | ops = getattr(app, 'operators', {}) 96 | ops.update(operators) 97 | for opname, func in ops.items(): 98 | self.add_operator(name, func, opname=opname) 99 | 100 | return name 101 | 102 | def add_operator(self, measurername, func, opname): 103 | m = self._apps[measurername] 104 | m.ops[opname] = func 105 | 106 | def operator(self, storer): 107 | return storer.data.pipe(func) 108 | 109 | # provides descriptor protocol access for interactive work 110 | self._ops[opname] = func 111 | if storage.pd: 112 | setattr(self.ops.__class__, opname, 113 | property(partial(operator, storer=m.storer))) 114 | 115 | # append any figure specification 116 | figspec = getattr(func, 'figspec', None) 117 | if figspec: 118 | self._figspecs[opname] = figspec 119 | 120 | def items(self): 121 | return list(reversed(self._apps.items())) 122 | 123 | def to_store(self, dirpath): 124 | """Dump all data + operator combinations to a backend storage format 125 | on disk. 126 | """ 127 | if not os.path.isdir(dirpath): 128 | raise ValueError("You must provide a directory") 129 | 130 | iterapps = self._apps.items() 131 | # infer storage backend from first store 132 | name, m = next(iter(iterapps)) 133 | storetype = m.storer.storetype 134 | storepath = os.path.join(dirpath, "switchio_measures") 135 | 136 | framedict = OrderedDict() 137 | # raw data sets 138 | for name, m in self._apps.items(): 139 | data = m.storer.data 140 | if len(data): 141 | framedict[name] = data 142 | 143 | if storage.pd: 144 | # processed (metrics) data sets 145 | for opname, op in m.ops.items(): 146 | framedict[os.path.join(name, opname)] = op(data) 147 | 148 | storepath = storetype.multiwrite(storepath, framedict.items()) 149 | # dump pickle file containing figspec (and possibly other meta-data) 150 | pklpath = os.path.join(dirpath, 'switchio_measures.pkl') 151 | with open(pklpath, 'wb') as pklfile: 152 | pickle.dump( 153 | {'storepath': storepath, 'figspecs': self._figspecs, 154 | 'storetype': storetype.ext}, 155 | pklfile, 156 | ) 157 | return pklpath 158 | 159 | if storage.pd: 160 | @property 161 | def merged_ops(self): 162 | """Merge and return all function operator frames from all measurers 163 | """ 164 | # concat along the columns 165 | return storage.pd.concat( 166 | (getattr(self.ops, name) for name in self._ops), 167 | axis=1 168 | ) 169 | 170 | def plot(self, **kwargs): 171 | """Plot all figures specified in the `figspecs` dict. 172 | """ 173 | return [ 174 | (figspec, plot_df(self.merged_ops, figspec, **kwargs)) 175 | for figspec in self._figspecs.values() 176 | ] 177 | 178 | 179 | def load(path, **kwargs): 180 | """Load a previously pickled data set from the filesystem and return it as 181 | a merged data frame 182 | """ 183 | with open(path, 'rb') as pkl: 184 | obj = pickle.load(pkl) 185 | log.debug("loaded pickled content:\n{}".format(obj)) 186 | if not isinstance(obj, dict): 187 | return load_legacy(obj) 188 | 189 | # attempt to find the hdf file 190 | storepath = obj['storepath'] 191 | if not os.path.exists(storepath): 192 | # it might be a sibling file 193 | storepath = os.path.basename(storepath) 194 | assert os.path.exists(storepath), "Can't find data store path?" 195 | 196 | # XXX should be removed once we don't have any more legacy hdf5 197 | # data sets to worry about 198 | storetype = storage.get_storetype(obj.get('storetype', 'hdf5')) 199 | 200 | merged = storetype.multiread(storepath, **kwargs) 201 | 202 | # XXX evetually we should support multiple figures 203 | figspecs = obj.get('figspecs', {}) 204 | figspec = figspecs[tuple(figspecs.keys())[0]] 205 | merged._plot = partial(plot_df, merged, figspec) 206 | return merged 207 | 208 | 209 | def load_legacy(array): 210 | '''Load a pickeled numpy structured array from the filesystem into a 211 | `DataFrame`. 212 | ''' 213 | if not storage.pd: 214 | raise RuntimeError("pandas is required to load legacy data sets") 215 | 216 | pd = storage.pd 217 | 218 | # calc and assign rate info 219 | def calc_rates(df): 220 | df = df.sort(['time']) 221 | mdf = pd.DataFrame( 222 | df, index=range(len(df))).assign(hangup_index=df.index).assign( 223 | inst_rate=lambda df: 1 / df['time'].diff() 224 | ).assign( 225 | wm_rate=lambda df: pd.rolling_mean(df['inst_rate'], 30) 226 | ) 227 | return mdf 228 | 229 | # adjust field spec to old record array record names 230 | figspec = { 231 | (1, 1): [ 232 | 'call_setup_latency', 233 | 'answer_latency', 234 | 'invite_latency', 235 | 'originate_latency', 236 | ], 237 | (2, 1): [ 238 | 'num_sessions', 239 | 'num_failed_calls', 240 | ], 241 | (3, 1): [ 242 | 'inst_rate', 243 | 'wm_rate', # why so many NaN? 244 | ] 245 | } 246 | df = calc_rates(pd.DataFrame.from_records(array)) 247 | df._plot = partial(plot_df, df, figspec) 248 | return df 249 | -------------------------------------------------------------------------------- /switchio/apps/measure/cdr.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | """ 5 | CDR app for collecting signalling latency and performance stats. 6 | """ 7 | import weakref 8 | import itertools 9 | import time 10 | from switchio.marks import event_callback 11 | from switchio import utils 12 | from .storage import pd, DataStorer 13 | 14 | 15 | def call_metrics(df): 16 | """Default call measurements computed from data retrieved by 17 | the `CDR` app. 18 | """ 19 | # sort by create time 20 | df = df.sort_values(by=['caller_create']) 21 | cr = 1 / df['caller_create'].diff() # compute instantaneous call rates 22 | # any more and the yaxis scale becomes a bit useless 23 | clippedcr = cr.clip(upper=1000) 24 | 25 | mdf = pd.DataFrame( 26 | data={ 27 | 'switchio_app': df['switchio_app'], 28 | 'hangup_cause': df['hangup_cause'], 29 | 'hangup_index': df.index, 30 | 'invite_latency': df['callee_create'] - df['caller_create'], 31 | 'answer_latency': df['caller_answer'] - df['callee_answer'], 32 | 'call_setup_latency': df['caller_answer'] - df['caller_create'], 33 | 'originate_latency': df['caller_req_originate'] - df['job_launch'], 34 | 'call_duration': df['caller_hangup'] - df['caller_create'], 35 | 'failed_calls': df['failed_calls'], 36 | 'active_sessions': df['active_sessions'], 37 | 'erlangs': df['erlangs'], 38 | 'call_rate': clippedcr, 39 | 'avg_call_rate': clippedcr.rolling(window=100).mean(), 40 | 'seizure_fail_rate': df['failed_calls'] / df.index.max(), 41 | }, 42 | # data will be sorted by 'caller_create` but re-indexed 43 | index=range(len(df)), 44 | ).assign(answer_seizure_ratio=lambda df: 1 - df['seizure_fail_rate']) 45 | return mdf 46 | 47 | 48 | # def hcm(df): 49 | # ''''Hierarchical indexed call metrics 50 | # ''' 51 | # cm = call_metrics(df) 52 | # return pd.DataFrame( 53 | # cm.values, 54 | # index=pd.MultiIndex.from_arrays( 55 | # [df['switchio_app'], df['hangup_cause'], cm.index]), 56 | # columns=cm.columns, 57 | # ) 58 | 59 | 60 | # def call_types(df, figspec=None): 61 | # """Hangup-cause and app plotting annotations 62 | # """ 63 | # # sort by create time 64 | # df = df.sort_values(by=['caller_create']) 65 | # ctdf = pd.DataFrame( 66 | # data={ 67 | # 'hangup_cause': df['hangup_cause'], 68 | # }, 69 | # # data will be sorted by 'caller_create` but re-indexed 70 | # index=range(len(df)), 71 | # ) 72 | 73 | # # create step funcs for each hangup cause 74 | # for cause in ctdf.hangup_cause.value_counts().keys(): 75 | # ctdf[cause.lower()] = (ctdf.hangup_cause == cause).astype(pd.np.float) 76 | 77 | # return ctdf 78 | 79 | 80 | call_metrics.figspec = { 81 | (1, 1): [ 82 | 'call_setup_latency', 83 | 'answer_latency', 84 | 'invite_latency', 85 | 'originate_latency', 86 | ], 87 | (2, 1): [ 88 | 'active_sessions', 89 | 'erlangs', 90 | 'failed_calls', 91 | ], 92 | (3, 1): [ 93 | 'call_rate', 94 | 'avg_call_rate', # why so many NaN? 95 | ], 96 | } 97 | 98 | 99 | class CDR(object): 100 | """Collect call detail record info including call oriented event time 101 | stamps and and active sessions data which can be used for per call metrics 102 | computations. 103 | """ 104 | fields = [ 105 | ('switchio_app', 'S50'), 106 | ('hangup_cause', 'S50'), 107 | ('caller_create', 'float64'), 108 | ('caller_answer', 'float64'), 109 | ('caller_req_originate', 'float64'), 110 | ('caller_originate', 'float64'), 111 | ('caller_hangup', 'float64'), 112 | ('job_launch', 'float64'), 113 | ('callee_create', 'float64'), 114 | ('callee_answer', 'float64'), 115 | ('callee_hangup', 'float64'), 116 | ('failed_calls', 'uint32'), 117 | ('active_sessions', 'uint32'), 118 | ('erlangs', 'uint32'), 119 | ] 120 | 121 | operators = { 122 | 'call_metrics': call_metrics, 123 | # 'call_types': call_types, 124 | # 'hcm': hcm, 125 | } 126 | 127 | def __init__(self): 128 | self.log = utils.get_logger(__name__) 129 | self._call_counter = itertools.count(0) 130 | 131 | def new_storer(self): 132 | return DataStorer(self.__class__.__name__, dtype=self.fields) 133 | 134 | def prepost(self, listener, storer=None, pool=None, orig=None): 135 | self.listener = listener 136 | self.orig = orig 137 | # create our own storer if we're not loaded as a `Measurer` 138 | self._ds = storer if storer else self.new_storer() 139 | self.pool = weakref.proxy(pool) if pool else self.listener 140 | 141 | @property 142 | def storer(self): 143 | return self._ds 144 | 145 | @event_callback('CHANNEL_CREATE') 146 | def on_create(self, sess): 147 | """Store total (cluster) session count at channel create time 148 | """ 149 | call_vars = sess.call.vars 150 | # call number tracking 151 | if not call_vars.get('call_index', None): 152 | call_vars['call_index'] = next(self._call_counter) 153 | # capture the current erlangs / call count 154 | call_vars['session_count'] = self.pool.count_sessions() 155 | call_vars['erlangs'] = self.pool.count_calls() 156 | 157 | @event_callback('CHANNEL_ORIGINATE') 158 | def on_originate(self, sess): 159 | # store local time stamp for originate 160 | sess.times['originate'] = sess.time 161 | sess.times['req_originate'] = time.time() 162 | 163 | @event_callback('CHANNEL_ANSWER') 164 | def on_answer(self, sess): 165 | sess.times['answer'] = sess.time 166 | 167 | @event_callback('CHANNEL_DESTROY') 168 | def log_stats(self, sess, job): 169 | """Append measurement data only once per call 170 | """ 171 | sess.times['hangup'] = sess.time 172 | call = sess.call 173 | 174 | if call.sessions: # still session(s) remaining to be hungup 175 | call.caller = call.first 176 | call.callee = call.last 177 | if job: 178 | call.job = job 179 | return # stop now since more sessions are expected to hangup 180 | 181 | # all other sessions have been hungup so store all measurements 182 | caller = getattr(call, 'caller', None) 183 | if not caller: 184 | # most likely only one leg was established and the call failed 185 | # (i.e. call.caller was never assigned above) 186 | caller = sess 187 | 188 | callertimes = caller.times 189 | callee = getattr(call, 'callee', None) 190 | calleetimes = callee.times if callee else None 191 | 192 | pool = self.pool 193 | job = getattr(call, 'job', None) 194 | # NOTE: the entries here correspond to the listed `CDR.fields` 195 | rollover = self._ds.append_row(( 196 | caller.appname, 197 | caller['Hangup-Cause'], 198 | callertimes['create'], # invite time index 199 | callertimes['answer'], 200 | callertimes['req_originate'], # local time stamp 201 | callertimes['originate'], 202 | callertimes['hangup'], 203 | # 2nd leg may not be successfully established 204 | job.launch_time if job else None, 205 | calleetimes['create'] if callee else None, 206 | calleetimes['answer'] if callee else None, 207 | calleetimes['hangup'] if callee else None, 208 | pool.count_failed(), 209 | call.vars['session_count'], 210 | call.vars['erlangs'], 211 | )) 212 | if rollover: 213 | self.log.debug('wrote data to disk') 214 | -------------------------------------------------------------------------------- /switchio/apps/measure/mpl_helpers.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | """ 5 | Measurement and plotting tools - pandas + mpl helpers 6 | """ 7 | # TODO: 8 | # - make legend malleable 9 | import sys 10 | import os 11 | from ... import utils 12 | from collections import namedtuple 13 | 14 | # handle remote execution plotting 15 | if not os.environ.get("DISPLAY"): 16 | import matplotlib 17 | matplotlib.use("Agg") 18 | 19 | import matplotlib.pyplot as plt 20 | import pylab 21 | 22 | log = utils.get_logger(__name__) 23 | 24 | plotitems = namedtuple('plotitems', 'mng fig axes artists') 25 | 26 | 27 | def multiplot(df, figspec, fig=None, mng=None, block=False, fname=None): 28 | '''Plot selected columns in appropriate axes on a figure using the pandas 29 | plotting helpers where possible. `figspec` is a map of subplot location 30 | tuples to column name iterables. 31 | ''' 32 | # figspec is a map of tuples like: {(row, column): []} 33 | rows, cols = max(figspec) 34 | 35 | # generate fig and axes set 36 | fig, axes_arr = plt.subplots( 37 | rows, 38 | cols, 39 | sharex=True, 40 | squeeze=False, 41 | tight_layout=True, 42 | # make a BIG plot if we're writing to file 43 | figsize=(2*24, 1*24) if fname else None, 44 | ) 45 | mng = mng if mng else plt.get_current_fig_manager() 46 | 47 | if block or fname: 48 | # turn interactive mode off 49 | plt.ioff() 50 | 51 | # plot loop 52 | artist_map = {} 53 | axes = {} 54 | for loc, colnames in sorted(figspec.items()): 55 | if loc is None: 56 | continue 57 | else: 58 | row, col = loc[0] - 1, loc[1] - 1 59 | 60 | ax = axes_arr[row, col] 61 | log.info("plotting '{}'".format(colnames)) 62 | ax = df[colnames].plot(ax=ax) # use the pandas plotter 63 | axes[loc] = ax 64 | artists, names = ax.get_legend_handles_labels() 65 | artist_map[loc] = { 66 | name: artist for name, artist in zip(names, artists)} 67 | 68 | # set legend 69 | ax.legend( 70 | artists, names, 71 | loc='upper left', fontsize='large', fancybox=True, framealpha=0.5 72 | ) 73 | 74 | ax.set_xlabel('Call Event Index', fontdict={'size': 'large'}) 75 | 76 | if getattr(df, 'title', None): 77 | fig.suptitle(os.path.basename(df.title), fontsize=15) 78 | 79 | if block: 80 | if sys.platform.lower() == 'darwin': 81 | # For MacOS only blocking mode is supported 82 | # the fig.show() method throws exceptions 83 | pylab.show() 84 | else: 85 | plt.show() 86 | # save to file depending on fname extension 87 | elif fname: 88 | plt.savefig(fname, bbox_inches='tight') 89 | # regular interactive plotting 90 | else: 91 | fig.show() 92 | 93 | return plotitems(mng, fig, axes, artist_map) 94 | -------------------------------------------------------------------------------- /switchio/apps/measure/shmarray.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Shared memory array implementation for numpy which delegates all the nasty 3 | stuff to multiprocessing.sharedctypes. 4 | 5 | Copyright (c) 2010, David Baddeley 6 | All rights reserved. 7 | ''' 8 | # Licensced under the BSD liscence ... 9 | # 10 | # Redistribution and use in source and binary forms, with or without 11 | # modification, # are permitted provided that the following conditions are met: 12 | # 13 | # Redistributions of source code must retain the above copyright notice, this 14 | # list # of conditions and the following disclaimer. 15 | # 16 | # Redistributions in binary form must reproduce the above copyright notice, 17 | # this # list of conditions and the following disclaimer in the documentation 18 | # and/or other 19 | # materials provided with the distribution. 20 | # 21 | # Neither the name of the nor the names of its contributors may 22 | # be # used to endorse or promote products derived from this software without 23 | # specific # prior written permission. 24 | # 25 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 26 | # AND # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 27 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 28 | # ARE DISCLAIMED. # IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 29 | # LIABLE FOR ANY DIRECT, # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 30 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 31 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 32 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 33 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 34 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 35 | # POSSIBILITY OF SUCH DAMAGE. 36 | 37 | import numpy 38 | from multiprocessing import sharedctypes 39 | from numpy import ctypeslib 40 | 41 | 42 | class shmarray(numpy.ndarray): 43 | '''subclass of ndarray with overridden pickling functions which record 44 | dtype, shape etc... but defer pickling of the underlying data to the 45 | original data source. 46 | 47 | Doesn't actually handle allocation of the shared memory - this is done in 48 | create, and zeros, ones, (or create_copy) are the functions which should be 49 | used for creating a new shared memory array. 50 | 51 | TODO - add argument checking to ensure that the user is passing reasonable 52 | values. 53 | ''' 54 | def __new__(cls, ctypesArray, shape, dtype=float, strides=None, offset=0, 55 | order=None): 56 | # some magic (copied from numpy.ctypeslib) to make sure the ctypes 57 | # array has the array interface 58 | tp = type(ctypesArray) 59 | try: 60 | tp.__array_interface__ 61 | except AttributeError: 62 | if hasattr(ctypeslib, 'prep_array'): 63 | ctypeslib.prep_array(tp) 64 | 65 | obj = numpy.ndarray.__new__( 66 | cls, shape, dtype, ctypesArray, offset, strides, order) 67 | 68 | # keep track of the underlying storage 69 | # this may not be strictly necessary as the same info should be stored 70 | # in .base 71 | obj.ctypesArray = ctypesArray 72 | return obj 73 | 74 | def __array_finalize__(self, obj): 75 | if obj is None: 76 | return 77 | self.ctypesArray = getattr(obj, 'ctypesArray', None) 78 | 79 | def __reduce_ex__(self, protocol): 80 | '''delegate pickling of the data to the underlying storage, but keep copies 81 | of shape, dtype & strides. 82 | 83 | TODO - find how to get at the offset and order parameters and keep 84 | track of them as well. 85 | ''' 86 | return shmarray, ( 87 | self.ctypesArray, self.shape, self.dtype, self.strides) 88 | # , self.offset, self.order) 89 | 90 | def __reduce__(self): 91 | return type(self).__reduce_ex__(self, 0) 92 | 93 | 94 | def create(shape, dtype='d', alignment=32): 95 | '''Create an uninitialised shared array. Avoid object arrays, as these 96 | will almost certainly break as the objects themselves won't be stored in 97 | shared memory, only the pointers 98 | ''' 99 | shape = numpy.atleast_1d(shape).astype('i') 100 | dtype = numpy.dtype(dtype) 101 | 102 | # we're going to use a flat ctypes array 103 | N = numpy.prod(shape) + alignment 104 | # The upper bound of size we want to allocate to be certain 105 | # that we can take an aligned array of the right size from it. 106 | N_bytes_big = N * dtype.itemsize 107 | # The final (= right) size of the array 108 | N_bytes_right = numpy.prod(shape) * dtype.itemsize 109 | dt = 'b' 110 | 111 | # We create the big array first 112 | a = sharedctypes.RawArray(dt, int(N_bytes_big)) 113 | sa = shmarray(a, (N_bytes_big,), dt) 114 | 115 | # We pick the first index of the new array that is aligned 116 | # If the address of the first element is 1 and we want 8-alignment, the 117 | # first aligned index of the array is going to be 7 == -1 % 8 118 | start_index = -sa.ctypes.data % alignment 119 | # Finally, we take the (aligned) subarray and reshape it. 120 | sa = sa[start_index:start_index + N_bytes_right].view(dtype).reshape(shape) 121 | 122 | return sa 123 | 124 | 125 | def zeros(shape, dtype='d'): 126 | """Create an shared array initialised to zeros. Avoid object arrays, as these 127 | will almost certainly break as the objects themselves won't be stored in 128 | shared memory, only the pointers 129 | """ 130 | sa = create(shape, dtype=dtype) 131 | # contrary to the documentation, sharedctypes.RawArray does NOT always 132 | # return an array which is initialised to zero - do it ourselves 133 | # http://code.google.com/p/python-multiprocessing/issues/detail?id=25 134 | sa[:] = numpy.zeros(1, dtype) 135 | return sa 136 | 137 | 138 | def ones(shape, dtype='d'): 139 | '''Create an shared array initialised to ones. Avoid object arrays, as these 140 | will almost certainly break as the objects themselves won't be stored in 141 | shared memory, only the pointers 142 | ''' 143 | sa = create(shape, dtype=dtype) 144 | sa[:] = numpy.ones(1, dtype) 145 | return sa 146 | 147 | 148 | def create_copy(a): 149 | '''create a a shared copy of an array 150 | ''' 151 | # create an empty array 152 | b = create(a.shape, a.dtype) 153 | # copy contents across 154 | b[:] = a[:] 155 | return b 156 | -------------------------------------------------------------------------------- /switchio/apps/measure/sys.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | """ 5 | Rudimentary system stats collection using ``psutil``. 6 | """ 7 | import time 8 | from switchio import event_callback, utils 9 | 10 | 11 | def sys_stats(df): 12 | """Reindex on the call index to allign with call metrics data 13 | and interpolate. 14 | """ 15 | df.index = df.call_index 16 | ci = df.pop('call_index') 17 | # iterpolate all system stats since the arrays will be sparse 18 | # compared to the associated call metrics data. 19 | return df.reindex(range(int(ci.iloc[-1]) + 1)).interpolate() 20 | 21 | 22 | class SysStats(object): 23 | """A switchio app for capturing system performance stats during load test 24 | using the `psutil`_ module. 25 | 26 | An instance of this app should be loaded if rate limited data gathering is 27 | to be shared across multiple slaves (threads). 28 | 29 | .. _psutil: 30 | https://pythonhosted.org/psutil/ 31 | """ 32 | operators = { 33 | 'sys_stats': sys_stats, 34 | } 35 | 36 | def __init__(self, psutil, rpyc=None): 37 | self._psutil = psutil 38 | self.rpyc = rpyc 39 | self._conn = None 40 | self.log = utils.get_logger(__name__) 41 | # required to define the columns for the data frame storer 42 | self.fields = [ 43 | 'call_index', 44 | 'total_cpu_percent', 45 | 'percent_cpu_sys', 46 | 'percent_cpu_usr', 47 | 'percent_cpu_idle', 48 | 'percent_cpu_iow', 49 | 'phymem_percent_usage', 50 | 'load_avg', 51 | ] 52 | # this call should ensure we have the correct type 53 | self._times_tup_type = psutil.cpu_times().__class__ 54 | self.log = utils.get_logger(type(self).__name__) 55 | 56 | # initial cpu usage 57 | self._last_cpu_times = self.psutil.cpu_times() 58 | 59 | @property 60 | def psutil(self): 61 | try: 62 | return self._psutil 63 | except (ReferenceError, EOFError): # rpyc and its weakrefs being flaky 64 | if self.rpyc: 65 | self.log.warning("resetting rypc connection...") 66 | self._conn = conn = self.rpyc.classic_connect() 67 | self._psutil = conn.modules.psutil 68 | return self._psutil 69 | raise 70 | 71 | def prepost(self, collect_rate=2, storer=None): 72 | self.storer = storer 73 | self.count = 0 74 | self._collect_period = 1. / collect_rate 75 | self._last_collect_time = 0 76 | 77 | @property 78 | def collect_rate(self): 79 | return 1. / self._collect_period 80 | 81 | @collect_rate.setter 82 | def collect_rate(self, rate): 83 | self._collect_period = 1. / rate 84 | 85 | @event_callback("CHANNEL_CREATE") 86 | def on_create(self, sess): 87 | now = time.time() 88 | if sess.is_outbound(): 89 | # rate limiting 90 | if (now - self._last_collect_time) >= self._collect_period: 91 | # XXX important to keep this here for performance and 92 | # avoiding thread racing 93 | self._last_collect_time = now 94 | 95 | psutil = self.psutil 96 | self.log.debug("writing psutil row at time '{}'".format(now)) 97 | 98 | curr_times = self.psutil.cpu_times() 99 | 100 | delta = self._times_tup_type(*tuple( 101 | now - last for now, last in 102 | zip(curr_times, self._last_cpu_times) 103 | )) 104 | self._last_cpu_times = curr_times 105 | tottime = sum(delta) 106 | 107 | self.storer.append_row(( 108 | sess.call.vars['call_index'], 109 | psutil.cpu_percent(interval=None), 110 | delta.system / tottime * 100., 111 | delta.user / tottime * 100., 112 | delta.idle / tottime * 100., 113 | delta.iowait / tottime * 100., 114 | psutil.phymem_usage().percent, 115 | psutil.os.getloadavg()[0], 116 | )) 117 | -------------------------------------------------------------------------------- /switchio/apps/routers.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | """ 5 | Routing apps 6 | """ 7 | import re 8 | import asyncio 9 | from functools import partial 10 | from collections import OrderedDict 11 | from collections import Counter 12 | from .. import utils 13 | from ..marks import coroutine, callback, extend_attr_list 14 | from ..apps import app 15 | 16 | 17 | @app 18 | class Proxier(object): 19 | """Proxy all inbound calls to the destination specified in the SIP 20 | Request-URI. 21 | 22 | .. note:: 23 | This is meant as a simple example for testing. If you want to build 24 | a routing system see the `Router` app below. 25 | """ 26 | @callback('CHANNEL_PARK') 27 | def on_park(self, sess): 28 | if sess.is_inbound(): 29 | # by default bridges to sess['variable_sip_req_uri'] 30 | sess.bridge() 31 | 32 | 33 | @app 34 | class Bridger(object): 35 | '''Bridge sessions within a call an arbitrary number of times. 36 | ''' 37 | def prepost(self): 38 | self.log = utils.get_logger(self.__class__.__name__) 39 | self.call2entries = Counter() 40 | self.count2bridgeargs = { # leg count to codec spec 41 | 1: 'proxy' # default is to proxy the call using the request uri 42 | } 43 | 44 | @callback("CHANNEL_PARK") 45 | def on_park(self, sess): 46 | '''Bridge per session for a given call using the argument spec 47 | provided in `count2bridgeargs`. If the value for a given count is 48 | `proxy` then simply proxy the session to the initial request uri 49 | destination. 50 | ''' 51 | call = sess.call 52 | self.call2entries[call] += 1 53 | args = self.count2bridgeargs.get(self.call2entries[call]) 54 | if args == 'proxy': # proxy to dest using request uri 55 | sess.bridge() 56 | elif args: # a dict of kwargs to pass to the bridge cmd 57 | sess.bridge(**args) 58 | 59 | @callback('CHANNEL_BRIDGE') 60 | def on_bridge(self, sess): 61 | self.log.debug("Bridged aleg session '{}' to bleg session '{}'" 62 | .format(sess.uuid, sess['Bridge-B-Unique-ID'])) 63 | 64 | 65 | class PatternRegistrar(object): 66 | """A `flask`-like pattern to callback registrar. 67 | 68 | Allows for registering callback functions (via decorators) which will be 69 | delivered when `PatterCaller.iter_matches()` is invoked with a matching 70 | value. 71 | """ 72 | def __init__(self): 73 | self.regex2funcs = OrderedDict() 74 | 75 | def update(self, other): 76 | """Update local registered functions from another registrar. 77 | """ 78 | self.regex2funcs.update(other.regex2funcs) 79 | 80 | def __call__(self, pattern, field='Caller-Destination-Number', **kwargs): 81 | """Decorator interface allowing you to register callback or coroutine 82 | functions with regex patterns and kwargs. When `iter_matches` is 83 | called with a mapping, any callable registered with a matching regex 84 | pattern will be delivered as a partial. 85 | """ 86 | def inner(func): 87 | assert asyncio.iscoroutinefunction(func), 'Not a coroutine' 88 | self.regex2funcs.setdefault( 89 | (pattern, field), []).append((func, kwargs)) 90 | return func 91 | 92 | return inner 93 | 94 | def iter_matches(self, fields, **kwargs): 95 | """Perform registered order lookup for all functions with a matching 96 | pattern. Each function is partially applied with it's matched value as 97 | an argument and any kwargs provided here. Any kwargs provided at 98 | registration are also forwarded. 99 | """ 100 | for (patt, field), funcitems in self.regex2funcs.items(): 101 | value = fields.get(field) 102 | if value: 103 | match = re.match(patt, value) 104 | if match: 105 | for func, defaults in funcitems: 106 | if kwargs: 107 | defaults.update(kwargs) 108 | yield partial(func, match=match, **defaults) 109 | 110 | 111 | @app 112 | class Router(object): 113 | '''Route sessions using registered callback functions (decorated as 114 | "routes") which are pattern matched based on selected channel variable 115 | contents. 116 | 117 | Requires that the handling SIP profile had been configured to use the 118 | 'switchio' dialplan context or at the very least a context which contains a 119 | park action extension. 120 | ''' 121 | # Signal a routing halt 122 | class StopRouting(Exception): 123 | """Signal a router to discontinue execution. 124 | """ 125 | 126 | def __init__(self, guards=None, reject_on_guard=True, subscribe=()): 127 | self.guards = guards or {} 128 | self.guard = reject_on_guard 129 | # subscribe for any additional event types requested by the user 130 | extend_attr_list(self.on_park, 'switchio_events_sub', subscribe) 131 | self.route = PatternRegistrar() 132 | 133 | def prepost(self, pool): 134 | self.pool = pool 135 | self.log = utils.get_logger( 136 | utils.pstr(self, pool.evals('listener.host')) 137 | ) 138 | 139 | @coroutine("CHANNEL_PARK") 140 | async def on_park(self, sess): 141 | handled = False 142 | if not all(sess[key] == val for key, val in self.guards.items()): 143 | self.log.warning("Session with id {} did not pass guards" 144 | .format(sess.uuid)) 145 | else: 146 | for func in self.route.iter_matches(sess, sess=sess, router=self): 147 | handled = True # at least one match 148 | try: 149 | self.log.debug( 150 | "Matched '{.string}' to route '{.__name__}'" 151 | .format(func.keywords['match'], func.func)) 152 | 153 | await func() 154 | except self.StopRouting: 155 | self.log.info( 156 | "Routing was halted at {} at match '{}' for session {}" 157 | .format(func, func.keywords['match'].string, sess.uuid) 158 | ) 159 | break 160 | except Exception: 161 | self.log.exception( 162 | "Failed to exec {} on match '{.string}' for session {}" 163 | .format(func.func, func.keywords['match'], sess.uuid) 164 | ) 165 | if not handled and self.guard: 166 | self.log.warning("Rejecting session {}".format(sess.uuid)) 167 | await sess.hangup('NO_ROUTE_DESTINATION') 168 | 169 | @staticmethod 170 | async def bridge( 171 | sess, match, router, dest_url=None, out_profile=None, 172 | gateway=None, proxy=None 173 | ): 174 | """A handy generic bridging function. 175 | """ 176 | sess.bridge( 177 | # bridge back out the same profile if not specified 178 | # (the default action taken by bridge) 179 | profile=out_profile, 180 | gateway=gateway, 181 | dest_url=dest_url, # default is ${sip_req_uri} 182 | proxy=proxy, 183 | ) 184 | -------------------------------------------------------------------------------- /switchio/commands.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | """ 5 | Command wrappers and helpers 6 | """ 7 | 8 | 9 | def build_originate_cmd(dest_url, uuid_str=None, profile='external', 10 | gateway=None, # optional gw name 11 | # explicit app + args 12 | app_name='park', app_arg_str='', 13 | # dp app 14 | dp_exten=None, dp_type='xml', dp_context='default', 15 | proxy=None, # first hop uri 16 | endpoint='sofia', 17 | timeout=60, 18 | caller_id='Mr_switchio', 19 | caller_id_num='1112223333', 20 | codec='PCMU', 21 | abs_codec='', 22 | xheaders=None, 23 | **kwargs): 24 | '''Return a formatted `originate` command string conforming 25 | to the syntax dictated by mod_commands of the form: 26 | 27 | ``originate |&() [] 28 | [] [] [] []`` 29 | 30 | Parameters 31 | ---------- 32 | dest_url : str 33 | call destination url with format @: 34 | profile : str 35 | sofia profile (UA) name to use for making outbound call 36 | dp_extension: str 37 | destination dp extension where the originating session (a-leg) will 38 | processed just after the call is answered 39 | 40 | Returns 41 | ------- 42 | originate command : string or callable 43 | full cmd string if uuid_str is not None, 44 | else callable f(uuid_str) -> full cmd string 45 | ''' 46 | # default params setup 47 | params = { 48 | 'originate_timeout': timeout, 49 | 'origination_caller_id_name': caller_id, 50 | 'origination_caller_id_number': caller_id_num, 51 | 'originator_codec': codec, 52 | 'absolute_codec_string': abs_codec, 53 | # must fill this in using a format string placeholder 54 | 'origination_uuid': uuid_str or '{uuid_str}', 55 | 'ignore_display_updates': 'true', 56 | 'ignore_early_media': 'true', 57 | } 58 | 59 | # set a proxy destination if provided (i.e. the first hop) 60 | dest_str = ";fs_path=sip:{}".format(proxy) if proxy else '' 61 | # params['sip_network_destination'] = 62 | 63 | # generate any requested Xheaders 64 | if xheaders is not None: 65 | for name, val in xheaders.items(): 66 | if name.startswith('sip_h_'): 67 | params[name] = val 68 | else: 69 | params['{}{}'.format('sip_h_X-', name)] = val 70 | 71 | # override with user settings 72 | params.update(kwargs) 73 | 74 | # render params as strings 75 | pairs = ['='.join(map(str, pair)) for pair in params.items()] 76 | 77 | # user specified app? 78 | if dp_exten: # use dialplan app for outbound channel 79 | app_part = '{} {} {}'.format(dp_exten, dp_type, dp_context) 80 | else: # render app syntax 81 | app_part = '&{}({})'.format(app_name, app_arg_str) 82 | 83 | # render final cmd str 84 | profile = profile if gateway is None else 'gateway/{}'.format(gateway) 85 | call_url = '{}/{}/{}{}'.format(endpoint, profile, dest_url, dest_str) 86 | if not uuid_str: 87 | prefix_vars = '{{{{{params}}}}}'.format(params=','.join(pairs)) 88 | else: 89 | prefix_vars = '{{{params}}}'.format(params=','.join(pairs)) 90 | 91 | return 'originate {pv}{call_url} {app_part}'.format( 92 | pv=prefix_vars, call_url=call_url, app_part=app_part) 93 | -------------------------------------------------------------------------------- /switchio/distribute.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | """ 5 | Manage clients over a cluster of FreeSWITCH processes. 6 | """ 7 | from itertools import cycle 8 | from operator import add 9 | from functools import partial, reduce 10 | from .utils import compose 11 | 12 | 13 | class MultiEval(object): 14 | """Invoke arbitrary python expressions on a collection of objects 15 | """ 16 | def __init__(self, slaves, delegator=cycle, accessor='.'): 17 | self._slaves = slaves 18 | self._cache = {} 19 | self.accessor = accessor 20 | self.delegator = delegator 21 | self.attrs(slaves) # cache slaves iter 22 | for attr in filter(lambda n: '_' not in n[0], dir(slaves)): 23 | try: 24 | setattr(self.__class__, attr, getattr(self._slaves, attr)) 25 | except AttributeError: 26 | pass 27 | 28 | def attrs(self, obj): 29 | """Cache of obj attributes since python has no built in for getting 30 | them all... 31 | """ 32 | key = id(obj) 33 | cache = self._cache 34 | try: 35 | return cache[key] 36 | except KeyError: 37 | cache[key] = {name: getattr(obj, name) for name in dir(obj)} 38 | return cache[key] 39 | 40 | def __len__(self): 41 | return len(self._slaves) 42 | 43 | @property 44 | def nodes(self): 45 | return self._slaves 46 | 47 | def __iter__(self): 48 | """Deliver component tuples for each slave as per the delegator's 49 | servicing algorithm 50 | """ 51 | return self.delegator(self._slaves) 52 | 53 | def evals(self, expr, **kwargs): 54 | """Evaluate expression on all slave sub-components 55 | (Warning: this is the slowest call) 56 | 57 | Parameters 58 | ---------- 59 | expr: str 60 | python expression to evaluate on slave components 61 | """ 62 | # Somehow faster then bottom one? - I assume this may not be the 63 | # case with py3. It's also weird how lists are faster then tuples... 64 | return [eval(expr, self.attrs(item), kwargs) for item in self._slaves] 65 | # return [res for res in self.iterevals(expr, **kwargs)] 66 | 67 | def iterevals(self, expr, **kwargs): 68 | # TODO: should consider passing code blocks that can be compiled 69 | # and exec-ed such that we can generate properties on the fly 70 | return self.partial(expr, **kwargs)() 71 | 72 | def reducer(self, func, expr, itertype='', **kwargs): 73 | """Reduces the iter retured by `evals(expr)` into a single value 74 | using the reducer `func` 75 | """ 76 | # if callable(expr): 77 | # # expr is a partial ready to call 78 | # return compose(func, expr, **kwargs) 79 | # else: 80 | return compose(func, self.partial(expr, itertype=itertype), 81 | **kwargs) 82 | 83 | def folder(self, func, expr, **kwargs): 84 | """Same as reducer but takes in a binary function 85 | """ 86 | def fold(evals): 87 | return reduce(func, evals()) 88 | return partial(fold, self.partial(expr, **kwargs)) 89 | 90 | def partial(self, expr, **kwargs): 91 | """Return a partial which will eval bytcode compiled from `expr` 92 | """ 93 | itertype = kwargs.pop('itertype', '') 94 | if not isinstance(itertype, str): 95 | # handle types as well 96 | itertype = itertype.__name__ 97 | 98 | # namespace can contain kwargs which are referenced in `expr` 99 | ns = {'slaves': self._slaves} 100 | ns.update(kwargs) 101 | return partial( 102 | eval, 103 | compile("{}(item{}{} for item in slaves)" 104 | .format(itertype, self.accessor, expr), 105 | '', 'eval'), 106 | ns) 107 | 108 | 109 | def SlavePool(slaves): 110 | """A slave pool for controlling multiple (`Client`, `EventListener`) 111 | pairs with ease 112 | """ 113 | # turns out to be slightly faster (x2) then the reducer call below 114 | def fast_count(self): 115 | return sum(i.listener.count_calls() for i in self._slaves) 116 | 117 | attrs = { 118 | 'fast_count': fast_count, 119 | } 120 | # make a specialized instance 121 | sp = type('SlavePool', (MultiEval,), attrs)(slaves) 122 | 123 | # add other handy attrs 124 | for name in ('client', 'listener'): 125 | setattr(sp, 'iter_{}s'.format(name), sp.partial(name)) 126 | setattr(sp, '{}s'.format(name), sp.evals(name)) 127 | 128 | sp.hangup_causes_per_slave = sp.evals('listener.hangup_causes') 129 | sp.hangup_causes = partial(reduce, add, sp.hangup_causes_per_slave) 130 | sp.sessions_per_app_per_slave = sp.evals('listener.sessions_per_app') 131 | sp.sessions_per_app = partial(reduce, add, sp.sessions_per_app_per_slave) 132 | 133 | # small reduction protocol for 'multi-actions' 134 | for attr in ('calls', 'jobs', 'sessions', 'failed'): 135 | setattr( 136 | sp, 137 | 'count_{}'.format(attr), 138 | sp.reducer( 139 | sum, 140 | 'listener.count_{}()'.format(attr), 141 | itertype=list 142 | ) 143 | ) 144 | 145 | # figures it's slower then `causes` above... 146 | sp.aggr_causes = sp.folder( 147 | add, 148 | 'listener.hangup_causes', 149 | itertype=list 150 | ) 151 | return sp 152 | -------------------------------------------------------------------------------- /switchio/marks.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | """ 5 | Marks for annotating callback functions 6 | """ 7 | from functools import partial 8 | 9 | 10 | def extend_attr_list(obj, attr, items): 11 | try: 12 | getattr(obj, attr).extend(items) 13 | # current_items.extend(items) 14 | except AttributeError: 15 | setattr(obj, attr, list(items)) 16 | 17 | 18 | def marker(event_type, cb_type='callback', subscribe=()): 19 | """Decorator to mark a callback function 20 | for handling events of a particular type 21 | """ 22 | et_attr = 'switchio_init_events' 23 | es_attr = 'switchio_events_sub' 24 | cbt_attr = '_switchio_handler_type' 25 | 26 | def inner(handler): 27 | extend_attr_list(handler, et_attr, [event_type]) 28 | # append any additional subscriptions 29 | extend_attr_list(handler, es_attr, subscribe) 30 | setattr(handler, cbt_attr, cb_type) 31 | return handler 32 | 33 | return inner 34 | 35 | 36 | callback = event_callback = marker 37 | coroutine = partial(marker, cb_type='coroutine') 38 | handler = partial(marker, cb_type='handler') 39 | 40 | 41 | def has_callbacks(ns): 42 | """Check if this namespace contains switchio callbacks. 43 | 44 | :param ns namespace: the namespace object containing marked callbacks 45 | :rtype: bool 46 | """ 47 | return any(getattr(obj, 'switchio_init_events', False) for obj in 48 | vars(ns).values()) 49 | 50 | 51 | def get_callbacks(ns, skip=(), only=False): 52 | """Deliver all switchio callbacks found in a namespace object yielding 53 | event `handler` marked functions first followed by non-handlers such as 54 | callbacks and coroutines. 55 | 56 | :param ns namespace: the namespace object containing marked handlers 57 | :yields: event_type, callback_type, callback_obj 58 | """ 59 | non_handlers = [] 60 | for name in (name for name in dir(ns) if name not in skip): 61 | try: 62 | obj = object.__getattribute__(ns, name) 63 | except AttributeError: 64 | continue 65 | try: 66 | ev_types = getattr(obj, 'switchio_init_events', False) 67 | cb_type = getattr(obj, '_switchio_handler_type', None) 68 | except ReferenceError: # handle weakrefs 69 | continue 70 | 71 | if ev_types: # only marked attrs 72 | if not only or cb_type == only: 73 | for ev in ev_types: 74 | if cb_type == 'handler': # deliver handlers immediately 75 | yield ev, cb_type, obj 76 | else: 77 | non_handlers.append((ev, cb_type, obj)) 78 | else: # yield all non_handlers last 79 | for tup in non_handlers: 80 | yield tup 81 | -------------------------------------------------------------------------------- /switchio/serve.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | """ 5 | Server components for building clustered call processing systems. 6 | """ 7 | from . import utils 8 | from .apps import AppManager 9 | from .api import get_pool 10 | 11 | 12 | class Service(object): 13 | """Serve centralized, long running, call processing apps on top of a 14 | FreeSWITCH cluster. 15 | """ 16 | def __init__(self, contacts, **kwargs): 17 | kwargs.setdefault('call_tracking_header', 'variable_call_uuid') 18 | self.pool = get_pool(contacts, **kwargs) 19 | self.apps = AppManager(self.pool) 20 | self.host = self.pool.evals('listener.host') 21 | self.log = utils.get_logger(utils.pstr(self)) 22 | # initialize all reactor event loops 23 | self.pool.evals('listener.connect()') 24 | self.pool.evals('client.connect()') 25 | 26 | def run(self, block=True): 27 | """Run service optionally blocking until stopped. 28 | """ 29 | self.pool.evals('listener.start()') 30 | assert all(self.pool.evals('listener.is_alive()')) 31 | if block: 32 | try: 33 | self.pool.evals('listener.event_loop.wait()') 34 | except KeyboardInterrupt: 35 | pass 36 | finally: 37 | self.stop() 38 | 39 | def is_alive(self): 40 | """Return bool indicating if a least one event loop is alive. 41 | """ 42 | return any(self.pool.evals('listener.is_alive()')) 43 | 44 | def stop(self): 45 | """Stop service and disconnect. 46 | """ 47 | self.pool.evals('listener.disconnect()') 48 | self.pool.evals('listener.event_loop.wait(1)') 49 | -------------------------------------------------------------------------------- /switchio/sync.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | """ 5 | Make calls synchronously 6 | """ 7 | from contextlib import contextmanager 8 | from switchio.apps.players import TonePlay 9 | from switchio.api import get_client 10 | 11 | 12 | @contextmanager 13 | def sync_caller(host, port='8021', password='ClueCon', 14 | apps={'TonePlay': TonePlay}): 15 | '''Deliver a provisioned synchronous caller function. 16 | 17 | A caller let's you make a call synchronously returning control once 18 | it has entered a stable state. The caller returns the active originating 19 | `Session` and a `waitfor` blocker method as output. 20 | ''' 21 | with get_client(host, port=port, auth=password, apps=apps) as client: 22 | 23 | def caller(dest_url, app_name, timeout=30, waitfor=None, 24 | **orig_kwargs): 25 | # override the channel variable used to look up the intended 26 | # switchio app to be run for this call 27 | if caller.app_lookup_vars: 28 | client.listener.app_id_vars.extend(caller.app_lookup_vars) 29 | 30 | job = client.originate(dest_url, app_id=app_name, **orig_kwargs) 31 | job.get(timeout) 32 | if not job.successful(): 33 | raise job.result 34 | call = client.listener.sessions[job.sess_uuid].call 35 | orig_sess = call.first # first sess is the originator 36 | if waitfor: 37 | var, time = waitfor 38 | client.listener.event_loop.waitfor(orig_sess, var, time) 39 | 40 | return orig_sess, client.listener.event_loop.waitfor 41 | 42 | # attach apps handle for easy interactive use 43 | caller.app_lookup_vars = [] 44 | caller.apps = client.apps 45 | caller.client = client 46 | caller.app_names = client._apps.keys() 47 | yield caller 48 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/friends-of-freeswitch/switchio/dee6e9addcf881b2b411ec1dbb397b0acfbb78cf/tests/__init__.py -------------------------------------------------------------------------------- /tests/apps/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Apps testing 3 | """ 4 | import pytest 5 | import switchio 6 | 7 | 8 | @pytest.yield_fixture 9 | def get_orig(request, fsip): 10 | '''An `Originator` factory which delivers instances configured to route 11 | calls back to the originating sip profile (i.e. in "loopback"). 12 | ''' 13 | origs = [] 14 | 15 | def factory(userpart, port=5080, limit=1, rate=1, offer=1, **kwargs): 16 | orig = switchio.get_originator( 17 | fsip, 18 | limit=limit, 19 | rate=rate, 20 | max_offered=offer, 21 | **kwargs 22 | ) 23 | 24 | # each profile should originate calls back to itself 25 | # to avoid dependency on another server 26 | orig.pool.evals( 27 | ("""client.set_orig_cmd('{}@{}:{}'.format( 28 | userpart, client.host, port), app_name='park')"""), 29 | userpart=userpart, 30 | port=port, 31 | ) 32 | origs.append(orig) 33 | return orig 34 | 35 | yield factory 36 | for orig in origs: 37 | orig.shutdown() 38 | -------------------------------------------------------------------------------- /tests/apps/test_originator.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | ''' 5 | `Originator` testing 6 | 7 | .. note:: 8 | these tests assume that the `external` sip profile's context 9 | has been assigned to the switchio dialplan. 10 | ''' 11 | from __future__ import division 12 | import time 13 | import math 14 | import pytest 15 | from switchio.apps import dtmf, players 16 | 17 | 18 | def test_dialer_state(get_orig): 19 | """Verify dialer state changes based on its API. 20 | """ 21 | dialer = get_orig('you', offer=float('inf')) 22 | dialer.load_app(players.TonePlay) 23 | dialer.duration = 0 # don't auto-hangup 24 | 25 | # ensure intial state interface 26 | assert dialer.check_state("INITIAL") 27 | 28 | # verify initial internal event states 29 | assert not dialer._start.is_set() 30 | assert not dialer._burst.is_set() 31 | assert not dialer._exit.is_set() 32 | 33 | dialer.start() 34 | time.sleep(0.3) 35 | assert not dialer._start.is_set() 36 | assert not dialer.stopped() 37 | assert dialer._burst.is_set() 38 | assert dialer.check_state("ORIGINATING") 39 | 40 | dialer.hupall() 41 | dialer.waitforstate('STOPPED', timeout=5) 42 | assert dialer.check_state("STOPPED") 43 | assert dialer.stopped() 44 | assert not dialer._start.is_set() 45 | assert not dialer._burst.is_set() 46 | 47 | 48 | def test_rep_fields(get_orig): 49 | """Test replacement fields within originate commands 50 | """ 51 | ret = {'field': 'kitty'} 52 | orig = get_orig('{field}', rep_fields_func=lambda: ret) 53 | orig.load_app(players.TonePlay) 54 | orig.duration = 0 # don't auto-hangup 55 | # check userpart passthrough 56 | assert 'sofia/external/{field}' in orig.originate_cmd[0] 57 | assert orig.rep_fields_func() == ret 58 | 59 | # verify invalid field causes failure 60 | orig.rep_fields_func = lambda: {'invalidname': 'kitty'} 61 | orig.start() 62 | time.sleep(0.2) 63 | # burst loop thread should fail due to missing 'field' kwarg to str.format 64 | assert orig.stopped() 65 | 66 | # verify field replacement func 67 | client = orig.pool.clients[0] 68 | listener = orig.pool.listeners[0] 69 | # set dest url and call associating xheader to our replaceable field 70 | ident = "{}@{}:{}".format('doggy', client.host, 5080) 71 | client.set_orig_cmd('{field}', 72 | xheaders={client.call_tracking_header: "{field}"}) 73 | orig.rep_fields_func = lambda: {'field': ident} 74 | orig.max_offered += 1 75 | orig.start() 76 | time.sleep(0.05) 77 | assert ident in listener.calls # since we replaced the call id xheader 78 | listener.calls[ident].hangup() 79 | time.sleep(0.05) 80 | assert orig.count_calls() == 0 81 | 82 | 83 | def test_dtmf_passthrough(get_orig): 84 | '''Test the dtmf app in coordination with the originator 85 | ''' 86 | orig = get_orig('doggy', offer=1) 87 | orig.load_app(dtmf.DtmfChecker) 88 | orig.duration = 0 89 | orig.start() 90 | checker = orig.pool.clients[0].apps.DtmfChecker['DtmfChecker'] 91 | time.sleep(checker.total_time + 1) 92 | orig.stop() 93 | assert not any( 94 | orig.pool.evals("client.apps.DtmfChecker['DtmfChecker'].incomplete")) 95 | assert not any( 96 | orig.pool.evals("client.apps.DtmfChecker['DtmfChecker'].failed")) 97 | assert orig.state == "STOPPED" 98 | 99 | 100 | @pytest.mark.skip(reason='record events broken on FS 1.6+') 101 | def test_convo_sim(get_orig): 102 | """Test the `PlayRec` app when used for a load test with the `Originator` 103 | """ 104 | recs = [] 105 | 106 | def count(recinfo): 107 | recs.append(recinfo) 108 | 109 | orig = get_orig('doggy') 110 | orig.load_app( 111 | players.PlayRec, 112 | ppkwargs={ 113 | 'rec_stereo': True, 114 | 'callback': count, 115 | } 116 | ) 117 | # manual app reference retrieval 118 | playrec = orig.pool.nodes[0].client.apps.PlayRec['PlayRec'] 119 | 120 | # verify dynamic load settings modify playrec settings 121 | orig.rate = 20 122 | orig.limit = orig.max_offered = 100 123 | playrec.rec_period = 2.0 124 | assert playrec.iterations * playrec.clip_length + playrec.tail == orig.duration 125 | 126 | orig.start() 127 | # ensure calls are set up fast enough 128 | start = time.time() 129 | time.sleep(float(orig.limit / orig.rate) + 1.0) 130 | stop = time.time() 131 | assert orig.pool.count_calls() == orig.limit 132 | 133 | # wait for all calls to end 134 | orig.waitwhile(timeout=30) 135 | # ensure number of calls recorded matches the rec period 136 | assert float(len(recs)) == math.floor((stop - start) / playrec.rec_period) 137 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | import os 5 | import sys 6 | import socket 7 | import itertools 8 | import pytest 9 | from distutils import spawn 10 | from switchio import utils 11 | 12 | 13 | def pytest_addoption(parser): 14 | '''Add server options for pointing to the engine we will use for testing 15 | ''' 16 | parser.addoption("--fshost", action="store", dest='fshost', 17 | default=None, 18 | help="fs-engine server host or ip") 19 | parser.addoption("--fsport", action="store", dest='fsport', 20 | default=5080, 21 | help="fs-engine contact port") 22 | parser.addoption("--cps", action="store", dest='cps', 23 | default=100, 24 | help="num of sipp calls to launch per second") 25 | parser.addoption("--use-docker", action="store_true", dest='usedocker', 26 | help="Toggle use of docker containers for testing") 27 | parser.addoption("--num-containers", action="store", dest='ncntrs', 28 | default=2, help="Number of docker containers to spawn") 29 | 30 | 31 | @pytest.fixture(scope='session') 32 | def travis(): 33 | return os.environ.get('TRAVIS', False) 34 | 35 | 36 | @pytest.fixture(scope='session', autouse=True) 37 | def loglevel(request): 38 | level = max(40 - request.config.option.verbose * 10, 5) 39 | if sys.stdout.isatty(): 40 | # enable console logging 41 | utils.log_to_stderr(level) 42 | 43 | return level 44 | 45 | 46 | @pytest.fixture(scope='session', autouse=True) 47 | def log(loglevel): 48 | return utils.log_to_stderr(loglevel) 49 | 50 | 51 | @pytest.fixture(scope='session') 52 | def projectdir(): 53 | dirname = os.path.dirname 54 | return os.path.abspath(dirname(dirname(os.path.realpath(__file__)))) 55 | 56 | 57 | @pytest.fixture(scope='session') 58 | def containers(request, projectdir): 59 | """Return a sequence of docker containers. 60 | """ 61 | freeswitch_conf_dir = os.path.join(projectdir, 'conf/ci-minimal/') 62 | freeswitch_sounds_dir = os.path.join(projectdir, 'freeswitch-sounds/') 63 | if request.config.option.usedocker: 64 | docker = request.getfixturevalue('dockerctl') 65 | with docker.run( 66 | 'safarov/freeswitch:latest', 67 | volumes={ 68 | freeswitch_conf_dir: {'bind': '/etc/freeswitch/'}, 69 | freeswitch_sounds_dir: {'bind': '/usr/share/freeswitch/sounds'}, 70 | }, 71 | environment={'SOUND_RATES': '8000:16000', 72 | 'SOUND_TYPES': 'music:en-us-callie'}, 73 | num=request.config.option.ncntrs 74 | ) as containers: 75 | yield containers 76 | else: 77 | pytest.skip( 78 | "You must specify `--use-docker` to activate containers") 79 | 80 | 81 | @pytest.fixture(scope='session') 82 | def fshosts(request, log): 83 | '''Return the FS test server hostnames passed via the 84 | ``--fshost`` cmd line arg. 85 | ''' 86 | argstring = request.config.option.fshost 87 | addrs = [] 88 | 89 | if argstring: 90 | # construct a list if passed as arg 91 | fshosts = argstring.split(',') 92 | yield fshosts 93 | 94 | elif request.config.option.usedocker: 95 | containers = request.getfixturevalue('containers') 96 | for container in containers: 97 | ipaddr = container.attrs['NetworkSettings']['IPAddress'] 98 | addrs.append(ipaddr) 99 | log.info( 100 | "FS container @ {} access: docker exec -ti {} fs_cli" 101 | .format(ipaddr, container.short_id) 102 | ) 103 | yield addrs 104 | 105 | else: 106 | pytest.skip("the '--fshost' or '--use-docker` options are required " 107 | "to determine the FreeSWITCH server(s) to connect " 108 | "to for testing") 109 | 110 | 111 | @pytest.fixture(scope='session') 112 | def fs_ip_addrs(fshosts): 113 | '''Convert provided host names to ip addrs via dns. 114 | ''' 115 | return list(map(utils.ncompose( 116 | socket.gethostbyname, socket.getfqdn), fshosts)) 117 | 118 | 119 | @pytest.fixture(scope='session') 120 | def fs_socks(request, fshosts): 121 | '''Return the fshost,fsport values as tuple (str, int). 122 | Use port 5080 (fs external profile) by default. 123 | ''' 124 | return list(zip(fshosts, itertools.repeat(request.config.option.fsport))) 125 | 126 | 127 | @pytest.fixture(scope='session') 128 | def fshost(fshosts): 129 | return fshosts[0] 130 | 131 | 132 | @pytest.fixture(scope='module') 133 | def fsip(fs_ip_addrs): 134 | return fs_ip_addrs[0] 135 | 136 | 137 | @pytest.fixture(scope='module') 138 | def fssock(fs_socks): 139 | return fs_socks[0] 140 | 141 | 142 | @pytest.fixture 143 | def cps(request, travis): 144 | """It appears as though fs can deliver channel create events at 145 | around 250 cps (don't know if we can even track faster 146 | then this) IF the calls are bridged directly using an xml 147 | dialplan (can get close with a pure esl dp and a fast server). 148 | Bridging them using the proxy_dp fixture above we can only 149 | get around 165 for slow servers... 150 | """ 151 | cps = int(request.config.option.cps) 152 | return cps if not travis else 80 153 | 154 | 155 | @pytest.yield_fixture 156 | def con(fshost): 157 | '''Deliver a esl connection to fshost 158 | ''' 159 | from switchio.connection import get_connection 160 | with get_connection(fshost) as con: 161 | yield con 162 | 163 | 164 | @pytest.yield_fixture 165 | def el(fshost): 166 | 'deliver a connected event listener' 167 | from switchio import get_listener 168 | listener = get_listener(fshost) 169 | el = listener.event_loop 170 | assert not el.connected() 171 | yield listener 172 | el.disconnect() 173 | # verify state 174 | assert not el.connected() 175 | assert not el.is_alive() 176 | 177 | 178 | @pytest.yield_fixture 179 | def client(fshost): 180 | """Deliver a core.Client connected to fshost 181 | """ 182 | from switchio import Client 183 | cl = Client(fshost) 184 | yield cl 185 | cl.disconnect() 186 | assert not cl.connected() 187 | 188 | 189 | @pytest.fixture 190 | def scenarios(request, fs_socks, loglevel): 191 | '''Provision and return a SIPp scenario with the remote proxy set to the 192 | current FS server. 193 | ''' 194 | sipp = spawn.find_executable('sipp') 195 | if not sipp: 196 | pytest.skip("SIPp is required to run call/speed tests") 197 | 198 | try: 199 | import pysipp 200 | except ImportError: 201 | pytest.skip("pysipp is required to run call/speed tests") 202 | 203 | pl = pysipp.utils.get_logger() 204 | pl.setLevel(loglevel) 205 | 206 | if request.config.option.usedocker: 207 | # use the docker 'bridge' network gateway address 208 | bind_addr = request.getfixturevalue( 209 | 'containers')[0].attrs['NetworkSettings']['Gateway'] 210 | else: 211 | # grab IP from DNS lookup 212 | bind_addr = socket.getaddrinfo( 213 | socket.getfqdn(), 0, socket.AF_INET, socket.SOCK_DGRAM)[0][4][0] 214 | 215 | scens = [] 216 | for fssock in fs_socks: 217 | # first hop should be fs server 218 | scen = pysipp.scenario( 219 | proxyaddr=fssock, 220 | defaults={'local_host': bind_addr} 221 | ) 222 | scen.log = pl 223 | 224 | # set client destination 225 | # NOTE: you must add a park extension to your default dialplan! 226 | scen.agents['uac'].uri_username = 'park' 227 | scens.append(scen) 228 | 229 | return scens 230 | 231 | 232 | @pytest.fixture 233 | def scenario(scenarios): 234 | return scenarios[0] 235 | -------------------------------------------------------------------------------- /tests/data/eventstream.txt: -------------------------------------------------------------------------------- 1 | Content-Type: auth/request 2 | 3 | Content-Type: command/reply 4 | Reply-Text: +OK accepted 5 | 6 | Content-Type: command/reply 7 | Reply-Text: +OK bye 8 | 9 | Content-Length: 67 10 | Content-Type: text/disconnect-notice 11 | 12 | Disconnected, goodbye. 13 | See you at ClueCon! http://www.cluecon.com/ 14 | 15 | Content-Length: 5407 16 | Content-Type: text/event-plain 17 | 18 | Event-Name: CHANNEL_CREATE 19 | Core-UUID: ed56dab6-a6fc-11e4-960f-6f83a2e5e50a 20 | FreeSWITCH-Hostname: evoluxdev 21 | FreeSWITCH-Switchname: evoluxdev 22 | FreeSWITCH-IPv4: 172.16.7.69 23 | FreeSWITCH-IPv6: ::1 24 | Event-Date-Local: 2015-01-28 15:00:44 25 | Event-Date-GMT: Wed, 28 Jan 2015 18:00:44 GMT 26 | Event-Date-Timestamp: 1422468044671081 27 | Event-Calling-File: switch_core_state_machine.c 28 | Event-Calling-Function: switch_core_session_run 29 | Event-Calling-Line-Number: 509 30 | Event-Sequence: 3372 31 | Channel-State: CS_INIT 32 | Channel-Call-State: DOWN 33 | Channel-State-Number: 2 34 | Channel-Name: sofia/internal/100@192.168.50.4 35 | Unique-ID: d0b1da34-a727-11e4-9728-6f83a2e5e50a 36 | Call-Direction: inbound 37 | Presence-Call-Direction: inbound 38 | Channel-HIT-Dialplan: true 39 | Channel-Presence-ID: 100@192.168.50.4 40 | Channel-Call-UUID: d0b1da34-a727-11e4-9728-6f83a2e5e50a 41 | Answer-State: ringing 42 | Caller-Direction: inbound 43 | Caller-Logical-Direction: inbound 44 | Caller-Username: 100 45 | Caller-Dialplan: XML 46 | Caller-Caller-ID-Name: edev - 100 47 | Caller-Caller-ID-Number: 100 48 | Caller-Orig-Caller-ID-Name: edev - 100 49 | Caller-Orig-Caller-ID-Number: 100 50 | Caller-Network-Addr: 192.168.50.1 51 | Caller-ANI: 100 52 | Caller-Destination-Number: 101 53 | Caller-Unique-ID: d0b1da34-a727-11e4-9728-6f83a2e5e50a 54 | Caller-Source: mod_sofia 55 | Caller-Context: out-extensions 56 | Caller-Channel-Name: sofia/internal/100@192.168.50.4 57 | Caller-Profile-Index: 1 58 | Caller-Profile-Created-Time: 1422468044671081 59 | Caller-Channel-Created-Time: 1422468044671081 60 | Caller-Channel-Answered-Time: 0 61 | Caller-Channel-Progress-Time: 0 62 | Caller-Channel-Progress-Media-Time: 0 63 | Caller-Channel-Hangup-Time: 0 64 | Caller-Channel-Transfer-Time: 0 65 | Caller-Channel-Resurrect-Time: 0 66 | Caller-Channel-Bridged-Time: 0 67 | Caller-Channel-Last-Hold: 0 68 | Caller-Channel-Hold-Accum: 0 69 | Caller-Screen-Bit: true 70 | Caller-Privacy-Hide-Name: false 71 | Caller-Privacy-Hide-Number: false 72 | variable_direction: inbound 73 | variable_uuid: d0b1da34-a727-11e4-9728-6f83a2e5e50a 74 | variable_call_uuid: d0b1da34-a727-11e4-9728-6f83a2e5e50a 75 | variable_session_id: 9 76 | variable_sip_from_user: 100 77 | variable_sip_from_uri: 100@192.168.50.4 78 | variable_sip_from_host: 192.168.50.4 79 | variable_channel_name: sofia/internal/100@192.168.50.4 80 | variable_sip_call_id: 6bG.Hj5UCe8pDFEy1R9FO8EIfHtKrZ3H 81 | variable_ep_codec_string: GSM@8000h@20i@13200b,PCMU@8000h@20i@64000b,PCMA@8000h@20i@64000b,G722@8000h@20i@64000b 82 | variable_sip_local_network_addr: 192.168.50.4 83 | variable_sip_network_ip: 192.168.50.1 84 | variable_sip_network_port: 58588 85 | variable_sip_received_ip: 192.168.50.1 86 | variable_sip_received_port: 58588 87 | variable_sip_via_protocol: udp 88 | variable_sip_authorized: true 89 | variable_Event-Name: REQUEST_PARAMS 90 | variable_Core-UUID: ed56dab6-a6fc-11e4-960f-6f83a2e5e50a 91 | variable_FreeSWITCH-Hostname: evoluxdev 92 | variable_FreeSWITCH-Switchname: evoluxdev 93 | variable_FreeSWITCH-IPv4: 172.16.7.69 94 | variable_FreeSWITCH-IPv6: ::1 95 | variable_Event-Date-Local: 2015-01-28 15:00:44 96 | variable_Event-Date-GMT: Wed, 28 Jan 2015 18:00:44 GMT 97 | variable_Event-Date-Timestamp: 1422468044671081 98 | variable_Event-Calling-File: sofia.c 99 | variable_Event-Calling-Function: sofia_handle_sip_i_invite 100 | variable_Event-Calling-Line-Number: 8539 101 | variable_Event-Sequence: 3368 102 | variable_sip_number_alias: 100 103 | variable_sip_auth_username: 100 104 | variable_sip_auth_realm: 192.168.50.4 105 | variable_number_alias: 100 106 | variable_requested_domain_name: 192.168.50.4 107 | variable_record_stereo: true 108 | variable_transfer_fallback_extension: operator 109 | variable_toll_allow: celular_ddd,celular_local,fixo_ddd,fixo_local,ligar_para_outro_ramal,ramais_evolux_office 110 | variable_evolux_cc_position: 100 111 | variable_user_context: out-extensions 112 | variable_accountcode: dev 113 | variable_callgroup: dev 114 | variable_effective_caller_id_name: Evolux 100 115 | variable_effective_caller_id_number: 100 116 | variable_outbound_caller_id_name: Dev 117 | variable_outbound_caller_id_number: 0000000000 118 | variable_user_name: 100 119 | variable_domain_name: 192.168.50.4 120 | variable_sip_from_user_stripped: 100 121 | variable_sip_from_tag: ocZZPAo1FTdXA10orlmCaYeqc4mzYem1 122 | variable_sofia_profile_name: internal 123 | variable_recovery_profile_name: internal 124 | variable_sip_full_via: SIP/2.0/UDP 172.16.7.70:58588;rport=58588;branch=z9hG4bKPj-0Wi47Dyiq1mz3t.Bm8aluRrPEHF7-6C;received=192.168.50.1 125 | variable_sip_from_display: edev - 100 126 | variable_sip_full_from: "edev - 100" ;tag=ocZZPAo1FTdXA10orlmCaYeqc4mzYem1 127 | variable_sip_full_to: 128 | variable_sip_req_user: 101 129 | variable_sip_req_uri: 101@192.168.50.4 130 | variable_sip_req_host: 192.168.50.4 131 | variable_sip_to_user: 101 132 | variable_sip_to_uri: 101@192.168.50.4 133 | variable_sip_to_host: 192.168.50.4 134 | variable_sip_contact_params: ob 135 | variable_sip_contact_user: 100 136 | variable_sip_contact_port: 58588 137 | variable_sip_contact_uri: 100@192.168.50.1:58588 138 | variable_sip_contact_host: 192.168.50.1 139 | variable_rtp_use_codec_string: G722,PCMA,PCMU,GSM,G729 140 | variable_sip_user_agent: Telephone 1.1.4 141 | variable_sip_via_host: 172.16.7.70 142 | variable_sip_via_port: 58588 143 | variable_sip_via_rport: 58588 144 | variable_max_forwards: 70 145 | variable_presence_id: 100@192.168.50.4 146 | variable_switch_r_sdp: v=0 147 | o=- 3631463817 3631463817 IN IP4 172.16.7.70 148 | s=pjmedia 149 | b=AS:84 150 | t=0 0 151 | a=X-nat:0 152 | m=audio 4016 RTP/AVP 103 102 104 109 3 0 8 9 101 153 | c=IN IP4 172.16.7.70 154 | b=AS:64000 155 | a=rtpmap:103 speex/16000 156 | a=rtpmap:102 speex/8000 157 | a=rtpmap:104 speex/32000 158 | a=rtpmap:109 iLBC/8000 159 | a=fmtp:109 mode=30 160 | a=rtpmap:3 GSM/8000 161 | a=rtpmap:0 PCMU/8000 162 | a=rtpmap:8 PCMA/8000 163 | a=rtpmap:9 G722/8000 164 | a=rtpmap:101 telephone-event/8000 165 | a=fmtp:101 0-15 166 | a=rtcp:4017 IN IP4 172.16.7.70 167 | 168 | -------------------------------------------------------------------------------- /tests/data/eventstream2.txt: -------------------------------------------------------------------------------- 1 | Content-Type: command/reply 2 | Reply-Text: +OK Job-UUID: 1246016f-ea2f-4cba-a018-16443bcadc6f 3 | Job-UUID: 1246016f-ea2f-4cba-a018-16443bcadc6f 4 | 5 | Content-Length: 1048 6 | Content-Type: text/event-plain 7 | 8 | -- 9 | Event-Name: BACKGROUND_JOB 10 | Core-UUID: 679056bb-0d5f-4045-8819-e0a886f6c2c5 11 | FreeSWITCH-Hostname: goodboy-xps 12 | FreeSWITCH-Switchname: goodboy-xps 13 | FreeSWITCH-IPv4: 192.168.0.105 14 | FreeSWITCH-IPv6: %3A%3A1 15 | Event-Date-Local: 2017-08-18%2003%3A58%3A55 16 | Event-Date-GMT: Fri,%2018%20Aug%202017%2003%3A58%3A55%20GMT 17 | Event-Date-Timestamp: 1503028735079552 18 | Event-Calling-File: mod_event_socket.c 19 | Event-Calling-Function: api_exec 20 | Event-Calling-Line-Number: 1557 21 | Event-Sequence: 1633 22 | Job-UUID: 86b51ad6-ebb9-4bca-8a41-f28986dde3e4 23 | Job-Command: originate 24 | Job-Command-Arg: %7Boriginate_timeout%3D60,origination_caller_id_name%3DMr_Switchy,origination_caller_id_number%3D1112223333,originator_codec%3DPCMU,absolute_codec_string%3D,origination_uuid%3D8de782e0-83c9-11e7-af1b-001500e3e25c,ignore_display_updates%3Dtrue,ignore_early_media%3Dtrue,sip_h_X-switchy_originating_session%3D8de782e0-83c9-11e7-af1b-001500e3e25c,sip_h_X-switchy_app%3DTonePlay%7Dsofia/external/doggy%40192.168.0.105%3A5080%20%26park() 25 | Content-Length: 41 26 | 27 | +OK 8de782e0-83c9-11e7-af1b-001500e3e25c 28 | Content-Length: 6516 29 | Content-Type: text/event-plain 30 | 31 | Event-Name: CHANNEL_PARK 32 | Core-UUID: 679056bb-0d5f-4045-8819-e0a886f6c2c5 33 | FreeSWITCH-Hostname: goodboy-xps 34 | FreeSWITCH-Switchname: goodboy-xps 35 | FreeSWITCH-IPv4: 192.168.0.105 36 | FreeSWITCH-IPv6: %3A%3A1 37 | Event-Date-Local: 2017-08-18%2003%3A58%3A55 38 | Event-Date-GMT: Fri,%2018%20Aug%202017%2003%3A58%3A55%20GMT 39 | Event-Date-Timestamp: 1503028735118169 40 | Event-Calling-File: switch_ivr.c 41 | Event-Calling-Function: switch_ivr_park 42 | Event-Calling-Line-Number: 950 43 | Event-Sequence: 1635 44 | Channel-State: CS_EXECUTE 45 | Channel-Call-State: ACTIVE 46 | Channel-State-Number: 4 47 | Channel-Name: sofia/external/doggy%40192.168.0.105%3A5080 48 | Unique-ID: 8de782e0-83c9-11e7-af1b-001500e3e25c 49 | Call-Direction: outbound 50 | Presence-Call-Direction: outbound 51 | Channel-HIT-Dialplan: true 52 | Channel-Call-UUID: 8de782e0-83c9-11e7-af1b-001500e3e25c 53 | Answer-State: answered 54 | Channel-Read-Codec-Name: PCMU 55 | Channel-Read-Codec-Rate: 8000 56 | Channel-Read-Codec-Bit-Rate: 64000 57 | Channel-Write-Codec-Name: PCMU 58 | Channel-Write-Codec-Rate: 8000 59 | Channel-Write-Codec-Bit-Rate: 64000 60 | Caller-Direction: outbound 61 | Caller-Logical-Direction: outbound 62 | Caller-Caller-ID-Name: Outbound%20Call 63 | Caller-Caller-ID-Number: doggy 64 | Caller-Orig-Caller-ID-Name: Mr_Switchy 65 | Caller-Orig-Caller-ID-Number: 1112223333 66 | Caller-Callee-ID-Name: Mr_Switchy 67 | Caller-Callee-ID-Number: 1112223333 68 | Caller-Network-Addr: 192.168.0.105 69 | Caller-ANI: 1112223333 70 | Caller-Destination-Number: doggy 71 | Caller-Unique-ID: 8de782e0-83c9-11e7-af1b-001500e3e25c 72 | Caller-Source: src/switch_ivr_originate.c 73 | Caller-Context: default 74 | Caller-Channel-Name: sofia/external/doggy%40192.168.0.105%3A5080 75 | Caller-Profile-Index: 1 76 | Caller-Profile-Created-Time: 1503028735058154 77 | Caller-Channel-Created-Time: 1503028735058154 78 | Caller-Channel-Answered-Time: 1503028735079552 79 | Caller-Channel-Progress-Time: 0 80 | Caller-Channel-Progress-Media-Time: 0 81 | Caller-Channel-Hangup-Time: 0 82 | Caller-Channel-Transfer-Time: 0 83 | Caller-Channel-Resurrect-Time: 0 84 | Caller-Channel-Bridged-Time: 0 85 | Caller-Channel-Last-Hold: 0 86 | Caller-Channel-Hold-Accum: 0 87 | Caller-Screen-Bit: true 88 | Caller-Privacy-Hide-Name: false 89 | Caller-Privacy-Hide-Number: false 90 | variable_direction: outbound 91 | variable_is_outbound: true 92 | variable_uuid: 8de782e0-83c9-11e7-af1b-001500e3e25c 93 | variable_session_id: 67 94 | variable_sip_profile_name: external 95 | variable_video_media_flow: sendrecv 96 | variable_audio_media_flow: sendrecv 97 | variable_channel_name: sofia/external/doggy%40192.168.0.105%3A5080 98 | variable_sip_destination_url: sip%3Adoggy%40192.168.0.105%3A5080 99 | variable_originate_timeout: 60 100 | variable_origination_caller_id_name: Mr_Switchy 101 | variable_origination_caller_id_number: 1112223333 102 | variable_originator_codec: PCMU 103 | variable_origination_uuid: 8de782e0-83c9-11e7-af1b-001500e3e25c 104 | variable_ignore_display_updates: true 105 | variable_ignore_early_media: true 106 | variable_sip_h_X-switchy_originating_session: 8de782e0-83c9-11e7-af1b-001500e3e25c 107 | variable_sip_h_X-switchy_app: TonePlay 108 | variable_originate_early_media: false 109 | variable_rtp_local_sdp_str: v%3D0%0D%0Ao%3DFreeSWITCH%201503007465%201503007466%20IN%20IP4%20192.168.0.105%0D%0As%3DFreeSWITCH%0D%0Ac%3DIN%20IP4%20192.168.0.105%0D%0At%3D0%200%0D%0Am%3Daudio%2021270%20RTP/AVP%200%20101%2013%0D%0Aa%3Drtpmap%3A0%20PCMU/8000%0D%0Aa%3Drtpmap%3A101%20telephone-event/8000%0D%0Aa%3Dfmtp%3A101%200-16%0D%0Aa%3Drtpmap%3A13%20CN/8000%0D%0Aa%3Dptime%3A20%0D%0Aa%3Dsendrecv%0D%0A 110 | variable_sip_outgoing_contact_uri: %3Csip%3Amod_sofia%40192.168.0.105%3A5080%3E 111 | variable_sip_req_uri: doggy%40192.168.0.105%3A5080 112 | variable_sofia_profile_name: external 113 | variable_recovery_profile_name: external 114 | variable_sip_local_network_addr: 192.168.0.105 115 | variable_sip_reply_host: 192.168.0.105 116 | variable_sip_reply_port: 5080 117 | variable_sip_network_ip: 192.168.0.105 118 | variable_sip_network_port: 5080 119 | variable_ep_codec_string: CORE_PCM_MODULE.PCMU%408000h%4020i%4064000b 120 | variable_sip_user_agent: FreeSWITCH-mod_sofia/1.6.18-35-6e79667~64bit 121 | variable_sip_allow: INVITE,%20ACK,%20BYE,%20CANCEL,%20OPTIONS,%20MESSAGE,%20INFO,%20UPDATE,%20REGISTER,%20REFER,%20NOTIFY 122 | variable_sip_recover_contact: %3Csip%3Adoggy%40192.168.0.105%3A5080%3Btransport%3Dudp%3E 123 | variable_sip_full_via: SIP/2.0/UDP%20192.168.0.105%3A5080%3Brport%3D5080%3Bbranch%3Dz9hG4bK56ZN518c10X2H 124 | variable_sip_recover_via: SIP/2.0/UDP%20192.168.0.105%3A5080%3Brport%3D5080%3Bbranch%3Dz9hG4bK56ZN518c10X2H 125 | variable_sip_from_display: Mr_Switchy 126 | variable_sip_full_from: %22Mr_Switchy%22%20%3Csip%3A1112223333%40192.168.0.105%3E%3Btag%3DXeSpyN1ZjBS0H 127 | variable_sip_full_to: %3Csip%3Adoggy%40192.168.0.105%3A5080%3E%3Btag%3DyQjF0gj3FmFKD 128 | variable_sip_from_user: 1112223333 129 | variable_sip_from_uri: 1112223333%40192.168.0.105 130 | variable_sip_from_host: 192.168.0.105 131 | variable_sip_to_user: doggy 132 | variable_sip_to_port: 5080 133 | variable_sip_to_uri: doggy%40192.168.0.105%3A5080 134 | variable_sip_to_host: 192.168.0.105 135 | variable_sip_contact_params: transport%3Dudp 136 | variable_sip_contact_user: doggy 137 | variable_sip_contact_port: 5080 138 | variable_sip_contact_uri: doggy%40192.168.0.105%3A5080 139 | variable_sip_contact_host: 192.168.0.105 140 | variable_sip_to_tag: yQjF0gj3FmFKD 141 | variable_sip_from_tag: XeSpyN1ZjBS0H 142 | variable_sip_cseq: 111178303 143 | variable_sip_call_id: 6549aa98-fe6c-1235-57b3-001500e3e25c 144 | variable_switch_r_sdp: v%3D0%0D%0Ao%3DFreeSWITCH%201503003729%201503003730%20IN%20IP4%20192.168.0.105%0D%0As%3DFreeSWITCH%0D%0Ac%3DIN%20IP4%20192.168.0.105%0D%0At%3D0%200%0D%0Am%3Daudio%2025006%20RTP/AVP%200%20101%2013%0D%0Aa%3Drtpmap%3A0%20PCMU/8000%0D%0Aa%3Drtpmap%3A101%20telephone-event/8000%0D%0Aa%3Dfmtp%3A101%200-16%0D%0Aa%3Drtpmap%3A13%20CN/8000%0D%0Aa%3Dptime%3A20%0D%0A 145 | variable_rtp_use_codec_string: PCMU 146 | variable_rtp_audio_recv_pt: 0 147 | variable_rtp_use_codec_name: PCMU 148 | variable_rtp_use_codec_rate: 8000 149 | variable_rtp_use_codec_ptime: 20 150 | variable_rtp_use_codec_channels: 1 151 | variable_rtp_last_audio_codec_string: PCMU%408000h%4020i%401c 152 | variable_read_codec: PCMU 153 | variable_original_read_codec: PCMU 154 | variable_read_rate: 8000 155 | variable_original_read_rate: 8000 156 | variable_write_codec: PCMU 157 | variable_write_rate: 8000 158 | variable_dtmf_type: rfc2833 159 | variable_local_media_ip: 192.168.0.105 160 | variable_local_media_port: 21270 161 | variable_advertised_media_ip: 192.168.0.105 162 | variable_rtp_use_pt: 0 163 | variable_rtp_use_ssrc: 1707956575 164 | variable_rtp_2833_send_payload: 101 165 | variable_rtp_2833_recv_payload: 101 166 | variable_remote_media_ip: 192.168.0.105 167 | variable_remote_media_port: 25006 168 | variable_endpoint_disposition: ANSWER 169 | variable_pre_transfer_caller_id_name: Mr_Switchy 170 | variable_pre_transfer_caller_id_number: 1112223333 171 | variable_call_uuid: 8de782e0-83c9-11e7-af1b-001500e3e25c 172 | variable_current_application: park 173 | 174 | Content-Length: 1048 175 | Content-Type: text/event-plain 176 | 177 | Event---Name: BACKGROUND_JOB 178 | Core-UUID: 679056bb-0d5f-4045-8819-e0a886f6c2c5 179 | FreeSWITCH-Hostname: goodboy-xps 180 | FreeSWITCH-Switchname: goodboy-xps 181 | FreeSWITCH-IPv4: 192.168.0.105 182 | FreeSWITCH-IPv6: %3A%3A1 183 | Event-Date-Local: 2017-08-18%2003%3A58%3A55 184 | Event-Date-GMT: Fri,%2018%20Aug%202017%2003%3A58%3A55%20GMT 185 | Event-Date-Timestamp: 1503028735079552 186 | Event-Calling-File: mod_event_socket.c 187 | Event-Calling-Function: api_exec 188 | Event-Calling-Line-Number: 1557 189 | Event-Sequence: 1633 190 | Job-UUID: 86b51ad6-ebb9-4bca-8a41-f28986dde3e4 191 | Job-Command: originate 192 | Job-Command-Arg: %7Boriginate_timeout%3D60,origination_caller_id_name%3DMr_Switchy,origination_caller_id_number%3D1112223333,originator_codec%3DPCMU,absolute_codec_string%3D,origination_uuid%3D8de782e0-83c9-11e7-af1b-001500e3e25c,ignore_display_updates%3Dtrue,ignore_early_media%3Dtrue,sip_h_X-switchy_originating_session%3D8de782e0-83c9-11e7-af1b-001500e3e25c,sip_h_X-switchy_app%3DTonePlay%7Dsofia/external/doggy%40192.168.0.105%3A5080%20%26park() 193 | Content-Length: 41 194 | 195 | +OK 8de782e0-83c9-11e7-af1b-001500e3e25c 196 | 197 | Content-Type: command/reply 198 | Reply-Text: +OK --Job-UUID: 1246016f-ea2f-4cba-a018-16443bcadc6f 199 | Job-UUID: 1246016f-ea2f-4cba-a018-16443bcadc6f 200 | 201 | -------------------------------------------------------------------------------- /tests/test_connection.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | ''' 5 | Test ESL protocol and connection wrappers 6 | ''' 7 | import os 8 | import asyncio 9 | import pytest 10 | import switchio 11 | from switchio.connection import get_connection 12 | from switchio.protocol import InboundProtocol 13 | from switchio import utils 14 | 15 | 16 | @pytest.fixture(scope='module') 17 | def loop(): 18 | return asyncio.new_event_loop() 19 | 20 | 21 | @pytest.fixture 22 | def con(fshost, loop): 23 | con = get_connection(fshost, loop=loop) 24 | yield con 25 | con.disconnect() 26 | pending = utils.all_tasks(loop) 27 | if pending: 28 | for task in pending: 29 | if not task.done(): 30 | task.cancel() 31 | loop.run_until_complete(asyncio.wait(pending, loop=loop)) 32 | 33 | 34 | @pytest.mark.parametrize( 35 | 'password, expect_auth', 36 | [('doggy', False), ('ClueCon', True)], 37 | ids=lambda item: item, 38 | ) 39 | def test_connect(con, password, expect_auth): 40 | """Connection basics. 41 | """ 42 | if expect_auth: 43 | con.connect(password=password) 44 | assert con.protocol.authenticated() 45 | else: 46 | with pytest.raises(switchio.ConnectionError): 47 | con.connect(password=password) 48 | assert not con.protocol.authenticated() 49 | 50 | 51 | def test_disconnect(con, loop): 52 | con.connect() 53 | assert con.connected() 54 | assert con.protocol.authenticated() 55 | con.disconnect() 56 | assert not con.connected() 57 | assert not con.protocol.authenticated() 58 | 59 | 60 | @pytest.fixture 61 | def get_event_stream(): 62 | 63 | def read_stream(filename): 64 | dirname = os.path.dirname 65 | filepath = os.path.abspath( 66 | os.path.join( 67 | dirname(os.path.realpath(__file__)), 68 | 'data/{}'.format(filename) 69 | ) 70 | ) 71 | with open(filepath, 'r') as evstream: 72 | return evstream.read().encode() 73 | 74 | return read_stream 75 | 76 | 77 | def test_parse_event_stream1(con, get_event_stream): 78 | """Assert event packet/chunk parsing is correct corresponding 79 | to our sample file. 80 | """ 81 | event_stream = get_event_stream('eventstream.txt') 82 | con.connect() 83 | events = con.protocol.data_received(event_stream) 84 | assert len(events[0]) == 1 85 | assert events[1]['Reply-Text'] == '+OK accepted' 86 | assert events[2]['Reply-Text'] == '+OK bye' 87 | assert events[3]['Body'] 88 | 89 | # std state update 90 | assert events[4]['Channel-State'] == 'CS_INIT' 91 | # multiline value 92 | ev4 = events[4] 93 | first = 'v=0' 94 | assert ev4['variable_switch_r_sdp'][:len(first)] == first 95 | last = 'a=rtcp:4017 IN IP4 172.16.7.70' 96 | assert ev4['variable_switch_r_sdp'][-1-len(last):-1] == last 97 | 98 | 99 | def test_parse_segmented_event_stream(get_event_stream): 100 | """Verify segmented packets are processed correctly. 101 | """ 102 | prot = InboundProtocol(None, None, None) 103 | first, second, third, fourth = get_event_stream( 104 | 'eventstream2.txt').split(b'--') 105 | events = prot.data_received(first) 106 | assert len(events) == 1 107 | assert events[0]['Job-UUID'] 108 | assert prot._segmented[1] == 1048 # len of bytes after splitting on '--' 109 | assert len(prot._segmented[0]) == 2 110 | 111 | events = prot.data_received(second) 112 | assert events[0]['Body'] == '+OK 8de782e0-83c9-11e7-af1b-001500e3e25c\n' 113 | assert events[1]['Event-Name'] == 'CHANNEL_PARK' 114 | 115 | assert prot._segmented[2] == 'Event' 116 | # import pdb; pdb.set_trace() 117 | events3 = prot.data_received(third) 118 | assert len(events3) == 1 119 | assert events3[0]['Event-Name'] == 'BACKGROUND_JOB' 120 | 121 | assert prot._segmented[2] 122 | assert not prot._segmented[0] 123 | assert prot._segmented[1] == 0 124 | events4 = prot.data_received(fourth) 125 | assert not any(prot._segmented) 126 | assert len(events4) == 1 127 | patt = '+OK Job-UUID' 128 | assert events4[0]['Reply-Text'][:len(patt)] == patt 129 | -------------------------------------------------------------------------------- /tests/test_console.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | ''' 5 | Tests for console and command tools 6 | ''' 7 | -------------------------------------------------------------------------------- /tests/test_coroutines.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helpers for testing py3.5+ ``asyncio`` functionality. 3 | 4 | This module is mostly to avoid syntax errors in modules still used for 5 | py2.7 testing. 6 | """ 7 | import pytest 8 | import asyncio 9 | import time 10 | from switchio import sync_caller 11 | from switchio import coroutine 12 | 13 | 14 | def test_coro_cancel(fsip): 15 | """Verify that if a call never receives an event which is being 16 | waited that the waiting coroutine is cancelled at call hangup. 17 | """ 18 | class MyApp: 19 | @coroutine("CHANNEL_CREATE") 20 | async def wait_on_bridge(self, sess): 21 | # should never arrive since we don't subscribe for the event type 22 | if sess.is_inbound(): 23 | sess.answer() 24 | await sess.recv("CHANNEL_ANSWER") 25 | await sess.recv("CHANNEL_BRIDGE") 26 | 27 | with sync_caller(fsip, apps={"MyApp": MyApp}) as caller: 28 | # have the external prof call itself by default 29 | assert 'MyApp' in caller.app_names 30 | sess, waitfor = caller( 31 | "doggy@{}:{}".format(caller.client.host, 5080), 32 | 'MyApp', 33 | timeout=3, 34 | ) 35 | assert sess.is_outbound() 36 | callee = sess.call.get_peer(sess) 37 | callee_futs = callee._futures 38 | assert callee_futs # answer fut should be inserted 39 | time.sleep(0.1) # wait for answer 40 | # answer future should be consumed already 41 | assert not callee_futs.get('CHANNEL_ANSWER', None) 42 | br_fut = callee_futs['CHANNEL_BRIDGE'] 43 | assert not br_fut.done() 44 | time.sleep(0.1) 45 | # ensure our coroutine has been scheduled 46 | task = callee.tasks[br_fut][0] 47 | el = caller.client.listener 48 | assert task in el.event_loop.get_tasks() 49 | 50 | sess.hangup() 51 | time.sleep(0.1) # wait for hangup 52 | assert br_fut.cancelled() 53 | assert not callee._futures # should be popped by done callback 54 | assert el.count_calls() == 0 55 | 56 | 57 | def test_coro_timeout(fsip): 58 | """Verify that if a call never receives an event which is being 59 | waited that the waiting coroutine is cancelled at call hangup. 60 | """ 61 | class MyApp: 62 | @coroutine("CHANNEL_CREATE") 63 | async def timeout_on_hangup(self, sess): 64 | # should never arrive since we don't subscribe for the event type 65 | if sess.is_inbound(): 66 | await sess.answer() 67 | sess.vars['answered'] = True 68 | await sess.recv("CHANNEL_HANGUP", timeout=1) 69 | 70 | with sync_caller(fsip, apps={"MyApp": MyApp}) as caller: 71 | # have the external prof call itself by default 72 | assert 'MyApp' in caller.app_names 73 | sess, waitfor = caller( 74 | "doggy@{}:{}".format(caller.client.host, 5080), 75 | 'MyApp', 76 | timeout=3, 77 | ) 78 | assert sess.is_outbound() 79 | callee = sess.call.get_peer(sess) 80 | callee_futs = callee._futures 81 | waitfor(callee, 'answered', timeout=0.2) 82 | # answer future should be popped 83 | assert not callee_futs.get('CHANNEL_ANSWER') 84 | hangup_fut = callee_futs.get('CHANNEL_HANGUP') 85 | assert hangup_fut 86 | time.sleep(1) # wait for timeout 87 | task = callee.tasks.pop(hangup_fut)[0] 88 | assert task.done() 89 | with pytest.raises(asyncio.TimeoutError): 90 | task.result() 91 | -------------------------------------------------------------------------------- /tests/test_distributed.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | ''' 5 | test mult-slave/cluster tools 6 | ''' 7 | import pytest 8 | 9 | 10 | @pytest.fixture(scope='module') 11 | def pool(fshosts): 12 | if not len(fshosts) > 1: 13 | pytest.skip("the '--fshost' option must be a list of 2 or more " 14 | "hostnames in order to run multi-slave tests") 15 | from switchio.api import get_pool 16 | return get_pool(fshosts) 17 | 18 | 19 | def test_setup(pool): 20 | from switchio.apps.bert import Bert 21 | from switchio import utils 22 | pool.evals('listener.event_loop.unsubscribe("CALL_UPDATE")') 23 | assert not any(pool.evals('listener.connected()')) 24 | pool.evals('listener.connect()') 25 | assert all(pool.evals('listener.connected()')) 26 | pool.evals('client.connect()') 27 | pool.evals('client.load_app(Bert)', Bert=Bert) 28 | name = utils.get_name(Bert) 29 | assert all(False for apps in pool.evals('client._apps') 30 | if name not in apps) 31 | pool.evals('listener.start()') 32 | assert all(pool.evals('listener.is_alive()')) 33 | pool.evals('listener.disconnect()') 34 | assert not all(pool.evals('listener.is_alive()')) 35 | -------------------------------------------------------------------------------- /tests/test_routing.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | ''' 5 | Tests for routing apps. 6 | 7 | Checklist: 8 | - verify route order 9 | - guards block 10 | - raise StopRouting 11 | - multiple matches 12 | - multiple routers 13 | x cluster support 14 | x load test over multiple nodes is fast 15 | ''' 16 | import pytest 17 | import time 18 | from copy import copy 19 | import contextlib 20 | from switchio import Service 21 | from switchio.apps.routers import Router 22 | from collections import defaultdict 23 | pysipp = pytest.importorskip("pysipp") 24 | 25 | 26 | @pytest.fixture 27 | def router(fshost): 28 | """An inbound router which processes sessions from the `external` 29 | SIP profile. 30 | """ 31 | return Router(guards={ 32 | 'Caller-Direction': 'inbound', 33 | 'variable_sofia_profile_name': 'external' 34 | }) 35 | 36 | 37 | @pytest.fixture 38 | def service(fshosts, router): 39 | """A switchio routing service. 40 | """ 41 | s = Service(fshosts) 42 | yield s 43 | s.stop() 44 | 45 | 46 | @contextlib.contextmanager 47 | def dial_all(scenarios, did, hosts, expect=True, **extra_settings): 48 | """Async dial all FS servers in the test cluster with the given ``did``. 49 | ``expect`` is a bool determining whether the calls should connect using 50 | the standard SIPp call flow. 51 | """ 52 | finalizers = [] 53 | # run all scens async 54 | for scenario, host in zip(scenarios, hosts): 55 | if getattr(scenario, 'agents', None): 56 | scenario.defaults.update(extra_settings) 57 | scenario.agents['uac'].uri_username = did 58 | else: # a client instance 59 | scenario.uri_username = did 60 | 61 | finalizers.append(scenario(block=False)) 62 | 63 | yield scenarios 64 | 65 | # finalize 66 | for finalize in finalizers: 67 | if expect is True: 68 | finalize() 69 | else: 70 | if isinstance(expect, list): 71 | exp = copy(expect) 72 | cmd2procs = finalize(raise_exc=False) 73 | for cmd, proc in cmd2procs.items(): 74 | rc = proc.returncode 75 | assert rc in exp, ( 76 | "{} for {} was not in expected return codes {}".format( 77 | rc, cmd, exp)) 78 | exp.remove(rc) 79 | 80 | else: # generic failure 81 | with pytest.raises(RuntimeError): 82 | finalize() 83 | 84 | 85 | def test_route_order(router): 86 | """Verify route registration order is maintained. 87 | """ 88 | @router.route('0', field='did') 89 | async def doggy(): 90 | pass 91 | 92 | @router.route('0', field='did') 93 | async def kitty(): 94 | pass 95 | 96 | @router.route('0', field='did') 97 | async def mousey(): 98 | pass 99 | 100 | assert ['doggy', 'kitty', 'mousey'] == [ 101 | p.func.__name__ for p in router.route.iter_matches({'did': '0'}) 102 | ] 103 | 104 | 105 | def test_guard_block(scenarios, service, router): 106 | """Verify that if a guard is not satisfied the call is rejected. 107 | """ 108 | router.guards['variable_sofia_profile_name'] = 'doggy' 109 | service.apps.load_app(router, app_id='default') 110 | service.run(block=False) 111 | assert service.is_alive() 112 | with dial_all( 113 | scenarios, 'doggy', router.pool.evals('client.host'), expect=False 114 | ): 115 | pass 116 | 117 | 118 | def test_break_on_true(fs_socks, service, router): 119 | """raising ``StopRouting`` should halt all further processing. 120 | """ 121 | did = '101' 122 | router.sessions = [] 123 | 124 | @router.route(did) 125 | async def answer(sess, router, match): 126 | await sess.answer() 127 | router.sessions.append(sess) 128 | # prevent the downstream hangup route from executing 129 | raise router.StopRouting 130 | 131 | @router.route(did) 132 | async def hangup(sess, router, match): 133 | router.sessions.append("hangup_route") 134 | await sess.hangup() 135 | 136 | # don't reject on guard 137 | router.guard = False 138 | # start router service 139 | service.apps.load_app(router, app_id='default') 140 | service.run(block=False) 141 | assert service.is_alive() 142 | 143 | clients = [] 144 | for socketaddr in fs_socks: 145 | client = pysipp.scenario().clients['uac'] 146 | client.destaddr = socketaddr 147 | client.pause_duration = 2000 148 | clients.append(client) 149 | 150 | hosts = router.pool.evals('client.host') 151 | 152 | with dial_all(clients, did, hosts): 153 | 154 | # wait for SIPp start up 155 | start = time.time() 156 | while len(router.sessions) < len(hosts) and time.time() - start < 5: 157 | time.sleep(0.1) 158 | 159 | # verify all sessions are still active and 2nd route was never called 160 | for sess in router.sessions: 161 | assert sess.answered and not sess.hungup 162 | assert "hangup_route" not in router.sessions 163 | 164 | # hangup should come shortly after 165 | time.sleep(0.5) 166 | for sess in router.sessions: 167 | assert sess.hungup 168 | 169 | 170 | @pytest.mark.parametrize( 171 | 'did, expect', [ 172 | ('bridge', True), # match first 173 | (' hangup', [1, 0]), # match second 174 | ('none', [1, 0]), # match nothing 175 | # match 2 and have FS hangup mid bridge 176 | ('bridge_hangup', [1, 0]), 177 | ], 178 | ) 179 | def test_routes(scenarios, service, router, did, expect): 180 | """Test routing based on Request-URI user part patterns. 181 | """ 182 | called = defaultdict(list) 183 | 184 | # route to the b-leg SIPp UAS 185 | @router.route('bridge.*', field='Caller-Destination-Number') 186 | async def bridge(sess, match, router): 187 | sess.bridge() 188 | called[sess.con.host].append('bridge') 189 | 190 | @router.route('.*hangup') 191 | async def hangup(sess, router, match): 192 | sess.hangup() 193 | called[sess.con.host].append('hangup') 194 | 195 | @router.route('reject') 196 | async def reject(sess, router, match): 197 | sess.respond('407') 198 | called[sess.con.host].append('reject') 199 | 200 | service.apps.load_app(router, app_id='default') 201 | service.run(block=False) 202 | assert service.is_alive() 203 | 204 | defaults = {'pause_duration': 10000} if 'hangup' in did else {} 205 | 206 | with dial_all( 207 | scenarios, did, router.pool.evals('client.host'), 208 | expect, **defaults 209 | ): 210 | pass 211 | 212 | # verify route paths 213 | for host, routepath in called.items(): 214 | for i, patt in enumerate(did.split('_')): 215 | assert routepath[i] == patt 216 | 217 | 218 | @pytest.mark.parametrize('order, reject, expect', [ 219 | (iter, True, False), (reversed, False, True)]) 220 | def test_multiple_routers(scenarios, service, router, order, reject, expect): 221 | """Test that multiple routers will work cooperatively. 222 | In this case the second rejects calls due to guarding. 223 | """ 224 | # first router bridges to the b-leg SIPp UAS 225 | router.route('bridge.*', field='Caller-Destination-Number')( 226 | router.bridge) 227 | 228 | router2 = Router({'Caller-Direction': 'doggy'}, reject_on_guard=reject) 229 | service.apps.load_multi_app(order([router, router2]), app_id='default') 230 | service.run(block=False) 231 | assert service.is_alive() 232 | 233 | with dial_all( 234 | scenarios, 'bridge', router.pool.evals('client.host'), expect=expect 235 | ): 236 | pass 237 | 238 | 239 | def test_extra_subscribe(fssock, scenario, service): 240 | """Test the introductor example in the readme. 241 | """ 242 | router = Router( 243 | guards={ 244 | 'Caller-Direction': 'inbound', 245 | 'variable_sofia_profile_name': 'external'}, 246 | subscribe=('PLAYBACK_START', 'PLAYBACK_STOP'), 247 | ) 248 | 249 | @router.route('(.*)') 250 | async def welcome(sess, match, router): 251 | """Say hello to inbound calls. 252 | """ 253 | await sess.answer() # resumes once call has been fully answered 254 | sess.log.info("Answered call to {}".format(match.groups(0))) 255 | 256 | sess.playback( # non-blocking 257 | 'en/us/callie/ivr/8000/ivr-founder_of_freesource.wav') 258 | sess.log.info("Playing welcome message") 259 | 260 | await sess.recv("PLAYBACK_START") 261 | await sess.recv("PLAYBACK_STOP") 262 | await sess.hangup() # resumes once call has been fully hungup 263 | 264 | service.apps.load_app(router, app_id='default') 265 | service.run(block=False) 266 | assert service.is_alive() 267 | 268 | # make inbound call with SIPp client 269 | uac = scenario.prepare()[1] 270 | uac.proxyaddr = None 271 | uac.destaddr = fssock 272 | uac.pause_duration = 4000 273 | uac() 274 | -------------------------------------------------------------------------------- /tests/test_sync_call.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | """ 5 | Tests for synchronous call helper 6 | """ 7 | import time 8 | import pytest 9 | from switchio import sync_caller 10 | from switchio.apps.players import TonePlay, PlayRec 11 | 12 | 13 | def test_toneplay(fsip): 14 | '''Test the synchronous caller with a simple toneplay 15 | ''' 16 | with sync_caller(fsip, apps={"TonePlay": TonePlay}) as caller: 17 | # have the external prof call itself by default 18 | assert 'TonePlay' in caller.app_names 19 | sess, waitfor = caller( 20 | "doggy@{}:{}".format(caller.client.host, 5080), 21 | 'TonePlay', 22 | timeout=3, 23 | ) 24 | assert sess.is_outbound() 25 | time.sleep(1) 26 | sess.hangup() 27 | time.sleep(0.1) 28 | assert caller.client.listener.count_calls() == 0 29 | 30 | 31 | @pytest.mark.skip(reason='FS 1.6+ bug in record events') 32 | def test_playrec(fsip): 33 | '''Test the synchronous caller with a simulated conversation using the the 34 | `PlayRec` app. Currently this test does no audio checking but merely 35 | verifies the callback chain is invoked as expected. 36 | ''' 37 | with sync_caller(fsip, apps={"PlayRec": PlayRec}) as caller: 38 | # have the external prof call itself by default 39 | caller.apps.PlayRec['PlayRec'].rec_rate = 1 40 | sess, waitfor = caller( 41 | "doggy@{}:{}".format(caller.client.host, 5080), 42 | 'PlayRec', 43 | timeout=10, 44 | ) 45 | waitfor(sess, 'recorded', timeout=15) 46 | waitfor(sess.call.get_peer(sess), 'recorded', timeout=15) 47 | assert sess.call.vars['record'] 48 | time.sleep(1) 49 | assert sess.hungup 50 | 51 | 52 | def test_alt_call_tracking_header(fsip): 53 | '''Test that an alternate `EventListener.call_tracking_header` (in this 54 | case using the 'Caller-Destination-Number' channel variable) can be used 55 | to associate sessions into calls. 56 | ''' 57 | with sync_caller(fsip) as caller: 58 | # use the destination number as the call association var 59 | caller.client.listener.call_tracking_header = 'Caller-Destination-Number' 60 | dest = 'doggy' 61 | # have the external prof call itself by default 62 | sess, waitfor = caller( 63 | "{}@{}:{}".format(dest, caller.client.host, 5080), 64 | 'TonePlay', # the default app 65 | timeout=3, 66 | ) 67 | assert sess.is_outbound() 68 | # call should be indexed by the req uri username 69 | assert dest in caller.client.listener.calls 70 | call = caller.client.listener.calls[dest] 71 | time.sleep(1) 72 | assert call.first is sess 73 | assert call.last 74 | call.hangup() 75 | time.sleep(0.1) 76 | assert caller.client.listener.count_calls() == 0 77 | 78 | 79 | def test_untracked_call(fsip): 80 | with sync_caller(fsip) as caller: 81 | # use an invalid chan var for call tracking 82 | caller.client.listener.call_tracking_header = 'doggypants' 83 | # have the external prof call itself by default 84 | sess, waitfor = caller( 85 | "{}@{}:{}".format('jonesy', caller.client.host, 5080), 86 | 'TonePlay', # the default app 87 | timeout=3, 88 | ) 89 | # calls should be created for both inbound and outbound sessions 90 | # since our tracking variable is nonsense 91 | l = caller.client.listener 92 | # assert len(l.sessions) == len(l.calls) == 2 93 | assert l.count_sessions() == l.count_calls() == 2 94 | sess.hangup() 95 | time.sleep(0.1) 96 | # no calls or sessions should be active 97 | assert l.count_sessions() == l.count_calls() == 0 98 | assert not l.sessions and not l.calls 99 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{37}-{basic,pandas} 3 | 4 | [testenv] 5 | commands = pytest {posargs} 6 | # An example command should include the argument which points to a 7 | # FreeSWITCH server: tox -- --fshost=sip-cannon.qa.sangoma.local" 8 | deps = 9 | -rrequirements-test.txt 10 | pdbpp 11 | colorlog 12 | pandas: pandas>=0.18 13 | pandas: matplotlib 14 | pandas: tables==3.6.1 15 | --------------------------------------------------------------------------------