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:
evgeny committed 2026-10-29 10:51:19 +01:00
1 parent 25cea8ca4c
commit f5bac950d1
82 files changed
+6608 -146

No files matched your search

+1 -1
View File
@@ -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
+227
View File
@@ -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-клиентов не проверены.
+5
View File
@@ -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 до подтверждения, отказ показывает имя.
+60
View File
@@ -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)
+35
View File
@@ -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
+556
View File
@@ -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;
}
+42
View File
@@ -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;
}
+17
View File
@@ -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
+301
View File
@@ -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);
}
+67
View File
@@ -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
+530
View File
@@ -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; }
}
+87
View File
@@ -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;
}
+37
View File
@@ -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;
}
+139
View File
@@ -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;
}
+23
View File
@@ -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;
}
+126
View File
@@ -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
View File
@@ -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 \
+23
View File
@@ -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;
}
+28
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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, &current)) {
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;
+3 -1
View File
@@ -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: файл уже заменён.
+4
View File
@@ -0,0 +1,4 @@
.gradle/
.kotlin/
**/build/
local.properties
+251
View File
@@ -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>
@@ -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"
}
}
@@ -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
)
}
@@ -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)
}
}
+5
View File
@@ -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
}
+8
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
android.useAndroidX=true
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2g
+4
View File
@@ -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")
@@ -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;
+15 -6
View File
@@ -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;
}
+22
View File
@@ -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;
};
+10 -2
View File
@@ -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);
+9
View File
@@ -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();
+2
View File
@@ -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;
};
+34
View File
@@ -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;
}
+62
View File
@@ -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"
+55
View File
@@ -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);
+56
View File
@@ -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>
+56
View File
@@ -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>
+4 -3
View File
@@ -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;
+111
View File
@@ -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;
}
+40
View File
@@ -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;
};
+31 -2
View File
@@ -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}
};
+4 -1
View File
@@ -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
+31 -1
View File
@@ -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);
+7
View File
@@ -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 до повторного старта и уничтожения объекта.