"""Byte-stream adapters for direct RFCOMM and optional serial COM ports.""" import asyncio import re import socket class RfcommStream: """Adapt an RFCOMM socket to the worker's serial-like read/write API. Idle reads return empty bytes, as pyserial does; a socket EOF instead raises, so a lost speaker connection cannot look like endless idle time. """ def __init__(self, address, channel=1): self.address = address self.channel = channel self.socket = None def __enter__(self): if not 1 <= self.channel <= 30: raise ValueError("RFCOMM channel must be between 1 and 30") if not hasattr(socket, "AF_BLUETOOTH") or not hasattr(socket, "BTPROTO_RFCOMM"): raise RuntimeError( "Direct Bluetooth requires a Python build with RFCOMM support. Use Windows Python 3.14." ) self.socket = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM) try: self.socket.settimeout(10) self.socket.connect((self.address, self.channel)) except TimeoutError as exc: self.socket.close() self.socket = None raise TimeoutError( f"Bluetooth connection timed out before the Samsung handshake " f"({self.address}, channel {self.channel})." ) from exc except BaseException: self.socket.close() self.socket = None raise return self def __exit__(self, *args): if self.socket is not None: self.socket.close() self.socket = None def write(self, data): self.socket.settimeout(2) # sendall handles partial socket writes. Never retry a whole command # after a send failure: remote key actions could otherwise repeat. try: self.socket.sendall(data) except TimeoutError as exc: raise TimeoutError( "Bluetooth connected, but sending a Samsung packet timed out." ) from exc return len(data) def read(self, size): self.socket.settimeout(0.05) try: data = self.socket.recv(size) except TimeoutError: return b"" if not data: raise ConnectionError("The speaker closed the Bluetooth connection.") return data class PreparedRfcommStream(RfcommStream): """Keep the WinRT device alive across the socket session, as the probe did.""" def __init__(self, address, channel, report): super().__init__(address, channel) self.report = report self.device = None def __enter__(self): from controller.windows_bluetooth import prepare_device self.device = asyncio.run(prepare_device(self.address, self.report)) try: self.report("Opening direct RFCOMM socket with Windows device handle retained…") return super().__enter__() except BaseException: self.device.close() self.device = None raise def __exit__(self, *args): try: super().__exit__(*args) finally: if self.device is not None: self.device.close() self.device = None def open_stream(target, channel=1, prepare=False, report=lambda text: None): """A Bluetooth MAC selects direct RFCOMM; other targets select serial.""" if re.fullmatch(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", target): if prepare: return PreparedRfcommStream(target, channel, report) return RfcommStream(target, channel) import serial return serial.Serial(target, 115200, timeout=0.05, write_timeout=2)