Volver al blog
Transport15 min read

Diseño del transporte BLE — Mensajes y descargas de archivos

Este artículo explica cómo PlainApp envía mensajes de chat y descarga archivos por Bluetooth Low Energy cuando ni la LAN ni Wi-Fi Aware están disponibles. BLE es el fallback garantizado: lento, pero funciona sin ningún tipo de conectividad IP. El artículo cubre el formato de transmisión, el diseño de fragmentación de dos capas, cómo el tráfico concurrente se prioriza (y cómo no), y por qué cada conexión se derriba tras cada solicitud.

Para la arquitectura de chat más amplia que consume este transporte, consulte Arquitectura de Chat. Para saber cómo dos dispositivos obtienen la clave ChaCha20 compartida utilizada para cifrar cada carga útil BLE, consulte Flujo de emparejamiento.

Tabla de contenidos

¿Por qué un transporte BLE? {#why-a-ble-transport-at-all}

PlainApp es serverless y offline-first. La capa de transporte es una cadena ordenada de fallback: LAN → Wi-Fi Aware → BLE. La LAN es el camino feliz (HTTPS sobre Wi-Fi, ~10 ms de ida y vuelta). Wi-Fi Aware cubre pares entre subredes (distintos SSID, VLAN de invitados vs IoT). Ambos requieren algún tipo de conectividad IP. BLE es el único transporte que funciona:

  • Cuando los dispositivos no están en la misma red IP en absoluto.
  • Cuando el Wi-Fi está apagado o en modo avión (la radio BLE es independiente).
  • Cuando Wi-Fi Aware no es compatible (Android < 13, todas las variantes iOS de PlainApp).

BLE es lento — decenas de KB/s, segundos de latencia por solicitud — pero está garantizado para cualquier par emparejado, porque lo único que necesita es el clientId del par, que siempre se difunde en la scan response de BLE.

Diagram 1
1

Disposición del servicio GATT {#gatt-service-layout}

PlainApp anuncia un único servicio GATT personalizado con dos características. No hay UUID de 16 bits registrado — el servicio usa un UUID de 128 bits cuyos bytes finales se decodifican en ASCII como plpai\x01:

Diagram 2
2

¿Por qué dos características?

Los dos protocolos tienen modelos de confianza y formas de carga útil completamente distintos:

  • NEARBY transporta mensajes de emparejamiento. Llegan antes de que el par esté emparejado (aún no hay clave compartida), así que usan sus propias cargas útiles JSON firmadas con Ed25519 con su propio enrutamiento por prefijo. El cuerpo es una cadena simple.
  • HTTP transporta todo el tráfico posterior al emparejamiento (chat, archivos, presencia). Siempre está cifrado con ChaCha20 usando la clave compartida y utiliza el mismo HttpRouteRegistry que el servidor Ktor de LAN, de modo que los manejadores de ruta (/peer_graphql, /fs, /peer_status) se escriben una vez y se reutilizan para ambos transportes.

¿Por qué notificaciones en vez de lecturas?

El protocolo BLE ATT limita una lectura de atributo único a 512 bytes. Una respuesta GraphQL o un fragmento de archivo de 16 KB puede ser mucho más grande. PlainApp lo resuelve nunca usando readCharacteristic para datos reales — el onCharacteristicReadRequest del servidor devuelve una carga útil vacía con GATT_SUCCESS. En su lugar, el cliente escribe su solicitud en la característica, y el servidor responde enviando una secuencia de notificaciones fragmentadas que el cliente reensambla. Esto está documentado en BleDeviceApi.requestAsync, BleServerProtocol.handleWrite y AndroidBleGattServer.sendChunkedResponse.

Identificación de pares: shortId, no MAC {#peer-identification-shortid-not-mac}

Los paquetes de publicidad BLE son diminutos (31 bytes) y la dirección MAC BLE es aleatorizada por Android cada ~15 minutos — así que no puede usarse como identificador estable. PlainApp en su lugar difunde una carga útil serviceData de 9 bytes en la scan response:

Diagram 3
3

¿Por qué un hash truncado en vez del clientId completo?

Un clientId de 13 caracteres cabría en 13 bytes, pero PlainApp opta por un SHA-256 truncado de 8 bytes por dos razones:

  1. Presupuesto de bytes estable. 9 bytes en total caben cómodamente en la carga útil de publicidad de 31 bytes junto con el UUID del servicio (16 bytes), longitud y campos de tipo (~27 bytes usados, 4 bytes de margen).
  2. Privacidad. Un observador pasivo escaneando BLE no puede recuperar el clientId a partir del shortId (el prefijo de 8 bytes de un hash SHA-256 es irreversible en la práctica). Solo puede reconocer un par que ya haya visto anunciar el mismo shortId — no puede enumerar usuarios de PlainApp.

El clientId completo solo se revela a un par que se haya conectado realmente por GATT e intercambiado un DDiscoverReply — es decir, un par con el que el usuario ya ha elegido interactuar.

Diseño de fragmentación de dos capas {#two-layer-chunking-design}

Esta es la parte más sutil del transporte BLE, y es esencial entender ambas capas porque tienen tamaños y propósitos completamente distintos:

Diagram 4
4

¿Por qué 380 caracteres?

El ATT MTU negociado es de 517 bytes en Android (requestMtu(517) — el máximo permitido por la especificación BLE) y ~185+ en iOS (auto-negociado por CoreBluetooth). Restando la cabecera ATT (~3 bytes) y la sobrecarga del envoltorio JSON de BleSegmentData ({"d":"...","s":N} añade ~12 bytes), 380 caracteres de carga útil caben cómodamente dentro de un único ATT MTU en ambas plataformas. El valor es simétrico (tanto los fragmentos de solicitud del cliente como los fragmentos de notificación del servidor usan 380), lo que mantiene el código simple.

¿Por qué 16 KiB para los fragmentos de archivo?

Un fragmento de archivo de 16 KiB se codifica en base64 a ~22 KiB de JSON, que se fragmenta en ~58 segmentos de notificación GATT. Cada ida y vuelta de requestAsync lleva segundos por BLE, así que menos fragmentos pero más grandes reducen la sobrecarga por fragmento. Ir mucho más grande arriesgaría superar los tiempos de espera del RPC BLE y produciría mala retroalimentación de progreso (el usuario ve el progreso actualizarse solo una vez por fragmento). 16 KiB es el punto óptimo ajustado empíricamente — suficientemente grande para rendimiento, suficientemente pequeño para una UI de progreso receptiva.

La primitiva RPC: BleDeviceApi.requestAsync {#the-rpc-primitive-bledeviceapirequestasync}

Cada mensaje de chat BLE y cada fragmento de archivo es una llamada a BleDeviceApi.requestAsync(service, requestData) — una función suspend que devuelve un BleResult. Es síncrona desde la perspectiva del llamador: una solicitud → una respuesta completamente reensamblada, sin pipelining.

Diagram 5
5

Invariantes clave

  1. Una solicitud → una respuesta. requestAsync es síncrona desde la perspectiva del llamador — solo devuelve tras reensamblar la respuesta completa. No hay pipelining.
  2. Notificaciones habilitadas por llamada. El cliente escribe el CCCD al inicio de cada requestAsync y lo deshabilita al final. Esto es despilfarrador (dos escrituras GATT extra por llamada) pero mantiene el protocolo sin estado — el servidor no tiene que rastrear qué clientes están «escuchando».
  3. Sin reintento dentro de un RPC. Si un único writeCharacteristic agota el tiempo de espera (5 s), el RPC completo se aborta. Solo ensureConnected reintenta (3 intentos en fallo de conexión). El backoff grueso a nivel de transporte lo proporciona PeerCircuitBreaker, no la capa RPC.

Formato del sobre de transmisión {#wire-envelope-format}

La carga útil dentro de los segmentos de la Capa A es un sobre JSON anidado. Despojando la fragmentación, la estructura lógica es:

Diagram 6
6

Forma de la respuesta

La respuesta fluye en la dirección opuesta a través de la misma fragmentación de la Capa A, pero el JSON interno es un BleHttpResponse con tres campos: s (código de estado HTTP), h (mapa de cabeceras de respuesta) y b (cuerpo). El cuerpo siempre está codificado en base64 por BleHttpCall.encodeResponse(), incluso cuando está vacío — la respuesta puede ser binaria (bytes GraphQL cifrados, bytes de archivo /fs en bruto) y el transporte BLE es solo de cadenas, así que el mismo sobre JSON transporta tanto cargas útiles de texto como binarias.

Ruta de envío de mensajes (de punta a punta) {#message-send-path-end-to-end}

Poniéndolo todo junto — qué ocurre cuando se envía un mensaje de chat por BLE:

Diagram 7
7

Decisiones de diseño notables

  • La misma clave que LAN. La clave compartida ChaCha20 del emparejamiento se reutiliza para BLE — no hay una clave BLE separada. El interceptor criptográfico OkHttp usado por LanTransport y el chaCha20Encrypt/ chaCha20Decrypt manual en BleTransport son la misma primitiva, solo invocados de forma distinta.
  • Los mismos manejadores de ruta que LAN. BleHttpRequest se despacha a través de HttpRouteRegistry.matchRoute(path), que es el mismo registro que usa el servidor LAN Ktor. Así que /peer_graphql, /fs, /peer_status etc. se implementan exactamente una vez y funcionan idénticamente sobre ambos transportes.
  • Sin reutilización de conexión. El bloque finally { scanner.teardownConnection(client) } siempre se ejecuta. Cada mensaje paga el coste completo de connect→discoverServices→MTU (~segundos). Es una contrapartida deliberada — véase Contrapartidas de diseño.

Ruta de descarga de archivos (de punta a punta) {#file-download-path-end-to-end}

Las descargas por BLE son streaming — el archivo se lee en fragmentos de 16 KiB y se escribe en un archivo temporal a medida que llega, así que un archivo de 10 MB no necesita 10 MB de RAM. El truco es que el RPC de cada fragmento es una llamada separada a requestAsync, y los fragmentos se empujan a un ByteChannel que el consumidor lee concurrentemente.

Diagram 8
8

¿Por qué streaming en vez de un único RPC grande?

Un archivo de 10 MB enviado como un único RPC significaría ~280 000 segmentos de notificación, todos retenidos en memoria en ambos lados antes de que la respuesta pudiera siquiera empezar — y toda la transferencia tendría que tener éxito antes de informar de cualquier progreso. Peor, una única notificación perdida en el medio corrompería el conjunto entero.

El diseño fragmentado tiene tres ventajas:

  1. Memoria constante. Solo hay un fragmento de 16 KiB en vuelo a la vez.
  2. Progreso en vivo. DownloadQueue.notifyProgressUpdate() se dispara cada segundo, y la UI muestra una barra de descarga.
  3. Resiliencia. Un fragmento fallido puede reintentarse de forma independiente (el DownloadQueue soporta pausa/reanudación/reintento a nivel de tarea; un fallo en medio del flujo deja el archivo temporal parcial, aunque actualmente el descargador lo borra en caso de fallo — véase contrapartidas).

Por qué onClose cancela el trabajo de descarga

El callback DownloadedResponse.onClose llama a downloadJob.cancel(). Esto es esencial porque el bucle de descarga se ejecuta en una corrutina hija que de otro modo seguiría ejecutándose para siempre si el consumidor abandonara el canal prematuramente (p. ej. el usuario pulsó Pausa). El contrato AutoCloseable en DownloadedResponse significa que el bloque use { ... } del consumidor invoca automáticamente onClose al salir, cancelando la corrutina de descarga BLE y derribando la conexión GATT en el bloque finally de la corrutina.

Priorización: cómo el chat vence a los archivos en la práctica {#prioritization-how-chat-beats-files-in-practice}

Esta es la pregunta más importante para cualquier aplicación de chat: cuando una descarga de archivo BLE lenta está en curso, ¿puede un nuevo mensaje de chat saltársela?

La respuesta honesta: no hay esquema de prioridad explícito

No hay campo de prioridad, ni cola de prioridad, ni apropiación en absoluto en el código BLE ni en la cola de descarga. Lo verifiqué por grep exhaustivo — las únicas coincidencias de priority en shared/src son niveles de prioridad de log y metadatos EXIF, nada relacionado con el orden mensajes-vs-descarga.

Lo que existe en su lugar es un conjunto de separaciones arquitectónicas que producen el comportamiento deseado como propiedad emergente:

Diagram 9
9

Por qué funciona en la práctica

La separación que hace que el chat «se sienta priorizado» es estructural:

  1. Los envíos de chat no pasan por DownloadQueue. Se emiten directamente por PeerGraphQLClientPeerTransportRouterBleTransport.send. Así que un mensaje de chat nunca se sienta detrás de una cola de descargas de archivos.
  2. Cada llamada a BleTransport abre su propia conexión GATT. Una descarga larga que mantiene una conexión no impide que un envío de chat abra una segunda conexión al mismo par. Android soporta múltiples conexiones GATT simultáneas.
  3. Los RPC de chat son cortos. Un único mensaje de chat es un ida y vuelta de requestAsync (~1 s tras conectar). Incluso si la radio está ocupada con una descarga, el envío de chat se completa en unos pocos segundos.

Dónde el diseño falla

Las contrapartidas de «sin prioridad explícita»:

  • Latencia de conexión. Tanto el chat como la descarga pagan el coste connect→discover→MTU (~segundos) cada vez, porque las conexiones no se reutilizan. Un mensaje de chat que llega durante una descarga no puede aprovechar la conexión existente de la descarga — abre una nueva.
  • Cola estática en Android. La operationQueue a nivel de proceso en AndroidBleGattClient serializa las operaciones GATT entre todos los pares y todas las conexiones. Así que aunque dos conexiones GATT pueden coexistir, sus operaciones write/read/notify se intercalan a nivel de cola. En la práctica esto está bien (cada op es ~ms) pero es un sutil cuello de botella global bajo alta concurrencia.
  • Sin apropiación. Una descarga en curso no puede pausarse para dejar pasar a un mensaje de chat. El envío de chat simplemente se ejecuta concurrentemente y compite por tiempo de radio.

Una mejora futura podría ser un Mutex por par alrededor de BleTransport.send y downloadFile, más un campo de prioridad en la cola — pero el diseño actual se apoya en el hecho de que los RPC de chat son lo suficientemente cortos como para que la contención rara vez sea visible para el usuario.

Control de concurrencia y la cola GATT estática {#concurrency-control--the-static-gatt-queue}

Esto merece su propia sección porque es el aspecto más sutil de la implementación BLE de Android.

Diagram 10
10

¿Por qué estática (a nivel de proceso)?

La pila BLE de Android no permite operaciones GATT concurrentes en una única instancia BluetoothGatt — llamar a writeCharacteristic mientras otra escritura está en vuelo devuelve false y silenciosamente descarta la segunda escritura. La solución estándar es una cola por BluetoothGatt. PlainApp va un paso más allá y usa una cola a nivel de proceso (en el companion object), lo cual es excesivamente conservador pero correcto: garantiza que ninguna dos operaciones GATT en ninguna parte de la aplicación se ejecuten simultáneamente.

El coste es que las operaciones write/read/notify de una larga descarga BLE de archivos se encolan detrás (y son encoladas detrás de) las operaciones GATT de cualquier otro par. Dado que cada op individual es ~ms, rara vez es un cuello de botella visible para el usuario — pero bajo tráfico BLE concurrente pesado hacia múltiples pares, podría convertirse en uno.

Sin bloqueo por par en la capa de transporte

BleDeviceApi.requestAsync es una suspend fun simple sin mutex, sin cola, sin serialización por par. Dos llamadas concurrentes a BleTransport.send para el mismo par abrirán cada una su propia conexión GATT y procederán de forma independiente. La serialización ocurre implícitamente a nivel de operación GATT (vía la cola estática en Android, o vía await secuencial en iOS).

Ciclo de vida de la conexión y negociación MTU {#connection-lifecycle--mtu-negotiation}

Diagram 11
11

¿Por qué requestMtu(517)?

El ATT MTU por defecto es de 23 bytes (solo 20 bytes de carga útil tras la cabecera ATT de 3 bytes). Con el MTU por defecto, cada segmento de 380 caracteres requeriría ~19 escrituras GATT en vez de 1 — una ralentización de 19×. Solicitar el MTU máximo permitido por la especificación BLE (517 bytes) permite que los segmentos de 380 caracteres quepan en una única operación ATT, mejorando drásticamente el rendimiento.

iOS no expone una API explícita de solicitud de MTU — CoreBluetooth lo negocia automáticamente con el periférico durante la conexión. Los dispositivos iOS modernos típicamente negocian ~185 bytes, lo que todavía cabe cómodamente los segmentos de 380 caracteres (tras restar la cabecera ATT + la sobrecarga del envoltorio JSON).

Control de flujo para notificaciones {#flow-control-for-notifications}

El servidor envía fragmentos de respuesta como notificaciones, pero las notificaciones BLE no tienen control de flujo integrado — si el servidor envía notificaciones más rápido de lo que el controlador puede transmitirlas, se descartan silenciosamente. PlainApp implementa control de flujo explícito basado en ack:

Diagram 12
12

Sin este control de flujo, las notificaciones consecutivas serían descartadas silenciosamente por el controlador BLE cuando su cola interna de envío se llenase — un problema bien conocido de BLE en Android documentado en los comentarios de la interfaz BleGattServer. La regla de un único envío en vuelo por dispositivo garantiza que cada notificación se transmite o dispara un tiempo de espera (que luego se trata como fallo de transporte).

Gestión de errores: TransportUnavailable frente a fallo real {#error-handling-transportunavailable-vs-real-failure}

TransportUnavailable es la señal que indica a PeerTransportRouter que caiga al siguiente transporte. Cualquier otra cosa es un fallo real devuelto al llamador.

Diagram 13
13

La sutileza del fallo de descarga

BleTransport.downloadFile devuelve DownloadedResponse(200, channel, onClose) inmediatamente — el bucle de descarga fragmentada se ejecuta en una corrutina en segundo plano que escribe en el canal. Si un RPC de fragmento falla a mitad de flujo, el bucle llama a channel.close(TransportUnavailable(...)), lo que significa que el consumidor (PeerFileDownloader.downloadAsync) ve el error como una excepción lanzada desde channel.readAvailable(buf).

Esto significa que la propia llamada PeerTransportRouter.downloadFile tuvo éxito (devolvió un DownloadedResponse), así que el disyuntor no registra un fallo para errores de descarga a mitad de flujo. Solo los fallos a nivel de conexión y de escaneo son capturados por el enrutador. Es una decisión de diseño deliberada — un fallo a mitad de flujo no debería deshabilitar BLE permanentemente para ese par (el par puede simplemente haberse salido del alcance temporalmente).

Referencia de constantes clave {#key-constants-reference}

ConstanteValorDóndePropósito
BleDeviceApi.CHUNK_SIZE380Fragmentación de segmento GATTTamaño de cada BleSegmentData.data (cabe dentro del ATT MTU tras la sobrecarga JSON)
BleTransport.CHUNK_SIZE16 384 (16 KiB)Rango de bytes de descarga de archivosTamaño de cada solicitud de fragmento /fs
BleTransport.SCAN_TIMEOUT_MS10 000Escaneo BLETiempo de espera para scanner.findOne
BleDeviceApi.NOTIFY_TIMEOUT_MS15 000Respuesta RPCEspera por notificación en requestAsync
AndroidBleGattClient MTU517Configuración de conexiónrequestMtu(517) — máximo permitido por la especificación BLE
AndroidBleGattClient connect timeout10 000Configuración de conexiónEspera de STATE_CONNECTED
AndroidBleGattClient MTU timeout5 000Configuración de conexiónEspera de onMtuChanged
AndroidBleGattClient write timeout5 000Escritura GATTEspera de onCharacteristicWrite
AndroidBleGattClient read timeout10 000Lectura GATTEspera de onCharacteristicRead (sin uso para datos reales)
AndroidBleGattClient notify-state timeout5 000Escritura CCCDEspera de escritura del descriptor CCCD
ensureConnected retries3Configuración de conexiónHasta 4 intentos totales (0..3)
AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS10 000Control de flujo de notificaciónEspera de onNotificationSent
AndroidBleGattServer notifyChunkSize380Fragmentación de respuestaIgual que BleDeviceApi.CHUNK_SIZE
IosBleGattServer retry cap10Control de flujo de notificaciónMáx. reintentos de updateValue antes de rendirse
PeerCircuitBreaker.WINDOW_MS30 000Disyuntor de transporteDuración abierta tras el umbral
PeerCircuitBreaker.MAX_FAILURES2Disyuntor de transporteFallos dentro de la ventana para abrir
DownloadQueue.MAX_CONCURRENT3Pool de workers de descargaCorrutinas de descarga concurrentes
BleServiceData.SHORT_ID_BYTES8Identificación de parBytes del prefijo SHA256 truncado
BleServiceData.PAYLOAD_BYTES9Identificación de par1 byte de banderas + 8 bytes shortId
BleSegmentData.STATE_START_BIT1Señalización EOF Capa APrimer segmento de un mensaje multi-segmento
BleSegmentData.STATE_END_BIT2Señalización EOF Capa AÚltimo segmento (o segmento único)

Resumen de contrapartidas de diseño {#design-trade-offs-recap}

Diagram 14
14

Lecturas adicionales

  • Arquitectura de Chat — cómo encaja BleTransport en la cadena de fallback LAN → Aware → BLE y el pipeline más amplio de envío/recepción de chat.
  • Flujo de emparejamiento — cómo se establece la clave ChaCha20 compartida usada por cada carga útil BLE, y cómo se usa la característica NEARBY para el apretón de manos de emparejamiento.