samedi 21 décembre 2019

Commander un ARDUINO par la ligne série ou BLUETOOTH


Commander un ARDUINO par la ligne série

ou BLUETOOTH



Après mon article sur l'alimentation numérique il m'a semblé intéressant d'isoler la partie commande par la ligne série.

En effet cette partie peut être reprise facilement si l'on désire envoyer des requêtes à un ARDUINO, que ce soit par une liaison série hardware, software, BLUETOOTH, ou autre.

1. Principe

Dans tous les cas, le principe est le même. Il s'agit de :
  • recevoir une chaîne de caractères provenant de la ligne série
  • interpréter la commande
  • exécuter la commande
  • renvoyer une réponse (éventuellement) 
Examinons chacun de ces points.

1.1. Réception

Recevoir une chaîne de caractères consiste à stocker les caractères reçus dans un buffer (une suite d'octets).

Pour que la lecture sur la ligne série soit efficace, il convient de convenir d'un caractère terminateur. La lecture s'arrêtera donc lorsque ce caractère terminateur sera lu.

On peut choisir par exemple un caractère retour à la ligne '\n' ou '\x0A', ou retour chariot '\r' ou '\x0D', ce qui sera pratique pour essayer les commandes à l'aide d'un terminal (moniteur série de l'IDE ARDUINO).

Pour ceux qui désireraient transférer des données de manière plus sécurisé qu'avec un simple terminateur je renvoie à cet article :
https://riton-duino.blogspot.com/2019/04/arduino-un-protocole-serie.html
Ce protocole implémente un CRC et des répétitions multiples.

1.2. Interprétation

Interpréter la commande revient à la découper en morceaux :
  • l'identifiant de la commande (un mot-clé, un entier, etc.)
  • les éventuels arguments
1.2.1. binaire, hexadécimal et ASCII
Très souvent les débutants se prennent les pieds dans le tapis avec ces notions.

La notation binaire est la représentation d'un nombre de un ou plusieurs octets sous forme de 1 et de 0. Le bit de poids faible (à droite) vaut 1 point, le deuxième vaut 2 points, le troisième vaut 4 points, le quatrième vaut 8 points, etc. En C on la représente comme ceci (avec un 0B) :

Sur 1 octet :
0B00000010 : 2
0B00000110 : 6
0B11111111 : 255

Sur 2 octets :
0B0000000000000110 : 6
0B1111111111111111 : 65535

L'hexadécimal est une représentation binaire compacte d'un ou plusieurs octets : 
0x7F : 0B01111111 ou 127 en décimal
0x68 : 0B1101000 ou 104 en décimal
0x7F68 : 0B0111111101101000 ou 32616 en décimal

En informatique, ce n'est qu'une représentation visuelle plus facile à lire que le binaire :

0x7F68 est plus simple à écrire que 0B0111111101101000 mais c'est la même chose.

L'ASCII est une norme d'encodage informatique des caractères alphanumériques.

Chaque caractère est représenté sur un seul octet :
'A' : 65 (0x41 en hexa)
'B' : 66 (0x42 en hexa)
'0' : 48 (0x30 en hexa)
'1' : 49 (0x31 en hexa) 

Il n'y a aucun besoin de conversion si l'on veut obtenir la valeur décimale ou hexadécimale d'un caractère ASCII :

'A' ou 65 ou 0x41 sont équivalents.

Le seul problème réside au niveau des types de variables employés :

void setup()
{
  Serial.begin(115200);
  Serial.println('A');
  Serial.println(0x41);
  Serial.println(65);
  Serial.println((char)0x41);
  Serial.println((char)65);
}

void loop()
{

}


L'opération (char)0x41 n'est pas une conversion, c'est un cast qui vise à faire croire au compilateur que le nombre passé en paramètre est un caractère.

Le compilateur utilisera donc une méthode Serial::println(char) à la place de Serial::println(int), et c'est tout.

Ce sketch affichera :

A
65
65
A
A


1.2.2. Chaîne de caractères
En C une chaîne de caractère peut être représentée de plusieurs manières :

"A56T" est équivalent à "\x65\x35\x36\x54".
"\x65\x66\x67\x0A" est équivalent à "ABC\x0A".

 '\x0A' est le caractère NL ou NewLine (retour à la ligne). Il ne peut être représenté de manière visuelle comme une lettre de l'alphabet. C'est un caractère non imprimable. Dans une chaîne de caractères on peut le remplacer par '\n' : "ABC\n".

Il faut bien distinguer les chiffres d'une chaîne de caractères ou chaque chiffre ou digit est un caractère imprimable, des chiffres d'un nombre entier :

"1234" est équivalent à "\x31\x32\x33\x34".
Tandis que le nombre 1234 est équivalent à 0x04D2.
"1234" occupe 4 octets en mémoire, 1234 n'en occupe que deux.

Si l'on veut transmettre un nombre sur une ligne série, on peut adopter les deux formats, mais le format chaîne de caractères impliquera une conversion :

Pour transmettre un nombre en clair, on utilisera la méthode Serial.print(), qui effectue elle-même la conversion :

Serial.print(10);

Ce qui aura pour conséquence de transmettre deux caractères '1' et '0', donc 0x31 et 0x30, ou la chaîne "\x31\x30" et donc "10". 

Serial.write(10);


Cet appel à la méthode write transmettra un unique caractère 10 ou 0x0A ou '\x0A'.

Dans le cas où l'on transmet en clair, lorsque l'on recevra "1234", on pourra convertir cette chaîne en nombre :

int n = atoi("1234");


Le cas du binaire est plus complexe. Il faudra tenir compte de l'endian du processeur.
Pour certains processeurs les entiers sont placés en mémoire avec l'octet de poids fort à gauche (big endian), pour d'autres processeurs le poids fort est à droite (little endian) :

Heureusement pour nous la majeure partie des processeurs que nous utilisons sont "little endian".

Sur un ARDUINO :

void setup() {
  Serial.begin(115200);
  char *s = "\x10\x11";
  int n = *(int *)s;
  s = "\x10\x11\x12\x13";
  long l = *(long *)s;
  Serial.write(10);
  Serial.println(n, HEX);
  Serial.println(l, HEX);
}

void loop()
{
}


Les deux chaînes de caractères "\x10\x11" et "\x10\x11\x12\x13" sont placées en mémoire dans l'ordre "naturel".
L'opération *(int *)s vise à faire croire au compilateur que les deux cases mémoire à l'adresse s sont en fait l'adresse d'un entier.
L'opération *(long *)s vise à faire croire au compilateur que les quatre cases mémoire à l'adresse s sont l'adresse d'un entier long.
Cette opération s'appelle un cast.

Le sketch affichera :

1110
13121110


Nous sommes bien en présence d'un processeur little endian.

Lors de la réception d'un entier binaire sur une ligne série, on pourra se dispenser de conversion. Imaginons que l'on ait reçu deux octets 0xD2 et 0x04 dans un buffer :

void setup() {
  Serial.begin(115200);
  char buf[] = "\xD2\x04";
  Serial.println(*(int *)buf);
}

void loop()
{
}


Ici aussi on utilise un cast.

"\xD2\x04" est la représentation du nombre 1234 ou 0x04D2 dans la mémoire d'un processeur little endian.

Le sketch affiche bien :

1234

Mais sur un processeur big endian on afficherait :

-11772

Ce qui n'est pas tout à fait la même chose.

Si l'on n'est pas familier avec ces notions il vaut mieux éviter le binaire et travailler en clair (commandes ASCII), même si cela implique des conversions.
D'autre part, comme dit précédemment, la transmission de trames binaires requiert l'utilisation d'un protocole beaucoup plus élaboré qu'un simple terminateur.

1.2.3. Commandes en clair
La commande peut être composée de mots en clair (ASCII) séparés par des espaces. Les mots clés et paramètres peuvent avoir une taille variable :

COMMANDE PARAM1 PARAM2

Exemple :

"LR ON
"LR OFF"
"LR BL 500"

Où chaque mot a une signification :
  • "LR" correspond au code "LED-ROUGE"
  • "ON" signifie allumer
  • "OFF" signifie éteindre
  • "BL" signifie clignoter
  • "500" donne la période de clignotement
1.2.4. Commandes compactes
Il est possible de compacter les données à transmettre, en réduisant la taille des mots clés et en supprimant les séparateurs. Dans ce cas les données doivent avoir une taille fixe.

Exemple :

"R1"
"R0"
"RB01F4"
  • "R" correspond au code "LED-ROUGE"
  • "1" signifie allumer
  • "0" signifie éteindre
  • "B" signifie clignoter
  • "01F4" (500) donne la période de clignotement (l'hexadécimal occupe moins de place)
Il sera là aussi possible d'utiliser un caractère terminateur du type retour ligne ou chariot, car ces caractères n'aparaissent jamais dans les données.

1.2.5. Commandes binaires
La commande peut aussi être composée de codes binaires de taille définie.

Exemple :

"\x11\x01"
"\x11\x00"
"\x11\x02\x01\xF4"
  • '\x11' correspond au code "LED-ROUGE"
  • '\x01' signifie allumer
  • '\x00' signifie éteindre
  • '\x02' signifie clignoter
  • "\x01\xF4" (500) donne la période de clignotement
Il est bien évident qu'envoyer quelques caractères binaires est plus économique qu'envoyer des caractères en mode texte pour faire le même travail.
Ici la période est transmise sur deux octets : "\x01\xF4"
Dans le cas précédent il en fallait quatre :  "01F4" c'est à dire "\x30\x31\x46\x34"

Mais le binaire complique pas mal les choses, et c'est nettement moins lisible.

Dans le cas où la commande est binaire, il conviendra d'adopter un format (structure) adapté. Il sera difficile d'envoyer les commandes à l'aide d'un terminal, il faudra écrire un logiciel émetteur adapté.
Il sera également impossible d'utiliser un caractère terminateur du type retour ligne ou chariot, car ces caractères peuvent faire partie des données binaires.

Si par exemple nous avions à transmettre la commande suivante :

"\x11\x03\x01\x0A"

Cette fois-ci la période de clignotement vaut 0x010A (266). Malheureusement le dernier caractère vaut 0x0A ou '\n' (retour à la ligne). La réception s'arrêtera donc à ce caractère et la commande sera tronquée :

"\x11\x03\x01"

 Il faudra nécessairement passer par un protocole plus complexe, comportant un caractère de début de trame, un caractère de fin de trame et une gestion des caractères de contrôle (mode transparent).

1.2.6. Restons simple
Dans cet article, censé rester simple, nous parlerons uniquement des deux premier cas : les commandes en clair et compactes.

1.3. Exécution

L'exécution de la commande revient à associer un morceau de code correspondant à un identifiant de commande connu.

Nous allons étudier un petit exemple répondant à quelques requêtes simples. Les requêtes sont du type chaîne de caractères (C string), de longueur variable, et le caractère terminateur sera un retour à la ligne :
  • "HELLO" : l'ARDUINO répond "HELLO"
  • "UPPER string" : l'ARDUINO renvoie la chaîne string en majuscules
  • "LOWER string" : l'ARDUINO renvoie la chaîne string en minuscules
  • "ADD a b" :  l'ARDUINO renvoie la somme de a + b
Il est ensuite facile de créer des commandes pour effectuer des actions plus concrètes :
  • commander un moteur
  • allumer des LEDs
  • afficher un message sur un LCD, un TFT
  • lire une température
  • etc.
Cet article développera donc les points suivants :
  • recevoir une chaîne de caractères provenant d'une ligne série
  • écrire un analyseur ou "parser" en utilisant sscanf et les pointeurs
  • exécuter des actions correspondant à une commande précise
L'intérêt des exemples suivants est de pouvoir être testés sans matériel particulier. Une simple carte ARDUINO suffit et un script PYTHON est même fourni si l'on désire essayer de faire communiquer l'ARDUINO avec un PC ou une RASBERRY PI.

2. La ligne série

Qu'il s'agisse d'une ligne série hardware ou software (émulée par logiciel sur deux broches d'entrée / sortie standard) une ligne série est un objet instancié à partir de la classe HardwareSerial ou SoftwareSerial.

2.1. HardwareSerial

Lorsqu'il s'agit d'un HardwareSerial l'objet est déjà instancié par la librairie ARDUINO : il s'appelle Serial et permet de communiquer par le cordon USB.

Certaines cartes disposent de plusieurs lignes série : la MEGA par exemple (Serial1, Serial2, etc.). Ces lignes série supplémentaires nécessitent l'emploi d'un convertisseur USB / série (voir plus bas).

2.2. SoftwareSerial

Lorsqu'il s'agit d'un SoftwareSerial il faudra instancier l'objet soi-même :

const byte rxPin = 2;
const byte txPin = 3;
SoftwareSerial mySerial (rxPin, txPin);


Il faudra également ajouter un convertisseur USB / Série si le but est de communiquer avec un PC ou une carte RASPBERRY PI :

Si deux ARDUINO communiquent entre eux à courte distance, on pourra les relier directement à l'aide de deux fils (RX sur TX et TX sur RX), et d'un fil de masse.
Si les distances sont plus importantes, un convertisseur de niveaux et un câble RS232 devront être utilisés :
S'il s'agit de communiquer par BLUETOOTH, le module (HC-05 par exemple) pourra être relié directement à l'ARDUINO toujours en croisant RX / TX et TX / RX. Je vous renvoie au blog d'Eskimon : utiliser-un-module-bluetooth-hc-05-avec-arduino

2.3. HardwareSerial ou SoftwareSerial

Si l'on désire faire communiquer l'ARDUINO avec une ligne série ou un module BLUETOOTH tout en conservant la ligne réservée au cordon USB pour le chargement et pour afficher des information aidant à déboguer, le SoftwareSerial est fortement recommandé.
Attention cependant, un SoftwareSerial est moins rapide. 57600 baud me semble la limite haute. Si l'on rencontre des problèmes de transmission, réduire la vitesse.

Qu'il s'agisse de l'un ou l'autre de ces deux classes, HardwareSerial et SoftwareSerial héritent des méthodes de la classe Stream (available(), read(), write(), etc.).

Ceci veut dire que, quel que soit le type de ligne série employé, dans notre programme seul l'instanciation de l'objet ligne série sera différente. Tout le reste du code sera identique :

Avec HardwareSerial :

void loop()
{
  if (Serial.available()) {
  // ...


Avec SoftwareSerial :

SoftwareSerial mySerial (rxPin, txPin);

void loop()
{
  if (mySerial.available()) {
  // ...


3. Choix de la méthode de lecture

Il existe plusieurs possibilités de lecture d'une chaîne de caractères sur la ligne série :
  • Stream.read() : lecture caractère par caractère
  • Stream.readStringUntil() : lecture d'une chaîne de caractères
  • Stream.readBytesUntil() : lecture d'une chaîne de caractères
Suivant la méthode choisie certaines options seront possibles, ou non.

3.1. Time-out

Le time-out est le temps en millisecondes que l'appelant peut tolérer pour la réception d'un caractère. On peut limiter ce temps à l'aide de la méthode setTimeout().

Cela veut dire que si aucun caractère n'est reçu dans les temps la méthode read() retournera -1, la méthode readStringUntil() retournera une chaîne vide, et la méthode readBytesUntil() retournera une taille de ZÉRO.

Par défaut ce temps vaut 1 seconde.

Si l'on a besoin de 3 secondes on ajoutera au setup() :

Serial.setTimeout(3000);

3.2. ReadBytesUntil

Cette méthode stocke les caractères reçus dans un buffer fourni par l'appelant (l'appelant fournit également sa taille). La lecture est effectuée jusqu'à rencontrer le caractère terminateur spécifié par l'appelant :

static char buf[100];
size_t size = Serial.readBytesUntil('\n', buf, 100);


La méthode a un inconvénient : la lecture est bloquante jusqu'à rencontrer le caractère terminateur ou jusqu'à ce que le time-out soit écoulé.

Cela veut dire que notre sketch ne pourra en aucun cas exécuter d'autres actions pendant la réception de la commande (sauf gestion par interruptions).

Voici notre exemple utilisant ReadBytesUntil() :

void setup()
{
  Serial.begin(115200);
  Serial.println(F("Boot message"));
}

#define CMD_MAX               100

void loop()
{
  static char buf[CMD_MAX];

  if (!Serial.available()) {
    return;
  }
  memset(buf, 0, CMD_MAX);
  size_t size = Serial.readBytesUntil('\n', buf, CMD_MAX - 1);

  // exécution de la commande

  if (!strncmp(buf, "HELLO", 4)) {
    Serial.println("HELLO");
  }
  else if (!strncmp(buf, "UPPER", 5)) {
    char s[20];
    char arg[20];
    if (sscanf(buf, "%s %s", s, arg) != 2) {
      // invalid arguments
      Serial.println("EINVAL");
      return;
    }
    Serial.println(strupr(arg));
  }
  else if (!strncmp(buf, "LOWER", 5)) {
    char s[20];
    char arg[20];
    if (sscanf(buf, "%s %s", s, arg) != 2) {
      // invalid arguments
      Serial.println("EINVAL");
      return;
    }
    Serial.println(strlwr(arg));
  }
  else if (!strncmp(buf, "ADD", 3)) {
    char s[20];
    int a, b;
    if (sscanf(buf, "%s %d %d", s, &a, &b) != 3) {
      // invalid arguments
      Serial.println("EINVAL");
      return;
    }
    Serial.println(a + b);
  }
  else {
    // command not supported
    Serial.println("WHAT'S UP DOC ?");
  }

  // -------------------------
}


Les réponses renvoyées à l'émetteur de la commande sont représentée en caractères gras.

3.2.1. Essai

Les commandes UPPER, LOWER et ADD peuvent être essayées à l'aide du moniteur série de l'IDE ARDUINO :
  • HELLO
  • UPPER azertyuiop
  • LOWER AZERTYUIOP
  • ADD 33 12
On peut ainsi directement visualiser les réponses.

3.2.2. Les arguments

Nous allons aborder la partie "parser" ou analyseur et donc voir comment récupérer les données de la commande.

Les commandes UPPER, LOWER et ADD acceptent un ou deux arguments ou paramètres. Ces arguments sont extraits de la commande à l'aide de sscanf().

La chaîne de format permet de spécifier le nombre d'arguments et leur type :
  • %s : chaîne de caractères
  • %d : entier
  • %u : entier non signé
  • %l : long
  • %lu : long non signé
La chaîne de format est suivie des adresses où vont être stockés les valeurs :

    char s[20];
    int a, b;
    if (sscanf(buf, "%s %d %d", s, &a, &b) != 3) {


s représente l'adresse d'une chaîne s de 20 caractères (la commande).
&a représente l'adresse d'un entier a (l'argument a).
&b représente l'adresse d'un entier b (l'argument b).

Si les pointeurs vous sont inconnus :
https://zestedesavoir.com/tutoriels/755/le-langage-c-1/1043_aggregats-memoire-et-fichiers/4277_les-pointeurs/

sscanf retourne le nombre d'arguments reconnus. Il est donc facile de contrôleur la validité de la commande.

On pourrait utiliser également strtok() pour analyser la commande.

3.2.3. Réponse
La réponse à une commande est renvoyée par la ligne série sous forme de chaîne de caractères terminée par '\n' : Serial.println().

L'émetteur de la commande sera ainsi en mesure de lire facilement la réponse.

3.2.4. Commande inconnue
Lorsque la commande n'est pas reconnue le sketch renvoie une erreur par la ligne série :

  else {
    // command not supported
    Serial.println("WHAT'S UP DOC ?");
  }


On peut renvoyer une erreur plus typée bien entendu : un numéro d'erreur par exemple.

3.3. ReadStringUntil

Cette méthode peut sembler la plus pratique, mais elle s'accompagne d'un inconvénient majeur : elle revoie un objet C++ du type String. Cela suppose une allocation dynamique de mémoire, et donc une possible fragmentation de celle-ci.

Elle est plutôt déconseillée sur les plateformes ATMEGA328, équipées de seulement 2K octets de RAM.

Si l'on désire malgré tout l'utiliser on peut remplacer dans l'exemple précédent :

  if (!Serial.available()) {
    return false;
  }
  memset(buf, 0, CMD_MAX);
  size_t size = Serial.readBytesUntil('\n', buf, CMD_MAX - 1);

  // exécution de la commande


Par :

  if (!Serial.available()) {
    return false;
  }
  String buf = Serial.readStringUntil('\n');

  // exécution de la commande


3.4. Read

Cette méthode stocke les caractères reçus dans un buffer fourni par l'appelant. L'appelant contrôle lui-même que la taille du buffer n'est pas dépassée. La lecture est effectuée caractère par caractère et entre chacun d'eux le programme peut faire autre chose.

Voici notre exemple utilisant read() :

void setup()
{
  Serial.begin(115200);
  Serial.println(F("Boot message"));
}

void loop()

{
  commandShell();

  // ICI : autres traitements

}

#define CMD_MAX               100

bool commandShell(void)
{
  static char buf[CMD_MAX];
  static int i;

  if (!Serial.available()) {
    return false;
  }
  int c = Serial.read();
  if (c != -1) {
    if (c == '\n') {
      if (strlen(buf) == 0) {
        return false;
      }

      // exécution de la commande

      if (!strncmp(buf, "HELLO", 4)) {
        Serial.println("HELLO");
      }
      else if (!strncmp(buf, "UPPER", 5)) {
        char s[20];
        char arg[20];
        if (sscanf(buf, "%s %s", s, arg) != 2) {
          // invalid arguments
          Serial.println("EINVAL");
          return true;
        }
        Serial.println(strupr(arg));
      }
      else if (!strncmp(buf, "LOWER", 5)) {
        char s[20];
        char arg[20];
        if (sscanf(buf, "%s %s", s, arg) != 2) {
          // invalid arguments
          Serial.println("EINVAL");
          return true;
        }
        Serial.println(strlwr(arg));
      }
      else if (!strncmp(buf, "ADD", 3)) {
        char s[20];
        int a, b;
        if (sscanf(buf, "%s %d %d", s, &a, &b) != 3) {
          // invalid arguments
          Serial.println("EINVAL");
          return true;
        }
        Serial.println(a + b);
      }
      else {
        // command not supported
        Serial.println("WHAT'S UP DOC ?");
      }

      // -------------------------

      buf[i] = 0;
      i = 0;
      return true;
    }
    if (c != '\r') {
      if (i < CMD_MAX - 1) {
        buf[i++] = c;
        buf[i] = '\0';
      }
      else {
        Serial.println("ETOOBIG");
      }
    }
  }
  return true;
}


Dans cet exemple on peut remarquer plusieurs choses :

La fonction loop() peut exécuter d'autres actions pendant la réception d'une commande :

void loop() {
  commandShell();

  // ICI : autres traitements

}


La réception s'arrête aussitôt que '\n' est rencontré :

    if (c == '\n') {

Le caractère '\r' est ignoré :

    if (c != '\r') {

Une commande de longueur nulle est ignorée :

      if (strlen(buf) == 0) {
        return;
      }


La commande est terminée par zéro, ce qui permettra son exploitation à l'aide des routines de manipulation de chaînes de caractères.
La longueur de la commande est vérifiée afin d'éviter les débordements :

      if (i < CMD_MAX - 1) {
        buf[i++] = c;
        buf[i] = '\0';
      }
      else {
        Serial.println("ETOOBIG");
      }


Si l'émetteur envoie une commande trop longue, le sketch renvoie une erreur.

3.5. Read (commandes compactes)

Cet exemple commande la LED D13 :

"R1" : allumage de la LED rouge
"R0" : allumage de la LED rouge
"RBXXXX" : clignotement de la LED rouge. XXXX est un nombre hexadécimal

Si XXXX vaut "01F4", cela équivaut à un clignotement à 500ms.

void setup()
{
  Serial.begin(115200);
  pinMode(13, OUTPUT);
  Serial.println(F("Boot message"));
}

int blink;
int timer;
byte ledState;

void loop()
{
  static unsigned long previousMillis = 0;

  commandShell();
  if (blink) {
    unsigned long currentMillis = millis();

    if (currentMillis - previousMillis >= blink) {
      previousMillis = currentMillis;
      if (ledState == LOW) {
        ledState = HIGH;
      } else {
        ledState = LOW;
      }
      digitalWrite(13, ledState);
      Serial.println(ledState);
    }
  }
}

#define CMD_MAX               10

bool commandShell(void)
{
  static char buf[CMD_MAX];
  static int i;

  if (!Serial.available()) {
    return false;
  }
  int c = Serial.read();
  if (c != -1) {
    if (c == '\n') {
      if (strlen(buf) == 0) {
        return false;
      }

      // exécution de la commande

      if (buf[0] == 'R') {
        if (buf[1] == 'B') {
          if (strlen(buf) != 6) {
            // invalid arguments
            Serial.println("EINVAL");
            return true;
          }
          sscanf(buf + 2, "%x", &blink);
          Serial.println("OK");
        }
        else {
          if (strlen(buf) != 2) {
            // invalid arguments
            Serial.println("EINVAL");
            return true;
          }
          if (buf[1] != '0' && buf[1] != '1') {
            // invalid arguments
            Serial.println("EINVAL");
            return true;
          }
          blink = 0;
          digitalWrite(13, buf[1] == '0' ? LOW : HIGH);
          Serial.println("OK");
        }
      }
      else {
        // command not supported
        Serial.println("WHAT'UP DOC ?");
      }
      // -------------------------

      buf[i] = 0;
      i = 0;
      return true;
    }
    if (c != '\r') {
      if (i < CMD_MAX - 1) {
        buf[i++] = c;
        buf[i] = '\0';
      }
      else {
        Serial.println("ETOOBIG");
      }
    }
  }
  return true;
}


Dans cet exemple à un seul octet de commande, une simple comparaison d'octet suffit à identifier la commande :

      if (buf[0] == 'R') {

Pour le deuxième octet également :

        if (buf[1] == 'B') {

Les valeurs possibles sont 'B', '1' et '0' (sinon une erreur est renvoyée) :

          if (buf[1] != '0' && buf[1] != '1') {
            // invalid arguments
            Serial.println("EINVAL");
            return true;
          }


Dans tous les cas, la longueur de la commande est contrôlée :

Clignotement :

          if (strlen(buf) != 6) {

Allumage et extinction :

          if (strlen(buf) != 2) {

La durée de clignotement est récupérée aussi à l'aide de sscanf :

          int blink;
          sscanf(buf + 2, "%x", &blink);


La valeur du clignotement est récupérée à l'adresse du troisième octet (buf + 2). Le format est %x, ce qui signifie entier hexadécimal.

Un format %lx signifierait entier long hexadécimal.

          long blink;          sscanf(buf + 2, "%lx", &blink);

4. Côté émetteur

Du côté émetteur, si celui-ci est un PC ou une RASPBERRY PI, on peut écrire un logiciel d'envoi de commandes à l'aide d'un langage quelconque, celui avec lequel on se sent le plus à l'aise :
  • PYTHON
  • JAVA
  • RUBY
  • GCC
  • Visual Studio
  • etc.
L'implémentation de routines de communication à l'aide de Visual Studio en C ou C++ n'est pas simple, à moins d'être spécialiste, et je recommande plutôt l'utilisation d'autres langages.

Dans tous les cas, si la communication doit se faire par le cordon USB de la carte ARDUINO, le moniteur série de l'IDE ARDUINO ou le terminal devra être fermé, sinon une erreur sera affichée :

serial.serialutil.SerialException: [Errno 16] could not open port /dev/ttyUSB1: [Errno 16] Device or resource busy: '/dev/ttyUSB1'

Les amateurs de BLUETOOTH s'orienteront sur ceci :
Serial Bluetooth Terminal

4.1. PYTHON

4.1.1. Commandes en clair
Voici un script PYTHON permettant de communiquer avec le sketch ARDUINO des exemples suivants :
  • 3.2. ReadBytesUntil 
  • 3.4. Read
#!/usr/bin/env python

import time, serial

def sendReply(request):
 global ser
 ser.write(request+'\r\n')
 answer = ser.read_until('\n')
 return answer.strip('\r\n')

if __name__ == "__main__":
 ser = serial.Serial('/dev/ttyUSB1', baudrate=115200)
 ser.timeout = 3
 time.sleep(1)
 ser.read_until('\n') # read boot message
 answer = sendReply('HELLO')
 print "answer:", answer
 answer = sendReply('UPPER azertyuiop')
 print "answer:", answer
 answer = sendReply('LOWER AZERTYUIOP')
 print "answer:", answer
 answer = sendReply('ADD 12 13')
 print "answer:", answer
 answer = sendReply('blabla')
 print "answer:", answer


4.1.2. Commandes compactes
Voici un script PYTHON permettant de communiquer avec le sketch ARDUINO de l'exemple suivant :
  • 3.5. Read (commandes compactes)
#!/usr/bin/env python

import time, serial

def sendReply(request):
    global ser
    ser.write(request+'\r\n')
    answer = ser.read_until('\n')
    return answer.strip('\r\n')

if __name__ == "__main__":
    ser = serial.Serial('/dev/ttyUSB1', baudrate=115200)
    ser.timeout = 3
    time.sleep(1)
    ser.read_until('\n') # read boot message
    answer = sendReply('R1')
    print "answer:", answer
    time.sleep(2)
    answer = sendReply('R0')
    print "answer:", answer
    time.sleep(2)
    answer = sendReply('RB01F4')
    print "answer:", answer
    time.sleep(4)
    answer = sendReply('R0')
    print "answer:", answer
    answer = sendReply('B')
    print "answer:", answer


Dans les deux cas la fonction sendReply() envoie dans un premier temps la commande :

 ser.write(request+'\r\n')

Puis reçoit la réponse de l'ARDUINO :

 answer = ser.read_until('\n')

Le principe de réception est tout à fait analogue à celui utilisé pour l'ARDUINO :

PYTHON :

 answer = ser.read_until('\n')

ARDUINO :

  String buf = Serial.readStringUntil('\n');

Ces scripts utilisent PySerial, un module permettant de communiquer par une ligne série sur un PC (Linux, Windows, BSD) ou bien sûr une RASPBERRY PI.

Il suffit de remplacer '/dev/ttyUSB1' par le port réel utilisé. Ce port est facilement identifiable sur une machine LINUX à l'aide de dmesg (après avoir connecté la carte sur le port USB) :

$ dmesg
[19050.492772] usb 2-1.1.1: new full-speed USB device number 22 using ehci-pci
[19050.586006] usb 2-1.1.1: New USB device found, idVendor=1a86, idProduct=7523
[19050.586012] usb 2-1.1.1: New USB device strings: Mfr=0, Product=2, SerialNumber=0
[19050.586015] usb 2-1.1.1: Product: USB2.0-Serial
[19050.586575] ch341 2-1.1.1:1.0: ch341-uart converter detected
[19050.588589] usb 2-1.1.1: ch341-uart converter now attached to ttyUSB1


Sur une machine Windows, utiliser le gestionnaire de périphériques et remplacer '/dev/ttyUSB1' par le port de COM adéquat (COM3, COM4, etc.).

L'exécution du premier script donne le résultat suivant :
 
$ ./serial-python.py 
answer: HELLO
answer: AZERTYUIOP
answer: azertyuiop
answer: 25
answer: WHAT'S UP DOC ?
$

L'exécution du deuxième script donne le résultat suivant :

$ ./serial-python-compact.py
answer: OK
answer: OK
answer: OK
answer: OK
answer: WHAT'UP DOC ?


4.2. RUBY

Voici un script RUBY permettant de communiquer avec le sketch ARDUINO des exemples suivants :
  • 3.2. ReadBytesUntil 
  • 3.4. Read
require "serialport"

def sendReply tty, s
    tty.write s
    return tty.readline
end
   
tty = SerialPort.new("/dev/ttyUSB1", 115200, 8, 1, SerialPort::NONE)
tty.read_timeout = 3000

boot = tty.readline
answer = sendReply tty, "HELLO"
puts "answer " + answer
answer = sendReply tty, "UPPER azertyuiop"
puts "answer " + answer
answer = sendReply tty, "LOWER AZERTYUIOP"
puts "answer " + answer
answer = sendReply tty, "ADD 12 13"
puts "answer " + answer
answer = sendReply tty, "blabla"
puts "answer " + answer


Ce script utilise SerialPort, l'équivalent RUBY de PySerial.

L'exécution du script donne le résultat suivant :

$ ruby serial-ruby.rb
answer HELLO
answer AZERTYUIOP
answer azertyuiop
answer 25
answer WHAT'UP DOC ?
$


5. Liens utiles

Un protocole série sécurisé avec CRC :
https://riton-duino.blogspot.com/2019/04/arduino-un-protocole-serie.html

Ce protocole est utilisable également lorsque les données doivent être transmises en toute sécurité avec un CRC.

6. Conclusion

J'espère avoir répondu aux questions que se posent souvent les débutants quand il s'agit de répondre à la problématique de la communication série, de l'interprétation et de l'exécution de commandes à distance.


Cordialement
Henri

7. Mises à jour
22/12/2019 : 1.2.1. binaire, hexadécimal et ASCII
                     1.2.2. Chaîne de caractères
                     3.5. Read (commandes compactes)
                     4.2. RUBY

Aucun commentaire:

Enregistrer un commentaire