Files
utun2/tools/ble-test-android/README.md
T
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

252 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# uTun BLE Test
Отдельное Android-приложение для проверки BLE discovery и автоматических двусторонних GATT-соединений.
Android 8.0+, compile/target SDK 36, JDK 17. `:app` — тестовый экран;
`:ble-transport` — переиспользуемая Android-библиотека с сервисом и очередями, без зависимости от экрана.
## Сборка и запуск
```bash
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](https://developer.android.com/develop/connectivity/bluetooth/ble/background).
## Binder API
Сервис принадлежит приложению, `exported=false`, использует тот же процесс.
Запустите его из видимой Activity после выдачи `requiredPermissions()`, затем привяжитесь через `bindService`.
`onServiceConnected` получает `BleBroadcastService.LocalBinder`.
```kotlin
// Периодический источник произвольных байтов; 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)`.
```kotlin
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`:
```kotlin
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](https://developer.android.com/reference/android/bluetooth/le/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/очереди, шифрование и аутентификация — задача чата.
```bash
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 на двух телефонах
```bash
./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`.