Update: 2024-06-06

Esp32 Zeitschaltuhr

Dual Zeitschaltuhr mit NTP Zeitsynchronisation

Automatischer Wechsel zwischen Sommer und Normalzeit

Betriebsstundenzähler für die Angeschlossenen Verbraucher

Schalten bei Dämmerung

Countdown Timer

Verbrauchsanzeige in Kilowattstunden

Ereignisdatenspeicher für Schaltzeiten und Auslöser

Zugriffsschutz mittels HTTP-Authentifizierung

Eins vorweg, ich übernehme keinerlei Verantwortung falls ihr diesen Sketch nutzt um mittels Relais oder SSR Netzspannung zu schalten. Ich stelle bewusst keine Schaltpläne dazu zur Verfügung. Wendet euch dafür an eine Elektrofachkraft mit entsprechender Ausbildung.

Getestet habe ich mit den, bei Arduino-Jüngern, beliebten blauen Relais Modulen, einem Mosfet "IRF3708" und einem Solid State Relais "Fotek SSR-40 DA".

Esp32 Zeitschaltuhr

Highlight

Der Sketch Zeitschaltuhr ist für LOW und HIGH aktive Relais, Solid State Relais oder Mosfet geeignet. Dies muss vor dem Hochladen einmalig im Sketch, im Tab Schaltuhr.ino, eingestellt werden. Es lassen sich bis zu 50 Ein-/Aus-Schaltzeiten für jedes angeschlossene Gerät einstellen. Dies kann vor dem Hochladen im Tab Schaltuhr.ino eingestellt werden. Rechts neben den Button zum manuellen Ein-/Ausschalten des Ausgangs befindet sich die optische Schaltzustandsanzeige.

Esp32 Zeitschaltuhr

Eine Betriebsstundenanzeige der Angeschlossenen Geräte erfolgt durch Klick/Touch auf die Uhrzeit. Gib den Verbrauch deines Gerätes in Watt in die Maske ein. Der Betriebstundenzähler lässt sich per Klick/Touch auf den Button zurücksetzen.

Esp32 Zeitschaltuhr

Es wird für jeden Monat eine neue CSV Datei angelegt, in der die Schaltzeiten und der Initiator des Schaltvorgangs gespeichert werden.

Ansicht Exel (formatiert)

Apr An Initiator Aus Initiator
1. 04:40:00 Uhrzeit 04:50:00 Uhrzeit
1. 21:36:20 192.168.178.36 21:36:41 192.168.178.36
1. 22:00:11 192.168.178.45 22:00:35 192.168.178.45
2. 04:38:00 Uhrzeit 04:40:00 Uhrzeit
3. 04:38:00 Uhrzeit 04:40:00 Uhrzeit
3. 20:47:07 192.168.178.36 20:47:48 192.168.178.36
3. 20:52:16 192.168.178.36 20:52:51 192.168.178.36
4. 07:47:09 Sonnenstand 07:53:21 Sonnenstand
5. 08:38:07 Timer 08:40:17 Timer



Mindestvorraussetzung EspCore 3.0.0

Im Boardverwalter der Arduino IDE lässt sich die EspCoreVersion einstellen.

Esp32 Zeitschaltuhr

Funktionen

Leuchtet das Sonne Symbol sind Schaltzeiten zur Dämmerung eingestellt. Ein Klick/Touch auf das Symbol öffnet bzw. schließt die Box zur Einstellung. Die täglich neu errechneten Dämmerungszeiten werden nun angezeigt. Es ist möglich das Einschalten bei Dämmerung mit einer einzelnen Ausschaltzeit (zB.: um 21:30) zu kombinieren.

Esp32 Zeitschaltuhr

Die einzelnen Schaltzeiten können mittels Schaltfläche R1/R2 ON/OFF aktiviert oder deaktiviert werden. Ein-und Ausschaltzeiten werden in einer Datei im Filesystem des Esp32 gespeichert. Eingegebene Zeitperioden werden durch Klick/Touch des Speichern Buttons zum ESP.. gesendet. Ein erfolgreiches speichern der Schaltzeiten auf dem Esp32 Webserver wird im Webinterface für 5 Sekunden signalisiert. Alle Schaltzeiten können auch gleichzeitig aktiviert/deaktiviert werden.

Grafik Esp32 Zeitschaltuhr

Wird der Kurzzeittimer gestartet werden alle anderen Schaltzeiten für den entsprechenden Verbraucher deaktiviert. Nach Ablauf oder durch pausieren wird der vorhergehende Zustand (Auto aktiv/Auto inaktiv) wieder hergestellt. Befindet sich der Kurzzeittimer im Modus Pause kann er durch Klick/Touch der Schaltfläche Pause fortgesetzt oder durch Start neu gestartet werden.

Grafik Esp32 Zeitschaltuhr

Anleitung zur Inbetriebnahme

Zuerst im "Connect" Tab deine Wlan Zugangsdaten eingeben, anschliesend im "Schaltuhr" Tab die Konstante "ACTIVE" auf HIGH oder LOW setzen. "constexpr auto ACTIVE = LOW;" Je nachdem welche Komponente du an den Ausgängen betreiben möchtest. Du kannst die Anzahl der Schaltzeiten pro Relais an deinen Bedarf anpassen. Diese werden im "Schaltuhr" Tab definiert. "constexpr uint8_t RECORDS = 20;" Im Tab Sonnenlauf den Längen- und Breitengrad deines Standortes eintragen. Eventuell den NTP Zeitserver und die Zeitzone, für deinen Standort, in der "Lokalzeit.ino" ändern. Anschließend den Sketch hochladen.
Im Seriellen Monitor wird die IP des ESP.. angezeigt. "deineIP/fs.html" Kopiere diese URL in die Adresszeile deines Browsers und verbinde dich mit deinem ESP32.
Falls sich im LittleFS (Speicher) des Esp32 noch keine "fs.html" befindet wird ein kleiner Helfer zu deinem Browser gesendet. Mit diesem kannst du die "fs.html" und die "style32.css" hochladen. Jetzt wird der Filesystem Manager angezeigt, mit dem du noch die "admin.html" , das Favicon und die "index.html" in den Speicher deines Esp... uploaden musst. Klick/Touch nun auf die index.html um zur Zeitschaltuhr zu kommen.

Der optionale Zugriffsschutz mittels HTTP-Authentifizierung lässt sich durch Ändern der Variable "constexpr bool PROTECT {1};" einschalten.

Wer möchte kann sich unten den Code ansehen und selbst in Dateien kopieren oder aber das Ganze als Archiv downloaden.

Zeitschaltuhr_Dual_32.ino

// ****************************************************************
// Sketch Esp32 Webserver Modular(Tab)
// created: Jens Fleischer, 2018-07-06
// last mod: Jens Fleischer, 2024-06-06
// For more information visit: https://fipsok.de
// ****************************************************************
// Hardware: Esp32
// Software: Esp32 Arduino Core 3.0.0 - 3.0.1
// Getestet auf: ESP32 NodeMCU-32s
/******************************************************************
  Copyright (c) 2024 Jens Fleischer. All rights reserved.

  This file is free software; you can redistribute it and/or
  modify it under the terms of the GNU Lesser General Public
  License as published by the Free Software Foundation; either
  version 2.1 of the License, or (at your option) any later version.
  This file is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
  Lesser General Public License for more details.
*******************************************************************/
// Der WebServer Tab ist der Haupt Tab mit "setup" und "loop".
// #include <LittleFS.h>, #include <WebServer.h> und
// #include <WiFi.h> müssen im Haupttab aufgerufen werden.
// "server.onNotFound()" darf nicht im Setup des ESP32 Webserver stehen.
// Inklusive Arduino OTA-Updates (Erfordert freien Flash-Speicher)
/*******************************************************************/

#include <WebServer.h>
#include <WiFi.h>
#include <ArduinoOTA.h>
#include <LittleFS.h>
#include <time.h>

constexpr bool PROTECT {0};                                  // HTML-Basisauthentifizierung aktivieren. (1 = aktiviert)
const char* www_username = "admin";                          // Die Anmeldeinformationen für HTML Seiten müssen im Browser eingegeben werden.
const char* www_password = "esp32";

enum Device : bool {Device_I, Device_II};
struct tm tm;

//#define DEBUGGING                                          // Einkommentieren für die Serielle Ausgabe

#ifdef DEBUGGING
#define DEBUG_B(...) Serial.begin(__VA_ARGS__)
#define DEBUG_P(...) Serial.println(__VA_ARGS__)
#define DEBUG_F(...) Serial.printf( __VA_ARGS__ )
#else
#define DEBUG_B(...)
#define DEBUG_P(...)
#define DEBUG_F(...)
#endif

WebServer server(80);

void setup() {
  DEBUG_B(115200);
  delay(100);
  DEBUG_F("\nSketchname: %s\nBuild: %s\t\tIDE: %d.%d.%d\n\n", __FILE__, __TIMESTAMP__, ARDUINO / 10000, ARDUINO % 10000 / 100, ARDUINO % 100 / 10 ? ARDUINO % 100 : ARDUINO % 10);

  setupFS();
  connectWifi();
  admin();
  setupTime();
  setupTimerSwitch();
  setupHobbsMeter();
  ArduinoOTA.onStart([]() {
    toSave();                                                // vor dem OTA Sketch Update Betriebsstunden in Datei schreiben
  });
  ArduinoOTA.begin();
  server.begin();
}

void loop() {
  ArduinoOTA.handle();
  server.handleClient();
  if (millis() < 0x2FFF || millis() > 0xFFFFF0FF) runtime();
  static uint32_t previousMillis {0};
  if (constexpr uint8_t interval {100}; millis() - previousMillis >= interval) {
    previousMillis += interval;
    localTime();
    sunRun();
    dualTimerSwitch();
  }
}

Admin.ino

// ****************************************************************
// Arduino IDE Tab Esp32 Admin
// created: Jens Fleischer, 2021-05-01
// last mod: Jens Fleischer, 2024-06-02
// For more information visit: https://fipsok.de
// ****************************************************************
// Hardware: Esp32
// Software: Esp32 Arduino Core 2.0.0 - 3.0.1
// Geprüft: bei 4MB Flash
// Getestet auf: ESP32 NodeMCU-32s
/******************************************************************
  Copyright (c) 2024 Jens Fleischer. All rights reserved.

  This file is free software; you can redistribute it and/or
  modify it under the terms of the GNU Lesser General Public
  License as published by the Free Software Foundation; either
  version 2.1 of the License, or (at your option) any later version.
  This file is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
  Lesser General Public License for more details.
*******************************************************************/
// Diese Version von Admin sollte als Tab eingebunden werden.
// #include <LittleFS.h>, #include <WebServer.h> und #include <WiFi.h> müssen im Haupttab aufgerufen werden.
// Die Funktionalität des ESP32 Webservers ist erforderlich.
// Die LittleFS.ino muss im ESP32 Webserver enthalten sein
// Funktion "admin();" muss im setup() nach setupFS() und dem Verbindungsaufbau aufgerufen werden.
// Die Funktion "runtime();" muss mindestens zweimal innerhalb 49 Tage aufgerufen werden.
// Entweder durch den Client(Webseite) oder zur Sicherheit im "loop();"
/**************************************************************************************/

#include <rom/rtc.h>

const char* const PROGMEM flashChipMode[] = {"QIO", "QOUT", "DIO", "DOUT", "Unbekannt"};
const char* const PROGMEM resetReason[] = {"ERR", "Power on", "Unknown", "Software", "Watch dog", "Deep Sleep", "SLC module", "Timer Group 0", "Timer Group 1",
                                           "RTC Watch dog", "Instrusion", "Time Group CPU", "Software CPU", "RTC Watch dog CPU", "Extern CPU", "Voltage not stable", "RTC Watch dog RTC"
                                          };

void admin() {                               // Funktionsaufruf "admin();" muss im Setup eingebunden werden
  File file = LittleFS.open(existFolder("/Config") + "/config.json", FILE_READ);
  if (file) {
    String Hostname = file.readStringUntil('\n');
    if (Hostname != "") {
      WiFi.setHostname(Hostname.substring(2, Hostname.length() - 2).c_str());
      ArduinoOTA.setHostname(WiFi.getHostname());
    }
  }
  file.close();
  server.on("/admin/renew", handlerenew);
  server.on("/admin/once", handleonce);
  server.on("/reconnect", []() {
    server.send(304, "message/http");
    WiFi.reconnect();
  });
  server.on("/restart", []() {
    server.send(304, "message/http");
    toSave();          //Einkommentieren wenn Werte vor dem Neustart gesichert werden sollen
    ESP.restart();
  });
}

void handlerenew() {
  server.send(200, "application/json", R"([")" + runtime() + R"(",")" + temperatureRead() + R"(",")" + WiFi.RSSI() + R"("])");     // Json als Array
}

void handleonce() {
  if (server.arg(0) != "") {
    WiFi.setHostname(server.arg(0).c_str());
    File file = LittleFS.open(existFolder("/Config") + "/config.json", FILE_WRITE);
    file.printf("[\"%s\"]", WiFi.getHostname());
    file.close();
  }
  String fname = String(__FILE__).substring( 3, String(__FILE__).lastIndexOf ('\\'));
  String temp = R"({"File":")" + fname.substring(fname.lastIndexOf ('\\') + 1, fname.length()) + R"(", "Build":")" + (String)__DATE__ + " " + (String)__TIME__ +
                R"(", "SketchSize":")" + formatBytes(ESP.getSketchSize()) + R"(", "SketchSpace":")" + formatBytes(ESP.getFreeSketchSpace()) +
                R"(", "LocalIP":")" + WiFi.localIP().toString() + R"(", "Hostname":")" + WiFi.getHostname() + R"(", "SSID":")" + WiFi.SSID() +
                R"(", "GatewayIP":")" + WiFi.gatewayIP().toString() + R"(", "Channel":")" + WiFi.channel() + R"(", "MacAddress":")" + WiFi.macAddress() +
                R"(", "SubnetMask":")" + WiFi.subnetMask().toString() + R"(", "BSSID":")" + WiFi.BSSIDstr() + R"(", "ClientIP":")" + server.client().remoteIP().toString() +
                R"(", "DnsIP":")" + WiFi.dnsIP().toString() + R"(", "ChipModel":")" + ESP.getChipModel() + R"(", "Reset1":")" + resetReason[rtc_get_reset_reason(0)] +
                R"(", "Reset2":")" + resetReason[rtc_get_reset_reason(1)] + R"(", "CpuFreqMHz":")" + ESP.getCpuFreqMHz() + R"(", "HeapSize":")" + formatBytes(ESP.getHeapSize()) +
                R"(", "FreeHeap":")" + formatBytes(ESP.getFreeHeap()) + R"(", "MinFreeHeap":")" + formatBytes(ESP.getMinFreeHeap()) +
                R"(", "ChipSize":")" + formatBytes(ESP.getFlashChipSize()) + R"(", "ChipSpeed":")" + ESP.getFlashChipSpeed() / 1000000 +
                R"(", "ChipMode":")" + flashChipMode[ESP.getFlashChipMode()] + R"(", "C++Version":")" + __cplusplus % 10000 / 100 + R"(", "IdeVersion":")" + ARDUINO +
                R"(", "CoreVersion":")" + ESP_ARDUINO_VERSION_MAJOR + "." + ESP_ARDUINO_VERSION_MINOR + "." + ESP_ARDUINO_VERSION_PATCH +
                R"(", "SdkVersion":")" + ESP.getSdkVersion() + R"("})";
  server.send(200, "application/json", temp);     // Json als Objekt
}

String runtime() {
  static uint8_t rolloverCounter;
  static uint32_t previousMillis;
  uint32_t currentMillis {millis()};
  if (currentMillis < previousMillis) {
    rolloverCounter++;                       // prüft Millis Überlauf
    DEBUG_P("Millis Überlauf");
  }
  previousMillis = currentMillis;
  uint32_t sec {(0xFFFFFFFF / 1000) * rolloverCounter + (currentMillis / 1000)};
  char buf[20];
  snprintf(buf, sizeof(buf), "%ld Tag%s %02ld:%02ld:%02ld", sec / 86400, sec < 86400 || sec >= 172800 ? "e" : "", sec / 3600 % 24, sec / 60 % 60, sec % 60);
  return buf;
}

In diesem Tab sind deine Wlan Zugangsdaten einzutragen.

Connect.ino

// ****************************************************************
// Arduino IDE Tab Esp32 Connect mit optischer Anzeige
// created: Jens Fleischer, 2018-07-06
// last mod: Jens Fleischer, 2020-03-26
// For more information visit: https://fipsok.de
// ****************************************************************
// Hardware: Esp32
// Software: Esp32 Arduino Core 1.0.0 - 3.0.1
// Getestet auf: ESP32 NodeMCU-32s
/******************************************************************
  Copyright (c) 2024 Jens Fleischer. All rights reserved.

  This file is free software; you can redistribute it and/or
  modify it under the terms of the GNU Lesser General Public
  License as published by the Free Software Foundation; either
  version 2.1 of the License, or (at your option) any later version.
  This file is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
  Lesser General Public License for more details.
*******************************************************************/
// Diese Version von Connect sollte als Tab eingebunden werden.
// #include <WebServer.h> muss im Haupttab aufgerufen werden
// Die Funktionalität des ESP32 Webservers ist erforderlich.
// Die Funktion "connectWifi();" muss im Setup eingebunden werden.
/**************************************************************************************/

const char* ssid = "Netzwerkname";               // << kann bis zu 32 Zeichen haben
const char*password = "PasswortvomNetzwerk";     // << mindestens 8 Zeichen jedoch nicht länger als 64 Zeichen

void connectWifi() {                             // Funktionsaufruf "connectWifi();" muss im Setup nach "setupFS();" eingebunden werden
  pinMode(LED_BUILTIN, OUTPUT);                  // OnBoardLed ESP32 Dev Module
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    digitalWrite(LED_BUILTIN, 1);
    delay(250);
    digitalWrite(LED_BUILTIN, 0);
    delay(250);
    DEBUG_F(".");
    if (millis() > 10000) {
      DEBUG_P("\nVerbindung zum AP fehlgeschlagen\n\n");
      ESP.restart();
    }
  }
  DEBUG_P("\nVerbunden mit: " + WiFi.SSID());
  DEBUG_P("Esp32 IP: " + WiFi.localIP().toString() + "\n");
}

In diesem Tab einstellen ob LOW oder HIGH aktiv geschaltet wird.
Und die Anzahl der Schaltzeiten einstellen. (1 bis 100).

Dualschaltuhr.ino

// ****************************************************************
// Arduino IDE Tab Esp32 Zeitschaltuhr Dual
// created: Jens Fleischer, 2018-12-06
// last mod: Jens Fleischer, 2024-06-06
// For more information visit: https://fipsok.de
// ****************************************************************
// Hardware: Esp32, Relais Modul o. Mosfet IRF3708 o. Fotek SSR-40 DA
// für Relais Modul
// GND an GND
// IN1 an T4 = GPIO13
// IN2 an T5 = GPIO12
// VCC an VIN -> je nach verwendeten Esp.. möglich
// Jumper JD-VCC VCC
// alternativ ext. 5V Netzteil verwenden
//
// für Mosfet IRF3708
// Source an GND
// Mosfet1 Gate an T4 = GPIO13
// Mosfet2 Gate an T5 = GPIO12
//
// für 3V Solid State Relais
// GND an GND
// SSR1 Input + an T4 = GPIO13
// SSR2 Input + an T5 = GPIO12
//
// Software: Esp32 Arduino Core 3.0.0 - 3.0.1
// Getestet auf: ESP32 NodeMCU-32s
/******************************************************************
  Copyright (c) 2024 Jens Fleischer. All rights reserved.

  This file is free software; you can redistribute it and/or
  modify it under the terms of the GNU Lesser General Public
  License as published by the Free Software Foundation; either
  version 2.1 of the License, or (at your option) any later version.
  This file is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
  Lesser General Public License for more details.
*******************************************************************/
// Diese Version von Dualschaltuhr sollte als Tab eingebunden werden.
// #include <WebServer.h> & #include <WiFi.h> müssen im Haupttab aufgerufen werden
// Die Funktionalität des ESP32 Webservers ist erforderlich.
// Der Lokalzeit Tab ist zum ausführen der Zeitschaltuhr einzubinden.
// Die Funktion "setupTimerSwitch();" muss im Setup aufgerufen werden.
// Zum schalten muss die Funktion "dualTimerSwitch();" im loop(); aufgerufen werden.
/**************************************************************************************/

#include "shorttimer.h"
#include <tuple>
#include <vector>

constexpr auto ACTIVE = LOW;                                                       // LOW für LOW aktive Relais oder HIGH für HIGH aktive (zB. SSR, Mosfet) einstellen
constexpr uint8_t DEVICE_PIN[] = {T4, T5};                                         // Pin für Device einstellen
constexpr uint8_t RECORDS = 20;                                                    // Anzahl Schaltzeiten festlegen 1 - 100
bool pinState[] = {!ACTIVE, !ACTIVE};

using namespace std;
using Subset = tuple<bool, uint8_t, int16_t, int16_t>;
vector<Subset> device;

uint16_t sunTime[4];

struct {
  uint16_t sun;
  bool lock[2];
} option;

ShortTimer timer1, timer2;

void setupTimerSwitch() {
  for (const auto& pin : DEVICE_PIN) digitalWrite(pin, !ACTIVE), pinMode(pin, OUTPUT);
  device.reserve(RECORDS);
  Subset t = make_tuple(1, 127, -1, -1);
  File file = LittleFS.open(existFolder("/Config") + "/dtime.dat", "r");
  if (file) {                                                                      // Einlesen aller Daten falls die Datei im LittleFS vorhanden ist.
    file.read(reinterpret_cast<uint8_t*>(&option), sizeof(option));
    for (uint8_t i {0}; i < device.capacity(); i++) {
      file.read(reinterpret_cast<uint8_t*>(&t), sizeof(Subset));
      device.emplace_back(t);
    }
    file.close();
  }
  else {
    DEBUG_P(F("Die \"dtime.dat\" ist nicht vorhanden!"));
    for (uint8_t i {0}; i < device.capacity(); i++) device.push_back(t);
  }
  server.on("/timer", HTTP_POST, []() {
    if (server.hasArg("dTime")) {
      device.clear();
      Subset t = make_tuple(0, 0, 0, 0);
      char str[server.arg("dTime").length()];
      strcpy (str, server.arg("dTime").c_str());
      char* ptr = strtok(str, "[\"");
      for (auto i {0}; ptr != NULL; i++, i %= 4) {
        if (strcmp(ptr, ",")) {
          if (i == 0) {
            get<0>(t) = atoi(ptr);
          }
          else if (i == 1) {
            get<1>(t) = atoi(ptr);
          }
          else if (i == 2) {
            get<2>(t) = atoi(ptr);
          }
          else if (i == 3) {
            get<3>(t) = atoi(ptr);
            device.emplace_back(t);
          }
        }
        ptr = strtok(NULL, "\",[]");
      }
      printer();
    }
    String temp = "[";
    for (char buf[8]; auto &t : device) {
      temp == "[" ? temp += "[" : temp += ",[";
      temp += "\"" + static_cast<String>(get<0>(t)) + "\",";
      temp += "\"" + static_cast<String>(get<1>(t)) + "\",";
      snprintf(buf, sizeof(buf), "%02d:%02d", (get<2>(t) / 100 % 100), (get<2>(t) % 100));
      temp += "\"" + static_cast<String>(buf) + "\",";
      snprintf(buf, sizeof(buf), "%02d:%02d", (get<3>(t) / 100 % 100), (get<3>(t) % 100));
      temp += "\"" + static_cast<String>(buf) + "\"]";
    }
    temp += "]";
    server.send(200, "application/json", temp);
  });

  server.on("/timer", HTTP_GET, []() {
    char buf[60];
    if (!server.hasArg("time")) {
      if (server.hasArg("switch")) {
        if (server.arg("switch") == "0" ) {
          timer1.stop();
          pinState[Device_I] = !pinState[Device_I];                                // Pin Status manuell ändern
          timeDataLogger(Device_I, server.client().remoteIP().toString());         // Funktionsaufruf Zeitdatenlogger
        }
        else if (server.arg("switch") == "1" ) {
          timer2.stop();
          pinState[Device_II] = !pinState[Device_II];                              // Pin Status manuell ändern
          timeDataLogger(Device_II, server.client().remoteIP().toString());        // Funktionsaufruf Zeitdatenlogger
        }
      }
      else if (server.hasArg("lock")) {
        if (server.arg("lock") == "0" && !timer1.timeIsRun()) {
          option.lock[Device_I] = !option.lock[Device_I];                          // alle Schaltzeiten deaktivieren/aktivieren
        }
        else if (server.arg("lock") == "1" && !timer2.timeIsRun()) {
          option.lock[Device_II] = !option.lock[Device_II];                        // alle Schaltzeiten deaktivieren/aktivieren
        }
        printer();
      }
      else if (server.hasArg("run")) {
        if (server.arg("run") == "0") {
          if (server.hasArg("start")) {
            timer1.start(server.arg("start").toInt());
          }
          else if (server.hasArg("pause")) {
            timer1.pause();
          }
        }
        else {
          if (server.hasArg("start")) {
            timer2.start(server.arg("start").toInt());
          }
          if (server.hasArg("pause")) {
            timer2.pause();
          }
        }
      }
      else if (server.hasArg("sun")) {
        if (server.hasArg("select")) {
          option.sun = server.arg("select").toInt();
          printer();
        }
        snprintf(buf, sizeof(buf), "[[\"%02d:%02d\",\"%02d:%02d\",\"%02d:%02d\",\"%02d:%02d\"],\"%d\"]",
                 sunTime[0] / 100 % 100, sunTime[0] % 100, sunTime[1] / 100 % 100, sunTime[1] % 100,
                 sunTime[2] / 100 % 100, sunTime[2] % 100, sunTime[3] / 100 % 100, sunTime[3] % 100, option.sun);
        return server.send(200, "application/json", buf);
      }
    }
    snprintf(buf, sizeof(buf), PSTR(R"(["%d","%d","%d","%d",["%02d:%02d","%02d:%02d","%d","%d"],"%s"])"),
             pinState[Device_I] == ACTIVE, pinState[Device_II] == ACTIVE, option.lock[Device_I], option.lock[Device_II],
             timer1.timeLeft() / 60, timer1.timeLeft() % 60, timer2.timeLeft() / 60, timer2.timeLeft() % 60, timer1.timeIsRun(), timer2.timeIsRun(), localTime());
    server.send(200, "application/json", buf);
  });
}

void printer() {
  //for (uint8_t i {0}; const auto &e : device) DEBUG_F(PSTR("Subset %d: %d, %d, %d, %d\n"), ++i, get<0>(e), get<1>(e), get<2>(e), get<3>(e));
  File file = LittleFS.open(existFolder("/Config") + "/dtime.dat", "w");
  if (file) {
    file.write(reinterpret_cast<const uint8_t*>(&option), sizeof(option));
    for (const auto &e : device) {
      file.write(reinterpret_cast<const uint8_t*>(&e), sizeof(e));
    }
    file.close();
  }
}

void dualTimerSwitch() {
  static uint8_t lastmin {CHAR_MAX}, lastState[] {!ACTIVE, !ACTIVE};
  hobbsMeter(pinState[Device_I], pinState[Device_II]);                             // Funktionsaufruf Betriebsstundenzähler mit Pin Status

  if (static bool timerFlag, statusFlag; timer1.timeIsRun()) {
    pinState[Device_I] = ACTIVE;
    timeDataLogger(Device_I, "Timer");
    if (!timerFlag) statusFlag = option.lock[Device_I];
    option.lock[Device_I] = true;                                                  // alle Schaltzeiten sperren
    timerFlag = true;
  }
  else {
    if (timerFlag) {
      pinState[Device_I] = !ACTIVE;
      timeDataLogger(Device_I, "Timer");
      option.lock[Device_I] = statusFlag;                                          // vorherigen Zustand wiederherstellen
    }
    timerFlag = false;
  }
  if (static bool timerFlag, statusFlag; timer2.timeIsRun()) {
    pinState[Device_II] = ACTIVE;
    timeDataLogger(Device_II, "Timer");
    if (!timerFlag) statusFlag = option.lock[Device_II];
    option.lock[Device_II] = true;                                                 // alle Schaltzeiten sperren
    timerFlag = true;
  }
  else {
    if (timerFlag) {
      pinState[Device_II] = !ACTIVE;
      timeDataLogger(Device_II, "Timer");
      option.lock[Device_II] = statusFlag;                                         // vorherigen Zustand wiederherstellen
    }
    timerFlag = false;
  }
  if (tm.tm_min != lastmin) {
    lastmin = tm.tm_min;
    const uint16_t currentTime = tm.tm_hour * 100 + tm.tm_min;
    for (uint8_t i{0}; auto &t : device) {
      if (get<bool>(t) && (get<1>(t) & (1 << (tm.tm_wday ? tm.tm_wday - 1 : 6)))) {
        if (i < (device.size() / 2) && !option.lock[Device_I]) {
          if (get<2>(t) == currentTime) pinState[Device_I] = ACTIVE;
          if (get<3>(t) == currentTime) pinState[Device_I] = !ACTIVE;
          if (pinState[Device_I] != lastState[Device_I]) timeDataLogger(Device_I, "Uhrzeit");
        }
        else if (i >= (device.size() / 2) && !option.lock[Device_II]) {
          if (get<2>(t) == currentTime) pinState[Device_II] = ACTIVE;
          if (get<3>(t) == currentTime) pinState[Device_II] = !ACTIVE;
          if (pinState[Device_II] != lastState[Device_II]) timeDataLogger(Device_II, "Uhrzeit");
        }
      }
      i++;
    }
    sunSwitch (currentTime);
  }

  if (pinState[Device_I] != lastState[Device_I] || pinState[Device_II] != lastState[Device_II]) {    // Relais schalten wenn sich der Status geändert hat
    for (auto i{0}; auto &state : pinState) {
      lastState[i] = state;
      digitalWrite(DEVICE_PIN[i], state);
      DEBUG_F("Relais %d %s\n", 1 + i, digitalRead(DEVICE_PIN[i]) == ACTIVE ? "an" : "aus");
      i++;
    }
  }
}

void sunSwitch (const uint16_t &cTime) {                                           // Pin Status zum Sonnenstand ändern
  bool laststate[] = {pinState[Device_I], pinState[Device_II]};
  for (uint8_t i{0}; auto &t : sunTime) {
    if (t == cTime && !option.lock[Device_I]) {
      if (option.sun & (1 << i * 2)) pinState[Device_I] = !ACTIVE;
      if (option.sun & (1 << (i * 2 + 1))) pinState[Device_I] = ACTIVE;
      if (pinState[Device_I] != laststate[0]) timeDataLogger(Device_I, "Sonnenstand");
    }
    if (t == cTime && !option.lock[Device_II]) {
      if (option.sun & (1 << (i * 2 + 8))) pinState[Device_II] = !ACTIVE;
      if (option.sun & (1 << (i * 2 + 9))) pinState[Device_II] = ACTIVE;
      if (pinState[Device_II] != laststate[1]) timeDataLogger(Device_II, "Sonnenstand");
    }
    i++;
  }
}

LittleFS.ino

// ****************************************************************
// Arduino IDE Tab Esp32 Filesystem Manager spezifisch sortiert
// created: Jens Fleischer, 2023-03-26
// last mod: Jens Fleischer, 2024-06-06
// For more information visit: https://fipsok.de
// ****************************************************************
// Hardware: Esp32
// Software: Esp32 Arduino Core 2.0.6 - 3.0.1
// Getestet auf: ESP32 NodeMCU-32s
/******************************************************************
  Copyright (c) 2024 Jens Fleischer. All rights reserved.

  This file is free software; you can redistribute it and/or
  modify it under the terms of the GNU Lesser General Public
  License as published by the Free Software Foundation; either
  version 2.1 of the License, or (at your option) any later version.
  This file is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
  Lesser General Public License for more details.
*******************************************************************/
// Diese Version von LittleFS sollte als Tab eingebunden werden.
// #include <LittleFS.h> #include <WebServer.h> müssen im Haupttab aufgerufen werden
// Die Funktionalität des ESP32 Webservers ist erforderlich.
// "server.onNotFound()" darf nicht im Setup des ESP32 Webserver stehen.
// Die Funktion "setupFS();" muss im Setup aufgerufen werden.
/**************************************************************************************/

#include <detail/RequestHandlersImpl.h>
#include <list>
#include <tuple>

const char WARNING[] PROGMEM = R"(<h2>LittleFS konnte nicht initialisiert werden!)";
const char HELPER[] PROGMEM = R"(<form method="POST" action="/upload" enctype="multipart/form-data">
<input type="file" name="[]" multiple><button>Upload</button></form>Lade die fs.html hoch.)";

void setupFS() {                                                                           // Funktionsaufruf "setupFS();" muss im Setup eingebunden werden
  LittleFS.begin(true);
  server.on("/format", formatFS);
  server.on("/upload", HTTP_POST, sendResponce, handleUpload);
  server.onNotFound([](String path = server.urlDecode(server.uri())) {
    if (!handleFile(path)) server.send(404, "text/html", ("Page Not Found: ") + path);
  });
  const char * headerkeys[] = {"If-None-Match"};                                           // "If-None-Match" HTTP-Anfrage-Header einfügen
  server.collectHeaders(headerkeys, static_cast<size_t>(1));                               // für ETag Unterstüzung: vor Core Version 3.x.x.
}

bool handleList() {                                                                        // Senden aller Daten an den Client
  File root = LittleFS.open("/");
  using namespace std;
  using records = tuple<String, String, size_t, time_t>;
  list<records> dirList;
  while (File f = root.openNextFile()) {                                                   // Ordner und Dateien zur Liste hinzufügen
    if (f.isDirectory()) {
      uint8_t ran {0};
      File fold = LittleFS.open(static_cast<String>("/") + f.name());
      while (File f = fold.openNextFile()) {
        ran++;
        dirList.emplace_back(fold.name(), f.name(), f.size(), f.getLastWrite());
      }
      if (!ran) dirList.emplace_back(fold.name(), "", 0, 0);
    }
    else {
      dirList.emplace_back("", f.name(), f.size(), f.getLastWrite());
    }
  }
  dirList.sort([](const records & f, const records & l) {                                  // Dateien sortieren
    if (server.arg(0) == "1") {                                                            // nach Größe
      return get<2>(f) > get<2>(l);
    } else if (server.arg(0) == "2") {                                                     // nach Zeit
      return get<3>(f) > get<3>(l);
    } else {                                                                               // nach Name
      for (uint8_t i = 0; i < 31; i++) {
        if (tolower(get<1>(f)[i]) < tolower(get<1>(l)[i])) return true;
        else if (tolower(get<1>(f)[i]) > tolower(get<1>(l)[i])) return false;
      }
      return false;
    }
  });
  dirList.sort([](const records & f, const records & l) {                                  // Ordner sortieren
    if (get<0>(f)[0] != 0x00 || get<0>(l)[0] != 0x00) {
      for (uint8_t i = 0; i < 31; i++) {
        if (tolower(get<0>(f)[i]) < tolower(get<0>(l)[i])) return true;
        else if (tolower(get<0>(f)[i]) > tolower(get<0>(l)[i])) return false;
      }
    }
    return false;
  });
  String temp = "[";
  for (auto& t : dirList) {
    if (temp != "[") temp += ',';
    temp += "{\"folder\":\"" + get<0>(t) + "\",\"name\":\"" + get<1>(t) + "\",\"size\":\"" + formatBytes(get<2>(t)) + "\",\"time\":\"" + get<3>(t) + "\"}";
  }
  temp += ",{\"usedBytes\":\"" + formatBytes(LittleFS.usedBytes()) +                       // Berechnet den verwendeten Speicherplatz
          "\",\"totalBytes\":\"" + formatBytes(LittleFS.totalBytes()) +                    // Zeigt die Größe des Speichers
          "\",\"freeBytes\":\"" + (LittleFS.totalBytes() - LittleFS.usedBytes()) + "\"}]"; // Berechnet den freien Speicherplatz
  server.send(200, "application/json", temp);
  return true;
}

void deleteFiles(const String &path) {
  DEBUG_F("delete: %s\n", path.c_str());
  if (!LittleFS.remove("/" + path)) {
    File root = LittleFS.open(path);
    while (String filename = root.getNextFileName()) {
      LittleFS.remove(filename);
      LittleFS.rmdir(path);
      if (filename.length() < 1) break;
    }
  }
}

bool handleFile(String &path) {
  if (!LittleFS.exists("/fs.html")) server.send(200, "text/html", LittleFS.begin(true) ? HELPER : WARNING);   // ermöglicht das hochladen der fs.html
  if (!(PROTECT && !authenticated())) {
    if (server.hasArg("new")) {
      String folderName {server.arg("new")};
      for (auto& c : {34, 37, 38, 47, 58, 59, 92}) for (auto& e : folderName) if (e == c) e = 95;   // Ersetzen der nicht erlaubten Zeichen
      DEBUG_F("Creating Dir: %s\n", folderName.c_str());
      LittleFS.mkdir("/" + folderName);
    }
    if (server.hasArg("sort")) return handleList();
    if (server.hasArg("delete")) {
      deleteFiles(server.arg("delete"));
      sendResponce();
      return true;
    }
    if (path.endsWith("/")) path += "index.html";
    File f = LittleFS.open(path);
    String eTag = String(f.getLastWrite(), HEX);                                             // Verwendet den Zeitstempel der Dateiänderung, um den ETag zu erstellen.
    if (server.header("If-None-Match") == eTag) {
      server.send(304);
      return true;
    }
    server.sendHeader("ETag", eTag);
    return LittleFS.exists(path) ? server.streamFile(f, StaticRequestHandler::getContentType(path)) : false;
  }
  return false;
}

void handleUpload() {                                                                      // Dateien ins Filesystem schreiben
  static File fsUploadFile;
  HTTPUpload& upload = server.upload();
  if (upload.status == UPLOAD_FILE_START) {
    if (upload.filename.length() > 31) {                                                   // Dateinamen kürzen
      upload.filename = upload.filename.substring(upload.filename.length() - 31, upload.filename.length());
    }
    DEBUG_F("handleFileUpload Name: /%s\n", upload.filename.c_str());
    fsUploadFile = LittleFS.open("/" + server.arg(0) + "/" + server.urlDecode(upload.filename), "w");
  } else if (upload.status == UPLOAD_FILE_WRITE) {
    DEBUG_F("handleFileUpload Data: %u\n", upload.currentSize);
    fsUploadFile.write(upload.buf, upload.currentSize);
  } else if (upload.status == UPLOAD_FILE_END) {
    DEBUG_F("handleFileUpload Size: %u\n", upload.totalSize);
    fsUploadFile.close();
  }
}

void formatFS() {                                                                          // Formatiert das Filesystem
  LittleFS.format();
  sendResponce();
}

void sendResponce() {
  server.sendHeader("Location", "fs.html");
  server.send(303, "message/http");
}

const String formatBytes(size_t const & bytes) {                                           // lesbare Anzeige der Speichergrößen
  return bytes < 1024 ? static_cast<String>(bytes) + " Byte" : bytes < 1048576 ? static_cast<String>(bytes / 1024.0) + " KB" : static_cast<String>(bytes / 1048576.0) + " MB";
}

String existFolder( String const& foldername) {
  if (!LittleFS.exists(foldername)) LittleFS.mkdir(foldername);
  return foldername;
}

bool authenticated() {
  if (server.authenticate(www_username, www_password)) return true;
  server.requestAuthentication(DIGEST_AUTH, "Anmeldung erforderlich", "Authentifizierung fehlgeschlagen!");
  return false;
}

Lokalzeit.ino

// ****************************************************************
// Arduino IDE Tab Esp32 Lokalzeit
// created: Jens Fleischer, 2024-03-02
// last mod: Jens Fleischer, 2024-03-06
// ****************************************************************
// Hardware: Esp32
// Software: Esp32 Arduino Core 2.0.6 - 3.0.1
// Getestet auf: ESP32 NodeMCU-32s
/******************************************************************
  Copyright (c) 2024 Jens Fleischer. All rights reserved.

  This file is free software; you can redistribute it and/or
  modify it under the terms of the GNU Lesser General Public
  License as published by the Free Software Foundation; either
  version 2.1 of the License, or (at your option) any later version.
  This file is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
  Lesser General Public License for more details.
*******************************************************************/
// Diese Version von Lokalzeit sollte als Tab eingebunden werden.
// #include <WebServer.h> oder #include <WiFi.h> muss im Haupttab aufgerufen werden
// Funktion "setupTime();" muss im setup() nach dem Verbindungsaufbau aufgerufen werden.
/**************************************************************************************/

#include "esp_sntp.h"

const char* const PROGMEM ntpServer[] = {"fritz.box", "de.pool.ntp.org", "at.pool.ntp.org", "ch.pool.ntp.org", "ptbtime1.ptb.de", "europe.pool.ntp.org"};
const char* const PROGMEM monthShortNames[] = {"Jan", "Feb", "Mrz", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"};

void setupTime() {
  sntp_set_time_sync_notification_cb([](struct timeval * t) {
    DEBUG_P("********* Zeitstempel vom NTP Server erhalten! *********");
  });
  configTzTime("CET-1CEST,M3.5.0/02,M10.5.0/03", ntpServer[1]);    // deinen NTP Server einstellen (von 0 - 5 aus obiger Liste)
}

char* localTime() {
  static char buf[10];                                             // je nach Format von "strftime" eventuell die Größe anpassen
  getLocalTime(&tm);
  strftime (buf, sizeof(buf), "%T", &tm);                          // http://www.cplusplus.com/reference/ctime/strftime/
  return buf;
}

shorttimer.h

// ****************************************************************
// Esp32 Klasse ShortTimer
// created: Jens Fleischer, 2024-01-18
// last mod: Jens Fleischer, 2024-01-18
// For more information visit: https://fipsok.de
// ****************************************************************
// Hardware: Esp32
// Software: Esp32 Arduino Core 3.0.0 - 3.0.1
// Getestet auf: ESP32 NodeMCU-32s
/******************************************************************
  Copyright (c) 2024 Jens Fleischer. All rights reserved.

  This file is free software; you can redistribute it and/or
  modify it under the terms of the GNU Lesser General Public
  License as published by the Free Software Foundation; either
  version 2.1 of the License, or (at your option) any later version.
  This file is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
  Lesser General Public License for more details.
*******************************************************************/

class ShortTimer {
  public:
    void start(uint16_t time_sec) {
      _duration = _ms(time_sec);
      _paused = false;
      if (_duration > 0) {
        _startTime = millis();
        _expired = false;
      }
    }

    void pause() {
      if (_paused) {
        start(_pausedTime);
      }
      else {
        _pausedTime = timeLeft();
        _paused = true;
      }
    }

    void stop() {
      _expired = true;
      _paused = false;
    }

    bool timeIsRun() {
      if (_paused) return false;
      bool result = (!_expired && !((millis() - _startTime) >= _duration));
      if (!result) _expired = true;
      return result;
    }

    uint16_t timeLeft() {
      if (_paused) return _pausedTime;
      if (!timeIsRun()) return 0;
      return _sec(_duration - (millis() - _startTime));
    }

  private:
    constexpr uint32_t _ms(uint16_t x) {
      return x * 1000;
    }
    constexpr uint16_t _sec(uint32_t x) {
      return x * 0.001;
    }
    uint32_t _duration{0};
    uint32_t _startTime{0};
    uint16_t _pausedTime{0};
    bool _expired{true};
    bool _paused{false};
};

In diesem Tab den Längen- und Breitengrad deines Standortes angeben.

Sonnenlauf.ino

// ****************************************************************
// Arduino IDE Tab Esp32 Sonnenlauf
// source: https://lexikon.astronomie.info/zeitgleichung/neu.html
// created: Jens Fleischer, 2018-12-29
// last mod: Jens Fleischer, 2024-06-06
// For more information visit: https://fipsok.de
// ****************************************************************
// Hardware: Esp32
// Software: Esp32 Arduino Core 2.0.6 - 3.0.1
// Getestet auf: ESP32 NodeMCU-32s
/******************************************************************
  Copyright (c) 2024 Jens Fleischer. All rights reserved.

  This file is free software; you can redistribute it and/or
  modify it under the terms of the GNU Lesser General Public
  License as published by the Free Software Foundation; either
  version 2.1 of the License, or (at your option) any later version.
  This file is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
  Lesser General Public License for more details.
*******************************************************************/
// Diese Version von Sonnenlauf sollte als Tab eingebunden werden.
// Der Lokalzeit Tab (bzw. "struct tm") ist zum ausführen erforderlich.
// Gib zunächst den Längen- und Breitengrad deines Ortes an.
// https://www.laengengrad-breitengrad.de/
/**************************************************************************************/

constexpr double LONGITUDE = 12.348239;  // Geographische Länge
constexpr double LATITUDE = 51.346030;   // Geographische Breite

void sunRun() {
  static uint8_t lastday, lastdst;
  if (tm.tm_mday != lastday || tm.tm_isdst != lastdst) {    // Sonnenlauf für Tage mit Zeitumstellung zweimal Täglich berechnen
    lastday = tm.tm_mday;
    lastdst = tm.tm_isdst;
    const double w = LATITUDE * DEG_TO_RAD;
    double JD = julianDate(1900 + tm.tm_year, 1 + tm.tm_mon, tm.tm_mday);
    double T = (JD - 2451545.0) / 36525.0;
    double DK;
    double EOT = calculateEOT(DK, T);
    double h = -0.833333333333333 * DEG_TO_RAD; // Sonenaufgang/Sonnenuntergang
    double differenceTime = 12.0 * acos((sin(h) - sin(w) * sin(DK)) / (cos(w) * cos(DK))) / PI;
    sunTime[1] = outputFormat((12.0 - differenceTime - EOT) - LONGITUDE / 15.0 + (_timezone * -1) / 3600 + tm.tm_isdst);
    sunTime[2] = outputFormat((12.0 + differenceTime - EOT) - LONGITUDE / 15.0 + (_timezone * -1) / 3600 + tm.tm_isdst);
    h = -6.0 * DEG_TO_RAD;                      // Bürgerliche Dämmerung
    differenceTime = 12.0 * acos((sin(h) - sin(w) * sin(DK)) / (cos(w) * cos(DK))) / PI;
    sunTime[0] = outputFormat((12.0 - differenceTime - EOT) - LONGITUDE / 15.0 + (_timezone * -1) / 3600 + tm.tm_isdst);
    sunTime[3] = outputFormat((12.0 + differenceTime - EOT) - LONGITUDE / 15.0 + (_timezone * -1) / 3600 + tm.tm_isdst);
  }
}

double julianDate (int y, int m, int d) {       // Gregorianischer Kalender
  if (m <= 2) {
    m = m + 12;
    y = y - 1;
  }
  int gregorian = (y / 400) - (y / 100) + (y / 4); // Gregorianischer Kalender
  return 2400000.5 + 365.0 * y - 679004.0 + gregorian + (30.6001 * (m + 1)) + d + 12.0 / 24.0;
}

double InPi(double x) {
  int n = x / TWO_PI;
  x = x - n * TWO_PI;
  if (x < 0) x += TWO_PI;
  return x;
}

double calculateEOT(double &DK, double T) {
  double RAm = 18.71506921 + 2400.0513369 * T + (2.5862e-5 - 1.72e-9 * T) * T * T;
  double M  = InPi(TWO_PI * (0.993133 + 99.997361 * T));
  double L  = InPi(TWO_PI * (  0.7859453 + M / TWO_PI + (6893.0 * sin(M) + 72.0 * sin(2.0 * M) + 6191.2 * T) / 1296.0e3));
  double e = DEG_TO_RAD * (23.43929111 + (-46.8150 * T - 0.00059 * T * T + 0.001813 * T * T * T) / 3600.0);    // Neigung der Erdachse
  double RA = atan(tan(L) * cos(e));
  if (RA < 0.0) RA += PI;
  if (L > PI) RA += PI;
  RA = 24.0 * RA / TWO_PI;
  DK = asin(sin(e) * sin(L));
  RAm = 24.0 * InPi(TWO_PI * RAm / 24.0) / TWO_PI;
  double dRA = RAm - RA;
  if (dRA < -12.0) dRA += 24.0;
  if (dRA > 12.0) dRA -= 24.0;
  dRA = dRA * 1.0027379;
  return dRA ;
}

uint16_t outputFormat(double sunTime) {
  if (sunTime < 0) sunTime += 24;
  else if (sunTime >= 24) sunTime -= 24;
  int8_t decimal = 60 * (sunTime - static_cast<int>(sunTime)) + 0.5;
  int8_t predecimal = sunTime;
  if (decimal >= 60) {
    decimal -= 60;
    predecimal++;
  }
  else if (decimal < 0) {
    decimal += 60;
    predecimal--;
    if (predecimal < 0) predecimal += 24;
  }
  return predecimal * 100 + decimal;
}

Stundenzaehler.ino

// ****************************************************************
// Arduino IDE Tab Esp32 Betriebsstundenzähler Dual
// created: Jens Fleischer, 2018-12-06
// last mod: Jens Fleischer, 2024-06-06
// For more information visit: https://fipsok.de
// ****************************************************************
// Hardware: Esp32
// Software: Esp32 Arduino Core 2.0.6 - 3.0.1
// Getestet auf: ESP32 NodeMCU-32s
/******************************************************************
  Copyright (c) 2024 Jens Fleischer. All rights reserved.

  This file is free software; you can redistribute it and/or
  modify it under the terms of the GNU Lesser General Public
  License as published by the Free Software Foundation; either
  version 2.1 of the License, or (at your option) any later version.
  This file is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
  Lesser General Public License for more details.
*******************************************************************/
// Diese Version von Betriebsstundenzähler sollte als Tab eingebunden werden.
// #include <LittleFS.h> #include <WebServer.h> müssen im Haupttab aufgerufen werden
// Die Funktionalität des ESP32 Webservers ist erforderlich.
// Der LittleFS Tab ist zum ausführen des Betriebsstundenzähler einzubinden.
// Die Funktion "setupHobbsMeter();" muss im Setup aufgerufen werden.
/**************************************************************************************/

uint16_t watt[2];
uint32_t totalmin[2];

void setupHobbsMeter() {
  File file = LittleFS.open(existFolder("/Config") + "/hobbs.json", "r");           // Betriebstunden(minuten) beim Neustart einlesen
  if (file) {
    char buf[file.size()];
    file.readBytes(buf, sizeof buf);
    totalmin[Device_I] = atoi(strtok(buf, "{\"runtimeOne:"));
    totalmin[Device_II] = atoi(strtok(NULL, "\",runtimeTwo:"));
    watt[Device_I] = atoi(strtok(NULL, "\",wattageOne:"));
    watt[Device_II] = atoi(strtok(NULL, "\",wattageTwo:"));
    file.close();
  }
  server.on("/hobbs", HTTP_GET, []() {
    uint32_t power[] {0, 0};
    if (server.hasArg("device")) {
      server.arg("device") == "0" ? watt[Device_I] = server.arg(1).toInt() : watt[Device_II] = server.arg(1).toInt();
      toSave();
    }
    if (server.hasArg("reset")) {
      server.arg(0) == "0" ? totalmin[Device_I] = 0 : totalmin[Device_II] = 0;     // Betriebsstundenzähler zurücksetzen
      toSave();
    }
    power[Device_I] = (watt[Device_I] * totalmin[Device_I]) / 6000;
    power[Device_II] = (watt[Device_II] * totalmin[Device_II]) / 6000;
    char buf[72];
    snprintf(buf, sizeof(buf), PSTR("[\"%ld,%ld\",\"%ld,%ld\",\"%d\",\"%d\",\"%ld,%ld\",\"%ld,%ld\"]"),
             totalmin[Device_I] / 60, totalmin[Device_I] / 6 % 10, totalmin[Device_II] / 60, totalmin[Device_II] / 6 % 10,
             watt[Device_I], watt[Device_II], power[Device_I] / 10, power[Device_I] % 10, power[Device_II] / 10, power[Device_II] % 10);
    server.send(200, "application/json", buf);
  });
}

void hobbsMeter(const bool &state_0, const bool &state_1) {                        // Aufrufen mit Pin Status
  static uint32_t lastmin[] {0, 0}, previousMillis[] {0, 0};
  uint32_t currentMillis {millis()};
  if (currentMillis - previousMillis[0] >= 6e4) {
    previousMillis[0] = currentMillis;
    if (state_0 == ACTIVE) totalmin[Device_I]++;                                   // Betriebstundenzähler Verbraucher 1 wird um eine Minute erhöht
    if (state_1 == ACTIVE) totalmin[Device_II]++;                                  // Betriebstundenzähler Verbraucher 2 wird um eine Minute erhöht
  }
  if (currentMillis - previousMillis[1] >= 864e5 && (totalmin[Device_I] != lastmin[Device_I] || totalmin[Device_II] != lastmin[Device_II])) {   // einmal am Tage Betriebsstunden in Datei schreiben wenn sich der Wert geändert hat
    previousMillis[1] = currentMillis;
    lastmin[Device_I] = totalmin[Device_I];
    lastmin[Device_II] = totalmin[Device_II];
    toSave();
  }
}

void toSave() {
  File file = LittleFS.open(existFolder("/Config") + "/hobbs.json", "w");           // Betriebstunden(minuten) speichern
  if (file) {
    file.printf(R"({"runtimeOne":"%lu","runtimeTwo":"%lu","wattageOne":"%d","wattageTwo":"%d"})", totalmin[Device_I], totalmin[Device_II], watt[Device_I], watt[Device_II]);
    file.close();
  }
}

Zeitlogger.ino

// ****************************************************************
// Arduino IDE Tab Esp32 Zeitdatenlogger Dual
// created: Jens Fleischer, 2024-06-06
// last mod: Jens Fleischer, 2024-06-06
// For more information visit: https://fipsok.de
// ****************************************************************
// Hardware: Esp32
// Software: Esp32 Arduino Core 3.0.0 - 3.0.1
// Getestet auf: ESP32 NodeMCU-32s
/******************************************************************
  Copyright (c) 2024 Jens Fleischer. All rights reserved.

  This file is free software; you can redistribute it and/or
  modify it under the terms of the GNU Lesser General Public
  License as published by the Free Software Foundation; either
  version 2.1 of the License, or (at your option) any later version.
  This file is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
  Lesser General Public License for more details.
*******************************************************************/
// Diese Version von Ereignisdatenspeicher sollte als Tab eingebunden werden.
// #include <LittleFS.h> muss im Haupttab aufgerufen werden
// Der Lokalzeit Tab ist zum ausführen des Zeitdatenlogger erforderlich.
// Der LittleFS Tab ist zum ausführen erforderlich.
/**************************************************************************************/

void timeDataLogger(const uint8_t num, const String customer) {                  // Relais Nummer und Ereignisauslöser
  static bool lastrelState[] {!ACTIVE, !ACTIVE};
  if (pinState[num] != lastrelState[num]) {                                      // Prüft ob sich der Relais Status geändert hat.
    char fileName[23];
    snprintf(fileName, sizeof(fileName), "/Relais%d_%s.csv", 1 + num, monthShortNames[tm.tm_mon]);
    if (!LittleFS.exists(existFolder("/Data") + fileName)) {                      // Logdatei für den aktuellen Monat anlegen falls nicht vorhanden.
      File file = LittleFS.open(existFolder("/Data") + fileName, "a");
      if (file) {                                                                // Prüft ob die Datei geöffnet wurde.
        file.printf("%s;An;Initiator;Aus;Initiator\n", monthShortNames[tm.tm_mon]);  // Kopfzeile schreiben
      }
      file.close();
      char path[23];
      snprintf(path, sizeof(path), "/Relais%d_%s.csv", 1 + num, monthShortNames[(tm.tm_mon + 1) % 12]);
      LittleFS.remove(path);                                                     // Löscht die elf Monate alte Logdatei.
    }
    File file = LittleFS.open(existFolder("/Data") + fileName, "a");              // Die Ereignisdaten für den aktuellen Monat speichern
    if (file) {
      pinState[num] == ACTIVE ? file.printf("%d.;%s;%s;", tm.tm_mday, localTime(), customer.c_str()) : file.printf("%s;%s;\n", localTime(), customer.c_str());
    }
    file.close();
  }
  lastrelState[num] = pinState[num];                                             // Ersetzt den letzten Status durch den aktuellen Relais Status.
}

admin.html

<!DOCTYPE HTML> <!-- For more information visit: https://fipsok.de -->
<html lang="de">
  <head>
	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1">
	<link rel="stylesheet" href="style32.css">
	<title>ESP32 Admin</title>
	<SCRIPT>
	  window.addEventListener('load', () => {
		renew(), once();
		document.querySelector('#fs').addEventListener('click', () => {
		  window.location = '/fs.html';
		});
		document.querySelector('#home').addEventListener('click', () => {
		  window.location = '/';
		});
		document.querySelector('#restart').addEventListener('click', () => {
		  if (confirm('Bist du sicher!')) re('restart');
		});
		document.querySelector('#reconnect').addEventListener('click', re.bind(this, 'reconnect'));
		document.querySelector('#hostbutton').addEventListener('click', check.bind(this, document.querySelector('input')));
		var output = document.querySelector('#note');
		async function once(arg1, arg2) {
		  try {
			let resp = await fetch('/admin/once', { method: 'POST', body: arg1});
			let obj = await resp.json();
			output.innerHTML = '';
			output.classList.remove('note');
			document.querySelector('form').reset();
			if (arg1 == undefined) wait = window.setInterval(renew, 1000);
			if (arg2 == 'reconnect') re(arg2);
			document.querySelector('#file').innerHTML = obj['File'];
			document.querySelector('#build').innerHTML = obj['Build'];
			document.querySelector('#sketchsize').innerHTML = obj['SketchSize'];
			document.querySelector('#sketchspace').innerHTML = obj['SketchSpace'];
			document.querySelector('#local').innerHTML = obj['LocalIP'];
			document.querySelector('#host').innerHTML = obj['Hostname'];
			document.querySelector('#ssid').innerHTML = obj['SSID'];
			document.querySelector('#gateway').innerHTML = obj['GatewayIP'];
			document.querySelector('#kanal').innerHTML = obj['Channel'];
			document.querySelector('#mac').innerHTML = obj['MacAddress'];
			document.querySelector('#subnet').innerHTML = obj['SubnetMask'];
			document.querySelector('#bss').innerHTML = obj['BSSID'];
			document.querySelector('#client').innerHTML = obj['ClientIP'];
			document.querySelector('#dns').innerHTML = obj['DnsIP'];
			document.querySelector('#chip').innerHTML = obj['ChipModel'];
			document.querySelector('#reset1').innerHTML = obj['Reset1'];
			document.querySelector('#reset2').innerHTML = obj['Reset2'];
			document.querySelector('#cpufreq').innerHTML = obj['CpuFreqMHz'] + ' MHz';
			document.querySelector('#heapsize').innerHTML = obj['HeapSize'];
			document.querySelector('#freeheap').innerHTML = obj['FreeHeap'];
			document.querySelector('#minfreeheap').innerHTML = obj['MinFreeHeap'];
			document.querySelector('#csize').innerHTML = obj['ChipSize'];
			document.querySelector('#cspeed').innerHTML = obj['ChipSpeed'] + ' MHz';
			document.querySelector('#cmode').innerHTML = obj['ChipMode'];
			document.querySelector('#cpp').innerHTML = obj['C++Version'];
			document.querySelector('#ide').innerHTML = obj['IdeVersion'].replace(/(\d)(\d)(\d)(\d)/,obj['IdeVersion'][3]!=0 ? '$1.$3.$4' : '$1.$3.');
			document.querySelector('#core').innerHTML = obj['CoreVersion'];
			document.querySelector('#sdk').innerHTML = obj['SdkVersion'];
		  } catch(err) {
			re();
		  }
		}
		async function renew() {
		  const resp = await fetch('admin/renew');
		  const array = await resp.json();
		  document.querySelector('#runtime').innerHTML = array[0];
		  document.querySelector('#temp').innerHTML = array[1] + ' °C';
		  document.querySelector('#rssi').innerHTML = array[2] + ' dBm';
		}
		function check(inObj) {
		  !inObj.checkValidity() ? (output.innerHTML = inObj.validationMessage, output.classList.add('note')) : (once(inObj.value, 'reconnect'));
		}
		function re(arg) {
		  window.clearInterval(wait);
		  fetch(arg);
		  output.classList.add('note');
		  if (arg == 'restart') {
			output.innerHTML = 'Der Server wird neu gestartet. Die Daten werden in 10 Sekunden neu geladen.';
			setTimeout(once, 10000);
		  } else if (arg == 'reconnect') {
			output.innerHTML = 'Die WiFi Verbindung wird neu gestartet. Daten werden in 5 Sekunden neu geladen.';
			setTimeout(once, 5000);
		  } else {
			output.innerHTML = 'Es ist ein Verbindungfehler aufgetreten. Es wird versucht neu zu verbinden.';
			setTimeout(once, 2000);
		  }
		}
	  });
	</SCRIPT>
  </head>
  <body>
	<h1>ESP32 Admin Page</h1>
	<main>
	  <aside id="left">
		<span>Runtime ESP:</span>
		<span>WiFi RSSI:</span>
		<span>CPU Temperatur:</span>
		<span>Sketch Name:</span>
		<span>Sketch Build:</span>
		<span>Sketch Size:</span>
		<span>Free Sketch Space:</span>
		<span>IP Address:</span>
		<span>Hostname:</span>
		<span>Connected to:</span>
		<span>Gateway IP:</span>
		<span>Channel:</span>
		<span>Mac Address:</span>
		<span>Subnet Mask:</span>
		<span>BSSID:</span>
		<span>Client IP:</span>
		<span>Dns IP:</span>
		<span>Chip Model:</span>
		<span>Reset CPU 1:</span>
		<span>Reset CPU 2:</span>
		<span>CPU Freq:</span>
		<span>Heap Size:</span>
		<span>Free Heap:</span>
		<span>Min Free Heap:</span>
		<span>Flash Size:</span>
		<span>Flash Speed:</span>
		<span>Flash Mode:</span>
		<span>C++ Version:</span>
		<span>Arduino IDE Version:</span>
		<span>Esp Core Version:</span>
		<span>SDK Version:</span>
	  </aside>
	  <aside>
		<span id="runtime">0</span>
		<span id="rssi">0</span>
		<span id="temp">0</span>
		<span id="file">?</span>
		<span id="build">0</span>
		<span id="sketchsize">0</span>
		<span id="sketchspace">0</span>
		<span id="local">0</span>
		<span id="host">?</span>
		<span id="ssid">?</span>
		<span id="gateway">0</span>
		<span id="kanal">0</span>
		<span id="mac">0</span>
		<span id="subnet">0</span>
		<span id="bss">0</span>
		<span id="client">0</span>
		<span id="dns">0</span>
		<span id="chip">?</span>
		<span id="reset1">0</span>
		<span id="reset2">0</span>
		<span id="cpufreq">0</span>
		<span id="heapsize">0</span>
		<span id="freeheap">0</span>
		<span id="minfreeheap">0</span>
		<span id="csize">0</span>
		<span id="cspeed">0</span>
		<span id="cmode">0</span>
		<span id="cpp">0</span>
		<span id="ide">0</span>
		<span id="core">0</span>
		<span id="sdk">0</span>
	  </aside>
	</main>
	<div>
	  <button class="button" id="fs">Filesystem</button>
	  <button class="button" id="home">Startseite</button>
	</div>
	<div id="note"></div>
	<div>
	  <form><input placeholder=" neuer Hostname" pattern="([A-Za-z0-9\-]{1,32})" title="Es dürfen nur Buchstaben (a-z, A-Z), Ziffern (0-9) und Bindestriche (-) enthalten sein. Maximal 32 Zeichen" required>
		<button class="button" type="button" id="hostbutton">Name Senden</button>
	  </form>
	</div>
	<div>
	  <button class="button" id="reconnect">WiFi Reconnect</button>
	  <button class="button" id="restart">ESP Restart</button>
	</div>
  </body>
</html>
fs.html

<!DOCTYPE HTML> <!-- For more information visit: https://fipsok.de -->
<html lang="de">
  <head>
	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1">
	<link rel="stylesheet" href="style32.css">
	<title>Filesystem Manager</title>
	<script>
	  document.addEventListener('DOMContentLoaded', () => {
		list(JSON.parse(localStorage.getItem('sortBy')));
		btn.addEventListener('click', () => {
		  if (!confirm(`Wirklich formatieren? Alle Daten gehen verloren.\nDu musst anschließend fs.html wieder laden.`)) event.preventDefault();
		});
	  });
	  async function list(to){
		let resp = await fetch(`?sort=${to}`);
		let json = await resp.json();
		let myList = document.querySelector('main'), noted = '';
		myList.innerHTML = '<nav><input type="radio" id="/" name="group" checked="checked"><label for="/"> &#128193;</label><span id="cr">+&#128193;</nav></span><span id="si"></span>';
		for (var i = 0; i < json.length - 1; i++) {
		  let dir = '', f = json[i].folder, n = json[i].name, t = new Date(json[i].time*1000).toLocaleString();
		  if (f != noted) {
			noted = f;
			dir = `<nav><input type="radio" id="${f}" name="group"><label for="${f}"></label> &#128193; ${f} <a href="?delete=/${f}">&#x1f5d1;&#xfe0f;</a></nav>`;
		  }
		  if (n != '') dir += `<li><a title="Geändert: ${t}" href="${f}/${n}">${n}</a><small> ${json[i].size}</small><a href="${f}/${n}"download="${n}"> Download</a> or<a href="?delete=${f}/${n}"> Delete</a>`;
		  myList.insertAdjacentHTML('beforeend', dir);
		}
		myList.insertAdjacentHTML('beforeend', `<li><b id="so">&#9660; ${to ? to > 1 ? 'Time' : 'Size' : 'A - Z'}</b> LittleFS belegt ${json[i].usedBytes.replace(".00", "")} von ${json[i].totalBytes.replace(".00", "")}`);
		var free = json[i].freeBytes;
		cr.addEventListener('click', () => {
		  document.getElementById('no').classList.toggle('no');
		});
		so.addEventListener('click', () => {
		  list(to=++to%3);
		  localStorage.setItem('sortBy', JSON.stringify(to));
		});
		document.addEventListener('change', (e) => {
		  if (e.target.id == 'fs') {
			for (var bytes = 0, i = 0; i < event.target.files.length; i++) bytes += event.target.files[i].size;
			for (var output = `${bytes} Byte`, i = 0, circa = bytes / 1024; circa > 1; circa /= 1024) output = circa.toFixed(2) + [' KB', ' MB', ' GB'][i++];
			if (bytes > free) {
			  si.innerHTML = `<li><b> ${output}</b><strong> Ungenügend Speicher frei</strong></li>`;
			  up.setAttribute('disabled', 'disabled');
			} 
			else {
			  si.innerHTML = `<li><b>Dateigröße:</b> ${output}</li>`;
			  up.removeAttribute('disabled');
			}
		  }
		  document.querySelectorAll(`input[type=radio]`).forEach(el => { if (el.checked) document.querySelector('form').setAttribute('action', '/upload?f=' + el.id)});
		});
		document.querySelectorAll('[href^="?delete=/"]').forEach(node => {
		  node.addEventListener('click', () => {
			if (!confirm('Sicher!')) event.preventDefault();
		  });
		});
	  }
	</script>
  </head>
  <body>
	<h2>ESP32 Filesystem Manager</h2>
	<form method="post" enctype="multipart/form-data">
	  <input id="fs" type="file" name="up[]" multiple>
	  <button id="up" disabled>Upload</button>
	</form>
	<form id="no" class="no" method="POST">
	  <input name="new" placeholder="Ordner Name" pattern="[^\x22/%&\\:;]{1,31}" title="Zeichen &#8220; % & / : ; \  sind nicht erlaubt." required="">
	  <button>Create</button>
	</form>
	<main></main>
	<form action="/format" method="POST">
	  <button id="btn">Format LittleFS</button>
	</form>
  </body>
</html>

Die Webseite zur Dual Zeitschaltuhr.

index.html

<!DOCTYPE HTML> <!-- For more information visit: https://fipsok.de -->
<html lang="de">
  <head>
	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1">
	<link rel="stylesheet" href="style32.css">
	<title>Dual Timer</title>
	<style>
	  main {
		background: linear-gradient(to right, #485d68 ,#333 5%, #333 95%, #485d68);
		padding:1em 1.5em;
		border-radius: 2em;
	  }
	  button, input, header, footer, [data-power], [data-sun], [data-timer]{
		background-color: #333 !important;
	  }
	  main, button, input:checked+label {
		color: #15dfdf;
	  }
	  button, header div>*, [data-on], [data-run], span[title] {
		cursor: pointer;
	  }
	  time, .timer, .sun{
		font-size: 1.5em;
		padding: 0;
	  }
	  input, section>div:not([data-box]), [data-run], [title] {
		border: solid #555;
	  }
	  header {
		position: sticky;
		top: 0;
		z-index: 1;
		padding-bottom: .4em;
	  }
	  header div {
		justify-content: space-between;
	  }
	  div:not([data-box], section) {
		display: flex;
		align-items: center;
	  }
	  div+[type="checkbox"] {
		margin-left: 1em;
	  }
	  div+span {
		display: flex;
		justify-content: space-evenly;
		margin-left: 1.7em;
	  }
	  span {
		padding: 0.5em;
	  }
	  svg {
		width: 3em;
	  }
	  time {
		font-weight: bold;
	  }
	  [type=time] {
		  min-width: 37%;
	  }
	  input {
		height: auto;
		width: auto;
		font-size: 3em;
		font-weight: bold;
		color: inherit;
		padding: 0;
	  }
	  label {
		font-style: italic;
		color: #777;
	  }
	  button {
		border: outset #999;
		width: 30%;
		margin: 0;
		box-shadow: none;
		border-radius: 2em;
	  }
	  footer button {
		width: 49%;
		margin-left: .2em;
	  }
	  [data-on] {
		width: 2em;
	  }
	  #tip {
		position: sticky;
		top: 50%;
		z-index: 3;
		height: 0;
		width: 20em;
	  }
	  [data-sun]{
		align-items: flex-end !important;
	  }
	  [data-power], [data-timer], [data-sun] {
		position: sticky;
		flex-direction: column;
		justify-content: center;
		top: 6.2em;
		height: 9em;
		margin-bottom: .3em;
		z-index: 1;
	  }
	  [data-timer] {
		z-index: 2;
	  }
	  [data-power] input {
		font-size: 1em;
		width: 2.6em;
		text-align: end;
	  }
	  [data-timer] input {
		width: 1.5em;
		text-align: center;
		font-size: 2em;  
	  }
	  output {
		cursor: default;
		border: solid #f00;
		margin: .5em;
		padding: .3em;
	  }
	  footer {
		position: sticky;
		bottom: .1em;
		padding: .7em 0 .7em 0;
	  }
	  .dark, .dim {
		opacity: .5;
	  }
	  .edit, .noedit, .reset {
		justify-content: center;
		width: 100%;
		background-color: red;
		color: #fff;
		padding: 1em;
		border-radius: .5em;
	  }
	  .edit:after {
		content: 'Eingabe gespeichert';  
	  }
	  .noedit:after {
		content: 'Bitte, halte dich an das Format!';
	  }
	  .reset:after {
		content: 'Zähler gelöscht';
	  }
	  .greyed, .out{
		color: #666 !important;
	  }
	  .none {
		display: none !important;
	  }
	  @media only screen and (max-width: 600px) {
		input {
		  font-size: 2.2em;
		}
	  }
	</style>
	<script> "use strict";
	  const $ = document.querySelector.bind(document);
	  const $$ = document.querySelectorAll.bind(document);
	  document.addEventListener('DOMContentLoaded', async () => {
		let resp = await fetch('/timer', {method: 'post'});
		let array = await resp.json();
		let buf = '<div data-timer="1" class="none"></div><div data-sun="1" class="none"></div><div data-power="1" class="none"></div>';
		for (let i = 0; i < array.length; i++) {
		  buf += '<div data-box><div><span data-on>ON</span><input type="time">\u00A0--\u00A0<input type="time"></div><span>';
		  ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'].forEach(d => {buf += `<input type="checkbox" checked><label>${d}</label>`;});
		  buf += '</span></div>';
		  if (i == parseInt(array.length/2-1)) {
			$('[data-index="1"]').insertAdjacentHTML('beforeend', buf);
			buf = '<div data-timer="2" class="none"></div><div data-sun="2" class="none"></div><div data-power="2" class="none"></div>';
		  }
		  if (i == array.length-1) $('[data-index="2"]').insertAdjacentHTML('beforeend', buf);
		}
		buf = '';
		['Morgendämmerung', 'Sonnenaufgang', 'Sonnenuntergang', 'Abenddämmerung'].forEach(s => {
		  buf += `<span>${s} <span></span><input type="checkbox"><label>Aus</label><input type="checkbox"><label>Ein</label></span>`;
		});
		$$('[data-sun]').forEach(el => {el.insertAdjacentHTML('beforeend', buf);});
		buf = '<span> Betriebsstunden <strong></strong></span><span>Verbrauch bei <input title="bis zu 4 Ziffern" pattern="[0-9]{1,4}"> Watt <output></output></span><span title="Reset">&#10060; Zähler zurücksetzen</span>';
		$$('[data-power]').forEach(el => {el.insertAdjacentHTML('beforeend', buf);});
		const box = '<input placeholder="00" pattern="[0-5]?[0-9]" title=" 0-59 ">'
		for (let i = 0; i <= 1; i++) {
		  buf = `<div><span>Min ${box}</span><span>${box} Sek</span></div><div><output></output><span data-run="run=${i}&start=">&#11208; Start</span><span data-run="run=${i}&pause=">&#9208; Pause</span></div>`;
		  $(`[data-timer="${i+1}"]`).insertAdjacentHTML('beforeend', buf);
		  buf = '<button>ON</button><svg viewBox="0 0 12 14"><polygon points="10.421,6.754 6.498,6.75 12.058,2.357 9.734,2.357 1.687,8.436 5.584,8.436 0,14.02"></polygon></svg>';
		  $('header div').insertAdjacentHTML('beforeend', buf);
		}
		fill(array);
		renew(), renew('sun='), setInterval(renew, 1000);
		$('main').classList.remove('none');
		$$('[data-timer="1"] input').forEach((el, i) => {let item = JSON.parse(localStorage.getItem('data-timer="1"')); !(i%2) ? el.value = parseInt(item/60) : el.value = item%60;});
		$$('[data-timer="2"] input').forEach((el, i) => {let item = JSON.parse(localStorage.getItem('data-timer="2"')); !(i%2) ? el.value = parseInt(item/60) : el.value = item%60;});
		let btn = $$('button');
        btn[0].addEventListener('click', renew.bind(this, 'switch=0'));
		btn[1].addEventListener('click', renew.bind(this, 'switch=1'));
		btn[2].addEventListener('click', show);
		btn[3].addEventListener('click', save);
		btn[4].addEventListener('click', renew.bind(this, 'lock='));
		$('header div').addEventListener('click',  () => location = '/fs.html');
		$$('[data-on]').forEach(el => {el.addEventListener('click', save)});
		$('.timer').addEventListener('click', () => $('section:not(.none) [data-timer]').classList.toggle('none'));
		$('.sun').addEventListener('click', () => $('section:not(.none) [data-sun]').classList.toggle('none'));
		$('time').addEventListener('click', () => {if (!$('section:not(.none) [data-power]').classList.toggle('none')) hours();});
		let node = $$('[data-sun] input');
		node.forEach((el, i) => {
		  el.addEventListener('change', (event) => {
			!(i%2) ? node[i+1].checked = false : node[i-1].checked = false;
			let x = 0;
			node.forEach((el, i) => {if (el.checked) x = x | (1 << i)});
			renew('sun=0&select=' + x);
			setStyle();
		  });
		});
		$$('[data-run]').forEach(el => {el.addEventListener('click', function f(){
			let x=0;
			$$(`[data-timer="${el.closest('[data-timer]').dataset.timer}"] [pattern]`).forEach((el, i) => {!el.checkValidity() ? out('noedit') : !(i%2) ? x=el.value*60 : x+=el.value*1;});
			localStorage.setItem(`data-timer="${el.closest('[data-timer]').dataset.timer}"`, JSON.stringify(x));
			renew(this.dataset.run+x);
		  });
		});
		$$('[title=Reset]').forEach((el, i) => {el.addEventListener('click', () => {if (confirm('Bist du sicher!')) hours(`reset=${i}`);});});
		$$('[data-power] input').forEach((el, i) => {el.addEventListener('blur', () => {
			!el.checkValidity() ? out('noedit') : hours(`device=${i}&watt=${parseInt(el.value)}`);
		  });
		});
		function show() {
		  this.innerText == 'Relais 1' ? btn[2].innerHTML = 'Relais 2' : btn[2].innerHTML = 'Relais 1';
		  $$('section').forEach(el => el.classList.toggle('none'));
		  setStyle();
		  btn[4].innerHTML = $('section:not(.none) [data-sun]').classList.contains('greyed') ? '&#10006; Auto inaktiv' : '&#9203; Auto aktiv';
		}
		async function renew(arg = 'time') {
		  if (event) event.stopPropagation();
		  if (arg == 'lock=') arg += $('section:not(.none)').dataset.index-1;
		  const resp = await fetch(`timer?${arg}`);
		  const array = await resp.json();
		  if (arg.startsWith('sun')) {
			resp.ok&&arg.startsWith('sun=0')&&out('edit');
			$$('[data-sun] span span').forEach((el, i) => {el.innerHTML = i<4 ? array[0][i] : array[0][i-4];});
			$$('[data-sun] input').forEach((el, i) => {array[1] & (1 << i) ? el.checked = true : el.checked = false});
		  }else {
			$$('polygon').forEach((el, i) => {el.style.fill = array[i] == 0 ? '#eee' : '#ff0'});
			btn[0].innerHTML = array[0]*1 ? '&#9995; R1 ON' : '&#9995; R1 OFF';
			btn[1].innerHTML = array[1]*1 ? '&#9995; R2 ON' : '&#9995; R2 OFF';
			btn[4].innerHTML = array[parseInt($('section:not(.none)').dataset.index)+1]*1 ? '&#10006; Auto inaktiv' : '&#9203; Auto aktiv';
			$$('[data-index="1"] div:not([data-timer="1"],[data-timer="1"] div,[data-power]),[data-index="1"] label').forEach(el => {array[2] == 0 ? el.classList.remove('greyed') : el.classList.add('greyed')});
			$$('[data-index="2"] div:not([data-timer="2"],[data-timer="2"] div,[data-power]),[data-index="2"] label').forEach(el => {array[3] == 0 ? el.classList.remove('greyed') : el.classList.add('greyed')});
			$$('[data-timer] output').forEach((el, i) => {i == 0 ? el.innerHTML = array[4][0] : el.innerHTML = array[4][1]});
			array[4][2] == '1' ? $('[data-timer="1"]').dataset.ison = '' : delete $('[data-timer="1"]').dataset.ison;
			array[4][3] == '1' ? $('[data-timer="2"]').dataset.ison = '' : delete $('[data-timer="2"]').dataset.ison;
			$('time').innerHTML = array[5]
		  }
		  setStyle();
		}
	  });
	  function setStyle(check = 0) {
		$$('section:not(.none) [data-sun] input').forEach(el => {if (el.checked) ++check;});
		check ? $('.sun').classList.remove('dark') : $('.sun').classList.add('dark');
		'ison' in $('section:not(.none) [data-timer]').dataset ? $('.timer').classList.remove('dark') : $('.timer').classList.add('dark');	
		$('section:not(.none) [data-sun]').classList.contains('greyed') ? $('.sun').classList.add('dim') : $('.sun').classList.remove('dim');
	  }
	  function out(arg) {
		let el = $('#info').classList;
		el.add(arg);
		setTimeout(() => {el.remove(arg);}, 4e3);
	  }
	  function fill(arr) {
		$$('[data-box]').forEach((e, i) => {
		  let c = e.querySelector('[data-on]');
		  c.textContent = (arr[i][0]*1 ? 'ON' : 'OFF');
		  e.querySelectorAll('[type=time]').forEach((el, j) => {el.value = arr[i][2+j]});
		  e.querySelectorAll('[type=checkbox]').forEach((el, j) => {arr[i][1] & (1 << j) ? el.checked = true : el.checked = false});
		  e.querySelectorAll('div, label').forEach(el => {c.textContent == 'ON' ? el.classList.remove('out') : el.classList.add('out')});
		});
	  }
	  async function save() {
		if (this.type !== 'submit') this.textContent == 'ON' ? this.textContent = 'OFF' : this.textContent = 'ON';
		let form = new FormData(), data = [];
		$$('[data-box]').forEach(e => {
		  let x = 0, arr = [e.querySelector('[data-on]').textContent == 'ON' ? '1' : '0'];
		  e.querySelectorAll('[type=checkbox]').forEach((el, i) => {if (el.checked) x = x | (1 << i)});
		  arr.push(x.toString());
		  e.querySelectorAll('[type=time]').forEach(el => {arr.push(el.value != 0 ? el.value.replace(':','') : -1)});
		  data.push(arr);
		});
		form.append('dTime', JSON.stringify(data));	  
		const resp = await fetch('/timer', {method: 'post', body: form});
		resp.ok&&out('edit');
		const json = await resp.json();
		fill(json);
	  }
	  async function hours(arg = '') {
		const resp = await fetch(`hobbs?${arg}`);
		resp.ok&&arg.startsWith('reset')&&out('reset');
		resp.ok&&arg.startsWith('device')&&out('edit');
		const json = await resp.json();
		$$('strong').forEach((el, i) => {el.innerHTML = json[i] + ' h'});
		$$('[data-power] input').forEach((el, i) => {el.value = json[i+2]});
		$$('[data-power] output').forEach((el, i) => {el.innerHTML = json[i+4] + ' kWh'});
	  }
	</script>
  </head>
  <body>
	<main class="none">
	  <header>
		<div></div>
		<div>
		  <button>Relais 1</button>
		  <span class="timer dark">&#9202;&#65039;</span>
		  <span class="sun dark">&#9728;&#65039;</span>	
		  <time>00:00:00</time>		  
		</div>
	  </header>
	  <div id="tip"><div id="info"></div></div>
	  <section data-index="1"></section>
	  <section data-index="2" class="none"></section>
	  <footer><button>&#9200;Zeiten Speichern</button><button></button></footer>
	</main>
  </body>
</html>
style32.css

/*For more information visit: https://fipsok.de*/
body {
	font-family: sans-serif;
	background-color: #a9a9a9;
	display: flex;
	flex-flow: column;
	align-items: center;
}
h1,h2 {
	color: #e1e1e1;
	text-shadow: 2px 2px 2px black;
}
li {
	background-color: #7cfc00;
	list-style-type: none;
	margin-bottom: 10px;
	padding-right: 5px;
	border-top: 3px solid #7cfc00;
	border-bottom: 3px solid #7cfc00;
	box-shadow: 5px 5px 5px #000000b3;
}
li a:first-child, li b {
	background-color: #ff4500;
	font-weight: bold;
	color: white;
	text-decoration: none;
	padding: 0 5px;
	border-top: 3.2px solid #ff4500;
	border-bottom: 3.6px solid #ff4500;
	text-shadow: 2px 2px 1px black;
	cursor:pointer;
}
li strong {
	color: red;
}
input {
	height: 35px;
	font-size: 13px;
}
input:invalid:focus{
	color:red;
}
h1+main {
	display: flex;
}
aside {
	display: flex;
	flex-direction: column;
	padding: 0.2em;
}
#left {
	align-items: flex-end;
	text-shadow: 1px 1px 2px #757474;
}
.note {
	background-color: salmon;
	padding: 0.5em;
	margin-top: 1em;
	text-align: center;
	max-width: 320px;
	border-radius: 0.5em;
}
nav {
	display: flex;
	align-items: baseline;
	justify-content: space-between;
}
.no {
	display: none;
}
#cr {
	font-weight: bold;
	cursor:pointer;
	font-size: 1.5em;
}
#up {
	width: auto; 
}
button {
	width: 130px;
	height: 40px;
	font-size: 16px;
	margin-top: 1em;
	cursor: pointer;
	box-shadow: 5px 5px 5px #000000b3;
}
div button {
	background-color: #adff2f;
}
form [title] {
	background-color: silver;
	font-size: 16px;
	width: 125px;
}
form:nth-of-type(2) {
	margin-bottom: 1em;
}
[name="group"] {
	display: none;
}
[name="group"] + label {
	font-size: 1.5em;
	margin-right: 5px;
}
[name="group"] + label::before {
	content: "\002610";
}	
[name="group"]:checked + label::before {
	content: '\002611\0027A5';
}
15 Kommentare - Kommentar eintragen
Andreas ✪ ✪ ✪ 28.07.2023
Hallo,

Erstmal Danke, die Anwendung ist sehr gut.

Ich bräuchte allerdings die Zeitschaltung noch mit Sekunden, eine Minute ist für meinen Fall etwas zu lange. Was müsste dafür alles geändert werden?

Gruß Andreas

Antwort:
Die "Dualschaltuhr.ino" und die Webseite.

Gruß Fips
Markus E. ✪ ✪ ✪ 26.08.2021
Hallo Fips,

erstmal Hut ab und alle Achtung. Ich betreibe das mit einer ESP01 und es lief bis jetzt super. (die Relaismodule, wo die ESP01 draufgesteckt wird). Leider bekomme ich diese Billigheimser scheinbar nicht mehr. Zumindest habe ich nach einigen Bestellungen immer die bekommen, die über die Serielle schnittstelle Schalten.
Nun bin ich da leider am verzweifeln, wie ich die einbauen muß.
Kannst du mit da helfen? Bitte.
In der Doku stand:

Im Setup-Teil:
Serial.begin(9600);

Zum Ausschalten:
byte open[] = {0xA0, 0x01, 0x00, 0xA1};
Serial.write(open, sizeof(open));

Zum Einschalten:
byte close[] = {0xA0, 0x01, 0x01, 0xA2};
Serial.write(close, sizeof(close));

Antwort:
Ein Esp-01 mit einem Esp32 Chip ist mir nicht bekannt.
Diese Zeitschaltuhr Kompiliert nicht mit einem Esp8266!

Gruß Fips
Fellix ✪ ✪ ✪ 14.07.2021
Hallo Fips,

echt gelungenes Projekt, Glückwünsche hier schonmal!

Ich konnte das ganze ziemlich fix einrichten, echt klasse.

Ich habe drei Fragen.

1. Wie lange hast du an dem ganzen gearbeitet?

2. Ich habe ein 8-Kanal-Relais und bin mir nicht ganz sicher, wie ich weitere 6 Relais korrekt unterbringe. Kannst du da weiterhelfen?

3. Mir ist noch nicht ganz klar, welche Pins ich auf die Relais setzen muss/welche geschaltet werden, das versuche ich aber gerade noch aus dem Code herauszubekommen.

Hier nochmal ein Großes Lob, echt klasse!

Antwort:
Das war 2018, das habe ich nicht mehr auf dem Schirm.
Javascript sollte dir geläufig sein.
Steht im Kommentar der Dualschaltuhr.ino, GPIO 12 und 13.

Gruß Fips
Reimund ❘ 04.10.2020
Bekomme immer die Meldung.


rom/rtc.h: No such file or directory

Antwort:
Mach dir am besten eine oder zwei Portable IDE.

https://fipsok.de/tipp

Gruß Fips

reimund ❘ 04.10.2020
Bekomme es nicht compaliert. Immer die Meldung.
rom/rtc.h fehlt

Antwort:
Das der Linker die Datei nicht findet liegt nicht am Sketch.

Die "rom/rtc.h" ist Bestandteil des Espressif SDK!
Es ist anzunehmen das deine Installation der Arduino IDE und/oder der Esp32 Erweiterung defekt ist.

Gruß Fips
Holger Binder ✪ ✪ ✪ 18.09.2020
Ich war der Meinung das der periodische Reset vom Zeitschaltprogramm ausgelöst wird? Der Reset ist also nicht normal...jetzt muss ich den Bug finden...
OK, ich suche. Danke.

Antwort:
Ich habe zwar momentan keinen Esp32 im Einsatz, aber letzte Weihnachtszeit hatte ich mit dem Sketch den Aussentannebaum bespaßt.
Und das Fehlerfrei, laut Admin.ino!

Gruß Fips
Holger Binder ✪ ✪ ✪ 18.09.2020
Bin gut zurecht gekommen mit Deiner Super Vorlage "Zeitschaltuhr32", auch das Login-Fenster für die Webseite wird nun aufgerufen. Habe eine Alarmanlage mit Matrix-Tastenpad und i2C Display erstellt. Zeiteinstellung der Scharfschaltung via Deinem Projekt... Super. Leider wechselt der ESP32 nach dem periodischen Reset ständig die IP Adresse.
Kann ich das irgendwie verhindern?

Anwort:
Ja, gib ihm eine Feste IP.
Warum leidet dein Esp32 an einem periodischen Reset?

Gruß Fips

André ✪ ✪ ✪ 23.08.2020
Hi Fips, nein das meinte ich nicht. Das context Menü das erscheint wenn ich rechts neben den Schaltzeiten auf das Bild mit der Uhr klicke. Da kann ich dann links die Stunden und rechts die Minuten auswählen. Vorbesetzt ist die aktuelle Zeit. Das Menü meine ich. Wo kommt das her?
Gruß André

Antwort:
Die Uhr gibt es im Firefox nicht, daher die Verwirrung.
Du meinst also den "Date Time Picker", der kommt in dem Fall vom Browser oder beim Mobile Device vom Betriebssystem.

Gruß Fips
André ✪ ✪ ✪ 22.08.2020
Hallo Fips, tolle Arbeit die du uns zur verfügung stellst! Ich schaue mir deinen Code an, um ihn zu verstehen und zu lernen. Ich kann aber die Stelle nicht finden, wo du die Sachen baust die angezeigt werden wenn auf das Uhrensymbol geklicht wird um die Schaltzeit zu setzen. Die funktion dom() baut es zusammen aber das Menü vermisse ich. Liebe Grüße.

Antwort:
Bin mir nicht sicher was du genau meinst!
Vermutlich die Anzeige "Schaltzeiten gespeichert".
Der Text kommt aus dem CSS und wird durch JavaScript "classList.add" eingeblendet.


Gruß Fips
Holger Binder ✪ ✪ ✪ 03.08.2020
Sehr gute Anwendung...

was muss getan werden, für den Zugriffsschutz?
Also ein vorheriges Login-Fenster zum Schutz der Schaltzeit-Parameter?

Antwort:
Schau dir das Beispiel "SimpleAuthentification" in der IDE an, eventuell kannst du es einarbeiten.

Gruß Fips
Kommentar eintragen

*