Ou échanger des données entre deux microcontroleurs par l’intermédiaire d’un protocole “bas niveau” existant (SPI, I2C ou RS232 par exemple).

Comment échanger des données entre deux systèmes communicants ?

NIVEAU 3

Objectifs

  • Etablir un protocole de communication de haut niveau pour échanger des données numériques entre deux noeuds

Pré-requis

Différentes couches protocolaires

L’échange d’informations entre deux systèmes (PC-uC ou uC-uC) repose sur la mise en place d’un protocole de communication afin que les deux systèmes (ou plus) se comprennent.

Protocoles de bas niveau

Il existe une multitude de protocoles de communication de “bas niveau” permettant de transmettre des données numériques sur un support physique :

  • Réseaux de terrain : Ethernet, RS232, SPI, I2C…
  • Réseaux de communication : Ethernet, Logical Link Control (LLC), Media Access Control (MAC), Point-to-point protocol (PPP)…

Ces protocoles permettent de garantir le transfert d’une série de données numériques, qu’on appellera trame, en normalisant certains aspects de la communication (vitesse de transfert, nombre d’octets transmis par paquet, niveaux de tension…)

Protocoles de plus haut niveau

Les protocoles précédents ne s’occupent que des informations binaires à transmettre d’un noeud A à un noeud B et leur codage, ils n’ont pas à gérer le contenu de ces paquets.

Ces contenus proviennent de couches protocolaires plus hautes.

Or pour que deux systèmes (dans l’exemple précédent, deux responsables de services différents) veulent communiquer des informations utiles, ils doivent suivre des étapes hiérarchiques particulières et des outils de communication à leur portée.

Parmi les protocoles de plus haut niveau, on peut citer le protocole Internet (IP), utilisant les protocoles de plus bas-niveau Ethernet et MAC (par exemple) et faisant appel à des couches de plus haut niveau type TCP/UDP pour la transmission de paquets sur un réseau.

Mise en place d’un protocole de communication

Définition des besoins

Avant de se lancer dans la communication inter-systèmes, il est donc intéressant de faire le point sur :

  • le type de données à transmettre
  • la quantité de données à transmettre
  • la direction de ces données (unidirectionnel ou bidirectionnel)
  • la vitesse de transmission

En fonction de ces informations, on choisira le protocole de bas niveau en conséquence.

Il faudra ensuite définir l’ordre dans lequel on envoie les différentes données et s’il est nécessaire, selon la taille des données, d’ajouter des caractères spécifiques pour séparer les données.

Acquittement et code de protection

Il est également intéressant d’ajouter au protocole de haut niveau une méthode de vérification que l’information a bien été reçue et correctement par le destinataire.

Certains protocoles de bas niveau intègrent déjà ce type de fonctionnalité (Ethernet, CAN…).

Lorsqu’on veut intégrer un acquittement dans les couches applicatives, il est alors nécessaire d’utiliser une couche physique (niveau bas de protocole) qui permette un échange bidirectionnel des informations. Par la suite, lorsqu’on transmettra une trame de données, le destinataire devra, si l’information a bien été reçue intégralement (d’où l’ajout de caractères spécifiques en début et fin de trame par exemple), il renverra une donnée de validation.

Pour garantir une meilleure efficacité de la transmission, on peut également ajouter des données supplémentaires calculées à partir des données à transmettre selon un algorithme prédéfini. On appelle cela un code correcteur d’erreur (ou au moins ici de validation des données transmises). Ces données sont également transmises, ce qui ralentit la communication utile entre les deux noeuds. Le destinataire reproduit le même calcul que l’émetteur et vérifie que la donnée est ainsi correctement arrivée sans erreur de transmission. Un acquittement est alors possible si la donnée est valide.

Exemple 1 : Allumer la LED de la carte Nucléo depuis un script Python

Protocole de “haut niveau”

Dans cet exemple simple d’application, on souhaite transmettre deux caractères distincts du PC à la carte Nucléo afin d’allumer (caractère ‘a’) ou d’éteindre (caractère ‘e’) une LED placée sur la carte Nucléo. La carte Nucléo acquittera de la bonne réception par deux caractères différents : ‘o’ lorsque la LED sera allumée et ‘b’ lorsqu’elle sera éteinte.

Protocole de “bas niveau”

Pour cela, on utilisera le protocole RS232 (via la liaison USB – port série virtuel) pour transmettre les informations numériques. Sur l’ordinateur, on pourra utiliser dans un premier temps un logiciel de type “console série” qui permet de visualiser et de transmettre des caractères sur ce type de liaison. La réception des données sur la carte Nucléo se fera par l’intermédiaire d’une interruption sur la réception du port série.

Code de la carte Nucléo

#include "mbed.h"

DigitalOut led1(LED1);
UnbufferedSerial      my_pc(USBTX, USBRX);
char data;

void ISR_my_pc_reception(void);

int main()
{    
    my_pc.baud(115200);
    my_pc.attach(&ISR_my_pc_reception, UnbufferedSerial::RxIrq);

    while (true){}
}

void ISR_my_pc_reception(void){
    my_pc.read(&data, 1);     // get the received byte
    if(data == 'a'){    led1 = 1;   } 
    if(data == 'e'){    led1 = 0;   }    
    my_pc.write(&data, 1);    // echo of the byte received
}

Programme en Python pour la commande de ce système

On peut remplacer le logiciel “console série” par une application faite de toute pièce permettant de récupérer les données sur la liaison série. L’intérêt est de pouvoir gérer au mieux les échanges et en particulier les acquittements et les erreurs de transmission possible.

On propose ici un script en Python 3.9 utilisant les bibliothèques pyserial pour la communication RS232.

from serial import Serial
import serial.tools.list_ports

if __name__ == "__main__":
    ports = serial.tools.list_ports.comports()
    # To obtain the list of the communication ports
    for port, desc, hwid in sorted(ports):
        print("{}: {}".format(port, desc))
    # To select the port to use
    selectPort = input("Select a COM port : ")    
    print(f"Port Selected : COM{selectPort}")
    # To open the serial communication at a specific baudrate
    serNuc = Serial('COM'+str(selectPort), 115200)  # Under Windows only

    appOk = 1

    while appOk:
        data_to_send = input("Char to send : ") 
        if data_to_send == 'q' or data_to_send == 'Q':
            appOk = 0
        else:
            serNuc.write(bytes(data_to_send, 'ascii'))
            while serNuc.inWaiting() == 0:
                pass
            data_rec = serNuc.read(1)  # bytes
            print(str(data_rec))
    
    serNuc.close()

Ce code permet :

  • de lister les ports de communication utilisables par la fonction serial_ports qui se base sur la bibliothèque pyserial ;
  • de demander à l’utilisateur le numéro du port à utiliser (fonction input et résultat stocké dans la variable selectPort) ;
  • d’ouvrir la communication sur le port sélectionné à la vitesse de 115200 bauds (objet de la classe Serial) ;
  • en boucle, jusqu’à ce que l’utilisateur saisisse la lettre ‘q’ ou ‘Q’ :
    • de demander à l’utilisateur un caractère (fonction input) ;
    • de transmettre ce caractère sur la liaison série (fonction write) si ce caractère n’est pas ‘q’ ou ‘Q’ ;
    • d’attendre le retour d’un caractère par la fonction inWaiting ;
    • d’afficher le caractère retourné (fonctions read et print).

On peut remarquer l’utilisation de la fonction str pour convertir les données reçues sous forme de bytes (octets) vers une chaine de caractères.

Exemple 2 : Application PyQt pour piloter la LED

Pour cet exemple, on repartira du code de l’exemple 1 du côté Nucléo. L’idée est de réaliser une interface graphique capable de reproduire le script Python proposé dans l’exemple 1.

Interface graphique sous Designer

Pour simplifier la création de l’interface graphique, il est possible d’utiliser QT Designer. Cette application permet de placer divers éléments graphiques dans une fenêtre et de générer un fichier réutilisable dans Python. L’interface réalisée est par exemple la suivante :

On trouve dans cette interface : une liste (QComboBox) pour le choix du port de communication, 4 boutons (QButton) cliquables liés à des actions différentes et une zone de texte (QLabel). Le fichier contenant cette interface est disponible à l’adresse suivante : https://github.com/jvillemejane/SupOpToolboxes/blob/main/embedded/python_interface/Python_to_Nucleo_ihm_v1.ui

Interactions en Python

Cette première phase permet de mettre en place uniquement l’affichage graphique. Il manque à coder les interactions entre les différents éléments. Pour cela, nous allons écrire un script Python, incluant la fenêtre graphique, et faire appel aux différents éléments de cette fenêtre.

Vous trouverez l’intégralité du code détaillé ci-dessous à l’adresse suivante : https://github.com/jvillemejane/SupOpToolboxes/blob/main/embedded/python_interface/python_to_Nucleo_via_Serial_v1.py

Bibliothèques nécessaires

Cette application s’appuie sur PyQt5, ainsi que sur les éléments déjà vus de la bibliothèque PySerial.

from serial import Serial
import serial.tools.list_ports

import sys
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtWidgets import (
    QApplication, QDialog, QMainWindow, QMessageBox, QWidget
)
from PyQt5.uic import loadUi

Classe contenant l’interface graphique

Afin de rendre le code plus lisible et plus facilement exploitable, nous créons une classe (MyWindow) pour l’objet graphique. Cette classe hérite de la classe QMainWindow qui est le conteneur de base pour une application avec PyQt.
Le terme “héritage” (en programmation orientée objet) signifie que les objets de la classe MyWindow (classe fille) sont des objets de la classe QMainWindow (classe mère) auxquels on ajoute des attributs et des méthodes supplémentaires spécifiques. L’ensemble des méthodes et des attributs de QMainWindow restent également accessibles pour les objets de type MyWindow.

class MyWindow(QMainWindow):

    def __init__(self, parent=None):
        super().__init__(parent)
        loadUi("Python_to_Nucleo_ihm_v1.ui", self)
        # To obtain the list of the communication ports
        self.ports = serial.tools.list_ports.comports()
        self.ports.sort()
        # To clear the list on the window
        self.comPortList.clear()
        # To add all the communication ports to the list
        for port, desc, hwid in sorted(self.ports):
            self.comPortList.addItem(f"{port} [{desc}]")
        # To link click to actions
        self.connectBt.clicked.connect(self.connectToNucleo)
        self.appQuitBt.clicked.connect(self.quitApp)
        self.ledOnBt.clicked.connect(self.switchOnLed)
        self.ledOffBt.clicked.connect(self.switchOffLed)
        # To create an empty serial connection
        self.serNuc = Serial()
        self.connected = 0
    def connectToNucleo(self):
        if(self.connected == 0):
            self.selectPort = self.ports[self.comPortList.currentIndex()].name
            self.serNuc = Serial(self.selectPort, 115200)
            self.connected = 1
            self.connectBt.setEnabled(False)
            self.ledOnBt.setEnabled(True)
            self.ledOffBt.setEnabled(True)
            self.lenseLabel.setText(f"Connected to {self.selectPort}")
            
    def quitApp(self):
        self.serNuc.close()
        self.close()
        
    def switchOnLed(self):
        if(self.connected):
            self.serNuc.write(bytes('a','utf-8'))
            while self.serNuc.inWaiting() != 0:
                pass
            self.data = self.serNuc.read(1)
            self.lenseLabel.setText(f"Switch On Led ({self.data})")

    def switchOffLed(self):
        if(self.connected):
            self.serNuc.write(bytes('e','utf-8'))
            while self.serNuc.inWaiting() != 0:
                pass
            self.data = self.serNuc.read(1)
            self.lenseLabel.setText(f"Switch Off Led ({self.data})")


if __name__ == "__main__":
    app = QApplication(sys.argv)
    
    win = MyWindow()
    win.show()
    sys.exit(app.exec())

Exemple 3 : Python, Thread et Interruptions

Le problème des applications précédentes vient de l’aspect bloquant de la fonction inWaiting pour l’attente de données provenant de la liaison série. Pour résoudre ce soucis et parce que les interruptions n’existent pas sur les machines sur lesquelles tournent Python, il faut passer par l’utilisation de processus “parallèles”, les Thread.

Code côté Nucléo

#include "mbed.h"

DigitalOut      led1(LED1);
InterruptIn     bpToSend(BUTTON1);
UnbufferedSerial      my_pc(USBTX, USBRX);
char    message[128];
char    data_to_send[5] = {0x10, 0x20, 0x35, 0x65, 0x50};
char    data;

void ISR_my_pc_reception(void);
void ISR_my_pc_transmit(void);

int main()
{    
    my_pc.baud(115200);
    my_pc.attach(&ISR_my_pc_reception, UnbufferedSerial::RxIrq);
    bpToSend.fall(&ISR_my_pc_transmit);

    while (true)
    {}
}

void ISR_my_pc_reception(void){
    my_pc.read(&data, 1);
    if(data == 'a'){    led1 = 1;   } 
    if(data == 'e'){    led1 = 0;   }    
    my_pc.write(&data, 1);
}

void ISR_my_pc_transmit(void){
    my_pc.write(data_to_send, 5);
}

Code côté Python

A venir…

MInE Prototyper Prototyper avec Nucleo et MBED

Nucléo / Echanger des données entre un PC et un uC