Déjà évoqué dans diverses publications [1], le protocole Machine-2-Machine MQTT, se veut être le standard de communication pour les objets connectés (comprendre IoT ici !). En effet, de par sa légèreté et son efficacité, il en fait un protocole très prisé pour la gestion de la télémétrie en environnement embarqué.
L’article qui suit sera, pour les auteurs, l’occasion non pas de présenter MQTT dans les moindres détails, mais plutôt d’introduire et utiliser le nouveau module Qt MQTT sur notre plateforme i.MX7. Nous réaliserons ainsi un petit démonstrateur s’articulant autour de ce module en intégrant une partie graphique pour la visualisation des données avec le module Qt Charts. Let ‘s go !
1. MQTT, bref rappel
Afin de mieux comprendre le contexte mis en place dans notre étude, les auteurs se permettront tout de même de faire une rapide présentation du protocole MQTT et de ses spécificités.
1.1 Il était une fois …
Contrairement à ce que l’on pourrait penser, MQTT (pour MQ Telemetry Transport) ne doit pas sa popularité au monde de l’IoT et des objets connectés. En effet, créé en 1999 par Andy Stanford-Clark d’IBM et Arien Nipper d’Arcom (Eurotech depuis), ce protocole dédié au monde du M2M (Machine-to-Machine), avait pour but d’être un moyen de communication simple, avec une faible consommation en énergie et léger en termes de bande passante (il faut se remettre dans le contexte de l’époque où les équipements devaient être connectés au satellite par exemple).
Basé sur un mécanisme « publication/abonnement » ou « publisher/subscriber » pour les anglophones, celui-ci est situé au niveau 5 – 7 du modèle OSI, le protocole MQTT repose ainsi sur la couche de transport TCP/IP et il est utilisé sur le port 1883 ou 8883 pour les communications chiffrées SSL/TLS lors des différents échanges de messages.
1.2 Le Principe
MQTT permet aux appareils connectés de partager des informations (données physiques en provenance des périphériques) sur un sujet donné (appelé Topic) à un serveur de messages (appelé Broker). Le Broker renvoie ensuite les différentes informations vers les clients qui se sont préalablement abonnés aux différents Topics.
Pour l’utilisateur, les Topics sont traités en hiérarchie en utilisant le « / » comme séparateur. Ceci a pour effet d’offrir la possibilité d’une gestion très fine des différents thèmes (différentes pièces d’une maison par exemple). L’agencement rappelle un peu la gestion d’un système de fichiers. Ainsi, les clients peuvent s’abonner à un niveau spécifique de la hiérarchie d’un sujet ou à plusieurs niveaux en utilisant des « wildcards » spécifiques que nous allons découvrir par la suite. L’image suivante (figure 1) permet de schématiser le concept du protocole MQTT :
Fig. 1 : Principe de fonctionnement.
1.3 Messages
Un client MQTT peut publier des messages dès qu’il se connecte à un Broker. Chaque message doit contenir un Topic que le Broker peut utiliser pour transmettre le message aux clients abonnés. Typiquement, chaque message a une charge utile (payload) qui contient les données à transmettre sous forme d’octet. C’est donc au client expéditeur de déterminer la structure de la charge utile ; on peut retrouver :
- des données binaires,
- des données texte,
- des données au format XML,
- des données au format JSON.
Comme brièvement abordé en début de section, un message PUBLISH comporte plusieurs attributs (voir figure 2) :
-
Topic : nous avons, dans cet exemple, une hiérarchie de plusieurs Topics accessibles chacun à leur niveau par d’éventuels clients destinataires (maison/chambre/temperature). Par exemple, afin de récupérer la donnée du capteur chambre de la maison, il faudra ainsi s’inscrire sur le Topic temperature.
Une notion vraiment intéressante dans le protocole MQTT, concerne l’utilisation des wildcards. En effet, il est aussi possible de ne pas spécifier de Topic de façon explicite lors d’une souscription, mais plutôt d’utiliser ce qu’on appellera des caractères génériques. MQTT en possède 2 (+ et #) :
- le + pour la gestion à 1 niveau de hiérarchie. Par exemple, il est possible de récupérer l’ensemble des températures de la maison de la manière suivante : maison/+/temperature
- le #, quant à lui, permet de gérer plusieurs niveaux de hiérarchie. Imaginons ici, vouloir récupérer l’ensemble des données de la maison (température, humidité…), il nous faudrait par exemple souscrire de la manière suivante : maison/#
- Charge utile : le contenu du message, ici une simple donnée de température.
- QoS : la qualité de service, que nous expliquerons juste après.
- Retain : permet de définir si le message doit être enregistré sur le Broker comme étant la dernière valeur valide. Celle-ci sera ainsi envoyée de façon automatique aux clients lors d’une connexion au Broker.
Fig. 2 : Message MQTT « PUBLISH ».
1.4 Sécurité
On ne parle pas protocole sans aborder la question de la sécurité, MQTT permet donc d’intégrer quelques notions à ce sujet, on retrouvera par exemple les différentes possibilités suivantes :
- le transport de données sécurisé en SSL/TLS ;
- l’authentification des clients par le biais de certificats SSL/TLS ;
- une gestion par mot de passe et login.
1.5 QoS ?
La Qualité de Service (Quality of Service) est un accord entre l’expéditeur d’un message et le destinataire d’un message qui définit la garantie de livraison pour un message spécifique. On retrouvera ainsi 3 niveaux :
- QoS 0 : Au plus une fois (At most once), c’est la méthode la plus rapide et elle ne nécessite qu’un seul message. C’est aussi le mode de transfert le moins fiable. Le message n’est pas stocké du côté de l’expéditeur et n’est pas acquitté.
- QoS 1 : Au moins une fois (At least once), ce niveau garantit que le message sera livré au moins une fois, mais peut être livré plus d'une fois.
- QoS 2 : Exactement une fois (Exactly once), le message est toujours délivré exactement une fois. C’est la méthode la plus lente, car elle nécessite 4 messages (PUBLISH, PUBREC, PUBREL, PUBCOMP).
On notera que la qualité de service doit dépendre entièrement de la donnée, de sa sensibilité et de sa criticité.
1.6 Le module Qt MQTT
Présent depuis la version 5.10 de Qt, le module qtmqtt (https://github.com/qt/qtmqtt) propose une implémentation du protocole MQTT (dans les versions 3.1, 3.1.1 et 5 pour Qt 5.12), permettant ainsi aux habitués et utilisateurs du framework déjà riche en fonctionnalités, de pouvoir implémenter un nouveau protocole, et qui plus est, un standard dans le monde de l’(I)IoT.
Qt MQTT est disponible sous licence GPLv3 et/ou sous licence commerciale. C’est aussi un module à part entière de Qt for Automation (incluant aussi Qt OPCUA, Qt KNX, etc.).
C’est donc ce module que nous découvrirons dans la deuxième partie de l’article. Soyez patient !
2. Préparation de l’environnement
Afin d’intégrer au mieux les différentes parties relatives à l’utilisation de Qt sur notre plateforme à disposition, la fameuse WaRP7 (encore elle !), nous allons commencer notre étude par une introduction consacrée à la configuration de notre cible, c’est-à-dire :
- configuration de l’environnement Yocto/OpenEmbedded ;
- configuration de l’image ;
- génération de l’image avec les composants Qt5 nécessaire ;
- génération du SDK compatible Qt5.
2.1 Configuration de l’environnement
Il nous faudra en premier lieu rapatrier les sources du BSP (Board Support Package) de la communauté Freescale/NXP. Pour ce faire, rien de plus simple, l’utilitaire repo nous permet en quelques commandes de récupérer l’ensemble des sources nécessaires à la bonne construction d’une image pour notre cible visée :
$ mkdir ~/bin
$ curl http://commondatastorage.googleapis.com/git-repo-downloads/repo > ~/bin/repo
$ chmod a+x ~/bin/repo
$ PATH=${PATH}:~/bin
$ mkdir warp7_bsp
$ cd warp7_bsp
$ repo init -u https://github.com/Freescale/fsl-community-bsp-platform -b sumo
$ repo sync
Pour ce qui est de la version du BSP utilisée dans cet article, les auteurs auront choisi la dernière version stable, à savoir sumo, thud n’étant pas encore disponible sur le référentiel Freescale (le mainteneur préférant attendre la consolidation du support de l’i.MX8).
Ensuite, une fois les sources à disposition, il nous faudra aussi intégrer la couche de métadonnées pour une utilisation de Qt5 :
$ cd sources
$ git clone -b master https://github.com/meta-qt5/meta-qt5.git
$ cd ..
L’étape qui suit nous permettra la création de notre environnement. Afin d’avoir un ensemble cohérent, il conviendra de placer plusieurs variables lors de l’invocation de cette commande :
- MACHINE : imx7s-warp, qui représente notre plateforme cible ;
- DISTRO : poky, permettant de choisir la distribution de référence du projet Yocto (configuration disponible sous poky/meta-poky/conf/distro/poky.conf pour les curieux) ;
- setup-environment : c’est le script qui nous permettra de configurer notre environnement de base ;
- warp7-build : qui représente notre fichier de construction.
$ DISTRO=poky MACHINE=imx7s-warp source setup-environment warp7-build
Une fois cette commande lancée, nous nous retrouvons dans notre répertoire de travail où il faudra par exemple, rendre disponible la couche meta-qt5 au grand guru bitbake. Pour ce faire, il est recommandé d’utiliser la commande bitbake-layers. Celle-ci permet la gestion des couches au sein de notre environnement. Nous passerons en paramètre de cette commande, l'option add-layer qui permettra de mettre à jour le fichier conf/bblayers.conf, vous-êtes prêts ?
$ bitbake-layers add-layer ../sources/meta-qt5/
Voilà, nous disposons maintenant d’un environnement de construction prêt à être configuré pour nos besoins. Passons à la configuration de notre image.
2.2 Configuration de notre image
Dans cette sous-partie, nous allons préparer notre image système afin que celle-ci soit la plus complète et exhaustive possible pour notre étude. Pour ce faire, il conviendra dans un premier temps de créer une couche (« layer » au sens Yocto/OpenEmbedded) spécifique à notre intégration, ce qui nous permettra ainsi de stocker les différentes recettes et recettes dérivées utilisées lors de la personnalisation des différentes applications (systemd par exemple).
Rien de plus simple, il suffit d’utiliser la commande bitbake-layers avec comme argument create-layer et en paramètre, le nom de la couche à créer, qui dans notre cas aura le nom de meta-glmf :
$ bitbake-layers create-layer ../sources/meta-glmf
NOTE : Starting bitbake server...
L’exécution de la commande aura pour effet de créer notre « layer » dédiée à l’endroit spécifié. On notera la disparition de la commande yocto-layer depuis la version 2.5 (sumo) [2]. En effet, celle-ci faisait doublon avec la nouvelle implémentation de la sous-commande create-layer comme vu précédemment.
Enfin, il en sera de même que pour la meta-qt5, il nous faudra rendre celle-ci accessible dans notre configuration :
$ bitbake-layers add-layer ../sources/meta-glmf/
Maintenant fin prêts pour accueillir les différentes recettes, il nous est possible d’attaquer la phase de configuration. Commençons par remplacer SysVinit, l’init manager utilisé par défaut au sein du projet Yocto, par son « remplaçant » systemd. Il faudra rajouter quelques lignes dans le fichier de configuration principal (conf/local.conf) :
# Use systemd as init system
VIRTUAL-RUNTIME_init_manager = "systemd"
DISTRO_FEATURES_BACKFILL_CONSIDERED = "sysvinit"
VIRTUAL-RUNTIME_initscripts = ""
DISTRO_FEATURES_append = " systemd"
Puis, on ajoutera le support du WiFi au sein de la variable DISTRO_FEATURES. Dans notre cas, cette variable aura pour effet de rajouter les composants logiciels relatifs à l’utilisation du Wi-Fi en espace utilisateur, nous retrouverons ainsi les paquets iw et wpa-supplicant (packagegroup-base-wifi) :
DISTRO_FEATURES_append = " wifi"
Bien entendu, il est possible ici aussi de se servir de la variable IMAGE_INSTALL pour y insérer les 2 paquets précédents.
Bref, restons dans le périmètre du sans-fil (sans jeu de mots) avec la configuration de notre interface réseau disponible sur notre plateforme. Pour ce faire, les auteurs ont choisi d’utiliser systemd-networkd pour gérer cet aspect. Ceci permet entre autres de ne pas rajouter de gestionnaire supplémentaire à notre image finale (NetworkManager ou connman par exemple). De plus, dans la configuration actuelle de systemd, networkd y est activé par défaut (depuis la version 2.4 de poky -> rocko). Pourquoi donc s’en priver ?
Un des atouts principal du projet Yocto réside dans le fait de pouvoir « surcharger » une recette existante sans en modifier les sources originales. Dans notre cas de figure, il nous faudra par exemple inclure le fichier de configuration pour notre interface wlan0, fichier au format .network pour la gestion avec systemd. Il nous est ainsi fortement conseillé d’utiliser le mécanisme de dérivation propre à bitbake.
Afin de réaliser cette opération, il va nous falloir respecter avant toutes choses quelques règles :
- Garder la même arborescence que la recette originale (pas obligatoire, mais c’est une bonne pratique) : l’originale étant définie comme suit : poky/meta/recipes-core/systemd/. Dans notre environnement, ceci deviendra meta-glmf/recipes-core/systemd/ ;
- Créer un fichier permettant d’utiliser la même recette, mais avec nos différents ajouts. Celui-ci devra porter l’extension .bbappend pour être interprété par bitbake comme étant une recette dérivée de l’originale. Dans notre cas, la recette principale est définie sous le nom systemd_237.bb, dans notre « layer » elle devra donc être implémentée sous la forme systemd_%.bbappend. Le « % » permet de ne pas spécifier de version particulière ;
- La recette dérivée devra comporter la tâche do_install_append() afin de réaliser l’opération d’installation du fichier .network après l’invocation du do_install() propre à la recette originale.
Ainsi nous aurons dans notre couche meta-glmf, la structure suivante :
$ tree recipes-core/systemd
recipes-core/systemd
├── systemd
│ ├── warp7-wifi.network
└── systemd_%.bbappend
Avec le fichier .bbappend qui contiendra les directives suivantes :
FILESEXTRAPATHS_prepend := "${THISDIR}/${PN}:"
SRC_URI_append = " \
file://warp7-wifi.network \
"
do_install_append() {
install -m 0644 ${WORKDIR}/warp7-wifi.network ${D}${sysconfdir}/systemd/network/
}
Quelques explications :
- SRC_URI, permet de spécifier les fichiers contenus dans la recette, ici nous faisons référence à la configuration réseau de notre interface sans-fil ;
- FILESEXTRAPATHS_prepend, qui permet d’inclure notre nouveau répertoire dans l’algorithme de recherche, rendant ainsi notre fichier visible par bitbake.
Pour ce qui est de la gestion de wlan0, nous faisons le choix d’utiliser le mode DHCP pour une attribution automatique de l’adresse IP :
[Match]
Name=wlan0
[Network]
DHCP=yes
Et voilà, notre interface Wi-Fi est maintenant configurée... enfin presque : il faudra lors du démarrage de la carte, renseigner les informations relatives à l’accès du sans-fil dans le fichier wpa_supplicant.conf (mais vous savez sans aucun doute réaliser cette étape !).
Autre point à implémenter au sein de notre configuration, la gestion de notre driver Wi-Fi. Par défaut, celui-ci se trouve être lié de façon non statique à notre image noyau, donc sous forme de module. Implémentation qui nous impose l’étape manuelle du modprobe pour l’insertion.
Par chance, Yocto/OE permet d’éviter de retoucher à l’interface ncurses pour la reconfiguration de l’image noyau. En effet, il est possible via la variable KERNEL_MODULE_AUTOLOAD, de gérer comme son nom l’indique, le chargement automatique du module lors du démarrage de la carte. En fait, cette commande a pour effet de créer un fichier <nom_module>.conf dans /etc/modules-load.d/. Tout comme systemd, il nous faudra ici aussi, dériver la recette principale de notre noyau Linux pour insérer les lignes suivantes :
KERNEL_MODULE_AUTOLOAD_append = " brcmfmac"
Nous retrouverons ainsi l’arborescence suivante :
$ tree recipes-kernel
recipes-kernel
└── linux
└── linux-fslc_%.bbappend
Enfin, avant de pouvoir « cuisiner » notre image, il nous faut au préalable prévoir l’intégration de quelques applications, on retrouvera par exemple le module qtmqtt utile pour notre applicatif qui va suivre, ainsi que l’implémentation d’un serveur sftp (openssh-sftp-server) pour déployer notre application par le biais de Qt Creator. On retrouvera aussi le paquet mosquitto, Broker open source implémentant le protocole MQTT, celui-ci nous sera utile dans notre étude dans la mesure ou au moins une des cibles aura aussi le rôle de Broker. Il faudra pour ce faire, intégrer dans le fichier conf/local.conf, la ligne suivante :
CORE_IMAGE_EXTRA_INSTALL_append = " qtmqtt openssh-sftp-server mosquitto"
2.3 Génération de l’image
Vous l’avez sans doute compris, nous pouvons maintenant lancer la construction de notre image :
$ bitbake core-image-base
Loading cache: 100% |########################################################################################################################################################################| Time: 0:00:00
Loaded 2451 entries from dependency cache.
NOTE: Resolving any missing task queue dependencies
Build Configuration:
BB_VERSION = "1.38.0"
BUILD_SYS = "x86_64-linux"
NATIVELSBSTRING = "universal"
TARGET_SYS = "arm-poky-linux-gnueabi"
MACHINE = "imx7s-warp"
DISTRO = "poky"
DISTRO_VERSION = "2.5.1"
TUNE_FEATURES = "arm armv7ve vfp thumb neon callconvention-hard"
TARGET_FPU = "hard"
meta
meta-poky = "HEAD:45ef387cc54a0584807e05a952e1e4681ec4c664"
….
Quelques cafés plus tard, notre image résultante se trouvera dans tmp/deploy/images/imx7s-warp/core-image-base-imx7s-warp.wic.gz, fichier qu’il faudra décompresser et copier sur la cible (en utilisant la commande dd). Pour de plus amples informations quant à l’opération de flashage, le lecteur pourra se référer à la documentation en [3].
2.4 Génération du SDK
La génération du SDK (Software Development Kit) est en soi une tâche peu compliquée, car bien évidemment facilitée par l’utilisation du projet Yocto ! C’est donc pour cette raison que nous ferons ici un bref rappel sur cette génération et nous ne pourrons que conseiller le lecteur à se référer aux diverses publications parues dans GLMF [4].
2.4.1 Génération
Par habitude des auteurs, l’ensemble de l’applicatif en espace utilisateur se fera au travers du framework Qt, il nous faudra donc générer une chaîne de compilation croisée compatible. Pour ce faire :
$ bitbake meta-toolchain-qt5
Une fois la génération terminée et comme d’habitude, le résultat de la compilation se trouvera dans tmp/deploy/sdk.
2.4.2 Installation
Elle se fera tout simplement en exécutant le script suivant où il conviendra de spécifier le chemin d'installation (/opt/poky/2.5.1 dans cet exemple) :
$ cd tmp/deploy/sdk
$ ./poky-glibc-x86_64-meta-toolchain-qt5-armv7vehf-neon-toolchain-2.5.1.sh
Poky (Yocto Project Reference Distro) SDK installer version 2.5.1
=================================================================
Enter target directory for SDK (default: /opt/poky/2.5.1):
2.4.3 Vérification
Afin de vérifier notre environnement, il nous faudra réaliser quelques commandes.
« Sourcer » notre environnement, plaçons-nous dans un Shell, et utilisons la commande ci-après :
$ . /opt/poky/2.5.1/environment-setup-armv7vehf-neon-poky-linux-gnueabi
Ou :
$ source /opt/poky/2.5.1/environment-setup-armv7vehf-neon-poky-linux-gnueabi
Il est ensuite possible de vérifier la version de Qt utilisée :
$ qmake -v
QMake version 3.1
Using Qt version 5.11.2 in /opt/poky/2.5.1/sysroots/armv7vehf-neon-poky-linux-gnueabi/usr/lib
Pour la création du Kit avec Qt Creator, les auteurs laisseront au lecteur le soin de le mettre en place en s’aidant des diverses publications de GNU/Linux Magazine.
2.5 Boot to Qt ?
Jamais évoqué dans les colonnes de GNU/Linux Magazine, Boot2Qt est une pile logicielle légère, optimisée et complète pour les systèmes Linux embarqués développée par The Qt Company. Celle-ci repose sur l’utilisation du projet Yocto et de ses sous-ensembles (bitbake, poky…) afin d’être la plus optimisée possible pour une utilisation avec Qt. Un des autres avantages de cette solution est la mise à disposition de plateformes de référence, par exemple :
- Raspberry Pi ;
- Toradex Colibri iMX7 ;
- NXP i.MX7D SABRE ;
- NVIDIA Jetson TX1/TX2 ;
- Boundary Devices Nitrogen7 ;
- NXP i.MX 8QM !
- WaRP7 !!
- et bien d’autres encore (http://doc.qt.io/QtForDeviceCreation/qtee-custom-embedded-linux-image.html).
Mais en plus de cela, chaque plateforme dispose d’un ensemble pré-généré (image + SDK)... intéressant, non ?
3. Mise en situation : démonstrateur MQTT
Comme annoncé en début d’article, l’idée ici est de réaliser une petite étude permettant de montrer le potentiel du module Qt MQTT en environnement embarqué, avec comme pour habitude des auteurs, une utilisation du projet Yocto pour l’intégration logicielle (promis le prochain parlera aussi de Buildroot). L’application MQTT reposera sur l’utilisation de 2 WaRP7 (1 pour chaque auteur), chacune étant distante et sur des réseaux séparés. Enfin, une des 2 plateformes s’occupera de la partie Broker (mosquitto) en plus de la partie Publish. Le tout sera agrémenté d’une partie IHM (subscriber) pour l’affichage des différentes données en provenance des WaRP7. Ci-après (figure 3), le schéma de principe de l’étude proposée :
Fig. 3 : Environnement de test.
3.1 Gestion « publisher »
Le publisher sera sous la forme d’un service embarqué sur la cible (WaRP7 dans notre exemple). Pour la lecture des capteurs, nous reprendrons ce qui a été fait dans un article précédent [5]. Nous axerons ainsi l’article sur l’échange de données et de leur visualisation. Les auteurs ont décidé ici de récupérer les données de l’accéléromètre ainsi que celles du capteur de température intégré à la WaRP7.
À la création du projet, il faudra bien entendu penser à ajouter le module MQTT dans le fichier de projet QMake (le fameux .pro), pour ce faire, rien de bien compliqué :
QT += mqtt
Comme défini, nous retrouverons les Topics suivants :
- Warp7MQTT/nom/accelerometer/{x,y,z}
- Warp7MQTT/nom/temperature
Où nom est fonction des auteurs (Jean et Pierre-Jean).
Pour Pierre-Jean (par exemple), la définition s’effectuera de la manière suivante :
#define ROOT_TOPIC QString("Warp7MQTT/")
#define HOME_NAME QString("Pierre-Jean/")
const QString TOPIC_PATH_ACC_X = QString(ROOT_TOPIC + HOME_NAME + "accelerometer/X");
const QString TOPIC_PATH_ACC_Y = QString(ROOT_TOPIC + HOME_NAME + "accelerometer/Y");
const QString TOPIC_PATH_ACC_Z = QString(ROOT_TOPIC + HOME_NAME + "accelerometer/Z");
const QString TOPIC_PATH_TEMP = QString(ROOT_TOPIC + HOME_NAME + "temperature");
Le reste de l’initialisation s’effectuera de manière très simple :
- Utilisation de la classe QMqttClient pour la création du client MQTT ;
- Définition des différents paramètres de connexion au Broker (hostname et numéro de port) ;
- Création d’une connexion avec une fonction de type SLOT pour la gestion des notifications lors d’une connexion :
//initialisation du client mqtt
m_mqttClient = new QMqttClient(this); // Création du client
m_mqttClient->setHostname("imx7s.ddns.net"); //désignation du broker
m_mqttClient->setPort(1883); //numéro de port
connect(m_mqttClient, &QMqttClient::stateChanged, this, &CMqttClient::clientStateChanged); //connexion d'un signal à une fonction
m_mqttClient->connectToHost(); //connexion
À noter que dans notre exemple, la connexion au Broker s’effectue par le biais de la fonction connectToHost(). Pour les lecteurs voulant intégrer des notions de sécurité, il est possible et préférable de passer par la fonction connectToHostEncrypted() afin de mettre en place une connexion chiffrée de type SSL/TLS.
On crée ensuite le timer m_updateEventLoopTimer (par le biais de la classe QTimer) qui permettra de publier les mises à jour de façon régulière vers le Broker :
//initialisation de la boucle de mise à jour
m_updateEventLoopTimer = new QTimer();
connect(m_updateEventLoopTimer, &QTimer::timeout, this, &CMqttClient::updateBroker);
Celui-ci sera déclenché uniquement lors d’une connexion effective au Broker (état Connected), état géré dans la fonction SLOT clientStateChanged lors d’un signal émis par la fonction stateChanged :
void CMqttClient::clientStateChanged(QMqttClient::ClientState state)
{
switch (state) {
case QMqttClient::Disconnected:
qDebug() << "Client Disconnected";
break;
case QMqttClient::Connecting:
qDebug() << "Client Connecting";
break;
case QMqttClient::Connected:
qDebug() << "Client Connected";
m_updateEventLoopTimer->start(100);
break;
}
}
Sur le signal timeout du timer défini précédemment, la fonction updateBroker() sera appelée (temps défini par le paramètre passé en argument de la fonction start()). Cette fonction aura pour rôle de récupérer les données physiques des capteurs (fonction readData()), afin de les publier par la suite sur les différents Topics :
void CMqttClient::updateBroker()
{
//on met à jour les données lues sur les capteurs
m_sensors->readData();
//on envoie au broker
//qos = 0
//retain msg = true
m_mqttClient->publish(TOPIC_PATH_ACC_X, QByteArray::number(m_sensors->accelerometerX()), 0, true);
m_mqttClient->publish(TOPIC_PATH_ACC_Y, QByteArray::number(m_sensors->accelerometerY()), 0, true);
m_mqttClient->publish(TOPIC_PATH_ACC_Z, QByteArray::number(m_sensors->accelerometerZ()), 0, true);
m_mqttClient->publish(TOPIC_PATH_TEMP, QByteArray::number(m_sensors->temperature()), 0, true);
}
Pour nos besoins, on se contente d’une qualité de service faible et on demande au broker d’enregistrer la valeur du dernier message grâce au booléen « Retain Message ».
À partir de là, le client est prêt. Il ne reste plus qu’à le déployer sur les WaRP7 avec un HOME_NAME différent pour chacune (il aurait été certainement plus judicieux de passer une gestion automatique de cette variable, une gestion avec adresse MAC ou au travers un fichier de configuration. Mais nous ne sommes bien entendu pas ici sur une phase d’industrialisation du produit).
Le lecteur souhaitant tester son implémentation peut d’ores et déjà vérifier son bon fonctionnement avec les clients gratuits disponibles sur smartphones/tablettes.
3.2 Dessine-moi une interface
Nous allons ici présenter plusieurs modules du framework Qt permettant de visualiser et représenter des données. Nous aborderons notamment les modules QtChart et QtDataVisualization.
Pour commencer, on crée un projet
et on ajoute directement les lignes ci-après afin de créer un lien vers les différents modules essentiels à notre application :QT += quick charts mqtt
Définissons ensuite une classe d’interface (CSensorsInterface) pour le QML afin de lui permettre d’accéder aux données :
class CSensorsInterface : public QObject
{
Q_OBJECT
public:
CSensorsInterface();
~CSensorsInterface();
Cette classe se verra ensuite être implémentée dans le fichier source principal (main.cpp), pour ensuite l’exposer au QML afin que ce dernier puisse accéder aux propriétés et/ou fonctions exposées :
CSensorsInterface *sensorsData = new CSensorsInterface();
QQmlApplicationEngine engine;
//export des classes vers QML
engine.rootContext()->setContextProperty("Data", sensorsData);
Conformément à la documentation Qt relative au module Qt Chart :
« Note: Since Qt Creator 3.0 the project created with Qt Quick Application wizard based on Qt Quick 2 template uses QGuiApplication by default. As Qt Charts utilizes Qt Graphics View Framework for drawing, QApplication must be used. The project created with the wizard is usable with Qt Charts after the QGuiApplication is replaced with QApplication. »
Il nous est nécessaire de déclarer une QApplication pour le bon fonctionnement avec le module nous donnant accès aux divers éléments graphiques :
//QApplication nécessaire pour l'utilisation des QtCharts
QApplication app(argc, argv);
3.3 Give me your data
Il existe de multiples façons d’exposer une donnée au QML. Dans notre cas, on va chercher à exposer une liste de valeurs (accéléromètres et températures), chacune à associer à une clé (le sujet ou « Topic »). De cette manière, nous éviterons de créer une multitude de fonctions. Rendant ainsi le code source bien plus lisible.
L’instanciation du client MQTT se fait de la même manière. Une fois connecté, il suffit de souscrire aux différents Topics définis :
void CSensorsInterface::clientStateChanged(QMqttClient::ClientState state)
{
switch (state) {
case QMqttClient::Disconnected:
qDebug() << "Client Disconnected";
break;
case QMqttClient::Connecting:
qDebug() << "Client Connecting";
break;
case QMqttClient::Connected:
qDebug() << "Client Connected";
//souscription à la maison de Jean
subscribe("Warp7MQTT/Jean/#");
//souscription à la maison de Pierre-Jean
subscribe("Warp7MQTT/Pierre-Jean/#");
break;
}
}
On remarquera ici, l’utilisation du caractère # pour permettre l’accès à un certain niveau de hiérarchie. Ici nous souhaitons récupérer l’ensemble des données physiques en provenance des capteurs.
Quant à la fonction subscribe() permettant la gestion des différents abonnements, elle s’implémentera de la manière suivante :
void CSensorsInterface::subscribe(QString topic)
{
//création d'un topic
QMqttTopicFilter topicFilter;
topicFilter.setFilter(topic);
//souscription au topic
QMqttSubscription *sub = m_mqttClient->subscribe(topicFilter, 0);
if (sub)
{
//création de la connexion à une fonction pour les mises à jour
connect(sub, &QMqttSubscription::messageReceived, this, &CSensorsInterface::messageReceived);
}
else
qDebug() << "Impossible de souscrire au Topic "<< topic;
}
À partir de là, une fois que le client sera connecté et que l’on aura souscrit aux bons sujets, les données seront récupérées de façon régulière par le biais de l’appel à la fonction messageReceived(). L’idée est, comme précisé plus haut, de stocker les données dans une table de hachage (clé/valeur). La table se rempliera et/ou remplacera les valeurs pour une clé déjà existante.
Un seul souci dans cette réflexion : les types supportés par le QML sont limités et ne permettent notamment pas d’exposer une variable de type QHash par exemple.
Plusieurs possibilités pour contourner le problème. Nous allons en proposer une générique. En effet, le moteur QML a la capacité d’introduire des instances QObject à travers le système de méta-objets. Cela signifie que n’importe quel code QML peut accéder aux membres d’une instance d’une classe dérivée de QObject. Il nous faudra donc dériver notre classe CMqttHashDaata de QObject. De plus, afin d’exposer la fonction de récupération des données au sein de notre de table de hachage au QML, celle-ci devra être marquée de la macro Q_INVOKABLE lors de la déclaration.
Regardons ainsi de plus près la définition de notre fameuse classe pour la gestion de la table de hachage où on retrouvera les 2 fonctions pour l’insertion (couple clé/valeur) et récupération :
class CMqttHashData : public QObject
{
Q_OBJECT
public:
Q_INVOKABLE double readValue(QString key);
void insertValue(QString key, double value);
Pour la déclaration de notre table de hachage :
private:
QHash<QString, double> m_hashValues;
Et les fonctions seront aussi simples que leurs déclarations :
double CMqttHashData::readValue(QString key)
{
return m_hashValues.value(key);
}
void CMqttHashData::insertValue(QString key, double value)
{
m_hashValues.insert(key, value);
}
On spécifie ensuite l’objet dans notre classe CSensorsInterface typée en QObject* et on y expose notre propriété, membre de donnée de notre classe via la macro Q_PROPERTY :
//exposition au QML de la classe m_values
Q_PROPERTY(QObject* values MEMBER m_values NOTIFY dataChanged())
private:
QObject *m_values;
À savoir, pour une interopérabilité maximale avec le QML, toute propriété inscriptible doit être associée à un signal NOTIFY qui est émis chaque fois que la valeur de la propriété change. Dans notre exemple, le signal dataChanged() sera émis lors d’une modification de donnée en provenance du client MQTT, permettant ainsi une mise à jour automatique côté interface. Ce qui donne côté implémentation :
void CSensorsInterface::messageReceived(QMqttMessage msg)
{
(static_cast<CMqttHashData*>(m_values))->insertValue(msg.topic().name(), msg.payload().toDouble());
Q_EMIT dataChanged();
}
Tout ça pour pouvoir accéder aux différentes valeurs de notre table depuis le QML !
Côté interface, voici comment s’effectue la lecture des données :
Data.values.readValue("Warp7MQTT/Jean/temperature")
Explications :
- Data est le nom d’exposition de la classe CSensorsInterface dans le fichier main.cpp,
- values représente la propriété exposée de la variable membre m_values,
- et on passe en paramètre de la fonction readValue(), la clé qui représente tout simplement le Topic désiré.
3.4 Du QML à portée de tous
Afin de représenter ces données brutes, les auteurs ont décidé de mettre en avant des modules QML permettant des affichages de graphes en 2D ou 3D : QtChart et QtDataVisualization.
Il nous faut, dans un premier temps, ajouter les deux modules au projet :
QT += charts
QT += datavisualization
On commence par le fichier main.qml. Il sera défini par trois composants : un fichier pour l’affichage des températures (TemperaturesChart), un fichier pour l’accéléromètre en représentation 2D (AccelerometersChart) et un dernier fichier pour l’accéléromètre en représentation 3D (Accelerometer3D). Tout ceci géré à l’aide d’une SwipeView permettant de glisser d’un composant à l’autre (pages) :
//permet de "swiper" entre les menus
SwipeView {
id: view
anchors.fill: parent
currentIndex: 0 //index par défaut
TemperaturesChart {
}
AccelerometersChart {
}
Accelerometer3D {
}
}
//indicateur permettant d'identifier l'index en cours
PageIndicator {
id: indicator
count: view.count
currentIndex: view.currentIndex
anchors.bottom: view.bottom
anchors.horizontalCenter: parent.horizontalCenter
}
3.4.1 La température monte
On commence par le plus simple : l’affichage de la température sous forme d’histogramme. Il faut donc dans un premier temps importer le module QML QtCharts :
import QtCharts 2.0
On créé ensuite l’élément graphique de type ChartView qui servira de base pour les graphes que nous afficherons :
ChartView {
id: chart
title: "Les températures !"
antialiasing: true
Afin de réaliser un histogramme, il conviendra de créer un composant BarSeries, composant auquel on associera des données de type BarSet :
//définition de l'axe des ordonnées
//valeurs entre 0 et 55
//avec un affichage sous forme de légende de 10 valeurs
axisY : ValueAxis {
min: 0
max: 55
tickCount: 10
}
//définition de l'axe des abscisses
//On crée ici une catégorie de valeurs, il peut y en avoir plusieurs !
axisX: BarCategoryAxis {
categories: ["Température"]
}
//on crée les "sets" de données provenant de Jean et Pierre-Jean
BarSet {
label: "Jean";
values: [Data.values.readValue("Warp7MQTT/Jean/temperature")]
}
BarSet {
label: "Pierre-Jean";
values: [Data.values.readValue("Warp7MQTT/Pierre-Jean/temperature")]
}
Lorsqu’on crée un BarSet, les valeurs se présentent sous la forme [données Cat 1, données Cat 2, données Cat N] qui correspondent aux catégories créées dans le CatergoryAxis (légende). Le résultat est visible sur la figure 4.
Fig. 4 : Histogramme représentant les températures.
3.4.2 On accélère le rythme
On va essayer de créer un historique défilant sur un nombre maximum de données définies. Et ceci, avec les deux fois 3 axes provenant des accéléromètres des WaRP7 respectives des auteurs. Le concept est tout à fait similaire à l’exemple ci-dessus, il suffit de créer un ChartView et d’y intégrer des types de graphes : ici des SplineSeries, ce sont des courbes qui prennent en paramètres des points en faisant des interpolations non linéaires pour rendre les transitions plus fluides, allons-y !
On crée les objets SplineSeries :
SplineSeries {
id: lineSeriesXJean
name: "Jean X"
axisY: dataAxisY
axisX: dataAxisX
}
SplineSeries {
id: lineSeriesPierreXJean
name: "Pierre-Jean X"
axisY: dataAxisY
axisX: dataAxisX
}
Pour ensuite définir les axes :
ValueAxis {
id: dataAxisY
min: -30
max: 30
tickCount: 15
}
ValueAxis {
id: dataAxisX
titleText: "Toutes les 100 ms"
min: 0
max: maxValues
tickCount: 5
}
La mise à jour de l’historique est cependant un peu plus complexe puisqu’il va falloir décaler d’un vers la gauche toutes les données du graphe lorsqu’on atteint la valeur maximale de données définie. Pour éviter de faire toute forme de traitement dans le QML, on va créer une fonction générique qui gère ça côté C++ :
void CSensorsInterface::updateHistoric(QLineSeries *series, qint32 maxLength, double value)
{
//si longueur maximale de l'historique atteinte
if (series->count() == maxLength)
{
//on retire l'élément le plus ancien
series->remove(0);
//dans ce cas on décale
for (qint32 i = 0 ; i < maxLength - 1 ; ++i)
{
series->replace(i, series->at(i).x() - 1, series->at(i).y());
}
}
//ajoute la dernière valeur
series->append(series->count(), value);
}
Et on l’expose au QML en utilisant la macro Q_INVOKABLE :
/**
* @brief updateHistoric
* @param series
* @param maxLength
* @param value
*/
Q_INVOKABLE void updateHistoric(QLineSeries *series, qint32 maxLength, double value);
On termine enfin sur la mise à jour des données du côté QML :
Connections {
target: Data
onDataChanged : {
//z
Data.updateHistoric(lineSeriesZJean, maxValues, Data.values.readValue("Warp7MQTT/Jean/accelerometer/Z"))
Data.updateHistoric(lineSeriesPierreZJean, maxValues, Data.values.readValue("Warp7MQTT/Pierre-Jean/accelerometer/Z"))
//y
Data.updateHistoric(lineSeriesYJean, maxValues, Data.values.readValue("Warp7MQTT/Jean/accelerometer/Y"))
Data.updateHistoric(lineSeriesPierreYJean, maxValues, Data.values.readValue("Warp7MQTT/Pierre-Jean/accelerometer/Y"))
//x
Data.updateHistoric(lineSeriesXJean, maxValues, Data.values.readValue("Warp7MQTT/Jean/accelerometer/X"))
Data.updateHistoric(lineSeriesPierreXJean, maxValues, Data.values.readValue("Warp7MQTT/Pierre-Jean/accelerometer/X"))
}
}
Lors d’une connexion avec les signaux/slots, Qt ajoute systématiquement le préfixe on suivi du nom du signal (avec la première lettre en majuscule). Donc le signal dataChanged() deviendra onDataChanged() afin de définir la fonction slot.
La figure 5 présente le résultat de notre précédente implémentation.
Fig. 5 : De jolies courbes !
3.4.3 Le meilleur pour la fin
Un petit exemple en 3D pour sublimer l’article, voilà ce qu’il nous faut. On va s’intéresser au module QtDataVisualization. On importe très logiquement le module dans notre fichier QML :
import QtDataVisualization 1.2
Pour l’exemple, on va chercher à visualiser l’accélération des deux WaRP7. Pour cela, on va juste afficher deux formes circulaires et les faire bouger dans l’espace tridimensionnel ! L’objet Scatter3D est parfaitement adapté pour cette utilisation :
Scatter3D {
id: scatterGraph
width: dataView.width
height: dataView.height
shadowQuality: AbstractGraph3D.ShadowQualitySoftLow
scene.activeCamera.cameraPreset: Camera3D.CameraPresetIsometricRight
}
On crée les 3 axes avec les caractéristiques suivantes :
ValueAxis3D {
id: valueAxisX
min: -20
max: 20
}
Et on les associe au scatterGraph précédemment défini (champ id de Scatter3D) :
axisZ: valueAxisZ
axisY: valueAxisY
axisX: valueAxisX
Et le modèle de données du graphe (Scatter3DSeries) se présente sous cette forme :
//création de la source de donnée
Scatter3DSeries {
id: scatterSeries
//mode d'affichage du texte lors d'un clic sur un élément
itemLabelFormat: "(@xLabel, @yLabel, @zLabel)"
meshSmooth: true
//lien vers la donnée réelle
ItemModelScatterDataProxy {
itemModel: dataModel
xPosRole: "xPos"
yPosRole: "yPos"
zPosRole: "zPos"
}
}
Le dataModel peut être défini de nombreuses manières, ici nous avons décidé d’utiliser le composant ListModelde QML (un conteneur de ListElement) :
ListModel {
id: dataModel
//list pour Jean
ListElement{
xPos: 0;
yPos: 0;
zPos: 0;
}
//list pour Pierre-Jean
ListElement{
xPos: 0;
yPos: 0;
zPos: 0;
}
}
On remarque que chaque ListElement fait partie d’une liste de données. À l’index , on retrouve la donnée de Jean et à l’index 1 la donnée de Pierre-Jean. Mais du coup, comment remplir ces données ? En fait, il convient tout simplement de reprendre le modèle de mise à jour de l’exemple en 2D :
Connections {
target: Data
onDataChanged : {
//Jean
dataModel.set(0,
{
"xPos":Data.values.readValue("Warp7MQTT/Jean/accelerometer/X"),
"yPos":Data.values.readValue("Warp7MQTT/Jean/accelerometer/Y"),
"zPos":Data.values.readValue("Warp7MQTT/Jean/accelerometer/Z")
})
//Pierre-Jean
dataModel.set(1,
{
"xPos":Data.values.readValue("Warp7MQTT/Pierre-Jean/accelerometer/X"),
"yPos":Data.values.readValue("Warp7MQTT/Pierre-Jean/accelerometer/Y"),
"zPos":Data.values.readValue("Warp7MQTT/Pierre-Jean/accelerometer/Z")
})
}
}
Tout ceci en mettant à jour le dataModel et en accédant à ces éléments via la fonction set qui prendra en paramètre, l’index de l’élément.
Et pour le plaisir des yeux, voyez la figure 6...
Fig. 6 : Affichage 3D des données.
Il est possible de sélectionner une forme circulaire de l’espace 3D et voir le texte des coordonnées sous le format défini dans le Scatter3D. Vous pouvez aussi faire un clic droit sur la souris pour vous déplacer dans le graphe.
3.5 Intégration de l’application à notre image
Maintenant que notre application est fonctionnelle, il est intéressant de l’intégrer à l’image de façon à permettre un déploiement plus aisé (reproductibilité en environnement industriel par exemple). Ni une ni deux, éditons une recette permettant de réaliser cette opération (warpmqtt_git.bb) :
SUMMARY = "Qt App (MQTT demo for GLMF)"
...
SRC_URI = " \
git://github.com/Jeanrenet/WarpMQTTSensor1.git \
file://warpMQTT.service \
"
SRCREV = "${AUTOREV}"
S = "${WORKDIR}/git"
DEPENDS = " qtbase qtmqtt"
inherit qmake5 systemd
do_install_append() {
install -Dm 644 ${WORKDIR}/warpMQTT.service ${D}${systemd_unitdir}/system/warpMQTT.service
}
SYSTEMD_SERVICE_${PN} = "warpMQTT.service"
Explications :
- SRC_URI, spécifie les fichiers contenus dans la recette (référentiel git contenant les sources + le fichier unité afin de gérer notre application au démarrage) ;
- SRCREV, pour la révision (AUTOREV afin de spécifier la dernière version disponible sur le référentiel) ;
- DEPENDS = "qtbase qtmqtt", pour spécifier les dépendances du paquet, dans notre qtbase pour la partie core et qtmqtt pour l’accès au module mqtt ;
- inherit qmake5, permet de spécifier la version spécifique de notre qmake ;
- inherit systemd, afin de pouvoir installer notre fichier unité ;
- do_install_append(), aura pour effet de surcharger l’étape d’installation (défini par la variable INSTALLS dans le fichier .pro du projet Qt), ceci afin d’installer notre fichier unité ;
- SYSTEMD_SERVICE_{PN}, permet de renseigner le nom de notre fichier unité à intégrer. Celui-ci sera lancé par défaut au démarrage.
Le fichier unité qui permettra de configurer le comportement de notre application au démarrage, comprendra les différentes options suivantes :
[Unit]
Description=WarpMqttSensor1 daemon service
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/bin/WarpMqttSensor1
[Install]
WantedBy=multi-user.target
Explications :
- La section [unit], contient les différentes options génériques de notre unité :
- Description : texte qui sera affiché lors de l’exécution de la commande systemctl status warpMQTT ;
- After et Wants forment un couple afin de s’assurer du démarrage de l’application une fois la mise en service du réseau. Cela permet de garantir qu’une adresse IP a été attribuée à notre interface wlan0. Pour de plus amples informations sur cette fonctionnalité, l’explication dans la documentation officielle en [6].
- La section [service], permet de spécifier des directives spécifiques (Type, ExecStart, …), dans notre cas, seule la variable ExecStart sera renseignée avant de spécifier le type de commande à lancer lors de l’exécution de l’unité (notre application WarpMqttSensor1) ;
- La section [install], contient diverses informations concernant l’installation de l’unité via les commandes systemctl enable et systemctl disable. WantedBy permettra de spécifier les différents niveaux d’activations (Target (runlevels)).
Et voilà notre système est complet, il nous suffit maintenant de générer une nouvelle fois notre image (en incluant notre package warpmqtt) et de déployer celle-ci sur notre cible (avec Mender pourquoi pas).
Conclusion
À travers cet article, nous avons pu découvrir la mise en application du protocole MQTT en environnement Linux embarqué, jusqu’à la gestion de l’interface en QML via le module Qt Chart pour la représentation des données, le tout en passant par la génération de notre système embarqué (merci encore au projet Yocto !).
Tout ceci est encore une fois une belle démonstration de force du framework Qt5. En effet, celui-ci offre de plus en plus de possibilités pour l’utilisateur final, qui en fait incontestablement le numéro 1 dans le monde de l’embarqué (gestion du port série (ou même d’une Interface Bus CAN), gestion des protocoles industriels, gestion des IPC, et pleins d’autres encore).
Nous avons aussi vu que MQTT est un protocole pratique et efficace avec une gestion intéressante du QoS (très utile pour des données critiques), mais plus qu’un protocole, il est adopté par de nombreux développeurs, inclus dans de nombreuses API (comme AWS, Google Cloud Platform et Mender, comme le montre la figure 7). C’est tout ceci qui en fait un protocole différenciateur dans le monde de l’IoT.
Fig. 7 : Google Cloud Platform, Mender & MQTT.
Pour aller plus loin, il serait intéressant de découvrir et mettre en pratique la gestion de Remote UI avec Qt WebGL, ceci permettrait par exemple de rendre la partie client beaucoup plus légère et plus portable (gestion depuis le navigateur), car l’ensemble du contrôle serait implémenté sur la partie embarquée. Pour les curieux, une petite vidéo sur i.MX7 : https://www.youtube.com/watch?v=YY9rvos_I5w.
Références
[1] D. BODOR, « Faites communiquer vos projets simplement avec MQTT », Hackable Magazine n°26, septembre 2018 : https://connect.ed-diamond.com/Hackable/HK-026/Faites-communiquer-vos-projets-simplement-avec-MQTT
[2] Commit de la suppression de yocto-layer : http://git.yoctoproject.org/cgit.cgi/poky/commit/scripts?h=sumo&id=31684e868588121a4fcc6a966a509e8281ec9f9d
[3] Petit guide pour la gestion du flashage : https://github.com/WaRP7/WaRP7-User-Guide/blob/master/06-Chapter/Yocto_Project.adoc#steps-to-update-the-image
[4] P.-J. TEXIER et J. CHABRERIE, « À la découverte de la WaRP7 », Open Silicium n°20, octobre 2016 : https://connect.ed-diamond.com/Open-Silicium/OS-020/A-la-decouverte-de-la-WaRP7
[5] P.-J. TEXIER et J. CHABRERIE, « À l'assaut du sous-système noyau « Industrial I/O » ! (et du QML …!) », GNU/Linux Magazine n°215, mai 2018 : https://connect.ed-diamond.com/GNU-Linux-Magazine/GLMF-215/A-l-assaut-du-sous-systeme-noyau-Industrial-I-O-!-et-du-QML-!
[6] « Running Services after network is up » : https://www.freedesktop.org/wiki/Software/systemd/NetworkTarget/