Hier zeige ich wie ich wie eine Wort Uhr gebaut habe, nach einer Idee von Mirc0.
Eine Wort Uhr ist eine besondere Form der Uhr, die die Zeit nicht mit Zeigern oder Ziffern, sondern mit Worten anzeigt. Hinter der 3D gedruckten Frontplatte mit ausgeschnittenen Buchstaben sitzt eine LED‑Matrix. Je nach Uhrzeit werden genau die Wörter beleuchtet, die den aktuellen Zeitpunkt beschreiben – zum Beispiel:
- ES IST FÜNF NACH HALB EINS
- ES IST VIERTEL VOR ZEHN
Dadurch wirkt die Uhr modern, minimalistisch und gleichzeitig sehr wohnlich. Sie ist ein beliebtes DIY‑Projekt, weil sie Technik, Design und 3D‑Druck perfekt verbindet. Der Designer hat die Druckvorlagen in verschiedenen Sprachen und Dialekten zu Verfügung gestellt.

Hardware:
- Entwicklungsboard ESP32
- LED-Streifen RGB WS2812b 2 Meter mit 17mm LED Abstand 60pro Meter
- RTC Modul DS3231
- Netzteil Anker 24W Ladegerät 4,8 Ampere 2x USB-A Anschluss
- USB-A auf USB-C Kabel
- USB-C Buchse zwei Adern
- 8 Blechschrauben M3x8

3D‑gedruckte Gehäuseteile (3 Teile Front, LED Streifen Halterung und Abdeckplatte)
Ausgedruckt habe ich die 3 Teile mit einen BambuLab 3D Drucker mit AMS der mehrere Farben in einem durchlauf drucken kann. Filamente PETG in schwarz und Transparent. Druckzeit: 11 Stunden

10 mal LED-Streifen mit je 11 LED´s und einmal 4 LED´s zuschneiden:

Elektrisch verbinden:
Achtet beim Zusammenlöten unbedingt auf die Pfeilmarkierungen auf den LED-Streifen. Diese geben die Laufrichtung an und müssen fortlaufend beibehalten werden, wenn die Streifen wellenförmig verlegt werden.

Verdrahtung im Detail

ESP32 ↔ RTC (DS3231)
- RTC VCC → ESP32 3V3
- RTC GND → ESP32 GND
- RTC SDA → ESP32 GPIO 21
- RTC SCL → ESP32 GPIO 22
ESP32 ↔ LED-Streifen (WS2812B)
- LED DIN → ESP32 GPIO 5
- Empfehlung: Manche LED-Streifen erwarten mindesten 3,7V und mehr damit eine sauberes HIGH erkannt wird. Der ESP32 liefert aber nur 3,3 Volt. Sollte also der LED-Streifen nicht so tolerant sein, dann schalte hier einen Logik-Level-Konverter dazwischen.
- LED GND → Netzteil GND
- LED +5V → Netzteil +5V
Netzteil / USB‑C‑Buchse
- USB‑C VBUS (+5 V) → Netzteil +5 V / LED +5 V
- USB‑C GND → Netzteil GND / LED GND / ESP32 GND
Wichtig:
Alle Massen müssen verbunden sein.
Quellcode: Verwendete V3 von Mirc0 unverändert. Zum besseren Verständnis nur mit Zeileninformationen ergänzt:
/*
Wortuhr (ESP32) - Offline Wortuhr + AP Config (1min) V3 von Mirc0 nur mit Zeileninformationen ergänzt
*/
/* ---- Benötigte Bibliotheken einbinden ---- */
#include <FastLED.h> // Steuerung von WS2812B-LEDs
#include <Wire.h> // I²C-Bus für die Kommunikation mit dem RTC-Modul
#include "RTClib.h" // Treiber für den DS3231 Echtzeituhr-Chip
#include <WiFi.h> // WLAN-Funktionen des ESP32
#include <WebServer.h> // Einfacher HTTP-Server
#include <DNSServer.h> // DNS-Server für den Captive Portal (fängt alle DNS-Anfragen ab)
#include <Preferences.h> // Persistente Schlüssel-Wert-Speicherung im Flash-Speicher
#include <time.h> // Standard-C-Zeitfunktionen (hier für Typen benötigt)
/* ========== Hardware-Definitionen / LED-Layout ========== */
// GPIO-Pin am ESP32, an dem der Dateneingang des LED-Streifens hängt
#define DATA_PIN 5
// Gesamtanzahl der LEDs im Streifen (10×11 = 110 Wort-LEDs + 4 Minuten-Punkte)
#define NUM_LEDS 114
/* Objekt für die Echtzeituhr DS3231 (kommuniziert über I²C) */
RTC_DS3231 rtc;
/*
Zwei LED-Arrays:
leds[] = der aktuell angezeigte Zustand (wird direkt an FastLED übergeben)
targetLeds[] = der Zielzustand für den nächsten Fade-Übergang
Der Crossfade-Effekt blendet leds[] schrittweise zu targetLeds[] über.
*/
CRGB leds[NUM_LEDS];
CRGB targetLeds[NUM_LEDS];
/* HTTP-Server auf Port 80 (Standard-Webport) */
WebServer server(80);
/* DNS-Server: fängt alle DNS-Anfragen im AP-Modus ab und leitet sie an den ESP32 weiter */
DNSServer dnsServer;
/* Preferences-Objekt zum Lesen und Schreiben von Einstellungen im Flash-Speicher */
Preferences prefs;
/* DNS läuft auf UDP-Port 53 (standardisierter DNS-Port) */
const byte DNS_PORT = 53;
/* ========== Standardwerte für alle Einstellungen ========== */
// Helligkeit tagsüber in Prozent (0–100)
const uint8_t DEFAULT_DAY_BRI_PERCENT = 80;
// Helligkeit nachts in Prozent (0–100)
const uint8_t DEFAULT_NIGHT_BRI_PERCENT = 20;
// Uhrzeit (Stunde), ab der der Nachtmodus beginnt (22 = 22:00 Uhr)
const uint8_t DEFAULT_NIGHT_START = 22;
// Uhrzeit (Stunde), ab der wieder der Tagmodus gilt (6 = 06:00 Uhr)
const uint8_t DEFAULT_NIGHT_END = 6;
// Standardfarbe der Wörter: warmweißes Licht (R=255, G=225, B=200)
const CRGB DEFAULT_COLOR = CRGB(255, 225, 200);
// Dialektmodus: 0 = Westdeutsch ("Viertel nach"), 1 = Ostdeutsch ("Dreiviertel")
const uint8_t DEFAULT_DIALECT = 0;
/* ========== Laufzeit-Variablen für gespeicherte Einstellungen ========== */
// Diese Werte werden beim Start aus dem Flash geladen und können über den Webserver geändert werden.
uint8_t dayBrightPercent = DEFAULT_DAY_BRI_PERCENT;
uint8_t nightBrightPercent = DEFAULT_NIGHT_BRI_PERCENT;
uint8_t nightStartHour = DEFAULT_NIGHT_START;
uint8_t nightEndHour = DEFAULT_NIGHT_END;
CRGB WORD_COLOR = DEFAULT_COLOR;
uint8_t dialectMode = DEFAULT_DIALECT;
/* ========== Schlüssel für den Flash-Speicher (Preferences) ========== */
// Diese kurzen Strings sind die "Variablennamen" im Flash.
// Preferences verwendet intern eine Datenbank; die Schlüssel müssen kurz bleiben (<16 Zeichen).
const char *P_NS = "nightStart"; // Nachtstart-Stunde
const char *P_NE = "nightEnd"; // Nachtende-Stunde
const char *P_DB = "dayB"; // Tages-Helligkeit
const char *P_NB = "nightB"; // Nacht-Helligkeit
const char *P_R = "rc"; // Rotkanal der Wortfarbe
const char *P_G = "gc"; // Grünkanal der Wortfarbe
const char *P_B = "bc"; // Blaukanal der Wortfarbe
const char *P_DI = "di"; // Dialektmodus
/* ========== WLAN / Access-Point Statusvariablen ========== */
bool apActive = false; // true, wenn der Access Point gerade aktiv ist
bool clientConnected = false; // true, sobald sich mindestens ein Gerät verbunden hat
unsigned long apStartTime = 0; // Zeitstempel (millis) des AP-Starts für den 60s-Timeout
/* ========== Puls-Animation für Status-LEDs ========== */
// Die vier Status-LEDs pulsieren, wenn der AP aktiv, aber kein Gerät verbunden ist.
int pulseValue = 10; // Aktueller Helligkeitswert der Pulsanimation (0–255)
int pulseDir = 8; // Schrittweite pro Loop-Durchlauf (positiv = heller, negativ = dunkler)
/* ========== Indizes der vier WLAN-Status-LEDs ========== */
// Diese LEDs werden separat behandelt und nie von der Wortuhr-Logik überschrieben.
const uint8_t WIFI_LEDS[4] = { 37, 38, 39, 40 };
/* ========== Wort-zu-LED-Zuordnung (LED-Indizes für jedes Wort) ========== */
/*
Jedes Array enthält die Indizes der LEDs, die zu einem bestimmten Wort gehören.
Die Reihenfolge entspricht dem physischen Layout der Buchstabenmatrix.
LED 0 = erste LED oben links, LED 113 = letzte LED unten rechts.
*/
/* Basiswörter (immer aktiv) */
const uint8_t ES_IST[] = { 0, 1, 3, 4, 5 }; // "ES IST" (LED 2 = Leerzeichen, bleibt aus)
const uint8_t UHR[] = { 101, 100, 99 }; // "UHR" (rückwärts im Streifen)
/* Minuten-Wörter */
const uint8_t FUENF[] = { 7, 8, 9, 10 }; // "FÜNF" (Minuten)
const uint8_t ZEHN[] = { 18, 19, 20, 21 }; // "ZEHN" (Minuten)
const uint8_t ZWANZIG[] = { 11, 12, 13, 14, 15, 16, 17 }; // "ZWANZIG"
const uint8_t VIERTEL[] = { 26, 27, 28, 29, 30, 31, 32 }; // "VIERTEL"
const uint8_t VOR[] = { 41, 42, 43 }; // "VOR"
const uint8_t NACH[] = { 33, 34, 35, 36 }; // "NACH"
const uint8_t HALB[] = { 44, 45, 46, 47 }; // "HALB"
/* DREIVIERTEL belegt LEDs 22–32 (enthält auch die LEDs von VIERTEL) */
const uint8_t DREIVIERTEL[] = { 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32 };
/* Stunden-Wörter */
const uint8_t DREI[] = { 66, 67, 68, 69 }; // "DREI"
const uint8_t VIER_H[] = { 73, 74, 75, 76 }; // "VIER" (Stunde)
const uint8_t FUENF_H[] = { 51, 52, 53, 54 }; // "FÜNF" (Stunde)
const uint8_t EINS[] = { 62, 63, 64, 65 }; // "EINS"
const uint8_t ZWEI[] = { 55, 56, 57, 58 }; // "ZWEI"
const uint8_t SECHS[] = { 83, 84, 85, 86, 87 }; // "SECHS"
const uint8_t ACHT[] = { 77, 78, 79, 80 }; // "ACHT"
const uint8_t SIEBEN[] = { 88, 89, 90, 91, 92, 93 }; // "SIEBEN"
const uint8_t ZWOELF[] = { 94, 95, 96, 97, 98 }; // "ZWÖLF"
const uint8_t ZEHN_H[] = { 106, 107, 108, 109 }; // "ZEHN" (Stunde)
const uint8_t NEUN[] = { 103, 104, 105, 106 }; // "NEUN"
const uint8_t ELF[] = { 49, 50, 51 }; // "ELF"
const uint8_t EIN[] = { 63, 64, 65 }; // "EIN" (für "EIN UHR", ohne das S)
/* Vier Minuten-Punkte (zeigen 1–4 Minuten innerhalb einer 5-Minuten-Gruppe an) */
const uint8_t MIN_LEDS[4] = { 110, 111, 112, 113 };
/* ========== Hilfsfunktion: Ist diese LED eine WLAN-Status-LED? ========== */
/*
Gibt true zurück, wenn der übergebene LED-Index zu den vier WLAN-LEDs gehört.
So können wir verhindern, dass die Wortuhr-Logik diese LEDs überschreibt.
*/
inline bool isWifiLed(uint8_t idx) {
for (uint8_t i = 0; i < 4; i++)
if (WIFI_LEDS[i] == idx) return true;
return false;
}
/* ========== Hilfsfunktionen für den Ziel-Zustand (targetLeds) ========== */
/* Setzt alle LEDs im Ziel-Array auf Schwarz (aus) */
void clearTarget() {
fill_solid(targetLeds, NUM_LEDS, CRGB::Black);
}
/*
Setzt die LEDs eines Wortes im Ziel-Array auf die aktuelle Wortfarbe.
arr = Array mit LED-Indizes, len = Anzahl der LEDs im Array.
WLAN-Status-LEDs werden dabei bewusst ausgespart (isWifiLed-Check).
*/
void setWordTarget(const uint8_t *arr, uint8_t len) {
for (uint8_t i = 0; i < len; i++) {
uint8_t idx = arr[i];
// Nur setzen, wenn der Index gültig ist UND keine WLAN-Status-LED
if (idx < NUM_LEDS && !isWifiLed(idx))
targetLeds[idx] = WORD_COLOR;
}
}
/*
Setzt die Minuten-Punkte im Ziel-Array.
count = 0..4: Wie viele der vier Punkte sollen leuchten?
Punkte 0..count-1 werden eingeschalten, der Rest bleibt aus.
*/
void setMinuteDotsTarget(uint8_t count) {
for (uint8_t i = 0; i < 4; i++) {
uint8_t idx = MIN_LEDS[i];
if (idx < NUM_LEDS)
targetLeds[idx] = (i < count) ? WORD_COLOR : CRGB::Black;
}
}
/* ========== Crossfade: Weicher Übergang von leds[] zu targetLeds[] ========== */
/*
Blendet den aktuellen Anzeigestatus (leds[]) schrittweise in den Zielstatus
(targetLeds[]) über. FastLED's blend()-Funktion mischt zwei Farben anhand
eines Wertes von 0 (100% alt) bis 255 (100% neu).
steps = Anzahl der Zwischenschritte (mehr = flüssiger, aber langsamer)
delayMs = Pause zwischen zwei Schritten in Millisekunden
*/
void crossFade(uint8_t steps = 45, uint16_t delayMs = 18) {
for (uint8_t s = 0; s <= steps; s++) {
// Mischverhältnis berechnen: 0 bei s=0, 255 bei s=steps
uint8_t amt = (255 * s) / steps;
for (int i = 0; i < NUM_LEDS; i++) {
// Jede LED zwischen altem und neuem Wert interpolieren
leds[i] = blend(leds[i], targetLeds[i], amt);
}
FastLED.show(); // Berechnetes Bild an den LED-Streifen senden
delay(delayMs); // Kurze Pause für das menschliche Auge
}
}
/* ========== Hilfsfunktion: Stunden-LED für eine bestimmte Stunde setzen ========== */
/*
Wählt anhand der Stunde (1–12) das passende Stunden-Wort-Array aus
und trägt es in targetLeds[] ein.
*/
void showHourTarget(uint8_t h) {
switch (h) {
case 1: setWordTarget(EINS, 4); break;
case 2: setWordTarget(ZWEI, 4); break;
case 3: setWordTarget(DREI, 4); break;
case 4: setWordTarget(VIER_H, 4); break;
case 5: setWordTarget(FUENF_H,4); break;
case 6: setWordTarget(SECHS, 5); break;
case 7: setWordTarget(SIEBEN, 6); break;
case 8: setWordTarget(ACHT, 4); break;
case 9: setWordTarget(NEUN, 4); break;
case 10: setWordTarget(ZEHN_H, 4); break;
case 11: setWordTarget(ELF, 3); break;
case 12: setWordTarget(ZWOELF, 5); break;
}
}
/* ========== Nachtmodus-Erkennung ========== */
/*
Gibt true zurück, wenn die angegebene Stunde in den Nachtmodus-Zeitraum fällt.
Berücksichtigt den Mitternachts-Überlauf (z. B. 22:00 bis 06:00).
Beispiel: nightStartHour=22, nightEndHour=6
- 23 Uhr → true (22 <= 23)
- 03 Uhr → true (3 < 6)
- 12 Uhr → false
*/
bool isNightHour(uint8_t hour) {
// Sonderfall: Start == Ende → kein Nachtmodus definiert
if (nightStartHour == nightEndHour) return false;
if (nightStartHour < nightEndHour) {
// Normaler Bereich innerhalb eines Tages (z.B. 08:00–22:00)
return (hour >= nightStartHour && hour < nightEndHour);
} else {
// Bereich über Mitternacht (z.B. 22:00–06:00)
return (hour >= nightStartHour || hour < nightEndHour);
}
}
/* ========== Kernfunktion: Ziel-LED-Zustand für eine Uhrzeit berechnen ========== */
/*
Baut den targetLeds[]-Zustand für die angegebene Stunde und Minute auf.
Wählt dabei automatisch zwischen West- und Ostdialekt.
hourVal = Stunde (0–23)
minuteVal = Minute (0–59)
*/
void buildTimeTarget(int hourVal, int minuteVal) {
/* Helligkeit je nach Tag/Nacht-Modus setzen */
uint8_t effectivePercent = isNightHour(hourVal) ? nightBrightPercent : dayBrightPercent;
// Prozent (0–100) auf FastLED-Helligkeitsskala (0–255) umrechnen
uint8_t brightnessVal = map(effectivePercent, 0, 100, 0, 255);
FastLED.setBrightness(brightnessVal);
/* Alle Ziel-LEDs zunächst ausschalten */
clearTarget();
/* "ES IST" leuchtet immer */
setWordTarget(ES_IST, 5);
/*
Minutenwert auf das nächste 5-Minuten-Raster abrunden:
rounded = 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55
extra = Restminuten (0–4) → werden als Punkte angezeigt
*/
uint8_t rounded = (minuteVal / 5) * 5;
uint8_t extra = minuteVal % 5;
/*
Stundenwert auf 12-Stunden-Format umrechnen (1–12).
Ab Minute 25 wird die nächste Stunde angesagt
(z.B. "fünf vor halb DREI" statt "fünf vor halb ZWEI").
*/
uint8_t h = hourVal % 12;
if (h == 0) h = 12;
if (rounded >= 25) h = (h % 12) + 1; // nächste Stunde
/* ---- Dialekt-Modus 1: Ostdeutsch ---- */
if (dialectMode == 1) {
/*
Ostdeutscher Dialekt:
"Viertel DREI" statt "Viertel nach ZWEI"
"Dreiviertel DREI" statt "Viertel vor DREI"
*/
switch (rounded) {
case 0: // Volle Stunde: "EIN UHR" oder "ZWEI UHR" usw.
if (h == 1) {
setWordTarget(EIN, 3); // "EIN" statt "EINS" (grammatikalisch korrekt)
} else {
showHourTarget(h);
}
setWordTarget(UHR, 3);
break;
case 5: // "FÜNF NACH [Stunde]"
setWordTarget(FUENF, 4);
setWordTarget(NACH, 4);
showHourTarget(h);
break;
case 10: // "ZEHN NACH [Stunde]"
setWordTarget(ZEHN, 4);
setWordTarget(NACH, 4);
showHourTarget(h);
break;
case 15: // Ostdialekt: "VIERTEL [nächste Stunde]" (ohne "nach"!)
{
uint8_t nextHour = (h % 12) + 1; // nächste Stunde berechnen
setWordTarget(VIERTEL, 7);
showHourTarget(nextHour);
}
break;
case 20: // "ZWANZIG NACH [Stunde]"
setWordTarget(ZWANZIG, 7);
setWordTarget(NACH, 4);
showHourTarget(h);
break;
case 25: // "FÜNF VOR HALB [nächste Stunde]"
setWordTarget(FUENF, 4);
setWordTarget(VOR, 3);
setWordTarget(HALB, 4);
showHourTarget(h);
break;
case 30: // "HALB [nächste Stunde]"
setWordTarget(HALB, 4);
showHourTarget(h);
break;
case 35: // "FÜNF NACH HALB [nächste Stunde]"
setWordTarget(FUENF, 4);
setWordTarget(NACH, 4);
setWordTarget(HALB, 4);
showHourTarget(h);
break;
case 40: // "ZWANZIG VOR [nächste Stunde]"
setWordTarget(ZWANZIG, 7);
setWordTarget(VOR, 3);
showHourTarget(h);
break;
case 45: // Ostdialekt: "DREIVIERTEL [nächste Stunde]" (ohne "vor"!)
setWordTarget(DREIVIERTEL, 11);
showHourTarget(h);
break;
case 50: // "ZEHN VOR [nächste Stunde]"
setWordTarget(ZEHN, 4);
setWordTarget(VOR, 3);
showHourTarget(h);
break;
case 55: // "FÜNF VOR [nächste Stunde]"
setWordTarget(FUENF, 4);
setWordTarget(VOR, 3);
showHourTarget(h);
break;
}
} else {
/* ---- Dialekt-Modus 0: Westdeutsch (Standard) ---- */
/*
Westdeutscher Dialekt:
"Viertel nach ZWEI"
"Viertel vor DREI"
*/
switch (rounded) {
case 0: // Volle Stunde: "EIN UHR" oder "ZWEI UHR" usw.
if (h == 1) {
setWordTarget(EIN, 3); // "EIN" statt "EINS"
} else {
showHourTarget(h);
}
setWordTarget(UHR, 3);
break;
case 5: // "FÜNF NACH [Stunde]"
setWordTarget(FUENF, 4);
setWordTarget(NACH, 4);
showHourTarget(h);
break;
case 10: // "ZEHN NACH [Stunde]"
setWordTarget(ZEHN, 4);
setWordTarget(NACH, 4);
showHourTarget(h);
break;
case 15: // Westdialekt: "VIERTEL NACH [Stunde]"
setWordTarget(VIERTEL, 7);
setWordTarget(NACH, 4);
showHourTarget(h);
break;
case 20: // "ZWANZIG NACH [Stunde]"
setWordTarget(ZWANZIG, 7);
setWordTarget(NACH, 4);
showHourTarget(h);
break;
case 25: // "FÜNF VOR HALB [nächste Stunde]"
setWordTarget(FUENF, 4);
setWordTarget(VOR, 3);
setWordTarget(HALB, 4);
showHourTarget(h);
break;
case 30: // "HALB [nächste Stunde]"
setWordTarget(HALB, 4);
showHourTarget(h);
break;
case 35: // "FÜNF NACH HALB [nächste Stunde]"
setWordTarget(FUENF, 4);
setWordTarget(NACH, 4);
setWordTarget(HALB, 4);
showHourTarget(h);
break;
case 40: // "ZWANZIG VOR [nächste Stunde]"
setWordTarget(ZWANZIG, 7);
setWordTarget(VOR, 3);
showHourTarget(h);
break;
case 45: // Westdialekt: "VIERTEL VOR [nächste Stunde]"
setWordTarget(VIERTEL, 7);
setWordTarget(VOR, 3);
showHourTarget(h);
break;
case 50: // "ZEHN VOR [nächste Stunde]"
setWordTarget(ZEHN, 4);
setWordTarget(VOR, 3);
showHourTarget(h);
break;
case 55: // "FÜNF VOR [nächste Stunde]"
setWordTarget(FUENF, 4);
setWordTarget(VOR, 3);
showHourTarget(h);
break;
}
}
/* Minuten-Punkte setzen (0–4 Punkte für die Restminuten) */
setMinuteDotsTarget(extra);
/*
Hinweis: Die WLAN-Status-LEDs (37–40) werden hier NICHT berührt.
Sie werden separat durch applyWifiStatusVisuals() gesteuert.
*/
}
/* ========== Webserver: HTML-Konfigurationsseite erzeugen ========== */
/*
Erstellt den vollständigen HTML-Code der Einstellungsseite als String.
Alle Platzhalter (%COL%, %DAY% usw.) werden durch die aktuellen Werte ersetzt.
Die Seite enthält:
- Farbwähler
- Schieberegler für Tag- und Nacht-Helligkeit
- Eingabefelder für Nachtmodus-Zeiten
- Dialektauswahl (West/Ost)
- Anzeige der aktuellen RTC-Zeit
- Schaltfläche "Zeit vom Handy übernehmen"
- Schaltfläche "Speichern & Neustart"
*/
String makePageHtml() {
/* Aktuelle Wortfarbe als HTML-Hex-String formatieren (z.B. "#FFE1C8") */
char colorHex[8];
sprintf(colorHex, "#%02X%02X%02X", WORD_COLOR.r, WORD_COLOR.g, WORD_COLOR.b);
/* Aktuelle RTC-Zeit lesen, um die Felder vorzubefüllen */
DateTime now = rtc.now();
int curY = now.year();
int curM = now.month();
int curD = now.day();
int curH = now.hour();
int curMin = now.minute();
/* RTC-Zeit als lesbaren String formatieren (DD.MM.YYYY HH:MM Uhr) */
char rtcBuf[64];
sprintf(rtcBuf, "%02d.%02d.%04d %02d:%02d Uhr",
curD, curM, curY, curH, curMin);
/*
HTML-Rohstring (R"rawliteral(...)rawliteral" = C++ Raw-String-Literal).
In einem Raw-String können Anführungszeichen und Backslashes ohne
Escape-Zeichen verwendet werden, was HTML/CSS/JS-Code sehr vereinfacht.
*/
String html = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wortuhr Setup</title>
<style>
body{
margin:0;
background:#0f1115;
color:#fff;
font-family:system-ui,Arial;
}
.container{
max-width:480px;
margin:auto;
padding:20px;
}
h1{text-align:center;}
.card{
background:#171a22;
padding:18px;
border-radius:16px;
margin-bottom:18px;
}
label{
font-size:16px;
display:flex;
justify-content:space-between;
align-items:center;
margin-bottom:8px;
}
.range-value{
text-align:right;
min-width:50px;
}
input[type=color]{
width:100%;
height:50px;
border:none;
}
input[type=range]{
width:100%;
}
select,input[type=number]{
width:100%;
height:48px;
font-size:18px;
}
button{
width:100%;
background:#4da3ff;
color:white;
border:none;
padding:16px;
border-radius:14px;
font-size:18px;
}
.secondary{
background:#2f3748;
}
.rtc{
text-align:center;
background:#10131a;
padding:12px;
border-radius:10px;
}
.row{
display:flex;
gap:12px;
}
.hint{
font-size:12px;
color:#aaa;
margin-top:6px;
}
.small-input{
width:50%;
}
</style>
</head>
<body>
<div class="container">
<h1>Wortuhr Setup</h1>
<!-- Farbwähler -->
<div class="card">
<label>Farbe</label>
<input id="col" type="color" value="%COL%">
</div>
<!-- Schieberegler Tageshelligkeit -->
<div class="card">
<label>
Helligkeit Tag
<span class="range-value"><span id="dayv">%DAY%</span>%</span>
</label>
<input id="day" type="range" min="0" max="100" value="%DAY%">
</div>
<!-- Schieberegler Nachthelligkeit -->
<div class="card">
<label>
Helligkeit Nacht
<span class="range-value"><span id="nightv">%NIGHT%</span>%</span>
</label>
<input id="night" type="range" min="0" max="100" value="%NIGHT%">
</div>
<!-- Nachtmodus Uhrzeiten -->
<div class="card">
<label>Nachtmodus (von / bis)</label>
<div class="row">
<input id="ns" type="number" min="0" max="23" value="%NS%" class="small-input">
<input id="ne" type="number" min="0" max="23" value="%NE%" class="small-input">
</div>
<div class="hint">Beispiel: 22 bis 6</div>
</div>
<!-- Dialektauswahl -->
<div class="card">
<label>Modus</label>
<select id="dial">
<option value="0">Westdeutsch (Viertel nach)</option>
<option value="1">Ostdeutsch (Viertel / Dreiviertel)</option>
</select>
</div>
<!-- Zeitanzeige und Synchronisation -->
<div class="card">
<label>Gespeicherte Zeit</label>
<div class="rtc">%RTC%</div>
<button class="secondary" onclick="setTime()">Zeit vom Handy uebernehmen</button>
</div>
<!-- Speichern und Neustart -->
<button onclick="save()">Speichern & Neustart</button>
<script>
/* Schieberegler-Beschriftung live aktualisieren */
document.getElementById('day').oninput = function(){
document.getElementById('dayv').innerText = this.value;
}
document.getElementById('night').oninput = function(){
document.getElementById('nightv').innerText = this.value;
}
/* Gespeicherten Dialektwert im Dropdown vorauswählen */
document.getElementById('dial').value = "%DIA%";
/* Alle Einstellungen per HTTP-GET an den ESP32 senden */
function save(){
var color = document.getElementById('col').value;
var day = document.getElementById('day').value;
var night = document.getElementById('night').value;
var ns = document.getElementById('ns').value;
var ne = document.getElementById('ne').value;
var di = document.getElementById('dial').value;
/* URL mit allen Parametern zusammenbauen und abrufen */
var url = '/save?c=' + encodeURIComponent(color)
+ '&day=' + day
+ '&night=' + night
+ '&ns=' + ns
+ '&ne=' + ne
+ '&di=' + di;
fetch(url)
.then(r=>r.text())
.then(t=>document.body.innerHTML = t); // Antwort (Bestätigungsseite) anzeigen
}
/* Lokale Handy-Zeit lesen und an den ESP32 übertragen */
function setTime(){
try {
let now = new Date(); // JavaScript-Date liefert die lokale Systemzeit des Handys
/* Datum und Uhrzeit als URL-Parameter aufbereiten */
let url = "/settime?"
+ "y=" + now.getFullYear()
+ "&mo=" + (now.getMonth()+1) // Monate in JS: 0–11, daher +1
+ "&da=" + now.getDate()
+ "&h=" + now.getHours()
+ "&mi=" + now.getMinutes()
+ "&s=" + now.getSeconds();
fetch(url)
.then(r => r.text())
.then(t => location.reload()); // Seite neu laden, damit neue Zeit angezeigt wird
} catch(e){
alert("Fehler beim Lesen der Zeit");
}
}
</script>
</div>
</body>
</html>
)rawliteral";
/* Alle Platzhalter im HTML-Template durch echte Werte ersetzen */
html.replace("%COL%", String(colorHex));
html.replace("%DAY%", String(dayBrightPercent));
html.replace("%NIGHT%", String(nightBrightPercent));
html.replace("%NS%", String(nightStartHour));
html.replace("%NE%", String(nightEndHour));
html.replace("%DIA%", String(dialectMode));
html.replace("%Y%", String(curY));
html.replace("%M%", String(curM));
html.replace("%D%", String(curD));
html.replace("%H%", String(curH));
html.replace("%MIN%", String(curMin));
html.replace("%RTC%", String(rtcBuf));
return html;
}
/* ========== Webserver-Handler: Startseite ("/") ========== */
void handleRoot() {
// HTTP 200 OK + HTML-Konfigurationsseite senden
server.send(200, "text/html", makePageHtml());
}
/* ========== Webserver-Handler: Android Captive-Portal-Erkennung ========== */
/*
Android-Geräte rufen "/generate_204" auf, wenn sie prüfen wollen, ob
eine echte Internetverbindung besteht. Wir antworten mit 200 OK + "OK",
damit Android kein "Kein Internetzugang"-Banner anzeigt.
*/
void handleGenerate204() {
server.send(200, "text/plain", "OK");
}
/* ========== Webserver-Handler: Einstellungen speichern ("/save") ========== */
/*
Liest alle URL-Parameter aus der GET-Anfrage, validiert sie und
speichert sie in den Preferences (Flash). Optional: RTC-Zeit setzen.
Nach dem Speichern wird der ESP32 neu gestartet.
*/
void handleSave() {
/* Farbe: Erwartet wird "#RRGGBB" als Hex-String */
if (server.hasArg("c")) {
String c = server.arg("c");
if (c.length() == 7 && c[0] == '#') {
// Hex-String ohne '#' in einen 24-Bit-Zahlenwert umwandeln
long v = strtol(c.substring(1).c_str(), NULL, 16);
WORD_COLOR = CRGB((v >> 16) & 255, (v >> 8) & 255, v & 255);
}
}
/* Tageshelligkeit in Prozent (0–100), Wert begrenzen */
if (server.hasArg("day")) {
int d = server.arg("day").toInt();
if (d < 0) d = 0;
if (d > 100) d = 100;
dayBrightPercent = d;
}
/* Nachthelligkeit in Prozent (0–100), Wert begrenzen */
if (server.hasArg("night")) {
int n = server.arg("night").toInt();
if (n < 0) n = 0;
if (n > 100) n = 100;
nightBrightPercent = n;
}
/* Nachtmodus-Startzeit in Stunden (0–23) */
if (server.hasArg("ns")) {
int ns = server.arg("ns").toInt();
if (ns < 0 || ns > 23) ns = 0; // Ungültiger Wert: Rückfall auf 0
nightStartHour = ns;
}
/* Nachtmodus-Endzeit in Stunden (0–23) */
if (server.hasArg("ne")) {
int ne = server.arg("ne").toInt();
if (ne < 0 || ne > 23) ne = 0;
nightEndHour = ne;
}
/* Dialektmodus: nur 0 oder 1 erlaubt */
if (server.hasArg("di")) {
int di = server.arg("di").toInt();
if (di != 0 && di != 1) di = 0;
dialectMode = di;
}
/* Optionale Datums-/Zeitangabe aus den URL-Parametern lesen */
bool gotDate = false;
int y = 0, mo = 0, da = 0, hh = 0, mm = 0;
if (server.hasArg("y") && server.hasArg("mo") && server.hasArg("da")
&& server.hasArg("h") && server.hasArg("mi")) {
y = server.arg("y").toInt();
mo = server.arg("mo").toInt();
da = server.arg("da").toInt();
hh = server.arg("h").toInt();
mm = server.arg("mi").toInt();
/* Plausibilitätsprüfung der Datums- und Zeitwerte */
if (y >= 2000 && y <= 2099 && mo >= 1 && mo <= 12
&& da >= 1 && da <= 31 && hh >= 0 && hh <= 23 && mm >= 0 && mm <= 59) {
gotDate = true;
}
}
/* Alle Einstellungen dauerhaft im Flash-Speicher sichern */
prefs.begin("wortuhr", false); // Namespace "wortuhr", false = Schreibschutz deaktiv
prefs.putUChar(P_DB, dayBrightPercent);
prefs.putUChar(P_NB, nightBrightPercent);
prefs.putUChar(P_NS, nightStartHour);
prefs.putUChar(P_NE, nightEndHour);
prefs.putUChar(P_R, WORD_COLOR.r);
prefs.putUChar(P_G, WORD_COLOR.g);
prefs.putUChar(P_B, WORD_COLOR.b);
prefs.putUChar(P_DI, dialectMode);
prefs.end(); // Namespace schließen (schreibt den Puffer in den Flash)
/* RTC-Zeit setzen, wenn gültige Zeitparameter übergeben wurden */
if (gotDate) {
rtc.adjust(DateTime(y, mo, da, hh, mm, 0));
}
/* Bestätigungsseite an den Browser senden */
server.send(200, "text/html",
"<html><body style='background:#111;color:white;text-align:center;font-family:Arial'>"
"<h2>Gespeichert!</h2><p>Uhr startet neu...</p></body></html>");
delay(900); // Kurz warten, damit der Browser die Bestätigungsseite empfängt
/* WLAN und DNS sauber herunterfahren, dann Neustart */
dnsServer.stop();
WiFi.softAPdisconnect(true);
delay(200);
ESP.restart();
}
/* ========== Webserver-Handler: RTC-Zeit setzen ("/settime") ========== */
/*
Empfängt Datum und Uhrzeit vom Browser (als lokale Zeit des Endgeräts)
und überträgt sie direkt in den DS3231-RTC-Chip.
Parameter: y, mo, da, h, mi, s (Jahr, Monat, Tag, Stunde, Minute, Sekunde)
*/
void handleSetTime() {
if (server.hasArg("y") && server.hasArg("mo") && server.hasArg("da")
&& server.hasArg("h") && server.hasArg("mi") && server.hasArg("s")) {
int y = server.arg("y").toInt();
int mo = server.arg("mo").toInt();
int da = server.arg("da").toInt();
int h = server.arg("h").toInt();
int mi = server.arg("mi").toInt();
int s = server.arg("s").toInt();
/* RTC-Chip mit den empfangenen Werten stellen */
rtc.adjust(DateTime(y, mo, da, h, mi, s));
server.send(200, "text/plain", "OK");
return;
}
/* Fehlende Parameter → 400 Bad Request */
server.send(400, "text/plain", "BAD");
}
/* ========== Webserver-Handler: Unbekannte URLs abfangen ========== */
/*
Alle Anfragen an nicht definierte Pfade werden mit einem HTTP-302-Redirect
auf die Startseite des ESP32 weitergeleitet.
Das ist der Kern des Captive-Portal-Mechanismus: Das Handy wird automatisch
auf die Konfigurationsseite weitergeleitet.
*/
void handleNotFound() {
IPAddress apIP = WiFi.softAPIP(); // IP-Adresse des ESP32 im AP-Modus lesen
server.sendHeader("Location", String("http://") + apIP.toString() + "/", true);
server.send(302, "text/plain", "");
}
/* ========== Einstellungen aus dem Flash laden ========== */
/*
Liest alle gespeicherten Werte aus dem Preferences-Flash-Speicher.
Falls ein Schlüssel noch nicht existiert (z.B. beim ersten Start),
wird der angegebene Standardwert verwendet.
*/
void loadSettings() {
prefs.begin("wortuhr", true); // Namespace "wortuhr", Nur-Lesen-Modus
dayBrightPercent = prefs.getUChar(P_DB, DEFAULT_DAY_BRI_PERCENT);
nightBrightPercent = prefs.getUChar(P_NB, DEFAULT_NIGHT_BRI_PERCENT);
nightStartHour = prefs.getUChar(P_NS, DEFAULT_NIGHT_START);
nightEndHour = prefs.getUChar(P_NE, DEFAULT_NIGHT_END);
uint8_t r = prefs.getUChar(P_R, DEFAULT_COLOR.r);
uint8_t g = prefs.getUChar(P_G, DEFAULT_COLOR.g);
uint8_t b = prefs.getUChar(P_B, DEFAULT_COLOR.b);
WORD_COLOR = CRGB(r, g, b);
dialectMode = prefs.getUChar(P_DI, DEFAULT_DIALECT);
prefs.end();
}
/* ========== Access Point, DNS und Webserver starten ========== */
/*
Öffnet einen ungesicherten WLAN-Hotspot "WORTUHR-SETUP".
DNS fängt alle Anfragen ab und leitet sie an den ESP32 weiter.
Der Webserver registriert alle URL-Handler.
*/
void startAP() {
WiFi.mode(WIFI_AP); // ESP32 als reiner Access Point konfigurieren (kein Client-Modus)
// AP ohne Passwort öffnen, damit die Einrichtung möglichst einfach ist
WiFi.softAP("WORTUHR-SETUP");
apStartTime = millis(); // Startzeitpunkt für den 60s-Timeout merken
apActive = true;
clientConnected = false;
/* DNS-Server: fängt alle Hostnamen ab ("*") und leitet sie an die AP-IP weiter */
IPAddress apIP = WiFi.softAPIP();
dnsServer.start(DNS_PORT, "*", apIP);
/* URL-Handler beim Webserver registrieren */
server.on("/", handleRoot);
server.on("/generate_204", handleGenerate204); // Android Captive-Portal-Probe
server.on("/fwlink", handleGenerate204); // Windows Captive-Portal-Probe
server.on("/save", handleSave);
server.on("/settime", handleSetTime); // RTC-Zeit per Browser setzen
server.onNotFound(handleNotFound); // Alles andere → Redirect zur Startseite
server.begin(); // HTTP-Server starten
}
/* ========== WLAN-Status-LEDs aktualisieren ========== */
/*
Zeigt den aktuellen AP-Status über die vier WLAN-LEDs (37–40) an:
- AP aus: LEDs aus (schwarz)
- AP an, kein Client: langsam pulsierendes Blau
- AP an, Client aktiv: dauerhaftes Blau (solid)
*/
void applyWifiStatusVisuals() {
if (apActive) {
int stations = WiFi.softAPgetStationNum(); // Anzahl verbundener Geräte abfragen
if (stations > 0) {
/* Mindestens ein Gerät ist verbunden → dauerhaftes Blau */
for (uint8_t i = 0; i < 4; i++) {
uint8_t idx = WIFI_LEDS[i];
if (idx < NUM_LEDS) leds[idx] = CRGB(0, 120, 255);
}
return;
} else {
/* AP aktiv, aber kein Gerät verbunden → pulsierendes Blau */
// Pulswert hin- und her-zählen
pulseValue += pulseDir;
if (pulseValue >= 200 || pulseValue <= 6) pulseDir = -pulseDir; // Richtung umkehren
uint8_t pv = constrain(pulseValue, 0, 255); // Sicherheitshalber auf 0–255 begrenzen
for (uint8_t i = 0; i < 4; i++) {
uint8_t idx = WIFI_LEDS[i];
if (idx < NUM_LEDS) leds[idx] = CRGB(0, 0, pv); // Nur Blaukanal variabel
}
return;
}
} else {
/* AP deaktiviert → alle Status-LEDs ausschalten */
for (uint8_t i = 0; i < 4; i++) {
uint8_t idx = WIFI_LEDS[i];
if (idx < NUM_LEDS) leds[idx] = CRGB::Black;
}
}
}
/* ========== Zeit anzeigen (mit Crossfade) ========== */
int lastMinute = -1; // Zuletzt angezeigte Minute; -1 erzwingt sofortige Aktualisierung
/*
Berechnet den Ziel-LED-Zustand für h:m und blendet
sanft von der aktuellen Darstellung in die neue über.
*/
void showTimeAndFade(int h, int m) {
buildTimeTarget(h, m); // targetLeds[] mit der neuen Uhrzeit füllen
crossFade(); // Weicher Übergang von leds[] nach targetLeds[]
// Nach dem Fade die WLAN-Status-LEDs erneut setzen
// (crossFade() könnte sie verändert haben, wenn ein Blend-Schritt dorthin geht)
applyWifiStatusVisuals();
FastLED.show();
}
/* ========== Startanimation ========== */
/*
Zeigt nach dem Einschalten eine bunte Laufanimation:
1. Alle LEDs schalten in zufälliger Reihenfolge in zufälligen Farben ein.
2. Nach einer kurzen Pause werden alle LEDs weich ausgeblendet.
So ist sichtbar, dass alle LEDs funktionieren.
*/
void startAnimation() {
FastLED.setBrightness(120); // Moderate Helligkeit für die Animation
/* Alle LEDs ausschalten */
fill_solid(leds, NUM_LEDS, CRGB::Black);
FastLED.show();
/*
Zufällige Reihenfolge der LED-Indizes bestimmen (Fisher-Yates-Shuffle).
So leuchten die LEDs in einer zufälligen, nicht sequenziellen Reihenfolge auf.
*/
uint8_t order[NUM_LEDS];
for (int i = 0; i < NUM_LEDS; i++) order[i] = i; // Mit 0, 1, 2, ..., 113 füllen
for (int i = NUM_LEDS - 1; i > 0; i--) {
int j = random(i + 1); // Zufälligen Index von 0 bis i wählen
uint8_t t = order[i]; // Tausch: order[i] ↔ order[j]
order[i] = order[j];
order[j] = t;
}
/* LEDs nacheinander in einer zufälligen bunten Farbe einschalten */
for (int i = 0; i < NUM_LEDS; i++) {
// CHSV: Hue (zufällige Farbe), Saturation=255 (voll gesättigt), Value=200 (Helligkeit)
leds[order[i]] = CHSV(random8(), 255, 200);
FastLED.show();
delay(35); // 35ms pro LED → Gesamtdauer ca. 4 Sekunden
}
delay(500); // Kurze Pause, alle LEDs leuchten bunt
/* Alle LEDs sanft ausblenden (Helligkeit von 200 auf 0 in Schritten von 5) */
for (int b = 200; b >= 0; b -= 5) {
FastLED.setBrightness(b);
FastLED.show();
delay(20);
}
/* Helligkeit zurücksetzen und LEDs für die normale Uhranzeige vorbereiten */
FastLED.setBrightness(255);
fill_solid(leds, NUM_LEDS, CRGB::Black);
FastLED.show();
}
/* ========== setup(): Wird einmalig beim Start ausgeführt ========== */
void setup() {
/* Serielle Konsole für Debug-Ausgaben initialisieren (115200 Baud) */
Serial.begin(115200);
delay(50); // Kurz warten, bis der serielle Port bereit ist
/* FastLED: WS2812B-Streifen am DATA_PIN mit GRB-Byte-Reihenfolge initialisieren */
FastLED.addLeds<WS2812, DATA_PIN, GRB>(leds, NUM_LEDS);
/* Beide LED-Arrays auf Schwarz (aus) setzen */
fill_solid(leds, NUM_LEDS, CRGB::Black);
fill_solid(targetLeds, NUM_LEDS, CRGB::Black);
/* I²C-Bus starten (Standard-Pins SDA/SCL des ESP32) und RTC initialisieren */
Wire.begin();
rtc.begin();
/*
Wenn die RTC einen Stromausfall hatte (Power-Loss-Flag gesetzt),
fällt sie auf die Compile-Zeit des Sketches zurück.
Das ist ungenau, aber besser als 00:00:00 01.01.2000.
Korrekte Zeit dann über den Webserver einstellen!
*/
if (rtc.lostPower()) {
rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
}
/* Gespeicherte Einstellungen aus dem Flash laden */
loadSettings();
/* Startanimation abspielen (alle LEDs bunt aufleuchten, dann ausblenden) */
startAnimation();
/* Access Point und Webserver starten (für 60s oder bis Client verbunden ist) */
startAP();
/* Aktuelle Zeit sofort anzeigen (kurzer, schneller Crossfade beim Start) */
DateTime now = rtc.now();
lastMinute = now.minute();
buildTimeTarget(now.hour(), now.minute());
crossFade(18, 12); // Schnellerer Fade beim Einschalten (weniger Schritte, kürzere Pause)
/* WLAN-Status-LEDs nach dem initialen Fade auffrischen und anzeigen */
applyWifiStatusVisuals();
FastLED.show();
}
/* ========== loop(): Wird dauerhaft wiederholt ========== */
void loop() {
/* DNS-Anfragen und HTTP-Anfragen des Webservers abarbeiten */
dnsServer.processNextRequest();
server.handleClient();
/* ---- AP-Timeout-Verwaltung ---- */
if (apActive) {
int stations = WiFi.softAPgetStationNum(); // Verbundene Geräte zählen
if (stations > 0) {
clientConnected = true; // Mindestens ein Client hat sich verbunden
}
/*
Wenn innerhalb von 60 Sekunden kein Gerät verbunden hat:
Access Point abschalten, um Energie zu sparen.
*/
if (!clientConnected && (millis() - apStartTime > 60000UL)) {
dnsServer.stop();
WiFi.softAPdisconnect(true);
apActive = false;
clientConnected = false;
/* WLAN-LEDs ausschalten */
for (uint8_t i = 0; i < 4; i++) {
if (WIFI_LEDS[i] < NUM_LEDS) leds[WIFI_LEDS[i]] = CRGB::Black;
}
FastLED.show();
}
/*
Wenn ein Client verbunden war und sich dann wieder getrennt hat
(stations == 0, aber clientConnected == true):
AP ebenfalls abschalten (Konfiguration ist abgeschlossen oder abgebrochen).
Der 500ms-Buffer verhindert ein sofortiges Abschalten beim ersten Verbindungsaufbau.
*/
if (clientConnected && (WiFi.softAPgetStationNum() == 0)
&& (millis() - apStartTime > 500)) {
dnsServer.stop();
WiFi.softAPdisconnect(true);
apActive = false;
clientConnected = false;
for (uint8_t i = 0; i < 4; i++) {
if (WIFI_LEDS[i] < NUM_LEDS) leds[WIFI_LEDS[i]] = CRGB::Black;
}
FastLED.show();
}
}
/* WLAN-Status-LEDs in jedem Loop-Durchlauf aktualisieren (Pulseffekt) */
applyWifiStatusVisuals();
/* ---- Uhranzeige aktualisieren ---- */
DateTime now = rtc.now();
if (now.minute() != lastMinute) {
/* Minute hat sich geändert → neue Uhrzeit mit Crossfade anzeigen */
lastMinute = now.minute();
showTimeAndFade(now.hour(), now.minute());
} else {
/* Minute unverändert → nur WLAN-Status-LEDs neu zeichnen und anzeigen */
FastLED.show();
}
/*
Kurze Pause am Loop-Ende:
- Hält die Loop-Frequenz für den Pulseffekt ausreichend niedrig
- Vermeidet unnötige CPU-Last
- Lässt dem ESP32 Zeit, WLAN-Tasks intern zu verarbeiten (yield)
*/
delay(80);
}Finaler Test:
Unbedingt darauf achten das die LED Streifen auch wirklich nicht über den ESP32, sondern über externe 5V versorgt werden.

Wie funktioniert diese ESP32‑Wortuhr?
Diese Version der Wortuhr wird von einem ESP32‑Mikrocontroller gesteuert. Er übernimmt drei Aufgaben:
1. LED‑Matrix ansteuern
Ein WS2812B‑LED‑Streifen beleuchtet die Buchstabenmatrix. Der ESP32 schaltet die LEDs so, dass die passenden Wörter erscheinen. Die Uhrzeit wird in 5‑Minuten‑Schritten dargestellt.
Beispiel: 14:09 Uhr → „ES IST FÜNF NACH ZWEI“ Die einzelnen Minuten werden unten als Punkte angezeigt (Siehe Bild Finaler Test)
2. Zeitquelle bereitstellen
Damit die Uhr auch ohne WLAN zuverlässig läuft, ist ein RTC‑Modul (DS3231) integriert.
3. Webinterface für Einstellungen
Über ein integriertes Webinterface können folgende Dinge eingestellt werden:
- Helligkeit am Tage und in der Nacht
- Text Farbe
- Zeitsynchronisation per Smartphone
- Sprache bzw. Dialekt (West oder Ost Deutsch)
Beim Einschalten werden testweise alle LED eingeschaltet, zur Kontrolle ob alle LED in Takt sind.

Auch wird in der ersten Minute ein eigene WLAN‑Access‑Point aktiviert, über den man die Konfiguration aufrufen und anpassen kann. Das wird angezeigt mit Blauer WIFI Schrift.

4. Persistente Speicherung von Einstellungen im Flash-Speicher
Wenn du deine Wortuhr über das WLAN-Menü einstellst – zum Beispiel eine neue Farbe wählst oder die Helligkeit änderst –, sollen diese Daten natürlich auch nach einem Stromausfall oder beim Umstecken des Netzteils erhalten bleiben. Variablen im RAM gehen beim Ausschalten verloren. Deshalb nutzen wir die sogenannte Preferences-Bibliothek (Einstellungs-Bibliothek), um die Daten dauerhaft in den NVS (Non-Volatile Storage), der ein Teil des Flash-Speicher ist, zu schreiben.
Die 3 logischen Phasen im Hintergrund:
Vorbereiten & Öffnen: Der ESP32 öffnet einen geschützten Bereich im Speicher, den wir im Code „wortuhr“ genannt haben. prefs.begin(„wortuhr“, false);
(Bedeutung: true = Schreibschutz aktiv, nur Lesen / false = deaktiv, also Lesen & Schreiben möglich)
Schreiben & Sichern: Die aktuellen Werte für Farbe, Helligkeit und Dialekt werden dauerhaft in diesen Bereich eingebrannt.
prefs.putUChar(P_DB, dayBrightPercent);
prefs.putUChar(P_NB, nightBrightPercent);
- putUChar: Steht für „Put Unsigned Char“. Da die Helligkeit in Prozent (0–100) vorliegt, reicht ein einzelnes Byte (8-Bit, Wertebereich 0–255) völlig aus. Es spart wertvollen Flash-Speicher. Für andere Datentypen gibt es Befehle wie putInt(), putFloat() oder putString().
- P_DB (Schlüssel / Key): Das ist der Name des Datenfelds im Flash (z. B. „dayB“). Auch dieser Name darf maximal 15 Zeichen lang sein.
- dayBrightPercent (Wert / Value): Die tatsächliche Variable, die im RAM liegt und nun in den Flash gespiegelt wird.
prefs.end(); Schreiben und NVS-Dateisystem schließen
Lesen & Laden: Bei jedem Neustart der Uhr wird dieser Bereich als Erstes ausgelesen, damit die Uhr genau so weiterleuchtet, wie du sie eingestellt hast.
prefs.begin(„wortuhr“, true); // Diesmal Read-Only geöffnet
dayBrightPercent = prefs.getUChar(P_DB, 80);
- Die 80 ist der Fallback-Wert (Standardwert): Falls die Uhr brandneu ist und noch nie Daten in den Flash geschrieben wurden, existiert der Schlüssel P_DB noch nicht. In diesem Fall gibt die Funktion einfach den Standardwert 80 zurück.
5. Warum kein USB-C Netzteil?
Wenn für die Stromversorgung eine einfache, 2-polige USB-C-Buchse verwendet wird, funktionieren moderne USB-C Power Delivery (PD) Netzteile nicht. Diese Netzteile benötigen die fehlenden CC-Pins zur Kommunikation, um die Spannung freizuschalten. Verwende stattdessen ein herkömmliches 5V USB-Netzteil mit einem USB-A-auf-USB-C-Kabel, da diese Netzteile die 5V direkt anlegen.“
Fazit
Eine Word Clock ist ein ideales Projekt für ESP32‑Einsteiger und Fortgeschrittene.
Mit WS2812B‑LEDs, einer Buchstabenmaske und etwas Logik entsteht eine moderne, minimalistische Uhr, die eher Zeitlos 😉 in jedem Zimmer zur Geltung kommt
