Files
soundtower-controller/controller/ble_link.py
T
lda Verified 038e152bbc
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
Add MX-T40 BLE desktop controller and protocol documentation
2026-10-25 04:43:47 +07:00

115 lines
4.9 KiB
Python

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