Назад к блогу
Transport15 min read

Дизайн BLE-транспорта — Сообщения и загрузка файлов

В этой статье объясняется, как PlainApp отправляет сообщения чата и скачивает файлы по Bluetooth Low Energy, когда ни LAN, ни Wi-Fi Aware недоступны. BLE — это гарантированный fallback: медленный, но работающий вообще без какой-либо IP-связности. Статья описывает формат кадров, двухуровневую схему фрагментации, как (и как не) приоритизируется параллельный трафик и почему каждое соединение разрывается после каждого запроса.

Более широкая архитектура чата, использующая этот транспорт, описана в Chat Architecture. О том, как два устройства получают общий ключ ChaCha20, которым шифруется каждая полезная нагрузка BLE, см. в Pairing Flow.

Содержание

Зачем вообще нужен BLE-транспорт? {#why-a-ble-transport-at-all}

PlainApp — serverless и offline-first. Транспортный слой представляет собой упорядоченную цепочку fallback'ов: LAN → Wi-Fi Aware → BLE. LAN — сценарий без ошибок (HTTPS через Wi-Fi, ~10 мс round trip). Wi-Fi Aware покрывает пиров в разных подсетях (разные SSID, guest vs IoT VLANs). Оба требуют некоторой IP-связности. BLE — единственный транспорт, работающий:

  • Когда устройства вообще не находятся в одной IP-сети.
  • Когда Wi-Fi выключен или включён режим «в самолёте» (BLE-радио отдельно).
  • Когда Wi-Fi Aware не поддерживается (Android < 13, все iOS-варианты PlainApp).

BLE медленный — десятки KB/s, секунды задержки на запрос — но он гарантирован для любого сопряжённого пира, потому что ему нужно только знать clientId пира, который всегда транслируется в BLE scan response.

Diagram 1
1

Структура GATT-сервиса {#gatt-service-layout}

PlainApp рекламирует один кастомный GATT-сервис с двумя характеристиками. Зарегистрированного 16-битного UUID нет — сервис использует 128-битный UUID, чей завершающий байт в ASCII-декодировании даёт plpai\x01:

Diagram 2
2

Почему две характеристики?

У двух протоколов совершенно разные модели доверия и формы полезной нагрузки:

  • NEARBY переносит сообщения сопряжения. Они приходят до того, как пир сопряжён (общего ключа ещё нет), поэтому используют собственные Ed25519-подписанные JSON-полезные нагрузки с собственной префиксной маршрутизацией. Тело — обычная строка.
  • HTTP переносит весь пост-сопряжённый трафик (чат, файлы, presence). Всегда шифруется ChaCha20 общим ключом и использует тот же HttpRouteRegistry, что и LAN Ktor-сервер, поэтому обработчики маршрутов (/peer_graphql, /fs, /peer_status) написаны один раз и переиспользуются для обоих транспортов.

Почему уведомления, а не чтения?

BLE ATT-протокол ограничивает чтение одного атрибута 512 байтами. GraphQL-ответ или 16 KB файл-чанк может быть значительно больше. PlainApp обходит это, никогда не используя readCharacteristic для реальных данныхonCharacteristicReadRequest сервера возвращает пустую полезную нагрузку с GATT_SUCCESS. Вместо этого клиент пишет свой запрос в характеристику, а сервер отвечает последовательностью фрагментированных уведомлений, которые клиент собирает обратно. Это описано в BleDeviceApi.requestAsync, BleServerProtocol.handleWrite и AndroidBleGattServer.sendChunkedResponse.

Идентификация пиров: shortId, а не MAC {#peer-identification-shortid-not-mac}

BLE-рекламные пакеты малы (31 байт), а BLE MAC-адрес рандомизируется Android примерно каждые 15 минут — поэтому его нельзя использовать как стабильный идентификатор. Вместо этого PlainApp транслирует 9-байтовую полезную нагрузку serviceData в scan response:

Diagram 3
3

Почему усечённый хеш, а не полный clientId?

13-символьный clientId уместился бы в 13 байт, но PlainApp выбирает 8-байтовый усечённый SHA-256 по двум причинам:

  1. Стабильный байтовый бюджет. 9 байт общего объёма комфортно умещаются в 31-байтовую рекламную полезную нагрузку вместе с service UUID (16 байт), полями длины и типа (~27 байт занято, 4 байта в запасе).
  2. Конфиденциальность. Пассивный наблюдатель BLE-сканирования не может восстановить clientId из shortId (8-байтовый префикс SHA-256 на практике необратим). Он может лишь узнать пира, который уже рекламировал тот же shortId ранее — перечислить пользователей PlainApp он не может.

Полный clientId раскрывается только пиру, который реально подключился по GATT и обменялся DDiscoverReply — т. е. пиру, с которым пользователь уже выбрал взаимодействие.

Двухуровневая схема фрагментации {#two-layer-chunking-design}

Это самая тонкая часть BLE-транспорта, и важно понимать оба уровня, поскольку они имеют совершенно разные размеры и назначение:

Diagram 4
4

Почему 380 символов?

Согласованный ATT MTU — 517 байт на Android (requestMtu(517) — максимум, допускаемый спецификацией BLE) и ~185+ на iOS (автосогласование CoreBluetooth). За вычетом ATT-заголовка (~3 байта) и накладных расходов JSON-обёртки BleSegmentData ({"d":"...","s":N} добавляет ~12 байт), 380 символов полезной нагрузки с запасом умещаются в один ATT MTU на обеих платформах. Значение симметрично (и фрагменты запроса клиента, и фрагменты уведомления сервера используют 380), что упрощает код.

Почему 16 KiB для файловых чанков?

16 KiB файл-чанк в base64 кодируется в ~22 KiB JSON, что фрагментируется в ~58 GATT-сегментов уведомления. Каждый round-trip requestAsync по BLE занимает секунды, поэтому меньшее число более крупных чанков снижает накладные расходы на чанк. Дальнейшее увеличение рискует упереться в BLE RPC-тайм-ауты и ухудшить отзывчивость прогресса (пользователь видит обновление прогресса только раз за чанк). 16 KiB — эмпирически подобранная золотая середина: достаточно большой для пропускной способности, достаточно маленький для отзывчивого UI прогресса.

RPC-примитив: BleDeviceApi.requestAsync {#the-rpc-primitive-bledeviceapirequestasync}

Каждое BLE-сообщение чата и каждый файл-чанк — это один вызов BleDeviceApi.requestAsync(service, requestData) — suspend-функция, возвращающая BleResult. С точки зрения вызывающего она синхронна: один запрос → один полностью собранный ответ, без конвейеризации.

Diagram 5
5

Ключевые инварианты

  1. Один запрос → один ответ. requestAsync синхронна с точки зрения вызывающего — она возвращается только после того, как полный ответ собран. Конвейеризации нет.
  2. Уведомления включаются на каждый вызов. Клиент пишет CCCD в начале каждого requestAsync и отключает в конце. Это расточительно (два лишних GATT-записа на вызов), но делает протокол stateless — серверу не нужно отслеживать, какие клиенты «слушают».
  3. Нет повторных попыток внутри RPC. Если любой одиночный writeCharacteristic истекает по тайм-ауту (5 с), весь RPC прерывается. Повторяет только ensureConnected (3 попытки при сбое подключения). Грубый транспортный backoff обеспечивает PeerCircuitBreaker, а не RPC-слой.

Формат конверта на проводе {#wire-envelope-format}

Полезная нагрузка внутри сегментов Layer A — это вложенный JSON-конверт. Без учёта фрагментации логическая структура такова:

Diagram 6
6

Форма ответа

Ответ течёт в обратном направлении через ту же фрагментацию Layer A, но внутренний JSON — это BleHttpResponse с тремя полями: s (HTTP-код статуса), h (карта заголовков ответа) и b (тело). Тело всегда base64-кодируется BleHttpCall.encodeResponse(), даже когда пусто — ответ может быть бинарным (зашифрованные GraphQL-байты, сырые байты файла /fs), а BLE-транспорт строковый, поэтому один и тот же JSON-конверт переносит как текстовые, так и бинарные полезные нагрузки.

Путь отправки сообщения (end-to-end) {#message-send-path-end-to-end}

Сводя всё вместе — что происходит при отправке сообщения чата через BLE:

Diagram 7
7

Примечательные проектные решения

  • Тот же ключ, что и для LAN. Общий ключ ChaCha20 из сопряжения переиспользуется для BLE — отдельного BLE-ключа нет. OkHttp crypto-интерсептор, используемый LanTransport, и ручные chaCha20Encrypt/chaCha20Decrypt в BleTransport — один и тот же примитив, просто вызывается по-разному.
  • Те же обработчики маршрутов, что и для LAN. BleHttpRequest проходит через HttpRouteRegistry.matchRoute(path) — тот же реестр, что использует Ktor LAN-сервер. Поэтому /peer_graphql, /fs, /peer_status и т. д. реализованы ровно один раз и работают одинаково над обоими транспортами.
  • Без переиспользования соединений. Блок finally { scanner.teardownConnection(client) } выполняется всегда. Каждое сообщение оплачивает полную стоимость connect→discoverServices→MTU (~секунды). Это намеренный компромисс — см. Design Trade-offs.

Путь загрузки файла (end-to-end) {#file-download-path-end-to-end}

Загрузки через BLE — стриминговые: файл читается 16 KiB чанками и пишется во временный файл по мере поступления, поэтому файлу 10 MB не нужно 10 MB RAM. Трюк в том, что RPC каждого чанка — отдельный вызов requestAsync, а чанки проталкиваются в ByteChannel, который потребитель читает параллельно.

Diagram 8
8

Почему стриминг, а не один большой RPC?

Файл 10 MB, отправленный одним RPC, означал бы ~280 000 сегментов уведомления, все одновременно в памяти на обеих сторонах, прежде чем ответ мог бы даже начаться — и вся передача должна была бы завершиться, прежде чем появится какой-либо прогресс. Хуже того, одна потерянная посередине нотификация испортила бы всё целиком.

Чанковая схема даёт три выигрыша:

  1. Постоянная память. В полёте одновременно только один чанк 16 KiB.
  2. Живой прогресс. DownloadQueue.notifyProgressUpdate() срабатывает каждую секунду, и UI показывает полосу загрузки.
  3. Устойчивость. Сбойный чанк можно повторить независимо (DownloadQueue поддерживает pause/resume/retry на уровне задачи; сбой в середине потока оставляет частичный временный файл, хотя сейчас загрузчик удаляет его при сбое — см. компромиссы).

Почему onClose отменяет job загрузки

Колбэк DownloadedResponse.onClose вызывает downloadJob.cancel(). Это необходимо, потому что цикл загрузки выполняется в дочерней корутине, которая иначе выполнялась бы вечно, если потребитель бросит канал раньше (например, пользователь нажал Pause). Контракт AutoCloseable у DownloadedResponse означает, что блок use { ... } потребителя автоматически вызовет onClose при выходе, отменяя корутину BLE-загрузки и разрывая GATT-соединение в блоке finally корутины.

Приоритизация: как чат обгоняет файлы на практике {#prioritization-how-chat-beats-files-in-practice}

Это самый важный вопрос для любого чат-приложения: когда идёт медленная BLE-загрузка файла, может ли новое сообщение чата перепрыгнуть её?

Честный ответ: явной схемы приоритетов нет

В BLE-коде и очереди загрузки нет поля priority, нет priority queue, нет preemption. Я проверил это исчерпывающим grep — единственные совпадения priority в shared/src относятся к уровням логирования и EXIF-метаданным, ничего связанного с упорядочиванием сообщение-против-загрузки.

Вместо этого существует набор архитектурных разделений, которые производят желаемое поведение как эмерджентное свойство:

Diagram 9
9

Почему это работает на практике

Разделение, благодаря которому чат «ощущается приоритизированным», структурное:

  1. Отправка чата не идёт через DownloadQueue. Она выполняется напрямую PeerGraphQLClientPeerTransportRouterBleTransport.send. Поэтому сообщение чата никогда не стоит за очередью загрузок файлов.
  2. Каждый вызов BleTransport открывает собственное GATT-соединение. Длительная загрузка, удерживающая одно соединение, не мешает отправке чата открыть второе соединение к тому же пиру. Android поддерживает несколько одновременных GATT-соединений.
  3. Chat-RPC короткие. Одно сообщение чата — один round-trip requestAsync (~1 с после подключения). Даже если радио занято загрузкой, отправка чата завершается за несколько секунд.

Где дизайн проигрывает

Компромиссы «без явного приоритета»:

  • Задержка подключения. И чат, и загрузка оплачивают стоимость connect→discover→MTU (~секунды) каждый раз, поскольку соединения не переиспользуются. Сообщение чата, пришедшее во время загрузки, не может «подсветиться» к существующему соединению загрузки — оно открывает новое.
  • Статическая очередь на Android. Процесс-глобальная operationQueue в AndroidBleGattClient сериализует GATT-операции по всем пирам и всем соединениям. Поэтому, хотя два GATT-соединения могут сосуществовать, их операции write/read/notify перемежаются на уровне очереди. На практике это нормально (каждая операция ~мс), но это тонкое глобальное узкое место при высокой параллельности.
  • Нет preemption. Идущая загрузка не может быть приостановлена, чтобы пропустить сообщение чата. Отправка чата просто выполняется параллельно и конкурирует за радио-время.

Будущим улучшением мог бы стать per-peer Mutex вокруг BleTransport.send и downloadFile, а также поле приоритета в очереди — но текущий дизайн опирается на то, что chat-RPC достаточно коротки, и конкуренция редко видна пользователю.

Управление параллелизмом и статическая GATT-очередь {#concurrency-control--the-static-gatt-queue}

Это заслуживает отдельного раздела, поскольку это самый тонкий аспект Android BLE-реализации.

Diagram 10
10

Почему статическая (процесс-глобальная)?

Android BLE-стек не допускает параллельных GATT-операций на одном экземпляре BluetoothGatt — вызов writeCharacteristic во время другого in-flight записи возвращает false и тихо отбрасывает вторую запись. Стандартный обход — очередь на каждый BluetoothGatt. PlainApp идёт дальше и использует процесс-глобальную очередь (в companion object), что избыточно консервативно, но корректно: гарантируется, что никакие две GATT-операции во всём приложении не выполняются одновременно.

Цена — операции write/read/notify длительной BLE-загрузки файла стоят за (и стоят за) операциями GATT любого другого пира. Поскольку каждая отдельная операция ~мс, это редко узкое место, видимое пользователю — но при интенсивном параллельном BLE-трафике к нескольким пирам оно может им стать.

Без per-peer блокировки на транспортном слое

BleDeviceApi.requestAsync — обычная suspend fun без мьютекса, без очереди, без per-peer сериализации. Два параллельных вызова BleTransport.send для одного пира откроют каждое своё GATT-соединение и выполнятся независимо. Сериализация происходит неявно на уровне GATT-операций (через статическую очередь на Android или через последовательный await на iOS).

Жизненный цикл соединения и согласование MTU {#connection-lifecycle--mtu-negotiation}

Diagram 11
11

Почему requestMtu(517)?

ATT MTU по умолчанию — 23 байта (только 20 байт полезной нагрузки после 3-байтового ATT-заголовка). С MTU по умолчанию каждый 380-символьный сегмент требовал бы ~19 GATT-записей вместо 1 — замедление в 19 раз. Запрос максимального MTU, допускаемого спецификацией BLE (517 байт), позволяет 380-символьным сегментам умещаться в одну ATT-операцию, резко повышая пропускную способность.

iOS не предоставляет явного API запроса MTU — CoreBluetooth согласовывает его автоматически с периферией при подключении. Современные iOS-устройства обычно согласовывают ~185 байт, что по-прежнему с запасом вмещает 380-символьные сегменты (после вычета ATT-заголовка и накладных расходов JSON-обёртки).

Управление потоком для уведомлений {#flow-control-for-notifications}

Сервер отправляет фрагменты ответа как уведомления, но BLE-уведомления не имеют встроенного управления потоком — если сервер отправляет уведомления быстрее, чем контроллер способен их передать, они тихо теряются. PlainApp реализует явное ack-управление потоком:

Diagram 12
12

Без этого управления потоком подряд идущие уведомления тихо отбрасывались бы BLE-контроллером при заполнении внутренней очереди отправки — известная проблема Android BLE, задокументированная в комментариях интерфейса BleGattServer. Правило «одно in-flight на устройство» гарантирует, что каждое уведомление либо передаётся, либо вызывает тайм-аут (который затем трактуется как транспортный сбой).

Обработка ошибок: TransportUnavailable против реального сбоя {#error-handling-transportunavailable-vs-real-failure}

TransportUnavailable — сигнал, говорящий PeerTransportRouterпровалиться к следующему транспорту. Всё остальное — реальный сбой, возвращаемый вызывающему.

Diagram 13
13

Тонкость сбоя загрузки

BleTransport.downloadFile возвращает DownloadedResponse(200, channel, onClose) немедленно — чанковый цикл загрузки выполняется в фоновой корутине, пишущей в канал. Если RPC чанка падает в середине потока, цикл вызывает channel.close(TransportUnavailable(...)), что означает, что потребитель (PeerFileDownloader.downloadAsync) видит ошибку как выброшенное исключение из channel.readAvailable(buf).

Это значит, что сам вызов PeerTransportRouter.downloadFile завершился успешно (вернул DownloadedResponse), поэтому circuit breaker не записывает сбой для ошибок загрузки в середине потока. Лишь сбои на этапе подключения и сканирования ловятся маршрутизатором. Это намеренное проектное решение — сбой в середине потока не должен навсегда отключать BLE для этого пира (пир мог просто временно выйти из зоны действия).

Справочник ключевых констант {#key-constants-reference}

КонстантаЗначениеГдеНазначение
BleDeviceApi.CHUNK_SIZE380Фрагментация GATT-сегментаРазмер каждого BleSegmentData.data (вмещается в ATT MTU после JSON-накладных)
BleTransport.CHUNK_SIZE16 384 (16 KiB)Байтовый диапазон загрузки файлаРазмер каждого запроса чанка /fs
BleTransport.SCAN_TIMEOUT_MS10 000BLE-сканированиеТайм-аут для scanner.findOne
BleDeviceApi.NOTIFY_TIMEOUT_MS15 000RPC-ответОжидание одного уведомления в requestAsync
AndroidBleGattClient MTU517Настройка соединенияrequestMtu(517) — максимум по спецификации BLE
AndroidBleGattClient connect timeout10 000Настройка соединенияОжидание STATE_CONNECTED
AndroidBleGattClient MTU timeout5 000Настройка соединенияОжидание onMtuChanged
AndroidBleGattClient write timeout5 000GATT-записьОжидание onCharacteristicWrite
AndroidBleGattClient read timeout10 000GATT-чтениеОжидание onCharacteristicRead (не используется для реальных данных)
AndroidBleGattClient notify-state timeout5 000CCCD-записьОжидание записи CCCD-дескриптора
ensureConnected retries3Настройка соединенияДо 4 попыток всего (0..3)
AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS10 000Управление потоком уведомленийОжидание onNotificationSent
AndroidBleGattServer notifyChunkSize380Фрагментация ответаТо же, что BleDeviceApi.CHUNK_SIZE
IosBleGattServer retry cap10Управление потоком уведомленийМаксимальное число повторов updateValue до сдачи
PeerCircuitBreaker.WINDOW_MS30 000Circuit breaker транспортаДлительность открытия после порога
PeerCircuitBreaker.MAX_FAILURES2Circuit breaker транспортаСбоев в окне для открытия
DownloadQueue.MAX_CONCURRENT3Пул воркеров загрузкиПараллельных корутин загрузки
BleServiceData.SHORT_ID_BYTES8Идентификация пировБайты усечённого префикса SHA256
BleServiceData.PAYLOAD_BYTES9Идентификация пиров1 байт флагов + 8 байт shortId
BleSegmentData.STATE_START_BIT1Сигнализация EOF Layer AПервый сегмент многосегментного сообщения
BleSegmentData.STATE_END_BIT2Сигнализация EOF Layer AПоследний сегмент (или одиночный сегмент)

Сводка проектных компромиссов {#design-trade-offs-recap}

Diagram 14
14

Дополнительная литература

  • Chat Architecture — как BleTransport вписывается в цепочку fallback'ов LAN → Aware → BLE и более широкий конвейер отправки/получения чата.
  • Pairing Flow — как устанавливается общий ключ ChaCha20, используемый каждой полезной нагрузкой BLE, и как характеристика NEARBY используется для рукопожатия сопряжения.