├── .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 |
8 |
9 |
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 |
--------------------------------------------------------------------------------