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
+86
View File
@@ -0,0 +1,86 @@
import unittest
from btd700.controller import Controller
from btd700.demo import DemoTransport
from btd700.protocol import Command as C
from btd700.transport import DeviceError
class RecordingDevice(DemoTransport):
def __init__(self):
super().__init__()
self.writes = []
def request(self, command, payload=b''):
if not command.name.startswith('GET_'):
self.writes.append((command, payload))
return super().request(command, payload)
class ControllerTests(unittest.TestCase):
def setUp(self):
self.device = RecordingDevice()
self.controller = Controller(self.device)
def test_snapshot_does_not_read_password(self):
self.device.values.pop(C.GET_KEY)
status = self.controller.snapshot()
self.assertEqual(status.codec_name, 'aptX Adaptive')
self.assertEqual(status.quality, '24 Bit / 48 kHz')
self.assertEqual(status.firmware, '3.11.0')
self.assertEqual(self.device.writes, [])
self.assertNotIn('password', status.public_dict())
def test_mode_preserves_auto_transport(self):
self.controller.set_mode(1)
self.assertEqual(self.device.writes, [(C.SET_MODE, b'\1\3')])
self.assertEqual(self.controller.snapshot().mode, 1)
def test_codec_capabilities_and_mode(self):
self.controller.set_codec(2)
self.assertEqual(self.controller.snapshot().codec, 2)
with self.assertRaises(ValueError):
self.controller.set_codec(32)
self.controller.set_mode(1)
with self.assertRaises(ValueError):
self.controller.set_codec(4)
def test_validation_happens_before_writes(self):
with self.assertRaises(ValueError):
self.controller.set_broadcast(name='Valid Name', password='bad')
self.assertEqual(self.device.writes, [])
with self.assertRaises(ValueError):
self.controller.set_broadcast(name='Valid Name', encrypted=True)
self.assertEqual(self.device.writes, [])
def test_broadcast_name_key_info_order(self):
self.controller.set_broadcast(name='Studio Audio', password='Secret123', public=False, quality=1)
self.assertEqual(self.device.writes, [(C.SET_KEY, b'Secret123'),
(C.SET_NAME, b'Studio Audio'), (C.SET_BROADCAST, b'\0\1\1')])
status = self.controller.snapshot()
self.assertEqual(status.broadcast_name, 'Studio Audio')
self.assertEqual(status.broadcast_encrypted, 1)
self.assertNotIn('Secret123', str(status.public_dict()))
def test_existing_password_not_cleared_on_other_change(self):
self.controller.set_broadcast(password='Secret123')
self.device.writes.clear()
self.controller.set_broadcast(quality=0)
self.assertEqual(self.device.writes, [(C.SET_BROADCAST, b'\1\0\1')])
self.assertEqual(self.device.values[C.GET_KEY].rstrip(b'\0'), b'Secret123')
def test_reset_requires_confirmation(self):
with self.assertRaises(ValueError):
self.controller.factory_reset()
self.assertEqual(self.device.writes, [])
def test_reconnect_and_disconnect(self):
self.controller.set_connection(False)
self.assertEqual(self.controller.snapshot().state, 1)
self.controller.set_connection(True)
self.assertEqual(self.controller.snapshot().state, 2)
def test_readback_mismatch_is_error(self):
original = self.device.request
self.device.request = lambda command, payload=b'': b'\0' if command == C.GET_CODEC else original(command, payload)
with self.assertRaises(DeviceError):
self.controller._verify(C.GET_CODEC, b'\4', timeout=0)
+63
View File
@@ -0,0 +1,63 @@
import tempfile
import unittest
from pathlib import Path
from btd700.protocol import Command as C, ProtocolError, acknowledge, decode, encode, validate_text
from btd700.transport import discover
class ProtocolTests(unittest.TestCase):
def test_captured_status_response(self):
message = decode(bytes.fromhex('34ff0103010301'))
self.assertEqual(message.payload, b'\1\3\1')
self.assertEqual(message.command, C.GET_MODE)
def test_captured_get_state_request(self):
self.assertEqual(encode(C.GET_STATE), bytes.fromhex('34fe0600') + bytes(60))
def test_all_non_control_command_numbers_rejected(self):
for value in range(256):
if value not in set(C):
with self.assertRaises(ValueError):
encode(value)
def test_setter_validation(self):
for command, args in ((C.SET_MODE, b'\3\3'), (C.SET_MODE, b'\0\0'),
(C.SET_CODEC, b'\3'), (C.SET_CONNECTION, b'\2'),
(C.SET_BROADCAST, b'\1\3\0'), (C.GET_STATE, b'\1'),
(C.SET_KEY, b'x' * 17), (C.FACTORY_RESET, b'\1')):
with self.assertRaises(ValueError):
encode(command, args)
def test_malformed_and_unrelated_reports(self):
for report in (b'\x34', b'\x34\xff\1', bytes.fromhex('34ff013d'), bytes.fromhex('34ff0602ff'), bytes.fromhex('34000600')):
with self.assertRaises(ProtocolError):
decode(report)
self.assertIsNone(decode(b'\1\0'))
self.assertIsNone(decode(b''))
def test_event_ack(self):
self.assertEqual(acknowledge(15), bytes.fromhex('34fd0f00') + bytes(60))
with self.assertRaises(ProtocolError):
acknowledge(255)
def test_name_and_key_bounds(self):
self.assertEqual(validate_text('Studio Audio'), b'Studio Audio')
self.assertEqual(validate_text(''), b'')
self.assertEqual(validate_text('A' * 16, key=True), b'A' * 16)
for name in ('Hi', 'x' * 17, ' Musik', 'Musik ', 'Müsik', 'abc\0def'):
with self.assertRaises(ValueError):
validate_text(name)
with self.assertRaises(ValueError):
validate_text('abc def', key=True)
def test_discovery_excludes_updater_and_other_devices(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
for number, vendor, descriptor in ((0, '3542', b'\x06\xa2\xff\x85\x34'),
(1, '3542', b'\x06\x00\xff\x85\x05'),
(2, '046D', b'\x06\xa2\xff\x85\x34')):
node = root / f'hidraw{number}' / 'device'
node.mkdir(parents=True)
(node / 'uevent').write_text(f'HID_ID=0003:0000{vendor}:00003001\nHID_UNIQ=test\n')
(node / 'report_descriptor').write_bytes(descriptor)
self.assertEqual([d.path for d in discover(root)], ['/dev/hidraw0'])
+56
View File
@@ -0,0 +1,56 @@
import os
import socket
import threading
import unittest
from btd700.protocol import Command as C, ProtocolError
from btd700.transport import Hidraw
class TransportTests(unittest.TestCase):
def setUp(self):
self.host, self.device = socket.socketpair(socket.AF_UNIX, socket.SOCK_DGRAM)
self.transport = Hidraw.__new__(Hidraw)
self.transport.fd = self.host.detach()
self.transport.timeout = 0.1
self.transport.events = {}
def tearDown(self):
self.transport.close()
self.device.close()
def test_event_and_unrelated_response_do_not_complete_request(self):
ack = []
def device():
self.device.recv(64)
os.write(self.device.fileno(), bytes.fromhex('0100'))
os.write(self.device.fileno(), bytes.fromhex('34ff01020003'))
os.write(self.device.fileno(), bytes.fromhex('34fc0f0103'))
ack.append(self.device.recv(64))
os.write(self.device.fileno(), bytes.fromhex('34ff060103'))
thread = threading.Thread(target=device)
thread.start()
self.assertEqual(self.transport.request(C.GET_STATE), b'\3')
thread.join(timeout=1)
self.assertEqual(self.transport.events[15], b'\3')
self.assertEqual(ack[0][:4], bytes.fromhex('34fd0f00'))
def test_timeout_is_bounded(self):
with self.assertRaises(TimeoutError):
self.transport.request(C.GET_STATE)
def test_short_payload_is_rejected(self):
def device():
self.device.recv(64)
os.write(self.device.fileno(), bytes.fromhex('34ff080102'))
thread = threading.Thread(target=device)
thread.start()
with self.assertRaises(ProtocolError):
self.transport.request(C.GET_QUALITY)
thread.join(timeout=1)
def test_invalid_command_does_not_touch_device(self):
with self.assertRaises(ValueError):
self.transport.request(255)
self.device.settimeout(0.01)
with self.assertRaises(TimeoutError):
self.device.recv(64)