Add MX-T40 BLE desktop controller and protocol documentation
Checks / tests (ubuntu-latest, 3.11) (push) Canceled after 0s
Checks / tests (ubuntu-latest, 3.14) (push) Canceled after 0s
Checks / tests (windows-latest, 3.11) (push) Canceled after 0s
Checks / tests (windows-latest, 3.14) (push) Canceled after 0s
Checks / package (push) Canceled after 0s

This commit is contained in:
lda committed 2026-10-25 04:43:47 +07:00 Verified
commit 038e152bbc
38 files changed
+2344

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
"""Replay the captured reply through asynchronous FFA1/FFA2 operations."""
import queue
import sys
import types
import unittest
from unittest.mock import AsyncMock, patch
from controller.ble_link import NOTIFY, SERVICE, WRITE, BLELink
class BLELinkTests(unittest.TestCase):
def run_peer(self, write_error=False):
operations = []
closed = []
write = types.SimpleNamespace(uuid=WRITE, properties=["write"])
notify = types.SimpleNamespace(uuid=NOTIFY, properties=["notify"])
service = types.SimpleNamespace(
get_characteristic=lambda uuid: {WRITE: write, NOTIFY: notify}.get(uuid)
)
class Events(queue.Queue):
def put(self, event, *args, **kwargs):
super().put(event, *args, **kwargs)
if event[0] == "ready":
link.stop()
events = Events()
link = BLELink("12:34:56:78:9A:BC", events)
class Peer:
services = types.SimpleNamespace(
get_service=lambda uuid: service if uuid == SERVICE else None
)
is_connected = True
async def __aenter__(self):
return self
async def __aexit__(self, *args):
closed.append(True)
async def start_notify(self, characteristic, callback):
operations.append(("subscribe", characteristic.uuid))
self.callback = callback
async def write_gatt_char(self, characteristic, data, response):
operations.append(("write", characteristic.uuid, data, response))
if write_error:
raise OSError("GATT write failed")
if data[6:-1] == b"\x01":
self.callback(notify, bytes.fromhex("00 bb 00 01 00 00 01"))
reply = bytes.fromhex("00 bb 01 1d 00 0b 02 10 01 34 30 04 01 02 04 05 01 b0")
self.callback(notify, reply[:8])
self.callback(notify, reply[8:])
elif data[6:-1] == b"\x04":
self.callback(notify, bytes.fromhex("00 bb 00 02 00 00 02"))
scanner = types.SimpleNamespace(find_device_by_address=AsyncMock(return_value=object()))
with patch.dict(
sys.modules,
{
"bleak": types.SimpleNamespace(
BleakScanner=scanner, BleakClient=lambda *a, **k: Peer()
)
},
):
link.run()
return operations, list(events.queue), closed
def test_subscribe_before_handshake_and_reassemble_notifications(self):
operations, events, closed = self.run_peer()
self.assertEqual(operations[0], ("subscribe", NOTIFY))
writes = operations[1:]
self.assertEqual([op[1] for op in writes], [WRITE, WRITE])
self.assertEqual(
[op[2] for op in writes],
[
bytes.fromhex("00 bb 01 01 00 01 01 04"),
bytes.fromhex("00 bb 01 02 00 01 04 08"),
],
)
self.assertTrue(all(op[3] is True for op in writes))
self.assertEqual(sum(kind == "ready" for kind, _ in events), 1)
self.assertFalse(any(kind == "error" for kind, _ in events))
self.assertEqual(closed, [True])
def test_write_error_disconnects_without_enabling_controls(self):
_, events, closed = self.run_peer(write_error=True)
self.assertFalse(any(kind == "ready" for kind, _ in events))
self.assertTrue(
any(kind == "error" and "GATT write failed" in value for kind, value in events)
)
self.assertEqual(events[-1], ("closed", None))
self.assertEqual(closed, [True])
+29
View File
@@ -0,0 +1,29 @@
"""Match semantic command builders against independently captured writes."""
import unittest
from controller import commands
from controller.protocol import Encoder
class CommandTests(unittest.TestCase):
def test_rock_packet_matches_hardware_capture(self):
encoder = Encoder()
encoder.sequence = 11
self.assertEqual(
encoder.encode(commands.set_eq(5)), bytes.fromhex("00 bb 01 0c 00 05 40 03 00 05 00 5a")
)
def test_bass_off_matches_hardware_capture(self):
encoder = Encoder()
encoder.sequence = 6
self.assertEqual(
encoder.encode(commands.set_bass(False)),
bytes.fromhex("00 bb 01 07 00 05 40 04 00 01 00 52"),
)
def test_invalid_rgb_is_rejected_before_transport(self):
with self.assertRaises(ValueError):
commands.set_rgb(256, 0, 0)
with self.assertRaises(ValueError):
commands.set_rgb(0.5, 0, 0)
+46
View File
@@ -0,0 +1,46 @@
"""Guard the seam between private firmware research and publishable source."""
import tempfile
import unittest
from pathlib import Path
from scripts.export_project import export_project
class ExportTests(unittest.TestCase):
def test_excludes_vendor_inputs_and_preserves_source(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary) / "workspace"
for name in (
"controller/main.py",
"controller/cache.bin",
"README.md",
"analysis/app-source/Vendor.java",
"speaker.bin",
"app.apk",
".env",
):
path = root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("fixture")
output = Path(temporary) / "export"
export_project(root, output)
self.assertEqual(
sorted(
str(path.relative_to(output)).replace("\\", "/")
for path in output.rglob("*")
if path.is_file()
),
["README.md", "controller/main.py"],
)
def test_refuses_existing_content(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
output = root / "export"
output.mkdir()
marker = output / "keep.txt"
marker.write_text("do not replace")
with self.assertRaises(ValueError):
export_project(root, output)
self.assertEqual(marker.read_text(), "do not replace")
+23
View File
@@ -0,0 +1,23 @@
"""The protocol and CLI must remain usable on machines without a display."""
import subprocess
import sys
import unittest
class ImportTests(unittest.TestCase):
def test_headless_import_and_help(self):
script = """
import sys
from controller import protocol, session, messages, cli
assert 'tkinter' not in sys.modules
assert 'bleak' not in sys.modules
try:
cli.main(['--help'])
except SystemExit as exc:
assert exc.code == 0
assert 'tkinter' not in sys.modules
"""
result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("--transport", result.stdout)
+63
View File
@@ -0,0 +1,63 @@
"""Exercise the connection state machine with a scripted serial peer."""
import queue
import sys
import types
import unittest
from unittest.mock import patch
from controller.link import Link
class HandshakeTests(unittest.TestCase):
def run_peer(self, malformed=False):
events = queue.Queue()
link = Link("COM7", events)
packets = []
# Replay actual device ACK + CONNECT_INFO, then a link-complete ACK.
# Peer data sequence 0x1d is independent of our outbound sequence 1.
info = (
bytes.fromhex("00 bb 01 1d 00 01 02 20")
if malformed
else bytes.fromhex("00 bb 01 1d 00 0b 02 10 01 34 30 04 01 02 04 05 01 b0")
)
reads = iter(
(bytes.fromhex("00 bb 00 01 00 00 01"), info, bytes.fromhex("00 bb 00 02 00 00 02"))
)
class Peer:
def __enter__(self):
return self
def __exit__(self, *args):
pass
def write(self, data):
packets.append(data)
return len(data)
def read(self, size):
try:
return next(reads)
except StopIteration:
link.stop()
return b""
with patch.dict(
sys.modules, {"serial": types.SimpleNamespace(Serial=lambda *a, **k: Peer())}
):
link.run()
return packets, list(events.queue)
def test_waits_for_peer_before_ready(self):
packets, events = self.run_peer()
self.assertEqual([p[6:-1] for p in packets], [b"\x01", b"\x04"])
self.assertEqual(packets[1], bytes.fromhex("00 bb 01 02 00 01 04 08"))
self.assertEqual(sum(kind == "ready" for kind, _ in events), 1)
self.assertFalse(any(kind == "error" for kind, _ in events))
def test_malformed_info_never_enables_controls(self):
packets, events = self.run_peer(malformed=True)
self.assertEqual(len(packets), 1)
self.assertFalse(any(kind == "ready" for kind, _ in events))
self.assertTrue(any(kind == "error" for kind, _ in events))
+50
View File
@@ -0,0 +1,50 @@
"""Human-readable packet meaning, including ACK versus actual state reports."""
import unittest
from controller.messages import ActivityLog, describe_command, describe_reply
from controller.protocol import Frame
class MessageTests(unittest.TestCase):
def test_outgoing_layout_and_rgb(self):
self.assertEqual(describe_command(bytes.fromhex("40 03 00 05 00")), "Set EQ to Rock")
self.assertEqual(
describe_command(bytes.fromhex("60 02 ff 80 00")), "Set lighting color to #FF8000"
)
def test_color_is_a_distinct_mode_and_play_pause_is_an_event(self):
self.assertEqual(describe_command(bytes.fromhex("50 03 07")), "Set lighting to Color")
self.assertEqual(describe_reply(bytes.fromhex("51 03 07")), "Lighting: Color")
self.assertEqual(describe_reply(bytes.fromhex("70 11")), "Button event: Play / pause")
def test_capture_maps_to_english_with_optional_hex(self):
log = ActivityLog()
raw = "TX BLE 00 bb 01 0c 00 05 40 03 00 05 00 5a"
log.record("log", raw)
log.record("log", "RX BLE 00 bb 00 0c 00 00 0c")
log.record("frame", Frame(0, 12, b""))
log.record("frame", Frame(1, 0xAD, bytes.fromhex("41 03 05 00")))
text = log.text()
self.assertIn("Sending: Set EQ to Rock", text)
self.assertIn("Speaker acknowledged: Set EQ to Rock", text)
self.assertIn("Speaker reports: EQ: Rock", text)
self.assertNotIn("00 bb", text)
self.assertIn(raw, log.text(raw=True))
def test_ack_is_not_reported_as_applied(self):
log = ActivityLog()
log.record("log", "TX BLE 00 bb 01 11 00 05 60 02 ff 80 00 f8")
log.record("frame", Frame(0, 17, b""))
self.assertIn("acknowledged: Set lighting color", log.text())
self.assertNotIn("Speaker reports:", log.text())
def test_unknown_reply_is_honest_and_does_not_crash(self):
self.assertIn("Unmapped", describe_reply(b"\xff"))
self.assertIn("Unmapped", describe_reply(b""))
def test_history_is_bounded(self):
log = ActivityLog(limit=2)
for value in ("one", "two", "three"):
log.record("log", value)
self.assertEqual(log.text(), "two\nthree\n")
+88
View File
@@ -0,0 +1,88 @@
"""Model the named overloads exported by Python/WinRT, not C# overloads."""
import asyncio
import contextlib
import io
import sys
import types
import unittest
from unittest.mock import patch
from controller.probe import SPP_UUID, check_channel, probe
class ProbeTests(unittest.TestCase):
def test_projected_overloads_and_resource_cleanup(self):
closed = []
selector = "paired Bluetooth selector"
service = types.SimpleNamespace(
service_id=types.SimpleNamespace(uuid=SPP_UUID),
connection_host_name=types.SimpleNamespace(raw_name="speaker-host"),
connection_service_name="1",
close=lambda: closed.append("service"),
)
async def get_services(cache_mode):
self.assertEqual(cache_mode, 1)
return types.SimpleNamespace(error=0, services=[service])
async def from_id(device_id):
self.assertEqual(device_id, "device-id")
return types.SimpleNamespace(
bluetooth_address=0x123456789ABC,
get_rfcomm_services_with_cache_mode_async=get_services,
close=lambda: closed.append("device"),
)
async def find_filtered(value):
self.assertEqual(value, selector)
return [types.SimpleNamespace(name="MX-T40", id="device-id")]
def wrong_overload(value):
# The installed projection treats this argument as DeviceClass.
raise TypeError("'str' object cannot be interpreted as an integer")
bluetooth = types.SimpleNamespace(
BluetoothCacheMode=types.SimpleNamespace(UNCACHED=1),
BluetoothDevice=types.SimpleNamespace(
get_device_selector_from_pairing_state=lambda paired: selector,
from_id_async=from_id,
),
)
enumeration = types.SimpleNamespace(
DeviceInformation=types.SimpleNamespace(
find_all_async=wrong_overload,
find_all_async_aqs_filter=find_filtered,
)
)
with (
patch.dict(
sys.modules,
{
"winrt.windows.devices.bluetooth": bluetooth,
"winrt.windows.devices.enumeration": enumeration,
},
),
contextlib.redirect_stdout(io.StringIO()) as output,
):
result = asyncio.run(probe("MX-T40"))
self.assertEqual(result, 0)
self.assertIn("SPP service found: True", output.getvalue())
self.assertEqual(closed, ["service", "device"])
def test_direct_channel_connects_without_sending(self):
from unittest.mock import MagicMock
stream = MagicMock()
with (
patch("controller.probe.socket.socket") as create,
patch("controller.probe.socket.AF_BLUETOOTH", 32, create=True),
patch("controller.probe.socket.BTPROTO_RFCOMM", 3, create=True),
):
create.return_value.__enter__.return_value = stream
check_channel("12:34:56:78:9A:BC", 1)
stream.connect.assert_called_once_with(("12:34:56:78:9A:BC", 1))
stream.settimeout.assert_called_once_with(10)
stream.send.assert_not_called()
stream.sendall.assert_not_called()
create.return_value.__exit__.assert_called_once()
+82
View File
@@ -0,0 +1,82 @@
"""Packet fixtures and stream boundaries independent of physical Bluetooth."""
import unittest
from controller.protocol import Decoder, Encoder
class ProtocolTests(unittest.TestCase):
def test_captured_mx_t40_connection_reply(self):
raw = bytes.fromhex("00 bb 01 1d 00 0b 02 10 01 34 30 04 01 02 04 05 01 b0")
frames = Decoder().feed(raw)
self.assertEqual(len(frames), 1)
self.assertEqual(frames[0].sequence, 0x1D)
self.assertEqual(frames[0].payload, bytes.fromhex("02 10 01 34 30 04 01 02 04 05 01"))
def test_captured_ble_source_and_eq_replies(self):
# These two consecutive hardware replies demonstrate both checksum
# variants: source excludes type, EQ includes it.
decoder = Decoder()
frames = decoder.feed(
bytes.fromhex("00 bb 01 6d 00 02 31 01 a1 00 bb 01 6e 00 04 41 03 02 00 b9")
)
self.assertEqual([frame.payload for frame in frames], [b"\x31\x01", b"\x41\x03\x02\x00"])
def test_captured_lighting_checksum_quirk(self):
for raw, mode in [
("00 bb 01 91 00 03 51 03 03 e9", 3),
("00 bb 01 ab 00 03 51 03 05 03", 5),
("00 bb 01 b0 00 03 51 03 06 08", 6),
("00 bb 01 b1 00 03 51 03 01 09", 1),
]:
with self.subTest(raw=raw):
frame = Decoder().feed(bytes.fromhex(raw))[0]
self.assertEqual(frame.payload, bytes((0x51, 3, mode)))
self.assertTrue(frame.checksum_valid)
def test_unknown_checksum_is_flagged_in_compatibility_mode(self):
raw = bytes.fromhex("00 bb 01 91 00 03 51 03 03 20")
self.assertEqual(Decoder().feed(raw), [])
frame = Decoder(strict_checksums=False).feed(raw)[0]
self.assertFalse(frame.checksum_valid)
def test_captured_play_pause_event_checksums(self):
for raw in ("00 bb 01 cd 00 02 70 11 40", "00 bb 01 ce 00 02 70 11 41"):
with self.subTest(raw=raw):
frame = Decoder().feed(bytes.fromhex(raw))[0]
self.assertEqual(frame.payload, b"\x70\x11")
self.assertTrue(frame.checksum_valid)
def test_app_connection_fixture(self):
self.assertEqual(Encoder().encode(b"\x01"), bytes.fromhex("00 bb 01 01 00 01 01 04"))
def test_signed_java_sequence_rollover(self):
encoder = Encoder()
for _ in range(127):
encoder.encode(b"\x01")
self.assertEqual(encoder.sequence, 127)
self.assertEqual(encoder.encode(b"\x01")[3], 1)
def test_every_split_and_multiple_frames(self):
# Peer replies use a different checksum rule than Encoder's commands.
first = bytes.fromhex("00 bb 01 01 00 04 41 03 00 02 4b")
second = bytes.fromhex("00 bb 01 02 00 03 51 03 01 5a")
for split in range(len(first) + 1):
decoder = Decoder()
frames = decoder.feed(first[:split]) + decoder.feed(first[split:] + second)
self.assertEqual([f.payload for f in frames], [b"\x41\x03\x00\x02", b"\x51\x03\x01"])
def test_corruption_and_noise_recovery(self):
bad = bytearray.fromhex("00 bb 01 01 00 01 01 03")
bad[-1] ^= 1
good = bytes.fromhex("00 bb 01 02 00 01 04 07")
frames = Decoder().feed(b"noise" + bad + good)
self.assertEqual([f.payload for f in frames], [b"\x04"])
def test_empty_ack(self):
frame = Decoder().feed(bytes.fromhex("00 bb 00 02 00 00 02"))[0]
self.assertEqual((frame.kind, frame.sequence, frame.payload), (0, 2, b""))
if __name__ == "__main__":
unittest.main()
+34
View File
@@ -0,0 +1,34 @@
"""Exercise the session interface without a thread or transport."""
import unittest
from controller.session import Session
INFO = bytes.fromhex("00 bb 01 1d 00 0b 02 10 01 34 30 04 01 02 04 05 01 b0")
class SessionTests(unittest.TestCase):
def test_captured_handshake_with_split_reply(self):
session = Session()
self.assertEqual(session.start(), bytes.fromhex("00 bb 01 01 00 01 01 04"))
self.assertFalse(session.receive(INFO[:9]).outgoing)
update = session.receive(INFO[9:])
self.assertEqual(update.outgoing, (bytes.fromhex("00 bb 01 02 00 01 04 08"),))
self.assertFalse(session.ready)
update = session.receive(bytes.fromhex("00 bb 00 02 00 00 02"))
self.assertTrue(update.became_ready)
self.assertTrue(session.ready)
self.assertFalse(session.receive(bytes.fromhex("00 bb 00 02 00 00 02")).became_ready)
def test_notification_sequence_cannot_complete_handshake(self):
session = Session()
session.start()
session.receive(INFO)
session.receive(bytes.fromhex("00 bb 01 02 00 02 31 01 36"))
self.assertFalse(session.ready)
def test_repeated_info_does_not_repeat_link_complete(self):
session = Session()
session.start()
self.assertEqual(len(session.receive(INFO).outgoing), 1)
self.assertEqual(session.receive(INFO).outgoing, ())
+21
View File
@@ -0,0 +1,21 @@
"""Decode actual status payloads without constructing a Tk window."""
import unittest
from controller.messages import describe_reply
class StatusTests(unittest.TestCase):
def test_captured_standard_eq(self):
self.assertEqual(describe_reply(bytes.fromhex("41 03 02 00")), "EQ: Standard")
def test_captured_volume_mute_and_lighting(self):
for raw, expected in [
("41 01 0b 00", "Volume: 11"),
("41 01 0c 00", "Volume: 12"),
("41 07 01 00", "Mute: On"),
("41 07 02 00", "Mute: Off"),
("51 03 03", "Lighting: Dance"),
]:
with self.subTest(raw=raw):
self.assertEqual(describe_reply(bytes.fromhex(raw)), expected)
+88
View File
@@ -0,0 +1,88 @@
"""Verify direct connections, write completion, and disconnect handling."""
import socket
import types
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
from controller.transport import RfcommStream, open_stream
class TransportTests(unittest.TestCase):
def setUp(self):
self.mock_socket = MagicMock()
# Replace this adapter's socket module reference rather than globally
# mocking socket.socket, which also breaks asyncio's internal sockets
# (particularly Windows' Proactor event loop).
isolated_socket = types.SimpleNamespace(
socket=self.mock_socket,
AF_BLUETOOTH=32,
BTPROTO_RFCOMM=3,
SOCK_STREAM=socket.SOCK_STREAM,
timeout=TimeoutError,
)
replacement = patch("controller.transport.socket", isolated_socket)
replacement.start()
self.addCleanup(replacement.stop)
self.peer = self.mock_socket.return_value
def test_direct_address_bypasses_serial_and_closes(self):
with patch.dict("sys.modules", {"serial": None}):
with open_stream("12:34:56:78:9A:BC", 1) as stream:
self.assertEqual(stream.write(b"packet"), 6)
self.peer.connect.assert_called_once_with(("12:34:56:78:9A:BC", 1))
self.peer.sendall.assert_called_once_with(b"packet")
self.peer.close.assert_called_once()
def test_idle_is_not_disconnect(self):
with RfcommStream("12:34:56:78:9A:BC") as stream:
self.peer.recv.side_effect = TimeoutError()
self.assertEqual(stream.read(1024), b"")
self.peer.recv.side_effect = None
self.peer.recv.return_value = b""
with self.assertRaises(ConnectionError):
stream.read(1024)
def test_connect_failure_closes_socket(self):
self.peer.connect.side_effect = OSError("connection refused")
with self.assertRaises(OSError):
with RfcommStream("12:34:56:78:9A:BC"):
self.fail("Failed connection entered context")
self.peer.close.assert_called_once()
def test_connect_timeout_identifies_phase_and_closes(self):
self.peer.connect.side_effect = TimeoutError("timed out")
with self.assertRaisesRegex(TimeoutError, "before the Samsung handshake"):
with RfcommStream("12:34:56:78:9A:BC"):
self.fail("Timed out connection entered context")
self.peer.close.assert_called_once()
def test_write_timeout_identifies_phase(self):
with RfcommStream("12:34:56:78:9A:BC") as stream:
self.peer.sendall.side_effect = TimeoutError("timed out")
with self.assertRaisesRegex(TimeoutError, "sending a Samsung packet"):
stream.write(b"packet")
def test_prepared_connection_retains_windows_handle(self):
device = MagicMock()
prepare = AsyncMock(return_value=device)
report = MagicMock()
with patch("controller.windows_bluetooth.prepare_device", prepare):
with open_stream("12:34:56:78:9A:BC", 1, prepare=True, report=report):
device.close.assert_not_called()
prepare.assert_awaited_once_with("12:34:56:78:9A:BC", report)
self.peer.connect.assert_called_once_with(("12:34:56:78:9A:BC", 1))
device.close.assert_called_once()
def test_prepared_connect_failure_releases_both_handles(self):
device = MagicMock()
# The base adapter's socket cleanup is covered separately. Mock its
# entry here so asyncio's own internal sockets do not affect counts.
with (
patch("controller.windows_bluetooth.prepare_device", AsyncMock(return_value=device)),
patch.object(RfcommStream, "__enter__", side_effect=OSError("host down")),
):
with self.assertRaises(OSError):
with open_stream("12:34:56:78:9A:BC", 1, prepare=True):
self.fail("Failed connection entered context")
device.close.assert_called_once()