/* * 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), NTC (Gehäuse/Tassenablage). * Funktionen: Eco-Modus, Profile, Fast-Heat-Up, WLAN-Weboberfläche, AutoTune, * Wartungserinnerung, Werkseinstellungen, Hostname-Konfiguration. * * Compiler- und Hardware-Umgebung: * - ESP32: ESP32 S3 DevKit ************************************************************************************/ /************************************************************************************ * WAAGEN-KONFIGURATION ************************************************************************************/ #define SCALE_NONE 0 #define SCALE_I2C 1 #define SCALE_ESPNOW 2 #define SCALE_HX711 3 /************************************************************************************ * NACH BEDARF ANPASSEN - HARDWARE-SPEZIFIKATIONEN ************************************************************************************/ // Standard-Waagen-Typ (kann in /Sensoren geändert werden) #define DEFAULT_SCALE_TYPE SCALE_ESPNOW // 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 StandbyTimerEntry; struct MomentaryButtonState; struct ShotStats; struct BrewControlUpdate; struct SensorSettingsUpdate; struct SensorSettingsApplyResult; struct ServiceSettingsUpdate; struct FullyKioskConfig; class AsyncWebServerRequest; // Vorwaertsdeklarationen fuer Funktionen, die vor ihrer Definition genutzt werden double readNTCTemperature(); double readCaseTemperature(); void updateDisplay(); bool executeDashboardAction(const String& action, const String& value, bool& success, String& message, bool& settingsChanged, bool& pidNeedsUpdate); void touchUartRxTick(); void touchUartTick(); void applyLightOutput(); void initResetDiagnostics(); void recordResetCheckpoint(uint16_t checkpoint); void handleClockPage(AsyncWebServerRequest *request); void syncFullyKioskWithStandby(bool active); /************************************************************************************ * Includes & Bibliotheken ************************************************************************************/ #include #ifdef ENABLE_DISPLAY #include #include #endif // ENABLE_DISPLAY #include #include #ifdef LIBRARY_VERSION #undef LIBRARY_VERSION #endif #include // Modifizierte AutoTune-Funktion - Muss als .zip eingebunden werden #include "max6675.h" #include // für isnan() #include // Dateisystem-Basis #include // FATFS/FFat-Implementierung für ESP32 #include #include #include // für Zeitfunktionen (struct tm, mktime, etc.) #include #include // Erforderlich für std::vector #include // ESP32-only build guard #if !defined(ESP32) #error "This firmware supports ESP32 only." #endif // Keep the existing storage calls and route them to FFat. #define LittleFS FFat // Plattformspezifische Includes (ESP32) // für asynchronen Webserver #include // ESP32 WiFi Core #include // TCP Stack für AsyncWebServer #include // Asynchroner Webserver #include // ESP32 mDNS Implementation #include // Fuer Fully-Kiosk REST-Aufrufe #include // Für Firmware-Updates #include "mbedtls/base64.h" // Base64 fuer Display-OTA ueber UART #ifdef LED_BUILTIN #undef LED_BUILTIN #endif #define LED_BUILTIN 2 // Standard LED Pin für viele ESP32 Boards (optional, falls genutzt) // --- KONDITIONALE WAAGEN-INCLUDES --- #include "UNIT_SCALES.h" // für I2C-Waage #include // für ESP-NOW-Waage #include "HX711.h" // für HX711-Waage // --- ENDE KONDITIONALE WAAGEN-INCLUDES --- /************************************************************************************ * Webserver und zugehörige Objekte ************************************************************************************/ // Asynchroner Webserver (ESP32) 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; #define DASHBOARD_JSON_BUFFER_SIZE 2400 unsigned long lastDashboardWsPushMs = 0; // JC-Touch-Display (UART). Bewusst UNABHAENGIG von ENABLE_DISPLAY (OLED), damit alle // Kombinationen baubar sind (nur OLED / nur JC / beide / keines). Auf 1 setzen, sobald // das JC-Display an Serial1 (GPIO43/44) angeschlossen ist. #define TOUCH_UART_ENABLED 1 #define TOUCH_UART_TX_PIN 43 #define TOUCH_UART_RX_PIN 44 #define TOUCH_UART_BAUDRATE 230400 #define TOUCH_UART_MAX_LINE_LEN 1024 #define TOUCH_UART_PROTOCOL_VERSION 2 // v2: + Netzwerk-/PID-/Duty-/AutoTune-Telemetrie, hello-Handshake #define TOUCH_UART_JSON_BUFFER_SIZE 3000 // v2: groesserer Headroom fuer die zusaetzlichen Felder #define DEFAULT_FULLY_KIOSK_ENABLED 0 #define DEFAULT_FULLY_KIOSK_HOST "" #define DEFAULT_FULLY_KIOSK_PORT 2323 #define DEFAULT_FULLY_KIOSK_PASSWORD "" #define DEFAULT_FULLY_KIOSK_COMMAND_TIMEOUT_MS 700 #define DEFAULT_FULLY_KIOSK_STANDBY_PATH "/clock" #define DEFAULT_FULLY_KIOSK_ACTIVE_PATH "/dashboard" const unsigned long TOUCH_UART_INTERVAL_IDLE_MS = 1000; const unsigned long TOUCH_UART_INTERVAL_ACTIVE_MS = 250; const unsigned long TOUCH_UART_SHOT_END_GRACE_MS = 500; const unsigned long TOUCH_UART_CLIENT_TIMEOUT_MS = 15000; unsigned long lastTouchUartPushMs = 0; HardwareSerial& touchUart = Serial1; String touchUartRxLine; bool touchUartRxOverflow = false; bool touchUartClientActive = false; String touchUartClientFirmware; // vom P4 im hello gemeldeter Firmware-Stand ("" = unbekannt) unsigned long touchUartLastClientSeenMs = 0; bool touchUartLastShotActive = false; bool touchUartLastFlushActive = false; bool touchUartLastSteamCircuitActive = false; // fuer sofortigen Push bei Dampfbezug/Dampf-Flush (setzt steamCircuitActive) bool touchUartLastTareActive = false; // fuer sofortigen Push bei Start der Tarier-Phase (Brew-by-Weight) bool touchUartLastErrorActive = false; // fuer sofortigen Push bei Sensorfehler/Sicherheitsabschaltung bool touchUartLastCleaningActive = false; // fuer sofortigen Push bei Start/Stopp des Reinigungsassistenten bool touchUartLastStandby = false; // fuer sofortigen Push bei Standby-Wechsel (Display an/aus) unsigned long touchUartBurstUntil = 0; bool touchUartWifiScanPending = false; // asynchroner WLAN-Scan laeuft (fuer Display) unsigned long touchUartWifiScanStartMs = 0; // Startzeitpunkt fuer Scan-Timeout volatile bool displayOtaActive = false; // Display-Firmware-Update laeuft (UART exklusiv) bool displayOtaResultOk = false; // Ergebnis des letzten Display-OTA enum ResetDiagCheckpoint : uint16_t { RESET_CP_UNKNOWN = 0, RESET_CP_SETUP_START, RESET_CP_SETUP_WIFI, RESET_CP_SETUP_DISPLAY, RESET_CP_SETUP_SCALE, RESET_CP_SETUP_WEBSERVER, RESET_CP_LOOP_START, RESET_CP_LOOP_TEMPERATURE, RESET_CP_LOOP_SSR, RESET_CP_LOOP_SHOT, RESET_CP_LOOP_ECO, RESET_CP_LOOP_DISPLAY, RESET_CP_LOOP_SCALE, RESET_CP_LOOP_WS, RESET_CP_DISPLAY_RENDER, RESET_CP_SCALE_HX711_READ, RESET_CP_SCALE_I2C_READ, RESET_CP_SCALE_BUTTON_READ, RESET_CP_ESPNOW_RECV, RESET_CP_TOUCH_UART_RX, RESET_CP_TOUCH_UART_TX }; struct ResetDiagRTCData { uint32_t magic; uint16_t version; uint16_t checkpoint; uint32_t uptimeMs; uint32_t freeHeap; uint32_t flags; int16_t waterTempDeciC; int16_t steamTempDeciC; int16_t caseTempDeciC; int16_t weightDeciG; uint8_t scaleType; int8_t wifiStatus; uint8_t wifiMode; uint8_t reserved; }; constexpr uint32_t RESET_DIAG_MAGIC = 0x52445354UL; // "RDST" constexpr uint16_t RESET_DIAG_VERSION = 1; constexpr int16_t RESET_DIAG_INVALID_VALUE = -32768; constexpr uint32_t RESET_DIAG_FLAG_STANDBY = 1UL << 0; constexpr uint32_t RESET_DIAG_FLAG_ECO = 1UL << 1; constexpr uint32_t RESET_DIAG_FLAG_SHOT = 1UL << 2; constexpr uint32_t RESET_DIAG_FLAG_STEAM = 1UL << 3; constexpr uint32_t RESET_DIAG_FLAG_FLUSH = 1UL << 4; constexpr uint32_t RESET_DIAG_FLAG_CLEANING = 1UL << 5; constexpr uint32_t RESET_DIAG_FLAG_SCALE_CONNECTED = 1UL << 6; constexpr uint32_t RESET_DIAG_FLAG_SCALE_MODE = 1UL << 7; constexpr uint32_t RESET_DIAG_FLAG_WIFI_CONNECTED = 1UL << 8; constexpr uint32_t RESET_DIAG_FLAG_TARE_PENDING = 1UL << 9; constexpr uint32_t RESET_DIAG_FLAG_TARE_SETTLING = 1UL << 10; constexpr uint32_t RESET_DIAG_FLAG_SENSOR_W_ERROR = 1UL << 11; constexpr uint32_t RESET_DIAG_FLAG_SENSOR_D_ERROR = 1UL << 12; constexpr uint32_t RESET_DIAG_FLAG_SENSOR_CASE_ERROR = 1UL << 13; constexpr uint32_t RESET_DIAG_FLAG_STEAM_HEAT_DISABLED = 1UL << 14; RTC_DATA_ATTR ResetDiagRTCData rtcResetDiag = {}; // --- Boot-Loop-Absicherung / Rettungs-Modus ------------------------------------------- // Zaehlt aufeinanderfolgende Fruehabstuerze im RTC-Speicher (ueberlebt Warm-/Crash-Resets, // nicht Power-On -> per Magic abgesichert). Nach RESCUE_CRASH_THRESHOLD Abstuerzen faehrt // setup() nur ein erprobtes Minimum hoch (Aktoren aus, WLAN, OTA), damit per Web-UI eine // funktionierende Firmware geflasht werden kann - ohne USB. Siehe enterRescueMode(). RTC_DATA_ATTR uint32_t rtcBootGuardMagic; // gueltig => Werte ueberlebten einen Warm-Reset RTC_DATA_ATTR uint16_t rtcConsecutiveCrashes; // Zahl der Fruehabstuerze in Folge RTC_DATA_ATTR uint16_t rtcRescueEntries; // wie oft schon in Rettung eingetreten (STA nur beim 1. Mal) RTC_DATA_ATTR uint16_t rtcForceRescue; // Test-Ausloeser: naechster Boot -> Rettungs-Modus static const uint32_t BOOT_GUARD_MAGIC = 0x424F4F54UL; // "BOOT" static const uint16_t RESCUE_CRASH_THRESHOLD = 3; // Frueh-Abstuerze in Folge -> Rettung static const uint32_t RESCUE_STABLE_UPTIME_MS = 30000UL; // so lange stabil -> Zaehler zuruecksetzen static bool rescueModeActive = false; bool lastResetDiagAvailable = false; esp_reset_reason_t lastResetReason = ESP_RST_UNKNOWN; ResetDiagRTCData lastResetDiag = {}; unsigned long scheduledRestart = 0; void scheduleRestart(uint32_t delayMs) { scheduledRestart = millis() + delayMs; } /************************************************************************************ * Hardware-Pinbelegung (ESP32 S3 DevKit) ************************************************************************************/ // --- ESP32 S3 DevKit Pinout --- #define SSR_WASSER_PIN 47 // SSR water heater (GPIO47) #define SSR_DAMPF_PIN 48 // SSR steam heater (GPIO48) #define SSR_LIGHT_PIN 15 // SSR light (GPIO15) #define SSR_STEAM_CIRCUIT_PIN 16 // SSR pump + valve (steam circuit) (GPIO16) #define SHOT_TIMER_PIN 8 // Shot switch input (GPIO8) #define SHOT_TIMER_ACTIVE_LEVEL LOW // Pull-up wiring: switch to GND = active #define STEAM_CIRCUIT_SWITCH_PIN 9 // Steam circuit switch input (GPIO9) #define STEAM_CIRCUIT_SWITCH_ACTIVE_LEVEL LOW // Pull-up wiring: switch to GND = on #define STANDBY_SWITCH_PIN 10 // Standby switch input (GPIO10) #define STANDBY_SWITCH_ACTIVE_LEVEL LOW // Pull-up wiring: switch to GND = on #define ECO_SWITCH_PIN 11 // Eco switch input (GPIO11) #define ECO_SWITCH_ACTIVE_LEVEL LOW // Pull-up wiring: switch to GND = on #define X_SWITCH_PIN 42 // X-Switch input (GPIO42) #define X_SWITCH_ACTIVE_LEVEL LOW // Pull-up wiring: switch to GND = on const unsigned long STANDBY_SWITCH_DEBOUNCE_MS = 50; const unsigned long STANDBY_SWITCH_HOLD_HINT_MS = 1500; const unsigned long ECO_SWITCH_DEBOUNCE_MS = 50; const unsigned long X_SWITCH_DEBOUNCE_MS = 50; const unsigned long SHOT_BUTTON_DEBOUNCE_MS = 50; const unsigned long STEAM_BUTTON_DEBOUNCE_MS = 50; const unsigned long DEFAULT_BUTTON_LONG_PRESS_MS = 1000; const unsigned long BUTTON_LONG_PRESS_MIN_MS = 400; const unsigned long BUTTON_LONG_PRESS_MAX_MS = 3000; #define ANALOG_NTC_PIN 4 // NTC 50k divider to 3V3 (GPIO4 / ADC1_CH3) #define ANALOG_CASE_NTC_PIN 5 // NTC 50k divider to 3V3 (GPIO5) #define IIC_5V_SCK 17 // I2C SCK (GPIO17) #define IIC_5V_SDA 18 // I2C SDA (GPIO18) // Piezo #define PIEZO_PIN 14 // Piezo buzzer (GPIO14) // Pump and valve #define PUMP_PIN 7 // Pump SSR (GPIO7) #define VALVE_PIN 6 // Valve SSR (GPIO6) // MAX6675 (steam temp) - SPI pins for ESP32 #define MAX6675_SCK 41 // Serial Clock (GPIO41) #define MAX6675_CS 40 // Chip Select (GPIO40) #define MAX6675_SO 39 // Serial Out (GPIO39) // HX711 (scale) #define HX711_DT_PIN 12 // HX711 DT (GPIO12) #define HX711_SCK_PIN 13 // HX711 SCK (GPIO13) /************************************************************************************ * MAX6675 (Dampf-Temperatursensor) ************************************************************************************/ // Pins are defined above. MAX6675 thermocouple(MAX6675_SCK, MAX6675_CS, MAX6675_SO); /************************************************************************************ * 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 = "5.0.10"; 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 + Zusatzsensor) * - Temperaturmessung über Spannungsteiler an ADC-Pins ************************************************************************************/ 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 /************************************************************************************ * Waagen-Instanz & Variablen (Runtime konfigurierbar) ************************************************************************************/ // --- Gemeinsame Waagen-Variablen --- bool scaleEnabled = true; // Waage aktiv (per Web-UI steuerbar) uint8_t scaleType = DEFAULT_SCALE_TYPE; // Waagen-Typ (SCALE_*) bool scaleConnected = false; // Zeigt an, ob eine Waage verbunden ist (egal welcher Typ) bool scaleModeActive = false; // Fuer Anzeige der Waage im Display volatile float currentWeightReading = 0.0f; // Aktueller Messwert der Waage volatile float scaleDisplayWeightReading = 0.0f; // Geglaetteter Wert fuer den Waage-Modus volatile bool scaleDisplayWeightInitialized = false; float scaleSanitizerSamples[3] = {0.0f, 0.0f, 0.0f}; uint8_t scaleSanitizerSampleCount = 0; uint8_t scaleSanitizerSampleIndex = 0; uint8_t scaleSanitizerSuspectCount = 0; bool scaleSanitizerInitialized = false; const float SCALE_DISPLAY_ZERO_SNAP_GRAMS = 0.05f; const float SCALE_DISPLAY_DEADBAND_GRAMS = 0.05f; const float SCALE_DISPLAY_SMALL_DELTA_GRAMS = 0.3f; const float SCALE_DISPLAY_FAST_DELTA_GRAMS = 1.0f; const float SCALE_DISPLAY_SLOW_ALPHA = 0.18f; const float SCALE_DISPLAY_MID_ALPHA = 0.35f; const float SCALE_DISPLAY_FAST_ALPHA = 0.70f; const float SCALE_SANITIZER_IDLE_SPIKE_GRAMS = 3.0f; const float SCALE_SANITIZER_SHOT_SPIKE_GRAMS = 10.0f; const uint8_t SCALE_SANITIZER_ACCEPT_AFTER_SUSPECTS = 2; volatile float brewStartWeight = 0.0f; const float WEIGHT_CHANGE_THRESHOLD = 0.1f; // Schwelle in g fuer die erste Gewichtsaenderung unsigned long firstWeightChangeTime = 0; // Zeitpunkt der ersten Gewichtsaenderung bool firstWeightChangeDetected = false; // Flag, ob bereits eine Aenderung erkannt wurde unsigned long lastShotFirstWeightChangeTime = 0; // Gespeicherter Zeitpunkt fuer den letzten Shot static void resetScaleDisplayFilter(float rawWeight = 0.0f); static float updateScaleDisplayFilter(float rawWeight, bool force = false); static void resetScaleReadingFilters(float rawWeight = 0.0f); static float acceptScaleReading(float rawWeight, bool forceSanitizer = false, bool forceDisplay = false); static float getWeightReadingForUi(); // --- I2C-spezifische Variablen --- UNIT_SCALES scales; // Instanz fuer die I2C-Waage // Button-Logik fuer 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; // Verzoegertes Tarieren fuer die I2C-Waage bool tareScaleAfterDelay = false; unsigned long tareScaleDelayStartTime = 0; const unsigned long TARE_SCALE_DELAY_DURATION = 1000; bool pendingShotStartAfterScaleTare = false; bool hx711ShotTarePrepared = false; uint8_t hx711ShotPostTareReadsRemaining = 0; // Nachlaufzeit nach dem Tarieren (nicht-blockierend) bool tareScaleSettling = false; unsigned long tareScaleSettlingStartTime = 0; const unsigned long TARE_SCALE_SETTLING_DURATION = 50; // Einmalige, stille Tarierung direkt nach dem Start (ohne Piezo-Piep), // damit nicht sofort ein Restwert (z.B. 13 g) im Display erscheint. bool startupSilentTarePending = false; const uint8_t HX711_POST_TARE_STABILIZATION_READS = 2; // --- ESP-NOW-spezifische Variablen --- typedef struct struct_espnow_scale_message { float weight_g; uint8_t status_flags; uint8_t battery_percentage; } struct_espnow_scale_message; #define ESPNOW_SCALE_FLAG_JUST_TARED (1 << 0) #define ESPNOW_SCALE_FLAG_TOGGLE_MODE (1 << 1) #define ESPNOW_SCALE_FLAG_AWOKE (1 << 2) unsigned long lastScaleMessageTime = 0; const unsigned long SCALE_TIMEOUT = 5000; // 5 Sekunden Timeout // --- HX711-spezifische Variablen --- HX711 hx711; unsigned long hx711LastReadyTime = 0; unsigned long hx711LastReadAttemptTime = 0; bool hx711HasValidReading = false; uint8_t hx711NotReadyCounter = 0; const uint8_t HX711_NOT_READY_LIMIT = 30; // ~2.4s bei 80ms Poll-Intervall bis "getrennt" const unsigned long HX711_MIN_READ_INTERVAL = 80; // HX711-Leseversuche drosseln, um loop() flüssig zu halten /************************************************************************************ * Temperatur-Profil-Struktur (ERWEITERT) * - Speichert nahezu alle konfigurierbaren Einstellungen pro Profil ************************************************************************************/ // VERSIONSNUMMER const uint16_t CURRENT_PROFILE_VERSION = 5; // 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; bool steamHeatDisabledOnStartupWake; // --- Fast-Heat-Up (Seite: /Fast-Heat-Up) --- bool fastHeatUpAktiv; // --- Brew Control Einstellungen bool brewByTimeEnabled; float brewByTimeTargetSeconds; // Geändert bool steamByTimeEnabled; float steamByTimeTargetSeconds; 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 }; struct StandbyTimerEntry { uint32_t id = 0; uint8_t hour = 0; uint8_t minute = 0; uint8_t weekdaysMask = 0x7F; // Bit 0-6 = Montag-Sonntag uint8_t action = 0; // 0 = Standby aktivieren, 1 = Standby aufheben bool enabled = true; int32_t lastTriggeredDateKey = -1; }; struct MomentaryButtonState { bool stablePressed = false; bool lastRawPressed = false; unsigned long lastChangeMs = 0; bool lastStablePressed = false; unsigned long pressedAt = 0; bool longPressHandled = false; }; /************************************************************************************ * 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; double OffsetCase = 0.0; double RawInputDampf = NAN, RawInputWasser = NAN; double displayReferenceRawDampf = NAN, displayReferenceRawWasser = NAN; bool offsetCompensationEnabled = false; const double OFFSET_COMPENSATION_MAX_REFERENCE_RAW_C = 40.0; void resetOffsetCompensationReferences() { displayReferenceRawWasser = NAN; displayReferenceRawDampf = NAN; } void updateOffsetCompensationReferences() { if (!isfinite(displayReferenceRawWasser) && isfinite(RawInputWasser) && RawInputWasser <= OFFSET_COMPENSATION_MAX_REFERENCE_RAW_C) { displayReferenceRawWasser = RawInputWasser; } if (!isfinite(displayReferenceRawDampf) && isfinite(RawInputDampf) && RawInputDampf <= OFFSET_COMPENSATION_MAX_REFERENCE_RAW_C) { displayReferenceRawDampf = RawInputDampf; } } double getDisplayTemperature(double correctedTemp, double rawTemp, double offset, double referenceRaw) { if (!offsetCompensationEnabled || offset >= 0.0 || !isfinite(correctedTemp) || !isfinite(rawTemp) || !isfinite(referenceRaw)) { return correctedTemp; } double switchThreshold = referenceRaw + fabs(offset); if (rawTemp < switchThreshold) { return rawTemp; } return correctedTemp; } double getDisplayedWaterTemperature() { return getDisplayTemperature(InputWasser, RawInputWasser, OffsetWasser, displayReferenceRawWasser); } double getDisplayedSteamTemperature() { return getDisplayTemperature(InputDampf, RawInputDampf, OffsetDampf, displayReferenceRawDampf); } // 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 ************************************************************************************/ enum PreInfusionState { PI_INACTIVE, PI_PRE_BREW, PI_PAUSE, PI_MAIN_BREW }; PreInfusionState currentPreInfusionState = PI_INACTIVE; unsigned long preInfusionPhaseStartTime = 0; // 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; bool flowGuardEnabled = false; float flowGuardMinBrewSeconds = 30.0f; uint16_t flowGuardPulsePeriodMs = 1200; float flowGuardMinDutyPercent = 35.0f; bool steamByTimeEnabled = false; float steamByTimeTargetSeconds = 15.0f; unsigned long steamCircuitSessionStartTime = 0; // Kurz-Flush aus dem Dashboard bool flushActive = false; unsigned long flushEndTime = 0; const uint8_t defaultFlushDurationSeconds = 3; uint8_t flushDurationSeconds = defaultFlushDurationSeconds; bool steamFlushActive = false; unsigned long steamFlushEndTime = 0; const uint8_t defaultSteamFlushDurationSeconds = 3; uint8_t steamFlushDurationSeconds = defaultSteamFlushDurationSeconds; // Helligkeit des UART-Touch-Displays (ESP32-P4) in %, per UART an den P4 gepusht. const uint8_t defaultBacklightActivePercent = 100; // aktiv (Display an) const uint8_t defaultBacklightStandbyClockPercent = 30; // Standby mit Uhrzeit-Anzeige (abgedunkelt) uint8_t backlightActivePercent = defaultBacklightActivePercent; uint8_t backlightStandbyClockPercent = defaultBacklightStandbyClockPercent; bool lightOn = false; // tatsaechlicher Ausgangszustand (gespiegelt ans Display/Web) bool lightDesiredOn = false; // vom Benutzer gewuenschter Zustand (persistent); Ausgang folgt ueber applyLightOutput() // 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 const bool defaultFlowGuardEnabled = false; const float defaultFlowGuardMinBrewSeconds = 30.0f; const uint16_t defaultFlowGuardPulsePeriodMs = 1200; const float defaultFlowGuardMinDutyPercent = 35.0f; const float FLOW_GUARD_MIN_SECONDS_MIN = 5.0f; const float FLOW_GUARD_MIN_SECONDS_MAX = 120.0f; const uint16_t FLOW_GUARD_PULSE_PERIOD_MIN_MS = 200; const uint16_t FLOW_GUARD_PULSE_PERIOD_MAX_MS = 3000; const float FLOW_GUARD_MIN_DUTY_MIN = 5.0f; const float FLOW_GUARD_MIN_DUTY_MAX = 95.0f; const bool defaultSteamByTimeEnabled = false; const float defaultSteamByTimeTargetSeconds = 15.0f; float flowGuardFilteredFlow = NAN; float flowGuardLastNetWeight = NAN; unsigned long flowGuardLastSampleMs = 0; bool flowGuardPumpPulseActive = false; /************************************************************************************ * Standardwerte und Default-Einstellungen ************************************************************************************/ double defaultSetpointDampf = 165.0; double defaultSetpointWasser = 93.0; double defaultOffsetDampf = 0.0; double defaultOffsetWasser = 0.0; double defaultOffsetCase = 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; const bool defaultEcoLightAutoOffEnabled = false; const bool defaultSteamHeatDisabledOnStartupWake = false; const bool defaultStandbyTimeOnDisplay = false; const bool defaultPowerOnStandby = false; const uint8_t DASHBOARD_TEMP_DISPLAY_CLASSIC = 0; const uint8_t DASHBOARD_TEMP_DISPLAY_BARS = 1; const uint8_t DASHBOARD_TEMP_DISPLAY_GAUGES = 2; const uint8_t DASHBOARD_TEMP_DISPLAY_RAILS = 3; const uint8_t defaultDashboardTempDisplayMode = DASHBOARD_TEMP_DISPLAY_CLASSIC; const uint8_t X_SWITCH_ACTION_NONE = 0; const uint8_t X_SWITCH_ACTION_MAINTENANCE = 1; const uint8_t X_SWITCH_ACTION_FAST_HEAT_UP = 2; const uint8_t X_SWITCH_ACTION_STEAM_DELAY_OVERRIDE = 3; const uint8_t X_SWITCH_ACTION_SCALE_MODE = 4; const uint8_t X_SWITCH_ACTION_DISABLE_STEAM_HEAT = 5; const uint8_t X_SWITCH_ACTION_TARE_SCALE = 6; const uint8_t X_SWITCH_ACTION_MAX = X_SWITCH_ACTION_TARE_SCALE; const uint8_t defaultXSwitchAction = X_SWITCH_ACTION_NONE; const uint8_t defaultXSwitchLongAction = X_SWITCH_ACTION_NONE; const uint8_t CASE_SENSOR_TYPE_HOUSING = 0; const uint8_t CASE_SENSOR_TYPE_CUPTRAY = 1; const bool defaultCaseSensorEnabled = false; const uint8_t defaultCaseSensorType = CASE_SENSOR_TYPE_HOUSING; const bool defaultCaseTempOnDashboard = false; const bool defaultCaseTempOnDisplay = false; const bool defaultScaleEnabled = true; const uint8_t defaultScaleType = DEFAULT_SCALE_TYPE; const bool defaultHx711DisplaySmoothingEnabled = true; const float HX711_CALIBRATION_FACTOR = 673.84998f; // TODO: Kalibrierfaktor anpassen float hx711CalibrationFactor = HX711_CALIBRATION_FACTOR; bool hx711DisplaySmoothingEnabled = defaultHx711DisplaySmoothingEnabled; // 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 const int EEPROM_ADDR_CASE_SENSOR_ENABLED = 654; // 1 Byte (bool) -> Ende 654 const int EEPROM_ADDR_CASE_SENSOR_TYPE = 655; // 1 Byte (uint8_t) -> Ende 655 const int EEPROM_ADDR_SCALE_ENABLED = 656; // 1 Byte (bool) -> Ende 656 const int EEPROM_ADDR_SCALE_TYPE = 657; // 1 Byte (uint8_t) -> Ende 657 const int EEPROM_ADDR_HX711_CAL_FACTOR = 658; // 4 Bytes (float) -> Ende 661 const int EEPROM_ADDR_LIGHT_ON = 662; // 1 Byte (bool) -> Ende 662 const int EEPROM_ADDR_ECO_LIGHT_AUTO_OFF = 663; // 1 Byte (bool) -> Ende 663 const int EEPROM_ADDR_STANDBY_TIME_ON_DISPLAY = 664; // 1 Byte (bool) -> Ende 664 const int EEPROM_ADDR_X_SWITCH_ACTION = 665; // 1 Byte (uint8_t) -> Ende 665 const int EEPROM_ADDR_DASHBOARD_TEMP_DISPLAY_MODE = 666; // 1 Byte (uint8_t) -> Ende 666 const int EEPROM_ADDR_CASE_TEMP_DASHBOARD = 667; // 1 Byte (bool) -> Ende 667 const int EEPROM_ADDR_STEAM_HEAT_ON_DRAW = 668; // 1 Byte (bool) -> Ende 668 const int EEPROM_ADDR_FLUSH_DURATION_SECONDS = 669; // 1 Byte (uint8_t) -> Ende 669 const int EEPROM_ADDR_STEAM_FLUSH_DURATION_SECONDS = 670; // 1 Byte (uint8_t) -> Ende 670 const int EEPROM_ADDR_X_SWITCH_LONG_ACTION = 671; // 1 Byte (uint8_t) -> Ende 671 const int EEPROM_ADDR_STEAMBYTIME_ENABLED = 672; // 1 Byte (bool) -> Ende 672 const int EEPROM_ADDR_STEAMBYTIME_SECONDS = 673; // 4 Bytes (float) -> Ende 676 const int EEPROM_ADDR_CASE_TEMP_ON_DISPLAY = 677; // 1 Byte (bool) -> Ende 677 const int EEPROM_ADDR_STEAM_HEAT_DISABLED_ON_STARTUP_WAKE = 678; // 1 Byte (bool) -> Ende 678 const int EEPROM_ADDR_OFFSET_COMPENSATION_ENABLED = 679; // 1 Byte (bool) -> Ende 679 const int EEPROM_ADDR_OFFSET_CASE = 680; // 8 Bytes (double) -> Ende 687 const int EEPROM_ADDR_FLOWGUARD_ENABLED = 688; // 1 Byte (bool) -> Ende 688 const int EEPROM_ADDR_FLOWGUARD_MIN_SECONDS = 689; // 4 Bytes (float) -> Ende 692 const int EEPROM_ADDR_FLOWGUARD_PULSE_PERIOD_MS = 693; // 2 Bytes (uint16_t) -> Ende 694 const int EEPROM_ADDR_FLOWGUARD_MIN_DUTY = 695; // 4 Bytes (float) -> Ende 698 const int EEPROM_ADDR_BUTTON_LONG_PRESS_MS = 699; // 2 Bytes (uint16_t) -> Ende 700 const int EEPROM_ADDR_FULLY_CONFIG_MAGIC = 701; // 5 Bytes (char[5]) -> Ende 705 const int EEPROM_ADDR_FULLY_ENABLED = 706; // 1 Byte (uint8_t) -> Ende 706 const int EEPROM_ADDR_FULLY_HOST = 707; // 64 Bytes -> Ende 770 const int EEPROM_ADDR_FULLY_PASSWORD = 771; // 64 Bytes -> Ende 834 const int EEPROM_ADDR_FULLY_PORT = 835; // 2 Bytes (uint16_t) -> Ende 836 const int EEPROM_ADDR_FULLY_TIMEOUT_MS = 837; // 2 Bytes (uint16_t) -> Ende 838 const int EEPROM_ADDR_FULLY_STANDBY_PATH = 839; // 32 Bytes -> Ende 870 const int EEPROM_ADDR_FULLY_ACTIVE_PATH = 871; // 32 Bytes -> Ende 902 const int EEPROM_ADDR_HX711_DISPLAY_SMOOTHING_ENABLED = 903; // 1 Byte (bool) -> Ende 903 const int EEPROM_ADDR_HEATUP_MINUTES = 904; // 4 Bytes (int) -> Ende 907 const int EEPROM_ADDR_BACKLIGHT_ACTIVE_PERCENT = 908; // 1 Byte (uint8_t) -> Ende 908 const int EEPROM_ADDR_BACKLIGHT_STANDBY_CLOCK_PERCENT = 909; // 1 Byte (uint8_t) -> Ende 909 const int EEPROM_ADDR_POWERON_STANDBY = 910; // 1 Byte (bool) -> Ende 910 // Naechste freie Adresse: 911 (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 int heatUpMinutes = 0; // Aufheizzeit (Minuten) bis "durchgewaermt"; 0 = aus (Anzeige-Countdown ab Start) 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; bool ecoLightAutoOffEnabled = false; bool steamHeatDisabledOnStartupWake = defaultSteamHeatDisabledOnStartupWake; bool standbyTimeOnDisplay = false; bool powerOnStandby = defaultPowerOnStandby; // Nach Neustart/Power-On automatisch in Standby gehen bool standbyModeActive = false; bool standbyOverriddenByWeb = false; bool standbySwitchStableOn = false; bool standbySwitchHeldHintActive = false; MomentaryButtonState standbyButtonState; const char* standbyTimersFilePath = "/standby_timers.dat"; const uint32_t STANDBY_TIMERS_FILE_MAGIC = 0x54494D52UL; // TIMR const uint16_t STANDBY_TIMERS_FILE_VERSION = 1; std::vector standbyTimers; uint32_t nextStandbyTimerId = 1; bool ecoSwitchActive = false; bool xSwitchActive = false; MomentaryButtonState ecoButtonState; MomentaryButtonState shotButtonState; MomentaryButtonState steamButtonState; MomentaryButtonState xButtonState; uint8_t xSwitchAction = defaultXSwitchAction; uint8_t xSwitchLongAction = defaultXSwitchLongAction; uint16_t buttonLongPressMs = (uint16_t)DEFAULT_BUTTON_LONG_PRESS_MS; uint8_t dashboardTempDisplayMode = defaultDashboardTempDisplayMode; unsigned long ecoModeActivatedTime = 0; bool ecoForcedActive = false; /************************************************************************************ * Wartungsmodus Variable ************************************************************************************/ bool wartungsModusAktiv = false; // Ist der Reinigungs-/Wartungsmodus aktiv? int wartungsModusTemp = 35; // Zieltemp Wasser und Dampf im Wartungsmodus const uint8_t defaultCleaningAssistantBrewSeconds = 5; const uint8_t defaultCleaningAssistantCycles = 15; const uint8_t defaultCleaningAssistantPauseSeconds = 10; const double cleaningAssistantWaterSetpoint = 93.0; const double cleaningAssistantWaterReadyTolerance = 1.0; uint8_t cleaningAssistantBrewSeconds = defaultCleaningAssistantBrewSeconds; uint8_t cleaningAssistantCycles = defaultCleaningAssistantCycles; uint8_t cleaningAssistantPauseSeconds = defaultCleaningAssistantPauseSeconds; bool cleaningAssistantActive = false; bool cleaningAssistantWaitingForTemperature = false; bool cleaningAssistantInBrewPhase = false; uint8_t cleaningAssistantCurrentCycle = 0; unsigned long cleaningAssistantPhaseStartTime = 0; String cleaningAssistantStatusMessage = ""; double cleaningAssistantPreviousSetpointWasser = 0.0; double cleaningAssistantPreviousSetpointDampf = 0.0; bool cleaningAssistantPreviousSteamHeatDisabled = false; /************************************************************************************ * 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; bool caseSensorEnabled = defaultCaseSensorEnabled; uint8_t caseSensorType = defaultCaseSensorType; double InputCase = NAN; bool caseSensorError = false; bool caseTempOnDashboard = defaultCaseTempOnDashboard; bool caseTempOnDisplay = defaultCaseTempOnDisplay; /************************************************************************************ * 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; // --- AutoTune-Ergebnis/-Status (fuer Web- und P4-Anzeige) --- // Erlaubt nachzuvollziehen, wie ein Tuning-Lauf endete (Erfolg/Abbruch + Grund), // statt nur "laeuft / laeuft nicht" zu sehen. enum AutoTuneStatus : uint8_t { AT_IDLE = 0, // noch nie gelaufen / nach Neustart AT_RUNNING = 1, // laeuft gerade AT_SUCCESS = 2, // erfolgreich abgeschlossen, neue Werte uebernommen AT_FAILSAFE_PEAKS = 3, // beendet via Failsafe (>9 Peaks, Schwingung nie stabil) - Werte ggf. ungenau AT_ABORT_DEGENERATE = 4, // beendet, aber keine echte Schwingung -> unbrauchbare Werte, NICHT uebernommen AT_ABORT_SENSOR = 5, // Abbruch: Temperatursensorfehler AT_ABORT_SAFETY = 6, // Abbruch: Sicherheitsabschaltung (Uebertemperatur) AT_ABORT_MANUAL = 7 // Abbruch: manuell gestoppt }; AutoTuneStatus autoTuneWasserStatus = AT_IDLE; AutoTuneStatus autoTuneDampfStatus = AT_IDLE; // Ergebniswerte des letzten erfolgreichen Laufs (nur zur Anzeige) float autoTuneWasserResultKp = 0, autoTuneWasserResultKi = 0, autoTuneWasserResultKd = 0; float autoTuneDampfResultKp = 0, autoTuneDampfResultKi = 0, autoTuneDampfResultKd = 0; // 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; const bool defaultSteamHeatOnDraw = false; bool steamHeatOnDraw = defaultSteamHeatOnDraw; bool steamHeatDisabledByUser = 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 shotSoftwareActive = false; bool steamCircuitSoftwareActive = false; bool steamCircuitActive = false; unsigned long steamCircuitStartTime = 0; // Startzeitpunkt des Dampfbezugs (fuer steamElapsedMs ans Display) static void resetScaleDisplayFilter(float rawWeight) { if (!isfinite(rawWeight) || fabs(rawWeight) < SCALE_DISPLAY_ZERO_SNAP_GRAMS) { rawWeight = 0.0f; } scaleDisplayWeightReading = rawWeight; scaleDisplayWeightInitialized = true; } static float updateScaleDisplayFilter(float rawWeight, bool force) { if (!isfinite(rawWeight)) { return scaleDisplayWeightReading; } if (force || !scaleDisplayWeightInitialized) { resetScaleDisplayFilter(rawWeight); return scaleDisplayWeightReading; } if (fabs(rawWeight) < SCALE_DISPLAY_ZERO_SNAP_GRAMS) { rawWeight = 0.0f; } float currentDisplayWeight = scaleDisplayWeightReading; float delta = rawWeight - currentDisplayWeight; float absDelta = fabs(delta); float alpha = SCALE_DISPLAY_SLOW_ALPHA; if (absDelta >= SCALE_DISPLAY_FAST_DELTA_GRAMS) { alpha = SCALE_DISPLAY_FAST_ALPHA; } else if (absDelta >= SCALE_DISPLAY_SMALL_DELTA_GRAMS) { alpha = SCALE_DISPLAY_MID_ALPHA; } float filteredWeight = currentDisplayWeight + (delta * alpha); if (fabs(filteredWeight) < SCALE_DISPLAY_ZERO_SNAP_GRAMS) { filteredWeight = 0.0f; } if (fabs(filteredWeight - currentDisplayWeight) >= SCALE_DISPLAY_DEADBAND_GRAMS || absDelta >= SCALE_DISPLAY_FAST_DELTA_GRAMS) { scaleDisplayWeightReading = filteredWeight; } return scaleDisplayWeightReading; } static float medianOfThree(float a, float b, float c) { if ((a <= b && b <= c) || (c <= b && b <= a)) { return b; } if ((b <= a && a <= c) || (c <= a && a <= b)) { return a; } return c; } static void resetScaleReadingFilters(float rawWeight) { if (!isfinite(rawWeight) || fabs(rawWeight) < SCALE_DISPLAY_ZERO_SNAP_GRAMS) { rawWeight = 0.0f; } currentWeightReading = rawWeight; scaleSanitizerSampleCount = 0; scaleSanitizerSampleIndex = 0; scaleSanitizerSuspectCount = 0; scaleSanitizerInitialized = true; resetScaleDisplayFilter(rawWeight); } static float acceptScaleReading(float rawWeight, bool forceSanitizer, bool forceDisplay) { if (!isfinite(rawWeight)) { return currentWeightReading; } if (forceSanitizer || !scaleSanitizerInitialized) { resetScaleReadingFilters(rawWeight); if (forceDisplay) { resetScaleDisplayFilter(currentWeightReading); } return currentWeightReading; } scaleSanitizerSamples[scaleSanitizerSampleIndex] = rawWeight; scaleSanitizerSampleIndex = (scaleSanitizerSampleIndex + 1) % 3; if (scaleSanitizerSampleCount < 3) { scaleSanitizerSampleCount++; } float candidate = rawWeight; if (scaleSanitizerSampleCount >= 3) { candidate = medianOfThree(scaleSanitizerSamples[0], scaleSanitizerSamples[1], scaleSanitizerSamples[2]); } if (!shotActive && fabs(candidate) < SCALE_DISPLAY_ZERO_SNAP_GRAMS) { candidate = 0.0f; } float spikeLimit = shotActive ? SCALE_SANITIZER_SHOT_SPIKE_GRAMS : SCALE_SANITIZER_IDLE_SPIKE_GRAMS; if (fabs(candidate - currentWeightReading) > spikeLimit) { if (scaleSanitizerSuspectCount < 255) { scaleSanitizerSuspectCount++; } if (scaleSanitizerSuspectCount < SCALE_SANITIZER_ACCEPT_AFTER_SUSPECTS) { updateScaleDisplayFilter(currentWeightReading, forceDisplay); return currentWeightReading; } } else { scaleSanitizerSuspectCount = 0; } currentWeightReading = candidate; scaleSanitizerSuspectCount = 0; updateScaleDisplayFilter(currentWeightReading, forceDisplay); return currentWeightReading; } static bool shouldBypassHx711ScaleModeDisplaySmoothing() { return (scaleType == SCALE_HX711 && scaleModeActive && !shotActive && !hx711DisplaySmoothingEnabled); } static float getWeightReadingForUi() { if (shouldBypassHx711ScaleModeDisplaySmoothing()) { return currentWeightReading; } if (scaleModeActive && !shotActive) { if (!scaleDisplayWeightInitialized) { resetScaleDisplayFilter(currentWeightReading); } return scaleDisplayWeightReading; } return currentWeightReading; } /************************************************************************************ * Shot-Zähler und Betriebszeit ************************************************************************************/ unsigned long shotCounter = 0; // Shots > 20 Sekunden unsigned long totalRuntime = 0; // Gesamt-Betriebszeit in Sekunden unsigned long lastPersistedRuntime = 0; // Zuletzt in EEPROM gespeicherte Betriebszeit 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 bool lastShotStoppedByWeight = false; // war der letzte Bezug gewichtsbasiert? (fuer P4-Endgewicht inkl. Offset) /************************************************************************************ * 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 String standbyTimerStatusMessage = ""; // fuer Nachrichten auf der Timer-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) ************************************************************************************/ static const char commonNav[] PROGMEM = R"rawliteral( )rawliteral"; /************************************************************************************ * 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 struct FullyKioskConfig { bool enabled = (DEFAULT_FULLY_KIOSK_ENABLED != 0); char host[64] = DEFAULT_FULLY_KIOSK_HOST; char password[64] = DEFAULT_FULLY_KIOSK_PASSWORD; uint16_t port = DEFAULT_FULLY_KIOSK_PORT; uint16_t timeoutMs = DEFAULT_FULLY_KIOSK_COMMAND_TIMEOUT_MS; char standbyPath[32] = DEFAULT_FULLY_KIOSK_STANDBY_PATH; char activePath[32] = DEFAULT_FULLY_KIOSK_ACTIVE_PATH; }; FullyKioskConfig fullyKioskConfig; static void setFullyKioskDefaults(FullyKioskConfig& config) { config.enabled = (DEFAULT_FULLY_KIOSK_ENABLED != 0); strncpy(config.host, DEFAULT_FULLY_KIOSK_HOST, sizeof(config.host) - 1); config.host[sizeof(config.host) - 1] = '\0'; strncpy(config.password, DEFAULT_FULLY_KIOSK_PASSWORD, sizeof(config.password) - 1); config.password[sizeof(config.password) - 1] = '\0'; config.port = DEFAULT_FULLY_KIOSK_PORT; config.timeoutMs = DEFAULT_FULLY_KIOSK_COMMAND_TIMEOUT_MS; strncpy(config.standbyPath, DEFAULT_FULLY_KIOSK_STANDBY_PATH, sizeof(config.standbyPath) - 1); config.standbyPath[sizeof(config.standbyPath) - 1] = '\0'; strncpy(config.activePath, DEFAULT_FULLY_KIOSK_ACTIVE_PATH, sizeof(config.activePath) - 1); config.activePath[sizeof(config.activePath) - 1] = '\0'; } static void normalizeFullyKioskConfig(FullyKioskConfig& config) { config.host[sizeof(config.host) - 1] = '\0'; config.password[sizeof(config.password) - 1] = '\0'; config.standbyPath[sizeof(config.standbyPath) - 1] = '\0'; config.activePath[sizeof(config.activePath) - 1] = '\0'; if (config.port == 0) { config.port = DEFAULT_FULLY_KIOSK_PORT; } if (config.timeoutMs < 200 || config.timeoutMs > 5000) { config.timeoutMs = DEFAULT_FULLY_KIOSK_COMMAND_TIMEOUT_MS; } if (strlen(config.standbyPath) == 0) { strncpy(config.standbyPath, DEFAULT_FULLY_KIOSK_STANDBY_PATH, sizeof(config.standbyPath) - 1); config.standbyPath[sizeof(config.standbyPath) - 1] = '\0'; } if (strlen(config.activePath) == 0) { strncpy(config.activePath, DEFAULT_FULLY_KIOSK_ACTIVE_PATH, sizeof(config.activePath) - 1); config.activePath[sizeof(config.activePath) - 1] = '\0'; } if (config.standbyPath[0] != '/' && strncmp(config.standbyPath, "http://", 7) != 0 && strncmp(config.standbyPath, "https://", 8) != 0) { char tmp[sizeof(config.standbyPath)]; strncpy(tmp, config.standbyPath, sizeof(tmp) - 1); tmp[sizeof(tmp) - 1] = '\0'; snprintf(config.standbyPath, sizeof(config.standbyPath), "/%s", tmp); } if (config.activePath[0] != '/' && strncmp(config.activePath, "http://", 7) != 0 && strncmp(config.activePath, "https://", 8) != 0) { char tmp[sizeof(config.activePath)]; strncpy(tmp, config.activePath, sizeof(tmp) - 1); tmp[sizeof(tmp) - 1] = '\0'; snprintf(config.activePath, sizeof(config.activePath), "/%s", tmp); } } static void loadFullyKioskConfig(FullyKioskConfig& config) { char magic[5] = {0}; EEPROM.get(EEPROM_ADDR_FULLY_CONFIG_MAGIC, magic); if (strncmp(magic, storageMagicValue, 4) != 0) { setFullyKioskDefaults(config); return; } uint8_t enabled = 0; EEPROM.get(EEPROM_ADDR_FULLY_ENABLED, enabled); EEPROM.get(EEPROM_ADDR_FULLY_HOST, config.host); EEPROM.get(EEPROM_ADDR_FULLY_PASSWORD, config.password); EEPROM.get(EEPROM_ADDR_FULLY_PORT, config.port); EEPROM.get(EEPROM_ADDR_FULLY_TIMEOUT_MS, config.timeoutMs); EEPROM.get(EEPROM_ADDR_FULLY_STANDBY_PATH, config.standbyPath); EEPROM.get(EEPROM_ADDR_FULLY_ACTIVE_PATH, config.activePath); config.enabled = (enabled == 1); normalizeFullyKioskConfig(config); } static void saveFullyKioskConfig(const FullyKioskConfig& config) { FullyKioskConfig stored = config; normalizeFullyKioskConfig(stored); uint8_t enabled = stored.enabled ? 1 : 0; EEPROM.put(EEPROM_ADDR_FULLY_CONFIG_MAGIC, storageMagicValue); EEPROM.put(EEPROM_ADDR_FULLY_ENABLED, enabled); EEPROM.put(EEPROM_ADDR_FULLY_HOST, stored.host); EEPROM.put(EEPROM_ADDR_FULLY_PASSWORD, stored.password); EEPROM.put(EEPROM_ADDR_FULLY_PORT, stored.port); EEPROM.put(EEPROM_ADDR_FULLY_TIMEOUT_MS, stored.timeoutMs); EEPROM.put(EEPROM_ADDR_FULLY_STANDBY_PATH, stored.standbyPath); EEPROM.put(EEPROM_ADDR_FULLY_ACTIVE_PATH, stored.activePath); EEPROM.commit(); } 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(''>
)rawliteral"; // Schliesst staticFields static const char wifiPageChunk11_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"; // --- 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; loadFullyKioskConfig(fullyKioskConfig); 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)); response->print(F("

Kiosk-Display / Fully Kiosk

")); response->print(F("")); response->print(F("")); response->print(F("")); response->print(F("")); response->print(F("")); response->print(F("")); response->print(F("")); response->print(F("
Standby lädt die Uhr-Seite, Aufwecken lädt das Dashboard. Relative Pfade beziehen sich auf diese Steuerung.
")); response->print(FPSTR(wifiPageChunk11_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(); } FullyKioskConfig newFullyConfig = fullyKioskConfig; newFullyConfig.enabled = request->hasArg(F("fullyEnabled")); if (request->hasArg(F("fullyHost"))) { strncpy(newFullyConfig.host, request->arg(F("fullyHost")).c_str(), sizeof(newFullyConfig.host) - 1); newFullyConfig.host[sizeof(newFullyConfig.host) - 1] = '\0'; } if (request->hasArg(F("fullyPassword"))) { strncpy(newFullyConfig.password, request->arg(F("fullyPassword")).c_str(), sizeof(newFullyConfig.password) - 1); newFullyConfig.password[sizeof(newFullyConfig.password) - 1] = '\0'; } if (request->hasArg(F("fullyPort"))) { uint32_t portValue = (uint32_t)request->arg(F("fullyPort")).toInt(); if (portValue > 0 && portValue <= 65535) { newFullyConfig.port = (uint16_t)portValue; } } if (request->hasArg(F("fullyTimeout"))) { uint32_t timeoutValue = (uint32_t)request->arg(F("fullyTimeout")).toInt(); if (timeoutValue >= 200 && timeoutValue <= 5000) { newFullyConfig.timeoutMs = (uint16_t)timeoutValue; } } if (request->hasArg(F("fullyStandbyPath"))) { strncpy(newFullyConfig.standbyPath, request->arg(F("fullyStandbyPath")).c_str(), sizeof(newFullyConfig.standbyPath) - 1); newFullyConfig.standbyPath[sizeof(newFullyConfig.standbyPath) - 1] = '\0'; } if (request->hasArg(F("fullyActivePath"))) { strncpy(newFullyConfig.activePath, request->arg(F("fullyActivePath")).c_str(), sizeof(newFullyConfig.activePath) - 1); newFullyConfig.activePath[sizeof(newFullyConfig.activePath) - 1] = '\0'; } normalizeFullyKioskConfig(newFullyConfig); // --- 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 saveFullyKioskConfig(newFullyConfig); fullyKioskConfig = newFullyConfig; // 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; lastPersistedRuntime = 0; EEPROM.put(EEPROM_ADDR_RUNTIME, totalRuntime); EEPROM.commit(); AsyncWebServerResponse *response = request->beginResponse(303); response->addHeader(F("Location"), F("/Info")); request->send(response); } static bool waitForHx711Ready(unsigned long timeoutMs) { unsigned long waitStart = millis(); while (!hx711.is_ready() && (millis() - waitStart < timeoutMs)) { delay(10); yield(); } return hx711.is_ready(); } static void sendHx711CalibrationJson(AsyncWebServerRequest *request, bool success, const String& message, float calFactor = NAN, float measuredWeight = NAN, float rawValue = NAN) { String json = "{\"success\":"; json += success ? "true" : "false"; json += ",\"message\":\""; String escapedMessage = message; escapedMessage.replace("\\", "\\\\"); escapedMessage.replace("\"", "\\\""); json += escapedMessage; json += "\""; if (isfinite(calFactor)) { json += ",\"calFactor\":"; json += String(calFactor, 4); } if (isfinite(measuredWeight)) { json += ",\"measuredWeight\":"; json += String(measuredWeight, 2); } if (isfinite(rawValue)) { json += ",\"rawValue\":"; json += String(rawValue, 1); } json += "}"; request->send(200, "application/json", json); } static bool canRunHx711Calibration(AsyncWebServerRequest *request) { if (!scaleEnabled || scaleType != SCALE_HX711) { sendHx711CalibrationJson(request, false, "HX711-Waage ist nicht aktiv."); return false; } if (shotActive || pendingShotStartAfterScaleTare || tareScaleAfterDelay || tareScaleSettling) { sendHx711CalibrationJson(request, false, "Kalibrierung nicht moeglich, waehrend Bezug oder Tara laeuft."); return false; } hx711.power_up(); hx711.set_scale(hx711CalibrationFactor); if (!waitForHx711Ready(1200)) { scaleConnected = false; sendHx711CalibrationJson(request, false, "HX711 ist nicht bereit. Bitte Verbindung pruefen."); return false; } scaleConnected = true; return true; } void handleHx711CalibrationTare(AsyncWebServerRequest *request) { if (!canRunHx711Calibration(request)) { return; } hx711.tare(10); resetScaleReadingFilters(0.0f); hx711LastReadyTime = millis(); hx711HasValidReading = true; hx711NotReadyCounter = 0; sendHx711CalibrationJson(request, true, "Nullpunkt gesetzt. Jetzt Referenzgewicht auflegen und Gewicht eingeben."); } void handleHx711CalibrationApply(AsyncWebServerRequest *request) { if (!canRunHx711Calibration(request)) { return; } if (!request->hasArg("knownWeight")) { sendHx711CalibrationJson(request, false, "Referenzgewicht fehlt."); return; } float knownWeight = request->arg("knownWeight").toFloat(); if (!isfinite(knownWeight) || knownWeight <= 0.0f || knownWeight > 5000.0f) { sendHx711CalibrationJson(request, false, "Referenzgewicht muss zwischen 1 g und 5000 g liegen."); return; } float rawNetValue = hx711.get_value(10); if (!isfinite(rawNetValue) || fabs(rawNetValue) < 50.0f) { sendHx711CalibrationJson(request, false, "Referenzgewicht wurde nicht sicher erkannt. Gewicht auflegen und erneut versuchen."); return; } float newCalFactor = rawNetValue / knownWeight; if (!isfinite(newCalFactor) || fabs(newCalFactor) < 0.0001f) { sendHx711CalibrationJson(request, false, "Kalibrierungsfaktor konnte nicht berechnet werden."); return; } hx711CalibrationFactor = newCalFactor; EEPROM.put(EEPROM_ADDR_HX711_CAL_FACTOR, hx711CalibrationFactor); EEPROM.commit(); hx711.set_scale(hx711CalibrationFactor); float measuredWeight = hx711.get_units(5); acceptScaleReading(measuredWeight, true, true); hx711LastReadyTime = millis(); hx711HasValidReading = true; hx711NotReadyCounter = 0; scaleConnected = true; sendHx711CalibrationJson(request, true, "Kalibrierungsfaktor gespeichert.", hx711CalibrationFactor, measuredWeight, rawNetValue); } /************************************************************************************ * 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; if (!scaleEnabled || scaleType != SCALE_I2C) { return; } (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); } } /************************************************************************************ * EEPROM-Backup auf FFat (Schutz gegen Datenverlust bei unsauberem Reset/Brownout) * - settingsBackupPath haelt eine 1:1-Kopie des EEPROM (EEPROM_SIZE Bytes). * - Wird vor jedem Firmware-Update (S3-Selbst-Update UND P4-Display-Update) * aufgefrischt und beim Boot angelegt, falls noch keins existiert. * - Beim Boot ohne gueltigen Magic-Value wird DARAUS wiederhergestellt, statt * sofort Werkseinstellungen zu setzen. So kostet ein einzelner Crash waehrend * eines Updates nicht mehr alle Einstellungen. ************************************************************************************/ static const char* settingsBackupPath = "/settings_backup.bin"; static const char* settingsBackupTmpPath = "/settings_backup.tmp"; bool eepromRestoredFromBackup = false; // true, wenn beim letzten Boot aus dem Backup wiederhergestellt wurde // Schreibt den aktuellen EEPROM-Inhalt atomar (temp + rename) als Backup. // Nur wenn der aktuelle Inhalt einen gueltigen Magic-Value hat - sonst wuerde ein // bereits beschaedigtes EEPROM ein gutes Backup ueberschreiben. bool writeEepromBackup() { if (!LittleFS.begin()) return false; char magic[5] = {0}; EEPROM.get(EEPROM_ADDR_MAGICVALUE, magic); if (strncmp(magic, storageMagicValue, 4) != 0) return false; static uint8_t backupBuf[EEPROM_SIZE]; for (int i = 0; i < EEPROM_SIZE; i++) backupBuf[i] = EEPROM.read(i); if (LittleFS.exists(settingsBackupTmpPath)) LittleFS.remove(settingsBackupTmpPath); File f = LittleFS.open(settingsBackupTmpPath, FILE_WRITE); if (!f) return false; size_t written = f.write(backupBuf, EEPROM_SIZE); f.flush(); f.close(); if (written != (size_t)EEPROM_SIZE) { LittleFS.remove(settingsBackupTmpPath); return false; } // Atomar ueberschreiben: erst die alte Datei weg, dann temp umbenennen if (LittleFS.exists(settingsBackupPath)) LittleFS.remove(settingsBackupPath); if (!LittleFS.rename(settingsBackupTmpPath, settingsBackupPath)) { LittleFS.remove(settingsBackupTmpPath); return false; } return true; } // Legt ein Backup an, falls noch keins existiert (z. B. erster Boot mit gueltigem EEPROM). void ensureEepromBackupExists() { if (!LittleFS.begin()) return; if (LittleFS.exists(settingsBackupPath)) return; writeEepromBackup(); } // Stellt das EEPROM aus dem FFat-Backup wieder her (true bei Erfolg). bool tryRestoreEepromFromBackup() { if (!LittleFS.begin()) return false; if (!LittleFS.exists(settingsBackupPath)) return false; File f = LittleFS.open(settingsBackupPath, FILE_READ); if (!f) return false; if (f.size() != (size_t)EEPROM_SIZE) { f.close(); return false; } static uint8_t restoreBuf[EEPROM_SIZE]; size_t rd = f.read(restoreBuf, EEPROM_SIZE); f.close(); if (rd != (size_t)EEPROM_SIZE) return false; if (strncmp((const char*)restoreBuf, storageMagicValue, 4) != 0) return false; // Backup unbrauchbar for (int i = 0; i < EEPROM_SIZE; i++) EEPROM.write(i, restoreBuf[i]); return EEPROM.commit(); } /************************************************************************************ * 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); EEPROM.put(EEPROM_ADDR_FLOWGUARD_ENABLED, defaultFlowGuardEnabled); EEPROM.put(EEPROM_ADDR_FLOWGUARD_MIN_SECONDS, defaultFlowGuardMinBrewSeconds); EEPROM.put(EEPROM_ADDR_FLOWGUARD_PULSE_PERIOD_MS, defaultFlowGuardPulsePeriodMs); EEPROM.put(EEPROM_ADDR_FLOWGUARD_MIN_DUTY, defaultFlowGuardMinDutyPercent); EEPROM.put(EEPROM_ADDR_STEAMBYTIME_ENABLED, defaultSteamByTimeEnabled); EEPROM.put(EEPROM_ADDR_STEAMBYTIME_SECONDS, defaultSteamByTimeTargetSeconds); // 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); EEPROM.put(EEPROM_ADDR_ECO_LIGHT_AUTO_OFF, defaultEcoLightAutoOffEnabled); EEPROM.put(EEPROM_ADDR_STEAM_HEAT_DISABLED_ON_STARTUP_WAKE, defaultSteamHeatDisabledOnStartupWake); EEPROM.put(EEPROM_ADDR_STANDBY_TIME_ON_DISPLAY, defaultStandbyTimeOnDisplay); EEPROM.put(EEPROM_ADDR_X_SWITCH_ACTION, defaultXSwitchAction); EEPROM.put(EEPROM_ADDR_X_SWITCH_LONG_ACTION, defaultXSwitchLongAction); EEPROM.put(EEPROM_ADDR_BUTTON_LONG_PRESS_MS, (uint16_t)DEFAULT_BUTTON_LONG_PRESS_MS); EEPROM.put(EEPROM_ADDR_DASHBOARD_TEMP_DISPLAY_MODE, defaultDashboardTempDisplayMode); EEPROM.put(EEPROM_ADDR_CASE_SENSOR_ENABLED, (uint8_t)(defaultCaseSensorEnabled ? 1 : 0)); EEPROM.put(EEPROM_ADDR_CASE_SENSOR_TYPE, defaultCaseSensorType); EEPROM.put(EEPROM_ADDR_CASE_TEMP_DASHBOARD, (uint8_t)(defaultCaseTempOnDashboard ? 1 : 0)); EEPROM.put(EEPROM_ADDR_CASE_TEMP_ON_DISPLAY, (uint8_t)(defaultCaseTempOnDisplay ? 1 : 0)); EEPROM.put(EEPROM_ADDR_OFFSET_CASE, defaultOffsetCase); EEPROM.put(EEPROM_ADDR_STEAM_HEAT_ON_DRAW, (uint8_t)(defaultSteamHeatOnDraw ? 1 : 0)); EEPROM.put(EEPROM_ADDR_FLUSH_DURATION_SECONDS, defaultFlushDurationSeconds); EEPROM.put(EEPROM_ADDR_STEAM_FLUSH_DURATION_SECONDS, defaultSteamFlushDurationSeconds); EEPROM.put(EEPROM_ADDR_BACKLIGHT_ACTIVE_PERCENT, defaultBacklightActivePercent); EEPROM.put(EEPROM_ADDR_BACKLIGHT_STANDBY_CLOCK_PERCENT, defaultBacklightStandbyClockPercent); EEPROM.put(EEPROM_ADDR_POWERON_STANDBY, (uint8_t)(defaultPowerOnStandby ? 1 : 0)); EEPROM.put(EEPROM_ADDR_OFFSET_COMPENSATION_ENABLED, (uint8_t)0); EEPROM.put(EEPROM_ADDR_SCALE_ENABLED, (uint8_t)(defaultScaleEnabled ? 1 : 0)); EEPROM.put(EEPROM_ADDR_SCALE_TYPE, defaultScaleType); EEPROM.put(EEPROM_ADDR_HX711_CAL_FACTOR, HX711_CALIBRATION_FACTOR); EEPROM.put(EEPROM_ADDR_HX711_DISPLAY_SMOOTHING_ENABLED, (uint8_t)(defaultHx711DisplaySmoothingEnabled ? 1 : 0)); EEPROM.put(EEPROM_ADDR_HEATUP_MINUTES, (int)0); EEPROM.put(EEPROM_ADDR_LIGHT_ON, (uint8_t)0); setFullyKioskDefaults(fullyKioskConfig); saveFullyKioskConfig(fullyKioskConfig); // 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; OffsetCase = defaultOffsetCase; offsetCompensationEnabled = false; KpWasser = defaultKpWasser; KiWasser = defaultKiWasser; KdWasser = defaultKdWasser; KpDampf = defaultKpDampf; KiDampf = defaultKiDampf; KdDampf = defaultKdDampf; ecoModeMinutes = defaultEcoModeMinutes; ecoModeTempWasser = defaultEcoModeTempWasser; ecoModeTempDampf = defaultEcoModeTempDampf; dynamicEcoActive = false; fastHeatUpAktiv = false; dampfVerzoegerung = defaultDampfVerzoegerung; heatUpMinutes = 0; 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 brewByWeightEnabled = defaultBrewByWeightEnabled; brewByWeightTargetGrams = defaultBrewByWeightTargetGrams; brewByWeightOffsetGrams = defaultBrewByWeightOffsetGrams; flowGuardEnabled = defaultFlowGuardEnabled; flowGuardMinBrewSeconds = defaultFlowGuardMinBrewSeconds; flowGuardPulsePeriodMs = defaultFlowGuardPulsePeriodMs; flowGuardMinDutyPercent = defaultFlowGuardMinDutyPercent; flowGuardFilteredFlow = NAN; flowGuardLastNetWeight = NAN; flowGuardLastSampleMs = 0; flowGuardPumpPulseActive = false; steamByTimeEnabled = defaultSteamByTimeEnabled; steamByTimeTargetSeconds = defaultSteamByTimeTargetSeconds; // 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; ecoLightAutoOffEnabled = defaultEcoLightAutoOffEnabled; steamHeatDisabledOnStartupWake = defaultSteamHeatDisabledOnStartupWake; standbyTimeOnDisplay = defaultStandbyTimeOnDisplay; xSwitchAction = defaultXSwitchAction; xSwitchLongAction = defaultXSwitchLongAction; buttonLongPressMs = (uint16_t)DEFAULT_BUTTON_LONG_PRESS_MS; dashboardTempDisplayMode = defaultDashboardTempDisplayMode; xSwitchActive = false; ecoForcedActive = false; standbyOverriddenByWeb = false; caseSensorEnabled = defaultCaseSensorEnabled; caseSensorType = defaultCaseSensorType; OffsetCase = defaultOffsetCase; caseTempOnDisplay = defaultCaseTempOnDisplay; caseTempOnDashboard = defaultCaseTempOnDashboard; steamHeatOnDraw = defaultSteamHeatOnDraw; flushDurationSeconds = defaultFlushDurationSeconds; steamFlushDurationSeconds = defaultSteamFlushDurationSeconds; backlightActivePercent = defaultBacklightActivePercent; backlightStandbyClockPercent = defaultBacklightStandbyClockPercent; powerOnStandby = defaultPowerOnStandby; scaleEnabled = defaultScaleEnabled; scaleType = defaultScaleType; hx711CalibrationFactor = HX711_CALIBRATION_FACTOR; hx711DisplaySmoothingEnabled = defaultHx711DisplaySmoothingEnabled; scaleConnected = false; scaleModeActive = false; resetScaleReadingFilters(0.0f); brewStartWeight = 0.0f; pendingShotStartAfterScaleTare = false; hx711ShotTarePrepared = false; firstWeightChangeDetected = false; lightDesiredOn = false; lightOn = false; digitalWrite(SSR_LIGHT_PIN, LOW); standbyModeActive = false; InputCase = NAN; caseSensorError = false; // 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 ************************************************************************************/ // 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 } /************************************************************************************ * ESP-NOW Empfangsfunktion ************************************************************************************/ /** * @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) { recordResetCheckpoint(RESET_CP_ESPNOW_RECV); if (!scaleEnabled || scaleType != SCALE_ESPNOW) { return; } // 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 bool wasScaleConnected = scaleConnected; lastScaleMessageTime = millis(); // Zeit des letzten Empfangs für Timeout merken bool forceDisplayFilter = !wasScaleConnected; bool forceSanitizer = !wasScaleConnected; // 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; if (scaleModeActive) { forceDisplayFilter = true; } // 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) { forceSanitizer = true; forceDisplayFilter = true; // 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."); } acceptScaleReading(receivedData.weight_g, forceSanitizer, forceDisplayFilter); } } void resetScaleState() { scaleConnected = false; scaleModeActive = false; resetScaleReadingFilters(0.0f); brewStartWeight = 0.0f; firstWeightChangeDetected = false; tareScaleAfterDelay = false; tareScaleSettling = false; startupSilentTarePending = false; pendingShotStartAfterScaleTare = false; hx711ShotTarePrepared = false; hx711ShotPostTareReadsRemaining = 0; scaleButtonIsCurrentlyPressed = false; lastScaleMessageTime = 0; hx711LastReadyTime = 0; hx711LastReadAttemptTime = 0; hx711HasValidReading = false; hx711NotReadyCounter = 0; } void deinitScale() { if (scaleType == SCALE_ESPNOW) { esp_now_deinit(); } else if (scaleType == SCALE_HX711) { hx711.power_down(); } resetScaleState(); } void initScale() { resetScaleState(); if (!scaleEnabled || scaleType == SCALE_NONE) { return; } if (scaleType == SCALE_I2C) { if (!scales.begin(&Wire)) { scaleConnected = false; } else { scaleConnected = true; } } else if (scaleType == SCALE_ESPNOW) { esp_now_deinit(); if (esp_now_init() == ESP_OK) { esp_now_register_recv_cb(OnDataRecv); } } else if (scaleType == SCALE_HX711) { hx711.begin(HX711_DT_PIN, HX711_SCK_PIN); hx711.power_up(); hx711.set_scale(hx711CalibrationFactor); unsigned long waitStart = millis(); while (!hx711.is_ready() && (millis() - waitStart < 1000)) { delay(10); yield(); } if (hx711.is_ready()) { hx711.tare(); resetScaleReadingFilters(0.0f); hx711LastReadyTime = millis(); hx711HasValidReading = true; hx711NotReadyCounter = 0; scaleConnected = true; } } } /************************************************************************************ * Variable für Systemstartzeit (relevant für die Dampf-Verzögerung) - Unverändert ************************************************************************************/ void updateHx711ReadingState() { recordResetCheckpoint(RESET_CP_SCALE_HX711_READ); unsigned long nowMs = millis(); if (nowMs - hx711LastReadAttemptTime < HX711_MIN_READ_INTERVAL) { return; } hx711LastReadAttemptTime = nowMs; if (hx711.is_ready()) { acceptScaleReading(hx711.get_units(1)); hx711LastReadyTime = nowMs; hx711HasValidReading = true; hx711NotReadyCounter = 0; scaleConnected = true; return; } if (hx711HasValidReading) { if (hx711NotReadyCounter < 255) { hx711NotReadyCounter++; } if (hx711NotReadyCounter >= HX711_NOT_READY_LIMIT) { scaleConnected = false; } } } unsigned long startupTime; void applyCleaningAssistantTemperatureTargets() { SetpointWasser = cleaningAssistantWaterSetpoint; steamHeatDisabledByUser = true; OutputDampf = 0; } static void applySteamHeatDisabledOnStartupWakeIfConfigured() { if (steamHeatDisabledOnStartupWake) { steamHeatDisabledByUser = true; OutputDampf = 0; } } // Bringt die Maschine beim Controller-Start in den Standby, wenn so konfiguriert // (ECO-Einstellung "Nach Neustart/Power-On in Standby gehen"). Spiegelt die // wesentlichen Effekte der Dashboard-Aktion "activateStandby". Nur einmalig in setup() // aufrufen - NICHT bei jeder Standby-Transition. static void applyPowerOnStandbyIfConfigured() { if (powerOnStandby) { standbyModeActive = true; ecoForcedActive = false; steamDelayOverridden = false; SetpointWasser = 0; SetpointDampf = 0; OutputWasser = 0; OutputDampf = 0; shotSoftwareActive = false; steamCircuitSoftwareActive = false; steamFlushActive = false; steamCircuitActive = false; ecoModeAktiv = false; ecoModeActivatedTime = 0; applyLightOutput(); // Standby unterdrueckt den Lichtausgang (Wunsch bleibt erhalten) } } static void initMomentaryButtonState(MomentaryButtonState& button, bool initialPressed, unsigned long currentMillis) { button.stablePressed = initialPressed; button.lastRawPressed = initialPressed; button.lastChangeMs = currentMillis; button.lastStablePressed = initialPressed; button.pressedAt = initialPressed ? currentMillis : 0; button.longPressHandled = false; } static void updateMomentaryButtonState( MomentaryButtonState& button, bool rawPressed, unsigned long debounceMs, unsigned long currentMillis ) { if (rawPressed != button.lastRawPressed) { button.lastRawPressed = rawPressed; button.lastChangeMs = currentMillis; } if (currentMillis - button.lastChangeMs >= debounceMs) { button.stablePressed = button.lastRawPressed; } } static bool buttonJustPressed(MomentaryButtonState& button, unsigned long currentMillis) { if (button.stablePressed && !button.lastStablePressed) { button.pressedAt = currentMillis; button.longPressHandled = false; return true; } return false; } static bool buttonJustReleased(const MomentaryButtonState& button) { return !button.stablePressed && button.lastStablePressed; } static bool buttonLongPressReady(const MomentaryButtonState& button, unsigned long holdMs, unsigned long currentMillis) { return button.stablePressed && button.pressedAt > 0 && !button.longPressHandled && (currentMillis - button.pressedAt >= holdMs); } static void finalizeMomentaryButtonState(MomentaryButtonState& button) { if (!button.stablePressed) { button.pressedAt = 0; button.longPressHandled = false; } button.lastStablePressed = button.stablePressed; } static bool invokeDashboardAction(const String& action, const String& value = "") { bool success = false; String message = ""; bool settingsChanged = false; bool pidNeedsUpdate = false; executeDashboardAction(action, value, success, message, settingsChanged, pidNeedsUpdate); return success; } static void executeXSwitchAction(uint8_t action) { if (action == X_SWITCH_ACTION_MAINTENANCE) { if (wartungsModusAktiv) { wartungsModusAktiv = false; EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); } else { wartungsModusAktiv = true; SetpointWasser = wartungsModusTemp; SetpointDampf = wartungsModusTemp; } } else if (action == X_SWITCH_ACTION_FAST_HEAT_UP) { if (fastHeatUpAktiv) { fastHeatUpAktiv = false; fastHeatUpHeating = false; EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); } else { fastHeatUpAktiv = true; fastHeatUpHeating = true; } } else if (action == X_SWITCH_ACTION_STEAM_DELAY_OVERRIDE) { steamDelayOverridden = !steamDelayOverridden; } else if (action == X_SWITCH_ACTION_SCALE_MODE) { bool newScaleModeState = !scaleModeActive; if (newScaleModeState && scaleEnabled && scaleType == SCALE_HX711) { requestHx711Tare(false, true); } else { scaleModeActive = newScaleModeState; if (scaleModeActive) { resetScaleDisplayFilter(currentWeightReading); } } } else if (action == X_SWITCH_ACTION_DISABLE_STEAM_HEAT) { steamHeatDisabledByUser = !steamHeatDisabledByUser; if (steamHeatDisabledByUser) { OutputDampf = 0; } } else if (action == X_SWITCH_ACTION_TARE_SCALE) { invokeDashboardAction("tareScale"); } } static bool isXSwitchActionActive(uint8_t action) { if (action == X_SWITCH_ACTION_MAINTENANCE) { return wartungsModusAktiv; } if (action == X_SWITCH_ACTION_FAST_HEAT_UP) { return fastHeatUpAktiv; } if (action == X_SWITCH_ACTION_STEAM_DELAY_OVERRIDE) { return steamDelayOverridden; } if (action == X_SWITCH_ACTION_SCALE_MODE) { return scaleModeActive; } if (action == X_SWITCH_ACTION_DISABLE_STEAM_HEAT) { return steamHeatDisabledByUser; } return false; } static bool isHx711TareSequenceActive() { return scaleEnabled && scaleType == SCALE_HX711 && (tareScaleAfterDelay || tareScaleSettling); } static void requestHx711Tare(bool prepareShotStart, bool keepScaleModeActive) { if (!scaleEnabled || scaleType != SCALE_HX711) { return; } if (!(scaleConnected || hx711.is_ready() || hx711HasValidReading)) { scaleModeActive = keepScaleModeActive; return; } pendingShotStartAfterScaleTare = prepareShotStart; if (!prepareShotStart) { hx711ShotTarePrepared = false; } hx711ShotPostTareReadsRemaining = 0; scaleModeActive = keepScaleModeActive; if (!tareScaleAfterDelay && !tareScaleSettling) { tareScaleAfterDelay = true; tareScaleDelayStartTime = millis(); } } static uint8_t standbyTimerWeekdayBitFromTmWday(int tmWday) { return (tmWday == 0) ? 6 : (uint8_t)(tmWday - 1); // Montag = 0, Sonntag = 6 } static int standbyTimerDateKeyFromTm(const tm& timeinfo) { return (timeinfo.tm_year + 1900) * 10000 + (timeinfo.tm_mon + 1) * 100 + timeinfo.tm_mday; } static String standbyTimerActionLabel(uint8_t action) { return (action == 0) ? "Standby aktivieren" : "Standby aufheben"; } static String standbyTimerWeekdaysLabel(uint8_t weekdaysMask) { static const char* labels[7] = {"Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"}; if (weekdaysMask == 0x7F) { return "Taeglich"; } String result; for (uint8_t i = 0; i < 7; ++i) { if ((weekdaysMask & (1U << i)) == 0) { continue; } if (result.length() > 0) { result += ", "; } result += labels[i]; } return result.length() > 0 ? result : "Kein Wochentag"; } static StandbyTimerEntry* findStandbyTimerById(uint32_t id) { for (size_t i = 0; i < standbyTimers.size(); ++i) { if (standbyTimers[i].id == id) { return &standbyTimers[i]; } } return nullptr; } static void sortStandbyTimers() { std::sort(standbyTimers.begin(), standbyTimers.end(), [](const StandbyTimerEntry& a, const StandbyTimerEntry& b) { const int aTotalMinutes = (int)a.hour * 60 + (int)a.minute; const int bTotalMinutes = (int)b.hour * 60 + (int)b.minute; if (aTotalMinutes != bTotalMinutes) { return aTotalMinutes < bTotalMinutes; } if (a.action != b.action) { return a.action < b.action; } return a.id < b.id; }); } bool saveStandbyTimers() { struct StandbyTimersFileHeader { uint32_t magic; uint16_t version; uint16_t count; uint32_t nextId; }; struct PersistedStandbyTimerEntry { uint32_t id; uint8_t hour; uint8_t minute; uint8_t weekdaysMask; uint8_t action; uint8_t enabled; }; sortStandbyTimers(); if (LittleFS.exists(standbyTimersFilePath)) { LittleFS.remove(standbyTimersFilePath); } File file = LittleFS.open(standbyTimersFilePath, FILE_WRITE); if (!file) { return false; } StandbyTimersFileHeader header; header.magic = STANDBY_TIMERS_FILE_MAGIC; header.version = STANDBY_TIMERS_FILE_VERSION; header.count = (uint16_t)standbyTimers.size(); header.nextId = nextStandbyTimerId; if (file.write((const uint8_t*)&header, sizeof(header)) != sizeof(header)) { file.close(); return false; } for (size_t i = 0; i < standbyTimers.size(); ++i) { PersistedStandbyTimerEntry persisted; persisted.id = standbyTimers[i].id; persisted.hour = standbyTimers[i].hour; persisted.minute = standbyTimers[i].minute; persisted.weekdaysMask = standbyTimers[i].weekdaysMask; persisted.action = standbyTimers[i].action; persisted.enabled = standbyTimers[i].enabled ? 1 : 0; if (file.write((const uint8_t*)&persisted, sizeof(persisted)) != sizeof(persisted)) { file.close(); return false; } } file.flush(); file.close(); return true; } bool loadStandbyTimers() { standbyTimers.clear(); nextStandbyTimerId = 1; if (!LittleFS.exists(standbyTimersFilePath)) { return true; } struct StandbyTimersFileHeader { uint32_t magic; uint16_t version; uint16_t count; uint32_t nextId; }; struct PersistedStandbyTimerEntry { uint32_t id; uint8_t hour; uint8_t minute; uint8_t weekdaysMask; uint8_t action; uint8_t enabled; }; File file = LittleFS.open(standbyTimersFilePath, FILE_READ); if (!file) { return false; } StandbyTimersFileHeader header; if (file.read((uint8_t*)&header, sizeof(header)) != sizeof(header) || header.magic != STANDBY_TIMERS_FILE_MAGIC || header.version != STANDBY_TIMERS_FILE_VERSION) { file.close(); return false; } nextStandbyTimerId = (header.nextId == 0) ? 1 : header.nextId; for (uint16_t i = 0; i < header.count; ++i) { PersistedStandbyTimerEntry persisted; if (file.read((uint8_t*)&persisted, sizeof(persisted)) != sizeof(persisted)) { file.close(); return false; } StandbyTimerEntry timer; timer.id = persisted.id; timer.hour = min((int)persisted.hour, 23); timer.minute = min((int)persisted.minute, 59); timer.weekdaysMask = persisted.weekdaysMask & 0x7F; timer.action = (persisted.action == 0) ? 0 : 1; timer.enabled = (persisted.enabled != 0); timer.lastTriggeredDateKey = -1; standbyTimers.push_back(timer); if (timer.id >= nextStandbyTimerId) { nextStandbyTimerId = timer.id + 1; } } file.close(); sortStandbyTimers(); return true; } void evaluateStandbyTimers() { if (!timeSynced || standbyTimers.empty()) { return; } time_t now_t; time(&now_t); struct tm localTime; localtime_r(&now_t, &localTime); if (localTime.tm_year < (2024 - 1900)) { return; } const uint8_t weekdayBit = standbyTimerWeekdayBitFromTmWday(localTime.tm_wday); const uint8_t weekdayMask = (uint8_t)(1U << weekdayBit); const int dateKey = standbyTimerDateKeyFromTm(localTime); for (size_t i = 0; i < standbyTimers.size(); ++i) { StandbyTimerEntry& timer = standbyTimers[i]; if (!timer.enabled || timer.weekdaysMask == 0) { continue; } if ((timer.weekdaysMask & weekdayMask) == 0) { continue; } if (timer.hour != localTime.tm_hour || timer.minute != localTime.tm_min) { continue; } if (timer.lastTriggeredDateKey == dateKey) { continue; } standbyModeActive = (timer.action == 0); timer.lastTriggeredDateKey = dateKey; } } bool canStartCleaningAssistant(String& reason) { if (cleaningAssistantActive) { reason = "Reinigungsassistent läuft bereits."; return false; } if (standbyModeActive) { reason = "Standby aktiv - Start nicht moeglich."; return false; } if (wartungsModusAktiv || autoTuneWasserActive || autoTuneDampfActive) { reason = "Start im Wartungsmodus/PID-Tuning nicht moeglich."; return false; } if (fastHeatUpAktiv || fastHeatUpHeating) { reason = "Start im Fast-Heat-Up nicht moeglich."; return false; } if (shotActive || shotSoftwareActive) { reason = "Bezug aktiv - Start nicht moeglich."; return false; } if (flushActive || steamFlushActive) { reason = "Flush aktiv - Start nicht moeglich."; return false; } if (steamCircuitActive || steamCircuitSoftwareActive) { reason = "Dampfkreis aktiv - Start nicht moeglich."; return false; } return true; } void stopCleaningAssistant(const String& statusMessage = "") { cleaningAssistantActive = false; cleaningAssistantWaitingForTemperature = false; cleaningAssistantInBrewPhase = false; cleaningAssistantCurrentCycle = 0; cleaningAssistantPhaseStartTime = 0; if (standbyModeActive) { SetpointWasser = 0; SetpointDampf = 0; } else { SetpointWasser = cleaningAssistantPreviousSetpointWasser; SetpointDampf = cleaningAssistantPreviousSetpointDampf; } steamHeatDisabledByUser = cleaningAssistantPreviousSteamHeatDisabled; digitalWrite(PUMP_PIN, LOW); digitalWrite(VALVE_PIN, LOW); cleaningAssistantStatusMessage = statusMessage; } void startCleaningAssistant() { cleaningAssistantPreviousSetpointWasser = SetpointWasser; cleaningAssistantPreviousSetpointDampf = SetpointDampf; cleaningAssistantPreviousSteamHeatDisabled = steamHeatDisabledByUser; cleaningAssistantActive = true; cleaningAssistantWaitingForTemperature = (!isfinite(InputWasser) || (InputWasser < (cleaningAssistantWaterSetpoint - cleaningAssistantWaterReadyTolerance))); cleaningAssistantInBrewPhase = !cleaningAssistantWaitingForTemperature; cleaningAssistantCurrentCycle = cleaningAssistantWaitingForTemperature ? 0 : 1; cleaningAssistantPhaseStartTime = millis(); scaleModeActive = false; applyCleaningAssistantTemperatureTargets(); digitalWrite(PUMP_PIN, cleaningAssistantWaitingForTemperature ? LOW : HIGH); digitalWrite(VALVE_PIN, cleaningAssistantWaitingForTemperature ? LOW : HIGH); cleaningAssistantStatusMessage = ""; } void resetFlowGuardRuntime() { flowGuardFilteredFlow = NAN; flowGuardLastNetWeight = NAN; flowGuardLastSampleMs = 0; flowGuardPumpPulseActive = false; } void updateFlowGuardFlowEstimate(float netWeight, unsigned long nowMs) { if (!isfinite(netWeight)) { return; } if (flowGuardLastSampleMs == 0 || !isfinite(flowGuardLastNetWeight)) { flowGuardLastSampleMs = nowMs; flowGuardLastNetWeight = netWeight; return; } unsigned long dtMs = nowMs - flowGuardLastSampleMs; if (dtMs < 80UL) { return; } float dtSec = dtMs / 1000.0f; float dWeight = netWeight - flowGuardLastNetWeight; float instantFlow = (dtSec > 0.0f) ? (dWeight / dtSec) : 0.0f; if (!isfinite(instantFlow) || instantFlow < 0.0f) { instantFlow = 0.0f; } if (!isfinite(flowGuardFilteredFlow)) { flowGuardFilteredFlow = instantFlow; } else { flowGuardFilteredFlow = (0.25f * instantFlow) + (0.75f * flowGuardFilteredFlow); } flowGuardLastSampleMs = nowMs; flowGuardLastNetWeight = netWeight; } void applyFlowGuardPumpControl(unsigned long currentShotTimeMillis, float netWeight, float targetWeightToStopAt) { if (currentPreInfusionState != PI_MAIN_BREW) { flowGuardPumpPulseActive = false; return; } bool guardCanRun = flowGuardEnabled && brewByWeightEnabled && scaleEnabled && scaleConnected && !wartungsModusAktiv && isfinite(targetWeightToStopAt) && (targetWeightToStopAt > 0.0f); if (!guardCanRun) { flowGuardPumpPulseActive = false; digitalWrite(PUMP_PIN, HIGH); digitalWrite(VALVE_PIN, HIGH); return; } unsigned long nowMs = millis(); updateFlowGuardFlowEstimate(netWeight, nowMs); float elapsedSec = currentShotTimeMillis / 1000.0f; float remainingWeight = targetWeightToStopAt - netWeight; if (elapsedSec >= flowGuardMinBrewSeconds || remainingWeight <= 0.0f) { flowGuardPumpPulseActive = false; digitalWrite(PUMP_PIN, HIGH); digitalWrite(VALVE_PIN, HIGH); return; } float flowEstimate = flowGuardFilteredFlow; if (!isfinite(flowEstimate) || flowEstimate < 0.05f) { flowGuardPumpPulseActive = false; digitalWrite(PUMP_PIN, HIGH); digitalWrite(VALVE_PIN, HIGH); return; } float remainingTimeToMin = flowGuardMinBrewSeconds - elapsedSec; if (remainingTimeToMin <= 0.0f) { flowGuardPumpPulseActive = false; digitalWrite(PUMP_PIN, HIGH); digitalWrite(VALVE_PIN, HIGH); return; } float requiredAvgFlow = remainingWeight / remainingTimeToMin; if (!isfinite(requiredAvgFlow) || requiredAvgFlow <= 0.0f) { flowGuardPumpPulseActive = false; digitalWrite(PUMP_PIN, HIGH); digitalWrite(VALVE_PIN, HIGH); return; } float minDuty = flowGuardMinDutyPercent / 100.0f; if (!isfinite(minDuty)) { minDuty = defaultFlowGuardMinDutyPercent / 100.0f; } minDuty = constrain(minDuty, FLOW_GUARD_MIN_DUTY_MIN / 100.0f, FLOW_GUARD_MIN_DUTY_MAX / 100.0f); float duty = requiredAvgFlow / flowEstimate; duty = constrain(duty, minDuty, 1.0f); uint16_t periodMs = flowGuardPulsePeriodMs; if (periodMs < FLOW_GUARD_PULSE_PERIOD_MIN_MS || periodMs > FLOW_GUARD_PULSE_PERIOD_MAX_MS || duty >= 0.999f) { flowGuardPumpPulseActive = false; digitalWrite(PUMP_PIN, HIGH); digitalWrite(VALVE_PIN, HIGH); return; } unsigned long onTimeMs = (unsigned long)((float)periodMs * duty); if (onTimeMs < 1UL) { onTimeMs = 1UL; } if (onTimeMs > periodMs) { onTimeMs = periodMs; } unsigned long phaseMs = currentShotTimeMillis % periodMs; bool pumpOn = (phaseMs < onTimeMs); flowGuardPumpPulseActive = (duty < 0.999f); digitalWrite(VALVE_PIN, HIGH); digitalWrite(PUMP_PIN, pumpOn ? HIGH : LOW); } /************************************************************************************ * Stoppt den Brühvorgang (Pumpe/Ventil) und verarbeitet Shot-Daten. * Wird von updateShotTimer() oder aus loop() (BrewByTime/Weight) aufgerufen. ************************************************************************************/ 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 lastShotStoppedByWeight = stoppedByWeight; // fuer P4-Endgewicht inkl. Offset if (stoppedByWeight) { lastShotFinalNetWeight = currentWeightReading - brewStartWeight; } // Statusvariablen zurücksetzen shotActive = false; shotSoftwareActive = false; currentPreInfusionState = PI_INACTIVE; lastShotTime = millis(); // Wichtig für Eco-Modus resetFlowGuardRuntime(); // Hardware stoppen digitalWrite(PUMP_PIN, LOW); digitalWrite(VALVE_PIN, LOW); // Ventil öffnen -> Druck ablassen if (!manualStop) { beepShort(); } // 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; } /************************************************************************************ * 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 ************************************************************************************/ // ===================================================================================== // Rettungs-Modus (Boot-Loop-Absicherung) // ===================================================================================== // Wird aus setup() aufgerufen, wenn der S3 mehrfach in Folge frueh abgestuerzt ist ODER // wenn der Test-Ausloeser (/rescueTest) gesetzt wurde. Faehrt bewusst nur ERPROBTES // MINIMUM hoch (Aktoren aus, WLAN, OTA) und kehrt NIE zurueck -> so kann ueber die Web-UI // eine funktionierende Firmware geflasht werden, ohne USB. // ------------------------------------------------------------------------------------- void handleRescueRoot(AsyncWebServerRequest *request) { AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8"); response->print(F( "" "" "Rettungs-Modus" "" "

⚠ Rettungs-Modus

" "

Die Steuerung ist nach mehreren Startfehlern in den Rettungs-Modus gewechselt. " "Heizungen und Pumpe sind ausgeschaltet.

" "

Bitte eine funktionierende Firmware (.bin) hochladen:

" "
" "

" "" "
" "

Nach erfolgreichem Upload startet die Steuerung automatisch neu.

" "")); request->send(response); } // Test-Ausloeser: erzwingt beim naechsten Boot den Rettungs-Modus (zum gefahrlosen Testen // der OTA-Recovery, ohne echten Absturz). Danach normal flashen, um ihn zu verlassen. void handleRescueTest(AsyncWebServerRequest *request) { rtcForceRescue = 1; request->send(200, "text/html; charset=utf-8", F("" "Rettungs-Modus wird zum Test erzwungen. Die Steuerung startet gleich neu und " "fährt nur WLAN + OTA hoch.")); scheduleRestart(1500); } void enterRescueMode() { rescueModeActive = true; rtcRescueEntries++; Serial.println(F("\n*** RETTUNGS-MODUS: mehrere Startfehler erkannt - nur WLAN + OTA ***")); // 1) Aktoren hart AUS (Sicherheit zuerst). Latch LOW -> OUTPUT -> LOW (glitchfrei). digitalWrite(SSR_WASSER_PIN, LOW); pinMode(SSR_WASSER_PIN, OUTPUT); digitalWrite(SSR_WASSER_PIN, LOW); digitalWrite(SSR_DAMPF_PIN, LOW); pinMode(SSR_DAMPF_PIN, OUTPUT); digitalWrite(SSR_DAMPF_PIN, LOW); digitalWrite(SSR_STEAM_CIRCUIT_PIN, LOW); pinMode(SSR_STEAM_CIRCUIT_PIN, OUTPUT); digitalWrite(SSR_STEAM_CIRCUIT_PIN, LOW); digitalWrite(PUMP_PIN, LOW); pinMode(PUMP_PIN, OUTPUT); digitalWrite(PUMP_PIN, LOW); digitalWrite(VALVE_PIN, LOW); pinMode(VALVE_PIN, OUTPUT); digitalWrite(VALVE_PIN, LOW); digitalWrite(SSR_LIGHT_PIN, LOW); pinMode(SSR_LIGHT_PIN, OUTPUT); digitalWrite(SSR_LIGHT_PIN, LOW); // 2) Minimal-Init fuer OTA: EEPROM (WLAN-Config) + Dateisystem (EEPROM-Backup). Best effort. EEPROM.begin(EEPROM_SIZE); LittleFS.begin(); // 3) WLAN: erst gespeichertes Netz (STA), sonst eigener Rettungs-AP. Ab dem 2. Eintritt // direkt AP - falls die STA-Init selbst der Absturzgrund war. bool trySta = (rtcRescueEntries <= 1); bool staOk = false; WiFi.persistent(false); if (trySta) { WiFiConfig cfg; if (loadWiFiConfig(cfg) && strlen(cfg.ssid) > 0) { WiFi.mode(WIFI_STA); WiFi.setSleep(false); WiFi.begin(cfg.ssid, cfg.password); for (int i = 0; i < 16 && WiFi.status() != WL_CONNECTED; i++) { delay(500); yield(); } staOk = (WiFi.status() == WL_CONNECTED); } } if (staOk) { Serial.print(F("Rettung: WLAN verbunden, IP: ")); Serial.println(WiFi.localIP()); } else { WiFi.mode(WIFI_AP); WiFi.softAP("Dual-PID-Rescue", "QuickMill"); Serial.print(F("Rettung: AP 'Dual-PID-Rescue' (PW QuickMill), IP: ")); Serial.println(WiFi.softAPIP()); } // 4) Minimaler Webserver: Startseite + OTA-Upload (bestehende, erprobte Handler). asyncServer.on("/", HTTP_GET, handleRescueRoot); asyncServer.on("/update", HTTP_POST, handleFirmwareUpdateResponse, handleFirmwareUploadProgress); asyncServer.begin(); // 5) Endlosschleife: nur WLAN/OTA bedienen + Watchdog fuettern. Kehrt NIE zurueck. while (true) { if (scheduledRestart && millis() >= scheduledRestart) { ESP.restart(); } delay(50); yield(); } } void setup() { Serial.begin(115200); initResetDiagnostics(); // --- Boot-Loop-Absicherung: nach mehreren Fruehabstuerzen in den Rettungs-Modus --- // rtcResetDiag/lastResetReason wurden von initResetDiagnostics() bereits gesetzt. if (rtcBootGuardMagic != BOOT_GUARD_MAGIC) { // Power-On: RTC-RAM undefiniert -> initialisieren rtcBootGuardMagic = BOOT_GUARD_MAGIC; rtcConsecutiveCrashes = 0; rtcRescueEntries = 0; rtcForceRescue = 0; } if (rtcForceRescue == 1) { // Test-Ausloeser (/rescueTest) rtcForceRescue = 0; enterRescueMode(); // kehrt nie zurueck } { esp_reset_reason_t rr = lastResetReason; bool wasCrash = (rr == ESP_RST_PANIC || rr == ESP_RST_INT_WDT || rr == ESP_RST_TASK_WDT || rr == ESP_RST_WDT); if (wasCrash) { rtcConsecutiveCrashes++; } else { // sauberer Start bricht die Kette rtcConsecutiveCrashes = 0; rtcRescueEntries = 0; } } if (rtcConsecutiveCrashes >= RESCUE_CRASH_THRESHOLD) { enterRescueMode(); // kehrt nie zurueck } #if TOUCH_UART_ENABLED // TX-Puffer >= max. Zeilenlaenge (State-JSON bis TOUCH_UART_JSON_BUFFER_SIZE=3000 // + "\r\n"), damit println() die Hauptschleife NIE blockiert (sonst bis ~41 ms // Stall -> PID-Jitter). Headroom deckt zudem OTA-Chunks (~1,4 KB) und gestaute // Nachrichten (ack direkt vor State-Push) ab. touchUart.setTxBufferSize(4096); // war 2048; nicht-blockierendes Senden bis 3000-Byte-Zeile touchUart.begin(TOUCH_UART_BAUDRATE, SERIAL_8N1, TOUCH_UART_RX_PIN, TOUCH_UART_TX_PIN); touchUartRxLine.reserve(TOUCH_UART_MAX_LINE_LEN); #endif EEPROM.begin(EEPROM_SIZE); // EEPROM initialisieren loadFullyKioskConfig(fullyKioskConfig); // --- PinModes initialisieren --- // Preload output latches with LOW before switching the pins to OUTPUT to avoid startup glitches. digitalWrite(SSR_DAMPF_PIN, LOW); digitalWrite(SSR_WASSER_PIN, LOW); digitalWrite(SSR_LIGHT_PIN, LOW); digitalWrite(SSR_STEAM_CIRCUIT_PIN, LOW); digitalWrite(PUMP_PIN, LOW); digitalWrite(VALVE_PIN, LOW); digitalWrite(PIEZO_PIN, LOW); pinMode(SSR_DAMPF_PIN, OUTPUT); pinMode(SSR_WASSER_PIN, OUTPUT); pinMode(SSR_LIGHT_PIN, OUTPUT); pinMode(SSR_STEAM_CIRCUIT_PIN, OUTPUT); digitalWrite(SSR_STEAM_CIRCUIT_PIN, LOW); pinMode(SHOT_TIMER_PIN, INPUT_PULLUP); // Pull-up wiring: switch to GND = active pinMode(STEAM_CIRCUIT_SWITCH_PIN, INPUT_PULLUP); // Pull-up wiring: switch to GND = on pinMode(STANDBY_SWITCH_PIN, INPUT_PULLUP); // Pull-up wiring: switch to GND = on pinMode(ECO_SWITCH_PIN, INPUT_PULLUP); // Pull-up wiring: switch to GND = on pinMode(X_SWITCH_PIN, INPUT_PULLUP); // Pull-up wiring: switch to GND = on unsigned long buttonInitNow = millis(); bool initialShotButtonPressed = (digitalRead(SHOT_TIMER_PIN) == SHOT_TIMER_ACTIVE_LEVEL); bool initialSteamButtonPressed = (digitalRead(STEAM_CIRCUIT_SWITCH_PIN) == STEAM_CIRCUIT_SWITCH_ACTIVE_LEVEL); bool initialStandbyButtonPressed = (digitalRead(STANDBY_SWITCH_PIN) == STANDBY_SWITCH_ACTIVE_LEVEL); bool initialEcoButtonPressed = (digitalRead(ECO_SWITCH_PIN) == ECO_SWITCH_ACTIVE_LEVEL); bool initialXButtonPressed = (digitalRead(X_SWITCH_PIN) == X_SWITCH_ACTIVE_LEVEL); standbyModeActive = false; standbySwitchStableOn = initialStandbyButtonPressed; standbySwitchHeldHintActive = false; initMomentaryButtonState(shotButtonState, initialShotButtonPressed, buttonInitNow); initMomentaryButtonState(steamButtonState, initialSteamButtonPressed, buttonInitNow); initMomentaryButtonState(standbyButtonState, initialStandbyButtonPressed, buttonInitNow); initMomentaryButtonState(ecoButtonState, initialEcoButtonPressed, buttonInitNow); initMomentaryButtonState(xButtonState, initialXButtonPressed, buttonInitNow); 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-Pins konfigurieren analogSetPinAttenuation(ANALOG_NTC_PIN, ADC_11db); analogSetPinAttenuation(ANALOG_CASE_NTC_PIN, ADC_11db); analogReadResolution(12); // } else { // } // Initialisiere SSRs als AUS digitalWrite(SSR_DAMPF_PIN, LOW); digitalWrite(SSR_WASSER_PIN, LOW); digitalWrite(SSR_LIGHT_PIN, LOW); // Pumpe & Ventil Pins initialisieren (AUS) digitalWrite(PUMP_PIN, LOW); digitalWrite(VALVE_PIN, LOW); // --- Wire (I2C) initialisieren (Display/Waage/Sensoren) --- Wire.begin(IIC_5V_SDA, IIC_5V_SCK); // --- 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; } // Magic fehlt (z. B. EEPROM durch unsauberen Reset/Brownout beschaedigt): // ERST aus dem FFat-Backup wiederherstellen versuchen, BEVOR auf Werk zurueckgesetzt wird. if (!magicValueVorhanden) { if (tryRestoreEepromFromBackup()) { EEPROM.get(EEPROM_ADDR_MAGICVALUE, storedMagicValue); if (strncmp(storedMagicValue, storageMagicValue, 4) == 0) { magicValueVorhanden = true; eepromRestoredFromBackup = true; } } } // 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_HEATUP_MINUTES, heatUpMinutes); if (heatUpMinutes < 0 || heatUpMinutes > 60) heatUpMinutes = 0; // Plausibilitaet / uninitialisiertes EEPROM 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); uint8_t storedOffsetCompensationEnabled = 0; EEPROM.get(EEPROM_ADDR_OFFSET_COMPENSATION_ENABLED, storedOffsetCompensationEnabled); offsetCompensationEnabled = (storedOffsetCompensationEnabled == 1); // 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); EEPROM.get(EEPROM_ADDR_FLOWGUARD_ENABLED, flowGuardEnabled); EEPROM.get(EEPROM_ADDR_FLOWGUARD_MIN_SECONDS, flowGuardMinBrewSeconds); EEPROM.get(EEPROM_ADDR_FLOWGUARD_PULSE_PERIOD_MS, flowGuardPulsePeriodMs); EEPROM.get(EEPROM_ADDR_FLOWGUARD_MIN_DUTY, flowGuardMinDutyPercent); if (!isfinite(flowGuardMinBrewSeconds) || flowGuardMinBrewSeconds < FLOW_GUARD_MIN_SECONDS_MIN || flowGuardMinBrewSeconds > FLOW_GUARD_MIN_SECONDS_MAX) { flowGuardMinBrewSeconds = defaultFlowGuardMinBrewSeconds; } if (flowGuardPulsePeriodMs < FLOW_GUARD_PULSE_PERIOD_MIN_MS || flowGuardPulsePeriodMs > FLOW_GUARD_PULSE_PERIOD_MAX_MS) { flowGuardPulsePeriodMs = defaultFlowGuardPulsePeriodMs; } if (!isfinite(flowGuardMinDutyPercent) || flowGuardMinDutyPercent < FLOW_GUARD_MIN_DUTY_MIN || flowGuardMinDutyPercent > FLOW_GUARD_MIN_DUTY_MAX) { flowGuardMinDutyPercent = defaultFlowGuardMinDutyPercent; } EEPROM.get(EEPROM_ADDR_STEAMBYTIME_ENABLED, steamByTimeEnabled); EEPROM.get(EEPROM_ADDR_STEAMBYTIME_SECONDS, steamByTimeTargetSeconds); if (!isfinite(steamByTimeTargetSeconds) || steamByTimeTargetSeconds < 1.0f || steamByTimeTargetSeconds > 180.0f) { steamByTimeTargetSeconds = defaultSteamByTimeTargetSeconds; } // 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); uint8_t storedEcoLightAutoOff = 0; uint8_t storedSteamHeatDisabledOnWake = 0; EEPROM.get(EEPROM_ADDR_ECO_LIGHT_AUTO_OFF, storedEcoLightAutoOff); EEPROM.get(EEPROM_ADDR_STEAM_HEAT_DISABLED_ON_STARTUP_WAKE, storedSteamHeatDisabledOnWake); if (storedEcoLightAutoOff > 1) { storedEcoLightAutoOff = (uint8_t)(defaultEcoLightAutoOffEnabled ? 1 : 0); } ecoLightAutoOffEnabled = (storedEcoLightAutoOff == 1); if (storedSteamHeatDisabledOnWake > 1) { storedSteamHeatDisabledOnWake = (uint8_t)(defaultSteamHeatDisabledOnStartupWake ? 1 : 0); } steamHeatDisabledOnStartupWake = (storedSteamHeatDisabledOnWake == 1); uint8_t storedStandbyTimeOnDisplay = 0; EEPROM.get(EEPROM_ADDR_STANDBY_TIME_ON_DISPLAY, storedStandbyTimeOnDisplay); if (storedStandbyTimeOnDisplay > 1) { storedStandbyTimeOnDisplay = (uint8_t)(defaultStandbyTimeOnDisplay ? 1 : 0); } standbyTimeOnDisplay = (storedStandbyTimeOnDisplay == 1); uint8_t storedPowerOnStandby = 0; EEPROM.get(EEPROM_ADDR_POWERON_STANDBY, storedPowerOnStandby); if (storedPowerOnStandby > 1) { storedPowerOnStandby = (uint8_t)(defaultPowerOnStandby ? 1 : 0); } powerOnStandby = (storedPowerOnStandby == 1); uint8_t storedXSwitchAction = defaultXSwitchAction; EEPROM.get(EEPROM_ADDR_X_SWITCH_ACTION, storedXSwitchAction); if (storedXSwitchAction > X_SWITCH_ACTION_MAX) { storedXSwitchAction = defaultXSwitchAction; } xSwitchAction = storedXSwitchAction; uint8_t storedXSwitchLongAction = defaultXSwitchLongAction; EEPROM.get(EEPROM_ADDR_X_SWITCH_LONG_ACTION, storedXSwitchLongAction); if (storedXSwitchLongAction > X_SWITCH_ACTION_MAX) { storedXSwitchLongAction = defaultXSwitchLongAction; } xSwitchLongAction = storedXSwitchLongAction; uint16_t storedButtonLongPressMs = (uint16_t)DEFAULT_BUTTON_LONG_PRESS_MS; EEPROM.get(EEPROM_ADDR_BUTTON_LONG_PRESS_MS, storedButtonLongPressMs); if (storedButtonLongPressMs < BUTTON_LONG_PRESS_MIN_MS || storedButtonLongPressMs > BUTTON_LONG_PRESS_MAX_MS) { storedButtonLongPressMs = (uint16_t)DEFAULT_BUTTON_LONG_PRESS_MS; } buttonLongPressMs = storedButtonLongPressMs; applySteamHeatDisabledOnStartupWakeIfConfigured(); uint8_t storedTempDisplayMode = defaultDashboardTempDisplayMode; EEPROM.get(EEPROM_ADDR_DASHBOARD_TEMP_DISPLAY_MODE, storedTempDisplayMode); if (storedTempDisplayMode > DASHBOARD_TEMP_DISPLAY_RAILS) { storedTempDisplayMode = defaultDashboardTempDisplayMode; } dashboardTempDisplayMode = storedTempDisplayMode; uint8_t storedCaseEnabled = 0; uint8_t storedCaseType = defaultCaseSensorType; uint8_t storedCaseTempDashboard = 0; uint8_t storedCaseTempDisplay = 0; uint8_t storedSteamHeatOnDraw = 0; double storedCaseOffset = defaultOffsetCase; EEPROM.get(EEPROM_ADDR_CASE_SENSOR_ENABLED, storedCaseEnabled); EEPROM.get(EEPROM_ADDR_CASE_SENSOR_TYPE, storedCaseType); EEPROM.get(EEPROM_ADDR_CASE_TEMP_DASHBOARD, storedCaseTempDashboard); EEPROM.get(EEPROM_ADDR_CASE_TEMP_ON_DISPLAY, storedCaseTempDisplay); EEPROM.get(EEPROM_ADDR_OFFSET_CASE, storedCaseOffset); EEPROM.get(EEPROM_ADDR_STEAM_HEAT_ON_DRAW, storedSteamHeatOnDraw); caseSensorEnabled = (storedCaseEnabled == 1); caseSensorType = storedCaseType; if (caseSensorType > CASE_SENSOR_TYPE_CUPTRAY) { caseSensorType = defaultCaseSensorType; } if (!isfinite(storedCaseOffset)) { storedCaseOffset = defaultOffsetCase; } OffsetCase = storedCaseOffset; if (storedCaseTempDashboard > 1) { storedCaseTempDashboard = (uint8_t)(defaultCaseTempOnDashboard ? 1 : 0); } caseTempOnDashboard = (storedCaseTempDashboard == 1); if (storedCaseTempDisplay > 1) { storedCaseTempDisplay = (uint8_t)(defaultCaseTempOnDisplay ? 1 : 0); } caseTempOnDisplay = (storedCaseTempDisplay == 1); if (storedSteamHeatOnDraw > 1) { storedSteamHeatOnDraw = (uint8_t)(defaultSteamHeatOnDraw ? 1 : 0); } steamHeatOnDraw = (storedSteamHeatOnDraw == 1); uint8_t storedFlushDurationSeconds = defaultFlushDurationSeconds; EEPROM.get(EEPROM_ADDR_FLUSH_DURATION_SECONDS, storedFlushDurationSeconds); if (storedFlushDurationSeconds < 1 || storedFlushDurationSeconds > 30) { storedFlushDurationSeconds = defaultFlushDurationSeconds; } flushDurationSeconds = storedFlushDurationSeconds; uint8_t storedSteamFlushDurationSeconds = defaultSteamFlushDurationSeconds; EEPROM.get(EEPROM_ADDR_STEAM_FLUSH_DURATION_SECONDS, storedSteamFlushDurationSeconds); if (storedSteamFlushDurationSeconds < 1 || storedSteamFlushDurationSeconds > 30) { storedSteamFlushDurationSeconds = defaultSteamFlushDurationSeconds; } steamFlushDurationSeconds = storedSteamFlushDurationSeconds; uint8_t storedBacklightActive = defaultBacklightActivePercent; EEPROM.get(EEPROM_ADDR_BACKLIGHT_ACTIVE_PERCENT, storedBacklightActive); if (storedBacklightActive < 5 || storedBacklightActive > 100) { storedBacklightActive = defaultBacklightActivePercent; // u. a. unprogrammierte 0xFF } backlightActivePercent = storedBacklightActive; uint8_t storedBacklightStandbyClock = defaultBacklightStandbyClockPercent; EEPROM.get(EEPROM_ADDR_BACKLIGHT_STANDBY_CLOCK_PERCENT, storedBacklightStandbyClock); if (storedBacklightStandbyClock > 100) { storedBacklightStandbyClock = defaultBacklightStandbyClockPercent; } backlightStandbyClockPercent = storedBacklightStandbyClock; uint8_t storedScaleEnabled = 0; uint8_t storedScaleType = defaultScaleType; EEPROM.get(EEPROM_ADDR_SCALE_ENABLED, storedScaleEnabled); EEPROM.get(EEPROM_ADDR_SCALE_TYPE, storedScaleType); if (storedScaleEnabled > 1) { scaleEnabled = defaultScaleEnabled; } else { scaleEnabled = (storedScaleEnabled == 1); } scaleType = storedScaleType; if (scaleType > SCALE_HX711) { scaleType = defaultScaleType; } float storedHx711Cal = HX711_CALIBRATION_FACTOR; EEPROM.get(EEPROM_ADDR_HX711_CAL_FACTOR, storedHx711Cal); if (!isfinite(storedHx711Cal) || storedHx711Cal == 0.0f) { storedHx711Cal = HX711_CALIBRATION_FACTOR; } hx711CalibrationFactor = storedHx711Cal; uint8_t storedHx711DisplaySmoothing = (uint8_t)(defaultHx711DisplaySmoothingEnabled ? 1 : 0); EEPROM.get(EEPROM_ADDR_HX711_DISPLAY_SMOOTHING_ENABLED, storedHx711DisplaySmoothing); if (storedHx711DisplaySmoothing > 1) { storedHx711DisplaySmoothing = (uint8_t)(defaultHx711DisplaySmoothingEnabled ? 1 : 0); } hx711DisplaySmoothingEnabled = (storedHx711DisplaySmoothing == 1); uint8_t storedLightOn = 0; EEPROM.get(EEPROM_ADDR_LIGHT_ON, storedLightOn); if (storedLightOn > 1) { storedLightOn = 0; } lightDesiredOn = (storedLightOn == 1); applyLightOutput(); // setzt lightOn + GPIO passend zu Standby/Eco // 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 --- recordResetCheckpoint(RESET_CP_SETUP_WIFI); WiFiConfig wifiConfig; bool hasWiFiConfig = loadWiFiConfig(wifiConfig); // Lädt auch Hostname WiFi.hostname(hostname); // Hostname setzen VOR begin/softAP if (hasWiFiConfig) { WiFi.mode(WIFI_STA); WiFi.setSleep(false); 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 // ESP32 WiFi.config(ip, gateway, subnet, primaryDNS_optional, secondaryDNS_optional) // If only one DNS server is available, wifiConfig.dns is fine. 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(); } lastPersistedRuntime = totalRuntime; 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 recordResetCheckpoint(RESET_CP_SETUP_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) --- recordResetCheckpoint(RESET_CP_SETUP_SCALE); initScale(); // Einmalige, stille Tarierung nach dem Start einleiten (ohne Piezo-Piep), // damit nach dem Hochfahren nicht sofort ein Restwert (z.B. 13 g) angezeigt wird. // Nutzt die vorhandene verzoegerte Tara-Logik in loop() (I2C und HX711). if (scaleEnabled && (scaleType == SCALE_I2C || scaleType == SCALE_HX711)) { tareScaleAfterDelay = true; tareScaleDelayStartTime = millis(); startupSilentTarePending = true; } // --- 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); // --- FFat Initialisierung --- // Serial.println("Initialisiere FFat..."); if (!LittleFS.begin()) { // Serial.println("--------------------------------------------------"); // Serial.println("FEHLER: FFat Mount fehlgeschlagen!"); // Versuch zu formatieren (Achtung: löscht alle Daten!) // Serial.println("Versuche FFat zu formatieren..."); if (!LittleFS.format()) { // Serial.println("FEHLER: FFat Formatierung fehlgeschlagen!"); } else { // Serial.println("FFat formatiert. Versuche erneut zu mounten..."); if (!LittleFS.begin()) { // Serial.println("FFat Mount auch nach Formatierung fehlgeschlagen!"); } else { // Serial.println("FFat nach Formatierung erfolgreich gemountet."); } } } else { // Serial.println("FFat 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("FFat Info: Total= %llu, Used= %llu, Free= %llu Bytes\n", totalBytes, usedBytes, totalBytes - usedBytes); // #else // FSInfo fs_info; // if(LittleFS.info(fs_info)) { // Serial.printf("FFat Info: Total= %u, Used= %u, Free= %u Bytes\n", fs_info.totalBytes, fs_info.usedBytes, fs_info.totalBytes - fs_info.usedBytes); // } // #endif } // Ende des FFat-Blocks // EEPROM-Backup auf FFat sicherstellen (jetzt ist das Dateisystem gemountet und das // EEPROM hat einen gueltigen Magic-Value - egal ob geladen, wiederhergestellt oder // gerade auf Werk gesetzt). Legt nur an, falls noch keins existiert. ensureEepromBackupExists(); if (!loadStandbyTimers()) { standbyTimerStatusMessage = "FEHLER: Timer konnten nicht vom Dateisystem geladen werden."; } // --- Routen für Webserver definieren --- // Serial.println("Definiere Webserver Routen..."); asyncServer.on("/", HTTP_GET, handleDashboard); // Dashboard als Hauptseite asyncServer.on("/dashboard", HTTP_GET, handleDashboard); asyncServer.on("/clock", HTTP_GET, handleClockPage); asyncServer.on("/dashboard-data", HTTP_GET, handleDashboardData); asyncServer.on("/dashboard-action", HTTP_POST, handleDashboardAction); asyncServer.on("/api/status", HTTP_GET, handleApiStatus); asyncServer.on("/api/standby/on", HTTP_POST, handleApiStandbyOn); asyncServer.on("/api/standby/off", HTTP_POST, handleApiStandbyOff); asyncServer.on("/api/start", HTTP_POST, handleApiStartHeating); 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("/startCleaningAssistant", HTTP_POST, handleStartCleaningAssistant); asyncServer.on("/stopCleaningAssistant", HTTP_POST, handleStopCleaningAssistant); 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("/displayUpdate", HTTP_GET, handleDisplayUpdatePage); asyncServer.on("/displayUpdate", HTTP_POST, handleDisplayUpdateDone, handleDisplayFirmwareUploadProgress); 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("/Sensoren", HTTP_GET, handleSensors); asyncServer.on("/updateSensorSettings", HTTP_POST, handleUpdateSensorSettings); asyncServer.on("/hx711CalibrationTare", HTTP_POST, handleHx711CalibrationTare); asyncServer.on("/hx711CalibrationApply", HTTP_POST, handleHx711CalibrationApply); 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("/Timer", HTTP_GET, handleTimerPage); asyncServer.on("/saveStandbyTimer", HTTP_POST, handleSaveStandbyTimer); asyncServer.on("/deleteStandbyTimer", HTTP_POST, handleDeleteStandbyTimer); 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 asyncServer.on("/rescueTest", HTTP_GET, handleRescueTest); // Test: erzwingt beim naechsten Boot den Rettungs-Modus // Routen für Brew Control asyncServer.on("/Brew-Control", HTTP_GET, handleBrewControl); asyncServer.on("/saveBrewControl", HTTP_POST, handleSaveBrewControl); dashboardWs.onEvent(onDashboardWsEvent); asyncServer.addHandler(&dashboardWs); // Webserver starten recordResetCheckpoint(RESET_CP_SETUP_WEBSERVER); 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 resetOffsetCompensationReferences(); // Optional: nach Neustart/Power-On direkt in Standby gehen (ECO-Einstellung). // Erst hier aufrufen, nachdem alle EEPROM-Einstellungen (inkl. Licht/Setpoints) geladen sind. applyPowerOnStandbyIfConfigured(); recordResetCheckpoint(RESET_CP_LOOP_START); yield(); // Dem System kurz Zeit geben } // Ende setup() void handleAutoTune(AsyncWebServerRequest *request = nullptr); /************************************************************************************ * applyLightOutput(): zentrale, pegelbasierte Lichtsteuerung * ------------------------------------------------------------------------------ * Einzige Stelle, die SSR_LIGHT_PIN schaltet. Der Ausgang folgt deterministisch dem * Benutzerwunsch (lightDesiredOn, persistent) abzueglich der Unterdrueckungs- * bedingungen (Standby bzw. Eco bei aktiviertem Auto-Off). Idempotent: kann beliebig * oft aufgerufen werden. Ersetzt die frueheren flankengesteuerten Off/Restore-Pfade, * die sich bei schnellem Standby-Umschalten verschraenken konnten. ************************************************************************************/ void applyLightOutput() { bool suppress = ecoLightAutoOffEnabled && (standbyModeActive || ecoModeAktiv); bool out = lightDesiredOn && !suppress; lightOn = out; // Spiegel fuer State-Push ans Display/Web digitalWrite(SSR_LIGHT_PIN, out ? HIGH : LOW); // idempotent: bei jedem Aufruf gesetzt } /************************************************************************************ * loop(): Hauptschleife * Enthält Logik für Pre-Infusion Ablauf und Brew-By-Time Check ************************************************************************************/ void loop() { recordResetCheckpoint(RESET_CP_LOOP_START); unsigned long currentMillis = millis(); if (scheduledRestart && currentMillis >= scheduledRestart) { ESP.restart(); } // Boot-Loop-Absicherung: laeuft die Firmware lange genug stabil, gilt sie als gesund // -> Crash-Zaehler zuruecksetzen (verhindert Fehlalarm durch einen spaeteren Einzel-Absturz). if (rtcConsecutiveCrashes != 0 && currentMillis > RESCUE_STABLE_UPTIME_MS) { rtcConsecutiveCrashes = 0; rtcRescueEntries = 0; } // Pruefen, ob Zeit bereits synchronisiert wurde if (!timeSynced && WiFi.status() == WL_CONNECTED && timeClient.update()) { timeSynced = true; } evaluateStandbyTimers(); updateMomentaryButtonState( standbyButtonState, (digitalRead(STANDBY_SWITCH_PIN) == STANDBY_SWITCH_ACTIVE_LEVEL), STANDBY_SWITCH_DEBOUNCE_MS, currentMillis ); if (buttonJustPressed(standbyButtonState, currentMillis)) { standbyModeActive = !standbyModeActive; } standbySwitchStableOn = standbyButtonState.stablePressed; standbySwitchHeldHintActive = standbyButtonState.stablePressed && standbyButtonState.pressedAt > 0 && (currentMillis - standbyButtonState.pressedAt >= STANDBY_SWITCH_HOLD_HINT_MS); updateMomentaryButtonState( ecoButtonState, (digitalRead(ECO_SWITCH_PIN) == ECO_SWITCH_ACTIVE_LEVEL), ECO_SWITCH_DEBOUNCE_MS, currentMillis ); if (buttonJustPressed(ecoButtonState, currentMillis)) { ecoSwitchActive = !ecoSwitchActive; } updateMomentaryButtonState( xButtonState, (digitalRead(X_SWITCH_PIN) == X_SWITCH_ACTIVE_LEVEL), X_SWITCH_DEBOUNCE_MS, currentMillis ); buttonJustPressed(xButtonState, currentMillis); if (xSwitchLongAction != X_SWITCH_ACTION_NONE && buttonLongPressReady(xButtonState, buttonLongPressMs, currentMillis)) { xButtonState.longPressHandled = true; executeXSwitchAction(xSwitchLongAction); } else if (buttonJustReleased(xButtonState) && !xButtonState.longPressHandled) { executeXSwitchAction(xSwitchAction); } updateMomentaryButtonState( shotButtonState, (digitalRead(SHOT_TIMER_PIN) == SHOT_TIMER_ACTIVE_LEVEL), SHOT_BUTTON_DEBOUNCE_MS, currentMillis ); buttonJustPressed(shotButtonState, currentMillis); if (buttonLongPressReady(shotButtonState, buttonLongPressMs, currentMillis)) { shotButtonState.longPressHandled = true; invokeDashboardAction("flush"); } else if (buttonJustReleased(shotButtonState) && !shotButtonState.longPressHandled) { invokeDashboardAction((shotActive || shotSoftwareActive) ? "stopShot" : "startShot"); } updateMomentaryButtonState( steamButtonState, (digitalRead(STEAM_CIRCUIT_SWITCH_PIN) == STEAM_CIRCUIT_SWITCH_ACTIVE_LEVEL), STEAM_BUTTON_DEBOUNCE_MS, currentMillis ); buttonJustPressed(steamButtonState, currentMillis); if (buttonLongPressReady(steamButtonState, buttonLongPressMs, currentMillis)) { steamButtonState.longPressHandled = true; invokeDashboardAction("steamFlush"); } else if (buttonJustReleased(steamButtonState) && !steamButtonState.longPressHandled) { invokeDashboardAction((steamCircuitActive || steamCircuitSoftwareActive || steamFlushActive) ? "stopSteam" : "startSteam"); } xSwitchActive = isXSwitchActionActive(xSwitchAction) || isXSwitchActionActive(xSwitchLongAction); if (steamFlushActive) { if (standbyModeActive || steamCircuitSoftwareActive) { steamFlushActive = false; } else if ((long)(currentMillis - steamFlushEndTime) >= 0) { steamFlushActive = false; } } bool steamCircuitSwitchOn = !standbyModeActive && (steamCircuitSoftwareActive || steamFlushActive); steamCircuitActive = steamCircuitSwitchOn; digitalWrite(SSR_STEAM_CIRCUIT_PIN, steamCircuitSwitchOn ? HIGH : LOW); static bool lastSteamCircuitActionActive = false; bool currentSteamCircuitActionActive = !standbyModeActive && steamCircuitSoftwareActive && !steamFlushActive; if (currentSteamCircuitActionActive && !lastSteamCircuitActionActive) { steamCircuitSessionStartTime = currentMillis; } else if (!currentSteamCircuitActionActive) { steamCircuitSessionStartTime = 0; } lastSteamCircuitActionActive = currentSteamCircuitActionActive; if (steamByTimeEnabled && currentSteamCircuitActionActive && steamCircuitSessionStartTime > 0 && (currentMillis - steamCircuitSessionStartTime >= (unsigned long)(steamByTimeTargetSeconds * 1000.0f))) { invokeDashboardAction("stopSteam"); } finalizeMomentaryButtonState(standbyButtonState); finalizeMomentaryButtonState(ecoButtonState); finalizeMomentaryButtonState(xButtonState); finalizeMomentaryButtonState(shotButtonState); finalizeMomentaryButtonState(steamButtonState); static bool lastStandbyModeActive = false; if (standbyModeActive) { ecoForcedActive = false; steamDelayOverridden = false; SetpointWasser = 0; SetpointDampf = 0; OutputWasser = 0; OutputDampf = 0; shotSoftwareActive = false; steamCircuitSoftwareActive = false; steamFlushActive = false; steamCircuitActive = false; // Licht: zentral ueber applyLightOutput() (Standby unterdrueckt den Ausgang) if (ecoModeAktiv) { ecoModeAktiv = false; ecoModeActivatedTime = 0; } } else if (lastStandbyModeActive) { startupTime = currentMillis; ecoForcedActive = false; steamDelayOverridden = false; windowStartTimeWasser = currentMillis; windowStartTimeDampf = currentMillis; EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); // Licht: zentral ueber applyLightOutput() (beim Standby-Ende faellt die Unterdrueckung weg) ecoModeAktiv = false; ecoModeActivatedTime = 0; lastShotTime = millis(); resetOffsetCompensationReferences(); applySteamHeatDisabledOnStartupWakeIfConfigured(); } if (standbyModeActive != lastStandbyModeActive) { syncFullyKioskWithStandby(standbyModeActive); } lastStandbyModeActive = standbyModeActive; // --- Temperaturmessung und PID-Berechnung (im Intervall) --- if (currentMillis - previousMillis >= interval) { recordResetCheckpoint(RESET_CP_LOOP_TEMPERATURE); previousMillis = currentMillis; // Temperaturmessung RawInputDampf = thermocouple.readCelsius(); RawInputWasser = readNTCTemperature(); InputDampf = round(RawInputDampf + OffsetDampf); InputWasser = round(RawInputWasser + OffsetWasser); updateOffsetCompensationReferences(); if (caseSensorEnabled) { InputCase = round(readCaseTemperature() + OffsetCase); caseSensorError = (isnan(InputCase) || InputCase < 0.0); } else { InputCase = NAN; caseSensorError = false; } yield(); // --- Angepasste Sicherheitsueberpruefung Wasser --- if (InputWasser < 0.0 || isnan(InputWasser)) { wasserSensorError = true; wasserSafetyShutdown = false; OutputWasser = 0; // Ton ausgeben zur Warnung beepShort(); // Serial.println("WARNUNG: Wassertemperatursensor liefert ungueltigen Wert!"); // Optional Log } else if (InputWasser > maxTempWasser) { wasserSensorError = false; wasserSafetyShutdown = true; OutputWasser = 0; // Ton ausgeben zur Warnung beepShort(); // Serial.printf("WARNUNG: Wassertemperatur ueber Limit! Shutdown."); // 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 Sicherheitsueberpruefung Dampf --- if (isnan(InputDampf) || InputDampf < 0.0) { dampfSensorError = true; dampfSafetyShutdown = false; OutputDampf = 0; // Ton ausgeben zur Warnung beepShort(); // Serial.println("WARNUNG: Dampftemperatursensor liefert ungueltigen Wert!"); // Optional Log } else if (InputDampf > maxTempDampf) { dampfSensorError = false; dampfSafetyShutdown = true; OutputDampf = 0; // Ton ausgeben zur Warnung beepShort(); // Serial.printf("WARNUNG: Dampftemperatur ueber Limit! Shutdown."); // 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(); // Pruefen der Startverzoegerung fuer Dampf bool steamDelayActiveSystem = (dampfVerzoegerung > 0 && (millis() - startupTime < (unsigned long)dampfVerzoegerung * 60000UL)); // Pruefe, ob WIRKLICH verzoegert 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 verzoegert werden soll, erzwinge PID-Output auf 0 OutputDampf = 0; // Serial.println("Dampf-PID-Berechnung wegen Verzoegerung uebersprungen."); // Optional Debug } else { // Keine Verzoegerung ODER Override aktiv -> PID normal berechnen/Boost pruefen 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) } } if (scaleEnabled && (scaleModeActive || (shotActive && brewByWeightEnabled))) { if (scaleType == SCALE_I2C && scaleConnected) { recordResetCheckpoint(RESET_CP_SCALE_I2C_READ); acceptScaleReading(scales.getWeight()); } else if (scaleType == SCALE_HX711) { updateHx711ReadingState(); } } if (!scaleConnected) { resetScaleReadingFilters(0.0f); } // AutoTune-Logik handleAutoTune(); } // Ende des Temperaturmessungs-/PID-Berechnungsintervalls yield(); // --- Zeitproportionale SSR-Ansteuerung (wird kontinuierlich geprueft) --- recordResetCheckpoint(RESET_CP_LOOP_SSR); unsigned long now = millis(); // Wasser SSR if (wasserSensorError || wasserSafetyShutdown || standbyModeActive || displayOtaActive) { digitalWrite(SSR_WASSER_PIN, LOW); } else { if (now - windowStartTimeWasser > windowSizeWasser) { windowStartTimeWasser = now; } 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 || standbyModeActive || steamHeatDisabledByUser || displayOtaActive) { digitalWrite(SSR_DAMPF_PIN, LOW); } else { if (now - windowStartTimeDampf > windowSizeDampf) { windowStartTimeDampf = now; } if (steamHeatOnDraw && steamCircuitSwitchOn) { digitalWrite(SSR_DAMPF_PIN, HIGH); } else { // Pruefe, ob die Verzoegerung aktiv UND NICHT ueberschrieben ist bool steamDelayActiveSystem = (dampfVerzoegerung > 0 && (millis() - startupTime < (unsigned long)dampfVerzoegerung * 60000UL)); bool shouldDelaySteam = steamDelayActiveSystem && !steamDelayOverridden; if (shouldDelaySteam && !wartungsModusAktiv) { // Wenn verzoegert werden soll (und kein Wartungsmodus) OutputDampf = 0; // PID Output ueberschreiben (wird unten zum Abschalten fuehren) digitalWrite(SSR_DAMPF_PIN, LOW); // Sicherstellen, dass SSR aus ist // Optional: Logge, dass Verzoegerung aktiv ist // Serial.println("Dampfverzoegerung aktiv."); } else { // Keine Verzoegerung ODER ueberschrieben -> Normale PID-Logik bool pidWantsHeat = (OutputDampf > 0) && (now < (windowStartTimeDampf + (unsigned long)OutputDampf)); bool preventOverheatActive = (preventHeatAboveSetpointDampf && (InputDampf > SetpointDampf)) || (ecoModeAktiv && (InputDampf > SetpointDampf)); if (pidWantsHeat && !preventOverheatActive && !wartungsModusAktiv) { // Zusaetzliche Pruefung 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 noetig, // ausser wenn explizit verzoegert wird (siehe oben). } } } yield(); // *** Brueh-Logik (Pre-Infusion, Brew-by-Time, Brew-by-Weight) *** recordResetCheckpoint(RESET_CP_LOOP_SHOT); if (shotActive) { unsigned long currentShotTimeMillis = millis() - shotStartTime; // --- Waage lesen --- if (scaleEnabled) { if (scaleType == SCALE_I2C && scaleConnected) { recordResetCheckpoint(RESET_CP_SCALE_I2C_READ); acceptScaleReading(scales.getWeight()); } else if (scaleType == SCALE_HX711) { updateHx711ReadingState(); } } // Fuer ESP-NOW wird currentWeightReading im Hintergrund aktualisiert. if (!scaleConnected) { // Gemeinsame Logik fuer "nicht verbunden" resetScaleReadingFilters(0.0f); } // --- Stopp-Bedingungen pruefen --- 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) { stopBrewSequence(currentShotTimeMillis, false, stopForWeight); // Uebergibt die aktuelle Dauer und den Grund des Stopps } // --- Pre-Infusion Zustandsmaschine (Nur ausfuehren, 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))) { 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))) { digitalWrite(PUMP_PIN, HIGH); currentPreInfusionState = PI_MAIN_BREW; preInfusionPhaseStartTime = millis(); } } if (shotActive) { applyFlowGuardPumpControl(currentShotTimeMillis, netWeight, targetWeightToStopAt); } // Kein else hier -> Entweder Stopp, oder PreInfusion-Check, oder normaler Bezug ohne PreInfusion laeuft weiter } // Shot-Timer (Diese Funktion initiiert nur den Start oder stoppt bei manuellem Ende) updateShotTimer(); if (flushActive) { if (shotActive) { 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); } } if (cleaningAssistantActive) { if (standbyModeActive || wartungsModusAktiv || shotActive || flushActive || steamCircuitActive || steamCircuitSoftwareActive || steamFlushActive || wasserSensorError || wasserSafetyShutdown) { stopCleaningAssistant("Reinigungsassistent abgebrochen: Systemzustand geaendert."); } else { lastShotTime = currentMillis; if (cleaningAssistantWaitingForTemperature) { digitalWrite(PUMP_PIN, LOW); digitalWrite(VALVE_PIN, LOW); if (InputWasser >= (cleaningAssistantWaterSetpoint - cleaningAssistantWaterReadyTolerance)) { cleaningAssistantWaitingForTemperature = false; cleaningAssistantInBrewPhase = true; cleaningAssistantCurrentCycle = 1; cleaningAssistantPhaseStartTime = currentMillis; digitalWrite(PUMP_PIN, HIGH); digitalWrite(VALVE_PIN, HIGH); } } else { unsigned long phaseDurationMs = cleaningAssistantInBrewPhase ? (unsigned long)cleaningAssistantBrewSeconds * 1000UL : (unsigned long)cleaningAssistantPauseSeconds * 1000UL; if (currentMillis - cleaningAssistantPhaseStartTime >= phaseDurationMs) { if (cleaningAssistantInBrewPhase) { digitalWrite(PUMP_PIN, LOW); digitalWrite(VALVE_PIN, LOW); if (cleaningAssistantCurrentCycle >= cleaningAssistantCycles) { stopCleaningAssistant(); } else { cleaningAssistantInBrewPhase = false; cleaningAssistantPhaseStartTime = currentMillis; } } else { cleaningAssistantCurrentCycle++; cleaningAssistantInBrewPhase = true; cleaningAssistantPhaseStartTime = currentMillis; digitalWrite(PUMP_PIN, HIGH); digitalWrite(VALVE_PIN, HIGH); } } } } } // Eco-Timer bei Ende von Dampfbezug und Flush zuruecksetzen (analog Shot-Ende) static bool lastSteamCircuitActiveForEcoTimer = false; static bool lastFlushActiveForEcoTimer = false; if (!lastSteamCircuitActiveForEcoTimer && steamCircuitActive) { steamCircuitStartTime = currentMillis; // Dampfbezug startet -> Zeitstempel fuer Display-Timer } if (lastSteamCircuitActiveForEcoTimer && !steamCircuitActive) { lastShotTime = currentMillis; } if (lastFlushActiveForEcoTimer && !flushActive) { lastShotTime = currentMillis; } lastSteamCircuitActiveForEcoTimer = steamCircuitActive; lastFlushActiveForEcoTimer = flushActive; bool ecoIdleBlocked = shotActive || shotSoftwareActive || pendingShotStartAfterScaleTare || steamCircuitActive || steamCircuitSoftwareActive || steamFlushActive || flushActive || cleaningAssistantActive; // Eco-Modus, wenn kein AutoTune oder Wartungsmodus recordResetCheckpoint(RESET_CP_LOOP_ECO); static bool lastEcoSwitchActive = false; if (!standbyModeActive) { if (ecoSwitchActive || ecoForcedActive) { unsigned long currentTimeEco = millis(); 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 (lastEcoSwitchActive) { ecoModeAktiv = false; ecoModeActivatedTime = 0; EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); lastShotTime = millis(); } if (!autoTuneWasserActive && !autoTuneDampfActive && !wartungsModusAktiv) { if (ecoModeMinutes > 0 && !ecoIdleBlocked) { // Eco nur wenn wirklich keine Aktion aktiv oder angefordert ist 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); } } } else if (ecoModeAktiv) { ecoModeAktiv = false; ecoModeActivatedTime = 0; } lastEcoSwitchActive = ecoSwitchActive; if (cleaningAssistantActive) { applyCleaningAssistantTemperatureTargets(); } // Lichtausgang zentral und pegelbasiert setzen (nach Standby- und Eco-Bestimmung). // Der Ausgang folgt lightDesiredOn abzueglich Standby-/Eco-Unterdrueckung - keine // flankengesteuerten Off/Restore-Pfade mehr, die sich verschraenken koennten. applyLightOutput(); // Display regelmaessig updaten, wenn aktiviert / angeschlossen #ifdef ENABLE_DISPLAY recordResetCheckpoint(RESET_CP_LOOP_DISPLAY); updateDisplay(); #endif // ENABLE_DISPLAY // --- Waagen-spezifische Logik in der Hauptschleife --- recordResetCheckpoint(RESET_CP_LOOP_SCALE); if (scaleEnabled) { if (scaleType == SCALE_I2C) { if (scaleConnected) { handleScaleButtonPress(nullptr); } 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)) { recordResetCheckpoint(RESET_CP_SCALE_I2C_READ); resetScaleReadingFilters(0.0f); tareScaleSettling = false; Serial.println(F("Waage nach Verzoegerung tariert.")); if (piezoEnabled && !startupSilentTarePending) { beepShort(2800, 50); } startupSilentTarePending = false; } } else if (scaleType == SCALE_HX711) { unsigned long hx711ReadyTimeBeforeRead = hx711LastReadyTime; updateHx711ReadingState(); bool hx711FreshReadingAvailable = (hx711LastReadyTime != hx711ReadyTimeBeforeRead); if (tareScaleAfterDelay && (currentMillis - tareScaleDelayStartTime >= TARE_SCALE_DELAY_DURATION)) { if (hx711.is_ready()) { hx711.tare(); hx711LastReadyTime = millis(); hx711HasValidReading = true; hx711NotReadyCounter = 0; scaleConnected = true; tareScaleAfterDelay = false; tareScaleSettling = true; tareScaleSettlingStartTime = currentMillis; } } if (tareScaleSettling && scaleConnected && (currentMillis - tareScaleSettlingStartTime >= TARE_SCALE_SETTLING_DURATION)) { if (hx711.is_ready()) { resetScaleReadingFilters(0.0f); hx711LastReadyTime = millis(); hx711HasValidReading = true; hx711NotReadyCounter = 0; scaleConnected = true; } tareScaleSettling = false; if (pendingShotStartAfterScaleTare) { hx711ShotPostTareReadsRemaining = HX711_POST_TARE_STABILIZATION_READS; } Serial.println(F("Waage nach Verzoegerung tariert.")); if (piezoEnabled && !startupSilentTarePending) { beepShort(2800, 50); } startupSilentTarePending = false; } if (pendingShotStartAfterScaleTare && hx711ShotPostTareReadsRemaining > 0 && hx711FreshReadingAvailable) { hx711ShotPostTareReadsRemaining--; if (hx711ShotPostTareReadsRemaining == 0) { resetScaleReadingFilters(0.0f); hx711ShotTarePrepared = true; pendingShotStartAfterScaleTare = false; } } } else if (scaleType == SCALE_ESPNOW) { if (scaleConnected && (millis() - lastScaleMessageTime > SCALE_TIMEOUT)) { scaleConnected = false; scaleModeActive = false; resetScaleReadingFilters(0.0f); Serial.println("WARNUNG: Verbindung zur ESP-NOW Waage verloren (Timeout)."); } } } // AsyncWebServer verarbeitet Clients im Hintergrund, kein handleClient erforderlich recordResetCheckpoint(RESET_CP_LOOP_WS); dashboardWs.cleanupClients(); dashboardWsTick(); #if TOUCH_UART_ENABLED touchUartRxTick(); touchUartTick(); #endif yield(); // Betriebszeit zaehlen static unsigned long lastRuntimeSecond = 0; unsigned long runtimeNow = millis(); if (standbyModeActive) { lastRuntimeSecond = runtimeNow; } else if (runtimeNow - lastRuntimeSecond >= 1000) { unsigned long elapsedSeconds = (runtimeNow - lastRuntimeSecond) / 1000UL; totalRuntime += elapsedSeconds; lastRuntimeSecond += elapsedSeconds * 1000UL; } // Speichern der Betriebszeit und WiFi-Check if (!demoModus) { unsigned long currentMillisLoop = runtimeNow; unsigned long intervalToUse = !initialRuntimeSaveDone ? initialRuntimeSaveDelay : subsequentRuntimeSaveInterval; if (currentMillisLoop - lastRuntimeSave >= intervalToUse) { if (totalRuntime != lastPersistedRuntime) { EEPROM.put(EEPROM_ADDR_RUNTIME, totalRuntime); EEPROM.commit(); lastPersistedRuntime = totalRuntime; } lastRuntimeSave = currentMillisLoop; if (!initialRuntimeSaveDone) initialRuntimeSaveDone = true; if (WiFi.getMode() == WIFI_STA) checkWifiConnection(); } } yield(); // Zur Speicherung des Bezugszaehlers if (!shotActive) { handleShotSaving(nullptr); } yield(); } /************************************************************************************ * Liest die Temperatur vom NTC-Sensor ueber den Spannungsteiler (ADC) ************************************************************************************/ double readNTCTemperatureFromPin(int pin) { double sensorVoltage = NAN; int adcValue = analogRead(pin); const double MAX_ADC_VALUE = 4095.0; // ESP32 standard 12-bit // Spannung ueber ADC-API ermitteln (kompatibel mit ADC driver_ng) uint32_t voltage_mv = analogReadMilliVolts(pin); if (voltage_mv > 0) { sensorVoltage = voltage_mv / 1000.0; } else { sensorVoltage = (adcValue / MAX_ADC_VALUE) * ADC_REF; } if (sensorVoltage >= V_SUPPLY || sensorVoltage <= 0) { return NAN; } double R_ntc = R_FIXED * ((V_SUPPLY / sensorVoltage) - 1.0); if (R_ntc <= 0) { return NAN; } double log_R_R0 = log(R_ntc / NTC_R0); double temp_inv_K = (1.0 / NTC_T0) + (1.0 / beta) * log_R_R0; if (temp_inv_K == 0) { return NAN; } double temperatureK = 1.0 / temp_inv_K; return temperatureK - 273.15; } double readNTCTemperature() { return readNTCTemperatureFromPin(ANALOG_NTC_PIN); } double readCaseTemperature() { return readNTCTemperatureFromPin(ANALOG_CASE_NTC_PIN); } #ifdef ENABLE_DISPLAY void updateDisplay() { recordResetCheckpoint(RESET_CP_DISPLAY_RENDER); double displayedWaterTemp = getDisplayedWaterTemperature(); double displayedSteamTemp = getDisplayedSteamTemperature(); // ================================================================================= // HÖCHSTE PRIORITÄT: Spezielle Vollbild-Anzeigen, die alles andere überschreiben // ================================================================================= static bool lastStandbyHoldHintDisplayed = false; if (standbySwitchHeldHintActive) { lastStandbyHoldHintDisplayed = true; display.clearDisplay(); display.setTextSize(1); display.setTextColor(SH110X_WHITE); display.setCursor(0, 0); display.println(F("Standby-Taster")); display.println(F("bitte loslassen")); display.println(F("")); display.println(F("Dann kann erneut")); display.println(F("umgeschaltet")); display.println(F("werden.")); if (WiFi.status() == WL_CONNECTED) { drawwifiSymbol(113, 0); } display.display(); return; } static bool lastStandbyDisplayActive = false; static int lastStandbyDisplayMinute = -1; if (standbyModeActive) { bool shouldRender = false; bool canShowTime = false; struct tm timeinfo; if (standbyTimeOnDisplay && timeSynced) { time_t now_t; time(&now_t); localtime_r(&now_t, &timeinfo); if (timeinfo.tm_year > (2023 - 1900)) { canShowTime = true; } } if (!lastStandbyDisplayActive) { shouldRender = true; lastStandbyDisplayMinute = -1; } if (lastStandbyHoldHintDisplayed) { shouldRender = true; lastStandbyHoldHintDisplayed = false; lastStandbyDisplayMinute = -1; } if (canShowTime && timeinfo.tm_min != lastStandbyDisplayMinute) { shouldRender = true; lastStandbyDisplayMinute = timeinfo.tm_min; } if (!shouldRender) { lastStandbyDisplayActive = true; return; } display.clearDisplay(); if (canShowTime) { char timeBuffer[6]; strftime(timeBuffer, sizeof(timeBuffer), "%H:%M", &timeinfo); int16_t x1, y1; uint16_t w, h; display.setTextColor(SH110X_WHITE); display.setTextSize(2); display.getTextBounds(timeBuffer, 0, 0, &x1, &y1, &w, &h); display.setCursor((display.width() - w) / 2, (display.height() - h) / 2); display.print(timeBuffer); } display.display(); lastStandbyDisplayActive = true; return; } lastStandbyDisplayActive = false; lastStandbyHoldHintDisplayed = false; static bool lastSteamCircuitDisplayActive = false; static unsigned long steamCircuitDisplayStartTime = 0; static unsigned long steamCircuitDisplayEndTime = 0; static unsigned long lastSteamCircuitDurationMs = 0; const unsigned long steamTimerHoldAfterEndMs = 3000UL; if (steamCircuitActive && !lastSteamCircuitDisplayActive) { steamCircuitDisplayStartTime = millis(); steamCircuitDisplayEndTime = 0; lastSteamCircuitDurationMs = 0; } else if (!steamCircuitActive && lastSteamCircuitDisplayActive) { if (steamCircuitDisplayStartTime > 0) { lastSteamCircuitDurationMs = millis() - steamCircuitDisplayStartTime; } else { lastSteamCircuitDurationMs = 0; } steamCircuitDisplayEndTime = millis(); steamCircuitDisplayStartTime = 0; } lastSteamCircuitDisplayActive = steamCircuitActive; if (pendingShotStartAfterScaleTare && scaleEnabled && scaleType == SCALE_HX711) { display.clearDisplay(); display.setTextSize(1); display.setTextColor(SH110X_WHITE); display.setCursor(0, 0); display.println(F("Bezug startet")); display.println(F("")); display.println(F("Waage wird")); display.println(F("tariert...")); if (WiFi.status() == WL_CONNECTED) { drawwifiSymbol(113, 0); } display.display(); return; } if (scaleModeActive && scaleEnabled && scaleType == SCALE_HX711 && isHx711TareSequenceActive()) { display.clearDisplay(); display.setTextSize(1); display.setTextColor(SH110X_WHITE); display.setCursor(0, 0); display.println(F("Waage:")); display.println(F("")); display.println(F("Waage wird")); display.println(F("tariert...")); if (WiFi.status() == WL_CONNECTED) { drawwifiSymbol(113, 0); } display.display(); return; } // --- 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 = getWeightReadingForUi(); // 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 } // --- 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: Reinigungsassistent aktiv --- if (cleaningAssistantActive) { display.clearDisplay(); display.setCursor(0, 0); display.setTextSize(1); display.setTextColor(SH110X_WHITE); display.println(F("Reinigung aktiv:")); if (cleaningAssistantWaitingForTemperature) { display.println(F("Heizt auf 93 C")); display.println(F("vor...")); display.print(F("Wasser: ")); display.print((int)displayedWaterTemp); display.print(F("/")); display.print((int)cleaningAssistantWaterSetpoint); display.print(F(" ")); display.print((char)247); display.println(F("C")); display.println(F("Zyklen starten")); display.println(F("automatisch.")); } else { display.print(F("Zyklus: ")); display.print(cleaningAssistantCurrentCycle); display.print(F("/")); display.println(cleaningAssistantCycles); display.print(F("Phase: ")); display.println(cleaningAssistantInBrewPhase ? F("Bezug") : F("Pause")); display.print(F("Rest: ")); unsigned long phaseDurationMs = cleaningAssistantInBrewPhase ? (unsigned long)cleaningAssistantBrewSeconds * 1000UL : (unsigned long)cleaningAssistantPauseSeconds * 1000UL; unsigned long elapsedMs = millis() - cleaningAssistantPhaseStartTime; unsigned long remainingMs = (elapsedMs < phaseDurationMs) ? (phaseDurationMs - elapsedMs) : 0; display.print((remainingMs + 999UL) / 1000UL); display.println(F(" s")); display.print(F("Wasser: ")); display.print((int)displayedWaterTemp); display.print(F("/")); display.print((int)cleaningAssistantWaterSetpoint); display.print(F(" ")); display.print((char)247); display.println(F("C")); display.println(F("Dampfheiz. aus")); } 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 ? displayedWaterTemp : displayedSteamTemp)); 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)displayedWaterTemp); 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(); if (piezoEnabled) { beepMaintenanceAlert(); } } 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 if (piezoEnabled) { beepMaintenanceAlert(); // Piepton perfekt synchron zur Meldung auslösen! } } 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*? 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 { 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")); 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."));} } fastHeatUpHeating = false; } else if (steamCircuitActive || (steamCircuitDisplayEndTime > 0 && (millis() - steamCircuitDisplayEndTime) <= steamTimerHoldAfterEndMs)) { unsigned long steamElapsedMs = steamCircuitActive ? ((steamCircuitDisplayStartTime > 0) ? (millis() - steamCircuitDisplayStartTime) : 0) : lastSteamCircuitDurationMs; display.println(F("Dampf-Timer:")); display.println(""); display.setTextSize(3); char buffer[6]; snprintf(buffer, sizeof(buffer), "%.1f", steamElapsedMs / 1000.0); display.println(buffer); display.setTextSize(1); display.println(F("Sekunden")); if (!steamCircuitActive) { display.println(F("Dampf beendet")); } } else if (flushActive) { unsigned long nowMs = millis(); unsigned long flushRemainingMs = 0; if ((long)(flushEndTime - nowMs) > 0) { flushRemainingMs = flushEndTime - nowMs; } unsigned long flushDurationMs = (unsigned long)flushDurationSeconds * 1000UL; unsigned long flushElapsedMs = 0; if (flushRemainingMs < flushDurationMs) { flushElapsedMs = flushDurationMs - flushRemainingMs; } display.println(F("Flush-Timer:")); display.println(""); display.setTextSize(3); char buffer[6]; snprintf(buffer, sizeof(buffer), "%.1f", flushElapsedMs / 1000.0); display.println(buffer); display.setTextSize(1); display.println(F("Sekunden")); } else { // Standardanzeige: Temperaturen / Fehler / Sicherheitsabschaltung (Fallback, wenn nichts anderes zu tun ist) bool ecoDisplayContentAvailable = false; if (ecoInfoOnDisplay) { ecoDisplayContentAvailable = ecoModeAktiv || (ecoModeMinutes > 0 && !ecoSwitchActive); } bool caseDisplayContentAvailable = caseTempOnDisplay && caseSensorEnabled && !caseSensorError && !isnan(InputCase); bool displayAdditionalInfo = ecoDisplayContentAvailable || caseDisplayContentAvailable; bool showEcoInfoBlock = false; bool showCaseInfoBlock = false; if (displayAdditionalInfo) { if (ecoDisplayContentAvailable && caseDisplayContentAvailable) { bool showCaseNow = ((millis() / 5000UL) % 2UL) == 1UL; showCaseInfoBlock = showCaseNow; showEcoInfoBlock = !showCaseNow; } else { showEcoInfoBlock = ecoDisplayContentAvailable; showCaseInfoBlock = caseDisplayContentAvailable; } } if (!displayAdditionalInfo) { 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)displayedWaterTemp); display.print(F(" / ")); display.print((int)SetpointWasser); display.print(F(" ")); display.print((char)247); display.print(F("C")); if (ecoModeAktiv && dynamicEcoActive) display.print(ecoSwitchActive ? F(" (ECO+ S)") : F(" (ECO+)")); else if (ecoModeAktiv && !dynamicEcoActive) display.print(ecoSwitchActive ? F(" (ECO S)") : F(" (ECO)")); } display.println(""); display.println(""); display.println(F("Dampf: ")); if (dampfSafetyShutdown) { display.print(F("Sicherheitsabsch.")); } else if (dampfSensorError) { display.print(F("Sensorfehler!")); } else if (steamHeatDisabledByUser) { display.print(F("Nicht Heizen aktiv")); } else { bool steamDelayActiveSystem = (dampfVerzoegerung > 0 && (millis() - startupTime < (unsigned long)dampfVerzoegerung * 60000UL)); bool shouldShowDelayMessage = steamDelayActiveSystem && !steamDelayOverridden; if (!shouldShowDelayMessage) { display.print((int)displayedSteamTemp); display.print(F(" / ")); display.print((int)SetpointDampf); display.print(F(" ")); display.print((char)247); display.print(F("C")); if (ecoModeAktiv && dynamicEcoActive) display.print(ecoSwitchActive ? F(" (ECO+ S)") : F(" (ECO+)")); else if (ecoModeAktiv && !dynamicEcoActive) display.print(ecoSwitchActive ? F(" (ECO S)") : F(" (ECO)")); } else { display.print(F("Startverz\x94gerung")); } } if (showEcoInfoBlock) { if (ecoModeAktiv) { display.println(""); display.println(""); display.print(dynamicEcoActive ? F("Eco+ Modus aktiv") : F("Eco-Modus aktiv")); if (ecoSwitchActive) { display.print(F(" (Schalter)")); } } else if (ecoModeMinutes > 0 && !ecoSwitchActive) { 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 if (showCaseInfoBlock) { display.println(""); display.println(""); if (caseSensorType == CASE_SENSOR_TYPE_CUPTRAY) { display.print(F("Tassenablage: ")); } else { display.print(F("Geh\201use: ")); } display.print((int)InputCase); display.print(F(" ")); display.print((char)247); display.print(F("C")); } } } yield(); display.display(); // Display *immer* am Ende aktualisieren } #endif // ENABLE_DISPLAY void updateShotTimer() { if (cleaningAssistantActive) { pendingShotStartAfterScaleTare = false; hx711ShotTarePrepared = false; hx711ShotPostTareReadsRemaining = 0; shotSoftwareActive = false; return; } bool shotInputActive = shotSoftwareActive; if (pendingShotStartAfterScaleTare) { if (!shotInputActive || standbyModeActive) { pendingShotStartAfterScaleTare = false; hx711ShotTarePrepared = false; hx711ShotPostTareReadsRemaining = 0; } else { return; } } // --- Shot beginnt (Eingang ist aktiv) --- if (shotInputActive && !shotActive) { if (scaleEnabled && brewByWeightEnabled && scaleType == SCALE_HX711 && !hx711ShotTarePrepared) { scaleModeActive = false; requestHx711Tare(true, false); return; } // Pruefen, ob die Dampfverzoegerung per Schalter uebersprungen werden soll if (steamDelayOverrideBySwitchEnabled) { steamDelayOverridden = true; } scaleModeActive = false; // Manuellen Waage-Anzeigemodus beim Start des Bezugs beenden shotActive = true; shotStartTime = millis(); lastShotDurationMillis = 0; shotEndTime = 0; firstWeightChangeDetected = false; firstWeightChangeTime = 0; resetFlowGuardRuntime(); if (!wartungsModusAktiv) { // Nur ausfuehren, wenn NICHT im Wartungsmodus displayMaintenanceMessage = false; // Wartungsmeldung ausblenden // Eco Modus direkt beenden, falls aktiv if (ecoModeAktiv && !ecoSwitchActive) { ecoModeAktiv = false; ecoModeActivatedTime = 0; EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); pidWasser.SetMode(AUTOMATIC); pidDampf.SetMode(AUTOMATIC); } } if (!wartungsModusAktiv) { if (scaleEnabled) { if (scaleType == SCALE_I2C) { if (scaleConnected) { scales.setOffset(); delay(50); recordResetCheckpoint(RESET_CP_SCALE_I2C_READ); resetScaleReadingFilters(0.0f); } brewStartWeight = 0.0f; } else if (scaleType == SCALE_HX711) { if (hx711ShotTarePrepared) { hx711ShotTarePrepared = false; hx711ShotPostTareReadsRemaining = 0; brewStartWeight = 0.0f; } else if (hx711.is_ready()) { hx711.tare(); resetScaleReadingFilters(0.0f); hx711LastReadyTime = millis(); hx711HasValidReading = true; hx711NotReadyCounter = 0; scaleConnected = true; brewStartWeight = 0.0f; } else { brewStartWeight = 0.0f; } } else if (scaleType == SCALE_ESPNOW) { brewStartWeight = currentWeightReading; } } // Pre-Infusion oder direkten Start behandeln if (preInfusionEnabled) { currentPreInfusionState = PI_PRE_BREW; preInfusionPhaseStartTime = millis(); digitalWrite(PUMP_PIN, HIGH); digitalWrite(VALVE_PIN, HIGH); } else { currentPreInfusionState = PI_MAIN_BREW; preInfusionPhaseStartTime = millis(); digitalWrite(PUMP_PIN, HIGH); digitalWrite(VALVE_PIN, HIGH); } } else { // Im Wartungsmodus: Pumpe und Ventil direkt schalten digitalWrite(PUMP_PIN, HIGH); digitalWrite(VALVE_PIN, HIGH); } } // --- Shot ends (switch released) --- else if (!shotInputActive && shotActive) { unsigned long shotDuration = millis() - shotStartTime; if (!wartungsModusAktiv) { stopBrewSequence(shotDuration, true, false); } else { digitalWrite(PUMP_PIN, LOW); digitalWrite(VALVE_PIN, LOW); lastShotDurationMillis = shotDuration; shotEndTime = millis(); resetFlowGuardRuntime(); } shotActive = false; } } /************************************************************************************ * 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; autoTuneWasserStatus = AT_RUNNING; // 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; autoTuneDampfStatus = AT_RUNNING; // 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."); // } autoTuneWasserStatus = wasserSensorError ? AT_ABORT_SENSOR : AT_ABORT_SAFETY; 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 (zunaechst lokal, Plausibilitaet pruefen) double newKp = autoTuneWasser->GetKp(); double newKi = autoTuneWasser->GetKi(); double newKd = autoTuneWasser->GetKd(); int peaks = autoTuneWasser->getPeakCount(); // > 9 => Failsafe-Ende (Schwingung nie stabil) // Degenerierter Lauf (keine echte Schwingung -> Kp<=0/NaN): Werte NICHT uebernehmen, // sonst wuerde der PID-Regler mit unbrauchbaren Werten ueberschrieben. if (!(newKp > 0.0) || isnan(newKp) || isnan(newKi) || isnan(newKd)) { autoTuneWasserStatus = AT_ABORT_DEGENERATE; } else { KpWasser = newKp; KiWasser = newKi; KdWasser = newKd; // 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(); // Ergebniswerte fuer Anzeige merken autoTuneWasserResultKp = (float)KpWasser; autoTuneWasserResultKi = (float)KiWasser; autoTuneWasserResultKd = (float)KdWasser; autoTuneWasserStatus = (peaks > 9) ? AT_FAILSAFE_PEAKS : AT_SUCCESS; } // 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."); // } autoTuneDampfStatus = dampfSensorError ? AT_ABORT_SENSOR : AT_ABORT_SAFETY; 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 (zunaechst lokal, Plausibilitaet pruefen) double newKp = autoTuneDampf->GetKp(); double newKi = autoTuneDampf->GetKi(); double newKd = autoTuneDampf->GetKd(); int peaks = autoTuneDampf->getPeakCount(); // > 9 => Failsafe-Ende (Schwingung nie stabil) // Degenerierter Lauf (keine echte Schwingung -> Kp<=0/NaN): Werte NICHT uebernehmen. if (!(newKp > 0.0) || isnan(newKp) || isnan(newKi) || isnan(newKd)) { autoTuneDampfStatus = AT_ABORT_DEGENERATE; } else { KpDampf = newKp; KiDampf = newKi; KdDampf = newKd; // 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(); // Ergebniswerte fuer Anzeige merken autoTuneDampfResultKp = (float)KpDampf; autoTuneDampfResultKi = (float)KiDampf; autoTuneDampfResultKd = (float)KdDampf; autoTuneDampfStatus = (peaks > 9) ? AT_FAILSAFE_PEAKS : AT_SUCCESS; } // 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) ************************************************************************************/ // --- 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.
FlowGuard verlängert bei Bedarf die Bezugszeit bis zur eingestellten Mindest-Brühzeit, indem die Pumpe in der Hauptbezugsphase dynamisch gepulst wird.

)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"; // --- FlowGuard Chunks --- static const char brewControlFG_H3[] PROGMEM = R"rawliteral(

FlowGuard (Mindest-Brühzeit)

)rawliteral"; static const char brewControlFG_ToggleContainerStart[] PROGMEM = R"rawliteral(
Aktivieren:
)rawliteral"; static const char brewControlFG_Fields[] PROGMEM = R"rawliteral( (Benötigt aktive + verbundene Waage sowie Brew-By-Weight. FlowGuard schätzt den Endzeitpunkt aus Restgewicht/Flow und reduziert bei Bedarf den Pumpen-Duty-Cycle.)
)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"; // --- Steam-By-Time (SBT) Chunks --- static const char brewControlSBT_H3[] PROGMEM = R"rawliteral(

Dampf-Timer

)rawliteral"; static const char brewControlSBT_ToggleContainerStart[] PROGMEM = R"rawliteral(
Aktivieren:
)rawliteral"; static const char brewControlSBT_Fields[] PROGMEM = R"rawliteral( (Stoppt den Dampfbezug automatisch nach dieser Zeit)
)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[16]; // 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(); // --- FlowGuard Sektion --- response->print(FPSTR(brewControlFG_H3)); response->print(FPSTR(brewControlFG_ToggleContainerStart)); if (flowGuardEnabled) { response->print(F(" checked")); } response->print(FPSTR(brewControlFG_ToggleContainerEnd)); String fgFieldsHtml = FPSTR(brewControlFG_Fields); snprintf(buffer, sizeof(buffer), "%.1f", flowGuardMinBrewSeconds); fgFieldsHtml.replace("{FG_MIN_SECS}", buffer); snprintf(buffer, sizeof(buffer), "%u", (unsigned int)flowGuardPulsePeriodMs); fgFieldsHtml.replace("{FG_PULSE_MS}", buffer); snprintf(buffer, sizeof(buffer), "%.1f", flowGuardMinDutyPercent); fgFieldsHtml.replace("{FG_MIN_DUTY}", buffer); response->print(fgFieldsHtml); 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(); // --- Dampf-Timer Sektion --- response->print(FPSTR(brewControlSBT_H3)); response->print(FPSTR(brewControlSBT_ToggleContainerStart)); if (steamByTimeEnabled) { response->print(F(" checked")); } response->print(FPSTR(brewControlSBT_ToggleContainerEnd)); String sbtFieldsHtml = FPSTR(brewControlSBT_Fields); snprintf(buffer, sizeof(buffer), "%.1f", steamByTimeTargetSeconds); sbtFieldsHtml.replace("{SBT_SECS}", buffer); response->print(sbtFieldsHtml); yield(); // Formular Ende response->print(FPSTR(brewControlFormEnd)); request->send(response); } /************************************************************************************ * Handler zum Speichern der Brew Control Einstellungen (POST /saveBrewControl) ************************************************************************************/ struct BrewControlUpdate { bool hasBBTEnabled = false; bool bbtEnabled = false; bool hasBBTSecs = false; float bbtSecs = 0.0f; bool hasBBWEnabled = false; bool bbwEnabled = false; bool hasBBWTarget = false; float bbwTarget = 0.0f; bool hasBBWOffset = false; float bbwOffset = 0.0f; bool hasFlowGuardEnabled = false; bool flowGuardEnabled = false; bool hasFlowGuardMinSecs = false; float flowGuardMinSecs = 0.0f; bool hasFlowGuardPulseMs = false; uint16_t flowGuardPulseMs = 0; bool hasFlowGuardMinDuty = false; float flowGuardMinDuty = 0.0f; bool hasPIEnabled = false; bool piEnabled = false; bool hasPIDurSecs = false; float piDurSecs = 0.0f; bool hasPIPauseSecs = false; float piPauseSecs = 0.0f; bool hasSBTEnabled = false; bool sbtEnabled = false; bool hasSBTSecs = false; float sbtSecs = 0.0f; }; bool applyBrewControlUpdate(const BrewControlUpdate& update) { bool changed = false; bool flowGuardConfigChanged = false; if (update.hasBBTEnabled && update.bbtEnabled != brewByTimeEnabled) { brewByTimeEnabled = update.bbtEnabled; EEPROM.put(EEPROM_ADDR_BREWBYTIME_ENABLED, brewByTimeEnabled); changed = true; } if (update.hasBBTSecs) { float newBBTSecs = update.bbtSecs; if (newBBTSecs >= 5.0f && newBBTSecs <= 120.0f && fabs(newBBTSecs - brewByTimeTargetSeconds) > 0.01f) { brewByTimeTargetSeconds = newBBTSecs; EEPROM.put(EEPROM_ADDR_BREWBYTIME_SECONDS, brewByTimeTargetSeconds); changed = true; } } if (update.hasBBWEnabled && update.bbwEnabled != brewByWeightEnabled) { brewByWeightEnabled = update.bbwEnabled; EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_ENABLED, brewByWeightEnabled); changed = true; } if (update.hasBBWTarget) { float newBBWTarget = update.bbwTarget; if (newBBWTarget >= 10.0f && newBBWTarget <= 150.0f && fabs(newBBWTarget - brewByWeightTargetGrams) > 0.01f) { brewByWeightTargetGrams = newBBWTarget; EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_TARGET, brewByWeightTargetGrams); changed = true; } } if (update.hasBBWOffset) { float newBBWOffset = update.bbwOffset; if (newBBWOffset >= 0.0f && newBBWOffset <= 10.0f && fabs(newBBWOffset - brewByWeightOffsetGrams) > 0.01f) { brewByWeightOffsetGrams = newBBWOffset; EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_OFFSET, brewByWeightOffsetGrams); changed = true; } } if (update.hasFlowGuardEnabled && update.flowGuardEnabled != flowGuardEnabled) { flowGuardEnabled = update.flowGuardEnabled; EEPROM.put(EEPROM_ADDR_FLOWGUARD_ENABLED, flowGuardEnabled); changed = true; flowGuardConfigChanged = true; } if (update.hasFlowGuardMinSecs) { float newFlowGuardMinSecs = update.flowGuardMinSecs; if (newFlowGuardMinSecs >= FLOW_GUARD_MIN_SECONDS_MIN && newFlowGuardMinSecs <= FLOW_GUARD_MIN_SECONDS_MAX && fabs(newFlowGuardMinSecs - flowGuardMinBrewSeconds) > 0.01f) { flowGuardMinBrewSeconds = newFlowGuardMinSecs; EEPROM.put(EEPROM_ADDR_FLOWGUARD_MIN_SECONDS, flowGuardMinBrewSeconds); changed = true; flowGuardConfigChanged = true; } } if (update.hasFlowGuardPulseMs) { uint16_t newPulseMs = update.flowGuardPulseMs; if (newPulseMs >= FLOW_GUARD_PULSE_PERIOD_MIN_MS && newPulseMs <= FLOW_GUARD_PULSE_PERIOD_MAX_MS && newPulseMs != flowGuardPulsePeriodMs) { flowGuardPulsePeriodMs = newPulseMs; EEPROM.put(EEPROM_ADDR_FLOWGUARD_PULSE_PERIOD_MS, flowGuardPulsePeriodMs); changed = true; flowGuardConfigChanged = true; } } if (update.hasFlowGuardMinDuty) { float newFlowGuardMinDuty = update.flowGuardMinDuty; if (newFlowGuardMinDuty >= FLOW_GUARD_MIN_DUTY_MIN && newFlowGuardMinDuty <= FLOW_GUARD_MIN_DUTY_MAX && fabs(newFlowGuardMinDuty - flowGuardMinDutyPercent) > 0.01f) { flowGuardMinDutyPercent = newFlowGuardMinDuty; EEPROM.put(EEPROM_ADDR_FLOWGUARD_MIN_DUTY, flowGuardMinDutyPercent); changed = true; flowGuardConfigChanged = true; } } if (flowGuardConfigChanged) { resetFlowGuardRuntime(); } if (update.hasPIEnabled && update.piEnabled != preInfusionEnabled) { preInfusionEnabled = update.piEnabled; EEPROM.put(EEPROM_ADDR_PREINF_ENABLED, preInfusionEnabled); changed = true; } if (update.hasPIDurSecs) { float newPIDur = update.piDurSecs; if (newPIDur >= 0.5f && newPIDur <= 20.0f && fabs(newPIDur - preInfusionDurationSeconds) > 0.01f) { preInfusionDurationSeconds = newPIDur; EEPROM.put(EEPROM_ADDR_PREINF_DUR_SEC, preInfusionDurationSeconds); changed = true; } } if (update.hasPIPauseSecs) { float newPIPause = update.piPauseSecs; if (newPIPause >= 0.0f && newPIPause <= 20.0f && fabs(newPIPause - preInfusionPauseSeconds) > 0.01f) { preInfusionPauseSeconds = newPIPause; EEPROM.put(EEPROM_ADDR_PREINF_PAUSE_SEC, preInfusionPauseSeconds); changed = true; } } if (update.hasSBTEnabled && update.sbtEnabled != steamByTimeEnabled) { steamByTimeEnabled = update.sbtEnabled; EEPROM.put(EEPROM_ADDR_STEAMBYTIME_ENABLED, steamByTimeEnabled); changed = true; } if (update.hasSBTSecs) { float newSBTSecs = update.sbtSecs; if (newSBTSecs >= 1.0f && newSBTSecs <= 180.0f && fabs(newSBTSecs - steamByTimeTargetSeconds) > 0.01f) { steamByTimeTargetSeconds = newSBTSecs; EEPROM.put(EEPROM_ADDR_STEAMBYTIME_SECONDS, steamByTimeTargetSeconds); changed = true; } } if (changed) { EEPROM.commit(); } return changed; } void handleSaveBrewControl(AsyncWebServerRequest *request) { BrewControlUpdate update; update.hasBBTEnabled = true; update.bbtEnabled = request->hasArg("bbtEnabled"); update.hasBBTSecs = request->hasArg("bbtSecs"); if (update.hasBBTSecs) { update.bbtSecs = request->arg("bbtSecs").toFloat(); } update.hasBBWEnabled = true; update.bbwEnabled = request->hasArg("bbwEnabled"); update.hasBBWTarget = request->hasArg("bbwTarget"); if (update.hasBBWTarget) { update.bbwTarget = request->arg("bbwTarget").toFloat(); } update.hasBBWOffset = request->hasArg("bbwOffset"); if (update.hasBBWOffset) { update.bbwOffset = request->arg("bbwOffset").toFloat(); } update.hasFlowGuardEnabled = true; update.flowGuardEnabled = request->hasArg("fgEnabled"); update.hasFlowGuardMinSecs = request->hasArg("fgMinSecs"); if (update.hasFlowGuardMinSecs) { update.flowGuardMinSecs = request->arg("fgMinSecs").toFloat(); } update.hasFlowGuardPulseMs = request->hasArg("fgPulseMs"); if (update.hasFlowGuardPulseMs) { update.flowGuardPulseMs = (uint16_t)request->arg("fgPulseMs").toInt(); } update.hasFlowGuardMinDuty = request->hasArg("fgMinDuty"); if (update.hasFlowGuardMinDuty) { update.flowGuardMinDuty = request->arg("fgMinDuty").toFloat(); } update.hasPIEnabled = true; update.piEnabled = request->hasArg("piEnabled"); update.hasPIDurSecs = request->hasArg("piDurSecs"); if (update.hasPIDurSecs) { update.piDurSecs = request->arg("piDurSecs").toFloat(); } update.hasPIPauseSecs = request->hasArg("piPauseSecs"); if (update.hasPIPauseSecs) { update.piPauseSecs = request->arg("piPauseSecs").toFloat(); } update.hasSBTEnabled = true; update.sbtEnabled = request->hasArg("sbtEnabled"); update.hasSBTSecs = request->hasArg("sbtSecs"); if (update.hasSBTSecs) { update.sbtSecs = request->arg("sbtSecs").toFloat(); } applyBrewControlUpdate(update); AsyncWebServerResponse *response = request->beginResponse(303); response->addHeader("Location", "/Brew-Control"); request->send(response); } /************************************************************************************ * 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 ************************************************************************************/ static int16_t encodeResetDiagSignedTenths(float value) { if (!isfinite(value)) { return RESET_DIAG_INVALID_VALUE; } float scaled = value * 10.0f; if (scaled > 32767.0f) { scaled = 32767.0f; } else if (scaled < -32767.0f) { scaled = -32767.0f; } return (int16_t)lroundf(scaled); } static void printResetDiagTenths(AsyncResponseStream *response, int16_t value, const __FlashStringHelper *unit) { if (value == RESET_DIAG_INVALID_VALUE) { response->print(F("N/A")); return; } char valueBuffer[16]; snprintf(valueBuffer, sizeof(valueBuffer), "%.1f", value / 10.0f); response->print(valueBuffer); if (unit != nullptr) { response->print(unit); } } static const __FlashStringHelper* resetReasonToText(esp_reset_reason_t reason) { switch (reason) { case ESP_RST_UNKNOWN: return F("Unbekannt"); case ESP_RST_POWERON: return F("Power On"); case ESP_RST_EXT: return F("External Pin"); case ESP_RST_SW: return F("Software"); case ESP_RST_PANIC: return F("Panic/Exception"); case ESP_RST_INT_WDT: return F("Interrupt Watchdog"); case ESP_RST_TASK_WDT: return F("Task Watchdog"); case ESP_RST_WDT: return F("Other Watchdog"); case ESP_RST_DEEPSLEEP: return F("Deep Sleep Wakeup"); case ESP_RST_BROWNOUT: return F("Brownout"); case ESP_RST_SDIO: return F("SDIO"); default: return F("Anderer Grund"); } } static bool resetReasonHasCustomCode(esp_reset_reason_t reason) { switch (reason) { case ESP_RST_UNKNOWN: case ESP_RST_POWERON: case ESP_RST_EXT: case ESP_RST_SW: case ESP_RST_PANIC: case ESP_RST_INT_WDT: case ESP_RST_TASK_WDT: case ESP_RST_WDT: case ESP_RST_DEEPSLEEP: case ESP_RST_BROWNOUT: case ESP_RST_SDIO: return false; default: return true; } } static const __FlashStringHelper* resetCheckpointToText(uint16_t checkpoint) { switch (checkpoint) { case RESET_CP_SETUP_START: return F("Setup Start"); case RESET_CP_SETUP_WIFI: return F("Setup WLAN"); case RESET_CP_SETUP_DISPLAY: return F("Setup Display"); case RESET_CP_SETUP_SCALE: return F("Setup Waage"); case RESET_CP_SETUP_WEBSERVER: return F("Setup Webserver"); case RESET_CP_LOOP_START: return F("Loop Start"); case RESET_CP_LOOP_TEMPERATURE: return F("Loop Temperatur/PID"); case RESET_CP_LOOP_SSR: return F("Loop SSR"); case RESET_CP_LOOP_SHOT: return F("Loop Bezugslogik"); case RESET_CP_LOOP_ECO: return F("Loop Eco"); case RESET_CP_LOOP_DISPLAY: return F("Loop Display"); case RESET_CP_LOOP_SCALE: return F("Loop Waage"); case RESET_CP_LOOP_WS: return F("Loop Hintergrunddienste"); case RESET_CP_DISPLAY_RENDER: return F("Display Render"); case RESET_CP_SCALE_HX711_READ: return F("HX711 Lesen"); case RESET_CP_SCALE_I2C_READ: return F("I2C Waage lesen"); case RESET_CP_SCALE_BUTTON_READ: return F("I2C Waagenbutton"); case RESET_CP_ESPNOW_RECV: return F("ESP-NOW Empfang"); case RESET_CP_TOUCH_UART_RX: return F("Touch UART RX"); case RESET_CP_TOUCH_UART_TX: return F("Touch UART TX"); default: return F("Unbekannt"); } } void recordResetCheckpoint(uint16_t checkpoint) { rtcResetDiag.magic = RESET_DIAG_MAGIC; rtcResetDiag.version = RESET_DIAG_VERSION; rtcResetDiag.checkpoint = checkpoint; rtcResetDiag.uptimeMs = millis(); rtcResetDiag.freeHeap = ESP.getFreeHeap(); uint32_t flags = 0; if (standbyModeActive) flags |= RESET_DIAG_FLAG_STANDBY; if (ecoModeAktiv) flags |= RESET_DIAG_FLAG_ECO; if (shotActive) flags |= RESET_DIAG_FLAG_SHOT; if (steamCircuitActive) flags |= RESET_DIAG_FLAG_STEAM; if (flushActive) flags |= RESET_DIAG_FLAG_FLUSH; if (cleaningAssistantActive) flags |= RESET_DIAG_FLAG_CLEANING; if (scaleConnected) flags |= RESET_DIAG_FLAG_SCALE_CONNECTED; if (scaleModeActive) flags |= RESET_DIAG_FLAG_SCALE_MODE; if (WiFi.status() == WL_CONNECTED) flags |= RESET_DIAG_FLAG_WIFI_CONNECTED; if (tareScaleAfterDelay) flags |= RESET_DIAG_FLAG_TARE_PENDING; if (tareScaleSettling) flags |= RESET_DIAG_FLAG_TARE_SETTLING; if (wasserSensorError) flags |= RESET_DIAG_FLAG_SENSOR_W_ERROR; if (dampfSensorError) flags |= RESET_DIAG_FLAG_SENSOR_D_ERROR; if (caseSensorError) flags |= RESET_DIAG_FLAG_SENSOR_CASE_ERROR; if (steamHeatDisabledByUser) flags |= RESET_DIAG_FLAG_STEAM_HEAT_DISABLED; rtcResetDiag.flags = flags; rtcResetDiag.waterTempDeciC = encodeResetDiagSignedTenths((float)InputWasser); rtcResetDiag.steamTempDeciC = encodeResetDiagSignedTenths((float)InputDampf); rtcResetDiag.caseTempDeciC = encodeResetDiagSignedTenths((float)InputCase); rtcResetDiag.weightDeciG = encodeResetDiagSignedTenths(currentWeightReading); rtcResetDiag.scaleType = scaleType; rtcResetDiag.wifiStatus = (int8_t)WiFi.status(); rtcResetDiag.wifiMode = (uint8_t)WiFi.getMode(); } void initResetDiagnostics() { lastResetReason = esp_reset_reason(); lastResetDiagAvailable = (rtcResetDiag.magic == RESET_DIAG_MAGIC && rtcResetDiag.version == RESET_DIAG_VERSION); if (lastResetDiagAvailable) { lastResetDiag = rtcResetDiag; } else { memset(&lastResetDiag, 0, sizeof(lastResetDiag)); } memset(&rtcResetDiag, 0, sizeof(rtcResetDiag)); rtcResetDiag.magic = RESET_DIAG_MAGIC; rtcResetDiag.version = RESET_DIAG_VERSION; recordResetCheckpoint(RESET_CP_SETUP_START); } static void printLastResetDiagnostics(AsyncResponseStream *response) { // Hinweis, falls die Einstellungen beim Boot aus dem FFat-Backup wiederhergestellt wurden // (EEPROM war beschaedigt, aber dank Backup kein Werksreset noetig). if (eepromRestoredFromBackup) { response->print(F("
Hinweis: EEPROM war ungültig – Einstellungen wurden beim Start automatisch aus dem Backup wiederhergestellt.")); } if (!lastResetDiagAvailable) { response->print(F("
Reset-Details: Keine Zusatzdaten verfügbar.")); return; } response->print(F("
Reset-Details: letzter Checkpoint: ")); response->print(resetCheckpointToText(lastResetDiag.checkpoint)); response->print(F("
Uptime vor Reset: ")); response->print(lastResetDiag.uptimeMs); response->print(F(" ms")); response->print(F("
Heap vor Reset: ")); response->print(lastResetDiag.freeHeap); response->print(F(" Bytes")); response->print(F("
Zustand: ")); bool wroteState = false; struct StateLabel { uint32_t flag; const __FlashStringHelper *text; }; static const StateLabel stateLabels[] = { {RESET_DIAG_FLAG_STANDBY, F("Standby")}, {RESET_DIAG_FLAG_ECO, F("Eco")}, {RESET_DIAG_FLAG_SHOT, F("Bezug")}, {RESET_DIAG_FLAG_STEAM, F("Dampf")}, {RESET_DIAG_FLAG_FLUSH, F("Flush")}, {RESET_DIAG_FLAG_CLEANING, F("Reinigung")}, {RESET_DIAG_FLAG_SCALE_CONNECTED, F("Waage verbunden")}, {RESET_DIAG_FLAG_SCALE_MODE, F("Waagenmodus")}, {RESET_DIAG_FLAG_WIFI_CONNECTED, F("WLAN verbunden")}, {RESET_DIAG_FLAG_TARE_PENDING, F("Tara wartet")}, {RESET_DIAG_FLAG_TARE_SETTLING, F("Tara settling")}, {RESET_DIAG_FLAG_SENSOR_W_ERROR, F("Sensorfehler Wasser")}, {RESET_DIAG_FLAG_SENSOR_D_ERROR, F("Sensorfehler Dampf")}, {RESET_DIAG_FLAG_SENSOR_CASE_ERROR, F("Sensorfehler Gehäuse")}, {RESET_DIAG_FLAG_STEAM_HEAT_DISABLED, F("Dampfheizung gesperrt")}, }; for (size_t i = 0; i < (sizeof(stateLabels) / sizeof(stateLabels[0])); ++i) { if ((lastResetDiag.flags & stateLabels[i].flag) == 0) { continue; } if (wroteState) { response->print(F(", ")); } response->print(stateLabels[i].text); wroteState = true; } if (!wroteState) { response->print(F("keine Sonderzustände")); } response->print(F("
Temperaturen vor Reset: Wasser ")); printResetDiagTenths(response, lastResetDiag.waterTempDeciC, F(" °C")); response->print(F(", Dampf ")); printResetDiagTenths(response, lastResetDiag.steamTempDeciC, F(" °C")); response->print(F(", Gehäuse ")); printResetDiagTenths(response, lastResetDiag.caseTempDeciC, F(" °C")); response->print(F("
Gewicht vor Reset: ")); printResetDiagTenths(response, lastResetDiag.weightDeciG, F(" g")); response->print(F("
Waagentyp: ")); switch (lastResetDiag.scaleType) { case SCALE_I2C: response->print(F("I2C")); break; case SCALE_ESPNOW: response->print(F("ESP-NOW")); break; case SCALE_HX711: response->print(F("HX711")); break; default: response->print(F("Keine")); break; } response->print(F("
WLAN-Statuscode vor Reset: ")); response->print((int)lastResetDiag.wifiStatus); response->print(F("
WLAN-Modus vor Reset: ")); response->print((unsigned int)lastResetDiag.wifiMode); } 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 --- response->print(FPSTR(infoChunk_AfterCore)); // "
Boot-Version: " response->print(F("N/A")); // ESP32 has no getBootVersion() // --- Chip ID --- response->print(FPSTR(infoChunk_AfterBoot)); // "
Chip-ID: " uint64_t chipid_64 = ESP.getEfuseMac(); uint32_t chipid_32 = (uint32_t)(chipid_64 >> 16); snprintf(buffer, sizeof(buffer), "%08X", chipid_32); 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 --- response->print(FPSTR(infoChunk_AfterCPU)); // " MHz
Letzter Reset-Grund: " response->print(resetReasonToText(lastResetReason)); if (resetReasonHasCustomCode(lastResetReason)) { response->print(F(" (")); response->print((int)lastResetReason); response->print(F(")")); } printLastResetDiagnostics(response); // --- 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 static const char serviceChunk_CleaningAssistantFormStart[] PROGMEM = R"rawliteral(

Reinigungsassistent

1. Blindsieb in den Siebträger einsetzen.
2. Reinigungstablette einlegen.
3. Siebträger einspannen.
4. Assistenten starten.

)rawliteral"; static const char serviceChunk_CleaningAssistantStopForm[] PROGMEM = R"rawliteral(
)rawliteral"; static const char serviceChunk_CleaningAssistantStatusStart[] PROGMEM = R"rawliteral(
Reinigungsassistent: )rawliteral"; static const char serviceChunk_CleaningAssistantStatusEnd[] PROGMEM = R"rawliteral(
)rawliteral"; // --- 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)); snprintf(buffer, sizeof(buffer), "%u", (unsigned int)flushDurationSeconds); response->print(buffer); response->print(FPSTR(serviceChunk_AfterFlushDuration)); snprintf(buffer, sizeof(buffer), "%u", (unsigned int)steamFlushDurationSeconds); response->print(buffer); response->print(FPSTR(serviceChunk_AfterSteamFlushDuration)); // Ende Wartungs-Form yield(); if (cleaningAssistantStatusMessage.length() > 0) { response->print(FPSTR(serviceChunk_CleaningAssistantStatusStart)); response->print(cleaningAssistantStatusMessage); response->print(FPSTR(serviceChunk_CleaningAssistantStatusEnd)); cleaningAssistantStatusMessage = ""; yield(); } response->print(FPSTR(serviceChunk_CleaningAssistantFormStart)); snprintf(buffer, sizeof(buffer), "%u", (unsigned int)cleaningAssistantBrewSeconds); response->print(buffer); response->print(FPSTR(serviceChunk_CleaningAssistantMid)); snprintf(buffer, sizeof(buffer), "%u", (unsigned int)cleaningAssistantCycles); response->print(buffer); response->print(FPSTR(serviceChunk_CleaningAssistantPause)); snprintf(buffer, sizeof(buffer), "%u", (unsigned int)cleaningAssistantPauseSeconds); response->print(buffer); response->print(FPSTR(serviceChunk_CleaningAssistantEnd)); yield(); if (cleaningAssistantActive) { response->print(F("

Status: ")); if (cleaningAssistantWaitingForTemperature) { response->print(F("Heizt auf 93 °C auf")); } else { response->print(cleaningAssistantInBrewPhase ? F("Bezug läuft") : F("Pause zwischen den Zyklen")); } response->print(F("
Zyklus: ")); response->print(cleaningAssistantCurrentCycle); response->print(F(" / ")); response->print(cleaningAssistantCycles); if (cleaningAssistantWaitingForTemperature) { response->print(F("
Wasser: ")); snprintf(buffer, sizeof(buffer), "%.1f", getDisplayedWaterTemperature()); response->print(buffer); response->print(F(" / 93.0 °C

")); } else { response->print(F("
Verbleibend: ")); unsigned long phaseDurationMs = cleaningAssistantInBrewPhase ? (unsigned long)cleaningAssistantBrewSeconds * 1000UL : (unsigned long)cleaningAssistantPauseSeconds * 1000UL; unsigned long elapsedMs = millis() - cleaningAssistantPhaseStartTime; unsigned long remainingMs = (elapsedMs < phaseDurationMs) ? (phaseDurationMs - elapsedMs) : 0; snprintf(buffer, sizeof(buffer), "%lu", (remainingMs + 999UL) / 1000UL); response->print(buffer); response->print(F(" s

")); } response->print(FPSTR(serviceChunk_CleaningAssistantStopForm)); response->print(F("")); 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 Sensoren-Seite (/Sensoren) ************************************************************************************/ static const char sensorsHtmlHead[] PROGMEM = R"rawliteral( Sensoren )rawliteral"; static const char sensorsHtmlHeadEnd[] PROGMEM = R"rawliteral( )rawliteral"; static const char sensorsBodyStart[] PROGMEM = R"rawliteral(

Sensoren

Zusatz-Temperatur-Sensor

Sensor aktivieren:
Zusätzlicher NTC für Gehäuse oder Tassenablage.

Sensor-Typ

Im Chart kann die Anzeige separat aktiviert werden.

Offset

Eigenes Offset für Gehäuse/Tassenablage. Nicht von der Offset-Kompensation betroffen.

Dashboard

Temperatur im Dashboard anzeigen:
Nur aktiv, wenn der Zusatzsensor eingeschaltet ist.

Display

Zusatzsensor auf dem Display anzeigen:
Zeigt "Gehäuse" oder "Tassenablage" im Idle-Display an. Wenn auch die Eco-Info aktiviert ist, wechseln beide Anzeigen alle 5 Sekunden.

Waage

Waage aktivieren:
Waage ein- oder ausschalten (Brew-By-Weight nutzt diese Einstellung).

Waagen-Typ

Auswahl des Waage-Typs.

HX711 Kalibrierung

Wert wird nur bei HX711 genutzt.
Anzeige im Waage-Modus glätten:
Aktiviert nur die ruhigere Anzeige im HX711-Waage-Modus. Schnelle Schutzfilter bleiben immer aktiv.

Kalibrierungsassistent

1. Alle Gewichte entfernen und Nullpunkt setzen. 2. Referenzgewicht auflegen, Gewicht eingeben und Faktor berechnen.




X-Switch (GPIO42)

Funktion bei kurzem Tastendruck.
Funktion bei langem Tastendruck.
Gilt für alle Taster mit Long-Press-Funktion (X-Switch, Bezug, Dampf).
)rawliteral"; void handleSensors(AsyncWebServerRequest *request) { AsyncResponseStream *response = request->beginResponseStream("text/html"); response->print(FPSTR(sensorsHtmlHead)); response->print(FPSTR(commonStyle)); response->print(FPSTR(sensorsHtmlHeadEnd)); response->print(FPSTR(commonNav)); response->print(FPSTR(sensorsBodyStart)); if (caseSensorEnabled) { response->print(F(" checked")); } response->print(FPSTR(sensorsBodyAfterEnabled)); if (caseSensorType == CASE_SENSOR_TYPE_HOUSING) { response->print(F("selected")); } response->print(FPSTR(sensorsTypeOptionMid)); if (caseSensorType == CASE_SENSOR_TYPE_CUPTRAY) { response->print(F("selected")); } response->print(FPSTR(sensorsCaseSectionEnd)); if (!caseSensorEnabled) { response->print(F("disabled ")); } response->print(F("value='")); char caseOffsetBuffer[16]; snprintf(caseOffsetBuffer, sizeof(caseOffsetBuffer), "%.1f", OffsetCase); response->print(caseOffsetBuffer); response->print(F("'")); response->print(FPSTR(sensorsCaseOffsetAfterInput)); if (caseTempOnDashboard) { response->print(F(" checked")); } response->print(FPSTR(sensorsCaseDashboardAfterToggle)); if (caseTempOnDisplay) { response->print(F(" checked")); } response->print(FPSTR(sensorsCaseDisplayAfterToggle)); if (scaleEnabled) { response->print(F(" checked")); } response->print(FPSTR(sensorsScaleAfterEnabled)); if (scaleType == SCALE_I2C) { response->print(F("selected")); } response->print(FPSTR(sensorsScaleOptionMid1)); if (scaleType == SCALE_ESPNOW) { response->print(F("selected")); } response->print(FPSTR(sensorsScaleOptionMid2)); if (scaleType == SCALE_HX711) { response->print(F("selected")); } response->print(FPSTR(sensorsScaleAfterOptionsStart)); if (!(scaleEnabled && scaleType == SCALE_HX711)) { response->print(F("disabled ") ); } response->print(F("value='")); char hxCalBuffer[16]; snprintf(hxCalBuffer, sizeof(hxCalBuffer), "%.2f", hx711CalibrationFactor); response->print(hxCalBuffer); response->print(F("'")); response->print(FPSTR(sensorsBodyAfterHx711Start)); if (hx711DisplaySmoothingEnabled) { response->print(F(" checked")); } response->print(FPSTR(sensorsBodyAfterHx711DisplaySmoothing)); if (xSwitchAction == X_SWITCH_ACTION_NONE) { response->print(F("selected")); } response->print(FPSTR(sensorsXSwitchOptionMid1)); if (xSwitchAction == X_SWITCH_ACTION_MAINTENANCE) { response->print(F("selected")); } response->print(FPSTR(sensorsXSwitchOptionMid2)); if (xSwitchAction == X_SWITCH_ACTION_FAST_HEAT_UP) { response->print(F("selected")); } response->print(FPSTR(sensorsXSwitchOptionMid3)); if (xSwitchAction == X_SWITCH_ACTION_STEAM_DELAY_OVERRIDE) { response->print(F("selected")); } response->print(FPSTR(sensorsXSwitchOptionMid4)); if (xSwitchAction == X_SWITCH_ACTION_SCALE_MODE) { response->print(F("selected")); } response->print(FPSTR(sensorsXSwitchOptionMid5)); if (xSwitchAction == X_SWITCH_ACTION_DISABLE_STEAM_HEAT) { response->print(F("selected")); } response->print(FPSTR(sensorsXSwitchOptionMid6)); if (xSwitchAction == X_SWITCH_ACTION_TARE_SCALE) { response->print(F("selected")); } response->print(FPSTR(sensorsXSwitchLongStart)); if (xSwitchLongAction == X_SWITCH_ACTION_NONE) { response->print(F("selected")); } response->print(FPSTR(sensorsXSwitchOptionMid1)); if (xSwitchLongAction == X_SWITCH_ACTION_MAINTENANCE) { response->print(F("selected")); } response->print(FPSTR(sensorsXSwitchOptionMid2)); if (xSwitchLongAction == X_SWITCH_ACTION_FAST_HEAT_UP) { response->print(F("selected")); } response->print(FPSTR(sensorsXSwitchOptionMid3)); if (xSwitchLongAction == X_SWITCH_ACTION_STEAM_DELAY_OVERRIDE) { response->print(F("selected")); } response->print(FPSTR(sensorsXSwitchOptionMid4)); if (xSwitchLongAction == X_SWITCH_ACTION_SCALE_MODE) { response->print(F("selected")); } response->print(FPSTR(sensorsXSwitchOptionMid5)); if (xSwitchLongAction == X_SWITCH_ACTION_DISABLE_STEAM_HEAT) { response->print(F("selected")); } response->print(FPSTR(sensorsXSwitchLongOptionMid6)); if (xSwitchLongAction == X_SWITCH_ACTION_TARE_SCALE) { response->print(F("selected")); } response->print(FPSTR(sensorsXSwitchLongPressStart)); response->print((unsigned int)buttonLongPressMs); response->print(FPSTR(sensorsBodyEnd)); 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_SteamHeatDisabledOnWakeToggle_Start[] PROGMEM = R"rawliteral(
Dampf bei Neustart/Aufwachen auf "Nicht heizen" setzen:
Setzt die Dampf-Heizung nach Controller-Neustart und beim Verlassen des Standby automatisch auf "Nicht heizen aktiv". )rawliteral"; static const char ecoChunk_PowerOnStandbyToggle_Start[] PROGMEM = R"rawliteral(

Neustart-Verhalten

Nach Neustart/Power-On in Standby gehen:
Die Maschine startet nach jedem Controller-Neustart bzw. nach dem Einschalten direkt im Standby (Heizung aus). Zum Betrieb den Standby über Dashboard, Touch-Display oder Standby-Schalter aufheben. )rawliteral"; static const char ecoChunk_StandbyTimeToggle_Start[] PROGMEM = R"rawliteral(
Uhrzeit im Standby anzeigen:
Nur aktiv, wenn die Zeit synchronisiert ist. )rawliteral"; // --- Display-Helligkeit (nur UART-Touch-Display / ESP32-P4) --- static const char ecoChunk_BacklightActive_Start[] PROGMEM = R"rawliteral(

Display-Helligkeit (UART-Touch-Display)

Gilt ausschließlich für das UART-Touch-Display (ESP32-P4), nicht für das OLED der Steuerung. 0 % bei der Standby-Uhr = Hintergrundbeleuchtung aus. )rawliteral"; static const char ecoChunk_LightAutoOffToggle_Start[] PROGMEM = R"rawliteral(

Beleuchtung

Licht in Eco/Standby automatisch ausschalten:
Schaltet das Licht im Eco- und Standby-Modus aus und stellt es danach wieder her. )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(); response->print(FPSTR(ecoChunk_SteamHeatDisabledOnWakeToggle_Start)); if (steamHeatDisabledOnStartupWake) { response->print(F(" checked")); } response->print(FPSTR(ecoChunk_SteamHeatDisabledOnWakeToggle_End)); 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(); // Toggle-Switch fuer Standby-Zeit auf dem Display response->print(FPSTR(ecoChunk_StandbyTimeToggle_Start)); // HTML bis vor 'checked' if (standbyTimeOnDisplay) { response->print(F(" checked")); } response->print(FPSTR(ecoChunk_StandbyTimeToggle_End)); // Rest des Toggle-HTMLs mit Beschreibung yield(); // Display-Helligkeit (UART-Touch-Display / P4) response->print(FPSTR(ecoChunk_BacklightActive_Start)); snprintf(buffer, sizeof(buffer), "%u", (unsigned int)backlightActivePercent); response->print(buffer); response->print(FPSTR(ecoChunk_BacklightStandby_Mid)); snprintf(buffer, sizeof(buffer), "%u", (unsigned int)backlightStandbyClockPercent); response->print(buffer); response->print(FPSTR(ecoChunk_Backlight_End)); yield(); // Toggle-Switch fuer Eco-Lichtabschaltung response->print(FPSTR(ecoChunk_LightAutoOffToggle_Start)); // HTML bis vor 'checked' if (ecoLightAutoOffEnabled) { response->print(F(" checked")); } response->print(FPSTR(ecoChunk_LightAutoOffToggle_End)); // Rest des Toggle-HTMLs mit Beschreibung yield(); // Abschnitt "Neustart-Verhalten": nach Neustart/Power-On in Standby gehen response->print(FPSTR(ecoChunk_PowerOnStandbyToggle_Start)); if (powerOnStandby) { response->print(F(" checked")); } response->print(FPSTR(ecoChunk_PowerOnStandbyToggle_End)); 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

Offset-Kompensation:
Zeigt bei negativem Offset in Web-UI und Display zunächst die Temperatur ohne Offset, bis die offset-korrigierte Temperatur den Referenzwert beim Start/Aufwachen erreicht. )rawliteral"; // --- Dampf Boost Toggle --- static const char rootChunk_ToggleBoostD_Start[] PROGMEM = R"rawliteral(
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"; // --- Dampf Permanent Heat bei Bezug Toggle --- static const char rootChunk_ToggleSteamHeatOnDraw_Start[] PROGMEM = R"rawliteral(
Permanent heizen bei Dampf-Bezug:
Dampf-Heizung dauerhaft ein, solange Dampfbezug aktiv ist
)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); response->write(rootChunk_OffsetCompensationToggle_Start, strlen_P(rootChunk_OffsetCompensationToggle_Start)); if (offsetCompensationEnabled) response->print(F(" checked")); response->write(rootChunk_OffsetCompensationToggle_End, strlen_P(rootChunk_OffsetCompensationToggle_End)); 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_ToggleSteamHeatOnDraw_Start, strlen_P(rootChunk_ToggleSteamHeatOnDraw_Start)); if (steamHeatOnDraw) response->print(F(" checked")); response->write(rootChunk_ToggleSteamHeatOnDraw_End, strlen_P(rootChunk_ToggleSteamHeatOnDraw_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 offsetCompensationParamsChanged = false; 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; offsetCompensationParamsChanged = 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; offsetCompensationParamsChanged = true; } } { bool newVal = request->hasArg(F("offsetCompensation")); if (newVal != offsetCompensationEnabled) { offsetCompensationEnabled = newVal; EEPROM.put(EEPROM_ADDR_OFFSET_COMPENSATION_ENABLED, (uint8_t)(offsetCompensationEnabled ? 1 : 0)); valueChanged = true; offsetCompensationParamsChanged = 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"); } bool newSteamHeatOnDraw = request->hasArg(F("steamHeatOnDraw")); if (newSteamHeatOnDraw != steamHeatOnDraw) { steamHeatOnDraw = newSteamHeatOnDraw; EEPROM.put(EEPROM_ADDR_STEAM_HEAT_ON_DRAW, (uint8_t)(steamHeatOnDraw ? 1 : 0)); valueChanged = true; } 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")); if (offsetCompensationParamsChanged) { resetOffsetCompensationReferences(); } 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"; // Display-Firmware-Sektion (UART-Touch-Display / ESP32-P4). Wird dynamisch // zusammengesetzt, damit Verbindungsstatus + gemeldeter Firmware-Stand eingeblendet // werden koennen. Bewusst klar abgegrenzt vom OLED der Steuerung. static const char firmwareDisplaySectionStart[] PROGMEM = R"rawliteral(

UART-Touch-Display (ESP32-P4)

Hinweis: Diese Optionen betreffen ausschließlich das per UART angeschlossene Touch-Display (ESP32-P4)nicht das OLED der Steuerung.

Status: )rawliteral"; // Endet vor {STATUS} static const char firmwareDisplaySectionFw[] PROGMEM = R"rawliteral(
Firmware-Stand Display: )rawliteral"; // Endet vor {DISPLAYFW} static const char firmwareDisplaySectionEnd[] PROGMEM = R"rawliteral(

Firmware des P4-Touch-Displays über die Steuerung aktualisieren (Übertragung per UART, dauert einige Minuten).

)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)); // Display-Firmware-Sektion (UART-Touch-Display / P4) mit Live-Status response->print(FPSTR(firmwareDisplaySectionStart)); if (touchUartClientActive) { response->print(F("verbunden")); } else { response->print(F("nicht verbunden")); } response->print(FPSTR(firmwareDisplaySectionFw)); if (touchUartClientActive && touchUartClientFirmware.length() > 0) { response->print(touchUartClientFirmware); } else { response->print(F("unbekannt")); } response->print(FPSTR(firmwareDisplaySectionEnd)); // 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"; // --- Hilfsfunktion: Status-/Ergebnis-Banner eines AutoTune-Laufs in den Web-Stream schreiben --- // status als uint8_t (nicht AutoTuneStatus): der Arduino-Auto-Prototyp wird am Dateianfang // erzeugt, wo der weiter unten definierte Enum-Typ noch nicht bekannt ist. static void printAutoTuneStatusLine(AsyncResponseStream *response, const char *kreis, uint8_t status, float kp, float ki, float kd) { if (status == AT_IDLE) return; // noch nie gelaufen -> nichts anzeigen const char *farbe = "#888888"; String text; char vals[80]; snprintf(vals, sizeof(vals), " (Kp=%.2f, Ki=%.2f, Kd=%.2f)", kp, ki, kd); switch (status) { case AT_RUNNING: farbe = "#FFCC00"; text = "läuft gerade ..."; break; case AT_SUCCESS: farbe = "#33CC66"; text = String("Erfolgreich abgeschlossen, neue Werte übernommen.") + vals; break; case AT_FAILSAFE_PEAKS: farbe = "#FF9900"; text = String("Beendet per Failsafe (Schwingung wurde nie stabil, >9 Peaks). Werte übernommen, aber evtl. ungenau – bitte prüfen.") + vals; break; case AT_ABORT_DEGENERATE: farbe = "#FF4444"; text = "Abgebrochen: keine verwertbare Schwingung (z. B. Zieltemperatur nicht erreicht oder Step zu klein). Werte NICHT geändert."; break; case AT_ABORT_SENSOR: farbe = "#FF4444"; text = "Abgebrochen: Temperatursensorfehler. Werte nicht geändert."; break; case AT_ABORT_SAFETY: farbe = "#FF4444"; text = "Abgebrochen: Sicherheitsabschaltung (Übertemperatur). Werte nicht geändert."; break; case AT_ABORT_MANUAL: farbe = "#AAAAAA"; text = "Manuell abgebrochen. Werte nicht geändert."; break; default: return; } response->print(F("

")); response->print(kreis); response->print(F(": ")); response->print(text); response->print(F("

")); } static void printAutoTuneStatusBanner(AsyncResponseStream *response) { if (autoTuneWasserStatus == AT_IDLE && autoTuneDampfStatus == AT_IDLE) return; response->print(F("

Letztes Tuning-Ergebnis

")); printAutoTuneStatusLine(response, "Wasser / Kessel", autoTuneWasserStatus, autoTuneWasserResultKp, autoTuneWasserResultKi, autoTuneWasserResultKd); printAutoTuneStatusLine(response, "Dampf / Thermoblock", autoTuneDampfStatus, autoTuneDampfResultKp, autoTuneDampfResultKi, autoTuneDampfResultKd); response->print(F("
")); } // --- 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)); } // Status/Ergebnis des letzten (bzw. laufenden) Tunings anzeigen printAutoTuneStatusBanner(response); 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); if (autoTuneWasserActive) autoTuneWasserStatus = AT_ABORT_MANUAL; if (autoTuneDampfActive) autoTuneDampfStatus = AT_ABORT_MANUAL; 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; } if (request->hasArg("ecoLightAutoOff")) { ecoLightAutoOffEnabled = true; } else { ecoLightAutoOffEnabled = false; } if (request->hasArg("steamHeatDisabledOnWake")) { steamHeatDisabledOnStartupWake = true; } else { steamHeatDisabledOnStartupWake = false; } if (request->hasArg("standbyTimeDisplay")) { standbyTimeOnDisplay = true; } else { standbyTimeOnDisplay = false; } if (request->hasArg("powerOnStandby")) { powerOnStandby = true; } else { powerOnStandby = false; } if (request->hasArg("backlightActive")) { int v = request->arg("backlightActive").toInt(); if (v < 5) v = 5; if (v > 100) v = 100; backlightActivePercent = (uint8_t)v; } if (request->hasArg("backlightStandbyClock")) { int v = request->arg("backlightStandbyClock").toInt(); if (v < 0) v = 0; if (v > 100) v = 100; backlightStandbyClockPercent = (uint8_t)v; } 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_STEAM_DELAY_OVERRIDE_SWITCH, steamDelayOverrideBySwitchEnabled); EEPROM.put(EEPROM_ADDR_ECO_INFO_ON_DISPLAY, ecoInfoOnDisplay); EEPROM.put(EEPROM_ADDR_ECO_LIGHT_AUTO_OFF, ecoLightAutoOffEnabled); EEPROM.put(EEPROM_ADDR_STEAM_HEAT_DISABLED_ON_STARTUP_WAKE, steamHeatDisabledOnStartupWake); EEPROM.put(EEPROM_ADDR_STANDBY_TIME_ON_DISPLAY, standbyTimeOnDisplay); EEPROM.put(EEPROM_ADDR_POWERON_STANDBY, (uint8_t)(powerOnStandby ? 1 : 0)); EEPROM.put(EEPROM_ADDR_BACKLIGHT_ACTIVE_PERCENT, backlightActivePercent); EEPROM.put(EEPROM_ADDR_BACKLIGHT_STANDBY_CLOCK_PERCENT, backlightStandbyClockPercent); EEPROM.commit(); // Wenn Eco deaktiviert, Setpoints zurückladen if (ecoModeMinutes == 0 && !ecoSwitchActive) { 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); } struct ServiceSettingsUpdate { bool hasPiezoEnabled = false; bool piezoEnabled = false; bool hasMaintenanceInterval = false; int maintenanceInterval = 0; bool hasFlushDurationSeconds = false; uint8_t flushDurationSeconds = 1; bool hasSteamFlushDurationSeconds = false; uint8_t steamFlushDurationSeconds = 1; bool resetMaintenanceCounter = false; }; bool applyServiceSettingsUpdate(const ServiceSettingsUpdate& update) { bool changed = false; if (update.hasPiezoEnabled && update.piezoEnabled != piezoEnabled) { piezoEnabled = update.piezoEnabled; EEPROM.put(EEPROM_ADDR_PIEZO_ENABLED, piezoEnabled); changed = true; } if (update.hasMaintenanceInterval) { int newInterval = update.maintenanceInterval; if (newInterval < 0) { newInterval = 0; } if (newInterval != maintenanceInterval) { maintenanceInterval = newInterval; EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL, maintenanceInterval); changed = true; } } if (update.hasFlushDurationSeconds) { int newFlushDuration = (int)update.flushDurationSeconds; if (newFlushDuration < 1) { newFlushDuration = 1; } if (newFlushDuration > 30) { newFlushDuration = 30; } if ((uint8_t)newFlushDuration != flushDurationSeconds) { flushDurationSeconds = (uint8_t)newFlushDuration; EEPROM.put(EEPROM_ADDR_FLUSH_DURATION_SECONDS, flushDurationSeconds); changed = true; } } if (update.hasSteamFlushDurationSeconds) { int newSteamFlushDuration = (int)update.steamFlushDurationSeconds; if (newSteamFlushDuration < 1) { newSteamFlushDuration = 1; } if (newSteamFlushDuration > 30) { newSteamFlushDuration = 30; } if ((uint8_t)newSteamFlushDuration != steamFlushDurationSeconds) { steamFlushDurationSeconds = (uint8_t)newSteamFlushDuration; EEPROM.put(EEPROM_ADDR_STEAM_FLUSH_DURATION_SECONDS, steamFlushDurationSeconds); changed = true; } } if (update.resetMaintenanceCounter) { if (maintenanceIntervalCounter != 0) { maintenanceIntervalCounter = 0; EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter); changed = true; } displayMaintenanceMessage = false; } if (changed) { EEPROM.commit(); } return changed; } /************************************************************************************ * Speichert NUR die Einstellungen fuer den Zusatz-NTC ************************************************************************************/ struct SensorSettingsUpdate { bool updateCaseSection = false; bool hasCaseEnabled = false; bool caseEnabled = false; bool hasCaseType = false; uint8_t caseType = 0; bool hasCaseOffset = false; double caseOffset = 0.0; bool hasCaseTempDashboard = false; bool caseTempDashboard = false; bool hasCaseTempDisplay = false; bool caseTempDisplay = false; bool updateScaleSection = false; bool hasScaleEnabled = false; bool scaleEnabled = false; bool hasScaleType = false; uint8_t scaleType = 0; bool hasHx711Cal = false; float hx711Cal = 0.0f; bool hasHx711DisplaySmoothing = false; bool hx711DisplaySmoothing = defaultHx711DisplaySmoothingEnabled; bool updateXSwitchSection = false; bool hasXSwitchAction = false; uint8_t xSwitchAction = 0; bool hasXSwitchLongAction = false; uint8_t xSwitchLongAction = 0; bool hasButtonLongPressMs = false; uint16_t buttonLongPressMs = 0; }; struct SensorSettingsApplyResult { bool changed = false; bool scaleSettingsChanged = false; }; SensorSettingsApplyResult applySensorSettingsUpdate(const SensorSettingsUpdate& update) { SensorSettingsApplyResult result; if (update.updateCaseSection) { if (update.hasCaseEnabled && update.caseEnabled != caseSensorEnabled) { caseSensorEnabled = update.caseEnabled; EEPROM.put(EEPROM_ADDR_CASE_SENSOR_ENABLED, (uint8_t)(caseSensorEnabled ? 1 : 0)); result.changed = true; } if (update.hasCaseType) { uint8_t newType = update.caseType; if (newType > CASE_SENSOR_TYPE_CUPTRAY) { newType = defaultCaseSensorType; } if (newType != caseSensorType) { caseSensorType = newType; EEPROM.put(EEPROM_ADDR_CASE_SENSOR_TYPE, caseSensorType); result.changed = true; } } if (update.hasCaseOffset) { double newCaseOffset = update.caseOffset; if (!isfinite(newCaseOffset)) { newCaseOffset = defaultOffsetCase; } if (fabs(newCaseOffset - OffsetCase) > 0.0001) { OffsetCase = newCaseOffset; EEPROM.put(EEPROM_ADDR_OFFSET_CASE, OffsetCase); result.changed = true; } } if (update.hasCaseTempDashboard && update.caseTempDashboard != caseTempOnDashboard) { caseTempOnDashboard = update.caseTempDashboard; EEPROM.put(EEPROM_ADDR_CASE_TEMP_DASHBOARD, (uint8_t)(caseTempOnDashboard ? 1 : 0)); result.changed = true; } if (update.hasCaseTempDisplay && update.caseTempDisplay != caseTempOnDisplay) { caseTempOnDisplay = update.caseTempDisplay; EEPROM.put(EEPROM_ADDR_CASE_TEMP_ON_DISPLAY, (uint8_t)(caseTempOnDisplay ? 1 : 0)); result.changed = true; } } if (update.updateScaleSection) { bool newScaleEnabled = scaleEnabled; uint8_t newScaleType = scaleType; if (update.hasScaleEnabled) { newScaleEnabled = update.scaleEnabled; } if (update.hasScaleType) { newScaleType = update.scaleType; if (newScaleType > SCALE_HX711) { newScaleType = defaultScaleType; } } if (newScaleEnabled != scaleEnabled || newScaleType != scaleType) { deinitScale(); if (newScaleEnabled != scaleEnabled) { scaleEnabled = newScaleEnabled; EEPROM.put(EEPROM_ADDR_SCALE_ENABLED, (uint8_t)(scaleEnabled ? 1 : 0)); result.changed = true; } if (newScaleType != scaleType) { scaleType = newScaleType; EEPROM.put(EEPROM_ADDR_SCALE_TYPE, scaleType); result.changed = true; } result.scaleSettingsChanged = true; } if (update.hasHx711Cal) { float newCal = update.hx711Cal; if (!isfinite(newCal) || newCal == 0.0f) { newCal = HX711_CALIBRATION_FACTOR; } if (fabs(newCal - hx711CalibrationFactor) > 0.0001f) { hx711CalibrationFactor = newCal; EEPROM.put(EEPROM_ADDR_HX711_CAL_FACTOR, hx711CalibrationFactor); result.changed = true; if (scaleEnabled && scaleType == SCALE_HX711) { hx711.set_scale(hx711CalibrationFactor); if (hx711.is_ready()) { hx711.tare(); resetScaleReadingFilters(0.0f); hx711LastReadyTime = millis(); hx711HasValidReading = true; hx711NotReadyCounter = 0; scaleConnected = true; } } } } if (update.hasHx711DisplaySmoothing && update.hx711DisplaySmoothing != hx711DisplaySmoothingEnabled) { hx711DisplaySmoothingEnabled = update.hx711DisplaySmoothing; EEPROM.put(EEPROM_ADDR_HX711_DISPLAY_SMOOTHING_ENABLED, (uint8_t)(hx711DisplaySmoothingEnabled ? 1 : 0)); resetScaleReadingFilters(currentWeightReading); result.changed = true; } } if (update.updateXSwitchSection && update.hasXSwitchAction) { uint8_t newAction = update.xSwitchAction; if (newAction > X_SWITCH_ACTION_MAX) { newAction = defaultXSwitchAction; } if (newAction != xSwitchAction) { xSwitchAction = newAction; EEPROM.put(EEPROM_ADDR_X_SWITCH_ACTION, xSwitchAction); result.changed = true; } } if (update.updateXSwitchSection && update.hasXSwitchLongAction) { uint8_t newLongAction = update.xSwitchLongAction; if (newLongAction > X_SWITCH_ACTION_MAX) { newLongAction = defaultXSwitchLongAction; } if (newLongAction != xSwitchLongAction) { xSwitchLongAction = newLongAction; EEPROM.put(EEPROM_ADDR_X_SWITCH_LONG_ACTION, xSwitchLongAction); result.changed = true; } } if (update.updateXSwitchSection && update.hasButtonLongPressMs) { uint16_t newLongPressMs = update.buttonLongPressMs; if (newLongPressMs < BUTTON_LONG_PRESS_MIN_MS) { newLongPressMs = (uint16_t)BUTTON_LONG_PRESS_MIN_MS; } if (newLongPressMs > BUTTON_LONG_PRESS_MAX_MS) { newLongPressMs = (uint16_t)BUTTON_LONG_PRESS_MAX_MS; } if (newLongPressMs != buttonLongPressMs) { buttonLongPressMs = newLongPressMs; EEPROM.put(EEPROM_ADDR_BUTTON_LONG_PRESS_MS, buttonLongPressMs); result.changed = true; } } if (result.changed) { EEPROM.commit(); } if (result.scaleSettingsChanged) { initScale(); } return result; } void handleUpdateSensorSettings(AsyncWebServerRequest *request) { String section = request->hasArg("sensorSection") ? request->arg("sensorSection") : ""; bool isCaseSection = (section.length() == 0 || section == "case"); bool isScaleSection = (section.length() == 0 || section == "scale"); bool isXSwitchSection = (section.length() == 0 || section == "xSwitch"); SensorSettingsUpdate update; update.updateCaseSection = isCaseSection; update.updateScaleSection = isScaleSection; update.updateXSwitchSection = isXSwitchSection; if (isCaseSection) { update.hasCaseEnabled = true; update.caseEnabled = request->hasArg("caseSensorEnabled"); update.hasCaseType = request->hasArg("caseSensorType"); if (update.hasCaseType) { update.caseType = (uint8_t)request->arg("caseSensorType").toInt(); } update.hasCaseOffset = request->hasArg("caseOffset"); if (update.hasCaseOffset) { update.caseOffset = request->arg("caseOffset").toDouble(); } update.hasCaseTempDashboard = true; update.caseTempDashboard = request->hasArg("caseTempDashboard"); update.hasCaseTempDisplay = true; update.caseTempDisplay = request->hasArg("caseTempDisplay"); } if (isScaleSection) { update.hasScaleEnabled = true; update.scaleEnabled = request->hasArg("scaleEnabled"); update.hasScaleType = request->hasArg("scaleType"); if (update.hasScaleType) { update.scaleType = (uint8_t)request->arg("scaleType").toInt(); } update.hasHx711Cal = request->hasArg("hx711CalFactor"); if (update.hasHx711Cal) { update.hx711Cal = request->arg("hx711CalFactor").toFloat(); } update.hasHx711DisplaySmoothing = true; update.hx711DisplaySmoothing = request->hasArg("hx711DisplaySmoothing"); } if (isXSwitchSection) { update.hasXSwitchAction = request->hasArg("xSwitchAction"); if (update.hasXSwitchAction) { update.xSwitchAction = (uint8_t)request->arg("xSwitchAction").toInt(); } update.hasXSwitchLongAction = request->hasArg("xSwitchLongAction"); if (update.hasXSwitchLongAction) { update.xSwitchLongAction = (uint8_t)request->arg("xSwitchLongAction").toInt(); } update.hasButtonLongPressMs = request->hasArg("buttonLongPressMs"); if (update.hasButtonLongPressMs) { update.buttonLongPressMs = (uint16_t)request->arg("buttonLongPressMs").toInt(); } } applySensorSettingsUpdate(update); AsyncWebServerResponse *resp = request->beginResponse(303); resp->addHeader("Location", "/Sensoren"); 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 (request->hasArg("flushDurationSeconds")) { int newFlushDuration = request->arg("flushDurationSeconds").toInt(); if (newFlushDuration < 1) newFlushDuration = 1; if (newFlushDuration > 30) newFlushDuration = 30; if ((uint8_t)newFlushDuration != flushDurationSeconds) { flushDurationSeconds = (uint8_t)newFlushDuration; EEPROM.put(EEPROM_ADDR_FLUSH_DURATION_SECONDS, flushDurationSeconds); changed = true; } } if (request->hasArg("steamFlushDurationSeconds")) { int newSteamFlushDuration = request->arg("steamFlushDurationSeconds").toInt(); if (newSteamFlushDuration < 1) newSteamFlushDuration = 1; if (newSteamFlushDuration > 30) newSteamFlushDuration = 30; if ((uint8_t)newSteamFlushDuration != steamFlushDurationSeconds) { steamFlushDurationSeconds = (uint8_t)newSteamFlushDuration; EEPROM.put(EEPROM_ADDR_STEAM_FLUSH_DURATION_SECONDS, steamFlushDurationSeconds); 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; 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; response->print(F("{\"wasser\":")); snprintf(buffer, sizeof(buffer), "%.1f", getDisplayedWaterTemperature()); 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", getDisplayedSteamTemperature()); response->print(buffer); response->print(F(",\"setpointdampf\":")); snprintf(buffer, sizeof(buffer), "%.1f", SetpointDampf); response->print(buffer); response->print(F(",\"caseEnabled\":")); response->print(caseSensorEnabled ? F("true") : F("false")); response->print(F(",\"caseType\":")); response->print(caseSensorType); response->print(F(",\"caseTempDisplaySetting\":")); response->print(caseTempOnDisplay ? F("true") : F("false")); response->print(F(",\"caseTemp\":")); if (!caseSensorEnabled || caseSensorError || isnan(InputCase)) { response->print(F("null")); } else { snprintf(buffer, sizeof(buffer), "%.1f", InputCase); 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 caseColor = '#8BC34A'; 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: 'Zusatzsensor IST', data: [], borderColor: caseColor, backgroundColor: 'rgba(139, 195, 74, 0.12)', borderWidth: 2, fill: 'start', tension: 0.35, pointRadius: 0, pointHoverRadius: 6, pointHitRadius: 10, pointHoverBackgroundColor: caseColor }, { 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; const caseToggleContainer = document.getElementById('caseSensorChartToggleContainer'); const caseToggle = document.getElementById('caseSensorChartToggle'); const caseToggleLabel = document.getElementById('caseSensorChartLabel'); const caseTypeLabels = { 0: 'Gehäuse', 1: 'Tassenablage' }; let currentCaseType = 0; let caseSensorEnabled = false; combinedChart.data.datasets[2].hidden = true; if (caseToggleContainer) { caseToggleContainer.style.display = 'none'; } 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 labels = combinedChart.data.labels; const datasets = combinedChart.data.datasets; if (typeof data.caseEnabled === 'boolean') { caseSensorEnabled = data.caseEnabled; } if (data.caseType !== undefined && !isNaN(data.caseType)) { currentCaseType = data.caseType; } const caseLabel = caseTypeLabels[currentCaseType] || 'Zusatzsensor'; datasets[2].label = caseLabel + ' IST'; if (caseToggleLabel) { caseToggleLabel.textContent = caseLabel + ' anzeigen:'; } if (caseToggleContainer) { caseToggleContainer.style.display = caseSensorEnabled ? '' : 'none'; } if (caseToggle) { if (!caseSensorEnabled) { caseToggle.checked = false; } datasets[2].hidden = !caseSensorEnabled || !caseToggle.checked; } const now = new Date(); const timeLabel = now.toLocaleTimeString('de-DE'); labels.push(timeLabel); datasets[0].data.push(data.wasser); datasets[1].data.push(data.dampf); const caseValue = (caseSensorEnabled && caseToggle && caseToggle.checked) ? data.caseTemp : null; datasets[2].data.push((caseValue === undefined) ? null : caseValue); datasets[3].data.push(currentSetpointWasser); datasets[4].data.push(currentSetpointDampf); datasets[5].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}`); } }); if (caseToggle) { caseToggle.addEventListener('change', function() { combinedChart.data.datasets[2].hidden = !caseToggle.checked; combinedChart.update('none'); }); } 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
"; } else if (caseSensorEnabled && caseSensorError) { const char* caseLabel = (caseSensorType == CASE_SENSOR_TYPE_CUPTRAY) ? "Tassenablage" : "Gehäuse"; errorMessageHtml = "
Temperaturwert " + String(caseLabel) + " 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"; } // FFat/FATFS kommt mit ASCII-Dateinamen am zuverlässigsten zurecht. profileName.replace("Ä", "Ae"); profileName.replace("Ö", "Oe"); profileName.replace("Ü", "Ue"); profileName.replace("ä", "ae"); profileName.replace("ö", "oe"); profileName.replace("ü", "ue"); profileName.replace("ß", "ss"); String sanitizedName; sanitizedName.reserve(profileName.length()); bool lastWasUnderscore = false; for (size_t i = 0; i < profileName.length(); ++i) { char c = profileName[i]; bool isAsciiLetter = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); bool isAsciiDigit = (c >= '0' && c <= '9'); bool keepChar = isAsciiLetter || isAsciiDigit || c == '-' || c == '_'; if (keepChar) { sanitizedName += c; lastWasUnderscore = false; } else if (!lastWasUnderscore) { sanitizedName += '_'; lastWasUnderscore = true; } } while (sanitizedName.startsWith("_")) { sanitizedName.remove(0, 1); } while (sanitizedName.endsWith("_")) { sanitizedName.remove(sanitizedName.length() - 1); } if (sanitizedName.length() == 0) { sanitizedName = "Unbenannt"; } // Maximale Länge begrenzen (z.B. 30 Zeichen + .txt) if (sanitizedName.length() > 30) { sanitizedName = sanitizedName.substring(0, 30); } return sanitizedName; } String normalizeProfileDisplayName(String profileName) { profileName.trim(); if (profileName.length() == 0) { profileName = "Unbenannt"; } if (profileName.length() > 30) { profileName = profileName.substring(0, 30); } return profileName; } String htmlEscape(const String& input) { String escaped = input; escaped.replace("&", "&"); escaped.replace("\"", """); escaped.replace("'", "'"); escaped.replace("<", "<"); escaped.replace(">", ">"); return escaped; } String jsSingleQuoteEscape(const String& input) { String escaped = input; escaped.replace("\\", "\\\\"); escaped.replace("'", "\\'"); escaped.replace("\r", ""); escaped.replace("\n", "\\n"); return escaped; } static String getStandbyTimerCurrentTimeLabel() { if (!timeSynced) { return "NTP-Zeit nicht synchronisiert"; } time_t now_t; time(&now_t); struct tm localTime; localtime_r(&now_t, &localTime); if (localTime.tm_year < (2024 - 1900)) { return "NTP-Zeit nicht synchronisiert"; } char buffer[24]; snprintf(buffer, sizeof(buffer), "%02d.%02d.%04d %02d:%02d", localTime.tm_mday, localTime.tm_mon + 1, localTime.tm_year + 1900, localTime.tm_hour, localTime.tm_min); return String(buffer); } static bool parseStandbyTimerTimeValue(const String& timeValue, uint8_t& hour, uint8_t& minute) { const int colonPos = timeValue.indexOf(':'); if (colonPos < 1) { return false; } int parsedHour = timeValue.substring(0, colonPos).toInt(); int parsedMinute = timeValue.substring(colonPos + 1).toInt(); if (parsedHour < 0 || parsedHour > 23 || parsedMinute < 0 || parsedMinute > 59) { return false; } hour = (uint8_t)parsedHour; minute = (uint8_t)parsedMinute; return true; } static uint8_t readStandbyTimerWeekdaysFromRequest(AsyncWebServerRequest *request) { uint8_t weekdaysMask = 0; for (uint8_t i = 0; i < 7; ++i) { String argName = "wd"; argName += String(i); if (request->hasArg(argName)) { weekdaysMask |= (uint8_t)(1U << i); } } return weekdaysMask; } static void appendStandbyTimerWeekdayInputs(AsyncResponseStream *response, uint8_t weekdaysMask) { static const char* labels[7] = {"Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"}; for (uint8_t i = 0; i < 7; ++i) { String item = "