Add MX-T40 BLE desktop controller and protocol documentation
Checks / tests (ubuntu-latest, 3.11) (push) Canceled after 0s
Checks / tests (ubuntu-latest, 3.14) (push) Canceled after 0s
Checks / tests (windows-latest, 3.11) (push) Canceled after 0s
Checks / tests (windows-latest, 3.14) (push) Canceled after 0s
Checks / package (push) Canceled after 0s
Checks / tests (ubuntu-latest, 3.11) (push) Canceled after 0s
Checks / tests (ubuntu-latest, 3.14) (push) Canceled after 0s
Checks / tests (windows-latest, 3.11) (push) Canceled after 0s
Checks / tests (windows-latest, 3.14) (push) Canceled after 0s
Checks / package (push) Canceled after 0s
This commit is contained in:
38 files changed
+2344
No files matched your search
@@ -0,0 +1 @@
|
||||
"""MX-T40 desktop controller."""
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Preserve `python -m controller` alongside the installed soundtower command."""
|
||||
|
||||
from controller.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 "")
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user