blob: 9d2d2e2f5d924abf33136ef7b2e06e1f1a100b79 [file] [log] [blame]
Alexandre Lision0e143012014-01-22 11:02:46 -05001# $Id: chat.py 4704 2014-01-16 05:30:46Z ming $
2#
3# pjsua Python GUI Demo
4#
5# Copyright (C)2013 Teluu Inc. (http://www.teluu.com)
6#
7# This program is free software; you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation; either version 2 of the License, or
10# (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program; if not, write to the Free Software
19# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
20#
21import sys
22if sys.version_info[0] >= 3: # Python 3
23 import tkinter as tk
24 from tkinter import ttk
25else:
26 import Tkinter as tk
27 import ttk
28
29import buddy
30import call
31import chatgui as gui
32import endpoint as ep
33import pjsua2 as pj
34import re
35
36SipUriRegex = re.compile('(sip|sips):([^:;>\@]*)@?([^:;>]*):?([^:;>]*)')
37ConfIdx = 1
38
39# Simple SIP uri parser, input URI must have been validated
40def ParseSipUri(sip_uri_str):
41 m = SipUriRegex.search(sip_uri_str)
42 if not m:
43 assert(0)
44 return None
45
46 scheme = m.group(1)
47 user = m.group(2)
48 host = m.group(3)
49 port = m.group(4)
50 if host == '':
51 host = user
52 user = ''
53
54 return SipUri(scheme.lower(), user, host.lower(), port)
55
56class SipUri:
57 def __init__(self, scheme, user, host, port):
58 self.scheme = scheme
59 self.user = user
60 self.host = host
61 self.port = port
62
63 def __cmp__(self, sip_uri):
64 if self.scheme == sip_uri.scheme and self.user == sip_uri.user and self.host == sip_uri.host:
65 # don't check port, at least for now
66 return 0
67 return -1
68
69 def __str__(self):
70 s = self.scheme + ':'
71 if self.user: s += self.user + '@'
72 s += self.host
73 if self.port: s+= ':' + self.port
74 return s
75
76class Chat(gui.ChatObserver):
77 def __init__(self, app, acc, uri, call_inst=None):
78 self._app = app
79 self._acc = acc
80 self.title = ''
81
82 global ConfIdx
83 self.confIdx = ConfIdx
84 ConfIdx += 1
85
86 # each participant call/buddy instances are stored in call list
87 # and buddy list with same index as in particpant list
88 self._participantList = [] # list of SipUri
89 self._callList = [] # list of Call
90 self._buddyList = [] # list of Buddy
91
92 self._gui = gui.ChatFrame(self)
93 self.addParticipant(uri, call_inst)
94
95 def _updateGui(self):
96 if self.isPrivate():
97 self.title = str(self._participantList[0])
98 else:
99 self.title = 'Conference #%d (%d participants)' % (self.confIdx, len(self._participantList))
100 self._gui.title(self.title)
101 self._app.updateWindowMenu()
102
103 def _getCallFromUriStr(self, uri_str, op = ''):
104 uri = ParseSipUri(uri_str)
105 if uri not in self._participantList:
106 print "=== %s cannot find participant with URI '%s'" % (op, uri_str)
107 return None
108 idx = self._participantList.index(uri)
109 if idx < len(self._callList):
110 return self._callList[idx]
111 return None
112
113 def _getActiveMediaIdx(self, thecall):
114 ci = thecall.getInfo()
115 for mi in ci.media:
116 if mi.type == pj.PJMEDIA_TYPE_AUDIO and \
117 (mi.status != pj.PJSUA_CALL_MEDIA_NONE and \
118 mi.status != pj.PJSUA_CALL_MEDIA_ERROR):
119 return mi.index
120 return -1
121
122 def _getAudioMediaFromUriStr(self, uri_str):
123 c = self._getCallFromUriStr(uri_str)
124 if not c: return None
125
126 idx = self._getActiveMediaIdx(c)
127 if idx < 0: return None
128
129 m = c.getMedia(idx)
130 am = pj.AudioMedia.typecastFromMedia(m)
131 return am
132
133 def _sendTypingIndication(self, is_typing, sender_uri_str=''):
134 sender_uri = ParseSipUri(sender_uri_str) if sender_uri_str else None
135 type_ind_param = pj.SendTypingIndicationParam()
136 type_ind_param.isTyping = is_typing
137 for idx, p in enumerate(self._participantList):
138 # don't echo back to the original sender
139 if sender_uri and p == sender_uri:
140 continue
141
142 # send via call, if any, or buddy
143 sender = None
144 if self._callList[idx] and self._callList[idx].connected:
145 sender = self._callList[idx]
146 else:
147 sender = self._buddyList[idx]
148 assert(sender)
149
150 try:
151 sender.sendTypingIndication(type_ind_param)
152 except:
153 pass
154
155 def _sendInstantMessage(self, msg, sender_uri_str=''):
156 sender_uri = ParseSipUri(sender_uri_str) if sender_uri_str else None
157 send_im_param = pj.SendInstantMessageParam()
158 send_im_param.content = str(msg)
159 for idx, p in enumerate(self._participantList):
160 # don't echo back to the original sender
161 if sender_uri and p == sender_uri:
162 continue
163
164 # send via call, if any, or buddy
165 sender = None
166 if self._callList[idx] and self._callList[idx].connected:
167 sender = self._callList[idx]
168 else:
169 sender = self._buddyList[idx]
170 assert(sender)
171
172 try:
173 sender.sendInstantMessage(send_im_param)
174 except:
175 # error will be handled via Account::onInstantMessageStatus()
176 pass
177
178 def isPrivate(self):
179 return len(self._participantList) <= 1
180
181 def isUriParticipant(self, uri):
182 return uri in self._participantList
183
184 def registerCall(self, uri_str, call_inst):
185 uri = ParseSipUri(uri_str)
186 try:
187 idx = self._participantList.index(uri)
188 bud = self._buddyList[idx]
189 self._callList[idx] = call_inst
190 call_inst.chat = self
191 call_inst.peerUri = bud.cfg.uri
192 except:
193 assert(0) # idx must be found!
194
195 def showWindow(self, show_text_chat = False):
196 self._gui.bringToFront()
197 if show_text_chat:
198 self._gui.textShowHide(True)
199
200 def addParticipant(self, uri, call_inst=None):
201 # avoid duplication
202 if self.isUriParticipant(uri): return
203
204 uri_str = str(uri)
205
206 # find buddy, create one if not found (e.g: for IM/typing ind),
207 # it is a temporary one and not really registered to acc
208 bud = None
209 try:
210 bud = self._acc.findBuddy(uri_str)
211 except:
212 bud = buddy.Buddy(None)
213 bud_cfg = pj.BuddyConfig()
214 bud_cfg.uri = uri_str
215 bud_cfg.subscribe = False
216 bud.create(self._acc, bud_cfg)
217 bud.cfg = bud_cfg
218 bud.account = self._acc
219
220 # update URI from buddy URI
221 uri = ParseSipUri(bud.cfg.uri)
222
223 # add it
224 self._participantList.append(uri)
225 self._callList.append(call_inst)
226 self._buddyList.append(bud)
227 self._gui.addParticipant(str(uri))
228 self._updateGui()
229
230 def kickParticipant(self, uri):
231 if (not uri) or (uri not in self._participantList):
232 assert(0)
233 return
234
235 idx = self._participantList.index(uri)
236 del self._participantList[idx]
237 del self._callList[idx]
238 del self._buddyList[idx]
239 self._gui.delParticipant(str(uri))
240
241 if self._participantList:
242 self._updateGui()
243 else:
244 self.onCloseWindow()
245
246 def addMessage(self, from_uri_str, msg):
247 if from_uri_str:
248 # print message on GUI
249 msg = from_uri_str + ': ' + msg
250 self._gui.textAddMessage(msg)
251 # now relay to all participants
252 self._sendInstantMessage(msg, from_uri_str)
253 else:
254 self._gui.textAddMessage(msg, False)
255
256 def setTypingIndication(self, from_uri_str, is_typing):
257 # notify GUI
258 self._gui.textSetTypingIndication(from_uri_str, is_typing)
259 # now relay to all participants
260 self._sendTypingIndication(is_typing, from_uri_str)
261
262 def startCall(self):
263 self._gui.enableAudio()
264 call_param = pj.CallOpParam()
265 call_param.opt.audioCount = 1
266 call_param.opt.videoCount = 0
267 fails = []
268 for idx, p in enumerate(self._participantList):
269 # just skip if call is instantiated
270 if self._callList[idx]:
271 continue
272
273 uri_str = str(p)
274 c = call.Call(self._acc, uri_str, self)
275 self._callList[idx] = c
276 self._gui.audioUpdateState(uri_str, gui.AudioState.INITIALIZING)
277
278 try:
279 c.makeCall(uri_str, call_param)
280 except:
281 self._callList[idx] = None
282 self._gui.audioUpdateState(uri_str, gui.AudioState.FAILED)
283 fails.append(p)
284
285 for p in fails:
286 # kick participants with call failure, but spare the last (avoid zombie chat)
287 if not self.isPrivate():
288 self.kickParticipant(p)
289
290 def stopCall(self):
291 for idx, p in enumerate(self._participantList):
292 self._gui.audioUpdateState(str(p), gui.AudioState.DISCONNECTED)
293 c = self._callList[idx]
294 if c:
295 c.hangup(pj.CallOpParam())
296
297 def updateCallState(self, thecall, info = None):
298 # info is optional here, just to avoid calling getInfo() twice (in the caller and here)
299 if not info: info = thecall.getInfo()
300
301 if info.state < pj.PJSIP_INV_STATE_CONFIRMED:
302 self._gui.audioUpdateState(thecall.peerUri, gui.AudioState.INITIALIZING)
303 elif info.state == pj.PJSIP_INV_STATE_CONFIRMED:
304 self._gui.audioUpdateState(thecall.peerUri, gui.AudioState.CONNECTED)
305 med_idx = self._getActiveMediaIdx(thecall)
306 si = thecall.getStreamInfo(med_idx)
307 stats_str = "Audio codec: %s/%s\n..." % (si.codecName, si.codecClockRate)
308 self._gui.audioSetStatsText(thecall.peerUri, stats_str)
309 elif info.state == pj.PJSIP_INV_STATE_DISCONNECTED:
310 if info.lastStatusCode/100 != 2:
311 self._gui.audioUpdateState(thecall.peerUri, gui.AudioState.FAILED)
312 else:
313 self._gui.audioUpdateState(thecall.peerUri, gui.AudioState.DISCONNECTED)
314
315 # reset entry in the callList
316 try:
317 idx = self._callList.index(thecall)
318 if idx >= 0: self._callList[idx] = None
319 except:
320 pass
321
322 self.addMessage(None, "Call to '%s' disconnected: %s" % (thecall.peerUri, info.lastReason))
323
324 # kick the disconnected participant, but the last (avoid zombie chat)
325 if not self.isPrivate():
326 self.kickParticipant(ParseSipUri(thecall.peerUri))
327
328
329 # ** callbacks from GUI (ChatObserver implementation) **
330
331 # Text
332 def onSendMessage(self, msg):
333 self._sendInstantMessage(msg)
334
335 def onStartTyping(self):
336 self._sendTypingIndication(True)
337
338 def onStopTyping(self):
339 self._sendTypingIndication(False)
340
341 # Audio
342 def onHangup(self, peer_uri_str):
343 c = self._getCallFromUriStr(peer_uri_str, "onHangup()")
344 if not c: return
345 call_param = pj.CallOpParam()
346 c.hangup(call_param)
347
348 def onHold(self, peer_uri_str):
349 c = self._getCallFromUriStr(peer_uri_str, "onHold()")
350 if not c: return
351 call_param = pj.CallOpParam()
352 c.setHold(call_param)
353
354 def onUnhold(self, peer_uri_str):
355 c = self._getCallFromUriStr(peer_uri_str, "onUnhold()")
356 if not c: return
357
358 call_param = pj.CallOpParam()
359 call_param.opt.audioCount = 1
360 call_param.opt.videoCount = 0
361 call_param.opt.flag |= pj.PJSUA_CALL_UNHOLD
362 c.reinvite(call_param)
363
364 def onRxMute(self, peer_uri_str, mute):
365 am = self._getAudioMediaFromUriStr(peer_uri_str)
366 if not am: return
367 if mute:
368 am.stopTransmit(ep.Endpoint.instance.audDevManager().getPlaybackDevMedia())
369 self.addMessage(None, "Muted audio from '%s'" % (peer_uri_str))
370 else:
371 am.startTransmit(ep.Endpoint.instance.audDevManager().getPlaybackDevMedia())
372 self.addMessage(None, "Unmuted audio from '%s'" % (peer_uri_str))
373
374 def onRxVol(self, peer_uri_str, vol_pct):
375 am = self._getAudioMediaFromUriStr(peer_uri_str)
376 if not am: return
377 # pjsua volume range = 0:mute, 1:no adjustment, 2:100% louder
378 am.adjustRxLevel(vol_pct/50.0)
379 self.addMessage(None, "Adjusted volume level audio from '%s'" % (peer_uri_str))
380
381 def onTxMute(self, peer_uri_str, mute):
382 am = self._getAudioMediaFromUriStr(peer_uri_str)
383 if not am: return
384 if mute:
385 ep.Endpoint.instance.audDevManager().getCaptureDevMedia().stopTransmit(am)
386 self.addMessage(None, "Muted audio to '%s'" % (peer_uri_str))
387 else:
388 ep.Endpoint.instance.audDevManager().getCaptureDevMedia().startTransmit(am)
389 self.addMessage(None, "Unmuted audio to '%s'" % (peer_uri_str))
390
391 # Chat room
392 def onAddParticipant(self):
393 buds = []
394 dlg = AddParticipantDlg(None, self._app, buds)
395 if dlg.doModal():
396 for bud in buds:
397 uri = ParseSipUri(bud.cfg.uri)
398 self.addParticipant(uri)
399 if not self.isPrivate():
400 self.startCall()
401
402 def onStartAudio(self):
403 self.startCall()
404
405 def onStopAudio(self):
406 self.stopCall()
407
408 def onCloseWindow(self):
409 self.stopCall()
410 # will remove entry from list eventually destroy this chat?
411 if self in self._acc.chatList: self._acc.chatList.remove(self)
412 self._app.updateWindowMenu()
413 # destroy GUI
414 self._gui.destroy()
415
416
417class AddParticipantDlg(tk.Toplevel):
418 """
419 List of buddies
420 """
421 def __init__(self, parent, app, bud_list):
422 tk.Toplevel.__init__(self, parent)
423 self.title('Add participants..')
424 self.transient(parent)
425 self.parent = parent
426 self._app = app
427 self.buddyList = bud_list
428
429 self.isOk = False
430
431 self.createWidgets()
432
433 def doModal(self):
434 if self.parent:
435 self.parent.wait_window(self)
436 else:
437 self.wait_window(self)
438 return self.isOk
439
440 def createWidgets(self):
441 # buddy list
442 list_frame = ttk.Frame(self)
443 list_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=1, padx=20, pady=20)
444 #scrl = ttk.Scrollbar(self, orient=tk.VERTICAL, command=list_frame.yview)
445 #list_frame.config(yscrollcommand=scrl.set)
446 #scrl.pack(side=tk.RIGHT, fill=tk.Y)
447
448 # draw buddy list
449 self.buddies = []
450 for acc in self._app.accList:
451 self.buddies.append((0, acc.cfg.idUri))
452 for bud in acc.buddyList:
453 self.buddies.append((1, bud))
454
455 self.bud_var = []
456 for idx,(flag,bud) in enumerate(self.buddies):
457 self.bud_var.append(tk.IntVar())
458 if flag==0:
459 s = ttk.Separator(list_frame, orient=tk.HORIZONTAL)
460 s.pack(fill=tk.X)
461 l = tk.Label(list_frame, anchor=tk.W, text="Account '%s':" % (bud))
462 l.pack(fill=tk.X)
463 else:
464 c = tk.Checkbutton(list_frame, anchor=tk.W, text=bud.cfg.uri, variable=self.bud_var[idx])
465 c.pack(fill=tk.X)
466 s = ttk.Separator(list_frame, orient=tk.HORIZONTAL)
467 s.pack(fill=tk.X)
468
469 # Ok/cancel buttons
470 tail_frame = ttk.Frame(self)
471 tail_frame.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=1)
472
473 btnOk = ttk.Button(tail_frame, text='Ok', default=tk.ACTIVE, command=self.onOk)
474 btnOk.pack(side=tk.LEFT, padx=20, pady=10)
475 btnCancel = ttk.Button(tail_frame, text='Cancel', command=self.onCancel)
476 btnCancel.pack(side=tk.RIGHT, padx=20, pady=10)
477
478 def onOk(self):
479 self.buddyList[:] = []
480 for idx,(flag,bud) in enumerate(self.buddies):
481 if not flag: continue
482 if self.bud_var[idx].get() and not (bud in self.buddyList):
483 self.buddyList.append(bud)
484
485 self.isOk = True
486 self.destroy()
487
488 def onCancel(self):
489 self.destroy()