Add native Linux controls and tray menu for BTD 700
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""Independent Linux controls for the Sennheiser BTD 700."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,85 @@
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Native Linux-Steuerung für den BTD 700; ohne Firmware-Updates.')
|
||||
parser.add_argument('--device', help='Steuergerät aus dem Befehl devices, z. B. /dev/hidraw6')
|
||||
parser.add_argument('--background', action='store_true', help='Nur im Infobereich starten')
|
||||
parser.add_argument('--demo', action='store_true', help='Vorführmodus ohne USB-Zugriff')
|
||||
parser.add_argument('--smoke-seconds', type=int, default=0, help=argparse.SUPPRESS)
|
||||
subs = parser.add_subparsers(dest='command')
|
||||
subs.add_parser('devices', help='BTD-700-Steuerschnittstellen auflisten')
|
||||
subs.add_parser('status', help='Aktuellen Status als JSON ausgeben (ohne Passwort)')
|
||||
mode = subs.add_parser('mode', help='Audiomodus wählen')
|
||||
mode.add_argument('value', choices=['standard', 'gaming', 'auracast'])
|
||||
mode.add_argument('--transport', choices=['auto', 'classic', 'le'])
|
||||
codec = subs.add_parser('codec', help='Codec im Standard-Modus wählen')
|
||||
codec.add_argument('value', choices=['sbc', 'aptx', 'adaptive', 'lossless', 'qmap', 'lc3'])
|
||||
subs.add_parser('connect', help='Gekoppelte Kopfhörer verbinden')
|
||||
subs.add_parser('disconnect', help='Kopfhörer trennen')
|
||||
broadcast = subs.add_parser('auracast', help='Auracast-Einstellungen speichern')
|
||||
broadcast.add_argument('--name')
|
||||
broadcast.add_argument('--password', action='store_true', help='Neues Passwort verdeckt abfragen')
|
||||
broadcast.add_argument('--public', choices=['on', 'off'])
|
||||
broadcast.add_argument('--encryption', choices=['on', 'off'])
|
||||
broadcast.add_argument('--quality', choices=['16k', '24k', 'high'])
|
||||
reset = subs.add_parser('reset', help='Alle Kopplungen und Einstellungen löschen')
|
||||
reset.add_argument('--confirm', action='store_true')
|
||||
args = parser.parse_args()
|
||||
if not args.command:
|
||||
try:
|
||||
from .app import run
|
||||
return run(background=args.background, demo=args.demo, device=args.device, smoke_seconds=args.smoke_seconds)
|
||||
except (ImportError, ValueError) as exc:
|
||||
print(f'GTK-Oberfläche nicht verfügbar: {exc}\nAbhängigkeiten: siehe README.md. CLI: ./run.sh status', file=sys.stderr)
|
||||
return 1
|
||||
from .transport import DeviceError, Hidraw, discover
|
||||
from .controller import Controller
|
||||
from .protocol import ProtocolError
|
||||
if args.command == 'devices':
|
||||
from dataclasses import asdict
|
||||
print(json.dumps([asdict(d) for d in discover()], indent=2))
|
||||
return 0
|
||||
transport = None
|
||||
try:
|
||||
if args.demo:
|
||||
from .demo import DemoTransport
|
||||
transport = DemoTransport()
|
||||
else:
|
||||
devices = [d for d in discover() if not args.device or d.path == args.device]
|
||||
if len(devices) != 1:
|
||||
raise DeviceError('Kein eindeutiger BTD 700 gefunden. devices ausführen; bei mehreren Dongles --device angeben.')
|
||||
transport = Hidraw(devices[0])
|
||||
controller = Controller(transport)
|
||||
if args.command == 'mode':
|
||||
controller.set_mode(['standard', 'gaming', 'auracast'].index(args.value),
|
||||
{'auto': 3, 'classic': 1, 'le': 2}.get(args.transport))
|
||||
elif args.command == 'codec':
|
||||
controller.set_codec(1 << ['sbc', 'aptx', 'adaptive', 'lossless', 'qmap', 'lc3'].index(args.value))
|
||||
elif args.command in ('connect', 'disconnect'):
|
||||
controller.set_connection(args.command == 'connect')
|
||||
elif args.command == 'auracast':
|
||||
controller.set_broadcast(name=args.name,
|
||||
password=getpass.getpass('Neues Auracast-Passwort: ') if args.password else None,
|
||||
public=None if args.public is None else args.public == 'on',
|
||||
encrypted=None if args.encryption is None else args.encryption == 'on',
|
||||
quality={'16k': 0, '24k': 1, 'high': 2}.get(args.quality))
|
||||
elif args.command == 'reset':
|
||||
controller.factory_reset(confirmed=args.confirm)
|
||||
print('Zurücksetzen angefordert. Kopfhörer danach neu koppeln.')
|
||||
return 0
|
||||
print(json.dumps(controller.snapshot().public_dict(), indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
except (OSError, DeviceError, ProtocolError, ValueError) as exc:
|
||||
print(f'Fehler: {exc}', file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
if transport:
|
||||
transport.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
+382
@@ -0,0 +1,382 @@
|
||||
"""Native GTK 4 / libadwaita controls and a persistent notification-area menu."""
|
||||
import sys
|
||||
import gi
|
||||
|
||||
gi.require_version('Gtk', '4.0')
|
||||
gi.require_version('Adw', '1')
|
||||
from gi.repository import Adw, Gio, GLib, Gtk
|
||||
from .integration import APP_ID, autostart_path, set_autostart
|
||||
from .protocol import CODECS, MODES, STATES
|
||||
from .worker import Worker
|
||||
|
||||
|
||||
class Application(Adw.Application):
|
||||
def __init__(self, *, background=False, demo=False, device=None, smoke_seconds=0):
|
||||
super().__init__(application_id=APP_ID + ('.Demo' if demo else ''), flags=Gio.ApplicationFlags.DEFAULT_FLAGS)
|
||||
self.background = background
|
||||
self.demo = demo
|
||||
self.device = device
|
||||
self.smoke_seconds = smoke_seconds
|
||||
self.status = None
|
||||
self.window = None
|
||||
self.tray = None
|
||||
self.worker = None
|
||||
self.busy = False
|
||||
self.updating = False
|
||||
self.dirty = False
|
||||
self.form_submitted = False
|
||||
self.last_error = ''
|
||||
self.codec_values = []
|
||||
self.transport_values = []
|
||||
self.received_status = False
|
||||
self.smoke_ok = False
|
||||
|
||||
def do_startup(self):
|
||||
Adw.Application.do_startup(self)
|
||||
self.hold()
|
||||
self._build_window()
|
||||
try:
|
||||
from .tray import Tray
|
||||
self.tray = Tray(self)
|
||||
except (ImportError, ValueError, GLib.Error) as exc:
|
||||
self.last_error = f'Infobereich nicht verfügbar: {exc}'
|
||||
self.worker = Worker(self._on_status, demo=self.demo, device_path=self.device)
|
||||
self.worker.start()
|
||||
self.worker.commands.put(None)
|
||||
if self.smoke_seconds:
|
||||
GLib.timeout_add_seconds(self.smoke_seconds, self._finish_smoke)
|
||||
|
||||
def do_activate(self):
|
||||
if not self.background or not self.window:
|
||||
self.show_window()
|
||||
self.background = False # A second launch opens the existing window.
|
||||
GLib.timeout_add_seconds(4, self._ensure_accessible)
|
||||
|
||||
def do_shutdown(self):
|
||||
if self.worker:
|
||||
self.worker.stop()
|
||||
self.worker.join(timeout=12)
|
||||
if self.tray:
|
||||
self.tray.close()
|
||||
Adw.Application.do_shutdown(self)
|
||||
|
||||
def _ensure_accessible(self):
|
||||
if not self.tray or not self.tray.available:
|
||||
self.show_window()
|
||||
return False
|
||||
|
||||
def _finish_smoke(self):
|
||||
self.smoke_ok = self.received_status and bool(self.tray and self.tray.available)
|
||||
print(f'GUI_SMOKE status={self.received_status} tray={bool(self.tray and self.tray.available)}', flush=True)
|
||||
self.quit()
|
||||
return False
|
||||
|
||||
def _build_window(self):
|
||||
self.window = Adw.ApplicationWindow(application=self, title='BTD 700 Control',
|
||||
default_width=620, default_height=800)
|
||||
self.window.set_size_request(420, 420)
|
||||
self.window.connect('close-request', self._close_window)
|
||||
toolbar = Adw.ToolbarView()
|
||||
header = Adw.HeaderBar()
|
||||
header.set_title_widget(Adw.WindowTitle(title='BTD 700', subtitle='Linux Control' + (' · DEMO' if self.demo else '')))
|
||||
refresh = Gtk.Button(icon_name='view-refresh-symbolic', tooltip_text='Status aktualisieren')
|
||||
refresh.connect('clicked', lambda *_: self.worker.commands.put(None))
|
||||
header.pack_start(refresh)
|
||||
self.refresh_button = refresh
|
||||
quit_button = Gtk.Button(icon_name='application-exit-symbolic', tooltip_text='App vollständig beenden')
|
||||
quit_button.connect('clicked', lambda *_: self.quit())
|
||||
header.pack_end(quit_button)
|
||||
toolbar.add_top_bar(header)
|
||||
self.toast = Adw.ToastOverlay()
|
||||
scroll = Gtk.ScrolledWindow(hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||
clamp = Adw.Clamp(maximum_size=600, tightening_threshold=500)
|
||||
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=22,
|
||||
margin_top=24, margin_bottom=28, margin_start=20, margin_end=20)
|
||||
clamp.set_child(content)
|
||||
scroll.set_child(clamp)
|
||||
self.toast.set_child(scroll)
|
||||
toolbar.set_content(self.toast)
|
||||
self.window.set_content(toolbar)
|
||||
self.scroll = scroll
|
||||
|
||||
heading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||
icon = Gtk.Image(icon_name='audio-headphones-symbolic', pixel_size=48)
|
||||
heading.append(icon)
|
||||
self.codec_label = Gtk.Label(label='BTD 700 verbinden')
|
||||
self.codec_label.add_css_class('title-1')
|
||||
heading.append(self.codec_label)
|
||||
self.state_label = Gtk.Label(label='Suche nach deinem USB-Dongle …', wrap=True)
|
||||
self.state_label.add_css_class('dim-label')
|
||||
heading.append(self.state_label)
|
||||
self.quality_label = Gtk.Label(label='')
|
||||
self.quality_label.add_css_class('caption')
|
||||
heading.append(self.quality_label)
|
||||
content.append(heading)
|
||||
|
||||
self.error_label = Gtk.Label(wrap=True, xalign=0)
|
||||
self.error_label.add_css_class('error')
|
||||
self.error_label.set_visible(False)
|
||||
content.append(self.error_label)
|
||||
self.device_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=22, sensitive=False)
|
||||
content.append(self.device_box)
|
||||
|
||||
mode_group = Adw.PreferencesGroup(title='Audiomodus', description='Der Modus wird direkt am Dongle umgeschaltet.')
|
||||
mode_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0, homogeneous=True)
|
||||
mode_box.add_css_class('linked')
|
||||
self.mode_buttons = []
|
||||
for mode, label in MODES.items():
|
||||
button = Gtk.ToggleButton(label=label)
|
||||
if self.mode_buttons:
|
||||
button.set_group(self.mode_buttons[0])
|
||||
button.connect('clicked', lambda b, v=mode: self._mode_clicked(b, v))
|
||||
mode_box.append(button)
|
||||
self.mode_buttons.append(button)
|
||||
mode_group.add(mode_box)
|
||||
self.device_box.append(mode_group)
|
||||
|
||||
audio = Adw.PreferencesGroup(title='Kopfhörer')
|
||||
self.codec_row = Adw.ComboRow(title='Codec')
|
||||
self.codec_row.connect('notify::selected', self._codec_selected)
|
||||
audio.add(self.codec_row)
|
||||
self.transport_row = Adw.ComboRow(title='Bluetooth-Transport')
|
||||
self.transport_row.connect('notify::selected', self._transport_selected)
|
||||
audio.add(self.transport_row)
|
||||
self.connection_row = Adw.ActionRow(title='Verbindung')
|
||||
self.connection_button = Gtk.Button(label='Verbinden', valign=Gtk.Align.CENTER)
|
||||
self.connection_button.connect('clicked', lambda *_: self.perform('set_connection', self.status.state < 2) if self.status else None)
|
||||
self.connection_row.add_suffix(self.connection_button)
|
||||
audio.add(self.connection_row)
|
||||
self.device_box.append(audio)
|
||||
|
||||
self.auracast_group = Adw.PreferencesGroup(title='Auracast',
|
||||
description='Der Audiomodus „Auracast“ startet die Übertragung an mehrere Empfänger.')
|
||||
self.name_entry = Adw.EntryRow(title='Name der Übertragung')
|
||||
self.name_entry.connect('changed', self._form_changed)
|
||||
self.auracast_group.add(self.name_entry)
|
||||
self.public_row = Adw.SwitchRow(title='Öffentlich auffindbar',
|
||||
subtitle='Übertragung in der Auracast-Suche anzeigen')
|
||||
self.public_row.connect('notify::active', self._form_changed)
|
||||
self.auracast_group.add(self.public_row)
|
||||
self.encryption_row = Adw.SwitchRow(title='Passwortschutz')
|
||||
self.encryption_row.connect('notify::active', self._form_changed)
|
||||
self.auracast_group.add(self.encryption_row)
|
||||
self.password_entry = Adw.PasswordEntryRow(title='Neues Passwort')
|
||||
self.password_entry.set_tooltip_text('4–16 Buchstaben oder Ziffern. Leer lassen behält das bisherige Passwort.')
|
||||
self.password_entry.connect('changed', self._password_changed)
|
||||
self.auracast_group.add(self.password_entry)
|
||||
self.broadcast_quality = Adw.ComboRow(title='Übertragungsqualität',
|
||||
model=Gtk.StringList.new(['Standard · 16 kHz', 'Standard · 24 kHz', 'Hohe Qualität']))
|
||||
self.broadcast_quality.connect('notify::selected', self._form_changed)
|
||||
self.auracast_group.add(self.broadcast_quality)
|
||||
actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8, halign=Gtk.Align.END, margin_top=12)
|
||||
self.discard_button = Gtk.Button(label='Verwerfen', sensitive=False)
|
||||
self.discard_button.connect('clicked', self._discard)
|
||||
self.save_button = Gtk.Button(label='Speichern', sensitive=False)
|
||||
self.save_button.add_css_class('suggested-action')
|
||||
self.save_button.connect('clicked', self._save_broadcast)
|
||||
actions.append(self.discard_button)
|
||||
actions.append(self.save_button)
|
||||
self.auracast_group.add(actions)
|
||||
hint = Gtk.Label(label='Passwort leer lassen, um das bisherige zu behalten.',
|
||||
wrap=True, xalign=0, margin_top=8)
|
||||
hint.add_css_class('caption')
|
||||
hint.add_css_class('dim-label')
|
||||
self.auracast_group.add(hint)
|
||||
self.device_box.append(self.auracast_group)
|
||||
|
||||
integration = Adw.PreferencesGroup(title='App')
|
||||
self.tray_row = Adw.ActionRow(title='Im Infobereich weiterlaufen', subtitle='Infobereich wird verbunden …')
|
||||
integration.add(self.tray_row)
|
||||
autostart = Adw.SwitchRow(title='Beim Anmelden starten', subtitle='Startet unauffällig im Infobereich',
|
||||
active=autostart_path().exists())
|
||||
autostart.set_sensitive(not self.demo)
|
||||
autostart.connect('notify::active', self._autostart_changed)
|
||||
integration.add(autostart)
|
||||
content.append(integration)
|
||||
self.firmware_label = Gtk.Label(label='Unabhängige Steuerungs-App · Keine Firmware-Updates', wrap=True)
|
||||
self.firmware_label.add_css_class('caption')
|
||||
self.firmware_label.add_css_class('dim-label')
|
||||
content.append(self.firmware_label)
|
||||
reset = Gtk.Button(label='Dongle auf Werkseinstellungen zurücksetzen …', halign=Gtk.Align.CENTER)
|
||||
reset.add_css_class('flat')
|
||||
reset.connect('clicked', lambda *_: self.confirm_reset())
|
||||
self.reset_button = reset
|
||||
content.append(reset)
|
||||
|
||||
def show_window(self, section=None):
|
||||
self.window.present()
|
||||
if section == 'auracast':
|
||||
self.name_entry.grab_focus()
|
||||
elif section == 'password':
|
||||
self.password_entry.grab_focus()
|
||||
|
||||
def _close_window(self, *_):
|
||||
if self.tray and self.tray.available:
|
||||
self.window.set_focus(None)
|
||||
self.window.set_visible(False)
|
||||
else:
|
||||
self.quit()
|
||||
return True
|
||||
|
||||
def tray_changed(self):
|
||||
available = bool(self.tray and self.tray.available)
|
||||
self.tray_row.set_subtitle('Beim Schließen bleibt das Symbol in der oberen Leiste.' if available else
|
||||
'Kein Infobereich verfügbar; Schließen beendet die App.')
|
||||
|
||||
def perform(self, method, *args, **kwargs):
|
||||
if self.busy or not self.status:
|
||||
return
|
||||
self.refresh_button.grab_focus()
|
||||
self.busy = True
|
||||
self.last_error = ''
|
||||
self.worker.submit(method, args, kwargs)
|
||||
self._render()
|
||||
|
||||
def _on_status(self, status, error, completed):
|
||||
self.status = status
|
||||
self.received_status |= status is not None
|
||||
if completed:
|
||||
self.busy = False
|
||||
self.last_error = error
|
||||
if self.form_submitted:
|
||||
if not error:
|
||||
self.dirty = False
|
||||
self.updating = True
|
||||
self.password_entry.set_text('')
|
||||
self.updating = False
|
||||
self.form_submitted = False
|
||||
self.toast.add_toast(Adw.Toast.new(error or 'Vom Dongle bestätigt'))
|
||||
elif not status:
|
||||
self.last_error = error
|
||||
elif self.last_error.startswith(('Kein BTD 700', 'USB-Zugriff fehlt', '[Errno')):
|
||||
self.last_error = ''
|
||||
self._render()
|
||||
return False
|
||||
|
||||
def _render(self):
|
||||
self.updating = True
|
||||
s = self.status
|
||||
self.device_box.set_sensitive(s is not None and not self.busy)
|
||||
self.reset_button.set_sensitive(s is not None and not self.busy)
|
||||
self.error_label.set_label(self.last_error)
|
||||
self.error_label.set_visible(bool(self.last_error))
|
||||
if s:
|
||||
self.codec_label.set_label('Auracast' if s.mode == 2 else s.codec_name)
|
||||
self.state_label.set_label('Einstellung wird übernommen …' if self.busy else STATES.get(s.state, f'Status {s.state}'))
|
||||
self.quality_label.set_label(s.quality)
|
||||
for mode, button in enumerate(self.mode_buttons):
|
||||
button.set_active(mode == s.mode)
|
||||
button.set_sensitive(mode != 1 or s.gaming_allowed)
|
||||
values = [v for v in CODECS if v & s.codecs]
|
||||
if values != self.codec_values:
|
||||
self.codec_values = values
|
||||
self.codec_row.set_model(Gtk.StringList.new([CODECS[v] for v in values]))
|
||||
self.codec_row.set_selected(next((i for i, v in enumerate(values) if v & s.codec), Gtk.INVALID_LIST_POSITION))
|
||||
self.codec_row.set_sensitive(s.mode == 0 and s.state >= 2 and len(values) > 1)
|
||||
self.codec_row.set_subtitle('Auswahl im Standard-Modus' if s.mode != 0 else 'Vom Dongle angebotene Codecs')
|
||||
values = [3] + [v for v in (1, 2) if s.transports & v]
|
||||
if s.transport in (1, 2) and s.transport not in values:
|
||||
values.append(s.transport)
|
||||
if values != self.transport_values:
|
||||
self.transport_values = values
|
||||
names = {3: 'Automatisch', 1: 'Bluetooth Classic', 2: 'LE Audio'}
|
||||
self.transport_row.set_model(Gtk.StringList.new([names[v] for v in values]))
|
||||
self.transport_row.set_selected(values.index(s.transport) if s.transport in values else Gtk.INVALID_LIST_POSITION)
|
||||
self.transport_row.set_sensitive(s.mode == 0)
|
||||
self.connection_row.set_subtitle(STATES.get(s.state, 'Unbekannt'))
|
||||
self.connection_button.set_label('Trennen' if s.state >= 2 else 'Verbinden')
|
||||
self.connection_button.set_sensitive(s.mode != 2)
|
||||
if not self.dirty:
|
||||
self.name_entry.set_text(s.broadcast_name)
|
||||
self.public_row.set_active(bool(s.broadcast_public))
|
||||
self.encryption_row.set_active(bool(s.broadcast_encrypted))
|
||||
self.broadcast_quality.set_selected(s.broadcast_quality)
|
||||
self.firmware_label.set_label(f'Firmware {s.firmware} · Unabhängige App · Keine Firmware-Updates')
|
||||
else:
|
||||
self.codec_label.set_label('BTD 700 verbinden')
|
||||
self.state_label.set_label('Warte auf den USB-Dongle …')
|
||||
self.quality_label.set_label('')
|
||||
self.save_button.set_sensitive(self.dirty and not self.busy)
|
||||
self.discard_button.set_sensitive(self.dirty and not self.busy)
|
||||
self.updating = False
|
||||
if self.tray:
|
||||
self.tray.update(s, self.busy, self.last_error)
|
||||
|
||||
def _mode_clicked(self, button, mode):
|
||||
if not self.updating and button.get_active() and self.status and mode != self.status.mode:
|
||||
self.perform('set_mode', mode)
|
||||
|
||||
def _codec_selected(self, row, _):
|
||||
i = row.get_selected()
|
||||
if not self.updating and i < len(self.codec_values):
|
||||
self.perform('set_codec', self.codec_values[i])
|
||||
|
||||
def _transport_selected(self, row, _):
|
||||
i = row.get_selected()
|
||||
if not self.updating and self.status and i < len(self.transport_values):
|
||||
self.perform('set_mode', self.status.mode, transport=self.transport_values[i])
|
||||
|
||||
def _form_changed(self, *_):
|
||||
if not self.updating:
|
||||
self.dirty = True
|
||||
self.save_button.set_sensitive(not self.busy)
|
||||
self.discard_button.set_sensitive(not self.busy)
|
||||
|
||||
def _password_changed(self, *_):
|
||||
if not self.updating and self.password_entry.get_text():
|
||||
self.encryption_row.set_active(True)
|
||||
self._form_changed()
|
||||
|
||||
def _discard(self, *_):
|
||||
self.dirty = False
|
||||
self.updating = True
|
||||
self.password_entry.set_text('')
|
||||
self.updating = False
|
||||
self._render()
|
||||
|
||||
def _save_broadcast(self, *_):
|
||||
if not self.status:
|
||||
return
|
||||
name = self.name_entry.get_text()
|
||||
self.form_submitted = True
|
||||
self.perform('set_broadcast',
|
||||
name=name if name != self.status.broadcast_name else None,
|
||||
password=self.password_entry.get_text() or None,
|
||||
public=self.public_row.get_active(), quality=self.broadcast_quality.get_selected(),
|
||||
encrypted=self.encryption_row.get_active())
|
||||
|
||||
def toggle_encryption(self):
|
||||
if self.status and self.status.broadcast_encrypted:
|
||||
self.perform('set_broadcast', encrypted=False)
|
||||
else:
|
||||
self.show_window('password')
|
||||
self.encryption_row.set_active(True)
|
||||
|
||||
def confirm_reset(self):
|
||||
self.show_window()
|
||||
if not self.status or self.busy:
|
||||
return
|
||||
dialog = Adw.AlertDialog(heading='BTD 700 zurücksetzen?',
|
||||
body='Alle Kopplungen und gespeicherten Einstellungen werden gelöscht. Danach musst du die Kopfhörer erneut koppeln.')
|
||||
dialog.add_response('cancel', 'Abbrechen')
|
||||
dialog.add_response('reset', 'Zurücksetzen')
|
||||
dialog.set_response_appearance('reset', Adw.ResponseAppearance.DESTRUCTIVE)
|
||||
dialog.set_default_response('cancel')
|
||||
dialog.set_close_response('cancel')
|
||||
dialog.connect('response', lambda _, response: self.perform('factory_reset', confirmed=True) if response == 'reset' else None)
|
||||
dialog.present(self.window)
|
||||
|
||||
def _autostart_changed(self, row, _):
|
||||
try:
|
||||
set_autostart(row.get_active())
|
||||
except OSError as exc:
|
||||
self.last_error = f'Autostart konnte nicht gespeichert werden: {exc}'
|
||||
self._render()
|
||||
|
||||
|
||||
def run(**kwargs):
|
||||
app = Application(**kwargs)
|
||||
result = app.run([sys.argv[0]])
|
||||
if kwargs.get('smoke_seconds') and not app.smoke_ok:
|
||||
return 1
|
||||
return result
|
||||
@@ -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 4–16 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 4–16 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)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Explicit, isolated demonstration device; never opens USB."""
|
||||
from .protocol import Command as C, encode
|
||||
|
||||
|
||||
class DemoTransport:
|
||||
def __init__(self):
|
||||
self.events = {}
|
||||
self.values = {C.GET_STATE: b'\x03', C.GET_MODE: b'\0\3\1',
|
||||
C.GET_CODECS: b'\x0f', C.GET_CODEC: b'\4', C.GET_LE_STATE: b'\1',
|
||||
C.GET_QUALITY: b'\2\2', C.GET_TRANSPORTS: b'\1',
|
||||
C.GET_BROADCAST: b'\1\2\0', C.GET_NAME: b'BTD700 Demo'+bytes(21),
|
||||
C.GET_KEY: bytes(16), C.GET_VERSION: b'\3\x0b\0'}
|
||||
|
||||
def request(self, command, payload=b''):
|
||||
encode(command, payload)
|
||||
if command in self.values:
|
||||
return self.values[command]
|
||||
if command == C.SET_MODE:
|
||||
self.values[C.GET_MODE] = payload + b'\1'
|
||||
elif command == C.SET_CODEC:
|
||||
self.values[C.GET_CODEC] = payload
|
||||
elif command == C.SET_CONNECTION:
|
||||
self.values[C.GET_STATE] = b'\2' if payload[0] else b'\1'
|
||||
elif command == C.SET_BROADCAST:
|
||||
self.values[C.GET_BROADCAST] = payload
|
||||
elif command == C.SET_NAME:
|
||||
self.values[C.GET_NAME] = (payload or b'BTD700 Demo').ljust(32, b'\0')
|
||||
elif command == C.SET_KEY:
|
||||
self.values[C.GET_KEY] = payload.ljust(16, b'\0')
|
||||
elif command == C.FACTORY_RESET:
|
||||
self.__init__()
|
||||
else:
|
||||
raise TimeoutError('Nicht unterstützte Demo-Abfrage')
|
||||
return b''
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
@@ -0,0 +1,37 @@
|
||||
"""User-scoped desktop integration."""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
APP_ID = 'io.github.btd700linux.Control'
|
||||
|
||||
|
||||
def desktop_entry(*, background=False):
|
||||
checkout = (ROOT / 'run.sh').is_file()
|
||||
path = str(ROOT / 'run.sh') if checkout else sys.executable
|
||||
module_args = '' if checkout else ' -m btd700'
|
||||
icon = str(ROOT / 'packaging/btd700-control.svg') if checkout else 'audio-headphones'
|
||||
# Desktop Entry Exec quoting is not shell quoting.
|
||||
escaped = path.replace('\\', '\\\\').replace('"', '\\"').replace('`', '\\`').replace('$', '\\$').replace('%', '%%')
|
||||
return ('[Desktop Entry]\nType=Application\nName=BTD 700 Control\n'
|
||||
'Comment=Linux-Steuerung für den Sennheiser BTD 700\n'
|
||||
f'Exec="{escaped}"{module_args}' + (' --background' if background else '') + '\n'
|
||||
f'Icon={icon}\nTerminal=false\n'
|
||||
'Categories=AudioVideo;Audio;\n'
|
||||
'Keywords=Sennheiser;Bluetooth;aptX;Auracast;Dongle;\n'
|
||||
f'StartupWMClass={APP_ID}\n')
|
||||
|
||||
|
||||
def autostart_path():
|
||||
base = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config'))
|
||||
return base / 'autostart' / f'{APP_ID}.desktop'
|
||||
|
||||
|
||||
def set_autostart(enabled):
|
||||
path = autostart_path()
|
||||
if enabled:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(desktop_entry(background=True))
|
||||
else:
|
||||
path.unlink(missing_ok=True)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""BTD 700 control protocol. No updater transport or arbitrary commands."""
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
import re
|
||||
|
||||
REPORT_ID = 0x34
|
||||
REPORT_SIZE = 64
|
||||
|
||||
|
||||
class ProtocolError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Command(IntEnum):
|
||||
GET_MODE = 0x01
|
||||
SET_MODE = 0x02
|
||||
GET_CODECS = 0x03
|
||||
SET_CODEC = 0x04
|
||||
GET_CODEC = 0x05
|
||||
GET_STATE = 0x06
|
||||
GET_LE_STATE = 0x07
|
||||
GET_QUALITY = 0x08
|
||||
GET_BROADCAST = 0x09
|
||||
SET_BROADCAST = 0x0A
|
||||
GET_KEY = 0x0B
|
||||
SET_KEY = 0x0C
|
||||
GET_NAME = 0x0D
|
||||
SET_NAME = 0x0E
|
||||
GET_VERSION = 0x12
|
||||
FACTORY_RESET = 0x13
|
||||
SET_CONNECTION = 0x14
|
||||
GET_TRANSPORTS = 0x15
|
||||
GET_GAMING = 0x17
|
||||
|
||||
|
||||
READ_COMMANDS = frozenset(c for c in Command if c.name.startswith('GET_'))
|
||||
EVENTS = frozenset((2, 3, 4, 15, 16, 17, 22, 23))
|
||||
CODECS = {1: 'SBC', 2: 'aptX Classic', 4: 'aptX Adaptive', 8: 'aptX Lossless',
|
||||
16: 'aptX Lite (QMAP)', 32: 'LC3'}
|
||||
MODES = {0: 'Standard', 1: 'Gaming', 2: 'Auracast'}
|
||||
TRANSPORTS = {0: 'Nicht verbunden', 1: 'Bluetooth Classic', 2: 'LE Audio', 3: 'Automatisch'}
|
||||
STATES = {0: 'Bereit', 1: 'Kopfhörer getrennt', 2: 'Kopfhörer verbunden',
|
||||
3: 'Musikwiedergabe', 4: 'Sprachanruf'}
|
||||
MIN_LENGTHS = {Command.GET_MODE: 2, Command.GET_CODECS: 1, Command.GET_CODEC: 1,
|
||||
Command.GET_STATE: 1, Command.GET_LE_STATE: 1, Command.GET_QUALITY: 2,
|
||||
Command.GET_BROADCAST: 3, Command.GET_VERSION: 3,
|
||||
Command.GET_TRANSPORTS: 1, Command.GET_GAMING: 1}
|
||||
|
||||
|
||||
def validate_text(value: str, *, key: bool = False) -> bytes:
|
||||
"""Match the original app's 4–16 ASCII character input bounds; empty resets."""
|
||||
pattern = r'[A-Za-z0-9]{4,16}' if key else r'[A-Za-z0-9 ]{4,16}'
|
||||
if value and (not re.fullmatch(pattern, value) or value != value.strip()):
|
||||
what = 'Passwort: 4–16 Buchstaben oder Ziffern.' if key else (
|
||||
'Name: 4–16 Buchstaben, Ziffern oder innere Leerzeichen.')
|
||||
raise ValueError(what)
|
||||
return value.encode('ascii')
|
||||
|
||||
|
||||
def encode(command: Command, payload: bytes = b'') -> bytes:
|
||||
try:
|
||||
command = Command(command)
|
||||
except ValueError as exc:
|
||||
raise ValueError('Unbekannter Steuerbefehl; kein Zugriff auf Update-Befehle.') from exc
|
||||
if command in READ_COMMANDS or command == Command.FACTORY_RESET:
|
||||
valid = not payload
|
||||
elif command == Command.SET_MODE:
|
||||
valid = len(payload) == 2 and payload[0] in MODES and payload[1] in (1, 2, 3)
|
||||
elif command == Command.SET_CODEC:
|
||||
valid = len(payload) == 1 and payload[0] in CODECS
|
||||
elif command == Command.SET_CONNECTION:
|
||||
valid = payload in (b'\0', b'\1')
|
||||
elif command == Command.SET_BROADCAST:
|
||||
valid = len(payload) == 3 and payload[0] in (0, 1) and payload[1] in (0, 1, 2) and payload[2] in (0, 1)
|
||||
elif command in (Command.SET_NAME, Command.SET_KEY):
|
||||
# Existing device names can contain an underscore. New user input is
|
||||
# validated more narrowly by validate_text, preserving originals on undo.
|
||||
valid = len(payload) <= 16 and all(32 <= b <= 126 for b in payload)
|
||||
else:
|
||||
valid = False
|
||||
if not valid:
|
||||
raise ValueError(f'Ungültige Parameter für {command.name}.')
|
||||
return bytes((REPORT_ID, 0xFE, command, len(payload))) + payload + bytes(60 - len(payload))
|
||||
|
||||
|
||||
def acknowledge(event: int) -> bytes:
|
||||
if event not in EVENTS:
|
||||
raise ProtocolError(f'Unbekannte Benachrichtigung: {event:#x}')
|
||||
return bytes((REPORT_ID, 0xFD, event, 0)) + bytes(60)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Message:
|
||||
kind: int
|
||||
command: int
|
||||
payload: bytes
|
||||
|
||||
|
||||
def decode(report: bytes) -> Message | None:
|
||||
if not report or report[0] != REPORT_ID:
|
||||
return None # Media keys share the same interface.
|
||||
if len(report) < 4 or report[1] not in (0xFC, 0xFF) or report[3] > 60 or len(report) < 4 + report[3]:
|
||||
raise ProtocolError('Ungültige Antwort des Dongles.')
|
||||
return Message(report[1], report[2], report[4:4 + report[3]])
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Linux hidraw access, limited to the BTD 700 control interface."""
|
||||
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('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('Der Dongle ist bereits in einer anderen Instanz geöffnet.') from exc
|
||||
except PermissionError as exc:
|
||||
self.close()
|
||||
raise DeviceError('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('Dongle ist nicht geöffnet.')
|
||||
if not select.select([], [self.fd], [], self.timeout)[1]:
|
||||
raise TimeoutError('USB-Schreibzugriff hat nicht geantwortet.')
|
||||
if os.write(self.fd, report) != len(report):
|
||||
raise DeviceError('Unvollständiger USB-Schreibvorgang.')
|
||||
|
||||
def _receive(self):
|
||||
report = os.read(self.fd, 1024)
|
||||
if not report:
|
||||
raise DeviceError('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(f'Unvollständige Antwort auf {command.name}.')
|
||||
return message.payload
|
||||
raise TimeoutError(f'Der Dongle antwortet nicht auf {command.name}.')
|
||||
except (OSError, ProtocolError):
|
||||
self.close()
|
||||
raise
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
"""StatusNotifierItem + libdbusmenu, compatible with GNOME AppIndicator and KDE."""
|
||||
import gi
|
||||
from gi.repository import Gio, GLib
|
||||
from .protocol import CODECS, MODES, STATES
|
||||
|
||||
gi.require_version('Dbusmenu', '0.4')
|
||||
from gi.repository import Dbusmenu
|
||||
|
||||
INTERFACE = 'org.kde.StatusNotifierItem'
|
||||
XML = '''<node><interface name="org.kde.StatusNotifierItem">
|
||||
<property name="Category" type="s" access="read"/>
|
||||
<property name="Id" type="s" access="read"/>
|
||||
<property name="Title" type="s" access="read"/>
|
||||
<property name="Status" type="s" access="read"/>
|
||||
<property name="WindowId" type="u" access="read"/>
|
||||
<property name="IconName" type="s" access="read"/>
|
||||
<property name="IconThemePath" type="s" access="read"/>
|
||||
<property name="IconPixmap" type="a(iiay)" access="read"/>
|
||||
<property name="OverlayIconName" type="s" access="read"/>
|
||||
<property name="OverlayIconPixmap" type="a(iiay)" access="read"/>
|
||||
<property name="AttentionIconName" type="s" access="read"/>
|
||||
<property name="AttentionIconPixmap" type="a(iiay)" access="read"/>
|
||||
<property name="AttentionMovieName" type="s" access="read"/>
|
||||
<property name="ToolTip" type="(sa(iiay)ss)" access="read"/>
|
||||
<property name="ItemIsMenu" type="b" access="read"/>
|
||||
<property name="Menu" type="o" access="read"/>
|
||||
<method name="Activate"><arg type="i" direction="in"/><arg type="i" direction="in"/></method>
|
||||
<method name="SecondaryActivate"><arg type="i" direction="in"/><arg type="i" direction="in"/></method>
|
||||
<method name="ContextMenu"><arg type="i" direction="in"/><arg type="i" direction="in"/></method>
|
||||
<method name="Scroll"><arg type="i" direction="in"/><arg type="s" direction="in"/></method>
|
||||
<signal name="NewTitle"/><signal name="NewIcon"/><signal name="NewToolTip"/>
|
||||
<signal name="NewStatus"><arg type="s"/></signal>
|
||||
</interface></node>'''
|
||||
|
||||
|
||||
class Tray:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
self.status = None
|
||||
self.available = False
|
||||
self._signature = None
|
||||
self.bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||||
self.registration = self.bus.register_object('/StatusNotifierItem',
|
||||
Gio.DBusNodeInfo.new_for_xml(XML).interfaces[0], self._method, self._property, None)
|
||||
self.server = Dbusmenu.Server.new('/Menu')
|
||||
self.watch = Gio.bus_watch_name_on_connection(self.bus, 'org.kde.StatusNotifierWatcher',
|
||||
Gio.BusNameWatcherFlags.NONE, self._watcher_appeared, self._watcher_lost)
|
||||
self.update(None, False)
|
||||
|
||||
def _watcher_appeared(self, connection, _name, _owner):
|
||||
def done(bus, result):
|
||||
try:
|
||||
bus.call_finish(result)
|
||||
self.available = True
|
||||
except GLib.Error:
|
||||
self.available = False
|
||||
self.app.tray_changed()
|
||||
connection.call('org.kde.StatusNotifierWatcher', '/StatusNotifierWatcher',
|
||||
'org.kde.StatusNotifierWatcher', 'RegisterStatusNotifierItem',
|
||||
GLib.Variant('(s)', (connection.get_unique_name(),)), None,
|
||||
Gio.DBusCallFlags.NONE, 3000, None, done)
|
||||
|
||||
def _watcher_lost(self, *_):
|
||||
self.available = False
|
||||
self.app.tray_changed()
|
||||
|
||||
def _property(self, _bus, _sender, _path, _iface, name):
|
||||
tooltip = 'BTD 700 anschließen'
|
||||
if self.status:
|
||||
tooltip = f'{MODES.get(self.status.mode, "—")} · {self.status.codec_name} · {self.status.quality}'
|
||||
props = {
|
||||
'Category': ('s', 'Hardware'), 'Id': ('s', 'btd700-control-demo' if self.app.demo else 'btd700-control'),
|
||||
'Title': ('s', 'BTD 700 Control · DEMO' if self.app.demo else 'BTD 700 Control'), 'Status': ('s', 'Active'),
|
||||
'WindowId': ('u', 0), 'IconName': ('s', 'audio-headphones-symbolic'),
|
||||
'IconThemePath': ('s', ''), 'IconPixmap': ('a(iiay)', []),
|
||||
'OverlayIconName': ('s', ''), 'OverlayIconPixmap': ('a(iiay)', []),
|
||||
'AttentionIconName': ('s', ''), 'AttentionIconPixmap': ('a(iiay)', []),
|
||||
'AttentionMovieName': ('s', ''),
|
||||
'ToolTip': ('(sa(iiay)ss)', ('audio-headphones-symbolic', [], 'BTD 700', tooltip)),
|
||||
'ItemIsMenu': ('b', True), 'Menu': ('o', '/Menu')}
|
||||
if name in props:
|
||||
return GLib.Variant(*props[name])
|
||||
return None
|
||||
|
||||
def _method(self, _bus, _sender, _path, _iface, method, _params, invocation):
|
||||
if method in ('Activate', 'SecondaryActivate', 'ContextMenu'):
|
||||
self.app.show_window()
|
||||
invocation.return_value(None)
|
||||
|
||||
@staticmethod
|
||||
def _item(label, action=None, *, enabled=True, checked=None, radio=False):
|
||||
item = Dbusmenu.Menuitem.new()
|
||||
item.property_set('label', label)
|
||||
item.property_set_bool('visible', True)
|
||||
item.property_set_bool('enabled', enabled)
|
||||
if checked is not None:
|
||||
item.property_set('toggle-type', 'radio' if radio else 'checkmark')
|
||||
item.property_set_int('toggle-state', int(checked))
|
||||
if action:
|
||||
item.connect('item-activated', lambda *_: action())
|
||||
return item
|
||||
|
||||
def _submenu(self, root, label):
|
||||
item = self._item(label)
|
||||
item.property_set('children-display', 'submenu')
|
||||
root.child_append(item)
|
||||
return item
|
||||
|
||||
def update(self, status, busy, error=''):
|
||||
signature = (repr(status), busy, error)
|
||||
if signature == self._signature:
|
||||
return
|
||||
self._signature = signature
|
||||
self.status = status
|
||||
root = Dbusmenu.Menuitem.new()
|
||||
add = root.child_append
|
||||
add(self._item('BTD 700 Control · DEMO' if self.app.demo else 'BTD 700 Control', self.app.show_window))
|
||||
if status:
|
||||
add(self._item(f'{status.codec_name} · {status.quality}', enabled=False))
|
||||
add(self._item(STATES.get(status.state, 'Unbekannter Status'), enabled=False))
|
||||
modes = self._submenu(root, 'Audiomodus')
|
||||
for value, label in MODES.items():
|
||||
modes.child_append(self._item(label,
|
||||
lambda v=value: self.app.perform('set_mode', v),
|
||||
enabled=not busy and (value != 1 or status.gaming_allowed),
|
||||
checked=status.mode == value, radio=True))
|
||||
codecs = self._submenu(root, 'Codec')
|
||||
for value, label in CODECS.items():
|
||||
if status.codecs & value:
|
||||
codecs.child_append(self._item(label,
|
||||
lambda v=value: self.app.perform('set_codec', v),
|
||||
enabled=not busy and status.mode == 0 and status.state >= 2,
|
||||
checked=bool(status.codec & value), radio=True))
|
||||
if not codecs.get_children():
|
||||
codecs.child_append(self._item('Keine verbundenen Kopfhörer', enabled=False))
|
||||
transports = self._submenu(root, 'Bluetooth-Transport')
|
||||
for value, label in ((3, 'Automatisch'), (1, 'Bluetooth Classic'), (2, 'LE Audio')):
|
||||
transports.child_append(self._item(label,
|
||||
lambda v=value: self.app.perform('set_mode', status.mode, transport=v),
|
||||
enabled=not busy and status.mode == 0 and (value == 3 or bool(status.transports & value)),
|
||||
checked=status.transport == value, radio=True))
|
||||
auracast = self._submenu(root, 'Auracast')
|
||||
auracast.child_append(self._item('Name und Passwort …', lambda: self.app.show_window('auracast')))
|
||||
auracast.child_append(self._item('Öffentlich auffindbar',
|
||||
lambda: self.app.perform('set_broadcast', public=not status.broadcast_public),
|
||||
enabled=not busy, checked=bool(status.broadcast_public)))
|
||||
auracast.child_append(self._item('Passwortschutz',
|
||||
lambda: self.app.toggle_encryption(), enabled=not busy,
|
||||
checked=bool(status.broadcast_encrypted)))
|
||||
quality = self._submenu(auracast, 'Übertragungsqualität')
|
||||
for value, label in enumerate(('Standard · 16 kHz', 'Standard · 24 kHz', 'Hohe Qualität')):
|
||||
quality.child_append(self._item(label,
|
||||
lambda v=value: self.app.perform('set_broadcast', quality=v),
|
||||
enabled=not busy, checked=status.broadcast_quality == value, radio=True))
|
||||
add(self._item('Kopfhörer trennen' if status.state >= 2 else 'Kopfhörer verbinden',
|
||||
lambda: self.app.perform('set_connection', status.state < 2), enabled=not busy and status.mode != 2))
|
||||
add(self._item('Werkseinstellungen …', self.app.confirm_reset, enabled=not busy))
|
||||
else:
|
||||
add(self._item('Dongle nicht verbunden' if not error else 'USB-Zugriff prüfen – Fenster öffnen',
|
||||
self.app.show_window))
|
||||
if busy:
|
||||
add(self._item('Einstellung wird übernommen …', enabled=False))
|
||||
if error:
|
||||
add(self._item('Fehler anzeigen …', self.app.show_window))
|
||||
separator = Dbusmenu.Menuitem.new()
|
||||
separator.property_set('type', 'separator')
|
||||
add(separator)
|
||||
add(self._item('Fenster öffnen', self.app.show_window))
|
||||
add(self._item('Beenden', self.app.quit))
|
||||
self.root = root # Keep Python signal closures alive.
|
||||
self.server.set_root(root)
|
||||
self.bus.emit_signal(None, '/StatusNotifierItem', INTERFACE, 'NewToolTip', None)
|
||||
|
||||
def close(self):
|
||||
Gio.bus_unwatch_name(self.watch)
|
||||
self.bus.unregister_object(self.registration)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""One thread owns the USB handle; the GTK main loop never waits for USB."""
|
||||
import queue
|
||||
import threading
|
||||
from gi.repository import GLib
|
||||
from .controller import Controller
|
||||
from .transport import DeviceError, Hidraw, discover
|
||||
|
||||
|
||||
class Worker(threading.Thread):
|
||||
def __init__(self, callback, *, device_path=None, demo=False):
|
||||
super().__init__(name='btd700-usb', daemon=True)
|
||||
self.callback = callback
|
||||
self.device_path = device_path
|
||||
self.demo = demo
|
||||
self.commands = queue.Queue()
|
||||
self.stopping = threading.Event()
|
||||
self.controller = None
|
||||
self.transport = None
|
||||
|
||||
def submit(self, method, args, kwargs):
|
||||
self.commands.put((method, args, kwargs))
|
||||
|
||||
def stop(self):
|
||||
self.stopping.set()
|
||||
self.commands.put(None)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
while not self.stopping.is_set():
|
||||
try:
|
||||
action = self.commands.get(timeout=2)
|
||||
except queue.Empty:
|
||||
action = None
|
||||
if self.stopping.is_set():
|
||||
break
|
||||
error = ''
|
||||
try:
|
||||
if self.controller is None:
|
||||
if self.demo:
|
||||
from .demo import DemoTransport
|
||||
self.transport = DemoTransport()
|
||||
else:
|
||||
devices = discover()
|
||||
if self.device_path:
|
||||
devices = [d for d in devices if d.path == self.device_path]
|
||||
if not devices:
|
||||
raise DeviceError('Kein BTD 700 gefunden. Bitte den Dongle einstecken.')
|
||||
if len(devices) > 1:
|
||||
raise DeviceError('Mehrere BTD 700 gefunden. Mit --device /dev/hidrawN auswählen.')
|
||||
self.transport = Hidraw(devices[0])
|
||||
self.controller = Controller(self.transport)
|
||||
if action:
|
||||
method, args, kwargs = action
|
||||
try:
|
||||
getattr(self.controller, method)(*args, **kwargs)
|
||||
except (ValueError, DeviceError, TimeoutError) as exc:
|
||||
error = str(exc)
|
||||
status = self.controller.snapshot()
|
||||
GLib.idle_add(self.callback, status, error, bool(action))
|
||||
except Exception as exc:
|
||||
if self.transport:
|
||||
self.transport.close()
|
||||
self.transport = self.controller = None
|
||||
GLib.idle_add(self.callback, None, error or str(exc), bool(action))
|
||||
finally:
|
||||
if self.transport:
|
||||
self.transport.close()
|
||||
Reference in New Issue
Block a user