Files
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

179 lines
8.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Device operations with readback and capability checks."""
from .i18n import tr
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: tr('16 Bit'), 2: tr('24 Bit')}.get(self.resolution, '')
frequency = {1: tr('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=tr(STATES.get(self.state, tr('Unbekannt ({value})').format(value=self.state))),
mode_name=tr(MODES.get(self.mode, tr('Unbekannt ({value})').format(value=self.mode))),
codec_name=self.codec_name, audio_quality=self.quality,
transport_name=tr(TRANSPORTS.get(self.transport, tr('Unbekannt ({value})').format(value=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 = tr('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(tr('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(tr('Unbekannter Audiomodus.'))
if mode == 1 and not status.gaming_allowed:
raise ValueError(tr('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(tr('Unbekannter Bluetooth-Transport.'))
if transport is not None and selected in (1, 2) and status.transports and not status.transports & selected:
raise ValueError(tr('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(tr('Codec-Auswahl benötigt verbundene Kopfhörer im Standard-Modus.'))
if codec not in CODECS or not status.codecs & codec:
raise ValueError(tr('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(tr('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(tr('Auracast-Schalter benötigen einen booleschen Wert.'))
if quality is not None and quality not in (0, 1, 2):
raise ValueError(tr('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(tr('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(tr('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(tr('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(tr('Zurücksetzen muss ausdrücklich bestätigt werden; alle Kopplungen werden gelöscht.'))
self.transport.request(C.FACTORY_RESET)