Add native Linux controls and tray menu for BTD 700

This commit is contained in:
Justin
2026-09-05 20:43:35 +02:00
commit 3579667c6e
25 changed files with 2047 additions and 0 deletions
+177
View File
@@ -0,0 +1,177 @@
"""Device operations with readback and capability checks."""
from dataclasses import asdict, dataclass
import time
from .protocol import CODECS, MODES, STATES, TRANSPORTS, Command as C, validate_text
from .transport import DeviceError
@dataclass
class Status:
state: int
mode: int
transport: int
connected_transport: int
codecs: int
codec: int
resolution: int
frequency: int
le_state: int
transports: int
broadcast_public: int
broadcast_quality: int
broadcast_encrypted: int
broadcast_name: str
firmware: str
gaming: int = -1
@property
def codec_name(self):
return ' + '.join(name for bit, name in CODECS.items() if self.codec & bit) or ''
@property
def quality(self):
bits = {1: '16 Bit', 2: '24 Bit'}.get(self.resolution, '')
frequency = {1: '44,1 kHz', 2: '48 kHz', 3: '96 kHz'}.get(self.frequency, '')
return f'{bits} / {frequency}'
@property
def gaming_allowed(self):
if self.gaming >= 0:
return self.gaming == 1
return self.mode == 1 or (self.state >= 2 and (self.connected_transport in (2, 3) or bool(self.codecs & 4)))
def public_dict(self):
result = asdict(self)
result.update(state_name=STATES.get(self.state, f'Unbekannt ({self.state})'),
mode_name=MODES.get(self.mode, f'Unbekannt ({self.mode})'),
codec_name=self.codec_name, audio_quality=self.quality,
transport_name=TRANSPORTS.get(self.transport, f'Unbekannt ({self.transport})'))
return result
class Controller:
def __init__(self, transport):
self.transport = transport
self.firmware = None
def snapshot(self) -> Status:
q = self.transport.request
state = q(C.GET_STATE)[0]
mode = q(C.GET_MODE)
quality = q(C.GET_QUALITY)
codecs = q(C.GET_CODECS)[0]
codec = q(C.GET_CODEC)[0]
le = q(C.GET_LE_STATE)[0]
transports = q(C.GET_TRANSPORTS)[0] if state >= 2 else 0
broadcast = q(C.GET_BROADCAST)
name = q(C.GET_NAME).split(b'\0', 1)[0].decode('utf-8', errors='replace')
if self.firmware is None:
try:
self.firmware = '.'.join(map(str, q(C.GET_VERSION)[:3]))
except TimeoutError:
self.firmware = 'Unbekannt'
# Firmware 3.11 does not respond to GET_GAMING; use events/fallback,
# matching the original application's capability logic.
event = self.transport.events.get(23, b'')
return Status(state, mode[0], mode[1], mode[2] if len(mode) > 2 else mode[1],
codecs, codec, *quality[:2], le, transports, *broadcast[:3],
name, self.firmware, event[0] if event else -1)
def _verify(self, getter, expected, *, timeout=5.0, exact=True, null_padded=False):
deadline = time.monotonic() + timeout
while True:
result = self.transport.request(getter)
comparison = result.rstrip(b'\0') if null_padded else result
matches = comparison == expected if exact else comparison.startswith(expected)
if matches:
return
if time.monotonic() >= deadline:
raise DeviceError('Der Dongle hat die angeforderte Einstellung nicht bestätigt. Bitte den aktuellen Status prüfen.')
time.sleep(0.15)
def set_mode(self, mode: int, transport: int | None = None):
status = self.snapshot()
if mode not in MODES:
raise ValueError('Unbekannter Audiomodus.')
if mode == 1 and not status.gaming_allowed:
raise ValueError('Gaming ist für die aktuelle Verbindung nicht verfügbar.')
selected = status.transport if transport is None else transport
if selected not in (1, 2, 3):
raise ValueError('Unbekannter Bluetooth-Transport.')
if transport is not None and selected in (1, 2) and status.transports and not status.transports & selected:
raise ValueError('Die Kopfhörer unterstützen diesen Transport nicht.')
payload = bytes((mode, selected))
self.transport.request(C.SET_MODE, payload)
self._verify(C.GET_MODE, payload, exact=False)
def set_codec(self, codec: int):
status = self.snapshot()
if status.mode != 0 or status.state < 2:
raise ValueError('Codec-Auswahl benötigt verbundene Kopfhörer im Standard-Modus.')
if codec not in CODECS or not status.codecs & codec:
raise ValueError('Dieser Codec wird für die aktuelle Verbindung nicht angeboten.')
payload = bytes((codec,))
self.transport.request(C.SET_CODEC, payload)
self._verify(C.GET_CODEC, payload)
def set_connection(self, connected: bool):
self.transport.request(C.SET_CONNECTION, bytes((int(connected),)))
deadline = time.monotonic() + 8
while True:
state = self.transport.request(C.GET_STATE)[0]
if (state >= 2) == connected:
return
if time.monotonic() >= deadline:
raise DeviceError('Verbindung wurde angefordert, aber noch nicht bestätigt. Sind die gekoppelten Kopfhörer eingeschaltet?')
time.sleep(0.25)
def set_broadcast(self, *, name=None, password=None, public=None, quality=None, encrypted=None):
# Validate the entire change before issuing any setter.
name_data = validate_text(name) if name is not None else None
key_data = validate_text(password, key=True) if password is not None else None
if any(value is not None and not isinstance(value, bool) for value in (public, encrypted)):
raise ValueError('Auracast-Schalter benötigen einen booleschen Wert.')
if quality is not None and quality not in (0, 1, 2):
raise ValueError('Unbekannte Auracast-Qualität.')
before = self.snapshot()
info = [before.broadcast_public, before.broadcast_quality, before.broadcast_encrypted]
if public is not None:
info[0] = int(public)
if quality is not None:
info[1] = quality
if password is not None:
info[2] = int(bool(password))
if encrypted is not None:
info[2] = int(encrypted)
if info[2] and key_data == b'':
raise ValueError('Verschlüsselung benötigt ein Passwort mit 416 Zeichen.')
if info[2] and key_data is None:
existing = self.transport.request(C.GET_KEY).rstrip(b'\0')
if len(existing) < 4:
raise ValueError('Bitte zuerst ein Passwort mit 416 Zeichen eingeben.')
# Preserve the original settings while changing a key, then write
# key, name, and final settings in the order used by the original app.
try:
if key_data is not None and before.broadcast_encrypted:
self.transport.request(C.SET_BROADCAST, bytes((before.broadcast_public, before.broadcast_quality, before.broadcast_encrypted)))
if key_data is not None:
self.transport.request(C.SET_KEY, key_data)
self._verify(C.GET_KEY, key_data, null_padded=True)
if name_data is not None:
self.transport.request(C.SET_NAME, name_data)
if name_data:
self._verify(C.GET_NAME, name_data, null_padded=True)
else:
self.transport.request(C.GET_NAME)
self.transport.request(C.SET_BROADCAST, bytes(info))
self._verify(C.GET_BROADCAST, bytes(info))
except Exception as exc:
# Do not silently repeat writes or claim atomicity: settings persist
# independently on the dongle.
raise DeviceError('Auracast wurde nur teilweise oder nicht übernommen. Aktuellen Namen und Passwortschutz prüfen.') from exc
def factory_reset(self, *, confirmed=False):
if not confirmed:
raise ValueError('Zurücksetzen muss ausdrücklich bestätigt werden; alle Kopplungen werden gelöscht.')
self.transport.request(C.FACTORY_RESET)