/************************************************************************************ * 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 * - ESP32: D1 mini ************************************************************************************/ /************************************************************************************ * Includes & Bibliotheken ************************************************************************************/ #include #include #include #include #include #include // Modifizierte AutoTune-Funktion - Muss als .zip eingebunden werden #include "max6675.h" #include // Für isnan() #include // Dateisystem-Basis #include // LittleFS-Implementierung (für ESP32 ggf. explizit nötig) #include #include #include // Für Zeitfunktionen (struct tm, mktime, etc.) #include // Erforderlich für std::vector // Plattformspezifische Includes #ifdef ESP32 #include // ESP32 WiFi Core #include // ESP32 WebServer #include // ESP32 mDNS Implementation #include // Für Firmware-Updates #define LED_BUILTIN 2 // Standard LED Pin für viele ESP32 Boards (optional, falls genutzt) #else // ESP8266 #include #include #include // LED_BUILTIN ist für ESP8266 meistens vordefiniert (oft 2 oder DX Pin) #ifndef LED_BUILTIN #define LED_BUILTIN 2 // Fallback falls nicht vordefiniert #endif #endif /************************************************************************************ * Webserver und zugehörige Objekte ************************************************************************************/ #ifdef ESP32 WebServer server(80); // ESP32 verwendet WebServer #else // ESP8266 ESP8266WebServer server(80); // ESP8266 verwendet ESP8266WebServer #endif /************************************************************************************ * Hardware-Pinbelegung (Plattformabhängig) ************************************************************************************/ #ifdef ESP32 // --- ESP32 Pinout --- #define SSR_WASSER_PIN 21 // SSR für Wasser-Heizung (GPIO21) #define SSR_DAMPF_PIN 22 // SSR für Dampf-Heizung (GPIO22) #define SHOT_TIMER_PIN 5 // Input für Shot-Timer (GPIO5) #define OLED_SDA 17 // SDA-Leitung für OLED (GPIO17) #define OLED_SCK 16 // SCK-Leitung für OLED (GPIO16) #define ANALOG_NTC_PIN 36 // Analoger Eingang für NTC (ADC1_CH0 - VP) - Nur ADC1 Pins (GPIO 32-39) sind sicher im WiFi Betrieb! // Pins für Pumpe und Ventil (Nur ESP32) #define PUMP_PIN 4 // SSR für Pumpe (GPIO4) #define VALVE_PIN 2 // SSR für Magnetventil (GPIO2) // MAX6675 (Dampf-Temperatursensor) - SPI Pins für ESP32 #define MAX6675_SCK 23 // Serial Clock (GPIO23) - Standard HSPI SCK #define MAX6675_CS 19 // Chip Select (GPIO19) - Frei wählbar #define MAX6675_SO 18 // Serial Out (GPIO18) - Standard HSPI MISO (oft als 'SDO' oder 'SO' bezeichnet) \ // Mosi (SDI) vom MAX6675 wird nicht benötigt, da nur gelesen wird. #else // ESP8266 \ // --- ESP8266 Pinout (Original) --- #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 // MAX6675 (Dampf-Temperatursensor) - SPI Pins für ESP8266 #define MAX6675_SCK D7 // Serial Clock (GPIO13 / D7) - Kann auch D5 (GPIO14/SCK) sein #define MAX6675_CS D6 // Chip Select (GPIO12 / D6) - Kann auch D8 (GPIO15/CS) sein #define MAX6675_SO D5 // Serial Out (GPIO14 / D5) - Kann auch D6 (GPIO12/MISO) sein #define A0 A0 // Explizite Definition für ESP8266 falls analogRead(A0) direkt verwendet wird #endif /************************************************************************************ * MAX6675 (Dampf-Temperatursensor) ************************************************************************************/ // Die Pins werden oben plattformabhängig definiert. MAX6675 thermocouple(MAX6675_SCK, MAX6675_CS, MAX6675_SO); /************************************************************************************ * Display-Objekt (Adafruit SH1106G) ************************************************************************************/ // Dieser Block muss vorhanden sein! Adafruit_SH1106G display = Adafruit_SH1106G(128, 64, &Wire); /************************************************************************************ * Firmware-Informationen ************************************************************************************/ String version = "3.0.0"; 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 NTC_T0 = 298.15; // Referenztemperatur in Kelvin (25°C) const double NTC_R0 = 50000.0; // NTC-Widerstand bei 25°C const double beta = 3950.0; // Beta-Wert /************************************************************************************ * Temperatur-Profil-Struktur (ERWEITERT) * - Speichert nahezu alle konfigurierbaren Einstellungen pro Profil ************************************************************************************/ // VERSIONSNUMMER const uint16_t CURRENT_PROFILE_VERSION = 2; // Versionsnummer für die Struktur struct TemperatureProfile { // --- Identifikation & Version --- uint16_t profileVersion; // Zur Kompatibilitätsprüfung beim Laden char profileName[31]; // Angezeigter Name (etwas mehr Platz + Nullterminator) // --- PID Einstellungen (Seite: /) --- double setpointWasser; double setpointDampf; double offsetWasser; double offsetDampf; double kpWasser; double kiWasser; double kdWasser; double kpDampf; double kiDampf; double kdDampf; bool boostWasserActive; bool boostDampfActive; bool preventHeatAboveSetpointWasser; bool preventHeatAboveSetpointDampf; unsigned long windowSizeWasser; unsigned long windowSizeDampf; double maxTempWasser; double maxTempDampf; // --- PID Tuning Parameter (Seite: /PID-Tuning) --- double tuningStepWasser; double tuningNoiseWasser; double tuningStartValueWasser; unsigned int tuningLookBackWasser; double tuningStepDampf; double tuningNoiseDampf; double tuningStartValueDampf; unsigned int tuningLookBackDampf; // --- ECO Modus (Seite: /ECO) --- int ecoModeMinutes; int ecoModeTempWasser; int ecoModeTempDampf; bool dynamicEcoActive; int dampfVerzoegerung; // Dampfverzögerung ist auf ECO-Seite // --- Fast-Heat-Up (Seite: /Fast-Heat-Up) --- bool fastHeatUpAktiv; // --- Brew Control Einstellungen bool brewByTimeEnabled; float brewByTimeTargetSeconds; // Geändert bool preInfusionEnabled; float preInfusionDurationSeconds; // Geändert float preInfusionPauseSeconds; // Geändert }; /************************************************************************************ * 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 }; /************************************************************************************ * Brew Control Variablen & Defaults ************************************************************************************/ #ifdef ESP32 enum PreInfusionState { PI_INACTIVE, PI_PRE_BREW, PI_PAUSE, PI_MAIN_BREW }; PreInfusionState currentPreInfusionState = PI_INACTIVE; unsigned long preInfusionPhaseStartTime = 0; #endif // Globale Variablen für den aktuellen Zustand bool brewByTimeEnabled = false; float brewByTimeTargetSeconds = 30.0f; // Laufender Wert bool preInfusionEnabled = false; float preInfusionDurationSeconds = 5.0f; // Laufender Wert float preInfusionPauseSeconds = 3.0f; // Laufender Wert // Standard-Werte für Brew-Control const bool defaultBrewByTimeEnabled = false; const float defaultBrewByTimeTargetSeconds = 30.0f; const bool defaultPreInfusionEnabled = false; const float defaultPreInfusionDurationSeconds = 5.0f; const float defaultPreInfusionPauseSeconds = 3.0f; /************************************************************************************ * 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; // 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) // --- 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 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 // --- BREW CONTROL const int EEPROM_ADDR_BREWBYTIME_ENABLED = 624; // 1 Byte (bool) -> Ende 624 const int EEPROM_ADDR_BREWBYTIME_SECONDS = 625; // 4 Bytes (unsigned long) -> Ende 628 const int EEPROM_ADDR_PREINF_ENABLED = 629; // 1 Byte (bool) -> Ende 629 const int EEPROM_ADDR_PREINF_DUR_SEC = 630; // 4 Bytes (unsigned long) -> Ende 633 const int EEPROM_ADDR_PREINF_PAUSE_SEC = 634; // 4 Bytes (unsigned long) -> Ende 637 /************************************************************************************ * 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 /************************************************************************************ * Profile ************************************************************************************/ String profileStatusMessage = ""; // Für Nachrichten auf der Profil-Seite /************************************************************************************ * 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 CSS-Styles (PROGMEM) - Unverändert ************************************************************************************/ static const char commonStyle[] PROGMEM = R"rawliteral( )rawliteral"; /************************************************************************************ * 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 ************************************************************************************/ // === PROGMEM Teile definieren (Neue Reihenfolge: IP, Gateway, DNS, Subnet) === static const char wifiPageChunk1[] PROGMEM = R"rawliteral( WLAN-Konfiguration )rawliteral"; static const char wifiPageChunk2[] PROGMEM = R"rawliteral()rawliteral"; // Head Ende // Teil 3a: Body bis VOR Signalstärke-Wert static const char wifiPageChunk3_a[] PROGMEM = R"rawliteral(

WLAN-Konfiguration

Netzwerk

)rawliteral"; // Endet vor dem Wert // Teil 3b: NACH Signalstärke-Wert bis VOR Hostname Value static const char wifiPageChunk3_b[] PROGMEM = R"rawliteral(

IP-Einstellungen

)rawliteral"; // Beginnt mit '', endet mit display:%s'> // Teil 6: IP-Label und Input Start static const char wifiPageChunk6_ip[] PROGMEM = R"rawliteral(, endet mit value=' // Teil 9 NEU: NACH DNS Value bis Subnet Value static const char wifiPageChunk9_subnet[] PROGMEM = R"rawliteral('>, endet mit value=' // Teil 10 NEU (Rest): NACH Subnet Value (schließt Input), schließendes Div für staticFields, Submit-Button, AP-Form, Script, Ende body/html static const char wifiPageChunk10_rest[] PROGMEM = R"rawliteral(''>

AP-Modus

Verwendung im Access Point-Modus (AP).

Netzwerk-Name: Dual-PID
Passwort: QuickMill
IP-Adresse: 192.168.4.1
)rawliteral"; // Beginnt mit '>, dann , Rest bis Ende // --- 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; } void handleWiFiConfig() { // 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 // ... (Code für Signalstärke bleibt unverändert) ... 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 ---- server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, F("text/html"), ""); // Header senden // Chunk 1: HTML-Start server.sendContent_P(wifiPageChunk1); // Chunk 1.5: Common Style (global) server.sendContent_P(commonStyle); // Chunk 2: Head Ende server.sendContent_P(wifiPageChunk2); // Chunk 2.5: Common Nav (global) server.sendContent_P(commonNav); // --- Body und Netzwerk-Sektion --- server.sendContent_P(wifiPageChunk3_a); // Body bis VOR Signalwert server.sendContent(signalBuffer); // Signalwert senden server.sendContent_P(wifiPageChunk3_b); // NACH Signalwert bis VOR Hostname Value server.sendContent(hostname); // Aktuellen Hostnamen senden server.sendContent_P(wifiPageChunk3_c); // NACH Hostname bis VOR SSID Value // SSID einfügen if (strlen(currentConfig.ssid) == 0) { server.sendContent(F("SSID eingeben")); } else { server.sendContent(currentConfig.ssid); } server.sendContent_P(wifiPageChunk4); // Nach SSID bis vor Passwort // Passwort einfügen if (!demoModus) { if (strlen(currentConfig.password) == 0) { server.sendContent(F("Passwort eingeben")); } else { server.sendContent(currentConfig.password); } } else { server.sendContent(F("********")); } // --- IP-Einstellungen --- snprintf_P(formatBuffer, sizeof(formatBuffer), wifiPageChunk5_format, currentConfig.useStaticIP ? "" : "checked", currentConfig.useStaticIP ? "checked" : "", currentConfig.useStaticIP ? "block" : "none"); server.sendContent(formatBuffer); // Sendet formatierten Chunk 5 // --- Statische IP Felder (NEUE REIHENFOLGE) --- // 1. IP-Adresse server.sendContent_P(wifiPageChunk6_ip); // IP-Label und Input Start snprintf(ipBuffer, sizeof(ipBuffer), "%d.%d.%d.%d", currentConfig.staticIP[0], currentConfig.staticIP[1], currentConfig.staticIP[2], currentConfig.staticIP[3]); server.sendContent(ipBuffer); // IP-Wert senden // 2. Gateway server.sendContent_P(wifiPageChunk7_gateway); // Gateway-Label und Input Start snprintf(ipBuffer, sizeof(ipBuffer), "%d.%d.%d.%d", currentConfig.gateway[0], currentConfig.gateway[1], currentConfig.gateway[2], currentConfig.gateway[3]); server.sendContent(ipBuffer); // Gateway-Wert senden // 3. DNS-Server server.sendContent_P(wifiPageChunk8_dns); // DNS-Label und Input Start snprintf(ipBuffer, sizeof(ipBuffer), "%d.%d.%d.%d", currentConfig.dns[0], currentConfig.dns[1], currentConfig.dns[2], currentConfig.dns[3]); server.sendContent(ipBuffer); // DNS-Wert senden // 4. Subnetzmaske server.sendContent_P(wifiPageChunk9_subnet); // Subnet-Label und Input Start snprintf(ipBuffer, sizeof(ipBuffer), "%d.%d.%d.%d", currentConfig.subnet[0], currentConfig.subnet[1], currentConfig.subnet[2], currentConfig.subnet[3]); server.sendContent(ipBuffer); // Subnet-Wert senden // Rest der Seite (schließendes '>' für Subnet-Input, schließendes Div, Submit, AP-Form, Script, /body, /html) server.sendContent_P(wifiPageChunk10_rest); // Letzter leerer Chunk zum Abschließen der Antwort server.sendContent(F("")); // ---- Ende des Sendens ---- } /************************************************************************************ * Handler, um den AP-Modus per Knopfdruck zu erzwingen - Heap-Optimiert ************************************************************************************/ // 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() { // --- Logik zum Speichern der leeren Konfig (BLEIBT UNVERÄNDERT) --- // Leer-Konfiguration speichern, damit beim nächsten Start AP aktiv wird WiFiConfig emptyConfig; // Wichtig: Hostname NICHT entfernen! if (!demoModus) { saveWiFiConfig(emptyConfig); } // --- Senden der Bestätigungsseite (Heap-Optimiert) --- server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, F("text/html"), ""); // Header senden server.sendContent_P(forceAPHtml); // Chunk 1 (Head Start) server.sendContent_P(commonStyle); // Common CSS server.sendContent_P(forceAPHtml2); // Chunk 2 (Head End) server.sendContent_P(commonNav); // Common Navigation server.sendContent_P(forceAPHtml3); // Chunk 3 (Body) server.sendContent(F("")); // Abschluss-Chunk // --- Logik nach dem Senden (BLEIBT UNVERÄNDERT) --- delay(1000); ESP.restart(); } /************************************************************************************ * Handler zum Speichern der WLAN-Konfiguration - Heap-Optimiert (Bestätigungsseite) ************************************************************************************/ // 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() { // --- Daten validieren und auslesen --- if (!server.hasArg(F("ssid"))) // Grundlegende Prüfung auf SSID { server.send(400, F("text/plain"), F("Fehler: SSID fehlt!")); // 400 Bad Request return; } // Optional: Weitere Prüfungen (z.B. Passwortlänge, IP-Format) könnten hier hinzugefügt werden. // --- Konfigurationsstruktur füllen --- WiFiConfig newConfig; // SSID und Passwort kopieren (mit Längenbegrenzung und Nullterminierung) strncpy(newConfig.ssid, server.arg(F("ssid")).c_str(), sizeof(newConfig.ssid) - 1); newConfig.ssid[sizeof(newConfig.ssid) - 1] = '\0'; // Nur das Passwort lesen, wenn es tatsächlich gesendet wurde (könnte bei manchen Browsern fehlen, wenn leer) if (server.hasArg(F("password"))) { strncpy(newConfig.password, server.arg(F("password")).c_str(), sizeof(newConfig.password) - 1); newConfig.password[sizeof(newConfig.password) - 1] = '\0'; } else { // Falls Passwort nicht gesendet wurde (z.B. leeres Feld), explizit leeren newConfig.password[0] = '\0'; } // IP-Typ bestimmen newConfig.useStaticIP = (server.arg(F("ipType")) == "static"); // Statische IP-Einstellungen parsen, falls ausgewählt if (newConfig.useStaticIP) { if (!server.hasArg(F("ip")) || !server.hasArg(F("gateway")) || !server.hasArg(F("subnet")) || !server.hasArg(F("dns"))) { server.send(400, F("text/plain"), F("Fehler: Fehlende Felder für statische IP!")); return; } // Versuche, die IP-Adressen zu parsen bool ipOK = newConfig.staticIP.fromString(server.arg(F("ip"))); bool gwOK = newConfig.gateway.fromString(server.arg(F("gateway"))); bool subOK = newConfig.subnet.fromString(server.arg(F("subnet"))); bool dnsOK = newConfig.dns.fromString(server.arg(F("dns"))); // Optional: Prüfen, ob das Parsen erfolgreich war if (!ipOK || !gwOK || !subOK || !dnsOK) { Serial.println(F("WARNUNG: Mindestens ein Feld für statische IP hatte ein ungültiges Format.")); // Entscheiden, wie damit umgegangen wird: // Option A: Fehler senden // server.send(400, F("text/plain"), F("Fehler: Ungültiges Format für statische IP-Felder!")); // return; // Option B: Mit Standardwerten speichern (wie im Code unten, da fromString oft 0.0.0.0 liefert bei Fehler) // Oder explizit auf Default setzen: // if (!ipOK) newConfig.staticIP = IPAddress(192, 168, 4, 1); // Beispiel // if (!dnsOK) newConfig.dns = IPAddress(8, 8, 8, 8); // Beispiel } 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 { // Wenn DHCP: Die IP-Felder in der Struktur behalten ihre Standardwerte (oder was auch immer vorher drin war), // aber sie werden bei der Verbindung nicht verwendet. DNS wird vom DHCP-Server bezogen. Serial.println(F("Speichere Konfiguration für DHCP.")); } // Hostname speichern (separat, da nicht Teil der WiFiConfig im EEPROM) if (server.hasArg(F("hostname"))) { strncpy(hostname, server.arg(F("hostname")).c_str(), sizeof(hostname) - 1); hostname[sizeof(hostname) - 1] = '\0'; // Sicherstellen der Nullterminierung // Validierung (optional, z.B. auf erlaubte Zeichen prüfen oder Länge) if (strlen(hostname) == 0) { // Wenn leer, Default verwenden strncpy(hostname, defaultHostname.c_str(), sizeof(hostname) - 1); hostname[sizeof(hostname) - 1] = '\0'; } EEPROM.put(EEPROM_ADDR_HOSTNAME, hostname); // Hostname direkt ins EEPROM schreiben 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); // Speichert SSID, PW, IP-Config (inkl. DNS) in den EEPROM-Block ab EEPROM_ADDR_WIFI_CONFIG_DATA // EEPROM.commit() wird innerhalb von saveWiFiConfig bereits für den WiFi-Block // und separat oben für den Hostnamen aufgerufen. Ein zusätzlicher Commit hier wäre redundant, // es sei denn, saveWiFiConfig würde nicht committen. Gemäß der Funktion tut sie es aber. // -> KEIN EEPROM.commit() HIER NÖTIG. } else { Serial.println("Demo-Modus aktiv: Einstellungen werden NICHT gespeichert."); } // --- Senden der Bestätigungsseite (Heap-Optimiert) --- server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, F("text/html"), ""); // Header senden server.sendContent_P(saveConfigHtml); // Chunk 1 (Head Start) server.sendContent_P(commonStyle); // Common CSS server.sendContent_P(saveConfigHtml2); // Chunk 2 (Head End) server.sendContent_P(commonNav); // Common Navigation server.sendContent_P(saveConfigHtml3); // Chunk 3 (Body) server.sendContent(F("")); // Abschluss-Chunk // --- Neustart einleiten --- Serial.println("Einstellungen verarbeitet. Starte Neustart in 5 Sekunden..."); delay(5000); // Kurze Verzögerung, damit der Browser die Bestätigung empfangen kann 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 - URL angepasst ************************************************************************************/ void handleResetRuntime() { totalRuntime = 0; EEPROM.put(EEPROM_ADDR_RUNTIME, totalRuntime); EEPROM.commit(); server.sendHeader(F("Location"), F("/Info")); // URL angepasst server.send(303); } /************************************************************************************ * Handler zum Rücksetzen des Shotzählers - URL angepasst ************************************************************************************/ void handleResetShots() { shotCounter = 0; EEPROM.put(EEPROM_ADDR_SHOTCOUNTER, shotCounter); EEPROM.commit(); server.sendHeader(F("Location"), F("/Info")); // URL angepasst server.send(303); } /************************************************************************************ * Handler zum Rücksetzen des Wartungszählers (über Shot-Zähler) ************************************************************************************/ void handleResetMaintenance() { maintenanceIntervalCounter = 0; // Wartungszähler-Reset auf 0 EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter); EEPROM.commit(); displayMaintenanceMessage = false; // Meldung ggf. ausblenden server.sendHeader(F("Location"), F("/Service")); // URL angepasst server.send(303); } /************************************************************************************ * Handler für den Export der EEPROM-Einstellungen - Unverändert ************************************************************************************/ void handleExportSettings() { // Header für Dateidownload setzen server.sendHeader("Content-Disposition", "attachment; filename=\"dual_pid_settings.bin\""); server.sendHeader("Content-Type", "application/octet-stream"); server.setContentLength(EEPROM_SIZE); // Wichtig: Korrekte Größe senden // EEPROM-Inhalt byteweise senden // Puffergröße klein halten, um Speicher zu sparen const size_t bufferSize = 64; byte buffer[bufferSize]; size_t bytesSent = 0; // Start des Sendens (Status 200 OK) server.send(200, "application/octet-stream", ""); // EEPROM lesen und in Chunks senden 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(); } server.sendContent((const char*)buffer, currentChunkSize); bytesSent += currentChunkSize; yield(); // Wichtig für Netzwerkstabilität bei längeren Operationen } // Kein server.stop() hier, da die Verbindung automatisch geschlossen wird. } /************************************************************************************ * Handler für den Import der EEPROM-Einstellungen (Upload-Verarbeitung) - Unverändert ************************************************************************************/ // Globale Variablen bleiben gleich static int eepromWriteAddress = 0; static bool importSuccessful = false; static String importStatusMessage = ""; void handleImportUpload() { HTTPUpload& upload = server.upload(); // Demo-Modus Prüfung (Bleibt bestehen) if (demoModus) { if (upload.status == UPLOAD_FILE_START) { importStatusMessage = F("Import im Demo-Modus nicht erlaubt!"); importSuccessful = false; } return; } switch (upload.status) { case UPLOAD_FILE_START: eepromWriteAddress = 0; // Zähler zurücksetzen importSuccessful = false; // Status zurücksetzen importStatusMessage = ""; // Nachricht zurücksetzen break; case UPLOAD_FILE_WRITE: // Prüfen, ob bereits ein Fehler aufgetreten ist (z.B. durch zu viele Daten im vorherigen Chunk) if (importStatusMessage.length() > 0) { yield(); break; // Beende die Verarbeitung für diesen Status } // Schreibe die empfangenen Bytes, aber schütze vor Überlauf for (size_t i = 0; i < upload.currentSize; i++) { // Prüfen, ob das Schreiben das EEPROM_SIZE Limit überschreiten würde if (eepromWriteAddress < EEPROM_SIZE) { EEPROM.write(eepromWriteAddress, upload.buf[i]); eepromWriteAddress++; } else { // Es kommen MEHR Daten als erwartet! Serial.printf("FEHLER: Mehr als %d Bytes empfangen! Adresse: %d. Breche ab.\n", EEPROM_SIZE, eepromWriteAddress); importStatusMessage = F("Importfehler: Empfangene Datei zu groß!"); // 'ß' für ß // Breche die Verarbeitung dieses Chunks und zukünftiger Chunks ab goto end_write_processing; // Springe aus der Schleife und dem Case } yield(); } end_write_processing:; // Sprungziel für goto yield(); break; case UPLOAD_FILE_END: // FINALE GRÖSSENPRÜFUNG und Fehlerprüfung // 1. Gab es einen Fehler während des Schreibens (z.B. Datei zu groß)? if (importStatusMessage.length() > 0) { Serial.println("Import nicht erfolgreich aufgrund vorheriger Fehler (z.B. Datei zu groß)."); importSuccessful = false; // Sicherstellen, dass Flag false ist } // 2. Wurden exakt EEPROM_SIZE Bytes verarbeitet? else if (eepromWriteAddress == EEPROM_SIZE) { if (EEPROM.commit()) { importSuccessful = true; importStatusMessage = F("Einstellungen erfolgreich importiert! Neustart wird durchgeführt..."); // 'ü' für ü } else { Serial.println("FEHLER: EEPROM Commit fehlgeschlagen!"); importSuccessful = false; importStatusMessage = F("Import fehlgeschlagen: EEPROM Commit Fehler!"); } } // 3. Wurden weniger oder mehr Bytes verarbeitet (und kein Fehler vorher gesetzt)? 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!"); // 'ö', 'ß' } // Bereinigung für den nächsten Upload eepromWriteAddress = 0; break; case UPLOAD_FILE_ABORTED: Serial.println("EEPROM Import vom Client abgebrochen."); importSuccessful = false; // Nur Nachricht setzen, wenn nicht schon ein Fehler vorliegt if (importStatusMessage.length() == 0) { importStatusMessage = F("Import abgebrochen!"); } eepromWriteAddress = 0; break; default: Serial.printf("Unbekannter Upload-Status: %d\n", upload.status); break; } } /************************************************************************************ * Handler für die Import-Seite (nach dem Upload) - Heap-Optimiert ************************************************************************************/ // PROGMEM Chunks für die Seite 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} void handleImportSettings() { // Statusvariablen importSuccessful und importStatusMessage werden von handleImportUpload gesetzt server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, F("text/html"), ""); server.sendContent_P(importDoneHtmlStart); server.sendContent_P(commonStyle); server.sendContent_P(importDoneHtmlHeadEnd); server.sendContent_P(commonNav); server.sendContent_P(importDoneHtmlBodyStart); // Statusmeldung senden (importStatusMessage ist globale String-Variable) if (importStatusMessage.length() > 0) { server.sendContent(importStatusMessage); } server.sendContent_P(importDoneHtmlBodyEnd); server.sendContent(F("")); // Abschluss-Chunk // Neustart-Logik bleibt unverändert if (importSuccessful) { Serial.println("Neustart in 5 Sekunden nach EEPROM-Import..."); delay(5000); ESP.restart(); } } /************************************************************************************ * Funktion zum Zurücksetzen auf Werkseinstellungen * Setzt auch Brew Control Werte zurück ************************************************************************************/ 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 // 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? // 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); // Brew Control auf Defaults im EEPROM schreiben *** EEPROM.put(EEPROM_ADDR_BREWBYTIME_ENABLED, defaultBrewByTimeEnabled); EEPROM.put(EEPROM_ADDR_BREWBYTIME_SECONDS, defaultBrewByTimeTargetSeconds); // Schreibt float EEPROM.put(EEPROM_ADDR_PREINF_ENABLED, defaultPreInfusionEnabled); EEPROM.put(EEPROM_ADDR_PREINF_DUR_SEC, defaultPreInfusionDurationSeconds); // Schreibt float EEPROM.put(EEPROM_ADDR_PREINF_PAUSE_SEC, defaultPreInfusionPauseSeconds); // Schreibt float // 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."); } else { Serial.println("FEHLER: Schreiben der Werkseinstellungen ins EEPROM fehlgeschlagen!"); } // WICHTIG: Globale Variablen auch auf Default setzen, damit der laufende Betrieb korrekt ist 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; // Globale Brew Control Variablen auf Defaults setzen *** brewByTimeEnabled = defaultBrewByTimeEnabled; brewByTimeTargetSeconds = defaultBrewByTimeTargetSeconds; // float preInfusionEnabled = defaultPreInfusionEnabled; preInfusionDurationSeconds = defaultPreInfusionDurationSeconds; // float preInfusionPauseSeconds = defaultPreInfusionPauseSeconds; // float pidWasser.SetOutputLimits(0, windowSizeWasser); pidDampf.SetOutputLimits(0, windowSizeDampf); } /************************************************************************************ * Handler für das Zurücksetzen auf Werkseinstellungen - Heap-Optimiert ************************************************************************************/ // 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() { // Logik zum Resetten bleibt unverändert if (!demoModus) { resetToDefaults(); // Führt das eigentliche Reset durch } else { server.send(403, F("text/plain"), F("Reset im Demo-Modus nicht erlaubt.")); return; } // Bestätigungsseite senden (Heap-optimiert) server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, F("text/html"), ""); server.sendContent_P(resetDoneHtmlStart); server.sendContent_P(commonStyle); server.sendContent_P(resetDoneHtmlHeadEnd); server.sendContent_P(commonNav); server.sendContent_P(resetDoneHtmlBody); // Body ist komplett statisch server.sendContent(F("")); // Abschluss-Chunk // 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; /************************************************************************************ * setup(): Initialisierung des Systems - Angepasst für neue EEPROM-Werte, Hostname, URLs ************************************************************************************/ void setup() { Serial.begin(115200); EEPROM.begin(EEPROM_SIZE); // Größe angepasst pinMode(SSR_DAMPF_PIN, OUTPUT); pinMode(SSR_WASSER_PIN, OUTPUT); #ifdef ESP32 pinMode(SHOT_TIMER_PIN, INPUT_PULLDOWN); // ESP32 uses Pulldown for HIGH active // Pumpe & Ventil Pins initialisieren *** pinMode(PUMP_PIN, OUTPUT); pinMode(VALVE_PIN, OUTPUT); #else // ESP8266 pinMode(SHOT_TIMER_PIN, INPUT_PULLUP); // ESP8266 uses Pullup for HIGH active (check circuit!) #endif // Initialisiere SSRs als AUS digitalWrite(SSR_DAMPF_PIN, LOW); digitalWrite(SSR_WASSER_PIN, LOW); #ifdef ESP32 // Pumpe & Ventil Pins initialisieren (AUS) *** digitalWrite(PUMP_PIN, LOW); digitalWrite(VALVE_PIN, LOW); #endif // 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; // Hier noch KEINE Defaults schreiben, das passiert unten gebündelt } // 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); // Brew Control Einstellungen laden *** EEPROM.get(EEPROM_ADDR_BREWBYTIME_ENABLED, brewByTimeEnabled); EEPROM.get(EEPROM_ADDR_BREWBYTIME_SECONDS, brewByTimeTargetSeconds); // Liest float EEPROM.get(EEPROM_ADDR_PREINF_ENABLED, preInfusionEnabled); EEPROM.get(EEPROM_ADDR_PREINF_DUR_SEC, preInfusionDurationSeconds); // Liest float EEPROM.get(EEPROM_ADDR_PREINF_PAUSE_SEC, preInfusionPauseSeconds); // Liest float // Sicherstellen, dass Strings nullterminiert sind infoHersteller[sizeof(infoHersteller) - 1] = '\0'; infoModell[sizeof(infoModell) - 1] = '\0'; infoZusatz[sizeof(infoZusatz) - 1] = '\0'; } else { // Magic Value fehlt -> ALLES auf Default setzen // Setze globale Variablen auf Defaults 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; // Brew Control auf Defaults setzen *** brewByTimeEnabled = defaultBrewByTimeEnabled; brewByTimeTargetSeconds = defaultBrewByTimeTargetSeconds; // float preInfusionEnabled = defaultPreInfusionEnabled; preInfusionDurationSeconds = defaultPreInfusionDurationSeconds; // float preInfusionPauseSeconds = defaultPreInfusionPauseSeconds; // float // Ruft resetToDefaults() auf, was *alle* Defaults ins EEPROM schreibt (inkl. der neuen) 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); // In den Station-Modus wechseln if (wifiConfig.useStaticIP) // Prüfen, ob statische IP verwendet werden soll { // Log-Ausgaben für die statische Konfiguration (inkl. DNS) 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); // *** GEÄNDERTER WiFi.config Aufruf mit 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()); // --- NTP Client & Zeitzone initialisieren --- timeClient.begin(); Serial.println("NTP Client gestartet."); configTzTime(TZ_INFO, "pool.ntp.org"); // NTP-Server für Zeitzonen-Sync Serial.printf("Zeitzone gesetzt: %s\n", TZ_INFO); // Demo-Modus IP-Check if (WiFi.localIP() == demoModusIP) { demoModus = true; Serial.println("INFO: Demo-Modus aktiviert (basierend auf IP-Adresse)."); } // mDNS starten if (MDNS.begin(hostname)) { Serial.println("mDNS Responder gestartet"); MDNS.addService("http", "tcp", 80); } else { Serial.println("Fehler beim Starten des mDNS Responders!"); } } else { // Verbindung fehlgeschlagen Serial.println(F("\nKonnte keine Verbindung herstellen! Starte AP-Modus...")); startAPMode(); // Fallback zum AP-Modus } } else { // Keine Konfiguration im EEPROM 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)) { // Plausibilitätscheck (z.B. > 10 Jahre) Serial.println("Ungültige Betriebszeit im EEPROM, setze auf 0."); totalRuntime = 0; EEPROM.put(EEPROM_ADDR_RUNTIME, totalRuntime); // Korrigierten Wert speichern EEPROM.commit(); } EEPROM.get(EEPROM_ADDR_SHOTCOUNTER, shotCounter); if (shotCounter == ULONG_MAX || shotCounter > 1000000UL) { // Plausibilitätscheck 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) { // Plausibilitätscheck 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")); // \201 ist ü für manche Displays display.display(); delay(delayInit1); // --- PID-Regler Konfiguration --- // Plausibilitätsprüfung - Bei Bedarf auf Standard-Wert setzen, wenn nicht plausibel if (windowSizeWasser == 0 || windowSizeWasser > 60000) { windowSizeWasser = defaultWindowSizeWasser; } if (windowSizeDampf == 0 || windowSizeDampf > 60000) { windowSizeDampf = defaultWindowSizeDampf; } // Setze Output-Grenzen auf die Fenstergröße für zeitproportionale Steuerung pidWasser.SetOutputLimits(0, windowSizeWasser); pidDampf.SetOutputLimits(0, windowSizeDampf); // Setze PID-Parameter pidDampf.SetTunings(KpDampf, KiDampf, KdDampf); pidWasser.SetTunings(KpWasser, KiWasser, KdWasser); // Setze PID-Modus auf AUTOMATIC pidDampf.SetMode(AUTOMATIC); pidWasser.SetMode(AUTOMATIC); // Initialisiere Startzeiten für die Zeitfenster 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("--------------------------------------------------"); Serial.println("FEHLER: LittleFS Mount fehlgeschlagen!"); // ... (restliche Fehlermeldungen und Formatierungsversuch wie im Original) ... if (!LittleFS.begin()) { Serial.println("LittleFS Mount auch nach Formatierung fehlgeschlagen! Halte an."); while (1) delay(100); // Anhalten bei Fehler } else { Serial.println("LittleFS nach Formatierung erfolgreich gemountet."); } } else { Serial.println("LittleFS erfolgreich gemountet."); if (!LittleFS.exists("/Profile")) { if (LittleFS.mkdir("/Profile")) { Serial.println("Verzeichnis '/Profile' erstellt."); } else { Serial.println("FEHLER: Konnte Verzeichnis '/Profile' nicht erstellen!"); } } else { Serial.println("Verzeichnis '/Profile' existiert bereits."); } } // Ende des LittleFS-Blocks // Routen für Webserver server.on("/", handleRoot); server.on("/updateSettings", handleSettingsUpdate); server.on("/Info", handleInfo); server.on("/updateInfoSettings", handleInfoUpdate); server.on("/downloadNutzungsstatistik", handleDownloadNutzungsstatistik); server.on("/deleteStatistics", HTTP_POST, handleDeleteStatistics); server.on("/toggleWartungsmodus", HTTP_POST, handleToggleWartungsmodus); server.on("/Service", handleService); server.on("/AutoTune-Wasser", handleAutoTuneWasser); server.on("/AutoTune-Dampf", handleAutoTuneDampf); server.on("/updateAutotuneSettings", HTTP_POST, handleUpdateAutotuneSettings); server.on("/PID-Tuning-Abbruch", HTTP_POST, handlePidTuningAbbruch); server.on("/PID-Tuning", handlePidTuning); server.on("/ECO", handleEco); server.on("/updateEcoSettings", handleEcoUpdate); server.on("/Firmware", handleFirmware); server.on("/update", HTTP_POST, handleFirmwareUpdateResponse, handleFirmwareUploadProgress); server.on("/Fast-Heat-Up", handleFastHeatUp); server.on("/updateFast-Heat-Up-Settings", handleFastHeatUpSettings); server.on("/Dateimanager", HTTP_GET, handleFileManager); server.on("/Datei-Upload", HTTP_POST, handleFileUploaded, handleFileUpload); server.on("/Datei-Entfernen", HTTP_GET, handleFileDelete); server.on("/Datei-Download", HTTP_GET, handleFileDownload); server.on("/Verzeichnis-Erstellen", HTTP_POST, handleCreateDirectory); server.on("/Chart-Daten", HTTP_GET, handleChartData); server.on("/Chart", handleCharts); server.on("/chart.umd.min.js", HTTP_GET, handleServeChartJs); server.on("/Netzwerk", handleWiFiConfig); server.on("/saveWiFiConfig", handleSaveWiFiConfig); server.on("/forceAPMode", handleForceAPMode); server.on("/Profile", HTTP_GET, handleProfilesPage); server.on("/loadProfile", HTTP_POST, handleLoadProfile); server.on("/saveProfile", HTTP_POST, handleSaveProfile); server.on("/deleteProfile", HTTP_POST, handleDeleteProfile); server.on("/exportSettings", HTTP_GET, handleExportSettings); server.on("/importSettings", HTTP_POST, handleImportSettings, handleImportUpload); server.on("/resetDefaults", HTTP_POST, handleResetDefaults); server.on("/updateMaintenanceSettings", handleMaintenanceUpdate); server.on("/resetRuntime", handleResetRuntime); server.on("/resetShots", handleResetShots); server.on("/resetMaintenance", handleResetMaintenance); #ifdef ESP32 // Routen für Brew Control (Nur ESP32) *** server.on("/Brew-Control", HTTP_GET, handleBrewControl); server.on("/saveBrewControl", HTTP_POST, handleSaveBrewControl); #endif // Webserver starten server.begin(); Serial.println(F("Webserver gestartet")); startupTime = millis(); yield(); } /************************************************************************************ * loop(): Hauptschleife * Enthält Logik für Pre-Infusion Ablauf und Brew-By-Time Check (Nur ESP32) ************************************************************************************/ 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); yield(); // --- Angepasste Sicherheitsüberprüfung Wasser --- // 1. Echter Sensorfehler? (NaN oder negativ) 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 - ggf. wieder aktivieren wenn zu viel Log Serial.println("WARNUNG: Wassertemperatursensor liefert ungültigen Wert!"); } } // 2. Sensor OK, aber Sicherheitslimit überschritten? 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); } } // 3. Sensor OK und Temperatur OK else { // Beide Fehler/Warn-Flags zurücksetzen, wenn alles in Ordnung ist 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; // Boost: Volle Leistung } else if (!autoTuneWasserActive) { // Kein Boost ODER Bedingung nicht erfüllt UND kein AutoTune pidWasser.Compute(); // Normaler PID-Betrieb } // Bei AutoTune wird OutputWasser in handleAutoTune() gesetzt } // Ende Wasser Prüfung yield(); // --- Angepasste Sicherheitsüberprüfung Dampf --- // 1. Echter Sensorfehler? (NaN oder negativ) 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 - ggf. wieder aktivieren Serial.println("WARNUNG: Dampftemperatursensor liefert ungültigen Wert!"); // } } // 2. Sensor OK, aber Sicherheitslimit überschritten? 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); } } // 3. Sensor OK und Temperatur OK else { // Beide Fehler/Warn-Flags zurücksetzen, wenn alles in Ordnung ist if (dampfSensorError) Serial.println("INFO: Dampftemperatursensor wieder OK."); dampfSensorError = false; if (dampfSafetyShutdown) Serial.println("INFO: Dampftemperatur wieder im sicheren Bereich."); dampfSafetyShutdown = false; // Prüfen, ob die Startverzögerung für Dampf noch aktiv ist bool steamDelayActive = (dampfVerzoegerung > 0 && (millis() - startupTime < (unsigned long)dampfVerzoegerung * 60000UL)); if (steamDelayActive && !wartungsModusAktiv) { OutputDampf = 0; // Während Verzögerung heizen verhindern } else { // Keine Startverzögerung mehr aktiv // --- NEUE DAMPF BOOST LOGIK (oder normale PID-Berechnung) --- bool activateBoostDampf = boostDampfActive && !wartungsModusAktiv && !ecoModeAktiv && !autoTuneDampfActive && (InputDampf < (SetpointDampf * 0.90)); if (activateBoostDampf) { OutputDampf = windowSizeDampf; // Boost Dampf aktiv } else if (!autoTuneDampfActive) { // Kein Boost ODER Bedingung nicht erfüllt UND kein AutoTune pidDampf.Compute(); // Normaler PID-Betrieb für Dampf } // Bei AutoTune wird OutputDampf in handleAutoTune() gesetzt } // Ende "Keine Startverzögerung" } // Ende Dampf Prüfung // AutoTune-Logik (unverändert) handleAutoTune(); } // Ende des Temperaturmessungs-/PID-Berechnungsintervalls yield(); // --- Zeitproportionale SSR-Ansteuerung (wird kontinuierlich geprüft) --- unsigned long now = millis(); // --- Wasser SSR --- if (wasserSensorError || wasserSafetyShutdown) { digitalWrite(SSR_WASSER_PIN, LOW); // Sicherheit: Bei Fehler/Shutdown immer AUS } else { // Nur heizen, wenn KEIN Fehler UND KEIN Shutdown 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); } } yield(); // --- Dampf SSR --- if (dampfSensorError || dampfSafetyShutdown) { digitalWrite(SSR_DAMPF_PIN, LOW); // Sicherheit: Bei Fehler/Shutdown immer AUS } else { // Nur heizen, wenn KEIN Fehler UND KEIN Shutdown 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); } } yield(); // Shot-Timer (Diese Funktion initiiert nur den Start oder stoppt bei manuellem Ende) updateShotTimer(); #ifdef ESP32 // *** Pre-Infusion Ablauf und Brew-By-Time Check (ANGEPASST für float Sekunden) *** if (shotActive) { unsigned long currentShotTimeMillis = millis() - shotStartTime; // --- Brew-By-Time Prüfung --- // Verwende float * 1000.0 für den Vergleich mit Millisekunden if (brewByTimeEnabled && (currentShotTimeMillis >= (unsigned long)(brewByTimeTargetSeconds * 1000.0f))) { Serial.println("Brew-By-Time Limit erreicht. Stoppe Bezug..."); stopBrewSequence(currentShotTimeMillis); } // --- Pre-Infusion Zustandsmaschine --- else if (preInfusionEnabled) { unsigned long currentPhaseTimeMillis = millis() - preInfusionPhaseStartTime; // Wechsel von Pre-Infusion zu Pause (Vergleich mit float * 1000.0) if (currentPreInfusionState == PI_PRE_BREW && (currentPhaseTimeMillis >= (unsigned long)(preInfusionDurationSeconds * 1000.0f))) { Serial.println("Pre-Infusion -> Pause"); digitalWrite(PUMP_PIN, LOW); currentPreInfusionState = PI_PAUSE; preInfusionPhaseStartTime = millis(); } // Wechsel von Pause zu Hauptbezug (Vergleich mit float * 1000.0) else if (currentPreInfusionState == PI_PAUSE && (currentPhaseTimeMillis >= (unsigned long)(preInfusionPauseSeconds * 1000.0f))) { Serial.println("Pause -> Main Brew"); digitalWrite(PUMP_PIN, HIGH); currentPreInfusionState = PI_MAIN_BREW; preInfusionPhaseStartTime = millis(); } } } #endif // ESP32 // Eco-Modus, wenn kein AutoTune oder Wartungsmodus if (!autoTuneWasserActive && !autoTuneDampfActive && !wartungsModusAktiv) { if (ecoModeMinutes > 0 && !shotActive) { // Eco nur wenn kein Shot läuft unsigned long currentTimeEco = millis(); if (currentTimeEco - lastShotTime > (ecoModeMinutes * 60UL * 1000UL)) { if (!ecoModeAktiv) { ecoModeActivatedTime = currentTimeEco; } ecoModeAktiv = true; if (dynamicEcoActive) { int minutesPassed = (currentTimeEco - ecoModeActivatedTime) / (60UL * 1000UL); // Sicherstellen, dass Temperatur nicht unter 0 geht SetpointWasser = max(0, ecoModeTempWasser - minutesPassed); SetpointDampf = max(0, ecoModeTempDampf - minutesPassed); } else { SetpointWasser = ecoModeTempWasser; SetpointDampf = ecoModeTempDampf; } } else { // Wenn Eco-Zeit noch nicht erreicht ODER Shot gerade beendet if (ecoModeAktiv) { // Nur laden, wenn Eco gerade deaktiviert wird EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); } ecoModeAktiv = false; ecoModeActivatedTime = 0; } } else if (ecoModeAktiv) { // Sicherstellen, dass Eco deaktiviert wird, wenn ecoModeMinutes=0 oder Shot aktiv ecoModeAktiv = false; ecoModeActivatedTime = 0; EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); } yield(); } else if (ecoModeAktiv) { // Wenn AutoTune oder Wartung gestartet wurde, während Eco aktiv war -> deaktiviere Eco 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(); // Webserver #ifdef ESP8266 MDNS.update(); // mDNS-Anfragen nur auf ESP8266 aktiv bearbeiten #endif #ifdef ESP32 // ESP32 mDNS läuft im Hintergrund, kein explizites Update nötig #endif yield(); // Betriebszeit zählen static unsigned long lastRuntimeSecond = 0; if (millis() - lastRuntimeSecond >= 1000) { totalRuntime++; lastRuntimeSecond = millis(); } // Speichern der Betriebszeit und WiFi-Check (Logik unverändert) if (!demoModus) { unsigned long currentMillisLoop = millis(); unsigned long intervalToUse; if (!initialRuntimeSaveDone) { intervalToUse = initialRuntimeSaveDelay; } else { intervalToUse = subsequentRuntimeSaveInterval; } if (currentMillisLoop - lastRuntimeSave >= intervalToUse) { EEPROM.put(EEPROM_ADDR_RUNTIME, totalRuntime); if(EEPROM.commit()){ // Serial.println("Betriebszeit gespeichert."); // Optional: Log reduzieren } else { Serial.println("FEHLER: EEPROM Commit beim Speichern der Betriebszeit fehlgeschlagen!"); } 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(); } } } yield(); } /************************************************************************************ * Liest die Temperatur vom NTC-Sensor (Wasser) über den Spannungsteiler (ADC) * - Verwendet jetzt ANALOG_NTC_PIN für Plattformkompatibilität ************************************************************************************/ double readNTCTemperature() { #ifdef ESP32 // Optional: ADC-Konfiguration für ESP32 (Genauigkeit verbessern) // analogSetAttenuation(ADC_11db); // Beispiel: Voller 3.3V Bereich // analogReadResolution(12); // Beispiel: 12-bit Auflösung int adcValue = analogRead(ANALOG_NTC_PIN); const double MAX_ADC_VALUE = 4095.0; // ESP32 hat standardmäßig 12-bit ADC #else // ESP8266 int adcValue = analogRead(ANALOG_NTC_PIN); // Verwendet jetzt das Makro const double MAX_ADC_VALUE = 1023.0; // ESP8266 hat 10-bit ADC #endif // Prüfen auf ungültige ADC-Werte (können bei Problemen auftreten) // ESP8266: 0 bis 1023, ESP32: 0 bis 4095 (je nach Konfiguration) if (adcValue <= 0 || adcValue >= MAX_ADC_VALUE) { Serial.print("WARNUNG: Ungültiger ADC-Wert vom NTC-Sensor: "); Serial.println(adcValue); return NAN; // Not-a-Number als Fehlerwert zurückgeben } #ifdef ESP32 // ESP32 ADC ist nicht linear. Ggf. Kalibrierungsdaten verwenden oder Mapping-Funktion // Hier eine einfache lineare Annahme als erster Schritt: double sensorVoltage = adcValue * (ADC_REF / MAX_ADC_VALUE); // TODO: Für höhere Genauigkeit auf ESP32 -> Kalibrierung oder Lookup-Tabelle #else // ESP8266 double sensorVoltage = adcValue * (ADC_REF / MAX_ADC_VALUE); // ADC_REF sollte hier eher 1.0V sein für ESP8266 A0? Prüfen! // Wenn Spannungsteiler an 3.3V hängt, ist ADC_REF=3.3V korrekt. #endif // 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_NTC_R0 = log(R_ntc / NTC_R0); double temp_inv_K = (1.0 / NTC_T0) + (1.0 / beta) * log_R_NTC_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" yield(); 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 ************************************************************************************/ /************************************************************************************ * Verwaltet den Shot-Timer (wenn z.B. der Nutzer den Bezug aktiviert) * Initiiert Pre-Infusion (ESP32) * Ruft stopBrewSequence auf (ESP32) ************************************************************************************/ void updateShotTimer() { // Wenn Wartungsmodus aktiv ist, diese Funktion sofort verlassen if (wartungsModusAktiv) { // Sicherstellen, dass ein evtl. laufender Shot als beendet gilt #ifdef ESP32 stopBrewSequence(0); // Ruft die Stopp-Sequenz auf (Dauer 0, wird nicht gezählt) #else if (shotActive) { shotActive = false; shotStartTime = 0; shotEndTime = 0; lastShotDurationMillis = 0; } #endif return; // Verhindert die restliche Logik der Funktion } bool shotInputActive = (digitalRead(SHOT_TIMER_PIN) == HIGH); // --- Shot beginnt (Eingang ist HIGH) --- if (shotInputActive) { 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 ecoModeAktiv = false; // Eco Modus beim Start eines Shots beenden ecoModeActivatedTime = 0; // Wenn Eco aktiv war, normale Sollwerte wiederherstellen EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); Serial.println("Shot Timer STARTET..."); #ifdef ESP32 // Pre-Infusion oder direkten Start behandeln if (preInfusionEnabled) { Serial.println("Start Pre-Infusion Phase..."); currentPreInfusionState = PI_PRE_BREW; preInfusionPhaseStartTime = millis(); digitalWrite(PUMP_PIN, HIGH); // Pumpe AN für Pre-Infusion digitalWrite(VALVE_PIN, HIGH); // Ventil ZU für Pre-Infusion } else { Serial.println("Start Main Brew Phase (keine Pre-Infusion)..."); currentPreInfusionState = PI_MAIN_BREW; // Direkt zur Hauptphase preInfusionPhaseStartTime = millis(); // Timer trotzdem starten (für Konsistenz) digitalWrite(PUMP_PIN, HIGH); // Pumpe AN für Hauptbezug digitalWrite(VALVE_PIN, HIGH); // Ventil ZU für Hauptbezug } #endif // ESP32 } } // --- Shot endet (Eingang ist LOW) --- else { if (shotActive) { // Eingang ist LOW, Shot war aktiv -> Normales Ende durch Benutzer Serial.println("Shot Timer STOPPT (Eingang LOW)..."); #ifdef ESP32 // Berechne Dauer und rufe die Stopp-Sequenz auf unsigned long shotDuration = millis() - shotStartTime; stopBrewSequence(shotDuration); #else // ESP8266 - Original Logik ohne Pumpe/Ventil unsigned long shotDuration = millis() - shotStartTime; lastShotDurationMillis = shotDuration; // Dauer speichern shotEndTime = millis(); // Endzeitpunkt speichern // nur Shots > 20 Sekunden zählen if (shotDuration > 20000) { shotCounter++; maintenanceIntervalCounter++; logShot(shotDuration); // Schreibt in CSV (wenn NTP Zeit ok) if (!demoModus) { // Nur speichern, wenn kein Demo-Modus EEPROM.put(EEPROM_ADDR_SHOTCOUNTER, shotCounter); EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter); if (!EEPROM.commit()) { Serial.println("FEHLER: EEPROM Commit beim Speichern der Shot-Zähler fehlgeschlagen!"); } } 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 fastHeatUpHeating = false; // F-H-U Anzeige beenden #endif // ESP32 / ESP8266 } } } /************************************************************************************ * 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. } } /************************************************************************************ * Handler für die Brew Control Seite (GET /Brew-Control) - Nur ESP32 ************************************************************************************/ #ifdef ESP32 // Handler sind nur für ESP32 sinnvoll // --- PROGMEM Chunks für die Brew Control Seite (ANGEPASST für float) --- static const char brewControlHead[] PROGMEM = R"rawliteral( Brew Control )rawliteral"; static const char brewControlHeadEnd[] PROGMEM = R"rawliteral()rawliteral"; static const char brewControlBodyStart[] PROGMEM = R"rawliteral(

Brew Control

)rawliteral"; // Brew-By-Time Section (ANGEPASST für float) static const char brewControlBBTSection[] PROGMEM = R"rawliteral(

Brew-By-Time


(Stoppt den Bezug automatisch nach dieser Zeit)

)rawliteral"; // Pre-Infusion Section (ANGEPASST für float) static const char brewControlPISection[] PROGMEM = R"rawliteral(

Pre-Infusion


(Pumpe AN -> Pumpe AUS -> Pumpe AN)
)rawliteral"; // Formular Ende static const char brewControlFormEnd[] PROGMEM = R"rawliteral(
)rawliteral"; // Handler für die Brew Control Seite (GET /Brew-Control) - ANGEPASST für float void handleBrewControl() { char buffer[10]; // Puffer für Zahlen server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, "text/html", ""); server.sendContent_P(brewControlHead); server.sendContent_P(commonStyle); server.sendContent_P(brewControlHeadEnd); server.sendContent_P(commonNav); server.sendContent_P(brewControlBodyStart); // Brew-By-Time Sektion rendern (mit float Formatierung) String bbtHtml = FPSTR(brewControlBBTSection); bbtHtml.replace("{BBT_CHECKED}", brewByTimeEnabled ? "checked" : ""); snprintf(buffer, sizeof(buffer), "%.1f", brewByTimeTargetSeconds); // Format mit einer Nachkommastelle bbtHtml.replace("{BBT_SECS}", buffer); server.sendContent(bbtHtml); // Pre-Infusion Sektion rendern (mit float Formatierung) String piHtml = FPSTR(brewControlPISection); piHtml.replace("{PI_CHECKED}", preInfusionEnabled ? "checked" : ""); snprintf(buffer, sizeof(buffer), "%.1f", preInfusionDurationSeconds); // Format mit einer Nachkommastelle piHtml.replace("{PI_DUR_SECS}", buffer); snprintf(buffer, sizeof(buffer), "%.1f", preInfusionPauseSeconds); // Format mit einer Nachkommastelle piHtml.replace("{PI_PAUSE_SECS}", buffer); server.sendContent(piHtml); server.sendContent_P(brewControlFormEnd); server.sendContent(""); } /************************************************************************************ * Handler zum Speichern der Brew Control Einstellungen (POST /saveBrewControl) - Nur ESP32 ************************************************************************************/ // Handler zum Speichern der Brew Control Einstellungen (POST /saveBrewControl) - ANGEPASST für float void handleSaveBrewControl() { bool changed = false; // Brew-By-Time Auswerten (jetzt mit toFloat()) bool newBBTEnabled = server.hasArg("bbtEnabled"); if (newBBTEnabled != brewByTimeEnabled) { brewByTimeEnabled = newBBTEnabled; EEPROM.put(EEPROM_ADDR_BREWBYTIME_ENABLED, brewByTimeEnabled); changed = true; } if (server.hasArg("bbtSecs")) { float newBBTSecs = server.arg("bbtSecs").toFloat(); // toFloat() verwenden if (newBBTSecs >= 5.0f && newBBTSecs <= 120.0f) { // Plausibilitätscheck mit float if (abs(newBBTSecs - brewByTimeTargetSeconds) > 0.01f) { // Vergleich mit Toleranz brewByTimeTargetSeconds = newBBTSecs; EEPROM.put(EEPROM_ADDR_BREWBYTIME_SECONDS, brewByTimeTargetSeconds); // Schreibt float changed = true; } } else { Serial.printf("WARNUNG: Ungültiger Wert für Brew-By-Time Dauer empfangen: %s\n", server.arg("bbtSecs").c_str()); } } // Pre-Infusion Auswerten (jetzt mit toFloat()) bool newPIEnabled = server.hasArg("piEnabled"); if (newPIEnabled != preInfusionEnabled) { preInfusionEnabled = newPIEnabled; EEPROM.put(EEPROM_ADDR_PREINF_ENABLED, preInfusionEnabled); changed = true; } if (server.hasArg("piDurSecs")) { float newPIDur = server.arg("piDurSecs").toFloat(); // toFloat() verwenden if (newPIDur >= 0.5f && newPIDur <= 20.0f) { // Plausibilitätscheck mit float if(abs(newPIDur - preInfusionDurationSeconds) > 0.01f) { // Vergleich mit Toleranz preInfusionDurationSeconds = newPIDur; EEPROM.put(EEPROM_ADDR_PREINF_DUR_SEC, preInfusionDurationSeconds); // Schreibt float changed = true; } } else { Serial.printf("WARNUNG: Ungültiger Wert für Pre-Infusion Dauer empfangen: %s\n", server.arg("piDurSecs").c_str()); } } if (server.hasArg("piPauseSecs")) { float newPIPause = server.arg("piPauseSecs").toFloat(); // toFloat() verwenden if (newPIPause >= 0.0f && newPIPause <= 20.0f) { // Plausibilitätscheck mit float if(abs(newPIPause - preInfusionPauseSeconds) > 0.01f) { // Vergleich mit Toleranz preInfusionPauseSeconds = newPIPause; EEPROM.put(EEPROM_ADDR_PREINF_PAUSE_SEC, preInfusionPauseSeconds); // Schreibt float changed = true; } } else { Serial.printf("WARNUNG: Ungültiger Wert für Pre-Infusion Pause empfangen: %s\n", server.arg("piPauseSecs").c_str()); } } if (changed) { if (EEPROM.commit()) { Serial.println("Brew Control Einstellungen erfolgreich gespeichert (float)."); } else { Serial.println("FEHLER: EEPROM Commit für Brew Control Einstellungen fehlgeschlagen!"); } } server.sendHeader("Location", "/Brew-Control"); server.send(303); } #endif // ESP32 - Ende der Handler für Brew Control /************************************************************************************ * 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 entfernen 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, Lösch-Button und plattformabhängiger Systeminfos ************************************************************************************/ /************************************************************************************ * Handler für die Info-Seite - Inkl. NTP-Status, Download-Button, * neuer Statistiken, Lösch-Button und plattformabhängiger Systeminfos * - Korrigierte String-Verknüpfung für ESP32 Reset Reason ************************************************************************************/ void handleInfo() // URL: /Info { char buffer[40]; // Allzweck-Puffer für Zahlen etc. char dateBuffer[25]; // Puffer für Datumsformatierung char tempStringCopyBuffer[50]; // Temp Puffer für String-Kopien server.setContentLength(CONTENT_LENGTH_UNKNOWN); // Wichtig für Chunked Sending server.send(200, F("text/html"), ""); // Header senden // --- Kopfzeile, Styles, Navigation senden --- server.sendContent_P(infoHtmlHead); server.sendContent_P(commonStyle); // Globale Styles nicht vergessen server.sendContent_P(infoHtmlHeadEnd); server.sendContent_P(commonNav); // Globale Navigation nicht vergessen yield(); // --- Geräteinfo Formular --- server.sendContent_P(infoChunk_BodyStart); // Start Body, H1, Start Geräteinfo-Form // Sicherstellen, dass Strings nicht über Puffer laufen und terminiert sind strncpy(tempStringCopyBuffer, infoHersteller, sizeof(tempStringCopyBuffer) - 1); tempStringCopyBuffer[sizeof(tempStringCopyBuffer) - 1] = '\0'; if (strlen(tempStringCopyBuffer) > 0) { server.sendContent(tempStringCopyBuffer); } // Wert einfügen server.sendContent_P(infoChunk_AfterHersteller); // Label+Input für Modell yield(); strncpy(tempStringCopyBuffer, infoModell, sizeof(tempStringCopyBuffer) - 1); tempStringCopyBuffer[sizeof(tempStringCopyBuffer) - 1] = '\0'; if (strlen(tempStringCopyBuffer) > 0) { server.sendContent(tempStringCopyBuffer); } // Wert einfügen server.sendContent_P(infoChunk_AfterModell); // Label+Input für Zusatz yield(); strncpy(tempStringCopyBuffer, infoZusatz, sizeof(tempStringCopyBuffer) - 1); tempStringCopyBuffer[sizeof(tempStringCopyBuffer) - 1] = '\0'; if (strlen(tempStringCopyBuffer) > 0) { server.sendContent(tempStringCopyBuffer); } // Wert einfügen server.sendContent_P(infoChunk_AfterZusatz); // Ende Geräteinfo-Form yield(); // --- Betriebszeit Reset Formular --- server.sendContent_P(infoChunk_RuntimeResetForm); // Start Betriebszeit-Reset-Form, endet vor formatierter Zeit // Formatierte Betriebszeit berechnen und senden 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); server.sendContent(buffer); server.sendContent(F(" Tag(e), ")); snprintf(buffer, sizeof(buffer), "%lu", hours); server.sendContent(buffer); server.sendContent(F(" Stunde(n) und ")); snprintf(buffer, sizeof(buffer), "%lu", minutes); server.sendContent(buffer); server.sendContent(F(" Minuten")); server.sendContent_P(infoChunk_AfterRuntime); // Ende Betriebszeit-Reset-Form yield(); // --- Shot Zähler Reset Formular --- server.sendContent_P(infoChunk_ShotCounterResetForm); // Start Shot-Reset-Form, endet vor Zähler-Wert snprintf(buffer, sizeof(buffer), "%lu", shotCounter); // Wert einfügen server.sendContent(buffer); server.sendContent_P(infoChunk_ShotCounterFormEnd); // Ende Shot-Reset-Form yield(); // --- Shot-Verlauf Statistik --- ShotStats stats = calculateShotStatistics(); // Berechne Statistiken yield(); // Nach der (potenziell) längeren Berechnung if (stats.historyAvailable) { server.sendContent_P(infoChunk_StatsStart); // Start des Statistik-Blocks (

) // Bestehende allgemeine Statistiken server.sendContent_P(infoChunk_StatsTotal); // "Geloggte Bezüge gesamt: " snprintf(buffer, sizeof(buffer), "%lu", stats.totalShotsLogged); server.sendContent(buffer); yield(); server.sendContent_P(infoChunk_StatsAvg); // "
Durchschnittliche Bezugsdauer: " snprintf(buffer, sizeof(buffer), "%.1f", stats.averageDurationSec); server.sendContent(buffer); server.sendContent(F(" s")); // Einheit hinzufügen yield(); // Durchschnitt pro Tag server.sendContent_P(infoChunk_StatsAvgPerDay); // "
Durchschnittliche Bezüge pro Tag (seit Log): " snprintf(buffer, sizeof(buffer), "%.1f", stats.avgShotsPerDay); server.sendContent(buffer); yield(); // Bestehende Zeitfenster server.sendContent_P(infoChunk_StatsToday); // " s

Bezüge Heute: " snprintf(buffer, sizeof(buffer), "%lu", stats.shotsToday); server.sendContent(buffer); yield(); server.sendContent_P(infoChunk_StatsYesterday); // "
Bezüge Gestern: " snprintf(buffer, sizeof(buffer), "%lu", stats.shotsYesterday); server.sendContent(buffer); yield(); server.sendContent_P(infoChunk_StatsWeek); // "
Bezüge in dieser Woche: " snprintf(buffer, sizeof(buffer), "%lu", stats.shotsThisWeek); server.sendContent(buffer); yield(); server.sendContent_P(infoChunk_StatsLastWeek); // "
Bezüge Letzte Woche (Mo-So): " snprintf(buffer, sizeof(buffer), "%lu", stats.shotsLastWeek); server.sendContent(buffer); yield(); server.sendContent_P(infoChunk_StatsMonth); // "
Bezüge in diesem Monat: " snprintf(buffer, sizeof(buffer), "%lu", stats.shotsThisMonth); server.sendContent(buffer); yield(); server.sendContent_P(infoChunk_StatsLastMonth); // "
Bezüge Letzter Monat: " snprintf(buffer, sizeof(buffer), "%lu", stats.shotsLastMonth); server.sendContent(buffer); yield(); // Nach mehreren Ausgaben // Ersten und letzten Shot anzeigen, falls vorhanden (wie bisher) if (stats.firstShotTimestamp > 0) { server.sendContent_P(infoChunk_StatsFirst); // "

Erster geloggter Bezug: " struct tm first_tm; localtime_r(&stats.firstShotTimestamp, &first_tm); strftime(dateBuffer, sizeof(dateBuffer), "%d.%m.%Y %H:%M", &first_tm); // Datum formatieren server.sendContent(dateBuffer); // Wert senden server.sendContent(F(" Uhr")); yield(); } if (stats.lastShotTimestamp > 0) { server.sendContent_P(infoChunk_StatsLast); // "
Letzter geloggter Bezug: " struct tm last_tm; localtime_r(&stats.lastShotTimestamp, &last_tm); strftime(dateBuffer, sizeof(dateBuffer), "%d.%m.%Y %H:%M", &last_tm); // Datum formatieren server.sendContent(dateBuffer); // Wert senden server.sendContent(F(" Uhr")); yield(); } // Hinweis auf NTP anzeigen, wenn aktuelle Zeit ungültig erscheint (wie bisher) 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)) { // Prüft ob aktuelles Jahr > 2023 server.sendContent_P(infoChunk_StatsTimeError); // Sendet den Hinweis } // Download Button hinzufügen (wie bisher) server.sendContent_P(infoChunk_StatsDownloadButton); server.sendContent_P(infoChunk_StatsEnd); // Ende des Statistik-Blocks (

) yield(); } else { // Alternative Meldung, falls keine Statistik verfügbar (wie bisher) server.sendContent_P(infoChunk_StatsNotAvailable); } // --- Ende Statistikbereich --- yield(); server.sendContent_P(infoChunk_DeleteStatsForm); // --- Systeminfo (mit plattformabhängigen Teilen) --- server.sendContent_P(infoChunk_SysInfoStart); // Start Systeminfo
, endet vor Heap-Wert snprintf(buffer, sizeof(buffer), "%u", ESP.getFreeHeap()); server.sendContent(buffer); // Heap-Wert senden server.sendContent_P(infoChunk_AfterFreeHeap); // Sendet " Bytes
NTP Zeit synchronisiert: " yield(); // NTP Status senden (Ja/Nein) server.sendContent(timeSynced ? F("Ja") : F("Nein")); server.sendContent_P(infoChunk_AfterNTPStatus); // Sendet "
SDK-Version: " yield(); // --- SDK Version --- const char* sdkVersion = ESP.getSdkVersion(); if (sdkVersion && strlen(sdkVersion) > 0) { server.sendContent(sdkVersion); } else { server.sendContent(F("N/A")); } // --- Core Version --- server.sendContent_P(infoChunk_AfterSDK); // "
Core-Version: " String coreVersionStr = ESP.getCoreVersion(); yield(); strncpy(tempStringCopyBuffer, coreVersionStr.c_str(), sizeof(tempStringCopyBuffer) - 1); tempStringCopyBuffer[sizeof(tempStringCopyBuffer) - 1] = '\0'; if (strlen(tempStringCopyBuffer) > 0) server.sendContent(tempStringCopyBuffer); // --- Boot Version (Plattformabhängig) --- server.sendContent_P(infoChunk_AfterCore); // "
Boot-Version: " #ifdef ESP32 server.sendContent(F("N/A")); // ESP32 hat keine getBootVersion() #else // ESP8266 snprintf(buffer, sizeof(buffer), "%u", ESP.getBootVersion()); server.sendContent(buffer); #endif // --- Chip ID (Plattformabhängig) --- server.sendContent_P(infoChunk_AfterBoot); // "
Chip-ID: " #ifdef ESP32 uint64_t chipid_64 = ESP.getEfuseMac(); uint32_t chipid_32 = (uint32_t)(chipid_64 >> 16); snprintf(buffer, sizeof(buffer), "%08X", chipid_32); #else // ESP8266 snprintf(buffer, sizeof(buffer), "%08X", ESP.getChipId()); #endif server.sendContent(buffer); // Formatierten Chip-ID Wert senden // --- CPU Takt --- server.sendContent_P(infoChunk_AfterChipID); // "
CPU-Takt: " snprintf(buffer, sizeof(buffer), "%u", ESP.getCpuFreqMHz()); server.sendContent(buffer); // --- Reset Grund (Plattformabhängig & KORRIGIERT) --- server.sendContent_P(infoChunk_AfterCPU); // " MHz
Letzter Reset-Grund: " #ifdef ESP32 esp_reset_reason_t reason = esp_reset_reason(); switch (reason) { case ESP_RST_UNKNOWN: server.sendContent(F("Unbekannt")); break; case ESP_RST_POWERON: server.sendContent(F("Power On")); break; case ESP_RST_EXT: server.sendContent(F("External Pin")); break; case ESP_RST_SW: server.sendContent(F("Software")); break; case ESP_RST_PANIC: server.sendContent(F("Panic/Exception")); break; case ESP_RST_INT_WDT: server.sendContent(F("Interrupt Watchdog")); break; case ESP_RST_TASK_WDT: server.sendContent(F("Task Watchdog")); break; case ESP_RST_WDT: server.sendContent(F("Other Watchdog")); break; case ESP_RST_DEEPSLEEP: server.sendContent(F("Deep Sleep Wakeup")); break; case ESP_RST_BROWNOUT: server.sendContent(F("Brownout")); break; case ESP_RST_SDIO: server.sendContent(F("SDIO")); break; default: // *** KORREKTUR: String-Verknüpfung vermeiden *** server.sendContent(F("Anderer Grund (")); // Teil 1 server.sendContent(String(reason)); // Teil 2 (Grund als Zahl) server.sendContent(F(")")); // Teil 3 break; } #else // ESP8266 String resetReasonStr = ESP.getResetReason(); strncpy(tempStringCopyBuffer, resetReasonStr.c_str(), sizeof(tempStringCopyBuffer) - 1); tempStringCopyBuffer[sizeof(tempStringCopyBuffer) - 1] = '\0'; if (strlen(tempStringCopyBuffer) > 0) server.sendContent(tempStringCopyBuffer); #endif // --- Sketch Größe --- server.sendContent_P(infoChunk_AfterResetReason); // "

Sketch-Größe: " snprintf(buffer, sizeof(buffer), "%u", ESP.getSketchSize()); server.sendContent(buffer); yield(); // --- Freier Sketch Speicher --- server.sendContent_P(infoChunk_AfterSketchSize); // " Bytes
Freier Sketch-Speicher: " snprintf(buffer, sizeof(buffer), "%u", ESP.getFreeSketchSpace()); server.sendContent(buffer); // --- Flash Größe --- server.sendContent_P(infoChunk_AfterFreeSketch); // " Bytes
Flash-Größe (Chip): " snprintf(buffer, sizeof(buffer), "%u", ESP.getFlashChipSize()); server.sendContent(buffer); yield(); // Vor dem letzten Chunk server.sendContent_P(infoChunk_End); // Ende yield(); server.sendContent(F("")); // Letzter leerer Chunk zum Abschließen der Übertragung yield(); } /************************************************************************************ * 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() { char buffer[20]; // Puffer für Zahlen server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, F("text/html"), ""); // Header senden // --- Kopfzeile, Styles, Navigation senden --- server.sendContent_P(serviceHtmlHead); server.sendContent_P(commonStyle); // Globale Styles nicht vergessen server.sendContent_P(serviceHtmlHeadEnd); server.sendContent_P(commonNav); // Globale Navigation nicht vergessen yield(); // --- Body Start --- server.sendContent_P(serviceChunk_BodyStart); yield(); // --- Wartung Intervall Formular --- server.sendContent_P(serviceChunk_MaintenanceForm); // Start Wartungs-Form, endet vor Intervall-Wert snprintf(buffer, sizeof(buffer), "%d", maintenanceInterval); server.sendContent(buffer); // Wert einfügen server.sendContent_P(serviceChunk_AfterMaintInterval); // Ende Wartungs-Form yield(); // --- Wartungsmodus Sektion --- server.sendContent_P(serviceChunk_WartungStart); // Start der Sektion if (wartungsModusAktiv) { server.sendContent(F("Aktiv")); // Zeige "Aktiv" } else { server.sendContent(F("Inaktiv")); // Zeige "Inaktiv" } yield(); server.sendContent_P(serviceChunk_WartungButton); // Bis vor Button-Text yield(); if (wartungsModusAktiv) { server.sendContent(F("Deaktivieren")); // Button zum Deaktivieren } else { server.sendContent(F("Aktivieren")); // Button zum Aktivieren } yield(); server.sendContent_P(serviceChunk_WartungEnd); // Rest des Formulars yield(); // --- Wartungszähler Reset Formular --- server.sendContent_P(serviceChunk_MaintenanceResetForm); // Start Zähler-Reset-Form, endet vor Zähler-Wert snprintf(buffer, sizeof(buffer), "%lu", maintenanceIntervalCounter); server.sendContent(buffer); // Wert einfügen server.sendContent_P(serviceChunk_AfterMaintCounter); // Ende Zähler-Reset-Form yield(); // --- Body Ende --- server.sendContent_P(serviceChunk_BodyEnd); yield(); // --- Abschluss --- server.sendContent(F("")); // Letzter leerer Chunk zum Abschließen der Übertragung yield(); } /************************************************************************************ * 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"; // --- Überarbeitete Funktion handleEco --- void handleEco() // URL: /ECO { char buffer[12]; // Puffer für Zahlenumwandlungen server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, F("text/html"), ""); // Kopfzeile senden server.sendContent_P(ecoHtmlHead); server.sendContent_P(commonStyle); server.sendContent_P(ecoHtmlHeadEnd); server.sendContent_P(commonNav); // Navigation // Body Start bis Eco Mode Wert server.sendContent_P(ecoChunk_BodyStart); // Eco Mode Wert (int) snprintf(buffer, sizeof(buffer), "%d", ecoModeMinutes); server.sendContent(buffer); // Nach Eco Mode bis Eco Temp Wasser server.sendContent_P(ecoChunk_AfterEcoMode); // Eco Temp Wasser Wert (int) snprintf(buffer, sizeof(buffer), "%d", ecoModeTempWasser); server.sendContent(buffer); // Nach Eco Temp Wasser bis Eco Temp Dampf server.sendContent_P(ecoChunk_AfterTempW); // Eco Temp Dampf Wert (int) snprintf(buffer, sizeof(buffer), "%d", ecoModeTempDampf); server.sendContent(buffer); // Nach Eco Temp Dampf bis Dynamic Eco Checkbox server.sendContent_P(ecoChunk_AfterTempD); // Dynamic Eco checked Attribut (falls nötig) if (dynamicEcoActive) { server.sendContent(F(" checked")); } // Nach Dynamic Eco Checkbox bis Dampf Delay server.sendContent_P(ecoChunk_AfterDynCheck); // Dampf Delay Wert (int) snprintf(buffer, sizeof(buffer), "%d", dampfVerzoegerung); server.sendContent(buffer); // Rest der Seite server.sendContent_P(ecoChunk_AfterDampfDelay); // Letzter leerer Chunk zum Abschließen server.sendContent(F("")); } /************************************************************************************ * 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"; // --- Überarbeitete Funktion handleFastHeatUp --- void handleFastHeatUp() // URL: /Fast-Heat-Up { server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, F("text/html"), ""); // Kopfzeile senden server.sendContent_P(fhuHtmlHead); server.sendContent_P(commonStyle); server.sendContent_P(fhuHtmlHeadEnd); server.sendContent_P(commonNav); // Navigation // Chunk 1 senden (bis zur Checkbox) server.sendContent_P(fhuChunk1); // "checked" Attribut senden, falls Fast-Heat-Up aktiv ist if (fastHeatUpAktiv) { server.sendContent(F(" checked")); } // Chunk 2 senden (Rest der Seite) server.sendContent_P(fhuChunk2); // Letzter leerer Chunk zum Abschließen server.sendContent(F("")); } /************************************************************************************ * Handler zum Speichern der Fast-Heat-Up-Einstellung - URL angepasst ************************************************************************************/ void handleFastHeatUpSettings() { if (server.hasArg(F("fastHeatUpAktiv"))) { fastHeatUpAktiv = true; } else { fastHeatUpAktiv = false; } EEPROM.put(EEPROM_ADDR_FASTHEATUP_DATA, fastHeatUpAktiv); EEPROM.commit(); server.sendHeader(F("Location"), F("/Fast-Heat-Up")); // URL angepasst server.send(303); } /************************************************************************************ * 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() { char buffer[20]; // Puffer für Zahlenumwandlungen (float/int/ulong) server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, F("text/html"), ""); // Kopfzeile senden server.sendContent_P(rootHtmlHead); server.sendContent_P(commonStyle); server.sendContent_P(rootHtmlHeadEnd); server.sendContent_P(commonNav); // Navigation // Body Start server.sendContent_P(rootChunk_BodyStart); // --- Dynamische Fehlermeldungen generieren und senden --- String errorMessageHtml = ""; // Kleiner temporärer String ist ok 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) { server.sendContent(errorMessageHtml); // Fehler als Chunk senden } // Formular Anfang bis Wasser Setpoint server.sendContent_P(rootChunk_FormStart); // Wasser Setpoint Wert snprintf(buffer, sizeof(buffer), "%d", (int)SetpointWasser); server.sendContent(buffer); // Nach Wasser Setpoint bis Wasser Offset server.sendContent_P(rootChunk_AfterWasser); // Wasser Offset Wert snprintf(buffer, sizeof(buffer), "%.2f", OffsetWasser); server.sendContent(buffer); // Nach Wasser Offset bis Boost Wasser Checkbox server.sendContent_P(rootChunk_AfterOffsetW); // Boost Wasser checked Attribut (falls nötig) if (boostWasserActive) server.sendContent(F(" checked")); // Nach Boost Wasser bis Prevent Heat Wasser Checkbox server.sendContent_P(rootChunk_AfterBoostW); // Prevent Heat Wasser checked Attribut (falls nötig) if (preventHeatAboveSetpointWasser) server.sendContent(F(" checked")); // Nach Prevent Heat Wasser bis Dampf Setpoint server.sendContent_P(rootChunk_AfterPreventW); // Dampf Setpoint Wert snprintf(buffer, sizeof(buffer), "%d", (int)SetpointDampf); server.sendContent(buffer); // Nach Dampf Setpoint bis Dampf Offset server.sendContent_P(rootChunk_AfterDampf); // Dampf Offset Wert snprintf(buffer, sizeof(buffer), "%.2f", OffsetDampf); server.sendContent(buffer); // Nach Dampf Offset bis Boost Dampf Checkbox server.sendContent_P(rootChunk_AfterOffsetD); // Boost Dampf checked Attribut (falls nötig) if (boostDampfActive) server.sendContent(F(" checked")); // Nach Boost Dampf bis Prevent Heat Dampf Checkbox server.sendContent_P(rootChunk_AfterBoostD); // Prevent Heat Dampf checked Attribut (falls nötig) if (preventHeatAboveSetpointDampf) server.sendContent(F(" checked")); // Nach Prevent Heat Dampf bis Kp Wasser server.sendContent_P(rootChunk_AfterPreventD); // Kp Wasser Wert snprintf(buffer, sizeof(buffer), "%.2f", KpWasser); server.sendContent(buffer); // Nach Kp Wasser bis Ki Wasser server.sendContent_P(rootChunk_AfterKpW); // Ki Wasser Wert snprintf(buffer, sizeof(buffer), "%.2f", KiWasser); server.sendContent(buffer); // Nach Ki Wasser bis Kd Wasser server.sendContent_P(rootChunk_AfterKiW); // Kd Wasser Wert snprintf(buffer, sizeof(buffer), "%.2f", KdWasser); server.sendContent(buffer); // Nach Kd Wasser bis Window Size Wasser server.sendContent_P(rootChunk_AfterKdW); // Window Size Wasser Wert snprintf(buffer, sizeof(buffer), "%lu", windowSizeWasser); server.sendContent(buffer); // Nach Window Size Wasser bis Kp Dampf server.sendContent_P(rootChunk_AfterWindowW); // Kp Dampf Wert snprintf(buffer, sizeof(buffer), "%.2f", KpDampf); server.sendContent(buffer); // Nach Kp Dampf bis Ki Dampf server.sendContent_P(rootChunk_AfterKpD); // Ki Dampf Wert snprintf(buffer, sizeof(buffer), "%.2f", KiDampf); server.sendContent(buffer); // Nach Ki Dampf bis Kd Dampf server.sendContent_P(rootChunk_AfterKiD); // Kd Dampf Wert snprintf(buffer, sizeof(buffer), "%.2f", KdDampf); server.sendContent(buffer); // Nach Kd Dampf bis Window Size Dampf server.sendContent_P(rootChunk_AfterKdD); // Window Size Dampf Wert snprintf(buffer, sizeof(buffer), "%lu", windowSizeDampf); server.sendContent(buffer); // Nach Window Size Dampf bis Max Temp Wasser server.sendContent_P(rootChunk_AfterWindowD); // Max Temp Wasser Wert snprintf(buffer, sizeof(buffer), "%.2f", maxTempWasser); server.sendContent(buffer); // Nach Max Temp Wasser bis Max Temp Dampf server.sendContent_P(rootChunk_AfterMaxW); // Max Temp Dampf Wert snprintf(buffer, sizeof(buffer), "%.2f", maxTempDampf); server.sendContent(buffer); // Formular Ende und HTML Ende server.sendContent_P(rootChunk_FormEnd); // Letzter leerer Chunk zum Abschließen server.sendContent(F("")); } /************************************************************************************ * Schreibt die Werte in den EEPROM (PID/Eco) - Unverändert ************************************************************************************/ void handleSettingsUpdate() { bool valueChanged = false; // Flag um zu prüfen, ob EEPROM.commit() nötig ist // --- Bestehende Werte lesen --- // Sicherheitslimits if (server.hasArg(F("maxTempWasser"))) { float newVal = server.arg(F("maxTempWasser")).toFloat(); if (newVal != maxTempWasser) { maxTempWasser = newVal; EEPROM.put(EEPROM_ADDR_MAX_TEMP_WASSER, maxTempWasser); valueChanged = true; } } if (server.hasArg(F("maxTempDampf"))) { float newVal = server.arg(F("maxTempDampf")).toFloat(); if (newVal != maxTempDampf) { maxTempDampf = newVal; EEPROM.put(EEPROM_ADDR_MAX_TEMP_DAMPF, maxTempDampf); valueChanged = true; } } // Setpoints if (server.hasArg(F("wasser"))) { float newVal = server.arg(F("wasser")).toFloat(); if (newVal != SetpointWasser) { SetpointWasser = newVal; EEPROM.put(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); valueChanged = true; } } if (server.hasArg(F("dampf"))) { float newVal = server.arg(F("dampf")).toFloat(); if (newVal != SetpointDampf) { SetpointDampf = newVal; EEPROM.put(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); valueChanged = true; } } // Offsets if (server.hasArg(F("offsetWasser"))) { float newVal = server.arg(F("offsetWasser")).toFloat(); if (newVal != OffsetWasser) { OffsetWasser = newVal; EEPROM.put(EEPROM_ADDR_OFFSET_WASSER, OffsetWasser); valueChanged = true; } } if (server.hasArg(F("offsetDampf"))) { float newVal = server.arg(F("offsetDampf")).toFloat(); if (newVal != OffsetDampf) { OffsetDampf = newVal; EEPROM.put(EEPROM_ADDR_OFFSET_DAMPF, OffsetDampf); valueChanged = true; } } // PID Wasser if (server.hasArg(F("kpWasser"))) { float newVal = server.arg(F("kpWasser")).toFloat(); if (newVal != KpWasser) { KpWasser = newVal; EEPROM.put(EEPROM_ADDR_KP_WASSER, KpWasser); valueChanged = true; } } if (server.hasArg(F("kiWasser"))) { float newVal = server.arg(F("kiWasser")).toFloat(); if (newVal != KiWasser) { KiWasser = newVal; EEPROM.put(EEPROM_ADDR_KI_WASSER, KiWasser); valueChanged = true; } } if (server.hasArg(F("kdWasser"))) { float newVal = server.arg(F("kdWasser")).toFloat(); if (newVal != KdWasser) { KdWasser = newVal; EEPROM.put(EEPROM_ADDR_KD_WASSER, KdWasser); valueChanged = true; } } // PID Dampf if (server.hasArg(F("kpDampf"))) { float newVal = server.arg(F("kpDampf")).toFloat(); if (newVal != KpDampf) { KpDampf = newVal; EEPROM.put(EEPROM_ADDR_KP_DAMPF, KpDampf); valueChanged = true; } } if (server.hasArg(F("kiDampf"))) { float newVal = server.arg(F("kiDampf")).toFloat(); if (newVal != KiDampf) { KiDampf = newVal; EEPROM.put(EEPROM_ADDR_KI_DAMPF, KiDampf); valueChanged = true; } } if (server.hasArg(F("kdDampf"))) { float newVal = server.arg(F("kdDampf")).toFloat(); if (newVal != KdDampf) { KdDampf = newVal; EEPROM.put(EEPROM_ADDR_KD_DAMPF, KdDampf); valueChanged = true; } } // Boost Flags bool newBoostWasser = server.hasArg(F("boostWasser")); if (newBoostWasser != boostWasserActive) { boostWasserActive = newBoostWasser; EEPROM.put(EEPROM_ADDR_BOOST_WASSER_ACTIVE, boostWasserActive); valueChanged = true; } bool newBoostDampf = server.hasArg(F("boostDampf")); if (newBoostDampf != boostDampfActive) { boostDampfActive = newBoostDampf; EEPROM.put(EEPROM_ADDR_BOOST_DAMPF_ACTIVE, boostDampfActive); valueChanged = true; } // Prevent Heat Above Setpoint Flags bool newPreventHeatWasser = server.hasArg(F("preventHeatWasser")); 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 = server.hasArg(F("preventHeatDampf")); 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; // Startwert mit aktuellem Wert if (server.hasArg(F("windowSizeWasser"))) { char* endptr; // Konvertiere den String-Parameter zu unsigned long unsigned long val = strtoul(server.arg(F("windowSizeWasser")).c_str(), &endptr, 10); // Prüfe, ob Konvertierung erfolgreich war und Wert sinnvoll ist (z.B. >0 und nicht zu groß) if (*endptr == '\0' && val > 0 && val <= 60000) { // Beispiel: max 60 Sekunden newWindowSizeWasser = val; } else { Serial.printf("WARNUNG: Ungültiger Wert für windowSizeWasser empfangen: %s\n", server.arg(F("windowSizeWasser")).c_str()); } } // Wenn sich der Wert geändert hat: if (newWindowSizeWasser != windowSizeWasser) { windowSizeWasser = newWindowSizeWasser; EEPROM.put(EEPROM_ADDR_WINDOWSIZE_WASSER, windowSizeWasser); pidWasser.SetOutputLimits(0, windowSizeWasser); // <-- WICHTIG: PID Limit sofort aktualisieren! valueChanged = true; Serial.printf("Fenstergröße Wasser aktualisiert auf: %lu ms\n", windowSizeWasser); } unsigned long newWindowSizeDampf = windowSizeDampf; // Startwert mit aktuellem Wert if (server.hasArg(F("windowSizeDampf"))) { char* endptr; unsigned long val = strtoul(server.arg(F("windowSizeDampf")).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", server.arg(F("windowSizeDampf")).c_str()); } } // Wenn sich der Wert geändert hat: if (newWindowSizeDampf != windowSizeDampf) { windowSizeDampf = newWindowSizeDampf; EEPROM.put(EEPROM_ADDR_WINDOWSIZE_DAMPF, windowSizeDampf); pidDampf.SetOutputLimits(0, windowSizeDampf); // <-- WICHTIG: PID Limit sofort aktualisieren! valueChanged = true; Serial.printf("Fenstergröße Dampf aktualisiert auf: %lu ms\n", windowSizeDampf); } // --- EEPROM speichern und PID aktualisieren --- if (valueChanged) { // Nur speichern, wenn sich mindestens ein Wert geändert hat if (EEPROM.commit()) { Serial.println("Einstellungen erfolgreich in EEPROM gespeichert."); } else { Serial.println("FEHLER: EEPROM Commit fehlgeschlagen!"); } } // PID-Parameter an die Regler übergeben pidDampf.SetTunings(KpDampf, KiDampf, KdDampf); pidWasser.SetTunings(KpWasser, KiWasser, KdWasser); // Zurück zur Hauptseite (bereits vorhanden) server.sendHeader(F("Location"), F("/")); server.send(303); } /************************************************************************************ * 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"; // --- Neue Funktion handleFirmware --- void handleFirmware() { // URL: /Firmware, Methode: GET server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, F("text/html"), ""); // Kopfzeile senden server.sendContent_P(firmwareHtmlHead); // Enthält öffnendes und bis server.sendContent_P(commonStyle); // ist im Head-Chunk enthalten server.sendContent_P(commonNav); // Navigation // Body Start und Firmware Info Sektion server.sendContent_P(firmwareBodyStart); // Enthält ,

und Start der
für Info // Firmware Version if (version.length() > 0) { // Prüfen, ob String nicht leer ist server.sendContent(version); } server.sendContent_P(firmwareInfoChunk2); // Enthält
Hersteller: // Hersteller if (versionHersteller.length() > 0) { server.sendContent(versionHersteller); } server.sendContent_P(firmwareInfoChunk3); // Enthält
E-Mail: // Mail (ist HTML, kann direkt gesendet werden) if (versionHerstellerMail.length() > 0) { server.sendContent(versionHerstellerMail); } server.sendContent_P(firmwareInfoChunk4); // Enthält
Website: // Website (ist HTML, kann direkt gesendet werden) if (versionHerstellerWeb.length() > 0) { server.sendContent(versionHerstellerWeb); } server.sendContent_P(firmwareInfoChunkEnd); // Enthält
// Firmware Update Sektion server.sendContent_P(firmwareUpdateSection); // Dateimanager Sektion server.sendContent_P(firmwareFileManagerSection); // Einstellungen Export Sektion server.sendContent_P(firmwareExportSection); // Einstellungen Import Sektion server.sendContent_P(firmwareImportSection); // Werkseinstellungen Sektion server.sendContent_P(firmwareResetSection); // Body / HTML Ende server.sendContent_P(firmwareBodyEnd); // Letzter leerer Chunk zum Abschließen server.sendContent(F("")); } /************************************************************************************ * 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"; // --- Neue Funktion handleAutoTuneWasser --- void handleAutoTuneWasser() { server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, F("text/html"), ""); // Header senden server.sendContent_P(autotuneWasserHtmlHead); server.sendContent_P(commonStyle); server.sendContent_P(commonNav); server.sendContent_P(autotuneWasserBodyStart); // Prüfen, ob bereits ein Tuning läuft if (!autoTuneWasserActive && !autoTuneDampfActive) { // Wartungsmodus explizit deaktivieren if (wartungsModusAktiv) { wartungsModusAktiv = false; // Standard-Temperaturwerte laden 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(); // Funktion zum Starten des Tunings aufrufen server.sendContent_P(autotuneWasserStartSuccess); } else { // Meldung, dass bereits ein Tuning aktiv ist server.sendContent_P(autotuneWasserAlreadyActive); } server.sendContent_P(autotuneWasserBodyEnd); server.sendContent(F("")); // Abschluss-Chunk } /************************************************************************************ * 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() { server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, F("text/html"), ""); // Header senden server.sendContent_P(autotuneDampfHtmlHead); // Eigener Head-Chunk server.sendContent_P(commonStyle); server.sendContent_P(commonNav); server.sendContent_P(autotuneWasserBodyStart); // Wiederverwendet // Prüfen, ob bereits ein Tuning läuft if (!autoTuneWasserActive && !autoTuneDampfActive) { // Wartungsmodus explizit deaktivieren if (wartungsModusAktiv) { wartungsModusAktiv = false; // Standard-Temperaturwerte laden 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(); // Funktion zum Starten des Tunings aufrufen server.sendContent_P(autotuneDampfStartSuccess); // Eigener Success-Chunk } else { // Meldung, dass bereits ein Tuning aktiv ist server.sendContent_P(autotuneWasserAlreadyActive); // Wiederverwendet } server.sendContent_P(autotuneWasserBodyEnd); // Wiederverwendet server.sendContent(F("")); // Abschluss-Chunk } /************************************************************************************ * Handler zum Speichern der AutoTune-Einstellungen - ausgelagert ************************************************************************************/ void handleUpdateAutotuneSettings() { bool changed = false; // Flag, um zu prüfen, ob EEPROM.commit() nötig ist // --- Wasser Parameter --- if (server.hasArg(F("tuningStepWasser"))) { tuningStepWasser = server.arg(F("tuningStepWasser")).toFloat(); EEPROM.put(EEPROM_ADDR_TUNING_STEP_WASSER, tuningStepWasser); changed = true; } if (server.hasArg(F("tuningNoiseWasser"))) { tuningNoiseWasser = server.arg(F("tuningNoiseWasser")).toFloat(); EEPROM.put(EEPROM_ADDR_TUNING_NOISE_WASSER, tuningNoiseWasser); changed = true; } if (server.hasArg(F("tuningStartValueWasser"))) { tuningStartValueWasser = server.arg(F("tuningStartValueWasser")).toFloat(); EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_WASSER, tuningStartValueWasser); changed = true; } if (server.hasArg(F("tuningLookBackWasser"))) { // Wichtig: toInt() verwenden, da es unsigned int ist, aber über HTTP als String kommt int lookbackInt = server.arg(F("tuningLookBackWasser")).toInt(); if (lookbackInt >= 0) { // Grundlegende Plausibilitätsprüfung tuningLookBackWasser = (unsigned int)lookbackInt; EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_WASSER, tuningLookBackWasser); changed = true; } } // --- Dampf Parameter --- if (server.hasArg(F("tuningStepDampf"))) { tuningStepDampf = server.arg(F("tuningStepDampf")).toFloat(); EEPROM.put(EEPROM_ADDR_TUNING_STEP_DAMPF, tuningStepDampf); changed = true; } if (server.hasArg(F("tuningNoiseDampf"))) { tuningNoiseDampf = server.arg(F("tuningNoiseDampf")).toFloat(); EEPROM.put(EEPROM_ADDR_TUNING_NOISE_DAMPF, tuningNoiseDampf); changed = true; } if (server.hasArg(F("tuningStartValueDampf"))) { tuningStartValueDampf = server.arg(F("tuningStartValueDampf")).toFloat(); EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_DAMPF, tuningStartValueDampf); changed = true; } if (server.hasArg(F("tuningLookBackDampf"))) { // Wichtig: toInt() verwenden, da es unsigned int ist, aber über HTTP als String kommt int lookbackInt = server.arg(F("tuningLookBackDampf")).toInt(); if (lookbackInt >= 0) { // Grundlegende Plausibilitätsprüfung tuningLookBackDampf = (unsigned int)lookbackInt; EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_DAMPF, tuningLookBackDampf); changed = true; } } // Nur speichern, wenn sich etwas geändert hat if (changed) { if (!EEPROM.commit()) { Serial.println("FEHLER: EEPROM Commit für AutoTune-Settings fehlgeschlagen!"); } else { Serial.println("AutoTune-Parameter gespeichert."); } } // Weiterleitung zurück zur PID-Tuning Seite server.sendHeader(F("Location"), F("/PID-Tuning")); // URL bleibt gleich server.send(303); // 303 See Other - Korrekt für Redirect nach POST } /************************************************************************************ * 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() { char buffer[20]; // Puffer für Zahlenumwandlungen server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, F("text/html"), ""); server.sendContent_P(pidTuningPageHtmlHead); server.sendContent_P(commonStyle); // ist im Head-Chunk enthalten server.sendContent_P(commonNav); server.sendContent_P(pidTuningPageBodyStart); // Optionales "Abbrechen"-Formular anzeigen if (autoTuneWasserActive || autoTuneDampfActive) { server.sendContent_P(pidTuningPageAbortForm); } // Einführungstext und Start-Buttons server.sendContent_P(pidTuningPageIntro); // Parameter-Formular (Stück für Stück) server.sendContent_P(pidTuningPageParamsFormStart); // Bis vor WindowSize Wasser snprintf(buffer, sizeof(buffer), "%lu", windowSizeWasser); server.sendContent(buffer); // WindowSize Wasser server.sendContent_P(pidTuningPageParamsW1); // Bis vor Step Wasser snprintf(buffer, sizeof(buffer), "%.2f", tuningStepWasser); server.sendContent(buffer); // Step Wasser server.sendContent_P(pidTuningPageParamsW2); // Bis vor Noise Wasser snprintf(buffer, sizeof(buffer), "%.2f", tuningNoiseWasser); server.sendContent(buffer); // Noise Wasser server.sendContent_P(pidTuningPageParamsW3); // Bis vor WindowSize Wasser (für StartValue) snprintf(buffer, sizeof(buffer), "%lu", windowSizeWasser); server.sendContent(buffer); // WindowSize Wasser server.sendContent_P(pidTuningPageParamsW4); // Bis vor StartValue Wasser snprintf(buffer, sizeof(buffer), "%.2f", tuningStartValueWasser); server.sendContent(buffer); // StartValue Wasser server.sendContent_P(pidTuningPageParamsW5); // Bis vor LookBack Wasser snprintf(buffer, sizeof(buffer), "%u", tuningLookBackWasser); server.sendContent(buffer); // LookBack Wasser server.sendContent_P(pidTuningPageParamsDStart); // Bis vor WindowSize Dampf snprintf(buffer, sizeof(buffer), "%lu", windowSizeDampf); server.sendContent(buffer); // WindowSize Dampf server.sendContent_P(pidTuningPageParamsD1); // Bis vor Step Dampf snprintf(buffer, sizeof(buffer), "%.2f", tuningStepDampf); server.sendContent(buffer); // Step Dampf server.sendContent_P(pidTuningPageParamsD2); // Bis vor Noise Dampf snprintf(buffer, sizeof(buffer), "%.2f", tuningNoiseDampf); server.sendContent(buffer); // Noise Dampf server.sendContent_P(pidTuningPageParamsD3); // Bis vor WindowSize Dampf (für StartValue) snprintf(buffer, sizeof(buffer), "%lu", windowSizeDampf); server.sendContent(buffer); // WindowSize Dampf server.sendContent_P(pidTuningPageParamsD4); // Bis vor StartValue Dampf snprintf(buffer, sizeof(buffer), "%.2f", tuningStartValueDampf); server.sendContent(buffer); // StartValue Dampf server.sendContent_P(pidTuningPageParamsD5); // Bis vor LookBack Dampf snprintf(buffer, sizeof(buffer), "%u", tuningLookBackDampf); server.sendContent(buffer); // LookBack Dampf server.sendContent_P(pidTuningPageParamsFormEnd); // Rest des Formulars // Erklärungen server.sendContent_P(pidTuningPageExplanation); server.sendContent(F("")); // Abschluss-Chunk } /************************************************************************************ * 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() { // Seite senden server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, F("text/html"), ""); server.sendContent_P(abortPidTuningHtmlStart); server.sendContent_P(commonStyle); server.sendContent_P(abortPidTuningHtmlHeadEnd); server.sendContent_P(commonNav); server.sendContent_P(abortPidTuningHtmlBody); // Body ist komplett statisch server.sendContent(F("")); // Abschluss-Chunk // Eigentliche Abbruch-Logik ausführen, NACHDEM die Antwort gesendet wurde stopAutoTuneWasser(); stopAutoTuneDampf(); } /************************************************************************************ * Speichert Eco-Einstellungen - URL angepasst ************************************************************************************/ void handleEcoUpdate() { if (server.hasArg(F("ecoMode"))) ecoModeMinutes = server.arg(F("ecoMode")).toInt(); if (server.hasArg(F("ecoModeTempWasser"))) ecoModeTempWasser = server.arg(F("ecoModeTempWasser")).toInt(); if (server.hasArg(F("ecoModeTempDampf"))) ecoModeTempDampf = server.arg(F("ecoModeTempDampf")).toInt(); if (server.hasArg(F("dynamicEcoMode"))) { dynamicEcoActive = true; } else { dynamicEcoActive = false; } if (server.hasArg(F("dampfDelay"))) { dampfVerzoegerung = server.arg(F("dampfDelay")).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(); // Wenn Eco deaktiviert, Setpoints zurückladen if (ecoModeMinutes == 0) { ecoModeAktiv = false; ecoModeActivatedTime = 0; EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); } server.sendHeader(F("Location"), F("/ECO")); // URL angepasst server.send(303); } /************************************************************************************ * Speichert Geräte-Infos ************************************************************************************/ void handleInfoUpdate() { bool changed = false; if (server.hasArg(F("hersteller"))) { strncpy(infoHersteller, server.arg(F("hersteller")).c_str(), sizeof(infoHersteller) - 1); infoHersteller[sizeof(infoHersteller) - 1] = '\0'; EEPROM.put(EEPROM_ADDR_INFO_HERSTELLER, infoHersteller); changed = true; } if (server.hasArg(F("modell"))) { strncpy(infoModell, server.arg(F("modell")).c_str(), sizeof(infoModell) - 1); infoModell[sizeof(infoModell) - 1] = '\0'; EEPROM.put(EEPROM_ADDR_INFO_MODELL, infoModell); changed = true; } if (server.hasArg(F("zusatz"))) { strncpy(infoZusatz, server.arg(F("zusatz")).c_str(), sizeof(infoZusatz) - 1); infoZusatz[sizeof(infoZusatz) - 1] = '\0'; EEPROM.put(EEPROM_ADDR_INFO_ZUSATZ, infoZusatz); changed = true; } if (changed) { EEPROM.commit(); } server.sendHeader(F("Location"), F("/Info")); // URL angepasst server.send(303); } /************************************************************************************ * Speichert das Wartungsintervall ************************************************************************************/ void handleMaintenanceUpdate() { bool changed = false; // Wartungsintervall speichern if (server.hasArg(F("maintenanceInterval"))) { int newInterval = server.arg(F("maintenanceInterval")).toInt(); if (newInterval < 0) newInterval = 0; // Sicherstellen, dass nicht negativ 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(); } server.sendHeader(F("Location"), F("/Service")); // URL angepasst server.send(303); } /************************************************************************************ * Handler für JSON-Chart-Daten (/Chart-Daten) - Heap-Optimiert ************************************************************************************/ void handleChartData() { // JSON-Daten als Chunks senden server.setContentLength(CONTENT_LENGTH_UNKNOWN); // WICHTIG: Content-Type beibehalten! server.send(200, F("application/json"), ""); // Header senden char buffer[20]; // Puffer für Zahlen (double mit einer Nachkommastelle) server.sendContent(F("{\"wasser\":")); // Formatierung mit einer Nachkommastelle, wie im Chart-Tooltip snprintf(buffer, sizeof(buffer), "%.1f", InputWasser); server.sendContent(buffer); server.sendContent(F(",\"setpointwasser\":")); snprintf(buffer, sizeof(buffer), "%.1f", SetpointWasser); server.sendContent(buffer); server.sendContent(F(",\"dampf\":")); snprintf(buffer, sizeof(buffer), "%.1f", InputDampf); server.sendContent(buffer); server.sendContent(F(",\"setpointdampf\":")); snprintf(buffer, sizeof(buffer), "%.1f", SetpointDampf); server.sendContent(buffer); server.sendContent(F("}")); server.sendContent(F("")); // Abschluss-Chunk } /************************************************************************************ * 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. ************************************************************************************/ void handleCharts() // URL: /Chart { bool chartJsLocal = LittleFS.exists("/chart.umd.min.js"); bool isConnected = (WiFi.status() == WL_CONNECTED); String chartJsUrl = ""; bool canLoadChartJs = false; // Logik zur Bestimmung von chartJsUrl und canLoadChartJs bleibt gleich... 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 (lokal nicht gefunden)."); } else { canLoadChartJs = false; Serial.println("Chart-Handler: Kann chart.js nicht laden (nicht lokal, keine Verbindung)."); } // --- HTML-Teile definieren (PROGMEM für Effizienz) --- // Teil 1: HTML-Start, Meta-Tags (bleibt gleich) static const char chartsHtml_HeadStart[] PROGMEM = R"rawliteral( Temperatur-Chart )rawliteral"; // Teil 1b: Spezifische Styles (leicht angepasst für neue Klasse) static const char chartsHtml_SpecificStyles[] PROGMEM = R"rawliteral( )rawliteral"; // Teil 2: Schließendes Head-Tag (bleibt gleich) static const char chartsHtml_HeadEnd[] PROGMEM = "\n"; // 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"; // Div wird später geschlossen ODER Inhalt geändert // 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'; // 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(); } }); // 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"; // --- Seitenaufbau starten --- server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, F("text/html"), ""); // Header senden server.sendContent_P(chartsHtml_HeadStart); server.sendContent_P(commonStyle); // Allgemeine Styles server.sendContent_P(chartsHtml_SpecificStyles); // Chart-spezifische Styles if (canLoadChartJs) { // Script-Tag für Chart.js einfügen (lokal oder CDN) String scriptTag = "\n"; server.sendContent(scriptTag); yield(); } // Head immer schließen server.sendContent_P(chartsHtml_HeadEnd); // Body-Tag senden server.sendContent("\n"); // Navigation immer einfügen server.sendContent_P(commonNav); // Haupt-Wrapper für den Inhalt öffnen server.sendContent("
\n"); // --- Body-Inhalt basierend auf Verfügbarkeit von Chart.js --- if (canLoadChartJs) { // Normaler Body mit Chart String bodyStart = FPSTR(chartsHtml_BodyStartAndUI); // Platzhalter holen // Platzhalter für Selektoren ersetzen String selectorsUI = FPSTR(chartsHtml_SelectorsUI); bodyStart.replace(F("{SELECTOR_UI_PLACEHOLDER}"), selectorsUI); // Platzhalter für Sensorfehler ersetzen (wie vorher) 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); server.sendContent(bodyStart); // Modifizierten Body-Start senden // Canvas-Element für Chart.js einfügen server.sendContent(""); // Chart-Container-Div schließen server.sendContent("
\n"); // Schließt das
// Chart Initialisierungs-JavaScript senden server.sendContent("\n"); yield(); } else { // Fehlerfall: Chart.js nicht verfügbar (bleibt gleich) server.sendContent("\n"); // Body trotzdem öffnen server.sendContent("

Temperatur-Chart

\n"); // Titel server.sendContent("
"); server.sendContent("

Chart kann nicht angezeigt werden

"); server.sendContent("

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

"); server.sendContent("

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.

"); server.sendContent("
\n"); } server.sendContent("\n"); // Schließt den
Wrapper // Body und HTML immer schließen server.sendContent_P(chartsHtml_BodyEnd); // Leeren Chunk senden, um die Übertragung abzuschließen server.sendContent(""); yield(); } /************************************************************************************ * Hilfsfunktionen für Profile ************************************************************************************/ // Hilfsfunktion zum Bereinigen von Profilnamen für Dateinamen String sanitizeProfileName(String profileName) { profileName.trim(); // Leerzeichen am Anfang/Ende entfernen if (profileName.length() == 0) { profileName = "Unbenannt"; } // Ersetze ungültige Zeichen (Beispiel: Leerzeichen, /, \, :, *, ?, ", <, >, |) profileName.replace(" ", "_"); profileName.replace("/", "_"); profileName.replace("\\", "_"); profileName.replace(":", "_"); profileName.replace("*", "_"); profileName.replace("?", "_"); profileName.replace("\"", "_"); profileName.replace("<", "_"); profileName.replace(">", "_"); profileName.replace("|", "_"); // Maximale Länge begrenzen (z.B. 30 Zeichen + .txt) if (profileName.length() > 30) { profileName = profileName.substring(0, 30); } return profileName; } // Listet alle Profile (.prof Dateien) im /Profile Verzeichnis auf std::vector listProfiles() { std::vector profileNames; Serial.println("Listing profiles (.prof) in /Profile:"); // Debug-Meldung angepasst #ifdef ESP32 File root = LittleFS.open("/Profile"); if (!root || !root.isDirectory()) { Serial.println(" Failed to open /Profile directory."); if(root) root.close(); return profileNames; // Leere Liste zurückgeben } File file = root.openNextFile(); while(file){ String filePath = file.path(); // ESP32 gibt vollen Pfad inkl. /Profile/ zurück if (!file.isDirectory() && filePath.endsWith(".prof")) { // *** GEÄNDERT ZU .prof *** // Extrahiere nur den Namen ohne Pfad und Endung int lastSlash = filePath.lastIndexOf('/'); int lastDot = filePath.lastIndexOf('.'); // Sollte .prof sein if (lastSlash != -1 && lastDot != -1 && lastDot > lastSlash) { String profileName = filePath.substring(lastSlash + 1, lastDot); // Extrahiert den Namen profileNames.push_back(profileName); Serial.printf(" - Found profile: %s\n", profileName.c_str()); } else { Serial.printf(" - Ignoring file (invalid format?): %s\n", filePath.c_str()); } } else if (!file.isDirectory()) { Serial.printf(" - Ignoring file (not .prof): %s\n", filePath.c_str()); // Meldung angepasst } else { Serial.printf(" - Ignoring directory: %s\n", filePath.c_str()); } file.close(); // Wichtig! file = root.openNextFile(); yield(); // Wichtig bei vielen Dateien } root.close(); #else // ESP8266 Dir dir = LittleFS.openDir("/Profile"); while (dir.next()) { String fileName = dir.fileName(); // Gibt nur Dateiname.prof zurück if (!dir.isDirectory() && fileName.endsWith(".prof")) { // *** GEÄNDERT ZU .prof *** int lastDot = fileName.lastIndexOf('.'); // Sollte .prof sein if (lastDot != -1) { String profileName = fileName.substring(0, lastDot); // Extrahiert den Namen profileNames.push_back(profileName); Serial.printf(" - Found profile: %s\n", profileName.c_str()); } else { Serial.printf(" - Ignoring file (no extension?): %s\n", fileName.c_str()); } } else if (!dir.isDirectory()) { Serial.printf(" - Ignoring file (not .prof): %s\n", fileName.c_str()); // Meldung angepasst } else { Serial.printf(" - Ignoring directory: %s\n", fileName.c_str()); } yield(); // Wichtig bei vielen Dateien } // dir schließt automatisch #endif Serial.printf("Found %d profiles.\n", profileNames.size()); return profileNames; } // Speichert die übergebenen Profileinstellungen als BINÄRE Datei (.prof) bool saveProfile(const String& profileName, const TemperatureProfile& profileData) { String sanitizedName = sanitizeProfileName(profileName); if (sanitizedName.length() == 0) return false; // Kein gültiger Name nach Bereinigung // Dateiendung auf .prof ändern, um Text von Binär zu unterscheiden String filePath = "/Profile/" + sanitizedName + ".prof"; Serial.printf("Saving profile (binary) to: %s\n", filePath.c_str()); File profileFile = LittleFS.open(filePath, "w"); // "w" für (binäres) Schreiben if (!profileFile) { Serial.println(" ERROR: Failed to open file for writing."); return false; } // Stelle sicher, dass die Versionsnummer korrekt ist, bevor gespeichert wird // Das sollte im Konstruktor oder in getCurrentSettingsAsProfile passieren, // aber zur Sicherheit hier nochmal setzen: TemperatureProfile dataToSave = profileData; // Kopie erstellen dataToSave.profileVersion = CURRENT_PROFILE_VERSION; // Sicherstellen! // Schreibe die gesamte Struktur als Bytes size_t bytesWritten = profileFile.write((uint8_t*)&dataToSave, sizeof(TemperatureProfile)); profileFile.close(); // Datei schließen if (bytesWritten == sizeof(TemperatureProfile)) { Serial.println(" Profile saved successfully (Binary Format)."); return true; } else { Serial.printf(" ERROR: Failed to write complete profile data! Bytes written: %d, Expected: %d\n", bytesWritten, sizeof(TemperatureProfile)); // Versuch, die unvollständige Datei zu löschen LittleFS.remove(filePath); return false; } } // Lädt ein Profil aus einer BINÄREN Datei (.prof) in die übergebene Struktur bool loadProfile(const String& profileName, TemperatureProfile& profileData) { String sanitizedName = sanitizeProfileName(profileName); // Verwende den Namen aus der Liste if (sanitizedName.length() == 0) return false; // Dateiendung auf .prof ändern String filePath = "/Profile/" + sanitizedName + ".prof"; Serial.printf("Loading profile (binary) from: %s\n", filePath.c_str()); if (!LittleFS.exists(filePath)) { Serial.println(" ERROR: Profile file not found."); return false; } File profileFile = LittleFS.open(filePath, "r"); // "r" für (binäres) Lesen if (!profileFile) { Serial.println(" ERROR: Failed to open profile file for reading."); return false; } // --- Validierung der Dateigröße --- size_t fileSize = profileFile.size(); if (fileSize != sizeof(TemperatureProfile)) { Serial.printf(" ERROR: Profile file size mismatch! Expected %d bytes, got %d bytes.\n", sizeof(TemperatureProfile), fileSize); Serial.println(" Profile might be corrupted or from an incompatible version."); profileFile.close(); return false; } // Lese die Struktur-Daten size_t bytesRead = profileFile.read((uint8_t*)&profileData, sizeof(TemperatureProfile)); profileFile.close(); // Datei nach dem Lesen schließen if (bytesRead != sizeof(TemperatureProfile)) { Serial.printf(" ERROR: Failed to read complete profile data! Bytes read: %d\n", bytesRead); return false; } // --- Validierung der Profil-Version --- if (profileData.profileVersion != CURRENT_PROFILE_VERSION) { Serial.printf(" ERROR: Profile version mismatch! Expected version %d, got version %d.\n", CURRENT_PROFILE_VERSION, profileData.profileVersion); Serial.println(" Cannot load profile from incompatible version."); // Optional: Hier könnte man versuchen, alte Versionen zu konvertieren return false; } Serial.println(" Profile loaded successfully (Binary Format)."); // Der in der Datei gespeicherte `profileName` ist jetzt in profileData.profileName Serial.printf(" Loaded Profile Name from file: %s\n", profileData.profileName); return true; } // Wendet ALLE geladenen Profileinstellungen auf die aktuellen Systemvariablen und EEPROM an // Wendet auch Brew Control Einstellungen an void applyProfileSettings(const TemperatureProfile& profileData) { Serial.printf("Applying settings from loaded profile: %s (Version %d)\n", profileData.profileName, profileData.profileVersion); // --- Prüfen der Profilversion (Sicherheitscheck) --- if (profileData.profileVersion != CURRENT_PROFILE_VERSION) { Serial.printf(" FEHLER: Kann Profil '%s' nicht anwenden. Version %d ist inkompatibel mit erwarteter Version %d.\n", profileData.profileName, profileData.profileVersion, CURRENT_PROFILE_VERSION); profileStatusMessage = "FEHLER: Profil '" + String(profileData.profileName) + "' hat eine inkompatible Version."; return; // Funktion hier abbrechen } // --- Globale Variablen direkt aktualisieren --- SetpointWasser = profileData.setpointWasser; SetpointDampf = profileData.setpointDampf; OffsetWasser = profileData.offsetWasser; OffsetDampf = profileData.offsetDampf; KpWasser = profileData.kpWasser; KiWasser = profileData.kiWasser; KdWasser = profileData.kdWasser; KpDampf = profileData.kpDampf; KiDampf = profileData.kiDampf; KdDampf = profileData.kdDampf; boostWasserActive = profileData.boostWasserActive; boostDampfActive = profileData.boostDampfActive; preventHeatAboveSetpointWasser = profileData.preventHeatAboveSetpointWasser; preventHeatAboveSetpointDampf = profileData.preventHeatAboveSetpointDampf; windowSizeWasser = profileData.windowSizeWasser; windowSizeDampf = profileData.windowSizeDampf; maxTempWasser = profileData.maxTempWasser; maxTempDampf = profileData.maxTempDampf; tuningStepWasser = profileData.tuningStepWasser; tuningNoiseWasser = profileData.tuningNoiseWasser; tuningStartValueWasser = profileData.tuningStartValueWasser; tuningLookBackWasser = profileData.tuningLookBackWasser; tuningStepDampf = profileData.tuningStepDampf; tuningNoiseDampf = profileData.tuningNoiseDampf; tuningStartValueDampf = profileData.tuningStartValueDampf; tuningLookBackDampf = profileData.tuningLookBackDampf; ecoModeMinutes = profileData.ecoModeMinutes; ecoModeTempWasser = profileData.ecoModeTempWasser; ecoModeTempDampf = profileData.ecoModeTempDampf; dynamicEcoActive = profileData.dynamicEcoActive; dampfVerzoegerung = profileData.dampfVerzoegerung; fastHeatUpAktiv = profileData.fastHeatUpAktiv; // Brew Control Einstellungen anwenden brewByTimeEnabled = profileData.brewByTimeEnabled; brewByTimeTargetSeconds = profileData.brewByTimeTargetSeconds; // float preInfusionEnabled = profileData.preInfusionEnabled; preInfusionDurationSeconds = profileData.preInfusionDurationSeconds; // float preInfusionPauseSeconds = profileData.preInfusionPauseSeconds; // float // --- Werte ins EEPROM schreiben (an die korrekten Adressen) --- EEPROM.put(EEPROM_ADDR_SETPOINT_WASSER, profileData.setpointWasser); EEPROM.put(EEPROM_ADDR_SETPOINT_DAMPF, profileData.setpointDampf); EEPROM.put(EEPROM_ADDR_OFFSET_WASSER, profileData.offsetWasser); EEPROM.put(EEPROM_ADDR_OFFSET_DAMPF, profileData.offsetDampf); EEPROM.put(EEPROM_ADDR_KP_WASSER, profileData.kpWasser); EEPROM.put(EEPROM_ADDR_KI_WASSER, profileData.kiWasser); EEPROM.put(EEPROM_ADDR_KD_WASSER, profileData.kdWasser); EEPROM.put(EEPROM_ADDR_KP_DAMPF, profileData.kpDampf); EEPROM.put(EEPROM_ADDR_KI_DAMPF, profileData.kiDampf); EEPROM.put(EEPROM_ADDR_KD_DAMPF, profileData.kdDampf); EEPROM.put(EEPROM_ADDR_BOOST_WASSER_ACTIVE, profileData.boostWasserActive); EEPROM.put(EEPROM_ADDR_BOOST_DAMPF_ACTIVE, profileData.boostDampfActive); EEPROM.put(EEPROM_ADDR_PREVENTHEAT_WASSER, profileData.preventHeatAboveSetpointWasser); EEPROM.put(EEPROM_ADDR_PREVENTHEAT_DAMPF, profileData.preventHeatAboveSetpointDampf); EEPROM.put(EEPROM_ADDR_WINDOWSIZE_WASSER, profileData.windowSizeWasser); EEPROM.put(EEPROM_ADDR_WINDOWSIZE_DAMPF, profileData.windowSizeDampf); EEPROM.put(EEPROM_ADDR_MAX_TEMP_WASSER, profileData.maxTempWasser); EEPROM.put(EEPROM_ADDR_MAX_TEMP_DAMPF, profileData.maxTempDampf); EEPROM.put(EEPROM_ADDR_TUNING_STEP_WASSER, profileData.tuningStepWasser); EEPROM.put(EEPROM_ADDR_TUNING_NOISE_WASSER, profileData.tuningNoiseWasser); EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_WASSER, profileData.tuningStartValueWasser); EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_WASSER, profileData.tuningLookBackWasser); EEPROM.put(EEPROM_ADDR_TUNING_STEP_DAMPF, profileData.tuningStepDampf); EEPROM.put(EEPROM_ADDR_TUNING_NOISE_DAMPF, profileData.tuningNoiseDampf); EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_DAMPF, profileData.tuningStartValueDampf); EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_DAMPF, profileData.tuningLookBackDampf); EEPROM.put(EEPROM_ADDR_ECOMODE_MINUTES, profileData.ecoModeMinutes); EEPROM.put(EEPROM_ADDR_ECOMODE_TEMP_WASSER, profileData.ecoModeTempWasser); EEPROM.put(EEPROM_ADDR_ECOMODE_TEMP_DAMPF, profileData.ecoModeTempDampf); EEPROM.put(EEPROM_ADDR_DYNAMIC_ECO_MODE, profileData.dynamicEcoActive); EEPROM.put(EEPROM_ADDR_STEAM_DELAY, profileData.dampfVerzoegerung); EEPROM.put(EEPROM_ADDR_FASTHEATUP_DATA, profileData.fastHeatUpAktiv); // Brew Control Einstellungen ins EEPROM schreiben EEPROM.put(EEPROM_ADDR_BREWBYTIME_ENABLED, profileData.brewByTimeEnabled); EEPROM.put(EEPROM_ADDR_BREWBYTIME_SECONDS, profileData.brewByTimeTargetSeconds); // Schreibt float EEPROM.put(EEPROM_ADDR_PREINF_ENABLED, profileData.preInfusionEnabled); EEPROM.put(EEPROM_ADDR_PREINF_DUR_SEC, profileData.preInfusionDurationSeconds); // Schreibt float EEPROM.put(EEPROM_ADDR_PREINF_PAUSE_SEC, profileData.preInfusionPauseSeconds); // Schreibt float // Commit ins EEPROM nicht vergessen if (!EEPROM.commit()) { Serial.println(" ERROR: EEPROM commit failed while applying profile settings!"); } else { Serial.println(" EEPROM updated with loaded profile settings."); } // --- PID-Regler sofort aktualisieren --- pidWasser.SetTunings(KpWasser, KiWasser, KdWasser); pidDampf.SetTunings(KpDampf, KiDampf, KdDampf); pidWasser.SetOutputLimits(0, windowSizeWasser); // Wichtig! pidDampf.SetOutputLimits(0, windowSizeDampf); // Wichtig! Serial.println(" PID controllers reconfigured with loaded settings."); // Eco-Status zurücksetzen, da ein neues Profil geladen wurde ecoModeAktiv = false; ecoModeActivatedTime = 0; Serial.println(" Eco mode status reset."); } // Holt ALLE aktuellen relevanten Einstellungen aus den globalen Variablen und packt sie in eine Profilstruktur // Holt auch Brew Control Einstellungen TemperatureProfile getCurrentSettingsAsProfile() { TemperatureProfile currentProfile; // Ruft den Konstruktor auf, der profileVersion setzt // --- Globale Variablen in die Struktur kopieren --- // PID (/), ECO (/ECO), FastHeatUp (/Fast-Heat-Up) currentProfile.setpointWasser = SetpointWasser; currentProfile.setpointDampf = SetpointDampf; currentProfile.offsetWasser = OffsetWasser; currentProfile.offsetDampf = OffsetDampf; currentProfile.kpWasser = KpWasser; currentProfile.kiWasser = KiWasser; currentProfile.kdWasser = KdWasser; currentProfile.kpDampf = KpDampf; currentProfile.kiDampf = KiDampf; currentProfile.kdDampf = KdDampf; currentProfile.boostWasserActive = boostWasserActive; currentProfile.boostDampfActive = boostDampfActive; currentProfile.preventHeatAboveSetpointWasser = preventHeatAboveSetpointWasser; currentProfile.preventHeatAboveSetpointDampf = preventHeatAboveSetpointDampf; currentProfile.windowSizeWasser = windowSizeWasser; currentProfile.windowSizeDampf = windowSizeDampf; currentProfile.maxTempWasser = maxTempWasser; currentProfile.maxTempDampf = maxTempDampf; currentProfile.ecoModeMinutes = ecoModeMinutes; currentProfile.ecoModeTempWasser = ecoModeTempWasser; currentProfile.ecoModeTempDampf = ecoModeTempDampf; currentProfile.dynamicEcoActive = dynamicEcoActive; currentProfile.dampfVerzoegerung = dampfVerzoegerung; currentProfile.fastHeatUpAktiv = fastHeatUpAktiv; // PID Tuning (/PID-Tuning) currentProfile.tuningStepWasser = tuningStepWasser; currentProfile.tuningNoiseWasser = tuningNoiseWasser; currentProfile.tuningStartValueWasser = tuningStartValueWasser; currentProfile.tuningLookBackWasser = tuningLookBackWasser; currentProfile.tuningStepDampf = tuningStepDampf; currentProfile.tuningNoiseDampf = tuningNoiseDampf; currentProfile.tuningStartValueDampf = tuningStartValueDampf; currentProfile.tuningLookBackDampf = tuningLookBackDampf; // Brew Control Einstellungen in Struktur kopieren currentProfile.brewByTimeEnabled = brewByTimeEnabled; currentProfile.brewByTimeTargetSeconds = brewByTimeTargetSeconds; // float currentProfile.preInfusionEnabled = preInfusionEnabled; currentProfile.preInfusionDurationSeconds = preInfusionDurationSeconds; // float currentProfile.preInfusionPauseSeconds = preInfusionPauseSeconds; // float // Profilname bleibt vorerst leer, wird in handleSaveProfile gesetzt strncpy(currentProfile.profileName, "", sizeof(currentProfile.profileName)); return currentProfile; } // Löscht eine Profildatei (.prof) bool deleteProfile(const String& profileName) { String sanitizedName = sanitizeProfileName(profileName); // Verwende den Namen aus der Liste if (sanitizedName.length() == 0) return false; // Dateiendung .prof verwenden String filePath = "/Profile/" + sanitizedName + ".prof"; // *** GEÄNDERT ZU .prof *** Serial.printf("Deleting profile: %s\n", filePath.c_str()); if (LittleFS.exists(filePath)) { if (LittleFS.remove(filePath)) { Serial.println(" Profile deleted successfully."); return true; } else { Serial.println(" ERROR: Failed to delete profile file."); return false; } } else { Serial.println(" ERROR: Profile file not found, cannot delete."); return false; } } // *** ENDE PROFIL-HILFS-FUNKTIONEN *** // --- PROGMEM Chunks für die neue Profil-Seite --- static const char profilesPageHead[] PROGMEM = R"rawliteral( Profile)rawliteral"; static const char profilesPageStyle[] PROGMEM = R"rawliteral( )rawliteral"; // Schließt static const char profilesPageBodyStart[] PROGMEM = R"rawliteral(

Temperaturprofile

)rawliteral"; static const char profilesPageStatusPlaceholder[] PROGMEM = R"rawliteral({STATUS_MESSAGE_PLACEHOLDER})rawliteral"; static const char profilesPageListStart[] PROGMEM = R"rawliteral(

Gespeicherte Profile

    )rawliteral"; static const char profilesPageListItemStart[] PROGMEM = R"rawliteral(
  • )rawliteral"; // Nach static const char profilesPageListItemEnd[] PROGMEM = R"rawliteral(
  • )rawliteral"; // Ersetzt {PROFILE_NAME} mehrfach static const char profilesPageNoProfiles[] PROGMEM = R"rawliteral(
  • Keine Profile gespeichert.
  • )rawliteral"; static const char profilesPageListEnd[] PROGMEM = R"rawliteral(
)rawliteral"; static const char profilesPageSaveForm[] PROGMEM = R"rawliteral(

Aktuelle Einstellungen speichern

(Wird als neues Profil gespeichert oder überschreibt ein bestehendes mit gleichem Namen)

)rawliteral"; // ----------- Ende PROGMEM Chunks ----------- // Zeigt die Profil-Verwaltungsseite an void handleProfilesPage() { std::vector profiles = listProfiles(); // Hole die Liste der Profile server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, "text/html", ""); server.sendContent_P(profilesPageHead); server.sendContent_P(commonStyle); // Globale Styles server.sendContent_P(profilesPageStyle); // Seiten-spezifische Styles und server.sendContent_P(commonNav); // Navigation server.sendContent_P(profilesPageBodyStart); // und

// Statusmeldung einfügen (falls vorhanden) if (profileStatusMessage.length() > 0) { String statusDiv = "
" + profileStatusMessage + "
"; server.sendContent(statusDiv); profileStatusMessage = ""; // Nachricht entfernen, nachdem sie angezeigt wurde } // Profilliste generieren server.sendContent_P(profilesPageListStart); //
    if (profiles.empty()) { server.sendContent_P(profilesPageNoProfiles); } else { for (const String& name : profiles) { server.sendContent_P(profilesPageListItemStart); //
  • server.sendContent(name); // Profilnamen einfügen String itemEnd = FPSTR(profilesPageListItemEnd); itemEnd.replace("{PROFILE_NAME}", name); // Platzhalter im Template ersetzen server.sendContent(itemEnd); // Rest des
  • Elements yield(); // Bei vielen Profilen } } server.sendContent_P(profilesPageListEnd); //
// Speicherformular server.sendContent_P(profilesPageSaveForm); // Formular und server.sendContent(""); // Abschluss } // Lädt ein ausgewähltes Profil und wendet es an void handleLoadProfile() { if (server.hasArg("profileName")) { String profileToLoad = server.arg("profileName"); TemperatureProfile loadedData; if (loadProfile(profileToLoad, loadedData)) { applyProfileSettings(loadedData); // *** KORRIGIERT: Erfolgsmeldung hier *** profileStatusMessage = "Profil '" + String(loadedData.profileName) + "' erfolgreich geladen und angewendet."; } else { // *** KORRIGIERT: Fehlermeldung hier *** profileStatusMessage = "FEHLER: Profil '" + profileToLoad + "' konnte nicht geladen werden (inkompatibel, beschädigt oder nicht gefunden?)."; } } else { profileStatusMessage = "FEHLER: Kein Profilname zum Laden übermittelt."; } // Redirect zurück zur Profilseite, um die Nachricht anzuzeigen server.sendHeader("Location", "/Profile", true); // true für permanent = false server.send(303); } // Speichert die aktuellen Einstellungen unter einem neuen Namen void handleSaveProfile() { if (server.hasArg("profileName")) { String newName = server.arg("profileName"); String sanitizedName = sanitizeProfileName(newName); if (sanitizedName.length() == 0) { profileStatusMessage = "FEHLER: Ungültiger oder leerer Profilname angegeben."; } else { TemperatureProfile currentData = getCurrentSettingsAsProfile(); // Setze den vom Benutzer gewünschten (originalen) Namen in die Struktur strncpy(currentData.profileName, newName.c_str(), sizeof(currentData.profileName) - 1); currentData.profileName[sizeof(currentData.profileName) - 1] = '\0'; // Speichere unter dem bereinigten Dateinamen if (saveProfile(sanitizedName, currentData)) { profileStatusMessage = "Aktuelle Einstellungen erfolgreich als Profil '" + String(currentData.profileName) + "' gespeichert."; } else { profileStatusMessage = "FEHLER: Einstellungen konnten nicht als Profil '" + String(currentData.profileName) + "' gespeichert werden."; } } } else { profileStatusMessage = "FEHLER: Kein Profilname zum Speichern übermittelt."; } // Redirect zurück zur Profilseite server.sendHeader("Location", "/Profile", true); server.send(303); } // Löscht ein ausgewähltes Profil void handleDeleteProfile() { if (server.hasArg("profileName")) { String profileToDelete = server.arg("profileName"); if (deleteProfile(profileToDelete)) { profileStatusMessage = "Profil '" + profileToDelete + "' erfolgreich entfernt."; } else { profileStatusMessage = "FEHLER: Profil '" + profileToDelete + "' konnte nicht entfernt werden."; } } else { profileStatusMessage = "FEHLER: Kein Profilname zum entfernen übermittelt."; } // Redirect zurück zur Profilseite server.sendHeader("Location", "/Profile", true); server.send(303); } // *** ENDE NEUE WEB UI HANDLER *** /************************************************************************************ * Handler für die Dateimanager-Seite (/Dateimanager) - Heap-Optimiert * - Plattformabhängige FS-API berücksichtigt * - Unterstützt jetzt Verzeichnisnavigation ************************************************************************************/ // --- PROGMEM Chunks für handleFileManager (angepasst) --- static const char fileMgrHead[] PROGMEM = R"rawliteral( Dateimanager )rawliteral"; // Ende des Head+Style Blocks, vor // Platzhalter für Pfad und Fehlermeldung static const char fileMgrTitle[] PROGMEM = R"rawliteral(

Dateimanager

Aktueller Pfad: {CURRENT_PATH}

{STATUS_MESSAGE})rawliteral"; // List Start static const char fileMgrListStart[] PROGMEM = "
    "; // List Item: Zurück ( .. ) static const char fileMgrListItemUp[] PROGMEM = R"rawliteral(
  • [ .. Zurück]
  • )rawliteral"; // List Item: Verzeichnis static const char fileMgrListItemDir[] PROGMEM = R"rawliteral(
  • [ {DIR_NAME} ]
  • )rawliteral"; // DIR_PATH ist der volle Pfad, DIR_NAME nur der Name // List Item: Datei (wie bisher, aber mit vollem Pfad bei name=...) static const char fileMgrListItemFileStart[] PROGMEM = R"rawliteral(
  • )rawliteral"; // Start + Span für Name static const char fileMgrListItemFileMid1[] PROGMEM = R"rawliteral()rawliteral"; // Nach Name, vor Größe static const char fileMgrListItemFileMid2[] PROGMEM = R"rawliteral( Bytes
  • )rawliteral"; // Nach Delete-Pfad, bis Ende
  • // Meldungen für leere Liste oder Fehler static const char fileMgrNoFiles[] PROGMEM = R"rawliteral(
  • (Verzeichnis ist leer)
  • )rawliteral"; static const char fileMgrListError[] PROGMEM = R"rawliteral(
  • Fehler beim Auflisten des Verzeichnisses.
  • )rawliteral"; static const char fileMgrOpenError[] PROGMEM = R"rawliteral(
  • Fehler beim Öffnen des Verzeichnisses: Pfad nicht gefunden oder keine Berechtigung.
  • )rawliteral"; // Ende der Liste + Trennlinie static const char fileMgrListEnd[] PROGMEM = R"rawliteral(

)rawliteral"; // Upload Formular (mit verstecktem Pfad) static const char fileMgrUploadForm[] PROGMEM = R"rawliteral(

Datei hochladen (in aktuellen Pfad)


)rawliteral"; // Verzeichnis erstellen Formular (NEU) static const char fileMgrCreateDirForm[] PROGMEM = R"rawliteral(

Verzeichnis erstellen (im aktuellen Pfad)

)rawliteral"; // Dateisystem Info (wie bisher) 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"; // Body Ende static const char fileMgrBodyEnd[] PROGMEM = R"rawliteral()rawliteral"; // Globale Variable für Statusmeldungen im Dateimanager String fileManagerStatusMessage = ""; String fileManagerStatusClass = ""; // "status-success" oder "status-error" // Hilfsfunktion zum rekursiven Löschen von Verzeichnissen bool removeDirectoryRecursive(const String& path) { Serial.printf("Attempting to recursively remove directory: %s\n", path.c_str()); bool success = true; #ifdef ESP32 File root = LittleFS.open(path); if (!root) { Serial.printf(" ERROR: Failed to open directory %s for removal.\n", path.c_str()); return false; } if (!root.isDirectory()) { Serial.printf(" ERROR: %s is not a directory.\n", path.c_str()); root.close(); return false; // Oder sollte eine Datei einfach gelöscht werden? Hier nur Verzeichnisse. } File file = root.openNextFile(); while (file) { String entryPath = path + "/" + file.name(); // Korrekten Pfad bilden // Korrektur für ESP32: file.name() liefert nur den Namen, nicht den vollen Pfad relativ zum FS-Root. // Wir brauchen den vollen Pfad für die Rekursion und das Löschen. entryPath = file.path(); // Besser: ESP32 gibt vollen Pfad zurück. if (file.isDirectory()) { Serial.printf(" Recursing into directory: %s\n", entryPath.c_str()); if (!removeDirectoryRecursive(entryPath)) { Serial.printf(" ERROR: Failed to remove subdirectory %s\n", entryPath.c_str()); success = false; // Fehler beim Löschen des Unterverzeichnisses // Man könnte hier entscheiden abzubrechen oder weiterzumachen } } else { Serial.printf(" Deleting file: %s\n", entryPath.c_str()); if (!LittleFS.remove(entryPath)) { Serial.printf(" ERROR: Failed to remove file %s\n", entryPath.c_str()); success = false; // Fehler beim Löschen der Datei } } file.close(); // Wichtig: Datei schließen file = root.openNextFile(); yield(); // Luft holen } root.close(); // Root des zu löschenden Verzeichnisses schließen // Nachdem der Inhalt (hoffentlich) weg ist, das leere Verzeichnis löschen if (success) { // Nur versuchen, wenn bisher alles geklappt hat Serial.printf(" Attempting to remove now (hopefully) empty directory: %s\n", path.c_str()); if (!LittleFS.rmdir(path)) { // rmdir für Verzeichnisse auf ESP32 Serial.printf(" ERROR: Failed to remove directory %s itself.\n", path.c_str()); success = false; } else { Serial.printf(" Successfully removed directory %s.\n", path.c_str()); } } else { Serial.printf(" Skipping removal of directory %s due to previous errors.\n", path.c_str()); } #else // ESP8266 Dir dir = LittleFS.openDir(path); while (dir.next()) { String entryName = dir.fileName(); String entryPath = path + "/" + entryName; if (entryName == "." || entryName == "..") continue; // Ignorieren if (dir.isDirectory()) { Serial.printf(" Recursing into directory: %s\n", entryPath.c_str()); if (!removeDirectoryRecursive(entryPath)) { Serial.printf(" ERROR: Failed to remove subdirectory %s\n", entryPath.c_str()); success = false; } } else { Serial.printf(" Deleting file: %s\n", entryPath.c_str()); if (!LittleFS.remove(entryPath)) { Serial.printf(" ERROR: Failed to remove file %s\n", entryPath.c_str()); success = false; } } yield(); // Luft holen } // dir schließt automatisch if (success) { Serial.printf(" Attempting to remove now (hopefully) empty directory: %s\n", path.c_str()); if (!LittleFS.rmdir(path)) { // rmdir auch für ESP8266 Serial.printf(" ERROR: Failed to remove directory %s itself.\n", path.c_str()); success = false; } else { Serial.printf(" Successfully removed directory %s.\n", path.c_str()); } } else { Serial.printf(" Skipping removal of directory %s due to previous errors.\n", path.c_str()); } #endif return success; } /************************************************************************************ * Handler für die Dateimanager-Seite (/Dateimanager) - Heap-Optimiert * - Plattformabhängige FS-API berücksichtigt * - Unterstützt jetzt Verzeichnisnavigation ************************************************************************************/ void handleFileManager() { Serial.println("Anfrage für /Dateimanager"); char buffer[20]; // Puffer für Zahlen (Größe, FS Info) - HIER deklariert für die ganze Funktion // Aktuellen Pfad aus URL holen, Standard ist "/" String currentPath = server.hasArg("path") ? server.arg("path") : "/"; // Pfad bereinigen und validieren if (!currentPath.startsWith("/")) { currentPath = "/" + currentPath; } // Einfache Normalisierung (doppelte Slashes entfernen) currentPath.replace("//", "/"); // Entferne abschließenden Slash, außer bei Root "/" if (currentPath.length() > 1 && currentPath.endsWith("/")) { currentPath.remove(currentPath.length() - 1); } Serial.printf(" Aktueller Pfad: %s\n", currentPath.c_str()); // --- Header senden --- server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, F("text/html; charset=utf-8"), ""); // UTF-8 für Umlaute // --- Header und Style senden --- server.sendContent_P(fileMgrHead); // Enthält , ,