LOCODUINO

Identification des trains par infrarouge 38 kHz

.
Par : bobyAndCo

DIFFICULTÉ :

L’identification des trains sur un réseau autorise un niveau élevé d’automatisation et contribue à la sécurité du matériel et des équipements.

Avec l’identification, on parle d’un niveau supérieur à la détection de présence. Cette dernière est importante, mais non suffisante lorsque l’on veut agir directement sur le comportement d’une locomotive ou un d’un train en particulier.

Nous avons déjà eu l’occasion de présenter l’identification par RFID et aussi par Railcom®. L’identification par infrarouge entre dans ce petit club très restreint.

C’est un fil du forum initié par JPM06 qui m’a donné l’envie de développer ce système complet et éprouvé avec cette technique que je n’avais pas encore approchée. Tout au moins sous l’angle de l’identification.

La technique de l’infrarouge est beaucoup utilisée dans les télécommandes d’appareils électroniques, notamment les téléviseurs où elle permet d’envoyer des ordres codés (volume, chaîne, etc.) par impulsions lumineuses invisibles.

JPEG - 206.6 kio

Elle est également employée dans des systèmes de transfert de données entre appareils (comme les anciens téléphones ou imprimantes IRDA) ou encore des systèmes d’identification.

La communication par infrarouges est utilisée principalement par ce qu’elle est relativement peu sensible à l’environnement lumineux ambiant et aussi par ce qu’elle met en œuvre des techniques très économiques. D’un autre point de vue qui ne nous concerne pas dans notre hobby, c’est une technique d’échange d’informations très sûre car il n’est pas possible de traverser par exemple des cloisons. Cela reste donc restreint à des espaces réduits.

Le principe :

Le rayonnement infrarouge a été découvert en 1800 par l’astronome britannique Sir William Herschel. Dans le domaine de la communication, l’utilisation de l’infrarouge permet d’éviter les perturbations des autres fréquences électromagnétiques, en particulier la lumière visible à l’œil comme les rayonnements du soleil ou l’éclairage ambiant.

PNG - 28.7 kio
Figure 1 - Les spectre lumineux à l’intérieur des différentes longueurs d’ondes.
Crédit : https://briques-de-geomatique.readt... Licence : licence CC-BY-NC-SA

La technique :

Dans notre cas, on utilise une led spéciale à laquelle on envoie une signal électrique modulé (PWM) à raison d’une alternance toutes les 13µs (micro secondes). Soit un cycle complet toutes les 26µs, ce qui, répété 38 000 fois, équivaut (presque) à une seconde ; 988 millisecondes exactement.

Pour la réception, on utilise un composant qui est conçu pour ne réagir que lorsqu’il reçoit des signaux dont la fréquence est proche de 38kHz.

L’une des particularités du récepteur est de produire un autre signal, linéaire cette fois, et à l’état haut tant que l’émetteur génère des ondes à 38 kHz. Quand l’émetteur cesse d’émettre, le récepteur ne produit plus de signal ou un signal linéaire à l’état bas. En réalité, pour le TSOP4838 utilisé l’état de sortie est inverse : Haute lorsqu’il ne détecte rien / Basse ( 0V) lorsqu’il détecte une modulation IR valide à 38 kHz. Mais le principe est similaire.

Ceci est illustré par la figure 2 ci-dessous :

PNG - 35.3 kio
Figure 2 - En bleu le signal à la sortie de l’émetteur / En jaune, le signal en sortie du récepteur

Transmettre des données par codage du signal :

Il apparait assez vite qu’organiser cette alternance de signaux courts ou longs et d’espacements eux aussi plus ou moins longs pouvait constituer une technique de communication comparable au Morse. (Figure 3)

JPEG - 44.3 kio
Figure 3 - L’alphabet en Morse

En adoptant un protocole déterminé, le système autorise la transmission de données reposant sur l’envoi d’une succession de bits 0 ou 1.

On peut par exemple convenir dans le protocole qu’un signal actif correspond à un bit 1 (état haut) et un signal inactif à un bit 0 (état bas). Mais cela n’est pas suffisant car se pose la question de deux ou plusieurs bits successifs à 1 ou à 0. Il est donc nécessaire d’introduire une dimension temporelle pour distinguer chaque bit.

On retrouve ce principe pour le codage du DCC où les bits 1 ont une durée de 58µ secondes environ et les bits 0 une durée supérieure à 100µ secondes.

C’est un principe similaire que j’ai retenu comme protocole pour lequel une durée entre deux fronts identiques (montants ou descendants) d’une durée approximative de 560µs sera interprétés comme un bit à 1. (Figure 4)

PNG - 35.3 kio
Figure 4 - bit 1

Une durée entre deux fronts identiques (montants ou descendants) d’une durée approximative de 1000µs sera interprétée comme un bit à 0. (Figure 5)

PNG - 34.6 kio
Figure 5 - bit 0

Enfin, une durée entre deux fronts identiques (montants ou descendants) d’une durée approximative de 2000µs sera interprétée comme bit de synchro qui permet de repérer le début des bits de datas qui vont suivre. (Figure 6)

PNG - 36.1 kio
Figure 6 - bit de synchro

En utilisant les interruptions matérielles avec un microcontrôleur (Arduino, Raspberry Pi Pico, ESP32…) il est très facile de mesurer les durées respectives de chacune de ces alternances et donc de décoder les messages. Nous verrons cela quand nous aborderons la programmation.

Voici ci-dessous la représentation à l’oscilloscope de l’octet 0xAA ou 10101010 en binaire. (Figure 7)

PNG - 35.6 kio
Figure 7 - L’octet 0xAA vu à l’oscilloscope

Dans ce protocole que j’ai créé adhoc, la durée moyenne pour la transmission d’un octet est de 6 à 7 ms. Si l’on ajoute 2 ms pour le bit de synchro, il faut au total en moyenne 8 à 9 ms pour transmettre un identifiant de train (ou l’adresse DCC d’une locomotive). Soit environ 110 envois par seconde ce qui rend l’identification particulièrement fiable.

Le matériel :

Pour la LED émettrice, j’ai choisi (Figure 8) la référence TSAL6400 de VISHAY achetée pour 0,31€ (par 5) chez TME.

JPEG - 87.9 kio
Figure 8 - TSAL6400 de VISHAY

Pour la version CMS (Figure 9) chez TME et sa datasheet :

PNG - 46.1 kio
Figure 9 - LED cms VSMB2020X01 de VISHAY

Pour le récepteur, j’ai aussi choisi la marque VISHAY, le modèle TSOP4838 (Figure 10) à 0,64€ (par 10) là aussi chez TME et dont vous trouverez ici la datasheet.

PNG - 256.4 kio
Figure 10 - TSOP4838 de VISHAY

Je dois avouer que j’ai été impressionné par les performances de l’un et de l’autre. Pas de problème de distance même à 10 centimètres ou plus et surtout, un angle de détection de plus de 25° de chaque côté per rapport à son axe ce qui donne 50° au total. Ce que l’on peut voir sur la figure 11 ci-dessous.

PNG - 196.4 kio
Figure 11 - Diagramme d’action de la led TSAL6400 combinée au récepteur TSOP4838

Du côté émetteur (Figure 12) comme du côté récepteur (Figure 13), un microcontrôleur est nécessaire.

PNG - 529.5 kio
Figure 12 - Schéma de l’émetteur

Pour l’émetteur, un ATTiny 25 ou 45 suffit qui est par ailleurs privilégié pour son faible encombrement. On peut aussi utiliser un Arduino Nano, plus simple à programmer qu’un ATtiny. Cela, particulièrement pendant la phase de tests où l’on peut avoir à téléchargement plusieurs fois le programme.

PNG - 165.9 kio
Figure 13 - Schéma du récepteur

La partie électronique des récepteurs se limite à trois composants, la led réceptrice, un condensateur et une résistance. Un même microcontrôleur peut collecter les informations de plusieurs récepteurs. Jusqu’à seize dans l’exemple de code présenté plus loin.

Un Arduino Nano pour générer le signal sur l’émetteur :

Pour réaliser mes tests, j’ai utilisé un Arduino Nano dont la programmation est identique à celle d’un ATtiny 25/45/85. Le Nano, quoiqu’« encombrant », peut constituer une alternative pour tous ceux que la programmation d’ATtiny pourrait rebuter.

JPEG - 1.6 Mio
JPEG - 1.9 Mio
JPEG - 221.3 kio
Figure 16 - Le wagon avec à sa gauche sous la voie le récepteur.
JPEG - 83.5 kio
Figure 17 - Le plancher du wagon est percé d’un trou au diamètre 5 mm et le courant d’alimentation est capté sur les essieux inversé pour la polarité.

Figure 17 - La led émettrice traverse le plancher du wagon. Des palpeurs sont installés sur les essieux pour collecter chacune des phases du courant DCC qui sert à l’alimentation électrique de l’ensemble. Vérifiez que les bagues isolantes soient bien inversées pour que chaque essieu capte une phase différente du signal DCC.

Figure 18 - Voici une led CMS montée sous la locomotive :

JPEG - 28.6 kio
Figure 18 - Led CMS placée sous la locomotive
Crédit : https://www.persmodelrailroad.com/te.html

et l’électronique de l’émetteur dans la cabine de la locomotive. Figure 19.

JPEG - 42.7 kio
Figure 19 - L’électronique de l’émetteur placée la locomotive
Crédit : https://www.persmodelrailroad.com/te.html

Le courant DCC est redressé au travers d’un simple pont de diodes, la tension est abaissée à 5 volts au travers d’un régulateur. Un gros condensateur compense les microcoupures et assure le lissage de la tension. Figure 20

PNG - 498.6 kio
Figure 20 - Schéma électronique pour l’émetteur à base d’Arduino Nano.

Voici Figure 21 le PCB pour l’Arduino Nano :

JPEG - 1.2 Mio

Et les fichiers Gerber pour la fabrication :

Téléchargement des fichiers Gerber pour Arduino Nano :

Cliquez sur l’icône pour lancer le téléchargement.


L’ATtiny pour un émetteur à l’encombrement réduit :

Pour l’usage que nous en avons, l’ATtiny 25/45 ou 85 dispose de caractéristiques équivalentes à celles d’un Arduino. Rien à craindre côté performances. Il permet de réduire sensiblement l’encombrement mais aussi le coût de fabrication des récepteurs. Un ATtiny de la série 25/45 ou 85 coute entre 1,5 et 2€.

PNG - 284.7 kio
Figure 22 - Schéma électronique pour l’ATtiny avec composants traversants.

Voici le PCB pour ATtiny - Figure 23 :

JPEG - 1.6 Mio

Et les fichiers Gerber pour la fabrication :

Téléchargement des fichiers Gerber pour ATtiny traversant :

Cliquez sur l’icône pour lancer le téléchargement.


Une version ATtiny partiellement en CMS :

Pour finir avec les émetteurs, voici un montage pour lequel certains des composants sont remplacés par leur version CMS – Figure 24.

PNG - 452 kio
Figure 24 - Schéma électronique pour l’ATtiny et quelques composants en CMS
PNG - 829.7 kio
Figure 25 - Le rendu en 3D du PCB en CMS

La led à droite est vraiment petite et pourra être discrètement fixée sous la locomotive ou le wagon. Pour vous donner une idée du rapport de taille, le PCB mesure lui seulement 22 mm x 19 mm.

En aparté, je voudrais préciser que la programmation d’un ATtiny est beaucoup plus simple que l’idée que l’on s’en fait peut-être.

Comme j’ai déjà eu l’occasion de le souligner, dans la plupart des cas, la programmation est identique à celle d’un Arduino. Un Uno ou un Nano seront préférés pendant la phase de mise au point du programme. Pour le chargement final, Christian dans son article explique très bien comment procéder en utilisant un Arduino Uno.

Pour les plus convaincus, vous pouvez également vous équiper d’un programmateur Locoduino

PNG - 334.4 kio
Figure 26 - Programmateur pour microcontrôleurs ATtiny de Locoduino

J’ai eu la chance de pouvoir disposer des différents composants au moment de la réalisation de ce projet et j’en suis vraiment très satisfait.

Pour les ATtiny CMS, il n’y a pas d’autre possibilité que de s’équiper de ce type d’adaptateur – Figure 27 :

JPEG - 154.9 kio
Figure 27 - Support pour la programmation d’un composant CMS
Téléchargement des fichiers Gerber pour ATtiny CMS :

Cliquez sur l’icône pour lancer le téléchargement.


PNG - 35.6 kio
BOM pour PCB ATtiny CMS

Le récepteur :

Côté réception maintenant, le nombre de composants est réduit. Le PCB qui trouvera sa place sous le réseau est de petite taille.

PNG - 363.4 kio
Figure 28 - Schéma électronique pour le récepteur (ne concerne que la partie encadrée en rouge).

Sur le schéma figure 28, le composant se limite au cadre rouge en bas à gauche. Il est ici représenté raccordé à un Raspberry Pi Pico pour visualiser les liaisons avec celui-ci. Il faut noter qu’un seul Raspberry Pi Pico peut en principe recevoir les données d’au moins seize récepteurs.

Au-delà de cinq récepteurs, il est préférable de les relier à une alimentation externe de 3,3 volts même si la consommation moyenne du TSOP4838 est donnée pour 0,35mA sous 3,3 volts. Voir ce point très important de ce que l’on peut alimenter dans l’article de Jean-Luc.

PNG - 105.2 kio
Figure 29 - Vue du PCB du récepteur.
JPEG - 1 Mio
Figure 29 bis - Le composant monté

Et les fichiers Gerber pour la fabrication :

Téléchargement des fichiers Gerber le récepteur :

Cliquez sur l’icône pour lancer le téléchargement.


NB : Dans les différents schémas ci-dessus, il est utilisé indistinctement des diodes 1N4005 et 1N4007 qui présentent des caractéristiques similaires.

Figure 30 - Voici la carte à base de Raspberry Pi Pico qui m’a servie pour tester le programme pour relier 16 capteurs infrarouge. Au besoin, plusieurs cartes peuvent être reliées entre elles. Le bus CAN est relié à Rocrail au travers d’une passerelle TCP. Il s’agit ici d’un Raspberry sans wifi. Contrairement au Raspberry utilisé pour le dernier programme qui lui est un modèle wifi et qui permet de se connecter directement au serveur TCP de Rocrail.

Je sais que de son côté, Dominique travaille également sur des satellites à base de Raspberry Pi Pico qui seront également capable de faire remonter via un bus CAN les identifiants à une centrale.

PNG - 16.9 Mio
Figure 30 - Décodeur CAN pour 16 détecteurs infrarouge. Raspberry sans wifi.

Après le matériel, voyons maintenant les programmes.

Les programmes :

Côté émetteur :

Téléchargement du programme pour Arduino (Uno, Nano...) et ATtiny (25, 45, 85) :

Cliquez sur l’icône pour lancer le téléchargement.


Voyons tout d’abord l’intégralité du code :

/*
    idTrainIr38kHz_emetteur

    Programme pour générer un identifiant en utilisant un émetteur et un récepteur infrarouge

    Programme pour Arduino (Uno, Nano...) et ATtiny (25, 45, 85)

    Christophe Bobille pour Locoduino mai 2025

     version 1.1

*/


#include <Arduino.h>

// Configuration du timer pour PWM IR
#  if defined(__AVR_ATmega328P__) || defined(__AVR_ATmega328PB__) || defined(__AVR_ATmega168__)
#define IR_USE_AVR_TIMER2  // Utilise Timer2 pour l'envoi (pin D3)
#define IR_SEND_PIN 3                   // Broche utilisée
#  elif defined(__AVR_ATtiny25__) || defined(__AVR_ATtiny45__) || defined(__AVR_ATtiny85__)
#define IR_SEND_PIN 4                   // Broche utilisée
#define IR_USE_AVR_TIMER_TINY1 // send pin = (pin 4) ATtiny25 / 45 / 85
#  endif

#define SEND_PWM_BY_TIMER  // Active le mode PWM matériel

#include "IRTimer.hpp"  // Part of Arduino-IRremote https://github.com/Arduino-IRremote/Arduino-IRremote.


constexpr uint16_t ID = 0xA5;            // Identifiant à envoyer
constexpr uint8_t sizeofId = 8;           // Taille de l'identifiant
constexpr uint16_t BIT_DURATION = 250;   // Durée minimale d'un front en µs

// Envoie un octet codé via LED IR
void sendByte(uint16_t value) {
  for (int i = sizeofId - 1; i >= 0; i--) {        // MSB
    bool bit = (value >> i) & 0x01;
    if (bit) {
      // 1
      disableSendPWMByTimer();
      delayMicroseconds(BIT_DURATION);
      enableSendPWMByTimer();
      delayMicroseconds(BIT_DURATION);
    } else {
      // 0
      disableSendPWMByTimer();
      delayMicroseconds(BIT_DURATION * 2);
      enableSendPWMByTimer();
      delayMicroseconds(BIT_DURATION * 2);
    }
  }
  // Fin de trame
    disableSendPWMByTimer();
    delayMicroseconds(BIT_DURATION * 4);
    enableSendPWMByTimer();
    delayMicroseconds(BIT_DURATION * 4);
}

void setup() {
  pinMode(IR_SEND_PIN, OUTPUT);
  timerConfigForSend(38);   // Fréquence IR : 38 kHz
  disableSendPWMByTimer();  // Démarre éteint
}

void loop() {
  sendByte(ID);
}

Comme j’ai déjà eu l’occasion de le préciser, le programme de l’émetteur est identique qu’il soit destiné à un Arduino Nano ou un ATtiny.

Cela est rendu possible par l’intégration du fichier IRTimer.hpp de la bibliothèque IRremote qui gère la paramétrage (complexe) des broches et de PWM. C’est lui également qui expose les fonctions enableSendPWMByTimer() et disableSendPWMByTimer() qui permettent respectivement d’activer ou de désactiver l’envoi de la PWM.

Attention : Il n’est pas nécessaire de d’inclure la bibliothèque IRremote entière mais n’oubliez pas le fichier IRTimer.hpp qui est déjà dans le dossier du programme.

La seule information que l’utilisateur aura à saisir dans le code est l’identifiant à envoyer ligne 31 :

constexpr uint16_t ID = 0xA5;            // A modifier par l'utilisateur en fonction de l'identifiant souhaité

constexpr uint8_t sizeofID = 8;          // Taille en bits de l'identifiant

On renseigne ici la taille de l’identifiant (en nombre de bits). Ce n’est pas nécessairement un multiple de 8. Cela peut être 10, 13, 25...

Dans le setup(), sont configurées la broche utilisée en sortie et la fréquence de la PWM. La broche de sortie est mise à l’état repos.

void setup() {
  pinMode(IR_SEND_PIN, OUTPUT);
  timerConfigForSend(38);   // Fréquence IR : 38 kHz
  disableSendPWMByTimer();  // Démarre éteint
}

Dans la fonction sendByte(uint16_t value), on active et on désactive la PWM pour une durée qui est un multiple de la constante BIT_DURATION fixée ici à 250µs.

constexpr uint16_t BIT_DURATION = 250;  // Durée minimale d'un front en µs

Cette durée correspond à un bit 1

    if (bit) {
      // 1
      disableSendPWMByTimer();
      delayMicroseconds(BIT_DURATION);
      enableSendPWMByTimer();
      delayMicroseconds(BIT_DURATION);

Pour obtenir un bit 0, cette durée est multipliée par 2 :

    } else {
      // 0
      disableSendPWMByTimer();
      delayMicroseconds(BIT_DURATION * 2);
      enableSendPWMByTimer();
      delayMicroseconds(BIT_DURATION * 2) ;

Et pour le bit de synchronisation (séparation de octets), la durée est multipliée par 4 :

  // Fin de trame
      disableSendPWMByTimer();
      delayMicroseconds(BIT_DURATION * 4);
      enableSendPWMByTimer();
      delayMicroseconds(BIT_DURATION * 4);

L’envoi des bits est réalisé dans le sens bit de poids fort en premier (MBS)

      for (uint8_t i = sizeofID - 1; i >= 0; i--) {        // MSB
         bool bit = (value >> i) & 0x01;

Notez qu’il serait très facile d’envoyer un identifiant sur plus de 8 bits, 16 par exemple. Il suffirait de modifier la valeur de l’identifiant :

constexpr uint16_t ID = 0xA5E4;            // Nouvel identifiant sur 16 bits

Côté récepteur :

Pour exécuter le code côté récepteur, j’ai souhaité utiliser un Raspberry Pi Pico dont vous nous entendez de plus en plus parler sur Locoduino. Et à juste titre au regard de ses performances et de son cout, de l’ordre de 5€ pour la version sans WiFi et 7€ pour la version avec WiFi.

Au sujet du Raspberry Pi Pico, je vous invite à lire l’article très intéressant de Jean-Luc ici.

Voici tout d’abord un code qui sert essentiellement aux tests et à la compréhension du mécanisme :

Téléchargement du programme pour Raspberry Pi Pico ou Raspberry Pi Pico W :

Cliquez sur l’icône pour lancer le téléchargement.


/*
    IdTrainIr38KhzPicoRecepteur

    For Raspberry Pi Pico and Raspberry Pi Pico W

    Programme pour générer un identifiant en utilisant un émetteur et un récepteur infrarouge

    Christophe Bobille pour Locoduino mai 2025

    version 1.0

*/


#include <Arduino.h>
#include "pico/util/queue.h"

volatile uint32_t duration = 0;

constexpr byte pinIn = 15;  // Broche du TSOP

bool receiving = false;
uint8_t currentByte = 0;
constexpr uint8_t sizeofID = 8;  // Important, renseigner le nombre de bits attendus pour les identifiants : 8, 16, 24 ou +
int8_t bitIndex = 0;

// File pour les bits détectés
queue_t durationQueue;

enum DecodeState {
  IDLE,
  RECEIVING
};
DecodeState currentState = IDLE;

void handleIR() {
  static uint32_t lastTime = 0;
  uint32_t now = micros();
  uint32_t duration = now - lastTime;
  lastTime = now;
  queue_try_add(&durationQueue, &duration);
}

void setup() {
  Serial.begin(115200);
    while (!Serial) {
    ; // wait for serial port to connect. Needed for native USB port only
  }
  pinMode(pinIn, INPUT);
  // Initialisation de la queue pour 16 durées
  queue_init(&durationQueue, sizeof(duration), 16);
  attachInterrupt(digitalPinToInterrupt(pinIn), handleIR, RISING);
}

void loop() {
  uint32_t duration = 0;
  if (queue_try_remove(&durationQueue, &duration)) {

    switch (currentState) {

      case IDLE:
        if (duration > 1600 && duration < 2400) {
          // Début de trame détecté
          currentByte = 0;
          bitIndex = sizeofID - 1;
          currentState = RECEIVING;
        }
        break;

      case RECEIVING:

        if (duration >= 400 && duration <= 700) {
          // Bit 1
          currentByte |= (1 << bitIndex);
          bitIndex--;
        } else if (duration >= 800 && duration <= 1200) {
          // Bit 0
          currentByte &= ~(1 << bitIndex);
          bitIndex--;
        } else {
          // Durée invalide
          currentState = IDLE;
          break;
        }

        if (bitIndex < 0) {
          Serial.printf("Octet reçu : 0x%02X\n", currentByte);
          currentState = IDLE;
        }
        break;
    }
  }
}

Pour exécuter le code côté récepteur, j’ai souhaité utiliser un Raspberry Pi Pico dont vous nous entendez de plus en plus parler sur Locoduino et à juste titre concernant ses performances et son cout, de l’ordre de 5€ pour la version sans WiFi et 7€ pour la version avec WiFi.

Voici tout d’abord un code qui sert essentiellement aux tests et à la compréhension du mécanisme.

Globalement, ce programme est assez simple avec trois choses principales à repérer :

  • Une routine d’interruption.
  • Le traitement des données.
  • Une file pour échanger les données entre la routine d’interruption et la fonction qui traite les données.

La routine d’interruption est appelée de manière classique :

attachInterrupt(digitalPinToInterrupt(pinIn), handleIR, RISING);

Notez que la routine n’est exécutée que sur les seuls fronts montants (RISING). Rappelez-vous les captures d’écrans d’oscilloscope qui présentaient la durée des trames mesurées d’un état HAUT à un autre état HAUT.

Comme il se doit, la routine d’interruption est simplifiée au maximum. En fait, elle se contente de mesurer la durée entre chaque front montant et de placer cette durée dans une file (durationQueue) en attente de traitement dans la fonction loop() ;

void handleIR() {
  static uint32_t lastTime = 0;
  uint32_t now = micros();
  uint32_t duration = now - lastTime;
  lastTime = now;
  queue_try_add(&durationQueue, &duration);
}

Voyons justement la fonction loop() :

Après avoir relevé la première valeur disponible dans la file
if (queue_try_remove(&durationQueue, &duration)) {

la fonction va tourner en boucle jusqu’à rencontrer une durée comprise entre 1600µs et 2400 µs, durée caractéristique d’une trame de synchronisation qui indique que les bits suivants sont les bits de data. La variable d’état currentState est alors modifiée.

        if (duration > 1600 && duration < 2400) {
          // Début de trame détecté
          currentByte = 0;
          bitIndex = sizeofID - 1;
          currentState = RECEIVING;
        }	
        break;

Le programme va maintenant examiner les durées qui correspondent à des données :

  • durée supérieure à 400 µs et inférieure à 700 µs, il s’agit d’un bit 1.
  • durée supérieure à 800 µs et inférieure à 1200 µs, il s’agit d’un bit 0.

Dans le cas d’une durée contraire, il s’agit très probablement d’une erreur, on réinitialise sans plus attendre la machine à états à la recherche d’un nouveau bit de synchro.

case RECEIVING:

        if (duration >= 400 && duration <= 700) {
          // Bit 1
          currentByte |= (1 << bitIndex);
          bitIndex--;
        } else if (duration >= 800 && duration <= 1200) {
          // Bit 0
          currentByte &= ~(1 << bitIndex);
          bitIndex--;
        } else {
          // Durée invalide
          currentState = IDLE;
          break;
        }

Si tout s’est bien déroulé, on affiche la valeur des données reçues et on initialise la machine à états.

if (bitIndex < 0) {
          Serial.printf("Octet reçu : 0x%02X\n", currentByte);
          currentState = IDLE;
        }

Voilà l’essentiel de ce que l’on peut dire de ce programme, dont je rappelle qu’il ne sert qu’à l’affichage de l’identifiant reçu.

Je vous propose maintenant un programme qui pourra être utilisé tel quel sur votre réseau. Il permet avec un seul Raspberry Pi Pico de raccorder 16 capteurs IR et d’envoyer les informations à un gestionnaire de réseau. Ici, les résultats sont envoyés au logiciel Rocrail®.

Ce programme nécessite un Raspberry Pi Pico WiFi.

Téléchargement du programme pour Raspberry Pi Pico W :

Cliquez sur l’icône pour lancer le téléchargement.


Voici le programme dans son ensemble :

/*

    Mettre plusieurs broches sous interruption en utilisant une seule fonction callback

    Le RP2040 du Pico dispose de 30 broches GPIO, numérotées de 0 à 29. Parmi elles :

    - Presque toutes peuvent être configurées pour déclencher une interruption sur front montant, descendant ou les deux.
    - Il est donc possible d'attacher une routine à chaque GPIO, mais elles doivent partager la même fonction de callback


    RP Pi Pico pinout : https://www.raspberrypi.com/documentation/microcontrollers/images/pico-2-r4-pinout.svg

    version 0.5 du 29 mai 2025

*/

#include <Arduino.h>
#include <FreeRTOS.h>
#include <task.h>
#include <queue.h>
#include <WiFi.h>
#include <WiFiClient.h>

// Configuration Wi-Fi
const char* ssid = "YOUR_SSID";
const char* password = "YOUR_PASSWORD";

// Adresse de Rocrail
const char* serverIP = "192.168.1.15";  // IP du PC avec Rocrail
const uint16_t serverPort = 8051;       // Port LAN configuré dans Rocrail

WiFiClient client;  // Instance de Wifi

typedef struct {  // Structure pour envoi des messages par TCP
  char text[64];
} TcpMessage;

QueueHandle_t tcpMsgQueue;  // sizeof(TcpMessage)

const uint8_t sizeOfID = 8;  // Important, renseigner le nombre de bits attendus pour les identifiants

// Création d'un tableau pour les broches sous interruption
const byte nbInterPin = 16;                                                                // Choisir le nombre de broches
uint interPin[nbInterPin] = { 3, 4, 5, 6, 7, 8, 14, 15, 16, 17, 18, 19, 20, 21, 27, 28 };  // Identification des broches du Pico                                                              // Important, renseigner le nombre de bits attendus pour les identifiants : 8, 16, 24 ou +
const byte dernPin = interPin[nbInterPin - 1];                                             // Numéro de la dernière broche utilisée

// ID des capteurs dans Rocrail
const char* rrSensor[nbInterPin] = {
  "sb1e", "sb1i",
  "sb2e", "sb2i",
  "sb3e", "sb3i",
  "sb4e", "sb4i",
  "sb5e", "sb5i",
  "sb6e", "sb6i",
  "sb7e", "sb7i",
  "sb8e", "sb8i"
};

QueueHandle_t eventQueue;            // Queue pour stockage des données reçues
constexpr byte eventQueueSize = 32;  // Taille pour la file

typedef struct {      // Une structure pour le stockage des informations suite à interruption
  uint gpio;          // De quelle pin s'agit - il ?
  uint32_t duration;  // Durée entre deux fronts montants
} GpioEvent;

typedef struct {  // Une structure pour stockage des données après traitement
  uint gpio;
  int8_t bitIndex;
  uint8_t currentState;
  uint32_t currentByte;
  uint32_t duration;
  const char* rrSensor;
} Msg;

// Routine d'interruption
volatile uint32_t* lastTime = new uint32_t[dernPin - 1];  // On prévoit pour tous les GPIO (0–29)

void initLastTime() {
  for (int i = 0; i < dernPin - 1; i++)
    lastTime[i] = 0;
}
void handleIR(uint gpio, uint32_t events) {
  uint32_t now = micros();
  uint32_t duration = now - lastTime[gpio];
  lastTime[gpio] = now;
  BaseType_t xHigherPriorityTaskWoken = pdFALSE;
  GpioEvent evt = { gpio, duration };
  xQueueSendFromISR(eventQueue, &evt, &xHigherPriorityTaskWoken);
  // demande un changement de contexte si nécessaire
  portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

// Traitement des données
void taskTraitementData(void* pvParameters) {


  enum { IDLE,
         RECEIVING };  // Etats pendant le traitement

  GpioEvent evt;  // Instance de la structure GpioEvent

  Msg** msg = new Msg*[dernPin];  // Tableau de pointeurs vers Msg

  // Initialiser tout le pointeurs à NULL
  for (byte i = 0; i <= dernPin; i++) {
    msg[i] = nullptr;
  }

  // Pointeurs utilisés uniquement
  for (byte i = 0; i < nbInterPin; i++) {
    byte pin = interPin[i];
    msg[pin] = new Msg;
    msg[pin]->gpio = pin;
    msg[pin]->bitIndex = -1;
    msg[pin]->currentState = 0;
    msg[pin]->currentByte = 0;
    msg[pin]->duration = 0;
    msg[pin]->rrSensor = rrSensor[i];

    Serial.print("Initialisation msg[");
    Serial.print(pin);
    Serial.print("] -> sensor : ");
    Serial.println(msg[pin]->rrSensor);
  }

  for (;;) {
    while (xQueueReceive(eventQueue, &evt, portMAX_DELAY)) {
      Serial.printf("GPIO %d déclenché,  durée %d\n", evt.gpio, evt.duration);
      const byte pin = evt.gpio;

      switch (msg[pin]->currentState) {

        case IDLE:                                         // Recherche du bit de synchro
          if (evt.duration > 1600 && evt.duration < 2400)  // Début de trame détecté
          {
            msg[pin]->currentByte = 0;
            msg[pin]->bitIndex = sizeOfID - 1;
            msg[pin]->currentState = RECEIVING;
          }
          break;

        case RECEIVING:                                    // Réception des données
          if (evt.duration >= 400 && evt.duration <= 700)  // Bit 1
          {
            msg[pin]->currentByte |= (1 << msg[pin]->bitIndex);
            msg[pin]->bitIndex--;
          } else if (evt.duration >= 800 && evt.duration <= 1200)  // Bit 0
          {
            msg[pin]->currentByte &= ~(1 << msg[pin]->bitIndex);
            msg[pin]->bitIndex--;
          } else {
            // Durée invalide
            msg[pin]->currentState = IDLE;  // On arrete le processus en cours et on se met en attente d'un bit de synchro
            break;
          }

          if (msg[pin]->bitIndex < 0) {  // Tous les bits sont reçus
            Serial.printf("Octet reçu capteur %s : 0x%02X\n", msg[pin]->rrSensor, msg[pin]->currentByte);
            // On prépare le message et on le pplace dans la file d'attente pour envoi
            TcpMessage rrMsg;
            snprintf(rrMsg.text, sizeof(rrMsg.text),
                     "<fb id=\"%s\" identifier=\"%d\" state=\"true\" bididir=\"1\" actor=\"user\"/>", msg[pin]->rrSensor, msg[pin]->currentByte);
            xQueueSend(tcpMsgQueue, &rrMsg, 0);
            msg[pin]->currentState = IDLE;
          }
          break;
      }
      vTaskDelay(1);
    }
  }
}

void tcpSend(void* param) {
  TcpMessage msg;
  while (true) {
    if (xQueueReceive(tcpMsgQueue, &msg, portMAX_DELAY)) {
      if (client.connect(serverIP, serverPort)) {
        Serial.println("Connexion TCP OK");

        // Envoi du message reçu
        client.print(msg.text);
        delay(50);
        client.stop();
        Serial.println("Connexion fermée");
      } else {
        Serial.println("Connexion échouée");
      }
    }
  }
}



/*---------------------------------------------------------------------------------------------
setup
----------------------------------------------------------------------------------------------*/

void setup() {

  Serial.begin(115200);
    while (!Serial) {
    ; // wait for serial port to connect. Needed for native USB port only
  }

  // === Connexion Wi-Fi ===
  Serial.print("Connexion à : ");
  Serial.println(ssid);
  WiFi.begin(ssid, password);

  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nWi-Fi connecté !");
  Serial.print("Adresse IP : ");
  Serial.println(WiFi.localIP());

  // === Connexion à Rocrail ===
  Serial.print("Connexion à Rocrail : ");
  Serial.print(serverIP);
  Serial.print(":");
  Serial.println(serverPort);

  eventQueue = xQueueCreate(eventQueueSize, sizeof(GpioEvent));  // Une file d'attente entre l'interruption et le traitement
  tcpMsgQueue = xQueueCreate(10, sizeof(char[64]));              // Une file d'attente après traitement pour envoi à Rocrail


  //init des broches des capteurs et création des interruptions
  for (byte i = 0; i < nbInterPin; i++) {
    // Initialisation des GPIO en entrée avec pull-up
    gpio_init(interPin[i]);
    gpio_set_dir(interPin[i], GPIO_IN);
    gpio_pull_up(interPin[i]);

    // création des interruptions
    if (i == 0)
      gpio_set_irq_enabled_with_callback(interPin[i], GPIO_IRQ_EDGE_RISE, true, &handleIR);  // La foncion de callback n'est déclarée qu'une seule fois
    else
      gpio_set_irq_enabled(interPin[i], GPIO_IRQ_EDGE_RISE, true);
  }

  // Initialisation pour utilisation dans la routine d'interruption handleIR
  initLastTime();

  // Création de la tâches FreeRTOS pour le traitement des données
  xTaskCreate(taskTraitementData, "Traitement data", 1024, nullptr, 1, nullptr);
  // Création de la tâches FreeRTOS pour l'envoi à Rocrail
  xTaskCreate(tcpSend, "tcpSend", 2048, NULL, 1, NULL);
}

void loop() {}

Nous avons également besoin d’une structure plus complète cette fois pour stocker les informations durant le traitement.

Nous stockons bien sûr le numéro de broche, mais aussi la position du bit dans le message (bitIndex), l’état de la machine à état, est-ce un bit de synchro ou un bit de données (currentState) et la durée du front pour dédire la nature du bit : 0 ou 1 ou encore bit de synchro.

// Traitement des données
void taskTraitementData(void* pvParameters) {

  enum { IDLE,
         RECEIVING };

  GpioEvent evt;

  Msg msg[nbInterPin];
  for (byte i = 0; i < nbInterPin; i++) {
    msg[i].gpio = interPin[i];
    msg[i].currentState = IDLE;
  }

Voici le début de la tâche de traitement des données.

Nous commençons par créer une petite énumération, simplement pour rendre le code plus lisible dans la machine à états qui sera utilisée plus loin : IDLE et RECEIVING étant plus explicite que 0 ou 1 !

Nous créons ensuite une nouvelle instance de GpioEvent que nous appellerons evt et qui va recevoir les données en provenance de la routine d’interruption au travers de la file eventQueue.

Puis autant d’instance de la structure Msg que nous avons de pins sous interruption. Il s’agt en réalité d’un tableau de type Msg de taille nbInterPin.

Nous initialisons immédiatement certaines valeurs :

    msg[i].gpio = interPin[i];
    msg[i].currentState = IDLE;

msg[i].gpio qui contient la valeur de la broche sous interruption et l’état initial qui doit être IDLE.

Les autres valeurs des membres de la structure sont initialisées par défaut selon le principe du c/c++.

for (;;) {
    while (xQueueReceive(eventQueue, &evt, portMAX_DELAY)) {
      printf("GPIO %d déclenché,  durée %d\n", evt.gpio, evt.duration);

La particularité d’une tâche FreeRTOS est qu’elle possède son propre loop() qui est ici représenté par for (;;) (boucle infinie) ou encore while (true), tant que la condition est vraie ce qui, écrit comme ceci est toujours le cas !

while (xQueueReceive(eventQueue, &evt, portMAX_DELAY)) {

Avec while et portMAX_DELAY, le programme attend qu’il y ait au moins une valeur dans la file eventQueue. Quand une donnée se présente, elle est stockée dans la variable evt.

Une fois reçu une nouvelle variable evt, il faut tout d’abord chercher dans le tableau msg a quel élément s’applique la durée reçue evt.duration.

La méthode à laquelle on pense en premier, celle que j’avais utilisée initialement, est de parcourir le tableau msg jusqu’à trouver l’élément qui correspond à la broche de l’interruption (evt.gpio).

for (byte i = 0; i < nbInterPin; i++) {
        if (msg[i].gpio == evt.gpio) {

Mais cette façon de faire est très insatisfaisante car il nous faut potentiellement tester de nombreuses option invalides avant de trouver la bonne.

Comme il y a 16 broches sous interruption, cela veut dire que l’on peut potentiellement tester 15 possibilités avant de trouver la bonne. Multiplié par les 8 bits, cela correspond à 120 tests inutiles !!!

En cherchant, j’ai trouvé une solution qui me permet de trouver immédiatement l’élément du tableau qui correspond au données reçues.

Je cré un tableau de msg dont l’index sera équivalent au numéro de broche reçu.

Il me faut donc pour cela créer un tableau dont la taille est au moins équivalent à la valeur de la dernière broche.

  const byte dernPin = interPin[nbInterPin - 1];  // Numéro de la dernière pin utilisée
  Msg** msg = new Msg*[dernPin];  

C’est ce que je fais ici : const byte dernPin = interPin[nbInterPin - 1];.

et que j’applique ici : Msg** msg = new Msg*[dernPin];

Mais alors, pourquoi utiliser des pointeurs (*) et même un pointeur de pointeur (**) ?

J’aurais pu créer directement un tableau Msg msg[dernPin]; mais celui-ci aurait alors une occupation de la mémoire égale à 28 x l’empreinte mémoire d’un élément msg de l’ordre de 12 octets chaque. Ce n’est pas négligeable, il vaut mieux utiliser des pointeurs dont l’empreinte mémoire sur un Pico est 32 bits (4 octets).

  for (byte i = 0; i <= dernPin; i++) {
    msg[i] = nullptr;
  }

Pour faire simple, j’initialise tous les pointeurs à nullptr. L’empreinte mémoire est insignifiante.

// Pointeurs utilisés uniquement
  for (byte i = 0; i < nbInterPin; i++) {
    byte pin = interPin[i];
    msg[pin] = new Msg;
    msg[pin]->gpio = pin;
    msg[pin]->bitIndex = -1;
    msg[pin]->currentState = 0;
    msg[pin]->currentByte = 0;
    msg[pin]->duration = 0;
    msg[pin]->rrSensor = rrSensor[i];

    Serial.print("Initialisation msg[");
    Serial.print(pin);
    Serial.print("] -> sensor : ");
    Serial.println(msg[pin]->rrSensor);
  }

Puis je n’affecte de valeurs qu’aux seuls éléments du tableau que j’utilise. J’envoie le résultat sur la console de l’IDE pour m’assurer que le résultat est bien conforme à ce que je souhaitais.

Cette façon de faire est un peu plus longue, mais n’est réalisée qu’une seule fois au début du programme.

Voyons maintenant le traitement :

La condition switch...case réalise une petite machine à état qui a ici deux valeurs : IDLE, le bit de synchro pour un message n’a pas été trouvé (état initial) ou RECEIVING, l’analyse des données.

switch (msg[pin]->currentState)

Cette ligne a pour objectif de vérifier dans quel état est le message concerné.

 case IDLE:                                         // Recherche du bit de synchro
 if (evt.duration > 1600 && evt.duration < 2400)  // Début de trame détecté
  {
  msg[pin]->currentByte = 0;
  msg[pin]->bitIndex = sizeOfID - 1;
  msg[pin]->currentState = RECEIVING;
 }
break;

Lorsque la durée mesurée dans la routine d’interruption et comprise entre 1600 µs et 2400µs, on considère qu’il s’agit d’un bit de synchro, on met les valeurs à zéro et on change le statut pour la machine à états.

case RECEIVING:                                    // Réception des données
          if (evt.duration >= 400 && evt.duration <= 700)  // Bit 1
          {
            msg[pin]->currentByte |= (1 << msg[pin]->bitIndex);
            msg[pin]->bitIndex--;
          } else if (evt.duration >= 800 && evt.duration <= 1200)  // Bit 0
          {
            msg[pin]->currentByte &= ~(1 << msg[pin]->bitIndex);
            msg[pin]->bitIndex--;
          } else {
            // Durée invalide
            msg[pin]->currentState = IDLE;  // On arrête le processus en cours et on se met en attente d'un bit de synchro
            break;
          }

          if (msg[pin]->bitIndex < 0) {  // Tous les bits sont reçus
            Serial.printf("Octet reçu capteur %s : 0x%02X\n", msg[pin]->rrSensor, msg[pin]->currentByte);
            // On prépare le message et on le pplace dans la file d'attente pour envoi
            TcpMessage rrMsg;
            snprintf(rrMsg.text, sizeof(rrMsg.text),
                     "<fb id=\"%s\" identifier=\"%d\" state=\"true\" bididir=\"1\" actor=\"user\"/>", msg[pin]->rrSensor, msg[pin]->currentByte);
            xQueueSend(tcpMsgQueue, &rrMsg, 0);
            msg[pin]->currentState = IDLE;
          }
          break;

Maintenant que l’état est RECEIVING, le programme va faire le tri entre les durée comprises entre if (evt.duration >= 400 && evt.duration <= 700)  // Bit 1 correspondant à un bit 1 et else if (evt.duration >= 800 && evt.duration <= 1200)  // Bit 0 correspondant à un bit 0.

On considère que tous les autres cas sont des erreurs et qu’il nous faut abandonner et attendre un nouveau bit de synchro.

} else {
            // Durée invalide
            msg[pin]->currentState = IDLE;  // On arrete le processus en cours et on se met en attente d'un bit de synchro
            break;
          }
if (msg[pin]->bitIndex < 0) {  // Tous les bits sont reçus
            Serial.printf("Octet reçu capteur %s : 0x%02X\n", msg[pin]->rrSensor, msg[pin]->currentByte);
            // On prépare le message et on le place dans la file d'attente pour envoi
            TcpMessage rrMsg;
            snprintf(rrMsg.text, sizeof(rrMsg.text),
                     "<fb id=\"%s\" identifier=\"%d\" state=\"true\" bididir=\"1\" actor=\"user\"/>", msg[pin]->rrSensor, msg[pin]->currentByte);
            xQueueSend(tcpMsgQueue, &rrMsg, 0);
            msg[pin]->currentState = IDLE;
          }
          break;

Dans le cas où tous les bits attendus ont été reçu, on prépare le message à envoyer que l’on place dans une nouvelle file d’attente (tcpMsgQueue) et on n’oublie pas de changer l’état pour que le programme aille maintenant à la recherche d’un nouveau bit de synchro.

L’envoi du message est réalisé dans la méthode suivante qui je crois, ne nécessite pas de commentaires particuliers :

void tcpSend(void* param) {
  TcpMessage msg;
  while (true) {
    if (xQueueReceive(tcpMsgQueue, &msg, portMAX_DELAY)) {
      if (client.connect(serverIP, serverPort)) {
        Serial.println("Connexion TCP OK");

        // Envoi du message reçu
        client.print(msg.text);
        delay(50);
        client.stop();
        Serial.println("Connexion fermée");
      } else {
        Serial.println("Connexion échouée");
      }
    }
  }
}

Voyons enfin la routine d’interruption, celle qui va mesurer la durée entre deux fronts montants pour nous permettre de déduire la valeur du bit envoyé.

A part quelques mécanismes de gestion des priorités propre à freeRTOS, cette fonction est très simple et surtout très rapidement exécutée.

  volatile uint32_t* lastTime = new uint32_t[dernPin - 1];  // On prévoit pour tous les GPIO

void initLastTime() {
  for (int i = 0; i < dernPin - 1; i++)
    lastTime[i] = 0;
}

A l’extérieur de la routine, nous allons déclarer un pointeur de tableau intitulé lastTime. C’est dans ce tableau que seront stockées pour chacune des broches les valeurs nécessaires au calcul de la durée d’impulsion.

Dans une boucle for ous allons initialiser toutes les variables du tableau en une seule opération.

void handleIR(uint gpio, uint32_t events) {
  uint32_t now = micros();
  uint32_t duration = now - lastTime[gpio];
  lastTime[gpio] = now;
  BaseType_t xHigherPriorityTaskWoken = pdFALSE;
  GpioEvent evt = { gpio, duration };
  xQueueSendFromISR(eventQueue, &evt, &xHigherPriorityTaskWoken);
  // demande un changement de contexte si nécessaire
  portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

Comme j’ai déjà eu l’occasion de le dire, les opérations dans la routine sont assez simples :

  • On déclare et on initialise une variable now à laquelle on affecte l’heure exacte courante avec la fonction micros().
  • On déclare et on initialise une variable duration et on lui affecte une valeur qui est la différence temporelle entre la dernière lecture lastTime[gpio] et la lecture temporelle que nous venons de faire
  • On affecte à lastTime[gpio] la valeur temporelle actuelle now dont nous allons avoir besoin lors de la prochaine interruption sur cette broche.
  • Avec GpioEvent evt = { gpio, duration }; on déclare une variable de type GpioEvent dans laquelle on place la valeur de la broche et la durée constatée
  • Puis on envoie la variable evt dans la file d’attente eventQueue pour qu’elle soit traitée par la méthode vue plus avant.
PNG - 517.8 kio
Figure 31 - Création d’un contrôleur dédié dans Rocrail

Dans le logiciel Rocrail®, (Figure 31) vous devrez créer un contrôleur spécifique qui va écouter les communication TCP sur le port 8051. Le même que celui déclaré dans le programme bien sûr.

Vous pouvez voir en arrière-plan en haut de l’image, mes autres contrôleurs dans Rocrail®, dccpp, celui qui me permet de piloter labox et decodeurs_CAN, celui qui reçoit les communications en CAN.

PNG - 89.4 kio
Figure 32 - A l’activation du capteur sur le réseau, Rocrail® affiche la locomotive concernée.

Figure 32 – Affichage sur le TCO de Rocrail® du capteur sb1equi est actionné. Rocrail® associe alors l’identifiant à la locomotive concernée, ici ma 241A004.

Voilà l’essentiel je pense sur ce sujet de l’identification par infrarouge. Il y a de nombreuses applications possibles sur nos réseaux comme la gestion d’une butte de triage ou d’une gare cachée.

JPEG - 350.1 kio
Crédit : https://docrail.fr/les-regimes-ordi...

On peut-aussi penser à la télécommande par infrarouge des trains tout comme vous modifiez les chaines et modulez le son sur votre téléviseur

Pour ne pas être trop long, je n’ai pas pu détailler le code sur certains points comme les tâches FreeRTOS, mais j’apporterai tout compléments d’information et Je répondrai volontiers à toutes vos questions soit à la suite sur cette page ou mieux, sur le fil du forum dédié à ce sujet : https://forum.locoduino.org/index.p...

2 Messages

  • Bonjour

    Tres interessant sujet et tres belle réalisation

    Il me semble toutefois que lorsque l ID passe de 8 a 16 bits il faut aussi modifier "sizeofID = 16" ; (qui représente la longueur de cet ID) afin que tous les bits soient envoyés.
    "
    Il suffirait de modifier la valeur de l’identifiant :

    constexpr uint16_t ID = 0xA5E4 ; // Nouvel identifiant sur 16 bits
    "
    et sa longeur donc.
    constexpr uint8_t sizeofId = 16 ;

    Répondre

Réagissez à « Identification des trains par infrarouge 38 kHz »

Qui êtes-vous ?
Votre message

Pour créer des paragraphes, laissez simplement des lignes vides.

Lien hypertexte

(Si votre message se réfère à un article publié sur le Web, ou à une page fournissant plus d’informations, vous pouvez indiquer ci-après le titre de la page et son adresse.)

Rubrique « Matériel »

Les derniers articles

Les articles les plus lus