/*- * Copyright (c) 2025 Thomas Müller * All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions * are met: * 1. Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ /************************************************************************************ * 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 ************************************************************************************/ /************************************************************************************ * WAAGEN-KONFIGURATION ************************************************************************************/ #define SCALE_I2C 1 #define SCALE_ESPNOW 2 /************************************************************************************ * NACH BEDARF ANPASSEN - HARDWARE-SPEZIFIKATIONEN ************************************************************************************/ // Die Waage auskommentieren, welche NICHT verwendet wird! #define SCALE_TYPE SCALE_ESPNOW // #define SCALE_TYPE SCALE_I2C // Nachfolgende Zeile auskommentieren, falls die Steuerung ohne Display verwendet wird! #define ENABLE_DISPLAY /************************************************************************************ * Vorwärtsdeklarationen benutzerdefinierter Strukturen * Erforderlich, damit die Arduino-Präprozessor-Prototypen gültige Typen sehen ************************************************************************************/ struct WiFiConfig; struct TemperatureProfile; struct ShotStats; /************************************************************************************ * Includes & Bibliotheken ************************************************************************************/ #include #ifdef ENABLE_DISPLAY #include #include #endif // ENABLE_DISPLAY #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 #include // Plattformspezifische Includes // Für asynchronen Webserver #ifdef ESP32 #include // ESP32 WiFi Core #include // TCP Stack für AsyncWebServer #include // Asynchroner Webserver #include // ESP32 mDNS Implementation #include // Für Firmware-Updates #include // Für ADC-Kalibrierung #define LED_BUILTIN 2 // Standard LED Pin für viele ESP32 Boards (optional, falls genutzt) // --- KONDITIONALE WAAGEN-INCLUDES --- #if (SCALE_TYPE == SCALE_I2C) #include "UNIT_SCALES.h" // Für I2C-Waage #elif (SCALE_TYPE == SCALE_ESPNOW) #include // Für ESP-NOW-Waage #endif // --- ENDE KONDITIONALE WAAGEN-INCLUDES --- #else // ESP8266 #include #include // TCP Stack für AsyncWebServer #include // Asynchroner Webserver #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 // Ende des #ifdef ESP32 / #else Blocks /************************************************************************************ * Webserver und zugehörige Objekte ************************************************************************************/ // Gemeinsamer asynchroner Webserver für ESP32 und ESP8266 AsyncWebServer asyncServer(80); AsyncWebSocket dashboardWs("/ws-dashboard"); const unsigned long DASHBOARD_WS_INTERVAL_IDLE_MS = 1000; const unsigned long DASHBOARD_WS_INTERVAL_ACTIVE_MS = 250; const unsigned long DASHBOARD_WS_SHOT_END_GRACE_MS = 500; unsigned long lastDashboardWsPushMs = 0; unsigned long scheduledRestart = 0; void scheduleRestart(uint32_t delayMs) { scheduledRestart = millis() + delayMs; } /************************************************************************************ * 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 ANALOG_NTC_PIN 36 // Analoger Eingang für NTC (ADC1_CH0 - VP) - Nur ADC1 Pins (GPIO 32-39) sind sicher im WiFi Betrieb! #define IIC_5V_SDA 17 // SDA-Leitung für OLED (GPIO17) #define IIC_5V_SCK 16 // SCK-Leitung für OLED (GPIO16) // Piezo für ESP #define PIEZO_PIN 33 // Piezo-Buzzer an GPIO35 // 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 18 // Serial Clock (GPIO23) - Standard HSPI SCK #define MAX6675_CS 19 // Chip Select (GPIO19) - Frei wählbar #define MAX6675_SO 23 // 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 ANALOG_NTC_PIN A0 // Analoger Eingang für den NTC-Sensor // ENABLE_DISPLAY #ifdef ENABLE_DISPLAY #define IIC_5V_SDA D3 // SDA-Leitung für OLED #define IIC_5V_SCK D4 // SCK-Leitung für OLED #endif // 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); /************************************************************************************ * Ultraschallsensor (Wasserstand) - I2C Variante ************************************************************************************/ // --- I2C Adresse Ultraschallsensor --- // !!! WICHTIG: Adresse prüfen und ggf. anpassen! 0x57 ist eine häufige, aber nicht garantierte Adresse. !!! #define ULTRASONIC_I2C_ADDR 0x57 // --- Globale Variablen für Ultraschallsensor --- bool ultrasonicEnabled = false; // Ist die Messung per Ultraschall aktiviert? bool ultrasonicSensorConnected = false; // Wird automatisch beim Start geprüft float tankHeightCm = 20.0; // Höhe des Tanks in cm (Beispielwert, konfigurierbar) float waterLevelCm = -1.0; // Berechneter Wasserstand in cm (-1 = ungültig/nicht gemessen) float minLevelWarnCm = 5.0; // Minimaler Wasserstand für Warnung (Beispielwert, konfigurierbar) unsigned long lastUltrasonicReadTime = 0; // Zeitpunkt der letzten Messung const unsigned long ULTRASONIC_READ_INTERVAL = 2000; // Intervall für Messung in ms (z.B. alle 2 Sekunden) const unsigned int ULTRASONIC_MIN_DIST_MM = 180; // 18 cm Mindestabstand laut Datenblatt const unsigned int ULTRASONIC_MAX_DIST_MM = 6000; // 600 cm Maximalabstand laut Datenblatt /************************************************************************************ * Display-Objekt (Adafruit SH1106G) ************************************************************************************/ #ifdef ENABLE_DISPLAY /************************************************************************************ * Display-Objekt (Adafruit SH1106G) ************************************************************************************/ // Dieser Block muss vorhanden sein, wenn Display aktiviert! Adafruit_SH1106G display = Adafruit_SH1106G(128, 64, &Wire); #endif // ENABLE_DISPLAY /************************************************************************************ * Firmware-Informationen ************************************************************************************/ String version = "3.7.2"; String versionHersteller = "Thomas Müller"; String versionHerstellerMail = "thomas@mueller.black"; String versionHerstellerWeb = "https://raw-designs.de/ | " "https://mueller.black/"; String versionHerstellerGitHub = "https://github.com/thomas-michael-mueller/Dual-PID-Controller"; // 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, 88); // 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 // Globale Variable für ADC-Kalibrierungsdaten #ifdef ESP32 static esp_adc_cal_characteristics_t adc_chars; #endif /************************************************************************************ * Waagen-Instanz & Variablen (Konditional) ************************************************************************************/ #ifdef ESP32 // --- Gemeinsame Waagen-Variablen --- bool scaleConnected = false; // Zeigt an, ob eine Waage verbunden ist (egal welcher Typ) bool scaleModeActive = false; // Für Anzeige der Waage im Display volatile float currentWeightReading = 0.0f; // Aktueller Messwert der Waage volatile float brewStartWeight = 0.0f; const float WEIGHT_CHANGE_THRESHOLD = 0.1f; // Schwelle in g für die erste Gewichtsänderung unsigned long firstWeightChangeTime = 0; // Zeitpunkt der ersten Gewichtsänderung bool firstWeightChangeDetected = false; // Flag, ob bereits eine Änderung erkannt wurde unsigned long lastShotFirstWeightChangeTime = 0; // Gespeicherter Zeitpunkt für den letzten Shot #if (SCALE_TYPE == SCALE_I2C) // --- I2C-spezifische Variablen --- UNIT_SCALES scales; // Instanz für die I2C-Waage // Button-Logik für die I2C-Waage unsigned long scaleButtonPressStartTime = 0; bool scaleButtonIsCurrentlyPressed = false; uint8_t lastScaleButtonRawState = 1; const unsigned long longPressThresholdScale = 1000; const unsigned long debounceDelayScale = 50; unsigned long lastScaleButtonDebounceTime = 0; uint8_t currentScaleButtonDebouncedState = 1; uint8_t lastScaleButtonDebouncedState = 1; // Verzögertes Tarieren für die I2C-Waage bool tareScaleAfterDelay = false; unsigned long tareScaleDelayStartTime = 0; const unsigned long TARE_SCALE_DELAY_DURATION = 1000; // Nachlaufzeit nach dem Tarieren (nicht-blockierend) bool tareScaleSettling = false; unsigned long tareScaleSettlingStartTime = 0; const unsigned long TARE_SCALE_SETTLING_DURATION = 50; #elif (SCALE_TYPE == SCALE_ESPNOW) // --- ESP-NOW-spezifische Variablen --- // Datenstruktur für empfangene Nachrichten (muss mit dem Sender übereinstimmen) typedef struct struct_espnow_scale_message { float weight_g; uint8_t status_flags; uint8_t battery_percentage; } struct_espnow_scale_message; // Flags für die Status-Nachrichten #define ESPNOW_SCALE_FLAG_JUST_TARED (1 << 0) #define ESPNOW_SCALE_FLAG_TOGGLE_MODE (1 << 1) #define ESPNOW_SCALE_FLAG_AWOKE (1 << 2) // Timeout-Handling unsigned long lastScaleMessageTime = 0; const unsigned long SCALE_TIMEOUT = 5000; // 5 Sekunden Timeout #endif // Ende SCALE_TYPE Check #endif // Ende ESP32 Check /************************************************************************************ * Temperatur-Profil-Struktur (ERWEITERT) * - Speichert nahezu alle konfigurierbaren Einstellungen pro Profil ************************************************************************************/ // VERSIONSNUMMER const uint16_t CURRENT_PROFILE_VERSION = 3; // 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 bool steamDelayOverrideBySwitch; // --- 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 bool brewByWeightEnabled; float brewByWeightTargetGrams; float brewByWeightOffsetGrams; // Offset zum früheren Stoppen // --- Systemtöne / PIEZO --- bool piezoEnabled; // Status der Piezo-Töne }; /************************************************************************************ * 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; bool preInfusionEnabled = false; float preInfusionDurationSeconds = 5.0f; float preInfusionPauseSeconds = 3.0f; bool brewByWeightEnabled = false; float brewByWeightTargetGrams = 36.0f; float brewByWeightOffsetGrams = 0.0f; // Kurz-Flush aus dem Dashboard (ESP32) #ifdef ESP32 bool flushActive = false; unsigned long flushEndTime = 0; const unsigned long FLUSH_DURATION_MS = 3000; #endif // Die Variable "currentWeightReading" ist jetzt im konditionalen Waagen-Block definiert. // 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; const bool defaultBrewByWeightEnabled = false; const float defaultBrewByWeightTargetGrams = 36.0f; const float defaultBrewByWeightOffsetGrams = 0.0f; // Standard kein Offset /************************************************************************************ * 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; const bool defaultSteamDelayOverrideBySwitchEnabled = false; bool steamDelayOverrideBySwitchEnabled = false; const bool defaultEcoInfoOnDisplay = false; // MagicValue zum Erkennen bereits gespeicherter EEPROM-Daten const char storageMagicValue[5] = "MGVE"; /************************************************************************************ * EEPROM Adressen für das Speichern/Laden von Werten * KORRIGIERT: Adressen ab Hostname / Windowsize angepasst (Overlap entfernt) * Kommentare für Endadressen überprüft/korrigiert ************************************************************************************/ const int EEPROM_SIZE = 1024; // Gesamtgröße des verwendeten EEPROM-Speichers // --- Allgemeine Einstellungen --- const int EEPROM_ADDR_MAGICVALUE = 0; // 5 Bytes (char[5]) -> Ende 4 const int EEPROM_ADDR_SETPOINT_DAMPF = 5; // 8 Bytes (double) -> Ende 12 const int EEPROM_ADDR_SETPOINT_WASSER = 13; // 8 Bytes -> Ende 20 const int EEPROM_ADDR_OFFSET_DAMPF = 21; // 8 Bytes -> Ende 28 const int EEPROM_ADDR_OFFSET_WASSER = 29; // 8 Bytes -> Ende 36 const int EEPROM_ADDR_KP_DAMPF = 37; // 8 Bytes -> Ende 44 const int EEPROM_ADDR_KI_DAMPF = 45; // 8 Bytes -> Ende 52 const int EEPROM_ADDR_KD_DAMPF = 53; // 8 Bytes -> Ende 60 const int EEPROM_ADDR_KP_WASSER = 61; // 8 Bytes -> Ende 68 const int EEPROM_ADDR_KI_WASSER = 69; // 8 Bytes -> Ende 76 const int EEPROM_ADDR_KD_WASSER = 77; // 8 Bytes -> Ende 84 const int EEPROM_ADDR_ECOMODE_MINUTES = 85; // 4 Bytes (int) -> Ende 88 const int EEPROM_ADDR_ECOMODE_TEMP_WASSER = 89; // 4 Bytes -> Ende 92 const int EEPROM_ADDR_ECOMODE_TEMP_DAMPF = 93; // 4 Bytes -> Ende 96 const int EEPROM_ADDR_INFO_HERSTELLER = 97; // 50 Bytes (char[50]) -> Ende 146 const int EEPROM_ADDR_INFO_MODELL = 147; // 50 Bytes -> Ende 196 const int EEPROM_ADDR_INFO_ZUSATZ = 197; // 50 Bytes -> Ende 246 const int EEPROM_ADDR_FASTHEATUP_DATA = 247; // 1 Byte (bool) -> Ende 247 const int EEPROM_ADDR_RUNTIME = 248; // 4 Bytes (unsigned long) -> Ende 251 const int EEPROM_ADDR_SHOTCOUNTER = 252; // 4 Bytes (unsigned long) -> Ende 255 const int EEPROM_ADDR_DYNAMIC_ECO_MODE = 256; // 1 Byte (bool) -> Ende 256 // --- AutoTune Parameter Wasser --- (Start bei 357 -> Lücke vorhanden) const int EEPROM_ADDR_TUNING_STEP_WASSER = 357; // 8 Bytes (double) -> Ende 364 const int EEPROM_ADDR_TUNING_NOISE_WASSER = 365; // 8 Bytes (double) -> Ende 372 const int EEPROM_ADDR_TUNING_STARTVALUE_WASSER = 373; // 8 Bytes (double) -> Ende 380 const int EEPROM_ADDR_TUNING_LOOKBACK_WASSER = 381; // 4 Bytes (unsigned int) -> Ende 384 // --- WiFi Konfiguration (Feldweise) --- const int EEPROM_ADDR_WIFI_CONFIG_MAGIC = 385; // 5 Bytes (char[5]) -> Ende 389 const int EEPROM_ADDR_WIFI_SSID = 390; // 32 Bytes -> Ende 421 const int EEPROM_ADDR_WIFI_PASSWORD = 422; // 64 Bytes -> Ende 485 const int EEPROM_ADDR_WIFI_USE_STATIC = 486; // 1 Byte -> Ende 486 const int EEPROM_ADDR_WIFI_STATIC_IP = 487; // 4 Bytes -> Ende 490 const int EEPROM_ADDR_WIFI_GATEWAY = 491; // 4 Bytes -> Ende 494 const int EEPROM_ADDR_WIFI_SUBNET = 495; // 4 Bytes -> Ende 498 const int EEPROM_ADDR_WIFI_DNS = 499; // 4 Bytes -> Ende 502 // --- Sonstige Einstellungen (Lücke vorhanden) --- 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) -> Ende 538 const int EEPROM_ADDR_TUNING_NOISE_DAMPF = 539; // 8 Bytes (double) -> Ende 546 const int EEPROM_ADDR_TUNING_STARTVALUE_DAMPF = 547; // 8 Bytes (double) -> Ende 554 const int EEPROM_ADDR_TUNING_LOOKBACK_DAMPF = 555; // 4 Bytes (unsigned int) -> Ende 558 // --- Adressen für Übertemperatursicherung --- 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 // Zusätzliche EEPROM-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 582 // Kommentar korrigiert const int EEPROM_ADDR_HOSTNAME = 583; // 33 Bytes (char[33]) -> Ende 615 // --- KORRIGIERTE / VERSCHOBENE ADRESSEN (Start nach Hostname) --- const int EEPROM_ADDR_WINDOWSIZE_WASSER = 616; // WAR 614 -> 4 Bytes (unsigned long) -> Ende 619 const int EEPROM_ADDR_WINDOWSIZE_DAMPF = 620; // WAR 618 -> 4 Bytes (unsigned long) -> Ende 623 const int EEPROM_ADDR_BOOST_WASSER_ACTIVE = 624; // WAR 612 -> 1 Byte (bool) -> Ende 624 const int EEPROM_ADDR_BOOST_DAMPF_ACTIVE = 625; // WAR 613 -> 1 Byte (bool) -> Ende 625 const int EEPROM_ADDR_PREVENTHEAT_WASSER = 626; // WAR 622 -> 1 Byte (bool) -> Ende 626 const int EEPROM_ADDR_PREVENTHEAT_DAMPF = 627; // WAR 623 -> 1 Byte (bool) -> Ende 627 // --- BREW CONTROL (Start nach Prevent Heat) --- const int EEPROM_ADDR_BREWBYTIME_ENABLED = 628; // WAR 624 -> 1 Byte (bool) -> Ende 628 // ACHTUNG: brewByTimeTargetSeconds ist float (4 Bytes), nicht unsigned long const int EEPROM_ADDR_BREWBYTIME_SECONDS = 629; // WAR 625 -> 4 Bytes (float) -> Ende 632 const int EEPROM_ADDR_PREINF_ENABLED = 633; // WAR 629 -> 1 Byte (bool) -> Ende 633 // ACHTUNG: preInfusionDurationSeconds/preInfusionPauseSeconds sind float (4 Bytes) const int EEPROM_ADDR_PREINF_DUR_SEC = 634; // WAR 630 -> 4 Bytes (float) -> Ende 637 const int EEPROM_ADDR_PREINF_PAUSE_SEC = 638; // WAR 634 -> 4 Bytes (float) -> Ende 641 const int EEPROM_ADDR_BREWBYWEIGHT_ENABLED = 642; // WAR 638 -> bool (1 byte) -> Ende 642 const int EEPROM_ADDR_BREWBYWEIGHT_TARGET = 643; // WAR 639 -> float (4 bytes) -> Ende 646 const int EEPROM_ADDR_BREWBYWEIGHT_OFFSET = 647; // WAR 643 -> float (4 bytes) -> Ende 650 // --- PIEZO (Start nach Brew By Weight) --- const int EEPROM_ADDR_PIEZO_ENABLED = 651; // WAR 647 -> 1 Byte (bool) -> Ende 651 // --- Dampfverzögerung über ECO-Einstellungen temporär überspringen const int EEPROM_ADDR_STEAM_DELAY_OVERRIDE_SWITCH = 652; // 1 Byte (bool) -> Ende 652 const int EEPROM_ADDR_ECO_INFO_ON_DISPLAY = 653; // 1 Byte (bool) -> Ende 653 // Nächste freie Adresse: 654 (Innerhalb EEPROM_SIZE=1024) /************************************************************************************ * 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; bool ecoInfoOnDisplay = 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; // Globale Variable für temporäre Dampfverzögerungs-Überbrückung bool steamDelayOverridden = false; 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; unsigned long lastShotStartTime = 0; // Startzeit des letzten Shots bool shotActive = false; unsigned long lastShotDurationMillis = 0; // Speichert die Dauer des letzten Shots in ms bool manualSwitchReleasedAfterAutoStop = true; // Startwert: true, bereit für manuellen Start /************************************************************************************ * 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; // VARIABLEN ZUM VERZÖGERTEN SPEICHERN VON SHOTS, NACH DESSEN BEZUG bool shotNeedsToBeSaved = false; unsigned long savedShotDuration = 0; // --- Zusätzliche Variablen für das Speichern des Gewichts --- bool savedShotWasByWeight = false; // War der zu speichernde Shot ein "By-Weight"-Shot? float savedShotFinalWeight = 0.0f; // Das finale Gewicht des zu speichernden Shots /************************************************************************************ * 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 /************************************************************************************ * Zustandsmaschine für die Anzeige nach dem Shot ************************************************************************************/ #define POST_SHOT_IDLE 0 // Standardzustand, nichts zu tun #define POST_SHOT_SHOW_DURATION 1 // Zustand: Zeige Bezugsdauer an #define POST_SHOT_SHOW_MAINTENANCE 2 // Zustand: Zeige Wartungsmeldung an #define POST_SHOT_SHOW_WEIGHT_RESULT 3 // Zustand: Zeige Gewicht und Dauer an (für By-Weight) uint8_t postShotDisplayState = POST_SHOT_IDLE; // Unsere neue Variable, die den aktuellen Zustand speichert float lastShotFinalNetWeight = 0.0f; // Globale Variable für das finale Nettogewicht /************************************************************************************ * Systemtöne / Piezo ************************************************************************************/ bool piezoEnabled = true; // Standardmäßig aktiviert const bool defaultPiezoEnabled = true; /************************************************************************************ * Verzögerungen für Anzeigen ************************************************************************************/ int delayInit1 = 2000; // 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 String uploadStatusMessage = ""; // Statusmeldung für Upload-Ergebnisse String uploadStatusClass = ""; // "status-success" oder "status-error" /************************************************************************************ * 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) ************************************************************************************/ #ifdef ENABLE_DISPLAY static const unsigned char PROGMEM wifiSymbol[] = { 0x1f, 0xc0, 0x20, 0x20, 0x4f, 0x90, 0x90, 0x48, 0x27, 0x20, 0x08, 0x80, 0x02, 0x00 }; #endif // ENABLE_DISPLAY /************************************************************************************ * Gemeinsame CSS-Styles (PROGMEM) - Unverändert ************************************************************************************/ static const char commonStyle[] PROGMEM = R"rawliteral( )rawliteral"; /************************************************************************************ * Gemeinsame Navigation (PROGMEM) ************************************************************************************/ #ifdef ESP32 static const char commonNav[] PROGMEM = R"rawliteral( )rawliteral"; #else static const char commonNav[] PROGMEM = R"rawliteral( )rawliteral"; #endif /************************************************************************************ * WiFi-Konfigurationsstrukturen und Funktionen ************************************************************************************/ 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 (Feld für Feld, IPs manuell) // Gibt true zurück, wenn eine gültige Konfiguration (Magic Value OK und SSID nicht leer) geladen wurde bool loadWiFiConfig(WiFiConfig& config) { // config wird per Referenz übergeben char magic[5] = { 0 }; EEPROM.get(EEPROM_ADDR_WIFI_CONFIG_MAGIC, magic); // Lade Hostname EEPROM.get(EEPROM_ADDR_HOSTNAME, hostname); hostname[sizeof(hostname) - 1] = '\0'; if (strlen(hostname) == 0) { strncpy(hostname, defaultHostname.c_str(), sizeof(hostname) - 1); hostname[sizeof(hostname) - 1] = '\0'; EEPROM.put(EEPROM_ADDR_HOSTNAME, hostname); EEPROM.commit(); } // Prüfe Magic Value if (strncmp(magic, storageMagicValue, 4) != 0) { // Serial.println("DEBUG Boot - Magic Value MISMATCH! Setze WiFi Defaults."); // Debug entfernt strcpy(config.ssid, ""); strcpy(config.password, ""); config.useStaticIP = false; config.staticIP = IPAddress(192, 168, 4, 1); // Default AP Mode IP config.gateway = IPAddress(192, 168, 4, 1); // Default AP Mode GW config.subnet = IPAddress(255, 255, 255, 0); config.dns = IPAddress(8, 8, 8, 8); // Default Public DNS return false; } // Magic Value war korrekt, fahre mit dem Lesen fort // Serial.println("DEBUG Boot - Magic Value OK."); // Debug entfernt byte ipBytes[4]; // Puffer für IP-Bytes // Laden der einfachen Felder EEPROM.get(EEPROM_ADDR_WIFI_SSID, config.ssid); EEPROM.get(EEPROM_ADDR_WIFI_PASSWORD, config.password); EEPROM.get(EEPROM_ADDR_WIFI_USE_STATIC, config.useStaticIP); // --- MANUELLES LESEN FÜR IPAddress FELDER --- for(int i=0; i<4; i++) ipBytes[i] = EEPROM.read(EEPROM_ADDR_WIFI_STATIC_IP + i); config.staticIP = IPAddress(ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3]); for(int i=0; i<4; i++) ipBytes[i] = EEPROM.read(EEPROM_ADDR_WIFI_GATEWAY + i); config.gateway = IPAddress(ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3]); for(int i=0; i<4; i++) ipBytes[i] = EEPROM.read(EEPROM_ADDR_WIFI_SUBNET + i); config.subnet = IPAddress(ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3]); for(int i=0; i<4; i++) ipBytes[i] = EEPROM.read(EEPROM_ADDR_WIFI_DNS + i); config.dns = IPAddress(ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3]); // --- ENDE MANUELLES LESEN --- // Nullterminierung sicherstellen config.ssid[sizeof(config.ssid) - 1] = '\0'; config.password[sizeof(config.password) - 1] = '\0'; // Normale Log-Ausgabe der geladenen Werte // Serial.println("WiFi Konfiguration geladen:"); // Serial.print(" SSID: "); Serial.println(config.ssid); // Serial.print(" Static IP Mode: "); Serial.println(config.useStaticIP); // if(config.useStaticIP) { // Serial.print(" Static IP: "); Serial.println(config.staticIP); // Serial.print(" Gateway: "); Serial.println(config.gateway); // Serial.print(" Subnet: "); Serial.println(config.subnet); // Serial.print(" DNS: "); Serial.println(config.dns); // } return (strlen(config.ssid) > 0); } // Schreibt die WLAN-Konfiguration ins EEPROM (Feld für Feld, IPs manuell byte-weise) void saveWiFiConfig(const WiFiConfig& config) { // Serial.println("Schreibe WiFi Konfiguration Feld für Feld (manuelles Schreiben für IPs)..."); // Debug entfernt EEPROM.put(EEPROM_ADDR_WIFI_CONFIG_MAGIC, storageMagicValue); // Einfache Felder mit EEPROM.put EEPROM.put(EEPROM_ADDR_WIFI_SSID, config.ssid); EEPROM.put(EEPROM_ADDR_WIFI_PASSWORD, config.password); EEPROM.put(EEPROM_ADDR_WIFI_USE_STATIC, config.useStaticIP); // --- IPAddress Felder manuell mit EEPROM.write() schreiben --- // Serial.print(" Schreibe Static IP bytes: "); Serial.println(config.staticIP); // Debug entfernt for(int i=0; i<4; i++) { EEPROM.write(EEPROM_ADDR_WIFI_STATIC_IP + i, config.staticIP[i]); } // Serial.print(" Schreibe Gateway bytes: "); Serial.println(config.gateway); // Debug entfernt for(int i=0; i<4; i++) { EEPROM.write(EEPROM_ADDR_WIFI_GATEWAY + i, config.gateway[i]); } // Serial.print(" Schreibe Subnet bytes: "); Serial.println(config.subnet); // Debug entfernt for(int i=0; i<4; i++) { EEPROM.write(EEPROM_ADDR_WIFI_SUBNET + i, config.subnet[i]); } // Serial.print(" Schreibe DNS bytes: "); Serial.println(config.dns); // Debug entfernt for(int i=0; i<4; i++) { EEPROM.write(EEPROM_ADDR_WIFI_DNS + i, config.dns[i]); } // --- ENDE Manuelles Schreiben --- // Commit der Änderungen 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 NACH DNS Value bis Subnet Value static const char wifiPageChunk9_subnet[] PROGMEM = R"rawliteral('>, endet mit value=' // Teil 10 (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(AsyncWebServerRequest *request) { WiFiConfig currentConfig; char magic[5] = { 0 }; EEPROM.get(EEPROM_ADDR_WIFI_CONFIG_MAGIC, magic); bool configValid = (strncmp(magic, storageMagicValue, 4) == 0); if (configValid) { // Serial.println("Lese WiFi Konfiguration für WebUI Feld für Feld (manuelles Lesen für IPs)..."); // Debug entfernt byte ipBytes[4]; // Puffer // Laden der einfachen Felder EEPROM.get(EEPROM_ADDR_WIFI_SSID, currentConfig.ssid); EEPROM.get(EEPROM_ADDR_WIFI_PASSWORD, currentConfig.password); EEPROM.get(EEPROM_ADDR_WIFI_USE_STATIC, currentConfig.useStaticIP); // --- MANUELLES LESEN FÜR IPAddress FELDER (ohne Byte-Logging) --- for(int i=0; i<4; i++) ipBytes[i] = EEPROM.read(EEPROM_ADDR_WIFI_STATIC_IP + i); currentConfig.staticIP = IPAddress(ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3]); for(int i=0; i<4; i++) ipBytes[i] = EEPROM.read(EEPROM_ADDR_WIFI_GATEWAY + i); currentConfig.gateway = IPAddress(ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3]); for(int i=0; i<4; i++) ipBytes[i] = EEPROM.read(EEPROM_ADDR_WIFI_SUBNET + i); currentConfig.subnet = IPAddress(ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3]); for(int i=0; i<4; i++) ipBytes[i] = EEPROM.read(EEPROM_ADDR_WIFI_DNS + i); currentConfig.dns = IPAddress(ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3]); // --- ENDE MANUELLES LESEN --- // Nullterminierung currentConfig.ssid[sizeof(currentConfig.ssid) - 1] = '\0'; currentConfig.password[sizeof(currentConfig.password) - 1] = '\0'; } else { // Defaults setzen // Serial.println("WiFi Magic Value nicht gefunden (in handleWiFiConfig). Setze Defaults für UI."); // Debug entfernt 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); } // Hostname ist global verfügbar // --- Signalstärke ermitteln --- char signalBuffer[50]; 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]; char ipBuffer[16]; AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8"); // --- Senden der HTML Chunks (unverändert) --- response->print(FPSTR(wifiPageChunk1)); response->print(FPSTR(commonStyle)); response->print(FPSTR(wifiPageChunk2)); response->print(FPSTR(commonNav)); response->print(FPSTR(wifiPageChunk3_a)); response->print(signalBuffer); response->print(FPSTR(wifiPageChunk3_b)); response->print(hostname); response->print(FPSTR(wifiPageChunk3_c)); // SSID einfügen if (strlen(currentConfig.ssid) == 0) { response->print(F("SSID eingeben")); } else { response->print(currentConfig.ssid); } response->print(FPSTR(wifiPageChunk4)); // Passwort einfügen if (!demoModus) { if (strlen(currentConfig.password) == 0) { response->print(F("Passwort eingeben")); } else { response->print(currentConfig.password); } } else { response->print(F("********")); } // IP-Einstellungen (Radios + Div) snprintf_P(formatBuffer, sizeof(formatBuffer), wifiPageChunk5_format, currentConfig.useStaticIP ? "" : "checked", currentConfig.useStaticIP ? "checked" : "", currentConfig.useStaticIP ? "block" : "none"); response->print(formatBuffer); // Statische IP-Felder füllen response->print(FPSTR(wifiPageChunk6_ip)); // IP Label+Input snprintf(ipBuffer, sizeof(ipBuffer), "%d.%d.%d.%d", currentConfig.staticIP[0], currentConfig.staticIP[1], currentConfig.staticIP[2], currentConfig.staticIP[3]); response->print(ipBuffer); // IP Wert response->print(FPSTR(wifiPageChunk7_gateway)); // GW Label+Input snprintf(ipBuffer, sizeof(ipBuffer), "%d.%d.%d.%d", currentConfig.gateway[0], currentConfig.gateway[1], currentConfig.gateway[2], currentConfig.gateway[3]); response->print(ipBuffer); // GW Wert response->print(FPSTR(wifiPageChunk8_dns)); // DNS Label+Input snprintf(ipBuffer, sizeof(ipBuffer), "%d.%d.%d.%d", currentConfig.dns[0], currentConfig.dns[1], currentConfig.dns[2], currentConfig.dns[3]); response->print(ipBuffer); // DNS Wert response->print(FPSTR(wifiPageChunk9_subnet)); // Subnet Label+Input snprintf(ipBuffer, sizeof(ipBuffer), "%d.%d.%d.%d", currentConfig.subnet[0], currentConfig.subnet[1], currentConfig.subnet[2], currentConfig.subnet[3]); response->print(ipBuffer); // Subnet Wert // Rest der Seite response->print(FPSTR(wifiPageChunk10_rest)); request->send(response); } /************************************************************************************ * 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(AsyncWebServerRequest *request) { // Leer-Konfiguration speichern, damit beim nächsten Start AP aktiv wird WiFiConfig emptyConfig; // Wichtig: Hostname NICHT entfernen! if (!demoModus) { saveWiFiConfig(emptyConfig); } AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8"); response->print(FPSTR(forceAPHtml)); // Chunk 1 (Head Start) response->print(FPSTR(commonStyle)); // Common CSS response->print(FPSTR(forceAPHtml2)); // Chunk 2 (Head End) response->print(FPSTR(commonNav)); // Common Navigation response->print(FPSTR(forceAPHtml3)); // Chunk 3 (Body) request->send(response); // --- Logik nach dem Senden (BLEIBT UNVERÄNDERT) --- scheduleRestart(1000); } /************************************************************************************ * 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(AsyncWebServerRequest *request) { // --- Daten validieren und auslesen --- if (!request->hasArg(F("ssid"))) { request->send(400, F("text/plain"), F("Fehler: SSID fehlt!")); return; } // --- Konfigurationsstruktur füllen --- WiFiConfig newConfig; // SSID und Passwort kopieren (mit Längenbegrenzung und Nullterminierung) strncpy(newConfig.ssid, request->arg(F("ssid")).c_str(), sizeof(newConfig.ssid) - 1); newConfig.ssid[sizeof(newConfig.ssid) - 1] = '\0'; if (request->hasArg(F("password"))) { strncpy(newConfig.password, request->arg(F("password")).c_str(), sizeof(newConfig.password) - 1); newConfig.password[sizeof(newConfig.password) - 1] = '\0'; } else { newConfig.password[0] = '\0'; } // IP-Typ bestimmen newConfig.useStaticIP = (request->arg(F("ipType")) == "static"); // Statische IP-Einstellungen parsen, falls ausgewählt if (newConfig.useStaticIP) { if (!request->hasArg(F("ip")) || !request->hasArg(F("gateway")) || !request->hasArg(F("subnet")) || !request->hasArg(F("dns"))) { request->send(400, F("text/plain"), F("Fehler: Fehlende Felder für statische IP!")); return; } bool ipOK = newConfig.staticIP.fromString(request->arg(F("ip"))); bool gwOK = newConfig.gateway.fromString(request->arg(F("gateway"))); bool subOK = newConfig.subnet.fromString(request->arg(F("subnet"))); bool dnsOK = newConfig.dns.fromString(request->arg(F("dns"))); } // Hostname speichern (separat, da nicht Teil der WiFiConfig im EEPROM) if (request->hasArg(F("hostname"))) { strncpy(hostname, request->arg(F("hostname")).c_str(), sizeof(hostname) - 1); hostname[sizeof(hostname) - 1] = '\0'; // Sicherstellen der Nullterminierung 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 EEPROM.commit(); } // --- Speichern (wenn nicht im Demo-Modus) --- if (!demoModus) { // Serial.println("Schreibe WiFi-Konfiguration ins EEPROM..."); saveWiFiConfig(newConfig); // Ruft die Funktion auf, die Feld für Feld schreibt // EEPROM.commit() wird jetzt innerhalb von saveWiFiConfig aufgerufen. } // --- Senden der Bestätigungsseite (Heap-Optimiert) --- AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8"); response->print(FPSTR(saveConfigHtml)); // Chunk 1 (Head Start) response->print(FPSTR(commonStyle)); // Common CSS response->print(FPSTR(saveConfigHtml2)); // Chunk 2 (Head End) response->print(FPSTR(commonNav)); // Common Navigation response->print(FPSTR(saveConfigHtml3)); // Chunk 3 (Body) request->send(response); // --- Neustart einleiten --- scheduleRestart(5000); // Browser kann Bestätigung empfangen } // 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!")); } } } #ifdef ENABLE_DISPLAY // WiFi-Symbol aufs Display zeichnen void drawwifiSymbol(int16_t x, int16_t y) { display.drawBitmap(x, y, wifiSymbol, 13, 7, SH110X_WHITE); } #endif // ENABLE_DISPLAY /************************************************************************************ * Handler zum Rücksetzen der Betriebszeit - URL angepasst ************************************************************************************/ void handleResetRuntime(AsyncWebServerRequest *request) { totalRuntime = 0; EEPROM.put(EEPROM_ADDR_RUNTIME, totalRuntime); EEPROM.commit(); AsyncWebServerResponse *response = request->beginResponse(303); response->addHeader(F("Location"), F("/Info")); request->send(response); } /************************************************************************************ * Handler zum Rücksetzen des Shotzählers - URL angepasst ************************************************************************************/ void handleResetShots(AsyncWebServerRequest *request) { shotCounter = 0; EEPROM.put(EEPROM_ADDR_SHOTCOUNTER, shotCounter); EEPROM.commit(); AsyncWebServerResponse *response = request->beginResponse(303); response->addHeader(F("Location"), F("/Info")); request->send(response); } /************************************************************************************ * Handler zum Rücksetzen des Wartungszählers (über Shot-Zähler) ************************************************************************************/ void handleResetMaintenance(AsyncWebServerRequest *request) { maintenanceIntervalCounter = 0; // Wartungszähler-Reset auf 0 EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter); EEPROM.commit(); displayMaintenanceMessage = false; // Meldung ggf. ausblenden AsyncWebServerResponse *response = request->beginResponse(303); response->addHeader(F("Location"), F("/Service")); request->send(response); } /************************************************************************************ * Handler für den Export der EEPROM-Einstellungen - Unverändert ************************************************************************************/ void handleExportSettings(AsyncWebServerRequest *request) { const size_t bufferSize = 64; byte buffer[bufferSize]; AsyncResponseStream *response = request->beginResponseStream("application/octet-stream"); response->addHeader("Content-Disposition", "attachment; filename=\"dual_pid_settings.bin\""); response->setContentLength(EEPROM_SIZE); 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(); } response->write(buffer, currentChunkSize); yield(); } request->send(response); } /************************************************************************************ * 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(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { (void)request; (void)filename; if (demoModus) { if (index == 0) { importStatusMessage = F("Import im Demo-Modus nicht erlaubt!"); importSuccessful = false; } return; } if (index == 0) { eepromWriteAddress = 0; importSuccessful = false; importStatusMessage = ""; } if (len) { if (importStatusMessage.length() == 0) { for (size_t i = 0; i < len; i++) { if (eepromWriteAddress < EEPROM_SIZE) { EEPROM.write(eepromWriteAddress, data[i]); eepromWriteAddress++; } else { importStatusMessage = F("Importfehler: Empfangene Datei zu groß!"); break; } yield(); } } } if (final) { if (importStatusMessage.length() > 0) { importSuccessful = false; } else if (eepromWriteAddress == EEPROM_SIZE) { if (EEPROM.commit()) { importSuccessful = true; importStatusMessage = F("Einstellungen erfolgreich importiert! Neustart wird durchgeführt..."); } else { importSuccessful = false; importStatusMessage = F("Import fehlgeschlagen: EEPROM Commit Fehler!"); } } else { importSuccessful = false; importStatusMessage = F("Import fehlgeschlagen: Falsche Dateigröße!"); } eepromWriteAddress = 0; } } /************************************************************************************ * 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(AsyncWebServerRequest *request) { AsyncResponseStream *response = request->beginResponseStream("text/html"); response->write(importDoneHtmlStart, strlen_P(importDoneHtmlStart)); response->write(commonStyle, strlen_P(commonStyle)); response->write(importDoneHtmlHeadEnd, strlen_P(importDoneHtmlHeadEnd)); response->write(commonNav, strlen_P(commonNav)); response->write(importDoneHtmlBodyStart, strlen_P(importDoneHtmlBodyStart)); if (importStatusMessage.length() > 0) { response->print(importStatusMessage); } response->write(importDoneHtmlBodyEnd, strlen_P(importDoneHtmlBodyEnd)); request->send(response); if (importSuccessful) { scheduleRestart(5000); } } /************************************************************************************ * Funktion zum Zurücksetzen auf Werkseinstellungen * Setzt auch Brew Control und Brew-by-Weight Werte zurück ************************************************************************************/ void resetToDefaults() { // PID & Offset etc. (Bestehende Werte) 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); EEPROM.put(EEPROM_ADDR_BOOST_WASSER_ACTIVE, defaultBoostWasserActive); EEPROM.put(EEPROM_ADDR_BOOST_DAMPF_ACTIVE, defaultBoostDampfActive); EEPROM.put(EEPROM_ADDR_PREVENTHEAT_WASSER, defaultPreventHeatAboveSetpointWasser); EEPROM.put(EEPROM_ADDR_PREVENTHEAT_DAMPF, defaultPreventHeatAboveSetpointDampf); EEPROM.put(EEPROM_ADDR_MAX_TEMP_WASSER, maxTempWasser); // Sollte ggf. eigene Defaults haben EEPROM.put(EEPROM_ADDR_MAX_TEMP_DAMPF, maxTempDampf); // Sollte ggf. eigene Defaults haben // 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); 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); // 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); // 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); EEPROM.put(EEPROM_ADDR_PREINF_ENABLED, defaultPreInfusionEnabled); EEPROM.put(EEPROM_ADDR_PREINF_DUR_SEC, defaultPreInfusionDurationSeconds); EEPROM.put(EEPROM_ADDR_PREINF_PAUSE_SEC, defaultPreInfusionPauseSeconds); EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_ENABLED, defaultBrewByWeightEnabled); EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_TARGET, defaultBrewByWeightTargetGrams); EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_OFFSET, defaultBrewByWeightOffsetGrams); // Piezo auf Default im EEPROM schreiben EEPROM.put(EEPROM_ADDR_PIEZO_ENABLED, defaultPiezoEnabled); // Dampfverzögerungs-Override auf Default im EEPROM schreiben EEPROM.put(EEPROM_ADDR_STEAM_DELAY_OVERRIDE_SWITCH, defaultSteamDelayOverrideBySwitchEnabled); // Eco-Info Anzeige auf Default im EEPROM schreiben EEPROM.put(EEPROM_ADDR_ECO_INFO_ON_DISPLAY, defaultEcoInfoOnDisplay); // Magic Value für allgemeine Einstellungen setzen (wichtig!) EEPROM.put(EEPROM_ADDR_MAGICVALUE, storageMagicValue); // Alle Änderungen ins EEPROM schreiben EEPROM.commit(); // WICHTIG: Globale Variablen auch auf Default setzen SetpointWasser = defaultSetpointWasser; SetpointDampf = defaultSetpointDampf; OffsetWasser = defaultOffsetWasser; OffsetDampf = defaultOffsetDampf; KpWasser = defaultKpWasser; KiWasser = defaultKiWasser; KdWasser = defaultKdWasser; KpDampf = defaultKpDampf; KiDampf = defaultKiDampf; KdDampf = defaultKdDampf; ecoModeMinutes = defaultEcoModeMinutes; ecoModeTempWasser = defaultEcoModeTempWasser; ecoModeTempDampf = defaultEcoModeTempDampf; dynamicEcoActive = false; fastHeatUpAktiv = false; dampfVerzoegerung = defaultDampfVerzoegerung; tuningStepWasser = defaultTuningStepWasser; tuningNoiseWasser = defaultTuningNoiseWasser; tuningStartValueWasser = defaultTuningStartValueWasser; tuningLookBackWasser = defaultTuningLookBackWasser; tuningStepDampf = defaultTuningStepDampf; tuningNoiseDampf = defaultTuningNoiseDampf; tuningStartValueDampf = defaultTuningStartValueDampf; tuningLookBackDampf = defaultTuningLookBackDampf; maintenanceInterval = defaultMaintenanceInterval; maintenanceIntervalCounter = 0; boostWasserActive = defaultBoostWasserActive; boostDampfActive = defaultBoostDampfActive; preventHeatAboveSetpointWasser = defaultPreventHeatAboveSetpointWasser; preventHeatAboveSetpointDampf = defaultPreventHeatAboveSetpointDampf; windowSizeWasser = defaultWindowSizeWasser; windowSizeDampf = defaultWindowSizeDampf; strncpy(hostname, defaultHostname.c_str(), sizeof(hostname) - 1); hostname[sizeof(hostname) - 1] = '\0'; // Globale Brew Control Variablen auf Defaults setzen brewByTimeEnabled = defaultBrewByTimeEnabled; brewByTimeTargetSeconds = defaultBrewByTimeTargetSeconds; preInfusionEnabled = defaultPreInfusionEnabled; preInfusionDurationSeconds = defaultPreInfusionDurationSeconds; preInfusionPauseSeconds = defaultPreInfusionPauseSeconds; // Globale Brew-by-Weight Variablen auf Defaults setzen #ifdef ESP32 brewByWeightEnabled = defaultBrewByWeightEnabled; brewByWeightTargetGrams = defaultBrewByWeightTargetGrams; brewByWeightOffsetGrams = defaultBrewByWeightOffsetGrams; #endif // Globale Piezo Variable auf Default setzen piezoEnabled = defaultPiezoEnabled; // Globale Variable für Dampfverzögerungs-Override auf Default setzen steamDelayOverrideBySwitchEnabled = defaultSteamDelayOverrideBySwitchEnabled; // Globale Variable für Eco-Info Anzeige auf Default setzen ecoInfoOnDisplay = defaultEcoInfoOnDisplay; // PID Limits neu setzen (falls Fenstergröße geändert wurde) 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(AsyncWebServerRequest *request) { if (!demoModus) { resetToDefaults(); } else { request->send(403, "text/plain", "Reset im Demo-Modus nicht erlaubt."); return; } AsyncResponseStream *response = request->beginResponseStream("text/html"); response->write(resetDoneHtmlStart, strlen_P(resetDoneHtmlStart)); response->write(commonStyle, strlen_P(commonStyle)); response->write(resetDoneHtmlHeadEnd, strlen_P(resetDoneHtmlHeadEnd)); response->write(commonNav, strlen_P(commonNav)); response->write(resetDoneHtmlBody, strlen_P(resetDoneHtmlBody)); request->send(response); scheduleRestart(5000); } /************************************************************************************ * Piezo-Hilfsfunktionen (Nur ESP32) ************************************************************************************/ #ifdef ESP32 // Funktionen nur für ESP32 kompilieren // Erzeugt einen kurzen Piepton void beepShort(int frequency = 3000, int duration = 1000) { if (!piezoEnabled) return; tone(PIEZO_PIN, frequency, duration); // delay(duration + 10); // Kurze Pause danach, falls nötig, aber `tone` mit Dauer ist blockierend } // Spezifischer Piepton für die Wartungserinnerung void beepMaintenanceAlert(int frequency = 3000, int duration = 3000) { if (!piezoEnabled) return; tone(PIEZO_PIN, frequency, duration); // delay(duration + 10); // Kurze Pause danach, falls nötig, aber `tone` mit Dauer ist blockierend } #endif // ESP32 /************************************************************************************ * ESP-NOW Empfangsfunktion (Nur ESP32) ************************************************************************************/ #if (defined(ESP32) && (SCALE_TYPE == SCALE_ESPNOW)) /** * @brief Callback-Funktion, die bei Empfang einer ESP-NOW Nachricht aufgerufen wird. * Verarbeitet die Daten der Waage. * KORREKTE SIGNATUR FÜR AKTUELLE ESP32-CORES */ void OnDataRecv(const esp_now_recv_info * info, const uint8_t *incomingData, int len) { // Der erste Parameter ist jetzt eine Struktur, wir ignorieren sie aber, da wir nur die Daten brauchen. // Die MAC-Adresse wäre z.B. über "info->src_addr" erreichbar. // Prüfen, ob die Länge der Nachricht der erwarteten Struktur entspricht if (len == sizeof(struct_espnow_scale_message)) { struct_espnow_scale_message receivedData; memcpy(&receivedData, incomingData, sizeof(receivedData)); // Globale Variablen aktualisieren currentWeightReading = receivedData.weight_g; lastScaleMessageTime = millis(); // Zeit des letzten Empfangs für Timeout merken // Verbindung als hergestellt markieren, falls sie es noch nicht war if (!scaleConnected) { scaleConnected = true; // Serial.println("INFO: ESP-NOW Waage verbunden und erste Daten empfangen."); } // Status-Flags auswerten if (receivedData.status_flags & ESPNOW_SCALE_FLAG_TOGGLE_MODE) { scaleModeActive = !scaleModeActive; // Serial.print("INFO: Waagen-Anzeigemodus via ESP-NOW umgeschaltet: "); // Serial.println(scaleModeActive ? "AN" : "AUS"); if (piezoEnabled) { beepShort(scaleModeActive ? 3500 : 3200, 70); // Unterschiedliche Töne für an/aus } } if (receivedData.status_flags & ESPNOW_SCALE_FLAG_JUST_TARED) { // Serial.println("INFO: Waage wurde via ESP-NOW tariert."); if (piezoEnabled) { beepShort(2800, 50); // Bestätigungston } } if (receivedData.status_flags & ESPNOW_SCALE_FLAG_AWOKE) { // Serial.println("INFO: ESP-NOW Waage ist aus dem Schlaf aufgewacht."); } } } #endif /************************************************************************************ * Variable für Systemstartzeit (relevant für die Dampf-Verzögerung) - Unverändert ************************************************************************************/ unsigned long startupTime; #ifdef ESP32 /************************************************************************************ * Stoppt den Brühvorgang (Pumpe/Ventil) und verarbeitet Shot-Daten (ESP32) * Wird von updateShotTimer() oder aus loop() (BrewByTime/Weight) aufgerufen. * Setzt das manualSwitchReleasedAfterAutoStop Flag entsprechend. ************************************************************************************/ void stopBrewSequence(unsigned long durationMillis, bool manualStop = false, bool stoppedByWeight = false) { if (!shotActive) return; // Verhindert mehrfaches Ausführen lastShotDurationMillis = durationMillis; // Dauer speichern shotEndTime = millis(); // Endzeitpunkt speichern lastShotFirstWeightChangeTime = firstWeightChangeTime; lastShotStartTime = shotStartTime; firstWeightChangeDetected = false; firstWeightChangeTime = 0; // Wenn durch Gewicht gestoppt, das finale Gewicht für die Anzeige speichern if (stoppedByWeight) { lastShotFinalNetWeight = currentWeightReading - brewStartWeight; } // Statusvariablen zurücksetzen shotActive = false; currentPreInfusionState = PI_INACTIVE; lastShotTime = millis(); // Wichtig für Eco-Modus // Hardware stoppen digitalWrite(PUMP_PIN, LOW); digitalWrite(VALVE_PIN, LOW); // Ventil öffnen -> Druck ablassen // --- Sperre das System, wenn es ein AUTO-Stopp war --- if (!manualStop) { // Prüft den übergebenen Parameter manualSwitchReleasedAfterAutoStop = false; // Sperren, bis der Schalter losgelassen wird // Hinweiston ausgeben beepShort(); } else { manualSwitchReleasedAfterAutoStop = true; // Sicherstellen, dass nach manuellem Stopp entsperrt ist } // --- Ende Sperrlogik --- // Shot zählen und loggen (nur wenn > 20 Sekunden) if (durationMillis > 20000 && !wartungsModusAktiv) { // Langsames Speichern wird in die Hauptschleife ausgelagert. // Hier werden jetzt alle relevanten Daten für den Log zwischengespeichert. shotNeedsToBeSaved = true; savedShotDuration = durationMillis; savedShotWasByWeight = stoppedByWeight; // Kennzeichnen, ob der Bezug gewichtsbasierend war if(stoppedByWeight) { savedShotFinalWeight = lastShotFinalNetWeight + brewByWeightOffsetGrams; ; // Gewicht (inkl. Offset) für den Log merken } else { savedShotFinalWeight = 0.0f; // Sicherstellen, dass kein altes Gewicht geloggt wird } } // ZUSTANDSMASCHINE STARTEN (JETZT KONDITIONAL) if (stoppedByWeight) { postShotDisplayState = POST_SHOT_SHOW_WEIGHT_RESULT; // Setze neuen Zustand für Gewichtsanzeige } else { postShotDisplayState = POST_SHOT_SHOW_DURATION; // Setze alten Zustand für Zeit/manuell } // Fast-Heat-Up eventuell deaktivieren fastHeatUpHeating = false; } #endif // ESP32 /************************************************************************************ * setup(): Initialisierung des Systems * - Angepasst für optionale Display-Unterstützung via ENABLE_DISPLAY * - Korrekte Wire.begin() Logik für Display/Waage * - Zusätzliche Serial-Ausgaben für Headless-Betrieb ************************************************************************************/ void setup() { Serial.begin(115200); EEPROM.begin(EEPROM_SIZE); // EEPROM initialisieren // --- PinModes initialisieren --- pinMode(SSR_DAMPF_PIN, OUTPUT); pinMode(SSR_WASSER_PIN, OUTPUT); #ifdef ESP32 pinMode(SHOT_TIMER_PIN, INPUT_PULLDOWN); // ESP32 uses Pulldown for HIGH active // Sicherstellen, dass nicht durch vorherigen Bezug, welcher mit Brew-Control beendet wurde noch der Bezugsschalter auf "EIN" ist // und dadurch direkt nach dem Einschalten ein Bezug erfolgt - Nur Relevant für ESP32-Variante! if (digitalRead(SHOT_TIMER_PIN) == HIGH) { manualSwitchReleasedAfterAutoStop = false; // Behandle es so, als ob gerade ein Auto-Stopp erfolgt wäre } pinMode(PUMP_PIN, OUTPUT); pinMode(VALVE_PIN, OUTPUT); // Piezo-Pin als Ausgang konfigurieren <<< pinMode(PIEZO_PIN, OUTPUT); digitalWrite(PIEZO_PIN, LOW); // Sicherstellen, dass er initial aus ist // ADC für NTC-Pin konfigurieren und kalibrieren (nur ESP32) analogSetPinAttenuation(ANALOG_NTC_PIN, ADC_11db); analogReadResolution(12); esp_adc_cal_value_t val_type = esp_adc_cal_characterize(ADC_UNIT_1, ADC_ATTEN_DB_11, ADC_WIDTH_BIT_12, 1100, &adc_chars); // if (val_type == ESP_ADC_CAL_VAL_EFUSE_VREF) { // Serial.println(" Kalibrierung: eFuse Vref verwendet."); // } else if (val_type == ESP_ADC_CAL_VAL_EFUSE_TP) { // Serial.println(" Kalibrierung: Two Point eFuse Werte verwendet."); // } else { // Serial.println(" Kalibrierung: Default Vref verwendet."); // } // *** ENDE ADC Konfiguration/Kalibrierung *** #else // ESP8266 pinMode(SHOT_TIMER_PIN, INPUT_PULLUP); // ESP8266 uses Pullup for HIGH active #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 // --- Wire (I2C) initialisieren, wenn benötigt (Display oder Waage) --- #if defined(ENABLE_DISPLAY) || defined(ESP32) // Wenn Display aktiviert ODER ESP32 (für Waage) #ifdef ENABLE_DISPLAY // Priorisiere Display-Pins, falls aktiviert Wire.begin(IIC_5V_SDA, IIC_5V_SCK); #elif defined(ESP32) // Nur Waage auf ESP32 (kein Display) // Nutze Standard I2C Pins oder spezifische Waagen-Pins falls anders definiert Wire.begin(IIC_5V_SDA, IIC_5V_SCK); // Oder Wire.begin(SCALE_SDA, SCALE_SCL); falls vorhanden #endif #endif // --- EEPROM-Werte laden oder Defaults setzen --- char storedMagicValue[5] = {0}; bool magicValueVorhanden = false; EEPROM.get(EEPROM_ADDR_MAGICVALUE, storedMagicValue); if (strncmp(storedMagicValue, storageMagicValue, 4) == 0) { magicValueVorhanden = true; } else { magicValueVorhanden = false; } // Werte aus dem EEPROM laden oder Defaults setzen (kompletter Block) if (magicValueVorhanden) { // --- Bestehende EEPROM Ladevorgänge --- 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); EEPROM.get(EEPROM_ADDR_PREINF_ENABLED, preInfusionEnabled); EEPROM.get(EEPROM_ADDR_PREINF_DUR_SEC, preInfusionDurationSeconds); EEPROM.get(EEPROM_ADDR_PREINF_PAUSE_SEC, preInfusionPauseSeconds); EEPROM.get(EEPROM_ADDR_BREWBYWEIGHT_ENABLED, brewByWeightEnabled); EEPROM.get(EEPROM_ADDR_BREWBYWEIGHT_TARGET, brewByWeightTargetGrams); EEPROM.get(EEPROM_ADDR_BREWBYWEIGHT_OFFSET, brewByWeightOffsetGrams); // Piezo-Status laden EEPROM.get(EEPROM_ADDR_PIEZO_ENABLED, piezoEnabled); // Status für Dampfverzögerungs-Override per Schalter laden EEPROM.get(EEPROM_ADDR_STEAM_DELAY_OVERRIDE_SWITCH, steamDelayOverrideBySwitchEnabled); // Eco-Info Anzeige auf dem Display laden EEPROM.get(EEPROM_ADDR_ECO_INFO_ON_DISPLAY, ecoInfoOnDisplay); // 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 und speichern // Serial.println("Keine gültigen Einstellungen im EEPROM gefunden. Setze Werkseinstellungen."); resetToDefaults(); // Diese Funktion setzt globale Variablen UND schreibt ins EEPROM } // --- WiFi Konfiguration --- WiFiConfig wifiConfig; bool hasWiFiConfig = loadWiFiConfig(wifiConfig); // Lädt auch Hostname WiFi.hostname(hostname); // Hostname setzen VOR begin/softAP if (hasWiFiConfig) { WiFi.mode(WIFI_STA); #ifdef ESP32 // Sicherstellen, dass dies nur für ESP32 gilt WiFi.setSleep(false); #endif if (wifiConfig.useStaticIP) { // Serial.println(F("Verwende statische IP-Konfiguration:")); // Optional für Debugging // Serial.print(F(" IP: ")); Serial.println(wifiConfig.staticIP); // Optional // Serial.print(F(" Gateway: ")); Serial.println(wifiConfig.gateway); // Optional // Serial.print(F(" Subnet: ")); Serial.println(wifiConfig.subnet); // Optional // Serial.print(F(" DNS: ")); Serial.println(wifiConfig.dns); // Optional // Für ESP8266 und ESP32 ist die Reihenfolge der primären DNS-Parameter leicht unterschiedlich, // aber ein einzelner DNS-Server sollte funktionieren. // ESP8266: WiFi.config(ip, gateway, subnet, dns1, dns2_optional) // ESP32: WiFi.config(ip, gateway, subnet, primaryDNS_optional, secondaryDNS_optional) // Wenn Sie nur einen DNS-Server haben, ist `wifiConfig.dns` für beide Plattformen in Ordnung. if (!WiFi.config(wifiConfig.staticIP, wifiConfig.gateway, wifiConfig.subnet, wifiConfig.dns)) { Serial.println(F("FEHLER: Statische IP-Konfiguration fehlgeschlagen!")); // Optional } // } else { // Serial.println(F("Verwende DHCP.")); // Optional für Debugging } // Serial.printf("Verbinde mit SSID: %s, Hostname: %s\n", wifiConfig.ssid, hostname); WiFi.begin(wifiConfig.ssid, wifiConfig.password); // Warte auf Verbindung (mit Timeout) int retries = 0; while (WiFi.status() != WL_CONNECTED && retries < 20) { delay(500); retries++; yield(); } if (WiFi.status() == WL_CONNECTED) { // Serial.print(F("\nErfolgreich verbunden! IP-Adresse: ")); Serial.println(WiFi.localIP()); // NTP Client und Zeitzone konfigurieren timeClient.begin(); // Serial.println("NTP Client gestartet."); configTzTime(TZ_INFO, "pool.ntp.org"); // Serial.printf("Zeitzone gesetzt: %s\n", TZ_INFO); // Demo Modus 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(F("\nKonnte keine Verbindung herstellen! Starte AP-Modus...")); startAPMode(); // Funktion startet Soft AP und gibt Infos aus } } else { // Serial.println("Keine WLAN-Konfiguration gefunden. Starte AP-Modus..."); startAPMode(); // Funktion startet Soft AP und gibt Infos aus } // --- Ende WiFi Konfiguration --- // --- Wichtige Infos auf Serial ausgeben (Alternativ zur Displayausgabe) --- // Serial.println("---------------------------------------------"); // Serial.print("Firmware Version: "); Serial.println(version); // #ifdef ENABLE_DISPLAY // Serial.println("Display: Aktiviert"); // #else // Serial.println("Display: Deaktiviert"); // #endif // if (WiFi.getMode() == WIFI_AP || WiFi.status() != WL_CONNECTED) { // Serial.println(F("WiFi Modus: Access Point (AP)")); // Serial.print(F("SSID: ")); Serial.println(WiFi.softAPSSID()); // Serial.print(F("Passwort: QuickMill")); // Oder was in startAPMode() gesetzt wird // Serial.print(F("AP IP Addresse: ")); Serial.println(WiFi.softAPIP()); // } else { // Serial.println(F("WiFi Modus: Station (STA)")); // Serial.print(F("Verbunden mit SSID: ")); Serial.println(WiFi.SSID()); // Serial.print(F("IP Addresse: ")); Serial.println(WiFi.localIP()); // Serial.print(F("Hostname: ")); Serial.print(hostname); Serial.println(".local"); // Serial.print(F("Signalstärke: ")); Serial.print(WiFi.RSSI()); Serial.println(" dBm"); // } // Serial.println("---------------------------------------------"); // --- Ende Serial Ausgabe --- // --- Weitere Initialisierungen --- // Wartungszähler, Betriebszeit und Shotcounter aus EEPROM laden EEPROM.get(EEPROM_ADDR_RUNTIME, totalRuntime); if (totalRuntime == ULONG_MAX || totalRuntime > (86400UL * 365 * 10)) { totalRuntime = 0; EEPROM.put(EEPROM_ADDR_RUNTIME, totalRuntime); EEPROM.commit(); } EEPROM.get(EEPROM_ADDR_SHOTCOUNTER, shotCounter); if (shotCounter == ULONG_MAX || shotCounter > 1000000UL) { shotCounter = 0; EEPROM.put(EEPROM_ADDR_SHOTCOUNTER, shotCounter); EEPROM.commit(); } EEPROM.get(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter); if (maintenanceIntervalCounter == ULONG_MAX || maintenanceIntervalCounter > 1000000UL) { maintenanceIntervalCounter = 0; EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter); EEPROM.commit(); } // Fast Heat Up Status setzen if (fastHeatUpAktiv) { fastHeatUpHeating = true; } // --- Display-Initialisierung (nur wenn aktiviert) --- #ifdef ENABLE_DISPLAY if (!display.begin(0x3C, true)) { // display.begin benutzt das ggf. schon initialisierte Wire Serial.println(F("Display konnte nicht initialisiert werden!")); } else { Serial.println(F("Display initialisiert.")); // Display-Ausgabe am Start (1) 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); // Verzögerung nur bei aktiviertem Display // Display-Ausgabe am Start (2) - WiFi Info 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(WiFi.softAPSSID()); 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); // Verzögerung nur bei aktiviertem Display } #endif // ENABLE_DISPLAY // --- Waage Initialisierung (nur ESP32) --- #ifdef ESP32 #if (SCALE_TYPE == SCALE_I2C) // Serial.println("Initialisiere I2C Waage..."); if (!scales.begin(&Wire)) { // Serial.println("FEHLER: I2C Waage nicht gefunden oder Initialisierung fehlgeschlagen!"); scaleConnected = false; } else { // Serial.println("I2C Waage erfolgreich initialisiert."); scaleConnected = true; } #elif (SCALE_TYPE == SCALE_ESPNOW) // Serial.println("Initialisiere ESP-NOW für Waagen-Empfang..."); // WiFi ist bereits im STA Modus, was für ESP-NOW Empfang notwendig ist. if (esp_now_init() != ESP_OK) { // Serial.println("FEHLER: ESP-NOW Initialisierung fehlgeschlagen!"); } else { esp_now_register_recv_cb(OnDataRecv); // Serial.println("ESP-NOW für Empfang von Waage bereit."); // `scaleConnected` wird `true`, sobald die erste Nachricht empfangen wird. } #endif #endif // --- PID-Regler Konfiguration --- // Plausibilitätscheck für Fenstergrößen if (windowSizeWasser == 0 || windowSizeWasser > 60000) { // Serial.printf("WARNUNG: Ungültige windowSizeWasser (%lu), setze auf Default (%lu).\n", windowSizeWasser, defaultWindowSizeWasser); windowSizeWasser = defaultWindowSizeWasser; EEPROM.put(EEPROM_ADDR_WINDOWSIZE_WASSER, windowSizeWasser); // Korrigieren EEPROM.commit(); } if (windowSizeDampf == 0 || windowSizeDampf > 60000) { // Serial.printf("WARNUNG: Ungültige windowSizeDampf (%lu), setze auf Default (%lu).\n", windowSizeDampf, defaultWindowSizeDampf); windowSizeDampf = defaultWindowSizeDampf; EEPROM.put(EEPROM_ADDR_WINDOWSIZE_DAMPF, windowSizeDampf); // Korrigieren EEPROM.commit(); } pidWasser.SetOutputLimits(0, windowSizeWasser); pidDampf.SetOutputLimits(0, windowSizeDampf); pidWasser.SetTunings(KpWasser, KiWasser, KdWasser); pidDampf.SetTunings(KpDampf, KiDampf, KdDampf); pidWasser.SetMode(AUTOMATIC); pidDampf.SetMode(AUTOMATIC); windowStartTimeWasser = millis(); windowStartTimeDampf = millis(); // Serial.printf("PID Wasser: Kp=%.2f, Ki=%.2f, Kd=%.2f, Win=%lu\n", KpWasser, KiWasser, KdWasser, windowSizeWasser); // Serial.printf("PID Dampf : Kp=%.2f, Ki=%.2f, Kd=%.2f, Win=%lu\n", KpDampf, KiDampf, KdDampf, windowSizeDampf); // --- LittleFS Initialisierung --- // Serial.println("Initialisiere LittleFS..."); if (!LittleFS.begin()) { // Serial.println("--------------------------------------------------"); // Serial.println("FEHLER: LittleFS Mount fehlgeschlagen!"); // Versuch zu formatieren (Achtung: löscht alle Daten!) // Serial.println("Versuche LittleFS zu formatieren..."); if (!LittleFS.format()) { // Serial.println("FEHLER: LittleFS Formatierung fehlgeschlagen!"); } else { // Serial.println("LittleFS formatiert. Versuche erneut zu mounten..."); if (!LittleFS.begin()) { // Serial.println("LittleFS Mount auch nach Formatierung fehlgeschlagen!"); } else { // Serial.println("LittleFS nach Formatierung erfolgreich gemountet."); } } } else { // Serial.println("LittleFS erfolgreich gemountet."); // Prüfe/Erstelle /Profile Verzeichnis 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' gefunden."); } // Optional: FS Info ausgeben // #ifdef ESP32 // uint64_t totalBytes = LittleFS.totalBytes(); // uint64_t usedBytes = LittleFS.usedBytes(); // Serial.printf("LittleFS Info: Total= %llu, Used= %llu, Free= %llu Bytes\n", totalBytes, usedBytes, totalBytes - usedBytes); // #else // FSInfo fs_info; // if(LittleFS.info(fs_info)) { // Serial.printf("LittleFS Info: Total= %u, Used= %u, Free= %u Bytes\n", fs_info.totalBytes, fs_info.usedBytes, fs_info.totalBytes - fs_info.usedBytes); // } // #endif } // Ende des LittleFS-Blocks // --- Routen für Webserver definieren --- // Serial.println("Definiere Webserver Routen..."); asyncServer.on("/", HTTP_GET, handleDashboard); // Dashboard als Hauptseite asyncServer.on("/dashboard-data", HTTP_GET, handleDashboardData); asyncServer.on("/dashboard-action", HTTP_POST, handleDashboardAction); asyncServer.on("/PID", HTTP_GET, handlePidSettings); asyncServer.on("/updateSettings", HTTP_POST, handleSettingsUpdate); asyncServer.on("/Info", HTTP_GET, handleInfo); asyncServer.on("/updateInfoSettings", HTTP_POST, handleInfoUpdate); asyncServer.on("/downloadNutzungsstatistik", handleDownloadNutzungsstatistik); asyncServer.on("/deleteStatistics", HTTP_POST, handleDeleteStatistics); asyncServer.on("/toggleWartungsmodus", HTTP_POST, handleToggleWartungsmodus); // In Service integriert asyncServer.on("/Service", HTTP_GET, handleService); // Eigene Seite für Wartung, Reset etc. asyncServer.on("/AutoTune-Wasser", handleAutoTuneWasser); asyncServer.on("/AutoTune-Dampf", handleAutoTuneDampf); asyncServer.on("/updateAutotuneSettings", HTTP_POST, handleUpdateAutotuneSettings); asyncServer.on("/PID-Tuning-Abbruch", HTTP_POST, handlePidSettingsTuningAbbruch); asyncServer.on("/PID-Tuning", handlePidSettingsTuning); // Eigene Seite für AutoTune Start & Parameter asyncServer.on("/ECO", HTTP_GET, handleEco); asyncServer.on("/updateEcoSettings", HTTP_POST, handleEcoUpdate); asyncServer.on("/Firmware", HTTP_GET, handleFirmware); // Kombinierte Firmware & Settings Seite asyncServer.on("/update", HTTP_POST, handleFirmwareUpdateResponse, handleFirmwareUploadProgress); asyncServer.on("/Fast-Heat-Up", HTTP_GET, handleFastHeatUp); asyncServer.on("/updateFast-Heat-Up-Settings", HTTP_POST, handleFastHeatUpSettings); asyncServer.on("/Dateimanager", HTTP_GET, handleFileManager); asyncServer.on("/Datei-Upload", HTTP_POST, handleFileUploaded, handleFileUpload); asyncServer.on("/Datei-Entfernen", HTTP_GET, handleFileDelete); asyncServer.on("/Datei-Download", HTTP_GET, handleFileDownload); asyncServer.on("/Verzeichnis-Erstellen", HTTP_POST, handleCreateDirectory); asyncServer.on("/Chart-Daten", HTTP_GET, handleChartData); asyncServer.on("/Chart", HTTP_GET, handleCharts); // Eigene Seite für die Charts asyncServer.on("/Netzwerk", HTTP_GET, handleWiFiConfig); // Eigene Seite für WiFi asyncServer.on("/saveWiFiConfig", HTTP_POST, handleSaveWiFiConfig); asyncServer.on("/forceAPMode", HTTP_GET, handleForceAPMode); asyncServer.on("/Profile", HTTP_GET, handleProfilesPage); // Eigene Seite für Profile asyncServer.on("/loadProfile", HTTP_POST, handleLoadProfile); asyncServer.on("/saveProfile", HTTP_POST, handleSaveProfile); asyncServer.on("/deleteProfile", HTTP_POST, handleDeleteProfile); asyncServer.on("/exportSettings", HTTP_GET, handleExportSettings); // In Firmware-Seite integriert asyncServer.on("/importSettings", HTTP_POST, handleImportSettings, handleImportUpload); // In Firmware-Seite integriert asyncServer.on("/resetDefaults", HTTP_POST, handleResetDefaults); // In Firmware-Seite integriert asyncServer.on("/updatePiezoSettings", HTTP_POST, handleUpdatePiezoSettings); // Service-Seite asyncServer.on("/updateIntervalSettings", HTTP_POST, handleUpdateIntervalSettings); // Service-Seite asyncServer.on("/resetRuntime", HTTP_POST, handleResetRuntime); // In Info-Seite integriert asyncServer.on("/resetShots", HTTP_POST, handleResetShots); // In Info-Seite integriert asyncServer.on("/resetMaintenance", HTTP_POST, handleResetMaintenance); // In Service-Seite integriert asyncServer.on("/restartDevice", HTTP_POST, handleRestartDevice); // Neustart des Microcontrollers #ifdef ESP32 // Routen für Brew Control (Nur ESP32) asyncServer.on("/Brew-Control", HTTP_GET, handleBrewControl); asyncServer.on("/saveBrewControl", HTTP_POST, handleSaveBrewControl); #endif dashboardWs.onEvent(onDashboardWsEvent); asyncServer.addHandler(&dashboardWs); // Webserver starten asyncServer.begin(); yield(); // Dem System kurz Zeit geben // Serial.println(F("Webserver gestartet und bereit.")); // Serial.println("---------------------------------------------"); // Serial.println("Setup abgeschlossen. Hauptschleife beginnt."); // Serial.println("---------------------------------------------"); startupTime = millis(); // Startzeit für Dampfverzögerung speichern yield(); // Dem System kurz Zeit geben } // Ende setup() void handleAutoTune(AsyncWebServerRequest *request = nullptr); /************************************************************************************ * loop(): Hauptschleife * Enthält Logik für Pre-Infusion Ablauf und Brew-By-Time Check (Nur ESP32) ************************************************************************************/ void loop() { unsigned long currentMillis = millis(); if (scheduledRestart && currentMillis >= scheduledRestart) { ESP.restart(); } // 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 --- if (InputWasser < 0.0 || isnan(InputWasser)) { wasserSensorError = true; wasserSafetyShutdown = false; OutputWasser = 0; // Ton ausgeben zur Warnung #ifdef ESP32 // Nur piepen, wenn auf ESP32 beepShort(); #endif // Serial.println("WARNUNG: Wassertemperatursensor liefert ungültigen Wert!"); // Optional Log } else if (InputWasser > maxTempWasser) { wasserSensorError = false; wasserSafetyShutdown = true; OutputWasser = 0; // Ton ausgeben zur Warnung #ifdef ESP32 // Nur piepen, wenn auf ESP32 beepShort(); #endif // Serial.printf("WARNUNG: Wassertemperatur über Limit! Shutdown.\n"); // Optional Log } else { if (wasserSensorError) Serial.println("INFO: Wassertemperatursensor wieder OK."); wasserSensorError = false; if (wasserSafetyShutdown) Serial.println("INFO: Wassertemperatur wieder im sicheren Bereich."); wasserSafetyShutdown = false; bool activateBoost = boostWasserActive && !wartungsModusAktiv && !ecoModeAktiv && !autoTuneWasserActive && (InputWasser < (SetpointWasser * 0.95)); if (activateBoost) { OutputWasser = windowSizeWasser; } else if (!autoTuneWasserActive) { pidWasser.Compute(); } } yield(); // --- Angepasste Sicherheitsüberprüfung Dampf --- if (isnan(InputDampf) || InputDampf < 0.0) { dampfSensorError = true; dampfSafetyShutdown = false; OutputDampf = 0; // Ton ausgeben zur Warnung #ifdef ESP32 // Nur piepen, wenn auf ESP32 beepShort(); #endif // Serial.println("WARNUNG: Dampftemperatursensor liefert ungültigen Wert!"); // Optional Log } else if (InputDampf > maxTempDampf) { dampfSensorError = false; dampfSafetyShutdown = true; OutputDampf = 0; // Ton ausgeben zur Warnung #ifdef ESP32 // Nur piepen, wenn auf ESP32 beepShort(); #endif // Serial.printf("WARNUNG: Dampftemperatur über Limit! Shutdown.\n"); // Optional Log } else { if (dampfSensorError) Serial.println("INFO: Dampftemperatursensor wieder OK."); dampfSensorError = false; if (dampfSafetyShutdown) Serial.println("INFO: Dampftemperatur wieder im sicheren Bereich."); dampfSafetyShutdown = false; bool steamDelayActive = (dampfVerzoegerung > 0 && (millis() - startupTime < (unsigned long)dampfVerzoegerung * 60000UL)); if (steamDelayActive && !wartungsModusAktiv) { OutputDampf = 0; } else { bool activateBoostDampf = boostDampfActive && !wartungsModusAktiv && !ecoModeAktiv && !autoTuneDampfActive && (InputDampf < (SetpointDampf * 0.90)); if (activateBoostDampf) { OutputDampf = windowSizeDampf; } else if (!autoTuneDampfActive) { pidDampf.Compute(); } } } yield(); // Prüfen der Startverzögerung für Dampf bool steamDelayActiveSystem = (dampfVerzoegerung > 0 && (millis() - startupTime < (unsigned long)dampfVerzoegerung * 60000UL)); // Prüfe, ob WIRKLICH verzögert werden soll (System will UND kein Override aktiv) bool shouldDelaySteamPID = steamDelayActiveSystem && !steamDelayOverridden; // Nur fortfahren, wenn kein Sensorfehler und keine Sicherheitsabschaltung aktiv ist if (!dampfSensorError && !dampfSafetyShutdown) { if (shouldDelaySteamPID && !wartungsModusAktiv) { // Wenn verzögert werden soll, erzwinge PID-Output auf 0 OutputDampf = 0; // Serial.println("Dampf-PID-Berechnung wegen Verzögerung übersprungen."); // Optional Debug } else { // Keine Verzögerung ODER Override aktiv -> PID normal berechnen/Boost prüfen bool activateBoostDampf = boostDampfActive && !wartungsModusAktiv && !ecoModeAktiv && !autoTuneDampfActive && (InputDampf < (SetpointDampf * 0.90)); if (activateBoostDampf) { OutputDampf = windowSizeDampf; // Boost hat Vorrang } else if (!autoTuneDampfActive) { pidDampf.Compute(); // PID normal berechnen lassen (jetzt sicher) } // Wenn AutoTune aktiv ist, wird OutputDampf dort gesetzt (in handleAutoTune) } } #ifdef ESP32 #if (SCALE_TYPE == SCALE_I2C) // Waage nur bei I2C-Typ aktiv auslesen if (scaleConnected && (scaleModeActive || (shotActive && brewByWeightEnabled))) { currentWeightReading = scales.getWeight(); } #elif (SCALE_TYPE == SCALE_ESPNOW) // Für ESP-NOW passiert hier nichts, der Wert wird automatisch // im Hintergrund durch die OnDataRecv-Funktion aktualisiert. #endif if (!scaleConnected) { currentWeightReading = 0.0f; // Dies gilt für beide Typen } #endif // AutoTune-Logik 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); } else { if (now - windowStartTimeWasser > windowSizeWasser) { windowStartTimeWasser += windowSizeWasser; } bool pidWantsHeat = (OutputWasser > 0) && (now < (windowStartTimeWasser + (unsigned long)OutputWasser)); bool preventOverheatActive = (preventHeatAboveSetpointWasser && (InputWasser > SetpointWasser)) || (ecoModeAktiv && (InputWasser > SetpointWasser)); if (pidWantsHeat && !preventOverheatActive) { digitalWrite(SSR_WASSER_PIN, HIGH); } else { digitalWrite(SSR_WASSER_PIN, LOW); } } yield(); // Dampf SSR if (dampfSensorError || dampfSafetyShutdown) { digitalWrite(SSR_DAMPF_PIN, LOW); } else { if (now - windowStartTimeDampf > windowSizeDampf) { windowStartTimeDampf += windowSizeDampf; } // Prüfe, ob die Verzögerung aktiv UND NICHT überschrieben ist bool steamDelayActiveSystem = (dampfVerzoegerung > 0 && (millis() - startupTime < (unsigned long)dampfVerzoegerung * 60000UL)); bool shouldDelaySteam = steamDelayActiveSystem && !steamDelayOverridden; if (shouldDelaySteam && !wartungsModusAktiv) { // Wenn verzögert werden soll (und kein Wartungsmodus) OutputDampf = 0; // PID Output überschreiben (wird unten zum Abschalten führen) digitalWrite(SSR_DAMPF_PIN, LOW); // Sicherstellen, dass SSR aus ist // Optional: Logge, dass Verzögerung aktiv ist // Serial.println("Dampfverzögerung aktiv."); } else { // Keine Verzögerung ODER überschrieben -> Normale PID-Logik bool pidWantsHeat = (OutputDampf > 0) && (now < (windowStartTimeDampf + (unsigned long)OutputDampf)); bool preventOverheatActive = (preventHeatAboveSetpointDampf && (InputDampf > SetpointDampf)) || (ecoModeAktiv && (InputDampf > SetpointDampf)); if (pidWantsHeat && !preventOverheatActive && !wartungsModusAktiv) { // Zusätzliche Prüfung auf Wartungsmodus hier digitalWrite(SSR_DAMPF_PIN, HIGH); } else { digitalWrite(SSR_DAMPF_PIN, LOW); } // PID muss normal weiterlaufen, auch wenn SSR aus ist, daher keine Output-Manipulation mehr hier nötig, // außer wenn explizit verzögert wird (siehe oben). } } yield(); #ifdef ESP32 // *** Brüh-Logik (Pre-Infusion, Brew-by-Time, Brew-by-Weight) *** if (shotActive) { unsigned long currentShotTimeMillis = millis() - shotStartTime; // --- Waage lesen --- #if (SCALE_TYPE == SCALE_I2C) if (scaleConnected) { currentWeightReading = scales.getWeight(); // Lese Rohwert aktiv nur für I2C } #endif // Für ESP-NOW wird currentWeightReading im Hintergrund aktualisiert. if (!scaleConnected) { // Gemeinsame Logik für "nicht verbunden" currentWeightReading = 0.0f; } // --- Stopp-Bedingungen prüfen --- float netWeight = currentWeightReading - brewStartWeight; // Berechne das Nettogewicht des Kaffees if (!firstWeightChangeDetected && scaleConnected && netWeight >= WEIGHT_CHANGE_THRESHOLD) { firstWeightChangeDetected = true; firstWeightChangeTime = millis(); } float targetWeightToStopAt = brewByWeightTargetGrams - brewByWeightOffsetGrams; bool stopForTime = brewByTimeEnabled && (currentShotTimeMillis >= (unsigned long)(brewByTimeTargetSeconds * 1000.0f)); bool stopForWeight = brewByWeightEnabled && scaleConnected && (netWeight >= targetWeightToStopAt); if (stopForTime || stopForWeight) { // if (stopForTime) Serial.println("Brew-By-Time Limit erreicht. Stoppe Bezug..."); // if (stopForWeight) Serial.printf("Brew-By-Weight Limit erreicht (Aktuell: %.1fg, Ziel: %.1fg). Stoppe Bezug...\n", currentWeightReading, targetWeightToStopAt); stopBrewSequence(currentShotTimeMillis, false, stopForWeight); // Übergibt die aktuelle Dauer und den Grund des Stopps } // --- Pre-Infusion Zustandsmaschine (Nur ausführen, wenn noch nicht gestoppt wurde) --- else if (preInfusionEnabled) { unsigned long currentPhaseTimeMillis = millis() - preInfusionPhaseStartTime; // Wechsel von Pre-Infusion zu Pause 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 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(); } } // Kein else hier -> Entweder Stopp, oder PreInfusion-Check, oder normaler Bezug ohne PreInfusion läuft weiter } #endif // ESP32 // Shot-Timer (Diese Funktion initiiert nur den Start oder stoppt bei manuellem Ende) updateShotTimer(); // Ruft Waage tarren auf, wenn der Shot beginnt #ifdef ESP32 if (flushActive) { bool shotInputActive = (digitalRead(SHOT_TIMER_PIN) == HIGH); if (shotActive || shotInputActive || !manualSwitchReleasedAfterAutoStop) { flushActive = false; } else if ((long)(millis() - flushEndTime) >= 0) { flushActive = false; } if (flushActive) { digitalWrite(PUMP_PIN, HIGH); digitalWrite(VALVE_PIN, HIGH); } else if (!shotActive) { digitalWrite(PUMP_PIN, LOW); digitalWrite(VALVE_PIN, LOW); } } #endif // 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); SetpointWasser = max(0, ecoModeTempWasser - minutesPassed); SetpointDampf = max(0, ecoModeTempDampf - minutesPassed); } else { SetpointWasser = ecoModeTempWasser; SetpointDampf = ecoModeTempDampf; } } else { if (ecoModeAktiv) { EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); } ecoModeAktiv = false; ecoModeActivatedTime = 0; } } else if (ecoModeAktiv) { ecoModeAktiv = false; ecoModeActivatedTime = 0; EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); } yield(); } else if (ecoModeAktiv) { ecoModeAktiv = false; ecoModeActivatedTime = 0; EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); } // Display regelmäßig updaten, wenn aktiviert / angeschlossen #ifdef ENABLE_DISPLAY updateDisplay(); #endif // ENABLE_DISPLAY // --- Waagen-spezifische Logik in der Hauptschleife --- #ifdef ESP32 #if (SCALE_TYPE == SCALE_I2C) // Logik nur für I2C-Waage if (scaleConnected) { handleScaleButtonPress(nullptr); } // Verzögertes Tarieren für I2C-Waage ausführen if (tareScaleAfterDelay && scaleConnected && (currentMillis - tareScaleDelayStartTime >= TARE_SCALE_DELAY_DURATION)) { scales.setOffset(); tareScaleAfterDelay = false; tareScaleSettling = true; tareScaleSettlingStartTime = currentMillis; } if (tareScaleSettling && scaleConnected && (currentMillis - tareScaleSettlingStartTime >= TARE_SCALE_SETTLING_DURATION)) { currentWeightReading = scales.getWeight(); tareScaleSettling = false; Serial.println(F("Waage nach Verzögerung tariert.")); if (piezoEnabled) { beepShort(2800, 50); } } #elif (SCALE_TYPE == SCALE_ESPNOW) // Logik nur für ESP-NOW-Waage: Timeout-Prüfung if (scaleConnected && (millis() - lastScaleMessageTime > SCALE_TIMEOUT)) { scaleConnected = false; scaleModeActive = false; // Modus ebenfalls zurücksetzen currentWeightReading = 0.0; Serial.println("WARNUNG: Verbindung zur ESP-NOW Waage verloren (Timeout)."); } #endif #endif // AsyncWebServer verarbeitet Clients im Hintergrund, kein handleClient erforderlich dashboardWs.cleanupClients(); dashboardWsTick(); #ifdef ESP8266 MDNS.update(); #endif yield(); // Betriebszeit zählen static unsigned long lastRuntimeSecond = 0; if (millis() - lastRuntimeSecond >= 1000) { totalRuntime++; lastRuntimeSecond = millis(); } // Speichern der Betriebszeit und WiFi-Check if (!demoModus) { unsigned long currentMillisLoop = millis(); unsigned long intervalToUse = !initialRuntimeSaveDone ? initialRuntimeSaveDelay : subsequentRuntimeSaveInterval; if (currentMillisLoop - lastRuntimeSave >= intervalToUse) { EEPROM.put(EEPROM_ADDR_RUNTIME, totalRuntime); EEPROM.commit(); lastRuntimeSave = currentMillisLoop; if (!initialRuntimeSaveDone) initialRuntimeSaveDone = true; if (WiFi.getMode() == WIFI_STA) checkWifiConnection(); } } yield(); // Zur Speicherung des Bezugszählers if (!shotActive) { handleShotSaving(nullptr); } yield(); } // Ende loop() /************************************************************************************ * Liest die Temperatur vom NTC-Sensor (Wasser) über den Spannungsteiler (ADC) * - Verwendet jetzt ANALOG_NTC_PIN für Plattformkompatibilität ************************************************************************************/ double readNTCTemperature() { double sensorVoltage = NAN; // Mit Fehlerwert initialisieren int adcValue = analogRead(ANALOG_NTC_PIN); #ifdef ESP32 // --- ESP32 Logik --- const double MAX_ADC_VALUE = 4095.0; // ESP32 Standard 12-bit // Prüfen auf ungültige ADC-Werte (ESP32) // Kann für Debugging auskommentiert werden - Kommentiert, damit Demo-Controller ohne permanente Meldungen aufgesetzt werden kann // if (adcValue <= 0 || adcValue >= MAX_ADC_VALUE) { // Serial.print("WARNUNG (ESP32): Ungültiger ADC-Wert vom NTC-Sensor: "); // Serial.println(adcValue); // return NAN; // Not-a-Number als Fehlerwert zurückgeben // } // Versuche kalibrierte Spannung zu erhalten (adc_chars muss vorher initialisiert sein!) uint32_t voltage_mv = esp_adc_cal_raw_to_voltage(adcValue, &adc_chars); if (voltage_mv > 0) { // Prüfen, ob Kalibrierung erfolgreich / plausibel war sensorVoltage = voltage_mv / 1000.0; // Kalibrierte Spannung nutzen // Serial.print("ESP32 Calibrated Voltage: "); // Serial.println(sensorVoltage); // Zum Debuggen } else { // Fallback auf lineare Berechnung, wenn Kalibrierung nicht geht/konfiguriert ist // Serial.println("WARNUNG (ESP32): ADC Kalibrierung nicht verfügbar/erfolgreich. Nutze lineare Schätzung."); // ADC_REF muss definiert sein (z.B. 3.3 für 3.3V Referenz) sensorVoltage = adcValue * (ADC_REF / MAX_ADC_VALUE); // Serial.print("ESP32 Linear Voltage: "); // Serial.println(sensorVoltage); // Zum Debuggen } // Optional: ADC-Konfiguration hier oder global setzen (z.B. Attenuation für vollen Range) // analogSetAttenuation(ADC_11db); // analogReadResolution(12); #else // ESP8266 Logik // --- ESP8266 Logik --- const double MAX_ADC_VALUE = 1023.0; // ESP8266 10-bit // Prüfen auf ungültige ADC-Werte (ESP8266) // Kann für Debugging auskommentiert werden - Kommentiert, damit Demo-Controller ohne permanente Meldungen aufgesetzt werden kann // if (adcValue <= 0 || adcValue >= MAX_ADC_VALUE) { // Serial.print("WARNUNG (ESP8266): Ungültiger ADC-Wert vom NTC-Sensor: "); // Serial.println(adcValue); // return NAN; // Not-a-Number als Fehlerwert zurückgeben // } // Lineare Berechnung für ESP8266 // WICHTIG: Stelle sicher, dass ADC_REF korrekt ist! // Wenn der Spannungsteiler mit V_SUPPLY (z.B. 3.3V) betrieben wird, sollte ADC_REF dieser Spannung entsprechen. // Siehe Kommentar im alten Code bzgl. interner 1.0V Referenz vs. externer Beschaltung. // Annahme hier: ADC_REF ist die Spannung bei MAX_ADC_VALUE für die externe Schaltung. sensorVoltage = adcValue * (ADC_REF / MAX_ADC_VALUE); // ADC_REF muss definiert sein (z.B. 3.3) // Serial.print("ESP8266 Linear Voltage: "); Serial.println(sensorVoltage); // Zum Debuggen #endif // --- Gemeinsame NTC-Berechnung & Fehlerprüfungen --- // Prüfen, ob sensorVoltage gültig berechnet wurde if (isnan(sensorVoltage)) { // Serial.println("FEHLER: sensorVoltage konnte nicht ermittelt werden."); return NAN; } // Vermeide Division durch Null oder ungültige Bedingungen if (sensorVoltage <= 0) { // <= 0 fängt auch den Fall ab, der R_ntc negativ machen würde // Serial.println("WARNUNG: NTC-Spannung ist 0 oder negativ nach Berechnung."); return NAN; } if (sensorVoltage >= V_SUPPLY) { // Serial.println("WARNUNG: NTC-Spannung entspricht Versorgungsspannung, R_ntc wäre unendlich."); return NAN; // Fehler } // NTC Widerstand berechnen double R_ntc = R_FIXED * ((V_SUPPLY / sensorVoltage) - 1.0); if (R_ntc <= 0) { // Sollte durch die Prüfungen oben eigentlich nicht mehr vorkommen, aber sicher ist sicher // Serial.print("WARNUNG: Berechneter NTC-Widerstand ungültig: "); // Serial.println(R_ntc); return NAN; // Fehler } // Temperatur über Beta-Gleichung berechnen double log_R_NTC_R0 = log(R_ntc / NTC_R0); // log() braucht positiven Wert, ist durch R_ntc > 0 sichergestellt double temp_inv_K = (1.0 / NTC_T0) + (1.0 / beta) * log_R_NTC_R0; if (temp_inv_K == 0) { // Vermeide Division durch Null // 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 // if (temperatureC < -50 || temperatureC > 200) { ... } return temperatureC; } /************************************************************************************ * Aktualisiert das Display (Temperaturanzeige, Shot-Timer, Wartung etc.) * Priorität: PID Tuning > Fast Heat Up > Aktiver Shot > Letzte Shot-Zeit > Wartung > Temp/Shutdown/Fehler * Verwendet eine Zustandsmaschine für die Anzeigen nach einem Shot. ************************************************************************************/ #ifdef ENABLE_DISPLAY void updateDisplay() { // ================================================================================= // HÖCHSTE PRIORITÄT: Spezielle Vollbild-Anzeigen, die alles andere überschreiben // ================================================================================= #ifdef ESP32 // --- Anzeige: Bezugsschalter nach Auto-Stopp zurückstellen --- if (!manualSwitchReleasedAfterAutoStop) { display.clearDisplay(); display.setTextSize(1); display.setTextColor(SH110X_WHITE); display.setCursor(0, 0); display.println(F("Brew Control:")); display.println(F("")); display.println(F("Bitte Bezugsschalter")); display.println(F("zur\201ckstellen.")); if (WiFi.status() == WL_CONNECTED) { drawwifiSymbol(113, 0); } display.display(); return; // WICHTIG: Funktion hier beenden } // --- Anzeige: Waage-Modus aktiv --- if (scaleModeActive) { display.clearDisplay(); display.setTextSize(1); display.setTextColor(SH110X_WHITE); display.setCursor(0, 0); display.println(F("Waage:")); display.println(F("")); display.setTextSize(3); display.setCursor(0, 20); if (scaleConnected) { // --- START ÄNDERUNG --- float weightToShow = currentWeightReading; // Verhindert die Anzeige von "-0.0" bei sehr kleinen negativen Werten if (weightToShow < 0.0 && weightToShow > -0.05) { weightToShow = 0.0; } char weightBuffer[10]; // Benutzen Sie die neue Variable für die Anzeige snprintf(weightBuffer, sizeof(weightBuffer), "%.1f g", weightToShow); // --- ENDE ÄNDERUNG --- int16_t x1, y1; uint16_t w, h; display.getTextBounds(weightBuffer, 0, 0, &x1, &y1, &w, &h); display.setCursor((display.width() - w) / 2, 20); display.println(weightBuffer); } else { display.setTextSize(1); display.setCursor(0, 20); display.println(F("Waage nicht")); display.setCursor(0, 30); display.println(F("verbunden!")); } display.setTextSize(1); if (WiFi.status() == WL_CONNECTED) { drawwifiSymbol(113, 0); } display.display(); return; // WICHTIG: Funktion hier beenden } #endif // ESP32 // --- Anzeige: Wartungsmodus aktiv --- 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); display.print(F("C gehalten.")); if (WiFi.status() == WL_CONNECTED) { drawwifiSymbol(113, 0); } display.display(); return; // Funktion hier beenden } // --- Anzeige: PID-Tuning aktiv --- 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(""); int currentPeakCount = 0; int currentPeakType = 0; if (autoTuneWasserActive && autoTuneWasser != nullptr) { currentPeakCount = autoTuneWasser->getPeakCount(); currentPeakType = autoTuneWasser->getPeakType(); } else if (autoTuneDampfActive && autoTuneDampf != nullptr) { currentPeakCount = autoTuneDampf->getPeakCount(); currentPeakType = autoTuneDampf->getPeakType(); } display.print(F("Cycle: ")); display.print( (currentPeakCount < 0 || currentPeakCount > 10) ? "0" : String(currentPeakCount) ); display.println(F(" von 5-10")); display.print(F("Phase: ")); if (currentPeakType == 1) { display.print(F("Heizen")); } else if (currentPeakType == -1) { display.print(F("K\201hlen")); } else { display.print(F("Init")); } display.println(); display.print("Temperatur:"); display.print((int)(autoTuneWasserActive ? InputWasser : InputDampf)); display.print(F(" ")); display.print((char)247); display.print(F("C")); if (WiFi.status() == WL_CONNECTED) { drawwifiSymbol(113, 0); } display.display(); return; // Funktion hier beenden } // ================================================================================= // STANDARD-ANZEIGEN-LOGIK // ================================================================================= display.clearDisplay(); if (WiFi.status() == WL_CONNECTED) { drawwifiSymbol(113, 0); } display.setCursor(0, 0); display.setTextColor(SH110X_WHITE); display.setTextSize(1); // --- Anzeige: Fast-Heat-Up aktiv --- if (fastHeatUpHeating && !shotActive && !wasserSensorError && ((int)InputWasser < fastHeatUpSetpoint)) { SetpointWasser = fastHeatUpSetpoint; display.println(F("Fast-Heat-Up aktiv:")); display.println(""); display.println(F("20 Sek. Flushen,")); display.println(F("wenn Temp. erreicht!")); display.println(F(" ")); display.println(F("Temperatur: ")); display.print((int)InputWasser); display.print(F(" / ")); display.print((int)SetpointWasser); display.print(F(" ")); display.print((char)247); display.print(F("C")); } else { // // --- ZUSTANDSMASCHINE FÜR ANZEIGEN NACH DEM SHOT --- // if (postShotDisplayState != POST_SHOT_IDLE) { switch (postShotDisplayState) { case POST_SHOT_SHOW_WEIGHT_RESULT: { // --- Werte berechnen --- float finalWeight = lastShotFinalNetWeight + brewByWeightOffsetGrams; float totalDurationSec = lastShotDurationMillis / 1000.0; float activeDurationSec = 0.0f; if (lastShotFirstWeightChangeTime > 0 && shotEndTime > lastShotFirstWeightChangeTime) { activeDurationSec = (shotEndTime - lastShotFirstWeightChangeTime) / 1000.0f; if (preInfusionEnabled) { unsigned long pauseStart = lastShotStartTime + (unsigned long)(preInfusionDurationSeconds * 1000.0f); if (lastShotFirstWeightChangeTime < pauseStart) { activeDurationSec -= preInfusionPauseSeconds; } } } else { activeDurationSec = totalDurationSec; if (preInfusionEnabled) { activeDurationSec -= preInfusionPauseSeconds; } } if (activeDurationSec < 0) activeDurationSec = 0; float flowRate = 0.0; if (activeDurationSec > 0) { flowRate = finalWeight / activeDurationSec; } // --- Buffer für die formatierten Strings --- char weightBuffer[12]; char totalTimeBuffer[12]; char activeTimeBuffer[12]; char flowBuffer[15]; // --- Werte in Strings formatieren --- snprintf(weightBuffer, sizeof(weightBuffer), "%.1f g", finalWeight); snprintf(totalTimeBuffer, sizeof(totalTimeBuffer), "%.1f s", totalDurationSec); snprintf(activeTimeBuffer, sizeof(activeTimeBuffer), "%.1f s", activeDurationSec); snprintf(flowBuffer, sizeof(flowBuffer), "%.2f g/s", flowRate); // --- Strukturiertes Layout mit allen Details --- display.setTextSize(1); // Titel anzeigen display.setCursor(0, 0); display.print("Bezug beendet"); // Layout-Positionen definieren const int LABEL_X = 0; const int VALUE_X = 58; const int Y_LINE_1 = 14; const int Y_LINE_2 = 24; const int Y_LINE_3 = 34; const int Y_LINE_4 = 44; // Zeile: Gewicht display.setCursor(LABEL_X, Y_LINE_1); display.print("Gewicht:"); display.setCursor(VALUE_X, Y_LINE_1); display.print(weightBuffer); // Zeile: Gesamtdauer display.setCursor(LABEL_X, Y_LINE_2); display.print("Gesamt:"); display.setCursor(VALUE_X, Y_LINE_2); display.print(totalTimeBuffer); // Zeile: Aktive Dauer (nur wenn Pre-Infusion aktiv war) if (preInfusionEnabled) { display.setCursor(LABEL_X, Y_LINE_3); display.print("Aktiv:"); display.setCursor(VALUE_X, Y_LINE_3); display.print(activeTimeBuffer); } // Zeile: Flow-Rate display.setCursor(LABEL_X, Y_LINE_4); display.print("Flow:"); display.setCursor(VALUE_X, Y_LINE_4); display.print(flowBuffer); // --- Übergangslogik zum nächsten Zustand (bleibt unverändert) --- if (millis() > shotEndTime + 5000) { // Anzeigezeit auf 5s erhöht für mehr Infos if (maintenanceInterval > 0 && maintenanceIntervalCounter >= maintenanceInterval) { postShotDisplayState = POST_SHOT_SHOW_MAINTENANCE; maintenanceMessageStartTime = millis(); #ifdef ESP32 if (piezoEnabled) { beepMaintenanceAlert(); } #endif } else { postShotDisplayState = POST_SHOT_IDLE; } } break; } case POST_SHOT_SHOW_DURATION: { // --- Zustand 2: Bezugsdauer anzeigen --- display.println(F("Bezugszeit:")); display.println(""); display.setTextSize(3); char buffer[6]; snprintf(buffer, sizeof(buffer), "%.1f", lastShotDurationMillis / 1000.0); display.println(buffer); display.setTextSize(1); display.println(F("Sekunden")); // Prüfen, ob 2 Sekunden um sind if (millis() > shotEndTime + 2000) { // Prüfen, ob eine Wartung fällig ist if (maintenanceInterval > 0 && maintenanceIntervalCounter >= maintenanceInterval) { // JA -> Gehe zu Zustand 2 (Wartung anzeigen) postShotDisplayState = POST_SHOT_SHOW_MAINTENANCE; maintenanceMessageStartTime = millis(); // Timer für Meldung JETZT starten #ifdef ESP32 if (piezoEnabled) { beepMaintenanceAlert(); // Piepton perfekt synchron zur Meldung auslösen! } #endif } else { // NEIN -> Sequenz beendet postShotDisplayState = POST_SHOT_IDLE; } } break; // Wichtig! } case POST_SHOT_SHOW_MAINTENANCE: { // --- Zustand 3: Wartungsmeldung anzeigen --- display.println(F("Erinnerung:")); display.println(F("")); display.println(F("Reinigung /")); display.println(F("Wartung")); // Prüfen, ob 6 Sekunden um sind if (millis() > maintenanceMessageStartTime + 6000) { displayMaintenanceMessage = false; // Flag für alle Fälle zurücksetzen postShotDisplayState = POST_SHOT_IDLE; // Sequenz beendet } break; // Wichtig! } } } // --- ENDE DER ZUSTANDSMASCHINE --- // Wenn die Zustandsmaschine nicht aktiv ist, zeige den laufenden Shot oder die Temperaturen else if (shotActive) { // Priorität: Shot läuft *aktuell*? #ifdef ESP32 if (brewByWeightEnabled && scaleConnected) { display.setTextSize(1); display.setCursor(0, 0); display.println(F("Bezug aktiv:")); unsigned long elapsed = millis() - shotStartTime; char timeBuffer[8]; snprintf(timeBuffer, sizeof(timeBuffer), "%.1fs", elapsed / 1000.0); display.setCursor(0, 15); display.println(timeBuffer); // --- START ÄNDERUNG --- float netWeight = currentWeightReading - brewStartWeight; // Verhindert die Anzeige von "-0.0" für das Nettogewicht if (netWeight < 0.0 && netWeight > -0.05) { netWeight = 0.0; } char weightBuffer[15]; snprintf(weightBuffer, sizeof(weightBuffer), "%.1f/%.1fg", netWeight, brewByWeightTargetGrams); // --- ENDE ÄNDERUNG --- display.setCursor(0, 30); display.println(weightBuffer); display.println(""); if(preInfusionEnabled && currentPreInfusionState == PI_PRE_BREW) {display.println(F("Pre-Infusion ..."));} if(preInfusionEnabled && currentPreInfusionState == PI_PAUSE) {display.println(F("Pause ..."));} if(preInfusionEnabled && currentPreInfusionState == PI_MAIN_BREW) {display.println(F("Pre-Infusion beendet."));} } else { #endif unsigned long elapsed = millis() - shotStartTime; display.println(F("Shot-Timer:")); display.println(""); display.setTextSize(3); char buffer[6]; snprintf(buffer, sizeof(buffer), "%.1f", elapsed / 1000.0); display.println(buffer); display.setTextSize(1); display.println(F("Sekunden")); #ifdef ESP32 if(preInfusionEnabled && currentPreInfusionState == PI_PRE_BREW) {display.println(F("Pre-Infusion ..."));} if(preInfusionEnabled && currentPreInfusionState == PI_PAUSE) {display.println(F("Pause ..."));} if(preInfusionEnabled && currentPreInfusionState == PI_MAIN_BREW) {display.println(F("Pre-Infusion beendet."));} } #endif fastHeatUpHeating = false; } else { // Standardanzeige: Temperaturen / Fehler / Sicherheitsabschaltung (Fallback, wenn nichts anderes zu tun ist) if (ecoInfoOnDisplay) { display.println(F("Wasser:")); if (wasserSafetyShutdown) { display.print(F("Sicherheitsabsch.")); } else if (wasserSensorError) { display.print(F("Sensorfehler!")); } else { display.print((int)InputWasser); display.print(F(" / ")); display.print((int)SetpointWasser); display.print(F(" ")); display.print((char)247); display.print(F("C")); if (ecoModeAktiv && dynamicEcoActive) display.print(F(" (ECO+)")); else if (ecoModeAktiv && !dynamicEcoActive) display.print(F(" (ECO)")); } display.println(""); display.println(""); display.println(F("Dampf:")); if (dampfSafetyShutdown) { display.print(F("Sicherheitsabsch.")); } else if (dampfSensorError) { display.print(F("Sensorfehler!")); } else { bool steamDelayActiveSystem = (dampfVerzoegerung > 0 && (millis() - startupTime < (unsigned long)dampfVerzoegerung * 60000UL)); bool shouldShowDelayMessage = steamDelayActiveSystem && !steamDelayOverridden; if (!shouldShowDelayMessage) { display.print((int)InputDampf); display.print(F(" / ")); display.print((int)SetpointDampf); display.print(F(" ")); display.print((char)247); display.print(F("C")); if (ecoModeAktiv && dynamicEcoActive) display.print(F(" (ECO+)")); else if (ecoModeAktiv && !dynamicEcoActive) display.print(F(" (ECO)")); } else { display.print(F("Startverz\x94gerung")); } } if (ecoModeAktiv) { display.println(""); display.println(""); display.print(F("Eco-Modus aktiv")); } else if (ecoModeMinutes > 0) { display.println(""); display.println(""); unsigned long elapsedMs = millis() - lastShotTime; unsigned long ecoMs = (unsigned long)ecoModeMinutes * 60UL * 1000UL; long remainingMs = (long)ecoMs - (long)elapsedMs; int remainingMin = 0; if (remainingMs > 0) { remainingMin = (int)((remainingMs + 60000UL - 1) / 60000UL); } display.print(F("Eco-Modus in ")); display.print(remainingMin); display.print(F("min")); } } else { display.println(F("Temperaturen:")); display.println(""); display.println(F("Wasser: ")); if (wasserSafetyShutdown) { display.print(F("Sicherheitsabsch.")); } else if (wasserSensorError) { display.print(F("Sensorfehler!")); } else { display.print((int)InputWasser); display.print(F(" / ")); display.print((int)SetpointWasser); display.print(F(" ")); display.print((char)247); display.print(F("C")); if (ecoModeAktiv && dynamicEcoActive) display.print(F(" (ECO+)")); else if (ecoModeAktiv && !dynamicEcoActive) display.print(F(" (ECO)")); } display.println(""); display.println(""); display.println(F("Dampf: ")); if (dampfSafetyShutdown) { display.print(F("Sicherheitsabsch.")); } else if (dampfSensorError) { display.print(F("Sensorfehler!")); } else { bool steamDelayActiveSystem = (dampfVerzoegerung > 0 && (millis() - startupTime < (unsigned long)dampfVerzoegerung * 60000UL)); bool shouldShowDelayMessage = steamDelayActiveSystem && !steamDelayOverridden; if (!shouldShowDelayMessage) { display.print((int)InputDampf); display.print(F(" / ")); display.print((int)SetpointDampf); display.print(F(" ")); display.print((char)247); display.print(F("C")); if (ecoModeAktiv && dynamicEcoActive) display.print(F(" (ECO+)")); else if (ecoModeAktiv && !dynamicEcoActive) display.print(F(" (ECO)")); } else { display.print(F("Startverz\x94gerung")); } } } } } yield(); display.display(); // Display *immer* am Ende aktualisieren } #endif // ENABLE_DISPLAY /************************************************************************************ * Verwaltet den Shot-Timer basierend auf dem physischen Schalter. * Berücksichtigt den Status 'manualSwitchReleasedAfterAutoStop', um ein * sofortiges Neustarten nach automatischem Stopp zu verhindern. * Initiiert Pre-Infusion (ESP32). * Ruft stopBrewSequence auf (ESP32) für manuelle Stopps. ************************************************************************************/ void updateShotTimer() { bool shotInputActive = (digitalRead(SHOT_TIMER_PIN) == HIGH); // --- Shot beginnt (Eingang ist HIGH) --- if (shotInputActive && !shotActive && manualSwitchReleasedAfterAutoStop) { // Prüfen, ob die Dampfverzögerung per Schalter übersprungen werden soll if (steamDelayOverrideBySwitchEnabled) { steamDelayOverridden = true; // Serial.println("INFO: Dampfverzögerung wird wegen Bezugsschalter-Aktivierung übersprungen."); } #ifdef ESP32 // Sicherstellen, dass dies nur den ESP32 betrifft scaleModeActive = false; // Manuellen Waage-Anzeigemodus beim Start des Bezugs beenden #endif shotActive = true; shotStartTime = millis(); // Startzeit für interne Logik (z.B. Dauer), aber kein Logging/Zählen im Wartungsmodus lastShotDurationMillis = 0; shotEndTime = 0; firstWeightChangeDetected = false; firstWeightChangeTime = 0; if (!wartungsModusAktiv) { // Nur ausführen, wenn NICHT im Wartungsmodus displayMaintenanceMessage = false; // Wartungsmeldung ausblenden // Eco Modus direkt beenden, falls aktiv if (ecoModeAktiv) { ecoModeAktiv = false; ecoModeActivatedTime = 0; // Normale Sollwerte wiederherstellen (wichtig!) EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); pidWasser.SetMode(AUTOMATIC); // Sicherstellen, dass PID läuft pidDampf.SetMode(AUTOMATIC); } } #ifdef ESP32 if (!wartungsModusAktiv) { #if (SCALE_TYPE == SCALE_I2C) // Waage nur bei I2C-Typ automatisch auf Null setzen if (scaleConnected) { scales.setOffset(); delay(50); currentWeightReading = scales.getWeight(); } #else // "Software-Tara" für ESP-NOW-Waage: Speichere das aktuelle Gewicht als Startpunkt für die Differenzrechnung brewStartWeight = currentWeightReading; #endif // Pre-Infusion oder direkten Start behandeln if (preInfusionEnabled) { 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 { 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 } } else { // Im Wartungsmodus: Pumpe und Ventil direkt schalten digitalWrite(PUMP_PIN, HIGH); digitalWrite(VALVE_PIN, HIGH); // Annahme: Ventil soll ZU sein, während Pumpe läuft (wie bei Bezug) // Ggf. anpassen, falls für Reinigung das Ventil offen sein soll, während Pumpe läuft. // Serial.println("Wartungsmodus: Pumpe AN, Ventil ZU (Bezugsschalter)"); } #endif // ESP32 } // --- Shot endet (Eingang ist LOW - manuelles Stoppen) --- else if (!shotInputActive && shotActive) { #ifdef ESP32 unsigned long shotDuration = millis() - shotStartTime; if (!wartungsModusAktiv) { // Normale Stopp-Sequenz nur wenn NICHT im Wartungsmodus stopBrewSequence(shotDuration, true, false); // true für manual stop, false für "nicht durch Gewicht gestoppt" } else { // Im Wartungsmodus: Pumpe und Ventil direkt schalten, keine Statistik etc. digitalWrite(PUMP_PIN, LOW); digitalWrite(VALVE_PIN, LOW); // Ventil ÖFFNEN zum Druck ablassen/Spülen beenden // Serial.println("Wartungsmodus: Pumpe AUS, Ventil AUF (Bezugsschalter losgelassen)"); // Für den Wartungsmodus werden keine Shot-Daten geloggt oder Zähler erhöht. // lastShotDurationMillis und shotEndTime könnten hier optional gesetzt werden, // wenn man die Dauer im Display anzeigen wollte, aber es wird nicht geloggt. lastShotDurationMillis = shotDuration; shotEndTime = millis(); } #else // ESP8266 - Logik (...) if (!wartungsModusAktiv) { unsigned long shotDuration = millis() - shotStartTime; lastShotDurationMillis = shotDuration; shotEndTime = millis(); postShotDisplayState = POST_SHOT_SHOW_DURATION; if (shotDuration > 20000) { shotNeedsToBeSaved = true; savedShotDuration = shotDuration; } lastShotTime = millis(); fastHeatUpHeating = false; } #endif // ESP32 / ESP8266 shotActive = false; // Wichtig, um den Zustand zurückzusetzen (für beide Modi) // Logik für manualSwitchReleasedAfterAutoStop bleibt wichtig if (!manualSwitchReleasedAfterAutoStop) { manualSwitchReleasedAfterAutoStop = true; } } // --- Behandelt den Fall, dass der Schalter losgelassen wird, *nachdem* der Shot bereits automatisch gestoppt wurde --- else if (!shotInputActive && !shotActive && !manualSwitchReleasedAfterAutoStop) { manualSwitchReleasedAfterAutoStop = true; shotEndTime = millis(); } // --- Optional: Wenn Schalter gehalten wird, aber System gesperrt ist (nach Auto-Stopp) --- else if (shotInputActive && !shotActive && !manualSwitchReleasedAfterAutoStop) { // Hier passiert nichts, der Timer startet nicht. #ifdef ESP32 // Sicherstellen, dass Pumpe/Ventil aus bleiben, wenn System gesperrt ist digitalWrite(PUMP_PIN, LOW); digitalWrite(VALVE_PIN, LOW); #endif } } /************************************************************************************ * 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(AsyncWebServerRequest *request) { (void)request; // --- 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 // --- Angepasste PROGMEM Chunks für die Brew Control Seite mit Toggle Switches --- // Head und Body Start bleiben gleich 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

Steuert das automatische Beenden des Brühvorgangs basierend auf Zeit oder Gewicht. Wenn beide aktiviert sind, stoppt der Bezug, sobald die erste Bedingung (Zeit oder Gewicht) erreicht ist. Der Bezug kann aber weiterhin manuell über den Bezugs-Schalter beendet werden.
Ebenso kann eine Pre-Infusion aktiviert und eingestellt werden.

)rawliteral"; // --- Brew-By-Time (BBT) Chunks --- static const char brewControlBBT_H3[] PROGMEM = R"rawliteral(

Brew-By-Time

)rawliteral"; static const char brewControlBBT_ToggleContainerStart[] PROGMEM = R"rawliteral(
Aktivieren:
)rawliteral"; // Schließt Input, Span, Label, Div static const char brewControlBBT_Fields[] PROGMEM = R"rawliteral( (Stoppt nach dieser Zeit)
)rawliteral"; // Beschreibung hat jetzt toggle-description Klasse // --- Brew-By-Weight (BBW) Chunks --- static const char brewControlBBW_H3[] PROGMEM = R"rawliteral(

Brew-By-Weight

)rawliteral"; static const char brewControlBBW_ToggleContainerStart[] PROGMEM = R"rawliteral(
Aktivieren:
)rawliteral"; // Schließt Input, Span, Label, Div static const char brewControlBBW_Fields[] PROGMEM = R"rawliteral( (Stoppt x Gramm früher) {SCALE_STATUS_MSG}
)rawliteral"; // --- Pre-Infusion (PI) Chunks --- static const char brewControlPI_H3[] PROGMEM = R"rawliteral(

Pre-Infusion

)rawliteral"; static const char brewControlPI_ToggleContainerStart[] PROGMEM = R"rawliteral(
Aktivieren:
)rawliteral"; // Schließt Input, Span, Label, Div static const char brewControlPI_Fields[] PROGMEM = R"rawliteral( (Pumpe AN -> Pumpe AUS -> Pumpe AN)
)rawliteral"; // Formular Ende bleibt gleich static const char brewControlFormEnd[] PROGMEM = R"rawliteral(
)rawliteral"; // --- Ende der PROGMEM Chunks --- // Handler für die Brew Control Seite (GET /Brew-Control) - Mit Toggle Switches void handleBrewControl(AsyncWebServerRequest *request) { char buffer[10]; // Puffer für Zahlen AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8"); response->print(FPSTR(brewControlHead)); response->print(FPSTR(commonStyle)); // Dein globaler Style mit den Toggle-CSS-Regeln response->print(FPSTR(brewControlHeadEnd)); response->print(FPSTR(commonNav)); response->print(FPSTR(brewControlBodyStart)); yield(); // --- Brew-By-Time Sektion --- response->print(FPSTR(brewControlBBT_H3)); response->print(FPSTR(brewControlBBT_ToggleContainerStart)); // Beginnt Toggle bis vor 'checked' if (brewByTimeEnabled) { response->print(F(" checked")); // Fügt 'checked' hinzu, wenn aktiv } response->print(FPSTR(brewControlBBT_ToggleContainerEnd)); // Schließt den Toggle HTML-Teil // Felder für BBT rendern String bbtFieldsHtml = FPSTR(brewControlBBT_Fields); snprintf(buffer, sizeof(buffer), "%.1f", brewByTimeTargetSeconds); bbtFieldsHtml.replace("{BBT_SECS}", buffer); response->print(bbtFieldsHtml); yield(); // --- Brew-By-Weight Sektion --- response->print(FPSTR(brewControlBBW_H3)); response->print(FPSTR(brewControlBBW_ToggleContainerStart)); // Beginnt Toggle bis vor 'checked'/'disabled' if (brewByWeightEnabled) { response->print(F(" checked")); // Fügt 'checked' hinzu, wenn aktiv } if (!scaleConnected) { response->print(F(" disabled")); // Fügt 'disabled' hinzu, wenn Waage nicht verbunden } response->print(FPSTR(brewControlBBW_ToggleContainerEnd)); // Schließt den Toggle HTML-Teil // Felder für BBW rendern String bbwFieldsHtml = FPSTR(brewControlBBW_Fields); snprintf(buffer, sizeof(buffer), "%.1f", brewByWeightTargetGrams); bbwFieldsHtml.replace("{BBW_TARGET}", buffer); snprintf(buffer, sizeof(buffer), "%.1f", brewByWeightOffsetGrams); bbwFieldsHtml.replace("{BBW_OFFSET}", buffer); // Deaktiviere Felder und zeige Statusmeldung, wenn Waage nicht verbunden ist if (!scaleConnected) { bbwFieldsHtml.replace("{SCALE_DISABLED}", "disabled"); bbwFieldsHtml.replace("{SCALE_STATUS_MSG}", "Waage nicht verbunden!
"); } else { bbwFieldsHtml.replace("{SCALE_DISABLED}", ""); bbwFieldsHtml.replace("{SCALE_STATUS_MSG}", ""); // Keine Meldung wenn ok } response->print(bbwFieldsHtml); yield(); // --- Pre-Infusion Sektion --- response->print(FPSTR(brewControlPI_H3)); response->print(FPSTR(brewControlPI_ToggleContainerStart)); // Beginnt Toggle bis vor 'checked' if (preInfusionEnabled) { response->print(F(" checked")); // Fügt 'checked' hinzu, wenn aktiv } response->print(FPSTR(brewControlPI_ToggleContainerEnd)); // Schließt den Toggle HTML-Teil // Felder für PI rendern String piFieldsHtml = FPSTR(brewControlPI_Fields); snprintf(buffer, sizeof(buffer), "%.1f", preInfusionDurationSeconds); piFieldsHtml.replace("{PI_DUR_SECS}", buffer); snprintf(buffer, sizeof(buffer), "%.1f", preInfusionPauseSeconds); piFieldsHtml.replace("{PI_PAUSE_SECS}", buffer); response->print(piFieldsHtml); yield(); // Formular Ende response->print(FPSTR(brewControlFormEnd)); request->send(response); } /************************************************************************************ * Handler zum Speichern der Brew Control Einstellungen (POST /saveBrewControl) - Nur ESP32 ************************************************************************************/ void handleSaveBrewControl(AsyncWebServerRequest *request) { bool changed = false; // Brew-By-Time Auswerten (Unverändert, verwendet float) bool newBBTEnabled = request->hasArg("bbtEnabled"); if (newBBTEnabled != brewByTimeEnabled) { brewByTimeEnabled = newBBTEnabled; EEPROM.put(EEPROM_ADDR_BREWBYTIME_ENABLED, brewByTimeEnabled); changed = true; } if (request->hasArg("bbtSecs")) { float newBBTSecs = request->arg("bbtSecs").toFloat(); if (newBBTSecs >= 5.0f && newBBTSecs <= 120.0f) { if (abs(newBBTSecs - brewByTimeTargetSeconds) > 0.01f) { brewByTimeTargetSeconds = newBBTSecs; EEPROM.put(EEPROM_ADDR_BREWBYTIME_SECONDS, brewByTimeTargetSeconds); changed = true; } } } // *** Brew-By-Weight Auswerten *** bool newBBWEnabled = request->hasArg("bbwEnabled"); if (newBBWEnabled != brewByWeightEnabled) { brewByWeightEnabled = newBBWEnabled; EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_ENABLED, brewByWeightEnabled); changed = true; } if (request->hasArg("bbwTarget")) { float newBBWTarget = request->arg("bbwTarget").toFloat(); if (newBBWTarget >= 10.0f && newBBWTarget <= 70.0f) { if (abs(newBBWTarget - brewByWeightTargetGrams) > 0.01f) { brewByWeightTargetGrams = newBBWTarget; EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_TARGET, brewByWeightTargetGrams); changed = true; } } } if (request->hasArg("bbwOffset")) { float newBBWOffset = request->arg("bbwOffset").toFloat(); if (newBBWOffset >= 0.0f && newBBWOffset <= 10.0f) { if (abs(newBBWOffset - brewByWeightOffsetGrams) > 0.01f) { brewByWeightOffsetGrams = newBBWOffset; EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_OFFSET, brewByWeightOffsetGrams); changed = true; } } } // Pre-Infusion Auswerten (Unverändert, verwendet float) bool newPIEnabled = request->hasArg("piEnabled"); if (newPIEnabled != preInfusionEnabled) { preInfusionEnabled = newPIEnabled; EEPROM.put(EEPROM_ADDR_PREINF_ENABLED, preInfusionEnabled); changed = true; } if (request->hasArg("piDurSecs")) { float newPIDur = request->arg("piDurSecs").toFloat(); if (newPIDur >= 0.5f && newPIDur <= 20.0f) { if(abs(newPIDur - preInfusionDurationSeconds) > 0.01f) { preInfusionDurationSeconds = newPIDur; EEPROM.put(EEPROM_ADDR_PREINF_DUR_SEC, preInfusionDurationSeconds); changed = true; } } } if (request->hasArg("piPauseSecs")) { float newPIPause = request->arg("piPauseSecs").toFloat(); if (newPIPause >= 0.0f && newPIPause <= 20.0f) { if(abs(newPIPause - preInfusionPauseSeconds) > 0.01f) { preInfusionPauseSeconds = newPIPause; EEPROM.put(EEPROM_ADDR_PREINF_PAUSE_SEC, preInfusionPauseSeconds); changed = true; } } } if (changed) { EEPROM.commit(); } AsyncWebServerResponse *response = request->beginResponse(303); response->addHeader("Location", "/Brew-Control"); request->send(response); } #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:

Betriebszeit gesamt:
)rawliteral"; // Endet vor formatierter Betriebszeit static const char infoChunk_AfterRuntime[] PROGMEM = R"rawliteral(

Betriebszeit seit Einschalten:
)rawliteral"; static const char infoChunk_AfterCurrentRuntime[] 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 // 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) // 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_MacAddress[] PROGMEM = R"rawliteral(
MAC-Adresse (WLAN): )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
Nutzbarer 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 ************************************************************************************/ void handleInfo(AsyncWebServerRequest *request) // URL: /Info { AsyncResponseStream *response = request->beginResponseStream(F("text/html")); response->setCode(200); 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 // --- Kopfzeile, Styles, Navigation senden --- response->print(FPSTR(infoHtmlHead)); response->print(FPSTR(commonStyle)); // Globale Styles nicht vergessen response->print(FPSTR(infoHtmlHeadEnd)); response->print(FPSTR(commonNav)); // Globale Navigation nicht vergessen yield(); // --- Geräteinfo Formular --- response->print(FPSTR(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) { response->print(tempStringCopyBuffer); } // Wert einfügen response->print(FPSTR(infoChunk_AfterHersteller)); // Label+Input für Modell yield(); strncpy(tempStringCopyBuffer, infoModell, sizeof(tempStringCopyBuffer) - 1); tempStringCopyBuffer[sizeof(tempStringCopyBuffer) - 1] = '\0'; if (strlen(tempStringCopyBuffer) > 0) { response->print(tempStringCopyBuffer); } // Wert einfügen response->print(FPSTR(infoChunk_AfterModell)); // Label+Input für Zusatz yield(); strncpy(tempStringCopyBuffer, infoZusatz, sizeof(tempStringCopyBuffer) - 1); tempStringCopyBuffer[sizeof(tempStringCopyBuffer) - 1] = '\0'; if (strlen(tempStringCopyBuffer) > 0) { response->print(tempStringCopyBuffer); } // Wert einfügen response->print(FPSTR(infoChunk_AfterZusatz)); // Ende Geräteinfo-Form yield(); // --- Betriebszeit Reset Formular --- response->print(FPSTR(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); response->print(buffer); response->print(F(" Tag(e), ")); snprintf(buffer, sizeof(buffer), "%lu", hours); response->print(buffer); response->print(F(" Stunde(n) und ")); snprintf(buffer, sizeof(buffer), "%lu", minutes); response->print(buffer); response->print(F(" Minuten")); response->print(FPSTR(infoChunk_AfterRuntime)); unsigned long currentSeconds = millis() / 1000UL; unsigned long currentDays = currentSeconds / 86400UL; unsigned long currentHours = (currentSeconds % 86400UL) / 3600UL; unsigned long currentMinutes = (currentSeconds % 3600UL) / 60UL; snprintf(buffer, sizeof(buffer), "%lu", currentDays); response->print(buffer); response->print(F(" Tag(e), ")); snprintf(buffer, sizeof(buffer), "%lu", currentHours); response->print(buffer); response->print(F(" Stunde(n) und ")); snprintf(buffer, sizeof(buffer), "%lu", currentMinutes); response->print(buffer); response->print(F(" Minuten")); response->print(FPSTR(infoChunk_AfterCurrentRuntime)); // Ende Betriebszeit-Reset-Form yield(); // --- Shot Zähler Reset Formular --- response->print(FPSTR(infoChunk_ShotCounterResetForm)); // Start Shot-Reset-Form, endet vor Zähler-Wert snprintf(buffer, sizeof(buffer), "%lu", shotCounter); // Wert einfügen response->print(buffer); response->print(FPSTR(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) { response->print(FPSTR(infoChunk_StatsStart)); // Start des Statistik-Blocks (

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

Bezüge Heute: " snprintf(buffer, sizeof(buffer), "%lu", stats.shotsToday); response->print(buffer); yield(); response->print(FPSTR(infoChunk_StatsYesterday)); // "
Bezüge Gestern: " snprintf(buffer, sizeof(buffer), "%lu", stats.shotsYesterday); response->print(buffer); yield(); response->print(FPSTR(infoChunk_StatsWeek)); // "
Bezüge in dieser Woche: " snprintf(buffer, sizeof(buffer), "%lu", stats.shotsThisWeek); response->print(buffer); yield(); response->print(FPSTR(infoChunk_StatsLastWeek)); // "
Bezüge Letzte Woche (Mo-So): " snprintf(buffer, sizeof(buffer), "%lu", stats.shotsLastWeek); response->print(buffer); yield(); response->print(FPSTR(infoChunk_StatsMonth)); // "
Bezüge in diesem Monat: " snprintf(buffer, sizeof(buffer), "%lu", stats.shotsThisMonth); response->print(buffer); yield(); response->print(FPSTR(infoChunk_StatsLastMonth)); // "
Bezüge Letzter Monat: " snprintf(buffer, sizeof(buffer), "%lu", stats.shotsLastMonth); response->print(buffer); yield(); // Nach mehreren Ausgaben // Ersten und letzten Shot anzeigen, falls vorhanden (wie bisher) if (stats.firstShotTimestamp > 0) { response->print(FPSTR(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 response->print(dateBuffer); // Wert senden response->print(F(" Uhr")); yield(); } if (stats.lastShotTimestamp > 0) { response->print(FPSTR(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 response->print(dateBuffer); // Wert senden response->print(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 response->print(FPSTR(infoChunk_StatsTimeError)); // Sendet den Hinweis } // Download Button hinzufügen (wie bisher) response->print(FPSTR(infoChunk_StatsDownloadButton)); response->print(FPSTR(infoChunk_StatsEnd)); // Ende des Statistik-Blocks (

) yield(); } else { // Alternative Meldung, falls keine Statistik verfügbar (wie bisher) response->print(FPSTR(infoChunk_StatsNotAvailable)); } // --- Ende Statistikbereich --- yield(); response->print(FPSTR(infoChunk_DeleteStatsForm)); // --- Systeminfo (mit plattformabhängigen Teilen) --- response->print(FPSTR(infoChunk_SysInfoStart)); // Start Systeminfo
, endet vor Heap-Wert snprintf(buffer, sizeof(buffer), "%u", ESP.getFreeHeap()); response->print(buffer); // Heap-Wert senden response->print(FPSTR(infoChunk_AfterFreeHeap)); // Sendet " Bytes
NTP Zeit synchronisiert: " yield(); // NTP Status senden (Ja/Nein) response->print(timeSynced ? F("Ja") : F("Nein")); response->print(FPSTR(infoChunk_AfterNTPStatus)); // Sendet "
SDK-Version: " yield(); // --- SDK Version --- const char* sdkVersion = ESP.getSdkVersion(); if (sdkVersion && strlen(sdkVersion) > 0) { response->print(sdkVersion); } else { response->print(F("N/A")); } // --- Core Version --- response->print(FPSTR(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) response->print(tempStringCopyBuffer); // --- Boot Version (Plattformabhängig) --- response->print(FPSTR(infoChunk_AfterCore)); // "
Boot-Version: " #ifdef ESP32 response->print(F("N/A")); // ESP32 hat keine getBootVersion() #else // ESP8266 snprintf(buffer, sizeof(buffer), "%u", ESP.getBootVersion()); response->print(buffer); #endif // --- Chip ID (Plattformabhängig) --- response->print(FPSTR(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 response->print(buffer); // Formatierten Chip-ID Wert senden // MAC-Adresse SICHER ausgeben response->print(FPSTR(infoChunk_MacAddress)); // Label "MAC-Adresse (WLAN): " senden if (WiFi.status() == WL_CONNECTED) { response->print(WiFi.macAddress()); // Die MAC-Adresse als String senden } else { response->print(F("N/A (nicht verbunden)")); } // --- CPU Takt --- response->print(FPSTR(infoChunk_AfterChipID)); // "
CPU-Takt: " snprintf(buffer, sizeof(buffer), "%u", ESP.getCpuFreqMHz()); response->print(buffer); // --- Reset Grund (Plattformabhängig & KORRIGIERT) --- response->print(FPSTR(infoChunk_AfterCPU)); // " MHz
Letzter Reset-Grund: " #ifdef ESP32 esp_reset_reason_t reason = esp_reset_reason(); switch (reason) { case ESP_RST_UNKNOWN: response->print(F("Unbekannt")); break; case ESP_RST_POWERON: response->print(F("Power On")); break; case ESP_RST_EXT: response->print(F("External Pin")); break; case ESP_RST_SW: response->print(F("Software")); break; case ESP_RST_PANIC: response->print(F("Panic/Exception")); break; case ESP_RST_INT_WDT: response->print(F("Interrupt Watchdog")); break; case ESP_RST_TASK_WDT: response->print(F("Task Watchdog")); break; case ESP_RST_WDT: response->print(F("Other Watchdog")); break; case ESP_RST_DEEPSLEEP: response->print(F("Deep Sleep Wakeup")); break; case ESP_RST_BROWNOUT: response->print(F("Brownout")); break; case ESP_RST_SDIO: response->print(F("SDIO")); break; default: // *** KORREKTUR: String-Verknüpfung vermeiden *** response->print(F("Anderer Grund (")); // Teil 1 response->print(String(reason)); // Teil 2 (Grund als Zahl) response->print(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) response->print(tempStringCopyBuffer); #endif // --- Sketch Größe --- response->print(FPSTR(infoChunk_AfterResetReason)); // "

Sketch-Größe: " snprintf(buffer, sizeof(buffer), "%u", ESP.getSketchSize()); response->print(buffer); yield(); // --- Freier Sketch Speicher --- response->print(FPSTR(infoChunk_AfterSketchSize)); // " Bytes
Freier Sketch-Speicher: " snprintf(buffer, sizeof(buffer), "%u", ESP.getFreeSketchSpace()); response->print(buffer); // --- Flash Größe --- response->print(FPSTR(infoChunk_AfterFreeSketch)); // " Bytes
Flash-Größe (Chip): " snprintf(buffer, sizeof(buffer), "%u", ESP.getFlashChipSize()); response->print(buffer); yield(); // Vor dem letzten Chunk response->print(FPSTR(infoChunk_End)); // Ende yield(); request->send(response); } /************************************************************************************ * PROGMEM Chunks für die Service-Seite (/Service) ************************************************************************************/ // --- Kopfzeile --- static const char serviceHtmlHead[] PROGMEM = R"rawliteral( Service )rawliteral"; // bleibt gleich static const char serviceHtmlHeadEnd[] PROGMEM = R"rawliteral( )rawliteral"; static const char serviceChunk_BodyStart[] PROGMEM = R"rawliteral(

Service & Wartung

)rawliteral"; // --- Systemtöne / Piezo --- static const char serviceChunk_PiezoToggleStart[] PROGMEM = R"rawliteral(

Systemtöne

Aktivieren:
Aktiviert oder deaktiviert die akustischen Signale des Piezo-Lautsprechers (falls angeschlossen).
Hierdurch werden bei Sensorfehlern, Sicherheitsabschaltung, Übergang in den Eco-Modus, ... Hinweistöne ausgegeben.
)rawliteral"; // --- Wartung Intervall Formular (ehemals infoChunk_MaintenanceForm) --- static const char serviceChunk_MaintenanceForm[] PROGMEM = R"rawliteral(

Reinigung & Wartung

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

Wartungsmodus (Entkalkung)

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

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

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

Wartungszähler zurücksetzen

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

Setzt den Zähler für die Reinigungs- & Wartungserinnerung zurück.
Dies ist nach der erfolgreichen Durchführung notwendig,
damit die Meldung nicht nach jedem Bezug erneut scheint.
)rawliteral"; // Ende Wartungszähler-Reset-Formular static const char serviceChunk_BodyEnd[] PROGMEM = R"rawliteral( )rawliteral"; /************************************************************************************ * Handler für die Service-Seite (/Service) ************************************************************************************/ void handleService(AsyncWebServerRequest *request) { AsyncResponseStream *response = request->beginResponseStream("text/html"); char buffer[20]; // Puffer für Zahlen // --- Kopfzeile, Styles, Navigation senden --- response->print(FPSTR(serviceHtmlHead)); response->print(FPSTR(commonStyle)); // Globale Styles nicht vergessen response->print(FPSTR(serviceHtmlHeadEnd)); response->print(FPSTR(commonNav)); // Globale Navigation nicht vergessen yield(); // --- Body Start --- response->print(FPSTR(serviceChunk_BodyStart)); yield(); // --- Systemtöne / Piezo --- response->print(FPSTR(serviceChunk_PiezoToggleStart)); // Bis vor 'checked' if (piezoEnabled) { response->print(F(" checked")); // Fügt 'checked' hinzu, wenn aktiv } response->print(FPSTR(serviceChunk_PiezoToggleEnd)); // Schließt den Toggle HTML-Teil yield(); // --- Wartung Intervall Formular --- response->print(FPSTR(serviceChunk_MaintenanceForm)); // Start Wartungs-Form, endet vor Intervall-Wert snprintf(buffer, sizeof(buffer), "%d", maintenanceInterval); response->print(buffer); // Wert einfügen response->print(FPSTR(serviceChunk_AfterMaintInterval)); // Ende Wartungs-Form yield(); // --- Wartungsmodus Sektion --- response->print(FPSTR(serviceChunk_WartungStart)); // Start der Sektion if (wartungsModusAktiv) { response->print(F("Aktiv")); // Zeige "Aktiv" } else { response->print(F("Inaktiv")); // Zeige "Inaktiv" } yield(); response->print(FPSTR(serviceChunk_WartungButton)); // Bis vor Button-Text yield(); if (wartungsModusAktiv) { response->print(F("Deaktivieren")); // Button zum Deaktivieren } else { response->print(F("Aktivieren")); // Button zum Aktivieren } yield(); response->print(FPSTR(serviceChunk_WartungEnd)); // Rest des Formulars yield(); // --- Wartungszähler Reset Formular --- response->print(FPSTR(serviceChunk_MaintenanceResetForm)); // Start Zähler-Reset-Form, endet vor Zähler-Wert snprintf(buffer, sizeof(buffer), "%lu", maintenanceIntervalCounter); response->print(buffer); // Wert einfügen response->print(FPSTR(serviceChunk_AfterMaintCounter)); // Ende Zähler-Reset-Form yield(); // --- Body Ende --- response->print(FPSTR(serviceChunk_BodyEnd)); yield(); request->send(response); } /************************************************************************************ * Handler für die Eco-Modus-Konfiguration - Heap-Optimiert mit Chunked Sending ************************************************************************************/ // --- Angepasste PROGMEM Chunks für handleEco mit neuem Toggle Switch --- // Head und Body Start bleiben gleich static const char ecoHtmlHead[] PROGMEM = R"rawliteral( Eco-Modus )rawliteral"; static const char ecoHtmlHeadEnd[] PROGMEM = R"rawliteral( )rawliteral"; 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

)rawliteral"; // Chunk: Der Toggle Switch Container für Dynamic Eco (Start) static const char ecoChunk_ToggleDynEco_Start[] PROGMEM = R"rawliteral(
Dynamischen Eco-Modus aktivieren (ECO+):
)rawliteral"; // Schließt Input, Span, Label, Div // Chunk: Beschreibung für Dynamic Eco und Start der Dampfverzögerung static const char ecoChunk_DynEcoDesc_And_SteamDelay[] PROGMEM = R"rawliteral(
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

)rawliteral"; static const char ecoChunk_SteamOverrideToggle_Start[] PROGMEM = R"rawliteral(
Aufheizverzögerung per Bezugsschalter überspringen:
Wenn aktiviert, startet das Heizen des Dampfkreislaufs sofort, sobald der Bezugsschalter betätigt wird, und ignoriert die eingestellte Verzögerung. )rawliteral"; static const char ecoChunk_DisplayInfoToggle_Start[] PROGMEM = R"rawliteral(

Display

Eco-Info auf dem Display anzeigen:
Zeigt im Idle-Display die Restzeit bis zur Eco-Aktivierung an. )rawliteral"; static const char ecoChunk_FinalWarningAndSubmit[] PROGMEM = R"rawliteral(

ACHTUNG:
Um Konflikte zu vermeiden, sollte der Wert der Verzögerung geringer sein, als der des Eco-Modus, falls dieser aktiviert ist!

)rawliteral"; // --- Vollständig überarbeitete Funktion handleEco --- void handleEco(AsyncWebServerRequest *request) // URL: /ECO { AsyncResponseStream *response = request->beginResponseStream("text/html"); char buffer[12]; // Puffer für Zahlenumwandlungen // Kopfzeile senden response->print(FPSTR(ecoHtmlHead)); response->print(FPSTR(commonStyle)); // Globaler Style mit Toggle-CSS response->print(FPSTR(ecoHtmlHeadEnd)); response->print(FPSTR(commonNav)); // Navigation // Body Start bis Eco Mode Wert response->print(FPSTR(ecoChunk_BodyStart)); snprintf(buffer, sizeof(buffer), "%d", ecoModeMinutes); // Eco Mode Wert (int) response->print(buffer); yield(); // Nach Eco Mode bis Eco Temp Wasser response->print(FPSTR(ecoChunk_AfterEcoMode)); snprintf(buffer, sizeof(buffer), "%d", ecoModeTempWasser); // Eco Temp Wasser Wert (int) response->print(buffer); yield(); // Nach Eco Temp Wasser bis Eco Temp Dampf response->print(FPSTR(ecoChunk_AfterTempW)); snprintf(buffer, sizeof(buffer), "%d", ecoModeTempDampf); // Eco Temp Dampf Wert (int) response->print(buffer); yield(); // Dynamic Eco Start und Toggle response->print(FPSTR(ecoChunk_DynamicEco_Start)); // '>' nach Dampf Temp,
, H3 response->print(FPSTR(ecoChunk_ToggleDynEco_Start)); // Toggle HTML bis vor checked if (dynamicEcoActive) { response->print(F(" checked")); // checked Attribut einfügen } response->print(FPSTR(ecoChunk_ToggleDynEco_End)); // Rest des Toggles yield(); // Beschreibung Dynamic Eco und Start Dampfverzögerung response->print(FPSTR(ecoChunk_DynEcoDesc_And_SteamDelay)); // Beschreibung, H3, Label bis value= snprintf(buffer, sizeof(buffer), "%d", dampfVerzoegerung); // Dampf Delay Wert (int) response->print(buffer); yield(); // Schließt das Input-Feld der Dampfverzögerung response->print(FPSTR(ecoChunk_AfterDampfDelay_BeforeToggle)); // Neuer Toggle-Switch für den Override response->print(FPSTR(ecoChunk_SteamOverrideToggle_Start)); // HTML bis vor 'checked' if (steamDelayOverrideBySwitchEnabled) { response->print(F(" checked")); // 'checked' Attribut einfügen, wenn die Option aktiv ist } response->print(FPSTR(ecoChunk_SteamOverrideToggle_End)); // Rest des Toggle-HTMLs mit Beschreibung yield(); // Toggle-Switch für Eco-Info im Display response->print(FPSTR(ecoChunk_DisplayInfoToggle_Start)); // HTML bis vor 'checked' if (ecoInfoOnDisplay) { response->print(F(" checked")); } response->print(FPSTR(ecoChunk_DisplayInfoToggle_End)); // Rest des Toggle-HTMLs mit Beschreibung yield(); // Rest der Seite (Warnung, Button, Ende) response->print(FPSTR(ecoChunk_FinalWarningAndSubmit)); request->send(response); } /************************************************************************************ * Handler für die Fast-Heat-Up Seite - Heap-Optimiert mit Chunked Sending ************************************************************************************/ // --- Angepasste PROGMEM Chunks für handleFastHeatUp mit Toggle Switch --- // Kopfzeile bleibt gleich static const char fhuHtmlHead[] PROGMEM = R"rawliteral( Fast-Heat-Up-Modus )rawliteral"; // bleibt gleich static const char fhuHtmlHeadEnd[] PROGMEM = R"rawliteral( )rawliteral"; // Neuer Chunk: Body Start bis vor den Toggle Switch static const char fhuBodyStart[] PROGMEM = R"rawliteral(

Fast-Heat-Up-Modus

Fast-Heat-Up-Modus

)rawliteral"; // Neuer Chunk: Der Toggle Switch Container Start bis VOR das checked Attribut static const char fhuToggleContainerStart[] PROGMEM = R"rawliteral(
Fast-Heat-Up aktivieren:
)rawliteral"; // Schließt Input, Span, Label, Div // Neuer Chunk: Beschreibungstext und Formular-Ende static const char fhuDescriptionAndEnd[] 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"; // --- Ende der PROGMEM Chunks --- // --- Überarbeitete Funktion handleFastHeatUp mit Toggle Switch --- void handleFastHeatUp(AsyncWebServerRequest *request) // URL: /Fast-Heat-Up { AsyncResponseStream *response = request->beginResponseStream("text/html"); // Kopfzeile senden response->print(FPSTR(fhuHtmlHead)); response->print(FPSTR(commonStyle)); // Globaler Style mit Toggle-CSS response->print(FPSTR(fhuHtmlHeadEnd)); response->print(FPSTR(commonNav)); // Navigation // Body Start und Formular-Anfang response->print(FPSTR(fhuBodyStart)); yield(); // Toggle Switch HTML-Teile senden response->print(FPSTR(fhuToggleContainerStart)); // Bis vor 'checked' if (fastHeatUpAktiv) { response->print(F(" checked")); // Fügt 'checked' hinzu, wenn aktiv } response->print(FPSTR(fhuToggleContainerEnd)); // Schließt den Toggle-Switch yield(); // Beschreibung und Rest der Seite senden response->print(FPSTR(fhuDescriptionAndEnd)); request->send(response); } /************************************************************************************ * Handler zum Speichern der Fast-Heat-Up-Einstellung - URL angepasst ************************************************************************************/ void handleFastHeatUpSettings(AsyncWebServerRequest *request) { if (request->hasArg(F("fastHeatUpAktiv"))) { fastHeatUpAktiv = true; } else { fastHeatUpAktiv = false; } EEPROM.put(EEPROM_ADDR_FASTHEATUP_DATA, fastHeatUpAktiv); EEPROM.commit(); AsyncWebServerResponse *resp = request->beginResponse(303); resp->addHeader(F("Location"), F("/Fast-Heat-Up")); request->send(resp); } /************************************************************************************ * Handler für Root (PID-Einstellungen) - Heap-Optimiert mit Chunked Sending ************************************************************************************/ // --- PROGMEM Chunks für handlePidSettings mit Toggle Switches --- // Head und Body Start bleiben gleich static const char rootHtmlHead[] PROGMEM = R"rawliteral( PID-Einstellung )rawliteral"; static const char rootHtmlHeadEnd[] PROGMEM = R"rawliteral( )rawliteral"; static const char rootChunk_BodyStart[] PROGMEM = R"rawliteral(

PID-Einstellung

)rawliteral"; // --- Wasser Temperatur Sektion --- static const char rootChunk_WasserTempStart[] PROGMEM = R"rawliteral(

Temperatur: Wasser / Kessel

Boost-Funktion:
(Volle Heizleistung erzwingen bis 95% der Zieltemperatur) )rawliteral"; // --- Wasser Prevent Heat Toggle --- static const char rootChunk_TogglePreventW_Start[] PROGMEM = R"rawliteral(
Heizen oberhalb Setpoint verhindern:
(Kein Heizen, wenn Temp. > Sollwert)
)rawliteral"; // --- Dampf Temperatur Sektion --- static const char rootChunk_DampfTempStart[] PROGMEM = R"rawliteral(

Temperatur: Dampf / Thermoblock

Boost-Funktion:
(Volle Heizleistung erzwingen bis 90% der Zieltemperatur) )rawliteral"; // --- Dampf Prevent Heat Toggle --- static const char rootChunk_TogglePreventD_Start[] PROGMEM = R"rawliteral(
Heizen oberhalb Setpoint verhindern:
(Kein Heizen, wenn Temp. > Sollwert)
)rawliteral"; // --- PID Wasser Sektion --- static const char rootChunk_PIDWasser_Start[] PROGMEM = R"rawliteral(

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"; // --- Ende der PROGMEM Chunks --- // Überarbeitete Funktion handlePidSettings mit Toggle Switches void handlePidSettings(AsyncWebServerRequest *request) { char buffer[20]; AsyncResponseStream *response = request->beginResponseStream("text/html"); response->write(rootHtmlHead, strlen_P(rootHtmlHead)); response->write(commonStyle, strlen_P(commonStyle)); response->write(rootHtmlHeadEnd, strlen_P(rootHtmlHeadEnd)); response->write(commonNav, strlen_P(commonNav)); response->write(rootChunk_BodyStart, strlen_P(rootChunk_BodyStart)); yield(); String errorMessageHtml = ""; if (demoModus) { errorMessageHtml = "
Demo-Betrieb:
Es sind keine Sensoren angeschlossen und daher keine Temperaturwerte verfügbar.
"; } else if (wasserSensorError && dampfSensorError) { errorMessageHtml = "
Die Daten der Temperatursensoren sind derzeit nicht verfügbar!
Bitte Temperatur-Sensoren prüfen!
"; } else if (wasserSensorError) { errorMessageHtml = "
Temperaturwert für Wasser nicht verfügbar
"; } else if (dampfSensorError) { errorMessageHtml = "
Temperaturwert für Dampf nicht verfügbar
"; } if (errorMessageHtml.length() > 0) { response->print(errorMessageHtml); yield(); } response->write(rootChunk_WasserTempStart, strlen_P(rootChunk_WasserTempStart)); snprintf(buffer, sizeof(buffer), "%d", (int)SetpointWasser); response->print(buffer); response->write(rootChunk_WasserOffset, strlen_P(rootChunk_WasserOffset)); snprintf(buffer, sizeof(buffer), "%.2f", OffsetWasser); response->print(buffer); yield(); response->write(rootChunk_ToggleBoostW_Start, strlen_P(rootChunk_ToggleBoostW_Start)); if (boostWasserActive) response->print(F(" checked")); response->write(rootChunk_ToggleBoostW_End, strlen_P(rootChunk_ToggleBoostW_End)); yield(); response->write(rootChunk_TogglePreventW_Start, strlen_P(rootChunk_TogglePreventW_Start)); if (preventHeatAboveSetpointWasser) response->print(F(" checked")); response->write(rootChunk_TogglePreventW_End, strlen_P(rootChunk_TogglePreventW_End)); yield(); response->write(rootChunk_DampfTempStart, strlen_P(rootChunk_DampfTempStart)); snprintf(buffer, sizeof(buffer), "%d", (int)SetpointDampf); response->print(buffer); response->write(rootChunk_DampfOffset, strlen_P(rootChunk_DampfOffset)); snprintf(buffer, sizeof(buffer), "%.2f", OffsetDampf); response->print(buffer); yield(); response->write(rootChunk_ToggleBoostD_Start, strlen_P(rootChunk_ToggleBoostD_Start)); if (boostDampfActive) response->print(F(" checked")); response->write(rootChunk_ToggleBoostD_End, strlen_P(rootChunk_ToggleBoostD_End)); yield(); response->write(rootChunk_TogglePreventD_Start, strlen_P(rootChunk_TogglePreventD_Start)); if (preventHeatAboveSetpointDampf) response->print(F(" checked")); response->write(rootChunk_TogglePreventD_End, strlen_P(rootChunk_TogglePreventD_End)); yield(); response->write(rootChunk_PIDWasser_Start, strlen_P(rootChunk_PIDWasser_Start)); snprintf(buffer, sizeof(buffer), "%.2f", KpWasser); response->print(buffer); response->write(rootChunk_PIDWasser_Ki, strlen_P(rootChunk_PIDWasser_Ki)); snprintf(buffer, sizeof(buffer), "%.2f", KiWasser); response->print(buffer); response->write(rootChunk_PIDWasser_Kd, strlen_P(rootChunk_PIDWasser_Kd)); snprintf(buffer, sizeof(buffer), "%.2f", KdWasser); response->print(buffer); response->write(rootChunk_PIDWasser_Window, strlen_P(rootChunk_PIDWasser_Window)); snprintf(buffer, sizeof(buffer), "%lu", windowSizeWasser); response->print(buffer); yield(); response->write(rootChunk_PIDDampf_Start, strlen_P(rootChunk_PIDDampf_Start)); snprintf(buffer, sizeof(buffer), "%.2f", KpDampf); response->print(buffer); response->write(rootChunk_PIDDampf_Ki, strlen_P(rootChunk_PIDDampf_Ki)); snprintf(buffer, sizeof(buffer), "%.2f", KiDampf); response->print(buffer); response->write(rootChunk_PIDDampf_Kd, strlen_P(rootChunk_PIDDampf_Kd)); snprintf(buffer, sizeof(buffer), "%.2f", KdDampf); response->print(buffer); response->write(rootChunk_PIDDampf_Window, strlen_P(rootChunk_PIDDampf_Window)); snprintf(buffer, sizeof(buffer), "%lu", windowSizeDampf); response->print(buffer); yield(); response->write(rootChunk_Safety_Start, strlen_P(rootChunk_Safety_Start)); snprintf(buffer, sizeof(buffer), "%.1f", maxTempWasser); response->print(buffer); response->write(rootChunk_Safety_MaxDampf, strlen_P(rootChunk_Safety_MaxDampf)); snprintf(buffer, sizeof(buffer), "%.1f", maxTempDampf); response->print(buffer); yield(); response->write(rootChunk_FormEnd, strlen_P(rootChunk_FormEnd)); request->send(response); yield(); } /************************************************************************************ * Schreibt die Werte in den EEPROM (PID/Eco) - Unverändert ************************************************************************************/ void handleSettingsUpdate(AsyncWebServerRequest *request) { bool valueChanged = false; // Flag um zu prüfen, ob EEPROM.commit() nötig ist // --- Bestehende Werte lesen --- // Sicherheitslimits if (request->hasArg(F("maxTempWasser"))) { float newVal = request->arg(F("maxTempWasser")).toFloat(); if (newVal != maxTempWasser) { maxTempWasser = newVal; EEPROM.put(EEPROM_ADDR_MAX_TEMP_WASSER, maxTempWasser); valueChanged = true; } } if (request->hasArg(F("maxTempDampf"))) { float newVal = request->arg(F("maxTempDampf")).toFloat(); if (newVal != maxTempDampf) { maxTempDampf = newVal; EEPROM.put(EEPROM_ADDR_MAX_TEMP_DAMPF, maxTempDampf); valueChanged = true; } } // Setpoints if (request->hasArg(F("wasser"))) { float newVal = request->arg(F("wasser")).toFloat(); if (newVal != SetpointWasser) { SetpointWasser = newVal; EEPROM.put(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); valueChanged = true; } } if (request->hasArg(F("dampf"))) { float newVal = request->arg(F("dampf")).toFloat(); if (newVal != SetpointDampf) { SetpointDampf = newVal; EEPROM.put(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); valueChanged = true; } } // Offsets if (request->hasArg(F("offsetWasser"))) { float newVal = request->arg(F("offsetWasser")).toFloat(); if (newVal != OffsetWasser) { OffsetWasser = newVal; EEPROM.put(EEPROM_ADDR_OFFSET_WASSER, OffsetWasser); valueChanged = true; } } if (request->hasArg(F("offsetDampf"))) { float newVal = request->arg(F("offsetDampf")).toFloat(); if (newVal != OffsetDampf) { OffsetDampf = newVal; EEPROM.put(EEPROM_ADDR_OFFSET_DAMPF, OffsetDampf); valueChanged = true; } } // PID Wasser if (request->hasArg(F("kpWasser"))) { float newVal = request->arg(F("kpWasser")).toFloat(); if (newVal != KpWasser) { KpWasser = newVal; EEPROM.put(EEPROM_ADDR_KP_WASSER, KpWasser); valueChanged = true; } } if (request->hasArg(F("kiWasser"))) { float newVal = request->arg(F("kiWasser")).toFloat(); if (newVal != KiWasser) { KiWasser = newVal; EEPROM.put(EEPROM_ADDR_KI_WASSER, KiWasser); valueChanged = true; } } if (request->hasArg(F("kdWasser"))) { float newVal = request->arg(F("kdWasser")).toFloat(); if (newVal != KdWasser) { KdWasser = newVal; EEPROM.put(EEPROM_ADDR_KD_WASSER, KdWasser); valueChanged = true; } } // PID Dampf if (request->hasArg(F("kpDampf"))) { float newVal = request->arg(F("kpDampf")).toFloat(); if (newVal != KpDampf) { KpDampf = newVal; EEPROM.put(EEPROM_ADDR_KP_DAMPF, KpDampf); valueChanged = true; } } if (request->hasArg(F("kiDampf"))) { float newVal = request->arg(F("kiDampf")).toFloat(); if (newVal != KiDampf) { KiDampf = newVal; EEPROM.put(EEPROM_ADDR_KI_DAMPF, KiDampf); valueChanged = true; } } if (request->hasArg(F("kdDampf"))) { float newVal = request->arg(F("kdDampf")).toFloat(); if (newVal != KdDampf) { KdDampf = newVal; EEPROM.put(EEPROM_ADDR_KD_DAMPF, KdDampf); valueChanged = true; } } // Boost Flags bool newBoostWasser = request->hasArg(F("boostWasser")); if (newBoostWasser != boostWasserActive) { boostWasserActive = newBoostWasser; EEPROM.put(EEPROM_ADDR_BOOST_WASSER_ACTIVE, boostWasserActive); valueChanged = true; } bool newBoostDampf = request->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 = request->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 = request->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 (request->hasArg(F("windowSizeWasser"))) { char* endptr; // Konvertiere den String-Parameter zu unsigned long unsigned long val = strtoul(request->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", request->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 (request->hasArg(F("windowSizeDampf"))) { char* endptr; unsigned long val = strtoul(request->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", request->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 EEPROM.commit(); } // PID-Parameter an die Regler übergeben pidDampf.SetTunings(KpDampf, KiDampf, KdDampf); pidWasser.SetTunings(KpWasser, KiWasser, KdWasser); // Zurück zur Hauptseite (bereits vorhanden) AsyncWebServerResponse *resp = request->beginResponse(303); resp->addHeader(F("Location"), F("/PID")); request->send(resp); } /************************************************************************************ * 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 firmwareInfoChunkGitHub[] PROGMEM = R"rawliteral(
GitHub: )rawliteral"; // Endet vor {SWGITHUB} 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.
Profile und Statistiken / Verläufe müssen jedoch separat über den Dateimanager gesichert werden!
)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 firmwareRestartSection[] PROGMEM = R"rawliteral(

Neustart

Führt einen Neustart des Systems durch.
Alle nicht gespeicherten Einstellungen gehen verloren.
Die Verbindung wird kurzzeitig unterbrochen.
)rawliteral"; static const char firmwareBodyEnd[] PROGMEM = R"rawliteral( )rawliteral"; // --- Neue Funktion handleFirmware --- void handleFirmware(AsyncWebServerRequest *request) { // URL: /Firmware, Methode: GET AsyncResponseStream *response = request->beginResponseStream("text/html"); // Kopfzeile senden response->print(FPSTR(firmwareHtmlHead)); // Enthält öffnendes und bis response->print(FPSTR(commonStyle)); // ist im Head-Chunk enthalten response->print(FPSTR(commonNav)); // Navigation // Body Start und Firmware Info Sektion response->print(FPSTR(firmwareBodyStart)); // Enthält ,

und Start der
für Info // Firmware Version if (version.length() > 0) { // Prüfen, ob String nicht leer ist response->print(version); } response->print(FPSTR(firmwareInfoChunk2)); // Enthält
Hersteller: // Hersteller if (versionHersteller.length() > 0) { response->print(versionHersteller); } response->print(FPSTR(firmwareInfoChunk3)); // Enthält
E-Mail: // Mail (ist HTML, kann direkt gesendet werden) if (versionHerstellerMail.length() > 0) { response->print(versionHerstellerMail); } response->print(FPSTR(firmwareInfoChunk4)); // Enthält
Website: // Website (ist HTML, kann direkt gesendet werden) if (versionHerstellerWeb.length() > 0) { response->print(versionHerstellerWeb); } response->print(FPSTR(firmwareInfoChunkGitHub)); // Enthält
GitHub: response->print(versionHerstellerGitHub); response->print(FPSTR(firmwareInfoChunkEnd)); // Enthält
// Firmware Update Sektion response->print(FPSTR(firmwareUpdateSection)); // Dateimanager Sektion response->print(FPSTR(firmwareFileManagerSection)); // Einstellungen Export Sektion response->print(FPSTR(firmwareExportSection)); // Einstellungen Import Sektion response->print(FPSTR(firmwareImportSection)); // Werkseinstellungen Sektion response->print(FPSTR(firmwareResetSection)); // Neustart Sektion hinzufügen response->print(FPSTR(firmwareRestartSection)); // Body / HTML Ende response->print(FPSTR(firmwareBodyEnd)); request->send(response); } /************************************************************************************ * Handler für AutTune Wasser ************************************************************************************/ // --- PROGMEM Chunks für handleAutoTuneWasser --- static const char autotuneWasserHtmlHead[] PROGMEM = R"rawliteral( AutoTune Wasser )rawliteral"; static const char autotuneWasserBodyStart[] PROGMEM = R"rawliteral(

PID-Tuning

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

AutoTune gestartet: Wasser-PID


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

AutoTune ist bereits aktiv!


)rawliteral"; static const char autotuneWasserBodyEnd[] PROGMEM = R"rawliteral( )rawliteral"; // --- Neue Funktion handleAutoTuneWasser --- void handleAutoTuneWasser(AsyncWebServerRequest *request) { AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8"); response->print(FPSTR(autotuneWasserHtmlHead)); response->print(FPSTR(commonStyle)); response->print(FPSTR(commonNav)); response->print(FPSTR(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); } startAutoTuneWasser(); response->print(FPSTR(autotuneWasserStartSuccess)); } else { response->print(FPSTR(autotuneWasserAlreadyActive)); } response->print(FPSTR(autotuneWasserBodyEnd)); request->send(response); } /************************************************************************************ * Handler für AutTune Dampf ************************************************************************************/ // --- PROGMEM Chunks für handleAutoTuneDampf --- static const char autotuneDampfHtmlHead[] PROGMEM = R"rawliteral( AutoTune Dampf )rawliteral"; // BodyStart, AlreadyActive und BodyEnd können von handleAutoTuneWasser wiederverwendet werden // (autotuneWasserBodyStart, autotuneWasserAlreadyActive, autotuneWasserBodyEnd) static const char autotuneDampfStartSuccess[] PROGMEM = R"rawliteral(

AutoTune gestartet: Dampf-PID


)rawliteral"; void handleAutoTuneDampf(AsyncWebServerRequest *request) { AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8"); response->print(FPSTR(autotuneDampfHtmlHead)); // Eigener Head-Chunk response->print(FPSTR(commonStyle)); response->print(FPSTR(commonNav)); response->print(FPSTR(autotuneWasserBodyStart)); // Wiederverwendet // Prüfen, ob bereits ein Tuning läuft if (!autoTuneWasserActive && !autoTuneDampfActive) { // Wartungsmodus explizit deaktivieren if (wartungsModusAktiv) { wartungsModusAktiv = false; EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); } startAutoTuneDampf(); // Funktion zum Starten des Tunings aufrufen response->print(FPSTR(autotuneDampfStartSuccess)); // Eigener Success-Chunk } else { response->print(FPSTR(autotuneWasserAlreadyActive)); // Wiederverwendet } response->print(FPSTR(autotuneWasserBodyEnd)); // Wiederverwendet request->send(response); } /************************************************************************************ * Handler zum Speichern der AutoTune-Einstellungen - ausgelagert ************************************************************************************/ void handleUpdateAutotuneSettings(AsyncWebServerRequest *request) { bool changed = false; // Flag, um zu prüfen, ob EEPROM.commit() nötig ist // --- Wasser Parameter --- if (request->hasArg(F("tuningStepWasser"))) { tuningStepWasser = request->arg(F("tuningStepWasser")).toFloat(); EEPROM.put(EEPROM_ADDR_TUNING_STEP_WASSER, tuningStepWasser); changed = true; } if (request->hasArg(F("tuningNoiseWasser"))) { tuningNoiseWasser = request->arg(F("tuningNoiseWasser")).toFloat(); EEPROM.put(EEPROM_ADDR_TUNING_NOISE_WASSER, tuningNoiseWasser); changed = true; } if (request->hasArg(F("tuningStartValueWasser"))) { tuningStartValueWasser = request->arg(F("tuningStartValueWasser")).toFloat(); EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_WASSER, tuningStartValueWasser); changed = true; } if (request->hasArg(F("tuningLookBackWasser"))) { int lookbackInt = request->arg(F("tuningLookBackWasser")).toInt(); if (lookbackInt >= 0) { tuningLookBackWasser = (unsigned int)lookbackInt; EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_WASSER, tuningLookBackWasser); changed = true; } } // --- Dampf Parameter --- if (request->hasArg(F("tuningStepDampf"))) { tuningStepDampf = request->arg(F("tuningStepDampf")).toFloat(); EEPROM.put(EEPROM_ADDR_TUNING_STEP_DAMPF, tuningStepDampf); changed = true; } if (request->hasArg(F("tuningNoiseDampf"))) { tuningNoiseDampf = request->arg(F("tuningNoiseDampf")).toFloat(); EEPROM.put(EEPROM_ADDR_TUNING_NOISE_DAMPF, tuningNoiseDampf); changed = true; } if (request->hasArg(F("tuningStartValueDampf"))) { tuningStartValueDampf = request->arg(F("tuningStartValueDampf")).toFloat(); EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_DAMPF, tuningStartValueDampf); changed = true; } if (request->hasArg(F("tuningLookBackDampf"))) { int lookbackInt = request->arg(F("tuningLookBackDampf")).toInt(); if (lookbackInt >= 0) { tuningLookBackDampf = (unsigned int)lookbackInt; EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_DAMPF, tuningLookBackDampf); changed = true; } } if (changed) { EEPROM.commit(); } AsyncWebServerResponse *response = request->beginResponse(303); response->addHeader(F("Location"), F("/PID-Tuning")); request->send(response); } /************************************************************************************ * Handler für PID-Tuning ************************************************************************************/ // --- PROGMEM Chunks für handlePidSettingsTuning --- 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"; // --- handlePidSettingsTuning --- void handlePidSettingsTuning(AsyncWebServerRequest *request) { char buffer[20]; // Puffer für Zahlenumwandlungen AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8"); response->print(FPSTR(pidTuningPageHtmlHead)); response->print(FPSTR(commonStyle)); response->print(FPSTR(commonNav)); response->print(FPSTR(pidTuningPageBodyStart)); if (autoTuneWasserActive || autoTuneDampfActive) { response->print(FPSTR(pidTuningPageAbortForm)); } response->print(FPSTR(pidTuningPageIntro)); response->print(FPSTR(pidTuningPageParamsFormStart)); snprintf(buffer, sizeof(buffer), "%lu", windowSizeWasser); response->print(buffer); response->print(FPSTR(pidTuningPageParamsW1)); snprintf(buffer, sizeof(buffer), "%.2f", tuningStepWasser); response->print(buffer); response->print(FPSTR(pidTuningPageParamsW2)); snprintf(buffer, sizeof(buffer), "%.2f", tuningNoiseWasser); response->print(buffer); response->print(FPSTR(pidTuningPageParamsW3)); snprintf(buffer, sizeof(buffer), "%lu", windowSizeWasser); response->print(buffer); response->print(FPSTR(pidTuningPageParamsW4)); snprintf(buffer, sizeof(buffer), "%.2f", tuningStartValueWasser); response->print(buffer); response->print(FPSTR(pidTuningPageParamsW5)); snprintf(buffer, sizeof(buffer), "%u", tuningLookBackWasser); response->print(buffer); response->print(FPSTR(pidTuningPageParamsDStart)); snprintf(buffer, sizeof(buffer), "%lu", windowSizeDampf); response->print(buffer); response->print(FPSTR(pidTuningPageParamsD1)); snprintf(buffer, sizeof(buffer), "%.2f", tuningStepDampf); response->print(buffer); response->print(FPSTR(pidTuningPageParamsD2)); snprintf(buffer, sizeof(buffer), "%.2f", tuningNoiseDampf); response->print(buffer); response->print(FPSTR(pidTuningPageParamsD3)); snprintf(buffer, sizeof(buffer), "%lu", windowSizeDampf); response->print(buffer); response->print(FPSTR(pidTuningPageParamsD4)); snprintf(buffer, sizeof(buffer), "%.2f", tuningStartValueDampf); response->print(buffer); response->print(FPSTR(pidTuningPageParamsD5)); snprintf(buffer, sizeof(buffer), "%u", tuningLookBackDampf); response->print(buffer); response->print(FPSTR(pidTuningPageParamsFormEnd)); response->print(FPSTR(pidTuningPageExplanation)); request->send(response); } /************************************************************************************ * Handler für die Antwort nach Abbruch des PID-Tunings - Heap-Optimiert ************************************************************************************/ // PROGMEM Chunks für die Seite (Namen angepasst für Klarheit) static const char abortPidTuningHtmlStart[] PROGMEM = R"rawliteral( Info )rawliteral"; static const char abortPidTuningHtmlHeadEnd[] PROGMEM = R"rawliteral()rawliteral"; static const char abortPidTuningHtmlBody[] PROGMEM = R"rawliteral(

PID-Tuning Abbruch

Das PID-Tuning wurde erfolgreich abgebrochen.

Der Normalbetrieb wird nun fortgesetzt.

)rawliteral"; // --- Neue Funktion handlePidSettingsTuningAbbruch --- void handlePidSettingsTuningAbbruch(AsyncWebServerRequest *request) { AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8"); response->print(FPSTR(abortPidTuningHtmlStart)); response->print(FPSTR(commonStyle)); response->print(FPSTR(abortPidTuningHtmlHeadEnd)); response->print(FPSTR(commonNav)); response->print(FPSTR(abortPidTuningHtmlBody)); request->send(response); stopAutoTuneWasser(); stopAutoTuneDampf(); } /************************************************************************************ * Speichert Eco-Einstellungen - URL angepasst ************************************************************************************/ void handleEcoUpdate(AsyncWebServerRequest *request) { if (request->hasArg(F("ecoMode"))) ecoModeMinutes = request->arg(F("ecoMode")).toInt(); if (request->hasArg(F("ecoModeTempWasser"))) ecoModeTempWasser = request->arg(F("ecoModeTempWasser")).toInt(); if (request->hasArg(F("ecoModeTempDampf"))) ecoModeTempDampf = request->arg(F("ecoModeTempDampf")).toInt(); if (request->hasArg(F("dynamicEcoMode"))) { dynamicEcoActive = true; } else { dynamicEcoActive = false; } if (request->hasArg(F("dampfDelay"))) { dampfVerzoegerung = request->arg(F("dampfDelay")).toInt(); } if (request->hasArg("steamDelayOverrideBySwitch")) { steamDelayOverrideBySwitchEnabled = true; } else { steamDelayOverrideBySwitchEnabled = false; } if (request->hasArg("ecoInfoDisplay")) { ecoInfoOnDisplay = true; } else { ecoInfoOnDisplay = false; } 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.put(EEPROM_ADDR_ECO_INFO_ON_DISPLAY, ecoInfoOnDisplay); 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); } AsyncWebServerResponse *resp = request->beginResponse(303); resp->addHeader(F("Location"), F("/ECO")); // URL angepasst request->send(resp); } /************************************************************************************ * Speichert Geräte-Infos ************************************************************************************/ void handleInfoUpdate(AsyncWebServerRequest *request) { bool changed = false; if (request->hasArg(F("hersteller"))) { strncpy(infoHersteller, request->arg(F("hersteller")).c_str(), sizeof(infoHersteller) - 1); infoHersteller[sizeof(infoHersteller) - 1] = '\0'; EEPROM.put(EEPROM_ADDR_INFO_HERSTELLER, infoHersteller); changed = true; } if (request->hasArg(F("modell"))) { strncpy(infoModell, request->arg(F("modell")).c_str(), sizeof(infoModell) - 1); infoModell[sizeof(infoModell) - 1] = '\0'; EEPROM.put(EEPROM_ADDR_INFO_MODELL, infoModell); changed = true; } if (request->hasArg(F("zusatz"))) { strncpy(infoZusatz, request->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(); } AsyncWebServerResponse *response = request->beginResponse(303); response->addHeader(F("Location"), F("/Info")); request->send(response); } /************************************************************************************ * Speichert NUR die Einstellung für die Systemtöne (Piezo) ************************************************************************************/ void handleUpdatePiezoSettings(AsyncWebServerRequest *request) { bool changed = false; // Piezo-Status speichern bool newPiezoState = request->hasArg("piezoEnabled"); // Checkbox ist da, wenn checked if (newPiezoState != piezoEnabled) { piezoEnabled = newPiezoState; EEPROM.put(EEPROM_ADDR_PIEZO_ENABLED, piezoEnabled); changed = true; } if (changed) { EEPROM.commit(); } AsyncWebServerResponse *resp = request->beginResponse(303); resp->addHeader("Location", "/Service"); request->send(resp); } /************************************************************************************ * Speichert NUR die Einstellung für das Wartungsintervall ************************************************************************************/ void handleUpdateIntervalSettings(AsyncWebServerRequest *request) { bool changed = false; // Wartungsintervall speichern if (request->hasArg("maintenanceInterval")) { int newInterval = request->arg("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; } } if (changed) { EEPROM.commit(); } AsyncWebServerResponse *resp = request->beginResponse(303); resp->addHeader("Location", "/Service"); request->send(resp); } /************************************************************************************ * Handler für JSON-Chart-Daten (/Chart-Daten) - Heap-Optimiert ************************************************************************************/ void handleChartData(AsyncWebServerRequest *request) { AsyncResponseStream *response = request->beginResponseStream("application/json"); char buffer[20]; // Dynamische Flow-Rate (g/s) berechnen, falls ESP32 + Waage + Bezug aktiv // Falls kein Bezug/Waage: 0.0 zurückgeben und internen Zustand zurücksetzen float flowGs = 0.0f; #ifdef ESP32 static float lastWeightForFlow = 0.0f; static unsigned long lastFlowCalcTimeMs = 0; static bool lastShotState = false; static float smoothedFlow = 0.0f; // einfacher EMA-Filter const bool mayCalcFlow = (shotActive && scaleConnected); const unsigned long nowMs = millis(); if (mayCalcFlow) { if (lastFlowCalcTimeMs == 0 || !lastShotState) { // Initialisieren beim Start des Bezugs lastFlowCalcTimeMs = nowMs; lastWeightForFlow = currentWeightReading; smoothedFlow = 0.0f; flowGs = 0.0f; } else { unsigned long dt = nowMs - lastFlowCalcTimeMs; if (dt > 0) { float dWeight = currentWeightReading - lastWeightForFlow; float instFlow = dWeight / (float(dt) / 1000.0f); if (instFlow < 0) instFlow = 0.0f; // Unterdrücke negatives Rauschen // Exponential Moving Average zur Glättung const float alpha = 0.3f; smoothedFlow = alpha * instFlow + (1.0f - alpha) * smoothedFlow; flowGs = smoothedFlow; lastFlowCalcTimeMs = nowMs; lastWeightForFlow = currentWeightReading; } } } else { // Reset, damit beim nächsten Shot sauber neu gestartet wird lastFlowCalcTimeMs = 0; smoothedFlow = 0.0f; flowGs = 0.0f; } lastShotState = shotActive; #endif response->print(F("{\"wasser\":")); snprintf(buffer, sizeof(buffer), "%.1f", InputWasser); response->print(buffer); response->print(F(",\"setpointwasser\":")); snprintf(buffer, sizeof(buffer), "%.1f", SetpointWasser); response->print(buffer); response->print(F(",\"dampf\":")); snprintf(buffer, sizeof(buffer), "%.1f", InputDampf); response->print(buffer); response->print(F(",\"setpointdampf\":")); snprintf(buffer, sizeof(buffer), "%.1f", SetpointDampf); response->print(buffer); // Flow-Rate immer mitsenden (ESP32: berechnet, sonst 0.0) -> Null-Linie bei nicht aktivem Bezug/Waage response->print(F(",\"flow\":")); snprintf(buffer, sizeof(buffer), "%.2f", flowGs); response->print(buffer); response->print(F("}")); request->send(response); } /************************************************************************************ * Handler für Temperatur-Charts (Live) - URL angepasst (/Chart) * Lädt chart.js bevorzugt aus LittleFS, sonst CDN, sonst Fehler. * Auswahl für maximale Datenpunkte hinzugefügt. * Vollbild-Funktion hinzugefügt (Layout korrigiert: Label über Select). ************************************************************************************/ // --- PROGMEM Chunks für die Chart-Seite --- // Teil 1: HTML-Start, Meta-Tags static const char chartsHtml_HeadStart[] PROGMEM = R"rawliteral( Chart )rawliteral"; // Teil 1b: Spezifische Styles (Normal-Layout: Button mittig zu den Blöcken) static const char chartsHtml_SpecificStyles[] PROGMEM = R"rawliteral( )rawliteral"; // Teil 2: Schließendes Head-Tag static const char chartsHtml_HeadEnd[] PROGMEM = "\n"; // Teil 3: Selektoren-UI (HTML-Struktur angepasst: Label über Select) static const char chartsHtml_SelectorsUI[] PROGMEM = R"rawliteral(
)rawliteral"; // Teil 4: Chart.js Code (Unverändert zur letzten Version) 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 flowColor = '#46C37B'; const gridColor = 'rgba(255, 255, 255, 0.1)'; const textColor = '#E0E0E0'; let maxDataPoints = parseInt(document.getElementById('datapointLimit').value, 10) || 100; 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 }, { label: 'Flow (g/s)', data: [], borderColor: flowColor, backgroundColor: 'rgba(70, 195, 123, 0.15)', borderWidth: 2, fill: false, tension: 0.25, pointRadius: 0, yAxisID: 'y2' } ] }, 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) { return value + ' °C'; } }, grid: { color: gridColor, drawBorder: false } }, y2: { position: 'right', suggestedMin: 0, suggestedMax: 6, title: { display: true, text: 'Flow (g/s)', color: textColor, font: { size: 13, weight: '300' } }, ticks: { color: textColor, font: { size: 11 } }, grid: { drawOnChartArea: 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) { const isFlow = (context.dataset.yAxisID === 'y2') || /Flow/.test(context.dataset.label||''); label += context.parsed.y.toFixed(2) + (isFlow ? ' g/s' : ' °C'); } return label; } } } } } }); let updateInterval = parseInt(document.getElementById('updateRate').value, 10) || 15000; 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); datasets[4].data.push((typeof data.flow === 'number') ? data.flow : 0.0); 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.`); } document.getElementById('updateRate').addEventListener('change', function(event) { const newInterval = parseInt(event.target.value, 10); if (!isNaN(newInterval) && newInterval > 0) { updateInterval = newInterval; startFetching(); } }); 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}`); } }); const fullscreenBtn = document.getElementById('chart-fullscreen-btn'); const bodyElement = document.body; const enterFsIcon = ` `; const exitFsIcon = ` `; fullscreenBtn.innerHTML = enterFsIcon; fullscreenBtn.addEventListener('click', () => { bodyElement.classList.toggle('chart-fullscreen-active'); const isFs = bodyElement.classList.contains('chart-fullscreen-active'); fullscreenBtn.innerHTML = isFs ? exitFsIcon : enterFsIcon; fullscreenBtn.setAttribute('aria-label', isFs ? 'Vollbild verlassen' : 'Vollbild'); fullscreenBtn.setAttribute('title', isFs ? 'Vollbild verlassen' : 'Vollbild'); setTimeout(() => { if (typeof combinedChart !== 'undefined' && combinedChart) { combinedChart.resize(); } }, 50); }); startFetching(); )rawliteral"; // Teil 5: Chunk für den Fehlerfall static const char chartsHtml_ErrorBox[] PROGMEM = R"rawliteral(

Chart kann nicht angezeigt werden

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

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.

)rawliteral"; // Teil 6: Schließendes Body- und HTML-Tag static const char chartsHtml_BodyEnd[] PROGMEM = R"rawliteral( )rawliteral"; // --- handleCharts Funktion --- void handleCharts(AsyncWebServerRequest *request) // URL: /Chart { bool chartJsLocal = LittleFS.exists("/chart.umd.min.js"); bool isConnected = (WiFi.status() == WL_CONNECTED); bool canLoadChartJs = false; AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8"); response->print(FPSTR(chartsHtml_HeadStart)); response->print(FPSTR(commonStyle)); response->print(FPSTR(chartsHtml_SpecificStyles)); // CSS mit korrektem Normal-Layout if (chartJsLocal) { File chartFile = LittleFS.open("/chart.umd.min.js", "r"); if (chartFile) { canLoadChartJs = true; response->print(F("\n")); yield(); } } else if (isConnected) { response->print(F("\n")); canLoadChartJs = true; yield(); } response->print(FPSTR(chartsHtml_HeadEnd)); response->print("\n"); response->print(FPSTR(commonNav)); response->print("
\n"); response->print("

Chart

\n"); if (canLoadChartJs) { response->print(FPSTR(chartsHtml_SelectorsUI)); // HTML mit Label über Select yield(); // Sensorfehler-Meldungen String errorMessageHtml = ""; if (demoModus) { errorMessageHtml = "
Demo-Betrieb:
Es sind keine Sensoren angeschlossen.
"; } else if (wasserSensorError && dampfSensorError) { errorMessageHtml = "
Temperatursensoren nicht verfügbar! Bitte prüfen!
"; } else if (wasserSensorError) { errorMessageHtml = "
Temperaturwert Wasser nicht verfügbar
"; } else if (dampfSensorError) { errorMessageHtml = "
Temperaturwert Dampf nicht verfügbar
"; } if (errorMessageHtml.length() > 0) { response->print(errorMessageHtml); } yield(); response->print("
\n"); response->print("\n"); response->print("
\n"); yield(); response->print("\n"); yield(); } else { response->print(FPSTR(chartsHtml_ErrorBox)); yield(); } response->print("
\n"); response->print(FPSTR(chartsHtml_BodyEnd)); request->send(response); } // Ende handleCharts() /************************************************************************************ * 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 * Inklusive Brew-by-Weight und Versionscheck. ************************************************************************************/ void applyProfileSettings(const TemperatureProfile& profileData) { // Serial.printf("Applying settings from loaded profile: %s (Version %d)\n", profileData.profileName, profileData.profileVersion); // --- Prüfen der Profilversion (SEHR WICHTIG!) --- 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 (" + String(profileData.profileVersion) + ", erwartet " + String(CURRENT_PROFILE_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; preInfusionEnabled = profileData.preInfusionEnabled; preInfusionDurationSeconds = profileData.preInfusionDurationSeconds; preInfusionPauseSeconds = profileData.preInfusionPauseSeconds; // Brew-by-Weight Einstellungen anwenden #ifdef ESP32 brewByWeightEnabled = profileData.brewByWeightEnabled; brewByWeightTargetGrams = profileData.brewByWeightTargetGrams; brewByWeightOffsetGrams = profileData.brewByWeightOffsetGrams; #endif // Piezo Status anwenden piezoEnabled = profileData.piezoEnabled; // Dampfverzögerungs-Override anwenden steamDelayOverrideBySwitchEnabled = profileData.steamDelayOverrideBySwitch; // --- 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); EEPROM.put(EEPROM_ADDR_PREINF_ENABLED, profileData.preInfusionEnabled); EEPROM.put(EEPROM_ADDR_PREINF_DUR_SEC, profileData.preInfusionDurationSeconds); EEPROM.put(EEPROM_ADDR_PREINF_PAUSE_SEC, profileData.preInfusionPauseSeconds); // Brew-by-Weight Einstellungen ins EEPROM schreiben #ifdef ESP32 EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_ENABLED, profileData.brewByWeightEnabled); EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_TARGET, profileData.brewByWeightTargetGrams); EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_OFFSET, profileData.brewByWeightOffsetGrams); #endif // Systemtöne / Piezo EEPROM.put(EEPROM_ADDR_PIEZO_ENABLED, profileData.piezoEnabled); // <-- DIESE ZEILE HINZUFÜGEN // Dampfverzögerungs-Override EEPROM.put(EEPROM_ADDR_STEAM_DELAY_OVERRIDE_SWITCH, profileData.steamDelayOverrideBySwitch); EEPROM.commit(); // --- PID-Regler sofort aktualisieren --- pidWasser.SetTunings(KpWasser, KiWasser, KdWasser); pidDampf.SetTunings(KpDampf, KiDampf, KdDampf); pidWasser.SetOutputLimits(0, windowSizeWasser); pidDampf.SetOutputLimits(0, windowSizeDampf); // Serial.println(" PID controllers reconfigured with loaded settings."); // Eco-Status zurücksetzen 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 (inkl. Brew-by-Weight). ************************************************************************************/ TemperatureProfile getCurrentSettingsAsProfile() { TemperatureProfile currentProfile; // Ruft den Konstruktor auf // Setze die aktuelle Versionsnummer in das zu speichernde Profil currentProfile.profileVersion = CURRENT_PROFILE_VERSION; // --- 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.steamDelayOverrideBySwitch = steamDelayOverrideBySwitchEnabled; 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; currentProfile.preInfusionEnabled = preInfusionEnabled; currentProfile.preInfusionDurationSeconds = preInfusionDurationSeconds; currentProfile.preInfusionPauseSeconds = preInfusionPauseSeconds; // Brew-by-Weight Einstellungen in Struktur kopieren #ifdef ESP32 currentProfile.brewByWeightEnabled = brewByWeightEnabled; currentProfile.brewByWeightTargetGrams = brewByWeightTargetGrams; currentProfile.brewByWeightOffsetGrams = brewByWeightOffsetGrams; #else // Setze auf Defaults für ESP8266, falls das Profil dort erstellt wird (unwahrscheinlich, aber sicher) currentProfile.brewByWeightEnabled = defaultBrewByWeightEnabled; currentProfile.brewByWeightTargetGrams = defaultBrewByWeightTargetGrams; currentProfile.brewByWeightOffsetGrams = defaultBrewByWeightOffsetGrams; #endif // Piezo Status in Struktur kopieren currentProfile.piezoEnabled = piezoEnabled; // 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(AsyncWebServerRequest *request) { std::vector profiles = listProfiles(); // Hole die Liste der Profile AsyncResponseStream *response = request->beginResponseStream("text/html"); response->print(FPSTR(profilesPageHead)); response->print(FPSTR(commonStyle)); // Globale Styles response->print(FPSTR(profilesPageStyle)); // Seiten-spezifische Styles und response->print(FPSTR(commonNav)); // Navigation response->print(FPSTR(profilesPageBodyStart)); // und

// Statusmeldung einfügen (falls vorhanden) if (profileStatusMessage.length() > 0) { String statusDiv = "
" + profileStatusMessage + "
"; response->print(statusDiv); profileStatusMessage = ""; // Nachricht entfernen, nachdem sie angezeigt wurde } // Profilliste generieren response->print(FPSTR(profilesPageListStart)); //
    if (profiles.empty()) { response->print(FPSTR(profilesPageNoProfiles)); } else { for (const String& name : profiles) { response->print(FPSTR(profilesPageListItemStart)); //
  • response->print(name); // Profilnamen einfügen String itemEnd = FPSTR(profilesPageListItemEnd); itemEnd.replace("{PROFILE_NAME}", name); // Platzhalter im Template ersetzen response->print(itemEnd); // Rest des
  • Elements yield(); // Bei vielen Profilen } } response->print(FPSTR(profilesPageListEnd)); //
// Speicherformular response->print(FPSTR(profilesPageSaveForm)); // Formular und request->send(response); } // Lädt ein ausgewähltes Profil und wendet es an void handleLoadProfile(AsyncWebServerRequest *request) { if (request->hasArg("profileName")) { String profileToLoad = request->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, da es eventuell zu dieser Firmware-Version nicht mehr kompatibel ist."; } } else { profileStatusMessage = "FEHLER: Kein Profilname zum Laden übermittelt."; } // Redirect zurück zur Profilseite, um die Nachricht anzuzeigen AsyncWebServerResponse *response = request->beginResponse(303); response->addHeader("Location", "/Profile"); request->send(response); } // Speichert die aktuellen Einstellungen unter einem neuen Namen void handleSaveProfile(AsyncWebServerRequest *request) { if (request->hasArg("profileName")) { String newName = request->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 AsyncWebServerResponse *response = request->beginResponse(303); response->addHeader("Location", "/Profile"); request->send(response); } // Löscht ein ausgewähltes Profil void handleDeleteProfile(AsyncWebServerRequest *request) { if (request->hasArg("profileName")) { String profileToDelete = request->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 AsyncWebServerResponse *response = request->beginResponse(303); response->addHeader("Location", "/Profile"); request->send(response); } // *** ENDE 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 --- 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 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(AsyncWebServerRequest *request) { // 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 = request->hasArg("path") ? request->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()); // --- Antwort-Stream erstellen --- AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8"); // --- Header und Style senden --- response->print(FPSTR(fileMgrHead)); // Enthält , , )rawliteral"; static const char dashboardBodyStart[] PROGMEM = R"rawliteral( )rawliteral"; static const char dashboardContainerStart[] PROGMEM = R"rawliteral(

Wasser

--.-
Soll: --.- °C

Dampf

--.-
Soll: --.- °C
Status: Initialisiere...

Bezug aktiv

0.0s
)rawliteral"; #ifdef ESP32 static const char dashboardShotWeight[] PROGMEM = R"rawliteral(
Gewicht: --.- g
)rawliteral"; #endif // Angepasster Control Panel HTML String (Eco Toggle -> Button) static const char dashboardControlPanelHTML[] PROGMEM = R"rawliteral(

Schnelleinstellung

Modus

Wartungsmodus:

Boost & Heizung

Boost Wasser:
Boost Dampf:

Profile

)rawliteral"; #ifdef ESP32 static const char dashboardBrewControlSection[] PROGMEM = R"rawliteral(

Brew Control

Pre-Infusion:
Brew-By-Time:
Brew-By-Weight:
Waage anzeigen:
)rawliteral"; #endif static const char dashboardEndContainer[] PROGMEM = R"rawliteral(
)rawliteral"; // --- Angepasstes JavaScript (v3.10 - Shot Timer Anzeige um 3s verzögert ausblenden) --- static const char dashboardJavaScript[] PROGMEM = R"rawliteral( )rawliteral"; static const char dashboardBodyEnd[] PROGMEM = R"rawliteral( )rawliteral"; // ============================================================================= // C++ Handler Funktionen für das Dashboard // ============================================================================= void handleDashboard(AsyncWebServerRequest *request) { AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8"); response->print(FPSTR(dashboardHtmlHead)); response->print(FPSTR(commonStyle)); // Stelle sicher, dass commonStyle existiert response->print(FPSTR(dashboardStyles)); // Enthält response->print(FPSTR(dashboardBodyStart)); // response->print(FPSTR(commonNav)); // Stelle sicher, dass commonNav existiert // Main Display bis Ende Shot Info response->print(FPSTR(dashboardContainerStart)); #ifdef ESP32 // Optional: Gewichtsanzeige im Main Display response->print(FPSTR(dashboardShotWeight)); #endif // Control Panel HTML holen (enthält jetzt Eco Button statt Toggle) String controlPanelHtml = FPSTR(dashboardControlPanelHTML); // Profil-Optionen generieren und einfügen std::vector profiles = listProfiles(); String profileOptions = ""; if (!profiles.empty()) { for (const String& name : profiles) { String nameHtmlEncoded = name; nameHtmlEncoded.replace("\"", """); profileOptions += ""; yield(); } } controlPanelHtml.replace("{PROFILE_OPTIONS}", profileOptions); response->print(controlPanelHtml); // Sendet den Control Panel Teil BIS zum optionalen Brew Control #ifdef ESP32 // ESP32 spezifischen Brew Control Abschnitt senden response->print(FPSTR(dashboardBrewControlSection)); #endif // Restliche Container schließen response->print(FPSTR(dashboardEndContainer)); // JavaScript einfügen response->print(FPSTR(dashboardJavaScript)); // Body / HTML schließen response->print(FPSTR(dashboardBodyEnd)); request->send(response); } void handleDashboardAction(AsyncWebServerRequest *request) { if (!request->hasArg("action")) { request->send(400, "application/json", "{\"success\":false, \"message\":\"Aktion fehlt!\"}"); return; } String action = request->arg("action"); String value = request->hasArg("value") ? request->arg("value") : ""; bool success = false; String message = ""; bool settingsChanged = false; // Flag für EEPROM Commit bool pidNeedsUpdate = false; // Flag für PID Aktualisierung // Serial.printf("Dashboard Aktion: %s, Wert: %s\n", action.c_str(), value.c_str()); // --- Temperatur-Änderungen --- if (action == "setTempW") { float newVal = value.toFloat(); if (newVal >= 0 && newVal <= 135) { if (abs(newVal - SetpointWasser) > 0.01) { SetpointWasser = newVal; EEPROM.put(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); settingsChanged = true; pidNeedsUpdate = true; message = "Wasser Sollwert auf " + String(newVal, 1) + " °C gesetzt."; success = true; } else { message = "Wasser Sollwert bereits gesetzt."; success = true; } } else { message = "Ungültiger Wasser Sollwert."; } } else if (action == "setTempD") { float newVal = value.toFloat(); if (newVal >= 0 && newVal <= 200) { if (abs(newVal - SetpointDampf) > 0.01) { SetpointDampf = newVal; EEPROM.put(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); settingsChanged = true; pidNeedsUpdate = true; message = "Dampf Sollwert auf " + String(newVal, 1) + " °C gesetzt."; success = true; } else { message = "Dampf Sollwert bereits gesetzt."; success = true; } } else { message = "Ungültiger Dampf Sollwert."; } // --- Eco Modus nur für diesen Zyklus beenden (Button) --- } else if (action == "stopEco") { // Serial.println("[Action: stopEco] Request received."); // Nur ausführen, wenn Eco gerade aktiv kühlt if (ecoModeAktiv) { // Zeit für letzten Bezug auf jetzt setzen, um den Eco-Modus temporär zu deaktivieren lastShotTime = millis(); // Normale Temperaturen wiederherstellen (aus EEPROM) EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); pidNeedsUpdate = true; // PID muss die neuen Setpoints bekommen // *** KEINE ÄNDERUNG MEHR AN ecoModeMinutes oder EEPROM hier! *** // settingsChanged = false; // Sicherstellen, dass kein unnötiger Commit erfolgt message = "Eco Modus für diesen Zyklus beendet."; // Angepasste Meldung success = true; // Serial.println("[Action: stopEco] Deactivated running Eco cooling. Timer setting remains unchanged."); } else { message = "Eco Modus ist derzeit nicht aktiv."; // Angepasste Meldung success = true; // Keine Aktion nötig, aber kein Fehler // Serial.println("[Action: stopEco] Eco currently not active, no action taken."); } } // --- Ende stopEco --- // --- Wartungsmodus Toggle --- else if (action == "toggleMaintenance") { bool newState = (value == "true"); if (newState != wartungsModusAktiv) { wartungsModusAktiv = newState; if (!wartungsModusAktiv) { // Deaktiviert -> Normale Temps laden EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); } else { // Aktiviert -> Wartungstemps setzen SetpointWasser = wartungsModusTemp; SetpointDampf = wartungsModusTemp; } pidNeedsUpdate = true; // Weil Setpoints geändert wurden message = String("Wartungsmodus ") + (wartungsModusAktiv ? "aktiviert." : "deaktiviert."); success = true; // Kein EEPROM Commit, da temporär } else { message="Wartungsmodus unverändert."; success=true;} // --- Heiz-Optionen (Persistent) --- } else if (action == "toggleBoostW") { bool newState = (value == "true"); if (newState != boostWasserActive) { boostWasserActive = newState; EEPROM.put(EEPROM_ADDR_BOOST_WASSER_ACTIVE, boostWasserActive); settingsChanged = true; message = String("Boost Wasser ") + (boostWasserActive ? "aktiviert." : "deaktiviert."); success = true; } else { message="Boost Wasser unverändert."; success = true; } } else if (action == "toggleBoostD") { bool newState = (value == "true"); if (newState != boostDampfActive) { boostDampfActive = newState; EEPROM.put(EEPROM_ADDR_BOOST_DAMPF_ACTIVE, boostDampfActive); settingsChanged = true; message = String("Boost Dampf ") + (boostDampfActive ? "aktiviert." : "deaktiviert."); success = true; } else { message="Boost Dampf unverändert."; success = true; } } else if (action == "overrideSteamDelay") { steamDelayOverridden = true; // Globale Variable setzen message = "Dampfverzögerung temporär übersprungen."; success = true; // Kein EEPROM Commit, da temporär // --- Profil Laden --- } else if (action == "loadProfile") { TemperatureProfile loadedData; String profileNameToLoad = value; profileNameToLoad.replace(""", "\""); // Einfaches HTML Decoding if (loadProfile(profileNameToLoad, loadedData)) { applyProfileSettings(loadedData); // Wendet an und speichert EEPROM message = "Profil '" + String(loadedData.profileName) + "' geladen."; success = true; settingsChanged = false; // Bereits in applyProfileSettings erledigt pidNeedsUpdate = false; // Bereits in applyProfileSettings erledigt } else { message = "Fehler beim Laden von Profil '" + profileNameToLoad + "'."; } // --- Brew Control Toggles (Persistent - ESP32 only) --- #ifdef ESP32 } else if (action == "flush") { if (shotActive || (digitalRead(SHOT_TIMER_PIN) == HIGH)) { message = "Spülen nicht möglich: Bezug aktiv."; success = false; } else if (!manualSwitchReleasedAfterAutoStop) { message = "Spülen nicht möglich: Bezugsschalter zurückstellen."; success = false; } else if (flushActive) { message = "Spülen läuft bereits."; success = true; } else { flushActive = true; flushEndTime = millis() + FLUSH_DURATION_MS; digitalWrite(PUMP_PIN, HIGH); digitalWrite(VALVE_PIN, HIGH); message = "Spülen gestartet (3s)."; success = true; } } else if (action == "togglePI") { bool newState = (value == "true"); if (newState != preInfusionEnabled) { preInfusionEnabled = newState; EEPROM.put(EEPROM_ADDR_PREINF_ENABLED, preInfusionEnabled); settingsChanged = true; message = String("Pre-Infusion ") + (preInfusionEnabled ? "aktiviert." : "deaktiviert."); success = true; } else { message="Pre-Infusion unverändert."; success=true;} } else if (action == "toggleBBT") { bool newState = (value == "true"); if (newState != brewByTimeEnabled) { brewByTimeEnabled = newState; EEPROM.put(EEPROM_ADDR_BREWBYTIME_ENABLED, brewByTimeEnabled); settingsChanged = true; message = String("Brew-By-Time ") + (brewByTimeEnabled ? "aktiviert." : "deaktiviert."); success = true; } else { message="Brew-By-Time unverändert."; success=true;} } else if (action == "toggleBBW") { bool newState = (value == "true"); if (newState != brewByWeightEnabled) { brewByWeightEnabled = newState; EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_ENABLED, brewByWeightEnabled); settingsChanged = true; message = String("Brew-By-Weight ") + (brewByWeightEnabled ? "aktiviert." : "deaktiviert."); success = true; } else { message="Brew-By-Weight unverändert."; success=true;} // --- Waage Aktion (ESP32 only) --- } else if (action == "tareScale") { #if (SCALE_TYPE == SCALE_I2C) if (scaleConnected) { if (!tareScaleAfterDelay && !tareScaleSettling) { tareScaleAfterDelay = true; tareScaleDelayStartTime = millis(); } message = "Tara eingeleitet."; success = true; } else { message = "I2C-Waage nicht verbunden."; } #elif (SCALE_TYPE == SCALE_ESPNOW) message = "Tarieren via UI nur für I2C-Waage."; success = false; // Aktion nicht erfolgreich, da nicht unterstützt #endif } else if (action == "toggleScaleMode") { bool newState = (value == "true"); if (newState != scaleModeActive) { scaleModeActive = newState; // KEIN EEPROM Commit, da dies ein temporärer Modus ist // KEIN settingsChanged = true; message = String("Waage Modus ") + (scaleModeActive ? "aktiviert." : "deaktiviert."); success = true; // Serial.println(message); // Log-Ausgabe } else { message="Waage Modus unverändert."; success=true; } #endif // ESP32 // --- Unbekannte Aktion --- } else { message = "Unbekannte Aktion."; } // --- Abschlussaktionen --- if (settingsChanged) { EEPROM.commit(); } if (pidNeedsUpdate) { pidWasser.SetTunings(KpWasser, KiWasser, KdWasser); pidDampf.SetTunings(KpDampf, KiDampf, KdDampf); // Serial.println("PID-Parameter nach Dashboard-Aktion aktualisiert."); } // JSON Antwort senden char responseBuf[200]; message.replace("\"", "'"); snprintf(responseBuf, sizeof(responseBuf), "{\"success\":%s, \"message\":\"%s\"}", success ? "true" : "false", message.c_str()); request->send(200, "application/json", responseBuf); } // ----------------------------------------------------------------------------- // JSON Builder fuer Dashboard-Daten (HTTP und WebSocket) // ----------------------------------------------------------------------------- void buildDashboardJson(char *buffer, size_t bufferSize) { char floatBuf[10]; // --- Status bestimmen --- String statusText = "Bereit"; String statusKey = "ready"; if (wasserSensorError || dampfSensorError || wasserSafetyShutdown || dampfSafetyShutdown) { statusText = ""; if (wasserSensorError) statusText += "Fehler Wasser-Sensor! "; if (dampfSensorError) statusText += "Fehler Dampf-Sensor! "; if (wasserSafetyShutdown) statusText += "Sicherheitsabsch. Wasser! "; if (dampfSafetyShutdown) statusText += "Sicherheitsabsch. Dampf! "; statusKey = "error"; } else if (wartungsModusAktiv) { statusText = "Wartungsmodus aktiv"; statusKey = "maintenance"; } else if (autoTuneWasserActive) { statusText = "PID Tuning Wasser..."; statusKey = "tuning"; } else if (autoTuneDampfActive) { statusText = "PID Tuning Dampf..."; statusKey = "tuning"; } else if (shotActive) { // *** KORREKTUR START: #ifdef auf eigenen Zeilen *** #ifdef ESP32 switch (currentPreInfusionState) { case PI_PRE_BREW: statusText = "Pre-Infusion..."; break; case PI_PAUSE: statusText = "Pre-Infusion Pause..."; break; case PI_MAIN_BREW:statusText = "Bezug aktiv..."; break; default: statusText = "Bezug aktiv..."; break; } #else // Wenn nicht ESP32 statusText = "Bezug aktiv..."; #endif statusKey = "brewing"; // *** KORREKTUR ENDE *** } else if (ecoModeAktiv) { statusText = dynamicEcoActive ? "Eco+ Modus aktiv (Kühlen)" : "Eco Modus aktiv (Kühlen)"; statusKey = "eco"; } else if (fastHeatUpHeating) { statusText = "Fast Heat-Up aktiv..."; // Logik bleibt, auch wenn Toggle weg ist statusKey = "fastheatup"; } else if (digitalRead(SSR_WASSER_PIN) == HIGH || digitalRead(SSR_DAMPF_PIN) == HIGH) { statusText = "Heizen..."; statusKey = "heating"; } else if (ecoModeMinutes > 0 && !ecoModeAktiv) { // Nur anzeigen wenn Timer gesetzt, aber nicht aktiv kühlt unsigned long elapsedMs = millis() - lastShotTime; unsigned long ecoMs = (unsigned long)ecoModeMinutes * 60UL * 1000UL; long remainingMs = (long)ecoMs - (long)elapsedMs; int remainingMin = 0; if (remainingMs > 0) { remainingMin = (int)((remainingMs + 60000UL - 1) / 60000UL); // Aufrunden auf volle Minuten } statusText = "Bereit (Eco-Modus in " + String(remainingMin) + "min)"; statusKey = "ready_eco_pending"; } // Ansonsten bleibt statusText = "Bereit" // --- Zeit holen --- String currentTime = timeSynced ? timeClient.getFormattedTime() : "N/A"; // --- Dampfverzögerung prüfen --- bool steamDelayIsCurrentlyActive = (dampfVerzoegerung > 0 && !steamDelayOverridden && (millis() - startupTime < (unsigned long)dampfVerzoegerung * 60000UL)); unsigned long shotElapsedMs = shotActive ? (millis() - shotStartTime) : 0; // --- JSON zusammensetzen --- int offset = 0; // Wichtig: offset muss hier deklariert werden! offset += snprintf(buffer + offset, bufferSize - offset, "{"); // Temperaturen dtostrf(InputWasser, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"tempW\":%s,", floatBuf); dtostrf(SetpointWasser, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"setW\":%s,", floatBuf); dtostrf(InputDampf, 5, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"tempD\":%s,", floatBuf); dtostrf(SetpointDampf, 5, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"setD\":%s,", floatBuf); // Status statusText.replace("\"", "\\\""); offset += snprintf(buffer + offset, bufferSize - offset, "\"statusText\":\"%s\",", statusText.c_str()); offset += snprintf(buffer + offset, bufferSize - offset, "\"statusKey\":\"%s\",", statusKey.c_str()); // Shot-Daten offset += snprintf(buffer + offset, bufferSize - offset, "\"shotActive\":%s,", shotActive ? "true" : "false"); offset += snprintf(buffer + offset, bufferSize - offset, "\"shotStart\":%llu,", (unsigned long long)shotStartTime); offset += snprintf(buffer + offset, bufferSize - offset, "\"shotElapsedMs\":%lu,", shotElapsedMs); // Modus-Flags offset += snprintf(buffer + offset, bufferSize - offset, "\"ecoActive\":%s,", ecoModeAktiv ? "true" : "false"); // Wird für Button :disabled benötigt offset += snprintf(buffer + offset, bufferSize - offset, "\"maintenanceActive\":%s,", wartungsModusAktiv ? "true" : "false"); offset += snprintf(buffer + offset, bufferSize - offset, "\"boostWActive\":%s,", boostWasserActive ? "true" : "false"); offset += snprintf(buffer + offset, bufferSize - offset, "\"boostDActive\":%s,", boostDampfActive ? "true" : "false"); offset += snprintf(buffer + offset, bufferSize - offset, "\"steamDelayActiveNow\":%s,", steamDelayIsCurrentlyActive ? "true" : "false"); #ifdef ESP32 offset += snprintf(buffer + offset, bufferSize - offset, "\"flushActive\":%s,", flushActive ? "true" : "false"); #else offset += snprintf(buffer + offset, bufferSize - offset, "\"flushActive\":false,"); #endif // Fehler-Flags offset += snprintf(buffer + offset, bufferSize - offset, "\"errorW\":%s,", wasserSensorError ? "true" : "false"); offset += snprintf(buffer + offset, bufferSize - offset, "\"errorD\":%s,", dampfSensorError ? "true" : "false"); offset += snprintf(buffer + offset, bufferSize - offset, "\"safetyW\":%s,", wasserSafetyShutdown ? "true" : "false"); offset += snprintf(buffer + offset, bufferSize - offset, "\"safetyD\":%s,", dampfSafetyShutdown ? "true" : "false"); // ESP32 spezifische Daten #ifdef ESP32 dtostrf(currentWeightReading, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"weight\":%s,", floatBuf); dtostrf(brewByWeightTargetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"targetWeight\":%s,", floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"scaleConnected\":%s,", scaleConnected ? "true" : "false"); offset += snprintf(buffer + offset, bufferSize - offset, "\"piEnabled\":%s,", preInfusionEnabled ? "true" : "false"); offset += snprintf(buffer + offset, bufferSize - offset, "\"bbtEnabled\":%s,", brewByTimeEnabled ? "true" : "false"); offset += snprintf(buffer + offset, bufferSize - offset, "\"bbwEnabled\":%s,", brewByWeightEnabled ? "true" : "false"); offset += snprintf(buffer + offset, bufferSize - offset, "\"scaleModeActive\":%s,", scaleModeActive ? "true" : "false"); #else offset += snprintf(buffer + offset, bufferSize - offset, "\"weight\":0.0,"); offset += snprintf(buffer + offset, bufferSize - offset, "\"targetWeight\":0.0,"); offset += snprintf(buffer + offset, bufferSize - offset, "\"scaleConnected\":false,"); offset += snprintf(buffer + offset, bufferSize - offset, "\"piEnabled\":false,"); offset += snprintf(buffer + offset, bufferSize - offset, "\"bbtEnabled\":false,"); offset += snprintf(buffer + offset, bufferSize - offset, "\"bbwEnabled\":false,"); #endif // Letztes Element ohne Komma offset += snprintf(buffer + offset, bufferSize - offset, "\"currentTime\":\"%s\"", currentTime.c_str()); offset += snprintf(buffer + offset, bufferSize - offset, "}"); } // ----------------------------------------------------------------------------- // Handler fuer Dashboard-Daten (GET /dashboard-data) - v3.6 Korrigierte #ifdef // ----------------------------------------------------------------------------- void handleDashboardData(AsyncWebServerRequest *request) { char buffer[700]; buildDashboardJson(buffer, sizeof(buffer)); request->send(200, "application/json", buffer); } void onDashboardWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len) { if (type == WS_EVT_CONNECT) { char buffer[700]; buildDashboardJson(buffer, sizeof(buffer)); client->text(buffer); } } void dashboardWsTick() { if (dashboardWs.count() == 0) { return; } unsigned long now = millis(); static bool lastShotActive = false; static bool lastFlushActive = false; static unsigned long burstUntil = 0; bool forcePush = false; #ifdef ESP32 bool flushActiveNow = flushActive; #else bool flushActiveNow = false; #endif if (shotActive && !lastShotActive) { forcePush = true; } else if (!shotActive && lastShotActive) { burstUntil = now + DASHBOARD_WS_SHOT_END_GRACE_MS; forcePush = true; } lastShotActive = shotActive; if (flushActiveNow && !lastFlushActive) { forcePush = true; } else if (!flushActiveNow && lastFlushActive) { forcePush = true; } lastFlushActive = flushActiveNow; bool burstActive = shotActive || flushActiveNow || ((long)(burstUntil - now) > 0); unsigned long interval = burstActive ? DASHBOARD_WS_INTERVAL_ACTIVE_MS : DASHBOARD_WS_INTERVAL_IDLE_MS; if (!forcePush && (now - lastDashboardWsPushMs < interval)) { return; } lastDashboardWsPushMs = now; char buffer[700]; buildDashboardJson(buffer, sizeof(buffer)); dashboardWs.textAll(buffer); } // ============================================================================= // ENDE: Dashboard Code Block // ============================================================================= /************************************************************************************ * Handler für Button der Waage ************************************************************************************/ #if (defined(ESP32) && (SCALE_TYPE == SCALE_I2C)) /** * Verarbeitet den Tastendruck des Waagen-Buttons. * Unterscheidet zwischen kurzem (Tarieren mit Verzögerung) und langem Druck (scaleModeActive umschalten). * Verwendet invertierte Button-Logik (0 = gedrückt). */ void handleScaleButtonPress(AsyncWebServerRequest *request) { // Umbenannt zur Klarheit, dass es um den Press-Event geht (void)request; if (!scaleConnected) { return; } uint8_t currentRawButtonState = scales.getBtnStatus(); // --- Entprellung --- if (currentRawButtonState != lastScaleButtonRawState) { lastScaleButtonDebounceTime = millis(); } lastScaleButtonRawState = currentRawButtonState; if ((millis() - lastScaleButtonDebounceTime) > debounceDelayScale) { if (currentRawButtonState != currentScaleButtonDebouncedState) { currentScaleButtonDebouncedState = currentRawButtonState; // Invertierte Logik: 0 = GEDRÜCKT, 1 (oder !=0) = NICHT GEDRÜCKT if (currentScaleButtonDebouncedState == 0 && lastScaleButtonDebouncedState != 0) { // Fallende Flanke: Button wurde gerade GEDRÜCKT scaleButtonPressStartTime = millis(); scaleButtonIsCurrentlyPressed = true; // Serial.println(F("Waagen-Button gedrückt")); // Für Debugging } else if (currentScaleButtonDebouncedState != 0 && lastScaleButtonDebouncedState == 0) { // Steigende Flanke: Button wurde gerade LOSGELASSEN if (scaleButtonIsCurrentlyPressed) { unsigned long pressDuration = millis() - scaleButtonPressStartTime; // Serial.print(F("Waagen-Button losgelassen. Dauer: ")); Serial.println(pressDuration); // Für Debugging if (pressDuration >= longPressThresholdScale) { // Langer Druck scaleModeActive = !scaleModeActive; // Serial.print(F("Langer Druck: scaleModeActive = ")); Serial.println(scaleModeActive); if (piezoEnabled) { beepShort(scaleModeActive ? 3500 : 3200, 70); // Unterschiedliche Töne für an/aus } } else { // Kurzer Druck: Tarieren nach Verzögerung anstoßen if (scaleConnected && !tareScaleAfterDelay) { // Verhindere mehrfaches Auslösen der Verzögerung // Serial.println(F("Kurzer Druck: Starte Verzögerung für Tara.")); tareScaleAfterDelay = true; tareScaleDelayStartTime = millis(); // Der eigentliche Tara-Befehl erfolgt in loop() nach der Verzögerung } } } scaleButtonIsCurrentlyPressed = false; } } } lastScaleButtonDebouncedState = currentScaleButtonDebouncedState; } #endif