11.9. Actuar como periférico¶
El patrón BLE más común del lado de la cámara consiste en actuar como periférico: publicar una pequeña base de datos GATT, anunciar su existencia, aceptar una conexión de un teléfono o de un dispositivo acompañante y transmitir valores a quien esté al otro lado.
11.9.1. Construir la base de datos GATT¶
Lo primero que hace un periférico al arrancar – incluso antes de encender la radio – es construir la base de datos que pretende exponer, crear objetos para cada servicio y característica, y luego registrarlo todo:
import aioble
import bluetooth
ENV_SERVICE = bluetooth.UUID(0x181A) # Environmental Sensing
TEMP_UUID = bluetooth.UUID(0x2A6E) # Temperature
HUMID_UUID = bluetooth.UUID(0x2A6F) # Humidity
env = aioble.Service(ENV_SERVICE)
temp_char = aioble.Characteristic(
env, TEMP_UUID,
read=True, notify=True, initial=b"\\x00\\x00",
)
humid_char = aioble.Characteristic(
env, HUMID_UUID,
read=True, notify=True, initial=b"\\x00\\x00",
)
aioble.register_services(env)
Cada aioble.Characteristic se adjunta a su servicio simplemente construyéndola con el servicio como primer argumento. Los argumentos de palabra clave booleanos (read, write, write_no_response, notify, indicate) seleccionan qué operaciones GATT se le permitirá realizar al cliente; pasar False (el valor predeterminado) significa que el bit de la propiedad no se establece.
aioble.register_services() confirma el árbol ensamblado en el servidor GATT. Debe llamarse una vez, antes de que cualquier aioble.advertise() comience; llamarla de nuevo reemplaza la base de datos anterior.
11.9.2. Anunciar (advertising)¶
Una vez que la base de datos está en su lugar, el anuncio es una sola llamada a una corrutina que espera una conexión:
async def serve_one():
connection = await aioble.advertise(
interval_us=250000,
name="openmv-env",
services=[ENV_SERVICE],
appearance=0x0540, # Generic Sensor
)
Los argumentos de palabra clave se asignan directamente a los campos de la carga útil del anuncio. name es el campo de nombre local; services es la lista de UUID de servicios que aloja el dispositivo (un escáner del lado del teléfono puede filtrar por ellos); appearance es una pista de los valores de apariencia estándar de 16 bits que permite que el central muestre un icono adecuado. Los datos específicos del fabricante se incluyen mediante manufacturer=(company_id, data_bytes).
Un puñado de palabras clave menos comunes cubren el resto del espacio de banderas del anuncio:
connectable=False– modo solo difusión (nunca se acepta una conexión). La opción correcta para cargas útiles de estilo baliza.limited_disc=True– usa la bandera descubrible limitado en lugar de descubrible general; algunos sistemas operativos tratan ambas de forma diferente en su interfaz de emparejamiento.adv_data/resp_data– bytes en bruto si la aplicación necesita control total sobre la disposición.timeout_ms– desistir tras un tiempo fijo. El valor predeterminado es anunciar indefinidamente.
Cuando un central se conecta, aioble.advertise() devuelve la aioble.DeviceConnection resultante. El periférico deja de anunciarse en este punto.
11.9.3. Atender a un cliente¶
El bucle principal de un periférico normalmente tiene este aspecto:
async def serve():
while True:
connection = await aioble.advertise(
interval_us=250000,
name="openmv-env",
services=[ENV_SERVICE],
)
print("connected:", connection.device.addr_hex())
async with connection:
await connection.disconnected()
print("disconnected; advertising again")
asyncio.run(serve())
async with connection hace que la limpieza al desconectarse sea automática. disconnected() es una corrutina que se suspende hasta que cualquiera de los dos lados termina la conexión – una forma limpia de mantener al periférico atendiendo hasta que el central se marche, para luego volver a anunciar en la siguiente ronda.
11.9.4. Actualizar una característica¶
El periférico actualiza la base de datos GATT local con aioble.Characteristic.write():
temp_char.write(b"\\x9a\\x09") # 24.58 deg C as sint16, 0.01 units
Eso cambia el valor que devolvería la siguiente operación read de cualquier cliente. Por sí solo, no envía el nuevo valor – un cliente suscrito no verá nada hasta que el cliente consulte o el periférico envíe una notificación explícita.
El lado del envío es una sola palabra clave en la misma llamada:
temp_char.write(temp_bytes, send_update=True)
send_update=True notifica (o indica) a cada cliente que se ha suscrito a esta característica. La mayor parte del código de estilo sensor reside en una tarea por conexión que lee el sensor en bucle y escribe el valor con send_update=True cada segundo aproximadamente:
async def stream_temperature(connection):
while connection.is_connected():
temp_char.write(encode_temperature(read_sensor()), send_update=True)
await asyncio.sleep(1)
async def serve():
while True:
connection = await aioble.advertise(
interval_us=250000,
name="openmv-env",
services=[ENV_SERVICE],
)
async with connection:
asyncio.create_task(stream_temperature(connection))
await connection.disconnected()
Si prefieres dirigir una notificación a un cliente específico en lugar de a todo el conjunto de suscriptores (por ejemplo, una respuesta privada de la conexión al comando de ese cliente), aioble.Characteristic.notify() e indicate() aceptan un argumento DeviceConnection y una carga útil opcional.
11.9.5. Recibir escrituras¶
El otro sentido – que un cliente escriba en una característica – está disponible cuando la característica se construye con write=True o write_no_response=True. El periférico espera la siguiente escritura con aioble.Characteristic.written():
cmd_char = aioble.Characteristic(env, CMD_UUID, write=True, capture=True)
async def handle_commands():
while True:
connection, data = await cmd_char.written()
print("command from", connection.device.addr_hex(), "=", data)
Sin capture=True, written() devuelve únicamente la conexión que escribe; el nuevo valor reside en el búfer de respaldo de la característica y la aplicación lo obtiene con read(). Si llega una segunda escritura antes de que la aplicación haya leído la primera, el segundo valor sobrescribe al primero en el búfer y el valor original se pierde – written() aún despierta a la aplicación, pero solo una vez por «hay algo nuevo», no una vez por escritura.
La palabra clave capture=True corrige eso. Cada escritura entrante se añade a una cola a nivel de módulo, y written() devuelve una tupla (connection, data) por cada escritura individual – el bucle de la aplicación ve cada una exactamente una vez, en orden de llegada. Dos consecuencias prácticas:
La cola está acotada y se comparte entre todas las características con captura habilitada del dispositivo. Se toleran ráfagas breves de escrituras consecutivas; un desbordamiento sostenido (escrituras que llegan más rápido de lo que la aplicación las drena) descarta de forma silenciosa las entradas en cola más antiguas, y el tráfico en ráfagas en una característica puede desalojar entradas pendientes de otra.
Elige
capture=Truepara escrituras de estilo comando donde cada valor importa. Déjalo desactivado para características de estilo estado donde el único valor de interés es el más reciente.
Si una lectura del cliente debe responderse mediante código que se ejecuta bajo demanda en lugar de un valor estático, sobrescribe on_read(). El método se llama de forma síncrona cuando llega una lectura; devuelve 0 para permitir la lectura (se enviará el valor actual de write()), o un código de error ATT distinto de cero para rechazarla:
import time
_ATT_ERR_READ_NOT_PERMITTED = const(0x02)
_MIN_READ_INTERVAL_MS = const(1000) # at most once per second
class TempChar(aioble.Characteristic):
_last_read_ms = 0
def on_read(self, connection):
now = time.ticks_ms()
if time.ticks_diff(now, self._last_read_ms) < _MIN_READ_INTERVAL_MS:
return _ATT_ERR_READ_NOT_PERMITTED
self._last_read_ms = now
self.write(encode_temperature(read_sensor()))
return 0
temp_char = TempChar(env, TEMP_UUID, read=True)
La función de retorno (callback) muestrea el sensor y actualiza el valor de la característica justo antes de que la pila GATT atienda la lectura, de modo que el cliente siempre ve datos frescos. El límite de frecuencia impide que un cliente sobrecargue el sensor más rápido de lo que puede muestrearse – cualquier lectura dentro del enfriamiento de un segundo se rebota como un error ATT Read Not Permitted en lugar de devolver un valor obsoleto.
11.9.5.1. Búferes de respaldo más grandes – BufferedCharacteristic¶
El búfer de respaldo de una Characteristic normal tiene 20 bytes de ancho – el límite práctico con la MTU predeterminada de 23 bytes. Un cliente que escribe más que eso en una característica normal ve su valor truncado. Para valores entrantes más grandes o para encolar escrituras consecutivas que el bucle de la aplicación procesará más tarde, declara la característica como BufferedCharacteristic y elige el tamaño del búfer de antemano:
blob = aioble.BufferedCharacteristic(
service, BLOB_UUID,
max_len=512, append=True,
write=True, capture=True,
)
async def receive_blob():
while True:
connection, chunk = await blob.written()
handle_chunk(connection, chunk)
Dos ajustes la distinguen de una Characteristic simple:
max_lenes el tamaño del búfer de respaldo en bytes. Elígelo para que coincida con la mayor escritura individual que se espera que haga el cliente (tras la negociación de la MTU).append=Truehace que las escrituras secuenciales se añadan al búfer en lugar de sobrescribir – útil para recibir un valor que llega a lo largo de varias escrituras (fragmentos de actualización de firmware, líneas de registro). Conappend=Falseel búfer se comporta como una característica normal, solo que más ancha.
Todas las demás banderas del constructor (read, write, notify, indicate, capture, initial) se reenvían sin cambios a la característica subyacente.
11.9.6. Servicios estándar y los UUID asignados por el SIG¶
Ceñirse a los UUID de números asignados (0x180F para Battery Service, 0x181A para Environmental Sensing, 0x180D para Heart Rate, etcétera) significa que el menú genérico de Bluetooth de un teléfono o cualquier aplicación de escáner de terceros puede identificar el propósito del dispositivo sin ningún código de cliente personalizado. La disposición de bytes dentro de cada característica estándar también está fijada por la especificación – Battery Level (0x2A19) es un único byte de 0 a 100; Temperature (0x2A6E) es un sint16 little-endian en unidades de 0.01 grados C. Para aplicaciones que no encajan en un servicio estándar, genera un UUID de 128 bits una vez y úsalo en todos los servicios y características del dispositivo.
Un periférico que publica solo UUID personalizados sigue siendo válido – solo necesita una aplicación cliente personalizada que conozca esos UUID.
Nota
Los valores BLE son little-endian en todas partes – la especificación GATT, cada característica estándar, cada campo de anuncio. Los enteros de varios bytes van por el cable con el byte bajo primero. El prefijo < en las cadenas de formato de struct es lo que conviene para codificar/decodificar ("<h", "<H", "<I", …); usar el orden de bytes nativo predeterminado en un MCU little-endian funciona por ahora, pero escribir explícitamente < es el hábito seguro.
11.9.7. La radio detrás de todo¶
La radio se enciende en el momento en que la primera corrutina de aioble la toca. Hasta que un central se conecta, el periférico pasa su tiempo alternando entre breves ráfagas de anuncio y reposo; tras una conexión sigue el intervalo de conexión negociado. El periférico paga un pequeño coste de energía por cada anuncio, por lo que la elección de interval_us en aioble.advertise() es el ajuste más directo que tiene un periférico para equilibrar la latencia de descubrimiento frente a la duración de la batería.