diff --git a/tests/test_hotkey.py b/tests/test_hotkey.py new file mode 100644 index 0000000..ad130d6 --- /dev/null +++ b/tests/test_hotkey.py @@ -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) diff --git a/whisper_local/hotkey.py b/whisper_local/hotkey.py new file mode 100644 index 0000000..8f4f6d3 --- /dev/null +++ b/whisper_local/hotkey.py @@ -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)