OLED-Menüsystem mit Rotary Encoder bauen: Scrollbare Menüs für deine Arduino-Projekte
Ein Poti pro Einstellung, ein Taster pro Funktion – so wächst dein Projekt schnell zu einem Kabelsalat, der kaum noch in ein Gehäuse passt. Dabei geht es viel eleganter: Ein einziger Rotary Encoder und ein kleines OLED-Display ersetzen ein halbes Dutzend Bedienelemente. Drehen zum Navigieren, drücken zum Bestätigen – genau wie am Backofen, am 3D-Drucker oder am Autoradio. In diesem Tutorial bauen wir Schritt für Schritt ein vollwertiges Menüsystem: von der sauberen Encoder-Auswertung per Interrupt über ein scrollbares Menü bis hin zu Untermenüs und Werten, die du direkt am Rad einstellst. Am Ende hast du eine wiederverwendbare Menü-Vorlage, die du in jedes eigene Projekt übernehmen kannst.
Warum ein Rotary Encoder statt Taster und Potis?
Ein Drehimpulsgeber (englisch Rotary Encoder) ist kein Poti. Ein Poti hat einen Anfang und ein Ende und liefert einen absoluten Spannungswert. Ein Encoder dreht sich endlos und liefert stattdessen Impulse: Bei jeder Rastung sagt er dir nur, ob du nach links oder rechts gedreht hast. Genau das macht ihn zum perfekten Navigations-Werkzeug.
- Endlos drehbar: Kein Anschlag – du kannst durch beliebig lange Menülisten scrollen.
- Integrierter Taster: Die meisten Encoder wie der KY-040 haben einen Druckschalter in der Achse. Drehen = navigieren, Drücken = auswählen. Ein Bauteil, zwei Funktionen.
- Ein Bedienelement für alles: Statt zehn Tasten brauchst du genau einen Knopf für ein komplettes Menü mit Untermenüs und Einstellungen.
- Wertig und robust: Der metallene EC11-Encoder fühlt sich beim Drehen deutlich hochwertiger an als jeder Taster – und hält zehntausende Rastungen aus.
ℹ️ KY-040 verstehen: Der Rotary Encoder KY-040 hat fünf Pins: CLK (A), DT (B), SW (Taster), + und GND. CLK und DT sind die beiden Signale, aus deren Reihenfolge sich die Drehrichtung ergibt. SW ist der Taster, der beim Drücken auf GND zieht. Die Grundlagen zum reinen Auslesen haben wir im Artikel Rotary Encoder KY-040 auslesen ausführlich behandelt – hier bauen wir darauf ein komplettes Menü.
Das brauchst du
💡 Set spart Kabel: Wenn du sowieso mit Display und Encoder anfängst, ist das Set aus 1,3-Zoll-OLED und EC11-Encoder praktisch: Beide Bauteile passen perfekt zusammen, und du hast direkt die richtige Kombination für ein Bediengerät. Wer schon ein 0,96-Zoll-OLED herumliegen hat, kann auch das nehmen – der Code ist identisch, weil beide denselben SSD1306-Controller nutzen.
Verkabelung
OLED und Encoder teilen sich Strom und Masse. Das Display hängt am I2C-Bus (A4/A5 beim UNO/Nano), der Encoder an drei digitalen Pins. Wichtig: Die beiden Encoder-Signale CLK und DT gehören an interruptfähige Pins – beim UNO/Nano sind das D2 und D3.
| Bauteil | Pin am Modul | Arduino UNO/Nano | Anmerkung |
|---|---|---|---|
| OLED | VCC | 5V | Modul hat 3,3V-Regler an Bord |
| OLED | GND | GND | – |
| OLED | SDA | A4 | I2C-Daten |
| OLED | SCL | A5 | I2C-Takt |
| Encoder | + (VCC) | 5V | – |
| Encoder | GND | GND | – |
| Encoder | CLK | D2 | Interrupt-Pin! |
| Encoder | DT | D3 | Interrupt-Pin! |
| Encoder | SW | D4 | Taster, mit INPUT_PULLUP |
⚠️ Pull-Ups nicht vergessen: Das KY-040-Modul hat auf CLK und DT bereits Pull-Up-Widerstände bestückt, am nackten EC11-Encoder fehlen sie. Falls dein Encoder wild Werte springt oder nur in eine Richtung zählt, aktiviere im Code für alle drei Eingänge INPUT_PULLUP – das schadet nie und stabilisiert die Signale deutlich.
1Den Encoder sauber auslesen – per Interrupt
Der häufigste Anfängerfehler: den Encoder im loop() pollend abfragen. Drehst du schnell, verpasst der Arduino Impulse, und die Zählung springt. Die saubere Lösung ist ein Interrupt: Sobald sich CLK ändert, unterbricht der Prozessor kurz und wertet die Drehrichtung aus. So geht kein Impuls verloren, egal wie hektisch du drehst.
Zuerst der reine Encoder-Test, damit du die Hardware prüfst, bevor Menü-Logik dazukommt:
#define ENC_CLK 2 // Interrupt-Pin
#define ENC_DT 3
#define ENC_SW 4 // Taster
volatile int encoderPos = 0; // von der ISR veraendert -> volatile!
volatile bool bewegt = false;
void setup() {
Serial.begin(9600);
pinMode(ENC_CLK, INPUT_PULLUP);
pinMode(ENC_DT, INPUT_PULLUP);
pinMode(ENC_SW, INPUT_PULLUP);
// Bei jeder fallenden Flanke an CLK die ISR ausloesen
attachInterrupt(digitalPinToInterrupt(ENC_CLK), encoderISR, FALLING);
Serial.println("Encoder-Test: drehen und druecken");
}
// Interrupt Service Routine – muss kurz sein!
void encoderISR() {
// Steht DT auf HIGH, drehen wir in die eine, sonst in die andere Richtung
if (digitalRead(ENC_DT)) {
encoderPos++;
} else {
encoderPos--;
}
bewegt = true;
}
void loop() {
if (bewegt) {
Serial.print("Position: ");
Serial.println(encoderPos);
bewegt = false;
}
if (digitalRead(ENC_SW) == LOW) {
Serial.println("Klick!");
delay(250); // simple Entprellung fuer den Test
}
}
ℹ️ Warum volatile? Variablen, die eine ISR verändert und der loop() liest, müssen als volatile deklariert sein. Sonst optimiert der Compiler den Zugriff weg und liest einen veralteten Wert aus einem Register. Merke: Alles, was zwischen Interrupt und Hauptprogramm ausgetauscht wird, ist volatile.
2Das OLED vorbereiten und ein erstes Menü zeichnen
Für das Display nutzen wir die bewährten Bibliotheken Adafruit SSD1306 und Adafruit GFX. Installiere beide über den Bibliotheksverwalter. Ein 128x64-Display fasst bei Textgröße 1 rund acht Zeilen – ideal für ein Menü mit Überschrift und mehreren Einträgen.
Wir definieren das Menü als einfaches String-Array und zeichnen den aktuell gewählten Eintrag invertiert (heller Balken), damit man sofort sieht, wo man steht:
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#define ENC_CLK 2
#define ENC_DT 3
#define ENC_SW 4
Adafruit_SSD1306 display(128, 64, &Wire, -1);
const char* menue[] = {
"LED-Helligkeit",
"Blinkrate",
"Temperatur",
"Modus",
"Info"
};
const int menueLen = 5;
volatile int encoderPos = 0;
int auswahl = 0;
void encoderISR() {
if (digitalRead(ENC_DT)) encoderPos++;
else encoderPos--;
}
void setup() {
pinMode(ENC_CLK, INPUT_PULLUP);
pinMode(ENC_DT, INPUT_PULLUP);
pinMode(ENC_SW, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(ENC_CLK), encoderISR, FALLING);
display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
zeichneMenue();
}
void zeichneMenue() {
display.clearDisplay();
display.setTextSize(1);
// Titelzeile
display.setCursor(0, 0);
display.println("== HAUPTMENUE ==");
// Menueeintraege ab y=16, je Zeile 12 Pixel hoch
for (int i = 0; i < menueLen; i++) {
int y = 16 + i * 12;
if (i == auswahl) {
// heller Balken hinter dem aktiven Eintrag
display.fillRect(0, y - 1, 128, 11, SSD1306_WHITE);
display.setTextColor(SSD1306_BLACK);
} else {
display.setTextColor(SSD1306_WHITE);
}
display.setCursor(4, y);
display.println(menue[i]);
}
display.display();
}
void loop() {
// Drehung in Menue-Auswahl umsetzen
static int letzte = 0;
if (encoderPos != letzte) {
auswahl += (encoderPos > letzte) ? 1 : -1;
letzte = encoderPos;
// an den Grenzen umbrechen (wrap-around)
if (auswahl < 0) auswahl = menueLen - 1;
if (auswahl >= menueLen) auswahl = 0;
zeichneMenue();
}
}
Ab jetzt dreht sich der helle Balken durch die Liste und springt am Ende sauber wieder an den Anfang. Das ist bereits ein funktionierendes Menü – es fehlt nur noch die Reaktion auf den Klick.
3Klick auswerten und Werte einstellen
Jetzt kommt die eigentliche Menü-Logik: Ein Klick soll den gewählten Eintrag öffnen. Bei einem Zahlenwert wie der Helligkeit wechseln wir in einen Edit-Modus, in dem dieselbe Drehbewegung nicht mehr die Auswahl, sondern den Wert verändert. Ein weiterer Klick speichert und geht zurück.
Damit der Taster zuverlässig auslöst und nicht mehrfach prellt, entprellen wir ihn sauber über einen Zeitstempel statt mit delay():
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#define ENC_CLK 2
#define ENC_DT 3
#define ENC_SW 4
Adafruit_SSD1306 display(128, 64, &Wire, -1);
volatile int encoderPos = 0;
int auswahl = 0;
int letzte = 0;
// Zustandsmaschine: navigieren oder Wert bearbeiten?
enum Zustand { NAVIGIEREN, BEARBEITEN };
Zustand zustand = NAVIGIEREN;
// die einstellbaren Werte
int helligkeit = 128; // 0..255
int blinkrate = 500; // ms
const char* menue[] = { "LED-Helligkeit", "Blinkrate", "Info" };
const int menueLen = 3;
void encoderISR() {
if (digitalRead(ENC_DT)) encoderPos++;
else encoderPos--;
}
// Taster mit Entprellung: gibt genau EINEN true-Impuls pro Druck
bool tasterGedrueckt() {
static bool letzterZustand = HIGH;
static unsigned long tZeit = 0;
bool jetzt = digitalRead(ENC_SW);
if (jetzt == LOW && letzterZustand == HIGH && millis() - tZeit > 200) {
tZeit = millis();
letzterZustand = LOW;
return true;
}
if (jetzt == HIGH) letzterZustand = HIGH;
return false;
}
void setup() {
pinMode(ENC_CLK, INPUT_PULLUP);
pinMode(ENC_DT, INPUT_PULLUP);
pinMode(ENC_SW, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(ENC_CLK), encoderISR, FALLING);
display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
display.setTextColor(SSD1306_WHITE);
zeichne();
}
void zeichne() {
display.clearDisplay();
display.setTextSize(1);
display.setCursor(0, 0);
if (zustand == NAVIGIEREN) {
display.println("== MENUE ==");
for (int i = 0; i < menueLen; i++) {
int y = 16 + i * 14;
if (i == auswahl) {
display.fillRect(0, y - 1, 128, 13, SSD1306_WHITE);
display.setTextColor(SSD1306_BLACK);
} else {
display.setTextColor(SSD1306_WHITE);
}
display.setCursor(4, y);
display.println(menue[i]);
}
} else {
// Edit-Ansicht mit grossem Wert
display.setTextColor(SSD1306_WHITE);
display.println(menue[auswahl]);
display.println("");
display.setTextSize(3);
if (auswahl == 0) display.print(helligkeit);
if (auswahl == 1) display.print(blinkrate);
display.setTextSize(1);
display.setCursor(0, 56);
display.print("Klick = speichern");
}
display.display();
}
void loop() {
// 1) Drehung auswerten
if (encoderPos != letzte) {
int richtung = (encoderPos > letzte) ? 1 : -1;
letzte = encoderPos;
if (zustand == NAVIGIEREN) {
auswahl += richtung;
if (auswahl < 0) auswahl = menueLen - 1;
if (auswahl >= menueLen) auswahl = 0;
} else {
// im Edit-Modus Wert aendern
if (auswahl == 0) helligkeit = constrain(helligkeit + richtung * 5, 0, 255);
if (auswahl == 1) blinkrate = constrain(blinkrate + richtung * 50, 50, 5000);
}
zeichne();
}
// 2) Klick auswerten
if (tasterGedrueckt()) {
if (zustand == NAVIGIEREN && auswahl != 2) {
zustand = BEARBEITEN; // Info-Eintrag hat keinen Edit-Modus
} else {
zustand = NAVIGIEREN;
}
zeichne();
}
}
💡 Die Zustandsmaschine ist der Trick: Statt für jede Ansicht eigenen Wust-Code zu schreiben, hältst du in einer einzigen Variable (zustand) fest, wo der Nutzer gerade ist. Dieselbe Drehbewegung tut je nach Zustand etwas anderes – navigieren oder Wert ändern. Genau so funktionieren auch professionelle Geräte-Menüs. Willst du mehr Ebenen, ergänzt du einfach weitere Zustände wie UNTERMENUE.
4Lange Listen: Scrolling einbauen
Ein 128x64-Display zeigt bequem vier bis fünf Einträge. Was, wenn dein Menü zehn Punkte hat? Dann brauchst du Scrolling: Es werden immer nur so viele Einträge gezeichnet, wie aufs Display passen, und das sichtbare Fenster wandert mit der Auswahl mit.
const char* menue[] = {
"Helligkeit", "Kontrast", "Blinkrate", "Modus",
"Sprache", "Uhrzeit", "Datum", "Alarm",
"WLAN", "Werksreset"
};
const int menueLen = 10;
const int sichtbar = 4; // so viele Zeilen passen aufs Display
int auswahl = 0; // aktueller Eintrag
int fensterTop = 0; // erster sichtbarer Eintrag
void zeichneScroll() {
display.clearDisplay();
display.setTextSize(1);
// Fenster nachfuehren, damit die Auswahl immer sichtbar bleibt
if (auswahl < fensterTop) fensterTop = auswahl;
if (auswahl >= fensterTop + sichtbar) fensterTop = auswahl - sichtbar + 1;
for (int i = 0; i < sichtbar; i++) {
int idx = fensterTop + i;
if (idx >= menueLen) break;
int y = i * 14 + 4;
if (idx == auswahl) {
display.fillRect(0, y - 2, 122, 14, SSD1306_WHITE);
display.setTextColor(SSD1306_BLACK);
} else {
display.setTextColor(SSD1306_WHITE);
}
display.setCursor(4, y);
display.println(menue[idx]);
}
// einfache Scrollbar am rechten Rand
int barHoehe = 64 / menueLen;
int barY = (64 * fensterTop) / menueLen;
display.drawRect(124, 0, 4, 64, SSD1306_WHITE);
display.fillRect(124, barY, 4, barHoehe * sichtbar, SSD1306_WHITE);
display.display();
}
Die kleine Scrollbar rechts zeigt dem Nutzer, wo in der Liste er sich befindet – ein Detail, das dein Menü sofort professionell wirken lässt. Kombiniere zeichneScroll() mit der Encoder-Auswertung aus Schritt 3, und du hast eine beliebig lange, flüssig scrollende Navigation.
Troubleshooting: die typischen Encoder-Probleme
⚠️ Die Auswahl springt beim Drehen wild hin und her: Klassisches Kontaktprellen des Encoders. Setze CLK und DT auf INPUT_PULLUP und werte im Interrupt nur die fallende Flanke aus (FALLING). Hilft das nicht, löte je 100 nF von CLK und DT nach GND – das filtert das Prellen hardwareseitig weg.
⚠️ Der Encoder zählt zwei Schritte pro Rastung: Manche EC11 geben pro mechanischer Rastung zwei Impulse ab. Lösung: Werte im Sketch nur jeden zweiten Schritt, oder nutze CHANGE statt FALLING und teile die Zählung durch zwei.
⚠️ Das OLED bleibt schwarz: Fast immer die I2C-Adresse. Die meisten 128x64-Module sitzen auf 0x3C, manche auf 0x3D. Lass einen I2C-Scanner laufen, um die richtige Adresse zu finden – wie das geht, steht im Artikel I2C-Bus mit Arduino verstehen.
⚠️ Der Klick löst mehrfach aus: Der Taster prellt. Nutze die tasterGedrueckt()-Funktion aus Schritt 3 mit Zeitstempel-Entprellung statt einer simplen if-Abfrage.
Praxistipps für schöne Menüs
-
Nur neu zeichnen, wenn sich etwas ändert: Ruf
display.display()nicht in jedemloop()-Durchlauf auf, sondern nur nach einer Drehung oder einem Klick. Das I2C-Update dauert ~20 ms – ständiges Zeichnen macht die Bedienung träge. - Beschleunigung einbauen: Miss die Zeit zwischen zwei Impulsen. Dreht der Nutzer schnell, ändere den Wert in größeren Schritten (z. B. 10 statt 1). Das spart bei großen Wertebereichen viele Umdrehungen.
- Icons statt Text: Die Adafruit-GFX-Library kann Bitmaps zeichnen. Ein kleines Zahnrad neben "Einstellungen" wertet die Optik enorm auf.
-
Werte dauerhaft speichern: Schreib eingestellte Werte mit
EEPROM.put()ins EEPROM, dann bleiben sie nach dem Ausschalten erhalten. - Timeout zurück ins Hauptmenü: Passiert eine Weile nichts, springe automatisch zur Startansicht zurück – wie bei jedem guten Gerätemenü.
🚀 Alles für dein Menü-Projekt bei MAKEROO
Display, Drehregler und Board – die komplette Bedieneinheit:
Fazit
Ein Rotary Encoder und ein OLED-Display verwandeln jedes Projekt vom Kabelsalat-Prototyp in ein Gerät, das sich bedienen lässt wie ein fertiges Produkt. Der Schlüssel liegt in drei Bausteinen: den Encoder per Interrupt auslesen, damit kein Impuls verloren geht, den Taster sauber entprellen, und die Ansicht über eine Zustandsmaschine steuern, die zwischen Navigieren und Bearbeiten umschaltet.
Hast du dieses Grundgerüst einmal verstanden, kannst du es in jedes eigene Projekt übernehmen – ob 3D-Drucker-Steuerung, Wecker, Laborgerät oder Synthesizer. Ergänze Untermenüs, speichere Werte im EEPROM, füg Icons hinzu: Die Vorlage wächst mit deinen Ideen. Fang mit dem einfachen Fünf-Punkte-Menü an, bring es zum Laufen, und erweitere Schritt für Schritt. In einer Nachmittagssitzung hast du ein Bediengerät, auf das du stolz sein kannst.