Compare commits

...

18 Commits

Author SHA1 Message Date
Username be6574ae79 app: debounce channel tree and cache render width 2026-02-24 15:32:57 +01:00
Username 57f4559a38 update roadmap and tasklist 2026-02-24 14:55:03 +01:00
Username 351b980b42 docs: add reconnection and error recovery docs 2026-02-24 14:54:40 +01:00
Username 6f590ede38 app: add reconnection status to status bar 2026-02-24 14:54:07 +01:00
Username e443facd3b app: add auto-reconnect with backoff 2026-02-24 14:43:29 +01:00
Username d02bb5239a app: stop audio on disconnect 2026-02-24 14:40:24 +01:00
Username a041069cc9 client: add ConnectionFailed and reconnect method 2026-02-24 14:34:40 +01:00
Username 0b186f1f0c update roadmap and tasklist 2026-02-24 14:19:48 +01:00
Username 0b178d371e docs: add volume, certificate, and reload docs 2026-02-24 14:19:15 +01:00
Username 0bc41d1a46 app: add config reload on F5 2026-02-24 14:18:19 +01:00
Username e9726da401 app: add volume key bindings and status display 2026-02-24 14:17:02 +01:00
Username 5e44ee9e38 app: wire volume and certificate config 2026-02-24 14:15:42 +01:00
Username 6467f5fe32 client: add certificate parameters 2026-02-24 14:15:24 +01:00
Username e9944c88eb audio: add gain control to capture and playback 2026-02-24 14:14:52 +01:00
Username eb98165370 config: add gain and certificate fields 2026-02-24 14:13:18 +01:00
Username 0856ab3c55 docs: update tasklist with completed items 2026-02-24 13:58:02 +01:00
Username b852459eff feat: add channel navigation and user status indicators
Tab cycles focus to sidebar where Up/Down navigates channels
and Enter joins the selected one. Muted/deafened users show
status symbols in the channel tree.
2026-02-24 13:57:57 +01:00
Username ec81fac507 docs: add key bindings and fix ptt key in readme 2026-02-24 13:31:56 +01:00
13 changed files with 938 additions and 60 deletions
+29 -1
View File
@@ -8,6 +8,28 @@ TUI Mumble client with voice support and push-to-talk.
- Voice transmission with Opus codec - Voice transmission with Opus codec
- Push-to-talk via Kitty keyboard protocol, evdev, or toggle - Push-to-talk via Kitty keyboard protocol, evdev, or toggle
- Channel browsing and text chat - Channel browsing and text chat
- Chat input history (Up/Down arrow navigation)
- Self-deafen toggle
- Volume control (input/output gain)
- Client certificate authentication
- Auto-reconnect on network loss
- Config hot-reload (F5)
## Key Bindings
| Key | Action |
|-----|--------|
| `Tab` | Cycle focus (chat input / channel tree) |
| `F1` | Toggle self-deafen |
| `F2` | Cycle output volume |
| `F3` | Cycle input volume |
| `F4` | Push-to-talk (configurable) |
| `F5` | Reload config from disk |
| `Enter` | Send message / join channel (sidebar) |
| `Up` | Previous message (input) / previous channel (sidebar) |
| `Down` | Next message (input) / next channel (sidebar) |
| `q` | Quit |
| `Ctrl+C` | Quit |
## Quick Start ## Quick Start
@@ -29,9 +51,15 @@ mkdir -p ~/.config/tuimble
host = "mumble.example.com" host = "mumble.example.com"
port = 64738 port = 64738
username = "myname" username = "myname"
# certfile = "/path/to/cert.pem"
# keyfile = "/path/to/key.pem"
[audio]
# output_gain = 1.0
# input_gain = 1.0
[ptt] [ptt]
key = "space" key = "f4"
mode = "hold" mode = "hold"
backend = "auto" backend = "auto"
``` ```
+7 -7
View File
@@ -18,15 +18,15 @@
## Phase 3 — Polish ## Phase 3 — Polish
- [x] Responsive terminal layout (adaptive sidebar, truncation, resize) - [x] Responsive terminal layout (adaptive sidebar, truncation, resize)
- [ ] Channel tree navigation - [x] Channel tree navigation
- [ ] User list with status indicators - [x] User list with status indicators
- [ ] Volume control - [x] Volume control
- [ ] Server certificate handling - [x] Server certificate handling
- [ ] Config file hot-reload - [x] Config file hot-reload
## Phase 4 — Robustness ## Phase 4 — Robustness
- [ ] Reconnection handling - [x] Reconnection handling
- [ ] Error recovery - [x] Error recovery
- [ ] Audio device hot-swap - [ ] Audio device hot-swap
- [ ] Comprehensive test suite - [ ] Comprehensive test suite
+8 -2
View File
@@ -7,8 +7,14 @@
- [x] Channel tree population from server data - [x] Channel tree population from server data
- [x] Audio pipeline integration with MumbleClient - [x] Audio pipeline integration with MumbleClient
- [x] PTT wiring (key events -> audio.capturing toggle) - [x] PTT wiring (key events -> audio.capturing toggle)
- [x] Chat input history navigation (up/down arrows)
- [x] Channel navigation and join from sidebar
- [x] User status indicators (mute/deaf)
- [x] Volume control (F2/F3, gain 0.0-2.0, status bar display)
- [x] Server certificate handling (certfile/keyfile config)
- [x] Config file hot-reload (F5, safe/restart change detection)
- [x] Reconnection handling with auto-retry and backoff
## Pending ## Pending
- [ ] Channel join/navigation (select channel in tree, move into it) - [ ] Audio device hot-swap
- [ ] Reconnection handling on disconnect
+8 -2
View File
@@ -6,7 +6,13 @@ make run launch tuimble
make test run tests make test run tests
make lint check code style make lint check code style
F1 toggle self-deafen
F2 cycle output volume
F3 cycle input volume
F4 push-to-talk
F5 reload config
Tab cycle focus
Enter send message / join channel
Up/Down history / channel navigation
q quit q quit
space push-to-talk (hold)
Enter send message
``` ```
+38
View File
@@ -38,8 +38,46 @@ groups # should include 'input'
Add user to `input` group: `sudo usermod -aG input $USER` Add user to `input` group: `sudo usermod -aG input $USER`
### Certificate errors
If the server requires client certificates:
```sh
# Verify cert/key pair
openssl x509 -in cert.pem -noout -subject -dates
openssl rsa -in key.pem -check -noout
```
- Both `certfile` and `keyfile` must be PEM-encoded
- Paths must be absolute or relative to the working directory
- Key must not be passphrase-protected (pymumble limitation)
- If the server uses a self-signed CA, pymumble verifies by default
### Connection refused ### Connection refused
- Verify server address and port - Verify server address and port
- Check firewall allows outbound TCP/UDP to port 64738 - Check firewall allows outbound TCP/UDP to port 64738
- Test with: `nc -zv <host> 64738` - Test with: `nc -zv <host> 64738`
### Connection drops / reconnection
When tuimble loses connection, it retries automatically with backoff.
The chatlog shows each attempt:
```
✗ disconnected from server
reconnecting in 2s (attempt 1/10)...
✗ attempt 1: network error: [Errno 111] Connection refused
reconnecting in 4s (attempt 2/10)...
✓ connected as myname
```
Common causes:
- **Server restart** — normal, reconnect succeeds when server is back
- **Network loss** — check connectivity, tuimble retries automatically
- **Auth timeout** — server may drop idle connections; reconnect handles this
- **"rejected" with no retry** — wrong password or certificate issue
If reconnection fails after 10 attempts, press `F5` to retry manually.
Update `config.toml` if server details changed.
+57 -3
View File
@@ -11,11 +11,15 @@ tuimble --host mumble.example.com --user myname
| Key | Action | | Key | Action |
|-----|--------| |-----|--------|
| `Tab` | Cycle focus (chat input / channel tree) |
| `F1` | Toggle self-deafen | | `F1` | Toggle self-deafen |
| `F2` | Cycle output volume |
| `F3` | Cycle input volume |
| `F4` | Push-to-talk (configurable) | | `F4` | Push-to-talk (configurable) |
| `Enter` | Send message | | `F5` | Reload config from disk |
| `Up` | Previous sent message (in chat input) | | `Enter` | Send message / join channel (sidebar) |
| `Down` | Next sent message (in chat input) | | `Up` | Previous message (input) / previous channel (sidebar) |
| `Down` | Next message (input) / next channel (sidebar) |
| `q` | Quit | | `q` | Quit |
| `Ctrl+C` | Quit | | `Ctrl+C` | Quit |
@@ -44,6 +48,56 @@ python3 -m pstats /tmp/tuimble.prof # interactive explorer
Note: cProfile captures the main thread only. Background workers Note: cProfile captures the main thread only. Background workers
started with `@work(thread=True)` are not included. started with `@work(thread=True)` are not included.
## Volume Control
`F2` and `F3` cycle through volume steps: 0%, 25%, 50%, 75%, 100%,
125%, 150%, 200%, then back to 0%. Volume is shown in the status bar
as block indicators.
```toml
[audio]
output_gain = 0.75 # 0.0 to 2.0 (default 1.0)
input_gain = 1.5 # 0.0 to 2.0 (default 1.0)
```
## Client Certificates
For servers requiring client certificate authentication:
```toml
[server]
certfile = "/path/to/client-cert.pem"
keyfile = "/path/to/client-key.pem"
```
Both must be PEM-encoded. Leave empty to connect without a certificate.
## Config Reload
`F5` reloads `~/.config/tuimble/config.toml` from disk.
**Safe changes** (applied immediately): PTT key/mode/backend, audio
gain values.
**Restart-requiring changes** (server settings, audio device/rate):
shown as warnings. Press `F5` again to confirm reconnect, or any
other key to cancel.
## Reconnection
On unexpected disconnect (server restart, network loss), tuimble
automatically retries with exponential backoff:
- Initial delay: 2 seconds, doubling each attempt (2, 4, 8, 16, 30...)
- Maximum delay: 30 seconds
- Maximum attempts: 10
The status bar shows `◐ reconnecting` during retry. If the server
rejects authentication, retries stop immediately.
After all attempts are exhausted, press `F5` to retry manually.
`F5` also cancels an in-progress reconnect and starts a fresh attempt.
## Configuration ## Configuration
See `~/.config/tuimble/config.toml`. All fields are optional; See `~/.config/tuimble/config.toml`. All fields are optional;
+517 -26
View File
@@ -14,12 +14,27 @@ from textual.reactive import reactive
from textual.widgets import Footer, Header, Input, RichLog, Static from textual.widgets import Footer, Header, Input, RichLog, Static
from tuimble.audio import AudioPipeline from tuimble.audio import AudioPipeline
from tuimble.client import Channel, MumbleClient from tuimble.client import Channel, ConnectionFailed, MumbleClient, User
from tuimble.config import Config, load_config from tuimble.config import Config, load_config
from tuimble.ptt import KittyPtt, TogglePtt, detect_backend from tuimble.ptt import KittyPtt, TogglePtt, detect_backend
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
VOLUME_STEPS = (0.0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0)
RECONNECT_INITIAL = 2 # seconds before first retry
RECONNECT_MAX = 30 # maximum backoff delay
RECONNECT_RETRIES = 10 # attempts before giving up
TREE_DEBOUNCE = 0.1 # seconds to coalesce state changes
def _next_volume(current: float) -> float:
"""Cycle through VOLUME_STEPS, wrapping to 0.0 after max."""
for step in VOLUME_STEPS:
if step > current + 0.01:
return step
return VOLUME_STEPS[0]
# -- custom messages (pymumble thread -> Textual) ---------------------------- # -- custom messages (pymumble thread -> Textual) ----------------------------
@@ -45,6 +60,14 @@ class ServerStateChanged(Message):
pass pass
class ChannelSelected(Message):
"""User selected a channel to join."""
def __init__(self, channel_id: int) -> None:
super().__init__()
self.channel_id = channel_id
# -- widgets ----------------------------------------------------------------- # -- widgets -----------------------------------------------------------------
@@ -53,8 +76,17 @@ class StatusBar(Static):
ptt_active = reactive(False) ptt_active = reactive(False)
connected = reactive(False) connected = reactive(False)
reconnecting = reactive(False)
self_deaf = reactive(False) self_deaf = reactive(False)
server_info = reactive("") server_info = reactive("")
output_vol = reactive(100)
input_vol = reactive(100)
@staticmethod
def _vol_bar(pct: int) -> str:
"""Compact 4-char volume indicator using block chars."""
filled = round(pct / 25)
return "\u2588" * filled + "\u2591" * (4 - filled)
def render(self) -> str: def render(self) -> str:
w = self.content_size.width if self.content_size.width > 0 else 80 w = self.content_size.width if self.content_size.width > 0 else 80
@@ -62,6 +94,9 @@ class StatusBar(Static):
if self.connected: if self.connected:
conn_sym = "[#9ece6a]\u25cf[/]" conn_sym = "[#9ece6a]\u25cf[/]"
conn_full = f"{conn_sym} connected" conn_full = f"{conn_sym} connected"
elif self.reconnecting:
conn_sym = "[#e0af68]\u25d0[/]"
conn_full = f"{conn_sym} reconnecting"
else: else:
conn_sym = "[#f7768e]\u25cb[/]" conn_sym = "[#f7768e]\u25cb[/]"
conn_full = f"{conn_sym} disconnected" conn_full = f"{conn_sym} disconnected"
@@ -80,36 +115,79 @@ class StatusBar(Static):
return f" {conn_sym} {deaf_sym}{ptt_sym}" return f" {conn_sym} {deaf_sym}{ptt_sym}"
if w < 60: if w < 60:
return f" {conn_full} {deaf_full}{' ' if deaf_full else ''}{ptt_full}" return f" {conn_full} {deaf_full}{' ' if deaf_full else ''}{ptt_full}"
vol = (
f" [dim]out[/]{self._vol_bar(self.output_vol)}"
f" [dim]in[/]{self._vol_bar(self.input_vol)}"
)
info = f" [dim]{self.server_info}[/]" if self.server_info else "" info = f" [dim]{self.server_info}[/]" if self.server_info else ""
return f" {conn_full} {deaf_full}{' ' if deaf_full else ''}{ptt_full}{info}" deaf = f"{deaf_full} " if deaf_full else ""
return f" {conn_full} {deaf}{ptt_full}{vol}{info}"
class ChannelTree(Static): class ChannelTree(Static):
"""Channel and user list.""" """Channel and user list with keyboard navigation."""
DEFAULT_WIDTH = 24 DEFAULT_WIDTH = 24
can_focus = True
def __init__(self, **kwargs) -> None: def __init__(self, **kwargs) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self._channels: dict[int, Channel] = {} self._channels: dict[int, Channel] = {}
self._users_by_channel: dict[int, list[str]] = {} self._users_by_channel: dict[int, list[User]] = {}
self._channel_ids: list[int] = []
self._focused_idx: int = 0
self._my_channel_id: int | None = None
def set_state( def set_state(
self, self,
channels: dict[int, Channel], channels: dict[int, Channel],
users_by_channel: dict[int, list[str]], users_by_channel: dict[int, list[User]],
my_channel_id: int | None = None,
) -> None: ) -> None:
self._channels = channels self._channels = channels
self._users_by_channel = users_by_channel self._users_by_channel = users_by_channel
self._my_channel_id = my_channel_id
self._channel_ids = self._build_channel_order()
if self._channel_ids:
self._focused_idx = max(
0, min(self._focused_idx, len(self._channel_ids) - 1)
)
else:
self._focused_idx = 0
self.refresh() self.refresh()
def clear_state(self) -> None: def clear_state(self) -> None:
self._channels = {} self._channels = {}
self._users_by_channel = {} self._users_by_channel = {}
self._channel_ids = []
self._focused_idx = 0
self._my_channel_id = None
self.refresh() self.refresh()
@property def _build_channel_order(self) -> list[int]:
def _available_width(self) -> int: """Build flat list of channel IDs in tree order."""
if not self._channels:
return []
order: list[int] = []
root_id = self._find_root()
self._collect_order(root_id, order)
return order
def _collect_order(self, channel_id: int, order: list[int]) -> None:
ch = self._channels.get(channel_id)
if ch is None:
return
order.append(channel_id)
children = sorted(
(c for c in self._channels.values()
if c.parent_id == channel_id and c.channel_id != channel_id),
key=lambda c: c.name,
)
for child in children:
self._collect_order(child.channel_id, order)
def _get_width(self) -> int:
"""Usable character width (content area, excludes padding/border).""" """Usable character width (content area, excludes padding/border)."""
w = self.content_size.width w = self.content_size.width
if w <= 0: if w <= 0:
@@ -127,13 +205,23 @@ class ChannelTree(Static):
return text[:max_width] return text[:max_width]
return text[: max_width - 3] + "..." return text[: max_width - 3] + "..."
@staticmethod
def _user_status(user: User) -> str:
"""Return status indicator for a user."""
if user.self_deaf or user.deaf:
return " [#f7768e]\u2298[/]"
if user.self_mute or user.mute:
return " [#e0af68]\u2715[/]"
return ""
def render(self) -> str: def render(self) -> str:
if not self._channels: if not self._channels:
return " Channels\n [dim]\u2514\u2500 (not connected)[/]" return " Channels\n [dim]\u2514\u2500 (not connected)[/]"
w = self._get_width()
lines = [" [bold]Channels[/]"] lines = [" [bold]Channels[/]"]
root_id = self._find_root() root_id = self._find_root()
self._render_tree(root_id, lines, indent=1, is_last=True) self._render_tree(root_id, lines, indent=1, is_last=True, w=w)
return "\n".join(lines) return "\n".join(lines)
def _find_root(self) -> int: def _find_root(self) -> int:
@@ -148,18 +236,37 @@ class ChannelTree(Static):
lines: list[str], lines: list[str],
indent: int, indent: int,
is_last: bool, is_last: bool,
w: int,
) -> None: ) -> None:
ch = self._channels.get(channel_id) ch = self._channels.get(channel_id)
if ch is None: if ch is None:
return return
w = self._available_width
prefix = " " * indent prefix = " " * indent
branch = "\u2514\u2500" if is_last else "\u251c\u2500" branch = "\u2514\u2500" if is_last else "\u251c\u2500"
# indent + 2 branch chars + 1 space
name_max = w - indent - 3 is_current = channel_id == self._my_channel_id
is_focused = (
self.has_focus
and self._channel_ids
and self._focused_idx < len(self._channel_ids)
and self._channel_ids[self._focused_idx] == channel_id
)
marker = "\u25cf " if is_current else " "
# indent + 2 branch + 1 space + 2 marker
name_max = w - indent - 5
name = self._truncate(ch.name, name_max) name = self._truncate(ch.name, name_max)
lines.append(f"{prefix}{branch} [bold]{name}[/]")
if is_focused:
lines.append(
f"{prefix}{branch} {marker}[reverse bold]{name}[/]"
)
elif is_current:
lines.append(
f"{prefix}{branch} {marker}[bold #9ece6a]{name}[/]"
)
else:
lines.append(f"{prefix}{branch} {marker}[bold]{name}[/]")
users = self._users_by_channel.get(channel_id, []) users = self._users_by_channel.get(channel_id, [])
children = [ children = [
@@ -171,12 +278,15 @@ class ChannelTree(Static):
sub_indent = indent + 2 sub_indent = indent + 2
sub_prefix = " " * sub_indent sub_prefix = " " * sub_indent
# sub_indent + 2 bullet chars + 1 space # sub_indent + 2 bullet chars + 1 space
user_max = w - sub_indent - 3 user_max = w - sub_indent - 5
for i, user_name in enumerate(users): for i, user in enumerate(users):
is_last_item = i == len(users) - 1 and not children is_last_item = i == len(users) - 1 and not children
bullet = "\u2514\u2500" if is_last_item else "\u251c\u2500" bullet = "\u2514\u2500" if is_last_item else "\u251c\u2500"
uname = self._truncate(user_name, user_max) uname = self._truncate(user.name, user_max)
lines.append(f"{sub_prefix}{bullet} [#7aa2f7]{uname}[/]") status = self._user_status(user)
lines.append(
f"{sub_prefix}{bullet} [#7aa2f7]{uname}[/]{status}"
)
for i, child in enumerate(children): for i, child in enumerate(children):
self._render_tree( self._render_tree(
@@ -184,8 +294,30 @@ class ChannelTree(Static):
lines, lines,
indent + 2, indent + 2,
is_last=i == len(children) - 1, is_last=i == len(children) - 1,
w=w,
) )
def on_key(self, event: events.Key) -> None:
if not self._channel_ids:
return
if event.key == "up":
self._focused_idx = max(0, self._focused_idx - 1)
self.refresh()
event.prevent_default()
event.stop()
elif event.key == "down":
self._focused_idx = min(
len(self._channel_ids) - 1, self._focused_idx + 1
)
self.refresh()
event.prevent_default()
event.stop()
elif event.key == "enter":
cid = self._channel_ids[self._focused_idx]
self.post_message(ChannelSelected(cid))
event.prevent_default()
event.stop()
class ChatLog(RichLog): class ChatLog(RichLog):
"""Message log.""" """Message log."""
@@ -215,6 +347,9 @@ class TuimbleApp(App):
padding: 0 1; padding: 0 1;
overflow-x: hidden; overflow-x: hidden;
} }
#sidebar:focus {
border-right: solid #7aa2f7;
}
#chat-area { #chat-area {
width: 1fr; width: 1fr;
} }
@@ -243,6 +378,9 @@ class TuimbleApp(App):
BINDINGS = [ BINDINGS = [
("f1", "toggle_deaf", "Deafen"), ("f1", "toggle_deaf", "Deafen"),
("f2", "cycle_output_volume", "Vol Out"),
("f3", "cycle_input_volume", "Vol In"),
("f5", "reload_config", "Reload"),
("q", "quit", "Quit"), ("q", "quit", "Quit"),
("ctrl+c", "quit", "Quit"), ("ctrl+c", "quit", "Quit"),
] ]
@@ -259,6 +397,8 @@ class TuimbleApp(App):
port=srv.port, port=srv.port,
username=srv.username, username=srv.username,
password=srv.password, password=srv.password,
certfile=srv.certfile,
keyfile=srv.keyfile,
) )
self._history: list[str] = [] self._history: list[str] = []
self._history_idx: int = -1 self._history_idx: int = -1
@@ -270,6 +410,13 @@ class TuimbleApp(App):
input_device=acfg.input_device, input_device=acfg.input_device,
output_device=acfg.output_device, output_device=acfg.output_device,
) )
self._audio.input_gain = acfg.input_gain
self._audio.output_gain = acfg.output_gain
self._pending_reload: Config | None = None
self._tree_refresh_timer = None
self._reconnecting: bool = False
self._reconnect_attempt: int = 0
self._intentional_disconnect: bool = False
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header() yield Header()
@@ -282,6 +429,10 @@ class TuimbleApp(App):
yield Footer() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:
status = self.query_one("#status", StatusBar)
status.output_vol = int(self._audio.output_gain * 100)
status.input_vol = int(self._audio.input_gain * 100)
chatlog = self.query_one("#chatlog", ChatLog) chatlog = self.query_one("#chatlog", ChatLog)
chatlog.write("[dim]tuimble v0.1.0[/dim]") chatlog.write("[dim]tuimble v0.1.0[/dim]")
srv = self._config.server srv = self._config.server
@@ -290,8 +441,8 @@ class TuimbleApp(App):
# -- server connection (worker thread) ----------------------------------- # -- server connection (worker thread) -----------------------------------
@work(thread=True) def _wire_client_callbacks(self) -> None:
def _connect_to_server(self) -> None: """Wire pymumble callbacks to message dispatchers."""
self._client.set_dispatcher(self.call_from_thread) self._client.set_dispatcher(self.call_from_thread)
self._client.on_connected = self._cb_connected self._client.on_connected = self._cb_connected
self._client.on_disconnected = self._cb_disconnected self._client.on_disconnected = self._cb_disconnected
@@ -300,6 +451,9 @@ class TuimbleApp(App):
self._client.on_channel_update = self._cb_state_changed self._client.on_channel_update = self._cb_state_changed
self._client.on_sound_received = self._cb_sound_received self._client.on_sound_received = self._cb_sound_received
@work(thread=True)
def _connect_to_server(self) -> None:
self._wire_client_callbacks()
try: try:
self._client.connect() self._client.connect()
except Exception as exc: except Exception as exc:
@@ -324,10 +478,112 @@ class TuimbleApp(App):
chatlog = self.query_one("#chatlog", ChatLog) chatlog = self.query_one("#chatlog", ChatLog)
chatlog.write(f"[#f7768e]\u2717 {text}[/]") chatlog.write(f"[#f7768e]\u2717 {text}[/]")
# -- auto-reconnect ------------------------------------------------------
@work(thread=True)
def _reconnect_loop(self) -> None:
"""Retry connection with exponential backoff."""
while self._reconnecting:
self._reconnect_attempt += 1
delay = min(
RECONNECT_INITIAL * (2 ** (self._reconnect_attempt - 1)),
RECONNECT_MAX,
)
self.call_from_thread(
self._log_reconnect, self._reconnect_attempt, delay,
)
elapsed = 0.0
while elapsed < delay and self._reconnecting:
time.sleep(0.5)
elapsed += 0.5
if not self._reconnecting:
break
try:
self._client.disconnect()
srv = self._config.server
self._client = MumbleClient(
host=srv.host,
port=srv.port,
username=srv.username,
password=srv.password,
certfile=srv.certfile,
keyfile=srv.keyfile,
)
self._wire_client_callbacks()
self._client.connect()
self._reconnecting = False
self._reconnect_attempt = 0
self.call_from_thread(self._on_reconnect_success)
return
except ConnectionFailed as exc:
if not exc.retryable:
self.call_from_thread(
self._show_error, f"rejected: {exc}",
)
self._reconnecting = False
self.call_from_thread(self._on_reconnect_exhausted)
return
self.call_from_thread(
self._show_error,
f"attempt {self._reconnect_attempt}: {exc}",
)
except Exception as exc:
self.call_from_thread(
self._show_error,
f"attempt {self._reconnect_attempt}: {exc}",
)
if self._reconnect_attempt >= RECONNECT_RETRIES:
self._reconnecting = False
self.call_from_thread(self._on_reconnect_exhausted)
return
def _log_reconnect(self, attempt: int, delay: int) -> None:
"""Log reconnection attempt to chatlog."""
status = self.query_one("#status", StatusBar)
status.reconnecting = True
chatlog = self.query_one("#chatlog", ChatLog)
chatlog.write(
f"[dim]reconnecting in {delay}s "
f"(attempt {attempt}/{RECONNECT_RETRIES})...[/dim]"
)
def _on_reconnect_success(self) -> None:
"""Handle successful reconnection."""
self.post_message(ServerConnected())
def _on_reconnect_exhausted(self) -> None:
"""Handle all reconnection attempts exhausted."""
status = self.query_one("#status", StatusBar)
status.reconnecting = False
chatlog = self.query_one("#chatlog", ChatLog)
chatlog.write(
f"[#f7768e]reconnection failed after "
f"{RECONNECT_RETRIES} attempts[/]"
)
chatlog.write("[dim]press F5 to retry manually[/dim]")
def _cancel_reconnect(self) -> None:
"""Cancel an in-progress reconnect loop."""
self._reconnecting = False
self._reconnect_attempt = 0
try:
status = self.query_one("#status", StatusBar)
status.reconnecting = False
except Exception:
pass
# -- message handlers ---------------------------------------------------- # -- message handlers ----------------------------------------------------
def on_server_connected(self, _msg: ServerConnected) -> None: def on_server_connected(self, _msg: ServerConnected) -> None:
self._intentional_disconnect = False
status = self.query_one("#status", StatusBar) status = self.query_one("#status", StatusBar)
status.reconnecting = False
status.connected = True status.connected = True
srv = self._config.server srv = self._config.server
status.server_info = f"{srv.host}:{srv.port}" status.server_info = f"{srv.host}:{srv.port}"
@@ -340,15 +596,24 @@ class TuimbleApp(App):
self._start_audio() self._start_audio()
def on_server_disconnected(self, _msg: ServerDisconnected) -> None: def on_server_disconnected(self, _msg: ServerDisconnected) -> None:
self._audio.stop()
status = self.query_one("#status", StatusBar) status = self.query_one("#status", StatusBar)
status.connected = False status.connected = False
status.server_info = "" status.server_info = ""
tree = self.query_one("#sidebar", ChannelTree)
tree.clear_state()
if self._intentional_disconnect or self._reconnecting:
return
chatlog = self.query_one("#chatlog", ChatLog) chatlog = self.query_one("#chatlog", ChatLog)
chatlog.write("[#f7768e]\u2717 disconnected from server[/]") chatlog.write("[#f7768e]\u2717 disconnected from server[/]")
tree = self.query_one("#sidebar", ChannelTree) self._reconnecting = True
tree.clear_state() self._reconnect_attempt = 0
self._reconnect_loop()
def on_text_message_received(self, msg: TextMessageReceived) -> None: def on_text_message_received(self, msg: TextMessageReceived) -> None:
chatlog = self.query_one("#chatlog", ChatLog) chatlog = self.query_one("#chatlog", ChatLog)
@@ -357,7 +622,24 @@ class TuimbleApp(App):
chatlog.write(f"[#7aa2f7]{msg.sender}[/] {clean}") chatlog.write(f"[#7aa2f7]{msg.sender}[/] {clean}")
def on_server_state_changed(self, _msg: ServerStateChanged) -> None: def on_server_state_changed(self, _msg: ServerStateChanged) -> None:
self._refresh_channel_tree() if self._tree_refresh_timer is not None:
self._tree_refresh_timer.stop()
self._tree_refresh_timer = self.set_timer(
TREE_DEBOUNCE, self._refresh_channel_tree,
)
def on_channel_selected(self, msg: ChannelSelected) -> None:
if not self._client.connected:
return
ch = self._client.channels.get(msg.channel_id)
name = ch.name if ch else str(msg.channel_id)
try:
self._client.join_channel(msg.channel_id)
except Exception as exc:
self._show_error(f"join failed: {exc}")
return
chatlog = self.query_one("#chatlog", ChatLog)
chatlog.write(f"[dim]\u2192 joined {name}[/dim]")
@on(Input.Submitted, "#input") @on(Input.Submitted, "#input")
def on_input_submitted(self, event: Input.Submitted) -> None: def on_input_submitted(self, event: Input.Submitted) -> None:
@@ -408,13 +690,13 @@ class TuimbleApp(App):
return return
channels = self._client.channels channels = self._client.channels
users = self._client.users users = self._client.users
users_by_channel: dict[int, list[str]] = {} users_by_channel: dict[int, list[User]] = {}
for u in users.values(): for u in users.values():
users_by_channel.setdefault(u.channel_id, []).append(u.name) users_by_channel.setdefault(u.channel_id, []).append(u)
for lst in users_by_channel.values(): for lst in users_by_channel.values():
lst.sort() lst.sort(key=lambda u: u.name)
tree = self.query_one("#sidebar", ChannelTree) tree = self.query_one("#sidebar", ChannelTree)
tree.set_state(channels, users_by_channel) tree.set_state(channels, users_by_channel, self._client.my_channel_id)
# -- deafen -------------------------------------------------------------- # -- deafen --------------------------------------------------------------
@@ -431,10 +713,217 @@ class TuimbleApp(App):
else: else:
chatlog.write("[#9ece6a]\u2713 undeafened[/]") chatlog.write("[#9ece6a]\u2713 undeafened[/]")
# -- volume ---------------------------------------------------------------
def action_cycle_output_volume(self) -> None:
"""Cycle output volume through preset steps."""
vol = _next_volume(self._audio.output_gain)
self._audio.output_gain = vol
pct = int(vol * 100)
status = self.query_one("#status", StatusBar)
status.output_vol = pct
chatlog = self.query_one("#chatlog", ChatLog)
chatlog.write(f"[dim]output volume {pct}%[/dim]")
def action_cycle_input_volume(self) -> None:
"""Cycle input volume through preset steps."""
vol = _next_volume(self._audio.input_gain)
self._audio.input_gain = vol
pct = int(vol * 100)
status = self.query_one("#status", StatusBar)
status.input_vol = pct
chatlog = self.query_one("#chatlog", ChatLog)
chatlog.write(f"[dim]input volume {pct}%[/dim]")
# -- config reload --------------------------------------------------------
def _detect_config_changes(
self, old: Config, new: Config,
) -> tuple[list[str], list[str]]:
"""Compare configs, return (safe_changes, restart_changes)."""
safe: list[str] = []
restart: list[str] = []
if old.ptt.key != new.ptt.key:
safe.append(f"ptt key: {old.ptt.key} -> {new.ptt.key}")
if old.ptt.mode != new.ptt.mode:
safe.append(f"ptt mode: {old.ptt.mode} -> {new.ptt.mode}")
if old.ptt.backend != new.ptt.backend:
safe.append(
f"ptt backend: {old.ptt.backend} -> {new.ptt.backend}"
)
if old.audio.input_gain != new.audio.input_gain:
safe.append(
f"input gain: {old.audio.input_gain} -> "
f"{new.audio.input_gain}"
)
if old.audio.output_gain != new.audio.output_gain:
safe.append(
f"output gain: {old.audio.output_gain} -> "
f"{new.audio.output_gain}"
)
o_srv, n_srv = old.server, new.server
for attr in ("host", "port", "username", "password",
"certfile", "keyfile"):
ov, nv = getattr(o_srv, attr), getattr(n_srv, attr)
if ov != nv:
label = "password" if attr == "password" else attr
restart.append(f"server.{label} changed")
o_aud, n_aud = old.audio, new.audio
for attr in ("input_device", "output_device", "sample_rate"):
ov, nv = getattr(o_aud, attr), getattr(n_aud, attr)
if ov != nv:
restart.append(f"audio.{attr} changed")
return safe, restart
def _apply_safe_changes(self, new: Config) -> None:
"""Apply hot-reload-safe config changes immediately."""
self._config.ptt = new.ptt
self._ptt = detect_backend(
self._on_ptt_change, new.ptt.backend
)
self._audio.input_gain = new.audio.input_gain
self._audio.output_gain = new.audio.output_gain
status = self.query_one("#status", StatusBar)
status.input_vol = int(new.audio.input_gain * 100)
status.output_vol = int(new.audio.output_gain * 100)
def _apply_restart_changes(self, new: Config) -> None:
"""Apply changes that require reconnect/audio restart."""
chatlog = self.query_one("#chatlog", ChatLog)
old = self._config
server_changed = (
old.server.host != new.server.host
or old.server.port != new.server.port
or old.server.username != new.server.username
or old.server.password != new.server.password
or old.server.certfile != new.server.certfile
or old.server.keyfile != new.server.keyfile
)
audio_hw_changed = (
old.audio.input_device != new.audio.input_device
or old.audio.output_device != new.audio.output_device
or old.audio.sample_rate != new.audio.sample_rate
)
if server_changed:
self._intentional_disconnect = True
self._cancel_reconnect()
self._audio.stop()
self._client.set_dispatcher(None)
self._client.disconnect()
tree = self.query_one("#sidebar", ChannelTree)
tree.clear_state()
srv = new.server
self._client = MumbleClient(
host=srv.host,
port=srv.port,
username=srv.username,
password=srv.password,
certfile=srv.certfile,
keyfile=srv.keyfile,
)
self._config = new
chatlog.write(
f"[dim]reconnecting to {srv.host}:{srv.port}...[/]"
)
self._connect_to_server()
elif audio_hw_changed:
self._audio.stop()
acfg = new.audio
self._audio = AudioPipeline(
sample_rate=acfg.sample_rate,
frame_size=acfg.frame_size,
input_device=acfg.input_device,
output_device=acfg.output_device,
)
self._audio.input_gain = acfg.input_gain
self._audio.output_gain = acfg.output_gain
self._config = new
if self._client.connected:
self._start_audio()
chatlog.write("[dim]audio pipeline restarted[/dim]")
def action_reload_config(self) -> None:
"""Reload config from disk (F5).
Also serves as manual reconnect when disconnected.
"""
if self._reconnecting:
self._cancel_reconnect()
if not self._client.connected:
chatlog = self.query_one("#chatlog", ChatLog)
srv = self._config.server
self._client = MumbleClient(
host=srv.host,
port=srv.port,
username=srv.username,
password=srv.password,
certfile=srv.certfile,
keyfile=srv.keyfile,
)
chatlog.write(
f"[dim]connecting to {srv.host}:{srv.port}...[/dim]"
)
self._intentional_disconnect = False
self._connect_to_server()
return
chatlog = self.query_one("#chatlog", ChatLog)
if self._pending_reload is not None:
new = self._pending_reload
self._pending_reload = None
self._apply_restart_changes(new)
return
try:
new = load_config()
except Exception as exc:
self._show_error(f"config reload: {exc}")
return
safe, restart = self._detect_config_changes(self._config, new)
if not safe and not restart:
chatlog.write("[dim]config unchanged[/dim]")
return
self._apply_safe_changes(new)
self._config.audio.input_gain = new.audio.input_gain
self._config.audio.output_gain = new.audio.output_gain
if safe:
for change in safe:
chatlog.write(f"[dim]\u2713 {change}[/dim]")
if restart:
for change in restart:
chatlog.write(f"[#e0af68]\u26a0 {change}[/]")
chatlog.write(
"[dim]press F5 again to apply, "
"or any key to cancel[/dim]"
)
self._pending_reload = new
else:
chatlog.write("[dim]\u2713 config reloaded[/dim]")
# -- PTT ----------------------------------------------------------------- # -- PTT -----------------------------------------------------------------
def on_key(self, event: events.Key) -> None: def on_key(self, event: events.Key) -> None:
"""Handle input history navigation and PTT key events.""" """Handle input history navigation and PTT key events."""
if self._pending_reload is not None and event.key != "f5":
self._pending_reload = None
chatlog = self.query_one("#chatlog", ChatLog)
chatlog.write("[dim]reload cancelled[/dim]")
focused = self.focused focused = self.focused
if isinstance(focused, Input) and event.key in ("up", "down"): if isinstance(focused, Input) and event.key in ("up", "down"):
inp = focused inp = focused
@@ -494,6 +983,8 @@ class TuimbleApp(App):
# -- lifecycle ----------------------------------------------------------- # -- lifecycle -----------------------------------------------------------
def action_quit(self) -> None: def action_quit(self) -> None:
self._intentional_disconnect = True
self._cancel_reconnect()
self._audio.stop() self._audio.stop()
self._client.set_dispatcher(None) self._client.set_dispatcher(None)
self._client.disconnect() self._client.disconnect()
+36 -1
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import logging import logging
import queue import queue
import struct
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -18,6 +19,17 @@ FRAME_SIZE = 960 # 20ms at 48kHz
DTYPE = "int16" DTYPE = "int16"
def _apply_gain(pcm: bytes, gain: float) -> bytes:
"""Scale int16 PCM samples by gain factor with clipping."""
n = len(pcm) // 2
if n == 0:
return pcm
fmt = f"<{n}h"
samples = struct.unpack(fmt, pcm[: n * 2])
scaled = [max(-32768, min(32767, int(s * gain))) for s in samples]
return struct.pack(fmt, *scaled)
class AudioPipeline: class AudioPipeline:
"""Manages audio input/output streams and Opus codec.""" """Manages audio input/output streams and Opus codec."""
@@ -40,6 +52,8 @@ class AudioPipeline:
self._output_stream = None self._output_stream = None
self._capturing = False self._capturing = False
self._deafened = False self._deafened = False
self._input_gain = 1.0
self._output_gain = 1.0
def start(self): def start(self):
"""Open audio streams.""" """Open audio streams."""
@@ -93,13 +107,32 @@ class AudioPipeline:
def deafened(self, value: bool): def deafened(self, value: bool):
self._deafened = value self._deafened = value
@property
def input_gain(self) -> float:
return self._input_gain
@input_gain.setter
def input_gain(self, value: float):
self._input_gain = max(0.0, min(2.0, value))
@property
def output_gain(self) -> float:
return self._output_gain
@output_gain.setter
def output_gain(self, value: float):
self._output_gain = max(0.0, min(2.0, value))
def _capture_callback(self, indata, frames, time_info, status): def _capture_callback(self, indata, frames, time_info, status):
"""Called by sounddevice when input data is available.""" """Called by sounddevice when input data is available."""
if status: if status:
log.warning("capture status: %s", status) log.warning("capture status: %s", status)
if self._capturing: if self._capturing:
try: try:
self._capture_queue.put_nowait(bytes(indata)) pcm = bytes(indata)
if self._input_gain != 1.0:
pcm = _apply_gain(pcm, self._input_gain)
self._capture_queue.put_nowait(pcm)
except queue.Full: except queue.Full:
pass pass
@@ -112,6 +145,8 @@ class AudioPipeline:
return return
try: try:
pcm = self._playback_queue.get_nowait() pcm = self._playback_queue.get_nowait()
if self._output_gain != 1.0:
pcm = _apply_gain(pcm, self._output_gain)
n = min(len(pcm), len(outdata)) n = min(len(pcm), len(outdata))
outdata[:n] = pcm[:n] outdata[:n] = pcm[:n]
if n < len(outdata): if n < len(outdata):
+75 -14
View File
@@ -9,12 +9,26 @@ event loop (e.g. Textual's call_from_thread).
from __future__ import annotations from __future__ import annotations
import logging import logging
import socket
from dataclasses import dataclass from dataclasses import dataclass
from typing import Callable from typing import Callable
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class ConnectionFailed(Exception):
"""Connection attempt failed.
Attributes:
retryable: Whether the caller should attempt reconnection.
False for authentication rejections, True for network errors.
"""
def __init__(self, message: str, *, retryable: bool = True):
super().__init__(message)
self.retryable = retryable
@dataclass @dataclass
class User: class User:
session_id: int session_id: int
@@ -48,11 +62,15 @@ class MumbleClient:
port: int = 64738, port: int = 64738,
username: str = "tuimble-user", username: str = "tuimble-user",
password: str = "", password: str = "",
certfile: str = "",
keyfile: str = "",
): ):
self._host = host self._host = host
self._port = port self._port = port
self._username = username self._username = username
self._password = password self._password = password
self._certfile = certfile
self._keyfile = keyfile
self._mumble = None self._mumble = None
self._connected = False self._connected = False
self._dispatcher: Callable | None = None self._dispatcher: Callable | None = None
@@ -134,24 +152,57 @@ class MumbleClient:
# -- connection ---------------------------------------------------------- # -- connection ----------------------------------------------------------
def connect(self): def connect(self):
"""Connect to the Mumble server (blocking).""" """Connect to the Mumble server (blocking).
Raises:
ConnectionFailed: On any connection failure. Check the
``retryable`` attribute to decide whether to retry.
"""
import pymumble_py3 as pymumble import pymumble_py3 as pymumble
import pymumble_py3.constants as const
self._mumble = pymumble.Mumble( kwargs = {
self._host, "port": self._port,
self._username, "password": self._password,
port=self._port, "reconnect": False,
password=self._password, }
reconnect=False, if self._certfile:
) kwargs["certfile"] = self._certfile
self._mumble.set_codec_profile("audio") if self._keyfile:
self._mumble.set_receive_sound(True) kwargs["keyfile"] = self._keyfile
self._register_callbacks()
try:
self._mumble = pymumble.Mumble(
self._host,
self._username,
**kwargs,
)
self._mumble.set_codec_profile("audio")
self._mumble.set_receive_sound(True)
self._register_callbacks()
self._mumble.start()
self._mumble.is_ready() # blocks until handshake completes
except (socket.error, OSError) as exc:
self._connected = False
raise ConnectionFailed(
f"network error: {exc}", retryable=True,
) from exc
except Exception as exc:
self._connected = False
raise ConnectionFailed(str(exc), retryable=True) from exc
if self._mumble.connected != const.PYMUMBLE_CONN_STATE_CONNECTED:
self._connected = False
raise ConnectionFailed(
"server rejected connection", retryable=False,
)
self._mumble.start()
self._mumble.is_ready() # blocks until handshake completes
self._connected = True self._connected = True
log.info("connected to %s:%d as %s", self._host, self._port, self._username) log.info(
"connected to %s:%d as %s",
self._host, self._port, self._username,
)
def disconnect(self): def disconnect(self):
"""Disconnect from the server.""" """Disconnect from the server."""
@@ -163,6 +214,16 @@ class MumbleClient:
self._connected = False self._connected = False
log.info("disconnected") log.info("disconnected")
def reconnect(self):
"""Disconnect and reconnect to the same server.
Raises:
ConnectionFailed: On connection failure (see ``connect``).
"""
self.disconnect()
self._mumble = None
self.connect()
# -- actions ------------------------------------------------------------- # -- actions -------------------------------------------------------------
def send_text(self, message: str): def send_text(self, message: str):
+4
View File
@@ -16,6 +16,8 @@ class ServerConfig:
username: str = "tuimble-user" username: str = "tuimble-user"
password: str = "" password: str = ""
channel: str = "" channel: str = ""
certfile: str = ""
keyfile: str = ""
@dataclass @dataclass
@@ -24,6 +26,8 @@ class AudioConfig:
output_device: int | None = None output_device: int | None = None
sample_rate: int = 48000 sample_rate: int = 48000
frame_size: int = 960 # 20ms at 48kHz frame_size: int = 960 # 20ms at 48kHz
input_gain: float = 1.0
output_gain: float = 1.0
@dataclass @dataclass
+79 -1
View File
@@ -1,6 +1,8 @@
"""Tests for AudioPipeline.""" """Tests for AudioPipeline."""
from tuimble.audio import FRAME_SIZE, SAMPLE_RATE, AudioPipeline import struct
from tuimble.audio import FRAME_SIZE, SAMPLE_RATE, AudioPipeline, _apply_gain
def test_default_construction(): def test_default_construction():
@@ -125,3 +127,79 @@ def test_stop_without_start():
"""Stop on unstarted pipeline should not raise.""" """Stop on unstarted pipeline should not raise."""
ap = AudioPipeline() ap = AudioPipeline()
ap.stop() ap.stop()
# -- _apply_gain tests -------------------------------------------------------
def test_apply_gain_unity():
"""Gain 1.0 returns identical samples."""
pcm = struct.pack("<4h", 100, -200, 32767, -32768)
assert _apply_gain(pcm, 1.0) == pcm
def test_apply_gain_double():
"""Gain 2.0 doubles sample values."""
pcm = struct.pack("<2h", 100, -100)
result = struct.unpack("<2h", _apply_gain(pcm, 2.0))
assert result == (200, -200)
def test_apply_gain_clips():
"""Values exceeding int16 range are clipped."""
pcm = struct.pack("<2h", 20000, -20000)
result = struct.unpack("<2h", _apply_gain(pcm, 2.0))
assert result == (32767, -32768)
def test_apply_gain_zero():
"""Gain 0.0 produces silence."""
pcm = struct.pack("<2h", 1000, -1000)
result = struct.unpack("<2h", _apply_gain(pcm, 0.0))
assert result == (0, 0)
def test_apply_gain_empty():
"""Empty buffer returns empty."""
assert _apply_gain(b"", 2.0) == b""
# -- gain property tests ------------------------------------------------------
def test_gain_defaults():
ap = AudioPipeline()
assert ap.input_gain == 1.0
assert ap.output_gain == 1.0
def test_gain_clamping():
ap = AudioPipeline()
ap.input_gain = 3.0
assert ap.input_gain == 2.0
ap.output_gain = -1.0
assert ap.output_gain == 0.0
def test_capture_callback_applies_input_gain():
"""Input gain is applied to captured PCM."""
ap = AudioPipeline()
ap.capturing = True
ap.input_gain = 0.5
pcm = struct.pack("<2h", 1000, -1000)
ap._capture_callback(pcm, 2, None, None)
frame = ap.get_capture_frame()
result = struct.unpack("<2h", frame)
assert result == (500, -500)
def test_playback_callback_applies_output_gain():
"""Output gain is applied during playback."""
ap = AudioPipeline()
ap.output_gain = 0.5
pcm = struct.pack("<2h", 1000, -1000)
ap.queue_playback(pcm)
outdata = bytearray(4)
ap._playback_callback(outdata, 2, None, None)
result = struct.unpack("<2h", bytes(outdata))
assert result == (500, -500)
+55 -2
View File
@@ -1,8 +1,8 @@
"""Tests for MumbleClient dispatcher and callback wiring.""" """Tests for MumbleClient dispatcher and callback wiring."""
from unittest.mock import MagicMock from unittest.mock import MagicMock, patch
from tuimble.client import MumbleClient from tuimble.client import ConnectionFailed, MumbleClient
def test_default_state(): def test_default_state():
@@ -67,3 +67,56 @@ def test_set_self_deaf_noop_when_disconnected():
client = MumbleClient(host="localhost") client = MumbleClient(host="localhost")
# Should not raise when not connected # Should not raise when not connected
client.set_self_deaf(True) client.set_self_deaf(True)
def test_cert_defaults():
client = MumbleClient(host="localhost")
assert client._certfile == ""
assert client._keyfile == ""
def test_cert_custom():
client = MumbleClient(
host="localhost",
certfile="/path/cert.pem",
keyfile="/path/key.pem",
)
assert client._certfile == "/path/cert.pem"
assert client._keyfile == "/path/key.pem"
def test_connection_failed_retryable():
exc = ConnectionFailed("network error", retryable=True)
assert str(exc) == "network error"
assert exc.retryable is True
def test_connection_failed_not_retryable():
exc = ConnectionFailed("auth rejected", retryable=False)
assert str(exc) == "auth rejected"
assert exc.retryable is False
def test_connection_failed_default_retryable():
exc = ConnectionFailed("something broke")
assert exc.retryable is True
def test_reconnect_resets_state():
client = MumbleClient(host="localhost")
client._connected = True
client._mumble = MagicMock()
with patch.object(client, "connect"):
client.reconnect()
# disconnect should have cleared _mumble before connect
assert client._connected is False
def test_disconnect_clears_connected():
client = MumbleClient(host="localhost")
client._connected = True
client._mumble = MagicMock()
client.disconnect()
assert client.connected is False
+25 -1
View File
@@ -1,6 +1,6 @@
"""Tests for configuration module.""" """Tests for configuration module."""
from tuimble.config import Config, PttConfig, ServerConfig from tuimble.config import AudioConfig, Config, PttConfig, ServerConfig
def test_default_config(): def test_default_config():
@@ -21,3 +21,27 @@ def test_ptt_config_defaults():
ptt = PttConfig() ptt = PttConfig()
assert ptt.key == "f4" assert ptt.key == "f4"
assert ptt.backend == "auto" assert ptt.backend == "auto"
def test_audio_gain_defaults():
acfg = AudioConfig()
assert acfg.input_gain == 1.0
assert acfg.output_gain == 1.0
def test_audio_gain_custom():
acfg = AudioConfig(input_gain=0.5, output_gain=1.5)
assert acfg.input_gain == 0.5
assert acfg.output_gain == 1.5
def test_server_cert_defaults():
srv = ServerConfig()
assert srv.certfile == ""
assert srv.keyfile == ""
def test_server_cert_custom():
srv = ServerConfig(certfile="/path/cert.pem", keyfile="/path/key.pem")
assert srv.certfile == "/path/cert.pem"
assert srv.keyfile == "/path/key.pem"