# -*- coding: utf-8 -*- import threading import logging import time import select import socket import ssl import struct from errors import * from constants import * import users import channels import blobs import commands import messages import callbacks import tools import soundoutput import mumble_pb2 from pycelt import SUPPORTED_BITSTREAMS class Mumble(threading.Thread): """ Mumble client library main object. basically a thread """ def __init__(self, host=None, port=None, user=None, password=None, client_certif=None, reconnect=False, debug=False): """ host=mumble server hostname or address port=mumble server port user=user to use for the connection password=password for the connection client_certif=client certificate to authenticate the connection (NOT IMPLEMENTED) reconnect=if True, try to reconnect if disconnected debug=if True, send debugging messages (lot of...) to the stdout """ #TODO: client certificate authentication #TODO: exit both threads properly #TODO: use UDP audio threading.Thread.__init__(self) self.Log = logging.getLogger("PyMumble") # logging object for errors and debugging if debug: self.Log.setLevel(logging.DEBUG) else: self.Log.setLevel(logging.ERROR) ch = logging.StreamHandler() ch.setLevel(logging.DEBUG) formatter = logging.Formatter('%(asctime)s-%(name)s-%(levelname)s-%(message)s') ch.setFormatter(formatter) self.Log.addHandler(ch) self.parent_thread = threading.current_thread() # main thread of the calling application self.mumble_thread = None # thread of the mumble client library self.host = host self.port = port self.user = user self.password = password self.client_certif = client_certif self.reconnect = reconnect self.receive_sound = False # set to True to treat incoming audio, otherwise it is simply ignored self.loop_rate = PYMUMBLE_LOOP_RATE self.application = PYMUMBLE_VERSION_STRING self.callbacks = callbacks.CallBacks() #callbacks management self.ready_lock = threading.Lock() # released when the connection is fully established with the server self.ready_lock.acquire() def init_connection(self): """Initialize variables that are local to a connection, (needed if the client automatically reconnect)""" self.ready_lock.acquire(False) # reacquire the ready-lock in case of reconnection self.connected = PYMUMBLE_CONN_STATE_NOT_CONNECTED self.control_socket = None self.media_socket = None # Not implemented - for UDP media self.bandwidth = PYMUMBLE_BANDWIDTH # reset the outgoing bandwidth to it's default before connectiong self.server_max_bandwidth = None self.udp_active = False self.users = users.Users(self, self.callbacks) # contain the server's connected users informations self.channels = channels.Channels(self, self.callbacks) # contain the server's channels informations self.blobs = blobs.Blobs(self) # manage the blob objects self.sound_output = soundoutput.SoundOutput(self, PYMUMBLE_AUDIO_PER_PACKET, self.bandwidth) # manage the outgoing sounds self.commands = commands.Commands() # manage commands sent between the main and the mumble threads self.receive_buffer = "" # initialize the control connection input buffer def run(self): """Connect to the server and start the loop in its thread. Retry if requested""" self.mumble_thread = threading.current_thread() # loop if auto-reconnect is requested while True: self.init_connection() # reset the connection-specific object members self.connect() self.loop() if not self.reconnect or not self.parent_thread.is_alive(): break time.sleep(PYMUMBLE_CONNECTION_RETRY_INTERVAL) def connect(self): """Connect to the server""" # Connect the SSL tunnel self.Log.debug("connecting to %s on port %i.", self.host, self.port) std_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.control_socket = ssl.wrap_socket(std_sock, certfile=self.client_certif, ssl_version=ssl.PROTOCOL_TLSv1) self.control_socket.connect((self.host, self.port)) self.control_socket.setblocking(0) # Perform the Mumble authentication version = mumble_pb2.Version() version.version = (PYMUMBLE_PROTOCOL_VERSION[0] << 16) + (PYMUMBLE_PROTOCOL_VERSION[1] << 8) + PYMUMBLE_PROTOCOL_VERSION[2] version.release = self.application version.os = PYMUMBLE_OS_STRING version.os_version = PYMUMBLE_OS_VERSION_STRING self.Log.debug("sending: version: %s", version) self.send_message(PYMUMBLE_MSG_TYPES_VERSION, version) authenticate = mumble_pb2.Authenticate() authenticate.username = self.user authenticate.password = self.password authenticate.celt_versions.extend(SUPPORTED_BITSTREAMS.keys()) # authenticate.celt_versions.extend([-2147483637]) # for debugging - only celt 0.7 authenticate.opus = True self.Log.debug("sending: authenticate: %s", authenticate) self.send_message(PYMUMBLE_MSG_TYPES_AUTHENTICATE, authenticate) self.connected = PYMUMBLE_CONN_STATE_AUTHENTICATING def loop(self): """ Main loop waiting for a message from the server for maximum self.loop_rate time take care of sending the ping take care of sending the queued commands to the server check on every iteration for outgoing sound check for disconnection """ self.Log.debug("entering loop") last_ping = time.time() # keep track of the last ping time # loop as long as the connection and the parent thread are alive while self.connected != PYMUMBLE_CONN_STATE_NOT_CONNECTED and self.parent_thread.is_alive(): if last_ping + PYMUMBLE_PING_DELAY <= time.time(): # when it is time, send the ping self.ping() last_ping = time.time() if self.connected == PYMUMBLE_CONN_STATE_CONNECTED: while self.commands.is_cmd(): self.treat_command(self.commands.pop_cmd()) # send the commands coming from the application to the server self.sound_output.send_audio() # send outgoing audio if available (rlist, wlist, xlist) = select.select([self.control_socket], [], [self.control_socket], self.loop_rate) # wait for a socket activity if self.control_socket in rlist: # something to be read on the control socket self.read_control_messages() elif self.control_socket in xlist: # socket was closed self.control_socket.close() self.connected = PYMUMBLE_CONN_STATE_NOT_CONNECTED def ping(self): """Send the keepalive through available channels""" #TODO: Ping counters ping = mumble_pb2.Ping() ping.timestamp=int(time.time()) self.Log.debug("sending: ping: %s", ping) self.send_message(PYMUMBLE_MSG_TYPES_PING, ping) def send_message(self, type, message): """Send a control message to the server""" packet=struct.pack("!HL", type, message.ByteSize()) + message.SerializeToString() while len(packet)>0: self.Log.debug("sending message") sent=self.control_socket.send(packet) if sent < 0: raise socket.error("Server socket error") packet=packet[sent:] def read_control_messages(self): """Read control messages coming from the server""" # from tools import toHex # for debugging buffer = self.control_socket.recv(PYMUMBLE_READ_BUFFER_SIZE) self.receive_buffer += buffer while len(self.receive_buffer) >= 6: # header is present (type + length) self.Log.debug("read control connection") header = self.receive_buffer[0:6] (type, size) = struct.unpack("!HL", header) # decode header if len(self.receive_buffer) < size+6: # if not length data, read further break # self.Log.debug("message received : " + toHex(self.receive_buffer[0:size+6])) # for debugging message = self.receive_buffer[6:size+6] # get the control message self.receive_buffer = self.receive_buffer[size+6:] # remove from the buffer the read part self.dispatch_control_message(type, message) def dispatch_control_message(self, type, message): """Dispatch control messages based on their type""" self.Log.debug("dispatch control message") if type == PYMUMBLE_MSG_TYPES_UDPTUNNEL: # audio encapsulated in control message self.sound_received(message) elif type == PYMUMBLE_MSG_TYPES_VERSION: mess = mumble_pb2.Version() mess.ParseFromString(message) self.Log.debug("message: Version : %s", mess) elif type == PYMUMBLE_MSG_TYPES_AUTHENTICATE: mess = mumble_pb2.Authenticate() mess.ParseFromString(message) self.Log.debug("message: Authenticate : %s", mess) elif type == PYMUMBLE_MSG_TYPES_PING: mess = mumble_pb2.Ping() mess.ParseFromString(message) self.Log.debug("message: Ping : %s", mess) elif type == PYMUMBLE_MSG_TYPES_REJECT: mess = mumble_pb2.Reject() mess.ParseFromString(message) self.Log.debug("message: reject : %s", mess) self.ready_lock.release() raise ConnectionRejectedError(mess.reason) elif type == PYMUMBLE_MSG_TYPES_SERVERSYNC: # this message finish the connection process mess = mumble_pb2.ServerSync() mess.ParseFromString(message) self.Log.debug("message: serversync : %s", mess) self.users.set_myself(mess.session) self.server_max_bandwidth = mess.max_bandwidth self.set_bandwidth(mess.max_bandwidth) if self.connected == PYMUMBLE_CONN_STATE_AUTHENTICATING: self.connected = PYMUMBLE_CONN_STATE_CONNECTED self.callbacks(PYMUMBLE_CLBK_CONNECTED) self.ready_lock.release() # release the ready-lock elif type == PYMUMBLE_MSG_TYPES_CHANNELREMOVE: mess = mumble_pb2.ChannelRemove() mess.ParseFromString(message) self.Log.debug("message: ChannelRemove : %s", mess) self.channels.remove(mess.channel_id) elif type == PYMUMBLE_MSG_TYPES_CHANNELSTATE: mess = mumble_pb2.ChannelState() mess.ParseFromString(message) self.Log.debug("message: channelstate : %s", mess) self.channels.update(mess) elif type == PYMUMBLE_MSG_TYPES_USERREMOVE: mess = mumble_pb2.UserRemove() mess.ParseFromString(message) self.Log.debug("message: UserRemove : %s", mess) self.users.remove(mess) elif type == PYMUMBLE_MSG_TYPES_USERSTATE: mess = mumble_pb2.UserState() mess.ParseFromString(message) self.Log.debug("message: userstate : %s", mess) self.users.update(mess) elif type == PYMUMBLE_MSG_TYPES_BANLIST: mess = mumble_pb2.BanList() mess.ParseFromString(message) self.Log.debug("message: BanList : %s", mess) elif type == PYMUMBLE_MSG_TYPES_TEXTMESSAGE: mess = mumble_pb2.TextMessage() mess.ParseFromString(message) self.Log.debug("message: TextMessage : %s", mess) self.callbacks(PYMUMBLE_CLBK_TEXTMESSAGERECEIVED, mess.message) elif type == PYMUMBLE_MSG_TYPES_PERMISSIONDENIED: mess = mumble_pb2.PermissionDenied() mess.ParseFromString(message) self.Log.debug("message: PermissionDenied : %s", mess) elif type == PYMUMBLE_MSG_TYPES_ACL: mess = mumble_pb2.ACL() mess.ParseFromString(message) self.Log.debug("message: ACL : %s", mess) elif type == PYMUMBLE_MSG_TYPES_QUERYUSERS: mess = mumble_pb2.QueryUsers() mess.ParseFromString(message) self.Log.debug("message: QueryUsers : %s", mess) elif type == PYMUMBLE_MSG_TYPES_CRYPTSETUP: mess = mumble_pb2.CryptSetup() mess.ParseFromString(message) self.Log.debug("message: CryptSetup : %s", mess) elif type == PYMUMBLE_MSG_TYPES_CONTEXTACTIONADD: mess = mumble_pb2.ContextActionAdd() mess.ParseFromString(message) self.Log.debug("message: ContextActionAdd : %s", mess) elif type == PYMUMBLE_MSG_TYPES_CONTEXTACTION: mess = mumble_pb2.ContextActionAdd() mess.ParseFromString(message) self.Log.debug("message: ContextActionAdd : %s", mess) elif type == PYMUMBLE_MSG_TYPES_USERLIST: mess = mumble_pb2.UserList() mess.ParseFromString(message) self.Log.debug("message: UserList : %s", mess) elif type == PYMUMBLE_MSG_TYPES_VOICETARGET: mess = mumble_pb2.VoiceTarget() mess.ParseFromString(message) self.Log.debug("message: VoiceTarget : %s", mess) elif type == PYMUMBLE_MSG_TYPES_PERMISSIONQUERY: mess = mumble_pb2.PermissionQuery() mess.ParseFromString(message) self.Log.debug("message: PermissionQuery : %s", mess) elif type == PYMUMBLE_MSG_TYPES_CODECVERSION: mess = mumble_pb2.CodecVersion() mess.ParseFromString(message) self.Log.debug("message: CodecVersion : %s", mess) self.sound_output.set_default_codec(mess) elif type == PYMUMBLE_MSG_TYPES_USERSTATS: mess = mumble_pb2.UserStats() mess.ParseFromString(message) self.Log.debug("message: UserStats : %s", mess) elif type == PYMUMBLE_MSG_TYPES_REQUESTBLOB: mess = mumble_pb2.RequestBlob() mess.ParseFromString(message) self.Log.debug("message: RequestBlob : %s", mess) elif type == PYMUMBLE_MSG_TYPES_SERVERCONFIG: mess = mumble_pb2.ServerConfig() mess.ParseFromString(message) self.Log.debug("message: ServerConfig : %s", mess) def set_bandwidth(self, bandwidth): """set the total allowed outgoing bandwidth""" if self.server_max_bandwidth is not None and bandwidth > self.server_max_bandwidth: self.bandwidth = self.server_max_bandwidth else: self.bandwidth = bandwidth self.sound_output.set_bandwidth(self.bandwidth) # communicate the update to the outgoing audio manager def sound_received(self, message): """Manage a received sound message""" # from tools import toHex # for debugging pos = 0 # self.Log.debug("sound packet : " + toHex(message)) # for debugging (header, ) = struct.unpack("!B", message[pos]) # extract the header type = ( header & 0b11100000 ) >> 5 target = header & 0b00011111 pos += 1 if type == PYMUMBLE_AUDIO_TYPE_PING: return session = tools.VarInt() # decode session id pos += session.decode(message[pos:pos+10]) sequence = tools.VarInt() # decode sequence number pos += sequence.decode(message[pos:pos+10]) self.Log.debug("audio packet received from %i, sequence %i, type:%i, target:%i, lenght:%i", session.value, sequence.value, type, target, len(message)) terminator = False # set to true if it's the last 10 ms audio frame for the packet (used with CELT codec) while ( pos < len(message)) and not terminator: # get the audio frames one by one if type == PYMUMBLE_AUDIO_TYPE_OPUS: size = tools.VarInt() # OPUS use varint for the frame length pos += size.decode(message[pos:pos+10]) size = size.value if not (size & 0x2000): # terminator is 0x2000 in the resulting int. terminator = True # should actually always be 0 as OPUS can use variable length audio frames size = size & 0x1fff # isolate the size from the terminator else: (header, ) = struct.unpack("!B", message[pos]) # CELT length and terminator is encoded in a 1 byte int if not (header & 0b10000000): terminator = True size = header & 0b01111111 pos += 1 self.Log.debug("Audio frame : time:%f, last:%s, size:%i, type:%i, target:%i, pos:%i",time.time(), str(terminator), size, type, target, pos-1) if size > 0 and self.receive_sound: # if audio must be treated try: newsound = self.users[session.value].sound.add(message[pos:pos+size], sequence.value, type, target) # add the sound to the user's sound queue self.callbacks(PYMUMBLE_CLBK_SOUNDRECEIVED, self.users[session.value], newsound) self.Log.debug("Audio frame : time:%f last:%s, size:%i, uncompressed:%i, type:%i, target:%i",time.time(), str(terminator), size, newsound.size, type, target) except CodecNotSupportedError as msg: print msg except KeyError: # sound received after user removed pass sequence.value += int(round(newsound.duration / 1000 * 10)) # add 1 sequence per 10ms of audio # if len(message) - pos < size: # raise InvalidFormatError("Invalid audio frame size") pos += size # go further in the packet, after the audio frame #TODO: get position info def set_application_string(self, string): """Set the application name, that can be viewed by other clients on the server""" self.application = string def set_loop_rate(self, rate): """set the current main loop rate (pause per iteration)""" self.loop_rate = rate def get_loop_rate(self): """get the current main loop rate (pause per iteration)""" return(self.loop_rate) def set_receive_sound(self, value): """Enable or disable the management of incoming sounds""" if value: self.receive_sound = True else: self.receive_sound = False def is_ready(self): """Wait for the connection to be fully completed. To be used in the main thread""" self.ready_lock.acquire() self.ready_lock.release() def execute_command(self, cmd, blocking=True): """Create a command to be sent to the server. To be userd in the main thread""" self.is_ready() lock = self.commands.new_cmd(cmd) if blocking and self.mumble_thread is not threading.current_thread(): lock.acquire() lock.release() return lock #TODO: manage a timeout for blocking commands. Currently, no command actually waits for the server to execute # The result of these commands should actually be checked against incoming server updates def treat_command(self, cmd): """Send the awaiting commands to the server. Used in the pymumble thread.""" if cmd.cmd == PYMUMBLE_CMD_MOVE: userstate = mumble_pb2.UserState() userstate.session = cmd.parameters["session"] userstate.channel_id = cmd.parameters["channel_id"] self.Log.debug("Moving to channel") self.send_message(PYMUMBLE_MSG_TYPES_USERSTATE, userstate) cmd.response = True self.commands.answer(cmd) elif cmd.cmd == PYMUMBLE_CMD_MODUSERSTATE: userstate = mumble_pb2.UserState() userstate.session = cmd.parameters["session"] if "mute" in cmd.parameters: userstate.mute = cmd.parameters["mute"] if "self_mute" in cmd.parameters: userstate.self_mute = cmd.parameters["self_mute"] if "deaf" in cmd.parameters: userstate.deaf = cmd.parameters["deaf"] if "self_deaf" in cmd.parameters: userstate.self_deaf = cmd.parameters["self_deaf"] if "suppress" in cmd.parameters: userstate.suppress = cmd.parameters["suppress"] if "recording" in cmd.parameters: userstate.recording = cmd.parameters["recording"] if "comment" in cmd.parameters: userstate.comment = cmd.parameters["comment"] if "texture" in cmd.parameters: userstate.texture = cmd.parameters["texture"] self.send_message(PYMUMBLE_MSG_TYPES_USERSTATE, userstate) cmd.response = True self.commands.answer(cmd)