feat: add hotkey module with evdev push-to-talk listener

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 20:23:48 +02:00
parent 8224fdcc3f
commit ef6ff5b35b
2 changed files with 95 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from whisper_local.hotkey import HotkeyListener
@pytest.fixture
def listener():
return HotkeyListener(key_name="KEY_F12")
class TestHotkeyListener:
def test_init_stores_key_name(self, listener):
assert listener.key_name == "KEY_F12"
@pytest.mark.asyncio
async def test_key_down_calls_callback(self, listener):
on_press = AsyncMock()
listener.on_press = on_press
await listener._handle_key_event(key_down=True)
on_press.assert_awaited_once()
@pytest.mark.asyncio
async def test_key_up_calls_callback(self, listener):
on_release = AsyncMock()
listener.on_release = on_release
await listener._handle_key_event(key_down=False)
on_release.assert_awaited_once()
@pytest.mark.asyncio
async def test_no_callback_no_error(self, listener):
# Kein Callback gesetzt — soll nicht crashen
await listener._handle_key_event(key_down=True)
await listener._handle_key_event(key_down=False)
+57
View File
@@ -0,0 +1,57 @@
"""Hotkey-Listener via evdev für Push-to-Talk."""
import asyncio
import logging
from pathlib import Path
from typing import Callable, Coroutine
import evdev
from evdev import InputDevice, categorize, ecodes
logger = logging.getLogger(__name__)
AsyncCallback = Callable[[], Coroutine]
def find_keyboard_device() -> InputDevice:
"""Findet das erste Keyboard-Device in /dev/input/."""
devices = [InputDevice(path) for path in evdev.list_devices()]
for device in devices:
capabilities = device.capabilities(verbose=True)
for (etype_name, _etype_code), events in capabilities.items():
if etype_name == "EV_KEY":
key_names = [name for name, _code in events]
if "KEY_A" in key_names:
logger.info("Keyboard gefunden: %s (%s)", device.name, device.path)
return device
raise RuntimeError("Kein Keyboard-Device gefunden in /dev/input/")
class HotkeyListener:
def __init__(self, key_name: str = "KEY_F12"):
self.key_name = key_name
self.key_code = ecodes.ecodes.get(key_name)
if self.key_code is None:
raise ValueError(f"Unbekannter Key-Name: {key_name}")
self.on_press: AsyncCallback | None = None
self.on_release: AsyncCallback | None = None
async def _handle_key_event(self, key_down: bool) -> None:
"""Ruft den passenden Callback auf."""
if key_down and self.on_press:
await self.on_press()
elif not key_down and self.on_release:
await self.on_release()
async def listen(self) -> None:
"""Lauscht auf evdev-Events der konfigurierten Taste."""
device = find_keyboard_device()
logger.info("Lausche auf %s auf %s", self.key_name, device.name)
async for event in device.async_read_loop():
if event.type == ecodes.EV_KEY and event.code == self.key_code:
if event.value == 1: # Key down
logger.debug("%s gedrückt", self.key_name)
await self._handle_key_event(key_down=True)
elif event.value == 0: # Key up
logger.debug("%s losgelassen", self.key_name)
await self._handle_key_event(key_down=False)