Files
btd700linux/btd700/transport.py
T
Justin dd0809e877
Tests / unit (de, 3.10) (push) Has been cancelled
Tests / unit (de, 3.14) (push) Has been cancelled
Tests / unit (en, 3.14) (push) Has been cancelled
Tests / unit (en, 3.10) (push) Has been cancelled
Add English localization and MIT-licensed public release documentation
2026-09-05 21:02:14 +02:00

117 lines
4.5 KiB
Python

"""Linux hidraw access, limited to the BTD 700 control interface."""
from .i18n import tr
from dataclasses import dataclass
from pathlib import Path
import fcntl
import os
import select
import struct
import time
from .protocol import Command, EVENTS, MIN_LENGTHS, ProtocolError, acknowledge, decode, encode
class DeviceError(Exception):
pass
@dataclass(frozen=True)
class Device:
path: str
serial: str
name: str = 'Sennheiser BTD 700'
def discover(sysfs: Path = Path('/sys/class/hidraw')) -> list[Device]:
devices = []
for node in sorted(sysfs.glob('hidraw*')):
try:
info = dict(line.split('=', 1) for line in (node / 'device/uevent').read_text().splitlines() if '=' in line)
if info.get('HID_ID', '').upper() != '0003:00003542:00003001':
continue
descriptor = (node / 'device/report_descriptor').read_bytes()
if b'\x06\xa2\xff' not in descriptor or b'\x85\x34' not in descriptor:
continue
devices.append(Device('/dev/' + node.name, info.get('HID_UNIQ', '')))
except (OSError, ValueError):
continue
return devices
class Hidraw:
def __init__(self, device: Device, timeout: float = 1.0):
self.device = device
self.timeout = timeout
self.fd = None
self.events = {}
try:
self.fd = os.open(device.path, os.O_RDWR | os.O_NONBLOCK | os.O_CLOEXEC | os.O_NOFOLLOW)
info = bytearray(8)
fcntl.ioctl(self.fd, 0x80084803, info, True) # HIDIOCGRAWINFO
bus, vendor, product = struct.unpack('IHH', info)
if (bus, vendor, product) != (3, 0x3542, 0x3001):
raise DeviceError(tr('Gerätepfad gehört nicht mehr zum BTD 700.'))
try:
fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
raise DeviceError(tr('Der Dongle ist bereits in einer anderen Instanz geöffnet.')) from exc
except PermissionError as exc:
self.close()
raise DeviceError(tr('USB-Zugriff fehlt. Die mitgelieferte udev-Regel installieren und den Dongle neu einstecken.')) from exc
except BaseException:
self.close()
raise
def close(self):
if self.fd is not None:
os.close(self.fd)
self.fd = None
def __enter__(self):
return self
def __exit__(self, *_):
self.close()
def _write(self, report):
if self.fd is None:
raise DeviceError(tr('Dongle ist nicht geöffnet.'))
if not select.select([], [self.fd], [], self.timeout)[1]:
raise TimeoutError(tr('USB-Schreibzugriff hat nicht geantwortet.'))
if os.write(self.fd, report) != len(report):
raise DeviceError(tr('Unvollständiger USB-Schreibvorgang.'))
def _receive(self):
report = os.read(self.fd, 1024)
if not report:
raise DeviceError(tr('Dongle wurde entfernt.'))
message = decode(report)
if message and message.kind == 0xFC and message.command in EVENTS:
self.events[message.command] = message.payload
self._write(acknowledge(message.command))
return message
def request(self, command: Command, payload: bytes = b'') -> bytes:
report = encode(command, payload) # Validate before any I/O.
try:
# Discard stale replies, acknowledging events. The cap prevents an
# unrelated consumer-control input flood from blocking indefinitely.
for _ in range(128):
if not select.select([self.fd], [], [], 0)[0]:
break
self._receive()
self._write(report)
deadline = time.monotonic() + self.timeout
while (remaining := deadline - time.monotonic()) > 0:
if not select.select([self.fd], [], [], remaining)[0]:
break
message = self._receive()
if message and message.kind == 0xFF and message.command == command:
if len(message.payload) < MIN_LENGTHS.get(command, 0):
raise ProtocolError(tr('Unvollständige Antwort auf {command}.').format(command=command.name))
return message.payload
raise TimeoutError(tr('Der Dongle antwortet nicht auf {command}.').format(command=command.name))
except (OSError, ProtocolError):
self.close()
raise