Programmation Arduino

Après la présentation des microcontrôleurs quelques infos sur la programmation. Et pour des exemples de réalisation c’est par là.

Boucles et gestion des timers

Un programme Arduino est composé de deux fonctions :

void setup() {}
void loop() {}

La première setup est appelée une fois au début du programme et permet de réaliser les initialisations. La deuxième loop est appelée de façon répétée et contient les instructions permettant le fonctionnement dans le temps.

Dans la plupart des programmes on doit réaliser des vérifications ou faire des mises à jour à fréquence fixe (par exemple enregistrer la position GPS toutes les 5 secondes). Il y a deux façons de faire :

  • soit on met le processeur en pause pendant un certain temps
  • soit on mesure le temps écoulé depuis la dernière action pour vérifier s’il faut la réaliser à nouveau.

La première méthode de mise en pause est à éviter, elle empêche le bon fonctionnement de certaines librairies et devient compliquée si on a plusieurs actions en parallèle avec des fréquences différentes.

La bonne technique consiste à vérifier l’horloge système donnée  par millis().

#define DISP_REFRESH 5000L

uint32_t last_disp = 0;

void loop() {
  uint32_t curr = millis();

  if (curr - last_disp > DISP_REFRESH) {
    // Refresh display
    last_disp = curr;
  }
}

Je préfère utiliser une définition des constantes #define au début du programme pour les modifier sans devoir chercher dans le code. Ici 5000 correspond à 5000 millisecondes soit 5 secondes.

On définit une variable last_disp pour stocker le timestamp de dernière réalisation, elle est de type unint32_t car c’est le format retourné par millis() et on l’initialise bien à 0.

Ensuite dans la boucle loop() je n’appelle millis() qu’une seule fois au début et je stocke le timestamp courant dans une variable locale curr, cela permet de gérer plusieurs timers sans appels multiples à la fonction système.

Enfin on vérifie le temps écoulé depuis la dernière action et si il dépasse intervalle choisi on réalise l’action et on garde ce timestamp comme nouvelle dernière réalisation.

Avec ce modèle on peut facilement multiplier les timers, chacun nous coûtant seulement 4 octets et un if avec soustraction et comparaison.

Utiliser un GPS

Voici le code pour utiliser un module GPS, ici un NEO6M mais la librairie est compatible avec d’autres modules, à verifier quand même.

Il va nous falloir deux librairies :

#include <SoftwareSerial.h>
#include <TinyGPS++.h>

La première permet de communiquer en mode série sur deux ports, la deuxième gère les échanges avec le module GPS et l’interpréation des données.

Il faudra définir sur quels ports (numériques) est branché le GPS :

#define TX_PIN 3
#define RX_PIN 4

Et définir les variables de fonctionnement de ces librairies :

SoftwareSerial ss(RX_PIN, TX_PIN);
TinyGPSPlus gps;

Et aussi quelques variables pour récupérer les données GPS :

#define GPS_INTER 5000 // get pos every 5s
bool gpsFix = false; // true is GPS fix was acquired (thus we have date and position)
uint32_t lastCheck = 0; // last GPS check (used with GPS_INTER)
uint8_t satCount; // number of satellites for last pos
double lat, lng; // last queried position
uint32_t fixDate; // date retrieved on last GPS pos
uint8_t fixHr; // hour of last GPS pos
uint8_t fixMn;
uint8_t fixSc;

On initialise la librairie de communication série :

void setup() {
  ss.begin(9600);
}

Et voici le code GPS, je décris les détails plus bas :

void loop() {
  unsigned long cur = millis();

  // keep reading and interpreting GPS data
  while (ss.available() > 0) {
    gps.encode(ss.read());
  }

  // check GPS fix
  if (cur - lastCheck > GPS_INTER) {
    // try to get GPS info
    if (gps.location.isUpdated()) {
      satCount = gps.satellites.value();
      lat = gps.location.lat();
      lng = gps.location.lng();
      fixDate = gps.date.value();
      fixHr = gps.time.hour();
      fixMn = gps.time.minute();
      fixSc = gps.time.second();
      if (!gpsFix and satCount > 3) {
        // got a fix
        gpsFix = true;
        // NEW GPS FIX
      } else if (gpsFix and satCount <= 3) {
        // consider position invalid
        gpsFix = false;
        // UNRELIABLE GPS POS
      }
    } else {
      // could not get GPS fix
      if (gpsFix) {
        gpsFix = false;
        // LOST GPS FIX
      }
    }
    lastCheck = cur;
    if (gpsFix) {
      // USE GPS DATA
    }
  }
}

La première chose à faire est de consommer en permanence les données envoyées par le GPS et les donner à manger à la librairie TinyGPS++ qui va les interpréter.

Ensuite le code gère toute la partie acquisition et perte de signal GPS. SI on reçoit 3 satellites ou moins on considère aussi que la position n’est pas assez précise, vous pouvez modifier au besoin.

Dans ce bloc  vous pouvez réaliser des actions à la place des textes en majuscules qui correspondent respectivement à :

  • l’acquisition d’un signal – NEW GPS FIX
  • la détection d’un signal pas assez fiable (considéré comme perte) – UNRELIABLE GPS POS
  • la perte de signal – LOST GPS FIX.

Ces actions peuvent être de mettre à jour une interface ou fermer un fichier par exemple.

Enfin le code pour utiliser la nouvelle position GPS se situe au niveau de USE GPS DATA, avec le variables disponibles suivantes :

  • satCount – nombre de satellites reçus, permet d’avoir une idée de la précision
  • lat, lng – position calculée (note : on peut aussi obtenir l’altitude)
  • fixDate, fixHr, fixMn, fixSc – contienent la date et l’heure de la mesure.

Pour rappel dans cet exemple on récupère les données GPS toutes les 5 secondes (voir #define GPS_INTER 5000), le module NEO6M est normalement capable de le faire toutes les secondes donc on peut diminuer cet interval. Toutefois si on descend sous ce que le module peut fournir le code ci-dessus considérera que le signal est perdu car pas de nouvelle donnée disponible.

Utiliser un lecteur de carte SD

Les microcontrôleurs n’ont généralement pas de mémoire de stockage permanent de données, ou alors difficilement accessible et limitée en taille.

Pour certaines applications enregistrant des données un module SD permet de facilement enregistrer de grands volumes de données. Généralement les modules supportent les systèmes de fichier FAT32, assurez-vous de formater les cartes de cette façon. Avec des tailles de carte allant jusqu’à 32 Go, il y a de quoi logger un moment.

Ce module très standard utilise le bus SPI.

Il nous faut donc deux librairies :

#include <SPI.h>
#include <SD.h>

La première gère la communication et la deuxième la fonctionnalité SD.

On précise sur quel interface est branché le module (le Chip Select CS, en plus des ports SPI) :

#define SD_PIN 10
#define FLUSH_PERIOD 60000

Je définis en plus une période de flush forcé, nous y reviendrons.

On définit une variable de type File et un flag permettant de savoir si le fichier est ouvert :

File dataFile;
bool dataOpened = false; // true if data can be written to SD

Enfin on initialise le module :

void setup() {
  if (!SD.begin(SD_PIN)) {
    // Oops, could not initialize SD module, abort
  }
}

Si le code échoue pas la peine d’aller plus loin, je renseigne généralement un flag d’erreur générale pour prévenir l’utilisateur et on s’arrête là. Il faut bien sûr une carte SD insérée !

Voici la fonction d’ouverture d’un fichier pour écriture :

void openFile(char *filename) {
  if (dataFile = SD.open(fileName, FILE_WRITE)) {
    dataOpened = true;
  } else {
    // Oops, could not create file
  }  
}

Le fichier sera créé s’il n’existe pas, et s’il existe les données seront ajoutées à la fin, on peut donc ré-ouvrir sans risque un fichier existant.

Pour écrire c’est ultra simple :

dataFile.println(s);

s étant une chaîne de caractères (char *) ou une chaîne littérale « Ceci est une chaîne ».

Si vous voulez gérer le cas dans lequel on ne peut pas écrire mais que le programme va continuer il suffit de rendre l’écriture conditionnelle :

if (dataOpened) { dataFile.println(s); }

Deux point importants à prendre en compte :

  • Il vaut mieux forcer une écriture des données (flush) si vous laissez le fichier ouvert longtemps, car un cache d’optimisation limite les écritures physiques dans la mémoire flash et après un println les données ne sont pas forcément réellement écrites.
  • Il faut essayer de fermer proprement le fichier pour éviter les problèmes, donc prévoir une façon de réaliser cette fermeture avant coupure de l’alimentation.

Le code suivant assure une écriture régulière (ici toutes les minutes) des données pour ne pas en perdre en cas de coupure brutale du programme (genre perte d’alimentation) :

#define FLUSH_PERIOD 60000

uint32_t lastFlush = 0;

void loop() {
  unsigned long cur = millis();

  if (dataOpened and cur - lastFlush > FLUSH_PERIOD) {
    dataFile.flush();
    lastFlush = cur;
  } 
}

Enfin pour fermer proprement le fichier, par exemple suite à pression du bouton d’arrêt :

if (dataOpened) {
  dataFile.close();
  dataOpened = false;
}

Il suffit ensuite de sortir la carte SD pour lire les données.

Horloge temps réel (RTC)

Il nous faudra trois librairies :

#include <TimeLib.h>
#include <Wire.h>
#include <ds3231.h>

TimeLib permet de manipuler les dates et heures système, Wire sert à la communication série avec le module RTC et enfin ds3231 permet de contrôler le module.

On définit quelques paramètres :

#define RTC_REFRESH 3600000 // refresh every hour

Ici pour le timer de mise à jour à partir du module RTC (voir plus loin).

Et quelques variables :

struct ts rtc;
uint32_t last_rtc = 0;

Une structure qui sera renseignée avec l’heure lue (ou qui permet de la fixer) et un timestamp pour le timer.

L’initialisation du module est simple :

void setup() {
  Wire.begin();
  DS3231_init(DS3231_INTCN);
  DS3231_get(&rtc);
  // use RTC to set system time
  setTime(rtc.hour, rtc.min, rtc.sec, rtc.mday, rtc.mon, rtc.year);
}

On fait au passage une première lecture de l’heure et on l’utilise pour initialiser l’heure système. En effet les microcontrôleurs possèdent une horloge interne qui si elle est moins précise qu’un RTC n’est pas non plus complètement à jeter, il n’est pas nécessaire (et peu efficace) de lire l’heure du module RTC chaque fois qu’on en a besoin, il suffit d’initialiser l’heure système avec la valeur conservée dans le RTC et rafraîchir de temps en temps :

void loop() {
  uint32_t curr = millis();

  if (curr - last_rtc > RTC_REFRESH) {
    // update time from RTC
    DS3231_get(&rtc);
    setTime(rtc.hour, rtc.min, rtc.sec, rtc.mday, rtc.mon, rtc.year);
    last_rtc = curr;
  }
}

Mais comment initialiser l’heure du module RTC ? On peut charger un programme pour fixer date et heure, ensuite le module doit conserver l’heure correcte grâce à sa pile bouton, même alimentation coupée.

Voici à quoi ressemble le setup pour choisir date et heure :

void setup() {
  Wire.begin();
  DS3231_init(DS3231_INTCN);
  rtc.year = 2020;
  rtc.mon = 8;
  rtc.mday = 22;
  rtc.hour = 20;
  rtc.min = 8;
  rtc.sec = 10;
  DS3231_set(rtc);
}

LEDs RGB WS2812

Ces LED extrêmement pratiques sont reliées en série et ne nécessitent que 3 broches : 5V, masse et signal. Le signal est assez complexe et permet d’adresser les LED individuellement et choisir les 3 niveaux rouge vert bleu.

J’utilise la librairie FastLED :

#include <FastLED.h>

Elle nécessite quelques éléments de configuration :

#define LED_PIN 7
#define LED_TYPE WS2812B
#define COLOR_ORDER GRB
#define NUM_LEDS 6
#define MAX_AMPS 1000

On définit l’interface utilisée pour le signal de pilotage des LED et le nombre de LEDs en série.

Côté variables il nous faut un tableau pour stocker les valeurs RGB des LEDS :

CRGB leds[NUM_LEDS];

La librairie nécessite une initialisation :

void setup() {
  FastLED.addLeds<LED_TYPE, LED_PIN, COLOR_ORDER>(leds, NUM_LEDS);
  FastLED.setMaxPowerInVoltsAndMilliamps(5, MAX_AMPS);
}

On y spécifie le type des LED (ici WS2812B qui est le plus courant), la broche utilisée pour le signal, l’ordre des couleurs GRB (et non RGB), le tableau que l’on a préparé et le nombre de LEDs à piloter.

On peut aussi, et c’est une bonne idée pour ne pas cramer une alimentation, définir l’intensité maximale du courant, FastLED se chargera de limité l’intensité totale des LEDs pour ne pas dépasser la valeur fixée. Ici 1000 mA d’une petite alimentation de téléphone. Avec 6 LEDs pas trop de risque, mais une LED RGB à pleine intensité c’est 60 mA, si on en a 60 on peut tirer jusqu’à 3,6 A.

Ensuite allumer les LED est un jeu d’enfant, il suffit de mettre les bonnes valeurs dans le tableau et d’appeler :

FastLED.show();

On évite d’appeler show à chaque exécution de loop, donc ici aussi un timer est une bonne idée :

#define DISP_REFRESH 1000L

uint32_t last_disp = 0;

void loop() {
  unsigned long cur = millis();

  // update display
  if (curr - last_disp > DISP_REFRESH) {
    FastLED.show();
  }
}

Bien sûr en fonction de l’application on pourra choisir des périodes beaucoup plus courtes, de l’ordre de quelques dizaines de ms.

Plusieurs fonctions permettent de choisir les couleurs des LEDs, voici des exemples en vrac :

leds[0] = CRGB::Green // first LED set to Green (0, 255, 0)
leds[1] = CRGB(255, 255, 0); // second LED set to yellow
leds[2] = CHSV(192, 255, 0); // third LED set to purple

// clear all LEDs
fill_solid(&(leds[0]), NUM_LEDS, CRGB::Black);

On peut aussi travailler en CHSV (Hue Saturation Value) avec FastLED et faire plein d’autres choses, consultez la documentation.

Capteur de température

Voici le code nécessaire pour lire température et humidité d’un capteur de type DHT22, précis à 0,5 degrés.

On aura besoin d’un seule librairie (qui supporte d’autres modèles) :

#include <DHT.h>

On définit quelques constantes :

#define DHTPIN 2
#define DHTTYPE DHT22
#define TEMP_REFRESH 60000L

L’interface sur laquelle le capteur est branché, le type de capteur et le classique timer pour rafraîchir la valeur lue.

On a besoin de quelques variables :

DHT dht(DHTPIN, DHTTYPE);
uint32_t last_temp = 0;
float humi = 0;
float temp = 0;

Un timestamp pour le rafraichissement et deux variables pour les valeurs de température et d’humidité lues.

On initialise et on lit une première valeur :

void setup() {
  dht.begin();
  delay(1000); // let DHT initialize
  humi = dht.readHumidity();
  temp = dht.readTemperature();
}

Exceptionnellement j’utilise un delay pour laisser le temps au DHT d’être prêt à fournir un valeur, mais on est dans le setup alors c’est autorisé 😉

Ensuite on peut rafraîchir régulièrement ces valeurs :

void loop() {
  uint32_t curr = millis();

  if (curr - last_temp > TEMP_REFRESH) {
    humi = dht.readHumidity();
    temp = dht.readTemperature();
    last_temp = curr;
  }
}

Ajuster la luminosité

Pour ajuster automatiquement la luminosité de l’affichage de mes montages j’utilise simplement une photo résistance en pont de résistances (avec une autre résistance) et je lis la tension sur une entrée analogique du microcontrôleur.

Je définis les valeurs initiales minimale, moyenne et maximale :

#define AMBIANT_PIN 0
#define AMBIANT_MIN 300
#define AMBIANT_AVG 600
#define AMBIANT_MAX 900
#define AMBIANT_REFRESH 5000L

Ainsi que l’interface sur laquelle est lue la valeur analogique et un timer de rafraichissement.

Quelques variables initialisées avec les constantes définies :

uint32_t last_ambiant = 0;
uint16_t ambiant_cur = AMBIANT_AVG;
uint16_t ambiant = AMBIANT_AVG;
uint16_t ambiant_min = AMBIANT_MIN;
uint16_t ambiant_max = AMBIANT_MAX;

Et le code permettant de gérer tout ce petit monde. L’idée est de garder les valeurs minimales et maximales mesurées pour s’adapter à n’importe quel usage et utiliser toute la plage de luminosité. Les changements sont aussi amortis pour éviter les variations violentes désagréables.

void loop() {
  uint32_t curr = millis();

  if (curr - last_ambiant > AMBIANT_REFRESH) {
    // measure ambiant
    ambiant = analogRead(AMBIANT_PIN);
    if (ambiant < ambiant_min) { ambiant_min = ambiant; }
    if (ambiant > ambiant_max) { ambiant_max = ambiant; }
    // adjust current ambiant correction
    if (ambiant_cur > ambiant) {
      ambiant_cur -= (ambiant_cur - ambiant) / 2;
    } else {
      ambiant_cur += (ambiant - ambiant_cur) / 2;
    }
    last_ambiant = curr;
  }
}

Pour corriger la luminosité j’utilise une fonction comme suit :

byte adjust_ambiant(byte val) {
  byte adj = (uint32_t)val * (ambiant_cur - ambiant_min) / (ambiant_max - ambiant_min);
  if (adj == 0) {
    return 1;
  } else {
    return adj;
  }
}

Je m’assure de retourner une valeur minimale de 1. Ce code peut être utilisé directement pour moduler la valeur d’une couleur HSV :

leds[63] = CHSV(0, 255, adjust_ambiant(20));