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
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:
38 files changed
+2344
No files matched your search
@@ -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])
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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, ())
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user