Add cross-platform BLE transport and Bluetooth settings
Integrate shared C transport with desktop and Android frontends, add radio probes and detailed Windows diagnostics, and fix GUI build dependencies and early configuration startup errors.
This commit is contained in:
1 parent
25cea8ca4c
commit
f5bac950d1
82 files changed
+6608
-146
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -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-клиентов не проверены.
|
||||
@@ -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 до подтверждения, отказ показывает имя.
|
||||
|
||||
@@ -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}" -- $<TARGET_FILE:test_ble_bluez>)
|
||||
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)
|
||||
@@ -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
|
||||
@@ -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 <systemd/sd-bus.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <poll.h>
|
||||
#include <time.h>
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#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;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#include "ble_identity.h"
|
||||
#include "../debug_config.h"
|
||||
#include <string.h>
|
||||
/* Строгая проверка 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;
|
||||
}
|
||||
@@ -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 <stdint.h>
|
||||
#include <stddef.h>
|
||||
#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
|
||||
@@ -0,0 +1,301 @@
|
||||
#include "ble_backend.h"
|
||||
#include "../u_async.h"
|
||||
#include "../mem.h"
|
||||
#include "../debug_config.h"
|
||||
#include <string.h>
|
||||
#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);
|
||||
}
|
||||
@@ -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 <stddef.h>
|
||||
#include <stdint.h>
|
||||
#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
|
||||
@@ -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 <winrt/Windows.Foundation.h>
|
||||
#include <winrt/Windows.Foundation.Collections.h>
|
||||
#include <winrt/Windows.Devices.Bluetooth.h>
|
||||
#include <winrt/Windows.Devices.Bluetooth.Advertisement.h>
|
||||
#include <winrt/Windows.Devices.Bluetooth.GenericAttributeProfile.h>
|
||||
#include <winrt/Windows.Storage.Streams.h>
|
||||
#include <array>
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <cstring>
|
||||
#include <cstdio>
|
||||
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<WinState> {
|
||||
struct UASYNC* ua = nullptr;
|
||||
struct ble_transport* owner = nullptr;
|
||||
uint16_t company = 0;
|
||||
std::mutex mutex;
|
||||
std::deque<std::function<void(WinState&)>> 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<ble_link_id, std::unique_ptr<WinLink>> links;
|
||||
std::array<uint8_t, BLE_BROADCAST_MAX> broadcast{};
|
||||
size_t broadcast_size = 0;
|
||||
bool broadcast_enabled = false;
|
||||
uint64_t publish_generation = 0;
|
||||
static void dispatch(void* arg) {
|
||||
auto& s = *static_cast<WinState*>(arg);
|
||||
std::deque<std::function<void(WinState&)>> 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<void(WinState&)> 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<struct posted_task*>(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<typename Result, typename Callback>
|
||||
void await(const char* phase, const IAsyncOperation<Result>& 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<Result>& 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<const uint8_t>(data, data + len)); return writer.DetachBuffer();
|
||||
}
|
||||
static std::vector<uint8_t> bytes(const IBuffer& buffer) {
|
||||
if (buffer.Length() > BLE_VALUE_MAX) throw hresult_invalid_argument();
|
||||
std::vector<uint8_t> 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<WinLink>(); 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<ble_link_id> 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<ble_link_id> 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<WriteDeferral>(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<std::function<void(WinState&)>> 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<WinState> 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<WinState>(); 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<IBluetoothAdapter3>();
|
||||
auto phy = adapter.try_as<IBluetoothAdapter4>();
|
||||
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<WinLink>(); l->id = id; l->endpoint = ep->id; l->outgoing = true;
|
||||
s.links.emplace(id, std::move(l)); s.connect(id, address, static_cast<BluetoothAddressType>(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; }
|
||||
}
|
||||
@@ -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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/* Реальное радио, запуск вручную: ble_probe [seconds]. Публикует только тестовое объявление. */
|
||||
#include "ble_transport.h"
|
||||
#include "u_async.h"
|
||||
#include "debug_config.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
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;
|
||||
}
|
||||
@@ -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 <systemd/sd-bus.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#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;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#include "ble_identity.h"
|
||||
#include "debug_config.h"
|
||||
#include <assert.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
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;
|
||||
}
|
||||
@@ -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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#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;
|
||||
}
|
||||
+3
-1
@@ -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 \
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
#include "config_error.h"
|
||||
#include <errno.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
+9
-8
@@ -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;
|
||||
}
|
||||
|
||||
+108
-97
@@ -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 = "<buffer>";
|
||||
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;
|
||||
}
|
||||
|
||||
+4
-1
@@ -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 означает <buffer>. Возврат как у parse_config. */
|
||||
|
||||
+17
-16
@@ -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;
|
||||
|
||||
@@ -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: файл уже заменён.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.gradle/
|
||||
.kotlin/
|
||||
**/build/
|
||||
local.properties
|
||||
@@ -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<ByteArray> = api.drainNodeReceived(remoteId, limit = 64)
|
||||
val links = api.connectionSnapshots()
|
||||
api.disconnect(remoteId) // отменяет повторные попытки и автоматическое подключение к этому ID
|
||||
```
|
||||
|
||||
`start(..., autoConnect=true)` включает автоматический запрос к обнаруженным узлам.
|
||||
Параметры `start` сохраняются для sticky restart, явно добавленные Binder-запросы действуют до stop/смерти процесса.
|
||||
`connect` возвращает `REQUESTED`, `STOPPED`, `INVALID_ID` или `LIMIT_REACHED`;
|
||||
`REQUESTED` означает принятую заявку, готовность видна как `READY` в снимке.
|
||||
Одновременно допускается до 8 транспортов, история очередей ограничена 32 ID до пересоздания сервиса.
|
||||
|
||||
Для восстановления политики после смерти процесса класс `Application` может реализовать `BleConnectionValidator`;
|
||||
сервис возьмёт этот callback при создании. Пример — `TestApplication`.
|
||||
Callback выполняется синхронно в BLE worker, должен быстро вернуть `Boolean`, исключение означает отказ.
|
||||
Он получает собственный ID, ID отправителя HELLO и имя из discovery, если оно уже получено.
|
||||
Сервис сначала проверяет HELLO `source != 0`, `source != local`, `target == local`;
|
||||
инициатор сверяет ID в ACCEPT с ожидаемым ID назначения. DATA до допуска запрещён.
|
||||
ID здесь заявленный, без криптографического доказательства владения. Для чата аутентификацию выполняет его протокол.
|
||||
|
||||
`sendTo` возвращает `QUEUED`, `NOT_CONNECTED`, `QUEUE_FULL` или `TOO_LARGE`.
|
||||
Максимум сообщения — 4096 байт. Для каждого ID отдельные FIFO TX и RX по 64 сообщения;
|
||||
входной payload копируется. При `QUEUE_FULL` дождитесь уменьшения `txQueued`.
|
||||
GUI умеет отправлять текст и пачку 64 × 512 байт, показывать MTU, байты, очереди, ожидание ACK и блокировку RX.
|
||||
Флажок чтения RX позволяет остановить consumer и проверить backpressure.
|
||||
|
||||
Congestion/flow control консервативный: окно — один фрагмент, следующий выпускается по ACK предыдущего.
|
||||
Размер фрагмента следует согласованному MTU (23–517), ATT value ограничен 512 байтами.
|
||||
Сервер передаёт indications, клиент — writes с ответом; один ATT writer сериализует операции.
|
||||
Служебный ACK имеет приоритет над DATA, поэтому одновременный обмен не блокирует подтверждения.
|
||||
При полном RX финальный ACK сообщения удерживается, сохраняется одно дополнительное собираемое сообщение;
|
||||
чтение RX возвращает кредит и возобновляет отправителя. RX соединения ничего не вытесняет.
|
||||
ACK означает помещение сообщения в RX удалённого API, счётчик `sent` растёт только после этого.
|
||||
При обрыве неподтверждённый TX сбрасывается с `discardedTx`, готовый RX сохраняется в пределах жизни сервиса.
|
||||
Приложение само решает, какие сообщения повторять: финальный ACK мог потеряться уже после помещения в RX.
|
||||
Handshake ограничен 30 с, отдельная ATT операция — 20 с; после обрыва заявка повторяется через 10 с.
|
||||
Ожидание свободного RX не имеет короткого таймаута: consumer может быть остановлен намеренно.
|
||||
|
||||
Wire GATT: HELLO/ACCEPT — `type:u8 | source:u64le | target:u64le` (типы 1/2);
|
||||
DATA — `3 | sequence:u32le | offset:u16le | total:u16le | fragment`;
|
||||
ACK — `4 | sequence:u32le | acceptedOffset:u16le`; REJECT — байт 5.
|
||||
Счётчики последовательности независимы по направлениям, начинаются с 1 на новом соединении.
|
||||
|
||||
`send` возвращает `QUEUED`, `STOPPED`, `QUEUE_FULL`, `PAYLOAD_TOO_LARGE` или `SEQUENCE_EXHAUSTED`.
|
||||
При `QUEUE_FULL` дождитесь уменьшения `snapshot().txQueued` перед повтором, не накапливайте новые сообщения.
|
||||
Одна FIFO TX на 64 записи; головная запись снимается только после успешного callback.
|
||||
При сбое радио запись сохраняется и повторяется с тем же номером. При явном stop ожидающий TX
|
||||
удаляется и учитывается в `txDiscarded`. Уже опубликованный пакет передаётся минимум 250 мс перед следующим.
|
||||
Периодический источник пропускает тик при занятом TX/отключённом радио, не раздувая очередь.
|
||||
|
||||
RX — одна FIFO на 256 уникальных сообщений, с одним логическим consumer.
|
||||
При заполнении удаляется старейшее сообщение, растёт `rxDropped`, появляется warning.
|
||||
GUI забирает RX при видимом экране, но сервис ведёт статистику независимо от чтения очереди.
|
||||
После длительной работы в фоне переполнение RX ожидаемо; оно не означает потерю радиопакетов.
|
||||
`BleReceived` содержит `session`, `sequence`, `payload`, `rssi`, `elapsedMs`.
|
||||
Время — монотонное Android elapsed realtime, не UTC.
|
||||
|
||||
Идентификатор сеанса — случайное ненулевое unsigned 32-bit число, новый при каждом запуске транспорта.
|
||||
Дедупликация по `(session, sequence)`, окно перестановок — 256 сообщений.
|
||||
Повторы и пакеты старше окна учитываются отдельно, в RX не попадают.
|
||||
Статистика ограничена 256 сеансами; при превышении вытесняется дольше всего не наблюдавшийся,
|
||||
растёт `peerEvictions`. Общие счётчики сохраняются. Снимки и входные payload копируются.
|
||||
История и идентичность сеансов не сохраняются в БД. Переполнение unsigned sequence требует нового запуска.
|
||||
|
||||
## Подключение к chatgui-android
|
||||
|
||||
В `tools/chatgui-android/settings.gradle.kts`:
|
||||
|
||||
```kotlin
|
||||
include(":ble-transport")
|
||||
project(":ble-transport").projectDir = file("../ble-test-android/ble-transport")
|
||||
```
|
||||
|
||||
В `app/build.gradle.kts` добавить `implementation(project(":ble-transport"))`,
|
||||
в корневой plugins-блок — `id("com.android.library") version "8.13.2" apply false`.
|
||||
Manifest библиотеки добавит BLE-права и сервис. Приложение отвечает за runtime-разрешения,
|
||||
привязку, чтение RX и явный stop. Unbind останавливает только связь GUI с API.
|
||||
Для транспорта, привязанного к жизненному циклу чат-сервиса, запускать/останавливать этот сервис явно;
|
||||
текущий API не добавляет BLE в native-ядро и не требует изменений протокола чата.
|
||||
|
||||
## Радиоформат и диагностика
|
||||
|
||||
Один legacy advertising set, connectable/scannable, LE 1M, интервал объявления 100 мс.
|
||||
Advertising данные меняются через `setAdvertisingData`, без цикла stop/start для каждого сообщения.
|
||||
Сканирование LOW_LATENCY с manufacturer-фильтром и нулевой задержкой отчётов.
|
||||
API Android описан в [AdvertisingSet](https://developer.android.com/reference/android/bluetooth/le/AdvertisingSet).
|
||||
|
||||
Manufacturer Company ID `0xffff` используется только для локального эксперимента;
|
||||
перед production нужен собственный Company ID или другой AD-формат.
|
||||
Manufacturer data: `75 54 | version:u8=2 | session:u32le | sequence:u32le | payload:0..13 bytes`.
|
||||
Всего до 24 байт, плюс 4 байта manufacturer AD и резерв 3 байта flags: укладывается в 31 байт legacy BLE.
|
||||
Scan response содержит service data UUID `f61a9001-4abc-45ac-98e1-761df4c13811`:
|
||||
`version:u8=2 | nodeId:u64le` (9 байт + 18 байт AD). Android возвращает имя и ID в объединённом ScanRecord.
|
||||
Имя обновляется вместе со счётчиком; ID неизменен во время работы транспорта. Обратной совместимости с v1 нет.
|
||||
Ник — соглашение тестового приложения; транспорт принимает любые байты.
|
||||
Broadcast discovery не имеет ACK. Для данных соединения есть фрагментация/ACK/очереди, шифрование и аутентификация — задача чата.
|
||||
|
||||
```bash
|
||||
adb -s DEVICE_SERIAL logcat -v threadtime -s UtunBle
|
||||
adb -s DEVICE_SERIAL shell setprop log.tag.UtunBle DEBUG # дополнительно каждый опубликованный TX
|
||||
adb -s DEVICE_SERIAL shell setprop log.tag.UtunBle INFO
|
||||
```
|
||||
|
||||
INFO: запуск/остановка, состояние адаптера, готовность advertising, сводка каждые 15 с.
|
||||
WARN/ERROR: переполнения, отказ API, некорректные пакеты, код/название ошибки BLE, callback timeout,
|
||||
ошибки очистки и отозванные права. Ошибка радио закрывает обе операции и запускает повтор через 30 с;
|
||||
поздние callbacks старого поколения игнорируются. Таймаут advertising callback — 10 с.
|
||||
|
||||
Unit tests проверяют wire-формат и лимит размера, unsigned поля, malformed данные, backpressure,
|
||||
дедупликацию/перестановки, переполнение RX, независимость сеансов, ограничение памяти,
|
||||
копирование payload, stop/start и одновременную запись из нескольких потоков.
|
||||
Радиоприём, Doze и поведение конкретного BLE-чипа требуют проверки на физических телефонах.
|
||||
|
||||
## Выполненная проверка — 28.10.2026
|
||||
|
||||
11 unit tests PASS; lint приложения и библиотеки — без замечаний. Собраны debug APK и
|
||||
`ble-transport/build/outputs/aar/ble-transport-debug.aar`.
|
||||
На Q8 / Android 14 окончательная сборка выполнила 199 публикаций за короткий прогон:
|
||||
проверены работа со свёрнутой Activity и погашенным экраном, ручной TX через Binder,
|
||||
восстановление после off/on Bluetooth и явная остановка; итоговый счётчик ошибок — 0.
|
||||
Подключено одно устройство: физический приём между телефонами и длительный прогон ещё не проверены.
|
||||
Общий `./check.sh` вне песочницы: 223 PASS / 3 FAIL из-за недоступного IPv6 loopback `::1`
|
||||
(`test_ncd_faults`, `test_ipv6_sockets`, `test_route_connectivity`); известная проблема среды уже описана в `doc/tasks.md`.
|
||||
|
||||
## Проверка GATT API на двух телефонах
|
||||
|
||||
```bash
|
||||
./gradlew :ble-transport:testDebugUnitTest :app:assembleDebug :app:assembleDebugAndroidTest :app:lintDebug :ble-transport:lintDebug
|
||||
python3 tests/run_two_devices.py --install \
|
||||
--receiver Q8_SERIAL --receiver-id Q8_NODE_ID \
|
||||
--initiator SAMSUNG_SERIAL --initiator-id SAMSUNG_NODE_ID \
|
||||
--output /tmp/ble-gatt-duplex
|
||||
# Та же команда с --reject и другой --output проверяет отказ валидации.
|
||||
```
|
||||
|
||||
Runner запускает оба instrumentation процесса одновременно. `BleApiProbe` обращается к Binder API без GUI consumer,
|
||||
отправляет в обе стороны по 70 сообщений (69 × 512 + 4096 байт), проверяет содержимое и порядок,
|
||||
заполняет RX, выдерживает паузу без кредита и затем проверяет продолжение.
|
||||
После прогона сервис останавливается. Instrumentation код находится только в отдельном test APK.
|
||||
Модульных тестов — 18: discovery, все 64 бита ID, ACK window, фрагментация при MTU 23/64/247/517,
|
||||
нулевые/максимальные сообщения, duplex, неправильные ACK/offset/size, TX/RX backpressure и disconnect.
|
||||
|
||||
На Q8 и Samsung SM-A525F оба прогона PASS: разрешение/отказ callback и duplex по 70 сообщений,
|
||||
39424 байта в каждом направлении, MTU 517. Обе стороны зафиксировали заполнение RX и возобновление после возврата кредита;
|
||||
содержимое и порядок проверены побайтово. 18 unit tests PASS, lint обоих модулей без замечаний.
|
||||
Повторный общий прогон: 223 PASS / те же 3 FAIL из-за недоступного `::1`.
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<application android:name=".TestApplication" android:label="uTun BLE Test" android:icon="@drawable/ic_ble"
|
||||
android:allowBackup="false" android:fullBackupContent="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules" android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
<activity android:name=".MainActivity" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -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<out String>, 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 зарезервирован" } }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp" android:height="48dp" android:viewportWidth="48" android:viewportHeight="48">
|
||||
<path android:fillColor="#1768AC" android:pathData="M0,0h48v48h-48z" />
|
||||
<path android:fillColor="#FFFFFF" android:fillType="evenOdd"
|
||||
android:pathData="M22,4v16l-9,-9 -3,3 11,10 -11,10 3,3 9,-9v16l16,-12 -10,-8 10,-8z M26,12l6,4 -6,5z M26,27l6,5 -6,4z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,8 @@
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="sharedpref" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="sharedpref" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" tools:targetApi="s" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<application>
|
||||
<service android:name="com.utun.ble.BleBroadcastService" android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice" android:stopWithTask="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
+533
@@ -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<String> = 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<BleReceived> = 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<ByteArray> = connections.drainReceived(nodeId, limit)
|
||||
/** Неизменяемые снимки известных соединений; sent учитывает только сообщения с удалённым ACK. */
|
||||
fun connectionSnapshots(): List<BleConnectionStats> = 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<Long>()
|
||||
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<ScanResult>) {
|
||||
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"
|
||||
}
|
||||
}
|
||||
+169
@@ -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<ByteArray>()
|
||||
private val rx = ArrayDeque<ByteArray>()
|
||||
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<ByteArray> {
|
||||
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)
|
||||
}
|
||||
@@ -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<ByteArray>()
|
||||
}
|
||||
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<Long>()
|
||||
private val states = ConcurrentHashMap<Long, BleConnectionQueues>()
|
||||
private val discoveries = LinkedHashMap<Long, Discovery>()
|
||||
private val retryAt = HashMap<Long, Long>()
|
||||
private val byNode = HashMap<Long, Link>()
|
||||
private val incoming = HashMap<String, Link>()
|
||||
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<ByteArray> {
|
||||
require(limit > 0)
|
||||
val result = states[nodeId]?.drainReceived(limit).orEmpty()
|
||||
if (result.isNotEmpty()) worker.post { returnCredit(nodeId) }
|
||||
return result
|
||||
}
|
||||
|
||||
fun snapshots(): List<BleConnectionStats> = 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<String, Int>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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<BlePeerStats>, 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<BleProtocol.Packet>()
|
||||
private val rx = ArrayDeque<BleReceived>()
|
||||
private val peers = LinkedHashMap<Identity, Peer>(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<BleReceived> {
|
||||
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
|
||||
)
|
||||
}
|
||||
+133
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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<String>()
|
||||
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<String>()
|
||||
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<String>()
|
||||
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<String>()
|
||||
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<String>()
|
||||
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<String>())
|
||||
val results = Collections.synchronizedList(mutableListOf<SendResult>())
|
||||
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<Long>()
|
||||
while (queues.peekTx() != null) {
|
||||
val seq = queues.peekTx()!!.sequence
|
||||
numbers.add(seq)
|
||||
queues.sent(seq)
|
||||
}
|
||||
assertEquals((1L..64L).toList(), numbers)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
# По умолчанию только сборка и проверки. install <serial> — установка готового 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
|
||||
@@ -0,0 +1,3 @@
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
org.gradle.jvmargs=-Xmx2g
|
||||
+4
@@ -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" "$@"
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
@@ -36,6 +36,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.utun.chat"
|
||||
testInstrumentationRunner = "com.utun.chat.BlePeerProbe"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,12 @@
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
@@ -66,7 +72,7 @@
|
||||
<service
|
||||
android:name=".headless.HeadlessService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
android:foregroundServiceType="specialUse|connectedDevice">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="p2p_chat_network_service" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<Long, Link>()
|
||||
private val incomingMtu = mutableMapOf<String, Int>()
|
||||
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<String> = 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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
+65
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,4 +617,14 @@
|
||||
<string name="server_add_channel">Добавить сервер в канал</string>
|
||||
<string name="server_add_no_channels">Создайте канал перед добавлением сервера.</string>
|
||||
<string name="server_add_bad_key">Ключ доступа к серверу неверен или был изменён.</string>
|
||||
<string name="bluetooth">Bluetooth</string>
|
||||
<string name="ble_enabled">Использовать Bluetooth</string>
|
||||
<string name="ble_advertise">Рекламировать себя</string>
|
||||
<string name="ble_incoming">Разрешить подключения ко мне</string>
|
||||
<string name="ble_hint">При разрешённых входящих подключениях GATT-сервис остаётся обнаруживаемым даже без персонального объявления.</string>
|
||||
<string name="ble_starting">Bluetooth запускается</string>
|
||||
<string name="ble_ready">Bluetooth работает</string>
|
||||
<string name="ble_stopped">Bluetooth остановлен или недоступен</string>
|
||||
<string name="ble_permission">Нужно разрешение Bluetooth и включённый системный Bluetooth.</string>
|
||||
<string name="ble_failed">Не удалось применить настройки Bluetooth</string>
|
||||
</resources>
|
||||
@@ -616,4 +616,14 @@
|
||||
<string name="server_add_channel">Add server to channel</string>
|
||||
<string name="server_add_no_channels">Create a channel before adding a server.</string>
|
||||
<string name="server_add_bad_key">The server access key is invalid or has changed.</string>
|
||||
<string name="bluetooth">Bluetooth</string>
|
||||
<string name="ble_enabled">Use Bluetooth</string>
|
||||
<string name="ble_advertise">Advertise myself</string>
|
||||
<string name="ble_incoming">Allow incoming connections</string>
|
||||
<string name="ble_hint">When incoming connections are allowed, the GATT service remains discoverable even if your personal advertisement is disabled.</string>
|
||||
<string name="ble_starting">Bluetooth is starting</string>
|
||||
<string name="ble_ready">Bluetooth is running</string>
|
||||
<string name="ble_stopped">Bluetooth is stopped or unavailable</string>
|
||||
<string name="ble_permission">Bluetooth permission or system Bluetooth activation is required.</string>
|
||||
<string name="ble_failed">Could not apply Bluetooth settings</string>
|
||||
</resources>
|
||||
@@ -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 <jni.h>
|
||||
#include <pthread.h>
|
||||
#include <string.h>
|
||||
#include <sys/eventfd.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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) ── */
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
#include "bluetoothsettingspage.h"
|
||||
#include "../transport/utun_node.h"
|
||||
#include "../transport/config_updater.h"
|
||||
#include "../transport/node_config.h"
|
||||
#include <QCheckBox>
|
||||
#include <QCoreApplication>
|
||||
#include <QLabel>
|
||||
#include <QListWidget>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/* Черновик разрешений desktop BLE, сохранение INI и применение через UtunNode.
|
||||
* Состояние радио и найденные узлы читаются как копия раз в секунду. */
|
||||
#pragma once
|
||||
#include <QWidget>
|
||||
#include <QPointer>
|
||||
|
||||
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<UtunNode> m_node;
|
||||
QCheckBox *m_enabled, *m_advertise, *m_incoming;
|
||||
QLabel *m_status, *m_advertisingStatus;
|
||||
QListWidget* m_peers;
|
||||
};
|
||||
@@ -25,7 +25,6 @@
|
||||
#include "desktop_integration.h"
|
||||
#include "gui_log.h"
|
||||
#include "config_parser.h"
|
||||
#include "../../../src/config_updater.h"
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <cerrno>
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include <QString>
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -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 <QCoreApplication>
|
||||
#include <QElapsedTimer>
|
||||
#include <cstdio>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 <QtTest>
|
||||
#include <memory>
|
||||
|
||||
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<const char*>(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<UASYNC, decltype(destroy)> 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<const uint8_t*>(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"
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <QTemporaryDir>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <thread>
|
||||
#ifdef Q_OS_UNIX
|
||||
#include <sys/resource.h>
|
||||
#include <csignal>
|
||||
@@ -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<utun_config, decltype(&free_config)>;
|
||||
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<bool>("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")) {
|
||||
|
||||
@@ -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 <QtTest>
|
||||
#include <QTemporaryDir>
|
||||
#include <QFile>
|
||||
@@ -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<QCheckBox*>("bluetoothEnabled");
|
||||
auto* advertise = page.findChild<QCheckBox*>("bluetoothAdvertise");
|
||||
auto* incoming = page.findChild<QCheckBox*>("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<QCheckBox*>("bluetoothEnabled")->isChecked());
|
||||
QVERIFY(restored.findChild<QCheckBox*>("bluetoothIncoming")->isChecked());
|
||||
enabled->setChecked(false); QVERIFY(page.save());
|
||||
QVERIFY(config.load()); QCOMPARE(config.bluetoothFlags(), 6);
|
||||
BluetoothSettingsPage disabled(path, nullptr);
|
||||
QVERIFY(!disabled.findChild<QCheckBox*>("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);
|
||||
|
||||
@@ -2691,5 +2691,61 @@ See log:
|
||||
<source>Could not delete the conversation. Please try again.</source>
|
||||
<translation>Could not delete the conversation. Please try again.</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Bluetooth</source>
|
||||
<translation>Bluetooth</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Enable Bluetooth</source>
|
||||
<translation>Enable Bluetooth</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Advertise this device</source>
|
||||
<translation>Advertise this device</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Allow incoming Bluetooth connections</source>
|
||||
<translation>Allow incoming Bluetooth connections</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Nearby VibeChat devices can see your node ID and name when advertising is enabled. Bluetooth must also be enabled in system settings.</source>
|
||||
<translation>Nearby VibeChat devices can see your node ID and name when advertising is enabled. Bluetooth must also be enabled in system settings.</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Nearby VibeChat devices:</source>
|
||||
<translation>Nearby VibeChat devices:</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Bluetooth: node is not running</source>
|
||||
<translation>Bluetooth: node is not running</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Bluetooth is off</source>
|
||||
<translation>Bluetooth is off</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Starting Bluetooth...</source>
|
||||
<translation>Starting Bluetooth...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Bluetooth is ready. Connections: %1</source>
|
||||
<translation>Bluetooth is ready. Connections: %1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Bluetooth is unavailable: %1</source>
|
||||
<translation>Bluetooth is unavailable: %1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Advertising is active</source>
|
||||
<translation>Advertising is active</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Advertising: %1</source>
|
||||
<translation>Advertising: %1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Failed to save Bluetooth settings. Check the configuration file.</source>
|
||||
<translation>Failed to save Bluetooth settings. Check the configuration file.</translation>
|
||||
</message>
|
||||
</context>
|
||||
</TS>
|
||||
@@ -2700,5 +2700,61 @@ See log:
|
||||
<source>Could not delete the conversation. Please try again.</source>
|
||||
<translation>Не удалось удалить переписку. Повторите попытку.</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Bluetooth</source>
|
||||
<translation>Bluetooth</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Enable Bluetooth</source>
|
||||
<translation>Включить Bluetooth</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Advertise this device</source>
|
||||
<translation>Рекламировать этот узел</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Allow incoming Bluetooth connections</source>
|
||||
<translation>Разрешить входящие подключения Bluetooth</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Nearby VibeChat devices can see your node ID and name when advertising is enabled. Bluetooth must also be enabled in system settings.</source>
|
||||
<translation>При включённой рекламе устройства VibeChat поблизости видят ID и имя вашего узла. Bluetooth также должен быть включён в настройках системы.</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Nearby VibeChat devices:</source>
|
||||
<translation>Устройства VibeChat поблизости:</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Bluetooth: node is not running</source>
|
||||
<translation>Bluetooth: узел не запущен</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Bluetooth is off</source>
|
||||
<translation>Bluetooth выключен</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Starting Bluetooth...</source>
|
||||
<translation>Запуск Bluetooth...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Bluetooth is ready. Connections: %1</source>
|
||||
<translation>Bluetooth готов. Подключений: %1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Bluetooth is unavailable: %1</source>
|
||||
<translation>Bluetooth недоступен: %1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Advertising is active</source>
|
||||
<translation>Реклама активна</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Advertising: %1</source>
|
||||
<translation>Реклама: %1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Failed to save Bluetooth settings. Check the configuration file.</source>
|
||||
<translation>Не удалось сохранить настройки Bluetooth. Проверьте файл конфигурации.</translation>
|
||||
</message>
|
||||
</context>
|
||||
</TS>
|
||||
@@ -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;
|
||||
|
||||
@@ -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<const char*>(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<DesktopBle*>(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<DesktopBle*>(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<DesktopBle*>(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<DesktopBle*>(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;
|
||||
}
|
||||
@@ -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 <QString>
|
||||
#include <QStringList>
|
||||
#include <QMap>
|
||||
#include <QElapsedTimer>
|
||||
|
||||
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<uint64_t, Peer> m_peers;
|
||||
QMap<ble_link_id, bool> m_connections;
|
||||
};
|
||||
@@ -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}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -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 <cinttypes>
|
||||
#include <utility>
|
||||
@@ -169,6 +170,25 @@ bool UtunNode::ntpEnabled() const {
|
||||
return m_snapshot.ntpEnabled;
|
||||
}
|
||||
|
||||
DesktopBleSnapshot UtunNode::bluetoothSnapshot() const {
|
||||
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<QString> startup) {
|
||||
@@ -257,7 +278,8 @@ void UtunNode::runLoop(std::promise<QString> 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<QString> 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<QString> 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);
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#include <condition_variable>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#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<UTUN_INSTANCE*> m_instance{nullptr}; // Публикуется после готовности; не продлевает жизнь ядра.
|
||||
std::atomic<int> 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 до повторного старта и уничтожения объекта.
|
||||
|
||||
Reference in new issue
Block a user