2026-04-08 10:28:52 +02:00
|
|
|
"""Hotkey-Listener via evdev für Push-to-Talk (Linux)."""
|
2026-04-06 20:23:48 +02:00
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import logging
|
|
|
|
|
|
|
|
|
|
import evdev
|
|
|
|
|
from evdev import InputDevice, categorize, ecodes
|
|
|
|
|
|
2026-04-08 10:28:52 +02:00
|
|
|
from whisper_local.hotkey import AsyncCallback
|
2026-04-06 20:23:48 +02:00
|
|
|
|
2026-04-08 10:28:52 +02:00
|
|
|
logger = logging.getLogger(__name__)
|
2026-04-06 20:23:48 +02:00
|
|
|
|
|
|
|
|
|
2026-04-06 20:50:44 +02:00
|
|
|
def find_keyboard_devices(key_name: str) -> list[InputDevice]:
|
|
|
|
|
"""Findet alle Devices die den angegebenen Key unterstützen."""
|
|
|
|
|
matches = []
|
|
|
|
|
for path in evdev.list_devices():
|
|
|
|
|
device = InputDevice(path)
|
2026-04-06 20:23:48 +02:00
|
|
|
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]
|
2026-04-06 20:50:44 +02:00
|
|
|
if key_name in key_names:
|
|
|
|
|
logger.info("Device mit %s gefunden: %s (%s)", key_name, device.name, device.path)
|
|
|
|
|
matches.append(device)
|
|
|
|
|
break
|
|
|
|
|
if not matches:
|
|
|
|
|
raise RuntimeError(f"Kein Device mit {key_name} gefunden in /dev/input/")
|
|
|
|
|
return matches
|
2026-04-06 20:23:48 +02:00
|
|
|
|
|
|
|
|
|
2026-04-08 10:28:52 +02:00
|
|
|
class EvdevHotkeyListener:
|
2026-04-06 20:23:48 +02:00
|
|
|
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
|
2026-04-11 21:21:57 +02:00
|
|
|
self._tasks: list[asyncio.Task] = []
|
|
|
|
|
self._devices: list[InputDevice] = []
|
2026-04-06 20:23:48 +02:00
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
2026-04-06 20:50:44 +02:00
|
|
|
async def _read_device(self, device: InputDevice) -> None:
|
|
|
|
|
"""Liest Events von einem einzelnen Device."""
|
2026-04-06 20:23:48 +02:00
|
|
|
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
|
2026-04-06 20:50:44 +02:00
|
|
|
logger.debug("%s gedrückt (via %s)", self.key_name, device.path)
|
2026-04-06 20:23:48 +02:00
|
|
|
await self._handle_key_event(key_down=True)
|
|
|
|
|
elif event.value == 0: # Key up
|
2026-04-06 20:50:44 +02:00
|
|
|
logger.debug("%s losgelassen (via %s)", self.key_name, device.path)
|
2026-04-06 20:23:48 +02:00
|
|
|
await self._handle_key_event(key_down=False)
|
2026-04-06 20:50:44 +02:00
|
|
|
|
2026-04-10 21:05:20 +02:00
|
|
|
def stop(self) -> None:
|
2026-04-11 21:21:57 +02:00
|
|
|
"""Cancelt laufende Read-Tasks und schließt Devices."""
|
|
|
|
|
for task in self._tasks:
|
|
|
|
|
task.cancel()
|
|
|
|
|
for dev in self._devices:
|
|
|
|
|
try:
|
|
|
|
|
dev.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
self._tasks = []
|
|
|
|
|
self._devices = []
|
2026-04-10 21:05:20 +02:00
|
|
|
|
2026-04-06 20:50:44 +02:00
|
|
|
async def listen(self) -> None:
|
|
|
|
|
"""Lauscht auf evdev-Events der konfigurierten Taste auf allen passenden Devices."""
|
2026-04-11 21:21:57 +02:00
|
|
|
self._devices = find_keyboard_devices(self.key_name)
|
|
|
|
|
for dev in self._devices:
|
2026-04-06 20:50:44 +02:00
|
|
|
logger.info("Lausche auf %s auf %s (%s)", self.key_name, dev.name, dev.path)
|
2026-04-11 21:21:57 +02:00
|
|
|
self._tasks = [asyncio.create_task(self._read_device(dev)) for dev in self._devices]
|
|
|
|
|
await asyncio.gather(*self._tasks, return_exceptions=True)
|