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
115 lines
4.9 KiB
Python
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.
|