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
+176
View File
@@ -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)