/************************************************************************************ * Dual-PID Controller Firmware (Zeitproportional) * Version (Siehe unten im Code) * * Dieses Programm steuert Wasser- und Dampftemperatur über zwei unabhängige PID-Regler * mit zeitproportionaler Ansteuerung der SSRs. * Sensoren: MAX6675 (Dampf), NTC (Wasser). * Funktionen: Eco-Modus, Profile, Fast-Heat-Up, WLAN-Weboberfläche, AutoTune, * Wartungserinnerung, Werkseinstellungen, Hostname-Konfiguration. * * Compiler- und Hardware-Umgebung: * - ESP8266: Wemos D1 mini, 4MB * - Bibliotheken: Wire, Adafruit_GFX, Adafruit_SH110X, EEPROM, PID_v1, PID_AutoTune_v0, * max6675, ESP8266WiFi, ESPAsyncTCP, ESPAsyncWebServer, ESP8266mDNS // Geändert! ************************************************************************************/ #include #include #include #include #include #include // Modifizierte AutoTune-Funktion - Muss als .zip eingebunden werden #include "max6675.h" #include // #include // Entfernt #include // Hinzugefügt #include // Hinzugefügt #include #include // Für isnan() #include // Dateisystem-Basis #include // LittleFS-Implementierung #include #include #include // Für Zeitfunktionen (struct tm, mktime, etc.) /************************************************************************************ * Hardware-Pinbelegung ************************************************************************************/ #define SSR_WASSER_PIN D2 // SSR für Wasser-Heizung #define SSR_DAMPF_PIN D1 // SSR für Dampf-Heizung #define SHOT_TIMER_PIN D8 // Input für Shot-Timer #define OLED_SDA D3 // SDA-Leitung für OLED #define OLED_SCK D4 // SCK-Leitung für OLED #define ANALOG_NTC_PIN A0 // Analoger Eingang für den NTC-Sensor // Display-Objekt (Adafruit SH1106G) Adafruit_SH1106G display = Adafruit_SH1106G(128, 64, &Wire); /************************************************************************************ * MAX6675 (Dampf-Temperatursensor) ************************************************************************************/ #define MAX6675_SCK D7 // Serial Clock #define MAX6675_CS D6 // Chip Select #define MAX6675_SO D5 // Serial Out MAX6675 thermocouple(MAX6675_SCK, MAX6675_CS, MAX6675_SO); /************************************************************************************ * Firmware-Informationen ************************************************************************************/ String version = "2.9.5-Async"; // Versionsnummer angepasst String versionHersteller = "Thomas Müller"; String versionHerstellerMail = "thomas@mueller.black"; String versionHerstellerWeb = "https://raw-designs.de/ | " "https://mueller.black/"; // Demo-Modus: Hier können bestimmte Funktionen (WLAN-Anpassungen etc.) eingeschränkt werden. // Entweder hier pauschal auf true setzen oder über IP-Adresse im Setup()-Teil auf true setzen lassen. bool demoModus = false; IPAddress demoModusIP(192, 168, 178, 67); // Dieses Kennwort muss in einer Firmware-Binärdatei vorhanden sein, damit ein Update gültig ist. static const char FIRMWARE_PASSWORD[] = "FWKennwort123"; static bool passwordFound = false; static int passMatchPos = 0; const int passLength = sizeof(FIRMWARE_PASSWORD) - 1; /************************************************************************************ * Geräte-Infos ************************************************************************************/ char infoHersteller[50]; char infoModell[50]; char infoZusatz[50]; /************************************************************************************ * NTC-Sensor-Parameter (Wasser) * - A0 wird verwendet, um die Temperatur über einen Spannungsteiler zu messen ************************************************************************************/ const double V_SUPPLY = 3.3; // Versorgungsspannung const double ADC_REF = 3.3; // ADC-Referenz const double R_FIXED = 3000.0; // Festwiderstand 3 kΩ // Daten des NTC const double T0 = 298.15; // Referenztemperatur in Kelvin (25°C) const double R0 = 50000.0; // NTC-Widerstand bei 25°C const double beta = 3950.0; // Beta-Wert /************************************************************************************ * Temperatur-Profil-Struktur * - Speichert Wasser- und Dampf-Setpoint, Eco-Einstellungen und Profilname ************************************************************************************/ struct TemperatureProfile { double setpointWasser; double setpointDampf; int ecoModeMinutes; bool dynamicEcoActive; int dampfVerzoegerung; char profileName[20]; }; /************************************************************************************ * PID-Regler-Konfiguration & Zeitproportionale Steuerung ************************************************************************************/ // PID Input/Output/Setpoint Variablen double SetpointDampf, InputDampf, OutputDampf; double SetpointWasser, InputWasser, OutputWasser; double OffsetDampf = 0.0, OffsetWasser = 0.0; // PID-Konstanten (Regelungsparameter) double KpDampf = 3.0, KiDampf = 0.1, KdDampf = 0.5; double KpWasser = 5.0, KiWasser = 0.2, KdWasser = 1.0; // Zeitproportionale Steuerung unsigned long windowSizeWasser; // Fenstergröße für Wasser-SSR (in ms) unsigned long windowSizeDampf; // Fenstergröße für Dampf-SSR (in ms) unsigned long windowStartTimeWasser; // Startzeit des aktuellen Fensters für Wasser unsigned long windowStartTimeDampf; // Startzeit des aktuellen Fensters für Dampf // PID-Regler Instanzen // WICHTIG: Die Output-Grenzen werden in setup() auf die Fenstergröße gesetzt! PID pidDampf(&InputDampf, &OutputDampf, &SetpointDampf, KpDampf, KiDampf, KdDampf, DIRECT); PID pidWasser(&InputWasser, &OutputWasser, &SetpointWasser, KpWasser, KiWasser, KdWasser, DIRECT); // Variablen für Sicherheitsabschaltung double maxTempWasser = 110.0; // Standardwert, wie bisher hardcoded double maxTempDampf = 180.0; // Standardwert, wie bisher hardcoded // --- NTP Client Konfiguration --- WiFiUDP ntpUDP; // Zeitverschiebung UTC+1 (Berlin/CET), automatische Sommerzeit (CEST) wird über TZ_INFO gehandhabt // Offset 0 hier, da TZ_INFO die Regeln enthält. Update-Intervall 60000ms = 1 Minute. NTPClient timeClient(ntpUDP, "pool.ntp.org", 0, 60000); bool timeSynced = false; // Zeitzonen-String für Deutschland (CET/CEST) const char* TZ_INFO = "CET-1CEST,M3.5.0,M10.5.0/3"; // Germany // --- Für Shot-Statistik --- struct ShotStats { unsigned long totalShotsLogged = 0; unsigned long totalDurationMs = 0; unsigned long shotsToday = 0; unsigned long shotsThisWeek = 0; unsigned long shotsThisMonth = 0; double averageDurationSec = 0.0; time_t firstShotTimestamp = 0; time_t lastShotTimestamp = 0; bool historyAvailable = false; // Flag, ob Daten gelesen werden konnten unsigned long shotsYesterday = 0; unsigned long shotsLastWeek = 0; // Kalenderwoche unsigned long shotsLastMonth = 0; // Kalendermonat double avgShotsPerDay = 0.0; // Durchschnitt seit erstem Log }; /************************************************************************************ * Standardwerte und Default-Einstellungen ************************************************************************************/ double defaultSetpointDampf = 165.0; double defaultSetpointWasser = 93.0; double defaultOffsetDampf = 0.0; double defaultOffsetWasser = 0.0; double defaultKpDampf = 3.0; double defaultKiDampf = 0.1; double defaultKdDampf = 0.5; double defaultKpWasser = 5.0; double defaultKiWasser = 0.2; double defaultKdWasser = 1.0; int defaultEcoModeMinutes = 0; int defaultEcoModeTempWasser = 60; int defaultEcoModeTempDampf = 60; String defaultInfoHersteller = ""; String defaultInfoModell = ""; String defaultInfoZusatz = ""; int defaultDampfVerzoegerung = 0; int dampfVerzoegerung = 0; int defaultMaintenanceInterval = 0; String defaultHostname = "Dual-PID"; unsigned long defaultWindowSizeWasser = 2000; unsigned long defaultWindowSizeDampf = 2000; /************************************************************************************ * Webserver und zugehörige Objekte ************************************************************************************/ // ESP8266WebServer server(80); // Webserver auf Port 80 // Entfernt AsyncWebServer server(80); // Hinzugefügt // MagicValue zum Erkennen bereits gespeicherter EEPROM-Daten const char storageMagicValue[5] = "MGVE"; /************************************************************************************ * EEPROM Adressen für das Speichern/Laden von Werten ************************************************************************************/ const int EEPROM_SIZE = 1024; // Gesamtgröße des verwendeten EEPROM-Speichers /************************************************************************************ * EEPROM Adressen für das Speichern/Laden von Werten ************************************************************************************/ const int EEPROM_ADDR_MAGICVALUE = 0; // 5 Bytes (char[5]) const int EEPROM_ADDR_SETPOINT_DAMPF = 5; // 8 Bytes (double) const int EEPROM_ADDR_SETPOINT_WASSER = 13; // 8 Bytes const int EEPROM_ADDR_OFFSET_DAMPF = 21; // 8 Bytes const int EEPROM_ADDR_OFFSET_WASSER = 29; // 8 Bytes const int EEPROM_ADDR_KP_DAMPF = 37; // 8 Bytes const int EEPROM_ADDR_KI_DAMPF = 45; // 8 Bytes const int EEPROM_ADDR_KD_DAMPF = 53; // 8 Bytes const int EEPROM_ADDR_KP_WASSER = 61; // 8 Bytes const int EEPROM_ADDR_KI_WASSER = 69; // 8 Bytes const int EEPROM_ADDR_KD_WASSER = 77; // 8 Bytes const int EEPROM_ADDR_ECOMODE_MINUTES = 85; // 4 Bytes (int) const int EEPROM_ADDR_ECOMODE_TEMP_WASSER = 89; // 4 Bytes const int EEPROM_ADDR_ECOMODE_TEMP_DAMPF = 93; // 4 Bytes const int EEPROM_ADDR_INFO_HERSTELLER = 97; // 50 Bytes (char[50]) const int EEPROM_ADDR_INFO_MODELL = 147; // 50 Bytes const int EEPROM_ADDR_INFO_ZUSATZ = 197; // 50 Bytes const int EEPROM_ADDR_FASTHEATUP_DATA = 247; // 1 Byte (bool) const int EEPROM_ADDR_RUNTIME = 248; // 4 Bytes (unsigned long) const int EEPROM_ADDR_SHOTCOUNTER = 252; // 4 Bytes (unsigned long) const int EEPROM_ADDR_DYNAMIC_ECO_MODE = 256; // 1 Byte (bool) const int EEPROM_ADDR_PROFILE_1 = 257; // 50 Bytes für Profil 1 (struct TemperatureProfile) const int EEPROM_ADDR_PROFILE_2 = 307; // 50 Bytes für Profil 2 (struct TemperatureProfile) // --- AutoTune Parameter Wasser --- const int EEPROM_ADDR_TUNING_STEP_WASSER = 357; // 8 Bytes (double) const int EEPROM_ADDR_TUNING_NOISE_WASSER = 365; // 8 Bytes (double) const int EEPROM_ADDR_TUNING_STARTVALUE_WASSER = 373; // 8 Bytes (double) const int EEPROM_ADDR_TUNING_LOOKBACK_WASSER = 381; // 4 Bytes (unsigned int) -> Ende 384 // --- WiFi Konfiguration --- const int EEPROM_ADDR_WIFI_CONFIG_MAGIC = 385; // 5 Bytes (char[5]) const int EEPROM_ADDR_WIFI_CONFIG_DATA = 390; // 113 Bytes (struct WiFiConfig) -> Ende ca. 503 // --- Sonstige Einstellungen --- const int EEPROM_ADDR_STEAM_DELAY = 527; // 4 Bytes (int) -> Ende 530 // --- AutoTune Parameter Dampf --- const int EEPROM_ADDR_TUNING_STEP_DAMPF = 531; // 8 Bytes (double) const int EEPROM_ADDR_TUNING_NOISE_DAMPF = 539; // 8 Bytes (double) const int EEPROM_ADDR_TUNING_STARTVALUE_DAMPF = 547; // 8 Bytes (double) const int EEPROM_ADDR_TUNING_LOOKBACK_DAMPF = 555; // 4 Bytes (unsigned int) -> Ende 558 // --- Adressen für Übertemperatursicherung / Sicherheitsabschaltung --- const int EEPROM_ADDR_MAX_TEMP_WASSER = 559; // 8 Bytes (double) -> Ende 566 const int EEPROM_ADDR_MAX_TEMP_DAMPF = 567; // 8 Bytes (double) -> Ende 574 // --- NEUE ADRESSEN --- const int EEPROM_ADDR_MAINTENANCE_INTERVAL = 575; // 4 Bytes (int) -> Ende 578 const int EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER = 579; // 4 Bytes (int) -> Ende 578 // Alte Adresse war 578, korrigiert zu 579 const int EEPROM_ADDR_HOSTNAME = 583; // 33 Bytes (char[33]) -> Ende 611 // --- Boost-Funktion const int EEPROM_ADDR_BOOST_WASSER_ACTIVE = 612; // 1 Byte (bool) -> Ende 612 const int EEPROM_ADDR_BOOST_DAMPF_ACTIVE = 613; // 1 Byte (bool) -> Ende 613 // --- Zeitfenster-Größen für Dampf und Wasser const int EEPROM_ADDR_WINDOWSIZE_WASSER = 614; // 4 Bytes (unsigned long) -> Ende 617 const int EEPROM_ADDR_WINDOWSIZE_DAMPF = 618; // 4 Bytes (unsigned long) -> Ende 621 // --- Heiz-Begrenzung oberhalb Setpoints const int EEPROM_ADDR_PREVENTHEAT_WASSER = 622; // 1 Byte (bool) -> Ende 622 const int EEPROM_ADDR_PREVENTHEAT_DAMPF = 623; // 1 Byte (bool) -> Ende 623 /************************************************************************************ * Eco-Mode Variablen ************************************************************************************/ unsigned long lastShotTime = 0; // Wann wurde zuletzt ein Shot beendet int ecoModeMinutes = 0; // Zeit (Minuten) bis Eco-Modus bool ecoModeAktiv = 0; // Flag, ob Eco läuft int ecoModeTempWasser = 50; // Zieltemp Wasser im Eco int ecoModeTempDampf = 50; // Zieltemp Dampf im Eco bool dynamicEcoActive = false; unsigned long ecoModeActivatedTime = 0; /************************************************************************************ * Wartungsmodus Variable ************************************************************************************/ bool wartungsModusAktiv = false; // Ist der Reinigungs-/Wartungsmodus aktiv? int wartungsModusTemp = 35; // Zieltemp Wasser und Dampf im Wartungsmodus /************************************************************************************ * Fast-Heat-Up Variablen ************************************************************************************/ bool fastHeatUpAktiv = 0; // Ist Fast-Heat-Up aktiviert? bool fastHeatUpHeating = 0; // Wird gerade hochgeheizt (Wasser >130)? int fastHeatUpSetpoint = 130; /************************************************************************************ * Sicherheits- und Fehlerstatus ************************************************************************************/ bool wasserSensorError = false; // Flag für Fehler am Wassertemperatursensor bool dampfSensorError = false; // Flag für Fehler am Dampftemperatursensor bool wasserSafetyShutdown = false; bool dampfSafetyShutdown = false; /************************************************************************************ * Auto-Tune-Einstellungen ************************************************************************************/ // Parameter für Wasser double tuningStepWasser; double tuningNoiseWasser; double tuningStartValueWasser; unsigned int tuningLookBackWasser; const double defaultTuningStepWasser = 500.0; const double defaultTuningNoiseWasser = 1.0; const double defaultTuningStartValueWasser = 1000.0; const unsigned int defaultTuningLookBackWasser = 60; // Parameter für Dampf double tuningStepDampf; double tuningNoiseDampf; double tuningStartValueDampf; unsigned int tuningLookBackDampf; const double defaultTuningStepDampf = 300.0; const double defaultTuningNoiseDampf = 1.0; const double defaultTuningStartValueDampf = 1000.0; const unsigned int defaultTuningLookBackDampf = 30; PID_ATune* autoTuneWasser; PID_ATune* autoTuneDampf; bool autoTuneWasserActive = false; bool autoTuneDampfActive = false; // Boost-Feature bool boostWasserActive = false; const bool defaultBoostWasserActive = false; bool boostDampfActive = false; const bool defaultBoostDampfActive = false; // Heizen oberhalb Setpoint verhindern bool preventHeatAboveSetpointWasser = false; // Standardmäßig AUS const bool defaultPreventHeatAboveSetpointWasser = false; bool preventHeatAboveSetpointDampf = false; // Standardmäßig AUS const bool defaultPreventHeatAboveSetpointDampf = false; /************************************************************************************ * Shot-Timer Variablen ************************************************************************************/ unsigned long shotStartTime = 0; unsigned long shotEndTime = 0; bool shotActive = false; unsigned long lastShotDurationMillis = 0; // Speichert die Dauer des letzten Shots in ms /************************************************************************************ * Shot-Zähler und Betriebszeit ************************************************************************************/ unsigned long shotCounter = 0; // Shots > 20 Sekunden unsigned long totalRuntime = 0; // Gesamt-Betriebszeit in Sekunden unsigned long lastRuntimeSave = 0; // Wann zuletzt gespeichert const unsigned long initialRuntimeSaveDelay = 10 * 60 * 1000UL; // 10 Minuten const unsigned long subsequentRuntimeSaveInterval = 5 * 60 * 1000UL; // 5 Minuten bool initialRuntimeSaveDone = false; /************************************************************************************ * Wartungserinnerung Variablen ************************************************************************************/ int maintenanceInterval = 0; // Intervall in Shots (0 = deaktiviert) unsigned long maintenanceIntervalCounter = 0; // Zähler für Wartungsintervall bool displayMaintenanceMessage = false; // Flag, ob Meldung aktiv angezeigt wird unsigned long maintenanceMessageStartTime = 0; // Startzeit der Meldungsanzeige /************************************************************************************ * Verzögerungen für Anzeigen ************************************************************************************/ int delayInit1 = 1500; // Display-Bild am Anfang int delayInit2 = 3000; // Warte für Anzeige unsigned long previousMillis = 0; // für Intervall z.B. 250ms const unsigned long interval = 250; // PID-Berechnungsintervall /************************************************************************************ * LittleFS ************************************************************************************/ File fsUploadFile; // Globale Variable für die Datei während des Uploads /************************************************************************************ * Hostname Variable ************************************************************************************/ char hostname[33] = "Dual-PID"; // Max 32 Zeichen + Nullterminator /************************************************************************************ * WiFi Signal Bitmap (Display-Anzeige) ************************************************************************************/ static const unsigned char PROGMEM wifiSymbol[] = { 0x1f, 0xc0, 0x20, 0x20, 0x4f, 0x90, 0x90, 0x48, 0x27, 0x20, 0x08, 0x80, 0x02, 0x00 }; /************************************************************************************ * Gemeinsame Navigation (PROGMEM) - URLs angepasst ************************************************************************************/ static const char commonNav[] PROGMEM = R"rawliteral( )rawliteral"; /************************************************************************************ * WiFi-Konfigurationsstrukturen und Funktionen - Hostname hinzugefügt ************************************************************************************/ struct WiFiConfig { char ssid[32] = ""; char password[64] = ""; bool useStaticIP = false; IPAddress staticIP = IPAddress(192, 168, 4, 1); IPAddress gateway = IPAddress(192, 168, 4, 1); IPAddress subnet = IPAddress(255, 255, 255, 0); IPAddress dns = IPAddress(8, 8, 8, 8); // Hostname in WiFi-Config (obwohl global gespeichert, hier für Vollständigkeit) // Wird nicht direkt hier gespeichert, sondern über globale Variable `hostname` }; // Liest die WLAN-Konfiguration aus dem EEPROM // Gibt true zurück, wenn eine gültige Konfiguration (SSID) geladen wurde bool loadWiFiConfig(WiFiConfig& config) { char magic[5] = { 0 }; EEPROM.get(EEPROM_ADDR_WIFI_CONFIG_MAGIC, magic); // Lade Hostname (immer, auch wenn keine WiFi-Config da ist) EEPROM.get(EEPROM_ADDR_HOSTNAME, hostname); hostname[sizeof(hostname) - 1] = '\0'; // Nullterminierung sicherstellen // Wenn Hostname leer ist (z.B. nach Reset), Default setzen if (strlen(hostname) == 0) { strncpy(hostname, defaultHostname.c_str(), sizeof(hostname) - 1); hostname[sizeof(hostname) - 1] = '\0'; EEPROM.put(EEPROM_ADDR_HOSTNAME, hostname); // Default im EEPROM speichern EEPROM.commit(); } // Falls MagicValue nicht übereinstimmt, Standardwerte setzen if (strncmp(magic, storageMagicValue, 4) != 0) { strcpy(config.ssid, ""); strcpy(config.password, ""); config.useStaticIP = false; config.staticIP = IPAddress(192, 168, 4, 1); config.gateway = IPAddress(192, 168, 4, 1); config.subnet = IPAddress(255, 255, 255, 0); config.dns = IPAddress(8, 8, 8, 8); return false; // Keine gültige Konfiguration gefunden } // Laden der gespeicherten Daten EEPROM.get(EEPROM_ADDR_WIFI_CONFIG_DATA, config); // Nullterminierung sicherstellen (wichtig!) config.ssid[sizeof(config.ssid) - 1] = '\0'; config.password[sizeof(config.password) - 1] = '\0'; // Prüfen, ob SSID leer ist (kann nach AP-Modus-Erzwingung passieren) return (strlen(config.ssid) > 0); // True, wenn SSID nicht leer ist } // Schreibt die WLAN-Konfiguration ins EEPROM void saveWiFiConfig(const WiFiConfig& config) { EEPROM.put(EEPROM_ADDR_WIFI_CONFIG_MAGIC, storageMagicValue); EEPROM.put(EEPROM_ADDR_WIFI_CONFIG_DATA, config); // Hostname wird separat gespeichert (in handleSaveWiFiConfig) EEPROM.commit(); } // Aktiviert den Access Point Modus und zeigt IP an void startAPMode() { WiFi.hostname(hostname); WiFi.softAP("Dual-PID", "QuickMill"); // Setzt SSID "Dual-PID" und Passwort "QuickMill" Serial.print(F("AP-Mode gestartet. SSID: Dual-PID, Passwort: QuickMill, IP: ")); Serial.println(WiFi.softAPIP()); } /************************************************************************************ * Webserver-Handler: WLAN-Konfigurations-Seite - Hostname hinzugefügt * Async angepasst ************************************************************************************/ // === PROGMEM Teile bleiben gleich === static const char wifiPageChunk1[] PROGMEM = R"rawliteral( WLAN-Konfiguration )rawliteral"; static const char wifiPageChunk2[] PROGMEM = R"rawliteral()rawliteral"; // Head Ende static const char wifiPageChunk3_a[] PROGMEM = R"rawliteral(

WLAN-Konfiguration

Netzwerk

)rawliteral"; static const char wifiPageChunk3_b[] PROGMEM = R"rawliteral(

IP-Einstellungen

)rawliteral"; static const char wifiPageChunk6_ip[] PROGMEM = R"rawliteral(

AP-Modus

Verwendung im Access Point-Modus (AP).

Netzwerk-Name: Dual-PID
Passwort: QuickMill
IP-Adresse: 192.168.4.1
)rawliteral"; // --- Hilfsfunktion zur Umrechnung von RSSI in Prozent (Approximation) --- int calculateSignalPercentage(int rssi) { int quality = 0; if (rssi <= -100) { quality = 0; } else if (rssi >= -50) { // Guter Empfang quality = 100; } else { // Lineare Skalierung zwischen -100dBm (0%) und -50dBm (100%) quality = 2 * (rssi + 100); } return quality; } // Handler angepasst für AsyncWebServerRequest void handleWiFiConfig(AsyncWebServerRequest *request) { // EEPROM Lese-Logik (inkl. Hostname aus globaler Variable) WiFiConfig currentConfig; char magic[5] = { 0 }; EEPROM.get(EEPROM_ADDR_WIFI_CONFIG_MAGIC, magic); bool configValid = (strncmp(magic, storageMagicValue, 4) == 0); if (configValid) { EEPROM.get(EEPROM_ADDR_WIFI_CONFIG_DATA, currentConfig); // Nullterminierung sicherstellen currentConfig.ssid[sizeof(currentConfig.ssid) - 1] = '\0'; currentConfig.password[sizeof(currentConfig.password) - 1] = '\0'; } else { // Standardwerte, falls keine gültige Konfig im EEPROM strcpy(currentConfig.ssid, ""); strcpy(currentConfig.password, ""); currentConfig.useStaticIP = false; currentConfig.staticIP = IPAddress(192, 168, 4, 1); currentConfig.gateway = IPAddress(192, 168, 4, 1); currentConfig.subnet = IPAddress(255, 255, 255, 0); currentConfig.dns = IPAddress(8, 8, 8, 8); // Default DNS } // Hostname ist in 'hostname' verfügbar // --- Signalstärke ermitteln --- char signalBuffer[50]; // Puffer für den Anzeigetext if (WiFi.getMode() == WIFI_STA && WiFi.status() == WL_CONNECTED) { long rssi = WiFi.RSSI(); int percentage = calculateSignalPercentage(rssi); snprintf(signalBuffer, sizeof(signalBuffer), "%d dBm (%d%%)", (int)rssi, percentage); } else if (WiFi.getMode() == WIFI_AP) { strcpy(signalBuffer, "AP-Modus (keine Messung)"); } else { strcpy(signalBuffer, "Nicht verbunden"); } // Temporäre Puffer für Senden char formatBuffer[256]; // Für snprintf_P char ipBuffer[16]; // Für IP-Adressen-Formatierung // ---- Beginn des Sendens mit AsyncResponseStream ---- AsyncResponseStream *response = request->beginResponseStream("text/html"); response->print(FPSTR(wifiPageChunk1)); response->print("\n"); response->print(FPSTR(wifiPageChunk2)); response->print(FPSTR(commonNav)); // --- Body und Netzwerk-Sektion --- response->print(FPSTR(wifiPageChunk3_a)); response->print(signalBuffer); response->print(FPSTR(wifiPageChunk3_b)); response->print(hostname); response->print(FPSTR(wifiPageChunk3_c)); // SSID einfügen if (strlen(currentConfig.ssid) == 0) { response->print(F("SSID eingeben")); } else { response->print(currentConfig.ssid); } response->print(FPSTR(wifiPageChunk4)); // Passwort einfügen if (!demoModus) { if (strlen(currentConfig.password) == 0) { response->print(F("Passwort eingeben")); } else { response->print(currentConfig.password); } } else { response->print(F("********")); } // --- IP-Einstellungen --- snprintf_P(formatBuffer, sizeof(formatBuffer), wifiPageChunk5_format, currentConfig.useStaticIP ? "" : "checked", currentConfig.useStaticIP ? "checked" : "", currentConfig.useStaticIP ? "block" : "none"); response->print(formatBuffer); // --- Statische IP Felder (NEUE REIHENFOLGE) --- // 1. IP-Adresse response->print(FPSTR(wifiPageChunk6_ip)); snprintf(ipBuffer, sizeof(ipBuffer), "%d.%d.%d.%d", currentConfig.staticIP[0], currentConfig.staticIP[1], currentConfig.staticIP[2], currentConfig.staticIP[3]); response->print(ipBuffer); // 2. Gateway response->print(FPSTR(wifiPageChunk7_gateway)); snprintf(ipBuffer, sizeof(ipBuffer), "%d.%d.%d.%d", currentConfig.gateway[0], currentConfig.gateway[1], currentConfig.gateway[2], currentConfig.gateway[3]); response->print(ipBuffer); // 3. DNS-Server response->print(FPSTR(wifiPageChunk8_dns)); snprintf(ipBuffer, sizeof(ipBuffer), "%d.%d.%d.%d", currentConfig.dns[0], currentConfig.dns[1], currentConfig.dns[2], currentConfig.dns[3]); response->print(ipBuffer); // 4. Subnetzmaske response->print(FPSTR(wifiPageChunk9_subnet)); snprintf(ipBuffer, sizeof(ipBuffer), "%d.%d.%d.%d", currentConfig.subnet[0], currentConfig.subnet[1], currentConfig.subnet[2], currentConfig.subnet[3]); response->print(ipBuffer); // Rest der Seite response->print(FPSTR(wifiPageChunk10_rest)); // ---- Ende des Sendens ---- request->send(response); } /************************************************************************************ * Handler, um den AP-Modus per Knopfdruck zu erzwingen - Async angepasst ************************************************************************************/ // Die PROGMEM-Definitionen für die Bestätigungsseite bleiben bestehen static const char forceAPHtml[] PROGMEM = R"rawliteral( AP-Modus aktivieren )rawliteral"; static const char forceAPHtml2[] PROGMEM = R"rawliteral( )rawliteral"; static const char forceAPHtml3[] PROGMEM = R"rawliteral(

AP-Modus wird aktiviert...

Die Einstellungen wurden gespeichert. Neustart im AP-Modus ...

)rawliteral"; // --- Überarbeitete Funktion handleForceAPMode --- void handleForceAPMode(AsyncWebServerRequest *request) { // --- Logik zum Speichern der leeren Konfig (BLEIBT UNVERÄNDERT) --- WiFiConfig emptyConfig; if (!demoModus) { saveWiFiConfig(emptyConfig); } // --- Senden der Bestätigungsseite mit AsyncResponseStream --- AsyncResponseStream *response = request->beginResponseStream("text/html"); response->print(FPSTR(forceAPHtml)); response->print("\n"); response->print(FPSTR(forceAPHtml2)); response->print(FPSTR(commonNav)); response->print(FPSTR(forceAPHtml3)); request->send(response); // --- Logik nach dem Senden (BLEIBT UNVERÄNDERT) --- // Führe den Neustart nach einer Verzögerung aus, um sicherzustellen, dass die Antwort gesendet wurde // Eine bessere Methode mit Async wäre ein Timer, aber delay() ist minimalinvasiv delay(1000); ESP.restart(); } /************************************************************************************ * Handler zum Speichern der WLAN-Konfiguration - Async angepasst ************************************************************************************/ // Die PROGMEM-Definitionen für die Bestätigungsseite bleiben bestehen static const char saveConfigHtml[] PROGMEM = R"rawliteral( Einstellungen gespeichert )rawliteral"; static const char saveConfigHtml2[] PROGMEM = R"rawliteral( )rawliteral"; static const char saveConfigHtml3[] PROGMEM = R"rawliteral(

Einstellungen gespeichert

Die Einstellungen wurden gespeichert. Neustart ...

)rawliteral"; // --- Funktion handleSaveWiFiConfig --- void handleSaveWiFiConfig(AsyncWebServerRequest *request) { // --- Daten validieren und auslesen --- if (!request->hasParam("ssid", true)) // true checkt POST { request->send(400, F("text/plain"), F("Fehler: SSID fehlt!")); return; } // --- Konfigurationsstruktur füllen --- WiFiConfig newConfig; // SSID und Passwort kopieren strncpy(newConfig.ssid, request->getParam("ssid", true)->value().c_str(), sizeof(newConfig.ssid) - 1); newConfig.ssid[sizeof(newConfig.ssid) - 1] = '\0'; if (request->hasParam("password", true)) { strncpy(newConfig.password, request->getParam("password", true)->value().c_str(), sizeof(newConfig.password) - 1); newConfig.password[sizeof(newConfig.password) - 1] = '\0'; } else { newConfig.password[0] = '\0'; } // IP-Typ bestimmen if (request->hasParam("ipType", true)) { newConfig.useStaticIP = (request->getParam("ipType", true)->value() == "static"); } else { newConfig.useStaticIP = false; // Fallback } // Statische IP-Einstellungen parsen, falls ausgewählt if (newConfig.useStaticIP) { if (!request->hasParam("ip", true) || !request->hasParam("gateway", true) || !request->hasParam("subnet", true) || !request->hasParam("dns", true)) { request->send(400, F("text/plain"), F("Fehler: Fehlende Felder für statische IP!")); return; } bool ipOK = newConfig.staticIP.fromString(request->getParam("ip", true)->value()); bool gwOK = newConfig.gateway.fromString(request->getParam("gateway", true)->value()); bool subOK = newConfig.subnet.fromString(request->getParam("subnet", true)->value()); bool dnsOK = newConfig.dns.fromString(request->getParam("dns", true)->value()); if (!ipOK || !gwOK || !subOK || !dnsOK) { Serial.println(F("WARNUNG: Mindestens ein Feld für statische IP hatte ein ungültiges Format.")); // Option B: Mit Defaultwerten speichern (fromString liefert oft 0.0.0.0 bei Fehler) } Serial.printf("Speichere statische IP: %s, GW: %s, Sub: %s, DNS: %s\n", newConfig.staticIP.toString().c_str(), newConfig.gateway.toString().c_str(), newConfig.subnet.toString().c_str(), newConfig.dns.toString().c_str()); } else { Serial.println(F("Speichere Konfiguration für DHCP.")); } // Hostname speichern if (request->hasParam("hostname", true)) { strncpy(hostname, request->getParam("hostname", true)->value().c_str(), sizeof(hostname) - 1); hostname[sizeof(hostname) - 1] = '\0'; if (strlen(hostname) == 0) { strncpy(hostname, defaultHostname.c_str(), sizeof(hostname) - 1); hostname[sizeof(hostname) - 1] = '\0'; } EEPROM.put(EEPROM_ADDR_HOSTNAME, hostname); Serial.printf("Speichere Hostname: %s\n", hostname); } // --- Speichern (wenn nicht im Demo-Modus) --- if (!demoModus) { Serial.println("Schreibe WiFi-Konfiguration und Hostname ins EEPROM..."); saveWiFiConfig(newConfig); // EEPROM.commit() ist in saveWiFiConfig und oben für Hostname } else { Serial.println("Demo-Modus aktiv: Einstellungen werden NICHT gespeichert."); } // --- Senden der Bestätigungsseite mit AsyncResponseStream --- AsyncResponseStream *response = request->beginResponseStream("text/html"); response->print(FPSTR(saveConfigHtml)); response->print("\n"); response->print(FPSTR(saveConfigHtml2)); response->print(FPSTR(commonNav)); response->print(FPSTR(saveConfigHtml3)); request->send(response); // --- Neustart einleiten --- Serial.println("Einstellungen verarbeitet. Starte Neustart in 5 Sekunden..."); delay(5000); ESP.restart(); } // Prüfung der WiFi-Verbindung (Reconnect falls getrennt) - Unverändert void checkWifiConnection() { if (WiFi.status() != WL_CONNECTED) { WiFi.reconnect(); if (WiFi.waitForConnectResult() != WL_CONNECTED) { Serial.println(F("WiFi-Verbindung kann nicht hergestellt werden!")); } } } // WiFi-Symbol aufs Display zeichnen - Unverändert void drawwifiSymbol(int16_t x, int16_t y) { display.drawBitmap(x, y, wifiSymbol, 13, 7, SH110X_WHITE); } /************************************************************************************ * Handler zum Rücksetzen der Betriebszeit - Async angepasst ************************************************************************************/ void handleResetRuntime(AsyncWebServerRequest *request) { totalRuntime = 0; EEPROM.put(EEPROM_ADDR_RUNTIME, totalRuntime); EEPROM.commit(); request->redirect("/Info"); // Async redirect } /************************************************************************************ * Handler zum Rücksetzen des Shotzählers - Async angepasst ************************************************************************************/ void handleResetShots(AsyncWebServerRequest *request) { shotCounter = 0; EEPROM.put(EEPROM_ADDR_SHOTCOUNTER, shotCounter); EEPROM.commit(); request->redirect("/Info"); // Async redirect } /************************************************************************************ * Handler zum Rücksetzen des Wartungszählers (über Shot-Zähler) - Async angepasst ************************************************************************************/ void handleResetMaintenance(AsyncWebServerRequest *request) { maintenanceIntervalCounter = 0; // Wartungszähler-Reset auf 0 EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter); EEPROM.commit(); displayMaintenanceMessage = false; // Meldung ggf. ausblenden request->redirect("/Service"); // Async redirect } /************************************************************************************ * Handler für den Export der EEPROM-Einstellungen - Async angepasst ************************************************************************************/ void handleExportSettings(AsyncWebServerRequest *request) { AsyncResponseStream *response = request->beginResponseStream("application/octet-stream", EEPROM_SIZE); response->addHeader("Content-Disposition", "attachment; filename=\"dual_pid_settings.bin\""); // EEPROM-Inhalt byteweise zum Stream schreiben // Puffergröße klein halten, um Speicher zu sparen const size_t bufferSize = 64; byte buffer[bufferSize]; for (int i = 0; i < EEPROM_SIZE; i += bufferSize) { size_t currentChunkSize = (EEPROM_SIZE - i < bufferSize) ? (EEPROM_SIZE - i) : bufferSize; for (size_t j = 0; j < currentChunkSize; j++) { buffer[j] = EEPROM.read(i + j); yield(); // Wichtig für Netzwerkstabilität bei längeren Operationen } response->write(buffer, currentChunkSize); yield(); } request->send(response); } /************************************************************************************ * Handler für den Import der EEPROM-Einstellungen (Async Upload-Verarbeitung) ************************************************************************************/ // PROGMEM Chunks für die Import-Antwortseite (handleImportSettingsResponse) static const char importDoneHtmlStart[] PROGMEM = R"rawliteral( Einstellungen Import )rawliteral"; static const char importDoneHtmlHeadEnd[] PROGMEM = R"rawliteral()rawliteral"; static const char importDoneHtmlBodyStart[] PROGMEM = R"rawliteral(

Einstellungen Import

)rawliteral"; // Endet vor {status_message} static const char importDoneHtmlBodyEnd[] PROGMEM = R"rawliteral(

)rawliteral"; // Beginnt nach {status_message} // Globale Variablen bleiben gleich static int eepromWriteAddress = 0; static bool importSuccessful = false; static String importStatusMessage = ""; // Diese Funktion wird AUFGERUFEN, wenn der Upload-Vorgang abgeschlossen ist (oder fehlschlägt) // Sie sendet die Statusseite. void handleImportSettingsResponse(AsyncWebServerRequest *request) { AsyncResponseStream *response = request->beginResponseStream("text/html"); response->print(FPSTR(importDoneHtmlStart)); response->print("\n"); response->print(FPSTR(importDoneHtmlHeadEnd)); response->print(FPSTR(commonNav)); response->print(FPSTR(importDoneHtmlBodyStart)); // Statusmeldung senden (importStatusMessage ist globale String-Variable) if (importStatusMessage.length() > 0) { response->print(importStatusMessage); } response->print(FPSTR(importDoneHtmlBodyEnd)); request->send(response); // Neustart-Logik bleibt unverändert if (importSuccessful) { Serial.println("Neustart in 5 Sekunden nach EEPROM-Import..."); delay(5000); ESP.restart(); } } // Dies ist der Callback, der während des Uploads aufgerufen wird. void handleImportUpload(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { // Demo-Modus Prüfung if (demoModus) { if (index == 0) { // Nur beim Start prüfen importStatusMessage = F("Import im Demo-Modus nicht erlaubt!"); importSuccessful = false; Serial.println("EEPROM Import im Demo Modus abgelehnt."); // Sende sofort eine Fehlermeldung? Besser am Ende in handleImportSettingsResponse } return; // Keine weitere Verarbeitung im Demo Modus } if(index == 0){ // UPLOAD_FILE_START Serial.printf("EEPROM Import Start: %s\n", filename.c_str()); eepromWriteAddress = 0; // Zähler zurücksetzen importSuccessful = false; // Status zurücksetzen importStatusMessage = ""; // Nachricht zurücksetzen } // Prüfen, ob bereits ein Fehler aufgetreten ist if (importStatusMessage.length() > 0) { return; // Beende die Verarbeitung für diesen Chunk } // Schreibe die empfangenen Bytes, aber schütze vor Überlauf for (size_t i = 0; i < len; i++) { if (eepromWriteAddress < EEPROM_SIZE) { EEPROM.write(eepromWriteAddress, data[i]); eepromWriteAddress++; } else { Serial.printf("FEHLER: Mehr als %d Bytes empfangen! Adresse: %d. Breche ab.\n", EEPROM_SIZE, eepromWriteAddress); importStatusMessage = F("Importfehler: Empfangene Datei zu groß!"); goto end_write_processing_async; // Springe aus der Schleife } yield(); } end_write_processing_async:; // Sprungziel für goto if(final){ // UPLOAD_FILE_END Serial.printf("EEPROM Import Ende. Verarbeitete Bytes: %d\n", eepromWriteAddress); // FINALE GRÖSSENPRÜFUNG und Fehlerprüfung if (importStatusMessage.length() > 0) { Serial.println("Import nicht erfolgreich aufgrund vorheriger Fehler (z.B. Datei zu groß)."); importSuccessful = false; } else if (eepromWriteAddress == EEPROM_SIZE) { if (EEPROM.commit()) { importSuccessful = true; importStatusMessage = F("Einstellungen erfolgreich importiert! Neustart wird durchgeführt..."); } else { Serial.println("FEHLER: EEPROM Commit fehlgeschlagen!"); importSuccessful = false; importStatusMessage = F("Import fehlgeschlagen: EEPROM Commit Fehler!"); } } else { Serial.printf("FEHLER: Upload beendet, aber falsche Anzahl Bytes verarbeitet! Verarbeitet: %d, Erwartet: %d\n", eepromWriteAddress, EEPROM_SIZE); importSuccessful = false; importStatusMessage = F("Import fehlgeschlagen: Falsche Dateigröße!"); } eepromWriteAddress = 0; // Bereinigung // **WICHTIG**: Sende die Antwortseite *nachdem* der Upload abgeschlossen ist. // Der ursprüngliche handleImportSettings wird hier aufgerufen. handleImportSettingsResponse(request); } } /************************************************************************************ * Handler für die Import-Seite (nur zum Anzeigen der Seite, die den Upload auslöst) * Diese Funktion wird nicht mehr benötigt, da der Upload direkt über POST an /importSettings geht * und die Antwort nach dem Upload gesendet wird. ************************************************************************************/ // void handleImportSettings(AsyncWebServerRequest *request) { ... } // Diese Funktion ist nicht mehr nötig für POST /************************************************************************************ * Funktion zum Zurücksetzen auf Werkseinstellungen ************************************************************************************/ void resetToDefaults() { Serial.println("Zurücksetzen auf Werkseinstellungen..."); // PID & Offset EEPROM.put(EEPROM_ADDR_SETPOINT_DAMPF, defaultSetpointDampf); EEPROM.put(EEPROM_ADDR_SETPOINT_WASSER, defaultSetpointWasser); EEPROM.put(EEPROM_ADDR_OFFSET_DAMPF, defaultOffsetDampf); EEPROM.put(EEPROM_ADDR_OFFSET_WASSER, defaultOffsetWasser); EEPROM.put(EEPROM_ADDR_KP_DAMPF, defaultKpDampf); EEPROM.put(EEPROM_ADDR_KI_DAMPF, defaultKiDampf); EEPROM.put(EEPROM_ADDR_KD_DAMPF, defaultKdDampf); EEPROM.put(EEPROM_ADDR_KP_WASSER, defaultKpWasser); EEPROM.put(EEPROM_ADDR_KI_WASSER, defaultKiWasser); EEPROM.put(EEPROM_ADDR_KD_WASSER, defaultKdWasser); EEPROM.put(EEPROM_ADDR_WINDOWSIZE_WASSER, defaultWindowSizeWasser); EEPROM.put(EEPROM_ADDR_WINDOWSIZE_DAMPF, defaultWindowSizeDampf); // Boost-Funktion EEPROM.put(EEPROM_ADDR_BOOST_WASSER_ACTIVE, defaultBoostWasserActive); EEPROM.put(EEPROM_ADDR_BOOST_DAMPF_ACTIVE, defaultBoostDampfActive); // Heiz-Begrenzung EEPROM.put(EEPROM_ADDR_PREVENTHEAT_WASSER, defaultPreventHeatAboveSetpointWasser); EEPROM.put(EEPROM_ADDR_PREVENTHEAT_DAMPF, defaultPreventHeatAboveSetpointDampf); // Eco Modus EEPROM.put(EEPROM_ADDR_ECOMODE_MINUTES, defaultEcoModeMinutes); EEPROM.put(EEPROM_ADDR_ECOMODE_TEMP_WASSER, defaultEcoModeTempWasser); EEPROM.put(EEPROM_ADDR_ECOMODE_TEMP_DAMPF, defaultEcoModeTempDampf); EEPROM.put(EEPROM_ADDR_DYNAMIC_ECO_MODE, false); // Default für Dynamic Eco EEPROM.put(EEPROM_ADDR_STEAM_DELAY, defaultDampfVerzoegerung); // Geräteinfo char tempInfo[50]; strncpy(tempInfo, defaultInfoHersteller.c_str(), sizeof(tempInfo) - 1); tempInfo[sizeof(tempInfo) - 1] = '\0'; EEPROM.put(EEPROM_ADDR_INFO_HERSTELLER, tempInfo); strncpy(tempInfo, defaultInfoModell.c_str(), sizeof(tempInfo) - 1); tempInfo[sizeof(tempInfo) - 1] = '\0'; EEPROM.put(EEPROM_ADDR_INFO_MODELL, tempInfo); strncpy(tempInfo, defaultInfoZusatz.c_str(), sizeof(tempInfo) - 1); tempInfo[sizeof(tempInfo) - 1] = '\0'; EEPROM.put(EEPROM_ADDR_INFO_ZUSATZ, tempInfo); // Fast Heat Up EEPROM.put(EEPROM_ADDR_FASTHEATUP_DATA, false); // Default für Fast Heat Up // Profile (mit Default-Werten überschreiben) TemperatureProfile defaultProfile; defaultProfile.setpointWasser = defaultSetpointWasser; defaultProfile.setpointDampf = defaultSetpointDampf; defaultProfile.ecoModeMinutes = defaultEcoModeMinutes; defaultProfile.dynamicEcoActive = false; defaultProfile.dampfVerzoegerung = defaultDampfVerzoegerung; strncpy(defaultProfile.profileName, "Unbenannt", sizeof(defaultProfile.profileName)); defaultProfile.profileName[sizeof(defaultProfile.profileName) - 1] = '\0'; EEPROM.put(EEPROM_ADDR_PROFILE_1, defaultProfile); EEPROM.put(EEPROM_ADDR_PROFILE_2, defaultProfile); // AutoTune Parameter EEPROM.put(EEPROM_ADDR_TUNING_STEP_WASSER, defaultTuningStepWasser); EEPROM.put(EEPROM_ADDR_TUNING_NOISE_WASSER, defaultTuningNoiseWasser); EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_WASSER, defaultTuningStartValueWasser); EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_WASSER, defaultTuningLookBackWasser); EEPROM.put(EEPROM_ADDR_TUNING_STEP_DAMPF, defaultTuningStepDampf); EEPROM.put(EEPROM_ADDR_TUNING_NOISE_DAMPF, defaultTuningNoiseDampf); EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_DAMPF, defaultTuningStartValueDampf); EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_DAMPF, defaultTuningLookBackDampf); // Sicherheitslimits (verwenden die globalen Variablen, die beim Start initialisiert wurden) EEPROM.put(EEPROM_ADDR_MAX_TEMP_WASSER, maxTempWasser); // Diese sollten eigentlich auch Defaults haben EEPROM.put(EEPROM_ADDR_MAX_TEMP_DAMPF, maxTempDampf); // TODO: Eigene Defaults definieren? // // WiFi Konfiguration löschen (erzwingt AP-Modus beim nächsten Start) // char emptyMagic[5] = {0}; // EEPROM.put(EEPROM_ADDR_WIFI_CONFIG_MAGIC, emptyMagic); // Hostname auf Standard strncpy(hostname, defaultHostname.c_str(), sizeof(hostname) - 1); hostname[sizeof(hostname) - 1] = '\0'; EEPROM.put(EEPROM_ADDR_HOSTNAME, hostname); // Wartungsintervall zurücksetzen EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, 0UL); EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL, defaultMaintenanceInterval); // // Zähler zurücksetzen (optional, aber sinnvoll) // EEPROM.put(EEPROM_ADDR_RUNTIME, 0UL); // EEPROM.put(EEPROM_ADDR_SHOTCOUNTER, 0UL); // Magic Value für allgemeine Einstellungen setzen (wichtig!) EEPROM.put(EEPROM_ADDR_MAGICVALUE, storageMagicValue); // Alle Änderungen ins EEPROM schreiben if (EEPROM.commit()) { Serial.println("Werkseinstellungen erfolgreich ins EEPROM geschrieben."); // Log hinzugefügt } else { Serial.println("FEHLER: Schreiben der Werkseinstellungen ins EEPROM fehlgeschlagen!"); } // WICHTIG: Globale Variablen auch auf Default setzen, damit der laufende Betrieb korrekt ist // bis zum Neustart (obwohl ein Neustart nach Reset empfohlen wird) maintenanceInterval = defaultMaintenanceInterval; maintenanceIntervalCounter = 0; boostWasserActive = defaultBoostWasserActive; boostDampfActive = defaultBoostDampfActive; preventHeatAboveSetpointWasser = defaultPreventHeatAboveSetpointWasser; preventHeatAboveSetpointDampf = defaultPreventHeatAboveSetpointDampf; strncpy(hostname, defaultHostname.c_str(), sizeof(hostname) - 1); hostname[sizeof(hostname) - 1] = '\0'; windowSizeWasser = defaultWindowSizeWasser; windowSizeDampf = defaultWindowSizeDampf; pidWasser.SetOutputLimits(0, windowSizeWasser); pidDampf.SetOutputLimits(0, windowSizeDampf); } /************************************************************************************ * Handler für das Zurücksetzen auf Werkseinstellungen - Async angepasst ************************************************************************************/ // PROGMEM Chunks für die Seite static const char resetDoneHtmlStart[] PROGMEM = R"rawliteral( Werkseinstellungen )rawliteral"; static const char resetDoneHtmlHeadEnd[] PROGMEM = R"rawliteral()rawliteral"; static const char resetDoneHtmlBody[] PROGMEM = R"rawliteral(

Werkseinstellungen

Alle Einstellungen wurden auf die Werkseinstellungen zurückgesetzt. Das Gerät wird neu gestartet...

)rawliteral"; // Überarbeitete Funktion void handleResetDefaults(AsyncWebServerRequest *request) { // Logik zum Resetten bleibt unverändert if (!demoModus) { resetToDefaults(); // Führt das eigentliche Reset durch } else { request->send(403, F("text/plain"), F("Reset im Demo-Modus nicht erlaubt.")); return; } // Bestätigungsseite senden (Async) AsyncResponseStream *response = request->beginResponseStream("text/html"); response->print(FPSTR(resetDoneHtmlStart)); response->print("\n"); response->print(FPSTR(resetDoneHtmlHeadEnd)); response->print(FPSTR(commonNav)); response->print(FPSTR(resetDoneHtmlBody)); request->send(response); // Neustart-Logik bleibt unverändert delay(5000); ESP.restart(); } /************************************************************************************ * Variable für Systemstartzeit (relevant für die Dampf-Verzögerung) - Unverändert ************************************************************************************/ unsigned long startupTime; // Forward Deklarationen für Upload Handler void handleFirmwareUploadProgress(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final); void handleFirmwareUpdateResponse(AsyncWebServerRequest *request); void handleFileUploadProgress(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final); void handleFileUploadedResponse(AsyncWebServerRequest *request); // void handleImportUpload(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final); // Bereits deklariert/definiert // void handleImportSettingsResponse(AsyncWebServerRequest *request); // Bereits deklariert/definiert /************************************************************************************ * setup(): Initialisierung des Systems - Async angepasst ************************************************************************************/ void setup() { Serial.begin(115200); EEPROM.begin(EEPROM_SIZE); // Größe angepasst pinMode(SSR_DAMPF_PIN, OUTPUT); pinMode(SSR_WASSER_PIN, OUTPUT); pinMode(SHOT_TIMER_PIN, INPUT_PULLUP); // Initialisiere SSRs als AUS digitalWrite(SSR_DAMPF_PIN, LOW); digitalWrite(SSR_WASSER_PIN, LOW); // Prüfen, ob MagicValue bereits im EEPROM liegt char storedMagicValue[5] = { 0 }; bool magicValueVorhanden = false; EEPROM.get(EEPROM_ADDR_MAGICVALUE, storedMagicValue); if (strncmp(storedMagicValue, storageMagicValue, 4) == 0) { magicValueVorhanden = true; } else { magicValueVorhanden = false; } // Werte aus dem EEPROM laden, wenn vorhanden, sonst mit Default-Werten initialisieren if (magicValueVorhanden) { EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_OFFSET_DAMPF, OffsetDampf); EEPROM.get(EEPROM_ADDR_OFFSET_WASSER, OffsetWasser); EEPROM.get(EEPROM_ADDR_KP_DAMPF, KpDampf); EEPROM.get(EEPROM_ADDR_KI_DAMPF, KiDampf); EEPROM.get(EEPROM_ADDR_KD_DAMPF, KdDampf); EEPROM.get(EEPROM_ADDR_KP_WASSER, KpWasser); EEPROM.get(EEPROM_ADDR_KI_WASSER, KiWasser); EEPROM.get(EEPROM_ADDR_KD_WASSER, KdWasser); EEPROM.get(EEPROM_ADDR_ECOMODE_MINUTES, ecoModeMinutes); EEPROM.get(EEPROM_ADDR_ECOMODE_TEMP_WASSER, ecoModeTempWasser); EEPROM.get(EEPROM_ADDR_ECOMODE_TEMP_DAMPF, ecoModeTempDampf); EEPROM.get(EEPROM_ADDR_INFO_HERSTELLER, infoHersteller); EEPROM.get(EEPROM_ADDR_INFO_MODELL, infoModell); EEPROM.get(EEPROM_ADDR_INFO_ZUSATZ, infoZusatz); EEPROM.get(EEPROM_ADDR_DYNAMIC_ECO_MODE, dynamicEcoActive); EEPROM.get(EEPROM_ADDR_FASTHEATUP_DATA, fastHeatUpAktiv); EEPROM.get(EEPROM_ADDR_STEAM_DELAY, dampfVerzoegerung); EEPROM.get(EEPROM_ADDR_TUNING_STEP_WASSER, tuningStepWasser); EEPROM.get(EEPROM_ADDR_TUNING_NOISE_WASSER, tuningNoiseWasser); EEPROM.get(EEPROM_ADDR_TUNING_STARTVALUE_WASSER, tuningStartValueWasser); EEPROM.get(EEPROM_ADDR_TUNING_LOOKBACK_WASSER, tuningLookBackWasser); EEPROM.get(EEPROM_ADDR_TUNING_STEP_DAMPF, tuningStepDampf); EEPROM.get(EEPROM_ADDR_TUNING_NOISE_DAMPF, tuningNoiseDampf); EEPROM.get(EEPROM_ADDR_TUNING_STARTVALUE_DAMPF, tuningStartValueDampf); EEPROM.get(EEPROM_ADDR_TUNING_LOOKBACK_DAMPF, tuningLookBackDampf); EEPROM.get(EEPROM_ADDR_MAX_TEMP_WASSER, maxTempWasser); EEPROM.get(EEPROM_ADDR_MAX_TEMP_DAMPF, maxTempDampf); EEPROM.get(EEPROM_ADDR_MAINTENANCE_INTERVAL, maintenanceInterval); EEPROM.get(EEPROM_ADDR_BOOST_WASSER_ACTIVE, boostWasserActive); EEPROM.get(EEPROM_ADDR_BOOST_DAMPF_ACTIVE, boostDampfActive); EEPROM.get(EEPROM_ADDR_PREVENTHEAT_WASSER, preventHeatAboveSetpointWasser); EEPROM.get(EEPROM_ADDR_PREVENTHEAT_DAMPF, preventHeatAboveSetpointDampf); EEPROM.get(EEPROM_ADDR_WINDOWSIZE_WASSER, windowSizeWasser); EEPROM.get(EEPROM_ADDR_WINDOWSIZE_DAMPF, windowSizeDampf); infoHersteller[sizeof(infoHersteller) - 1] = '\0'; infoModell[sizeof(infoModell) - 1] = '\0'; infoZusatz[sizeof(infoZusatz) - 1] = '\0'; } else // Magic Value fehlt -> ALLES auf Default setzen { SetpointDampf = defaultSetpointDampf; SetpointWasser = defaultSetpointWasser; OffsetDampf = defaultOffsetDampf; OffsetWasser = defaultOffsetWasser; KpDampf = defaultKpDampf; KiDampf = defaultKiDampf; KdDampf = defaultKdDampf; KpWasser = defaultKpWasser; KiWasser = defaultKiWasser; KdWasser = defaultKdWasser; ecoModeMinutes = defaultEcoModeMinutes; ecoModeTempWasser = defaultEcoModeTempWasser; ecoModeTempDampf = defaultEcoModeTempDampf; strncpy(infoHersteller, defaultInfoHersteller.c_str(), sizeof(infoHersteller) - 1); infoHersteller[sizeof(infoHersteller) - 1] = '\0'; strncpy(infoModell, defaultInfoModell.c_str(), sizeof(infoModell) - 1); infoModell[sizeof(infoModell) - 1] = '\0'; strncpy(infoZusatz, defaultInfoZusatz.c_str(), sizeof(infoZusatz) - 1); infoZusatz[sizeof(infoZusatz) - 1] = '\0'; dynamicEcoActive = false; fastHeatUpAktiv = false; dampfVerzoegerung = defaultDampfVerzoegerung; tuningStepWasser = defaultTuningStepWasser; tuningNoiseWasser = defaultTuningNoiseWasser; tuningStartValueWasser = defaultTuningStartValueWasser; tuningLookBackWasser = defaultTuningLookBackWasser; tuningStepDampf = defaultTuningStepDampf; tuningNoiseDampf = defaultTuningNoiseDampf; tuningStartValueDampf = defaultTuningStartValueDampf; tuningLookBackDampf = defaultTuningLookBackDampf; maintenanceInterval = defaultMaintenanceInterval; maintenanceIntervalCounter = 0; boostWasserActive = defaultBoostWasserActive; boostDampfActive = defaultBoostDampfActive; preventHeatAboveSetpointWasser = defaultPreventHeatAboveSetpointWasser; preventHeatAboveSetpointDampf = defaultPreventHeatAboveSetpointDampf; windowSizeWasser = defaultWindowSizeWasser; windowSizeDampf = defaultWindowSizeDampf; // resetToDefaults schreibt Defaults ins EEPROM und setzt Magic Value resetToDefaults(); } // WiFi-Einstellungen laden und ggf. herstellen WiFiConfig wifiConfig; bool hasWiFiConfig = loadWiFiConfig(wifiConfig); // Lädt auch Hostname WiFi.hostname(hostname); // Hostname setzen VOR begin/softAP if (hasWiFiConfig) { WiFi.mode(WIFI_STA); if (wifiConfig.useStaticIP) { Serial.println(F("Verwende statische IP-Konfiguration:")); Serial.print(F(" IP: ")); Serial.println(wifiConfig.staticIP); Serial.print(F(" Gateway: ")); Serial.println(wifiConfig.gateway); Serial.print(F(" Subnet: ")); Serial.println(wifiConfig.subnet); Serial.print(F(" DNS: ")); Serial.println(wifiConfig.dns); if (!WiFi.config(wifiConfig.staticIP, wifiConfig.gateway, wifiConfig.subnet, wifiConfig.dns)) { Serial.println(F("FEHLER: Statische IP-Konfiguration fehlgeschlagen!")); } } else { Serial.println(F("Verwende DHCP.")); } Serial.printf("Verbinde mit SSID: %s, Hostname: %s\n", wifiConfig.ssid, hostname); WiFi.begin(wifiConfig.ssid, wifiConfig.password); int retries = 0; while (WiFi.status() != WL_CONNECTED && retries < 20) { delay(500); Serial.print(F(".")); retries++; yield(); } if (WiFi.status() == WL_CONNECTED) { Serial.print(F("\nErfolgreich verbunden! IP-Adresse: ")); Serial.println(WiFi.localIP()); timeClient.begin(); Serial.println("NTP Client gestartet."); configTzTime(TZ_INFO, "pool.ntp.org"); Serial.printf("Zeitzone gesetzt: %s\n", TZ_INFO); if (WiFi.localIP() == demoModusIP) { demoModus = true; Serial.println("INFO: Demo-Modus aktiviert (basierend auf IP-Adresse)."); } if (MDNS.begin(hostname)) { Serial.println("mDNS Responder gestartet"); MDNS.addService("http", "tcp", 80); } else { Serial.println("Fehler beim Starten des mDNS Responders!"); } } else { Serial.println(F("\nKonnte keine Verbindung herstellen! Starte AP-Modus...")); startAPMode(); } } else { Serial.println("Keine WLAN-Konfiguration gefunden. Starte AP-Modus..."); startAPMode(); } // Wartungszähler, Betriebszeit und Shotcounter aus EEPROM laden EEPROM.get(EEPROM_ADDR_RUNTIME, totalRuntime); if (totalRuntime == ULONG_MAX || totalRuntime > (86400UL * 365 * 10)) { Serial.println("Ungültige Betriebszeit im EEPROM, setze auf 0."); totalRuntime = 0; EEPROM.put(EEPROM_ADDR_RUNTIME, totalRuntime); EEPROM.commit(); } EEPROM.get(EEPROM_ADDR_SHOTCOUNTER, shotCounter); if (shotCounter == ULONG_MAX || shotCounter > 1000000UL) { Serial.println("Ungültiger Shotcounter im EEPROM, setze auf 0."); shotCounter = 0; EEPROM.put(EEPROM_ADDR_SHOTCOUNTER, shotCounter); EEPROM.commit(); } EEPROM.get(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter); if (maintenanceIntervalCounter == ULONG_MAX || maintenanceIntervalCounter > 1000000UL) { Serial.println("Ungültiger Wartungszähler im EEPROM, setze auf 0."); maintenanceIntervalCounter = 0; EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter); EEPROM.commit(); } if (fastHeatUpAktiv) { fastHeatUpHeating = true; } // OLED-Initialisierung Wire.begin(OLED_SDA, OLED_SCK); if (!display.begin(0x3C, true)) { Serial.println(F("Display konnte nicht initialisiert werden!")); } display.clearDisplay(); display.setCursor(0, 0); display.setTextColor(SH110X_WHITE); display.setTextSize(1); display.println(infoHersteller); display.println(infoModell); display.println(infoZusatz); display.println(""); display.println(F("Dual-PID")); display.print(F("Version: ")); display.println(version); display.println(F("von Thomas M\201ller")); display.display(); delay(delayInit1); // --- PID-Regler Konfiguration --- if (windowSizeWasser == 0 || windowSizeWasser > 60000) { windowSizeWasser = defaultWindowSizeWasser; } if (windowSizeDampf == 0 || windowSizeDampf > 60000) { windowSizeDampf = defaultWindowSizeDampf; } pidWasser.SetOutputLimits(0, windowSizeWasser); pidDampf.SetOutputLimits(0, windowSizeDampf); pidDampf.SetTunings(KpDampf, KiDampf, KdDampf); pidWasser.SetTunings(KpWasser, KiWasser, KdWasser); pidDampf.SetMode(AUTOMATIC); pidWasser.SetMode(AUTOMATIC); windowStartTimeWasser = millis(); windowStartTimeDampf = millis(); // Anzeige zur WiFi-Verbindung display.clearDisplay(); display.setCursor(0, 0); display.setTextColor(SH110X_WHITE); display.setTextSize(1); display.println(infoHersteller); display.println(infoModell); display.println(infoZusatz); display.println(""); if (WiFi.status() != WL_CONNECTED) { display.println(F("WiFi: AP-Modus")); display.print(F("SSID: ")); display.println(hostname); display.print(F("IP: ")); display.println(WiFi.softAPIP()); } else { display.println(F("Webserver gestartet")); display.print(F("IP: ")); display.println(WiFi.localIP()); display.print(F("Host: ")); display.print(hostname); display.println(".local"); } display.display(); delay(delayInit2); // --- LittleFS Initialisierung --- if (!LittleFS.begin()) { Serial.println("LittleFS Mount fehlgeschlagen!"); display.clearDisplay(); display.setCursor(0, 0); display.println("FEHLER:"); display.println("LittleFS konnte"); display.println("nicht initialisiert"); display.println("werden!"); display.display(); while (1) delay(100); } else { Serial.println("LittleFS erfolgreich gemountet."); Dir dir = LittleFS.openDir("/"); Serial.println("Dateien im LittleFS:"); while (dir.next()) { Serial.print(" - "); Serial.print(dir.fileName()); Serial.print(" ("); Serial.print(dir.fileSize()); Serial.println(" Bytes)"); } Serial.println("--------------------"); } // Routen für AsyncWebServer server.on("/style.css", HTTP_GET, handleCss); // Hauptseite server.on("/", HTTP_GET, handleRoot); server.on("/updateSettings", HTTP_POST, handleSettingsUpdate); // Info server.on("/Info", HTTP_GET, handleInfo); server.on("/updateInfoSettings", HTTP_POST, handleInfoUpdate); server.on("/downloadNutzungsstatistik", HTTP_GET, handleDownloadNutzungsstatistik); server.on("/deleteStatistics", HTTP_POST, handleDeleteStatistics); server.on("/toggleWartungsmodus", HTTP_POST, handleToggleWartungsmodus); // NEUE ROUTE // Service server.on("/Service", HTTP_GET, handleService); // AutoTune server.on("/AutoTune-Wasser", HTTP_POST, handleAutoTuneWasser); // POST zum Starten server.on("/AutoTune-Dampf", HTTP_POST, handleAutoTuneDampf); // POST zum Starten server.on("/updateAutotuneSettings", HTTP_POST, handleUpdateAutotuneSettings); server.on("/PID-Tuning-Abbruch", HTTP_POST, handlePidTuningAbbruch); server.on("/PID-Tuning", HTTP_GET, handlePidTuning); // GET zum Anzeigen der Seite // ECO-Modus server.on("/ECO", HTTP_GET, handleEco); server.on("/updateEcoSettings", HTTP_POST, handleEcoUpdate); // Firmware server.on("/Firmware", HTTP_GET, handleFirmware); // Firmware Upload: GET zeigt Seite, POST startet Upload (Antwort nach Upload), onUpload verarbeitet Daten server.on("/update", HTTP_POST, handleFirmwareUpdateResponse, handleFirmwareUploadProgress); // Fast-Heat-Up server.on("/Fast-Heat-Up", HTTP_GET, handleFastHeatUp); server.on("/updateFast-Heat-Up-Settings", HTTP_POST, handleFastHeatUpSettings); // Dateimanager server.on("/Dateimanager", HTTP_GET, handleFileManager); // Datei Upload: GET zeigt Seite (Dateimanager), POST startet Upload, onUpload verarbeitet, Response nach Upload server.on("/Datei-Upload", HTTP_POST, handleFileUploadedResponse, handleFileUploadProgress); server.on("/Datei-Entfernen", HTTP_GET, handleFileDelete); server.on("/Datei-Download", HTTP_GET, handleFileDownload); // Chart server.on("/Chart-Daten", HTTP_GET, handleChartData); server.on("/Chart", HTTP_GET, handleCharts); server.on("/chart.umd.min.js", HTTP_GET, handleServeChartJs); // Netzwerk / WiFi server.on("/Netzwerk", HTTP_GET, handleWiFiConfig); server.on("/saveWiFiConfig", HTTP_POST, handleSaveWiFiConfig); server.on("/forceAPMode", HTTP_POST, handleForceAPMode); // Profile server.on("/Profile", HTTP_GET, handleProfiles); server.on("/saveProfile1", HTTP_POST, handleSaveProfile1); server.on("/loadProfile1", HTTP_POST, handleLoadProfile1); server.on("/saveProfile2", HTTP_POST, handleSaveProfile2); server.on("/loadProfile2", HTTP_POST, handleLoadProfile2); // Einstellungen Import & Export + Reset server.on("/exportSettings", HTTP_GET, handleExportSettings); // Einstellungen Import: GET zeigt Firmware-Seite, POST startet Upload, onUpload verarbeitet, Response nach Upload server.on("/importSettings", HTTP_POST, handleImportSettingsResponse, handleImportUpload); server.on("/resetDefaults", HTTP_POST, handleResetDefaults); // Zähler & Wartung server.on("/updateMaintenanceSettings", HTTP_POST, handleMaintenanceUpdate); server.on("/resetRuntime", HTTP_POST, handleResetRuntime); server.on("/resetShots", HTTP_POST, handleResetShots); server.on("/resetMaintenance", HTTP_POST, handleResetMaintenance); // Not Found Handler server.onNotFound([](AsyncWebServerRequest *request){ request->send(404, "text/plain", "Seite nicht gefunden"); }); // Webserver starten server.begin(); Serial.println(F("Async Webserver gestartet")); startupTime = millis(); } /************************************************************************************ * loop(): Hauptschleife - Async angepasst (server.handleClient() entfernt) ************************************************************************************/ void loop() { unsigned long currentMillis = millis(); // Prüfen, ob Zeit bereits synchronisiert wurde if (!timeSynced && WiFi.status() == WL_CONNECTED && timeClient.update()) { timeSynced = true; } // --- Temperaturmessung und PID-Berechnung (im Intervall) --- if (currentMillis - previousMillis >= interval) { previousMillis = currentMillis; // Temperaturmessung InputDampf = round(thermocouple.readCelsius() + OffsetDampf); InputWasser = round(readNTCTemperature() + OffsetWasser); // --- Angepasste Sicherheitsüberprüfung Wasser --- if (InputWasser < 0.0 || isnan(InputWasser)) { wasserSensorError = true; // Echter Sensorfehler wasserSafetyShutdown = false; // Kein Shutdown-Grund, da Wert ungültig OutputWasser = 0; // Heizen stoppen if (!wasserSensorError) { // Nur einmal loggen, wenn Fehler neu auftritt Serial.println("WARNUNG: Wassertemperatursensor liefert ungültigen Wert!"); } } else if (InputWasser > maxTempWasser) { wasserSensorError = false; // Sensor liefert ja gültigen Wert wasserSafetyShutdown = true; // Sicherheitsabschaltung aktiv! OutputWasser = 0; // Heizen stoppen if (!wasserSafetyShutdown) { // Nur einmal loggen, wenn Shutdown neu auftritt Serial.printf("WARNUNG: Wassertemperatur (%.1f°C) über Sicherheitslimit (%.1f°C)! Sicherheitsabschaltung.\n", InputWasser, maxTempWasser); } } else { if (wasserSensorError) Serial.println("INFO: Wassertemperatursensor wieder OK."); wasserSensorError = false; if (wasserSafetyShutdown) Serial.println("INFO: Wassertemperatur wieder im sicheren Bereich."); wasserSafetyShutdown = false; // --- NEUE BOOST LOGIK (oder normale PID-Berechnung) --- bool activateBoost = boostWasserActive && !wartungsModusAktiv && !ecoModeAktiv && !autoTuneWasserActive && (InputWasser < (SetpointWasser * 0.95)); if (activateBoost) { OutputWasser = windowSizeWasser; } else if (!autoTuneWasserActive) { pidWasser.Compute(); } } // --- Angepasste Sicherheitsüberprüfung Dampf --- if (isnan(InputDampf) || InputDampf < 0.0) { dampfSensorError = true; // Echter Sensorfehler dampfSafetyShutdown = false; // Kein Shutdown-Grund, da Wert ungültig OutputDampf = 0; // Heizen stoppen if (!dampfSensorError) { // Nur einmal loggen Serial.println("WARNUNG: Dampftemperatursensor liefert ungültigen Wert!"); } } else if (InputDampf > maxTempDampf) { dampfSensorError = false; // Sensor liefert ja gültigen Wert dampfSafetyShutdown = true; // Sicherheitsabschaltung aktiv! OutputDampf = 0; // Heizen stoppen if (!dampfSafetyShutdown) { // Nur einmal loggen Serial.printf("WARNUNG: Dampftemperatur (%.1f°C) über Sicherheitslimit (%.1f°C)! Sicherheitsabschaltung.\n", InputDampf, maxTempDampf); } } else { if (dampfSensorError) Serial.println("INFO: Dampftemperatursensor wieder OK."); dampfSensorError = false; if (dampfSafetyShutdown) Serial.println("INFO: Dampftemperatur wieder im sicheren Bereich."); dampfSafetyShutdown = false; bool steamDelayActive = (dampfVerzoegerung > 0 && (millis() - startupTime < (unsigned long)dampfVerzoegerung * 60000UL)); if (steamDelayActive && !wartungsModusAktiv) { OutputDampf = 0; } else { bool activateBoostDampf = boostDampfActive && !wartungsModusAktiv && !ecoModeAktiv && !autoTuneDampfActive && (InputDampf < (SetpointDampf * 0.90)); if (activateBoostDampf) { OutputDampf = windowSizeDampf; } else if (!autoTuneDampfActive) { pidDampf.Compute(); } } } // AutoTune-Logik handleAutoTune(); } // --- Zeitproportionale SSR-Ansteuerung --- unsigned long now = millis(); // --- Wasser SSR --- if (wasserSensorError || wasserSafetyShutdown) { digitalWrite(SSR_WASSER_PIN, LOW); } else { if (now - windowStartTimeWasser > windowSizeWasser) { windowStartTimeWasser += windowSizeWasser; } bool pidWantsHeat = (OutputWasser > 0) && (now < (windowStartTimeWasser + (unsigned long)OutputWasser)); bool preventOverheatActive = (preventHeatAboveSetpointWasser && (InputWasser > SetpointWasser)) || (ecoModeAktiv && (InputWasser > SetpointWasser)); if (pidWantsHeat && !preventOverheatActive) { digitalWrite(SSR_WASSER_PIN, HIGH); } else { digitalWrite(SSR_WASSER_PIN, LOW); } } // --- Dampf SSR --- if (dampfSensorError || dampfSafetyShutdown) { digitalWrite(SSR_DAMPF_PIN, LOW); } else { if (now - windowStartTimeDampf > windowSizeDampf) { windowStartTimeDampf += windowSizeDampf; } bool pidWantsHeat = (OutputDampf > 0) && (now < (windowStartTimeDampf + (unsigned long)OutputDampf)); bool preventOverheatActive = (preventHeatAboveSetpointDampf && (InputDampf > SetpointDampf)) || (ecoModeAktiv && (InputDampf > SetpointDampf)); if (pidWantsHeat && !preventOverheatActive) { digitalWrite(SSR_DAMPF_PIN, HIGH); } else { digitalWrite(SSR_DAMPF_PIN, LOW); } } // Shot-Timer updateShotTimer(); // Eco-Modus if (!autoTuneWasserActive && !autoTuneDampfActive && !wartungsModusAktiv) { if (ecoModeMinutes > 0 && !shotActive) { unsigned long currentTimeEco = millis(); if (currentTimeEco - lastShotTime > (ecoModeMinutes * 60UL * 1000UL)) { if (!ecoModeAktiv) { ecoModeActivatedTime = currentTimeEco; } ecoModeAktiv = true; if (dynamicEcoActive) { int minutesPassed = (currentTimeEco - ecoModeActivatedTime) / (60UL * 1000UL); SetpointWasser = (ecoModeTempWasser - minutesPassed > 0 ? ecoModeTempWasser - minutesPassed : 0); SetpointDampf = (ecoModeTempDampf - minutesPassed > 0 ? ecoModeTempDampf - minutesPassed : 0); } else { SetpointWasser = ecoModeTempWasser; SetpointDampf = ecoModeTempDampf; } } else { if (ecoModeAktiv) { EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); } ecoModeAktiv = false; ecoModeActivatedTime = 0; } } else if (ecoModeAktiv) { ecoModeAktiv = false; ecoModeActivatedTime = 0; EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); } } // Display regelmäßig updaten updateDisplay(); // server.handleClient(); // Entfernt für AsyncWebServer MDNS.update(); // mDNS-Anfragen bearbeiten (bleibt bestehen) // Betriebszeit zählen static unsigned long lastRuntimeSecond = 0; if (millis() - lastRuntimeSecond >= 1000) { totalRuntime++; lastRuntimeSecond = millis(); } // Speichern der Betriebszeit und WiFi-Check if (!demoModus) { unsigned long currentMillisLoop = millis(); unsigned long intervalToUse = initialRuntimeSaveDone ? subsequentRuntimeSaveInterval : initialRuntimeSaveDelay; if (currentMillisLoop - lastRuntimeSave >= intervalToUse) { EEPROM.put(EEPROM_ADDR_RUNTIME, totalRuntime); EEPROM.commit(); lastRuntimeSave = currentMillisLoop; if (!initialRuntimeSaveDone) { initialRuntimeSaveDone = true; Serial.println("Betriebszeit: Erstes Speichern nach 10 Minuten erfolgt."); } else { Serial.println("Betriebszeit: Regelmäßiges Speichern (alle 5 Minuten) erfolgt."); } if (WiFi.getMode() == WIFI_STA) { checkWifiConnection(); } } } } /************************************************************************************ * Liest die Temperatur vom NTC-Sensor (Wasser) über den Spannungsteiler (ADC) - Unverändert ************************************************************************************/ double readNTCTemperature() { int adcValue = analogRead(A0); // Prüfen auf ungültige ADC-Werte (können bei Problemen auftreten) if (adcValue <= 0 || adcValue >= 1023) // 1023 ist max. Wert, kann auf Kurzschluss hindeuten { Serial.print("WARNUNG: Ungültiger ADC-Wert vom NTC-Sensor: "); Serial.println(adcValue); return NAN; // Not-a-Number als Fehlerwert zurückgeben } double sensorVoltage = adcValue * (ADC_REF / 1023.0); // Vermeide Division durch Null, falls sensorVoltage == V_SUPPLY if (sensorVoltage >= V_SUPPLY) { Serial.println("WARNUNG: NTC-Spannung entspricht Versorgungsspannung, R_ntc wäre unendlich."); return NAN; // Fehler } // Vermeide Division durch Null, falls sensorVoltage == 0 (bereits oben abgefangen, aber zur Sicherheit) if (sensorVoltage <= 0) { Serial.println("WARNUNG: NTC-Spannung ist 0 oder negativ."); return NAN; // Fehler } double R_ntc = R_FIXED * ((V_SUPPLY / sensorVoltage) - 1.0); // Prüfen auf negativen oder sehr kleinen Widerstandswert if (R_ntc <= 0) { Serial.print("WARNUNG: Berechneter NTC-Widerstand ungültig: "); Serial.println(R_ntc); return NAN; // Fehler } // Temperaturberechnung über die Beta-Gleichung // log() erwartet einen positiven Wert double log_R_R0 = log(R_ntc / R0); double temp_inv_K = (1.0 / T0) + (1.0 / beta) * log_R_R0; // Prüfen auf Division durch Null bei der Invertierung if (temp_inv_K == 0) { Serial.println("WARNUNG: Kehrwert der Kelvin-Temperatur ist 0."); return NAN; // Fehler } double temperatureK = 1.0 / temp_inv_K; double temperatureC = temperatureK - 273.15; // Optional: Plausibilitätsprüfung des Ergebnisses // if (temperatureC < -50 || temperatureC > 200) { // Beispielgrenzen // Serial.print("WARNUNG: Berechnete NTC-Temperatur unplausibel: "); // Serial.println(temperatureC); // return NAN; // } return temperatureC; } /************************************************************************************ * Aktualisiert das Display (Temperaturanzeige, Shot-Timer, Wartung etc.) - Angepasst * Priorität: PID Tuning > Fast Heat Up > Aktiver Shot > Letzte Shot-Zeit > Wartung > Temp/Shutdown/Fehler ************************************************************************************/ void updateDisplay() { // 0. Höchste Priorität: Wartungsmodus aktiv? -> Eigene Anzeige, dann Ende. if (wartungsModusAktiv) { display.clearDisplay(); display.setCursor(0, 0); display.setTextSize(1); display.setTextColor(SH110X_WHITE); display.println(F("Wartungsmodus")); display.println(F("ist aktiv!")); display.println(F("")); display.println(F("Temperaturen werden")); display.println(F("auf ")); display.print(wartungsModusTemp); display.print(F(" ")); display.print((char)247); // Grad-Symbol display.print(F("C gehalten.")); // WiFi Symbol trotzdem anzeigen, falls verbunden if (WiFi.status() == WL_CONNECTED) { drawwifiSymbol(113, 0); } display.display(); return; // Funktion hier beenden } // 1. Höchste Priorität: PID Tuning aktiv? -> Eigene Anzeige, dann Ende. if (autoTuneWasserActive || autoTuneDampfActive) { display.clearDisplay(); display.setCursor(0, 0); display.setTextSize(1); display.setTextColor(SH110X_WHITE); display.println(F("PID-Tuning aktiv:")); display.println(autoTuneWasserActive ? F("Wasser-PID") : F("Dampf-PID")); display.println(""); // Statusvariablen abfragen und anzeigen int currentPeakCount = 0; int currentPeakType = 0; if (autoTuneWasserActive && autoTuneWasser != nullptr) { // Sicherstellen, dass Objekt existiert currentPeakCount = autoTuneWasser->getPeakCount(); currentPeakType = autoTuneWasser->getPeakType(); } else if (autoTuneDampfActive && autoTuneDampf != nullptr) { // Sicherstellen, dass Objekt existiert currentPeakCount = autoTuneDampf->getPeakCount(); currentPeakType = autoTuneDampf->getPeakType(); } display.print(F("Cycle: ")); if (currentPeakCount < 0 || currentPeakCount > 10) { // Falls kleiner 0 oder größer 10 (kann bei Start vorkommen) display.print(F("0")); } else { // Zeigt an, wie viele Zyklen/Phasen erkannt wurden display.print(currentPeakCount); } display.print(F(" von 5-10")); display.println(); // Zeilenumbruch display.print(F("Phase:")); if (currentPeakType == 1) { display.print(F("Heizen")); // Letztes war/suche Maximum } else if (currentPeakType == -1) { display.print(F("K\201hlen")); // Letztes war/suche Minimum } else { display.print(F("Init")); // Initialisierungsphase } display.println(); // Zeilenumbruch if (autoTuneWasserActive) { display.print("Temperatur:"); display.print((int)InputWasser); display.print(F(" ")); display.print((char)247); // Grad-Symbol display.print(F("C")); } if (autoTuneDampfActive) { display.print("Temperatur:"); display.print((int)InputDampf); display.print(F(" ")); display.print((char)247); // Grad-Symbol display.print(F("C")); } // WiFi Symbol trotzdem anzeigen, falls verbunden if (WiFi.status() == WL_CONNECTED) { drawwifiSymbol(113, 0); } display.display(); return; // Funktion hier beenden } // --- Keine Tuning-Anzeige --- display.clearDisplay(); // WiFi Symbol (immer anzeigen, wenn verbunden) if (WiFi.status() == WL_CONNECTED) { drawwifiSymbol(113, 0); } display.setCursor(0, 0); display.setTextColor(SH110X_WHITE); display.setTextSize(1); // 2. Priorität: Fast-Heat-Up aktiv und relevant? if (fastHeatUpHeating && !shotActive && !wasserSensorError && ((int)InputWasser < fastHeatUpSetpoint)) { SetpointWasser = fastHeatUpSetpoint; // Setpoint temporär anpassen für Anzeige display.println(F("Fast-Heat-Up aktiv:")); display.println(""); display.println(F("20 Sek. Flushen,")); display.println(F("wenn Temp. erreicht!")); display.println(F(" ")); // Leere Zeile display.println(F("Temperatur: ")); display.print((int)InputWasser); display.print(F(" / ")); display.print((int)SetpointWasser); // Zeigt den F-H-U Setpoint display.print(F(" ")); display.print((char)247); // Grad-Symbol display.print(F("C")); // Hier keine Dampfanzeige während Fast-Heat-Up, um Platz zu sparen } // 3. Kein Fast-Heat-Up: Was soll sonst angezeigt werden? else { // 3a. Priorität: Shot läuft *aktuell*? if (shotActive) { unsigned long elapsed = millis() - shotStartTime; display.println(F("Shot-Timer:")); display.println(""); display.setTextSize(3); // Größere Schrift für den Timer // Zeige nur eine Nachkommastelle an char buffer[6]; // Puffer für "xx.y" + Nullterminator snprintf(buffer, sizeof(buffer), "%.1f", elapsed / 1000.0); display.println(buffer); display.setTextSize(1); // Zurück zur normalen Schriftgröße display.println(F("Sekunden")); fastHeatUpHeating = false; // Laufender Shot beendet F-H-U Anzeige visuell } // 3b. Priorität: Letzte Shot-Zeit anzeigen (für 2 Sekunden)? else if (shotEndTime > 0 && millis() < shotEndTime + 2000) { display.println(F("Bezugszeit:")); display.println(""); display.setTextSize(3); // Größere Schrift // Zeige nur eine Nachkommastelle an char buffer[6]; snprintf(buffer, sizeof(buffer), "%.1f", lastShotDurationMillis / 1000.0); display.println(buffer); display.setTextSize(1); // Zurück zur normalen Schriftgröße display.println(F("Sekunden")); } // 3c. Priorität: Wartungserinnerung anzeigen (für 6 Sekunden nach Trigger) // Diese Bedingung wird nur erreicht, wenn kein Shot aktiv ist UND die letzte Shot-Zeit nicht angezeigt wird. else if (displayMaintenanceMessage && millis() < maintenanceMessageStartTime + 6000) { display.println(F("Erinnerung:")); // Titel display.println(F("Reinigung /")); // Zeile 1 display.println(F("Wartung fällig!")); // Zeile 2 // Restliche Zeilen leer lassen oder für Details nutzen display.println(""); display.println(""); display.println(""); } // 3d. Standardanzeige: Temperaturen / Fehler / Sicherheitsabschaltung else { display.println(F("Temperaturen:")); // Titelzeile display.println(""); // Leerzeile // Wasser Temperatur / Sicherheitsabschaltung / Fehleranzeige display.println(F("Wasser: ")); // Print, kein Zeilenumbruch if (wasserSafetyShutdown) { // Prüfung 1: Sicherheitsabschaltung display.print(F("Sicherheitsabsch.")); } else if (wasserSensorError) { // Prüfung 2: Echter Sensorfehler display.print(F("Sensorfehler!")); } else { // Prüfung 3: Alles OK -> Temperatur anzeigen display.print((int)InputWasser); display.print(F(" / ")); display.print((int)SetpointWasser); display.print(F(" ")); display.print((char)247); // Grad-Symbol display.print(F("C")); // ECO-Modus Anzeige anhängen, falls aktiv if (ecoModeAktiv && dynamicEcoActive) display.print(F(" (ECO+)")); else if (ecoModeAktiv && !dynamicEcoActive) display.print(F(" (ECO)")); } display.println(""); // Zeilenumbruch nach der Wasserzeile display.println(""); // Leere Zeile für Abstand // Dampf Temperatur / Sicherheitsabschaltung / Fehleranzeige display.println(F("Dampf: ")); // Print, kein Zeilenumbruch, 2 Leerzeichen für Ausrichtung if (dampfSafetyShutdown) { // Prüfung 1: Sicherheitsabschaltung display.print(F("Sicherheitsabsch.")); } else if (dampfSensorError) { // Prüfung 2: Echter Sensorfehler display.print(F("Sensorfehler!")); } else { // Prüfung 3: Alles OK -> Temperatur anzeigen (oder Verzögerung) // Prüfen auf Startverzögerung (wie vorher) bool steamDelayActive = (dampfVerzoegerung > 0 && (millis() - startupTime < (unsigned long)dampfVerzoegerung * 60000UL)); if (!steamDelayActive) // Wenn keine Verzögerung aktiv ist { display.print((int)InputDampf); display.print(F(" / ")); display.print((int)SetpointDampf); display.print(F(" ")); display.print((char)247); // Grad-Symbol display.print(F("C")); // ECO-Modus Anzeige anhängen, falls aktiv if (ecoModeAktiv && dynamicEcoActive) display.print(F(" (ECO+)")); else if (ecoModeAktiv && !dynamicEcoActive) display.print(F(" (ECO)")); } else { // Wenn Startverzögerung aktiv ist display.print(F("Startverz\x94gerung")); } } // Kein println() am Ende der letzten Zeile nötig } // Ende Standardanzeige (Temperaturen etc.) } // Ende "Kein Fast-Heat-Up" display.display(); // Display *immer* am Ende aktualisieren, egal was angezeigt wurde } /************************************************************************************ * Verwaltet den Shot-Timer (wenn z.B. der Nutzer den Bezug aktiviert) - Wartungscheck hinzugefügt ************************************************************************************/ void updateShotTimer() { // Wenn Wartungsmodus aktiv ist, diese Funktion sofort verlassen if (wartungsModusAktiv) { // Sicherstellen, dass ein evtl. laufender Shot als beendet gilt, // falls der Modus während eines Shots aktiviert wird. if (shotActive) { shotActive = false; shotStartTime = 0; // Zurücksetzen für Sauberkeit shotEndTime = 0; lastShotDurationMillis = 0; } return; // Verhindert die restliche Logik der Funktion } if (digitalRead(SHOT_TIMER_PIN) == HIGH) // Annahme: HIGH = Bezug aktiv { // Shot beginnt if (!shotActive) { shotActive = true; shotStartTime = millis(); lastShotDurationMillis = 0; // Dauer für neuen Shot zurücksetzen shotEndTime = 0; // Endzeit für neuen Shot zurücksetzen displayMaintenanceMessage = false; // Sicherstellen, dass alte Meldung weg ist } } else // Annahme: LOW = Bezug inaktiv { // Shot endet if (shotActive) { unsigned long shotDuration = millis() - shotStartTime; lastShotDurationMillis = shotDuration; // *** Letzte Dauer speichern *** shotEndTime = millis(); // *** Endzeitpunkt speichern *** // nur Shots > 20 Sekunden zählen if (shotDuration > 20000) { shotCounter++; maintenanceIntervalCounter++; // Shot in Log schreiben logShot(shotDuration); if (!demoModus) { // Nur speichern, wenn kein Demo-Modus EEPROM.put(EEPROM_ADDR_SHOTCOUNTER, shotCounter); EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter); EEPROM.commit(); } Serial.print(F("Shots: ")); Serial.println(shotCounter); // Prüfen, ob Wartungsintervall erreicht ist if (maintenanceInterval > 0 && maintenanceIntervalCounter >= maintenanceInterval) { displayMaintenanceMessage = true; // Flag für Anzeige setzen maintenanceMessageStartTime = millis(); // Startzeit für Anzeige merken Serial.println("Wartungsintervall erreicht!"); } } shotActive = false; // Shot als beendet markieren lastShotTime = millis(); // Wichtig für Eco-Modus (bleibt wie es ist) // shotEndTime und delayDisplayUpdate werden nicht mehr benötigt (bzw. shotEndTime hat neue Bedeutung) } } } /************************************************************************************ * Startet AutoTune für Wasser-PID (Angepasst für Zeitproportional) ************************************************************************************/ void startAutoTuneWasser() { // Eco-Modus deaktivieren, falls aktiv if (ecoModeAktiv) { ecoModeAktiv = false; // Ursprünglichen Setpoint wiederherstellen (aus EEPROM oder Profil) EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); // Optional: Auch Dampf-Setpoint wiederherstellen, falls Eco beide beeinflusst EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); } // Stelle sicher, dass der PID im Automatikmodus ist bevor Tuning beginnt pidWasser.SetMode(AUTOMATIC); // Sicherstellen, dass die Output-Grenzen korrekt gesetzt sind pidWasser.SetOutputLimits(0, windowSizeWasser); autoTuneWasser = new PID_ATune(&InputWasser, &OutputWasser); // Verwende die Wasser-spezifischen Parameter autoTuneWasser->SetOutputStep(tuningStepWasser); // Angepasst autoTuneWasser->SetControlType(1); // DIRECT control (heating) autoTuneWasser->SetNoiseBand(tuningNoiseWasser); // Angepasst autoTuneWasser->SetLookbackSec((int)tuningLookBackWasser); // Angepasst (Cast zu int für Funktion) // Setze den Startwert für den Output // WICHTIG: AutoTune manipuliert OutputWasser direkt. Die zeitproportionale // Logik in loop() wird diesen Wert verwenden, um den SSR zu steuern. OutputWasser = tuningStartValueWasser; // Angepasst // KEIN direktes digitalWrite mehr hier! Die loop() übernimmt das. autoTuneWasserActive = true; Serial.println("Starte AutoTune für Wasser (Zeitproportional)..."); } /************************************************************************************ * Startet AutoTune für Dampf-PID (Angepasst für Zeitproportional) ************************************************************************************/ void startAutoTuneDampf() { // Eco-Modus deaktivieren, falls aktiv if (ecoModeAktiv) { ecoModeAktiv = false; // Ursprünglichen Setpoint wiederherstellen (aus EEPROM oder Profil) EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); // Optional: Auch Wasser-Setpoint wiederherstellen, falls Eco beide beeinflusst EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); } // Stelle sicher, dass der PID im Automatikmodus ist bevor Tuning beginnt pidDampf.SetMode(AUTOMATIC); // Sicherstellen, dass die Output-Grenzen korrekt gesetzt sind pidDampf.SetOutputLimits(0, windowSizeDampf); autoTuneDampf = new PID_ATune(&InputDampf, &OutputDampf); // Verwende die Dampf-spezifischen Parameter autoTuneDampf->SetOutputStep(tuningStepDampf); // Angepasst autoTuneDampf->SetControlType(1); // DIRECT control (heating) autoTuneDampf->SetNoiseBand(tuningNoiseDampf); // Angepasst autoTuneDampf->SetLookbackSec((int)tuningLookBackDampf); // Angepasst (Cast zu int für Funktion) // Setze den Startwert für den Output // WICHTIG: AutoTune manipuliert OutputDampf direkt. Die zeitproportionale // Logik in loop() wird diesen Wert verwenden, um den SSR zu steuern. OutputDampf = tuningStartValueDampf; // Angepasst // KEIN direktes digitalWrite mehr hier! Die loop() übernimmt das. autoTuneDampfActive = true; Serial.println("Starte AutoTune für Dampf (Zeitproportional)..."); } /************************************************************************************ * Stoppt AutoTune für Wasser - Unverändert ************************************************************************************/ void stopAutoTuneWasser() { if (autoTuneWasserActive) { autoTuneWasserActive = false; delete autoTuneWasser; autoTuneWasser = nullptr; // Setze PID zurück in den Normalbetrieb (ggf. Output auf 0) pidWasser.SetMode(AUTOMATIC); // Sicherstellen, dass PID wieder normal läuft OutputWasser = 0; // Optional: Output zurücksetzen // Stelle sicher, dass SSR ausgeschaltet ist digitalWrite(SSR_WASSER_PIN, LOW); Serial.println("AutoTune Wasser gestoppt."); } } /************************************************************************************ * Stoppt AutoTune für Dampf - Unverändert ************************************************************************************/ void stopAutoTuneDampf() { if (autoTuneDampfActive) { autoTuneDampfActive = false; delete autoTuneDampf; autoTuneDampf = nullptr; // Setze PID zurück in den Normalbetrieb (ggf. Output auf 0) pidDampf.SetMode(AUTOMATIC); // Sicherstellen, dass PID wieder normal läuft OutputDampf = 0; // Optional: Output zurücksetzen // Stelle sicher, dass SSR ausgeschaltet ist digitalWrite(SSR_DAMPF_PIN, LOW); Serial.println("AutoTune Dampf gestoppt."); } } /************************************************************************************ * Überwacht die AutoTune-Prozesse (Dampf/Wasser) * - Prüft auf Sensorfehler ODER Sicherheitsabschaltung vor Ausführung * - Display-Logik ist in updateDisplay() ausgelagert ************************************************************************************/ void handleAutoTune() { // --- AutoTune Wasser --- if (autoTuneWasserActive) { // Erweiterte Prüfung auf Sensorfehler ODER Sicherheitsabschaltung während des Tunings if (wasserSensorError || wasserSafetyShutdown) { // Logge den spezifischen Grund für den Abbruch if (wasserSensorError) { Serial.println("FEHLER: Wassertemperatursensor während AutoTune ausgefallen! Breche Tuning ab."); } else { // Muss wasserSafetyShutdown sein Serial.println("WARNUNG: Sicherheitslimit Wasser während AutoTune überschritten! Breche Tuning ab."); } stopAutoTuneWasser(); // Ruft Funktion auf, die Flag zurücksetzt, Speicher freigibt und SSR ausschaltet return; // Verlasse die Funktion für diesen Durchlauf, um Runtime() nicht auszuführen } // Nur wenn kein Fehler/Shutdown vorliegt, führe AutoTune->Runtime() aus. // Diese Funktion berechnet und SETZT OutputWasser für den nächsten Schritt! if (autoTuneWasser->Runtime() == 1) // Runtime() gibt 1 zurück, wenn Tuning abgeschlossen { // Tuning abgeschlossen, Werte holen KpWasser = autoTuneWasser->GetKp(); KiWasser = autoTuneWasser->GetKi(); KdWasser = autoTuneWasser->GetKd(); // WICHTIG: PID-Tunings sofort anwenden pidWasser.SetTunings(KpWasser, KiWasser, KdWasser); // Neue Werte im EEPROM speichern EEPROM.put(EEPROM_ADDR_KP_WASSER, KpWasser); EEPROM.put(EEPROM_ADDR_KI_WASSER, KiWasser); EEPROM.put(EEPROM_ADDR_KD_WASSER, KdWasser); EEPROM.commit(); Serial.println("AutoTune Wasser erfolgreich abgeschlossen."); Serial.printf("Neue Werte: Kp=%.2f, Ki=%.2f, Kd=%.2f\n", KpWasser, KiWasser, KdWasser); // AutoTune-Prozess korrekt beenden stopAutoTuneWasser(); } // Hinweis: Die Display-Anzeige für laufendes Tuning wird in updateDisplay() gehandhabt. } // --- AutoTune Dampf --- if (autoTuneDampfActive) { // Erweiterte Prüfung auf Sensorfehler ODER Sicherheitsabschaltung während des Tunings if (dampfSensorError || dampfSafetyShutdown) { // Logge den spezifischen Grund für den Abbruch if (dampfSensorError) { Serial.println("FEHLER: Dampftemperatursensor während AutoTune ausgefallen! Breche Tuning ab."); } else { // Muss dampfSafetyShutdown sein Serial.println("WARNUNG: Sicherheitslimit Dampf während AutoTune überschritten! Breche Tuning ab."); } stopAutoTuneDampf(); // Ruft Funktion auf, die Flag zurücksetzt, Speicher freigibt und SSR ausschaltet return; // Verlasse die Funktion für diesen Durchlauf, um Runtime() nicht auszuführen } // Nur wenn kein Fehler/Shutdown vorliegt, führe AutoTune->Runtime() aus. // Diese Funktion berechnet und SETZT OutputDampf für den nächsten Schritt! if (autoTuneDampf->Runtime() == 1) // Runtime() gibt 1 zurück, wenn Tuning abgeschlossen { // Tuning abgeschlossen, Werte holen KpDampf = autoTuneDampf->GetKp(); KiDampf = autoTuneDampf->GetKi(); KdDampf = autoTuneDampf->GetKd(); // WICHTIG: PID-Tunings sofort anwenden pidDampf.SetTunings(KpDampf, KiDampf, KdDampf); // Neue Werte im EEPROM speichern EEPROM.put(EEPROM_ADDR_KP_DAMPF, KpDampf); EEPROM.put(EEPROM_ADDR_KI_DAMPF, KiDampf); EEPROM.put(EEPROM_ADDR_KD_DAMPF, KdDampf); EEPROM.commit(); Serial.println("AutoTune Dampf erfolgreich abgeschlossen."); Serial.printf("Neue Werte: Kp=%.2f, Ki=%.2f, Kd=%.2f\n", KpDampf, KiDampf, KdDampf); // AutoTune-Prozess korrekt beenden stopAutoTuneDampf(); } // Hinweis: Die Display-Anzeige für laufendes Tuning wird in updateDisplay() gehandhabt. } } /************************************************************************************ * PROGMEM Chunks für die Info-Seite (/Info) ************************************************************************************/ // --- Kopfzeile --- static const char infoHtmlHead[] PROGMEM = R"rawliteral( Info )rawliteral"; static const char infoHtmlHeadEnd[] PROGMEM = R"rawliteral( )rawliteral"; // --- Geräteinfo Formular --- static const char infoChunk_BodyStart[] PROGMEM = R"rawliteral(

Info

Geräteinfo

Die Geräteinformationen werden beim Start der Maschine, bzw. PID-Controllers im Display angezeigt.

)rawliteral"; // Ende Geräteinfo-Formular // --- Betriebszeit Reset Formular --- static const char infoChunk_RuntimeResetForm[] PROGMEM = R"rawliteral(

Betriebszeit:

)rawliteral"; // Endet vor formatierter Betriebszeit static const char infoChunk_AfterRuntime[] PROGMEM = R"rawliteral(
)rawliteral"; // Ende Betriebszeit-Reset-Formular // --- Shot Zähler Reset Formular --- static const char infoChunk_ShotCounterResetForm[] PROGMEM = R"rawliteral(

Shots: (Bezüge über 20 Sekunden)

Anzahl: )rawliteral"; // Endet vor Shot-Zähler-Wert static const char infoChunk_ShotCounterFormEnd[] PROGMEM = R"rawliteral(
)rawliteral"; // Ende Shot-Zähler-Reset-Formular // --- Statistik Chunks --- static const char infoChunk_StatsStart[] PROGMEM = R"rawliteral(

Statistik

)rawliteral"; // Start Statistik-Sektion static const char infoChunk_StatsTotal[] PROGMEM = R"rawliteral(Bezüge in der Statistik: )rawliteral"; static const char infoChunk_StatsAvg[] PROGMEM = R"rawliteral(
Durchschnittliche Bezugsdauer: )rawliteral"; static const char infoChunk_StatsAvgPerDay[] PROGMEM = R"rawliteral(
Durchschnittliche Bezüge pro Tag: )rawliteral"; static const char infoChunk_StatsToday[] PROGMEM = R"rawliteral(

Anzahl Bezüge:
Heute: )rawliteral"; static const char infoChunk_StatsYesterday[] PROGMEM = R"rawliteral(
Gestern: )rawliteral"; static const char infoChunk_StatsWeek[] PROGMEM = R"rawliteral(
Diese Woche: )rawliteral"; static const char infoChunk_StatsLastWeek[] PROGMEM = R"rawliteral(
Letzte Woche: )rawliteral"; static const char infoChunk_StatsMonth[] PROGMEM = R"rawliteral(
Dieses Monat: )rawliteral"; static const char infoChunk_StatsLastMonth[] PROGMEM = R"rawliteral(
Letzes Monat: )rawliteral"; static const char infoChunk_StatsFirst[] PROGMEM = R"rawliteral(

Erster geloggter Bezug: )rawliteral"; static const char infoChunk_StatsLast[] PROGMEM = R"rawliteral(
Letzter geloggter Bezug: )rawliteral"; static const char infoChunk_StatsTimeError[] PROGMEM = R"rawliteral(
(Datum/Zeit-basierte Zähler benötigen aktive WLAN-Verbindung und NTP-Synchronisation))rawliteral"; // --- Statistik Download static const char infoChunk_StatsDownloadButton[] PROGMEM = R"rawliteral(
)rawliteral"; // --- Statistik Löschen static const char infoChunk_DeleteStatsForm[] PROGMEM = R"rawliteral(

Nutzungsstatistik zurücksetzen

Die aktuelle Statistik wird gelöscht und es kann eine neue Statistik begonnen werden.
)rawliteral"; static const char infoChunk_StatsEnd[] PROGMEM = R"rawliteral( )rawliteral"; // Ende Statistik-Sektion (mit Verlauf) static const char infoChunk_StatsNotAvailable[] PROGMEM = R"rawliteral(

Shot-Verlauf Statistik

Kein Shot-Verlauf gefunden oder Datei ist leer.

(Bezüge werden erst nach erfolgreicher NTP-Zeitsynchronisation geloggt.
Hierzu wird eine Internetverbindung benötigt!)

)rawliteral"; // Meldung, falls kein Verlauf da // --- Systeminfo Chunks (Angepasst für NTP-Status) --- static const char infoChunk_SysInfoStart[] PROGMEM = R"rawliteral(

Systeminfo

Freier Heap: )rawliteral"; // Endet VOR dem Heap-Wert // NEU/Angepasst: Nach dem Heap-Wert und vor dem NTP-Statuswert static const char infoChunk_AfterFreeHeap[] PROGMEM = R"rawliteral( Bytes
NTP Zeit synchronisiert: )rawliteral"; // Endet VOR dem NTP-Status (Ja/Nein) // NEU: Nach dem NTP-Statuswert und vor der SDK-Version static const char infoChunk_AfterNTPStatus[] PROGMEM = R"rawliteral(
SDK-Version: )rawliteral"; // Endet VOR dem SDK-Wert // Bestehende Chunks für restliche Systeminfos static const char infoChunk_AfterSDK[] PROGMEM = R"rawliteral(
Core-Version: )rawliteral"; static const char infoChunk_AfterCore[] PROGMEM = R"rawliteral(
Boot-Version: )rawliteral"; static const char infoChunk_AfterBoot[] PROGMEM = R"rawliteral(
Chip-ID: )rawliteral"; static const char infoChunk_AfterChipID[] PROGMEM = R"rawliteral(
CPU-Takt: )rawliteral"; static const char infoChunk_AfterCPU[] PROGMEM = R"rawliteral( MHz
Letzter Reset-Grund: )rawliteral"; static const char infoChunk_AfterResetReason[] PROGMEM = R"rawliteral(

Sketch-Größe: )rawliteral"; static const char infoChunk_AfterSketchSize[] PROGMEM = R"rawliteral( Bytes
Freier Sketch-Speicher: )rawliteral"; static const char infoChunk_AfterFreeSketch[] PROGMEM = R"rawliteral( Bytes
Flash-Größe (Chip): )rawliteral"; static const char infoChunk_End[] PROGMEM = R"rawliteral( Bytes
)rawliteral"; // Ende Systeminfo und HTML-Seite /************************************************************************************ * Handler für die Info-Seite - Inkl. NTP-Status, Download-Button, * neuer Statistiken und Lösch-Button ************************************************************************************/ void handleInfo(AsyncWebServerRequest *request) { char buffer[40]; char dateBuffer[25]; char tempStringCopyBuffer[50]; AsyncResponseStream *response = request->beginResponseStream("text/html"); // --- Kopfzeile, Styles, Navigation senden --- response->print(FPSTR(infoHtmlHead)); response->print("\n"); response->print(FPSTR(infoHtmlHeadEnd)); response->print(FPSTR(commonNav)); // --- Geräteinfo Formular --- response->print(FPSTR(infoChunk_BodyStart)); strncpy(tempStringCopyBuffer, infoHersteller, sizeof(tempStringCopyBuffer) - 1); tempStringCopyBuffer[sizeof(tempStringCopyBuffer) - 1] = '\0'; if (strlen(tempStringCopyBuffer) > 0) { response->print(tempStringCopyBuffer); } response->print(FPSTR(infoChunk_AfterHersteller)); strncpy(tempStringCopyBuffer, infoModell, sizeof(tempStringCopyBuffer) - 1); tempStringCopyBuffer[sizeof(tempStringCopyBuffer) - 1] = '\0'; if (strlen(tempStringCopyBuffer) > 0) { response->print(tempStringCopyBuffer); } response->print(FPSTR(infoChunk_AfterModell)); strncpy(tempStringCopyBuffer, infoZusatz, sizeof(tempStringCopyBuffer) - 1); tempStringCopyBuffer[sizeof(tempStringCopyBuffer) - 1] = '\0'; if (strlen(tempStringCopyBuffer) > 0) { response->print(tempStringCopyBuffer); } response->print(FPSTR(infoChunk_AfterZusatz)); // --- Betriebszeit Reset Formular --- response->print(FPSTR(infoChunk_RuntimeResetForm)); unsigned long totalSeconds = totalRuntime; unsigned long days = totalSeconds / 86400; unsigned long hours = (totalSeconds % 86400) / 3600; unsigned long minutes = (totalSeconds % 3600) / 60; snprintf(buffer, sizeof(buffer), "%lu", days); response->print(buffer); response->print(F(" Tag(e), ")); snprintf(buffer, sizeof(buffer), "%lu", hours); response->print(buffer); response->print(F(" Stunde(n) und ")); snprintf(buffer, sizeof(buffer), "%lu", minutes); response->print(buffer); response->print(F(" Minuten")); response->print(FPSTR(infoChunk_AfterRuntime)); // --- Shot Zähler Reset Formular --- response->print(FPSTR(infoChunk_ShotCounterResetForm)); snprintf(buffer, sizeof(buffer), "%lu", shotCounter); response->print(buffer); response->print(FPSTR(infoChunk_ShotCounterFormEnd)); // --- Shot-Verlauf Statistik --- ShotStats stats = calculateShotStatistics(); if (stats.historyAvailable) { response->print(FPSTR(infoChunk_StatsStart)); response->print(FPSTR(infoChunk_StatsTotal)); snprintf(buffer, sizeof(buffer), "%lu", stats.totalShotsLogged); response->print(buffer); response->print(FPSTR(infoChunk_StatsAvg)); snprintf(buffer, sizeof(buffer), "%.1f", stats.averageDurationSec); response->print(buffer); response->print(F(" s")); response->print(FPSTR(infoChunk_StatsAvgPerDay)); snprintf(buffer, sizeof(buffer), "%.1f", stats.avgShotsPerDay); response->print(buffer); response->print(FPSTR(infoChunk_StatsToday)); snprintf(buffer, sizeof(buffer), "%lu", stats.shotsToday); response->print(buffer); response->print(FPSTR(infoChunk_StatsYesterday)); snprintf(buffer, sizeof(buffer), "%lu", stats.shotsYesterday); response->print(buffer); response->print(FPSTR(infoChunk_StatsWeek)); snprintf(buffer, sizeof(buffer), "%lu", stats.shotsThisWeek); response->print(buffer); response->print(FPSTR(infoChunk_StatsLastWeek)); snprintf(buffer, sizeof(buffer), "%lu", stats.shotsLastWeek); response->print(buffer); response->print(FPSTR(infoChunk_StatsMonth)); snprintf(buffer, sizeof(buffer), "%lu", stats.shotsThisMonth); response->print(buffer); response->print(FPSTR(infoChunk_StatsLastMonth)); snprintf(buffer, sizeof(buffer), "%lu", stats.shotsLastMonth); response->print(buffer); if (stats.firstShotTimestamp > 0) { response->print(FPSTR(infoChunk_StatsFirst)); struct tm first_tm; localtime_r(&stats.firstShotTimestamp, &first_tm); strftime(dateBuffer, sizeof(dateBuffer), "%d.%m.%Y %H:%M", &first_tm); response->print(dateBuffer); response->print(F(" Uhr")); } if (stats.lastShotTimestamp > 0) { response->print(FPSTR(infoChunk_StatsLast)); struct tm last_tm; localtime_r(&stats.lastShotTimestamp, &last_tm); strftime(dateBuffer, sizeof(dateBuffer), "%d.%m.%Y %H:%M", &last_tm); response->print(dateBuffer); response->print(F(" Uhr")); } time_t now_t_stats; time(&now_t_stats); struct tm now_tm_stats; localtime_r(&now_t_stats, &now_tm_stats); if (now_tm_stats.tm_year <= (2023 - 1900)) { response->print(FPSTR(infoChunk_StatsTimeError)); } response->print(FPSTR(infoChunk_StatsDownloadButton)); response->print(FPSTR(infoChunk_StatsEnd)); } else { response->print(FPSTR(infoChunk_StatsNotAvailable)); } response->print(FPSTR(infoChunk_DeleteStatsForm)); // --- Systeminfo --- response->print(FPSTR(infoChunk_SysInfoStart)); snprintf(buffer, sizeof(buffer), "%u", ESP.getFreeHeap()); response->print(buffer); response->print(FPSTR(infoChunk_AfterFreeHeap)); response->print(timeSynced ? F("Ja") : F("Nein")); response->print(FPSTR(infoChunk_AfterNTPStatus)); const char* sdkVersion = ESP.getSdkVersion(); if (sdkVersion && strlen(sdkVersion) > 0) { response->print(sdkVersion); } else { response->print(F("N/A")); } response->print(FPSTR(infoChunk_AfterSDK)); String coreVersionStr = ESP.getCoreVersion(); strncpy(tempStringCopyBuffer, coreVersionStr.c_str(), sizeof(tempStringCopyBuffer) - 1); tempStringCopyBuffer[sizeof(tempStringCopyBuffer) - 1] = '\0'; if (strlen(tempStringCopyBuffer) > 0) response->print(tempStringCopyBuffer); response->print(FPSTR(infoChunk_AfterCore)); snprintf(buffer, sizeof(buffer), "%u", ESP.getBootVersion()); response->print(buffer); response->print(FPSTR(infoChunk_AfterBoot)); #ifdef ESP32 uint64_t chipid = ESP.getEfuseMac(); snprintf(buffer, sizeof(buffer), "%04X%08X", (uint16_t)(chipid >> 32), (uint32_t)chipid); #else snprintf(buffer, sizeof(buffer), "%08X", ESP.getChipId()); #endif response->print(buffer); response->print(FPSTR(infoChunk_AfterChipID)); snprintf(buffer, sizeof(buffer), "%u", ESP.getCpuFreqMHz()); response->print(buffer); response->print(FPSTR(infoChunk_AfterCPU)); String resetReasonStr = ESP.getResetReason(); strncpy(tempStringCopyBuffer, resetReasonStr.c_str(), sizeof(tempStringCopyBuffer) - 1); tempStringCopyBuffer[sizeof(tempStringCopyBuffer) - 1] = '\0'; if (strlen(tempStringCopyBuffer) > 0) response->print(tempStringCopyBuffer); response->print(FPSTR(infoChunk_AfterResetReason)); snprintf(buffer, sizeof(buffer), "%u", ESP.getSketchSize()); response->print(buffer); response->print(FPSTR(infoChunk_AfterSketchSize)); snprintf(buffer, sizeof(buffer), "%u", ESP.getFreeSketchSpace()); response->print(buffer); response->print(FPSTR(infoChunk_AfterFreeSketch)); snprintf(buffer, sizeof(buffer), "%u", ESP.getFlashChipSize()); response->print(buffer); response->print(FPSTR(infoChunk_End)); // --- Abschluss --- request->send(response); } /************************************************************************************ * PROGMEM Chunks für die Service-Seite (/Service) ************************************************************************************/ // --- Kopfzeile --- static const char serviceHtmlHead[] PROGMEM = R"rawliteral( Service )rawliteral"; // bleibt gleich static const char serviceHtmlHeadEnd[] PROGMEM = R"rawliteral( )rawliteral"; static const char serviceChunk_BodyStart[] PROGMEM = R"rawliteral(

Service & Wartung

)rawliteral"; // --- Wartung Intervall Formular (ehemals infoChunk_MaintenanceForm) --- static const char serviceChunk_MaintenanceForm[] PROGMEM = R"rawliteral(

Reinigung & Wartung

Zeigt nach der eingestellten Anzahl von Bezügen eine Reinigungs-/ Wartungserinnerung im Display an.
)rawliteral"; // Ende Wartungsintervall-Formular // --- Wartungsmodus Sektion (ehemals infoChunk_Wartung...) --- static const char serviceChunk_WartungStart[] PROGMEM = R"rawliteral(

Wartungsmodus (Entkalkung)

Aktiviert einen Modus zur einfachen Entkalkung der Maschine.
Im Wartungsmodus ist das Heizen von Wasser und Dampf deaktiviert.
Bezüge werden in diesem Modus nicht gezählt.

Aktueller Status: )rawliteral"; // Endet vor dem Status (Aktiv/Inaktiv) static const char serviceChunk_WartungButton[] PROGMEM = R"rawliteral(

)rawliteral"; // Ende Wartungsmodus-Formular // --- Wartungszähler Reset Formular (ehemals infoChunk_MaintenanceResetForm) --- static const char serviceChunk_MaintenanceResetForm[] PROGMEM = R"rawliteral(

Wartungszähler zurücksetzen

Aktueller Zählerstand: )rawliteral"; // Endet vor Wartungszähler-Wert static const char serviceChunk_AfterMaintCounter[] PROGMEM = R"rawliteral(

Setzt den Zähler für die Reinigungs- & Wartungserinnerung zurück.
Dies ist nach der erfolgreichen Durchführung notwendig,
damit die Meldung nicht nach jedem Bezug erneut scheint.
)rawliteral"; // Ende Wartungszähler-Reset-Formular static const char serviceChunk_BodyEnd[] PROGMEM = R"rawliteral( )rawliteral"; /************************************************************************************ * Handler für die Service-Seite (/Service) ************************************************************************************/ void handleService(AsyncWebServerRequest *request) { char buffer[20]; AsyncResponseStream *response = request->beginResponseStream("text/html"); // --- Kopfzeile, Styles, Navigation senden --- response->print(FPSTR(serviceHtmlHead)); response->print("\n"); response->print(FPSTR(serviceHtmlHeadEnd)); response->print(FPSTR(commonNav)); // --- Body Start --- response->print(FPSTR(serviceChunk_BodyStart)); // --- Wartung Intervall Formular --- response->print(FPSTR(serviceChunk_MaintenanceForm)); snprintf(buffer, sizeof(buffer), "%d", maintenanceInterval); response->print(buffer); response->print(FPSTR(serviceChunk_AfterMaintInterval)); // --- Wartungsmodus Sektion --- response->print(FPSTR(serviceChunk_WartungStart)); response->print(wartungsModusAktiv ? F("Aktiv") : F("Inaktiv")); response->print(FPSTR(serviceChunk_WartungButton)); response->print(wartungsModusAktiv ? F("Deaktivieren") : F("Aktivieren")); response->print(FPSTR(serviceChunk_WartungEnd)); // --- Wartungszähler Reset Formular --- response->print(FPSTR(serviceChunk_MaintenanceResetForm)); snprintf(buffer, sizeof(buffer), "%lu", maintenanceIntervalCounter); response->print(buffer); response->print(FPSTR(serviceChunk_AfterMaintCounter)); // --- Body Ende --- response->print(FPSTR(serviceChunk_BodyEnd)); // --- Abschluss --- request->send(response); } /************************************************************************************ * Handler für die Eco-Modus-Konfiguration - Heap-Optimiert mit Chunked Sending ************************************************************************************/ // --- PROGMEM Chunks für handleEco --- // Kopfzeile bleibt gleich wie im Original `ecoHtml` static const char ecoHtmlHead[] PROGMEM = R"rawliteral( Eco-Modus )rawliteral"; // bleibt gleich wie im Original `ecoHtml2` static const char ecoHtmlHeadEnd[] PROGMEM = R"rawliteral( )rawliteral"; // Aufteilung von ecoHtml3 in Chunks static const char ecoChunk_BodyStart[] PROGMEM = R"rawliteral(

Eco-Modus

Eco-Modus

Der Eco-Modus senkt die voreingestellten Temperaturen nach der angegebenen Zeit auf die Eco-Temperaturen ab.

Dynamischer Eco-Modus

Der dynamische Eco-Modus verhindert, dass die Maschine bis in die Unendlichkeit eine gewisse Temperatur aufrecht erhält.
Er senkt die Temperatur pro Minute um ein weiteres Grad ab, bis letzlich das Heizen vollständig beendet wird.
Der dynamische Eco-Modus ist nur in Kombination mit dem Eco-Modus nutzbar!

Beispiel für eine praktische Anwendung:
Peter hat an seiner Maschine den Eco-Modus auf 45 Minuten gestellt, mit Eco-Temperatur von 60 Grad. Nun kommt ihm etwas dazwischen und er kommt erst bei Minute 60 an die Maschine, um sich einen Espresso zuzubereiten. Die Maschine ist nun allerdings schon auf 60 Grad herabgekühlt und er muss erneut das Aufheizen abwarten ...
Markus passiert das gleiche, doch er hat den dynamischen Eco-Modus mit einer initialen Eco-Temperatur von 95 Grad aktiviert - Seine Maschine ist nun zumindest noch bei 80 Grad.
Markus muss zwar auch warten, kann jedoch noch vor Peter einen Espresso trinken.

Verzögerung für Dampf


ACHTUNG:
Um Konflikte zu vermeiden, sollte der Wert geringer sein, als der des Eco-Modus, falls dieser aktiviert ist!
)rawliteral"; void handleEco(AsyncWebServerRequest *request) { char buffer[12]; AsyncResponseStream *response = request->beginResponseStream("text/html"); response->print(FPSTR(ecoHtmlHead)); response->print("\n"); response->print(FPSTR(ecoHtmlHeadEnd)); response->print(FPSTR(commonNav)); response->print(FPSTR(ecoChunk_BodyStart)); snprintf(buffer, sizeof(buffer), "%d", ecoModeMinutes); response->print(buffer); response->print(FPSTR(ecoChunk_AfterEcoMode)); snprintf(buffer, sizeof(buffer), "%d", ecoModeTempWasser); response->print(buffer); response->print(FPSTR(ecoChunk_AfterTempW)); snprintf(buffer, sizeof(buffer), "%d", ecoModeTempDampf); response->print(buffer); response->print(FPSTR(ecoChunk_AfterTempD)); if (dynamicEcoActive) { response->print(F(" checked")); } response->print(FPSTR(ecoChunk_AfterDynCheck)); snprintf(buffer, sizeof(buffer), "%d", dampfVerzoegerung); response->print(buffer); response->print(FPSTR(ecoChunk_AfterDampfDelay)); request->send(response); } /************************************************************************************ * Handler für die Fast-Heat-Up Seite - Heap-Optimiert mit Chunked Sending ************************************************************************************/ // --- PROGMEM Chunks für handleFastHeatUp --- // Kopfzeile bleibt gleich wie im Original `fhuHtml` static const char fhuHtmlHead[] PROGMEM = R"rawliteral( Fast-Heat-Up-Modus )rawliteral"; // bleibt gleich wie im Original `fhuHtml2` static const char fhuHtmlHeadEnd[] PROGMEM = R"rawliteral( )rawliteral"; // Aufteilung von fhuHtml3 in Chunks // Teil 1: Bis zum Ende des

Fast-Heat-Up-Modus

Fast-Heat-Up-Modus

' nach der Checkbox bis zum Ende static const char fhuChunk2[] PROGMEM = R"rawliteral(>
Der Fast-Heat-Up-Modus ermöglicht es, die Maschine noch schneller aufzuheizen.
Der Kessel wird beim Start auf 130 Grad Celsius erhitzt.
Nachdem die Temperatur erreicht ist, muss ein Flush von ca. 20 Sekunden durchgeführt werden.

ACHTUNG:
Bitte beachten, dass auf der Seite PID-Einstellung eventuell die max. Wassertemperatur der Sicherheitsabschaltung angepasst werden muss!
)rawliteral"; void handleFastHeatUp(AsyncWebServerRequest *request) { AsyncResponseStream *response = request->beginResponseStream("text/html"); response->print(FPSTR(fhuHtmlHead)); response->print("\n"); response->print(FPSTR(fhuHtmlHeadEnd)); response->print(FPSTR(commonNav)); response->print(FPSTR(fhuChunk1)); if (fastHeatUpAktiv) { response->print(F(" checked")); } response->print(FPSTR(fhuChunk2)); request->send(response); } /************************************************************************************ * Handler zum Speichern der Fast-Heat-Up-Einstellung - URL angepasst ************************************************************************************/ void handleFastHeatUpSettings(AsyncWebServerRequest *request) { // Parameter über POST holen if (request->hasParam("fastHeatUpAktiv", true)) { // true = POST Parameter fastHeatUpAktiv = true; } else { fastHeatUpAktiv = false; } EEPROM.put(EEPROM_ADDR_FASTHEATUP_DATA, fastHeatUpAktiv); EEPROM.commit(); request->redirect("/Fast-Heat-Up"); // Async redirect } /************************************************************************************ * Handler für CSS-Datei ************************************************************************************/ void handleCss(AsyncWebServerRequest *request) { // Prüfen, ob die Datei existiert if(LittleFS.exists("/style.css")){ // Datei senden, mit korrektem MIME-Typ 'text/css' // Das letzte 'false' verhindert Caching auf Client-Seite (optional, für Entwicklung gut) // Für Produktion kann man es auf 'true' setzen oder weglassen, damit der Browser cacht. request->send(LittleFS, "/style.css", "text/css"); } else { // Datei nicht gefunden request->send(404, "text/plain", "CSS Datei nicht gefunden"); } } /************************************************************************************ * Handler für Root (PID-Einstellungen) - Heap-Optimiert mit Chunked Sending ************************************************************************************/ // --- PROGMEM Chunks für handleRoot --- // Kopfzeile bleibt gleich wie im Original `rootHtml` static const char rootHtmlHead[] PROGMEM = R"rawliteral( PID-Einstellung )rawliteral"; // bleibt gleich wie im Original `rootHtml2` static const char rootHtmlHeadEnd[] PROGMEM = R"rawliteral( )rawliteral"; // Aufteilung von rootHtml3 in Chunks static const char rootChunk_BodyStart[] PROGMEM = R"rawliteral(

PID-Einstellung

)rawliteral"; // Nach {SENSOR_ERROR_MESSAGES} bis value='{WASSER}' static const char rootChunk_FormStart[] PROGMEM = R"rawliteral(

Temperatur: Wasser / Kessel


(Volle Heizleistung erzwingen bis 95% der Zieltemperatur)

(Kein Heizen, wenn Temp. > Sollwert)

Temperatur: Dampf / Thermoblock


(Volle Heizleistung erzwingen bis 90% der Zieltemperatur)

(Kein Heizen, wenn Temp. > Sollwert)

PID-Parameter: Wasser / Kessel


PID-Parameter: Dampf / Thermoblock


Sicherheitsabschaltung

Die Sicherheitsabschaltung bietet eine softwareseitige Übertemperatursicherung,
ersetzt jedoch keine hardwareseitige Lösung durch z.B. Bimetall Temperaturschalter!
Bei der Verwendung von Fast-Heat-Up muss der Wert für die Maximaltemperatur angepasst werden!

)rawliteral"; // Überarbeitete Funktion handleRoot void handleRoot(AsyncWebServerRequest *request) { char buffer[20]; AsyncResponseStream *response = request->beginResponseStream("text/html"); response->print(FPSTR(rootHtmlHead)); response->print("\n"); response->print(FPSTR(rootHtmlHeadEnd)); response->print(FPSTR(commonNav)); response->print(FPSTR(rootChunk_BodyStart)); // --- Dynamische Fehlermeldungen generieren und senden --- String errorMessageHtml = ""; if (demoModus) { errorMessageHtml = "
Demo-Betrieb:
Es sind keine Sensoren angeschlossen und daher keine Temperaturwerte verfügbar.
"; } else if (wasserSensorError && dampfSensorError) { errorMessageHtml = "
Die Daten der Temperatursensoren sind derzeit nicht verfügbar!
Bitte Temperatur-Sensoren prüfen!
"; } else if (wasserSensorError) { errorMessageHtml = "
Temperaturwert für Wasser nicht verfügbar
"; } else if (dampfSensorError) { errorMessageHtml = "
Temperaturwert für Dampf nicht verfügbar
"; } if (errorMessageHtml.length() > 0) { response->print(errorMessageHtml); } // Formular Schritt für Schritt aufbauen response->print(FPSTR(rootChunk_FormStart)); snprintf(buffer, sizeof(buffer), "%d", (int)SetpointWasser); response->print(buffer); response->print(FPSTR(rootChunk_AfterWasser)); snprintf(buffer, sizeof(buffer), "%.2f", OffsetWasser); response->print(buffer); response->print(FPSTR(rootChunk_AfterOffsetW)); if (boostWasserActive) response->print(F(" checked")); response->print(FPSTR(rootChunk_AfterBoostW)); if (preventHeatAboveSetpointWasser) response->print(F(" checked")); response->print(FPSTR(rootChunk_AfterPreventW)); snprintf(buffer, sizeof(buffer), "%d", (int)SetpointDampf); response->print(buffer); response->print(FPSTR(rootChunk_AfterDampf)); snprintf(buffer, sizeof(buffer), "%.2f", OffsetDampf); response->print(buffer); response->print(FPSTR(rootChunk_AfterOffsetD)); if (boostDampfActive) response->print(F(" checked")); response->print(FPSTR(rootChunk_AfterBoostD)); if (preventHeatAboveSetpointDampf) response->print(F(" checked")); response->print(FPSTR(rootChunk_AfterPreventD)); snprintf(buffer, sizeof(buffer), "%.2f", KpWasser); response->print(buffer); response->print(FPSTR(rootChunk_AfterKpW)); snprintf(buffer, sizeof(buffer), "%.2f", KiWasser); response->print(buffer); response->print(FPSTR(rootChunk_AfterKiW)); snprintf(buffer, sizeof(buffer), "%.2f", KdWasser); response->print(buffer); response->print(FPSTR(rootChunk_AfterKdW)); snprintf(buffer, sizeof(buffer), "%lu", windowSizeWasser); response->print(buffer); response->print(FPSTR(rootChunk_AfterWindowW)); snprintf(buffer, sizeof(buffer), "%.2f", KpDampf); response->print(buffer); response->print(FPSTR(rootChunk_AfterKpD)); snprintf(buffer, sizeof(buffer), "%.2f", KiDampf); response->print(buffer); response->print(FPSTR(rootChunk_AfterKiD)); snprintf(buffer, sizeof(buffer), "%.2f", KdDampf); response->print(buffer); response->print(FPSTR(rootChunk_AfterKdD)); snprintf(buffer, sizeof(buffer), "%lu", windowSizeDampf); response->print(buffer); response->print(FPSTR(rootChunk_AfterWindowD)); snprintf(buffer, sizeof(buffer), "%.2f", maxTempWasser); response->print(buffer); response->print(FPSTR(rootChunk_AfterMaxW)); snprintf(buffer, sizeof(buffer), "%.2f", maxTempDampf); response->print(buffer); response->print(FPSTR(rootChunk_FormEnd)); request->send(response); } /************************************************************************************ * Schreibt die Werte in den EEPROM (PID/Eco) - Unverändert ************************************************************************************/ void handleSettingsUpdate(AsyncWebServerRequest *request) { bool valueChanged = false; // --- Werte aus POST Request lesen --- // Sicherheitslimits if (request->hasParam("maxTempWasser", true)) { float newVal = request->getParam("maxTempWasser", true)->value().toFloat(); if (newVal != maxTempWasser) { maxTempWasser = newVal; EEPROM.put(EEPROM_ADDR_MAX_TEMP_WASSER, maxTempWasser); valueChanged = true; } } if (request->hasParam("maxTempDampf", true)) { float newVal = request->getParam("maxTempDampf", true)->value().toFloat(); if (newVal != maxTempDampf) { maxTempDampf = newVal; EEPROM.put(EEPROM_ADDR_MAX_TEMP_DAMPF, maxTempDampf); valueChanged = true; } } // Setpoints if (request->hasParam("wasser", true)) { float newVal = request->getParam("wasser", true)->value().toFloat(); if (newVal != SetpointWasser) { SetpointWasser = newVal; EEPROM.put(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); valueChanged = true; } } if (request->hasParam("dampf", true)) { float newVal = request->getParam("dampf", true)->value().toFloat(); if (newVal != SetpointDampf) { SetpointDampf = newVal; EEPROM.put(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); valueChanged = true; } } // Offsets if (request->hasParam("offsetWasser", true)) { float newVal = request->getParam("offsetWasser", true)->value().toFloat(); if (newVal != OffsetWasser) { OffsetWasser = newVal; EEPROM.put(EEPROM_ADDR_OFFSET_WASSER, OffsetWasser); valueChanged = true; } } if (request->hasParam("offsetDampf", true)) { float newVal = request->getParam("offsetDampf", true)->value().toFloat(); if (newVal != OffsetDampf) { OffsetDampf = newVal; EEPROM.put(EEPROM_ADDR_OFFSET_DAMPF, OffsetDampf); valueChanged = true; } } // PID Wasser if (request->hasParam("kpWasser", true)) { float newVal = request->getParam("kpWasser", true)->value().toFloat(); if (newVal != KpWasser) { KpWasser = newVal; EEPROM.put(EEPROM_ADDR_KP_WASSER, KpWasser); valueChanged = true; } } if (request->hasParam("kiWasser", true)) { float newVal = request->getParam("kiWasser", true)->value().toFloat(); if (newVal != KiWasser) { KiWasser = newVal; EEPROM.put(EEPROM_ADDR_KI_WASSER, KiWasser); valueChanged = true; } } if (request->hasParam("kdWasser", true)) { float newVal = request->getParam("kdWasser", true)->value().toFloat(); if (newVal != KdWasser) { KdWasser = newVal; EEPROM.put(EEPROM_ADDR_KD_WASSER, KdWasser); valueChanged = true; } } // PID Dampf if (request->hasParam("kpDampf", true)) { float newVal = request->getParam("kpDampf", true)->value().toFloat(); if (newVal != KpDampf) { KpDampf = newVal; EEPROM.put(EEPROM_ADDR_KP_DAMPF, KpDampf); valueChanged = true; } } if (request->hasParam("kiDampf", true)) { float newVal = request->getParam("kiDampf", true)->value().toFloat(); if (newVal != KiDampf) { KiDampf = newVal; EEPROM.put(EEPROM_ADDR_KI_DAMPF, KiDampf); valueChanged = true; } } if (request->hasParam("kdDampf", true)) { float newVal = request->getParam("kdDampf", true)->value().toFloat(); if (newVal != KdDampf) { KdDampf = newVal; EEPROM.put(EEPROM_ADDR_KD_DAMPF, KdDampf); valueChanged = true; } } // Boost Flags bool newBoostWasser = request->hasParam("boostWasser", true); if (newBoostWasser != boostWasserActive) { boostWasserActive = newBoostWasser; EEPROM.put(EEPROM_ADDR_BOOST_WASSER_ACTIVE, boostWasserActive); valueChanged = true; } bool newBoostDampf = request->hasParam("boostDampf", true); if (newBoostDampf != boostDampfActive) { boostDampfActive = newBoostDampf; EEPROM.put(EEPROM_ADDR_BOOST_DAMPF_ACTIVE, boostDampfActive); valueChanged = true; } // Prevent Heat Above Setpoint Flags bool newPreventHeatWasser = request->hasParam("preventHeatWasser", true); if (newPreventHeatWasser != preventHeatAboveSetpointWasser) { preventHeatAboveSetpointWasser = newPreventHeatWasser; EEPROM.put(EEPROM_ADDR_PREVENTHEAT_WASSER, preventHeatAboveSetpointWasser); valueChanged = true; Serial.printf("PreventHeat Wasser %s\n", preventHeatAboveSetpointWasser ? "aktiviert" : "deaktiviert"); } bool newPreventHeatDampf = request->hasParam("preventHeatDampf", true); if (newPreventHeatDampf != preventHeatAboveSetpointDampf) { preventHeatAboveSetpointDampf = newPreventHeatDampf; EEPROM.put(EEPROM_ADDR_PREVENTHEAT_DAMPF, preventHeatAboveSetpointDampf); valueChanged = true; Serial.printf("PreventHeat Dampf %s\n", preventHeatAboveSetpointDampf ? "aktiviert" : "deaktiviert"); } unsigned long newWindowSizeWasser = windowSizeWasser; if (request->hasParam("windowSizeWasser", true)) { char* endptr; unsigned long val = strtoul(request->getParam("windowSizeWasser", true)->value().c_str(), &endptr, 10); if (*endptr == '\0' && val > 0 && val <= 60000) { newWindowSizeWasser = val; } else { Serial.printf("WARNUNG: Ungültiger Wert für windowSizeWasser empfangen: %s\n", request->getParam("windowSizeWasser", true)->value().c_str()); } } if (newWindowSizeWasser != windowSizeWasser) { windowSizeWasser = newWindowSizeWasser; EEPROM.put(EEPROM_ADDR_WINDOWSIZE_WASSER, windowSizeWasser); pidWasser.SetOutputLimits(0, windowSizeWasser); valueChanged = true; Serial.printf("Fenstergröße Wasser aktualisiert auf: %lu ms\n", windowSizeWasser); } unsigned long newWindowSizeDampf = windowSizeDampf; if (request->hasParam("windowSizeDampf", true)) { char* endptr; unsigned long val = strtoul(request->getParam("windowSizeDampf", true)->value().c_str(), &endptr, 10); if (*endptr == '\0' && val > 0 && val <= 60000) { newWindowSizeDampf = val; } else { Serial.printf("WARNUNG: Ungültiger Wert für windowSizeDampf empfangen: %s\n", request->getParam("windowSizeDampf", true)->value().c_str()); } } if (newWindowSizeDampf != windowSizeDampf) { windowSizeDampf = newWindowSizeDampf; EEPROM.put(EEPROM_ADDR_WINDOWSIZE_DAMPF, windowSizeDampf); pidDampf.SetOutputLimits(0, windowSizeDampf); valueChanged = true; Serial.printf("Fenstergröße Dampf aktualisiert auf: %lu ms\n", windowSizeDampf); } // --- EEPROM speichern und PID aktualisieren --- if (valueChanged) { if (EEPROM.commit()) { Serial.println("Einstellungen erfolgreich in EEPROM gespeichert."); } else { Serial.println("FEHLER: EEPROM Commit fehlgeschlagen!"); } } pidDampf.SetTunings(KpDampf, KiDampf, KdDampf); pidWasser.SetTunings(KpWasser, KiWasser, KdWasser); request->redirect("/"); // Async redirect } /************************************************************************************ * Handler für die Firmware & Einstellungen Seite - Heap-Optimiert ************************************************************************************/ // --- PROGMEM Chunks für handleFirmware --- static const char firmwareHtmlHead[] PROGMEM = R"rawliteral( Firmware & Einstellungen )rawliteral"; // Head inklusive static const char firmwareBodyStart[] PROGMEM = R"rawliteral(

Firmware & Einstellungen

Firmware-Info

Firmware-Version: )rawliteral"; // Endet vor {FIRMWAREVERSION} static const char firmwareInfoChunk2[] PROGMEM = R"rawliteral(
Hersteller: )rawliteral"; // Endet vor {SWHERSTELLER} static const char firmwareInfoChunk3[] PROGMEM = R"rawliteral(
E-Mail: )rawliteral"; // Endet vor {SWHERSTELLERMAIL} static const char firmwareInfoChunk4[] PROGMEM = R"rawliteral(
Website: )rawliteral"; // Endet vor {SWHERSTELLERWEBSITE} static const char firmwareInfoChunkEnd[] PROGMEM = R"rawliteral(
)rawliteral"; // Ende Firmware-Info Sektion static const char firmwareUpdateSection[] PROGMEM = R"rawliteral(

Firmware-Update

Das Firmware-Update kann durch den Upload einer .bin-Datei durchgeführt werden.
Nach dem Update wird ein automatischer Neustart durchgeführt.
Sollte der Neustart nicht erfolgen, so kann dieser auch durch kurzzeitiges Trennen der Stromversorgung erfolgen.


)rawliteral"; static const char firmwareFileManagerSection[] PROGMEM = R"rawliteral(

Dateimanager

Verwaltung von Dateien des Systems.
)rawliteral"; static const char firmwareExportSection[] PROGMEM = R"rawliteral(

Einstellungen exportieren

Sicherung aller aktuellen Einstellungen in einer Datei.
)rawliteral"; static const char firmwareImportSection[] PROGMEM = R"rawliteral(

Einstellungen importieren

Alle Einstellungen aus einer zuvor exportierten .bin-Datei wiederherstellen.

ACHTUNG:
Dieser Vorgang überschreibt ALLE aktuellen Einstellungen!
Nach dem Import einfolgt ein automatischer Neustart.


)rawliteral"; static const char firmwareResetSection[] PROGMEM = R"rawliteral(

Werkseinstellungen

Setzt ALLE Einstellungen (mit Ausnahme der WiFi-Konfiguration, der Shot- und Betriebsstundenzähler) auf die Standardwerte zurück.

ACHTUNG:
Es ist zu empfehlen, vorher einen Export der Einstellungen durchzuführen.
)rawliteral"; static const char firmwareBodyEnd[] PROGMEM = R"rawliteral( )rawliteral"; void handleFirmware(AsyncWebServerRequest *request) { AsyncResponseStream *response = request->beginResponseStream("text/html"); response->print(FPSTR(firmwareHtmlHead)); response->print("\n"); response->print(FPSTR(commonNav)); response->print(FPSTR(firmwareBodyStart)); if (version.length() > 0) { response->print(version); } response->print(FPSTR(firmwareInfoChunk2)); if (versionHersteller.length() > 0) { response->print(versionHersteller); } response->print(FPSTR(firmwareInfoChunk3)); if (versionHerstellerMail.length() > 0) { response->print(versionHerstellerMail); } response->print(FPSTR(firmwareInfoChunk4)); if (versionHerstellerWeb.length() > 0) { response->print(versionHerstellerWeb); } response->print(FPSTR(firmwareInfoChunkEnd)); response->print(FPSTR(firmwareUpdateSection)); response->print(FPSTR(firmwareFileManagerSection)); response->print(FPSTR(firmwareExportSection)); response->print(FPSTR(firmwareImportSection)); response->print(FPSTR(firmwareResetSection)); response->print(FPSTR(firmwareBodyEnd)); request->send(response); } /************************************************************************************ * Handler für AutTune Wasser ************************************************************************************/ // --- PROGMEM Chunks für handleAutoTuneWasser --- static const char autotuneWasserHtmlHead[] PROGMEM = R"rawliteral( AutoTune Wasser )rawliteral"; static const char autotuneWasserBodyStart[] PROGMEM = R"rawliteral(

PID-Tuning

)rawliteral"; static const char autotuneWasserStartSuccess[] PROGMEM = R"rawliteral(

AutoTune gestartet: Wasser-PID


)rawliteral"; static const char autotuneWasserAlreadyActive[] PROGMEM = R"rawliteral(

AutoTune ist bereits aktiv!


)rawliteral"; static const char autotuneWasserBodyEnd[] PROGMEM = R"rawliteral( )rawliteral"; void handleAutoTuneWasser(AsyncWebServerRequest *request) { AsyncResponseStream *response = request->beginResponseStream("text/html"); response->print(FPSTR(autotuneWasserHtmlHead)); response->print("\n"); response->print(FPSTR(commonNav)); response->print(FPSTR(autotuneWasserBodyStart)); if (!autoTuneWasserActive && !autoTuneDampfActive) { if (wartungsModusAktiv) { wartungsModusAktiv = false; EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); Serial.println("Wartungsmodus automatisch deaktiviert durch Start von PID-Tuning."); } startAutoTuneWasser(); response->print(FPSTR(autotuneWasserStartSuccess)); } else { response->print(FPSTR(autotuneWasserAlreadyActive)); } response->print(FPSTR(autotuneWasserBodyEnd)); request->send(response); } /************************************************************************************ * Handler für AutTune Dampf ************************************************************************************/ // --- PROGMEM Chunks für handleAutoTuneDampf --- static const char autotuneDampfHtmlHead[] PROGMEM = R"rawliteral( AutoTune Dampf )rawliteral"; // BodyStart, AlreadyActive und BodyEnd können von handleAutoTuneWasser wiederverwendet werden // (autotuneWasserBodyStart, autotuneWasserAlreadyActive, autotuneWasserBodyEnd) static const char autotuneDampfStartSuccess[] PROGMEM = R"rawliteral(

AutoTune gestartet: Dampf-PID


)rawliteral"; void handleAutoTuneDampf(AsyncWebServerRequest *request) { AsyncResponseStream *response = request->beginResponseStream("text/html"); response->print(FPSTR(autotuneDampfHtmlHead)); response->print("\n"); response->print(FPSTR(commonNav)); response->print(FPSTR(autotuneWasserBodyStart)); // Wiederverwendet if (!autoTuneWasserActive && !autoTuneDampfActive) { if (wartungsModusAktiv) { wartungsModusAktiv = false; EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); Serial.println("Wartungsmodus automatisch deaktiviert durch Start von PID-Tuning."); } startAutoTuneDampf(); response->print(FPSTR(autotuneDampfStartSuccess)); } else { response->print(FPSTR(autotuneWasserAlreadyActive)); } response->print(FPSTR(autotuneWasserBodyEnd)); request->send(response); } /************************************************************************************ * Handler zum Speichern der AutoTune-Einstellungen - ausgelagert ************************************************************************************/ void handleUpdateAutotuneSettings(AsyncWebServerRequest *request) { bool changed = false; // --- Wasser Parameter --- if (request->hasParam("tuningStepWasser", true)) { tuningStepWasser = request->getParam("tuningStepWasser", true)->value().toFloat(); EEPROM.put(EEPROM_ADDR_TUNING_STEP_WASSER, tuningStepWasser); changed = true; } if (request->hasParam("tuningNoiseWasser", true)) { tuningNoiseWasser = request->getParam("tuningNoiseWasser", true)->value().toFloat(); EEPROM.put(EEPROM_ADDR_TUNING_NOISE_WASSER, tuningNoiseWasser); changed = true; } if (request->hasParam("tuningStartValueWasser", true)) { tuningStartValueWasser = request->getParam("tuningStartValueWasser", true)->value().toFloat(); EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_WASSER, tuningStartValueWasser); changed = true; } if (request->hasParam("tuningLookBackWasser", true)) { int lookbackInt = request->getParam("tuningLookBackWasser", true)->value().toInt(); if (lookbackInt >= 0) { tuningLookBackWasser = (unsigned int)lookbackInt; EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_WASSER, tuningLookBackWasser); changed = true; } } // --- Dampf Parameter --- if (request->hasParam("tuningStepDampf", true)) { tuningStepDampf = request->getParam("tuningStepDampf", true)->value().toFloat(); EEPROM.put(EEPROM_ADDR_TUNING_STEP_DAMPF, tuningStepDampf); changed = true; } if (request->hasParam("tuningNoiseDampf", true)) { tuningNoiseDampf = request->getParam("tuningNoiseDampf", true)->value().toFloat(); EEPROM.put(EEPROM_ADDR_TUNING_NOISE_DAMPF, tuningNoiseDampf); changed = true; } if (request->hasParam("tuningStartValueDampf", true)) { tuningStartValueDampf = request->getParam("tuningStartValueDampf", true)->value().toFloat(); EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_DAMPF, tuningStartValueDampf); changed = true; } if (request->hasParam("tuningLookBackDampf", true)) { int lookbackInt = request->getParam("tuningLookBackDampf", true)->value().toInt(); if (lookbackInt >= 0) { tuningLookBackDampf = (unsigned int)lookbackInt; EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_DAMPF, tuningLookBackDampf); changed = true; } } if (changed) { if (!EEPROM.commit()) { Serial.println("FEHLER: EEPROM Commit für AutoTune-Settings fehlgeschlagen!"); } else { Serial.println("AutoTune-Parameter gespeichert."); } } request->redirect("/PID-Tuning"); } /************************************************************************************ * Handler für PID-Tuning ************************************************************************************/ // --- PROGMEM Chunks für handlePidTuning --- static const char pidTuningPageHtmlHead[] PROGMEM = R"rawliteral( PID-Tuning )rawliteral"; static const char pidTuningPageBodyStart[] PROGMEM = R"rawliteral( )rawliteral"; static const char pidTuningPageAbortForm[] PROGMEM = R"rawliteral(

Laufendes PID-Tuning abbrechen?


)rawliteral"; static const char pidTuningPageIntro[] PROGMEM = R"rawliteral(

PID-Tuning

Das automatische PID-Tuning dient dazu, die optimalen Parameter für den PID-Regler (Proportional-, Integral- und Differentialanteil) selbständig zu ermitteln. Dabei analysiert das System mithilfe von Algorithmen das Regelverhalten (z. B. Reaktion auf einen Testimpuls) und passt Kp, Ki und Kd so an, dass gewünschte Kriterien wie kurze Einschwingzeit, geringe Überschwingung und stabile Regelung erreicht werden

Ein AutoTune-Vorgang kann durchaus bis zu 60 Minuten in Anspruch nehmen!

WICHTIG:
Vor dem Start des PID-Tunings sollte die Maschine die gewünschte Betriebstemperatur erreicht haben und stabil sein.
Andernfalls könnten die automatisch ermittelten PID-Werte für diesen Temperaturbereich ungenau oder nicht optimal sein.

Automatisches PID-Tuning für Wasser

Automatische Ermittlung der PID-Werte für Wasser / Kessel

Automatisches PID-Tuning für Dampf

Automatische Ermittlung der PID-Werte für Dampf / Thermoblock
)rawliteral"; // --- Chunks für das Parameter-Formular --- static const char pidTuningPageParamsFormStart[] PROGMEM = R"rawliteral(

AutoTune-Parameter (Zeitproportional)

Hier können die AutoTune-Parameter geändert werden.
Änderungen sollten mit Vorsicht durchgeführt werden!

Parameter Wasser / Kessel


Parameter Dampf / Thermoblock

)rawliteral"; static const char pidTuningPageExplanation[] PROGMEM = R"rawliteral(


Erklärung der Parameter (Zeitproportional):

Noise (°C):
Beobachte das "Zittern" der Temperaturanzeige, wenn die Temperatur (nahe am Zielwert) stabil ist.
Wenn sie z.B. um +/- 0.5°C schwankt, setze Noise auf 1.0.
Dieser Wert definiert ein Toleranzband, um das Sensorrauschen zu ignorieren.

StartValue (ms):
Schätze, wie viel Heizzeit (in Millisekunden, innerhalb des eingestellten Zeitfensters (siehe PID-Einstellung) nötig ist, um die Zieltemperatur konstant zu halten.
Dies ist die durchschnittliche Heizzeit im stabilen Zustand (z.B. 250 ms).

Step (ms):
Die Größe des "Sprungs" der Heizzeit (in ms) nach oben/unten um den StartValue, den AutoTune nutzt, um die Temperatur zum Schwingen zu zwingen.
Muss groß genug für eine Reaktion sein, aber nicht 0-100% (z.B. 500 ms).

LookBack (s):
AutoTune erzeugt eine langsame Welle der Temperatur. Miss (in einem Testlauf) oder schätze, wie lange eine volle Welle dauert (Spitze zu Spitze, in Sekunden).
Setze Lookback auf einen Wert, der *deutlich länger* ist als diese Dauer (z.B. 1.5x so lang; Welle=130s => Lookback=180s).
Dieser Wert muss *vor* dem Start festgelegt werden und sagt AutoTune, wie weit es zurückschauen soll, um die Welle zu messen.

Kontrolle per Chart:
Während AutoTune läuft, beobachte das Temperaturchart.
Wenn sich dort eine langsame, regelmäßige Welle (wie ein \"langgezogener Sinus\") um den Sollwert entwickelt, ist das ein gutes Zeichen, dass der Prozess funktioniert.

)rawliteral"; // --- handlePidTuning --- void handlePidTuning(AsyncWebServerRequest *request) { char buffer[20]; AsyncResponseStream *response = request->beginResponseStream("text/html"); response->print(FPSTR(pidTuningPageHtmlHead)); response->print("\n"); response->print(FPSTR(commonNav)); response->print(FPSTR(pidTuningPageBodyStart)); if (autoTuneWasserActive || autoTuneDampfActive) { response->print(FPSTR(pidTuningPageAbortForm)); } response->print(FPSTR(pidTuningPageIntro)); // Parameter-Formular response->print(FPSTR(pidTuningPageParamsFormStart)); snprintf(buffer, sizeof(buffer), "%lu", windowSizeWasser); response->print(buffer); response->print(FPSTR(pidTuningPageParamsW1)); snprintf(buffer, sizeof(buffer), "%.2f", tuningStepWasser); response->print(buffer); response->print(FPSTR(pidTuningPageParamsW2)); snprintf(buffer, sizeof(buffer), "%.2f", tuningNoiseWasser); response->print(buffer); response->print(FPSTR(pidTuningPageParamsW3)); snprintf(buffer, sizeof(buffer), "%lu", windowSizeWasser); response->print(buffer); response->print(FPSTR(pidTuningPageParamsW4)); snprintf(buffer, sizeof(buffer), "%.2f", tuningStartValueWasser); response->print(buffer); response->print(FPSTR(pidTuningPageParamsW5)); snprintf(buffer, sizeof(buffer), "%u", tuningLookBackWasser); response->print(buffer); response->print(FPSTR(pidTuningPageParamsDStart)); snprintf(buffer, sizeof(buffer), "%lu", windowSizeDampf); response->print(buffer); response->print(FPSTR(pidTuningPageParamsD1)); snprintf(buffer, sizeof(buffer), "%.2f", tuningStepDampf); response->print(buffer); response->print(FPSTR(pidTuningPageParamsD2)); snprintf(buffer, sizeof(buffer), "%.2f", tuningNoiseDampf); response->print(buffer); response->print(FPSTR(pidTuningPageParamsD3)); snprintf(buffer, sizeof(buffer), "%lu", windowSizeDampf); response->print(buffer); response->print(FPSTR(pidTuningPageParamsD4)); snprintf(buffer, sizeof(buffer), "%.2f", tuningStartValueDampf); response->print(buffer); response->print(FPSTR(pidTuningPageParamsD5)); snprintf(buffer, sizeof(buffer), "%u", tuningLookBackDampf); response->print(buffer); response->print(FPSTR(pidTuningPageParamsFormEnd)); response->print(FPSTR(pidTuningPageExplanation)); request->send(response); } /************************************************************************************ * Handler für die Antwort nach Abbruch des PID-Tunings - Heap-Optimiert ************************************************************************************/ // PROGMEM Chunks für die Seite (Namen angepasst für Klarheit) static const char abortPidTuningHtmlStart[] PROGMEM = R"rawliteral( Info )rawliteral"; static const char abortPidTuningHtmlHeadEnd[] PROGMEM = R"rawliteral()rawliteral"; static const char abortPidTuningHtmlBody[] PROGMEM = R"rawliteral(

PID-Tuning Abbruch

Das PID-Tuning wurde erfolgreich abgebrochen.

Der Normalbetrieb wird nun fortgesetzt.

)rawliteral"; // --- Neue Funktion handlePidTuningAbbruch --- void handlePidTuningAbbruch(AsyncWebServerRequest *request) { // Seite senden AsyncResponseStream *response = request->beginResponseStream("text/html"); response->print(FPSTR(abortPidTuningHtmlStart)); response->print("\n"); response->print(FPSTR(abortPidTuningHtmlHeadEnd)); response->print(FPSTR(commonNav)); response->print(FPSTR(abortPidTuningHtmlBody)); request->send(response); // Eigentliche Abbruch-Logik stopAutoTuneWasser(); stopAutoTuneDampf(); } /************************************************************************************ * Speichert Eco-Einstellungen - URL angepasst ************************************************************************************/ void handleEcoUpdate(AsyncWebServerRequest *request) { // Parameter aus POST holen if (request->hasParam("ecoMode", true)) ecoModeMinutes = request->getParam("ecoMode", true)->value().toInt(); if (request->hasParam("ecoModeTempWasser", true)) ecoModeTempWasser = request->getParam("ecoModeTempWasser", true)->value().toInt(); if (request->hasParam("ecoModeTempDampf", true)) ecoModeTempDampf = request->getParam("ecoModeTempDampf", true)->value().toInt(); dynamicEcoActive = request->hasParam("dynamicEcoMode", true); if (request->hasParam("dampfDelay", true)) dampfVerzoegerung = request->getParam("dampfDelay", true)->value().toInt(); EEPROM.put(EEPROM_ADDR_ECOMODE_MINUTES, ecoModeMinutes); EEPROM.put(EEPROM_ADDR_ECOMODE_TEMP_WASSER, ecoModeTempWasser); EEPROM.put(EEPROM_ADDR_ECOMODE_TEMP_DAMPF, ecoModeTempDampf); EEPROM.put(EEPROM_ADDR_DYNAMIC_ECO_MODE, dynamicEcoActive); EEPROM.put(EEPROM_ADDR_STEAM_DELAY, dampfVerzoegerung); EEPROM.commit(); if (ecoModeMinutes == 0) { ecoModeAktiv = false; ecoModeActivatedTime = 0; EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); } request->redirect("/ECO"); } /************************************************************************************ * Speichert Geräte-Infos ************************************************************************************/ void handleInfoUpdate(AsyncWebServerRequest *request) { bool changed = false; if (request->hasParam("hersteller", true)) { strncpy(infoHersteller, request->getParam("hersteller", true)->value().c_str(), sizeof(infoHersteller) - 1); infoHersteller[sizeof(infoHersteller) - 1] = '\0'; EEPROM.put(EEPROM_ADDR_INFO_HERSTELLER, infoHersteller); changed = true; } if (request->hasParam("modell", true)) { strncpy(infoModell, request->getParam("modell", true)->value().c_str(), sizeof(infoModell) - 1); infoModell[sizeof(infoModell) - 1] = '\0'; EEPROM.put(EEPROM_ADDR_INFO_MODELL, infoModell); changed = true; } if (request->hasParam("zusatz", true)) { strncpy(infoZusatz, request->getParam("zusatz", true)->value().c_str(), sizeof(infoZusatz) - 1); infoZusatz[sizeof(infoZusatz) - 1] = '\0'; EEPROM.put(EEPROM_ADDR_INFO_ZUSATZ, infoZusatz); changed = true; } if (changed) { EEPROM.commit(); } request->redirect("/Info"); } /************************************************************************************ * Speichert das Wartungsintervall ************************************************************************************/ void handleMaintenanceUpdate(AsyncWebServerRequest *request) { bool changed = false; if (request->hasParam("maintenanceInterval", true)) { int newInterval = request->getParam("maintenanceInterval", true)->value().toInt(); if (newInterval < 0) newInterval = 0; if (newInterval != maintenanceInterval) { maintenanceInterval = newInterval; EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL, maintenanceInterval); changed = true; Serial.printf("Wartungsintervall gesetzt auf: %d Shots\n", maintenanceInterval); } } if (changed) { EEPROM.commit(); } request->redirect("/Service"); } /************************************************************************************ * Handler für JSON-Chart-Daten (/Chart-Daten) - Heap-Optimiert ************************************************************************************/ void handleChartData(AsyncWebServerRequest *request) { // Build JSON string (can be large, consider AsyncJson library for very complex cases) String json = "{"; json += "\"wasser\":" + String(InputWasser, 1) + ","; json += "\"setpointwasser\":" + String(SetpointWasser, 1) + ","; json += "\"dampf\":" + String(InputDampf, 1) + ","; json += "\"setpointdampf\":" + String(SetpointDampf, 1); json += "}"; request->send(200, "application/json", json); } /************************************************************************************ * Handler für Temperatur-Charts (Live) - URL angepasst * Lädt chart.js bevorzugt aus LittleFS, sonst CDN, sonst Fehler. * NEU: Auswahl für maximale Datenpunkte hinzugefügt. ************************************************************************************/ // Teil 1: HTML-Start, Meta-Tags (bleibt gleich) static const char chartsHtml_HeadStart[] PROGMEM = R"rawliteral( Temperatur-Chart )rawliteral"; // Teil 2: Schließendes Head-Tag (bleibt gleich) static const char chartsHtml_HeadEnd[] PROGMEM = "\n"; // *** NEU: Selektoren-UI als eigener Chunk *** static const char chartsHtml_SelectorsUI[] PROGMEM = R"rawliteral(
)rawliteral"; // Teil 3: Body-Start, Titel, *Platzhalter für Selektoren*, Placeholder für Sensorfehler, Chart-Container-Div static const char chartsHtml_BodyStartAndUI[] PROGMEM = R"rawliteral(

Temperatur-Chart

{SELECTOR_UI_PLACEHOLDER} {SENSOR_ERROR_MESSAGES_PLACEHOLDER}
)rawliteral"; // Teil 4: Der eigentliche Chart.js Initialisierungs- und Update-Code // (Wird im nächsten Schritt angepasst) static const char chartsHtml_ChartJSCode[] PROGMEM = R"rawliteral( const ctx = document.getElementById('combinedChart').getContext('2d'); let currentSetpointDampf = 0; let currentSetpointWasser = 0; const wasserColor = '#00AEEF'; const dampfColor = '#F7941D'; const setpointWasserColor = 'rgba(0, 174, 239, 0.7)'; const setpointDampfColor = 'rgba(247, 148, 29, 0.7)'; const gridColor = 'rgba(255, 255, 255, 0.1)'; const textColor = '#E0E0E0'; // *** NEU: Variable für max. Datenpunkte *** let maxDataPoints = parseInt(document.getElementById('datapointLimit').value, 10) || 100; // Init mit Dropdown-Wert oder Fallback const combinedChart = new Chart(ctx, { type: 'line', data: { labels: [], datasets: [ { label: 'Wasser IST', data: [], borderColor: wasserColor, backgroundColor: 'rgba(0, 174, 239, 0.1)', borderWidth: 2, fill: 'start', tension: 0.4, pointRadius: 0, pointHoverRadius: 6, pointHitRadius: 10, pointHoverBackgroundColor: wasserColor }, { label: 'Dampf IST', data: [], borderColor: dampfColor, backgroundColor: 'rgba(247, 148, 29, 0.1)', borderWidth: 2, fill: 'start', tension: 0.4, pointRadius: 0, pointHoverRadius: 6, pointHitRadius: 10, pointHoverBackgroundColor: dampfColor }, { label: 'Wasser SOLL', data: [], borderColor: setpointWasserColor, borderWidth: 1.5, borderDash: [6, 3], fill: false, tension: 0.1, pointRadius: 0, pointHoverRadius: 0 }, { label: 'Dampf SOLL', data: [], borderColor: setpointDampfColor, borderWidth: 1.5, borderDash: [6, 3], fill: false, tension: 0.1, pointRadius: 0, pointHoverRadius: 0 } ] }, options: { responsive: true, maintainAspectRatio: false, animation: false, interaction: { intersect: false, mode: 'index' }, scales: { x: { type: 'category', title: { display: true, text: 'Uhrzeit', color: textColor, font: { size: 13, weight: '300' } }, ticks: { color: textColor, font: { size: 11 }, maxRotation: 0, autoSkip: true, maxTicksLimit: 8 }, grid: { color: gridColor, drawBorder: false } }, y: { suggestedMin: 0, suggestedMax: 180, title: { display: true, text: 'Temperatur (°C)', color: textColor, font: { size: 13, weight: '300' } }, ticks: { color: textColor, font: { size: 11 }, stepSize: 20, callback: function(value, index, values) { return value + ' °C'; } }, grid: { color: gridColor, drawBorder: false } } }, plugins: { legend: { position: 'bottom', align: 'center', labels: { color: textColor, font: { size: 12 }, usePointStyle: true, pointStyle: 'rectRounded', boxWidth: 15, padding: 15 } }, tooltip: { enabled: true, mode: 'index', intersect: false, backgroundColor: 'rgba(0, 0, 0, 0.85)', titleColor: '#ffffff', titleFont: { size: 13, weight: 'bold' }, bodyColor: '#dddddd', bodyFont: { size: 12 }, bodySpacing: 4, borderColor: 'rgba(255, 255, 255, 0.1)', borderWidth: 1, padding: 10, cornerRadius: 8, displayColors: true, boxPadding: 4, callbacks: { label: function(context) { let label = context.dataset.label || ''; if (label) { label += ': '; } if (context.parsed.y !== null) { label += context.parsed.y.toFixed(1) + ' °C'; } return label; } } } } } }); let updateInterval = parseInt(document.getElementById('updateRate').value, 10) || 15000; // Init mit Dropdown-Wert oder Fallback let intervalId = null; function fetchData() { fetch('/Chart-Daten').then(response => { if (!response.ok) { console.error('Netzwerkantwort war nicht ok:', response.statusText); throw new Error('Network response was not ok'); } return response.json(); }) .then(data => { if (data.setpointwasser !== undefined) currentSetpointWasser = data.setpointwasser; if (data.setpointdampf !== undefined) currentSetpointDampf = data.setpointdampf; const now = new Date(); const timeLabel = now.toLocaleTimeString('de-DE'); const labels = combinedChart.data.labels; const datasets = combinedChart.data.datasets; labels.push(timeLabel); datasets[0].data.push(data.wasser); datasets[1].data.push(data.dampf); datasets[2].data.push(currentSetpointWasser); datasets[3].data.push(currentSetpointDampf); // *** HIER die Variable verwenden *** while (labels.length > maxDataPoints) { labels.shift(); datasets.forEach(dataset => { dataset.data.shift(); }); } combinedChart.update('none'); }).catch(err => { console.error("Fehler beim Abrufen der Daten:", err); }); } function startFetching() { if (intervalId) { clearInterval(intervalId); intervalId = null; } fetchData(); intervalId = setInterval(fetchData, updateInterval); console.log(`Chart-Aktualisierung gestartet: Intervall ${updateInterval / 1000} Sekunden.`); } // Event Listener für Update-Rate document.getElementById('updateRate').addEventListener('change', function(event) { const newInterval = parseInt(event.target.value, 10); if (!isNaN(newInterval) && newInterval > 0) { updateInterval = newInterval; startFetching(); } }); // *** NEU: Event Listener für Max. Datenpunkte *** document.getElementById('datapointLimit').addEventListener('change', function(event) { const newLimit = parseInt(event.target.value, 10); if (!isNaN(newLimit) && newLimit > 0) { maxDataPoints = newLimit; console.log(`Maximale Datenpunkte geändert auf: ${maxDataPoints}`); // Optional: Sofortiges Kürzen der Daten, wenn nötig (siehe unten) // const currentLength = combinedChart.data.labels.length; // if (currentLength > maxDataPoints) { // const excess = currentLength - maxDataPoints; // combinedChart.data.labels.splice(0, excess); // combinedChart.data.datasets.forEach(dataset => { dataset.data.splice(0, excess); }); // combinedChart.update('none'); // } } }); startFetching(); // Initialen Fetch und Intervall starten )rawliteral"; // Teil 5: Schließendes Body- und HTML-Tag (bleibt gleich) static const char chartsHtml_BodyEnd[] PROGMEM = R"rawliteral( )rawliteral"; void handleCharts(AsyncWebServerRequest *request) { bool chartJsLocal = LittleFS.exists("/chart.umd.min.js"); bool isConnected = (WiFi.status() == WL_CONNECTED); String chartJsUrl = ""; bool canLoadChartJs = false; if (chartJsLocal) { chartJsUrl = "/chart.umd.min.js"; canLoadChartJs = true; Serial.println("Chart-Handler: Verwende lokale chart.umd.min.js"); } else if (isConnected) { chartJsUrl = "https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js"; canLoadChartJs = true; Serial.println("Chart-Handler: Lade chart.umd.min.js vom CDN."); } else { canLoadChartJs = false; Serial.println("Chart-Handler: Kann chart.js nicht laden."); } AsyncResponseStream *response = request->beginResponseStream("text/html"); response->print(FPSTR(chartsHtml_HeadStart)); response->print("\n"); if (canLoadChartJs) { response->printf("\n", chartJsUrl.c_str()); } response->print(FPSTR(chartsHtml_HeadEnd)); response->print("\n"); response->print(FPSTR(commonNav)); response->print("
\n"); if (canLoadChartJs) { String bodyStart = FPSTR(chartsHtml_BodyStartAndUI); String selectorsUI = FPSTR(chartsHtml_SelectorsUI); bodyStart.replace(F("{SELECTOR_UI_PLACEHOLDER}"), selectorsUI); String errorMessageHtml = ""; if (demoModus) { errorMessageHtml = "
Demo-Betrieb:
Es sind keine Sensoren angeschlossen und daher keine Temperaturwerte verfügbar.
"; } else if (wasserSensorError && dampfSensorError) { errorMessageHtml = "
Die Daten der Temperatursensoren sind derzeit nicht verfügbar!
Bitte Temperatur-Sensoren prüfen!
"; } else if (wasserSensorError) { errorMessageHtml = "
Temperaturwert für Wasser nicht verfügbar
"; } else if (dampfSensorError) { errorMessageHtml = "
Temperaturwert für Dampf nicht verfügbar
"; } bodyStart.replace(F("{SENSOR_ERROR_MESSAGES_PLACEHOLDER}"), errorMessageHtml); response->print(bodyStart); response->print(""); response->print("
\n"); // Schließt chart-container response->print("\n"); } else { // Fehlerfall HTML generieren response->print("

Temperatur-Chart

\n"); response->print("
"); response->print("

Chart kann nicht angezeigt werden

"); response->print("

Die Chart-Bibliothek (chart.umd.min.js) wurde weder im Dateisystem gefunden, noch besteht eine Internetverbindung zum Laden vom CDN.

"); response->print("

Bitte laden Sie die Datei chart.umd.min.js herunter und laden Sie sie über den Dateimanager hoch, oder verbinden Sie das Gerät mit dem Internet.

"); response->print("
\n"); } response->print("\n"); response->print(FPSTR(chartsHtml_BodyEnd)); request->send(response); } /************************************************************************************ * Profil-Funktionen (Anzeige) - Heap-Optimiert mit Chunked Sending & Leer-Check ************************************************************************************/ // --- PROGMEM Chunks für handleProfiles --- // Kopfzeile bleibt gleich wie im Original `profilesHtml` static const char profilesHtmlHead[] PROGMEM = R"rawliteral( Profile )rawliteral"; // bleibt gleich wie im Original `profilesHtml2` static const char profilesHtmlHeadEnd[] PROGMEM = R"rawliteral( )rawliteral"; // Aufteilung von profilesHtml3 in Chunks static const char profilesChunk_Intro[] PROGMEM = R"rawliteral(

Temperaturprofile

Es können zwei unabhängige Profile angelegt oder geladen werden.

In den Profilen werden gespeichert:
- Setpoint Wasser
- Setpoint Dampf
- Eco-Modus-Einstellungen
- Aufheizen für Dampf verzögern
- Profilname

Profil 1 (Name: )rawliteral"; // Endet vor dem 1. {PROFILE1NAME} static const char profilesChunk_P1_Val[] PROGMEM = R"rawliteral()

Profil 1 laden

Temperatureinstellungen aus Profil 1 laden.

Profil 2 (Name: )rawliteral"; // Endet vor dem 1. {PROFILE2NAME} static const char profilesChunk_P2_Val[] PROGMEM = R"rawliteral()

Profil 2 laden

Temperatureinstellungen aus Profil 2 laden
)rawliteral"; void handleProfiles(AsyncWebServerRequest *request) { TemperatureProfile p1, p2; EEPROM.get(EEPROM_ADDR_PROFILE_1, p1); p1.profileName[sizeof(p1.profileName) - 1] = '\0'; EEPROM.get(EEPROM_ADDR_PROFILE_2, p2); p2.profileName[sizeof(p2.profileName) - 1] = '\0'; AsyncResponseStream *response = request->beginResponseStream("text/html"); response->print(FPSTR(profilesHtmlHead)); response->print("\n"); response->print(FPSTR(profilesHtmlHeadEnd)); response->print(FPSTR(commonNav)); response->print(FPSTR(profilesChunk_Intro)); if (strlen(p1.profileName) > 0) { response->print(p1.profileName); } response->print(FPSTR(profilesChunk_P1_Val)); if (strlen(p1.profileName) > 0) { response->print(p1.profileName); } response->print(FPSTR(profilesChunk_P1_EndForm)); if (strlen(p2.profileName) > 0) { response->print(p2.profileName); } response->print(FPSTR(profilesChunk_P2_Val)); if (strlen(p2.profileName) > 0) { response->print(p2.profileName); } response->print(FPSTR(profilesChunk_P2_EndForm)); request->send(response); } // Speichert aktuelles Setup in Profil 1 - URL angepasst void handleSaveProfile1(AsyncWebServerRequest *request) { TemperatureProfile p; EEPROM.get(EEPROM_ADDR_PROFILE_1, p); if (request->hasParam("profileName", true)) { strncpy(p.profileName, request->getParam("profileName", true)->value().c_str(), sizeof(p.profileName) - 1); p.profileName[sizeof(p.profileName) - 1] = '\0'; } EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, p.setpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, p.setpointDampf); p.ecoModeMinutes = ecoModeMinutes; p.dynamicEcoActive = dynamicEcoActive; p.dampfVerzoegerung = dampfVerzoegerung; EEPROM.put(EEPROM_ADDR_PROFILE_1, p); EEPROM.commit(); request->redirect("/Profile"); } // Lädt Profil 1 in die aktuellen Parameter - URL angepasst void handleLoadProfile1(AsyncWebServerRequest *request) { TemperatureProfile p; EEPROM.get(EEPROM_ADDR_PROFILE_1, p); SetpointWasser = p.setpointWasser; SetpointDampf = p.setpointDampf; ecoModeMinutes = p.ecoModeMinutes; dynamicEcoActive = p.dynamicEcoActive; dampfVerzoegerung = p.dampfVerzoegerung; EEPROM.put(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.put(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); EEPROM.put(EEPROM_ADDR_ECOMODE_MINUTES, ecoModeMinutes); EEPROM.put(EEPROM_ADDR_DYNAMIC_ECO_MODE, dynamicEcoActive); EEPROM.put(EEPROM_ADDR_STEAM_DELAY, dampfVerzoegerung); EEPROM.commit(); ecoModeAktiv = false; ecoModeActivatedTime = 0; request->redirect("/Profile"); } // Speichert aktuelles Setup in Profil 2 - Async angepasst void handleSaveProfile2(AsyncWebServerRequest *request) { TemperatureProfile p; EEPROM.get(EEPROM_ADDR_PROFILE_2, p); if (request->hasParam("profileName", true)) { strncpy(p.profileName, request->getParam("profileName", true)->value().c_str(), sizeof(p.profileName) - 1); p.profileName[sizeof(p.profileName) - 1] = '\0'; } EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, p.setpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, p.setpointDampf); p.ecoModeMinutes = ecoModeMinutes; p.dynamicEcoActive = dynamicEcoActive; p.dampfVerzoegerung = dampfVerzoegerung; EEPROM.put(EEPROM_ADDR_PROFILE_2, p); EEPROM.commit(); request->redirect("/Profile"); } // Lädt Profil 2 in die aktuellen Parameter - Async angepasst void handleLoadProfile2(AsyncWebServerRequest *request) { TemperatureProfile p; EEPROM.get(EEPROM_ADDR_PROFILE_2, p); SetpointWasser = p.setpointWasser; SetpointDampf = p.setpointDampf; ecoModeMinutes = p.ecoModeMinutes; dynamicEcoActive = p.dynamicEcoActive; dampfVerzoegerung = p.dampfVerzoegerung; EEPROM.put(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.put(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); EEPROM.put(EEPROM_ADDR_ECOMODE_MINUTES, ecoModeMinutes); EEPROM.put(EEPROM_ADDR_DYNAMIC_ECO_MODE, dynamicEcoActive); EEPROM.put(EEPROM_ADDR_STEAM_DELAY, dampfVerzoegerung); EEPROM.commit(); ecoModeAktiv = false; ecoModeActivatedTime = 0; request->redirect("/Profile"); } /************************************************************************************ * Handler für die Dateimanager-Seite (/Dateimanager) - Heap-Optimiert ************************************************************************************/ // --- PROGMEM Chunks für handleFileManager --- static const char fileMgrHead[] PROGMEM = R"rawliteral( Dateimanager

Dateimanager (LittleFS)

Vorhandene Dateien

    )rawliteral"; // Endet nach
      static const char fileMgrListItemStart[] PROGMEM = R"rawliteral(
    • )rawliteral"; // Start eines Listeneintrags + Span static const char fileMgrListItemMid1[] PROGMEM = R"rawliteral( ( )rawliteral"; // Nach Dateiname, vor Größe static const char fileMgrListItemMid2[] PROGMEM = R"rawliteral( Bytes)
    • )rawliteral"; // Nach Delete-Dateiname, bis Ende
    • static const char fileMgrNoFiles[] PROGMEM = R"rawliteral(
    • Keine Dateien im Dateisystem gefunden.
    • )rawliteral"; static const char fileMgrListError[] PROGMEM = R"rawliteral(
    • Fehler beim Auflisten der Dateien.
    • )rawliteral"; static const char fileMgrOpenError[] PROGMEM = R"rawliteral(
    • Fehler beim Öffnen des Root-Verzeichnisses.
    • )rawliteral"; static const char fileMgrListEnd[] PROGMEM = R"rawliteral(

    )rawliteral"; // Ende der Liste + Trennlinie static const char fileMgrUploadForm[] PROGMEM = R"rawliteral(

    Datei hochladen



    )rawliteral"; static const char fileMgrInfoStart[] PROGMEM = R"rawliteral(

    Info

    )rawliteral"; static const char fileMgrInfoTotal[] PROGMEM = R"rawliteral(Gesamtspeicher: )rawliteral"; static const char fileMgrInfoUsed[] PROGMEM = R"rawliteral( Bytes
    Belegt: )rawliteral"; static const char fileMgrInfoFree[] PROGMEM = R"rawliteral( Bytes
    Frei: )rawliteral"; static const char fileMgrInfoEnd[] PROGMEM = R"rawliteral( Bytes
    )rawliteral"; static const char fileMgrInfoError[] PROGMEM = R"rawliteral(Fehler beim Abrufen der Dateisystem-Informationen.
    )rawliteral"; static const char fileMgrBodyEnd[] PROGMEM = R"rawliteral()rawliteral"; void handleFileManager(AsyncWebServerRequest *request) { Serial.println("Anfrage für /Dateimanager empfangen (Async)."); char buffer[20]; AsyncResponseStream *response = request->beginResponseStream("text/html"); response->print(FPSTR(fileMgrHead)); // --- Dateien auflisten --- int fileCount = 0; Dir dir = LittleFS.openDir("/"); while (dir.next()) { fileCount++; String currentFileName = dir.fileName(); size_t currentFileSize = dir.fileSize(); response->print(FPSTR(fileMgrListItemStart)); if (currentFileName.length() > 0) response->print(currentFileName.c_str()); response->print(FPSTR(fileMgrListItemMid1)); snprintf(buffer, sizeof(buffer), "%d", currentFileSize); response->print(buffer); response->print(FPSTR(fileMgrListItemMid2)); if (currentFileName.length() > 0) response->print(currentFileName.c_str()); response->print(FPSTR(fileMgrListItemMid3)); if (currentFileName.length() > 0) response->print(currentFileName.c_str()); response->print(FPSTR(fileMgrListItemEnd)); } if (fileCount == 0) { File root = LittleFS.open("/", "r"); if (root) { File file = root.openNextFile(); if (!file) { response->print(FPSTR(fileMgrNoFiles)); } else { response->print(FPSTR(fileMgrListError)); file.close(); } root.close(); } else { response->print(FPSTR(fileMgrOpenError)); } } response->print(FPSTR(fileMgrListEnd)); response->print(FPSTR(fileMgrUploadForm)); response->print(FPSTR(fileMgrInfoStart)); FSInfo fs_info; if (LittleFS.info(fs_info)) { response->print(FPSTR(fileMgrInfoTotal)); snprintf(buffer, sizeof(buffer), "%u", fs_info.totalBytes); response->print(buffer); response->print(FPSTR(fileMgrInfoUsed)); snprintf(buffer, sizeof(buffer), "%u", fs_info.usedBytes); response->print(buffer); response->print(FPSTR(fileMgrInfoFree)); snprintf(buffer, sizeof(buffer), "%u", fs_info.totalBytes - fs_info.usedBytes); response->print(buffer); response->print(FPSTR(fileMgrInfoEnd)); } else { response->print(FPSTR(fileMgrInfoError)); } response->print(FPSTR(fileMgrBodyEnd)); request->send(response); } /************************************************************************************ * Handler während des Datei-Uploads ************************************************************************************/ void handleFileUploadProgress(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { if(index == 0){ // UPLOAD_FILE_START // Basisschutz gegen Pfadmanipulation if (filename.indexOf('/') != -1 && filename.indexOf('/') == 0) { filename = filename.substring(filename.lastIndexOf('/') + 1); } filename = "/" + filename; // Immer im Root speichern Serial.printf("Datei Upload Start: %s\n", filename.c_str()); fsUploadFile = LittleFS.open(filename, "w"); if (!fsUploadFile) { Serial.println("Konnte Datei für Upload nicht erstellen/öffnen!"); // Hier könnte man den Request abbrechen, z.B. request->send(500, ...); return; } } if (fsUploadFile) { // UPLOAD_FILE_WRITE size_t bytesWritten = fsUploadFile.write(data, len); if (bytesWritten != len) { Serial.printf("Fehler beim Schreiben! Erwartet: %d, Geschrieben: %d\n", len, bytesWritten); fsUploadFile.close(); LittleFS.remove(fsUploadFile.name()); // Versuch, unvollständige Datei zu löschen // Den Request hier informieren? // request->send(500, "text/plain", "Fehler beim Schreiben der Datei"); // return; // Verhindert weitere Verarbeitung } } else { // Kann passieren, wenn UPLOAD_FILE_START fehlgeschlagen ist if (len > 0) { // Nur loggen, wenn tatsächlich Daten kamen Serial.println("Datei Upload WRITE, aber keine Datei geöffnet!"); } } if (final) { // UPLOAD_FILE_END if (fsUploadFile) { String finalName = fsUploadFile.name(); // Name merken vor dem Schließen fsUploadFile.close(); Serial.printf("Datei Upload Ende: %s. Gesamtgröße: %d Bytes\n", finalName.c_str(), index + len); } else { Serial.println("Datei Upload END, aber keine Datei geöffnet!"); } // Die Antwort wird im handleFileUploadedResponse Handler gesendet, der nach `final=true` aufgerufen wird. } } /************************************************************************************ * Handler NACHDEM der Upload abgeschlossen (oder fehlgeschlagen) ist ************************************************************************************/ void handleFileUploadedResponse(AsyncWebServerRequest *request) { // Redirect zurück zum Dateimanager. request->redirect("/Dateimanager"); } /************************************************************************************ * Handler zum Herunterladen einer Datei ************************************************************************************/ void handleFileDownload(AsyncWebServerRequest *request) { if (!request->hasParam("name")) { request->send(400, "text/plain", "FEHLER: Dateiname zum Herunterladen fehlt!"); return; } String filenameParam = request->getParam("name")->value(); String filePath = filenameParam; if (!filePath.startsWith("/")) { filePath = "/" + filePath; } if (filePath == "/") { request->send(400, "text/plain", "FEHLER: Root-Verzeichnis kann nicht heruntergeladen werden."); return; } Serial.printf("Download-Anfrage für: %s (Parameter: %s)\n", filePath.c_str(), filenameParam.c_str()); if (LittleFS.exists(filePath)) { // Async sendFile sendet die Datei effizient request->send(LittleFS, filePath, "application/octet-stream", true); // true = force download Serial.printf("Datei '%s' zum Senden übergeben.\n", filePath.c_str()); } else { Serial.println("Datei zum Herunterladen nicht gefunden!"); request->send(404, "text/plain", "FEHLER: Datei nicht gefunden."); } } /************************************************************************************ * Handler zum Löschen einer Datei ************************************************************************************/ void handleFileDelete(AsyncWebServerRequest *request) { if (!request->hasParam("name")) { request->send(400, "text/plain", "FEHLER: Dateiname zum Löschen fehlt!"); return; } String filename = request->getParam("name")->value(); if (!filename.startsWith("/")) { filename = "/" + filename; } if (filename == "/") { request->send(400, "text/plain", "FEHLER: Root-Verzeichnis kann nicht gelöscht werden."); return; } Serial.printf("Versuche Datei zu löschen: %s\n", filename.c_str()); if (LittleFS.exists(filename)) { if (LittleFS.remove(filename)) { Serial.println("Datei erfolgreich gelöscht."); request->redirect("/Dateimanager"); } else { Serial.println("Fehler beim Löschen der Datei!"); request->send(500, "text/plain", "FEHLER: Datei konnte nicht gelöscht werden."); } } else { Serial.println("Datei zum Löschen nicht gefunden!"); request->send(404, "text/plain", "FEHLER: Datei nicht gefunden."); } } /************************************************************************************ * Handler zum Ausliefern der lokalen Chart.js Datei aus LittleFS ************************************************************************************/ void handleServeChartJs(AsyncWebServerRequest *request) { const char* chartJsPath = "/chart.umd.min.js"; if (LittleFS.exists(chartJsPath)) { request->send(LittleFS, chartJsPath, "text/javascript"); Serial.println("Lokale chart.umd.min.js ausgeliefert (Async)."); } else { Serial.println("Angeforderte lokale chart.umd.min.js nicht gefunden!"); request->send(404, "text/plain", "Chart.js nicht gefunden"); } } /************************************************************************************ * Handler für den Firmware-Upload-Prozess (während des Uploads) ************************************************************************************/ void handleFirmwareUploadProgress(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { if(index == 0){ // UPLOAD_FILE_START Serial.printf("Firmware Update gestartet: %s\n", filename.c_str()); passwordFound = false; passMatchPos = 0; // Prüfe, ob genügend Speicher vorhanden ist // ESP.getFreeSketchSpace() gibt den freien Platz zurück. Update.begin prüft intern nochmals. size_t updateSize = (ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000; if (!Update.begin(updateSize)) { Update.printError(Serial); // Update kann nicht gestartet werden, hier den Request informieren? // z.B. globale Variable setzen und in handleFirmwareUpdateResponse auswerten Update.end(false); // Markiere Update als fehlgeschlagen return; // Verhindere weitere Verarbeitung } } // Verarbeite Daten nur, wenn Update.begin erfolgreich war if(Update.isRunning()) { // Prüfen auf Firmware-Passwort während des Schreibens for (size_t i = 0; i < len; i++) { char c = (char)data[i]; if (!passwordFound) { if (c == FIRMWARE_PASSWORD[passMatchPos]) { passMatchPos++; if (passMatchPos == passLength) { passwordFound = true; Serial.println("Firmware-Kennwort gefunden!"); } } else { passMatchPos = (c == FIRMWARE_PASSWORD[0]) ? 1 : 0; } } yield(); } // Daten in den Flash schreiben if (Update.write(data, len) != len) { Update.printError(Serial); // Fehler beim Schreiben, Update abbrechen Update.end(false); return; // Verhindere weitere Verarbeitung } } if (final) { // UPLOAD_FILE_END if(Update.isRunning()) { // Nur abschließen, wenn Update noch lief if (!passwordFound) { Serial.println("Kennwort nicht in Firmware gefunden! Update wird abgebrochen."); Update.end(false); } else { if (Update.end(true)) { Serial.printf("Update erfolgreich: %u Bytes\n", index + len); } else { Update.printError(Serial); } } } else { Serial.println("Update Ende empfangen, aber Update lief nicht mehr (vermutlich Fehler zuvor)."); } // Die Antwort wird im handleFirmwareUpdateResponse Handler gesendet. } } /************************************************************************************ * Handler für die Antwort nach einem Firmware-Update - Heap-Optimiert ************************************************************************************/ // PROGMEM Chunks für die Seite static const char updateDoneHtmlStart[] PROGMEM = R"rawliteral( Firmware-Update )rawliteral"; static const char updateDoneHtmlHeadEnd[] PROGMEM = R"rawliteral( )rawliteral"; static const char updateDoneHtmlBodyStart[] PROGMEM = R"rawliteral(

    )rawliteral"; // Endet vor {status_message} static const char updateDoneHtmlBodyEnd[] PROGMEM = R"rawliteral(

    Weiterleitung zur Startseite in 30 Sekunden ...

    )rawliteral"; // Beginnt nach {status_message} void handleFirmwareUpdateResponse(AsyncWebServerRequest *request) { String statusMessage; // Status basiert auf dem letzten Update-Versuch if (!passwordFound && !Update.hasError()) { // Explizit prüfen, ob Fehler durch Passwort war statusMessage = F("Update abgebrochen! Die Firmware konnte nicht validiert werden."); } else if (Update.hasError()) { statusMessage = F("Update fehlgeschlagen!"); // Optional: Detailliertere Fehlermeldung von Update.getError() holen // statusMessage += " (Fehlercode: " + String(Update.getError()) + ")"; } else { statusMessage = F("Update erfolgreich!"); } // Seite senden AsyncResponseStream *response = request->beginResponseStream("text/html"); response->print(FPSTR(updateDoneHtmlStart)); response->print("\n"); response->print(FPSTR(updateDoneHtmlHeadEnd)); response->print(FPSTR(commonNav)); response->print(FPSTR(updateDoneHtmlBodyStart)); if (statusMessage.length() > 0) { response->print(statusMessage); } response->print(FPSTR(updateDoneHtmlBodyEnd)); request->send(response); // Neustart-Logik if (!Update.hasError() && passwordFound) { // Nur neustarten bei Erfolg delay(1000); ESP.restart(); } } /************************************************************************************ * Schreibt einen gültigen Shot (Dauer > 20s) in die CSV-Logdatei. * Dateiname: Nutzungsstatistik.csv * Format: UnixTimestamp,DauerInMs ************************************************************************************/ void logShot(unsigned long durationMs) { // Nur loggen, wenn Zeit synchronisiert ist (prüft Jahr > 2023) time_t now_t; time(&now_t); // Holt aktuelle Zeit (Unix Timestamp) vom System struct tm timeinfo; localtime_r(&now_t, &timeinfo); // Konvertiert in lokale Zeit if (timeinfo.tm_year > (2023 - 1900)) { // tm_year ist Jahre seit 1900 // *** DATEINAME GEÄNDERT *** File logFile = LittleFS.open("/Nutzungsstatistik.csv", "a"); // "a" = Append if (!logFile) { // *** DATEINAME IN FEHLERMELDUNG GEÄNDERT *** Serial.println("FEHLER: Konnte Nutzungsstatistik.csv zum Schreiben nicht öffnen!"); return; } String logEntry = String(now_t) + "," + String(durationMs) + "\n"; if (logFile.print(logEntry)) { // Serial.printf("Shot geloggt: %lu ms (Timestamp: %lu)\n", durationMs, now_t); // Optional: Debug-Ausgabe } else { // *** DATEINAME IN FEHLERMELDUNG GEÄNDERT *** Serial.println("FEHLER: Konnte nicht in Nutzungsstatistik.csv schreiben!"); } logFile.close(); } else { Serial.println("WARNUNG: NTP Zeit noch nicht synchronisiert, Shot nicht geloggt."); } } // --- Hilfsfunktionen für Datumsvergleiche --- bool isSameDay(time_t timestamp, time_t now) { struct tm ts_tm, now_tm; localtime_r(×tamp, &ts_tm); localtime_r(&now, &now_tm); return (ts_tm.tm_year == now_tm.tm_year && ts_tm.tm_mon == now_tm.tm_mon && ts_tm.tm_mday == now_tm.tm_mday); } bool isSameWeek(time_t timestamp, time_t now) { struct tm ts_tm, now_tm; localtime_r(×tamp, &ts_tm); localtime_r(&now, &now_tm); int ts_wday = (ts_tm.tm_wday == 0) ? 6 : ts_tm.tm_wday - 1; // Mo=0..So=6 int now_wday = (now_tm.tm_wday == 0) ? 6 : now_tm.tm_wday - 1; time_t ts_monday_start_of_day = timestamp - ts_tm.tm_hour * 3600 - ts_tm.tm_min * 60 - ts_tm.tm_sec - ts_wday * 86400; time_t now_monday_start_of_day = now - now_tm.tm_hour * 3600 - now_tm.tm_min * 60 - now_tm.tm_sec - now_wday * 86400; return (ts_monday_start_of_day == now_monday_start_of_day); } bool isSameMonth(time_t timestamp, time_t now) { struct tm ts_tm, now_tm; localtime_r(×tamp, &ts_tm); localtime_r(&now, &now_tm); return (ts_tm.tm_year == now_tm.tm_year && ts_tm.tm_mon == now_tm.tm_mon); } /************************************************************************************ * Liest die Log-Datei (Nutzungsstatistik.csv) und berechnet Statistiken. * Gibt eine ShotStats-Struktur zurück. ************************************************************************************/ ShotStats calculateShotStatistics() { ShotStats stats; // Initialisiert alle Felder mit 0/false time_t now_t; time(&now_t); struct tm timeinfo; localtime_r(&now_t, &timeinfo); // Prüfe, ob die *aktuelle* Zeit gültig erscheint (wichtig für relative Vergleiche wie Gestern etc.) bool timeValid = (timeinfo.tm_year > (2023 - 1900)); // Beispiel: Prüft ob Jahr > 2023 File logFile = LittleFS.open("/Nutzungsstatistik.csv", "r"); if (!logFile || logFile.size() == 0) { if (logFile) logFile.close(); stats.historyAvailable = false; // Serial.println("Statistik: Log-Datei nicht gefunden oder leer."); // Optional: Debug-Ausgabe return stats; } stats.historyAvailable = true; // Datei existiert und ist > 0 Bytes int lineCounter = 0; // Zähler für yield() while (logFile.available()) { String line = logFile.readStringUntil('\n'); line.trim(); if (line.length() == 0) continue; int commaIndex = line.indexOf(','); if (commaIndex != -1) { // strtoull verwenden, um mögliche Überläufe bei 32-Bit time_t zu vermeiden time_t timestamp = (time_t)strtoull(line.substring(0, commaIndex).c_str(), NULL, 10); unsigned long durationMs = strtoul(line.substring(commaIndex + 1).c_str(), NULL, 10); if (timestamp > 0 && durationMs > 0) { // Grundlegende Prüfung der gelesenen Werte stats.totalShotsLogged++; stats.totalDurationMs += durationMs; // Ersten und letzten Zeitstempel aktualisieren if (stats.firstShotTimestamp == 0 || timestamp < stats.firstShotTimestamp) { stats.firstShotTimestamp = timestamp; } if (timestamp > stats.lastShotTimestamp) { stats.lastShotTimestamp = timestamp; } // Zeitbasierte Zähler nur füllen, wenn aktuelle Zeit gültig ist if (timeValid) { if (isSameDay(timestamp, now_t)) { stats.shotsToday++; } if (isSameWeek(timestamp, now_t)) { stats.shotsThisWeek++; } if (isSameMonth(timestamp, now_t)) { stats.shotsThisMonth++; } // --- NEUE ZÄHLER AKTUALISIEREN --- if (isYesterday(timestamp, now_t)) { stats.shotsYesterday++; } if (isLastWeek(timestamp, now_t)) { stats.shotsLastWeek++; } if (isLastMonth(timestamp, now_t)) { stats.shotsLastMonth++; } } } else { Serial.printf("WARNUNG: Ungültige Zeile in Log übersprungen: %s\n", line.c_str()); } } else { Serial.printf("WARNUNG: Komma in Logzeile nicht gefunden: %s\n", line.c_str()); } lineCounter++; if (lineCounter % 50 == 0) yield(); // Watchdog bei großen Dateien vermeiden } logFile.close(); // Berechnungen nach dem Lesen der Datei if (stats.totalShotsLogged > 0) { stats.averageDurationSec = (double)stats.totalDurationMs / 1000.0 / stats.totalShotsLogged; // --- Durchschnitt Shots/Tag --- if (stats.firstShotTimestamp > 0 && timeValid && now_t > stats.firstShotTimestamp) { unsigned long secondsElapsed = now_t - stats.firstShotTimestamp; unsigned long daysElapsed = secondsElapsed / 86400UL; // Ganze Tage seit erstem Shot if (daysElapsed == 0) { daysElapsed = 1; } // Mindestens 1 Tag annehmen, um Division durch 0 zu vermeiden stats.avgShotsPerDay = (double)stats.totalShotsLogged / daysElapsed; } } return stats; } /************************************************************************************ * Handler zum Erstellen und Senden des formatierten Verlaufs (aus Nutzungsstatistik.csv) * als TXT-Datei. ************************************************************************************/ void handleDownloadNutzungsstatistik(AsyncWebServerRequest *request) { File logFile = LittleFS.open("/Nutzungsstatistik.csv", "r"); if (!logFile) { request->send(404, "text/plain", "Fehler: Log-Datei nicht gefunden."); return; } if (logFile.size() == 0) { logFile.close(); request->send(200, "text/plain", "Nutzungsstatistik ist leer."); return; } AsyncResponseStream *response = request->beginResponseStream("text/plain; charset=utf-8"); response->addHeader("Content-Disposition", "attachment; filename=\"Nutzungsverlauf.txt\""); char lineBuffer[100]; char dateBuffer[25]; char durationBuffer[10]; response->print("Datum, Uhrzeit, Dauer (s)\n"); response->print("--------------------------\n"); int lineCount = 0; while (logFile.available()) { String line = logFile.readStringUntil('\n'); line.trim(); if (line.length() == 0) continue; int commaIndex = line.indexOf(','); if (commaIndex != -1) { time_t timestamp = (time_t)strtoull(line.substring(0, commaIndex).c_str(), NULL, 10); // Verwende strtoull unsigned long durationMs = strtoul(line.substring(commaIndex + 1).c_str(), NULL, 10); if (timestamp > 0 && durationMs > 0) { struct tm timeinfo; localtime_r(×tamp, &timeinfo); strftime(dateBuffer, sizeof(dateBuffer), "%d.%m.%Y, %H:%M:%S", &timeinfo); snprintf(durationBuffer, sizeof(durationBuffer), "%.1f", durationMs / 1000.0); snprintf(lineBuffer, sizeof(lineBuffer), "%s, %s\n", dateBuffer, durationBuffer); response->print(lineBuffer); lineCount++; if (lineCount % 20 == 0) { yield(); } } } yield(); } logFile.close(); request->send(response); // Send the response stream Serial.printf("Formatierter Verlauf mit %d Zeilen gesendet.\n", lineCount); } /************************************************************************************ * Hilfsfunktionen zum Erstellen der Statistik-Daten ************************************************************************************/ // Prüft, ob der timestamp am Vortag von 'now' liegt bool isYesterday(time_t timestamp, time_t now) { time_t yesterday_t = now - 86400UL; // Zeitstempel für vor 24 Stunden struct tm ts_tm, yest_tm; localtime_r(×tamp, &ts_tm); // Konvertiert Timestamp in lokale Zeit-Struktur localtime_r(&yesterday_t, &yest_tm); // Konvertiert Zeitstempel von Gestern // Prüfen, ob Jahr, Monat und Tag übereinstimmen return (ts_tm.tm_year == yest_tm.tm_year && ts_tm.tm_mon == yest_tm.tm_mon && ts_tm.tm_mday == yest_tm.tm_mday); } // Prüft, ob der timestamp in der Kalenderwoche *vor* der von 'now' liegt (Annahme: Woche beginnt Montag) bool isLastWeek(time_t timestamp, time_t now) { struct tm ts_tm, now_tm; localtime_r(×tamp, &ts_tm); localtime_r(&now, &now_tm); // Berechne den Start der aktuellen Woche (Montag 00:00:00) int now_wday = (now_tm.tm_wday == 0) ? 6 : now_tm.tm_wday - 1; // Korrigiert Wochentag Mo=0..So=6 time_t startOfThisWeek = now - (now_tm.tm_hour * 3600) - (now_tm.tm_min * 60) - now_tm.tm_sec - (now_wday * 86400UL); // Berechne den Start der letzten Woche (Montag 00:00:00) time_t startOfLastWeek = startOfThisWeek - (7 * 86400UL); // Prüfe, ob der Timestamp in der letzten Woche liegt (>= Start der letzten Woche UND < Start dieser Woche) return (timestamp >= startOfLastWeek && timestamp < startOfThisWeek); } // Prüft, ob der timestamp im Kalendermonat *vor* dem von 'now' liegt bool isLastMonth(time_t timestamp, time_t now) { struct tm ts_tm, now_tm; localtime_r(×tamp, &ts_tm); localtime_r(&now, &now_tm); int lastMonth = now_tm.tm_mon - 1; // Monat vor dem aktuellen int lastMonthYear = now_tm.tm_year; // Jahr des letzten Monats (Standard: aktuelles Jahr) if (lastMonth < 0) { // Sonderfall: Aktueller Monat ist Januar (tm_mon = 0) lastMonth = 11; // Letzter Monat war Dezember (tm_mon = 11) lastMonthYear = now_tm.tm_year - 1; // im vorherigen Jahr } // Prüfe, ob Jahr und Monat des Timestamps mit dem letzten Monat übereinstimmen return (ts_tm.tm_year == lastMonthYear && ts_tm.tm_mon == lastMonth); } /************************************************************************************ * Handler zum Löschen der Nutzungsstatistik-Datei (/Nutzungsstatistik.csv) ************************************************************************************/ void handleDeleteStatistics(AsyncWebServerRequest *request) { const char* statsFilename = "/Nutzungsstatistik.csv"; Serial.printf("Anfrage zum Löschen von '%s' erhalten.\n", statsFilename); bool success = false; if (LittleFS.exists(statsFilename)) { if (LittleFS.remove(statsFilename)) { Serial.printf("Datei '%s' erfolgreich gelöscht.\n", statsFilename); success = true; // shotCounter = 0; EEPROM.put(EEPROM_ADDR_SHOTCOUNTER, shotCounter); // Optional Zähler resetten } else { Serial.printf("FEHLER: Datei '%s' konnte nicht gelöscht werden!\n", statsFilename); } } else { Serial.printf("Datei '%s' nicht gefunden, nichts zu löschen.\n", statsFilename); success = true; } request->redirect("/Info"); } /************************************************************************************ * Handler zum Umschalten des Wartungsmodus ************************************************************************************/ void handleToggleWartungsmodus(AsyncWebServerRequest *request) { wartungsModusAktiv = !wartungsModusAktiv; if(!wartungsModusAktiv) { EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); } else { SetpointWasser = wartungsModusTemp; SetpointDampf = wartungsModusTemp; } Serial.print("Wartungsmodus: "); Serial.println(wartungsModusAktiv ? "aktiviert" : "deaktiviert"); request->redirect("/Service"); }