Add native Linux controls and tray menu for BTD 700
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise the GTK UI and real D-Bus menu against an isolated demo device."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from btd700.app import Application
|
||||
from gi.repository import Gio, GLib, Gtk
|
||||
|
||||
app = Application(demo=True)
|
||||
step = 0
|
||||
wait_ticks = 0
|
||||
passed = False
|
||||
bus_pending = False
|
||||
|
||||
|
||||
def menu_item(labels):
|
||||
root = app.tray.root
|
||||
for label in labels:
|
||||
root = next(item for item in root.get_children() if item.property_get('label') == label)
|
||||
return root
|
||||
|
||||
|
||||
def click_menu(*labels):
|
||||
global bus_pending
|
||||
item = menu_item(labels)
|
||||
assert item.property_get_bool('enabled'), labels
|
||||
bus_pending = True
|
||||
def complete(bus, result):
|
||||
global bus_pending
|
||||
try:
|
||||
bus.call_finish(result)
|
||||
except Exception:
|
||||
app.quit()
|
||||
raise
|
||||
bus_pending = False
|
||||
app.tray.bus.call(app.tray.bus.get_unique_name(), '/Menu', 'com.canonical.dbusmenu', 'Event',
|
||||
GLib.Variant('(isvu)', (item.get_id(), 'clicked', GLib.Variant('i', 0), 0)),
|
||||
None, Gio.DBusCallFlags.NONE, 2000, None, complete)
|
||||
|
||||
|
||||
def screenshot(path):
|
||||
window = app.window
|
||||
paintable = Gtk.WidgetPaintable.new(window)
|
||||
snapshot = Gtk.Snapshot.new()
|
||||
paintable.snapshot(snapshot, window.get_width(), window.get_height())
|
||||
node = snapshot.to_node()
|
||||
if node is None:
|
||||
raise RuntimeError('Window snapshot is not ready')
|
||||
texture = window.get_renderer().render_texture(node, None)
|
||||
texture.save_to_png(path)
|
||||
|
||||
|
||||
def tick():
|
||||
global step, passed, wait_ticks
|
||||
try:
|
||||
wait_ticks += 1
|
||||
if wait_ticks > 200:
|
||||
raise TimeoutError(f'GUI step {step} timed out')
|
||||
if not app.status or not app.tray or not app.tray.available or app.busy or bus_pending:
|
||||
return True
|
||||
assert not app.last_error, app.last_error
|
||||
if step == 0:
|
||||
assert app.status.mode == 0
|
||||
click_menu('Audiomodus', 'Gaming')
|
||||
elif step == 1:
|
||||
assert app.status.mode == 1
|
||||
assert not menu_item(('Codec', 'SBC')).property_get_bool('enabled')
|
||||
click_menu('Audiomodus', 'Standard')
|
||||
elif step == 2:
|
||||
assert app.status.mode == 0
|
||||
click_menu('Codec', 'SBC')
|
||||
elif step == 3:
|
||||
assert app.status.codec == 1
|
||||
click_menu('Bluetooth-Transport', 'Bluetooth Classic')
|
||||
elif step == 4:
|
||||
assert app.status.transport == 1
|
||||
click_menu('Kopfhörer trennen')
|
||||
elif step == 5:
|
||||
assert app.status.state == 1
|
||||
click_menu('Kopfhörer verbinden')
|
||||
elif step == 6:
|
||||
assert app.status.state == 2
|
||||
click_menu('Audiomodus', 'Auracast')
|
||||
elif step == 7:
|
||||
assert app.status.mode == 2
|
||||
click_menu('Auracast', 'Öffentlich auffindbar')
|
||||
elif step == 8:
|
||||
assert app.status.broadcast_public == 0
|
||||
click_menu('Auracast', 'Übertragungsqualität', 'Standard · 24 kHz')
|
||||
elif step == 9:
|
||||
assert app.status.broadcast_quality == 1
|
||||
click_menu('Auracast', 'Name und Passwort …')
|
||||
elif step == 10:
|
||||
assert app.window.get_visible()
|
||||
app.name_entry.set_text('Studio Linux')
|
||||
app.password_entry.set_text('DemoSecret123')
|
||||
app.save_button.emit('clicked')
|
||||
elif step == 11:
|
||||
assert app.status.broadcast_name == 'Studio Linux'
|
||||
assert app.status.broadcast_encrypted == 1
|
||||
assert not app.password_entry.get_text()
|
||||
assert not app.dirty
|
||||
click_menu('Auracast', 'Passwortschutz')
|
||||
elif step == 12:
|
||||
assert app.status.broadcast_encrypted == 0
|
||||
app.window.close()
|
||||
assert not app.window.get_visible()
|
||||
assert app.tray.available
|
||||
click_menu('Fenster öffnen')
|
||||
elif step == 13:
|
||||
assert app.window.get_visible()
|
||||
app.name_entry.set_text('Unsaved edit')
|
||||
app._on_status(app.status, '', False)
|
||||
assert app.name_entry.get_text() == 'Unsaved edit'
|
||||
app.discard_button.emit('clicked')
|
||||
assert app.name_entry.get_text() == 'Studio Linux'
|
||||
app.mode_buttons[0].emit('clicked') if app.mode_buttons[0].get_active() else app.mode_buttons[0].set_active(True)
|
||||
app.perform('set_mode', 0)
|
||||
elif step == 14:
|
||||
screenshot('/tmp/btd700-gui-test.png')
|
||||
adjustment = app.scroll.get_vadjustment()
|
||||
adjustment.set_value(adjustment.get_upper() - adjustment.get_page_size())
|
||||
elif step == 15:
|
||||
screenshot('/tmp/btd700-gui-test-bottom.png')
|
||||
app.window.set_default_size(420, 600)
|
||||
elif step == 16:
|
||||
screenshot('/tmp/btd700-gui-test-narrow.png')
|
||||
print('PASS: GTK form, all D-Bus tray control groups, encryption, close/reopen, dirty edits, screenshots', flush=True)
|
||||
passed = True
|
||||
app.quit()
|
||||
return False
|
||||
print('GUI step', step, 'passed', flush=True)
|
||||
step += 1
|
||||
wait_ticks = 0
|
||||
except Exception as exc:
|
||||
print('FAIL: GUI step', step, type(exc).__name__, str(exc), flush=True)
|
||||
app.quit()
|
||||
return False
|
||||
return True
|
||||
|
||||
GLib.timeout_add(250, tick)
|
||||
app.run(['btd700-gui-test'])
|
||||
raise SystemExit(0 if passed else 1)
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Opt-in reversible control test. Never resets or updates the dongle."""
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from btd700.controller import Controller
|
||||
from btd700.protocol import Command as C, encode
|
||||
from btd700.transport import Hidraw, discover
|
||||
|
||||
parser = argparse.ArgumentParser(description='Steuerfunktionen testen und Originalwerte wiederherstellen; unterbricht kurz Audio.')
|
||||
parser.add_argument('--run', action='store_true', required=True)
|
||||
args = parser.parse_args()
|
||||
devices = discover()
|
||||
assert len(devices) == 1, 'Exactly one BTD 700 required'
|
||||
with Hidraw(devices[0]) as transport:
|
||||
c = Controller(transport)
|
||||
original = c.snapshot()
|
||||
original_name = transport.request(C.GET_NAME).rstrip(b'\0')
|
||||
original_key = transport.request(C.GET_KEY).rstrip(b'\0')
|
||||
original_info = transport.request(C.GET_BROADCAST)
|
||||
# Refuse to mutate unless all saved values can be restored by this driver.
|
||||
encode(C.SET_KEY, original_key)
|
||||
encode(C.SET_NAME, original_name)
|
||||
encode(C.SET_BROADCAST, original_info)
|
||||
encode(C.SET_MODE, bytes((original.mode, original.transport)))
|
||||
print('Original:', original.mode, original.transport, original.codec, original.quality, flush=True)
|
||||
try:
|
||||
c.set_mode(0)
|
||||
print('PASS: Standard mode readback', flush=True)
|
||||
s = c.snapshot()
|
||||
chosen = next((value for value in (1, 2, 4, 8, 16, 32) if s.codecs & value), None)
|
||||
if chosen:
|
||||
c.set_codec(chosen)
|
||||
print('PASS: codec readback', chosen, flush=True)
|
||||
c.set_mode(2)
|
||||
print('PASS: Auracast mode readback', flush=True)
|
||||
c.set_broadcast(name='BTD700 Linux', public=not bool(original_info[0]), quality=(original_info[1]+1)%3)
|
||||
print('PASS: broadcast name, visibility and quality readback', flush=True)
|
||||
if len(original_key) <= 16 and all(32 <= b <= 126 for b in original_key):
|
||||
c.set_broadcast(password='LinuxTest2026', encrypted=True)
|
||||
assert transport.request(C.GET_KEY).rstrip(b'\0') == b'LinuxTest2026'
|
||||
print('PASS: password and encryption readback (secret omitted)', flush=True)
|
||||
finally:
|
||||
transport.request(C.SET_KEY, original_key)
|
||||
transport.request(C.SET_NAME, original_name)
|
||||
transport.request(C.SET_BROADCAST, original_info)
|
||||
transport.request(C.SET_MODE, bytes((0, original.transport)))
|
||||
deadline = time.monotonic()+10
|
||||
while time.monotonic() < deadline:
|
||||
s = c.snapshot()
|
||||
if s.state >= 2:
|
||||
break
|
||||
time.sleep(.25)
|
||||
if s.state >= 2 and s.codecs & original.codec:
|
||||
c.set_codec(original.codec)
|
||||
transport.request(C.SET_MODE, bytes((original.mode, original.transport)))
|
||||
c._verify(C.GET_MODE, bytes((original.mode, original.transport)), exact=False)
|
||||
assert transport.request(C.GET_NAME).rstrip(b'\0') == original_name
|
||||
assert transport.request(C.GET_KEY).rstrip(b'\0') == original_key
|
||||
assert transport.request(C.GET_BROADCAST) == original_info
|
||||
final = c.snapshot()
|
||||
print('RESTORED: mode, transport, Auracast name, key, visibility, quality, encryption', flush=True)
|
||||
print('Final:', final.mode, final.transport, final.codec, final.quality, flush=True)
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract only the application assembly from a locally supplied .NET bundle.
|
||||
|
||||
No downloading or execution of the original application. Output remains private
|
||||
analysis material; it is not needed at runtime and must not be distributed here.
|
||||
"""
|
||||
import argparse
|
||||
import io
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('exe', type=Path)
|
||||
parser.add_argument('output', type=Path, help='Output DLL path, outside this project')
|
||||
args = parser.parse_args()
|
||||
data = args.exe.read_bytes()
|
||||
signature = bytes.fromhex('8b1202b96a612038727b930214d7a03213f5b9e6efae3318ee3b2dce24b36aae')
|
||||
position = data.find(signature)
|
||||
if position < 8:
|
||||
parser.error('No .NET bundle signature found')
|
||||
offset = struct.unpack_from('<q', data, position - 8)[0]
|
||||
if not 0 <= offset < len(data):
|
||||
parser.error('Invalid manifest offset')
|
||||
stream = io.BytesIO(data)
|
||||
stream.seek(offset)
|
||||
|
||||
|
||||
def unpack(fmt):
|
||||
return struct.unpack(fmt, stream.read(struct.calcsize(fmt)))
|
||||
|
||||
|
||||
def read_string():
|
||||
length = 0
|
||||
for shift in range(0, 35, 7):
|
||||
value = unpack('<B')[0]
|
||||
length |= (value & 127) << shift
|
||||
if not value & 128:
|
||||
if length > 4096:
|
||||
raise ValueError('Unexpected string length')
|
||||
return stream.read(length).decode('utf-8')
|
||||
raise ValueError('Invalid string prefix')
|
||||
|
||||
|
||||
major, minor, count = unpack('<IIi')
|
||||
if major != 6 or not 1 <= count <= 10000:
|
||||
parser.error('Expected a version-6 .NET bundle')
|
||||
read_string()
|
||||
stream.read(40)
|
||||
for _ in range(count):
|
||||
offset, size, compressed, kind = unpack('<qqqB')
|
||||
name = read_string()
|
||||
if name != 'Sennheiser Dongle Control.dll':
|
||||
continue
|
||||
length = compressed or size
|
||||
if not 0 <= offset <= offset + length <= len(data) or not 0 < size < 32 * 1024 * 1024:
|
||||
parser.error('Invalid assembly bounds')
|
||||
blob = data[offset:offset + length]
|
||||
if compressed:
|
||||
decoder = zlib.decompressobj(-15)
|
||||
blob = decoder.decompress(blob, size + 1)
|
||||
if len(blob) != size or not blob.startswith(b'MZ'):
|
||||
parser.error('Assembly size or signature mismatch')
|
||||
with args.output.open('xb') as output:
|
||||
output.write(blob)
|
||||
print(f'Extracted {name}: {len(blob)} bytes to {args.output}')
|
||||
break
|
||||
else:
|
||||
parser.error('Application assembly not found')
|
||||
Reference in New Issue
Block a user