Sim Racing open-source - Partie 2 - Dashboard

Où l'on parle de faire de l'affichage sur une carte d'évaluation STM32, intercepter des télémétries en UDP, et Python.

3615MAVIE : j'ai abandonné le projet de fabriquer un volant. Trop compliqué, trop galère. J'ai acheté le moteur pas à pas, puis en faisant des manips avec SimpleFOC j'ai flingué le driver, puis je me suis fait fracturer ma bagnole et on m'a volé le sac qui contenait le moteur et son driver (cassé), ce qui a définitivement mis un terme à ce projet. Mais avant ça j'avais déjà craqué, je sentais le projet qui n'en finit jamais et qui coûte beaucoup trop cher. J'étais dans un mode culpabiliste, genre "nooon c'est pas pour jouer, c'est pour apprendre" et puis à un moment j'ai arrêté de me prendre la tête et j'ai acheté un T300. Voilà.

Par contre, je reste dans l'idée de fabriquer moi-même les accessoires, levier de vitesse, frein à main, dashboard ... Mais comme j'ai déjà un setup qui marche, j'ai beaucoup moins la pression. Donc, commençons par le dashboard.

screenshot_gui.PNG, avr. 2022

dash_external.jpg, avr. 2022

Quoi qu'on fait et comment qu'on fait ?

La visualisation suppose une communication entre le jeu et un device externe connecté au PC.

Pour la partie software sur le PC, aujourd'hui le go-to classique me semble être Simhub. C'est un logiciel qui fait l'interface entre le jeu et des accessoires divers et variés, mais permet aussi de faire du logging et de l'overlay. C'est bien foutu et pas mal documenté, et ça fait globalement tout ce qu'il faut pour gérer autant un affichage que des entrées d'accessoires. Mais ce n'est pas open source ni libre et ça m'embête. il y a certaines limitations dans la licence gratuite, entre autres le rafraichissement est limité à 10Hz pour les devices externes. Après, la licence est vraiment pas chère, et on sent qu'il y a une volonté de bien faire et de vraiment fournir un outil complet et bien documenté. Mais bon, ça reste un truc fermé. Donc, comme je suis orgueilleux (et un peu maniaque), je vais me faire une solution perso et open-source :) Et puis j'aime bien comprendre ce que je fais, aussi.

Pour la partie "device", à savoir le dashboard en lui-même, je vais utiliser une carte d'évaluation Discovery STM32F746NG, parce que c'est cool et c'est pas cher et ça marche plutôt bien et j'en ai en stock. Ceci-dit, s'il s'agit de faire une interface graphique, ça devient tout de suite assez complexe, et je n'envisage pas de ne pas passer par une librairie graphique et/ou un outil pour l'interface, sinon ça va juste être la mort.

Les libs graphiques

Deux alternatives proposées par défaut par ST : STemWin et TouchGFX.

STemWin est fourni par Segger, il y a une version d'évaluation. Dans le package à télécharger, en plus de la lib elle-même (qui est fournie sous forme de binaire pré-compilé) on a une série d'outils bien rustiques pour générer les interfaces.

TouchGFX est fourni par ST (si j'ai bien compris) et est gratuit et libre (licence ST Ultimate - Il faudrait check les termes de la licence pour vérifier ces allégations). Il y a un package pour la lib en elle-même, et l'outil de création d'interfaces est dans "utilities". Très clairement, il est beaucoup plus impressionnant que STemWin, maintenant reste à voir lequel des eux est le plus pratique. A noter que dans le readme de la lib ST qui donne le lien de téléchargement, ça pointe vers une version très ancienne, donc plutôt télécharger via le lien "officiel".

Autre alternative : Embedded Wizard, c'est du proprio avec licence, donc idem STemWin.

Testons les démos : On prend donc le repo de librairies de ST pour les STM32F7. Dans le répertoire "Projects/Demonstration" on trouve des exemples "tout faits" pour à peu près toutes les cartes dévaluation / Nucleo qu'ils vendent. Le principe de ces repos, c'est qu'il y a toutes les librairies possibles et imaginables (OS, middlewares, BSP ...), et les exemples vont pointer dans les répertoires ad-hoc pour les dépendances. Étonnamment ça marche pas mal du tout, exemple ici.

[Prendre l'exemple STemWin, ouvrir dans STM32CubeIDE le projet SW4 sous forme de projet AC6, il le convertit, aller télécharger la lib STemWin manuellement, copier dans le répertoire ad-hoc, ça compile]

La démo de TouchGFX a l'air beaucoup plus évoluée, et surtout c'est libre et proposé par ST en direct, donc je vais me tourner vers ce middleware.

Découverte de TouchGFX

La doc est très avenante, mais aussi très fournie, on ne sait pas trop par quel bout la prendre. Je propose d'attaquer par la section tutorial. Donc on charge un exemple pour tester, en indiquant bien la carte qu'on veut utiliser (Discovery STM32F746G ici). Si on fait "Run Target" (bouton tout en bas à droite / F6), ça va générer le code, le compiler, et tenter de le charger, mais dans mon cas ça plante à la programmation vu que le STM32Cube Programmer n'est pas installé dans le répertoire par défaut, et il n'y a visiblement pas moyen de lui indiquer le répertoire d'installation de l'outil. Qu'à cela ne tienne, il y a un bouton "Files" en bas à gauche, qui ouvre le répertoire où on trouve les fichiers générés (dans TouchGFX/build), on lance STM32Cube Programmer manuellement, et on lui fait charger les .hex qui ont été générés. A priori le fichier target.hex est suffisant et contient tout. On le charge, on déconnecte la carte et on la reset, et magie ! Ça tourne ! Après, on peut ouvrir le projet généré dans STM32CubeIDE et ajouter le back-end.

Ça se complique quand on commence à vraiment utiliser des containers. les objets n'étant plus "à plat" dans un screen, mais contenus dans un sous-ensemble, les interractions des boutons n'apparaissent plus dans le contexte du screen, et si on veut avoir des interactions entre des containers, cela créée une isolation. Pour contre-balancer cela, il faut transmettre les triggers internes aux containers vers le screen qui le contient, ou plus généralement vers l'élément "parent"; pour cela, dans les containers on peut créer des "triggers", qui sont des sorte d'appels de callbacks ou d’événements qui sont "lisibles" par les objets parents.

Par exemple, si on a un container qui contient un bouton, pour que le screen qui contient le container puisse être prévenu d'un appui sur le bouton, il faut créer un trigger dans le container correspondant à un appui sur le bouton en question, et créer une interaction qui va émettre le trigger quand l'appui est effectué. Une fois cela fait, si dans le screen on créée une interaction, ce nouveau trigger sera disponible, et donc utilisable comme déclencheur de l'interaction. Dans le cas d'un appui de bouton il n'y a rien d'autre à faire qu'un appel, mais on peut aussi transmettre une variable, ce qui va être utile pour les boites de dialogue par exemple.

Bon, d'une façon générale l'archi est assez alambiquée malgré tout, ce qui est surtout dû à l'encapsulation en C++. Grosso-modo, ce qu'il faut retenir c'est qu'il y a une partie à laquelle on ne touche jamais (les "drivers" et le moteur de TouchGFX en lui-même), une partie générée par l'outil Designer (à ne pas modifier à la main autant que possible), et une partie éditable par l'utilisateur. Chacune a ses propres classes, avec des éléments protégés / inaccessibles par d'autres classes. Pour permettre l'accès, il faut créer des méthodes publiques, et c'est à ça que sert la création de triggers et d'actions dans Designer : créer des méthodes pbliques qu'on peut ensuite appeler dans d'autres classes.

Workflow et notes en vrac

Il y a deux approches possibles, soit en partant de STM32Cube, soit en partant de ToucheGFX Designer.

En partant de STM32Cube, dans l'IDE on va ajouter le composant TouchGFX et l'ajouter en tant que middleware / composant additionnel dans l'ioc. Ensuite on va ouvrir le sous-projet ainsi généré dans TouchGFX Designer pour créer l'interface graphique. J'ai essayé vite-fait : ça n'a pas du tout l'air intuitif, donc je préconise plutôt l'autre méthode.

En partant de TouchGFX, quand on lui fait générer le code, il va générer un projet complet, avec tous les sources, de TouchGFX, mais aussi avec l'initialisation du MCU, l'ioc adapté, et même les fichiers projet pour plusieurs IDE, dont, bien entendu, STM32CubeIDE. L'on va donc ensuite ouvrir ce projet dans STM32CubeIDE pour ajouter le back-end. L'inconvénient c'est qu'on ne choisit pas l'architecture : forcément il y a du FreeRTOS, mais l'avantage c'est que c'est beaucoup plus simple et que ça marche beaucoup mieux qu'en partant de STM32Cube.

D'ailleurs, si on cherche des infos sur la façon de s'y prendre pour se connecter au backend, la section ad-hoc de la doc n'est pas évidente à trouver : c'est ici.

D'une façon générale, chaque élément graphique créé dans TouchGFX existe sous forme de classe, et les interactions entre les classes, et entre les classes TouchGFX et le reste de l'application se font via la classe Model, ce qui est expliqué aussi dans cette section de la doc. Cette classe dispose:

  • d'une méthode tick() qui est appelée à la fréquence de rafraichissement de l'interface graphique, donc permet de faire des actions cycliques (polling...), et donc permet aux objets de l'interface d'accéder aux objets du reste de l'application,
  • d'un pointeur vers l'objet Presenter, qui est un point d'accès vers les objets de l'interface, ce qui permet donc au reste de l'application d'accéder aux objets de l'interface graphique.

Ces deux points d'accès permettent donc de créer des canaux de communication interface vers appli, et appli vers interface. Donc, si on résume:

  • Model contient la "logique" et les interfaces de communication avec le reste de l'appli,
  • View gère uniquement l'aspect graphique, fournit des fonctions qui permettent d'agir sur l'affichage, et agit quand il y a des inputs sur l'interface,
  • Presenter fait la glue entre les deux, et permet la communication et l'interaction entre les objets de type View.

(Note : Il y a une séparation entre le code généré par TouchGFX et le code qui doit être modifié par l'utilisateur, globalement c'est /gui -> utilisateur et codage manuel, /gui_generated -> code généré. or, dans /gui_generated il y a des fichiers à éditer manuellement, ceux qui commencent par "My", ce qui est très perturbant)

Architecture

Bon, maintenant qu'on a une vision à peu près claire de comment travailler avec ce middleware, on va réfléchir à l'architecture. Déjà, on va faire une pseudo-spécification pour définir ce qu'on veut implémenter:

  • Affichage de données de course : RPM, vitesse, pression de boost, rapport de boîte enclenché... Ces infos viennent des télémétries du jeu, via de l'UDP, donc a priori ça passerait par le port Ethernet (il ya sans doute moyen de bricoler pour faire autrement, mais dans un premier temps on va faire comme ça),
  • Affichage du statut des entrées accessoires : frein à main, shift up/down, vitesse enclenchée "mécaniquement" par le levier de boîte de H. Ça va surtout servir pour le debug (et puis visuellement ça sera sympa).
  • Envoi des commandes des accessoires au jeu, a priori via USB HID (idem que pour les télémétrie, on pourrait faire autrement, mais dans un premier temps on va faire "simple").

Dans la partie View / Screen, ce qu'on verra à l'écran ça sera donc une barre de régime moteur, la vitesse de la bagnole, la vitesse enclenchée, mais aussi des petites icônes qui indiqueront si le frein à main est actionné, si le shifter est actionné, et quelle est la vitesse "mécanique" sur le H-pattern. Niveau interactivité et tactile, je pense mettre juste un splash screen avec mon logo (parce que quand-même), et un menu de réglages, pour au moins modifier les temps de debounce des entrées, l'adresse IP (si on peut la modifier à la volée), et activer / désactiver des accessoires pour ne pas prendre en compte les entrées non-connectées.

Pour ce qui est des infos PC -> carte, il faut donc envoyer les télémétries (ça c'est du réseau / IP / UDP), et a priori c'est tout.

Pour ce qui est des infos carte -> PC, il faut envoyer les inputs des accessoires.

Entre les deux, il faut faire communiquer le hardware avec l'interface:

  • Envoyer les entrées debouncées vers les widgets voyants,
  • Envoyer les valeurs de télémétries de l'Ethernet vers les widgets de l'écran,
  • Récupérer les valeurs de debounce et les mettre à jour dans les fonctions de filtrage,
  • Récupérer les activations des entrées
  • Bonus : Récupérer l'adresse IP et la mettre à jour,

Pour commencer, dans Model, on va mettre:

  • Les variables de télémétrie, avec les méthodes set pour les écrire (il n'y rien qui revient de l'écran, c'est juste de la lecture de son côté),
  • Les flags d'activation des entrées, avec les méthodes get et set (écrites par l'interface graphique, lues par le bas-niveau),
  • Les valeurs de debounce, avec méthodes get et set (écrites par l'interface graphique, lues par le bas-niveau),
  • Le flag du frein à main, shift up, shift down, avec méthodes get et set (écrites par le bas-niveau, lues par l'interface),
  • La valeur de la position du levier de vitesse en H, avec méthodes get et set (écrites par le bas-niveau, lues par l'interface),
  • Bonus : L'adresse IP, avec méthodes get et set (écrites par l'interface graphique, lues par le bas-niveau).

Je pense que je vais juste tout mettre à jour dans le tick().

Transmission des télémétries

Une fois l'interface faite, il faut lui transmettre des mesures à afficher. il faut donc d'une part les faire sortir du jeu, puis les envoyer à la Disco.

Sortir les télémétries n'est pas très compliqué, la plupart des jeux de bagnole sérieux permettent - parfois moyennant des hacks - de récupérer en temps-réel des informations de télémétrie. En général c'est diffusé via Ethernet en UDP sur le port 20777 (pour les jeux Codemasters en tous cas). Il n"y a "plus" ensuite qu'à traiter / transférer ces données pour logging ou visualisation. Visiblement les jeux Codemasters utilisant à peu près tous le même moteur - ou du moins la même famille de moteur - les télémétries ont toujours à peu près la même config. On les active en modifiant un fichier de configuration.

[Lien vers une description de la manip]

Les données une fois sorties, elles peuvent être consommées par des outils de logging pour de l'analyse offline, ou sur des outils de visualisation, genre des HUD en overlay (pratique pour streamer, ou pour faire le beau :p ). Pour les transmettre à un appareil extérieur, soit il faut que celui-ci puisse lire les paquets UDP en direct sur l'Ethernet, soit il faut faire une moulinette qui convertit sur un autre canal de communication.

Exemple avec cet outil qui permet de transférer des télémétries en VCOM vers un Arduino.

Dans mon cas, la carte Discovery a un port Ethernet, et je pourrais très bien lui faire lire les paquets en direct. Ceci-dit, le setup devient vite compliqué car ça oblige à avoir à la fois l'USB pour les entrées frein à main / levier de vitesse, et un RJ45 pour les télémétries, donc deux câbles différents. En fait je vois deux configurations possibles:

  • La carte est autonome et décode les télémétries en direct de l'UDP : pas besoin de faire tourner un logiciel "de conversion" sur le PC, mais il faut un Ethernet en plus de l'USB.
  • La carte n'est pas autonome et reçoit les télémétries via un canal autre que l'UDP, mais il faut faire tourner un logiciel pour faire la "conversion", mais ça peut passer par de l'USB, donc on peut faire un device composite pour n'avoir qu'un seul USB pour les deux fonctions (envoi appuis frein à main / gearbox et lecture des télémétries pour visu).

L'option sans Ethernet me semble pas mal, avec à terme un device composite (si j'arrive à l'implémenter). Je vois un autre inconvénient à cette configuration : le logiciel de conversion va être client du port sur lequel les télémétries sont diffusées, ce qui fait que je ne pourrai pas avoir un autre logiciel qui consomme ces données en même-temps, par exemple un enregistreur genre comme celui-ci. A moins de faire un bypass et de re-publier les données sur un autre port, il faudra que je pense à trouver une solution pour ça. Par exemple, Simhub permet de faire du port forwarding pour permettre à une autre application de lire les paquets, il faudrait que j'implémente quelque chose d'équivalent.

Pour faire simple on va commencer par travailler avec pygauge, et faire un parser de trames UART sur le VCOM dans la carte dévaluation. Pygauge est en Python 2.7, donc ça reste raisonnable à travailler. D'ailleurs, étant donné que j'envisage de faire de la communication UART pour de la configuration sur d'autres projets, il n'est pas idiot de regarder comment s'y prendre, et idéalement commencer une "plateforme" de logiciel de communication. Bref, tout me pousse à faire ça.

Pygauge est très basique, et fonctionne uniquement en ligne de commande. Dans un premier temps je vais juste le modifier pour faire des adaptations. A terme, je vais développer une interface graphique pour être plus user-friendly, et j'en ferai une "plateforme" pour mes prochains projets.

Logiciel d'interface

Justement, parlons de cette interface graphique. deux options me semblent accessibles "facilement":
  • Utiliser Python et un middleware GUI genre tkinker : c'est portable, mais j'ai de mauvais souvenirs des middlewares d'interface pour Python, qui me semblaient peu intuitifs à développer.
  • Utiliser C# et faire des WinForms : pas forcément portable (quoique, peut-être qu'il est possible de faire quelque chose avec Mono) mais me semble plus "accessible" niveau développement.

Je vais commencer avec Python - vu que c'est la base de Pygauge - et je porterai, plus tard, peut-être, sur un autre langage / framework.

Pour ce qui est du toolkit d'interface, je connais déjà wxPython, vu que j'ai déjà fait un outil de génération de code VHDL avec pour le taf.Je ne suis pas ultra-fan de l'architecture qui est assez compliquée à gérer, mais en vrai ça fait le taf, et pour des interfaces "old-school" standard genre WinForms ça convient bien. Les autres alternatives accessibles qui viennent à l'esprit en premier ce sont tkInter et Kivy, mais ni l'un ni l'autre ne me plaisent : tkInter est trop chiant à utiliser, et Kivy est trop orienté écran tactile et je n'aime pas du tout la façon dont on développe avec. Donc : wxPython.

Voici les features que je veux avoir:

  • Listing automatique des ports COM dispo et possibilité de choisir celui qu'on veut,
  • "Synchronisation" avec la carte, et idéalement possibilité de modifier les paramètres et "override" les réglages manuels sur l'écran tactile,
  • Configuration via des fichiers de configuration pour la config UDP,
  • Import / Export des configs internes de la carte et sauvegarde dans des fichiers de config, pour ne pas être obligé de re-configurer à chaque démarrage.

Python et WX

J'ai envie de profiter de ce projet pour construire une application / interface graphique qui fait de l'UART et plus si affinités, que je pourrais réutiliser dans d'autres applications. Ici l'application sera un merge entre pygauge et dr2_logger. Ceci-dit, n'aimant pas ré-inventer la roue tous les quatre matins, je vais me servir de ce projet comme prétexte pour développer un logiciel d'interface "générique" que je pourrais réutiliser sur d'autres projets.

Architecture

Après avoir un peu expérimenté avec WX, je commence à avoir une idée assez précise de ce que je peux et veut faire. Mon idée étant de faire un logiciel générique, je vais découper autant que possible les fonctions et les rendre les plus agnostiques les unes des autres de façon à pouvoir "construire" une application spécifique en fonction de mes besoins. A terme, je souhaiterais avoir un seul logiciel qui soit configurable en fonction de l'application. Dans un premier temps, je vais déjà faire une "plateforme", quitte à améliorer plus tard.

Mon idée principale, c'est de faire une interface modulaire, dans laquelle j'ajouterai et j'instancierai les modules dont j'ai besoin pour mon application. Le tout sera dans un "main frame", la fenêtre principale, et les modules seront dans des panels dédiés. Le "main frame" gérera le layout de tout ça.

Les modules communiqueront via PyPubSub, qui permet la publication de messages sur des canaux auxquels il est possible de souscrire. Ça ressemble beaucoup à la façon dont fonctionne MQTT, et ça me semble une bonne façon de rendre les modules indépendants : ils souscrivent à des canaux sur lesquels ils attendent leurs entrées, et ils publient sur les canaux dans lesquels ils envoient leurs sorties. L'absence d'émetteur ou de récepteur n'empêche pas d'y faire tourner et de tester, juste ça ne fera pas la fonction. Et surtout c'est indépendant du "main frame", qui n'a pas besoin de gérer la communication inter-modules.

Chaque module aura un panel avec des contrôles d'interface, boutons, listes déroulantes, voyants, etc.

Il y aura un panel avec la console, pour le debug, et pour les messages. Elle aura son propre canal dédié pour que les autres panels puissent lui envoyer des print. j'ai trouvé comment rediriger le stdout, ce qui fait que je peux y envoyer les sorties de DR2_logger sans avoir besoin de modifier ses print.

Pour la communication série, je vais faire un protocole "générique" à deux niveaux encapsulés, le premier niveau permettant de dispatcher les commandes entre les modules concernés, et le deuxième niveau permettra de faire des trames dédiées à une application.

Idéalement il faudra que tout cela soit paramétrable via des fichiers de config, YAML ou JSON.

Pour la suite, je vais désigner "device" la carte électronique avec laquelle on communique, et "tool" le software dont on parle ici-même.

Au niveau des modules / panels, j'identifie déjà :

  • serial : gère la communiation série, scan des ports disponible, définition des paramètres (baudrate, nombre de bits ...)
  • device_communication (DCM) : parse les données reçues par le port série, transforme les commandes en trames et les envoie au module serial
  • console : déroute le stdout, affiche des infos de debug et des messages
  • fuzz : gère les actions / lectures que l'on veut faire sur la fuzz factory, je détaillerai sur le projet ad-hoc
  • simracing_telemetry_logger (STL) : lit les trames UDP du jeu et en extrait les télémétries utiles pour les autres modules (genre dashboard), encapsulation de DR2_logger
  • simracing_dashboard (SDB) :récupère les télémetries et les met en forme pour l'affichage sur le dashboard, récupère les inputs du dashboard
  • input_generator (ING) : reçoit des commandes et génère des appuis touche pour l'OS, pour remplacer le HID.

J'hésite encore sur certains détails, par exemple à quel niveau est-il décidé quelle sera la touche / bouton appuyé pour un input donné, dans simracing_dashboard ou input_generator ? Mais je pourrai voir ça plus tard.

Au passage, un repo intéressant avec plein de démos : https://github.com/driscollis/wxpythoncookbookcode

Threads

Pour faire ça proprement, je vais utiliser des threads, ce qui signifie que je vais devoir comprendre comment ils marchent :-/ Heureusement on trouve des tutoriels pas trop mal foutus, genre celui-ci. wxPython fournit des méthodes spécifiques pour gérer les thread liés à l'interface, mais je ne vais pas m'en servir dans un premier temps, je vais commencer par faire du threading "basique".

Mon idée pour commencer est de faire un thread pour dr2_logger, et un autre pour pygauge, ou du moins leurs équivalents fonctionnels dans mon merge.

Dans les exemples fournis avec pyserial, il y en a quelques uns qui utilisent wxPython, ce qui semble une bonne base de départ. Si on regarde l'exemple wxTerminal, on constate sans surprise qu'il y a un thread pour gérer les données entrantes (fonction ComPortThread).

dr2_logger : le main démarre un thread qui gère les inputs clavier, puis une boucle infinie qui va dépiler les inputs récupérés par le thread en question. Lorsque le programme se finit, il attend la fin du thread avec un .join() avant de se finir. Ça donne un truc dans ce genre:

message_queue = queue.Queue()
input_thread = threading.Thread(target=add_input, args=(message_queue,), daemon=True)
input_thread.start()

while not end_program:

    print_current_state()

    while not message_queue.empty():

        command message_quque.get()

        # Traitement des messages ...

input_thread.join()

(à noter que si un thread est indiqué comme étant un "daemon" comme ici, il n'est pas nécessaire de se soucier de l'arrêter en quittant le programme, le thread sera tué automatiquement)

Je pense qu'il faut que je mettre la boucle infinie dans un thread, plutôt.

Communication inter-modules

On va donc utiliser PyPubSub. L'usage est très simple : on import la lib, pour publier on fait un pub.sendMessage() avec le nom du canal et la donnée à envoyer, et pour recevoir on fait un pub.subscribe() avec une fonction de callback et le nom du canal. Quand un message est émis sur le canal en question, la callback est appelée et va recevoir les données en paramètres. Et si personne ne consomme un canal, et ben les données sont perdues, rien de plus.

L'avantage c'est que ça rend les modules très indépendants du programme dans lequel ils sont instancié, vu qu'ils gèrent eux-mêmes directement leurs canaux.

J'ai identifié les canaux suivants:

  • serial_tx et serial_rx, entrée et sortie du module serial, qui seront reliées au module DCM,
  • print et print_debug pour envoyer du texte vers la console,
  • cmd_fuzz_to_device, cmd_SDB_to_device : commandes vers la carte externe, généré par les modules fuzz et SDB,
  • cmd_device_to_fuzz, cmd_device_to_SDB : commandes venant de la carte externe, généré par DCM,
  • game_telemetry : télémetries extraites des trames UDP, généré par STL a priori (idéalement il faudrait que je fasse un module dédié pour sortir la lecture des trames UDP de DR2_logger),
  • cmd_game_input : détection d'une entrée / état d'une entrée, généré par SDB,

L'interrogation principale, en prenant cette logique et en utilisant ces canaux de communication, c'est la performance. Est-ce que c'est suffisamment rapide pour ne pas trop avoir de latence ? Autant pour de la config ce n'est pas très grave, mais pour la transmission des entrées vers le jeu, ou la synchro de l'affichage, ça serait bien qu'il n'y ait pas un bottleneck à ce niveau. Et les infos que je trouve sur le net ne sont pas très rassurantes. Bon, on verra bien :-/

Configuration

Comme dit plus haut je vais utiliser des fichiers de config, pour faciliter l'usage. PyGauge utilise du YAML, donc je vais partir là-dessus par défaut. L'idée est d'avoir des paramètres par défaut (port COM par défaut, baudrate, réglages "standards" ...) mais aussi des paramètres permettant de customiser les fonctions, par exemple changer les paramètres du dashboard en fonction du jeu, ou plus simplement avoir les configs nécessaire au parsing des trames UDP (longueur, indexes des données ...).

A terme, l'idéal serait que TOUT soit paramétrable : le protocole, la disposition des fenêtres, etc. Mais bon, on va commencer tranquillement, faire un truc qui marche, et après on mettra des fonctionnalités avancées. Ceci-dit, quand j'en serai là, il faudra que je me penche sur XRC, qui permet de séparer la définition du layout dans un fichier XML, alors que le code ne garde que la logique. Ça me semble l'outil adapté pour implémenter ce genre de paramétrage.

Et puisqu'on est dans le nice-to-have, il existe également une lib pour permettre la mise à jour automatique d'une application générée à partir de Python. Il faudra que j'y jette un œil, mais là c'est vraiment plus du gadget qu'autre chose, vu mon usage, surtout que les libs mises en œuvre semblent tombées dans l'oubli, genre dernière release d'Esky en 2015 ... Pas bon signe. Il y a un tutoriel ici.

Console

Pour avoir un résultat "propre", je vais rediriger la sortie du print vers le text control de la console. Pour cela "yaka" rediriger sys.stdout vers un affichage. J'ai trouvé plusieurs threads de forum qui en parlent, il y a plusieurs façons de le faire, pour faire simple je vais suivre la méthode donnée en exemple ici. A priori j'ai deux possibilités d'implémentation de la fonction de remplacement de stdout : soit je fait écrire directement dans une fonction publique du panel console, soit j'envoie des messages via pypubsub.

Malheureusement tout n'est pas aussi simple. Pour faire fonctionner dr2_logger, il faut appeler en boucle les fonctions 'check_udp_messages', 'check_state_changes' et surtout 'get_game_state_str' qui, va afficher l'avancement de la course. Or, ceci est fait en dessinant une barre de progression dans le terminal, ce qui est fait en utilisant les fonctions sys.stdout.write() et sys.stdout.flush(). Il faut donc les créer dans la classe "de remplacement" que l'on va utiliser pour la redirection. Ces deux fonctions sont présentes dans la classe TextCtrl de WX, ce qui pourrait laisser penser qu'il suffit de ne rien faire et uste rediriger les appels, mais tel quel cela a pour conséquence de planter le thread juste après la réception du premier paquet et l'affichage des données qu'il contient. le problème c'est que autant write marche bien, par contre flush est un placeholder explicitement documenté comme étant vide. Et, vu la description du fonctionnement de l'objet TextCtrl, il n'existe pas de méthode native faisant la même chose qu'un flush() sur stdout, ce qui explique sans doute pourquoi la méthode avec ce nom ne fait rien. Il n'y a pas de notion de buffer sur un TextCtrl, le contenu est géré d'un seul bloc, on ne peut pas effacer "la dernière entrée", car une fois "append" dans le champ "value", toute nouvelle entrée devient, à l'intérieur de l'objet TextCtrl, indissociable du reste des caractères qui sont déjà contenus dedans.

Ici, le but de l'utilisation de flush est d'effacer les dernières lignes, pour pouvoir les remplacer au fur et à mesure par du contenu mis à jour. Si on veut réimplémenter un équivalent de cette fonction sans disposer du "buffer de la dernière entrée", il faut parser le texte depuis la fin pour remonter à ce qui correspond au "début de la dernière entrée". Il faut donc pouvoir l'identifier de façon sûre, ça c'est facile : yaka compter les caractères de la dernière entrée, et remonter de ce nombre à chaque "flush". Le problème, c'est que c'est assez intense en calcul, et TextCtrl n'est clairement pas adapté pour faire de la mise à jour en masse. En tous cas, j'ai essayé, ça flicker, et ça rame sa race.

Par ailleurs, je me rends compte que l'architecture de dr2_logger ne permet pas d'extraire des données spécifiques. La méthode get_game_state_str qui permet de remonter des données parsées des trames UDP ne fait que remonter une chaîne de caractère avec un contenu pré-formatté, uniquement prévu pour être affiché dans le terminal. Il n'a pas été prévu de pouvoir remonter spécifique le rapport de boîte engagé ou la vitesse par exemple, tout est juste stocké dans le log (et compressé au passage, d'ailleurs).

J'en conclus donc que ma seule option est de modifier dr2_logger et ajouter des méthodes pour remonter spécifiquement ces données. Ce n'est pas plus mal, car ça me permettra de comprendre comment il fonctionne, ce qui me facilitera la tâche pour ajouter les classes équivalentes pour RBR.

Dashboard

Comme on l'a vu juste au-dessus, il faut que j'ajoute des méthodes qui remontent les données qui m'intéressent pour le dashboard. L'idée, c'est d'avoir au niveau des classes spécifiques à chaque jeu une série de méthodes qui permettent de remonter ces données, avec les mêmes prototypes, et remontant des données selon un format commun. De cette façon, le module logger_backend n'aura qu'à appeler ces méthodes, et émettre le résultat sur les canaux ad-hoc (avec re-formatage si besoin). Le traitement de son côté sera le même quel que soit le jeu, c'est la classe spécifique au jeu qui pré-traitera les données de télémétrie spécifiques et devra se démerder pour fournir une valeur cohérente et dans le bon format. Ensuite,le module SDB pourra les récupérer et les afficher.

Le module DCM sera abonné à ces canaux en parallèle et pourra également formater les commandes à envoyer au dashboard externe.

Le module dashboard dans la GUI va juste faire de la visualisation, et ça va aussi me permettre de faire du debug. Je vais mettre deux cadrans, un pour le régime moteur et un pour la vitesse, ainsi qu'un digit pour la vitesse engagée, un compteur de temps et une barre de progression.

Pour la barre de progression je vais instancier un objet wx.Gauge. Attention au format : il prend des pourcentages en entier.

Pour le rapport de boîte et le timer, il n'y a a pas d'objet natif, mais il existe une sorte de lib tierce appelée "gizmos" qui contient un objet du genre digits en LED 7 segments. Ça n'accepte que des chiffres et des ponctuations genre ":", mais ça fait le taf.

Enfin, pour le régime moteur et la vitesse je vais utiliser les widgets agw.speedometer. Assez relou à mettre en place, mais j'ai trouvé un code qui marche [mettre le lien vers le code qui marche]. Quand on parle formatage, sur ces grandeurs les valeurs sont souvent dans des formats à la con, genre en dizaines de RPM, ou par pas de 5km/h. Donc faire la conversion dans le bon format au niveau du module spécifique du jeu est nécessaire pour avoir un process unique d'affichage et ne pas devoir mettre des conversions conditionnelles dans la GUI.

Communication série

Pour la gestion de la com série, on utilise pyserial, aucune raison de se prendre la tête. Ce qu'il y a de bien c'est qu'ils fournissent des exemples de terminal qui utilise wxPython : yaka se servir !

Pour le protocole en lui-même, pygauge en définit un, très basique, que je vis reprendre dans un premier temps, mais je pense que je vais à terme utiliser le même protocole que Simhub, pour pouvoir faire des essais avec cet outil, pour compatibilité, et parce qu'il n'a pas l'air plus stupide qu'un autre protocole. En vrai, c'est assez ouvert vu qu'on peut définir ses propres formats de trames, donc ça ne va pas être si contraignant que ça. D'ailleurs ils fournissent une API pour pouvoir interfacer directement dans du code Arduino, et ça a l'air simple et facile d'usage. Encore une fois : ça me semble un bon, outil, juste c'est pas libre et ça m'embête.

Pour la lecture des trames entrantes en provenance du device, je vais faire un thread, comme dans les exemples pyserial.

Configuration

Or, pour rendre ce programme le plus générique et configurable possible, il me faut un système de fichier de configuration qui permette de modifier le comportement du programme en-dehors du code source en lui-même. Pour cela on va passer par des fichiers de configuration. Mais quel type de fichier choisir ?

En Python, visiblement ce qui est courant c'est d'utiliser soit des fichiers INI avec la lib configparser, soit des YAML ou JSON via des libs yaml et json déjà bien implantées. Sinon il y a aussi moyen d'utiliser des XML, mais ça n'a pas l'air très populaire dans le milieu Python, par contre en C# ça a l'air très répandu, et plus largement sur les outils Microsoft.

Dans notre cas, dr2_logger utilise un fichier INI et configparser, donc dans un premier temps je me suis calé là-dessus. Malheureusement, les limitations de ce format sont trop lourdes pour l'usage que j'envisage. Tout gérer en chaîne de caractère c'est vraiment pénible, mais surtout j'aimerais bien pouvoir définir le contenu des trames UDP via des fichiers de configuration, et ne pas pouvoir faire de structures complexes est vraiment trop limitant. Je vais donc partir sur du YAML, puisque JSON est un subset de YAML autant prendre le format le plus généraliste.

Mon idée c'est de permettre le plus de configuration "externe" possible, et permettre d'avoir une gestion simple des fichiers de config. L'approche sur laquelle je pars : un fichier de config général, qui contient une liste de jeux supportés, et les noms des fichiers de config pour chaque jeu. Chaque fichier de config de jeu contiendra les infos spécifiques : IP, port, index des données dans la trame UDP, format, gain et offset pour les valeurs numériques, pour pouvoir adapter (genre Dirt qui renvoie la vitesse en m/s qu'il faudrait avoir en km/h pour la lisibilité). Je vais ajouter aussi un moyen de faire une lookup table, pour la lecture de la vitesse engagée et gérer la marche arrière si il y a des jeux qui ont une config bizarre. Toute l'interface se basera sur le parsing de ces fichiers pour déterminer les jeux supportés et ce qu'il faut faire avec les données reçues. Avec ça, pour ajouter le support d'un jeu, il n'y aura qu'à créer un fichier de config pour le jeu et l'ajouter dans la config générale, et il sera automatiquement ajouté dans les menus, et géré par le logger.

Ce qu'il faut aussi, c'est que l'absence d'une info dans le fichier de config indique que l'info n'est pas disponible, et donc que ça soit géré. Je vais aussi faire un fichier de config spécifique pour le logger, pour pouvoir déterminer en fonction du jeu quelles sont les infos qui doivent être tracées, et les regrouper automatiquement plutôt qu'avoir un layout "fixe".

Avec YAML tout ça est relativement simple à faire. Vu qu'on peut faire des listes de "paquets" d'éléments, ça permet de faire une sorte de tableau "normalisé", là où avec ConfigParser il faudrait avoir un nom unique de variable par élément.

Conversions analogiques

Petit aparté concernant l'implémentation de la lecture analogique sur la Disco. Incroyable à quel point il est impossible de trouver un tutoriel adapté, alors que le setup est vraiment simple : je veux juste faire l'acquisition de plusieurs entrées analogiques différentes en DMA. Ya pas deux tutoriels ou deux réponses de forums qui disent la même chose, incroyable.

Ce que j'ai identifié : dans cubeMX il faut donc activer un canal DMA, et dans la config ADC il faut enable Continuous conversion, DMA continuous requests, Scan conversion mode, ne pas oublier de mettre le nombre de canaux à acquérir dans Number of conversions. Ensuite dans le code, on lance HAL_ADC_Start_DMA avec le handle de l'ADC et un buffer en UINT16 qui fait la taille du nombre de canaux à acquérir. Attention : il faut cast le pointeur vers le buffer en (uint32_t*) dans l'appel de HAL_ADC_Start_DMA sinon ça déconne.

Autre problème : il n'y a qu'une seule acquisition lancée au démarrage, et je n'ai pas trouvé comment les faire tourner automatiquement. En fait, ce qu'il semble se passer, c'est que si on met en "continuous", il va y avoir un traitement d'interruption à chaque acquisition, et si le temps d'acquisition est mis au minimum, en pratique le CPU va se prendre des interruptions non-stop, et fatalement ça va faire n'importe quoi. Donc en fait il faut soit trigger les acquisitions en one-shot sur un PWM "lent", soit les trigger en SW dans une tâche et gérer la mise à jour des variables dans l'interruption, soit augmenter le nombre de cycle de conversion pour ralentir le rythme. J'ai mis les cycles d'acquisition au max et ça semble marcher pas mal, même si c'est crado comme implémentation.

Note : le code merde avec la dernière version 1.16.2 du firmware, ça plante au boot, donc je suis resté sur 1.16.1 dans un premier temps, je n'ai pas migré le projet.

Sinon j'ai créé des tables d'interpolation pour pouvoir changer la grille du levier de vitesse. Peu probable que je m'en serve (je suis trop habitué à la grille standard) mais ça fait une petite fonctionnalité en plus.

Et ça en est où ?

Plutôt en vrac, mais très honnêtement je n'ai pas une motivation extrême sur ce projet. Donc tant pis. Ça marchotte, j'arrive à récupérer les data de DiRT et de RBR, avec les menus déroulants qui marchent tant qu'on ne change pas de config en cours de route. Par contre côté performance c'est assez mauvais, j'ai souvent des dé-synchro, je pense que Python + subscribe n'est pas adapté, trop lourd. Mais bon, sur le principe ça marche.

Boîtier

Pour avoir quelque chose de propre il va falloir faire un boîtier. Je vais partir dans un premier temps sur un boîtier semi-custom fait à la lasercut. Boxes.py est le go-to dans ces cas-là. Pourquoi s'embêter à faire des trucs compliqués quand on a un outil qui marche déjà ? J'ai pris le modèle console2, dont j'ai adapté les dimensions à mon usage. Clairement, c'est conçu pour des objets beaucoup plus grands que mon petit écran, car les "pieds" générés avaient une forme vraiment bizarre. j'ai du le reprendre avec Inkscape. De toutes façons il fallait que j'y passe pour ajouter l'ouverture et les trous de vis pour l'écran.

Pour le passage des connecteurs j'ai découpé une ouverture sur le dessous. C'est crado mais ça marche. Je me suis rendu compte après coup qu'il me manquait de la marge en hauteur, donc j'ai repris des chutes et j'ai fait une mini-extension pour corriger ça. Bref : ça fait le taf.

On trouve ça où ?

Ici sur Github

Ajouter un rétrolien

URL de rétrolien : http://blog.randagodron.eu/index.php?trackback/98

Haut de page