service.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  1. # -*- coding: utf-8 -*-
  2. """
  3. HID Media Key Service.
  4. Lauscht auf USB-HID-Eingabegeraete (Konferenzmikrofone, Headsets)
  5. und wandelt Media-Tasten in Trixy-Events um.
  6. Unterstuetzt u.a.:
  7. - ANKER PowerConf S330: MUTE, VOL+/-, MICMUTE, PLAY, NEXT/PREV + LED
  8. - Jabra Speak: MUTE, VOL+/-, HOOK + LED
  9. - Poly/Plantronics: MUTE, VOL+/-, HOOK
  10. - Generische USB-HID Media Keys
  11. Benoetigt: evdev (pip install evdev)
  12. Benoetigt: Leserechte auf /dev/input/event* (Gruppe 'input' oder root)
  13. """
  14. from __future__ import annotations
  15. import asyncio
  16. import time
  17. from dataclasses import dataclass, field
  18. from typing import TYPE_CHECKING, Callable, Awaitable
  19. from trixy_core.service import IService, ServicePriority, ServiceGroup
  20. from trixy_core.hid.events import HIDEvent, HIDKeyType, HIDLedType
  21. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  22. if TYPE_CHECKING:
  23. from trixy_core.application import BaseApplication
  24. # evdev Key-Codes → HIDKeyType Mapping
  25. _KEY_MAP: dict[int, HIDKeyType] = {}
  26. # evdev LED-Codes → HIDLedType Mapping
  27. _LED_MAP: dict[int, HIDLedType] = {}
  28. try:
  29. from evdev import ecodes
  30. _KEY_MAP = {
  31. ecodes.KEY_VOLUMEUP: HIDKeyType.VOLUME_UP,
  32. ecodes.KEY_VOLUMEDOWN: HIDKeyType.VOLUME_DOWN,
  33. ecodes.KEY_MUTE: HIDKeyType.MUTE,
  34. ecodes.KEY_PLAY: HIDKeyType.PLAY,
  35. ecodes.KEY_PLAYPAUSE: HIDKeyType.PLAY_PAUSE,
  36. ecodes.KEY_PAUSECD: HIDKeyType.PLAY_PAUSE,
  37. ecodes.KEY_PLAYCD: HIDKeyType.PLAY,
  38. ecodes.KEY_NEXTSONG: HIDKeyType.NEXT_TRACK,
  39. ecodes.KEY_PREVIOUSSONG: HIDKeyType.PREV_TRACK,
  40. ecodes.KEY_PHONE: HIDKeyType.HOOK,
  41. ecodes.KEY_MEDIA: HIDKeyType.HOOK,
  42. }
  43. # Geraete-spezifische Tasten
  44. if hasattr(ecodes, "KEY_MICMUTE"):
  45. _KEY_MAP[ecodes.KEY_MICMUTE] = HIDKeyType.MIC_MUTE
  46. if hasattr(ecodes, "KEY_HOOKSWITCH"):
  47. _KEY_MAP[ecodes.KEY_HOOKSWITCH] = HIDKeyType.HOOK
  48. _LED_MAP = {
  49. ecodes.LED_MUTE: HIDLedType.MUTE,
  50. }
  51. if hasattr(ecodes, "LED_MAIL"):
  52. _LED_MAP[ecodes.LED_MAIL] = HIDLedType.MAIL
  53. except ImportError:
  54. pass
  55. @dataclass
  56. class HIDServiceConfig:
  57. """Konfiguration fuer den HID-Service."""
  58. # Automatisch alle HID-Geraete mit Media-Tasten erkennen
  59. auto_detect: bool = True
  60. # Manuelle Geraete-Pfade (zusaetzlich zur Auto-Erkennung)
  61. device_paths: list[str] = field(default_factory=list)
  62. # Lautstaerke-Schrittweite (0.0-1.0)
  63. volume_step: float = 0.05
  64. # Hook-Taste als Wakeword-Trigger
  65. hook_triggers_wakeword: bool = True
  66. # MicMute-Taste: kurzer Druck = Mute, langer Druck = Wakeword-Trigger
  67. micmute_long_press_as_wakeword: bool = True
  68. # PLAY-Taste als Wakeword-Trigger (fuer Geraete ohne Hook)
  69. play_triggers_wakeword: bool = True
  70. # Langer Druck Schwelle (Sekunden)
  71. long_press_seconds: float = 2.0
  72. @dataclass
  73. class _LedDevice:
  74. """Ein Geraet mit LED-Steuerung."""
  75. device: object # evdev.InputDevice
  76. led_codes: dict[HIDLedType, int] = field(default_factory=dict)
  77. class HIDService(IService):
  78. """
  79. Service fuer USB-HID Media Keys und LED-Steuerung.
  80. Erkennt automatisch HID-Geraete, lauscht auf Tasten,
  81. misst Drueck-Dauer und steuert LEDs.
  82. """
  83. PRIORITY = ServicePriority.OPTIONAL
  84. GROUP = ServiceGroup.AUDIO
  85. DEPENDENCIES = []
  86. def __init__(
  87. self,
  88. application: "BaseApplication",
  89. config: HIDServiceConfig | None = None,
  90. ) -> None:
  91. super().__init__(application)
  92. self._config = config or HIDServiceConfig()
  93. self._tasks: list[asyncio.Task] = []
  94. self._key_devices: list = [] # evdev Geraete mit Tasten
  95. self._led_devices: list[_LedDevice] = [] # evdev Geraete mit LEDs
  96. self._hidraw_readers: list = [] # HidrawReader fuer Telephony
  97. self._callbacks: list[Callable[[HIDEvent], Awaitable[None]]] = []
  98. self._available = False
  99. self._mic_muted = False
  100. # Zeitstempel fuer Hold-Duration
  101. self._press_times: dict[tuple[str, int], float] = {}
  102. # === Properties ===
  103. @property
  104. def available(self) -> bool:
  105. """True wenn evdev verfuegbar und Geraete gefunden."""
  106. return self._available
  107. @property
  108. def device_count(self) -> int:
  109. """Anzahl der verbundenen HID-Geraete (Tasten)."""
  110. return len(self._key_devices)
  111. @property
  112. def device_names(self) -> list[str]:
  113. """Namen der verbundenen HID-Geraete."""
  114. return [d.name for d in self._key_devices]
  115. @property
  116. def has_leds(self) -> bool:
  117. """True wenn LED-steuerbare Geraete vorhanden."""
  118. return len(self._led_devices) > 0
  119. @property
  120. def mic_muted(self) -> bool:
  121. """Mikrofon-Stummschaltungsstatus."""
  122. return self._mic_muted
  123. # === Service Lifecycle ===
  124. async def start(self) -> None:
  125. """Startet den HID-Service."""
  126. try:
  127. import evdev
  128. self._available = True
  129. except ImportError:
  130. pdebug("evdev nicht installiert — HID Media Keys deaktiviert")
  131. return
  132. key_devices, led_devices = self._find_devices()
  133. if not key_devices and not led_devices:
  134. pdebug("Keine HID-Geraete gefunden")
  135. return
  136. self._key_devices = key_devices
  137. self._led_devices = led_devices
  138. for dev in key_devices:
  139. task = asyncio.create_task(self._listen_device(dev))
  140. self._tasks.append(task)
  141. pinfo(f"HID-Tasten: {dev.name} ({dev.path})")
  142. for ld in led_devices:
  143. leds = [lt.value for lt in ld.led_codes]
  144. pinfo(f"HID-LEDs: {ld.device.name} ({ld.device.path}) — {', '.join(leds)}")
  145. # Hidraw-Reader fuer Telephony-Geraete (Hook, Phone Mute)
  146. try:
  147. from trixy_core.hid.hidraw import find_hidraw_devices, HidrawReader
  148. hidraw_devs = find_hidraw_devices()
  149. for hdev in hidraw_devs:
  150. reader = HidrawReader(
  151. hdev.path, hdev.name,
  152. on_key=self._on_hidraw_key,
  153. )
  154. await reader.start()
  155. if reader.is_running:
  156. self._hidraw_readers.append(reader)
  157. except Exception as e:
  158. pdebug(f"HID-Raw nicht verfuegbar: {e}")
  159. total = len(key_devices) + len(self._hidraw_readers)
  160. if total == 0 and not led_devices:
  161. pdebug("Keine HID-Geraete gefunden")
  162. return
  163. self._available = True
  164. pinfo(
  165. f"HID-Service gestartet: {len(key_devices)} evdev, "
  166. f"{len(self._hidraw_readers)} hidraw, "
  167. f"{len(led_devices)} LED-Geraet(e)"
  168. )
  169. async def stop(self) -> None:
  170. """Stoppt den HID-Service."""
  171. for task in self._tasks:
  172. task.cancel()
  173. self._tasks.clear()
  174. for dev in self._key_devices:
  175. try:
  176. dev.close()
  177. except Exception:
  178. pass
  179. self._key_devices.clear()
  180. # LED-Devices nicht schliessen wenn sie auch Key-Device sind
  181. self._led_devices.clear()
  182. # Hidraw-Reader stoppen
  183. for reader in self._hidraw_readers:
  184. await reader.stop()
  185. self._hidraw_readers.clear()
  186. if self._available:
  187. pdebug("HID-Service gestoppt")
  188. def on_event(self, callback: Callable[[HIDEvent], Awaitable[None]]) -> None:
  189. """Registriert einen Callback fuer HID-Events."""
  190. self._callbacks.append(callback)
  191. # === LED-Steuerung ===
  192. async def _on_hidraw_key(self, event: HIDEvent) -> None:
  193. """Callback fuer hidraw-Events (Telephony-Tasten)."""
  194. # Gleiche Verarbeitung wie evdev-Events
  195. if event.pressed:
  196. await self._handle_press(event)
  197. else:
  198. await self._handle_release(event)
  199. # Callbacks
  200. for callback in self._callbacks:
  201. try:
  202. await callback(event)
  203. except Exception as e:
  204. perror(f"HID-Callback Fehler: {e}")
  205. # Allgemeines Event
  206. events = getattr(self._application, "events", None)
  207. if events:
  208. await events.emit("hid_key", {
  209. "key": event.key_type.value,
  210. "pressed": event.pressed,
  211. "hold_seconds": event.hold_seconds,
  212. "device": event.device_name,
  213. "source": "hidraw",
  214. })
  215. async def set_led(self, led_type: HIDLedType, on: bool) -> bool:
  216. """
  217. Schaltet eine LED auf einem HID-Geraet.
  218. Args:
  219. led_type: Welche LED (MUTE, MAIL)
  220. on: True = an, False = aus
  221. Returns:
  222. True wenn erfolgreich
  223. """
  224. import evdev
  225. for ld in self._led_devices:
  226. led_code = ld.led_codes.get(led_type)
  227. if led_code is not None:
  228. try:
  229. ld.device.write(evdev.ecodes.EV_LED, led_code, 1 if on else 0)
  230. ld.device.syn()
  231. pdebug(f"HID LED {led_type.value}: {'AN' if on else 'AUS'} ({ld.device.name})")
  232. return True
  233. except Exception as e:
  234. perror(f"HID LED-Fehler: {e}")
  235. return False
  236. async def set_mute_led(self, on: bool) -> bool:
  237. """Schaltet die Mute-LED (evdev oder hidraw)."""
  238. # Zuerst evdev versuchen
  239. if await self.set_led(HIDLedType.MUTE, on):
  240. return True
  241. # Fallback: hidraw
  242. for reader in self._hidraw_readers:
  243. if reader.set_led("mute", on):
  244. return True
  245. return False
  246. async def set_ring_led(self, on: bool) -> bool:
  247. """Schaltet die Ring-LED (nur hidraw)."""
  248. for reader in self._hidraw_readers:
  249. if reader.set_led("ring", on):
  250. return True
  251. return False
  252. async def set_hold_led(self, on: bool) -> bool:
  253. """Schaltet die Hold-LED (nur hidraw)."""
  254. for reader in self._hidraw_readers:
  255. if reader.set_led("hold", on):
  256. return True
  257. return False
  258. async def set_status_led(self, on: bool) -> bool:
  259. """Schaltet die Status-LED (LED_MAIL via evdev)."""
  260. return await self.set_led(HIDLedType.MAIL, on)
  261. # === Geraete-Erkennung ===
  262. def _find_devices(self) -> tuple[list, list[_LedDevice]]:
  263. """Findet HID-Geraete mit Tasten und/oder LEDs."""
  264. import evdev
  265. key_devices = []
  266. led_devices: list[_LedDevice] = []
  267. seen_paths: set[str] = set()
  268. # Manuelle Pfade
  269. for path in self._config.device_paths:
  270. try:
  271. dev = evdev.InputDevice(path)
  272. self._classify_device(dev, key_devices, led_devices)
  273. seen_paths.add(path)
  274. except (FileNotFoundError, PermissionError) as e:
  275. pwarn(f"HID-Geraet nicht verfuegbar: {path} ({e})")
  276. # Auto-Detect
  277. if self._config.auto_detect:
  278. for path in sorted(evdev.list_devices()):
  279. if path in seen_paths:
  280. continue
  281. try:
  282. dev = evdev.InputDevice(path)
  283. added = self._classify_device(dev, key_devices, led_devices)
  284. if not added:
  285. dev.close()
  286. except (PermissionError, OSError):
  287. pass
  288. return key_devices, led_devices
  289. def _classify_device(
  290. self,
  291. dev,
  292. key_devices: list,
  293. led_devices: list[_LedDevice],
  294. ) -> bool:
  295. """Klassifiziert ein Geraet (Tasten, LEDs oder beides). Gibt True zurueck wenn relevant."""
  296. import evdev
  297. caps = dev.capabilities(verbose=False)
  298. added = False
  299. # Tasten pruefen
  300. key_caps = caps.get(evdev.ecodes.EV_KEY, [])
  301. has_keys = any(kc in _KEY_MAP for kc in key_caps)
  302. if has_keys:
  303. key_devices.append(dev)
  304. keys = [_KEY_MAP[kc].value for kc in key_caps if kc in _KEY_MAP]
  305. pdebug(f"HID Tasten: {dev.name} ({dev.path}) — {', '.join(keys)}")
  306. added = True
  307. # LEDs pruefen
  308. led_caps = caps.get(evdev.ecodes.EV_LED, [])
  309. if led_caps:
  310. led_codes = {}
  311. for lc in led_caps:
  312. led_type = _LED_MAP.get(lc)
  313. if led_type:
  314. led_codes[led_type] = lc
  315. if led_codes:
  316. led_devices.append(_LedDevice(device=dev, led_codes=led_codes))
  317. pdebug(f"HID LEDs: {dev.name} ({dev.path}) — {[lt.value for lt in led_codes]}")
  318. added = True
  319. return added
  320. # === Event-Verarbeitung ===
  321. async def _listen_device(self, device) -> None:
  322. """Lauscht auf Events eines einzelnen Geraets."""
  323. import evdev
  324. try:
  325. async for event in device.async_read_loop():
  326. if event.type != evdev.ecodes.EV_KEY:
  327. continue
  328. key_type = _KEY_MAP.get(event.code)
  329. if key_type is None:
  330. continue
  331. # Nur key-down (1) und key-up (0), kein auto-repeat (2)
  332. if event.value not in (0, 1):
  333. continue
  334. pressed = event.value == 1
  335. device_key = (device.path, event.code)
  336. hold_seconds = 0.0
  337. if pressed:
  338. self._press_times[device_key] = time.monotonic()
  339. else:
  340. press_time = self._press_times.pop(device_key, None)
  341. if press_time:
  342. hold_seconds = time.monotonic() - press_time
  343. hid_event = HIDEvent(
  344. key_type=key_type,
  345. device_name=device.name,
  346. pressed=pressed,
  347. hold_seconds=hold_seconds,
  348. )
  349. # Debug-Ausgabe
  350. if pressed:
  351. pinfo(f"HID [{device.name}] {key_type.value} GEDRUECKT")
  352. else:
  353. pinfo(f"HID [{device.name}] {key_type.value} LOSGELASSEN ({hold_seconds:.1f}s)")
  354. # Aktionen bei key-down
  355. if pressed:
  356. await self._handle_press(hid_event)
  357. # Aktionen bei key-up (langer Druck)
  358. if not pressed:
  359. await self._handle_release(hid_event)
  360. # Callbacks
  361. for callback in self._callbacks:
  362. try:
  363. await callback(hid_event)
  364. except Exception as e:
  365. perror(f"HID-Callback Fehler: {e}")
  366. # Allgemeines Event
  367. events = getattr(self._application, "events", None)
  368. if events:
  369. await events.emit("hid_key", {
  370. "key": key_type.value,
  371. "pressed": pressed,
  372. "hold_seconds": hold_seconds,
  373. "device": device.name,
  374. })
  375. except asyncio.CancelledError:
  376. pass
  377. except OSError:
  378. pwarn(f"HID-Geraet getrennt: {device.name}")
  379. async def _handle_press(self, event: HIDEvent) -> None:
  380. """Verarbeitet Tastendruck (key-down)."""
  381. events = getattr(self._application, "events", None)
  382. if not events:
  383. return
  384. if event.key_type == HIDKeyType.VOLUME_UP:
  385. step_pct = int(self._config.volume_step * 100)
  386. pinfo(f"HID: Lautstaerke +{step_pct}%")
  387. await events.emit("volume_change", {
  388. "direction": "up",
  389. "step": self._config.volume_step,
  390. "device": event.device_name,
  391. })
  392. elif event.key_type == HIDKeyType.VOLUME_DOWN:
  393. step_pct = int(self._config.volume_step * 100)
  394. pinfo(f"HID: Lautstaerke -{step_pct}%")
  395. await events.emit("volume_change", {
  396. "direction": "down",
  397. "step": self._config.volume_step,
  398. "device": event.device_name,
  399. })
  400. elif event.key_type == HIDKeyType.MUTE:
  401. pinfo("HID: Lautsprecher-Mute umschalten")
  402. await events.emit("mute_toggle", {
  403. "device": event.device_name,
  404. "type": "speaker",
  405. })
  406. elif event.key_type == HIDKeyType.MIC_MUTE:
  407. # Mikrofon-Mute: Toggle und LED aktualisieren
  408. self._mic_muted = not self._mic_muted
  409. pinfo(f"HID: Mikrofon {'STUMM' if self._mic_muted else 'AKTIV'}")
  410. await self.set_mute_led(self._mic_muted)
  411. await events.emit("mute_toggle", {
  412. "device": event.device_name,
  413. "type": "microphone",
  414. "muted": self._mic_muted,
  415. })
  416. elif event.key_type == HIDKeyType.HOOK:
  417. if self._config.hook_triggers_wakeword:
  418. pinfo("HID: Hook-Taste → Wakeword-Trigger")
  419. await events.emit("wakeword_manual_trigger", {
  420. "device": event.device_name,
  421. "forced": True,
  422. })
  423. elif event.key_type == HIDKeyType.PLAY:
  424. if self._config.play_triggers_wakeword:
  425. pinfo("HID: Play-Taste → Wakeword-Trigger")
  426. await events.emit("wakeword_manual_trigger", {
  427. "device": event.device_name,
  428. "forced": True,
  429. })
  430. else:
  431. await events.emit("playback_toggle", {"device": event.device_name})
  432. elif event.key_type == HIDKeyType.PLAY_PAUSE:
  433. await events.emit("playback_toggle", {"device": event.device_name})
  434. elif event.key_type == HIDKeyType.NEXT_TRACK:
  435. pinfo("HID: Naechster Track")
  436. await events.emit("music_next", {"device": event.device_name})
  437. elif event.key_type == HIDKeyType.PREV_TRACK:
  438. pinfo("HID: Vorheriger Track")
  439. await events.emit("music_previous", {"device": event.device_name})
  440. async def _handle_release(self, event: HIDEvent) -> None:
  441. """Verarbeitet Tastenloslassen (key-up) — prueft langen Druck."""
  442. if event.hold_seconds < self._config.long_press_seconds:
  443. return
  444. events = getattr(self._application, "events", None)
  445. if not events:
  446. return
  447. pinfo(
  448. f"HID: Langer Druck {event.key_type.value} "
  449. f"({event.hold_seconds:.1f}s) von {event.device_name}"
  450. )
  451. await events.emit("hid_long_press", {
  452. "key": event.key_type.value,
  453. "hold_seconds": event.hold_seconds,
  454. "device": event.device_name,
  455. })
  456. # MicMute lang gedrueckt → Wakeword-Trigger
  457. if (event.key_type == HIDKeyType.MIC_MUTE
  458. and self._config.micmute_long_press_as_wakeword):
  459. pinfo("HID: MicMute langer Druck → Wakeword-Trigger")
  460. # Mute aufheben fuer Aufnahme
  461. if self._mic_muted:
  462. self._mic_muted = False
  463. await self.set_mute_led(False)
  464. await events.emit("wakeword_manual_trigger", {
  465. "device": event.device_name,
  466. "forced": True,
  467. })