device.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. # MicroPython aioble module
  2. # MIT license; Copyright (c) 2021 Jim Mussared
  3. from micropython import const
  4. import asyncio
  5. import binascii
  6. from .core import ble, register_irq_handler, log_error
  7. _IRQ_MTU_EXCHANGED = const(21)
  8. # Raised by `with device.timeout()`.
  9. class DeviceDisconnectedError(Exception):
  10. pass
  11. def _device_irq(event, data):
  12. if event == _IRQ_MTU_EXCHANGED:
  13. conn_handle, mtu = data
  14. if device := DeviceConnection._connected.get(conn_handle, None):
  15. device.mtu = mtu
  16. if device._mtu_event:
  17. device._mtu_event.set()
  18. register_irq_handler(_device_irq, None)
  19. # Context manager to allow an operation to be cancelled by timeout or device
  20. # disconnection. Don't use this directly -- use `with connection.timeout(ms):`
  21. # instead.
  22. class DeviceTimeout:
  23. def __init__(self, connection, timeout_ms):
  24. self._connection = connection
  25. self._timeout_ms = timeout_ms
  26. # We allow either (or both) connection and timeout_ms to be None. This
  27. # allows this to be used either as a just-disconnect, just-timeout, or
  28. # no-op.
  29. # This task is active while the operation is in progress. It sleeps
  30. # until the timeout, and then cancels the working task. If the working
  31. # task completes, __exit__ will cancel the sleep.
  32. self._timeout_task = None
  33. # This is the task waiting for the actual operation to complete.
  34. # Usually this is waiting on an event that will be set() by an IRQ
  35. # handler.
  36. self._task = asyncio.current_task()
  37. # Tell the connection that if it disconnects, it should cancel this
  38. # operation (by cancelling self._task).
  39. if connection:
  40. connection._timeouts.append(self)
  41. async def _timeout_sleep(self):
  42. try:
  43. await asyncio.sleep_ms(self._timeout_ms)
  44. except asyncio.CancelledError:
  45. # The operation completed successfully and this timeout task was
  46. # cancelled by __exit__.
  47. return
  48. # The sleep completed, so we should trigger the timeout. Set
  49. # self._timeout_task to None so that we can tell the difference
  50. # between a disconnect and a timeout in __exit__.
  51. self._timeout_task = None
  52. self._task.cancel()
  53. def __enter__(self):
  54. if self._timeout_ms:
  55. # Schedule the timeout waiter.
  56. self._timeout_task = asyncio.create_task(self._timeout_sleep())
  57. def __exit__(self, exc_type, exc_val, exc_traceback):
  58. # One of five things happened:
  59. # 1 - The operation completed successfully.
  60. # 2 - The operation timed out.
  61. # 3 - The device disconnected.
  62. # 4 - The operation failed for a different exception.
  63. # 5 - The task was cancelled by something else.
  64. # Don't need the connection to tell us about disconnection anymore.
  65. if self._connection:
  66. self._connection._timeouts.remove(self)
  67. try:
  68. if exc_type == asyncio.CancelledError:
  69. # Case 2, we started a timeout and it's completed.
  70. if self._timeout_ms and self._timeout_task is None:
  71. raise asyncio.TimeoutError
  72. # Case 3, we have a disconnected device.
  73. if self._connection and self._connection._conn_handle is None:
  74. raise DeviceDisconnectedError
  75. # Case 5, something else cancelled us.
  76. # Allow the cancellation to propagate.
  77. return
  78. # Case 1 & 4. Either way, just stop the timeout task and let the
  79. # exception (if case 4) propagate.
  80. finally:
  81. # In all cases, if the timeout is still running, cancel it.
  82. if self._timeout_task:
  83. self._timeout_task.cancel()
  84. class Device:
  85. def __init__(self, addr_type, addr):
  86. # Public properties
  87. self.addr_type = addr_type
  88. self.addr = addr if len(addr) == 6 else binascii.unhexlify(addr.replace(":", ""))
  89. self._connection = None
  90. def __eq__(self, rhs):
  91. return self.addr_type == rhs.addr_type and self.addr == rhs.addr
  92. def __hash__(self):
  93. return hash((self.addr_type, self.addr))
  94. def __str__(self):
  95. return "Device({}, {}{})".format(
  96. "ADDR_PUBLIC" if self.addr_type == 0 else "ADDR_RANDOM",
  97. self.addr_hex(),
  98. ", CONNECTED" if self._connection else "",
  99. )
  100. def addr_hex(self):
  101. return binascii.hexlify(self.addr, ":").decode()
  102. async def connect(
  103. self,
  104. timeout_ms=10000,
  105. scan_duration_ms=None,
  106. min_conn_interval_us=None,
  107. max_conn_interval_us=None,
  108. ):
  109. if self._connection:
  110. return self._connection
  111. # Forward to implementation in central.py.
  112. from .central import _connect
  113. await _connect(
  114. DeviceConnection(self),
  115. timeout_ms,
  116. scan_duration_ms,
  117. min_conn_interval_us,
  118. max_conn_interval_us,
  119. )
  120. # Start the device task that will clean up after disconnection.
  121. self._connection._run_task()
  122. return self._connection
  123. class DeviceConnection:
  124. # Global map of connection handle to active devices (for IRQ mapping).
  125. _connected = {}
  126. def __init__(self, device):
  127. self.device = device
  128. device._connection = self
  129. self.encrypted = False
  130. self.authenticated = False
  131. self.bonded = False
  132. self.key_size = False
  133. self.mtu = None
  134. self._conn_handle = None
  135. # This event is fired by the IRQ both for connection and disconnection
  136. # and controls the device_task.
  137. self._event = asyncio.ThreadSafeFlag()
  138. # If we're waiting for a pending MTU exchange.
  139. self._mtu_event = None
  140. # In-progress client discovery instance (e.g. services, chars,
  141. # descriptors) used for IRQ mapping.
  142. self._discover = None
  143. # Map of value handle to characteristic (so that IRQs with
  144. # conn_handle,value_handle can route to them). See
  145. # ClientCharacteristic._find for where this is used.
  146. self._characteristics = {}
  147. self._task = None
  148. # DeviceTimeout instances that are currently waiting on this device
  149. # and need to be notified if disconnection occurs.
  150. self._timeouts = []
  151. # Fired by the encryption update event.
  152. self._pair_event = None
  153. # Active L2CAP channel for this device.
  154. # TODO: Support more than one concurrent channel.
  155. self._l2cap_channel = None
  156. # While connected, this tasks waits for disconnection then cleans up.
  157. async def device_task(self):
  158. assert self._conn_handle is not None
  159. # Wait for the (either central or peripheral) disconnected irq.
  160. await self._event.wait()
  161. # Mark the device as disconnected.
  162. del DeviceConnection._connected[self._conn_handle]
  163. self._conn_handle = None
  164. self.device._connection = None
  165. # Cancel any in-progress operations on this device.
  166. for t in self._timeouts:
  167. t._task.cancel()
  168. def _run_task(self):
  169. self._task = asyncio.create_task(self.device_task())
  170. async def disconnect(self, timeout_ms=2000):
  171. await self.disconnected(timeout_ms, disconnect=True)
  172. async def disconnected(self, timeout_ms=None, disconnect=False):
  173. if not self.is_connected():
  174. return
  175. # The task must have been created after successful connection.
  176. assert self._task
  177. if disconnect:
  178. try:
  179. ble.gap_disconnect(self._conn_handle)
  180. except OSError as e:
  181. log_error("Disconnect", e)
  182. with DeviceTimeout(None, timeout_ms):
  183. await self._task
  184. # Retrieve a single service matching this uuid.
  185. async def service(self, uuid, timeout_ms=2000):
  186. result = None
  187. # Make sure loop runs to completion.
  188. async for service in self.services(uuid, timeout_ms):
  189. if not result and service.uuid == uuid:
  190. result = service
  191. return result
  192. # Search for all services (optionally by uuid).
  193. # Use with `async for`, e.g.
  194. # async for service in device.services():
  195. # Note: must allow the loop to run to completion.
  196. # TODO: disconnection / timeout
  197. def services(self, uuid=None, timeout_ms=2000):
  198. from .client import ClientDiscover, ClientService
  199. return ClientDiscover(self, ClientService, self, timeout_ms, uuid)
  200. async def pair(self, *args, **kwargs):
  201. from .security import pair
  202. await pair(self, *args, **kwargs)
  203. def is_connected(self):
  204. return self._conn_handle is not None
  205. # Use with `with` to simplify disconnection and timeout handling.
  206. def timeout(self, timeout_ms):
  207. return DeviceTimeout(self, timeout_ms)
  208. async def exchange_mtu(self, mtu=None, timeout_ms=1000):
  209. if not self.is_connected():
  210. raise ValueError("Not connected")
  211. if mtu:
  212. ble.config(mtu=mtu)
  213. self._mtu_event = self._mtu_event or asyncio.ThreadSafeFlag()
  214. ble.gattc_exchange_mtu(self._conn_handle)
  215. with self.timeout(timeout_ms):
  216. await self._mtu_event.wait()
  217. return self.mtu
  218. # Wait for a connection on an L2CAP connection-oriented-channel.
  219. async def l2cap_accept(self, psm, mtu, timeout_ms=None):
  220. from .l2cap import accept
  221. return await accept(self, psm, mtu, timeout_ms)
  222. # Attempt to connect to a listening device.
  223. async def l2cap_connect(self, psm, mtu, timeout_ms=1000):
  224. from .l2cap import connect
  225. return await connect(self, psm, mtu, timeout_ms)
  226. # Context manager -- automatically disconnect.
  227. async def __aenter__(self):
  228. return self
  229. async def __aexit__(self, exc_type, exc_val, exc_traceback):
  230. await self.disconnect()
  231. __version__ = '0.4.0'