1. Einleitung
Nachdem ich mich in einem früheren Beitrag bereits mit dem Aufbau einer Grandmaster-Clock für präzise Zeitsynchronisation im Netzwerk beschäftigt habe, folgt nun ein kleineres, praktisches Bastelprojekt:
Eine kompakte NTP-Uhr, die sich direkt über Power-over-Ethernet (PoE) mit Strom versorgt und ihre Zeit per Network Time Protocol bezieht.
Als Herzstück dient der Olimex ESP32-POE-ISO, der neben Ethernet und PoE auch eine galvanische Trennung integriert.
Diese sorgt dafür, dass sich der Mikrocontroller gleichzeitig über USB programmieren und über Ethernet mit dem Netzwerk verbinden lässt – ohne Risiko von Masseschleifen oder Kurzschlüssen.
Das macht den Aufbau besonders robust und prädestiniert für den Dauerbetrieb in Netzwerkschränken, Laboren oder Werkstätten.
Die Uhr läuft völlig autark: kein Netzteil, kein WLAN, keine Cloud.
Sie zeigt die exakte Zeit direkt aus dem Netzwerk an und benötigt dafür nur wenige Bauteile.
Ein ideales Projekt für alle, die Spaß am Selbstbau kleiner, funktionaler Elektroniklösungen haben und gleichzeitig ein nützliches Gerät für den Alltag erschaffen möchten.
2. Benötigte Komponenten
Für diese kleine PoE-NTP-Uhr werden nur wenige, leicht erhältliche Bauteile benötigt.
Alle Komponenten sind Standardteile, die ohne besondere Vorkenntnisse oder Spezialwerkzeug zusammengebaut werden können.
Das gesamte Projekt bleibt dadurch kostengünstig, robust und gut reproduzierbar – ideal für den Basteltisch oder den späteren Dauerbetrieb im Netzwerkschrank.
Hauptkomponenten
| Komponente | Beschreibung | Hinweise |
|---|---|---|
| Olimex ESP32-POE-ISO (16 MB) | Mikrocontroller mit integriertem Ethernet, PoE-Stromversorgung und galvanischer Trennung | Die 16-MB-Version lohnt sich, da sie nur rund 1 € teurer ist, aber genügend Speicher für spätere Erweiterungen bietet (z. B. Webinterface oder Logging). Die galvanische Trennung schützt sowohl das angeschlossene USB-Gerät als auch das Netzwerk. |
| LCD-Display 1604 | 16 Zeichen × 4 Zeilen, HD44780-kompatibel | Zeigt Uhrzeit, Datum oder Statusinformationen an. Varianten mit aufgelötetem I²C-Modul sind besonders platzsparend. |
| I²C-Interface (PCF8574-Modul) | bereits auf vielen 1604-Displays vormontiert | reduziert die Verkabelung auf nur zwei Leitungen (SDA/SCL). |
| Level-Shifter (BSS138, 4-Kanal) | bidirektionaler Pegelwandler 3,3 V ↔ 5 V | notwendig zur sicheren Kommunikation zwischen ESP32 (3,3 V) und Display (5 V). |
| RJ45-Netzwerkanschluss mit PoE | direkt über PoE-Switch oder PoE-Injektor | versorgt das Board über Ethernet mit 5 V und Daten – eine externe Stromquelle ist nicht erforderlich. |
| USB-Kabel (Micro-USB) | zum Flashen der Firmware | kann gleichzeitig mit dem Ethernetkabel verbunden bleiben – die galvanische Trennung verhindert Masseschleifen. |
| Steckbrett & Jumperkabel | für den Prototypaufbau | ermöglicht flexibles Testen, bevor man den Aufbau auf eine Lochraster- oder eigene Platine überträgt. |
💡 Optionale Komponenten
- Kleines Potentiometer (10 kΩ) für die Kontrasteinstellung, falls nicht bereits auf dem I²C-Modul vorhanden.
- 3D-gedrucktes oder handgefertigtes Gehäuse, um Display und Elektronik ansprechend zu verpacken.
- Kurzes Ethernet-Kabel, wenn das Gerät später in einem Serverschrank montiert wird.
Bezug und Werkzeug
Bis auf das Olimex ESP32-POE-ISO lassen sich sämtliche Komponenten problemlos und günstig auf Plattformen wie AliExpress oder eBay beziehen.
Gerade Level-Shifter, LCD-Module und Jumperkabel bekommt man dort oft im Set für wenige Euro.
Das Olimex-Board selbst ist etwas teurer (ca. 25,- Euro in der 16M Version), bietet dafür aber entscheidende Vorteile:
- integrierte PoE-Versorgung mit galvanischer Trennung,
- Ethernet-Anschluss ohne zusätzliche Module,
- und eine sehr gute Dokumentation auf der Herstellerseite.
Für die Softwareseite genügt die Arduino IDE, die sich kostenlos installieren lässt und den ESP32 nativ unterstützt.
Damit können alle notwendigen Bibliotheken (z. B. für NTP, Ethernet und LCD-Ansteuerung) direkt eingebunden und kompiliert werden, ohne zusätzliche Entwicklungsumgebung.
Das gesamte Projekt bleibt dadurch einsteigerfreundlich, aber zugleich technisch sauber und erweiterbar – eine ideale Kombination aus Bastelprojekt und praxisnaher Lösung.
Selbstverständlich kann die Uhr auch ohne PoE betrieben werden.
Das Olimex ESP32-POE-ISO lässt sich ebenso einfach über den Micro-USB-Anschluss mit Strom versorgen, während der Ethernet-Port weiterhin für die NTP-Zeitabfrage genutzt wird.
Damit eignet sich der Aufbau auch für Bastler, die (noch) keinen PoE-Switch besitzen oder das Projekt zunächst am heimischen Schreibtisch ausprobieren möchten.
2.1 Aufbau auf dem Steckbrett
Bevor man das Projekt auf eine Platine überträgt, lässt sich der komplette Aufbau bequem auf einem Steckbrett testen.
Das folgende Foto zeigt, wie einfach die Schaltung tatsächlich ist:
Abbildung 1: Der komplette Aufbau der NTP-Uhr auf einem Steckbrett. Der ESP32-POE-ISO wird direkt über Ethernet mit Strom versorgt, das Display ist über I²C angebunden.

Links ist der Olimex ESP32-POE-ISO zu sehen, der über das gelbe Ethernetkabel sowohl Daten als auch Strom erhält.
Rechts daneben befindet sich das 1604-LCD, das die aktuelle Uhrzeit und das Datum anzeigt.
Die wenigen Jumperkabel verdeutlichen, dass der Schaltungsaufwand minimal bleibt – im Wesentlichen sind nur SDA, SCL, 5 V und GND verbunden.
Dank der galvanischen Trennung des Olimex-Boards kann das USB-Kabel während des Programmierens gleichzeitig eingesteckt bleiben, ohne das Netzwerk zu beeinflussen.
Die Anzeige zeigt hier bereits die aktuelle NTP-Zeit an, synchronisiert über Ethernet.
Dieser einfache Steckbrettaufbau eignet sich hervorragend zum Experimentieren oder als Grundlage für spätere Erweiterungen – etwa eine Gehäuseversion, eine Hintergrundbeleuchtung mit Sensor oder eine Statusanzeige für den Netzwerkbetrieb.
3. Hardware und Verdrahtung
Der Aufbau der NTP-Uhr bleibt bewusst einfach gehalten und lässt sich vollständig auf einem Steckbrett realisieren.
Das zentrale Element ist der Olimex ESP32-POE-ISO, der sich direkt über Power-over-Ethernet (PoE) versorgen lässt und über dieselbe Leitung auch die Netzwerkzeit bezieht.
So entfällt jegliche externe Spannungsquelle, und die gesamte Schaltung bleibt aufgeräumt und zuverlässig.
Das 1604-LCD wird über ein aufgestecktes I²C-Modul (PCF8574) angesteuert.
Dadurch reduziert sich die Verkabelung auf nur zwei Signalleitungen (SDA und SCL).
Da das LCD-Modul mit 5 V-Logik arbeitet, der ESP32 jedoch 3,3 V nutzt, kommt ein kleiner BSS138-Level-Shifter zum Einsatz.
Dieser sorgt für eine sichere, bidirektionale Pegelwandlung zwischen beiden Spannungsbereichen, ohne die Signalqualität zu beeinträchtigen.

Abbildung 2: Verdrahtung des ESP32-POE-ISO mit dem 1604-LCD über einen BSS138-Level-Shifter. Die Schaltung bleibt dank PoE-Versorgung und I²C-Anbindung extrem kompakt.
Pinbelegung im Überblick
| ESP32-POE-ISO | GPIO | Level-Shifter (LV-Seite) | Level-Shifter (HV-Seite) | LCD-I²C-Modul | Beschreibung |
|---|---|---|---|---|---|
| 3V3 | – | LV | – | – | Versorgung 3,3 V für Logik und LV-Seite |
| 5V | – | – | HV | VCC | 5 V-Versorgung für Display über PoE |
| GND | – | GND | GND | GND | Gemeinsame Masseleitung |
| GPIO 13 | 13 | LV1 | HV1 | SCL | I²C-Taktleitung |
| GPIO 16 | 16 | LV2 | HV2 | SDA | I²C-Datenleitung |
Die Initialisierung in der Arduino-IDE erfolgt über:
Wire.begin(16, 13);
Damit wird GPIO 16 als SDA und GPIO 13 als SCL festgelegt.
Diese Kombination funktioniert auf dem ESP32-POE-ISO zuverlässig, ohne die Ethernet-Funktion zu beeinträchtigen.
Hinweise zu den Pull-Up-Widerständen
Beim I²C-Bus werden die Daten- und Taktleitungen (SDA und SCL) nicht aktiv getrieben, sondern nur auf Masse gezogen.
Damit das funktioniert, müssen beide Leitungen über Pull-Up-Widerstände auf die Betriebsspannung gezogen werden.
Erst dadurch entstehen saubere logische Pegel und klar definierte Flanken.
In dieser Schaltung befinden sich die Pull-Ups (meist 4,7 kΩ bis 10 kΩ) bereits auf dem I²C-Interface-Modul des Displays, also auf der 5 V-Seite des Level-Shifters.
Da der BSS138-Level-Shifter bidirektional arbeitet, werden die Pegel korrekt auf die 3,3 V-Seite übertragen.
Wichtig ist, dass keine zusätzlichen Pull-Ups auf der 3,3 V-Seite des ESP32 vorhanden sind – sonst entsteht ein Spannungsteiler, der die Signale verfälschen oder die Pegelgrenzen verletzen kann.
Bei den meisten ESP32-Boards sind diese Widerstände ohnehin nicht bestückt, sodass keine Konflikte entstehen.
Wer dennoch Instabilitäten auf dem Bus feststellt (z. B. flackernde Anzeige oder Kommunikationsfehler), kann versuchsweise die vorhandenen Pull-Ups auf 5 V leicht erhöhen (z. B. 10 kΩ statt 4,7 kΩ) oder den Bus mit einem Logikanalysator prüfen.
Im Regelfall funktioniert die werkseitige Bestückung der PCF8574-Module jedoch einwandfrei.
Die Kombination aus einseitig platzierten Pull-Ups und bidirektionalem Level-Shifter sorgt hier für stabile, störungsfreie Kommunikation und saubere Pegelübergänge – auch bei längeren Kabeln bis etwa 30 cm.
4. Einrichtung der Arduino IDE
Bevor der ESP32 programmiert werden kann, muss die Arduino IDE für die Verwendung von ESP32-Boards eingerichtet werden.
Das Olimex ESP32-POE-ISO nutzt dieselbe Softwarebasis wie alle anderen ESP32-Module, erfordert aber einige angepasste Board-Einstellungen, damit Flash-Größe, Port und Upload-Parameter korrekt gesetzt sind.
Vorbereitung der Arduino IDE
- Arduino IDE starten
– getestet wurde hier mit Version 2.x, ältere 1.8.x-Versionen funktionieren ebenfalls. - Boardverwalter-URL hinzufügen
Öffne
Datei → Voreinstellungen → Zusätzliche Boardverwalter-URLs
und trage folgende Zeile ein:https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.jsonDamit werden die offiziellen ESP32-Pakete von Espressif eingebunden. - ESP32-Boardpaket installieren
Unter
Werkzeuge → Board → Boardverwalter
nach „ESP32“ suchen und „esp32 by Espressif Systems“ installieren.
Danach kann als Board entweder
„Olimex ESP32-POE-ISO“ (falls vorhanden)
oder alternativ „ESP32 Dev Module“ gewählt werden.
Board-Einstellungen
Nach der Installation müssen die Parameter unter Werkzeuge kontrolliert bzw. angepasst werden:
| Einstellung | Empfohlener Wert | Erläuterung |
|---|---|---|
| Board | Olimex ESP32-POE-ISO oder ESP32 Dev Module | Basis-Definition |
| Upload-Geschwindigkeit (Upload Speed) | 115200 Baud | Stabiler Wert für die meisten USB-UART-Adapter |
| Flash-Frequenz | 80 MHz | Standard für ESP32-Module |
| Flash-Größe (Flash Size) | 16 MB (128 Mbit) | Unbedingt anpassen, wenn die 16 MB-Version des Boards verwendet wird – sonst stehen nur 4 MB zur Verfügung |
| Partition Scheme | Default (1.2 MB APP/1.5 MB SPIFFS) | ausreichend für dieses Projekt |
| PSRAM | Disabled | nicht erforderlich |
| Core Debug Level | None | optional auf „Error“ stellen, wenn man Debugausgaben sehen möchte |
| Port (Windows) | COMx | der vom System zugewiesene COM-Port (z. B. COM4) |
| Port (Linux) | /dev/ttyUSB0 oder /dev/ttyACM0 | je nach verwendetem USB-Chip |
Unter Windows erscheint der Port nach Anschluss des Boards automatisch in der Liste, sofern der CP2102-Treiber installiert ist.
Unter Linux kann man den Anschluss prüfen mit:
ls /dev/ttyUSB*
oder
dmesg | grep tty
Sobald der korrekte Port ausgewählt ist, kann die Verbindung getestet werden, indem man über Werkzeuge → Serieller Monitor eine Ausgabe öffnet und das Board anschließend resetet.
Notwendige Bibliotheken
Neben der Board-Definition werden zwei zusätzliche Bibliotheken benötigt:
| Bibliothek | Funktion | Installation |
|---|---|---|
| NTPClient | Abfrage der Netzwerkzeit (NTP) über Ethernet | über den Bibliotheksverwalter installieren |
| NewLiquidCrystal | Ansteuerung des LCD-Displays über das I²C-Interface (PCF8574) | ebenfalls über den Bibliotheksverwalter installierbar |
Beide Bibliotheken lassen sich komfortabel über
Sketch → Bibliothek einbinden → Bibliotheken verwalten
installieren.
Nach erfolgreicher Installation kann das Display im Sketch über folgende Zeilen initialisiert werden:
#include <Wire.h> #include <LiquidCrystal_I2C.h>
Die typische I²C-Adresse des Moduls ist 0x27 (manche Varianten nutzen 0x3F); die genaue Adresse kann später im Code angepasst werden.
Damit ist die Entwicklungsumgebung vollständig eingerichtet.
Das Board ist einsatzbereit, und im nächsten Schritt folgt der eigentliche Sketch, der die NTP-Abfrage durchführt und die Uhrzeit auf dem Display ausgibt.
5. Software & Sketch – was der Code macht und was du anpasst
Hier das komplette Sketch für eine simple Variante einer NTP Uhr.
#include <WiFi.h>
#include <ETH.h>
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <time.h>
#include "esp_sntp.h"
// ---------- ESP32-POE-ISO Ethernet (LAN8710/LAN8720-kompatibel) ----------
#define ETH_ADDR 0
#define ETH_POWER_PIN 12
#define ETH_MDC_PIN 23
#define ETH_MDIO_PIN 18
#define ETH_TYPE ETH_PHY_LAN8720
#define ETH_CLK_MODE ETH_CLOCK_GPIO17_OUT
// ---------- I2C / LCD ----------
#define SDA_PIN 13 // UEXT SDA
#define SCL_PIN 16 // UEXT SCL
#define LCD_ADDR 0x27 // deine gefundene Adresse
// BEISPIEL für gängiges Backpack-Mapping (FC-113 / YwRobot):
// ctor(addr, en, rw, rs, d4, d5, d6, d7, backlightPin, polarity)
// >>> Nimm hier stattdessen DEINE bereits funktionierende ctor-Zeile <<<
LiquidCrystal_I2C lcd(LCD_ADDR, 2, 1, 0, 4, 5, 6, 7, 3, POSITIVE);
// ---------- Status ----------
volatile bool eth_up = false;
volatile bool eth_got_ip = false;
// ---------- Events ----------
void WiFiEvent(arduino_event_id_t event) {
switch (event) {
case ARDUINO_EVENT_ETH_START:
ETH.setHostname("esp32-poe-iso-clock");
break;
case ARDUINO_EVENT_ETH_CONNECTED:
eth_up = true;
break;
case ARDUINO_EVENT_ETH_GOT_IP:
eth_got_ip = true;
break;
case ARDUINO_EVENT_ETH_DISCONNECTED:
case ARDUINO_EVENT_ETH_STOP:
eth_up = false;
eth_got_ip = false;
break;
default:
break;
}
}
// ---------- Setup ----------
void setup() {
// I2C auf UEXT-Pins, etwas langsamer für robuste Geräte
Wire.begin(SDA_PIN, SCL_PIN);
Wire.setClock(100000);
// LCD init
lcd.begin(16, 2);
lcd.backlight();
lcd.display();
lcd.noCursor();
lcd.noBlink();
lcd.clear();
lcd.setCursor(0,0); lcd.print("Init Ethernet...");
// Ethernet starten
WiFi.onEvent(WiFiEvent);
ETH.begin(ETH_TYPE, ETH_ADDR, ETH_MDC_PIN, ETH_MDIO_PIN, ETH_POWER_PIN, ETH_CLK_MODE);
// Auf DHCP warten (max 10 s)
unsigned long t0 = millis();
while (!eth_got_ip && millis() - t0 < 10000) delay(10);
lcd.clear();
if (!eth_got_ip) {
lcd.setCursor(0,0); lcd.print("ETH/DHCP FAIL");
return;
}
// IP kurz anzeigen
lcd.setCursor(0,0); lcd.print("IP:");
lcd.setCursor(0,1); lcd.print(ETH.localIP().toString());
delay(1500);
// --- Manuelle NTP-Konfiguration ---
setenv("TZ", "CET-1CEST,M3.5.0,M10.5.0/3", 1);
tzset();
sntp_setoperatingmode(SNTP_OPMODE_POLL);
sntp_setservername(0, "0.de.pool.ntp.org");
sntp_setservername(1, "1.de.pool.ntp.org");
sntp_setservername(2, "2.de.pool.ntp.org");
// Intervall in Millisekunden → 60 s = 60 000 ms
sntp_set_sync_interval(60 * 1000UL);
sntp_init();
// Warten bis Zeit gesetzt ist
tm tminfo;
t0 = millis();
while (!getLocalTime(&tminfo, 1000)) { // 1s Timeout pro Versuch
if (millis() - t0 > 10000) break; // max 10s
}
lcd.clear();
}
// ---------- Loop ----------
void loop() {
if (!eth_got_ip) {
lcd.setCursor(0,0); lcd.print("ETH lost ");
lcd.setCursor(0,1); lcd.print(" ");
delay(500);
return;
}
static int last_sec = -1;
tm tminfo;
if (getLocalTime(&tminfo, 10)) {
if (tminfo.tm_sec != last_sec) {
last_sec = tminfo.tm_sec;
// deutsche Wochentage
const char* wochentag[7] = {"Son", "Mon", "Die", "Mit", "Don", "Fre", "Sam"};
// Zeile 1: Wochentag + Datum
char d[17];
snprintf(d, sizeof(d), "%s %02d.%02d.%04d",
wochentag[tminfo.tm_wday],
tminfo.tm_mday, tminfo.tm_mon + 1, tminfo.tm_year + 1900);
lcd.setCursor(0,0); lcd.print(d);
for (int i=strlen(d); i<16; ++i) lcd.print(' '); // Rest auffüllen
// Zeile 2: Uhrzeit HH:MM:SS
char t[17];
snprintf(t, sizeof(t), "%02d:%02d:%02d",
tminfo.tm_hour, tminfo.tm_min, tminfo.tm_sec);
lcd.setCursor(0,1); lcd.print(t);
for (int i=strlen(t); i<16; ++i) lcd.print(' ');
}
}
delay(50);
}
5.1 Ethernet-/PHY-Konfiguration (Olimex ESP32-POE-ISO)
#define ETH_ADDR 0 #define ETH_POWER_PIN 12 #define ETH_MDC_PIN 23 #define ETH_MDIO_PIN 18 #define ETH_TYPE ETH_PHY_LAN8720 #define ETH_CLK_MODE ETH_CLOCK_GPIO17_OUT
- Diese Pins/Parameter passen zum ESP32-POE-ISO (RMII, LAN8720-kompatibel).
- Nur ändern, wenn du ein anderes ESP32-PoE-Board oder einen anderen PHY verwendest.
ETH_CLOCK_GPIO17_OUT: der ESP32 liefert den RMII-Takt über GPIO17 – das ist beim Olimex korrekt.
Die Event-Routine:
void WiFiEvent(arduino_event_id_t event) { ... }
- Setzt Hostname, merkt Link up / IP erhalten.
- Wird im
setup()mitWiFi.onEvent(WiFiEvent);registriert.
DHCP-Wartefenster (10 s):
while (!eth_got_ip && millis() - t0 < 10000) delay(10);
- Falls dein Netz länger braucht (VLAN, langsamere DHCP-Response), Timeout erhöhen (z. B. 15000–20000 ms).
5.2 I²C-Bus, Pins & Takt
#define SDA_PIN 13 #define SCL_PIN 16 ... Wire.begin(SDA_PIN, SCL_PIN); Wire.setClock(100000);
- I²C läuft hier mit 100 kHz (robust bei BSS138-Shiftern).
- WICHTIG (Konsistenz): Du hattest weiter oben „SDA=16, SCL=13“ erwähnt.
In diesem Sketch sind die Defines SDA=13 und SCL=16.
→ Prüfe deine Verdrahtung und setze entwederWire.begin(16, 13);(SDA=16, SCL=13)
oder
belasse die Defines wie hier (SDA=13, SCL=16) – aber dann muss die Hardware so verdrahtet sein.
Wenn das Display stumm bleibt: SDA/SCL tauschen und erneut testen.
5.3 LCD-Initialisierung & Backpack-Mapping
#define LCD_ADDR 0x27 LiquidCrystal_I2C lcd(LCD_ADDR, 2, 1, 0, 4, 5, 6, 7, 3, POSITIVE); ... lcd.begin(16, 2);
- I²C-Adresse: häufig
0x27oder0x3F. Wenn unklar → kurz mit einem I²C-Scanner ermitteln. - Backpack-Mapping: Die ctor-Parameter sind für gängige FC-113/YwRobot-Module.
Wenn dein Modul anders belegt ist (PCF8574-Pins ↔ LCD-Pins), musst du die Reihenfolge anpassen (oder eine zur Platine passende Variante aus derNewLiquidCrystal-Doku wählen). - Zeilen/Spalten: Du nutzt
lcd.begin(16, 2). Dein Projekt spricht von 1604 (16×4).- Falls du wirklich ein 16×4 verwendest, setze
lcd.begin(16, 4);und schreibe in alle vier Zeilen. - Der aktuelle Code schreibt nur zwei Zeilen (Datum, Uhrzeit). Das ist für 1602 korrekt.
- Falls du wirklich ein 16×4 verwendest, setze
Backlight/Anzeige:
lcd.backlight(); lcd.display(); lcd.noCursor(); lcd.noBlink();
- Backlight an, Anzeige aktiv, ohne Cursor.
5.4 Zeitzone & NTP (SNTP)
setenv("TZ", "CET-1CEST,M3.5.0,M10.5.0/3", 1);
tzset();
- Stellt die Mitteleuropäische Zeit mit Sommerzeit ein (Europa/Berlin).
- Wenn du außerhalb D/AT/CH bist, TZ-String anpassen.
sntp_setservername(0, "0.de.pool.ntp.org"); sntp_setservername(1, "1.de.pool.ntp.org"); sntp_setservername(2, "2.de.pool.ntp.org"); sntp_set_sync_interval(60 * 1000UL); // 60 s sntp_init();
- Drei NTP-Server aus dem de.pool.ntp.org (DNS muss funktionieren).
- Sync-Intervall: 60 s ist sehr häufig – technisch okay, aber unnötig kurz.
- Empfehlung für den Dauerbetrieb: 5–15 min (z. B.
15 * 60 * 1000UL). - Kürzere Intervalle erhöhen Netzlast und NTP-Load, bringen aber keinen sichtbaren Vorteil.
- Empfehlung für den Dauerbetrieb: 5–15 min (z. B.
Warten, bis Zeit steht:
while (!getLocalTime(&tminfo, 1000)) { ... }
- Wartet maximal 10 s auf gültige Zeit. Bei schwankendem Netz ggf. Timeout erhöhen.
5.5 Anzeigeformat & deutsche Wochentage
const char* wochentag[7] = {"Son", "Mon", "Die", "Mit", "Don", "Fre", "Sam"};
- Kürzel sind sauber gesetzt (Son, Mon, Die, Mit, Don, Fre, Sam).
- Datum (DD.MM.YYYY) und Uhrzeit (HH:MM:SS) werden sekundengenau aktualisiert.
- Die Schleifen mit
for (int i=strlen(...); i<16; ++i) lcd.print(' ');löschen Restzeichen – gut gegen „Zeilenreste“.
5.6 Typische Stellen, die du an deine Umgebung anpasst
- SDA/SCL-Pins:
- Entweder (SDA=16, SCL=13) via
Wire.begin(16, 13); - oder (SDA=13, SCL=16) wie im Sketch definiert.
Entscheidend ist die Verdrahtung.
- Entweder (SDA=16, SCL=13) via
- LCD-Größe:
- 1602 →
lcd.begin(16, 2)(aktuell so im Code) - 1604 →
lcd.begin(16, 4)+ zusätzlichelcd.setCursor(...);/lcd.print(...)für Zeile 3/4.
- 1602 →
- I²C-Adresse:
LCD_ADDR=0x27oder0x3F(per Scanner sicherstellen).
- Backpack-Mapping:
- Wenn das Display nur Kauderwelsch zeigt oder Backlight/Zeichen nicht passen: ctor-Mapping an deine Backpack-Platine anpassen.
- NTP-Server & Intervall:
- Eigene Server (z. B. lokale Grandmaster/PTP-zu-NTP-Bridge) eintragen.
- Intervall ggf. auf 15 min setzen.
- DHCP-Timeout:
- Wenn du per VLAN/Firewall verzögerst: Timeout erhöhen.
- TZ-String:
- Andere Zeitzonen? TZ anpassen (oder per Menü/Config speichern, falls du später erweiterst).
5.7 Warum 100 kHz I²C und BSS138?
- Der BSS138-Shifter ist bidirektional und perfekt für I²C – aber nicht für sehr hohe I²C-Taktraten optimiert.
- 100 kHz ist daher ein guter, stabiler Wert. 400 kHz kann funktionieren, ist aber mit manchen Backpacks/Shiftern grenzwertig.
5.8 Fehlersuche (kurz & effektiv)
- Display bleibt leer:
- SDA/SCL tauschen /
Wire.begin()-Reihenfolge prüfen - I²C-Adresse scannen und
LCD_ADDRanpassen - Backpack-Mapping der
LiquidCrystal_I2C-ctor prüfen
- SDA/SCL tauschen /
- Ä, Ö, Ü / Sonderzeichen: HD44780 kennt nur begrenzte Glyphen. Für Umlaute Mapping/CGRAM oder Umschreibung (z. B. „März“ → „Maerz“).
- Ethernet keine IP: DHCP-Timeout erhöhen, Switch/PoE-Port testen, ggf. eigene VLAN-Regeln prüfen.
6. Erweiterte Version mit Weboberfläche
Nach der einfachen Grundvariante folgt nun eine erweiterte Version der PoE-betriebenen NTP-Uhr, die über eine integrierte Weboberfläche konfigurierbar ist.
Damit lässt sich das Verhalten der Uhr bequem über den Browser anpassen, ohne den Code neu flashen zu müssen.
Die Uhr zeigt weiterhin Datum und Uhrzeit auf dem bekannten 1602-LCD an, arbeitet über Ethernet, bezieht ihre Zeit per NTP und speichert die Konfiguration dauerhaft im Flash-Speicher (NVS).
Über die Weboberfläche können unter anderem die NTP-Server, das Synchronisationsintervall sowie eine manuelle Sofort-Synchronisation eingestellt werden.
Diese Version ist deutlich flexibler und bildet eine solide Grundlage für weitere Erweiterungen wie etwa Netzwerkstatus-Anzeigen, Helligkeitssteuerung oder eine einfache API-Anbindung.
Im Folgenden befindet sich der komplette Beispielcode für die erweiterte Variante.
Er kann direkt in der Arduino-IDE kompiliert werden, wenn die zuvor genannten Bibliotheken installiert und das Board korrekt eingerichtet ist:
/*
* ESP32-POE-ISO NTP-Uhr (1602 I2C) – flackerfrei (Diff-Update) + Web-UI
* - ESP32 Arduino Core 3.x (IDF 5.x) mit esp_sntp_* API
* - Ethernet ereignisgesteuert (sofort weiter bei IP), IP-Anzeige 5s
* - Web: NTP-Server (1–3; 1 genügt), Intervall (Min), Status, letzter Sync, "Jetzt synchronisieren"
* - LCD: Uhr oben (eingezogen +4), Datum unten (+1), rechts oben ✔ (sync) / ! (unsync/stale/offline)
*/
#include <WiFi.h>
#include <ETH.h>
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <WebServer.h>
#include <Preferences.h>
#include <esp_sntp.h>
#include <time.h>
// ---------- Ethernet (ESP32-POE-ISO / LAN8710/8720) ----------
#define ETH_ADDR 0
#define ETH_POWER_PIN 12
#define ETH_MDC_PIN 23
#define ETH_MDIO_PIN 18
#define ETH_TYPE ETH_PHY_LAN8720
#define ETH_CLK_MODE ETH_CLOCK_GPIO17_OUT
// ---------- I2C / LCD ----------
#define SDA_PIN 13
#define SCL_PIN 16
#define LCD_ADDR 0x27
// >>> Falls dein Backpack anders gemappt ist, diese Zeile durch DEINE funktionierende ersetzen:
LiquidCrystal_I2C lcd(LCD_ADDR, 2, 1, 0, 4, 5, 6, 7, 3, POSITIVE);
// ---------- Web / Storage ----------
WebServer server(80);
Preferences prefs;
// ---------- NTP-Einstellungen ----------
char ntp1[64], ntp2[64], ntp3[64];
uint32_t ntp_interval_ms = 60 * 1000UL; // Default: 1 Minute
// ---------- Status ----------
volatile bool eth_got_ip = false;
volatile time_t last_sync = 0;
volatile bool have_synced = false;
// ---------- UI-State ----------
enum UiState { UI_BOOT, UI_SHOW_IP, UI_RUN };
UiState ui_state = UI_BOOT;
unsigned long ui_since = 0;
// ---------- Sync-State ----------
enum SyncState { SYNC_OFFLINE, SYNC_NEVER, SYNC_SYNCING, SYNC_OK, SYNC_STALE };
// ---------- Custom-Chars (✔ und !) ----------
byte glyph_check[8] = {
0b00000, 0b00001, 0b00011, 0b10110, 0b11100, 0b01000, 0b00000, 0b00000
};
byte glyph_bang[8] = {
0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00000, 0b00100, 0b00000
};
// ---------- Diff-Update-Puffer & Helfer ----------
char prevLine0[17] = {0};
char prevLine1[17] = {0};
SyncState prevSyncState = (SyncState)-1;
void lcdWriteDiff(uint8_t row, const char* now, char* prev) {
for (uint8_t i = 0; i < 16; ) {
if (now[i] == prev[i]) { i++; continue; }
uint8_t j = i;
while (j < 16 && now[j] != prev[j]) j++;
lcd.setCursor(i, row);
for (uint8_t k = i; k < j; k++) {
lcd.write(now[k]);
prev[k] = now[k];
}
i = j;
}
}
void lcdFillSpaces(uint8_t row) {
lcd.setCursor(0,row);
for (uint8_t i=0;i<16;i++) lcd.write(' ');
}
// ========= SNTP =========
static void sntp_time_sync_cb(struct timeval *tv) {
time_t now = time(nullptr);
last_sync = now;
have_synced = true;
}
void normalizeNTP() {
auto trim_inplace = [](char *s){
while (*s && isspace((unsigned char)*s)) memmove(s, s+1, strlen(s));
int n = strlen(s);
while (n>0 && isspace((unsigned char)s[n-1])) s[--n] = '\0';
};
trim_inplace(ntp1); trim_inplace(ntp2); trim_inplace(ntp3);
if (ntp1[0] == '\0') strncpy(ntp1, "0.de.pool.ntp.org", sizeof(ntp1));
// Ein Server reicht: leere Felder mit ntp1 auffüllen
if (ntp2[0] == '\0') strncpy(ntp2, ntp1, sizeof(ntp2));
if (ntp3[0] == '\0') strncpy(ntp3, ntp1, sizeof(ntp3));
if (ntp_interval_ms < 1000UL) ntp_interval_ms = 60 * 1000UL;
}
void applySNTP() {
normalizeNTP();
if (esp_sntp_enabled()) esp_sntp_stop();
esp_sntp_setoperatingmode(SNTP_OPMODE_POLL);
esp_sntp_set_sync_mode(SNTP_SYNC_MODE_IMMED);
esp_sntp_set_time_sync_notification_cb(sntp_time_sync_cb);
esp_sntp_setservername(0, ntp1);
esp_sntp_setservername(1, ntp2);
esp_sntp_setservername(2, ntp3);
esp_sntp_set_sync_interval(ntp_interval_ms);
have_synced = false;
last_sync = 0;
esp_sntp_init(); // sofortige erste Anfrage
}
SyncState currentSyncState() {
if (!eth_got_ip) return SYNC_OFFLINE;
if (!esp_sntp_enabled()) return SYNC_NEVER;
if (!have_synced) return SYNC_SYNCING;
time_t now = time(nullptr);
if (now == 0) return SYNC_SYNCING;
// "frisch", wenn innerhalb 3×Intervall + 30s synchronisiert
uint32_t max_age_s = (ntp_interval_ms/1000UL) * 3UL + 30UL;
time_t age = (now > last_sync) ? (now - last_sync) : 0;
return (age <= (time_t)max_age_s) ? SYNC_OK : SYNC_STALE;
}
// ========= HTML =========
String fmtLocal(time_t t) {
if (t == 0) return String("–");
struct tm tm;
localtime_r(&t, &tm);
char buf[32];
snprintf(buf, sizeof(buf), "%04d-%02d-%02d %02d:%02d:%02d",
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
tm.tm_hour, tm.tm_min, tm.tm_sec);
return String(buf);
}
String syncBadgeHtml() {
SyncState st = currentSyncState();
String label, color;
switch (st) {
case SYNC_OK: label="Synchron"; color="#16a34a"; break;
case SYNC_SYNCING: label="Syncing…"; color="#2563eb"; break;
case SYNC_STALE: label="Stale"; color="#d97706"; break;
case SYNC_OFFLINE: label="Offline"; color="#dc2626"; break;
case SYNC_NEVER: label="Nie"; color="#6b7280"; break;
}
return "<span style='display:inline-block;padding:2px 8px;border-radius:999px;color:#fff;background:"+color+"'>"+label+"</span>";
}
String htmlPage() {
String ip = eth_got_ip ? ETH.localIP().toString() : String("—");
String last = have_synced ? fmtLocal(last_sync) : String("noch nie");
String html =
"<!doctype html><html><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>ESP32 NTP Setup</title>"
"<style>"
"body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;font-size:16px;max-width:680px;margin:24px auto;padding:0 12px}"
"h1{font-size:22px;margin:0 0 12px}"
"form{display:grid;gap:10px}"
"label{display:block;font-weight:600;margin:4px 0}"
"input{width:100%;padding:8px;border:1px solid #ccc;border-radius:6px}"
"button{padding:10px 14px;border:0;border-radius:8px;background:#0b5cff;color:#fff;cursor:pointer}"
".row{display:grid;grid-template-columns:160px 1fr;gap:12px;align-items:center}"
".card{border:1px solid #e5e7eb;border-radius:12px;padding:14px 16px;margin:12px 0}"
".actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:10px}"
".hint{color:#666;font-size:13px}"
"</style></head><body>";
html += "<h1>ESP32 PoE Uhr – NTP Konfiguration</h1>";
html += "<div class='card'>"
"<div class='row'><div>IP-Adresse</div><div><b>"+ip+"</b></div></div>"
"<div class='row'><div>Status</div><div>"+syncBadgeHtml()+"</div></div>"
"<div class='row'><div>Intervall</div><div><b>"+String(ntp_interval_ms/60000)+" min</b></div></div>"
"<div class='row'><div>Letzter Sync</div><div><b>"+last+"</b></div></div>"
"<div class='actions'>"
"<form method='POST' action='/sync'><button>Jetzt synchronisieren</button></form>"
"</div>"
"</div>";
html += "<form method='POST' action='/save'>";
html += "<div class='row'><label for='s1'>NTP-Server 1</label>"
"<input id='s1' name='s1' value='"+String(ntp1)+"'></div>";
html += "<div class='row'><label for='s2'>NTP-Server 2</label>"
"<input id='s2' name='s2' value='"+String(ntp2)+"'></div>";
html += "<div class='row'><label for='s3'>NTP-Server 3</label>"
"<input id='s3' name='s3' value='"+String(ntp3)+"'></div>";
html += "<div class='row'><label for='iv'>Intervall (Min)</label>"
"<input id='iv' name='iv' type='number' min='1' max='1440' value='"+String(ntp_interval_ms/60000)+"'>"
"<div class='hint'>1–1440 Minuten; 1 Server genügt, leere Felder werden ergänzt</div></div>";
html += "<div><button type='submit'>Speichern</button></div></form>";
html += "</body></html>";
return html;
}
// ========= Web-Handler =========
void handleRoot(){ server.send(200, "text/html; charset=utf-8", htmlPage()); }
void handleSave(){
auto norm = [](String s)->String{ s.trim(); return s; };
if (server.hasArg("s1")) strncpy(ntp1, norm(server.arg("s1")).c_str(), sizeof(ntp1));
if (server.hasArg("s2")) strncpy(ntp2, norm(server.arg("s2")).c_str(), sizeof(ntp2));
if (server.hasArg("s3")) strncpy(ntp3, norm(server.arg("s3")).c_str(), sizeof(ntp3));
if (server.hasArg("iv")) {
long m = server.arg("iv").toInt();
if (m < 1) m = 1; if (m > 1440) m = 1440;
ntp_interval_ms = (uint32_t)m * 60UL * 1000UL;
}
prefs.begin("ntp", false);
prefs.putString("s1", ntp1);
prefs.putString("s2", ntp2);
prefs.putString("s3", ntp3);
prefs.putULong("iv", ntp_interval_ms);
prefs.end();
applySNTP(); // sofort übernehmen
server.sendHeader("Location", "/");
server.send(303);
}
void handleSync(){
have_synced = false;
last_sync = 0;
applySNTP();
server.sendHeader("Location", "/");
server.send(303);
}
// ========= Events =========
void WiFiEvent(arduino_event_id_t event) {
if (event == ARDUINO_EVENT_ETH_GOT_IP) {
eth_got_ip = true;
ui_state = UI_SHOW_IP;
ui_since = millis();
lcd.clear();
lcd.setCursor(0,0); lcd.print("IP-Adresse:");
lcd.setCursor(0,1); lcd.print(ETH.localIP().toString());
setenv("TZ", "CET-1CEST,M3.5.0,M10.5.0/3", 1);
tzset();
applySNTP();
}
if (event == ARDUINO_EVENT_ETH_DISCONNECTED || event == ARDUINO_EVENT_ETH_STOP) {
eth_got_ip = false;
ui_state = UI_BOOT;
lcd.clear();
lcd.setCursor(0,0); lcd.print("ETH offline");
}
}
// ========= Setup / Loop =========
void setup() {
Wire.begin(SDA_PIN, SCL_PIN);
Wire.setClock(100000); // PCF8574: 100 kHz
lcd.begin(16, 2);
lcd.createChar(0, glyph_check);
lcd.createChar(1, glyph_bang);
lcd.backlight(); lcd.display();
lcd.noCursor(); lcd.noBlink();
lcd.clear();
lcd.setCursor(0,0); lcd.print("Init Ethernet...");
// NVS laden
prefs.begin("ntp", true);
String s1 = prefs.getString("s1", "0.de.pool.ntp.org");
String s2 = prefs.getString("s2", "1.de.pool.ntp.org");
String s3 = prefs.getString("s3", "2.de.pool.ntp.org");
uint32_t iv = prefs.getULong("iv", 60 * 1000UL);
prefs.end();
strncpy(ntp1, s1.c_str(), sizeof(ntp1));
strncpy(ntp2, s2.c_str(), sizeof(ntp2));
strncpy(ntp3, s3.c_str(), sizeof(ntp3));
ntp_interval_ms = iv ? iv : (60 * 1000UL);
WiFi.onEvent(WiFiEvent);
ETH.begin(ETH_TYPE, ETH_ADDR, ETH_MDC_PIN, ETH_MDIO_PIN, ETH_POWER_PIN, ETH_CLK_MODE);
server.on("/", handleRoot);
server.on("/save", HTTP_POST, handleSave);
server.on("/sync", HTTP_POST, handleSync);
server.begin();
}
void loop() {
server.handleClient();
// IP nur 5 s zeigen, dann RUN initialisieren
if (ui_state == UI_SHOW_IP && millis() - ui_since >= 5000UL) {
ui_state = UI_RUN;
lcd.clear();
// Vorpuffer initialisieren & Zeilen füllen
memset(prevLine0, ' ', 16); prevLine0[16] = 0;
memset(prevLine1, ' ', 16); prevLine1[16] = 0;
lcdFillSpaces(0);
lcdFillSpaces(1);
prevSyncState = (SyncState)-1;
}
if (ui_state != UI_RUN) { delay(10); return; }
static int last_sec = -1;
static int last_min = -1;
tm tminfo;
if (getLocalTime(&tminfo, 10)) {
if (tminfo.tm_sec != last_sec) {
last_sec = tminfo.tm_sec;
const char* wday[7] = {"Son","Mon","Die","Mit","Don","Fre","Sam"};
char timeLine[17]; // Zeile 0
char dateLine[17]; // Zeile 1
// Uhrzeit oben: +4 Einrückung; Spalte 15 frei fürs Symbol
snprintf(timeLine, sizeof(timeLine), " %02d:%02d:%02d", tminfo.tm_hour, tminfo.tm_min, tminfo.tm_sec);
for (int i = strlen(timeLine); i < 16; ++i) timeLine[i] = ' ';
timeLine[16] = 0;
// Zell 15 NICHT vom Diff überschreiben lassen:
timeLine[15] = prevLine0[15];
// Nur geänderte Zeichen der Uhr schreiben
lcdWriteDiff(0, timeLine, prevLine0);
// Datum unten nur bei Minutenwechsel neu schreiben
if (tminfo.tm_min != last_min) {
last_min = tminfo.tm_min;
snprintf(dateLine, sizeof(dateLine), " %s %02d.%02d.%04d",
wday[tminfo.tm_wday],
tminfo.tm_mday, tminfo.tm_mon + 1, tminfo.tm_year + 1900);
for (int i = strlen(dateLine); i < 16; ++i) dateLine[i] = ' ';
dateLine[16] = 0;
lcdWriteDiff(1, dateLine, prevLine1);
}
// Statussymbol rechts oben nur bei Statuswechsel setzen
SyncState st = currentSyncState();
if (st != prevSyncState) {
prevSyncState = st;
lcd.setCursor(15, 0);
if (st == SYNC_OK) lcd.write((uint8_t)0); // ✔
else lcd.write((uint8_t)1); // !
// prevLine0[15] absichtlich NICHT ändern
}
}
}
delay(10);
}
Nachfolgend wird der Sketch im Detail besprochen: welche Änderungen vorgenommen wurden,
wie die Web-GUI aufgebaut ist, welche Funktionen hinzukamen und
was sich individuell anpassen lässt.
6.1 Ethernet-Initialisierung und Ereignissteuerung
Die Ethernet-Anbindung erfolgt wie in der Grundversion über den integrierten LAN8720-Chip des ESP32-POE-ISO.
Im Setup wird das Interface mit den korrekten Pins initialisiert:
ETH.begin(ETH_TYPE, ETH_ADDR, ETH_MDC_PIN, ETH_MDIO_PIN, ETH_POWER_PIN, ETH_CLK_MODE);
Über die Funktion
void WiFiEvent(arduino_event_id_t event)
werden die Ereignisse des Ethernet-Stacks überwacht:
- ARDUINO_EVENT_ETH_GOT_IP: Das Board hat eine gültige IP-Adresse erhalten → Anzeige der IP auf dem Display für fünf Sekunden und Start der NTP-Synchronisation.
- ARDUINO_EVENT_ETH_DISCONNECTED / STOP: Verbindung verloren → Anzeige „Init Ethernet…“ und Warten auf Neuverbindung.
Dadurch kann das Gerät auch nach einem DHCP-Neustart oder kurzzeitigen Netzunterbruch automatisch weiterarbeiten.
6.2 Zeitabgleich über NTP (SNTP)
Die Zeitabfrage erfolgt über die SNTP-Bibliothek von Espressif:
esp_sntp_setservername(0, ntp1); esp_sntp_set_sync_interval(ntp_interval_ms); esp_sntp_init();
- Es können bis zu drei NTP-Server konfiguriert werden.
- Das Synchronisationsintervall ist frei wählbar (1–1440 Minuten) und wird automatisch überprüft, sodass keine ungültigen Werte eingetragen werden können.
- Der Callback
sntp_time_sync_cb()wird bei jeder erfolgreichen Synchronisation aufgerufen und speichert den Zeitpunkt im Speicher, um den Sync-Status berechnen zu können. - Die Zeitzone wird fest auf Mitteleuropäische Zeit (CET/CEST) eingestellt:
setenv("TZ","CET-1CEST,M3.5.0,M10.5.0/3",1); tzset();
Damit läuft die Uhr vollständig unabhängig vom Internetanbieter oder vom Standort und zeigt automatisch Sommer- und Winterzeit an.
6.3 Speicherung der Einstellungen (Preferences / NVS)
Die Werte für die drei NTP-Server und das Intervall werden mit der Klasse
Preferences prefs;
dauerhaft im internen Flash des ESP32 gespeichert.
Beim Start liest das Programm diese Werte:
prefs.begin("ntp", true);
String s1 = prefs.getString("s1", "0.de.pool.ntp.org");
uint32_t iv = prefs.getULong("iv", 60 * 1000UL);
prefs.end();
Und beim Ändern in der Weboberfläche werden sie wieder geschrieben:
prefs.begin("ntp", false);
prefs.putString("s1", ntp1);
prefs.putULong("iv", ntp_interval_ms);
prefs.end();
Damit bleiben alle Einstellungen auch nach einem Neustart oder Stromausfall erhalten.
6.4 Die Weboberfläche
Nach dem Start stellt die Uhr einen kleinen integrierten Webserver auf Port 80 bereit.
Die Oberfläche ist bewusst schlicht gehalten, vollständig in HTML/CSS geschrieben und funktioniert ohne JavaScript.
Aufgerufen wird sie einfach über die IP-Adresse des Geräts, zum Beispiel:http://192.168.1.42
Angezeigt werden:
- Die aktuelle IP-Adresse
- Der Synchronisationsstatus (grün = synchron, blau = aktuell synchronisierend, orange = veraltet, rot = offline, grau = noch nie synchronisiert)
- Das aktuelle Intervall in Minuten
- Der Zeitpunkt des letzten erfolgreichen Syncs
Darunter befindet sich ein Eingabeformular mit:
- Drei Feldern für die NTP-Server (leere Felder werden automatisch mit Server 1 ergänzt)
- Einem Zahlenfeld für das Intervall (1 – 1440 Minuten)
- Dem Button „Speichern“ (sendet an
/save) - Dem Button „Jetzt synchronisieren“ (sendet an
/sync)
Beim Speichern werden die Werte direkt im Flash abgelegt, der SNTP-Dienst neu gestartet und die Seite aktualisiert.
Die Kommunikation erfolgt lokal, ohne Internetzugriff, und ist damit auch in isolierten Netzwerken einsetzbar.
6.5 Anzeige und Zustände auf dem LCD
Das Display arbeitet weiterhin mit der Bibliothek NewLiquidCrystal über den I²C-Bus.
Angezeigt werden zwei Zeilen:
Zeile 1: Datum mit deutschem Wochentag
Zeile 2: Uhrzeit im Format HH:MM:SS
Der Inhalt wird jede Sekunde aktualisiert.
Bei Netzwerkfehlern oder während des Startvorgangs zeigt die erste Zeile den aktuellen Status an („Init Ethernet…“, „ETH/DHCP FAIL“ usw.).
Dank der sauberen Trennung von Anzeigelogik und Zeitverwaltung kann das LCD leicht ersetzt oder erweitert werden, z. B. durch ein größeres 20×4-Display oder eine OLED-Variante – der Code bleibt identisch.
6.6 Anpassungen für eigene Umgebung
- NTP-Server:
Im Webformular eigene Serveradressen (z. B. aus dem lokalen Netz) eintragen.
Bei nur einem aktiven Server genügt die erste Zeile; die anderen dürfen leer bleiben. - Intervall:
Für den Dauerbetrieb genügt in der Regel ein Intervall von 5–15 Minuten.
Kürzere Zeiten sind möglich, belasten aber unnötig die Server. - DHCP-Timeout:
Falls der DHCP-Server träge ist, kann im Setup das Wartefenster erhöht werden:while (!eth_got_ip && millis() - t0 < 20000) delay(10); - Zeitzone:
Die Zeitzone ist fest im Code definiert; wer außerhalb Mitteleuropas arbeitet, kann hier eigene Angaben eintragen.
6.7 Erweiterungsmöglichkeiten
Diese Version bildet eine solide Basis für eigene Ideen.
Durch die integrierte Web- und NTP-Funktionalität lassen sich viele Erweiterungen einfach realisieren, etwa:
- zusätzliche Anzeige der IP oder des letzten Sync-Ergebnisses im LCD
- Anzeige der Laufzeit seit letztem Start
- Implementierung einer kleinen REST-Schnittstelle
- OTA-Updates (Firmware-Upload über Web-UI)
- Anpassung der Zeitzone über die Weboberfläche
- Helligkeitsregelung oder Display-Dimmer
Der Fantasie sind praktisch keine Grenzen gesetzt – alles, was das Netzwerk hergibt, kann angebunden werden.
Diese Version ist damit nicht nur eine Uhr, sondern ein vollwertiges, eigenständiges Netzwerkgerät, das sich vollständig über das Web konfigurieren lässt und zuverlässig die exakte Uhrzeit anzeigt – ganz ohne zusätzliche Infrastruktur oder manuelle Eingriffe.
7. Fazit und Ausblick
Mit dieser erweiterten Version ist aus der einfachen NTP-Uhr ein ausgesprochen vielseitiges und dennoch kompaktes Netzwerkgerät geworden.
Das Olimex ESP32-POE-ISO zeigt dabei eindrucksvoll, wie viel sich mit wenig Hardware, sauberer Beschaltung und etwas durchdachtem Code erreichen lässt.
Die Uhr läuft völlig autark, bezieht ihre Energie über Power-over-Ethernet, holt sich die exakte Zeit per NTP und kann dank der integrierten Weboberfläche bequem im Browser konfiguriert werden.
Einmal eingerichtet, arbeitet sie zuverlässig im Hintergrund, ganz ohne Display-Flackern, instabile WLAN-Verbindungen oder separate Netzteile.
Der größte Fortschritt gegenüber der Grundversion liegt in der Selbstkonfiguration:
Die NTP-Server und das Synchronisationsintervall können direkt online geändert werden, ohne dass man den Mikrocontroller neu programmieren muss.
Gleichzeitig werden alle Werte dauerhaft im internen Flash gespeichert – ein klarer Vorteil im Dauerbetrieb oder bei Spannungsverlust.
Das Projekt ist damit nicht nur ein Bastelaufbau, sondern bereits ein funktionsfähiges Embedded-System, das sich nahtlos in jede Netzwerkinfrastruktur einfügt.
Auch die Systemarchitektur ist bewusst offen gehalten:
Das Webinterface, der NTP-Teil und die Anzeige sind klar voneinander getrennt und können unabhängig voneinander erweitert oder ersetzt werden.
Das macht die Uhr zu einer idealen Basis für eigene Experimente – etwa für den Einstieg in Web-IoT-Funktionen oder für präzise Zeitverteilung im Heimnetz.
Mögliche Erweiterungen und Ideen
- Automatische Helligkeitsregelung:
Über einen einfachen LDR-Sensor oder eine RTC-Zeitregelung kann das LCD nachts gedimmt oder abgeschaltet werden. - Web-API oder JSON-Schnittstelle:
Ausgabe der aktuellen Zeit, des Synchronstatus oder der IP-Adresse als JSON – z. B. für Smart-Home-Integrationen. - Mehrsprachige Weboberfläche:
Umschaltbar zwischen Deutsch und Englisch oder mit eigenem Logo für ein professionelles Erscheinungsbild. - Konfigurierbare Zeitzone:
Eingabefeld in der Web-UI, das den TZ-String im Flash speichert und ohne Neustart übernimmt. - NTP-Roundtrip- und Offset-Anzeige:
Erfassung der Antwortzeiten und Differenzen, um die Genauigkeit der Zeitsynchronisation zu visualisieren. - Erweiterte Display-Layouts:
Wechselnde Ansichten – Uhrzeit, Datum, Wochentag, Laufzeit seit Start, Anzahl der erfolgreichen Syncs usw. - OTA-Firmware-Updates:
Firmware-Upload direkt über das Webinterface, ohne USB-Kabel oder IDE. - Audio- oder LED-Signal bei Verlust der Netzwerksynchronisation:
Eine akustische oder optische Meldung, wenn die Verbindung länger ausfällt oder die Zeit veraltet. - Integration eines kleinen Web-Dashboards:
Übersicht über Zeit, Netzwerkstatus, Spannung und Temperatur – alles direkt im Browser abrufbar.
Zusammenfassung
Dieses Projekt zeigt, dass sich mit überschaubarem Aufwand eine vollständig netzwerkfähige Uhr realisieren lässt, die stromsparend, wartungsfrei und langfristig stabil läuft.
Die Kombination aus PoE-Versorgung, Ethernet-Kommunikation, Flash-Speicherung und Browser-Konfiguration macht sie zu einer idealen Plattform für viele andere Anwendungen – von Statusanzeigen über Sensor-Gateways bis hin zu autonomen Messsystemen.
Wer möchte, kann das Projekt als Grundlage für eigene Entwicklungen verwenden oder weiter verfeinern.
Egal ob als Werkstattanzeige, Netzwerkmonitor, Zeitquelle für Laborgeräte oder einfach als nützliches Bastelprojekt – diese kleine Uhr beweist, dass man mit einem einzigen ESP32-Modul und etwas Kreativität erstaunlich viel erreichen kann.
