commit 038e152bbc0aa94a9a9699270fc335547d5aee9b Author: lda Date: Sun Oct 25 04:43:47 2026 +0700 Add MX-T40 BLE desktop controller and protocol documentation diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..c6cda22 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,29 @@ +name: Checks +on: [push, pull_request] +permissions: + contents: read +jobs: + tests: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + python: ['3.11', '3.14'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - run: python -m unittest discover -s tests -v + - run: python -m controller --help + package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - run: python -m pip install 'ruff>=0.15,<0.16' build + - run: ruff check controller tests scripts + - run: ruff format --check controller tests scripts + - run: python -m build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3ce182 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +__pycache__/ +*.pyc +.venv/ +.ruff_cache/ +.pytest_cache/ +build/ +dist/ +*.egg-info/ +.env +.env.* +!.env.example + +# Local reverse-engineering inputs and vendor-owned artifacts. +*.bin +*.apk +*.apkm +*.zip +/_*.extracted/ +/ahh/ +/analysis/ +/uncompressed_text.txt +/.agents/ +/.aws/ +/.codex/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..854ef88 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,23 @@ +# Contributing + +Use `uv run --group dev` for tests and formatting; the README lists the commands. +Keep Tk imports in `gui.py` and protocol behavior in the shared session. Add +comments for observed firmware quirks and distinguish evidence from inference. + +For a protocol change, include a small packet fixture and explain the model, +transport, requested action, and observed result. Distinguish a packet ACK from +an actual state report. Never add real Bluetooth addresses or phone identifiers +to tests; use a synthetic address such as `12:34:56:78:9A:BC`. + +Do not commit firmware, APKs, decompiled vendor code, extracted configuration +files, or full personal Bluetooth traces. Document protocol facts in original +words and include only the small packet examples needed to reproduce a finding. + +Compatibility reports should state speaker model/firmware, Windows version, +Python version, whether audio playback also worked, and the relevant redacted +activity log. Tests using fake transports are useful but are not hardware +compatibility evidence. + +Before publishing, choose a license and initialize Git in the clean exported +folder. GitHub/Gitea repository creation and remote configuration are separate +from local export; the exporter never uploads anything. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..c3eeec5 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,10 @@ +include README.md CONTRIBUTING.md pyproject.toml requirements*.txt .gitignore +recursive-include docs *.md +recursive-include scripts *.py +recursive-include tests *.py +recursive-include .github *.yml +prune analysis +prune ahh +prune dist +prune build +global-exclude __pycache__ *.py[cod] *.bin *.apk *.apkm *.zip diff --git a/README.md b/README.md new file mode 100644 index 0000000..b228f4d --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# Sound Tower Controller + +A small, unofficial desktop remote for Samsung Sound Tower speakers. Control +EQ, bass, lighting, RGB color, volume, mute, playback, and source selection +through Bluetooth Low Energy. + +Verified interactively on one **Samsung MX-T40 with Windows**. Other models +and operating systems have not been verified. No firmware flashing, Samsung +account, or bundled vendor software is required. + +## Run with uv + +Use Windows Python for a Windows Bluetooth adapter. From WSL, invoke `uv.exe` +so Bluetooth runs on Windows rather than inside WSL. + +```powershell +uv run soundtower --address YOUR_SPEAKER_MAC +``` + +For WSL: + +```bash +uv.exe run soundtower --address YOUR_SPEAKER_MAC +``` + +Replace `YOUR_SPEAKER_MAC` with the Bluetooth address of your speaker. Keep it +powered on, close the phone app, and click **Connect**. The address can also be +entered in the window. `uv run python -m controller` remains supported, as does +the earlier `--with-requirements requirements.txt` launch command. + +Python 3.11 or newer is required; physical device testing used Windows Python +3.14. The interpreter needs Tcl/Tk. If the interpreter provided by uv lacks Tk, +use a Windows Python installation with Tk support, for example: + +```powershell +uv run --python C:\Path\To\Python\python.exe soundtower --address YOUR_SPEAKER_MAC +``` + +## Controls + +- EQ: Party, Standard, Hip-hop, EDM, Rock, Latin, House, Reggae. +- Bass boost, volume up/down, mute, play/pause, and audio source. +- Lighting: Off, Ambient, Party, Dance, Thunder, Star, Lover, Color. +- RGB color picker; lighting mode is queried after a color change. +- Readable activity log with optional raw packet display. + +A command acknowledgment means the packet was received. A later status report +confirms the reported setting; the log distinguishes the two. Choosing an RGB +color switches the tested speaker to COLOR mode. A play/pause event does not +reveal the absolute playback state. + +The source menu includes protocol options that may not exist on every model. +The tested MX-T40 reports Bluetooth, USB 1, AUX 1, and AUX 2. + +## Audio versus control + +This app opens the BLE remote-control connection. Windows establishes the +separate audio playback connection. Neither a BLE connection nor Windows' +word “Connected” guarantees an audio output is available. + +If the speaker disappears from Windows' output list, disconnect/reconnect it +through Windows and select it as the sound output. Both connection orders +have worked in testing; no fixed startup order is established. Once playback +works, the BLE controller can operate alongside it. + +The older RFCOMM/COM path is experimental and was unreliable on this MX-T40: +`--transport rfcomm` selects it. BLE is the default. + +## Development + +```bash +uv run --group dev python -m unittest discover -s tests -v +uv run --group dev ruff check controller tests scripts +uv run --group dev ruff format --check controller tests scripts +uv build +``` + +The first online `uv run` resolves dependencies and creates `uv.lock`; commit +that lockfile after resolving it for the project. A lockfile is not yet bundled +because dependency resolution was unavailable in the offline development shell. + +The tests use captured packet fixtures and fake transports; they do not need +a speaker, Tk, or a display. They do not replace physical compatibility tests. +The optional GitHub Actions workflow runs headless tests on Windows/Linux and +Python 3.11/3.14. The same commands can be used in Gitea CI. + +See [architecture](docs/ARCHITECTURE.md), [protocol](docs/PROTOCOL.md), +[feature research](docs/FEATURES.md), and [contributing](CONTRIBUTING.md). + +## Diagnostics + +List the MX-T40's advertised BLE/GATT endpoints without sending controls: + +```powershell +uv run python -m controller.ble_probe +``` + +Experimental Windows RFCOMM discovery: + +```powershell +uv run --extra diagnostics python -m controller.probe --channel 1 +``` + +## Preparing a repository + +This development folder also contains private research inputs. Export only the +controller, tests, and original documentation into a **new, empty directory**: + +```bash +python scripts/export_project.py dist/soundtower-controller +``` + +The export uses an explicit allowlist. Firmware, APKs, extracted vendor files, +decompiled Java, environment files, and caches are excluded. Initialize Git in +that exported directory before adding a GitHub or Gitea remote. No remote is +configured and nothing is uploaded automatically. + +## Project status + +This is an independent interoperability project, not an official Samsung or +Qualcomm product. It contains original controller code and protocol notes, +not redistributed firmware or application code. A project license has not yet +been selected; no open-source license grant is currently declared. diff --git a/controller/__init__.py b/controller/__init__.py new file mode 100644 index 0000000..7710bf9 --- /dev/null +++ b/controller/__init__.py @@ -0,0 +1 @@ +"""MX-T40 desktop controller.""" diff --git a/controller/__main__.py b/controller/__main__.py new file mode 100644 index 0000000..ad1febe --- /dev/null +++ b/controller/__main__.py @@ -0,0 +1,6 @@ +"""Preserve `python -m controller` alongside the installed soundtower command.""" + +from controller.cli import main + +if __name__ == "__main__": + main() diff --git a/controller/ble_link.py b/controller/ble_link.py new file mode 100644 index 0000000..b0b8f75 --- /dev/null +++ b/controller/ble_link.py @@ -0,0 +1,114 @@ +"""Samsung FFA0 BLE transport using the endpoints observed on the MX-T40.""" + +import asyncio +import queue +import time + +from controller.session import Session +from controller.worker import ConnectionWorker + +SERVICE = "0000ffa0-0000-1000-8000-00805f9b34fb" +WRITE = "0000ffa1-0000-1000-8000-00805f9b34fb" +NOTIFY = "0000ffa2-0000-1000-8000-00805f9b34fb" + + +class BLELink(ConnectionWorker): + """Own Bleak's event loop in a worker; Tk communicates only via queues.""" + + def work(self): + asyncio.run(self.run_ble()) + + async def run_ble(self): + from bleak import BleakClient, BleakScanner + + def report(text): + self.events.put(("status", text)) + self.events.put(("log", text)) + + report(f"Scanning for BLE speaker {self.port}…") + device = await BleakScanner.find_device_by_address(self.port, timeout=15) + if self.stopped.is_set(): + return + if device is None: + raise ConnectionError( + "No BLE advertisement from this address. Close the phone app and check the speaker is on." + ) + received = asyncio.Queue(maxsize=256) + overflow = asyncio.Event() + + def notified(characteristic, data): + # Notifications can split one packet or combine several packets; + # Decoder retains framing state across callback boundaries. + try: + received.put_nowait(bytes(data)) + except asyncio.QueueFull: + overflow.set() + + report("Connecting to speaker…") + async with BleakClient(device, timeout=20) as client: + service = client.services.get_service(SERVICE) + if service is None: + raise ConnectionError("Speaker does not expose Samsung FFA0 service.") + write = service.get_characteristic(WRITE) + notify = service.get_characteristic(NOTIFY) + if ( + write is None + or notify is None + or "write" not in write.properties + or "notify" not in notify.properties + ): + raise ConnectionError( + "Samsung FFA1 write / FFA2 notify endpoints are missing or incompatible." + ) + report("Listening for speaker updates…") + await asyncio.wait_for(client.start_notify(notify, notified), 10) + if self.stopped.is_set(): + return + # Samsung's parser ignores receive checksums. Preserve framed BLE + # replies with unknown checksums and log them for investigation; + # observed checksum variants are recognized by Decoder. + session = Session(strict_checksums=False) + + async def send(packet): + # All currently exposed commands fit in the baseline ATT + # payload of 20 bytes. Do not guess a fragmentation scheme. + if len(packet) > 20: + raise ValueError("BLE packet exceeds the verified 20-byte command limit.") + self.events.put(("log", "TX BLE " + packet.hex(" "))) + await asyncio.wait_for(client.write_gatt_char(write, packet, response=True), 10) + + report("Connected; identifying speaker…") + await send(session.start()) + deadline = time.monotonic() + 10 + last_sent = time.monotonic() + while not self.stopped.is_set(): + if not client.is_connected: + raise ConnectionError("The speaker closed the BLE connection.") + if overflow.is_set(): + raise BufferError( + "BLE notification queue overflow; disconnecting rather than losing framing." + ) + try: + chunk = await asyncio.wait_for(received.get(), 0.05) + except TimeoutError: + chunk = b"" + if chunk: + self.events.put(("log", "RX BLE " + chunk.hex(" "))) + update = session.receive(chunk) + self.publish(update) + for packet in update.outgoing: + await send(packet) + last_sent = time.monotonic() + if not session.ready and time.monotonic() > deadline: + raise TimeoutError( + "Samsung BLE endpoints connected, but the app handshake did not complete. Keep the TX/RX log." + ) + if session.ready and time.monotonic() - last_sent >= 0.1: + try: + payload = self.commands.get_nowait() + except queue.Empty: + continue + await send(session.command(payload)) + last_sent = time.monotonic() + # Bleak's context manager disconnects and stops notifications on + # exit, including handshake errors and an explicit GUI disconnect. diff --git a/controller/ble_probe.py b/controller/ble_probe.py new file mode 100644 index 0000000..987cda0 --- /dev/null +++ b/controller/ble_probe.py @@ -0,0 +1,65 @@ +"""Inspect MX-T40 BLE services without reading/writing control values.""" + +import argparse +import asyncio + +# MX-T40 model advertisements from the Android APK's strings.xml. These +# identify discovery records; they are not assumed to be writable services. +MODEL_UUIDS = { + f"0000{short}-0000-1000-8000-00805f9b34fb" for short in ("ff40", "fe40", "ff41", "fe41") +} + + +def is_candidate(device, advertisement): + names = f"{device.name or ''} {advertisement.local_name or ''}".upper() + return "T40" in names or bool( + MODEL_UUIDS.intersection(value.lower() for value in advertisement.service_uuids) + ) + + +async def inspect(timeout): + from bleak import BleakClient, BleakScanner + + print(f"Scanning BLE advertisements for {timeout:g} seconds…", flush=True) + devices = await BleakScanner.discover(timeout=timeout, return_adv=True) + candidates = [(device, adv) for device, adv in devices.values() if is_candidate(device, adv)] + print(f"Nearby BLE devices: {len(devices)}; MX-T40 candidates: {len(candidates)}") + if not candidates: + print("No matching advertisement. This does not rule out BLE support.") + print("Named nearby devices:") + for device, adv in devices.values(): + name = adv.local_name or device.name + if name: + print(f" {name} [{device.address}]") + return 1 + failures = False + for device, adv in candidates: + print(f"\nDevice: {adv.local_name or device.name} [{device.address}]") + print("Advertised services:", ", ".join(adv.service_uuids) or "(none)") + try: + # Use the scanned device object so Bleak retains Windows address + # type information; the classic audio MAC may not be the BLE MAC. + async with BleakClient(device, timeout=15) as client: + for service in client.services: + print(f"Service: {service.uuid} {service.description}") + for char in service.characteristics: + print(f" Characteristic: {char.uuid} [{', '.join(char.properties)}]") + except Exception as exc: + failures = True + print(f"GATT inspection failed: {type(exc).__name__}: {exc}") + print("\nNo control values were read or written; no notifications enabled.") + return 1 if failures else 0 + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--timeout", type=float, default=15) + args = parser.parse_args() + try: + raise SystemExit(asyncio.run(inspect(args.timeout))) + except ImportError: + print("Run: uv.exe run --python 3.14 --with bleak -m controller.ble_probe") + raise SystemExit(2) + except Exception as exc: + print(f"BLE scan failed: {type(exc).__name__}: {exc}") + raise SystemExit(1) diff --git a/controller/cli.py b/controller/cli.py new file mode 100644 index 0000000..5fd685d --- /dev/null +++ b/controller/cli.py @@ -0,0 +1,31 @@ +"""Command-line entry point; --help works without Tk or Bluetooth installed.""" + +import argparse + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Samsung Sound Tower desktop controller") + parser.add_argument( + "--address", default="", help="Speaker Bluetooth MAC address, or an outgoing COM port" + ) + parser.add_argument("--channel", type=int, default=1, choices=range(1, 31)) + parser.add_argument( + "--prepare", + action="store_true", + help="Experimental: initialize Windows Bluetooth before RFCOMM", + ) + parser.add_argument( + "--transport", + choices=("ble", "rfcomm"), + default="ble", + help="Control transport (default: Samsung BLE FFA0)", + ) + args = parser.parse_args(argv) + # Keep Tk out of the CLI import path, headless tests, and package consumers. + from controller.gui import launch + + launch(args.address, args.channel, args.prepare, args.transport) + + +if __name__ == "__main__": + main() diff --git a/controller/commands.py b/controller/commands.py new file mode 100644 index 0000000..b3a70ec --- /dev/null +++ b/controller/commands.py @@ -0,0 +1,58 @@ +"""Build control payloads without exposing packet layout to the GUI. + +Framing and sequence numbers belong to Session. These helpers describe user +actions and return payloads only; they do not connect or send anything. +""" + +from controller.protocol import EQ, LIGHTS + +SOURCES = {"Bluetooth": 1, "USB 1": 2, "USB 2": 3, "AUX 1": 4, "AUX 2": 5} +KEYS = {"volume_up": 15, "volume_down": 16, "play_pause": 17, "mute": 20} + + +def set_eq(preset): + if preset not in EQ.values(): + raise ValueError("Unknown EQ preset") + return bytes((0x40, 3, 0, preset, 0)) + + +def set_bass(enabled): + # Samsung's bass flag is inverted: 0 means on, 1 means off. + return bytes((0x40, 4, 0, 0 if enabled else 1, 0)) + + +def set_lighting(mode): + if mode not in LIGHTS.values(): + raise ValueError("Unknown lighting mode") + return bytes((0x50, 3, mode)) + + +def set_source(source): + if source not in SOURCES.values(): + raise ValueError("Unknown source") + return bytes((0x30, source)) + + +def set_rgb(red, green, blue): + if any(type(value) is not int or not 0 <= value <= 255 for value in (red, green, blue)): + raise ValueError("RGB values must be integers between 0 and 255") + return bytes((0x60, 2, red, green, blue)) + + +def remote_key(action): + try: + return bytes((0x70, KEYS[action])) + except KeyError as exc: + raise ValueError("Unknown remote action") from exc + + +def lighting_status(): + return b"\x52\x03" + + +def status_queries(): + return b"\x42\x03", lighting_status() + + +def initial_queries(): + return b"\x32", b"\x42\x03" diff --git a/controller/gui.py b/controller/gui.py new file mode 100644 index 0000000..7e64751 --- /dev/null +++ b/controller/gui.py @@ -0,0 +1,225 @@ +"""Tk interface; Bluetooth workers and protocol code never import this module.""" + +import queue +import tkinter as tk +from tkinter import colorchooser, ttk + +from controller import commands +from controller.ble_link import BLELink +from controller.link import Link +from controller.messages import ActivityLog, describe_reply +from controller.protocol import EQ, LIGHTS + + +class App: + def __init__(self, root, address="", channel=1, prepare=False, transport="ble"): + self.root = root + self.link = None + self.events = queue.Queue() + self.ready = False + self.channel = channel + self.prepare = prepare + self.transport = transport + self.activity = ActivityLog() + root.title("MX-T40 · Sound Tower") + root.minsize(480, 640) + root.protocol("WM_DELETE_WINDOW", self.close) + style = ttk.Style() + if "vista" in style.theme_names(): + style.theme_use("vista") + style.configure("TButton", padding=(10, 6)) + outer = ttk.Frame(root, padding=20) + outer.pack(fill="both", expand=True) + ttk.Label(outer, text="MX-T40", font=("Segoe UI", 22, "bold")).pack(anchor="w") + ttk.Label(outer, text="Sound Tower controller", font=("Segoe UI", 11)).pack( + anchor="w", pady=(0, 18) + ) + connection = ttk.Frame(outer) + connection.pack(fill="x") + ttk.Label(connection, text="Address / port").pack(side="left") + self.port = ttk.Combobox(connection, width=20) + self.port.set(address) + self.port.pack(side="left", padx=8) + ttk.Button(connection, text="Refresh", command=self.refresh).pack(side="left") + self.connect = ttk.Button(connection, text="Connect", command=self.toggle) + self.connect.pack(side="right") + self.status = tk.StringVar( + value="Enter the speaker’s Bluetooth address. Using Samsung BLE control." + if transport == "ble" + else f"Enter the speaker’s Bluetooth address. Direct RFCOMM channel: {channel}." + ) + ttk.Label(outer, textvariable=self.status, wraplength=440).pack(anchor="w", pady=(10, 16)) + + self.controls = ttk.Frame(outer) + self.controls.pack(fill="x") + self.selection("EQ preset", EQ, commands.set_eq) + self.selection("Bass boost", {"On": True, "Off": False}, commands.set_bass) + self.selection("Lighting mode", LIGHTS, commands.set_lighting) + self.selection("Source", commands.SOURCES, commands.set_source) + row = ttk.Frame(self.controls) + row.pack(fill="x", pady=5) + ttk.Label(row, text="Lighting color", width=17).pack(side="left") + ttk.Button(row, text="Choose color…", command=self.color).pack(side="left") + row = ttk.Frame(self.controls) + row.pack(fill="x", pady=(12, 5)) + for label, action in [ + ("Volume −", "volume_down"), + ("Volume +", "volume_up"), + ("Mute", "mute"), + ("Play / pause", "play_pause"), + ]: + ttk.Button( + row, text=label, command=lambda a=action: self.send(commands.remote_key(a)) + ).pack(side="left", padx=(0, 5)) + ttk.Button( + self.controls, text="Read EQ and lighting status", command=self.read_status + ).pack(anchor="w", pady=8) + ttk.Label(outer, text="Speaker status", font=("Segoe UI", 11, "bold")).pack( + anchor="w", pady=(16, 4) + ) + self.report = tk.StringVar(value="Waiting for a connection.") + ttk.Label(outer, textvariable=self.report, wraplength=440).pack(anchor="w") + activity_header = ttk.Frame(outer) + activity_header.pack(fill="x", pady=(12, 0)) + ttk.Label(activity_header, text="Activity", font=("Segoe UI", 11, "bold")).pack(side="left") + self.raw_packets = tk.BooleanVar(value=False) + ttk.Checkbutton( + activity_header, + text="Show raw packets", + variable=self.raw_packets, + command=self.render_log, + ).pack(side="right") + self.log = tk.Text( + outer, height=7, width=58, state="disabled", font=("Segoe UI", 10), wrap="word" + ) + self.log.pack(fill="both", expand=True, pady=(12, 0)) + self.enable(False) + self.refresh() + root.after(60, self.poll) + + def selection(self, label, values, build): + row = ttk.Frame(self.controls) + row.pack(fill="x", pady=5) + ttk.Label(row, text=label, width=17).pack(side="left") + choice = ttk.Combobox(row, values=list(values), state="readonly", width=17) + choice.set(next(iter(values))) + choice.pack(side="left", padx=(0, 8)) + ttk.Button(row, text="Apply", command=lambda: self.send(build(values[choice.get()]))).pack( + side="right" + ) + + def enable(self, enabled): + def visit(widget): + for child in widget.winfo_children(): + if isinstance(child, ttk.Combobox): + child.configure(state="readonly" if enabled else "disabled") + elif isinstance(child, ttk.Button): + child.configure(state="normal" if enabled else "disabled") + visit(child) + + visit(self.controls) + + def refresh(self): + try: + from serial.tools import list_ports + + ports = [p.device for p in list_ports.comports()] + self.port.configure(values=ports) + except ImportError: + # Direct RFCOMM does not need pyserial. Missing it only removes + # optional COM-port enumeration from the address dropdown. + self.port.configure(values=[]) + + def toggle(self): + if self.link: + self.link.stop() + self.connect.configure(state="disabled") + self.enable(False) + self.ready = False + self.status.set("Disconnecting…") + return + port = self.port.get().strip() + if not port: + self.status.set("Enter the MX-T40 Bluetooth address, or select its outgoing COM port.") + return + if self.transport == "ble": + self.link = BLELink(port, self.events) + else: + self.link = Link(port, self.events, self.channel, self.prepare) + self.activity.pending.clear() + self.port.configure(state="disabled") + self.connect.configure(text="Disconnect") + self.status.set("Connecting to speaker…") + self.link.start() + + def send(self, payload): + if self.ready and self.link: + try: + self.link.commands.put_nowait(payload) + self.status.set("Command queued. Speaker reports appear below when received.") + except queue.Full: + self.status.set("Command queue is full; wait a moment and try again.") + + def color(self): + color, _ = colorchooser.askcolor(parent=self.root, title="Lighting color") + if color: + self.send(commands.set_rgb(*(round(v) for v in color))) + # The owner observes the speaker switch to COLOR on RGB writes. + # Ask for the actual mode rather than treating its ACK as proof. + self.send(commands.lighting_status()) + + def read_status(self): + for payload in commands.status_queries(): + self.send(payload) + + def poll(self): + log_changed = False + try: + while True: + kind, value = self.events.get_nowait() + if kind in ("status", "error"): + self.status.set(value) + elif kind == "ready": + self.ready = True + self.enable(True) + self.status.set("Speaker handshake complete.") + elif kind == "closed": + self.link = None + self.ready = False + self.enable(False) + self.connect.configure(text="Connect", state="normal") + self.port.configure(state="normal") + if not self.status.get().startswith(("No ", "Malformed", "could", "Install")): + self.status.set("Disconnected. " + self.status.get()) + elif kind == "payload": + self.report.set(describe_reply(value)) + elif kind in ("log", "frame"): + self.activity.record(kind, value) + log_changed = True + except queue.Empty: + pass + if log_changed: + self.render_log() + self.root.after(60, self.poll) + + def render_log(self): + """Render the same bounded history in English or English plus raw bytes.""" + self.log.configure( + state="normal", font=("Consolas", 9) if self.raw_packets.get() else ("Segoe UI", 10) + ) + self.log.delete("1.0", "end") + self.log.insert("end", self.activity.text(raw=self.raw_packets.get())) + self.log.see("end") + self.log.configure(state="disabled") + + def close(self): + if self.link: + self.link.stop() + self.root.destroy() + + +def launch(address="", channel=1, prepare=False, transport="ble"): + """Create and run the Tk window on the calling (main) thread.""" + root = tk.Tk() + App(root, address, channel, prepare, transport) + root.mainloop() diff --git a/controller/link.py b/controller/link.py new file mode 100644 index 0000000..1b2cde4 --- /dev/null +++ b/controller/link.py @@ -0,0 +1,55 @@ +"""Experimental RFCOMM/serial adapter for the shared Samsung session.""" + +import queue +import time + +from controller.session import Session +from controller.transport import open_stream +from controller.worker import ConnectionWorker + + +class Link(ConnectionWorker): + def __init__(self, port, events, channel=1, prepare=False): + super().__init__(port, events) + self.channel = channel + self.prepare = prepare + + def work(self): + self.events.put(("log", f"Opening {self.port} (direct RFCOMM uses channel {self.channel})")) + + def report(text): + self.events.put(("status", text)) + self.events.put(("log", text)) + + with open_stream(self.port, self.channel, self.prepare, report) as stream: + session = Session(strict_checksums=True) + + def send(packet): + if stream.write(packet) != len(packet): + raise OSError("Incomplete Bluetooth write") + self.events.put(("log", "TX " + packet.hex(" "))) + + report("Bluetooth connected; waiting for speaker handshake…") + send(session.start()) + deadline = time.monotonic() + 10 + last_sent = time.monotonic() + while not self.stopped.is_set(): + chunk = stream.read(1024) + if chunk: + self.events.put(("log", "RX " + chunk.hex(" "))) + update = session.receive(chunk) + self.publish(update) + for packet in update.outgoing: + send(packet) + last_sent = time.monotonic() + if not session.ready and time.monotonic() > deadline: + raise TimeoutError( + "No completed speaker handshake. Close the phone app and check the TX/RX log." + ) + if session.ready and time.monotonic() - last_sent >= 0.1: + try: + payload = self.commands.get_nowait() + except queue.Empty: + continue + send(session.command(payload)) + last_sent = time.monotonic() diff --git a/controller/messages.py b/controller/messages.py new file mode 100644 index 0000000..653738f --- /dev/null +++ b/controller/messages.py @@ -0,0 +1,136 @@ +"""Translate Samsung payloads into activity text without claiming ACK = applied. + +Outgoing settings and incoming reports have different field layouts. Unknown +commands stay explicitly unmapped; raw packet bytes remain available separately. +""" + +from collections import deque + +from controller.protocol import EQ, LIGHTS + +SOURCES = {1: "Bluetooth", 2: "USB 1", 3: "USB 2", 4: "AUX 1", 5: "AUX 2"} +SOUND_TYPES = { + 1: "volume", + 2: "subwoofer volume", + 3: "EQ preset", + 4: "bass boost", + 5: "DJ effect", + 6: "tempo", + 7: "mute", +} +REMOTE_KEYS = { + 1: "Power", + 2: "Cycle lighting", + 7: "Cycle EQ preset", + 15: "Volume up", + 16: "Volume down", + 17: "Play / pause", + 18: "Next track", + 19: "Previous track", + 20: "Toggle mute", + 21: "Toggle bass boost", +} + + +def name_for(mapping, value): + return next((name for name, code in mapping.items() if code == value), f"Unknown ({value})") + + +def describe_reply(data): + """Read TYPE/VALUE/VALUE2 from reports, not the outgoing OPTION layout.""" + if len(data) >= 4 and data[0] == 0x41: + kind, value = data[1:3] + if kind == 3: + return "EQ: " + name_for(EQ, value) + if kind == 1: + return f"Volume: {value}" + if kind == 2: + return f"Subwoofer volume: {value}" + if kind == 4: + return "Bass boost: " + {0: "On", 1: "Off"}.get(value, f"Unknown ({value})") + if kind == 7: + return "Mute: " + {1: "On", 2: "Off"}.get(value, f"Unknown ({value})") + if len(data) >= 2 and data[0] == 0x31: + return "Source: " + SOURCES.get(data[1], f"Unknown ({data[1]})") + if len(data) >= 3 and data[:2] == b"\x51\x03": + return "Lighting: " + name_for(LIGHTS, data[2]) + if len(data) >= 2 and data[0] == 0x70: + # A remote-key echo is an action event, not an absolute playback state. + return "Button event: " + REMOTE_KEYS.get(data[1], f"Unknown key ({data[1]})") + if len(data) >= 7 and data[0] == 2 and len(data) >= 7 + data[5]: + sources = ", ".join( + SOURCES.get(value, f"Unknown ({value})") for value in data[6 : 6 + data[5]] + ) + return "Speaker identified. Available sources: " + sources + return "Unmapped speaker reply: " + data.hex(" ") + + +def describe_command(data): + """Describe the requested action; success must come from a later report.""" + if data == b"\x01": + return "Request speaker connection" + if data == b"\x04": + return "Complete speaker handshake" + if data == b"\x32": + return "Read audio source" + if len(data) >= 2 and data[0] == 0x42: + return "Read " + SOUND_TYPES.get(data[1], f"sound setting {data[1]}") + if data == b"\x52\x03": + return "Read lighting mode" + if len(data) >= 2 and data[0] == 0x70: + return REMOTE_KEYS.get(data[1], f"Remote key {data[1]}") + if len(data) >= 2 and data[0] == 0x30: + return "Set source to " + SOURCES.get(data[1], f"Unknown ({data[1]})") + if len(data) >= 5 and data[0] == 0x40: + if data[1] == 3: + return "Set EQ to " + name_for(EQ, data[3]) + if data[1] == 4: + return "Set bass boost to " + {0: "On", 1: "Off"}.get(data[3], f"Unknown ({data[3]})") + if len(data) >= 3 and data[:2] == b"\x50\x03": + return "Set lighting to " + name_for(LIGHTS, data[2]) + if len(data) >= 5 and data[:2] == b"\x60\x02": + return f"Set lighting color to #{data[2]:02X}{data[3]:02X}{data[4]:02X}" + return "Unmapped command: " + data.hex(" ") + + +class ActivityLog: + """Bounded readable/debug history with ACKs matched to request sequences.""" + + def __init__(self, limit=300): + self.entries = deque(maxlen=limit) + self.pending = {} + + def record(self, kind, value): + if kind == "frame": + if value.kind == 0: + request = self.pending.pop(value.sequence, f"request {value.sequence}") + self.entries.append(("Speaker acknowledged: " + request, None)) + elif value.payload: + self.entries.append(("Speaker reports: " + describe_reply(value.payload), None)) + return + if kind != "log": + return + if value.startswith(("TX BLE ", "TX ")): + raw = value.removeprefix("TX BLE ").removeprefix("TX ") + try: + packet = bytes.fromhex(raw) + if len(packet) >= 7: + description = describe_command(packet[6:-1]) + self.pending[packet[3]] = description + self.entries.append(("Sending: " + description, value)) + return + except ValueError: + pass + if value.startswith(("RX BLE ", "RX ")) and not value.startswith("RX checksum"): + self.entries.append((None, value)) + else: + self.entries.append((value, None)) + + def text(self, raw=False): + lines = [] + for readable, packet in self.entries: + if readable: + lines.append(readable) + if raw and packet: + lines.append(" " + packet) + return "\n".join(lines) + ("\n" if lines else "") diff --git a/controller/probe.py b/controller/probe.py new file mode 100644 index 0000000..adca73b --- /dev/null +++ b/controller/probe.py @@ -0,0 +1,141 @@ +"""Read Windows' RFCOMM service discovery results without sending app commands. + +Run separately from Tk so WinRT async operations use a clean event loop. +This diagnoses the real Windows Bluetooth stack, which WSL cannot reproduce. +""" + +import argparse +import asyncio +import socket +import sys +import warnings + +SPP_UUID = "00001101-0000-1000-8000-00805f9b34fb" + + +def check_channel(address, channel): + """Test the app's channel-1 fallback, without writing protocol bytes. + + A successful socket connection is evidence of an RFCOMM listener, not + evidence that the Samsung control handshake or commands are accepted. + """ + if not 1 <= channel <= 30: + raise ValueError("RFCOMM channel must be between 1 and 30") + if not hasattr(socket, "AF_BLUETOOTH") or not hasattr(socket, "BTPROTO_RFCOMM"): + raise RuntimeError( + "This Python build lacks Bluetooth sockets. Use Windows CPython 3.14 or newer." + ) + with socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM) as stream: + stream.settimeout(10) + stream.connect((address, channel)) + + +async def probe(name_filter, channel=None): + from winrt.windows.devices.bluetooth import BluetoothCacheMode, BluetoothDevice + from winrt.windows.devices.enumeration import DeviceInformation + + selector = BluetoothDevice.get_device_selector_from_pairing_state(True) + # Python/WinRT exposes the AQS string overload under a separate name. + # find_all_async(selector) can instead try converting the string to a + # DeviceClass integer, unlike the similarly named C# API. + devices = await asyncio.wait_for(DeviceInformation.find_all_async_aqs_filter(selector), 20) + matches = [device for device in devices if name_filter.casefold() in device.name.casefold()] + if not matches: + print(f"No paired Bluetooth Classic device matching {name_filter!r}.") + print("Paired device names:", ", ".join(device.name for device in devices) or "(none)") + return 1 + found_spp = False + channel_open = False + failures = False + for info in matches: + print(f"\nDevice: {info.name}") + device = None + try: + device = await asyncio.wait_for(BluetoothDevice.from_id_async(info.id), 20) + if device is None: + print("Windows could not open this device. Check Bluetooth access permissions.") + failures = True + continue + address_hex = f"{device.bluetooth_address:012X}" + address = ":".join(address_hex[i : i + 2] for i in range(0, 12, 2)) + print("Bluetooth address:", address) + # Uncached discovery asks the speaker instead of relying on the + # cached service list used by some Windows settings screens. + # Keep the explicit overload for compatibility with older WinRT + # projections; recent versions retain it as a deprecated alias. + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message=r"BluetoothDevice\.get_rfcomm_services_with_cache_mode_async\(\).*", + category=DeprecationWarning, + ) + result = await asyncio.wait_for( + device.get_rfcomm_services_with_cache_mode_async(BluetoothCacheMode.UNCACHED), + 30, + ) + print("Discovery status:", result.error) + if int(result.error) != 0: + failures = True + if not result.services: + print("No RFCOMM services returned. This is not proof the firmware lacks SPP.") + for service in result.services: + try: + service_uuid = str(service.service_id.uuid).lower() + is_spp = service_uuid == SPP_UUID + found_spp |= is_spp + print("Service:", service_uuid, "(SPP)" if is_spp else "") + print( + "Endpoint:", + service.connection_host_name.raw_name, + service.connection_service_name, + ) + finally: + service.close() + if channel is not None: + print(f"Testing direct RFCOMM channel {channel} (no application data)…", flush=True) + try: + await asyncio.to_thread(check_channel, address, channel) + channel_open = True + print("Direct channel connection: SUCCESS") + except Exception as exc: + failures = True + print(f"Direct channel connection: FAILED: {type(exc).__name__}: {exc}") + except Exception as exc: + print(f"Discovery failed: {type(exc).__name__}: {exc}") + failures = True + finally: + if device is not None: + device.close() + print("\nSPP service found:", found_spp) + print("No application commands were sent.") + return 0 if (found_spp or channel_open) and not failures else 1 + + +def main(): + if sys.platform != "win32": + print("Run this with Windows uv.exe in PowerShell, not Linux Python in WSL.") + return 2 + try: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("name", nargs="?", default="MX-T40") + parser.add_argument( + "--channel", + type=int, + choices=range(1, 31), + help="Test a direct RFCOMM channel without sending app commands", + ) + args = parser.parse_args() + return asyncio.run(probe(args.name, args.channel)) + except ImportError as exc: + print("Missing Windows diagnostic dependency:", exc) + print( + "Use: uv.exe run --with-requirements requirements-diagnostics.txt -m controller.probe" + ) + return 2 + except Exception as exc: + print(f"Windows Bluetooth query failed: {type(exc).__name__}: {exc}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/controller/protocol.py b/controller/protocol.py new file mode 100644 index 0000000..5a913d3 --- /dev/null +++ b/controller/protocol.py @@ -0,0 +1,95 @@ +"""Samsung app packet framing, independent of Bluetooth and the desktop UI.""" + +from dataclasses import dataclass + +EQ = dict( + zip(("Party", "Standard", "Hip-hop", "EDM", "Rock", "Latin", "House", "Reggae"), range(1, 9)) +) +LIGHTS = dict( + zip(("Off", "Ambient", "Party", "Dance", "Thunder", "Star", "Lover", "Color"), range(8)) +) + + +@dataclass(frozen=True) +class Frame: + kind: int + sequence: int + payload: bytes + checksum_valid: bool = True + + +class Encoder: + """Match CommandUtil, including its unusual signed-byte sequence rollover.""" + + def __init__(self): + self.sequence = 0 + + def encode(self, payload): + payload = bytes(payload) + if not 1 <= len(payload) <= 65535: + raise ValueError("Payload must contain 1–65535 bytes") + self.sequence = self.sequence % 127 + 1 + body = bytes((1, self.sequence)) + len(payload).to_bytes(2, "big") + payload + return b"\x00\xbb" + body + bytes((sum(body) & 255,)) + + +class Decoder: + """Buffer split/combined reads and optionally reject unknown checksums. + + Hardware captures show both checksum variants: connection/source replies + exclude the type byte, while an EQ reply includes it. Accept both observed + sums plus the observed fixed-value lighting formula. The exact firmware + paths selecting these variants are not yet known. Compatibility mode + retains mismatches but marks Frame.checksum_valid false. + A conservative 4096-byte cap prevents bogus lengths from indefinitely + hiding subsequent control frames. + """ + + def __init__(self, strict_checksums=True): + self.buffer = bytearray() + self.strict_checksums = strict_checksums + + def feed(self, chunk): + self.buffer.extend(chunk) + frames = [] + while True: + start = self.buffer.find(b"\x00\xbb") + if start < 0: + self.buffer[:] = b"\x00" if self.buffer.endswith(b"\x00") else b"" + break + del self.buffer[:start] + if len(self.buffer) < 7: + break + size = int.from_bytes(self.buffer[4:6], "big") + if self.buffer[2] not in (0, 1) or size > 4096: + del self.buffer[0] + continue + total = size + 7 + if len(self.buffer) < total: + break + checksum = sum(self.buffer[3 : total - 1]) & 255 + valid = self.buffer[total - 1] in (checksum, (checksum + self.buffer[2]) & 255) + # All captured 51/03 lighting reports (Ambient, Dance, Star, Lover) + # match a sum with the mode slot fixed at 1. This is an observed + # compatibility formula, not a claim about firmware implementation. + if ( + self.buffer[2] == 1 + and size == 3 + and self.buffer[6:8] == b"\x51\x03" + and self.buffer[8] <= 7 + ): + lighting_checksum = (checksum - self.buffer[8] + 1) & 255 + valid |= self.buffer[total - 1] == lighting_checksum + # Captured play/pause echoes sum as if the key slot were 1: + # sequence CD, payload 70/11 -> checksum 40; CE -> 41. + # Scope this rule to the observed payload bytes. + if self.buffer[2] == 1 and size == 2 and self.buffer[6:8] == b"\x70\x11": + valid |= self.buffer[total - 1] == (checksum - 0x11 + 1) & 255 + if not valid and self.strict_checksums: + del self.buffer[0] + continue + frames.append( + Frame(self.buffer[2], self.buffer[3], bytes(self.buffer[6 : total - 1]), valid) + ) + del self.buffer[:total] + return frames diff --git a/controller/session.py b/controller/session.py new file mode 100644 index 0000000..61035d4 --- /dev/null +++ b/controller/session.py @@ -0,0 +1,56 @@ +"""Samsung connection state shared by BLE and RFCOMM adapters. + +The caller supplies received bytes and writes returned packets. This module +owns sequence numbers, framing, connection-info validation, and readiness; +it has no threads, Bluetooth dependencies, timers, or UI dependencies. +""" + +from dataclasses import dataclass + +from controller.protocol import Decoder, Encoder, Frame + + +@dataclass(frozen=True) +class Update: + frames: tuple[Frame, ...] + outgoing: tuple[bytes, ...] + became_ready: bool = False + + +class Session: + """A fresh instance represents exactly one speaker connection.""" + + def __init__(self, strict_checksums=False): + self.encoder = Encoder() + self.decoder = Decoder(strict_checksums=strict_checksums) + self.ready = False + self._complete_sequence = None + + def start(self): + return self.command(b"\x01") + + def command(self, payload): + return self.encoder.encode(payload) + + def receive(self, chunk): + frames = tuple(self.decoder.feed(chunk)) + outgoing = [] + became_ready = False + for frame in frames: + # Speaker notifications have their own sequence counter. Only an + # ACK for our link-complete request establishes session readiness. + if frame.kind == 0 and frame.sequence == self._complete_sequence and not self.ready: + self.ready = True + became_ready = True + if ( + frame.kind == 1 + and frame.payload + and frame.payload[0] == 2 + and self._complete_sequence is None + ): + payload = frame.payload + if len(payload) < 7 or len(payload) < 7 + payload[5]: + raise ValueError("Malformed speaker connection response") + outgoing.append(self.command(b"\x04")) + self._complete_sequence = self.encoder.sequence + return Update(frames, tuple(outgoing), became_ready) diff --git a/controller/transport.py b/controller/transport.py new file mode 100644 index 0000000..3275577 --- /dev/null +++ b/controller/transport.py @@ -0,0 +1,109 @@ +"""Byte-stream adapters for direct RFCOMM and optional serial COM ports.""" + +import asyncio +import re +import socket + + +class RfcommStream: + """Adapt an RFCOMM socket to the worker's serial-like read/write API. + + Idle reads return empty bytes, as pyserial does; a socket EOF instead + raises, so a lost speaker connection cannot look like endless idle time. + """ + + def __init__(self, address, channel=1): + self.address = address + self.channel = channel + self.socket = None + + def __enter__(self): + if not 1 <= self.channel <= 30: + raise ValueError("RFCOMM channel must be between 1 and 30") + if not hasattr(socket, "AF_BLUETOOTH") or not hasattr(socket, "BTPROTO_RFCOMM"): + raise RuntimeError( + "Direct Bluetooth requires a Python build with RFCOMM support. Use Windows Python 3.14." + ) + self.socket = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM) + try: + self.socket.settimeout(10) + self.socket.connect((self.address, self.channel)) + except TimeoutError as exc: + self.socket.close() + self.socket = None + raise TimeoutError( + f"Bluetooth connection timed out before the Samsung handshake " + f"({self.address}, channel {self.channel})." + ) from exc + except BaseException: + self.socket.close() + self.socket = None + raise + return self + + def __exit__(self, *args): + if self.socket is not None: + self.socket.close() + self.socket = None + + def write(self, data): + self.socket.settimeout(2) + # sendall handles partial socket writes. Never retry a whole command + # after a send failure: remote key actions could otherwise repeat. + try: + self.socket.sendall(data) + except TimeoutError as exc: + raise TimeoutError( + "Bluetooth connected, but sending a Samsung packet timed out." + ) from exc + return len(data) + + def read(self, size): + self.socket.settimeout(0.05) + try: + data = self.socket.recv(size) + except TimeoutError: + return b"" + if not data: + raise ConnectionError("The speaker closed the Bluetooth connection.") + return data + + +class PreparedRfcommStream(RfcommStream): + """Keep the WinRT device alive across the socket session, as the probe did.""" + + def __init__(self, address, channel, report): + super().__init__(address, channel) + self.report = report + self.device = None + + def __enter__(self): + from controller.windows_bluetooth import prepare_device + + self.device = asyncio.run(prepare_device(self.address, self.report)) + try: + self.report("Opening direct RFCOMM socket with Windows device handle retained…") + return super().__enter__() + except BaseException: + self.device.close() + self.device = None + raise + + def __exit__(self, *args): + try: + super().__exit__(*args) + finally: + if self.device is not None: + self.device.close() + self.device = None + + +def open_stream(target, channel=1, prepare=False, report=lambda text: None): + """A Bluetooth MAC selects direct RFCOMM; other targets select serial.""" + if re.fullmatch(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", target): + if prepare: + return PreparedRfcommStream(target, channel, report) + return RfcommStream(target, channel) + import serial + + return serial.Serial(target, 115200, timeout=0.05, write_timeout=2) diff --git a/controller/windows_bluetooth.py b/controller/windows_bluetooth.py new file mode 100644 index 0000000..2d1f555 --- /dev/null +++ b/controller/windows_bluetooth.py @@ -0,0 +1,43 @@ +"""Optional WinRT initialization matching the successful service probe.""" + +import asyncio +import warnings + + +async def prepare_device(address, report): + """Return a live Windows device handle after uncached service discovery. + + Keeping this handle alive is an experiment based on the difference between + the successful probe and a failing bare socket connect. It is not evidence + that every RFCOMM device requires this initialization. + """ + from winrt.windows.devices.bluetooth import BluetoothCacheMode, BluetoothDevice + + report("Opening speaker through Windows Bluetooth API…") + device = await asyncio.wait_for( + BluetoothDevice.from_bluetooth_address_async(int(address.replace(":", ""), 16)), 20 + ) + if device is None: + raise ConnectionError("Windows Bluetooth API could not open the speaker.") + try: + report("Querying speaker services before direct connection…") + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message=r"BluetoothDevice\.get_rfcomm_services_with_cache_mode_async\(\).*", + category=DeprecationWarning, + ) + result = await asyncio.wait_for( + device.get_rfcomm_services_with_cache_mode_async(BluetoothCacheMode.UNCACHED), 30 + ) + report( + f"Windows service query: status {result.error}, {len(result.services)} RFCOMM services" + ) + for service in result.services: + service.close() + # Empty SDP results were also observed in the successful direct probe; + # they must not prevent the explicit channel-1 connection attempt. + return device + except BaseException: + device.close() + raise diff --git a/controller/worker.py b/controller/worker.py new file mode 100644 index 0000000..df0cdbb --- /dev/null +++ b/controller/worker.py @@ -0,0 +1,55 @@ +"""Thread/event-queue interface shared by connection adapters. + +The GUI enqueues payload bytes in commands and consumes events. Only a worker +touches its transport; workers never call Tk. Settings are never retried +automatically because repeating a remote key could perform an action twice. +""" + +import queue +import threading + +from controller.commands import initial_queries +from controller.session import Update + + +class ConnectionWorker(threading.Thread): + def __init__(self, target, events): + super().__init__(daemon=True) + self.port = target + self.events = events + self.commands = queue.Queue(maxsize=32) + self.stopped = threading.Event() + + def run(self): + try: + self.work() + except Exception as exc: + self.events.put(("log", f"ERROR {type(exc).__name__}: {exc}")) + self.events.put(("error", str(exc))) + finally: + self.events.put(("closed", None)) + + def work(self): + raise NotImplementedError + + def stop(self): + self.stopped.set() + + def publish(self, update: Update): + """Deliver replies and queue initial queries once per handshake.""" + for frame in update.frames: + self.events.put(("frame", frame)) + if not frame.checksum_valid: + self.events.put( + ( + "log", + "RX checksum differs from known rules; retained payload " + + frame.payload.hex(" "), + ) + ) + if frame.kind and frame.payload: + self.events.put(("payload", frame.payload)) + if update.became_ready: + self.events.put(("ready", None)) + for payload in initial_queries(): + self.commands.put_nowait(payload) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..171bbee --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,58 @@ +# Architecture + +The protocol module has no Bluetooth or GUI dependencies. Tk exists only in +`controller/gui.py`, which `cli.py` imports after parsing arguments. Importing +the package or running `--help` does not load Tk or Bleak. + +| Module | Responsibility | +| --- | --- | +| `protocol.py` | Packet encoding, stream framing, observed checksum variants | +| `commands.py` | Semantic command builders; no packet layout in Tk callbacks | +| `session.py` | Handshake, outgoing sequence numbers, connection readiness | +| `messages.py` | English command/reply descriptions and bounded activity history | +| `worker.py` | Thread lifecycle and event/command queues | +| `ble_link.py` | Bleak connection, notifications, characteristic writes | +| `link.py`, `transport.py` | Experimental RFCOMM and serial adapters | +| `gui.py` | Tk controls and event rendering | +| `cli.py`, `__main__.py` | Installed command and backwards-compatible module entry | +| `ble_probe.py`, `probe.py` | Endpoint diagnostics | +| `windows_bluetooth.py` | Experimental RFCOMM initialization | + +## Session interface + +Create one `Session` per connection. Write `session.start()` to begin. Pass +received chunks to `session.receive(chunk)` and write every returned `outgoing` +packet. `Update.frames` carries decoded packets; `became_ready` is emitted once +when the speaker acknowledges the link-complete request. Use +`session.command(payload)` for subsequent outgoing packets. + +Both transports cross this same seam. The session owns framing and the state +machine, so fixes do not need to be repeated in separate Bluetooth loops. + +A transport connection, a GATT write response, a Samsung packet acknowledgment, +and a speaker state report are distinct events. Only the link-complete ACK +makes the session ready. Speaker notifications have an independent sequence +counter and cannot substitute for that ACK. + +## Threads and ownership + +Tk runs on the main thread. Each connection worker owns its transport. BLE's +async event loop lives entirely within the worker thread. The GUI submits +payloads through a bounded command queue and consumes events through another +queue; workers never call Tk methods. + +BLE notifications enter a bounded async queue and can contain partial or +combined packets. The decoder retains framing state between notifications. +Queue overflow terminates the connection rather than silently losing framing. + +Writes are spaced and never automatically retried. Retrying a volume or mute +key could perform the action twice. The GUI enables controls only after the +protocol handshake and disables them on connection closure. + +## Testing + +Protocol tests use captured byte fixtures independently of the outgoing +encoder. Session tests cover split replies, duplicate replies, and ACK matching. +Transport tests verify subscription order, write mode, disconnects, resource +cleanup, and readable error phases. Presentation tests verify the difference +between acknowledgment and a reported state. No test opens physical Bluetooth. diff --git a/docs/FEATURES.md b/docs/FEATURES.md new file mode 100644 index 0000000..56a7a20 --- /dev/null +++ b/docs/FEATURES.md @@ -0,0 +1,32 @@ +# Implemented and potential features + +“Verified” below means the owner exercised the control on a physical MX-T40, +with packet reports or direct observation. It does not establish compatibility +with other Samsung models. + +| Feature | Status | +| --- | --- | +| BLE connection/status queries | Verified | +| EQ presets | Verified, including Rock/Hip-hop/Standard transitions | +| Bass boost, volume, mute | Verified | +| Lighting presets and RGB color | Verified; RGB activates COLOR mode | +| Play/pause and source selection | Owner reports working; playback echo is an event, not absolute state | +| DJ effects: off, delay, filter, flanger, chorus, wah-wah | Found in Android app; not implemented or tested here | +| Tempo controls | Found in Android app; not implemented or tested here | +| USB playback, repeat/shuffle, browsing and track/time information | Found in Android app; not implemented or tested here | +| Speaker grouping / wireless party controls | Found in Android app; not implemented or tested here | +| Bluetooth multi-connect setting/status | Found in Android app; model compatibility unverified | +| Arbitrary per-band EQ | No Samsung app command established | + +## Next work + +Keep the working transport stable. Add read-only status decoding before new +controls, then validate one feature at a time on hardware. DJ effects are a +useful next feature because the app contains both effect selection and level +commands. Grouping is more involved because it changes relationships between +speakers and needs more than one device for testing. + +The firmware XML is a Qualcomm configuration schema, not a dump of configured +values or a Samsung command dictionary. It documents audio routing/gain curves, +button/LED patterns, codec-related definitions, power policies, and GAIA fields. +Listed schema options do not prove those features are enabled on the MX-T40. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..0b5a115 --- /dev/null +++ b/docs/PROTOCOL.md @@ -0,0 +1,107 @@ +# Samsung control protocol notes + +These original interoperability notes describe observations from an MX-T40 and +analysis of the Android Sound Tower app version 0.0.80. Vendor application code +and firmware are not included. The working desktop transport is BLE; the iOS +app's internal transport/framework has not been established. + +## BLE endpoints + +| Role | UUID | +| --- | --- | +| Samsung service | `0000ffa0-0000-1000-8000-00805f9b34fb` | +| Write, with response | `0000ffa1-0000-1000-8000-00805f9b34fb` | +| Notify | `0000ffa2-0000-1000-8000-00805f9b34fb` | + +Subscribe before sending the connection request. Commands currently exposed by +the GUI fit within 20 bytes; no larger-command fragmentation scheme is claimed. +The speaker also exposes a separate Qualcomm service with UUID +`00001100-d102-11e1-9b23-00025b00a5a5`, which this controller does not use. + +MX-T40 model advertisements include FE40/FF40; the Android discovery code also +maps FE41/FF41 to T40 names. An advertisement UUID identifies discovery, not +necessarily a writable control service. + +## Framing + +```text +00 BB TYPE SEQUENCE LENGTH_HI LENGTH_LO PAYLOAD... CHECKSUM +``` + +Type 0 is an acknowledgment; type 1 carries data. Length counts payload bytes +only, in big-endian order. Outgoing sequence numbers cycle 1–127, matching the +Android app's signed-byte rollover. Incoming notifications use a separate +sequence counter, observed above 127 as well. + +Outgoing checksums sum type, sequence, both length bytes, and payload, modulo +256. Incoming packets have several observed checksum variants: + +- Connection/source reports: sum excluding type. +- Sound reports: sum including type. +- Lighting reports `51 03 MODE`: captured packets match a checksum calculated + as though MODE were 1, across Ambient/Dance/Star/Lover/Off observations. +- Play/pause echoes `70 11`: the two captured replies similarly match a key + slot of 1. This compatibility rule is scoped to that specific payload. + +Why the firmware emits these variants is unknown. Strict decoding recognizes +these observed rules. BLE uses advisory mode, retaining structurally framed +packets with unfamiliar checksums and flagging them in the activity log. This +is compatibility behavior, not a cryptographic integrity guarantee. + +## Handshake + +1. Send payload `01`. +2. Receive ACK and connection information beginning `02`. +3. Send payload `04` (link complete). +4. Receive the ACK matching that outgoing sequence; only then enable controls. +5. Query source (`32`) and EQ (`42 03`). + +The captured connection info contains model byte `34` and source IDs +`01 02 04 05`: Bluetooth, USB 1, AUX 1, AUX 2. The two bytes following the initial +`02` are interpreted by the Android app as protocol version. + +## Payloads + +Numbers below are hexadecimal unless explicitly called decimal. + +| Action | Payload | +| --- | --- | +| Remote key | `70 KEY` | +| Select source | `30 SOURCE` | +| Query source | `32` | +| Set sound option | `40 TYPE OPTION VALUE EXTRA` | +| Query sound option | `42 TYPE` | +| Set system option | `50 TYPE VALUE` | +| Query system option | `52 TYPE` | +| Set RGB color | `60 02 RED GREEN BLUE` | + +Sound TYPE: volume=1, subwoofer=2, EQ=3, bass=4, DJ=5, tempo=6, mute=7. +For the implemented EQ/bass controls OPTION and EXTRA are zero. +Sound replies use `41 TYPE VALUE VALUE2`, a different layout from writes. + +EQ values (decimal): Party=1, Standard=2, Hip-hop=3, EDM=4, Rock=5, Latin=6, +House=7, Reggae=8. Bass values: on=0, off=1. Mute reports: on=1, off=2. + +System TYPE=3 controls lighting. Mode values (decimal): Off=0, Ambient=1, +Party=2, Dance=3, Thunder=4, Star=5, Lover=6, Color=7. Reply: `51 03 MODE`. +The speaker displays COLOR when the owner chooses an RGB color. The desktop +queries lighting mode after RGB writes rather than treating ACK as mode proof. + +Remote keys: volume up=`0F`, volume down=`10`, play/pause=`11`, mute=`14`. +A reply `70 11` is a button-event echo, not an absolute playing/paused state. + +## Additional app commands, untested here + +The app defines grouping `05 TYPE CONTROL`, USB playback/browsing/status and +repeat controls, DJ selection/levels, tempo, and Bluetooth multi-connect state. +See FEATURES.md for the distinction between discovered and verified behavior. +Do not infer arbitrary EQ-band access from the firmware configuration schema. + +## Evidence and limits + +Tests contain small, independently captured packet fixtures: connection info, +ACKs, source/EQ replies, lighting checksum variants, and play/pause echoes. Real +Bluetooth addresses are replaced with synthetic ones. Physical verification +covers one MX-T40 on Windows; other models, firmware versions, and platforms +remain unverified. RFCOMM channel 1 worked initially but later failed repeatedly; +no general explanation or guaranteed workaround has been established. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fb749d6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "soundtower-controller" +version = "0.1.0" +description = "Unofficial Samsung Sound Tower BLE desktop controller" +readme = "README.md" +requires-python = ">=3.11" +dependencies = ["bleak>=3,<4", "pyserial>=3.5,<4"] +keywords = ["bluetooth", "ble", "speaker", "mx-t40"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Programming Language :: Python :: 3", + "Operating System :: Microsoft :: Windows", +] + +[project.optional-dependencies] +diagnostics = [ + "winrt-Windows.Devices.Bluetooth; sys_platform == 'win32'", + "winrt-Windows.Devices.Bluetooth.Rfcomm; sys_platform == 'win32'", + "winrt-Windows.Devices.Enumeration; sys_platform == 'win32'", +] + +[dependency-groups] +dev = ["ruff>=0.15,<0.16", "build>=1.2,<2"] + +[project.scripts] +soundtower = "controller.cli:main" + +[tool.setuptools] +packages = ["controller"] + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I", "UP"] diff --git a/requirements-diagnostics.txt b/requirements-diagnostics.txt new file mode 100644 index 0000000..6c38f01 --- /dev/null +++ b/requirements-diagnostics.txt @@ -0,0 +1,4 @@ +winrt-Windows.Devices.Bluetooth; sys_platform == 'win32' +winrt-Windows.Devices.Bluetooth.Rfcomm; sys_platform == 'win32' +winrt-Windows.Devices.Enumeration; sys_platform == 'win32' +-r requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3dc1238 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pyserial>=3.5,<4 +bleak>=3,<4 diff --git a/scripts/export_project.py b/scripts/export_project.py new file mode 100644 index 0000000..2fe0eb6 --- /dev/null +++ b/scripts/export_project.py @@ -0,0 +1,83 @@ +"""Copy an explicit source allowlist into a new repository directory. + +This workspace also holds firmware research. Exporting from an allowlist, +rather than recursively copying the workspace and subtracting known inputs, +prevents newly added vendor artifacts from silently entering a release. +The destination must be empty; existing content is never replaced. +""" + +import argparse +import shutil +from pathlib import Path + +ROOT_FILES = ( + "README.md", + "CONTRIBUTING.md", + "LICENSE", + "LICENSE.txt", + "pyproject.toml", + "uv.lock", + "MANIFEST.in", + "requirements.txt", + "requirements-diagnostics.txt", + ".gitignore", +) +TREES = { + "controller": {".py"}, + "tests": {".py"}, + "docs": {".md"}, + "scripts": {".py"}, + ".github": {".yml", ".yaml"}, +} + + +def source_files(root): + root = Path(root).resolve() + selected = [root / name for name in ROOT_FILES if (root / name).is_file()] + for folder, suffixes in TREES.items(): + selected.extend( + path + for path in (root / folder).rglob("*") + if path.is_file() and path.suffix in suffixes and "__pycache__" not in path.parts + ) + for path in selected: + if path.is_symlink() or not path.resolve().is_relative_to(root): + raise ValueError( + f"Export refuses symlinked or external source: {path.relative_to(root)}" + ) + return sorted(selected) + + +def export_project(root, destination): + root = Path(root).resolve() + destination = Path(destination) + if destination.is_symlink(): + raise ValueError("Export destination cannot be a symlink") + destination = destination.resolve() + if destination == root or any(destination.is_relative_to(root / folder) for folder in TREES): + raise ValueError("Choose a destination outside the included source folders") + if destination.exists() and (not destination.is_dir() or any(destination.iterdir())): + raise ValueError("Export destination must be a new or empty directory") + files = source_files(root) + destination.mkdir(parents=True, exist_ok=True) + for source in files: + target = destination / source.relative_to(root) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + return len(files) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("destination", type=Path) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + try: + count = export_project(root, args.destination) + except ValueError as exc: + parser.error(str(exc)) + print(f"Exported {count} source/documentation files to {args.destination}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_ble_link.py b/tests/test_ble_link.py new file mode 100644 index 0000000..d92e771 --- /dev/null +++ b/tests/test_ble_link.py @@ -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]) diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..135f911 --- /dev/null +++ b/tests/test_commands.py @@ -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) diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..b90fa93 --- /dev/null +++ b/tests/test_export.py @@ -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") diff --git a/tests/test_imports.py b/tests/test_imports.py new file mode 100644 index 0000000..af7439c --- /dev/null +++ b/tests/test_imports.py @@ -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) diff --git a/tests/test_link.py b/tests/test_link.py new file mode 100644 index 0000000..1059db3 --- /dev/null +++ b/tests/test_link.py @@ -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)) diff --git a/tests/test_messages.py b/tests/test_messages.py new file mode 100644 index 0000000..db2b6de --- /dev/null +++ b/tests/test_messages.py @@ -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") diff --git a/tests/test_probe.py b/tests/test_probe.py new file mode 100644 index 0000000..7864062 --- /dev/null +++ b/tests/test_probe.py @@ -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() diff --git a/tests/test_protocol.py b/tests/test_protocol.py new file mode 100644 index 0000000..91a7f7d --- /dev/null +++ b/tests/test_protocol.py @@ -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() diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..8b52f34 --- /dev/null +++ b/tests/test_session.py @@ -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, ()) diff --git a/tests/test_status.py b/tests/test_status.py new file mode 100644 index 0000000..10315db --- /dev/null +++ b/tests/test_status.py @@ -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) diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..0b36695 --- /dev/null +++ b/tests/test_transport.py @@ -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()