"""
Part of the low-level API: Provides the transport layer for toy communication.
You are not meant to instantiate any of these classes yourself. Objects of these classes are created by the ConnectionBuilder as needed.
Decouples toy protocol logic from the underlying wire technology (BLE, USB, etc.).
Concrete implementations wrap bleak or pyserial-asyncio-fast.
"""
import asyncio
from abc import ABC, abstractmethod
from typing import Any, Awaitable, Callable, Type
import serial_asyncio_fast
from bleak import BleakClient
from bleak.backends.device import BLEDevice
[docs]
class Transport(ABC):
"""
Abstract transport layer for a connected toy.
Wraps the raw I/O operations so that ``Toy`` subclasses can send commands and receive notifications without knowing
whether the underlying link is BLE, USB, or anything else.
A ``Transport`` is always created in a *connected* state. Connection setup is the responsibility of the ``ConnectionBuilder``.
Args:
toy_id: Unique identifier e.g., BLE: Bluetooth address string, USB: serial port path.
name: Human-readable device name e.g., BLE: advertised name. USB: the literal string ``"usb-<serial port path>"``.
"""
def __init__(self, toy_id: str, name: str):
self._toy_id = toy_id
self._name = name
self._intentional_disconnect = False
@property
def toy_id(self) -> str:
"""returns a unique identifier (BLE address or USB port)."""
return self._toy_id
@property
def name(self) -> str:
"""Returns a Human-readable device name."""
return self._name
@property
@abstractmethod
def is_connected(self) -> bool:
"""``True`` while the underlying link is open, else ``False``."""
raise NotImplementedError
[docs]
@abstractmethod
async def reconnect(self) -> None:
"""Attempts to reconnect to the toy. Does nothing if already connected."""
raise NotImplementedError
[docs]
@abstractmethod
async def send(self, data: bytes) -> None:
"""
Write raw bytes to the device.
Args:
data: Bytes to send (the ``Toy`` layer is responsible for framing).
Raises:
ConnectionError: If the transport is not connected or the operation fails.
"""
raise NotImplementedError
[docs]
@abstractmethod
async def start_notify(self, callback: Callable[[bytes], None]) -> None:
"""
Start receiving inbound data and invoke *callback* for each one.
Args:
callback: callback invoked with each inbound ``bytes`` payload.
Raises:
ConnectionError: If the transport is not connected or the operation fails.
"""
raise NotImplementedError
[docs]
@abstractmethod
async def disconnect(self) -> None:
"""
Close the underlying connection and release all resources. After this call ``is_connected`` must return ``False``.
Raises:
ConnectionError: If the operation fails.
"""
raise NotImplementedError
[docs]
class BleTransport(Transport):
"""
``Transport`` implementation for Bluetooth Low Energy via bleak.
Call :meth:`connect` after construction!
It establishes the BLE connection and resolves TX / RX UUIDs, via the supplied *uuid_resolver*
Args:
device: The ``BLEDevice`` to connect to.
uuid_resolver: Async callable that receives the connected ``BleakClient`` and returns ``(tx_uuid, rx_uuid)``.
Invoked once inside :meth:`connect` while the link is already open, so it can inspect the GATT services.
on_disconnect: Optional callback is invoked with this self.toy_id when the underlying BLE
connection drops unexpectedly. Not invoked for intentional disconnects via :meth:`disconnect`.
client_class: ``BleakClient`` subclass to use. Defaults to ``BleakClient``; override for testing.
"""
def __init__(
self,
device: BLEDevice,
uuid_resolver: Callable[[BleakClient], Awaitable[tuple[str, str]]],
on_disconnect: Callable[[str], None] | None = None,
client_class: Type[BleakClient] = BleakClient,
):
super().__init__(toy_id=device.address, name=device.name or "")
self._uuid_resolver = uuid_resolver
self._on_disconnect = on_disconnect
self._tx_uuid: str | None = None
self._rx_uuid: str | None = None
def _bleak_disconnect_cb(_: BleakClient) -> None:
if not self._intentional_disconnect and on_disconnect:
on_disconnect(self.toy_id)
self._client: BleakClient | None = client_class(
device, disconnected_callback=_bleak_disconnect_cb
)
[docs]
async def connect(self) -> None:
"""
Do not call. The ``ConnectionBuilder`` will call this for you.
Opens the BLE connection and resolves TX / RX UUIDs.
Must be called exactly once after construction and before any other method.
Raises:
ConnectionError: If the BLE connection fails or if UUID resolution raises.
"""
assert self._client is not None
try:
await self._client.connect()
self._tx_uuid, self._rx_uuid = await self._uuid_resolver(self._client)
except Exception as e:
raise ConnectionError(
f"Error connecting to toy at {self.toy_id}: {e!r}"
) from e
@property
def is_connected(self) -> bool:
"""``True`` while the underlying link is open, else ``False``."""
return self._client is not None and self._client.is_connected
[docs]
async def reconnect(self) -> None:
"""
Attempts to reconnect to the toy. Does nothing if already connected.
Use only after an unintended disconnect (``ConnectionError``).
If you disconnected via :meth:`disconnect`, use the ``ConnectionBuilder`` instead.
Raises:
RuntimeError: If called after an intentional disconnect.
ConnectionError: If the reconnection attempt fails.
"""
if self.is_connected:
return
if self._intentional_disconnect:
raise RuntimeError("Cannot reconnect after intentional disconnect")
assert self._client is not None
try:
await self._client.connect()
except Exception as e:
raise ConnectionError(
f"Error connecting to toy at {self.toy_id}: {e!r}"
) from e
[docs]
async def send(self, data: bytes) -> None:
"""
Send encoded data to the toy.
Args:
data: Encoded data to send to the toy.
Raises:
ConnectionError: If the transport is not connected or the operation fails.
"""
assert self._client is not None and self._tx_uuid is not None
try:
await self._client.write_gatt_char(self._tx_uuid, data, response=False)
except Exception as e:
raise ConnectionError(
f"Error sending data to toy at {self.toy_id}: {e!r}"
) from e
[docs]
async def start_notify(self, callback: Callable[[bytes], None]) -> None:
"""
Start receiving inbound data and invoke *callback* for each one.
Args:
callback: callback invoked with each inbound ``bytes`` payload.
Raises:
ConnectionError: If the transport is not connected or the operation fails.
"""
# Bleak passes (characteristic, data: bytearray); we forward the data as bytes.
def _bleak_cb(_: Any, data: bytearray) -> None:
callback(bytes(data))
assert self._client is not None and self._rx_uuid is not None
try:
await self._client.start_notify(self._rx_uuid, _bleak_cb)
except Exception as e:
raise ConnectionError(
f"Error starting notifications for toy at {self.toy_id}: {e!r}"
) from e
[docs]
async def disconnect(self) -> None:
"""
Close the BLE connection and release all resources. After this call ``is_connected`` returns ``False``.
Raises:
ConnectionError: If the operation fails. Connection is still regarded as closed if this exception is raised.
"""
if not self._client:
return
self._intentional_disconnect = True
try:
await self._client.disconnect()
except Exception as e:
raise ConnectionError(
f"Error disconnecting from toy at {self.toy_id}: {e!r}"
)
finally:
self._client = None
[docs]
class MockTransport(Transport):
"""
In-memory ``Transport`` for the fictional ``MockEstimToys`` brand.
Backed by no real hardware: it simulates a device that speaks a tiny line protocol (commands and responses are
UTF-8 strings terminated by ``;``). On every :meth:`send` it computes a canned response and feeds it straight back
through the notification callback, so the ``Toy`` layer's request/response flow works exactly as it would over BLE.
Used purely to explore the library's architecture and to add a second brand/transport without a physical toy.
Args:
toy_id: Unique identifier for the fake device (e.g. ``"Thunder_ID"``).
name: Human-readable device name (e.g. ``"Thunder1"``).
battery: Battery percentage the device reports in response to a ``Battery`` command.
on_disconnect: Optional callback invoked with ``toy_id`` on every :meth:`disconnect`
(intentional or not). Used by the ``MockConnectionBuilder`` to make the device
discoverable again once it is no longer connected.
"""
def __init__(
self,
toy_id: str,
name: str,
battery: int = 77,
on_disconnect: Callable[[str], None] | None = None,
):
super().__init__(toy_id, name)
self._connected = True
self._battery = battery
self._notify_callback: Callable[[bytes], None] | None = None
self._on_disconnect = on_disconnect
@property
def is_connected(self) -> bool:
return self._connected
[docs]
async def reconnect(self) -> None:
"""
Re-open the simulated link. Does nothing if already connected.
Raises:
RuntimeError: If called after an intentional :meth:`disconnect`.
"""
if self._connected:
return
if self._intentional_disconnect:
raise RuntimeError("Cannot reconnect after intentional disconnect")
self._connected = True
[docs]
async def send(self, data: bytes) -> None:
"""
Accept a command and immediately feed the simulated response back through the notification callback.
Raises:
ConnectionError: If the transport is not connected.
"""
if not self._connected:
raise ConnectionError(f"Mock transport {self._toy_id} is not connected")
response = self._respond(data.decode("utf-8").rstrip(";"))
if response is not None and self._notify_callback:
self._notify_callback((response + ";").encode("utf-8"))
[docs]
async def start_notify(self, callback: Callable[[bytes], None]) -> None:
"""
Register the callback that receives simulated responses.
Raises:
ConnectionError: If the transport is not connected.
"""
if not self._connected:
raise ConnectionError(f"Mock transport {self._toy_id} is not connected")
self._notify_callback = callback
[docs]
async def disconnect(self) -> None:
"""Close the simulated link. After this call ``is_connected`` returns ``False``."""
self._intentional_disconnect = True
self._connected = False
self._notify_callback = None
if self._on_disconnect is not None:
self._on_disconnect(self._toy_id)
def _respond(self, command: str) -> str | None:
"""Compute the device's canned reply to a command (without the ``;`` terminator)."""
if command.startswith("Channel1:") or command.startswith("Channel2:"):
return "OK"
if command == "Battery":
return str(self._battery)
if command == "DeviceType":
return "MockEstim"
# Lenient default: acknowledge anything else so direct commands succeed.
return "OK"
[docs]
class UsbTransport(Transport):
"""
``Transport`` implementation for USB serial via pyserial-asyncio-fast.
Args:
port: Serial port path, e.g. ``"/dev/ttyUSB0"`` or ``"COM3"``.
baudrate: Baud rate for the serial connection.
reader: ``asyncio.StreamReader`` from ``serial_asyncio_fast``.
writer: ``asyncio.StreamWriter`` from ``serial_asyncio_fast``.
"""
def __init__(
self,
port: str,
baudrate: int,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
):
super().__init__(toy_id=port, name="usb")
self._reader: asyncio.StreamReader | None = reader
self._writer: asyncio.StreamWriter | None = writer
self._port = port
self._baudrate = baudrate
self._connected = True
self._notify_callback: Callable[[bytes], None] | None = None
self._read_task: asyncio.Task[None] | None = None
[docs]
@classmethod
async def connect(cls, port: str, baudrate: int) -> "UsbTransport":
"""Open the serial port and return a connected ``UsbTransport``."""
reader, writer = await serial_asyncio_fast.open_serial_connection(
url=port, baudrate=baudrate
)
return cls(port, baudrate, reader, writer)
@property
def is_connected(self) -> bool:
return self._connected
[docs]
async def send(self, data: bytes) -> None:
if not self._connected or self._writer is None:
raise ConnectionError(f"USB transport {self._toy_id} is not connected")
self._writer.write(data)
await self._writer.drain()
[docs]
async def reconnect(self) -> None:
"""
Attempts to reconnect to the toy. Does nothing if already connected.
Use only after an unintended disconnect (``ConnectionError``).
If you disconnected via :meth:`disconnect`, use the ``ConnectionBuilder`` instead.
Raises:
RuntimeError: If called after an intentional disconnect.
ConnectionError: If the reconnection attempt fails.
"""
if self.is_connected:
return
if self._intentional_disconnect:
raise RuntimeError("Cannot reconnect after intentional disconnect")
try:
if not self._notify_callback:
raise RuntimeError("Cannot reconnect without a notification callback")
self._reader, self._writer = (
await serial_asyncio_fast.open_serial_connection(
url=self._port, baudrate=self._baudrate
)
)
self._connected = True
await self.start_notify(self._notify_callback)
except Exception as e:
raise ConnectionError(f"Error connecting to toy at {self.toy_id}: {e!r}")
[docs]
async def start_notify(self, callback: Callable[[bytes], None]) -> None:
"""
Spawns a background task that reads lines from the serial port and invokes *callback* for each one.
Args:
callback: callback invoked with each inbound ``bytes`` payload.
Raises:
ConnectionError: If the transport is not connected or the operation fails.
"""
# TODO: Assumes readline strategy. Other read strategies (fixed-size ``read``) might be possible.
# Might need multiple USB Transport layer classes to handle different USB toy protocols.
async def _read_loop() -> None:
reader = self._reader
if reader is None:
return
try:
while self._connected:
data = await reader.readline()
if data:
callback(data)
except asyncio.CancelledError:
pass
self._notify_callback = callback
self._read_task = asyncio.create_task(_read_loop())
[docs]
async def disconnect(self) -> None:
"""
Close the serial connection and release all resources. After this call ``is_connected`` returns ``False``.
Raises:
ConnectionError: If the operation fails. The connection is still regarded as closed if this exception is raised.
"""
self._connected = False
try:
if self._read_task:
self._read_task.cancel()
await asyncio.gather(self._read_task, return_exceptions=True)
self._read_task = None
if self._writer is not None:
self._writer.close()
await self._writer.wait_closed()
except Exception as e:
raise ConnectionError(
f"Error disconnecting from toy at {self.toy_id}: {e!r}"
)
finally:
self._reader = None
self._writer = None