#include #include #include #include #include #include #include #include #include // ================= RTC ================= // RTC-Modul waehlen (vor dem Kompilieren): RTC_MODUL_DS3231, RTC_MODUL_DS1307 oder RTC_MODUL_KEINE. // PlatformIO: ueber die Umgebung (siehe platformio.ini). Arduino IDE: hier eintragen. // Das Board (D1 mini / NodeMCU) waehlt man in der IDE bzw. ebenfalls ueber die Umgebung; // die Pinnamen D1/D2/D4 sind bei beiden identisch. #if !defined(RTC_MODUL_DS3231) && !defined(RTC_MODUL_DS1307) && !defined(RTC_MODUL_KEINE) #define RTC_MODUL_DS3231 #endif #if defined(RTC_MODUL_DS3231) || defined(RTC_MODUL_DS1307) #include #define RTC_VORHANDEN #endif #if defined(RTC_MODUL_DS3231) RTC_DS3231 rtc; #elif defined(RTC_MODUL_DS1307) RTC_DS1307 rtc; #endif #define RTC_SDA D2 #define RTC_SCL D1 bool rtcOk = false; bool rtcBatteryEmpty = false; // RTC hat beim Start die Zeit verloren volatile bool ntpJustSynced = false; void onTimeSet(bool fromSntp){ if(fromSntp) ntpJustSynced = true; } // ================= LED ================= #define LED_PIN D4 // Neue Aufteilung (cad/openscad/gemeinsam.scad): je Wort ein LED-Streifen mit einer // LED pro Buchstabe, Zickzack durch die Zeilen. Zeile 0 laeuft links->rechts, Zeile 1 // rechts->links usw. Die Fuellbuchstaben gibt es nicht, nur die Woerter (inkl. NTP, // API, RTC, ESP). Minutenpunkte sind abgeschaltet. #define NUMPIXELS 104 Adafruit_NeoPixel pixels(NUMPIXELS, LED_PIN, NEO_GRB + NEO_KHZ800); // ================= SERVER & NTP ================= ESP8266WebServer server(80); const char DEFAULT_NTP[] = "de.pool.ntp.org"; const char DEFAULT_TZ[] = "CET-1CEST,M3.5.0/02,M10.5.0/03"; String ntpServer = DEFAULT_NTP; String tzString = DEFAULT_TZ; // ================= SETTINGS ================= #define SETTINGS_MAGIC 0x57554853UL // erkennt gueltige EEPROM-Daten struct Settings { uint32_t magic; int brightness; int nightBrightness; bool autoNight; int nightStart; int nightEnd; uint32_t color; uint32_t bgColor; bool backgroundMode; bool nightBackgroundDisabled; bool statusLeds; int ntpShowSec; char ntpServer[64]; char tzString[64]; } settings; // ================= VAR ================= uint32_t currentColor = 0x00FFFF; uint32_t bgColor = 0x000000; bool backgroundMode = false; bool nightBackgroundDisabled = false; bool statusLeds = true; // Statuswoerter NTP/RTC/ESP anzeigen int ntpShowSec = 10; // Mindestdauer der gruenen NTP-Anzeige bool forceInstant = false; bool updateNeeded = true; int brightness = 120; int nightBrightness = 10; bool autoNight = true; int nightStart = 22; int nightEnd = 6; uint32_t targetBuffer[NUMPIXELS]; // Soll-Farben (ohne Helligkeit/Gamma) uint32_t currentBuffer[NUMPIXELS]; // zuletzt angezeigte Soll-Farben int lastMinute = -1; int lastH = -1; // ================= ZEIT ================= bool timeSynced(){ return time(nullptr) > 100000; } // ================= EEPROM ================= void saveSettings(){ settings.magic = SETTINGS_MAGIC; settings.brightness = brightness; settings.nightBrightness = nightBrightness; settings.autoNight = autoNight; settings.nightStart = nightStart; settings.nightEnd = nightEnd; settings.color = currentColor; settings.bgColor = bgColor; settings.backgroundMode = backgroundMode; settings.nightBackgroundDisabled = nightBackgroundDisabled; settings.statusLeds = statusLeds; settings.ntpShowSec = ntpShowSec; strncpy(settings.ntpServer, ntpServer.c_str(), sizeof(settings.ntpServer) - 1); settings.ntpServer[sizeof(settings.ntpServer) - 1] = '\0'; strncpy(settings.tzString, tzString.c_str(), sizeof(settings.tzString) - 1); settings.tzString[sizeof(settings.tzString) - 1] = '\0'; EEPROM.put(0, settings); EEPROM.commit(); } void loadSettings(){ EEPROM.begin(512); EEPROM.get(0, settings); if(settings.magic == SETTINGS_MAGIC){ brightness = constrain(settings.brightness, 0, 255); nightBrightness = constrain(settings.nightBrightness, 0, 255); autoNight = settings.autoNight; nightStart = constrain(settings.nightStart, 0, 23); nightEnd = constrain(settings.nightEnd, 0, 23); currentColor = settings.color & 0xFFFFFF; bgColor = settings.bgColor & 0xFFFFFF; backgroundMode = settings.backgroundMode; nightBackgroundDisabled = settings.nightBackgroundDisabled; statusLeds = settings.statusLeds; ntpShowSec = constrain(settings.ntpShowSec, 1, 60); settings.ntpServer[sizeof(settings.ntpServer) - 1] = '\0'; settings.tzString[sizeof(settings.tzString) - 1] = '\0'; ntpServer = String(settings.ntpServer); tzString = String(settings.tzString); if(ntpServer.length() == 0) ntpServer = DEFAULT_NTP; if(tzString.length() == 0) tzString = DEFAULT_TZ; } configTime(tzString.c_str(), ntpServer.c_str()); } // Zeit aus der RTC uebernehmen. Die RTC laeuft in UTC. #define RTC_INTERVAL_MS 3600000UL // ohne NTP: RTC stuendlich neu einlesen #define NTP_GUELTIG_MS 7200000UL // NTP-Sync gilt 2 h als aktuell unsigned long lastRtcReadMs = 0; unsigned long lastNtpMs = 0; // 0 = noch kein NTP-Sync seit Start bool ntpShowing = false; // NTP-Wort leuchtet gerade unsigned long ntpShowUntil = 0; bool ntpAktuell(){ return lastNtpMs != 0 && millis() - lastNtpMs < NTP_GUELTIG_MS; } // Liest die RTC; liefert false, wenn sie keine gueltige Zeit hat (Batterie leer). bool readRtcToSystem(){ #ifdef RTC_VORHANDEN lastRtcReadMs = millis(); #if defined(RTC_MODUL_DS3231) bool valid = !rtc.lostPower(); #else bool valid = rtc.isrunning(); #endif DateTime n = rtc.now(); if(valid && n.year() >= 2024){ struct timeval tv = { (time_t)n.unixtime(), 0 }; settimeofday(&tv, nullptr); ntpJustSynced = false; // der eigene Aufruf ist keine NTP-Zeit return true; } #endif return false; } void initRtc(){ #ifdef RTC_VORHANDEN Wire.begin(RTC_SDA, RTC_SCL); if(!rtc.begin()) return; rtcOk = true; // Nach dem Nachstellen per NTP ist der Batteriestatus nicht mehr erkennbar, // daher wird er nur beim Start bestimmt. rtcBatteryEmpty = !readRtcToSystem(); #endif } // Nach jeder NTP-Synchronisation die RTC nachstellen; ohne aktuellen NTP-Sync // stuendlich die Systemzeit aus der RTC auffrischen. void updateTimeSources(){ if(ntpJustSynced){ ntpJustSynced = false; if(timeSynced()){ lastNtpMs = millis() ? millis() : 1; ntpShowing = true; ntpShowUntil = millis() + (unsigned long)ntpShowSec * 1000UL; #ifdef RTC_VORHANDEN if(rtcOk) rtc.adjust(DateTime((uint32_t)time(nullptr))); #endif } } if(ntpShowing && (long)(millis() - ntpShowUntil) >= 0) ntpShowing = false; #ifdef RTC_VORHANDEN if(rtcOk && !rtcBatteryEmpty && !ntpAktuell() && millis() - lastRtcReadMs >= RTC_INTERVAL_MS) readRtcToSystem(); #endif } // ================= STATUS-WOERTER ================= // NTP (gruen): waehrend/kurz nach dem NTP-Abgleich // RTC (gelb): RTC vorhanden, aber kein aktueller NTP-Abgleich; bei leerer Batterie // blinkt das Wort im 10-s-Takt // ESP (rot): weder gueltige RTC noch NTP #define STATUS_GRUEN 0x00FF00 #define STATUS_GELB 0xFFFF00 #define STATUS_ROT 0xFF0000 struct StatusFarben { uint32_t ntp, rtc, esp; }; StatusFarben statusAktuell = {0, 0, 0}; StatusFarben berechneStatus(){ StatusFarben st = {0, 0, 0}; if(!statusLeds) return st; bool ntpOk = ntpAktuell(); bool rtcGut = rtcOk && !rtcBatteryEmpty; if(ntpShowing) st.ntp = STATUS_GRUEN; if(rtcOk){ if(rtcBatteryEmpty) st.rtc = ((millis() / 10000UL) & 1) ? 0 : STATUS_GELB; else if(!ntpOk) st.rtc = STATUS_GELB; } if(!rtcGut && !ntpOk) st.esp = STATUS_ROT; return st; } // Aenderung der Statusanzeige erkennen (loest ein Neuzeichnen aus) bool statusGeaendert(){ StatusFarben st = berechneStatus(); bool changed = st.ntp != statusAktuell.ntp || st.rtc != statusAktuell.rtc || st.esp != statusAktuell.esp; statusAktuell = st; return changed; } void applyStatus(){ for(int i=34;i<=36;i++) if(statusAktuell.ntp) targetBuffer[i] = statusAktuell.ntp; for(int i=56;i<=58;i++) if(statusAktuell.rtc) targetBuffer[i] = statusAktuell.rtc; for(int i=67;i<=69;i++) if(statusAktuell.esp) targetBuffer[i] = statusAktuell.esp; } // ================= HELFER ================= bool isNightTime(struct tm *t){ if(!autoNight) return false; int h = t->tm_hour; if(nightStart > nightEnd){ return (h >= nightStart || h < nightEnd); } else { return (h >= nightStart && h < nightEnd); } } void setRange(int a,int b){ for(int i=a;i<=b;i++) targetBuffer[i]=currentColor; } uint32_t blend(uint32_t c1, uint32_t c2, float t){ uint8_t r1=(c1>>16)&255, g1=(c1>>8)&255, b1=c1&255; uint8_t r2=(c2>>16)&255, g2=(c2>>8)&255, b2=c2&255; return pixels.Color((uint8_t)(r1+(r2-r1)*t + 0.5f), (uint8_t)(g1+(g2-g1)*t + 0.5f), (uint8_t)(b1+(b2-b1)*t + 0.5f)); } uint8_t gamma8(uint8_t x){ float f = (float)x / 255.0f; return (uint8_t)(f * f * 255.0f + 0.5f); } // Skaliert einen Farbkanal mit der Helligkeit und korrigiert Gamma. // Ein aktiver Kanal bleibt bei Helligkeit > 0 mindestens auf 1, damit er nicht erlischt. uint8_t scaleChannel(uint8_t c, float factor){ if(c == 0 || factor <= 0) return 0; uint8_t v = gamma8((uint8_t)(c * factor)); return v ? v : 1; } uint32_t applyGammaBrightness(uint32_t c, float brightnessFactor){ return pixels.Color(scaleChannel((c >> 16) & 255, brightnessFactor), scaleChannel((c >> 8) & 255, brightnessFactor), scaleChannel(c & 255, brightnessFactor)); } int getBrightness(struct tm *t){ return isNightTime(t) ? nightBrightness : brightness; } void showInstant(){ time_t now = time(nullptr); struct tm *t = localtime(&now); float b = getBrightness(t) / 255.0f; for(int i=0;irechts) void word_IST(){ setRange(2,4); } void word_FUENF_M(){ setRange(5,8); } void word_ZEHN_M(){ setRange(9,12); } // Zeile 1 (rechts->links) void word_ZWANZIG(){ setRange(13,19); } void word_VIERTEL(){ setRange(20,26); } // Zeile 2 void word_VOR(){ setRange(27,29); } void word_NACH(){ setRange(30,33); } // Zeile 3 (rechts->links) // NTP 34-36 void word_HALB(){ setRange(37,40); } void word_NEUN(){ setRange(41,44); } // Zeile 4 // API 45-47 void word_FUENF_H(){ setRange(48,51); } void word_EINS(){ setRange(52,55); } // Zeile 5 (rechts->links): S, EIN, RTC, ZWEI void word_EIN(){ setRange(53,55); } // RTC 56-58 void word_ZWEI(){ setRange(59,62); } void word_DREI(){ setRange(63,66); } // Zeile 6 // ESP 67-69 void word_VIER(){ setRange(70,73); } void word_SECHS(){ setRange(74,78); } // Zeile 7 (rechts->links) void word_ACHT(){ setRange(79,82); } void word_SIEBEN(){ setRange(83,88); } // Zeile 8 void word_ZWOELF(){ setRange(89,93); } void word_ZEHN_H(){ setRange(94,97); } // Zeile 9 (rechts->links) void word_ELF(){ setRange(98,100); } void word_UHR(){ setRange(101,103); } String getTimeText(int h, int m){ int m5 = (m/5)*5; if(m5 >= 25) h++; const char* arr[] = {"Zwölf","Eins","Zwei","Drei","Vier","Fünf","Sechs","Sieben","Acht","Neun","Zehn","Elf"}; String hName = arr[h%12]; String t = "Es ist "; switch(m5){ case 0: t += (h%12 == 1) ? String("ein") : hName; t += " Uhr"; break; case 5: t += "fünf nach " + hName; break; case 10: t += "zehn nach " + hName; break; case 15: t += "viertel nach " + hName; break; case 20: t += "zwanzig nach " + hName; break; case 25: t += "fünf vor halb " + hName; break; case 30: t += "halb " + hName; break; case 35: t += "fünf nach halb " + hName; break; case 40: t += "zwanzig vor " + hName; break; case 45: t += "viertel vor " + hName; break; case 50: t += "zehn vor " + hName; break; case 55: t += "fünf vor " + hName; break; } return t; } void showTimeWords(int h, int m){ time_t now = time(nullptr); struct tm *t = localtime(&now); // Startpunkt des Fades ist die zuletzt angezeigte Soll-Farbe (ohne Helligkeit/Gamma) for(int i=0;i= 25) h++; switch(m5){ case 0: word_UHR(); break; case 5: word_FUENF_M(); word_NACH(); break; case 10: word_ZEHN_M(); word_NACH(); break; case 15: word_VIERTEL(); word_NACH(); break; case 20: word_ZWANZIG(); word_NACH(); break; case 25: word_FUENF_M(); word_VOR(); word_HALB(); break; case 30: word_HALB(); break; case 35: word_FUENF_M(); word_NACH(); word_HALB(); break; case 40: word_ZWANZIG(); word_VOR(); break; case 45: word_VIERTEL(); word_VOR(); break; case 50: word_ZEHN_M(); word_VOR(); break; case 55: word_FUENF_M(); word_VOR(); break; } int hh = h % 12; if(hh == 1 && full) word_EIN(); else if(hh == 1) word_EINS(); else if(hh == 2) word_ZWEI(); else if(hh == 3) word_DREI(); else if(hh == 4) word_VIER(); else if(hh == 5) word_FUENF_H(); else if(hh == 6) word_SECHS(); else if(hh == 7) word_SIEBEN(); else if(hh == 8) word_ACHT(); else if(hh == 9) word_NEUN(); else if(hh == 10) word_ZEHN_H(); else if(hh == 11) word_ELF(); else word_ZWOELF(); applyStatus(); if(forceInstant){ showInstant(); forceInstant = false; } else { fadeToTargetSmooth(20, 10); } } // Solange noch keine Zeit bekannt ist, nur Hintergrund und Statuswoerter zeigen void showStatusOnly(){ for(int i=0;i hi){ server.send(400, "text/plain", "Ungueltiger Wert"); return false; } out = v; return true; } void handleStatus(){ time_t now = time(nullptr); struct tm *t = localtime(&now); char hexCol[10], hexBg[10]; snprintf(hexCol, sizeof(hexCol), "#%06X", (unsigned int)currentColor); snprintf(hexBg, sizeof(hexBg), "#%06X", (unsigned int)bgColor); String ntpStat = timeSynced() ? "OK" : "SYNC..."; String json = "{"; json += "\"txt\":\"" + (timeSynced() ? jsonEscape(getTimeText(t->tm_hour, t->tm_min)) : String("Warte auf Zeit...")) + "\","; json += "\"ip\":\"" + WiFi.localIP().toString() + "\","; json += "\"ntp\":\"" + ntpStat + "\","; json += "\"ntpServer\":\"" + jsonEscape(ntpServer) + "\","; json += "\"tzString\":\"" + jsonEscape(tzString) + "\","; json += "\"brightness\":" + String(brightness) + ","; json += "\"nightBrightness\":" + String(nightBrightness) + ","; json += "\"color\":\"" + String(hexCol) + "\","; json += "\"bgColor\":\"" + String(hexBg) + "\","; json += "\"night\":" + String(autoNight ? 1 : 0) + ","; json += "\"nightStart\":" + String(nightStart) + ","; json += "\"nightEnd\":" + String(nightEnd) + ","; json += "\"bg\":" + String(backgroundMode ? 1 : 0) + ","; json += "\"nightBgOff\":" + String(nightBackgroundDisabled ? 1 : 0) + ","; json += "\"statusLeds\":" + String(statusLeds ? 1 : 0) + ","; json += "\"ntpShowSec\":" + String(ntpShowSec); json += "}"; server.send(200, "application/json", json); } static const char INDEX_HTML[] PROGMEM = R"rawliteral( Wortuhr
Lade...
Verbinde...
Hintergrund

Nachtmodus

Von: Uhr Bis: Uhr

Hintergrund in Nacht aus
Status-Anzeige (NTP grün, RTC gelb, ESP rot)

NTP-Anzeige mind. Sekunden
)rawliteral"; void handleRoot(){ server.send_P(200, "text/html", INDEX_HTML); } void handleColor(){ if(server.hasArg("c")){ currentColor = strtoul(server.arg("c").c_str(), NULL, 16) & 0xFFFFFF; forceInstant = true; updateNeeded = true; } sendOK(); } void handleBGColor(){ if(server.hasArg("c")){ bgColor = strtoul(server.arg("c").c_str(), NULL, 16) & 0xFFFFFF; forceInstant = true; updateNeeded = true; } sendOK(); } void handleBrightness(){ int v; if(!argInRange(0,255,v)) return; brightness=v; forceInstant=true; updateNeeded=true; sendOK(); } void handleNight(){ int v; if(!argInRange(0,1,v)) return; autoNight=v; forceInstant=true; updateNeeded=true; sendOK(); } void handleNightStart(){ int v; if(!argInRange(0,23,v)) return; nightStart=v; forceInstant=true; updateNeeded=true; sendOK(); } void handleNightEnd(){ int v; if(!argInRange(0,23,v)) return; nightEnd=v; forceInstant=true; updateNeeded=true; sendOK(); } void handleNightBrightness(){ int v; if(!argInRange(0,255,v)) return; nightBrightness=v; forceInstant=true; updateNeeded=true; sendOK(); } void handleNightBgOff(){ int v; if(!argInRange(0,1,v)) return; nightBackgroundDisabled=v; forceInstant=true; updateNeeded=true; sendOK(); } void handleStatusLeds(){ int v; if(!argInRange(0,1,v)) return; statusLeds=v; forceInstant=true; updateNeeded=true; sendOK(); } void handleNtpShowSec(){ int v; if(!argInRange(1,60,v)) return; ntpShowSec=v; sendOK(); } void handleBG(){ int v; if(!argInRange(0,1,v)) return; backgroundMode=v; forceInstant=true; updateNeeded=true; sendOK(); } void handleNTP(){ String v = server.arg("val"); v.trim(); if(v.length() == 0 || v.length() >= sizeof(settings.ntpServer)){ server.send(400, "text/plain", "Ungueltiger Wert"); return; } ntpServer = v; configTime(tzString.c_str(), ntpServer.c_str()); sendOK(); } void handleTZ(){ String v = server.arg("val"); v.trim(); if(v.length() == 0 || v.length() >= sizeof(settings.tzString)){ server.send(400, "text/plain", "Ungueltiger Wert"); return; } tzString = v; configTime(tzString.c_str(), ntpServer.c_str()); sendOK(); } void handleSave(){ saveSettings(); sendOK(); } // ================= SETUP & LOOP ================= void setup(){ Serial.begin(115200); pixels.begin(); pixels.clear(); pixels.show(); settimeofday_cb(onTimeSet); loadSettings(); initRtc(); WiFiManager wm; wm.autoConnect("Wortuhr"); server.on("/", handleRoot); server.on("/status", handleStatus); server.on("/color", handleColor); server.on("/bgcolor", handleBGColor); server.on("/brightness", handleBrightness); server.on("/night", handleNight); server.on("/nightStart", handleNightStart); server.on("/nightEnd", handleNightEnd); server.on("/nightBrightness", handleNightBrightness); server.on("/nightBgOff", handleNightBgOff); server.on("/bg", handleBG); server.on("/statusLeds", handleStatusLeds); server.on("/ntpShowSec", handleNtpShowSec); server.on("/setNTP", handleNTP); server.on("/setTZ", handleTZ); server.on("/save", handleSave); server.begin(); } void loop(){ server.handleClient(); updateTimeSources(); if(statusGeaendert()){ forceInstant = true; updateNeeded = true; } // Ohne bekannte Zeit nur die Statuswoerter anzeigen if(!timeSynced()){ if(updateNeeded){ updateNeeded = false; forceInstant = false; showStatusOnly(); } return; } time_t now = time(nullptr); struct tm *t = localtime(&now); int currentM5 = (t->tm_min / 5) * 5; if(currentM5 != lastMinute || t->tm_hour != lastH){ lastMinute = currentM5; lastH = t->tm_hour; updateNeeded = true; } if(updateNeeded){ updateNeeded = false; showTimeWords(t->tm_hour, t->tm_min); } }