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.
This commit is contained in:
Username
2026-02-24 12:00:59 +01:00
parent 5df1f484a4
commit 5fecebaa12

View File

@@ -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)