# $Id$ # # pjsua Python GUI Demo # # Copyright (C)2013 Teluu Inc. (http://www.teluu.com) # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # import sys if sys.version_info[0] >= 3: # Python 3 import tkinter as tk from tkinter import ttk else: import Tkinter as tk import ttk import buddy import call import chatgui as gui import endpoint as ep import pjsua2 as pj import re SipUriRegex = re.compile('(sip|sips):([^:;>\@]*)@?([^:;>]*):?([^:;>]*)') ConfIdx = 1 write=sys.stdout.write # Simple SIP uri parser, input URI must have been validated def ParseSipUri(sip_uri_str): m = SipUriRegex.search(sip_uri_str) if not m: assert(0) return None scheme = m.group(1) user = m.group(2) host = m.group(3) port = m.group(4) if host == '': host = user user = '' return SipUri(scheme.lower(), user, host.lower(), port) class SipUri: def __init__(self, scheme, user, host, port): self.scheme = scheme self.user = user self.host = host self.port = port def __cmp__(self, sip_uri): if self.scheme == sip_uri.scheme and self.user == sip_uri.user and self.host == sip_uri.host: # don't check port, at least for now return 0 return -1 def __str__(self): s = self.scheme + ':' if self.user: s += self.user + '@' s += self.host if self.port: s+= ':' + self.port return s class Chat(gui.ChatObserver): def __init__(self, app, acc, uri, call_inst=None): self._app = app self._acc = acc self.title = '' global ConfIdx self.confIdx = ConfIdx ConfIdx += 1 # each participant call/buddy instances are stored in call list # and buddy list with same index as in particpant list self._participantList = [] # list of SipUri self._callList = [] # list of Call self._buddyList = [] # list of Buddy self._gui = gui.ChatFrame(self) self.addParticipant(uri, call_inst) def _updateGui(self): if self.isPrivate(): self.title = str(self._participantList[0]) else: self.title = 'Conference #%d (%d participants)' % (self.confIdx, len(self._participantList)) self._gui.title(self.title) self._app.updateWindowMenu() def _getCallFromUriStr(self, uri_str, op = ''): uri = ParseSipUri(uri_str) if uri not in self._participantList: write("=== "+ op +" cannot find participant with URI '" + uri_str + "'\r\n") return None idx = self._participantList.index(uri) if idx < len(self._callList): return self._callList[idx] return None def _getActiveMediaIdx(self, thecall): ci = thecall.getInfo() for mi in ci.media: if mi.type == pj.PJMEDIA_TYPE_AUDIO and \ (mi.status != pj.PJSUA_CALL_MEDIA_NONE and \ mi.status != pj.PJSUA_CALL_MEDIA_ERROR): return mi.index return -1 def _getAudioMediaFromUriStr(self, uri_str): c = self._getCallFromUriStr(uri_str) if not c: return None idx = self._getActiveMediaIdx(c) if idx < 0: return None m = c.getMedia(idx) am = pj.AudioMedia.typecastFromMedia(m) return am def _sendTypingIndication(self, is_typing, sender_uri_str=''): sender_uri = ParseSipUri(sender_uri_str) if sender_uri_str else None type_ind_param = pj.SendTypingIndicationParam() type_ind_param.isTyping = is_typing for idx, p in enumerate(self._participantList): # don't echo back to the original sender if sender_uri and p == sender_uri: continue # send via call, if any, or buddy target = None if self._callList[idx] and self._callList[idx].connected: target = self._callList[idx] else: target = self._buddyList[idx] assert(target) try: target.sendTypingIndication(type_ind_param) except: pass def _sendInstantMessage(self, msg, sender_uri_str=''): sender_uri = ParseSipUri(sender_uri_str) if sender_uri_str else None send_im_param = pj.SendInstantMessageParam() send_im_param.content = str(msg) for idx, p in enumerate(self._participantList): # don't echo back to the original sender if sender_uri and p == sender_uri: continue # send via call, if any, or buddy target = None if self._callList[idx] and self._callList[idx].connected: target = self._callList[idx] else: target = self._buddyList[idx] assert(target) try: target.sendInstantMessage(send_im_param) except: # error will be handled via Account::onInstantMessageStatus() pass def isPrivate(self): return len(self._participantList) <= 1 def isUriParticipant(self, uri): return uri in self._participantList def registerCall(self, uri_str, call_inst): uri = ParseSipUri(uri_str) try: idx = self._participantList.index(uri) bud = self._buddyList[idx] self._callList[idx] = call_inst call_inst.chat = self call_inst.peerUri = bud.cfg.uri except: assert(0) # idx must be found! def showWindow(self, show_text_chat = False): self._gui.bringToFront() if show_text_chat: self._gui.textShowHide(True) def addParticipant(self, uri, call_inst=None): # avoid duplication if self.isUriParticipant(uri): return uri_str = str(uri) # find buddy, create one if not found (e.g: for IM/typing ind), # it is a temporary one and not really registered to acc bud = None try: bud = self._acc.findBuddy(uri_str) except: bud = buddy.Buddy(None) bud_cfg = pj.BuddyConfig() bud_cfg.uri = uri_str bud_cfg.subscribe = False bud.create(self._acc, bud_cfg) bud.cfg = bud_cfg bud.account = self._acc # update URI from buddy URI uri = ParseSipUri(bud.cfg.uri) # add it self._participantList.append(uri) self._callList.append(call_inst) self._buddyList.append(bud) self._gui.addParticipant(str(uri)) self._updateGui() def kickParticipant(self, uri): if (not uri) or (uri not in self._participantList): assert(0) return idx = self._participantList.index(uri) del self._participantList[idx] del self._callList[idx] del self._buddyList[idx] self._gui.delParticipant(str(uri)) if self._participantList: self._updateGui() else: self.onCloseWindow() def addMessage(self, from_uri_str, msg): if from_uri_str: # print message on GUI msg = from_uri_str + ': ' + msg self._gui.textAddMessage(msg) # now relay to all participants self._sendInstantMessage(msg, from_uri_str) else: self._gui.textAddMessage(msg, False) def setTypingIndication(self, from_uri_str, is_typing): # notify GUI self._gui.textSetTypingIndication(from_uri_str, is_typing) # now relay to all participants self._sendTypingIndication(is_typing, from_uri_str) def startCall(self): self._gui.enableAudio() call_param = pj.CallOpParam() call_param.opt.audioCount = 1 call_param.opt.videoCount = 0 fails = [] for idx, p in enumerate(self._participantList): # just skip if call is instantiated if self._callList[idx]: continue uri_str = str(p) c = call.Call(self._acc, uri_str, self) self._callList[idx] = c self._gui.audioUpdateState(uri_str, gui.AudioState.INITIALIZING) try: c.makeCall(uri_str, call_param) except: self._callList[idx] = None self._gui.audioUpdateState(uri_str, gui.AudioState.FAILED) fails.append(p) for p in fails: # kick participants with call failure, but spare the last (avoid zombie chat) if not self.isPrivate(): self.kickParticipant(p) def stopCall(self): for idx, p in enumerate(self._participantList): self._gui.audioUpdateState(str(p), gui.AudioState.DISCONNECTED) c = self._callList[idx] if c: c.hangup(pj.CallOpParam()) def updateCallState(self, thecall, info = None): # info is optional here, just to avoid calling getInfo() twice (in the caller and here) if not info: info = thecall.getInfo() if info.state < pj.PJSIP_INV_STATE_CONFIRMED: self._gui.audioUpdateState(thecall.peerUri, gui.AudioState.INITIALIZING) elif info.state == pj.PJSIP_INV_STATE_CONFIRMED: self._gui.audioUpdateState(thecall.peerUri, gui.AudioState.CONNECTED) if not self.isPrivate(): # inform peer about conference participants conf_welcome_str = '\n---\n' conf_welcome_str += 'Welcome to the conference, participants:\n' conf_welcome_str += '%s (host)\n' % (self._acc.cfg.idUri) for p in self._participantList: conf_welcome_str += '%s\n' % (str(p)) conf_welcome_str += '---\n' send_im_param = pj.SendInstantMessageParam() send_im_param.content = conf_welcome_str try: thecall.sendInstantMessage(send_im_param) except: pass # inform others, including self msg = "[Conf manager] %s has joined" % (thecall.peerUri) self.addMessage(None, msg) self._sendInstantMessage(msg, thecall.peerUri) elif info.state == pj.PJSIP_INV_STATE_DISCONNECTED: if info.lastStatusCode/100 != 2: self._gui.audioUpdateState(thecall.peerUri, gui.AudioState.FAILED) else: self._gui.audioUpdateState(thecall.peerUri, gui.AudioState.DISCONNECTED) # reset entry in the callList try: idx = self._callList.index(thecall) if idx >= 0: self._callList[idx] = None except: pass self.addMessage(None, "Call to '%s' disconnected: %s" % (thecall.peerUri, info.lastReason)) # kick the disconnected participant, but the last (avoid zombie chat) if not self.isPrivate(): self.kickParticipant(ParseSipUri(thecall.peerUri)) # inform others, including self msg = "[Conf manager] %s has left" % (thecall.peerUri) self.addMessage(None, msg) self._sendInstantMessage(msg, thecall.peerUri) def updateCallMediaState(self, thecall, info = None): # info is optional here, just to avoid calling getInfo() twice (in the caller and here) if not info: info = thecall.getInfo() med_idx = self._getActiveMediaIdx(thecall) if (med_idx < 0): self._gui.audioSetStatsText(thecall.peerUri, 'No active media') return si = thecall.getStreamInfo(med_idx) dir_str = '' if si.dir == 0: dir_str = 'inactive' else: if si.dir & pj.PJMEDIA_DIR_ENCODING: dir_str += 'send ' if si.dir & pj.PJMEDIA_DIR_DECODING: dir_str += 'receive ' stats_str = "Direction : %s\n" % (dir_str) stats_str += "Audio codec : %s (%sHz)" % (si.codecName, si.codecClockRate) self._gui.audioSetStatsText(thecall.peerUri, stats_str) m = pj.AudioMedia.typecastFromMedia(thecall.getMedia(med_idx)) # make conference for c in self._callList: if c == thecall: continue med_idx = self._getActiveMediaIdx(c) if med_idx < 0: continue mm = pj.AudioMedia.typecastFromMedia(c.getMedia(med_idx)) m.startTransmit(mm) mm.startTransmit(m) # ** callbacks from GUI (ChatObserver implementation) ** # Text def onSendMessage(self, msg): self._sendInstantMessage(msg) def onStartTyping(self): self._sendTypingIndication(True) def onStopTyping(self): self._sendTypingIndication(False) # Audio def onHangup(self, peer_uri_str): c = self._getCallFromUriStr(peer_uri_str, "onHangup()") if not c: return call_param = pj.CallOpParam() c.hangup(call_param) def onHold(self, peer_uri_str): c = self._getCallFromUriStr(peer_uri_str, "onHold()") if not c: return call_param = pj.CallOpParam() c.setHold(call_param) def onUnhold(self, peer_uri_str): c = self._getCallFromUriStr(peer_uri_str, "onUnhold()") if not c: return call_param = pj.CallOpParam() call_param.opt.audioCount = 1 call_param.opt.videoCount = 0 call_param.opt.flag |= pj.PJSUA_CALL_UNHOLD c.reinvite(call_param) def onRxMute(self, peer_uri_str, mute): am = self._getAudioMediaFromUriStr(peer_uri_str) if not am: return if mute: am.stopTransmit(ep.Endpoint.instance.audDevManager().getPlaybackDevMedia()) self.addMessage(None, "Muted audio from '%s'" % (peer_uri_str)) else: am.startTransmit(ep.Endpoint.instance.audDevManager().getPlaybackDevMedia()) self.addMessage(None, "Unmuted audio from '%s'" % (peer_uri_str)) def onRxVol(self, peer_uri_str, vol_pct): am = self._getAudioMediaFromUriStr(peer_uri_str) if not am: return # pjsua volume range = 0:mute, 1:no adjustment, 2:100% louder am.adjustRxLevel(vol_pct/50.0) self.addMessage(None, "Adjusted volume level audio from '%s'" % (peer_uri_str)) def onTxMute(self, peer_uri_str, mute): am = self._getAudioMediaFromUriStr(peer_uri_str) if not am: return if mute: ep.Endpoint.instance.audDevManager().getCaptureDevMedia().stopTransmit(am) self.addMessage(None, "Muted audio to '%s'" % (peer_uri_str)) else: ep.Endpoint.instance.audDevManager().getCaptureDevMedia().startTransmit(am) self.addMessage(None, "Unmuted audio to '%s'" % (peer_uri_str)) # Chat room def onAddParticipant(self): buds = [] dlg = AddParticipantDlg(None, self._app, buds) if dlg.doModal(): for bud in buds: uri = ParseSipUri(bud.cfg.uri) self.addParticipant(uri) if not self.isPrivate(): self.startCall() def onStartAudio(self): self.startCall() def onStopAudio(self): self.stopCall() def onCloseWindow(self): self.stopCall() # will remove entry from list eventually destroy this chat? if self in self._acc.chatList: self._acc.chatList.remove(self) self._app.updateWindowMenu() # destroy GUI self._gui.destroy() class AddParticipantDlg(tk.Toplevel): """ List of buddies """ def __init__(self, parent, app, bud_list): tk.Toplevel.__init__(self, parent) self.title('Add participants..') self.transient(parent) self.parent = parent self._app = app self.buddyList = bud_list self.isOk = False self.createWidgets() def doModal(self): if self.parent: self.parent.wait_window(self) else: self.wait_window(self) return self.isOk def createWidgets(self): # buddy list list_frame = ttk.Frame(self) list_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=1, padx=20, pady=20) #scrl = ttk.Scrollbar(self, orient=tk.VERTICAL, command=list_frame.yview) #list_frame.config(yscrollcommand=scrl.set) #scrl.pack(side=tk.RIGHT, fill=tk.Y) # draw buddy list self.buddies = [] for acc in self._app.accList: self.buddies.append((0, acc.cfg.idUri)) for bud in acc.buddyList: self.buddies.append((1, bud)) self.bud_var = [] for idx,(flag,bud) in enumerate(self.buddies): self.bud_var.append(tk.IntVar()) if flag==0: s = ttk.Separator(list_frame, orient=tk.HORIZONTAL) s.pack(fill=tk.X) l = tk.Label(list_frame, anchor=tk.W, text="Account '%s':" % (bud)) l.pack(fill=tk.X) else: c = tk.Checkbutton(list_frame, anchor=tk.W, text=bud.cfg.uri, variable=self.bud_var[idx]) c.pack(fill=tk.X) s = ttk.Separator(list_frame, orient=tk.HORIZONTAL) s.pack(fill=tk.X) # Ok/cancel buttons tail_frame = ttk.Frame(self) tail_frame.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=1) btnOk = ttk.Button(tail_frame, text='Ok', default=tk.ACTIVE, command=self.onOk) btnOk.pack(side=tk.LEFT, padx=20, pady=10) btnCancel = ttk.Button(tail_frame, text='Cancel', command=self.onCancel) btnCancel.pack(side=tk.RIGHT, padx=20, pady=10) def onOk(self): self.buddyList[:] = [] for idx,(flag,bud) in enumerate(self.buddies): if not flag: continue if self.bud_var[idx].get() and not (bud in self.buddyList): self.buddyList.append(bud) self.isOk = True self.destroy() def onCancel(self): self.destroy()