Files
utun2/tools/ble-test-android
evgeny ba1c4cf160 Add cross-platform BLE transport and Bluetooth settings
Integrate shared C transport with desktop and Android frontends, add radio probes and detailed Windows diagnostics, and fix GUI build dependencies and early configuration startup errors.
2026-10-29 10:51:19 +01:00
..

uTun BLE Test

Отдельное Android-приложение для проверки BLE discovery и автоматических двусторонних GATT-соединений. Android 8.0+, compile/target SDK 36, JDK 17. :app — тестовый экран; :ble-transport — переиспользуемая Android-библиотека с сервисом и очередями, без зависимости от экрана.

Сборка и запуск

cd tools/ble-test-android
./build.sh                         # unit tests, debug APK, Android lint
./build.sh install DEVICE_SERIAL  # то же + установка на выбранное устройство

APK: app/build/outputs/apk/debug/app-debug.apk, package: com.utun.bletest. Gradle wrapper используется из соседнего chatgui-android; NDK, Compose и native-ядро не нужны.

На двух и более устройствах задайте разные ники и одинаковый период, например 1000 мс. Нажмите «Запустить тест», выдайте разрешения и включите Bluetooth по запросу. Ник занимает 1–13 байт UTF-8: латиница — один байт, кириллица обычно два. Период — 250–3600000 мс. Для изменения настроек остановите сервис и запустите снова. На Android 8–11 нужны разрешение геопозиции и включённая системная геопозиция. Android 12+ запрашивает Nearby devices, Android 13+ отдельно запрашивает уведомления; отказ от уведомлений не запрещает тест.

GUI отображает успешные публикации TX, уникальные сообщения RX по никам, повторы, номер последнего сообщения, RSSI и давность приёма каждого сеанса. Отдельно видны ошибки, очереди, переполнения и пропуски периодического источника. Одинаковые ники суммируются, идентификаторы сеансов остаются видимыми. Установка получает постоянный ненулевой 64-битный ID, видимый над настройками и в статистике соседей. Флажок автоматического подключения включает discovery → запрос → callback допуска → READY без bonding/диалогов. Направление автоматического запроса выбирается по unsigned ID: меньший подключается к большему. Можно явно задать ID назначения. Поле допуска ограничивает входящие одним ID; пустое разрешает любые ID в тестовом приложении. В библиотеке отсутствие callback означает отказ. Настройки допуска применяются при запуске теста.

Фон и проверка стабильности

Сервис connectedDevice работает с постоянным уведомлением и partial wake lock. Закрытие Activity, Home, блокировка экрана и удаление задачи из recent apps его не останавливают. Остановка — кнопкой на экране или в уведомлении. Wake lock удерживается только во время теста: он намеренно расходует батарею ради непрерывной работы таймеров при погашенном экране. После уничтожения процесса система может восстановить START_STICKY-сервис с сохранёнными параметрами; при этом создаётся новый сеанс, а статистика и очереди в памяти начинаются заново. Force stop, перезагрузка телефона и ограничения конкретной прошивки не дают гарантии восстановления. Автозапуска после загрузки нет. При необходимости разрешите фоновую работу в настройках прошивки.

Рекомендуемый ручной прогон:

  1. Сверить рост TX и RX на двух телефонах при видимых экранах, проверить «Отправить сейчас».
  2. Уйти на Home и погасить экраны на 15–30 минут; затем сверить новые сообщения и давность приёма.
  3. Выключить и включить Bluetooth на одном телефоне: сервис ждёт адаптер и восстанавливает радио.
  4. После ошибки BLE проверить повторный запуск через 30 секунд и причину в GUI/logcat.
  5. Проверить stop/start, поворот экрана и повторное открытие приложения.

«Отправлено» означает успешный callback Bluetooth-стека о публикации новых данных. Одна публикация многократно объявляется контроллером до следующего обновления; это не число физических радиообъявлений и не подтверждение получения другими устройствами. Нельзя вычислять точный процент радиопотерь только по этим счётчикам: есть повторы, позднее подключение, перезапуски и время без сканирования. Последний seq помогает сравнить прогресс.

Android BLE не гарантирует доставку, точный период или непрерывную работу на любой прошивке. Ограничения фоновой работы описаны в Android BLE background guide.

Binder API

Сервис принадлежит приложению, exported=false, использует тот же процесс. Запустите его из видимой Activity после выдачи requiredPermissions(), затем привяжитесь через bindService. onServiceConnected получает BleBroadcastService.LocalBinder.

// Периодический источник произвольных байтов; null отключает источник, оставляя ручной TX и RX.
BleBroadcastService.start(context, periodicPayload = "Phone-1".toByteArray(), periodMs = 1000)

context.bindService(
    Intent(context, BleBroadcastService::class.java),
    connection,
    Context.BIND_AUTO_CREATE
)

// Из onServiceConnected:
val api = binder as BleBroadcastService.LocalBinder
val result = api.send(payload)     // неблокирующая запись; копирует payload
val packet = api.pollReceived()    // одно сообщение или null
val batch = api.drainReceived(64)  // до 64 сообщений; limit > 0
val stats = api.snapshot()         // потокобезопасный снимок, не забирает сообщения

BleBroadcastService.stop(context)

API подключения и обмена с узлом

При интеграции передавайте ID существующего ядра через localNodeId; по умолчанию берётся постоянный ID установки. Для смены собственного ID сначала остановите транспорт. Все 64 бита передаются без усечения: Kotlin Long хранит unsigned ID как битовое представление, для текста используйте BleProtocol.nodeIdText(id).

BleBroadcastService.start(context, "Phone-1".toByteArray(), 1000,
    targetNodeId = remoteId, localNodeId = myNodeId)

// После привязки. Callback принадлежит приложению/сервису, а не Activity.
api.setConnectionValidator { request ->
    request.localNodeId == myNodeId && allowedNodeIds.contains(request.remoteNodeId)
}
val requested = api.connect(remoteId) // поиск адреса по broadcasts, connectGatt, HELLO, повтор после обрыва
val result = api.sendTo(remoteId, payload)
val packet: ByteArray? = api.pollNodeReceived(remoteId)
val packets: List<ByteArray> = api.drainNodeReceived(remoteId, limit = 64)
val links = api.connectionSnapshots()
api.disconnect(remoteId) // отменяет повторные попытки и автоматическое подключение к этому ID

start(..., autoConnect=true) включает автоматический запрос к обнаруженным узлам. Параметры start сохраняются для sticky restart, явно добавленные Binder-запросы действуют до stop/смерти процесса. connect возвращает REQUESTED, STOPPED, INVALID_ID или LIMIT_REACHED; REQUESTED означает принятую заявку, готовность видна как READY в снимке. Одновременно допускается до 8 транспортов, история очередей ограничена 32 ID до пересоздания сервиса.

Для восстановления политики после смерти процесса класс Application может реализовать BleConnectionValidator; сервис возьмёт этот callback при создании. Пример — TestApplication. Callback выполняется синхронно в BLE worker, должен быстро вернуть Boolean, исключение означает отказ. Он получает собственный ID, ID отправителя HELLO и имя из discovery, если оно уже получено. Сервис сначала проверяет HELLO source != 0, source != local, target == local; инициатор сверяет ID в ACCEPT с ожидаемым ID назначения. DATA до допуска запрещён. ID здесь заявленный, без криптографического доказательства владения. Для чата аутентификацию выполняет его протокол.

sendTo возвращает QUEUED, NOT_CONNECTED, QUEUE_FULL или TOO_LARGE. Максимум сообщения — 4096 байт. Для каждого ID отдельные FIFO TX и RX по 64 сообщения; входной payload копируется. При QUEUE_FULL дождитесь уменьшения txQueued. GUI умеет отправлять текст и пачку 64 × 512 байт, показывать MTU, байты, очереди, ожидание ACK и блокировку RX. Флажок чтения RX позволяет остановить consumer и проверить backpressure.

Congestion/flow control консервативный: окно — один фрагмент, следующий выпускается по ACK предыдущего. Размер фрагмента следует согласованному MTU (23–517), ATT value ограничен 512 байтами. Сервер передаёт indications, клиент — writes с ответом; один ATT writer сериализует операции. Служебный ACK имеет приоритет над DATA, поэтому одновременный обмен не блокирует подтверждения. При полном RX финальный ACK сообщения удерживается, сохраняется одно дополнительное собираемое сообщение; чтение RX возвращает кредит и возобновляет отправителя. RX соединения ничего не вытесняет. ACK означает помещение сообщения в RX удалённого API, счётчик sent растёт только после этого. При обрыве неподтверждённый TX сбрасывается с discardedTx, готовый RX сохраняется в пределах жизни сервиса. Приложение само решает, какие сообщения повторять: финальный ACK мог потеряться уже после помещения в RX. Handshake ограничен 30 с, отдельная ATT операция — 20 с; после обрыва заявка повторяется через 10 с. Ожидание свободного RX не имеет короткого таймаута: consumer может быть остановлен намеренно.

Wire GATT: HELLO/ACCEPT — type:u8 | source:u64le | target:u64le (типы 1/2); DATA — 3 | sequence:u32le | offset:u16le | total:u16le | fragment; ACK — 4 | sequence:u32le | acceptedOffset:u16le; REJECT — байт 5. Счётчики последовательности независимы по направлениям, начинаются с 1 на новом соединении.

send возвращает QUEUED, STOPPED, QUEUE_FULL, PAYLOAD_TOO_LARGE или SEQUENCE_EXHAUSTED. При QUEUE_FULL дождитесь уменьшения snapshot().txQueued перед повтором, не накапливайте новые сообщения. Одна FIFO TX на 64 записи; головная запись снимается только после успешного callback. При сбое радио запись сохраняется и повторяется с тем же номером. При явном stop ожидающий TX удаляется и учитывается в txDiscarded. Уже опубликованный пакет передаётся минимум 250 мс перед следующим. Периодический источник пропускает тик при занятом TX/отключённом радио, не раздувая очередь.

RX — одна FIFO на 256 уникальных сообщений, с одним логическим consumer. При заполнении удаляется старейшее сообщение, растёт rxDropped, появляется warning. GUI забирает RX при видимом экране, но сервис ведёт статистику независимо от чтения очереди. После длительной работы в фоне переполнение RX ожидаемо; оно не означает потерю радиопакетов. BleReceived содержит session, sequence, payload, rssi, elapsedMs. Время — монотонное Android elapsed realtime, не UTC.

Идентификатор сеанса — случайное ненулевое unsigned 32-bit число, новый при каждом запуске транспорта. Дедупликация по (session, sequence), окно перестановок — 256 сообщений. Повторы и пакеты старше окна учитываются отдельно, в RX не попадают. Статистика ограничена 256 сеансами; при превышении вытесняется дольше всего не наблюдавшийся, растёт peerEvictions. Общие счётчики сохраняются. Снимки и входные payload копируются. История и идентичность сеансов не сохраняются в БД. Переполнение unsigned sequence требует нового запуска.

Подключение к chatgui-android

В tools/chatgui-android/settings.gradle.kts:

include(":ble-transport")
project(":ble-transport").projectDir = file("../ble-test-android/ble-transport")

В app/build.gradle.kts добавить implementation(project(":ble-transport")), в корневой plugins-блок — id("com.android.library") version "8.13.2" apply false. Manifest библиотеки добавит BLE-права и сервис. Приложение отвечает за runtime-разрешения, привязку, чтение RX и явный stop. Unbind останавливает только связь GUI с API. Для транспорта, привязанного к жизненному циклу чат-сервиса, запускать/останавливать этот сервис явно; текущий API не добавляет BLE в native-ядро и не требует изменений протокола чата.

Радиоформат и диагностика

Один legacy advertising set, connectable/scannable, LE 1M, интервал объявления 100 мс. Advertising данные меняются через setAdvertisingData, без цикла stop/start для каждого сообщения. Сканирование LOW_LATENCY с manufacturer-фильтром и нулевой задержкой отчётов. API Android описан в AdvertisingSet.

Manufacturer Company ID 0xffff используется только для локального эксперимента; перед production нужен собственный Company ID или другой AD-формат. Manufacturer data: 75 54 | version:u8=2 | session:u32le | sequence:u32le | payload:0..13 bytes. Всего до 24 байт, плюс 4 байта manufacturer AD и резерв 3 байта flags: укладывается в 31 байт legacy BLE. Scan response содержит service data UUID f61a9001-4abc-45ac-98e1-761df4c13811: version:u8=2 | nodeId:u64le (9 байт + 18 байт AD). Android возвращает имя и ID в объединённом ScanRecord. Имя обновляется вместе со счётчиком; ID неизменен во время работы транспорта. Обратной совместимости с v1 нет. Ник — соглашение тестового приложения; транспорт принимает любые байты. Broadcast discovery не имеет ACK. Для данных соединения есть фрагментация/ACK/очереди, шифрование и аутентификация — задача чата.

adb -s DEVICE_SERIAL logcat -v threadtime -s UtunBle
adb -s DEVICE_SERIAL shell setprop log.tag.UtunBle DEBUG  # дополнительно каждый опубликованный TX
adb -s DEVICE_SERIAL shell setprop log.tag.UtunBle INFO

INFO: запуск/остановка, состояние адаптера, готовность advertising, сводка каждые 15 с. WARN/ERROR: переполнения, отказ API, некорректные пакеты, код/название ошибки BLE, callback timeout, ошибки очистки и отозванные права. Ошибка радио закрывает обе операции и запускает повтор через 30 с; поздние callbacks старого поколения игнорируются. Таймаут advertising callback — 10 с.

Unit tests проверяют wire-формат и лимит размера, unsigned поля, malformed данные, backpressure, дедупликацию/перестановки, переполнение RX, независимость сеансов, ограничение памяти, копирование payload, stop/start и одновременную запись из нескольких потоков. Радиоприём, Doze и поведение конкретного BLE-чипа требуют проверки на физических телефонах.

Выполненная проверка — 28.10.2026

11 unit tests PASS; lint приложения и библиотеки — без замечаний. Собраны debug APK и ble-transport/build/outputs/aar/ble-transport-debug.aar. На Q8 / Android 14 окончательная сборка выполнила 199 публикаций за короткий прогон: проверены работа со свёрнутой Activity и погашенным экраном, ручной TX через Binder, восстановление после off/on Bluetooth и явная остановка; итоговый счётчик ошибок — 0. Подключено одно устройство: физический приём между телефонами и длительный прогон ещё не проверены. Общий ./check.sh вне песочницы: 223 PASS / 3 FAIL из-за недоступного IPv6 loopback ::1 (test_ncd_faults, test_ipv6_sockets, test_route_connectivity); известная проблема среды уже описана в doc/tasks.md.

Проверка GATT API на двух телефонах

./gradlew :ble-transport:testDebugUnitTest :app:assembleDebug :app:assembleDebugAndroidTest :app:lintDebug :ble-transport:lintDebug
python3 tests/run_two_devices.py --install \
  --receiver Q8_SERIAL --receiver-id Q8_NODE_ID \
  --initiator SAMSUNG_SERIAL --initiator-id SAMSUNG_NODE_ID \
  --output /tmp/ble-gatt-duplex
# Та же команда с --reject и другой --output проверяет отказ валидации.

Runner запускает оба instrumentation процесса одновременно. BleApiProbe обращается к Binder API без GUI consumer, отправляет в обе стороны по 70 сообщений (69 × 512 + 4096 байт), проверяет содержимое и порядок, заполняет RX, выдерживает паузу без кредита и затем проверяет продолжение. После прогона сервис останавливается. Instrumentation код находится только в отдельном test APK. Модульных тестов — 18: discovery, все 64 бита ID, ACK window, фрагментация при MTU 23/64/247/517, нулевые/максимальные сообщения, duplex, неправильные ACK/offset/size, TX/RX backpressure и disconnect.

На Q8 и Samsung SM-A525F оба прогона PASS: разрешение/отказ callback и duplex по 70 сообщений, 39424 байта в каждом направлении, MTU 517. Обе стороны зафиксировали заполнение RX и возобновление после возврата кредита; содержимое и порядок проверены побайтово. 18 unit tests PASS, lint обоих модулей без замечаний. Повторный общий прогон: 223 PASS / те же 3 FAIL из-за недоступного ::1.