"""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.