Files
Dual-PID/Sicherungen/22.04.2025.ino
raw-designs 453644816f Initiale Bereitstellung
Initiale Bereitstellung der aktuellen Version auf Gitea
2026-07-10 18:13:43 +02:00

7225 lines
334 KiB
Arduino

/************************************************************************************
* Dual-PID Controller Firmware (Zeitproportional)
* Version (Siehe unten im Code)
*
* Dieses Programm steuert Wasser- und Dampftemperatur über zwei unabhängige PID-Regler
* mit zeitproportionaler Ansteuerung der SSRs.
* Sensoren: MAX6675 (Dampf), NTC (Wasser).
* Funktionen: Eco-Modus, Profile, Fast-Heat-Up, WLAN-Weboberfläche, AutoTune,
* Wartungserinnerung, Werkseinstellungen, Hostname-Konfiguration.
*
* Compiler- und Hardware-Umgebung:
* - ESP8266: Wemos D1 mini, 4MB
* - ESP32: D1 mini
************************************************************************************/
/************************************************************************************
* Includes & Bibliotheken
************************************************************************************/
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SH110X.h>
#include <EEPROM.h>
#include <PID_v1.h>
#include <PID_AutoTune_v0.h> // Modifizierte AutoTune-Funktion - Muss als .zip eingebunden werden
#include "max6675.h"
#include <math.h> // Für isnan()
#include <FS.h> // Dateisystem-Basis
#include <LittleFS.h> // LittleFS-Implementierung (für ESP32 ggf. explizit nötig)
#include <WiFiUdp.h>
#include <NTPClient.h>
#include <time.h> // Für Zeitfunktionen (struct tm, mktime, etc.)
#include <vector> // Erforderlich für std::vector
// Plattformspezifische Includes
#ifdef ESP32
#include <WiFi.h> // ESP32 WiFi Core
#include <WebServer.h> // ESP32 WebServer
#include <ESPmDNS.h> // ESP32 mDNS Implementation
#include <Update.h> // Für Firmware-Updates
#include <esp_adc_cal.h> // Für ADC-Kalibrierung
#include "UNIT_SCALES.h" // Für Waage / Brew-By-Weight
#define LED_BUILTIN 2 // Standard LED Pin für viele ESP32 Boards (optional, falls genutzt)
#else // ESP8266
#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <ESP8266mDNS.h>
// LED_BUILTIN ist für ESP8266 meistens vordefiniert (oft 2 oder DX Pin)
#ifndef LED_BUILTIN
#define LED_BUILTIN 2 // Fallback falls nicht vordefiniert
#endif
#endif
/************************************************************************************
* Webserver und zugehörige Objekte
************************************************************************************/
#ifdef ESP32
WebServer server(80); // ESP32 verwendet WebServer
#else // ESP8266
ESP8266WebServer server(80); // ESP8266 verwendet ESP8266WebServer
#endif
/************************************************************************************
* Hardware-Pinbelegung (Plattformabhängig)
************************************************************************************/
#ifdef ESP32
// --- ESP32 Pinout ---
#define SSR_WASSER_PIN 21 // SSR für Wasser-Heizung (GPIO21)
#define SSR_DAMPF_PIN 22 // SSR für Dampf-Heizung (GPIO22)
#define SHOT_TIMER_PIN 5 // Input für Shot-Timer (GPIO5)
#define OLED_SDA 17 // SDA-Leitung für OLED (GPIO17)
#define OLED_SCK 16 // SCK-Leitung für OLED (GPIO16)
#define ANALOG_NTC_PIN 36 // Analoger Eingang für NTC (ADC1_CH0 - VP) - Nur ADC1 Pins (GPIO 32-39) sind sicher im WiFi Betrieb!
// Pins für Pumpe und Ventil (Nur ESP32)
#define PUMP_PIN 4 // SSR für Pumpe (GPIO4)
#define VALVE_PIN 2 // SSR für Magnetventil (GPIO2)
// MAX6675 (Dampf-Temperatursensor) - SPI Pins für ESP32
#define MAX6675_SCK 23 // Serial Clock (GPIO23) - Standard HSPI SCK
#define MAX6675_CS 19 // Chip Select (GPIO19) - Frei wählbar
#define MAX6675_SO 18 // Serial Out (GPIO18) - Standard HSPI MISO (oft als 'SDO' oder 'SO' bezeichnet) \
// Mosi (SDI) vom MAX6675 wird nicht benötigt, da nur gelesen wird.
#else // ESP8266 \
// --- ESP8266 Pinout (Original) ---
#define SSR_WASSER_PIN D2 // SSR für Wasser-Heizung
#define SSR_DAMPF_PIN D1 // SSR für Dampf-Heizung
#define SHOT_TIMER_PIN D8 // Input für Shot-Timer
#define OLED_SDA D3 // SDA-Leitung für OLED
#define OLED_SCK D4 // SCK-Leitung für OLED
#define ANALOG_NTC_PIN A0 // Analoger Eingang für den NTC-Sensor
// MAX6675 (Dampf-Temperatursensor) - SPI Pins für ESP8266
#define MAX6675_SCK D7 // Serial Clock (GPIO13 / D7) - Kann auch D5 (GPIO14/SCK) sein
#define MAX6675_CS D6 // Chip Select (GPIO12 / D6) - Kann auch D8 (GPIO15/CS) sein
#define MAX6675_SO D5 // Serial Out (GPIO14 / D5) - Kann auch D6 (GPIO12/MISO) sein
#define A0 A0 // Explizite Definition für ESP8266 falls analogRead(A0) direkt verwendet wird
#endif
/************************************************************************************
* MAX6675 (Dampf-Temperatursensor)
************************************************************************************/
// Die Pins werden oben plattformabhängig definiert.
MAX6675 thermocouple(MAX6675_SCK, MAX6675_CS, MAX6675_SO);
/************************************************************************************
* Display-Objekt (Adafruit SH1106G)
************************************************************************************/
// Dieser Block muss vorhanden sein!
Adafruit_SH1106G display = Adafruit_SH1106G(128, 64, &Wire);
/************************************************************************************
* Firmware-Informationen
************************************************************************************/
String version = "3.0.0";
String versionHersteller = "Thomas M&uuml;ller";
String versionHerstellerMail =
"<a href='mailto:thomas@mueller.black' class='info-link'>thomas@mueller.black</a>";
String versionHerstellerWeb =
"<a href='https://raw-designs.de/' target='_blank' class='info-link'>https://raw-designs.de/</a> | "
"<a href='https://mueller.black/' target='_blank' class='info-link'>https://mueller.black/</a>";
// Demo-Modus: Hier können bestimmte Funktionen (WLAN-Anpassungen etc.) eingeschränkt werden.
// Entweder hier pauschal auf true setzen oder über IP-Adresse im Setup()-Teil auf true setzen lassen.
bool demoModus = false;
IPAddress demoModusIP(192, 168, 178, 67);
// Dieses Kennwort muss in einer Firmware-Binärdatei vorhanden sein, damit ein Update gültig ist.
static const char FIRMWARE_PASSWORD[] = "FWKennwort123";
static bool passwordFound = false;
static int passMatchPos = 0;
const int passLength = sizeof(FIRMWARE_PASSWORD) - 1;
/************************************************************************************
* Geräte-Infos
************************************************************************************/
char infoHersteller[50];
char infoModell[50];
char infoZusatz[50];
/************************************************************************************
* NTC-Sensor-Parameter (Wasser)
* - A0 wird verwendet, um die Temperatur über einen Spannungsteiler zu messen
************************************************************************************/
const double V_SUPPLY = 3.3; // Versorgungsspannung
const double ADC_REF = 3.3; // ADC-Referenz
const double R_FIXED = 3000.0; // Festwiderstand 3 kΩ
// Daten des NTC
const double NTC_T0 = 298.15; // Referenztemperatur in Kelvin (25°C)
const double NTC_R0 = 50000.0; // NTC-Widerstand bei 25°C
const double beta = 3950.0; // Beta-Wert
// Globale Variable für ADC-Kalibrierungsdaten
#ifdef ESP32
static esp_adc_cal_characteristics_t adc_chars;
#endif
/************************************************************************************
* M5Stack Waage Instanz
************************************************************************************/
#ifdef ESP32
UNIT_SCALES scales; // Instanz für die Waage (nur ESP32)
bool scaleConnected = false; // Flag, ob die Waage erfolgreich initialisiert wurde
#endif
/************************************************************************************
* Temperatur-Profil-Struktur (ERWEITERT)
* - Speichert nahezu alle konfigurierbaren Einstellungen pro Profil
************************************************************************************/
// VERSIONSNUMMER
const uint16_t CURRENT_PROFILE_VERSION = 2; // Versionsnummer für die Struktur
struct TemperatureProfile {
// --- Identifikation & Version ---
uint16_t profileVersion; // Zur Kompatibilitätsprüfung beim Laden
char profileName[31]; // Angezeigter Name (etwas mehr Platz + Nullterminator)
// --- PID Einstellungen (Seite: /) ---
double setpointWasser;
double setpointDampf;
double offsetWasser;
double offsetDampf;
double kpWasser;
double kiWasser;
double kdWasser;
double kpDampf;
double kiDampf;
double kdDampf;
bool boostWasserActive;
bool boostDampfActive;
bool preventHeatAboveSetpointWasser;
bool preventHeatAboveSetpointDampf;
unsigned long windowSizeWasser;
unsigned long windowSizeDampf;
double maxTempWasser;
double maxTempDampf;
// --- PID Tuning Parameter (Seite: /PID-Tuning) ---
double tuningStepWasser;
double tuningNoiseWasser;
double tuningStartValueWasser;
unsigned int tuningLookBackWasser;
double tuningStepDampf;
double tuningNoiseDampf;
double tuningStartValueDampf;
unsigned int tuningLookBackDampf;
// --- ECO Modus (Seite: /ECO) ---
int ecoModeMinutes;
int ecoModeTempWasser;
int ecoModeTempDampf;
bool dynamicEcoActive;
int dampfVerzoegerung; // Dampfverzögerung ist auf ECO-Seite
// --- Fast-Heat-Up (Seite: /Fast-Heat-Up) ---
bool fastHeatUpAktiv;
// --- Brew Control Einstellungen
bool brewByTimeEnabled;
float brewByTimeTargetSeconds; // Geändert
bool preInfusionEnabled;
float preInfusionDurationSeconds; // Geändert
float preInfusionPauseSeconds; // Geändert
bool brewByWeightEnabled;
float brewByWeightTargetGrams;
float brewByWeightOffsetGrams; // Offset zum früheren Stoppen
};
/************************************************************************************
* PID-Regler-Konfiguration & Zeitproportionale Steuerung
************************************************************************************/
// PID Input/Output/Setpoint Variablen
double SetpointDampf, InputDampf, OutputDampf;
double SetpointWasser, InputWasser, OutputWasser;
double OffsetDampf = 0.0, OffsetWasser = 0.0;
// PID-Konstanten (Regelungsparameter)
double KpDampf = 3.0, KiDampf = 0.1, KdDampf = 0.5;
double KpWasser = 5.0, KiWasser = 0.2, KdWasser = 1.0;
// Zeitproportionale Steuerung
unsigned long windowSizeWasser; // Fenstergröße für Wasser-SSR (in ms)
unsigned long windowSizeDampf; // Fenstergröße für Dampf-SSR (in ms)
unsigned long windowStartTimeWasser; // Startzeit des aktuellen Fensters für Wasser
unsigned long windowStartTimeDampf; // Startzeit des aktuellen Fensters für Dampf
// PID-Regler Instanzen
// WICHTIG: Die Output-Grenzen werden in setup() auf die Fenstergröße gesetzt!
PID pidDampf(&InputDampf, &OutputDampf, &SetpointDampf, KpDampf, KiDampf, KdDampf, DIRECT);
PID pidWasser(&InputWasser, &OutputWasser, &SetpointWasser, KpWasser, KiWasser, KdWasser, DIRECT);
// Variablen für Sicherheitsabschaltung
double maxTempWasser = 110.0; // Standardwert, wie bisher hardcoded
double maxTempDampf = 180.0; // Standardwert, wie bisher hardcoded
// --- NTP Client Konfiguration ---
WiFiUDP ntpUDP;
// Zeitverschiebung UTC+1 (Berlin/CET), automatische Sommerzeit (CEST) wird über TZ_INFO gehandhabt
// Offset 0 hier, da TZ_INFO die Regeln enthält. Update-Intervall 60000ms = 1 Minute.
NTPClient timeClient(ntpUDP, "pool.ntp.org", 0, 60000);
bool timeSynced = false;
// Zeitzonen-String für Deutschland (CET/CEST)
const char* TZ_INFO = "CET-1CEST,M3.5.0,M10.5.0/3"; // Germany
// --- Für Shot-Statistik ---
struct ShotStats {
unsigned long totalShotsLogged = 0;
unsigned long totalDurationMs = 0;
unsigned long shotsToday = 0;
unsigned long shotsThisWeek = 0;
unsigned long shotsThisMonth = 0;
double averageDurationSec = 0.0;
time_t firstShotTimestamp = 0;
time_t lastShotTimestamp = 0;
bool historyAvailable = false; // Flag, ob Daten gelesen werden konnten
unsigned long shotsYesterday = 0;
unsigned long shotsLastWeek = 0; // Kalenderwoche
unsigned long shotsLastMonth = 0; // Kalendermonat
double avgShotsPerDay = 0.0; // Durchschnitt seit erstem Log
};
/************************************************************************************
* Brew Control Variablen & Defaults
************************************************************************************/
#ifdef ESP32
enum PreInfusionState { PI_INACTIVE, PI_PRE_BREW, PI_PAUSE, PI_MAIN_BREW };
PreInfusionState currentPreInfusionState = PI_INACTIVE;
unsigned long preInfusionPhaseStartTime = 0;
#endif
// Globale Variablen für den aktuellen Zustand
bool brewByTimeEnabled = false;
float brewByTimeTargetSeconds = 30.0f; // Laufender Wert
bool preInfusionEnabled = false;
float preInfusionDurationSeconds = 5.0f; // Laufender Wert
float preInfusionPauseSeconds = 3.0f; // Laufender Wert
bool brewByWeightEnabled = false;
float brewByWeightTargetGrams = 36.0f;
float brewByWeightOffsetGrams = 0.0f; // Offset (z.B. 2.0 für Stopp 2g früher)
float currentWeightReading = 0.0f; // Aktueller Messwert der Waage
// Standard-Werte für Brew-Control
const bool defaultBrewByTimeEnabled = false;
const float defaultBrewByTimeTargetSeconds = 30.0f;
const bool defaultPreInfusionEnabled = false;
const float defaultPreInfusionDurationSeconds = 5.0f;
const float defaultPreInfusionPauseSeconds = 3.0f;
const bool defaultBrewByWeightEnabled = false;
const float defaultBrewByWeightTargetGrams = 36.0f;
const float defaultBrewByWeightOffsetGrams = 0.0f; // Standard kein Offset
/************************************************************************************
* Standardwerte und Default-Einstellungen
************************************************************************************/
double defaultSetpointDampf = 165.0;
double defaultSetpointWasser = 93.0;
double defaultOffsetDampf = 0.0;
double defaultOffsetWasser = 0.0;
double defaultKpDampf = 3.0;
double defaultKiDampf = 0.1;
double defaultKdDampf = 0.5;
double defaultKpWasser = 5.0;
double defaultKiWasser = 0.2;
double defaultKdWasser = 1.0;
int defaultEcoModeMinutes = 0;
int defaultEcoModeTempWasser = 60;
int defaultEcoModeTempDampf = 60;
String defaultInfoHersteller = "";
String defaultInfoModell = "";
String defaultInfoZusatz = "";
int defaultDampfVerzoegerung = 0;
int dampfVerzoegerung = 0;
int defaultMaintenanceInterval = 0;
String defaultHostname = "Dual-PID";
unsigned long defaultWindowSizeWasser = 2000;
unsigned long defaultWindowSizeDampf = 2000;
// MagicValue zum Erkennen bereits gespeicherter EEPROM-Daten
const char storageMagicValue[5] = "MGVE";
/************************************************************************************
* EEPROM Adressen für das Speichern/Laden von Werten
************************************************************************************/
const int EEPROM_SIZE = 1024; // Gesamtgröße des verwendeten EEPROM-Speichers
/************************************************************************************
* EEPROM Adressen für das Speichern/Laden von Werten
************************************************************************************/
const int EEPROM_ADDR_MAGICVALUE = 0; // 5 Bytes (char[5])
const int EEPROM_ADDR_SETPOINT_DAMPF = 5; // 8 Bytes (double)
const int EEPROM_ADDR_SETPOINT_WASSER = 13; // 8 Bytes
const int EEPROM_ADDR_OFFSET_DAMPF = 21; // 8 Bytes
const int EEPROM_ADDR_OFFSET_WASSER = 29; // 8 Bytes
const int EEPROM_ADDR_KP_DAMPF = 37; // 8 Bytes
const int EEPROM_ADDR_KI_DAMPF = 45; // 8 Bytes
const int EEPROM_ADDR_KD_DAMPF = 53; // 8 Bytes
const int EEPROM_ADDR_KP_WASSER = 61; // 8 Bytes
const int EEPROM_ADDR_KI_WASSER = 69; // 8 Bytes
const int EEPROM_ADDR_KD_WASSER = 77; // 8 Bytes
const int EEPROM_ADDR_ECOMODE_MINUTES = 85; // 4 Bytes (int)
const int EEPROM_ADDR_ECOMODE_TEMP_WASSER = 89; // 4 Bytes
const int EEPROM_ADDR_ECOMODE_TEMP_DAMPF = 93; // 4 Bytes
const int EEPROM_ADDR_INFO_HERSTELLER = 97; // 50 Bytes (char[50])
const int EEPROM_ADDR_INFO_MODELL = 147; // 50 Bytes
const int EEPROM_ADDR_INFO_ZUSATZ = 197; // 50 Bytes
const int EEPROM_ADDR_FASTHEATUP_DATA = 247; // 1 Byte (bool)
const int EEPROM_ADDR_RUNTIME = 248; // 4 Bytes (unsigned long)
const int EEPROM_ADDR_SHOTCOUNTER = 252; // 4 Bytes (unsigned long)
const int EEPROM_ADDR_DYNAMIC_ECO_MODE = 256; // 1 Byte (bool)
// --- AutoTune Parameter Wasser ---
const int EEPROM_ADDR_TUNING_STEP_WASSER = 357; // 8 Bytes (double)
const int EEPROM_ADDR_TUNING_NOISE_WASSER = 365; // 8 Bytes (double)
const int EEPROM_ADDR_TUNING_STARTVALUE_WASSER = 373; // 8 Bytes (double)
const int EEPROM_ADDR_TUNING_LOOKBACK_WASSER = 381; // 4 Bytes (unsigned int) -> Ende 384
// --- WiFi Konfiguration ---
const int EEPROM_ADDR_WIFI_CONFIG_MAGIC = 385; // 5 Bytes (char[5])
const int EEPROM_ADDR_WIFI_CONFIG_DATA = 390; // 113 Bytes (struct WiFiConfig) -> Ende ca. 503
// --- Sonstige Einstellungen ---
const int EEPROM_ADDR_STEAM_DELAY = 527; // 4 Bytes (int) -> Ende 530
// --- AutoTune Parameter Dampf ---
const int EEPROM_ADDR_TUNING_STEP_DAMPF = 531; // 8 Bytes (double)
const int EEPROM_ADDR_TUNING_NOISE_DAMPF = 539; // 8 Bytes (double)
const int EEPROM_ADDR_TUNING_STARTVALUE_DAMPF = 547; // 8 Bytes (double)
const int EEPROM_ADDR_TUNING_LOOKBACK_DAMPF = 555; // 4 Bytes (unsigned int) -> Ende 558
// --- Adressen für Übertemperatursicherung / Sicherheitsabschaltung ---
const int EEPROM_ADDR_MAX_TEMP_WASSER = 559; // 8 Bytes (double) -> Ende 566
const int EEPROM_ADDR_MAX_TEMP_DAMPF = 567; // 8 Bytes (double) -> Ende 574
// --- NEUE ADRESSEN ---
const int EEPROM_ADDR_MAINTENANCE_INTERVAL = 575; // 4 Bytes (int) -> Ende 578
const int EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER = 579; // 4 Bytes (int) -> Ende 578
const int EEPROM_ADDR_HOSTNAME = 583; // 33 Bytes (char[33]) -> Ende 611
// --- Boost-Funktion
const int EEPROM_ADDR_BOOST_WASSER_ACTIVE = 612; // 1 Byte (bool) -> Ende 612
const int EEPROM_ADDR_BOOST_DAMPF_ACTIVE = 613; // 1 Byte (bool) -> Ende 613
// --- Zeitfenster-Größen für Dampf und Wasser
const int EEPROM_ADDR_WINDOWSIZE_WASSER = 614; // 4 Bytes (unsigned long) -> Ende 617
const int EEPROM_ADDR_WINDOWSIZE_DAMPF = 618; // 4 Bytes (unsigned long) -> Ende 621
// --- Heiz-Begrenzung oberhalb Setpoints
const int EEPROM_ADDR_PREVENTHEAT_WASSER = 622; // 1 Byte (bool) -> Ende 622
const int EEPROM_ADDR_PREVENTHEAT_DAMPF = 623; // 1 Byte (bool) -> Ende 623
// --- BREW CONTROL
const int EEPROM_ADDR_BREWBYTIME_ENABLED = 624; // 1 Byte (bool) -> Ende 624
const int EEPROM_ADDR_BREWBYTIME_SECONDS = 625; // 4 Bytes (unsigned long) -> Ende 628
const int EEPROM_ADDR_PREINF_ENABLED = 629; // 1 Byte (bool) -> Ende 629
const int EEPROM_ADDR_PREINF_DUR_SEC = 630; // 4 Bytes (unsigned long) -> Ende 633
const int EEPROM_ADDR_PREINF_PAUSE_SEC = 634; // 4 Bytes (unsigned long) -> Ende 637
const int EEPROM_ADDR_BREWBYWEIGHT_ENABLED = 638; // bool (1 byte) -> Ende 638
const int EEPROM_ADDR_BREWBYWEIGHT_TARGET = 639; // float (4 bytes) -> Ende 642
const int EEPROM_ADDR_BREWBYWEIGHT_OFFSET = 643; // float (4 bytes) -> Ende 646
/************************************************************************************
* Eco-Mode Variablen
************************************************************************************/
unsigned long lastShotTime = 0; // Wann wurde zuletzt ein Shot beendet
int ecoModeMinutes = 0; // Zeit (Minuten) bis Eco-Modus
bool ecoModeAktiv = 0; // Flag, ob Eco läuft
int ecoModeTempWasser = 50; // Zieltemp Wasser im Eco
int ecoModeTempDampf = 50; // Zieltemp Dampf im Eco
bool dynamicEcoActive = false;
unsigned long ecoModeActivatedTime = 0;
/************************************************************************************
* Wartungsmodus Variable
************************************************************************************/
bool wartungsModusAktiv = false; // Ist der Reinigungs-/Wartungsmodus aktiv?
int wartungsModusTemp = 35; // Zieltemp Wasser und Dampf im Wartungsmodus
/************************************************************************************
* Fast-Heat-Up Variablen
************************************************************************************/
bool fastHeatUpAktiv = 0; // Ist Fast-Heat-Up aktiviert?
bool fastHeatUpHeating = 0; // Wird gerade hochgeheizt (Wasser >130)?
int fastHeatUpSetpoint = 130;
/************************************************************************************
* Sicherheits- und Fehlerstatus
************************************************************************************/
bool wasserSensorError = false; // Flag für Fehler am Wassertemperatursensor
bool dampfSensorError = false; // Flag für Fehler am Dampftemperatursensor
bool wasserSafetyShutdown = false;
bool dampfSafetyShutdown = false;
/************************************************************************************
* Auto-Tune-Einstellungen
************************************************************************************/
// Parameter für Wasser
double tuningStepWasser;
double tuningNoiseWasser;
double tuningStartValueWasser;
unsigned int tuningLookBackWasser;
const double defaultTuningStepWasser = 500.0;
const double defaultTuningNoiseWasser = 1.0;
const double defaultTuningStartValueWasser = 1000.0;
const unsigned int defaultTuningLookBackWasser = 60;
// Parameter für Dampf
double tuningStepDampf;
double tuningNoiseDampf;
double tuningStartValueDampf;
unsigned int tuningLookBackDampf;
const double defaultTuningStepDampf = 300.0;
const double defaultTuningNoiseDampf = 1.0;
const double defaultTuningStartValueDampf = 1000.0;
const unsigned int defaultTuningLookBackDampf = 30;
PID_ATune* autoTuneWasser;
PID_ATune* autoTuneDampf;
bool autoTuneWasserActive = false;
bool autoTuneDampfActive = false;
// Boost-Feature
bool boostWasserActive = false;
const bool defaultBoostWasserActive = false;
bool boostDampfActive = false;
const bool defaultBoostDampfActive = false;
// Heizen oberhalb Setpoint verhindern
bool preventHeatAboveSetpointWasser = false; // Standardmäßig AUS
const bool defaultPreventHeatAboveSetpointWasser = false;
bool preventHeatAboveSetpointDampf = false; // Standardmäßig AUS
const bool defaultPreventHeatAboveSetpointDampf = false;
/************************************************************************************
* Shot-Timer Variablen
************************************************************************************/
unsigned long shotStartTime = 0;
unsigned long shotEndTime = 0;
bool shotActive = false;
unsigned long lastShotDurationMillis = 0; // Speichert die Dauer des letzten Shots in ms
/************************************************************************************
* Shot-Zähler und Betriebszeit
************************************************************************************/
unsigned long shotCounter = 0; // Shots > 20 Sekunden
unsigned long totalRuntime = 0; // Gesamt-Betriebszeit in Sekunden
unsigned long lastRuntimeSave = 0; // Wann zuletzt gespeichert
const unsigned long initialRuntimeSaveDelay = 10 * 60 * 1000UL; // 10 Minuten
const unsigned long subsequentRuntimeSaveInterval = 5 * 60 * 1000UL; // 5 Minuten
bool initialRuntimeSaveDone = false;
/************************************************************************************
* Wartungserinnerung Variablen
************************************************************************************/
int maintenanceInterval = 0; // Intervall in Shots (0 = deaktiviert)
unsigned long maintenanceIntervalCounter = 0; // Zähler für Wartungsintervall
bool displayMaintenanceMessage = false; // Flag, ob Meldung aktiv angezeigt wird
unsigned long maintenanceMessageStartTime = 0; // Startzeit der Meldungsanzeige
/************************************************************************************
* Verzögerungen für Anzeigen
************************************************************************************/
int delayInit1 = 1500; // Display-Bild am Anfang
int delayInit2 = 3000; // Warte für Anzeige
unsigned long previousMillis = 0; // für Intervall z.B. 250ms
const unsigned long interval = 250; // PID-Berechnungsintervall
/************************************************************************************
* LittleFS
************************************************************************************/
File fsUploadFile; // Globale Variable für die Datei während des Uploads
/************************************************************************************
* Hostname Variable
************************************************************************************/
char hostname[33] = "Dual-PID"; // Max 32 Zeichen + Nullterminator
/************************************************************************************
* Profile
************************************************************************************/
String profileStatusMessage = ""; // Für Nachrichten auf der Profil-Seite
/************************************************************************************
* WiFi Signal Bitmap (Display-Anzeige)
************************************************************************************/
static const unsigned char PROGMEM wifiSymbol[] = {
0x1f, 0xc0, 0x20, 0x20, 0x4f, 0x90, 0x90, 0x48,
0x27, 0x20, 0x08, 0x80, 0x02, 0x00
};
/************************************************************************************
* Gemeinsame CSS-Styles (PROGMEM) - Unverändert
************************************************************************************/
static const char commonStyle[] PROGMEM = R"rawliteral(
<style>
/* Farben und Grundvariablen */
:root {
--primary-bg: #000000; /* Schwarz */
--secondary-bg: #1a1a1a; /* Dunkles Grau/Schwarz */
--accent-color: #d89904; /* Gold-/Kupferton */
--text-color: #ffffff; /* Weiß für Schrift */
--card-bg-opacity: 0.15; /* Opazität für Form-Karten-Hintergrund */
}
/* Seitenhintergrund: Farbverlauf */
body {
margin: 0;
padding: 0;
font-family: "Segoe UI", Tahoma, Arial, sans-serif;
background: linear-gradient(120deg, #000000 0%, #1a1a1a 100%);
background-repeat: no-repeat;
background-attachment: fixed;
background-size: cover;
min-height: 100vh;
color: #ffffff;
}
/************************************************************************
* Links / Anker-Tags
************************************************************************/
a,
a:visited {
text-decoration: none;
transition: color 0.2s ease, background-color 0.2s ease;
}
a:hover,
a:focus {
text-decoration: none;
}
.info-link,
.info-link:visited {
color: #d89904; /* Oder var(--accent-color), falls das die Akzentfarbe ist */
text-decoration: none; /* Unterstreichung entfernen (wie schon bei 'a') */
/* !important ist hier wahrscheinlich nicht mehr nötig, da die Klasse spezifischer ist als 'a' */
}
.info-link:hover {
/* z.B. Helligkeit ändern oder doch unterstreichen */
filter: brightness(1.2);
/* text-decoration: underline; */
}
/************************************************************************
* Navigation (Sticky Top Bar)
************************************************************************/
nav {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(6px);
padding: 10px 0;
text-align: center;
position: sticky;
top: 0;
/* <b style='color: #FFCC00;'>WICHTIG:</b>: z-index erhöhen, damit das X-Icon klickbar bleibt */
z-index: 1000;
position: relative; /* Basis für absolute Positionierung von Kindern */
}
/* Wrapper für die Navigationslinks */
.nav-links {
display: inline;
}
/* Desktop Navigationslinks */
.nav-links a {
color: #fff;
font-weight: 500;
text-decoration: none;
margin: 0 10px;
padding: 10px 20px;
border-radius: 8px;
transition: background 0.3s ease, color 0.3s ease;
display: inline-block;
}
/* Desktop Hover/Focus/Active States */
.nav-links a:hover,
.nav-links a:focus,
.nav-links a:active,
.nav-links a.active {
background: var(--accent-color);
color: #000000;
}
/* Hamburger-Icon / Close-Icon Styling */
.hamburger {
display: none;
font-size: 30px; /* Etwas größer für bessere Klickbarkeit */
font-weight: bold; /* Macht das 'X' oft etwas klarer */
line-height: 1;
position: absolute;
/* Anpassung - weiter nach unten verschoben */
top: calc(50% + 25px); /* Passe '25px' nach Bedarf an */
transform: translateY(-50%);
right: 20px;
color: var(--text-color);
cursor: pointer;
padding: 5px; /* Etwas Padding für größere Klickfläche */
z-index: 1001; /* Sicherstellen, dass der Button über dem Overlay liegt */
}
.hamburger:hover {
color: #ddd;
}
/************************************************************************
* Formulare (Card-Design, Glas-Effekt)
************************************************************************/
form {
background: rgba(255, 255, 255, var(--card-bg-opacity));
backdrop-filter: blur(8px);
border-radius: 12px;
padding: 20px;
margin: 20px auto;
width: 90%;
max-width: 600px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
color: var(--text-color);
}
/* Überschriften */
h1 {
text-align: center;
margin-top: 20px;
font-size: 2rem;
}
h3 {
margin-top: 0;
font-weight: 600;
padding-bottom: 3px;
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
}
/* Labels */
label {
display: block;
margin: 10px 0 5px;
font-weight: 600;
}
/************************************************************************
* Eingabefelder (Inputs, Select, Textarea) MIT FOKUS-EFFEKT
************************************************************************/
input[type="text"],
input[type="password"],
input[type="number"],
select,
textarea {
width: auto;
min-width: 200px;
max-width: 300px;
display: block;
margin-bottom: 5px;
background: rgba(0, 0, 0, 0.4);
color: var(--text-color);
border: 1px solid #555; /* Standard-Rand */
border-radius: 8px;
padding: 10px;
box-shadow: inset 0 4px 8px rgba(0, 0, 0, 0.2);
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
/* Fokus-Effekt FÜR ALLE INPUTS/SELECT/TEXTAREA */
input[type="text"]:focus,
input[type="password"]:focus,
input[type="number"]:focus,
select:focus,
textarea:focus {
outline: none;
background: rgba(0, 0, 0, 0.6);
/* Orangener Rand und Schein bei Fokus */
border-color: var(--accent-color, #d89904);
box-shadow: 0 0 8px rgba(216, 153, 4, 0.3);
}
/* --- CSS zum Ausblenden der Pfeile bei number Inputs --- */
input[type=number]::-webkit-outer-spin-button,
input[type=number]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type=number] {
-moz-appearance: textfield;
}
/************************************************************************
* Buttons
************************************************************************/
input[type="submit"],
button {
background: var(--accent-color);
color: #000000;
border: none;
border-radius: 30px;
padding: 10px 20px;
margin-top: 12px;
width: auto;
min-width: 100px;
max-width: 200px;
cursor: pointer;
font-weight: 600;
box-shadow: 0 4px 12px rgba(216, 153, 4, 0.3);
transition: all 0.3s ease;
}
input[type="submit"]:hover,
button:hover {
opacity: 0.85;
transform: translateY(-2px);
}
/* Style für Danger-Button (Werkseinstellungen) */
button.danger {
background: #dc3545; /* Roter Hintergrund */
color: #ffffff; /* Weiße Schrift */
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3); /* Roter Schatten */
}
button.danger:hover {
background: #c82333; /* Dunkleres Rot bei Hover */
opacity: 1; /* Opazität zurücksetzen, da Hintergrund dunkler wird */
transform: translateY(-2px);
}
/************************************************************************
* Fehlermeldungen / Sensorfehler
************************************************************************/
.sensor-error-message {
color: #FFCC00; /* Auffälliges Gelb/Gold für Warnungen im Dark Mode */
text-align: center;
margin-top: 5px; /* Weniger Abstand nach oben */
margin-bottom: 15px; /* Abstand zum Chart-Container */
padding: 8px 10px;
background-color: rgba(255, 204, 0, 0.1); /* Sehr dezenter gelber Hintergrund */
border: 1px solid rgba(255, 204, 0, 0.2); /* Sehr dezenter gelber Rand */
border-radius: 8px;
font-weight: 500; /* Etwas fetter als normal */
font-size: 0.9em;
max-width: 600px; /* Verhindert zu breite Box auf großen Screens */
margin-left: auto; /* Zentriert die Box selbst */
margin-right: auto;
}
/************************************************************************
* Responsive Design - MOBILE OVERLAY HIER IMPLEMENTIERT
************************************************************************/
@media (max-width: 768px) {
.hamburger {
display: block; /* Hamburger anzeigen */
}
/* Der Link-Wrapper wird zum absolut positionierten Overlay */
.nav-links {
display: none;
position: absolute;
top: 100%; /* Unter der Nav-Leiste */
left: 0;
width: 100%;
background: rgba(26, 26, 26, 0.95);
backdrop-filter: blur(8px);
padding: 10px 0;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
border-top: 1px solid rgba(255, 255, 255, 0.1);
/* <b style='color: #FFCC00;'>WICHTIG:</b>: z-index niedriger als der Hamburger/Close-Button */
z-index: 1000;
}
/* Wenn Menü aktiv, Wrapper sichtbar */
nav.active .nav-links {
display: block;
}
/* Styling der Links im mobilen Overlay */
nav .nav-links a {
display: block;
margin: 0;
padding: 15px 25px;
text-align: center; /* Links zentrieren */
border-radius: 0;
color: #eee;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
nav .nav-links a:last-child {
border-bottom: none;
}
/* Hover/Active für mobile Links */
nav .nav-links a:hover,
nav .nav-links a:active,
nav .nav-links a.active {
background: var(--accent-color);
color: #000000;
}
} /* Ende @media (max-width: 768px) */
@media (max-width: 480px) {
h1 {
font-size: 1.4rem;
}
/* Buttons auf 100% Breite */
input[type="submit"],
button {
width: 100%;
max-width: none;
}
/* Eingabefelder evtl. auch anpassen */
input[type="text"],
input[type="password"],
select,
textarea {
max-width: 90%;
min-width: 150px;
}
} /* Ende @media (max-width: 480px) */
</style>
<script>
// Angepasste JavaScript-Funktion für Icon-Wechsel
function toggleMenu() {
var nav = document.querySelector('nav');
var hamburger = document.querySelector('.hamburger');
nav.classList.toggle('active');
// Prüfen, ob das Menü JETZT aktiv ist und Icon entsprechend ändern
if (nav.classList.contains('active')) {
hamburger.innerHTML = '&times;'; // Ändert zu einem 'X' (Schließen-Symbol)
} else {
hamburger.innerHTML = '&#9776;'; // Ändert zurück zum Hamburger-Symbol
}
}
// OPTIONAL: Schließen des Menüs bei Klick außerhalb (Verbessert Usability)
document.addEventListener('click', function(event) {
var nav = document.querySelector('nav');
var hamburger = document.querySelector('.hamburger');
var navLinks = document.querySelector('.nav-links');
// Prüfen ob Menü überhaupt offen ist
if (nav.classList.contains('active')) {
// Prüfen ob Klick außerhalb des Menüs UND außerhalb des Buttons war
var isClickInsideNavLinks = navLinks.contains(event.target);
var isClickOnHamburger = hamburger.contains(event.target);
if (!isClickInsideNavLinks && !isClickOnHamburger) {
toggleMenu(); // Schließe das Menü
}
}
});
// Bestätigungsdialog für Werkseinstellungen
function confirmResetDefaults() {
return confirm("Sicher, dass Sie alle Einstellungen auf die Werkseinstellungen geladen werden sollen?");
}
</script>
)rawliteral";
/************************************************************************************
* Gemeinsame Navigation (PROGMEM)
************************************************************************************/
#ifdef ESP32
static const char commonNav[] PROGMEM = R"rawliteral(
<nav>
<span class="hamburger" onclick="toggleMenu()">&#9776;</span>
<div class="nav-links">
<a href="/">PID-Einstellung</a>
<a href="/PID-Tuning">PID-Tuning</a>
<a href="/Profile">Profile</a>
<a href="/Brew-Control">Brew Control</a>
<a href="/Chart">Chart</a>
<a href="/Fast-Heat-Up">Fast-Heat-Up</a>
<a href="/ECO">Eco</a>
<a href="/Info">Info</a>
<a href="/Service">Service</a>
<a href="/Netzwerk">WiFi</a>
<a href="/Firmware">Firmware</a>
</div>
</nav>
)rawliteral";
#else
static const char commonNav[] PROGMEM = R"rawliteral(
<nav>
<span class="hamburger" onclick="toggleMenu()">&#9776;</span>
<div class="nav-links">
<a href="/">PID-Einstellung</a>
<a href="/PID-Tuning">PID-Tuning</a>
<a href="/Profile">Profile</a>
<a href="/Chart">Chart</a>
<a href="/Fast-Heat-Up">Fast-Heat-Up</a>
<a href="/ECO">Eco</a>
<a href="/Info">Info</a>
<a href="/Service">Service</a>
<a href="/Netzwerk">WiFi</a>
<a href="/Firmware">Firmware</a>
</div>
</nav>
)rawliteral";
#endif
/************************************************************************************
* WiFi-Konfigurationsstrukturen und Funktionen - Hostname hinzugefügt
************************************************************************************/
struct WiFiConfig {
char ssid[32] = "";
char password[64] = "";
bool useStaticIP = false;
IPAddress staticIP = IPAddress(192, 168, 4, 1);
IPAddress gateway = IPAddress(192, 168, 4, 1);
IPAddress subnet = IPAddress(255, 255, 255, 0);
IPAddress dns = IPAddress(8, 8, 8, 8);
// Hostname in WiFi-Config (obwohl global gespeichert, hier für Vollständigkeit)
// Wird nicht direkt hier gespeichert, sondern über globale Variable `hostname`
};
// Liest die WLAN-Konfiguration aus dem EEPROM
// Gibt true zurück, wenn eine gültige Konfiguration (SSID) geladen wurde
bool loadWiFiConfig(WiFiConfig& config) {
char magic[5] = { 0 };
EEPROM.get(EEPROM_ADDR_WIFI_CONFIG_MAGIC, magic);
// Lade Hostname (immer, auch wenn keine WiFi-Config da ist)
EEPROM.get(EEPROM_ADDR_HOSTNAME, hostname);
hostname[sizeof(hostname) - 1] = '\0'; // Nullterminierung sicherstellen
// Wenn Hostname leer ist (z.B. nach Reset), Default setzen
if (strlen(hostname) == 0) {
strncpy(hostname, defaultHostname.c_str(), sizeof(hostname) - 1);
hostname[sizeof(hostname) - 1] = '\0';
EEPROM.put(EEPROM_ADDR_HOSTNAME, hostname); // Default im EEPROM speichern
EEPROM.commit();
}
// Falls MagicValue nicht übereinstimmt, Standardwerte setzen
if (strncmp(magic, storageMagicValue, 4) != 0) {
strcpy(config.ssid, "");
strcpy(config.password, "");
config.useStaticIP = false;
config.staticIP = IPAddress(192, 168, 4, 1);
config.gateway = IPAddress(192, 168, 4, 1);
config.subnet = IPAddress(255, 255, 255, 0);
config.dns = IPAddress(8, 8, 8, 8);
return false; // Keine gültige Konfiguration gefunden
}
// Laden der gespeicherten Daten
EEPROM.get(EEPROM_ADDR_WIFI_CONFIG_DATA, config);
// Nullterminierung sicherstellen (wichtig!)
config.ssid[sizeof(config.ssid) - 1] = '\0';
config.password[sizeof(config.password) - 1] = '\0';
// Prüfen, ob SSID leer ist (kann nach AP-Modus-Erzwingung passieren)
return (strlen(config.ssid) > 0); // True, wenn SSID nicht leer ist
}
// Schreibt die WLAN-Konfiguration ins EEPROM
void saveWiFiConfig(const WiFiConfig& config) {
EEPROM.put(EEPROM_ADDR_WIFI_CONFIG_MAGIC, storageMagicValue);
EEPROM.put(EEPROM_ADDR_WIFI_CONFIG_DATA, config);
// Hostname wird separat gespeichert (in handleSaveWiFiConfig)
EEPROM.commit();
}
// Aktiviert den Access Point Modus und zeigt IP an
void startAPMode() {
WiFi.hostname(hostname);
WiFi.softAP("Dual-PID", "QuickMill"); // Setzt SSID "Dual-PID" und Passwort "QuickMill"
Serial.print(F("AP-Mode gestartet. SSID: Dual-PID, Passwort: QuickMill, IP: "));
Serial.println(WiFi.softAPIP());
}
/************************************************************************************
* Webserver-Handler: WLAN-Konfigurations-Seite - Hostname hinzugefügt
************************************************************************************/
// === PROGMEM Teile definieren (Neue Reihenfolge: IP, Gateway, DNS, Subnet) ===
static const char wifiPageChunk1[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html><head><title>WLAN-Konfiguration</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
)rawliteral";
static const char wifiPageChunk2[] PROGMEM = R"rawliteral(</head>)rawliteral"; // Head Ende
// Teil 3a: Body bis VOR Signalstärke-Wert
static const char wifiPageChunk3_a[] PROGMEM = R"rawliteral(
<body><h1>WLAN-Konfiguration</h1><form action='/saveWiFiConfig' method='POST'><h3>Netzwerk</h3><div style='margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid rgba(255,255,255,0.1);'><label style='margin-right: 5px;'>Aktuelle Signalst&auml;rke:</label><span>)rawliteral"; // Endet vor dem Wert
// Teil 3b: NACH Signalstärke-Wert bis VOR Hostname Value
static const char wifiPageChunk3_b[] PROGMEM = R"rawliteral(</span></div><label for='hostname'>Hostname:</label><input type='text' id='hostname' name='hostname' value=')rawliteral"; // Endet mit value='
// Teil 3c: NACH Hostname Value bis VOR SSID Value
static const char wifiPageChunk3_c[] PROGMEM = R"rawliteral('' required><label for='ssid'>SSID:</label><input type='text' id='ssid' name='ssid' value=')rawliteral"; // Beginnt mit ', endet mit value='
// Teil 4: NACH SSID Value bis VOR Passwort Value
static const char wifiPageChunk4[] PROGMEM = R"rawliteral('' required><label for='password'>Passwort:</label><input type='password' id='password' name='password' value=')rawliteral"; // Type=password, Beginnt mit ', endet mit value='
// Teil 5: NACH Passwort Value bis staticFields div Start (formatiert)
static const char wifiPageChunk5_format[] PROGMEM = R"rawliteral(''><h3>IP-Einstellungen</h3><label><input type='radio' name='ipType' value='dhcp' %s> DHCP</label><label><input type='radio' name='ipType' value='static' %s> Statische IP</label><div id='staticFields' style='display:%s'>)rawliteral"; // Beginnt mit '', endet mit display:%s'>
// Teil 6: IP-Label und Input Start
static const char wifiPageChunk6_ip[] PROGMEM = R"rawliteral(<label for='ip'>IP-Adresse:</label><input type='text' id='ip' name='ip' value=')rawliteral"; // Beginnt normal, endet mit value='
// Teil 7: NACH IP Value bis Gateway Value
static const char wifiPageChunk7_gateway[] PROGMEM = R"rawliteral('><label for='gateway'>Gateway:</label><input type='text' id='gateway' name='gateway' value=')rawliteral"; // Beginnt mit ', endet mit value='
// Teil 8 NEU: NACH Gateway Value bis DNS Value
static const char wifiPageChunk8_dns[] PROGMEM = R"rawliteral('><label for='dns'>DNS-Server:</label><input type='text' id='dns' name='dns' value=')rawliteral"; // Beginnt mit '>, endet mit value='
// Teil 9 NEU: NACH DNS Value bis Subnet Value
static const char wifiPageChunk9_subnet[] PROGMEM = R"rawliteral('><label for='subnet'>Subnetzmaske:</label><input type='text' id='subnet' name='subnet' value=')rawliteral"; // Beginnt mit '>, endet mit value='
// Teil 10 NEU (Rest): NACH Subnet Value (schließt Input), schließendes Div für staticFields, Submit-Button, AP-Form, Script, Ende body/html
static const char wifiPageChunk10_rest[] PROGMEM = R"rawliteral(''></div><input type='submit' value='Speichern'></form><form action='/forceAPMode' method='POST' style='margin-top: 20px;'><h3>AP-Modus</h3>Verwendung im Access Point-Modus (AP).<br><br>Netzwerk-Name: Dual-PID<br>Passwort: QuickMill<br>IP-Adresse: 192.168.4.1<br><input type='submit' value='AP-Modus verwenden'></form><script>document.querySelectorAll('input[name="ipType"]').forEach(radio => {radio.addEventListener('change', () => {document.getElementById('staticFields').style.display = radio.value === 'static' ? 'block' : 'none';});});document.addEventListener('DOMContentLoaded', () => { const initialIpType = document.querySelector('input[name="ipType"]:checked').value; document.getElementById('staticFields').style.display = initialIpType === 'static' ? 'block' : 'none'; });</script></body></html>)rawliteral"; // Beginnt mit '>, dann </div>, Rest bis Ende
// --- Hilfsfunktion zur Umrechnung von RSSI in Prozent (Approximation) ---
int calculateSignalPercentage(int rssi) {
int quality = 0;
if (rssi <= -100) {
quality = 0;
} else if (rssi >= -50) { // Guter Empfang
quality = 100;
} else {
// Lineare Skalierung zwischen -100dBm (0%) und -50dBm (100%)
quality = 2 * (rssi + 100);
}
return quality;
}
void handleWiFiConfig() {
// EEPROM Lese-Logik (inkl. Hostname aus globaler Variable)
WiFiConfig currentConfig;
char magic[5] = { 0 };
EEPROM.get(EEPROM_ADDR_WIFI_CONFIG_MAGIC, magic);
bool configValid = (strncmp(magic, storageMagicValue, 4) == 0);
if (configValid) {
EEPROM.get(EEPROM_ADDR_WIFI_CONFIG_DATA, currentConfig);
// Nullterminierung sicherstellen
currentConfig.ssid[sizeof(currentConfig.ssid) - 1] = '\0';
currentConfig.password[sizeof(currentConfig.password) - 1] = '\0';
} else {
// Standardwerte, falls keine gültige Konfig im EEPROM
strcpy(currentConfig.ssid, "");
strcpy(currentConfig.password, "");
currentConfig.useStaticIP = false;
currentConfig.staticIP = IPAddress(192, 168, 4, 1);
currentConfig.gateway = IPAddress(192, 168, 4, 1);
currentConfig.subnet = IPAddress(255, 255, 255, 0);
currentConfig.dns = IPAddress(8, 8, 8, 8); // Default DNS
}
// Hostname ist in 'hostname' verfügbar
// --- Signalstärke ermitteln ---
char signalBuffer[50]; // Puffer für den Anzeigetext
// ... (Code für Signalstärke bleibt unverändert) ...
if (WiFi.getMode() == WIFI_STA && WiFi.status() == WL_CONNECTED) {
long rssi = WiFi.RSSI();
int percentage = calculateSignalPercentage(rssi);
snprintf(signalBuffer, sizeof(signalBuffer), "%d dBm (%d%%)", (int)rssi, percentage);
} else if (WiFi.getMode() == WIFI_AP) {
strcpy(signalBuffer, "AP-Modus (keine Messung)");
} else {
strcpy(signalBuffer, "Nicht verbunden");
}
// Temporäre Puffer für Senden
char formatBuffer[256]; // Für snprintf_P
char ipBuffer[16]; // Für IP-Adressen-Formatierung
// ---- Beginn des Sendens ----
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, F("text/html"), ""); // Header senden
// Chunk 1: HTML-Start
server.sendContent_P(wifiPageChunk1);
// Chunk 1.5: Common Style (global)
server.sendContent_P(commonStyle);
// Chunk 2: Head Ende
server.sendContent_P(wifiPageChunk2);
// Chunk 2.5: Common Nav (global)
server.sendContent_P(commonNav);
// --- Body und Netzwerk-Sektion ---
server.sendContent_P(wifiPageChunk3_a); // Body bis VOR Signalwert
server.sendContent(signalBuffer); // Signalwert senden
server.sendContent_P(wifiPageChunk3_b); // NACH Signalwert bis VOR Hostname Value
server.sendContent(hostname); // Aktuellen Hostnamen senden
server.sendContent_P(wifiPageChunk3_c); // NACH Hostname bis VOR SSID Value
// SSID einfügen
if (strlen(currentConfig.ssid) == 0) {
server.sendContent(F("SSID eingeben"));
} else {
server.sendContent(currentConfig.ssid);
}
server.sendContent_P(wifiPageChunk4); // Nach SSID bis vor Passwort
// Passwort einfügen
if (!demoModus) {
if (strlen(currentConfig.password) == 0) {
server.sendContent(F("Passwort eingeben"));
} else {
server.sendContent(currentConfig.password);
}
} else {
server.sendContent(F("********"));
}
// --- IP-Einstellungen ---
snprintf_P(formatBuffer, sizeof(formatBuffer), wifiPageChunk5_format,
currentConfig.useStaticIP ? "" : "checked",
currentConfig.useStaticIP ? "checked" : "",
currentConfig.useStaticIP ? "block" : "none");
server.sendContent(formatBuffer); // Sendet formatierten Chunk 5
// --- Statische IP Felder (NEUE REIHENFOLGE) ---
// 1. IP-Adresse
server.sendContent_P(wifiPageChunk6_ip); // IP-Label und Input Start
snprintf(ipBuffer, sizeof(ipBuffer), "%d.%d.%d.%d", currentConfig.staticIP[0], currentConfig.staticIP[1], currentConfig.staticIP[2], currentConfig.staticIP[3]);
server.sendContent(ipBuffer); // IP-Wert senden
// 2. Gateway
server.sendContent_P(wifiPageChunk7_gateway); // Gateway-Label und Input Start
snprintf(ipBuffer, sizeof(ipBuffer), "%d.%d.%d.%d", currentConfig.gateway[0], currentConfig.gateway[1], currentConfig.gateway[2], currentConfig.gateway[3]);
server.sendContent(ipBuffer); // Gateway-Wert senden
// 3. DNS-Server
server.sendContent_P(wifiPageChunk8_dns); // DNS-Label und Input Start
snprintf(ipBuffer, sizeof(ipBuffer), "%d.%d.%d.%d", currentConfig.dns[0], currentConfig.dns[1], currentConfig.dns[2], currentConfig.dns[3]);
server.sendContent(ipBuffer); // DNS-Wert senden
// 4. Subnetzmaske
server.sendContent_P(wifiPageChunk9_subnet); // Subnet-Label und Input Start
snprintf(ipBuffer, sizeof(ipBuffer), "%d.%d.%d.%d", currentConfig.subnet[0], currentConfig.subnet[1], currentConfig.subnet[2], currentConfig.subnet[3]);
server.sendContent(ipBuffer); // Subnet-Wert senden
// Rest der Seite (schließendes '>' für Subnet-Input, schließendes Div, Submit, AP-Form, Script, /body, /html)
server.sendContent_P(wifiPageChunk10_rest);
// Letzter leerer Chunk zum Abschließen der Antwort
server.sendContent(F(""));
// ---- Ende des Sendens ----
}
/************************************************************************************
* Handler, um den AP-Modus per Knopfdruck zu erzwingen - Heap-Optimiert
************************************************************************************/
// Die PROGMEM-Definitionen für die Bestätigungsseite bleiben bestehen
static const char forceAPHtml[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<title>AP-Modus aktivieren</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
)rawliteral";
static const char forceAPHtml2[] PROGMEM = R"rawliteral(
</head>
)rawliteral";
static const char forceAPHtml3[] PROGMEM = R"rawliteral(
<body>
<h1>AP-Modus wird aktiviert...</h1>
<form><p>Die Einstellungen wurden gespeichert. Neustart im AP-Modus ...</p></form>
</body>
</html>
)rawliteral";
// --- Überarbeitete Funktion handleForceAPMode ---
void handleForceAPMode() {
// --- Logik zum Speichern der leeren Konfig (BLEIBT UNVERÄNDERT) ---
// Leer-Konfiguration speichern, damit beim nächsten Start AP aktiv wird
WiFiConfig emptyConfig;
// Wichtig: Hostname NICHT entfernen!
if (!demoModus) { saveWiFiConfig(emptyConfig); }
// --- Senden der Bestätigungsseite (Heap-Optimiert) ---
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, F("text/html"), ""); // Header senden
server.sendContent_P(forceAPHtml); // Chunk 1 (Head Start)
server.sendContent_P(commonStyle); // Common CSS
server.sendContent_P(forceAPHtml2); // Chunk 2 (Head End)
server.sendContent_P(commonNav); // Common Navigation
server.sendContent_P(forceAPHtml3); // Chunk 3 (Body)
server.sendContent(F("")); // Abschluss-Chunk
// --- Logik nach dem Senden (BLEIBT UNVERÄNDERT) ---
delay(1000);
ESP.restart();
}
/************************************************************************************
* Handler zum Speichern der WLAN-Konfiguration - Heap-Optimiert (Bestätigungsseite)
************************************************************************************/
// Die PROGMEM-Definitionen für die Bestätigungsseite bleiben bestehen
static const char saveConfigHtml[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<title>Einstellungen gespeichert</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
)rawliteral";
static const char saveConfigHtml2[] PROGMEM = R"rawliteral(
</head>
)rawliteral";
static const char saveConfigHtml3[] PROGMEM = R"rawliteral(
<body>
<h1>Einstellungen gespeichert</h1>
<form><p>Die Einstellungen wurden gespeichert. Neustart ...</p></form>
</body>
</html>
)rawliteral";
// --- Funktion handleSaveWiFiConfig ---
void handleSaveWiFiConfig() {
// --- Daten validieren und auslesen ---
if (!server.hasArg(F("ssid"))) // Grundlegende Prüfung auf SSID
{
server.send(400, F("text/plain"), F("Fehler: SSID fehlt!")); // 400 Bad Request
return;
}
// Optional: Weitere Prüfungen (z.B. Passwortlänge, IP-Format) könnten hier hinzugefügt werden.
// --- Konfigurationsstruktur füllen ---
WiFiConfig newConfig;
// SSID und Passwort kopieren (mit Längenbegrenzung und Nullterminierung)
strncpy(newConfig.ssid, server.arg(F("ssid")).c_str(), sizeof(newConfig.ssid) - 1);
newConfig.ssid[sizeof(newConfig.ssid) - 1] = '\0';
// Nur das Passwort lesen, wenn es tatsächlich gesendet wurde (könnte bei manchen Browsern fehlen, wenn leer)
if (server.hasArg(F("password"))) {
strncpy(newConfig.password, server.arg(F("password")).c_str(), sizeof(newConfig.password) - 1);
newConfig.password[sizeof(newConfig.password) - 1] = '\0';
} else {
// Falls Passwort nicht gesendet wurde (z.B. leeres Feld), explizit leeren
newConfig.password[0] = '\0';
}
// IP-Typ bestimmen
newConfig.useStaticIP = (server.arg(F("ipType")) == "static");
// Statische IP-Einstellungen parsen, falls ausgewählt
if (newConfig.useStaticIP) {
if (!server.hasArg(F("ip")) || !server.hasArg(F("gateway")) || !server.hasArg(F("subnet")) || !server.hasArg(F("dns"))) {
server.send(400, F("text/plain"), F("Fehler: Fehlende Felder für statische IP!"));
return;
}
// Versuche, die IP-Adressen zu parsen
bool ipOK = newConfig.staticIP.fromString(server.arg(F("ip")));
bool gwOK = newConfig.gateway.fromString(server.arg(F("gateway")));
bool subOK = newConfig.subnet.fromString(server.arg(F("subnet")));
bool dnsOK = newConfig.dns.fromString(server.arg(F("dns")));
// Optional: Prüfen, ob das Parsen erfolgreich war
if (!ipOK || !gwOK || !subOK || !dnsOK) {
Serial.println(F("WARNUNG: Mindestens ein Feld für statische IP hatte ein ungültiges Format."));
// Entscheiden, wie damit umgegangen wird:
// Option A: Fehler senden
// server.send(400, F("text/plain"), F("Fehler: Ungültiges Format für statische IP-Felder!"));
// return;
// Option B: Mit Standardwerten speichern (wie im Code unten, da fromString oft 0.0.0.0 liefert bei Fehler)
// Oder explizit auf Default setzen:
// if (!ipOK) newConfig.staticIP = IPAddress(192, 168, 4, 1); // Beispiel
// if (!dnsOK) newConfig.dns = IPAddress(8, 8, 8, 8); // Beispiel
}
Serial.printf("Speichere statische IP: %s, GW: %s, Sub: %s, DNS: %s\n",
newConfig.staticIP.toString().c_str(),
newConfig.gateway.toString().c_str(),
newConfig.subnet.toString().c_str(),
newConfig.dns.toString().c_str());
} else {
// Wenn DHCP: Die IP-Felder in der Struktur behalten ihre Standardwerte (oder was auch immer vorher drin war),
// aber sie werden bei der Verbindung nicht verwendet. DNS wird vom DHCP-Server bezogen.
Serial.println(F("Speichere Konfiguration für DHCP."));
}
// Hostname speichern (separat, da nicht Teil der WiFiConfig im EEPROM)
if (server.hasArg(F("hostname"))) {
strncpy(hostname, server.arg(F("hostname")).c_str(), sizeof(hostname) - 1);
hostname[sizeof(hostname) - 1] = '\0'; // Sicherstellen der Nullterminierung
// Validierung (optional, z.B. auf erlaubte Zeichen prüfen oder Länge)
if (strlen(hostname) == 0) { // Wenn leer, Default verwenden
strncpy(hostname, defaultHostname.c_str(), sizeof(hostname) - 1);
hostname[sizeof(hostname) - 1] = '\0';
}
EEPROM.put(EEPROM_ADDR_HOSTNAME, hostname); // Hostname direkt ins EEPROM schreiben
Serial.printf("Speichere Hostname: %s\n", hostname);
}
// --- Speichern (wenn nicht im Demo-Modus) ---
if (!demoModus) {
Serial.println("Schreibe WiFi-Konfiguration und Hostname ins EEPROM...");
saveWiFiConfig(newConfig); // Speichert SSID, PW, IP-Config (inkl. DNS) in den EEPROM-Block ab EEPROM_ADDR_WIFI_CONFIG_DATA
// EEPROM.commit() wird innerhalb von saveWiFiConfig bereits für den WiFi-Block
// und separat oben für den Hostnamen aufgerufen. Ein zusätzlicher Commit hier wäre redundant,
// es sei denn, saveWiFiConfig würde nicht committen. Gemäß der Funktion tut sie es aber.
// -> KEIN EEPROM.commit() HIER NÖTIG.
} else {
Serial.println("Demo-Modus aktiv: Einstellungen werden NICHT gespeichert.");
}
// --- Senden der Bestätigungsseite (Heap-Optimiert) ---
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, F("text/html"), ""); // Header senden
server.sendContent_P(saveConfigHtml); // Chunk 1 (Head Start)
server.sendContent_P(commonStyle); // Common CSS
server.sendContent_P(saveConfigHtml2); // Chunk 2 (Head End)
server.sendContent_P(commonNav); // Common Navigation
server.sendContent_P(saveConfigHtml3); // Chunk 3 (Body)
server.sendContent(F("")); // Abschluss-Chunk
// --- Neustart einleiten ---
Serial.println("Einstellungen verarbeitet. Starte Neustart in 5 Sekunden...");
delay(5000); // Kurze Verzögerung, damit der Browser die Bestätigung empfangen kann
ESP.restart();
}
// Prüfung der WiFi-Verbindung (Reconnect falls getrennt) - Unverändert
void checkWifiConnection() {
if (WiFi.status() != WL_CONNECTED) {
WiFi.reconnect();
if (WiFi.waitForConnectResult() != WL_CONNECTED) {
Serial.println(F("WiFi-Verbindung kann nicht hergestellt werden!"));
}
}
}
// WiFi-Symbol aufs Display zeichnen - Unverändert
void drawwifiSymbol(int16_t x, int16_t y) {
display.drawBitmap(x, y, wifiSymbol, 13, 7, SH110X_WHITE);
}
/************************************************************************************
* Handler zum Rücksetzen der Betriebszeit - URL angepasst
************************************************************************************/
void handleResetRuntime() {
totalRuntime = 0;
EEPROM.put(EEPROM_ADDR_RUNTIME, totalRuntime);
EEPROM.commit();
server.sendHeader(F("Location"), F("/Info")); // URL angepasst
server.send(303);
}
/************************************************************************************
* Handler zum Rücksetzen des Shotzählers - URL angepasst
************************************************************************************/
void handleResetShots() {
shotCounter = 0;
EEPROM.put(EEPROM_ADDR_SHOTCOUNTER, shotCounter);
EEPROM.commit();
server.sendHeader(F("Location"), F("/Info")); // URL angepasst
server.send(303);
}
/************************************************************************************
* Handler zum Rücksetzen des Wartungszählers (über Shot-Zähler)
************************************************************************************/
void handleResetMaintenance() {
maintenanceIntervalCounter = 0; // Wartungszähler-Reset auf 0
EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter);
EEPROM.commit();
displayMaintenanceMessage = false; // Meldung ggf. ausblenden
server.sendHeader(F("Location"), F("/Service")); // URL angepasst
server.send(303);
}
/************************************************************************************
* Handler für den Export der EEPROM-Einstellungen - Unverändert
************************************************************************************/
void handleExportSettings() {
// Header für Dateidownload setzen
server.sendHeader("Content-Disposition", "attachment; filename=\"dual_pid_settings.bin\"");
server.sendHeader("Content-Type", "application/octet-stream");
server.setContentLength(EEPROM_SIZE); // Wichtig: Korrekte Größe senden
// EEPROM-Inhalt byteweise senden
// Puffergröße klein halten, um Speicher zu sparen
const size_t bufferSize = 64;
byte buffer[bufferSize];
size_t bytesSent = 0;
// Start des Sendens (Status 200 OK)
server.send(200, "application/octet-stream", "");
// EEPROM lesen und in Chunks senden
for (int i = 0; i < EEPROM_SIZE; i += bufferSize) {
size_t currentChunkSize = (EEPROM_SIZE - i < bufferSize) ? (EEPROM_SIZE - i) : bufferSize;
for (size_t j = 0; j < currentChunkSize; j++) {
buffer[j] = EEPROM.read(i + j);
yield();
}
server.sendContent((const char*)buffer, currentChunkSize);
bytesSent += currentChunkSize;
yield(); // Wichtig für Netzwerkstabilität bei längeren Operationen
}
// Kein server.stop() hier, da die Verbindung automatisch geschlossen wird.
}
/************************************************************************************
* Handler für den Import der EEPROM-Einstellungen (Upload-Verarbeitung) - Unverändert
************************************************************************************/
// Globale Variablen bleiben gleich
static int eepromWriteAddress = 0;
static bool importSuccessful = false;
static String importStatusMessage = "";
void handleImportUpload() {
HTTPUpload& upload = server.upload();
// Demo-Modus Prüfung (Bleibt bestehen)
if (demoModus) {
if (upload.status == UPLOAD_FILE_START) {
importStatusMessage = F("Import im Demo-Modus nicht erlaubt!");
importSuccessful = false;
}
return;
}
switch (upload.status) {
case UPLOAD_FILE_START:
eepromWriteAddress = 0; // Zähler zurücksetzen
importSuccessful = false; // Status zurücksetzen
importStatusMessage = ""; // Nachricht zurücksetzen
break;
case UPLOAD_FILE_WRITE:
// Prüfen, ob bereits ein Fehler aufgetreten ist (z.B. durch zu viele Daten im vorherigen Chunk)
if (importStatusMessage.length() > 0) {
yield();
break; // Beende die Verarbeitung für diesen Status
}
// Schreibe die empfangenen Bytes, aber schütze vor Überlauf
for (size_t i = 0; i < upload.currentSize; i++) {
// Prüfen, ob das Schreiben das EEPROM_SIZE Limit überschreiten würde
if (eepromWriteAddress < EEPROM_SIZE) {
EEPROM.write(eepromWriteAddress, upload.buf[i]);
eepromWriteAddress++;
} else {
// Es kommen MEHR Daten als erwartet!
Serial.printf("FEHLER: Mehr als %d Bytes empfangen! Adresse: %d. Breche ab.\n", EEPROM_SIZE, eepromWriteAddress);
importStatusMessage = F("Importfehler: Empfangene Datei zu gro&szlig;!"); // '&szlig;' für ß
// Breche die Verarbeitung dieses Chunks und zukünftiger Chunks ab
goto end_write_processing; // Springe aus der Schleife und dem Case
}
yield();
}
end_write_processing:; // Sprungziel für goto
yield();
break;
case UPLOAD_FILE_END:
// FINALE GRÖSSENPRÜFUNG und Fehlerprüfung
// 1. Gab es einen Fehler während des Schreibens (z.B. Datei zu groß)?
if (importStatusMessage.length() > 0) {
Serial.println("Import nicht erfolgreich aufgrund vorheriger Fehler (z.B. Datei zu groß).");
importSuccessful = false; // Sicherstellen, dass Flag false ist
}
// 2. Wurden exakt EEPROM_SIZE Bytes verarbeitet?
else if (eepromWriteAddress == EEPROM_SIZE) {
if (EEPROM.commit()) {
importSuccessful = true;
importStatusMessage = F("Einstellungen erfolgreich importiert! Neustart wird durchgef&uuml;hrt..."); // '&uuml;' für ü
} else {
Serial.println("FEHLER: EEPROM Commit fehlgeschlagen!");
importSuccessful = false;
importStatusMessage = F("Import fehlgeschlagen: EEPROM Commit Fehler!");
}
}
// 3. Wurden weniger oder mehr Bytes verarbeitet (und kein Fehler vorher gesetzt)?
else {
Serial.printf("FEHLER: Upload beendet, aber falsche Anzahl Bytes verarbeitet! Verarbeitet: %d, Erwartet: %d\n", eepromWriteAddress, EEPROM_SIZE);
importSuccessful = false;
importStatusMessage = F("Import fehlgeschlagen: Falsche Dateigr&ouml;&szlig;e!"); // '&ouml;', '&szlig;'
}
// Bereinigung für den nächsten Upload
eepromWriteAddress = 0;
break;
case UPLOAD_FILE_ABORTED:
Serial.println("EEPROM Import vom Client abgebrochen.");
importSuccessful = false;
// Nur Nachricht setzen, wenn nicht schon ein Fehler vorliegt
if (importStatusMessage.length() == 0) {
importStatusMessage = F("Import abgebrochen!");
}
eepromWriteAddress = 0;
break;
default:
Serial.printf("Unbekannter Upload-Status: %d\n", upload.status);
break;
}
}
/************************************************************************************
* Handler für die Import-Seite (nach dem Upload) - Heap-Optimiert
************************************************************************************/
// PROGMEM Chunks für die Seite
static const char importDoneHtmlStart[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html><head><title>Einstellungen Import</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
)rawliteral";
static const char importDoneHtmlHeadEnd[] PROGMEM = R"rawliteral(</head>)rawliteral";
static const char importDoneHtmlBodyStart[] PROGMEM = R"rawliteral(
<body><h1>Einstellungen Import</h1>
<form><p>)rawliteral"; // Endet vor {status_message}
static const char importDoneHtmlBodyEnd[] PROGMEM = R"rawliteral(</p></form>
</body></html>
)rawliteral"; // Beginnt nach {status_message}
void handleImportSettings() {
// Statusvariablen importSuccessful und importStatusMessage werden von handleImportUpload gesetzt
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, F("text/html"), "");
server.sendContent_P(importDoneHtmlStart);
server.sendContent_P(commonStyle);
server.sendContent_P(importDoneHtmlHeadEnd);
server.sendContent_P(commonNav);
server.sendContent_P(importDoneHtmlBodyStart);
// Statusmeldung senden (importStatusMessage ist globale String-Variable)
if (importStatusMessage.length() > 0) {
server.sendContent(importStatusMessage);
}
server.sendContent_P(importDoneHtmlBodyEnd);
server.sendContent(F("")); // Abschluss-Chunk
// Neustart-Logik bleibt unverändert
if (importSuccessful) {
Serial.println("Neustart in 5 Sekunden nach EEPROM-Import...");
delay(5000);
ESP.restart();
}
}
/************************************************************************************
* Funktion zum Zurücksetzen auf Werkseinstellungen
* Setzt auch Brew Control und Brew-by-Weight Werte zurück
************************************************************************************/
void resetToDefaults() {
Serial.println("Zurücksetzen auf Werkseinstellungen...");
// 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);
// NEU: Brew-by-Weight auf Defaults im EEPROM schreiben
#ifdef ESP32
EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_ENABLED, defaultBrewByWeightEnabled);
EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_TARGET, defaultBrewByWeightTargetGrams);
EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_OFFSET, defaultBrewByWeightOffsetGrams);
#endif
// Magic Value für allgemeine Einstellungen setzen (wichtig!)
EEPROM.put(EEPROM_ADDR_MAGICVALUE, storageMagicValue);
// Alle Änderungen ins EEPROM schreiben
if (EEPROM.commit()) { Serial.println("Werkseinstellungen erfolgreich ins EEPROM geschrieben."); }
else { Serial.println("FEHLER: Schreiben der Werkseinstellungen ins EEPROM fehlgeschlagen!"); }
// WICHTIG: Globale Variablen auch auf Default setzen
SetpointWasser = defaultSetpointWasser; SetpointDampf = defaultSetpointDampf;
OffsetWasser = defaultOffsetWasser; OffsetDampf = defaultOffsetDampf;
KpWasser = defaultKpWasser; KiWasser = defaultKiWasser; KdWasser = defaultKdWasser;
KpDampf = defaultKpDampf; KiDampf = defaultKiDampf; KdDampf = defaultKdDampf;
ecoModeMinutes = defaultEcoModeMinutes; ecoModeTempWasser = defaultEcoModeTempWasser; ecoModeTempDampf = defaultEcoModeTempDampf;
dynamicEcoActive = false; fastHeatUpAktiv = false; dampfVerzoegerung = defaultDampfVerzoegerung;
tuningStepWasser = defaultTuningStepWasser; tuningNoiseWasser = defaultTuningNoiseWasser; tuningStartValueWasser = defaultTuningStartValueWasser; tuningLookBackWasser = defaultTuningLookBackWasser;
tuningStepDampf = defaultTuningStepDampf; tuningNoiseDampf = defaultTuningNoiseDampf; tuningStartValueDampf = defaultTuningStartValueDampf; tuningLookBackDampf = defaultTuningLookBackDampf;
maintenanceInterval = defaultMaintenanceInterval; maintenanceIntervalCounter = 0;
boostWasserActive = defaultBoostWasserActive; boostDampfActive = defaultBoostDampfActive;
preventHeatAboveSetpointWasser = defaultPreventHeatAboveSetpointWasser; preventHeatAboveSetpointDampf = defaultPreventHeatAboveSetpointDampf;
windowSizeWasser = defaultWindowSizeWasser; windowSizeDampf = defaultWindowSizeDampf;
strncpy(hostname, defaultHostname.c_str(), sizeof(hostname) - 1); hostname[sizeof(hostname) - 1] = '\0';
// Globale Brew Control Variablen auf Defaults setzen
brewByTimeEnabled = defaultBrewByTimeEnabled; brewByTimeTargetSeconds = defaultBrewByTimeTargetSeconds;
preInfusionEnabled = defaultPreInfusionEnabled; preInfusionDurationSeconds = defaultPreInfusionDurationSeconds; preInfusionPauseSeconds = defaultPreInfusionPauseSeconds;
// NEU: Globale Brew-by-Weight Variablen auf Defaults setzen
#ifdef ESP32
brewByWeightEnabled = defaultBrewByWeightEnabled; brewByWeightTargetGrams = defaultBrewByWeightTargetGrams; brewByWeightOffsetGrams = defaultBrewByWeightOffsetGrams;
#endif
// 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(
<!DOCTYPE html><html><head><title>Werkseinstellungen</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
)rawliteral";
static const char resetDoneHtmlHeadEnd[] PROGMEM = R"rawliteral(</head>)rawliteral";
static const char resetDoneHtmlBody[] PROGMEM = R"rawliteral(
<body><h1>Werkseinstellungen</h1>
<form><p>Alle Einstellungen wurden auf die Werkseinstellungen zur&uuml;ckgesetzt. Das Ger&auml;t wird neu gestartet...</p></form>
</body></html>
)rawliteral";
// Überarbeitete Funktion
void handleResetDefaults() {
// Logik zum Resetten bleibt unverändert
if (!demoModus) {
resetToDefaults(); // Führt das eigentliche Reset durch
} else {
server.send(403, F("text/plain"), F("Reset im Demo-Modus nicht erlaubt."));
return;
}
// Bestätigungsseite senden (Heap-optimiert)
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, F("text/html"), "");
server.sendContent_P(resetDoneHtmlStart);
server.sendContent_P(commonStyle);
server.sendContent_P(resetDoneHtmlHeadEnd);
server.sendContent_P(commonNav);
server.sendContent_P(resetDoneHtmlBody); // Body ist komplett statisch
server.sendContent(F("")); // Abschluss-Chunk
// Neustart-Logik bleibt unverändert
delay(5000);
ESP.restart();
}
/************************************************************************************
* Variable für Systemstartzeit (relevant für die Dampf-Verzögerung) - Unverändert
************************************************************************************/
unsigned long startupTime;
/************************************************************************************
* setup(): Initialisierung des Systems - Angepasst für neue EEPROM-Werte, Hostname, URLs
************************************************************************************/
void setup() {
Serial.begin(115200);
EEPROM.begin(EEPROM_SIZE); // Größe angepasst (oder bleibt 1024)
pinMode(SSR_DAMPF_PIN, OUTPUT);
pinMode(SSR_WASSER_PIN, OUTPUT);
#ifdef ESP32
pinMode(SHOT_TIMER_PIN, INPUT_PULLDOWN); // ESP32 uses Pulldown for HIGH active
pinMode(PUMP_PIN, OUTPUT);
pinMode(VALVE_PIN, OUTPUT);
// ADC für NTC-Pin konfigurieren und kalibrieren
Serial.println("Konfiguriere und kalibriere ESP32 ADC für NTC...");
analogSetPinAttenuation(ANALOG_NTC_PIN, ADC_11db);
analogReadResolution(12);
esp_adc_cal_value_t val_type = esp_adc_cal_characterize(ADC_UNIT_1, ADC_ATTEN_DB_11, ADC_WIDTH_BIT_12, 1100, &adc_chars);
if (val_type == ESP_ADC_CAL_VAL_EFUSE_VREF) {
Serial.println(" Kalibrierung: eFuse Vref verwendet.");
} else if (val_type == ESP_ADC_CAL_VAL_EFUSE_TP) {
Serial.println(" Kalibrierung: Two Point eFuse Werte verwendet.");
} else {
Serial.println(" Kalibrierung: Default Vref verwendet.");
}
// *** ENDE ADC Konfiguration/Kalibrierung ***
#else // ESP8266
pinMode(SHOT_TIMER_PIN, INPUT_PULLUP); // ESP8266 uses Pullup for HIGH active (check circuit!)
#endif
// Initialisiere SSRs als AUS
digitalWrite(SSR_DAMPF_PIN, LOW);
digitalWrite(SSR_WASSER_PIN, LOW);
#ifdef ESP32
// Pumpe & Ventil Pins initialisieren (AUS) ***
digitalWrite(PUMP_PIN, LOW);
digitalWrite(VALVE_PIN, LOW);
#endif
// Prüfen, ob MagicValue bereits im EEPROM liegt
char storedMagicValue[5] = {0};
bool magicValueVorhanden = false;
EEPROM.get(EEPROM_ADDR_MAGICVALUE, storedMagicValue);
if (strncmp(storedMagicValue, storageMagicValue, 4) == 0) {
magicValueVorhanden = true;
} else {
magicValueVorhanden = false;
// Hier noch KEINE Defaults schreiben, das passiert unten gebündelt
}
// Werte aus dem EEPROM laden, wenn vorhanden, sonst mit Default-Werten initialisieren
if (magicValueVorhanden) {
// --- Bestehende EEPROM Ladevorgänge ---
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
EEPROM.get(EEPROM_ADDR_OFFSET_DAMPF, OffsetDampf);
EEPROM.get(EEPROM_ADDR_OFFSET_WASSER, OffsetWasser);
EEPROM.get(EEPROM_ADDR_KP_DAMPF, KpDampf);
EEPROM.get(EEPROM_ADDR_KI_DAMPF, KiDampf);
EEPROM.get(EEPROM_ADDR_KD_DAMPF, KdDampf);
EEPROM.get(EEPROM_ADDR_KP_WASSER, KpWasser);
EEPROM.get(EEPROM_ADDR_KI_WASSER, KiWasser);
EEPROM.get(EEPROM_ADDR_KD_WASSER, KdWasser);
EEPROM.get(EEPROM_ADDR_ECOMODE_MINUTES, ecoModeMinutes);
EEPROM.get(EEPROM_ADDR_ECOMODE_TEMP_WASSER, ecoModeTempWasser);
EEPROM.get(EEPROM_ADDR_ECOMODE_TEMP_DAMPF, ecoModeTempDampf);
EEPROM.get(EEPROM_ADDR_INFO_HERSTELLER, infoHersteller);
EEPROM.get(EEPROM_ADDR_INFO_MODELL, infoModell);
EEPROM.get(EEPROM_ADDR_INFO_ZUSATZ, infoZusatz);
EEPROM.get(EEPROM_ADDR_DYNAMIC_ECO_MODE, dynamicEcoActive);
EEPROM.get(EEPROM_ADDR_FASTHEATUP_DATA, fastHeatUpAktiv);
EEPROM.get(EEPROM_ADDR_STEAM_DELAY, dampfVerzoegerung);
EEPROM.get(EEPROM_ADDR_TUNING_STEP_WASSER, tuningStepWasser);
EEPROM.get(EEPROM_ADDR_TUNING_NOISE_WASSER, tuningNoiseWasser);
EEPROM.get(EEPROM_ADDR_TUNING_STARTVALUE_WASSER, tuningStartValueWasser);
EEPROM.get(EEPROM_ADDR_TUNING_LOOKBACK_WASSER, tuningLookBackWasser);
EEPROM.get(EEPROM_ADDR_TUNING_STEP_DAMPF, tuningStepDampf);
EEPROM.get(EEPROM_ADDR_TUNING_NOISE_DAMPF, tuningNoiseDampf);
EEPROM.get(EEPROM_ADDR_TUNING_STARTVALUE_DAMPF, tuningStartValueDampf);
EEPROM.get(EEPROM_ADDR_TUNING_LOOKBACK_DAMPF, tuningLookBackDampf);
EEPROM.get(EEPROM_ADDR_MAX_TEMP_WASSER, maxTempWasser);
EEPROM.get(EEPROM_ADDR_MAX_TEMP_DAMPF, maxTempDampf);
EEPROM.get(EEPROM_ADDR_MAINTENANCE_INTERVAL, maintenanceInterval);
EEPROM.get(EEPROM_ADDR_BOOST_WASSER_ACTIVE, boostWasserActive);
EEPROM.get(EEPROM_ADDR_BOOST_DAMPF_ACTIVE, boostDampfActive);
EEPROM.get(EEPROM_ADDR_PREVENTHEAT_WASSER, preventHeatAboveSetpointWasser);
EEPROM.get(EEPROM_ADDR_PREVENTHEAT_DAMPF, preventHeatAboveSetpointDampf);
EEPROM.get(EEPROM_ADDR_WINDOWSIZE_WASSER, windowSizeWasser);
EEPROM.get(EEPROM_ADDR_WINDOWSIZE_DAMPF, windowSizeDampf);
// Brew Control Einstellungen laden
EEPROM.get(EEPROM_ADDR_BREWBYTIME_ENABLED, brewByTimeEnabled);
EEPROM.get(EEPROM_ADDR_BREWBYTIME_SECONDS, brewByTimeTargetSeconds);
EEPROM.get(EEPROM_ADDR_PREINF_ENABLED, preInfusionEnabled);
EEPROM.get(EEPROM_ADDR_PREINF_DUR_SEC, preInfusionDurationSeconds);
EEPROM.get(EEPROM_ADDR_PREINF_PAUSE_SEC, preInfusionPauseSeconds);
// NEU: Brew-by-Weight Einstellungen laden
#ifdef ESP32 // Nur laden wenn ESP32
EEPROM.get(EEPROM_ADDR_BREWBYWEIGHT_ENABLED, brewByWeightEnabled);
EEPROM.get(EEPROM_ADDR_BREWBYWEIGHT_TARGET, brewByWeightTargetGrams);
EEPROM.get(EEPROM_ADDR_BREWBYWEIGHT_OFFSET, brewByWeightOffsetGrams);
#endif
// Sicherstellen, dass Strings nullterminiert sind
infoHersteller[sizeof(infoHersteller) - 1] = '\0';
infoModell[sizeof(infoModell) - 1] = '\0';
infoZusatz[sizeof(infoZusatz) - 1] = '\0';
} else { // Magic Value fehlt -> ALLES auf Default setzen
// Setze globale Variablen auf Defaults
SetpointDampf = defaultSetpointDampf;
SetpointWasser = defaultSetpointWasser;
OffsetDampf = defaultOffsetDampf;
OffsetWasser = defaultOffsetWasser;
KpDampf = defaultKpDampf;
KiDampf = defaultKiDampf;
KdDampf = defaultKdDampf;
KpWasser = defaultKpWasser;
KiWasser = defaultKiWasser;
KdWasser = defaultKdWasser;
ecoModeMinutes = defaultEcoModeMinutes;
ecoModeTempWasser = defaultEcoModeTempWasser;
ecoModeTempDampf = defaultEcoModeTempDampf;
strncpy(infoHersteller, defaultInfoHersteller.c_str(), sizeof(infoHersteller) - 1); infoHersteller[sizeof(infoHersteller) - 1] = '\0';
strncpy(infoModell, defaultInfoModell.c_str(), sizeof(infoModell) - 1); infoModell[sizeof(infoModell) - 1] = '\0';
strncpy(infoZusatz, defaultInfoZusatz.c_str(), sizeof(infoZusatz) - 1); infoZusatz[sizeof(infoZusatz) - 1] = '\0';
dynamicEcoActive = false;
fastHeatUpAktiv = false;
dampfVerzoegerung = defaultDampfVerzoegerung;
tuningStepWasser = defaultTuningStepWasser;
tuningNoiseWasser = defaultTuningNoiseWasser;
tuningStartValueWasser = defaultTuningStartValueWasser;
tuningLookBackWasser = defaultTuningLookBackWasser;
tuningStepDampf = defaultTuningStepDampf;
tuningNoiseDampf = defaultTuningNoiseDampf;
tuningStartValueDampf = defaultTuningStartValueDampf;
tuningLookBackDampf = defaultTuningLookBackDampf;
maintenanceInterval = defaultMaintenanceInterval;
maintenanceIntervalCounter = 0;
boostWasserActive = defaultBoostWasserActive;
boostDampfActive = defaultBoostDampfActive;
preventHeatAboveSetpointWasser = defaultPreventHeatAboveSetpointWasser;
preventHeatAboveSetpointDampf = defaultPreventHeatAboveSetpointDampf;
// maxTempWasser/Dampf behalten ihre Standard-Initialwerte
windowSizeWasser = defaultWindowSizeWasser;
windowSizeDampf = defaultWindowSizeDampf;
// Brew Control auf Defaults setzen
brewByTimeEnabled = defaultBrewByTimeEnabled;
brewByTimeTargetSeconds = defaultBrewByTimeTargetSeconds;
preInfusionEnabled = defaultPreInfusionEnabled;
preInfusionDurationSeconds = defaultPreInfusionDurationSeconds;
preInfusionPauseSeconds = defaultPreInfusionPauseSeconds;
// NEU: Brew-by-Weight auf Defaults setzen
#ifdef ESP32 // Nur setzen wenn ESP32
brewByWeightEnabled = defaultBrewByWeightEnabled;
brewByWeightTargetGrams = defaultBrewByWeightTargetGrams;
brewByWeightOffsetGrams = defaultBrewByWeightOffsetGrams;
#endif
// Ruft resetToDefaults() auf, was *alle* Defaults ins EEPROM schreibt (inkl. der neuen)
resetToDefaults(); // Stellt sicher, dass EEPROM konsistent ist
}
// --- WiFi Konfiguration (Unverändert) ---
WiFiConfig wifiConfig;
bool hasWiFiConfig = loadWiFiConfig(wifiConfig); // Lädt auch Hostname
WiFi.hostname(hostname); // Hostname setzen VOR begin/softAP
if (hasWiFiConfig) {
WiFi.mode(WIFI_STA);
if (wifiConfig.useStaticIP) {
Serial.println(F("Verwende statische IP-Konfiguration:"));
Serial.print(F(" IP: ")); Serial.println(wifiConfig.staticIP);
Serial.print(F(" Gateway: ")); Serial.println(wifiConfig.gateway);
Serial.print(F(" Subnet: ")); Serial.println(wifiConfig.subnet);
Serial.print(F(" DNS: ")); Serial.println(wifiConfig.dns);
if (!WiFi.config(wifiConfig.staticIP, wifiConfig.gateway, wifiConfig.subnet, wifiConfig.dns)) {
Serial.println(F("FEHLER: Statische IP-Konfiguration fehlgeschlagen!"));
}
} else {
Serial.println(F("Verwende DHCP."));
}
Serial.printf("Verbinde mit SSID: %s, Hostname: %s\n", wifiConfig.ssid, hostname);
WiFi.begin(wifiConfig.ssid, wifiConfig.password);
int retries = 0;
while (WiFi.status() != WL_CONNECTED && retries < 20) {
delay(500); Serial.print(F(".")); retries++; yield();
}
if (WiFi.status() == WL_CONNECTED) {
Serial.print(F("\nErfolgreich verbunden! IP-Adresse: ")); Serial.println(WiFi.localIP());
timeClient.begin(); Serial.println("NTP Client gestartet.");
configTzTime(TZ_INFO, "pool.ntp.org"); Serial.printf("Zeitzone gesetzt: %s\n", TZ_INFO);
if (WiFi.localIP() == demoModusIP) { demoModus = true; Serial.println("INFO: Demo-Modus aktiviert (basierend auf IP-Adresse)."); }
if (MDNS.begin(hostname)) { Serial.println("mDNS Responder gestartet"); MDNS.addService("http", "tcp", 80); } else { Serial.println("Fehler beim Starten des mDNS Responders!"); }
} else {
Serial.println(F("\nKonnte keine Verbindung herstellen! Starte AP-Modus..."));
startAPMode();
}
} else {
Serial.println("Keine WLAN-Konfiguration gefunden. Starte AP-Modus...");
startAPMode();
}
// --- Ende WiFi Konfiguration ---
// Wartungszähler, Betriebszeit und Shotcounter aus EEPROM laden (Unverändert)
EEPROM.get(EEPROM_ADDR_RUNTIME, totalRuntime);
if (totalRuntime == ULONG_MAX || totalRuntime > (86400UL * 365 * 10)) { totalRuntime = 0; EEPROM.put(EEPROM_ADDR_RUNTIME, totalRuntime); EEPROM.commit(); }
EEPROM.get(EEPROM_ADDR_SHOTCOUNTER, shotCounter);
if (shotCounter == ULONG_MAX || shotCounter > 1000000UL) { shotCounter = 0; EEPROM.put(EEPROM_ADDR_SHOTCOUNTER, shotCounter); EEPROM.commit(); }
EEPROM.get(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter);
if (maintenanceIntervalCounter == ULONG_MAX || maintenanceIntervalCounter > 1000000UL) { maintenanceIntervalCounter = 0; EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter); EEPROM.commit(); }
if (fastHeatUpAktiv) {
fastHeatUpHeating = true;
}
// OLED-Initialisierung
Wire.begin(OLED_SDA, OLED_SCK);
if (!display.begin(0x3C, true)) {
Serial.println(F("Display konnte nicht initialisiert werden!"));
}
#ifdef ESP32
// NEU: Waage initialisieren (NACH Wire.begin!)
Serial.println("Initialisiere M5Stack Waage...");
// Annahme: Default-Adresse 0x26 wird verwendet. Wenn nicht, hier anpassen.
if (!scales.begin(&Wire, OLED_SDA, OLED_SCK)) { // Wiederverwendet SDA/SCL Pins vom OLED
Serial.println("FEHLER: M5Stack Waage nicht gefunden oder Initialisierung fehlgeschlagen!");
scaleConnected = false;
// Optional: Weitere Fehlerbehandlung (z.B. LED blinken lassen)
} else {
Serial.println("M5Stack Waage erfolgreich initialisiert.");
scaleConnected = true;
// Optional: Filter der Waage konfigurieren, falls gewünscht
// scales.setLPFilter(1); // Low-pass Filter aktivieren
// scales.setAvgFilter(4); // Moving Average Filter (0-16, 0=aus)
// scales.setEmaFilter(20); // Exponential Moving Average (0-99, 0=aus)
}
#endif
// Display-Ausgabe am Start (Unverändert)
display.clearDisplay();
display.setCursor(0, 0);
display.setTextColor(SH110X_WHITE);
display.setTextSize(1);
display.println(infoHersteller);
display.println(infoModell);
display.println(infoZusatz);
display.println("");
display.println(F("Dual-PID"));
display.print(F("Version: "));
display.println(version);
display.println(F("von Thomas M\201ller")); // \201 ist ü für manche Displays
display.display();
delay(delayInit1);
// PID-Regler Konfiguration (Unverändert bezüglich Plausibilität und Limits)
if (windowSizeWasser == 0 || windowSizeWasser > 60000) { windowSizeWasser = defaultWindowSizeWasser; }
if (windowSizeDampf == 0 || windowSizeDampf > 60000) { windowSizeDampf = defaultWindowSizeDampf; }
pidWasser.SetOutputLimits(0, windowSizeWasser);
pidDampf.SetOutputLimits(0, windowSizeDampf);
pidDampf.SetTunings(KpDampf, KiDampf, KdDampf);
pidWasser.SetTunings(KpWasser, KiWasser, KdWasser);
pidDampf.SetMode(AUTOMATIC);
pidWasser.SetMode(AUTOMATIC);
windowStartTimeWasser = millis();
windowStartTimeDampf = millis();
// Anzeige zur WiFi-Verbindung (Unverändert)
display.clearDisplay();
display.setCursor(0, 0);
display.setTextColor(SH110X_WHITE);
display.setTextSize(1);
display.println(infoHersteller);
display.println(infoModell);
display.println(infoZusatz);
display.println("");
if (WiFi.status() != WL_CONNECTED) {
display.println(F("WiFi: AP-Modus"));
display.print(F("SSID: ")); display.println(hostname);
display.print(F("IP: ")); display.println(WiFi.softAPIP());
} else {
display.println(F("Webserver gestartet"));
display.print(F("IP: ")); display.println(WiFi.localIP());
display.print(F("Host: ")); display.print(hostname); display.println(".local");
}
display.display();
delay(delayInit2);
// LittleFS Initialisierung (Unverändert)
if (!LittleFS.begin()) {
Serial.println("--------------------------------------------------");
Serial.println("FEHLER: LittleFS Mount fehlgeschlagen!");
if (!LittleFS.begin()) {
Serial.println("LittleFS Mount auch nach Formatierung fehlgeschlagen! Halte an.");
while (1) delay(100); // Anhalten bei Fehler
} else {
Serial.println("LittleFS nach Formatierung erfolgreich gemountet.");
}
} else {
Serial.println("LittleFS erfolgreich gemountet.");
if (!LittleFS.exists("/Profile")) {
if (LittleFS.mkdir("/Profile")) {
Serial.println("Verzeichnis '/Profile' erstellt.");
} else {
Serial.println("FEHLER: Konnte Verzeichnis '/Profile' nicht erstellen!");
}
} else {
Serial.println("Verzeichnis '/Profile' gefunden.");
}
} // Ende des LittleFS-Blocks
// Routen für Webserver (Unverändert, nur Brew-Control Route wird jetzt mehr tun)
server.on("/", handleRoot);
server.on("/updateSettings", handleSettingsUpdate);
server.on("/Info", handleInfo);
server.on("/updateInfoSettings", handleInfoUpdate);
server.on("/downloadNutzungsstatistik", handleDownloadNutzungsstatistik);
server.on("/deleteStatistics", HTTP_POST, handleDeleteStatistics);
server.on("/toggleWartungsmodus", HTTP_POST, handleToggleWartungsmodus); // War schon in Service
server.on("/Service", handleService);
server.on("/AutoTune-Wasser", handleAutoTuneWasser);
server.on("/AutoTune-Dampf", handleAutoTuneDampf);
server.on("/updateAutotuneSettings", HTTP_POST, handleUpdateAutotuneSettings);
server.on("/PID-Tuning-Abbruch", HTTP_POST, handlePidTuningAbbruch);
server.on("/PID-Tuning", handlePidTuning);
server.on("/ECO", handleEco);
server.on("/updateEcoSettings", handleEcoUpdate);
server.on("/Firmware", handleFirmware);
server.on("/update", HTTP_POST, handleFirmwareUpdateResponse, handleFirmwareUploadProgress);
server.on("/Fast-Heat-Up", handleFastHeatUp);
server.on("/updateFast-Heat-Up-Settings", handleFastHeatUpSettings);
server.on("/Dateimanager", HTTP_GET, handleFileManager);
server.on("/Datei-Upload", HTTP_POST, handleFileUploaded, handleFileUpload);
server.on("/Datei-Entfernen", HTTP_GET, handleFileDelete);
server.on("/Datei-Download", HTTP_GET, handleFileDownload);
server.on("/Verzeichnis-Erstellen", HTTP_POST, handleCreateDirectory);
server.on("/Chart-Daten", HTTP_GET, handleChartData);
server.on("/Chart", handleCharts);
server.on("/chart.umd.min.js", HTTP_GET, handleServeChartJs);
server.on("/Netzwerk", handleWiFiConfig);
server.on("/saveWiFiConfig", handleSaveWiFiConfig);
server.on("/forceAPMode", handleForceAPMode);
server.on("/Profile", HTTP_GET, handleProfilesPage);
server.on("/loadProfile", HTTP_POST, handleLoadProfile);
server.on("/saveProfile", HTTP_POST, handleSaveProfile);
server.on("/deleteProfile", HTTP_POST, handleDeleteProfile);
server.on("/exportSettings", HTTP_GET, handleExportSettings);
server.on("/importSettings", HTTP_POST, handleImportSettings, handleImportUpload);
server.on("/resetDefaults", HTTP_POST, handleResetDefaults);
server.on("/updateMaintenanceSettings", handleMaintenanceUpdate);
server.on("/resetRuntime", handleResetRuntime);
server.on("/resetShots", handleResetShots);
server.on("/resetMaintenance", handleResetMaintenance);
#ifdef ESP32
// Routen für Brew Control (Nur ESP32) - Bleibt gleich, Handler wird angepasst
server.on("/Brew-Control", HTTP_GET, handleBrewControl);
server.on("/saveBrewControl", HTTP_POST, handleSaveBrewControl);
#endif
// Webserver starten
server.begin();
Serial.println(F("Webserver gestartet"));
startupTime = millis();
yield();
} // Ende setup()
/************************************************************************************
* loop(): Hauptschleife
* Enthält Logik für Pre-Infusion Ablauf und Brew-By-Time Check (Nur ESP32)
************************************************************************************/
void loop() {
unsigned long currentMillis = millis();
// Prüfen, ob Zeit bereits synchronisiert wurde (Unverändert)
if (!timeSynced && WiFi.status() == WL_CONNECTED && timeClient.update()) { timeSynced = true; }
// --- Temperaturmessung und PID-Berechnung (im Intervall) ---
if (currentMillis - previousMillis >= interval) {
previousMillis = currentMillis;
// Temperaturmessung (Unverändert)
InputDampf = round(thermocouple.readCelsius() + OffsetDampf);
InputWasser = round(readNTCTemperature() + OffsetWasser);
yield();
// --- Angepasste Sicherheitsüberprüfung Wasser --- (Unverändert)
if (InputWasser < 0.0 || isnan(InputWasser)) {
wasserSensorError = true; wasserSafetyShutdown = false; OutputWasser = 0;
// Serial.println("WARNUNG: Wassertemperatursensor liefert ungültigen Wert!"); // Optional Log
} else if (InputWasser > maxTempWasser) {
wasserSensorError = false; wasserSafetyShutdown = true; OutputWasser = 0;
// Serial.printf("WARNUNG: Wassertemperatur über Limit! Shutdown.\n"); // Optional Log
} else {
if (wasserSensorError) Serial.println("INFO: Wassertemperatursensor wieder OK."); wasserSensorError = false;
if (wasserSafetyShutdown) Serial.println("INFO: Wassertemperatur wieder im sicheren Bereich."); wasserSafetyShutdown = false;
bool activateBoost = boostWasserActive && !wartungsModusAktiv && !ecoModeAktiv && !autoTuneWasserActive && (InputWasser < (SetpointWasser * 0.95));
if (activateBoost) { OutputWasser = windowSizeWasser; } else if (!autoTuneWasserActive) { pidWasser.Compute(); }
}
yield();
// --- Angepasste Sicherheitsüberprüfung Dampf --- (Unverändert)
if (isnan(InputDampf) || InputDampf < 0.0) {
dampfSensorError = true; dampfSafetyShutdown = false; OutputDampf = 0;
// Serial.println("WARNUNG: Dampftemperatursensor liefert ungültigen Wert!"); // Optional Log
} else if (InputDampf > maxTempDampf) {
dampfSensorError = false; dampfSafetyShutdown = true; OutputDampf = 0;
// Serial.printf("WARNUNG: Dampftemperatur über Limit! Shutdown.\n"); // Optional Log
} else {
if (dampfSensorError) Serial.println("INFO: Dampftemperatursensor wieder OK."); dampfSensorError = false;
if (dampfSafetyShutdown) Serial.println("INFO: Dampftemperatur wieder im sicheren Bereich."); dampfSafetyShutdown = false;
bool steamDelayActive = (dampfVerzoegerung > 0 && (millis() - startupTime < (unsigned long)dampfVerzoegerung * 60000UL));
if (steamDelayActive && !wartungsModusAktiv) { OutputDampf = 0; } else {
bool activateBoostDampf = boostDampfActive && !wartungsModusAktiv && !ecoModeAktiv && !autoTuneDampfActive && (InputDampf < (SetpointDampf * 0.90));
if (activateBoostDampf) { OutputDampf = windowSizeDampf; } else if (!autoTuneDampfActive) { pidDampf.Compute(); }
}
}
// AutoTune-Logik (unverändert)
handleAutoTune();
} // Ende des Temperaturmessungs-/PID-Berechnungsintervalls
yield();
// --- Zeitproportionale SSR-Ansteuerung (wird kontinuierlich geprüft) --- (Unverändert)
unsigned long now = millis();
// Wasser SSR
if (wasserSensorError || wasserSafetyShutdown) { digitalWrite(SSR_WASSER_PIN, LOW); } else {
if (now - windowStartTimeWasser > windowSizeWasser) { windowStartTimeWasser += windowSizeWasser; }
bool pidWantsHeat = (OutputWasser > 0) && (now < (windowStartTimeWasser + (unsigned long)OutputWasser));
bool preventOverheatActive = (preventHeatAboveSetpointWasser && (InputWasser > SetpointWasser)) || (ecoModeAktiv && (InputWasser > SetpointWasser));
if (pidWantsHeat && !preventOverheatActive) { digitalWrite(SSR_WASSER_PIN, HIGH); } else { digitalWrite(SSR_WASSER_PIN, LOW); }
}
yield();
// Dampf SSR
if (dampfSensorError || dampfSafetyShutdown) { digitalWrite(SSR_DAMPF_PIN, LOW); } else {
if (now - windowStartTimeDampf > windowSizeDampf) { windowStartTimeDampf += windowSizeDampf; }
bool pidWantsHeat = (OutputDampf > 0) && (now < (windowStartTimeDampf + (unsigned long)OutputDampf));
bool preventOverheatActive = (preventHeatAboveSetpointDampf && (InputDampf > SetpointDampf)) || (ecoModeAktiv && (InputDampf > SetpointDampf));
if (pidWantsHeat && !preventOverheatActive) { digitalWrite(SSR_DAMPF_PIN, HIGH); } else { digitalWrite(SSR_DAMPF_PIN, LOW); }
}
yield();
// Shot-Timer (Diese Funktion initiiert nur den Start oder stoppt bei manuellem Ende)
updateShotTimer(); // Ruft Waage tarren auf, wenn der Shot beginnt
#ifdef ESP32
// *** Brüh-Logik (Pre-Infusion, Brew-by-Time, Brew-by-Weight) ***
if (shotActive) {
unsigned long currentShotTimeMillis = millis() - shotStartTime;
// --- Waage lesen ---
if (scaleConnected) {
currentWeightReading = scales.getWeight(); // Lese Rohwert
// Optional: Glättung/Filterung des Rohwerts hier hinzufügen, falls nötig
} else {
currentWeightReading = 0.0f; // Kein gültiger Wert, wenn Waage nicht verbunden
}
// --- Stopp-Bedingungen prüfen ---
float targetWeightToStopAt = brewByWeightTargetGrams - brewByWeightOffsetGrams; // Zielgewicht unter Berücksichtigung des Offsets
bool stopForTime = brewByTimeEnabled && (currentShotTimeMillis >= (unsigned long)(brewByTimeTargetSeconds * 1000.0f));
bool stopForWeight = brewByWeightEnabled && scaleConnected && (currentWeightReading >= targetWeightToStopAt);
if (stopForTime || stopForWeight) {
if (stopForTime) Serial.println("Brew-By-Time Limit erreicht. Stoppe Bezug...");
if (stopForWeight) Serial.printf("Brew-By-Weight Limit erreicht (Aktuell: %.1fg, Ziel: %.1fg). Stoppe Bezug...\n", currentWeightReading, targetWeightToStopAt);
stopBrewSequence(currentShotTimeMillis); // Übergibt die aktuelle Dauer
}
// --- Pre-Infusion Zustandsmaschine (Nur ausführen, wenn noch nicht gestoppt wurde) ---
else if (preInfusionEnabled) {
unsigned long currentPhaseTimeMillis = millis() - preInfusionPhaseStartTime;
// Wechsel von Pre-Infusion zu Pause
if (currentPreInfusionState == PI_PRE_BREW && (currentPhaseTimeMillis >= (unsigned long)(preInfusionDurationSeconds * 1000.0f))) {
Serial.println("Pre-Infusion -> Pause");
digitalWrite(PUMP_PIN, LOW);
currentPreInfusionState = PI_PAUSE;
preInfusionPhaseStartTime = millis();
}
// Wechsel von Pause zu Hauptbezug
else if (currentPreInfusionState == PI_PAUSE && (currentPhaseTimeMillis >= (unsigned long)(preInfusionPauseSeconds * 1000.0f))) {
Serial.println("Pause -> Main Brew");
digitalWrite(PUMP_PIN, HIGH);
currentPreInfusionState = PI_MAIN_BREW;
preInfusionPhaseStartTime = millis();
}
}
// Kein else hier -> Entweder Stopp, oder PreInfusion-Check, oder normaler Bezug ohne PreInfusion läuft weiter
}
#endif // ESP32
// Eco-Modus, wenn kein AutoTune oder Wartungsmodus (Unverändert)
if (!autoTuneWasserActive && !autoTuneDampfActive && !wartungsModusAktiv) {
if (ecoModeMinutes > 0 && !shotActive) { // Eco nur wenn kein Shot läuft
unsigned long currentTimeEco = millis();
if (currentTimeEco - lastShotTime > (ecoModeMinutes * 60UL * 1000UL)) {
if (!ecoModeAktiv) { ecoModeActivatedTime = currentTimeEco; }
ecoModeAktiv = true;
if (dynamicEcoActive) {
int minutesPassed = (currentTimeEco - ecoModeActivatedTime) / (60UL * 1000UL);
SetpointWasser = max(0, ecoModeTempWasser - minutesPassed); SetpointDampf = max(0, ecoModeTempDampf - minutesPassed);
} else {
SetpointWasser = ecoModeTempWasser; SetpointDampf = ecoModeTempDampf;
}
} else {
if (ecoModeAktiv) { EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); }
ecoModeAktiv = false; ecoModeActivatedTime = 0;
}
} else if (ecoModeAktiv) {
ecoModeAktiv = false; ecoModeActivatedTime = 0;
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
}
yield();
} else if (ecoModeAktiv) {
ecoModeAktiv = false; ecoModeActivatedTime = 0;
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
}
// Display regelmäßig updaten
updateDisplay(); // Wird das Gewicht anzeigen, wenn shotActive
server.handleClient(); // Webserver
#ifdef ESP8266
MDNS.update();
#endif
yield();
// Betriebszeit zählen (Unverändert)
static unsigned long lastRuntimeSecond = 0;
if (millis() - lastRuntimeSecond >= 1000) { totalRuntime++; lastRuntimeSecond = millis(); }
// Speichern der Betriebszeit und WiFi-Check (Unverändert)
if (!demoModus) {
unsigned long currentMillisLoop = millis();
unsigned long intervalToUse = !initialRuntimeSaveDone ? initialRuntimeSaveDelay : subsequentRuntimeSaveInterval;
if (currentMillisLoop - lastRuntimeSave >= intervalToUse) {
EEPROM.put(EEPROM_ADDR_RUNTIME, totalRuntime);
if(EEPROM.commit()){ /* Serial.println("Betriebszeit gespeichert."); */ } else { Serial.println("FEHLER: EEPROM Commit beim Speichern der Betriebszeit fehlgeschlagen!"); }
lastRuntimeSave = currentMillisLoop;
if (!initialRuntimeSaveDone) initialRuntimeSaveDone = true;
if (WiFi.getMode() == WIFI_STA) checkWifiConnection();
}
}
yield();
} // Ende loop()
/************************************************************************************
* Liest die Temperatur vom NTC-Sensor (Wasser) über den Spannungsteiler (ADC)
* - Verwendet jetzt ANALOG_NTC_PIN für Plattformkompatibilität
************************************************************************************/
double readNTCTemperature() {
double sensorVoltage = NAN; // Mit Fehlerwert initialisieren
int adcValue = analogRead(ANALOG_NTC_PIN);
#ifdef ESP32
// --- ESP32 Logik ---
const double MAX_ADC_VALUE = 4095.0; // ESP32 Standard 12-bit
// Prüfen auf ungültige ADC-Werte (ESP32)
if (adcValue <= 0 || adcValue >= MAX_ADC_VALUE) {
Serial.print("WARNUNG (ESP32): Ungültiger ADC-Wert vom NTC-Sensor: ");
Serial.println(adcValue);
return NAN; // Not-a-Number als Fehlerwert zurückgeben
}
// Versuche kalibrierte Spannung zu erhalten (adc_chars muss vorher initialisiert sein!)
uint32_t voltage_mv = esp_adc_cal_raw_to_voltage(adcValue, &adc_chars);
if (voltage_mv > 0) { // Prüfen, ob Kalibrierung erfolgreich / plausibel war
sensorVoltage = voltage_mv / 1000.0; // Kalibrierte Spannung nutzen
// Serial.print("ESP32 Calibrated Voltage: "); Serial.println(sensorVoltage); // Zum Debuggen
} else {
// Fallback auf lineare Berechnung, wenn Kalibrierung nicht geht/konfiguriert ist
Serial.println("WARNUNG (ESP32): ADC Kalibrierung nicht verfügbar/erfolgreich. Nutze lineare Schätzung.");
// ADC_REF muss definiert sein (z.B. 3.3 für 3.3V Referenz)
sensorVoltage = adcValue * (ADC_REF / MAX_ADC_VALUE);
// Serial.print("ESP32 Linear Voltage: "); Serial.println(sensorVoltage); // Zum Debuggen
}
// Optional: ADC-Konfiguration hier oder global setzen (z.B. Attenuation für vollen Range)
// analogSetAttenuation(ADC_11db);
// analogReadResolution(12);
#else // ESP8266 Logik
// --- ESP8266 Logik ---
const double MAX_ADC_VALUE = 1023.0; // ESP8266 10-bit
// Prüfen auf ungültige ADC-Werte (ESP8266)
if (adcValue <= 0 || adcValue >= MAX_ADC_VALUE) {
Serial.print("WARNUNG (ESP8266): Ungültiger ADC-Wert vom NTC-Sensor: ");
Serial.println(adcValue);
return NAN; // Not-a-Number als Fehlerwert zurückgeben
}
// Lineare Berechnung für ESP8266
// WICHTIG: Stelle sicher, dass ADC_REF korrekt ist!
// Wenn der Spannungsteiler mit V_SUPPLY (z.B. 3.3V) betrieben wird, sollte ADC_REF dieser Spannung entsprechen.
// Siehe Kommentar im alten Code bzgl. interner 1.0V Referenz vs. externer Beschaltung.
// Annahme hier: ADC_REF ist die Spannung bei MAX_ADC_VALUE für die externe Schaltung.
sensorVoltage = adcValue * (ADC_REF / MAX_ADC_VALUE); // ADC_REF muss definiert sein (z.B. 3.3)
// Serial.print("ESP8266 Linear Voltage: "); Serial.println(sensorVoltage); // Zum Debuggen
#endif
// --- Gemeinsame NTC-Berechnung & Fehlerprüfungen ---
// Prüfen, ob sensorVoltage gültig berechnet wurde
if (isnan(sensorVoltage)) {
Serial.println("FEHLER: sensorVoltage konnte nicht ermittelt werden.");
return NAN;
}
// Vermeide Division durch Null oder ungültige Bedingungen
if (sensorVoltage <= 0) { // <= 0 fängt auch den Fall ab, der R_ntc negativ machen würde
Serial.println("WARNUNG: NTC-Spannung ist 0 oder negativ nach Berechnung.");
return NAN;
}
if (sensorVoltage >= V_SUPPLY) {
Serial.println("WARNUNG: NTC-Spannung entspricht Versorgungsspannung, R_ntc wäre unendlich.");
return NAN; // Fehler
}
// NTC Widerstand berechnen
double R_ntc = R_FIXED * ((V_SUPPLY / sensorVoltage) - 1.0);
if (R_ntc <= 0) { // Sollte durch die Prüfungen oben eigentlich nicht mehr vorkommen, aber sicher ist sicher
Serial.print("WARNUNG: Berechneter NTC-Widerstand ungültig: ");
Serial.println(R_ntc);
return NAN; // Fehler
}
// Temperatur über Beta-Gleichung berechnen
double log_R_NTC_R0 = log(R_ntc / NTC_R0); // log() braucht positiven Wert, ist durch R_ntc > 0 sichergestellt
double temp_inv_K = (1.0 / NTC_T0) + (1.0 / beta) * log_R_NTC_R0;
if (temp_inv_K == 0) { // Vermeide Division durch Null
Serial.println("WARNUNG: Kehrwert der Kelvin-Temperatur ist 0.");
return NAN; // Fehler
}
double temperatureK = 1.0 / temp_inv_K;
double temperatureC = temperatureK - 273.15;
// Optional: Plausibilitätsprüfung
// if (temperatureC < -50 || temperatureC > 200) { ... }
return temperatureC;
}
/************************************************************************************
* Aktualisiert das Display (Temperaturanzeige, Shot-Timer, Wartung etc.) - Angepasst
* Priorität: PID Tuning > Fast Heat Up > Aktiver Shot > Letzte Shot-Zeit > Wartung > Temp/Shutdown/Fehler
************************************************************************************/
void updateDisplay() {
// 0. Höchste Priorität: Wartungsmodus aktiv? -> Eigene Anzeige, dann Ende.
if (wartungsModusAktiv) {
display.clearDisplay();
display.setCursor(0, 0);
display.setTextSize(1);
display.setTextColor(SH110X_WHITE);
display.println(F("Wartungsmodus"));
display.println(F("ist aktiv!"));
display.println(F(""));
display.println(F("Temperaturen werden"));
display.println(F("auf "));
display.print(wartungsModusTemp);
display.print(F(" "));
display.print((char)247); // Grad-Symbol
display.print(F("C gehalten."));
// WiFi Symbol trotzdem anzeigen, falls verbunden
if (WiFi.status() == WL_CONNECTED) {
drawwifiSymbol(113, 0);
}
display.display();
return; // Funktion hier beenden
}
// 1. Höchste Priorität: PID Tuning aktiv? -> Eigene Anzeige, dann Ende.
if (autoTuneWasserActive || autoTuneDampfActive) {
display.clearDisplay();
display.setCursor(0, 0);
display.setTextSize(1);
display.setTextColor(SH110X_WHITE);
display.println(F("PID-Tuning aktiv:"));
display.println(autoTuneWasserActive ? F("Wasser-PID") : F("Dampf-PID"));
display.println("");
// Statusvariablen abfragen und anzeigen
int currentPeakCount = 0;
int currentPeakType = 0;
if (autoTuneWasserActive && autoTuneWasser != nullptr) { // Sicherstellen, dass Objekt existiert
currentPeakCount = autoTuneWasser->getPeakCount();
currentPeakType = autoTuneWasser->getPeakType();
} else if (autoTuneDampfActive && autoTuneDampf != nullptr) { // Sicherstellen, dass Objekt existiert
currentPeakCount = autoTuneDampf->getPeakCount();
currentPeakType = autoTuneDampf->getPeakType();
}
display.print(F("Cycle: "));
if (currentPeakCount < 0 || currentPeakCount > 10) {
// Falls kleiner 0 oder größer 10 (kann bei Start vorkommen)
display.print(F("0"));
} else {
// Zeigt an, wie viele Zyklen/Phasen erkannt wurden
display.print(currentPeakCount);
}
display.print(F(" von 5-10"));
display.println(); // Zeilenumbruch
display.print(F("Phase:"));
if (currentPeakType == 1) {
display.print(F("Heizen")); // Letztes war/suche Maximum
} else if (currentPeakType == -1) {
display.print(F("K\201hlen")); // Letztes war/suche Minimum
} else {
display.print(F("Init")); // Initialisierungsphase
}
display.println(); // Zeilenumbruch
if (autoTuneWasserActive) {
display.print("Temperatur:");
display.print((int)InputWasser);
display.print(F(" "));
display.print((char)247); // Grad-Symbol
display.print(F("C"));
}
if (autoTuneDampfActive) {
display.print("Temperatur:");
display.print((int)InputDampf);
display.print(F(" "));
display.print((char)247); // Grad-Symbol
display.print(F("C"));
}
// WiFi Symbol trotzdem anzeigen, falls verbunden
if (WiFi.status() == WL_CONNECTED) {
drawwifiSymbol(113, 0);
}
display.display();
return; // Funktion hier beenden
}
// --- Keine Tuning-Anzeige ---
display.clearDisplay();
// WiFi Symbol (immer anzeigen, wenn verbunden)
if (WiFi.status() == WL_CONNECTED) {
drawwifiSymbol(113, 0);
}
display.setCursor(0, 0);
display.setTextColor(SH110X_WHITE);
display.setTextSize(1);
// 2. Priorität: Fast-Heat-Up aktiv und relevant?
if (fastHeatUpHeating && !shotActive && !wasserSensorError && ((int)InputWasser < fastHeatUpSetpoint)) {
SetpointWasser = fastHeatUpSetpoint; // Setpoint temporär anpassen für Anzeige
display.println(F("Fast-Heat-Up aktiv:"));
display.println("");
display.println(F("20 Sek. Flushen,"));
display.println(F("wenn Temp. erreicht!"));
display.println(F(" ")); // Leere Zeile
display.println(F("Temperatur: "));
display.print((int)InputWasser);
display.print(F(" / "));
display.print((int)SetpointWasser); // Zeigt den F-H-U Setpoint
display.print(F(" "));
display.print((char)247); // Grad-Symbol
display.print(F("C"));
// Hier keine Dampfanzeige während Fast-Heat-Up, um Platz zu sparen
}
// 3. Kein Fast-Heat-Up: Was soll sonst angezeigt werden?
else {
// 3a. Priorität: Shot läuft *aktuell*?
if (shotActive) {
unsigned long elapsed = millis() - shotStartTime;
display.println(F("Shot-Timer:"));
display.println("");
display.setTextSize(3); // Größere Schrift für den Timer
// Zeige nur eine Nachkommastelle an
char buffer[6]; // Puffer für "xx.y" + Nullterminator
snprintf(buffer, sizeof(buffer), "%.1f", elapsed / 1000.0);
display.println(buffer);
display.setTextSize(1); // Zurück zur normalen Schriftgröße
display.println(F("Sekunden"));
fastHeatUpHeating = false; // Laufender Shot beendet F-H-U Anzeige visuell
}
// 3b. Priorität: Letzte Shot-Zeit anzeigen (für 2 Sekunden)?
else if (shotEndTime > 0 && millis() < shotEndTime + 2000) {
display.println(F("Bezugszeit:"));
display.println("");
display.setTextSize(3); // Größere Schrift
// Zeige nur eine Nachkommastelle an
char buffer[6];
snprintf(buffer, sizeof(buffer), "%.1f", lastShotDurationMillis / 1000.0);
display.println(buffer);
display.setTextSize(1); // Zurück zur normalen Schriftgröße
display.println(F("Sekunden"));
}
// 3c. Priorität: Wartungserinnerung anzeigen (für 6 Sekunden nach Trigger)
// Diese Bedingung wird nur erreicht, wenn kein Shot aktiv ist UND die letzte Shot-Zeit nicht angezeigt wird.
else if (displayMaintenanceMessage && millis() < maintenanceMessageStartTime + 6000) {
display.println(F("Erinnerung:")); // Titel
display.println(F("Reinigung /")); // Zeile 1
display.println(F("Wartung fällig!")); // Zeile 2
// Restliche Zeilen leer lassen oder für Details nutzen
display.println("");
display.println("");
display.println("");
}
// 3d. Standardanzeige: Temperaturen / Fehler / Sicherheitsabschaltung
else {
display.println(F("Temperaturen:")); // Titelzeile
display.println(""); // Leerzeile
// Wasser Temperatur / Sicherheitsabschaltung / Fehleranzeige
display.println(F("Wasser: ")); // Print, kein Zeilenumbruch
if (wasserSafetyShutdown) { // Prüfung 1: Sicherheitsabschaltung
display.print(F("Sicherheitsabsch."));
} else if (wasserSensorError) { // Prüfung 2: Echter Sensorfehler
display.print(F("Sensorfehler!"));
} else { // Prüfung 3: Alles OK -> Temperatur anzeigen
display.print((int)InputWasser);
display.print(F(" / "));
display.print((int)SetpointWasser);
display.print(F(" "));
display.print((char)247); // Grad-Symbol
display.print(F("C"));
// ECO-Modus Anzeige anhängen, falls aktiv
if (ecoModeAktiv && dynamicEcoActive) display.print(F(" (ECO+)"));
else if (ecoModeAktiv && !dynamicEcoActive) display.print(F(" (ECO)"));
}
display.println(""); // Zeilenumbruch nach der Wasserzeile
display.println(""); // Leere Zeile für Abstand
// Dampf Temperatur / Sicherheitsabschaltung / Fehleranzeige
display.println(F("Dampf: ")); // Print, kein Zeilenumbruch, 2 Leerzeichen für Ausrichtung
if (dampfSafetyShutdown) { // Prüfung 1: Sicherheitsabschaltung
display.print(F("Sicherheitsabsch."));
} else if (dampfSensorError) { // Prüfung 2: Echter Sensorfehler
display.print(F("Sensorfehler!"));
} else { // Prüfung 3: Alles OK -> Temperatur anzeigen (oder Verzögerung)
// Prüfen auf Startverzögerung (wie vorher)
bool steamDelayActive = (dampfVerzoegerung > 0 && (millis() - startupTime < (unsigned long)dampfVerzoegerung * 60000UL));
if (!steamDelayActive) // Wenn keine Verzögerung aktiv ist
{
display.print((int)InputDampf);
display.print(F(" / "));
display.print((int)SetpointDampf);
display.print(F(" "));
display.print((char)247); // Grad-Symbol
display.print(F("C"));
// ECO-Modus Anzeige anhängen, falls aktiv
if (ecoModeAktiv && dynamicEcoActive) display.print(F(" (ECO+)"));
else if (ecoModeAktiv && !dynamicEcoActive) display.print(F(" (ECO)"));
} else { // Wenn Startverzögerung aktiv ist
display.print(F("Startverz\x94gerung"));
}
}
// Kein println() am Ende der letzten Zeile nötig
} // Ende Standardanzeige (Temperaturen etc.)
} // Ende "Kein Fast-Heat-Up"
yield();
display.display(); // Display *immer* am Ende aktualisieren, egal was angezeigt wurde
}
/************************************************************************************
* Verwaltet den Shot-Timer (wenn z.B. der Nutzer den Bezug aktiviert) - Wartungscheck hinzugefügt
************************************************************************************/
/************************************************************************************
* Verwaltet den Shot-Timer (wenn z.B. der Nutzer den Bezug aktiviert)
* Initiiert Pre-Infusion (ESP32)
* Ruft stopBrewSequence auf (ESP32)
************************************************************************************/
void updateShotTimer() {
// Wenn Wartungsmodus aktiv ist, diese Funktion sofort verlassen
if (wartungsModusAktiv) {
#ifdef ESP32
stopBrewSequence(0); // Stoppt Pumpe/Ventil, setzt Flags zurück
#else
if (shotActive) { shotActive = false; shotStartTime = 0; shotEndTime = 0; lastShotDurationMillis = 0; }
#endif
return;
}
bool shotInputActive = (digitalRead(SHOT_TIMER_PIN) == HIGH);
// --- Shot beginnt (Eingang ist HIGH) ---
if (shotInputActive) {
if (!shotActive) {
shotActive = true;
shotStartTime = millis();
lastShotDurationMillis = 0;
shotEndTime = 0;
displayMaintenanceMessage = false;
ecoModeAktiv = false;
ecoModeActivatedTime = 0;
// Normale Sollwerte wiederherstellen, falls Eco aktiv war
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
Serial.println("Shot Timer STARTET...");
#ifdef ESP32
// NEU: Waage auf Null setzen (Tarrieren)
if (scaleConnected) {
Serial.println("Tarriere Waage...");
scales.setOffset();
delay(50); // Kurze Pause nach dem Tarrieren, falls nötig
currentWeightReading = scales.getWeight(); // Lese direkt nach dem Tarrieren
Serial.printf("Gewicht nach Tarrieren: %.2fg\n", currentWeightReading);
} else {
Serial.println("Waage nicht verbunden, kann nicht tarriert werden.");
currentWeightReading = 0.0f; // Setze internen Wert zurück
}
// Pre-Infusion oder direkten Start behandeln
if (preInfusionEnabled) {
Serial.println("Start Pre-Infusion Phase...");
currentPreInfusionState = PI_PRE_BREW;
preInfusionPhaseStartTime = millis();
digitalWrite(PUMP_PIN, HIGH); // Pumpe AN für Pre-Infusion
digitalWrite(VALVE_PIN, HIGH); // Ventil ZU für Pre-Infusion
} else {
Serial.println("Start Main Brew Phase (keine Pre-Infusion)...");
currentPreInfusionState = PI_MAIN_BREW; // Direkt zur Hauptphase
preInfusionPhaseStartTime = millis(); // Timer trotzdem starten (für Konsistenz)
digitalWrite(PUMP_PIN, HIGH); // Pumpe AN für Hauptbezug
digitalWrite(VALVE_PIN, HIGH); // Ventil ZU für Hauptbezug
}
#endif // ESP32
}
}
// --- Shot endet (Eingang ist LOW - manuelles Stoppen) ---
else {
if (shotActive) {
// Eingang ist LOW, Shot war aktiv -> Normales Ende durch Benutzer
Serial.println("Shot Timer STOPPT (Eingang LOW)...");
#ifdef ESP32
// Berechne Dauer und rufe die Stopp-Sequenz auf
unsigned long shotDuration = millis() - shotStartTime;
stopBrewSequence(shotDuration); // Diese Funktion handhabt alles Weitere (Pumpe aus, Zähler etc.)
#else // ESP8266 - Original Logik ohne Pumpe/Ventil/Waage
unsigned long shotDuration = millis() - shotStartTime;
lastShotDurationMillis = shotDuration;
shotEndTime = millis();
if (shotDuration > 20000) {
shotCounter++; maintenanceIntervalCounter++;
logShot(shotDuration);
if (!demoModus) { EEPROM.put(EEPROM_ADDR_SHOTCOUNTER, shotCounter); EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter); EEPROM.commit(); }
Serial.print(F("Shots: ")); Serial.println(shotCounter);
if (maintenanceInterval > 0 && maintenanceIntervalCounter >= maintenanceInterval) { displayMaintenanceMessage = true; maintenanceMessageStartTime = millis(); Serial.println("Wartungsintervall erreicht!"); }
}
shotActive = false;
lastShotTime = millis();
fastHeatUpHeating = false;
#endif // ESP32 / ESP8266
}
}
}
/************************************************************************************
* Startet AutoTune für Wasser-PID (Angepasst für Zeitproportional)
************************************************************************************/
void startAutoTuneWasser() {
// Eco-Modus deaktivieren, falls aktiv
if (ecoModeAktiv) {
ecoModeAktiv = false;
// Ursprünglichen Setpoint wiederherstellen (aus EEPROM oder Profil)
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
// Optional: Auch Dampf-Setpoint wiederherstellen, falls Eco beide beeinflusst
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
}
// Stelle sicher, dass der PID im Automatikmodus ist bevor Tuning beginnt
pidWasser.SetMode(AUTOMATIC);
// Sicherstellen, dass die Output-Grenzen korrekt gesetzt sind
pidWasser.SetOutputLimits(0, windowSizeWasser);
autoTuneWasser = new PID_ATune(&InputWasser, &OutputWasser);
// Verwende die Wasser-spezifischen Parameter
autoTuneWasser->SetOutputStep(tuningStepWasser); // Angepasst
autoTuneWasser->SetControlType(1); // DIRECT control (heating)
autoTuneWasser->SetNoiseBand(tuningNoiseWasser); // Angepasst
autoTuneWasser->SetLookbackSec((int)tuningLookBackWasser); // Angepasst (Cast zu int für Funktion)
// Setze den Startwert für den Output
// WICHTIG: AutoTune manipuliert OutputWasser direkt. Die zeitproportionale
// Logik in loop() wird diesen Wert verwenden, um den SSR zu steuern.
OutputWasser = tuningStartValueWasser; // Angepasst
// KEIN direktes digitalWrite mehr hier! Die loop() übernimmt das.
autoTuneWasserActive = true;
Serial.println("Starte AutoTune für Wasser (Zeitproportional)...");
}
/************************************************************************************
* Startet AutoTune für Dampf-PID (Angepasst für Zeitproportional)
************************************************************************************/
void startAutoTuneDampf() {
// Eco-Modus deaktivieren, falls aktiv
if (ecoModeAktiv) {
ecoModeAktiv = false;
// Ursprünglichen Setpoint wiederherstellen (aus EEPROM oder Profil)
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
// Optional: Auch Wasser-Setpoint wiederherstellen, falls Eco beide beeinflusst
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
}
// Stelle sicher, dass der PID im Automatikmodus ist bevor Tuning beginnt
pidDampf.SetMode(AUTOMATIC);
// Sicherstellen, dass die Output-Grenzen korrekt gesetzt sind
pidDampf.SetOutputLimits(0, windowSizeDampf);
autoTuneDampf = new PID_ATune(&InputDampf, &OutputDampf);
// Verwende die Dampf-spezifischen Parameter
autoTuneDampf->SetOutputStep(tuningStepDampf); // Angepasst
autoTuneDampf->SetControlType(1); // DIRECT control (heating)
autoTuneDampf->SetNoiseBand(tuningNoiseDampf); // Angepasst
autoTuneDampf->SetLookbackSec((int)tuningLookBackDampf); // Angepasst (Cast zu int für Funktion)
// Setze den Startwert für den Output
// WICHTIG: AutoTune manipuliert OutputDampf direkt. Die zeitproportionale
// Logik in loop() wird diesen Wert verwenden, um den SSR zu steuern.
OutputDampf = tuningStartValueDampf; // Angepasst
// KEIN direktes digitalWrite mehr hier! Die loop() übernimmt das.
autoTuneDampfActive = true;
Serial.println("Starte AutoTune für Dampf (Zeitproportional)...");
}
/************************************************************************************
* Stoppt AutoTune für Wasser - Unverändert
************************************************************************************/
void stopAutoTuneWasser() {
if (autoTuneWasserActive) {
autoTuneWasserActive = false;
delete autoTuneWasser;
autoTuneWasser = nullptr;
// Setze PID zurück in den Normalbetrieb (ggf. Output auf 0)
pidWasser.SetMode(AUTOMATIC); // Sicherstellen, dass PID wieder normal läuft
OutputWasser = 0; // Optional: Output zurücksetzen
// Stelle sicher, dass SSR ausgeschaltet ist
digitalWrite(SSR_WASSER_PIN, LOW);
Serial.println("AutoTune Wasser gestoppt.");
}
}
/************************************************************************************
* Stoppt AutoTune für Dampf - Unverändert
************************************************************************************/
void stopAutoTuneDampf() {
if (autoTuneDampfActive) {
autoTuneDampfActive = false;
delete autoTuneDampf;
autoTuneDampf = nullptr;
// Setze PID zurück in den Normalbetrieb (ggf. Output auf 0)
pidDampf.SetMode(AUTOMATIC); // Sicherstellen, dass PID wieder normal läuft
OutputDampf = 0; // Optional: Output zurücksetzen
// Stelle sicher, dass SSR ausgeschaltet ist
digitalWrite(SSR_DAMPF_PIN, LOW);
Serial.println("AutoTune Dampf gestoppt.");
}
}
/************************************************************************************
* Überwacht die AutoTune-Prozesse (Dampf/Wasser)
* - Prüft auf Sensorfehler ODER Sicherheitsabschaltung vor Ausführung
* - Display-Logik ist in updateDisplay() ausgelagert
************************************************************************************/
void handleAutoTune() {
// --- AutoTune Wasser ---
if (autoTuneWasserActive) {
// Erweiterte Prüfung auf Sensorfehler ODER Sicherheitsabschaltung während des Tunings
if (wasserSensorError || wasserSafetyShutdown) {
// Logge den spezifischen Grund für den Abbruch
if (wasserSensorError) {
Serial.println("FEHLER: Wassertemperatursensor während AutoTune ausgefallen! Breche Tuning ab.");
} else { // Muss wasserSafetyShutdown sein
Serial.println("WARNUNG: Sicherheitslimit Wasser während AutoTune überschritten! Breche Tuning ab.");
}
stopAutoTuneWasser(); // Ruft Funktion auf, die Flag zurücksetzt, Speicher freigibt und SSR ausschaltet
return; // Verlasse die Funktion für diesen Durchlauf, um Runtime() nicht auszuführen
}
// Nur wenn kein Fehler/Shutdown vorliegt, führe AutoTune->Runtime() aus.
// Diese Funktion berechnet und SETZT OutputWasser für den nächsten Schritt!
if (autoTuneWasser->Runtime() == 1) // Runtime() gibt 1 zurück, wenn Tuning abgeschlossen
{
// Tuning abgeschlossen, Werte holen
KpWasser = autoTuneWasser->GetKp();
KiWasser = autoTuneWasser->GetKi();
KdWasser = autoTuneWasser->GetKd();
// WICHTIG: PID-Tunings sofort anwenden
pidWasser.SetTunings(KpWasser, KiWasser, KdWasser);
// Neue Werte im EEPROM speichern
EEPROM.put(EEPROM_ADDR_KP_WASSER, KpWasser);
EEPROM.put(EEPROM_ADDR_KI_WASSER, KiWasser);
EEPROM.put(EEPROM_ADDR_KD_WASSER, KdWasser);
EEPROM.commit();
Serial.println("AutoTune Wasser erfolgreich abgeschlossen.");
Serial.printf("Neue Werte: Kp=%.2f, Ki=%.2f, Kd=%.2f\n", KpWasser, KiWasser, KdWasser);
// AutoTune-Prozess korrekt beenden
stopAutoTuneWasser();
}
// Hinweis: Die Display-Anzeige für laufendes Tuning wird in updateDisplay() gehandhabt.
}
// --- AutoTune Dampf ---
if (autoTuneDampfActive) {
// Erweiterte Prüfung auf Sensorfehler ODER Sicherheitsabschaltung während des Tunings
if (dampfSensorError || dampfSafetyShutdown) {
// Logge den spezifischen Grund für den Abbruch
if (dampfSensorError) {
Serial.println("FEHLER: Dampftemperatursensor während AutoTune ausgefallen! Breche Tuning ab.");
} else { // Muss dampfSafetyShutdown sein
Serial.println("WARNUNG: Sicherheitslimit Dampf während AutoTune überschritten! Breche Tuning ab.");
}
stopAutoTuneDampf(); // Ruft Funktion auf, die Flag zurücksetzt, Speicher freigibt und SSR ausschaltet
return; // Verlasse die Funktion für diesen Durchlauf, um Runtime() nicht auszuführen
}
// Nur wenn kein Fehler/Shutdown vorliegt, führe AutoTune->Runtime() aus.
// Diese Funktion berechnet und SETZT OutputDampf für den nächsten Schritt!
if (autoTuneDampf->Runtime() == 1) // Runtime() gibt 1 zurück, wenn Tuning abgeschlossen
{
// Tuning abgeschlossen, Werte holen
KpDampf = autoTuneDampf->GetKp();
KiDampf = autoTuneDampf->GetKi();
KdDampf = autoTuneDampf->GetKd();
// WICHTIG: PID-Tunings sofort anwenden
pidDampf.SetTunings(KpDampf, KiDampf, KdDampf);
// Neue Werte im EEPROM speichern
EEPROM.put(EEPROM_ADDR_KP_DAMPF, KpDampf);
EEPROM.put(EEPROM_ADDR_KI_DAMPF, KiDampf);
EEPROM.put(EEPROM_ADDR_KD_DAMPF, KdDampf);
EEPROM.commit();
Serial.println("AutoTune Dampf erfolgreich abgeschlossen.");
Serial.printf("Neue Werte: Kp=%.2f, Ki=%.2f, Kd=%.2f\n", KpDampf, KiDampf, KdDampf);
// AutoTune-Prozess korrekt beenden
stopAutoTuneDampf();
}
// Hinweis: Die Display-Anzeige für laufendes Tuning wird in updateDisplay() gehandhabt.
}
}
/************************************************************************************
* Handler für die Brew Control Seite (GET /Brew-Control) - Nur ESP32
************************************************************************************/
#ifdef ESP32 // Handler sind nur für ESP32 sinnvoll
// --- PROGMEM Chunks für die Brew Control Seite (ERWEITERT für Brew-by-Weight) ---
static const char brewControlHead[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html><head><title>Brew Control</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
)rawliteral";
static const char brewControlHeadEnd[] PROGMEM = R"rawliteral(</head>)rawliteral";
static const char brewControlBodyStart[] PROGMEM = R"rawliteral(
<body><h1>Brew Control</h1>
<form action='/saveBrewControl' method='POST'>
Steuert das automatische Beenden des Br&uuml;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 &uuml;ber den Bezugs-Schalter beendet werden.<br>
Ebenso kann eine Pre-Infusion aktiviert und eingestellt werden.<br><br>
)rawliteral";
// Brew-By-Time Section (Unverändert, verwendet float)
static const char brewControlBBTSection[] PROGMEM = R"rawliteral(
<h3>Brew-By-Time</h3>
<label for='bbtEnabled' style='display:inline-block; margin-right:10px;'>Aktivieren:</label>
<input type='checkbox' id='bbtEnabled' name='bbtEnabled' value='1' {BBT_CHECKED}><br>
<label for='bbtSecs'>Zieldauer (Sekunden):</label>
<input type='number' min='5.0' max='120.0' step='0.1' id='bbtSecs' name='bbtSecs' value='{BBT_SECS}' required> <small style='color: #ccc;'>(Stoppt nach dieser Zeit)</small><br><br>
)rawliteral";
// *** NEU: Brew-By-Weight Section ***
static const char brewControlBBWSection[] PROGMEM = R"rawliteral(
<h3>Brew-By-Weight</h3>
<label for='bbwEnabled' style='display:inline-block; margin-right:10px;'>Aktivieren:</label>
<input type='checkbox' id='bbwEnabled' name='bbwEnabled' value='1' {BBW_CHECKED} {SCALE_DISABLED}><br>
<label for='bbwTarget'>Zielgewicht (Gramm):</label>
<input type='number' min='10.0' max='70.0' step='0.1' id='bbwTarget' name='bbwTarget' value='{BBW_TARGET}' required {SCALE_DISABLED}>
<label for='bbwOffset'>Offset (Gramm):</label>
<input type='number' min='0.0' max='10.0' step='0.1' id='bbwOffset' name='bbwOffset' value='{BBW_OFFSET}' required {SCALE_DISABLED}> <small style='color: #ccc;'>(Stoppt x Gramm fr&uuml;her)</small><br>
{SCALE_STATUS_MSG}
<br><br>
)rawliteral";
// Pre-Infusion Section (Unverändert, verwendet float)
static const char brewControlPISection[] PROGMEM = R"rawliteral(
<h3>Pre-Infusion</h3>
<label for='piEnabled' style='display:inline-block; margin-right:10px;'>Aktivieren:</label>
<input type='checkbox' id='piEnabled' name='piEnabled' value='1' {PI_CHECKED}><br>
<label for='piDurSecs'>Pre-Infusion Dauer (Sekunden):</label>
<input type='number' min='0.5' max='20.0' step='0.1' id='piDurSecs' name='piDurSecs' value='{PI_DUR_SECS}' required>
<label for='piPauseSecs'>Pause nach Pre-Infusion (Sekunden):</label>
<input type='number' min='0.0' max='20.0' step='0.1' id='piPauseSecs' name='piPauseSecs' value='{PI_PAUSE_SECS}' required> <small style='color: #ccc;'>(Pumpe AN -> Pumpe AUS -> Pumpe AN)</small><br>
)rawliteral";
// Formular Ende (Unverändert)
static const char brewControlFormEnd[] PROGMEM = R"rawliteral(
<input type='submit' value='Einstellungen speichern'>
</form>
</body></html>
)rawliteral";
// Handler für die Brew Control Seite (GET /Brew-Control) - ERWEITERT
void handleBrewControl() {
char buffer[10]; // Puffer für Zahlen
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "text/html", "");
server.sendContent_P(brewControlHead);
server.sendContent_P(commonStyle);
server.sendContent_P(brewControlHeadEnd);
server.sendContent_P(commonNav);
server.sendContent_P(brewControlBodyStart);
// Brew-By-Time Sektion rendern (mit float Formatierung)
String bbtHtml = FPSTR(brewControlBBTSection);
bbtHtml.replace("{BBT_CHECKED}", brewByTimeEnabled ? "checked" : "");
snprintf(buffer, sizeof(buffer), "%.1f", brewByTimeTargetSeconds);
bbtHtml.replace("{BBT_SECS}", buffer);
server.sendContent(bbtHtml);
yield();
// *** NEU: Brew-By-Weight Sektion rendern ***
String bbwHtml = FPSTR(brewControlBBWSection);
bbwHtml.replace("{BBW_CHECKED}", brewByWeightEnabled ? "checked" : "");
snprintf(buffer, sizeof(buffer), "%.1f", brewByWeightTargetGrams);
bbwHtml.replace("{BBW_TARGET}", buffer);
snprintf(buffer, sizeof(buffer), "%.1f", brewByWeightOffsetGrams);
bbwHtml.replace("{BBW_OFFSET}", buffer);
// Deaktiviere Felder, wenn Waage nicht verbunden ist
if (!scaleConnected) {
bbwHtml.replace("{SCALE_DISABLED}", "disabled");
bbwHtml.replace("{SCALE_STATUS_MSG}", "<small style='color: #FFCC00;'>Waage nicht verbunden!</small>");
} else {
bbwHtml.replace("{SCALE_DISABLED}", "");
bbwHtml.replace("{SCALE_STATUS_MSG}", ""); // Keine Meldung wenn ok
}
server.sendContent(bbwHtml);
yield();
// Pre-Infusion Sektion rendern (mit float Formatierung)
String piHtml = FPSTR(brewControlPISection);
piHtml.replace("{PI_CHECKED}", preInfusionEnabled ? "checked" : "");
snprintf(buffer, sizeof(buffer), "%.1f", preInfusionDurationSeconds);
piHtml.replace("{PI_DUR_SECS}", buffer);
snprintf(buffer, sizeof(buffer), "%.1f", preInfusionPauseSeconds);
piHtml.replace("{PI_PAUSE_SECS}", buffer);
server.sendContent(piHtml);
yield();
server.sendContent_P(brewControlFormEnd);
server.sendContent("");
}
/************************************************************************************
* Handler zum Speichern der Brew Control Einstellungen (POST /saveBrewControl) - Nur ESP32
************************************************************************************/
// Handler zum Speichern der Brew Control Einstellungen (POST /saveBrewControl) - ERWEITERT
void handleSaveBrewControl() {
bool changed = false;
// Brew-By-Time Auswerten (Unverändert, verwendet float)
bool newBBTEnabled = server.hasArg("bbtEnabled");
if (newBBTEnabled != brewByTimeEnabled) { brewByTimeEnabled = newBBTEnabled; EEPROM.put(EEPROM_ADDR_BREWBYTIME_ENABLED, brewByTimeEnabled); changed = true; }
if (server.hasArg("bbtSecs")) {
float newBBTSecs = server.arg("bbtSecs").toFloat();
if (newBBTSecs >= 5.0f && newBBTSecs <= 120.0f) { if (abs(newBBTSecs - brewByTimeTargetSeconds) > 0.01f) { brewByTimeTargetSeconds = newBBTSecs; EEPROM.put(EEPROM_ADDR_BREWBYTIME_SECONDS, brewByTimeTargetSeconds); changed = true; } }
else { Serial.printf("WARNUNG: Ungültiger Wert für Brew-By-Time Dauer empfangen: %s\n", server.arg("bbtSecs").c_str()); }
}
// *** NEU: Brew-By-Weight Auswerten ***
bool newBBWEnabled = server.hasArg("bbwEnabled");
if (newBBWEnabled != brewByWeightEnabled) { brewByWeightEnabled = newBBWEnabled; EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_ENABLED, brewByWeightEnabled); changed = true; }
if (server.hasArg("bbwTarget")) {
float newBBWTarget = server.arg("bbwTarget").toFloat();
if (newBBWTarget >= 10.0f && newBBWTarget <= 70.0f) { // Plausibilitätscheck Gewicht
if (abs(newBBWTarget - brewByWeightTargetGrams) > 0.01f) { brewByWeightTargetGrams = newBBWTarget; EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_TARGET, brewByWeightTargetGrams); changed = true; }
} else { Serial.printf("WARNUNG: Ungültiger Wert für Brew-By-Weight Ziel empfangen: %s\n", server.arg("bbwTarget").c_str()); }
}
if (server.hasArg("bbwOffset")) {
float newBBWOffset = server.arg("bbwOffset").toFloat();
if (newBBWOffset >= 0.0f && newBBWOffset <= 10.0f) { // Plausibilitätscheck Offset
if (abs(newBBWOffset - brewByWeightOffsetGrams) > 0.01f) { brewByWeightOffsetGrams = newBBWOffset; EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_OFFSET, brewByWeightOffsetGrams); changed = true; }
} else { Serial.printf("WARNUNG: Ungültiger Wert für Brew-By-Weight Offset empfangen: %s\n", server.arg("bbwOffset").c_str()); }
}
// Pre-Infusion Auswerten (Unverändert, verwendet float)
bool newPIEnabled = server.hasArg("piEnabled");
if (newPIEnabled != preInfusionEnabled) { preInfusionEnabled = newPIEnabled; EEPROM.put(EEPROM_ADDR_PREINF_ENABLED, preInfusionEnabled); changed = true; }
if (server.hasArg("piDurSecs")) {
float newPIDur = server.arg("piDurSecs").toFloat();
if (newPIDur >= 0.5f && newPIDur <= 20.0f) { if(abs(newPIDur - preInfusionDurationSeconds) > 0.01f) { preInfusionDurationSeconds = newPIDur; EEPROM.put(EEPROM_ADDR_PREINF_DUR_SEC, preInfusionDurationSeconds); changed = true; } }
else { Serial.printf("WARNUNG: Ungültiger Wert für Pre-Infusion Dauer empfangen: %s\n", server.arg("piDurSecs").c_str()); }
}
if (server.hasArg("piPauseSecs")) {
float newPIPause = server.arg("piPauseSecs").toFloat();
if (newPIPause >= 0.0f && newPIPause <= 20.0f) { if(abs(newPIPause - preInfusionPauseSeconds) > 0.01f) { preInfusionPauseSeconds = newPIPause; EEPROM.put(EEPROM_ADDR_PREINF_PAUSE_SEC, preInfusionPauseSeconds); changed = true; } }
else { Serial.printf("WARNUNG: Ungültiger Wert für Pre-Infusion Pause empfangen: %s\n", server.arg("piPauseSecs").c_str()); }
}
if (changed) {
if (EEPROM.commit()) { Serial.println("Brew Control Einstellungen (inkl. Weight) erfolgreich gespeichert."); }
else { Serial.println("FEHLER: EEPROM Commit für Brew Control Einstellungen fehlgeschlagen!"); }
}
server.sendHeader("Location", "/Brew-Control");
server.send(303);
}
#endif // ESP32 - Ende der Handler für Brew Control
/************************************************************************************
* PROGMEM Chunks für die Info-Seite (/Info)
************************************************************************************/
// --- Kopfzeile ---
static const char infoHtmlHead[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<title>Info</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
)rawliteral";
static const char infoHtmlHeadEnd[] PROGMEM = R"rawliteral(
</head>
)rawliteral";
// --- Geräteinfo Formular ---
static const char infoChunk_BodyStart[] PROGMEM = R"rawliteral(
<body>
<h1>Info</h1>
<form action='/updateInfoSettings' method='POST'>
<h3>Ger&auml;teinfo</h3>
Die Ger&auml;teinformationen werden beim Start der Maschine, bzw. PID-Controllers im Display angezeigt.
<br><br>
<label for='hersteller'>Hersteller:</label>
<input type='text' id='hersteller' name='hersteller' value=')rawliteral"; // Endet vor Hersteller-Wert
static const char infoChunk_AfterHersteller[] PROGMEM = R"rawliteral('>
<label for='modell'>Modell:</label>
<input type='text' id='modell' name='modell' value=')rawliteral"; // Endet vor Modell-Wert
static const char infoChunk_AfterModell[] PROGMEM = R"rawliteral('>
<label for='zusatz'>Zusatz (z.B. Limited Edition):</label>
<input type='text' id='zusatz' name='zusatz' value=')rawliteral"; // Endet vor Zusatz-Wert
static const char infoChunk_AfterZusatz[] PROGMEM = R"rawliteral('>
<input type='submit' value='Einstellungen speichern'>
</form>
)rawliteral"; // Ende Geräteinfo-Formular
// --- Betriebszeit Reset Formular ---
static const char infoChunk_RuntimeResetForm[] PROGMEM = R"rawliteral(
<form action='/resetRuntime' method='POST'>
<h3>Betriebszeit:</h3>
)rawliteral"; // Endet vor formatierter Betriebszeit
static const char infoChunk_AfterRuntime[] PROGMEM = R"rawliteral(<br>
<input type='submit' value='Zur&uuml;cksetzen'>
</form>
)rawliteral"; // Ende Betriebszeit-Reset-Formular
// --- Shot Zähler Reset Formular ---
static const char infoChunk_ShotCounterResetForm[] PROGMEM = R"rawliteral(
<form action='/resetShots' method='POST'>
<h3>Shots: (Bez&uuml;ge &uuml;ber 20 Sekunden)</h3>
Anzahl: )rawliteral"; // Endet vor Shot-Zähler-Wert
static const char infoChunk_ShotCounterFormEnd[] PROGMEM = R"rawliteral(<br>
<input type='submit' value='Zur&uuml;cksetzen'>
</form>
)rawliteral"; // Ende Shot-Zähler-Reset-Formular
// --- Statistik Chunks ---
static const char infoChunk_StatsStart[] PROGMEM = R"rawliteral(
<form method='GET' action='/downloadNutzungsstatistik' target='_blank'enctype='multipart/form-data'>
<h3>Statistik</h3>
<p>
)rawliteral"; // Start Statistik-Sektion
static const char infoChunk_StatsTotal[] PROGMEM = R"rawliteral(Bez&uuml;ge in der Statistik: )rawliteral";
static const char infoChunk_StatsAvg[] PROGMEM = R"rawliteral(<br>Durchschnittliche Bezugsdauer: )rawliteral";
static const char infoChunk_StatsAvgPerDay[] PROGMEM = R"rawliteral(<br>Durchschnittliche Bez&uuml;ge pro Tag: )rawliteral";
static const char infoChunk_StatsToday[] PROGMEM = R"rawliteral(<br><br><b>Anzahl Bez&uuml;ge:</b></br>Heute: )rawliteral";
static const char infoChunk_StatsYesterday[] PROGMEM = R"rawliteral(<br>Gestern: )rawliteral";
static const char infoChunk_StatsWeek[] PROGMEM = R"rawliteral(<br>Diese Woche: )rawliteral";
static const char infoChunk_StatsLastWeek[] PROGMEM = R"rawliteral(<br>Letzte Woche: )rawliteral";
static const char infoChunk_StatsMonth[] PROGMEM = R"rawliteral(<br>Dieses Monat: )rawliteral";
static const char infoChunk_StatsLastMonth[] PROGMEM = R"rawliteral(<br>Letzes Monat: )rawliteral";
static const char infoChunk_StatsFirst[] PROGMEM = R"rawliteral(<br><br>Erster geloggter Bezug: )rawliteral";
static const char infoChunk_StatsLast[] PROGMEM = R"rawliteral(<br>Letzter geloggter Bezug: )rawliteral";
static const char infoChunk_StatsTimeError[] PROGMEM = R"rawliteral(<br><small>(Datum/Zeit-basierte Z&auml;hler ben&ouml;tigen aktive WLAN-Verbindung und NTP-Synchronisation)</small>)rawliteral";
// --- Statistik Download
static const char infoChunk_StatsDownloadButton[] PROGMEM = R"rawliteral(
<br><button type='submit' >Nutzungsverlauf herunterladen</button>
)rawliteral";
// --- Statistik entfernen
static const char infoChunk_DeleteStatsForm[] PROGMEM = R"rawliteral(
<form action='/deleteStatistics' method='POST' onsubmit='return confirm("Sicher, dass die aktuelle Nutzungsstatistik (Nutzungsstatistik.csv) entfernt werden soll?");'>
<h3>Nutzungsstatistik zur&uuml;cksetzen</h3>
Die aktuelle Statistik wird gel&ouml;scht und es kann eine neue Statistik begonnen werden.</br>
<button type='submit' class='danger'>Zur&uuml;cksetzen</button>
</form>
)rawliteral";
static const char infoChunk_StatsEnd[] PROGMEM = R"rawliteral(
</form>
)rawliteral"; // Ende Statistik-Sektion (mit Verlauf)
static const char infoChunk_StatsNotAvailable[] PROGMEM = R"rawliteral(
<form>
<h3>Shot-Verlauf Statistik</h3>
<p>Kein Shot-Verlauf gefunden oder Datei ist leer.</p>
<p><small>(Bez&uuml;ge werden erst nach erfolgreicher NTP-Zeitsynchronisation geloggt.<br>Hierzu wird eine Internetverbindung ben&ouml;tigt!)</small></p>
</form>
)rawliteral"; // Meldung, falls kein Verlauf da
// --- Systeminfo Chunks (Angepasst für NTP-Status) ---
static const char infoChunk_SysInfoStart[] PROGMEM = R"rawliteral(
<form>
<h3>Systeminfo</h3>
Freier Heap: )rawliteral"; // Endet VOR dem Heap-Wert
// NEU/Angepasst: Nach dem Heap-Wert und vor dem NTP-Statuswert
static const char infoChunk_AfterFreeHeap[] PROGMEM = R"rawliteral( Bytes<br>
NTP Zeit synchronisiert: )rawliteral"; // Endet VOR dem NTP-Status (Ja/Nein)
// NEU: Nach dem NTP-Statuswert und vor der SDK-Version
static const char infoChunk_AfterNTPStatus[] PROGMEM = R"rawliteral(<br>
SDK-Version: )rawliteral"; // Endet VOR dem SDK-Wert
// Bestehende Chunks für restliche Systeminfos
static const char infoChunk_AfterSDK[] PROGMEM = R"rawliteral(<br>
Core-Version: )rawliteral";
static const char infoChunk_AfterCore[] PROGMEM = R"rawliteral(<br>
Boot-Version: )rawliteral";
static const char infoChunk_AfterBoot[] PROGMEM = R"rawliteral(<br>
Chip-ID: )rawliteral";
static const char infoChunk_AfterChipID[] PROGMEM = R"rawliteral(<br>
CPU-Takt: )rawliteral";
static const char infoChunk_AfterCPU[] PROGMEM = R"rawliteral( MHz<br>
Letzter Reset-Grund: )rawliteral";
static const char infoChunk_AfterResetReason[] PROGMEM = R"rawliteral(<br>
<br>
Sketch-Gr&ouml;&szlig;e: )rawliteral";
static const char infoChunk_AfterSketchSize[] PROGMEM = R"rawliteral( Bytes<br>
Freier Sketch-Speicher: )rawliteral";
static const char infoChunk_AfterFreeSketch[] PROGMEM = R"rawliteral( Bytes<br>
Flash-Gr&ouml;&szlig;e (Chip): )rawliteral";
static const char infoChunk_End[] PROGMEM = R"rawliteral( Bytes<br>
</form>
</body>
</html>
)rawliteral"; // Ende Systeminfo und HTML-Seite
/************************************************************************************
* Handler für die Info-Seite - Inkl. NTP-Status, Download-Button,
* neuer Statistiken, Lösch-Button und plattformabhängiger Systeminfos
************************************************************************************/
/************************************************************************************
* Handler für die Info-Seite - Inkl. NTP-Status, Download-Button,
* neuer Statistiken, Lösch-Button und plattformabhängiger Systeminfos
* - Korrigierte String-Verknüpfung für ESP32 Reset Reason
************************************************************************************/
void handleInfo() // URL: /Info
{
char buffer[40]; // Allzweck-Puffer für Zahlen etc.
char dateBuffer[25]; // Puffer für Datumsformatierung
char tempStringCopyBuffer[50]; // Temp Puffer für String-Kopien
server.setContentLength(CONTENT_LENGTH_UNKNOWN); // Wichtig für Chunked Sending
server.send(200, F("text/html"), ""); // Header senden
// --- Kopfzeile, Styles, Navigation senden ---
server.sendContent_P(infoHtmlHead);
server.sendContent_P(commonStyle); // Globale Styles nicht vergessen
server.sendContent_P(infoHtmlHeadEnd);
server.sendContent_P(commonNav); // Globale Navigation nicht vergessen
yield();
// --- Geräteinfo Formular ---
server.sendContent_P(infoChunk_BodyStart); // Start Body, H1, Start Geräteinfo-Form
// Sicherstellen, dass Strings nicht über Puffer laufen und terminiert sind
strncpy(tempStringCopyBuffer, infoHersteller, sizeof(tempStringCopyBuffer) - 1);
tempStringCopyBuffer[sizeof(tempStringCopyBuffer) - 1] = '\0';
if (strlen(tempStringCopyBuffer) > 0) { server.sendContent(tempStringCopyBuffer); } // Wert einfügen
server.sendContent_P(infoChunk_AfterHersteller); // Label+Input für Modell
yield();
strncpy(tempStringCopyBuffer, infoModell, sizeof(tempStringCopyBuffer) - 1);
tempStringCopyBuffer[sizeof(tempStringCopyBuffer) - 1] = '\0';
if (strlen(tempStringCopyBuffer) > 0) { server.sendContent(tempStringCopyBuffer); } // Wert einfügen
server.sendContent_P(infoChunk_AfterModell); // Label+Input für Zusatz
yield();
strncpy(tempStringCopyBuffer, infoZusatz, sizeof(tempStringCopyBuffer) - 1);
tempStringCopyBuffer[sizeof(tempStringCopyBuffer) - 1] = '\0';
if (strlen(tempStringCopyBuffer) > 0) { server.sendContent(tempStringCopyBuffer); } // Wert einfügen
server.sendContent_P(infoChunk_AfterZusatz); // Ende Geräteinfo-Form
yield();
// --- Betriebszeit Reset Formular ---
server.sendContent_P(infoChunk_RuntimeResetForm); // Start Betriebszeit-Reset-Form, endet vor formatierter Zeit
// Formatierte Betriebszeit berechnen und senden
unsigned long totalSeconds = totalRuntime;
unsigned long days = totalSeconds / 86400;
unsigned long hours = (totalSeconds % 86400) / 3600;
unsigned long minutes = (totalSeconds % 3600) / 60;
snprintf(buffer, sizeof(buffer), "%lu", days);
server.sendContent(buffer);
server.sendContent(F(" Tag(e), "));
snprintf(buffer, sizeof(buffer), "%lu", hours);
server.sendContent(buffer);
server.sendContent(F(" Stunde(n) und "));
snprintf(buffer, sizeof(buffer), "%lu", minutes);
server.sendContent(buffer);
server.sendContent(F(" Minuten"));
server.sendContent_P(infoChunk_AfterRuntime); // Ende Betriebszeit-Reset-Form
yield();
// --- Shot Zähler Reset Formular ---
server.sendContent_P(infoChunk_ShotCounterResetForm); // Start Shot-Reset-Form, endet vor Zähler-Wert
snprintf(buffer, sizeof(buffer), "%lu", shotCounter); // Wert einfügen
server.sendContent(buffer);
server.sendContent_P(infoChunk_ShotCounterFormEnd); // Ende Shot-Reset-Form
yield();
// --- Shot-Verlauf Statistik ---
ShotStats stats = calculateShotStatistics(); // Berechne Statistiken
yield(); // Nach der (potenziell) längeren Berechnung
if (stats.historyAvailable) {
server.sendContent_P(infoChunk_StatsStart); // Start des Statistik-Blocks (<form><h3><p>)
// Bestehende allgemeine Statistiken
server.sendContent_P(infoChunk_StatsTotal); // "Geloggte Bezüge gesamt: "
snprintf(buffer, sizeof(buffer), "%lu", stats.totalShotsLogged);
server.sendContent(buffer);
yield();
server.sendContent_P(infoChunk_StatsAvg); // "<br>Durchschnittliche Bezugsdauer: "
snprintf(buffer, sizeof(buffer), "%.1f", stats.averageDurationSec);
server.sendContent(buffer);
server.sendContent(F(" s")); // Einheit hinzufügen
yield();
// Durchschnitt pro Tag
server.sendContent_P(infoChunk_StatsAvgPerDay); // "<br>Durchschnittliche Bez&uuml;ge pro Tag (seit Log): "
snprintf(buffer, sizeof(buffer), "%.1f", stats.avgShotsPerDay);
server.sendContent(buffer);
yield();
// Bestehende Zeitfenster
server.sendContent_P(infoChunk_StatsToday); // " s<br><br>Bez&uuml;ge Heute: "
snprintf(buffer, sizeof(buffer), "%lu", stats.shotsToday);
server.sendContent(buffer);
yield();
server.sendContent_P(infoChunk_StatsYesterday); // "<br>Bez&uuml;ge Gestern: "
snprintf(buffer, sizeof(buffer), "%lu", stats.shotsYesterday);
server.sendContent(buffer);
yield();
server.sendContent_P(infoChunk_StatsWeek); // "<br>Bez&uuml;ge in dieser Woche: "
snprintf(buffer, sizeof(buffer), "%lu", stats.shotsThisWeek);
server.sendContent(buffer);
yield();
server.sendContent_P(infoChunk_StatsLastWeek); // "<br>Bez&uuml;ge Letzte Woche (Mo-So): "
snprintf(buffer, sizeof(buffer), "%lu", stats.shotsLastWeek);
server.sendContent(buffer);
yield();
server.sendContent_P(infoChunk_StatsMonth); // "<br>Bez&uuml;ge in diesem Monat: "
snprintf(buffer, sizeof(buffer), "%lu", stats.shotsThisMonth);
server.sendContent(buffer);
yield();
server.sendContent_P(infoChunk_StatsLastMonth); // "<br>Bez&uuml;ge Letzter Monat: "
snprintf(buffer, sizeof(buffer), "%lu", stats.shotsLastMonth);
server.sendContent(buffer);
yield(); // Nach mehreren Ausgaben
// Ersten und letzten Shot anzeigen, falls vorhanden (wie bisher)
if (stats.firstShotTimestamp > 0) {
server.sendContent_P(infoChunk_StatsFirst); // "<br><br>Erster geloggter Bezug: "
struct tm first_tm;
localtime_r(&stats.firstShotTimestamp, &first_tm);
strftime(dateBuffer, sizeof(dateBuffer), "%d.%m.%Y %H:%M", &first_tm); // Datum formatieren
server.sendContent(dateBuffer); // Wert senden
server.sendContent(F(" Uhr"));
yield();
}
if (stats.lastShotTimestamp > 0) {
server.sendContent_P(infoChunk_StatsLast); // "<br>Letzter geloggter Bezug: "
struct tm last_tm;
localtime_r(&stats.lastShotTimestamp, &last_tm);
strftime(dateBuffer, sizeof(dateBuffer), "%d.%m.%Y %H:%M", &last_tm); // Datum formatieren
server.sendContent(dateBuffer); // Wert senden
server.sendContent(F(" Uhr"));
yield();
}
// Hinweis auf NTP anzeigen, wenn aktuelle Zeit ungültig erscheint (wie bisher)
time_t now_t_stats;
time(&now_t_stats);
struct tm now_tm_stats;
localtime_r(&now_t_stats, &now_tm_stats);
if (now_tm_stats.tm_year <= (2023 - 1900)) { // Prüft ob aktuelles Jahr > 2023
server.sendContent_P(infoChunk_StatsTimeError); // Sendet den Hinweis
}
// Download Button hinzufügen (wie bisher)
server.sendContent_P(infoChunk_StatsDownloadButton);
server.sendContent_P(infoChunk_StatsEnd); // Ende des Statistik-Blocks (</p></form>)
yield();
} else {
// Alternative Meldung, falls keine Statistik verfügbar (wie bisher)
server.sendContent_P(infoChunk_StatsNotAvailable);
}
// --- Ende Statistikbereich ---
yield();
server.sendContent_P(infoChunk_DeleteStatsForm);
// --- Systeminfo (mit plattformabhängigen Teilen) ---
server.sendContent_P(infoChunk_SysInfoStart); // Start Systeminfo <form>, endet vor Heap-Wert
snprintf(buffer, sizeof(buffer), "%u", ESP.getFreeHeap());
server.sendContent(buffer); // Heap-Wert senden
server.sendContent_P(infoChunk_AfterFreeHeap); // Sendet " Bytes<br> NTP Zeit synchronisiert: "
yield();
// NTP Status senden (Ja/Nein)
server.sendContent(timeSynced ? F("Ja") : F("Nein"));
server.sendContent_P(infoChunk_AfterNTPStatus); // Sendet "<br> SDK-Version: "
yield();
// --- SDK Version ---
const char* sdkVersion = ESP.getSdkVersion();
if (sdkVersion && strlen(sdkVersion) > 0) {
server.sendContent(sdkVersion);
} else {
server.sendContent(F("N/A"));
}
// --- Core Version ---
server.sendContent_P(infoChunk_AfterSDK); // "<br> Core-Version: "
String coreVersionStr = ESP.getCoreVersion();
yield();
strncpy(tempStringCopyBuffer, coreVersionStr.c_str(), sizeof(tempStringCopyBuffer) - 1);
tempStringCopyBuffer[sizeof(tempStringCopyBuffer) - 1] = '\0';
if (strlen(tempStringCopyBuffer) > 0) server.sendContent(tempStringCopyBuffer);
// --- Boot Version (Plattformabhängig) ---
server.sendContent_P(infoChunk_AfterCore); // "<br> Boot-Version: "
#ifdef ESP32
server.sendContent(F("N/A")); // ESP32 hat keine getBootVersion()
#else // ESP8266
snprintf(buffer, sizeof(buffer), "%u", ESP.getBootVersion());
server.sendContent(buffer);
#endif
// --- Chip ID (Plattformabhängig) ---
server.sendContent_P(infoChunk_AfterBoot); // "<br> Chip-ID: "
#ifdef ESP32
uint64_t chipid_64 = ESP.getEfuseMac();
uint32_t chipid_32 = (uint32_t)(chipid_64 >> 16);
snprintf(buffer, sizeof(buffer), "%08X", chipid_32);
#else // ESP8266
snprintf(buffer, sizeof(buffer), "%08X", ESP.getChipId());
#endif
server.sendContent(buffer); // Formatierten Chip-ID Wert senden
// --- CPU Takt ---
server.sendContent_P(infoChunk_AfterChipID); // "<br> CPU-Takt: "
snprintf(buffer, sizeof(buffer), "%u", ESP.getCpuFreqMHz());
server.sendContent(buffer);
// --- Reset Grund (Plattformabhängig & KORRIGIERT) ---
server.sendContent_P(infoChunk_AfterCPU); // " MHz<br> Letzter Reset-Grund: "
#ifdef ESP32
esp_reset_reason_t reason = esp_reset_reason();
switch (reason) {
case ESP_RST_UNKNOWN: server.sendContent(F("Unbekannt")); break;
case ESP_RST_POWERON: server.sendContent(F("Power On")); break;
case ESP_RST_EXT: server.sendContent(F("External Pin")); break;
case ESP_RST_SW: server.sendContent(F("Software")); break;
case ESP_RST_PANIC: server.sendContent(F("Panic/Exception")); break;
case ESP_RST_INT_WDT: server.sendContent(F("Interrupt Watchdog")); break;
case ESP_RST_TASK_WDT: server.sendContent(F("Task Watchdog")); break;
case ESP_RST_WDT: server.sendContent(F("Other Watchdog")); break;
case ESP_RST_DEEPSLEEP: server.sendContent(F("Deep Sleep Wakeup")); break;
case ESP_RST_BROWNOUT: server.sendContent(F("Brownout")); break;
case ESP_RST_SDIO: server.sendContent(F("SDIO")); break;
default:
// *** KORREKTUR: String-Verknüpfung vermeiden ***
server.sendContent(F("Anderer Grund (")); // Teil 1
server.sendContent(String(reason)); // Teil 2 (Grund als Zahl)
server.sendContent(F(")")); // Teil 3
break;
}
#else // ESP8266
String resetReasonStr = ESP.getResetReason();
strncpy(tempStringCopyBuffer, resetReasonStr.c_str(), sizeof(tempStringCopyBuffer) - 1);
tempStringCopyBuffer[sizeof(tempStringCopyBuffer) - 1] = '\0';
if (strlen(tempStringCopyBuffer) > 0) server.sendContent(tempStringCopyBuffer);
#endif
// --- Sketch Größe ---
server.sendContent_P(infoChunk_AfterResetReason); // "<br><br> Sketch-Gr&ouml;&szlig;e: "
snprintf(buffer, sizeof(buffer), "%u", ESP.getSketchSize());
server.sendContent(buffer);
yield();
// --- Freier Sketch Speicher ---
server.sendContent_P(infoChunk_AfterSketchSize); // " Bytes<br> Freier Sketch-Speicher: "
snprintf(buffer, sizeof(buffer), "%u", ESP.getFreeSketchSpace());
server.sendContent(buffer);
// --- Flash Größe ---
server.sendContent_P(infoChunk_AfterFreeSketch); // " Bytes<br> Flash-Gr&ouml;&szlig;e (Chip): "
snprintf(buffer, sizeof(buffer), "%u", ESP.getFlashChipSize());
server.sendContent(buffer);
yield(); // Vor dem letzten Chunk
server.sendContent_P(infoChunk_End); // Ende </body></html>
yield();
server.sendContent(F("")); // Letzter leerer Chunk zum Abschließen der Übertragung
yield();
}
/************************************************************************************
* PROGMEM Chunks für die Service-Seite (/Service)
************************************************************************************/
// --- Kopfzeile ---
static const char serviceHtmlHead[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<title>Service</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
)rawliteral";
// </head> bleibt gleich
static const char serviceHtmlHeadEnd[] PROGMEM = R"rawliteral(
</head>
)rawliteral";
static const char serviceChunk_BodyStart[] PROGMEM = R"rawliteral(
<body>
<h1>Service & Wartung</h1>
)rawliteral";
// --- Wartung Intervall Formular (ehemals infoChunk_MaintenanceForm) ---
static const char serviceChunk_MaintenanceForm[] PROGMEM = R"rawliteral(
<form action='/updateMaintenanceSettings' method='POST'>
<h3>Reinigung & Wartung</h3>
Zeigt nach der eingestellten Anzahl von Bez&uuml;gen eine Reinigungs-/ Wartungserinnerung im Display an.<br>
<label for='maintenanceInterval'>Anzahl an Bez&uuml;gen bis Erinnerung: (0 = deaktiviert)</label>
<input type='number' min='0' step='1' id='maintenanceInterval' name='maintenanceInterval' value=')rawliteral"; // Endet vor Wartungsintervall-Wert
static const char serviceChunk_AfterMaintInterval[] PROGMEM = R"rawliteral('>
<input type='submit' value='Einstellungen speichern'>
</form>
)rawliteral"; // Ende Wartungsintervall-Formular
// --- Wartungsmodus Sektion (ehemals infoChunk_Wartung...) ---
static const char serviceChunk_WartungStart[] PROGMEM = R"rawliteral(
<form action='/toggleWartungsmodus' method='POST'>
<h3>Wartungsmodus (Entkalkung)</h3>
<p>Aktiviert einen Modus zur einfachen Entkalkung der Maschine.<br>
Im Wartungsmodus ist das Heizen von Wasser und Dampf deaktiviert.<br>
Bez&uuml;ge werden in diesem Modus nicht gez&auml;hlt.</p>
<p><b>Aktueller Status:</b> )rawliteral"; // Endet vor dem Status (Aktiv/Inaktiv)
static const char serviceChunk_WartungButton[] PROGMEM = R"rawliteral(</p>
<button type='submit'>Wartungsmodus )rawliteral"; // Endet vor dem Button-Text (Aktivieren/Deaktivieren)
static const char serviceChunk_WartungEnd[] PROGMEM = R"rawliteral(</button>
</form>
)rawliteral"; // Ende Wartungsmodus-Formular
// --- Wartungszähler Reset Formular (ehemals infoChunk_MaintenanceResetForm) ---
static const char serviceChunk_MaintenanceResetForm[] PROGMEM = R"rawliteral(
<form action='/resetMaintenance' method='POST'>
<h3>Wartungsz&auml;hler zur&uuml;cksetzen</h3>
Aktueller Z&auml;hlerstand: )rawliteral"; // Endet vor Wartungszähler-Wert
static const char serviceChunk_AfterMaintCounter[] PROGMEM = R"rawliteral(<br>
<br>
Setzt den Z&auml;hler f&uuml;r die Reinigungs- & Wartungserinnerung zur&uuml;ck.<br>
Dies ist nach der erfolgreichen Durchf&uuml;hrung notwendig,<br>
damit die Meldung nicht nach jedem Bezug erneut scheint.<br>
<input type='submit' value='Zur&uuml;cksetzen'>
</form>
)rawliteral"; // Ende Wartungszähler-Reset-Formular
static const char serviceChunk_BodyEnd[] PROGMEM = R"rawliteral(
</body>
</html>
)rawliteral";
/************************************************************************************
* Handler für die Service-Seite (/Service)
************************************************************************************/
void handleService() {
char buffer[20]; // Puffer für Zahlen
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, F("text/html"), ""); // Header senden
// --- Kopfzeile, Styles, Navigation senden ---
server.sendContent_P(serviceHtmlHead);
server.sendContent_P(commonStyle); // Globale Styles nicht vergessen
server.sendContent_P(serviceHtmlHeadEnd);
server.sendContent_P(commonNav); // Globale Navigation nicht vergessen
yield();
// --- Body Start ---
server.sendContent_P(serviceChunk_BodyStart);
yield();
// --- Wartung Intervall Formular ---
server.sendContent_P(serviceChunk_MaintenanceForm); // Start Wartungs-Form, endet vor Intervall-Wert
snprintf(buffer, sizeof(buffer), "%d", maintenanceInterval);
server.sendContent(buffer); // Wert einfügen
server.sendContent_P(serviceChunk_AfterMaintInterval); // Ende Wartungs-Form
yield();
// --- Wartungsmodus Sektion ---
server.sendContent_P(serviceChunk_WartungStart); // Start der Sektion
if (wartungsModusAktiv) {
server.sendContent(F("Aktiv")); // Zeige "Aktiv"
} else {
server.sendContent(F("Inaktiv")); // Zeige "Inaktiv"
}
yield();
server.sendContent_P(serviceChunk_WartungButton); // Bis vor Button-Text
yield();
if (wartungsModusAktiv) {
server.sendContent(F("Deaktivieren")); // Button zum Deaktivieren
} else {
server.sendContent(F("Aktivieren")); // Button zum Aktivieren
}
yield();
server.sendContent_P(serviceChunk_WartungEnd); // Rest des Formulars
yield();
// --- Wartungszähler Reset Formular ---
server.sendContent_P(serviceChunk_MaintenanceResetForm); // Start Zähler-Reset-Form, endet vor Zähler-Wert
snprintf(buffer, sizeof(buffer), "%lu", maintenanceIntervalCounter);
server.sendContent(buffer); // Wert einfügen
server.sendContent_P(serviceChunk_AfterMaintCounter); // Ende Zähler-Reset-Form
yield();
// --- Body Ende ---
server.sendContent_P(serviceChunk_BodyEnd);
yield();
// --- Abschluss ---
server.sendContent(F("")); // Letzter leerer Chunk zum Abschließen der Übertragung
yield();
}
/************************************************************************************
* Handler für die Eco-Modus-Konfiguration - Heap-Optimiert mit Chunked Sending
************************************************************************************/
// --- PROGMEM Chunks für handleEco ---
// Kopfzeile bleibt gleich wie im Original `ecoHtml`
static const char ecoHtmlHead[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<title>Eco-Modus</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
)rawliteral";
// </head> bleibt gleich wie im Original `ecoHtml2`
static const char ecoHtmlHeadEnd[] PROGMEM = R"rawliteral(
</head>
)rawliteral";
// Aufteilung von ecoHtml3 in Chunks
static const char ecoChunk_BodyStart[] PROGMEM = R"rawliteral(
<body>
<h1>Eco-Modus</h1>
<form action='/updateEcoSettings' method='POST'>
<h3>Eco-Modus</h3>
Der Eco-Modus senkt die voreingestellten Temperaturen nach der angegebenen Zeit auf die Eco-Temperaturen ab.<br>
<label for='ecoMode'>Eco-Modus (Minuten, 0 = deaktiviert):</label>
<input type='number' min='0' step='1' id='ecoMode' name='ecoMode' value=')rawliteral"; // Endet vor {ECO_MODE}
static const char ecoChunk_AfterEcoMode[] PROGMEM = R"rawliteral('>
<label for='ecoModeTempWasser'>Eco-Temperatur Wasser:</label>
<input type='number' min='0' max='150'step='1' id='ecoModeTempWasser' name='ecoModeTempWasser' value=')rawliteral"; // Endet vor {ECO_MODE_TEMP_WASSER}
static const char ecoChunk_AfterTempW[] PROGMEM = R"rawliteral('>
<label for='ecoModeTempDampf'>Eco-Temperatur Dampf:</label>
<input type='number' min='0' max='200'step='1' id='ecoModeTempDampf' name='ecoModeTempDampf' value=')rawliteral"; // Endet vor {ECO_MODE_TEMP_DAMPF}
static const char ecoChunk_AfterTempD[] PROGMEM = R"rawliteral('>
<div style='margin-bottom:15px;'>
<br>
<h3>Dynamischer Eco-Modus</h3>
<label for='fast-heat-up-aktivieren' style='display:inline-block; margin-right:10px;'>
Dynamischen Eco-Modus aktivieren (ECO+):
<input type='checkbox' id='dynamicEcoMode' name='dynamicEcoMode' value='1')rawliteral"; // Endet vor {DYNAMIC_ECO_MODE_CHECKBOX}
static const char ecoChunk_AfterDynCheck[] PROGMEM = R"rawliteral(>
</label>
</div>
<div style='margin-top:15px;'>
Der dynamische Eco-Modus verhindert, dass die Maschine bis in die Unendlichkeit eine gewisse Temperatur aufrecht erh&auml;lt.<br>
Er senkt die Temperatur pro Minute um ein weiteres Grad ab, bis letzlich das Heizen vollst&auml;ndig beendet wird.<br>
Der dynamische Eco-Modus ist nur in Kombination mit dem Eco-Modus nutzbar!<br>
<br>
<b>Beispiel f&uuml;r eine praktische Anwendung:</b><br>
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&uuml;hlt und er muss erneut das Aufheizen abwarten ...<br>
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.<br>
Markus muss zwar auch warten, kann jedoch noch vor Peter einen Espresso trinken.
</div><br>
<h3>Verz&ouml;gerung f&uuml;r Dampf</h3>
<label for='dampfDelay'>Aufheizen f&uuml;r Dampf verz&ouml;gern (Minuten, 0 = deaktiviert):</label>
<input type='number' min='0' step='1' id='dampfDelay' name='dampfDelay' value=')rawliteral"; // Endet vor {DAMPF_DELAY}
static const char ecoChunk_AfterDampfDelay[] PROGMEM = R"rawliteral('>
<br><b style='color: #FFCC00;'>ACHTUNG:</b><br>
Um Konflikte zu vermeiden, sollte der Wert geringer sein, als der des Eco-Modus, falls dieser aktiviert ist!
<br>
<input type='submit' value='Einstellungen speichern'>
</form>
</body>
</html>
)rawliteral";
// --- Überarbeitete Funktion handleEco ---
void handleEco() // URL: /ECO
{
char buffer[12]; // Puffer für Zahlenumwandlungen
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, F("text/html"), "");
// Kopfzeile senden
server.sendContent_P(ecoHtmlHead);
server.sendContent_P(commonStyle);
server.sendContent_P(ecoHtmlHeadEnd);
server.sendContent_P(commonNav); // Navigation
// Body Start bis Eco Mode Wert
server.sendContent_P(ecoChunk_BodyStart);
// Eco Mode Wert (int)
snprintf(buffer, sizeof(buffer), "%d", ecoModeMinutes);
server.sendContent(buffer);
// Nach Eco Mode bis Eco Temp Wasser
server.sendContent_P(ecoChunk_AfterEcoMode);
// Eco Temp Wasser Wert (int)
snprintf(buffer, sizeof(buffer), "%d", ecoModeTempWasser);
server.sendContent(buffer);
// Nach Eco Temp Wasser bis Eco Temp Dampf
server.sendContent_P(ecoChunk_AfterTempW);
// Eco Temp Dampf Wert (int)
snprintf(buffer, sizeof(buffer), "%d", ecoModeTempDampf);
server.sendContent(buffer);
// Nach Eco Temp Dampf bis Dynamic Eco Checkbox
server.sendContent_P(ecoChunk_AfterTempD);
// Dynamic Eco checked Attribut (falls nötig)
if (dynamicEcoActive) {
server.sendContent(F(" checked"));
}
// Nach Dynamic Eco Checkbox bis Dampf Delay
server.sendContent_P(ecoChunk_AfterDynCheck);
// Dampf Delay Wert (int)
snprintf(buffer, sizeof(buffer), "%d", dampfVerzoegerung);
server.sendContent(buffer);
// Rest der Seite
server.sendContent_P(ecoChunk_AfterDampfDelay);
// Letzter leerer Chunk zum Abschließen
server.sendContent(F(""));
}
/************************************************************************************
* Handler für die Fast-Heat-Up Seite - Heap-Optimiert mit Chunked Sending
************************************************************************************/
// --- PROGMEM Chunks für handleFastHeatUp ---
// Kopfzeile bleibt gleich wie im Original `fhuHtml`
static const char fhuHtmlHead[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<title>Fast-Heat-Up-Modus</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
)rawliteral";
// </head> bleibt gleich wie im Original `fhuHtml2`
static const char fhuHtmlHeadEnd[] PROGMEM = R"rawliteral(
</head>
)rawliteral";
// Aufteilung von fhuHtml3 in Chunks
// Teil 1: Bis zum Ende des <input type='checkbox' ... value='1' Teils
static const char fhuChunk1[] PROGMEM = R"rawliteral(
<body>
<h1>Fast-Heat-Up-Modus</h1>
<form action='/updateFast-Heat-Up-Settings' method='POST'>
<h3>Fast-Heat-Up-Modus</h3>
<div style='margin-bottom:15px;'>
<label for='fast-heat-up-aktivieren' style='display:inline-block; margin-right:10px;'>
Fast-Heat-Up aktivieren:
</label>
<input type='checkbox' id='fast-heat-up-aktivieren' name='fastHeatUpAktiv' value='1')rawliteral"; // Endet direkt vor {FASTHEATUP_MODE_CHECKBOX}
// Teil 2: Vom '>' nach der Checkbox bis zum Ende
static const char fhuChunk2[] PROGMEM = R"rawliteral(>
</div>
<div style='margin-top:15px;'>
Der Fast-Heat-Up-Modus erm&ouml;glicht es, die Maschine noch schneller aufzuheizen.<br>
Der Kessel wird beim Start auf 130 Grad Celsius erhitzt.<br>
Nachdem die Temperatur erreicht ist, muss ein Flush von ca. 20 Sekunden durchgef&uuml;hrt werden.<br>
<br>
<b style='color: #FFCC00;'>ACHTUNG:</b><br>
Bitte beachten, dass auf der Seite PID-Einstellung eventuell die max. Wassertemperatur der Sicherheitsabschaltung angepasst werden muss!<br>
</div>
<input type='submit' value='Einstellungen speichern' style='margin-top:20px;'>
</form>
</body>
</html>
)rawliteral";
// --- Überarbeitete Funktion handleFastHeatUp ---
void handleFastHeatUp() // URL: /Fast-Heat-Up
{
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, F("text/html"), "");
// Kopfzeile senden
server.sendContent_P(fhuHtmlHead);
server.sendContent_P(commonStyle);
server.sendContent_P(fhuHtmlHeadEnd);
server.sendContent_P(commonNav); // Navigation
// Chunk 1 senden (bis zur Checkbox)
server.sendContent_P(fhuChunk1);
// "checked" Attribut senden, falls Fast-Heat-Up aktiv ist
if (fastHeatUpAktiv) {
server.sendContent(F(" checked"));
}
// Chunk 2 senden (Rest der Seite)
server.sendContent_P(fhuChunk2);
// Letzter leerer Chunk zum Abschließen
server.sendContent(F(""));
}
/************************************************************************************
* Handler zum Speichern der Fast-Heat-Up-Einstellung - URL angepasst
************************************************************************************/
void handleFastHeatUpSettings() {
if (server.hasArg(F("fastHeatUpAktiv"))) {
fastHeatUpAktiv = true;
} else {
fastHeatUpAktiv = false;
}
EEPROM.put(EEPROM_ADDR_FASTHEATUP_DATA, fastHeatUpAktiv);
EEPROM.commit();
server.sendHeader(F("Location"), F("/Fast-Heat-Up")); // URL angepasst
server.send(303);
}
/************************************************************************************
* Handler für Root (PID-Einstellungen) - Heap-Optimiert mit Chunked Sending
************************************************************************************/
// --- PROGMEM Chunks für handleRoot ---
// Kopfzeile bleibt gleich wie im Original `rootHtml`
static const char rootHtmlHead[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<title>PID-Einstellung</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
)rawliteral";
// </head> bleibt gleich wie im Original `rootHtml2`
static const char rootHtmlHeadEnd[] PROGMEM = R"rawliteral(
</head>
)rawliteral";
// Aufteilung von rootHtml3 in Chunks
static const char rootChunk_BodyStart[] PROGMEM = R"rawliteral(
<body>
<h1>PID-Einstellung</h1>
)rawliteral";
// Nach {SENSOR_ERROR_MESSAGES} bis value='{WASSER}'
static const char rootChunk_FormStart[] PROGMEM = R"rawliteral(
<form action='/updateSettings' method='POST'>
<h3>Temperatur: Wasser / Kessel</h3>
<label for='wasser'>Setpoint (&deg;C):</label>
<input type='number' min='0' max='135' step='1' id='wasser' name='wasser' value=')rawliteral";
// Nach {WASSER} bis value='{OFFSET_WASSER}'
static const char rootChunk_AfterWasser[] PROGMEM = R"rawliteral('>
<label for='offsetWasser'>Offset (&deg;C):</label>
<input type='number' step='0.01' id='offsetWasser' name='offsetWasser' value=')rawliteral";
// Nach {OFFSET_WASSER} bis {BOOST_WASSER_CHECKED}
static const char rootChunk_AfterOffsetW[] PROGMEM = R"rawliteral('>
<label for='boostWasser' style='display:inline-block; margin-right:10px;'>
Boost-Funktion:
</label>
<input type='checkbox' id='boostWasser' name='boostWasser' value='1')rawliteral"; // Endet direkt vor dem checked Attribut
// Nach {BOOST_WASSER_CHECKED} bis {PREVENTHEAT_WASSER_CHECKED}
static const char rootChunk_AfterBoostW[] PROGMEM = R"rawliteral(>
<br>
<small style='color: #ccc;'>(Volle Heizleistung erzwingen bis 95% der Zieltemperatur)</small>
<br>
<label for='preventHeatWasser' style='display:inline-block; margin-right:10px;'>
Heizen oberhalb Setpoint verhindern:
</label>
<input type='checkbox' id='preventHeatWasser' name='preventHeatWasser' value='1')rawliteral"; // Endet direkt vor dem checked Attribut
// Nach {PREVENTHEAT_WASSER_CHECKED} bis value='{DAMPF}'
static const char rootChunk_AfterPreventW[] PROGMEM = R"rawliteral(>
<br>
<small style='color: #ccc;'>(Kein Heizen, wenn Temp. > Sollwert)</small>
<br><br>
<h3>Temperatur: Dampf / Thermoblock</h3>
<label for='dampf'>Setpoint (&deg;C):</label>
<input type='number' min='0' max='200' step='1' id='dampf' name='dampf' value=')rawliteral";
// Nach {DAMPF} bis value='{OFFSET_DAMPF}'
static const char rootChunk_AfterDampf[] PROGMEM = R"rawliteral('>
<label for='offsetDampf'>Offset (&deg;C):</label>
<input type='number' step='0.01' id='offsetDampf' name='offsetDampf' value=')rawliteral";
// Nach {OFFSET_DAMPF} bis {BOOST_DAMPF_CHECKED}
static const char rootChunk_AfterOffsetD[] PROGMEM = R"rawliteral('>
<label for='boostDampf' style='display:inline-block; margin-right:10px;'>
Boost-Funktion:
</label>
<input type='checkbox' id='boostDampf' name='boostDampf' value='1')rawliteral"; // Endet direkt vor dem checked Attribut
// Nach {BOOST_DAMPF_CHECKED} bis {PREVENTHEAT_DAMPF_CHECKED}
static const char rootChunk_AfterBoostD[] PROGMEM = R"rawliteral(>
<br>
<small style='color: #ccc;'>(Volle Heizleistung erzwingen bis 90% der Zieltemperatur)</small>
<br>
<label for='preventHeatDampf' style='display:inline-block; margin-right:10px;'>
Heizen oberhalb Setpoint verhindern:
</label>
<input type='checkbox' id='preventHeatDampf' name='preventHeatDampf' value='1')rawliteral"; // Endet direkt vor dem checked Attribut
// Nach {PREVENTHEAT_DAMPF_CHECKED} bis value='{KP_WASSER}'
static const char rootChunk_AfterPreventD[] PROGMEM = R"rawliteral(>
<br>
<small style='color: #ccc;'>(Kein Heizen, wenn Temp. > Sollwert)</small>
<br><br>
<h3>PID-Parameter: Wasser / Kessel</h3>
<label for='kpWasser'>Kp:</label>
<input type='number' step='0.01' id='kpWasser' name='kpWasser' value=')rawliteral";
// Nach {KP_WASSER} bis value='{KI_WASSER}'
static const char rootChunk_AfterKpW[] PROGMEM = R"rawliteral('>
<label for='kiWasser'>Ki:</label>
<input type='number' step='0.01' id='kiWasser' name='kiWasser' value=')rawliteral";
// Nach {KI_WASSER} bis value='{KD_WASSER}'
static const char rootChunk_AfterKiW[] PROGMEM = R"rawliteral('>
<label for='kdWasser'>Kd:</label>
<input type='number' step='0.01' id='kdWasser' name='kdWasser' value=')rawliteral";
// Nach {KD_WASSER} bis value='{WINDOW_SIZE_WASSER}'
static const char rootChunk_AfterKdW[] PROGMEM = R"rawliteral('>
<label for='windowSizeWasser'>Zeitfenster (ms):</label>
<input type='number' step='0.01' id='windowSizeWasser' name='windowSizeWasser' value=')rawliteral";
// Nach {WINDOW_SIZE_WASSER} bis value='{KP_DAMPF}'
static const char rootChunk_AfterWindowW[] PROGMEM = R"rawliteral('>
</br>
<h3>PID-Parameter: Dampf / Thermoblock</h3>
<label for='kpDampf'>Kp:</label>
<input type='number' step='0.01' id='kpDampf' name='kpDampf' value=')rawliteral";
// Nach {KP_DAMPF} bis value='{KI_DAMPF}'
static const char rootChunk_AfterKpD[] PROGMEM = R"rawliteral('>
<label for='kiDampf'>Ki:</label>
<input type='number' step='0.01' id='kiDampf' name='kiDampf' value=')rawliteral";
// Nach {KI_DAMPF} bis value='{KD_DAMPF}'
static const char rootChunk_AfterKiD[] PROGMEM = R"rawliteral('>
<label for='kdDampf'>Kd:</label>
<input type='number' step='0.01' id='kdDampf' name='kdDampf' value=')rawliteral";
// Nach {KD_DAMPF} bis value='{WINDOW_SIZE_DAMPF}'
static const char rootChunk_AfterKdD[] PROGMEM = R"rawliteral('>
<label for='windowSizeDampf'>Zeitfenster (ms):</label>
<input type='number' step='0.01' id='windowSizeDampf' name='windowSizeDampf' value=')rawliteral";
// Nach {WINDOW_SIZE_DAMPF} bis value='{MAX_TEMP_WASSER}'
static const char rootChunk_AfterWindowD[] PROGMEM = R"rawliteral('>
</br>
<h3>Sicherheitsabschaltung</h3>
Die Sicherheitsabschaltung bietet eine softwareseitige &Uuml;bertemperatursicherung,<br>
ersetzt jedoch keine hardwareseitige L&ouml;sung durch z.B. Bimetall Temperaturschalter!<br>
Bei der Verwendung von Fast-Heat-Up muss der Wert f&uuml;r die Maximaltemperatur angepasst werden!<br>
<br>
<label for='maxTempWasser'>Max. Temp Wasser (&deg;C):</label>
<input type='number' step='0.01' id='maxTempWasser' name='maxTempWasser' value=')rawliteral";
// Nach {MAX_TEMP_WASSER} bis value='{MAX_TEMP_DAMPF}'
static const char rootChunk_AfterMaxW[] PROGMEM = R"rawliteral('>
<label for='maxTempDampf'>Max. Temp Dampf (&deg;C):</label>
<input type='number' step='0.01' id='maxTempDampf' name='maxTempDampf' value=')rawliteral";
// Nach {MAX_TEMP_DAMPF} bis Ende
static const char rootChunk_FormEnd[] PROGMEM = R"rawliteral('>
<input type='submit' value='Einstellungen speichern'>
</form>
</body>
</html>
)rawliteral";
// Überarbeitete Funktion handleRoot
void handleRoot() {
char buffer[20]; // Puffer für Zahlenumwandlungen (float/int/ulong)
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, F("text/html"), "");
// Kopfzeile senden
server.sendContent_P(rootHtmlHead);
server.sendContent_P(commonStyle);
server.sendContent_P(rootHtmlHeadEnd);
server.sendContent_P(commonNav); // Navigation
// Body Start
server.sendContent_P(rootChunk_BodyStart);
// --- Dynamische Fehlermeldungen generieren und senden ---
String errorMessageHtml = ""; // Kleiner temporärer String ist ok
if (demoModus) {
errorMessageHtml = "<div class=\"sensor-error-message\"><b>Demo-Betrieb:</b><br>Es sind keine Sensoren angeschlossen und daher keine Temperaturwerte verf&uuml;gbar.</div>";
} else if (wasserSensorError && dampfSensorError) {
errorMessageHtml = "<div class=\"sensor-error-message\">Die Daten der Temperatursensoren sind derzeit nicht verf&uuml;gbar!<br>Bitte Temperatur-Sensoren pr&uuml;fen!</div>";
} else if (wasserSensorError) {
errorMessageHtml = "<div class=\"sensor-error-message\">Temperaturwert f&uuml;r Wasser nicht verf&uuml;gbar</div>";
} else if (dampfSensorError) {
errorMessageHtml = "<div class=\"sensor-error-message\">Temperaturwert f&uuml;r Dampf nicht verf&uuml;gbar</div>";
}
if (errorMessageHtml.length() > 0) {
server.sendContent(errorMessageHtml); // Fehler als Chunk senden
}
// Formular Anfang bis Wasser Setpoint
server.sendContent_P(rootChunk_FormStart);
// Wasser Setpoint Wert
snprintf(buffer, sizeof(buffer), "%d", (int)SetpointWasser);
server.sendContent(buffer);
// Nach Wasser Setpoint bis Wasser Offset
server.sendContent_P(rootChunk_AfterWasser);
// Wasser Offset Wert
snprintf(buffer, sizeof(buffer), "%.2f", OffsetWasser);
server.sendContent(buffer);
// Nach Wasser Offset bis Boost Wasser Checkbox
server.sendContent_P(rootChunk_AfterOffsetW);
// Boost Wasser checked Attribut (falls nötig)
if (boostWasserActive) server.sendContent(F(" checked"));
// Nach Boost Wasser bis Prevent Heat Wasser Checkbox
server.sendContent_P(rootChunk_AfterBoostW);
// Prevent Heat Wasser checked Attribut (falls nötig)
if (preventHeatAboveSetpointWasser) server.sendContent(F(" checked"));
// Nach Prevent Heat Wasser bis Dampf Setpoint
server.sendContent_P(rootChunk_AfterPreventW);
// Dampf Setpoint Wert
snprintf(buffer, sizeof(buffer), "%d", (int)SetpointDampf);
server.sendContent(buffer);
// Nach Dampf Setpoint bis Dampf Offset
server.sendContent_P(rootChunk_AfterDampf);
// Dampf Offset Wert
snprintf(buffer, sizeof(buffer), "%.2f", OffsetDampf);
server.sendContent(buffer);
// Nach Dampf Offset bis Boost Dampf Checkbox
server.sendContent_P(rootChunk_AfterOffsetD);
// Boost Dampf checked Attribut (falls nötig)
if (boostDampfActive) server.sendContent(F(" checked"));
// Nach Boost Dampf bis Prevent Heat Dampf Checkbox
server.sendContent_P(rootChunk_AfterBoostD);
// Prevent Heat Dampf checked Attribut (falls nötig)
if (preventHeatAboveSetpointDampf) server.sendContent(F(" checked"));
// Nach Prevent Heat Dampf bis Kp Wasser
server.sendContent_P(rootChunk_AfterPreventD);
// Kp Wasser Wert
snprintf(buffer, sizeof(buffer), "%.2f", KpWasser);
server.sendContent(buffer);
// Nach Kp Wasser bis Ki Wasser
server.sendContent_P(rootChunk_AfterKpW);
// Ki Wasser Wert
snprintf(buffer, sizeof(buffer), "%.2f", KiWasser);
server.sendContent(buffer);
// Nach Ki Wasser bis Kd Wasser
server.sendContent_P(rootChunk_AfterKiW);
// Kd Wasser Wert
snprintf(buffer, sizeof(buffer), "%.2f", KdWasser);
server.sendContent(buffer);
// Nach Kd Wasser bis Window Size Wasser
server.sendContent_P(rootChunk_AfterKdW);
// Window Size Wasser Wert
snprintf(buffer, sizeof(buffer), "%lu", windowSizeWasser);
server.sendContent(buffer);
// Nach Window Size Wasser bis Kp Dampf
server.sendContent_P(rootChunk_AfterWindowW);
// Kp Dampf Wert
snprintf(buffer, sizeof(buffer), "%.2f", KpDampf);
server.sendContent(buffer);
// Nach Kp Dampf bis Ki Dampf
server.sendContent_P(rootChunk_AfterKpD);
// Ki Dampf Wert
snprintf(buffer, sizeof(buffer), "%.2f", KiDampf);
server.sendContent(buffer);
// Nach Ki Dampf bis Kd Dampf
server.sendContent_P(rootChunk_AfterKiD);
// Kd Dampf Wert
snprintf(buffer, sizeof(buffer), "%.2f", KdDampf);
server.sendContent(buffer);
// Nach Kd Dampf bis Window Size Dampf
server.sendContent_P(rootChunk_AfterKdD);
// Window Size Dampf Wert
snprintf(buffer, sizeof(buffer), "%lu", windowSizeDampf);
server.sendContent(buffer);
// Nach Window Size Dampf bis Max Temp Wasser
server.sendContent_P(rootChunk_AfterWindowD);
// Max Temp Wasser Wert
snprintf(buffer, sizeof(buffer), "%.2f", maxTempWasser);
server.sendContent(buffer);
// Nach Max Temp Wasser bis Max Temp Dampf
server.sendContent_P(rootChunk_AfterMaxW);
// Max Temp Dampf Wert
snprintf(buffer, sizeof(buffer), "%.2f", maxTempDampf);
server.sendContent(buffer);
// Formular Ende und HTML Ende
server.sendContent_P(rootChunk_FormEnd);
// Letzter leerer Chunk zum Abschließen
server.sendContent(F(""));
}
/************************************************************************************
* Schreibt die Werte in den EEPROM (PID/Eco) - Unverändert
************************************************************************************/
void handleSettingsUpdate() {
bool valueChanged = false; // Flag um zu prüfen, ob EEPROM.commit() nötig ist
// --- Bestehende Werte lesen ---
// Sicherheitslimits
if (server.hasArg(F("maxTempWasser"))) {
float newVal = server.arg(F("maxTempWasser")).toFloat();
if (newVal != maxTempWasser) {
maxTempWasser = newVal;
EEPROM.put(EEPROM_ADDR_MAX_TEMP_WASSER, maxTempWasser);
valueChanged = true;
}
}
if (server.hasArg(F("maxTempDampf"))) {
float newVal = server.arg(F("maxTempDampf")).toFloat();
if (newVal != maxTempDampf) {
maxTempDampf = newVal;
EEPROM.put(EEPROM_ADDR_MAX_TEMP_DAMPF, maxTempDampf);
valueChanged = true;
}
}
// Setpoints
if (server.hasArg(F("wasser"))) {
float newVal = server.arg(F("wasser")).toFloat();
if (newVal != SetpointWasser) {
SetpointWasser = newVal;
EEPROM.put(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
valueChanged = true;
}
}
if (server.hasArg(F("dampf"))) {
float newVal = server.arg(F("dampf")).toFloat();
if (newVal != SetpointDampf) {
SetpointDampf = newVal;
EEPROM.put(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
valueChanged = true;
}
}
// Offsets
if (server.hasArg(F("offsetWasser"))) {
float newVal = server.arg(F("offsetWasser")).toFloat();
if (newVal != OffsetWasser) {
OffsetWasser = newVal;
EEPROM.put(EEPROM_ADDR_OFFSET_WASSER, OffsetWasser);
valueChanged = true;
}
}
if (server.hasArg(F("offsetDampf"))) {
float newVal = server.arg(F("offsetDampf")).toFloat();
if (newVal != OffsetDampf) {
OffsetDampf = newVal;
EEPROM.put(EEPROM_ADDR_OFFSET_DAMPF, OffsetDampf);
valueChanged = true;
}
}
// PID Wasser
if (server.hasArg(F("kpWasser"))) {
float newVal = server.arg(F("kpWasser")).toFloat();
if (newVal != KpWasser) {
KpWasser = newVal;
EEPROM.put(EEPROM_ADDR_KP_WASSER, KpWasser);
valueChanged = true;
}
}
if (server.hasArg(F("kiWasser"))) {
float newVal = server.arg(F("kiWasser")).toFloat();
if (newVal != KiWasser) {
KiWasser = newVal;
EEPROM.put(EEPROM_ADDR_KI_WASSER, KiWasser);
valueChanged = true;
}
}
if (server.hasArg(F("kdWasser"))) {
float newVal = server.arg(F("kdWasser")).toFloat();
if (newVal != KdWasser) {
KdWasser = newVal;
EEPROM.put(EEPROM_ADDR_KD_WASSER, KdWasser);
valueChanged = true;
}
}
// PID Dampf
if (server.hasArg(F("kpDampf"))) {
float newVal = server.arg(F("kpDampf")).toFloat();
if (newVal != KpDampf) {
KpDampf = newVal;
EEPROM.put(EEPROM_ADDR_KP_DAMPF, KpDampf);
valueChanged = true;
}
}
if (server.hasArg(F("kiDampf"))) {
float newVal = server.arg(F("kiDampf")).toFloat();
if (newVal != KiDampf) {
KiDampf = newVal;
EEPROM.put(EEPROM_ADDR_KI_DAMPF, KiDampf);
valueChanged = true;
}
}
if (server.hasArg(F("kdDampf"))) {
float newVal = server.arg(F("kdDampf")).toFloat();
if (newVal != KdDampf) {
KdDampf = newVal;
EEPROM.put(EEPROM_ADDR_KD_DAMPF, KdDampf);
valueChanged = true;
}
}
// Boost Flags
bool newBoostWasser = server.hasArg(F("boostWasser"));
if (newBoostWasser != boostWasserActive) {
boostWasserActive = newBoostWasser;
EEPROM.put(EEPROM_ADDR_BOOST_WASSER_ACTIVE, boostWasserActive);
valueChanged = true;
}
bool newBoostDampf = server.hasArg(F("boostDampf"));
if (newBoostDampf != boostDampfActive) {
boostDampfActive = newBoostDampf;
EEPROM.put(EEPROM_ADDR_BOOST_DAMPF_ACTIVE, boostDampfActive);
valueChanged = true;
}
// Prevent Heat Above Setpoint Flags
bool newPreventHeatWasser = server.hasArg(F("preventHeatWasser"));
if (newPreventHeatWasser != preventHeatAboveSetpointWasser) {
preventHeatAboveSetpointWasser = newPreventHeatWasser;
EEPROM.put(EEPROM_ADDR_PREVENTHEAT_WASSER, preventHeatAboveSetpointWasser);
valueChanged = true;
Serial.printf("PreventHeat Wasser %s\n", preventHeatAboveSetpointWasser ? "aktiviert" : "deaktiviert");
}
bool newPreventHeatDampf = server.hasArg(F("preventHeatDampf"));
if (newPreventHeatDampf != preventHeatAboveSetpointDampf) {
preventHeatAboveSetpointDampf = newPreventHeatDampf;
EEPROM.put(EEPROM_ADDR_PREVENTHEAT_DAMPF, preventHeatAboveSetpointDampf);
valueChanged = true;
Serial.printf("PreventHeat Dampf %s\n", preventHeatAboveSetpointDampf ? "aktiviert" : "deaktiviert");
}
unsigned long newWindowSizeWasser = windowSizeWasser; // Startwert mit aktuellem Wert
if (server.hasArg(F("windowSizeWasser"))) {
char* endptr;
// Konvertiere den String-Parameter zu unsigned long
unsigned long val = strtoul(server.arg(F("windowSizeWasser")).c_str(), &endptr, 10);
// Prüfe, ob Konvertierung erfolgreich war und Wert sinnvoll ist (z.B. >0 und nicht zu groß)
if (*endptr == '\0' && val > 0 && val <= 60000) { // Beispiel: max 60 Sekunden
newWindowSizeWasser = val;
} else {
Serial.printf("WARNUNG: Ungültiger Wert für windowSizeWasser empfangen: %s\n", server.arg(F("windowSizeWasser")).c_str());
}
}
// Wenn sich der Wert geändert hat:
if (newWindowSizeWasser != windowSizeWasser) {
windowSizeWasser = newWindowSizeWasser;
EEPROM.put(EEPROM_ADDR_WINDOWSIZE_WASSER, windowSizeWasser);
pidWasser.SetOutputLimits(0, windowSizeWasser); // <-- WICHTIG: PID Limit sofort aktualisieren!
valueChanged = true;
Serial.printf("Fenstergröße Wasser aktualisiert auf: %lu ms\n", windowSizeWasser);
}
unsigned long newWindowSizeDampf = windowSizeDampf; // Startwert mit aktuellem Wert
if (server.hasArg(F("windowSizeDampf"))) {
char* endptr;
unsigned long val = strtoul(server.arg(F("windowSizeDampf")).c_str(), &endptr, 10);
if (*endptr == '\0' && val > 0 && val <= 60000) {
newWindowSizeDampf = val;
} else {
Serial.printf("WARNUNG: Ungültiger Wert für windowSizeDampf empfangen: %s\n", server.arg(F("windowSizeDampf")).c_str());
}
}
// Wenn sich der Wert geändert hat:
if (newWindowSizeDampf != windowSizeDampf) {
windowSizeDampf = newWindowSizeDampf;
EEPROM.put(EEPROM_ADDR_WINDOWSIZE_DAMPF, windowSizeDampf);
pidDampf.SetOutputLimits(0, windowSizeDampf); // <-- WICHTIG: PID Limit sofort aktualisieren!
valueChanged = true;
Serial.printf("Fenstergröße Dampf aktualisiert auf: %lu ms\n", windowSizeDampf);
}
// --- EEPROM speichern und PID aktualisieren ---
if (valueChanged) { // Nur speichern, wenn sich mindestens ein Wert geändert hat
if (EEPROM.commit()) {
Serial.println("Einstellungen erfolgreich in EEPROM gespeichert.");
} else {
Serial.println("FEHLER: EEPROM Commit fehlgeschlagen!");
}
}
// PID-Parameter an die Regler übergeben
pidDampf.SetTunings(KpDampf, KiDampf, KdDampf);
pidWasser.SetTunings(KpWasser, KiWasser, KdWasser);
// Zurück zur Hauptseite (bereits vorhanden)
server.sendHeader(F("Location"), F("/"));
server.send(303);
}
/************************************************************************************
* Handler für die Firmware & Einstellungen Seite - Heap-Optimiert
************************************************************************************/
// --- PROGMEM Chunks für handleFirmware ---
static const char firmwareHtmlHead[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<title>Firmware & Einstellungen</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
</head>
)rawliteral"; // Head inklusive </head>
static const char firmwareBodyStart[] PROGMEM = R"rawliteral(
<body>
<h1>Firmware & Einstellungen</h1>
<form>
<h3>Firmware-Info</h3>
Firmware-Version: )rawliteral"; // Endet vor {FIRMWAREVERSION}
static const char firmwareInfoChunk2[] PROGMEM = R"rawliteral(<br>
Hersteller: )rawliteral"; // Endet vor {SWHERSTELLER}
static const char firmwareInfoChunk3[] PROGMEM = R"rawliteral(<br>
E-Mail: )rawliteral"; // Endet vor {SWHERSTELLERMAIL}
static const char firmwareInfoChunk4[] PROGMEM = R"rawliteral(<br>
Website: )rawliteral"; // Endet vor {SWHERSTELLERWEBSITE}
static const char firmwareInfoChunkEnd[] PROGMEM = R"rawliteral(<br>
</form>
)rawliteral"; // Ende Firmware-Info Sektion
static const char firmwareUpdateSection[] PROGMEM = R"rawliteral(
<form method='POST' action='/update' enctype='multipart/form-data'>
<h3>Firmware-Update</h3>
Das Firmware-Update kann durch den Upload einer .bin-Datei durchgef&uuml;hrt werden.<br>
Nach dem Update wird ein automatischer Neustart durchgef&uuml;hrt.<br>
Sollte der Neustart nicht erfolgen, so kann dieser auch durch kurzzeitiges Trennen der Stromversorgung erfolgen.<br>
<br>
<input type='file' name='firmware' accept='.bin'>
<br>
<button>Update starten</button>
</form>
)rawliteral";
static const char firmwareFileManagerSection[] PROGMEM = R"rawliteral(
<form method='GET' action='/Dateimanager' target='_blank'enctype='multipart/form-data'>
<h3>Dateimanager</h3>
Verwaltung von Dateien des Systems.<br>
<button type='submit' >Dateimanager</button>
</form>
)rawliteral";
static const char firmwareExportSection[] PROGMEM = R"rawliteral(
<form method='GET' action='/exportSettings' enctype='multipart/form-data'>
<h3>Einstellungen exportieren</h3>
Sicherung aller aktuellen Einstellungen in einer Datei.<br>
<button type='submit' >Exportieren</button>
</form>
)rawliteral";
static const char firmwareImportSection[] PROGMEM = R"rawliteral(
<form method='POST' action='/importSettings' enctype='multipart/form-data'>
<h3>Einstellungen importieren</h3>
Alle Einstellungen aus einer zuvor exportierten .bin-Datei wiederherstellen.<br><br>
<b style='color: #FFCC00;'>ACHTUNG:</b><br>
Dieser Vorgang &uuml;berschreibt ALLE aktuellen Einstellungen!<br>
Nach dem Import einfolgt ein automatischer Neustart.<br>
<br>
<input type='file' name='settings' accept='.bin' required>
<br>
<button>Import & Neustart</button>
</form>
)rawliteral";
static const char firmwareResetSection[] PROGMEM = R"rawliteral(
<form action='/resetDefaults' method='POST' onsubmit='return confirmResetDefaults();'>
<h3>Werkseinstellungen</h3>
Setzt ALLE Einstellungen (mit Ausnahme der WiFi-Konfiguration, der Shot- und Betriebsstundenz&auml;hler) auf die Standardwerte zur&uuml;ck.<br><br>
<b style='color: #FFCC00;'>ACHTUNG:</b><br>
Es ist zu empfehlen, vorher einen Export der Einstellungen durchzuf&uuml;hren.
<br>
<button type='submit' class='danger'>Werkseinstellungen laden</button>
</form>
)rawliteral";
static const char firmwareBodyEnd[] PROGMEM = R"rawliteral(
</body>
</html>
)rawliteral";
// --- Neue Funktion handleFirmware ---
void handleFirmware() { // URL: /Firmware, Methode: GET
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, F("text/html"), "");
// Kopfzeile senden
server.sendContent_P(firmwareHtmlHead); // Enthält öffnendes <html> und <head> bis </head>
server.sendContent_P(commonStyle);
// </head> ist im Head-Chunk enthalten
server.sendContent_P(commonNav); // Navigation
// Body Start und Firmware Info Sektion
server.sendContent_P(firmwareBodyStart); // Enthält <body>, <h1> und Start der <form> für Info
// Firmware Version
if (version.length() > 0) { // Prüfen, ob String nicht leer ist
server.sendContent(version);
}
server.sendContent_P(firmwareInfoChunk2); // Enthält <br>Hersteller:
// Hersteller
if (versionHersteller.length() > 0) {
server.sendContent(versionHersteller);
}
server.sendContent_P(firmwareInfoChunk3); // Enthält <br>E-Mail:
// Mail (ist HTML, kann direkt gesendet werden)
if (versionHerstellerMail.length() > 0) {
server.sendContent(versionHerstellerMail);
}
server.sendContent_P(firmwareInfoChunk4); // Enthält <br>Website:
// Website (ist HTML, kann direkt gesendet werden)
if (versionHerstellerWeb.length() > 0) {
server.sendContent(versionHerstellerWeb);
}
server.sendContent_P(firmwareInfoChunkEnd); // Enthält <br></form>
// Firmware Update Sektion
server.sendContent_P(firmwareUpdateSection);
// Dateimanager Sektion
server.sendContent_P(firmwareFileManagerSection);
// Einstellungen Export Sektion
server.sendContent_P(firmwareExportSection);
// Einstellungen Import Sektion
server.sendContent_P(firmwareImportSection);
// Werkseinstellungen Sektion
server.sendContent_P(firmwareResetSection);
// Body / HTML Ende
server.sendContent_P(firmwareBodyEnd);
// Letzter leerer Chunk zum Abschließen
server.sendContent(F(""));
}
/************************************************************************************
* Handler für AutTune Wasser
************************************************************************************/
// --- PROGMEM Chunks für handleAutoTuneWasser ---
static const char autotuneWasserHtmlHead[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<title>AutoTune Wasser</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
</head>
)rawliteral";
static const char autotuneWasserBodyStart[] PROGMEM = R"rawliteral(
<body>
<h1>PID-Tuning</h1>
)rawliteral";
static const char autotuneWasserStartSuccess[] PROGMEM = R"rawliteral(
<form action='/PID-Tuning-Abbruch' method='POST' >
<p>AutoTune gestartet: Wasser-PID</p>
<br>
<input type='submit' value='PID-Tuning abbrechen'>
</form>
)rawliteral";
static const char autotuneWasserAlreadyActive[] PROGMEM = R"rawliteral(
<form action='/PID-Tuning-Abbruch' method='POST' >
<p style="color: red;">AutoTune ist bereits aktiv!</p>
<br>
<input type='submit' value='PID-Tuning abbrechen'>
</form>
)rawliteral";
static const char autotuneWasserBodyEnd[] PROGMEM = R"rawliteral(
</body>
</html>
)rawliteral";
// --- Neue Funktion handleAutoTuneWasser ---
void handleAutoTuneWasser() {
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, F("text/html"), ""); // Header senden
server.sendContent_P(autotuneWasserHtmlHead);
server.sendContent_P(commonStyle);
server.sendContent_P(commonNav);
server.sendContent_P(autotuneWasserBodyStart);
// Prüfen, ob bereits ein Tuning läuft
if (!autoTuneWasserActive && !autoTuneDampfActive) {
// Wartungsmodus explizit deaktivieren
if (wartungsModusAktiv) {
wartungsModusAktiv = false;
// Standard-Temperaturwerte laden
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
Serial.println("Wartungsmodus automatisch deaktiviert durch Start von PID-Tuning.");
}
// ************************************************
startAutoTuneWasser(); // Funktion zum Starten des Tunings aufrufen
server.sendContent_P(autotuneWasserStartSuccess);
} else {
// Meldung, dass bereits ein Tuning aktiv ist
server.sendContent_P(autotuneWasserAlreadyActive);
}
server.sendContent_P(autotuneWasserBodyEnd);
server.sendContent(F("")); // Abschluss-Chunk
}
/************************************************************************************
* Handler für AutTune Dampf
************************************************************************************/
// --- PROGMEM Chunks für handleAutoTuneDampf ---
static const char autotuneDampfHtmlHead[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<title>AutoTune Dampf</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
</head>
)rawliteral";
// BodyStart, AlreadyActive und BodyEnd können von handleAutoTuneWasser wiederverwendet werden
// (autotuneWasserBodyStart, autotuneWasserAlreadyActive, autotuneWasserBodyEnd)
static const char autotuneDampfStartSuccess[] PROGMEM = R"rawliteral(
<form action='/PID-Tuning-Abbruch' method='POST' >
<p>AutoTune gestartet: Dampf-PID</p>
<br>
<input type='submit' value='PID-Tuning abbrechen'>
</form>
)rawliteral";
void handleAutoTuneDampf() {
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, F("text/html"), ""); // Header senden
server.sendContent_P(autotuneDampfHtmlHead); // Eigener Head-Chunk
server.sendContent_P(commonStyle);
server.sendContent_P(commonNav);
server.sendContent_P(autotuneWasserBodyStart); // Wiederverwendet
// Prüfen, ob bereits ein Tuning läuft
if (!autoTuneWasserActive && !autoTuneDampfActive) {
// Wartungsmodus explizit deaktivieren
if (wartungsModusAktiv) {
wartungsModusAktiv = false;
// Standard-Temperaturwerte laden
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
Serial.println("Wartungsmodus automatisch deaktiviert durch Start von PID-Tuning.");
}
// ************************************************
startAutoTuneDampf(); // Funktion zum Starten des Tunings aufrufen
server.sendContent_P(autotuneDampfStartSuccess); // Eigener Success-Chunk
} else {
// Meldung, dass bereits ein Tuning aktiv ist
server.sendContent_P(autotuneWasserAlreadyActive); // Wiederverwendet
}
server.sendContent_P(autotuneWasserBodyEnd); // Wiederverwendet
server.sendContent(F("")); // Abschluss-Chunk
}
/************************************************************************************
* Handler zum Speichern der AutoTune-Einstellungen - ausgelagert
************************************************************************************/
void handleUpdateAutotuneSettings() {
bool changed = false; // Flag, um zu prüfen, ob EEPROM.commit() nötig ist
// --- Wasser Parameter ---
if (server.hasArg(F("tuningStepWasser"))) {
tuningStepWasser = server.arg(F("tuningStepWasser")).toFloat();
EEPROM.put(EEPROM_ADDR_TUNING_STEP_WASSER, tuningStepWasser);
changed = true;
}
if (server.hasArg(F("tuningNoiseWasser"))) {
tuningNoiseWasser = server.arg(F("tuningNoiseWasser")).toFloat();
EEPROM.put(EEPROM_ADDR_TUNING_NOISE_WASSER, tuningNoiseWasser);
changed = true;
}
if (server.hasArg(F("tuningStartValueWasser"))) {
tuningStartValueWasser = server.arg(F("tuningStartValueWasser")).toFloat();
EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_WASSER, tuningStartValueWasser);
changed = true;
}
if (server.hasArg(F("tuningLookBackWasser"))) {
// Wichtig: toInt() verwenden, da es unsigned int ist, aber über HTTP als String kommt
int lookbackInt = server.arg(F("tuningLookBackWasser")).toInt();
if (lookbackInt >= 0) { // Grundlegende Plausibilitätsprüfung
tuningLookBackWasser = (unsigned int)lookbackInt;
EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_WASSER, tuningLookBackWasser);
changed = true;
}
}
// --- Dampf Parameter ---
if (server.hasArg(F("tuningStepDampf"))) {
tuningStepDampf = server.arg(F("tuningStepDampf")).toFloat();
EEPROM.put(EEPROM_ADDR_TUNING_STEP_DAMPF, tuningStepDampf);
changed = true;
}
if (server.hasArg(F("tuningNoiseDampf"))) {
tuningNoiseDampf = server.arg(F("tuningNoiseDampf")).toFloat();
EEPROM.put(EEPROM_ADDR_TUNING_NOISE_DAMPF, tuningNoiseDampf);
changed = true;
}
if (server.hasArg(F("tuningStartValueDampf"))) {
tuningStartValueDampf = server.arg(F("tuningStartValueDampf")).toFloat();
EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_DAMPF, tuningStartValueDampf);
changed = true;
}
if (server.hasArg(F("tuningLookBackDampf"))) {
// Wichtig: toInt() verwenden, da es unsigned int ist, aber über HTTP als String kommt
int lookbackInt = server.arg(F("tuningLookBackDampf")).toInt();
if (lookbackInt >= 0) { // Grundlegende Plausibilitätsprüfung
tuningLookBackDampf = (unsigned int)lookbackInt;
EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_DAMPF, tuningLookBackDampf);
changed = true;
}
}
// Nur speichern, wenn sich etwas geändert hat
if (changed) {
if (!EEPROM.commit()) {
Serial.println("FEHLER: EEPROM Commit für AutoTune-Settings fehlgeschlagen!");
} else {
Serial.println("AutoTune-Parameter gespeichert.");
}
}
// Weiterleitung zurück zur PID-Tuning Seite
server.sendHeader(F("Location"), F("/PID-Tuning")); // URL bleibt gleich
server.send(303); // 303 See Other - Korrekt für Redirect nach POST
}
/************************************************************************************
* Handler für PID-Tuning
************************************************************************************/
// --- PROGMEM Chunks für handlePidTuning ---
static const char pidTuningPageHtmlHead[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<title>PID-Tuning</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
</head>
)rawliteral";
static const char pidTuningPageBodyStart[] PROGMEM = R"rawliteral(
<body>
)rawliteral";
static const char pidTuningPageAbortForm[] PROGMEM = R"rawliteral(
<form action='/PID-Tuning-Abbruch' method='POST' >
<h3>Laufendes PID-Tuning abbrechen?</h3>
<input type='submit' value='Abbrechen'>
</form></br>
)rawliteral";
static const char pidTuningPageIntro[] PROGMEM = R"rawliteral(
<h1>PID-Tuning</h1>
<form>Das automatische PID-Tuning dient dazu, die optimalen Parameter f&uuml;r den PID-Regler (Proportional-, Integral- und Differentialanteil) selbst&auml;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&uuml;nschte Kriterien wie kurze Einschwingzeit, geringe &Uuml;berschwingung und stabile Regelung erreicht werden
<br><br>
Ein AutoTune-Vorgang kann durchaus bis zu 60 Minuten in Anspruch nehmen!
<br><br>
<b style='color: #FFCC00;'>WICHTIG:</b><br>
Vor dem Start des PID-Tunings sollte die Maschine die gew&uuml;nschte Betriebstemperatur erreicht haben und stabil sein.<br>
Andernfalls k&ouml;nnten die automatisch ermittelten PID-Werte f&uuml;r diesen Temperaturbereich ungenau oder nicht optimal sein.
<br><br>
</form>
<form action='/AutoTune-Wasser' method='POST' >
<h3>Automatisches PID-Tuning f&uuml;r Wasser</h3>
Automatische Ermittlung der PID-Werte f&uuml;r Wasser / Kessel<br>
<input type='submit' value='Tuning starten'>
</form>
<form action='/AutoTune-Dampf' method='POST' >
<h3>Automatisches PID-Tuning f&uuml;r Dampf</h3>
Automatische Ermittlung der PID-Werte f&uuml;r Dampf / Thermoblock<br>
<input type='submit' value='Tuning starten'>
</form>
)rawliteral";
// --- Chunks für das Parameter-Formular ---
static const char pidTuningPageParamsFormStart[] PROGMEM = R"rawliteral(
<form action='/updateAutotuneSettings' method='POST'>
<h3>AutoTune-Parameter (Zeitproportional)</h3>
Hier k&ouml;nnen die AutoTune-Parameter ge&auml;ndert werden.<br>&Auml;nderungen sollten mit Vorsicht durchgef&uuml;hrt werden!<br><br>
<h3>Parameter Wasser / Kessel</h3>
<label for='tuningStepWasser'>Step (Output 0-)rawliteral"; // Endet vor {WINDOW_SIZE_WASSER}
static const char pidTuningPageParamsW1[] PROGMEM = R"rawliteral():</label>
<input type='number' step='0.01' id='tuningStepWasser' name='tuningStepWasser' value=')rawliteral"; // Endet vor {TUNING_STEP_WASSER}
static const char pidTuningPageParamsW2[] PROGMEM = R"rawliteral('>
<label for='tuningNoiseWasser'>Noise (&deg;C):</label>
<input type='number' step='0.01' id='tuningNoiseWasser' name='tuningNoiseWasser' value=')rawliteral"; // Endet vor {TUNING_NOISE_WASSER}
static const char pidTuningPageParamsW3[] PROGMEM = R"rawliteral('>
<label for='tuningStartValueWasser'>StartValue (Output 0-)rawliteral"; // Endet vor {WINDOW_SIZE_WASSER}
static const char pidTuningPageParamsW4[] PROGMEM = R"rawliteral():</label>
<input type='number' step='0.01' id='tuningStartValueWasser' name='tuningStartValueWasser' value=')rawliteral"; // Endet vor {TUNING_STARTVALUE_WASSER}
static const char pidTuningPageParamsW5[] PROGMEM = R"rawliteral('>
<label for='tuningLookBackWasser'>LookBack (Sekunden):</label>
<input type='number' step='1' id='tuningLookBackWasser' name='tuningLookBackWasser' value=')rawliteral"; // Endet vor {TUNING_LOOKBACK_WASSER}
static const char pidTuningPageParamsDStart[] PROGMEM = R"rawliteral('>
<br><h3>Parameter Dampf / Thermoblock</h3>
<label for='tuningStepDampf'>Step (Output 0-)rawliteral"; // Endet vor {WINDOW_SIZE_DAMPF}
static const char pidTuningPageParamsD1[] PROGMEM = R"rawliteral():</label>
<input type='number' step='0.01' id='tuningStepDampf' name='tuningStepDampf' value=')rawliteral"; // Endet vor {TUNING_STEP_DAMPF}
static const char pidTuningPageParamsD2[] PROGMEM = R"rawliteral('>
<label for='tuningNoiseDampf'>Noise (&deg;C):</label>
<input type='number' step='0.01' id='tuningNoiseDampf' name='tuningNoiseDampf' value=')rawliteral"; // Endet vor {TUNING_NOISE_DAMPF}
static const char pidTuningPageParamsD3[] PROGMEM = R"rawliteral('>
<label for='tuningStartValueDampf'>StartValue (Output 0-)rawliteral"; // Endet vor {WINDOW_SIZE_DAMPF}
static const char pidTuningPageParamsD4[] PROGMEM = R"rawliteral():</label>
<input type='number' step='0.01' id='tuningStartValueDampf' name='tuningStartValueDampf' value=')rawliteral"; // Endet vor {TUNING_STARTVALUE_DAMPF}
static const char pidTuningPageParamsD5[] PROGMEM = R"rawliteral('>
<label for='tuningLookBackDampf'>LookBack (Sekunden):</label>
<input type='number' step='1' 'tuningLookBackDampf' name='tuningLookBackDampf' value=')rawliteral"; // Endet vor {TUNING_LOOKBACK_DAMPF}
static const char pidTuningPageParamsFormEnd[] PROGMEM = R"rawliteral('>
<input type='submit' value='Parameter speichern'>
</form>
)rawliteral";
static const char pidTuningPageExplanation[] PROGMEM = R"rawliteral(
<br><hr><br>
<form>
<h3>Erkl&auml;rung der Parameter (Zeitproportional):</h3>
<p><strong>Noise (&deg;C):</strong><br>
Beobachte das "Zittern" der Temperaturanzeige, wenn die Temperatur (nahe am Zielwert) stabil ist.<br>Wenn sie z.B. um +/- 0.5&deg;C schwankt, setze Noise auf <code>1.0</code>.<br>Dieser Wert definiert ein Toleranzband, um das Sensorrauschen zu ignorieren.</p>
<p><strong>StartValue (ms):</strong><br>
Sch&auml;tze, wie viel Heizzeit (in Millisekunden, innerhalb des eingestellten Zeitfensters (siehe PID-Einstellung) n&ouml;tig ist, um die Zieltemperatur konstant zu halten.<br>Dies ist die durchschnittliche Heizzeit im stabilen Zustand (z.B. <code>250</code> ms).</p>
<p><strong>Step (ms):</strong><br>
Die Gr&ouml;&szlig;e des "Sprungs" der Heizzeit (in ms) nach oben/unten um den <code>StartValue</code>, den AutoTune nutzt, um die Temperatur zum Schwingen zu zwingen.<br>Muss gro&szlig; genug f&uuml;r eine Reaktion sein, aber nicht 0-100% (z.B. <code>500</code> ms).</p>
<p><strong>LookBack (s):</strong><br>
AutoTune erzeugt eine langsame Welle der Temperatur. Miss (in einem Testlauf) oder sch&auml;tze, wie lange eine volle Welle dauert (Spitze zu Spitze, in Sekunden).<br>Setze Lookback auf einen Wert, der *deutlich l&auml;nger* ist als diese Dauer (z.B. 1.5x so lang; Welle=130s => Lookback=<code>180</code>s).<br>Dieser Wert muss *vor* dem Start festgelegt werden und sagt AutoTune, wie weit es zur&uuml;ckschauen soll, um die Welle zu messen.</p>
<p><strong>Kontrolle per Chart:</strong><br>
W&auml;hrend AutoTune l&auml;uft, beobachte das Temperaturchart.<br>Wenn sich dort eine langsame, regelm&auml;&szlig;ige Welle (wie ein \"langgezogener Sinus\") um den Sollwert entwickelt, ist das ein gutes Zeichen, dass der Prozess funktioniert.</p>
</form>
</body>
</html>
)rawliteral";
// --- handlePidTuning ---
void handlePidTuning() {
char buffer[20]; // Puffer für Zahlenumwandlungen
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, F("text/html"), "");
server.sendContent_P(pidTuningPageHtmlHead);
server.sendContent_P(commonStyle);
// </head> ist im Head-Chunk enthalten
server.sendContent_P(commonNav);
server.sendContent_P(pidTuningPageBodyStart);
// Optionales "Abbrechen"-Formular anzeigen
if (autoTuneWasserActive || autoTuneDampfActive) {
server.sendContent_P(pidTuningPageAbortForm);
}
// Einführungstext und Start-Buttons
server.sendContent_P(pidTuningPageIntro);
// Parameter-Formular (Stück für Stück)
server.sendContent_P(pidTuningPageParamsFormStart); // Bis vor WindowSize Wasser
snprintf(buffer, sizeof(buffer), "%lu", windowSizeWasser);
server.sendContent(buffer); // WindowSize Wasser
server.sendContent_P(pidTuningPageParamsW1); // Bis vor Step Wasser
snprintf(buffer, sizeof(buffer), "%.2f", tuningStepWasser);
server.sendContent(buffer); // Step Wasser
server.sendContent_P(pidTuningPageParamsW2); // Bis vor Noise Wasser
snprintf(buffer, sizeof(buffer), "%.2f", tuningNoiseWasser);
server.sendContent(buffer); // Noise Wasser
server.sendContent_P(pidTuningPageParamsW3); // Bis vor WindowSize Wasser (für StartValue)
snprintf(buffer, sizeof(buffer), "%lu", windowSizeWasser);
server.sendContent(buffer); // WindowSize Wasser
server.sendContent_P(pidTuningPageParamsW4); // Bis vor StartValue Wasser
snprintf(buffer, sizeof(buffer), "%.2f", tuningStartValueWasser);
server.sendContent(buffer); // StartValue Wasser
server.sendContent_P(pidTuningPageParamsW5); // Bis vor LookBack Wasser
snprintf(buffer, sizeof(buffer), "%u", tuningLookBackWasser);
server.sendContent(buffer); // LookBack Wasser
server.sendContent_P(pidTuningPageParamsDStart); // Bis vor WindowSize Dampf
snprintf(buffer, sizeof(buffer), "%lu", windowSizeDampf);
server.sendContent(buffer); // WindowSize Dampf
server.sendContent_P(pidTuningPageParamsD1); // Bis vor Step Dampf
snprintf(buffer, sizeof(buffer), "%.2f", tuningStepDampf);
server.sendContent(buffer); // Step Dampf
server.sendContent_P(pidTuningPageParamsD2); // Bis vor Noise Dampf
snprintf(buffer, sizeof(buffer), "%.2f", tuningNoiseDampf);
server.sendContent(buffer); // Noise Dampf
server.sendContent_P(pidTuningPageParamsD3); // Bis vor WindowSize Dampf (für StartValue)
snprintf(buffer, sizeof(buffer), "%lu", windowSizeDampf);
server.sendContent(buffer); // WindowSize Dampf
server.sendContent_P(pidTuningPageParamsD4); // Bis vor StartValue Dampf
snprintf(buffer, sizeof(buffer), "%.2f", tuningStartValueDampf);
server.sendContent(buffer); // StartValue Dampf
server.sendContent_P(pidTuningPageParamsD5); // Bis vor LookBack Dampf
snprintf(buffer, sizeof(buffer), "%u", tuningLookBackDampf);
server.sendContent(buffer); // LookBack Dampf
server.sendContent_P(pidTuningPageParamsFormEnd); // Rest des Formulars
// Erklärungen
server.sendContent_P(pidTuningPageExplanation);
server.sendContent(F("")); // Abschluss-Chunk
}
/************************************************************************************
* Handler für die Antwort nach Abbruch des PID-Tunings - Heap-Optimiert
************************************************************************************/
// PROGMEM Chunks für die Seite (Namen angepasst für Klarheit)
static const char abortPidTuningHtmlStart[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<title>Info</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
)rawliteral";
static const char abortPidTuningHtmlHeadEnd[] PROGMEM = R"rawliteral(</head>)rawliteral";
static const char abortPidTuningHtmlBody[] PROGMEM = R"rawliteral(
<body>
<h1>PID-Tuning Abbruch</h1>
<form>
<h3>Das PID-Tuning wurde erfolgreich abgebrochen.</h3>
<p>Der Normalbetrieb wird nun fortgesetzt.</p>
</form>
</body>
</html>
)rawliteral";
// --- Neue Funktion handlePidTuningAbbruch ---
void handlePidTuningAbbruch() {
// Seite senden
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, F("text/html"), "");
server.sendContent_P(abortPidTuningHtmlStart);
server.sendContent_P(commonStyle);
server.sendContent_P(abortPidTuningHtmlHeadEnd);
server.sendContent_P(commonNav);
server.sendContent_P(abortPidTuningHtmlBody); // Body ist komplett statisch
server.sendContent(F("")); // Abschluss-Chunk
// Eigentliche Abbruch-Logik ausführen, NACHDEM die Antwort gesendet wurde
stopAutoTuneWasser();
stopAutoTuneDampf();
}
/************************************************************************************
* Speichert Eco-Einstellungen - URL angepasst
************************************************************************************/
void handleEcoUpdate() {
if (server.hasArg(F("ecoMode"))) ecoModeMinutes = server.arg(F("ecoMode")).toInt();
if (server.hasArg(F("ecoModeTempWasser"))) ecoModeTempWasser = server.arg(F("ecoModeTempWasser")).toInt();
if (server.hasArg(F("ecoModeTempDampf"))) ecoModeTempDampf = server.arg(F("ecoModeTempDampf")).toInt();
if (server.hasArg(F("dynamicEcoMode"))) {
dynamicEcoActive = true;
} else {
dynamicEcoActive = false;
}
if (server.hasArg(F("dampfDelay"))) {
dampfVerzoegerung = server.arg(F("dampfDelay")).toInt();
}
EEPROM.put(EEPROM_ADDR_ECOMODE_MINUTES, ecoModeMinutes);
EEPROM.put(EEPROM_ADDR_ECOMODE_TEMP_WASSER, ecoModeTempWasser);
EEPROM.put(EEPROM_ADDR_ECOMODE_TEMP_DAMPF, ecoModeTempDampf);
EEPROM.put(EEPROM_ADDR_DYNAMIC_ECO_MODE, dynamicEcoActive);
EEPROM.put(EEPROM_ADDR_STEAM_DELAY, dampfVerzoegerung);
EEPROM.commit();
// Wenn Eco deaktiviert, Setpoints zurückladen
if (ecoModeMinutes == 0) {
ecoModeAktiv = false;
ecoModeActivatedTime = 0;
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
}
server.sendHeader(F("Location"), F("/ECO")); // URL angepasst
server.send(303);
}
/************************************************************************************
* Speichert Geräte-Infos
************************************************************************************/
void handleInfoUpdate() {
bool changed = false;
if (server.hasArg(F("hersteller"))) {
strncpy(infoHersteller, server.arg(F("hersteller")).c_str(), sizeof(infoHersteller) - 1);
infoHersteller[sizeof(infoHersteller) - 1] = '\0';
EEPROM.put(EEPROM_ADDR_INFO_HERSTELLER, infoHersteller);
changed = true;
}
if (server.hasArg(F("modell"))) {
strncpy(infoModell, server.arg(F("modell")).c_str(), sizeof(infoModell) - 1);
infoModell[sizeof(infoModell) - 1] = '\0';
EEPROM.put(EEPROM_ADDR_INFO_MODELL, infoModell);
changed = true;
}
if (server.hasArg(F("zusatz"))) {
strncpy(infoZusatz, server.arg(F("zusatz")).c_str(), sizeof(infoZusatz) - 1);
infoZusatz[sizeof(infoZusatz) - 1] = '\0';
EEPROM.put(EEPROM_ADDR_INFO_ZUSATZ, infoZusatz);
changed = true;
}
if (changed) {
EEPROM.commit();
}
server.sendHeader(F("Location"), F("/Info")); // URL angepasst
server.send(303);
}
/************************************************************************************
* Speichert das Wartungsintervall
************************************************************************************/
void handleMaintenanceUpdate() {
bool changed = false;
// Wartungsintervall speichern
if (server.hasArg(F("maintenanceInterval"))) {
int newInterval = server.arg(F("maintenanceInterval")).toInt();
if (newInterval < 0) newInterval = 0; // Sicherstellen, dass nicht negativ
if (newInterval != maintenanceInterval) {
maintenanceInterval = newInterval;
EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL, maintenanceInterval);
changed = true;
Serial.printf("Wartungsintervall gesetzt auf: %d Shots\n", maintenanceInterval);
}
}
if (changed) {
EEPROM.commit();
}
server.sendHeader(F("Location"), F("/Service")); // URL angepasst
server.send(303);
}
/************************************************************************************
* Handler für JSON-Chart-Daten (/Chart-Daten) - Heap-Optimiert
************************************************************************************/
void handleChartData() {
// JSON-Daten als Chunks senden
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
// WICHTIG: Content-Type beibehalten!
server.send(200, F("application/json"), ""); // Header senden
char buffer[20]; // Puffer für Zahlen (double mit einer Nachkommastelle)
server.sendContent(F("{\"wasser\":"));
// Formatierung mit einer Nachkommastelle, wie im Chart-Tooltip
snprintf(buffer, sizeof(buffer), "%.1f", InputWasser);
server.sendContent(buffer);
server.sendContent(F(",\"setpointwasser\":"));
snprintf(buffer, sizeof(buffer), "%.1f", SetpointWasser);
server.sendContent(buffer);
server.sendContent(F(",\"dampf\":"));
snprintf(buffer, sizeof(buffer), "%.1f", InputDampf);
server.sendContent(buffer);
server.sendContent(F(",\"setpointdampf\":"));
snprintf(buffer, sizeof(buffer), "%.1f", SetpointDampf);
server.sendContent(buffer);
server.sendContent(F("}"));
server.sendContent(F("")); // Abschluss-Chunk
}
/************************************************************************************
* Handler für Temperatur-Charts (Live) - URL angepasst (/Chart)
* Lädt chart.js bevorzugt aus LittleFS, sonst CDN, sonst Fehler.
* NEU: Auswahl für maximale Datenpunkte hinzugefügt.
* NEU: Vollbild-Funktion hinzugefügt (Layout korrigiert: Label über Select).
************************************************************************************/
// --- PROGMEM Chunks für die Chart-Seite ---
// Teil 1: HTML-Start, Meta-Tags (Unverändert)
static const char chartsHtml_HeadStart[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="de">
<head>
<title>Temperatur-Chart</title>
<meta charset="UTF-8">
<meta name='theme-color' content='#111111'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
)rawliteral";
// Teil 1b: Spezifische Styles (Normal-Layout: Button mittig zu den Blöcken)
static const char chartsHtml_SpecificStyles[] PROGMEM = R"rawliteral(
<style>
/* Body und Haupt-Wrapper für Flexbox */
html { height: 100%; }
body {
margin: 0; display: flex; flex-direction: column; min-height: 100vh;
}
main.chart-page-content {
flex: 1; display: flex; flex-direction: column; padding: 15px;
box-sizing: border-box; overflow: auto;
max-width: 1600px; margin-left: auto; margin-right: auto; width: 100%;
}
h1.chart-title {
text-align: center; color: #efefef; margin-top: 20px; margin-bottom: 10px;
font-weight: 300; font-size: 1.8rem;
}
/* Container für Selektoren & Button im Normalzustand */
.chart-controls-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center; /* <<< ZURÜCK ZU CENTER für vertikale Mitte */
gap: 25px;
margin: 10px auto 20px auto;
position: relative;
z-index: 10;
transition: all 0.3s ease-in-out;
}
/* Container für einzelne Controls (Label+Select) im Normalzustand */
.chart-controls-container > div {
text-align: left;
}
.chart-controls-container label {
display: block;
margin-bottom: 5px;
color: #ccc; font-size: 0.9em;
}
.chart-controls-container select {
background: #252525; color: #e0e0e0; border: 1px solid #555;
padding: 8px 12px; border-radius: 8px; font-size: 0.9em; cursor: pointer;
min-width: 150px; height: 38px; box-sizing: border-box;
}
.chart-controls-container select:focus {
outline: none; border-color: var(--accent-color, #d89904);
box-shadow: 0 0 8px rgba(216, 153, 4, 0.3);
}
/* Style für den Vollbild-Button im Normalzustand */
#chart-fullscreen-btn {
padding: 8px 12px; border-radius: 8px; font-size: 0.9em; cursor: pointer;
height: 38px; box-sizing: border-box; min-width: 100px;
display: inline-block;
transition: all 0.3s ease-in-out;
margin-top: 30px;
}
#chart-fullscreen-btn:hover {
background: #d89904; color: #000000; border: 1px solid #555;
}
#chart-fullscreen-btn:focus { outline: none; border-color: var(--accent-color, #d89904); box-shadow: 0 0 8px rgba(216, 153, 4, 0.3); }
/* Restliche Styles bleiben unverändert */
.chart-container { width: 95%; height: 65vh; min-height: 350px; margin: 15px auto; background: rgba(0, 0, 0, 0.2); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4); border-radius: 15px; position: relative; border: 1px solid rgba(255, 255, 255, 0.1); padding: 20px; box-sizing: border-box; transition: all 0.3s ease-in-out; }
.chart-container canvas { width: 100% !important; height: 100% !important; }
.sensor-error-message { color: #FFCC00; text-align: center; margin-top: 5px; margin-bottom: 15px; padding: 8px 10px; background-color: rgba(255, 204, 0, 0.1); border: 1px solid rgba(255, 204, 0, 0.2); border-radius: 8px; font-weight: 500; font-size: 0.9em; max-width: 600px; margin-left: auto; margin-right: auto; }
.chart-error-box { margin: 20px auto; padding: 20px; max-width: 600px; background-color: rgba(255, 221, 221, 0.9); border: 1px solid #dc3545; color: #333; border-radius: 8px; text-align: center; }
.chart-error-box h2 { color: #dc3545; margin-bottom: 10px;}
.chart-error-box a { color: #0056b3; text-decoration: underline; }
/* --- Vollbild-Styles (Unverändert zur funktionierenden Version) --- */
body.chart-fullscreen-active { overflow: hidden; }
body.chart-fullscreen-active nav, body.chart-fullscreen-active h1.chart-title, body.chart-fullscreen-active footer { display: none !important; }
body.chart-fullscreen-active main.chart-page-content { padding: 0; max-width: none; height: 100vh; overflow: hidden; }
body.chart-fullscreen-active .chart-container { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 9999; margin: 0; padding: 10px; border-radius: 0; border: none; background: var(--primary-bg, #000000); box-shadow: none; }
body.chart-fullscreen-active .chart-controls-container { position: fixed; top: 10px; right: 15px; z-index: 10000; margin: 0; padding: 0; width: auto; background: none; box-shadow: none; gap: 0; display: block; }
body.chart-fullscreen-active .chart-controls-container > div { display: none !important; }
body.chart-fullscreen-active #chart-fullscreen-btn { border: 1px solid #666; margin: 0; }
/* Mobile Styles (Unverändert zur letzten Version) */
@media (max-width: 600px) {
h1.chart-title { font-size: 1.5rem; }
.chart-container { width: 98%; padding: 10px; height: 65vh; }
.chart-controls-container { flex-direction: column; gap: 15px; align-items: center; }
.chart-controls-container > div { width: 100%; max-width: 250px; text-align: center; }
.chart-controls-container select { width: 100%; }
#chart-fullscreen-btn { margin-top: 10px; margin-left: 0; width: 100%; max-width: 250px; }
.sensor-error-message { width: 90%; font-size: 0.85em; }
.chart-error-box { width: 90%; padding: 15px;}
body.chart-fullscreen-active .chart-controls-container { top: 5px; right: 5px; }
body.chart-fullscreen-active #chart-fullscreen-btn { padding: 6px 10px; font-size: 0.8em; }
}
</style>
)rawliteral";
// Teil 2: Schließendes Head-Tag (Unverändert)
static const char chartsHtml_HeadEnd[] PROGMEM = "</head>\n";
// Teil 3: Selektoren-UI (HTML-Struktur angepasst: Label über Select)
static const char chartsHtml_SelectorsUI[] PROGMEM = R"rawliteral(
<div class="chart-controls-container">
<div> <label for="updateRate">Aktualisierungsrate:</label>
<select id="updateRate">
<option value="1000">1 Sekunde</option> <option value="2000">2 Sekunden</option> <option value="3000">3 Sekunden</option> <option value="5000">5 Sekunden</option> <option value="10000">10 Sekunden</option> <option value="15000" selected>15 Sekunden</option> <option value="30000">30 Sekunden</option> <option value="45000">45 Sekunden</option> <option value="60000">60 Sekunden</option>
</select>
</div>
<div> <label for="datapointLimit">Max. Datenpunkte:</label>
<select id="datapointLimit">
<option value="30">30</option> <option value="50">50</option> <option value="100" selected>100</option> <option value="150">150</option> <option value="200">200</option> <option value="300">300</option> <option value="500">500</option>
</select>
</div>
<button id="chart-fullscreen-btn">Vollbild</button>
</div>
)rawliteral";
// Teil 4: Chart.js Code (Unverändert zur letzten Version)
static const char chartsHtml_ChartJSCode[] PROGMEM = R"rawliteral(
const ctx = document.getElementById('combinedChart').getContext('2d');
let currentSetpointDampf = 0; let currentSetpointWasser = 0; const wasserColor = '#00AEEF'; const dampfColor = '#F7941D'; const setpointWasserColor = 'rgba(0, 174, 239, 0.7)'; const setpointDampfColor = 'rgba(247, 148, 29, 0.7)'; const gridColor = 'rgba(255, 255, 255, 0.1)'; const textColor = '#E0E0E0';
let maxDataPoints = parseInt(document.getElementById('datapointLimit').value, 10) || 100;
const combinedChart = new Chart(ctx, { type: 'line', data: { labels: [], datasets: [ { label: 'Wasser IST', data: [], borderColor: wasserColor, backgroundColor: 'rgba(0, 174, 239, 0.1)', borderWidth: 2, fill: 'start', tension: 0.4, pointRadius: 0, pointHoverRadius: 6, pointHitRadius: 10, pointHoverBackgroundColor: wasserColor }, { label: 'Dampf IST', data: [], borderColor: dampfColor, backgroundColor: 'rgba(247, 148, 29, 0.1)', borderWidth: 2, fill: 'start', tension: 0.4, pointRadius: 0, pointHoverRadius: 6, pointHitRadius: 10, pointHoverBackgroundColor: dampfColor }, { label: 'Wasser SOLL', data: [], borderColor: setpointWasserColor, borderWidth: 1.5, borderDash: [6, 3], fill: false, tension: 0.1, pointRadius: 0, pointHoverRadius: 0 }, { label: 'Dampf SOLL', data: [], borderColor: setpointDampfColor, borderWidth: 1.5, borderDash: [6, 3], fill: false, tension: 0.1, pointRadius: 0, pointHoverRadius: 0 } ] }, options: { responsive: true, maintainAspectRatio: false, animation: false, interaction: { intersect: false, mode: 'index' }, scales: { x: { type: 'category', title: { display: true, text: 'Uhrzeit', color: textColor, font: { size: 13, weight: '300' } }, ticks: { color: textColor, font: { size: 11 }, maxRotation: 0, autoSkip: true, maxTicksLimit: 8 }, grid: { color: gridColor, drawBorder: false } }, y: { suggestedMin: 0, suggestedMax: 180, title: { display: true, text: 'Temperatur (°C)', color: textColor, font: { size: 13, weight: '300' } }, ticks: { color: textColor, font: { size: 11 }, stepSize: 20, callback: function(value, index, values) { return value + ' °C'; } }, grid: { color: gridColor, drawBorder: false } } }, plugins: { legend: { position: 'bottom', align: 'center', labels: { color: textColor, font: { size: 12 }, usePointStyle: true, pointStyle: 'rectRounded', boxWidth: 15, padding: 15 } }, tooltip: { enabled: true, mode: 'index', intersect: false, backgroundColor: 'rgba(0, 0, 0, 0.85)', titleColor: '#ffffff', titleFont: { size: 13, weight: 'bold' }, bodyColor: '#dddddd', bodyFont: { size: 12 }, bodySpacing: 4, borderColor: 'rgba(255, 255, 255, 0.1)', borderWidth: 1, padding: 10, cornerRadius: 8, displayColors: true, boxPadding: 4, callbacks: { label: function(context) { let label = context.dataset.label || ''; if (label) { label += ': '; } if (context.parsed.y !== null) { label += context.parsed.y.toFixed(1) + ' °C'; } return label; } } } } } });
let updateInterval = parseInt(document.getElementById('updateRate').value, 10) || 15000; let intervalId = null;
function fetchData() { fetch('/Chart-Daten').then(response => { if (!response.ok) { console.error('Netzwerkantwort war nicht ok:', response.statusText); throw new Error('Network response was not ok'); } return response.json(); }).then(data => { if (data.setpointwasser !== undefined) currentSetpointWasser = data.setpointwasser; if (data.setpointdampf !== undefined) currentSetpointDampf = data.setpointdampf; const now = new Date(); const timeLabel = now.toLocaleTimeString('de-DE'); const labels = combinedChart.data.labels; const datasets = combinedChart.data.datasets; labels.push(timeLabel); datasets[0].data.push(data.wasser); datasets[1].data.push(data.dampf); datasets[2].data.push(currentSetpointWasser); datasets[3].data.push(currentSetpointDampf); while (labels.length > maxDataPoints) { labels.shift(); datasets.forEach(dataset => { dataset.data.shift(); }); } combinedChart.update('none'); }).catch(err => { console.error("Fehler beim Abrufen der Daten:", err); }); }
function startFetching() { if (intervalId) { clearInterval(intervalId); intervalId = null; } fetchData(); intervalId = setInterval(fetchData, updateInterval); console.log(`Chart-Aktualisierung gestartet: Intervall ${updateInterval / 1000} Sekunden.`); }
document.getElementById('updateRate').addEventListener('change', function(event) { const newInterval = parseInt(event.target.value, 10); if (!isNaN(newInterval) && newInterval > 0) { updateInterval = newInterval; startFetching(); } });
document.getElementById('datapointLimit').addEventListener('change', function(event) { const newLimit = parseInt(event.target.value, 10); if (!isNaN(newLimit) && newLimit > 0) { maxDataPoints = newLimit; console.log(`Maximale Datenpunkte geändert auf: ${maxDataPoints}`); } });
const fullscreenBtn = document.getElementById('chart-fullscreen-btn'); const bodyElement = document.body;
fullscreenBtn.addEventListener('click', () => { bodyElement.classList.toggle('chart-fullscreen-active'); if (bodyElement.classList.contains('chart-fullscreen-active')) { fullscreenBtn.textContent = 'Verlassen'; } else { fullscreenBtn.textContent = 'Vollbild'; } setTimeout(() => { if (typeof combinedChart !== 'undefined' && combinedChart) { combinedChart.resize(); } }, 50); });
startFetching();
)rawliteral";
// Teil 5: Chunk für den Fehlerfall (Unverändert)
static const char chartsHtml_ErrorBox[] PROGMEM = R"rawliteral(
<div class='chart-error-box'> <h2>Chart kann nicht angezeigt werden</h2> <p>Die Chart-Bibliothek (chart.umd.min.js) wurde weder im Dateisystem gefunden, noch besteht eine Internetverbindung zum Laden vom CDN.</p> <p>Bitte laden Sie die Datei <a href='https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js' target='_blank'>chart.umd.min.js</a> herunter und laden Sie sie über den <a href='/Dateimanager' target='_blank'>Dateimanager</a> hoch, oder verbinden Sie das Gerät mit dem Internet.</p> </div>
)rawliteral";
// Teil 6: Schließendes Body- und HTML-Tag (Unverändert)
static const char chartsHtml_BodyEnd[] PROGMEM = R"rawliteral(
</body>
</html>
)rawliteral";
// --- Vollständige handleCharts Funktion ---
void handleCharts() // URL: /Chart
{
bool chartJsLocal = LittleFS.exists("/chart.umd.min.js");
bool isConnected = (WiFi.status() == WL_CONNECTED);
String chartJsUrl = "";
bool canLoadChartJs = false;
if (chartJsLocal) { chartJsUrl = "/chart.umd.min.js"; canLoadChartJs = true; Serial.println("Chart-Handler: Verwende lokale chart.umd.min.js"); }
else if (isConnected) { chartJsUrl = "https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js"; canLoadChartJs = true; Serial.println("Chart-Handler: Lade chart.umd.min.js vom CDN."); }
else { canLoadChartJs = false; Serial.println("Chart-Handler: Kann chart.js nicht laden."); }
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, F("text/html; charset=utf-8"), "");
server.sendContent_P(chartsHtml_HeadStart);
server.sendContent_P(commonStyle);
server.sendContent_P(chartsHtml_SpecificStyles); // CSS mit korrektem Normal-Layout
if (canLoadChartJs) {
String scriptTag = "<script src=\""; scriptTag += chartJsUrl; scriptTag += "\"></script>\n";
server.sendContent(scriptTag); yield();
}
server.sendContent_P(chartsHtml_HeadEnd);
server.sendContent("<body>\n");
server.sendContent_P(commonNav);
server.sendContent("<main class=\"chart-page-content\">\n");
server.sendContent("<h1 class=\"chart-title\">Temperatur-Chart</h1>\n");
if (canLoadChartJs) {
server.sendContent_P(chartsHtml_SelectorsUI); // HTML mit Label über Select
yield();
// Sensorfehler-Meldungen
String errorMessageHtml = "";
if (demoModus) { errorMessageHtml = "<div class=\"sensor-error-message\"><b>Demo-Betrieb:</b><br>Es sind keine Sensoren angeschlossen.</div>"; }
else if (wasserSensorError && dampfSensorError) { errorMessageHtml = "<div class=\"sensor-error-message\">Temperatursensoren nicht verf&uuml;gbar! Bitte pr&uuml;fen!</div>"; }
else if (wasserSensorError) { errorMessageHtml = "<div class=\"sensor-error-message\">Temperaturwert Wasser nicht verf&uuml;gbar</div>"; }
else if (dampfSensorError) { errorMessageHtml = "<div class=\"sensor-error-message\">Temperaturwert Dampf nicht verf&uuml;gbar</div>"; }
if (errorMessageHtml.length() > 0) { server.sendContent(errorMessageHtml); }
yield();
server.sendContent("<div class=\"chart-container\">\n");
server.sendContent("<canvas id=\"combinedChart\"></canvas>\n");
server.sendContent("</div>\n");
yield();
server.sendContent("<script>\n");
server.sendContent_P(chartsHtml_ChartJSCode);
server.sendContent("\n</script>\n");
yield();
} else {
server.sendContent_P(chartsHtml_ErrorBox);
yield();
}
server.sendContent("</main>\n");
server.sendContent_P(chartsHtml_BodyEnd);
server.sendContent("");
yield();
} // Ende handleCharts()
/************************************************************************************
* Hilfsfunktionen für Profile
************************************************************************************/
// Hilfsfunktion zum Bereinigen von Profilnamen für Dateinamen
String sanitizeProfileName(String profileName) {
profileName.trim(); // Leerzeichen am Anfang/Ende entfernen
if (profileName.length() == 0) {
profileName = "Unbenannt";
}
// Ersetze ungültige Zeichen (Beispiel: Leerzeichen, /, \, :, *, ?, ", <, >, |)
profileName.replace(" ", "_");
profileName.replace("/", "_");
profileName.replace("\\", "_");
profileName.replace(":", "_");
profileName.replace("*", "_");
profileName.replace("?", "_");
profileName.replace("\"", "_");
profileName.replace("<", "_");
profileName.replace(">", "_");
profileName.replace("|", "_");
// Maximale Länge begrenzen (z.B. 30 Zeichen + .txt)
if (profileName.length() > 30) {
profileName = profileName.substring(0, 30);
}
return profileName;
}
// Listet alle Profile (.prof Dateien) im /Profile Verzeichnis auf
std::vector<String> listProfiles() {
std::vector<String> profileNames;
Serial.println("Listing profiles (.prof) in /Profile:"); // Debug-Meldung angepasst
#ifdef ESP32
File root = LittleFS.open("/Profile");
if (!root || !root.isDirectory()) {
Serial.println(" Failed to open /Profile directory.");
if(root) root.close();
return profileNames; // Leere Liste zurückgeben
}
File file = root.openNextFile();
while(file){
String filePath = file.path(); // ESP32 gibt vollen Pfad inkl. /Profile/ zurück
if (!file.isDirectory() && filePath.endsWith(".prof")) { // *** GEÄNDERT ZU .prof ***
// Extrahiere nur den Namen ohne Pfad und Endung
int lastSlash = filePath.lastIndexOf('/');
int lastDot = filePath.lastIndexOf('.'); // Sollte .prof sein
if (lastSlash != -1 && lastDot != -1 && lastDot > lastSlash) {
String profileName = filePath.substring(lastSlash + 1, lastDot); // Extrahiert den Namen
profileNames.push_back(profileName);
Serial.printf(" - Found profile: %s\n", profileName.c_str());
} else {
Serial.printf(" - Ignoring file (invalid format?): %s\n", filePath.c_str());
}
} else if (!file.isDirectory()) {
Serial.printf(" - Ignoring file (not .prof): %s\n", filePath.c_str()); // Meldung angepasst
} else {
Serial.printf(" - Ignoring directory: %s\n", filePath.c_str());
}
file.close(); // Wichtig!
file = root.openNextFile();
yield(); // Wichtig bei vielen Dateien
}
root.close();
#else // ESP8266
Dir dir = LittleFS.openDir("/Profile");
while (dir.next()) {
String fileName = dir.fileName(); // Gibt nur Dateiname.prof zurück
if (!dir.isDirectory() && fileName.endsWith(".prof")) { // *** GEÄNDERT ZU .prof ***
int lastDot = fileName.lastIndexOf('.'); // Sollte .prof sein
if (lastDot != -1) {
String profileName = fileName.substring(0, lastDot); // Extrahiert den Namen
profileNames.push_back(profileName);
Serial.printf(" - Found profile: %s\n", profileName.c_str());
} else {
Serial.printf(" - Ignoring file (no extension?): %s\n", fileName.c_str());
}
} else if (!dir.isDirectory()) {
Serial.printf(" - Ignoring file (not .prof): %s\n", fileName.c_str()); // Meldung angepasst
} else {
Serial.printf(" - Ignoring directory: %s\n", fileName.c_str());
}
yield(); // Wichtig bei vielen Dateien
}
// dir schließt automatisch
#endif
Serial.printf("Found %d profiles.\n", profileNames.size());
return profileNames;
}
// Speichert die übergebenen Profileinstellungen als BINÄRE Datei (.prof)
bool saveProfile(const String& profileName, const TemperatureProfile& profileData) {
String sanitizedName = sanitizeProfileName(profileName);
if (sanitizedName.length() == 0) return false; // Kein gültiger Name nach Bereinigung
// Dateiendung auf .prof ändern, um Text von Binär zu unterscheiden
String filePath = "/Profile/" + sanitizedName + ".prof";
Serial.printf("Saving profile (binary) to: %s\n", filePath.c_str());
File profileFile = LittleFS.open(filePath, "w"); // "w" für (binäres) Schreiben
if (!profileFile) {
Serial.println(" ERROR: Failed to open file for writing.");
return false;
}
// Stelle sicher, dass die Versionsnummer korrekt ist, bevor gespeichert wird
// Das sollte im Konstruktor oder in getCurrentSettingsAsProfile passieren,
// aber zur Sicherheit hier nochmal setzen:
TemperatureProfile dataToSave = profileData; // Kopie erstellen
dataToSave.profileVersion = CURRENT_PROFILE_VERSION; // Sicherstellen!
// Schreibe die gesamte Struktur als Bytes
size_t bytesWritten = profileFile.write((uint8_t*)&dataToSave, sizeof(TemperatureProfile));
profileFile.close(); // Datei schließen
if (bytesWritten == sizeof(TemperatureProfile)) {
Serial.println(" Profile saved successfully (Binary Format).");
return true;
} else {
Serial.printf(" ERROR: Failed to write complete profile data! Bytes written: %d, Expected: %d\n", bytesWritten, sizeof(TemperatureProfile));
// Versuch, die unvollständige Datei zu löschen
LittleFS.remove(filePath);
return false;
}
}
// Lädt ein Profil aus einer BINÄREN Datei (.prof) in die übergebene Struktur
bool loadProfile(const String& profileName, TemperatureProfile& profileData) {
String sanitizedName = sanitizeProfileName(profileName); // Verwende den Namen aus der Liste
if (sanitizedName.length() == 0) return false;
// Dateiendung auf .prof ändern
String filePath = "/Profile/" + sanitizedName + ".prof";
Serial.printf("Loading profile (binary) from: %s\n", filePath.c_str());
if (!LittleFS.exists(filePath)) {
Serial.println(" ERROR: Profile file not found.");
return false;
}
File profileFile = LittleFS.open(filePath, "r"); // "r" für (binäres) Lesen
if (!profileFile) {
Serial.println(" ERROR: Failed to open profile file for reading.");
return false;
}
// --- Validierung der Dateigröße ---
size_t fileSize = profileFile.size();
if (fileSize != sizeof(TemperatureProfile)) {
Serial.printf(" ERROR: Profile file size mismatch! Expected %d bytes, got %d bytes.\n", sizeof(TemperatureProfile), fileSize);
Serial.println(" Profile might be corrupted or from an incompatible version.");
profileFile.close();
return false;
}
// Lese die Struktur-Daten
size_t bytesRead = profileFile.read((uint8_t*)&profileData, sizeof(TemperatureProfile));
profileFile.close(); // Datei nach dem Lesen schließen
if (bytesRead != sizeof(TemperatureProfile)) {
Serial.printf(" ERROR: Failed to read complete profile data! Bytes read: %d\n", bytesRead);
return false;
}
// --- Validierung der Profil-Version ---
if (profileData.profileVersion != CURRENT_PROFILE_VERSION) {
Serial.printf(" ERROR: Profile version mismatch! Expected version %d, got version %d.\n", CURRENT_PROFILE_VERSION, profileData.profileVersion);
Serial.println(" Cannot load profile from incompatible version.");
// Optional: Hier könnte man versuchen, alte Versionen zu konvertieren
return false;
}
Serial.println(" Profile loaded successfully (Binary Format).");
// Der in der Datei gespeicherte `profileName` ist jetzt in profileData.profileName
Serial.printf(" Loaded Profile Name from file: %s\n", profileData.profileName);
return true;
}
/************************************************************************************
* Wendet ALLE geladenen Profileinstellungen auf die aktuellen Systemvariablen und EEPROM an
* Inklusive Brew-by-Weight und Versionscheck.
************************************************************************************/
void applyProfileSettings(const TemperatureProfile& profileData) {
Serial.printf("Applying settings from loaded profile: %s (Version %d)\n", profileData.profileName, profileData.profileVersion);
// --- Prüfen der Profilversion (SEHR WICHTIG!) ---
if (profileData.profileVersion != CURRENT_PROFILE_VERSION) {
Serial.printf(" FEHLER: Kann Profil '%s' nicht anwenden. Version %d ist inkompatibel mit erwarteter Version %d.\n",
profileData.profileName, profileData.profileVersion, CURRENT_PROFILE_VERSION);
profileStatusMessage = "FEHLER: Profil '" + String(profileData.profileName) + "' hat eine inkompatible Version ("
+ String(profileData.profileVersion) + ", erwartet " + String(CURRENT_PROFILE_VERSION) + ").";
return; // Funktion hier abbrechen
}
// --- Globale Variablen direkt aktualisieren ---
SetpointWasser = profileData.setpointWasser; SetpointDampf = profileData.setpointDampf;
OffsetWasser = profileData.offsetWasser; OffsetDampf = profileData.offsetDampf;
KpWasser = profileData.kpWasser; KiWasser = profileData.kiWasser; KdWasser = profileData.kdWasser;
KpDampf = profileData.kpDampf; KiDampf = profileData.kiDampf; KdDampf = profileData.kdDampf;
boostWasserActive = profileData.boostWasserActive; boostDampfActive = profileData.boostDampfActive;
preventHeatAboveSetpointWasser = profileData.preventHeatAboveSetpointWasser; preventHeatAboveSetpointDampf = profileData.preventHeatAboveSetpointDampf;
windowSizeWasser = profileData.windowSizeWasser; windowSizeDampf = profileData.windowSizeDampf;
maxTempWasser = profileData.maxTempWasser; maxTempDampf = profileData.maxTempDampf;
tuningStepWasser = profileData.tuningStepWasser; tuningNoiseWasser = profileData.tuningNoiseWasser; tuningStartValueWasser = profileData.tuningStartValueWasser; tuningLookBackWasser = profileData.tuningLookBackWasser;
tuningStepDampf = profileData.tuningStepDampf; tuningNoiseDampf = profileData.tuningNoiseDampf; tuningStartValueDampf = profileData.tuningStartValueDampf; tuningLookBackDampf = profileData.tuningLookBackDampf;
ecoModeMinutes = profileData.ecoModeMinutes; ecoModeTempWasser = profileData.ecoModeTempWasser; ecoModeTempDampf = profileData.ecoModeTempDampf;
dynamicEcoActive = profileData.dynamicEcoActive; dampfVerzoegerung = profileData.dampfVerzoegerung;
fastHeatUpAktiv = profileData.fastHeatUpAktiv;
// Brew Control Einstellungen anwenden
brewByTimeEnabled = profileData.brewByTimeEnabled; brewByTimeTargetSeconds = profileData.brewByTimeTargetSeconds;
preInfusionEnabled = profileData.preInfusionEnabled; preInfusionDurationSeconds = profileData.preInfusionDurationSeconds; preInfusionPauseSeconds = profileData.preInfusionPauseSeconds;
// NEU: Brew-by-Weight Einstellungen anwenden
#ifdef ESP32
brewByWeightEnabled = profileData.brewByWeightEnabled; brewByWeightTargetGrams = profileData.brewByWeightTargetGrams; brewByWeightOffsetGrams = profileData.brewByWeightOffsetGrams;
#endif
// --- Werte ins EEPROM schreiben (an die korrekten Adressen) ---
EEPROM.put(EEPROM_ADDR_SETPOINT_WASSER, profileData.setpointWasser); EEPROM.put(EEPROM_ADDR_SETPOINT_DAMPF, profileData.setpointDampf);
EEPROM.put(EEPROM_ADDR_OFFSET_WASSER, profileData.offsetWasser); EEPROM.put(EEPROM_ADDR_OFFSET_DAMPF, profileData.offsetDampf);
EEPROM.put(EEPROM_ADDR_KP_WASSER, profileData.kpWasser); EEPROM.put(EEPROM_ADDR_KI_WASSER, profileData.kiWasser); EEPROM.put(EEPROM_ADDR_KD_WASSER, profileData.kdWasser);
EEPROM.put(EEPROM_ADDR_KP_DAMPF, profileData.kpDampf); EEPROM.put(EEPROM_ADDR_KI_DAMPF, profileData.kiDampf); EEPROM.put(EEPROM_ADDR_KD_DAMPF, profileData.kdDampf);
EEPROM.put(EEPROM_ADDR_BOOST_WASSER_ACTIVE, profileData.boostWasserActive); EEPROM.put(EEPROM_ADDR_BOOST_DAMPF_ACTIVE, profileData.boostDampfActive);
EEPROM.put(EEPROM_ADDR_PREVENTHEAT_WASSER, profileData.preventHeatAboveSetpointWasser); EEPROM.put(EEPROM_ADDR_PREVENTHEAT_DAMPF, profileData.preventHeatAboveSetpointDampf);
EEPROM.put(EEPROM_ADDR_WINDOWSIZE_WASSER, profileData.windowSizeWasser); EEPROM.put(EEPROM_ADDR_WINDOWSIZE_DAMPF, profileData.windowSizeDampf);
EEPROM.put(EEPROM_ADDR_MAX_TEMP_WASSER, profileData.maxTempWasser); EEPROM.put(EEPROM_ADDR_MAX_TEMP_DAMPF, profileData.maxTempDampf);
EEPROM.put(EEPROM_ADDR_TUNING_STEP_WASSER, profileData.tuningStepWasser); EEPROM.put(EEPROM_ADDR_TUNING_NOISE_WASSER, profileData.tuningNoiseWasser); EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_WASSER, profileData.tuningStartValueWasser); EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_WASSER, profileData.tuningLookBackWasser);
EEPROM.put(EEPROM_ADDR_TUNING_STEP_DAMPF, profileData.tuningStepDampf); EEPROM.put(EEPROM_ADDR_TUNING_NOISE_DAMPF, profileData.tuningNoiseDampf); EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_DAMPF, profileData.tuningStartValueDampf); EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_DAMPF, profileData.tuningLookBackDampf);
EEPROM.put(EEPROM_ADDR_ECOMODE_MINUTES, profileData.ecoModeMinutes); EEPROM.put(EEPROM_ADDR_ECOMODE_TEMP_WASSER, profileData.ecoModeTempWasser); EEPROM.put(EEPROM_ADDR_ECOMODE_TEMP_DAMPF, profileData.ecoModeTempDampf);
EEPROM.put(EEPROM_ADDR_DYNAMIC_ECO_MODE, profileData.dynamicEcoActive); EEPROM.put(EEPROM_ADDR_STEAM_DELAY, profileData.dampfVerzoegerung);
EEPROM.put(EEPROM_ADDR_FASTHEATUP_DATA, profileData.fastHeatUpAktiv);
// Brew Control Einstellungen ins EEPROM schreiben
EEPROM.put(EEPROM_ADDR_BREWBYTIME_ENABLED, profileData.brewByTimeEnabled); EEPROM.put(EEPROM_ADDR_BREWBYTIME_SECONDS, profileData.brewByTimeTargetSeconds);
EEPROM.put(EEPROM_ADDR_PREINF_ENABLED, profileData.preInfusionEnabled); EEPROM.put(EEPROM_ADDR_PREINF_DUR_SEC, profileData.preInfusionDurationSeconds); EEPROM.put(EEPROM_ADDR_PREINF_PAUSE_SEC, profileData.preInfusionPauseSeconds);
// NEU: Brew-by-Weight Einstellungen ins EEPROM schreiben
#ifdef ESP32
EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_ENABLED, profileData.brewByWeightEnabled); EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_TARGET, profileData.brewByWeightTargetGrams); EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_OFFSET, profileData.brewByWeightOffsetGrams);
#endif
// Commit ins EEPROM nicht vergessen
if (!EEPROM.commit()) { Serial.println(" ERROR: EEPROM commit failed while applying profile settings!"); }
else { Serial.println(" EEPROM updated with loaded profile settings."); }
// --- PID-Regler sofort aktualisieren ---
pidWasser.SetTunings(KpWasser, KiWasser, KdWasser); pidDampf.SetTunings(KpDampf, KiDampf, KdDampf);
pidWasser.SetOutputLimits(0, windowSizeWasser); pidDampf.SetOutputLimits(0, windowSizeDampf);
Serial.println(" PID controllers reconfigured with loaded settings.");
// Eco-Status zurücksetzen
ecoModeAktiv = false; ecoModeActivatedTime = 0;
Serial.println(" Eco mode status reset.");
}
/************************************************************************************
* Holt ALLE aktuellen relevanten Einstellungen aus den globalen Variablen
* und packt sie in eine Profilstruktur (inkl. Brew-by-Weight).
************************************************************************************/
TemperatureProfile getCurrentSettingsAsProfile() {
TemperatureProfile currentProfile; // Ruft den Konstruktor auf
// Setze die aktuelle Versionsnummer in das zu speichernde Profil
currentProfile.profileVersion = CURRENT_PROFILE_VERSION;
// --- Globale Variablen in die Struktur kopieren ---
// PID (/), ECO (/ECO), FastHeatUp (/Fast-Heat-Up)
currentProfile.setpointWasser = SetpointWasser; currentProfile.setpointDampf = SetpointDampf;
currentProfile.offsetWasser = OffsetWasser; currentProfile.offsetDampf = OffsetDampf;
currentProfile.kpWasser = KpWasser; currentProfile.kiWasser = KiWasser; currentProfile.kdWasser = KdWasser;
currentProfile.kpDampf = KpDampf; currentProfile.kiDampf = KiDampf; currentProfile.kdDampf = KdDampf;
currentProfile.boostWasserActive = boostWasserActive; currentProfile.boostDampfActive = boostDampfActive;
currentProfile.preventHeatAboveSetpointWasser = preventHeatAboveSetpointWasser; currentProfile.preventHeatAboveSetpointDampf = preventHeatAboveSetpointDampf;
currentProfile.windowSizeWasser = windowSizeWasser; currentProfile.windowSizeDampf = windowSizeDampf;
currentProfile.maxTempWasser = maxTempWasser; currentProfile.maxTempDampf = maxTempDampf;
currentProfile.ecoModeMinutes = ecoModeMinutes; currentProfile.ecoModeTempWasser = ecoModeTempWasser; currentProfile.ecoModeTempDampf = ecoModeTempDampf;
currentProfile.dynamicEcoActive = dynamicEcoActive; currentProfile.dampfVerzoegerung = dampfVerzoegerung;
currentProfile.fastHeatUpAktiv = fastHeatUpAktiv;
// PID Tuning (/PID-Tuning)
currentProfile.tuningStepWasser = tuningStepWasser; currentProfile.tuningNoiseWasser = tuningNoiseWasser; currentProfile.tuningStartValueWasser = tuningStartValueWasser; currentProfile.tuningLookBackWasser = tuningLookBackWasser;
currentProfile.tuningStepDampf = tuningStepDampf; currentProfile.tuningNoiseDampf = tuningNoiseDampf; currentProfile.tuningStartValueDampf = tuningStartValueDampf; currentProfile.tuningLookBackDampf = tuningLookBackDampf;
// Brew Control Einstellungen in Struktur kopieren
currentProfile.brewByTimeEnabled = brewByTimeEnabled; currentProfile.brewByTimeTargetSeconds = brewByTimeTargetSeconds;
currentProfile.preInfusionEnabled = preInfusionEnabled; currentProfile.preInfusionDurationSeconds = preInfusionDurationSeconds; currentProfile.preInfusionPauseSeconds = preInfusionPauseSeconds;
// NEU: Brew-by-Weight Einstellungen in Struktur kopieren
#ifdef ESP32
currentProfile.brewByWeightEnabled = brewByWeightEnabled; currentProfile.brewByWeightTargetGrams = brewByWeightTargetGrams; currentProfile.brewByWeightOffsetGrams = brewByWeightOffsetGrams;
#else
// Setze auf Defaults für ESP8266, falls das Profil dort erstellt wird (unwahrscheinlich, aber sicher)
currentProfile.brewByWeightEnabled = defaultBrewByWeightEnabled;
currentProfile.brewByWeightTargetGrams = defaultBrewByWeightTargetGrams;
currentProfile.brewByWeightOffsetGrams = defaultBrewByWeightOffsetGrams;
#endif
// Profilname bleibt vorerst leer, wird in handleSaveProfile gesetzt
strncpy(currentProfile.profileName, "", sizeof(currentProfile.profileName));
return currentProfile;
}
// Löscht eine Profildatei (.prof)
bool deleteProfile(const String& profileName) {
String sanitizedName = sanitizeProfileName(profileName); // Verwende den Namen aus der Liste
if (sanitizedName.length() == 0) return false;
// Dateiendung .prof verwenden
String filePath = "/Profile/" + sanitizedName + ".prof"; // *** GEÄNDERT ZU .prof ***
Serial.printf("Deleting profile: %s\n", filePath.c_str());
if (LittleFS.exists(filePath)) {
if (LittleFS.remove(filePath)) {
Serial.println(" Profile deleted successfully.");
return true;
} else {
Serial.println(" ERROR: Failed to delete profile file.");
return false;
}
} else {
Serial.println(" ERROR: Profile file not found, cannot delete.");
return false;
}
}
// *** ENDE PROFIL-HILFS-FUNKTIONEN ***
// --- PROGMEM Chunks für die neue Profil-Seite ---
static const char profilesPageHead[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html><head><title>Profile</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>)rawliteral";
static const char profilesPageStyle[] PROGMEM = R"rawliteral(
<style>
/* Zusätzliche Styles für die Profilliste und Speicher-Form */
.profiles-form {
background: none;
backdrop-filter: none;
border-radius: 12px;
padding: 0px;
margin: 0px;
width: 90%;
max-width: 600px;
box-shadow: none;
color: var(--text-color);
}
.profile-list, .save-profile-form {
max-width: 650px;
margin-left: auto;
margin-right: auto;
background: rgba(255, 255, 255, var(--card-bg-opacity));
backdrop-filter: blur(8px);
border-radius: 12px;
padding: 20px 25px; /* Haupt-Innenabstand der Karten */
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.profile-list {
list-style: none;
padding-top: 15px;
padding-bottom: 15px;
margin-top: 25px;
margin-bottom: 0;
}
.save-profile-form {
margin-top: 15px;
margin-bottom: 25px;
}
.profile-list h3, .save-profile-form h3 {
margin-top: 0;
margin-bottom: 18px;
font-weight: 600;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
}
.profile-list li {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(0,0,0, 0.2);
padding: 8px 15px; /* <<< Horizontalen Innenabstand reduziert (war 18px) */
margin-bottom: 10px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.15);
transition: background-color 0.2s ease, transform 0.2s ease;
}
.profile-list li:hover {
background: rgba(255, 255, 255, 0.15);
transform: scale(1.015);
}
.profile-list li.no-profiles i {
color: #aaa;
display: block;
width: 100%;
text-align: center;
font-size: 0.95em;
}
.profile-list li.no-profiles:hover {
background: rgba(0,0,0, 0.2);
transform: none;
}
.profile-list span {
font-weight: 500;
font-size: 1.05em;
margin-right: 10px; /* <<< Abstand zum Button-Bereich leicht reduziert */
flex-grow: 1;
color: #ffffff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-actions {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 5px; /* <<< Abstand zwischen Buttons weiter reduziert (war 6px) */
}
.profile-actions form {
display: inline-block;
}
/* Buttons in der Liste werden kompakter */
.profile-actions button {
padding: 5px 10px; /* <<< Button Padding weiter reduziert */
font-size: 0.85em;
cursor: pointer;
border-radius: 18px;
border: none;
font-weight: 600;
transition: all 0.2s ease;
/* min-width entfernt, damit Buttons schmaler werden können */
text-align: center;
line-height: 1.2;
border: none;
border-radius: 30px;
padding: 10px 20px;
margin-top: 0px;
width: auto;
min-width: 100px;
max-width: 200px;
cursor: pointer;
font-weight: 600;
box-shadow: none;
transition: all 0.3s ease;
}
.profile-actions button:hover {
opacity: 0.85;
transform: translateY(-1px);
}
.profile-actions .load-button { background-color: #28a745; color: white; }
.profile-actions .delete-button { background-color: #dc3545; color: white; }
/* Speichern-Button bleibt unverändert */
#save-profile-button {
background-color: var(--accent-color);
color: #000000;
width: 100%;
max-width: 300px;
margin-left: auto;
margin-right: auto;
padding: 8px 15px;
font-size: 0.9em;
border-radius: 20px;
border: none; /* Sicherstellen, dass kein Rand da ist */
font-weight: 600; /* Fettschrift */
transition: all 0.2s ease; /* Übergang für Hover */
cursor: pointer; /* Mauszeiger */
line-height: 1.2; /* Vertikale Textzentrierung verbessern */
}
#save-profile-button:hover {
opacity: 0.85;
transform: translateY(-1px);
}
/* Style für das Eingabefeld (unverändert) */
.save-profile-form input[type="text"] {
width: calc(100% - 22px);
display: block;
margin-bottom: 10px;
background: rgba(0, 0, 0, 0.4);
color: var(--text-color);
border: 1px solid #555;
border-radius: 8px;
padding: 10px;
box-shadow: inset 0 4px 8px rgba(0, 0, 0, 0.2);
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.save-profile-form input[type="text"]:focus {
outline: none;
background: rgba(0, 0, 0, 0.6);
border-color: var(--accent-color, #d89904);
box-shadow: 0 0 8px rgba(216, 153, 4, 0.3);
}
.status-message {
text-align: center;
padding: 8px 10px; /* Angepasst an Vorlage */
margin: 15px auto; /* Behält oberen/unteren Abstand und Zentrierung */
max-width: 600px; /* Angepasst an Vorlage */
border-radius: 8px;
color: white; /* Beibehalten für guten Kontrast */
font-weight: 500; /* Angepasst an Vorlage (war bold) */
font-size: 0.9em; /* Angepasst an Vorlage */
border: 1px solid transparent; /* Basis-Rahmen, Farbe wird unten überschrieben */
background-color: transparent; /* Basis-Hintergrund, wird unten überschrieben */
}
/* Spezifische Styles für Erfolgsmeldungen */
.status-success {
background-color: rgba(40, 167, 69, 0.15); /* Dezentes Grün-BG (RGB von Bootstrap .btn-success) */
border-color: rgba(40, 167, 69, 0.3); /* Etwas sichtbarer grüner Rahmen */
color: #62c34b
}
/* Spezifische Styles für Fehlermeldungen */
.status-error {
background-color: rgba(220, 53, 69, 0.15); /* Dezentes Rot-BG (RGB von Bootstrap .btn-danger) */
border-color: rgba(220, 53, 69, 0.3); /* Etwas sichtbarer roter Rahmen */
color: #f8d7da
}
@media (max-width: 600px) {
.profile-list li { flex-direction: column; align-items: stretch; padding: 10px 15px; /* Padding für Mobile anpassen */}
.profile-list span { margin-bottom: 10px; white-space: normal; margin-right: 0;}
.profile-actions { margin-top: 10px; width: 100%; display: flex; justify-content: flex-end; gap: 8px; /* Etwas mehr Lücke auf Mobile */}
.profile-actions button { padding: 6px 10px; font-size: 0.8em; /* Ggf. noch kleiner auf Mobile */ }
.save-profile-form input[type="text"] { width: calc(100% - 22px); }
#save-profile-button { max-width: none; }
}
</style></head>)rawliteral"; // Schließt </head>
static const char profilesPageBodyStart[] PROGMEM = R"rawliteral(
<body><h1>Temperaturprofile</h1>)rawliteral";
static const char profilesPageStatusPlaceholder[] PROGMEM = R"rawliteral({STATUS_MESSAGE_PLACEHOLDER})rawliteral";
static const char profilesPageListStart[] PROGMEM = R"rawliteral(
<div class="profile-list"><h3>Gespeicherte Profile</h3><ul>)rawliteral";
static const char profilesPageListItemStart[] PROGMEM = R"rawliteral(<li><span>)rawliteral"; // Nach <span>
static const char profilesPageListItemEnd[] PROGMEM = R"rawliteral(</span><div class="profile-actions">
<form action="/loadProfile" method="POST" class="profiles-form"><input type="hidden" name="profileName" value="{PROFILE_NAME}"><button type="submit" class="load-button">Laden</button></form>
<form action="/deleteProfile" method="POST" onsubmit="return confirm('Profil \'{PROFILE_NAME}\' wirklich entfernen?');" class="profiles-form"><input type="hidden" name="profileName" value="{PROFILE_NAME}"><button type="submit" class="delete-button">Entfernen</button></form>
</div></li>)rawliteral"; // Ersetzt {PROFILE_NAME} mehrfach
static const char profilesPageNoProfiles[] PROGMEM = R"rawliteral(<li class="no-profiles"><i>Keine Profile gespeichert.</i></li>)rawliteral";
static const char profilesPageListEnd[] PROGMEM = R"rawliteral(</ul></div>)rawliteral";
static const char profilesPageSaveForm[] PROGMEM = R"rawliteral(
<form action="/saveProfile" method="POST" class="save-profile-form">
<h3>Aktuelle Einstellungen speichern</h3>
<label for="newProfileName">Profilname:</label>
<input type="text" id="newProfileName" name="profileName" placeholder="Neuer Profilname" required maxlength="30">
<p style="font-size:0.9em; ">(Wird als neues Profil gespeichert oder &uuml;berschreibt ein bestehendes mit gleichem Namen)</p>
<button type="submit" id="save-profile-button">Aktuelle Einstellungen speichern</button>
</form>
</body></html>)rawliteral";
// ----------- Ende PROGMEM Chunks -----------
// Zeigt die Profil-Verwaltungsseite an
void handleProfilesPage() {
std::vector<String> profiles = listProfiles(); // Hole die Liste der Profile
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "text/html", "");
server.sendContent_P(profilesPageHead);
server.sendContent_P(commonStyle); // Globale Styles
server.sendContent_P(profilesPageStyle); // Seiten-spezifische Styles und </head>
server.sendContent_P(commonNav); // Navigation
server.sendContent_P(profilesPageBodyStart); // <body> und <h1>
// Statusmeldung einfügen (falls vorhanden)
if (profileStatusMessage.length() > 0) {
String statusDiv = "<div class=\"status-message ";
// Annahme: Nachrichten, die mit "FEHLER" beginnen, sind Fehler
if (profileStatusMessage.startsWith("FEHLER")) {
statusDiv += "status-error";
} else {
statusDiv += "status-success";
}
statusDiv += "\">" + profileStatusMessage + "</div>";
server.sendContent(statusDiv);
profileStatusMessage = ""; // Nachricht entfernen, nachdem sie angezeigt wurde
}
// Profilliste generieren
server.sendContent_P(profilesPageListStart); // <ul>
if (profiles.empty()) {
server.sendContent_P(profilesPageNoProfiles);
} else {
for (const String& name : profiles) {
server.sendContent_P(profilesPageListItemStart); // <li><span>
server.sendContent(name); // Profilnamen einfügen
String itemEnd = FPSTR(profilesPageListItemEnd);
itemEnd.replace("{PROFILE_NAME}", name); // Platzhalter im Template ersetzen
server.sendContent(itemEnd); // Rest des <li> Elements
yield(); // Bei vielen Profilen
}
}
server.sendContent_P(profilesPageListEnd); // </ul>
// Speicherformular
server.sendContent_P(profilesPageSaveForm); // Formular und </body></html>
server.sendContent(""); // Abschluss
}
// Lädt ein ausgewähltes Profil und wendet es an
void handleLoadProfile() {
if (server.hasArg("profileName")) {
String profileToLoad = server.arg("profileName");
TemperatureProfile loadedData;
if (loadProfile(profileToLoad, loadedData)) {
applyProfileSettings(loadedData);
// *** KORRIGIERT: Erfolgsmeldung hier ***
profileStatusMessage = "Profil '" + String(loadedData.profileName) + "' erfolgreich geladen und angewendet.";
} else {
// *** KORRIGIERT: Fehlermeldung hier ***
profileStatusMessage = "FEHLER: Profil '" + profileToLoad + "' konnte nicht geladen werden, da es eventuell zu dieser Firmware-Version nicht mehr kompatibel ist.";
}
} else {
profileStatusMessage = "FEHLER: Kein Profilname zum Laden &uuml;bermittelt.";
}
// Redirect zurück zur Profilseite, um die Nachricht anzuzeigen
server.sendHeader("Location", "/Profile", true); // true für permanent = false
server.send(303);
}
// Speichert die aktuellen Einstellungen unter einem neuen Namen
void handleSaveProfile() {
if (server.hasArg("profileName")) {
String newName = server.arg("profileName");
String sanitizedName = sanitizeProfileName(newName);
if (sanitizedName.length() == 0) {
profileStatusMessage = "FEHLER: Ung&uuml;ltiger oder leerer Profilname angegeben.";
} else {
TemperatureProfile currentData = getCurrentSettingsAsProfile();
// Setze den vom Benutzer gewünschten (originalen) Namen in die Struktur
strncpy(currentData.profileName, newName.c_str(), sizeof(currentData.profileName) - 1);
currentData.profileName[sizeof(currentData.profileName) - 1] = '\0';
// Speichere unter dem bereinigten Dateinamen
if (saveProfile(sanitizedName, currentData)) {
profileStatusMessage = "Aktuelle Einstellungen erfolgreich als Profil '" + String(currentData.profileName) + "' gespeichert.";
} else {
profileStatusMessage = "FEHLER: Einstellungen konnten nicht als Profil '" + String(currentData.profileName) + "' gespeichert werden.";
}
}
} else {
profileStatusMessage = "FEHLER: Kein Profilname zum Speichern übermittelt.";
}
// Redirect zurück zur Profilseite
server.sendHeader("Location", "/Profile", true);
server.send(303);
}
// Löscht ein ausgewähltes Profil
void handleDeleteProfile() {
if (server.hasArg("profileName")) {
String profileToDelete = server.arg("profileName");
if (deleteProfile(profileToDelete)) {
profileStatusMessage = "Profil '" + profileToDelete + "' erfolgreich entfernt.";
} else {
profileStatusMessage = "FEHLER: Profil '" + profileToDelete + "' konnte nicht entfernt werden.";
}
} else {
profileStatusMessage = "FEHLER: Kein Profilname zum entfernen übermittelt.";
}
// Redirect zurück zur Profilseite
server.sendHeader("Location", "/Profile", true);
server.send(303);
}
// *** ENDE NEUE WEB UI HANDLER ***
/************************************************************************************
* Handler für die Dateimanager-Seite (/Dateimanager) - Heap-Optimiert
* - Plattformabhängige FS-API berücksichtigt
* - Unterstützt jetzt Verzeichnisnavigation
************************************************************************************/
// --- PROGMEM Chunks für handleFileManager (angepasst) ---
static const char fileMgrHead[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html lang='de'><head><meta charset='UTF-8'><title>Dateimanager</title>
<style>
body { font-family: sans-serif; background-color: #f0f0f0; margin: 15px; }
h1, h2 { color: #333; }
ul { list-style: none; padding: 0; }
li { margin-bottom: 8px; background: #fff; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; /* Für kleine Bildschirme */ }
li span { flex-grow: 1; margin-right: 10px; word-break: break-all; /* Lange Namen umbrechen */ }
.file-info { display: inline-block; min-width: 80px; text-align: right; color: #666; font-size: 0.9em; margin-left: 10px; } /* Dateigröße */
.item-name { font-weight: bold; }
.dir-name { color: #0056b3; } /* Blaue Farbe für Ordner */
.file-actions { display: flex; align-items: center; gap: 5px; flex-shrink: 0; /* Verhindert Schrumpfen */ margin-left:auto; /* Schiebt Aktionen nach rechts */ padding-left: 10px; /* Etwas Abstand */}
a { text-decoration: none; color: #007bff; }
a:hover { text-decoration: underline; }
a.button-like, input[type='submit'], button.button-like { text-decoration: none; background: #eee; padding: 4px 8px; border: 1px solid #ccc; border-radius: 3px; color: #333; cursor: pointer; font-size: 0.9em; display: inline-block; /* Wichtig für korrekte Darstellung */}
a.button-like:hover, input[type='submit']:hover, button.button-like:hover { background: #ddd; }
form { margin: 0; padding: 0; border: none; display: inline; } /* Forms in Aktionen */
hr { margin: 20px 0; border: 0; border-top: 1px solid #ccc; }
.upload-form, .create-dir-form { margin-top: 20px; background: #fff; padding: 15px; border: 1px solid #ccc; border-radius: 4px; }
.upload-form input[type='file'] { margin-bottom: 10px; }
.status-message { padding: 10px; margin-bottom: 15px; border-radius: 4px; color: #fff; }
.status-error { background-color: #dc3545; }
.status-success { background-color: #28a745; }
/* Responsive Anpassungen */
@media (max-width: 600px) {
li { flex-direction: column; align-items: flex-start; }
.file-actions { width: 100%; margin-top: 8px; margin-left: 0; justify-content: flex-end; /* Buttons nach rechts */}
.file-info { text-align: left; margin-left: 0; margin-top: 4px;}
li span { margin-right: 0; }
}
</style>
</head><body>
)rawliteral"; // Ende des Head+Style Blocks, vor <body>
// Platzhalter für Pfad und Fehlermeldung
static const char fileMgrTitle[] PROGMEM = R"rawliteral(<h1>Dateimanager</h1><h2>Aktueller Pfad: {CURRENT_PATH}</h2>{STATUS_MESSAGE})rawliteral";
// List Start
static const char fileMgrListStart[] PROGMEM = "<ul>";
// List Item: Zurück ( .. )
static const char fileMgrListItemUp[] PROGMEM = R"rawliteral(<li><a href='/Dateimanager?path={PARENT_PATH}' class='item-name dir-name'>[ .. Zur&uuml;ck]</a></li>)rawliteral";
// List Item: Verzeichnis
static const char fileMgrListItemDir[] PROGMEM = R"rawliteral(<li><a href='/Dateimanager?path={DIR_PATH}' class='item-name dir-name'>[ {DIR_NAME} ]</a><div class='file-actions'><form action='/Datei-Entfernen' method='GET' onsubmit='return confirm(\"Sicher, dass das Verzeichnis {DIR_NAME} und sein Inhalt gelöscht werden soll?\");'><input type='hidden' name='name' value='{DIR_PATH}'><input type='hidden' name='isDir' value='1'><input type='submit' value='Entfernen'></form></div></li>)rawliteral"; // DIR_PATH ist der volle Pfad, DIR_NAME nur der Name
// List Item: Datei (wie bisher, aber mit vollem Pfad bei name=...)
static const char fileMgrListItemFileStart[] PROGMEM = R"rawliteral(<li><span class='item-name'>)rawliteral"; // Start + Span für Name
static const char fileMgrListItemFileMid1[] PROGMEM = R"rawliteral(</span><span class='file-info'>)rawliteral"; // Nach Name, vor Größe
static const char fileMgrListItemFileMid2[] PROGMEM = R"rawliteral( Bytes</span><div class='file-actions'><a href='/Datei-Download?name=)rawliteral"; // Nach Größe, bis vor Download-Pfad
static const char fileMgrListItemFileMid3[] PROGMEM = R"rawliteral(' class='button-like' download>Download</a><form action='/Datei-Entfernen' method='GET' onsubmit='return confirm(\"Sicher, dass die Datei {FILE_NAME} gelöscht werden soll?\");'><input type='hidden' name='name' value=')rawliteral"; // Nach Download-Pfad, bis vor Delete-Pfad
static const char fileMgrListItemFileEnd[] PROGMEM = R"rawliteral('><input type='submit' value='Entfernen'></form></div></li>)rawliteral"; // Nach Delete-Pfad, bis Ende <li>
// Meldungen für leere Liste oder Fehler
static const char fileMgrNoFiles[] PROGMEM = R"rawliteral(<li><i>(Verzeichnis ist leer)</i></li>)rawliteral";
static const char fileMgrListError[] PROGMEM = R"rawliteral(<li><i>Fehler beim Auflisten des Verzeichnisses.</i></li>)rawliteral";
static const char fileMgrOpenError[] PROGMEM = R"rawliteral(<li><i>Fehler beim Öffnen des Verzeichnisses: Pfad nicht gefunden oder keine Berechtigung.</i></li>)rawliteral";
// Ende der Liste + Trennlinie
static const char fileMgrListEnd[] PROGMEM = R"rawliteral(</ul><hr>)rawliteral";
// Upload Formular (mit verstecktem Pfad)
static const char fileMgrUploadForm[] PROGMEM = R"rawliteral(
<div class='upload-form'>
<h2>Datei hochladen (in aktuellen Pfad)</h2>
<form method='POST' action='/Datei-Upload' enctype='multipart/form-data'>
<input type='hidden' name='path' value='{CURRENT_PATH}'>
<input type='file' name='upload' required><br>
<input type='submit' value='Datei Hochladen'>
</form>
</div>
)rawliteral";
// Verzeichnis erstellen Formular (NEU)
static const char fileMgrCreateDirForm[] PROGMEM = R"rawliteral(
<div class='create-dir-form'>
<h2>Verzeichnis erstellen (im aktuellen Pfad)</h2>
<form method='POST' action='/Verzeichnis-Erstellen'>
<input type='hidden' name='basePath' value='{CURRENT_PATH}'>
<input type='text' name='dirName' placeholder='Neuer Verzeichnisname' required pattern='^[a-zA-Z0-9_.-]+$' title='Nur Buchstaben, Zahlen, _, -, . erlaubt'>
<input type='submit' value='Verzeichnis erstellen'>
</form>
</div>
)rawliteral";
// Dateisystem Info (wie bisher)
static const char fileMgrInfoStart[] PROGMEM = R"rawliteral(<hr><h2>Info</h2>)rawliteral";
static const char fileMgrInfoTotal[] PROGMEM = R"rawliteral(Gesamtspeicher: )rawliteral";
static const char fileMgrInfoUsed[] PROGMEM = R"rawliteral( Bytes<br>Belegt: )rawliteral";
static const char fileMgrInfoFree[] PROGMEM = R"rawliteral( Bytes<br>Frei: )rawliteral";
static const char fileMgrInfoEnd[] PROGMEM = R"rawliteral( Bytes<br>)rawliteral";
static const char fileMgrInfoError[] PROGMEM = R"rawliteral(Fehler beim Abrufen der Dateisystem-Informationen.<br>)rawliteral";
// Body Ende
static const char fileMgrBodyEnd[] PROGMEM = R"rawliteral(</body></html>)rawliteral";
// Globale Variable für Statusmeldungen im Dateimanager
String fileManagerStatusMessage = "";
String fileManagerStatusClass = ""; // "status-success" oder "status-error"
// Hilfsfunktion zum rekursiven Löschen von Verzeichnissen
bool removeDirectoryRecursive(const String& path) {
Serial.printf("Attempting to recursively remove directory: %s\n", path.c_str());
bool success = true;
#ifdef ESP32
File root = LittleFS.open(path);
if (!root) {
Serial.printf(" ERROR: Failed to open directory %s for removal.\n", path.c_str());
return false;
}
if (!root.isDirectory()) {
Serial.printf(" ERROR: %s is not a directory.\n", path.c_str());
root.close();
return false; // Oder sollte eine Datei einfach gelöscht werden? Hier nur Verzeichnisse.
}
File file = root.openNextFile();
while (file) {
String entryPath = path + "/" + file.name(); // Korrekten Pfad bilden
// Korrektur für ESP32: file.name() liefert nur den Namen, nicht den vollen Pfad relativ zum FS-Root.
// Wir brauchen den vollen Pfad für die Rekursion und das Löschen.
entryPath = file.path(); // Besser: ESP32 gibt vollen Pfad zurück.
if (file.isDirectory()) {
Serial.printf(" Recursing into directory: %s\n", entryPath.c_str());
if (!removeDirectoryRecursive(entryPath)) {
Serial.printf(" ERROR: Failed to remove subdirectory %s\n", entryPath.c_str());
success = false; // Fehler beim Löschen des Unterverzeichnisses
// Man könnte hier entscheiden abzubrechen oder weiterzumachen
}
} else {
Serial.printf(" Deleting file: %s\n", entryPath.c_str());
if (!LittleFS.remove(entryPath)) {
Serial.printf(" ERROR: Failed to remove file %s\n", entryPath.c_str());
success = false; // Fehler beim Löschen der Datei
}
}
file.close(); // Wichtig: Datei schließen
file = root.openNextFile();
yield(); // Luft holen
}
root.close(); // Root des zu löschenden Verzeichnisses schließen
// Nachdem der Inhalt (hoffentlich) weg ist, das leere Verzeichnis löschen
if (success) { // Nur versuchen, wenn bisher alles geklappt hat
Serial.printf(" Attempting to remove now (hopefully) empty directory: %s\n", path.c_str());
if (!LittleFS.rmdir(path)) { // rmdir für Verzeichnisse auf ESP32
Serial.printf(" ERROR: Failed to remove directory %s itself.\n", path.c_str());
success = false;
} else {
Serial.printf(" Successfully removed directory %s.\n", path.c_str());
}
} else {
Serial.printf(" Skipping removal of directory %s due to previous errors.\n", path.c_str());
}
#else // ESP8266
Dir dir = LittleFS.openDir(path);
while (dir.next()) {
String entryName = dir.fileName();
String entryPath = path + "/" + entryName;
if (entryName == "." || entryName == "..") continue; // Ignorieren
if (dir.isDirectory()) {
Serial.printf(" Recursing into directory: %s\n", entryPath.c_str());
if (!removeDirectoryRecursive(entryPath)) {
Serial.printf(" ERROR: Failed to remove subdirectory %s\n", entryPath.c_str());
success = false;
}
} else {
Serial.printf(" Deleting file: %s\n", entryPath.c_str());
if (!LittleFS.remove(entryPath)) {
Serial.printf(" ERROR: Failed to remove file %s\n", entryPath.c_str());
success = false;
}
}
yield(); // Luft holen
}
// dir schließt automatisch
if (success) {
Serial.printf(" Attempting to remove now (hopefully) empty directory: %s\n", path.c_str());
if (!LittleFS.rmdir(path)) { // rmdir auch für ESP8266
Serial.printf(" ERROR: Failed to remove directory %s itself.\n", path.c_str());
success = false;
} else {
Serial.printf(" Successfully removed directory %s.\n", path.c_str());
}
} else {
Serial.printf(" Skipping removal of directory %s due to previous errors.\n", path.c_str());
}
#endif
return success;
}
/************************************************************************************
* Handler für die Dateimanager-Seite (/Dateimanager) - Heap-Optimiert
* - Plattformabhängige FS-API berücksichtigt
* - Unterstützt jetzt Verzeichnisnavigation
************************************************************************************/
void handleFileManager() {
Serial.println("Anfrage für /Dateimanager");
char buffer[20]; // Puffer für Zahlen (Größe, FS Info) - HIER deklariert für die ganze Funktion
// Aktuellen Pfad aus URL holen, Standard ist "/"
String currentPath = server.hasArg("path") ? server.arg("path") : "/";
// Pfad bereinigen und validieren
if (!currentPath.startsWith("/")) {
currentPath = "/" + currentPath;
}
// Einfache Normalisierung (doppelte Slashes entfernen)
currentPath.replace("//", "/");
// Entferne abschließenden Slash, außer bei Root "/"
if (currentPath.length() > 1 && currentPath.endsWith("/")) {
currentPath.remove(currentPath.length() - 1);
}
Serial.printf("Aktueller Pfad: %s\n", currentPath.c_str());
// --- Header senden ---
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, F("text/html; charset=utf-8"), ""); // UTF-8 für Umlaute
// --- Header und Style senden ---
server.sendContent_P(fileMgrHead); // Enthält <html>, <head>, <style>, </head>, <body>
// --- Titel und Statusmeldung ---
String titleHtml = FPSTR(fileMgrTitle);
titleHtml.replace("{CURRENT_PATH}", currentPath); // Zeige aktuellen Pfad
// Füge Statusmeldung ein, falls vorhanden
if (fileManagerStatusMessage.length() > 0) {
String statusHtml = "<div class='status-message " + fileManagerStatusClass + "'>" + fileManagerStatusMessage + "</div>";
titleHtml.replace("{STATUS_MESSAGE}", statusHtml);
fileManagerStatusMessage = ""; // Nachricht zurücksetzen nach Anzeige
fileManagerStatusClass = "";
} else {
titleHtml.replace("{STATUS_MESSAGE}", ""); // Kein Status -> Platzhalter entfernen
}
server.sendContent(titleHtml);
yield();
// --- Dateiliste starten ---
server.sendContent_P(fileMgrListStart); // <ul>
// --- "Zurück"-Link hinzufügen, wenn nicht im Root ---
if (currentPath != "/") {
String parentPath = "/"; // Standard-Elternpfad ist Root
int lastSlash = currentPath.lastIndexOf('/');
if (lastSlash > 0) { // Wenn Slash nicht am Anfang ist (z.B. bei /subdir)
parentPath = currentPath.substring(0, lastSlash);
}
// parentPath ist "/" wenn currentPath z.B. "/subdir" war
// parentPath bleibt "/", wenn currentPath "/" ist (wird aber oben schon abgefangen)
String upLink = FPSTR(fileMgrListItemUp);
upLink.replace("{PARENT_PATH}", parentPath);
server.sendContent(upLink);
yield();
}
// --- Verzeichnisinhalt auflisten ---
int entryCount = 0; // Zählt gefundene Dateien/Verzeichnisse (außer . und ..)
bool errorOccurred = false; // Flag für Fehler beim Öffnen (primär für ESP32 relevant)
#ifdef ESP32
File root = LittleFS.open(currentPath);
if (!root) {
server.sendContent_P(fileMgrOpenError);
errorOccurred = true;
} else if (!root.isDirectory()) {
server.sendContent(F("<li>Fehler: Angegebener Pfad ist keine Verzeichnis.</li>"));
errorOccurred = true;
root.close();
} else {
File entry = root.openNextFile();
while (entry) {
entryCount++;
String entryName = entry.name(); // ESP32: Gibt nur den Namen zurück
String entryFullPath = entry.path(); // ESP32: Gibt vollen Pfad zurück
if (entry.isDirectory()) {
// Verzeichnis-Eintrag
String dirItem = FPSTR(fileMgrListItemDir);
// Ersetze Platzhalter im Template
dirItem.replace("{DIR_PATH}", entryFullPath);
dirItem.replace("{DIR_NAME}", entryName);
dirItem.replace("{DIR_NAME}", entryName); // Erneut für Confirm-Dialog
server.sendContent(dirItem);
} else {
// Datei-Eintrag
size_t fileSize = entry.size();
server.sendContent_P(fileMgrListItemFileStart); // <li><span class='item-name'>
server.sendContent(entryName); // Dateiname
server.sendContent_P(fileMgrListItemFileMid1); // </span><span class='file-info'>
snprintf(buffer, sizeof(buffer), "%llu", (uint64_t)fileSize); // ESP32 size_t ist oft 64bit? Sicherer mit ll/u64
server.sendContent(buffer); // Dateigröße
server.sendContent_P(fileMgrListItemFileMid2); // Bytes</span><div ... href='...?name=
server.sendContent(entryFullPath); // Voller Pfad für Download
String fileItemEnd = FPSTR(fileMgrListItemFileMid3);
fileItemEnd.replace("{FILE_NAME}", entryName); // Name für Confirm-Dialog
server.sendContent(fileItemEnd); // ' class...>...<input value='
server.sendContent(entryFullPath); // Voller Pfad für Delete
server.sendContent_P(fileMgrListItemFileEnd); // '><input ...></div></li>
}
entry.close(); // Wichtig: Eintrag schließen
entry = root.openNextFile();
yield();
}
root.close(); // Wichtig: Verzeichnis schließen
}
#else // ESP8266
// Für ESP8266: KEINE explizite Prüfung (!dir) hier, verlasse dich auf dir.next()
Dir dir = LittleFS.openDir(currentPath);
while (dir.next()) { // Die Schleife wird nicht betreten, wenn openDir fehlschlug oder das Verzeichnis leer ist
String entryName = dir.fileName();
// Kleiner Fix: Verhindern, dass "." und ".." aufgelistet werden und gezählt werden
if (entryName == "." || entryName == "..") {
continue; // Nicht auflisten
}
entryCount++; // Zähle nur gültige Einträge
String entryFullPath = currentPath;
if (currentPath == "/") {
entryFullPath += entryName; // Root: /dateiname
} else {
entryFullPath += "/" + entryName; // Subdir: /subdir/dateiname
}
if (dir.isDirectory()) {
// Verzeichnis-Eintrag
String dirItem = FPSTR(fileMgrListItemDir);
// Ersetze Platzhalter im Template
dirItem.replace("{DIR_PATH}", entryFullPath);
dirItem.replace("{DIR_NAME}", entryName);
dirItem.replace("{DIR_NAME}", entryName); // Erneut für Confirm-Dialog
server.sendContent(dirItem);
} else {
// Datei-Eintrag
size_t fileSize = dir.fileSize();
server.sendContent_P(fileMgrListItemFileStart); // <li><span class='item-name'>
server.sendContent(entryName); // Dateiname
server.sendContent_P(fileMgrListItemFileMid1); // </span><span class='file-info'>
snprintf(buffer, sizeof(buffer), "%u", fileSize); // %u für unsigned size_t auf ESP8266
server.sendContent(buffer); // Dateigröße
server.sendContent_P(fileMgrListItemFileMid2); // Bytes</span><div ... href='...?name=
server.sendContent(entryFullPath); // Voller Pfad für Download
String fileItemEnd = FPSTR(fileMgrListItemFileMid3);
fileItemEnd.replace("{FILE_NAME}", entryName); // Name für Confirm-Dialog
server.sendContent(fileItemEnd); // ' class...>...<input value='
server.sendContent(entryFullPath); // Voller Pfad für Delete
server.sendContent_P(fileMgrListItemFileEnd); // '><input ...></div></li>
}
yield();
}
// dir schließt automatisch auf ESP8266
// Die Variable errorOccurred wird im ESP8266-Teil nicht gesetzt, da wir uns auf dir.next() verlassen.
// Die Prüfung auf entryCount == 0 deckt "leer" und "konnte nicht geöffnet werden" ab.
#endif // Ende Plattform-Block
// --- Meldung, wenn keine Einträge gefunden wurden ---
if (entryCount == 0 && !errorOccurred) { // errorOccurred ist nur für ESP32 relevant
server.sendContent_P(fileMgrNoFiles); // Zeigt "(Verzeichnis ist leer)"
}
// --- Ende der Liste + Trennlinie ---
server.sendContent_P(fileMgrListEnd); // </ul><hr>
yield();
// --- Datei-Upload-Formular ---
String uploadForm = FPSTR(fileMgrUploadForm);
uploadForm.replace("{CURRENT_PATH}", currentPath); // Aktuellen Pfad für Upload übergeben
server.sendContent(uploadForm);
yield();
// --- Verzeichnis erstellen Formular ---
String createDirForm = FPSTR(fileMgrCreateDirForm);
createDirForm.replace("{CURRENT_PATH}", currentPath); // Aktuellen Pfad für Erstellung übergeben
server.sendContent(createDirForm);
yield();
// --- Dateisystem-Informationen ---
server.sendContent_P(fileMgrInfoStart); // <hr><h2>Info</h2>
#ifdef ESP32
// ESP32 Info Code...
uint64_t totalBytes = LittleFS.totalBytes();
uint64_t usedBytes = LittleFS.usedBytes();
server.sendContent_P(fileMgrInfoTotal);
snprintf(buffer, sizeof(buffer), "%llu", totalBytes); server.sendContent(buffer);
server.sendContent_P(fileMgrInfoUsed);
snprintf(buffer, sizeof(buffer), "%llu", usedBytes); server.sendContent(buffer);
server.sendContent_P(fileMgrInfoFree);
snprintf(buffer, sizeof(buffer), "%llu", totalBytes - usedBytes); server.sendContent(buffer);
server.sendContent_P(fileMgrInfoEnd);
#else // ESP8266
// ESP8266 Info Code...
FSInfo fs_info;
if (LittleFS.info(fs_info)) {
server.sendContent_P(fileMgrInfoTotal);
snprintf(buffer, sizeof(buffer), "%u", fs_info.totalBytes); server.sendContent(buffer);
server.sendContent_P(fileMgrInfoUsed);
snprintf(buffer, sizeof(buffer), "%u", fs_info.usedBytes); server.sendContent(buffer);
server.sendContent_P(fileMgrInfoFree);
snprintf(buffer, sizeof(buffer), "%u", fs_info.totalBytes - fs_info.usedBytes); server.sendContent(buffer);
server.sendContent_P(fileMgrInfoEnd);
} else {
server.sendContent_P(fileMgrInfoError);
}
#endif
yield();
// --- Ende Body/HTML ---
server.sendContent_P(fileMgrBodyEnd); // </body></html>
// --- Abschluss-Chunk ---
server.sendContent(F(""));
Serial.println(" Dateimanager-Seite gesendet.");
} // Ende handleFileManager()
/************************************************************************************
* Handler während des Datei-Uploads (angepasst für Pfad)
************************************************************************************/
void handleFileUpload() {
HTTPUpload& upload = server.upload();
String targetPath = "/"; // Standardpfad
// Pfad aus dem Formular lesen
if (server.hasArg("path")) {
targetPath = server.arg("path");
// Bereinigen (wie im handleFileManager)
if (!targetPath.startsWith("/")) { targetPath = "/" + targetPath; }
targetPath.replace("//", "/");
if (targetPath.length() > 1 && targetPath.endsWith("/")) {
targetPath.remove(targetPath.length() - 1);
}
}
switch (upload.status) {
case UPLOAD_FILE_START: {
String filename = upload.filename;
// Basisschutz gegen Pfadmanipulation im Dateinamen selbst
if (filename.indexOf('/') != -1) {
filename = filename.substring(filename.lastIndexOf('/') + 1);
}
// Vollständigen Zielpfad erstellen
String fullTargetPath;
if (targetPath == "/") {
fullTargetPath = "/" + filename;
} else {
fullTargetPath = targetPath + "/" + filename;
}
Serial.printf("Upload Start: %s -> %s\n", upload.filename.c_str(), fullTargetPath.c_str());
fsUploadFile = LittleFS.open(fullTargetPath, "w");
if (!fsUploadFile) {
Serial.println("! Fehler: Konnte Datei für Upload nicht erstellen/öffnen!");
// Status für handleFileUploaded setzen (optional, erfordert globale Variable)
fileManagerStatusMessage = "FEHLER: Datei '" + filename + "' konnte nicht erstellt werden (Pfad existiert? Speicherplatz?).";
fileManagerStatusClass = "status-error";
} else {
// Status zurücksetzen bei erfolgreichem Start
fileManagerStatusMessage = "";
fileManagerStatusClass = "";
}
break;
}
case UPLOAD_FILE_WRITE: {
if (fsUploadFile) {
size_t bytesWritten = fsUploadFile.write(upload.buf, upload.currentSize);
if (bytesWritten != upload.currentSize) {
Serial.println("! Fehler beim Schreiben des Upload-Chunks!");
fsUploadFile.close(); // Schließen bei Fehler
// Status für handleFileUploaded setzen
fileManagerStatusMessage = "FEHLER: Schreibfehler während des Uploads von '" + String(fsUploadFile.name()) + "'. Upload abgebrochen.";
fileManagerStatusClass = "status-error";
// Optional: Datei entfernen
LittleFS.remove(fsUploadFile.name());
// Setze fsUploadFile auf null, um weitere Schreibversuche zu verhindern?
// Direkt geht das nicht, aber der Fehlerstatus hilft.
}
} else if (fileManagerStatusMessage.length() == 0) { // Nur loggen, wenn nicht schon ein Startfehler vorlag
Serial.println("Upload WRITE, aber fsUploadFile nicht gültig!");
}
yield(); // Wichtig bei großen Dateien
break;
}
case UPLOAD_FILE_END: {
if (fsUploadFile) { // War die Datei jemals erfolgreich geöffnet?
String finalName = fsUploadFile.name(); // Namen merken vor dem Schließen
fsUploadFile.close();
if (fileManagerStatusMessage.length() == 0) { // Nur wenn kein Fehler vorher aufgetreten ist
Serial.printf("Upload Ende. Datei: %s, Größe: %d Bytes\n", finalName.c_str(), upload.totalSize);
fileManagerStatusMessage = "Datei '" + finalName.substring(finalName.lastIndexOf('/') + 1) + "' erfolgreich hochgeladen.";
fileManagerStatusClass = "status-success";
} else {
Serial.printf("Upload Ende, aber mit vorherigem Fehler für Datei: %s\n", finalName.c_str());
// Die Fehlermeldung von UPLOAD_FILE_WRITE bleibt bestehen
}
} else {
Serial.println("Upload END, aber fsUploadFile war nicht gültig (wahrscheinlich Startfehler).");
// Die Fehlermeldung vom Start bleibt bestehen
}
break;
}
case UPLOAD_FILE_ABORTED: {
Serial.println("Upload vom Client abgebrochen!");
if (fsUploadFile) {
String abortedName = fsUploadFile.name();
fsUploadFile.close();
// Versuche, unvollständige Datei zu entfernen
if (LittleFS.remove(abortedName)) {
Serial.printf("Abgebrochene Upload-Datei '%s' gelöscht.\n", abortedName.c_str());
} else {
Serial.printf("Konnte abgebrochene Upload-Datei '%s' nicht löschen.\n", abortedName.c_str());
}
}
fileManagerStatusMessage = "Upload wurde abgebrochen.";
fileManagerStatusClass = "status-error"; // Oder eine neutrale Klasse
break;
}
}
}
/************************************************************************************
* Handler NACHDEM der Upload abgeschlossen (oder fehlgeschlagen) ist
* Leitet zurück zum Dateimanager mit dem korrekten Pfad.
************************************************************************************/
void handleFileUploaded() {
String redirectPath = "/Dateimanager";
// Pfad aus dem Formular holen, um zur richtigen Ansicht zurückzukehren
if (server.hasArg("path")) {
String targetPath = server.arg("path");
// Bereinigen (wie im handleFileManager)
if (!targetPath.startsWith("/")) { targetPath = "/" + targetPath; }
targetPath.replace("//", "/");
if (targetPath.length() > 1 && targetPath.endsWith("/")) {
targetPath.remove(targetPath.length() - 1);
}
redirectPath += "?path=" + targetPath; // Pfad an URL anhängen
}
// Die Statusmeldung wurde bereits in handleFileUpload gesetzt
server.sendHeader("Location", redirectPath);
server.send(303); // 303 See Other
}
/************************************************************************************
* Handler zum Herunterladen einer Datei (unverändert, sollte mit Pfaden funktionieren)
************************************************************************************/
void handleFileDownload() {
if (!server.hasArg("name") || server.arg("name") == NULL || server.arg("name") == "") {
fileManagerStatusMessage = "FEHLER: Dateiname zum Herunterladen fehlt!";
fileManagerStatusClass = "status-error";
server.sendHeader("Location", "/Dateimanager"); // Zurückleiten
server.send(303);
return;
}
String filePath = server.arg("name"); // Enthält bereits den vollen Pfad
// Zusätzliche Sicherheitsprüfung (optional, aber gut)
if (filePath.indexOf("..") != -1) {
fileManagerStatusMessage = "FEHLER: Ungültiger Pfad für Download.";
fileManagerStatusClass = "status-error";
server.sendHeader("Location", "/Dateimanager");
server.send(303);
return;
}
// Sicherstellen, dass der Pfad mit '/' beginnt
if (!filePath.startsWith("/")) {
filePath = "/" + filePath;
}
Serial.printf("Download-Anfrage für: %s\n", filePath.c_str());
if (LittleFS.exists(filePath)) {
File file = LittleFS.open(filePath, "r");
if (file) {
if (file.isDirectory()) { // Verzeichnisse nicht herunterladen
file.close();
Serial.println("Fehler: Versuch, ein Verzeichnis herunterzuladen.");
fileManagerStatusMessage = "FEHLER: Verzeichnisse können nicht heruntergeladen werden.";
fileManagerStatusClass = "status-error";
server.sendHeader("Location", "/Dateimanager?path=" + filePath.substring(0, filePath.lastIndexOf('/'))); // Zurück zum Parent
server.send(303);
return;
}
String contentType = "application/octet-stream"; // Standard für Download
// Dateinamen für den Browser extrahieren (letzter Teil des Pfades)
String downloadFilename = filePath.substring(filePath.lastIndexOf('/') + 1);
server.sendHeader("Content-Disposition", "attachment; filename=\"" + downloadFilename + "\"");
size_t sentBytes = server.streamFile(file, contentType);
file.close();
Serial.printf("Datei '%s' (%d Bytes) gesendet.\n", filePath.c_str(), sentBytes);
// Kein Redirect hier, der Download startet direkt
return;
} else {
Serial.println("Fehler beim Öffnen der Datei für den Download!");
fileManagerStatusMessage = "FEHLER: Datei '" + filePath + "' konnte nicht geöffnet werden.";
fileManagerStatusClass = "status-error";
}
} else {
Serial.println("Datei zum Herunterladen nicht gefunden!");
fileManagerStatusMessage = "FEHLER: Datei '" + filePath + "' nicht gefunden.";
fileManagerStatusClass = "status-error";
}
// Bei Fehlern zurückleiten
String parentPath = filePath.substring(0, filePath.lastIndexOf('/'));
if (parentPath == "") parentPath = "/";
server.sendHeader("Location", "/Dateimanager?path=" + parentPath);
server.send(303);
}
/************************************************************************************
* Handler zum Löschen einer Datei oder eines Verzeichnisses
************************************************************************************/
void handleFileDelete() {
if (!server.hasArg("name") || server.arg("name") == "") {
fileManagerStatusMessage = "FEHLER: Name zum Löschen fehlt!";
fileManagerStatusClass = "status-error";
server.sendHeader("Location", "/Dateimanager");
server.send(303);
return;
}
String itemPath = server.arg("name"); // Enthält den vollen Pfad
bool isDir = server.hasArg("isDir") && server.arg("isDir") == "1"; // Prüfen ob es ein Verzeichnis ist
// Zusätzliche Sicherheitsprüfung
if (itemPath.indexOf("..") != -1 || itemPath == "/") {
fileManagerStatusMessage = "FEHLER: Ungültiger Pfad zum Löschen.";
fileManagerStatusClass = "status-error";
server.sendHeader("Location", "/Dateimanager");
server.send(303);
return;
}
// Sicherstellen, dass der Pfad mit '/' beginnt
if (!itemPath.startsWith("/")) {
itemPath = "/" + itemPath;
}
String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1);
String parentPath = itemPath.substring(0, itemPath.lastIndexOf('/'));
if (parentPath == "") parentPath = "/";
Serial.printf("Lösch-Anfrage für %s: %s\n", isDir ? "Verzeichnis" : "Datei", itemPath.c_str());
bool success = false;
if (LittleFS.exists(itemPath)) {
if (isDir) {
// --- Rekursives Löschen für Verzeichnisse ---
success = removeDirectoryRecursive(itemPath);
if (success) {
fileManagerStatusMessage = "Verzeichnis '" + itemName + "' und Inhalt erfolgreich gelöscht.";
fileManagerStatusClass = "status-success";
} else {
fileManagerStatusMessage = "FEHLER: Verzeichnis '" + itemName + "' konnte nicht vollständig gelöscht werden.";
fileManagerStatusClass = "status-error";
}
} else {
// --- Einfaches Löschen für Dateien ---
if (LittleFS.remove(itemPath)) {
Serial.println("Datei erfolgreich gelöscht.");
fileManagerStatusMessage = "Datei '" + itemName + "' erfolgreich gelöscht.";
fileManagerStatusClass = "status-success";
success = true;
} else {
Serial.println("Fehler beim Löschen der Datei!");
fileManagerStatusMessage = "FEHLER: Datei '" + itemName + "' konnte nicht gelöscht werden.";
fileManagerStatusClass = "status-error";
}
}
} else {
Serial.println("Element zum Löschen nicht gefunden!");
fileManagerStatusMessage = "FEHLER: Element '" + itemName + "' nicht gefunden.";
fileManagerStatusClass = "status-error";
}
// Redirect zurück zum übergeordneten Verzeichnis
server.sendHeader("Location", "/Dateimanager?path=" + parentPath);
server.send(303);
}
// NEUER HANDLER zum Erstellen von Verzeichnissen
void handleCreateDirectory() {
String basePath = "/";
if (server.hasArg("basePath")) {
basePath = server.arg("basePath");
// Bereinigen
if (!basePath.startsWith("/")) { basePath = "/" + basePath; }
basePath.replace("//", "/");
if (basePath.length() > 1 && basePath.endsWith("/")) {
basePath.remove(basePath.length() - 1);
}
}
if (!server.hasArg("dirName") || server.arg("dirName") == "") {
fileManagerStatusMessage = "FEHLER: Name für neues Verzeichnis fehlt!";
fileManagerStatusClass = "status-error";
} else {
String dirName = server.arg("dirName");
// Einfache Validierung des Namens (gegen Pfadtrenner etc.)
if (dirName.indexOf('/') != -1 || dirName.indexOf('\\') != -1 || dirName == "." || dirName == "..") {
fileManagerStatusMessage = "FEHLER: Ungültiger Verzeichnisname!";
fileManagerStatusClass = "status-error";
} else {
String newDirPath;
if (basePath == "/") {
newDirPath = "/" + dirName;
} else {
newDirPath = basePath + "/" + dirName;
}
Serial.printf("Versuche Verzeichnis zu erstellen: %s\n", newDirPath.c_str());
if (LittleFS.mkdir(newDirPath)) {
Serial.println("Verzeichnis erfolgreich erstellt.");
fileManagerStatusMessage = "Verzeichnis '" + dirName + "' erfolgreich erstellt.";
fileManagerStatusClass = "status-success";
} else {
Serial.println("Fehler beim Erstellen des Verzeichnisses!");
fileManagerStatusMessage = "FEHLER: Verzeichnis '" + dirName + "' konnte nicht erstellt werden (existiert bereits?).";
fileManagerStatusClass = "status-error";
}
}
}
// Redirect zurück zum Basisverzeichnis
server.sendHeader("Location", "/Dateimanager?path=" + basePath);
server.send(303);
}
/************************************************************************************
* Handler zum Ausliefern der lokalen Chart.js Datei aus LittleFS
************************************************************************************/
void handleServeChartJs() {
const char* chartJsPath = "/chart.umd.min.js";
if (LittleFS.exists(chartJsPath)) {
File file = LittleFS.open(chartJsPath, "r");
if (file) {
server.streamFile(file, "text/javascript"); // Effizientes Senden
file.close();
Serial.println("Lokale chart.umd.min.js ausgeliefert.");
return; // Wichtig: Beenden nach erfolgreichem Senden
} else {
Serial.println("Fehler beim Öffnen der lokalen chart.umd.min.js!");
}
} else {
Serial.println("Angeforderte lokale chart.umd.min.js nicht gefunden!");
}
// Wenn hier angekommen, ist etwas schiefgelaufen
server.send(404, "text/plain", "Chart.js nicht gefunden");
}
/************************************************************************************
* Handler für den Firmware-Upload-Prozess (während des Uploads)
* - Berücksichtigt Unterschiede bei Update.begin() für ESP32/ESP8266
************************************************************************************/
void handleFirmwareUploadProgress() {
HTTPUpload& upload = server.upload(); // Referenz auf das Upload-Objekt holen
if (upload.status == UPLOAD_FILE_START) {
Serial.printf("Update gestartet: %s\n", upload.filename.c_str());
passwordFound = false; // Reset für jede neue Datei
passMatchPos = 0;
uint32_t maxSketchSpace = 0;
bool updateStarted = false;
#ifdef ESP32
// Für ESP32: UPDATE_SIZE_UNKNOWN verwenden, um die nächste OTA-Partition automatisch zu finden.
// U_FLASH gibt an, dass der Sketch aktualisiert werden soll.
Serial.println("Update.begin() für ESP32 mit UPDATE_SIZE_UNKNOWN, U_FLASH");
// Hinweis: U_SPIFFS wäre für Dateisystem-Update
updateStarted = Update.begin(UPDATE_SIZE_UNKNOWN, U_FLASH);
#else // ESP8266
// Für ESP8266: Originalberechnung der maximalen Größe verwenden.
maxSketchSpace = (ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000;
Serial.printf("Update.begin() für ESP8266 mit max. Größe: %u\n", maxSketchSpace);
updateStarted = Update.begin(maxSketchSpace);
#endif
if (!updateStarted) { // Prüfen, ob begin() erfolgreich war
Update.printError(Serial);
// Optional: Hier könnte man versuchen, den Upload abzubrechen,
// aber das ist innerhalb dieses Handlers schwierig sauber zu implementieren.
// Der Server wird wahrscheinlich weiterhin Daten senden.
// Fehler wird dann spätestens bei Update.end() erkannt.
}
} else if (upload.status == UPLOAD_FILE_WRITE) {
// Prüfen auf Firmware-Passwort während des Schreibens
// Diese Logik bleibt wie im Original
for (size_t i = 0; i < upload.currentSize; i++) {
char c = (char)upload.buf[i];
if (!passwordFound) { // Nur prüfen, wenn noch nicht gefunden
if (c == FIRMWARE_PASSWORD[passMatchPos]) {
passMatchPos++;
if (passMatchPos == passLength) {
passwordFound = true;
Serial.println("Firmware-Kennwort gefunden!");
}
} else {
// Reset, wenn Zeichen nicht passt, aber prüfe, ob es der Anfang des Passworts ist
passMatchPos = (c == FIRMWARE_PASSWORD[0]) ? 1 : 0;
}
}
// yield() innerhalb der Schleife ist nicht ideal, aber kann nötig sein
// if (i % 1024 == 0) yield(); // Z.B. alle 1KB
}
yield(); // yield() nach der Schleife ist besser für Performance
// Daten in den Flash schreiben
if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) {
Update.printError(Serial);
// Update könnte hier bereits fehlerhaft sein, Fehler wird in Update.end() behandelt
}
} else if (upload.status == UPLOAD_FILE_END) {
// Upload abgeschlossen
Serial.printf("Upload Ende empfangen. Gesamtgröße: %u\n", upload.totalSize);
if (!passwordFound) // Wurde das Kennwort während des Uploads gefunden?
{
Serial.println("FEHLER: Kennwort nicht in Firmware gefunden! Update wird abgebrochen.");
Update.end(false); // Update abbrechen (false = kein Erfolg)
} else {
// Kennwort gefunden, versuche Update abzuschließen
Serial.println("Kennwort gefunden. Versuche Update abzuschließen...");
if (Update.end(true)) // Update erfolgreich beenden (true = Erfolg)
{
Serial.printf("Update erfolgreich abgeschlossen: %u Bytes geschrieben.\n", upload.totalSize);
// Die Antwort wird in handleFirmwareUpdateResponse() gesendet, die nach UPLOAD_FILE_END aufgerufen wird.
} else {
Serial.println("FEHLER: Update.end() fehlgeschlagen!");
Update.printError(Serial); // Gibt den spezifischen Update-Fehler aus
}
}
// Variablen für nächsten Upload zurücksetzen (optional, da eh in START zurückgesetzt)
// passwordFound = false;
// passMatchPos = 0;
} else if (upload.status == UPLOAD_FILE_ABORTED) {
// Upload vom Client abgebrochen
Update.end(false); // Sicherstellen, dass Update als nicht erfolgreich markiert wird
Serial.println(F("Update abgebrochen (vom Client)."));
passwordFound = false; // Status zurücksetzen
passMatchPos = 0;
}
yield(); // Wichtig, um den ESP am Laufen zu halten, besonders bei langen Uploads
}
/************************************************************************************
* Handler für die Antwort nach einem Firmware-Update - Heap-Optimiert
************************************************************************************/
// PROGMEM Chunks für die Seite
static const char updateDoneHtmlStart[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<title>Firmware-Update</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
)rawliteral";
static const char updateDoneHtmlHeadEnd[] PROGMEM = R"rawliteral(
</head>
)rawliteral";
static const char updateDoneHtmlBodyStart[] PROGMEM = R"rawliteral(
<head>
<meta http-equiv="refresh" content="30;url=/" />
<script>
setTimeout(function() {
window.location.href = "/";
}, 30000);
</script>
<body>
<h1>)rawliteral"; // Endet vor {status_message}
static const char updateDoneHtmlBodyEnd[] PROGMEM = R"rawliteral(</h1>
<form><p>Weiterleitung zur Startseite in 30 Sekunden ...</p></form>
</body>
</html>
)rawliteral"; // Beginnt nach {status_message}
// --- Neue Funktion ---
void handleFirmwareUpdateResponse() {
String statusMessage; // Status wird hier ermittelt
if (!passwordFound) {
statusMessage = F("Update abgebrochen! Die Firmware konnte nicht validiert werden.");
} else {
if (Update.hasError()) {
statusMessage = F("Update fehlgeschlagen!");
} else {
statusMessage = F("Update erfolgreich!");
}
}
// Seite senden
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, F("text/html"), "");
server.sendContent_P(updateDoneHtmlStart);
server.sendContent_P(commonStyle);
server.sendContent_P(updateDoneHtmlHeadEnd);
server.sendContent_P(commonNav);
server.sendContent_P(updateDoneHtmlBodyStart);
// Status senden (ist ein F()-Makro oder String)
if (statusMessage.length() > 0) {
server.sendContent(statusMessage);
}
server.sendContent_P(updateDoneHtmlBodyEnd);
server.sendContent(F("")); // Abschluss-Chunk
// Neustart-Logik
delay(1000); // Warte kurz, damit der Browser die Seite rendern kann
ESP.restart();
}
/************************************************************************************
* Schreibt einen gültigen Shot (Dauer > 20s) in die CSV-Logdatei.
* Dateiname: Nutzungsstatistik.csv
* Format: UnixTimestamp,DauerInMs
************************************************************************************/
void logShot(unsigned long durationMs) {
// Nur loggen, wenn Zeit synchronisiert ist (prüft Jahr > 2023)
time_t now_t;
time(&now_t); // Holt aktuelle Zeit (Unix Timestamp) vom System
struct tm timeinfo;
localtime_r(&now_t, &timeinfo); // Konvertiert in lokale Zeit
if (timeinfo.tm_year > (2023 - 1900)) { // tm_year ist Jahre seit 1900
// *** DATEINAME GEÄNDERT ***
File logFile = LittleFS.open("/Nutzungsstatistik.csv", "a"); // "a" = Append
if (!logFile) {
// *** DATEINAME IN FEHLERMELDUNG GEÄNDERT ***
Serial.println("FEHLER: Konnte Nutzungsstatistik.csv zum Schreiben nicht öffnen!");
return;
}
String logEntry = String(now_t) + "," + String(durationMs) + "\n";
if (logFile.print(logEntry)) {
// Serial.printf("Shot geloggt: %lu ms (Timestamp: %lu)\n", durationMs, now_t); // Optional: Debug-Ausgabe
} else {
// *** DATEINAME IN FEHLERMELDUNG GEÄNDERT ***
Serial.println("FEHLER: Konnte nicht in Nutzungsstatistik.csv schreiben!");
}
logFile.close();
} else {
Serial.println("WARNUNG: NTP Zeit noch nicht synchronisiert, Shot nicht geloggt.");
}
yield();
}
// --- Hilfsfunktionen für Datumsvergleiche ---
bool isSameDay(time_t timestamp, time_t now) {
struct tm ts_tm, now_tm;
localtime_r(&timestamp, &ts_tm);
localtime_r(&now, &now_tm);
return (ts_tm.tm_year == now_tm.tm_year && ts_tm.tm_mon == now_tm.tm_mon && ts_tm.tm_mday == now_tm.tm_mday);
}
bool isSameWeek(time_t timestamp, time_t now) {
struct tm ts_tm, now_tm;
localtime_r(&timestamp, &ts_tm);
localtime_r(&now, &now_tm);
int ts_wday = (ts_tm.tm_wday == 0) ? 6 : ts_tm.tm_wday - 1; // Mo=0..So=6
int now_wday = (now_tm.tm_wday == 0) ? 6 : now_tm.tm_wday - 1;
time_t ts_monday_start_of_day = timestamp - ts_tm.tm_hour * 3600 - ts_tm.tm_min * 60 - ts_tm.tm_sec - ts_wday * 86400;
time_t now_monday_start_of_day = now - now_tm.tm_hour * 3600 - now_tm.tm_min * 60 - now_tm.tm_sec - now_wday * 86400;
return (ts_monday_start_of_day == now_monday_start_of_day);
}
bool isSameMonth(time_t timestamp, time_t now) {
struct tm ts_tm, now_tm;
localtime_r(&timestamp, &ts_tm);
localtime_r(&now, &now_tm);
return (ts_tm.tm_year == now_tm.tm_year && ts_tm.tm_mon == now_tm.tm_mon);
}
/************************************************************************************
* Liest die Log-Datei (Nutzungsstatistik.csv) und berechnet Statistiken.
* Gibt eine ShotStats-Struktur zurück.
************************************************************************************/
ShotStats calculateShotStatistics() {
ShotStats stats; // Initialisiert alle Felder mit 0/false
time_t now_t;
time(&now_t);
struct tm timeinfo;
localtime_r(&now_t, &timeinfo);
// Prüfe, ob die *aktuelle* Zeit gültig erscheint (wichtig für relative Vergleiche wie Gestern etc.)
bool timeValid = (timeinfo.tm_year > (2023 - 1900)); // Beispiel: Prüft ob Jahr > 2023
File logFile = LittleFS.open("/Nutzungsstatistik.csv", "r");
if (!logFile || logFile.size() == 0) {
if (logFile) logFile.close();
stats.historyAvailable = false;
// Serial.println("Statistik: Log-Datei nicht gefunden oder leer."); // Optional: Debug-Ausgabe
return stats;
}
stats.historyAvailable = true; // Datei existiert und ist > 0 Bytes
int lineCounter = 0; // Zähler für yield()
while (logFile.available()) {
String line = logFile.readStringUntil('\n');
line.trim();
if (line.length() == 0) continue;
int commaIndex = line.indexOf(',');
if (commaIndex != -1) {
// strtoull verwenden, um mögliche Überläufe bei 32-Bit time_t zu vermeiden
time_t timestamp = (time_t)strtoull(line.substring(0, commaIndex).c_str(), NULL, 10);
unsigned long durationMs = strtoul(line.substring(commaIndex + 1).c_str(), NULL, 10);
if (timestamp > 0 && durationMs > 0) { // Grundlegende Prüfung der gelesenen Werte
stats.totalShotsLogged++;
stats.totalDurationMs += durationMs;
// Ersten und letzten Zeitstempel aktualisieren
if (stats.firstShotTimestamp == 0 || timestamp < stats.firstShotTimestamp) {
stats.firstShotTimestamp = timestamp;
}
if (timestamp > stats.lastShotTimestamp) {
stats.lastShotTimestamp = timestamp;
}
// Zeitbasierte Zähler nur füllen, wenn aktuelle Zeit gültig ist
if (timeValid) {
if (isSameDay(timestamp, now_t)) { stats.shotsToday++; }
if (isSameWeek(timestamp, now_t)) { stats.shotsThisWeek++; }
if (isSameMonth(timestamp, now_t)) { stats.shotsThisMonth++; }
// --- NEUE ZÄHLER AKTUALISIEREN ---
if (isYesterday(timestamp, now_t)) { stats.shotsYesterday++; }
if (isLastWeek(timestamp, now_t)) { stats.shotsLastWeek++; }
if (isLastMonth(timestamp, now_t)) { stats.shotsLastMonth++; }
}
} else {
Serial.printf("WARNUNG: Ungültige Zeile in Log übersprungen: %s\n", line.c_str());
}
} else {
Serial.printf("WARNUNG: Komma in Logzeile nicht gefunden: %s\n", line.c_str());
}
lineCounter++;
if (lineCounter % 50 == 0)
yield(); // Watchdog bei großen Dateien vermeiden
}
logFile.close();
// Berechnungen nach dem Lesen der Datei
if (stats.totalShotsLogged > 0) {
stats.averageDurationSec = (double)stats.totalDurationMs / 1000.0 / stats.totalShotsLogged;
// --- Durchschnitt Shots/Tag ---
if (stats.firstShotTimestamp > 0 && timeValid && now_t > stats.firstShotTimestamp) {
unsigned long secondsElapsed = now_t - stats.firstShotTimestamp;
unsigned long daysElapsed = secondsElapsed / 86400UL; // Ganze Tage seit erstem Shot
if (daysElapsed == 0) { daysElapsed = 1; } // Mindestens 1 Tag annehmen, um Division durch 0 zu vermeiden
stats.avgShotsPerDay = (double)stats.totalShotsLogged / daysElapsed;
}
}
return stats;
}
/************************************************************************************
* Handler zum Erstellen und Senden des formatierten Verlaufs (aus Nutzungsstatistik.csv)
* als TXT-Datei.
************************************************************************************/
void handleDownloadNutzungsstatistik() {
// *** DATEINAME GEÄNDERT ***
File logFile = LittleFS.open("/Nutzungsstatistik.csv", "r");
if (!logFile) {
// *** DATEINAME IN FEHLERMELDUNG GEÄNDERT ***
Serial.println("FEHLER: /Nutzungsstatistik.csv für formatierten Download nicht gefunden!");
server.send(404, "text/plain", "Fehler: Log-Datei nicht gefunden.");
yield();
return;
}
if (logFile.size() == 0) {
// *** DATEINAME IN FEHLERMELDUNG GEÄNDERT ***
Serial.println("INFO: /Nutzungsstatistik.csv für formatierten Download ist leer.");
logFile.close();
server.send(200, "text/plain", "Nutzungsstatistik ist leer."); // Sendet leere Datei
yield();
return;
}
// *** DATEINAME FÜR DOWNLOAD GEÄNDERT ***
server.sendHeader("Content-Disposition", "attachment; filename=\"Nutzungsverlauf.txt\"");
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "text/plain; charset=utf-8", "");
char lineBuffer[100];
char dateBuffer[25];
char durationBuffer[10];
server.sendContent("Datum, Uhrzeit, Dauer (s)\n");
server.sendContent("--------------------------\n");
int lineCount = 0;
while (logFile.available()) {
String line = logFile.readStringUntil('\n');
line.trim();
if (line.length() == 0) continue;
int commaIndex = line.indexOf(',');
if (commaIndex != -1) {
time_t timestamp = (time_t)line.substring(0, commaIndex).toInt();
unsigned long durationMs = line.substring(commaIndex + 1).toInt();
if (timestamp > 0 && durationMs > 0) {
struct tm timeinfo;
localtime_r(&timestamp, &timeinfo);
strftime(dateBuffer, sizeof(dateBuffer), "%d.%m.%Y, %H:%M:%S", &timeinfo);
snprintf(durationBuffer, sizeof(durationBuffer), "%.1f", durationMs / 1000.0);
snprintf(lineBuffer, sizeof(lineBuffer), "%s, %s\n", dateBuffer, durationBuffer);
server.sendContent(lineBuffer);
lineCount++;
if (lineCount % 20 == 0) { yield(); }
}
}
yield();
}
logFile.close();
server.sendContent("");
Serial.printf("Formatierter Verlauf mit %d Zeilen gesendet.\n", lineCount);
}
/************************************************************************************
* Hilfsfunktionen zum Erstellen der Statistik-Daten
************************************************************************************/
// Prüft, ob der timestamp am Vortag von 'now' liegt
bool isYesterday(time_t timestamp, time_t now) {
time_t yesterday_t = now - 86400UL; // Zeitstempel für vor 24 Stunden
struct tm ts_tm, yest_tm;
localtime_r(&timestamp, &ts_tm); // Konvertiert Timestamp in lokale Zeit-Struktur
localtime_r(&yesterday_t, &yest_tm); // Konvertiert Zeitstempel von Gestern
yield();
// Prüfen, ob Jahr, Monat und Tag übereinstimmen
return (ts_tm.tm_year == yest_tm.tm_year && ts_tm.tm_mon == yest_tm.tm_mon && ts_tm.tm_mday == yest_tm.tm_mday);
}
// Prüft, ob der timestamp in der Kalenderwoche *vor* der von 'now' liegt (Annahme: Woche beginnt Montag)
bool isLastWeek(time_t timestamp, time_t now) {
struct tm ts_tm, now_tm;
localtime_r(&timestamp, &ts_tm);
localtime_r(&now, &now_tm);
// Berechne den Start der aktuellen Woche (Montag 00:00:00)
int now_wday = (now_tm.tm_wday == 0) ? 6 : now_tm.tm_wday - 1; // Korrigiert Wochentag Mo=0..So=6
time_t startOfThisWeek = now - (now_tm.tm_hour * 3600) - (now_tm.tm_min * 60) - now_tm.tm_sec - (now_wday * 86400UL);
// Berechne den Start der letzten Woche (Montag 00:00:00)
time_t startOfLastWeek = startOfThisWeek - (7 * 86400UL);
// Prüfe, ob der Timestamp in der letzten Woche liegt (>= Start der letzten Woche UND < Start dieser Woche)
yield();
return (timestamp >= startOfLastWeek && timestamp < startOfThisWeek);
}
// Prüft, ob der timestamp im Kalendermonat *vor* dem von 'now' liegt
bool isLastMonth(time_t timestamp, time_t now) {
struct tm ts_tm, now_tm;
localtime_r(&timestamp, &ts_tm);
localtime_r(&now, &now_tm);
int lastMonth = now_tm.tm_mon - 1; // Monat vor dem aktuellen
int lastMonthYear = now_tm.tm_year; // Jahr des letzten Monats (Standard: aktuelles Jahr)
if (lastMonth < 0) { // Sonderfall: Aktueller Monat ist Januar (tm_mon = 0)
lastMonth = 11; // Letzter Monat war Dezember (tm_mon = 11)
lastMonthYear = now_tm.tm_year - 1; // im vorherigen Jahr
}
// Prüfe, ob Jahr und Monat des Timestamps mit dem letzten Monat übereinstimmen
yield();
return (ts_tm.tm_year == lastMonthYear && ts_tm.tm_mon == lastMonth);
}
/************************************************************************************
* Handler zum entfernen der Nutzungsstatistik-Datei (/Nutzungsstatistik.csv)
************************************************************************************/
void handleDeleteStatistics() {
const char* statsFilename = "/Nutzungsstatistik.csv";
Serial.printf("Anfrage zum entfernen von '%s' erhalten.\n", statsFilename);
bool success = false;
if (LittleFS.exists(statsFilename)) {
if (LittleFS.remove(statsFilename)) {
Serial.printf("Datei '%s' erfolgreich gel&ouml;scht.\n", statsFilename);
success = true;
// Reset der Statistik-Variablen im RAM (optional, aber sinnvoll für sofortige Anzeige)
shotCounter = 0; // Wenn der globale Zähler auch zurückgesetzt werden soll
EEPROM.put(EEPROM_ADDR_SHOTCOUNTER, shotCounter); // Ggf. auch im EEPROM speichern
// Andere Statistikvariablen werden beim nächsten Aufruf von calculateShotStatistics neu berechnet
// maintenanceIntervalCounter NICHT zurücksetzen, das hat einen eigenen Reset.
} else {
Serial.printf("FEHLER: Datei '%s' konnte nicht gel&ouml;scht werden!\n", statsFilename);
}
} else {
Serial.printf("Datei '%s' nicht gefunden, nichts zu entfernen.\n", statsFilename);
// Man könnte dies auch als Erfolg werten, da das Ergebnis (keine Datei) erreicht ist.
success = true;
}
// Nach Aktion zurück zur Info-Seite leiten
server.sendHeader(F("Location"), "/Info"); // F() Makro verwenden
server.send(303); // 303 See Other - Korrekt für Redirect nach POST mit Location Header
yield();
}
/************************************************************************************
* Handler zum Umschalten des Wartungsmodus
************************************************************************************/
void handleToggleWartungsmodus() {
wartungsModusAktiv = !wartungsModusAktiv; // Zustand umkehren
if (!wartungsModusAktiv) {
// Standard-Temperaturwerte laden
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
}
if (wartungsModusAktiv) {
// Temperaturen auf Wartungsmodus-Temperaturen setzen
SetpointWasser = wartungsModusTemp;
SetpointDampf = wartungsModusTemp;
}
Serial.print("Wartungsmodus: ");
Serial.println(wartungsModusAktiv ? "aktiviert" : "deaktiviert");
// Zurück zur Info-Seite leiten
server.sendHeader(F("Location"), F("/Service"));
server.send(303);
}
#ifdef ESP32
/************************************************************************************
* Stoppt den Brühvorgang (Pumpe/Ventil) und verarbeitet Shot-Daten
* Wird von updateShotTimer() oder aus loop() (BrewByTime) aufgerufen.
************************************************************************************/
void stopBrewSequence(unsigned long durationMillis) {
if (!shotActive) return; // Verhindert mehrfaches Ausführen
lastShotDurationMillis = durationMillis; // Dauer speichern
shotEndTime = millis(); // Endzeitpunkt speichern
Serial.printf("Bezug beendet - Dauer: %.1f s\n", durationMillis / 1000.0);
// Shot zählen und loggen (nur wenn > 20 Sekunden)
if (durationMillis > 20000) {
shotCounter++;
maintenanceIntervalCounter++;
logShot(durationMillis); // Schreibt in CSV (wenn NTP Zeit ok)
if (!demoModus) { // Nur speichern, wenn kein Demo-Modus
EEPROM.put(EEPROM_ADDR_SHOTCOUNTER, shotCounter);
EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter);
if (!EEPROM.commit()) {
Serial.println("FEHLER: EEPROM Commit beim Speichern der Shot-Zähler fehlgeschlagen!");
}
}
Serial.print(F("Shot gezählt. Neuer Stand: ")); Serial.println(shotCounter);
// Prüfen, ob Wartungsintervall erreicht ist
if (maintenanceInterval > 0 && maintenanceIntervalCounter >= maintenanceInterval) {
displayMaintenanceMessage = true; // Flag für Anzeige setzen
maintenanceMessageStartTime = millis(); // Startzeit für Anzeige merken
Serial.println("Wartungsintervall erreicht!");
}
}
// Statusvariablen zurücksetzen
shotActive = false;
currentPreInfusionState = PI_INACTIVE;
lastShotTime = millis(); // Wichtig für Eco-Modus
// Hardware stoppen
digitalWrite(PUMP_PIN, LOW);
digitalWrite(VALVE_PIN, LOW); // Ventil öffnen -> Druck ablassen
// Fast-Heat-Up eventuell deaktivieren (obwohl unwahrscheinlich hier noch aktiv)
fastHeatUpHeating = false;
}
#endif // ESP32