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,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.
|
||||
Reference in new issue
Block a user