11.10. Actuar como central¶
El otro lado de la conversación es el central – el dispositivo que escanea en busca de periféricos que anuncian, elige uno con el que hablar, abre una conexión, recorre la base de datos GATT remota y lee o se suscribe a las características de esta. Una cámara que recopila lecturas de un sensor vestible, escucha una baliza o habla con un microcontrolador acompañante es un central.
El patrón central en aioble transcurre por cuatro etapas: escanear, conectar, descubrir, operar.
11.10.1. Escaneo¶
aioble.scan() devuelve un gestor de contexto asíncrono que funciona además como iterador asíncrono sobre los dispositivos descubiertos. El uso típico es escanear hasta que aparezca un dispositivo de interés y entonces salir de la iteración:
import aioble
import asyncio
import bluetooth
HR_SERVICE = bluetooth.UUID(0x180D)
async def find_heart_rate():
async with aioble.scan(duration_ms=5000, active=True) as scanner:
async for result in scanner:
if HR_SERVICE in result.services():
return result.device
return None
duration_ms=5000 limita cuánto tiempo se ejecuta el escaneo; duration_ms=0 escanea indefinidamente (hasta que el gestor de contexto sale). active=True solicita respuestas de escaneo, lo que duplica el tamaño de la carga útil por dispositivo a costa de una pequeña transmisión adicional por ambos lados. Los argumentos de palabra clave restantes interval_us / window_us ajustan el ciclo de trabajo de radio del propio escáner y rara vez se cambian de sus valores predeterminados.
Cada aioble.ScanResult expone la dirección del dispositivo, el último RSSI, los bytes brutos del anuncio y de la respuesta de escaneo, y utilidades que analizan los campos estándar:
result.device– unaioble.Devicelisto para llamar aconnect().result.rssi– el indicador de intensidad de la señal recibida en dBm, útil para la lógica de «elegir el más cercano».result.name()– la cadena del nombre local, oNonesi no se anuncia.result.services()– un generador debluetooth.UUIDpara cada servicio que el dispositivo anuncia.result.manufacturer()– un generador de tuplas(company_id, data)para los campos específicos del fabricante.result.connectable– si el anuncio más reciente era conectable.
El mismo ScanResult se vuelve a entregar a medida que llegan nuevos datos de anuncio para el mismo dispositivo, de modo que un oyente pasivo que solo quiere rastrear dispositivos indefinidamente puede ejecutar el iterador asíncrono para siempre y despachar en cada evento.
11.10.2. Conexión¶
Una vez identificado un dispositivo objetivo, abrir una conexión es un solo await:
async def talk_to(device):
connection = await device.connect() # 10 s timeout
async with connection:
# ... do GATT work ...
pass
aioble.Device.connect() toma timeout_ms (cuánto esperar a que la conexión se establezca; valor predeterminado 10 s), y min_conn_interval_us / max_conn_interval_us (el rango de intervalo de conexión solicitado, de Conexiones).
11.10.2.1. Reconectarse a un par conocido sin escanear¶
Una vez que existe un vínculo con un par, la dirección ya es conocida y otra ronda de escanear y elegir es tiempo de radio desperdiciado. Construye un aioble.Device directamente con la dirección guardada y pasa directamente a connect():
import aioble
KITCHEN_CAM = aioble.Device(aioble.ADDR_PUBLIC,
"aa:bb:cc:dd:ee:ff")
async def talk_to_kitchen():
async with await KITCHEN_CAM.connect() as connection:
# ... GATT work ...
pass
El primer argumento es uno de aioble.ADDR_PUBLIC (la dirección de fábrica de un controlador) o aioble.ADDR_RANDOM (una dirección privada estática o resoluble generada); el segundo es o bien un valor bytes de seis bytes o bien una cadena hexadecimal separada por dos puntos. Los atributos addr_type y addr de cualquier Device (por ejemplo, uno obtenido antes de un ScanResult) pueden persistirse y volver a introducirse aquí.
El aioble.DeviceConnection devuelto es de lo que depende el resto del trabajo del central. async with garantiza que la conexión se cierre cuando el bloque sale – ya sea con éxito, por cancelación o por cualquier excepción, incluida aioble.DeviceDisconnectedError cuando el par desaparece.
Si el central necesita un valor de característica mayor del que permite la MTU predeterminada de 23 bytes, este es el lugar para negociarlo:
await connection.exchange_mtu(512)
(exchange_mtu() devuelve la MTU realmente negociada, que es el mínimo entre el valor solicitado y lo que el par admite.)
11.10.3. Descubrimiento¶
El descubrimiento recorre la base de datos GATT remota para encontrar los servicios y características por sus UUID. Hay dos variantes: dirigida (conoces el UUID y quieres una cosa concreta) y exhaustiva (lo quieres todo).
Dirigida – el caso común:
service = await connection.service(HR_SERVICE)
if service is None:
return # no such service
char = await service.characteristic(HR_MEASUREMENT)
if char is None:
return # no such characteristic
aioble.DeviceConnection.service() y aioble.ClientService.characteristic() toman cada uno un bluetooth.UUID y devuelven el objeto coincidente (o None). Ambos tienen un argumento de palabra clave timeout_ms por descubrimiento que toma 2 s por defecto.
Exhaustiva:
async for service in connection.services():
print("service:", service.uuid)
async for char in service.characteristics():
print(" characteristic:", char.uuid, "properties:", hex(char.properties))
Esto es lo que hacen las aplicaciones genéricas de exploración de Bluetooth – útil para el desarrollo, menos para el código de producción que ya sabe qué UUID espera.
11.10.3.1. Inspeccionar lo que admite una característica¶
El descubrimiento devuelve la máscara de bits de propiedades GATT que el par anunció para cada característica como properties. Los bits son los definidos por GATT – lectura (0x02), escritura sin respuesta (0x04), escritura (0x08), notificación (0x10), indicación (0x20) y similares. Inspeccionar la máscara de bits antes de emitir una operación permite que un cliente genérico se adapte a características cuyas capacidades no conoce de antemano:
_PROP_READ = const(0x02)
_PROP_NOTIFY = const(0x10)
char = await service.characteristic(STATUS_UUID)
if char.properties & _PROP_NOTIFY:
await char.subscribe(notify=True)
value = await char.notified()
elif char.properties & _PROP_READ:
value = await char.read()
else:
value = None # nothing the client can do
El código de producción que ya conoce el perfil GATT del par normalmente no necesita esto – los UUID estaban documentados de antemano. Los clientes genéricos o exploratorios (una página de ajustes que recorre un dispositivo desconocido, un anfitrión de complementos) se apoyan en ello.
11.10.4. Operación¶
Una vez que el central tiene una ClientCharacteristic, cada operación GATT es una sola llamada a corrutina:
Lectura. Emite una lectura GATT y recupera el valor:
value = await char.read() print("value:", value)
Las lecturas largas (valores mayores que la MTU) se gestionan de forma transparente.
Escritura. Envía un nuevo valor al servidor:
await char.write(b"\\x01")response=Trueespera una respuesta de escritura y lanzaaioble.GattErrorsi el servidor rechaza la escritura.response=Falsees escritura sin respuesta: dispara y olvida.response=None(el valor predeterminado) elige automáticamente en función de lo que el par anunció.Suscripción. Habilita las notificaciones o indicaciones escribiendo en el CCCD de la característica:
await char.subscribe(notify=True)Una vez que esto retorna, el central puede esperar los envíos entrantes.
Notificado / indicado. Espera el siguiente envío del servidor:
while True: data = await char.notified() print("push:", data)
timeout_ms=None(el valor predeterminado) espera indefinidamente; pasa un entero en milisegundos para rendirse pasado un tiempo.
Juntar las cuatro da el programa central canónico de «conectar, suscribir, transmitir»:
async def stream_heart_rate():
async with aioble.scan(duration_ms=5000, active=True) as scanner:
async for result in scanner:
if HR_SERVICE in result.services():
device = result.device
break
else:
return
async with await device.connect() as connection:
service = await connection.service(HR_SERVICE)
char = await service.characteristic(HR_MEASUREMENT)
await char.subscribe(notify=True)
while connection.is_connected():
data = await char.notified()
print("hr push:", data)
asyncio.run(stream_heart_rate())
El conjunto ocupa alrededor de una docena de líneas y cubre el flujo desde «sin Bluetooth en marcha» hasta «datos en vivo transmitiéndose». El iterador de escaneo se corresponde con el patrón de difusor/observador, connect abre la conexión GAP, service / characteristic recorre el árbol GATT, subscribe escribe el CCCD y notified espera los envíos.
11.10.5. Desconexiones y reconexión¶
Cualquier cosa que le ocurra al enlace de radio aflora en la corrutina que estaba esperando en él. aioble.DeviceDisconnectedError es la señal de que el par desapareció o de que se disparó el tiempo de espera de supervisión; la excepción termina cualquier llamada a read(), write() o notified() que estuviera en curso, y cualquier bloque async with connection sale limpiamente.
Un central que debe reconectarse ante una pérdida envuelve el trabajo en su propio bucle externo:
async def keep_streaming():
while True:
try:
await stream_heart_rate()
except aioble.DeviceDisconnectedError:
print("disconnected, retrying...")
await asyncio.sleep(2)
11.10.5.1. Acotar una secuencia con timeout()¶
Cuando varias operaciones GATT seguidas deben completarse todas dentro de un único presupuesto – no cada una individualmente con su propio timeout_ms – usa aioble.DeviceConnection.timeout() para envolverlas. El gestor de contexto devuelto cancela su cuerpo si el presupuesto se agota (lanzando asyncio.TimeoutError) o si el par se desconecta (lanzando aioble.DeviceDisconnectedError):
async with await device.connect() as connection:
try:
with connection.timeout(2000): # 2 s for the whole block
service = await connection.service(HR_SERVICE)
char = await service.characteristic(HR_MEASUREMENT)
await char.subscribe(notify=True)
except asyncio.TimeoutError:
print("discovery + subscribe took too long")
Esta es la alternativa más limpia a envolver cada llamada individualmente en asyncio.wait_for() y evita los éxitos espurios en los que cada llamada cumple su propio plazo pero la secuencia en su conjunto se excede. Pasar timeout_ms=None a timeout() desactiva el plazo y deja activa únicamente la protección de desconexión.