545 lines
18 KiB
Python
545 lines
18 KiB
Python
#
|
|
# 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.findBuddy2(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()
|