Reverse Engineering d'un clavier NZXT

Comprendre et réimplémenter un protocole RGB propriétaire


Introduction

Ce projet part de la “simple” volonté de jouer avec du ambilight ou d’autres effets avancés sur mon clavier NZXT Function. Malheureusement comme toujours le protocole est propriétaire et le clavier seulement contrôlable via l’application du constructeur (NZXT Cam).

Encore plus malheureusement personne dans la communauté n’a encore reversé ce clavier pourtant connu, ni même un autre clavier RGB NZXT.

Heureusement avec Wireshark, l’outil propriétaire, un peu d’IA et de quoi rejouer des paquets on a largement de quoi comprendre le protocole.

Outil propriétaire et capture d’une trame de contrôle RGB

NZXT Cam presentation

Les fonctions propriétaires sont plutôt limitées.

Pour capturer l’échange RGB on a besoin de :

Le remplacement du driver se fait facilement avec Zadig.

Attention, après changement du driver il peut être difficile de réinstaller le driver propriétaire (généralement désinstaller le device via Windows, possiblement réinstaller NZXT Cam).

Pour identifier le clavier dans Wireshark, il faut trouver la bonne interface à capturer, ma seule solution est d’écouter sur toutes, et voir quelle interface réagit uniquement à l’appui d’une touche sur le clavier, ou d’un changement de couleur, c’est assez rapide.

Maintenant, lorsqu’on change la couleur d’une seule touche sur Cam on voit ces paquets :

Wireshark Exchange

On observe que :

Extraction d’informations de base

Pour plus tard rejouer les paquets, il faut avant tout:

Vendor ID et product ID sont trouvables dans le paquet GET DESCRIPTOR Response DEVICE envoyé au branchement du clavier.

Interface et endpoint sont trouvables dans le paquet GET DESCRIPTOR Response CONFIGURATION, mais il y en a plusieurs:

USB interfaces and endpoints in wireshark

Le fonctionnement interface / endpoint est tel que:

Device USB
 ├── Interface 0 (Fonction A)
 │   ├── Endpoint 1 IN
 │   └── Endpoint 2 OUT
 └── Interface 1 (Fonction B)
     └── Endpoint 3 IN

Sur le NZXT Function il n’y qu’un seul endpoint OUT, avec l’ID 0x02. Il y a deux interfaces, dont une avec le type KEYBOARD indiqué par Wireshark, c’est donc l’autre, avec l’ID 0x01.

Info Valeur
Vendor ID 0x1e71(NZXT)
Product ID 0x2106
Interface ID 0x01
Endpoint ID OUT 0x02
Endpoint ID IN 0x82

Rejeu et modification des paquets

Utilisant les infos de base précédentes, écrivons un premier script de connection en utilisant libusb:

#include "libusb.h"
#define NZXT_FUNCTION_KEYBOARD_RGB_SEND_ENDPOINT     2
#define NZXT_FUNCTION_KEYBOARD_RGB_RECV_ENDPOINT 0x82
#define NZXT_FUNCTION_KEYBOARD_DATA_SIZE 64
#define NZXT_FUNCTION_KEYBOARD_RGB_INTERFACE_ID 1

int main() {
    libusb_context* ctx = nullptr;
    libusb_device_handle*     handle = nullptr;
    
    libusb_init(&ctx);
    
    handle = libusb_open_device_with_vid_pid(ctx, 0x1e71, 0x2106);
    if (!handle) {
        libusb_exit(ctx);
        return;
    }
    
    // Auto-détache kernel driver
    libusb_set_auto_detach_kernel_driver(handle, 1);
    
    // Claim cette interface
    if (libusb_claim_interface(handle, NZXT_FUNCTION_KEYBOARD_RGB_INTERFACE_ID) < 0) {
        return;
    }
    
    // CODE
    
    libusb_release_interface(handle, NZXT_FUNCTION_KEYBOARD_RGB_INTERFACE_ID);
    libusb_close(handle);
    libusb_exit(ctx);
}

Avec les fonctions d’envoi / réception suivantes:

void send(libusb_device_handle* handle, unsigned char* rgb_data) {
    int transferred = 0;
    int res = libusb_interrupt_transfer(
        handle,
        NZXT_FUNCTION_KEYBOARD_RGB_SEND_ENDPOINT,
        rgb_data,
        NZXT_FUNCTION_KEYBOARD_DATA_SIZE,
        &transferred,
        1000
        );
    if(res != 0) {
        LOG_ERROR("libusb_interrupt_transfer error: %s", libusb_error_name(res));
    }
}

void receive(libusb_device_handle* handle) {
    int transferred = 0;
    for (int i = 0; i < 1; i++) {
        unsigned char data_in[64] = {0};
        libusb_interrupt_transfer(
            handle,
            NZXT_FUNCTION_KEYBOARD_RGB_RECV_ENDPOINT,
            data_in,
            NZXT_FUNCTION_KEYBOARD_DATA_SIZE,
            &transferred,
            100
            );
    }
}

Le code suivant sera à la place de //code

Pour “jouer” avec les données:

unsigned char test_data[64] = {
    0x43, 0xb5, 0x00, 0x37, 0x00, 0x08, 0xff, 0x7f,
    0xff, 0x5f, 0xff, 0x5f, 0xff, 0x7f, 0xff, 0x6d,
    0x27, 0x7e, 0xfa, 0x47, 0xfe, 0x5f, 0x00, 0x00,
    0x3d, 0x00, 0x02, 0xff, 0xff, 0xff, 0x07, 0x00,
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x00, 0x46, 0x00, 0x03, 0x02, 0xff, 0xff, 0xff,
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
};
send(test_data)
receive()

Le receive doit être placé après chaque requête (sinon le clavier n’accepte plus rien).

Voir le clavier réagir signifie que l’envoi est correct.

Analyse du protocole

A force de rejeu / écoute et d’analyse du comportement réel, on déduit certaines commandes:

Avec le format suivant:

USB packets analysis

(paquet 1 en haut, paquet 2 en bas)

En jouant avec les modes, on déduit les suivants: - 0x01: Statique - 0x02: Eteint / Inconnu - 0x03: Arc en ciel - 0x04: Respiration

Pour la bitmap des touches, chaque byte représente 5 touches, pour allumer la touche 1 on fait: 0b00000001, pour 1 et 2 allumer le deuxième bit etc. (un OR binaire)

Enchainement des paquets

Pour définir plusieurs couleurs et faire du vrai touche par touche, on doit donc émettre plusieurs paquets (premier paquet définit 2 couleurs, deuxième définit 3 couleurs etc.). Emettre un premier paquet 0x43,0xbd puis 0x43,0x3d semble fonctionnel jusqu’au quatrième paquet. (soit 9 couleurs). La solution semble pour l’heure être d’écouter le code pour chaque paquet (bd,3d,0c etc.) jusqu’au 23ème (chaque touche à une couleur différente).

Mon problème est qu’actuellement avec tous mes tests, je n’arrive pas à reconnecter le clavier à Cam (même avec driver fonctionnel), il semble que le driver soit très sensible à ce que répond le clavier…

Pour l’heure commençons par implémenter un premier plugin OpenRGB.

Implémentation dans OpenRGB

L’implémentation de controleurs RGB n’est pas très documentée (la doc principale est ici).

Par contre le code se lit plutôt bien de lui même, continuons avec libusb, mes tests avec hidapi, préconisé par OpenRGB n’ont pas été concluants.

QtCreator importe bien le projet, et une fois VisualStudio C++ SDK installé le projet est directement reconnu avec le bon profil de build.

Commençons par créer la base du controleur:

Ce qui donne dans mon cas cette structure de fichiers:

img.png

Puis commencer à implémenter le détecteur.

Créer le détecteur

L’ensemble du plugin est importé à partir de cette fonction:

REGISTER_HID_DETECTOR_IPU("NZXT Function Keyboard", DetectNZXTFunctionKeyboardControllers, 0x1e71, 0x2106, 0, 0x001, 0x006);

Il va ajouter une fonction de callback appelée quand le vendor ID, le product ID et les filtres corrects sont rencontrés.

Le constructeur du callback:

void DetectNZXTFunctionKeyboardControllers(hid_device_info* info, const std::string& name)

Le callback:

NZXTFunctionKeyboardController*     controller     = new NZXTFunctionKeyboardController(*info);
RGBController_NZXTFunctionKeyboard* rgb_controller = new RGBController_NZXTFunctionKeyboard(controller);
rgb_controller->name                         = name;

ResourceManager::get()->RegisterRGBController(rgb_controller);

Deux éléments distincts sont définis: - Le controller, backend du protocole - Le Device, hérité de RGBController, il mappe les fonctions OpenRGB et les propriétés requises à notre device

La fonction RegisterRGBController ajoute le contrôleur à l’interface OpenRGB.

Implémenter le device

Le device n’est pas très intéréssant en lui même, il s’agit principalement de code générique adapté, seule la méthode DeviceUpdateLEDs est intéressante.

La fonction DeviceUpdateLeds():

std::map<std::tuple<int, int, int>, std::vector<unsigned int>> leds_states_by_color; // {(R,G,B): [indexes]}

for(int i = 0; i < NZXT_FUNCTION_KEYBOARD_NUM_LEDS; i++)
{
    auto rgb_tuple = std::tuple<int,int,int>(RGBGetRValue(colors[i]), RGBGetGValue(colors[i]),RGBGetBValue(colors[i]));
    if(rgb_tuple == std::tuple<int,int,int>(0, 0, 0)) {
        continue;
    }
    if(leds_states_by_color.find(rgb_tuple) == leds_states_by_color.end()) {
        // Append the color to the list
        leds_states_by_color[rgb_tuple] = std::vector<unsigned int>({leds[i].value});
    } else {
        // Append the led to this color
        leds_states_by_color[rgb_tuple].push_back(leds[i].value);
    }
}

controller->SendColors(leds_states_by_color, modes[active_mode].brightness);

Pour obtenir la couleur des leds, il faut passer par RGBGet<R/G/B>Value, et on stocke les trois dans un tuple. Puis on rassemble les leds par couleur. Notez que je n’utilise pas <i> l’index de la led mais leds[i].value qui correspond au layout.

Implémenter le protocole

La meilleure partie, qui finalement ne fait que reprendre le code que j’ai déjà, avec les modifs suivantes:

Les fonction send et receive sont exactement les mêmes, mises en méthode du controller.

Il faut aussi rajouter les fonctions permettant d’obtenir SerialString, Firmware version etc. (les miennes sont approximatives).

Puis la fonction SendColors(std::map<std::tuple<int, int, int>, std::vector<unsigned int>> leds_states_by_color, unsigned char brightness): D’abord faire les payloads des paquets, cette partie là:

img.png

En ajoutant simplement la donnée pour chaque couleur.


std::deque<unsigned char> leds_bytes;
for(auto e : leds_states_by_color) {
    auto color = e.first;
    auto leds = e.second;
    std::vector<unsigned char> bitmap(16, 0); // Keys *16
    //unsigned char bitmap[16] = {0};
    for(auto led_id : leds) {
        int byte_index = led_id / 8;
        int bit_index = led_id % 8;
        bitmap[byte_index] |= (1 << bit_index);
    }

    leds_bytes.push_back(0x01); // Mode
    leds_bytes.insert(leds_bytes.end(), bitmap.begin(), bitmap.end()); // Key * 16
    leds_bytes.push_back(0x00); // Padding
    leds_bytes.push_back(0x00); // Padding
    leds_bytes.push_back(std::get<0>(color)); // Color
    leds_bytes.push_back(std::get<1>(color)); // Color
    leds_bytes.push_back(std::get<2>(color)); // Color
}

Puis créer les paquets complets:

std::deque<std::vector<unsigned char>> packets;
bool begin = true;
while(leds_bytes.size()) {
    std::vector<unsigned char> packet(64, 0);
    size_t payload_offset = 5; // Different for the first packet

    if(begin) { // First packet is different
        begin = false;
        packet[0] = 0x43;
        packet[1] = 0xbd;
        packet[2] = 0x00; // Other packets to send count
        packet[3] = 0x30; // Padding
        packet[4] = 0x00; // Padding
    } else {
        packet[0] = 0x43;
        packet[1] = 0x3d;
        packet[2] = 0x00; // Other packets to send count
        payload_offset = 3;
    }
    size_t bytes_to_move = (std::min)(leds_bytes.size(), size_t(64-payload_offset));
    std::copy(leds_bytes.begin(), leds_bytes.begin() + bytes_to_move, packet.data() + payload_offset);
    leds_bytes.erase(leds_bytes.begin(), leds_bytes.begin() + bytes_to_move);

    packets.push_back(std::move(packet));
}

for(int i = 0; i < packets.size(); i++) { // Define packet counts
    packets[i][2] = packets.size() - (i+1);
}

La deuxième boucle définit, pour chaque paquet le nombre de paquets qui le suivent

Plus qu’à gérer l’envoi:

for(auto packet : packets) {
    send(packet.data());
    receive();
}
receive();
unsigned char final[64] = {
    0x43, 0xb5, 0x00, 0x37, 0x00, 0x08, 0xff, 0x7f, 0xff, 0x5f, 0xff, 0x5f, 0xff, 0x7f, 0xff, 0x6d, 0x27, 0x7e, 0xfa, 0x47, 0xfe, 0x5f, 0x00, 0x00, 0x3d, 0x00, 0x02, 0xff, 0xff, 0xff, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x03, 0x02, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
};
send(final);
receive();
receive();

Le dernier paquet brut correspond au paquet que j’utilise pour que Cam reconnaisse encore le clavier après un set custom. (Valide l’enregistrement ou autre)

Définir le mapping touche OpenRGB / bitmap ID

Simple mais désagréable, j’ai dû retrouver quel ID hardware correspondait à quel touche OpenRGB:

Pour ça, j’ai simplement réutilisé mon script du début, en mettant cette boucle en code utile:

while (true) {
    int wanted_key;
    std::cin >> wanted_key;
    std::cout << "Turning on key " << wanted_key << std::endl;
    memset(leds_bytes, 0, NZXT_FUNCTION_KEYBOARD_NUM_LEDS*3);
    leds_bytes[wanted_key*3] = 255;
    send_keys(handle, leds_bytes, 100);
}

Et en mettant simplement 1,2,3 etc., chaque touche s’allume en rouge, puis je peux mapper directement le tableau suivant:

layout_values nzxt_function_keyboard_offset_values =
{
    {
        /* ESC          F1    F2    F3    F4    F5    F6    F7    F8    F9   F10   F11   F12   PRSC  SCLK  PSBK                         */
        0,         1,   2,   3,   4,   5,   6,   7,   8,   9,   10,   11,   12,   13,  14,  115,
        /* BKTK    1     2     3     4     5     6     7     8     9     0     -     =   BSPC  INS   HOME  PGUP  NLCK   NP/   NP*  NP-  */
        16,   17,   18,   19,   20,   21,   22,   23,   24,   25,   26,   27,   28,   30,   46,   126,   99,    100,   101,   102,  103,
        /* TAB     Q     W     E     R     T     Y     U     I     O     P     [     ]     \   DEL   END   PGDN   NP7   NP8   NP9   NP+ */
        32,   33,   34,   35,   36,   37,   38,   39,   40,   42,   42,   43,   44,   62,   62,   104,   105,    106,   97,   113,  114,
        /* CPLK    A     S     D     F     G     H     J     K     L     ;     "     #   ENTR                     NP4   NP5   NP6       */
        48,   49,   50,   51,   52,   53,   54,   55,   56,   57,   58,   59,    60,   61,                      116,   117,   118,
        /* LSFT    \     Z     X     C     V     B     N     M     ,     .     /   RSFT             ARWU          NP1   NP2   NP3  NPEN */
        64,    65,   66,   67,    68,    69,   70,    71,    72,   73,   74,   75,   77,               78,          119,   120,   121,  123,
        /* LCTL  LWIN  LALT               SPC              RALT  RFNC  RMNU  RCTL              ARWL ARWD   ARWR   NP0         NP.       */
        80,    81,    82,                85,                89,    90,   91,    92,               93,   94,   110,    124,         122
    },
    {
        /* Add more regional layout fixes here */
    }
};

Désormais OpenRGB peut controler chaque touche individuellement (jusqu’à 9 couleurs différentes comme dit plus haut)

Importation dans Artemis

Artemis fait simplement la partie contrôle “artistique” du clavier. Pour l’utiliser, il suffit de l’installer, installer le plugin OpenRGB. Dans OpenRGB, démarrer le serveur API:

Start OpenRGB SDK Server

Et là encore, le mapping OpenRGB / Artemis n’est pas bon, il me faut donc en créer un à partir d’un existant.

C’est cette fois plus simple car l’ID n’est pas hardware mais logique. En effet, au lieu d’être mappé sur la touche Artemis …_ESCAPE, la touche échap est sur CustomKey1, et la suite se valide pour tout le clavier.

J’ai donc simplement repris un clavier full size existant pour avoir la position des touches, et simplement renommé dans le fichier XML:

<?xml version="1.0" encoding="utf-8"?>
<Device xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Name>NZXT Function TEST</Name>
<Description />
<Author>Patch</Author>
<Type>Keyboard</Type>
<Vendor>NZXT</Vendor>
<Model>Function</Model>
<Width>437</Width>
<Height>156</Height>
<Leds>
  <Led Id="Keyboard_Custom1">
    <X>4</X>
    <Y>33</Y>
    <CustomData xsi:type="LayoutCustomLedData">
      <LogicalLayouts>
        <LogicalLayout Name="NA" />
      </LogicalLayouts>
    </CustomData>
  </Led>
  <Led Id="Keyboard_Custom2">
    <X>+19</X>
    <CustomData xsi:type="LayoutCustomLedData">
      <LogicalLayouts>
        <LogicalLayout Name="NA" />
      </LogicalLayouts>
    </CustomData>
  </Led>
  ...

Et voilà le résultat avec un effet snake:

Snake on keyboard with Artemis

Conclusion

Je suis moyennement satisfait, j’aurais aimé pouvoir faire du vrai ambilight par exemple, mais les 9 couleurs max me limitent. Il y a probablement une solution, au moins en réutilisant les paquets du constructeur mais je n’y ai plus accès.

Plus globalement je suis content d’avoir pu comprendre un protocole USB de zéro, il faut se contenter de ça :)

Annexe & Sources