Journal RaspberryPi, capteurs USB, dbus et systemd, utiliser des briques Linux "desktop" pour une architect

Posté par . Licence CC by-sa.
39
21
déc.
2019

Sommaire

Bonjour tout le monde

Mon activité professionnelle quotidienne m'ayant éloigné du développement (je suis devenu DBA parce que le développement logiciel en entreprise me paraissait de plus en plus ridicule, mais libre à vous de me convaincre du contraire), je travaille en auto-entrepreneur sur les projets intéressants que l'on pourrait me présenter. Depuis quelques temps, je travaille sur un système embarqué, où une carte centrale (Raspberry Pi hélas, faute de mieux sur le plan prix/fiabilité d'apprivisionnement notamment) avec une interface graphique en C++/Qt qui communique avec un ensemble de capteurs basés sur des cartes Nucleo reliés en USB.
J'ai récemment fait une grosse altération de l'architecture de cette application : codé initialement sous forme d'un unique processus, il y a désormais un ensemble de processus communiquant entre eux par DBus. Bien que le code ne soit pas libre (mais promis aucune licence libre n'a été maltraitée dans l'opération), comme ce système exploite différents composants standards de nos systèmes, parfois dans des façons qui sont peu connues ou assez mal documentées, je me suis dit qu'il serait intéressant de rédiger ici un journal expliquant cette architecture, les avantages et inconvénients que l'on peut y voir.

1) L'architecture monolithique précédente

La version précédente du logiciel était constituée d'un seul processus (bon, à l'exception des processus de la saleté de moteur Web qu'il faut sortir pour certains affichage), utilisant les fonctionnalités de signaux/slots de Qt pour faire de l'asynchrone et gérer l'ensemble du matériel en un seul thread. L'ensemble fonctionnait plutôt bien jusqu'à ce qu'il se prenne la brique de la réalité dans la face : le matériel ne fonctionne pas toujours aussi bien qu'on l'espère. En l'occurence, les capteurs reliés en USB se sont révélés dans certains cas instables, avec des communications qui finissent par échouer, voire qui plantent le contrôleur USB de la Pi. L'ajout de la gestion du branchement à chaud du matériel est devenu nécessaire et "relativement" facile (il suffit après tout de surveiller /dev, ou de parler sur le line netlink d'udev, mais c'est plus compliqué), mais la coupure en pleine communication est bien plus compliquée à gérer.

Par ailleurs, d'autres phénomènes rigolos apparaissent : malgré le mécanisme de signal/slot de Qt, le code reste plus simple en synchrone. Et en cas de communication intense, si on ne découpe pas massivement le code, on se retrouve avec des gels de l'interface (j'espère sincèrement que le système de coroutines du C++20 éliminera ces problématiques). De plus, quand on utilise des bibliothèques externes, on ne gère pas vraiment la durée des traitements.

La solution classique dans un tel cas est l'utilisation de threads. Avec les threads, un même espace mémoire est utilisé par plusieurs fils d'exécution… mais si un fil d'exécution plante, il entraîne généralement avec lui l'ensemble du processus. De plus, si on utilise des bibliothèques tierces, sont-elles bien compatibles avec l'usage dans plusieurs threads ? Enfin, une telle évolution est contraignante sur notre propre code (même si Qt sait lever une partie des problèmes liés aux threads en permettant plus facilement d'isoler les objets au sein de chaque thread en les faisant communiquer par signaux/slots).

Du coup, il m'est venu l'idée d'une architecture en plusieurs processus…

2) La nouvelle architecture

a) systemd pour lancer un process par carte

On veut qu'un processus soit lancé par carte USB branchée. Le processus doit être tué quand le capteur est débranché. Il doit être démarré au boot par contre…
Je sais que cela déplaira à certains, mais une excellente solution pour ça est systemd. Ne s'agit-il pas quasiment d'un cas d'usage mis en avant lors de sa conception initiale ?
Pour cela, il y a deux étapes. Tout d'abord, il faut identifier avec udev le matériel et le marquer de sorte que systemd lance le service. Cela se fait avec un fichier de règle d'une ligne, à déposer dans /etc/udev/rules.d/ :

KERNEL=="ttyACM[0-9]+", SUBSYSTEMS=="usb", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="374b", TAG+="systemd" ENV{SYSTEMD_ALIAS}+="/sys/class/tty/%k" ENV{SYSTEMD_WANTS}="captor@%k.service"

Ces règles udev vont déclencher différentes actions au niveau de systemd.

Essayons déjà cette partie.

Branchons, avant d'avoir défini la règle udev, un périphérique nucleo. Il apparait sous /dev/ttyACM0. Demandons à systemd ce qu'il pense de ce périphérique :

root@peanuts2:~# systemctl status sys-class-tty-ttyACM0.device
● sys-class-tty-ttyACM0.device - /sys/class/tty/ttyACM0
     Loaded: loaded
     Active: inactive (dead)

Le périphérique n'est pas reconnu.
Ajoutons maintenant la règle udev, rechargeons udev (systemctl reload udev) et rebranchons le périphérique :

root@peanuts2:~# systemctl status sys-class-tty-ttyACM0.device
● sys-class-tty-ttyACM0.device - ST-LINK/V2.1
     Loaded: loaded
     Active: active (plugged) since Tue 2019-12-17 12:01:19 CET; 21min ago
     Device: /sys/devices/pci0000:00/0000:00:14.0/usb2/2-1/2-1:1.2/tty/ttyACM0

Il est désormais reconnu par systemd, pour être pris en compte dans les calculs des dépendances.
Il ne nous reste donc plus qu'à ajouter le fichier captor@.service pour qu'il instancie proprement nos services au branchement du périphérique. J'installe pour cela le fichier suivant dans /lib/systemd/system :

[Service]
ExecStart=/usr/bin/nucleo-captor %i
User=pi

[Unit]
BindsTo=sys-class-tty-%i.device
After=sys-class-tty-%i.device

Et il suffit ensuite de faire un systemctl daemon-reload pour que notre service soit démarré et arrêté au branchement du périphérique… Ce fut simple, non ?

b) dbus pour relier les processus entre eux

Bon. Le problème de dbus c'est le manque de main d'œuvre pour maintenir une documentation complète à jour, pour améliorer l'outillage dans l'ensemble des projets qui fournissent des bindings… Du coup ce qui suit contient des éléments que je n'ai trouvé que dans le code source du démon ou des projets l'utilisant, et pas dans la documentation. quoi…

Premier élément, le bus… C'est là où se connectent les différents processus pour communiquer entre eux. Par défaut sous Linux, avec un utilisateur connecté, il y a deux bus, le bus système et un bus propre à chaque session. Dans notre cas, nous allons utiliser le bus système, démarré par systemd au boot. Par contre, par contre… Ce bus est restreint. Il serait malvenu qu'un processus malveillant puisse s'y inscrire et fournisse de fausses réponses à d'autres éléments du système, n'est-ce-pas ? Il y a donc pour cela des fichiers de configuration listant les accès autorisés au bus système pour chaque utilisateur…
Ci dessous, le fichier que j'utilise pour mon système embarqué, dans /etc/dbus-1/system.d/fr.corp.conf :

<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
 "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig>

  <!-- ../system.conf have denied everything, so we just punch some holes -->

  <policy user="pi">
    <allow own_prefix="fr.corp"/>
    <allow send_destination="fr.corp.core"/>
    <allow send_interface="fr.corp.CaptorInterface"/>
    <allow send_interface="org.freedesktop.DBus.ObjectManager"/>
    <allow send_interface="org.freedesktop.DBus.Properties"/>
    <allow send_interface="org.freedesktop.DBus.Introspectable"/>
  </policy>
</busconfig>

Beaucoup de allow (ne vous endormez pas, un sleepy allow finit toujours mal) dans ce fichier… Donc, on s'en doute en le lisant, DBus a été conçu dans les années 2000 (pourquoi utiliser du XML sinon…)
La politique de droits décrite dans ce fichier s'applique à l'utilisateur pi (dans un prochain épisode, on parlera peut-être du passage à buildroot, mais là nous sommes encore en phase de développement, ne nous dispersons pas). Elle autorise l'utilisateur pi à:
- déclarer sur le bus des services dont le nom commence par fr.corp.
- envoyer des messages à fr.corp.core (on en parlera après, il s'agit de l'appli qui fournit l'interface graphique)
- envoyer des messages sur l'interface des capteurs, nommée fr.corp.CaptorInterface (on en parle juste après), et à quelques interfaces système fort utiles pour explorer le bus.

3) Exposer un capteur sur DBus

Pour exposer un service sur DBus, comme mentionné précédemment, il faut qu'il expose une interface. Dans la grande tradition des systèmes de communication, DBus définit un langage pour décrire les interfaces. Évidemment, années 2000 obligent, c'est en XML.
Décrivons donc notre capteur. On va le faire un peu évolué pour que l'exemple soit complet:

<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
        "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
<node name="/captor">
  <interface name="fr.corp.CaptorInterface">
    <method name="readValue">
      <arg name="result" type="i" direction="out"/>
    </method>
    <method name="lightUp"/>
    <method name="lightDown"/>
    <property name="serial" type="s" access="read"/>
    <signal name="overValue"/>
    <signal name="buttonPressed">
      <arg name="duration" type="i" direction="out"/>
    </signal>
  </interface>
</node>

Rien de très compliqué. Notre service sera exposé à l'adresse /captor sous l'interface CaptorInterface. Il aura une méthode readValue, avec un retour de type entier, deux méthodes lightUp et lightDown, un numéro de série exposé sous forme de propriété en lecture seule, un signal overValue pour exposer une valeur au dessus d'un seuil de mesure, et un signal pour indiquer qu'un bouton a été pressé pendant x secondes.

Du coup, derrière, comment on implémente ça ? Tout dépend du langage et des bibliothèques que vous utilisez.
Dans mon cas, en C++/Qt, je déclare DBUS_ADAPTORS += captor.xml et j'obtiens un objet me permettant d'exposer correctement mon service tout en respectant ce contrat. Je suis pas entièrement fan de ce système, il a ses arguments mais je le trouve trop laxiste et donc dans un sens risqué, mais de sérieux tests suffisent à valider le bon fonctionnement tout de même.
Après avoir déclaré cet «adaptor», il suffit dans le code de l'utiliser, ce qui donne en C++ ceci :

    // Ce devName vient de systemd du coup, allez lire plus haut si vous avez oublié
    auto devName = parser.positionalArguments()[0];
    auto device = new Device(devName);
    auto adaptor = new CaptorInterfaceAdaptor(captor);
    if (!QDBusConnection::systemBus().registerService(QString("fr.corp.captor.%1").arg(devName)))
        qFatal("Failed to register service on system bus");
    QDBusConnection::systemBus().registerObject("/captor", device);

Notez que l'utilisation d'un qFatal permet d'avoir une sortie en erreur et donc systemd qui sait que quelque chose de mal s'est produit… Vous monitorez bien la sortie de systemctl list-units --failed, n'est-ce-pas ?
Ce journal devenant bien long, je laisse en exercice aux lecteurs l'implémentation d'un capteur en se conformant à cette interface. Notez que, et c'est fort pratique, le vocable DBus se prête bien au vocable de Qt et ses signaux, slots et propriétés…

4) Communiquer avec nos capteurs

Deux éléments importent ici : savoir lister, dynamiquement, les capteurs, et savoir parler avec eux.
Commençons par les lister. Le bus nous notifie de l'arrivée de congénères, c'est parfait. Il existe des classes dans Qt pour écouter ces événements, donc il suffit de se brancher sur l'une d'elles : serviceOwnerChanged. Ok, j'explique…
Lorsqu'un service arrive, il arrive sans propriétaires ni données, et change ensuite d'owner. Nous attendons donc tout simplement ce changement pour capturer l'information.
Pour simplifier, voici ce à quoi ressemble un tel code :

    auto bus = QDBusConnection::systemBus();
    if (!bus.isConnected())
        qFatal("BAD");
    auto iface = bus.interface();

    // Connection is queued to make sure DBus will be available and working when processing the following events.
    // This way, we don't have to push queues everywhere and limit the unsafety here.
    connect(iface, &QDBusConnectionInterface::serviceOwnerChanged, [] (const QString &service, const QString &oldOwner, const QString &newOwner) {
        if (service.startsWith("fr.corp.captor.")) {
            if (newOwner.isEmpty()) {
                // This is goodbye !
                emit(deviceRemoved(service));
            } else if (oldOwner.isEmpty()) {
                emit(deviceAdded(service));
            }
        }
    });

Très simple, non ?

Quant à l'utilisation…
J'ai dans mon projet l'équivalent «utilisateur» de DBUS_ADAPTORS, à savoir DBUS_INTERFACES. Cela me génère une classe complète qui encapsule en C++/Qt toute l'interface définie dans mon service DBus.
Du coup… quand mon énumérateur m'envoie un signale deviceAdded avec un nom de service, il me suffit de faire ceci:

    auto device = new fr::corp::CaptorInterface(service, "/captor", QDBusConnection::systemBus());

Et j'ai alors un objet device répondant intégralement au cahier des charges précédent !

qDebug() << device->serial();
device->lightUp();
device->lightDown();

etc, etc…
Bien sûr, grand défaut : il est tentant d'appeler de manière synchrone ces fonctions. C'est un piège, dont on se sort heureusement facilement :

QDBusPendingReply<int> valueReply = device->readValue();
auto valueWatcher = new QDBusPendingCallWatcher(valueReply, this);

Et l'on attend le signal QDBusPendingCallWatcher::finished…

5) Comparaison rapide

L'introduction de cette modification a profondément changé l'organisation de mon projet. Il m'a fallu déplacer beaucoup de code de part et d'autre, mais j'ai pu :
- me débarasser de la gestion du matériel, gestion assez compliquée mine de rien s'agissant d'USB sur une Pi (je ne sais toujours pas comment, avec du code tournant en espace utilisateur, j'ai bien pu planter la puce USB de la Pi… mais c'était horrible à essayer de debugger, pour sûr)
- réduire le risque de plantage visible de mon application en cas de soucis dans la communication USB
- (m')imposer de bien être en asynchrone pour les communications avec les périphériques, sans pour autant avoir à écrire une gestion asynchrone des écritures sur le port USB
- ouvrir la voie à une réécriture de morceaux de l'application dans d'autres langages si je le souhaite, tant que l'on respecte le 'protocole' (Python, Rust, Go, le choix est libre)
- proposer d'avoir des centaines de capteurs centralisés sur un système, en passant les services DBus par le réseau (j'en parlerai si j'en venais à mettre ceci en production, pour le moment mon prototype a fonctionné, pour mon plus grand plaisir, mais je n'ai pas encore réfléchi à comment le rendre propre)
- et le plus amusant, j'ai pu coder en quelques lignes une interface graphique me permettant de faire un capteur virtuel, sous forme d'une application graphique…

À l'avenir, je pourrais commencer à tester les pires scenarios en simulant dans un langage de script des capteurs et tester l'interaction de l'application avec ces derniers, ou à l'inverse tester le bon comportement de mes capteurs sans sortir tout le code de l'application.
Et ces derniers points m'ont déjà servi à moultes reprises en libérant tout simplement mon bureau d'une quantité de cables et périphériques…

Merci à tous pour votre patience et pour avoir lu ce pavé.

Bonnes fêtes de fin d'année à tous !

  • # marrant...

    Posté par . Évalué à 5 (+4/-1).

    J'ai pris le même type de décision (juste pas les mêmes choix de technologie finale) il y a 2 ans, a quelques différences près:

    • le soft d'origine était loin d'être stable;
    • pas d'écran graphique, mais une carte pilotée en RS232;
    • diverses cartes sur divers bus RS485;
    • l'idée était de porter l'existant vers une interface graphique tout en maintenant l'ancien hard;
    • l'ajout d'une interface graphique à déclenché la migration vers une archi Intel, totalement overkill, mais mes prédécesseurs sont partis la-dessus pour jouer avec étron-js, qui bien sûr ne peut fonctionner sur un SoC arm avec «seulement» 512Mio de ram. Le nombre de galères que j'ai eues avec ça…;
    • un seul port USB, qui nous relie a un démultiplexeur. Tiens, on a aussi eu des merdes avec ça d'ailleurs, dans certains cas, il lâchait, mais grâce à runit, ça n'a pas posé plus de soucis que ça;
    • «mon» code historique utilisais des threads. C'est justement ça, ainsi qu'un générateur de code C++ codé maison et le départ de celui qui s'est tapé la maintenance peu de temps après mon arrivée (1 mois) m'ont forcé à déclencher la réécriture totale (du spaghetti en telles qualité et quantité que l'Italie en serait jalouse);

    Je te rejoins sur les bénéfices, mais tu es passé à côté d'autres:

    • multi-thread, ça encourage à utiliser du partage mémoire. C'est chiant à débugguer, et ça n'apporte aucun intérêt réel si tous les threads ont la même durée de vie (sous linux, il semble que la gestion des threads et celle des processus soient extrêmement proches). Enfin, rien d'autre que des emmerdes quoi.
    • possibilité de travailler à plusieurs chacun «dans son coin», fusionner les codes deviens un besoin anecdotique, puisque la plupart du code d'une appli n'a aucune relation avec les autres;
    • possibilité de relancer les daemon systèmes, chose que rc.d n'a jamais été capable de faire malgré plusieurs milliers de lignes de shell imbuvable. Ça m'a servi à mieux supporter certaines cartes SIM qu'un routeur, du fait que j'ai configuré pppd pour se fermer quand la connexion échoue: il est donc relancé, jusqu'a ce que la co prenne, chose dont les routeurs que nous utilisons sur certains systèmes sont incapables de faire… (le sentiment de satisfaction que j'ai éprouvé quand j'ai remarqué ça était très agréable :D).

    J'ai hâte de voir ton prochain épisode sur buildroot, j'aimerai aussi mettre ce genre de trucs en place, mais tant de trucs à faire avant…

    • [^] # Re: marrant...

      Posté par . Évalué à 4 (+2/-0).

      Je sais que c'est un gros pavé, mais j'ai bien parlé des threads :)

      La solution classique dans un tel cas est l'utilisation de threads. Avec les threads, un même espace mémoire est utilisé par plusieurs fils d'exécution… mais si un fil d'exécution plante, il entraîne généralement avec lui l'ensemble du processus. De plus, si on utilise des bibliothèques tierces, sont-elles bien compatibles avec l'usage dans plusieurs threads ? Enfin, une telle évolution est contraignante sur notre propre code (même si Qt sait lever une partie des problèmes liés aux threads en permettant plus facilement d'isoler les objets au sein de chaque thread en les faisant communiquer par signaux/slots).

      Et pour les autres points positifs que tu cites, je ne suis pas fortement concerné car j'ai majoritairement travaillé seul sur ce projet, et je considère que si tu n'as pas d'outils pour avoir un démon qui se relance automatiquement t'as raté ta vie :)
      À ce sujet, rions un peu, j'ai eu une mise à jour de mon flipper (cf https://linuxfr.org/users/pied/journaux/an-unexpected-linux-reverse-engineering pour plus d'histoires) et en creusant j'ai vu que leur script d'init fait:

      while true ; do
         /game/bin/game
         show_message "Restarting"
      done
      

      Et j'ai à certains démarrages, mais pas tous, le message "Restarting" à l'écran…

  • # Tout pareil !

    Posté par . Évalué à 4 (+2/-0).

    Sympa ton retour d'expérience. J'ai justement eu un projet sur lequel j'ai fusionné, exactement comme toi, udev et systemd (dans mon cas, c'est pour déclencher des mises à jour produit sur l'insertion d'une clé USB), et dans lequel l'application principale utilise dbus (principalement pour piloter OMXplayer).

    L'idée était d'avoir une architecture portable sur d'autres SoC. Ca marche sur une Pi 0W, et force est de constater que le système sera un peu juste sur certaines évolutions du produit, le choix d'utiliser au maximum l'environnement système permettant d'évoluer sereinement et découpler les fonctions.

  • # Olimex

    Posté par (page perso) . Évalué à 2 (+1/-0).

    Intéressant projet.

    Pour la carte, vu que tu n'as pas l'air d'apprécier raspberry pi plus que ça, as tu été voir du côté de chez Olimex ? Ils semblent assez orientés openhardware (ils font la freedombox pioneer par ex) et garantissent leur approvisionnement dans le temps (cf https://olimex.wordpress.com/2019/09/04/this-is-how-allwinner-keep-their-promise-for-long-term-supply-with-olimex/ ).

    • [^] # Re: Olimex

      Posté par . Évalué à 3 (+1/-0).

      La OLinuXino aurait été intéressante oui… Bon, le GPU Mali-400 m'aurait encore provoqué moultes découragements avec webengine (cf. https://blog.bshah.in/2019/12/20/plasma-mobile-as-daily-driver/ pour pleurer), mais certains défauts n'ont été réglés que récemment genre https://olimex.wordpress.com/2019/03/08/a64-olinuxinogot-mainline-linux-kernel-5-0-images/
      Entre la Raspberry Pi et sa raspbian trafiquée comme une mob et l'olinuxino avec un noyau 3.10… J'avais voté pour la Pi. Aujourd'hui, si c'était à refaire, je demanderais une palette d'olinuxino pour tester…

      • [^] # Re: Olimex

        Posté par . Évalué à 2 (+0/-0).

        Olimex bosse lentement pour un cahier des charge de matériel industriel. Mais si tu veux un système avec un noyau plus à jour, il faut remonter à la source et regarder du côté de Armbian voire même directement chez Linux-sunxi (et chez Lima, qui est désormais en mainline).

  • # Captor

    Posté par . Évalué à 6 (+6/-1). Dernière modification le 22/12/19 à 16:46.

    Apparemment cela signifie "preneur d’otage".

    "Sensor" est probablement plus approprié :D

    Sauf si tu réalises effectivement une carte preneuse d’otage automatisée.

  • # Et MQTT

    Posté par . Évalué à 3 (+2/-0).

    Salut,

    Je n'ai pas de projet similaire, mais en m'essayant à la domotique, je vois que MQTT semble être bien plus apprécié que DBus.

    A priori il semble bien adapté pour des capteurs qui publient des états; et a l'air mieux documenté que DBus.

    Tu as jeté un oeil ? Un commentaire à faire ?

    • [^] # Re: Et MQTT

      Posté par . Évalué à 4 (+2/-0).

      Salut

      Ça me semble pas comparable : MQTT est un 'simple' transport de messages en 'publish/subscribe', DBus est un système d'IPC/RPC. J'aurais pu utiliser MQTT, presque comme j'aurais pu utiliser des sockets UNIX. Il aurait fallu par exemple que je sérialise/désérialise les messages à un format convenable.
      De plus, la bibliothèque QtMQTT est en GPL uniquement… Ça aurait été compliqué avec mon client hélas :/

  • # Moi j'utiliserais...

    Posté par (page perso) . Évalué à 1 (+0/-0).

    Bravo pour le boulot et merci de le partager.

    Sinon, par curiosité, as-tu déjà regardé ce projet: https://nerves-project.org/ ?

    La stack est à peu près celle-là:
    * buildroot pour la base
    * beam, la vm erlang
    * elixir, le langage qui compile sur la vm erlang, mais avec une syntaxe plus proche de ruby
    * Nerves: un ensemble de librairies pour un faire quasi un OS: gestion des périphériques, du réseau, etc

    En gros, les problématiques que tu évoques sont toutes couvertes, soit par le langage lui-même, soit par les librairies fournies:
    * communication par message dans le langage
    * threads légers gérés par la vm
    * gestion des périphériques dynamiques sous forme d'évènements
    * les pannes/fautes sont gérés par des superviseurs de threads, de manière très simple: tu déclares les threads à superviser, leur état initial et le système s'occupe du redémarrage, des notifications si les fautes sont trop fréquentes, etc
    * serveur web ultra-léger et performant disponible sous forme de librairie (cowboy ou la stack complète phoenix)
    * pour la partie gui, il existe un framework dédié: https://hexdocs.pm/scenic/welcome.html

    Bref, je l'ai déjà utilisé pour un client. Le projet consistait en un raspberry qui contrôlait une MCU par RS232, avec une interface web (REST + SPA angular), du SNMP, un LCD + des boutons de contrôle. L'appli permettait également de multiplexer un port USB en façade. Tout cela tourne parfaitement sur un raspberry 2 et tient sur une image de 30Mo.

    • [^] # Re: Moi j'utiliserais...

      Posté par . Évalué à 3 (+1/-0).

      Merci pour le lien, je le note dans un coin… mais le truc qui tue en général les outils de ce genre pour mon usage : il fallait pouvoir afficher une page web dans un coin de l'interface… Donc exit beaucoup de bibliothèques alternatives hélas.

Envoyer un commentaire

Suivre le flux des commentaires

Note : les commentaires appartiennent à ceux qui les ont postés. Nous n'en sommes pas responsables.