Reise nach Jerusalem mit einem Arduino

Ein weiteres Beispiel für eine "Finite State machine" - einen "Endlicher Zustandsautomat" auf Basis der "Reise nach Jerusalem".

Das Spiel sollte so ablaufen: ein Spielleiter startet die Musik per Knopfdruck. Solange die Musik spielt dürfen die Spieler ihren Buzzer nicht drücken. Die Musik verstummt nach einer zufälligen Zeit. Sobald die Musik verstummt, müssen die Spieler so schnell wie möglich ihren Buzzer drücken. Wer seinen Buzzer als letztes drückt scheidet aus. Nach jeder Runde startet der Spielleiter das Spiel. Das Spiel endet, wenn nur mehr 1 Spieler übrig ist. Gewinner ist der letzte Spieler.

100mm Button An Hardware brauchen wir

  • eine Arduino Uno
  • 4 große Buzzer/Buttons für die Spieler
  • 4 LEDs und
  • 4 Vorwiderstände passend zu den LEDs
  • 1 Piezo Lautsprecher für eine akustische Ausgabe
  • 1 "Clear" Button für den Spielleiter

Die Verkabelung ist ganz übersichtlich: Jeder Button wird an einen eigenen GPIO sowie GND angeschlossen. Ebenso wird jede 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 einfachen Ablaufplan für unsere State Machine damit wir uns nicht verlieren.

Wenn das Spiel beginnt, warten wir auf einen Tastendruck von einem der 4 Buttons. Sobald der erste Button gedrück ist, wird die LED des Spielers aktiviert. Die anderen Buttons haben keine Funktion mehr.

Für eine Sekunde zeigen wir den schnellsten Spieler mit der zum Button passenden LED an. Optional können wir einen Piezo Lautsprecher ansteuern. Eine Sekunde soll reichen, das Piepen kann ziemlich nervig sein.

Dann Warten wir auf die Freigabe durch den Spielleiter. Erst wenn der Spielleiter seinen Clear Button drückt, wird die Sieger LED wieder abgeschaltet und das Spiel kann erneut beginnen. Den Spielleiter und den anderen Spielern wird diese Phase durch Blinken der LED angezeigt.

Man könnte den Zuständen folgende Verhaltensspezifikationen angeben:

Angewandt für unser Beispiel zeichnest du dir zunächst ein Zustandsübergangsdiagramm:. Ich gebe keines vor - aber es könnte ähnlich wie im Buzzerspiel aussehen.

Schritt für Schritt zur State Machine

Eigentlich ist es ein etwas komplexererr Ablauf und du siehst, mit delay() wirst du nicht weit kommen. Aber du kannst einen nicht blockierenden Code - ganz ohne delay() erstellen. Bau den Sketch schrittweise auf. Weiter unten gibts den kompletten Code.

Variablen und Pin Definitionen

Zunächst beginnst du mit den Pin Definitionen. Pins ändern sich nicht daher machst du sie konstant. Vorzeichenlose Variablen mit der Länge von einem Byte reichen für die Zuordnung von Pins. Die Töne der Liedes kommen in ein Array.

Um die Melodie starten, spielen und stoppen zu können erstellst du dir ein eigene Klasse die das zuvor definierte Lied in einer Dauerschleife abspielen kann. Prinzipiell verwendest du Praktikten die aus dem Beispiel "BlinkWithoutDelay" kommen. Du musst nur dafür sorgen dass die Methode run() regelmäßig aufgerufen wird. Das angelegte Objekt (melody Kleingeschrieben) kümmert sich dann um das Abspielen jeder einzelnen Note. Am Ende des Liedes beginnt es wieder von Vorne.

Anschließend erstellst du eine Klasse für die Spieler. Je Spieler benötigst du folgende Membervariablen:

  private:
    bool state = true;                 // ist der Spieler noch im Spiel (true) oder bereits ausgeschieden (false)
    bool sits = false;                 // Sitzt der Spieler
    const uint8_t buzzer;              // GPIO für eine Buzzer / Taster gegen GND
    const uint8_t led;                 // GPIO für eine LED

Mit private verhinderst du den Zugriff auf die Member-Variablen, daher musst du eigene Setter und Getter Methoden zur Verfügung stellen, wenn du die Werte verändern möchtest. Die public: Methoden stellen dein Interface zur Klasse Player dar.

Und schließlich musst du noch tatsächliche Instanzenen für deine  Spieler anlegen. Du könntest die 4 Spieler "anton", "berta", "caesar", "dora" nennen. Echte Namen bringen dir hier aber keinen Vorteil, daher verwenden wir Arrays. Du musst beachten, dass C++ bei der Nummerierung mit 0 (für den ersten Spieler) beginnt. Du legst daher diese 4 Spieler als Array an und weist auch gleich die jeweiligen Pins zu:

Player player[] {                      // dann legen wir für unsere Spieler ein Array [] an und weisen die konkreten GPIOs zu
  {A0, 2},                             // Spieler 0: Buzzer GPIO, LED GPIO
  {A1, 3},                             // Spieler 1
  {A2, 4},                             // Spieler 2
  {10, 5}                              // Spieler 3: 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.

Du hast in deinem Zustandsübergangsdiagramm festgestellt, dass du 3 Schritte durchführen musst. Man könnte die Schritte 0, 1, 2 durchnummerieren, oder eine C++ Enumeration (Aufzählung) verwenden. Genau genommen eine enum class (eine "scoped enumeration"):

// eine Enumeration für die einzelnen Zustände der State Machine
enum class State {WALK_AROUND,         // Spiele Musik
                  SIT_DOWN,            // Musik aus - langsamsten Teilnehmer ermitteln
                  WAIT_FOR_CLEAR       // Warten auf clear durch Spielleiter
                 } state;              // eine Variable in der wir den aktuellen Status speichern

Damit überlässt du die Nummerierung dem Compiler und verwendest künftig nur die Status "State::WALK_AROUND", "State::SIT_DOWN", "State::WAIT_FOR_CLEAR". Da du den aktuellen Status auch irgendwo speichern musst, legst du auch eine Variable state an.

setup()

Der Anfang des  setup() dürfte selbstsprechend sein. Eventuell brauchst du Debug Ausgaben auf dem Serial Monitor, daher startest du die serielle Schnittstelle (und begrüßt den Anwender).

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

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

Für die Button und LED Pins der Spieler legst du dir in der Spieler Klasse eine eigene begin() Methode an. Du rufst im setup() für jeden Spieler die begin() Methode auf. Du könntest das einzeln machen. Praktischerweise hast du deine Spieler in einem Array und kannst daher eine For Schleife verwenden. Im konkreten Fall verwendest du eine Range based for loop:

   for (auto &i : player) i.begin(); // range based for ... i ist nun eine Referenz 

das auto range for erspart dir das übliche "int=0, i < wie_viele_hatten_wir_noch; i++". auto übernimmt den Typ der Objekte player und in der for schleife hast du mit i eine Referenz (daher das &) auf das jeweilige Objekt player. Nun kannst du mit  i.begin() den Pinmode für alle GPIOs  setzen. Willst du 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 3 Status und du kannst dafür einfache switch case Anweisungen für jeden einzelnen Status verwenden. Zusätzlich benötigst du einige Variablen für die State Machine. Variablen die den Durchlauf "überleben" müssen deklarierst du static:

  static uint8_t currentRound = 1;        // current Round
  static uint32_t currentPlayTimeMS = 42; // current planed playtime in MS
  static uint8_t currentPresses = 0;      // wie viele Spieler haben in dieser Runde schon gedrückt
  static uint32_t previousMillis;         // letzter Aufruf
  uint32_t currentMillis = millis();      // aktuellen Zeitstempel merken

Das Gesamtbeispiel

Abschließend der ganze Beispielsketch zum rauskopieren:

/*
  Reise nach Jerusalem
  Basierend auf: Quiz Buzzersystem für 8 Spieler

  Jeder Spieler hat eine LED und einen Button
  Wenn er an den Strom angeschlossen wird, dann soll die hier angezeigte Melodie fuer eine zufällige Zeit anfangen zu spielen.
  In der ersten Runde sollten 3 Spieler weiter kommen -
  das heisst 3 Button werden gedrueckt und dann geht die Melodie weiter und die LED von dem einen Spieler der als letztes gedrueckt hat geht aus.
  Das Spiel sollte dann enden sobald nur noch 1 Spieler da ist.
  https://forum.arduino.cc/index.php?topic=674983.0

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

  copyright by noiasca

  Der Code kann genutzt oder verändert werden, solange der volle Copyright Vermerk und der Verweis auf 
  die ursprüngliche Download-Quelle: 
  https://werner.rothschopf.net/microcontroller/202103_arduino_reise_nach_jerusalem.htm
  als Kommentar erhalten bleibt.
  2022-10-30 minor fix
  2021-05-29
*/

// define the GPIO
const uint8_t clearPin = A3;           // GPIO zum Zurücksetzen des Spiels (statt dem RESET Button)
const uint8_t walkAroundMin = 4;       // kürzeste Spielzeit der Musik (sec)
const uint8_t walkAroundMax = 10;      // längste Spielzeit der Musik (sec)
const uint8_t sitdownMax = 30;         // Beschränkung der Zeit für das "Niedersetzen" (sec)
const uint8_t song[] {49, 33, 37, 33, 49, 33, 37, 33, 37, 41, 44, 37, 41, 44};  // ein Song als Melodie

class Melody {
  private:
    uint32_t previousMillis;           // Zeitmanagement für die Melodie
    uint8_t nextTone = 0;              // welche Note im Song wird als nächstes gespielt
    bool state;                        // läuft der Song gerade
    const uint16_t interval = 500;     // wie viele MS wird jede Note gespielt
    const uint8_t peepPin;                // GPIO
    
  public:
    Melody(const uint8_t peepPin) : peepPin{peepPin} {}

    void start()
    {
      nextTone = 0;
      state = true;
      run();
    }

    void stop()
    {
      noTone(peepPin);
      state = false;
    }

    void run()                                             // Zeitsteuerung für die Melodie, muss laufend aufgerufen werden 
    {
      uint32_t currentMillis = millis();
      if (currentMillis - previousMillis > interval)       // Es ist Zeit für die nächste Note
      {
        previousMillis = currentMillis;
        tone(peepPin, song[nextTone]);
        nextTone++;
        if (nextTone >= sizeof(song) / sizeof(song[0])) nextTone = 0;
      }
    }
};

Melody melody(13);                     // ein Melodyobjekt mit Übergabe des GPIO

class Player {                         // ein Klasse für die Spieler
  private:
    bool state = true;                 // ist der Spieler noch im Spiel (true) oder bereits ausgeschieden (false)
    bool sits = false;                 // Sitzt der Spieler
    const uint8_t buzzer;              // GPIO für eine Buzzer / Taster gegen GND
    const uint8_t led;                 // GPIO für eine LED
    
  public:
    Player (const uint8_t buzzer, const uint8_t led) :
      buzzer(buzzer),
      led(led) {}

    void begin(void)
    {
      pinMode(buzzer, INPUT_PULLUP);   // Buzzer sind Input, auch hier - alle Button schließen gegen Masse
      pinMode(led, OUTPUT);            // LEDs sind Output
      digitalWrite(led, HIGH);         // Zum Spielanfang einschalten
    }

    bool getState()
    {
      return state;
    }

    void setState(bool newState)
    {
      state = newState;
      digitalWrite(led, newState);
    }

    void setSits(bool newSits)
    {
      sits = newSits;
    }

    bool pressed()                     // drückt der Spieler gerade den Button?
    {
      if (digitalRead(buzzer) == LOW)
        return true;
      else
        return false;
    }

    bool getSits()
    {
      return sits;
    }
};

Player player[] {                      // dann legen wir für unsere Spieler ein Array [] an und weisen die konkreten GPIOs zu
  {A0, 2},                             // Spieler 0: Buzzer GPIO, LED GPIO
  {A1, 3},                             // Spieler 1
  {A2, 4},                             // Spieler 2
  {10, 5}                              // Spieler 3: aufpassen, beim letzten kein komma mehr
};

constexpr size_t noOfPlayer = sizeof(player) / sizeof(player[0]);  // einmal die Anzahl der Spieler ermitteln

// eine Enumeration für die einzelnen Zustände der State Machine
enum class State {WALK_AROUND,         // Spiele Musik
                  SIT_DOWN,            // Musik aus - langsamsten Teilnehmer ermitteln
                  WAIT_FOR_CLEAR       // Warten auf clear durch Spielleiter
                 } state;              // eine Variable in der wir den aktuellen Status speichern

void setup() {
  Serial.begin(115200);                          // die Serielle aktivieren, damit man sieht was passiert
  Serial.println(F("\nMusical chairs, also known as Trip to Jerusalem"));
  Serial.println(F("press start to play music ..."));
  pinMode(clearPin, INPUT_PULLUP);               // internen Pullup verwenden, Taster gegen Masse schalten
  for (auto &i : player) i.begin();              // range based for ... i ist nun eine Referenz auf den jeweiligen player
  state = State::WAIT_FOR_CLEAR;                 // Spielbegin: auf eine Freigabe durch den Spielleiter warten
}

void loop() {
  static uint8_t currentRound = 1;               // current Round
  static uint32_t currentPlayTimeMS = 42;        // current planed playtime in MS
  static uint8_t currentPresses = 0;             // wie viele Spieler haben in dieser Runde schon gedrückt
  static uint32_t previousMillis;                // letzter Aufruf
  uint32_t currentMillis = millis();             // aktuellen Zeitstempel merken
                                                 
  switch (state)                                 
  {                                              
    case State::WALK_AROUND :                    // Warten auf die Tastendrücke
      melody.run();                              // dafür sorgen, dass die Musik gegebenenfalls die nächste Note spielt
      
      for (size_t i = 0; i < noOfPlayer; i++)    // prüfen ob ein aktiver Spieler zu früh drückt
      {
        if (player[i].getState() == true && player[i].pressed() == true)
        {
          Serial.print(F("pressed to early index=")); Serial.println(i);
          player[i].setState(false);
          melody.stop();                         // Musik spielt ja noch, müssen wir also stoppen
          state = State::WAIT_FOR_CLEAR;
          Serial.println(F("--> WAIT_FOR_CLEAR"));
        }
      }
      
      if (currentMillis - previousMillis > currentPlayTimeMS)  // prüfen auf Zeitablauf
      {
        melody.stop();                           
        state = State::SIT_DOWN;
        Serial.println(F("--> SIT_DOWN"));
        previousMillis = currentMillis;          // verwenden wir auch für den Timeout zum Niedersetzen
      }

      break;

    case State::SIT_DOWN :                      // Musik ist aus, die Spielen sollen sich auf freien Plätzen niedersetzen
      for (size_t i = 0; i < noOfPlayer; i++)   // prüfen ob ein aktiver Spieler gedrückt hat
      {
        if (player[i].getState() == true && player[i].getSits() == false && player[i].pressed() == true)
        {
          Serial.print(F("player has seated, player=")); Serial.println(i);
          player[i].setSits(true);
          currentPresses++;
        }
      }

      if (currentPresses == currentRound)       // prüfen ob bis auf einen alle Spieler gedrückt haben
      {
        Serial.println(F("enough players have pressed"));
        // wer steht noch rum?
        for (size_t i = 0; i < noOfPlayer; i++)
        {
          if (player[i].getState() == true && player[i].getSits() == false)
          {
            player[i].setState(false); // Spieler rauswerfen
          }
        }
        state = State::WAIT_FOR_CLEAR;
        Serial.println(F("--> WAIT_FOR_CLEAR"));
      }

      if (currentMillis - previousMillis > sitdownMax * 1000UL)   // Zeit ist abgelaufen
      {
        Serial.println(F("timeout"));
        state = State::WAIT_FOR_CLEAR;
        Serial.println(F("--> WAIT_FOR_CLEAR"));
      }
      break;

    case State::WAIT_FOR_CLEAR :                           // warten, bis Clear gedrückt wird
      if (digitalRead(clearPin) == LOW)                    // checken ob Neustart gedrückt wird
      {
        currentRound--;                                    // wir zählen um eine Runde runter
        if (currentRound == 0)                             // sollten wir in Runde 0 angelangt sein, fangen wir ein neues Spiel an
        {
          Serial.println(F("start new game"));
          currentRound = noOfPlayer - 1;                   // minus 1 weil wir einen Spieler je Runde rauswerfen
          for (auto &i : player) i.setState(true);         // alle Spieler wieder aktivieren
        }
        else
        {
          Serial.println(F("start new round"));
        }
        Serial.print(F("currentRound=")); Serial.println(currentRound);
        for (auto &i : player) i.setSits(false);           // alle Spieler sollen auf stehen (= sie sitzen nicht mehr)
        currentPresses = 0;                                // noch hat keiner gedrückt
        currentPlayTimeMS = random(walkAroundMin * 1000UL, walkAroundMax * 1000UL); // Laufzeitzeit für Melodie festlegen
        previousMillis = currentMillis;                    // aktuelle Zeit zurücksetzen
        melody.start();                                    // Melodie einschalten
        state = State::WALK_AROUND;
        Serial.println(F("--> WALK_AROUND"));
      }
      break;
  }
}

Zusammenfassung

  • Du hast einen nicht blockierenden Code mit Hilfe einer Finite State Machine erarbeitet.
  • Die einzelnen Status verwaltest du in einer enum class.
  • Du hast gelernt, wie man von einem Status in den anderen mittels einer Aktion (ein Tastendruck) oder mittels Zeitablauf kommt.
  • Du hast Objekte kennengelernt, um Daten zusammenzufassen und gesehen, dass sich Objekte auch als Array anlegen lassen.
  • Du nutzt künftig die 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: 2021-03-23 | Version: 2024-03-22