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

vendredi 13 décembre 2019

MOSFETs : utilisations inhabituelles



MOSFETs : utilisations inhabituelles


Dans cet article nous allons parler de cas d'utilisation peu habituels de transistors MOSFETs.

Le but n'est pas de refaire un Nième tutoriel sur les MOSFETs utilisés en commutation avec des exemples simplistes, utilisant le plus souvent un MOSFET canal N :
Commande d'un relais 5V
Commande d'un ruban de LEDs

Les tutoriels basiques sur les MOSFETs se trouvent à la pelle.

S'il s'agit d'activer un relais ou un moteur, ce sujet a déjà été discuté ici :
https://riton-duino.blogspot.com/2018/08/alimenter-un-relais-transistor.html

Le but de cet article est plutôt d'explorer quelques situations moins courantes, en particulier l'utilisation de MOSFETs canal P en commutation d'alimentation ou en tant que diode idéale.

1. Recommandation

Lorsque l'on dessine des schémas comportant des MOSFETs il est important de choisir des symboles schématiques adaptés, incluant la diode inverse du composant.
Cela permet de mieux visualiser les chemins de passage possibles pour le courant, en fonction des tensions présentes.

2. Le MOSFET en commutation d'alimentation

Commençons par la plus simple des utilisations, et la plus habituelle.

Lorsque l'on veut commuter une alimentation on utilise un MOSFET canal P comme ceci :

2.1. Fonctionnement

Un MOSFET canal P entre en état de conduction si l'on applique une tension négative suffisante entre grille et source.
Négative ne veut pas dire négative par rapport au 0V, mais négative par rapport à la tension sur la source. Donc un ZÉRO volts est considéré comme négatif si l'on prend comme référence la tension sur la source.

Il suffit donc d'appliquer un ZÉRO volts sur l'entrée de commande P1 pour que le transistor Q1 conduise, et une tension de 5V sera présente sur la sortie.

La tension de sortie sera égale à la tension d'entrée, légèrement inférieure en fait, en fonction de la résistance RDSon du MOSFET et du courant.

Afin de pouvoir être commandé par une tension inférieure à 5V, le MOSFET sera du type logic-level. Sa tension de grille VGSth doit être de préférence inférieure à 3V.

Ici le transistor AOI403 a une résistance RDSon de 12mΩ pour une tension VGS de 5V et un courant de 20A.

Voir ici : https://riton-duino.blogspot.com/2019/01/mosfets-de-puissance.html

2.2. Explications

Le transistor est piloté par une sortie du microcontrôleur à travers une résistance de 220Ω dont le but est de limiter le courant d'appel lors de la montée du signal sur la grille (la grille se comporte comme un condensateur). Une résistance de pull-down de 100KΩ est câblée entre grille et source afin de ne pas laisser la grille en l'air dans le cas où la sortie du microcontrôleur est en haute impédance, au démarrage par exemple.

Pourquoi ne pas utiliser un transistor PNP ?
Parce qu'un transistor PNP entraînerait une chute de tension plus importante qu'un MOSFET.

Pourquoi ne pas utiliser un MOSFET canal N ?
Parce qu'un MOSFET canal N réclamerait une tension VGS positive pour entrer en état de conduction, 9V par exemple.

2.3. Limitations

Ce montage comporte un inconvénient. Si l'on applique une tension de 5V sur la sortie, même si le MOSFET est coupé, la diode interne laissera passer le courant vers l'entrée.
Cela peut être gênant par exemple si la tension d'entrée est une batterie ou un panneau solaire, et que la tension de sortie est fournie par défaut par un bloc secteur :

Dans cet exemple si la tension de 5V est présente, même si le MOSFET est coupé, la batterie recevra un courant provenant du 5V, à travers la diode interne. Ce courant n'étant pas maîtrisé, cela peut être dangereux pour la batterie.

3. Le MOSFET en montage inversé

3.1. Fonctionnement

La diode interne du MOSFET laisse passer le courant de l'entrée vers la sortie, mais elle a une tension de chute directe importante.
Appliquer un ZÉRO volts sur l'entrée de commande P1 fait entrer le transistor Q1 en état de conduction et comme il a une résistance RDSon faible, permet de réduire la chute de tension due à la diode.

3.2. Explications

Un MOSFET est bidirectionnel. Le courant passe aussi bien de la source vers le drain que dans le sens inverse.
Au départ la tension sur la source est égale à la tension de drain, moins quelques centaines de millivolts (chute de tension de la diode). La source est donc à un potentiel permettant d'obtenir une tension VGS importante si la grille est commandée par un niveau bas.

3.3. Avantages

Une diode Schottky SR540 5A par exemple provoquerait une chute de tension importante :
Pour 0.5A
Vf = 220mV
Pour 2A :
Vf = 300mV

Un MOSFET du type AOI403, avec ses 12mΩ de résistance provoquera une chute plus faible :

Pour 0.5A
Vf = 12mΩ x 0.5A = 6mV
Pour 2A :
Vf = 12mΩ x 2A = 24mV

Lorsque l'on a affaire à des tensions d'alimentation faibles (3.3V, 3.7V, 5V), c'est loin d'être négligeable.

Outre le fait que ce montage permette de gagner quelques centaines de millivolts en sortie, il permet également de dissiper beaucoup moins de calories qu'une simple diode.

Lorsque l'on a affaire à un courant de 1A, 0.25V de chute de tension dans une diode entraîne une puissance de 0.25W à dissiper.
Avec 10A, la chute de tension sera de 0.6V, la puissance sera de 6W, ce qui nécessite un dissipateur assez conséquent, et l'utilisation d'une diode en boîtier TO220 :


Le MOSFET n'aura qu'une puissance faible à dissiper :

P = R x I² = 12mΩ x 10² = 1.2W

Le dissipateur sera beaucoup plus petit et il est fort probable que souder le drain sur un plan de cuivre de quelques cm² suffise.

3.4. Limitations

Ce montage ne se comporte pas comme un interrupteur, puisque lorsque le MOSFET est coupé, le courant passe par la diode.
De plus, comme le transistor est conducteur dans les deux sens, si le MOSFET est en état de conduction et que l'on applique une tension sur la sortie et que cette tension est supérieure à celle de l'entrée, le courant passera en direction de l'entrée.

Cette technique est cependant utilisée comme semi diode commandée à faible chute de tension dans certains montages, mais le circuit se comporte comme une vraie diode seulement dans le cas où le MOSFET est coupé :

Le circuit ci-dessus remplace celui-ci :

4. Le MOSFET en montage dos à dos (back to back)

4.1. Fonctionnement

Les diodes internes des MOSFETs ne laissent pas passer le courant de l'entrée vers la sortie, ni dans le sens inverse, car elles sont montées tête-bêche.
Appliquer un ZÉRO volts sur l'entrée de commande P1 permet de rendre les transistors Q1 et Q2 passants.

4.2. Explications

Au départ la tension sur la source de Q1 est égale à la tension de drain, moins quelques centaines de millivolts (chute de tension de la diode). La source est donc à un potentiel permettant d'obtenir une tension VGS importante si la grille est commandée par un niveau bas.
A partir du moment ou Q1 est en état de conduction, la tension sur la source de Q2 est également à un potentiel permettant d'obtenir une tension VGS importante si sa grille est commandée par un niveau bas.

La résistance RDSon du transistor est doublée bien entendu.

Contrairement au montage précédent Ce montage se comporte comme un interrupteur, puisque lorsque le MOSFET est coupé, le courant ne passe pas par les diodes.

4.3. Limitations

Comme dans le montage précédent, si les MOSFET sont en état de conduction et que l'on applique une tension sur la sortie et que cette tension est supérieure à celle de l'entrée, le courant passera en direction de l'entrée.

5. Le MOSFET en diode parfaite

5.1. Driver à pompe de charge

Ici un MOSFET canal N est utilisé, commandé par un LTC4357, qui permet de fournir à la grille du MOSFET une tension supérieure à sa tension de source grâce à une pompe de charge (élévateur de tension).
Lorsque la tension de drain est supérieure à la tension de source, le LTC4357 coupe le MOSFET.

On obtient donc un circuit équivalent à une diode idéale, dont la chute de tension dépend uniquement de la résistance RDSon du MOSFET.

Certains circuits permettent d'utiliser des MOSFETs canal N en high-side, ce qui offre un choix beaucoup plus large. D'autres permettent de piloter des MOSFETs canal P.
Un bon nombre de ces composants intègrent le MOSFET.

Attention à la tension d'alimentation.
Un LTC4357 devra être alimenté entre 9V et 80V, un LTC4353 se contentera de 2.9V à 18V.

Analog Devices propose un choix de drivers de MOSFETs assez large, Texas Instruments également.
Ces circuits sont relativement chers et existent seulement en CMS.

Cela va du simple driver de diode idéale à des petits monstres fort sympathiques comme le LTC3118 :  sélection automatique de deux sources de puissance, avec convertisseur DC/DC BUCK BOOST intégré (sortie=2A) :

https://www.analog.com/cn/technical-articles/18v-buck-boost-converter-with-intelligent-power-path-control.html

5.2. Diode idéale maison

Cette solution provient de cette page :
https://www.electro-tech-online.com/articles/simple-inexpensive-ideal-diode-mosfet-circuits.817/

Sur ce schéma un MOSFET canal P est utilisé, ainsi qu'un miroir de courant.
Si VIN est supérieure à VOUT, cela déséquilibre le miroir et grâce à la différence entre les tensions d'émetteur Q3 est bloqué. La grille du MOSFET se retrouve à un potentiel proche de 0V à travers la résistance R2, et celui-ci entre en conduction.

Si VOUT est supérieure à VIN, Q3 devient passant et la grille du MOSFET se retrouve à un potentiel proche de VOUT, et se trouve donc bloqué.

Ce circuit marche parfaitement pour une tension VIN de 3.3V. Par contre VOUT ne devra pas dépasser 3.3 + 5V, 5V étant la tension VBE inverse du 2N3906.

Je l'ai essayé. Il provoque une chûte de tension de 40mV pour un courant de 500mA. Dans les mêmes condition une très bonne diode Schottky du type SR540 (5A) provoquerait une chûte de 320mV, donc 8 fois plus importante.

ATTENTION : les deux transistors doivent être appairés pour que le circuit fonctionne correctement. Leur tension VBE doit être identique à quelques millivolts près.
Il est facile de trouver dans le même lot de transistors deux exemplaires qui conviennent. Les deux premiers que j'ai choisi dans le tiroir satisfaisaient ce critère (2mV de différence).
Voici un petit outil bon marché et très précis, bien utile pour tester :

https://riton-duino.blogspot.com/2019/12/testeur-de-composants-le-gm328.html

Sinon on peut acheter des DMMT3906W, doubles 2N3906 appairés (CMS), à un prix très abordable.

Pour tester le circuit une tension de 3.3V est appliquée sur VIN et une tension de 5V est appliquée sur VOUT toutes les secondes, pendant 500ms. Une charge de 10Ω est branchée sur VOUT :


En jaune : On voit bien que VOUT varie entre 3V et 5V.
En bleu : VIN varie entre 3.3V et 3V.

La chute de tension (3V au lieu de 3.3V) n'est pas due à un défaut du circuit, mais au fait que lorsque l'alimentation branchée sur VOUT est actionnée elle fournit tout le courant à la charge, et l'alimentation branchée sur VIN ne débite rien.
Quand l'alimentation branchée sur VOUT est coupée, l'alimentation branchée sur VIN débite 300mA dans la charge, ce qui fait chuter la tension, à cause des fils de section 0.5mm2 et 50cm de long que j'ai utilisé.

La conception de ce genre de circuit implique de dimensionner correctement les pistes de circuit imprimé et le câblage, sinon les pertes seront importantes.

5. Conclusion

J'espère que ce petit article aura fait germer quelques idées et pourra vous éviter certaines erreurs.


Cordialement
Henri

6. Mises à jour

12/03/2020 : correction schéma diode idéale