Buzzer-Buttons für 4 Spieler mit einem Arduino
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.
- 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, Aktivern wir die LED des Spielers. Die anderen Buttons haben keine Funktion mehr.
Für eine Sekunde zeigen wir den Schnellsten Spieler mit der zum Button passenden LED and. Optional können wir einen Piezo Lautsprecher ansteuern. Eine Sekunde soll reichen, das Tröten kann ziemlich nervig sein.
Dann Warten wir auf die Freigabe durch den Spielleiter. Erst wenn der Spielleiter seinen Clear Button drückt, wird die LED wieder abgeschaltet und das Spiel kann erneut beginnen. Den Spielleiter und den anderen Spielern wird diese Phase durch Blinken der LED angezeigt.
Bilder sagen mehr als 1000 Worte:
Man könnte die 3 Boxen als Zustände sehen und daher für jeden Zustand Verhaltensspezifikationen angeben:
Angewandt für unser Beispiel sieht das in einem (stark vereinfachten) Zustandsübergangsdiagramm dann so aus:
Schritt für Schritt zur State Machine
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.
Wir bauen den Beispielsketch schrittweise auf. Weiter unten gibts den kompletten Code.
Variablen und Pin Definitionen
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.
const byte p0 = A0; const byte p1 = A1; const byte p2 = A2; const byte p3 = A3; const byte l0 = 2; const byte l1 = 3; const byte l2 = 4; const byte l3 = 5; const byte peep = 12; const byte clearPin = A5;
Ein Button und eine LED sind genau einem Spieler zugeordnet. Wir definieren daher eine Struktur Player mit zwei Variablen.
struct Player { // ein Spieler ... byte buzzer; // ... hat einen GPIO für seinen Buzzer byte led; // ... hat einen GPIO für seine LED };
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.
Wir haben mit unserem Zustandsübergangsdiagramm festgestellt, dass wir 3 Schritte durchführen müssen. 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 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
Wir überlassen die Nummerierung dem Compiler, und werden künftig nur unsere Status "State::IDLE", "State::PEEP", "State::WAIT_FOR_CLEAR" verwenden. Da wir auch den aktuellen Status irgendwo speichern müssen, legen wir uns auch eine Variable state an.
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()
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() { Serial.begin(115200); Serial.println(F("\nBuzzer")); Serial.println(F("press a buzzer..."));
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); // 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) { pinMode(i.buzzer, INPUT_PULLUP); // 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 3 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;
}
}
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); 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; 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 [] {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); 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.