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