From 5fecebaa12d5eeffaf0f1fbc2f01a150d49ea87a Mon Sep 17 00:00:00 2001 From: Username Date: Tue, 24 Feb 2026 12:00:59 +0100 Subject: [PATCH] client: replace async wrapper with thread-safe dispatcher Convert connect/disconnect to blocking calls (pymumble is synchronous). Remove asyncio loop coupling. Add set_dispatcher() for marshalling callbacks into the host event loop. Register all pymumble callbacks: connected, disconnected, user/channel CRUD, text message, sound. --- src/tuimble/client.py | 137 +++++++++++++++++++++++++++++++----------- 1 file changed, 101 insertions(+), 36 deletions(-) diff --git a/src/tuimble/client.py b/src/tuimble/client.py index 971234f..64a8725 100644 --- a/src/tuimble/client.py +++ b/src/tuimble/client.py @@ -1,14 +1,16 @@ """Mumble protocol client. -Wraps pymumble to provide an async-friendly interface for connecting -to Mumble servers, handling channels, users, and voice data. +Wraps pymumble to provide an interface for connecting to Mumble servers, +handling channels, users, and voice data. All callbacks fire from the +pymumble thread; use set_dispatcher() to marshal them into the host +event loop (e.g. Textual's call_from_thread). """ from __future__ import annotations -import asyncio import logging from dataclasses import dataclass +from typing import Callable log = logging.getLogger(__name__) @@ -33,7 +35,12 @@ class Channel: class MumbleClient: - """Async wrapper around pymumble.""" + """Blocking wrapper around pymumble. + + pymumble runs its own network thread. Callbacks arrive on that thread. + Call set_dispatcher(fn) with a function that marshals calls into the + host event loop (e.g. Textual's call_from_thread). + """ def __init__( self, @@ -48,11 +55,37 @@ class MumbleClient: self._password = password self._mumble = None self._connected = False - self._loop: asyncio.AbstractEventLoop | None = None + self._dispatcher: Callable | None = None - self.on_text_message = None # callback(sender, message) - self.on_user_state = None # callback(user) - self.on_sound_received = None # callback(user, pcm_data) + # Application callbacks (fired via dispatcher) + self.on_connected = None + self.on_disconnected = None + self.on_text_message = None # (sender_name, message) + self.on_user_update = None # () + self.on_channel_update = None # () + self.on_sound_received = None # (user, pcm_data) + + def set_dispatcher(self, fn: Callable): + """Set a function to marshal callbacks into the host event loop. + + Typically Textual's ``call_from_thread``. + """ + self._dispatcher = fn + + def _dispatch(self, callback, *args): + """Call *callback* via the dispatcher, or directly if none is set.""" + if callback is None: + return + try: + if self._dispatcher: + self._dispatcher(callback, *args) + else: + callback(*args) + except RuntimeError: + # Event loop closed during shutdown — safe to ignore + pass + + # -- properties ---------------------------------------------------------- @property def connected(self) -> bool: @@ -89,42 +122,49 @@ class MumbleClient: ) return result - async def connect(self): - """Connect to the Mumble server.""" + @property + def my_channel_id(self) -> int | None: + if not self._mumble or not self._connected: + return None + try: + return self._mumble.users.myself["channel_id"] + except (AttributeError, KeyError): + return None + + # -- connection ---------------------------------------------------------- + + def connect(self): + """Connect to the Mumble server (blocking).""" import pymumble_py3 as pymumble - self._loop = asyncio.get_running_loop() self._mumble = pymumble.Mumble( self._host, self._username, port=self._port, password=self._password, - reconnect=True, + reconnect=False, ) self._mumble.set_codec_profile("audio") - self._mumble.callbacks.set_callback( - pymumble.constants.PYMUMBLE_CLBK_TEXTMESSAGERECEIVED, - self._on_text_message, - ) - self._mumble.callbacks.set_callback( - pymumble.constants.PYMUMBLE_CLBK_SOUNDRECEIVED, - self._on_sound_received, - ) + self._register_callbacks() - # pymumble runs its own thread self._mumble.start() - self._mumble.is_ready() + self._mumble.is_ready() # blocks until handshake completes self._connected = True log.info("connected to %s:%d as %s", self._host, self._port, self._username) - async def disconnect(self): + def disconnect(self): """Disconnect from the server.""" if self._mumble: - self._mumble.stop() + try: + self._mumble.stop() + except Exception: + pass self._connected = False log.info("disconnected") - def send_text(self, message: str, channel: bool = True): + # -- actions ------------------------------------------------------------- + + def send_text(self, message: str): """Send a text message to the current channel.""" if self._mumble and self._connected: ch = self._mumble.channels[self._mumble.users.myself["channel_id"]] @@ -140,17 +180,42 @@ class MumbleClient: if self._mumble and self._connected: self._mumble.channels[channel_id].move_in() + # -- pymumble callbacks (run on pymumble thread) ------------------------- + + def _register_callbacks(self): + import pymumble_py3.constants as const + + cb = self._mumble.callbacks + cb.set_callback(const.PYMUMBLE_CLBK_CONNECTED, self._on_connected) + cb.set_callback(const.PYMUMBLE_CLBK_DISCONNECTED, self._on_disconnected) + cb.set_callback(const.PYMUMBLE_CLBK_TEXTMESSAGERECEIVED, self._on_text_message) + cb.set_callback(const.PYMUMBLE_CLBK_SOUNDRECEIVED, self._on_sound_received) + cb.set_callback(const.PYMUMBLE_CLBK_USERCREATED, self._on_user_event) + cb.set_callback(const.PYMUMBLE_CLBK_USERUPDATED, self._on_user_event) + cb.set_callback(const.PYMUMBLE_CLBK_USERREMOVED, self._on_user_event) + cb.set_callback(const.PYMUMBLE_CLBK_CHANNELCREATED, self._on_channel_event) + cb.set_callback(const.PYMUMBLE_CLBK_CHANNELUPDATED, self._on_channel_event) + cb.set_callback(const.PYMUMBLE_CLBK_CHANNELREMOVED, self._on_channel_event) + + def _on_connected(self): + self._connected = True + self._dispatch(self.on_connected) + + def _on_disconnected(self): + self._connected = False + self._dispatch(self.on_disconnected) + def _on_text_message(self, message): - if self.on_text_message and self._loop: - actor = message.actor - users = self._mumble.users - name = users[actor]["name"] if actor in users else "?" - self._loop.call_soon_threadsafe( - self.on_text_message, name, message.message - ) + users = self._mumble.users + actor = message.actor + name = users[actor]["name"] if actor in users else "?" + self._dispatch(self.on_text_message, name, message.message) def _on_sound_received(self, user, sound_chunk): - if self.on_sound_received and self._loop: - self._loop.call_soon_threadsafe( - self.on_sound_received, user, sound_chunk.pcm - ) + self._dispatch(self.on_sound_received, user, sound_chunk.pcm) + + def _on_user_event(self, *_args): + self._dispatch(self.on_user_update) + + def _on_channel_event(self, *_args): + self._dispatch(self.on_channel_update)