├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.rst ├── callram.example.conf ├── callram.py ├── paging.example.conf ├── paging.py ├── setup-configs ├── mpd.instance.conf ├── mpd@.service ├── paging-debian-9.pa ├── paging.pa ├── paging.service └── pulse.service ├── setup-scripts ├── install.debian_jessie.from_debs.sh ├── install.debian_jessie.sh ├── paging-server-setup.orangepi.debian_jessie.separate-mono-speakers.sh ├── paging-server-setup.orangepi.debian_jessie.sh └── paging-server-setup.orangepi.debian_stretch.sh └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | /paging.conf 2 | /callram.conf 3 | /*.wav 4 | /*.mp3 5 | /*.ogg 6 | 7 | /*.egg-info 8 | /dist 9 | 10 | /dst 11 | /sync.sh 12 | /test.py 13 | /TODO 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | {description} 294 | Copyright (C) {year} {fullname} 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | {signature of Ty Coon}, 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | 341 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include paging.example.conf README.* 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | PagingServer 2 | ============ 3 | 4 | SIP-based Announcement / PA / Paging / Public Address Server system. 5 | 6 | Main component of this project is a script to run PJSUA_ SIP client connected to 7 | a PulseAudio_ sound server routing audio to whatever sound cards and speaker 8 | sets. 9 | 10 | It picks up calls, plays klaxon on speakers, followed by the announcement made 11 | in that call. Music plays in-between announcements. 12 | 13 | Script controls PJSUA and PulseAudio (muting/unmuting streams there) to make 14 | them work to that effect. 15 | 16 | | 17 | 18 | .. contents:: 19 | :backlinks: none 20 | 21 | 22 | 23 | Usage 24 | ----- 25 | 26 | After installation (see below), the script should be configured, providing it 27 | with at least the SIP account data for the general usage. 28 | 29 | Configuration file (`ini format`_) locations: 30 | 31 | * paging.conf 32 | * /etc/paging.conf 33 | * callpipe.conf 34 | * /etc/callpipe.conf 35 | * Paths specified on the command line. 36 | 37 | All files will be looked up and parsed in that order, values in next ones 38 | overriding corresponding ones in the previous and defaults. 39 | 40 | See output of ``paging --help`` for info on how to specify additional 41 | configuration, more up-to-date list of default paths, as well as general 42 | information for all the other options available. 43 | 44 | Provided `paging.example.conf`_ file has all the available configuration options 45 | and their descriptions. 46 | 47 | To see default configuration options, use ``paging --dump-conf-defaults``, and 48 | run ``paging --dump-conf ...`` to see the actual options being picked-up and 49 | used at any time. 50 | 51 | There are two general (supported) ways to start and run the script: 52 | 53 | * In the foreground (non-forking). 54 | * As a systemd service. 55 | 56 | Both are described in more detail below. 57 | 58 | 59 | Start/run in the foreground 60 | ``````````````````````````` 61 | 62 | First - make sure PulseAudio_ and its ALSA_ backend are configured (and unmuted, 63 | in case of ALSA) as they should be and pulse server can run/runs for same user 64 | that this script will be running as. 65 | 66 | How to do that is out of scope for this README. 67 | 68 | Then just run the thing as:: 69 | 70 | % paging 71 | 72 | Can be used directly from terminal, or with any init system or daemon manager, 73 | including systemd, upstart, openrc, runit, daemontools, debian's 74 | "start-stop-daemon", simple bash scripts, etc. 75 | 76 | For systemd in particular, see the "Running as a systemd service" section below. 77 | 78 | Running from terminal to understand what's going on, these options might be also 79 | useful:: 80 | 81 | % paging --debug 82 | % paging --debug --pjsua-log-level 10 83 | % paging --dump-conf 84 | 85 | See also "Installation" and "Audio configuration" sections below. 86 | 87 | 88 | Running as a systemd service 89 | ```````````````````````````` 90 | 91 | This method should be preferred, as it correctly notifies init when service is 92 | actually ready (i.e. pjsua inputs/outputs initialized), so that others can be 93 | scheduled around that, and primes watchdog timer, detecting if/when app might 94 | hang due to some bug. 95 | 96 | Provided ``paging.service`` file (in the repository, just an ini file) should be 97 | installed to ``/etc/systemd/system``, and assumes following things: 98 | 99 | * PagingServer app should be run as a "paging" user, which exists on the system 100 | (e.g. in ``/etc/passwd``). 101 | 102 | * "paging.py" script, its "entry point" or symlink to it is installed at 103 | ``/usr/local/bin/paging``. 104 | 105 | * Configuration file can be read from one of default paths 106 | (see above for a list of these). 107 | 108 | * Optional python-systemd_ module dependency is installed. 109 | 110 | With all these correct, service can then be used like this: 111 | 112 | * Start/stop/restart service:: 113 | 114 | % systemctl start paging 115 | % systemctl stop paging 116 | % systemctl restart paging 117 | 118 | * Enable service(s) to start on OS boot:: 119 | 120 | systemctl enable paging 121 | 122 | * See if service is running, show last log entries: ``systemctl status paging`` 123 | * Show all logs for service since last OS boot: ``journalctl -ab -u paging`` 124 | 125 | * Continously show ("tail") all logs in the system: ``journalctl -af`` 126 | 127 | * Brutally kill service if it hangs on stop/restart: 128 | ``systemctl kill -s KILL paging`` 129 | (will be done after ~60s by systemd automatically). 130 | 131 | See `systemctl(1) manpage`_ for more info on such commands. 132 | 133 | If either app itself is installed to another location (not 134 | ``/usr/local/bin/paging``) or extra command-line parameters for it are required, 135 | ``ExecStart=`` line can be altered either in installed systemd unit file 136 | directly, or via ``systemctl edit paging``. 137 | 138 | ``systemctl daemon-reload`` should be run for any modifications to 139 | ``/etc/systemd/system/paging.service`` to take effect. 140 | 141 | Similarly, ``User=paging`` line can be altered or overidden to change system uid 142 | to use for the app. 143 | 144 | If python-systemd module is unavailable, following lines should be dropped from 145 | the ``paging.service``:: 146 | 147 | Type=notify 148 | WatchdogSec=... 149 | 150 | And ``--systemd`` option removed from ``ExecStart=`` line, so that app would be 151 | started as a simple non-forking process, which will then be treated correctly by 152 | systemd without two options above. 153 | 154 | 155 | 156 | Installation 157 | ------------ 158 | 159 | This is a regular package for Python 2.7 (not 3.X), but with some extra 160 | run-time requirements (see below), which can't be installed from PyPI. 161 | 162 | Package itself can be installed at any time using pip_, e.g. via ``pip install 163 | PagingServer`` (this will try to install stuff to /usr!!!). 164 | 165 | Unless you know python packaging though, please look at `pip2014.com`_, `python 166 | packaging tutorial`_, documentation below for easy installation (from 167 | packages/repo) on specific systems. 168 | 169 | 170 | Requirements 171 | ```````````` 172 | 173 | * `Python 2.7`_ (NOT 3.X). 174 | 175 | * PJSUA_ (PJSIP User Agent) and its python bindings. 176 | 177 | Can be packaged as "pjsip", "pjsua" or "pjproject" in linux distros. 178 | 179 | Python bindings (from the same tarball) can also be packaged separately as 180 | "python-pjproject" or something like that. 181 | 182 | If either of those isn't available, be sure to build and install pjsua AND its 183 | python bindings manually from the same sources, and NOT e.g. install pjsua 184 | from package and then build bindings separately. 185 | 186 | * PulseAudio_ 187 | 188 | * `pulsectl python module`_ 189 | 190 | * (optional) ffmpeg_ binary - if audio samples are not wav files (will be 191 | converted on every startup, if needed). 192 | 193 | * (optional) python-systemd_ - only if ``--systemd`` option is used (e.g. with 194 | shipped .service file). 195 | 196 | Developed and shipped separately from main systemd package since v223 197 | (2015-07-29), likely comes installed with systemd prior to that. 198 | 199 | Would probably make sense to install that module from OS package, which should 200 | be available if systemd is used there as init by default. 201 | 202 | * (optional) raven_ python module - for reporting any errors via sentry. 203 | 204 | 205 | Debian Jessie 206 | ````````````` 207 | 208 | * Installing everything via debian packages from third-party repository. 209 | 210 | Running this one-liner should be the easiest way by far:: 211 | 212 | wget -O- https://raw.githubusercontent.com/AccelerateNetworks/PagingServer/master/setup-scripts/install.debian_jessie.from_debs.sh | bash 213 | 214 | Or, if ``wget ... | bash`` sounds too scary, same exact steps as in that 215 | script are:: 216 | 217 | # apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 3D021F1F4C670809 218 | # echo 'deb http://paging-server.ddns.net/ jessie main' >/etc/apt/sources.list.d/paging-server.list 219 | # apt-get update 220 | 221 | # apt-get install --no-install-recommends pulseaudio pulseaudio-utils alsa-utils 222 | # apt-get install paging-server python-systemd 223 | 224 | # useradd -rm -s /bin/false -G audio paging 225 | # install -o root -g paging -m640 -T /usr/share/doc/paging-server/paging.example.conf /etc/paging.conf 226 | 227 | Configure, set-levels and unmute alsa/pulse, if necessary (depends heavily on 228 | the specific setup):: 229 | 230 | # alsamixer 231 | # nano /etc/pulse/default.pa 232 | 233 | Then edit config in ``/etc/paging.conf`` and start/enable the daemon:: 234 | 235 | # nano /etc/paging.conf 236 | # systemctl start paging 237 | # systemctl enable paging 238 | 239 | See "Usage" section for more details on how to run the thing. 240 | 241 | Packages here are built with `install.debian_jessie.sh`_ script described in 242 | the next section. 243 | 244 | * Building/installing everything on-site with one script. 245 | 246 | It's possible to install all required packages, building missing ones where 247 | necessary by running `install.debian_jessie.sh`_ script from the repository as 248 | a root user (as it runs apt-get and such):: 249 | 250 | # wget https://raw.githubusercontent.com/AccelerateNetworks/PagingServer/master/setup-scripts/install.debian_jessie.sh 251 | # bash install.debian_jessie.sh -x 252 | 253 | (running without -x flag will issue a warning message and exit) 254 | 255 | It's safe to run the script several times or on a machine where some of the 256 | requirements (see the list above) are installed already - should skip steps 257 | that are already done or unnecessary. 258 | 259 | Script builds everything into deb packages, stores each in 260 | ``/var/tmp/PagingServer.debs``, and installs them. 261 | 262 | Also creates ``apt-get-installed.list`` file in the same directory, where 263 | every package name it has passed to apt-get (i.e. packages that it has 264 | installed via apt-get) is recorded, in case there might be a need to clean 265 | these up later. 266 | 267 | After successful installation, enable/run the service as described in "Usage" section. 268 | 269 | * Manual installation. 270 | 271 | Follow roughly same steps as what `install.debian_jessie.sh`_ script does. 272 | 273 | 274 | 275 | Other systems 276 | ````````````` 277 | 278 | Just build/install all the requirements above from OS packages or however. 279 | 280 | 281 | 282 | Audio configuration 283 | ------------------- 284 | 285 | Overview of the software stack related to audio flow: 286 | 287 | * PJSUA picks-up the calls, decoding audio streams from SIP connections. 288 | 289 | * PJSUA outputs call audio to via PortAudio_. 290 | 291 | * PortAudio can use multiple backends on linux systems, including: 292 | 293 | * ALSA_ libs (and straight down to linux kernel) 294 | * OSS (/dev/dsp*, only supported through emulation layer in modern kernels) 295 | * JACK sound server 296 | * PulseAudio_ sound server (through ALSA compatibility layer) 297 | 298 | In this particular implementation, PulseAudio backend is assumed. 299 | 300 | * PulseAudio serves as a "hub", receiving streams from music players (mpd_ 301 | instances), klaxon sounds, calls picked-up by PJSUA. 302 | 303 | Depending on PulseAudio and music players' configuration, these outputs can be 304 | then mixed together and mapped to audio cards (or specific channels of these) 305 | as necessary. 306 | 307 | * PulseAudio outputs sound through ALSA libs and that goes to kernel driver and 308 | hardware, eventually. 309 | 310 | Here make sure that ALSA is also configured properly - sound hardware unmuted, 311 | volume level is set correctly and any other necessary mixer controls are set. 312 | 313 | This all is usually easy to do with "alsamixer" tool. 314 | 315 | Whole stack can always be tested with command like this:: 316 | 317 | % paging --test-audio-file my-sound.wav 318 | 319 | That option makes script just play the specified file through pjsua (as it would 320 | output the sound of the incoming call or a klaxon sound) and exit. 321 | 322 | If that works correctly, all that sound output pipeline from pjsua to alsa 323 | should be fine. 324 | 325 | 326 | PagingServer audio configuration 327 | ```````````````````````````````` 328 | 329 | Configuration here can be roughly divided into these sections (at the moment): 330 | 331 | 332 | * Sound output settings for PJSUA. 333 | 334 | Related configuration options: 335 | 336 | * pjsua-device 337 | * pjsua-conf-port 338 | 339 | As PortAudio (used by pjsua) can use one (and only one) of multiple backends 340 | at a time, and each of these backend can have multiple "ports" in turn, 341 | ``pjsua-device`` should be configured to use Pulse/ALSA backend "device". 342 | 343 | Usually when pulse is installed, "pulse" ALSA output gets configured, and that 344 | is what script uses by default, so no addition configuration should be 345 | necessary in that case. 346 | 347 | Otherwise, to see all devices that PJSUA and PortAudio detects, run:: 348 | 349 | % paging --dump-pjsua-devices 350 | 351 | Detected sound devices: 352 | [0] HDA ATI SB: ID 440 Analog (hw:0,0) 353 | [1] HDA ATI SB: ID 440 Digital (hw:0,3) 354 | [2] HDA ATI HDMI: 0 (hw:1,3) 355 | [3] sysdefault 356 | [4] front 357 | [5] surround21 358 | [6] surround40 359 | ... 360 | [13] dmix 361 | [14] default 362 | [15] pulse 363 | [15] system 364 | [16] PulseAudio JACK Source 365 | 366 | (output is truncated, as it also includes misc info for each of these 367 | devices/ports that PortAudio/PJSUA provides) 368 | 369 | This should print a potentially-long list of "playback devices" (PJSUA 370 | terminology) that can be used for output there, as shown above. 371 | 372 | "aplay -L" command can also be used to match that with ALSA outputs. 373 | 374 | PortAudio-output should be specified either as numeric id (number in square 375 | brackets on the left) or regexp (python style) to match against name in the 376 | list via ``pjsua-device`` option. 377 | 378 | Note: at this time (6/1/2019) numeric id matching seems to cause a crash, set 379 | ``pjsua-device = sysdefault`` or similar to ensure a match. 380 | 381 | To avoid having any confusing non-ALSA (incl. pulse-alsa emulation) ports 382 | there, PortAudio can be compiled with only ALSA as a backend. 383 | 384 | ``pjsua-conf-port`` option can be used to match one of the "conference ports" 385 | from ``paging --dump-pjsua-conf-ports`` command output in the same fashion, if 386 | there will ever be more than one (due to more complex pjsua configuration, for 387 | example), otherwise it'll work fine with empty default. 388 | 389 | 390 | * Configuration for any non-call inputs (music, klaxons, etc) for pulse. 391 | 392 | Related configuration options: 393 | 394 | * klaxon 395 | * pulse-mute 396 | 397 | "klaxon" can be a path to any file that has sound in it (that ffmpeg would 398 | understand), and will be played before each announcement call gets picked-up. 399 | 400 | "pulse-mute" should be a regexp to match any sufficiently unique property of 401 | music streams, that would play in-between announcements. 402 | 403 | For example, if mpd_ player is used for music output, ``pulse-mute = 404 | ^application\.name=mpd$`` setting should match and mute all running player 405 | instances as necessary. 406 | 407 | Script can be run with ``--debug --dump-pulse-props`` option to show 408 | properties of each PulseAudio stream, and info on when/whether they match 409 | ``pulse-mute`` option. 410 | 411 | See `paging.example.conf`_ for more detailed info on these options. 412 | 413 | 414 | All settings mentioned here are located in the ``[audio]`` section of the 415 | configuration file. 416 | 417 | See `paging.example.conf`_ for more detailed descriptons. 418 | 419 | 420 | 421 | Misc tips and tricks 422 | -------------------- 423 | 424 | Collection of various things related to this project. 425 | 426 | 427 | Pre-convert klaxon sound(s) to wav from any format 428 | `````````````````````````````````````````````````` 429 | 430 | Can be done via ffmpeg_ with:: 431 | 432 | ffmpeg -y -v 0 -i sample.mp3 -f wav sample.wav 433 | 434 | Where it doesn't actually matter which format source "sample.mp3" is in - can be 435 | mp3, ogg, aac, mpc, mp4 or whatever else ffmpeg supports. 436 | 437 | Might help to avoid startup delays due to conversion of these on each run. 438 | 439 | If pjsua will be complaining about sample-rate difference between wav file and 440 | output, e.g. ``-ar 44100`` option can be used (after ``-f wav``) to have any 441 | sampling rate for the output file. 442 | 443 | 444 | Benchmark script (callram.py) 445 | ````````````````````````````` 446 | 447 | Description below is from old README.md file pretty much verbatim. 448 | 449 | We've tested this script with thousands of calls, it is fairly reliable and 450 | light on resources. Total CPU use on a Pentium 4 @ 2.8ghz hovered around 0.5% 451 | with 4MB ram usage. identical figures were observed on a Celeron D @ 2.53Ghz, 452 | you could probably get away with whatever your operating system requires to run 453 | in terms of hardware. 454 | 455 | To benchmark, you'll need to set up callram.py. 456 | 457 | * Setting up callram.py 458 | 459 | This setup assumes you have PJSUA installed, if not, go back to Installation 460 | earlier in this readme and install it. 461 | 462 | * Put the files in the right places:: 463 | 464 | sudo cp callram.py /opt/bin/callram.py 465 | sudo cp callram.example.conf /etc/callram.conf 466 | 467 | * Add your SIP account:: 468 | 469 | sudo nano /etc/callram.conf 470 | 471 | Change the top 3 values to your SIP server, username (usually ext. number) and 472 | password. 473 | 474 | Then fill in both SIP URI: fields (uri= and to=) with the SIP URI of the 475 | client you'd like to test. 476 | 477 | SIP URIs are usually formatted as ``sip:@`` in 478 | most cases. 479 | 480 | The Domain may sometimes be an IPv4 or IPv6 address depending on your setup. 481 | 482 | * Run:: 483 | 484 | /usr/bin/python /opt/bin/callram.py 485 | 486 | 487 | Sending error reports to Sentry 488 | ``````````````````````````````` 489 | 490 | Sentry_ is a "modern error logging and aggregation platform". 491 | 492 | Python raven_ module has to be installed in order for this to work. 493 | 494 | Uncomment and/or set "sentry_dsn" option under the ``[server]`` section of the 495 | configuration file. 496 | 497 | It can also be set via ``--sentry-dsn`` command-line option, e.g. in systemd 498 | unit distributed with the package, to apply on all setups where package is deployed. 499 | 500 | 501 | 502 | Copyright and License 503 | --------------------- 504 | 505 | | Code and documentation copyright 2015 Accelerate Networks. 506 | | Code released under the GNU General Public License v2.0. 507 | | See LICENSE file in the repository for more details. 508 | | Docs released under Creative Commons. 509 | 510 | 511 | 512 | .. _PJSUA: http://www.pjsip.org/ 513 | .. _PulseAudio: https://wiki.freedesktop.org/www/Software/PulseAudio/ 514 | .. _ALSA: http://www.alsa-project.org/main/index.php/Main_Page 515 | .. _ini format: https://en.wikipedia.org/wiki/INI_file 516 | .. _paging.example.conf: https://github.com/AccelerateNetworks/PagingServer/blob/master/paging.example.conf 517 | .. _PortAudio: http://www.portaudio.com/ 518 | .. _ffmpeg: http://ffmpeg.org/ 519 | .. _systemctl(1) manpage: http://www.freedesktop.org/software/systemd/man/systemctl.html 520 | .. _mpd: http://musicpd.org/ 521 | .. _Sentry: https://getsentry.com/ 522 | .. _pip: http://pip-installer.org/ 523 | .. _pip2014.com: http://pip2014.com/ 524 | .. _python packaging tutorial: https://packaging.python.org/en/latest/installing.html 525 | .. _Python 2.7: http://python.org/ 526 | .. _pulsectl python module: https://github.com/mk-fg/python-pulse-control 527 | .. _raven: https://pypi.python.org/pypi/raven/5.5.0 528 | .. _python-systemd: https://github.com/systemd/python-systemd 529 | .. _install.debian_jessie.sh: https://github.com/AccelerateNetworks/PagingServer/blob/master/setup-scripts/install.debian_jessie.sh 530 | -------------------------------------------------------------------------------- /callram.example.conf: -------------------------------------------------------------------------------- 1 | [account] 2 | domain = 3 | user = 4 | pass = 5 | 6 | [transfer] 7 | uri= 8 | 9 | [call] 10 | to= 11 | 12 | -------------------------------------------------------------------------------- /callram.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # $Id$ 3 | # 4 | # SIP account and registration sample. In this sample, the program 5 | # will block to wait until registration is complete 6 | # 7 | # Copyright (C) 2003-2008 Benny Prijono 8 | # 9 | # This program is free software; you can redistribute it and/or modify 10 | # it under the terms of the GNU General Public License as published by 11 | # the Free Software Foundation; either version 2 of the License, or 12 | # (at your option) any later version. 13 | # 14 | # This program is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU General Public License for more details. 18 | # 19 | # You should have received a copy of the GNU General Public License 20 | # along with this program; if not, write to the Free Software 21 | # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 22 | # 23 | import sys 24 | import pjsua as pj 25 | import ConfigParser 26 | try: 27 | from raven import Client 28 | raven = Client('http://dd2c825ff9b1417d88a99573903ebf80:91631495b10b45f8a1cdbc492088da6a@localhost:9000/1') 29 | except: 30 | print("ZOMG INSTALL RAVEN YOU FUCK") 31 | print("protip: pip install raven") 32 | sys.exit(1) 33 | 34 | 35 | config = ConfigParser.SafeConfigParser() 36 | config.read('config.ini', 'callram.conf', '/etc/callram.conf') 37 | 38 | 39 | # Logging callback 40 | def log_cb(level, string, length): 41 | for line in string.split("\n"): 42 | print("[LOG ] [%s] [%s] %s" % (level, length, line)) 43 | 44 | # Callback to receive events from Call 45 | class MyCallCallback(pj.CallCallback): 46 | number = None 47 | redial = 0 48 | 49 | def __init__(self, call=None, number=None, redial=0): 50 | pj.CallCallback.__init__(self, call) 51 | self.num = number 52 | self.redial = redial 53 | 54 | # Notification when call state has changed 55 | def on_state(self): 56 | try: 57 | print("[CALL] [%s] [%s] [STATE] [%s] %s" % (self.num, self.redial, self.call.info().state, self.call.info().state_text)) 58 | 59 | if self.call.info().state == 5: 60 | print("omg lel holding call open") 61 | #time.sleep(5) 62 | self.call.hangup() 63 | #self.call.transfer(config.get('call', 'to')) 64 | 65 | if self.call.info().state == 6: 66 | # self.call.hangup() 67 | acc.make_call(config.get('call', 'to'), 68 | MyCallCallback(number=self.num, 69 | redial=self.redial+1)) 70 | except (pj.Error, TypeError) as e: 71 | exc_type, exc_obj, exc_tb = sys.exc_info() 72 | print("[CALL] [%s] [%s] [Line %s] Error: %s" % (self.num, self.redial, exc_tb.tb_lineno, str(e))) 73 | 74 | # Notification when call's media state has changed. 75 | def on_media_state(self): 76 | global lib 77 | if self.call.info().media_state == pj.MediaState.ACTIVE: 78 | # Connect the call to sound device 79 | call_slot = self.call.info().conf_slot 80 | lib.conf_connect(call_slot, 0) 81 | lib.conf_connect(0, call_slot) 82 | 83 | try: 84 | # Create library instance 85 | lib = pj.Lib() 86 | 87 | # Create a user agent 88 | ua = pj.UAConfig() 89 | ua.max_calls = 100 90 | ua.user_agent = sys.argv[0] 91 | 92 | # Init library with default config 93 | lib.init(ua) 94 | 95 | # Create UDP transport which listens to any available port 96 | transport = lib.create_transport(pj.TransportType.UDP) 97 | 98 | # Start the library 99 | lib.start() 100 | 101 | # Create local/user-less account 102 | acc = lib.create_account(pj.AccountConfig( 103 | config.get("account", "domain"), 104 | config.get("account", "user"), 105 | config.get("account", "pass") 106 | )) 107 | 108 | # Make call 109 | for i in range(0, 1): 110 | call = acc.make_call(config.get('call', 'to'), MyCallCallback(number=i)) 111 | 112 | # Wait for ENTER before quitting 113 | print("Press to quit") 114 | sys.stdin.readline().rstrip("\r\n") 115 | 116 | # We're done, shutdown the library 117 | lib.destroy() 118 | lib = None 119 | 120 | 121 | except pj.Error as e: 122 | print("Exception: %s" % str(e)) 123 | lib.destroy() 124 | lib = None 125 | sys.exit(1) 126 | -------------------------------------------------------------------------------- /paging.example.conf: -------------------------------------------------------------------------------- 1 | ;;;; PagingServer configuration file 2 | 3 | ;; Uncommented values are mandatory and should be filled-up. 4 | ;; Commented-out values are optional. 5 | 6 | ;; See README.rst file for more details on installation/configuration. 7 | 8 | 9 | [sip] 10 | domain = 11 | user = 12 | pass = 13 | 14 | 15 | [audio] 16 | 17 | ;; klaxon: paging tone to play for callers. 18 | ;; Can be left empty (default) to have none. 19 | ;; If sample path is not *.wav, it will be converted with ffmpeg (to wav in temp-dir). 20 | ; klaxon = /etc/paging.wav 21 | 22 | ;; klaxon-max-length: simple sanity-check limit on klaxon length (in seconds). 23 | ;; This is NOT the "length of an audio file" option, and does not need to be touched at all. 24 | ;; Should likely only be used when something goes wrong and server does not pick up the calls. 25 | ;; Default is 10s. 26 | ; klaxon-max-length = 5.56 27 | 28 | ;; klaxon-padding: Delay (seconds) after klaxon sound finishes playing and call gets picked-up. 29 | ;; Can be negative to pick up the call before klaxon finishes playing. 30 | ;; Default is no delay (0s). 31 | ; klaxon-padding = 1.0 32 | 33 | ;; pjsua-device: regexp to pick pjsua output device by name. 34 | ;; Will raise error on >1 match. Numeric id can be used instead. 35 | ;; Use --dump-pjsua-devices cli option to see all devices available. 36 | ;; Default is to use "pulse" device, which should be alsa pulse plugin. 37 | ; pjsua-device = ^pulse$ 38 | 39 | ;; pjsua-conf-port: regexp to pick pjsua output conference port by name. 40 | ;; Will raise error on >1 match. Numeric id can be used instead. 41 | ;; Use --dump-pjsua-conf-ports cli option to see all ports available. 42 | ;; Default is to use any available port, will signal error if there's more than one. 43 | ; pjsua-conf-port = 44 | 45 | ;; pulse-match: regexp for pulse sink inputs to consider to be music players. 46 | ;; These will be muted when during calls and unmuted afterwards (and on restart). 47 | ;; All sink properties will be matched against this regexp, one-by-one, stopping on first match. 48 | ;; Default is: ^application\.process\.binary=mpd$ 49 | ; pulse-match = ^application\.process\.binary=mpd$ 50 | 51 | ;; volume-*: volume levels to set for audio streams, in percent of the full volume. 52 | ;; Should typically be a value in 0-100 range (floats will work too), 53 | ;; with values >100 meaning software-boosted volume, which can negatively affect audio quality. 54 | ;; Zero (0) will mute the stream. 55 | ;; Negative value (e.g. -1) will leave volume level untouched, i.e. up to pulse and/or player. 56 | ;; Default is -1 (don't set volume level) for all of these options. 57 | ; volume-music = 27.5 58 | ; volume-klaxon = 50 59 | ; volume-call = 65.0 60 | 61 | ;; music-fade-*-duration: duration in seconds for fade-in/fade-out 62 | ;; for music streams (matched by "pulse-match" regexp) after/before calls. 63 | ;; Zero (0) or negative value (e.g. -1) will disable fade-in/fade-out effects. 64 | ;; Default is 3s for fade-out, 10s for fade-in. 65 | ;; music-fade-*-offset: time offset (in seconds) for fade-in/fade-out to start/end. 66 | ;; Zero (0) means no offset, i.e. fade-out 67 | ;; ends when klaxon starts, and fade-in starts right after call ends. 68 | ;; Can be negative for fade-out, so it'd end before klaxon starts. 69 | ;; Default is 0 (no offset) for both fade-in/fade-out. 70 | ;; music-fade-*-min: min volume level for fade-in/fade-out. 71 | ;; Same format/meaning as with volume-* values (see above). 72 | ;; I.e. what fade-in starts from or fade-out "fades to", set right before/after mute/unmute. 73 | ;; Default is 0 - fade from/to complete silence. 74 | ; music-fade-in-duration = 5.15 75 | ; music-fade-in-offset = -0.5 76 | ; music-fade-in-min = 12.4 77 | ; music-fade-out-duration = 3.5 78 | ; music-fade-out-offset = 1.4 79 | ; music-fade-out-min = 5 80 | 81 | 82 | [calls] 83 | 84 | ;; hang-up-after-minutes: duration (from picking it up) after which to hang up calls, in minutes. 85 | ;; Can have a fractional part, e.g. "0.5" for 30s. 86 | ;; Default is 5 minutes. 87 | ; hang-up-after-minutes = 10 88 | 89 | ;; hold-concurrent: whether to hold concurrent calls or put them all into same conference slot (yes/no). 90 | ;; Default is "no" - to put all callers into same conference slot. 91 | ; hold-concurrent = yes 92 | 93 | 94 | [server] 95 | 96 | ;; debug: verbose operation mode. 97 | ; debug = no 98 | 99 | ;; pjsua-log-level: pjsua lib logging level. 100 | ;; Only used if debug is enabled. 101 | ;; Zero is only for fatal errors, higher levels are more noisy. 102 | ; pjsua-log-level = 0 103 | 104 | ;; sentry_dsn: use specified Sentry instance to capture errors/logging using "raven" module. 105 | ; sentry_dsn = https://0b915e29784f479f93db6ae2870515b6:b2fb7becafdc4c259b813a8f84f5b855@sentry.finn.io/2 106 | -------------------------------------------------------------------------------- /paging.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | # -*- coding: utf-8 -*- 3 | from __future__ import print_function 4 | 5 | import itertools as it, operator as op, functools as ft 6 | from os.path import join, exists, isfile, expanduser, dirname 7 | from contextlib import contextmanager, closing 8 | from collections import deque, OrderedDict 9 | from heapq import heappush, heappop, heappushpop 10 | import ConfigParser as configparser 11 | import os, sys, io, re, types, ctypes, threading 12 | import time, signal, logging, inspect 13 | 14 | 15 | class Conf(object): 16 | 17 | sip_domain = '' 18 | sip_user = '' 19 | sip_pass = '' 20 | 21 | audio_klaxon = '' 22 | audio_klaxon_tmpdir = '' 23 | audio_klaxon_max_length = 10.0 24 | audio_klaxon_padding = 0.0 25 | audio_pjsua_device = r'^pulse$' 26 | audio_pjsua_conf_port = '' # there should be only one 27 | audio_pulse_match = r'^application\.process\.binary=mpd$' 28 | 29 | audio_volume_music = -1.0 30 | audio_volume_klaxon = -1.0 31 | audio_volume_call = -1.0 32 | 33 | audio_music_fade_in_duration = 10.0 34 | audio_music_fade_out_duration = 3.0 35 | audio_music_fade_in_offset = 0.0 36 | audio_music_fade_out_offset = 0.0 37 | audio_music_fade_in_min = 0.0 38 | audio_music_fade_out_min = 0.0 39 | 40 | calls_hold_concurrent = False 41 | calls_hang_up_after_minutes = 5.0 42 | 43 | server_debug = False 44 | server_dump_pulse_props = False 45 | server_pjsua_log_level = 0 46 | server_sentry_dsn = '' 47 | server_pjsua_cleanup_timeout = 5 48 | 49 | _conf_paths = ( 'paging.conf', 50 | '/etc/paging.conf', 'callpipe.conf', '/etc/callpipe.conf' ) 51 | _conf_sections = 'sip', 'audio', 'calls', 'server' 52 | 53 | def __repr__(self): return repr(vars(self)) 54 | def get(self, *k): return getattr(self, '_'.join(k)) 55 | 56 | @staticmethod 57 | def parse_bool(val, _states={ 58 | '1': True, 'yes': True, 'true': True, 'on': True, 59 | '0': False, 'no': False, 'false': False, 'off': False }): 60 | try: return _states[val.lower()] 61 | except KeyError: raise ValueError(val) 62 | 63 | 64 | 65 | ### Utility boilerplates 66 | 67 | log = raven_client = None 68 | 69 | def err_report_wrapper(func=None, fatal=None): 70 | def _err_report_wrapper(func): 71 | @ft.wraps(func) 72 | def _wrapper(*args, **kws): 73 | try: return func(*args, **kws) 74 | except Exception as err: 75 | if raven_client: raven_client.captureException() 76 | if fatal is None and func.func_name == '__init__': raise # implicit 77 | elif fatal: raise 78 | if log: log.exception('ERROR (%s): %s', func.func_name, err) 79 | return _wrapper 80 | return _err_report_wrapper if func is None else _err_report_wrapper(func) 81 | 82 | err_report = err_report_wrapper 83 | err_report_only = err_report_wrapper(fatal=False) 84 | err_report_fatal = err_report_wrapper(fatal=True) 85 | 86 | def get_logger(logger=None, root=['__main__', 'paging']): 87 | 'Returns logger for calling class or function name and module path.' 88 | if logger is None: 89 | frame = inspect.stack()[1][0] 90 | name = inspect.getargvalues(frame).locals.get('self') 91 | if isinstance(root, types.StringTypes): root = [root] 92 | if name: 93 | name = '{}.{}'.format(name.__module__, name.__class__.__name__).split('.') 94 | for k in root: 95 | if k in name: break 96 | else: 97 | raise ValueError( 'Unable to find logger name' 98 | ' root(s) ({!r}) in module path: {!r}'.format(root, name) ) 99 | name = name[name.index(k):] 100 | if k == '__main__': name[0] = root[-1] 101 | else: name = root[-1:] 102 | name_ext = frame.f_code.co_name 103 | if name_ext not in ['__init__', '__new__']: 104 | name.append(name_ext) 105 | if name_ext[0].isupper(): name.append('core') 106 | logger = '.'.join(name) 107 | if isinstance(logger, types.StringTypes): 108 | logger = logging.getLogger(logger) 109 | return logger 110 | 111 | @contextmanager 112 | def suppress_streams(*streams): 113 | with open(os.devnull, 'wb') as stream_null: 114 | fd_null, replaced = stream_null.fileno(), dict() 115 | for k in streams or ['stdout', 'stderr']: 116 | stream = getattr(sys, k) 117 | fd = stream.fileno() 118 | replaced[k] = fd, os.dup(fd), stream 119 | os.dup2(fd_null, fd) 120 | setattr(sys, k, stream_null) 121 | yield 122 | for k, (fd, fd_bak, stream) in replaced.viewitems(): 123 | stream.flush() 124 | stream_base = getattr(sys, '__{}__'.format(k)) 125 | if stream_base is not stream: stream_base.flush() 126 | os.dup2(fd_bak, fd) 127 | setattr(sys, k, stream) 128 | 129 | 130 | def force_bytes(bytes_or_unicode, encoding='utf-8', errors='backslashreplace'): 131 | if isinstance(bytes_or_unicode, bytes): return bytes_or_unicode 132 | return bytes_or_unicode.encode(encoding, errors) 133 | 134 | def force_unicode(bytes_or_unicode, encoding='utf-8', errors='replace'): 135 | if isinstance(bytes_or_unicode, unicode): return bytes_or_unicode 136 | return bytes_or_unicode.decode(encoding, errors) 137 | 138 | def force_str_type(bytes_or_unicode, val_or_type, **conv_kws): 139 | if val_or_type is bytes or isinstance(val_or_type, bytes): f = force_bytes 140 | elif val_or_type is unicode or isinstance(val_or_type, unicode): f = force_unicode 141 | else: raise TypeError(val_or_type) 142 | return f(bytes_or_unicode, **conv_kws) 143 | 144 | 145 | def update_conf_from_file(conf, path_or_file, section='default', prefix=None): 146 | if isinstance(path_or_file, types.StringTypes): path_or_file = open(path_or_file) 147 | if isinstance(path_or_file, configparser.RawConfigParser): config = path_or_file 148 | else: 149 | with path_or_file as src: 150 | config = configparser.RawConfigParser(allow_no_value=True) 151 | config.readfp(src) 152 | for k in dir(conf): 153 | if prefix: 154 | if not k.startswith(prefix): continue 155 | conf_k, k = k, k[len(prefix):] 156 | elif k.startswith('_'): continue 157 | else: conf_k = k 158 | v = getattr(conf, conf_k) 159 | if isinstance(v, types.StringTypes): 160 | get_val = lambda *a: force_str_type(config.get(*a), v) 161 | elif isinstance(v, bool): get_val = config.getboolean 162 | elif isinstance(v, (int, long)): get_val = config.getint 163 | elif isinstance(v, float): get_val = lambda *a: float(config.get(*a)) 164 | else: continue # values with other types cannot be specified in config 165 | for k_conf in k, k.replace('_', '-'): 166 | try: setattr(conf, conf_k, get_val(section, k_conf)) 167 | except configparser.Error: pass 168 | 169 | 170 | def mono_time(): 171 | if not hasattr(mono_time, 'ts'): 172 | class timespec(ctypes.Structure): 173 | _fields_ = [('tv_sec', ctypes.c_long), ('tv_nsec', ctypes.c_long)] 174 | librt = ctypes.CDLL('librt.so.1', use_errno=True) 175 | mono_time.get = librt.clock_gettime 176 | mono_time.get.argtypes = [ctypes.c_int, ctypes.POINTER(timespec)] 177 | mono_time.ts = timespec 178 | ts = mono_time.ts() 179 | if mono_time.get(4, ctypes.pointer(ts)) != 0: 180 | err = ctypes.get_errno() 181 | raise OSError(err, os.strerror(err)) 182 | return ts.tv_sec + ts.tv_nsec * 1e-9 183 | 184 | 185 | def ffmpeg_towav(path=None, block=True, max_len=None, tmp_dir=None): 186 | if path and path.endswith('.wav'): return path 187 | import subprocess, hashlib, base64, tempfile, atexit 188 | 189 | self = ffmpeg_towav 190 | if not hasattr(self, 'init'): 191 | for p in 'ffmpeg', 'avconv': 192 | proc = subprocess.Popen(['/bin/which', p], stdout=subprocess.PIPE) 193 | ffmpeg_path = proc.stdout.read() 194 | if proc.wait() == 0 and ffmpeg_path.strip(): 195 | self.binary = p 196 | break 197 | else: 198 | raise PagingServerError(( 'ffmpeg/avconv binary is required to' 199 | ' convert specified file (path: {!r}) to wav format, and it was not found in PATH.' 200 | ' Either ffmpeg can be installed (e.g. "apt-get install libav-tools"),' 201 | ' or file should be pre-converted to wav.' ).format(path)) 202 | 203 | self.init, self.procs, self.log = True, dict(), get_logger() 204 | self.tmp_dir = tempfile.mkdtemp(prefix='ffmpeg_towav.{}.'.format(os.getpid())) 205 | def proc_gc(sig, frm): 206 | for p,proc in self.procs.items(): 207 | if p and proc and proc.poll() is not None: 208 | pid, err = proc.pid, proc.wait() 209 | if err != 0: 210 | self.log.warn( 'ffmpeg converter' 211 | ' pid (%s) has exited with error: %s', pid, err ) 212 | self.procs[p] = None 213 | def files_cleanup(): 214 | file_dirs, procs = set(), self.procs.items() 215 | self.log.debug( 216 | 'ffmpeg cleanup (%s pid(s), %s tmp file(s))', 217 | len(filter(all, procs)), len(procs) ) 218 | for p, proc in procs: 219 | if p and proc and proc.poll() is not None: proc.kill() 220 | try: os.unlink(p) 221 | except (OSError, IOError): pass 222 | file_dirs.add(dirname(p)) 223 | for p in file_dirs: 224 | try: os.rmdir(p) 225 | except (OSError, IOError): pass 226 | chk = signal.signal(signal.SIGCHLD, proc_gc) 227 | assert chk in [None, signal.SIG_IGN, signal.SIG_DFL], chk 228 | atexit.register(files_cleanup) 229 | if not tmp_dir: tmp_dir = self.tmp_dir 230 | 231 | proc = dst_path = None 232 | if path: 233 | dst_path = join(tmp_dir, '{}.wav'.format( 234 | base64.urlsafe_b64encode(hashlib.sha256(path).digest())[:8] )) 235 | if exists(dst_path): self.procs[dst_path] = None 236 | else: 237 | cmd = [self.binary, '-y', '-v', '0'] 238 | if max_len: cmd += ['-t', bytes(max_len)] 239 | cmd += ['-i', path, '-f', 'wav', dst_path] 240 | self.log.debug('Starting ffmpeg conversion: %s', ' '.join(cmd)) 241 | proc = self.procs[dst_path] = subprocess.Popen(cmd, close_fds=True) 242 | if block: 243 | self.log.debug( 244 | 'Waiting for %s ffmpeg pid(s) to finish conversion', 245 | len(filter(None, self.procs.values())) ) 246 | if proc: proc.wait() 247 | else: 248 | procs = self.procs.items() 249 | if isinstance(block, (set, frozenset, list, tuple)): 250 | procs = list((p,proc) for p,proc in procs if p in block) 251 | for p, proc in procs: proc.wait() 252 | return dst_path 253 | 254 | 255 | def dict_with(d, **kws): 256 | d.update(kws) 257 | return d 258 | 259 | def dict_for_ctype(obj): 260 | return dict((k, getattr(obj, k)) for k in dir(obj) if not k.startswith('_')) 261 | 262 | 263 | 264 | ### PJSUA event handlers 265 | 266 | class PSCallbacks(object): 267 | 268 | ev_type = None 269 | 270 | def __getattribute__(self, k): 271 | event, cb_default = k[3:] if k.startswith('on_') else None, False 272 | sself = super(PSCallbacks, self) 273 | if not event: 274 | try: return sself.__getattribute__(k) 275 | except AttributeError: return getattr(self.cbs, k) # proxy 276 | try: v = sself.__getattribute__(k) 277 | except AttributeError: v, cb_default = getattr(self.cbs, k, AttributeError), True 278 | if event: 279 | self.log.debug( '%s event: %s%s', 280 | self.ev_type or self.__class__.__name__, 281 | event, ' [default callback]' if cb_default else '' ) 282 | if v is AttributeError: raise v(k) 283 | return v 284 | 285 | 286 | class PSAccountState(PSCallbacks): 287 | 288 | @err_report 289 | def __init__(self, server): 290 | self.server, self.cbs = server, server.pj.AccountCallback() 291 | self.call_queue, self.total_calls, self.call_active = deque(), 0, None 292 | self.hang_up_after = self.server.conf.calls_hang_up_after_minutes * 60.0 293 | self.log = get_logger() 294 | 295 | @err_report 296 | def call_init(self, cs): 297 | self.log.info('Handling call: %s', cs.caller) 298 | self.call_active = cs 299 | self.server.set_music_mute(True) 300 | if self.server.conf.audio_klaxon: 301 | self.server.set_volume_level('klaxon') 302 | self.server.wav_play_sync( 303 | self.server.conf.audio_klaxon, 304 | max_len=self.server.conf.audio_klaxon_max_length, 305 | padding=self.server.conf.audio_klaxon_padding ) 306 | self.server.set_volume_level('call') 307 | cs.call.answer() 308 | if self.hang_up_after > 0: 309 | self.server.poll_callback(ft.partial( 310 | self.on_cs_timeout, cs, mono_time() ), self.hang_up_after) 311 | 312 | @err_report 313 | def call_cleanup(self, cs): 314 | if not self.call_active: return 315 | self.call_active = False 316 | 317 | @err_report_fatal 318 | def on_reg_state(self): 319 | acc = self.account.info() 320 | self.log.debug( 321 | 'acc registration state (active: %s): %s %s', 322 | acc.reg_active, acc.reg_status, acc.reg_reason ) 323 | if acc.reg_status >= 400: 324 | self.server.close() 325 | raise PSAuthError( 'Account registration' 326 | ' failure: {} {}'.format(acc.reg_status, acc.reg_reason) ) 327 | 328 | @err_report 329 | def on_incoming_call(self, call): 330 | self.total_calls += 1 331 | call.pj = self.server.pj 332 | cs = PSCallState(self, self.total_calls, call) 333 | if not self.server.conf.calls_hold_concurrent\ 334 | or not self.call_active: self.call_init(cs) 335 | else: 336 | self.log.info( 'Queueing parallel call/announcement %s, because' 337 | ' another one is already in-progress: %s', cs.caller, self.call_active.caller ) 338 | self.call_queue.append(cs) 339 | 340 | @err_report 341 | def on_cs_media_activated(self, cs, conf_slot): 342 | self.server.conf_port_connect(conf_slot) 343 | 344 | @err_report 345 | def on_cs_disconnected(self, cs): 346 | self.call_cleanup(cs) 347 | if self.call_queue: self.call_init(self.call_queue.popleft()) 348 | else: self.server.set_music_mute(False) 349 | 350 | @err_report 351 | def on_cs_timeout(self, cs, ts0=None): 352 | if cs.call_state in ['null', 'disconnected', 'terminated']: return 353 | ts_diff = mono_time() - ts0 354 | log.debug( 355 | 'Terminating call [%s] (state: %s) due' 356 | ' to call-duration limit (%ds), elapsed: %ds', 357 | cs.caller, cs.call_state, self.hang_up_after, ts_diff ) 358 | cs.call.hangup(reason='call duration limit') 359 | 360 | 361 | class PSCallState(PSCallbacks): 362 | 363 | # Includes "terminated" state from pjsip/src/pjsip-ua/sip_inv.c 364 | # Updated on pjsua callbacks only. 365 | call_state_names = OrderedDict(enumerate(( 'null calling' 366 | ' incoming early connecting confirmed disconnected terminated' ).split())) 367 | 368 | @err_report 369 | def __init__(self, acc, call_id, call): 370 | self.cbs = call.pj.CallCallback(call) 371 | self.acc, self.call_id, self.call = acc, call_id, call 372 | self.pj_media_states = dict( 373 | (v, k.lower()) for k,v in vars(call.pj.MediaState).viewitems() ) 374 | self.log = get_logger() 375 | 376 | ci = self.call.info() 377 | self.call_state = self.call_state_names.get(ci.state, 'unknown-init') 378 | self.media_state = self.pj_media_states[ci.media_state] 379 | self.caller = ci.remote_uri 380 | m = re.findall(r'<([^>]+)>', self.caller) 381 | if m: self.caller = ' / '.join(m) 382 | self.caller = '{} (#{})'.format(self.caller, self.call_id) 383 | self.log.debug( 'New incoming call [%s]' 384 | ' (remote contact: %s)', self.caller, ci.remote_contact ) 385 | self.ev_type = 'call [{}]'.format(self.caller) 386 | 387 | call.set_callback(self) 388 | 389 | @err_report 390 | def on_state(self): 391 | ci = self.call.info() 392 | state_last, self.call_state = self.call_state, self.call_state_names.get(ci.state, 'unknown') 393 | self.log.debug( 394 | 'call [%s] state change: %r -> %r (SIP status: %s %s)', 395 | self.caller, state_last, self.call_state, ci.last_code, ci.last_reason ) 396 | if self.call_state == 'disconnected': self.acc.on_cs_disconnected(self) 397 | 398 | @err_report 399 | def on_media_state(self, _state_dict=dict()): 400 | ci = self.call.info() 401 | state_last, self.media_state = self.media_state, self.pj_media_states[ci.media_state] 402 | self.log.debug( 403 | 'call [%s] media-state change: %r -> %r (call time: %s)', 404 | self.caller, state_last, self.media_state, ci.call_time ) 405 | if self.media_state == 'active': 406 | self.acc.on_cs_media_activated(self, ci.conf_slot) 407 | 408 | 409 | 410 | ### PulseAudio Client 411 | 412 | class PulseClient(object): 413 | 414 | def __init__(self, si_filter_regexp, si_filter_debug=False, volume=None, fade=None): 415 | from pulsectl import ( Pulse, PulseSinkInputInfo, 416 | PulseOperationFailed, PulseLoopStop, PulseIndexError, PulseError ) 417 | # Running client here might start pa pid, so defer it until we actually 418 | # init audio output, and not started to just display some info and exit. 419 | self.si_filter_regexp, self.si_filter_debug = si_filter_regexp, si_filter_debug 420 | self._connect, self._si_t, self.pulse = Pulse, PulseSinkInputInfo, None 421 | self.PulseError, self.PulseLoopStop = PulseError, PulseLoopStop 422 | self.log = get_logger() 423 | 424 | self.volume = dict(zip(['music', 'klaxon', 'call'], it.repeat(-1))) 425 | self.volume.update(volume or dict()) 426 | self.fade = dict((t, dict(duration=0, offset=0, min=0, steps=25)) for t in ['in', 'out']) 427 | for t, v in self.fade.viewitems(): v.update((fade or dict()).get(t) or dict()) 428 | 429 | def init(self): 430 | self.pulse = self._connect('paging-server') 431 | self.pulse.event_mask_set('sink_input') 432 | self.pulse.event_callback_set(self._handle_new_si) 433 | self.si_queue, self.si_pjsua, self.changes = deque(), None, dict() 434 | self.music_muted = False 435 | self.set_music_mute(False) 436 | 437 | def close(self): 438 | if self.pulse: 439 | self.pulse.close() 440 | self.pulse = None 441 | 442 | def set_music_mute(self, muted=None): 443 | if muted is None: muted = self.music_muted 444 | if self.music_muted and not muted: fade = 'in' 445 | elif not self.music_muted and muted: fade = 'out' 446 | else: fade = None 447 | self.music_muted = muted 448 | if fade: self.changes['music-fade'] = self._change_fade(fade) 449 | else: self.si_queue.append(None) 450 | 451 | def set_pjsua_volume(self, t): 452 | self.changes['pjsua-volume'] = self._change_volume(t) 453 | 454 | def _handle_new_si(self, ev): 455 | if ev.t != 'new' or ev.facility != 'sink_input': return 456 | self.si_queue.append(ev.index) 457 | raise self.PulseLoopStop 458 | 459 | def _match_music_si(self, si=None): 460 | idx, si = (si, self.pulse.sink_input_info(si))\ 461 | if not isinstance(si, self._si_t) else (si.index, si) 462 | for k, v in si.proplist.viewitems(): 463 | v = '{}={}'.format(k, v) 464 | m = re.search(self.si_filter_regexp, v) 465 | if self.si_filter_debug: 466 | self.log.debug(' - prop%s: %r', ['', '[MATCH]'][bool(m)], v) 467 | if m: return si 468 | # self.log.debug('Ignoring unmatched sink-input: %s', si) 469 | 470 | @err_report 471 | def _process_changes(self): 472 | wakeups = list() 473 | 474 | for k in self.changes.keys(): 475 | c = self.changes[k] 476 | try: wakeup = next(c) 477 | except StopIteration: del self.changes[k] 478 | else: 479 | if isinstance(wakeup, (int, float)): wakeup = [wakeup] 480 | wakeups.extend(wakeup) 481 | 482 | while self.si_queue: 483 | si = self.si_queue.popleft() 484 | if si is None: 485 | self.si_queue.extend(self.pulse.sink_input_list()) 486 | continue 487 | try: 488 | si = self._match_music_si(si) 489 | if si: 490 | if not self.music_muted and self.volume['music'] >= 0: 491 | self.pulse.volume_set_all_chans(si, self.volume['music']) 492 | self.log.debug( 'Setting mute to %s' 493 | ' for sink-input: %s', ['OFF', 'ON'][self.music_muted], si ) 494 | self.pulse.mute(si, self.music_muted) 495 | except self.PulseError: continue 496 | 497 | return wakeups 498 | 499 | @err_report 500 | def _change_volume(self, t): 501 | v = self.volume[t] 502 | if v <= 0: return 503 | if self.volume[t] >= 0: v = self.volume[t] 504 | for n in range(2): 505 | if not self.si_pjsua: 506 | pid = os.getpid() # pjsua runs in a thread of this process 507 | for si in self.pulse.sink_input_list(): 508 | pid_chk = int(si.proplist.get('application.process.id') or 0) 509 | m = pid_chk == pid 510 | if self.si_filter_debug: 511 | self.log.debug( 'Sink-input %s proc-id check: %s (si) ==' 512 | ' %s (pjsua)%s', si.index, pid_chk, pid, ' [MATCH]' if m else '' ) 513 | if not m: continue 514 | self.si_pjsua = si 515 | break 516 | if self.si_pjsua: 517 | try: 518 | v_old = self.pulse.volume_get_all_chans(self.si_pjsua) 519 | if round(v, 2) != round(v_old, 2): 520 | self.log.debug( 'Setting pjsua stream' 521 | ' volume level: %.2f -> %.2f (%s)', v_old, v, t ) 522 | self.pulse.volume_set_all_chans(self.si_pjsua, v) 523 | except self.PulseError: 524 | self.si_pjsua = None 525 | continue # check other streams, retry 526 | break 527 | if not self.si_pjsua: 528 | self.log.warn( 'Failed to detect pjsua stream' 529 | ' in pulse sink inputs, not adjusting volume for it' ) 530 | return; yield 531 | 532 | @err_report 533 | def _change_fade(self, t): 534 | s = self.fade[t] 535 | if s['duration'] <= 0: 536 | self.set_music_mute() 537 | return 538 | 539 | ts_start = mono_time() + s['offset'] 540 | si_list = filter(self._match_music_si, self.pulse.sink_input_list()) 541 | v_si_min, v_si_max = dict(), dict() 542 | if t == 'out': 543 | v_si_max.update( 544 | (si.index, self.pulse.volume_get_all_chans(si)) for si in si_list ) 545 | v_si_min.update((si.index, s['min']) for si in si_list) 546 | self.volume['fade'] = v_si_max # to restore same levels on fade-in 547 | else: 548 | v_si_max_prev = self.volume.pop('fade', dict()) 549 | for si in si_list: 550 | v = None 551 | if si.index in v_si_max_prev: v = v_si_max_prev[si.index] 552 | elif self.volume['music'] > 0: v = self.volume['music'] 553 | if v is not None: 554 | v_si_max[si.index], v_si_min[si.index] = v, s['min'] 555 | try: self.pulse.volume_set_all_chans(si, v_si_min[si.index]) 556 | except self.PulseError: pass 557 | self.set_music_mute() 558 | v_si_len = len(set(v_si_max.keys() + v_si_min.keys())) 559 | 560 | self.log.debug('Starting music fade-%s for %s pulse stream(s)', t, v_si_len) 561 | for n in xrange(1, s['steps']+1): 562 | ts_step = ts_start + (s['duration'] * (n / float(s['steps']))) 563 | while True: 564 | ts = mono_time() 565 | if ts_step > mono_time(): yield ts_step 566 | else: break 567 | for si in si_list: 568 | try: v_max = v_si_max[si.index] 569 | except KeyError: continue 570 | v_min = v_si_min.get(si.index, s['min']) 571 | v_range, k = max(0, v_max - v_min), n / float(s['steps']) 572 | v = v_min + v_range * (k if t == 'in' else (1-k)) 573 | # self.log.debug( 'Stream %s music fade-%s step' 574 | # ' %s/%s: base=%.2f level=%.2f', si.index, t, n, s['steps'], v_min, v ) 575 | try: self.pulse.volume_set_all_chans(si, v) 576 | except self.PulseError: pass 577 | 578 | for si in si_list: 579 | if t == 'out': v = v_si_min.get(si.index, s['min']) 580 | else: 581 | v = v_si_max.get(si.index) 582 | if v is None: continue 583 | try: self.pulse.volume_set_all_chans(si, v) 584 | except self.PulseError: pass 585 | 586 | self.log.debug('Finished music fade-%s sequence for %s stream(s)', t, v_si_len) 587 | self.set_music_mute() 588 | 589 | def poll_wakeup(self): 590 | if not self.pulse: return 591 | self.pulse.event_listen_stop() 592 | 593 | @err_report 594 | def poll(self, timeout=None): 595 | # Only safe to call pulse here, and before event_listen() 596 | wakeups = [mono_time() + timeout] + (self._process_changes() or list()) 597 | delay = min(wakeups) - mono_time() 598 | if delay > 0: 599 | try: self.pulse.event_listen(delay) 600 | except: 601 | if self.pulse: raise 602 | 603 | 604 | 605 | ### Server 606 | 607 | class PagingServerError(Exception): pass 608 | class PSConfigurationError(PagingServerError): pass 609 | class PSAuthError(PagingServerError): pass 610 | 611 | class PagingServer(object): 612 | 613 | lib = pj_out_dev = pj_out_port = None 614 | 615 | @err_report 616 | def __init__(self, conf, sd_cycle=None): 617 | import pjsua 618 | self.pj, self.pulse = pjsua, None 619 | self.conf, self.sd_cycle = conf, sd_cycle 620 | self.log = get_logger() 621 | self.running, self._poll_callbacks, self._locks = None, list(), set() 622 | self._poll_lock, self._poll_hold = threading.Lock(), threading.Lock() 623 | 624 | 625 | def match_info(self, infos, spec, kind): 626 | infos_match, infos_left = list(), list() 627 | if spec.isdigit(): 628 | try: infos_match = [infos[int(spec)]] 629 | except KeyError: 630 | self.log.error( 'Failed to find %s with id=%s,' 631 | ' available: %s', kind, spec, ', '.join(map(bytes, infos.keys())) ) 632 | infos_match, infos_left = list(), infos 633 | else: 634 | info_re = re.compile(spec, re.I) 635 | for info in infos.viewvalues(): 636 | dst_list = infos_match if info_re.search(info['name']) else infos_left 637 | dst_list.append(info) 638 | if len(infos_match) != 1: 639 | buff = io.BytesIO() 640 | pprint_infos( infos_match, 'Specification {!r}' 641 | ' matched {} entries'.format(spec, len(infos_match)), buff=buff ) 642 | pprint_infos( infos_left, 643 | 'Unmatched entries'.format(spec, len(infos_left)), buff=buff ) 644 | raise PSConfigurationError( 645 | ( 'Failed to pick matching {} after pjsua init.\n{}' 646 | 'Only one of these has to be specified in the configuration file.\n' 647 | 'See "Audio configuration" section in the README file for more details.' ) 648 | .format(kind, buff.getvalue()) ) 649 | return infos_match[0] 650 | 651 | def init_outputs(self): 652 | if self.pj_out_dev is None: 653 | m, spec = self.get_pj_out_devs(), self.conf.audio_pjsua_device 654 | m = self.match_info(m, spec, 'output device') 655 | self.pj_out_dev = m['id'] 656 | self.log.debug('Using output device: %s [%s]', m['name'], self.pj_out_dev) 657 | self.lib.set_snd_dev(self.pj_out_dev, self.pj_out_dev) 658 | 659 | if self.pj_out_port is None: 660 | m, spec = self.get_pj_conf_ports(), self.conf.audio_pjsua_conf_port 661 | m = self.match_info(m, spec, 'conference output port') 662 | self.pj_out_port = m['id'] 663 | self.log.debug('Using output port: %s [%s]', m['name'], self.pj_out_port) 664 | 665 | try: self.pulse.init() 666 | except Exception as err: 667 | self.log.error('Failed to initialize PulseAudio controls: %s', err) 668 | raise 669 | 670 | @err_report_fatal 671 | def init(self): 672 | assert not self.lib 673 | 674 | self.log.debug('pulse init') 675 | conf_volume = dict( 676 | (k, getattr(self.conf, 'audio_volume_{}'.format(k)) / 100.0) 677 | for k in ['music', 'klaxon', 'call'] ) 678 | conf_fade= dict( (t, dict( 679 | (k, getattr(self.conf, 'audio_music_fade_{}_{}'.format(t, k))) 680 | for k in ['duration', 'offset', 'min'] )) for t in ['in', 'out'] ) 681 | for t, v in conf_fade.viewitems(): v['min'] /= 100.0 682 | self.pulse = PulseClient( 683 | volume=conf_volume, fade=conf_fade, 684 | si_filter_regexp=self.conf.audio_pulse_match, 685 | si_filter_debug=self.conf.server_dump_pulse_props ) 686 | 687 | self.log.debug('pjsua init') 688 | 689 | # Before logging is configured, pjsua prints some init info to plain stderr fd 690 | # Unless there's a good reason to see this, like debugging early crashes, 691 | # there should be no need to have this exception, hence the "suppress" hack 692 | with suppress_streams('stdout'): self.lib = lib = self.pj.Lib() 693 | 694 | conf_ua = self.pj.UAConfig() 695 | conf_ua.max_calls = 10 696 | conf_ua.user_agent = ( 'PagingServer/git' 697 | ' (+https://github.com/AccelerateNetworks/PagingServer)' ) 698 | conf_media = self.pj.MediaConfig() 699 | 700 | conf_log = lambda level,msg,n,\ 701 | log=get_logger('pjsua'): log.debug(msg.strip().split(None,1)[-1]) 702 | conf_log = self.pj.LogConfig(level=self.conf.server_pjsua_log_level, callback=conf_log) 703 | 704 | lib.init(conf_ua, conf_log, conf_media) 705 | 706 | # lib.start(with_thread=False) doesn't work well with python code 707 | transport = lib.create_transport(self.pj.TransportType.UDP) 708 | lib.start(with_thread=True) 709 | lib.c = self.pj._pjsua 710 | 711 | @err_report_fatal 712 | def close(self): 713 | self.stop() 714 | if self.lib: 715 | self.log.debug('pjsua cleanup') 716 | self.lib.destroy() 717 | self.lib = None 718 | if self.pulse: 719 | self.log.debug('pulse cleanup') 720 | self.pulse.close() 721 | self.pulse = None 722 | 723 | def __enter__(self): 724 | self.init() 725 | return self 726 | def __exit__(self, *err): self.close() 727 | 728 | 729 | def poll_busywork(self): 730 | 'Stuff to do after every poll cycle.' 731 | if self.conf.audio_klaxon_tmpdir: os.utime(self.conf.audio_klaxon_tmpdir, None) 732 | 733 | @contextmanager 734 | def poll_wakeup(self, loop_wait=5.0, loop_interval=0.1): 735 | 'Anything poll-related MUST be done in this context.' 736 | lock = self.pulse and self.running is not False 737 | if lock: 738 | with self._poll_hold: 739 | for n in xrange(int(loop_wait / loop_interval)): 740 | # wakeup only works when loop is actually started, 741 | # which might not be the case regardless of any locks. 742 | self.pulse.poll_wakeup() 743 | if self._poll_lock.acquire(False): break 744 | time.sleep(loop_interval) 745 | else: 746 | raise PagingServerError('poll_wakeup() hangs, likely locking issue') 747 | try: yield 748 | finally: self._poll_lock.release() 749 | else: yield 750 | 751 | def poll_callback(self, func, delay=None, ts=None): 752 | with self.poll_wakeup(): 753 | if ts is None: ts = mono_time() 754 | ts += delay or 0 755 | heappush(self._poll_callbacks, (ts, func)) 756 | 757 | def poll_lock(self, delay): 758 | lock = threading.Lock() 759 | def lock_release_safe(): 760 | try: lock.release() 761 | except: pass 762 | self._locks.add(lock_release_safe) 763 | try: 764 | lock.acquire() 765 | self.poll_callback(lock_release_safe, delay) 766 | lock.acquire() 767 | finally: 768 | lock_release_safe() 769 | self._locks.discard(lock_release_safe) 770 | 771 | @err_report_fatal 772 | def poll(self, timeout=None): 773 | if threading.current_thread().name != 'MainThread': 774 | assert timeout 775 | return self.poll_lock(timeout) 776 | ts = mono_time() 777 | self.running, ts_deadline = True, timeout and mono_time() + timeout 778 | while True: 779 | with self._poll_hold: self._poll_lock.acquire() # fuck threads 780 | ts = mono_time() 781 | try: 782 | if not self.sd_cycle or not self.sd_cycle.ts_next: delay = 600 783 | else: 784 | delay = self.sd_cycle.ts_next - ts 785 | if delay <= 0: 786 | self.sd_cycle(ts) 787 | continue 788 | if not (self.running and self.lib): break 789 | if ts_deadline: delay = min(delay, ts_deadline - ts) 790 | while self._poll_callbacks: 791 | ts_cb, cb = self._poll_callbacks[0] 792 | if ts >= ts_cb: 793 | heappop(self._poll_callbacks) 794 | cb() 795 | else: 796 | delay = min(delay, ts_cb - ts) 797 | break 798 | # self.log.debug('poll delay: %.1f', delay) 799 | self.pulse.poll(max(0, delay)) 800 | self.poll_busywork() 801 | ts = mono_time() 802 | if ts_deadline and ts > ts_deadline: break 803 | finally: self._poll_lock.release() 804 | 805 | @err_report_fatal 806 | def run(self): 807 | assert self.lib, 'Must be initialized before run()' 808 | self.init_outputs() 809 | 810 | domain, user, pw = map(ft.partial(self.conf.get, 'sip'), ['domain', 'user', 'pass']) 811 | if not domain or domain == '': 812 | raise PagingServerError( 'SIP account credentials' 813 | ' (domain, user, password) were not configured, refusing to start' ) 814 | acc = self.lib.create_account(self.pj.AccountConfig(domain, user, pw)) 815 | acc.set_callback(PSAccountState(self)) 816 | 817 | self.log.debug('pjsua event loop started') 818 | self.poll() 819 | self.log.debug('pjsua event loop has been stopped') 820 | 821 | def stop(self): 822 | self.running = False 823 | if self._locks: 824 | for release_func in self._locks: release_func() 825 | self._locks.clear() 826 | self.poll_wakeup() 827 | 828 | 829 | def get_pj_conf_ports(self): 830 | return dict( 831 | (n, dict_with(dict_for_ctype(self.lib.c.conf_get_port_info(port_id)), id=n)) 832 | for n, port_id in enumerate(self.lib.c.enum_conf_ports()) ) 833 | 834 | def get_pj_out_devs(self): 835 | return dict( (n, dict_with(vars(dev), id=n)) 836 | for n, dev in enumerate(self.lib.enum_snd_dev()) ) 837 | 838 | def conf_port_connect(self, conf_port): 839 | self.lib.conf_connect(conf_port, self.pj_out_port) 840 | 841 | def set_music_mute(self, muted): 842 | with self.poll_wakeup(): self.pulse.set_music_mute(muted) 843 | 844 | def set_volume_level(self, state): 845 | assert state in ['klaxon', 'call'] 846 | with self.poll_wakeup(): self.pulse.set_pjsua_volume(state) 847 | 848 | 849 | @contextmanager 850 | def wav_play(self, path, loop=False, connect_to_out=True): 851 | player_id = self.lib.create_player(path, loop=loop) 852 | try: 853 | port = self.lib.player_get_slot(player_id) 854 | if connect_to_out: self.conf_port_connect(port) 855 | yield port 856 | finally: self.lib.player_destroy(player_id) 857 | 858 | def wav_length(self, path, force_file=True): 859 | # Only useful to stop playback in a hacky ad-hoc way, 860 | # because pjsua python lib doesn't export proper callback, 861 | # and ctypes wrapper doesn't seem to work reliably either (see 7f1df5d) 862 | import wave 863 | if force_file and not isfile(path): # missing, fifo, etc 864 | raise PagingServerError(path) 865 | with closing(wave.open(path, 'r')) as src: 866 | return src.getnframes() / float(src.getframerate()) 867 | 868 | def wav_play_sync(self, path, max_len=None, padding=0): 869 | ts_diff = self.wav_length(path) 870 | if max_len and max_len > 0: ts_diff = min(ts_diff, max_len) 871 | with self.wav_play(path) as port: 872 | self.log.debug('Started blocking playback of wav for time: %.1fs', ts_diff) 873 | self.poll(ts_diff + padding) 874 | self.log.debug('wav playback finished') 875 | 876 | 877 | 878 | ### CLI and such 879 | 880 | def pprint_infos(infos, title=None, pre=None, buff=None): 881 | p = print if not buff else ft.partial(print, file=buff) 882 | if title: 883 | p('{}:'.format(title)) 884 | if pre is None: pre = ' '*2 885 | pre = pre or '' 886 | if isinstance(infos, dict): infos = infos.values() 887 | for info in infos: 888 | info_id = '[{}] '.format(info['id']) if 'id' in info else '' 889 | p('{}{}{}'.format(pre, info_id, info['name'])) 890 | for k, v in sorted(info.viewitems()): 891 | if k in ['id', 'name']: continue 892 | p('{} {}: {}'.format(pre, k, v)) 893 | 894 | def pprint_conf(conf, title=None): 895 | cat, chk = None, re.compile( 896 | '^({})_(.*)$'.format('|'.join(map(re.escape, conf._conf_sections))) ) 897 | if title: print(';; {}'.format(title)) 898 | for k in sorted(dir(conf)): 899 | m = chk.search(k) 900 | if not m: continue 901 | if m.group(1) != cat: 902 | cat = m.group(1) 903 | print('\n[{}]'.format(cat)) 904 | v = conf.get(k) 905 | if isinstance(v, bool): v = ['no', 'yes'][v] 906 | print('{} = {}'.format(m.group(2), v)) 907 | 908 | def main(args=None, defaults=None): 909 | args, defaults = sys.argv[1:] if args is None else args, defaults or Conf() 910 | 911 | import argparse 912 | parser = argparse.ArgumentParser( 913 | usage='%(prog)s [options] [conf [conf ...]]', 914 | description='SIP-based Announcement / PA / Paging / Public Address Server system.') 915 | 916 | group = parser.add_argument_group('configuration options') 917 | group.add_argument('conf', nargs='*', 918 | help='Extra config files to load on top of default ones.' 919 | ' Values in latter ones override those in the former, cli values override all.' 920 | ' Initial files (always loaded, if exist): {}'.format(' '.join(defaults._conf_paths))) 921 | group.add_argument('--dump-conf', action='store_true', 922 | help='Print all configuration settings, which will be used with' 923 | ' currently detected (and/or specified) configuration files, and exit.') 924 | group.add_argument('--dump-conf-defaults', action='store_true', 925 | help='Print all default settings, which would be used' 926 | ' if no configuration files were overriding these, and exit.') 927 | 928 | group = parser.add_argument_group('startup options') 929 | group.add_argument('--systemd', action='store_true', 930 | help='Use systemd service' 931 | ' notification/watchdog mechanisms in daemon modes, if available.') 932 | 933 | group = parser.add_argument_group( 934 | 'pjsua output configuration and testing', 935 | 'Options related to sound output from SIP calls (pjsua client).') 936 | group.add_argument('--dump-pjsua-devices', action='store_true', 937 | help='Dump the list of sound devices that pjsua/portaudio detects and exit.') 938 | group.add_argument('--dump-pjsua-conf-ports', action='store_true', 939 | help='Dump the list of conference ports that pjsua creates after init and exit.') 940 | group.add_argument('--test-audio-file', metavar='path', 941 | help='Play specified wav file from pjsua output and exit.' 942 | ' Sound will be played with call volume level, if set via' 943 | ' config file (see "volume-call" option in "[audio]" section there).' 944 | ' Can be useful to test whether sound output from SIP calls is setup and working correctly.') 945 | 946 | group = parser.add_argument_group( 947 | 'debugging, logging and other misc options', 948 | 'Use these to understand more about what' 949 | ' is failing or going on. Can be especially useful on first runs.') 950 | group.add_argument('-d', '--debug', 951 | action='store_true', help='Verbose operation mode.') 952 | group.add_argument('--dump-pulse-props', action='store_true', 953 | help='Dump all properties of pulse streams as they get matched. Requires --debug.') 954 | group.add_argument('--pjsua-log-level', 955 | metavar='0-10', type=int, 956 | help='pjsua lib logging level. Only used when --debug is enabled.' 957 | ' Zero is only for fatal errors, higher levels are more noisy.' 958 | ' Default: {}'.format(defaults.server_pjsua_log_level)) 959 | group.add_argument('--sentry-dsn', metavar='dsn', 960 | help='Use Sentry to capture errors/logging using "raven" module.' 961 | ' Default: {}'.format(defaults.server_sentry_dsn)) 962 | group.add_argument('--version', action='version', 963 | version='%(prog)s version-unknown (see python package version)') 964 | 965 | opts = parser.parse_args(args) 966 | 967 | if opts.dump_conf_defaults: 968 | pprint_conf(defaults, 'Default configuration options') 969 | return 970 | 971 | conf_file = configparser.SafeConfigParser(allow_no_value=True) 972 | conf_user_paths = map(expanduser, opts.conf or list()) 973 | for p in conf_user_paths: 974 | if not os.access(p, os.O_RDONLY): 975 | parser.error('Specified config file does not exists: {}'.format(p)) 976 | conf_file.read(list(defaults._conf_paths) + conf_user_paths) 977 | 978 | conf = Conf() 979 | for k in conf._conf_sections: 980 | update_conf_from_file(conf, conf_file, section=k, prefix='{}_'.format(k)) 981 | for k in 'debug', 'dump_pulse_props', 'pjsua_log_level', 'sentry_dsn': 982 | v = getattr(opts, k) 983 | if v not in [None, False]: setattr(conf, 'server_{}'.format(k), v) 984 | 985 | if opts.dump_conf: 986 | pprint_conf(conf, 'Current configuration options') 987 | return 988 | 989 | global log 990 | log = '%(name)s %(levelname)s :: %(message)s' 991 | if not opts.systemd: log = '%(asctime)s :: {}'.format(log) 992 | logging.basicConfig( 993 | format=log, datefmt='%Y-%m-%d %H:%M:%S', 994 | level=logging.DEBUG if conf.server_debug else logging.WARNING ) 995 | log = logging.getLogger('main') 996 | if conf.server_debug: 997 | for k in 'stdout', 'stderr': 998 | setattr(sys, k, os.fdopen(getattr(sys, k).fileno(), 'wb', 0)) 999 | 1000 | if conf.server_sentry_dsn: 1001 | global raven_client 1002 | import raven 1003 | dsn = conf.server_sentry_dsn 1004 | raven_client = raven.Client(conf.server_sentry_dsn) 1005 | # XXX: can be hooked-up into logging and/or sys.excepthook 1006 | 1007 | if opts.systemd: 1008 | from systemd import daemon 1009 | def sd_cycle(ts=None): 1010 | if not sd_cycle.ready: 1011 | daemon.notify('READY=1') 1012 | daemon.notify('STATUS=Running...') 1013 | sd_cycle.ready = True 1014 | if sd_cycle.delay: 1015 | if ts is None: ts = mono_time() 1016 | delay = ts - sd_cycle.ts_next 1017 | if delay > 0: time.sleep(delay) 1018 | sd_cycle.ts_next += sd_cycle.delay 1019 | else: sd_cycle.ts_next = None 1020 | if sd_cycle.wdt: daemon.notify('WATCHDOG=1') 1021 | sd_cycle.ts_next = mono_time() 1022 | wd_pid, wd_usec = (os.environ.get(k) for k in ['WATCHDOG_PID', 'WATCHDOG_USEC']) 1023 | if wd_pid and wd_pid.isdigit() and int(wd_pid) == os.getpid(): 1024 | wd_interval = float(wd_usec) / 2e6 # half of interval in seconds 1025 | assert wd_interval > 0, wd_interval 1026 | else: wd_interval = None 1027 | if wd_interval: 1028 | log.debug('Initializing systemd watchdog pinger with interval: %ss', wd_interval) 1029 | sd_cycle.wdt, sd_cycle.delay = True, wd_interval 1030 | else: sd_cycle.wdt, sd_cycle.delay = False, None 1031 | sd_cycle.ready = False 1032 | else: sd_cycle = None 1033 | 1034 | server_ctx = PagingServer(conf, sd_cycle) 1035 | 1036 | if opts.dump_pjsua_devices: 1037 | with server_ctx as server: 1038 | devs = server.get_pj_out_devs() 1039 | pprint_infos(devs, 'Detected sound devices') 1040 | return 1041 | 1042 | if opts.dump_pjsua_conf_ports: 1043 | with server_ctx as server: 1044 | ports = server.get_pj_conf_ports() 1045 | pprint_infos(ports, 'Detected conference ports') 1046 | return 1047 | 1048 | if opts.test_audio_file: 1049 | opts.test_audio_file = ffmpeg_towav(opts.test_audio_file) 1050 | with server_ctx as server: 1051 | try: 1052 | server.init_outputs() 1053 | server.set_volume_level('call') 1054 | server.wav_play_sync(opts.test_audio_file) 1055 | except PSConfigurationError as err: 1056 | print(bytes(err), file=sys.stderr) 1057 | return 1 1058 | return 1059 | 1060 | if conf.audio_klaxon: 1061 | if not isfile(conf.audio_klaxon): 1062 | parser.error(( 'Specified klaxon file does not exists' 1063 | ' (set empty value there to disable using it entirely): {!r}' ).format(conf.audio_klaxon)) 1064 | if not conf.audio_klaxon.endswith('.wav'): 1065 | conf.audio_klaxon = ffmpeg_towav( conf.audio_klaxon, 1066 | max_len=conf.audio_klaxon_max_length, tmp_dir=conf.audio_klaxon_tmpdir ) 1067 | if not conf.audio_klaxon_tmpdir: conf.audio_klaxon_tmpdir = ffmpeg_towav.tmp_dir 1068 | 1069 | log.info('Starting PagingServer...') 1070 | with server_ctx as server: 1071 | for sig in signal.SIGINT, signal.SIGTERM: 1072 | signal.signal(sig, lambda sig,frm: server.close()) 1073 | try: server.run() 1074 | except (PSConfigurationError, PSAuthError) as err: 1075 | print('ERROR [{}]: {}'.format(err.__class__.__name__, err), file=sys.stderr) 1076 | return 1 1077 | except Exception as err: 1078 | # Logged here in case cleanup fails miserably and pid gets brutally murdered by kill -9 1079 | log.exception('Server runtime ERROR [%s], aborting: %s', err.__class__.__name__, err) 1080 | raise 1081 | except KeyboardInterrupt: pass 1082 | log.info('Finished') 1083 | 1084 | if __name__ == '__main__': sys.exit(main()) 1085 | -------------------------------------------------------------------------------- /setup-configs/mpd.instance.conf: -------------------------------------------------------------------------------- 1 | # log_file "/dev/stdout" 2 | music_directory "/var/empty" 3 | 4 | # password "super-sikrit-admin-password@read,add,control,admin" 5 | # password "password-for-teh-peeple@read,add,control" 6 | 7 | bind_to_address "/run/mpd-paging/instance" 8 | zeroconf_enabled "no" 9 | 10 | playlist_plugin { 11 | name "m3u" 12 | enabled "true" 13 | } 14 | playlist_plugin { 15 | name "extm3u" 16 | enabled "true" 17 | } 18 | playlist_plugin { 19 | name "pls" 20 | enabled "true" 21 | } 22 | 23 | input { 24 | plugin "curl" 25 | } 26 | 27 | audio_output { 28 | type "pulse" 29 | name "mpd-instance" 30 | sink "alsa-instance" 31 | } 32 | -------------------------------------------------------------------------------- /setup-configs/mpd@.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Requires=pulse.service 3 | After=network.target network-online.target sound.target pulse.service 4 | Wants=network-online.target 5 | 6 | [Service] 7 | User=paging 8 | LimitRTPRIO=50 9 | LimitRTTIME=infinity 10 | PermissionsStartOnly=yes 11 | ExecStartPre=/bin/mkdir -p -m770 /run/mpd-paging 12 | ExecStartPre=/bin/chown root:paging /run/mpd-paging 13 | ExecStart=/usr/bin/env mpd --no-daemon /etc/mpd.%I.conf 14 | ExecStartPost=/bin/bash -c '\ 15 | set -e -o pipefail;\ 16 | [[ ! -e /etc/mpd.%I.url ]] || {\ 17 | xargs curl -s /etc/mpd.%I.m3u\ 18 | || xargs wget -q -O- /etc/mpd.%I.m3u; };\ 19 | [[ ! -e /etc/mpd.%I.m3u ]] || {\ 20 | sleep 2; mpc="mpc --wait -h /run/mpd-paging/%I";\ 21 | $mpc repeat on;\ 22 | $mpc clear;\ 23 | tr -d \'\r\' &2 "Usage: $bin" 6 | echo >&2 7 | echo >&2 "Install PagingServer and all necessary" 8 | echo >&2 " dependencies from the deb repo on a Debian Jessie system." 9 | echo >&2 "See also README.install.rst file." 10 | exit ${1:-0} 11 | } 12 | [[ $# -gt 0 || "$1" = -h || "$1" = --help ]] && usage 13 | 14 | 15 | set -e -o pipefail 16 | 17 | apt_install() { 18 | env\ 19 | DEBIAN_FRONTEND=noninteractive\ 20 | DEBIAN_PRIORITY=critical\ 21 | apt-get\ 22 | -o Dpkg::Options::="--force-confdef"\ 23 | -o Dpkg::Options::="--force-confold"\ 24 | --force-yes -y install "$@" 25 | } 26 | 27 | apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 3D021F1F4C670809 28 | echo 'deb http://paging-server.ddns.net/ jessie main' >/etc/apt/sources.list.d/paging-server.list 29 | apt-get update 30 | 31 | apt_install --no-install-recommends pulseaudio pulseaudio-utils alsa-utils 32 | apt_install paging-server python-systemd 33 | apt_install python-setuptools # missing dep for older packages 34 | 35 | if getent passwd paging &>/dev/null ; then 36 | [[ -e /home/paging ]] || { 37 | usermod -d /home/paging paging 38 | mkdir -p -m700 /home/paging 39 | chown -R paging: /home/paging 40 | } 41 | else useradd -r -md /home/paging -s /bin/false -G audio paging 42 | fi 43 | 44 | [[ -e /etc/paging.conf ]]\ 45 | || install -o root -g paging -m640 -T /usr/share/doc/paging-server/paging.example.conf /etc/paging.conf 46 | 47 | 48 | echo 49 | echo -------------------- 50 | echo 51 | echo "Installation process completed successfully." 52 | echo 53 | echo "Edit configuration file in: /etc/paging.conf" 54 | echo "At least domain/user/pass MUST be specified there in the [sip] section." 55 | echo 56 | echo "Then configure pulseaudio and/or music player instances to start." 57 | echo 58 | echo "After that, start the service with: systemctl start paging" 59 | echo " check status: systemctl status paging" 60 | echo " check service log with: journalctl -ab -u paging" 61 | echo " continuously 'tail' log with: journalctl -af -u paging" 62 | echo " continuously tail all system logs with: journalctl -af" 63 | echo 64 | echo "If service has been started and is running successfully," 65 | echo " enable it to run on boot with: systemctl enable paging" 66 | echo 67 | echo -------------------- 68 | 69 | exit 0 70 | -------------------------------------------------------------------------------- /setup-scripts/install.debian_jessie.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | usage() { 4 | bin=$(basename $0) 5 | echo >&2 "Usage: $bin [-x]" 6 | echo >&2 7 | echo >&2 "Install PagingServer and all necessary" 8 | echo >&2 " dependencies to preset paths on a Debian Jessie system." 9 | echo >&2 "See also README.install.rst file." 10 | exit ${1:-0} 11 | } 12 | [[ $# -gt 1 || "$1" = -h || "$1" = --help ]] && usage 13 | 14 | set -e -o pipefail 15 | 16 | 17 | [[ "$1" = -x ]] || { 18 | echo >&2 "This script is intended to run ONLY on Debian Jessie system." 19 | echo >&2 "It will install packages via apt-get and create some preset paths (e.g. /srv/paging) on the system." 20 | echo >&2 "If you are OK with that, run script with -x option, like this: $(basename "$0") -x" 21 | echo >&2 "See README.install.rst file for descriptions of all the actions here." 22 | exit 1 23 | } 24 | 25 | [[ $(id -u) -eq 0 ]] || { 26 | echo >&2 "This script should be run as root." 27 | exit 1 28 | } 29 | 30 | 31 | pkg_cache=/var/tmp/PagingServer.debs 32 | pkg_list="$pkg_cache"/apt-get-installed.list 33 | pkg_release=4 34 | 35 | tmp_dir=$(mktemp -d "${HOME}"/PagingServer.install.XXXXXX) 36 | [[ -n "$NOCLEANUP" ]] || trap "rm -rf '$tmp_dir'" EXIT 37 | cd "$tmp_dir" 38 | 39 | echo -------------------- 40 | echo 41 | echo "Using temporary directory (will be removed on exit): $tmp_dir" 42 | echo 43 | echo "Names of all NEW packages installed *via apt-get* will be logged to: $pkg_list" 44 | echo "Some of these (e.g. build tools) can be manually removed afterwards." 45 | echo 46 | echo "All created debian packages will be stored in: $pkg_cache" 47 | echo 48 | echo -------------------- 49 | echo 50 | 51 | 52 | die() { echo >&2 "ERROR: $@"; exit 1; } 53 | die_check() { echo >&2 "Check failed: $@"; exit 1; } 54 | force_empty_line_end() { { rm "$1"; awk '{chk=!$0; print} END {if (!chk) print ""}' >"$1"; } <"$1"; } 55 | 56 | export DEBIAN_FRONTEND=noninteractive DEBIAN_PRIORITY=critical 57 | 58 | dpkg_check() { 59 | for p in "$@"; do 60 | dpkg-query -W -f='${Status}\n' "$p" | grep -q '^install ok installed$' || return 1 61 | done 62 | } 63 | 64 | apt_install() { 65 | local args=() args_pkg=() 66 | for arg in "$@"; do 67 | [[ "${arg#-}" = "$arg" ]] && 68 | { dpkg_check "$arg" || { args_pkg+=( "$arg" ); false; } }\ 69 | || args+=( "$arg" ) 70 | done 71 | [[ ${#args_pkg[@]} -ne 0 ]] || return 0 72 | 73 | for arg in "${args_pkg[@]}"; do echo "$arg" >>"$pkg_list"; done 74 | LC_ALL=C sort -u "$pkg_list" >"$pkg_list".clean 75 | mv "$pkg_list"{.clean,} 76 | 77 | apt-get\ 78 | -o Dpkg::Options::="--force-confdef"\ 79 | -o Dpkg::Options::="--force-confold"\ 80 | --force-yes -y install "${args[@]}" 81 | } 82 | 83 | chk_install() { 84 | checkinstall --pkgrelease="$pkg_release" "$@" 85 | } 86 | 87 | mkdir -p "$pkg_cache" 88 | 89 | 90 | apt_install --no-install-recommends pulseaudio pulseaudio-utils alsa-utils 91 | 92 | pulseaudio --version | grep '^pulseaudio '\ 93 | || die "Failed to match valid pulseaudio version from 'pulseaudio --version'" 94 | 95 | 96 | apt_install curl build-essential checkinstall python python-dev python-setuptools libasound2-dev 97 | 98 | cc --version 99 | make --version 100 | python2-config --includes 101 | 102 | dpkg_check pjproject python-pjsua >/dev/null || { 103 | pj_ver=2.5.5 104 | pj_dir="pjproject-${pj_ver}" 105 | pj_tar=/tmp/"${pj_dir}.tar.bz2" 106 | pj_url=http://www.pjsip.org/release/"${pj_ver}/${pj_dir}.tar.bz2" 107 | 108 | [[ ! -e "${pj_dir}" ]] || rm -rf "${pj_dir}" 109 | [[ -e "${pj_tar}" ]] || { 110 | echo "Using temporary pjproject tar-path: $pj_tar" 111 | curl -L -o "$pj_tar" "${pj_url}" 112 | } 113 | tar -xf "${pj_tar}" 114 | 115 | pushd "${pj_dir}" 116 | 117 | ./configure --prefix=/usr --disable-v4l2 --disable-video --enable-shared 118 | make dep 119 | make 120 | sed -i 's/^\(\s\+\)cp -af /\1cp -r /' Makefile 121 | 122 | chk_install -y --pkgname=pjproject --pkgversion="${pj_ver}" 123 | 124 | dpkg_check pjproject 125 | cp *.deb "$pkg_cache"/ 126 | 127 | pushd pjsip-apps/src/python 128 | 129 | chk_install -y\ 130 | --pkgname=python-pjsua --pkgversion="${pj_ver}"\ 131 | --requires 'python,pjproject,python-setuptools'\ 132 | -- python2 setup.py install\ 133 | --prefix=/usr --install-layout=deb 134 | 135 | dpkg_check python-pjsua 136 | cp *.deb "$pkg_cache"/ 137 | 138 | popd 139 | 140 | python2 -c 'import pjsua; lib = pjsua.Lib(); lib.init(); lib.destroy()' 2>&1 | 141 | grep 'Transport manager created' || die 'Failed to initialize pjsua python module' 142 | 143 | popd 144 | 145 | rm "${pj_tar}" 146 | } 147 | 148 | 149 | apt_install libpulse0 libsystemd0 libsystemd-daemon0 libsystemd-journal0 libsystemd-id128-0 150 | 151 | dpkg_check python-pulsectl || { 152 | apt_install python python-dev python-setuptools 153 | 154 | curl -L https://github.com/mk-fg/python-pulse-control/archive/master.tar.gz | tar xz 155 | pushd python-pulse-control-master 156 | 157 | chk_install -y\ 158 | --pkgname=python-pulsectl\ 159 | --pkgversion=$(grep 'version' setup.py | grep -o '[0-9.]\+')\ 160 | --requires 'python,libpulse0,python-setuptools'\ 161 | -- python2 setup.py install\ 162 | --prefix=/usr --install-layout=deb --old-and-unmanageable 163 | 164 | dpkg_check python-pulsectl 165 | cp *.deb "$pkg_cache"/ 166 | 167 | popd 168 | } 169 | python2 -c 'from pulsectl import Pulse' 170 | 171 | dpkg_check python-systemd || { 172 | apt_install libsystemd-dev libsystemd-journal-dev 173 | 174 | curl -L https://github.com/systemd/python-systemd/archive/v230.tar.gz | tar xz 175 | pushd python-systemd-230 176 | 177 | make 178 | chk_install -y\ 179 | --pkgname=python-systemd\ 180 | --pkgversion=$(grep 'version *=' setup.py | grep -o '[0-9.]\+')\ 181 | --requires 'systemd,libsystemd0,libsystemd-daemon0,libsystemd-journal0,libsystemd-id128-0,python-setuptools'\ 182 | -- python2 setup.py install\ 183 | --prefix=/usr --install-layout=deb 184 | 185 | dpkg_check python-systemd 186 | cp *.deb "$pkg_cache"/ 187 | 188 | popd 189 | } 190 | python2 -c 'import systemd.daemon; print systemd.daemon.__version__' 191 | 192 | dpkg_check paging-server || { 193 | curl -L https://github.com/AccelerateNetworks/PagingServer/archive/master.tar.gz | tar xz 194 | pushd PagingServer-master 195 | 196 | cat >extras.list </dev/null ; then 220 | [[ -e /home/paging ]] || { 221 | usermod -d /home/paging paging 222 | mkdir -p -m700 /home/paging 223 | chown -R paging: /home/paging 224 | } 225 | else useradd -r -md /home/paging -s /bin/false -G audio paging 226 | fi 227 | 228 | [[ -e /etc/paging.conf ]]\ 229 | || install -o root -g paging -m640 -T /usr/share/doc/paging-server/paging.example.conf /etc/paging.conf 230 | 231 | for s in paging 232 | do [[ -n "$(systemctl cat "$s")" ]] || die "Failed to load $s.service" 233 | done 234 | 235 | 236 | echo 237 | echo -------------------- 238 | echo 239 | echo "Installation process completed successfully." 240 | echo 241 | echo "Edit configuration file in: /etc/paging.conf" 242 | echo "At least domain/user/pass MUST be specified there in the [sip] section." 243 | echo 244 | echo "Then configure pulseaudio and/or music player instances to start." 245 | echo 246 | echo "After that, start the service with: systemctl start paging" 247 | echo " check status: systemctl status paging" 248 | echo " check service log with: journalctl -ab -u paging" 249 | echo " continuously 'tail' log with: journalctl -af -u paging" 250 | echo " continuously tail all system logs with: journalctl -af" 251 | echo 252 | echo "If service has been started and is running successfully," 253 | echo " enable it to run on boot with: systemctl enable paging" 254 | echo 255 | echo -------------------- 256 | 257 | exit 0 258 | -------------------------------------------------------------------------------- /setup-scripts/paging-server-setup.orangepi.debian_jessie.separate-mono-speakers.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | usage() { 4 | bin=$(basename $0) 5 | echo >&2 "Usage: $bin" 6 | echo >&2 "Usage: $bin -x" 7 | echo >&2 8 | echo >&2 "Setup OrangePi with Debian Jessie to run PagingServer as the main app." 9 | echo >&2 "This script sets up two mono outputs, with separate mpd running for each." 10 | echo >&2 "-x option disables check for platform type in /proc/cpuinfo." 11 | exit ${1:-0} 12 | } 13 | [[ $# -gt 1 || "$1" = -h || "$1" = --help ]] && usage 14 | [[ $# -eq 1 && "$1" != -x ]] && usage 1 15 | 16 | [[ "$UID" == 0 ]] || { 17 | echo >&2 "This script should be run as root." 18 | exit 1 19 | } 20 | 21 | 22 | set -e -o pipefail 23 | 24 | get_repo_file() { 25 | wget -q -O- https://raw.githubusercontent.com/AccelerateNetworks/PagingServer/master/"$1" 26 | } 27 | 28 | 29 | setup_tmp=$(mktemp /tmp/paging-server-setup.XXXXX.sh) 30 | trap "rm -f '$setup_tmp'" EXIT 31 | get_repo_file setup-scripts/paging-server-setup.orangepi.debian_jessie.sh >"$setup_tmp" 32 | bash "$setup_tmp" $1 33 | 34 | 35 | echo 36 | echo '-----===== Extra step: changing output to be two separate mono speakers' 37 | echo 38 | 39 | sed -i 's/^# \(load-module module-remap-sink \)/\1/' /etc/pulse/paging.pa 40 | 41 | get_repo_file setup-configs/mpd.instance.conf | sed 's|instance|left|' >/etc/mpd.left.conf 42 | get_repo_file setup-configs/mpd.instance.conf | sed 's|instance|right|' >/etc/mpd.right.conf 43 | 44 | systemctl stop mpd@speakers 45 | systemctl disable mpd@speakers 46 | 47 | systemctl daemon-reload 48 | systemctl enable mpd@left mpd@right 49 | 50 | echo 51 | echo -------------------- 52 | echo 53 | echo "System setup process completed successfully." 54 | echo 55 | echo "This particular setup starts separate mpd@left and mpd@right music players." 56 | echo "Use following files to init their playlists:" 57 | echo " /etc/mpd.left.url or /etc/mpd.left.m3u - for 'left channel' mpd player." 58 | echo " /etc/mpd.right.url or /etc/mpd.right.m3u - for 'right channel' mpd player." 59 | echo 60 | echo "mpd instances will be started on boot, or you can" 61 | echo " create url/m3u files and (re)start these manually, using command:" 62 | echo " systemctl restart mpd@left mpd@right" 63 | echo 64 | echo "Have a nice day." 65 | echo 66 | echo -------------------- 67 | -------------------------------------------------------------------------------- /setup-scripts/paging-server-setup.orangepi.debian_jessie.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | usage() { 4 | bin=$(basename $0) 5 | echo >&2 "Usage: $bin" 6 | echo >&2 "Usage: $bin -x" 7 | echo >&2 8 | echo >&2 "Setup OrangePi with Debian Jessie to run PagingServer as the main app." 9 | echo >&2 "-x option disables check for platform type in /proc/cpuinfo." 10 | exit ${1:-0} 11 | } 12 | [[ $# -gt 1 || "$1" = -h || "$1" = --help ]] && usage 13 | [[ $# -eq 1 && "$1" != -x ]] && usage 1 14 | 15 | [[ "$UID" == 0 ]] || { 16 | echo >&2 "This script should be run as root." 17 | exit 1 18 | } 19 | 20 | [[ "$1" == -x ]] || { 21 | grep -q '^Hardware[[:space:]]*:[[:space:]]*sun8i$' /proc/cpuinfo\ 22 | && grep -q '^Debian GNU/Linux 8 ' /etc/issue || { 23 | echo >&2 "Failed to match Hardware=sun8i in /proc/cpuinfo" 24 | echo >&2 " or 'Debian GNU/Linux 8' in /etc/issue." 25 | echo >&2 "This script specifically written for Debian Jessie on" 26 | echo >&2 " OrangePi boards and should not work on any other platforms." 27 | echo >&2 "Use -x option to disable this check and run it here anyway." 28 | exit 1 29 | } 30 | } 31 | 32 | 33 | set -e -o pipefail 34 | 35 | run_apt_get() { 36 | env\ 37 | DEBIAN_FRONTEND=noninteractive\ 38 | DEBIAN_PRIORITY=critical\ 39 | apt-get\ 40 | -o Dpkg::Options::="--force-confdef"\ 41 | -o Dpkg::Options::="--force-confold"\ 42 | --force-yes -y "$@" 43 | } 44 | 45 | get_repo_file() { 46 | wget -q -O- https://raw.githubusercontent.com/AccelerateNetworks/PagingServer/master/"$1" 47 | } 48 | 49 | 50 | 51 | echo 52 | echo '-----===== Step: install.debian_jessie.from_debs.sh' 53 | echo 54 | 55 | get_repo_file setup-scripts/install.debian_jessie.from_debs.sh | bash 56 | 57 | 58 | echo 59 | echo '-----===== Step: networking setup (replace NM with dhcpcd)' 60 | echo 61 | 62 | run_apt_get install --no-install-recommends dhcpcd5 63 | 64 | cat >/etc/dhcpcd.conf </etc/systemd/system/dhcpcd.service </etc/resolv.conf 98 | echo 'nameserver 8.8.4.4' >>/etc/resolv.conf 99 | 100 | systemctl disable NetworkManager ModemManager pppd-dns 101 | run_apt_get remove network-manager modemmanager ppp 102 | run_apt_get autoremove 103 | 104 | 105 | echo 106 | echo '-----===== Step: system.conf watchdog setup, journald.conf logging setup' 107 | echo 108 | 109 | grep -q '^RuntimeWatchdogSec=' /etc/systemd/system.conf\ 110 | || sed -i '/^\[Manager\]$/a\\nRuntimeWatchdogSec=14\nShutdownWatchdogSec=14\n' /etc/systemd/system.conf 111 | grep -q '^Storage=' /etc/systemd/journald.conf\ 112 | || sed -i '/^\[Journal\]$/a\\nStorage=volatile\nRuntimeMaxUse=10\nRuntimeMaxFileSize=2M\n' /etc/systemd/journald.conf 113 | 114 | 115 | echo 116 | echo '-----===== Step: alsa config/levels/mute setup' 117 | echo 118 | 119 | amixer sset 'Lineout volume control' 31 120 | amixer sset 'Audio lineout' on 121 | alsactl store 122 | 123 | ## PulseAudio should be doing all softvol stuff here 124 | # cat >/etc/asound.conf <"$s" 145 | echo 'StartLimitAction=reboot' >>"$s" 146 | done 147 | 148 | 149 | echo 150 | echo '-----===== Step: starting/enabling PagingServer-related stuff' 151 | echo 152 | 153 | run_apt_get install --no-install-recommends mpd mpc 154 | systemctl disable mpd 155 | systemctl stop mpd 156 | 157 | get_repo_file setup-configs/paging.pa | 158 | sed 's|\( module-alsa-sink device=sysdefault\) |\1:CARD=audiocodec |' | 159 | cat >/etc/pulse/paging.pa 160 | 161 | get_repo_file setup-configs/mpd.instance.conf | 162 | sed 's|instance|speakers|' >/etc/mpd.speakers.conf 163 | 164 | get_repo_file setup-configs/mpd@.service >/etc/systemd/system/mpd@.service 165 | get_repo_file setup-configs/pulse.service >/etc/systemd/system/pulse.service 166 | 167 | systemctl daemon-reload 168 | systemctl enable mpd@speakers 169 | 170 | if awk 'p&&/^\[/ {p=0} /^\[sip\]$/ {p=1} p&&/^ *(domain|user|pass) *= *<(sip server|username|password)>$/ {exit 1}' /etc/paging.conf 171 | then 172 | systemctl start paging 173 | systemctl enable paging 174 | echo 175 | echo -------------------- 176 | echo 177 | echo "System setup process completed successfully." 178 | echo 179 | echo "PagingServer has been started (should be running right now) and was enabled to start on boot." 180 | echo "If it will keep failing (with some restart-limit threshold)," 181 | echo " or its sound outputs will be crashing repeatedly, whole system will reboot." 182 | echo "So make sure that either configuration always stays correct," 183 | echo " or run: rm /etc/systemd/system/*.service.d/paging-reboot-on-fail.conf" 184 | echo 185 | echo "To auto-start radio playback on boot, create either" 186 | echo " /etc/mpd.speakers.url with e.g. 'https://live.uwave.fm:8443/listen-128.mp3.m3u' inside," 187 | echo " or /etc/mpd.speakers.m3u with list of tracks or urls to play." 188 | echo "*.url file will be re-downloaded every time mpd starts." 189 | echo 190 | echo "mpd music player will be started on boot, or you can" 191 | echo " create url/m3u file and (re)start it manually, using command:" 192 | echo " systemctl restart mpd@speakers" 193 | echo 194 | echo "Have a nice day." 195 | echo 196 | echo -------------------- 197 | exit 0 198 | else 199 | echo "ATTENTION:" 200 | echo "ATTENTION: Detected default or missing sip auth/connection credentials in /etc/paging.conf file." 201 | echo "ATTENTION:" 202 | echo "ATTENTION: These MUST be changed (under [sip] secion) to something that works" 203 | echo "ATTENTION: (i.e. real account data) before starting/enabling the daemon." 204 | echo "ATTENTION: See README.rst and comments there for more information on these options." 205 | echo "ATTENTION:" 206 | echo "ATTENTION: Edit that file right now and run this script again to enable the service." 207 | echo "ATTENTION: It's perfectly safe to re-run this script any number of times." 208 | echo "ATTENTION:" 209 | echo "ATTENTION: EXITING without enabling paging.service." 210 | echo "ATTENTION:" 211 | exit 1 212 | fi 213 | -------------------------------------------------------------------------------- /setup-scripts/paging-server-setup.orangepi.debian_stretch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | usage() { 4 | bin=$(basename $0) 5 | echo >&2 "Usage: $bin" 6 | echo >&2 "Usage: $bin -x" 7 | echo >&2 8 | echo >&2 "Setup OrangePi with Debian Stretch to run PagingServer as the main app." 9 | echo >&2 "-x option disables check for platform type in /proc/cpuinfo." 10 | exit ${1:-0} 11 | } 12 | [[ $# -gt 1 || "$1" = -h || "$1" = --help ]] && usage 13 | [[ $# -eq 1 && "$1" != -x ]] && usage 1 14 | 15 | [[ "$UID" == 0 ]] || { 16 | echo >&2 "This script should be run as root." 17 | exit 1 18 | } 19 | 20 | [[ "$1" == -x ]] || { 21 | grep -q '^Hardware[[:space:]]*:[[:space:]]*Allwinner[[:space:]]*sun8i[[:space:]]*Family$' /proc/cpuinfo\ 22 | && grep -q '^Debian GNU/Linux 9 ' /etc/issue || { 23 | echo >&2 "Failed to match Hardware=sun8i in /proc/cpuinfo" 24 | echo >&2 " or 'Debian GNU/Linux 9' in /etc/issue." 25 | echo >&2 "This script ia written for Debian Stretch/Armbian on" 26 | echo >&2 " OrangePi boards and should not work on other platforms." 27 | echo >&2 "Use -x option to disable this check and run it here anyway." 28 | exit 1 29 | } 30 | } 31 | 32 | 33 | set -e -o pipefail 34 | 35 | run_apt_get() { 36 | env\ 37 | DEBIAN_FRONTEND=noninteractive\ 38 | DEBIAN_PRIORITY=critical\ 39 | apt-get\ 40 | -o Dpkg::Options::="--force-confdef"\ 41 | -o Dpkg::Options::="--force-confold"\ 42 | --force-yes -y "$@" 43 | } 44 | 45 | get_repo_file() { 46 | wget -q -O- https://raw.githubusercontent.com/AccelerateNetworks/PagingServer/master/"$1" 47 | } 48 | 49 | 50 | 51 | echo 52 | echo '-----===== Step: install.debian_jessie.from_debs.sh' 53 | echo 54 | 55 | get_repo_file setup-scripts/install.debian_jessie.from_debs.sh | bash 56 | 57 | 58 | 59 | echo 60 | echo '-----===== Step: system.conf watchdog setup, journald.conf logging setup' 61 | echo 62 | 63 | grep -q '^RuntimeWatchdogSec=' /etc/systemd/system.conf\ 64 | || sed -i '/^\[Manager\]$/a\\nRuntimeWatchdogSec=14\nShutdownWatchdogSec=14\n' /etc/systemd/system.conf 65 | grep -q '^Storage=' /etc/systemd/journald.conf\ 66 | || sed -i '/^\[Journal\]$/a\\nStorage=volatile\nRuntimeMaxUse=10\nRuntimeMaxFileSize=2M\n' /etc/systemd/journald.conf 67 | 68 | 69 | echo 70 | echo '-----===== Step: alsa config/levels/mute setup' 71 | echo 72 | 73 | amixer -c 0 sset 'Line Out' 31 74 | amixer -c 0 sset 'DAC' 63 75 | alsactl store 76 | 77 | ## PulseAudio should be doing all softvol stuff here 78 | # cat >/etc/asound.conf <"$s" 99 | echo 'StartLimitAction=reboot' >>"$s" 100 | done 101 | 102 | 103 | echo 104 | echo '-----===== Step: starting/enabling PagingServer-related stuff' 105 | echo 106 | 107 | run_apt_get install --no-install-recommends mpd mpc 108 | systemctl disable mpd 109 | systemctl stop mpd 110 | 111 | get_repo_file setup-configs/paging-debian-9.pa | 112 | sed 's|\( module-alsa-sink device=sysdefault\) |\1:CARD=audiocodec |' | 113 | cat >/etc/pulse/paging.pa 114 | 115 | get_repo_file setup-configs/mpd.instance.conf | 116 | sed 's|instance|speakers|' >/etc/mpd.speakers.conf 117 | 118 | get_repo_file setup-configs/mpd@.service >/etc/systemd/system/mpd@.service 119 | get_repo_file setup-configs/pulse.service >/etc/systemd/system/pulse.service 120 | 121 | systemctl daemon-reload 122 | systemctl enable mpd@speakers 123 | 124 | if awk 'p&&/^\[/ {p=0} /^\[sip\]$/ {p=1} p&&/^ *(domain|user|pass) *= *<(sip server|username|password)>$/ {exit 1}' /etc/paging.conf 125 | then 126 | systemctl start paging 127 | systemctl enable paging 128 | echo 129 | echo -------------------- 130 | echo 131 | echo "System setup process completed successfully." 132 | echo 133 | echo "PagingServer has been started (should be running right now) and was enabled to start on boot." 134 | echo "If it will keep failing (with some restart-limit threshold)," 135 | echo " or its sound outputs will be crashing repeatedly, whole system will reboot." 136 | echo "So make sure that either configuration always stays correct," 137 | echo " or run: rm /etc/systemd/system/*.service.d/paging-reboot-on-fail.conf" 138 | echo 139 | echo "To auto-start radio playback on boot, create either" 140 | echo " /etc/mpd.speakers.url with e.g. 'https://live.uwave.fm:8443/listen-128.mp3.m3u' inside," 141 | echo " or /etc/mpd.speakers.m3u with list of tracks or urls to play." 142 | echo "*.url file will be re-downloaded every time mpd starts." 143 | echo 144 | echo "mpd music player will be started on boot, or you can" 145 | echo " create url/m3u file and (re)start it manually, using command:" 146 | echo " systemctl restart mpd@speakers" 147 | echo 148 | echo "Have a nice day." 149 | echo 150 | echo -------------------- 151 | exit 0 152 | else 153 | echo "ATTENTION:" 154 | echo "ATTENTION: Detected default or missing sip auth/connection credentials in /etc/paging.conf file." 155 | echo "ATTENTION:" 156 | echo "ATTENTION: These MUST be changed (under [sip] secion) to something that works" 157 | echo "ATTENTION: (i.e. real account data) before starting/enabling the daemon." 158 | echo "ATTENTION: See README.rst and comments there for more information on these options." 159 | echo "ATTENTION:" 160 | echo "ATTENTION: Edit that file right now and run this script again to enable the service." 161 | echo "ATTENTION: It's perfectly safe to re-run this script any number of times." 162 | echo "ATTENTION:" 163 | echo "ATTENTION: EXITING without enabling paging.service." 164 | echo "ATTENTION:" 165 | exit 1 166 | fi 167 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | 3 | from setuptools import setup, find_packages 4 | import os 5 | 6 | # Error-handling here is to allow package to be built w/o README included 7 | pkg_root = os.path.dirname(__file__) 8 | try: readme = open(os.path.join(pkg_root, 'README.rst')).read() 9 | except IOError: readme = '' 10 | 11 | setup( 12 | 13 | name = 'PagingServer', 14 | version = '19.5.2', 15 | author = 'Dan Ryan, Mike Kazantsev', 16 | author_email = 'dan@seattlemesh.net, mk.fraggod@gmail.com', 17 | license = 'GPL-2', 18 | keywords = [ 19 | 'sip', 'telephony', 'phone', 'paging', 'announcement', 20 | 'autoanswer', 'callpipe', 'klaxon', 21 | 'pj', 'pjproject', 'pjsip', 'pjsua', 'pulse', 'pulseaudio', 'pa' ], 22 | url = 'https://github.com/AccelerateNetworks/PagingServer', 23 | 24 | description = 'SIP-based Announcement / PA / Paging / Public Address Server system', 25 | long_description = readme, 26 | 27 | classifiers = [ 28 | 'Development Status :: 4 - Beta', 29 | 'Environment :: No Input/Output (Daemon)', 30 | 'Environment :: Other Environment', 31 | 'Intended Audience :: Customer Service', 32 | 'Intended Audience :: Telecommunications Industry', 33 | 'License :: OSI Approved', 34 | 'License :: OSI Approved :: GNU General Public License v2 (GPLv2)', 35 | 'Natural Language :: English', 36 | 'Operating System :: POSIX', 37 | 'Operating System :: POSIX :: Linux', 38 | 'Programming Language :: Python', 39 | 'Programming Language :: Python :: 2.7', 40 | 'Programming Language :: Python :: 2 :: Only', 41 | 'Topic :: Communications :: Telephony', 42 | 'Topic :: Multimedia :: Sound/Audio', 43 | 'Topic :: Multimedia :: Sound/Audio :: Mixers', 44 | 'Topic :: Multimedia :: Sound/Audio :: Speech' ], 45 | 46 | install_requires = ['pulsectl'], 47 | extras_require = {'sentry': ['raven']}, 48 | 49 | py_modules=['paging'], 50 | 51 | entry_points = { 52 | 'console_scripts': ['paging = paging:main'] }) 53 | --------------------------------------------------------------------------------