Pull to refresh

KatWalk C2: ч2, подслушаем, подсмотрим и разнюхаем или как общаться с незнакомым железом на незнакомом языке

Level of difficultyMedium
Reading time27 min
Views886

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

Однако, получение данных требует постоянно висящего приложения (на C#), и надо понять что же конкретно оно делает.

Давайте разберёмся как общаться с железом и избавимся от балласта!.. Переписав на Kotlin. Почему Kotlin? Потому что я на нем никогда еще не писал.

да потому, что это просто интересно!
да потому, что это просто интересно!

Что за extraData

Начнём с пропущенного в прошлый раз: что же такое за массив extraData, который отдаёт SDK.

Напомню, как выглядит структура состояния, которую мы можем запрашивать через SDK:

struct DeviceData
{
    bool    btnPressed;
    bool    isBatteryCharging;
    float   batteryLevel;
    char    firmwareVersion;
};

struct TreadMillData
{
    char         deviceName[64];
    bool         connected;
    double       lastUpdateTimePoint;
    Quaternion   bodyRotationRaw;
    Vector3      moveSpeed;
};

struct KATTreadMillMemoryData
{
    TreadMillData treadMillData;
    DeviceData    deviceDatas[3];
    char          extraData[128];
};

В структуре доступны кварт взгляда (нормализованный; за вычетом нулевого положения, которое можно обновить в любой момент нажав на кнопку калибровки в интерфейсе, или кнопку на сенсоре за спиной, либо нажав два триггера на контроллерах сразу ошибся тут -- кварт нормализованый, но нулевой угол не вычтен, его вычитает гейтвей для себя сам); вектор скорости движения и информацию об успешности соединения плюс момент последнего обновления данных. Помимо этого есть информация о сенсорах -- заряд, версия прошивки и нажата ли там кнопка. Короче, ничего интересного.

Но есть еще extraData, массив, в котором что-то есть! Давайте поймём, что там. В SDK файлах сходу ничего про них нет, так что грузим опять dotPeek (в котором у нас всё еще загружены сборки) и поищем (Ctrl+F) по "extraData". Эм. Ничего?

Так... А если Navigate->Search everywhere (Ctrl+T)..? "ZipExtraData" внутри "SharpZipLib". Точно не оно. Стоооп... по "extra" находится:

extraInfo in KATSDKInterfaceHelper
extraInfoLoco in KATSDKInterfaceHelper
extraInfoMini in KATSDKInterfaceHelper
...

ага, ну, что поделать, особенности copy-paste вероятно. Так, вот это уже похоже на правду:

    [StructLayout(LayoutKind.Sequential)]
    public struct extraInfo
    {
      [MarshalAs(UnmanagedType.U1)]
      public bool isLeftGround;
      [MarshalAs(UnmanagedType.U1)]
      public bool isRightGround;
      [MarshalAs(UnmanagedType.U1)]
      public bool isLeftStatic;
      [MarshalAs(UnmanagedType.U1)]
      public bool isRightStatic;
      [MarshalAs(UnmanagedType.U4)]
      public int motionType;
      public KATSDKInterfaceHelper.Vector3 skatingSpeed;
      public KATSDKInterfaceHelper.Vector3 lFootSpeed;
      public KATSDKInterfaceHelper.Vector3 rFootSpeed;
    }

    ...

    public static KATSDKInterfaceHelper.extraInfo GetExtraInfoC2(
      KATSDKInterfaceHelper.TreadMillData data)
    {
      GCHandle gcHandle = GCHandle.Alloc((object) data.extraData, GCHandleType.Pinned);
      try
      {
        return (KATSDKInterfaceHelper.extraInfo) Marshal.PtrToStructure(gcHandle.AddrOfPinnedObject(), typeof (KATSDKInterfaceHelper.extraInfo));
      }
      finally
      {
        gcHandle.Free();
      }
    }

От оно шо, массив extraData по сути представляет собой union из нескольких -- extraInfo / extraInfoMini / extraInfoLoco, в зависимости от типа используемого локомоушен решения от KAT. Странно, что C2 и C не разделены, так как C базируется на IMU сенсорах, а C2 -- на оптических.

Ищем по использованию...

// ...
    if (Home_Form_Walk_C2_Main.objextraInfo.isLeftGround && ((double) Home_Form_Walk_C2_Main.objextraInfo.lFootSpeed.x != 0.0 || (double) Home_Form_Walk_C2_Main.objextraInfo.lFootSpeed.z != 0.0))
    {
        float num8 = Home_Form_Walk_C2_Main.objextraInfo.lFootSpeed.x * 3f;
        float num9 = Home_Form_Walk_C2_Main.objextraInfo.lFootSpeed.z * 3f;
        Home_Form_Walk_C2_Main.LF_Foot.X += num8;
        Home_Form_Walk_C2_Main.LF_Foot.Y -= num9;
    }
    else if (!Home_Form_Walk_C2_Main.objextraInfo.isLeftGround || (double) Home_Form_Walk_C2_Main.objextraInfo.lFootSpeed.x == 0.0 && (double) Home_Form_Walk_C2_Main.objextraInfo.lFootSpeed.z == 0.0 && (double) Home_Form_Walk_C2_Main.objextraInfo.lFootSpeed.y == 0.0)
    {
        Home_Form_Walk_C2_Main.LF_Foot.X = 40f;
        Home_Form_Walk_C2_Main.LF_Foot.Y = 40f;
    }
// ...

А, понятно, для C2 данные просто в плоскости (x/z), в форме дельт. Правда, непонятно, в каких величинах скорости. И вообще, непонятно откуда берутся эти данные.

Вообще, гейтвей рисует те же данные, что отдаёт тот самый KATNativeSDK.dll. Так, а где они х берёт? Грузим её в IDA, идём в GetWalkStatus...

    strcpy((char *)Buf1, "KAT_SHARED_MEM_");
// ...

и так далее. Окей, то есть у нас есть отдельный процесс/поток, который читает данные, обрабатывает (очевидно, из двух скоростей ног надо получить одну скорость тушки) и складывает в общее место. SDK/клиенты/игры берут когда им надо (скорее всего -- каждый кадр) оттуда последние данные.

Что ж, мы можем избавиться от KATNativeSDK.dll и получить данные напрямую... Из гейтвея, тем же путём. Нет.

We need to go deeper!
We need to go deeper!

Как получить данные напрямую

Что ж, пора посмотреть что же у нее унутре. Возьмём старый добрый USBTreeView (который у меня лежит в C:\Games\, мнда) и посмотрим, что из себя представляет устройство... Видим два KAT девайса.

Один KATVR walk c2 position - HID, второй KATVR walk c2 receiver - HID. Методом выдергивания USB кабеля выясняем, что платформа это KATVR walk c2 receiver. А, да, есть же еще свисток для связи с сиденьем на платформе -- соответственно, это второй из них.

Что ж receiver из себя представляет?

      ========================== Summary =========================
Vendor ID                : 0xC4F4 (Unknown Vendor)
Product ID               : 0x2F37
USB Version              : 1.0
Port maximum Speed       : High-Speed
Device maximum Speed     : Full-Speed
Device Connection Speed  : Full-Speed
Self powered             : no
Demanded Current         : 160 mA
Used Endpoints           : 3

      ======================== USB Device ========================

        +++++++++++++++++ Device Information ++++++++++++++++++
...
 Child Device 1          : HID-compliant vendor-defined device
...
        ---------------- Connection Information ---------------
Connection Index         : 0x01 (Port 1)
Connection Status        : 0x01 (DeviceConnected)
Current Config Value     : 0x01 (Configuration 1)
Device Address           : 0x3D (61)
Is Hub                   : 0x00 (no)
Device Bus Speed         : 0x01 (Full-Speed)
Number Of Open Pipes     : 0x02 (2 pipes to data endpoints)
Pipe[0]                  : EndpointID=2  Direction=IN   ScheduleOffset=0  Type=Interrupt  wMaxPacketSize=0x20    bInterval=1   -> 420 Bits/ms = 52500 Bytes/s
Pipe[1]                  : EndpointID=2  Direction=OUT  ScheduleOffset=0  Type=Interrupt  wMaxPacketSize=0x20    bInterval=1   -> 420 Bits/ms = 52500 Bytes/s

...

        ------------------- HID Descriptor --------------------
bLength                  : 0x09 (9 bytes)
bDescriptorType          : 0x21 (HID Descriptor)
bcdHID                   : 0x0100 (HID Version 1.00)
bCountryCode             : 0x00 (00 = not localized)
bNumDescriptors          : 0x01
Data (HexDump)           : 09 21 00 01 00 01 22 25 00                        .!...."%.
Descriptor 1:
bDescriptorType          : 0x22 (Class=Report)
wDescriptorLength        : 0x0025 (37 bytes)
....

Ага, итого это просто нечто, именующее себя HID устройством (чтоб не требовать никаких драйверов в системе), с двумя трубами -- туда и обратно. Устройство USB2 (и зачем я покупал USB3 удлинитель, как было рекомендовано?), и не прикидывается никем особо -- просто vendor-defined device.

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

Следующий очевидный шаг -- берём Wireshark и ставим его со всем полезным, особенно -- USBPcap.

Запускаем, видим аж пять разных USBPcap устройств (по числу корневых хост-контроллеров в системе). Ну... Процесс обычный -- берём первый, запускаем, втыкаем провод, смотрим есть что или нет. Находим тот, на котором есть реакция. Подключаем, запускаем гейтвей, ждём пока всё соединится. Немного крутим спину, возим тапками по платформе, останавливаем запись.

Теперь надо бы отфильтровать всё, нас не интересующее. У меня интересующие пакеты шли с адресом 5.9.0, значит фильтр usb.addr == "5.9.0". Хм. Ничего кроме DESCRIPTOR пакетов не осталось. Э... Ой. Точно! Нас же интересуют все endpoint'ы, меняем фильтр на usb.addr ~ "5.9.". Вот! Теперь лучше.

GET DESCRIPTOR, SET CONFIGURATION пакеты не очень интересны... О! URB_INTERRUPT out:

wireshark1

Еще ниже -- в паре с ним еще и URB_INTERRUPT in. Так, надо осмотреть это как-то удобнее. Выделяем "HID Data" в обзоре пакетов внизу, правой кнопкой => "Apply as Column", и подтаскиваем его к началу, фильтруем только на канал данных (адрес 5.9.2)... Красота!

Итого. Все пакеты что видим -- одного размера, 59 байт, все USB_INTERRUPT в обе стороны с 32 байта данных.

Невооруженным глазом видна структура пакета:

[1F] [55] [AA] [00] [00] ...

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

Теперь смотрим еще раз пристально на пакеты:

(out) [1F] [55] [AA] [00] [00] [31] [ нули ]
(in) тишина

(out) [1F] [55] [AA] [00] [00] [05] [ нули ]
(in) [1F] [55] [AA] [00] [00] [05] [00] [03] [ нули ]

(out) [1F] [55] [AA] [00] [00] [21] [ нули ]
(in) [1f] [55] [AA] [00] [00] [21] [00] [03] [ca] [f8] ... (и еще много)

(out) [1F] [55] [AA] [00] [00] [A0] [00] [02] [ нули ]
(in) тишина

(out) [1F] [55] [AA] [00] [00] [31] [ нули ]
(in) тишина

(out) [1F] [55] [AA] [00] [00] [30] [ нули ]
(in) тишина

(out) [1F] [55] [AA] [00] [00] [30] [ нули ]
(in) тишина

(out) [1F] [55] [AA] [00] [00] [30] [ нули ]
(in) [1F] [55] [AA] [00] [00] [33] [00] [01] [00] [09] [00] [64] [ нули ]
(in) [1F] [55] [AA] [00] [00] [33] [00] [02] [00] [09] [00] [64] [ нули ]
(in) [1F] [55] [AA] [00] [00] [33] [00] [00] [00] [09] [00] [64] [ нули ]

(in) [1F] [55] [AA] [00] [00] [30] [01] [01] [00] [00] [c4] [00] [ff] [ff] [ff] [ff] [ff] [ff] [ff] [ff] [ff] [00] [00] [00] [00] [00] [82] [00] [00] [00] [00] [00]
...
1f55aa000030020100006f00ffffffffffffffffff0000000000780000000000
1f55aa000030020100006f00ffffffffffffffffff0000000000780000000000
1f55aa00003001010000c400ffffffffffffffffff0000000000800000000000
1f55aa000030020100006f00ffffffffffffffffff0000000000760000000000
...
1f55aa00003000e8000bd38a2d7e00000000000000ffffff0000000000000000
1f55aa00003001010000c400ffffffffffffffffff0000000000820000000000
1f55aa000030020100006f00ffffffffffffffffff0000000000760000000000
1f55aa00003000e8000bd38a2d7e00000000000000ffffff0000000000000000
1f55aa00003001010000c400ffffffffffffffffff0000000000810000000000
1f55aa000030020100006f00ffffffffffffffffff0000000000780000000000
1f55aa00003000e8000bd38a2d7e00000000000000ffffff0000000000000000
1f55aa00003001010000c400ffffffffffffffffff0000000000810000000000
1f55aa000030020100006f00ffffffffffffffffff0000000000770000000000
1f55aa00003000e8000bd38a2d7e00000000000000ffffff0000000000000000

АГА. Теперь смотрим еще пристальнее. Получается, формат пакета это:

  • [1F] => выглядит как длина данных, но все пакеты одной длины, так что одинаковый у всех.

  • [55] [AA] => стандартная битовая маска 10101010 и 01010101 для синхронизации скорости.

  • [00] [00] => пара нулевых байт (вероятно, задержка после синхронизации скорости?)

  • [XX] => один байт номер команды / операции / ответа

  • всё после -- параметры/ответы.

Из того что видим -- команды [05] и [21] читают конфигурацию какую-то. [A0] задаёт яркость подсветки платформы (я подтвердил это покрутив слайдер в настройках). Ну и очевидно, что [30] запускает поток, где [33] вначале даёт какую-то конфигурацию, а потом ответы [30] содержат данные ног и поворота по отдельности.

Итак, теперь есть общая идея что мы ищем... Глянем опять в гейтвей, что там про HID?

//...
    [DllImport("KATDeviceSDK.dll")]
    public static extern void GetSensorInformation(
      out KATSDKInterfaceHelper.sensorInformation obj,
      string sn);

    [DllImport("KATDeviceSDK.dll")]
    [return: MarshalAs(UnmanagedType.I1)]
    public static extern bool DeepSleep(string sn, int type);

    [DllImport("KATDeviceSDK.dll")]
    [return: MarshalAs(UnmanagedType.I1)]
    public static extern bool WriteDeviceId(string sn, int id);

    [DllImport("KATDeviceSDK.dll")]
    [return: MarshalAs(UnmanagedType.I1)]
    public static extern bool ReadDeviceId(string sn, out int id);

    [DllImport("KATDeviceSDK.dll")]
    [return: MarshalAs(UnmanagedType.I1)]
    public static extern bool SendHIDCommand(
      string sn,
      byte[] command,
      int cmdlen,
      byte[] outBuffer,
      int outLen);
//...

Ага, всё вкусное -- в нативном кода внутри KATDeviceSDK.dll.

Но если поглядеть по использованию SendHIDCommand:

    public static int Get_Version(string sn)
    {
      byte[] numArray1 = new byte[32];
      byte[] numArray2 = new byte[32];
      numArray1[0] = (byte) 32;
      numArray1[1] = (byte) 31;
      numArray1[2] = (byte) 85;
      numArray1[3] = (byte) 170;
      numArray1[4] = (byte) 0;
      numArray1[5] = (byte) 0;
      numArray1[6] = (byte) 5;
      for (int index = 0; index < 3; ++index)
      {
        if (KATSDKInterfaceHelper.SendHIDCommand(sn, numArray1, ((IEnumerable<byte>) numArray1).Count<byte>(), numArray2, ((IEnumerable<byte>) numArray2).Count<byte>()))
        {
          if (numArray2[0] == (byte) 0 && numArray2[1] == (byte) 0 && numArray2[2] == (byte) 0)
          {
            C2FirmwareUpdaeManager.SetFirmwareUpdaeState(sn, C2FirmwareUpdaeManager.nowVersion);
            C2FirmwareUpdaeManager.nowVersion = -1;
          }
          else if (numArray2[0] == (byte) 31 && numArray2[1] == (byte) 85 && numArray2[2] == (byte) 170 && numArray2[5] == (byte) 5)
          {
            C2FirmwareUpdaeManager.nowVersion = (int) numArray2[7];
            C2FirmwareUpdaeManager.SetFirmwareUpdaeState(sn, C2FirmwareUpdaeManager.nowVersion);
            return C2FirmwareUpdaeManager.nowVersion;
          }
        }
        Thread.Sleep(10);
      }
      C2FirmwareUpdaeManager.nowVersion = -1;
      C2FirmwareUpdaeManager.SetFirmwareUpdaeState(sn, C2FirmwareUpdaeManager.nowVersion);
      return C2FirmwareUpdaeManager.nowVersion;
    }

То мы узнаём, что:

  • команда [05] -- читает номер версии.

  • команда [07] -- Set_SN, задать серийник?

  • команда [A0] -- не только задаёт яркость подсветки, но и силу тактильный отклик

  • комагда [21] -- читает данные спаривания с сенсорами (параметры: 3 сенсора, каждый сенсор 6 байт, ага)

Плюс узнаём что Vehicle Hub (связь с сиденьем) работает на таких же пакетах.

Записываем эти знания в файлик, и переключается с dotPeek на IDA. Время почитать KATDeviceSDK.dll.

Пробегаемся по экспортированным функциям (и размечаем интересное по вкусу):

char __fastcall ReadDeviceId(char *a1, byte *a2, __int64 a3)
{
// ...
  KatBySN = (HANDLE **)FindKatBySN(a1, a2, a3, a1);
  v5 = (__int64 *)KatBySN;
  if ( !KatBySN )
    return 0;
  v6 = *KatBySN;
  memset(v16, 0, sizeof(v16));
  if ( (int)hid_read_timeout(v6, (byte *)v16, 0x20ui64, 100) < 0 )
    return 0;
  v11 = 0xAA551F20;
  v12 = 0;
  v13 = 3;
  v14 = 0i64;
  v15 = 0i64;
  hid_write(*v5, (char *)&v11, 0x1Fui64);
  timeout = hid_read_timeout((HANDLE *)*v5, (byte *)v16, 0x20ui64, 100);
  v8 = (HANDLE *)*v5;
  v9 = timeout;
  if ( v8 )
  {
    CancelIo(*v8);
    CloseHandle(v8[9]);
    CloseHandle(*v8);
    LocalFree(v8[3]);
    free(v8[5]);
    free(v8);
  }
  if ( v9 < 0 )
    return 0;
  *(_DWORD *)a2 = (unsigned __int8)v16[7];
  return 1;
}

Узнаём еще команды:

  • [23] -- усыпить устройство

  • [08] -- прочитать ID сенсора (6 байт, MAC?)

  • [31] -- MCUStopSend. ага, то есть 31 отключает поток данных, мне не показалось

  • [03] -- ReadDeviceId, не совсем понятно что за ID, но запомним

  • [04] -- WriteDeviceId

  • [20] -- WriteSensorPair. ага, а [21], которую мы видели выше, значит, -- читает данные спаривания.

Всё хорошо, но как понять пакеты [30]? (да и [33] если уж на то пошло)... Так, у нас есть еще функция StartListen, с которой (по логике) должно начинаться ожидание потока. Что у нас в ней?

// ...
  if ( ((v13 - 12087) & 0xFFFFEFFF) == 0 )
  {
    v18[1] = (__crt_strtox *)'lld.2Cr';
    v19 = 15i64;
    goto LABEL_32;
  }
  switch ( v13 )
  {
    case 12070:
      strcpy((char *)&v18[1], "rC.dll");
      v19 = 14i64;
LABEL_32:
      v18[0] = (__crt_strtox *)'evirDTAK';
      goto LABEL_33;
    case 12053:
    case 12069:
    case 12176:
      sub_7FF8E66F7000(v18, 18i64, v3, "KATDriverLocoS.dll");
      goto LABEL_33;
    case 3855:
      sub_7FF8E66F7000(v18, 21i64, v3, "KATDriverWalkMini.dll");
LABEL_33:
      v14 = (__crt_strtox *)v18;
      if ( v20 >= 0x10 )
        v14 = v18[0];
      __crt_strtox::multiply_by_power_of_ten(v14, a1, (unsigned int)v3);
      goto LABEL_36;
    case 40741:
      sub_7FF8E66F7000(v18, 16i64, v3, "KATDriver3DT.dll");
      goto LABEL_33;
  }
  sub_7FF8E66F1090("Device Not Implement!\n");
// ...

Ох уж эти восхитительные оптимизации inline'ов и маленьких std::string'ов! Но идея, в общем-то, понятна. Мы берём KATDrvier{тип}.dll и переходим тудыть.

Стоп стоп... чт за Case'ы такие? А ну-ка, в HEX их:

  if ( ((v13 - 0x2F37) & 0xFFFFEFFF) == 0 )
  {
    v18[1] = (__crt_strtox *)'lld.2Cr';
    v19 = 15i64;
    goto LABEL_32;
  }
  switch ( v13 )
  {
    case 0x2F26:
      strcpy((char *)&v18[1], "rC.dll");
      v19 = 14i64;
...

О, так это же наш USB PID -- 0x2F37 это C2. Прекрасно. Но, в общем-то, не важно. Важно что переключаемся на KATDriverC2.dll.

Внутри у нас всё те же hid_* функции, что и в других файлах, плюс LED(), Vibrate(), и StartListen()/StopListen().

StartListen это нечто огроменное, но зато с отладочной информацией, что ускоряет пропуски неинтересных частей.

Выясняем логику:

  • Грузим KatWalkerBase.dll, откуда импортируем пачку функций (InitAlgorithm, ShutDownAlgorithm, UpdateIMU, SensorDataUpdated, UpdateExtension, UpdateOpticalSensor).

  • Подключаемся (открываем HID устройство)

  • Берём тип устройства

    if ( v65 == 0x2F37 )
    {
      v66 = "KATVR Walk Coord2";
      v67 = 17i64;
    }
    else
    {
      if ( v65 != 0x3F37 )
      {
        sub_180001050("Invaild Device! 0x%x\n", v65);
        goto LABEL_321;
      }
      v66 = "KATVR Walk Coord2 Core";
      v67 = 22i64;
    }

(ага, то есть C2 и C2Core используют разный PID у чипа... Вооот зачем там выше было (v13 - 0x2F37) & 0xFFFFEFFF))

  • Подключаемся:

    • Команда [31]

    • спим секунду

    • Команда [30]

    • открываем shared memory KAT_DEVICE_CONNECTION_

    • и уходим в бесконечный цикл чтения.

Отлично! Мы нашли что хотели. Внутри этого мегацикла разная логика для обработки ошибок, если отвалилось, тишина и так далее. Пока это не важно. Листаем вниз... вниз... вниз... О! Вот, началось!

// ...
    if ( cmdPtr[4] == 0x33 )
    {
        if ( ansLen < 11 )
        goto LABEL_277;
        sub_1800063F0(&Buf2);
        ansLen = *(&v250[64] + 4);
    }
    if ( cmdPtr[4] == 0x32 )
    {
// ...

Итак, пакет [33]... Просто игнорируется. Щ_Щ ну, штошш.

Пакет [32] содержит конфигурацию/состояние сенсора: ID (ага! вот он!), версию прошивки, уровень заряда и факт заряжается ли. Причем, в пакете [32] номер устройства фиксирован: 1 == спина, 2 == левая нога, 3 == правая нога.

Пакет [30] содержит обновление данных, но проверяется на совпадение с ID устройства, полученным из пакета [32].

В принципе, данные ног просты как пробка:

float __fastcall parseFootSensor(char *cmdBuf, FOOT_SENSOR_DATA *sensorDataOut, int sensorPacketNo)
{
// ...
  sensorDataOut->packetNo = sensorPacketNo;
  timestamp_us = sensorDataOut->timestamp_us;
  v8.x = *(cmdBuf + 10) / 59055.117;
  v8.y = *(cmdBuf + 11) / 59055.117;
  sensorDataOut->vec = v8;
  sensorDataOut->status = cmdBuf[25];
  sensorDataOut->something = *(cmdBuf + 4);
  result = -5.1896949e11;
  current_us = Xtime_get_ticks() / 10000000.0;
  sensorDataOut->timestamp_us = current_us;
  v7 = current_us - timestamp_us;
  sensorDataOut->timedelta_us = v7;
  if ( v7 < 0.004 )
    sensorDataOut->timedelta_us = 0.004;
  return result;
}

А вот данные спины уже не так приятны для чтения:

__int64 __fastcall parseDirection(char *cmdBuf, _DIR_SENSOR_DATA *sensorDataOut)
{
// начинается за здравие:
  sensorDataOut->packet_no = dir_packet_count;
  v4 = COERCE_UNSIGNED_INT(-*(cmdBuf + 7));
  v4.m128_f32[0] = v4.m128_f32[0] * 0.00390625;
  v5 = COERCE_UNSIGNED_INT(*(cmdBuf + 9));
  v5.m128_f32[0] = v5.m128_f32[0] * 0.00390625;
  v6 = *(cmdBuf + 8) * 0.00390625;
  *&sensorDataOut->in_vel.X = _mm_unpacklo_ps(v4, v5).m128_u64[0];
  sensorDataOut->in_vel.Z = v6;
  qq2 = pow(2.0, -14.0) * *(cmdBuf + 4);
  qq3 = pow(2.0, -14.0) * *(cmdBuf + 5);
  qq4 = pow(2.0, -14.0) * *(cmdBuf + 6);
  qq1 = pow(2.0, -14.0) * *(cmdBuf + 3);
  sensorDataOut->in_dir.X = qq1;
  sensorDataOut->in_dir.Y = qq2;
  sensorDataOut->in_dir.Z = qq3;
  sensorDataOut->in_dir.W = qq4;
// Вот тут простое и логичное заканчивается, начинается трешак:
  if ( 0.0 > 2.0 )
    sqrt2 = sqrtf(2.0);
  else
    sqrt2 = fsqrt(2.0);
  sin45 = -(sqrt2 * 0.5);
  v13 = ((sin45 * sin45) + 0.0) + ((sin45 * sin45) + 0.0);// 1
  VERTICAL.X = -0.0 / v13;                      // 0
  VERTICAL.Y = -sin45 / v13;                    // 1/sqrt(2)
  VERTICAL.Z = -0.0 / v13;                      // 0
  VERTICAL.W = sin45 / v13;                     // -1/sqrt(2)
  quaternion_multiply(&a1, &VERTICAL, &sensorDataOut->in_dir);
  v14 = sinf(0.78539819);                       // sin(45deg)=1/sqrt(2)
  *&v15 = 0x3F490FDBu;
  *&v15 = cosf(0.78539819);                     // cos(45)=1/sqrt(2)
  v16 = *&v15;
  __zero_0 = (v14 * 0.0) * a1.Z;
  __zero_1 = (v14 * 0.0) * a1.X;
  __zero_2 = (v14 * 0.0) * a1.Y;
  __zero_3 = (v14 * 0.0) * a1.W;
  v16.m128_f32[0] = (((*&v15 * a1.W) - __zero_1) - __zero_2) - (v14 * a1.Z);// v16 = (v16, 0, 0, 0)
  v21 = _mm_shuffle_ps(v16, v16, 0);            // v21 = (v16, v16, v16, v16)
  v21.m128_f32[0] = (((*&v15 * a1.X) + __zero_3) + __zero_0) - (v14 * a1.Y);// v21 = (v21, v16, v16, v16)
  v22 = _mm_shuffle_ps(v21, v21, 225);          // v22 = (v16, v21, v16, v16)
  v22.m128_f32[0] = (((*&v15 * a1.Y) + __zero_3) + (v14 * a1.X)) - __zero_0;// v22 = (v22, v21, v16, v16)
  v23 = _mm_shuffle_ps(v22, v22, 198);          // // v23 = (v16, v21, v22, v16)
  v23.m128_f32[0] = (((*&v15 * a1.Z) + (v14 * a1.W)) + __zero_2) - __zero_1;// // v23 = (v23, v21, v22, v16)
  sensorDataOut->in_dir = _mm_shuffle_ps(v23, v23, 201);// res = (v21, v22, v23, v16)
  Y = sensorDataOut->in_dir.Y;
  v25 = -sensorDataOut->in_dir.Z;
  W = sensorDataOut->in_dir.W;
  sensorDataOut->in_dir.X = -sensorDataOut->in_dir.X;// -v21 => -a1.x/sqrt(2) + a1.y/sqrt(2)
  sensorDataOut->in_dir.Y = Y;                  // +v22 => a1.y/sqrt(2) + a1.x/sqrt(2)
  sensorDataOut->in_dir.Z = v25;                // -v22 => -a1.z/sqrt(2) - a1.w/sqrt(2)
  sensorDataOut->in_dir.W = W;                  // +v16 => a1.w/sqrt(2) - a1.z/sqrt(2)
  timestamp_us = sensorDataOut->timestamp_us;
  *&sensorDataOut->field_1C = _mm_unpacklo_ps(0i64, 0i64).m128_u64[0];
  *&sensorDataOut->field_34 = _mm_unpacklo_ps(0i64, 0i64).m128_u64[0];
  sensorDataOut->field_3C = 0;
  sensorDataOut->field_24 = 0;
  v15 = Xtime_get_ticks() / 10000000.0;
  sensorDataOut->timestamp_us = v15;
  sensorDataOut->timedelta_us = v15 - timestamp_us;
  result = cmdBuf[24] >> 7;
  sensorDataOut->gap60 = result;
  return result;
}

Я расставил море комментариев, чтобы не потерять нить рассуждений... Но итоговый кварт собирается из 4х исходных x/y/z/w вот в такой форме:

  out.x = in.x/sqrt(2) + in.y/sqrt(2);
  out.y = in.y/sqrt(2) + in.x/sqrt(2);
  out.z = -in.z/sqrt(2) - in.w/sqrt(2);
  out.w = in.w/sqrt(2) - in.z/sqrt(2);

Явно нормализация, но для чего и куда -- не спрашивайте. Я записал в блокнотик и пошел дальше.

Дальше в коде стоят проверки сколько видели пакетов, и если меньше чем 40 на каждый сенсор в секунду -- опять посылаем 0x31 и потом 0x30 (пытаемся переподключиться).

Пакеты полученные скармливаются через UpdateOpticalSensor и прочие функции в их "алгоритм", но это уже не важно. Важно, что мы теперь знаем всё, что хотели узнать:

Hidden text
Kat Walk C2 Receiver USB HID protocol:

HID Vendor 0xC4F4, PID 0x2F37 for C2, 0x3F37 for C2Core, 0x8F37 for C2+ Seat receiver

  Command format:
        prefix: 0x1F 0x55 0xAA 0x00 0x00 {command} {arguments, 32bytes total}
                  31   85  170    0    0 
        
  Command:
     GetFirmwareVersion {Command=0x05}: {no args}
     SetSN              {Command=0x07}: {Args = Serial number as ascii string}
     CloseVibration     {Command=0xA0}: 0x00 0x02 0x00 x00
     SetLED		{Command=0xA1}: 0x01 0x02 {HIGH(LED * 1000)} {LOW(LED*1000)}
     SetVibration       {Command=0xA1}: 0x00 0x02 {HIGH(Vibr*1000)} {LOW(Vibr*1000)}
     StartRead          {Command=0x30}: {no args, start listen to updates}
     StopRead           {Command=0x31}: {no args, stop listen to updates}
     WritePairing       {Command=0x20}: Cnt 0x00 0x00 {Mac0} ... {MacN}  // Write $Cnt MACs of sensors, 6 bytes each
     ReadPairing        {Command=0x21}: Cnt 0x00 0x00 {Mac0} ... {MacN}  // Write $Cnt MACs of sensors, 6 bytes each
     GetSensorInformation{Command=0x08}: {no args}  // Read MAC of device (receiver/sensor on usb)
     ReadDeviceId       {Command=0x03}: {no args}  // Read ID of device (receiver/sensor on usb) anwser in byte 7 (offset 5)
     DeepSleep          {Command=0x23}: SleepType 0 0 0 0  // SleepType==0 => off; 1=>on (put to sleep? or disable sleep? dunno)
     WriteDeviceId      {Command=0x04}: ID 0 0 0 // Write ID of device (via USB; left/right/body etc}

  Answer format:
        prefix: 0x1F 0x0x55 0xAA 0x00 0x00 {Command No}
     GetSN {Command=0x06}: 0x?? {Version}

  Stream Updates:

      len  0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
30:  main update? expected len >= 26
0000   1f 55 aa 00 00 30 01 01 00 00 c4 00 ff ff ff ff ff ff ff ff ff 00 00 00 00 00 82 00 00 00 00 00
0000   1f 55 aa 00 00 30 01 01 00 00 c4 00 ff ff ff ff ff ff ff ff ff 00 00 00 00 00 80 00 00 00 00 00
0000   1f 55 aa 00 00 30 01 01 00 00 c4 00 ff ff ff ff ff ff ff ff ff 00 00 00 00 00 81 00 00 00 00 00

0000   1f 55 aa 00 00 30 02 01 00 00 6f 00 ff ff ff ff ff ff ff ff ff 00 00 00 00 00 78 00 00 00 00 00
0000   1f 55 aa 00 00 30 02 01 00 00 6f 00 ff ff ff ff ff ff ff ff ff 00 00 00 00 00 76 00 00 00 00 00
0000   1f 55 aa 00 00 30 02 01 00 00 6f 00 ff ff ff ff ff ff ff ff ff 00 00 00 00 00 78 00 00 00 00 00
  feet sensors:             
                         ^^ [5] sensor id
                                  ^^^^^ [8-9, short => HZ] ??
                                                                      ^^^^^ [20-21, short => X] Speed X/59055.117
                                                                            ^^^^^ [22-23, short => Y] Speed Y/59055.117
                                                                                     ^^ [25, byte] status?

      len  0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
30:  ?!??! main update? expected len >= 26
0000   1f 55 aa 00 00 30 00 e8 00 0b d3 8a 2d 7e 00 00 00 00 00 00 00 ff ff ff 00 00 00 00 00 00 00 00
                         ^^ [5] sensor id
  direction sensor: Q1-Q4 is source for quarterion, A/B/C at the end -- dunno (yet)
                            ^^^^^ [6-7, short => Q1] (2^-14) * Q1
                                  ^^^^^ [8-9, short => Q2] (2^-14) * Q2
                                        ^^^^^ [10-11, short => Q3] (2^-14) * Q3
                                              ^^^^^ [12-13, short => Q4] (2^-14) * Q4

                                                    ^^^^^ [14-15, short => A]  (-A) * (1/256)
                                                          ^^^^^ [16-17, short => C]  (+C) * (1/256)
                                                                ^^^^^ [18-19, short => B]  (+B) * (1/256)
                                                                                  ^^ [24, bool] bit7: button pressed status


      len  0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
32:  ?!??! configuration/state? expecteed len >= 11
0000   1f 55 aa 00 00 32 00 01 02 00 64 04 00 00 00 00 00 00 00 ff ff ff 00 00 00 00 00 00 00 00 00 00
0000   1f 55 aa 00 00 32 01 02 02 00 64 05 ff ff ff ff ff ff ff 00 00 00 00 00 81 00 00 00 00 00 00 00
0000   1f 55 aa 00 00 32 02 03 02 00 64 05 ff ff ff ff ff ff ff 00 00 00 00 00 75 00 00 00 00 00 00 00
                                        ^^ [10] firmware version
                                  ^^^^^ [8-9, short] charge level
                               ^^ [7] bit 0: connected; bit 1: ?
                            ^^ [6] sensor type: 1 == direction; 2 == left; 3==right
                         ^^ [5] sensor id


33:  ?!??! [KATDriverC2 ignores packets, expects ansLen >= 11]
0000   1f 55 aa 00 00 33 00 00 00 09 00 64 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0000   1f 55 aa 00 00 33 00 01 00 09 00 64 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0000   1f 55 aa 00 00 33 00 02 00 09 00 64 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

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

Нативный Gateway -- это просто!

Что ж. Мы знаем как идёт общение, но что с ним можно сделать? Можно сделать свой SDK, со своим лунным модулем!

Но зачем? Чтобы встроить в игру. Я, конечно, игры не пишу, но в комьюнити есть несколько человек, кто их пишут. Например, Utopia Machina занимается разработкой как раз одной из них. У него был вопрос -- а нужен ли нам Gateway, особенно, для Standalone Game?

Так как я только что узнал как общаться без него, мне стало интересно -- а можно ли связаться прямо с хедсета?

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

Прототип: фейслифтинг рандомного проекта и настройка студии.

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

Сперва нам надо поставить Android Studio.

А затем проводим фейслифтинг. Для тех, кто, как и я, делает это впервые, полезные советы:

  • Определимся с целевой версией Android SDK. 22й, под который лежит проект, уже устарел. Quest 3, на текущий момент, требует Android 12L оно же SDK 32. Телефон, что у меня есть, на Android 14, он на SDK 34. Так что в Android Studio устанавливаем в довесок к последнему еще и 32й SDK.

  • При открытии старых проектов, Android Studio не может его загрузить и ругается, иногда предлагая запустить апгрейд скрипт, иногда нет. Соглашаемся со всем автоматическим предложенным: нам не надо максимальную версию, нам надо абы оно могло собраться.

  • Первым делом меняем версию Gradle (если не предложило само) в build.gradle с 3.6.3 на 8.2.1 (или что там нонче последнее стабильное), потом триггерим обновление проекта (иконка со слоном в правом верхнем углу, "Sync Project with Gradle Files", Ctrl+Shift+O). Начинают проклевываться первые робкие ошибки и ворнинги.

  • Меняем минимальный и целевой SDK во всё том же build.gradle, я всё затачиваю под хедсет, так что выставляю compileSdkVersion в 32, minSdkVersion тоже 32, targetSdkVerison, вы не поверите, тоже 32.

  • Затем "мигрируем" (если это можно так назвать) на androidx, во всё том же build.gradle:

    • implementation 'androidx.legacy:legacy-support-v4:1.0.0' меняется на implementation 'androidx.appcompat:appcompat:1.0.2'

    • в android секцию дописываем namespace "com.appspot.usbhidterminal"

  • Для радости последних удобств еще обновляем Java: "JavaVersion.VERSION_1_8" становится "JavaVersion.VERSION_17".

  • Обновляем AndroidManifest.xml:

    • выкидываем targetSdkVersion, он больше здесь не задаётся,

    • выкидываем ненужные uses-permission,

    • дописываем ставшие обязательными android:exported="true" ко всем <activity/>.

  • Я еще выкинул httpd зависимости, так как для моих экспериментов они не нужны.

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

Прототип: изучаем как пишутся приложения

Как я сказал выше, я не писал под Android, так что пришлось посмотреть на как сделан USBHIDTerminal. Впрочем, быстро стало ясно, что ничего нового под луной не обнаружено: структура очень сильно напоминает то, как собирается UI под GTK, а этого карася я уже жарил.

Значит, в res/layout/$Activity.xml лежит описание GUI -- какие компоненты засунуты в какие другие компоненты. 1-в-1 как в glade. К компонентам прописываются сигналы, на эти сигналы вешаются обработчики, которые что-то там делают. Добавляем, соответственно, кнопку для поиграться:

    <Button
        android:id="@+id/btnLedOn"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_toRightOf="@id/btnClear"
        android:minHeight="36dip"
        android:text="Led ON"
        android:textAppearance="?android:attr/textAppearanceSmallInverse" />

Не, пусть будет 4 кнопки (...). Затем идём в основной код и добавляем бойлерплейта:

public class USBHIDTerminal extends Activity implements View.OnClickListener {
// ...
    private Button btnInit;
    private Button btnStop;
    private Button btnLedOn;
    private Button btnLedOff;
// ...

    private void initUI() {
// ...
        btnLedOn = (Button) findViewById(R.id.btnLedOn);
        btnLedOn.setOnClickListener(this);
// ... и еще 3 штуки
    }

    public void onClick(View v) {
// ...
        } else if (v == btnInit) {
            eventBus.post(new USBDataSendTypedEvent("0x1F 0x55 0xAA 0x00 0x00 0x30 0x00 0x00 0x00 0x00", true));
        } else if (v == btnStop) {
            eventBus.post(new USBDataSendTypedEvent("0x1F 0x55 0xAA 0x00 0x00 0x31 0x00 0x00 0x00 0x00", true));
        } else if (v == btnLedOn) {
            eventBus.post(new USBDataSendTypedEvent("0x1F 0x55 0xAA 0x00 0x00 0xA1 0x01 0x02 0x03 0x8F", true));
        } else if (v == btnLedOff) {
            eventBus.post(new USBDataSendTypedEvent("0x1F 0x55 0xAA 0x00 0x00 0xA1 0x01 0x02 0x00 0x00", true));
        }
// ...
   }

	public void onEvent(DeviceAttachedEvent event) {
// ...
        btnLedOn.setEnabled(true);
        btnLedOff.setEnabled(true);
        btnStop.setEnabled(true);
        btnInit.setEnabled(true);
// ...
        // и такое же в DeviceDetachedEvent
    }

Пересобираем, запускаем в отладчике -- кнопки есть, но устройств не видно. Логично, надо как-то пробросить usb внутрь. Беглый поиск -- нет ответа. Углубленный поиск -- что-то как-то тоже нет. :(

Что ж, будем тестить на телефоне. Подключаем проводом, ставим приложение, отключаем провод, подключаем провод от платформы, запускаем, ура! Можно помигать лампочкой! И можно запустить поток. Ну, да, всё как мы хотим.

Правда, пользоваться неудобно, надо всё же сделать своё приложение.

Прототип: собираем всё своё

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

UI пусть будет а-ля гейтвей: нарисуем стрелку направления тела и два поля с точками векторов скоростей ног.

Значит, нам надо два компонента: для ног и для стрелки. Создаём новый компонент, ArrowView.

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

  • угол этой самой стрелки,

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

  • параметры компонента (высота-ширина, ибо интерфейс резиновый).

Примеры для всего уже в наличии, и даже пример onDraw есть. Да, где-то я это уже видел -- очень похоже на cairo который предполагалось использовать в GTK. Идея проста: рисуем стрелку как длинный прямоугольник, потом накладываем матрицу поворота в одну сторону, рисуем еще один прямоугольник, накладываем матрицу поворота в другую сторону -- еще один прямоугольник, получаем нечто напоминающее стрелку.

Добавляем перед этим всем поворот на угол всей стрелки -- и вуаля, готово. Вывод текста оставляем какой был.

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

Затем ругаемся тихонечко про себя, и правим res/values/attrs.xml так как оно не компилируется из-за дубликации параметров. Странно, в общем, работает мастер создания компонента из шаблона. Но быстрый гуглинг даёт ответ, как сгруппировать атрибуты:

<resources>
    <attr name="text" format="string" />
    <attr name="textSize" format="dimension" />
    <attr name="textColor" format="color" />

    <declare-styleable name="FeetDotView">
        <attr name="text" />
        <attr name="textSize" />
        <attr name="textColor" />
    </declare-styleable>

    <declare-styleable name="ArrowView">
        <attr name="textSize" />
        <attr name="textColor" />
    </declare-styleable>
</resources>

Что можно объявить какой параметр к кому относится до тех пор, пока их типы совпадают. Всё, ура.

Теперь можно подправить res/layout/activity_main.xml, куда надо добавить VBox c двумя HBox внутри в смысле,
<LinearLayout android:orientation="vertical">
внутрь которого положить парочку
<LinearLayout android:orientation="horizontal">.

В первый кладём кнопки управления, во второй -- свежесозданные наи ArrowView и два FeetDotView.

Теперь, к архитектуре самого приложения. Нам понадобится:

  • Поток для обработки пакетов от USB соединения, его задача -- принимать и отправлять пакеты.

  • Драйвер самой платформы, задача которого превращать пакеты в разумную информацию.

  • Основной поток UI, задача которого запустить фоновые потоки и потом обновлять состояние компонентов.

  • Плюс потребуется что-то для передачи событий между фоновым потоком и UI. Тут можно просто периодически (на каждый кадр) читать последнее состояние, либо можно организовать передачу сообщений и пусть каждый раз как придёт обновление -- мы его получим и передадим.

Вроде бы всё просто. В принципе, USBHIDTerminal организован подобным же образом -- откуда узнаем, что фоновые потоки в андроиде это Service, а для обмена сообщений есть простой и удобный eventbus. Поправка: Thread() никуда не делись, но Service это нечто делающее без участия в прямом UI пользователя, тогда как все Activity и прочие могут и будут нещадно убиваться. (Да, надо бы почитать подробнее, если вдруг придётся полноценно заняться разработкой под Android).

Импортируем последнюю версию eventbus'а и через [бутерброд]=>File=>New=>Service создаём KatGatewayService.

Итак, сервис должен сделать две вещи: найти USB хвост платформы (и/или поймать момент его подключения) и общаться с платформой после этого. Для "найти хвост" нам надо указать что приложению понадобится USB Host фича через <uses-feature android:name="android.hardware.usb.host" />. А для "поймать его момент подключения" (то есть чтобы приложение открывалось при подключении хвоста) надо добавить в Mainactivity соответствующий фильтр устройств, которыые нам интересны, плюс в <intent-filter/> добавить что мы хотим реагировать на событие:

    <activity
        android:name=".MainActivity"
        android:exported="true"
        android:screenOrientation="landscape">
        <intent-filter>
            <action android:name="android.intent.action.MAIN" />
            <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" /> <!-- !!!!! -->
            <category android:name="android.intent.category.LAUNCHER" />
        </intent-filter>

        <meta-data
            android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
            android:resource="@xml/usb_device_filter" /> <!-- !!!!! -->
    </activity>

Сам файл xml/usb_device_filter.xml представляет собой список VID+PID устройств:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!-- in hex: USB\VID_C4F4&PID_3F37 = C2 Core Receiver -->
    <usb-device vendor-id="50420" product-id="16183" />
    <!-- in hex: USB\VID_C4F4&PID_2F37 = C2/C2+ Receiver -->
    <usb-device vendor-id="50420" product-id="12087" />
</resources>

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

Итак, теперь нам надо в class MainActivity добавить инициализацию eventBus и запустить USB Service:

class MainActivity : AppCompatActivity(), View.OnClickListener {
    protected var eventBus = EventBus.getDefault()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        eventBus.register(this)
        katGatewayService = Intent(this, KatGatewayService::class.java)
        startService(katGatewayService)
        setContentView(R.layout.activity_main)
        initUI()
    }
//...

В что делать в initUI мы уже знаем, onClick тоже тривиален. Новый EventBus еще перешел на декораторы, так что нет необходимости делать onEvent, вместо этого делаем onЧтоугодно и помечаем его через @Subscribe().

Итак, с приложением и UI всё готово, соорудим сервис.

Сервису потребуется во-1х подписаться на USB события, во-2х запросить разрешения на связь. Разрешения работают по принципу отложенных событий плюс возможности проверить есть ли разрешение, таким образом все запросы разрешений асинхронные (и отрисовываются и/или обрабатываются системой отдельно от приложения).

Соответственно, сервис на старте подписывается, и триггерит поиск устройства, на случай если оно уже подключено:

    override fun onCreate() {
        super.onCreate()
        usbManager = getSystemService(USB_SERVICE) as UsbManager
        permissionIntent = PendingIntent.getBroadcast(
            this,
            0,
            Intent(ACTION_USB_PERMISSION),
            PendingIntent.FLAG_MUTABLE
        )
        filter = IntentFilter(ACTION_USB_PERMISSION)
        filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
        filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
        registerReceiver(usbPermissionReceiver, filter)
        eventBus.register(this)
        scanUsb()
    }

    fun scanUsb() {
        if (usbConnectedDevice != null) {
            disconnectDevice()
        }
        usbManager.deviceList.values.forEach {
            if (it.vendorId == 0xC4F4 && (it.productId == 0x2F37 || it.productId == 0x3F37)) {
                usbManager.requestPermission(it, permissionIntent)
                return@forEach
            }
        }
    }

requestPermission либо покажет пользователю окошко разрешения, либо примет решение это пропустить и сразу выдать (или не выдать). Так как мы создали IntentFilter для системный броадкастов (и нашего PendingIntent), то как что-либо из вышеобозначенного произойдёт, созданный нами usbPermissionReceiver() будет вызван:

    private val usbPermissionReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent) {
            val action = intent.action
            if (ACTION_USB_PERMISSION == action) {
                connectDevice(intent)
            }
            if (UsbManager.ACTION_USB_DEVICE_ATTACHED == action) {
                connectDevice(intent)
            }
            if (UsbManager.ACTION_USB_DEVICE_DETACHED == action) {
                disconnectDevice(intent)
            }
        }
    }

И вот когда разрешение было выдано и устройство тоже подключено, то connectDevice таки будет вызван. Важно: Service это не отдельный поток, это одельная функциональность приложения, но она вызывается из основного потока. Поэтому мы не можем просто взять и прямо здесь запустить бесконечный цикл приёма. Так что мы только проверяем что разрешения действительно есть, получаем endpoint'ы для чтения и отправки и запускаем непосредственно поток:

    fun connectDevice(intent: Intent) {
        val device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE)
        if (device != null && intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
            usbConnectedDeviceConnection = usbManager.openDevice(device)
            if (usbConnectedDeviceConnection == null) {
                return
            }
            usbConnectedDevice = device
            for (i in 0..usbConnectedDevice!!.interfaceCount - 1) {
                usbConnectedDevice!!.getInterface(i).let { intf ->
                    for (j in 0..intf.endpointCount - 1) {
                        intf.getEndpoint(j).also {
                            if ((it.direction == UsbConstants.USB_DIR_OUT)) {
                                if (usbSendEndpoint == null) {
                                    usbSendEndpoint = it
                                    usbConnectedDeviceConnection!!.claimInterface(intf, true)
                                }
                            } else if ((it.direction == UsbConstants.USB_DIR_IN)) {
                                if (usbReadEndpoint == null) {
                                    usbReadEndpoint = it
                                    usbConnectedDeviceConnection!!.claimInterface(intf, true)
                                }
                            }
                        }
                    }
                }
            }
            if (usbReadEndpoint == null || usbSendEndpoint == null) {
                disconnectDevice()
                return
            }
            usbReaderThread = USBReaderThread()
            usbReaderThread!!.start()
            eventBus.post(KatDeviceConnectedEvent(katWalk))
        }
    }

Вообще, код получился переусложнённым: мы точно знаем с каким устройством работаем, мы знаем сколько там endpoint'ов и в каком они идут порядке. Так что можно было ужать код.

disconnectDevice еще проще -- взводим флаг "всё, баста", ждём пока поток завершится и отключаемся.

Теперь о самом потоке USB обмена:

  • Читаем данные, с небольшим таймаутом.

  • Передаём прочитанные данные в функцию обработки пакета.

  • _ Если данных нет -- передаём ошибку в функцию обработки пакета.

  • Если функция обработки пакета передала нам что-нибудь для отправки -- отправляем.

  • Если функция обработки пакета сообщила о событии изменения данных -- отправляем соответствующее событие в eventBus.

  • Повторять, пока можно.

Соответственно, весь "интеллект" переносится в "драйвер" платформы. Интеллект, впрочем, особый и не нужен:

  • Если пришел пакет -- значит, связь есть;

  • Если пришла ошибка или пакета нет (таймаут на чтении) -- что-то пошло не так.

Для "что-то не так" берём счетчик плохих пакетов, и если их несколько -- сбрасываем соединение (выкл, потом вкл).

Для разбора пакетов придётся погрызть кактус: в Kotlin добавили поддержку unsigned, но как-то странно. Литералов нет, точнее они есть, но всё как-то косо и криво. В итоге чтобы всё работало надо приводить Byte к UByte, включая литералы: (bytes[2].toUByte() == 0xAAu.toUByte()). Выносим эти неприятности в отдельные функции, разматываем Quaternion нормализацию и получаем на выходе простой и лаконичный вариант типа:

    class DirectionSensor : Sensor() {
        protected var _direction: Quaternion = Quaternion.identity()
        protected var _angleDeg: Float = 0f
        protected var _angleZero: Float = 0f

        var direction: Quaternion
            get() = _direction
            set(value) {
                _direction = value
                val _angle = atan2(2 * (value.w * value.y - value.x * value.z), (value.w*value.w + value.x*value.x - value.y*value.y - value.z*value.z))
                _angleDeg = (_angle  * 180.0f / Math.PI).toFloat()
            }

        val angleDeg: Float
            get() = normalAngle(_angleDeg - _angleZero)

        fun normalAngle(x: Float): Float {
            if (x > 360f) {
                return x - 360f
            }
            if (x < 0f) {
                return x + 360f
            }
            return x
        }

        override fun parsePacket(packet: ByteArray) {
            val m15 = 0.000030517578125f // 2^-15
            val q1 = readShort(packet, 7)
            val q2 = readShort(packet, 9)
            val q3 = readShort(packet, 11)
            val q4 = readShort(packet, 13)
            direction = Quaternion(
                (+ q1 - q2 - q3 + q4) * m15,
                (- q1 - q2 + q3 + q4) * m15,
                (+ q1 + q2 + q3 + q4) * m15,
                (+ q1 - q2 + q3 - q4) * m15
            ).normalized()
            if (packet[25].toInt() < 0) {
                _angleZero = _angleDeg
            }
        }
    }

    class FootSensor : Sensor() {
        protected var _move_x: Float = 0f
        val move_x: Float
            get() = _move_x

        protected var _move_y: Float = 0f
        val move_y: Float
            get() = _move_y

        protected var _shade: Float = 0f
        val shade: Float
            get() = _shade

        protected var _ground: Boolean = false
        val ground: Boolean
            get() = _ground

        override fun parsePacket(packet: ByteArray) {
            _move_x = readShort(packet, 21) / 59055.117f
            _move_y = readShort(packet, 23) / 59055.117f
            _shade = packet[26].toInt() / 127f
            _ground = (packet[9] == 0.toByte())
        }
    }

Для общения с кодом USB обмена вводим еще Queue в которую будем складывать пакеты для отправки если надо (те самые стоп, старт, включить-выключить свет), и получаем итоговый код обработчика.

Компилируем, заливаем на телефон, отключаем провод, подключаем другой... Изучаем как включить wifi debug, перезагружаемся. Не спрашивайте, но иногда wifi pairing отказывается работать, лечится при этом либо перезагрузкой либо в консоли
adb pair $IP:$pairport $code
adb connect $IP:$connport
Порты для спаривания и соеднения разные, телефон во вкладке wifi debugging показывает порт соединения, но если зайти в спаривание покажет второй и код связи), соединяемся и ура -- можно подключить кабель к телефону а управлять и наблюдать прямо из студии. Удобно!

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

Native Gateway on Quest 3
Native Gateway on Quest 3

Поделился наработкой с вышеупомянутым стримером, кто обещает встроить "нативный гейтвей" в свою игру, а пока просто запилил ролик с грандиозными планами, чтоб поделиться с другими в Community.

... Он же уже передал мне баги этой версии: после запуска приложения и соединения всё работает прекрасно, но если отложить и дать ему уснуть, то потом не удаётся восстановить связь, надо перезапускать приложение. Определённо, надо таки прочитать документацию на сервисы и треды... Но если есть желающие поправить -- принимаю пулреквесты!

В следующей серии

Что ж, раз есть планы -- будем делать! Но для этого надо избавиться от провода -- не для того же Standalone игры, чтобы требовать провод. Так ли нужен провод? Как работают сенсоры? Мы пропатчим прошивки, попробуем дописать недописанное и, разумеется, пойдём на еще один уровень ниже!

Следующая серия тут.

Ссылки

  • Часть 1: "Играем с платформой" на [Habr], [Medium] и [LinkedIn].

  • Часть 2: "Начинаем погружение" на [Habr], [Medium] и [LinkedIn].

  • Часть 3: "Отрезаем провод" на [Habr], [Medium] и [LinkedIn].

  • Часть 4: "Играемся с прошивкой" на [Habr], [Medium] и [LinkedIn].

  • Часть 5: "Оверклокинг и багфиксинг" на [Habr], [Medium] и [LinkedIn].

Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
Total votes 2: ↑2 and ↓0+2
Comments4

Articles