hidraw.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. # -*- coding: utf-8 -*-
  2. """
  3. HID Raw Device Reader fuer Telephony-Tasten und LED-Steuerung.
  4. Manche USB-Geraete (z.B. ANKER PowerConf S330) senden
  5. Telephony-Tasten (Hook, Phone Mute) ueber die HID Telephony
  6. Usage Page (0x0B), die von Linux evdev nicht erkannt wird.
  7. Dieses Modul liest /dev/hidraw* direkt und dekodiert die Reports.
  8. Report-Struktur (ANKER PowerConf S330):
  9. Report ID 1 (Consumer): Play, Next, Prev, Vol+, Vol-, Mute
  10. → wird von evdev erkannt
  11. Report ID 2 (Telephony): Hook Switch, Flash, Drop, Phone Mute
  12. → NUR ueber hidraw lesbar
  13. Report ID 3 (LED Out): Ring LED
  14. Report ID 4 (LED Out): Mute LED
  15. Report ID 5 (LED Out): Hold LED
  16. """
  17. from __future__ import annotations
  18. import asyncio
  19. import os
  20. import struct
  21. from dataclasses import dataclass, field
  22. from pathlib import Path
  23. from typing import TYPE_CHECKING, Callable, Awaitable
  24. from trixy_core.hid.events import HIDEvent, HIDKeyType, HIDLedType
  25. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  26. if TYPE_CHECKING:
  27. pass
  28. @dataclass
  29. class HidrawDeviceInfo:
  30. """Informationen ueber ein hidraw-Geraet."""
  31. path: str # z.B. /dev/hidraw0
  32. name: str = ""
  33. vendor_id: int = 0
  34. product_id: int = 0
  35. has_telephony: bool = False
  36. has_leds: bool = False
  37. # Telephony Report ID 2 Bit-Mapping
  38. _TELEPHONY_BITS: dict[int, HIDKeyType] = {
  39. 0: HIDKeyType.HOOK, # Bit 0: Hook Switch (Call/Answer)
  40. # Bit 1: Flash
  41. # Bit 2: Drop
  42. 3: HIDKeyType.MIC_MUTE, # Bit 3: Phone Mute
  43. }
  44. # LED Report IDs
  45. _LED_REPORTS: dict[int, str] = {
  46. 3: "ring", # Ring-LED
  47. 4: "mute", # Mute-LED
  48. 5: "hold", # Hold-LED
  49. }
  50. class HidrawReader:
  51. """
  52. Liest HID-Reports direkt von /dev/hidraw*.
  53. Erkennt Telephony-Tasten die evdev nicht sieht
  54. und steuert LEDs ueber Output-Reports.
  55. Beispiel:
  56. ```python
  57. reader = HidrawReader("/dev/hidraw0", "ANKER PowerConf S330")
  58. reader.on_key = my_callback
  59. await reader.start()
  60. # LED steuern
  61. reader.set_led("mute", True)
  62. ```
  63. """
  64. def __init__(
  65. self,
  66. device_path: str,
  67. device_name: str = "",
  68. on_key: Callable[[HIDEvent], Awaitable[None]] | None = None,
  69. ) -> None:
  70. self._path = device_path
  71. self._name = device_name or device_path
  72. self._on_key = on_key
  73. self._fd: int | None = None
  74. self._task: asyncio.Task | None = None
  75. self._running = False
  76. # State: welche Tasten sind gerade gedrueckt (fuer press/release Erkennung)
  77. self._prev_report2: int = 0
  78. # Zeitstempel fuer Hold-Duration
  79. self._press_times: dict[int, float] = {}
  80. @property
  81. def is_running(self) -> bool:
  82. return self._running
  83. async def start(self) -> None:
  84. """Startet das Lesen von hidraw."""
  85. try:
  86. # Non-blocking oeffnen
  87. self._fd = os.open(self._path, os.O_RDWR | os.O_NONBLOCK)
  88. self._running = True
  89. self._task = asyncio.create_task(self._read_loop())
  90. pinfo(f"HID-Raw gestartet: {self._name} ({self._path})")
  91. except PermissionError:
  92. pwarn(f"HID-Raw: Keine Berechtigung fuer {self._path} (root oder Gruppe 'input' noetig)")
  93. except OSError as e:
  94. pwarn(f"HID-Raw: Kann {self._path} nicht oeffnen: {e}")
  95. async def stop(self) -> None:
  96. """Stoppt das Lesen."""
  97. self._running = False
  98. if self._task:
  99. self._task.cancel()
  100. try:
  101. await self._task
  102. except asyncio.CancelledError:
  103. pass
  104. self._task = None
  105. if self._fd is not None:
  106. try:
  107. os.close(self._fd)
  108. except OSError:
  109. pass
  110. self._fd = None
  111. def set_led(self, led_name: str, on: bool) -> bool:
  112. """
  113. Setzt eine LED ueber einen Output-Report.
  114. Args:
  115. led_name: "mute", "ring" oder "hold"
  116. on: True = an, False = aus
  117. Returns:
  118. True bei Erfolg
  119. """
  120. if self._fd is None:
  121. return False
  122. # Report-ID fuer die LED finden
  123. report_id = None
  124. for rid, name in _LED_REPORTS.items():
  125. if name == led_name:
  126. report_id = rid
  127. break
  128. if report_id is None:
  129. pdebug(f"HID-Raw: Unbekannte LED '{led_name}'")
  130. return False
  131. try:
  132. # Output-Report: [Report-ID, Value (1 Bit + 7 Padding)]
  133. value = 0x01 if on else 0x00
  134. data = struct.pack("BB", report_id, value)
  135. os.write(self._fd, data)
  136. pdebug(f"HID-Raw LED '{led_name}': {'AN' if on else 'AUS'}")
  137. return True
  138. except OSError as e:
  139. perror(f"HID-Raw LED-Fehler: {e}")
  140. return False
  141. async def _read_loop(self) -> None:
  142. """Haupt-Leseschleife fuer hidraw."""
  143. import time
  144. loop = asyncio.get_running_loop()
  145. try:
  146. while self._running:
  147. try:
  148. # Non-blocking read via Event-Loop
  149. data = await loop.run_in_executor(None, self._read_report)
  150. if data:
  151. await self._process_report(data)
  152. except asyncio.CancelledError:
  153. break
  154. except OSError:
  155. if self._running:
  156. pwarn(f"HID-Raw: Lesefehler auf {self._path}")
  157. break
  158. except asyncio.CancelledError:
  159. pass
  160. def _read_report(self) -> bytes | None:
  161. """Liest einen Report (blockierend, wird in Executor ausgefuehrt)."""
  162. import select
  163. import time
  164. if self._fd is None:
  165. return None
  166. # select mit Timeout damit wir nicht ewig blockieren
  167. try:
  168. ready, _, _ = select.select([self._fd], [], [], 0.5)
  169. if ready:
  170. return os.read(self._fd, 64)
  171. except (OSError, ValueError):
  172. pass
  173. return None
  174. async def _process_report(self, data: bytes) -> None:
  175. """Verarbeitet einen HID-Report."""
  176. import time
  177. if len(data) < 2:
  178. return
  179. report_id = data[0]
  180. report_data = data[1]
  181. # Nur Report ID 2 (Telephony) verarbeiten
  182. # Report ID 1 (Consumer) wird von evdev behandelt
  183. if report_id != 2:
  184. return
  185. # Bit-Aenderungen erkennen (press/release)
  186. changed = report_data ^ self._prev_report2
  187. self._prev_report2 = report_data
  188. if changed == 0:
  189. return
  190. now = time.monotonic()
  191. for bit, key_type in _TELEPHONY_BITS.items():
  192. mask = 1 << bit
  193. if not (changed & mask):
  194. continue
  195. pressed = bool(report_data & mask)
  196. hold_seconds = 0.0
  197. if pressed:
  198. self._press_times[bit] = now
  199. else:
  200. press_time = self._press_times.pop(bit, None)
  201. if press_time:
  202. hold_seconds = now - press_time
  203. event = HIDEvent(
  204. key_type=key_type,
  205. device_name=self._name,
  206. pressed=pressed,
  207. hold_seconds=hold_seconds,
  208. )
  209. if pressed:
  210. pinfo(f"HID-Raw [{self._name}] {key_type.value} GEDRUECKT")
  211. else:
  212. pinfo(f"HID-Raw [{self._name}] {key_type.value} LOSGELASSEN ({hold_seconds:.1f}s)")
  213. if self._on_key:
  214. await self._on_key(event)
  215. def find_hidraw_devices() -> list[HidrawDeviceInfo]:
  216. """
  217. Findet hidraw-Geraete mit Telephony-Support.
  218. Scannt /sys/class/hidraw/*/device/report_descriptor
  219. nach der Telephony Usage Page (0x050B).
  220. """
  221. devices: list[HidrawDeviceInfo] = []
  222. hidraw_dir = Path("/sys/class/hidraw")
  223. if not hidraw_dir.exists():
  224. return devices
  225. for entry in sorted(hidraw_dir.iterdir()):
  226. dev_path = f"/dev/{entry.name}"
  227. if not os.path.exists(dev_path):
  228. continue
  229. info = HidrawDeviceInfo(path=dev_path)
  230. # Report-Descriptor lesen
  231. desc_path = entry / "device" / "report_descriptor"
  232. if desc_path.exists():
  233. try:
  234. desc = desc_path.read_bytes()
  235. # Telephony Usage Page: 05 0B
  236. info.has_telephony = b"\x05\x0b" in desc
  237. # LED Usage Page: 05 08
  238. info.has_leds = b"\x05\x08" in desc
  239. except OSError:
  240. continue
  241. # Geraetename
  242. name_path = entry / "device" / "name"
  243. if not name_path.exists():
  244. # Alternativ: uevent parsen
  245. uevent_path = entry / "device" / "uevent"
  246. if uevent_path.exists():
  247. try:
  248. for line in uevent_path.read_text().splitlines():
  249. if line.startswith("HID_NAME="):
  250. info.name = line.split("=", 1)[1]
  251. break
  252. except OSError:
  253. pass
  254. if info.has_telephony:
  255. pdebug(f"HID-Raw Telephony: {info.name} ({dev_path})")
  256. devices.append(info)
  257. return devices