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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user