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

+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)