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

Les fonctions propriétaires sont plutôt limitées.
Pour capturer l’échange RGB on a besoin de :
- Changer le driver pour utiliser un driver USB classique (winUSB dans mon cas)
- Wireshark avec libusb installé (nécessite un redémarrage…)
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 :

On observe que :
- Les paquets RGB sont des URB_INTERRUPT
- Le format des échanges est requête / acquittement
- On remarque une séquence répétitive avec ce qui semble être des commandes(3b etc.)
Extraction d’informations de base
Pour plus tard rejouer les paquets, il faut avant tout:
- Le vendor ID
- Le product ID
- L’interface pour le RGB
- L’endpoint pour le RGB
- L’endpoint d’input pour le RGB (utilisé pour les acquittements dans notre cas)
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:

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:
- 43,b5,00,37: Permet de réutiliser NZXT Cam après write (probablement requis pour faire un write propre)
- 43,bd,00,30: Couleur premier paquet
- 43,1d: Couleur continuité
- 43,3d: Couleur continuité (avec 3 couleurs)
Avec le format suivant:

(paquet 1 en haut, paquet 2 en bas)
- Chaque payload fait 64 bytes.
- Le paramétrage des couleurs est en fait une seule suite répétée au long des paquets, avec toujours le même format:
- mode: Le mode de l’ensemble de touche
- touches: Bitmap représentant chaque touche
- couleur: RGB basique
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:
- Copier (bêtement) un autre controleur, pour ma part Clevo Keyboard
- Enlever tout son code “utile”
- Remplacer les noms, identifiants etc.
Ce qui donne dans mon cas cette structure de fichiers:

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:
- Mettre l’ouverture du device dans le constructeur
- Mettre la fermeture du device dans le destructeur
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à:

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:

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:

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
- OpenRGB: https://gitlab.com/CalcProgrammer1/OpenRGB
- Mon fork: https://gitlab.com/P-atch/OpenRGB