Итак, мы познакомились с платформой как пользователи. Посмотрели на связь игр с софтом. Софта с платформой. Пришла пора посмотреть как платформа общается со своими сенсорами: нужен ли нам провод до платформы вообще?

Как сенсоры присылают данные

Что мы знаем? Мы знаем, что сенсоры -- беспроводные. Мы знаем, что у них есть адрес в 6 байт. Мы знаем, что сиденье поставляется с USB-bluetooth спаренным приёмником. Мы знаем, что коробка под платформой зовётся Receiver. В общем, если что-то крякает, значит утка. Может, резиновая, но утка.

Как посмотреть, что за утки плавают вокруг? У компа моего материнка содержит wifi+bluetooth, но в системных bluetooth устройствах ничего интересного не видно. Есть разные Bluetooth LE Explorer, Bluetooth LE Lab и так далее, но они тоже ничего интересного не дали. Поиск приложений подсовывает вагон разных приложений на телефон, на посмотреть вокруг, и почему-то попалось nRF Connect, которое я и поставил.

Первым делом приложение показывает вкладки для устройств, с которыми телефон уже связан, например такое:

Какое-то устройство
Какое-то устройство

Что даёт надежду что сейчас будет всё просто и легко!

Во вкладке "Scanner", после нажатия на "SCAN" радостно висит вагон самых разных "KATVR":

И устройства видно
И устройства видно

Ура, значит, это просто Bluetooth LE девайсы! Тыкаем в первый попавшийся, на сенсоре загорается лампочка соединения, вкладка устройства открывается -- абсолютно пустая:

Соединение -- пусто
Соединение -- пусто

Эм... Отключаемся, соединяемся опять, смотрим в лог:

Ничего не понимаю.

Окей. В настройках для разработчика на телефоне включим "Snoop logs", подключим телефон по ADB, запустим опять wireshark, и подключимся к телефону:

Чего только в Wireshark нет
Чего только в Wireshark нет

Запускаем подслушивание, подключаемся на телефоне, ну вот же данные! Вот они!

Данные как на ладони
Данные как на ладони

Правда, как-то себя ведёт непонятно: на все запросы атрибутов отвечает ошибкой, но обновления присылает. На телефоне никаких обновлений нет, в приложении нигде ничего не видно... Что же происходит?

В любом случае, что мы уже узнали:

  • Сенсоры -- Bluetooth LE.

  • Сенсоры присылают обновления используя Notification для атрибута 0x002E, содержимое которого -- подозрительно напоминает те же пакеты, которые мы видим на USB.

  • Приложение на телефоне эти пакеты не видит.

  • Листинг параметров возвращает ошибки.

Как работает Bluetooth LE

Дилетантский обзор на Bluetooth LE. В Bluetooth LE есть понятие ролей, и машина состояний для каждого участника этого беспроводного кардебалета. Общение идёт методом отправки сообщений и ответов на постоянно меняющихся частотах (тот самый freq-hopping), где те, кому надо отправляют тогда, когда им надо.

Как узнать кому отправлять? Это всё управляется GAP -- "Generic Access Profile". Для прямого общения ролей две -- Peripheral и Central. Peripheral устройства периодически посылают Advertising Data (до 31 байта) прыгая по отдельным трём каналам для оповещения что они существуют. Central устройства слушают на каких-либо из этих каналов (или прыгая по ним) в период поиска нового устройства. Если Central устройству надо, оно может дополнительно запросить еще больше информации через Scan Response Request на который устройство ответит вторым пакетом информации.

Когда мы узнали с кем, можно соединятся. Есть несколько режимов соединения: в ответ на Advertising Data, просто послать прямой пакет. В любом случае, устанавливается соединение, в котором идёт договорённость про частоту обмена и алгоритм смены каналов.

Central роль ведущая. Устройство с ролью Central отслеживает время и является инициатором любых коммуникаций. В заранее оговоренное время Central отправляет пакет на следующем договорённом канале со своими данными и слушает на тему ответа от устройства. Спустя оговоренное время -- процесс повторится на следующем канале и так далее. С какой частотой Central обращается и как часто Peripheral обязан отвечать устанавливается в процессе установления соединения. Если peripheral не ответил больше договорённого -- связь считается потерянной. Таким образом регулируется баланс между энергопотреблением ведомых устройств и их задержками на ответы.

Peripheral устройства ведомые. Они могут спать себе спокойно столько, сколько позволяют параметры соединения, обязаны только просыпаться для передачи пустого пакета с заданной частотой. Если же им надо что-то делать активно, они могут слушать радиоэфир каждый раз, когда к ним должны обращаться. Возможен так же переход между разными параметрами соединения, чтобы переключаться между периодами когда мы просто ждём и периодами когда идёт толстый поток данных.

Неважно кто ведущий а кто ведомый, в любом случае у нас есть возможность отправить пакет(ы) туда и обратно в течение всего окна коммуникации. Слоем повыше в этом бутерброде идет структурирование данных для обмена в этих пакетах, GATT (работающий поверх ATT) или L2CAP. GATT -- это описание как держать в памяти "параметры", где каждый параметр идентифицируется по его GUID в таблице параметров, а потом читается-пишется-обновляется используя короткий 16битный Handle. То есть типичная работа по соединению: соединяемся, читаем таблицу параметров, потом читаем-пишем интересующие нас параметры.

GATT Server обычно расположен на Peripheral, но это не обязательно, оба устройства могут быть GATT Server, чтобы выставить свои параметры и оба же могут быть GATT Client'ами чтобы их читать или писать. Хоть в теории и создано это было для параметров, которые надо считывать, есть механизм пуша через Notification или Indication, которые позволяют GATT Client подписаться на изменения значения параметра у GATT Server, и каждый раз как параметр изменится, GATT Client получит соответствующий пакет с новым значением. Используя эту процедуру часто организуются способы передачи потока (например, для перешивки по воздуху).

L2CAP это обобщение ATT протокола. По сути это способ создать новый канал связи, в котором просто можно отсылать пакеты с данными когда надо, а уже получатель будет разбираться как с ними работать. ATT протокол обмена значениями и обновлениями параметров работает на L2CAP канале #4 (прибито гвоздями, ибо так исторически сложилось).

Как работать с Bluetooth LE

Не менее дилетантский обзор как же пользоваться знаниями, описанными выше.

Итак, мы хотим получить обновления данных с сенсоров. Типичный BLE стек для приложений (доступный на Windows, Linux, Android и т.п.) заточен на реализацию Central роли и умеет быть как GATT Server так и GATT Client. Но в основном, конечно же, привычно быть GATT Client.

Процесс связи и обмена получается таким:

  • Поиск устройств. Либо включается режим Scan (когда мы слушаем и запрашиваем дополнительные данные), либо мы уже знаем устройство (его MAC адрес).

  • Установление соединения. Когда мы знаем кому, мы посылаем пакет установления соединения, при котором обе стороны создают временный адрес на время соединения и уходят на личный канал; обмениваются параметрами соединения, договариваются о размере пакетов и так далее.

  • Установление шифрования. Если надо -- возможно повышение уровня безопасности с переходом на заранее заданный общий ключ, либо ключ полученный в результате показа PIN пользователю или ввода PIN пользователем и проч. К слову, поднять уровень шифрования можно позднее.

  • Считывание GATT профиля для устройства. кто бы ни был GATT Client, он запрашивает доступные параметры с сервера.

  • Подписка на нужные параметры (отправляются пакеты чтобы выставить Notification / Indication для параметров).

  • Когда надо -- параметры пишутся или читаются.

Блютус стек операционки берёт на себя работу по проверке пакетов, правильной организации пакетов и прочее.

Всё работает ровно до тех пор, пока всё хорошо... Аналогично своему прошлому опыту по изучению USB стека, я взял первый попавшийся проект на гитхабе -- Android-BLE-Connection, BluetoothLeGatt, Android-BLE-Connect-Example.

Разумеется, они все были outdated, пришлось воспользоваться иструкцией из прошлой статьи, чтоб обновить их до компилируемого состояния с легкими вариациями правок для изменений в BLE стеке... Но всё тщетно! Я успешно мог подписаться на параметры других Bluetooth сенсоров, но вот сенсоры от KATVR отказывались отдавать данные.

Я знал Handle параметра -- но это не помогало. Пришлось подключить еще и Logcat в Wireshark, и обнаружить вот такое:

Что-то там глубоко внутри пошло не так
Что-то там глубоко внутри пошло не так

Я даже слазил в исходники стека как в Java часть, так и в Native часть.

Потом поигрался попытками заставить стек принять фейковое описание, используя рефлексию для добычи скрытых методов. Пытался договориться с Native часть напрямую, используя имеющиеся интерфейсы для IPC и пытаясь установить свои соединения. Тщетно.

Попытка поиграться с Windows BLE стеком провалилось на том же месте. Стек считает что если параметров устройство не отдало -- то и вам их и не надо. И несмотря на то, что пакеты прилетают... Клиентский код их не получит. Никак. Ни за что.

Что мы делаем в таком случае? Правильно!

We need to go deeper
We need to go deeper

Делаем своё Bluetooth Central устройство

Железо -- nRF52840

Раз мы не можем получить данные после нескольких слоёв очень умных операционных систем, защищающих нас от любых неожиданностей, нам надо взять что-то, что не так следит за нашими делами.

Вроде только попробовать хотел на чем-нибудь, откуда столько?
Вроде только попробовать хотел на чем-нибудь, откуда столько?

Так как у меня был nRF52840 Dongle, который я купил для подсматривания и разнюхивания общения родного ресивера с сенсорами, я посмотрел а что же он умеет. Оказалось, что это очень вкусный зверёк, который умеет много радио протоколов, удобно выткается в USB порт, содержит аж мегабайт пространства для кода, четверть мега оперативки, это всё подаётся с 32битным ARM процессором работающим на 64 мегагерцах, да еще и с fpu. Проще говоря -- это нечто мощнее моего первого компьютера.

Добавим, что nRF Connect поставляемый легко взлетает в VS Code, обеспечивая возможность легко запустить примеры, я решил что особо ничего другого искать и не буду, его возможностей должно хватить. Да, конечно, они рекомендуют работать с DevKit, но у меня уже есть донгл! :) Впрочем, всё равно пришлось докупать еще, так как мне понадобилось в какой-то момент проверить где я не прав, я докупил Seeed Studio XIAO nRF52840. Он еще компактнее, удобно, что можно прошить без софта (у него UF2 прошивальщик сразу стоит, то есть просто скопировать файл на него), так что заканчивал проект я уже на нём, оставив nRF Dongle чисто для Sniffer'а.

Софт -- на базе Zephyr

Поигравшись с предоставленными примерами, я более-менее понял идею, что Zephyr OS прекрасно выполняет свою роль для RTOS -- довольно тонкие абстракции над железом, и при этом доступ до всего что может потребоваться. За счет глубокой интеграции и поддержки со стороны nRF SDK, всё работает сходу, что позволяет сфокусироваться на логике. Да, возможно, использование чего-то более простого или более низкоуровневого могло дать какие-то выгоды, но лучшее -- враг хорошего, а мне нужен результат :)

Структура проекта заточена на сборку с возможностью включать-выключать разные части, так что, в отличие от сборки в обычной студии, используемые исходники надо прописывать в CMakeLists.txt. Через конфигурацию, заданную в нём или в настройках в студии, или еще парой способов, выбирается какая конкретно плата будет использоваться, если надо -- можно складывать дополнительные параметры для отдельных плат, чтобы получить кросс-компиляцию.

В целом, для начала достаточно знать, что в prj.conf мы складываем параметры для RTOS, включая-выключая что нужно, полагаясь на то, что база для самой платы уже настроена. Если нам нужны свои параметры (или тюнинг значений по умолчанию), мы можем создать свой Kconfig. В остальном, пока всё не очень важно.

Шаг первый: USB Console

Так как мы работаем не на DevKit (то есть JTAG / консоль не встроены в плату, а внешнего JTAG у меня не завалялось), первым делом нам надо получить отладочную консоль. Забегая вперёд -- на XIAO nRF52840 она включена по умолчанию, ине совсем корректно (через prj.conf слой для платы), так что на нём сложнее её отключить. На nRF Dongle же, чтобы включить её надо добавить app.overlay, в котором прибиндить консоль к usb-uart и включить его:

/*
For now, enable single USB-UART that act as a debug console.
*/

/ {
    chosen {
        zephyr,console = &cdc_acm_uart0;
    };
};

&zephyr_udc0 {
    cdc_acm_uart0: cdc_acm_uart0 {
        compatible = "zephyr,cdc-acm-uart";
    };
};

В prj.conf включить соответствующие устройства, и немного отладочного вывода ядра для понимания:

CONFIG_USB_DEVICE_STACK=y
CONFIG_USB_DEVICE_PRODUCT="nRF KAT-VR Receiver"
CONFIG_USB_DEVICE_PID=0x0004
CONFIG_USB_DEVICE_INITIALIZE_AT_BOOT=n
CONFIG_SERIAL=y
CONFIG_CONSOLE=y
CONFIG_UART_CONSOLE=y
CONFIG_UART_LINE_CTRL=y

CONFIG_LOG=y
CONFIG_LOG_PRINTK=y
CONFIG_LOG_MODE_IMMEDIATE=y

После чего в src/main.c можно добавить BUILD_ASSERT чтоб не забыть, а в код main() добавить ожидание коннекта наблюдателя. Эта задержка очень удобна для отладки, когда на фоне висит "nRF Terminal" и автоматически соединяется при появлении устройства. Таким образом у нас всегда перед глазами лог с момента загрузки.

#include <zephyr/kernel.h>
#include <zephyr/drivers/uart.h>
#include <zephyr/usb/usb_device.h>
#include <zephyr/usb/usbd.h>
#include <zephyr/sys/printk.h>

// Ensure the console is USB-UART
BUILD_ASSERT(DT_NODE_HAS_COMPAT(DT_CHOSEN(zephyr_console), zephyr_cdc_acm_uart),
             "Console device is not ACM CDC UART device");

int main(void)
{
    int err;

    if (IS_ENABLED(CONFIG_USB_DEVICE_STACK))
    {
        const struct device *const dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_console));

        err = usb_enable(NULL);
        if (err && (err != -EALREADY))
        {
            printk("Failed to enable USB");
            return err;
        }

        /* Poll if the DTR flag was set */
        uint32_t dtr = 0;
        while (!dtr)
        {
                uart_line_ctrl_get(dev, UART_LINE_CTRL_DTR, &dtr);
                /* Give CPU resources to low priority threads. */
                k_sleep(K_MSEC(100));
        }
    }

    printk("*** nRF KAT Receiver ***\n");

    printk("Initialized.\n");
    return 0;
}

Теперь, когда у нас есть остов, поверх которого можно кодить -- приступаем!

Шаг второй: Bluetooth Central

Zephyr BLE стек умеет всё, что нам нужно. Дальше я рассуждал так: нам надо найти устройство и соединиться с ним. В принципе, устройства искать не требуется, так как мы уже знаем MAC адреса требуемых устройств (они прошиваются при спаривании), нам не надо GATT Server'а, нам даже GATT Client не нужен, поскольку сенсоры просто шлют обновления не спрашивая. Плюс, нам надо поддерживать до 4х соединений (левый-правый-спина-седло)...

Выставляем в prj.conf:

CONFIG_BT=y
CONFIG_BT_CENTRAL=y
CONFIG_BT_GATT_CLIENT=n
CONFIG_BT_GATT_DM=n
CONFIG_BT_SCAN=n
CONFIG_BT_MAX_CONN=4

добавляем в main.c:

#include <zephyr/bluetooth/bluetooth.h>

Для начала, просто будем соединяться с первым попавшимся под руку сенсором, который пропишем вручную в исходнике. MAC адреса сенсоров пишутся в network order, от младшего к старшему, так что разворачиваем вручную (или используем функцию конвертации строки в адрес... но зачем, для константы-то?):

const bt_addr_le_t katDevices[] = {
        // KAT_DIR
        {.type=BT_ADDR_LE_PUBLIC, .a={.val={0x01, 0x74, 0xEB, 0x16, 0x4D, 0xAC}}}, // AC:4D:16:EB:74:01
}

Затем нам надо два callback'а, на соединение и на разрыв. Соединение возможно только когда устройство в радиусе досягаемости, и оно может пропасть в любой момент если уйдёт далеко или уснёт или еще что. Поэтому, нужны оба -- если соединение упадёт, чтобы восстановить.

static void device_connected(struct bt_conn *conn, uint8_t conn_err)
{
    if (!conn_err) {
        int conn_num = bt_conn_index(conn);
        printk("Device connected (%p/%d)\n", conn, conn_num);
    } else {
        printk("Connect error (%p/%x)\n", conn, conn_err);
        bt_conn_unref(conn);
    }
}

static void device_disconnected(struct bt_conn *conn, uint8_t reason)
{
    printk("Device disconnected (%p/0x%02x)\n", conn, reason);
}

BT_CONN_CB_DEFINE(conn_callbacks) = {
    .connected = device_connected,
    .disconnected = device_disconnected,
};

Небольшая сноска на тему BT_CONN_CB_DEFINE. В Zephyr OS практически для всего почти всегда есть два подхода: динамическая и статическая инициализация. При динамической инициализации, в коде надо сформировать структуру и вызвать функцию; при статической -- структура заполняется с помощью соответствующего макроса. Макрос определяет переменную с определённым форматом названия, которые линкер собирает в одном месте и они инициализируются ядром оси в момент загрузки без необходимости писать код для этого. Это позволяет писать очень простые программы для простых случаев, но при этом оставляет полную свободу для развлечения когда надо сотворить что-нибудь эдакое, на что ОС не была рассчитана. Конкретно в случае bluetooth соединений, можно воспользоваться BT_CONN_CB_DEFINE который определит фиксированную структуру с нашими callback'ами, а можно было объявить bt_conn_cb структуру, которую скормить bt_conn_cb_register, и буде нужно отключить их -- её же можно было бы скормить bt_conn_cb_unregister потом. Но нам не надо, поэтому статическая инициализация -- самое оно.

Дальше нам надо во-1х инициализировать BLE стек, во-2х связаться с сенсором.

static struct bt_conn *default_conn;

int main(void)
{
    /// ...
    printk("*** nRF KAT Receiver ***\n");
    err = bt_enable(NULL);
    if (err)
    {
        printk("Bluetooth init failed (err %d)\n", err);
        return 0;
    }

    printk("Bluetooth initialized\n");
    err = bt_conn_le_create(&katDevices[0], BT_CONN_LE_CREATE_CONN,
            BT_LE_CONN_PARAM_DEFAULT, &default_conn);
    if (err) {
        printk("Create conn failed (%d)\n", err);
    }
}

В этот момент очень удобно пронаблюдать как мы соединяемся с сенсором, и соединение пропадает если его перезагрузить.

Всё бы хорошо, но соединятся надо не с одним устройством, и не хотелось бы заниматься этим вручную. Мы, конечно, знаем, что все устройства должны быть рядом и все ожидаются быть включенными, но всё же будет проще отдать это на обработку более низкому уровню.

Таких способов два. Во-1х можно запускать сканирование, и вызывать функцию соединения когда находим устройство; во-2х можно пользоваться пассивным прослушиванием и автоматическим соединением, если будет пойман Advertising пакет устройства. Важно помнить, что любой поиск -- это часть времени радиомодуль переключается на каналы оповещения и слушает там на предмет пакетов оповещения.

Итак, автоматическое прослушивание эфира и подключение работает так:

  1. Включаем в prj.conf поддержку фичи через CONFIG_BT_FILTER_ACCEPT_LIST=y

  2. Задаём нужные MAC адреса через bt_le_filter_accept_list_add.

  3. Включаем поиск режим автосоединения через bt_conn_le_create_auto.

Всё, какое первое из желаемых устройств попадёт в радиус -- с таким и будет установлено соединение. После того, как соединение установлено, можно запустить bt_conn_le_create_auto заново чтобы ждать следующее устройство.

В отличие от прямого соединения по bt_conn_le_create мы НЕ получаем структуру соединения, мы её первый раз увидим только в connected() колбэке, но нам его и не надо. Параметры к автосоединению задают как часто и на как долго устройство переключается в прослушивание. Если надо сохранить стабильное соединение с приборами и поиск новым, важно сперва настроить и задать все требуемые параметры только созданному соединению и уж потом включать поиск следующего. И не забываем балансировать таймслоты (об этом ниже).

Шаг третий: получаем ATT пакеты без GATT клиента

Теперь то, ради чего всё затевалось. Есть соединение, но пакетов нет! Ну вот так. Даже если повключать логи на ВСЁ из имеющихся модулей через prj.conf

CONFIG_BT_A2DP_LOG_LEVEL_DBG=y
CONFIG_BT_BAS_LOG_LEVEL_DBG=y
CONFIG_BT_DF_LOG_LEVEL_DBG=y
CONFIG_BT_HCI_DRIVER_LOG_LEVEL_DBG=y
CONFIG_BT_LOG_LEVEL_DBG=y
CONFIG_BT_RFCOMM_LOG_LEVEL_DBG=y
CONFIG_BT_LOG_SNIFFER_INFO=y
CONFIG_BT_ATT_LOG_LEVEL_DBG=y
CONFIG_BT_GATT_LOG_LEVEL_DBG=y
CONFIG_BT_CONN_LOG_LEVEL_DBG=y
CONFIG_BT_HCI_CORE_LOG_LEVEL_DBG=y
CONFIG_BT_L2CAP_LOG_LEVEL_DBG=y

Пакетов не увидим. Это был тот самый момент, когда я сдался, докупил второй донгл (XIAO который) и пытался долго сравнивать два трейса после сниффера: родного приёмника и моего. Впрочем, удалось понять что происходит только методом исключения: поток появлялся после того как централь (то есть мой ресивер) отправляла пакет с изменением параметров соединения. То есть в колбэк connected() пришлось воткнуть bt_conn_le_param_update с практически бесполезным изменением. Я просто меняю размер timeout, этого оказалось достаточно, чтобы сенсоры начали выдавать поток.

Чтобы принять поток пришлось залезть вглубь операционки. Причина в том, что GATT Client это глубоко встроенная фича, работает на фиксированном канале L2CAP и, разумеется, как и в других стеках может делать слишком много. Мне же надо просто получить пакеты как есть, без предобработки. При этом L2CAP как протокол предполагает, что номер канала не фиксирован, а как договорятся. Так что пришлось слазить в унутро и склонировать функцию генерации статического описания канала:

#ifndef BT_L2CAP_CHANNEL_DEFINE
// Include l2cap_internal.h if you build in-tree; otherwise, let's just steal definitions from compatible (v2.5.2)
struct bt_l2cap_fixed_chan {
    uint16_t cid;
    int (*accept)(struct bt_conn *conn, struct bt_l2cap_chan **chan);
    bt_l2cap_chan_destroy_t destroy;
};
#define BT_L2CAP_CHANNEL_DEFINE(_name, _cid, _accept, _destroy)         \
    const STRUCT_SECTION_ITERABLE(bt_l2cap_fixed_chan, _name) = {   \
        .cid = _cid,                            \
        .accept = _accept,                      \
        .destroy = _destroy,                    \
    }
#define BT_L2CAP_CID_ATT                0x0004
#endif

BT_L2CAP_CHANNEL_DEFINE(a_att_fixed_chan, BT_L2CAP_CID_ATT, my_att_accept, NULL);

Идея заключается в том, что нам надо объявить фиксированный канал и быть уверенными что он будет подцеплен первым, до GATT Client'а (буде он активирован). В то же время GATT Client стандартный уже об этом подумал и старается быть последним, так что все счастливы. Используя этот подход у меня остаётся свобода действий, если я захочу пользоваться для каких-то соединений стандартном.

Итак, колбэк на формирование L2CAP канала пока будет работать просто: его задача проверить что соединение подходит (для меня -- всегда), и создать структуру с колбэками для обработки его. Опять же мы приходим к тому, что мы работаем на железе и с RTOS: если можно обойтись без динамического распределения памяти -- это хорошая идея так и сделать. У нас всего CONFIG_BT_MAX_CONN соединений, и для любого соединения можно легко и дёшево (во времени выполнения) получить его индекс через bt_conn_index. Это позволяет управлять памятью для поддержки соединений через выделение статического массива. В итоге наш обработчик прост:

static struct bt_l2cap_le_chan my_att_chan_pool[CONFIG_BT_MAX_CONN];
static int my_att_accept(struct bt_conn *conn, struct bt_l2cap_chan **ch)
{
    static const struct bt_l2cap_chan_ops ops = {
        .recv = my_att_recv,
    };

    int id = bt_conn_index(conn);
    printk("Capturing L2CAP ATT channel on connection %p/%d\n", conn, id);

    struct bt_l2cap_le_chan *chan = &my_att_chan_pool[id];
    chan->chan.ops = &ops;
    *ch = &chan->chan;
    return 0;
}

Этот колбэк вызывается каждый раз как устанавливается новое соединение. После чего каждый раз как приходит пакет в этот канал, будет вызван наш обработчик данных:

static int my_att_recv(struct bt_l2cap_chan *chan, struct net_buf *req_buf)
{
    int id = bt_conn_index(chan->conn);

    printk("Received packet for conn %d [%d bytes]:", id, req_buf->len);
    for (int i = 0; i<req_buf->len; ++i) {
            printk(" %02x", req_buf->data[i]);
    }
    printk("\n");

    return 0;
}

Всё, поток данных получен, теперь можно с ним что-то делать: выделяем массивы для хранения текущего состояния сенсоров и пишем функции разбора пакетов ног, а потом и разбор пакетов направления.

Шаг четвертый: USB HID мультидевайс чтобы прикинуться оригинальным ресивером

Итак удалось принять данные, теперь их надо передать на комп. Самое простое -- прикинуться шлангом оригинальным ресивером. Таким образом, результатом работы будет что-то полезное людям: можно будет работать с платформой без необходимости кидать провод по комнате, например. Или для "новых нативных игр" можно будет микроприёмник держать на самом хедсете и играть без проводов. В общем, сплошные плюсы -- с "минимумом" работы.

Небольшой апгрейд...
Небольшой апгрейд...

Для клонирования VID/PID надо просто задать их в конфиге (prj.conf). А чтобы гейтвей не жаловался на то, что нашел новое устройство -- склонируем еще и серийник.

CONFIG_USB_DEVICE_VID=0xC4F4
CONFIG_USB_DEVICE_PID=0x2F37
CONFIG_USB_DEVICE_SN="CRA21D60xxx"

Небольшое отступление -- вообще, Zerphyr по умолчанию использует серийник, полученный на основе уникальных данных для каждого чипа. Но при использовании его, KAT Gateway просто падал при запуске. Прощёлкивание по шагам через отладчик (запустив KATDeviceSDK.dll через rundll в IDA) подсказало, что падает из-за слишком длинной строки. Там просто не было проверок... Так что требовалось использовать серийник не больше 12 символов длиной. Так что я задал прямо свой серийник в конфиге, и он сработал, но это оказалось багом, который я не заметил до самого последнего момента! То есть задание конфига не должно было работать... :)

Со клонированием HID дескриптора не совсем тривиально, но тоже просто. Включаем HID в настройках и задаём требуемые ТТХ (я долго тупил почему не работает, пока не нашел, что размер пакетов по умолчанию 16 байт). Нам надо одно HID устройство, прерывание на отправку и получение. Поскольку консоль для отладки всё еще нужна, включаем композитное устройство. Композитное устройство на USB позволяет объединить несколько разноплановых устройств в одно без необходимости использовать специальные драйверы -- драйвер операционки (и Win, и Lin, и Android) прекрасно разделяет их на отдельные кусочки, так что приложениям и драйверам становится безразлично -- это композит или отдельные устройства, при этом не надо прикидываться хабом.

CONFIG_USB_DEVICE_HID=y
CONFIG_USB_HID_DEVICE_COUNT=1
CONFIG_HID_INTERRUPT_EP_MPS=64
CONFIG_ENABLE_HID_INT_OUT_EP=y
CONFIG_USB_COMPOSITE_DEVICE=y
CONFIG_USB_DEVICE_INITIALIZE_AT_BOOT=n

Для получения HID дескриптора можно воспользоваться любым из способов -- использовать win-hid-dump, который нам выдаёт вот такое:

C4F4:2F37: KATVR - walk c2 receiver
PATH:\\?\hid#vid_c4f4&pid_2f37#8&a136e90&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}
DESCRIPTOR:
  06  A0  FF  09  01  A1  01  09  01  15  00  25  FF  35  00  45
  00  65  00  55  00  75  08  95  20  81  02  09  02  91  02  09
  03  95  05  B1  02  C1  00
  (39 bytes)

Либо подключить USB устройство к WSL2 линуху через:

> winget install --interactive --exact dorssel.usbipd-win
> usbipd bind --hardware-id c4f4:2f37
> usbipd attach --wsl -i c4f4:2f37

А в самом WSL вызвать usbhid-dump:

$ sudo usbhid-dump 
001:002:000:DESCRIPTOR         1710781357.084389
 06 A0 FF 09 01 A1 01 09 01 15 00 26 FF 00 75 08
 95 20 81 02 09 02 75 08 95 20 91 02 09 03 75 08
 95 05 B1 02 C0

С практической точки зрения, наверное, неважно чем, но win-hid-dump реконструирует дескриптор по данным винды, тогда как usbhid-dump печатает то, что реально отдаёт устройство.

В любом случае, полученный дескриптор скармливается USB Descriptor and Request Parser, после чего немного шлифуется:

static const uint8_t hid_report_desc[] = {
    HID_ITEM(HID_ITEM_TAG_USAGE_PAGE, HID_ITEM_TYPE_GLOBAL, 2),
    0xA0,
    0xFF,                                       // Usage Page (Vendor Defined 0xFFA0)
    HID_USAGE(0x01),                            // Usage (0x01)
    HID_COLLECTION(HID_COLLECTION_APPLICATION), // Collection (Application)
    HID_USAGE(0x01),                            //   Usage (0x01)
    HID_LOGICAL_MIN8(0x00),                     //   Logical Minimum (0)
    HID_LOGICAL_MAX16(0xFF, 0x00),              //   Logical Maximum (0xFF)
    HID_REPORT_SIZE(8),                         //   Report Size (8)
    HID_REPORT_COUNT(32),                       //   Report Count (32)
    HID_INPUT(0x02),                            //   Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position)
    HID_USAGE(0x02),                            //   Usage (0x02)
    HID_REPORT_SIZE(8),                         //   Report Size (8)
    HID_REPORT_COUNT(32),                       //   Report Count (32)
    HID_OUTPUT(0x02),                           //   Output (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile)
    HID_USAGE(0x03),                            //   Usage (0x03)
    HID_REPORT_SIZE(8),                         //   Report Size (8)
    HID_REPORT_COUNT(5),                        //   Report Count (5) -- why?!
    HID_FEATURE(0x02),                          //   Feature (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile)
    HID_END_COLLECTION,
};

Насколько я понял, USB драйвер в Zephyr сейчас находится в процессе миграции между старым и новым API, при этом старый уже заморожен а новый еще не стабилен. Поэтому некоторые углы не совсем красиво работают, то есть, например, нет статической инициализации HIDов, но абы работало:

static const struct device *hiddev;
static const struct hid_ops usb_ops = {
    .int_in_ready = int_in_ready_cb,
    .int_out_ready = int_out_ready_cb,
};

int start_usb(void)
{
    hiddev = device_get_binding("HID_0");
    if (hiddev == NULL)
    {
        return -ENODEV;
    }

    usb_hid_register_device(hiddev, hid_report_desc, sizeof(hid_report_desc), &usb_ops);

    int err = usb_hid_init(hiddev);
    if (err)
    {
        printk("usb_hid_init failed: %d\n", err);
        return err;
    }

    return usb_enable(NULL /*status_cb*/);
}

Еще раз уточню, что важно чтобы стояло CONFIG_USB_DEVICE_INITIALIZE_AT_BOOT=n иначе операционка поднимет USB для USB-Console слишком рано и будут сложности с конфигурацией HID устройства.

В usb_ops ставятся обработчики соответствующих колбэков. Нам их надо два -- один на "пакет успешно отправлен", второй "пакет получен". Всё остальное от лукавого для нашего случая. Как мы выяснили в прошлой части, всё общение идёт на URB_INTERRUPT пакетах, так что нам не надо разбираться ни с чем особенным.

Что нам надо, так это составить мысленно диаграммку состояний для USB общения. Учитываем, что буферов у нас нет; мы либо приняли пакет (и можем ответить), либо отправили что-то (и можем еще отправить), либо просто свободны что-то послать. Получать мы будем только команды, на некоторые требуется ответ, на некоторые нет. Отправлять мы будем либо ответы на команды, либо обновления данных с сенсоров.

Логика, соответственно, такая:

  • Пришел пакет: обработать.

    • Если нужен ответ: отправить если свободен, иначе буферизовать ответ.

  • Пакет ушел: проверить, есть ли что-то на отправку в буфере.

    • Если нет в буфере, проверить нет ли свежих данных.

    • Если нет свежих данных, свободны.

  • Если приняли данные, проверить не свободен ли USB, если свободен -- отправить.

Соответственно, буферизация нужна только на 1 ответ команды максимум; отвечать мы будем только на команды, так что можно переиспользовать тот же буфер, что для приёма.

Итоговая логика получается такой:

static void usb_write_and_forget(const struct device * dev, void *buf)
{
    int wrote = -1;
    hid_int_ep_write(dev, buf, KAT_USB_PACKET_LEN, &wrote); // feeling lucky
    if (wrote != KAT_USB_PACKET_LEN)
    {
        // This shouldn't happen. To avoid hanging USB, release it and keep trying other time.
        atomic_clear_bit(&usbbusy, cUsbOutBusy);
        printk("Send output bug: wrote only %d bytes instead of %d.\n", wrote, KAT_USB_PACKET_LEN);
    }
}

static void usb_send_or_queue(const struct device * dev, void *buf)
{
    // Check old business status, send if were free.
    if (!atomic_test_and_set_bit(&usbbusy, cUsbOutBusy))
    {
        usb_write_and_forget(dev, buf);
    }
    else
    {
        // Queue the buffer till the next time.
        if (!atomic_ptr_cas(&usb_queue, NULL, buf))
        {
            printk("Output queue is busy. Packet is lost. [should not happen (tm)]");
        }
    }
}

static void int_in_ready_cb(const struct device *dev)
{
    // We enter assuming we own the business lock.
    // If there queued packed to send -- send it now.
    atomic_ptr_val_t queue = atomic_ptr_clear(&usb_queue);
    if (queue)
    {
        usb_write_and_forget(dev, queue);
    }
    else
    {
        // if there is no queued packets - try to send fresh update.
        if (!send_update_packet(dev)) {
            // If there was nothing to send -- we clear out busy signal.
            atomic_clear_bit(&usbbusy, cUsbOutBusy);
        }
    }
}

tKatUsbBuf usb_command_buf;
static void int_out_ready_cb(const struct device *dev)
{
    int read = -1;
    int err = hid_int_ep_read(dev, usb_command_buf, sizeof(usb_command_buf), &read);
    if (!err) {
        if (handle_kat_usb(usb_command_buf, read))
        {
            usb_send_or_queue(dev, usb_command_buf);
        }
    }
}

Поверх этого остова строится тривиальный обработчик протокола и такие же незамысловатые энкодеры.

Шаг пятый: настраиваем тайминги

Теперь вроде как всё по логике хорошо, но не работает -- гейтвей отваливается связь, сигналы дерганые и так далее.

Во-1х, у нас тормозит USB. По умолчанию, USB поллинг работает раз в 9 миллисекунд, то есть у нас получается 111 пакетов в секунду, ибо мы не буферизуем ничего. Это исправляется через параметр

CONFIG_USB_HID_POLL_INTERVAL_MS=1

после чего мы можем до 1000 пакетов в секунду.

Во-2х, у нас тормозит BLE коммуникация. Причин тут несколько.

Сам BLE (на стандартном 1Mbit) работает в квантах по 1.25мс. При установлении соединений мы задаём "Connection Interval" -- с какой периодичностью ожидается окно связи с устройством. Оно задаётся в этих квантах, и минимальный интервал 6 квантов (раз в 7.5мс). Само общение требует времени, как минимум квант (всё чуть сложнее). В случае с сенсорами, они хоть и просят MTU в 250 байт, этим не пользуются, так что можно просто прикинуть что надо отправить пакет туда и обратно, пусть по кванту. Получаем, что у нас 1000/1.25=800 квантов в секунду, два на коммуникацию с каждым сенсором, сенсоров три, получаем 800/3/2 = 133Гц в пределе, но чтобы получить 133Гц нам надо во-1х задать Connection Window в те самые 6 квантов (то есть 2 кванта на связь, 4 кванта на связь с другими двумя, повторить). А затем еще и укоротить само окно общения, со стандартных 7.5мс. Минимально возможное окно может быть 1250 -- 1 квант -- но для стабильной связи потребовалось поднять немного, начиная с ~1500-1600 связь стала стабильной. В итоге я зафиксировался на 2мс:

#prj.conf
CONFIG_BT_CTLR_SDC_MAX_CONN_EVENT_LEN_DEFAULT=2000

И параметры соединения:

static const struct bt_le_conn_param btConnParam = BT_LE_CONN_PARAM_INIT(6, 6, 0, 2000);

При этом важно помнить, что пока мы не установили соединение со всеми сенсорами, нам нужны окна для поиска сенсоров и попыток с ними соединиться. Это не очень критично, с точки зрения потоковой коммуникации, но портит частичный приём пока не поймаем всех.

К сожалению, на максимальной скорости опроса сенсоры начали "проскальзывать": во-1х не каждый пакет присылать данные (это как раз более-менее исправилось с помощью подъёма окна соединения до 2000), во-2х начали прилетать нулевые пакеты, когда сенсор ведёшь-ведёшь, а он раз и внезапно нули, потом опять данные.

В качестве решения... Снизил скорость до 100Гц. На 100гц связь стабилизировалась. Чтобы получить 100Гц, требуется обновлять каждый сенсор каждые 1000/1.25/100 = 8 квантов.

static const struct bt_le_conn_param btConnParam = BT_LE_CONN_PARAM_INIT(8, 8, 0, 2000);

Шаг шестой: поддержка настроек

Спаривание происходит через передачу по USB функции записи MAC адресов сенсоров, соответственно, мой захардкоженый массив надо было выкинуть и заменить на настройку по USB. Но не будешь же спаривать каждый раз (для спаривания в гейтвее надо подключить по очереди все сенсоры к компьютеру кабелем, а для этого сенсор спины надо аж выкручивать из платформы...), поэтому надо запоминать.

Zephyr, разумеется, поддерживает и сохранение настроек через несколько бэкендов, умеющих работать как с флешкой -- как посекторной, так и просто дописываемой.

Чтобы включить поддержку надо в prj.conf подключить всё относящееся: настройки, настройки в NVS, драйвер NVS, драйвер флеша и карту памяти:

CONFIG_NVS=y
CONFIG_FLASH=y
CONFIG_FLASH_MAP=y
CONFIG_SETTINGS=y
CONFIG_SETTINGS_NVS=y

Настройка конкретной платы определяет стандартные регионы флеша, подходящие для сохранения в них данные (как раз во FLASH_MAP), поэтому просто подключение требуемых драйверов задает все необходимые настройки.

Типичным для Zephyr образом, можно прописать статически коллбэки для сохранения-чтения параметров, можно динамически:

SETTINGS_STATIC_HANDLER_DEFINE(
    katrc, "katrc",
    /*get=*/NULL,
    /*set=*/katreceiver_settings_set,
    /*commit=*/NULL,
    /*export=*/katreceiver_settings_export);

В зависимости от требуемого сценария использования можно сохранять всё разом через вызов settings_save(), который вызовет все настроенные export коллбеки, а можно сохранять по одному там где надо. Загрузка параметров из флеша выполняется в момент вызова settings_load, при этом вызываются set коллбек для каждого параметра найденного в секции настроек; по окончании загрузки всех параметров будет вызван commit. В моём случае все настройки грузятся один раз при старте, еще до инициализации всего прочего, поэтому мне хватит только set и export.

Параметры хранятся как key=>id=>value, где key -- строка, id -- уникальный хендл. Во флеше сохранена таблица key=>id, а в саму область сохранения параметров пишутся id и их value, при этом драйвер занимается тем, что при чтении возвращает только последнее значение; сохраняется значение только если оно изменилось, а если дошли до границы страницы -- все последние данные с прошлой страницы переносятся в новую, прежде чем старую стереть. В общем, всё продумано.

Впрочем, есть определённые неудобства, связанные с не полной симметричностью: set() вызывается уже после откусывания префикса ("katrc" заданного в структуре), тогда как export() должен сохранять все параметры с абсолютным путем до параметра.

В итоге сохранение параметра выглядит так:

int katreceiver_settings_export(int(*export_func)(const char *name, const void *val, size_t val_len))
{
    int ret;

    ret = export_func("katrc/devCnt", &numKatDevices, sizeof(numKatDevices));
    if (ret < 0) return ret;

    char argstr[100] = "katrc/dev/";
    char * argsuffix = &argstr[strlen(argstr)]; // argsuffix now is the pointer beyond "/"
    for (int dev = 0; dev < numKatDevices; ++dev) {
        sprintf(argsuffix, "%d", dev); // argstr now katrc/dev/N
        ret = export_func(argstr, &katDevices[dev].a, sizeof(katDevices[dev].a));
        if (ret < 0) return ret;
    }

    return 0;
}

Где мы просто вызываем export_func (поставляемый подключенным и включенным драйвером хранения параметров). А чтение:

int katreceiver_settings_set(const char *key, size_t len, settings_read_cb read_cb, void *cb_arg)
{
    const char *next;
    int ret;

    if (settings_name_steq(key, "devCnt", &next) && !next) {
        if (len != sizeof(numKatDevices)) {
            return -EINVAL;
        }

        ret = read_cb(cb_arg, &numKatDevices, sizeof(numKatDevices));
        if (ret < 0) {
            numKatDevices = 0;
            return ret;
        }
        return 0;
    }

    if (settings_name_steq(key, "dev", &next) && next) {
        int dev = atoi(next);

        if (dev < 0 || dev > ARRAY_SIZE(katDevices)) {
            return -ENOENT;
        }

        if (len != sizeof(katDevices[dev].a)) {
            return -EINVAL;
        }

        katDevices[dev].type = BT_ADDR_LE_PUBLIC; // Well, it's just zero, so noop.
        ret = read_cb(cb_arg, &katDevices[dev].a, sizeof(katDevices[dev].a));
        if (ret < 0) {
            memset(&katDevices[dev].a, 0, sizeof(katDevices[dev].a));
            return ret;
        }
        return 0;
    }

    return -ENOENT;
}

Чтение предполагет вызов на каждый обнаруженный параметр, для которого будет подгружено и его последнее значение и его название, но от названия уже будет откушен префикс, использованный при определении коллбэков параметров. Я понимаю, что так было проще реализовать, а еще это сохраняет микроскопическое количество тактов на протягивание контекста и ощутимое количество тактов на рекомбинацию строк -- поэтому так и было сделано -- но это важнол держать в памяти програмиста.

Для работы с параметрами предоставлены функции чем-то напоминающие strtok: можно проверить совпадает ли параметр с желаемым, будет ли это лист (первая ветка проверяет что параметр -- точно katvr/devCnt), или промежуточный (вторая ветка проверяет, что это katvr/dev/ и после слеша что-то есть).

Хорошей практикой рекомендуют проверять размеры параметров (на случай, что со временем приложение изменится, а настройки не будут очищены).

Так как некоторые модули операционной системы тоже хранят разные параметры (например, если использовать спаривание с сенсорами в Bluetooth смысле через обмен PIN), то предполагается вызывать settings_subsys_init() (настраивающую конкретный драйвер для параметров) и settings_load() для собственно загрузки параметров когда уже всё, что можно было без параметров настроить настроено и пришла пора сделать последний шажочек.

Всякие мелочи, требуемые при обновлении списка маков (типа "отсоедениться от всех сенсоров", "отключить автоподключение", "очистить фильтры", "загрузить фильтры" и так далее по списку) опустим.

Шаг седьмой: общий юзер-френдли

Я могу хоть клинописью на плате выбивать серийник, но для других людей это будет совершенно глупым занятием, поэтому надо сделать удобно, никто не будет компилировать прошивку под себя.

Итак, соберём требования:

  • Устройство должно работать просто при подключении (никакого ожидания подключения терминала)

  • Желательно избежать шага спаривания:

    • C2/C2+ имеют один PID, C2Core имеет другой PID (но тот же протокол и сенсоры)

    • Устройство должно иметь тот же серийник, что у пользователя (чтобы гейтвей не просил спарить сенсоры опять)

    • Устройство должно иметь возможность загрузить в него имеющееся спаривание

  • Простой способ прошивки для пользователя.

Вырезание отладочного функционала оказалось сложнее, чем я думал. Причина -- файлы описаня платформы для Seeeds модуля включают параметры говздяим через оверлей prj.conf. То есть мой исходный подход через задать все умолчания в Kconfig провалились: и SERIAL и CONSOLE и прочие остались включенными.

В качестве решения, в prj.conf пришлось вписать отключения, а включения обратно -- в prj-debug.conf, который подключается через дополнительный оверлей. Криво, косо, неудобно, но зато работает.

Дальше -- для поддержки C2Core я искал способ сменить PID динамчески. Для старого драйвера способа не нашел, разбираться в новом не хотелось... Раз уж я уже сделал два разных конфига для продакшена и дебага, просто добавил еще один конфиг-оверлей и нарисовал для своего удобства релиз скрипт.

В коде USB драйвера есть поддержка задачи серийника в момент загрузки. Но она заточена на однократное применение, причем выполняется очень рано, в момент инициализации драйвера. То есть еще даже до загрузки настроек. Плюс, мне хотелось бы смену серийника на лету, желательно еще и без потери соединения с USB консолью во время отладки.

И я смог этого добиться.

Просто пришлось склонировать часть логики из драйвера: старый драйвер полагается на жесткую структуру всё равно, так что пройтись по ней, найти нужный по порядку дескриптор, и скопировать строку из ascii в utf-16 дескриптора. Всё. Да, это не меняет кеши в операционке (и тот же UsbTreeView не показывает изменения), но API функции чтения дескрипторов после открытия HID устройства каждый раз посылают соответствующий запрос и возвращают актуальную информацию. Таким образом, после смены серийника гейтвей радостно подхватывает мой ресивер как родной.

Но кроме того, что сам донгл должен уметь запоминать серийник, надо еще способ серийник в него загрузить. USB протокол общения с сенсорами уже содержит функцию SetSN (команда 7), но её надо как-то вызвать -- гейтвей за нас это не сделает. К счастью, гейтвей написан на C#, а PowerShell -- это и есть C#, что позволило просто загрузить библиотеку как есть, вызвать её функции чтения настроек и имеющиеся функции для отправки HID пакета:

Add-Type -Path "C:\Program Files (x86)\KAT Gateway\IBizLibrary.dll"
[IBizLibrary.KATSDKInterfaceHelper].GetMethod('GetDeviceConnectionStatus').invoke($null, $null)
$dev = New-Object IBizLibrary.KATSDKInterfaceHelper+KATModels
[IBizLibrary.KATSDKInterfaceHelper]::GetDevicesDesc([ref]$dev, 0)
[IBizLibrary.ComUtility]::KatDevice='walk_c2'
[IBizLibrary.Configs].GetMethod('C2ReceiverPairingInfoRead').invoke($null, $null)

$newSn = [IBizLibrary.KATSDKInterfaceHelper]::receiverPairingInfoSave.ReceiverSN
[byte[]]$newSnArr = $newSn.ToCharArray()
[byte[]]$ans = New-Object byte[] 32
[byte[]]$command = 0x20,0x1f,0x55,0xAA,0x00,0x00,0x07,0x00 + $newSnArr + $ans
[IBizLibrary.KATSDKInterfaceHelper]::SendHIDCommand($dev.serialNumber, $command, 32, $ans, 32)

[IBizLibrary.KATSDKInterfaceHelper]::GetDevicesDesc([ref]$dev, 0)

$pairing=[IBizLibrary.KATSDKInterfaceHelper]::receiverPairingInfoSave.ReceiverPairingByte
[byte[]]$ans = New-Object byte[] 32
[byte[]]$command = 0x20,0x1f,0x55,0xAA,0x00,0x00,0x20,0x00 + $pairing + $ans
[IBizLibrary.KATSDKInterfaceHelper]::SendHIDCommand($dev.serialNumber, $command, 32, $ans, 32)

Добавив в скрипт еще кучу проверок и радостных известий для пользователя, удалось получить простой способ клонирования ресивера.

Для последнего шага, чтобы всё было красиво, осталось только добавить скрипт для выбора файла прошивки (C2 или C2Core) в зависимости от того, какая платформа у пользователя, скопировать её на найденный диск сенсора и после прошивки вызвать клонирование.

Тут я познакомился с тем, что так как PowerShell слишком уж Power, по умолчанию он в системе отключен и просто двойным кликом его не запустить. Пришлось сделать еще и махонький батничек, который вызывает PowerShell скрипт. Да-да, вот так -- можно. Не понимаю где тут безопасность, но что уж. В любом случае, в итоге пакет установки для пользователя представляет собой две прошивки, README.txt, install.cmd для запуска и скрипт установки на powershell. Пользователь-параноик может посмотреть что конкретно делается в скриптах.

Тяжелый параноик -- welcome прочитать исходники, благо их всего ничего.

Шаг восьмой: девелопер-френдли

... Вот только код к текущему моменту превратился в спагетти. Тут у нас разбор пакетов, тут формирование, тут настройки, одно вызывает другое, куда-то надо передать указатели, а где-то глобальные переменные. Каша. Впрочем, когда софт разрабатывался иначе?

- Знаешь, что общего между софтом и колбасой?
- Что?
- Лучше не знать, как это делается.

В итоге несколько выходных я посвятил второму по любимости (после написания своего велосипеда) занятию любого программиста -- рефакторингу.

Мне удалось, как мне кажется, получить более-менее разумное разделение ответственности и минимальную эрозию:

  • В kat_ble* всё, относящееся к BLE обработке, и kat_ble.c зависит только от kat_main.h и kat_ble_pack.c; плюс одна единственная функция kat_usb_try_send_update() для отправки обновления. Можно было добавить тонкую прослойку в main(), но это уже перебор как по мне.

  • В kat_usb* всё, относящееся к USB; так же kat_usb.c зависит только от kat_main.h и своих usb собратьев; плюс kat_ble_get_localaddr и kat_ble_update_devices.

  • Хак на обновление usb серийника вынес в kat_usb_serialno.c, так что когда сломается с переездом на новый USB драйвер, легко будет заменить чем-нибудь соответствующим для нового драйвера.

  • В main.c осталось обслуживание параметров и вызов функций инициализации всего и вся.

Для отладки бетатестерами, чтобы можно было посмотреть что пошло не так, я нагуглил набросок решения, которое с легким допиливанием превратилось в удобный батничек.

Теперь для отладки я просто передаю отладочный бинарь прошивки под платформу (C2 / C2Core) и этот батничек. Двойной клик на ресет, копируется файл, как диск исчез -- запускаешь батничек, результат копируешь мне (или скриншот или копия текста). Становится понятно где конкретно я накосячил :)

Профит!

Ура, оно работает, причем с заметно меньшей latency чем оригинальный ресивер. Сравните сами. Невооруженным взглядом видно, где моё :)

Utopia Machine нарисовал на эту тему новый ролик.

Огромное ему спасибо за большое количество тестирования!

Что дальше

К сожалению, хоть я и добился обновления 133 Гц сенсоров, связь была нестабильной -- сенсоры не всегда присылают пакеты, а иногда присылают, но пустые. Так что первый релиз -- на 100 Гц, так как использование на 133 Гц получается невозможным. Вот только так ли это невозможно? Часть-то времени они работают!

We need to go EVEN deeper
We need to go EVEN deeper

Да-да, вы правы. В следующей серии мы посмотрим, как исправить прошивку сенсоров и избавиться от потери пакетов, а так же добавить гораздо более бесполезные фичи к сенсорам. Я расскажу как работает их прошивка, и как реализовать diff/patch без использования внешних утилит.

Ссылки

Комментарии (5)


  1. XenRE
    22.03.2024 16:15

    можно будет микроприёмник держать на самом хедсете и играть без проводов.

    Так ведь сам шлем уже содержит блютуз. А еще там есть USB, в который можно подключить например USB блютуз свисток, если встроенный чем-то не устраивает.

    Кстати по поводу этих свистков - если не устраивает блютуз стек ОС - можно общаться напрямую с USB устройством, для винды например достаточно написать inf чтобы сменить драйвер с блютузного на winusb, с которым уже можно работать из user mode.

    Кстати а в нативках эта платформа вообще работает? Ведь шлем отслеживает перемещение относительно окружения, и топтание по платформе для него ничего не значит.


    1. datacompboy Автор
      22.03.2024 16:15
      +1

      Так ведь сам шлем уже содержит блютуз. 

      Да, содержит, но стек не даёт доступа до пакетов как есть, о чем я и писал в начале. :(

      Кстати по поводу этих свистков - если не устраивает блютуз стек ОС - можно общаться напрямую с USB устройством

      Свистки разные, с разным уровнем торчания их радиотракта. Нужны те, которые дают сырой HCI достаточно низкого уровня, чтобы получать пакеты в сыром виде.

      Я уж не говорю про Bluetooth Sniffer -- для которого вперёд, покупать свисток, на котором можно добраться до сырого радиотракта (и брать прошивку типа https://github.com/bluekitchen/raccoon).

      У меня сейчас лежит в счисле общей кучки девайсов BleuIO. Вот он тоже отказывается отдавать пакеты как есть :(

      В любом случае, если всё равно нужен внешний свисток -- так почему бы не сделать на нём полную имитацию исходного ресивера?

      для винды например достаточно написать inf чтобы сменить драйвер с блютузного на winusb, с которым уже можно работать из user mode.

      Что делает user-unfriendly. Всё равно надо какое-то доп железо (уже имеющееся не подойдёт), плюс телодвижения по установке дров, и добро пожаловать в мир поддержки зоопарка, так как HCI нифига не стандартизован, а блокировка пакетов происходит вообще где-то в недрах виндовых частей, в которые в Win10 еще можно залезть, то в Win11 я даже не нашел где и кто за них в ответе. Впрочем, я не так долго искал, решил пойти другим путём раньше.

      Кстати а в нативках эта платформа вообще работает? 

      Некоторые игры -- да, поддерживает в стандалон режиме.

      Для нативок у них есть свой APK который ставится прямо на хедсет и патчит игры для инъекции туда обработки, поток для обработки идёт с отдельного девайса (Nexus) к которому подключается платформа. До этой части (и её исправления/улучшения) я дойду позднее :)


      1. XenRE
        22.03.2024 16:15

        HCI нифига не стандартизован

        Вообще стандартизирован, я тоже возился с блютуз свистком (дешевым китайским) и там все внезапно по стандарту. Как раз заводил его через winusb. Может мне конечно повезло, и первый попавшийся китайский свисток внезапно оказался вменяемым.

        блокировка пакетов происходит вообще где-то в недрах виндовых частей

        Блокировка то происходит если винда работает со свистком как с блютуз девайсом, а если поставить драйвер winusb - со свистком можно работать напрямую, посылая ему пакеты по спецификации, а винда вообще не будет его считать блютуз девайсом и вмешиваться в обмен.

        Тут главный вопрос - насколько кривой протокол этой платформы и будет ли стандартный свисток выдавать нужные пакеты.

        P.S. Еще возился с ИК трансивером, так он был какбы HID устройством, но HID там был реализован криво, и нормально не работал. Пришлось тоже через winusb заводить.


        1. datacompboy Автор
          22.03.2024 16:15

          Вообще стандартизирован

          Да, но огромный пласт Vendor-Defined HCI Commands. Для моих целей ключевое было -- если я не могу полагаться что заведётся на том железе, которое точно у юзера есть -- то лучше пусть будет то железо, которое я могу контролировать.

          Да, свисток стоит $10, но он отлажен и точно работает. Либо перебирать $3 свистки с вероятностью что он работать не будет...


        1. datacompboy Автор
          22.03.2024 16:15

          К слову -- я бы с удовольствием почитал про запинывание BLE свистка напрямую через WinUSB!