Ce document propose un protocole de transmission de données entre un Arduino et un PC, via un script Python.
La transmission des données se fait par le port série. Du côté de l'Arduino, on utilise la classe Serial. Du côté du script Python sur le PC, on utilise le module serial.
Par définition, une donnée peut être :
Chaque donnée est identifié par un numéro codé sur 8 bits. Il est donc possible de gérer 256 données, ce qui est largement suffisant puisqu'une donnée peut être un tableau de taille quelconque.
Chaque donnée a une taille déterminée en octets. Par exemple, un entier 32 bits a une taille de 4 octets, un tableau de 10 flottants a une taille de 40 octets. Dans le programme Arduino, chaque donnée se voit affectée un tableau uint8_t de la taille correspondante. Ce tableau est un tampon utilisé par la fonction Serial.readBytes pour la transmission du PC vers l'Arduino et la fonction Serial.write pour la transmission de l'Arduino vers le PC.
Toutes les transmissions de données, dans un sens ou l'autre, sont initiées par le PC.
Dans certains cas, une donnée demandée par le PC n'est pas immédiatement disponible, par exemple si la donnée résulte d'une conversion A/N à une certaine fréquence d'échantillonnage. Dans le script Python, l'appel de la fonction de lecture est bloquant et se termine lorsque la donnée est disponible. Il sera bien sûr possible, via la classe Thread, de programmer un appel non bloquant avec une récupération asynchrone de la donnée.
Une donnée transmise par le PC à l'Arduino est immédiatement traitée par l'Arduino. Remarquons qu'une ou plusieurs données peuvent être utilisées pour déclencher des actions.
Il est en principe possible de programmer une Classe C++ permettant de gérer de manière générale les transmissions de données. Cependant, le caractère statique des tableaux utilisés pour la transmission (leur taille est fixée à la compilation) limite l'intérêt d'une telle classe. Par ailleurs, certaines opérations devront être effectuées dans des fonctions de traitement d'interruption programmées spécifiquement pour chaque application, ce qui serait difficile à gérer depuis une classe générique. En conséquence, le programme proposé ci-dessous est un prototype, qu'il sera aisé d'adapter en fonction de l'application souhaitée.
Ce prototype comporte des données modifiées périodiquement dans une fonction de traitement d'interruption (ISR).
Un échange est initié par le PC et il commence par un octet dont la valeur précise l'action à effectuer (lecture ou écriture). L'octet suivant comporte le numéro de la donnée.
La transmission d'une donnée du PC vers l'Arduino est définie par le code SET_DATA. La transmission de l'Arduino vers le PC, c'est-à-dire que le PC demande une donnée, est définie par le code GET_DATA.
On commence par définir ces deux codes puis on définit les tailles des données et les tableaux associés :
#include <Arduino.h> #define GET_DATA 10 #define SET_DATA 11 #define DATA_0_SIZE 4 // int32 #define DATA_1_SIZE 8 // tableau de 2 float #define DATA_2_SIZE 32 // tableau de 16 int16 uint8_t data_0[DATA_0_SIZE]; uint8_t data_1[DATA_1_SIZE]; uint8_t data_2[DATA_2_SIZE];
Une donnée demandée par le PC n'est pas toujours disponible au moment où il fait la demande. On doit donc définir des booléens qui précisent les disponibilités des données :
bool data_0_ready = false; bool data_1_ready = false; bool data_2_ready = true;
Lorsque le PC fait une demande de donnée, sa demande est enregistrée dans une variable booléenne :
bool data_0_request = false; bool data_1_request = false; bool data_2_request = false;
À chaque donnée doit être associée une variable. Dans le cas présent :
int32_t n0; // data_0 float x[2]; // data_1 uint16_t tab[16]; // data_2
Il faut remarquer que si plusieurs données sont toujours transmises en même temps, on aura intérêt à les regrouper dans un tableau.
Dans ce prototype, les données 0 et 1 sont modifiées périodiquement avec la pérdiode suivante (en microsecondes) :
#define DATA_SAMPLING_PERIOD 1000000
Les interruptions sont générées par le Timer 4, programmé avec la fonction suivante :
void timer4_init(uint32_t period){ // période en microsecondes uint16_t diviseur[6] = {0,1,8,64,256,1024}; TCCR4A = 0; TCCR4B = 0; TCCR4B |= (1 << WGM42); // mode CTC avec OCR1A pour le maximum uint32_t top = (F_CPU/1000000*period); int clock = 1; while ((top>0xFFFF)&&(clock<5)) { clock++; top = (F_CPU/1000000*period/diviseur[clock]); } OCR4A = top; // période TIMSK4 = (1 << OCIE4A); // interruption lorsque TCNT4=OCR4A TCCR4B |= clock; }
Dans l'ISR ci-dessous, on modifie les variables associées aux données 0 et 1 puis on copie le contenu de ces variables dans les tableaux uint8_t associés avant de déclarer ces données disponibles pour la lecture. La copie se fait avec la fonction memcpy.
ISR(TIMER4_COMPA_vect) { // mise à jour périodique des données 0 et 1 n0 += 1; x[0] *= 1.1; x[1] *= 1.2; // copie dans les tampons memcpy(data_0,&n0,DATA_0_SIZE); memcpy(data_1,x,DATA_1_SIZE); // les données sont prêtes pour transmission au PC data_0_ready = true; data_1_ready = true; }
Les tableaux data_0 et data_1 constituent des tampons. Après la recopie d'une donnée dans un tampon, il est possible de modifier le contenu de la variable avant que le PC ait lu le tampon. Par exemple, il est possible de modifier le contenu de la variable n0 alors que le contenu du tampon n'a pas encore été lu par le PC. Cette possibilité n'est pas utilisé dans ce prototype mais elle pourrait être nécessaire dans d'autres situations.
La fonction get_data est appelée après que le PC ait fait une demande de lecture de données :
void get_data() { char n; while (Serial.available()<1) {}; n = Serial.read(); if (n==0) data_0_request = true; else if (n==1) data_1_request = true; else if (n==2) data_2_request = true; }
La fonction send_data envoie les données qui sont disponibles, si le PC en a fait la demande :
void send_data() { if ((data_0_ready)&&(data_0_request)) { data_0_ready = false; data_0_request = false; Serial.write(data_0,DATA_0_SIZE); } if ((data_1_ready)&&(data_1_request)) { data_1_ready = false; data_1_request = false; Serial.write(data_1,DATA_1_SIZE); } if ((data_2_ready)&&(data_2_request)) { data_2_ready = false; data_2_request = false; Serial.write(data_2,DATA_2_SIZE); } }
L'indicateur de disponibilité est mis à false, car une demande ultérieure doit attendre qu'une nouvelle version de la donnée soit générée (dans la fonction de traitement de l'interruption). Ce mécanisme permet au PC de récupérer des données produites à intervalle de temps régulier. Bien évidemment, les demandes initiées par le PC devront se faire à une fréquence moyenne égale à la fréquence de production des données, mais avec une tolérance de variation. Dans le cas présent, les données 0 et 1 sont modifiées toutes les secondes. Le programme Python devra donc faire des demandes espacées en moyenne de une seconde.
La fonction set_data est appelée après que le PC ait fait une demande d'écriture de données :
void set_data() { char n; while (Serial.available()<1) {}; n = Serial.read(); if (n==0) { while (Serial.available()<DATA_0_SIZE) {}; Serial.readBytes(data_0,DATA_0_SIZE); memcpy(&n0,data_0,DATA_0_SIZE); } else if (n==1) { while (Serial.available()<DATA_1_SIZE) {}; Serial.readBytes(data_1,DATA_1_SIZE); memcpy(&x,data_1,DATA_1_SIZE); } else if (n==2) { while (Serial.available()<DATA_2_SIZE) {}; Serial.readBytes(data_2,DATA_2_SIZE); memcpy(&tab,data_2,DATA_2_SIZE); } }
Lorsqu'une donnée 0 est lue (écrite par le PC), elle est obtenue dans le tableau data_0 avant d'être recopiée dans la zone mémoire correspondant à la variable associée.
La fonction read_serial permet de détecter un octet sur le port série et de lire le code (GET_DATA ou SET_DATA) :
void read_serial() { char com; if (Serial.available()>0) { com = Serial.read(); if (com==GET_DATA) get_data(); else if (com==SET_DATA) set_data(); } }
Voici la fonction setup :
void setup() { // initialisation des données n0 = 0; x[0] = 1.0; x[1] = 2.0; for (int i=0; i<16; i++) tab[i] = 0; cli(); timer4_init(DATA_SAMPLING_PERIOD); sei(); char c; Serial.begin(115200); while(!Serial); }
Voici la fonction loop :
void loop() { read_serial(); send_data(); }
La classe Python présenté ci-dessous permet de gérer les échanges de données avec l'Arduino. Les deux arguments du constructeur sont port, le nom du port série et data_size, une liste qui précise les tailles des différentes données.
import serial import numpy as np import struct class Arduino(): def __init__(self,port,data_size,baudrate=115200): self.ser = serial.Serial(port,baudrate=baudrate) self.GET_DATA = 10 self.SET_DATA = 11 self.data_size = data_size self.nb_data = len(data_size) def close(self): self.ser.close() def read_data(self,n): self.ser.write((self.GET_DATA).to_bytes(1,byteorder='big')) self.ser.write((n).to_bytes(1,byteorder='big')) data = self.ser.read(self.data_size[n]) # appel bloquant return data def read_int8(self,n,signed=True): if signed : fmt = '<b' else : fmt = '<B' data = self.read_data(n) x = struct.unpack(fmt,data) return x[0] def read_int8_array(self,n,signed=True): data = self.read_data(n) size = len(data) if signed : fmt = '<b' a = np.zeros(size,np.int8) else : fmt = '<B' a = np.zeros(size,np.uint8) for k in range(size): a[k] = data[k] return a def read_int16(self,n,signed=True): if signed : fmt = '<h' else : fmt = '<H' data = self.read_data(n) x = struct.unpack(fmt,data) return x[0] def read_int16_array(self,n,signed=True): data = self.read_data(n) size = len(data)//2 if signed : fmt = '<h' a = np.zeros(size,np.int16) else : fmt = '<H' a = np.zeros(size,np.uint16) for k in range(size): i = 2*k x = struct.unpack(fmt,data[i:i+2]) a[k] = x[0] return a def read_int32(self,n,signed=True): if signed : fmt = '<i' else : fmt = '<I' data = self.read_data(n) x = struct.unpack(fmt,data) return x[0] def read_int32_array(self,n,signed=True): data = self.read_data(n) size = len(data)//4 if signed : fmt = '<i' a = np.zeros(size,np.int32) else : fmt = '<I' a = np.zeros(size,np.uint32) for k in range(size): i = 4*k x = struct.unpack(fmt,data[i:i+4]) a[k] = x[0] return a def read_float(self,n): data = self.read_data(n) x = struct.unpack('<f',data) return np.float32(x[0]) def read_float_array(self,n): data = self.read_data(n) size = len(data)//4 a = np.zeros(size,np.float32) for k in range(size): i = 4*k x = struct.unpack('<f',data[i:i+4]) a[k] = x[0] return a def write(self,n): self.ser.write((self.SET_DATA).to_bytes(1,byteorder='big')) self.ser.write((n).to_bytes(1,byteorder='big')) def write_int8(self,n,x,signed=True): self.ser.write((self.SET_DATA).to_bytes(1,byteorder='big')) self.ser.write((n).to_bytes(1,byteorder='big')) if signed: fmt = '<b' else: fmt = '<B' bts = struct.pack(fmt,x) for b in bts: self.ser.write((b).to_bytes(1,byteorder='big')) def write_int8_array(self,n,a,signed=True): self.ser.write((self.SET_DATA).to_bytes(1,byteorder='big')) if signed : fmt = '<b' else : fmt = '<B' self.ser.write((n).to_bytes(1,byteorder='big')) size = len(a) for k in range(size): bts = struct.pack(fmt,a[k]) for b in bts: self.ser.write((b).to_bytes(1,byteorder='big')) def write_int16(self,n,x,signed=True): self.ser.write((self.SET_DATA).to_bytes(1,byteorder='big')) if signed : fmt = '<h' else : fmt = '<H' self.ser.write((n).to_bytes(1,byteorder='big')) bts = struct.pack(fmt,x) for b in bts: self.ser.write((b).to_bytes(1,byteorder='big')) def write_int16_array(self,n,a,signed=True): self.ser.write((self.SET_DATA).to_bytes(1,byteorder='big')) if signed : fmt = '<h' else : fmt = '<H' self.ser.write((n).to_bytes(1,byteorder='big')) size = len(a) for k in range(size): bts = struct.pack(fmt,a[k]) for b in bts: self.ser.write((b).to_bytes(1,byteorder='big')) def write_int32(self,n,x,signed=True): self.ser.write((self.SET_DATA).to_bytes(1,byteorder='big')) if signed : fmt = '<i' else : fmt = '<I' self.ser.write((n).to_bytes(1,byteorder='big')) bts = struct.pack(fmt,x) for b in bts: self.ser.write((b).to_bytes(1,byteorder='big')) def write_int32_array(self,n,a,signed=True): self.ser.write((self.SET_DATA).to_bytes(1,byteorder='big')) if signed : fmt = '<i' else : fmt = '<I' self.ser.write((n).to_bytes(1,byteorder='big')) size = len(a) for k in range(size): bts = struct.pack(fmt,a[k]) for b in bts: self.ser.write((b).to_bytes(1,byteorder='big')) def write_float(self,n,x): self.ser.write((self.SET_DATA).to_bytes(1,byteorder='big')) self.ser.write((n).to_bytes(1,byteorder='big')) bts = struct.pack('<f',x) for b in bts: self.ser.write((b).to_bytes(1,byteorder='big')) def write_float_array(self,n,a): self.ser.write((self.SET_DATA).to_bytes(1,byteorder='big')) self.ser.write((n).to_bytes(1,byteorder='big')) size = len(a) for k in range(size): bts = struct.pack('<f',a[k]) for b in bts: self.ser.write((b).to_bytes(1,byteorder='big')) def write_double(self,n,x): self.ser.write((self.SET_DATA).to_bytes(1,byteorder='big')) self.ser.write((n).to_bytes(1,byteorder='big')) bts = struct.pack('<d',x) for b in bts: self.ser.write((b).to_bytes(1,byteorder='big')) def write_double_array(self,n,a): self.ser.write((self.SET_DATA).to_bytes(1,byteorder='big')) self.ser.write((n).to_bytes(1,byteorder='big')) size = len(a) for k in range(size): bts = struct.pack('<d',a[k]) for b in bts: self.ser.write((b).to_bytes(1,byteorder='big'))
Prenons l'exemple du prototype de programme Arduino présenté plus haut. Il y a trois données de tailles respectives 4,8 et 32. On a donc pour la création de l'objet : data_size=[4,8,32]. L'exemple ci-dessous comporte une écriture des données 0 et 1 puis une boucle où l'utilisateur est invité à entrer un numéro de donnée à demander à l'Arduino. La donnée correspondante est affichée après lecture.
ard = Arduino('COM4',[4,8,32]) ard.write_int32(0,100) ard.write_float_array(1,np.array([1.5,3.0e15])) while True: r = input('?') if r=='n': break n = int(r) if n==0: print(ard.read_int32(0,signed=True)) if n==1: print(ard.read_float_array(1)) if n==2: print(ard.read_int16_array(2)) ard.close()