Python figure depuis plusieurs années parmi les langages de programmation les plus utilisés. C'est probablement dû notamment à sa remarquable portabilité entre différentes plateformes, y compris sur des mini-PC comme les Raspberry Pi. Il est même possible d'utiliser Python pour faire des applications avec une véritable interface graphique, mais cela recèle un certain nombre de pièges, que nous allons vous aider à éviter...
Le but de ce projet est d'écrire une application en Python avec une interface graphique, et qui puisse interagir avec des modules Yoctopuce.
Pour cet exemple, nous avons choisi la librairie PyQt6, développée par Riverbank Computing, qui permet de faire des application graphiques portables d'une plateforme à l'autre. Comme les outils Python et Qt sont à l'origine développés sur Unix et y sont donc plus facilement portables, nous avons intentionnellement fait le développement sous Windows, afin de nous assurer que la solution soit directement utilisable sur cette plateforme également. Attention, PyQt6 peut être utilisé gratuitement sous licence GPLv3 pour une application elle-même distribuée avec une license GPLv3, mais si vous faites une application commerciale, vous devrez acheter une licence commerciale à Riverbank Computing.
Premier piège: ne confondez pas PyQt et PySide, qui est un fork de PyQt avec licence LGPL, mais qui n'est plus exactement équivalent depuis quelques années. De même, si vous réutilisez du code trouvé sur le web, assurez-vous que ce code concerne bien PyQt6, car il existe beaucoup d'exemples encore basés sur PyQt4 qui ne sont pas toujours utilisables avec PyQt6.
Installation
L'installation de PyQt6 se fait très facilement, même sous Windows. Vous devez bien entendu avoir installé Python d'abord - nous avons utilisé la version actuelle, Python 3.10 - et l'outil pip. Il suffit alors d'ouvrir une fenêtre de commande avec les droits administrateur et de lancer:
Deuxième piège: sous Windows, si vous installez pyqt6 sans avoir les droits administrateurs, l'installation échouera avec un message d'erreur à propos du fichier pylupdate6.exe. Pour la même raison, l'installation de PyQt6 directement via le panneau de configuration d'un IDE comme PyCharm a toutes les chances d'échouer. Faites donc l'installation dans une fenêtre de commande avec les droits administrateurs, et l'IDE retrouvera ensuite le package tout seul si vous le pointez vers la version de Python sur laquelle vous avez installé le package.
Si vous n'avez pas suivi ce conseil et lancé une installation sans les droits administrateurs, il faudra d'abord annuler cette installation partielle avec les commandes suivantes:
pip uninstall PyQt6-Qt6
pip uninstall PyQt6-sip
et seulement ensuite recommencer avec l'installation dans une fenêtre de commande avec les droits d'administrateur.
Structure de l'application
Lorsqu'une application dotée d'une interface graphique doit communiquer avec l'extérieur, par exemple avec des modules Yoctopuce, il faut impérativement séparer la tâche de communication et la tâche qui gère l'interface utilisateur. Ce n'est pas tout simple à mettre en place, mais c'est vraiment utile. C'est même la raison principale pour laquelle nous avons choisi d'écrire cet article :-)
Séparer l'interface et la tâche de communication exige de mettre en place un mécanisme de communication entre les deux tâches. En Qt, cela se fait à l'aide de signaux, qui permettent de transférer des données d'une tâche à l'autre de manière asynchrone. L'idée consistera donc à créer une tâche de communication, avec des signaux associés lui permettant de communiquer avec le reste de l'application, comme ceci:
Structure correcte d'une application graphique
Avec PyQt6, le code correspondant ressemble à ceci:
startTask = pyqtSignal() # in: start the task
stopTask = pyqtSignal() # in: stop the task
toggleRelay = pyqtSignal(str) # in: toggle a relay
statusMsg = pyqtSignal(str) # out: publish the task status
newValue = pyqtSignal(str,str) # out: publish a new function value
def __init__(self):
super(YoctopuceTask, self).__init__()
# connect incoming signals
self.startTask.connect(self.initAPI)
self.toggleRelay.connect(self.toggleIt)
self.stopTask.connect(self.freeAPI)
@pyqtSlot()
def initAPI(self):
errmsg = YRefParam()
YAPI.RegisterLogFunction(self.logfun)
# Setup the API to use Yoctopuce devices on localhost
if YAPI.RegisterHub('127.0.0.1', errmsg) != YAPI.SUCCESS:
self.logMessage.emit('Failed to init Yoctopuce API: '+
errmsg.value)
return
# more init code as needed...
self.statusMsg.emit('Yoctopuce task ready')
La tâche principale ouvre l'interface, et lance la tâche de communication dans un thread dédié:
def __init__(self):
super().__init__()
self.setWindowTitle('Sample PyQT6 application')
# ... add interface widgets
self.show()
def startIO(self):
# Start Yoctopuce I/O task in a separate thread
self.yoctoThread = QThread()
self.yoctoThread.start()
self.yoctoTask = YoctopuceTask()
self.yoctoTask.moveToThread(self.yoctoThread)
self.yoctoTask.statusMsg.connect(self.showMsg)
self.yoctoTask.newValue.connect(self.newValue)
self.yoctoTask.startSignal.emit()
Notez que les signaux peuvent être utilisés comme entrée ou comme sortie: c'est à la tâche qui veut recevoir un signal de le connecter à l'une de ces méthodes. Il est recommandé de décorer cette méthode par @pyqtSlot(): cela permet d'une part de spécifier le type des arguments, et cela accélère l'exécution des appels.
Troisème piège: les signaux Qt ne fonctionnent que lorsqu'ils sont déclarés comme des attributs d'une classe descendant de QObject. Il faut donc impérativement faire hériter cette classe de QObject, comme nous l'avons fait pour la classe YoctopuceTask.
Il pourrait être tentant de faire hériter la tâche directement de QThread, qui est elle-même une sous-classe de QObject, pour éviter de créer séparément un objet thread puis de l'affecter à la tâche. Mais attention, c'est à nouveau un piège:
Quatrième piège: seule la méthode run() des objets QThread est implicitement lancée dans le thread dédié. Toutes les autres méthodes, y compris les méthodes appelées par des signaux, sont lancées contre toute attente dans le thread de l'appelant. Il n'est donc généralement pas utile de dériver une classe de QThread, et il est en tout cas impératif d'appeler la méthode moveToThread pour s'assurer que les méthodes liées aux signaux soient exécutées dans le thread désiré.
Gestion du plug-and-play et des callbacks Yoctopuce
Les deux tâches indépendantes que nous avons créées permettent à l'interface graphique de cohabiter sans encombre avec la tâche qui gère les modules Yoctopuce.
Pour exploiter pleinement les possibilités de la librairie Yoctopuce, comme la détection des modules branchés en cours d'application et la transmission des mesures par callback, il faut encore ajouter à la tâche Yoctopuce un appel périodique aux méthodes YAPI.UpdateDeviceList() et YAPI.HandleEvents() (ou YAPI.Sleep()).
On pourrait donc être tenté d'ajouter les lignes suivantes à la méthode initAPI:
YAPI.UpdateDeviceList()
YAPI.Sleep(500)
Mais c'est une mauvaise idée: il faut réaliser que la communication asynchrone entre les tâches fonctionne uniquement grâce au fait que tant la tâche d'interface que la tâche Yoctopuce passent l'essentiel de leur temps dans la boucle de gestion des événements de Qt:
Les deux boucles de gestion des événements
Cinquième piège: si l'une des méthodes appelées par un signal reste dans une boucle sans fin, la boucle de gestion des événements de ce thread ne sera plus jamais appelée et aucun autre signal entrant ne sera traité.
La solution consiste donc à créer, lors de l'initialisation de la tâche Yoctopuce, un QTimer, qui appellera les méthodes de gestion des événements Yoctopuce à intervalle régulier:
self.checkDevices = 0
self.timer = QTimer()
self.timer.timeout.connect(self.handleEvents)
self.timer.start(50) # every 50ms
Le code appelé périodiquement se chargera d'invoquer deux fois par seconde YAPI.UpdateDeviceList, et le reste du temps simplement YAPI.HandleEvents:
def handleEvents(self):
errmsg = YRefParam()
if self.checkDevices <= 0:
YAPI.UpdateDeviceList(errmsg)
self.checkDevices = 10
else:
self.checkDevices -= 1
YAPI.HandleEvents()
Voilà qui termine le gros-œuvre nécessaire à la pleine utilisation de la librairie Yoctopuce dans une application PyQt6. Il vous restera à ajouter les signaux correspondant à vos besoin spécifiques pour remonter des informations depuis la tâche Yoctopuce vers la tâche d'interface, et inversément les signaux pour piloter les actuateurs partant depuis la tâche d'interface.
Le piège ultime
Comme si tout cela était trop simple, les développeurs de PyQt nous ont tendu un dernier piège. Depuis PyQt v5.5, les exceptions inattendues qui se produisent dans le code Python ne sont plus propagées par la méthode standard. A la place, elles appellent simplement la fonction Qt qFatal(). Dans la plupart des IDE comme PyCharm, cela ne déclenche pas le debugger, ni ne produit le moindre message d'erreur dans la console: l'application se termine simplement en retournant un code d'erreur, sans le moindre indice.
Sixième piège: Au minimum durant le phase de développement, il faut impérativement ajouter les ligne suivantes au début de votre programme pour permettre de tracer les exceptions inattendues:
sys.__excepthook__(cls, exception, traceback)
sys.excepthook = except_hook
Pour ne rien vous cacher, nous avons perdu pas mal de temps à cause de ce piège-ci avant comprendre le problème...
Un exemple complet
Pour ne pas vous laisser avec des fragments de code incomplets, nous avons ajouté dans la librairie Python un exemple simple mais complet démontrant ces principes. Vous le trouverez dans le répertoire Prog-PyQt6.
Afin de ne pas compliquer le code, nous avons utilisé une interface très primitive où les widgets sont créés directement en Python. Mais si vous voulez faire une application plus complexe, sachez que vous pouvez concevoir votre interface avec l'outil graphique QT Designer et ensuite la traduire automatiquement en code Python.
Voici le résultat que vous devriez obtenir en lançant cet exemple, si vous avez bien suivi l'étape d'installation de PyQt6:
Un exemple d'application graphique Yoctopuce avec PyQt6
Conclusion
On aurait pu espérer que le chemin pour créer une application Yoctopuce doté d'interface graphique PyQt6 soit un peu moins semé d'embuches, mais au moins vous avez les recettes pour y arriver. Et à la décharge de Python et de PyQt6, le problème du découplage entre les tâches de communication et les interfaces graphiques est universel: il y a presque autant de solutions que de systèmes de développement. A notre connaissance, aucun système n'est à la fois optimal pour les performances et très simple à l'utilisation. Au final, c'est au développeur de trouver la solution qui lui convient le mieux...