diff --git a/build_direct.sh b/build_direct.sh index 7d258182e..e0e878eac 100644 --- a/build_direct.sh +++ b/build_direct.sh @@ -70,7 +70,7 @@ echo "Building utun.exe..." cd src EXE_NEEDS_REBUILD=0 -CORE_SOURCES="utun.c utun_instance.c config_parser.c config_updater.c routing_layer/route_lib.c topo_bgp.c routing_layer/routing.c tun_if.c tun_route.c transport_layer/etcp.c transport_layer/etcp_padding.c transport_layer/reality_record.c transport_layer/reality_io.c transport_layer/etcp_session.c transport_layer/etcp_connections.c transport_layer/etcp_loadbalancer.c transport_layer/secure_channel.c transport_layer/crc32.c transport_layer/pkt_normalizer.c transport_layer/etcp_api.c tun_windows.c" +CORE_SOURCES="utun.c utun_instance.c config_parser.c config_error.c config_updater.c routing_layer/route_lib.c topo_bgp.c routing_layer/routing.c tun_if.c tun_route.c transport_layer/etcp.c transport_layer/etcp_padding.c transport_layer/reality_record.c transport_layer/reality_io.c transport_layer/etcp_session.c transport_layer/etcp_connections.c transport_layer/etcp_loadbalancer.c transport_layer/secure_channel.c transport_layer/crc32.c transport_layer/pkt_normalizer.c transport_layer/etcp_api.c tun_windows.c" for src in $CORE_SOURCES; do if [ -f "$src" ]; then diff --git a/doc/ble_transport.txt b/doc/ble_transport.txt new file mode 100644 index 000000000..894af6200 --- /dev/null +++ b/doc/ble_transport.txt @@ -0,0 +1,227 @@ +BLE транспорт VibeChat (lib/ble) + +Область модуля +--------------- +Независимая от Qt библиотека предоставляет manufacturer-броадкасты и адресные +двусторонние GATT-сессии. Один локальный GATT-сервис обслуживает до 16 соединений. +Броадкаст — повторяемое ОС объявление, а не гарантированно доставляемая датаграмма. +Общий codec ble_identity.h: VC (2 байта), версия 1 (1), node_id BE64 (8), +имя UTF-8 (0..13). Имя сокращается по границе символа. Всего 11..24 байта. +Приглашения и автоматический выбор соседей — следующий слой; транспорт не запускает JOIN/ETCP. +Node ID передаётся в HELLO для сопоставления сессий; подлинность он не доказывает. + +Публичный API: lib/ble/ble_transport.h. +Платформенный контракт: lib/ble/ble_backend.h. +Desktop GUI запускает библиотеку по настройкам «Bluetooth» (по умолчанию выключена). + +Потоки и время жизни +------------------- +Все функции и callbacks выполняются в одном переданном uasync-потоке. +Из GUI отправлять команды через существующий gui_bridge_post_bound_fn/owned_fn. +Остановить BLE до остановки/уничтожения этого uasync; из callbacks запрещены +stop/destroy/disconnect (отложить действие в uasync). send/read допустимы. +Входящие аргументы callbacks заимствованы до возврата; endpoint можно скопировать. +ID соединения монотонен в пределах handle transport. Старый ID не переиспользуется +после stop/start; после destroy ни один ID/указатель этого transport не действителен. + +Порядок использования +-------------------- +1. ble_create(ua, config): задать ненулевой node_id, manufacturer company_id, + callbacks и context. Linux adapter=NULL выбирает первый адаптер, иначе hciN. + Windows использует системный адаптер, adapter должен быть NULL. +2. ble_start(): асинхронная регистрация сервиса и scanning; ждать status(BLE_READY). +3. ble_advertise(data,len): 0..24 произвольных байта; NULL,0 выключает объявление. + company_id должен быть одинаковым у участников. 0xffff — только эксперимент. + advertising(READY) означает регистрацию/запуск ОС, не получение соседом. + Обновления могут объединяться до последнего значения; точный период не задаётся. +4. broadcast callback получает endpoint, bytes и RSSI (127 = отсутствует). + ble_connect(endpoint, expected_node) самостоятельно выполняет GATT discovery, + подписку и HELLO. expected_node=0 принимает любой ненулевой ID кроме своего. +5. validate вызывается для полученного HELLO/ACCEPT до допуска кадров. NULL + разрешает всех; ненулевой результат callback разрешает конкретный запрос. + connection(READY) сообщает согласованную сессию. GATT/ACL само по себе не READY. +6. Для отправки выделить ll_alloc_lldgram(length), заполнить dgram и len, + вызвать ble_tx_put. При успехе entry принадлежит transport, иначе остаётся + вызывающему. WOULD_BLOCK: сохранить entry и ждать writable, затем повторить. +7. received уведомляет о целых кадрах. ble_rx_get извлекает ll_entry; + после обработки освободить queue_dgram_free(entry), queue_entry_free(entry). +8. ble_disconnect закрывает свою сессию. ble_destroy выполняет stop и освобождение. + +Память и backpressure +-------------------- +Для каждого link: ll_queue TX/RX до 64 кадров, один текущий TX и один RX-буфер +сборки. Текущий TX входит в лимит 64. writable выдаётся после снижения TX ниже +32 кадров, если производитель ранее получил WOULD_BLOCK. Не обращаться к +внутренним очередям напрямую: API сохраняет правила владения при переполнении. +RX при заполнении отбрасывает целый новый кадр с WARN; старые кадры сохраняются. +Кадр означает непрозрачный буфер длиной 1..1500 байт ВКЛЮЧАЯ Ethernet-заголовок, +если он присутствует. Это не Ethernet payload MTU 1500 (которому нужно >=1514). +На link не больше одной незавершённой GATT-передачи. Она хранит свой fragment +до завершения ОС. Очередь ядра/Qt не используется как неограниченный TX-буфер. +Переподключение не переносит старые кадры и не повторяет неоднозначные отправки. + +Wire protocol +------------- +UUID service: f61a9020-4abc-45ac-98e1-761df4c13811 +UUID RX: f61a9021-4abc-45ac-98e1-761df4c13811 (Write) +UUID TX: f61a9022-4abc-45ac-98e1-761df4c13811 (Indicate) +RX/TX названы относительно сервера; API одинаков для обеих ролей. +UUID определяют эту версию. Это отдельный протокол от Android BLE test v2; +автоматическую совместимость с tools/ble-test-android он не заявляет. + +Все многобайтные целые big-endian, без выравнивания C-структур. +HELLO: [type=1:1][source_node:8][target_node:8], Central -> Peripheral. +ACCEPT: [type=2:1][source_node:8][target_node:8], Peripheral -> Central. +DATA: [type=3:1][frame_id:2][total_len:2][offset:2][payload:1..]. +frame_id увеличивается modulo 65536 отдельно для каждого направления. +Размер значения <= min(ATT_MTU-3,512), минимальный ATT MTU=23. +Нулевой offset начинает новый кадр и заменяет незавершённую предыдущую сборку. +Далее обязательны тот же frame_id/total_len и offset==число принятых байтов. +Пропуск, неверная длина, новое начало или 15-секундный таймаут сбрасывают сборку; +в RX помещается только целый кадр. Собственных ACK/ретрансляций/окна протокола нет. +Write Request и Indication используют подтверждения стека. Это не подтверждение +чтения кадра приложением и не защита от заполнения его RX. + +Linux +----- +BlueZ D-Bus, клиент sd-bus из libsystemd; зависимость от Qt отсутствует. +Собственная приватная D-Bus connection: закрытие освобождает registrations и scan. +Основной процесс ОС не обязан быть systemd, но нужны системный D-Bus и bluetoothd. +Connectable-объявление UUID и пользовательский manufacturer-броадкаст занимают +два advertising instances при одновременном включении. Оба connectable: +адрес manufacturer-маяка должен позволять GATT connect (отдельный broadcast +instance может иметь другой random address). Нехватка ресурса — ошибка. +Входящий WriteValue содержит device; серверный AcquireNotify(device,mtu) получает +отдельный SOCK_SEQPACKET socketpair для каждого клиента. В fd пишется indication; +байт 1 в обратную сторону подтверждает её ATT completion. EAGAIN ждёт writable. +Value/Confirm без адреса клиента для серверной передачи не используются. +Выходящий клиент: WriteValue(type=request) и StartNotify для indicate-only TX. +Не вызывается глобальный Device1.Disconnect: ACL может обслуживать другие сервисы. +Проверено с BlueZ 5.85; требуется серверный AcquireNotify с indication completion. + +Windows +------- +WinRT BluetoothLEAdvertisementWatcher/Publisher и GattServiceProvider. +GATT-клиент использует WriteValueWithResultAsync, сервер — адресный +NotifyValueAsync(value, subscribedClient) для indicate-only характеристики. +Результаты WinRT сериализуются в uasync через bounded mailbox (256 событий). +Опоздавшие callbacks используют weak ownership и ID, закрытые handles не трогают. +Перед остановкой uasync снять источники событий, отменить операции и posted_task. +WinRT может сохранять физический ACL для других сервисов. Гарантия close относится +к нашей сессии и ресурсам; принудительный разрыв общего ACL в API не обещается. +Нужен адаптер с central+peripheral и Windows с GATT server API/BytesSent +(Windows 10 1709+); версия ОС сама по себе поддержку драйвером не гарантирует. +Радио Windows необходимо проверить на реальном Windows-ПК; Wine этого не заменяет. + +Сборка и проверка +---------------- +Linux: пакет libsystemd-dev и dbus-daemon (dbus-run-session). +MSYS2 UCRT64: mingw-w64-ucrt-x86_64-cppwinrt; C++20, windowsapp. + +cmake -S lib/ble -B /tmp/utun-ble-build -DCMAKE_BUILD_TYPE=Debug +cmake --build /tmp/utun-ble-build -j4 +ctest --test-dir /tmp/utun-ble-build --output-on-failure + +test_ble_transport: реальные common transports с управляемым backend; MTU +23/247/517, duplex, пропущенный фрагмент, TX/RX full, несколько пиров, +отказ validate и уничтожение во время отправки; проверяется учёт памяти. +test_ble_bluez: отдельный dbus-run-session с подставным BlueZ; настоящее +экспортирование сервиса/объявлений, два AcquireNotify fd, адресные подтверждения, +приём кадров и независимое закрытие. К системному Bluetooth тест не обращается. +ble_probe 3: ручная проверка реального радио (scan, GATT registration, публикация +и замена тестового manufacturer-объявления); не подключается к чужим устройствам. +Логи: discovery=debug. INFO — состояния; DEBUG — очереди и целые кадры; +WARN/ERROR — причины отброса/отказа. Содержимое кадров/объявлений не выводится. + +Отладка Bluetooth на Windows +--------------------------- +В существующей секции [debug] файла vibechat.cfg установить discovery=debug; +общий [gui] debug_level может оставаться info. Журнал задаёт [gui] debug_file +(по умолчанию chatgui.log рядом с конфигом). После изменения фильтра перезапустить GUI. +Ранние ошибки подготовки INI всегда пишутся в chatgui.log до чтения настроек лога; +их точные причины также показаны в окне ошибки независимо от фильтра. + +DEBUG: адаптер (BLE, central/peripheral, extended advertising, поддержка 2M/Coded), +этап/результат каждой служебной async-операции, состояния рекламы и сканирования, +адрес и роль соединения, список UUID/свойств характеристик, CCCD, MTU/PDU, +целые TX/RX-пакеты и backpressure, закрытие с pending-состоянием и очистка mailbox. +HRESULT записывается с названием операции и текстом Windows; BluetoothError — +числом и именем, GATT status — числом, ATT protocol error — числом либо -1 (нет). +GATT status: 0=Success, 1=Unreachable, 2=ProtocolError, 3=AccessDenied. +PHY_2M_supported — возможность адаптера, НЕ измеренная скорость соединения; +-1 у необязательных возможностей означает отсутствие API в этой версии Windows. +Для каждого отдельного ATT-фрагмента временно использовать discovery=trace. +На DEBUG фрагменты/каждое повторное радиообъявление не засоряют журнал. + +Desktop GUI +----------- +Настройки → Bluetooth: включить транспорт, рекламировать ID/имя, принимать входящие. +В [gui] bluetooth_flags: bit 0 — транспорт, bit 1 — объявление, bit 2 — входящие; +по умолчанию 0. Save сохраняет флаги и будит uasync, без перезапуска ядра. +Системный переключатель Bluetooth пользователь включает в ОС. +DesktopBle создаётся/уничтожается в runLoop UtunNode и использует общий C API. +GUI читает копию состояния; список обнаружения ограничен 64 узлами с TTL 30 секунд. +Изменение имени обновляет объявление. Входящая валидация пока разрешает всех. +UtunNode::bluetoothTransport() — заимствованный handle ТОЛЬКО для uasync-потока; +кадры остаются в RX-очередях до чтения потребителем. Автоматических JOIN/ETCP нет. +Ручная проверка с Android BlePeerProbe initiator=true: build/ble_desktop_probe. +Проба проверяет готовность радио/объявления, обнаружение и входящую GATT-сессию, +затем останавливает transport до уничтожения uasync. + +Android +------- +Общие ble_transport.c/ble_identity.c компилируются в libutun.so; android_ble.c +реализует ble_backend.h через BleRadio.kt (Android BluetoothGatt/Server/LE). +Данные и callbacks core — в instance_lite uasync. Android callbacks сериализованы +HandlerThread, JNI mailbox ограничен 256 событиями, каждое несёт generation. +eventfd немедленно будит uasync при новом событии; polling-таймера между фрагментами нет. +Переполнение mailbox останавливает radio с ошибкой. На соединение не более одной +ожидающей отправки; сервер indications обслуживает адресно, по очереди. +Политика входящих ble_set_incoming закрывает только входящие сессии. +Настройки используют DataStore; Bluetooth off останавливает только радио приложения. +Android 12+: SCAN/CONNECT/ADVERTISE runtime permissions; системное включение BT +через ACTION_REQUEST_ENABLE. На Android 8–11 сканирование требует геопозицию. +Длительная работа принадлежит существующему HeadlessService (connectedDevice). +Перед teardown worker вызывает android_ble_detach, старые callbacks отбрасываются. +android_ble_transport() возвращает общий C handle только для использования в worker. +В обычном режиме нет автоматического connect/echo или подключения пакетов к ETCP. + +Физическая проверка +------------------- +./gradlew assembleDebug assembleDebugAndroidTest (в tools/chatgui-android) +adb install -r app/build/outputs/apk/debug/app-debug.apk +adb install -r app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk +Выдать приложению разрешения Bluetooth, включить системный Bluetooth. +Запустить одновременно: + adb shell am instrument -w -e seconds 90 -e initiator false \ + com.utun.chat.test/com.utun.chat.BlePeerProbe + /tmp/utun-ble-build/ble_peer_probe connect 75 +Обратные роли: initiator=true и ble_peer_probe listen 75. +Instrumentation временно включает echo, возвращает сохранённую политику после +теста. Успех определяет desktop RESULT ok=1: 96 пакетов размеров +1/13/14/20/244/512/1499/1500, побайтовая сверка, TX WOULD_BLOCK и writable. +Не запускать этот тест одновременно с обычным использованием приложения. + +Профиль скорости Android +------------------------ +После подключения client просит CONNECTION_PRIORITY_HIGH и ATT MTU 517. +Обе роли запрашивают PHY_LE_2M для TX/RX при поддержке локального контроллера. +Запрос не гарантирует смену PHY: onPhyUpdate/onPhyRead логируют фактические TX/RX. +При отсутствии поддержки остаётся согласованный PHY. S2/S8 — параметры только +LE Coded PHY (дальность/скорость), с выбранным 2M применяется NO_PREFERRED. +У Android BluetoothGattServer нет public requestConnectionPriority; интервал +для входящего подключения согласует central. Сам UUID/advertising остаётся legacy +1M для обнаружения; PHY 2M относится к установленному соединению. + +Проверка 2026-10-29 +------------------ +Физический Q8 DA6B8P01, Android 14 (API 34), Linux BlueZ на этом ПК. +Обе роли подключения: RESULT ok=1, sent=96, verified=96, blocked=1, discovered=1. +Каждое направление: 45636 байт с побайтовой сверкой; пакеты до 1500 включительно. +После профиля скорости: desktop central 19.801 с, Android central HIGH 9.977 с. +Это elapsed теста с подтверждениями/echo, не измерение предельной канальной скорости. +Q8 сообщает isLe2MPhySupported=false; readPhy возвращает status=6. PHY 2M +реализован как запрос, его фактическое согласование на этом телефоне не проверено. +Общий C codec/transport и BlueZ mock: ASAN+UBSAN, leak detection — 3/3 PASS. +Сборки Android arm64-v8a/x86_64, desktop GUI Linux и WinRT cross-build проходят. +Физический Windows radio и несколько одновременных Android-клиентов не проверены. diff --git a/doc/tasks.md b/doc/tasks.md index 0eaf99792..e2b430100 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -4,6 +4,11 @@ ## Текущие задачи +[ ] **Windows: единые UTF-8 пути конфигурации и раннего журнала** — 29.10.2026. + GUI передаёт toUtf8, а config_parser/config_updater/debug_config используют fopen и ANSI Win32 API. + Перевести эти операции на существующий file_io с wide-путями, проверить профиль в каталоге с кириллицей. + Это отдельный риск; причиной отказа присланного 2.log на Windows он не подтверждён. + [+] **Вложения: отправка независимо от лимита автозагрузки, метаданные фото/видео и результат GUI** — 28.10.2026. Исходящие файлы не ограничиваются storage_unit_size; defaults автозагрузки/кеша — 100 MiB/1 GiB. Парсер принимает подписанный хвост dur/w/h; канальный файл остаётся в composer до подтверждения, отказ показывает имя. diff --git a/lib/ble/CMakeLists.txt b/lib/ble/CMakeLists.txt new file mode 100644 index 000000000..0f855809f --- /dev/null +++ b/lib/ble/CMakeLists.txt @@ -0,0 +1,60 @@ +cmake_minimum_required(VERSION 3.16) +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + project(vibechat_ble LANGUAGES C CXX) + enable_testing() + set(CMAKE_C_STANDARD 99) + set(BLE_LIB "${CMAKE_CURRENT_SOURCE_DIR}/..") + add_library(uasync STATIC ${BLE_LIB}/u_async.c ${BLE_LIB}/ll_queue.c ${BLE_LIB}/timeout_heap.c + ${BLE_LIB}/memory_pool.c ${BLE_LIB}/mem.c ${BLE_LIB}/debug_config.c + ${BLE_LIB}/platform_compat.c ${BLE_LIB}/socket_compat.c) + target_include_directories(uasync PUBLIC "${BLE_LIB}") + if(WIN32) + target_link_libraries(uasync PUBLIC ws2_32 iphlpapi) + else() + target_link_libraries(uasync PUBLIC pthread m) + endif() +endif() +add_library(vibechat_ble_core OBJECT ble_transport.c ble_identity.c) +target_link_libraries(vibechat_ble_core PUBLIC uasync) +target_include_directories(vibechat_ble_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}") +set_target_properties(vibechat_ble_core PROPERTIES AUTOMOC OFF) +if(WIN32) + add_library(vibechat_ble STATIC ble_winrt.cpp) + target_compile_features(vibechat_ble PRIVATE cxx_std_20) + find_path(BLE_WINRT_INCLUDE winrt/Windows.Devices.Bluetooth.h REQUIRED) + target_include_directories(vibechat_ble PRIVATE "${BLE_WINRT_INCLUDE}") + target_link_libraries(vibechat_ble PRIVATE windowsapp) +elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") + find_package(PkgConfig REQUIRED) + pkg_check_modules(BLE_SYSTEMD REQUIRED IMPORTED_TARGET libsystemd) + add_library(vibechat_ble STATIC ble_bluez.c) + target_link_libraries(vibechat_ble PRIVATE PkgConfig::BLE_SYSTEMD) +else() + message(FATAL_ERROR "BLE backend supports Linux and Windows") +endif() +target_link_libraries(vibechat_ble PUBLIC vibechat_ble_core) +set_target_properties(vibechat_ble PROPERTIES AUTOMOC OFF) +add_executable(test_ble_transport tests/test_ble_transport.c) +target_link_libraries(test_ble_transport PRIVATE vibechat_ble_core) +set_target_properties(test_ble_transport PROPERTIES AUTOMOC OFF) +add_test(NAME test_ble_transport COMMAND test_ble_transport) +set_tests_properties(test_ble_transport PROPERTIES TIMEOUT 30) +add_executable(ble_probe tests/ble_probe.c) +target_link_libraries(ble_probe PRIVATE vibechat_ble) +set_target_properties(ble_probe PROPERTIES AUTOMOC OFF) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + find_program(BLE_DBUS_RUN_SESSION dbus-run-session REQUIRED) + add_executable(test_ble_bluez tests/test_ble_bluez.c) + target_link_libraries(test_ble_bluez PRIVATE vibechat_ble PkgConfig::BLE_SYSTEMD) + set_target_properties(test_ble_bluez PROPERTIES AUTOMOC OFF) + add_test(NAME test_ble_bluez COMMAND "${BLE_DBUS_RUN_SESSION}" -- $) + set_tests_properties(test_ble_bluez PROPERTIES TIMEOUT 30) +endif() +add_executable(ble_peer_probe tests/ble_peer_probe.c) +target_link_libraries(ble_peer_probe PRIVATE vibechat_ble) +set_target_properties(ble_peer_probe PROPERTIES AUTOMOC OFF) +add_executable(test_ble_identity tests/test_ble_identity.c ble_identity.c) +target_link_libraries(test_ble_identity PRIVATE uasync) +target_include_directories(test_ble_identity PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") +set_target_properties(test_ble_identity PROPERTIES AUTOMOC OFF) +add_test(NAME test_ble_identity COMMAND test_ble_identity) diff --git a/lib/ble/ble_backend.h b/lib/ble/ble_backend.h new file mode 100644 index 000000000..bfc0c4a48 --- /dev/null +++ b/lib/ble/ble_backend.h @@ -0,0 +1,35 @@ +/* Внутренний платформенный контракт. Все вызовы/события в uasync, send/close не вызывают события синхронно. + * ACCEPTED send копирует значение и даёт ровно один sent, либо closed отменяет все операции соединения. + * close/destroy отменяют callbacks; transport создаёт идентификаторы, backend не хранит указатели core-link. + * Реализации не создают неограниченных очередей: максимум одна операция send на link. */ +#ifndef VIBECHAT_BLE_BACKEND_H +#define VIBECHAT_BLE_BACKEND_H +#include "ble_transport.h" +#ifdef __cplusplus +extern "C" { +#endif +#define BLE_SERVICE_UUID "f61a9020-4abc-45ac-98e1-761df4c13811" +#define BLE_RX_UUID "f61a9021-4abc-45ac-98e1-761df4c13811" +#define BLE_TX_UUID "f61a9022-4abc-45ac-98e1-761df4c13811" +#define BLE_VALUE_MAX 512 +struct ble_backend; +struct ble_backend* ble_backend_create(struct ble_transport* owner, struct UASYNC* ua, const struct ble_config* config); +void ble_backend_destroy(struct ble_backend* backend); +int ble_backend_advertise(struct ble_backend* backend, const uint8_t* data, size_t len); +int ble_backend_connect(struct ble_backend* backend, ble_link_id id, const struct ble_endpoint* endpoint); +void ble_backend_close(struct ble_backend* backend, ble_link_id id); +int ble_backend_send(struct ble_backend* backend, ble_link_id id, const uint8_t* data, size_t len); +/* События backend. incoming вызывается до любых других событий нового входящего link; 0 = лимит/отказ. */ +ble_link_id ble_backend_incoming(struct ble_transport* owner, const struct ble_endpoint* endpoint); +void ble_backend_status(struct ble_transport* owner, int ready, const char* reason); +void ble_backend_advertising(struct ble_transport* owner, enum ble_state state, const char* reason); +void ble_backend_broadcast(struct ble_transport* owner, const struct ble_endpoint* endpoint, + const uint8_t* data, size_t len, int rssi); +void ble_backend_opened(struct ble_transport* owner, ble_link_id id, unsigned int mtu); +void ble_backend_received(struct ble_transport* owner, ble_link_id id, const uint8_t* data, size_t len); +void ble_backend_sent(struct ble_transport* owner, ble_link_id id, int success); +void ble_backend_closed(struct ble_transport* owner, ble_link_id id, const char* reason); +#ifdef __cplusplus +} +#endif +#endif diff --git a/lib/ble/ble_bluez.c b/lib/ble/ble_bluez.c new file mode 100644 index 000000000..01693cc25 --- /dev/null +++ b/lib/ble/ble_bluez.c @@ -0,0 +1,556 @@ +/* BlueZ D-Bus через sd-bus (без Qt/GLib). Один uasync-поток, без блокирующих вызовов. + * Серверные indications адресуются через отдельный AcquireNotify fd каждого клиента. + * Успешный send(fd) только ставит indication; байт 1 от BlueZ подтверждает ATT completion. */ +#include "ble_backend.h" +#include "../u_async.h" +#include "../mem.h" +#include "../debug_config.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#define CAT DEBUG_CATEGORY_DISCOVERY +#define ROOT "/org/vibechat/ble" +#define SERVICE ROOT "/service0" +#define RX SERVICE "/rx" +#define TX SERVICE "/tx" +#define ADV ROOT "/advertisement" +#define BEACON ROOT "/beacon" +#define DEVICE_IF "org.bluez.Device1" +#define CHAR_IF "org.bluez.GattCharacteristic1" +struct bluez_link { + struct ble_backend* backend; + ble_link_id id; + char device[BLE_ENDPOINT_MAX], rx[BLE_ENDPOINT_MAX], tx[BLE_ENDPOINT_MAX]; + sd_bus_slot *connect, *query, *subscribe, *write; + int fd, outgoing, opened, sending, buffered; + void* io; + unsigned int mtu; + uint8_t value[BLE_VALUE_MAX]; + size_t value_size; +}; +struct characteristic { struct ble_backend* backend; int tx; }; +struct advertisement { struct ble_backend* backend; int beacon; }; +struct ble_backend { + struct ble_transport* owner; + struct UASYNC* ua; + sd_bus* bus; + sd_bus_slot *setup, *signals, *objects, *removed, *owner_watch, *service, *rx, *tx, *manager, *adv, *beacon, *beacon_op; + struct characteristic rx_context, tx_context; + struct advertisement adv_context, beacon_context; + struct bluez_link links[BLE_LINKS_MAX]; + char adapter[BLE_ENDPOINT_MAX], adapter_name[64], daemon_owner[BLE_ENDPOINT_MAX]; + uint16_t company; + uint8_t data[BLE_BROADCAST_MAX], next_data[BLE_BROADCAST_MAX]; + size_t data_size, next_size; + int beacon_registered, beacon_desired, beacon_action, step, failed, processing; + void *io, *timer; +}; +static void bus_update(struct ble_backend* b); +static void bus_timer(void* arg); +static void query_link(struct bluez_link* l); +static void beacon_update(struct ble_backend* b); +static void fail(struct ble_backend* b, const char* where, int error) { + if (b->failed) return; + b->failed = 1; + DEBUG_ERROR(CAT, "BLE BlueZ %s: %s (%d)", where, strerror(error < 0 ? -error : error), error); + ble_backend_status(b->owner, 0, where); +} +static int response_error(sd_bus_message* m, const char* where) { + const sd_bus_error* e = sd_bus_message_get_error(m); + if (!e) return 0; + DEBUG_ERROR(CAT, "BLE BlueZ %s: %s: %s", where, e->name, e->message ? e->message : ""); return 1; +} +static void bus_process(struct ble_backend* b) { + if (b->processing || b->failed) return; + b->processing = 1; + int r = 0; + for (int i = 0; i < 64; i++) { r = sd_bus_process(b->bus, NULL); if (r <= 0 || b->failed) break; } + b->processing = 0; + if (r < 0) { fail(b, "D-Bus disconnected", r); return; } + bus_update(b); + if (r > 0 && !b->failed) { + DEBUG_DEBUG(CAT, "BLE D-Bus drain budget exhausted; continuing buffered messages"); + if (b->timer) uasync_cancel_timeout(b->ua, b->timer); + b->timer = uasync_set_timeout(b->ua, 0, b, bus_timer, "ble_dbus_drain"); + if (!b->timer) fail(b, "D-Bus drain allocation", ENOMEM); + } +} +static void bus_io(int fd, void* arg) { (void)fd; bus_process(arg); } +static void bus_timer(void* arg) { struct ble_backend* b = arg; b->timer = NULL; bus_process(b); } +static void bus_update(struct ble_backend* b) { + if (b->processing || b->failed) return; + int events = sd_bus_get_events(b->bus); + uint64_t timeout = UINT64_MAX; + int r = sd_bus_get_timeout(b->bus, &timeout); + if (events < 0 || r < 0) { fail(b, "D-Bus polling failed", events < 0 ? events : r); return; } + uasync_set_socket_read(b->ua, b->io, !!(events & POLLIN)); + uasync_set_socket_write(b->ua, b->io, !!(events & POLLOUT)); + if (b->timer) { uasync_cancel_timeout(b->ua, b->timer); b->timer = NULL; } + if (timeout != UINT64_MAX) { + struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); + uint64_t now = (uint64_t)ts.tv_sec * 1000000 + ts.tv_nsec / 1000; + uint64_t delay = timeout > now ? (timeout - now + 99) / 100 : 0; + b->timer = uasync_set_timeout(b->ua, delay > INT_MAX ? INT_MAX : (int)delay, b, bus_timer, "ble_dbus"); + if (!b->timer) fail(b, "D-Bus timer allocation", ENOMEM); + } +} +static struct bluez_link* find_link(struct ble_backend* b, ble_link_id id) { + for (int i = 0; i < BLE_LINKS_MAX; i++) if (b->links[i].id == id && id) return &b->links[i]; + return NULL; +} +static struct bluez_link* find_device(struct ble_backend* b, const char* device) { + for (int i = 0; i < BLE_LINKS_MAX; i++) if (b->links[i].id && !strcmp(b->links[i].device, device)) return &b->links[i]; + return NULL; +} +static struct bluez_link* alloc_link(struct ble_backend* b, ble_link_id id, const char* device, int outgoing) { + if (strlen(device) >= BLE_ENDPOINT_MAX || find_device(b, device)) return NULL; + for (int i = 0; i < BLE_LINKS_MAX; i++) if (!b->links[i].id) { + struct bluez_link* l = &b->links[i]; memset(l, 0, sizeof(*l)); + l->backend = b; l->id = id; l->fd = -1; l->mtu = 23; l->outgoing = outgoing; strcpy(l->device, device); return l; + } + return NULL; +} +static struct bluez_link* incoming(struct ble_backend* b, const char* device) { + struct bluez_link* l = find_device(b, device); + if (l) return l->outgoing ? NULL : l; + struct ble_endpoint ep = {0}; + if (strlen(device) >= sizeof(ep.id)) return NULL; + strcpy(ep.id, device); + ble_link_id id = ble_backend_incoming(b->owner, &ep); + if (!id) return NULL; + l = alloc_link(b, id, device, 0); + if (!l) ble_backend_closed(b->owner, id, "BlueZ link allocation failed"); + return l; +} +static void link_error(struct bluez_link* l, const char* reason) { + DEBUG_WARN(CAT, "BLE BlueZ link=%llu failed: %s", (unsigned long long)l->id, reason); + ble_backend_closed(l->backend->owner, l->id, reason); +} +static void notify_read(int fd, void* arg) { + struct bluez_link* l = arg; + uint8_t data[8]; ssize_t n = recv(fd, data, sizeof(data), MSG_DONTWAIT); + if (n < 0 && (errno == EAGAIN || errno == EINTR)) return; + if (n != 1 || data[0] != 1 || !l->sending || l->buffered) { link_error(l, "invalid indication completion/closed fd"); return; } + l->sending = 0; + ble_backend_sent(l->backend->owner, l->id, 1); +} +static void notify_write(int fd, void* arg) { + struct bluez_link* l = arg; + if (!l->buffered) { uasync_set_socket_write(l->backend->ua, l->io, 0); return; } + ssize_t n = send(fd, l->value, l->value_size, MSG_DONTWAIT | MSG_NOSIGNAL); + if (n < 0 && (errno == EAGAIN || errno == EINTR)) return; + if (n != (ssize_t)l->value_size) { link_error(l, "indication socket write failed"); return; } + l->buffered = 0; uasync_set_socket_write(l->backend->ua, l->io, 0); +} +static int options(sd_bus_message* m, const char** device, uint16_t* mtu, uint16_t* offset) { + int r = sd_bus_message_enter_container(m, 'a', "{sv}"); + if (r < 0) return r; + const char *key, *type; + while ((r = sd_bus_message_enter_container(m, 'e', "sv")) > 0) { + if ((r = sd_bus_message_read(m, "s", &key)) < 0 || (r = sd_bus_message_peek_type(m, NULL, &type)) < 0) return r; + if ((r = sd_bus_message_enter_container(m, 'v', type)) < 0) return r; + if (!strcmp(key, "device") && !strcmp(type, "o")) r = sd_bus_message_read(m, "o", device); + else if (!strcmp(key, "mtu") && !strcmp(type, "q")) r = sd_bus_message_read(m, "q", mtu); + else if (!strcmp(key, "offset") && !strcmp(type, "q")) r = sd_bus_message_read(m, "q", offset); + else r = sd_bus_message_skip(m, type); + if (r < 0) return r; + sd_bus_message_exit_container(m); sd_bus_message_exit_container(m); + } + if (r < 0) return r; + return sd_bus_message_exit_container(m); +} +static int reject(sd_bus_message* m, const char* reason) { + DEBUG_WARN(CAT, "BLE BlueZ request rejected: %s", reason); + return sd_bus_reply_method_errorf(m, "org.bluez.Error.Failed", "%s", reason); +} +static int write_value(sd_bus_message* m, void* arg, sd_bus_error* error) { + (void)error; struct characteristic* ctx = arg; struct ble_backend* b = ctx->backend; + const void* data; size_t len; const char* device = NULL; uint16_t mtu = 23, offset = 0; + if (!sd_bus_message_get_sender(m) || strcmp(sd_bus_message_get_sender(m), b->daemon_owner)) return reject(m, "sender is not BlueZ"); + if (ctx->tx || sd_bus_message_read_array(m, 'y', &data, &len) < 0 || options(m, &device, &mtu, &offset) < 0 || + !device || offset || !len || len > BLE_VALUE_MAX || mtu < 23) return reject(m, "invalid WriteValue"); + struct bluez_link* l = incoming(b, device); + if (!l) return reject(m, "connection unavailable"); + ble_link_id id = l->id; + ble_backend_received(b->owner, id, data, len); + if (!find_link(b, id)) return reject(m, "session rejected"); + return sd_bus_reply_method_return(m, ""); +} +static int acquire_notify(sd_bus_message* m, void* arg, sd_bus_error* error) { + (void)error; struct characteristic* ctx = arg; struct ble_backend* b = ctx->backend; + const char* device = NULL; uint16_t mtu = 23, offset = 0; + if (!sd_bus_message_get_sender(m) || strcmp(sd_bus_message_get_sender(m), b->daemon_owner)) return reject(m, "sender is not BlueZ"); + if (!ctx->tx || options(m, &device, &mtu, &offset) < 0 || !device || offset || mtu < 23) return reject(m, "invalid AcquireNotify"); + struct bluez_link* l = incoming(b, device); + if (!l || l->fd >= 0) return reject(m, "subscription unavailable"); + int pair[2]; + if (socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_NONBLOCK | SOCK_CLOEXEC, 0, pair) < 0) return reject(m, "socketpair failed"); + l->fd = pair[0]; l->mtu = mtu; + l->io = uasync_add_socket(b->ua, l->fd, notify_read, notify_write, notify_read, "ble_indicate", l); + if (!l->io) { close(pair[0]); close(pair[1]); l->fd = -1; return reject(m, "fd registration failed"); } + uasync_set_socket_write(b->ua, l->io, 0); + int r = sd_bus_reply_method_return(m, "hq", pair[1], mtu); close(pair[1]); + if (r < 0) { link_error(l, "AcquireNotify reply failed"); return r; } + l->opened = 1; + ble_backend_opened(b->owner, l->id, mtu); bus_update(b); return r; +} +static int get_service(sd_bus* bus, const char* path, const char* interface, const char* property, + sd_bus_message* reply, void* arg, sd_bus_error* error) { + (void)bus; (void)path; (void)interface; (void)arg; (void)error; + return !strcmp(property, "UUID") ? sd_bus_message_append(reply, "s", BLE_SERVICE_UUID) : sd_bus_message_append(reply, "b", 1); +} +static int get_char(sd_bus* bus, const char* path, const char* interface, const char* property, + sd_bus_message* reply, void* arg, sd_bus_error* error) { + (void)bus; (void)path; (void)interface; (void)error; struct characteristic* ctx = arg; + if (!strcmp(property, "UUID")) return sd_bus_message_append(reply, "s", ctx->tx ? BLE_TX_UUID : BLE_RX_UUID); + if (!strcmp(property, "Service")) return sd_bus_message_append(reply, "o", SERVICE); + if (!strcmp(property, "Flags")) return sd_bus_message_append(reply, "as", 1, ctx->tx ? "indicate" : "write"); + return sd_bus_message_append(reply, "b", 0); /* NotifyAcquired объявляет поддержку адресного fd API. */ +} +static int get_adv(sd_bus* bus, const char* path, const char* interface, const char* property, + sd_bus_message* reply, void* arg, sd_bus_error* error) { + (void)bus; (void)path; (void)interface; (void)error; + struct advertisement* a = arg; struct ble_backend* b = a->backend; + if (!strcmp(property, "Type")) return sd_bus_message_append(reply, "s", "peripheral"); + if (!strcmp(property, "ServiceUUIDs")) return sd_bus_message_append(reply, "as", 1, BLE_SERVICE_UUID); + int r = sd_bus_message_open_container(reply, 'a', "{qv}"); + if (r >= 0) r = sd_bus_message_open_container(reply, 'e', "qv"); + if (r >= 0) r = sd_bus_message_append(reply, "q", b->company); + if (r >= 0) r = sd_bus_message_open_container(reply, 'v', "ay"); + if (r >= 0) r = sd_bus_message_append_array(reply, 'y', b->data, b->data_size); + if (r >= 0) r = sd_bus_message_close_container(reply); + if (r >= 0) r = sd_bus_message_close_container(reply); + if (r >= 0) r = sd_bus_message_close_container(reply); + return r; +} +static int adv_release(sd_bus_message* m, void* arg, sd_bus_error* error) { + (void)error; struct advertisement* a = arg; + DEBUG_WARN(CAT, "BLE advertisement released by BlueZ beacon=%d", a->beacon); + if (a->beacon) { + a->backend->beacon_registered = 0; + ble_backend_advertising(a->backend->owner, BLE_CLOSED, "advertisement released"); + } + else fail(a->backend, "connectable advertisement released", EIO); + return sd_bus_reply_method_return(m, ""); +} +static const sd_bus_vtable service_table[] = { + SD_BUS_VTABLE_START(0), SD_BUS_PROPERTY("UUID", "s", get_service, 0, SD_BUS_VTABLE_PROPERTY_CONST), + SD_BUS_PROPERTY("Primary", "b", get_service, 0, SD_BUS_VTABLE_PROPERTY_CONST), SD_BUS_VTABLE_END +}; +static const sd_bus_vtable rx_table[] = { + SD_BUS_VTABLE_START(0), SD_BUS_PROPERTY("UUID", "s", get_char, 0, SD_BUS_VTABLE_PROPERTY_CONST), + SD_BUS_PROPERTY("Service", "o", get_char, 0, SD_BUS_VTABLE_PROPERTY_CONST), + SD_BUS_PROPERTY("Flags", "as", get_char, 0, SD_BUS_VTABLE_PROPERTY_CONST), + SD_BUS_METHOD("WriteValue", "aya{sv}", "", write_value, SD_BUS_VTABLE_UNPRIVILEGED), SD_BUS_VTABLE_END +}; +static const sd_bus_vtable tx_table[] = { + SD_BUS_VTABLE_START(0), SD_BUS_PROPERTY("UUID", "s", get_char, 0, SD_BUS_VTABLE_PROPERTY_CONST), + SD_BUS_PROPERTY("Service", "o", get_char, 0, SD_BUS_VTABLE_PROPERTY_CONST), + SD_BUS_PROPERTY("Flags", "as", get_char, 0, SD_BUS_VTABLE_PROPERTY_CONST), + SD_BUS_PROPERTY("NotifyAcquired", "b", get_char, 0, 0), + SD_BUS_METHOD("AcquireNotify", "a{sv}", "hq", acquire_notify, SD_BUS_VTABLE_UNPRIVILEGED), SD_BUS_VTABLE_END +}; +static const sd_bus_vtable adv_table[] = { + SD_BUS_VTABLE_START(0), SD_BUS_PROPERTY("Type", "s", get_adv, 0, SD_BUS_VTABLE_PROPERTY_CONST), + SD_BUS_PROPERTY("ServiceUUIDs", "as", get_adv, 0, SD_BUS_VTABLE_PROPERTY_CONST), + SD_BUS_METHOD("Release", "", "", adv_release, SD_BUS_VTABLE_UNPRIVILEGED), SD_BUS_VTABLE_END +}; +static const sd_bus_vtable beacon_table[] = { + SD_BUS_VTABLE_START(0), SD_BUS_PROPERTY("Type", "s", get_adv, 0, SD_BUS_VTABLE_PROPERTY_CONST), + SD_BUS_PROPERTY("ManufacturerData", "a{qv}", get_adv, 0, SD_BUS_VTABLE_PROPERTY_CONST), + SD_BUS_METHOD("Release", "", "", adv_release, SD_BUS_VTABLE_UNPRIVILEGED), SD_BUS_VTABLE_END +}; +static int subscribed(sd_bus_message* m, void* arg, sd_bus_error* error) { + (void)error; struct bluez_link* l = arg; l->subscribe = sd_bus_slot_unref(l->subscribe); + if (response_error(m, "StartNotify")) { link_error(l, "subscription failed"); return 0; } + l->opened = 1; ble_backend_opened(l->backend->owner, l->id, l->mtu); return 0; +} +static int properties(struct ble_backend* b, const char* path, const char* interface, sd_bus_message* m) { + const char *key, *type, *uuid = NULL; + int r, resolved = 0, disconnected = 0, rssi = 127, have_data = 0; + uint16_t mtu = 23; + uint8_t data[BLE_BROADCAST_MAX]; size_t data_size = 0; + if ((r = sd_bus_message_enter_container(m, 'a', "{sv}")) < 0) return r; + while ((r = sd_bus_message_enter_container(m, 'e', "sv")) > 0) { + if ((r = sd_bus_message_read(m, "s", &key)) < 0 || (r = sd_bus_message_peek_type(m, NULL, &type)) < 0) return r; + if ((r = sd_bus_message_enter_container(m, 'v', type)) < 0) return r; + if (!strcmp(interface, DEVICE_IF) && !strcmp(key, "ManufacturerData") && !strcmp(type, "a{qv}")) { + sd_bus_message_enter_container(m, 'a', "{qv}"); + while ((r = sd_bus_message_enter_container(m, 'e', "qv")) > 0) { + uint16_t company; const char* value_type; + sd_bus_message_read(m, "q", &company); sd_bus_message_peek_type(m, NULL, &value_type); + sd_bus_message_enter_container(m, 'v', value_type); + if (company == b->company && !strcmp(value_type, "ay")) { + const void* bytes; size_t size; + r = sd_bus_message_read_array(m, 'y', &bytes, &size); + if (r >= 0 && size <= sizeof(data)) { memcpy(data, bytes, size); data_size = size; have_data = 1; } + else DEBUG_WARN(CAT, "BLE rejected advertisement size=%zu", r < 0 ? 0 : size); + } else sd_bus_message_skip(m, value_type); + sd_bus_message_exit_container(m); sd_bus_message_exit_container(m); + } + sd_bus_message_exit_container(m); + } else if (!strcmp(interface, DEVICE_IF) && !strcmp(key, "RSSI") && !strcmp(type, "n")) { + int16_t value; r = sd_bus_message_read(m, "n", &value); rssi = value; + } else if (!strcmp(interface, DEVICE_IF) && !strcmp(type, "b") && (!strcmp(key, "Connected") || !strcmp(key, "ServicesResolved"))) { + int value; r = sd_bus_message_read(m, "b", &value); + if (!strcmp(key, "Connected")) disconnected = !value; else resolved = value; + } else if (!strcmp(interface, CHAR_IF) && !strcmp(key, "UUID") && !strcmp(type, "s")) r = sd_bus_message_read(m, "s", &uuid); + else if (!strcmp(interface, CHAR_IF) && !strcmp(key, "MTU") && !strcmp(type, "q")) r = sd_bus_message_read(m, "q", &mtu); + else if (!strcmp(interface, CHAR_IF) && !strcmp(key, "Value") && !strcmp(type, "ay")) { + const void* bytes; size_t size; r = sd_bus_message_read_array(m, 'y', &bytes, &size); + if (r >= 0) for (int i = 0; i < BLE_LINKS_MAX; i++) { + struct bluez_link* l = &b->links[i]; + if (size && l->id && l->outgoing && !strcmp(l->tx, path)) ble_backend_received(b->owner, l->id, bytes, size); + } + } else r = sd_bus_message_skip(m, type); + if (r < 0) return r; + sd_bus_message_exit_container(m); sd_bus_message_exit_container(m); + } + if (r < 0) return r; + sd_bus_message_exit_container(m); + if (!strcmp(interface, "org.bluez.Adapter1") && !b->adapter[0]) { + const char* name = strrchr(path, '/'); + if (strlen(path) < sizeof(b->adapter) && (!b->adapter_name[0] || (name && !strcmp(name + 1, b->adapter_name)))) strcpy(b->adapter, path); + } + if (!strcmp(interface, DEVICE_IF)) { + struct bluez_link* l = find_device(b, path); + if (l && disconnected) link_error(l, "device disconnected"); + else if (l && resolved && l->outgoing && !l->opened && !l->query) query_link(l); + if (have_data && strlen(path) < BLE_ENDPOINT_MAX) { + struct ble_endpoint ep = {0}; strcpy(ep.id, path); ble_backend_broadcast(b->owner, &ep, data, data_size, rssi); + } + } + if (uuid) for (int i = 0; i < BLE_LINKS_MAX; i++) { + struct bluez_link* l = &b->links[i]; size_t prefix = strlen(l->device); + if (!l->id || !l->outgoing || l->opened || strncmp(path, l->device, prefix) || path[prefix] != '/' || strlen(path) >= BLE_ENDPOINT_MAX) continue; + if (!strcmp(uuid, BLE_RX_UUID)) { strcpy(l->rx, path); if (mtu >= 23) l->mtu = mtu; } + if (!strcmp(uuid, BLE_TX_UUID)) strcpy(l->tx, path); + } + return 0; +} +static int interfaces(struct ble_backend* b, const char* path, sd_bus_message* m) { + int r = sd_bus_message_enter_container(m, 'a', "{sa{sv}}"); const char* iface; + if (r < 0) return r; + while ((r = sd_bus_message_enter_container(m, 'e', "sa{sv}")) > 0) { + if ((r = sd_bus_message_read(m, "s", &iface)) < 0 || (r = properties(b, path, iface, m)) < 0) return r; + sd_bus_message_exit_container(m); + } + if (r < 0) return r; + return sd_bus_message_exit_container(m); +} +static int managed_objects(struct ble_backend* b, sd_bus_message* m) { + int r = sd_bus_message_enter_container(m, 'a', "{oa{sa{sv}}}"); const char* path; + if (r < 0) return r; + while ((r = sd_bus_message_enter_container(m, 'e', "oa{sa{sv}}")) > 0) { + if ((r = sd_bus_message_read(m, "o", &path)) < 0 || (r = interfaces(b, path, m)) < 0) return r; + sd_bus_message_exit_container(m); + } + if (r < 0) return r; + return sd_bus_message_exit_container(m); +} +static int queried(sd_bus_message* m, void* arg, sd_bus_error* error) { + (void)error; struct bluez_link* l = arg; + /* Оставляем slot ненулевым при разборе, чтобы ServicesResolved не породил рекурсивный запрос. */ + int r = response_error(m, "GetManagedObjects") ? -EIO : managed_objects(l->backend, m); + l->query = sd_bus_slot_unref(l->query); + if (!l->id) return 0; + if (r < 0) { link_error(l, "service discovery failed"); return 0; } + if (l->rx[0] && l->tx[0] && !l->subscribe && !l->opened) { + r = sd_bus_call_method_async(l->backend->bus, &l->subscribe, "org.bluez", l->tx, CHAR_IF, "StartNotify", subscribed, l, ""); + if (r < 0) link_error(l, "StartNotify request failed"); + } + return 0; +} +static void query_link(struct bluez_link* l) { + if (l->query || l->subscribe || l->opened) return; + int r = sd_bus_call_method_async(l->backend->bus, &l->query, "org.bluez", "/", "org.freedesktop.DBus.ObjectManager", + "GetManagedObjects", queried, l, ""); + if (r < 0) link_error(l, "discovery request failed"); +} +static int connected(sd_bus_message* m, void* arg, sd_bus_error* error) { + (void)error; struct bluez_link* l = arg; l->connect = sd_bus_slot_unref(l->connect); + if (response_error(m, "Connect")) { link_error(l, "connect failed"); return 0; } + query_link(l); return 0; +} +static int signal_changed(sd_bus_message* m, void* arg, sd_bus_error* error) { + (void)error; struct ble_backend* b = arg; const char* iface; + int r = sd_bus_message_read(m, "s", &iface); + if (r >= 0) r = properties(b, sd_bus_message_get_path(m), iface, m); + if (r < 0) DEBUG_WARN(CAT, "BLE invalid BlueZ PropertiesChanged: %s", strerror(-r)); + return 0; +} +static int object_added(sd_bus_message* m, void* arg, sd_bus_error* error) { + (void)error; struct ble_backend* b = arg; const char* path; + int r = sd_bus_message_read(m, "o", &path); + if (r >= 0) r = interfaces(b, path, m); + if (r < 0) DEBUG_WARN(CAT, "BLE invalid BlueZ InterfacesAdded: %s", strerror(-r)); + return 0; +} +static int object_removed(sd_bus_message* m, void* arg, sd_bus_error* error) { + (void)error; struct ble_backend* b = arg; const char* path; + if (sd_bus_message_read(m, "o", &path) < 0) { DEBUG_WARN(CAT, "BLE invalid InterfacesRemoved"); return 0; } + if (!strcmp(path, b->adapter)) { fail(b, "Bluetooth adapter removed", ENODEV); return 0; } + for (int i = 0; i < BLE_LINKS_MAX; i++) { + struct bluez_link* l = &b->links[i]; + if (l->id && (!strcmp(path, l->device) || !strcmp(path, l->rx) || !strcmp(path, l->tx))) link_error(l, "BlueZ object removed"); + } + return 0; +} +static int owner_changed(sd_bus_message* m, void* arg, sd_bus_error* error) { + (void)error; const char *name, *old_owner, *new_owner; + if (sd_bus_message_read(m, "sss", &name, &old_owner, &new_owner) >= 0 && !new_owner[0]) fail(arg, "BlueZ stopped", ECONNRESET); + return 0; +} +static int setup_reply(sd_bus_message* m, void* arg, sd_bus_error* error) { + (void)error; struct ble_backend* b = arg; b->setup = sd_bus_slot_unref(b->setup); + if (response_error(m, "startup")) { fail(b, "BlueZ startup failed", EIO); return 0; } + int r = 0; + switch (b->step++) { + case 0: + snprintf(b->daemon_owner, sizeof(b->daemon_owner), "%s", sd_bus_message_get_sender(m)); + r = managed_objects(b, m); + if (r < 0 || !b->adapter[0]) { fail(b, "Bluetooth adapter unavailable", r < 0 ? r : ENODEV); return 0; } + r = sd_bus_call_method_async(b->bus, &b->setup, "org.bluez", b->adapter, "org.bluez.GattManager1", + "RegisterApplication", setup_reply, b, "oa{sv}", ROOT, 0); break; + case 1: + r = sd_bus_call_method_async(b->bus, &b->setup, "org.bluez", b->adapter, "org.bluez.LEAdvertisingManager1", + "RegisterAdvertisement", setup_reply, b, "oa{sv}", ADV, 0); break; + case 2: + r = sd_bus_call_method_async(b->bus, &b->setup, "org.bluez", b->adapter, "org.bluez.Adapter1", + "SetDiscoveryFilter", setup_reply, b, "a{sv}", 2, "Transport", "s", "le", "DuplicateData", "b", 1); break; + case 3: + r = sd_bus_call_method_async(b->bus, &b->setup, "org.bluez", b->adapter, "org.bluez.Adapter1", "StartDiscovery", setup_reply, b, ""); break; + case 4: ble_backend_status(b->owner, 1, "BlueZ scan/GATT ready"); break; + } + if (r < 0) fail(b, "BlueZ setup request failed", r); + return 0; +} +static int beacon_reply(sd_bus_message* m, void* arg, sd_bus_error* error) { + (void)error; struct ble_backend* b = arg; b->beacon_op = sd_bus_slot_unref(b->beacon_op); + if (response_error(m, "advertisement update")) { fail(b, "advertisement update failed", EIO); return 0; } + b->beacon_registered = b->beacon_action == 1; b->beacon_action = 0; + DEBUG_DEBUG(CAT, "BLE broadcast registered=%d bytes=%zu", b->beacon_registered, b->data_size); + ble_backend_advertising(b->owner, b->beacon_registered ? BLE_READY : BLE_CLOSED, "BlueZ advertisement registration changed"); + beacon_update(b); return 0; +} +static void beacon_update(struct ble_backend* b) { + if (b->beacon_op || b->failed) return; + int r; + if (b->beacon_registered) { + if (b->beacon_desired && b->next_size == b->data_size && !memcmp(b->data, b->next_data, b->data_size)) return; + b->beacon_action = 2; + r = sd_bus_call_method_async(b->bus, &b->beacon_op, "org.bluez", b->adapter, "org.bluez.LEAdvertisingManager1", + "UnregisterAdvertisement", beacon_reply, b, "o", BEACON); + } else { + if (!b->beacon_desired) return; + b->beacon_action = 1; + b->data_size = b->next_size; memcpy(b->data, b->next_data, b->data_size); + r = sd_bus_call_method_async(b->bus, &b->beacon_op, "org.bluez", b->adapter, "org.bluez.LEAdvertisingManager1", + "RegisterAdvertisement", beacon_reply, b, "oa{sv}", BEACON, 0); + } + if (r < 0) fail(b, "advertisement request failed", r); +} +struct ble_backend* ble_backend_create(struct ble_transport* owner, struct UASYNC* ua, const struct ble_config* cfg) { + struct ble_backend* b = u_calloc(1, sizeof(*b)); + if (!b) { DEBUG_ERROR(CAT, "BLE BlueZ allocation failed"); return NULL; } + b->owner = owner; b->ua = ua; b->company = cfg->company_id; + if (cfg->adapter) snprintf(b->adapter_name, sizeof(b->adapter_name), "%s", cfg->adapter); + b->rx_context.backend = b; b->tx_context.backend = b; b->tx_context.tx = 1; + b->adv_context.backend = b; b->beacon_context.backend = b; b->beacon_context.beacon = 1; + int r = sd_bus_open_system(&b->bus); + if (r >= 0) r = sd_bus_add_object_manager(b->bus, &b->manager, ROOT); + if (r >= 0) r = sd_bus_add_object_vtable(b->bus, &b->service, SERVICE, "org.bluez.GattService1", service_table, b); + if (r >= 0) r = sd_bus_add_object_vtable(b->bus, &b->rx, RX, CHAR_IF, rx_table, &b->rx_context); + if (r >= 0) r = sd_bus_add_object_vtable(b->bus, &b->tx, TX, CHAR_IF, tx_table, &b->tx_context); + if (r >= 0) r = sd_bus_add_object_vtable(b->bus, &b->adv, ADV, "org.bluez.LEAdvertisement1", adv_table, &b->adv_context); + if (r >= 0) r = sd_bus_add_object_vtable(b->bus, &b->beacon, BEACON, "org.bluez.LEAdvertisement1", beacon_table, &b->beacon_context); + if (r >= 0) r = sd_bus_add_match_async(b->bus, &b->signals, + "type='signal',sender='org.bluez',interface='org.freedesktop.DBus.Properties',member='PropertiesChanged'", signal_changed, NULL, b); + if (r >= 0) r = sd_bus_add_match_async(b->bus, &b->objects, + "type='signal',sender='org.bluez',interface='org.freedesktop.DBus.ObjectManager',member='InterfacesAdded'", object_added, NULL, b); + if (r >= 0) r = sd_bus_add_match_async(b->bus, &b->removed, + "type='signal',sender='org.bluez',interface='org.freedesktop.DBus.ObjectManager',member='InterfacesRemoved'", object_removed, NULL, b); + if (r >= 0) r = sd_bus_add_match_async(b->bus, &b->owner_watch, + "type='signal',sender='org.freedesktop.DBus',interface='org.freedesktop.DBus',member='NameOwnerChanged',arg0='org.bluez'", + owner_changed, NULL, b); + if (r >= 0) r = sd_bus_call_method_async(b->bus, &b->setup, "org.bluez", "/", "org.freedesktop.DBus.ObjectManager", + "GetManagedObjects", setup_reply, b, ""); + if (r >= 0) { b->io = uasync_add_socket(ua, sd_bus_get_fd(b->bus), bus_io, bus_io, bus_io, "ble_dbus", b); if (!b->io) r = -ENOMEM; } + if (r < 0) { DEBUG_ERROR(CAT, "BLE BlueZ create: %s", strerror(-r)); ble_backend_destroy(b); return NULL; } + bus_update(b); return b; +} +void ble_backend_close(struct ble_backend* b, ble_link_id id) { + struct bluez_link* l = find_link(b, id); if (!l) return; + l->connect = sd_bus_slot_unref(l->connect); l->query = sd_bus_slot_unref(l->query); + l->subscribe = sd_bus_slot_unref(l->subscribe); l->write = sd_bus_slot_unref(l->write); + if (l->io) uasync_remove_socket(b->ua, l->io); + if (l->fd >= 0) close(l->fd); + /* Не разрываем общий Device1 ACL: он может принадлежать другим профилям/приложениям. */ + if (l->outgoing && l->opened && !b->failed) { + int r = sd_bus_call_method_async(b->bus, NULL, "org.bluez", l->tx, CHAR_IF, "StopNotify", NULL, NULL, ""); + if (r < 0) DEBUG_WARN(CAT, "BLE StopNotify failed: %s", strerror(-r)); + } + memset(l, 0, sizeof(*l)); bus_update(b); +} +void ble_backend_destroy(struct ble_backend* b) { + if (!b) return; + b->failed = 1; + for (int i = 0; i < BLE_LINKS_MAX; i++) if (b->links[i].id) ble_backend_close(b, b->links[i].id); + if (b->timer) uasync_cancel_timeout(b->ua, b->timer); + if (b->io) uasync_remove_socket(b->ua, b->io); + sd_bus_slot_unref(b->setup); sd_bus_slot_unref(b->signals); sd_bus_slot_unref(b->objects); sd_bus_slot_unref(b->removed); + sd_bus_slot_unref(b->owner_watch); + sd_bus_slot_unref(b->service); sd_bus_slot_unref(b->rx); sd_bus_slot_unref(b->tx); sd_bus_slot_unref(b->manager); + sd_bus_slot_unref(b->adv); sd_bus_slot_unref(b->beacon); sd_bus_slot_unref(b->beacon_op); + /* Закрытие приватного bus освобождает регистрации/scan сессии в BlueZ по owner-disconnect. */ + sd_bus_close_unref(b->bus); u_free(b); +} +int ble_backend_advertise(struct ble_backend* b, const uint8_t* data, size_t len) { + if (b->failed) return BLE_NOT_READY; + b->beacon_desired = data != NULL; b->next_size = len; if (len) memcpy(b->next_data, data, len); + if (!b->beacon_op && b->beacon_desired == b->beacon_registered && + (!b->beacon_desired || (len == b->data_size && !memcmp(b->data, b->next_data, len)))) { + ble_backend_advertising(b->owner, b->beacon_registered ? BLE_READY : BLE_CLOSED, "advertisement unchanged"); return BLE_OK; + } + ble_backend_advertising(b->owner, BLE_STARTING, "updating advertisement"); + beacon_update(b); bus_update(b); return b->failed ? BLE_ERROR : BLE_OK; +} +int ble_backend_connect(struct ble_backend* b, ble_link_id id, const struct ble_endpoint* ep) { + if (b->failed || strncmp(ep->id, b->adapter, strlen(b->adapter)) || !strstr(ep->id, "/dev_")) { + DEBUG_WARN(CAT, "BLE invalid BlueZ endpoint=%s", ep->id); return BLE_ERROR; + } + struct bluez_link* l = alloc_link(b, id, ep->id, 1); + if (!l) { DEBUG_WARN(CAT, "BLE BlueZ duplicate/limit endpoint=%s", ep->id); return BLE_ERROR; } + int r = sd_bus_call_method_async(b->bus, &l->connect, "org.bluez", l->device, DEVICE_IF, "Connect", connected, l, ""); + if (r < 0) { DEBUG_ERROR(CAT, "BLE Connect request: %s", strerror(-r)); ble_backend_close(b, id); return BLE_ERROR; } + bus_update(b); return BLE_OK; +} +static int written(sd_bus_message* m, void* arg, sd_bus_error* error) { + (void)error; struct bluez_link* l = arg; l->write = sd_bus_slot_unref(l->write); l->sending = 0; + ble_backend_sent(l->backend->owner, l->id, !response_error(m, "WriteValue")); return 0; +} +int ble_backend_send(struct ble_backend* b, ble_link_id id, const uint8_t* data, size_t len) { + struct bluez_link* l = find_link(b, id); + if (!l || !l->opened || l->sending || !len || len > BLE_VALUE_MAX) { + DEBUG_ERROR(CAT, "BLE invalid backend send link=%llu", (unsigned long long)id); return BLE_ERROR; + } + if (!l->outgoing) { + memcpy(l->value, data, len); l->value_size = len; l->buffered = l->sending = 1; + uasync_set_socket_write(b->ua, l->io, 1); return BLE_OK; + } + sd_bus_message* m = NULL; + int r = sd_bus_message_new_method_call(b->bus, &m, "org.bluez", l->rx, CHAR_IF, "WriteValue"); + if (r >= 0) r = sd_bus_message_append_array(m, 'y', data, len); + if (r >= 0) r = sd_bus_message_append(m, "a{sv}", 1, "type", "s", "request"); + if (r >= 0) r = sd_bus_call_async(b->bus, &l->write, m, written, l, 10000000); + sd_bus_message_unref(m); + if (r < 0) { DEBUG_ERROR(CAT, "BLE WriteValue request: %s", strerror(-r)); return BLE_ERROR; } + DEBUG_TRACE(CAT, "BLE WriteValue submitted link=%llu bytes=%zu", (unsigned long long)id, len); + l->sending = 1; bus_update(b); return BLE_OK; +} diff --git a/lib/ble/ble_identity.c b/lib/ble/ble_identity.c new file mode 100644 index 000000000..feb459c95 --- /dev/null +++ b/lib/ble/ble_identity.c @@ -0,0 +1,42 @@ +#include "ble_identity.h" +#include "../debug_config.h" +#include +/* Строгая проверка scalar value: запрещены overlong, surrogate и > U+10FFFF. */ +static int character(const unsigned char* p, size_t n) { + unsigned v; int k; + if (!n || !p[0]) return -1; + if (p[0] < 128) return 1; + if (p[0] >= 0xc2 && p[0] <= 0xdf) { k = 2; v = p[0] & 31; } + else if (p[0] >= 0xe0 && p[0] <= 0xef) { k = 3; v = p[0] & 15; } + else if (p[0] >= 0xf0 && p[0] <= 0xf4) { k = 4; v = p[0] & 7; } + else return -1; + if (n < (size_t)k) return -1; + for (int i = 1; i < k; i++) { if ((p[i] & 0xc0) != 0x80) return -1; v = (v << 6) | (p[i] & 63); } + if ((k == 3 && v < 0x800) || (k == 4 && v < 0x10000) || (v >= 0xd800 && v <= 0xdfff) || v > 0x10ffff) return -1; + return k; +} +int ble_identity_encode(uint8_t out[24], uint64_t id, const char* name) { + if (!out || !id || !name) goto invalid; + size_t length = strlen(name), offset = 0; + while (offset < length) { + int n = character((const unsigned char*)name + offset, length - offset); + if (n < 0) goto invalid; + if (offset + n > 13) break; + offset += n; + } + out[0] = 'V'; out[1] = 'C'; out[2] = 1; + for (int i = 10; i >= 3; i--) { out[i] = id; id >>= 8; } + memcpy(out + 11, name, offset); return (int)(11 + offset); +invalid: + DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "BLE identity encode: invalid ID/name"); return -1; +} +int ble_identity_decode(struct ble_identity* out, const uint8_t* data, size_t size) { + if (!out || !data || size < 11 || size > 24 || data[0] != 'V' || data[1] != 'C' || data[2] != 1) return -1; + struct ble_identity result = {0}; + for (int i = 3; i < 11; i++) result.node_id = (result.node_id << 8) | data[i]; + if (!result.node_id) goto invalid; + for (size_t pos = 11; pos < size;) { int n = character(data + pos, size - pos); if (n < 0) goto invalid; pos += n; } + memcpy(result.name, data + 11, size - 11); *out = result; return 0; +invalid: + DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "BLE identity decode: invalid ID/UTF-8"); return -1; +} diff --git a/lib/ble/ble_identity.h b/lib/ble/ble_identity.h new file mode 100644 index 000000000..a90e51b70 --- /dev/null +++ b/lib/ble/ble_identity.h @@ -0,0 +1,17 @@ +/* Объявление VibeChat: VC, версия 1, node_id BE64, имя UTF-8 до 13 байт. + * encode возвращает размер 11..24 либо -1; decode возвращает 0 либо -1. + * Имя сокращается по целому символу; decode отклоняет неверный UTF-8 и нулевой ID. */ +#ifndef VIBECHAT_BLE_IDENTITY_H +#define VIBECHAT_BLE_IDENTITY_H +#include +#include +#ifdef __cplusplus +extern "C" { +#endif +struct ble_identity { uint64_t node_id; char name[14]; }; +int ble_identity_encode(uint8_t output[24], uint64_t node_id, const char* name); +int ble_identity_decode(struct ble_identity* output, const uint8_t* data, size_t size); +#ifdef __cplusplus +} +#endif +#endif diff --git a/lib/ble/ble_transport.c b/lib/ble/ble_transport.c new file mode 100644 index 000000000..ce205cce3 --- /dev/null +++ b/lib/ble/ble_transport.c @@ -0,0 +1,301 @@ +#include "ble_backend.h" +#include "../u_async.h" +#include "../mem.h" +#include "../debug_config.h" +#include +#define CAT DEBUG_CATEGORY_DISCOVERY +#define HELLO 1 +#define ACCEPT 2 +#define FRAME 3 +#define HEADER 7 +#define DEADLINE_US 15000000ULL +struct ble_connection { + ble_link_id id; + uint64_t peer, deadline, rx_deadline; + struct ble_endpoint endpoint; + struct ll_queue *tx, *rx; + struct ll_entry* current; + uint16_t sequence, offset, pending_bytes, rx_sequence, rx_size, rx_offset, dropped_sequence; + unsigned int capacity; + int outgoing, opened, ready, pending, blocked, control_pending, dropping; + uint8_t control[17], assembly[BLE_FRAME_MAX]; +}; +struct ble_transport { + struct UASYNC* ua; + struct ble_config config; + char adapter[64]; + struct ble_backend* backend; + struct ble_connection links[BLE_LINKS_MAX]; + ble_link_id sequence; + void* timer; + int started, ready, reject_incoming; +}; +static uint16_t read16(const uint8_t* p) { return (uint16_t)((p[0] << 8) | p[1]); } +static void write16(uint8_t* p, uint16_t v) { p[0] = v >> 8; p[1] = v; } +static uint64_t read64(const uint8_t* p) { + uint64_t v = 0; + for (int i = 0; i < 8; i++) v = (v << 8) | p[i]; + return v; +} +static void write64(uint8_t* p, uint64_t v) { for (int i = 7; i >= 0; i--) { p[i] = v; v >>= 8; } } +static void release_entry(struct ll_entry* e) { if (e) { queue_dgram_free(e); queue_entry_free(e); } } +static struct ble_connection* lookup(struct ble_transport* t, ble_link_id id) { + if (t && id) for (int i = 0; i < BLE_LINKS_MAX; i++) if (t->links[i].id == id) return &t->links[i]; + return NULL; +} +static void close_link(struct ble_transport* t, struct ble_connection* c, const char* reason) { + ble_link_id id = c->id; + uint64_t peer = c->peer; + DEBUG_INFO(CAT, "BLE closed link=%llu peer=%016llx reason=%s", (unsigned long long)id, (unsigned long long)peer, reason); + if (t->backend) ble_backend_close(t->backend, id); + release_entry(c->current); + queue_clear(c->tx); queue_free(c->tx); + queue_clear(c->rx); queue_free(c->rx); + memset(c, 0, sizeof(*c)); + if (t->config.callbacks.connection) t->config.callbacks.connection(t->config.arg, id, peer, BLE_CLOSED, reason); +} +static struct ble_connection* new_link(struct ble_transport* t, const struct ble_endpoint* ep, int outgoing, uint64_t peer) { + if (!t || !t->started || !ep || !memchr(ep->id, 0, sizeof(ep->id)) || !ep->id[0]) { + DEBUG_ERROR(CAT, "BLE invalid connection request"); return NULL; + } + for (int i = 0; i < BLE_LINKS_MAX; i++) if (t->links[i].id && !strcmp(t->links[i].endpoint.id, ep->id)) { + DEBUG_WARN(CAT, "BLE duplicate connection endpoint=%s", ep->id); return NULL; + } + for (int i = 0; i < BLE_LINKS_MAX; i++) if (!t->links[i].id) { + struct ble_connection* c = &t->links[i]; + c->tx = queue_new(t->ua, 0, 0, 0, "ble_tx"); c->rx = queue_new(t->ua, 0, 0, 0, "ble_rx"); + if (!c->tx || !c->rx) { + DEBUG_ERROR(CAT, "BLE queue allocation failed"); queue_free(c->tx); queue_free(c->rx); + memset(c, 0, sizeof(*c)); return NULL; + } + c->id = ++t->sequence; c->peer = peer; c->endpoint = *ep; c->outgoing = outgoing; + c->capacity = 20; c->deadline = get_time_us() + DEADLINE_US; + DEBUG_INFO(CAT, "BLE connecting link=%llu endpoint=%s outgoing=%d", (unsigned long long)c->id, ep->id, outgoing); + if (t->config.callbacks.connection) t->config.callbacks.connection(t->config.arg, c->id, peer, BLE_STARTING, "connecting"); + return c; + } + DEBUG_WARN(CAT, "BLE connection limit=%d", BLE_LINKS_MAX); return NULL; +} +static void pump(struct ble_transport* t, struct ble_connection* c) { + if (!c->opened || c->pending) return; + uint8_t value[BLE_VALUE_MAX]; + size_t len; + c->pending_bytes = 0; + if (c->control_pending) { memcpy(value, c->control, 17); len = 17; } + else { + if (!c->ready) return; + if (!c->current) { + c->current = queue_data_get(c->tx); c->offset = 0; + if (!c->current) return; + ++c->sequence; + } + size_t n = c->current->len - c->offset; + if (n > c->capacity - HEADER) n = c->capacity - HEADER; + value[0] = FRAME; write16(value + 1, c->sequence); write16(value + 3, c->current->len); write16(value + 5, c->offset); + memcpy(value + HEADER, c->current->dgram + c->offset, n); len = HEADER + n; c->pending_bytes = n; + } + int result = ble_backend_send(t->backend, c->id, value, len); + if (result == BLE_OK) { c->pending = 1; c->deadline = get_time_us() + DEADLINE_US; } + else { DEBUG_ERROR(CAT, "BLE backend send failed link=%llu result=%d", (unsigned long long)c->id, result); + close_link(t, c, "backend send failed"); } +} +static void tick(void* arg) { + struct ble_transport* t = arg; t->timer = NULL; + uint64_t now = get_time_us(); + for (int i = 0; i < BLE_LINKS_MAX; i++) { + struct ble_connection* c = &t->links[i]; + if (!c->id) continue; + if ((!c->ready || c->pending) && now >= c->deadline) { + DEBUG_WARN(CAT, "BLE handshake/send timeout link=%llu pending=%d", (unsigned long long)c->id, c->pending); + close_link(t, c, "operation timeout"); continue; + } + if (c->rx_size && now >= c->rx_deadline) { + DEBUG_WARN(CAT, "BLE frame timeout link=%llu received=%u expected=%u", + (unsigned long long)c->id, c->rx_offset, c->rx_size); + c->dropping = 1; c->dropped_sequence = c->rx_sequence; + c->rx_size = c->rx_offset = 0; + } + } + t->timer = uasync_set_timeout(t->ua, 1000, t, tick, "ble_tick"); + if (!t->timer) { DEBUG_ERROR(CAT, "BLE timer allocation failed"); ble_backend_status(t, 0, "timer allocation failed"); } +} +struct ble_transport* ble_create(struct UASYNC* ua, const struct ble_config* config) { + if (!ua || !config || !config->node_id || (config->adapter && strlen(config->adapter) >= 64)) { + DEBUG_ERROR(CAT, "BLE create invalid configuration"); return NULL; + } + struct ble_transport* t = u_calloc(1, sizeof(*t)); + if (!t) { DEBUG_ERROR(CAT, "BLE create allocation failed"); return NULL; } + t->ua = ua; t->config = *config; + if (config->adapter) { strcpy(t->adapter, config->adapter); t->config.adapter = t->adapter; } + return t; +} +int ble_start(struct ble_transport* t) { + if (!t || t->started) { DEBUG_ERROR(CAT, "BLE invalid/repeated start"); return BLE_ERROR; } + t->started = 1; + t->backend = ble_backend_create(t, t->ua, &t->config); + if (!t->backend) { t->started = 0; DEBUG_ERROR(CAT, "BLE backend creation failed"); return BLE_ERROR; } + t->timer = uasync_set_timeout(t->ua, 1000, t, tick, "ble_tick"); + if (!t->timer) { DEBUG_ERROR(CAT, "BLE timer allocation failed"); ble_stop(t); return BLE_ERROR; } + if (t->config.callbacks.status) t->config.callbacks.status(t->config.arg, BLE_STARTING, "registering BLE service"); + return BLE_OK; +} +void ble_stop(struct ble_transport* t) { + if (!t || !t->started) return; + t->started = t->ready = 0; + if (t->timer) { uasync_cancel_timeout(t->ua, t->timer); t->timer = NULL; } + for (int i = 0; i < BLE_LINKS_MAX; i++) if (t->links[i].id) close_link(t, &t->links[i], "transport stopped"); + ble_backend_destroy(t->backend); t->backend = NULL; + if (t->config.callbacks.status) t->config.callbacks.status(t->config.arg, BLE_CLOSED, "stopped"); +} +void ble_set_incoming(struct ble_transport* t, int allowed) { + if (!t) { DEBUG_ERROR(CAT, "BLE incoming policy: null transport"); return; } + t->reject_incoming = !allowed; + DEBUG_INFO(CAT, "BLE incoming allowed=%d", !!allowed); + if (!allowed) for (int i = 0; i < BLE_LINKS_MAX; i++) + if (t->links[i].id && !t->links[i].outgoing) close_link(t, &t->links[i], "incoming disabled"); +} +void ble_destroy(struct ble_transport* t) { if (t) { ble_stop(t); u_free(t); } } +int ble_advertise(struct ble_transport* t, const void* data, size_t len) { + if (!t || !t->ready) { DEBUG_WARN(CAT, "BLE advertise before ready"); return BLE_NOT_READY; } + if (len > BLE_BROADCAST_MAX || (len && !data)) { DEBUG_ERROR(CAT, "BLE invalid advertisement length=%zu", len); return BLE_ERROR; } + return ble_backend_advertise(t->backend, data, len); +} +ble_link_id ble_connect(struct ble_transport* t, const struct ble_endpoint* ep, uint64_t peer) { + if (!t || !t->ready) { DEBUG_WARN(CAT, "BLE connect before ready"); return 0; } + struct ble_connection* c = new_link(t, ep, 1, peer); + if (!c) return 0; + ble_link_id id = c->id; + if (ble_backend_connect(t->backend, id, ep) != BLE_OK) { close_link(t, c, "connect rejected"); return 0; } + return id; +} +void ble_disconnect(struct ble_transport* t, ble_link_id id) { + struct ble_connection* c = lookup(t, id); + if (c) close_link(t, c, "API disconnect"); + else DEBUG_WARN(CAT, "BLE disconnect unknown link=%llu", (unsigned long long)id); +} +int ble_tx_put(struct ble_transport* t, ble_link_id id, struct ll_entry* e) { + struct ble_connection* c = lookup(t, id); + if (!c || !c->ready) { DEBUG_WARN(CAT, "BLE TX link not ready=%llu", (unsigned long long)id); return BLE_NOT_READY; } + if (!e || e->owner || e->next || e->prev || !e->dgram || !e->len || e->len > BLE_FRAME_MAX || e->len > e->memlen) { + DEBUG_ERROR(CAT, "BLE invalid TX frame link=%llu", (unsigned long long)id); return BLE_ERROR; + } + if (queue_entry_count(c->tx) + !!c->current >= BLE_QUEUE_MAX) { + if (!c->blocked) DEBUG_DEBUG(CAT, "BLE TX paused link=%llu packets=%d", (unsigned long long)id, BLE_QUEUE_MAX); + c->blocked = 1; return BLE_WOULD_BLOCK; + } + /* Без лимита/индекса/consumer put не имеет отказа после проверок и не вызывает чужой callback. */ + if (queue_data_put(c->tx, e) != 0) { DEBUG_ERROR(CAT, "BLE TX queue invariant failed"); return BLE_ERROR; } + pump(t, c); return BLE_OK; +} +struct ll_entry* ble_rx_get(struct ble_transport* t, ble_link_id id) { + struct ble_connection* c = lookup(t, id); + return c ? queue_data_get(c->rx) : NULL; +} +ble_link_id ble_backend_incoming(struct ble_transport* t, const struct ble_endpoint* ep) { + if (t->reject_incoming) { DEBUG_INFO(CAT, "BLE incoming rejected by policy"); return 0; } + struct ble_connection* c = new_link(t, ep, 0, 0); return c ? c->id : 0; +} +void ble_backend_status(struct ble_transport* t, int ready, const char* reason) { + t->ready = ready; + if (!ready) { + DEBUG_ERROR(CAT, "BLE radio failed: %s", reason); + for (int i = 0; i < BLE_LINKS_MAX; i++) if (t->links[i].id) close_link(t, &t->links[i], reason); + } else DEBUG_INFO(CAT, "BLE radio ready node=%016llx", (unsigned long long)t->config.node_id); + if (t->config.callbacks.status) t->config.callbacks.status(t->config.arg, ready ? BLE_READY : BLE_CLOSED, reason); +} +void ble_backend_broadcast(struct ble_transport* t, const struct ble_endpoint* ep, const uint8_t* data, size_t len, int rssi) { + if (len > BLE_BROADCAST_MAX) { DEBUG_WARN(CAT, "BLE oversized broadcast bytes=%zu", len); return; } + if (t->config.callbacks.broadcast) t->config.callbacks.broadcast(t->config.arg, ep, data, len, rssi); +} +void ble_backend_advertising(struct ble_transport* t, enum ble_state state, const char* reason) { + DEBUG_INFO(CAT, "BLE advertising state=%d reason=%s", state, reason); + if (t->config.callbacks.advertising) t->config.callbacks.advertising(t->config.arg, state, reason); +} +void ble_backend_opened(struct ble_transport* t, ble_link_id id, unsigned int mtu) { + struct ble_connection* c = lookup(t, id); + if (!c) return; + if (mtu < 23) { DEBUG_ERROR(CAT, "BLE invalid MTU=%u", mtu); close_link(t, c, "invalid MTU"); return; } + c->capacity = mtu - 3; if (c->capacity > BLE_VALUE_MAX) c->capacity = BLE_VALUE_MAX; + c->opened = 1; + DEBUG_DEBUG(CAT, "BLE GATT open link=%llu mtu=%u outgoing=%d", (unsigned long long)id, mtu, c->outgoing); + if (c->outgoing) { + c->control[0] = HELLO; write64(c->control + 1, t->config.node_id); write64(c->control + 9, c->peer); + c->control_pending = 1; + } + pump(t, c); +} +void ble_backend_received(struct ble_transport* t, ble_link_id id, const uint8_t* data, size_t len) { + struct ble_connection* c = lookup(t, id); + if (!c) return; + if (len == 17 && !c->ready && ((data[0] == HELLO && !c->outgoing) || (data[0] == ACCEPT && c->outgoing))) { + uint64_t peer = read64(data + 1), target = read64(data + 9); + if (!peer || peer == t->config.node_id || (target && target != t->config.node_id) || (c->peer && peer != c->peer)) { + DEBUG_WARN(CAT, "BLE invalid HELLO identity link=%llu", (unsigned long long)id); close_link(t, c, "invalid identity"); return; + } + if (t->config.callbacks.validate && !t->config.callbacks.validate(t->config.arg, id, peer)) { + DEBUG_WARN(CAT, "BLE validation rejected link=%llu", (unsigned long long)id); close_link(t, c, "validation rejected"); return; + } + c->peer = peer; c->ready = 1; + if (!c->outgoing) { + c->control[0] = ACCEPT; write64(c->control + 1, t->config.node_id); write64(c->control + 9, peer); c->control_pending = 1; + } + DEBUG_INFO(CAT, "BLE session ready link=%llu peer=%016llx", (unsigned long long)id, (unsigned long long)peer); + if (t->config.callbacks.connection) t->config.callbacks.connection(t->config.arg, id, peer, BLE_READY, "HELLO accepted"); + pump(t, c); return; + } + if (!c->ready || len <= HEADER || len > c->capacity || data[0] != FRAME) { + DEBUG_WARN(CAT, "BLE invalid value link=%llu bytes=%zu ready=%d", (unsigned long long)id, len, c->ready); + c->rx_size = c->rx_offset = 0; return; + } + uint16_t seq = read16(data + 1), total = read16(data + 3), offset = read16(data + 5); + size_t n = len - HEADER; + if (offset && c->dropping && c->dropped_sequence == seq) { + DEBUG_TRACE(CAT, "BLE discarded frame tail link=%llu seq=%u offset=%u", (unsigned long long)id, seq, offset); return; + } + if (!offset) c->dropping = 0; + if (!total || total > BLE_FRAME_MAX || offset > total || n > (size_t)(total - offset)) goto invalid; + if (!offset) { + if (c->rx_size) DEBUG_WARN(CAT, "BLE incomplete frame replaced link=%llu", (unsigned long long)id); + c->rx_sequence = seq; c->rx_size = total; c->rx_offset = 0; c->rx_deadline = get_time_us() + DEADLINE_US; + } + if (c->rx_size != total || c->rx_sequence != seq || c->rx_offset != offset) goto invalid; + memcpy(c->assembly + offset, data + HEADER, n); c->rx_offset += n; + if (c->rx_offset != total) return; + c->rx_size = c->rx_offset = 0; + if (queue_entry_count(c->rx) >= BLE_QUEUE_MAX) { DEBUG_WARN(CAT, "BLE RX full: frame dropped link=%llu", (unsigned long long)id); return; } + struct ll_entry* entry = ll_alloc_lldgram(total); + if (!entry) { DEBUG_ERROR(CAT, "BLE RX allocation failed bytes=%u", total); return; } + memcpy(entry->dgram, c->assembly, total); entry->len = total; + if (queue_data_put(c->rx, entry) != 0) { DEBUG_ERROR(CAT, "BLE RX queue invariant failed"); return; } + DEBUG_DEBUG(CAT, "BLE RX frame link=%llu seq=%u bytes=%u queued=%d", (unsigned long long)id, seq, total, queue_entry_count(c->rx)); + if (t->config.callbacks.received) t->config.callbacks.received(t->config.arg, id); + return; +invalid: + DEBUG_WARN(CAT, "BLE fragment dropped link=%llu seq=%u offset=%u expected=%u total=%u bytes=%zu", + (unsigned long long)id, seq, offset, c->rx_offset, total, n); + c->rx_size = c->rx_offset = 0; + c->dropping = 1; c->dropped_sequence = seq; +} +void ble_backend_sent(struct ble_transport* t, ble_link_id id, int success) { + struct ble_connection* c = lookup(t, id); + if (!c) return; + if (!success || !c->pending) { DEBUG_WARN(CAT, "BLE send completion failed link=%llu", (unsigned long long)id); + close_link(t, c, "send failed"); return; } + c->pending = 0; + if (!c->pending_bytes) c->control_pending = 0; + else if (c->current) { + c->offset += c->pending_bytes; + if (c->offset == c->current->len) { + DEBUG_DEBUG(CAT, "BLE TX frame link=%llu seq=%u bytes=%u", (unsigned long long)id, c->sequence, c->current->len); + release_entry(c->current); c->current = NULL; + } + } + if (c->blocked && queue_entry_count(c->tx) + !!c->current < BLE_QUEUE_MAX / 2) { + c->blocked = 0; DEBUG_DEBUG(CAT, "BLE TX resumed link=%llu", (unsigned long long)id); + if (t->config.callbacks.writable) t->config.callbacks.writable(t->config.arg, id); + } + pump(t, c); +} +void ble_backend_closed(struct ble_transport* t, ble_link_id id, const char* reason) { + struct ble_connection* c = lookup(t, id); if (c) close_link(t, c, reason); +} diff --git a/lib/ble/ble_transport.h b/lib/ble/ble_transport.h new file mode 100644 index 000000000..5d9077290 --- /dev/null +++ b/lib/ble/ble_transport.h @@ -0,0 +1,67 @@ +/* BLE transport без Qt: create → start → advertise/connect → tx_put/rx_get → destroy. + * Все API и callbacks принадлежат переданному uasync-потоку; callbacks не вызывают stop/destroy/disconnect. + * Объявление — повторяемые ОС байты, не надёжная датаграмма. node_id в HELLO не аутентифицирован. + * Один сервис обслуживает независимые соединения; данные — непрозрачные кадры 1..1500 байт. + * Целые кадры находятся в ll_queue. На радио Write Request/Indication; своих ретрансляций нет. + * Диагностика: discovery (BLE-префикс). NULL validate разрешает запрос, как и начальная политика GUI. */ +#ifndef VIBECHAT_BLE_TRANSPORT_H +#define VIBECHAT_BLE_TRANSPORT_H +#include +#include +#include "../ll_queue.h" +#ifdef __cplusplus +extern "C" { +#endif +#define BLE_FRAME_MAX 1500 +#define BLE_BROADCAST_MAX 24 +#define BLE_ENDPOINT_MAX 256 +#define BLE_LINKS_MAX 16 +#define BLE_QUEUE_MAX 64 +struct ble_transport; +struct UASYNC; +typedef uint64_t ble_link_id; +enum ble_result { BLE_OK = 0, BLE_ERROR = -1, BLE_WOULD_BLOCK = -2, BLE_NOT_READY = -3 }; +enum ble_state { BLE_STARTING, BLE_READY, BLE_CLOSED }; +struct ble_endpoint { char id[BLE_ENDPOINT_MAX]; }; /* Непрозрачный адрес backend, копируется целиком. */ +struct ble_callbacks { + /* Объявление нужного company_id; буфер заимствован до возврата. RSSI=127 означает неизвестный. */ + void (*broadcast)(void* arg, const struct ble_endpoint* endpoint, const uint8_t* data, size_t len, int rssi); + void (*status)(void* arg, enum ble_state state, const char* reason); /* Состояние радио, reason заимствован. */ + void (*advertising)(void* arg, enum ble_state state, const char* reason); /* Публикация bytes: READY не подтверждает приём. */ + int (*validate)(void* arg, ble_link_id link, uint64_t node_id); /* Ненулевой результат разрешает HELLO. */ + void (*connection)(void* arg, ble_link_id link, uint64_t node_id, enum ble_state state, const char* reason); + void (*received)(void* arg, ble_link_id link); /* rx_get до NULL; допустим синхронный drain. */ + void (*writable)(void* arg, ble_link_id link); /* После заполнения TX снова можно вызвать tx_put. */ +}; +struct ble_config { + uint64_t node_id; /* Ненулевой ID для HELLO; не криптографическое доказательство. */ + uint16_t company_id; /* Manufacturer ID, одинаковый у участников. 0xffff только для разработки. */ + const char* adapter; /* Linux: hci0 либо NULL (первый адаптер); Windows: NULL. Копируется. */ + struct ble_callbacks callbacks; + void* arg; /* Заимствованный контекст callbacks, живёт до destroy. */ +}; +/* Создать остановленный transport. ua/config заимствованы только ua; возвращает handle или NULL с логом. */ +struct ble_transport* ble_create(struct UASYNC* ua, const struct ble_config* config); +/* Начать регистрацию сервиса/scan; 0 означает запуск, готовность сообщает status. Повторный start — ошибка. */ +int ble_start(struct ble_transport* transport); +/* Остановить свои объявления/scan/сессии, завершить pending и освободить очереди. Идемпотентно, вне callbacks. */ +void ble_stop(struct ble_transport* transport); +/* Разрешить/запретить новые входящие; запрет закрывает текущие входящие. По умолчанию разрешены. */ +void ble_set_incoming(struct ble_transport* transport, int allowed); +void ble_destroy(struct ble_transport* transport); /* stop + free; NULL допустим. */ +/* Заменить повторяемое manufacturer-объявление (0..24 байта); копирует данные. NULL,0 выключает его. + * Регистрация не гарантирует выход в эфир/получение. Собственное connectable-объявление сервиса независимо. */ +int ble_advertise(struct ble_transport* transport, const void* data, size_t len); +/* Самостоятельно выполнить connect/discovery/subscribe/HELLO. expected_node=0 не проверяет ID ответа. + * Handle либо 0 при отказе. Готовность/ошибка — connection; физическое ACL может обслуживать другие сервисы. */ +ble_link_id ble_connect(struct ble_transport* transport, const struct ble_endpoint* endpoint, uint64_t expected_node); +void ble_disconnect(struct ble_transport* transport, ble_link_id link); +/* Принять целый ll_entry в TX. При 0 владение передано; при любом отказе entry остаётся вызывающему. + * entry должен быть вне очереди, dgram!=NULL, len=1..1500. Заполнение: WOULD_BLOCK, затем writable. */ +int ble_tx_put(struct ble_transport* transport, ble_link_id link, struct ll_entry* entry); +/* Извлечь целый кадр из RX или NULL; вызывающий освобождает queue_dgram_free + queue_entry_free. */ +struct ll_entry* ble_rx_get(struct ble_transport* transport, ble_link_id link); +#ifdef __cplusplus +} +#endif +#endif diff --git a/lib/ble/ble_winrt.cpp b/lib/ble/ble_winrt.cpp new file mode 100644 index 000000000..145239a8b --- /dev/null +++ b/lib/ble/ble_winrt.cpp @@ -0,0 +1,530 @@ +/* WinRT backend: события Windows сериализуются в uasync через ограниченный mailbox. + * Каждая отправка адресована link/subscribedClient; одна незавершённая операция на link. + * Слабые ссылки в WinRT handlers и отменяемый posted_task исключают обращения к уничтоженному core. */ +#include "ble_backend.h" +#include "../u_async.h" +#include "../mem.h" +#include "../debug_config.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +using namespace winrt; +using namespace Windows::Foundation; +using namespace Windows::Devices::Bluetooth; +using namespace Windows::Devices::Bluetooth::Advertisement; +using namespace Windows::Devices::Bluetooth::GenericAttributeProfile; +using namespace Windows::Storage::Streams; +static constexpr auto CAT = DEBUG_CATEGORY_DISCOVERY; +static const char* bluetooth_error(BluetoothError error) { + switch (error) { + case BluetoothError::Success: return "Success"; + case BluetoothError::RadioNotAvailable: return "RadioNotAvailable"; + case BluetoothError::ResourceInUse: return "ResourceInUse"; + case BluetoothError::DeviceNotConnected: return "DeviceNotConnected"; + case BluetoothError::DisabledByPolicy: return "DisabledByPolicy"; + case BluetoothError::NotSupported: return "NotSupported"; + case BluetoothError::DisabledByUser: return "DisabledByUser"; + case BluetoothError::ConsentRequired: return "ConsentRequired"; + case BluetoothError::TransportNotSupported: return "TransportNotSupported"; + default: return "OtherError"; + } +} +/* Вызывать только из catch; сохраняем контекст и HRESULT вместо немого catch(...). */ +static std::string exception_reason(const char* phase, ble_link_id id = 0) { + char prefix[192]; + try { throw; } + catch (const hresult_error& e) { + std::snprintf(prefix, sizeof(prefix), "BLE WinRT phase=%s link=%llu HRESULT=0x%08x: ", + phase, (unsigned long long)id, (unsigned)e.code().value); + return prefix + to_string(e.message()); + } catch (const std::exception& e) { + return std::string("BLE WinRT phase=") + phase + ": " + e.what(); + } catch (...) { return std::string("BLE WinRT phase=") + phase + ": unknown exception"; } +} +struct WinState; +struct WinLink { + ble_link_id id = 0; + std::string endpoint; + hstring device_id; + bool outgoing = false, opened = false, sending = false; + BluetoothLEDevice device{nullptr}; + GattDeviceService service{nullptr}; + GattCharacteristic rx{nullptr}, tx{nullptr}; + GattSession session{nullptr}; + GattSubscribedClient subscriber{nullptr}; + IAsyncInfo operation{nullptr}; + GattCharacteristic::ValueChanged_revoker value_changed; + GattSession::SessionStatusChanged_revoker session_changed; + GattSession::MaxPduSizeChanged_revoker pdu_changed; +}; +struct WriteDeferral { + Deferral value; + explicit WriteDeferral(Deferral v) : value(v) {} + ~WriteDeferral() { try { value.Complete(); } catch (...) { DEBUG_WARN(CAT, "%s", exception_reason("write deferral").c_str()); } } +}; +struct WinState : std::enable_shared_from_this { + struct UASYNC* ua = nullptr; + struct ble_transport* owner = nullptr; + uint16_t company = 0; + std::mutex mutex; + std::deque> events; + struct posted_task* posted = nullptr; + bool stopped = false, failed = false, overflow = false, apartment = false, scan_ready = false, service_ready = false; + IAsyncInfo startup{nullptr}; + BluetoothLEAdvertisementWatcher watcher{nullptr}; + BluetoothLEAdvertisementPublisher publisher{nullptr}; + GattServiceProvider provider{nullptr}; + GattLocalCharacteristic local_rx{nullptr}, local_tx{nullptr}; + BluetoothLEAdvertisementWatcher::Received_revoker received; + BluetoothLEAdvertisementWatcher::Stopped_revoker scan_stopped; + BluetoothLEAdvertisementPublisher::StatusChanged_revoker publish_changed; + GattServiceProvider::AdvertisementStatusChanged_revoker service_changed; + GattLocalCharacteristic::WriteRequested_revoker write_requested; + GattLocalCharacteristic::SubscribedClientsChanged_revoker subscriptions_changed; + std::map> links; + std::array broadcast{}; + size_t broadcast_size = 0; + bool broadcast_enabled = false; + uint64_t publish_generation = 0; + static void dispatch(void* arg) { + auto& s = *static_cast(arg); + std::deque> batch; + bool overloaded; + { std::lock_guard guard(s.mutex); s.posted = nullptr; batch.swap(s.events); overloaded = s.overflow; s.overflow = false; } + if (overloaded) { s.fail("WinRT event queue overflow"); return; } + DEBUG_TRACE(CAT, "BLE WinRT dispatch events=%zu links=%zu", batch.size(), s.links.size()); + for (auto& event : batch) { + if (s.stopped || s.failed) break; + try { event(s); } + catch (...) { s.fail(exception_reason("event dispatch").c_str()); } + } + } + void post(std::function event) { + std::lock_guard guard(mutex); + if (stopped) return; + if (events.size() >= 256) { + if (!overflow) DEBUG_ERROR(CAT, "BLE WinRT event mailbox full queued=%zu limit=256", events.size()); + overflow = true; + } + else events.push_back(std::move(event)); + if (posted) return; + posted = static_cast(u_calloc(1, sizeof(struct posted_task))); + if (!posted) { overflow = true; DEBUG_ERROR(CAT, "BLE WinRT event allocation failed"); return; } + posted->callback = dispatch; posted->arg = this; uasync_post_reserved(ua, posted); + } + WinLink* link(ble_link_id id) { auto it = links.find(id); return it == links.end() ? nullptr : it->second.get(); } + void fail(const char* reason) { + if (failed) return; + failed = true; DEBUG_ERROR(CAT, "BLE WinRT failed: %s", reason); ble_backend_status(owner, 0, reason); + } + void error(ble_link_id id, const char* reason) { + DEBUG_WARN(CAT, "BLE WinRT link=%llu failed: %s", (unsigned long long)id, reason); + ble_backend_closed(owner, id, reason); + } + void radio_error(const char* phase, BluetoothError error) { + char reason[192]; + std::snprintf(reason, sizeof(reason), "%s: BluetoothError=%d (%s)", phase, (int)error, bluetooth_error(error)); + fail(reason); + } + template + void await(const char* phase, const IAsyncOperation& operation, ble_link_id id, Callback callback) { + const bool fragment = !std::strcmp(phase, "write frame") || !std::strcmp(phase, "indicate frame"); + if (!fragment) DEBUG_DEBUG(CAT, "BLE WinRT async begin phase=%s link=%llu", phase, (unsigned long long)id); + if (id) { auto* l = link(id); if (!l) { operation.Cancel(); return; } l->operation = operation; } + else startup = operation; + auto weak = weak_from_this(); + operation.Completed([weak, id, phase, fragment, callback](const IAsyncOperation& op, AsyncStatus status) { + if (auto s = weak.lock()) s->post([id, op, status, phase, fragment, callback](WinState& self) { + auto* l = id ? self.link(id) : nullptr; + if (id && !l) { + DEBUG_DEBUG(CAT, "BLE WinRT stale completion phase=%s link=%llu status=%d", phase, (unsigned long long)id, (int)status); + return; + } + if (l) l->operation = nullptr; else self.startup = nullptr; + if (status != AsyncStatus::Completed) { + char reason[192]; + std::snprintf(reason, sizeof(reason), "phase=%s async_status=%d HRESULT=0x%08x", phase, (int)status, + (unsigned)op.ErrorCode().value); + if (id) self.error(id, reason); else self.fail(reason); + return; + } + if (!fragment) DEBUG_DEBUG(CAT, "BLE WinRT async complete phase=%s link=%llu", phase, (unsigned long long)id); + callback(self, op.GetResults()); + }); + }); + } + static IBuffer buffer(const uint8_t* data, size_t len) { + DataWriter writer; writer.WriteBytes(array_view(data, data + len)); return writer.DetachBuffer(); + } + static std::vector bytes(const IBuffer& buffer) { + if (buffer.Length() > BLE_VALUE_MAX) throw hresult_invalid_argument(); + std::vector result(buffer.Length()); DataReader::FromBuffer(buffer).ReadBytes(result); return result; + } + void ready() { + DEBUG_DEBUG(CAT, "BLE WinRT readiness scan=%d service=%d", scan_ready, service_ready); + if (scan_ready && service_ready) ble_backend_status(owner, 1, "WinRT scan/GATT ready"); + } + void watch_session(WinLink& l) { + auto weak = weak_from_this(); auto id = l.id; + l.session_changed = l.session.SessionStatusChanged(auto_revoke, [weak, id](auto const&, GattSessionStatusChangedEventArgs const& a) { + auto status = a.Status(); auto error = a.Error(); + if (auto s = weak.lock()) s->post([id, status, error](WinState& s) { + DEBUG_DEBUG(CAT, "BLE WinRT session link=%llu status=%d error=%d (%s)", + (unsigned long long)id, (int)status, (int)error, bluetooth_error(error)); + if (status == GattSessionStatus::Closed && s.link(id)) s.error(id, "GATT session closed"); + }); + }); + l.pdu_changed = l.session.MaxPduSizeChanged(auto_revoke, [weak, id](auto const& session, auto const&) { + auto mtu = session.MaxPduSize(); + if (auto s = weak.lock()) s->post([id, mtu](WinState&) { + DEBUG_DEBUG(CAT, "BLE WinRT negotiated PDU changed link=%llu mtu=%u", (unsigned long long)id, (unsigned)mtu); + }); + }); + } + WinLink* incoming(const GattSession& session) { + hstring key = session.DeviceId().Id(); + for (auto& [id, l] : links) if (l->device_id == key) return l->outgoing ? nullptr : l.get(); + std::string endpoint = to_string(key); + if (endpoint.size() >= BLE_ENDPOINT_MAX || links.size() >= BLE_LINKS_MAX) { + DEBUG_WARN(CAT, "BLE WinRT incoming endpoint/connection limit"); return nullptr; + } + struct ble_endpoint ep{}; std::memcpy(ep.id, endpoint.c_str(), endpoint.size() + 1); + ble_link_id id = ble_backend_incoming(owner, &ep); if (!id) return nullptr; + auto l = std::make_unique(); l->id = id; l->endpoint = endpoint; l->device_id = key; l->session = session; + DEBUG_DEBUG(CAT, "BLE WinRT incoming link=%llu endpoint=%s mtu=%u", (unsigned long long)id, endpoint.c_str(), session.MaxPduSize()); + auto* result = l.get(); links.emplace(id, std::move(l)); watch_session(*result); return result; + } + void subscriptions() { + DEBUG_DEBUG(CAT, "BLE WinRT subscriptions clients=%u links=%zu", local_tx.SubscribedClients().Size(), links.size()); + std::vector active; + for (auto const& client : local_tx.SubscribedClients()) { + auto* l = incoming(client.Session()); if (!l) continue; + l->subscriber = client; active.push_back(l->id); + if (!l->opened) { + DEBUG_DEBUG(CAT, "BLE WinRT subscribed link=%llu notification_max=%u", (unsigned long long)l->id, client.MaxNotificationSize()); + l->opened = true; ble_backend_opened(owner, l->id, client.MaxNotificationSize() + 3); + } + } + std::vector gone; + for (auto& [id, l] : links) if (!l->outgoing && l->opened && std::find(active.begin(), active.end(), id) == active.end()) gone.push_back(id); + for (auto id : gone) error(id, "indication subscription removed"); + } + void make_server() { + await("create GATT service", GattServiceProvider::CreateAsync(guid(BLE_SERVICE_UUID)), 0, [](WinState& s, GattServiceProviderResult const& result) { + if (result.Error() != BluetoothError::Success) { s.radio_error("create GATT service", result.Error()); return; } + s.provider = result.ServiceProvider(); + GattLocalCharacteristicParameters parameters; + parameters.CharacteristicProperties(GattCharacteristicProperties::Write); + parameters.WriteProtectionLevel(GattProtectionLevel::Plain); + s.await("create RX characteristic", s.provider.Service().CreateCharacteristicAsync(guid(BLE_RX_UUID), parameters), 0, + [](WinState& s, GattLocalCharacteristicResult const& result) { + if (result.Error() != BluetoothError::Success) { s.radio_error("create RX characteristic", result.Error()); return; } + s.local_rx = result.Characteristic(); s.make_tx(); + }); + }); + } + void make_tx() { + GattLocalCharacteristicParameters parameters; parameters.CharacteristicProperties(GattCharacteristicProperties::Indicate); + parameters.ReadProtectionLevel(GattProtectionLevel::Plain); + await("create TX characteristic", provider.Service().CreateCharacteristicAsync(guid(BLE_TX_UUID), parameters), 0, + [](WinState& s, GattLocalCharacteristicResult const& result) { + if (result.Error() != BluetoothError::Success) { s.radio_error("create TX characteristic", result.Error()); return; } + s.local_tx = result.Characteristic(); s.start_server(); + }); + } + void start_server() { + auto weak = weak_from_this(); + write_requested = local_rx.WriteRequested(auto_revoke, [weak](auto const&, GattWriteRequestedEventArgs const& args) { + try { + auto deferral = std::make_shared(args.GetDeferral()); + auto request = args.GetRequestAsync(); auto session = args.Session(); + request.Completed([weak, deferral, session](auto const& operation, AsyncStatus status) { + if (auto s = weak.lock()) s->post([deferral, session, operation, status](WinState& s) { + if (status != AsyncStatus::Completed) { + DEBUG_WARN(CAT, "BLE WinRT GetRequest status=%d HRESULT=0x%08x", (int)status, (unsigned)operation.ErrorCode().value); + return; + } + auto request = operation.GetResults(); if (!request) { DEBUG_WARN(CAT, "BLE WinRT expired write request"); return; } + if (request.Offset() || request.Option() != GattWriteOption::WriteWithResponse || request.Value().Length() > BLE_VALUE_MAX) { + DEBUG_WARN(CAT, "BLE WinRT invalid write offset=%u option=%d bytes=%u", request.Offset(), + (int)request.Option(), request.Value().Length()); + request.RespondWithProtocolError(0x06); return; + } + auto* l = s.incoming(session); + if (!l) { request.RespondWithProtocolError(0x08); return; } + auto id = l->id; auto data = bytes(request.Value()); + DEBUG_TRACE(CAT, "BLE WinRT RX write link=%llu bytes=%zu", (unsigned long long)id, data.size()); + ble_backend_received(s.owner, id, data.data(), data.size()); + if (s.link(id)) request.Respond(); else request.RespondWithProtocolError(0x08); + }); + }); + } catch (...) { + auto reason = exception_reason("WriteRequested"); + if (auto s = weak.lock()) s->post([reason](WinState& s) { s.fail(reason.c_str()); }); + } + }); + subscriptions_changed = local_tx.SubscribedClientsChanged(auto_revoke, [weak](auto const&, auto const&) { + if (auto s = weak.lock()) s->post([](WinState& s) { s.subscriptions(); }); + }); + service_changed = provider.AdvertisementStatusChanged(auto_revoke, [weak](auto const&, auto const& args) { + auto status = args.Status(); auto error = args.Error(); + if (auto s = weak.lock()) s->post([status, error](WinState& s) { + DEBUG_DEBUG(CAT, "BLE WinRT service advertising state=%d error=%d (%s)", (int)status, (int)error, bluetooth_error(error)); + if (status == GattServiceProviderAdvertisementStatus::Started) { s.service_ready = true; s.ready(); } + else if (status == GattServiceProviderAdvertisementStatus::Aborted) s.radio_error("GATT advertising aborted", error); + else DEBUG_INFO(CAT, "BLE WinRT service advertising state=%d", (int)status); + }); + }); + GattServiceProviderAdvertisingParameters parameters; parameters.IsConnectable(true); parameters.IsDiscoverable(true); + DEBUG_DEBUG(CAT, "BLE WinRT start server service=%s connectable=1 discoverable=1", BLE_SERVICE_UUID); + provider.StartAdvertising(parameters); + watcher = BluetoothLEAdvertisementWatcher(); watcher.ScanningMode(BluetoothLEScanningMode::Active); + received = watcher.Received(auto_revoke, [weak](auto const&, BluetoothLEAdvertisementReceivedEventArgs const& args) { + auto state = weak.lock(); if (!state) return; + bool matching = false; + for (auto const& value : args.Advertisement().ManufacturerData()) if (value.CompanyId() == state->company) matching = true; + if (!matching) return; + state->post([args](WinState& s) { + for (auto const& value : args.Advertisement().ManufacturerData()) { + if (value.CompanyId() != s.company || value.Data().Length() > BLE_BROADCAST_MAX) continue; + struct ble_endpoint ep{}; + std::snprintf(ep.id, sizeof(ep.id), "%012llx/%d", (unsigned long long)args.BluetoothAddress(), (int)args.BluetoothAddressType()); + auto data = bytes(value.Data()); + DEBUG_TRACE(CAT, "BLE WinRT broadcast endpoint=%s company=%04x bytes=%zu rssi=%d", ep.id, s.company, + data.size(), args.RawSignalStrengthInDBm()); + ble_backend_broadcast(s.owner, &ep, data.data(), data.size(), args.RawSignalStrengthInDBm()); + } + }); + }); + scan_stopped = watcher.Stopped(auto_revoke, [weak](auto const&, auto const& args) { + auto error = args.Error(); if (auto s = weak.lock()) s->post([error](WinState& s) { + s.radio_error("scan stopped", error); + }); + }); + watcher.Start(); + DEBUG_DEBUG(CAT, "BLE WinRT scan requested mode=active status=%d company=%04x", (int)watcher.Status(), company); + scan_ready = true; ready(); + } + void publish() { + DEBUG_DEBUG(CAT, "BLE WinRT publish requested enabled=%d bytes=%zu generation=%llu", broadcast_enabled, + broadcast_size, (unsigned long long)publish_generation); + if (publisher) { + auto status = publisher.Status(); + if (status == BluetoothLEAdvertisementPublisherStatus::Started || status == BluetoothLEAdvertisementPublisherStatus::Waiting) { + publisher.Stop(); return; + } + if (status == BluetoothLEAdvertisementPublisherStatus::Stopping) return; + publish_changed.revoke(); publisher = nullptr; + } + if (!broadcast_enabled) return; + BluetoothLEAdvertisement advertisement; + BluetoothLEManufacturerData data; data.CompanyId(company); data.Data(buffer(broadcast.data(), broadcast_size)); + advertisement.ManufacturerData().Append(data); publisher = BluetoothLEAdvertisementPublisher(advertisement); + auto weak = weak_from_this(); auto generation = ++publish_generation; + publish_changed = publisher.StatusChanged(auto_revoke, [weak, generation](auto const&, auto const& args) { + auto status = args.Status(); auto error = args.Error(); + if (auto s = weak.lock()) s->post([status, error, generation](WinState& s) { + if (generation != s.publish_generation) return; + DEBUG_DEBUG(CAT, "BLE WinRT broadcast state=%d error=%d (%s) generation=%llu", (int)status, (int)error, + bluetooth_error(error), (unsigned long long)generation); + if (status == BluetoothLEAdvertisementPublisherStatus::Aborted) s.radio_error("broadcast publication aborted", error); + else if (status == BluetoothLEAdvertisementPublisherStatus::Stopped) { + ble_backend_advertising(s.owner, BLE_CLOSED, "publisher stopped"); s.publish(); + } else if (status == BluetoothLEAdvertisementPublisherStatus::Started) ble_backend_advertising(s.owner, BLE_READY, "publisher started"); + else ble_backend_advertising(s.owner, BLE_STARTING, "publisher waiting/stopping"); + }); + }); + publisher.Start(); + } + void connect(ble_link_id id, uint64_t address, BluetoothAddressType type) { + DEBUG_DEBUG(CAT, "BLE WinRT connect link=%llu address=%012llx type=%d", (unsigned long long)id, (unsigned long long)address, (int)type); + await("open BLE device", BluetoothLEDevice::FromBluetoothAddressAsync(address, type), id, [id](WinState& s, BluetoothLEDevice const& device) { + auto* l = s.link(id); + if (!device) { s.error(id, "BLE device unavailable"); return; } + l->device = device; l->device_id = device.BluetoothDeviceId().Id(); + DEBUG_DEBUG(CAT, "BLE WinRT device link=%llu endpoint=%s status=%d", (unsigned long long)id, + to_string(l->device_id).c_str(), (int)device.ConnectionStatus()); + s.await("discover GATT service", device.GetGattServicesForUuidAsync(guid(BLE_SERVICE_UUID), BluetoothCacheMode::Uncached), id, + [id](WinState& s, GattDeviceServicesResult const& result) { + auto protocol = result.ProtocolError(); + DEBUG_DEBUG(CAT, "BLE WinRT services link=%llu status=%d ATT_error=%d count=%u", (unsigned long long)id, + (int)result.Status(), protocol ? int(protocol.Value()) : -1, + result.Status() == GattCommunicationStatus::Success ? result.Services().Size() : 0); + if (result.Status() != GattCommunicationStatus::Success || result.Services().Size() != 1) { + s.error(id, "VibeChat GATT service unavailable/ambiguous"); return; + } + auto* l = s.link(id); l->service = result.Services().GetAt(0); + s.await("discover GATT characteristics", l->service.GetCharacteristicsAsync(BluetoothCacheMode::Uncached), id, + [id](WinState& s, GattCharacteristicsResult const& result) { + auto protocol = result.ProtocolError(); + DEBUG_DEBUG(CAT, "BLE WinRT characteristics link=%llu status=%d ATT_error=%d count=%u", (unsigned long long)id, + (int)result.Status(), protocol ? int(protocol.Value()) : -1, + result.Status() == GattCommunicationStatus::Success ? result.Characteristics().Size() : 0); + if (result.Status() != GattCommunicationStatus::Success) { s.error(id, "GATT discovery failed"); return; } + auto* l = s.link(id); + for (auto const& ch : result.Characteristics()) { + DEBUG_DEBUG(CAT, "BLE WinRT characteristic link=%llu uuid=%s properties=0x%x", (unsigned long long)id, + to_string(to_hstring(ch.Uuid())).c_str(), (unsigned)ch.CharacteristicProperties()); + if (ch.Uuid() == guid(BLE_RX_UUID)) l->rx = ch; + if (ch.Uuid() == guid(BLE_TX_UUID)) l->tx = ch; + } + if (!l->rx || !l->tx) { s.error(id, "RX/TX characteristics missing"); return; } + s.subscribe(id); + }); + }); + }); + } + void subscribe(ble_link_id id) { + auto* l = link(id); + await("open GATT session", GattSession::FromDeviceIdAsync(l->device.BluetoothDeviceId()), id, [id](WinState& s, GattSession const& session) { + auto* l = s.link(id); + if (!session) { s.error(id, "GATT session unavailable"); return; } + l->session = session; session.MaintainConnection(true); s.watch_session(*l); + DEBUG_DEBUG(CAT, "BLE WinRT session opened link=%llu mtu=%u maintain_connection=1", (unsigned long long)id, session.MaxPduSize()); + auto weak = s.weak_from_this(); + l->value_changed = l->tx.ValueChanged(auto_revoke, [weak, id](auto const&, GattValueChangedEventArgs const& args) { + if (auto s = weak.lock()) s->post([id, args](WinState& s) { + if (!s.link(id)) return; + auto data = bytes(args.CharacteristicValue()); + DEBUG_TRACE(CAT, "BLE WinRT RX indication link=%llu bytes=%zu", (unsigned long long)id, data.size()); + ble_backend_received(s.owner, id, data.data(), data.size()); + }); + }); + s.await("subscribe indications", l->tx.WriteClientCharacteristicConfigurationDescriptorAsync(GattClientCharacteristicConfigurationDescriptorValue::Indicate), id, + [id](WinState& s, GattCommunicationStatus status) { + DEBUG_DEBUG(CAT, "BLE WinRT subscribe link=%llu status=%d", (unsigned long long)id, (int)status); + if (status != GattCommunicationStatus::Success) { s.error(id, "GATT subscription failed"); return; } + auto* l = s.link(id); l->opened = true; ble_backend_opened(s.owner, id, l->session.MaxPduSize()); + }); + }); + } + void close(ble_link_id id) { + auto it = links.find(id); if (it == links.end()) return; + auto l = std::move(it->second); links.erase(it); + DEBUG_DEBUG(CAT, "BLE WinRT close link=%llu outgoing=%d opened=%d sending=%d pending=%d", (unsigned long long)id, + l->outgoing, l->opened, l->sending, bool(l->operation)); + l->value_changed.revoke(); l->session_changed.revoke(); l->pdu_changed.revoke(); + try { + if (l->operation) l->operation.Cancel(); + if (l->outgoing) { + if (l->session) { l->session.MaintainConnection(false); l->session.Close(); } + if (l->service) l->service.Close(); + if (l->device) l->device.Close(); + } + } catch (...) { DEBUG_WARN(CAT, "%s", exception_reason("close", id).c_str()); } + } + void shutdown() { + struct posted_task* task; + std::deque> discarded; + { std::lock_guard guard(mutex); stopped = true; task = posted; posted = nullptr; discarded.swap(events); } + DEBUG_DEBUG(CAT, "BLE WinRT shutdown links=%zu discarded_events=%zu pending_dispatch=%d", links.size(), discarded.size(), task != nullptr); + discarded.clear(); + if (task) uasync_cancel_post(ua, task); + received.revoke(); scan_stopped.revoke(); publish_changed.revoke(); service_changed.revoke(); + write_requested.revoke(); subscriptions_changed.revoke(); + while (!links.empty()) close(links.begin()->first); + try { if (startup) startup.Cancel(); } catch (...) { DEBUG_WARN(CAT, "%s", exception_reason("cancel startup").c_str()); } + try { if (watcher) watcher.Stop(); } catch (...) { DEBUG_WARN(CAT, "%s", exception_reason("stop scan").c_str()); } + try { if (publisher) publisher.Stop(); } catch (...) { DEBUG_WARN(CAT, "%s", exception_reason("stop publisher").c_str()); } + try { if (provider) provider.StopAdvertising(); } catch (...) { DEBUG_WARN(CAT, "%s", exception_reason("stop server").c_str()); } + watcher = nullptr; publisher = nullptr; provider = nullptr; local_rx = nullptr; local_tx = nullptr; startup = nullptr; + if (apartment) { uninit_apartment(); apartment = false; } + } +}; +struct ble_backend { std::shared_ptr state; }; +extern "C" struct ble_backend* ble_backend_create(struct ble_transport* owner, struct UASYNC* ua, const struct ble_config* cfg) { + if (cfg->adapter) { DEBUG_ERROR(CAT, "BLE WinRT explicit adapter selection unsupported"); return nullptr; } + auto state = std::make_shared(); state->owner = owner; state->ua = ua; state->company = cfg->company_id; + try { + DEBUG_DEBUG(CAT, "BLE WinRT initialize company=%04x node=%016llx apartment=MTA", cfg->company_id, (unsigned long long)cfg->node_id); + init_apartment(apartment_type::multi_threaded); state->apartment = true; + state->await("get default adapter", BluetoothAdapter::GetDefaultAsync(), 0, [](WinState& s, BluetoothAdapter const& adapter) { + if (!adapter) { s.fail("no default Bluetooth adapter"); return; } + DEBUG_DEBUG(CAT, "BLE WinRT adapter id=%s address=%012llx BLE=%d central=%d peripheral=%d", to_string(adapter.DeviceId()).c_str(), + (unsigned long long)adapter.BluetoothAddress(), adapter.IsLowEnergySupported(), + adapter.IsCentralRoleSupported(), adapter.IsPeripheralRoleSupported()); + try { + auto advertising = adapter.try_as(); + auto phy = adapter.try_as(); + DEBUG_DEBUG(CAT, "BLE WinRT adapter extended_advertising=%d advertisement_max=%d PHY_2M_supported=%d PHY_coded_supported=%d (-1=API unavailable)", + advertising ? int(advertising.IsExtendedAdvertisingSupported()) : -1, + advertising ? int(advertising.MaxAdvertisementDataLength()) : -1, + phy ? int(phy.IsLowEnergyUncoded2MPhySupported()) : -1, phy ? int(phy.IsLowEnergyCodedPhySupported()) : -1); + } catch (...) { DEBUG_WARN(CAT, "%s", exception_reason("optional adapter capabilities").c_str()); } + if (!adapter.IsLowEnergySupported() || !adapter.IsCentralRoleSupported() || !adapter.IsPeripheralRoleSupported()) { + s.fail("adapter lacks BLE central/peripheral support"); return; + } + s.make_server(); + }); + void* memory = u_malloc(sizeof(ble_backend)); + if (!memory) { DEBUG_ERROR(CAT, "BLE WinRT allocation failed"); state->shutdown(); return nullptr; } + return new(memory) ble_backend{state}; + } catch (...) { DEBUG_ERROR(CAT, "%s", exception_reason("initialize").c_str()); state->shutdown(); return nullptr; } +} +extern "C" void ble_backend_destroy(struct ble_backend* b) { + if (!b) return; + b->state->shutdown(); b->~ble_backend(); u_free(b); +} +extern "C" int ble_backend_advertise(struct ble_backend* b, const uint8_t* data, size_t len) { + try { + auto& s = *b->state; s.broadcast_enabled = data != nullptr; s.broadcast_size = len; + if (len) std::memcpy(s.broadcast.data(), data, len); + s.publish(); return BLE_OK; + } catch (...) { DEBUG_ERROR(CAT, "%s", exception_reason("advertise").c_str()); return BLE_ERROR; } +} +extern "C" int ble_backend_connect(struct ble_backend* b, ble_link_id id, const struct ble_endpoint* ep) { + unsigned long long address; int type, consumed = 0; + if (std::sscanf(ep->id, "%llx/%d%n", &address, &type, &consumed) != 2 || ep->id[consumed] || address >> 48 || type < 0 || type > 2) { + DEBUG_ERROR(CAT, "BLE invalid WinRT endpoint=%s", ep->id); return BLE_ERROR; + } + try { + auto& s = *b->state; auto l = std::make_unique(); l->id = id; l->endpoint = ep->id; l->outgoing = true; + s.links.emplace(id, std::move(l)); s.connect(id, address, static_cast(type)); return BLE_OK; + } catch (...) { DEBUG_ERROR(CAT, "%s", exception_reason("connect", id).c_str()); b->state->close(id); return BLE_ERROR; } +} +extern "C" void ble_backend_close(struct ble_backend* b, ble_link_id id) { b->state->close(id); } +extern "C" int ble_backend_send(struct ble_backend* b, ble_link_id id, const uint8_t* data, size_t len) { + auto& s = *b->state; auto* l = s.link(id); + if (!l || !l->opened || l->sending) { + DEBUG_ERROR(CAT, "BLE invalid WinRT send link=%llu exists=%d opened=%d sending=%d bytes=%zu", (unsigned long long)id, + l != nullptr, l && l->opened, l && l->sending, len); + return BLE_ERROR; + } + try { + DEBUG_TRACE(CAT, "BLE WinRT TX fragment link=%llu outgoing=%d bytes=%zu", (unsigned long long)id, l->outgoing, len); + l->sending = true; auto buffer = WinState::buffer(data, len); + if (l->outgoing) s.await("write frame", l->rx.WriteValueWithResultAsync(buffer, GattWriteOption::WriteWithResponse), id, + [id, len](WinState& s, GattWriteResult const& result) { + auto* l = s.link(id); l->sending = false; + auto protocol = result.ProtocolError(); + if (result.Status() != GattCommunicationStatus::Success) + DEBUG_WARN(CAT, "BLE WinRT write failed link=%llu bytes=%zu status=%d ATT_error=%d", (unsigned long long)id, + len, (int)result.Status(), protocol ? int(protocol.Value()) : -1); + else DEBUG_TRACE(CAT, "BLE WinRT write complete link=%llu bytes=%zu", (unsigned long long)id, len); + ble_backend_sent(s.owner, id, result.Status() == GattCommunicationStatus::Success); + }); + else s.await("indicate frame", s.local_tx.NotifyValueAsync(buffer, l->subscriber), id, [id, len](WinState& s, GattClientNotificationResult const& result) { + auto* l = s.link(id); l->sending = false; + if (result.Status() != GattCommunicationStatus::Success || result.BytesSent() != len) + DEBUG_WARN(CAT, "BLE WinRT indication failed link=%llu expected=%zu sent=%u status=%d", (unsigned long long)id, + len, result.BytesSent(), (int)result.Status()); + else DEBUG_TRACE(CAT, "BLE WinRT indication complete link=%llu bytes=%zu", (unsigned long long)id, len); + ble_backend_sent(s.owner, id, result.Status() == GattCommunicationStatus::Success && result.BytesSent() == len); + }); + return BLE_OK; + } catch (...) { l->sending = false; DEBUG_ERROR(CAT, "%s", exception_reason("send", id).c_str()); return BLE_ERROR; } +} diff --git a/lib/ble/tests/ble_peer_probe.c b/lib/ble/tests/ble_peer_probe.c new file mode 100644 index 000000000..c276276e4 --- /dev/null +++ b/lib/ble/tests/ble_peer_probe.c @@ -0,0 +1,87 @@ +/* Реальное Android↔desktop радио. Android instrumentation включает только тестовый echo. + * ble_peer_probe [connect|listen] [seconds]; 96 пакетов, TX saturation, побайтовая сверка. */ +#include "ble_transport.h" +#include "ble_identity.h" +#include "u_async.h" +#include "debug_config.h" +#include +#include +#include +static struct ble_transport* transport; +static ble_link_id session; +static unsigned sent, received_count, blocked; +static int failed, dial, seen, radio_ready; +static uint64_t peer, started, verified_bytes; +static const unsigned lengths[] = {1,13,14,20,244,512,1499,1500}; +static unsigned length(unsigned n) { return lengths[n % 8]; } +static uint8_t probe_byte(unsigned n, unsigned i) { return (uint8_t)(n + i * 37); } +static void send_more(void* arg, ble_link_id id) { + (void)arg; + while (sent < 96) { + struct ll_entry* e = ll_alloc_lldgram(length(sent)); + if (!e) { failed = 1; return; } + e->len = length(sent); + for (unsigned i = 0; i < e->len; i++) e->dgram[i] = probe_byte(sent, i); + int result = ble_tx_put(transport, id, e); + if (result != BLE_OK) { + queue_dgram_free(e); queue_entry_free(e); + if (result == BLE_WOULD_BLOCK) blocked++; else failed = 1; + return; + } + sent++; + } +} +static void received(void* arg, ble_link_id id) { + (void)arg; struct ll_entry* e; + while ((e = ble_rx_get(transport, id))) { + unsigned n = received_count; + if (n >= 96 || e->len != length(n)) failed = 1; + for (unsigned i = 0; i < e->len; i++) if (e->dgram[i] != probe_byte(n, i)) failed = 1; + printf("VERIFY packet=%u bytes=%u ok=%d\n", n, e->len, !failed); fflush(stdout); + verified_bytes += e->len; received_count++; queue_dgram_free(e); queue_entry_free(e); + } +} +static void connected(void* arg, ble_link_id id, uint64_t node, enum ble_state state, const char* reason) { + (void)arg; + printf("CONNECTION link=%llu peer=%016llx state=%d reason=%s\n", (unsigned long long)id, (unsigned long long)node, state, reason); + if (state == BLE_READY) { session = id; peer = node; started = get_time_us(); send_more(NULL, id); } + if (state == BLE_CLOSED && received_count < 96) failed = 1; +} +static void status(void* arg, enum ble_state state, const char* reason) { + (void)arg; printf("STATUS state=%d reason=%s\n", state, reason); + radio_ready = state == BLE_READY; + if (state == BLE_READY) { + uint8_t data[24]; int n = ble_identity_encode(data, 0x5643424c45544553ULL, "Desktop-test"); + if (ble_advertise(transport, data, n) != BLE_OK) failed = 1; + } +} +static void broadcast(void* arg, const struct ble_endpoint* ep, const uint8_t* data, size_t len, int rssi) { + (void)arg; struct ble_identity identity; + if (ble_identity_decode(&identity, data, len) < 0) return; + if (!seen) printf("DISCOVER node=%016llx name=%s rssi=%d endpoint=%s\n", + (unsigned long long)identity.node_id, identity.name, rssi, ep->id); + seen = 1; + if (dial && radio_ready && !session) { session = ble_connect(transport, ep, identity.node_id); if (!session) failed = 1; } +} +int main(int argc, char** argv) { + dial = argc < 2 || !strcmp(argv[1], "connect"); + int seconds = argc > 2 ? atoi(argv[2]) : 90; + debug_config_init(); debug_set_level(DEBUG_LEVEL_INFO); debug_set_category_level(DEBUG_CATEGORY_DISCOVERY, DEBUG_LEVEL_DEBUG); + struct UASYNC* ua = uasync_create(); struct ble_config cfg = {0}; + cfg.node_id = 0x5643424c45544553ULL; cfg.company_id = 0xffff; + cfg.callbacks.status = status; cfg.callbacks.broadcast = broadcast; cfg.callbacks.connection = connected; + cfg.callbacks.received = received; cfg.callbacks.writable = send_more; + transport = ble_create(ua, &cfg); + if (!transport || ble_start(transport) != BLE_OK) failed = 1; + uint64_t end = get_time_us() + (uint64_t)seconds * 1000000; + while (!failed && received_count < 96 && get_time_us() < end) uasync_poll(ua, 100); + int ok = !failed && received_count == 96 && seen && blocked; + printf("RESULT ok=%d sent=%u verified=%u blocked=%u discovered=%d peer=%016llx\n", + ok, sent, received_count, blocked, seen, (unsigned long long)peer); + if (started) { + double seconds = (get_time_us() - started) / 1000000.0; + printf("THROUGHPUT verified_bytes_each_direction=%llu seconds=%.3f aggregate_bytes_per_second=%.0f\n", + (unsigned long long)verified_bytes, seconds, seconds > 0 ? verified_bytes * 2 / seconds : 0); + } + ble_destroy(transport); uasync_destroy(ua, 0); return !ok; +} diff --git a/lib/ble/tests/ble_probe.c b/lib/ble/tests/ble_probe.c new file mode 100644 index 000000000..394727f04 --- /dev/null +++ b/lib/ble/tests/ble_probe.c @@ -0,0 +1,37 @@ +/* Реальное радио, запуск вручную: ble_probe [seconds]. Публикует только тестовое объявление. */ +#include "ble_transport.h" +#include "u_async.h" +#include "debug_config.h" +#include +#include +#include +static int ready, failed; +static struct ble_transport* transport; +static void status(void* arg, enum ble_state state, const char* reason) { + (void)arg; printf("BLE status=%d reason=%s\n", state, reason); fflush(stdout); + if (state == BLE_READY) { ready = 1; if (ble_advertise(transport, "vibechat-probe", 14) != BLE_OK) failed = 1; } + if (state == BLE_CLOSED && strcmp(reason, "stopped")) failed = 1; +} +static void broadcast(void* arg, const struct ble_endpoint* ep, const uint8_t* data, size_t len, int rssi) { + (void)arg; (void)data; printf("BLE broadcast endpoint=%s bytes=%zu rssi=%d\n", ep->id, len, rssi); +} +int main(int argc, char** argv) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_INFO); debug_set_category_level(DEBUG_CATEGORY_DISCOVERY, DEBUG_LEVEL_DEBUG); + int seconds = argc > 1 ? atoi(argv[1]) : 5; + if (seconds < 1 || seconds > 60) return 2; + struct UASYNC* ua = uasync_create(); + struct ble_config cfg = {0}; cfg.node_id = 0x5643424c45544553ULL; cfg.company_id = 0xffff; + cfg.callbacks.status = status; cfg.callbacks.broadcast = broadcast; + transport = ble_create(ua, &cfg); + if (!transport || ble_start(transport) != BLE_OK) failed = 1; + uint64_t start = get_time_us(), end = start + (uint64_t)seconds * 1000000; + int changed = 0; + while (!failed && get_time_us() < end) { + uasync_poll(ua, 100); + if (ready && !changed && get_time_us() >= start + 1000000) { + changed = 1; if (ble_advertise(transport, "vibechat-updated", 16) != BLE_OK) failed = 1; + } + } + ble_destroy(transport); uasync_destroy(ua, 0); + printf("BLE probe ready=%d failed=%d\n", ready, failed); return failed || !ready; +} diff --git a/lib/ble/tests/test_ble_bluez.c b/lib/ble/tests/test_ble_bluez.c new file mode 100644 index 000000000..9613426cf --- /dev/null +++ b/lib/ble/tests/test_ble_bluez.c @@ -0,0 +1,139 @@ +/* Private D-Bus + настоящий backend. Два серверных клиента: fd/подтверждения/кадры не смешиваются. */ +#include "ble_backend.h" +#include "u_async.h" +#include "mem.h" +#include "debug_config.h" +#include +#include +#include +#include +#include +#include +#include +#include +#define CHECK(v) do { if (!(v)) { fprintf(stderr, "FAIL line=%d: %s errno=%d\n", __LINE__, #v, errno); exit(1); } } while (0) +#define ROOT "/org/vibechat/ble" +#define ADAPTER "/org/bluez/hci0" +static struct UASYNC* ua; +static sd_bus* bus; +static struct ble_transport* transport; +static char application[256]; +static int ready, broadcasts, ready_links, advertisement_count; +static ble_link_id links[3]; +struct reply { sd_bus_slot* slot; int done, fd, failed; }; +static void poll_all(void) { + for (int i = 0; i < 500; i++) { CHECK(sd_bus_process(bus, NULL) >= 0); uasync_poll(ua, 10); } +} +static int register_app(sd_bus_message* m, void* arg, sd_bus_error* e) { + (void)arg; (void)e; snprintf(application, sizeof(application), "%s", sd_bus_message_get_sender(m)); + return sd_bus_reply_method_return(m, ""); +} +static int simple(sd_bus_message* m, void* arg, sd_bus_error* e) { (void)arg; (void)e; return sd_bus_reply_method_return(m, ""); } +static int advertise(sd_bus_message* m, void* arg, sd_bus_error* e) { + (void)arg; (void)e; advertisement_count++; + return sd_bus_reply_method_return(m, ""); +} +static int property(sd_bus* b, const char* p, const char* i, const char* name, sd_bus_message* m, void* arg, sd_bus_error* e) { + (void)b; (void)p; (void)i; (void)name; (void)arg; (void)e; return sd_bus_message_append(m, "b", 1); +} +static const sd_bus_vtable adapter_vtable[] = { + SD_BUS_VTABLE_START(0), SD_BUS_PROPERTY("Powered", "b", property, 0, 0), + SD_BUS_METHOD("SetDiscoveryFilter", "a{sv}", "", simple, SD_BUS_VTABLE_UNPRIVILEGED), + SD_BUS_METHOD("StartDiscovery", "", "", simple, SD_BUS_VTABLE_UNPRIVILEGED), SD_BUS_VTABLE_END +}; +static const sd_bus_vtable gatt_vtable[] = { + SD_BUS_VTABLE_START(0), SD_BUS_METHOD("RegisterApplication", "oa{sv}", "", register_app, SD_BUS_VTABLE_UNPRIVILEGED), SD_BUS_VTABLE_END +}; +static const sd_bus_vtable adv_vtable[] = { + SD_BUS_VTABLE_START(0), SD_BUS_METHOD("RegisterAdvertisement", "oa{sv}", "", advertise, SD_BUS_VTABLE_UNPRIVILEGED), + SD_BUS_METHOD("UnregisterAdvertisement", "o", "", simple, SD_BUS_VTABLE_UNPRIVILEGED), SD_BUS_VTABLE_END +}; +static void status(void* arg, enum ble_state state, const char* reason) { + (void)arg; (void)reason; if (state == BLE_READY) ready++; +} +static void connection(void* arg, ble_link_id id, uint64_t node, enum ble_state state, const char* reason) { + (void)arg; (void)reason; + if (state == BLE_READY) { CHECK(node < 3); links[node] = id; ready_links++; } +} +static void broadcast(void* arg, const struct ble_endpoint* ep, const uint8_t* data, size_t len, int rssi) { + (void)arg; CHECK(strstr(ep->id, "dev_A")); CHECK(len == 3 && !memcmp(data, "abc", 3)); CHECK(rssi == -42); broadcasts++; +} +static int replied(sd_bus_message* m, void* arg, sd_bus_error* error) { + (void)error; struct reply* reply = arg; + reply->failed = sd_bus_message_is_method_error(m, NULL); + if (reply->failed) { const sd_bus_error* e = sd_bus_message_get_error(m); fprintf(stderr, "reply error %s: %s\n", e->name, e->message); } + if (!reply->failed && !strcmp(sd_bus_message_get_signature(m, 1), "hq")) { + int fd; uint16_t mtu; CHECK(sd_bus_message_read(m, "hq", &fd, &mtu) > 0); CHECK(mtu == 23); + reply->fd = fcntl(fd, F_DUPFD_CLOEXEC, 3); CHECK(reply->fd >= 0); + } + reply->done = 1; return 0; +} +static int acquire(const char* device) { + struct reply reply = {.fd=-1}; + CHECK(sd_bus_call_method_async(bus, &reply.slot, application, ROOT "/service0/tx", "org.bluez.GattCharacteristic1", + "AcquireNotify", replied, &reply, "a{sv}", 2, "device", "o", device, "mtu", "q", 23) >= 0); + poll_all(); CHECK(reply.done && !reply.failed && reply.fd >= 0); sd_bus_slot_unref(reply.slot); return reply.fd; +} +static void write_value(const char* device, const uint8_t* data, size_t len) { + struct reply reply = {0}; sd_bus_message* m = NULL; + CHECK(sd_bus_message_new_method_call(bus, &m, application, ROOT "/service0/rx", "org.bluez.GattCharacteristic1", "WriteValue") >= 0); + CHECK(sd_bus_message_append_array(m, 'y', data, len) >= 0); + CHECK(sd_bus_message_append(m, "a{sv}", 2, "device", "o", device, "mtu", "q", 23) >= 0); + CHECK(sd_bus_call_async(bus, &reply.slot, m, replied, &reply, 1000000) >= 0); sd_bus_message_unref(m); + poll_all(); CHECK(reply.done && !reply.failed); sd_bus_slot_unref(reply.slot); +} +static void announce(void) { + sd_bus_message* m = NULL; + CHECK(sd_bus_message_new_signal(bus, &m, ADAPTER "/dev_A", "org.freedesktop.DBus.Properties", "PropertiesChanged") >= 0); + CHECK(sd_bus_message_append(m, "sa{sv}as", "org.bluez.Device1", 2, + "ManufacturerData", "a{qv}", 1, 0xffff, "ay", 3, 'a', 'b', 'c', "RSSI", "n", -42, 0) >= 0); + CHECK(sd_bus_send(bus, m, NULL) >= 0); sd_bus_message_unref(m); poll_all(); CHECK(broadcasts == 1); +} +static void hello(const char* device, int fd, uint8_t node) { + uint8_t data[17] = {1}; data[8] = node; data[16] = 200; + write_value(device, data, sizeof(data)); + CHECK(recv(fd, data, sizeof(data), MSG_DONTWAIT) == 17); CHECK(data[0] == 2 && data[8] == 200 && data[16] == node); + CHECK(send(fd, "\1", 1, MSG_NOSIGNAL) == 1); poll_all(); +} +static struct ll_entry* frame(int value) { + struct ll_entry* e = ll_alloc_lldgram(31); CHECK(e); e->len = 31; memset(e->dgram, value, e->len); return e; +} +int main(void) { + CHECK(getenv("DBUS_SESSION_BUS_ADDRESS")); CHECK(!setenv("DBUS_SYSTEM_BUS_ADDRESS", getenv("DBUS_SESSION_BUS_ADDRESS"), 1)); + debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); + size_t before = u_get_allocated_count(); + CHECK(sd_bus_open_user(&bus) >= 0); CHECK(sd_bus_request_name(bus, "org.bluez", 0) >= 0); + sd_bus_slot *manager = NULL, *adapter = NULL, *gatt = NULL, *adv = NULL; + CHECK(sd_bus_add_object_manager(bus, &manager, "/") >= 0); + CHECK(sd_bus_add_object_vtable(bus, &adapter, ADAPTER, "org.bluez.Adapter1", adapter_vtable, NULL) >= 0); + CHECK(sd_bus_add_object_vtable(bus, &gatt, ADAPTER, "org.bluez.GattManager1", gatt_vtable, NULL) >= 0); + CHECK(sd_bus_add_object_vtable(bus, &adv, ADAPTER, "org.bluez.LEAdvertisingManager1", adv_vtable, NULL) >= 0); + ua = uasync_create(); CHECK(ua); + struct ble_config config = {0}; config.node_id = 200; config.company_id = 0xffff; + config.callbacks.status = status; config.callbacks.connection = connection; config.callbacks.broadcast = broadcast; + transport = ble_create(ua, &config); CHECK(transport && !ble_start(transport)); poll_all(); CHECK(ready == 1); + CHECK(!ble_advertise(transport, "abc", 3)); poll_all(); CHECK(advertisement_count == 2); + CHECK(!ble_advertise(transport, "def", 3)); poll_all(); CHECK(advertisement_count == 3); + announce(); + int fd1 = acquire(ADAPTER "/dev_A"), fd2 = acquire(ADAPTER "/dev_B"); + hello(ADAPTER "/dev_A", fd1, 1); hello(ADAPTER "/dev_B", fd2, 2); CHECK(ready_links == 2); + CHECK(!ble_tx_put(transport, links[1], frame(0xa1))); CHECK(!ble_tx_put(transport, links[2], frame(0xb2))); poll_all(); + uint8_t data[64]; CHECK(recv(fd1, data, sizeof(data), MSG_DONTWAIT) == 20 && data[7] == 0xa1); + CHECK(recv(fd2, data, sizeof(data), MSG_DONTWAIT) == 20 && data[7] == 0xb2); + /* ACK второго клиента не разрешает следующий фрагмент первому. */ + CHECK(send(fd2, "\1", 1, MSG_NOSIGNAL) == 1); poll_all(); + CHECK(recv(fd1, data, sizeof(data), MSG_DONTWAIT) < 0 && errno == EAGAIN); + CHECK(recv(fd2, data, sizeof(data), MSG_DONTWAIT) == 20 && data[6] == 13 && data[7] == 0xb2); + CHECK(send(fd2, "\1", 1, MSG_NOSIGNAL) == 1); poll_all(); + CHECK(recv(fd2, data, sizeof(data), MSG_DONTWAIT) == 12 && data[6] == 26 && data[7] == 0xb2); + CHECK(send(fd2, "\1", 1, MSG_NOSIGNAL) == 1); poll_all(); + uint8_t incoming[] = {3, 0, 1, 0, 3, 0, 0, 0x11, 0x22, 0x33}; + write_value(ADAPTER "/dev_B", incoming, sizeof(incoming)); + CHECK(!ble_rx_get(transport, links[1])); struct ll_entry* e = ble_rx_get(transport, links[2]); CHECK(e && e->len == 3); + CHECK(!memcmp(e->dgram, incoming + 7, 3)); queue_dgram_free(e); queue_entry_free(e); + close(fd1); poll_all(); close(fd2); poll_all(); + ble_destroy(transport); uasync_destroy(ua, 0); + sd_bus_slot_unref(manager); sd_bus_slot_unref(adapter); sd_bus_slot_unref(gatt); sd_bus_slot_unref(adv); sd_bus_close_unref(bus); + CHECK(u_get_allocated_count() == before); puts("BlueZ private-bus tests PASS (broadcasts, two clients, addressed indications, RX, teardown)"); + return 0; +} diff --git a/lib/ble/tests/test_ble_identity.c b/lib/ble/tests/test_ble_identity.c new file mode 100644 index 000000000..1c36f2cc4 --- /dev/null +++ b/lib/ble/tests/test_ble_identity.c @@ -0,0 +1,23 @@ +#include "ble_identity.h" +#include "debug_config.h" +#include +#include +#include +int main(void) { + debug_config_init(); + uint8_t bytes[24]; struct ble_identity peer; + int n = ble_identity_encode(bytes, 0x0102030405060708ULL, "АБВГДЕЖ"); + assert(n == 23 && bytes[0] == 'V' && bytes[1] == 'C' && bytes[2] == 1 && bytes[3] == 1 && bytes[10] == 8); + assert(ble_identity_decode(&peer, bytes, n) == 0); + assert(peer.node_id == 0x0102030405060708ULL && !strcmp(peer.name, "АБВГДЕ")); + assert(ble_identity_encode(bytes, 1, "123456789012😀") == 23); + assert(ble_identity_encode(bytes, 1, "1234567890123tail") == 24); + assert(ble_identity_decode(&peer, bytes, 24) == 0 && !strcmp(peer.name, "1234567890123")); + assert(ble_identity_decode(&peer, bytes, 25) < 0); + bytes[2] = 2; assert(ble_identity_decode(&peer, bytes, 24) < 0); + n = ble_identity_encode(bytes, 1, ""); assert(n == 11 && ble_identity_decode(&peer, bytes, n) == 0); + bytes[11] = 0xc0; bytes[12] = 0x80; assert(ble_identity_decode(&peer, bytes, 13) < 0); + assert(ble_identity_encode(bytes, 0, "name") < 0); + assert(ble_identity_encode(bytes, 1, "\xed\xa0\x80") < 0); + puts("BLE identity PASS"); return 0; +} diff --git a/lib/ble/tests/test_ble_transport.c b/lib/ble/tests/test_ble_transport.c new file mode 100644 index 000000000..a62c173c8 --- /dev/null +++ b/lib/ble/tests/test_ble_transport.c @@ -0,0 +1,126 @@ +/* Два/три настоящих common transport с управляемым асинхронным GATT backend: проверяется весь lifecycle. */ +#include "ble_backend.h" +#include "u_async.h" +#include "mem.h" +#include "debug_config.h" +#include +#include +#include +#define CHECK(v) do { if (!(v)) { fprintf(stderr, "FAIL line=%d: %s\n", __LINE__, #v); exit(1); } } while (0) +struct fake_link { ble_link_id id, other; struct ble_backend* peer; uint8_t data[512]; size_t len; int pending; }; +struct ble_backend { struct ble_transport* owner; struct UASYNC* ua; void* task; int index, started; struct fake_link links[16]; }; +static struct ble_backend* backends[3]; +static unsigned int mtu = 23; +static int hold, drop, reject_peer, ready_count, writable_count; +static ble_link_id incoming_ids[3]; +static void run(void* arg); +static void schedule(struct ble_backend* b) { if (!b->task) b->task = uasync_call_soon(b->ua, b, run); CHECK(b->task); } +struct ble_backend* ble_backend_create(struct ble_transport* owner, struct UASYNC* ua, const struct ble_config* cfg) { + struct ble_backend* b = u_calloc(1, sizeof(*b)); CHECK(b); b->owner = owner; b->ua = ua; b->index = (int)cfg->node_id - 1; + backends[b->index] = b; schedule(b); return b; +} +void ble_backend_destroy(struct ble_backend* b) { + if (b->task) uasync_call_soon_cancel(b->ua, b->task); + backends[b->index] = NULL; u_free(b); +} +int ble_backend_advertise(struct ble_backend* b, const uint8_t* data, size_t len) { (void)b; (void)data; (void)len; return 0; } +static struct fake_link* slot(struct ble_backend* b, ble_link_id id) { + for (int i = 0; i < 16; i++) if (b->links[i].id == id) return &b->links[i]; + return NULL; +} +int ble_backend_connect(struct ble_backend* b, ble_link_id id, const struct ble_endpoint* ep) { + struct ble_backend* p = backends[atoi(ep->id)]; CHECK(p); + struct fake_link *a = slot(b, 0), *c = slot(p, 0); CHECK(a && c); + struct ble_endpoint remote = {0}; snprintf(remote.id, sizeof(remote.id), "%d", b->index); + ble_link_id other = ble_backend_incoming(p->owner, &remote); CHECK(other); + *a = (struct fake_link){.id=id, .other=other, .peer=p}; + *c = (struct fake_link){.id=other, .other=id, .peer=b}; + incoming_ids[p->index] = other; + schedule(b); schedule(p); return 0; +} +void ble_backend_close(struct ble_backend* b, ble_link_id id) { + struct fake_link* f = slot(b, id); if (f) memset(f, 0, sizeof(*f)); +} +int ble_backend_send(struct ble_backend* b, ble_link_id id, const uint8_t* data, size_t len) { + struct fake_link* f = slot(b, id); CHECK(f && !f->pending && len <= sizeof(f->data)); + CHECK(len <= mtu - 3); + memcpy(f->data, data, len); f->len = len; f->pending = 1; schedule(b); return 0; +} +static void run(void* arg) { + struct ble_backend* b = arg; b->task = NULL; + if (!b->started) { b->started = 1; ble_backend_status(b->owner, 1, "fake ready"); } + for (int i = 0; i < 16; i++) { + struct fake_link* f = &b->links[i]; if (!f->id) continue; + if (!f->len) { f->len = 1; ble_backend_opened(b->owner, f->id, mtu); } + if (!f->pending || hold) continue; + ble_link_id id = f->id, other = f->other; + struct ble_backend* peer = f->peer; + uint8_t data[512]; size_t len = f->len; memcpy(data, f->data, len); f->pending = 0; + int dropped = data[0] == 3 && drop && --drop == 0; + if (!dropped && peer && slot(peer, other)) ble_backend_received(peer->owner, other, data, len); + if (slot(b, id)) ble_backend_sent(b->owner, id, 1); + } +} +static int validate(void* arg, ble_link_id id, uint64_t node) { (void)arg; (void)id; (void)node; return !reject_peer; } +static void connection(void* arg, ble_link_id id, uint64_t node, enum ble_state s, const char* reason) { + (void)arg; (void)id; (void)node; (void)reason; if (s == BLE_READY) ready_count++; +} +static void writable(void* arg, ble_link_id id) { (void)arg; (void)id; writable_count++; } +static void drain(struct UASYNC* ua) { for (int i = 0; i < 1000; i++) uasync_poll(ua, 0); } +static struct ll_entry* frame(unsigned int size, uint8_t value) { + struct ll_entry* e = ll_alloc_lldgram(size); CHECK(e); e->len = size; memset(e->dgram, value, size); return e; +} +static void release(struct ll_entry* e) { queue_dgram_free(e); queue_entry_free(e); } +static void exercise(unsigned int test_mtu) { + mtu = test_mtu; hold = drop = reject_peer = ready_count = writable_count = 0; + struct UASYNC* ua = uasync_create(); CHECK(ua); + struct ble_transport* t[3]; + for (int i = 0; i < 3; i++) { + struct ble_config cfg = {0}; cfg.node_id = i + 1; cfg.company_id = 0xffff; + cfg.callbacks.validate = validate; cfg.callbacks.connection = connection; cfg.callbacks.writable = writable; + t[i] = ble_create(ua, &cfg); CHECK(t[i] && !ble_start(t[i])); + } + drain(ua); + struct ble_endpoint ep = {"1"}; + ble_link_id a = ble_connect(t[0], &ep, 2); CHECK(a); drain(ua); CHECK(ready_count == 2); + ble_link_id b = incoming_ids[1]; + CHECK(!ble_tx_put(t[0], a, frame(1500, 0x31))); CHECK(!ble_tx_put(t[1], b, frame(1499, 0x71))); drain(ua); + struct ll_entry* e = ble_rx_get(t[1], b); CHECK(e && e->len == 1500); + for (int i = 0; i < e->len; i++) CHECK(e->dgram[i] == 0x31); + release(e); + e = ble_rx_get(t[0], a); CHECK(e && e->len == 1499 && e->dgram[1498] == 0x71); release(e); + drop = 2; CHECK(!ble_tx_put(t[0], a, frame(1500, 0x44))); drain(ua); CHECK(!ble_rx_get(t[1], b)); + CHECK(!ble_tx_put(t[0], a, frame(5, 0x55))); drain(ua); e = ble_rx_get(t[1], b); CHECK(e && e->len == 5); release(e); + hold = 1; + for (int i = 0; i < BLE_QUEUE_MAX; i++) CHECK(!ble_tx_put(t[0], a, frame(1, i))); + e = frame(1, 99); CHECK(ble_tx_put(t[0], a, e) == BLE_WOULD_BLOCK); CHECK(!e->owner && e->dgram[0] == 99); release(e); + hold = 0; schedule(backends[0]); drain(ua); CHECK(writable_count == 1); + for (int i = 0; i < BLE_QUEUE_MAX; i++) { e = ble_rx_get(t[1], b); CHECK(e && e->dgram[0] == i); release(e); } + CHECK(!ble_rx_get(t[1], b)); + for (int i = 0; i < BLE_QUEUE_MAX + 1; i++) { CHECK(!ble_tx_put(t[0], a, frame(2, i))); drain(ua); } + for (int i = 0; i < BLE_QUEUE_MAX; i++) { e = ble_rx_get(t[1], b); CHECK(e && e->dgram[0] == i); release(e); } + CHECK(!ble_rx_get(t[1], b)); + strcpy(ep.id, "2"); ble_link_id c = ble_connect(t[0], &ep, 3); CHECK(c); drain(ua); CHECK(ready_count == 4); + hold = 1; CHECK(!ble_tx_put(t[0], a, frame(1500, 8))); CHECK(!ble_tx_put(t[0], c, frame(1500, 9))); + ble_disconnect(t[0], a); hold = 0; schedule(backends[0]); drain(ua); + e = ble_rx_get(t[2], incoming_ids[2]); CHECK(e && e->len == 1500 && e->dgram[1499] == 9); release(e); + e = frame(1, 0); CHECK(ble_tx_put(t[0], a, e) == BLE_NOT_READY); release(e); + /* Отказ политики на третьем независимом направлении не затрагивает существующее соединение. */ + reject_peer = 1; strcpy(ep.id, "2"); ble_link_id rejected = ble_connect(t[1], &ep, 3); CHECK(rejected); drain(ua); + CHECK(ready_count == 4); + e = frame(1, 0); CHECK(ble_tx_put(t[1], rejected, e) == BLE_NOT_READY); release(e); + ble_set_incoming(t[2], 0); + struct ble_endpoint denied = {"policy-test"}; + CHECK(!ble_backend_incoming(t[2], &denied)); + CHECK(!ble_rx_get(t[2], incoming_ids[2])); + ble_set_incoming(t[2], 1); + CHECK(ble_backend_incoming(t[2], &denied)); + for (int i = 0; i < 3; i++) ble_destroy(t[i]); + drain(ua); uasync_destroy(ua, 0); +} +int main(void) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); + size_t before = u_get_allocated_count(); + exercise(23); exercise(247); exercise(517); + CHECK(u_get_allocated_count() == before); puts("BLE transport tests PASS (MTU 23/247/517, duplex, loss, backpressure, peers, teardown)"); return 0; +} diff --git a/src/Makefile.am b/src/Makefile.am index d8f7f9b8d..e90b2b351 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -2,7 +2,7 @@ bin_PROGRAMS = utun noinst_LIBRARIES = libutun.a EXTRA_DIST = lwip_tcp/COPYING dnsmasq/COPYING dnsmasq/COPYING-v3 routing_layer/address_profile.h routing_layer/route_ping_doc.md \ - stream/stream_playback_audio.h stream/stream_playback_audio.cpp config_ini.h + stream/stream_playback_audio.h stream/stream_playback_audio.cpp config_ini.h config_error.h stream_core_files = \ stream/stream.h \ @@ -44,6 +44,7 @@ utun_CORE_SOURCES = \ utun.c \ utun_instance.c \ config_parser.c \ + config_error.c \ config_updater.c \ routing_layer/route_lib.c \ routing_layer/route6_lib.c \ @@ -237,6 +238,7 @@ libutun_a_SOURCES = \ $(stream_core_files) \ utun_instance.c \ config_parser.c \ + config_error.c \ config_updater.c \ routing_layer/route_lib.c \ routing_layer/route6_lib.c \ diff --git a/src/config_error.c b/src/config_error.c new file mode 100644 index 000000000..3d7f3ca25 --- /dev/null +++ b/src/config_error.c @@ -0,0 +1,23 @@ +#include "config_error.h" +#include +#include +#include +#include + +static __thread char config_errors[4096]; + +void config_error_clear(void) { config_errors[0] = '\0'; } +const char *config_error_text(void) { return config_errors; } + +void config_error_append(const char *format, ...) { + const int saved_errno = errno; + size_t used = strlen(config_errors); + if (used < sizeof(config_errors) - 2) { + if (used) config_errors[used++] = '\n'; + va_list args; + va_start(args, format); + vsnprintf(config_errors + used, sizeof(config_errors) - used, format, args); + va_end(args); + } + errno = saved_errno; +} diff --git a/src/config_error.h b/src/config_error.h new file mode 100644 index 000000000..96ba27a50 --- /dev/null +++ b/src/config_error.h @@ -0,0 +1,28 @@ +/* Причины отказа конфигурации для CLI/GUI, отдельно от фильтров журнала. + * Каждый поток хранит свой ограниченный список ошибок. Операция чтения/подготовки + * очищает его на входе; вызывающий копирует результат до следующей операции. + * Сообщения не должны содержать приватные ключи и другие секретные значения. */ +#ifndef CONFIG_ERROR_H +#define CONFIG_ERROR_H + +#include "../lib/debug_config.h" + +#ifdef __cplusplus +extern "C" { +#endif + +void config_error_clear(void); /* Начать новую операцию в текущем потоке. */ +const char *config_error_text(void); /* Заимствованный UTF-8, пустой при отсутствии ошибок. */ +void config_error_append(const char *format, ...); /* Добавить причину, сохранив errno. */ + +#ifdef __cplusplus +} +#endif + +/* Записать причину независимо от фильтра, затем вывести обычную CONFIG-диагностику. */ +#define CONFIG_ERROR(...) do { \ + config_error_append(__VA_ARGS__); \ + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, __VA_ARGS__); \ +} while (0) + +#endif diff --git a/src/config_ini.h b/src/config_ini.h index 7a5731192..0c0195380 100644 --- a/src/config_ini.h +++ b/src/config_ini.h @@ -5,6 +5,7 @@ #ifndef CONFIG_INI_H #define CONFIG_INI_H +#include "config_error.h" #include "../lib/platform_compat.h" #include "../lib/debug_config.h" #include "../lib/mem.h" @@ -55,15 +56,15 @@ static inline int config_ini_key_value(const char *line, char *key, size_t key_c /* Проверить физические строки до разбора, чтобы не принять хвост длинной строки за новую опцию. */ static inline int config_ini_validate(const char *text, size_t length, const char *filename) { size_t column = 0, line = 1; - if (!text && length) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: NULL configuration buffer", filename); errno = EINVAL; return -1; } + if (!text && length) { CONFIG_ERROR("%s: NULL configuration buffer", filename); errno = EINVAL; return -1; } for (size_t i = 0; i < length; i++) { if (!text[i]) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%zu: embedded NUL at byte %zu", filename, line, i); + CONFIG_ERROR("%s:%zu: embedded NUL at byte %zu", filename, line, i); errno = EINVAL; return -1; } if (text[i] != '\n' && ++column >= CONFIG_INI_LINE_SIZE - 1) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%zu: INI line exceeds 1022 bytes", filename, line); + CONFIG_ERROR("%s:%zu: INI line exceeds 1022 bytes", filename, line); errno = EOVERFLOW; return -1; } @@ -82,28 +83,28 @@ static inline int config_ini_read(FILE *file, const char *filename, char **text, while ((count = fread(chunk, 1, sizeof(chunk), file)) != 0) { const char *nul = (const char *)memchr(chunk, '\0', count); if (nul) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: embedded NUL at byte %zu", filename, output.len + (size_t)(nul - chunk)); + CONFIG_ERROR("%s: embedded NUL at byte %zu", filename, output.len + (size_t)(nul - chunk)); errno = EINVAL; goto failed; } if (output.len > UINT32_MAX / 2 - count - 1) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: INI text exceeds allocator size limit", filename); + CONFIG_ERROR("%s: INI text exceeds allocator size limit", filename); errno = EFBIG; goto failed; } if (strbuf_addf(&output, "%.*s", (int)count, chunk) < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: allocation failed while reading INI at byte %zu", filename, output.len); + CONFIG_ERROR("%s: allocation failed while reading INI at byte %zu", filename, output.len); errno = ENOMEM; goto failed; } } if (ferror(file)) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: read failed at byte %zu: %s", filename, output.len, strerror(errno)); + CONFIG_ERROR("%s: read failed at byte %zu: %s", filename, output.len, strerror(errno)); goto failed; } if (config_ini_validate(strbuf_str(&output), output.len, filename)) goto failed; if (!output.buf && strbuf_addf(&output, "%s", "") < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: cannot allocate empty configuration buffer", filename); + CONFIG_ERROR("%s: cannot allocate empty configuration buffer", filename); errno = ENOMEM; goto failed; } diff --git a/src/config_parser.c b/src/config_parser.c index 2edec169c..8132393a2 100644 --- a/src/config_parser.c +++ b/src/config_parser.c @@ -53,7 +53,7 @@ typedef enum { static int assign_string(char *dest, size_t dest_size, const char *src) { if (!dest || !src || strlen(src) >= dest_size) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "configuration string exceeds capacity=%zu or has a missing argument", dest_size); + CONFIG_ERROR("configuration string exceeds capacity=%zu or has a missing argument", dest_size); return -1; } strcpy(dest, src); @@ -67,7 +67,7 @@ static int parse_unsigned(const char* text, int base, uint64_t maximum, uint64_t unsigned long long value = strtoull(text, &end, base); if ((base == 16 ? config_ini_nibble((unsigned char)*text) < 0 : !isdigit((unsigned char)*text)) || end == text || *end || errno || value > maximum) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "invalid unsigned base-%d value '%s', maximum=%llu", base, text, (unsigned long long)maximum); + CONFIG_ERROR("invalid unsigned base-%d value '%s', maximum=%llu", base, text, (unsigned long long)maximum); return -1; } *out = value; return 0; @@ -79,7 +79,7 @@ static int parse_integer(const char* text, int minimum, int maximum, int* out) { errno = 0; long value = strtol(text, &end, 10); if ((!isdigit((unsigned char)*text) && *text != '-') || end == text || *end || errno || value < minimum || value > maximum) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "invalid integer '%s', expected %d..%d", text, minimum, maximum); return -1; + CONFIG_ERROR("invalid integer '%s', expected %d..%d", text, minimum, maximum); return -1; } *out = (int)value; return 0; } @@ -88,7 +88,7 @@ static int parse_integer(const char* text, int minimum, int maximum, int* out) { static int parse_boolean(const char* text) { if (!strcmp(text, "1") || !strcasecmp(text, "yes") || !strcasecmp(text, "true")) return 1; if (!strcmp(text, "0") || !strcasecmp(text, "no") || !strcasecmp(text, "false")) return 0; - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "invalid boolean '%s', expected yes/no, true/false or 1/0", text); return -1; + CONFIG_ERROR("invalid boolean '%s', expected yes/no, true/false or 1/0", text); return -1; } /* Версия содержит ровно три десятичных октета, без хвоста; выход применяется целиком. */ @@ -100,7 +100,7 @@ static int parse_version(const char* value, uint8_t out[3]) { for (unsigned i = 0; i < 3; i++) { char* dot = strchr(cursor, '.'); if ((i < 2 && !dot) || (i == 2 && dot)) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "invalid version format, expected x.y.z"); return -1; + CONFIG_ERROR("invalid version format, expected x.y.z"); return -1; } if (dot) *dot = '\0'; uint64_t number; @@ -121,7 +121,7 @@ static int parse_ip_with_netmask(const char *str, struct IP *ip, uint8_t *netmas if (inet_pton(AF_INET, ip_str, &parsed.addr.v4) == 1) { parsed.family = AF_INET; maximum = 32; } else if (inet_pton(AF_INET6, ip_str, &parsed.addr.v6) == 1) { parsed.family = AF_INET6; maximum = 128; } else { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "invalid IP address: %s", str); return -1; + CONFIG_ERROR("invalid IP address: %s", str); return -1; } uint64_t prefix = maximum; if (slash && parse_unsigned(slash, 10, maximum, &prefix)) return -1; @@ -141,7 +141,7 @@ static int parse_sockaddr(const char *addr_str, const char *port_str, struct soc if (addr_str[0] == '[') { size_t len = strlen(addr_str); if (len > 2 && addr_str[len - 1] == ']') { - if (len - 2 >= sizeof(clean_addr)) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "bracketed address is too long"); return -1; } + if (len - 2 >= sizeof(clean_addr)) { CONFIG_ERROR("bracketed address is too long"); return -1; } memcpy(clean_addr, addr_str + 1, len - 2); clean_addr[len - 2] = '\0'; a = clean_addr; @@ -151,11 +151,16 @@ static int parse_sockaddr(const char *addr_str, const char *port_str, struct soc struct addrinfo *result; int rc = getaddrinfo(a, port_str, &hints, &result); if (rc != 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "cannot resolve configured endpoint host=%s port=%s error=%d", a, port_str, rc); +#ifdef _WIN32 + const char *reason = rc == WSANOTINITIALISED ? "WinSock is not initialized; call WSAStartup first" : gai_strerrorA(rc); +#else + const char *reason = gai_strerror(rc); +#endif + CONFIG_ERROR("cannot resolve configured endpoint host=%s port=%s error=%d (%s)", a, port_str, rc, reason); return -1; } if (!result || result->ai_addrlen > sizeof(*sockaddr)) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "resolved endpoint has an invalid address size"); freeaddrinfo(result); return -1; + CONFIG_ERROR("resolved endpoint has an invalid address size"); freeaddrinfo(result); return -1; } memset(sockaddr, 0, sizeof(*sockaddr)); memcpy(sockaddr, result->ai_addr, result->ai_addrlen); @@ -168,7 +173,7 @@ static int parse_address_and_port(const char *str, struct sockaddr_storage *sock if (assign_string(addr_copy, sizeof(addr_copy), str)) return -1; char *port_str = strrchr(addr_copy, ':'); - if (!port_str) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "endpoint requires host:port: %s", str); return -1; } + if (!port_str) { CONFIG_ERROR("endpoint requires host:port: %s", str); return -1; } *port_str = '\0'; port_str++; @@ -190,7 +195,7 @@ static uint32_t get_netif_index(const char *ifname) { static struct CFG_CLIENT_LINK* create_client_link(struct CFG_SERVER* local_srv, const char *remote_addr) { struct CFG_CLIENT_LINK *link = u_calloc(1, sizeof(struct CFG_CLIENT_LINK)); if (!link) { - DEBUG_ERROR(DEBUG_CATEGORY_SYS, "create_client_link: failed to allocate memory for client link"); + CONFIG_ERROR("create_client_link: failed to allocate memory for client link"); return NULL; } @@ -217,7 +222,7 @@ static void free_cfg_client_links(struct CFG_CLIENT_LINK *links) { static struct CFG_ROUTE_ENTRY* create_route_entry(const char *subnet_str) { struct CFG_ROUTE_ENTRY *entry = u_calloc(1, sizeof(struct CFG_ROUTE_ENTRY)); if (!entry) { - DEBUG_ERROR(DEBUG_CATEGORY_SYS, "create_route_entry: failed to allocate memory for route entry"); + CONFIG_ERROR("create_route_entry: failed to allocate memory for route entry"); return NULL; } @@ -250,11 +255,11 @@ static int add_route_entry(struct CFG_ROUTE_ENTRY **list, const char *subnet_str /* Разрешение добавляется только после проверки всей строки; ошибочный порт не становится wildcard. */ static int parse_firewall_rule(const char *rule_str, struct global_config *global) { - if (!rule_str || !global) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Firewall: missing rule/config"); return -1; } + if (!rule_str || !global) { CONFIG_ERROR("Firewall: missing rule/config"); return -1; } char rule_copy[64]; if (strlen(rule_str) >= sizeof(rule_copy)) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Firewall: rule is too long: %s", rule_str); + CONFIG_ERROR("Firewall: rule is too long: %s", rule_str); return -1; } strcpy(rule_copy, rule_str); @@ -275,7 +280,7 @@ static int parse_firewall_rule(const char *rule_str, struct global_config *globa errno = 0; unsigned long parsed = strtoul(port_str, &end, 10); if (!isdigit((unsigned char)*port_str) || errno || *end || parsed > UINT16_MAX) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Firewall: invalid port in rule: %s", rule_str); + CONFIG_ERROR("Firewall: invalid port in rule: %s", rule_str); return -1; } port = (uint16_t)parsed; @@ -284,7 +289,7 @@ static int parse_firewall_rule(const char *rule_str, struct global_config *globa // Parse IP to uint32_t (host order) struct in_addr addr; if (inet_pton(AF_INET, rule_copy, &addr) != 1) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "parse_firewall_rule: invalid IP: %s", rule_str); + CONFIG_ERROR("parse_firewall_rule: invalid IP: %s", rule_str); return -1; } @@ -293,14 +298,14 @@ static int parse_firewall_rule(const char *rule_str, struct global_config *globa // Reallocate array if needed if ((global->firewall_rule_count % INITIAL_ARRAY_CAPACITY) == 0) { if ((uint32_t)global->firewall_rule_count > UINT32_MAX / sizeof(struct CFG_FIREWALL_RULE) - INITIAL_ARRAY_CAPACITY) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Firewall: too many rules"); + CONFIG_ERROR("Firewall: too many rules"); return -1; } int new_cap = global->firewall_rule_count + INITIAL_ARRAY_CAPACITY; struct CFG_FIREWALL_RULE *new_rules = u_realloc(global->firewall_rules, new_cap * sizeof(struct CFG_FIREWALL_RULE)); if (!new_rules) { - DEBUG_ERROR(DEBUG_CATEGORY_SYS, "Failed to realloc firewall rules"); + CONFIG_ERROR("Failed to realloc firewall rules"); return -1; } global->firewall_rules = new_rules; @@ -319,7 +324,7 @@ static int parse_control_allow(const char *rule_str, struct global_config *globa struct IP address; uint8_t cidr; if (parse_ip_with_netmask(rule_str, &address, &cidr) || address.family != AF_INET) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "control allow requires IPv4[/0..32]: %s", rule_str); + CONFIG_ERROR("control allow requires IPv4[/0..32]: %s", rule_str); return -1; } uint32_t ip = ntohl(address.addr.v4.s_addr); @@ -327,13 +332,13 @@ static int parse_control_allow(const char *rule_str, struct global_config *globa if ((global->control_allow_count % INITIAL_ARRAY_CAPACITY) == 0) { if ((uint32_t)global->control_allow_count > UINT32_MAX / sizeof(struct CFG_CONTROL_ALLOW) - INITIAL_ARRAY_CAPACITY) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "too many control allow rules"); return -1; + CONFIG_ERROR("too many control allow rules"); return -1; } int new_cap = global->control_allow_count + INITIAL_ARRAY_CAPACITY; struct CFG_CONTROL_ALLOW *new_allows = u_realloc(global->control_allows, new_cap * sizeof(struct CFG_CONTROL_ALLOW)); if (!new_allows) { - DEBUG_ERROR(DEBUG_CATEGORY_SYS, "Failed to realloc control allows"); + CONFIG_ERROR("Failed to realloc control allows"); return -1; } global->control_allows = new_allows; @@ -361,7 +366,7 @@ static struct CFG_SERVER* find_server_by_name(struct CFG_SERVER *servers, const static int parse_global(const char *key, const char *value, struct global_config *global, const char *filename, int line_num) { if ((!strcmp(key, "my_private_key") || !strcmp(key, "my_public_key")) && *value && config_ini_hex(value, NULL, SC_PRIVKEY_SIZE)) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: %s requires 64 HEX characters", filename, line_num, key); return -1; + CONFIG_ERROR("%s:%d: %s requires 64 HEX characters", filename, line_num, key); return -1; } if (!strcmp(key, "my_node_name")) { if (strlen(value) >= sizeof(global->name)) @@ -428,10 +433,10 @@ static int parse_global(const char *key, const char *value, struct global_config if (!strcasecmp(value, "tcp")) global->auto_socket_protocols = CFG_AUTO_SOCKET_TCP; else if (!strcasecmp(value, "udp")) global->auto_socket_protocols = CFG_AUTO_SOCKET_UDP; else if (!strcasecmp(value, "tcp+udp")) global->auto_socket_protocols = CFG_AUTO_SOCKET_BOTH; - else { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: expected auto_socket_protocols=tcp, udp or tcp+udp", filename, line_num); return -1; } + else { CONFIG_ERROR("%s:%d: expected auto_socket_protocols=tcp, udp or tcp+udp", filename, line_num); return -1; } return 0; } - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: unknown global option '%s'", filename, line_num, key); return -1; + CONFIG_ERROR("%s:%d: unknown global option '%s'", filename, line_num, key); return -1; } static int parse_control(const char *key, const char *value, struct global_config *global, const char *filename, int line_num) { @@ -453,7 +458,7 @@ static int parse_control(const char *key, const char *value, struct global_confi if (strcmp(key, "allow") == 0 || strcmp(key, "control_allow") == 0) { return parse_control_allow(value, global); } - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown control option '%s'. Valid: ip/control_ip, port/control_port, allow/control_allow", filename, line_num, key); + CONFIG_ERROR("%s:%d: Unknown control option '%s'. Valid: ip/control_ip, port/control_port, allow/control_allow", filename, line_num, key); return -1; } @@ -490,7 +495,7 @@ static int parse_tcp_proxy_client(const char *key, const char *value, struct glo } if (strcmp(key, "forward") == 0) { if (global->tcp_proxy_client_mapping_count >= MAX_TCP_PROXY_CLIENT_MAPPINGS) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Too many tcp_proxy_client forward rules (max %d)", MAX_TCP_PROXY_CLIENT_MAPPINGS); + CONFIG_ERROR("Too many tcp_proxy_client forward rules (max %d)", MAX_TCP_PROXY_CLIENT_MAPPINGS); return -1; } char buf[256]; @@ -501,24 +506,24 @@ static int parse_tcp_proxy_client(const char *key, const char *value, struct glo char* arrow = strtok(NULL, " "); char* remote_str = strtok(NULL, ""); if (!local_port_str || !arrow || !remote_str || strcmp(arrow, "->") != 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Invalid tcp_proxy_client forward format: %s (expected: port -> ip:port)", value); + CONFIG_ERROR("Invalid tcp_proxy_client forward format: %s (expected: port -> ip:port)", value); return -1; } uint64_t local_port; if (parse_unsigned(local_port_str, 10, UINT16_MAX, &local_port) || !local_port) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "invalid tcp_proxy_client forward local port"); return -1; + CONFIG_ERROR("invalid tcp_proxy_client forward local port"); return -1; } char* remote_ip_str = strtok(remote_str, ":"); char* remote_port_str = strtok(NULL, ""); if (!remote_ip_str || !remote_port_str) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Invalid tcp_proxy_client forward remote: %s (expected ip:port)", remote_str); + CONFIG_ERROR("Invalid tcp_proxy_client forward remote: %s (expected ip:port)", remote_str); return -1; } uint64_t remote_port; struct in_addr address; if (parse_unsigned(remote_port_str, 10, UINT16_MAX, &remote_port) || !remote_port || inet_pton(AF_INET, remote_ip_str, &address) != 1) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "invalid tcp_proxy_client forward remote IPv4/port"); return -1; + CONFIG_ERROR("invalid tcp_proxy_client forward remote IPv4/port"); return -1; } int idx = global->tcp_proxy_client_mapping_count; @@ -528,14 +533,14 @@ static int parse_tcp_proxy_client(const char *key, const char *value, struct glo global->tcp_proxy_client_mapping_count++; return 0; } - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown tcp_proxy_client option '%s'. Valid: enabled, tun_name, tun_ip, mtu, via_node, forward, socks_enabled, socks_addr, http_proxy_enabled, http_proxy_addr", filename, line_num, key); + CONFIG_ERROR("%s:%d: Unknown tcp_proxy_client option '%s'. Valid: enabled, tun_name, tun_ip, mtu, via_node, forward, socks_enabled, socks_addr, http_proxy_enabled, http_proxy_addr", filename, line_num, key); return -1; } static int parse_nat(const char *key, const char *value, struct global_config *global, const char *filename, int line_num) { if (strcmp(key, "tun_ifname") == 0) { if (assign_string(global->nat_tun_ifname, sizeof(global->nat_tun_ifname), value) != 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: NAT interface name too long: %s", filename, line_num, value); + CONFIG_ERROR("%s:%d: NAT interface name too long: %s", filename, line_num, value); return -1; } return 0; @@ -543,7 +548,7 @@ static int parse_nat(const char *key, const char *value, struct global_config *g if (strcmp(key, "tun_ip") == 0) { uint8_t netmask; if (parse_ip_with_netmask(value, &global->nat_tun_ip, &netmask) != 0 || global->nat_tun_ip.family != AF_INET) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: NAT requires an IPv4 tun_ip: %s", filename, line_num, value); + CONFIG_ERROR("%s:%d: NAT requires an IPv4 tun_ip: %s", filename, line_num, value); return -1; } return 0; @@ -553,7 +558,7 @@ static int parse_nat(const char *key, const char *value, struct global_config *g errno = 0; uint64_t node_id = strtoull(value, &end, 16); if (!*value || *value == '-' || *value == '+' || *end || errno == ERANGE) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Invalid NAT provider ID: %s", filename, line_num, value); + CONFIG_ERROR("%s:%d: Invalid NAT provider ID: %s", filename, line_num, value); return -1; } global->nat_via_node_id = node_id; @@ -564,7 +569,7 @@ static int parse_nat(const char *key, const char *value, struct global_config *g errno = 0; unsigned long port = strtoul(value, &end, 10); if (!isdigit((unsigned char)*value) || *end || errno == ERANGE || port == 0 || port > 65535) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Invalid NAT %s: %s (expected 1..65535)", filename, line_num, key, value); + CONFIG_ERROR("%s:%d: Invalid NAT %s: %s (expected 1..65535)", filename, line_num, key, value); return -1; } if (strcmp(key, "port_start") == 0) global->nat_port_start = (uint16_t)port; @@ -573,13 +578,13 @@ static int parse_nat(const char *key, const char *value, struct global_config *g } if (strcmp(key, "forward") == 0) { if (global->nat_forward_count >= MAX_NAT_FORWARDS) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Too many NAT forward rules (max %d)", MAX_NAT_FORWARDS); + CONFIG_ERROR("Too many NAT forward rules (max %d)", MAX_NAT_FORWARDS); return -1; } // Format: proto:node_id_hex:internal_ip:internal_port:external_port. char buf[128]; if (strlen(value) >= sizeof(buf)) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: NAT forward value too long", filename, line_num); + CONFIG_ERROR("%s:%d: NAT forward value too long", filename, line_num); return -1; } strcpy(buf, value); @@ -589,7 +594,7 @@ static int parse_nat(const char *key, const char *value, struct global_config *g fields[i] = cursor; char* colon = strchr(cursor, ':'); if ((i < 4 && !colon) || (i == 4 && colon) || !*cursor || colon == cursor) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Invalid NAT forward: %s (proto:node_id:ip:internal_port:external_port)", + CONFIG_ERROR("%s:%d: Invalid NAT forward: %s (proto:node_id:ip:internal_port:external_port)", filename, line_num, value); return -1; } @@ -601,7 +606,7 @@ static int parse_nat(const char *key, const char *value, struct global_config *g errno = 0; uint64_t node_id = strtoull(fields[1], &end, 16); if (!isxdigit((unsigned char)*fields[1]) || !node_id || *end || errno == ERANGE) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Invalid NAT forward node ID: %s", filename, line_num, fields[1]); + CONFIG_ERROR("%s:%d: Invalid NAT forward node ID: %s", filename, line_num, fields[1]); return -1; } uint16_t ports[2]; @@ -609,7 +614,7 @@ static int parse_nat(const char *key, const char *value, struct global_config *g errno = 0; unsigned long port = strtoul(fields[i + 3], &end, 10); if (!isdigit((unsigned char)*fields[i + 3]) || *end || errno == ERANGE || port == 0 || port > 65535) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Invalid NAT forward port: %s", filename, line_num, fields[i + 3]); + CONFIG_ERROR("%s:%d: Invalid NAT forward port: %s", filename, line_num, fields[i + 3]); return -1; } ports[i] = (uint16_t)port; @@ -619,13 +624,13 @@ static int parse_nat(const char *key, const char *value, struct global_config *g if (strcasecmp(proto_str, "tcp") == 0) proto = IPPROTO_TCP; else if (strcasecmp(proto_str, "udp") == 0) proto = IPPROTO_UDP; else { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Invalid NAT forward proto: %s (tcp/udp)", proto_str); + CONFIG_ERROR("Invalid NAT forward proto: %s (tcp/udp)", proto_str); return -1; } struct in_addr addr; if (inet_pton(AF_INET, ip_str, &addr) != 1) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Invalid NAT forward IP: %s", ip_str); + CONFIG_ERROR("Invalid NAT forward IP: %s", ip_str); return -1; } @@ -640,7 +645,7 @@ static int parse_nat(const char *key, const char *value, struct global_config *g ports[1], (unsigned long long)node_id, proto_str, ip_str, ports[0]); return 0; } - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown NAT option '%s'. Valid: tun_ifname, tun_ip, nat_via, port_start, port_end, forward", filename, line_num, key); + CONFIG_ERROR("%s:%d: Unknown NAT option '%s'. Valid: tun_ifname, tun_ip, nat_via, port_start, port_end, forward", filename, line_num, key); return -1; } @@ -680,7 +685,7 @@ static int parse_server(const char *key, const char *value, struct CFG_SERVER *s } else if (strcmp(value, "local") == 0) { srv->type = CFG_SERVER_TYPE_LOCAL; } else { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: unknown server type '%s'", filename, line_num, value); return -1; + CONFIG_ERROR("%s:%d: unknown server type '%s'", filename, line_num, value); return -1; } return 0; } @@ -694,7 +699,7 @@ static int parse_server(const char *key, const char *value, struct CFG_SERVER *s if (strcmp(key, "transport") == 0) { if (strcmp(value, "tcp") == 0) srv->transport = 1; else if (strcmp(value, "udp") == 0) srv->transport = 0; - else { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: unknown server transport '%s'", filename, line_num, value); return -1; } + else { CONFIG_ERROR("%s:%d: unknown server transport '%s'", filename, line_num, value); return -1; } return 0; } if (strcmp(key, "reality") == 0) { @@ -705,7 +710,7 @@ static int parse_server(const char *key, const char *value, struct CFG_SERVER *s int enabled = parse_boolean(value); if (enabled < 0) return -1; srv->socks_enabled = (uint8_t)enabled; return 0; } - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown server option '%s'. Valid: addr, so_mark, fib, netif, type, mtu, only_local, transport, reality, socks", filename, line_num, key); + CONFIG_ERROR("%s:%d: Unknown server option '%s'. Valid: addr, so_mark, fib, netif, type, mtu, only_local, transport, reality, socks", filename, line_num, key); return -1; } @@ -739,7 +744,7 @@ static int parse_client(const char *key, const char *value, struct CFG_CLIENT *c if (strcmp(key, "peer_public_key") == 0) { if (*value && config_ini_hex(value, NULL, SC_PUBKEY_SIZE)) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: peer_public_key requires 64 HEX characters", filename, line_num); return -1; + CONFIG_ERROR("%s:%d: peer_public_key requires 64 HEX characters", filename, line_num); return -1; } return assign_string(cli->peer_public_key_hex, MAX_KEY_LEN, value); } @@ -755,14 +760,14 @@ static int parse_client(const char *key, const char *value, struct CFG_CLIENT *c if (strcmp(key, "server_name") == 0) { return assign_string(cli->reality.server_name, sizeof(cli->reality.server_name), value); } if (strcmp(key, "short_id") == 0) { if (config_ini_hex(value, cli->reality.short_id, REALITY_SHORT_ID_SIZE) < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: client reality short_id must be %d hex chars", filename, line_num, REALITY_SHORT_ID_SIZE * 2); + CONFIG_ERROR("%s:%d: client reality short_id must be %d hex chars", filename, line_num, REALITY_SHORT_ID_SIZE * 2); return -1; } return 0; } if (strcmp(key, "public_key") == 0) { if (config_ini_hex(value, cli->reality.server_static_pubkey, REALITY_AUTH_KEY_SIZE) < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: client reality public_key must be %d hex chars", filename, line_num, REALITY_AUTH_KEY_SIZE * 2); + CONFIG_ERROR("%s:%d: client reality public_key must be %d hex chars", filename, line_num, REALITY_AUTH_KEY_SIZE * 2); return -1; } return 0; @@ -772,11 +777,11 @@ static int parse_client(const char *key, const char *value, struct CFG_CLIENT *c } if (strcmp(key, "fingerprint") == 0) { if (strcmp(value, "openssl") == 0) { cli->reality.fingerprint = REALITY_FP_OPENSSL; return 0; } - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: unknown client reality fingerprint '%s' (valid: openssl)", filename, line_num, value); + CONFIG_ERROR("%s:%d: unknown client reality fingerprint '%s' (valid: openssl)", filename, line_num, value); return -1; } - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown client option '%s'. Valid: link, peer_public_key, keepalive, reality, server_name, short_id, public_key, version, fingerprint", filename, line_num, key); + CONFIG_ERROR("%s:%d: Unknown client option '%s'. Valid: link, peer_public_key, keepalive, reality, server_name, short_id, public_key, version, fingerprint", filename, line_num, key); return -1; } @@ -799,7 +804,7 @@ static int parse_chatserver(const char *key, const char *value, struct global_co if (config_ini_hex(value, global->chatserver_add_key, sizeof(global->chatserver_add_key))) return -1; int nonzero = 0; for (size_t i = 0; i < sizeof(global->chatserver_add_key); i++) nonzero |= global->chatserver_add_key[i]; - if (!nonzero) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "chatserver add_key must be nonzero"); return -1; } + if (!nonzero) { CONFIG_ERROR("chatserver add_key must be nonzero"); return -1; } global->chatserver_has_add_key = 1; return 0; } @@ -809,7 +814,7 @@ static int parse_chatserver(const char *key, const char *value, struct global_co if (strcmp(key, "storage_total_size") == 0) { uint64_t sz; if (parse_size_with_suffix(value, &sz) < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Invalid chatserver storage_total_size '%s' (expected number with M/G suffix)", filename, line_num, value); + CONFIG_ERROR("%s:%d: Invalid chatserver storage_total_size '%s' (expected number with M/G suffix)", filename, line_num, value); return -1; } global->chatserver_storage_total_size = sz; @@ -818,7 +823,7 @@ static int parse_chatserver(const char *key, const char *value, struct global_co if (strcmp(key, "storage_unit_size") == 0) { uint64_t sz; if (parse_size_with_suffix(value, &sz) < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Invalid chatserver storage_unit_size '%s' (expected number with M/G suffix)", filename, line_num, value); + CONFIG_ERROR("%s:%d: Invalid chatserver storage_unit_size '%s' (expected number with M/G suffix)", filename, line_num, value); return -1; } global->chatserver_storage_unit_size = sz; @@ -841,14 +846,14 @@ static int parse_network(const char *key, const char *value, struct CFG_NETWORK return parse_unsigned(value, 16, UINT64_C(0x00ffffffffffffff), &net->id); } if (strcmp(key, "pubkey") == 0) { - if (config_ini_hex(value, NULL, SC_PUBKEY_SIZE)) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "network pubkey requires 64 HEX characters"); return -1; } + if (config_ini_hex(value, NULL, SC_PUBKEY_SIZE)) { CONFIG_ERROR("network pubkey requires 64 HEX characters"); return -1; } return assign_string(net->pubkey_hex, sizeof(net->pubkey_hex), value); } if (strcmp(key, "signing_key") == 0) { - if (config_ini_hex(value, NULL, SC_PRIVKEY_SIZE)) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "network signing key requires 64 HEX characters"); return -1; } + if (config_ini_hex(value, NULL, SC_PRIVKEY_SIZE)) { CONFIG_ERROR("network signing key requires 64 HEX characters"); return -1; } return assign_string(net->signing_key_hex, sizeof(net->signing_key_hex), value); } - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown network option '%s'. Valid: id, pubkey, signing_key", filename, line_num, key); + CONFIG_ERROR("%s:%d: Unknown network option '%s'. Valid: id, pubkey, signing_key", filename, line_num, key); return -1; } @@ -861,7 +866,7 @@ static int parse_reality(const char *key, const char *value, struct reality_conf if (strcmp(key, "dest") == 0) { return assign_string(rc->dest, sizeof(rc->dest), value); } if (strcmp(key, "short_id") == 0) { if (config_ini_hex(value, rc->short_id, REALITY_SHORT_ID_SIZE) < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: reality short_id must be %d hex chars", filename, line_num, REALITY_SHORT_ID_SIZE * 2); + CONFIG_ERROR("%s:%d: reality short_id must be %d hex chars", filename, line_num, REALITY_SHORT_ID_SIZE * 2); return -1; } return 0; @@ -869,7 +874,7 @@ static int parse_reality(const char *key, const char *value, struct reality_conf if (strcmp(key, "short_ids") == 0) { if (config_ini_hex_list(value, &rc->short_ids[0][0], REALITY_SHORT_ID_SIZE, REALITY_MAX_SHORT_IDS, &rc->short_id_count)) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: invalid reality short_ids list", filename, line_num); + CONFIG_ERROR("%s:%d: invalid reality short_ids list", filename, line_num); return -1; } return 0; @@ -877,7 +882,7 @@ static int parse_reality(const char *key, const char *value, struct reality_conf if (strcmp(key, "public_key") == 0) { rc->has_public_key = 0; if (config_ini_hex(value, rc->public_key, REALITY_AUTH_KEY_SIZE) < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: reality public_key must be %d hex chars", filename, line_num, REALITY_AUTH_KEY_SIZE * 2); + CONFIG_ERROR("%s:%d: reality public_key must be %d hex chars", filename, line_num, REALITY_AUTH_KEY_SIZE * 2); return -1; } rc->has_public_key = 1; @@ -886,7 +891,7 @@ static int parse_reality(const char *key, const char *value, struct reality_conf if (strcmp(key, "private_key") == 0) { rc->has_private_key = 0; if (config_ini_hex(value, rc->private_key, REALITY_AUTH_KEY_SIZE) < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: reality private_key must be %d hex chars", filename, line_num, REALITY_AUTH_KEY_SIZE * 2); + CONFIG_ERROR("%s:%d: reality private_key must be %d hex chars", filename, line_num, REALITY_AUTH_KEY_SIZE * 2); return -1; } rc->has_private_key = 1; @@ -899,10 +904,10 @@ static int parse_reality(const char *key, const char *value, struct reality_conf if (strcmp(key, "relay_idle_timeout") == 0) return parse_integer(value, 0, INT_MAX / 10000, &rc->relay_idle_timeout_sec); if (strcmp(key, "fingerprint") == 0) { if (strcmp(value, "openssl") == 0) { rc->fingerprint = REALITY_FP_OPENSSL; return 0; } - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: unknown reality fingerprint '%s' (valid: openssl)", filename, line_num, value); + CONFIG_ERROR("%s:%d: unknown reality fingerprint '%s' (valid: openssl)", filename, line_num, value); return -1; } - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown reality option '%s'. Valid: enabled, server_name, dest, short_id, short_ids, public_key, private_key, version, time_window, relay_idle_timeout, fingerprint", filename, line_num, key); + CONFIG_ERROR("%s:%d: Unknown reality option '%s'. Valid: enabled, server_name, dest, short_id, short_ids, public_key, private_key, version, time_window, relay_idle_timeout, fingerprint", filename, line_num, key); return -1; } @@ -914,14 +919,14 @@ static int parse_socks(const char *key, const char *value, struct global_config } if (strcmp(key, "addr") == 0) { const char *colon = strrchr(value, ':'); - if (!colon) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: socks addr must be 'host:port'", filename, line_num); return -1; } + if (!colon) { CONFIG_ERROR("%s:%d: socks addr must be 'host:port'", filename, line_num); return -1; } uint64_t port; if (parse_unsigned(colon + 1, 10, UINT16_MAX, &port) || !port) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: bad socks port", filename, line_num); return -1; + CONFIG_ERROR("%s:%d: bad socks port", filename, line_num); return -1; } char host[64]; size_t hlen = (size_t)(colon - value); - if (!hlen || hlen >= sizeof(host)) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: invalid socks host length", filename, line_num); return -1; } + if (!hlen || hlen >= sizeof(host)) { CONFIG_ERROR("%s:%d: invalid socks host length", filename, line_num); return -1; } memcpy(host, value, hlen); host[hlen] = '\0'; if (hlen >= 2 && host[0] == '[' && host[hlen - 1] == ']') { host[hlen - 1] = '\0'; memmove(host, host + 1, strlen(host)); } strncpy(global->socks_host, host, sizeof(global->socks_host) - 1); @@ -931,7 +936,7 @@ static int parse_socks(const char *key, const char *value, struct global_config } if (strcmp(key, "username") == 0) return assign_string(global->socks_username, sizeof(global->socks_username), value); if (strcmp(key, "password") == 0) return assign_string(global->socks_password, sizeof(global->socks_password), value); - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown socks option '%s'. Valid: enabled, addr, username, password", filename, line_num, key); + CONFIG_ERROR("%s:%d: Unknown socks option '%s'. Valid: enabled, addr, username, password", filename, line_num, key); return -1; } @@ -940,11 +945,11 @@ static int parse_stun(const char* key, const char* value, struct global_config* if (strcmp(key, "enabled") == 0) { if (!strcasecmp(value, "yes") || !strcasecmp(value, "true") || !strcmp(value, "1")) global->stun_enabled = 1; else if (!strcasecmp(value, "no") || !strcasecmp(value, "false") || !strcmp(value, "0")) global->stun_enabled = 0; - else { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: invalid stun enabled='%s'", filename, line_num, value); return -1; } + else { CONFIG_ERROR("%s:%d: invalid stun enabled='%s'", filename, line_num, value); return -1; } return 0; } if (strcmp(key, "server") != 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: unknown stun option '%s'; valid: enabled, server", filename, line_num, key); + CONFIG_ERROR("%s:%d: unknown stun option '%s'; valid: enabled, server", filename, line_num, key); return -1; } const char* colon = strrchr(value, ':'); @@ -953,24 +958,24 @@ static int parse_stun(const char* key, const char* value, struct global_config* long port = colon ? strtol(colon + 1, &end, 10) : 3478; if (!host_len || host_len >= sizeof(((struct CFG_STUN_SERVER*)0)->host) || (colon && (colon[1] == '\0' || *end || port < 1 || port > 65535))) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: invalid STUN server '%s'; expected host[:port]", filename, line_num, value); + CONFIG_ERROR("%s:%d: invalid STUN server '%s'; expected host[:port]", filename, line_num, value); return -1; } for (size_t i = 0; i < host_len; i++) { unsigned char c = (unsigned char)value[i]; if (!(isalnum(c) || c == '-' || c == '.' || c == '_')) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: invalid IPv4 STUN hostname '%s'", filename, line_num, value); + CONFIG_ERROR("%s:%d: invalid IPv4 STUN hostname '%s'", filename, line_num, value); return -1; } } for (struct CFG_STUN_SERVER* s = global->stun_servers; s; s = s->next) { if (strlen(s->host) == host_len && !strncasecmp(s->host, value, host_len) && s->port == port) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: duplicate STUN server '%s'", filename, line_num, value); + CONFIG_ERROR("%s:%d: duplicate STUN server '%s'", filename, line_num, value); return -1; } } struct CFG_STUN_SERVER* server = u_calloc(1, sizeof(*server)); - if (!server) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: cannot allocate STUN server", filename, line_num); return -1; } + if (!server) { CONFIG_ERROR("%s:%d: cannot allocate STUN server", filename, line_num); return -1; } memcpy(server->host, value, host_len); server->port = (uint16_t)port; struct CFG_STUN_SERVER** tail = &global->stun_servers; @@ -1034,10 +1039,11 @@ static int blr_get_line(blr_t *blr, char *line, size_t maxlen) { } struct utun_config* parse_config_from_buf(const char *buf, size_t len, const char *filename) { + config_error_clear(); if (!filename) filename = ""; if (config_ini_validate(buf, len, filename)) return NULL; struct utun_config *cfg = u_calloc(1, sizeof(struct utun_config)); - if (!cfg) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "failed to allocate memory for config structure"); return NULL; } + if (!cfg) { CONFIG_ERROR("failed to allocate memory for config structure"); return NULL; } cfg->global.name[0] = '\0'; cfg->global.mtu = 1280; cfg->global.keepalive_timeout = 2000; @@ -1098,14 +1104,14 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha } char key[MAX_LINE_LEN], value[MAX_LINE_LEN]; if (config_ini_key_value(trimmed, key, sizeof(key), value, sizeof(value)) < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Invalid key=value format", filename, line_num); + CONFIG_ERROR("%s:%d: Invalid key=value format", filename, line_num); goto error; } int option_result = 0; switch (cur_section) { case SECTION_GLOBAL: if (parse_global(key, value, &cfg->global, filename, line_num) < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: invalid global option %s", filename, line_num, key); + CONFIG_ERROR("%s:%d: invalid global option %s", filename, line_num, key); goto error; } break; @@ -1114,7 +1120,7 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha case SECTION_ROUTING: if (strcmp(key, "route_subnet") == 0) option_result = add_route_entry(&cfg->route_subnets, value); else if (strcmp(key, "my_subnet") == 0) option_result = add_route_entry(&cfg->my_subnets, value); - else { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: unknown routing option '%s'", filename, line_num, key); goto error; } + else { CONFIG_ERROR("%s:%d: unknown routing option '%s'", filename, line_num, key); goto error; } break; case SECTION_DEBUG: if (cfg->global.debug_levels.count < DEBUG_CATEGORY_COUNT) { @@ -1125,11 +1131,11 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha break; case SECTION_FIREWALL: if (strcmp(key, "allow") != 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown firewall option '%s'", filename, line_num, key); + CONFIG_ERROR("%s:%d: Unknown firewall option '%s'", filename, line_num, key); goto error; } if (parse_firewall_rule(value, &cfg->global) < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Invalid firewall rule allow=%s", filename, line_num, value); + CONFIG_ERROR("%s:%d: Invalid firewall rule allow=%s", filename, line_num, value); goto error; } break; @@ -1144,7 +1150,7 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha if (!nk) goto error; if (config_ini_hex(value, nk->key_bin, SC_PUBKEY_SIZE)) { u_free(nk); goto error; } nk->next = cfg->global.allowed_keys; cfg->global.allowed_keys = nk; cfg->global.allowed_keys_count++; - } else { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: unknown allowed_keys option '%s'", filename, line_num, key); goto error; } + } else { CONFIG_ERROR("%s:%d: unknown allowed_keys option '%s'", filename, line_num, key); goto error; } break; case SECTION_NAT: cfg->global.nat_enabled = 1; @@ -1165,7 +1171,7 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha } else if (strcmp(key, "connect_timeout") == 0) { option_result = parse_integer(value, 0, INT_MAX / 10, &cfg->global.tcp_proxy_server_connect_timeout_ms); } else { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown tcp_proxy_server option '%s'. Valid: tcp_recv_buf, fib, so_mark, netif, bind_ip, connect_timeout", filename, line_num, key); + CONFIG_ERROR("%s:%d: Unknown tcp_proxy_server option '%s'. Valid: tcp_recv_buf, fib, so_mark, netif, bind_ip, connect_timeout", filename, line_num, key); goto error; } break; @@ -1183,12 +1189,15 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha } else if (strcmp(key, "interval") == 0) { option_result = parse_integer(value, 0, INT_MAX / 10000, &cfg->global.ntp_resync_interval); if (cfg->global.ntp_resync_interval < 60) cfg->global.ntp_resync_interval = 60; - } else { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: unknown ntp option '%s'", filename, line_num, key); goto error; } + } else { CONFIG_ERROR("%s:%d: unknown ntp option '%s'", filename, line_num, key); goto error; } break; case SECTION_CHATSERVER: if (strcmp(key, "db_path") == 0) option_result = assign_string(cfg->global.db_path, sizeof(cfg->global.db_path), value); else if (parse_chatserver(key, value, &cfg->global, filename, line_num) < 0 && - chat_setting_set_state(&cfg->global.chat_settings, key, value) < 0) goto error; + chat_setting_set_state(&cfg->global.chat_settings, key, value) < 0) { + CONFIG_ERROR("%s:%d: invalid or unknown chatserver option '%s'", filename, line_num, key); + goto error; + } break; case SECTION_GUI: if (!strcasecmp(key, "utun_enabled")) { @@ -1196,8 +1205,9 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha cfg->global.gui_utun_enabled = 1; else if (!strcasecmp(value, "no") || !strcasecmp(value, "false") || !strcmp(value, "0")) cfg->global.gui_utun_enabled = 0; - else { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: invalid gui utun_enabled '%s'", filename, line_num, value); goto error; } - } + else { CONFIG_ERROR("%s:%d: invalid gui utun_enabled '%s'", filename, line_num, value); goto error; } + } else if (!strcasecmp(key, "bluetooth_flags")) + option_result = parse_integer(value, 0, 7, &cfg->global.gui_bluetooth_flags); break; case SECTION_REALITY: option_result = parse_reality(key, value, &cfg->global.reality, filename, line_num); break; case SECTION_SOCKS: option_result = parse_socks(key, value, &cfg->global, filename, line_num); break; @@ -1205,16 +1215,16 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha case SECTION_LOG_UDP: if (strcmp(key, "ip") == 0) option_result = assign_string(cfg->global.log_udp_ip, sizeof(cfg->global.log_udp_ip), value); else if (strcmp(key, "port") == 0) option_result = parse_integer(value, 0, UINT16_MAX, &cfg->global.log_udp_port); - else { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: unknown log_udp option '%s'", filename, line_num, key); goto error; } + else { CONFIG_ERROR("%s:%d: unknown log_udp option '%s'", filename, line_num, key); goto error; } break; default: DEBUG_WARN(DEBUG_CATEGORY_CONFIG, "%s:%d: Key outside section: %s", filename, line_num, key); break; } if (option_result < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: rejected option '%s'", filename, line_num, key); goto error; + CONFIG_ERROR("%s:%d: rejected option '%s'", filename, line_num, key); goto error; } } if (cfg->global.stun_enabled && cfg->global.stun_server_count < 2) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: enabled STUN requires at least two servers", filename); + CONFIG_ERROR("%s: enabled STUN requires at least two servers", filename); goto error; } if (cur_section == SECTION_SERVER && cur_server) { cur_server->next = cfg->servers; cfg->servers = cur_server; cur_server = NULL; } @@ -1222,7 +1232,7 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha if (cur_section == SECTION_NETWORK && cur_network) { cur_network->next = cfg->networks; cfg->networks = cur_network; cur_network = NULL; } for (struct CFG_NETWORK* net = cfg->networks; net; net = net->next) { if (config_ini_hex(net->pubkey_hex, NULL, SC_PUBKEY_SIZE) || config_ini_hex(net->signing_key_hex, NULL, SC_PRIVKEY_SIZE)) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: network '%s' requires both 32-byte HEX keys", filename, net->name); goto error; + CONFIG_ERROR("%s: network '%s' requires both 32-byte HEX keys", filename, net->name); goto error; } } if (!cfg->global.tun_enabled && cfg->global.tun_ifname[0] == '\0' && cfg->global.tun_ip.family == AF_UNSPEC) {} @@ -1230,7 +1240,7 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha else { cfg->global.tun_enabled = 1; if (cfg->global.tun_ifname[0] == '\0') { snprintf(cfg->global.tun_ifname, sizeof(cfg->global.tun_ifname), "tun0"); } } return cfg; error: - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: configuration rejected; releasing partial model", filename, line_num); + CONFIG_ERROR("%s:%d: configuration rejected; releasing partial model", filename, line_num); if (cur_server) u_free(cur_server); if (cur_client) { free_cfg_client_links(cur_client->links); u_free(cur_client); } if (cur_network) u_free(cur_network); @@ -1240,13 +1250,14 @@ error: /* Файл и буфер проходят один разбор; ошибки чтения/закрытия не дают частичный конфиг. */ struct utun_config* parse_config(const char *filename) { - if (!filename || !*filename) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "empty config filename"); return NULL; } + config_error_clear(); + if (!filename || !*filename) { CONFIG_ERROR("empty config filename"); return NULL; } FILE *file = fopen(filename, "rb"); - if (!file) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "cannot open config %s: %s", filename, strerror(errno)); return NULL; } + if (!file) { CONFIG_ERROR("cannot open config %s: %s", filename, strerror(errno)); return NULL; } char *text = NULL; size_t length = 0; int rc = config_ini_read(file, filename, &text, &length); - if (fclose(file)) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "cannot close config %s: %s", filename, strerror(errno)); rc = -1; } + if (fclose(file)) { CONFIG_ERROR("cannot close config %s: %s", filename, strerror(errno)); rc = -1; } struct utun_config *config = rc ? NULL : parse_config_from_buf(text, length, filename); u_free(text); return config; @@ -1423,13 +1434,13 @@ void print_config(const struct utun_config *cfg) { int update_config_keys(const char *filename, const char *priv_key, const char *pub_key) { if (!filename || !*filename || config_ini_hex(priv_key, NULL, SC_PRIVKEY_SIZE) || config_ini_hex(pub_key, NULL, SC_PUBKEY_SIZE)) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "update_config_keys requires a path and two 32-byte HEX keys"); return -1; + CONFIG_ERROR("update_config_keys requires a path and two 32-byte HEX keys"); return -1; } FILE *fp = fopen(filename, "a"); - if (!fp) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "cannot append keys to %s: %s", filename, strerror(errno)); return -1; } + if (!fp) { CONFIG_ERROR("cannot append keys to %s: %s", filename, strerror(errno)); return -1; } int failed = fprintf(fp, "\n[global]\nmy_private_key=%s\nmy_public_key=%s\n", priv_key, pub_key) < 0; - if (failed) DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "cannot write appended keys to %s: %s", filename, strerror(errno)); - if (fclose(fp)) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "cannot close appended key file %s: %s", filename, strerror(errno)); failed = 1; } + if (failed) CONFIG_ERROR("cannot write appended keys to %s: %s", filename, strerror(errno)); + if (fclose(fp)) { CONFIG_ERROR("cannot close appended key file %s: %s", filename, strerror(errno)); failed = 1; } if (!failed) DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "appended key configuration to %s", filename); return failed ? -1 : 0; } diff --git a/src/config_parser.h b/src/config_parser.h index 5aaf7fee7..d12031454 100644 --- a/src/config_parser.h +++ b/src/config_parser.h @@ -147,6 +147,7 @@ struct global_config { uint8_t client_type; // 0=server, 1=desktop, 2=mobile int tun_enabled; // 1 = запрошен TUN, 0 = выключен (значение по умолчанию) int gui_utun_enabled; // [gui] utun_enabled=yes: явно запустить UTUN в desktop/Android; по умолчанию 0 + int gui_bluetooth_flags; // [gui] bluetooth_flags: 1=BLE, 2=объявление узла, 4=входящие; по умолчанию 0 char tun_ifname[16]; // TUN interface name (e.g., "tun12") struct IP tun_ip; int mtu; @@ -281,7 +282,9 @@ struct utun_config { /* Непустой путь; результат освобождать free_config. NULL при ошибке открытия/чтения/закрытия, * памяти, формата или обработчика известной опции. Значения по умолчанию и полноту настройки * для конкретного сервиса дополнительно проверяет его start; ненулевой результат не гарантирует запуск. - * Файл не изменяется, ключи не генерируются: для этого отдельно вызвать config_ensure_keys_and_node_id. */ + * Файл не изменяется, ключи не генерируются: для этого отдельно вызвать config_ensure_keys_and_node_id. + * До вызова требуется socket_platform_init (для getaddrinfo на Windows). + * Причины отказа доступны через config_error_text() в текущем потоке, независимо от фильтров журнала. */ struct utun_config* parse_config(const char *filename); /* buf — len байтов без завершающего NUL (NULL допустим при len=0), буфер не меняется. * filename — источник для диагностики, NULL означает . Возврат как у parse_config. */ diff --git a/src/config_updater.c b/src/config_updater.c index 0bd13e3e1..b1de76f73 100644 --- a/src/config_updater.c +++ b/src/config_updater.c @@ -134,7 +134,7 @@ static int config_edit(const char *text, size_t length, const struct config_fiel for (size_t pos = 0;;) { /* Запас покрывает вставки полей и строки: strbuf не должен вырасти до 2^32 байтов. */ if (output->len > UINT32_MAX / 2 - 16384) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "edited INI exceeds allocator size limit"); + CONFIG_ERROR("edited INI exceeds allocator size limit"); return -1; } for (int s = CFG_GLOBAL_SECTION; s < CFG_SECTION_COUNT; s++) { @@ -160,7 +160,7 @@ static int config_edit(const char *text, size_t length, const struct config_fiel pos = end; } if (output->len > UINT32_MAX / 2 - 16384) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "edited INI exceeds allocator size limit"); + CONFIG_ERROR("edited INI exceeds allocator size limit"); return -1; } for (int s = CFG_GLOBAL_SECTION; s < CFG_SECTION_COUNT; s++) { @@ -225,7 +225,7 @@ static int config_publish(const char *filename, const char *text, size_t length, if (!lstat(filename, ¤t)) { if (!exists || !S_ISREG(current.st_mode) || current.st_nlink != 1 || current.st_dev != original->st_dev || current.st_ino != original->st_ino) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: destination changed or unsupported: existed=%d mode=%o links=%llu", + CONFIG_ERROR("%s: destination changed or unsupported: existed=%d mode=%o links=%llu", filename, exists, (unsigned)current.st_mode, (unsigned long long)current.st_nlink); errno = EINVAL; goto done; @@ -307,7 +307,7 @@ static int config_publish(const char *filename, const char *text, size_t length, phase = "replace destination"; #ifdef _WIN32 BOOL replaced = exists ? ReplaceFileA(filename, temp, NULL, 0, NULL, NULL) : MoveFileExA(temp, filename, MOVEFILE_WRITE_THROUGH); - if (!replaced) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: Windows replace failed: %lu", filename, GetLastError()); errno = EIO; goto done; } + if (!replaced) { CONFIG_ERROR("%s: Windows replace failed: %lu", filename, GetLastError()); errno = EIO; goto done; } #else if (rename(temp, filename)) goto done; #endif @@ -318,7 +318,7 @@ static int config_publish(const char *filename, const char *text, size_t length, #endif result = 0; done: - if (result) DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: config update failed at '%s': %s (replaced=%d)", filename, phase, strerror(errno), published); + if (result) CONFIG_ERROR("%s: config update failed at '%s': %s (replaced=%d)", filename, phase, strerror(errno), published); if (file && fclose(file)) DEBUG_WARN(DEBUG_CATEGORY_CONFIG, "%s: temporary close during cleanup failed: %s", filename, strerror(errno)); if (temp_owned && !published && remove(temp) && errno != ENOENT) { DEBUG_WARN(DEBUG_CATEGORY_CONFIG, "%s: temporary cleanup failed: %s", temp, strerror(errno)); @@ -334,7 +334,8 @@ done: /* Прочитать проверенный снимок, выбрать идентичность и опубликовать необходимые изменения. */ int config_ensure_keys_and_node_id(const char *filename) { - if (!filename || !*filename) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "empty config filename"); return -1; } + config_error_clear(); + if (!filename || !*filename) { CONFIG_ERROR("empty config filename"); return -1; } char *text = NULL; size_t length = 0; int exists = 0, result = -1; @@ -346,13 +347,13 @@ int config_ensure_keys_and_node_id(const char *filename) { if (!rc && !S_ISREG(original.st_mode)) { errno = EINVAL; rc = -1; } if (!rc) rc = config_ini_read(file, filename, &text, &length); if (fclose(file)) rc = -1; - if (rc) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: cannot read/close configuration: %s", filename, strerror(errno)); u_free(text); return -1; } + if (rc) { CONFIG_ERROR("%s: cannot read/close configuration: %s", filename, strerror(errno)); u_free(text); return -1; } } else if (errno != ENOENT) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: cannot open configuration: %s", filename, strerror(errno)); + CONFIG_ERROR("%s: cannot open configuration: %s", filename, strerror(errno)); return -1; } if (!text) text = u_strdup(""); - if (!text) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: cannot allocate configuration buffer", filename); return -1; } + if (!text) { CONFIG_ERROR("%s: cannot allocate configuration buffer", filename); return -1; } struct config_field fields[CFG_FIELD_COUNT]; size_t ends[CFG_SECTION_COUNT]; config_fields_init(fields); @@ -369,13 +370,13 @@ int config_ensure_keys_and_node_id(const char *filename) { uint8_t add_key[32]; if (fields[CFG_SERVER_KEY].present) { if (fields[CFG_SERVER_KEY].invalid || config_ini_hex(fields[CFG_SERVER_KEY].value, add_key, sizeof(add_key))) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: invalid chatserver add_key; refusing implicit rotation", filename); goto done; + CONFIG_ERROR("%s: invalid chatserver add_key; refusing implicit rotation", filename); goto done; } int nonzero = 0; for (size_t i = 0; i < sizeof(add_key); i++) nonzero |= add_key[i]; - if (!nonzero) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: zero chatserver add_key", filename); goto done; } + if (!nonzero) { CONFIG_ERROR("%s: zero chatserver add_key", filename); goto done; } } else { if (RAND_bytes(add_key, sizeof(add_key)) != 1) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: chatserver add_key generation failed", filename); goto done; + CONFIG_ERROR("%s: chatserver add_key generation failed", filename); goto done; } bytes_to_hex(add_key, sizeof(add_key), fields[CFG_SERVER_KEY].value, sizeof(fields[CFG_SERVER_KEY].value)); fields[CFG_SERVER_KEY].update = 1; @@ -393,7 +394,7 @@ int config_ensure_keys_and_node_id(const char *filename) { } if (sc_compute_public_key_from_private(private_key, public_key) != SC_OK) goto done; node_id = sc_derive_node_id_from_pubkey(public_key); - if (!node_id) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: derived zero node_id", filename); goto done; } + if (!node_id) { CONFIG_ERROR("%s: derived zero node_id", filename); goto done; } if (config_ini_hex(fields[CFG_PUBLIC].value, decoded, sizeof(decoded)) || memcmp(decoded, public_key, sizeof(decoded))) { fields[CFG_PUBLIC].update = 1; bytes_to_hex(public_key, sizeof(public_key), fields[CFG_PUBLIC].value, sizeof(fields[CFG_PUBLIC].value)); @@ -407,7 +408,7 @@ int config_ensure_keys_and_node_id(const char *filename) { int reality_enabled = !strcmp(enabled, "1") || !strcasecmp(enabled, "yes") || !strcasecmp(enabled, "true"); if (fields[CFG_REALITY_ENABLED].present && !reality_enabled && strcmp(enabled, "0") && strcasecmp(enabled, "no") && strcasecmp(enabled, "false")) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: invalid REALITY enabled value", filename); goto done; + CONFIG_ERROR("%s: invalid REALITY enabled value", filename); goto done; } if (fields[CFG_REALITY_ENABLED].invalid) fields[CFG_REALITY_ENABLED].update = 1; if (reality_enabled) { @@ -443,12 +444,12 @@ int config_ensure_keys_and_node_id(const char *filename) { if (!updates) { DEBUG_DEBUG(DEBUG_CATEGORY_CONFIG, "%s: identity and REALITY unchanged", filename); result = 0; goto done; } if (config_edit(text, length, fields, ends, &output) || config_ini_validate(strbuf_str(&output), output.len, filename) || config_verify(strbuf_str(&output), output.len, filename, private_key, public_key, node_id, reality_enabled, reality_private, ids, id_count)) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: prepared config failed construction/verification", filename); + CONFIG_ERROR("%s: prepared config failed construction/verification", filename); goto done; } result = config_publish(filename, strbuf_str(&output), output.len, exists, &original); done: - if (result) DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: cannot ensure identity/REALITY", filename); + if (result) CONFIG_ERROR("%s: cannot ensure identity/REALITY", filename); strbuf_free(&output); u_free(text); return result; diff --git a/src/config_updater.h b/src/config_updater.h index 8877ff315..2492f52b2 100644 --- a/src/config_updater.h +++ b/src/config_updater.h @@ -23,7 +23,9 @@ extern "C" { #endif /* filename — непустой путь. 0 — значения корректны, изменения при необходимости записаны; - * -1 — ошибка с диагностикой CONFIG. Содержимое готовится и проверяется до записи. + * -1 — ошибка с диагностикой CONFIG и причинами в config_error_text() текущего потока. + * До вызова требуется socket_platform_init: проверка правок использует парсер с getaddrinfo. + * Содержимое готовится и проверяется до записи. * Временный файл создаётся эксклюзивно рядом с исходным, синхронизируется и закрывается. * POSIX: rename + fsync каталога; до rename исходный файл сохраняется при ошибках. * После rename ошибка fsync каталога даёт -1 с replaced=1: файл уже заменён. diff --git a/tools/ble-test-android/.gitignore b/tools/ble-test-android/.gitignore new file mode 100644 index 000000000..778e2bc8e --- /dev/null +++ b/tools/ble-test-android/.gitignore @@ -0,0 +1,4 @@ +.gradle/ +.kotlin/ +**/build/ +local.properties diff --git a/tools/ble-test-android/README.md b/tools/ble-test-android/README.md new file mode 100644 index 000000000..3b2de9c3b --- /dev/null +++ b/tools/ble-test-android/README.md @@ -0,0 +1,251 @@ +# 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 = 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`. diff --git a/tools/ble-test-android/app/build.gradle.kts b/tools/ble-test-android/app/build.gradle.kts new file mode 100644 index 000000000..367a0a2c8 --- /dev/null +++ b/tools/ble-test-android/app/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} +android { + namespace = "com.utun.bletest" + compileSdk = 36 + defaultConfig { + applicationId = "com.utun.bletest" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "0.1.0" + testInstrumentationRunner = "com.utun.bletest.BleApiProbe" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} +kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } } +dependencies { + implementation(project(":ble-transport")) +} diff --git a/tools/ble-test-android/app/src/androidTest/java/com/utun/bletest/BleApiProbe.kt b/tools/ble-test-android/app/src/androidTest/java/com/utun/bletest/BleApiProbe.kt new file mode 100644 index 000000000..513424729 --- /dev/null +++ b/tools/ble-test-android/app/src/androidTest/java/com/utun/bletest/BleApiProbe.kt @@ -0,0 +1,115 @@ +package com.utun.bletest + +import android.app.Activity +import android.app.Instrumentation +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Bundle +import android.os.IBinder +import android.os.SystemClock +import com.utun.ble.* +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** Два физических телефона: Binder API, duplex, заполнение RX, возобновление кредита и отказ политики. */ +class BleApiProbe : Instrumentation() { + private lateinit var args: Bundle + override fun onCreate(arguments: Bundle) { + super.onCreate(arguments) + args = arguments + start() + } + + override fun onStart() { + val result = Bundle() + var resultCode = Activity.RESULT_OK + var bound = false + var api: BleBroadcastService.LocalBinder? = null + val boundLatch = CountDownLatch(1) + val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, service: IBinder) { + api = service as BleBroadcastService.LocalBinder + boundLatch.countDown() + } + override fun onServiceDisconnected(name: ComponentName) { api = null } + } + try { + val peer = java.lang.Long.parseUnsignedLong(checkNotNull(args.getString("peer")), 16) + val initiator = args.getString("initiator") == "true" + val reject = args.getString("reject") == "true" + val activity = startActivitySync(Intent(targetContext, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + bound = targetContext.bindService(Intent(targetContext, BleBroadcastService::class.java), connection, Context.BIND_AUTO_CREATE) + check(bound && boundLatch.await(10, TimeUnit.SECONDS)) { "Binder unavailable" } + val service = checkNotNull(api) + service.connectionSnapshots().forEach { service.disconnect(it.nodeId) } + val validations = AtomicInteger() + service.setConnectionValidator { request -> + validations.incrementAndGet() + !reject && request.localNodeId == service.nodeId() && request.remoteNodeId == peer + } + runOnMainSync { BleBroadcastService.start(targetContext, "API-probe".toByteArray(), 1000) } + val deadline = SystemClock.elapsedRealtime() + 180_000 + fun await(test: () -> Boolean) { + while (!test()) { + check(SystemClock.elapsedRealtime() < deadline) { "Timeout: ${service.connectionSnapshots()}" } + Thread.sleep(50) + } + } + await { service.snapshot().running } + runOnMainSync { activity.moveTaskToBack(true) } + if (initiator) check(service.connect(peer) == ConnectResult.REQUESTED) + if (reject) { + await { + if (initiator) service.connectionSnapshots().any { it.lastError.contains("Remote validation rejected") } + else validations.get() > 0 + } + if (!initiator) Thread.sleep(1500) // Дать REJECT indication дойти до инициатора до cleanup. + check(service.connectionSnapshots().none { it.state == "READY" }) { "Rejected peer became READY" } + result.putString("result", "PASS rejection validations=${validations.get()}") + } else { + await { service.connectionSnapshots().any { it.nodeId == peer && it.state == "READY" } } + var sent = 0 + var received = 0 + var blockedAt = 0L + var blocked = false + while (sent < 70 || received < 70) { + check(SystemClock.elapsedRealtime() < deadline) { "Duplex timeout sent=$sent received=$received ${service.connectionSnapshots()}" } + val stats = service.connectionSnapshots().single { it.nodeId == peer } + check(stats.state == "READY") { "Disconnected during transfer: $stats" } + if (sent < 70 && stats.txQueued < 64) { + check(service.sendTo(peer, payload(sent)) == NodeSendResult.QUEUED) + sent++ + } + if (stats.rxBlocked && !blocked) { + blocked = true + blockedAt = SystemClock.elapsedRealtime() + check(stats.rxQueued == 64) { "RX exceeded/undershot capacity: $stats" } + } + if (blocked && SystemClock.elapsedRealtime() - blockedAt >= 1000) { + service.drainNodeReceived(peer).forEach { data -> + check(data.contentEquals(payload(received))) { "Corrupt/out of order message=$received bytes=${data.size}" } + received++ + } + } + Thread.sleep(10) + } + await { service.connectionSnapshots().single { it.nodeId == peer }.sent == 70L } + check(blocked) { "RX backpressure was not exercised" } + result.putString("result", "PASS duplex sent=$sent received=$received rxBlocked=$blocked ${service.connectionSnapshots()}") + } + } catch (error: Throwable) { + resultCode = Activity.RESULT_CANCELED + result.putString("result", "FAIL ${error.stackTraceToString()}") + } finally { + api?.connectionSnapshots()?.forEach { api?.disconnect(it.nodeId) } + runOnMainSync { BleBroadcastService.stop(targetContext) } + if (bound) targetContext.unbindService(connection) + } + finish(resultCode, result) + } + + private fun payload(index: Int): ByteArray = ByteArray(if (index == 69) 4096 else 512) { ((it * 31 + index) and 0xff).toByte() } +} diff --git a/tools/ble-test-android/app/src/main/AndroidManifest.xml b/tools/ble-test-android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..1eef6da1e --- /dev/null +++ b/tools/ble-test-android/app/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/tools/ble-test-android/app/src/main/java/com/utun/bletest/MainActivity.kt b/tools/ble-test-android/app/src/main/java/com/utun/bletest/MainActivity.kt new file mode 100644 index 000000000..b9e9e984a --- /dev/null +++ b/tools/ble-test-android/app/src/main/java/com/utun/bletest/MainActivity.kt @@ -0,0 +1,401 @@ +package com.utun.bletest + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.content.pm.PackageManager +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.SystemClock +import android.provider.Settings +import android.text.InputType +import android.util.Log +import android.view.WindowInsets +import android.widget.Button +import android.widget.CheckBox +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import com.utun.ble.BleBroadcastService +import com.utun.ble.BleProtocol +import com.utun.ble.BleSnapshot +import com.utun.ble.SendResult +import java.util.UUID + +/** Тестовый клиент Binder API. Ни Bluetooth callbacks, ни фоновые таймеры здесь не живут. */ +@SuppressLint("SetTextI18n") // Локальная тестовая утилита с русским экраном диагностики, без локализации. +class MainActivity : Activity() { + private val handler = Handler(Looper.getMainLooper()) + private var api: BleBroadcastService.LocalBinder? = null + private var bound = false + private var consumed = 0L + private lateinit var nickname: EditText + private lateinit var period: EditText + private lateinit var targetId: EditText + private lateinit var allowedId: EditText + private lateinit var automatic: CheckBox + private lateinit var readNodeRx: CheckBox + private lateinit var connectionText: TextView + private lateinit var message: EditText + private var nodeConsumed = 0L + private var lastNodeMessage = "" + private lateinit var status: TextView + private lateinit var totals: TextView + private lateinit var peers: TextView + private lateinit var feedback: TextView + private lateinit var start: Button + private lateinit var stop: Button + private lateinit var send: Button + private val preferences by lazy { getSharedPreferences("test_ui", MODE_PRIVATE) } + + private val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, service: IBinder) { + api = service as BleBroadcastService.LocalBinder + refresh() + } + override fun onServiceDisconnected(name: ComponentName) { + api = null + status.text = "Соединение с сервисом потеряно" + Log.w(BleBroadcastService.TAG, "GUI service connection lost") + } + override fun onBindingDied(name: ComponentName) { + api = null + if (bound) unbindService(this) + bound = bindService(Intent(this@MainActivity, BleBroadcastService::class.java), this, Context.BIND_AUTO_CREATE) + Log.w(BleBroadcastService.TAG, "GUI rebinding after service death: bound=$bound") + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val root = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(20), dp(16), dp(20), dp(24)) + setBackgroundColor(Color.rgb(245, 248, 251)) + } + val scroll = ScrollView(this).apply { addView(root) } + scroll.setOnApplyWindowInsetsListener { view, insets -> + if (Build.VERSION.SDK_INT >= 30) { + val bars = insets.getInsets(WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout() or WindowInsets.Type.ime()) + view.setPadding(bars.left, bars.top, bars.right, bars.bottom) + } else { + @Suppress("DEPRECATION") + view.setPadding(insets.systemWindowInsetLeft, insets.systemWindowInsetTop, + insets.systemWindowInsetRight, insets.systemWindowInsetBottom) + } + insets + } + setContentView(scroll) + scroll.requestApplyInsets() + fun label(value: String, size: Float = 16f): TextView = TextView(this).apply { + text = value + textSize = size + setTextColor(Color.rgb(24, 42, 61)) + setPadding(0, dp(10), 0, dp(6)) + root.addView(this) + } + label("uTun · BLE Test", 28f) + label("ID: ${BleProtocol.nodeIdText(BleBroadcastService.nodeId(this))}").setTextIsSelectable(true) + label("Проверка обмена BLE broadcasts. Приём и передача продолжаются в фоне с уведомлением.") + label("Ник устройства · до ${BleProtocol.MAX_PAYLOAD} байт UTF-8") + nickname = EditText(this).apply { + isSingleLine = true + setText(preferences.getString("nickname", "BLE-${UUID.randomUUID().toString().take(4)}")) + contentDescription = "Ник устройства" + root.addView(this) + } + label("Период отправки, мс · 250–3600000") + period = EditText(this).apply { + inputType = InputType.TYPE_CLASS_NUMBER + isSingleLine = true + setText(preferences.getLong("period", 1000).toString()) + contentDescription = "Период отправки в миллисекундах" + root.addView(this) + } + label("ID назначения, hex · пусто — без явного запроса") + targetId = EditText(this).apply { + isSingleLine = true + contentDescription = "ID назначения" + setText(preferences.getString("targetText", "")) + root.addView(this) + } + label("Допуск входящих: ID, hex · пусто — любой ненулевой ID") + allowedId = EditText(this).apply { + isSingleLine = true + contentDescription = "Разрешённый ID" + setText(preferences.getString("allowedText", "")) + root.addView(this) + } + automatic = CheckBox(this).apply { + text = "Автоматически соединяться с обнаруженными узлами" + isChecked = preferences.getBoolean("automatic", true) + root.addView(this) + } + start = Button(this).apply { + text = "Запустить тест" + setOnClickListener { requestStart() } + root.addView(this) + } + stop = Button(this).apply { + text = "Остановить сервис" + setOnClickListener { + try { BleBroadcastService.stop(this@MainActivity) } + catch (error: RuntimeException) { showError("Не удалось остановить сервис", error) } + } + root.addView(this) + } + send = Button(this).apply { + text = "Отправить сейчас через очередь" + setOnClickListener { + val result = api?.send(nickname.text.toString().trim().toByteArray(Charsets.UTF_8)) + feedback.text = when (result) { + SendResult.QUEUED -> "Сообщение добавлено в очередь отправки" + SendResult.QUEUE_FULL -> "Очередь занята; повторите позднее" + else -> "Отправка отклонена: ${result ?: "сервис не подключён"}" + } + } + root.addView(this) + } + feedback = label("") + status = label("Подключение к сервису…", 20f) + totals = label("") + label("Соединения GATT", 22f) + connectionText = label("Соединений пока нет") + readNodeRx = CheckBox(this).apply { + text = "Забирать данные из RX (снять для проверки backpressure)" + isChecked = true + root.addView(this) + } + message = EditText(this).apply { + contentDescription = "Сообщение узлу" + setText("Hello over GATT") + root.addView(this) + } + Button(this).apply { + text = "Подключить ID назначения через API" + setOnClickListener { + try { + val id = parseId(targetId.text.toString()) + feedback.text = "connect: ${api?.connect(id)}" + } catch (e: RuntimeException) { showError("Неверный ID назначения", e) } + } + root.addView(this) + } + Button(this).apply { + text = "Отправить данные подключённым узлам" + setOnClickListener { + val service = api ?: return@setOnClickListener + val results = service.connectionSnapshots().filter { it.state == "READY" }.map { + "${BleProtocol.nodeIdText(it.nodeId)}: ${service.sendTo(it.nodeId, message.text.toString().toByteArray(Charsets.UTF_8))}" + } + feedback.text = results.joinToString("\n").ifEmpty { "Нет готовых соединений" } + } + root.addView(this) + } + Button(this).apply { + text = "Нагрузить TX: 64 сообщения × 512 байт" + setOnClickListener { + val service = api ?: return@setOnClickListener + var accepted = 0 + var rejected = 0 + service.connectionSnapshots().filter { it.state == "READY" }.forEach { peer -> + repeat(64) { number -> + val data = ByteArray(512) { ((it + number) and 0xff).toByte() } + if (service.sendTo(peer.nodeId, data) == com.utun.ble.NodeSendResult.QUEUED) accepted++ else rejected++ + } + } + feedback.text = "Добавлено: $accepted; отклонено: $rejected" + } + root.addView(this) + } + Button(this).apply { + text = "Отключить соединения через API" + setOnClickListener { api?.let { service -> service.connectionSnapshots().forEach { service.disconnect(it.nodeId) } } } + root.addView(this) + } + label("Принято по никам", 22f) + peers = label("Другие устройства ещё не обнаружены") + label("Отправлено = обновлений данных, подтверждённых BLE-стеком. Доставка не подтверждается. " + + "Повторы радиообъявлений не увеличивают число принятых сообщений. " + + "Кириллица занимает несколько байт на символ. Счётчики хранятся до завершения процесса приложения.", 14f) + Button(this).apply { + text = "Настройки приложения" + setOnClickListener { startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, android.net.Uri.parse("package:$packageName"))) } + root.addView(this) + } + if (savedInstanceState != null) { + nickname.setText(savedInstanceState.getString("nickname")) + period.setText(savedInstanceState.getString("period")) + targetId.setText(savedInstanceState.getString("targetText")) + allowedId.setText(savedInstanceState.getString("allowedText")) + automatic.isChecked = savedInstanceState.getBoolean("automatic") + } + } + + private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() + + override fun onStart() { + super.onStart() + bound = bindService(Intent(this, BleBroadcastService::class.java), connection, Context.BIND_AUTO_CREATE) + if (!bound) showError("Не удалось подключиться к BLE сервису") + handler.post(tick) + } + + override fun onStop() { + handler.removeCallbacks(tick) + if (bound) unbindService(connection) + bound = false + api = null + super.onStop() + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putString("nickname", nickname.text.toString()) + outState.putString("period", period.text.toString()) + outState.putString("targetText", targetId.text.toString()) + outState.putString("allowedText", allowedId.text.toString()) + outState.putBoolean("automatic", automatic.isChecked) + super.onSaveInstanceState(outState) + } + + private fun requestStart() { + val nick = nickname.text.toString().trim() + val interval = period.text.toString().toLongOrNull() + if (nick.isEmpty() || nick.toByteArray(Charsets.UTF_8).size > BleProtocol.MAX_PAYLOAD) { + showError("Укажите ник длиной 1–${BleProtocol.MAX_PAYLOAD} байт UTF-8") + return + } + if (interval == null || interval !in BleBroadcastService.MIN_PERIOD_MS..BleBroadcastService.MAX_PERIOD_MS) { + showError("Период должен быть от 250 до 3600000 мс") + return + } + if (!packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) { + showError("Устройство не поддерживает BLE") + return + } + if (!BleBroadcastService.hasPermissions(this)) { + requestPermissions(BleBroadcastService.requiredPermissions(), 100) + return + } + try { + val adapter = getSystemService(BluetoothManager::class.java)?.adapter + if (adapter == null) { + showError("Bluetooth адаптер отсутствует") + return + } + if (!adapter.isEnabled) { + @Suppress("DEPRECATION") + startActivityForResult(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE), 102) + return + } + val target = parseId(targetId.text.toString()) + val allowed = parseId(allowedId.text.toString()) + preferences.edit().putString("nickname", nick).putLong("period", interval) + .putString("targetText", targetId.text.toString()).putString("allowedText", allowedId.text.toString()) + .putLong("allowedId", allowed).putBoolean("automatic", automatic.isChecked).apply() + BleBroadcastService.start(this, nick.toByteArray(Charsets.UTF_8), interval, target, automatic.isChecked) + feedback.text = "Тест запускается. Для сравнения задайте на другом устройстве другой ник." + if (Build.VERSION.SDK_INT >= 33 && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 101) + } + } catch (error: RuntimeException) { showError("Не удалось запустить BLE тест", error) } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == 100) { + if (BleBroadcastService.hasPermissions(this)) requestStart() + else showError("Для BLE нужны разрешения. Если запрос больше не появляется, выдайте их в настройках приложения.") + } else if (requestCode == 101 && grantResults.firstOrNull() != PackageManager.PERMISSION_GRANTED) { + feedback.text = "Уведомления отключены; сервис продолжит работу. Разрешить их можно в настройках приложения." + Log.w(BleBroadcastService.TAG, "Notification permission denied; foreground service remains active") + } + } + + @Deprecated("Legacy Activity result callback") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == 102) { + if (resultCode == RESULT_OK) requestStart() + else showError("Включение Bluetooth отменено") + } + } + + private val tick = object : Runnable { + override fun run() { + refresh() + handler.postDelayed(this, 1000) + } + } + + private fun refresh() { + val service = api ?: return + // GUI выступает consumer очереди, статистика сервиса остаётся полной и в фоне. + consumed += service.drainReceived(256).size + if (readNodeRx.isChecked) service.connectionSnapshots().forEach { peer -> + val data = service.drainNodeReceived(peer.nodeId) + nodeConsumed += data.size + data.lastOrNull()?.let { + lastNodeMessage = "${BleProtocol.nodeIdText(peer.nodeId)}: ${it.size} байт, hex " + + it.take(24).joinToString("") { b -> "%02x".format(b) } + } + } + connectionText.text = service.connectionSnapshots().joinToString("\n\n") { + "${BleProtocol.nodeIdText(it.nodeId)} · ${it.state} · MTU ${it.mtu}\n" + + "TX/RX ${it.sent}/${it.received} · байт ${it.sentBytes}/${it.receivedBytes}\n" + + "Очереди ${it.txQueued}/${it.rxQueued} · ждёт ACK ${it.waitingAck} · RX блокирует ${it.rxBlocked}\n" + + "Сброшено TX ${it.discardedTx}" + if (it.lastError.isEmpty()) "" else "\n${it.lastError}" + }.ifEmpty { "Соединений пока нет" } + "\nGUI прочитал: $nodeConsumed\n$lastNodeMessage" + render(service.snapshot()) + } + + private fun render(s: BleSnapshot) { + status.text = s.state + nickname.isEnabled = !s.running + period.isEnabled = !s.running + allowedId.isEnabled = !s.running + automatic.isEnabled = !s.running + start.isEnabled = !s.running + stop.isEnabled = s.running + send.isEnabled = s.running + totals.text = "Отправлено: ${s.sent} Принято: ${s.received}\n" + + "Повторов: ${s.duplicates} Устаревших: ${s.stale}\n" + + "Очереди TX / RX: ${s.txQueued} / ${s.rxQueued}\n" + + "GUI забрал из RX: $consumed\n" + + "Отказов TX: ${s.txRejected} · Сброшено TX: ${s.txDiscarded}\n" + + "Переполнений RX: ${s.rxDropped} · Пропущено периодов: ${s.periodicSkipped}\n" + + "Ошибок: ${s.errors} · Неверных пакетов: ${s.invalid} · Вытеснено устройств: ${s.peerEvictions}\n" + + "Сеанс: ${s.session.toString(16)}" + if (s.lastError.isNotEmpty()) "\nПоследняя ошибка: ${s.lastError}" else "" + val now = SystemClock.elapsedRealtime() + peers.text = if (s.peers.isEmpty()) "Другие устройства ещё не обнаружены" else s.peers + .groupBy { String(it.payload, Charsets.UTF_8) }.toSortedMap().entries.joinToString("\n\n") { (nick, sessions) -> + "${nick.ifEmpty { "(пустые данные)" }} — принято ${sessions.sumOf { it.received }}, повторов ${sessions.sumOf { it.duplicates }}\n" + + sessions.joinToString("\n") { + " ID ${BleProtocol.nodeIdText(it.nodeId)} · session ${it.session.toString(16)}\n" + + " seq ${it.lastSequence} · ${it.rssi} dBm · ${maxOf(0, now - it.lastSeenMs) / 1000} с назад" + } + } + } + + private fun showError(message: String, error: RuntimeException? = null) { + feedback.text = if (error == null) message else "$message: ${error.message}" + Log.e(BleBroadcastService.TAG, feedback.text.toString(), error) + } + + private fun parseId(text: String): Long { + if (text.isBlank()) return 0 + require(text.trim().length <= 16) { "ID должен содержать до 16 hex цифр" } + return java.lang.Long.parseUnsignedLong(text.trim(), 16).also { require(it != 0L) { "ID 0 зарезервирован" } } + } +} diff --git a/tools/ble-test-android/app/src/main/java/com/utun/bletest/TestApplication.kt b/tools/ble-test-android/app/src/main/java/com/utun/bletest/TestApplication.kt new file mode 100644 index 000000000..2df75b275 --- /dev/null +++ b/tools/ble-test-android/app/src/main/java/com/utun/bletest/TestApplication.kt @@ -0,0 +1,20 @@ +package com.utun.bletest + +import android.app.Application +import android.util.Log +import com.utun.ble.BleBroadcastService +import com.utun.ble.BleConnectionRequest +import com.utun.ble.BleConnectionValidator +import com.utun.ble.BleProtocol + +/** Политика тестового приложения живёт без Activity и восстанавливается после перезапуска процесса. */ +class TestApplication : Application(), BleConnectionValidator { + override fun validate(request: BleConnectionRequest): Boolean { + val allowedId = getSharedPreferences("test_ui", MODE_PRIVATE).getLong("allowedId", 0) + val accepted = request.localNodeId == BleBroadcastService.nodeId(this) && request.remoteNodeId != 0L && + request.remoteNodeId != request.localNodeId && (allowedId == 0L || request.remoteNodeId == allowedId) + Log.i(BleBroadcastService.TAG, "App validation: remote=${BleProtocol.nodeIdText(request.remoteNodeId)}" + + " allowed=${BleProtocol.nodeIdText(allowedId)} accepted=$accepted") + return accepted + } +} diff --git a/tools/ble-test-android/app/src/main/res/drawable/ic_ble.xml b/tools/ble-test-android/app/src/main/res/drawable/ic_ble.xml new file mode 100644 index 000000000..72ea35784 --- /dev/null +++ b/tools/ble-test-android/app/src/main/res/drawable/ic_ble.xml @@ -0,0 +1,6 @@ + + + + diff --git a/tools/ble-test-android/app/src/main/res/xml/data_extraction_rules.xml b/tools/ble-test-android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 000000000..fcd964a2a --- /dev/null +++ b/tools/ble-test-android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tools/ble-test-android/ble-transport/build.gradle.kts b/tools/ble-test-android/ble-transport/build.gradle.kts new file mode 100644 index 000000000..010f8f609 --- /dev/null +++ b/tools/ble-test-android/ble-transport/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} +android { + namespace = "com.utun.ble" + compileSdk = 36 + defaultConfig { minSdk = 26 } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} +kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } } +dependencies { + testImplementation("junit:junit:4.13.2") +} diff --git a/tools/ble-test-android/ble-transport/src/main/AndroidManifest.xml b/tools/ble-test-android/ble-transport/src/main/AndroidManifest.xml new file mode 100644 index 000000000..0d3af0717 --- /dev/null +++ b/tools/ble-test-android/ble-transport/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + diff --git a/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleBroadcastService.kt b/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleBroadcastService.kt new file mode 100644 index 000000000..5999c08a7 --- /dev/null +++ b/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleBroadcastService.kt @@ -0,0 +1,533 @@ +package com.utun.ble + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.bluetooth.le.AdvertiseData +import android.bluetooth.le.AdvertisingSet +import android.bluetooth.le.AdvertisingSetCallback +import android.bluetooth.le.AdvertisingSetParameters +import android.bluetooth.le.BluetoothLeAdvertiser +import android.bluetooth.le.BluetoothLeScanner +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.location.LocationManager +import android.os.Binder +import android.os.Build +import android.os.Handler +import android.os.HandlerThread +import android.os.IBinder +import android.os.ParcelUuid +import android.os.PowerManager +import android.os.SystemClock +import android.provider.Settings +import android.util.Base64 +import android.util.Log +import java.security.SecureRandom + +/** + * Самостоятельный started + bound foreground-сервис. BLE и таймеры принадлежат одному HandlerThread. + * Activity только запускает/останавливает сервис и использует LocalBinder; unbind не останавливает BLE. + * API рассчитан на один процесс приложения. Вход/выход — произвольные байты, без зависимости от GUI и ников. + */ +@SuppressLint("MissingPermission") // Runtime-права проверяются до запуска и повторно в watchdog; отзыв ловится в catch. +class BleBroadcastService : Service() { + companion object { + const val TAG = "UtunBle" + const val MIN_PERIOD_MS = 250L + const val MAX_PERIOD_MS = 3_600_000L + private const val ACTION_START = "com.utun.ble.START" + private const val ACTION_STOP = "com.utun.ble.STOP" + private const val CHANNEL = "utun_ble_transport" + private const val NOTIFICATION = 8412 + private const val CALLBACK_TIMEOUT_MS = 10_000L + private const val RETRY_MS = 30_000L // Не попадаем в Android scan start throttling. + + /** Постоянный unsigned 64-bit ID установки; в Kotlin хранится в битах Long, 0 зарезервирован. */ + @Synchronized fun nodeId(context: Context): Long { + val prefs = context.getSharedPreferences("ble_transport", MODE_PRIVATE) + val existing = prefs.getLong("nodeId", 0) + if (existing != 0L) return existing + val id = generateSequence { SecureRandom().nextLong() }.first { it != 0L } + prefs.edit().putLong("nodeId", id).apply() + return id + } + + fun requiredPermissions(): Array = if (Build.VERSION.SDK_INT >= 31) { + arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_ADVERTISE, Manifest.permission.BLUETOOTH_CONNECT) + } else arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) + + fun hasPermissions(context: Context): Boolean = requiredPermissions().all { + context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED + } + + /** Вызывать из видимой Activity после выдачи прав. null payload отключает периодический источник. */ + fun start(context: Context, periodicPayload: ByteArray? = null, periodMs: Long = 1000, + targetNodeId: Long = 0, autoConnect: Boolean = false, localNodeId: Long = nodeId(context)) { + require(periodMs in MIN_PERIOD_MS..MAX_PERIOD_MS) + require(periodicPayload == null || periodicPayload.size <= BleProtocol.MAX_PAYLOAD) + require(localNodeId != 0L) + context.startForegroundService(Intent(context, BleBroadcastService::class.java).setAction(ACTION_START) + .putExtra("payload", periodicPayload).putExtra("period", periodMs) + .putExtra("targetNodeId", targetNodeId).putExtra("autoConnect", autoConnect).putExtra("localNodeId", localNodeId)) + } + + /** Остановка явная; закрытие Activity и отвязка Binder работу не прекращают. */ + fun stop(context: Context) { + context.startService(Intent(context, BleBroadcastService::class.java).setAction(ACTION_STOP)) + } + } + + inner class LocalBinder : Binder() { + /** Неблокирующая запись. QUEUE_FULL означает: повторить после уменьшения txQueued. */ + fun send(payload: ByteArray): SendResult { + val result = queues.send(payload) + if (result == SendResult.QUEUED) worker.post { pumpTx() } + return result + } + fun pollReceived(): BleReceived? = queues.pollReceived() + fun drainReceived(limit: Int = 64): List = queues.drainReceived(limit) + fun snapshot(): BleSnapshot = queues.snapshot() + fun nodeId(): Long = localNodeId + /** Политика входящих запросов в BLE worker. null запрещает входящие, callback не должен блокировать worker. */ + fun setConnectionValidator(callback: BleConnectionValidator?) { validator = callback } + /** Заявка по ID, адрес находится из discovery; REQUESTED не означает READY. Повторяет после обрыва до disconnect/stop. */ + fun connect(nodeId: Long): ConnectResult { + autoBlocked.remove(nodeId) + return connections.connect(nodeId) + } + /** Отменяет заявку и автоматические попытки к этому ID, закрывает текущее соединение. */ + fun disconnect(nodeId: Long) { + autoBlocked.add(nodeId) + connections.disconnect(nodeId) + } + /** Копирует сообщение до 4096 байт в TX готового узла; QUEUE_FULL требует дождаться освобождения очереди. */ + fun sendTo(nodeId: Long, payload: ByteArray): NodeSendResult = connections.send(nodeId, payload) + /** Один логический consumer забирает сообщение и возвращает кредит peer; null означает пустой RX. */ + fun pollNodeReceived(nodeId: Long): ByteArray? = connections.pollReceived(nodeId) + /** Забирает до limit сообщений, limit > 0. Кредит возвращается асинхронно в BLE worker. */ + fun drainNodeReceived(nodeId: Long, limit: Int = 64): List = connections.drainReceived(nodeId, limit) + /** Неизменяемые снимки известных соединений; sent учитывает только сообщения с удалённым ACK. */ + fun connectionSnapshots(): List = connections.snapshots() + } + + private val queues = BleQueues(info = { Log.i(TAG, it) }) { Log.w(TAG, it) } + private val binder = LocalBinder() + private val main = Handler(android.os.Looper.getMainLooper()) + private lateinit var thread: HandlerThread + private lateinit var worker: Handler + @Volatile private var localNodeId = 0L + @Volatile private lateinit var connections: BleGattTransport + @Volatile private var validator: BleConnectionValidator? = null + private var autoConnect = false + private val autoBlocked = java.util.concurrent.ConcurrentHashMap.newKeySet() + private var requested = false + private var generation = 0L + private var scanner: BluetoothLeScanner? = null + private var advertiser: BluetoothLeAdvertiser? = null + private var scanCallback: ScanCallback? = null + private var advertisingCallback: AdvertisingSetCallback? = null + private var advertisingSet: AdvertisingSet? = null + private var active: BleProtocol.Packet? = null + private var repeatPayload: ByteArray? = null + private var repeatPeriod = 1000L + private var txReadyAt = 0L + private var retryAt = 0L + private var wakeLock: PowerManager.WakeLock? = null + private var foreground = false // Только главный поток; защищает от запоздалого обновления notification после stop. + private val preferences by lazy { getSharedPreferences("ble_transport", MODE_PRIVATE) } + + private val bluetoothReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) + Log.i(TAG, "Adapter state=$state") + worker.post { + if (!requested) return@post + if (state == BluetoothAdapter.STATE_ON) { + retryAt = 0 + startRadio() + } else if (state == BluetoothAdapter.STATE_TURNING_OFF || state == BluetoothAdapter.STATE_OFF) { + closeRadio(adapterUnavailable = true) + queues.setState("Ожидание включения Bluetooth") + } + } + } + } + + override fun onCreate() { + super.onCreate() + thread = HandlerThread("utun-ble") + thread.start() + worker = Handler(thread.looper) + localNodeId = nodeId(this) + validator = application as? BleConnectionValidator + connections = BleGattTransport(this, worker, localNodeId, { request -> validator?.validate(request) == true }, + { pumpTx() }, { message -> radioFailed(message) }) + val filter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) + if (Build.VERSION.SDK_INT >= 33) registerReceiver(bluetoothReceiver, filter, RECEIVER_EXPORTED) + else registerReceiver(bluetoothReceiver, filter) + getSystemService(NotificationManager::class.java).createNotificationChannel( + NotificationChannel(CHANNEL, "BLE транспорт", NotificationManager.IMPORTANCE_LOW) + ) + Log.i(TAG, "Service created, BLE worker ready") + } + + override fun onBind(intent: Intent): IBinder = binder + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_STOP || (intent == null && !preferences.getBoolean("enabled", false))) { + preferences.edit().putBoolean("enabled", false).apply() + worker.post { stopTransport() } + foreground = false + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf(startId) + return START_NOT_STICKY + } + try { + val notification = notification("Запуск BLE") + if (Build.VERSION.SDK_INT >= 29) startForeground(NOTIFICATION, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE) + else startForeground(NOTIFICATION, notification) + foreground = true + val payload = if (intent == null) preferences.getString("payload", null)?.let { Base64.decode(it, Base64.NO_WRAP) } + else intent.getByteArrayExtra("payload") + val period = intent?.getLongExtra("period", 1000) ?: preferences.getLong("period", 1000) + val target = intent?.getLongExtra("targetNodeId", 0) ?: preferences.getLong("targetNodeId", 0) + val automatic = intent?.getBooleanExtra("autoConnect", false) ?: preferences.getBoolean("autoConnect", false) + val identity = intent?.getLongExtra("localNodeId", nodeId(this)) ?: nodeId(this) + require(period in MIN_PERIOD_MS..MAX_PERIOD_MS && (payload == null || payload.size <= BleProtocol.MAX_PAYLOAD)) + require(identity != 0L) + check(hasPermissions(this)) { "Нет разрешений Bluetooth/геопозиции" } + worker.post { + try { + check(!requested || identity == localNodeId) { "Stop transport before changing node ID" } + if (identity != localNodeId) { + connections.stop() + localNodeId = identity + connections = BleGattTransport(this, worker, localNodeId, { request -> validator?.validate(request) == true }, + { pumpTx() }, { message -> radioFailed(message) }) + } + autoConnect = automatic + autoBlocked.clear() + startTransport(payload, period) + if (requested) { + preferences.edit().putBoolean("enabled", true).putLong("period", period).putLong("nodeId", identity) + .putLong("targetNodeId", target).putBoolean("autoConnect", automatic) + .putString("payload", payload?.let { Base64.encodeToString(it, Base64.NO_WRAP) }).apply() + if (target != 0L) connections.connect(target) + } + } catch (error: RuntimeException) { reportError("Transport configuration failed", error) } + } + return START_STICKY + } catch (error: RuntimeException) { + reportError("Service start failed", error) + preferences.edit().putBoolean("enabled", false).apply() + worker.post { stopTransport() } + foreground = false + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf(startId) + return START_NOT_STICKY + } + } + + @SuppressLint("WakelockTimeout") // Длительность теста задаётся явным stop, acquire/release принадлежат сервису. + private fun startTransport(payload: ByteArray?, period: Long) { + repeatPayload = payload?.copyOf() + repeatPeriod = period + worker.removeCallbacks(periodic) + if (!requested) { + requested = true + val session = generateSequence { SecureRandom().nextInt().toLong() and 0xffffffffL }.first { it != 0L } + queues.start(session, localNodeId) + connections.enable() + retryAt = 0 + try { + wakeLock = getSystemService(PowerManager::class.java).newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "utun:ble-test") + wakeLock?.acquire() // Непрерывный stability test; парный release в stopTransport/onDestroy. + } catch (error: RuntimeException) { + reportError("Wake lock unavailable", error) + stopTransport() + main.post { + preferences.edit().putBoolean("enabled", false).apply() + foreground = false + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + return + } + Log.i(TAG, "Transport started: session=${session.toString(16)} periodMs=$period payloadBytes=${payload?.size}") + startRadio() + worker.post(watchdog) + } else Log.i(TAG, "Periodic source updated: periodMs=$period payloadBytes=${payload?.size}") + if (payload != null) worker.post(periodic) + else if (queues.send(byteArrayOf()) == SendResult.QUEUED) pumpTx() + } + + private val periodic = object : Runnable { + override fun run() { + if (!requested || repeatPayload == null) return + if (scanner != null && active == null && queues.peekTx() == null) { + if (queues.send(repeatPayload!!) == SendResult.QUEUED) pumpTx() + } else queues.skipPeriodic() + worker.postDelayed(this, repeatPeriod) + } + } + + /** Фильтр manufacturer data сохраняет сканирование при выключенном экране. */ + private fun startRadio() { + if (!requested || scanner != null || SystemClock.elapsedRealtime() < retryAt) return + try { + check(hasPermissions(this)) { "Разрешения Bluetooth/геопозиции отозваны" } + val adapter = getSystemService(BluetoothManager::class.java)?.adapter + check(adapter != null) { "Bluetooth адаптер отсутствует" } + if (!adapter.isEnabled) { + queues.setState("Ожидание включения Bluetooth") + return + } + if (Build.VERSION.SDK_INT < 31) { + @Suppress("DEPRECATION") + val locationEnabled = if (Build.VERSION.SDK_INT >= 28) getSystemService(LocationManager::class.java).isLocationEnabled + else Settings.Secure.getInt(contentResolver, Settings.Secure.LOCATION_MODE, 0) != 0 + check(locationEnabled) { "Android 8–11: включите геопозицию для BLE scan" } + } + check(adapter.isMultipleAdvertisementSupported) { "Адаптер не поддерживает BLE advertising" } + advertiser = checkNotNull(adapter.bluetoothLeAdvertiser) { "BLE advertiser недоступен" } + scanner = checkNotNull(adapter.bluetoothLeScanner) { "BLE scanner недоступен" } + val token = ++generation + connections.open() + scanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + worker.post { if (generation == token) receive(result) } + } + override fun onBatchScanResults(results: MutableList) { + worker.post { if (generation == token) results.forEach { receive(it) } } + } + override fun onScanFailed(errorCode: Int) { + worker.post { if (generation == token) radioFailed("Scan failed: ${scanError(errorCode)} ($errorCode)") } + } + } + val filter = ScanFilter.Builder().setManufacturerData(BleProtocol.COMPANY_ID, BleProtocol.filterPrefix).build() + val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).setReportDelay(0).build() + scanner!!.startScan(listOf(filter), settings, scanCallback!!) + queues.setState("BLE: приём запущен") + Log.i(TAG, "Radio started: generation=$token filteredScan=true legacyAdvertising=true") + pumpTx() + } catch (error: RuntimeException) { + radioFailed("Radio start failed", error) + } + } + + private fun receive(result: ScanResult) { + val data = result.scanRecord?.getManufacturerSpecificData(BleProtocol.COMPANY_ID) ?: return + val nodeId = BleProtocol.decodeIdentity(result.scanRecord?.getServiceData(ParcelUuid(BleProtocol.IDENTITY_UUID))) ?: return + queues.receive(data, result.rssi, result.timestampNanos / 1_000_000, nodeId) + val packet = BleProtocol.decode(data) ?: return + connections.observe(nodeId, result.device, String(packet.payload, Charsets.UTF_8)) + if (autoConnect && !autoBlocked.contains(nodeId) && java.lang.Long.compareUnsigned(localNodeId, nodeId) < 0) { + connections.connect(nodeId) + } + } + + /** Один advertising set на весь сеанс: данные обновляются без stop/start и scan throttling. */ + private fun pumpTx() { + if (!requested || scanner == null || !connections.ready || active != null) return + val packet = queues.peekTx() ?: return + val wait = txReadyAt - SystemClock.elapsedRealtime() + if (wait > 0) { + worker.removeCallbacks(pump) + worker.postDelayed(pump, wait) + return + } + active = packet + worker.postDelayed(callbackTimeout, CALLBACK_TIMEOUT_MS) + val data = AdvertiseData.Builder().addManufacturerData(BleProtocol.COMPANY_ID, BleProtocol.encode(packet)) + .setIncludeDeviceName(false).setIncludeTxPowerLevel(false).build() + try { + if (advertisingSet != null) advertisingSet!!.setAdvertisingData(data) + else { + val token = generation + advertisingCallback = object : AdvertisingSetCallback() { + override fun onAdvertisingSetStarted(set: AdvertisingSet?, txPower: Int, status: Int) { + if (generation != token) return + if (status != ADVERTISE_SUCCESS || set == null) { + radioFailed("Advertising start failed: ${advertisingError(status)} ($status)") + return + } + advertisingSet = set + Log.i(TAG, "Advertising set ready: txPower=$txPower") + completeTx() + } + override fun onAdvertisingDataSet(set: AdvertisingSet, status: Int) { + if (generation != token) return + if (status == ADVERTISE_SUCCESS) completeTx() + else radioFailed("Advertising update failed: ${advertisingError(status)} ($status)") + } + override fun onAdvertisingSetStopped(set: AdvertisingSet) { + if (generation == token) radioFailed("Advertising set stopped unexpectedly") + } + override fun onAdvertisingEnabled(set: AdvertisingSet, enabled: Boolean, status: Int) { + if (generation == token && (!enabled || status != ADVERTISE_SUCCESS)) { + radioFailed("Advertising disabled unexpectedly: enabled=$enabled status=$status") + } + } + } + val parameters = AdvertisingSetParameters.Builder().setLegacyMode(true).setConnectable(true).setScannable(true) + .setInterval(160).setTxPowerLevel(AdvertisingSetParameters.TX_POWER_MEDIUM).build() + val identity = AdvertiseData.Builder().addServiceData(ParcelUuid(BleProtocol.IDENTITY_UUID), + BleProtocol.encodeIdentity(localNodeId)).build() + advertiser!!.startAdvertisingSet(parameters, data, identity, null, null, advertisingCallback!!, worker) + } + } catch (error: RuntimeException) { + radioFailed("Advertising operation failed: seq=${packet.sequence}", error) + } + } + + private val pump = Runnable { pumpTx() } + private val callbackTimeout = Runnable { radioFailed("Advertising callback timeout: seq=${active?.sequence}") } + + private fun completeTx() { + val packet = active ?: run { + radioFailed("Advertising callback without active packet") + return + } + worker.removeCallbacks(callbackTimeout) + queues.sent(packet.sequence) + active = null + txReadyAt = SystemClock.elapsedRealtime() + MIN_PERIOD_MS + queues.setState("BLE: приём и передача") + if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "TX published: seq=${packet.sequence} bytes=${packet.payload.size}") + pumpTx() + } + + private val watchdog = object : Runnable { + override fun run() { + if (!requested) return + if (!hasPermissions(this@BleBroadcastService)) { + radioFailed("Разрешения Bluetooth/геопозиции отозваны") + } else startRadio() + val s = queues.snapshot() + Log.i(TAG, "Stats: state=${s.state} session=${s.session.toString(16)} tx=${s.sent} rx=${s.received} dup=${s.duplicates}" + + " stale=${s.stale} txQ=${s.txQueued} rxQ=${s.rxQueued} rxDrop=${s.rxDropped} rejected=${s.txRejected}" + + " discarded=${s.txDiscarded} invalid=${s.invalid} skipped=${s.periodicSkipped} peers=${s.peers.size} errors=${s.errors}") + connections.snapshots().forEach { Log.i(TAG, "GATT stats: $it") } + main.post { + if (foreground) getSystemService(NotificationManager::class.java) + .notify(NOTIFICATION, notification("${s.state} · TX ${s.sent} · RX ${s.received}")) + } + worker.postDelayed(this, 15_000) + } + } + + private fun radioFailed(message: String, error: RuntimeException? = null) { + reportError(message, error) + closeRadio() + retryAt = SystemClock.elapsedRealtime() + RETRY_MS + queues.setState("Ошибка BLE; повтор через 30 с") + if (requested) worker.postDelayed(retry, RETRY_MS) + } + + private val retry = Runnable { startRadio() } + + private fun reportError(message: String, error: RuntimeException? = null) { + val details = if (error == null) message else "$message: ${error.javaClass.simpleName}: ${error.message}" + queues.error(details) + Log.e(TAG, details, error) + } + + /** Сначала инвалидируем callbacks, затем освобождаем радио; TX остаётся для повторной попытки. */ + private fun closeRadio(adapterUnavailable: Boolean = false) { + generation++ + connections.closeRadio() + worker.removeCallbacks(callbackTimeout) + worker.removeCallbacks(pump) + worker.removeCallbacks(retry) + val oldScan = scanCallback + val oldAdvertising = advertisingCallback + if (adapterUnavailable) { + // При выключении адаптера Android сам очищает scan/advertising clients; stopScan уже запрещён. + Log.i(TAG, "Adapter shutting down; releasing local BLE references") + } else { + try { if (oldScan != null) scanner?.stopScan(oldScan) } + catch (error: RuntimeException) { reportError("Stop scan failed", error) } + try { if (oldAdvertising != null) advertiser?.stopAdvertisingSet(oldAdvertising) } + catch (error: RuntimeException) { reportError("Stop advertising failed", error) } + } + scanner = null + advertiser = null + scanCallback = null + advertisingCallback = null + advertisingSet = null + active = null + txReadyAt = 0 + Log.i(TAG, "Radio closed: generation=$generation retainedTx=${queues.snapshot().txQueued}") + } + + private fun stopTransport() { + requested = false + worker.removeCallbacks(periodic) + worker.removeCallbacks(watchdog) + closeRadio() + connections.stop() + queues.stop() + wakeLock?.let { if (it.isHeld) it.release() } + wakeLock = null + val s = queues.snapshot() + Log.i(TAG, "Transport stopped: session=${s.session.toString(16)} tx=${s.sent} rx=${s.received}" + + " txDiscarded=${s.txDiscarded} rxQueued=${s.rxQueued} errors=${s.errors}") + } + + override fun onDestroy() { + foreground = false + unregisterReceiver(bluetoothReceiver) + worker.post { + stopTransport() + thread.quitSafely() + } + Log.i(TAG, "Service destroyed") + super.onDestroy() + } + + private fun notification(text: String): Notification { + val stop = PendingIntent.getService(this, 1, Intent(this, BleBroadcastService::class.java).setAction(ACTION_STOP), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + val builder = Notification.Builder(this, CHANNEL).setContentTitle("uTun BLE") + .setContentText(text).setSmallIcon(android.R.drawable.stat_sys_data_bluetooth).setOngoing(true) + .addAction(Notification.Action.Builder(null, "Остановить", stop).build()) + packageManager.getLaunchIntentForPackage(packageName)?.let { + builder.setContentIntent(PendingIntent.getActivity(this, 0, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) + } + return builder.build() + } + + private fun advertisingError(code: Int): String = when (code) { + 1 -> "DATA_TOO_LARGE" + 2 -> "TOO_MANY_ADVERTISERS" + 3 -> "ALREADY_STARTED" + 4 -> "INTERNAL_ERROR" + 5 -> "FEATURE_UNSUPPORTED" + else -> "UNKNOWN" + } + + private fun scanError(code: Int): String = when (code) { + 1 -> "ALREADY_STARTED" + 2 -> "APPLICATION_REGISTRATION_FAILED" + 3 -> "INTERNAL_ERROR" + 4 -> "FEATURE_UNSUPPORTED" + 5 -> "OUT_OF_HARDWARE_RESOURCES" + 6 -> "SCANNING_TOO_FREQUENTLY" + else -> "UNKNOWN" + } +} diff --git a/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleConnectionQueues.kt b/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleConnectionQueues.kt new file mode 100644 index 000000000..e10c440ac --- /dev/null +++ b/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleConnectionQueues.kt @@ -0,0 +1,169 @@ +package com.utun.ble + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.ArrayDeque + +data class BleConnectionRequest(val localNodeId: Long, val remoteNodeId: Long, val advertisedName: String?) + +/** Выполняется в BLE worker, должна быстро вернуть решение; null callback означает отказ. */ +fun interface BleConnectionValidator { fun validate(request: BleConnectionRequest): Boolean } + +enum class ConnectResult { REQUESTED, STOPPED, INVALID_ID, LIMIT_REACHED } +enum class NodeSendResult { QUEUED, NOT_CONNECTED, QUEUE_FULL, TOO_LARGE } + +data class BleConnectionStats( + val nodeId: Long, val state: String, val mtu: Int, val txQueued: Int, val rxQueued: Int, + val sent: Long, val received: Long, val sentBytes: Long, val receivedBytes: Long, + val waitingAck: Boolean, val rxBlocked: Boolean, val discardedTx: Long, val lastError: String +) + +/** + * Ограниченные очереди одного соединения и ACK-clocked окно в один фрагмент. + * ACK выдаётся только после резервирования места в RX; при полном RX последний ACK удерживается до чтения. + * Это управление потоком и нагрузкой поверх надёжного ATT, без бессмысленных таймерных ретрансляций. + */ +internal class BleConnectionQueues(private val nodeId: Long, private val capacity: Int = 64) { + companion object { + const val MAX_MESSAGE = 4096 + const val HELLO = 1 + const val ACCEPT = 2 + const val DATA = 3 + const val ACK = 4 + const val REJECT = 5 + private fun buffer(size: Int) = ByteBuffer.allocate(size).order(ByteOrder.LITTLE_ENDIAN) + fun hello(type: Int, source: Long, target: Long): ByteArray = buffer(17).put(type.toByte()).putLong(source).putLong(target).array() + fun ack(sequence: Long, offset: Int): ByteArray = buffer(7).put(ACK.toByte()).putInt(sequence.toInt()).putShort(offset.toShort()).array() + } + + private val tx = ArrayDeque() + private val rx = ArrayDeque() + private var state = "DISCONNECTED" + private var mtu = 23 + private var error = "" + private var txSequence = 1L + private var txOffset = 0 + private var waitingOffset: Int? = null + private var rxSequence = 1L + private var assembly: ByteArray? = null + private var rxOffset = 0 + private var blocked = false + private var sent = 0L + private var received = 0L + private var sentBytes = 0L + private var receivedBytes = 0L + private var discardedTx = 0L + + init { require(capacity > 0) } + + @Synchronized fun phase(value: String) { state = value } + @Synchronized fun setMtu(value: Int) { mtu = value.coerceIn(23, 517) } + + @Synchronized fun close(reason: String) { + state = "DISCONNECTED" + error = reason + discardedTx += tx.size + tx.clear() + assembly = null + waitingOffset = null + txSequence = 1 + rxSequence = 1 + txOffset = 0 + rxOffset = 0 + blocked = false + mtu = 23 + } + + @Synchronized fun send(payload: ByteArray): NodeSendResult { + if (state != "READY") return NodeSendResult.NOT_CONNECTED + if (payload.size > MAX_MESSAGE) return NodeSendResult.TOO_LARGE + if (tx.size >= capacity) return NodeSendResult.QUEUE_FULL + tx.addLast(payload.copyOf()) + return NodeSendResult.QUEUED + } + + /** Вызывается только при свободном ATT writer; резервирует единственный in-flight фрагмент. */ + @Synchronized fun nextFrame(): ByteArray? { + if (state != "READY" || waitingOffset != null) return null + val message = tx.peekFirst() ?: return null + check(txSequence <= 0xffffffffL) { "TX sequence exhausted; reconnect required" } + val length = minOf(message.size - txOffset, minOf(mtu - 3, 512) - 9) + val frame = buffer(9 + length).put(DATA.toByte()).putInt(txSequence.toInt()) + .putShort(txOffset.toShort()).putShort(message.size.toShort()).put(message, txOffset, length).array() + waitingOffset = txOffset + length + return frame + } + + @Synchronized fun acceptAck(frame: ByteArray) { + require(frame.size == 7 && frame[0].toInt() == ACK) { "Malformed ACK" } + val b = ByteBuffer.wrap(frame).order(ByteOrder.LITTLE_ENDIAN) + b.get() + val seq = b.int.toLong() and 0xffffffffL + val offset = b.short.toInt() and 0xffff + require(waitingOffset != null && seq == txSequence && offset == waitingOffset) { + "Unexpected ACK seq=$seq offset=$offset expected=$txSequence/$waitingOffset" + } + txOffset = offset + waitingOffset = null + if (offset == checkNotNull(tx.peekFirst()).size) { + sentBytes += tx.removeFirst().size + sent++ + txSequence++ + txOffset = 0 + } + } + + /** Возвращает ACK или null, если заполненный RX остановил отправителя; malformed вызывает отказ соединения. */ + @Synchronized fun acceptData(frame: ByteArray): ByteArray? { + require(state == "READY" && frame.size in 9..minOf(mtu - 3, 512) && frame[0].toInt() == DATA) { "Invalid DATA frame" } + val b = ByteBuffer.wrap(frame).order(ByteOrder.LITTLE_ENDIAN) + b.get() + val seq = b.int.toLong() and 0xffffffffL + val offset = b.short.toInt() and 0xffff + val total = b.short.toInt() and 0xffff + require(!blocked && seq == rxSequence && offset == rxOffset && total <= MAX_MESSAGE) { + "DATA order/size error seq=$seq offset=$offset total=$total expected=$rxSequence/$rxOffset blocked=$blocked" + } + if (assembly == null) assembly = ByteArray(total) + require(assembly!!.size == total && b.remaining() <= total - offset && (b.hasRemaining() || total == 0)) { + "Invalid DATA fragment length=${b.remaining()} offset=$offset total=$total" + } + val length = b.remaining() + b.get(assembly!!, rxOffset, length) + rxOffset += length + if (rxOffset == total) { + if (rx.size == capacity) { + blocked = true + return null + } + return finishReceive() + } + return ack(seq, rxOffset) + } + + private fun finishReceive(): ByteArray { + val result = ack(rxSequence, rxOffset) + val message = checkNotNull(assembly) + rx.addLast(message) + received++ + receivedBytes += message.size + assembly = null + rxOffset = 0 + rxSequence++ + blocked = false + return result + } + + /** Worker вызывает после чтения consumer: освобождённое место возвращает отправителю кредит. */ + @Synchronized fun releaseCredit(): ByteArray? = if (blocked && rx.size < capacity) finishReceive() else null + + @Synchronized fun pollReceived(): ByteArray? = rx.pollFirst() + + @Synchronized fun drainReceived(limit: Int): List { + require(limit > 0) + return List(minOf(limit, rx.size)) { rx.removeFirst() } + } + + @Synchronized fun snapshot() = BleConnectionStats(nodeId, state, mtu, tx.size, rx.size, sent, received, + sentBytes, receivedBytes, waitingOffset != null, blocked, discardedTx, error) +} diff --git a/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleGattTransport.kt b/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleGattTransport.kt new file mode 100644 index 000000000..3b5bd2832 --- /dev/null +++ b/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleGattTransport.kt @@ -0,0 +1,500 @@ +package com.utun.ble + +import android.annotation.SuppressLint +import android.bluetooth.* +import android.content.Context +import android.os.Build +import android.os.Handler +import android.os.SystemClock +import android.util.Log +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.ArrayDeque +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * GATT central/peripheral одного сервиса. Все GATT операции принадлежат worker, API очередей потокобезопасен. + * HELLO(source,target) → callback политики → ACCEPT; до ACCEPT данные запрещены. Bonding не требуется. + * У каждой стороны один ATT writer, у серверных indications — общий writer; ACK имеет приоритет над DATA. + */ +@SuppressLint("MissingPermission") +internal class BleGattTransport( + private val context: Context, + private val worker: Handler, + private val localId: Long, + private val validate: (BleConnectionRequest) -> Boolean, + private val radioReady: () -> Unit, + private val radioError: (String) -> Unit +) { + companion object { + private val SERVICE = UUID.fromString("f61a9010-4abc-45ac-98e1-761df4c13811") + private val PIPE = UUID.fromString("f61a9011-4abc-45ac-98e1-761df4c13811") + private val CCC = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + private const val TAG = BleBroadcastService.TAG + private const val MAX_LINKS = 8 + } + + private class Link(val device: BluetoothDevice, var nodeId: Long, val outgoing: Boolean) { + var gatt: BluetoothGatt? = null + var pipe: BluetoothGattCharacteristic? = null + var queues: BleConnectionQueues? = null + var subscribed = false + var ready = false + var closed = false + var operation = false + var operationType = 0 + var deadline = SystemClock.elapsedRealtime() + 30_000 + var operationDeadline = 0L + val control = ArrayDeque() + } + private data class Discovery(val device: BluetoothDevice, val name: String, val seen: Long) + + @Volatile private var enabled = false + @Volatile var ready = false + private set + private val wanted = ConcurrentHashMap.newKeySet() + private val states = ConcurrentHashMap() + private val discoveries = LinkedHashMap() + private val retryAt = HashMap() + private val byNode = HashMap() + private val incoming = HashMap() + private var server: BluetoothGattServer? = null + private var serverPipe: BluetoothGattCharacteristic? = null + private var serverWriter: Link? = null + private var generation = 0L + + fun enable() { enabled = true } + + @Synchronized fun connect(nodeId: Long): ConnectResult { + if (!enabled) return ConnectResult.STOPPED + if (nodeId == 0L || nodeId == localId) return ConnectResult.INVALID_ID + if ((!wanted.contains(nodeId) && wanted.size >= MAX_LINKS) || (!states.containsKey(nodeId) && states.size >= 32)) { + Log.w(TAG, "GATT connect rejected: peer limit node=${BleProtocol.nodeIdText(nodeId)}") + return ConnectResult.LIMIT_REACHED + } + if (!wanted.add(nodeId)) return ConnectResult.REQUESTED + states.computeIfAbsent(nodeId) { BleConnectionQueues(it).also { q -> q.phase("WAIT_DISCOVERY") } } + worker.post { maintain() } + return ConnectResult.REQUESTED + } + + fun disconnect(nodeId: Long) { + wanted.remove(nodeId) + worker.post { + byNode[nodeId]?.let { closeLink(it, "Disconnected by API") } + states[nodeId]?.close("Disconnected by API") + } + } + + fun send(nodeId: Long, payload: ByteArray): NodeSendResult { + val result = states[nodeId]?.send(payload) ?: NodeSendResult.NOT_CONNECTED + if (result == NodeSendResult.QUEUED) worker.post { pumpAll() } + else Log.w(TAG, "GATT send rejected: node=${BleProtocol.nodeIdText(nodeId)} bytes=${payload.size} reason=$result") + return result + } + + fun pollReceived(nodeId: Long): ByteArray? { + val result = states[nodeId]?.pollReceived() + if (result != null) worker.post { returnCredit(nodeId) } + return result + } + + fun drainReceived(nodeId: Long, limit: Int): List { + require(limit > 0) + val result = states[nodeId]?.drainReceived(limit).orEmpty() + if (result.isNotEmpty()) worker.post { returnCredit(nodeId) } + return result + } + + fun snapshots(): List = states.values.map { it.snapshot() }.sortedBy { BleProtocol.nodeIdText(it.nodeId) } + + fun observe(nodeId: Long, device: BluetoothDevice, name: String) { + if (nodeId == localId) return + if (!discoveries.containsKey(nodeId) && discoveries.size >= 256) discoveries.remove(discoveries.keys.first()) + discoveries.remove(nodeId) + discoveries[nodeId] = Discovery(device, name, SystemClock.elapsedRealtime()) + if (wanted.contains(nodeId) && !byNode.containsKey(nodeId)) maintain() + } + + fun open() { + check(server == null) + val token = ++generation + val callback = object : BluetoothGattServerCallback() { + private fun dispatch(block: () -> Unit) { + worker.post { + if (generation != token || server == null) return@post + try { block() } catch (e: RuntimeException) { + Log.e(TAG, "GATT server callback failed", e) + radioError("GATT server callback failed: ${e.message}") + } + } + } + override fun onServiceAdded(status: Int, service: BluetoothGattService) = dispatch { + if (status != BluetoothGatt.GATT_SUCCESS) { + Log.e(TAG, "GATT addService failed: status=$status") + radioError("GATT addService failed: status=$status") + } else { + ready = true + Log.i(TAG, "GATT server ready: node=${BleProtocol.nodeIdText(localId)}") + radioReady() + } + } + override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) = dispatch { + if (newState == BluetoothProfile.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS) { + val outgoing = byNode.values.firstOrNull { it.outgoing && it.device.address == device.address } + // Android сообщает GATT server также о собственном central ACL. Это не второй запрос подключения. + if (outgoing?.ready == true) return@dispatch + if (outgoing == null && incoming.size + byNode.values.count { it.outgoing } >= MAX_LINKS) { + Log.w(TAG, "GATT incoming rejected: link limit address=${device.address}") + server?.cancelConnection(device) + } else if (!incoming.containsKey(device.address)) { + incoming[device.address] = Link(device, 0, false) + Log.i(TAG, "GATT incoming transport: address=${device.address}, awaiting HELLO") + } + } else incoming[device.address]?.let { closeLink(it, "GATT disconnected status=$status state=$newState") } + } + override fun onMtuChanged(device: BluetoothDevice, mtu: Int) = dispatch { + val link = incoming[device.address] ?: return@dispatch + link.queues?.setMtu(mtu) + // HELLO может прийти позже MTU callback, поэтому значение сохраняется отдельно для входящего link. + incomingMtu[device.address] = mtu + } + override fun onDescriptorWriteRequest(device: BluetoothDevice, requestId: Int, descriptor: BluetoothGattDescriptor, + preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray) = dispatch { + val link = incoming[device.address] + val valid = link != null && descriptor.uuid == CCC && !preparedWrite && offset == 0 && + value.contentEquals(BluetoothGattDescriptor.ENABLE_INDICATION_VALUE) + if (valid) link!!.subscribed = true + if (responseNeeded) respond(device, requestId, if (valid) BluetoothGatt.GATT_SUCCESS else BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED) + if (!valid) Log.w(TAG, "GATT invalid subscription: address=${device.address} offset=$offset prepared=$preparedWrite") + } + override fun onCharacteristicWriteRequest(device: BluetoothDevice, requestId: Int, characteristic: BluetoothGattCharacteristic, + preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray) = dispatch { + val link = incoming[device.address] + if (link == null || characteristic.uuid != PIPE || preparedWrite || offset != 0 || !responseNeeded) { + if (responseNeeded) respond(device, requestId, BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED) + Log.w(TAG, "GATT invalid write: address=${device.address} offset=$offset prepared=$preparedWrite response=$responseNeeded") + if (link != null) closeLink(link, "Invalid ATT write") + } else { + respond(device, requestId, BluetoothGatt.GATT_SUCCESS) + receiveFrame(link, value) + } + } + override fun onCharacteristicReadRequest(device: BluetoothDevice, requestId: Int, offset: Int, + characteristic: BluetoothGattCharacteristic) = dispatch { respond(device, requestId, BluetoothGatt.GATT_READ_NOT_PERMITTED) } + override fun onDescriptorReadRequest(device: BluetoothDevice, requestId: Int, offset: Int, + descriptor: BluetoothGattDescriptor) = dispatch { respond(device, requestId, BluetoothGatt.GATT_READ_NOT_PERMITTED) } + override fun onExecuteWrite(device: BluetoothDevice, requestId: Int, execute: Boolean) = dispatch { + respond(device, requestId, BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED) + } + override fun onNotificationSent(device: BluetoothDevice, status: Int) = dispatch { + val link = serverWriter + if (link == null || link.device.address != device.address) { + Log.w(TAG, "GATT stale indication completion: address=${device.address} status=$status") + } else { + serverWriter = null + completeOperation(link, status) + } + } + } + server = checkNotNull(context.getSystemService(BluetoothManager::class.java).openGattServer(context, callback)) + val service = BluetoothGattService(SERVICE, BluetoothGattService.SERVICE_TYPE_PRIMARY) + serverPipe = BluetoothGattCharacteristic(PIPE, + BluetoothGattCharacteristic.PROPERTY_WRITE or BluetoothGattCharacteristic.PROPERTY_INDICATE, + BluetoothGattCharacteristic.PERMISSION_WRITE).apply { + addDescriptor(BluetoothGattDescriptor(CCC, BluetoothGattDescriptor.PERMISSION_WRITE)) + } + service.addCharacteristic(serverPipe) + check(server!!.addService(service)) { "GATT addService refused" } + worker.post(tick) + } + + private val incomingMtu = HashMap() + + private fun respond(device: BluetoothDevice, requestId: Int, status: Int) { + try { + if (server?.sendResponse(device, requestId, status, 0, null) != true) { + Log.e(TAG, "GATT sendResponse refused: address=${device.address} request=$requestId status=$status") + } + } catch (e: RuntimeException) { Log.e(TAG, "GATT sendResponse failed", e) } + } + + private fun dial(nodeId: Long, discovery: Discovery) { + val link = Link(discovery.device, nodeId, true) + link.queues = states[nodeId] + link.queues?.phase("CONNECTING") + byNode[nodeId] = link + retryAt[nodeId] = SystemClock.elapsedRealtime() + 10_000 + Log.i(TAG, "GATT connecting: node=${BleProtocol.nodeIdText(nodeId)} address=${link.device.address}") + try { + link.gatt = link.device.connectGatt(context, false, clientCallback(link), BluetoothDevice.TRANSPORT_LE, + BluetoothDevice.PHY_LE_1M_MASK, worker) + check(link.gatt != null) { "connectGatt returned null" } + } catch (e: RuntimeException) { closeLink(link, "connectGatt failed: ${e.message}") } + } + + private fun clientCallback(link: Link) = object : BluetoothGattCallback() { + private fun valid(gatt: BluetoothGatt): Boolean = !link.closed && link.gatt === gatt + private fun dispatch(gatt: BluetoothGatt, block: () -> Unit) { + if (!valid(gatt)) return + try { block() } catch (e: RuntimeException) { closeLink(link, "GATT client callback failed: ${e.message}") } + } + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) = dispatch(gatt) { + Log.i(TAG, "GATT client transport: node=${BleProtocol.nodeIdText(link.nodeId)} status=$status state=$newState") + if (status != BluetoothGatt.GATT_SUCCESS || newState != BluetoothProfile.STATE_CONNECTED) { + closeLink(link, "Client disconnected status=$status state=$newState") + } else if (!gatt.requestMtu(247)) closeLink(link, "requestMtu refused") + } + override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) = dispatch(gatt) { + Log.i(TAG, "GATT MTU: node=${BleProtocol.nodeIdText(link.nodeId)} mtu=$mtu status=$status") + if (status == BluetoothGatt.GATT_SUCCESS) link.queues?.setMtu(mtu) + else Log.w(TAG, "GATT MTU fallback: node=${BleProtocol.nodeIdText(link.nodeId)} status=$status") + if (!gatt.discoverServices()) closeLink(link, "discoverServices refused") + } + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) = dispatch(gatt) { + Log.i(TAG, "GATT services discovered: node=${BleProtocol.nodeIdText(link.nodeId)} status=$status") + val pipe = gatt.getService(SERVICE)?.getCharacteristic(PIPE) + val ccc = pipe?.getDescriptor(CCC) + if (status != BluetoothGatt.GATT_SUCCESS || pipe == null || ccc == null) { + closeLink(link, "Service discovery failed status=$status pipe=${pipe != null} ccc=${ccc != null}") + return@dispatch + } + link.pipe = pipe + if (!gatt.setCharacteristicNotification(pipe, true)) { + closeLink(link, "setCharacteristicNotification refused") + return@dispatch + } + @Suppress("DEPRECATION") + val submitted = if (Build.VERSION.SDK_INT >= 33) { + gatt.writeDescriptor(ccc, BluetoothGattDescriptor.ENABLE_INDICATION_VALUE) == BluetoothStatusCodes.SUCCESS + } else { + ccc.value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE + gatt.writeDescriptor(ccc) + } + if (!submitted) closeLink(link, "CCC write refused") + } + override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) = dispatch(gatt) { + if (status != BluetoothGatt.GATT_SUCCESS || descriptor.uuid != CCC) { + closeLink(link, "CCC write failed status=$status") + } else { + link.subscribed = true + Log.i(TAG, "GATT subscribed; sending HELLO: node=${BleProtocol.nodeIdText(link.nodeId)}") + link.queues?.phase("VALIDATING") + queueControl(link, BleConnectionQueues.hello(BleConnectionQueues.HELLO, localId, link.nodeId)) + } + } + @Suppress("OVERRIDE_DEPRECATION") + override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { + if (valid(gatt)) completeOperation(link, status) + } + override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) { + if (valid(gatt) && characteristic.uuid == PIPE) receiveFrame(link, value) + } + @Deprecated("API < 33 callback") + override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { + @Suppress("DEPRECATION") + if (Build.VERSION.SDK_INT < 33 && valid(gatt) && characteristic.uuid == PIPE) receiveFrame(link, characteristic.value.copyOf()) + } + } + + private fun receiveFrame(link: Link, frame: ByteArray) { + if (link.closed) return + try { + require(frame.isNotEmpty()) { "Empty GATT frame" } + when (frame[0].toInt()) { + BleConnectionQueues.HELLO -> acceptHello(link, frame) + BleConnectionQueues.ACCEPT -> { + require(link.outgoing && !link.ready && identityMatches(frame, link.nodeId, localId)) { "Invalid ACCEPT identity/state" } + markReady(link) + } + BleConnectionQueues.REJECT -> closeLink(link, "Remote validation rejected") + BleConnectionQueues.DATA -> { + require(link.ready) { "DATA before validation" } + val ack = link.queues!!.acceptData(frame) + if (ack != null) queueControl(link, ack) + else Log.i(TAG, "GATT RX backpressure: node=${BleProtocol.nodeIdText(link.nodeId)}") + } + BleConnectionQueues.ACK -> { + require(link.ready) { "ACK before validation" } + link.queues!!.acceptAck(frame) + } + else -> error("Unknown GATT frame type=${frame[0]}") + } + pumpAll() + } catch (e: RuntimeException) { closeLink(link, "Protocol error: ${e.message}") } + } + + private fun identityMatches(frame: ByteArray, source: Long, target: Long): Boolean { + if (frame.size != 17) return false + val b = ByteBuffer.wrap(frame, 1, 16).order(ByteOrder.LITTLE_ENDIAN) + return b.long == source && b.long == target + } + + private fun acceptHello(link: Link, frame: ByteArray) { + require(!link.outgoing && !link.ready && link.subscribed && frame.size == 17) { "HELLO state/size invalid" } + val remoteId = ByteBuffer.wrap(frame, 1, 8).order(ByteOrder.LITTLE_ENDIAN).long + require(remoteId != 0L && remoteId != localId && identityMatches(frame, remoteId, localId)) { "HELLO node ID mismatch" } + val discovery = discoveries[remoteId] + val request = BleConnectionRequest(localId, remoteId, discovery?.name) + val allowed = try { validate(request) } catch (e: RuntimeException) { + Log.e(TAG, "GATT validation callback failed: remote=${BleProtocol.nodeIdText(remoteId)}", e) + false + } + Log.i(TAG, "GATT validation: local=${BleProtocol.nodeIdText(localId)} remote=${BleProtocol.nodeIdText(remoteId)} accepted=$allowed") + if (!allowed || (!states.containsKey(remoteId) && states.size >= 32)) { + queueControl(link, byteArrayOf(BleConnectionQueues.REJECT.toByte())) + return + } + val existing = byNode[remoteId] + if (existing != null && existing !== link) { + // Одновременный connect с двух сторон: сохраняем направление от меньшего ID к большему. + if (existing.ready || !existing.outgoing || java.lang.Long.compareUnsigned(localId, remoteId) < 0) { + queueControl(link, byteArrayOf(BleConnectionQueues.REJECT.toByte())) + return + } + closeLink(existing, "Simultaneous connect: keep lower-ID initiator") + } + link.nodeId = remoteId + link.queues = states.computeIfAbsent(remoteId) { BleConnectionQueues(it) } + link.queues!!.setMtu(incomingMtu[link.device.address] ?: 23) + byNode[remoteId] = link + markReady(link) + queueControl(link, BleConnectionQueues.hello(BleConnectionQueues.ACCEPT, localId, remoteId)) + } + + private fun markReady(link: Link) { + if (link.outgoing) { + incoming[link.device.address]?.takeIf { it.nodeId == 0L }?.let { observer -> + observer.closed = true + incoming.remove(link.device.address) + incomingMtu.remove(link.device.address) + Log.i(TAG, "GATT central ACL observer released: address=${link.device.address}") + } + } + link.ready = true + link.queues!!.phase("READY") + Log.i(TAG, "GATT READY: node=${BleProtocol.nodeIdText(link.nodeId)} outgoing=${link.outgoing} mtu=${link.queues!!.snapshot().mtu}") + } + + private fun queueControl(link: Link, frame: ByteArray) { + check(link.control.size < 8) { "Control queue overflow" } + link.control.addLast(frame) + pumpAll() + } + + private fun returnCredit(nodeId: Long) { + val link = byNode[nodeId] ?: return + link.queues?.releaseCredit()?.let { + Log.i(TAG, "GATT RX credit resumed: node=${BleProtocol.nodeIdText(nodeId)}") + queueControl(link, it) + } + } + + private fun pumpAll() { + (byNode.values + incoming.values).distinct().forEach { pump(it) } + } + + private fun pump(link: Link) { + if (link.closed || link.operation || !link.subscribed || (!link.outgoing && serverWriter != null)) return + try { + val frame = link.control.pollFirst() ?: link.queues?.nextFrame() ?: return + link.operation = true + link.operationType = frame[0].toInt() + link.operationDeadline = SystemClock.elapsedRealtime() + 20_000 + @Suppress("DEPRECATION") + val submitted = if (link.outgoing) { + val gatt = checkNotNull(link.gatt) + val pipe = checkNotNull(link.pipe) + if (Build.VERSION.SDK_INT >= 33) { + gatt.writeCharacteristic(pipe, frame, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) == BluetoothStatusCodes.SUCCESS + } else { + pipe.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + pipe.value = frame + gatt.writeCharacteristic(pipe) + } + } else { + serverWriter = link + val pipe = checkNotNull(serverPipe) + if (Build.VERSION.SDK_INT >= 33) { + server!!.notifyCharacteristicChanged(link.device, pipe, true, frame) == BluetoothStatusCodes.SUCCESS + } else { + pipe.value = frame + server!!.notifyCharacteristicChanged(link.device, pipe, true) + } + } + check(submitted) { "GATT writer refused type=${frame[0]} bytes=${frame.size}" } + } catch (e: RuntimeException) { closeLink(link, "GATT send failed: ${e.message}") } + } + + private fun completeOperation(link: Link, status: Int) { + if (link.closed) return + if (!link.operation || status != BluetoothGatt.GATT_SUCCESS) { + closeLink(link, "ATT completion failed: active=${link.operation} status=$status") + return + } + val reject = link.operationType == BleConnectionQueues.REJECT + link.operation = false + if (reject) closeLink(link, "Validation rejected") + pumpAll() + } + + private val tick = object : Runnable { + override fun run() { + if (server == null) return + maintain() + worker.postDelayed(this, 1000) + } + } + + private fun maintain() { + if (!ready) return + val now = SystemClock.elapsedRealtime() + (byNode.values + incoming.values).distinct().toList().forEach { + if ((!it.ready && now >= it.deadline) || (it.operation && now >= it.operationDeadline)) closeLink(it, "GATT operation/handshake timeout") + } + wanted.forEach { nodeId -> + val discovery = discoveries[nodeId] + if (!byNode.containsKey(nodeId) && now >= (retryAt[nodeId] ?: 0) && discovery != null && now - discovery.seen < 30_000 && + incoming.size + byNode.values.count { it.outgoing } < MAX_LINKS) dial(nodeId, discovery) + } + pumpAll() + } + + private fun closeLink(link: Link, reason: String) { + if (link.closed) return + link.closed = true + if (serverWriter === link) serverWriter = null + if (byNode[link.nodeId] === link) byNode.remove(link.nodeId) + if (incoming[link.device.address] === link) incoming.remove(link.device.address) + incomingMtu.remove(link.device.address) + link.queues?.close(reason) + Log.w(TAG, "GATT closed: node=${BleProtocol.nodeIdText(link.nodeId)} outgoing=${link.outgoing} reason=$reason") + try { + if (link.outgoing) link.gatt?.disconnect() else server?.cancelConnection(link.device) + } catch (e: RuntimeException) { Log.e(TAG, "GATT disconnect failed", e) } + try { link.gatt?.close() } catch (e: RuntimeException) { Log.e(TAG, "GATT close failed", e) } + retryAt[link.nodeId] = SystemClock.elapsedRealtime() + 10_000 + } + + fun closeRadio() { + ready = false + generation++ + worker.removeCallbacks(tick) + (byNode.values + incoming.values).distinct().toList().forEach { closeLink(it, "Radio closed") } + try { server?.close() } catch (e: RuntimeException) { Log.e(TAG, "GATT server close failed", e) } + server = null + serverPipe = null + incomingMtu.clear() + discoveries.clear() + serverWriter = null + } + + fun stop() { + enabled = false + wanted.clear() + closeRadio() + states.values.forEach { it.close("Transport stopped") } + retryAt.clear() + } +} diff --git a/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleProtocol.kt b/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleProtocol.kt new file mode 100644 index 000000000..58e1cbd3a --- /dev/null +++ b/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleProtocol.kt @@ -0,0 +1,48 @@ +package com.utun.ble + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.UUID + +/** Legacy BLE datagram: 24 байта manufacturer data; 13 байт доступны приложению. */ +object BleProtocol { + const val COMPANY_ID = 0xffff // Только экспериментальное использование, не production Company ID. + const val MAX_PAYLOAD = 13 + const val VERSION = 2 + val IDENTITY_UUID: UUID = UUID.fromString("f61a9001-4abc-45ac-98e1-761df4c13811") + val filterPrefix: ByteArray get() = byteArrayOf(0x75, 0x54, VERSION.toByte()) + + data class Packet(val session: Long, val sequence: Long, val payload: ByteArray) + + fun nodeIdText(nodeId: Long): String = java.lang.Long.toUnsignedString(nodeId, 16).padStart(16, '0') + + /** Постоянный ID в scan response; имя остаётся в manufacturer data основного объявления. */ + fun encodeIdentity(nodeId: Long): ByteArray { + require(nodeId != 0L) + return ByteBuffer.allocate(9).order(ByteOrder.LITTLE_ENDIAN).put(VERSION.toByte()).putLong(nodeId).array() + } + + fun decodeIdentity(data: ByteArray?): Long? { + if (data == null || data.size != 9 || data[0] != VERSION.toByte()) return null + return ByteBuffer.wrap(data, 1, 8).order(ByteOrder.LITTLE_ENDIAN).long.takeIf { it != 0L } + } + + /** Все целые wire-поля — unsigned 32-bit, little endian; sequence начинается с 1. */ + fun encode(packet: Packet): ByteArray { + require(packet.session in 1..0xffffffffL && packet.sequence in 1..0xffffffffL) + require(packet.payload.size <= MAX_PAYLOAD) + return ByteBuffer.allocate(11 + packet.payload.size).order(ByteOrder.LITTLE_ENDIAN) + .put(filterPrefix).putInt(packet.session.toInt()).putInt(packet.sequence.toInt()).put(packet.payload).array() + } + + /** null означает чужой или повреждённый пакет; вызывающий код учитывает и логирует отказ. */ + fun decode(data: ByteArray): Packet? { + if (data.size !in 11..24 || !data.copyOfRange(0, 3).contentEquals(filterPrefix)) return null + val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) + buffer.position(3) + val session = buffer.int.toLong() and 0xffffffffL + val sequence = buffer.int.toLong() and 0xffffffffL + if (session == 0L || sequence == 0L) return null + return Packet(session, sequence, data.copyOfRange(11, data.size)) + } +} diff --git a/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleQueues.kt b/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleQueues.kt new file mode 100644 index 000000000..e496b3dd8 --- /dev/null +++ b/tools/ble-test-android/ble-transport/src/main/java/com/utun/ble/BleQueues.kt @@ -0,0 +1,187 @@ +package com.utun.ble + +import java.util.ArrayDeque +import java.util.BitSet + +enum class SendResult { QUEUED, STOPPED, QUEUE_FULL, PAYLOAD_TOO_LARGE, SEQUENCE_EXHAUSTED } + +/** Копии payload принадлежат получателю; elapsedMs — монотонное время Android, не UTC. */ +data class BleReceived(val session: Long, val sequence: Long, val payload: ByteArray, val rssi: Int, val elapsedMs: Long, val nodeId: Long) + +data class BlePeerStats( + val session: Long, val payload: ByteArray, val received: Long, val duplicates: Long, + val stale: Long, val lastSequence: Long, val rssi: Int, val lastSeenMs: Long, val nodeId: Long +) + +data class BleSnapshot( + val running: Boolean, val state: String, val session: Long, val sent: Long, val received: Long, + val duplicates: Long, val stale: Long, val txQueued: Int, val rxQueued: Int, val txRejected: Long, + val txDiscarded: Long, val rxDropped: Long, val invalid: Long, val periodicSkipped: Long, + val peerEvictions: Long, val errors: Long, val lastError: String, val peers: List, val nodeId: Long +) + +/** + * Потокобезопасные ограниченные очереди и статистика. TX отклоняет запись при заполнении (backpressure), + * RX удаляет старейшее сообщение, сохраняя счётчики. Статистика не зависит от чтения RX или жизни Activity. + * warn получает редкие аномалии; все изменения и копирование снимков защищены одним монитором. + */ +internal class BleQueues( + private val txCapacity: Int = 64, + private val rxCapacity: Int = 256, + private val peerCapacity: Int = 256, + private val info: (String) -> Unit = {}, + private val warn: (String) -> Unit +) { + private data class Identity(val nodeId: Long, val session: Long) + private class Peer(val session: Long, val nodeId: Long) { + var payload = byteArrayOf() + var received = 0L + var duplicates = 0L + var stale = 0L + var highest = 0L + var rssi = 0 + var lastSeen = 0L + val seen = BitSet(256) + } + + private val tx = ArrayDeque() + private val rx = ArrayDeque() + private val peers = LinkedHashMap(16, 0.75f, true) + private var running = false + private var state = "Остановлен" + private var session = 0L + private var localNodeId = 0L + private var sequence = 0L + private var sent = 0L + private var received = 0L + private var duplicates = 0L + private var stale = 0L + private var txRejected = 0L + private var txDiscarded = 0L + private var rxDropped = 0L + private var invalid = 0L + private var periodicSkipped = 0L + private var peerEvictions = 0L + private var errors = 0L + private var lastError = "" + + init { require(txCapacity > 0 && rxCapacity > 0 && peerCapacity > 0) } + + @Synchronized fun start(newSession: Long, nodeId: Long = newSession) { + require(newSession in 1..0xffffffffL) + require(nodeId != 0L) + localNodeId = nodeId + session = newSession + sequence = 0 + running = true + state = "Запуск BLE" + } + + @Synchronized fun stop() { + running = false + txDiscarded += tx.size + if (tx.isNotEmpty()) warn("TX shutdown: discarded=${tx.size}") + tx.clear() + state = "Остановлен" + } + + @Synchronized fun setState(value: String) { state = value } + @Synchronized fun error(message: String) { errors++; lastError = message } + @Synchronized fun skipPeriodic() { periodicSkipped++ } + + @Synchronized fun send(payload: ByteArray): SendResult { + val failure = when { + !running -> SendResult.STOPPED + payload.size > BleProtocol.MAX_PAYLOAD -> SendResult.PAYLOAD_TOO_LARGE + tx.size >= txCapacity -> SendResult.QUEUE_FULL + sequence == 0xffffffffL -> SendResult.SEQUENCE_EXHAUSTED + else -> null + } + if (failure != null) { + txRejected++ + warn("TX rejected: reason=$failure bytes=${payload.size} queued=${tx.size}") + return failure + } + tx.addLast(BleProtocol.Packet(session, ++sequence, payload.copyOf())) + return SendResult.QUEUED + } + + @Synchronized fun peekTx(): BleProtocol.Packet? = tx.peekFirst() + + @Synchronized fun sent(sequence: Long) { + check(tx.peekFirst()?.sequence == sequence) + tx.removeFirst() + sent++ + } + + /** Сохраняет сообщения с перестановкой до 255 номеров; более старые считаются stale. */ + @Synchronized fun receive(data: ByteArray, rssi: Int, elapsedMs: Long, nodeId: Long) { + val packet = BleProtocol.decode(data) + if (packet == null) { + invalid++ + if (invalid == 1L || invalid % 100 == 0L) { + warn("RX invalid: bytes=${data.size} count=$invalid hex=${data.take(24).joinToString("") { "%02x".format(it) }}") + } + return + } + if (nodeId == 0L || nodeId == localNodeId) return + val identity = Identity(nodeId, packet.session) + val peer = peers[identity] ?: run { + if (peers.size == peerCapacity) { + val iterator = peers.entries.iterator() + val evicted = iterator.next().key + iterator.remove() + peerEvictions++ + warn("RX peer limit: evicted=$evicted count=$peerEvictions") + } + info("RX peer discovered: node=${BleProtocol.nodeIdText(nodeId)} session=${packet.session.toString(16)} seq=${packet.sequence}" + + " payloadHex=${packet.payload.joinToString("") { "%02x".format(it) }} rssi=$rssi") + Peer(packet.session, nodeId).also { peers[identity] = it } + } + peer.rssi = rssi + peer.lastSeen = maxOf(peer.lastSeen, elapsedMs) + if (packet.sequence <= peer.highest - 256) { + peer.stale++ + stale++ + return + } + if (packet.sequence > peer.highest) { + val advance = packet.sequence - peer.highest + if (advance >= 256) peer.seen.clear() + else for (number in peer.highest + 1..packet.sequence) peer.seen.clear((number % 256).toInt()) + peer.highest = packet.sequence + peer.payload = packet.payload.copyOf() + } + val slot = (packet.sequence % 256).toInt() + if (peer.seen[slot]) { + peer.duplicates++ + duplicates++ + return + } + peer.seen.set(slot) + peer.received++ + received++ + if (rx.size == rxCapacity) { + rx.removeFirst() + rxDropped++ + if (rxDropped == 1L || rxDropped % 100 == 0L) warn("RX overflow: dropped=$rxDropped capacity=$rxCapacity") + } + rx.addLast(BleReceived(packet.session, packet.sequence, packet.payload, rssi, elapsedMs, nodeId)) + } + + /** Забирает одно сообщение или null; один логический consumer владеет очередью RX. */ + @Synchronized fun pollReceived(): BleReceived? = rx.pollFirst() + + /** Забирает до limit сообщений; limit должен быть положительным. */ + @Synchronized fun drainReceived(limit: Int): List { + require(limit > 0) + return List(minOf(limit, rx.size)) { rx.removeFirst() } + } + + @Synchronized fun snapshot() = BleSnapshot( + running, state, session, sent, received, duplicates, stale, tx.size, rx.size, txRejected, txDiscarded, + rxDropped, invalid, periodicSkipped, peerEvictions, errors, lastError, + peers.values.map { BlePeerStats(it.session, it.payload.copyOf(), it.received, it.duplicates, + it.stale, it.highest, it.rssi, it.lastSeen, it.nodeId) }, localNodeId + ) +} diff --git a/tools/ble-test-android/ble-transport/src/test/java/com/utun/ble/BleConnectionQueuesTest.kt b/tools/ble-test-android/ble-transport/src/test/java/com/utun/ble/BleConnectionQueuesTest.kt new file mode 100644 index 000000000..ae43604e7 --- /dev/null +++ b/tools/ble-test-android/ble-transport/src/test/java/com/utun/ble/BleConnectionQueuesTest.kt @@ -0,0 +1,133 @@ +package com.utun.ble + +import org.junit.Assert.* +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class BleConnectionQueuesTest { + private fun ready(capacity: Int = 64, mtu: Int = 23) = BleConnectionQueues(2, capacity).apply { + phase("READY") + setMtu(mtu) + } + + @Test fun identityPreservesEveryUnsigned64Bit() { + for (id in listOf(1L, Long.MAX_VALUE, Long.MIN_VALUE, -1L)) { + assertEquals(id, BleProtocol.decodeIdentity(BleProtocol.encodeIdentity(id))) + assertEquals(16, BleProtocol.nodeIdText(id).length) + val hello = BleConnectionQueues.hello(BleConnectionQueues.HELLO, id, -id) + assertEquals(17, hello.size) + val b = ByteBuffer.wrap(hello, 1, 16).order(ByteOrder.LITTLE_ENDIAN) + assertEquals(id, b.long) + assertEquals(-id, b.long) + } + assertNull(BleProtocol.decodeIdentity(null)) + assertNull(BleProtocol.decodeIdentity(byteArrayOf())) + assertNull(BleProtocol.decodeIdentity(BleProtocol.encodeIdentity(1).also { it[0] = 99 })) + assertNull(BleProtocol.decodeIdentity(ByteArray(9).also { it[0] = BleProtocol.VERSION.toByte() })) + } + + @Test fun dataWaitsForValidationAndQueueCapacityProvidesBackpressure() { + val q = BleConnectionQueues(2, 1) + assertEquals(NodeSendResult.NOT_CONNECTED, q.send(byteArrayOf(1))) + assertNull(q.nextFrame()) + q.phase("READY") + assertEquals(NodeSendResult.TOO_LARGE, q.send(ByteArray(4097))) + val data = byteArrayOf(1) + assertEquals(NodeSendResult.QUEUED, q.send(data)) + data[0] = 7 + assertEquals(NodeSendResult.QUEUE_FULL, q.send(data)) + val frame = q.nextFrame()!! + assertEquals(1.toByte(), frame.last()) + assertNull(q.nextFrame()) + assertEquals(0L, q.snapshot().sent) + q.acceptAck(BleConnectionQueues.ack(1, 1)) + assertEquals(1L, q.snapshot().sent) + assertEquals(0, q.snapshot().txQueued) + } + + @Test fun fragmentationReassemblesExactMessagesAtEveryMtuAndSize() { + for (mtu in listOf(23, 64, 247, 517)) for (size in listOf(0, 1, 11, 12, 240, 512, 4096)) { + val sender = ready(mtu = mtu) + val receiver = ready(mtu = mtu) + val payload = ByteArray(size) { (it * 37).toByte() } + sender.send(payload) + var frames = 0 + while (sender.snapshot().txQueued > 0) { + val frame = sender.nextFrame()!! + assertTrue(frame.size <= minOf(mtu - 3, 512)) + assertNull(sender.nextFrame()) + sender.acceptAck(receiver.acceptData(frame)!!) + assertTrue(++frames <= 400) + } + assertArrayEquals(payload, receiver.pollReceived()) + assertEquals(size.toLong(), sender.snapshot().sentBytes) + assertEquals(size.toLong(), receiver.snapshot().receivedBytes) + assertNull(receiver.pollReceived()) + } + } + + @Test fun fullRxHoldsFinalAckUntilConsumerReturnsCredit() { + val sender = ready(capacity = 2) + val receiver = ready(capacity = 1) + sender.send(byteArrayOf(1)) + sender.send(byteArrayOf(2)) + sender.acceptAck(receiver.acceptData(sender.nextFrame()!!)!!) + assertNull(receiver.acceptData(sender.nextFrame()!!)) + assertTrue(receiver.snapshot().rxBlocked) + assertTrue(sender.snapshot().waitingAck) + assertNull(sender.nextFrame()) + assertEquals(1L, sender.snapshot().sent) + assertNull(receiver.releaseCredit()) + assertArrayEquals(byteArrayOf(1), receiver.pollReceived()) + sender.acceptAck(receiver.releaseCredit()!!) + assertFalse(receiver.snapshot().rxBlocked) + assertEquals(2L, sender.snapshot().sent) + assertArrayEquals(byteArrayOf(2), receiver.pollReceived()) + } + + @Test fun bothDirectionsMakeProgressWithIndependentAckWindows() { + val a = ready(mtu = 64) + val b = ready(mtu = 64) + val first = ByteArray(4096) { it.toByte() } + val second = ByteArray(2048) { (-it).toByte() } + a.send(first) + b.send(second) + repeat(100) { + a.nextFrame()?.let { frame -> a.acceptAck(b.acceptData(frame)!!) } + b.nextFrame()?.let { frame -> b.acceptAck(a.acceptData(frame)!!) } + } + assertArrayEquals(first, b.pollReceived()) + assertArrayEquals(second, a.pollReceived()) + } + + @Test fun protocolRejectsUnsolicitedAcksWrongOffsetsAndOversizedMessages() { + val q = ready() + assertThrows(IllegalArgumentException::class.java) { q.acceptAck(BleConnectionQueues.ack(1, 0)) } + q.send(byteArrayOf(1)) + val frame = q.nextFrame()!! + assertThrows(IllegalArgumentException::class.java) { q.acceptAck(BleConnectionQueues.ack(2, 1)) } + assertThrows(IllegalArgumentException::class.java) { q.acceptAck(BleConnectionQueues.ack(1, 0)) } + assertThrows(IllegalArgumentException::class.java) { ready().acceptData(frame.copyOf(8)) } + assertThrows(IllegalArgumentException::class.java) { ready().acceptData(frame.copyOf().also { it[5] = 1 }) } + assertThrows(IllegalArgumentException::class.java) { + ready().acceptData(frame.copyOf().also { it[7] = 1; it[8] = 0x20 }) + } + } + + @Test fun disconnectDiscardsUnconfirmedTxAndResetsFragmentState() { + val a = ready() + val b = ready() + a.send(ByteArray(4096)) + a.acceptAck(b.acceptData(a.nextFrame()!!)!!) + a.close("test disconnect") + b.close("test disconnect") + assertEquals(1L, a.snapshot().discardedTx) + assertEquals(0L, a.snapshot().sent) + a.phase("READY") + b.phase("READY") + a.send(byteArrayOf(7)) + a.acceptAck(b.acceptData(a.nextFrame()!!)!!) + assertArrayEquals(byteArrayOf(7), b.pollReceived()) + } +} diff --git a/tools/ble-test-android/ble-transport/src/test/java/com/utun/ble/BleQueuesTest.kt b/tools/ble-test-android/ble-transport/src/test/java/com/utun/ble/BleQueuesTest.kt new file mode 100644 index 000000000..826c819e3 --- /dev/null +++ b/tools/ble-test-android/ble-transport/src/test/java/com/utun/ble/BleQueuesTest.kt @@ -0,0 +1,177 @@ +package com.utun.ble + +import org.junit.Assert.* +import org.junit.Test +import java.util.Collections +import java.util.concurrent.CountDownLatch +import kotlin.concurrent.thread + +class BleQueuesTest { + private fun BleQueues.receive(data: ByteArray, rssi: Int, time: Long) = + receive(data, rssi, time, BleProtocol.decode(data)?.session ?: 42) + private fun wire(session: Long = 42, seq: Long = 1, text: String = "Phone") = + BleProtocol.encode(BleProtocol.Packet(session, seq, text.toByteArray(Charsets.UTF_8))) + + @Test fun protocolUsesUnsignedFieldsAndExactLegacyBudget() { + val payload = ByteArray(BleProtocol.MAX_PAYLOAD) { it.toByte() } + val wire = BleProtocol.encode(BleProtocol.Packet(0xffffffffL, 0xffffffffL, payload)) + assertEquals(24, wire.size) + assertTrue(wire.size + 4 + 3 <= 31) // manufacturer AD + необязательные flags. + assertEquals(0xff.toByte(), wire[3]) + val packet = BleProtocol.decode(wire)!! + assertEquals(0xffffffffL, packet.session) + assertEquals(0xffffffffL, packet.sequence) + assertArrayEquals(payload, packet.payload) + } + + @Test fun protocolRejectsMalformedPacketsAndVersionMismatch() { + for (size in 0..10) assertNull(BleProtocol.decode(ByteArray(size))) + assertNull(BleProtocol.decode(ByteArray(25))) + assertNull(BleProtocol.decode(wire().also { it[2] = 99 })) + assertNull(BleProtocol.decode(wire().also { it[0] = 0 })) + assertNull(BleProtocol.decode(wire().also { for (i in 3..6) it[i] = 0 })) + assertNull(BleProtocol.decode(wire().also { for (i in 7..10) it[i] = 0 })) + assertThrows(IllegalArgumentException::class.java) { + BleProtocol.encode(BleProtocol.Packet(1, 1, ByteArray(14))) + } + } + + @Test fun sendHasBackpressureAndCountsOnlySuccessfulPublication() { + val warnings = mutableListOf() + val queues = BleQueues(txCapacity = 1, warn = warnings::add) + assertEquals(SendResult.STOPPED, queues.send(byteArrayOf(1))) + queues.start(10) + assertEquals(SendResult.PAYLOAD_TOO_LARGE, queues.send(ByteArray(14))) + val payload = byteArrayOf(7) + assertEquals(SendResult.QUEUED, queues.send(payload)) + payload[0] = 9 + assertEquals(7.toByte(), queues.peekTx()!!.payload[0]) + assertEquals(SendResult.QUEUE_FULL, queues.send(byteArrayOf(8))) + assertEquals(0, queues.snapshot().sent) + assertEquals(1, queues.peekTx()!!.sequence) + queues.sent(1) + assertEquals(1, queues.snapshot().sent) + assertEquals(SendResult.QUEUED, queues.send(byteArrayOf(8))) + assertEquals(2, queues.peekTx()!!.sequence) + assertEquals(3, warnings.size) + } + + @Test fun duplicatesAndOutOfOrderPacketsDoNotInflateUniqueCount() { + val queues = BleQueues(warn = {}) + queues.start(1) + queues.receive(wire(seq = 3), -60, 100) + queues.receive(wire(seq = 3), -65, 200) + queues.receive(wire(seq = 1), -70, 150) + queues.receive(wire(seq = 2), -75, 300) + queues.receive(wire(seq = 1), -80, 400) + val s = queues.snapshot() + assertEquals(3, s.received) + assertEquals(2, s.duplicates) + assertEquals(3, s.peers.single().lastSequence) + assertEquals(400, s.peers.single().lastSeenMs) + assertEquals(listOf(3L, 1L, 2L), queues.drainReceived(10).map { it.sequence }) + } + + @Test fun windowRejectsOldDuplicatesAndAcceptsUnseenLatePackets() { + val queues = BleQueues(warn = {}) + queues.start(1) + for (seq in 1L..300L) queues.receive(wire(seq = seq), -50, seq) + queues.receive(wire(seq = 1), -50, 301) + queues.receive(wire(seq = 45), -50, 302) + queues.receive(wire(seq = 1000), -50, 303) + queues.receive(wire(seq = 999), -50, 304) + assertEquals(302, queues.snapshot().received) + assertEquals(1, queues.snapshot().stale) + assertEquals(1, queues.snapshot().duplicates) + } + + @Test fun rxOverflowIsVisibleAndStatisticsSurviveDrainingAndStop() { + val warnings = mutableListOf() + val queues = BleQueues(rxCapacity = 2, warn = warnings::add) + queues.start(1) + for (seq in 1L..3L) queues.receive(wire(seq = seq), -50, seq) + assertEquals(1, queues.snapshot().rxDropped) + assertEquals(2, queues.pollReceived()!!.sequence) + assertEquals(3, queues.pollReceived()!!.sequence) + assertNull(queues.pollReceived()) + queues.stop() + assertEquals(3, queues.snapshot().received) + assertEquals(3, queues.snapshot().peers.single().received) + assertEquals(1, warnings.size) + } + + @Test fun newSessionsWithSameNicknameAreIndependentAndLocalPacketsAreIgnored() { + val queues = BleQueues(warn = {}) + queues.start(1) + queues.receive(wire(session = 1), -50, 1) + queues.receive(wire(session = 2), -50, 1) + queues.receive(wire(session = 3), -50, 1) + assertEquals(2, queues.snapshot().received) + assertEquals(2, queues.snapshot().peers.size) + } + + @Test fun peerMemoryIsBoundedAndSnapshotPayloadsAreIndependent() { + val warnings = mutableListOf() + val queues = BleQueues(peerCapacity = 2, warn = warnings::add) + queues.start(1) + queues.receive(wire(session = 2), -50, 1) + queues.receive(wire(session = 3), -50, 2) + queues.receive(wire(session = 2), -50, 3) + queues.receive(wire(session = 4), -50, 4) + val snapshot = queues.snapshot() + assertEquals(setOf(2L, 4L), snapshot.peers.map { it.session }.toSet()) + assertEquals(1, snapshot.peerEvictions) + snapshot.peers.first().payload[0] = 0 + assertEquals('P'.code.toByte(), queues.snapshot().peers.first().payload[0]) + assertEquals(1, warnings.size) + } + + @Test fun stopDiscardsPendingTxAndRestartUsesNewSession() { + val warnings = mutableListOf() + val queues = BleQueues(warn = warnings::add) + queues.start(1) + queues.send(byteArrayOf(7)) + queues.stop() + assertEquals(1, queues.snapshot().txDiscarded) + assertNull(queues.peekTx()) + queues.start(2) + queues.send(byteArrayOf(8)) + assertEquals(2, queues.peekTx()!!.session) + assertEquals(1, queues.peekTx()!!.sequence) + assertEquals(1, warnings.size) + } + + @Test fun invalidPacketsAndQueueArgumentsAreObservable() { + val warnings = mutableListOf() + val queues = BleQueues(warn = warnings::add) + queues.receive(byteArrayOf(1), -50, 1) + assertEquals(1, queues.snapshot().invalid) + assertEquals(1, warnings.size) + assertThrows(IllegalArgumentException::class.java) { queues.drainReceived(0) } + } + + @Test fun simultaneousProducersCannotOverfillTxOrReuseSequenceNumbers() { + val warnings = Collections.synchronizedList(mutableListOf()) + val results = Collections.synchronizedList(mutableListOf()) + val queues = BleQueues(txCapacity = 64, warn = warnings::add) + queues.start(1) + val gate = CountDownLatch(1) + val workers = List(8) { + thread { + gate.await() + repeat(20) { results.add(queues.send(byteArrayOf(1))) } + } + } + gate.countDown() + workers.forEach { it.join() } + assertEquals(64, results.count { it == SendResult.QUEUED }) + assertEquals(96, results.count { it == SendResult.QUEUE_FULL }) + val numbers = mutableListOf() + while (queues.peekTx() != null) { + val seq = queues.peekTx()!!.sequence + numbers.add(seq) + queues.sent(seq) + } + assertEquals((1L..64L).toList(), numbers) + } +} diff --git a/tools/ble-test-android/build.gradle.kts b/tools/ble-test-android/build.gradle.kts new file mode 100644 index 000000000..0cedf8c36 --- /dev/null +++ b/tools/ble-test-android/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "8.13.2" apply false + id("com.android.library") version "8.13.2" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} diff --git a/tools/ble-test-android/build.sh b/tools/ble-test-android/build.sh new file mode 100755 index 000000000..2caf30fff --- /dev/null +++ b/tools/ble-test-android/build.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# По умолчанию только сборка и проверки. install — установка готового APK. +set -euo pipefail +cd "$(dirname "$0")" +./gradlew :ble-transport:testDebugUnitTest :ble-transport:lintDebug :app:assembleDebug :app:lintDebug +if [[ "${1:-}" == install ]]; then + adb -s "${2:?Usage: ./build.sh install DEVICE_SERIAL}" install -r app/build/outputs/apk/debug/app-debug.apk +fi diff --git a/tools/ble-test-android/gradle.properties b/tools/ble-test-android/gradle.properties new file mode 100644 index 000000000..e0011a56c --- /dev/null +++ b/tools/ble-test-android/gradle.properties @@ -0,0 +1,3 @@ +android.useAndroidX=true +kotlin.code.style=official +org.gradle.jvmargs=-Xmx2g diff --git a/tools/ble-test-android/gradlew b/tools/ble-test-android/gradlew new file mode 100755 index 000000000..46351e38c --- /dev/null +++ b/tools/ble-test-android/gradlew @@ -0,0 +1,4 @@ +#!/bin/sh +# Общая с Android-чатом версия Gradle; wrapper не дублируем. +BLE_PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec "$BLE_PROJECT_DIR/../chatgui-android/gradlew" -p "$BLE_PROJECT_DIR" "$@" diff --git a/tools/ble-test-android/settings.gradle.kts b/tools/ble-test-android/settings.gradle.kts new file mode 100644 index 000000000..804b3707d --- /dev/null +++ b/tools/ble-test-android/settings.gradle.kts @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "ble-test-android" +include(":app", ":ble-transport") diff --git a/tools/ble-test-android/tests/run_two_devices.py b/tools/ble-test-android/tests/run_two_devices.py new file mode 100644 index 000000000..5bb0b524d --- /dev/null +++ b/tools/ble-test-android/tests/run_two_devices.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Одновременный запуск Binder/GATT probe на двух телефонах, с полными раздельными логами.""" + +import argparse +from pathlib import Path +import subprocess +import sys +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--receiver", required=True, help="ADB serial принимающей стороны") + parser.add_argument("--initiator", required=True, help="ADB serial инициатора") + parser.add_argument("--receiver-id", required=True, help="64-bit ID принимающей стороны, hex") + parser.add_argument("--initiator-id", required=True, help="64-bit ID инициатора, hex") + parser.add_argument("--reject", action="store_true", help="Проверить отказ callback вместо duplex") + parser.add_argument("--install", action="store_true", help="Установить готовые app и androidTest APK перед тестом") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if args.receiver == args.initiator: + parser.error("Нужны два разных устройства") + for value in (args.receiver_id, args.initiator_id): + if not 0 < int(value, 16) <= 0xffffffffffffffff: + parser.error("ID должен быть ненулевым unsigned 64-bit числом") + args.output.mkdir(parents=True, exist_ok=True) + project = Path(__file__).resolve().parents[1] + roles = [("receiver", args.receiver, args.initiator_id, "false"), + ("initiator", args.initiator, args.receiver_id, "true")] + if args.install: + for _, serial, _, _ in roles: + for apk in ("app/build/outputs/apk/debug/app-debug.apk", + "app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk"): + subprocess.run(["adb", "-s", serial, "install", "-r", str(project / apk)], check=True) + processes = [] + files = [] + try: + # Оба процесса создаются до ожидания: согласование запуска не зависит от задержек отдельных tool approvals. + for role, serial, peer, initiate in roles: + logfile = args.output / f"{role}.log" + output = logfile.open("w") + files.append(output) + command = ["adb", "-s", serial, "shell", "am", "instrument", "-w", "-e", "peer", peer, + "-e", "initiator", initiate, "-e", "reject", str(args.reject).lower(), + "com.utun.bletest.test/com.utun.bletest.BleApiProbe"] + processes.append((role, subprocess.Popen(command, stdout=output, stderr=subprocess.STDOUT), logfile)) + print(f"Started {role}: {serial}, peer={peer}", flush=True) + deadline = time.monotonic() + 210 + while any(process.poll() is None for _, process, _ in processes): + if time.monotonic() >= deadline: + raise TimeoutError("Timed out waiting for two-device probe") + time.sleep(0.25) + success = True + for role, process, logfile in processes: + content = logfile.read_text() + print(f"{role}:\n{content}", flush=True) + success &= process.returncode == 0 and "result=PASS " in content and "INSTRUMENTATION_CODE: -1" in content + return 0 if success else 1 + finally: + for _, process, _ in processes: + if process.poll() is None: + process.terminate() + process.wait(timeout=10) + for output in files: + output.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/chatgui-android/app/build.gradle.kts b/tools/chatgui-android/app/build.gradle.kts index fd1e1018a..4f17e2835 100644 --- a/tools/chatgui-android/app/build.gradle.kts +++ b/tools/chatgui-android/app/build.gradle.kts @@ -36,6 +36,7 @@ android { defaultConfig { applicationId = "com.utun.chat" + testInstrumentationRunner = "com.utun.chat.BlePeerProbe" minSdk = 26 targetSdk = 36 versionCode = 1 diff --git a/tools/chatgui-android/app/src/androidTest/java/com/utun/chat/BlePeerProbe.kt b/tools/chatgui-android/app/src/androidTest/java/com/utun/chat/BlePeerProbe.kt new file mode 100644 index 000000000..a01fa929d --- /dev/null +++ b/tools/chatgui-android/app/src/androidTest/java/com/utun/chat/BlePeerProbe.kt @@ -0,0 +1,33 @@ +package com.utun.chat + +import android.app.Activity +import android.app.Instrumentation +import android.content.Intent +import android.os.Bundle +import android.os.SystemClock +import com.utun.chat.data.BleRadio +import com.utun.chat.data.NativeLib +import com.utun.chat.headless.HeadlessService + +/** Только ручной тест на физическом устройстве: временный echo общего C API. */ +class BlePeerProbe : Instrumentation() { + private lateinit var args: Bundle + override fun onCreate(arguments: Bundle) { super.onCreate(arguments); args = arguments; start() } + override fun onStart() { + val result = Bundle() + var code = Activity.RESULT_OK + try { + startActivitySync(Intent(targetContext, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + targetContext.startForegroundService(Intent(targetContext, HeadlessService::class.java)) + val end = SystemClock.elapsedRealtime() + 30_000 + while (!NativeLib.isReady()) { check(SystemClock.elapsedRealtime() < end) { "core startup timeout" }; Thread.sleep(100) } + Thread.sleep(1000) // Дождаться обычной политики SERVICE_STARTED перед тестовой. + check(BleRadio.permitted()) { "Bluetooth permissions missing" } + check(BleRadio.nativeConfigure(15 or if (args.getString("initiator") == "true") 16 else 0)) { "configure refused" } + Thread.sleep((args.getString("seconds") ?: "100").toLong() * 1000) + result.putString("stream", "BLE echo session completed; verify desktop RESULT\n") + } catch (e: Exception) { code = Activity.RESULT_CANCELED; result.putString("stream", "BLE FAIL: $e\n") } + finally { BleRadio.applySettings() } + finish(code, result) + } +} diff --git a/tools/chatgui-android/app/src/main/AndroidManifest.xml b/tools/chatgui-android/app/src/main/AndroidManifest.xml index 7dcc93574..01134ebd9 100644 --- a/tools/chatgui-android/app/src/main/AndroidManifest.xml +++ b/tools/chatgui-android/app/src/main/AndroidManifest.xml @@ -10,6 +10,12 @@ + + + + + + @@ -66,7 +72,7 @@ + android:foregroundServiceType="specialUse|connectedDevice"> diff --git a/tools/chatgui-android/app/src/main/cpp/CMakeLists.txt b/tools/chatgui-android/app/src/main/cpp/CMakeLists.txt index 1ddde7487..e1f0b824c 100644 --- a/tools/chatgui-android/app/src/main/cpp/CMakeLists.txt +++ b/tools/chatgui-android/app/src/main/cpp/CMakeLists.txt @@ -76,6 +76,9 @@ set_target_properties(utun_core PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED ON) # JNI bridge + link into shared library add_library(utun SHARED ${JNI_DIR}/android_jni_bridge.c + ${JNI_DIR}/android_ble.c + ${LIB_DIR}/ble/ble_transport.c + ${LIB_DIR}/ble/ble_identity.c ${JNI_DIR}/android_stream_jni.c ${JNI_DIR}/android_call_video_jni.c ${JNI_DIR}/android_call_output_jni.c diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt index 7d90de6de..f9b35cec4 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt @@ -36,6 +36,7 @@ object AppEventHandler : ChatEventCallback { override fun onEvent(type: Int, data: ByteArray?) { if (type == 12) mainHandler.post { com.utun.chat.data.CoreActivity.onCoreStarted() + com.utun.chat.data.BleRadio.applySettings() com.utun.chat.data.PhoneStateMonitor.refresh() } if (type == 12 || type == 13 || type == 65) @@ -94,6 +95,7 @@ class ChatApplication : Application() { /* One-time event callback registration — survives ViewModel recreation */ NativeLib.setEventCallback(AppEventHandler) + com.utun.chat.data.BleRadio.init() /* Incoming-call system effects (wake + ringtone + notification) — app-scoped */ IncomingCallManager.init() diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/BleRadio.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/BleRadio.kt new file mode 100644 index 000000000..79ab659ba --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/BleRadio.kt @@ -0,0 +1,362 @@ +package com.utun.chat.data + +import android.Manifest +import android.bluetooth.* +import android.bluetooth.le.* +import android.content.* +import android.content.pm.PackageManager +import android.os.* +import android.location.LocationManager +import android.provider.Settings +import androidx.core.content.ContextCompat +import com.utun.chat.ChatApplication +import kotlinx.coroutines.flow.MutableStateFlow +import java.util.UUID + +/** Только Android radio I/O. Протокол, сборка пакетов и ll_queue находятся в общем C transport. */ +@Suppress("MissingPermission", "DEPRECATION") +object BleRadio { + private val serviceUuid = UUID.fromString("f61a9020-4abc-45ac-98e1-761df4c13811") + private val rxUuid = UUID.fromString("f61a9021-4abc-45ac-98e1-761df4c13811") + private val txUuid = UUID.fromString("f61a9022-4abc-45ac-98e1-761df4c13811") + private val cccUuid = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + private val thread = HandlerThread("vibechat-ble").apply { start() } + private val worker = Handler(thread.looper) + private val context get() = ChatApplication.instance + private val adapter get() = context.getSystemService(BluetoothManager::class.java).adapter + val state = MutableStateFlow(2) // BLE_CLOSED; тексты выбирает UI. + val advertisingState = MutableStateFlow(2) + private var generation = 0L + private var company = 0xffff + private var incoming = false + private var server: BluetoothGattServer? = null + private var tx: BluetoothGattCharacteristic? = null + private var scan: ScanCallback? = null + private var advertisement: AdvertiseCallback? = null + private var beacon = byteArrayOf() + private var sequence = 0L + private var writer: Link? = null + private var lastWriter = 0L + private val links = linkedMapOf() + private val incomingMtu = mutableMapOf() + private data class Link(val token: Long, val device: BluetoothDevice, val outgoing: Boolean, + var gatt: BluetoothGatt? = null, var rx: BluetoothGattCharacteristic? = null, + var mtu: Int = 23, var opened: Boolean = false, var pending: ByteArray? = null, var writing: Boolean = false) + @JvmStatic external fun nativeInstall() + @JvmStatic external fun nativeConfigure(flags: Int): Boolean + @JvmStatic private external fun nativeEvent(gen: Long, token: Long, type: Int, arg: Int, text: String, bytes: ByteArray) + fun permissions(): Array = if (Build.VERSION.SDK_INT >= 31) arrayOf( + Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_ADVERTISE) + else arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) + fun permitted() = permissions().all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED } + fun init() { + nativeInstall() + val receiver = object : BroadcastReceiver() { + override fun onReceive(c: Context, intent: Intent) { applySettings() } + } + ContextCompat.registerReceiver(context, receiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED), ContextCompat.RECEIVER_EXPORTED) + } + fun applySettings() { + val flags = context.configProvider.getBleFlags() + val effective = try { if (permitted() && adapter?.isEnabled == true) flags else 0 } + catch (e: SecurityException) { log("WARN", "Permission revoked: ${e.message}"); 0 } + if (NativeLib.isReady() && !nativeConfigure(effective)) log("WARN", "Settings could not be queued") + if (effective == 0) state.value = 2 + } + private fun log(level: String, text: String) { LogManager.addLog(level, "discovery", "BLE $text") } + private fun emit(type: Int, token: Long = 0, arg: Int = 0, text: String = "", data: ByteArray = byteArrayOf()) { + nativeEvent(generation, token, type, arg, text, data) + } + private fun guarded(block: () -> Unit) { + try { block() } catch (e: RuntimeException) { + log("ERROR", "Radio failure: ${e.javaClass.simpleName}: ${e.message}") + emit(8, text = "${e.javaClass.simpleName}: ${e.message}") + stop() + } + } + @JvmStatic fun command(op: Int, gen: Long, token: Long, text: String, bytes: ByteArray, arg: Int) { + if (!worker.post { + guarded { + when (op) { + 0 -> if (generation == gen) stop() + 1 -> { stop(); generation = gen; company = arg; start() } + 6 -> { incoming = arg != 0; if (scan != null) advertise() } + 7 -> { state.value = arg; log("INFO", "State=$arg reason=$text") } + 8 -> { advertisingState.value = arg } + else -> if (generation == gen) when (op) { + 2 -> { beacon = if (arg != 0) bytes else byteArrayOf(); advertise() } + 3 -> connect(token, text) + 4 -> links[token]?.let { close(it, "native close", false) } + 5 -> { + val link = checkNotNull(links[token]) { "send unknown link=$token" } + check(link.pending == null) { "send already pending link=$token" } + link.pending = bytes + pump() + } + } + } + } + }) log("ERROR", "Worker rejected command=$op") + } + private fun start() { + check(permitted()) { "Bluetooth permission missing" } + check(adapter?.isEnabled == true) { "Bluetooth disabled" } + if (Build.VERSION.SDK_INT < 31) { + val enabled = if (Build.VERSION.SDK_INT >= 28) context.getSystemService(LocationManager::class.java).isLocationEnabled + else Settings.Secure.getInt(context.contentResolver, Settings.Secure.LOCATION_MODE, 0) != 0 + check(enabled) { "Android 8–11 requires location services for BLE scanning" } + } + val gen = generation + run { + val callback = object : BluetoothGattServerCallback() { + private fun post(block: () -> Unit) { worker.post { if (generation == gen) guarded(block) } } + override fun onServiceAdded(status: Int, service: BluetoothGattService) = post { + check(status == BluetoothGatt.GATT_SUCCESS) { "addService status=$status" }; startScan(); advertise(); emit(1) + } + override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) = post { + log("INFO", "Server ACL address=${device.address} status=$status state=$newState") + if (newState == BluetoothProfile.STATE_DISCONNECTED) { + links.values.filter { !it.outgoing && it.device.address == device.address }.toList().forEach { close(it, "ACL disconnected") } + incomingMtu.remove(device.address) + } + } + override fun onPhyUpdate(device: BluetoothDevice, txPhy: Int, rxPhy: Int, status: Int) = post { + phyResult("Server", device.address, txPhy, rxPhy, status) + } + override fun onPhyRead(device: BluetoothDevice, txPhy: Int, rxPhy: Int, status: Int) = post { + phyResult("Server read", device.address, txPhy, rxPhy, status) + } + override fun onMtuChanged(device: BluetoothDevice, mtu: Int) = post { + incomingMtu[device.address] = mtu + // После открытия используем уже объявленную core ёмкость, уменьшение требует reconnect. + log("DEBUG", "Server MTU address=${device.address} mtu=$mtu") + } + override fun onDescriptorWriteRequest(device: BluetoothDevice, requestId: Int, descriptor: BluetoothGattDescriptor, + preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray) = post { + val valid = incoming && descriptor.uuid == cccUuid && descriptor.characteristic.uuid == txUuid && + !preparedWrite && offset == 0 && value.contentEquals(BluetoothGattDescriptor.ENABLE_INDICATION_VALUE) + var link = links.values.find { !it.outgoing && it.device.address == device.address } + val accepted = valid && (link != null || links.size < 16) + if (responseNeeded) respond(device, requestId, if (accepted) 0 else BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED) + if (accepted && link == null) { + link = Link(--sequence, device, false, mtu = incomingMtu[device.address] ?: 23, opened = true) + links[link.token] = link + preferFastPhy(device, null) + emit(2, link.token, link.mtu, device.address) + } else if (!accepted) { + log("WARN", "Subscription rejected address=${device.address}") + if (link != null) close(link, "subscription disabled") + } + } + override fun onCharacteristicWriteRequest(device: BluetoothDevice, requestId: Int, characteristic: BluetoothGattCharacteristic, + preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray) = post { + val link = links.values.find { !it.outgoing && it.device.address == device.address } + val valid = link != null && characteristic.uuid == rxUuid && !preparedWrite && responseNeeded && offset == 0 + if (valid) emit(3, link!!.token, data = value.copyOf()) + if (responseNeeded) respond(device, requestId, if (valid) 0 else BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED) + if (!valid) log("WARN", "Write rejected address=${device.address} bytes=${value.size}") + } + override fun onNotificationSent(device: BluetoothDevice, status: Int) = post { + val link = writer + if (link == null || link.device.address != device.address) log("WARN", "Stale indication address=${device.address}") + else { + writer = null; worker.removeCallbacks(writerTimeout) + if (links[link.token] === link) complete(link, status) + else { log("DEBUG", "Retired indication completed link=${link.token} status=$status"); pump() } + } + } + override fun onCharacteristicReadRequest(device: BluetoothDevice, requestId: Int, offset: Int, + characteristic: BluetoothGattCharacteristic) = post { respond(device, requestId, BluetoothGatt.GATT_READ_NOT_PERMITTED) } + override fun onDescriptorReadRequest(device: BluetoothDevice, requestId: Int, offset: Int, + descriptor: BluetoothGattDescriptor) = post { respond(device, requestId, BluetoothGatt.GATT_READ_NOT_PERMITTED) } + override fun onExecuteWrite(device: BluetoothDevice, requestId: Int, execute: Boolean) = post { + respond(device, requestId, BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED) + } + } + server = checkNotNull(context.getSystemService(BluetoothManager::class.java).openGattServer(context, callback)) + val service = BluetoothGattService(serviceUuid, BluetoothGattService.SERVICE_TYPE_PRIMARY) + service.addCharacteristic(BluetoothGattCharacteristic(rxUuid, BluetoothGattCharacteristic.PROPERTY_WRITE, + BluetoothGattCharacteristic.PERMISSION_WRITE)) + tx = BluetoothGattCharacteristic(txUuid, BluetoothGattCharacteristic.PROPERTY_INDICATE, 0).apply { + addDescriptor(BluetoothGattDescriptor(cccUuid, BluetoothGattDescriptor.PERMISSION_WRITE)) + } + service.addCharacteristic(tx) + check(server!!.addService(service)) { "addService refused" } + } + } + private fun respond(device: BluetoothDevice, id: Int, status: Int) { + if (server?.sendResponse(device, id, status, 0, null) != true) log("ERROR", "sendResponse failed address=${device.address} request=$id") + } + private fun startScan() { + val gen = generation + scan = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + val data = result.scanRecord?.getManufacturerSpecificData(company)?.copyOf() ?: return + if (data.size !in 11..24 || data[0] != 86.toByte() || data[1] != 67.toByte() || data[2] != 1.toByte()) return + worker.post { if (generation == gen) emit(6, arg = result.rssi, text = result.device.address, data = data) } + } + override fun onScanFailed(errorCode: Int) { worker.post { if (generation == gen) guarded { error("scan error=$errorCode") } } } + } + val filter = ScanFilter.Builder().setManufacturerData(company, byteArrayOf(86, 67, 1)).build() + checkNotNull(adapter?.bluetoothLeScanner).startScan(listOf(filter), + ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(), scan) + log("INFO", "Scan started generation=$gen incoming=$incoming") + } + private fun advertise() { + val advertiser = adapter?.bluetoothLeAdvertiser + advertisement?.let { advertiser?.stopAdvertising(it) }; advertisement = null + if (!incoming && beacon.isEmpty()) { emit(7, arg = 1, text = "advertisement disabled"); return } + checkNotNull(advertiser) { "Advertising unsupported" } + val gen = generation + val data = AdvertiseData.Builder().setIncludeDeviceName(false) + if (beacon.isNotEmpty()) data.addManufacturerData(company, beacon) + val response = AdvertiseData.Builder() + if (incoming) response.addServiceUuid(ParcelUuid(serviceUuid)) + val callback = object : AdvertiseCallback() { + override fun onStartSuccess(settings: AdvertiseSettings) { worker.post { + if (generation == gen && advertisement === this) { log("INFO", "Advertising ready bytes=${beacon.size}"); emit(7, text = "published") } + } } + override fun onStartFailure(errorCode: Int) { worker.post { + if (generation == gen && advertisement === this) { log("ERROR", "Advertising failed code=$errorCode"); emit(7, arg = errorCode, text = "advertising failed=$errorCode") } + } } + } + advertisement = callback + advertiser.startAdvertising(AdvertiseSettings.Builder().setConnectable(incoming) + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY).setTimeout(0).build(), data.build(), response.build(), callback) + } + private fun phyResult(role: String, address: String, txPhy: Int, rxPhy: Int, status: Int) { + fun name(phy: Int) = when (phy) { BluetoothDevice.PHY_LE_1M -> "1M"; BluetoothDevice.PHY_LE_2M -> "2M" + BluetoothDevice.PHY_LE_CODED -> "CODED"; else -> "unknown($phy)" } + log(if (status == 0) "INFO" else "WARN", "$role PHY address=$address tx=${name(txPhy)} rx=${name(rxPhy)} status=$status") + } + // 2M — запрос предпочтения. S2/S8 относятся только к CODED PHY, здесь они не применяются. + private fun preferFastPhy(device: BluetoothDevice, gatt: BluetoothGatt?) { + try { + if (adapter?.isLe2MPhySupported == true) { + if (gatt != null) gatt.setPreferredPhy(BluetoothDevice.PHY_LE_2M_MASK, + BluetoothDevice.PHY_LE_2M_MASK, BluetoothDevice.PHY_OPTION_NO_PREFERRED) + else server?.setPreferredPhy(device, BluetoothDevice.PHY_LE_2M_MASK, + BluetoothDevice.PHY_LE_2M_MASK, BluetoothDevice.PHY_OPTION_NO_PREFERRED) + log("INFO", "PHY 2M requested address=${device.address} outgoing=${gatt != null}") + } else log("INFO", "Local controller has no PHY 2M; retaining negotiated PHY address=${device.address}") + if (gatt != null) gatt.readPhy() else server?.readPhy(device) + } catch (e: RuntimeException) { log("WARN", "PHY tuning unavailable address=${device.address}: ${e.message}") } + } + private fun connect(token: Long, address: String) { + check(links.size < 16) { "link limit" } + val link = Link(token, checkNotNull(adapter).getRemoteDevice(address), true) + links[token] = link + val gen = generation + val callback = object : BluetoothGattCallback() { + private fun valid(gatt: BluetoothGatt) = generation == gen && links[token] === link && link.gatt === gatt + private fun dispatch(gatt: BluetoothGatt, block: () -> Unit) { if (valid(gatt)) guarded(block) } + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) = dispatch(gatt) { + log("INFO", "Client link=$token status=$status state=$newState") + if (status != 0 || newState != BluetoothProfile.STATE_CONNECTED) close(link, "client status=$status state=$newState") + else { + val priority = gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) + log(if (priority) "INFO" else "WARN", "Connection priority HIGH requested link=$token accepted=$priority") + preferFastPhy(link.device, gatt) + if (!gatt.requestMtu(517)) close(link, "requestMtu refused") + } + } + override fun onPhyUpdate(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) = dispatch(gatt) { + phyResult("Client", address, txPhy, rxPhy, status) + } + override fun onPhyRead(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) = dispatch(gatt) { + phyResult("Client read", address, txPhy, rxPhy, status) + } + override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) = dispatch(gatt) { + if (status == 0) link.mtu = mtu + log("INFO", "Client MTU link=$token mtu=${link.mtu} status=$status") + if (!gatt.discoverServices()) close(link, "discoverServices refused") + } + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) = dispatch(gatt) { + val service = gatt.getService(serviceUuid) + val characteristic = service?.getCharacteristic(txUuid) + val ccc = characteristic?.getDescriptor(cccUuid) + link.rx = service?.getCharacteristic(rxUuid) + if (status != 0 || ccc == null || link.rx == null || !gatt.setCharacteristicNotification(characteristic, true)) { + close(link, "service discovery/subscription failed status=$status"); return@dispatch + } + val ok = if (Build.VERSION.SDK_INT >= 33) gatt.writeDescriptor(ccc, BluetoothGattDescriptor.ENABLE_INDICATION_VALUE) == 0 + else { ccc.value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE; gatt.writeDescriptor(ccc) } + if (!ok) close(link, "CCC write refused") + } + override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) = dispatch(gatt) { + if (status != 0) close(link, "CCC status=$status") + else { link.opened = true; emit(2, token, link.mtu, address) } + } + override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) = dispatch(gatt) { + complete(link, status) + } + override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) = dispatch(gatt) { + if (characteristic.uuid == txUuid) emit(3, token, data = value.copyOf()) + } + @Deprecated("API < 33") + override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) = dispatch(gatt) { + if (Build.VERSION.SDK_INT < 33 && characteristic.uuid == txUuid) emit(3, token, data = characteristic.value.copyOf()) + } + } + link.gatt = checkNotNull(link.device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE, + BluetoothDevice.PHY_LE_1M_MASK, worker)) { "connectGatt returned null" } + } + private fun pump() { + if (generation == 0L) return + val pending = links.values.filter { it.pending != null && !it.writing && it.opened }.sortedBy { it.token } + val ordered = pending.filter { it.token > lastWriter } + pending.filter { it.token <= lastWriter } + for (link in ordered) { + if (!link.outgoing && writer != null) continue + val bytes = checkNotNull(link.pending) + val ok = if (link.outgoing) { + val characteristic = checkNotNull(link.rx) + if (Build.VERSION.SDK_INT >= 33) link.gatt!!.writeCharacteristic(characteristic, bytes, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) == 0 + else { characteristic.value = bytes; characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT; link.gatt!!.writeCharacteristic(characteristic) } + } else { + val characteristic = checkNotNull(tx) + if (Build.VERSION.SDK_INT >= 33) server!!.notifyCharacteristicChanged(link.device, characteristic, true, bytes) == 0 + else { characteristic.value = bytes; server!!.notifyCharacteristicChanged(link.device, characteristic, true) } + } + if (ok) { + link.writing = true + if (!link.outgoing) { + writer = link; lastWriter = link.token + worker.postDelayed(writerTimeout, 20_000) + } + } + else { + log("WARN", "ATT busy link=${link.token}; fragment retained") + worker.removeCallbacks(retry); worker.postDelayed(retry, 50) + break + } + } + } + // Закрытый link остаётся tombstone до completion: callback Android содержит только device. + private val writerTimeout = Runnable { if (writer != null) guarded { error("Server indication completion timeout") } } + private val retry = Runnable { guarded { pump() } } + private fun complete(link: Link, status: Int) { + if (!link.writing) { log("WARN", "Stale completion link=${link.token}"); return } + link.writing = false; link.pending = null + emit(4, link.token, status) + pump() + } + private fun close(link: Link, reason: String, report: Boolean = true) { + links.remove(link.token) + log("INFO", "Closed link=${link.token} reason=$reason") + try { + if (link.outgoing) { link.gatt?.disconnect(); link.gatt?.close() } + else server?.cancelConnection(link.device) + } catch (e: RuntimeException) { log("WARN", "Close failed: ${e.message}") } + if (report) emit(5, link.token, text = reason) + pump() + } + private fun stop() { + generation = 0 + worker.removeCallbacks(retry); worker.removeCallbacks(writerTimeout) + links.values.toList().forEach { close(it, "radio stopped", false) } + try { scan?.let { adapter?.bluetoothLeScanner?.stopScan(it) } } catch (e: RuntimeException) { log("WARN", "Stop scan: ${e.message}") } + try { advertisement?.let { adapter?.bluetoothLeAdvertiser?.stopAdvertising(it) } } catch (e: RuntimeException) { log("WARN", "Stop advertise: ${e.message}") } + try { server?.close() } catch (e: RuntimeException) { log("WARN", "Stop server: ${e.message}") } + scan = null; advertisement = null; server = null; tx = null; writer = null; beacon = byteArrayOf(); incomingMtu.clear() + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt index 804d13be4..84c7da9bc 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt @@ -43,6 +43,13 @@ data class RadioCompressorSettings(val enabled: Boolean = true, val maxGainDb: I */ class ConfigProvider(private val context: Context) { private val configFiles = ConfigFileStore { level, message -> LogManager.addLog(level, "CFG", message) } + private val bleFlags = intPreferencesKey("bluetooth_flags") + fun getBleFlags(): Int = runBlocking { context.dataStore.data.first()[bleFlags] ?: 0 } + suspend fun setBleFlags(flags: Int) { + require(flags in 0..7) + context.dataStore.edit { it[bleFlags] = flags } + LogManager.addLog("INFO", "discovery", "BLE settings flags=$flags") + } private val nodePrivateKey = stringPreferencesKey("node_private_key") private val nodePublicKey = stringPreferencesKey("node_public_key") private val nodeName = stringPreferencesKey("node_name") diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/BluetoothSettingsTab.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/BluetoothSettingsTab.kt new file mode 100644 index 000000000..7baa88e92 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/BluetoothSettingsTab.kt @@ -0,0 +1,65 @@ +package com.utun.chat.ui.screens + +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.utun.chat.ChatApplication +import com.utun.chat.R +import com.utun.chat.data.BleRadio +import com.utun.chat.data.LogManager +import kotlinx.coroutines.launch +import kotlinx.coroutines.CancellationException + +@Composable +fun BluetoothSettingsTab() { + val app = ChatApplication.instance + val provider = app.configProvider + val scope = rememberCoroutineScope() + var flags by remember { mutableStateOf(provider.getBleFlags()) } + var denied by remember { mutableStateOf(false) } + var failed by remember { mutableStateOf(false) } + val state by BleRadio.state.collectAsState() + val advertisingState by BleRadio.advertisingState.collectAsState() + fun save(value: Int) { scope.launch { + try { provider.setBleFlags(value); flags = value; BleRadio.applySettings(); failed = false } + catch (e: CancellationException) { throw e } + catch (e: Exception) { failed = true; LogManager.addLog("ERROR", "discovery", "BLE settings: ${e.message}") } + } } + val enable = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == android.app.Activity.RESULT_OK) { denied = false; save(flags or 1) } else denied = true + } + fun activate() { + if (app.getSystemService(BluetoothManager::class.java).adapter?.isEnabled == true) { denied = false; save(flags or 1) } + else enable.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) + } + val permissions = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + if (BleRadio.permitted()) activate() else denied = true + } + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(stringResource(R.string.ble_enabled)) + Switch(checked = flags and 1 != 0, onCheckedChange = { + if (!it) save(flags and 1.inv()) + else if (BleRadio.permitted()) activate() else permissions.launch(BleRadio.permissions()) + }) + } + listOf(2 to R.string.ble_advertise, 4 to R.string.ble_incoming).forEach { (bit, label) -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(stringResource(label), Modifier.weight(1f)) + Switch(checked = flags and bit != 0, onCheckedChange = { save(if (it) flags or bit else flags and bit.inv()) }) + } + } + Text(stringResource(R.string.ble_hint), style = MaterialTheme.typography.bodySmall) + Text(stringResource(when (state) { 0 -> R.string.ble_starting; 1 -> R.string.ble_ready; else -> R.string.ble_stopped })) + if (denied) Text(stringResource(R.string.ble_permission), color = MaterialTheme.colorScheme.error) + if (failed || (state == 1 && flags and 2 != 0 && advertisingState == 2)) Text(stringResource(R.string.ble_failed), color = MaterialTheme.colorScheme.error) + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt index 70ff8a931..1bacca9f7 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt @@ -85,7 +85,7 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = { var applyFailed by remember { mutableStateOf(false) } var tabIndex by rememberSaveable { mutableStateOf(0) } - val tabs = listOf(R.string.settings, R.string.audio, R.string.storage, R.string.radio, R.string.broadcast, R.string.dev) + val tabs = listOf(R.string.settings, R.string.audio, R.string.storage, R.string.radio, R.string.broadcast, R.string.dev, R.string.bluetooth) val handleBack: () -> Unit = back@{ if (applyingConfig) return@back @@ -141,6 +141,7 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = { 3 -> RadioSettingsTab() 4 -> StreamSettingsTab(vm) 5 -> DeveloperSettingsTab(vm, onDirty = { dirty = it }, onExit) + 6 -> BluetoothSettingsTab() } } } diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml index ea89d98ab..b022427f4 100644 --- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml @@ -617,4 +617,14 @@ Добавить сервер в канал Создайте канал перед добавлением сервера. Ключ доступа к серверу неверен или был изменён. + Bluetooth + Использовать Bluetooth + Рекламировать себя + Разрешить подключения ко мне + При разрешённых входящих подключениях GATT-сервис остаётся обнаруживаемым даже без персонального объявления. + Bluetooth запускается + Bluetooth работает + Bluetooth остановлен или недоступен + Нужно разрешение Bluetooth и включённый системный Bluetooth. + Не удалось применить настройки Bluetooth diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml index bbde44d69..cff1ebce5 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -616,4 +616,14 @@ Add server to channel Create a channel before adding a server. The server access key is invalid or has changed. + Bluetooth + Use Bluetooth + Advertise myself + Allow incoming connections + When incoming connections are allowed, the GATT service remains discoverable even if your personal advertisement is disabled. + Bluetooth is starting + Bluetooth is running + Bluetooth is stopped or unavailable + Bluetooth permission or system Bluetooth activation is required. + Could not apply Bluetooth settings diff --git a/tools/chatgui-android/jni_bridge/android_ble.c b/tools/chatgui-android/jni_bridge/android_ble.c new file mode 100644 index 000000000..8714fda31 --- /dev/null +++ b/tools/chatgui-android/jni_bridge/android_ble.c @@ -0,0 +1,272 @@ +/* Android backend общего BLE C API. JNI события копируются в ограниченный mailbox; + * eventfd будит единственный uasync owner. Mutex защищает active/очередь/fd от teardown. + * Java commands: 0 stop, 1 start, 2 advertise, 3 connect, 4 close, 5 send, 6 incoming, + * 7 status UI, 8 advertising UI. Events: 1 ready, 2 opened, 3 received, 4 sent, + * 5 closed, 6 broadcast, 7 advertising, 8 failed. token < 0 — входящий Java link. + * Flags настроек: 1 enabled, 2 advertise, 4 incoming; 8 echo/16 dial — instrumentation. + * JVM class reference живёт до завершения процесса. Все transport handles — только worker. */ +#include +#include +#include +#include +#include +#include +#include "ble/ble_backend.h" +#include "ble/ble_identity.h" +#include "u_async.h" +#include "mem.h" +#include "debug_config.h" +#include "instance_lite.h" +#include "utun_instance.h" +#include "config_parser.h" +#include "android_ble.h" +#define CAT DEBUG_CATEGORY_DISCOVERY +#define EVENTS 256 +struct event { jlong token; int type, arg, len; char text[256]; uint8_t data[512]; }; +struct android_link { ble_link_id id; jlong token; }; +struct ble_backend { + struct UASYNC* ua; + struct ble_transport* owner; + void* io; + int wake_fd; + jlong generation; + struct android_link links[BLE_LINKS_MAX]; + struct event events[EVENTS]; + unsigned head, count; + int overflow; +}; +static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; +static struct ble_backend* active; +static jlong generation; +static JavaVM* vm; +static jclass radio_class; +static jmethodID command_method; +static struct ble_transport* transport; /* Только worker. */ +static int policy; +static int incoming_policy; +static int radio_ready; +static uint8_t identity[24]; +static int identity_len; +static int command(int op, jlong gen, jlong token, const char* text, const uint8_t* bytes, size_t len, int arg) { + JNIEnv* env = NULL; int attached = 0, ok = 0; + if (!vm || !radio_class) { DEBUG_ERROR(CAT, "BLE JNI is not installed"); return -1; } + if ((*vm)->GetEnv(vm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { + if ((*vm)->AttachCurrentThread(vm, &env, NULL) != JNI_OK) { DEBUG_ERROR(CAT, "BLE JNI attach failed"); return -1; } + attached = 1; + } + jstring string = (*env)->NewStringUTF(env, text ? text : ""); + jbyteArray data = string ? (*env)->NewByteArray(env, (jsize)len) : NULL; + if (string && data) { + if (len) (*env)->SetByteArrayRegion(env, data, 0, (jsize)len, (const jbyte*)bytes); + if (!(*env)->ExceptionCheck(env)) (*env)->CallStaticVoidMethod(env, radio_class, command_method, op, gen, token, string, data, arg); + ok = !(*env)->ExceptionCheck(env); + } + if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); DEBUG_ERROR(CAT, "BLE JNI command failed op=%d", op); } + if (string) (*env)->DeleteLocalRef(env, string); + if (data) (*env)->DeleteLocalRef(env, data); + if (attached) (*vm)->DetachCurrentThread(vm); + return ok ? 0 : -1; +} +static struct android_link* by_id(struct ble_backend* b, ble_link_id id) { + for (int i = 0; i < BLE_LINKS_MAX; i++) if (b->links[i].id == id) return &b->links[i]; + return NULL; +} +static struct android_link* by_token(struct ble_backend* b, jlong token) { + for (int i = 0; i < BLE_LINKS_MAX; i++) if (b->links[i].id && b->links[i].token == token) return &b->links[i]; + return NULL; +} +/* JNI держит lock до записи: detach не может закрыть/reuse fd под отправителем. */ +static void wake(struct ble_backend* b) { + uint64_t value = 1; ssize_t n; + do { n = write(b->wake_fd, &value, sizeof(value)); } while (n < 0 && errno == EINTR); + if (n < 0 && errno != EAGAIN) DEBUG_ERROR(CAT, "BLE eventfd write failed: %s", strerror(errno)); +} +/* Одна ограниченная порция событий; eventfd будит uasync сразу, без задержки polling-таймера. */ +static void drain(int fd, void* arg) { + struct ble_backend* b = arg; + uint64_t value; ssize_t n; + do { n = read(fd, &value, sizeof(value)); } while (n < 0 && errno == EINTR); + if (n < 0 && errno != EAGAIN) { + DEBUG_ERROR(CAT, "BLE eventfd read failed: %s", strerror(errno)); + ble_backend_status(b->owner, 0, "eventfd read failed"); return; + } + for (int i = 0; i < EVENTS; i++) { + struct event e; int available, overflow; + pthread_mutex_lock(&lock); + available = b->count != 0; overflow = b->overflow; b->overflow = 0; + if (available) { e = b->events[b->head]; b->head = (b->head + 1) % EVENTS; b->count--; } + pthread_mutex_unlock(&lock); + if (overflow) { + pthread_mutex_lock(&lock); b->head = b->count = 0; pthread_mutex_unlock(&lock); + command(0, b->generation, 0, NULL, NULL, 0, 0); + ble_backend_status(b->owner, 0, "Android event mailbox full"); break; + } + if (!available) break; + struct android_link* l = by_token(b, e.token); + struct ble_endpoint ep; memcpy(ep.id, e.text, sizeof(ep.id)); + switch (e.type) { + case 1: ble_backend_status(b->owner, 1, "Android scan/GATT ready"); break; + case 2: + if (!l && e.token < 0) { + l = by_id(b, 0); + ble_link_id id = l ? ble_backend_incoming(b->owner, &ep) : 0; + if (!id) { command(4, b->generation, e.token, NULL, NULL, 0, 0); break; } + l->id = id; l->token = e.token; + } + if (l) ble_backend_opened(b->owner, l->id, e.arg); + break; + case 3: if (l) ble_backend_received(b->owner, l->id, e.data, e.len); break; + case 4: if (l) ble_backend_sent(b->owner, l->id, e.arg == 0); break; + case 5: if (l) ble_backend_closed(b->owner, l->id, e.text); break; + case 6: ble_backend_broadcast(b->owner, &ep, e.data, e.len, e.arg); break; + case 7: ble_backend_advertising(b->owner, e.arg ? BLE_CLOSED : BLE_READY, e.text); break; + case 8: ble_backend_status(b->owner, 0, e.text); break; + default: DEBUG_WARN(CAT, "BLE unknown Android event=%d", e.type); break; + } + } + pthread_mutex_lock(&lock); + if (b->count || b->overflow) wake(b); + pthread_mutex_unlock(&lock); +} +JNIEXPORT void JNICALL Java_com_utun_chat_data_BleRadio_nativeInstall(JNIEnv* env, jclass cls) { + if (radio_class) return; /* Application устанавливает один раз, до запуска worker. */ + if ((*env)->GetJavaVM(env, &vm) != JNI_OK) { DEBUG_ERROR(CAT, "BLE GetJavaVM failed"); return; } + command_method = (*env)->GetStaticMethodID(env, cls, "command", "(IJJLjava/lang/String;[BI)V"); + if (!command_method) { DEBUG_ERROR(CAT, "BLE command method unavailable"); return; } + radio_class = (*env)->NewGlobalRef(env, cls); + if (!radio_class) DEBUG_ERROR(CAT, "BLE global class allocation failed"); +} +JNIEXPORT void JNICALL Java_com_utun_chat_data_BleRadio_nativeEvent(JNIEnv* env, jclass cls, jlong gen, + jlong token, jint type, jint arg, jstring text, jbyteArray bytes) { + (void)cls; + struct event e = {.token = token, .type = type, .arg = arg}; + e.len = bytes ? (*env)->GetArrayLength(env, bytes) : 0; + if (e.len > 512) { DEBUG_ERROR(CAT, "BLE JNI oversized event=%d", e.len); return; } + if (e.len) (*env)->GetByteArrayRegion(env, bytes, 0, e.len, (jbyte*)e.data); + const char* s = text ? (*env)->GetStringUTFChars(env, text, NULL) : NULL; + if (s) { snprintf(e.text, sizeof(e.text), "%s", s); (*env)->ReleaseStringUTFChars(env, text, s); } + if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); DEBUG_ERROR(CAT, "BLE JNI event copy failed"); return; } + pthread_mutex_lock(&lock); + if (active && active->generation == gen) { + if (active->count == EVENTS) active->overflow = 1; + else { active->events[(active->head + active->count) % EVENTS] = e; active->count++; } + wake(active); + } + pthread_mutex_unlock(&lock); +} +struct ble_backend* ble_backend_create(struct ble_transport* owner, struct UASYNC* ua, const struct ble_config* cfg) { + struct ble_backend* b = u_calloc(1, sizeof(*b)); + if (!b) { DEBUG_ERROR(CAT, "BLE Android allocation failed"); return NULL; } + b->owner = owner; b->ua = ua; + b->wake_fd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); + if (b->wake_fd < 0) { DEBUG_ERROR(CAT, "BLE eventfd create: %s", strerror(errno)); u_free(b); return NULL; } + pthread_mutex_lock(&lock); + if (active) { pthread_mutex_unlock(&lock); close(b->wake_fd); u_free(b); DEBUG_ERROR(CAT, "BLE Android already active"); return NULL; } + b->generation = ++generation; active = b; + pthread_mutex_unlock(&lock); + b->io = uasync_add_socket(ua, b->wake_fd, drain, NULL, drain, "android_ble_events", b); + if (!b->io) DEBUG_ERROR(CAT, "BLE eventfd registration failed"); + if (!b->io || command(1, b->generation, 0, NULL, NULL, 0, cfg->company_id) < 0) { + ble_backend_destroy(b); return NULL; + } + return b; +} +void ble_backend_destroy(struct ble_backend* b) { + if (!b) return; + pthread_mutex_lock(&lock); if (active == b) active = NULL; pthread_mutex_unlock(&lock); + command(0, b->generation, 0, NULL, NULL, 0, 0); + if (b->io) uasync_remove_socket(b->ua, b->io); + close(b->wake_fd); + u_free(b); +} +int ble_backend_advertise(struct ble_backend* b, const uint8_t* data, size_t len) { + return command(2, b->generation, 0, NULL, data, len, data != NULL); +} +int ble_backend_connect(struct ble_backend* b, ble_link_id id, const struct ble_endpoint* ep) { + struct android_link* l = by_id(b, 0); + if (!l) { DEBUG_WARN(CAT, "BLE Android link limit"); return BLE_ERROR; } + l->id = id; l->token = (jlong)id; + return command(3, b->generation, l->token, ep->id, NULL, 0, 0); +} +void ble_backend_close(struct ble_backend* b, ble_link_id id) { + struct android_link* l = by_id(b, id); if (!l) return; + command(4, b->generation, l->token, NULL, NULL, 0, 0); memset(l, 0, sizeof(*l)); +} +int ble_backend_send(struct ble_backend* b, ble_link_id id, const uint8_t* data, size_t len) { + struct android_link* l = by_id(b, id); + if (!l) { DEBUG_ERROR(CAT, "BLE Android send unknown link"); return BLE_ERROR; } + return command(5, b->generation, l->token, NULL, data, len, 0); +} +static void status(void* arg, enum ble_state state, const char* reason) { + (void)arg; + radio_ready = state == BLE_READY; + DEBUG_INFO(CAT, "BLE Android status=%d reason=%s", state, reason); + command(7, 0, 0, reason, NULL, 0, state); + if (state == BLE_READY && (policy & 2)) ble_advertise(transport, identity, identity_len); +} +static void advertising(void* arg, enum ble_state state, const char* reason) { + (void)arg; command(8, 0, 0, reason, NULL, 0, state); +} +static void broadcast(void* arg, const struct ble_endpoint* ep, const uint8_t* bytes, size_t len, int rssi) { + (void)arg; + struct ble_identity peer; + if (ble_identity_decode(&peer, bytes, len) < 0) return; + DEBUG_DEBUG(CAT, "BLE discovered node=%016llx name=%s rssi=%d endpoint=%s", + (unsigned long long)peer.node_id, peer.name, rssi, ep->id); + /* Только явно запрошенный instrumentation test; обычный транспорт не выбирает пиров. */ + if ((policy & 16) && peer.node_id == 0x5643424c45544553ULL) { + policy &= ~16; + ble_connect(transport, ep, peer.node_id); + } +} +static int validate(void* arg, ble_link_id link, uint64_t peer) { + (void)arg; DEBUG_INFO(CAT, "BLE validation allow link=%llu node=%016llx", (unsigned long long)link, (unsigned long long)peer); + return 1; +} +static void received(void* arg, ble_link_id link) { + (void)arg; + if (!(policy & 8)) return; + struct ll_entry* e; + while ((e = ble_rx_get(transport, link))) { + int result = ble_tx_put(transport, link, e); + if (result != BLE_OK) { + DEBUG_ERROR(CAT, "BLE test echo failed link=%llu result=%d", (unsigned long long)link, result); + queue_dgram_free(e); queue_entry_free(e); + } + } +} +struct ble_transport* android_ble_transport(void) { return transport; } +void android_ble_detach(void) { + if (transport) { ble_destroy(transport); transport = NULL; } +} +static void configure(void* arg) { + int flags = (int)(intptr_t)arg; + struct UTUN_INSTANCE* inst = instance_lite_get_instance(); + if (!inst) { DEBUG_WARN(CAT, "BLE configure cancelled: core stopped"); return; } + if (flags & 8) debug_set_category_level(CAT, DEBUG_LEVEL_DEBUG); + policy = flags; incoming_policy = !!(flags & 4); + if (transport && radio_ready && (flags & 1)) { + ble_set_incoming(transport, incoming_policy); + command(6, 0, 0, NULL, NULL, 0, incoming_policy); + ble_advertise(transport, (flags & 2) ? identity : NULL, (flags & 2) ? identity_len : 0); + return; + } + android_ble_detach(); + command(6, 0, 0, NULL, NULL, 0, incoming_policy); + if (!(flags & 1)) return; + struct ble_config cfg = {0}; cfg.node_id = inst->node_id; cfg.company_id = 0xffff; + cfg.callbacks.advertising = advertising; + cfg.callbacks.status = status; cfg.callbacks.broadcast = broadcast; cfg.callbacks.validate = validate; cfg.callbacks.received = received; + identity_len = ble_identity_encode(identity, cfg.node_id, inst->config->global.name); + if (identity_len < 0) return; + transport = ble_create(instance_lite_get_uasync(), &cfg); + if (transport) { ble_set_incoming(transport, incoming_policy); ble_start(transport); } +} +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_BleRadio_nativeConfigure(JNIEnv* env, jclass cls, jint flags) { + (void)env; (void)cls; + if (flags < 0 || flags > 31) { DEBUG_ERROR(CAT, "BLE invalid settings=%d", flags); return JNI_FALSE; } + struct posted_task* task = u_calloc(1, sizeof(*task)); + if (!task) { DEBUG_ERROR(CAT, "BLE settings allocation failed"); return JNI_FALSE; } + task->callback = configure; task->arg = (void*)(intptr_t)flags; + return instance_lite_post_frontend(task, CAT, "BLE") == 0; +} diff --git a/tools/chatgui-android/jni_bridge/android_ble.h b/tools/chatgui-android/jni_bridge/android_ble.h new file mode 100644 index 000000000..711c5ac34 --- /dev/null +++ b/tools/chatgui-android/jni_bridge/android_ble.h @@ -0,0 +1,10 @@ +/* Android BLE lifetime: detach вызывается в worker перед уничтожением instance/uasync. + * Радио живёт за JNI; транспорт и ll_queue принадлежат worker. */ +#ifndef ANDROID_BLE_H +#define ANDROID_BLE_H +struct ble_transport; +/* Заимствованный общий C handle либо NULL. Только поток ядра, действителен до detach. + * TX/RX — ble_tx_put/ble_rx_get; потребитель освобождает извлечённые ll_entry. */ +struct ble_transport* android_ble_transport(void); +void android_ble_detach(void); +#endif diff --git a/tools/chatgui-android/libutun_lite/instance_lite.c b/tools/chatgui-android/libutun_lite/instance_lite.c index cccffe8d8..13b0f11e1 100644 --- a/tools/chatgui-android/libutun_lite/instance_lite.c +++ b/tools/chatgui-android/libutun_lite/instance_lite.c @@ -308,7 +308,13 @@ static int attach_media(struct UTUN_INSTANCE* inst) { return 0; } +#ifdef __ANDROID__ +#include "../jni_bridge/android_ble.h" +#endif static void detach_media(struct UTUN_INSTANCE* inst) { +#ifdef __ANDROID__ + android_ble_detach(); +#endif #ifdef UTUN_WITH_VOICE call_audio_destroy(inst); #endif @@ -695,7 +701,7 @@ void instance_lite_restart(const char* new_config_text) { /* Принимает владение ненулевым task при любом результате. Все данные команды находятся в том же выделении * (task — первое поле) либо являются скалярами: uasync/free не должны отдельно освобождать arg. * Не заимствует instance; trampoline берёт его только в worker, пока тот исполняет свою очередь. */ -static int post_frontend_task(struct posted_task* task, debug_category_t category, const char* name) { +int instance_lite_post_frontend(struct posted_task* task, debug_category_t category, const char* name) { if (g_is_worker) { u_free(task); DEBUG_ERROR(category, "Android %s request refused: frontend thread required", name); return -1; @@ -731,7 +737,7 @@ int instance_lite_set_active(int active) { } task->callback = set_active_trampoline; task->arg = (void*)(intptr_t)!!active; - return post_frontend_task(task, DEBUG_CATEGORY_SYS, "activity"); + return instance_lite_post_frontend(task, DEBUG_CATEGORY_SYS, "activity"); } /* posted — первое поле: uasync освобождает всё выделение и после callback, и при отмене очереди. */ @@ -762,7 +768,7 @@ int instance_lite_set_phone_state(int state, unsigned policy) { } request->state = state; request->policy = policy; request->posted.callback = set_phone_state_trampoline; request->posted.arg = request; - return post_frontend_task(&request->posted, DEBUG_CATEGORY_CALL, "phone state"); + return instance_lite_post_frontend(&request->posted, DEBUG_CATEGORY_CALL, "phone state"); } struct radio_config_request { @@ -808,7 +814,7 @@ int instance_lite_configure_radio(int64_t request_id, const int* values, instanc req->request_id = request_id; req->configured = configured; memcpy(req->values, values, sizeof(req->values)); req->task.callback = radio_config_apply; req->task.arg = req; - return post_frontend_task(&req->task, DEBUG_CATEGORY_RADIO, "radio config"); + return instance_lite_post_frontend(&req->task, DEBUG_CATEGORY_RADIO, "radio config"); } /* ── Лёгкое обновление сокетов (auto_sockets=android) ── */ diff --git a/tools/chatgui-android/libutun_lite/instance_lite.h b/tools/chatgui-android/libutun_lite/instance_lite.h index 20dd46264..ac36ae854 100644 --- a/tools/chatgui-android/libutun_lite/instance_lite.h +++ b/tools/chatgui-android/libutun_lite/instance_lite.h @@ -17,6 +17,11 @@ extern "C" { #endif struct UASYNC; +struct posted_task; +#include "debug_config.h" +/* Frontend → worker; принимает владение task при любом результате. Arg внутри task либо скаляр. + * При stop/restart задача отменяется. 0 = поставлена; -1 = отказ с логом. */ +int instance_lite_post_frontend(struct posted_task* task, debug_category_t category, const char* name); struct UTUN_INSTANCE; struct utun_config; diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 54ebf8bcf..233d83cec 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -128,6 +128,14 @@ endif() # uTun core library (C, built from ../src/ and ../lib/) add_subdirectory(libutun) +add_subdirectory("${CMAKE_CURRENT_SOURCE_DIR}/../../lib/ble" "${CMAKE_CURRENT_BINARY_DIR}/ble") +add_library(chatgui_ble STATIC transport/desktop_ble.cpp) +target_link_libraries(chatgui_ble PUBLIC ${QT_CORE} vibechat_ble) +add_executable(test_desktop_ble tests/test_desktop_ble.cpp transport/desktop_ble.cpp) +target_link_libraries(test_desktop_ble PRIVATE ${QT_CORE} ${QT_TEST_LIB} vibechat_ble_core) +add_test(NAME test_desktop_ble COMMAND test_desktop_ble) +add_executable(ble_desktop_probe tests/ble_desktop_probe.cpp) +target_link_libraries(ble_desktop_probe PRIVATE chatgui_ble) # Ручная проверка codec backend перед включением в capabilities звонка; не часть ctest. if(PkgConfig_FOUND) @@ -316,6 +324,7 @@ add_executable(vibechat WIN32 src/joindialog.cpp src/settingsdialog.cpp src/networksettingspage.cpp + src/bluetoothsettingspage.cpp src/databasesettingspage.cpp src/statuspage.cpp src/nodespage.cpp @@ -446,7 +455,7 @@ if(TARGET X11::Xss) elseif(UNIX AND NOT APPLE) message(STATUS "X11 screen saver library not found: X11 activity uses ChatGUI input") endif() -target_link_libraries(vibechat PRIVATE chatgui_notifications chatgui_stream_publish chatgui_preview_cache) +target_link_libraries(vibechat PRIVATE chatgui_notifications chatgui_stream_publish chatgui_preview_cache chatgui_ble) if(WIN32) target_link_libraries(vibechat PRIVATE ${QT_LIBS} rlottie::rlottie ZLIB::ZLIB OpenSSL::Crypto ZXing::ZXing utun utun_voice pthread user32 ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES}) @@ -786,24 +795,24 @@ add_executable(test_radio_member_mute tests/test_radio_member_mute.cpp) target_include_directories(test_radio_member_mute PRIVATE ${UTUN_INCLUDE_DIRS}) target_link_libraries(test_radio_member_mute PRIVATE ${QT_CORE} utun_voice utun pthread) add_test(NAME test_radio_member_mute COMMAND test_radio_member_mute) -set(RADIO_MEMBER_TEST_SOURCES src/memberlistmodel.cpp src/avatars/avatarcache.cpp src/avatars/avatarmanager.cpp +set(RADIO_MEMBER_TEST_SOURCES src/memberlistmodel.cpp src/flagpainter.cpp src/avatars/avatarcache.cpp src/avatars/avatarmanager.cpp src/avatars/avatarcrop.cpp src/avatars/avatargallery.cpp src/imageviewer_window.cpp db/db_manager.cpp) add_executable(test_member_detail tests/test_member_detail.cpp src/accountlist.cpp src/accountdelegate.cpp - src/flagpainter.cpp src/memberpropsdialog.cpp src/renamedialog.cpp ${RADIO_MEMBER_TEST_SOURCES}) + src/memberpropsdialog.cpp src/renamedialog.cpp ${RADIO_MEMBER_TEST_SOURCES}) target_include_directories(test_member_detail PRIVATE ${UTUN_INCLUDE_DIRS}) target_link_libraries(test_member_detail PRIVATE ${QT_LIBS} ${QT_TEST_LIB} utun_voice utun pthread) add_test(NAME test_member_detail COMMAND test_member_detail) set_tests_properties(test_member_detail PROPERTIES TIMEOUT 20 ENVIRONMENT "QT_QPA_PLATFORM=offscreen") add_executable(test_radio_mute_bridge tests/test_radio_mute_bridge.cpp transport/utun_node.cpp transport/gui_bridge_impl.cpp transport/stream_bridge.cpp - src/accountdelegate.cpp src/flagpainter.cpp ${RADIO_MEMBER_TEST_SOURCES}) + src/accountdelegate.cpp ${RADIO_MEMBER_TEST_SOURCES}) target_include_directories(test_radio_mute_bridge PRIVATE ${UTUN_INCLUDE_DIRS}) -target_link_libraries(test_radio_mute_bridge PRIVATE ${QT_LIBS} ${QT_TEST_LIB} utun_voice utun pthread) +target_link_libraries(test_radio_mute_bridge PRIVATE ${QT_LIBS} ${QT_TEST_LIB} utun_voice utun pthread chatgui_ble) add_test(NAME test_radio_mute_bridge COMMAND test_radio_mute_bridge) set_tests_properties(test_radio_mute_bridge PROPERTIES TIMEOUT 15 ENVIRONMENT "QT_QPA_PLATFORM=offscreen") add_executable(test_utun_node tests/test_utun_node.cpp src/statuspage.cpp transport/utun_node.cpp transport/gui_bridge_impl.cpp transport/stream_bridge.cpp) target_include_directories(test_utun_node PRIVATE ${UTUN_INCLUDE_DIRS}) -target_link_libraries(test_utun_node PRIVATE ${QT_LIBS} ${QT_TEST_LIB} utun_voice utun pthread) +target_link_libraries(test_utun_node PRIVATE ${QT_LIBS} ${QT_TEST_LIB} utun_voice utun pthread chatgui_ble) if(CMAKE_SYSTEM_NAME STREQUAL "Linux") target_compile_definitions(test_utun_node PRIVATE UTUN_TEST_NODE_STREAM_FAILURES) target_link_options(test_utun_node PRIVATE "-Wl,--wrap=stream_publisher_create" "-Wl,--wrap=stream_viewers_create") diff --git a/tools/chatgui/src/bluetoothsettingspage.cpp b/tools/chatgui/src/bluetoothsettingspage.cpp new file mode 100644 index 000000000..41abce1ec --- /dev/null +++ b/tools/chatgui/src/bluetoothsettingspage.cpp @@ -0,0 +1,60 @@ +#include "bluetoothsettingspage.h" +#include "../transport/utun_node.h" +#include "../transport/config_updater.h" +#include "../transport/node_config.h" +#include +#include +#include +#include +#include +#include + +BluetoothSettingsPage::BluetoothSettingsPage(const QString& path, UtunNode* node, QWidget* parent) + : QWidget(parent), m_path(path), m_node(node) { + auto* layout = new QVBoxLayout(this); + m_enabled = new QCheckBox(QCoreApplication::translate("ChatGui", "Enable Bluetooth"), this); + m_advertise = new QCheckBox(QCoreApplication::translate("ChatGui", "Advertise this device"), this); + m_incoming = new QCheckBox(QCoreApplication::translate("ChatGui", "Allow incoming Bluetooth connections"), this); + m_enabled->setObjectName("bluetoothEnabled"); m_advertise->setObjectName("bluetoothAdvertise"); + m_incoming->setObjectName("bluetoothIncoming"); + NodeConfig config(path); + const int flags = config.exists() && config.load() ? config.bluetoothFlags() : 0; + m_enabled->setChecked(flags & 1); m_advertise->setChecked(flags & 2); m_incoming->setChecked(flags & 4); + auto permissions = [this](bool enabled) { m_advertise->setEnabled(enabled); m_incoming->setEnabled(enabled); }; + connect(m_enabled, &QCheckBox::toggled, this, permissions); permissions(flags & 1); + layout->addWidget(m_enabled); layout->addWidget(m_advertise); layout->addWidget(m_incoming); + auto* note = new QLabel(QCoreApplication::translate("ChatGui", + "Nearby VibeChat devices can see your node ID and name when advertising is enabled. Bluetooth must also be enabled in system settings."), this); + note->setWordWrap(true); layout->addWidget(note); + m_status = new QLabel(this); m_status->setWordWrap(true); m_status->setTextFormat(Qt::PlainText); + m_advertisingStatus = new QLabel(this); m_advertisingStatus->setWordWrap(true); m_advertisingStatus->setTextFormat(Qt::PlainText); + layout->addWidget(m_status); layout->addWidget(m_advertisingStatus); + layout->addWidget(new QLabel(QCoreApplication::translate("ChatGui", "Nearby VibeChat devices:"), this)); + m_peers = new QListWidget(this); layout->addWidget(m_peers, 1); + auto* timer = new QTimer(this); + connect(timer, &QTimer::timeout, this, [this] { refresh(); }); timer->start(1000); + refresh(); +} + +void BluetoothSettingsPage::refresh() { + const auto state = m_node ? m_node->bluetoothSnapshot() : DesktopBleSnapshot{}; + QString status; + if (!m_node || !m_node->isRunning()) status = QCoreApplication::translate("ChatGui", "Bluetooth: node is not running"); + else if (!(state.flags & 1)) status = QCoreApplication::translate("ChatGui", "Bluetooth is off"); + else if (state.state == BLE_STARTING) status = QCoreApplication::translate("ChatGui", "Starting Bluetooth..."); + else if (state.state == BLE_READY) + status = QCoreApplication::translate("ChatGui", "Bluetooth is ready. Connections: %1").arg(state.connections); + else status = QCoreApplication::translate("ChatGui", "Bluetooth is unavailable: %1").arg(state.reason); + m_status->setText(status); + m_advertisingStatus->setVisible((state.flags & 3) == 3); + m_advertisingStatus->setText(state.advertising == BLE_READY ? QCoreApplication::translate("ChatGui", "Advertising is active") : + QCoreApplication::translate("ChatGui", "Advertising: %1").arg(state.advertisingReason)); + m_peers->clear(); m_peers->addItems(state.peers); +} + +bool BluetoothSettingsPage::save() { + const int flags = (m_enabled->isChecked() ? 1 : 0) | (m_advertise->isChecked() ? 2 : 0) | (m_incoming->isChecked() ? 4 : 0); + if (!config_update_option(m_path, "gui", "bluetooth_flags", QString::number(flags))) return false; + if (m_node && m_node->isRunning()) m_node->setBluetoothFlags(flags); + return true; +} diff --git a/tools/chatgui/src/bluetoothsettingspage.h b/tools/chatgui/src/bluetoothsettingspage.h new file mode 100644 index 000000000..d8cbe3d69 --- /dev/null +++ b/tools/chatgui/src/bluetoothsettingspage.h @@ -0,0 +1,22 @@ +/* Черновик разрешений desktop BLE, сохранение INI и применение через UtunNode. + * Состояние радио и найденные узлы читаются как копия раз в секунду. */ +#pragma once +#include +#include + +class UtunNode; +class QCheckBox; +class QLabel; +class QListWidget; +class BluetoothSettingsPage : public QWidget { +public: + BluetoothSettingsPage(const QString& path, UtunNode* node, QWidget* parent = nullptr); + bool save(); // false при ошибке записи; причина залогирована, диалог показывает вызывающий. +private: + void refresh(); + QString m_path; + QPointer m_node; + QCheckBox *m_enabled, *m_advertise, *m_incoming; + QLabel *m_status, *m_advertisingStatus; + QListWidget* m_peers; +}; diff --git a/tools/chatgui/src/main.cpp b/tools/chatgui/src/main.cpp index c0524d3e5..2865662da 100644 --- a/tools/chatgui/src/main.cpp +++ b/tools/chatgui/src/main.cpp @@ -25,7 +25,6 @@ #include "desktop_integration.h" #include "gui_log.h" #include "config_parser.h" -#include "../../../src/config_updater.h" #include #include #include @@ -170,6 +169,14 @@ int main(int argc, char *argv[]) { return 1; } + /* Ошибка подготовки INI возможна ещё до чтения debug_file из профиля. */ + const QByteArray startupLogPath = (cfgDir + "/chatgui.log").toUtf8(); + debug_enable_file_output(startupLogPath.constData(), 0); + DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "GUI startup: version=%s config_directory=%s", qPrintable(app.applicationVersion()), qPrintable(cfgDir)); +#ifdef _WIN32 + DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "Windows storage selection: %s", qPrintable(storageReason)); +#endif + app.setStyle(QStyleFactory::create("Fusion")); QPalette p; p.setColor(QPalette::Window, QColor(32, 32, 32)); @@ -200,10 +207,11 @@ int main(int argc, char *argv[]) { QFile::rename(cfgDir + "/chatgui.cfg", cfgPath); NodeConfig cfg(cfgPath); - if (cfg.exists() && (config_ensure_keys_and_node_id(cfgPath.toUtf8().constData()) != 0 || !cfg.load())) { + if (cfg.exists() && !cfg.prepare()) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Cannot prepare/read GUI configuration: %s", qPrintable(cfgPath)); QMessageBox failure(QMessageBox::Critical, "vibechat", QCoreApplication::translate("ChatGui", "Invalid configuration:\n%1").arg(cfgPath), QMessageBox::Ok); + failure.setInformativeText(cfg.errors().join('\n')); instance.setWindow(&failure); failure.exec(); instance.setWindow(nullptr); diff --git a/tools/chatgui/src/settingsdialog.cpp b/tools/chatgui/src/settingsdialog.cpp index f2d966dcd..32fbaf600 100644 --- a/tools/chatgui/src/settingsdialog.cpp +++ b/tools/chatgui/src/settingsdialog.cpp @@ -2,6 +2,7 @@ // settingsdialog.cpp — Settings dialog with category list #include "settingsdialog.h" #include "networksettingspage.h" +#include "bluetoothsettingspage.h" #include "databasesettingspage.h" #include "statuspage.h" #include "nodespage.h" @@ -76,6 +77,7 @@ SettingsDialog::SettingsDialog(const QString& configPath, DbManager* db, UtunNod m_categoryList->addItem(QCoreApplication::translate("ChatGui", "Audio")); m_categoryList->addItem(QCoreApplication::translate("ChatGui", "Radio")); m_categoryList->addItem(QCoreApplication::translate("ChatGui", "Broadcast")); + m_categoryList->addItem(QCoreApplication::translate("ChatGui", "Bluetooth")); mainLayout->addWidget(m_categoryList); // --- Right panel: stacked pages --- @@ -264,6 +266,8 @@ SettingsDialog::SettingsDialog(const QString& configPath, DbManager* db, UtunNod streamNote->setWordWrap(true); streamForm->addRow(streamNote); m_pages->addWidget(streamPage); + m_bluetoothPage = new BluetoothSettingsPage(m_configPath, node, m_pages); + m_pages->addWidget(m_bluetoothPage); rightLayout->addWidget(m_pages, 1); // --- Bottom buttons --- @@ -342,6 +346,11 @@ void SettingsDialog::onCategoryChanged(int row) { } void SettingsDialog::onSave() { + if (!m_bluetoothPage->save()) { + QMessageBox::warning(this, QCoreApplication::translate("ChatGui", "Settings"), + QCoreApplication::translate("ChatGui", "Failed to save Bluetooth settings. Check the configuration file.")); + return; + } // Меняем только разрешение публикации, сохраняя остальные настройки входящих вызовов. QSettings callConfig(m_configPath, QSettings::IniFormat); int callPolicy = callConfig.value("chatserver/incoming_call_policy", CALL_POLICY_PUBLISH_PHONE).toInt(); diff --git a/tools/chatgui/src/settingsdialog.h b/tools/chatgui/src/settingsdialog.h index 19fab0565..a043861be 100644 --- a/tools/chatgui/src/settingsdialog.h +++ b/tools/chatgui/src/settingsdialog.h @@ -14,6 +14,7 @@ #include class QSpinBox; +class BluetoothSettingsPage; class NetworkSettingsPage; class DatabaseSettingsPage; class StatusPage; @@ -66,4 +67,5 @@ private: AudioDeviceSettingsPage* m_audioDevicePage; // Выбор устройств/аудиопараметров и тесты микрофона/динамика. StorageSettingsPage* m_storagePage; // Черновик автозагрузки и лимитов media-хранилища. RadioSettingsPage* m_radioPage; // Черновик активации рации и интерфейс PTT-хоткеев. + BluetoothSettingsPage* m_bluetoothPage; }; diff --git a/tools/chatgui/tests/ble_desktop_probe.cpp b/tools/chatgui/tests/ble_desktop_probe.cpp new file mode 100644 index 000000000..f68058306 --- /dev/null +++ b/tools/chatgui/tests/ble_desktop_probe.cpp @@ -0,0 +1,34 @@ +/* Ручная проверка desktop frontend на настоящем радио с Android BlePeerProbe initiator=true. */ +#include "../transport/desktop_ble.h" +#include "../../../lib/u_async.h" +#include "../../../lib/debug_config.h" +#include +#include +#include + +int main(int argc, char** argv) { + QCoreApplication app(argc, argv); + debug_config_init(); debug_set_level(DEBUG_LEVEL_INFO); + debug_set_category_level(DEBUG_CATEGORY_DISCOVERY, DEBUG_LEVEL_DEBUG); + UASYNC* ua = uasync_create(); + if (!ua) return 1; + bool ready = false, advertised = false, discovered = false, connected = false; + { + DesktopBle radio(ua, UINT64_C(0x5643424c45544553)); + radio.apply(7, "DesktopGUI"); + QElapsedTimer timer; timer.start(); + while (timer.elapsed() < 45000 && !(ready && advertised && discovered && connected)) { + uasync_poll(ua, 100); + const auto state = radio.snapshot(); + ready |= state.state == BLE_READY; + advertised |= state.advertising == BLE_READY; + discovered |= !state.peers.isEmpty(); + connected |= state.connections > 0; + } + radio.apply(0, "DesktopGUI"); + if (radio.transport() || radio.snapshot().state != BLE_CLOSED) return 1; + } + uasync_destroy(ua, 0); + printf("RESULT desktop_frontend ready=%d advertised=%d discovered=%d connected=%d\n", ready, advertised, discovered, connected); + return ready && advertised && discovered && connected ? 0 : 1; +} diff --git a/tools/chatgui/tests/test_desktop_ble.cpp b/tools/chatgui/tests/test_desktop_ble.cpp new file mode 100644 index 000000000..89bf9418e --- /dev/null +++ b/tools/chatgui/tests/test_desktop_ble.cpp @@ -0,0 +1,62 @@ +/* Настоящий frontend и C-транспорт, управляемый backend без радио/системной шины. */ +#include "../transport/desktop_ble.h" +#include "../../../lib/ble/ble_backend.h" +#include "../../../lib/ble/ble_identity.h" +#include "../../../lib/u_async.h" +#include "../../../lib/debug_config.h" +#include +#include + +struct ble_backend { ble_transport* owner = nullptr; }; +static ble_backend backend; +static int creations, destructions, advertisements; +static QByteArray advertised; +extern "C" ble_backend* ble_backend_create(ble_transport* owner, UASYNC*, const ble_config*) { + backend.owner = owner; ++creations; return &backend; +} +extern "C" void ble_backend_destroy(ble_backend*) { backend.owner = nullptr; ++destructions; } +extern "C" int ble_backend_advertise(ble_backend*, const uint8_t* data, size_t len) { + advertised = QByteArray(reinterpret_cast(data), int(len)); ++advertisements; return 0; +} +extern "C" int ble_backend_connect(ble_backend*, ble_link_id, const ble_endpoint*) { return BLE_ERROR; } +extern "C" void ble_backend_close(ble_backend*, ble_link_id) {} +extern "C" int ble_backend_send(ble_backend*, ble_link_id, const uint8_t*, size_t) { return BLE_ERROR; } + +class DesktopBleTest : public QObject { + Q_OBJECT +private slots: + void policyAndDiscovery() { + debug_config_init(); + auto destroy = [](UASYNC* ua) { uasync_destroy(ua, 0); }; + std::unique_ptr ua(uasync_create(), destroy); QVERIFY(ua); + DesktopBle radio(ua.get(), 1); + radio.apply(0, "Desktop"); QVERIFY(!radio.transport()); QCOMPARE(creations, 0); + radio.apply(3, "Desktop"); QVERIFY(radio.transport()); QCOMPARE(creations, 1); + QCOMPARE(radio.snapshot().state, BLE_STARTING); QCOMPARE(advertisements, 0); + ble_endpoint endpoint{}; strcpy(endpoint.id, "peer"); + QCOMPARE(ble_backend_incoming(backend.owner, &endpoint), ble_link_id(0)); + ble_backend_status(backend.owner, 1, "ready"); QCOMPARE(advertisements, 1); + ble_identity identity{}; + QCOMPARE(ble_identity_decode(&identity, reinterpret_cast(advertised.constData()), advertised.size()), 0); + QCOMPARE(identity.node_id, uint64_t(1)); QCOMPARE(QByteArray(identity.name), QByteArray("Desktop")); + radio.apply(3, "Desktop"); QCOMPARE(advertisements, 1); + radio.apply(7, "Renamed"); QCOMPARE(advertisements, 2); + QVERIFY(ble_backend_incoming(backend.owner, &endpoint) != 0); + uint8_t bytes[24]; int length = ble_identity_encode(bytes, 2, "Android"); + ble_backend_broadcast(backend.owner, &endpoint, bytes, length, -42); + const auto peers = radio.snapshot().peers; + QCOMPARE(peers.size(), 1); QVERIFY(peers.first().contains("Android")); QVERIFY(peers.first().contains("-42")); + ble_backend_broadcast(backend.owner, &endpoint, bytes, length, -40); + QCOMPARE(radio.snapshot().peers.size(), 1); + radio.apply(1, "Renamed"); QVERIFY(advertised.isEmpty()); + QCOMPARE(ble_backend_incoming(backend.owner, &endpoint), ble_link_id(0)); + radio.apply(0, "Renamed"); QVERIFY(!radio.transport()); QCOMPARE(destructions, 1); + QVERIFY(radio.snapshot().peers.isEmpty()); QCOMPARE(radio.snapshot().state, BLE_CLOSED); + radio.apply(1, "Renamed"); QCOMPARE(creations, 2); + ble_backend_status(backend.owner, 0, "adapter is off"); + QCOMPARE(radio.snapshot().reason, QString("adapter is off")); + radio.apply(0, "Renamed"); QCOMPARE(destructions, 2); + } +}; +QTEST_GUILESS_MAIN(DesktopBleTest) +#include "test_desktop_ble.moc" diff --git a/tools/chatgui/tests/test_node_config.cpp b/tools/chatgui/tests/test_node_config.cpp index 7fd81293f..8ec8e2c19 100644 --- a/tools/chatgui/tests/test_node_config.cpp +++ b/tools/chatgui/tests/test_node_config.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #ifdef Q_OS_UNIX #include #include @@ -12,6 +13,7 @@ extern "C" { #include "config_parser.h" +#include "config_error.h" #include "chat/chat_setting.h" #include "socket_compat.h" #include "debug_config.h" @@ -35,6 +37,52 @@ using Config = std::unique_ptr; class NodeConfigTest : public QObject { Q_OBJECT private slots: + void startupPreparesMissingServerKey() { + QTemporaryDir directory; QVERIFY(directory.isValid()); + const QString path = directory.filePath("startup.cfg"); + NodeConfig config(path); QVERIFY(config.create("Startup")); + const QString privateKey = config.privKey(), nodeId = config.nodeId(); + QVERIFY(config_update_option(path, "chatserver", "join_policy", "1")); + QProcess child; + child.start(QCoreApplication::applicationFilePath(), {"--prepare", path}); + QVERIFY(child.waitForFinished(10000)); + QCOMPARE(child.exitStatus(), QProcess::NormalExit); + QVERIFY2(child.exitCode() == 0, child.readAllStandardError().constData()); + QVERIFY(config.load()); + QCOMPARE(config.privKey(), privateKey); QCOMPARE(config.nodeId(), nodeId); + Config parsed(parse_config(qPrintable(path)), free_config); QVERIFY(parsed); + QVERIFY(parsed->global.chatserver_has_add_key); + QFile file(path); QVERIFY(file.open(QIODevice::ReadOnly)); + const auto prepared = file.readAll(); file.close(); + QVERIFY(config.prepare()); + QVERIFY(file.open(QIODevice::ReadOnly)); QCOMPARE(file.readAll(), prepared); + } + + void detailedErrorsSurviveDisabledLogging() { + debug_set_level(DEBUG_LEVEL_NONE); + const QByteArray invalid("[control]\nport=70000\n"); + Config parsed(parse_config_from_buf(invalid.constData(), size_t(invalid.size()), "bad.cfg"), free_config); + QVERIFY(!parsed); + const QByteArray error(config_error_text()); + QVERIFY(error.contains("65535")); QVERIFY(error.contains("bad.cfg:2")); QVERIFY(error.contains("port")); + std::thread worker([] { config_error_clear(); config_error_append("other thread"); }); + worker.join(); QCOMPARE(QByteArray(config_error_text()), error); + parsed.reset(parse_config_from_buf("", 0, "valid.cfg")); QVERIFY(parsed); + QVERIFY(QByteArray(config_error_text()).isEmpty()); + debug_set_level(DEBUG_LEVEL_INFO); + } + + void failedPreparationExplainsFieldWithoutLeakingKeys() { + QTemporaryDir directory; QVERIFY(directory.isValid()); + const QString path = directory.filePath("invalid.cfg"); + NodeConfig config(path); QVERIFY(config.create("Invalid")); + QVERIFY(config_update_option(path, "chatserver", "add_key", "private-invalid-secret")); + QVERIFY(!config.prepare()); + const QString reason = config.errors().join('\n'); + QVERIFY(reason.contains("add_key")); QVERIFY(reason.contains(path)); + QVERIFY(!reason.contains("private-invalid-secret")); + } + void identityFailureKeepsPreviousValues_data() { QTest::addColumn("nodeIdFailure"); QTest::newRow("keypair") << false; @@ -90,6 +138,7 @@ private slots: const QString path = directory.filePath("missing.cfg"); NodeConfig config(path); QVERIFY(config.create("Previous")); QVERIFY(QFile::remove(path)); QVERIFY(!config.load()); + QVERIFY(!config.errors().isEmpty()); QVERIFY(config.errors().join('\n').contains(path)); QCOMPARE(config.nodeName(), QString("Previous")); QVERIFY(config.nodeIdU64() != 0); } @@ -259,6 +308,12 @@ private slots: int main(int argc, char** argv) { QCoreApplication app(argc, argv); debug_config_init(); debug_set_level(DEBUG_LEVEL_INFO); + if (argc == 3 && !strcmp(argv[1], "--prepare")) { + NodeConfig config(QString::fromLocal8Bit(argv[2])); + if (config.prepare()) return 0; + fprintf(stderr, "%s\n", qPrintable(config.errors().join('\n'))); + return 1; + } if (socket_platform_init()) return 1; int result = 0; if (argc >= 3 && !strcmp(argv[1], "--validate")) { diff --git a/tools/chatgui/tests/test_settings_save.cpp b/tools/chatgui/tests/test_settings_save.cpp index 187bdbc8c..0b8c6e9e6 100644 --- a/tools/chatgui/tests/test_settings_save.cpp +++ b/tools/chatgui/tests/test_settings_save.cpp @@ -2,11 +2,13 @@ #include "../src/storagesettingspage.h" #include "../src/audiodevicesettingspage.h" #include "../src/settingsdialog.h" +#include "../src/bluetoothsettingspage.h" #include "../src/mainwindow.h" #include "../src/radio_audio_engine.h" #include "../src/voicemessageencoder.h" #include "../db/db_manager.h" #include "../transport/gui_bridge.h" +#include "../transport/node_config.h" #include #include #include @@ -165,6 +167,26 @@ class SettingsSaveTest : public QObject { } } private slots: + void bluetoothSettingsPersistAllPermissions() { + QTemporaryDir directory; QVERIFY(directory.isValid()); + const QString path = directory.filePath("bluetooth.cfg"); + BluetoothSettingsPage page(path, nullptr); + auto* enabled = page.findChild("bluetoothEnabled"); + auto* advertise = page.findChild("bluetoothAdvertise"); + auto* incoming = page.findChild("bluetoothIncoming"); + QVERIFY(enabled && advertise && incoming); + QVERIFY(!enabled->isChecked()); QVERIFY(!advertise->isEnabled()); + enabled->setChecked(true); advertise->setChecked(true); incoming->setChecked(true); + QVERIFY(page.save()); + NodeConfig config(path); QVERIFY(config.load()); QCOMPARE(config.bluetoothFlags(), 7); + BluetoothSettingsPage restored(path, nullptr); + QVERIFY(restored.findChild("bluetoothEnabled")->isChecked()); + QVERIFY(restored.findChild("bluetoothIncoming")->isChecked()); + enabled->setChecked(false); QVERIFY(page.save()); + QVERIFY(config.load()); QCOMPARE(config.bluetoothFlags(), 6); + BluetoothSettingsPage disabled(path, nullptr); + QVERIFY(!disabled.findChild("bluetoothEnabled")->isChecked()); + } void initTestCase() { debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); gui_bridge_init(); debug_set_category_level(DEBUG_CATEGORY_CONFIG, DEBUG_LEVEL_DEBUG); diff --git a/tools/chatgui/translations/chatgui_en.ts b/tools/chatgui/translations/chatgui_en.ts index 02664af70..2761f1fd1 100644 --- a/tools/chatgui/translations/chatgui_en.ts +++ b/tools/chatgui/translations/chatgui_en.ts @@ -2691,5 +2691,61 @@ See log: Could not delete the conversation. Please try again. Could not delete the conversation. Please try again. + + Bluetooth + Bluetooth + + + Enable Bluetooth + Enable Bluetooth + + + Advertise this device + Advertise this device + + + Allow incoming Bluetooth connections + Allow incoming Bluetooth connections + + + Nearby VibeChat devices can see your node ID and name when advertising is enabled. Bluetooth must also be enabled in system settings. + Nearby VibeChat devices can see your node ID and name when advertising is enabled. Bluetooth must also be enabled in system settings. + + + Nearby VibeChat devices: + Nearby VibeChat devices: + + + Bluetooth: node is not running + Bluetooth: node is not running + + + Bluetooth is off + Bluetooth is off + + + Starting Bluetooth... + Starting Bluetooth... + + + Bluetooth is ready. Connections: %1 + Bluetooth is ready. Connections: %1 + + + Bluetooth is unavailable: %1 + Bluetooth is unavailable: %1 + + + Advertising is active + Advertising is active + + + Advertising: %1 + Advertising: %1 + + + Failed to save Bluetooth settings. Check the configuration file. + Failed to save Bluetooth settings. Check the configuration file. + diff --git a/tools/chatgui/translations/chatgui_ru.ts b/tools/chatgui/translations/chatgui_ru.ts index 62cd48f6e..24215f8df 100644 --- a/tools/chatgui/translations/chatgui_ru.ts +++ b/tools/chatgui/translations/chatgui_ru.ts @@ -2700,5 +2700,61 @@ See log: Could not delete the conversation. Please try again. Не удалось удалить переписку. Повторите попытку. + + Bluetooth + Bluetooth + + + Enable Bluetooth + Включить Bluetooth + + + Advertise this device + Рекламировать этот узел + + + Allow incoming Bluetooth connections + Разрешить входящие подключения Bluetooth + + + Nearby VibeChat devices can see your node ID and name when advertising is enabled. Bluetooth must also be enabled in system settings. + При включённой рекламе устройства VibeChat поблизости видят ID и имя вашего узла. Bluetooth также должен быть включён в настройках системы. + + + Nearby VibeChat devices: + Устройства VibeChat поблизости: + + + Bluetooth: node is not running + Bluetooth: узел не запущен + + + Bluetooth is off + Bluetooth выключен + + + Starting Bluetooth... + Запуск Bluetooth... + + + Bluetooth is ready. Connections: %1 + Bluetooth готов. Подключений: %1 + + + Bluetooth is unavailable: %1 + Bluetooth недоступен: %1 + + + Advertising is active + Реклама активна + + + Advertising: %1 + Реклама: %1 + + + Failed to save Bluetooth settings. Check the configuration file. + Не удалось сохранить настройки Bluetooth. Проверьте файл конфигурации. + diff --git a/tools/chatgui/transport/config_updater.cpp b/tools/chatgui/transport/config_updater.cpp index d1529e27e..d0027c62e 100644 --- a/tools/chatgui/transport/config_updater.cpp +++ b/tools/chatgui/transport/config_updater.cpp @@ -77,21 +77,22 @@ bool config_update_in_lines(QStringList& lines, const QString& section, /* ENOENT допускает создание; ошибки чтения и непредставимый UTF-8 запрещают перезапись. */ bool config_read_lines(const QString& path, QStringList& lines, bool allowMissing) { + config_error_clear(); QFile input(path); const QFileInfo info(path); if (allowMissing && !info.exists() && !info.isSymLink()) { lines.clear(); return true; } if (!input.open(QIODevice::ReadOnly)) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config read open failed path=%s: %s", qPrintable(path), qPrintable(input.errorString())); + CONFIG_ERROR("Cannot open configuration %s: %s", qPrintable(path), qPrintable(input.errorString())); return false; } const QByteArray data = input.readAll(); if (input.error() != QFileDevice::NoError) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config read failed path=%s: %s", qPrintable(path), qPrintable(input.errorString())); + CONFIG_ERROR("Cannot read configuration %s: %s", qPrintable(path), qPrintable(input.errorString())); return false; } const QString text = QString::fromUtf8(data); if (text.toUtf8() != data) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config rejected non-UTF-8 text path=%s", qPrintable(path)); + CONFIG_ERROR("%s: configuration text is not valid UTF-8", qPrintable(path)); return false; } if (config_ini_validate(data.constData(), size_t(data.size()), qPrintable(path))) return false; diff --git a/tools/chatgui/transport/desktop_ble.cpp b/tools/chatgui/transport/desktop_ble.cpp new file mode 100644 index 000000000..bac9acf55 --- /dev/null +++ b/tools/chatgui/transport/desktop_ble.cpp @@ -0,0 +1,111 @@ +#include "desktop_ble.h" +#include "../../../lib/ble/ble_identity.h" +#include "../../../lib/debug_config.h" + +DesktopBle::DesktopBle(UASYNC* ua, uint64_t nodeId) : m_ua(ua), m_nodeId(nodeId) { m_clock.start(); } +DesktopBle::~DesktopBle() { ble_destroy(m_transport); } + +void DesktopBle::advertise() { + if (m_snapshot.state != BLE_READY) return; + const bool enabled = m_snapshot.flags & 2; + if (ble_advertise(m_transport, enabled ? m_identity.constData() : nullptr, enabled ? size_t(m_identity.size()) : 0)) { + m_snapshot.advertising = BLE_CLOSED; + m_snapshot.advertisingReason = QStringLiteral("Could not publish Bluetooth advertisement"); + DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "BLE desktop advertisement request failed"); + } +} + +void DesktopBle::apply(int flags, const char* name) { + const QByteArray nextName(name); + if (flags == m_snapshot.flags && nextName == m_name) return; + const int previous = m_snapshot.flags; + m_snapshot.flags = flags; + m_name = nextName; + DEBUG_INFO(DEBUG_CATEGORY_DISCOVERY, "BLE desktop settings: enabled=%d advertise=%d incoming=%d", + !!(flags & 1), !!(flags & 2), !!(flags & 4)); + if (!(flags & 1)) { + ble_destroy(m_transport); m_transport = nullptr; + m_snapshot = {}; m_snapshot.flags = flags; + m_peers.clear(); m_connections.clear(); + return; + } + uint8_t bytes[BLE_BROADCAST_MAX]; + const int length = ble_identity_encode(bytes, m_nodeId, name); + if (length < 0) { + m_snapshot.reason = QStringLiteral("Invalid Bluetooth node identity"); + DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "BLE desktop identity encoding failed node=%016llx", (unsigned long long)m_nodeId); + return; + } + m_identity = QByteArray(reinterpret_cast(bytes), length); + if (m_transport) { + if ((flags ^ previous) & 4) ble_set_incoming(m_transport, !!(flags & 4)); + advertise(); + return; + } + ble_config config{}; + config.node_id = m_nodeId; config.company_id = 0xffff; config.arg = this; + config.callbacks.status = [](void* arg, ble_state state, const char* reason) { + auto* self = static_cast(arg); + self->m_snapshot.state = state; self->m_snapshot.reason = QString::fromUtf8(reason); + if (state == BLE_CLOSED) { + self->m_snapshot.advertising = BLE_CLOSED; + self->m_snapshot.advertisingReason = self->m_snapshot.reason; + } + DEBUG_INFO(DEBUG_CATEGORY_DISCOVERY, "BLE desktop state=%d reason=%s", state, reason); + if (state == BLE_READY) self->advertise(); + }; + config.callbacks.advertising = [](void* arg, ble_state state, const char* reason) { + auto* self = static_cast(arg); + self->m_snapshot.advertising = state; self->m_snapshot.advertisingReason = QString::fromUtf8(reason); + DEBUG_INFO(DEBUG_CATEGORY_DISCOVERY, "BLE desktop advertising=%d reason=%s", state, reason); + }; + config.callbacks.validate = [](void*, ble_link_id link, uint64_t node) { + DEBUG_INFO(DEBUG_CATEGORY_DISCOVERY, "BLE desktop validation allow link=%llu node=%016llx", + (unsigned long long)link, (unsigned long long)node); + return 1; + }; + config.callbacks.connection = [](void* arg, ble_link_id link, uint64_t node, ble_state state, const char* reason) { + auto* self = static_cast(arg); + if (state == BLE_READY) self->m_connections.insert(link, true); + else self->m_connections.remove(link); + DEBUG_INFO(DEBUG_CATEGORY_DISCOVERY, "BLE desktop connection link=%llu node=%016llx state=%d reason=%s", + (unsigned long long)link, (unsigned long long)node, state, reason); + }; + config.callbacks.broadcast = [](void* arg, const ble_endpoint*, const uint8_t* data, size_t length, int rssi) { + auto* self = static_cast(arg); + ble_identity peer{}; + if (ble_identity_decode(&peer, data, length) || peer.node_id == self->m_nodeId) return; + if (!self->m_peers.contains(peer.node_id)) { + if (self->m_peers.size() >= 64) return; + DEBUG_INFO(DEBUG_CATEGORY_DISCOVERY, "BLE desktop discovered node=%016llx name=%s rssi=%d", + (unsigned long long)peer.node_id, peer.name, rssi); + } + const QString text = QStringLiteral("%1 · %2 · %3 dBm").arg(QString::fromUtf8(peer.name), + QString::number(peer.node_id, 16).rightJustified(16, '0'), rssi == 127 ? QStringLiteral("?") : QString::number(rssi)); + self->m_peers.insert(peer.node_id, {text, self->m_clock.elapsed()}); + }; + m_snapshot.state = BLE_STARTING; m_snapshot.reason.clear(); + m_transport = ble_create(m_ua, &config); + if (!m_transport) { + m_snapshot.state = BLE_CLOSED; m_snapshot.reason = QStringLiteral("Could not create Bluetooth transport"); + DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "BLE desktop transport creation failed"); + return; + } + ble_set_incoming(m_transport, !!(flags & 4)); + if (ble_start(m_transport)) { + m_snapshot.state = BLE_CLOSED; + if (m_snapshot.reason.isEmpty()) m_snapshot.reason = QStringLiteral("Could not start Bluetooth transport"); + DEBUG_ERROR(DEBUG_CATEGORY_DISCOVERY, "BLE desktop start failed"); + } +} + +DesktopBleSnapshot DesktopBle::snapshot() { + const qint64 now = m_clock.elapsed(); + m_snapshot.peers.clear(); + for (auto it = m_peers.begin(); it != m_peers.end();) { + if (now - it->seen > 30000) it = m_peers.erase(it); + else { m_snapshot.peers.append(it->text); ++it; } + } + m_snapshot.connections = m_connections.size(); + return m_snapshot; +} diff --git a/tools/chatgui/transport/desktop_ble.h b/tools/chatgui/transport/desktop_ble.h new file mode 100644 index 000000000..75aeff7f2 --- /dev/null +++ b/tools/chatgui/transport/desktop_ble.h @@ -0,0 +1,40 @@ +/* Desktop-владелец общего C BLE API. Создание/apply/snapshot/destroy — в uasync-потоке, + * вне BLE callbacks. GUI получает копию snapshot через UtunNode, а не handle ядра. + * Приёмные пакеты остаются в ll_queue транспорта до чтения потребителем C API. */ +#pragma once +#include "../../../lib/ble/ble_transport.h" +#include +#include +#include +#include + +struct DesktopBleSnapshot { + int flags = 0; // 1: транспорт, 2: объявление node_id/имени, 4: входящие подключения + ble_state state = BLE_CLOSED, advertising = BLE_CLOSED; + QString reason, advertisingReason; // Последние причины изменения состояния backend. + QStringList peers; // Свежие объявления: имя, node_id, RSSI; не более 64 узлов. + int connections = 0; // Готовые GATT-сессии. +}; + +class DesktopBle { +public: + DesktopBle(UASYNC* ua, uint64_t nodeId); + ~DesktopBle(); + DesktopBle(const DesktopBle&) = delete; + DesktopBle& operator=(const DesktopBle&) = delete; + void apply(int flags, const char* name); // Обновить политику/имя; повтор с теми же значениями — no-op. + DesktopBleSnapshot snapshot(); // Удалить объявления старше 30 секунд и вернуть копию. + ble_transport* transport() const { return m_transport; } // Заимствован только внутри uasync, до выключения. + +private: + void advertise(); + UASYNC* m_ua; + uint64_t m_nodeId; + ble_transport* m_transport = nullptr; + DesktopBleSnapshot m_snapshot; + QByteArray m_name, m_identity; + QElapsedTimer m_clock; + struct Peer { QString text; qint64 seen; }; + QMap m_peers; + QMap m_connections; +}; diff --git a/tools/chatgui/transport/node_config.cpp b/tools/chatgui/transport/node_config.cpp index 631055bec..36ad531d7 100644 --- a/tools/chatgui/transport/node_config.cpp +++ b/tools/chatgui/transport/node_config.cpp @@ -7,6 +7,8 @@ extern "C" { #include "../../../src/config_ini.h" +#include "../../../src/config_updater.h" +#include "../../../lib/socket_compat.h" #include "../../../src/transport_layer/secure_channel.h" } @@ -58,10 +60,31 @@ bool NodeConfig::create(const QString& nodeName) { return save(); } +bool NodeConfig::prepare() { + /* Проверка новых полей запускает штатный парсер, включая getaddrinfo. + * Это происходит раньше uasync_create и его инициализации WinSock. */ + config_error_clear(); + if (socket_platform_init()) { + CONFIG_ERROR("%s: socket subsystem initialization failed before configuration validation", qPrintable(m_path)); + m_errors = QStringList{QString::fromUtf8(config_error_text())}; + return false; + } + const int result = config_ensure_keys_and_node_id(m_path.toUtf8().constData()); + socket_platform_cleanup(); + if (result) { + m_errors = QStringList{QString::fromUtf8(config_error_text())}; + return false; + } + return load(); +} + /* Отдельная свежая проекция исключает сохранение удалённых полей после повторного load. */ bool NodeConfig::load() { QStringList lines; - if (!config_read_lines(m_path, lines)) return false; + if (!config_read_lines(m_path, lines)) { + m_errors = QStringList{QString::fromUtf8(config_error_text())}; + return false; + } NodeConfig next(m_path); QString section, controlIp = "127.0.0.1", controlPort; const auto invalid = [&](int line, const QString& field) { @@ -118,7 +141,12 @@ bool NodeConfig::load() { else controlPort = QString::number(port); } } else if (section == "gui") { - if (key == "utun_enabled") { + if (key == "bluetooth_flags") { + bool valid; + const int flags = value.toInt(&valid); + if (!valid || flags < 0 || flags > 7) invalid(i + 1, key); + else next.m_bluetoothFlags = flags; + } else if (key == "utun_enabled") { const int enabled = boolean_value(value); if (enabled < 0) invalid(i + 1, key); else next.m_utunEnabled = enabled != 0; @@ -179,6 +207,7 @@ bool NodeConfig::save() { {"global", "my_private_key", m_privKey}, {"global", "my_public_key", m_pubKey}, {"global", "auto_sockets", m_autoSocketsValue}, {"global", "auto_socket_protocols", m_autoSocketProtocols}, {"gui", "utun_enabled", m_utunEnabled ? "yes" : "no"}, {"gui", "db_path", m_dbPath.isEmpty() ? "chat_data" : m_dbPath}, + {"gui", "bluetooth_flags", QString::number(m_bluetoothFlags)}, {"gui", "debug_file", m_debugFile}, {"gui", "language", m_language}, {"gui", "debug_level", m_debugLevel.isEmpty() ? "info" : m_debugLevel} }; diff --git a/tools/chatgui/transport/node_config.h b/tools/chatgui/transport/node_config.h index 137b3d701..137614744 100644 --- a/tools/chatgui/transport/node_config.h +++ b/tools/chatgui/transport/node_config.h @@ -17,6 +17,7 @@ public: bool exists() const; // существует ли путь; доступность проверяется чтением bool create(const QString& nodeName); // новый файл: X25519/id, автосокеты TCP+UDP, control 127.0.0.1:9999 + bool prepare(); // подготовить ключи с инициализированным WinSock, затем load; причина отказа в errors() bool load(); // false при I/O/повреждённом тексте, прежняя модель сохраняется; true => проверить hasErrors() bool save(); // false при неверном поле/I/O; весь набор правок публикуется одной записью @@ -40,6 +41,7 @@ public: QString filePath() const { return m_path; } bool utunEnabled() const { return m_utunEnabled; } + int bluetoothFlags() const { return m_bluetoothFlags; } // 1=BLE, 2=объявление, 4=входящие void setUtunEnabled(bool enabled) { m_utunEnabled = enabled; } QString dbPath() const { return m_dbPath; } void setDbPath(const QString& path) { m_dbPath = path; } @@ -65,6 +67,7 @@ private: QString m_privKey; QString m_controlAddr; bool m_utunEnabled = false; // явно запускаемый дополнительный UTUN-сервис + int m_bluetoothFlags = 0; QString m_autoSocketsValue = QStringLiteral("no"); // yes/no/android, последний режим сохраняется до явного setter QString m_autoSocketProtocols = QStringLiteral("tcp+udp"); QString m_dbPath; // относительно каталога конфигурации; пустой save заменяет на chat_data @@ -72,6 +75,6 @@ private: QString m_debugLevel; QString m_debugCategories; QString m_language = QStringLiteral("system"); // system/en/ru - QStringList m_errors; // ошибки последнего успешного чтения, без секретных значений + QStringList m_errors; // ошибки последней загрузки/подготовки, без секретных значений }; #endif // NODE_CONFIG_H diff --git a/tools/chatgui/transport/utun_node.cpp b/tools/chatgui/transport/utun_node.cpp index a4dbbcb67..b4c380e04 100644 --- a/tools/chatgui/transport/utun_node.cpp +++ b/tools/chatgui/transport/utun_node.cpp @@ -1,5 +1,6 @@ // utun_node.cpp — embedded uTun node running in a dedicated thread #include "utun_node.h" +#include "../../../src/config_error.h" #include "stream_bridge.h" #include #include @@ -169,6 +170,25 @@ bool UtunNode::ntpEnabled() const { return m_snapshot.ntpEnabled; } +DesktopBleSnapshot UtunNode::bluetoothSnapshot() const { + std::lock_guard guard(m_snapshotMutex); + return m_snapshot.bluetooth; +} + +bool UtunNode::setBluetoothFlags(int flags) { + if (flags < 0 || flags > 7 || !m_running || m_stopping) { + DEBUG_WARN(DEBUG_CATEGORY_DISCOVERY, "BLE desktop settings rejected flags=%d running=%d stopping=%d", + flags, (int)m_running, (int)m_stopping); + return false; + } + m_bluetoothFlags = flags; + std::lock_guard guard(m_uaMutex); + if (m_ua) uasync_wakeup(m_ua); + return true; +} + +ble_transport* UtunNode::bluetoothTransport() const { return m_bluetooth ? m_bluetooth->transport() : nullptr; } + /* Значения копируются в потоке ядра; заимствованный SQLite защищён барьером stopping до уничтожения instance. */ void UtunNode::updateSnapshot(const UTUN_INSTANCE* inst) { std::lock_guard guard(m_snapshotMutex); @@ -183,6 +203,7 @@ void UtunNode::updateSnapshot(const UTUN_INSTANCE* inst) { m_snapshot.ntpOffsetUs = inst->ntp.offset_us; m_snapshot.ntpSynced = inst->ntp.synced; m_snapshot.ntpEnabled = inst->ntp.enabled; + if (m_bluetooth) m_snapshot.bluetooth = m_bluetooth->snapshot(); } void UtunNode::runLoop(std::promise startup) { @@ -257,7 +278,8 @@ void UtunNode::runLoop(std::promise startup) { struct utun_config* cfg = parse_config(m_configPath.toUtf8().constData()); if (!cfg) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "desktop startup: parse_config failed path=%s", qPrintable(m_configPath)); - failed(QCoreApplication::translate("ChatGui", "Invalid configuration:\n%1").arg(m_configPath)); + failed(QCoreApplication::translate("ChatGui", "Invalid configuration:\n%1").arg(m_configPath) + + '\n' + QString::fromUtf8(config_error_text())); return; } cfg->global.db_sync_enabled = 1; @@ -316,6 +338,10 @@ void UtunNode::runLoop(std::promise startup) { DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "chat_core + chat_sync initialized"); uasync_mark_running(ua); + DesktopBle bluetooth(ua, inst->node_id); + m_bluetooth = &bluetooth; + m_bluetoothFlags = cfg->global.gui_bluetooth_flags; + bluetooth.apply(m_bluetoothFlags, cfg->global.name); updateSnapshot(inst); m_instance = inst; gui_bridge_set_uasync(ua); @@ -329,11 +355,15 @@ void UtunNode::runLoop(std::promise startup) { const uint64_t generation = m_generation; while (!m_stop && inst->running) { uasync_poll(ua, 100); + bluetooth.apply(m_bluetoothFlags, cfg->global.name); updateSnapshot(inst); } if (!m_stop) DEBUG_ERROR(DEBUG_CATEGORY_SYS, "desktop core requested stop; releasing services outside callbacks"); m_stopping = true; + bluetooth.apply(0, cfg->global.name); + updateSnapshot(inst); + m_bluetooth = nullptr; gui_bridge_set_inst(nullptr); m_instance = nullptr; uasync_mark_stopped(ua); diff --git a/tools/chatgui/transport/utun_node.h b/tools/chatgui/transport/utun_node.h index e6a59b2be..e3ecaf4d8 100644 --- a/tools/chatgui/transport/utun_node.h +++ b/tools/chatgui/transport/utun_node.h @@ -20,6 +20,7 @@ #include #include #include +#include "desktop_ble.h" struct UTUN_INSTANCE; struct sqlite3; @@ -57,6 +58,9 @@ public: int64_t ntpOffsetUs() const; bool ntpSynced() const; bool ntpEnabled() const; + DesktopBleSnapshot bluetoothSnapshot() const; // GUI: копия состояния радио и свежих обнаруженных узлов. + bool setBluetoothFlags(int flags); // GUI: 0..7; применить в uasync после пробуждения, INI сохраняет вызывающий. + ble_transport* bluetoothTransport() const; // Только uasync: заимствованный C API handle до выключения/stop. signals: void started(); // Синхронно в GUI после успешного start. @@ -76,9 +80,12 @@ private: bool ntpSynced = false, ntpEnabled = false; struct sqlite3* database = nullptr; // Заимствован до подтверждения stopping в GUI. QString databasePath; // Каталог SQLite/media после применения GUI override. + DesktopBleSnapshot bluetooth; } m_snapshot; // Копия для GUI; доступ под m_snapshotMutex, указателей ядра не содержит. mutable std::mutex m_snapshotMutex; std::atomic m_instance{nullptr}; // Публикуется после готовности; не продлевает жизнь ядра. + std::atomic m_bluetoothFlags{0}; + DesktopBle* m_bluetooth = nullptr; // Только uasync; объект живёт на стеке runLoop. struct UASYNC* m_ua = nullptr; // Живёт до join; m_uaMutex защищает wakeup и откат неудачного старта. std::mutex m_uaMutex; std::thread m_thread; // Присоединяется GUI до повторного старта и уничтожения объекта.