Add in-app speaker discovery and connection guide

This commit is contained in:
lda committed 2026-10-25 04:59:07 +07:00 Verified
1 parent 038e152bbc
commit f75d3fb558
7 files changed
+287 -28

No files matched your search

+1 -12
View File
@@ -3,18 +3,7 @@
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)
)
from controller.discovery import is_candidate
async def inspect(timeout):
+92
View File
@@ -0,0 +1,92 @@
"""Find Sound Tower advertisements without connecting or loading Tk.
Some MX-T40 advertisements have no device name. Model UUIDs are therefore
important: the observed FE40 advertisement must still produce a selectable
speaker. These UUIDs identify discovery records, not verified control support.
"""
import argparse
import asyncio
import threading
from dataclasses import dataclass
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 any(name in names for name in ("T40", "MX-T", "MX-ST", "SOUND TOWER")) or bool(
MODEL_UUIDS.intersection(value.lower() for value in advertisement.service_uuids)
)
@dataclass(frozen=True)
class Speaker:
address: str
name: str
@property
def label(self):
return f"{self.name} — {self.address}"
def selected_address(selection, speakers):
"""Resolve a discovered label while preserving manually entered addresses."""
selection = selection.strip()
return next((speaker.address for speaker in speakers if speaker.label == selection), selection)
async def scan_speakers(timeout=8):
from bleak import BleakScanner
found = await BleakScanner.discover(timeout=timeout, return_adv=True)
speakers = {}
for device, advertisement in found.values():
if not is_candidate(device, advertisement):
continue
name = advertisement.local_name or device.name
if not name:
name = "MX-T40 (model advertisement)"
# Keep addresses in the choice label to distinguish identical names;
# deduplicate case-insensitively without changing the backend's address.
speakers[device.address.casefold()] = Speaker(device.address, name)
return sorted(speakers.values(), key=lambda speaker: (speaker.name.casefold(), speaker.address))
class ScanWorker(threading.Thread):
"""Run the bounded BLE scan off Tk's thread; return results through its queue."""
def __init__(self, events, timeout=8):
super().__init__(daemon=True)
self.events = events
self.timeout = timeout
def run(self):
try:
self.events.put(("scan_results", asyncio.run(scan_speakers(self.timeout))))
except Exception as exc:
self.events.put(("scan_error", str(exc)))
def main():
parser = argparse.ArgumentParser(
description="List nearby Sound Tower names and Bluetooth addresses"
)
parser.add_argument("--timeout", type=float, default=8)
args = parser.parse_args()
if args.timeout <= 0:
parser.error("timeout must be positive")
try:
speakers = asyncio.run(scan_speakers(args.timeout))
except Exception as exc:
parser.exit(1, f"Bluetooth scan failed: {exc}\n")
if not speakers:
print("No Sound Tower found. Turn it on, close the phone app, and scan again.")
for speaker in speakers:
print(speaker.label)
if __name__ == "__main__":
main()
+79 -9
View File
@@ -6,6 +6,7 @@ from tkinter import colorchooser, ttk
from controller import commands
from controller.ble_link import BLELink
from controller.discovery import ScanWorker, selected_address
from controller.link import Link
from controller.messages import ActivityLog, describe_reply
from controller.protocol import EQ, LIGHTS
@@ -21,8 +22,10 @@ class App:
self.prepare = prepare
self.transport = transport
self.activity = ActivityLog()
self.discovered = []
self.scanning = False
root.title("MX-T40 · Sound Tower")
root.minsize(480, 640)
root.minsize(520, 640)
root.protocol("WM_DELETE_WINDOW", self.close)
style = ttk.Style()
if "vista" in style.theme_names():
@@ -36,15 +39,27 @@ class App:
)
connection = ttk.Frame(outer)
connection.pack(fill="x")
ttk.Label(connection, text="Address / port").pack(side="left")
self.port = ttk.Combobox(connection, width=20)
connection.columnconfigure(0, weight=1)
ttk.Label(connection, text="Speaker" if transport == "ble" else "Address / port").grid(
row=0, column=0, sticky="w"
)
self.port = ttk.Combobox(connection, width=30)
self.port.set(address)
self.port.pack(side="left", padx=8)
ttk.Button(connection, text="Refresh", command=self.refresh).pack(side="left")
self.port.grid(row=1, column=0, sticky="ew", padx=(0, 8))
self.scan_button = ttk.Button(
connection, text="Scan" if transport == "ble" else "Refresh", command=self.refresh
)
self.scan_button.grid(row=1, column=1, padx=(0, 8))
self.connect = ttk.Button(connection, text="Connect", command=self.toggle)
self.connect.pack(side="right")
self.connect.grid(row=1, column=2)
if transport == "ble":
ttk.Label(
connection,
text="Turn the speaker on → Scan → choose it → Connect. Or enter its Bluetooth address.",
wraplength=440,
).grid(row=2, column=0, columnspan=3, sticky="w", pady=(8, 0))
self.status = tk.StringVar(
value="Enter the speaker’s Bluetooth address. Using Samsung BLE control."
value="Click Scan to find nearby speakers. Close the phone app if the speaker does not appear."
if transport == "ble"
else f"Enter the speaker’s Bluetooth address. Direct RFCOMM channel: {channel}."
)
@@ -94,7 +109,8 @@ class App:
)
self.log.pack(fill="both", expand=True, pady=(12, 0))
self.enable(False)
self.refresh()
if transport != "ble":
self.refresh()
root.after(60, self.poll)
def selection(self, label, values, build):
@@ -120,6 +136,16 @@ class App:
visit(self.controls)
def refresh(self):
if self.link or self.scanning:
return
if self.transport == "ble":
self.scanning = True
self.scan_button.configure(text="Scanning…", state="disabled")
self.connect.configure(state="disabled")
self.port.configure(state="disabled")
self.status.set("Searching for nearby speakers…")
ScanWorker(self.events).start()
return
try:
from serial.tools import list_ports
@@ -139,8 +165,13 @@ class App:
self.status.set("Disconnecting…")
return
port = self.port.get().strip()
port = selected_address(port, self.discovered)
if not port:
self.status.set("Enter the MX-T40 Bluetooth address, or select its outgoing COM port.")
self.status.set(
"Click Scan and choose your speaker, or enter its Bluetooth address."
if self.transport == "ble"
else "Enter the Bluetooth address or outgoing COM port."
)
return
if self.transport == "ble":
self.link = BLELink(port, self.events)
@@ -148,6 +179,7 @@ class App:
self.link = Link(port, self.events, self.channel, self.prepare)
self.activity.pending.clear()
self.port.configure(state="disabled")
self.scan_button.configure(state="disabled")
self.connect.configure(text="Disconnect")
self.status.set("Connecting to speaker…")
self.link.start()
@@ -179,6 +211,43 @@ class App:
kind, value = self.events.get_nowait()
if kind in ("status", "error"):
self.status.set(value)
elif kind in ("scan_results", "scan_error"):
self.scanning = False
self.scan_button.configure(text="Scan", state="normal")
self.connect.configure(state="normal")
self.port.configure(state="normal")
if kind == "scan_error":
self.status.set(
f"Scan failed: {value}. Check Windows Bluetooth is on and try again."
)
continue
previous = selected_address(self.port.get(), self.discovered)
self.discovered = value
self.port.configure(values=[speaker.label for speaker in value])
matching = next(
(
speaker
for speaker in value
if speaker.address.casefold() == previous.casefold()
),
None,
)
if matching:
self.port.set(matching.label)
elif len(value) == 1 and not previous:
self.port.set(value[0].label)
elif previous:
# Preserve a manual address or a prior device that is
# temporarily absent; never silently select another.
self.port.set(previous)
if value:
self.status.set(
f"Found {len(value)} speaker(s). Choose yours, then Connect."
)
else:
self.status.set(
"No speaker found. Turn it on, close the phone app, and scan again. A manual address also works."
)
elif kind == "ready":
self.ready = True
self.enable(True)
@@ -189,6 +258,7 @@ class App:
self.enable(False)
self.connect.configure(text="Connect", state="normal")
self.port.configure(state="normal")
self.scan_button.configure(state="normal")
if not self.status.get().startswith(("No ", "Malformed", "could", "Install")):
self.status.set("Disconnected. " + self.status.get())
elif kind == "payload":