Arduino: LED oder Relais per Tastendruck einschalten und mit Zeitablauf abschalten

Auf dieser Seite erarbeiten wir einen Arduino Sketch der per Tastendruck einen Ausgang (und einer daran angeschlossenen LED oder ein Relais) einschalten kann. Nach dem Loslassen des Tasters soll der Ausgang noch einige Zeit aktiv bleiben und nach einer definierten Zeit selbständig wieder abschalten. Ähnlich wie der Lüfter auf dem WC: auch wenn das Licht bereits abgeschalten ist, läuft der Lüfter für kurze Zeit nach.

Ausgehend von einem typischen Anfänger-Sketch verbessern wir den Sketch zu einer nicht-blockierdenden Variante unter Verwendung von millis(). Anschließend lernen wir den Umgang mit Arrays und abschließend lernen wir Strukturen (struct) kennen. Das ganze dauert etwa eine knappe Stunde. Wenn man nach dem ersten Teil nicht aufgibt, gibt es noch einen zweiten Teil zur Objekt Orientierten Programmierung (OOP) ein.

Anfängergerechte blockierende Ablaufvariante

Beginnt man mit der Arduino Programmierung, kommt man vermutlich relativ schnell auf diesen Ablauf:

  • Den Button mit digitalRead auslesen
  • Wenn Button gedrückt
    • LED einschalten
    • 3 Sekunden warten
    • Die LED wieder ausschalten

Ein einfacher Sketch dazu könnte so aussehen:

/*
  by noiasca 
  http://werner.rothschopf.net
*/

const byte buttonPin = A0;     
const byte outputPin = 2;      

void setup() {
  pinMode(buttonPin, INPUT_PULLUP);
  pinMode(outputPin, OUTPUT);
}

void loop() {
  if (digitalRead(buttonPin) == LOW) {
    digitalWrite(outputPin, HIGH);
    delay(3000); // dirty delay
    digitalWrite(outputPin, LOW);
  }
}

Der Sketch funktioniert, aber der Arduino ist die meiste Zeit mit "warten und nichts tun" beschäftigt. Mit jedem Tastendruck ist der Arduino für 3 Sekunden blockiert und man kann den Sketch nicht einfach für mehrere Buttons erweitern. Wir ahnen es schon: wir müssen das delay() elimieren.

Die Verkabelung gestaltet sich einfach: Der Taster schließt GPIO A0 gegen GND. Daher können wir den internen Pullup Widerstand nutzen. An den Ausgang kann eine LED mit Vorwiderstand oder ein Relais-Modul angeschlossen werden. Solltest du andere GPIOs verwenden reicht die Änderung der Konstante.

Nicht blockierende Variante nach "BlinkWithoutDelay"

Mit der Arduino IDE kommt das Beispiel 02. Digital | BlinkWithoutDelay. Dieses zeigt wie du Zeitfunktion millis() nutzen kannst, um nicht blockierenden Code zu schreiben. Während BlinkWithoutDelay die LED sowohl ein wie auch ausschaltet, werden wir den Pin per Tastendruck einschalten und nach Zeitablauf abschalten.

/*
  by noiasca 
  http://werner.rothschopf.net
*/

const byte buttonPin = A0;     
const byte outputPin = 2;      
uint32_t previousMillis = 0;   

void setup() {
  pinMode(buttonPin, INPUT_PULLUP);
  pinMode(outputPin, OUTPUT);
}

void loop() {
  if (digitalRead(buttonPin) == LOW) {
    digitalWrite(outputPin, HIGH);
    previousMillis = millis();   // save the time when we switched on the output
  }
  if (millis() - previousMillis > 3 * 1000UL && digitalRead(outputPin) == HIGH)  
  {
    digitalWrite(outputPin, LOW);
  }
}

Auch in dieser Variante wird mit digitalRead der Button eingelesen. Der Sketch wartet aber nicht mit delay(), sondern merkt sich mit previousMillis = millis(); sozusagen die aktuell Uhrzeit *).

Das zweite if prüft, zwei Bedingungen:
millis() - previousMillis > 3 * 1000UL
prüft, ob die jeweils aktuelle Uhrzeit *) minus der gemerkten Uhrzeit größer als 3 Sekunden ist. Das UL hinter 1000 wird wichtig sobald wir größere Zeiträume als 32767 Millisekunden benötigen. Mit UL wird sichergestellt, dass der Arduino diesen Term als unsigned long berechnet.

digitalRead(outputPin) == HIGH stellt sicher, dass der Funktionsblock der IF Anweisung nur dann ausgeführt wird, wenn der Output auch tatsächlich noch eingeschalten ist.

Beide Bedingungen sind mit dem logischen UND (&&) verknüpft. Sind beide Bedingungen wahr, wird der Output abgeschaltet.

Somit hast du den Sketch auf eine nicht blockierende Variante umgeschrieben.

*) "aktuelle Uhrzeit" ist natürlich nicht ganz korrekt. millis() gibt die Millisekunden seit Start des Arduinos zurück.

Eine eigene Funktion für das Nachlaufrelais

Damit der loop() kurz und übersichtlich bleibt, empfehle ich, das Auslesen des Buttons sowie das Zeitmanagement des Ausgangs in eine eigene Funktion auszulagern. Nenne die Funktion "tickButtonLed()". "tick" sowie das ticken einer mechanischen Uhr. Man könnte auch run... oder do... verwenden. Die Analogie auf die Uhr gefällt mir wegen der Verwendung von millis():

/*
  by noiasca 
  http://werner.rothschopf.net
*/

const byte buttonPin = A0;     
const byte outputPin = 2;      

void tickButtonLed()
{
  static uint32_t previousMillis = 0;  
  if (digitalRead(buttonPin) == LOW) {
    digitalWrite(outputPin, HIGH);
    previousMillis = millis();         
  }
  if (millis() - previousMillis > 3 * 1000UL && digitalRead(outputPin) == HIGH)  
  {
    digitalWrite(outputPin, LOW);
  }
}

void setup() {
  pinMode(buttonPin, INPUT_PULLUP);
  pinMode(outputPin, OUTPUT);
}

void loop() {
  tickButtonLed();
}

Was du auch siehst, mit static uint32_t previousMillis = 0; wird der Scope der Variable geändert. previousMillis muss nicht global sein. Sie wird nur von der Funktion tickButtonLed() benötigt. Normalerweise geht der Wert von lokalen Variablen nach Verlassen einer Funktion verloren. In diesem Fall benötigt der Sketch auch beim nächsten Funktionsaufruf für die Überprüfung, ob die Zeit abgelaufen ist. Du musst daher diese Variable static (statisch) machen. Damit bleibt der Wert auch nach Verlassen der Funktion erhalten.

Zwei Buttons - zwei LEDs

Du hast nun alle Vorrausetzungen um mehrere Buttons mit mehrere LEDs ansteuern zu können. Die GPIO Definitionen ergänzen, das setup() erweitern, die Funktion duplizieren einen neuen Namen vergeben, sowie die neue Funktion auch im loop() aufrufen.

Auch wenn ein derartiger Code funktioniert, sei festgehalten, dass sich allerlei Code-Duplikate im Sketch ergeben würden. Dazu muss es doch eine bessere Variante geben.

Viele Buttons - viele LEDs: Der einfache Umgang mit Arrays

Mit Hilfe von Arrays wird es sehr einfach, gleichartige Daten zu verwalten. Weiters kannst du mittels for-Schleifen auf Arrays bequem zugreifen. Schaue dir zunächst diesen Code an:

/*
  by noiasca 
  http://werner.rothschopf.net
*/
const byte buttonPin[] {A0, A1, A2}; 
const byte outputPin[] {2, 3, 4};    
const byte groups = sizeof(buttonPin);  

void tickButtonLed()
{
  static uint32_t previousMillis[groups];  
  for (byte i = 0; i < groups; i++)
  {
    if (digitalRead(buttonPin[i]) == LOW) {
      digitalWrite(outputPin[i], HIGH);
      previousMillis[i] = millis();   
    }
    if (millis() - previousMillis[i] > 3 * 1000UL && digitalRead(outputPin[i]) == HIGH) 
    {
      digitalWrite(outputPin[i], LOW);
    }
  }
}

void setup() {
  for (byte i = 0; i < groups; i++)
  {
    pinMode(buttonPin[i], INPUT_PULLUP);
    pinMode(outputPin[i], OUTPUT);
  }
}

void loop() {
  tickButtonLed();
}

Statt den einzelnen Variablen für die GPIOs sind nun zwei Arrays definiert:

const byte buttonPin[] {A0, A1, A2}; 
const byte outputPin[] {2, 3, 4};

In den geschweiften Klammern intialisierst du das Array mit den Pins. D.h. die beiden Arrays haben je eine Größe von 3 Elementen. Am Beispiel von outputPin sehen wir uns an, wie der Controller intern die Werte behandelt:

outputPin[0] = 2;
outputPin[1] = 3;
outputPin[2] = 4;

Kommen weitere Buttons und LEDs hinzu, reicht es, die entsprechenden Pins im Array zu ergänzen. Der Kompiler kümmert sich darum das Array groß genug zu machen.

Im Code wirst du öfters die Größe des Arrays benötigen. Du schreibst die Größe des Array aber nicht hardcoded in den Sketch, sondern lässt diese automatisch ermitteln: const byte groups = sizeof(buttonPin);
In groups steht nun die Größe des Arrays - nämlich 3 Elemente - zur Verfügung.

Springen wir kurz in das setup(): Mit Hilfe der for Schleife for (byte i = 0; i < groups; i++) legst du für jeden Pin den pinMode fest. Da groups gleich 3 ist, können wir durch die Indexe 0, 1 und 2 iterieren. Ähnlich verfahren wir auch im tickButtonLed(). Mit Hilfe for (byte i = 0; i < groups; i++) greifen wir auf jeden definierten Button Pin bzw. LED Pin zu.

Der Sketch skaliert sehr einfach: es ist lediglich notwendig, je einen buttonPin und einen ledPin im Array zu ergänzen. Im restlichen Sketch sind keinerlei Änderungen mehr notwendig. Der Sketch wächst mit dem Bedarf mit. Eine Änderung in der Logik (z.B. eine andere Nachlaufzeiten) findet sich ein einziges Mal im Code - und - der Sketch ist weiterhin nicht blockierend. Du brauchst zusätzlich eine blinkende LED - kein Problem, ergänze den Code und lass eine separate LED blinken. Vorrausetzung - auch die Blinkled ist nicht blockierend.

Variablen zu Strukturen (struct) verbinden

Betrachtet man obigen Sketch, stellt man fest, dass die beiden Arrays buttonPin[] und outputPin[] in enger Beziehung stehen. Jede Button-LED Kombination braucht je ein Element dieser beiden Arrays. Genau genommen haben wir noch ein drittes Array: previousMillis[]. In C++ kann man (auch unterschieldliche) Variablen zu einer Struktur verbinden. Es handelt sich um eine Struktur, weil die Daten in einer bestimmten Art und Weise angeordnet und verknüpft werden, um den Zugriff auf sie und ihre Verwaltung effizient zu ermöglichen.

/*
  topic: struct
  by noiasca
  http://werner.rothschopf.net
*/

struct Groups {
  const byte buttonPin;
  const byte outputPin;
  uint32_t previousMillis;
};

Groups group[] {
  {A0, 2, 0},  // index 0: button Pin, outputPin, previousMillis
  {A1, 3, 0},
  {A2, 4, 0}
};

const byte noOfgroups = sizeof(group) / sizeof(group[0]); 

void tickButtonLed()
{
  for (byte i = 0; i < noOfgroups; i++)
  {
    if (digitalRead(group[i].buttonPin) == LOW) {
      digitalWrite(group[i].outputPin, HIGH);
      group[i].previousMillis = millis();       
    }
    if (millis() - group[i].previousMillis > 3 * 1000UL && digitalRead(group[i].outputPin) == HIGH)
    {
      digitalWrite(group[i].outputPin, LOW);
    }
  }
}

void setup() {
  for (byte i = 0; i < noOfgroups; i++)
  {
    pinMode(group[i].buttonPin, INPUT_PULLUP);
    pinMode(group[i].outputPin, OUTPUT);
  }
}

void loop() {
  tickButtonLed();
}

Zunächst müssen wir die Struktur definieren. Nach dem Keyword struct vergeben wir einen Namen. Namen von Strukturen sollen mit einem Großbuchstabe beginnen. Anschließend definieren wir die Variablen die in dieser Struktur verwaltet werden:

struct Groups {
  const byte buttonPin;
  const byte outputPin;
  uint32_t previousMillis;
};

Nach Anlage der Struktur benötigen wir auch eine konkrete Variable. In unserem Falle wieder ein Array da wir mehrere Gruppen verwalten möchten. In den geschweiften Klammern Initialisieren wir jedes Element: Es finden sich wieder die einzelnen verwendeten Pins.

Groups group[] {
  {A0, 2, 0},  // index 0: button Pin, outputPin, previousMillis
  {A1, 3, 0},
  {A2, 4, 0}
};

Die Anzahl an Gruppen lassen wir wieder automatisch ermitteln. Dazu benötigt es einen kleinen Trick. sizeof(group) liefert uns nicht 3 zurück, sondern 18. Warum? Zunächst besteht die Struktur aus 3 Variablen der Größen 1byte + 1byte + uint32_t = 6. Dann haben wir aktuell 3 Elemente angelegt, daher 3 * 6 = 18. So wie wir mit sizeof(group) die Gesamtgröße ermitteln können, können wir auch mit sizeof(group[0]) die Größe des ersten Elements ermitteln. Ein Element hat 6 Byte. Mit einer einfachen Division 18 / 6 kommen wir auf unsere 3 Button-LED Kombinationen. Daher der Code:

const byte noOfgroups = sizeof(group) / sizeof(group[0]); 

Die for Schleife ändert sich (von der Variablenbennennung abgesehen) nicht.

Der Zugriff auf die einzelnen Variablen in der Gruppe ist ganz einfach: in i haben wir wieder die Indexe 0, 1, 2 und können daher in jedem Durchlauf auf die Variablen zugreifen:

group[i].buttonPin
group[i].outputPin
group[i].previousMillis

Mit der Struktur haben wir weitere Ordnung in unsen Code gebracht und einen großen Schritt in die Richtung der objektorientierung gemacht. Aber bleiben wir noch etwas bei der prozeduralen Programmierung.

Abschließendes Beispiel: range based for Loop für Arrays

Jetzt haben wir schon mehrmals sizeof(array) verwendet um die Größe von Arrays zu ermitteln. Oft brauchen wir das für for Schleifen. Mit C++11 wurden sogennante Range based for Loops eingeführt die eine weitere Vereinfachung ermöglichen. Wieder ein Beispiel:

/*
  topic: range based for loop
  by noiasca
  http://werner.rothschopf.net
*/

struct Groups {
  const byte buttonPin;
  const byte outputPin;
  uint32_t previousMillis;
};

Groups group[] {
  {A0, 2, 0},  // index 0: button Pin, outputPin, previousMillis
  {A1, 3, 0},
  {A2, 4, 0}
};

void tickButtonLed()
{
  for (auto & i : group)
  {
    if (digitalRead(i.buttonPin) == LOW) {
      digitalWrite(i.outputPin, HIGH);
      i.previousMillis = millis();   
    }
    if (millis() - i.previousMillis > 3 * 1000UL && digitalRead(i.outputPin) == HIGH) 
    {
      digitalWrite(i.outputPin, LOW);
    }
  }
}

void setup() {
  for (auto & i : group)
  {
    pinMode(i.buttonPin, INPUT_PULLUP);
    pinMode(i.outputPin, OUTPUT);
  }
}

void loop() {
  tickButtonLed();
}

Zunächst fällt auf, dass wir keinen separten Zähler noOfGroups mehr brauchen.

Dann kommt die erste range based for loop:

  for (auto & i : group)

Wir haben wieder eine Variable i. Diese ist jedoch nicht vom Typ byte, sondern erhält "automatisch" den Typ der Variable group. In unserem Fall ist group unsere angelegte Struktur Groups.Weiters legen wir keine Kopie des einzelnen group elements an, sondern wir erstellen mit dem & lediglich eine Referenz auf das jeweilige Element. D.h. bei jedem Durchgang - für jedes Element des Array group - steht die Referenz (i) zur Verfügung. i referenziert beim ersten Durchang auf group[0], dann auf group[1], und schließlich auf group[2]. Daher können wir in der range based for loop vereinfacht auf die Variablen von group zugreifen:

i.buttonPin 
i.outputPin
i.previousMillis

Gleiches wendest du im setup beim Festlegen von pinMode an..

Schlussendlich sparst du mit der range based for loop eine Variable sowie eine Menge Schreibarbeit.

Damit endet der Teil 1 der prozeduralen Programmierung. Wenn du noch einen weiteren Schritt machen möchtest, empfehle ich den Teil 2: Objekt Orientierte Programmierung unter C++ für den Arduino.

Zusammenfassung

  • Du hast schrittweise einen Sketch mit delay() auf nicht blockiernde Programmierung mit Hilfe von millis() umgebaut
  • Anschließend hast du gelernt, gleichartige Daten in Arrays zu halten
  • mit sizeof kannst du die Größe von Arrays ermitteln
  • Dann hast du C++ Strukturen kennen gelernt, auch diese können in Arrays verwaltet werden
  • um sie dann noch einfacher einer range based for loop zu behandeln

Links

Die mit Sternchen (*) gekennzeichneten Verweise sind sogenannte Affiliate/Provision-Links. Wenn du auf so einen Verweis klickst und über diesen Link einkaufst, bekomme ich von deinem Einkauf eine (kleine) Provision. Für dich verändert sich der Preis dadurch nicht. Ich empfehle nur Produkte die ich selber besitze und wenn ich überzeugt bin, dass sie für andere Maker interesssant sind.

 

Protokoll

First upload: 2020-04-08 | Version: 2021-08-27