Afin d'améliorer mon confort en télétravail (tousse améliorer le rendu visuel de mon setup tousse) j'ai fais l'acquisition d'un casque sans fil de chez SteelSeries, l'Arctis 9.

Qui dit sans fil dit batterie, autonomie, rechargement, etc. Même si ce modèle permet d'être utilisé tout en chargeant, quoi de plus désagréable que devoir passer en filaire à un moment inopportun ?

De base nous n'avons que 2 méthodes permettant de récupérer le niveau de batterie du casque:

  • Une LED sur le casque, sous l'oreille

Installez un miroir sur votre bureau ou asseyez vous dessus.

  • Une icone dans le logiciel SteelSeries Engine 3

Voilà où tout à commencé. Le logiciel constructeur nous affiche le niveau de batterie sous forme de pictogramme, disposant de seulement 4 niveaux.

Le premier problème évident est le manque de précision, 49% ou 26% ce n'est pas la même chose, pourtant ça l'est lorsque affiché sous cette forme.

Vient ensuite le plus gros problème pour moi, pour avoir l'information il faut aller la chercher. Je ne pense déjà pas à vérifier le niveau de mon clavier et ma souris qui doivent être chargés 2 fois par mois, alors un casque qui doit l'être presque tous les jours, encore moins.

Une fonctionnalité que j'aime beaucoup sur mon téléphone Android est l'icone indiquant la connexion mes écouteurs bluetooth ainsi que leur niveau de batterie. (La précision est à revoir, mais l'idée est très bonne)

Vous voyez déjà où je veux en venir, une nouvelle obsession voyait le jour.

Rétro-Ingénierie du casque

La partie que je préfère ! Commençons par une présentation générale de l'environnement:

  • Hôte sous Windows
  • Sur l'hôte se trouve un récepteur 2.4GHZ branché en USB faisant la liaison avec le casque en mode sans fil
  • VID:PID 0x1038: 0x12c2 & 0x12c4
Informations retournées via USB Tree View

Nous allons nous concentrer sur le PID 0x12c2, le 2eme étant, à première vue, le casque en lui même, il ne nous intéresse pas, nous avons besoin des données transitant par le dongle.

Pourquoi ? Car par design il est logique que ce soit ce dernier qui s'occupe de la réception des informations, si votre ordinateur (après branchement du dongle) connait le modèle et l'état de connexion de votre casque alors qu'il n'a jamais été connecté ni installé, nous pouvons déduire que l'informations ne vient pas de lui directement mais du dongle.

Je ne me suis pas trop attardé sur le logiciel constructeur, après quelques tests d'analyse côté Windows (API & outils Sysinternal) je n'avais rien de probant donc j'ai rapidement décidé de regarder ce qui se tramait (badum tss) côté communications USB.

Pour cela nous faisons appel à notre frère d'arme habituel, wireshark. Grace au plugin USBPcap nous pouvons l'utiliser pour inspecter les paquets transitant par le bus USB.

Parenthèse architecture USB

Afin de passer à la suite il peut être bon de se remémorer les grands principes de l'architecture USB. (Merci BeyondLogic)

L'hôte communique avec les périphériques via des canaux virtuel aka endpoints.

Une requête standard entre l'hôte et un endpoint est composée ainsi:

bmRequestType

  • Détermine la direction du paquet: host to device / device to host
  • Détermine le type de paquet: standard / class / vendor
  • Détermine le type du receveur: device / interface / endpoint / other

bRequest

  • Détermine la requête faites

wIndex

  • Numéro d'interface

wValue

  • Permet de passer des paramètres annexes dépendant du type de requête.

wLength

  • Nombre de bytes à transférer si il y a une phase de data

Data

  • Permet d'envoyer des données autres propres au périphérique/logiciel.

Maintenant que tout est clair (ou moins obscure du moins), on reprend !

Let's have fun

Afin d'être certain d'avoir des données intéressantes nous commençons l'écoute avec le logiciel constructeur fermé & le casque éteint. Puis nous démarrons les deux.

Une fois cela fait nous obtenons un paquet de paquets (badum tss²) à analyser.

Dans les premiers échanges l'hôte interroge les différents peripheriques en leur demandant de retourner leur description (les infos vues plus haut qui nous sont données par USB Tree View)

En filtrant les paquets sur le VID SteelSeries 4152 (équivalent de 0x1038) nous récupérons les adresses des périphériques de la marque.

Par chance je ne possède qu'un casque venant de chez eux donc le tri est rapide. En inspectant précisément les paquets nous obtenons les informations suivantes:

Nous voyons dans la description retournée par le périphérique que notre cible 0x12c2 est situé en 3.15.0

Nous pouvons donc affiner notre recherche et ignorer les paquets n'ayant ni pour source, ni pour target ce périphérique.

Parmi les paquets présent se trouve beaucoup de bruit, mais un type de paquet semble intéressant: les SET_REPORT

Contrairement aux autres paquets ceux-ci comportent une section Data, celui ci envoie la valeur 0x20

Nous obtenons ensuite une réponse de 3.15.0, qui n'est pas pas intéressante, puis on constate une arrivée de 3.15.1:

Cette entrée me titille, le champs HID DATA contenant des données propres au périphérique, il semble bon de creuser plus profondément...

Fast forward

Je vous ai épargné les heures passées à analyser les différents paquets ainsi que la recherche de solutions côté Windows permettant de faire des requetés HID sans épuiser mon stock de paracétamol.

A noter:

  • Même si je n'avais que peu de doute là dessus, mon amour pour l'environnement *nix est à nouveau confirmé
  • Envoyer des paquets à l'aveugle peut être dangereux Au cours de mes tests j'ai mis KO et déréglé le casque plusieurs fois. La bonne nouvelle est qu'un reboot de SteelSeries Engine permet de repush la config du casque et ainsi remettre à plat les réglages chamboulés par nos tests.
  • Dans les solutions trouvées, le plus simple pour faire du HID est soit python, soit nodejs, soit la lib hidapi. Je souhaitais avoir quelque chose d'assez portable/malléable pour la création du tray final et ayant horreur de la gestion d'env côté Windows j'ai donc opté pour le dernier choix.

POC

Grace aux exemples et à la doc fournit avec la lib nous avons une bonne base pour nous amuser, voici à quoi ressemble mon POC:

#include <stdio.h>
#include <wchar.h>
#include <string.h>
#include <stdlib.h>
#include "hidapi.h"

// Headers needed for sleeping.
#ifdef _WIN32
	#include <windows.h>
#else
	#include <unistd.h>
#endif

int main(int argc, char* argv[])
{
	(void)argc;
	(void)argv;

	int res;
	unsigned char buf[256];
	#define MAX_STR 255
	wchar_t wstr[MAX_STR];
	hid_device *handle;
	int i;

	struct hid_device_info *devs, *cur_dev;

	printf("Compiled with hidapi version %s, runtime version %s.\n", HID_API_VERSION_STR, hid_version_str());
	if (hid_version()->major == HID_API_VERSION_MAJOR && hid_version()->minor == HID_API_VERSION_MINOR && hid_version()->patch == HID_API_VERSION_PATCH) {
		printf("Compile-time version matches runtime version of hidapi.\n\n");
	}
	else {
		printf("Compile-time version is different than runtime version of hidapi.\n]n");
	}

	if (hid_init())
		return -1;

	// Set up the command buffer.
	memset(buf,0x00,sizeof(buf));
	buf[0] = 0x01;
	buf[1] = 0x81;

	// Open the device using the VID, PID and optionally the Serial number.
	////handle = hid_open(0x4d8, 0x3f, L"12345");
	handle = hid_open(0x1038, 0x12c2, NULL);
	if (!handle) {
		printf("unable to open device\n");
 		return 1;
	}

	// Read the Product String
	wstr[0] = 0x0000;
	res = hid_get_product_string(handle, wstr, MAX_STR);
	if (res < 0)
		printf("Unable to read product string\n");
	printf("Product found: %ls\n", wstr);

	// Set the hid_read() function to be non-blocking.
	hid_set_nonblocking(handle, 1);

	buf[0] = 0x0;
	buf[1] = 0x20;
	hid_write(handle, buf, 65);
	if (res < 0)
		printf("Unable to write() (2)\n");

	// Read requested state.
	res = 0;
	while (res == 0) {
		res = hid_read(handle, buf, sizeof(buf));
		if (res == 0)
			printf("waiting...\n");
		if (res < 0)
			printf("Unable to read()\n");
		#ifdef WIN32
		Sleep(500);
		#else
		usleep(500*1000);
		#endif
	}

	printf("\nData read:\n   ");
	// Print out the returned buffer.
	for (i = 0; i < res; i++)
		printf("%02hhx ", buf[i]);
	printf("\n\n");

	hid_close(handle);

	/* Free static HIDAPI objects. */
	hid_exit();

#ifdef WIN32
	system("pause");
#endif

	return 0;
}

Il permet de rejouer le SET_REPORT REQUEST envoyé à 3.15.0que nous avons vu plus haut et de retourner la réponse dans le terminal. Ainsi, nous pouvons nous passer de wireshark et de la tonne de filtrage à effectuer à chaque test.

Output du script

Tests

A ce stade nous savons interroger le casque et avoir un rapport, mais on ne sait pas sur quoi ce dernier porte, ni comment l'interpréter.

Il faut donc effectuer des tests croisés et grâce à notre script, regarder ce qui en sort, pour faire bref:

  • Eteindre le casque
  • Mettre le casque en charge
  • Charger le casque
  • Décharger le casque

Observations et interprétations

Une fois ceci fait voici mes observations:

Lorsque le casque est allumé la valeur du 2eme byte est fixée à 01

Lorsque le casque est éteint la valeur du 2eme byte est fixée à 03

Lorsque le casque est en charge:

  • la valeur du 4eme byte augmente avec le temps
  • la valeur du 5eme byte est fixée à 01

Lorsque le casque n'est pas en charge:

  • la valeur du 4eme byte diminue avec le temps
  • la valeur du 5eme byte est fixée à 00

On comprend donc rapidement que le 2eme byte indique l'état connecté/déconnecté du casque, le 4eme lui indique le niveau de batterie et pour finir le 5eme indique si le casque est en charge ou non.

La nouvelle difficulté à laquelle nous faisons face ici est la suivante: Si le 4eme byte retourne le niveau de batterie, comment interpréter la valeur retournée ?

Pour essayer de comprendre j'ai procédé ainsi: recharger le casque toute une nuit, pour être certain d'atteindre le maximum de batterie, puis à chaque changement dans l'interface SteelEngine, requêter le niveau de batterie via le POC créé plus tôt.

Le timing n'est pas 100% parfait car j'avais ouvert le logiciel en parallèle de ce que je faisais, il y a donc surement un léger décalage avec les valeurs hardcodées dans SteelSeries Engine.

On peut noter aussi que lorsqu'en charge, la valeur de batterie retournée est beaucoup plus grande que la réalité, on constate un drop assez important de la valeur au débranchement.

Etant sûrs des valeurs de chaque palier nous pouvons déduire des valeurs intermédiaires au doigt mouillé, si 25% = 0x70 et 50% = 0x7d alors 38% doit être vers 0x77et ainsi de suite.

J'ai donc recréé une échelle approximative, en suivant le relevé effectué on peut imaginer que le 0 se situe à ~100 en valeur décimale. Il ne reste qu'a échelonner et arrondir pour avoir quelque chose d'exploitable et améliorer la précision du retour.

Tray icon

Maintenant que nous savons récupérer et interpréter les valeurs qui nous intéressent, il ne reste plus qu'à créer l'app nous retournera une information visuelle dans le systray en fonction des ces dernières.

Voulant faire ça rapidement j'ai utilisé AHK, dans l'idéal il faudrait surement faire ça avec WPF mais ne maitrisant pas assez le sujet (appel aux personnes motivées) ça sera pour plus tard.

J'ai donc créé 3 exe qui retournent respectivement:

  • Si le casque est connecté ou non
  • Si il est en charge ou non
  • Son niveau de batterie

Mixez tout ça dans AHK et voilà  ce que ça donne:

DllCall("AllocConsole")
WinHide % "ahk_id " DllCall("GetConsoleWindow", "ptr")
; Tray icons
tray_icon = C:\SynologyDrive\Travaux\arctis-battery-tray\custom\icons\
tray_icon_off = C:\SynologyDrive\Travaux\arctis-battery-tray\custom\icons\off.png
tray_icon_charging = C:\SynologyDrive\Travaux\arctis-battery-tray\custom\icons\charging.png
tray_icon_normal = C:\SynologyDrive\Travaux\arctis-battery-tray\custom\icons\normal.png

shell := comobjcreate("wscript.shell")

HexToDec(hex)
{
    VarSetCapacity(dec, 66, 0)
    , val := DllCall("msvcrt.dll\_wcstoui64", "Str", hex, "UInt", 0, "UInt", 16, "CDECL Int64")
    , DllCall("msvcrt.dll\_i64tow", "Int64", val, "Str", dec, "UInt", 10, "CDECL")
    return dec
}

Concatenate(x, y) {
    Return, x y
}

while 1 = 1
{
    exec := (shell.exec(comspec " /c C:\SynologyDrive\Travaux\arctis-battery-tray\custom\power.exe"))
    power := exec.stdout.readall()

    exec := (shell.exec(comspec " /c C:\SynologyDrive\Travaux\arctis-battery-tray\custom\charging.exe"))
    charging := exec.stdout.readall()

    exec := (shell.exec(comspec " /c C:\SynologyDrive\Travaux\arctis-battery-tray\custom\level.exe"))
    level := exec.stdout.readall()
    level := HexToDec(level)
    
    If (power = "oui")
    {
        If (charging = "non")
        {
            If (level > 152)
            {
                actual_tray := Concatenate(tray_icon, "100")
            }        
            Else if (level > 149)
            {
                actual_tray := Concatenate(tray_icon, "95")
            }
            Else if (level > 146)
            {
                actual_tray := Concatenate(tray_icon, "90")
            }
            Else if (level > 143)
            {
                actual_tray := Concatenate(tray_icon, "85")
            }
            Else if (level > 140)
            {
                actual_tray := Concatenate(tray_icon, "80")
            }
            Else if (level > 135)
            {
                actual_tray := Concatenate(tray_icon, "75")
            }
            Else if (level > 133)
            {
                actual_tray := Concatenate(tray_icon, "70")
            }
            Else if (level > 130)
            {
                actual_tray := Concatenate(tray_icon, "65")
            }
            Else if (level > 127)
            {
                actual_tray := Concatenate(tray_icon, "60")
            }
            Else if (level > 125)
            {
                actual_tray := Concatenate(tray_icon, "55")
            }
            Else if (level > 122)
            {
                actual_tray := Concatenate(tray_icon, "50")
            }
            Else if (level > 120)
            {
                actual_tray := Concatenate(tray_icon, "45")
            }
            Else if (level > 117)
            {
                actual_tray := Concatenate(tray_icon, "40")
            }
            Else if (level > 115)
            {
                actual_tray := Concatenate(tray_icon, "35")
            }
            Else if (level > 112)
            {
                actual_tray := Concatenate(tray_icon, "30")
            }
            Else if (level > 110)
            {
                actual_tray := Concatenate(tray_icon, "25")
            }
            Else if (level > 107)
            {
                actual_tray := Concatenate(tray_icon, "20")
            }
            Else if (level > 105)
            {
                actual_tray := Concatenate(tray_icon, "15")
            }
            Else if (level > 103)
            {
                actual_tray := Concatenate(tray_icon, "10")
            }
            Else if (level > 100)
            {
                actual_tray := Concatenate(tray_icon, "5")
            }
            actual_tray := Concatenate(actual_tray, ".png")
            Menu, Tray, Icon, %actual_tray%
        }
        Else
        {
            Menu, Tray, Icon, %tray_icon_charging%
            Menu, Tray, Tip, Casque en charge
        }
    }
    Else
    {
        Menu, Tray, Icon, %tray_icon_off%
        Menu, Tray, Tip, Casque eteint
    }
    Sleep, 1000
}

Quick and dirty. Voici donc les différents états que l'on retrouve dans notre systray:

  • Déconnecté
  • En charge
  • En cours d'utilisation, niveau de batterie

Je vous laisse avec, pour finir, la démo vidéo.