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
110 lines
3.7 KiB
Python
110 lines
3.7 KiB
Python
"""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)
|