kopia lustrzana https://github.com/micropython/micropython-lib
micropython/aioble: Add asyncio-based wrapper for ubluetooth.
Signed-off-by: Jim Mussared <jim.mussared@gmail.com>pull/427/head
rodzic
fe975d973a
commit
8631225b7f
|
@ -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.
|
|
@ -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)
|
|
@ -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("<H", u)[0])
|
||||
for u in self._decode_field(_ADV_TYPE_UUID32_INCOMPLETE, _ADV_TYPE_UUID32_COMPLETE):
|
||||
yield bluetooth.UUID(struct.unpack("<I", u)[0])
|
||||
for u in self._decode_field(_ADV_TYPE_UUID128_INCOMPLETE, _ADV_TYPE_UUID128_COMPLETE):
|
||||
yield bluetooth.UUID(u)
|
||||
|
||||
# Generator that returns (manufacturer_id, data) tuples.
|
||||
def manufacturer(self, filter=None):
|
||||
for u in self._decode_field(_ADV_TYPE_MANUFACTURER):
|
||||
if len(u) < 2:
|
||||
continue
|
||||
m = struct.unpack("<H", u[0:2])[0]
|
||||
if filter is None or m == filter:
|
||||
yield (m, u[2:])
|
||||
|
||||
|
||||
# Use with:
|
||||
# async with aioble.scan(...) as scanner:
|
||||
# async for result in scanner:
|
||||
# ...
|
||||
class scan:
|
||||
def __init__(self, duration_ms, interval_us=None, window_us=None, active=False):
|
||||
self._queue = []
|
||||
self._event = asyncio.ThreadSafeFlag()
|
||||
self._done = False
|
||||
|
||||
# Keep track of what we've already seen.
|
||||
self._results = set()
|
||||
|
||||
# Ideally we'd start the scan here and avoid having to save these
|
||||
# values, but we need to stop any previous scan first via awaiting
|
||||
# _cancel_pending(), but __init__ isn't async.
|
||||
self._duration_ms = duration_ms
|
||||
self._interval_us = interval_us or 1280000
|
||||
self._window_us = window_us or 11250
|
||||
self._active = active
|
||||
|
||||
async def __aenter__(self):
|
||||
global _active_scanner
|
||||
ensure_active()
|
||||
await _cancel_pending()
|
||||
_active_scanner = self
|
||||
ble.gap_scan(self._duration_ms, self._interval_us, self._window_us, self._active)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_traceback):
|
||||
# Cancel the current scan if we're still the active scanner. This will
|
||||
# happen if the loop breaks early before the scan duration completes.
|
||||
if _active_scanner == self:
|
||||
await self.cancel()
|
||||
|
||||
def __aiter__(self):
|
||||
assert _active_scanner == self
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
global _active_scanner
|
||||
|
||||
if _active_scanner != self:
|
||||
# The scan has been canceled (e.g. a connection was initiated).
|
||||
raise StopAsyncIteration
|
||||
|
||||
while True:
|
||||
while self._queue:
|
||||
addr_type, addr, adv_type, rssi, adv_data = self._queue.pop()
|
||||
|
||||
# Try to find an existing ScanResult for this device.
|
||||
for r in self._results:
|
||||
if r.device.addr_type == addr_type and r.device.addr == addr:
|
||||
result = r
|
||||
break
|
||||
else:
|
||||
# New device, create a new Device & ScanResult.
|
||||
device = Device(addr_type, addr)
|
||||
result = ScanResult(device)
|
||||
self._results.add(result)
|
||||
|
||||
# Add the new information from this event.
|
||||
if result._update(adv_type, rssi, adv_data):
|
||||
# It's new information, so re-yield this result.
|
||||
return result
|
||||
|
||||
if self._done:
|
||||
# _IRQ_SCAN_DONE event was fired.
|
||||
_active_scanner = None
|
||||
raise StopAsyncIteration
|
||||
|
||||
# Wait for either done or result IRQ.
|
||||
await self._event.wait()
|
||||
|
||||
# Cancel any in-progress scan. We need to do this before starting any other operation.
|
||||
async def cancel(self):
|
||||
if self._done:
|
||||
return
|
||||
ble.gap_scan(None)
|
||||
while not self._done:
|
||||
await self._event.wait()
|
||||
global _active_scanner
|
||||
_active_scanner = None
|
|
@ -0,0 +1,413 @@
|
|||
# MicroPython aioble module
|
||||
# MIT license; Copyright (c) 2021 Jim Mussared
|
||||
|
||||
from micropython import const
|
||||
import uasyncio as asyncio
|
||||
import struct
|
||||
|
||||
import bluetooth
|
||||
|
||||
from .core import ble, GattError, register_irq_handler
|
||||
from .device import DeviceConnection
|
||||
|
||||
|
||||
_IRQ_GATTC_SERVICE_RESULT = const(9)
|
||||
_IRQ_GATTC_SERVICE_DONE = const(10)
|
||||
_IRQ_GATTC_CHARACTERISTIC_RESULT = const(11)
|
||||
_IRQ_GATTC_CHARACTERISTIC_DONE = const(12)
|
||||
_IRQ_GATTC_DESCRIPTOR_RESULT = const(13)
|
||||
_IRQ_GATTC_DESCRIPTOR_DONE = const(14)
|
||||
_IRQ_GATTC_READ_RESULT = const(15)
|
||||
_IRQ_GATTC_READ_DONE = const(16)
|
||||
_IRQ_GATTC_WRITE_DONE = const(17)
|
||||
_IRQ_GATTC_NOTIFY = const(18)
|
||||
_IRQ_GATTC_INDICATE = const(19)
|
||||
|
||||
_CCCD_UUID = const(0x2902)
|
||||
_CCCD_NOTIFY = const(1)
|
||||
_CCCD_INDICATE = const(2)
|
||||
|
||||
# Forward IRQs directly to static methods on the type that handles them and
|
||||
# knows how to map handles to instances. Note: We copy all uuid and data
|
||||
# params here for safety, but a future optimisation might be able to avoid
|
||||
# these copies in a few places.
|
||||
def _client_irq(event, data):
|
||||
if event == _IRQ_GATTC_SERVICE_RESULT:
|
||||
conn_handle, start_handle, end_handle, uuid = data
|
||||
ClientDiscover._discover_result(
|
||||
conn_handle, start_handle, end_handle, bluetooth.UUID(uuid)
|
||||
)
|
||||
elif event == _IRQ_GATTC_SERVICE_DONE:
|
||||
conn_handle, status = data
|
||||
ClientDiscover._discover_done(conn_handle, status)
|
||||
elif event == _IRQ_GATTC_CHARACTERISTIC_RESULT:
|
||||
conn_handle, def_handle, value_handle, properties, uuid = data
|
||||
ClientDiscover._discover_result(
|
||||
conn_handle, def_handle, value_handle, properties, bluetooth.UUID(uuid)
|
||||
)
|
||||
elif event == _IRQ_GATTC_CHARACTERISTIC_DONE:
|
||||
conn_handle, status = data
|
||||
ClientDiscover._discover_done(conn_handle, status)
|
||||
elif event == _IRQ_GATTC_DESCRIPTOR_RESULT:
|
||||
conn_handle, dsc_handle, uuid = data
|
||||
ClientDiscover._discover_result(conn_handle, dsc_handle, bluetooth.UUID(uuid))
|
||||
elif event == _IRQ_GATTC_DESCRIPTOR_DONE:
|
||||
conn_handle, status = data
|
||||
ClientDiscover._discover_done(conn_handle, status)
|
||||
elif event == _IRQ_GATTC_READ_RESULT:
|
||||
conn_handle, value_handle, char_data = data
|
||||
ClientCharacteristic._read_result(conn_handle, value_handle, bytes(char_data))
|
||||
elif event == _IRQ_GATTC_READ_DONE:
|
||||
conn_handle, value_handle, status = data
|
||||
ClientCharacteristic._read_done(conn_handle, value_handle, status)
|
||||
elif event == _IRQ_GATTC_WRITE_DONE:
|
||||
conn_handle, value_handle, status = data
|
||||
ClientCharacteristic._write_done(conn_handle, value_handle, status)
|
||||
elif event == _IRQ_GATTC_NOTIFY:
|
||||
conn_handle, value_handle, notify_data = data
|
||||
ClientCharacteristic._on_notify(conn_handle, value_handle, bytes(notify_data))
|
||||
elif event == _IRQ_GATTC_INDICATE:
|
||||
conn_handle, value_handle, indicate_data = data
|
||||
ClientCharacteristic._on_indicate(conn_handle, value_handle, bytes(indicate_data))
|
||||
|
||||
|
||||
register_irq_handler(_client_irq)
|
||||
|
||||
|
||||
# Async generator for discovering services, characteristics, descriptors.
|
||||
class ClientDiscover:
|
||||
def __init__(self, connection, disc_type, parent, timeout_ms, *args):
|
||||
self._connection = connection
|
||||
|
||||
# Each result IRQ will append to this.
|
||||
self._queue = []
|
||||
# This will be set by the done IRQ.
|
||||
self._status = None
|
||||
|
||||
# Tell the generator to process new events.
|
||||
self._event = asyncio.ThreadSafeFlag()
|
||||
|
||||
# Must implement the _start_discovery static method. Instances of this
|
||||
# type are returned by __anext__.
|
||||
self._disc_type = disc_type
|
||||
|
||||
# This will be the connection for a service discovery, and the service for a characteristic discovery.
|
||||
self._parent = parent
|
||||
|
||||
# Timeout for the discovery process.
|
||||
# TODO: Not implemented.
|
||||
self._timeout_ms = timeout_ms
|
||||
|
||||
# Additional arguments to pass to the _start_discovery method on disc_type.
|
||||
self._args = args
|
||||
|
||||
async def _start(self):
|
||||
if self._connection._discover:
|
||||
# TODO: cancel existing? (e.g. perhaps they didn't let the loop run to completion)
|
||||
raise ValueError("Discovery in progress")
|
||||
|
||||
# Tell the connection that we're the active discovery operation (the IRQ only gives us conn_handle).
|
||||
self._connection._discover = self
|
||||
# Call the appropriate ubluetooth.BLE method.
|
||||
self._disc_type._start_discovery(self._parent, *self._args)
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
if self._connection._discover != self:
|
||||
# Start the discovery if necessary.
|
||||
await self._start()
|
||||
|
||||
# Keep returning items from the queue until the status is set by the
|
||||
# done IRQ.
|
||||
while True:
|
||||
while self._queue:
|
||||
return self._disc_type(self._parent, *self._queue.pop())
|
||||
if self._status is not None:
|
||||
self._connection._discover = None
|
||||
raise StopAsyncIteration
|
||||
# Wait for more results to be added to the queue.
|
||||
await self._event.wait()
|
||||
|
||||
# Tell the active discovery instance for this connection to add a new result
|
||||
# to the queue.
|
||||
def _discover_result(conn_handle, *args):
|
||||
if connection := DeviceConnection._connected.get(conn_handle, None):
|
||||
if discover := connection._discover:
|
||||
discover._queue.append(args)
|
||||
discover._event.set()
|
||||
|
||||
# Tell the active discovery instance for this connection that it is complete.
|
||||
def _discover_done(conn_handle, status):
|
||||
if connection := DeviceConnection._connected.get(conn_handle, None):
|
||||
if discover := connection._discover:
|
||||
discover._status = status
|
||||
discover._event.set()
|
||||
|
||||
|
||||
# Represents a single service supported by a connection. Do not construct this
|
||||
# class directly, instead use `async for service in connection.services([uuid])` or
|
||||
# `await connection.service(uuid)`.
|
||||
class ClientService:
|
||||
def __init__(self, connection, start_handle, end_handle, uuid):
|
||||
self.connection = connection
|
||||
|
||||
# Used for characteristic discovery.
|
||||
self._start_handle = start_handle
|
||||
self._end_handle = end_handle
|
||||
|
||||
# Allows comparison to a known uuid.
|
||||
self.uuid = uuid
|
||||
|
||||
def __str__(self):
|
||||
return "Service: {} {} {}".format(self._start_handle, self._end_handle, self.uuid)
|
||||
|
||||
# Search for a specific characteristic by uuid.
|
||||
async def characteristic(self, uuid, timeout_ms=2000):
|
||||
result = None
|
||||
# Make sure loop runs to completion.
|
||||
async for characteristic in self.characteristics(uuid, timeout_ms):
|
||||
if not result and characteristic.uuid == uuid:
|
||||
# Keep first result.
|
||||
result = characteristic
|
||||
return result
|
||||
|
||||
# Search for all services (optionally by uuid).
|
||||
# Use with `async for`, e.g.
|
||||
# async for characteristic in service.characteristics():
|
||||
# Note: must allow the loop to run to completion.
|
||||
def characteristics(self, uuid=None, timeout_ms=2000):
|
||||
return ClientDiscover(self.connection, ClientCharacteristic, self, timeout_ms, uuid)
|
||||
|
||||
# For ClientDiscover
|
||||
def _start_discovery(connection, uuid=None):
|
||||
ble.gattc_discover_services(connection._conn_handle, uuid)
|
||||
|
||||
|
||||
class BaseClientCharacteristic:
|
||||
# Register this value handle so events can find us.
|
||||
def _register_with_connection(self):
|
||||
self._connection()._characteristics[self._value_handle] = self
|
||||
|
||||
# Map an incoming IRQ to an registered characteristic.
|
||||
def _find(conn_handle, value_handle):
|
||||
if connection := DeviceConnection._connected.get(conn_handle, None):
|
||||
if characteristic := connection._characteristics.get(value_handle, None):
|
||||
return characteristic
|
||||
else:
|
||||
# IRQ for a characteristic that we weren't expecting. e.g.
|
||||
# notification when we're not waiting on notified().
|
||||
# TODO: This will happen on btstack, which doesn't give us
|
||||
# value handle for the done event.
|
||||
return None
|
||||
|
||||
# Issue a read to the characteristic.
|
||||
async def read(self, timeout_ms=1000):
|
||||
# Make sure this conn_handle/value_handle is known.
|
||||
self._register_with_connection()
|
||||
# This will be set by the done IRQ.
|
||||
self._read_status = None
|
||||
# This will be set by the result and done IRQs. Re-use if possible.
|
||||
self._read_event = self._read_event or asyncio.ThreadSafeFlag()
|
||||
|
||||
# Issue the read.
|
||||
ble.gattc_read(self._connection()._conn_handle, self._value_handle)
|
||||
|
||||
with self._connection().timeout(timeout_ms):
|
||||
# The event will be set for each read result, then a final time for done.
|
||||
while self._read_status is None:
|
||||
await self._read_event.wait()
|
||||
if self._read_status != 0:
|
||||
raise GattError(self._read_status)
|
||||
return self._read_data
|
||||
|
||||
# Map an incoming result IRQ to a registered characteristic.
|
||||
def _read_result(conn_handle, value_handle, data):
|
||||
if characteristic := ClientCharacteristic._find(conn_handle, value_handle):
|
||||
characteristic._read_data = data
|
||||
characteristic._read_event.set()
|
||||
|
||||
# Map an incoming read done IRQ to a registered characteristic.
|
||||
def _read_done(conn_handle, value_handle, status):
|
||||
if characteristic := ClientCharacteristic._find(conn_handle, value_handle):
|
||||
characteristic._read_status = status
|
||||
characteristic._read_event.set()
|
||||
|
||||
async def write(self, data, response=False, timeout_ms=1000):
|
||||
# TODO: default response to True if properties includes WRITE and is char.
|
||||
# Something like:
|
||||
# if response is None and self.properties & _FLAGS_WRITE:
|
||||
# response = True
|
||||
|
||||
if response:
|
||||
# Same as read.
|
||||
self._register_with_connection()
|
||||
self._write_status = None
|
||||
self._write_event = self._write_event or asyncio.ThreadSafeFlag()
|
||||
|
||||
# Issue the write.
|
||||
ble.gattc_write(self._connection()._conn_handle, self._value_handle, data, response)
|
||||
|
||||
if response:
|
||||
with self._connection().timeout(timeout_ms):
|
||||
# The event will be set for the write done IRQ.
|
||||
await self._write_event.wait()
|
||||
if self._write_status != 0:
|
||||
raise GattError(self._write_status)
|
||||
|
||||
# Map an incoming write done IRQ to a registered characteristic.
|
||||
def _write_done(conn_handle, value_handle, status):
|
||||
if characteristic := ClientCharacteristic._find(conn_handle, value_handle):
|
||||
characteristic._write_status = status
|
||||
characteristic._write_event.set()
|
||||
|
||||
|
||||
# Represents a single characteristic supported by a service. Do not construct
|
||||
# this class directly, instead use `async for characteristic in
|
||||
# service.characteristics([uuid])` or `await service.characteristic(uuid)`.
|
||||
class ClientCharacteristic(BaseClientCharacteristic):
|
||||
def __init__(self, service, def_handle, value_handle, properties, uuid):
|
||||
self.service = service
|
||||
self.connection = service.connection
|
||||
|
||||
# Used for read/write/notify ops.
|
||||
self._def_handle = def_handle
|
||||
self._value_handle = value_handle
|
||||
|
||||
# Which operations are supported.
|
||||
self.properties = properties
|
||||
|
||||
# Allows comparison to a known uuid.
|
||||
self.uuid = uuid
|
||||
|
||||
# Fired for each read result and read done IRQ.
|
||||
self._read_event = None
|
||||
self._read_data = None
|
||||
# Used to indicate that the read is complete.
|
||||
self._read_status = None
|
||||
|
||||
# Fired for the write done IRQ.
|
||||
self._write_event = None
|
||||
# Used to indicate that the write is complete.
|
||||
self._write_status = None
|
||||
|
||||
# Fired when a notification arrives.
|
||||
self._notify_event = None
|
||||
# Data for the most recent notification.
|
||||
self._notify_data = None
|
||||
# Same for indications.
|
||||
self._indicate_event = None
|
||||
self._indicate_data = None
|
||||
|
||||
def __str__(self):
|
||||
return "Characteristic: {} {} {} {}".format(
|
||||
self._def_handle, self._value_handle, self._properties, self.uuid
|
||||
)
|
||||
|
||||
def _connection(self):
|
||||
return self.service.connection
|
||||
|
||||
# Search for a specific descriptor by uuid.
|
||||
async def descriptor(self, uuid, timeout_ms=2000):
|
||||
result = None
|
||||
# Make sure loop runs to completion.
|
||||
async for descriptor in self.descriptors(timeout_ms):
|
||||
if not result and descriptor.uuid == uuid:
|
||||
# Keep first result.
|
||||
result = descriptor
|
||||
return result
|
||||
|
||||
# Search for all services (optionally by uuid).
|
||||
# Use with `async for`, e.g.
|
||||
# async for descriptor in characteristic.descriptors():
|
||||
# Note: must allow the loop to run to completion.
|
||||
def descriptors(self, timeout_ms=2000):
|
||||
return ClientDiscover(self.connection, ClientDescriptor, self, timeout_ms)
|
||||
|
||||
# For ClientDiscover
|
||||
def _start_discovery(service, uuid=None):
|
||||
ble.gattc_discover_characteristics(
|
||||
service.connection._conn_handle,
|
||||
service._start_handle,
|
||||
service._end_handle,
|
||||
uuid,
|
||||
)
|
||||
|
||||
# Wait for the next notification.
|
||||
# Will return immediately if a notification has already been received.
|
||||
async def notified(self, timeout_ms=None):
|
||||
self._register_with_connection()
|
||||
data = self._notify_data
|
||||
if data is None:
|
||||
self._notify_event = self._notify_event or asyncio.ThreadSafeFlag()
|
||||
with self._connection().timeout(timeout_ms):
|
||||
await self._notify_event.wait()
|
||||
data = self._notify_data
|
||||
self._notify_data = None
|
||||
return data
|
||||
|
||||
# Map an incoming notify IRQ to a registered characteristic.
|
||||
def _on_notify(conn_handle, value_handle, notify_data):
|
||||
if characteristic := ClientCharacteristic._find(conn_handle, value_handle):
|
||||
characteristic._notify_data = notify_data
|
||||
if characteristic._notify_event:
|
||||
characteristic._notify_event.set()
|
||||
|
||||
# Wait for the next indication.
|
||||
# Will return immediately if an indication has already been received.
|
||||
async def indicated(self, timeout_ms=None):
|
||||
self._register_with_connection()
|
||||
data = self._indicate_data
|
||||
if data is None:
|
||||
self._indicate_event = self._indicate_event or asyncio.ThreadSafeFlag()
|
||||
with self._connection().timeout(timeout_ms):
|
||||
await self._indicate_event.wait()
|
||||
data = self._indicate_data
|
||||
self._indicate_data = None
|
||||
return data
|
||||
|
||||
# Map an incoming indicate IRQ to a registered characteristic.
|
||||
def _on_indicate(conn_handle, value_handle, indicate_data):
|
||||
if characteristic := ClientCharacteristic._find(conn_handle, value_handle):
|
||||
characteristic._indicate_data = indicate_data
|
||||
if characteristic._indicate_event:
|
||||
characteristic._indicate_event.set()
|
||||
|
||||
# Write to the Client Characteristic Configuration to subscribe to
|
||||
# notify/indications for this characteristic.
|
||||
async def subscribe(self, notify=True, indicate=False):
|
||||
if cccd := await self.descriptor(bluetooth.UUID(_CCCD_UUID)):
|
||||
await cccd.write(struct.pack("<H", _CCCD_NOTIFY * notify + _CCCD_INDICATE * indicate))
|
||||
else:
|
||||
raise ValueError("CCCD not found")
|
||||
|
||||
|
||||
# Represents a single descriptor supported by a characteristic. Do not construct
|
||||
# this class directly, instead use `async for descriptors in
|
||||
# characteristic.descriptors([uuid])` or `await characteristic.descriptor(uuid)`.
|
||||
class ClientDescriptor(BaseClientCharacteristic):
|
||||
def __init__(self, characteristic, dsc_handle, uuid):
|
||||
self.characteristic = characteristic
|
||||
|
||||
# Allows comparison to a known uuid.
|
||||
self.uuid = uuid
|
||||
|
||||
# Used for read/write.
|
||||
self._value_handle = dsc_handle
|
||||
|
||||
def __str__(self):
|
||||
return "Descriptor: {} {} {} {}".format(
|
||||
self._def_handle, self._value_handle, self._properties, self.uuid
|
||||
)
|
||||
|
||||
def _connection(self):
|
||||
return self.characteristic.service.connection
|
||||
|
||||
# For ClientDiscover
|
||||
def _start_discovery(characteristic, uuid=None):
|
||||
ble.gattc_discover_descriptors(
|
||||
characteristic._connection()._conn_handle,
|
||||
characteristic._value_handle,
|
||||
characteristic._value_handle + 5,
|
||||
)
|
|
@ -0,0 +1,71 @@
|
|||
# MicroPython aioble module
|
||||
# MIT license; Copyright (c) 2021 Jim Mussared
|
||||
|
||||
import bluetooth
|
||||
|
||||
|
||||
log_level = 1
|
||||
|
||||
|
||||
def log_error(*args):
|
||||
if log_level > 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)
|
|
@ -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()
|
|
@ -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)
|
|
@ -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("<H", appearance)
|
||||
)
|
||||
|
||||
if manufacturer:
|
||||
resp_data = _append(
|
||||
adv_data,
|
||||
resp_data,
|
||||
_ADV_TYPE_MANUFACTURER,
|
||||
struct.pack("<H", manufacturer[0]) + manufacturer[1],
|
||||
)
|
||||
|
||||
_connect_event = _connect_event or asyncio.ThreadSafeFlag()
|
||||
ble.gap_advertise(interval_us, adv_data=adv_data, resp_data=resp_data, connectable=connectable)
|
||||
|
||||
try:
|
||||
# Allow optional timeout for a central to connect to us (or just to stop advertising).
|
||||
with DeviceTimeout(None, timeout_ms):
|
||||
await _connect_event.wait()
|
||||
|
||||
# Get the newly connected connection to the central and start a task
|
||||
# to wait for disconnection.
|
||||
result = _incoming_connection
|
||||
_incoming_connection = None
|
||||
# This mirrors what connecting to a central does.
|
||||
result._run_task()
|
||||
return result
|
||||
except asyncio.CancelledError:
|
||||
# Something else cancelled this task (to manually stop advertising).
|
||||
ble.gap_advertise(None)
|
||||
except asyncio.TimeoutError:
|
||||
# DeviceTimeout waiting for connection.
|
||||
ble.gap_advertise(None)
|
||||
raise
|
|
@ -0,0 +1,171 @@
|
|||
# MicroPython aioble module
|
||||
# MIT license; Copyright (c) 2021 Jim Mussared
|
||||
|
||||
from micropython import const, schedule
|
||||
import uasyncio as asyncio
|
||||
import binascii
|
||||
import json
|
||||
|
||||
from .core import log_info, log_warn, ble, register_irq_handler
|
||||
from .device import DeviceConnection
|
||||
|
||||
_IRQ_ENCRYPTION_UPDATE = const(28)
|
||||
_IRQ_GET_SECRET = const(29)
|
||||
_IRQ_SET_SECRET = const(30)
|
||||
_IRQ_PASSKEY_ACTION = const(31)
|
||||
|
||||
_IO_CAPABILITY_DISPLAY_ONLY = const(0)
|
||||
_IO_CAPABILITY_DISPLAY_YESNO = const(1)
|
||||
_IO_CAPABILITY_KEYBOARD_ONLY = const(2)
|
||||
_IO_CAPABILITY_NO_INPUT_OUTPUT = const(3)
|
||||
_IO_CAPABILITY_KEYBOARD_DISPLAY = const(4)
|
||||
|
||||
_PASSKEY_ACTION_INPUT = const(2)
|
||||
_PASSKEY_ACTION_DISP = const(3)
|
||||
_PASSKEY_ACTION_NUMCMP = const(4)
|
||||
|
||||
_DEFAULT_PATH = "ble_secrets.json"
|
||||
|
||||
_secrets = {}
|
||||
_modified = False
|
||||
_path = None
|
||||
|
||||
|
||||
# Must call this before stack startup.
|
||||
def load_secrets(path=None):
|
||||
global _path, _secrets
|
||||
|
||||
# Use path if specified, otherwise use previous path, otherwise use
|
||||
# default path.
|
||||
_path = path or _path or _DEFAULT_PATH
|
||||
|
||||
# Reset old secrets.
|
||||
_secrets = {}
|
||||
try:
|
||||
with open(_path, "r") as f:
|
||||
entries = json.load(f)
|
||||
for sec_type, key, value in entries:
|
||||
# Decode bytes from hex.
|
||||
_secrets[sec_type, binascii.a2b_base64(key)] = binascii.a2b_base64(value)
|
||||
except:
|
||||
log_warn("No secrets available")
|
||||
|
||||
|
||||
# Call this whenever the secrets dict changes.
|
||||
def _save_secrets(arg=None):
|
||||
global _modified, _path
|
||||
|
||||
_path = _path or _DEFAULT_PATH
|
||||
|
||||
if not _modified:
|
||||
# Only save if the secrets changed.
|
||||
return
|
||||
|
||||
with open(_path, "w") as f:
|
||||
# Convert bytes to hex strings (otherwise JSON will treat them like
|
||||
# strings).
|
||||
json_secrets = [
|
||||
(sec_type, binascii.b2a_base64(key), binascii.b2a_base64(value))
|
||||
for (sec_type, key), value in _secrets.items()
|
||||
]
|
||||
json.dump(json_secrets, f)
|
||||
_modified = False
|
||||
|
||||
|
||||
def _security_irq(event, data):
|
||||
global _modified
|
||||
|
||||
if event == _IRQ_ENCRYPTION_UPDATE:
|
||||
# Connection has updated (usually due to pairing).
|
||||
conn_handle, encrypted, authenticated, bonded, key_size = data
|
||||
log_info("encryption update", conn_handle, encrypted, authenticated, bonded, key_size)
|
||||
if connection := DeviceConnection._connected.get(conn_handle, None):
|
||||
connection.encrypted = encrypted
|
||||
connection.authenticated = authenticated
|
||||
connection.bonded = bonded
|
||||
connection.key_size = key_size
|
||||
# TODO: Handle failure.
|
||||
if encrypted and connection._pair_event:
|
||||
connection._pair_event.set()
|
||||
|
||||
elif event == _IRQ_SET_SECRET:
|
||||
sec_type, key, value = data
|
||||
key = sec_type, bytes(key)
|
||||
value = bytes(value) if value else None
|
||||
|
||||
log_info("set secret:", key, value)
|
||||
|
||||
if value is None:
|
||||
# Delete secret.
|
||||
if key not in _secrets:
|
||||
return False
|
||||
|
||||
del _secrets[key]
|
||||
else:
|
||||
# Save secret.
|
||||
_secrets[key] = value
|
||||
|
||||
# Queue up a save (don't synchronously write to flash).
|
||||
_modified = True
|
||||
schedule(_save_secrets, None)
|
||||
|
||||
return True
|
||||
|
||||
elif event == _IRQ_GET_SECRET:
|
||||
sec_type, index, key = data
|
||||
|
||||
log_info("get secret:", sec_type, index, bytes(key) if key else None)
|
||||
|
||||
if key is None:
|
||||
# Return the index'th secret of this type.
|
||||
i = 0
|
||||
for (t, _key), value in _secrets.items():
|
||||
if t == sec_type:
|
||||
if i == index:
|
||||
return value
|
||||
i += 1
|
||||
return None
|
||||
else:
|
||||
# Return the secret for this key (or None).
|
||||
key = sec_type, bytes(key)
|
||||
return _secrets.get(key, None)
|
||||
|
||||
elif event == _IRQ_PASSKEY_ACTION:
|
||||
conn_handle, action, passkey = data
|
||||
log_info("passkey action", conn_handle, action, passkey)
|
||||
# if action == _PASSKEY_ACTION_NUMCMP:
|
||||
# # TODO: Show this passkey and confirm accept/reject.
|
||||
# accept = 1
|
||||
# self._ble.gap_passkey(conn_handle, action, accept)
|
||||
# elif action == _PASSKEY_ACTION_DISP:
|
||||
# # TODO: Generate and display a passkey so the remote device can enter it.
|
||||
# passkey = 123456
|
||||
# self._ble.gap_passkey(conn_handle, action, passkey)
|
||||
# elif action == _PASSKEY_ACTION_INPUT:
|
||||
# # TODO: Ask the user to enter the passkey shown on the remote device.
|
||||
# passkey = 123456
|
||||
# self._ble.gap_passkey(conn_handle, action, passkey)
|
||||
# else:
|
||||
# log_warn("unknown passkey action")
|
||||
|
||||
|
||||
register_irq_handler(_security_irq)
|
||||
|
||||
|
||||
# Use device.pair() rather than calling this directly.
|
||||
async def pair(
|
||||
connection,
|
||||
bond=True,
|
||||
le_secure=True,
|
||||
mitm=False,
|
||||
io=_IO_CAPABILITY_NO_INPUT_OUTPUT,
|
||||
timeout_ms=20000,
|
||||
):
|
||||
ble.config(bond=bond, le_secure=le_secure, mitm=mitm, io=io)
|
||||
|
||||
with connection.timeout(timeout_ms):
|
||||
connection._pair_event = asyncio.ThreadSafeFlag()
|
||||
ble.gap_pair(connection._conn_handle)
|
||||
await connection._pair_event.wait()
|
||||
# TODO: Allow the passkey action to return to here and
|
||||
# invoke a callback or task to process the action.
|
|
@ -0,0 +1,239 @@
|
|||
# MicroPython aioble module
|
||||
# MIT license; Copyright (c) 2021 Jim Mussared
|
||||
|
||||
from micropython import const
|
||||
import bluetooth
|
||||
import uasyncio as asyncio
|
||||
|
||||
from .core import (
|
||||
ensure_active,
|
||||
ble,
|
||||
log_info,
|
||||
log_error,
|
||||
log_warn,
|
||||
register_irq_handler,
|
||||
)
|
||||
from .device import DeviceConnection, DeviceTimeout
|
||||
|
||||
_registered_characteristics = {}
|
||||
|
||||
_IRQ_GATTS_WRITE = const(3)
|
||||
_IRQ_GATTS_READ_REQUEST = const(4)
|
||||
_IRQ_GATTS_INDICATE_DONE = const(20)
|
||||
|
||||
_FLAG_READ = const(0x0002)
|
||||
_FLAG_WRITE_NO_RESPONSE = const(0x0004)
|
||||
_FLAG_WRITE = const(0x0008)
|
||||
_FLAG_NOTIFY = const(0x0010)
|
||||
_FLAG_INDICATE = const(0x0020)
|
||||
|
||||
_FLAG_READ_ENCRYPTED = const(0x0200)
|
||||
_FLAG_READ_AUTHENTICATED = const(0x0400)
|
||||
_FLAG_READ_AUTHORIZED = const(0x0800)
|
||||
_FLAG_WRITE_ENCRYPTED = const(0x1000)
|
||||
_FLAG_WRITE_AUTHENTICATED = const(0x2000)
|
||||
_FLAG_WRITE_AUTHORIZED = const(0x4000)
|
||||
|
||||
_FLAG_DESC_READ = const(1)
|
||||
_FLAG_DESC_WRITE = const(2)
|
||||
|
||||
|
||||
def _server_irq(event, data):
|
||||
if event == _IRQ_GATTS_WRITE:
|
||||
conn_handle, attr_handle = data
|
||||
Characteristic._remote_write(conn_handle, attr_handle)
|
||||
elif event == _IRQ_GATTS_READ_REQUEST:
|
||||
conn_handle, attr_handle = data
|
||||
return Characteristic._remote_read(conn_handle, attr_handle)
|
||||
elif event == _IRQ_GATTS_INDICATE_DONE:
|
||||
conn_handle, value_handle, status = data
|
||||
Characteristic._indicate_done(conn_handle, value_handle, status)
|
||||
|
||||
|
||||
register_irq_handler(_server_irq)
|
||||
|
||||
|
||||
class Service:
|
||||
def __init__(self, uuid):
|
||||
self.uuid = uuid
|
||||
self.characteristics = []
|
||||
|
||||
# Generate tuple for gatts_register_services.
|
||||
def _tuple(self):
|
||||
return (self.uuid, tuple(c._tuple() for c in self.characteristics))
|
||||
|
||||
|
||||
class BaseCharacteristic:
|
||||
def _register(self, value_handle):
|
||||
self._value_handle = value_handle
|
||||
_registered_characteristics[value_handle] = self
|
||||
if self._initial is not None:
|
||||
self.write(self._initial)
|
||||
self._initial = None
|
||||
|
||||
# Generate tuple for gatts_register_services.
|
||||
def _tuple(self):
|
||||
return (self.uuid, self.flags)
|
||||
|
||||
# Read value from local db.
|
||||
def read(self):
|
||||
if self._value_handle is None:
|
||||
return self._initial or b""
|
||||
else:
|
||||
return ble.gatts_read(self._value_handle)
|
||||
|
||||
# Write value to local db.
|
||||
def write(self, data):
|
||||
if self._value_handle is None:
|
||||
self._initial = data
|
||||
else:
|
||||
ble.gatts_write(self._value_handle, data)
|
||||
|
||||
# Wait for a write on this characteristic.
|
||||
# Returns the device that did the write.
|
||||
async def written(self, timeout_ms=None):
|
||||
if not self._write_event:
|
||||
raise ValueError()
|
||||
data = self._write_connection
|
||||
if data is None:
|
||||
with DeviceTimeout(None, timeout_ms):
|
||||
await self._write_event.wait()
|
||||
data = self._write_connection
|
||||
self._write_connection = None
|
||||
return data
|
||||
|
||||
def on_read(self, connection):
|
||||
return 0
|
||||
|
||||
def _remote_write(conn_handle, value_handle):
|
||||
if characteristic := _registered_characteristics.get(value_handle, None):
|
||||
characteristic._write_connection = DeviceConnection._connected.get(conn_handle, None)
|
||||
characteristic._write_event.set()
|
||||
|
||||
def _remote_read(conn_handle, value_handle):
|
||||
if characteristic := _registered_characteristics.get(value_handle, None):
|
||||
return characteristic.on_read(DeviceConnection._connected.get(conn_handle, None))
|
||||
|
||||
|
||||
class Characteristic(BaseCharacteristic):
|
||||
def __init__(
|
||||
self,
|
||||
service,
|
||||
uuid,
|
||||
read=False,
|
||||
write=False,
|
||||
write_no_response=False,
|
||||
notify=False,
|
||||
indicate=False,
|
||||
initial=None,
|
||||
):
|
||||
service.characteristics.append(self)
|
||||
self.descriptors = []
|
||||
|
||||
flags = 0
|
||||
if read:
|
||||
flags |= _FLAG_READ
|
||||
if write or write_no_response:
|
||||
flags |= (_FLAG_WRITE if write else 0) | (
|
||||
_FLAG_WRITE_NO_RESPONSE if write_no_response else 0
|
||||
)
|
||||
self._write_connection = None
|
||||
self._write_event = asyncio.ThreadSafeFlag()
|
||||
if notify:
|
||||
flags |= _FLAG_NOTIFY
|
||||
if indicate:
|
||||
flags |= _FLAG_INDICATE
|
||||
# TODO: This should probably be a dict of connection to (ev, status).
|
||||
# Right now we just support a single indication at a time.
|
||||
self._indicate_connection = None
|
||||
self._indicate_event = asyncio.ThreadSafeFlag()
|
||||
self._indicate_status = None
|
||||
|
||||
self.uuid = uuid
|
||||
self.flags = flags
|
||||
self._value_handle = None
|
||||
self._initial = initial
|
||||
|
||||
def notify(self, connection, data=None):
|
||||
if not (self.flags & _FLAG_NOTIFY):
|
||||
raise ValueError("Not supported")
|
||||
ble.gatts_notify(connection._conn_handle, self._value_handle, data)
|
||||
|
||||
async def indicate(self, connection, timeout_ms=1000):
|
||||
if not (self.flags & _FLAG_INDICATE):
|
||||
raise ValueError("Not supported")
|
||||
if self._indicate_connection is not None:
|
||||
raise ValueError("In progress")
|
||||
if not connection.is_connected():
|
||||
raise ValueError("Not connected")
|
||||
|
||||
self._indicate_connection = connection
|
||||
self._indicate_status = None
|
||||
|
||||
try:
|
||||
with connection.timeout(timeout_ms):
|
||||
ble.gatts_indicate(connection._conn_handle, self._value_handle)
|
||||
await self._indicate_event.wait()
|
||||
if self._indicate_status != 0:
|
||||
raise GattError(self._indicate_status)
|
||||
finally:
|
||||
self._indicate_connection = None
|
||||
|
||||
def _indicate_done(conn_handle, value_handle, status):
|
||||
if characteristic := _registered_characteristics.get(value_handle, None):
|
||||
if connection := DeviceConnection._connected.get(conn_handle, None):
|
||||
if not characteristic._indicate_connection:
|
||||
# Timeout.
|
||||
return
|
||||
# See TODO in __init__ to support multiple concurrent indications.
|
||||
assert connection == characteristic._indicate_connection
|
||||
characteristic._indicate_status = status
|
||||
characteristic._indicate_event.set()
|
||||
|
||||
|
||||
class BufferedCharacteristic(Characteristic):
|
||||
def __init__(self, service, uuid, max_len=20, append=False):
|
||||
super().__init__(service, uuid, read=True)
|
||||
self._max_len = max_len
|
||||
self._append = append
|
||||
|
||||
def _register(self, value_handle):
|
||||
super()._register(value_handle)
|
||||
ble.gatts_set_buffer(value_handle, self._max_len, self._append)
|
||||
|
||||
|
||||
class Descriptor(BaseCharacteristic):
|
||||
def __init__(self, characteristic, uuid, read=False, write=False, initial=None):
|
||||
characteristic.descriptors.append(self)
|
||||
|
||||
# Workaround for https://github.com/micropython/micropython/issues/6864
|
||||
flags = 0
|
||||
if read:
|
||||
flags |= _FLAG_DESC_READ
|
||||
if write:
|
||||
self._write_connection = None
|
||||
self._write_event = asyncio.ThreadSafeFlag()
|
||||
flags |= _FLAG_DESC_WRITE
|
||||
|
||||
self.uuid = uuid
|
||||
self.flags = flags
|
||||
self._value_handle = None
|
||||
self._initial = initial
|
||||
|
||||
|
||||
# Turn the Service/Characteristic/Descriptor classes into a registration tuple
|
||||
# and then extract their value handles.
|
||||
def register_services(*services):
|
||||
ensure_active()
|
||||
_registered_characteristics.clear()
|
||||
handles = ble.gatts_register_services(tuple(s._tuple() for s in services))
|
||||
for i in range(len(services)):
|
||||
service_handles = handles[i]
|
||||
service = services[i]
|
||||
n = 0
|
||||
for characteristic in service.characteristics:
|
||||
characteristic._register(service_handles[n])
|
||||
n += 1
|
||||
for descriptor in characteristic.descriptors:
|
||||
descriptor._register(service_handles[n])
|
||||
n += 1
|
|
@ -0,0 +1,139 @@
|
|||
# MIT license; Copyright (c) 2021 Jim Mussared
|
||||
|
||||
# This is a WIP client for l2cap_file_server.py. See that file for more
|
||||
# information.
|
||||
|
||||
import sys
|
||||
|
||||
sys.path.append("")
|
||||
|
||||
from micropython import const
|
||||
|
||||
import uasyncio as asyncio
|
||||
import aioble
|
||||
import bluetooth
|
||||
|
||||
import random
|
||||
import struct
|
||||
|
||||
_FILE_SERVICE_UUID = bluetooth.UUID(0x1234)
|
||||
_CONTROL_CHARACTERISTIC_UUID = bluetooth.UUID(0x1235)
|
||||
|
||||
|
||||
_COMMAND_SEND = const(0)
|
||||
_COMMAND_RECV = const(1) # Not yet implemented.
|
||||
_COMMAND_LIST = const(2)
|
||||
_COMMAND_SIZE = const(3)
|
||||
_COMMAND_DONE = const(4)
|
||||
|
||||
_STATUS_OK = const(0)
|
||||
_STATUS_NOT_IMPLEMENTED = const(1)
|
||||
_STATUS_NOT_FOUND = const(2)
|
||||
|
||||
_L2CAP_PSN = const(22)
|
||||
_L2CAP_MTU = const(128)
|
||||
|
||||
|
||||
class FileClient:
|
||||
def __init__(self, device):
|
||||
self._device = device
|
||||
self._connection = None
|
||||
self._seq = 1
|
||||
|
||||
async def connect(self):
|
||||
try:
|
||||
print("Connecting to", self._device)
|
||||
self._connection = await self._device.connect()
|
||||
except asyncio.TimeoutError:
|
||||
print("Timeout during connection")
|
||||
return
|
||||
|
||||
try:
|
||||
print("Discovering...")
|
||||
file_service = await self._connection.service(_FILE_SERVICE_UUID)
|
||||
self._control_characteristic = await file_service.characteristic(
|
||||
_CONTROL_CHARACTERISTIC_UUID
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
print("Timeout discovering services/characteristics")
|
||||
return
|
||||
|
||||
print("Connecting channel")
|
||||
self._channel = await self._connection.l2cap_connect(_L2CAP_PSN, _L2CAP_MTU)
|
||||
|
||||
async def _command(self, cmd, data):
|
||||
send_seq = self._seq
|
||||
await self._control_characteristic.write(struct.pack("<BB", cmd, send_seq) + data)
|
||||
self._seq += 1
|
||||
return send_seq
|
||||
|
||||
async def size(self, path):
|
||||
print("Getting size")
|
||||
send_seq = await self._command(_COMMAND_SIZE, path.encode())
|
||||
|
||||
data = await self._control_characteristic.notified()
|
||||
if len(data) != 6:
|
||||
raise RuntimeError("Invalid response")
|
||||
|
||||
seq, status, size = struct.unpack("<BBI", data)
|
||||
if seq != send_seq:
|
||||
raise RuntimeError("Wrong reply")
|
||||
|
||||
print("result:", seq, status, size)
|
||||
return size
|
||||
|
||||
async def download(self, path, dest):
|
||||
size = await self.size(path)
|
||||
|
||||
send_seq = await self._command(_COMMAND_SEND, path.encode())
|
||||
|
||||
with open(dest, "wb") as f:
|
||||
total = 0
|
||||
buf = bytearray(self._channel.our_mtu)
|
||||
mv = memoryview(buf)
|
||||
while total < size:
|
||||
n = await self._channel.recvinto(buf)
|
||||
f.write(mv[:n])
|
||||
total += n
|
||||
|
||||
async def list(self, path):
|
||||
send_seq = await self._command(_COMMAND_LIST, path.encode())
|
||||
results = bytearray()
|
||||
buf = bytearray(self._channel.our_mtu)
|
||||
mv = memoryview(buf)
|
||||
while True:
|
||||
n = await self._channel.recvinto(buf)
|
||||
results += mv[:n]
|
||||
if results[len(results) - 1] == ord("\n") and results[len(results) - 2] == ord("\n"):
|
||||
break
|
||||
print(results.decode().split("\n"))
|
||||
|
||||
async def disconnect(self):
|
||||
if self._connection:
|
||||
await self._connection.disconnect()
|
||||
|
||||
|
||||
async def main():
|
||||
async with aioble.scan(5000, 30000, 30000, active=True) as scanner:
|
||||
async for result in scanner:
|
||||
if result.name() == "mpy-file" and _FILE_SERVICE_UUID in result.services():
|
||||
device = result.device
|
||||
break
|
||||
else:
|
||||
print("File server not found")
|
||||
return
|
||||
|
||||
client = FileClient(device)
|
||||
|
||||
await client.connect()
|
||||
print(await client.size("demo/file.txt"))
|
||||
print(await client.size("demo/notfound.bin"))
|
||||
|
||||
await client.download("demo/file.txt", "download.txt")
|
||||
|
||||
await client.list("demo")
|
||||
|
||||
await client.disconnect()
|
||||
|
||||
|
||||
asyncio.run(main())
|
|
@ -0,0 +1,185 @@
|
|||
# MIT license; Copyright (c) 2021 Jim Mussared
|
||||
|
||||
# This is a BLE file server, based very loosely on the Object Transfer Service
|
||||
# specification. It demonstrated transfering data over an L2CAP channel, as
|
||||
# well as using notifications and GATT writes on a characteristic.
|
||||
|
||||
# The server supports downloading and uploading files, as well as querying
|
||||
# directory listings and file sizes.
|
||||
|
||||
# In order to access the file server, a client must connect, then establish an
|
||||
# L2CAP channel. To being an operation, a command is written to the control
|
||||
# characteristic, including a command number, sequence number, and filesystem
|
||||
# path. The response will be either via a notification on the control
|
||||
# characteristic (e.g. file size), or via the L2CAP channel (file contents or
|
||||
# directory listing).
|
||||
|
||||
import sys
|
||||
|
||||
sys.path.append("")
|
||||
|
||||
from micropython import const
|
||||
|
||||
import uasyncio as asyncio
|
||||
import aioble
|
||||
import bluetooth
|
||||
|
||||
import struct
|
||||
import os
|
||||
|
||||
# Randomly generated UUIDs.
|
||||
_FILE_SERVICE_UUID = bluetooth.UUID("0492fcec-7194-11eb-9439-0242ac130002")
|
||||
_CONTROL_CHARACTERISTIC_UUID = bluetooth.UUID("0492fcec-7194-11eb-9439-0242ac130003")
|
||||
|
||||
# How frequently to send advertising beacons.
|
||||
_ADV_INTERVAL_MS = 250_000
|
||||
|
||||
|
||||
_COMMAND_SEND = const(0)
|
||||
_COMMAND_RECV = const(1) # Not yet implemented.
|
||||
_COMMAND_LIST = const(2)
|
||||
_COMMAND_SIZE = const(3)
|
||||
_COMMAND_DONE = const(4)
|
||||
|
||||
_STATUS_OK = const(0)
|
||||
_STATUS_NOT_IMPLEMENTED = const(1)
|
||||
_STATUS_NOT_FOUND = const(2)
|
||||
|
||||
_L2CAP_PSN = const(22)
|
||||
_L2CAP_MTU = const(128)
|
||||
|
||||
|
||||
# Register GATT server.
|
||||
file_service = aioble.Service(_FILE_SERVICE_UUID)
|
||||
control_characteristic = aioble.Characteristic(
|
||||
file_service, _CONTROL_CHARACTERISTIC_UUID, write=True, notify=True
|
||||
)
|
||||
aioble.register_services(file_service)
|
||||
|
||||
|
||||
send_file = None
|
||||
recv_file = None
|
||||
list_path = None
|
||||
op_seq = None
|
||||
l2cap_event = asyncio.Event()
|
||||
|
||||
|
||||
def send_done_notification(connection, status=_STATUS_OK):
|
||||
global op_seq
|
||||
control_characteristic.notify(connection, struct.pack("<BBB", _COMMAND_DONE, op_seq, status))
|
||||
op_seq = None
|
||||
|
||||
|
||||
async def l2cap_task(connection):
|
||||
global send_file, recv_file, list_path
|
||||
try:
|
||||
channel = await connection.l2cap_accept(_L2CAP_PSN, _L2CAP_MTU)
|
||||
print("channel accepted")
|
||||
|
||||
while True:
|
||||
await l2cap_event.wait()
|
||||
l2cap_event.clear()
|
||||
|
||||
if send_file:
|
||||
print("Sending:", send_file)
|
||||
with open(send_file, "rb") as f:
|
||||
buf = bytearray(channel.peer_mtu)
|
||||
mv = memoryview(buf)
|
||||
while n := f.readinto(buf):
|
||||
await channel.send(mv[:n])
|
||||
await channel.flush()
|
||||
send_done_notification(connection)
|
||||
send_file = None
|
||||
if recv_file:
|
||||
print("Receiving:", recv_file)
|
||||
send_done_notification(connection, _STATUS_NOT_IMPLEMENTED)
|
||||
recv_file = None
|
||||
if list_path:
|
||||
print("List:", list_path)
|
||||
try:
|
||||
for name, _, _, size in os.ilistdir(list_path):
|
||||
await channel.send("{}:{}\n".format(size, name))
|
||||
await channel.send("\n")
|
||||
await channel.flush()
|
||||
send_done_notification(connection)
|
||||
except OSError:
|
||||
send_done_notification(connection, _STATUS_NOT_FOUND)
|
||||
list_path = None
|
||||
|
||||
except aioble.DeviceDisconnectedError:
|
||||
print("Stopping l2cap")
|
||||
return
|
||||
|
||||
|
||||
async def control_task(connection):
|
||||
global send_file, recv_file, list_path
|
||||
|
||||
try:
|
||||
with connection.timeout(None):
|
||||
while True:
|
||||
print("Waiting for write")
|
||||
await control_characteristic.written()
|
||||
msg = control_characteristic.read()
|
||||
control_characteristic.write(b"")
|
||||
|
||||
if len(msg) < 3:
|
||||
continue
|
||||
|
||||
# Message is <command><seq><path...>.
|
||||
|
||||
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("<BBI", seq, status, size)
|
||||
)
|
||||
except aioble.DeviceDisconnectedError:
|
||||
return
|
||||
|
||||
|
||||
# Serially wait for connections. Don't advertise while a central is
|
||||
# connected.
|
||||
async def peripheral_task():
|
||||
while True:
|
||||
print("Waiting for connection")
|
||||
connection = await aioble.advertise(
|
||||
_ADV_INTERVAL_MS,
|
||||
name="mpy-file",
|
||||
services=[_FILE_SERVICE_UUID],
|
||||
)
|
||||
print("Connection from", connection.device)
|
||||
|
||||
t = asyncio.create_task(l2cap_task(connection))
|
||||
await control_task(connection)
|
||||
t.cancel()
|
||||
|
||||
await connection.disconnected()
|
||||
|
||||
|
||||
# Run both tasks.
|
||||
async def main():
|
||||
await peripheral_task()
|
||||
|
||||
|
||||
asyncio.run(main())
|
|
@ -0,0 +1,63 @@
|
|||
import sys
|
||||
|
||||
sys.path.append("")
|
||||
|
||||
from micropython import const
|
||||
|
||||
import uasyncio as asyncio
|
||||
import aioble
|
||||
import bluetooth
|
||||
|
||||
import random
|
||||
import struct
|
||||
|
||||
# org.bluetooth.service.environmental_sensing
|
||||
_ENV_SENSE_UUID = bluetooth.UUID(0x181A)
|
||||
# org.bluetooth.characteristic.temperature
|
||||
_ENV_SENSE_TEMP_UUID = bluetooth.UUID(0x2A6E)
|
||||
|
||||
|
||||
# Helper to decode the temperature characteristic encoding (sint16, hundredths of a degree).
|
||||
def _decode_temperature(data):
|
||||
return struct.unpack("<h", data)[0] / 100
|
||||
|
||||
|
||||
async def find_temp_sensor():
|
||||
# Scan for 5 seconds, in active mode, with very low interval/window (to
|
||||
# maximise detection rate).
|
||||
async with aioble.scan(5000, interval_us=30000, window_us=30000, active=True) as scanner:
|
||||
async for result in scanner:
|
||||
# See if it matches our name and the environmental sensing service.
|
||||
if result.name() == "mpy-temp" and _ENV_SENSE_UUID in result.services():
|
||||
return result.device
|
||||
return None
|
||||
|
||||
|
||||
async def main():
|
||||
device = await find_temp_sensor()
|
||||
if not device:
|
||||
print("Temperature sensor not found")
|
||||
return
|
||||
|
||||
try:
|
||||
print("Connecting to", device)
|
||||
connection = await device.connect()
|
||||
except asyncio.TimeoutError:
|
||||
print("Timeout during connection")
|
||||
return
|
||||
|
||||
async with connection:
|
||||
try:
|
||||
temp_service = await connection.service(_ENV_SENSE_UUID)
|
||||
temp_characteristic = await temp_service.characteristic(_ENV_SENSE_TEMP_UUID)
|
||||
except asyncio.TimeoutError:
|
||||
print("Timeout discovering services/characteristics")
|
||||
return
|
||||
|
||||
while True:
|
||||
temp_deg_c = _decode_temperature(await temp_characteristic.read())
|
||||
print("Temperature: {:.2f}".format(temp_deg_c))
|
||||
await asyncio.sleep_ms(1000)
|
||||
|
||||
|
||||
asyncio.run(main())
|
|
@ -0,0 +1,68 @@
|
|||
import sys
|
||||
|
||||
sys.path.append("")
|
||||
|
||||
from micropython import const
|
||||
|
||||
import uasyncio as asyncio
|
||||
import aioble
|
||||
import bluetooth
|
||||
|
||||
import random
|
||||
import struct
|
||||
|
||||
# org.bluetooth.service.environmental_sensing
|
||||
_ENV_SENSE_UUID = bluetooth.UUID(0x181A)
|
||||
# org.bluetooth.characteristic.temperature
|
||||
_ENV_SENSE_TEMP_UUID = bluetooth.UUID(0x2A6E)
|
||||
# org.bluetooth.characteristic.gap.appearance.xml
|
||||
_ADV_APPEARANCE_GENERIC_THERMOMETER = const(768)
|
||||
|
||||
# How frequently to send advertising beacons.
|
||||
_ADV_INTERVAL_MS = 250_000
|
||||
|
||||
|
||||
# Register GATT server.
|
||||
temp_service = aioble.Service(_ENV_SENSE_UUID)
|
||||
temp_characteristic = aioble.Characteristic(
|
||||
temp_service, _ENV_SENSE_TEMP_UUID, read=True, notify=True
|
||||
)
|
||||
aioble.register_services(temp_service)
|
||||
|
||||
|
||||
# Helper to encode the temperature characteristic encoding (sint16, hundredths of a degree).
|
||||
def _encode_temperature(temp_deg_c):
|
||||
return struct.pack("<h", int(temp_deg_c * 100))
|
||||
|
||||
|
||||
# This would be periodically polling a hardware sensor.
|
||||
async def sensor_task():
|
||||
t = 24.5
|
||||
while True:
|
||||
temp_characteristic.write(_encode_temperature(t))
|
||||
t += random.uniform(-0.5, 0.5)
|
||||
await asyncio.sleep_ms(1000)
|
||||
|
||||
|
||||
# Serially wait for connections. Don't advertise while a central is
|
||||
# connected.
|
||||
async def peripheral_task():
|
||||
while True:
|
||||
async with await aioble.advertise(
|
||||
_ADV_INTERVAL_MS,
|
||||
name="mpy-temp",
|
||||
services=[_ENV_SENSE_UUID],
|
||||
appearance=_ADV_APPEARANCE_GENERIC_THERMOMETER,
|
||||
) as connection:
|
||||
print("Connection from", connection.device)
|
||||
await connection.disconnected()
|
||||
|
||||
|
||||
# Run both tasks.
|
||||
async def main():
|
||||
t1 = asyncio.create_task(sensor_task())
|
||||
t2 = asyncio.create_task(peripheral_task())
|
||||
await asyncio.gather(t1, t2)
|
||||
|
||||
|
||||
asyncio.run(main())
|
|
@ -0,0 +1,33 @@
|
|||
import os
|
||||
|
||||
_files = (
|
||||
"__init__.py",
|
||||
"core.py",
|
||||
"device.py",
|
||||
)
|
||||
|
||||
options.defaults(peripheral=True, server=True)
|
||||
|
||||
if options.central:
|
||||
_files += ("central.py",)
|
||||
|
||||
if options.client:
|
||||
_files += ("client.py",)
|
||||
|
||||
if options.peripheral:
|
||||
_files += ("peripheral.py",)
|
||||
|
||||
if options.server:
|
||||
_files += ("server.py",)
|
||||
|
||||
if options.l2cap:
|
||||
_files += ("l2cap.py",)
|
||||
|
||||
if options.security:
|
||||
_files += ("security.py",)
|
||||
|
||||
freeze(
|
||||
".",
|
||||
tuple(os.path.join("aioble", f) for f in _files),
|
||||
opt=3,
|
||||
)
|
|
@ -0,0 +1,135 @@
|
|||
# Test characteristic read/write/notify from both GATTS and GATTC.
|
||||
|
||||
import sys
|
||||
|
||||
sys.path.append("")
|
||||
|
||||
from micropython import const
|
||||
import time, machine
|
||||
|
||||
import uasyncio as asyncio
|
||||
import aioble
|
||||
import bluetooth
|
||||
|
||||
TIMEOUT_MS = 5000
|
||||
|
||||
SERVICE_UUID = bluetooth.UUID("A5A5A5A5-FFFF-9999-1111-5A5A5A5A5A5A")
|
||||
CHAR_UUID = bluetooth.UUID("00000000-1111-2222-3333-444444444444")
|
||||
|
||||
|
||||
# Acting in peripheral role.
|
||||
async def instance0_task():
|
||||
service = aioble.Service(SERVICE_UUID)
|
||||
characteristic = aioble.Characteristic(
|
||||
service, CHAR_UUID, read=True, write=True, notify=True, indicate=True
|
||||
)
|
||||
aioble.register_services(service)
|
||||
|
||||
multitest.globals(BDADDR=aioble.config("mac"))
|
||||
multitest.next()
|
||||
|
||||
# Write initial characteristic value.
|
||||
characteristic.write("periph0")
|
||||
|
||||
# Wait for central to connect to us.
|
||||
print("advertise")
|
||||
connection = await aioble.advertise(
|
||||
20_000, adv_data=b"\x02\x01\x06\x04\xffMPY", timeout_ms=TIMEOUT_MS
|
||||
)
|
||||
print("connected")
|
||||
|
||||
# A
|
||||
|
||||
# Wait for a write to the characteristic from the central,
|
||||
# then reply with a notification.
|
||||
await characteristic.written(timeout_ms=TIMEOUT_MS)
|
||||
print("written", characteristic.read())
|
||||
print("write")
|
||||
characteristic.write("periph1")
|
||||
print("notify")
|
||||
characteristic.notify(connection)
|
||||
|
||||
# B
|
||||
|
||||
# Wait for a write to the characteristic from the central,
|
||||
# then reply with value-included notification.
|
||||
await characteristic.written(timeout_ms=TIMEOUT_MS)
|
||||
print("written", characteristic.read())
|
||||
print("notify")
|
||||
characteristic.notify(connection, "periph2")
|
||||
|
||||
# C
|
||||
|
||||
# Wait for a write to the characteristic from the central,
|
||||
# then reply with an indication.
|
||||
await characteristic.written(timeout_ms=TIMEOUT_MS)
|
||||
print("written", characteristic.read())
|
||||
print("write")
|
||||
characteristic.write("periph3")
|
||||
print("indicate", await characteristic.indicate(connection, timeout_ms=TIMEOUT_MS))
|
||||
|
||||
# Wait for the central to disconnect.
|
||||
await connection.disconnected(timeout_ms=TIMEOUT_MS)
|
||||
print("disconnected")
|
||||
|
||||
|
||||
def instance0():
|
||||
try:
|
||||
asyncio.run(instance0_task())
|
||||
finally:
|
||||
aioble.ble.active(0)
|
||||
|
||||
|
||||
# Acting in central role.
|
||||
async def instance1_task():
|
||||
multitest.next()
|
||||
|
||||
# Connect to peripheral and then disconnect.
|
||||
print("connect")
|
||||
device = aioble.Device(*BDADDR)
|
||||
connection = await device.connect(timeout_ms=TIMEOUT_MS)
|
||||
|
||||
# Discover characteristics.
|
||||
service = await connection.service(SERVICE_UUID)
|
||||
print("service", service.uuid)
|
||||
characteristic = await service.characteristic(CHAR_UUID)
|
||||
print("characteristic", characteristic.uuid)
|
||||
|
||||
# Issue read of characteristic, should get initial value.
|
||||
print("read", await characteristic.read(timeout_ms=TIMEOUT_MS))
|
||||
|
||||
# Write to the characteristic, which will trigger a notification.
|
||||
print("write")
|
||||
await characteristic.write("central0", response=True, timeout_ms=TIMEOUT_MS)
|
||||
# A
|
||||
print("notified", await characteristic.notified(timeout_ms=TIMEOUT_MS))
|
||||
# Read the new value set immediately before notification.
|
||||
print("read", await characteristic.read(timeout_ms=TIMEOUT_MS))
|
||||
|
||||
# Write to the characteristic, which will trigger a value-included notification.
|
||||
print("write")
|
||||
await characteristic.write("central1", response=True, timeout_ms=TIMEOUT_MS)
|
||||
# B
|
||||
print("notified", await characteristic.notified(timeout_ms=TIMEOUT_MS))
|
||||
# Read value should be unchanged.
|
||||
print("read", await characteristic.read(timeout_ms=TIMEOUT_MS))
|
||||
|
||||
# Write to the characteristic, which will trigger an indication.
|
||||
print("write")
|
||||
await characteristic.write("central2", response=True, timeout_ms=TIMEOUT_MS)
|
||||
# C
|
||||
print("indicated", await characteristic.indicated(timeout_ms=TIMEOUT_MS))
|
||||
# Read the new value set immediately before indication.
|
||||
print("read", await characteristic.read(timeout_ms=TIMEOUT_MS))
|
||||
|
||||
# Disconnect from peripheral.
|
||||
print("disconnect")
|
||||
await connection.disconnect(timeout_ms=TIMEOUT_MS)
|
||||
print("disconnected")
|
||||
|
||||
|
||||
def instance1():
|
||||
try:
|
||||
asyncio.run(instance1_task())
|
||||
finally:
|
||||
aioble.ble.active(0)
|
|
@ -0,0 +1,28 @@
|
|||
--- instance0 ---
|
||||
advertise
|
||||
connected
|
||||
written b'central0'
|
||||
write
|
||||
notify
|
||||
written b'central1'
|
||||
notify
|
||||
written b'central2'
|
||||
write
|
||||
indicate 0
|
||||
disconnected
|
||||
--- instance1 ---
|
||||
connect
|
||||
service UUID('a5a5a5a5-ffff-9999-1111-5a5a5a5a5a5a')
|
||||
characteristic UUID('00000000-1111-2222-3333-444444444444')
|
||||
read b'periph0'
|
||||
write
|
||||
notified b'periph1'
|
||||
read b'periph1'
|
||||
write
|
||||
notified b'periph2'
|
||||
read b'central1'
|
||||
write
|
||||
indicated b'periph3'
|
||||
read b'periph3'
|
||||
disconnect
|
||||
disconnected
|
|
@ -0,0 +1,103 @@
|
|||
# Ping-pong GATT notifications between two devices.
|
||||
|
||||
import sys
|
||||
|
||||
sys.path.append("")
|
||||
|
||||
from micropython import const
|
||||
import time, machine
|
||||
|
||||
import uasyncio as asyncio
|
||||
import aioble
|
||||
import bluetooth
|
||||
|
||||
TIMEOUT_MS = 5000
|
||||
|
||||
SERVICE_UUID = bluetooth.UUID("A5A5A5A5-FFFF-9999-1111-5A5A5A5A5A5A")
|
||||
CHAR_UUID = bluetooth.UUID("00000000-1111-2222-3333-444444444444")
|
||||
|
||||
# How long to run the test for.
|
||||
_NUM_NOTIFICATIONS = const(50)
|
||||
|
||||
|
||||
def register_server():
|
||||
server_service = aioble.Service(SERVICE_UUID)
|
||||
server_characteristic = aioble.Characteristic(
|
||||
server_service, CHAR_UUID, read=True, write=True, notify=True, indicate=True
|
||||
)
|
||||
aioble.register_services(server_service)
|
||||
return server_characteristic
|
||||
|
||||
|
||||
async def discover_server():
|
||||
client_service = await connection.service(SERVICE_UUID)
|
||||
return await client_service.characteristic(CHAR_UUID)
|
||||
|
||||
|
||||
# Acting in peripheral role.
|
||||
async def instance0_task():
|
||||
server_characteristic = register_server()
|
||||
|
||||
multitest.globals(BDADDR=aioble.config("mac"))
|
||||
multitest.next()
|
||||
|
||||
connection = await aioble.advertise(
|
||||
20_000, adv_data=b"\x02\x01\x06\x04\xffMPY", timeout_ms=TIMEOUT_MS
|
||||
)
|
||||
|
||||
client_characteristic = await discover_server()
|
||||
|
||||
# Give the central enough time to discover chars.
|
||||
await asyncio.sleep_ms(500)
|
||||
|
||||
ticks_start = time.ticks_ms()
|
||||
|
||||
for i in range(_NUM_NOTIFICATIONS):
|
||||
# Send a notification and wait for a response.
|
||||
server_characteristic.notify(connection, "peripheral" + str(i))
|
||||
await client_characteristic.notified()
|
||||
|
||||
ticks_end = time.ticks_ms()
|
||||
ticks_total = time.ticks_diff(ticks_end, ticks_start)
|
||||
print(
|
||||
"Acknowledged {} notifications in {} ms. {} ms/notification.".format(
|
||||
_NUM_NOTIFICATIONS, ticks_total, ticks_total // _NUM_NOTIFICATIONS
|
||||
)
|
||||
)
|
||||
|
||||
# Disconnect the central.
|
||||
await connection.disconnect()
|
||||
|
||||
|
||||
def instance0():
|
||||
try:
|
||||
asyncio.run(instance0_task())
|
||||
finally:
|
||||
aioble.ble.active(0)
|
||||
|
||||
|
||||
# Acting in central role.
|
||||
async def instance1_task():
|
||||
server_characteristic = register_server()
|
||||
|
||||
multitest.next()
|
||||
|
||||
device = aioble.Device(*BDADDR)
|
||||
connection = await device.connect(timeout_ms=TIMEOUT_MS)
|
||||
|
||||
client_characteristic = await discover_server()
|
||||
|
||||
for i in range(_NUM_NOTIFICATIONS):
|
||||
# Wait for notification and send response.
|
||||
data = await client_characteristic.notified()
|
||||
server_characteristic.notify(connection, b"central" + data)
|
||||
|
||||
# Wait for the peripheral to disconnect us.
|
||||
await connection.disconnected(timeout_ms=20000)
|
||||
|
||||
|
||||
def instance1():
|
||||
try:
|
||||
asyncio.run(instance1_task())
|
||||
finally:
|
||||
aioble.ble.active(0)
|
|
@ -0,0 +1,105 @@
|
|||
import sys
|
||||
|
||||
sys.path.append("")
|
||||
|
||||
from micropython import const
|
||||
import time, machine
|
||||
|
||||
import uasyncio as asyncio
|
||||
import aioble
|
||||
import bluetooth
|
||||
import random
|
||||
|
||||
TIMEOUT_MS = 5000
|
||||
|
||||
_L2CAP_PSM = const(22)
|
||||
_L2CAP_MTU = const(512)
|
||||
|
||||
_PAYLOAD_LEN = const(_L2CAP_MTU)
|
||||
_NUM_PAYLOADS = const(20)
|
||||
|
||||
_RANDOM_SEED = 22
|
||||
|
||||
|
||||
# Acting in peripheral role.
|
||||
async def instance0_task():
|
||||
multitest.globals(BDADDR=aioble.config("mac"))
|
||||
multitest.next()
|
||||
|
||||
connection = await aioble.advertise(
|
||||
20_000, adv_data=b"\x02\x01\x06\x04\xffMPY", timeout_ms=TIMEOUT_MS
|
||||
)
|
||||
|
||||
channel = await connection.l2cap_accept(_L2CAP_PSM, _L2CAP_MTU, timeout_ms=TIMEOUT_MS)
|
||||
|
||||
random.seed(_RANDOM_SEED)
|
||||
|
||||
buf = bytearray(_PAYLOAD_LEN)
|
||||
|
||||
for i in range(_NUM_PAYLOADS):
|
||||
for j in range(_PAYLOAD_LEN):
|
||||
buf[j] = random.randint(0, 255)
|
||||
await channel.send(buf)
|
||||
await channel.flush()
|
||||
|
||||
await asyncio.sleep_ms(500)
|
||||
|
||||
await channel.disconnect()
|
||||
|
||||
# Disconnect the central.
|
||||
await connection.disconnect()
|
||||
|
||||
|
||||
def instance0():
|
||||
try:
|
||||
asyncio.run(instance0_task())
|
||||
finally:
|
||||
aioble.stop()
|
||||
|
||||
|
||||
# Acting in central role.
|
||||
async def instance1_task():
|
||||
multitest.next()
|
||||
|
||||
device = aioble.Device(*BDADDR)
|
||||
connection = await device.connect(timeout_ms=TIMEOUT_MS)
|
||||
|
||||
await asyncio.sleep_ms(500)
|
||||
|
||||
channel = await connection.l2cap_connect(_L2CAP_PSM, _L2CAP_MTU, timeout_ms=TIMEOUT_MS)
|
||||
|
||||
random.seed(_RANDOM_SEED)
|
||||
|
||||
buf = bytearray(_PAYLOAD_LEN)
|
||||
|
||||
recv_bytes, recv_correct = 0, 0
|
||||
expected_bytes = _PAYLOAD_LEN * _NUM_PAYLOADS
|
||||
|
||||
ticks_first_byte = 0
|
||||
while recv_bytes < expected_bytes:
|
||||
n = await channel.recvinto(buf)
|
||||
if not ticks_first_byte:
|
||||
ticks_first_byte = time.ticks_ms()
|
||||
recv_bytes += n
|
||||
for i in range(n):
|
||||
if buf[i] == random.randint(0, 255):
|
||||
recv_correct += 1
|
||||
|
||||
ticks_end = time.ticks_ms()
|
||||
total_ticks = time.ticks_diff(ticks_end, ticks_first_byte)
|
||||
|
||||
print(
|
||||
"Received {}/{} bytes in {} ms. {} B/s".format(
|
||||
recv_bytes, recv_correct, total_ticks, recv_bytes * 1000 // total_ticks
|
||||
)
|
||||
)
|
||||
|
||||
# Wait for the peripheral to disconnect us.
|
||||
await connection.disconnected(timeout_ms=20000)
|
||||
|
||||
|
||||
def instance1():
|
||||
try:
|
||||
asyncio.run(instance1_task())
|
||||
finally:
|
||||
aioble.stop()
|
Ładowanie…
Reference in New Issue