From 8631225b7fbc10ed5b76d81ed975e67059c88621 Mon Sep 17 00:00:00 2001 From: Jim Mussared Date: Sat, 13 Feb 2021 10:41:19 +1100 Subject: [PATCH] micropython/aioble: Add asyncio-based wrapper for ubluetooth. Signed-off-by: Jim Mussared --- micropython/bluetooth/aioble/README.md | 139 ++++++ .../bluetooth/aioble/aioble/__init__.py | 32 ++ .../bluetooth/aioble/aioble/central.py | 290 ++++++++++++ micropython/bluetooth/aioble/aioble/client.py | 413 ++++++++++++++++++ micropython/bluetooth/aioble/aioble/core.py | 71 +++ micropython/bluetooth/aioble/aioble/device.py | 294 +++++++++++++ micropython/bluetooth/aioble/aioble/l2cap.py | 205 +++++++++ .../bluetooth/aioble/aioble/peripheral.py | 171 ++++++++ .../bluetooth/aioble/aioble/security.py | 171 ++++++++ micropython/bluetooth/aioble/aioble/server.py | 239 ++++++++++ .../aioble/examples/l2cap_file_client.py | 139 ++++++ .../aioble/examples/l2cap_file_server.py | 185 ++++++++ .../bluetooth/aioble/examples/temp_client.py | 63 +++ .../bluetooth/aioble/examples/temp_sensor.py | 68 +++ micropython/bluetooth/aioble/manifest.py | 33 ++ .../aioble/multitests/ble_characteristic.py | 135 ++++++ .../multitests/ble_characteristic.py.exp | 28 ++ .../aioble/multitests/perf_gatt_notify.py | 103 +++++ .../aioble/multitests/perf_gatt_notify.py.exp | 0 .../bluetooth/aioble/multitests/perf_l2cap.py | 105 +++++ .../aioble/multitests/perf_l2cap.py.exp | 0 21 files changed, 2884 insertions(+) create mode 100644 micropython/bluetooth/aioble/README.md create mode 100644 micropython/bluetooth/aioble/aioble/__init__.py create mode 100644 micropython/bluetooth/aioble/aioble/central.py create mode 100644 micropython/bluetooth/aioble/aioble/client.py create mode 100644 micropython/bluetooth/aioble/aioble/core.py create mode 100644 micropython/bluetooth/aioble/aioble/device.py create mode 100644 micropython/bluetooth/aioble/aioble/l2cap.py create mode 100644 micropython/bluetooth/aioble/aioble/peripheral.py create mode 100644 micropython/bluetooth/aioble/aioble/security.py create mode 100644 micropython/bluetooth/aioble/aioble/server.py create mode 100644 micropython/bluetooth/aioble/examples/l2cap_file_client.py create mode 100644 micropython/bluetooth/aioble/examples/l2cap_file_server.py create mode 100644 micropython/bluetooth/aioble/examples/temp_client.py create mode 100644 micropython/bluetooth/aioble/examples/temp_sensor.py create mode 100644 micropython/bluetooth/aioble/manifest.py create mode 100644 micropython/bluetooth/aioble/multitests/ble_characteristic.py create mode 100644 micropython/bluetooth/aioble/multitests/ble_characteristic.py.exp create mode 100644 micropython/bluetooth/aioble/multitests/perf_gatt_notify.py create mode 100644 micropython/bluetooth/aioble/multitests/perf_gatt_notify.py.exp create mode 100644 micropython/bluetooth/aioble/multitests/perf_l2cap.py create mode 100644 micropython/bluetooth/aioble/multitests/perf_l2cap.py.exp diff --git a/micropython/bluetooth/aioble/README.md b/micropython/bluetooth/aioble/README.md new file mode 100644 index 00000000..1c2fc7ae --- /dev/null +++ b/micropython/bluetooth/aioble/README.md @@ -0,0 +1,139 @@ +aioble +====== + +This library provides an object-oriented, asyncio-based wrapper for MicroPython's [ubluetooth](https://docs.micropython.org/en/latest/library/ubluetooth.html) API. + +**Note**: aioble requires MicroPython v1.15 or higher. + +Features +-------- + +Broadcaster (advertiser) role: +* Generate advertising and scan response payloads for common fields. +* Automatically split payload over advertising and scan response. +* Start advertising (indefinitely or for duration). + +Peripheral role: +* Wait for connection from central. +* Wait for MTU exchange. + +Observer (scanner) role: +* Scan for devices (passive + active). +* Combine advertising and scan response payloads for the same device. +* Parse common fields from advertising payloads. + +Central role: +* Connect to peripheral. +* Initiate MTU exchange. + +GATT Client: +* Discover services, characteristics, and descriptors (optionally by UUID). +* Read / write / write-with-response characters and descriptors. +* Subscribe to notifications and indications on characteristics (via the CCCD). +* Wait for notifications and indications. + +GATT Server: +* Register services, characteristics, and descriptors. +* Wait for writes on characteristics and descriptors. +* Intercept read requests. +* Send notifications and indications (and wait on response). + +L2CAP: +* Accept and connect L2CAP Connection-oriented-channels. +* Manage channel flow control. + +Security: +* JSON-backed key/secret management. +* Initiate pairing. +* Query encryption/authentication state. + +All remote operations (connect, disconnect, client read/write, server indicate, l2cap recv/send, pair) are awaitable and support timeouts. + +Usage +----- + +Scan for nearby devices: (Observer) + +```py +async with aioble.scan() as scanner: + async for result in scanner: + if result.name(): + print(result, result.name(), result.rssi, result.services()) +``` + +Connect to a peripheral device: (Central) + +```py +# Either from scan result +device = result.device +# Or with known address +device = aioble.Device(aioble.PUBLIC, "aa:bb:cc:dd:ee:ff") + +try: + connection = await device.connect(timeout_ms=2000) +except asyncio.TimeoutError: + print('Timeout') +``` + +Register services and wait for connection: (Peripheral, Server) + +```py +_ENV_SENSE_UUID = bluetooth.UUID(0x181A) +_ENV_SENSE_TEMP_UUID = bluetooth.UUID(0x2A6E) +_GENERIC_THERMOMETER = const(768) + +_ADV_INTERVAL_MS = const(250000) + +temp_service = aioble.Service(_ENV_SENSE_UUID) +temp_char = aioble.Characteristic(temp_service, _ENV_SENSE_TEMP_UUID, read=True, notify=True) + +aioble.register_services(temp_service) + +while True: + connection = await aioble.advertise( + _ADV_INTERVAL_MS, + name="temp-sense", + services=[_ENV_SENSE_UUID], + appearance=_GENERIC_THERMOMETER, + manufacturer=(0xabcd, b"1234"), + ) + print("Connection from", device) +``` + +Update characteristic value: (Server) + +```py +temp_char.write(b'data') + +temp_char.notify(b'optional data') + +await temp_char.indicate(timeout_ms=2000) +``` + +Query the value of a characteristic: (Client) + +```py +temp_service = await connection.service(_ENV_SENSE_UUID) +temp_char = await temp_service.characteristic(_ENV_SENSE_TEMP_UUID) + +data = await temp_char.read(timeout_ms=1000) + +temp_char.subscribe(notify=True) +while True: + data = await temp_char.notified() +``` + +Examples +-------- + +See the `examples` directory for some example applications. + +* temp_sensor.py: Temperature sensor peripheral. +* temp_client.py: Connects to the temp sensor. +* l2cap_file_server.py: Simple file server peripheral. (WIP) +* l2cap_file_client.py: Client for the file server. (WIP) + +Tests +----- + +The `multitests` directory provides tests that can be run with MicroPython's `run-multitests.py` script. These are based on the existing `multi_bluetooth` tests that are in the main repo. diff --git a/micropython/bluetooth/aioble/aioble/__init__.py b/micropython/bluetooth/aioble/aioble/__init__.py new file mode 100644 index 00000000..dde89f5e --- /dev/null +++ b/micropython/bluetooth/aioble/aioble/__init__.py @@ -0,0 +1,32 @@ +# MicroPython aioble module +# MIT license; Copyright (c) 2021 Jim Mussared + +from micropython import const + +from .device import Device, DeviceDisconnectedError +from .core import log_info, log_warn, log_error, GattError, config, stop + +try: + from .peripheral import advertise +except: + log_info("Peripheral support disabled") + +try: + from .central import scan +except: + log_info("Central support disabled") + +try: + from .server import ( + Service, + Characteristic, + BufferedCharacteristic, + Descriptor, + register_services, + ) +except: + log_info("GATT server support disabled") + + +ADDR_PUBLIC = const(0) +ADDR_RANDOM = const(1) diff --git a/micropython/bluetooth/aioble/aioble/central.py b/micropython/bluetooth/aioble/aioble/central.py new file mode 100644 index 00000000..374fc6ea --- /dev/null +++ b/micropython/bluetooth/aioble/aioble/central.py @@ -0,0 +1,290 @@ +# MicroPython aioble module +# MIT license; Copyright (c) 2021 Jim Mussared + +from micropython import const + +import bluetooth +import struct + +import uasyncio as asyncio + +from .core import ( + ensure_active, + ble, + log_info, + log_error, + log_warn, + register_irq_handler, +) +from .device import Device, DeviceConnection, DeviceTimeout + + +_IRQ_SCAN_RESULT = const(5) +_IRQ_SCAN_DONE = const(6) + +_IRQ_PERIPHERAL_CONNECT = const(7) +_IRQ_PERIPHERAL_DISCONNECT = const(8) + +_ADV_IND = const(0) +_ADV_DIRECT_IND = const(1) +_ADV_SCAN_IND = const(2) +_ADV_NONCONN_IND = const(3) +_SCAN_RSP = const(4) + +_ADV_TYPE_FLAGS = const(0x01) +_ADV_TYPE_NAME = const(0x09) +_ADV_TYPE_UUID16_INCOMPLETE = const(0x2) +_ADV_TYPE_UUID16_COMPLETE = const(0x3) +_ADV_TYPE_UUID32_INCOMPLETE = const(0x4) +_ADV_TYPE_UUID32_COMPLETE = const(0x5) +_ADV_TYPE_UUID128_INCOMPLETE = const(0x6) +_ADV_TYPE_UUID128_COMPLETE = const(0x7) +_ADV_TYPE_APPEARANCE = const(0x19) +_ADV_TYPE_MANUFACTURER = const(0xFF) + + +# Keep track of the active scanner so IRQs can be delivered to it. +_active_scanner = None + + +# Set of devices that are waiting for the peripheral connect IRQ. +_connecting = set() + + +def _central_irq(event, data): + # Send results and done events to the active scanner instance. + if event == _IRQ_SCAN_RESULT: + addr_type, addr, adv_type, rssi, adv_data = data + if not _active_scanner: + return + _active_scanner._queue.append((addr_type, bytes(addr), adv_type, rssi, bytes(adv_data))) + _active_scanner._event.set() + elif event == _IRQ_SCAN_DONE: + if not _active_scanner: + return + _active_scanner._done = True + _active_scanner._event.set() + + # Peripheral connect must be in response to a pending connection, so find + # it in the pending connection set. + elif event == _IRQ_PERIPHERAL_CONNECT: + conn_handle, addr_type, addr = data + + for d in _connecting: + if d.addr_type == addr_type and d.addr == addr: + # Allow connect() to complete. + connection = d._connection + connection._conn_handle = conn_handle + connection._event.set() + break + + # Find the active device connection for this connection handle. + elif event == _IRQ_PERIPHERAL_DISCONNECT: + conn_handle, _, _ = data + if connection := DeviceConnection._connected.get(conn_handle, None): + # Tell the device_task that it should terminate. + connection._event.set() + + +register_irq_handler(_central_irq) + + +# Cancel an in-progress scan. +async def _cancel_pending(): + if _active_scanner: + await _active_scanner.cancel() + + +# Start connecting to a peripheral. +# Call device.connect() rather than using method directly. +async def _connect(connection, timeout_ms): + device = connection.device + if device in _connecting: + return + + # Enable BLE and cancel in-progress scans. + ensure_active() + await _cancel_pending() + + # Allow the connected IRQ to find the device by address. + _connecting.add(device) + + # Event will be set in the connected IRQ, and then later + # re-used to notify disconnection. + connection._event = connection._event or asyncio.ThreadSafeFlag() + + try: + with DeviceTimeout(None, timeout_ms): + ble.gap_connect(device.addr_type, device.addr) + + # Wait for the connected IRQ. + await connection._event.wait() + assert connection._conn_handle is not None + + # Register connection handle -> device. + DeviceConnection._connected[connection._conn_handle] = connection + finally: + # After timeout, don't hold a reference and ignore future events. + _connecting.remove(device) + + +# Represents a single device that has been found during a scan. The scan +# iterator will return the same ScanResult instance multiple times as its data +# changes (i.e. changing RSSI or advertising data). +class ScanResult: + def __init__(self, device): + self.device = device + self.adv_data = None + self.resp_data = None + self.rssi = None + self.connectable = False + + # New scan result available, return true if it changes our state. + def _update(self, adv_type, rssi, adv_data): + updated = False + + if rssi != self.rssi: + self.rssi = rssi + updated = True + + if adv_type in (_ADV_IND, _ADV_NONCONN_IND): + if adv_data != self.adv_data: + self.adv_data = adv_data + self.connectable = adv_type == _ADV_IND + updated = True + elif adv_type == _ADV_SCAN_IND: + if adv_data != self.adv_data and self.resp_data: + updated = True + self.adv_data = adv_data + elif adv_type == _SCAN_RSP and adv_data: + if adv_data != self.resp_data: + self.resp_data = adv_data + updated = True + + return updated + + def __str__(self): + return "Scan result: {} {}".format(self.device, self.rssi) + + # Gets all the fields for the specified types. + def _decode_field(self, *adv_type): + # Advertising payloads are repeated packets of the following form: + # 1 byte data length (N + 1) + # 1 byte type (see constants below) + # N bytes type-specific data + for payload in (self.adv_data, self.resp_data): + if not payload: + continue + i = 0 + while i + 1 < len(payload): + if payload[i + 1] in adv_type: + yield payload[i + 2 : i + payload[i] + 1] + i += 1 + payload[i] + + # Returns the value of the advertised name, otherwise empty string. + def name(self): + for n in self._decode_field(_ADV_TYPE_NAME): + return str(n, "utf-8") if n else "" + + # Generator that enumerates the service UUIDs that are advertised. + def services(self): + for u in self._decode_field(_ADV_TYPE_UUID16_INCOMPLETE, _ADV_TYPE_UUID16_COMPLETE): + yield bluetooth.UUID(struct.unpack(" 0: + print("[aioble] E:", *args) + + +def log_warn(*args): + if log_level > 1: + print("[aioble] W:", *args) + + +def log_info(*args): + if log_level > 2: + print("[aioble] I:", *args) + + +class GattError(Exception): + def __init__(self, status): + self._status = status + + +def ensure_active(): + if not ble.active(): + try: + from .security import load_secrets + + load_secrets() + except: + pass + ble.active(True) + + +def config(*args, **kwargs): + ensure_active() + return ble.config(*args, **kwargs) + + +def stop(): + ble.active(False) + + +# Because different functionality is enabled by which files are available +# the different modules can register their IRQ handlers dynamically. +_irq_handlers = [] + + +def register_irq_handler(handler): + _irq_handlers.append(handler) + + +# Dispatch IRQs to the registered sub-modules. +def ble_irq(event, data): + log_info(event, data) + + for handler in _irq_handlers: + result = handler(event, data) + if result is not None: + return result + + +# TODO: Allow this to be injected. +ble = bluetooth.BLE() +ble.irq(ble_irq) diff --git a/micropython/bluetooth/aioble/aioble/device.py b/micropython/bluetooth/aioble/aioble/device.py new file mode 100644 index 00000000..9634f6d6 --- /dev/null +++ b/micropython/bluetooth/aioble/aioble/device.py @@ -0,0 +1,294 @@ +# MicroPython aioble module +# MIT license; Copyright (c) 2021 Jim Mussared + +from micropython import const + +import uasyncio as asyncio +import binascii + +from .core import ble, register_irq_handler, log_error + + +_IRQ_MTU_EXCHANGED = const(21) + + +# Raised by `with device.timeout()`. +class DeviceDisconnectedError(Exception): + pass + + +def _device_irq(event, data): + if event == _IRQ_MTU_EXCHANGED: + conn_handle, mtu = data + if device := DeviceConnection._connected.get(conn_handle, None): + device.mtu = mtu + if device._mtu_event: + device._mtu_event.set() + + +register_irq_handler(_device_irq) + + +# Context manager to allow an operation to be cancelled by timeout or device +# disconnection. Don't use this directly -- use `with connection.timeout(ms):` +# instead. +class DeviceTimeout: + def __init__(self, connection, timeout_ms): + self._connection = connection + self._timeout_ms = timeout_ms + + # We allow either (or both) connection and timeout_ms to be None. This + # allows this to be used either as a just-disconnect, just-timeout, or + # no-op. + + # This task is active while the operation is in progress. It sleeps + # until the timeout, and then cancels the working task. If the working + # task completes, __exit__ will cancel the sleep. + self._timeout_task = None + + # This is the task waiting for the actual operation to complete. + # Usually this is waiting on an event that will be set() by an IRQ + # handler. + self._task = asyncio.current_task() + + # Tell the connection that if it disconnects, it should cancel this + # operation (by cancelling self._task). + if connection: + connection._timeouts.append(self) + + async def _timeout_sleep(self): + try: + await asyncio.sleep_ms(self._timeout_ms) + except asyncio.CancelledError: + # The operation completed successfully and this timeout task was + # cancelled by __exit__. + return + + # The sleep completed, so we should trigger the timeout. Set + # self._timeout_task to None so that we can tell the difference + # between a disconnect and a timeout in __exit__. + self._timeout_task = None + self._task.cancel() + + def __enter__(self): + if self._timeout_ms: + # Schedule the timeout waiter. + self._timeout_task = asyncio.create_task(self._timeout_sleep()) + + def __exit__(self, exc_type, exc_val, exc_traceback): + # One of five things happened: + # 1 - The operation completed successfully. + # 2 - The operation timed out. + # 3 - The device disconnected. + # 4 - The operation failed for a different exception. + # 5 - The task was cancelled by something else. + + # Don't need the connection to tell us about disconnection anymore. + if self._connection: + self._connection._timeouts.remove(self) + + try: + if exc_type == asyncio.CancelledError: + # Case 2, we started a timeout and it's completed. + if self._timeout_ms and self._timeout_task is None: + raise asyncio.TimeoutError + + # Case 3, we have a disconnected device. + if self._connection and self._connection._conn_handle is None: + raise DeviceDisconnectedError + + # Case 5, something else cancelled us. + # Allow the cancellation to propagate. + return + + # Case 1 & 4. Either way, just stop the timeout task and let the + # exception (if case 4) propagate. + finally: + # In all cases, if the timeout is still running, cancel it. + if self._timeout_task: + self._timeout_task.cancel() + + +class Device: + def __init__(self, addr_type, addr): + # Public properties + self.addr_type = addr_type + self.addr = addr if len(addr) == 6 else binascii.unhexlify(addr.replace(":", "")) + self._connection = None + + def __eq__(self, rhs): + return self.addr_type == rhs.addr_type and self.addr == rhs.addr + + def __hash__(self): + return hash((self.addr_type, self.addr)) + + def __str__(self): + return "Device({}, {}{})".format( + "ADDR_PUBLIC" if self.addr_type == 0 else "ADDR_RANDOM", + self.addr_hex(), + ", CONNECTED" if self._connection else "", + ) + + def addr_hex(self): + return binascii.hexlify(self.addr, ":").decode() + + async def connect(self, timeout_ms=10000): + if self._connection: + return self._connection + + # Forward to implementation in central.py. + from .central import _connect + + await _connect(DeviceConnection(self), timeout_ms) + + # Start the device task that will clean up after disconnection. + self._connection._run_task() + return self._connection + + +class DeviceConnection: + # Global map of connection handle to active devices (for IRQ mapping). + _connected = {} + + def __init__(self, device): + self.device = device + device._connection = self + + self.encrypted = False + self.authenticated = False + self.bonded = False + self.key_size = False + self.mtu = None + + self._conn_handle = None + + # This event is fired by the IRQ both for connection and disconnection + # and controls the device_task. + self._event = None + + # If we're waiting for a pending MTU exchange. + self._mtu_event = None + + # In-progress client discovery instance (e.g. services, chars, + # descriptors) used for IRQ mapping. + self._discover = None + # Map of value handle to characteristic (so that IRQs with + # conn_handle,value_handle can route to them). See + # ClientCharacteristic._find for where this is used. + self._characteristics = {} + + self._task = None + + # DeviceTimeout instances that are currently waiting on this device + # and need to be notified if disconnection occurs. + self._timeouts = [] + + # Fired by the encryption update event. + self._pair_event = None + + # Active L2CAP channel for this device. + # TODO: Support more than one concurrent channel. + self._l2cap_channel = None + + # While connected, this tasks waits for disconnection then cleans up. + async def device_task(self): + assert self._conn_handle is not None + + # Wait for the (either central or peripheral) disconnected irq. + await self._event.wait() + + # Mark the device as disconnected. + del DeviceConnection._connected[self._conn_handle] + self._conn_handle = None + self.device._connection = None + + # Cancel any in-progress operations on this device. + for t in self._timeouts: + t._task.cancel() + + def _run_task(self): + # Event will be already created this if we initiated connection. + self._event = self._event or asyncio.ThreadSafeFlag() + + self._task = asyncio.create_task(self.device_task()) + + async def disconnect(self, timeout_ms=2000): + await self.disconnected(timeout_ms, disconnect=True) + + async def disconnected(self, timeout_ms=60000, disconnect=False): + if not self.is_connected(): + return + + # The task must have been created after successful connection. + assert self._task + + if disconnect: + try: + ble.gap_disconnect(self._conn_handle) + except OSError as e: + log_error("Disconnect", e) + + with DeviceTimeout(None, timeout_ms): + await self._task + + # Retrieve a single service matching this uuid. + async def service(self, uuid, timeout_ms=2000): + result = None + # Make sure loop runs to completion. + async for service in self.services(uuid, timeout_ms): + if not result and service.uuid == uuid: + result = service + return result + + # Search for all services (optionally by uuid). + # Use with `async for`, e.g. + # async for service in device.services(): + # Note: must allow the loop to run to completion. + # TODO: disconnection / timeout + def services(self, uuid=None, timeout_ms=2000): + from .client import ClientDiscover, ClientService + + return ClientDiscover(self, ClientService, self, timeout_ms, uuid) + + async def pair(self, *args, **kwargs): + from .security import pair + + await pair(self, *args, **kwargs) + + def is_connected(self): + return self._conn_handle is not None + + # Use with `with` to simplify disconnection and timeout handling. + def timeout(self, timeout_ms): + return DeviceTimeout(self, timeout_ms) + + async def exchange_mtu(self, mtu=None): + if not self.is_connected(): + raise ValueError("Not connected") + + if mtu: + ble.config(mtu=mtu) + + self._mtu_event = self._mtu_event or asyncio.ThreadSafeFlag() + ble.gattc_exchange_mtu(self._conn_handle) + await self._mtu_event.wait() + return self.mtu + + # Wait for a connection on an L2CAP connection-oriented-channel. + async def l2cap_accept(self, psm, mtu, timeout_ms=None): + from .l2cap import accept + + return await accept(self, psm, mtu, timeout_ms) + + # Attempt to connect to a listening device. + async def l2cap_connect(self, psm, mtu, timeout_ms=1000): + from .l2cap import connect + + return await connect(self, psm, mtu, timeout_ms) + + # Context manager -- automatically disconnect. + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_traceback): + await self.disconnect() diff --git a/micropython/bluetooth/aioble/aioble/l2cap.py b/micropython/bluetooth/aioble/aioble/l2cap.py new file mode 100644 index 00000000..9f31cfe3 --- /dev/null +++ b/micropython/bluetooth/aioble/aioble/l2cap.py @@ -0,0 +1,205 @@ +# MicroPython aioble module +# MIT license; Copyright (c) 2021 Jim Mussared + +from micropython import const + +import uasyncio as asyncio + +from .core import ble, log_error, register_irq_handler +from .device import DeviceConnection + + +_IRQ_L2CAP_ACCEPT = const(22) +_IRQ_L2CAP_CONNECT = const(23) +_IRQ_L2CAP_DISCONNECT = const(24) +_IRQ_L2CAP_RECV = const(25) +_IRQ_L2CAP_SEND_READY = const(26) + + +# Once we start listening we're listening forever. (Limitation in NimBLE) +_listening = False + + +def _l2cap_irq(event, data): + if event not in ( + _IRQ_L2CAP_CONNECT, + _IRQ_L2CAP_DISCONNECT, + _IRQ_L2CAP_RECV, + _IRQ_L2CAP_SEND_READY, + ): + return + + # All the L2CAP events start with (conn_handle, cid, ...) + if connection := DeviceConnection._connected.get(data[0], None): + if channel := connection._l2cap_channel: + # Expect to match the cid for this conn handle (unless we're + # waiting for connection in which case channel._cid is None). + if channel._cid is not None and channel._cid != data[1]: + return + + # Update the channel object with new information. + if event == _IRQ_L2CAP_CONNECT: + _, channel._cid, _, channel.our_mtu, channel.peer_mtu = data + elif event == _IRQ_L2CAP_DISCONNECT: + _, _, psm, status = data + channel._status = status + channel._cid = None + elif event == _IRQ_L2CAP_RECV: + channel._data_ready = True + elif event == _IRQ_L2CAP_SEND_READY: + channel._stalled = False + + # Notify channel. + channel._event.set() + + +register_irq_handler(_l2cap_irq) + + +# The channel was disconnected during a send/recvinto/flush. +class L2CAPDisconnectedError(Exception): + pass + + +# Failed to connect to connection (argument is status). +class L2CAPConnectionError(Exception): + pass + + +class L2CAPChannel: + def __init__(self, connection): + if not connection.is_connected(): + raise ValueError("Not connected") + + if connection._l2cap_channel: + raise ValueError("Already has channel") + connection._l2cap_channel = self + + self._connection = connection + + # Maximum size that the other side can send to us. + self.our_mtu = 0 + # Maximum size that we can send. + self.peer_mtu = 0 + + # Set back to None on disconnection. + self._cid = None + # Set during disconnection. + self._status = 0 + + # If true, must wait for _IRQ_L2CAP_SEND_READY IRQ before sending. + self._stalled = False + + # Has received a _IRQ_L2CAP_RECV since the buffer was last emptied. + self._data_ready = False + + self._event = asyncio.ThreadSafeFlag() + + def _assert_connected(self): + if self._cid is None: + raise L2CAPDisconnectedError + + async def recvinto(self, buf, timeout_ms=None): + self._assert_connected() + + # Wait until the data_ready flag is set. This flag is only ever set by + # the event and cleared by this function. + with self._connection.timeout(timeout_ms): + while not self._data_ready: + await self._event.wait() + self._assert_connected() + + self._assert_connected() + + # Extract up to len(buf) bytes from the channel buffer. + n = ble.l2cap_recvinto(self._connection._conn_handle, self._cid, buf) + + # Check if there's still remaining data in the channel buffers. + self._data_ready = ble.l2cap_recvinto(self._connection._conn_handle, self._cid, None) > 0 + + return n + + # Synchronously see if there's data ready. + def available(self): + self._assert_connected() + return self._data_ready + + # Waits until the channel is free and then sends buf. + # If the buffer is larger than the MTU it will be sent in chunks. + async def send(self, buf, timeout_ms=None): + self._assert_connected() + offset = 0 + chunk_size = min(self.our_mtu * 2, self.peer_mtu) + mv = memoryview(buf) + while offset < len(buf): + if self._stalled: + await self.flush(timeout_ms) + # l2cap_send returns True if you can send immediately. + self._stalled = not ble.l2cap_send( + self._connection._conn_handle, + self._cid, + mv[offset : offset + chunk_size], + ) + offset += chunk_size + + async def flush(self, timeout_ms=None): + self._assert_connected() + # Wait for the _stalled flag to be cleared by the IRQ. + with self._connection.timeout(timeout_ms): + while self._stalled: + await self._event.wait() + self._assert_connected() + + async def disconnect(self, timeout_ms=1000): + if self._cid is None: + return + + # Wait for the cid to be cleared by the disconnect IRQ. + with self._connection.timeout(timeout_ms): + ble.l2cap_disconnect(self._connection._conn_handle, self._cid) + while self._cid is not None: + await self._event.wait() + + # Context manager -- automatically disconnect. + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_traceback): + await self.disconnect() + + +# Use connection.l2cap_accept() instead of calling this directly. +async def accept(connection, psn, mtu, timeout_ms): + global _listening + + channel = L2CAPChannel(connection) + + # Start the stack listening if necessary. + if not _listening: + ble.l2cap_listen(psn, mtu) + _listening = True + + # Wait for the connect irq from the remote connection. + with connection.timeout(timeout_ms): + await channel._event.wait() + return channel + + +# Use connection.l2cap_connect() instead of calling this directly. +async def connect(connection, psn, mtu, timeout_ms): + if _listening: + raise ValueError("Can't connect while listening") + + channel = L2CAPChannel(connection) + + with connection.timeout(timeout_ms): + ble.l2cap_connect(connection._conn_handle, psn, mtu) + + # Wait for the connect irq from the remote connection. + # If the connection fails, we get a disconnect event (with status) instead. + await channel._event.wait() + + if channel._cid is not None: + return channel + else: + raise L2CAPConnectionError(channel._status) diff --git a/micropython/bluetooth/aioble/aioble/peripheral.py b/micropython/bluetooth/aioble/aioble/peripheral.py new file mode 100644 index 00000000..61beebf0 --- /dev/null +++ b/micropython/bluetooth/aioble/aioble/peripheral.py @@ -0,0 +1,171 @@ +# MicroPython aioble module +# MIT license; Copyright (c) 2021 Jim Mussared + +from micropython import const + +import bluetooth +import struct + +import uasyncio as asyncio + +from .core import ( + ensure_active, + ble, + log_info, + log_error, + log_warn, + register_irq_handler, +) +from .device import Device, DeviceConnection, DeviceTimeout + + +_IRQ_CENTRAL_CONNECT = const(1) +_IRQ_CENTRAL_DISCONNECT = const(2) + + +_ADV_TYPE_FLAGS = const(0x01) +_ADV_TYPE_NAME = const(0x09) +_ADV_TYPE_UUID16_COMPLETE = const(0x3) +_ADV_TYPE_UUID32_COMPLETE = const(0x5) +_ADV_TYPE_UUID128_COMPLETE = const(0x7) +_ADV_TYPE_UUID16_MORE = const(0x2) +_ADV_TYPE_UUID32_MORE = const(0x4) +_ADV_TYPE_UUID128_MORE = const(0x6) +_ADV_TYPE_APPEARANCE = const(0x19) +_ADV_TYPE_MANUFACTURER = const(0xFF) + +_ADV_PAYLOAD_MAX_LEN = const(31) + + +_incoming_connection = None +_connect_event = None + + +def _peripheral_irq(event, data): + global _incoming_connection + + if event == _IRQ_CENTRAL_CONNECT: + conn_handle, addr_type, addr = data + + # Create, initialise, and register the device. + device = Device(addr_type, bytes(addr)) + _incoming_connection = DeviceConnection(device) + _incoming_connection._conn_handle = conn_handle + DeviceConnection._connected[conn_handle] = _incoming_connection + + # Signal advertise() to return the connected device. + _connect_event.set() + + elif event == _IRQ_CENTRAL_DISCONNECT: + conn_handle, _, _ = data + if connection := DeviceConnection._connected.get(conn_handle, None): + # Tell the device_task that it should terminate. + connection._event.set() + + +register_irq_handler(_peripheral_irq) + + +# Advertising payloads are repeated packets of the following form: +# 1 byte data length (N + 1) +# 1 byte type (see constants below) +# N bytes type-specific data +def _append(adv_data, resp_data, adv_type, value): + data = struct.pack("BB", len(value) + 1, adv_type) + value + + if len(data) + len(adv_data) < _ADV_PAYLOAD_MAX_LEN: + adv_data += data + return resp_data + + if len(data) + (len(resp_data) if resp_data else 0) < _ADV_PAYLOAD_MAX_LEN: + if not resp_data: + # Overflow into resp_data for the first time. + resp_data = bytearray() + resp_data += data + return resp_data + + raise ValueError("Advertising payload too long") + + +async def advertise( + interval_us, + adv_data=None, + resp_data=None, + connectable=True, + limited_disc=False, + br_edr=False, + name=None, + services=None, + appearance=0, + manufacturer=None, + timeout_ms=None, +): + global _incoming_connection, _connect_event + + ensure_active() + + if not adv_data and not resp_data: + # If the user didn't manually specify adv_data / resp_data then + # construct them from the kwargs. Keep adding fields to adv_data, + # overflowing to resp_data if necessary. + # TODO: Try and do better bin-packing than just concatenating in + # order? + + adv_data = bytearray() + + resp_data = _append( + adv_data, + resp_data, + _ADV_TYPE_FLAGS, + struct.pack("B", (0x01 if limited_disc else 0x02) + (0x18 if br_edr else 0x04)), + ) + + if name: + resp_data = _append(adv_data, resp_data, _ADV_TYPE_NAME, name) + + if services: + for uuid in services: + b = bytes(uuid) + if len(b) == 2: + resp_data = _append(adv_data, resp_data, _ADV_TYPE_UUID16_COMPLETE, b) + elif len(b) == 4: + resp_data = _append(adv_data, resp_data, _ADV_TYPE_UUID32_COMPLETE, b) + elif len(b) == 16: + resp_data = _append(adv_data, resp_data, _ADV_TYPE_UUID128_COMPLETE, b) + + if appearance: + # See org.bluetooth.characteristic.gap.appearance.xml + resp_data = _append( + adv_data, resp_data, _ADV_TYPE_APPEARANCE, struct.pack(". + + command = msg[0] + seq = msg[1] + file = msg[2:].decode() + + if command == _COMMAND_SEND: + op_seq = seq + send_file = file + l2cap_event.set() + elif command == _COMMAND_RECV: + op_seq = seq + recv_file = file + l2cap_event.set() + elif command == _COMMAND_LIST: + op_seq = seq + list_path = file + l2cap_event.set() + elif command == _COMMAND_SIZE: + try: + stat = os.stat(file) + size = stat[6] + status = 0 + except OSError as e: + size = 0 + status = _STATUS_NOT_FOUND + control_characteristic.notify( + connection, struct.pack("