Lernende Infrarot-Fernbedienung mit ESP32

In einem Kurs mit dem ESP32 kam die Frage, ob man einen Sender für eine Infrarot-Fernbedienung selbst bauen kann. Ja, das geht. Es ist eine schöne Anwendung für den ESP32.

Zielvorgaben

Worum geht es also? Die Idee war, Signale von einer vorhandenen Infrarot-Fernbedienung einzulesen und dann gesteuert durch den Mikrocontroller zu reproduzieren. Der Controller kann z.B. zu einer bestimmten Zeit das Radio einschalten. Oder man kann einen kleinen Web-Server programmieren, der vom Smartphone gesteuert Licht oder Musik ein- oder ausschaltet. Geräte lassen sich automatisch steuern, ohne dass irgendein Eingriff in das Gerät notwendig ist. Vieles ist denkbar.

Um die Komplexität in Grenzen zu halten, nehmen wir für den Anfang nur drei Kanäle. Es soll also 3 Tasten geben, die jeweils ein angelerntes Signal abspielen können. Eine weitere Taste wird gebraucht, um das kleine Gerät in den Lernmodus zu versetzen. Wenn die Anlern-Taste für eine bestimmte Zeit (hier 3 Sekunden) gedrückt wird, soll das Gerät bereit sein, Signale von einer IR-Fernbedienung zu empfangen und aufzuzeichnen. Schließlich möchten wir noch Leuchtdioden haben, die den aktuellen Status anzeigen. Und die angelernten Daten sollen natürlich dauerhaft über das Stromabschalten hinaus erhalten bleiben. Das ist dann eigentlich schon alles.

IR Signal-Übertragung

Wie funktioniert die IR-Signalübertragung? Die üblichen IR-Sender produzieren Lichtsignale, die mit einer festen Frequenz zwischen 36 und 40 kHz moduliert sind, also sehr schnell ein- und ausgeschaltet werden. Dieser Frequenzbereich wurde gewählt, um den Signalabstand zu Störungen möglichst groß zu halten, denn schließlich ist Infrarotlicht allgegenwärtig, sei es als natürliches Licht von der Sonne oder als gepulstes Licht von diversen Lampen. Die eigentliche Information (z.B. “Radio einschalten”) befindet sich digital kodiert in der zeitlichen Abfolge von kurzen und langen Pulsen (“bursts”) mit z.B. 38 kHz. Ein typisches Signal von einer Fernbedienung kann z.B. 30 Millisekunden lang sein und 30, 40 oder 50 Pulse von kurzer oder langer Dauer enthalten.

Soweit die Theorie. Zum Glück ist in diesem Fall die Praxis nicht weit: Man kann mit einem Oszilloskop das Signal einer IR-Fernbedienung an einer Infrarot-Photodiode gut beobachten.

Schaltung zur Messung des Infrarot-Signals mit einer IR-Fotodiode. Die Fotodiode muss in Sperrrichtung gepolt sein.
Gemessene 38 kHz Bursts einer IR-Fernbedienung, hier eine “Bose Wave” Anlage. Einzelne Bursts sind etwa 550 Mikrosekunden lang. Die gesamte Sequenz erstreckt sich über 30 Millisekunden.

Für unsere Anwendung müssen wir das Signal zum Glück nicht “verstehen”. Aber es muss möglichst exakt aufgezeichnet und wieder abgespielt werden, damit das empfangende Gerät entsprechend reagiert.

Hardware

Es ist relativ einfach, IR-Signale mit einem Mikrocontroller zu generieren. Mit einem Timer wird eine Rechteckschwingung von 38 kHz erzeugt und auf einen der Ports gegeben. Typische IR-Leuchtdioden vertragen Ströme von bis zu 100 mA, was den Port überfordern würde. Deshalb kommt ein einfacher Transistor hinzu, z.B. ein BC337, der das Signal verstärkt. Um die 3.3V Board-Spannung des ESP32 nicht zu sehr durch die 38 kHz Pulse von 100mA zu belasten, wird die Infrarot-LED mit einem 33 Ohm Widerstand nach +5V verschaltet. Damit ist der Strom auf etwas unter 100 mA begrenzt.

Auf der Empfangsseite wird eine Schaltung benötigt, die möglichst empfindlich und gleichzeitig selektiv 38 kHz-IR-Signale herausfiltert und diese dann als digitale Zustände Ein (= Signal vorhanden) oder Aus (= kein Signal vorhanden) verfügbar macht. Das könnte aufwendig werden, wenn es dafür nicht fertige und preisgünstige Komponenten gäbe. Ich verwende den TSOP1138. Dieser oder ähnliche Empfänger mit guter Selektivität und hoher Empfindlichkeit sind in der Arduino-Welt verbreitet und z.B. in Funduino-Bausätzen enthalten. Der TSOP1138 hat drei Anschlüsse: Ground, Spannungsversorgung Vs und Signalausgang. Das Datenblatt sagt, dass die Betriebsspannung 5V betragen sollte. Tatsächlich habe ich sehr gute Ergebnisse mit den 3.3V des ESP32 erreicht. Es besteht also kein Bedarf für Pegelumsetzer.

TSOP1138

Schließlich kommen noch 4 Taster und 3 Leuchtdioden dazu. Damit ist die Hardware komplett. Die Schaltung ist einfach und kann gut auf einem Steckbrett aufgebaut werden.

Schaltbild IR Remote Control
Die Schaltung ist schnell auf einem Breadboard zusammengesetzt. Oben links befindet sich die IR-Sende-Diode mit dem Transistor zur Verstärkung. Oben mittig ist der IR-Empfänger. Taster und Leuchtdioden befinden sich auf der rechten Seite.

Wenn der Schaltplan klar ist, kann man die entsprechenden GPIO-Nummern im Sketch bereitstellen. Das geschieht im Programm ganz am Anfang als globale Konstanten. Damit sind die Definitionen an einem Ort und können bei Bedarf schnell angepasst werden.

// pin assignments
const int bt_learn = 16;
const int bt_play[] = {17, 5, 18};
const int ir_receiver = 34, ir_transmitter = 33;
const int led[] = {27, 26, 25};

Software

Der eigentlich spannende Teil des Projekts ist die Software. Natürlich gibt es ausgefeilte Bibliotheken für die Bearbeitung von Infrarot-Signalen mit einem Arduino (z.B. die multi-protocol-infrared-remote-library von Ken Shirriff). Diese sind aber nicht unbedingt für den ESP32 geeignet. Außerdem fand ich es interessanter, die notwendige Funktionalität mit Board-Mitteln zu programmieren.

System-Takt in Mikrosekunden

Grundsätzlich gibt es zwei mögliche Ansätze, um Zeitverläufe von Signalen aufzuzeichnen:

  1. Man kann mit einem regelmäßigen, möglichst engen Abstand den Zustand des Eingangs-Port testen und den jeweils aktuellen Wert als 0 oder 1 abspeichern. Üblich ist z.B. eine Abtastrate von 50 Mikrosekunden. Der Nachteil dieser Methode besteht darin, dass die Abtastrate unweigerlich einen möglichen zeitlichen Fehler (hier +/- 25 Mikrosekunden) mitbringt.
  2. Alternativ kann der Eingangs-Port ständig mit hoher Geschwindigkeit abgefragt (“Polling”) und jeweils der Zeitpunkt der Umschaltung aufgezeichnet werden. Wenn das Polling ausreichend schnell geschieht, ist die zeitliche Auflösung dieser Methode besser. Dieser Ansatz setzt allerdings eine möglichst exakte System-Uhr im Bereich von Mikrosekunden voraus.

Glücklicherweise verfügt der ESP32 über genau das: Eine Timer-gesteuerte System-Uhr, die die Anzahl der Mikrosekunden seit dem Einschalten zur Verfügung stellt: esp_timer_get_time() (siehe ESP Referenz). Die Ausführung der Funktion benötigt selbst weniger als eine Mikrosekunde, stellt also keine besondere Systemlast dar.

Die Zeitpunkte (Mikrosekunden) des Anfangs (Einschalten) und Ende (Ausschaltens) der einzelnen Bursts werden in Arrays abgelegt. Da wir mit 3 Kanälen arbeiten, gibt es ein zwedimensionales Array, um die Signal-Zeitpunkte abzuspeichern: ir_data[3][250] . Es enthält drei Zeilen, wobei jede Zeile bis zu 250 Datenpunkte aufnehmen kann. In meinen bisherigen Versuchen waren in der Regel nie mehr als 100 Datenpunkte für ein IR-Signal notwendig – aber es kann nicht schaden, Luft nach oben zu lassen.

Zusätzlich benötigen wir noch jeweils eine Integer-Variable, die die tatsächliche Anzahl der Einträge in den Arrays abspeichert. Ich nenne sie ir_data_len[3], ebenfalls ein Array mit 3 Werten für drei Kanäle. ir_data[][] und ir_data_len[] werden als globale Variablen definiert, also im Programmablauf vor allen Funktionen.

// global constants 
const int channel_cnt = 3;                  // this version supports 3 channels (maximum is 10)
const uint16_t ir_data_size = 250;          // size of ir data sequence

// global variables
Preferences prefs;                            // access to non volatile storage (NVS)
uint32_t ir_data[channel_cnt][ir_data_size];  // IR data set, 3 rows of up to 250 data items each
int ir_data_len[channel_cnt] = {0, 0, 0};
bool learn_bt_pressed = false;

Funktion read_ir_data()

Damit wird die Funktion zum Einlesen der IR-Signale read_ir_data() unkompliziert. Die Funktion übernimmt einen Zeiger auf das Daten-Array, in dem die Daten abgelegt werden, und einen Zeiger auf die zugehörige Zähler-Variable. Am Anfang des Lesevorgangs wartet die Software auf das erste Signal von der IR-Fernbedienung und erfasst die aktuelle Startzeit (start_time). Ab jetzt wird bei jedem Wechsel des Eingangs-Ports die aktuelle Zeit gelesen, die Differenz zur Startzeit errechnet (time_stamp) und diese im Array gespeichert. Die boolesche Variable edge sorgt für die Unterscheidung zwischen dem Wechsel von LOW -> HIGH oder HIGH -> LOW. Nach dem Ablauf der voreingestellten Zeit, hier 250 Millisekunden, wird die Erfassung beendet. Die Arrays mit den Umschalt-Zeitpunkten bilden die Datenbasis für eine exakte Reproduktion des Signals.

/* read_ir_data ---------------------------------------------------------------------------
Reads a sequence of ir data via the IR receiver. Captures the start- and stop time points
of each burst and stores them to the array ir_data[]. Data reading continues until either 
the data storage is exceeded or reading timeout is reached. Flashes the corresponding LED
during the reading process. 
Input:    channel -> channel number (0 … 2)
Output:   array ir_data -> ir data points
          int *ir_data_len -> number of data points stored in the array 
*/
void read_ir_data(int channel, uint32_t ir_data[], int *ir_data_len) {
  long start_time;
  uint32_t time_stamp;
  bool edge = true;            // false for falling edge, true for rising edge

  ledcAttachPin(led[channel], led_pwm_channel);                     // flash LED
  ledcWrite(led_pwm_channel, 128);
  *ir_data_len = 0;                                                    // reset array index
  while (digitalRead(ir_receiver) == HIGH);                         // wait for falling edge
  start_time = esp_timer_get_time();                                // capture start time
  do {
    time_stamp = (uint32_t) (esp_timer_get_time() - start_time);    // refresh time stamp 
    if (edge) {                                                     
      if (digitalRead(ir_receiver) == HIGH) {                       // waiting for rising edge
        ir_data[*ir_data_len] = time_stamp;                            // capture time stamp
        ++*ir_data_len;
        edge = false;                                               // switch to falling edge
      };  
    } else {                                                       
      if (digitalRead(ir_receiver) == LOW) {                        // waiting for falling edge
        ir_data[*ir_data_len] = time_stamp;                            // capture time stamp 
        ++*ir_data_len;
        edge = true;                                                // switch to rising edge
      };  
    };
  } while ((time_stamp < ir_read_timeout) &amp;&amp; (*ir_data_len < ir_data_size));
  ledcDetachPin(led[channel]);                                      // clear flashing LED
  digitalWrite(led[channel], HIGH);                                 // switch off LED
}

Um die Arbeitsweise zu kontrollieren, gibt es eine Funktion print_ir_data(), die die eingelesenen Daten auf dem seriellen Monitor ausgibt. Hier die Daten für “Einschalten” bei der “Bose Wave” Anlage. Das Signal wird zum Zeitpunkt 0 eingeschaltet, nach 1047 Mikrosekunden aus, dann nach 2499 Mikrosekunden wieder ein, nach 3040 Mikrosekunden aus, usw.

Channel 0  IR data (71):
1047, 2499, 3040, 3521, 4041, 4491, 5032, 6490, 7034, 8489, 9038, 
9512, 10029, 10507, 11030, 12480, 13023, 13503, 14024, 15475, 16016, 17475, 
18019, 18497, 19010, 19466, 20011, 21465, 22015, 23465, 24007, 24486, 25009, 
26452, 27001, 77482, 78523, 79984, 80526, 81005, 81527, 82002, 82518, 83975, 
84521, 85974, 86524, 86992, 87515, 87992, 88516, 89964, 90508, 90987, 91510, 
92960, 93502, 94960, 95505, 95981, 96496, 96950, 97498, 98949, 99500, 100949, 
101493, 101971, 102494, 103937, 104486

PWM zur Erzeugung des 38 kHz-Signals

Zum Abspielen der aufgezeichneten Signale muss ein 38 kHz-Signal erzeugt und entsprechend ein- und ausgeschaltet werden. Dazu eignet sich die PWM-Funktion der Arduinos. Diese unterscheidet sich beim ESP32 von den 8-Bit-Arduinos, die über die Funktion analogWrite() verfügen. Der ESP32 hat mehr Hardware-Möglichkeiten, insbesondere mehr PWM-Kanäle. Hier wird die PWM mit drei Low Level-Funktionen gesteuert.

1) ledcSetup(<channel>, <frequency>, <bit resolution>); 
2) ledcAttachPin(<GPIO no>, <channel>);
3) ledcWrite(<channel>, <duty cycle>); 

Mit ledcSetup() wird der gewünschte PWM-Kanals konfiguriert. Die Frequenz ist in unserem Fall 38 kHz. Die Funktion ledcAttachPin() bindet den PWM-Kanal an einen der Ausgabe-Ports (hier der Port für die Infrarot-Leuchtdiode). Schließlich setzt ledcWrite() den gewünschten Duty-Cycle. Wir arbeiten mit einer Bit Resolution von 8, so dass der Wert für Duty Cycle zwischen 0 (entspricht dauerhaft Aus) und 255 (entspricht dauerhaft Ein) liegen darf.

Eine gute Zusammenfassung der PWM beim ESP32 gibt es hier: EPS32 Arduino LED PWM Fading.

Der entsprechende Code im setup()-Block sieht so aus:

  // setup PWM for IR transmitter
  ledcSetup(ir_pwm_channel, ir_pwm_frequency, 8);    // pwm runs with 8 bit resolution
  ledcAttachPin(ir_transmitter, ir_pwm_channel);
  ledcWrite(ir_pwm_channel, 0);                      // switch off the IR transmitter for now

Funktion play_ir_data()

Im Programm übernimmt die Funktion play_ir_data() die Aufgabe, das IR-Signal – basierend auf den angelernten Daten – zu reproduzieren. Dabei kommt wieder die System-Uhr zur Hilfe, um im richtigen Moment die jeweiligen Bursts ein- oder auszuschalten. Einschalten heißt, den Duty-Cycle auf die Hälfte des Maximalwertes, also 128, zu setzen, so dass das PWM ein möglichst ausgeprägtes 38 kHz Signal erzeugt. Ausschalten heißt, den Duty-Cycle auf 0 zu setzen.

/* play_ir_data ---------------------------------------------------------------------------
Plays an IR data sequence
Input:  array ir_data -> holds the start- and stop time points for the bursts
        int ir_data_len -> number of data points in the array
*/
void play_ir_data(uint32_t ir_data[], uint16_t ir_data_len) {
  long start_time;
  uint32_t time_stamp;
  bool edge = true;                     // true for active, false for pause
  int i = 0;

  if (ir_data_len == 0) return;         // if data set is empty, then nothing to do

  start_time = esp_timer_get_time();    // capture start time
  ledcWrite(ir_pwm_channel, 128);       // activate IR transmitter
  do {
    time_stamp = esp_timer_get_time() - start_time;
    if (time_stamp >= ir_data[i]) {
      if (edge) {
         ledcWrite(ir_pwm_channel, 0);    // stop IR transmitter
         edge = false;
      } else {
         ledcWrite(ir_pwm_channel, 128);  // activate IR transmitter
         edge = true;
      };
      ++i;
    }
  } while (i < ir_data_len);
  ledcWrite(ir_pwm_channel, 0);           // ensure that IR transmitter is stopped 
}

Das Oszilloskop zeigt, dass das ursprüngliche Signal mit guter Genauigkeit reproduziert wird.

Ausgangs-Signal am IR-Empfänger für die Funktion “Einschalten” des Bose Wave-Systems
Signal am GPIO-Ausgang des ESP32. Der zeitliche Verlauf des ursprünglichen Signals wird exakt reproduziert.

Daten langfristig sichern: Preferences

Schließlich gibt es noch die Anforderung, dass der ESP32 die Daten der gelernten Signale über das Ausschalten hinaus im Speicher behalten soll. Beim 8-Bit Arduino gibt es das EEPROM zum dauerhaften Abspeichern von Daten. Leider ist der Speicherplatz des EEPROMS begrenzt und die Programmierung dazu etwas umständlich.

Der ESP32 bietet die Möglichkeit, aus dem Programm heraus den Flash-Speicher, in der ESP-Terminologie non-volatile storage (NVS), zu lesen und zu beschreiben. Der hat natürlich sehr viel Platz. Im Arduino-Framework gibt es das Preference-Objekt, mit dem der Flash-Speicher erreichbar ist. Dazu wird ein globales Objekt prefs vom Typ Preferences erzeugt.

#include <Preferences.h>
Preferences prefs;

Dort kann man Bereiche einrichten, die zum Abspeichern beliebiger Daten zur Verfügung stehen. Der Bereich bekommt einen Namen, der als String übergeben wird, und wird mit Begin geöffnet. Der Name kann willkürlich gewählt werden, hier ir_nvs.

prefs.begin("ir_nvs", false);

False bedeutet, dass der Bereich sowohl gelesen als auch beschrieben werden darf. Zum Lesen und Schreiben gibt es die Objekt-Methoden get…() und put…(), jeweils für die üblichen Datentypen, z.B.

 prefs.putUShort(<name_string>, <int value>); 

zum Schreiben eines 16-Bit Integer-Wertes ohne Vorzeichen. Der name_string kann wieder beliebig gewählt werden, muss aber eindeutig sein und dient zur Identifikation der Variable. Eine gute Praxis ist, dafür den Namen der entsprechenden Variable im RAM zu verwenden.

Für Arrays und andere größere Objekte kann man auf die Funktion

prefs.putBytes(<name_string>, <pointer>, <size>);

zurückgreifen, die den entsprechenden Speicherbereich ins Flash kopiert und so dauerhaft verfügbar macht.

Als Gegenstück kann mit

 <short_value> = prefs.getUShort(<name_string>); 

der gespeicherte Wert gelesen werden. Wenn man auf einen bisher noch unbeschrieben Namen zugreift, bekommt man den Wert 0.

Im Programm werden nach dem Einschalten im setup()-Block die Anzahl der abgespeicherten Daten für die drei Kanäle aus dem Flash-Speicher in das Array ir_data_len[] gelesen. Wenn die Werte größer als 0 sind, dann gibt es tatsächlich abgelegte IR-Daten, die dann in das Array ir_data[][] im RAM kopiert werden und für die Funktion play_ir_data() zur Verfügung stehen.

  // read IR data from NVS
  char ir_len_str[10] = "ir_len_0", ir_data_str[10] = "ir_data_0";

  prefs.begin("ir_nvs", false);
  Serial.println();
  Serial.println("Reading NVS ir_data ");
  for (int i = 0; i < channel_cnt; ++i) {
    ir_len_str[7] = '0' + i;
    ir_data_str[8] = '0' + i;
    ir_data_len[i] = prefs.getUShort(ir_len_str);
    Serial.print("Channel " + String(i) + ": ");
    Serial.println(String(ir_data_len[i]) + " Entries");
    if (ir_data_len[i] > 0) 
      prefs.getBytes(ir_data_str, ir_data[i], ir_data_len[i] * sizeof(uint32_t));
  }       
  Serial.println();

Entsprechend werden nach dem Anlernen von IR-Signalen die Daten in den Flash-Speicher geschrieben, so dass sie dauerhaft erhalten bleiben und beim nächsten Einschalten des Geräts gelesen werden können. Das übernimmt die Funktion learn_sequence(), die nacheinander für alle 3 Kanäle die Funktion read_ir_data() aufruft und die Daten dann in den Flash-Speicher schreibt.

/* -------------------------------------------------------------------------------------*/
void learn_sequence(void) {
  char ir_len_str[10] = "ir_len_0", ir_data_str[10] = "ir_data_0";
  for (int i = 0; i < channel_cnt; ++i) {
    read_ir_data(i, ir_data[i], &amp;ir_data_len[i]);
    print_ir_data(i, ir_data[i], ir_data_len[i]);
    ir_len_str[7] = '0' + i;
    ir_data_str[8] = '0' + i;
    prefs.putUShort(ir_len_str, ir_data_len[i]);
    prefs.putBytes(ir_data_str, ir_data[i], ir_data_len[i] * sizeof(uint32_t));
  };
}

Alle Komponenten zusammen setzen

Damit sind die wesentlichen Komponenten für das System vorhanden. Die Schleife loop() fragt die Buttons ab und verzweigt in die jeweiligen Funktionen.

/* -------------------------------------------------------------------------------------*/
void loop() {
  uint8_t learn_pressed_cnt = 0;

  // check whether the learn button is pressed for at least 3 seconds
  while (digitalRead(bt_learn) == LOW) {
    delay(200);
    ++learn_pressed_cnt;
    if (learn_pressed_cnt >= 15) {
      learn_sequence();
      learn_pressed_cnt = 0;
    }
  }

  // check the play buttons and play correspoding sequence 
  for (int i = 0; i < channel_cnt; ++i) {
    if (digitalRead(bt_play[i]) == LOW) {
      digitalWrite(led[i], LOW);
      play_ir_data(ir_data[i], ir_data_len[i]);
      digitalWrite(led[i], HIGH);
      delay(250);
    }
  }

Die Bedienung geschieht folgendermaßen:

  • Nach dem Einschalten (Power On) werden die drei Leuchtdioden der Reihe nach kurz durchgeschaltet, um zu zeigen, dass das System aktiv ist.
  • Das Gerät kann drei verschiedene Signale aufzeichnen und abspielen. Dazu dienen die drei Taster. Ein Druck auf einen der Taster bewirkt das Aussenden des entsprechenden Signals. Dabei leuchtet die zugehörige Leuchtdiode kurz auf.
  • Um in den Lernmodus zu gelangen, muss die Anlern-Taste für mindestens 3 Sekunden gedrückt werden. Dann blinkt die Leuchtdiode des ersten Kanals in einem schnellen Rhythmus, um anzuzeigen, dass das Gerät auf ein Signal zum Anlernen wartet. Man sollte jetzt die Fernbedienung auf den IR-Empfänger richten und kurz (!) die gewünschte Funktion drücken. Nachdem das passiert ist, wiederholt sich der Vorgang für Kanal 2 und 3. Damit ist das Gerät programmiert und bereit zum Einsatz.

Hier das ganze Programm.

/* ir_remote_control

This scetch records and reproduces IR signals from any IR remote control.
The IR data is stored in NVS memory and therefore retained beyond power off.
This version supports 3 channels. It can easily be extended to more channels.

Pin assignments:
  bt_learn        input pullup    GPIO16          learn button, push for at least 3 seconds
  bt_play         input pullup    GPIO17, 5, 18   buttons to send signal 
  ir receiver     input           GPIO34          IR receiver chip, e.g.TSOP1138 
  ir transmitter  output          GPIO33          IR transmitter diode, connected via a transistor
  led             output          GPIO27, 26, 25  control leds
  
SLW 10-Sep-2019
*/

#include <Arduino.h>
#include <Preferences.h>

// pin assignments
const int bt_learn = 16;
const int bt_play[] = {17, 5, 18};
const int ir_receiver = 34, ir_transmitter = 33;
const int led[] = {27, 26, 25};

// global constants 
const int channel_cnt = 3;                  // this version supports 3 channels (maximum is 10)
const uint16_t ir_data_size = 250;          // size of ir data sequence
const uint16_t ir_pwm_frequency = 38000;    // ir signal sends at 38kHz
const uint8_t ir_pwm_channel = 0;           // first pwm channel for ir signal
const uint8_t led_pwm_channel = 2;          // second pwm channel for LEDs
const uint16_t led_pwm_frequency = 8;       // frequency for flashing LEDs
const uint32_t ir_read_timeout = 250000;    // max duration is 0.2 sec 

// global variables
Preferences prefs;                            // access to non volatile storage (NVS)
uint32_t ir_data[channel_cnt][ir_data_size];  // IR data set, 3 rows of up to 250 data items each
int ir_data_len[channel_cnt] = {0, 0, 0};
bool learn_bt_pressed = false;

/* startup_msg ---------------------------------------------------------------------------
Flashes the LEDs at startup, just to show that the system is active.
*/
void startup_msg(void) {
  for (int i = 0; i < channel_cnt; ++i) {
    digitalWrite(led[i], LOW);
    delay(500);
    digitalWrite(led[i], HIGH);
  }
}

/* read_ir_data ---------------------------------------------------------------------------
Reads a sequence of ir data via the IR receiver. Captures the start- and stop time points
of each burst and stores them to the array ir_data[]. Data reading continues until either 
the data storage is exceeded or reading timeout is reached. Flashes the corresponding led
during the reading process. 
Input:    channel number
Output:   array ir_data -> ir data points
          int *ir_data_len -> number of data points stored in the array 
*/
void read_ir_data(int channel, uint32_t ir_data[], int *ir_data_len) {
  long start_time;
  uint32_t time_stamp;
  bool edge = true;            // false for falling edge, true for rising edge

  ledcAttachPin(led[channel], led_pwm_channel);                     // flash LED
  ledcWrite(led_pwm_channel, 128);
  *ir_data_len = 0;                                                    // reset array index
  while (digitalRead(ir_receiver) == HIGH);                         // wait for falling edge
  start_time = esp_timer_get_time();                                // capture start time
  do {
    time_stamp = (uint32_t) (esp_timer_get_time() - start_time);    // refresh time stamp 
    if (edge) {                                                     
      if (digitalRead(ir_receiver) == HIGH) {                       // waiting for rising edge
        ir_data[*ir_data_len] = time_stamp;                            // capture time stamp
        ++*ir_data_len;
        edge = false;                                               // switch to falling edge
      };  
    } else {                                                       
      if (digitalRead(ir_receiver) == LOW) {                        // waiting for falling edge
        ir_data[*ir_data_len] = time_stamp;                            // capture time stamp 
        ++*ir_data_len;
        edge = true;                                                // switch to rising edge
      };  
    };
  } while ((time_stamp < ir_read_timeout) &amp;&amp; (*ir_data_len < ir_data_size));
  ledcDetachPin(led[channel]);                                      // clear flashing LED
  digitalWrite(led[channel], HIGH);                                 // switch off LED
}

/* play_ir_data ---------------------------------------------------------------------------
Plays an IR data sequence
Input:  array ir_data -> holds the start- and stop time points for the bursts
        int ir_data_len -> number of data points in the array
*/
void play_ir_data(uint32_t ir_data[], uint16_t ir_data_len) {
  long start_time;
  uint32_t time_stamp;
  bool edge = true;                     // true for active, false for pause
  int i = 0;

  if (ir_data_len == 0) return;         // if data set is empty, then nothing to do

  start_time = esp_timer_get_time();    // capture start time
  ledcWrite(ir_pwm_channel, 128);       // activate IR transmitter
  do {
    time_stamp = esp_timer_get_time() - start_time;
    if (time_stamp >= ir_data[i]) {
      if (edge) {
         ledcWrite(ir_pwm_channel, 0);    // stop IR transmitter
         edge = false;
      } else {
         ledcWrite(ir_pwm_channel, 128);  // activate IR transmitter
         edge = true;
      };
      ++i;
    }
  } while (i < ir_data_len);
  ledcWrite(ir_pwm_channel, 0);           // ensure that IR transmitter is stopped 
}

/* print_ir_data ---------------------------------------------------------------------------
Prints ir data to the serial monitor, for control purpose only
*/
void print_ir_data(int channel, uint32_t ir_data[], uint16_t ir_data_len) {
  int line_cnt = 0;

  Serial.println();
  Serial.print("Channel " + String(channel) + "  IR data (");
  Serial.print(ir_data_len);
  Serial.println("):");
  for (int i = 0; i < ir_data_len; ++i) {
    Serial.print(ir_data[i]);
    if (i < ir_data_len - 1) Serial.print(", ");
    ++line_cnt;
    if (line_cnt > 10) {
      Serial.println();
      line_cnt = 0;
    }
  }
  Serial.println();
}

/* -------------------------------------------------------------------------------------*/
void learn_sequence(void) {
  char ir_len_str[10] = "ir_len_0", ir_data_str[10] = "ir_data_0";
  for (int i = 0; i < channel_cnt; ++i) {
    read_ir_data(i, ir_data[i], &amp;ir_data_len[i]);
    print_ir_data(i, ir_data[i], ir_data_len[i]);
    ir_len_str[7] = '0' + i;
    ir_data_str[8] = '0' + i;
    prefs.putUShort(ir_len_str, ir_data_len[i]);
    prefs.putBytes(ir_data_str, ir_data[i], ir_data_len[i] * sizeof(uint32_t));
  };
}

/* -------------------------------------------------------------------------------------*/
void setup() {
  char ir_len_str[10] = "ir_len_0", ir_data_str[10] = "ir_data_0";

  Serial.begin(115200);

  // read IR data from NVS
  prefs.begin("ir_nvs", false);
  Serial.println();
  Serial.println("Reading NVS ir_data ");
  for (int i = 0; i < channel_cnt; ++i) {
    ir_len_str[7] = '0' + i;
    ir_data_str[8] = '0' + i;
    ir_data_len[i] = prefs.getUShort(ir_len_str);
    Serial.print("Channel " + String(i) + ": ");
    Serial.println(String(ir_data_len[i]) + " Entries");
    if (ir_data_len[i] > 0) 
      prefs.getBytes(ir_data_str, ir_data[i], ir_data_len[i] * sizeof(uint32_t));
  }       
  Serial.println();

  // set pin modes
  for (int i = 0; i < channel_cnt; ++i) {
    pinMode(bt_play[i], INPUT_PULLUP);        // define ports for buttons
    pinMode(led[i], OUTPUT);                  // define ports for LEDs
    digitalWrite(led[i], HIGH);               // switch off the LEDs
  };
  pinMode(bt_learn, INPUT_PULLUP);
  pinMode(ir_receiver, INPUT);
 
  // setup PWM for IR transmitter
  ledcSetup(ir_pwm_channel, ir_pwm_frequency, 8);    // pwm runs with 8 bit resolution
  ledcAttachPin(ir_transmitter, ir_pwm_channel);
  ledcWrite(ir_pwm_channel, 0);                      // switch off the IR transmitter, for now

  // setup PWM for flashing LEDs
  ledcSetup(led_pwm_channel, led_pwm_frequency, 8);

  // show welcome message
  startup_msg();
}

/* -------------------------------------------------------------------------------------*/
void loop() {
  uint8_t learn_pressed_cnt = 0;

  // check whether the learn button is pressed for at least 3 seconds
  while (digitalRead(bt_learn) == LOW) {
    delay(200);
    ++learn_pressed_cnt;
    if (learn_pressed_cnt >= 15) {
      learn_sequence();
      learn_pressed_cnt = 0;
    }
  }

  // check the play buttons and play correspoding sequence
  for (int i = 0; i < channel_cnt; ++i) {  
    if (digitalRead(bt_play[i]) == LOW) {
      digitalWrite(led[i], LOW);
      play_ir_data(ir_data[i], ir_data_len[i]);
      digitalWrite(led[i], HIGH);
      delay(250);
    }
  }
}

Fazit

Eine lernfähige IR-Fernbedienung kann mit dem ESP32 relativ schnell entwickelt werden. Das Gerät arbeitet zuverlässig und hat sich bei verschiedenen Anwendungen bewährt. Die Genauigkeit der Reproduktion ist sehr hoch. Bei mir haben die Zielgeräte bisher klaglos die Signale der “fremden” Fernbedienung akzeptiert.

Bei Bedarf kann die Anzahl der Kanäle weiter erhöht werden, solange GPIOs für Tasten und Leuchtdioden vorhanden sind.

Bleibt die Frage, ob der ESP32 für diese Anwendung Vorteile im Vergleich zum 8-Bit Arduino bietet. Natürlich kann man ein ähnliches Ergebnis auch mit eine Arduino Nano erreichen. Dabei würde es aber im RAM knapp. 3 * 250 Datenpunkte von jeweils 32 Bit belegen mehr als 2 kB und würden das verfügbare RAM des ATmega328 bereits überfordern. Außerdem macht sich beim ESP32 der schnelle Systemtakt für eine höhere Genauigkeit der reproduzierten Signale und der der große Flash-Speicher zum dauerhaften Ablegen der Sequenzen nützlich. Spätestens wenn mehr Kanäle notwendig werden, wird der 8-Bitter nicht ausreichen. Und als Erweiterung kann der ESp32 die Bedienung über ein Web-Interface ermöglichen, was ein weiterer Schritt in Richtung Smart-Home wäre.

Downloads

ESP32

Die 8-Bit Mikroprozessoren sind aus dem elektronischen Alltag nicht mehr wegzudenken. Es gibt kaum ein Projekt in meiner Werkstatt, das nicht mindestens einen ATmega328 verwendet, sei es als “nackter” Prozessor auf der Platine oder zusammen mit USB-Interface und Quarz als Arduino Nano. Die Chips sind zuverlässig, vielseitig und vertraut – wie ein Werkzeug, das sehr gut in der Hand liegt.

Aber das Design der AVR-Prozessoren geht zurück auf die 90er Jahre und stößt natürlich an Grenzen. Besonders wünschenswert wären mehr RAM-Speicher, eine Floating-Point-Unit, mehr Timer und eine höhere Taktrate, die für zeitkritische Anwendungen hilfreich sein kann. Schon seit einiger Zeit gibt es verschiedene 32-Bit-Prozessoren, die mit wesentlich mehr Leistung anbieten und für eigene Entwicklungen geeignet sind. Hervorzuheben sind die STM32-Controller von ST (Nucleo-Boards) oder die SAM D21-Chips von Microchip Technology. Beide haben es auch in die Arduino-Welt geschafft (z.B. Arduino Due, Arduino-Board mit 32-Bit Architektur). Jedoch haben sich diese Chips in der Maker-Szene nicht so durchgesetzt, wie man es hätte erwarten können.

Anders erging des dem ESP32, eine neuere Entwicklung der chinesischen Firma Espressif. Dieser Chip verfügt über (fast) alles, was das Bastler-Herz begehrt, arbeitet mit Taktfrequenzen bis zu 240 MHz und ist darüber hinaus auf kleinen, günstigen Breakout-Boards verfügbar. Espressif hat es geschafft, den ESP32 sehr gut in die Arduino-Welt einzubetten, so dass der Umstieg von anderen Arduinos nicht schwer fällt. So ist es vielleicht nicht überraschend, dass der ESP32 eine beachtliche Verbreitung gefunden hat.

ESP32 Breakout Boards: Links das ESP32 Dev Modul, das leider etwas zu breit für einfache Steckbretter ist, dafür aber sehr preisgünstig. Rechts das etwas teurere ESP32 Pico Board.

Ein Grund für die große Akzeptanz ist sicherlich die Tatsache, dass Espressif WLAN- und Bluetooth-Kommunikation integriert hat. Ein zweiter Prozessor kümmert sich um die Netzwerkschnittstellen und arbeitet die entsprechenden Protokolle ab. Als Anwendungsentwickler hat man auch bei aktivem Netzwerk den Hauptprozessor vollständig zur Verfügung und muss sich nicht mit den Details der Kommunikation beschäftigen. Damit sind auch kleine Projekte ohne großen Aufwand über das Netz erreichbar. Der ESP32 ist zu einer Ikone des Internet of Things (IoT) geworden.

Breakout-Boards

Bei den Boards für den ESP32 gibt es etwas Wildwuchs. In meiner Praxis arbeite ich mit dem ESP32 Dev Module oder dem ESP32 Pico Board. Leider ist das preisgünstige Dev Module etwas zu breit für einfache Breadboards. Man kann aber zwei Breadboards zusammenstecken, so dass das Dev Module gut Platz findet.

ESP32 Dev Module auf zwei zusammengesetzten Breadboards.

Und es muss nicht unbedingt ein Steckbrett sein. Ich habe die Boards mit entsprechenden Buchsen-Leisten auch auf fertigen Platinen im Einsatz.

Die Boards haben viele Pins. Eine gute Übersicht des Pinout gibt es auf GitHub: ESP32-Pinout Map. Die Verwendung der Pins kann allerdings etwas verwirrend sein, weil einige der GPIOs in ihrer Funktionalität eingeschränkt sind. Zum Beispiel sind die Pins 34, 35, 36 und 39 nur als Eingänge verfügbar. Eine digitalWrite()-Anweisung für diese Ports wird von der Arduino-IDE klaglos akzeptiert, bleibt aber völlig wirkungslos. Oder der zweite ADC ist nicht verfügbar, solange der WiFi-Modus aktiv ist. Wenn man diese Dinge nicht weiß, kann die Fehlersuche sehr mühsam werden. Eine gute Zusammenstellung der möglichen Verwendung der Pins gibt es hier: https://randomnerdtutorials.com/esp32-pinout-reference-gpios/

Viel Peripherie

Ich beschäftige mich seit vielleicht zwei Jahren mit dem ESP32 und habe ihn für verschiedene Projekte eingesetzt, durchweg mit guten Resultaten. Selbst wenn das WLAN nicht gebraucht wird, verfügt der Chip über eine ganze Menge nützlicher Peripherie. Neben den GPIOs und den üblichen Schnittstellen (ISP, SPI, UART) gibt es zwei ADCs, einen DAC, Deep-Sleep-Modus, Zugriff auf den Flash zur permanenten Speicherung von Daten und vieles mehr. Einzig die ADCs erfüllen nicht ganz die Erwartungen. Sie arbeiten zwar mit einer Auflösung von bis zu beachtlichen 12 Bit, leider aber mit mäßiger Linearität (siehe ESP32 ADC Non-linear Issue). Damit sind die ADCs für absolute und genaue Messungen nur eingeschränkt brauchbar. Für relative Vergleiche von Messwerten reicht es aber auf jeden Fall. Wenn für eine gegebene Anwendung ein exakter ADC unentbehrlich ist, dann bleibt natürlich noch die Möglichkeit, einen externen ADC über I2C oder SPI anzuschließen. Ein Ansatz, der z.B. beim Raspberry Pi in jedem Fall notwendig ist.

Vereinfachtes Pinout des ESP32 Dev Module

PlatformIO als Entwicklungsumgebung

Es gibt eine Reihe von Entwicklungsumgebungen, die zur Programmierung verwendet werden können. An erster Stelle steht natürlich die Arduino-IDE, die über den Boardverwalter mit der URL https://dl.espressif.com/dl/package_esp32_index.json für den ESP32 leicht erweitert werden kann. Man fühlt sich schnell Zuhause, wenn auch die Arduino-IDE für komplexere Projekte zu einfach gestrickt ist.

Eine positive Überraschung war für mich PlatformIO (https://platformio.org/), eine komfortable, plattform-übergreifende und als Open Source kostenlose IDE, mit der ich inzwischen sehr gerne an Mikrocontroller-Projekten arbeite. Die Bibliotheken für Arduino und für den ESP32 werden als Extensions installiert. Danach hat man eine große Auswahl an ESP32-Boards.

Der Umgang mit PlatformIO ist im Vergleich zur Arduino IDE etwas gewöhnungsbedürftig. Aber schon nach kurzer Zeit findet man alles, was gebraucht wird. Die zentralen Projekt-Einstellungen werden über die Datei platformio.ini vorgenommen. Hier ein typisches Beispiel für den ESP32:

platformio.ini für ein Projekt mit dem ESP32

Der serielle Monitor, als Debugging-Tool unerlässlich, lässt sich unter den Project Tasks mit “Monitor” anwählen. Mit “Upload and Monitor” wird direkt nach dem Laden des Programms der serielle Monitor aktiviert.

PlatformIO Tasks. Hier findet sich auch der serielle Monitor.

Außerdem sind die wichtigsten Funktionen (Übersetzen, Upload, serieller Monitor und mehr) über eine kleine Taskleiste am unteren Bildschirmrand verfügbar.

PlatformIO Task-Leiste mit vielen Funktionen

Die umfangreiche Funktionalität des ESP32 ist weitgehend in Arduino-Funktionen verpackt, so dass die ersten Programme ohne große Umstellung funktionieren. Wenn man auf spezifische Funktionen des ESP32 zugreifen möchte, dann ist auch das kein Problem. Wie üblich im Ardunio Framework sind die Bibliotheken des Herstellers (nach Einbinden der entsprechenden Header-Files) transparent verfügbar, z.B. Funktionen wie adc1_get_raw(), um den ADC anzusprechen, oder esp_adc_cal_raw_to_voltage() für die Umrechnung der Werte mit Kompensierung von Referenzspannungs-Abweichungen (siehe ESP ADC Reference). Damit steht dem tieferen Einstieg in die Welt des ESP32 nichts im Wege – sofern man bereit ist, sich durch die Beschreibungen und Datenblätter zu arbeiten.

Fazit

Der ESp32 hat sich schnell in meinen Arbeitsalltag integriert und kommt oft zum Einsatz. Umfang und Leistungsfähigkeit stehen zwischen den 8-Bit AVRs auf der einen Seite und dem Raspberry Pi auf der anderen Seite. wobei der Chip kaum teurer ist, als ein einfacher Arduino. Die Integration in das Arduino-Framework und die Verfügbarkeit einer leistungsfähigen Open Source-IDE lassen kaum Wünsche offen.