# This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. """ Models representing FreeSWITCH entities """ import asyncio import time from collections import deque, defaultdict import multiprocessing as mp from concurrent import futures from pprint import pprint import warnings from . import utils class TimeoutError(Exception): pass class JobError(utils.ESLError): pass class Events(object): """Event collection which for most intents and purposes should quack like a ``collections.deque``. Data lookups are delegated to the internal deque of events in lilo order. """ def __init__(self, event=None): self._events = deque() if event is not None: # add initial event to our queue self.update(event) def __repr__(self): return '{}({})'.format(type(self).__name__, repr(self._events)) def update(self, event): '''Append an ESL.ESLEvent ''' self._events.appendleft(event) def __len__(self): return len(self._events) def __iter__(self): for ev in self._events: yield ev def get(self, key, default=None): """Return default if not found Should be faster then handling the key error? """ # iterate from most recent event for ev in self._events: value = ev.get(str(key)) if value: return value return default def __getitem__(self, key): '''Return either the value corresponding to variable 'key' or if type(key) == (int or slice) then return the corresponding event from the internal deque ''' value = self.get(key) if value: return value else: if isinstance(key, (int, slice)): return self._events[key] raise KeyError(key) def pprint(self, index=0): """Print serialized event data in chronological order to stdout """ for ev in reversed(list(self._events)[index:]): pprint(ev) class Session(object): '''Session API and state tracking. ''' create_ev = 'CHANNEL_CREATE' # TODO: eventually uuid should be removed def __init__(self, event, event_loop=None, uuid=None, con=None): self.events = Events(event) self.event_loop = event_loop self.uuid = uuid or self.events['Unique-ID'] self.con = con # sub-namespace for apps to set/get state self.vars = {} self._log = None self._futures = defaultdict(event_loop.loop.create_future) self.tasks = {} # public attributes self.duration = 0 self.bg_job = None self.answered = False self.call = None self.hungup = False # time stamps self.times = {}.fromkeys( ('create', 'answer', 'req_originate', 'originate', 'hangup')) self.times['create'] = utils.get_event_time(event) def done(self): return self.hungup @property def log(self): """Local logger instance. """ if not self._log: self._log = utils.get_logger(utils.pstr(self.con.host)) return self._log def __repr__(self): rep = object.__repr__(self).strip('<>') return "<{} with UUID: {}>".format(rep, self.uuid) def __dir__(self): # TODO: use a transform func to provide __getattr__ # access to event data return utils.dirinfo(self) def __getitem__(self, key): try: return self.events[key] except KeyError: raise KeyError("'{}' not found for session '{}'" .format(key, self.uuid)) def get(self, key, default=None): '''Get latest event header field for `key`. ''' return self.events.get(key, default) def update(self, event): '''Update state/data using an ESL.ESLEvent ''' self.events.update(event) def __enter__(self, connection): self.con = connection return self def __exit__(self, exc_type, exc_value, traceback): self.con = None @property def appname(self): return self.get('variable_switchio_app') @property def host(self): '''Return the hostname/ip address for the host which this session is currently active ''' return self.con.host @property def time(self): """Time stamp for the most recent received event """ return utils.get_event_time(self.events[0]) @property def uptime(self): """Time elapsed since the `Session.create_ev` to the most recent received event. """ return self.time - self.times['create'] def unreg_tasks(self, fut): if fut.cancelled(): # otherwise it's popped in the event loop self._futures.pop(fut._evname, None) else: assert not self._futures.get(fut._evname) # sanity def recv(self, name, timeout=None): """Return an awaitable which resumes once the event-type ``name`` is received for this session. """ loop = self.event_loop.loop fut = self._futures[name] # defaultdict: returns new future by default fut._evname = name # keep track of consuming coroutine(s) caller = asyncio.Task.current_task(loop) self.tasks.setdefault(fut, []).append(caller) fut.add_done_callback(self.unreg_tasks) return fut if not timeout else asyncio.wait_for( fut, timeout, loop=loop) async def poll(self, events, timeout=None, return_when=asyncio.FIRST_COMPLETED): """Poll for any of a set of event types to be received for this session. """ awaitables = {} for name in events: awaitables[self.recv(name)] = name done, pending = await asyncio.wait( awaitables, timeout=timeout, return_when=return_when) if done: ev_dicts = [] for fut in done: awaitables.pop(fut) ev_dicts.append(fut.result()) return ev_dicts, awaitables.values() else: raise asyncio.TimeoutError( "None of {} was received in {} seconds" .format(events, timeout)) # call control / 'mod_commands' methods # TODO: dynamically add @decorated functions to this class # and wrap them using functools.update_wrapper ...? def getvar(self, var): val = self.con.cmd("uuid_getvar {} {}".format(self.uuid, var)) return val if val != '_undef_' else None def setvar(self, var, value): """Set variable to value """ self.execute('set', '='.join((var, value))) def setvars(self, params): """Set all variables in map `params` with a single command """ pairs = ('='.join(map(str, pair)) for pair in params.items()) self.con.api("uuid_setvar_multi {} {}".format( self.uuid, ';'.join(pairs))) def unsetvar(self, var): """Unset a channel var. """ return self.execute("unset", var) def answer(self): self.con.api("uuid_answer {}".format(self.uuid)) return self.recv('CHANNEL_ANSWER') def hangup(self, cause='NORMAL_CLEARING'): '''Hangup this session with the provided `cause` hangup type keyword. ''' self.con.api('uuid_kill {} {}'.format(self.uuid, cause)) return self.recv('CHANNEL_HANGUP') def sched_hangup(self, timeout, cause='NORMAL_CLEARING'): '''Schedule this session to hangup after `timeout` seconds. ''' self.con.api('sched_hangup +{} {} {}'.format(timeout, self.uuid, cause)) def clear_tasks(self): '''Clear all scheduled tasks for this session. ''' self.con.api('sched_del {}'.format(self.uuid)) def sched_dtmf(self, delay, sequence, tone_duration=None): '''Schedule dtmf sequence to be played on this channel. :param float delay: scheduled future time when dtmf tones should play :param str sequence: sequence of dtmf digits to play ''' cmd = 'sched_api +{} none uuid_send_dtmf {} {}'.format( delay, self.uuid, sequence) if tone_duration is not None: cmd += ' @{}'.format(tone_duration) return self.con.api(cmd) def send_dtmf(self, sequence, duration='w'): '''Send a dtmf sequence with constant tone durations ''' # XXX looks like a bug with uuid_send_dtmf sending self.con.api('uuid_send_dtmf {} {} @{}'.format( self.uuid, sequence, duration), errcheck=False) def playback(self, args, start_sample=None, endless=False, leg='aleg', params=None): '''Playback a file on this session :param str args: arguments or path to audio file for playback app :type args: str or tuple :param str leg: call leg to transmit the audio on ''' app = 'endless_playback' if endless else 'playback' pairs = ('='.join(map(str, pair)) for pair in params.items()) if params else '' delim = ';' if isinstance(args, str): args = (args,) else: # set a stream file delimiter self.setvar('playback_delimiter', delim) varset = '{{{}}}'.format(','.join(pairs)) if pairs else '' args = '{streams}{start}'.format( streams=delim.join(args), start='@@{}'.format(start_sample) if start_sample else '', ) self.execute(app, args, params=varset) def start_record(self, path, rx_only=False, stereo=False, rate=16000): '''Record audio from this session to a local file on the slave filesystem using the `record_session`_ cmd. By default recordings are sampled at 16kHz. .. _record_session: https://freeswitch.org/confluence/display/FREESWITCH/record_session ''' if rx_only: self.setvar('RECORD_READ_ONLY', 'true') elif stereo: self.setvar('RECORD_STEREO', 'true') self.setvar('record_sample_rate', '{}'.format(rate)) self.execute('record_session', path) def stop_record(self, path='all', delay=0): '''Stop recording audio from this session to a local file on the slave filesystem using the `stop_record_session`_ cmd. .. _stop_record_session: https://freeswitch.org/confluence/display/FREESWITCH/mod_dptools%3A+stop_record_session ''' if delay: self.execute( "sched_api", "+{delay} none stop_record_session {path}". format(delay=delay, path=path) ) else: self.execute('stop_record_session', path) def record(self, action, path, rx_only=True): '''Record audio from this session to a local file on the slave filesystem using the `uuid_record`_ command: ``uuid_record <uuid> [start|stop|mask|unmask] <path> [<limit>]`` .. _uuid_record: https://freeswitch.org/confluence/display/FREESWITCH/mod_commands#mod_commands-uuid_record ''' self.con.api('uuid_record {} {} {}'.format(self.uuid, action, path)) def echo(self): '''Echo back all audio recieved. ''' self.execute('echo') def bypass_media(self, state): '''Re-invite a bridged node out of the media path for this session ''' if state: self.con.api('uuid_media off {}'.format(self.uuid)) else: self.con.api('uuid_media {}'.format(self.uuid)) def start_amd(self, delay=None): self.con.api('avmd {} start'.format(self.uuid)) if delay is not None: self.con.api('sched_api +{} none avmd {} stop'.format( int(delay), self.uuid)) def stop_amd(self): self.con.api('avmd {} stop'.format(self.uuid)) def park(self): '''Park this session ''' self.con.api('uuid_park {}'.format(self.uuid)) return self.recv('CHANNEL_PARK') def execute(self, cmd, arg='', params='', loops=1): """Execute an application async. """ return self.con.execute( self.uuid, cmd, arg, params=params, loops=loops) def broadcast(self, path, leg='', delay=None, hangup_cause=None): """Execute an application async on a chosen leg(s) with optional hangup afterwards. If provided tell FS to schedule the app ``delay`` seconds in the future. Uses either of the `uuid_broadcast`_ or `sched_broadcast`_ commands. .. _uuid_broadcast: https://freeswitch.org/confluence/display/FREESWITCH/mod_commands#mod_commands-uuid_broadcast .. _sched_broadcast: https://freeswitch.org/confluence/display/FREESWITCH/mod_commands#mod_commands-sched_broadcast """ warnings.warn(( "`Session.broadcast()` has been deprecated due to unreliable\ `uuid_broadcast` behaviour in FreeSWITCH core. Use\ `Session.execute()` instead."), DeprecationWarning) if not delay: return self.con.api( 'uuid_broadcast {} {} {}'.format(self.uuid, path, leg)) else: return self.con.api( 'sched_broadcast +{} {} {}'.format(delay, self.uuid, path, leg) ) def bridge(self, dest_url=None, profile=None, gateway=None, proxy=None, params=None): """Bridge this session using `uuid_broadcast` (so async). By default the current profile is used to bridge to the SIP Request-URI. """ pairs = ('='.join(map(str, pair)) for pair in params.items()) if params else '' if gateway: profile = 'gateway/{}'.format(gateway) self.execute( 'bridge', "{{{varset}}}sofia/{}/{}{dest}".format( profile if profile else self['variable_sofia_profile_name'], dest_url if dest_url else self['variable_sip_req_uri'], varset=','.join(pairs), dest=';fs_path=sip:{}'.format(proxy) if proxy else '' ) ) def breakmedia(self): '''Stop playback of media on this session and move on in the dialplan. ''' # XXX looks like a bug with uuid_break returning '-ERR no reply' self.con.api('uuid_break {}'.format(self.uuid), errcheck=False) def mute(self, direction='write', level=1): """Mute the current session. `level` determines the degree of comfort noise to generate if > 1. """ self.con.api( 'uuid_audio {uuid} {cmd} {direction} mute {level}' .format( uuid=self.uuid, cmd='start', direction=direction, level=1 if level else 0, ) ) def unmute(self, **kwargs): """Unmute the write buffer for this session """ self.mute(level=0, **kwargs) def respond(self, response): """Respond immediately with the following `response` code. see the FreeSWITCH `respond`_ dialplan application .. _respond: https://freeswitch.org/confluence/display/FREESWITCH/mod_dptools%3A+respond """ self.execute('respond', response) def deflect(self, uri): """Send a refer to the client. The only parameter should be the SIP URI to contact (with or without "sip:"):: <action application="deflect" data="sip:someone@somewhere.com" /> """ self.execute("deflect", uri) def speak(self, text, engine='flite', voice='kal', timer_name=''): """Speak, young switch (alpha). """ return self.execute( 'speak', '|'.join((engine, voice, text, timer_name))[:-1]) def is_inbound(self): """Return bool indicating whether this is an inbound session """ return self['Call-Direction'] == 'inbound' def is_outbound(self): """Return bool indicating whether this is an outbound session """ return self['Call-Direction'] == 'outbound' class Call(object): '''A collection of sessions which together compose a "phone call". ''' def __init__(self, uuid, session): self.uuid = uuid self.sessions = deque() self.sessions.append(session) self._firstref = session self._lastref = None # sub-namespace for apps to set/get state self.vars = {} def __repr__(self): return "<{}({}, {} sessions)>".format( type(self).__name__, self.uuid, len(self.sessions)) def append(self, sess): """Append a session to this call and update the ref to the last recently added session """ self.sessions.append(sess) self._lastref = sess def hangup(self): """Hangup up this call """ if self.first: self.first.hangup() @property def last(self): '''A reference to the session making up the final leg of this call ''' return self._lastref @property def first(self): '''A reference to the session making up the initial leg of this call ''' return self._firstref def get_peer(self, sess): """Convenience helper which can determine whether `sess` is one of `first` or `last` and returns the other when the former is true """ if sess: if sess is self.first: return self.last elif sess is self.last: return self.first return None class Job(object): '''A background job future. The interface closely matches `multiprocessing.pool.AsyncResult`. :param str uuid: job uuid returned directly by SOCKET_DATA event :param str sess_uuid: optional session uuid if job is associated with an active FS session ''' def __init__(self, future=None, sess_uuid=None, callback=None, event=None, client_id=None, con=None, kwargs={}): self.fut = future self.events = Events() if event: self.events.update(event) self.sess_uuid = sess_uuid self.launch_time = time.time() self.cid = client_id # placeholder for client ident self.con = con self._log = None # when the job returns use this callback self._cb = callback self.kwargs = kwargs self._result = None self._failed = False self._ev = None # signal/sync job completion @property def log(self): """Local logger instance. """ if not self._log: self._log = utils.get_logger(utils.pstr(self.con.host)) return self._log @property def uuid(self): try: return self.events['Job-UUID'] except KeyError: try: return self.fut.result()['Job-UUID'] except futures.TimeoutError: self.log.warn( "Response timeout for job {}" .format(self.sess_uuid) ) @property def result(self): '''The final result ''' return self.get() def done(self): """Return ``True`` if this job was successfully cancelled or finished running. """ return self._result or self._failed @property def _sig(self): if not self._ev: self._ev = mp.Event() # signal/sync job completion if self._result: self._ev.set() return self._ev def __call__(self, resp, *args, **kwargs): if self._cb: self.kwargs.update(kwargs) self._result = self._cb(resp, *args, **self.kwargs) else: self._result = resp if self._ev: # don't allocate an event if unused self._ev.set() # signal job completion return self._result def fail(self, resp, *args, **kwargs): '''Fail this job optionally adding an exception for its result ''' self._failed = True self._result = JobError(self(resp, *args, **kwargs)) def get(self, timeout=None): '''Get the result for this job waiting up to `timeout` seconds. Raises `TimeoutError` on if job does complete within alotted time. ''' ready = self._sig.wait(timeout) if ready: return self._result elif timeout: raise TimeoutError("Job not complete after '{}' seconds" .format(timeout)) def ready(self): '''Return bool indicating whether job has completed ''' return self._sig.is_set() def wait(self, timeout=None): '''Wait until job has completed or `timeout` has expired ''' self._sig.wait(timeout) def successful(self): '''Return bool determining whether job completed without error ''' assert self.ready(), 'Job has not completed yet' return not self._failed def update(self, event): '''Update job state/data using an event ''' self.events.update(event)