Arduino: Ein- /Ausverzögerung mit einem endlichen Automaten (OOP Variante)

Ursprünglich hätten es Buzzer-Buttons für 4 Spieler mit einem Arduino werden sollen, um festzustellen, welcher Spieler seinen Button als Erster gedrückt hat. Im Laufe der Programmentwicklung hat sich dann ergeben, dass dies ein schönes Beispiel für eine "Finite State machine" - einen "Endlicher Zustandsautomat" ist.

Das Spiel sollte später so ablaufen, dass ein Spielleiter eine Frage stellt und der jenige der die Antwort weiß buzzert (einen Button drückt). Damit eindeutig entschieden werden kann wer am schnellsten war, hat jeder Spieler einen Buzzer (einen Button) und eine LED die leuchtet, wenn er der erste war. Weitere Betätigungen von anderen Buzzern sollten gesperrt werden. Der Spielleiter gibt die Runde frei und setzt das System zurück.

An Hardware brauchen wir

  • eine Arduino Uno
  • 1 buttons
  • 1 LED mit Vorwiderstand oder ein Relaismoduld

Die Verkabelung ist ganz einfach: der Button wird an einen eigenen GPIO sowie GND angeschlossen. Ebenso wird die LED über einen entsprechenden Vorwiderstand (je nach LED Farbe und Größe so zwischen 220-470KOhm) an einen GPIO sowie GND angeschlossen. Einen Probeaufbau kann man auf einem Breadboard machen:

Ein einfacher Ablaufplan für unsere State Machine

Zunächst machen wir uns einen einfaches Zeitdiagramm vom gewünschten Ablauf

Das Zeitdiagramm macht den Ablauf einer Ein- und Ausschaltverzögerung sichtbar. Wir erkennen folgende 4 Phasen:

  • Nach dem Starten wartet das System auf einen Tastendruck. Wir nennen die Phase IDLE
  • Wird der Button gedückt, bleibt der Ausgang noch unverändert. Erst bei Ablauf einer Wartezeit ("on delay") wird der Ausgang eingeschaltet. (ONDELAY)
  • Solange der Button gedrückt bleibt, soll keine Änderung stattfinden (ON)
  • Wird der Button losgelassen soll der Ausgang eine gewisse Zeit ("off delay") weiterhin anbleiben. (OFFDELAY)

Nach der 4. Phase ist das System wieder im IDLE und wartet auf den nächsten Tastendruck. Die 4 Phasen stellen unsere Zustände für unseren Zustandsautomaten (engl. State Machine) dar. Man kann nun jeden Zustand bestimmte Verhaltensspezifikationen angeben:

Angewandt für unser Beispiel sieht das in einem (stark vereinfachten) Zustandsübergangsdiagramm dann so aus:

<erklärung notwendig>

Der objektorientierte Ansatz

Eigentlich ist es ein ganz einfacher Ablauf denn man auch mittels delay() machen könnte. Aber wir schreiben hier einen nicht blockierenden Code um später Erweiterungen einbauen zu können, entweder weitere Ein- Ausschaltverzögerungen oder eine Blink-LED zur Anzeige des geschaltenen Relais.

Man kann das Programm prozedural schreiben, eine Lösung mit objektorientierter Programmierung ist aber nicht viel schwierieger und eröffnet uns aber viele Erweiterungsmöglichkeiten, also warum nicht etwas neues ausprobieren.

Wir bauen den Beispielsketch schrittweise auf. Weiter unten gibts den kompletten Code.

Schritt für Schritt zur State Machine

Wir haben mit unserem Zustandsübergangsdiagramm festgestellt, dass wir 4 Phasen benötigen durchführen müssen. Man könnte die Schritte 0, 1, 2 4 durchnummerieren, oder eine C++ Enumeration (Aufzählung) verwenden. Genau genommen eine enum class (eine "scoped enumeration"):

enum class State : byte {                   // we have 4 states, so byte is enough 
  IDLE,                                     // wait for button press, output is off
  ONDELAY,                                  // wait a specified time, output is still off
  ON,                                       // button is still pressed and output is on
  OFFDELAY                                  // button was released, output is on
};

Wir überlassen die Nummerierung dem Compiler, und werden künftig nur unsere Status als State::IDLE State::ONDELAY State::ON und State::OFFDELAY verwenden.

Die eigentliche State Machine

 

 

Unsere Klasse für das Ein-Ausschalt Delay Objekt

Wir beginnen mit der Klassendefinition:

class OnOffDelay                             // a class for an on off delay
{
  private:
    const byte buttonPin;                    // GPIO for the button
    const byte outputPin;                    // GPIO for the LED/Relay which should be effected
    uint32_t previousMillis = 0;             // timestamp
    byte onDelay = 3;                        // delay after ON press (wait time)
    byte offDelay = 5;                       // delay after OFF release (wait time)
    State state = State::IDLE;               // stores the actual state of the state machine

  public:
    OnOffDelay(byte buttonPin, byte outputPin) :  // a constructor with initializer list
      buttonPin(buttonPin),
      outputPin(outputPin)
    {}

    void begin() {                            // a method which should be called in setup()
      pinMode(buttonPin, INPUT_PULLUP);
      digitalWrite(outputPin, HIGH);
      pinMode(outputPin, OUTPUT);
    }

    void tick() {               // a method which should be called permanently in loop()
      switch (state)
      {
        case State::IDLE:
          if (digitalRead(buttonPin) == LOW)
          {
            previousMillis = millis();
            state = State::ONDELAY;
          }
          break;
        case State::ONDELAY:                                       
          if (millis() - previousMillis > onDelay * 1000UL)
          {
            digitalWrite(outputPin, HIGH);
            previousMillis = millis();
            state = State::ON;
          }
          break;
        case State::ON:
          if (digitalRead(buttonPin) == HIGH)                     
          {
            previousMillis = millis();
            state = State::OFFDELAY;
          }
          break;
        case State::OFFDELAY:
          if (millis() - previousMillis > offDelay * 1000UL)
          {
            digitalWrite(outputPin, LOW);
            previousMillis = millis();
            state = State::IDLE;
          }
          break;
      }
    }
};

Unter einer Klasse (auch Objekttyp genannt) versteht man in der objektorientierten Programmierung einen Bauplan für eine Reihe von ähnlichen Objekten. Klingt jetzt komplizierter als es ist. Eine Klasse ist der Oberbegriff, die Objekte sind dann einzelne Instanzen. Oder: Annahme du definierst eine Klasse "Obst" dann wären Objekte z.B. apfel, birne, citrone. Üblicherweise lässt man Klassennamen mit Großbuchstaben beginnen.

Zurück zum Arduino: Jedes unserer Objekte braucht einen buttonPin und outputPin, auch diese sind wieder konstant. Einen Zeitstempel speichern wir in der Variable previousMillis und legen den Intervall fest. Diese Variablen sind "privat" - das heißt sie sind außerhalb der Klasse nicht direkt änderbar:

 

Zunächst beginnen wir mit den Pin Definitionen. Pins ändern sich nicht daher machen wir sie konstant. Byte Variablen reichen für die Zuordnung der Pins.

xxx

 

und schließlich müssen wir noch eine tatsächliche Instanzen für unsere Spieler anlegen. Wir könnten die 4 Spieler "Anton", "Berta", "Cäsar", "Dora" nenne. Echte Namen bringen uns aber keinen Vorteil. Wir legen daher diese 4 Spieler als Array an und weisen auch gleich die jeweiligen Pins zu:

Player const player[] {
  {p0, l0},                            // Buzzer GPIO, LED GPIO
  {p1, l1},
  {p2, l2},                           
  {p3, l3}                             // aufpassen, beim letzten kein komma mehr
};

Ob 2, 4 oder 8 Spieler spielt nun fast keine Rolle mehr. Eine Erweiterung ist nur mehr durch die verwendbaren GPIOs und den verfügbaren Speicher begrenzt.

Dann folgen noch zwei Variablen, deren Verwendung sich im laufe des Programms noch ergibt:

int8_t pressed = -1;                   // welcher Button wurde gedrückt, 
uint32_t previousMillis = 0;           // Zeitstempel wie in "BlinkWithoutDelay"

In die Variable pressed übernehmen wir den gedrückten Button. Da C++ Index-Zählungen mit 0 beginnt ist 0 der erste Button. Daher initialisieren wir die Variable auf -1. Das ist übrigens die einzige Magic Number im Code. Ich denke, 0, 1 und -1 sind akzeptable "Magic Numbers".

previousMillis verwenden wir zum Speichern von Zeitstempeln.

setup()

aufruf des Objektes zum festlegen der Pin Definitionen

 

Der Anfang des  setup() dürfte selbstsprechend sein. Eventuell brauchen wir Debug Ausgaben auf den Serial Monitor, daher starten wir die Serielle Schnittstelle (und begrüßen den Anwender)

void setup() {
  

Alle Buttons verwenden den internen Pullup und werden gegen GND geschaltet, sind im Idle daher HIGH und bei Tastendruck LOW. Wir beginnen mit dem Button für den Spielleiter:

  pinMode(clearPin, INPUT_PULLUP);     // internen Pullup verwenden, Taster gegen Masse schalten

Wir könnten nun die weiteren 8 GPIO's separat angeben. Praktischerweise haben wir diese jedoch in einem Array und konnen diese bequem in for-Schleifen abarbeiten. Ja eigentlich haben wir sogar eine Struktur angelegt womit wir nun nur eine einzige for-Schleife benötigen.

  for (auto &i : player)               // range based for
  {
    pinMode(i.buzzer, INPUT_PULLUP);   // Buzzer sind Input, auch hier - alle gegen Masse
    pinMode(i.led, OUTPUT);            // LEDs sind Output
  }

das auto range for erspart uns das übliche "int=0, i < wie_viele_hatten_wir_noch; i++". auto übernimmt den Typ der Struktur player und in der for schleife haben wir mit i eine Referenz (daher das &) auf das jeweilige Objekt player. Nun können wir mit i.buzzer bei den Buttons die Pullups einschalten bzw. mit i.led die Ausgänge definieren. Will man den Sketch auf 8 Spieler erweitern, so reicht die Erweiterung des Array player[]. Das Setup kann unverändert bleiben!

loop()

Im loop arbeitet eine State Machine je nach Status den jeweiligen Abschnitt ab. Die State Machine hat aktuell nur 4 Status, doch wir können dafür einen einfache switch case Anweisung für jeden einzelnen Status verwenden.

void loop() {
  switch (state)
  {
    case State::IDLE :                      // Warten auf den ersten Tastendruck
      for (byte i = 0; i < sizeof(player) / sizeof(player[0]); i++)          
      {
        if (digitalRead(player[i].buzzer) == LOW)
        {
          Serial.print(F("pressed index=")); Serial.println(i);
          pressed = i;                       // gedrückten index merken
          digitalWrite(player[pressed].led, HIGH);
          tone(peep, 500);
          previousMillis = millis();
          state = State::PEEP;               // State Machine weiterdrehen
          Serial.println(F("next: PEEP"));
          break;                             // wir haben einen Tastendruck
        }
      }
      break;

Wir beginnen mit dem Status State::IDLE. Wir müssen die 4 Spieler Buttons abfragen, ob einer davon gedrückt wurde. Da wir die Buttons in einem Array haben, können wir eine For Schleife nutzen. Anstatt dass wir hardcoded 4 Durchläufe definieren, ermitteln wir die notwendigen Durchläufe dynamisch. Dazu nehmen wir die Gesamtgröße vom struct und dividieren durch die Größe des ersten Elements und haben somit die Gesamtanzahl unserer Elemente in der Struktur.

Zur Erinnerung: wir haben die Pullups aktivert und erwarten ein LOW wenn der Button gegen GND schließt. In der Variable pressed merken wir uns den gedrückten Button. Das einschalten des Buttons und der Beginn der Tonausgabe ist klar. In previousMillis übernehmen wir den aktuellen Zeitstempel von millis() - das kennen wir aus dem Beispiel "BlinkWithoutDelay". Nun können unsere State Machine noch in den nächsten Status bringen. Da wir den ersten Tastendruck gefunden haben, macht es wenig Sinn die restlichen Buttons abzufragen. Daher brechen wir mit break aus der for-Schleife aus.

Im State::PEEP lassen wir für 1 Sekunde den Piezo piepsen:

    case State::PEEP :                       // den Peeper laufen lassen
      if (millis() - previousMillis > 1000)  // Einschaltdauer für den Peep
      {
        noTone(peep, LOW);
        state = State::WAIT_FOR_CLEAR;
        Serial.println(F("next: WAIT_FOR_CLEAR"));
      }
      break;

Sind 1000 Millisekunden vergangen schalten wir den Ton wieder ab und schalten weiter in den Status State::WAIT_FOR_CLEAR.

Im letzten Status erledigen wir zwei Aufgaben. Wir bringen die Sieger-LED zum blinken. Dazu wenden wir das klassische Beispiel Blinken ala "BlinkWithoutDelay" an.

   case State::WAIT_FOR_CLEAR :              // warten, bis Clear gedrückt wird
      if (millis() - previousMillis > 500)
      {
        digitalWrite(player[pressed].led, !digitalRead(player[pressed].led));    // Sieger LED blinken
        previousMillis = millis();
      }

Außerdem müssen wir den clearPin ab abfragen ob dieser gedrückt wurde (gegen GND, daher LOW).

      if (digitalRead(clearPin) == LOW)       // checken ob Neustart gedrückt wird
      {
        Serial.println(F("clear game"));
        digitalWrite(player[pressed].led, LOW);
        pressed = -1;
        state = State::IDLE;
        Serial.println(F("next: IDLE (=start new)"));
      }
      break;
  }
}

Wurde der clearPin gedrückt, so stellen wir den Siegerbutton (pressed) wieder zurück auf -1.

Das Gesamtbeispiel

Abschließend der ganze Beispielsketch zum rauskopieren:

*
  Quiz Buzzersystem für 8 Spieler

  Es sollte später so ablaufen, 
  dass jemand eine Frage stellt und wer die Antwort weiß buzzert.
  Damit eindeutig entschieden werden kann wer schneller war, 
  hat jeder Spieler eine LED die leuchtet, wenn er der erste war.
  https://forum.arduino.cc/index.php?topic=674983.0

  read buttons
  switch LEDs
  blink LED according "BlinkWithoutDelay"
  write non-blocking code

  by noiasca

  2020-04-03
*/

// define the GPIOs for buttons and LEDs
const byte p0 = A0; // Buzzer index 0! - Buttons müssen nach GND geschaltet werden
const byte p1 = A1;
const byte p2 = A2;
const byte p3 = A3; // im prinzip egal, ich brauch nur den A3 als Clear Game weiter unten
const byte l0 = 2;  // LED index 0!
const byte l1 = 3;
const byte l2 = 4;
const byte l3 = 5;
const byte peep = 12;                  // der Pieper als Signalton
const byte clearPin = A5;              // zum Zurücksetzen des Spiels

struct Player {                        // ein Spieler ...
  byte buzzer;                         // ... hat einen GPIO für seinen Buzzer
  byte led;                            // ... hat einen GPIO für seine LED
};

Player const player[] {                // dann legen wir für unsere Spieler ein Array an
  {p0, l0},                            // Buzzer GPIO, LED GPIO
  {p1, l1},
  {p2, l2},                            
  {p3, l3}                             // aufpassen, beim letzten kein komma mehr
};
// eine Enumeration für die Status unserer State Machine
enum class State {IDLE,                // warten auf den schnellsten Buzzer
                  PEEP,                // Peep laufen lassen
                  WAIT_FOR_CLEAR       // Warten auf clear
                 } state;              // eine Variable in der wir den aktuellen Status speichern

int8_t pressed = -1;                   // welcher Button wurde gedrückt
uint32_t previousMillis = 0;           // Zeitstempel wie in "BlinkWithoutDelay"

void setup() {
  Serial.begin(115200);                // die Serielle aktivieren, damit man sieht was passiert
  Serial.println(F("\nBuzzer"));
  Serial.println(F("press a buzzer..."));
  pinMode(clearPin, INPUT_PULLUP);     // internen Pullup verwenden, Taster gegen Masse schalten
  for (auto &i : player)               // range based for
  {
    pinMode(i.buzzer, INPUT_PULLUP);   // Buzzer sind Input, auch hier - alle gegen Masse
    pinMode(i.led, OUTPUT);            // LEDs sind Output
  }
}

void loop() {
  switch (state)
  {
    case State::IDLE :                      // Warten auf den ersten Tastendruck
      for (byte i = 0; i < sizeof(player) / sizeof(player[0]); i++)
      {
        if (digitalRead(player[i].buzzer) == LOW)
        {
          Serial.print(F("pressed index=")); Serial.println(i);
          pressed = i;                       // gedrückten index merken
          digitalWrite(player[pressed].led, HIGH);
          tone(peep, 500);
          previousMillis = millis();
          state = State::PEEP;               // State Machine weiterdrehen
          Serial.println(F("next: PEEP"));
          break;                             // wir haben einen Tastendruck
        }
      }
      break;
    case State::PEEP :                       // den Peeper laufen lassen
      if (millis() - previousMillis > 1000)  // Einschaltdauer für den Peep
      {
        noTone(peep, LOW);
        state = State::WAIT_FOR_CLEAR;
        Serial.println(F("next: WAIT_FOR_CLEAR"));
      }
      break;
    case State::WAIT_FOR_CLEAR :              // warten, bis Clear gedrückt wird
      if (millis() - previousMillis > 500)
      {
        digitalWrite(player[pressed].led, !digitalRead(player[pressed].led));    // Sieger LED blinken
        previousMillis = millis();
      }
      if (digitalRead(clearPin) == LOW)       // checken ob Neustart gedrückt wird
      {
        Serial.println(F("clear game"));
        digitalWrite(player[pressed].led, LOW);
        pressed = -1;
        state = State::IDLE;
        Serial.println(F("next: IDLE (=start new)"));
      }
      break;
  }
}

Zusammenfassung

  • Wir haben einen nicht blockierenden Code mit Hilfe einer Finite State Machine erarbeitet.
  • Die einzelnen Status verwalten wir über eine enum class.
  • Wir haben gelernt, wie man von einem Status in den anderen mittels einer Aktion (ein Tastendruck) oder mittels Zeitablauf kommt.
  • Wir haben Strukturen kennengelernt, um Daten zusammenzufassen.
  • Wir nutzen range based for loop.

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 interesssant sind.


Protokoll

First upload: 2020-04-05 | Version: 2024-03-22