Add in-app speaker discovery and connection guide
This commit is contained in:
7 files changed
+287
-28
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user