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

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

No files matched your search

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