feat: scaffold tuimble TUI mumble client

Core modules: TUI app (textual), mumble protocol client,
audio pipeline (sounddevice + opus), push-to-talk with
kitty protocol / evdev / toggle backends. Config via TOML.
This commit is contained in:
Username
2026-02-24 11:44:06 +01:00
commit 836018d146
22 changed files with 1040 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
__pycache__/
*.pyc
*.pyo
.venv/
*.egg-info/
dist/
build/
.eggs/
*.egg
.pytest_cache/
.mypy_cache/
.ruff_cache/

26
Makefile Normal file
View File

@@ -0,0 +1,26 @@
.PHONY: setup run lint test clean
VENV := .venv
PIP := $(VENV)/bin/pip
PY := $(VENV)/bin/python
setup: $(VENV)/bin/activate
$(VENV)/bin/activate:
python3 -m venv $(VENV)
$(PIP) install --upgrade pip
$(PIP) install -e ".[dev]"
run: setup
$(PY) -m tuimble
lint: setup
$(VENV)/bin/ruff check src/ tests/
$(VENV)/bin/ruff format --check src/ tests/
test: setup
$(VENV)/bin/pytest -v
clean:
rm -rf $(VENV) dist build *.egg-info .pytest_cache .ruff_cache
find . -type d -name __pycache__ -exec rm -rf {} +

28
PROJECT.md Normal file
View File

@@ -0,0 +1,28 @@
# tuimble
## Purpose
A terminal-based Mumble client with full voice support. Fills the gap
between heavyweight GUI clients and no-client-at-all for terminal users.
## Success Criteria
- Connect to any standard Mumble server
- Transmit and receive voice audio
- Push-to-talk with sub-100ms latency
- Browse channels, see users, send/receive text
- Works in Kitty, WezTerm, Ghostty, foot; degrades gracefully elsewhere
## Constraints
- Python 3.11+ (async features, tomllib)
- Minimal dependencies (5 core libraries)
- Single-user, single-server (no multi-server)
- Linux-first (evdev fallback is Linux-only)
## Non-Goals
- GUI or web interface
- Server administration
- Audio recording/playback to file
- Plugin system

44
README.md Normal file
View File

@@ -0,0 +1,44 @@
# tuimble
TUI Mumble client with voice support and push-to-talk.
## Features
- Terminal-based UI (Textual)
- Voice transmission with Opus codec
- Push-to-talk via Kitty keyboard protocol, evdev, or toggle
- Channel browsing and text chat
## Quick Start
```sh
make setup
make run
```
## Configuration
```sh
mkdir -p ~/.config/tuimble
```
`~/.config/tuimble/config.toml`:
```toml
[server]
host = "mumble.example.com"
port = 64738
username = "myname"
[ptt]
key = "space"
mode = "hold"
backend = "auto"
```
## Requirements
- Python 3.11+
- libopus
- portaudio (libportaudio2)
- A Mumble server to connect to

30
ROADMAP.md Normal file
View File

@@ -0,0 +1,30 @@
# Roadmap
## Phase 1 — Foundation (current)
- [x] Project scaffold
- [ ] Basic TUI layout (channels, chat, status)
- [ ] Mumble server connection
- [ ] Text chat (send/receive)
## Phase 2 — Voice
- [ ] Audio pipeline (capture + playback)
- [ ] Opus encode/decode integration
- [ ] Push-to-talk (Kitty protocol)
- [ ] PTT fallbacks (evdev, toggle)
## Phase 3 — Polish
- [ ] Channel tree navigation
- [ ] User list with status indicators
- [ ] Volume control
- [ ] Server certificate handling
- [ ] Config file hot-reload
## Phase 4 — Robustness
- [ ] Reconnection handling
- [ ] Error recovery
- [ ] Audio device hot-swap
- [ ] Comprehensive test suite

12
TASKLIST.md Normal file
View File

@@ -0,0 +1,12 @@
# Task List
## Active
- [ ] Wire TUI to MumbleClient (connect on startup, display state)
- [ ] Implement text message send/receive in chat log
## Pending
- [ ] Audio pipeline integration with MumbleClient
- [ ] PTT wiring (key events -> audio.capturing toggle)
- [ ] Channel tree population from server data

7
TODO.md Normal file
View File

@@ -0,0 +1,7 @@
# TODO
- Investigate pymumble_py3 vs pymumble fork status
- Check Textual key release event API surface
- Test opuslib availability on Debian 12
- Determine if sounddevice needs ALSA or PulseAudio config
- Consider TOML config validation (pydantic?)

12
docs/CHEATSHEET.md Normal file
View File

@@ -0,0 +1,12 @@
# Cheatsheet
```
make setup setup venv + deps
make run launch tuimble
make test run tests
make lint check code style
q quit
space push-to-talk (hold)
Enter send message
```

45
docs/DEBUG.md Normal file
View File

@@ -0,0 +1,45 @@
# Debugging
## Verbose Mode
```sh
tuimble --verbose # (planned)
```
## Common Issues
### No audio devices found
```sh
python3 -c "import sounddevice; print(sounddevice.query_devices())"
```
Check that portaudio is installed and devices are accessible.
### opuslib import error
```sh
python3 -c "import opuslib"
```
If this fails, install libopus: `apt install libopus0`
### Kitty protocol not detected
Verify terminal supports the Kitty keyboard protocol.
Known working: Kitty, WezTerm, Ghostty, foot.
### evdev permission denied
```sh
ls -la /dev/input/event*
groups # should include 'input'
```
Add user to `input` group: `sudo usermod -aG input $USER`
### Connection refused
- Verify server address and port
- Check firewall allows outbound TCP/UDP to port 64738
- Test with: `nc -zv <host> 64738`

52
docs/INSTALL.md Normal file
View File

@@ -0,0 +1,52 @@
# Installation
## Prerequisites
### System Libraries
```sh
# Debian/Ubuntu
sudo apt install libopus0 libopus-dev portaudio19-dev
# Fedora/RHEL
sudo dnf install opus opus-devel portaudio-devel
# Arch
sudo pacman -S opus portaudio
```
### Python
Python 3.11 or later required.
## Setup
```sh
git clone <repo-url> ~/git/tuimble
cd ~/git/tuimble
make setup
```
## Verify
```sh
make test
tuimble --version
```
## Optional: evdev PTT
For Linux evdev push-to-talk (physical key detection):
```sh
pip install tuimble[evdev]
sudo usermod -aG input $USER
# Log out and back in for group change
```
## Symlink
```sh
ln -sf ~/git/tuimble/.venv/bin/tuimble ~/.local/bin/tuimble
which tuimble
```

27
docs/USAGE.md Normal file
View File

@@ -0,0 +1,27 @@
# Usage
## Running
```sh
tuimble # uses ~/.config/tuimble/config.toml
tuimble --host mumble.example.com --user myname
```
## Key Bindings
| Key | Action |
|-----|--------|
| `q` | Quit |
| `space` | Push-to-talk (configurable) |
| `Enter` | Send message |
| `Ctrl+C` | Quit |
## Push-to-Talk Modes
- **hold** — hold key to transmit, release to stop (default)
- **toggle** — press to start, press again to stop
## Configuration
See `~/.config/tuimble/config.toml`. All fields are optional;
defaults connect to localhost:64738.

40
pyproject.toml Normal file
View File

@@ -0,0 +1,40 @@
[build-system]
requires = ["setuptools>=68.0", "setuptools-scm"]
build-backend = "setuptools.build_meta"
[project]
name = "tuimble"
version = "0.1.0"
description = "TUI Mumble client with voice support"
requires-python = ">=3.11"
dependencies = [
"textual>=1.0.0",
"pymumble>=1.6",
"sounddevice>=0.5.0",
"tomli>=2.0.0;python_version<'3.11'",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"ruff>=0.8.0",
]
evdev = [
"evdev>=1.7.0",
]
[project.scripts]
tuimble = "tuimble.__main__:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.ruff]
line-length = 88
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "W", "I"]
[tool.pytest.ini_options]
testpaths = ["tests"]

3
src/tuimble/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""TUI Mumble client with voice support."""
__version__ = "0.1.0"

14
src/tuimble/__main__.py Normal file
View File

@@ -0,0 +1,14 @@
"""Entry point for tuimble."""
import sys
def main():
from tuimble.app import TuimbleApp
app = TuimbleApp()
app.run()
if __name__ == "__main__":
sys.exit(main())

133
src/tuimble/app.py Normal file
View File

@@ -0,0 +1,133 @@
"""TUI application built on Textual."""
from __future__ import annotations
from textual import events
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.reactive import reactive
from textual.widgets import Footer, Header, Input, RichLog, Static
from tuimble.config import Config, load_config
from tuimble.ptt import KittyPtt, TogglePtt, detect_backend
class StatusBar(Static):
"""Connection and PTT status indicator."""
ptt_active = reactive(False)
connected = reactive(False)
def render(self) -> str:
if self.connected:
conn = "\x1b[38;2;158;206;106m\u25cf connected\x1b[0m"
else:
conn = "\x1b[38;2;247;118;142m\u25cb disconnected\x1b[0m"
if self.ptt_active:
ptt = "\x1b[38;2;224;175;104m\u25cf TX\x1b[0m"
else:
ptt = "\x1b[38;2;86;95;137m\u25cb idle\x1b[0m"
return f" {conn} {ptt}"
class ChannelTree(Static):
"""Channel and user list."""
def render(self) -> str:
hdr = "\x1b[38;2;169;177;214m Channels\x1b[0m"
empty = "\x1b[38;2;86;95;137m \u2514\u2500 (not connected)\x1b[0m"
return f"{hdr}\n{empty}"
class ChatLog(RichLog):
"""Message log."""
pass
class TuimbleApp(App):
"""Main application."""
TITLE = "tuimble"
CSS = """
Screen {
background: #1a1a2e;
color: #a9b1d6;
}
#main {
height: 1fr;
}
#sidebar {
width: 24;
border-right: solid #292e42;
padding: 0 1;
}
#chat-area {
width: 1fr;
}
#chatlog {
height: 1fr;
border-bottom: solid #292e42;
scrollbar-size: 1 1;
}
#input {
dock: bottom;
height: 3;
border-top: solid #292e42;
}
#status {
dock: bottom;
height: 1;
background: #16161e;
}
Footer {
background: #16161e;
}
Header {
background: #16161e;
}
"""
BINDINGS = [
("q", "quit", "Quit"),
("ctrl+c", "quit", "Quit"),
]
def __init__(self):
super().__init__()
self._config: Config = load_config()
self._ptt = detect_backend(self._on_ptt_change, self._config.ptt.backend)
def compose(self) -> ComposeResult:
yield Header()
with Horizontal(id="main"):
yield ChannelTree(id="sidebar")
with Vertical(id="chat-area"):
yield ChatLog(id="chatlog")
yield Input(placeholder="message...", id="input")
yield StatusBar(id="status")
yield Footer()
def on_mount(self):
chatlog = self.query_one("#chatlog", ChatLog)
chatlog.write("[dim]tuimble v0.1.0[/dim]")
chatlog.write("[dim]press q to quit[/dim]")
def on_key(self, event: events.Key) -> None:
"""Handle PTT key events."""
ptt_key = self._config.ptt.key
if isinstance(self._ptt, KittyPtt):
if event.key == ptt_key:
is_release = getattr(event, "key_type", None)
if is_release == "release":
self._ptt.key_up()
else:
self._ptt.key_down()
elif isinstance(self._ptt, TogglePtt):
if event.key == ptt_key:
self._ptt.toggle()
def _on_ptt_change(self, transmitting: bool):
"""Called when PTT state changes."""
status = self.query_one("#status", StatusBar)
status.ptt_active = transmitting

131
src/tuimble/audio.py Normal file
View File

@@ -0,0 +1,131 @@
"""Audio capture and playback pipeline.
Handles microphone input, speaker output, and Opus encoding/decoding.
Designed to run alongside the async event loop via callback-based I/O.
"""
from __future__ import annotations
import logging
import queue
log = logging.getLogger(__name__)
SAMPLE_RATE = 48000
CHANNELS = 1
FRAME_SIZE = 960 # 20ms at 48kHz
DTYPE = "int16"
class AudioPipeline:
"""Manages audio input/output streams and Opus codec."""
def __init__(
self,
sample_rate: int = SAMPLE_RATE,
frame_size: int = FRAME_SIZE,
input_device: int | None = None,
output_device: int | None = None,
):
self._sample_rate = sample_rate
self._frame_size = frame_size
self._input_device = input_device
self._output_device = output_device
self._capture_queue: queue.Queue[bytes] = queue.Queue(maxsize=50)
self._playback_queue: queue.Queue[bytes] = queue.Queue(maxsize=50)
self._encoder = None
self._decoder = None
self._input_stream = None
self._output_stream = None
self._capturing = False
def start(self):
"""Initialize codec and open audio streams."""
import opuslib
import sounddevice as sd
self._encoder = opuslib.Encoder(
self._sample_rate, CHANNELS, opuslib.APPLICATION_VOIP
)
self._decoder = opuslib.Decoder(self._sample_rate, CHANNELS)
self._output_stream = sd.RawOutputStream(
samplerate=self._sample_rate,
channels=CHANNELS,
dtype=DTYPE,
device=self._output_device,
blocksize=self._frame_size,
callback=self._playback_callback,
)
self._output_stream.start()
self._input_stream = sd.RawInputStream(
samplerate=self._sample_rate,
channels=CHANNELS,
dtype=DTYPE,
device=self._input_device,
blocksize=self._frame_size,
callback=self._capture_callback,
)
self._input_stream.start()
log.info("audio pipeline started (rate=%d)", self._sample_rate)
def stop(self):
"""Close audio streams."""
for stream in (self._input_stream, self._output_stream):
if stream is not None:
stream.stop()
stream.close()
self._input_stream = None
self._output_stream = None
log.info("audio pipeline stopped")
@property
def capturing(self) -> bool:
return self._capturing
@capturing.setter
def capturing(self, value: bool):
self._capturing = value
def _capture_callback(self, indata, frames, time_info, status):
"""Called by sounddevice when input data is available."""
if status:
log.warning("capture status: %s", status)
if self._capturing and self._encoder:
try:
encoded = self._encoder.encode(bytes(indata), self._frame_size)
self._capture_queue.put_nowait(encoded)
except queue.Full:
pass
def _playback_callback(self, outdata, frames, time_info, status):
"""Called by sounddevice when output buffer needs data."""
if status:
log.warning("playback status: %s", status)
try:
data = self._playback_queue.get_nowait()
if self._decoder:
pcm = self._decoder.decode(data, self._frame_size)
outdata[:] = pcm[: len(outdata)]
else:
outdata[:] = b"\x00" * len(outdata)
except queue.Empty:
outdata[:] = b"\x00" * len(outdata)
def get_encoded_frame(self) -> bytes | None:
"""Retrieve next encoded frame for transmission."""
try:
return self._capture_queue.get_nowait()
except queue.Empty:
return None
def queue_playback(self, opus_data: bytes):
"""Queue encoded audio data for playback."""
try:
self._playback_queue.put_nowait(opus_data)
except queue.Full:
pass

156
src/tuimble/client.py Normal file
View File

@@ -0,0 +1,156 @@
"""Mumble protocol client.
Wraps pymumble to provide an async-friendly interface for connecting
to Mumble servers, handling channels, users, and voice data.
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
log = logging.getLogger(__name__)
@dataclass
class User:
session_id: int
name: str
channel_id: int
mute: bool = False
deaf: bool = False
self_mute: bool = False
self_deaf: bool = False
@dataclass
class Channel:
channel_id: int
name: str
parent_id: int
description: str = ""
class MumbleClient:
"""Async wrapper around pymumble."""
def __init__(
self,
host: str,
port: int = 64738,
username: str = "tuimble-user",
password: str = "",
):
self._host = host
self._port = port
self._username = username
self._password = password
self._mumble = None
self._connected = False
self._loop: asyncio.AbstractEventLoop | 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)
@property
def connected(self) -> bool:
return self._connected
@property
def users(self) -> dict[int, User]:
if not self._mumble:
return {}
result = {}
for sid, u in self._mumble.users.items():
result[sid] = User(
session_id=sid,
name=u["name"],
channel_id=u.get("channel_id", 0),
mute=u.get("mute", False),
deaf=u.get("deaf", False),
self_mute=u.get("self_mute", False),
self_deaf=u.get("self_deaf", False),
)
return result
@property
def channels(self) -> dict[int, Channel]:
if not self._mumble:
return {}
result = {}
for cid, ch in self._mumble.channels.items():
result[cid] = Channel(
channel_id=cid,
name=ch["name"],
parent_id=ch.get("parent", 0),
description=ch.get("description", ""),
)
return result
async def connect(self):
"""Connect to the Mumble server."""
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,
)
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,
)
# pymumble runs its own thread
self._mumble.start()
self._mumble.is_ready()
self._connected = True
log.info("connected to %s:%d as %s", self._host, self._port, self._username)
async def disconnect(self):
"""Disconnect from the server."""
if self._mumble:
self._mumble.stop()
self._connected = False
log.info("disconnected")
def send_text(self, message: str, channel: bool = True):
"""Send a text message to the current channel."""
if self._mumble and self._connected:
ch = self._mumble.channels[self._mumble.users.myself["channel_id"]]
ch.send_text_message(message)
def send_audio(self, opus_data: bytes):
"""Send encoded audio to the server."""
if self._mumble and self._connected:
self._mumble.sound_output.add_sound(opus_data)
def join_channel(self, channel_id: int):
"""Move to a different channel."""
if self._mumble and self._connected:
self._mumble.channels[channel_id].move_in()
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
)
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
)

64
src/tuimble/config.py Normal file
View File

@@ -0,0 +1,64 @@
"""Configuration management."""
from dataclasses import dataclass, field
from pathlib import Path
CONFIG_DIR = Path.home() / ".config" / "tuimble"
CONFIG_FILE = CONFIG_DIR / "config.toml"
DEFAULT_PORT = 64738
@dataclass
class ServerConfig:
host: str = "localhost"
port: int = DEFAULT_PORT
username: str = "tuimble-user"
password: str = ""
channel: str = ""
@dataclass
class AudioConfig:
input_device: int | None = None
output_device: int | None = None
sample_rate: int = 48000
frame_size: int = 960 # 20ms at 48kHz
@dataclass
class PttConfig:
key: str = "space"
mode: str = "hold" # hold | toggle
backend: str = "auto" # auto | kitty | evdev | toggle
@dataclass
class Config:
server: ServerConfig = field(default_factory=ServerConfig)
audio: AudioConfig = field(default_factory=AudioConfig)
ptt: PttConfig = field(default_factory=PttConfig)
def load_config(path: Path | None = None) -> Config:
"""Load configuration from TOML file, falling back to defaults."""
path = path or CONFIG_FILE
if not path.exists():
return Config()
try:
import tomllib
except ImportError:
import tomli as tomllib
with open(path, "rb") as f:
data = tomllib.load(f)
cfg = Config()
if "server" in data:
cfg.server = ServerConfig(**data["server"])
if "audio" in data:
cfg.audio = AudioConfig(**data["audio"])
if "ptt" in data:
cfg.ptt = PttConfig(**data["ptt"])
return cfg

144
src/tuimble/ptt.py Normal file
View File

@@ -0,0 +1,144 @@
"""Push-to-talk input handling.
Backend priority:
1. Kitty keyboard protocol (terminal-native, no extra deps)
2. evdev (Linux /dev/input, requires 'input' group)
3. Toggle mode (press-on / press-off, universal fallback)
"""
from __future__ import annotations
import asyncio
import enum
import logging
from abc import ABC, abstractmethod
from typing import Callable
log = logging.getLogger(__name__)
Callback = Callable[[bool], None] # True = transmitting, False = idle
class PttState(enum.Enum):
IDLE = "idle"
TRANSMITTING = "transmitting"
class PttBackend(ABC):
"""Base class for push-to-talk backends."""
def __init__(self, callback: Callback):
self._callback = callback
self._state = PttState.IDLE
@property
def state(self) -> PttState:
return self._state
@property
def transmitting(self) -> bool:
return self._state is PttState.TRANSMITTING
def _set_state(self, transmitting: bool):
new = PttState.TRANSMITTING if transmitting else PttState.IDLE
if new != self._state:
self._state = new
self._callback(transmitting)
@abstractmethod
async def start(self): ...
@abstractmethod
async def stop(self): ...
class KittyPtt(PttBackend):
"""PTT via Kitty keyboard protocol key release events.
Relies on the TUI framework (Textual) forwarding key_down/key_up.
This backend is driven externally by the app's key event handlers.
"""
async def start(self):
log.info("kitty protocol PTT active")
async def stop(self):
self._set_state(False)
def key_down(self):
self._set_state(True)
def key_up(self):
self._set_state(False)
class EvdevPtt(PttBackend):
"""PTT via Linux evdev input subsystem."""
def __init__(self, callback: Callback, key_code: int = 57):
super().__init__(callback)
self._key_code = key_code # 57 = KEY_SPACE
self._task: asyncio.Task | None = None
async def start(self):
try:
import evdev
except ImportError:
raise RuntimeError("evdev not installed; pip install tuimble[evdev]")
devices = [evdev.InputDevice(p) for p in evdev.list_devices()]
kbd = None
for dev in devices:
caps = dev.capabilities(verbose=False)
if 1 in caps and self._key_code in caps[1]:
kbd = dev
break
if kbd is None:
raise RuntimeError("no suitable input device found")
log.info("evdev PTT active on %s", kbd.path)
self._task = asyncio.create_task(self._poll(kbd))
async def _poll(self, dev):
import evdev
async for event in dev.async_read_loop():
if event.type == evdev.ecodes.EV_KEY and event.code == self._key_code:
if event.value == 1: # press
self._set_state(True)
elif event.value == 0: # release
self._set_state(False)
async def stop(self):
if self._task:
self._task.cancel()
self._task = None
self._set_state(False)
class TogglePtt(PttBackend):
"""PTT via toggle: press once to start, press again to stop."""
async def start(self):
log.info("toggle PTT active")
async def stop(self):
self._set_state(False)
def toggle(self):
self._set_state(not self.transmitting)
def detect_backend(callback: Callback, preference: str = "auto") -> PttBackend:
"""Select the best available PTT backend."""
if preference == "kitty":
return KittyPtt(callback)
if preference == "evdev":
return EvdevPtt(callback)
if preference == "toggle":
return TogglePtt(callback)
# auto: try kitty first (will be validated at runtime by the app),
# then evdev, then toggle
return KittyPtt(callback)

0
tests/__init__.py Normal file
View File

23
tests/test_config.py Normal file
View File

@@ -0,0 +1,23 @@
"""Tests for configuration module."""
from tuimble.config import Config, PttConfig, ServerConfig
def test_default_config():
cfg = Config()
assert cfg.server.host == "localhost"
assert cfg.server.port == 64738
assert cfg.audio.sample_rate == 48000
assert cfg.ptt.mode == "hold"
def test_server_config():
srv = ServerConfig(host="mumble.example.com", port=12345)
assert srv.host == "mumble.example.com"
assert srv.port == 12345
def test_ptt_config_defaults():
ptt = PttConfig()
assert ptt.key == "space"
assert ptt.backend == "auto"

37
tests/test_ptt.py Normal file
View File

@@ -0,0 +1,37 @@
"""Tests for push-to-talk backends."""
from tuimble.ptt import KittyPtt, PttState, TogglePtt
def test_kitty_ptt_press_release():
states = []
ptt = KittyPtt(lambda tx: states.append(tx))
assert ptt.state is PttState.IDLE
ptt.key_down()
assert ptt.state is PttState.TRANSMITTING
assert states == [True]
ptt.key_up()
assert ptt.state is PttState.IDLE
assert states == [True, False]
def test_kitty_ptt_no_duplicate_events():
states = []
ptt = KittyPtt(lambda tx: states.append(tx))
ptt.key_down()
ptt.key_down() # repeat, no state change
assert states == [True]
def test_toggle_ptt():
states = []
ptt = TogglePtt(lambda tx: states.append(tx))
ptt.toggle()
assert ptt.transmitting is True
ptt.toggle()
assert ptt.transmitting is False
assert states == [True, False]