Salut à tous,
Comme l'usage de Python se répand à l'école, nombre d'entre vous cherchent à connecter leur Arduino à leur Mac ou PC en utilisant Python.
Utiliser le port série de votre ordinateur avec Python n'est pas compliqué, mais le faire de manière robuste est un peu plus difficile si l'on veut gérer le fonctionnement asynchrone inhérent aux liaisons séries.
Ce "pseudo" tuto a pour but de vous donner un code minimal côté python et côté Arduino pour gérer la communication sous forme de commandes ASCII. une fois cette communication maîtrisée, rien ne vous empêchera de modifier le code pour faire de la communication en binaire si vous le souhaitez (avec un protocole un peu robuste).
Pour ceux qui ne connaissent pas la notion de communication sur voie série avec Arduino (ou de gérer un flux asynchrone en général) vous pouvez jeter un oeil à mon autre tuto sur le sujet. Ça vous donnera des bases.
Un point important à connaître avec les Arduino comme les UNO ou MEGA ou Nano, c'est que par défaut ils font un reset logiciel (reboot) lorsque l'on ouvre la communication série.
Arduino a prévu cela pour que l'on puisse facilement télécharger du code (l'IDE ouvre la voie série, la carte reboote, le bootloader prend la main pendant quelques temps et regarde si on lui envoie un code à installer en mémoire. Si oui, il reçoit le code et le copie en mémoire flash puis reboote, si non il arrête d'écouter et lance le code existant sur l'Arduino).
Cela a pour conséquence que si vous branchez votre arduino dejà équipé de votre programme au port USB de votre ordinateur il va démarrer et commencer à s'exécuter puis au moment où vous lancerez le code Python qui va ouvrir la voie série votre Arduino va redémarrer.
En conséquence, si votre code python envoie une information à votre programme Arduino immédiatement (quelques nano secondes, votre ordinateur est rapide) après l'ouverture de la voie série, l'Arduino sera toujours en train de booter et Serial
ne sera pas encore activé ce qui fait que l'Arduino va rater la communication.
Pour pallier cela vous pouvez faire une modification physique au niveau de l'Arduino (par exemple en rajoutant un condensateur de 10 μF capacitor entre GND et RESET) ou plus définitif changer pour une plus petite résistance de pullup sur le reset ou alors couper la piste utilisée par CTS pour déclencher le reset).
Ces modifications physiques peuvent être utiles dans certains cas et sont donc à connaître mais dans la majorité des projets, ce n'est pas plus mal que l'Arduino démarre après le démarrage de l'application sur le PC comme cela vous savez exactement où vous en êtes.
Comme on ne sait pas combien de temps ça va prendre pour que l'Arduino démarre, le plus simple est de rajouter à la fin du setup() l'envoi d'un message depuis l'Arduino vers python pour lui dire "OK je suis prêt" et côté python on se met en attente de ce message avant de démarrer.
Le chronogramme est donc le suivant :
1/ La première question qui se pose c'est comment accéder le port série en python.
Pour cela il faut bien sûr avoir la bibliothèque Python. On va utiliser PySerial qui nécessite Python 2.7 ou Python 3.4 (ou plus) et au moins Windows 7 si vous êtes sur un PC avec Windows.
l'installation se fait par exemple en tapant dans un terminal
sur Mac par exemple sudo pip install pyserial
depuis python : python -m pip install pyserial
depuis conda : conda install pyserial
Si vous avez une vieille version vous devrez peut-être faire un
python3 -m pip install --upgrade pip
(➜ voir la doc)
2/ La seconde question c'est comment choisir le bon port série dans le programme python afin de l'ouvrir.
Pour cela on va importer la bibliothèque série et lui demander de lister les ports accessibles
si vous tapez ceci à la ligne de commande dans Python3
import serial
import serial.tools.list_ports
ports = serial.tools.list_ports.comports()
print(ports)
vous verrez une liste apparaître. Par exemple avec un UNO, un ESP32 et un MKR1000 branchés sur mon Mac, ça donne cela:
[
<serial.tools.list_ports_common.ListPortInfo object at 0x1069920f0>,
<serial.tools.list_ports_common.ListPortInfo object at 0x106992160>,
<serial.tools.list_ports_common.ListPortInfo object at 0x1069921d0>,
<serial.tools.list_ports_common.ListPortInfo object at 0x1069922e8>,
<serial.tools.list_ports_common.ListPortInfo object at 0x106992358>,
<serial.tools.list_ports_common.ListPortInfo object at 0x106992668>
]
pas très parlant vous me direz, mais ça veut dire que l'ordinateur a détecté 6 interfaces série et rempli un tableau avec des objets représentant ces interfaces (cf la doc sur les informations disponibles)
On peut donc faire une petite boucle pour extraire des informations.
for index,value in enumerate(sorted(ports)):
print(index, '\t', value.name, '\t', value.manufacturer)
J'obtiens
PORT DEVICE MANUFACTURER
0 cu.BLTH None
1 cu.Bluetooth-Incoming-Port None
2 cu.ESP32test None
3 cu.usbmodem14101 Arduino LLC
4 cu.usbmodem14201 Arduino (www.arduino.cc)
5 cu.usbserial-0001 Silicon Labs
On pourrait par exemple bâtir une liste en virant les "MANUFACTURER" inconnus
choices = []
print('PORT\tDEVICE\t\t\tMANUFACTURER')
for index,value in enumerate(sorted(ports)):
if (value.hwid != 'n/a'):
choices.append(index)
print(index, '\t', value.name, '\t', value.manufacturer)
➜
PORT DEVICE MANUFACTURER
3 cu.usbmodem14101 Arduino LLC
4 cu.usbmodem14201 Arduino (www.arduino.cc)
5 cu.usbserial-0001 Silicon Labs
et proposer à l'utilisateur de choisir un des ports connus
choice = -1
while choice not in choices:
answer = input("➜ Select your port: ")
if answer.isnumeric() and int(answer) <= int(max(choices)):
choice = int(answer)
print('selecting: ', ports[choice].device)
alors ça ne nous dit pas exactement quel est l'arduino connecté mais s'il n'y en a qu'un seul ce sera simple
il suffit de mettre cela dans une fonction et on a donc un moyen de choisir un des ports séries
def selectArduino():
ports = serial.tools.list_ports.comports()
choices = []
print('PORT\tDEVICE\t\t\tMANUFACTURER')
for index,value in enumerate(sorted(ports)):
if (value.hwid != 'n/a'):
choices.append(index)
print(index, '\t', value.name, '\t', value.manufacturer) # https://pyserial.readthedocs.io/en/latest/tools.html#serial.tools.list_ports.ListPortInfo
choice = -1
while choice not in choices:
answer = input("➜ Select your port: ")
if answer.isnumeric() and int(answer) <= int(max(choices)):
choice = int(answer)
print('selecting: ', ports[choice].device)
return ports[choice].device
et ensuite on fait une fonction de configuration
baudRate = 115200
def selectArduino():
ports = serial.tools.list_ports.comports()
choices = []
print('PORT\tDEVICE\t\t\tMANUFACTURER')
for index,value in enumerate(sorted(ports)):
if (value.hwid != 'n/a'):
choices.append(index)
print(index, '\t', value.name, '\t', value.manufacturer) # https://pyserial.readthedocs.io/en/latest/tools.html#serial.tools.list_ports.ListPortInfo
choice = -1
while choice not in choices:
answer = input("➜ Select your port: ")
if answer.isnumeric() and int(answer) <= int(max(choices)):
choice = int(answer)
print('selecting: ', ports[choice].device)
return ports[choice].device
def configureArduino():
global arduinoPort
arduinoPort = selectArduino()
global arduino
arduino = serial.Serial(arduinoPort, baudrate=baudRate, timeout=.1)
# ---- CODE PRINCIPAL -----
configureArduino()
3/ OK, donc maintenant on sait que l'Arduino et l'ordinateur sont connectés par cette voie série, comment va-t-on pouvoir discuter ?
Comme votre programme python fonctionne sur un ordinateur puissant et multitâche, le plus efficace c'est d'écouter le port série dans une tâche séparée et d'enregistrer les messages qui arrivent depuis l'arduino dans une file d'attente (une queue python).
La tâche de fond reçoit les caractères qui arrivent sur la voie série un par un et les ajoute dans un buffer et quand le message est terminé, il est rajouté dans la file d'attente.
Comment sait-on que le message est terminé ? Si vous avez lu mon tuto sur le port série, vous savez que c'est bien si les commandes / messages ont un marqueur de fin ➜ Ici on va utiliser la fin de ligne '\n' comme marque de fin comme cela depuis l'Arduino il suffira de faire un println()
pour envoyer une commande.
La tâche python principale se contentera donc de regarder s'il y a un nouveau message dans la queue et si oui de l'enlever de la queue et de faire ce qui est demandé.
Il nous faudra donc utiliser des classes un peu avancées de python qui savent parler au système d'exploitation de votre ordinateur et gérer les queues. Notre code python commencera par importer les éléments suivants :
#!/usr/bin/python3
import sys, threading, queue, serial
import serial.tools.list_ports
la tâche qui va écouter ce que dit l'Arduino peut être définie simplement :
on boucle de manière infinie sur l'écoute du port série et quand on a le marqueur de fin, on rajoute à la queue sinon on rajoute le caractère reçu dans notre message
def listenToArduino():
message = b''
while True:
incoming = arduino.read()
if (incoming == b'\n'):
arduinoQueue.put(message.decode('utf-8').strip().upper())
message = b''
else:
if ((incoming != b'') and (incoming != b'\r')):
message += incoming
et pour lancer cette tâche de fond, le code principal doit faire
arduinoThread = threading.Thread(target=listenToArduino, args=())
arduinoThread.daemon = True
arduinoThread.start()
Notez que l'on peut faire la même chose pour gérer l'interface clavier de l'ordinateur, si on veut envoyer des commandes depuis le PC vers le programme python, on pourrait aussi avoir une tâche et une file d'attente d'écoute du clavier, sur un principe identique.
par exemple ici je gère l'écoute du clavier et j'enregistre les commandes en majuscule (avec le .upper()
)
def listenToLocal():
while True:
command = sys.stdin.readline().strip().upper()
localQueue.put(command)
def configureUserInput():
localThread = threading.Thread(target=listenToLocal, args=())
localThread.daemon = True
localThread.start()
4/ Donc on sait maintenant écouter notre Arduino ou ce que dit l'utilisateur. Il nous reste le point de synchro avec l'arduino dont on a parlé au début.
Pour cela il suffit d'être d'accord sur la vitesse en baud (115200 dans noter exemple) et de mettre à la fin du setup()
l'envoi d'un message connu et attendu par le code Python qui va simplement attendre en boucle en scrutant la file d'attente des messages pour voir s'il reçoit ce message, par exemple juste "OK"
print("Attente de l'Arduino")
while True:
if not arduinoQueue.empty():
if arduinoQueue.get() == "OK":
break
print("Arduino Ready")
le code de base Arduino:
void setup() {
Serial.begin(115200); Serial.println();
// autres éléments de configuration de votre arduino
Serial.println(F("OK"); // on dit à python que le setup a été exécuté
}
void loop() {
// votre programme
}
5/ Le code python principal
maintenant que tout est configuré, le code python principal peut être très simple :
- on regarde s'il y a un message dans la file d'attente arduino et on le gère (en l'enlevant de la file)
- on regarde s'il y a un message dans la file d'attente utilisateur et on le gère (en l'enlevant de la file)
- on fait autre chose de non bloquant
while True:
if not arduinoQueue.empty():
handleArduinoMessage(arduinoQueue.get())
if not localQueue.empty():
handleLocalMessage(localQueue.get())
# autre chose ici si vous voulez du moment que c'est non bloquant
les fonctions handleArduinoMessage()
et handleLocalMessage()
sont celles où vous gérez les commandes
6/ il ne reste plus qu'à mettre tout cela ensemble
pour réaliser un petit code de démo qui fait du ping-pong : ce que l'on tape côté python est mis en majuscule (lors de la saisie par la tâche de fond), puis est envoyé à l'Arduino qui ne fait que le renvoyer sur la voie série au programme python.
le programme Python3:
arduino.py
#!/usr/bin/python3
# ============================================
# code is placed under the MIT license
# Copyright (c) 2023 J-M-L
# For the Arduino Forum : https://forum.arduino.cc/u/j-m-l
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
# ===============================================
import sys, threading, queue, serial
import serial.tools.list_ports
baudRate = 115200
arduinoQueue = queue.Queue()
localQueue = queue.Queue()
def selectArduino():
ports = serial.tools.list_ports.comports()
choices = []
print('PORT\tDEVICE\t\t\tMANUFACTURER')
for index,value in enumerate(sorted(ports)):
if (value.hwid != 'n/a'):
choices.append(index)
print(index, '\t', value.name, '\t', value.manufacturer) # https://pyserial.readthedocs.io/en/latest/tools.html#serial.tools.list_ports.ListPortInfo
choice = -1
while choice not in choices:
answer = input("➜ Select your port: ")
if answer.isnumeric() and int(answer) <= int(max(choices)):
choice = int(answer)
print('selecting: ', ports[choice].device)
return ports[choice].device
def listenToArduino():
message = b''
while True:
incoming = arduino.read()
if (incoming == b'\n'):
arduinoQueue.put(message.decode('utf-8').strip().upper())
message = b''
else:
if ((incoming != b'') and (incoming != b'\r')):
message += incoming
def listenToLocal():
while True:
command = sys.stdin.readline().strip().upper()
localQueue.put(command)
def configureUserInput():
localThread = threading.Thread(target=listenToLocal, args=())
localThread.daemon = True
localThread.start()
def configureArduino():
global arduinoPort
arduinoPort = selectArduino()
global arduino
arduino = serial.Serial(arduinoPort, baudrate=baudRate, timeout=.1)
arduinoThread = threading.Thread(target=listenToArduino, args=())
arduinoThread.daemon = True
arduinoThread.start()
# ---- CALLBACKS UPON MESSAGES -----
def handleLocalMessage(aMessage):
print("=> [" + aMessage + "]")
arduino.write(aMessage.encode('utf-8'))
arduino.write(bytes('\n', encoding='utf-8'))
def handleArduinoMessage(aMessage):
print("<= [" + aMessage + "]")
# ---- MAIN CODE -----
configureArduino() # will reboot AVR based Arduinos
configureUserInput() # handle stdin
print("Waiting for Arduino")
# --- A good practice would be to wait for a know message from the Arduino
# for example at the end of the setup() the Arduino could send "OK"
while True:
if not arduinoQueue.empty():
if arduinoQueue.get() == "OK":
break
print("Arduino Ready")
# --- Now you handle the commands received either from Arduino or stdin
while True:
if not arduinoQueue.empty():
handleArduinoMessage(arduinoQueue.get())
if not localQueue.empty():
handleLocalMessage(localQueue.get())
le programme Arduino
pingpong.ino
void setup() {
Serial.begin(115200); Serial.println();
Serial.println("OK"); // let the python code know we are ready
}
void loop() {
// on renvoie ce que l'on a reçu
if (Serial.available())
Serial.write(Serial.read());
}
petit exemple de ce que l'on voit dans le terminal avec un UNO connecté sur cu.usbmodem14201
(je rentre au début exprès des faux N° pour choisir le port, on voit que ça attend correctement)
Le Arduino Ready arrive après environ 1 à 2 secondes, c'est le temps du boot de mon UNO
python3 arduino.py
PORT DEVICE MANUFACTURER
3 cu.usbmodem14201 Arduino (www.arduino.cc)
➜ Select your port: 7
➜ Select your port: 22
➜ Select your port: xxx
➜ Select your port: 3
selecting: /dev/cu.usbmodem14201
Waiting for Arduino
Arduino Ready
coucou
=> [COUCOU]
<= [COUCOU]
bonjour depuis Python
=> [BONJOUR DEPUIS PYTHON]
<= [BONJOUR DEPUIS PYTHON]
Voilà ensuite il ne reste qu'à connecter cela à un gestionnaire de commandes textuelles côté Arduino par exemple et vous pouvez avoir une interface depuis votre programme python
testé uniquement sur MacOS X.
Dites moi si vous testez sur linux ou windows et si vous rencontrez des soucis ou si ça fonctionne pour vous.