| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315 |
- # -*- coding: utf-8 -*-
- """
- HID Raw Device Reader fuer Telephony-Tasten und LED-Steuerung.
- Manche USB-Geraete (z.B. ANKER PowerConf S330) senden
- Telephony-Tasten (Hook, Phone Mute) ueber die HID Telephony
- Usage Page (0x0B), die von Linux evdev nicht erkannt wird.
- Dieses Modul liest /dev/hidraw* direkt und dekodiert die Reports.
- Report-Struktur (ANKER PowerConf S330):
- Report ID 1 (Consumer): Play, Next, Prev, Vol+, Vol-, Mute
- → wird von evdev erkannt
- Report ID 2 (Telephony): Hook Switch, Flash, Drop, Phone Mute
- → NUR ueber hidraw lesbar
- Report ID 3 (LED Out): Ring LED
- Report ID 4 (LED Out): Mute LED
- Report ID 5 (LED Out): Hold LED
- """
- from __future__ import annotations
- import asyncio
- import os
- import struct
- from dataclasses import dataclass, field
- from pathlib import Path
- from typing import TYPE_CHECKING, Callable, Awaitable
- from trixy_core.hid.events import HIDEvent, HIDKeyType, HIDLedType
- from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
- if TYPE_CHECKING:
- pass
- @dataclass
- class HidrawDeviceInfo:
- """Informationen ueber ein hidraw-Geraet."""
- path: str # z.B. /dev/hidraw0
- name: str = ""
- vendor_id: int = 0
- product_id: int = 0
- has_telephony: bool = False
- has_leds: bool = False
- # Telephony Report ID 2 Bit-Mapping
- _TELEPHONY_BITS: dict[int, HIDKeyType] = {
- 0: HIDKeyType.HOOK, # Bit 0: Hook Switch (Call/Answer)
- # Bit 1: Flash
- # Bit 2: Drop
- 3: HIDKeyType.MIC_MUTE, # Bit 3: Phone Mute
- }
- # LED Report IDs
- _LED_REPORTS: dict[int, str] = {
- 3: "ring", # Ring-LED
- 4: "mute", # Mute-LED
- 5: "hold", # Hold-LED
- }
- class HidrawReader:
- """
- Liest HID-Reports direkt von /dev/hidraw*.
- Erkennt Telephony-Tasten die evdev nicht sieht
- und steuert LEDs ueber Output-Reports.
- Beispiel:
- ```python
- reader = HidrawReader("/dev/hidraw0", "ANKER PowerConf S330")
- reader.on_key = my_callback
- await reader.start()
- # LED steuern
- reader.set_led("mute", True)
- ```
- """
- def __init__(
- self,
- device_path: str,
- device_name: str = "",
- on_key: Callable[[HIDEvent], Awaitable[None]] | None = None,
- ) -> None:
- self._path = device_path
- self._name = device_name or device_path
- self._on_key = on_key
- self._fd: int | None = None
- self._task: asyncio.Task | None = None
- self._running = False
- # State: welche Tasten sind gerade gedrueckt (fuer press/release Erkennung)
- self._prev_report2: int = 0
- # Zeitstempel fuer Hold-Duration
- self._press_times: dict[int, float] = {}
- @property
- def is_running(self) -> bool:
- return self._running
- async def start(self) -> None:
- """Startet das Lesen von hidraw."""
- try:
- # Non-blocking oeffnen
- self._fd = os.open(self._path, os.O_RDWR | os.O_NONBLOCK)
- self._running = True
- self._task = asyncio.create_task(self._read_loop())
- pinfo(f"HID-Raw gestartet: {self._name} ({self._path})")
- except PermissionError:
- pwarn(f"HID-Raw: Keine Berechtigung fuer {self._path} (root oder Gruppe 'input' noetig)")
- except OSError as e:
- pwarn(f"HID-Raw: Kann {self._path} nicht oeffnen: {e}")
- async def stop(self) -> None:
- """Stoppt das Lesen."""
- self._running = False
- if self._task:
- self._task.cancel()
- try:
- await self._task
- except asyncio.CancelledError:
- pass
- self._task = None
- if self._fd is not None:
- try:
- os.close(self._fd)
- except OSError:
- pass
- self._fd = None
- def set_led(self, led_name: str, on: bool) -> bool:
- """
- Setzt eine LED ueber einen Output-Report.
- Args:
- led_name: "mute", "ring" oder "hold"
- on: True = an, False = aus
- Returns:
- True bei Erfolg
- """
- if self._fd is None:
- return False
- # Report-ID fuer die LED finden
- report_id = None
- for rid, name in _LED_REPORTS.items():
- if name == led_name:
- report_id = rid
- break
- if report_id is None:
- pdebug(f"HID-Raw: Unbekannte LED '{led_name}'")
- return False
- try:
- # Output-Report: [Report-ID, Value (1 Bit + 7 Padding)]
- value = 0x01 if on else 0x00
- data = struct.pack("BB", report_id, value)
- os.write(self._fd, data)
- pdebug(f"HID-Raw LED '{led_name}': {'AN' if on else 'AUS'}")
- return True
- except OSError as e:
- perror(f"HID-Raw LED-Fehler: {e}")
- return False
- async def _read_loop(self) -> None:
- """Haupt-Leseschleife fuer hidraw."""
- import time
- loop = asyncio.get_running_loop()
- try:
- while self._running:
- try:
- # Non-blocking read via Event-Loop
- data = await loop.run_in_executor(None, self._read_report)
- if data:
- await self._process_report(data)
- except asyncio.CancelledError:
- break
- except OSError:
- if self._running:
- pwarn(f"HID-Raw: Lesefehler auf {self._path}")
- break
- except asyncio.CancelledError:
- pass
- def _read_report(self) -> bytes | None:
- """Liest einen Report (blockierend, wird in Executor ausgefuehrt)."""
- import select
- import time
- if self._fd is None:
- return None
- # select mit Timeout damit wir nicht ewig blockieren
- try:
- ready, _, _ = select.select([self._fd], [], [], 0.5)
- if ready:
- return os.read(self._fd, 64)
- except (OSError, ValueError):
- pass
- return None
- async def _process_report(self, data: bytes) -> None:
- """Verarbeitet einen HID-Report."""
- import time
- if len(data) < 2:
- return
- report_id = data[0]
- report_data = data[1]
- # Nur Report ID 2 (Telephony) verarbeiten
- # Report ID 1 (Consumer) wird von evdev behandelt
- if report_id != 2:
- return
- # Bit-Aenderungen erkennen (press/release)
- changed = report_data ^ self._prev_report2
- self._prev_report2 = report_data
- if changed == 0:
- return
- now = time.monotonic()
- for bit, key_type in _TELEPHONY_BITS.items():
- mask = 1 << bit
- if not (changed & mask):
- continue
- pressed = bool(report_data & mask)
- hold_seconds = 0.0
- if pressed:
- self._press_times[bit] = now
- else:
- press_time = self._press_times.pop(bit, None)
- if press_time:
- hold_seconds = now - press_time
- event = HIDEvent(
- key_type=key_type,
- device_name=self._name,
- pressed=pressed,
- hold_seconds=hold_seconds,
- )
- if pressed:
- pinfo(f"HID-Raw [{self._name}] {key_type.value} GEDRUECKT")
- else:
- pinfo(f"HID-Raw [{self._name}] {key_type.value} LOSGELASSEN ({hold_seconds:.1f}s)")
- if self._on_key:
- await self._on_key(event)
- def find_hidraw_devices() -> list[HidrawDeviceInfo]:
- """
- Findet hidraw-Geraete mit Telephony-Support.
- Scannt /sys/class/hidraw/*/device/report_descriptor
- nach der Telephony Usage Page (0x050B).
- """
- devices: list[HidrawDeviceInfo] = []
- hidraw_dir = Path("/sys/class/hidraw")
- if not hidraw_dir.exists():
- return devices
- for entry in sorted(hidraw_dir.iterdir()):
- dev_path = f"/dev/{entry.name}"
- if not os.path.exists(dev_path):
- continue
- info = HidrawDeviceInfo(path=dev_path)
- # Report-Descriptor lesen
- desc_path = entry / "device" / "report_descriptor"
- if desc_path.exists():
- try:
- desc = desc_path.read_bytes()
- # Telephony Usage Page: 05 0B
- info.has_telephony = b"\x05\x0b" in desc
- # LED Usage Page: 05 08
- info.has_leds = b"\x05\x08" in desc
- except OSError:
- continue
- # Geraetename
- name_path = entry / "device" / "name"
- if not name_path.exists():
- # Alternativ: uevent parsen
- uevent_path = entry / "device" / "uevent"
- if uevent_path.exists():
- try:
- for line in uevent_path.read_text().splitlines():
- if line.startswith("HID_NAME="):
- info.name = line.split("=", 1)[1]
- break
- except OSError:
- pass
- if info.has_telephony:
- pdebug(f"HID-Raw Telephony: {info.name} ({dev_path})")
- devices.append(info)
- return devices
|