11.4. Anuncio y escaneo

Dos dispositivos BLE que nunca se han encontrado antes tienen que localizarse primero. Las redes resuelven esto asignando a cada dispositivo una dirección de un fondo compartido y dejando que cualquiera de las partes alcance a la otra a través de routers. BLE no tiene routers, ni fondo compartido y, entre la mayoría de los pares de dispositivos, ninguna relación previa en absoluto. El Generic Access Profile (GAP) resuelve el descubrimiento con un patrón de emisión y escucha en su lugar. Un lado anuncia (advertising): transmite un paquete corto en los tres canales de anuncio a un intervalo regular, describiendo quién es. El otro lado escanea (scan): recorre esos mismos tres canales escuchando esos paquetes.

GAP define cuatro roles en torno a ese patrón, cada uno una combinación específica de anuncio y escucha.

11.4.1. Los cuatro roles GAP

Una matriz de dos por dos. Las filas están etiquetadas como "anuncia" y "no anuncia". Las columnas están etiquetadas como "acepta conexiones" y "no acepta conexiones". Las cuatro celdas contienen los nombres de los roles: Peripheral, Broadcaster, Central, Observer.

Los cuatro roles GAP. El eje vertical indica si el dispositivo anuncia; el eje horizontal indica si acepta (o inicia) conexiones.

  • Un peripheral anuncia paquetes que dicen «estoy aquí y puedes conectarte a mí». Cuando otro dispositivo abre una conexión, el peripheral deja de anunciar y empieza a atender solicitudes GATT. Las bandas de frecuencia cardíaca, los termómetros y la mayoría de las cámaras-como-sensores actúan como peripherals.

  • Un central escanea en busca de peripherals, elige uno e inicia una conexión. Tras conectarse habla GATT como cliente. Los teléfonos, los portátiles y las cámaras que actúan como recolectores de datos son centrals.

  • Un broadcaster anuncia pero nunca acepta conexiones. Su carga útil de anuncio es el dato: no hay nada a lo que conectarse. Los iBeacons y la mayoría de las balizas de presencia en tiendas son broadcasters.

  • Un observer escanea esos anuncios y lee la carga útil, de nuevo sin conectarse nunca. Una cámara que escucha balizas cercanas y actúa según lo que oye es un observer.

Un mismo dispositivo puede desempeñar más de un rol al mismo tiempo: una cámara puede ser un peripheral que publica su propio estado y un central que se conecta a un sensor cercano. La radio multiplexa el trabajo.

11.4.2. Qué contiene un paquete de anuncio

Un paquete de anuncio es pequeño: 31 bytes de carga útil, o 62 si el anunciante también publica una scan response que los escáneres pueden solicitar sobre la marcha. La carga útil es una lista de campos cortos con tipo:

  • Flags. Conectable o no, descubrible general/limitado.

  • Nombre local. Una cadena corta y fácil de leer: el nombre que el sistema operativo de un teléfono o portátil muestra en su menú de Bluetooth.

  • UUID de servicio. Una lista de identificadores de servicio GATT que el dispositivo aloja, de modo que un escáner pueda reconocer los peripherals capaces sin conectarse primero. Una banda de frecuencia cardíaca anuncia 0x180D (el UUID estándar del servicio Heart-Rate) y una app de frecuencia cardíaca en el teléfono sabe solo con eso que vale la pena conectarse al dispositivo.

  • Appearance. Un valor de 16 bits de la lista de números asignados de Bluetooth (sensor, medio genérico, reloj genérico, …): una pista para el central sobre qué mostrar.

  • Datos específicos del fabricante. Bytes de formato libre precedidos por un ID de compañía. Los iBeacons usan este campo para transportar su UUID, major y minor; las aplicaciones personalizadas pueden poner aquí lo que quieran.

Las cargas útiles de anuncio son ajustadas. El límite de 31 bytes convierte la elección de qué incluir en una verdadera decisión de diseño: un nombre largo legible por humanos puede dejar rápidamente sin espacio para los UUID de servicio. La API aioble.advertise() toma cada uno de estos como argumento de palabra clave y ensambla los bytes por ti, desbordando automáticamente hacia la scan response si el paquete principal se llena.

11.4.3. Escaneo activo y pasivo

Un escáner puede funcionar de forma pasiva, en la que escucha paquetes de anuncio y analiza lo que llega, o activa, en la que además envía una scan request a cada anunciante y analiza la scan response que regresa.

El escaneo pasivo solo ve el paquete de anuncio inicial (hasta 31 bytes). El escaneo activo duplica eso: la scan response son otros 31 bytes que el peripheral puede usar para campos que no cupieron. El escaneo activo también consume energía en ambos lados, ya que el escáner transmite y el anunciante transmite un paquete adicional, así que es una opción y no un valor por defecto.

En la API aioble, active=True en aioble.scan() cambia el modo, y cada ScanResult expone los adv_data combinados más resp_data, así como ayudantes como result.name() y result.services() que ocultan el análisis a nivel de byte.

Nota

Los atributos adv_data y resp_data son las cargas útiles en bruto del anuncio y de la scan response (bytes). Los ayudantes (name(), services(), manufacturer()) cubren los campos estándar habituales y son la opción correcta el 99% de las veces. Recurre a los bytes en bruto solo cuando necesites un campo de fabricante que los ayudantes no analizan (URLs Eddystone, UUID/major/minor de iBeacon, tipos de anuncio personalizados). La disposición de bytes es la estándar TLV: cada campo es length, type, value....

11.4.4. El intervalo de anuncio

La frecuencia con que el peripheral emite es una concesión entre energía y latencia de descubrimiento. Los anuncios que salen cada 20 ms los recoge un escáner casi de inmediato, pero mantienen la radio ocupada y agotan la batería; los anuncios cada segundo apenas consumen energía pero hacen que un escáner tarde más en advertir el dispositivo.

interval_us en aioble.advertise() establece el intervalo en microsegundos:

  • 20.000 a 100.000 us (20 ms - 100 ms): emparejamiento rápido, la app espera una respuesta veloz, dispositivo conectado a la corriente.

  • 250.000 a 1.000.000 us (250 ms - 1 s): un valor por defecto razonable para un peripheral alimentado por batería que quiere ser descubrible sin gastar carga.

  • Por encima de 1.000.000 us: emisión lenta en segundo plano, balizas que envían una actualización de posición cada pocos segundos.

El lado del escáner tiene sus propios ajustes: aioble.scan() toma interval_us y window_us (con qué frecuencia el escáner despierta su radio y cuánto escucha cada vez). Los valores por defecto están bien; el único cambio habitual es igualar ambos para un escaneo continuo cuando la batería no es una preocupación.

11.4.5. Patrones sin conexión: broadcaster y observer

Las páginas sobre Actuar como periférico y Actuar como central recorren la forma conectable de la API, en la que un peripheral acepta una conexión y ambos lados intercambian datos a través de GATT. La otra forma es sin conexión: un broadcaster transmite la carga útil como anuncio, y cualquier observer dentro del rango puede leerla sin conectarse nunca. Las balizas, los sensores de presencia y toda la telemetría unidireccional viven aquí.

Un broadcaster es aioble.advertise() con connectable=False. Los datos específicos del fabricante transportan la carga útil:

import aioble
import asyncio
import struct

_COMPANY_ID = const(0xFFFF)                # 0xFFFF is "no specific vendor"

async def beacon():
    seq = 0
    while True:
        seq = (seq + 1) & 0xFFFF
        payload = struct.pack("<H", seq)
        await aioble.advertise(
            interval_us=500000,
            connectable=False,
            name="openmv-beacon",
            manufacturer=(_COMPANY_ID, payload),
            timeout_ms=1000,                # one cycle, then loop
        )

asyncio.run(beacon())

La palabra clave timeout_ms finaliza la llamada de anuncio tras un segundo; el bucle exterior la reemite con el siguiente número de secuencia para que los oyentes vean datos frescos. El flag connectable=False es lo que hace que el anuncio sea de estilo broadcaster: la cámara no responderá a una solicitud de conexión aunque llegue una.

Un observer es el escáner de solo lectura que le corresponde. Ejecuta aioble.scan() indefinidamente, analiza los anuncios entrantes y nunca llama a connect():

import aioble
import asyncio

_COMPANY_ID = const(0xFFFF)

async def watch():
    async with aioble.scan(duration_ms=0, active=False) as scanner:
        async for result in scanner:
            for company, data in result.manufacturer(filter=_COMPANY_ID):
                print(result.device.addr_hex(),
                      "rssi", result.rssi, "data", data)

asyncio.run(watch())

duration_ms=0 escanea hasta que el gestor de contexto sale; active=False mantiene en silencio la propia radio del observer (sin solicitudes de scan response) para el menor consumo de energía. El argumento filter= en manufacturer() descarta todo anuncio que no coincida con el ID de compañía, de modo que el bucle solo se dispara con el tráfico del broadcaster.

11.4.6. Del descubrimiento a una conexión

Una vez que un central elige un peripheral con el que hablar, deja de escuchar, envía una connect request en el canal de anuncio que el peripheral usó por última vez, y ambos lados pasan a los canales de datos con salto de frecuencia de la capa de enlace. El peripheral normalmente deja de anunciar en este punto. Lo que sucede a continuación (parámetros de conexión, descubrimiento GATT, la vida útil del enlace) está en Conexiones.