diff --git a/trashtalker.py b/trashtalker.py index 289a930..da50d62 100644 --- a/trashtalker.py +++ b/trashtalker.py @@ -35,42 +35,92 @@ from random import shuffle class State: lib=None running=False - def start(self): + sip_ringing=180 + sip_answer=200 + def Log(level, source, line, error=False): + pfx='*' + if error: + pfx='!' + print("%s %s: %s" % (pfx*level, source, line)) + sys.stdout.flush() + def preinit(self): + self.Log(1, "preinit", "initialising from environment") + self.LOG_LEVEL=int(getenv('TT_LOG_LEVEL', 0)) + self.port=int(getenv('TT_LISTEN_PORT', 55060)) + self.source=getenv('TT_MEDIA_SOURCE', '/opt/media/') + assert self.source.startswith('/'), "TT_MEDIA_SOURCE must specify an absolute path!" + def init(self): + self.lib=pj.Lib() + self.cfg_ua=pj.UAConfig() + self.cfg_md=pj.MediaConfig() + self.cfg_ua.max_calls, self.cfg_ua.user_agent = 32, "TrashTalker/1.0" + self.cfg_md.no_vad, self.cfg_md.enable_ice = True, False + self.cfg_lg=pj.LogConfig( + level=self.LOG_LEVEL, + callback=PJLog) + self.lib.init( + ua_cfg=cfg_ua, + media_cfg=cfg_md, + log_cfg=cfg_lg) + self.lib.set_null_snd_dev() + self.lib.start( + with_thread=True) + self.transport=self.lib.create_transport( + pj.TransportType.UDP, + pj.TransportConfig(self.port)) + self.account=self.lib.create_account_for_transport( + self.transport, + cb=AccountCb()) + self.uri="sip:%s:%s" % (self.transport.info().host, self.transport.info().port) + def media_init(self): + self.Log(3, "playlist-load", "loading playlist files from media path %s" % self.source) + if not self.source.endswith('/'): + self.Log(4, "playlist-load", "appending trailing / to TT_MEDIA_SOURCE") + self.source="%s/" % self.source + self.playlist=listdir(self.source) + self.playlist[:]=[self.source+file for file in self.playlist] + assert (len(self.playlist) > 1), "playlist path %s must contain more than one audio file" % self.source + self.Log(3, "playlist-load", "loaded %s media items from path %s" % (len(self.playlist), self.source)) + def deinit(self): + self.lib.hangup_all() + self.lib.handle_events(timeout=250) + try: + self.account.delete() + self.lib.destroy() + self.lib=self.account=self.transport=None + except AttributeError: + self.Log(1, "deinit", "Got an AttributeError exception during shutdown.", error=True) + pass + except pj.Error as e: + self.Log(1, "deinit", "Got a PJError exception during shutdown: %s" % str(e), error=True) + pass + def run(self): self.running=True + while self.running: + sleep(0.2) def stop(self): self.running=False -class PJStates: - init=0 - deinit=1 -class SIPStates: - ringing=180 - answer=200 #Utility and state definitions state=State() # Logging def PJLog(level, line, length): - Log(level+1, "pjsip", line) -def Log(level, source, line, error=False): - pfx='*' - if error: - pfx='!' - print("%s %s: %s" % (pfx*level, source, line)) - sys.stdout.flush() + global state + state.Log(level+1, "pjsip", line) # Signal handling def sighandle(_signo, _stack_frame): global state - Log(1, "sighandler", "caught signal %s" % _signo) + state.Log(1, "sighandler", "caught signal %s" % _signo) if _signo == 1: #SIGHUP - Log(1, "sighandler", "SIGHUP invoked playlist reload") - MediaLoadPlaylist() + state.Log(1, "sighandler", "SIGHUP invoked playlist reload") + state.media_init() elif _signo == 2: #SIGINT - Log(1, "sighandler", "SIGINT invoked current call flush") + state.Log(1, "sighandler", "SIGINT invoked current call flush") state.lib.hangup_all() elif _signo == 15: #SIGTERM - Log(1, "sighandler", "SIGTERM invoked app shutdown") + state.Log(1, "sighandler", "SIGTERM invoked app shutdown") state.stop() pass @@ -81,11 +131,12 @@ class AccountCb(pj.AccountCallback): pj.AccountCallback.__init__(self, account) def on_incoming_call(self, call): - Log(2, "event-call-in", "caller %s dialled in" % call.info().remote_uri) + global state + state.Log(2, "event-call-in", "caller %s dialled in" % call.info().remote_uri) call.set_callback(CallCb(call)) #Answer call with SIP/2.0 180 RINGING #This kicks in the EARLY media state, allowing us to initialise the playlist before the call connects - call.answer(SIPStates.ringing) + call.answer(state.sip_ringing) # Call Callback class class CallCb(pj.CallCallback): def __init__(self, call=None): @@ -98,50 +149,50 @@ class CallCb(pj.CallCallback): self.instmedia=state.lib.create_playlist( loop=True, filelist=self.playlist, label="trashtalklist") self.slotmedia=state.lib.playlist_get_slot(self.instmedia) - Log(4, "call-media-create", "created playlist for current call") + state.Log(4, "call-media-create", "created playlist for current call") def connect_media(self): global state self.slotcall=self.call.info().conf_slot state.lib.conf_connect(self.slotmedia, self.slotcall) - Log(4, "call-media-connect", "connected playlist media endpoint to call") + state.Log(4, "call-media-connect", "connected playlist media endpoint to call") def disconnect_media(self): global state state.lib.conf_disconnect(self.slotmedia, self.slotcall) - Log(4, "call-media-disconnect", "disconnected playlist media endpoint from call") + state.Log(4, "call-media-disconnect", "disconnected playlist media endpoint from call") def destroy_media(self): global state state.lib.playlist_destroy(self.instmedia) self.instmedia=None self.slotmedia=None self.playlist=None - Log(4, "call-media-destroy", "destroyed playlist endpoint and object") + state.Log(4, "call-media-destroy", "destroyed playlist endpoint and object") def on_state(self): global state - Log(2, "event-state-change", "SIP/2.0 %s (%s), call %s in call with party %s" % + state.Log(2, "event-state-change", "SIP/2.0 %s (%s), call %s in call with party %s" % (self.call.info().last_code, self.call.info().last_reason, self.call.info().state_text, self.call.info().remote_uri)) #EARLY media state allows us to init the playlist while the call establishes if self.call.info().state == pj.CallState.EARLY: self.create_media() - Log(3, "event-call-state-early", "initialised new trashtalk playlist instance") + state.Log(3, "event-call-state-early", "initialised new trashtalk playlist instance") #answer the call once playlist is prepared - self.call.answer(SIPStates.answer) + self.call.answer(state.sip_answer) #CONFIRMED state indicates the call is connected elif self.call.info().state == pj.CallState.CONFIRMED: - Log(3, "event-call-state-confirmed", "answered call") + state.Log(3, "event-call-state-confirmed", "answered call") self.connect_media() - Log(3, "event-call-conf-joined", "joined trashtalk to call") + state.Log(3, "event-call-conf-joined", "joined trashtalk to call") #DISCONNECTED state indicates the call has ended (whether on our end or the caller's) elif self.call.info().state == pj.CallState.DISCONNECTED: - Log(3, "event-call-state-disconnected", "call disconnected") + state.Log(3, "event-call-state-disconnected", "call disconnected") self.disconnect_media() self.destroy_media() - Log(3, "event-call-conf-left", "removed trashtalk instance from call and destroyed it") + state.Log(3, "event-call-conf-left", "removed trashtalk instance from call and destroyed it") def on_dtmf_digit(self, digit): global state - Log(3, "dtmf-digit", "received DTMF signal: %s" % digit) + state.Log(3, "dtmf-digit", "received DTMF signal: %s" % digit) if digit == '*': self.disconnect_media() self.destroy_media() @@ -150,106 +201,51 @@ class CallCb(pj.CallCallback): #I'm not sure what this is for, as all media handling is actually done within the SIP events above def on_media_state(self): + global state if self.call.info().media_state == pj.MediaState.ACTIVE: - Log(4, "event-media-state-change", "Media State transitioned to ACTIVE") + state.Log(4, "event-media-state-change", "Media State transitioned to ACTIVE") else: - Log(4, "event-media-state-change", "Media State transitioned to INACTIVE") + state.Log(4, "event-media-state-change", "Media State transitioned to INACTIVE") # Main logic functions -def PJControl(action): - global state - if action == PJStates.init: - state.lib=pj.Lib() - state.cfg_ua=pj.UAConfig() - state.cfg_md=pj.MediaConfig() - state.cfg_ua.max_calls, state.cfg_ua.user_agent = 32, "TrashTalker/1.0" - state.cfg_md.no_vad, state.cfg_md.enable_ice = True, False - state.lib.init( - ua_cfg=state.cfg_ua, - media_cfg=state.cfg_md, - log_cfg=pj.LogConfig( - level=state.LOG_LEVEL, - callback=PJLog - ) - ) - state.lib.set_null_snd_dev() - state.lib.start(with_thread=True) - state.transport=state.lib.create_transport( - pj.TransportType.UDP, - pj.TransportConfig(state.port) - ) - state.account=state.lib.create_account_for_transport( - state.transport, - cb=AccountCb() - ) - state.uri="sip:%s:%s" % (state.transport.info().host, state.transport.info().port) - elif action == PJStates.deinit: - state.lib.hangup_all() - # allow time for cleanup before destroying objects - state.lib.handle_events(timeout=250) - try: - state.account.delete() - state.lib.destroy() - state.lib=state.account=state.transport=None - except AttributeError: - Log(1, "deinit", "AttributeError when clearing down pjsip, this is likely fine", error=True) - pass - except pj.Error as e: - Log(1, "deinit", "pjsip error when clearing down: %s" % str(e), error=True) - pass - -def WaitLoop(): - global state - while state.running: - sleep(0.2) - -def MediaLoadPlaylist(): - Log(3, "playlist-load", "loading playlist files") - global state - if not state.source.endswith('/'): - Log(4, "playlist-load", "appending trailing / to TT_MEDIA_SOURCE") - state.source="%s/" % state.source - state.playlist=listdir(state.source) - state.playlist[:]=[state.source+file for file in state.playlist] - assert (len(state.playlist) > 1), "playlist path %s must contain more than one audio file" % state.source - Log(3, "playlist-load", - "load playlist from %s, got %s files" % (state.source, len(state.playlist))) - def main(): global state - Log(1, "init", "initialising trashtalker") - state.LOG_LEVEL=int(getenv('TT_LOG_LEVEL', 0)) - #TT_MEDIA_SOURCE and TT_LISTEN_PORT can be configured via env. variables - state.source=getenv('TT_MEDIA_SOURCE', '/opt/media/') - state.port=int(getenv('TT_LISTEN_PORT', 55060)) - state.start() + #Try to pre-init + try: + state.preinit() + except AssertionError as e: + state.Log(1, "preinit", "AssertionError while pre-initialising: %s" % str(e)) + raise Exception("Unable to start up TrashTalker. Check all configuration parameters are correct, and review logs.") + #Attach signal handlers signal(SIGHUP, sighandle) signal(SIGINT, sighandle) signal(SIGTERM, sighandle) - assert state.source.startswith('/'), "Environment variable TT_MEDIA_PATH must be an absolute path!" + #Try to initialise media try: - MediaLoadPlaylist() + state.media_init() except: - Log(2, "playlist-load", "exception encountered while loading playlist from path %s" % state.source, error=True) + state.Log(2, "playlist-load", "exception encountered while loading playlist from path %s" % state.source, error=True) raise Exception("Unable to load playlist") + #Try to initialise main process; only fault here should be if the configured listening port is unavailable to us try: - PJControl(PJStates.init) + state.init() except: - Log(2, "pj-init", "Unable to initialise pjsip library; please check media path and SIP listening port are correct", error=True) + state.Log(2, "pj-init", "Unable to initialise pjsip library; please check media path and SIP listening port are correct", error=True) raise Exception("Unable to initialise pjsip library; please check media path and SIP listening port are correct") - Log(1, "init-complete", "trashtalker listening on uri %s and serving %s media items from %s" % (state.uri, len(state.playlist), state.source)) + state.Log(1, "init-complete", "trashtalker listening on uri %s and serving %s media items from %s" % (state.uri, len(state.playlist), state.source)) + #Enter main loop try: - WaitLoop() + state.run() except pj.Error as e: - Log(2, "pjsip-error", "trashtalker encountered pjsip exception %s" % str(e), error=True) + state.Log(2, "pjsip-error", "trashtalker encountered pjsip exception %s" % str(e), error=True) state.stop() pass except KeyboardInterrupt: state.stop() pass - Log(1, "deinit", "main loop exited, shutting down") - PJControl(PJStates.deinit) - Log(1, "deinit-complete", "trashtalker has shut down") + state.Log(1, "deinit", "main loop exited, shutting down") + state.deinit() + state.Log(1, "deinit", "trashtalker has shut down") if __name__ == "__main__": main()