16187 lines
719 KiB
Arduino
16187 lines
719 KiB
Arduino
/*
|
|
* Copyright (c) 2025 Thomas Müller
|
|
* All rights reserved.
|
|
*
|
|
* Redistribution and use in source and binary forms, with or without
|
|
* modification, are permitted provided that the following conditions
|
|
* are met:
|
|
* 1. Redistributions of source code must retain the above copyright
|
|
* notice, this list of conditions and the following disclaimer.
|
|
* 2. Redistributions in binary form must reproduce the above copyright
|
|
* notice, this list of conditions and the following disclaimer in the
|
|
* documentation and/or other materials provided with the distribution.
|
|
*
|
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
|
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
|
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
|
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
|
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
|
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
|
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
*/
|
|
|
|
/************************************************************************************
|
|
* Dual-PID Controller Firmware (Zeitproportional)
|
|
* Version (Siehe unten im Code)
|
|
*
|
|
* Dieses Programm steuert Wasser- und Dampftemperatur über zwei unabhängige PID-Regler
|
|
* mit zeitproportionaler Ansteuerung der SSRs.
|
|
* Sensoren: MAX6675 (Dampf), NTC (Wasser), NTC (Gehäuse/Tassenablage).
|
|
* Funktionen: Eco-Modus, Profile, Fast-Heat-Up, WLAN-Weboberfläche, AutoTune,
|
|
* Wartungserinnerung, Werkseinstellungen, Hostname-Konfiguration.
|
|
*
|
|
* Compiler- und Hardware-Umgebung:
|
|
* - ESP32: ESP32 S3 DevKit
|
|
************************************************************************************/
|
|
|
|
/************************************************************************************
|
|
* WAAGEN-KONFIGURATION
|
|
************************************************************************************/
|
|
#define SCALE_NONE 0
|
|
#define SCALE_I2C 1
|
|
#define SCALE_ESPNOW 2
|
|
#define SCALE_HX711 3
|
|
|
|
/************************************************************************************
|
|
* NACH BEDARF ANPASSEN - HARDWARE-SPEZIFIKATIONEN
|
|
************************************************************************************/
|
|
// Standard-Waagen-Typ (kann in /Sensoren geändert werden)
|
|
#define DEFAULT_SCALE_TYPE SCALE_ESPNOW
|
|
|
|
// Nachfolgende Zeile auskommentieren, falls die Steuerung ohne Display verwendet wird!
|
|
// #define ENABLE_DISPLAY
|
|
|
|
/************************************************************************************
|
|
* Vorwärtsdeklarationen benutzerdefinierter Strukturen
|
|
* Erforderlich, damit die Arduino-Präprozessor-Prototypen gültige Typen sehen
|
|
************************************************************************************/
|
|
struct WiFiConfig;
|
|
struct TemperatureProfile;
|
|
struct StandbyTimerEntry;
|
|
struct MomentaryButtonState;
|
|
struct ShotStats;
|
|
struct BrewControlUpdate;
|
|
struct SensorSettingsUpdate;
|
|
struct SensorSettingsApplyResult;
|
|
struct ServiceSettingsUpdate;
|
|
struct FullyKioskConfig;
|
|
class AsyncWebServerRequest;
|
|
|
|
// Vorwaertsdeklarationen fuer Funktionen, die vor ihrer Definition genutzt werden
|
|
double readNTCTemperature();
|
|
double readCaseTemperature();
|
|
void updateDisplay();
|
|
bool executeDashboardAction(const String& action, const String& value,
|
|
bool& success, String& message,
|
|
bool& settingsChanged, bool& pidNeedsUpdate);
|
|
void touchUartRxTick();
|
|
void touchUartTick();
|
|
void initResetDiagnostics();
|
|
void recordResetCheckpoint(uint16_t checkpoint);
|
|
void handleClockPage(AsyncWebServerRequest *request);
|
|
void syncFullyKioskWithStandby(bool active);
|
|
|
|
/************************************************************************************
|
|
* Includes & Bibliotheken
|
|
************************************************************************************/
|
|
#include <Wire.h>
|
|
|
|
#ifdef ENABLE_DISPLAY
|
|
#include <Adafruit_GFX.h>
|
|
#include <Adafruit_SH110X.h>
|
|
#endif // ENABLE_DISPLAY
|
|
|
|
#include <EEPROM.h>
|
|
#include <PID_v1.h>
|
|
#ifdef LIBRARY_VERSION
|
|
#undef LIBRARY_VERSION
|
|
#endif
|
|
#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 <FFat.h> // FATFS/FFat-Implementierung für ESP32
|
|
#include <WiFiUdp.h>
|
|
#include <NTPClient.h>
|
|
#include <time.h> // für Zeitfunktionen (struct tm, mktime, etc.)
|
|
#include <algorithm>
|
|
#include <vector> // Erforderlich für std::vector
|
|
#include <pgmspace.h>
|
|
|
|
// ESP32-only build guard
|
|
#if !defined(ESP32)
|
|
#error "This firmware supports ESP32 only."
|
|
#endif
|
|
|
|
// Keep the existing storage calls and route them to FFat.
|
|
#define LittleFS FFat
|
|
|
|
// Plattformspezifische Includes (ESP32)
|
|
// für asynchronen Webserver
|
|
#include <WiFi.h> // ESP32 WiFi Core
|
|
#include <AsyncTCP.h> // TCP Stack für AsyncWebServer
|
|
#include <ESPAsyncWebServer.h> // Asynchroner Webserver
|
|
#include <ESPmDNS.h> // ESP32 mDNS Implementation
|
|
#include <HTTPClient.h> // Fuer Fully-Kiosk REST-Aufrufe
|
|
#include <Update.h> // Für Firmware-Updates
|
|
#include "mbedtls/base64.h" // Base64 fuer Display-OTA ueber UART
|
|
#ifdef LED_BUILTIN
|
|
#undef LED_BUILTIN
|
|
#endif
|
|
#define LED_BUILTIN 2 // Standard LED Pin für viele ESP32 Boards (optional, falls genutzt)
|
|
|
|
// --- KONDITIONALE WAAGEN-INCLUDES ---
|
|
#include "UNIT_SCALES.h" // für I2C-Waage
|
|
#include <esp_now.h> // für ESP-NOW-Waage
|
|
#include "HX711.h" // für HX711-Waage
|
|
// --- ENDE KONDITIONALE WAAGEN-INCLUDES ---
|
|
|
|
/************************************************************************************
|
|
* Webserver und zugehörige Objekte
|
|
************************************************************************************/
|
|
// Asynchroner Webserver (ESP32)
|
|
AsyncWebServer asyncServer(80);
|
|
AsyncWebSocket dashboardWs("/ws-dashboard");
|
|
|
|
const unsigned long DASHBOARD_WS_INTERVAL_IDLE_MS = 1000;
|
|
const unsigned long DASHBOARD_WS_INTERVAL_ACTIVE_MS = 250;
|
|
const unsigned long DASHBOARD_WS_SHOT_END_GRACE_MS = 500;
|
|
#define DASHBOARD_JSON_BUFFER_SIZE 2400
|
|
unsigned long lastDashboardWsPushMs = 0;
|
|
|
|
// JC-Touch-Display (UART). Bewusst UNABHAENGIG von ENABLE_DISPLAY (OLED), damit alle
|
|
// Kombinationen baubar sind (nur OLED / nur JC / beide / keines). Auf 1 setzen, sobald
|
|
// das JC-Display an Serial1 (GPIO43/44) angeschlossen ist.
|
|
#define TOUCH_UART_ENABLED 1
|
|
#define TOUCH_UART_TX_PIN 43
|
|
#define TOUCH_UART_RX_PIN 44
|
|
#define TOUCH_UART_BAUDRATE 230400
|
|
#define TOUCH_UART_MAX_LINE_LEN 1024
|
|
#define TOUCH_UART_PROTOCOL_VERSION 2 // v2: + Netzwerk-/PID-/Duty-/AutoTune-Telemetrie, hello-Handshake
|
|
#define TOUCH_UART_JSON_BUFFER_SIZE 3000 // v2: groesserer Headroom fuer die zusaetzlichen Felder
|
|
|
|
#define DEFAULT_FULLY_KIOSK_ENABLED 0
|
|
#define DEFAULT_FULLY_KIOSK_HOST ""
|
|
#define DEFAULT_FULLY_KIOSK_PORT 2323
|
|
#define DEFAULT_FULLY_KIOSK_PASSWORD ""
|
|
#define DEFAULT_FULLY_KIOSK_COMMAND_TIMEOUT_MS 700
|
|
#define DEFAULT_FULLY_KIOSK_STANDBY_PATH "/clock"
|
|
#define DEFAULT_FULLY_KIOSK_ACTIVE_PATH "/dashboard"
|
|
|
|
const unsigned long TOUCH_UART_INTERVAL_IDLE_MS = 1000;
|
|
const unsigned long TOUCH_UART_INTERVAL_ACTIVE_MS = 250;
|
|
const unsigned long TOUCH_UART_SHOT_END_GRACE_MS = 500;
|
|
const unsigned long TOUCH_UART_CLIENT_TIMEOUT_MS = 15000;
|
|
unsigned long lastTouchUartPushMs = 0;
|
|
|
|
HardwareSerial& touchUart = Serial1;
|
|
String touchUartRxLine;
|
|
bool touchUartRxOverflow = false;
|
|
bool touchUartClientActive = false;
|
|
String touchUartClientFirmware; // vom P4 im hello gemeldeter Firmware-Stand ("" = unbekannt)
|
|
unsigned long touchUartLastClientSeenMs = 0;
|
|
bool touchUartLastShotActive = false;
|
|
bool touchUartLastFlushActive = false;
|
|
bool touchUartLastSteamCircuitActive = false; // fuer sofortigen Push bei Dampfbezug/Dampf-Flush (setzt steamCircuitActive)
|
|
bool touchUartLastTareActive = false; // fuer sofortigen Push bei Start der Tarier-Phase (Brew-by-Weight)
|
|
bool touchUartLastErrorActive = false; // fuer sofortigen Push bei Sensorfehler/Sicherheitsabschaltung
|
|
bool touchUartLastCleaningActive = false; // fuer sofortigen Push bei Start/Stopp des Reinigungsassistenten
|
|
bool touchUartLastStandby = false; // fuer sofortigen Push bei Standby-Wechsel (Display an/aus)
|
|
unsigned long touchUartBurstUntil = 0;
|
|
bool touchUartWifiScanPending = false; // asynchroner WLAN-Scan laeuft (fuer Display)
|
|
unsigned long touchUartWifiScanStartMs = 0; // Startzeitpunkt fuer Scan-Timeout
|
|
volatile bool displayOtaActive = false; // Display-Firmware-Update laeuft (UART exklusiv)
|
|
bool displayOtaResultOk = false; // Ergebnis des letzten Display-OTA
|
|
|
|
enum ResetDiagCheckpoint : uint16_t {
|
|
RESET_CP_UNKNOWN = 0,
|
|
RESET_CP_SETUP_START,
|
|
RESET_CP_SETUP_WIFI,
|
|
RESET_CP_SETUP_DISPLAY,
|
|
RESET_CP_SETUP_SCALE,
|
|
RESET_CP_SETUP_WEBSERVER,
|
|
RESET_CP_LOOP_START,
|
|
RESET_CP_LOOP_TEMPERATURE,
|
|
RESET_CP_LOOP_SSR,
|
|
RESET_CP_LOOP_SHOT,
|
|
RESET_CP_LOOP_ECO,
|
|
RESET_CP_LOOP_DISPLAY,
|
|
RESET_CP_LOOP_SCALE,
|
|
RESET_CP_LOOP_WS,
|
|
RESET_CP_DISPLAY_RENDER,
|
|
RESET_CP_SCALE_HX711_READ,
|
|
RESET_CP_SCALE_I2C_READ,
|
|
RESET_CP_SCALE_BUTTON_READ,
|
|
RESET_CP_ESPNOW_RECV,
|
|
RESET_CP_TOUCH_UART_RX,
|
|
RESET_CP_TOUCH_UART_TX
|
|
};
|
|
|
|
struct ResetDiagRTCData {
|
|
uint32_t magic;
|
|
uint16_t version;
|
|
uint16_t checkpoint;
|
|
uint32_t uptimeMs;
|
|
uint32_t freeHeap;
|
|
uint32_t flags;
|
|
int16_t waterTempDeciC;
|
|
int16_t steamTempDeciC;
|
|
int16_t caseTempDeciC;
|
|
int16_t weightDeciG;
|
|
uint8_t scaleType;
|
|
int8_t wifiStatus;
|
|
uint8_t wifiMode;
|
|
uint8_t reserved;
|
|
};
|
|
|
|
constexpr uint32_t RESET_DIAG_MAGIC = 0x52445354UL; // "RDST"
|
|
constexpr uint16_t RESET_DIAG_VERSION = 1;
|
|
constexpr int16_t RESET_DIAG_INVALID_VALUE = -32768;
|
|
constexpr uint32_t RESET_DIAG_FLAG_STANDBY = 1UL << 0;
|
|
constexpr uint32_t RESET_DIAG_FLAG_ECO = 1UL << 1;
|
|
constexpr uint32_t RESET_DIAG_FLAG_SHOT = 1UL << 2;
|
|
constexpr uint32_t RESET_DIAG_FLAG_STEAM = 1UL << 3;
|
|
constexpr uint32_t RESET_DIAG_FLAG_FLUSH = 1UL << 4;
|
|
constexpr uint32_t RESET_DIAG_FLAG_CLEANING = 1UL << 5;
|
|
constexpr uint32_t RESET_DIAG_FLAG_SCALE_CONNECTED = 1UL << 6;
|
|
constexpr uint32_t RESET_DIAG_FLAG_SCALE_MODE = 1UL << 7;
|
|
constexpr uint32_t RESET_DIAG_FLAG_WIFI_CONNECTED = 1UL << 8;
|
|
constexpr uint32_t RESET_DIAG_FLAG_TARE_PENDING = 1UL << 9;
|
|
constexpr uint32_t RESET_DIAG_FLAG_TARE_SETTLING = 1UL << 10;
|
|
constexpr uint32_t RESET_DIAG_FLAG_SENSOR_W_ERROR = 1UL << 11;
|
|
constexpr uint32_t RESET_DIAG_FLAG_SENSOR_D_ERROR = 1UL << 12;
|
|
constexpr uint32_t RESET_DIAG_FLAG_SENSOR_CASE_ERROR = 1UL << 13;
|
|
constexpr uint32_t RESET_DIAG_FLAG_STEAM_HEAT_DISABLED = 1UL << 14;
|
|
|
|
RTC_DATA_ATTR ResetDiagRTCData rtcResetDiag = {};
|
|
|
|
bool lastResetDiagAvailable = false;
|
|
esp_reset_reason_t lastResetReason = ESP_RST_UNKNOWN;
|
|
ResetDiagRTCData lastResetDiag = {};
|
|
|
|
|
|
unsigned long scheduledRestart = 0;
|
|
void scheduleRestart(uint32_t delayMs) {
|
|
scheduledRestart = millis() + delayMs;
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Hardware-Pinbelegung (ESP32 S3 DevKit)
|
|
************************************************************************************/
|
|
|
|
// --- ESP32 S3 DevKit Pinout ---
|
|
#define SSR_WASSER_PIN 47 // SSR water heater (GPIO47)
|
|
#define SSR_DAMPF_PIN 48 // SSR steam heater (GPIO48)
|
|
#define SSR_LIGHT_PIN 15 // SSR light (GPIO15)
|
|
#define SSR_STEAM_CIRCUIT_PIN 16 // SSR pump + valve (steam circuit) (GPIO16)
|
|
#define SHOT_TIMER_PIN 8 // Shot switch input (GPIO8)
|
|
#define SHOT_TIMER_ACTIVE_LEVEL LOW // Pull-up wiring: switch to GND = active
|
|
#define STEAM_CIRCUIT_SWITCH_PIN 9 // Steam circuit switch input (GPIO9)
|
|
#define STEAM_CIRCUIT_SWITCH_ACTIVE_LEVEL LOW // Pull-up wiring: switch to GND = on
|
|
#define STANDBY_SWITCH_PIN 10 // Standby switch input (GPIO10)
|
|
#define STANDBY_SWITCH_ACTIVE_LEVEL LOW // Pull-up wiring: switch to GND = on
|
|
#define ECO_SWITCH_PIN 11 // Eco switch input (GPIO11)
|
|
#define ECO_SWITCH_ACTIVE_LEVEL LOW // Pull-up wiring: switch to GND = on
|
|
#define X_SWITCH_PIN 42 // X-Switch input (GPIO42)
|
|
#define X_SWITCH_ACTIVE_LEVEL LOW // Pull-up wiring: switch to GND = on
|
|
const unsigned long STANDBY_SWITCH_DEBOUNCE_MS = 50;
|
|
const unsigned long STANDBY_SWITCH_HOLD_HINT_MS = 1500;
|
|
const unsigned long ECO_SWITCH_DEBOUNCE_MS = 50;
|
|
const unsigned long X_SWITCH_DEBOUNCE_MS = 50;
|
|
const unsigned long SHOT_BUTTON_DEBOUNCE_MS = 50;
|
|
const unsigned long STEAM_BUTTON_DEBOUNCE_MS = 50;
|
|
const unsigned long DEFAULT_BUTTON_LONG_PRESS_MS = 1000;
|
|
const unsigned long BUTTON_LONG_PRESS_MIN_MS = 400;
|
|
const unsigned long BUTTON_LONG_PRESS_MAX_MS = 3000;
|
|
#define ANALOG_NTC_PIN 4 // NTC 50k divider to 3V3 (GPIO4 / ADC1_CH3)
|
|
#define ANALOG_CASE_NTC_PIN 5 // NTC 50k divider to 3V3 (GPIO5)
|
|
#define IIC_5V_SCK 17 // I2C SCK (GPIO17)
|
|
#define IIC_5V_SDA 18 // I2C SDA (GPIO18)
|
|
|
|
// Piezo
|
|
#define PIEZO_PIN 14 // Piezo buzzer (GPIO14)
|
|
|
|
// Pump and valve
|
|
#define PUMP_PIN 7 // Pump SSR (GPIO7)
|
|
#define VALVE_PIN 6 // Valve SSR (GPIO6)
|
|
|
|
// MAX6675 (steam temp) - SPI pins for ESP32
|
|
#define MAX6675_SCK 41 // Serial Clock (GPIO41)
|
|
#define MAX6675_CS 40 // Chip Select (GPIO40)
|
|
#define MAX6675_SO 39 // Serial Out (GPIO39)
|
|
|
|
// HX711 (scale)
|
|
#define HX711_DT_PIN 12 // HX711 DT (GPIO12)
|
|
#define HX711_SCK_PIN 13 // HX711 SCK (GPIO13)
|
|
|
|
/************************************************************************************
|
|
* MAX6675 (Dampf-Temperatursensor)
|
|
************************************************************************************/
|
|
// Pins are defined above.
|
|
MAX6675 thermocouple(MAX6675_SCK, MAX6675_CS, MAX6675_SO);
|
|
|
|
/************************************************************************************
|
|
* Display-Objekt (Adafruit SH1106G)
|
|
************************************************************************************/
|
|
#ifdef ENABLE_DISPLAY
|
|
/************************************************************************************
|
|
* Display-Objekt (Adafruit SH1106G)
|
|
************************************************************************************/
|
|
// Dieser Block muss vorhanden sein, wenn Display aktiviert!
|
|
Adafruit_SH1106G display = Adafruit_SH1106G(128, 64, &Wire);
|
|
#endif // ENABLE_DISPLAY
|
|
|
|
/************************************************************************************
|
|
* Firmware-Informationen
|
|
************************************************************************************/
|
|
|
|
String version = "5.0.4";
|
|
String versionHersteller = "Thomas Mü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>";
|
|
String versionHerstellerGitHub =
|
|
"<a href='https://github.com/thomas-michael-mueller/Dual-PID-Controller' target='_blank' class='info-link'>https://github.com/thomas-michael-mueller/Dual-PID-Controller</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, 88);
|
|
|
|
// Dieses Kennwort muss in einer Firmware-Binärdatei vorhanden sein, damit ein Update gültig ist.
|
|
static const char FIRMWARE_PASSWORD[] = "FWKennwort123";
|
|
static bool passwordFound = false;
|
|
static int passMatchPos = 0;
|
|
const int passLength = sizeof(FIRMWARE_PASSWORD) - 1;
|
|
|
|
/************************************************************************************
|
|
* Geräte-Infos
|
|
************************************************************************************/
|
|
|
|
char infoHersteller[50];
|
|
char infoModell[50];
|
|
char infoZusatz[50];
|
|
|
|
/************************************************************************************
|
|
* NTC-Sensor-Parameter (Wasser + Zusatzsensor)
|
|
* - Temperaturmessung über Spannungsteiler an ADC-Pins
|
|
************************************************************************************/
|
|
|
|
const double V_SUPPLY = 3.3; // Versorgungsspannung
|
|
const double ADC_REF = 3.3; // ADC-Referenz
|
|
const double R_FIXED = 3000.0; // Festwiderstand 3 kΩ
|
|
|
|
// Daten des NTC
|
|
const double NTC_T0 = 298.15; // Referenztemperatur in Kelvin (25°C)
|
|
const double NTC_R0 = 50000.0; // NTC-Widerstand bei 25°C
|
|
const double beta = 3950.0; // Beta-Wert
|
|
|
|
|
|
/************************************************************************************
|
|
* Waagen-Instanz & Variablen (Runtime konfigurierbar)
|
|
************************************************************************************/
|
|
// --- Gemeinsame Waagen-Variablen ---
|
|
bool scaleEnabled = true; // Waage aktiv (per Web-UI steuerbar)
|
|
uint8_t scaleType = DEFAULT_SCALE_TYPE; // Waagen-Typ (SCALE_*)
|
|
bool scaleConnected = false; // Zeigt an, ob eine Waage verbunden ist (egal welcher Typ)
|
|
bool scaleModeActive = false; // Fuer Anzeige der Waage im Display
|
|
volatile float currentWeightReading = 0.0f; // Aktueller Messwert der Waage
|
|
volatile float scaleDisplayWeightReading = 0.0f; // Geglaetteter Wert fuer den Waage-Modus
|
|
volatile bool scaleDisplayWeightInitialized = false;
|
|
float scaleSanitizerSamples[3] = {0.0f, 0.0f, 0.0f};
|
|
uint8_t scaleSanitizerSampleCount = 0;
|
|
uint8_t scaleSanitizerSampleIndex = 0;
|
|
uint8_t scaleSanitizerSuspectCount = 0;
|
|
bool scaleSanitizerInitialized = false;
|
|
const float SCALE_DISPLAY_ZERO_SNAP_GRAMS = 0.05f;
|
|
const float SCALE_DISPLAY_DEADBAND_GRAMS = 0.05f;
|
|
const float SCALE_DISPLAY_SMALL_DELTA_GRAMS = 0.3f;
|
|
const float SCALE_DISPLAY_FAST_DELTA_GRAMS = 1.0f;
|
|
const float SCALE_DISPLAY_SLOW_ALPHA = 0.18f;
|
|
const float SCALE_DISPLAY_MID_ALPHA = 0.35f;
|
|
const float SCALE_DISPLAY_FAST_ALPHA = 0.70f;
|
|
const float SCALE_SANITIZER_IDLE_SPIKE_GRAMS = 3.0f;
|
|
const float SCALE_SANITIZER_SHOT_SPIKE_GRAMS = 10.0f;
|
|
const uint8_t SCALE_SANITIZER_ACCEPT_AFTER_SUSPECTS = 2;
|
|
volatile float brewStartWeight = 0.0f;
|
|
const float WEIGHT_CHANGE_THRESHOLD = 0.1f; // Schwelle in g fuer die erste Gewichtsaenderung
|
|
unsigned long firstWeightChangeTime = 0; // Zeitpunkt der ersten Gewichtsaenderung
|
|
bool firstWeightChangeDetected = false; // Flag, ob bereits eine Aenderung erkannt wurde
|
|
unsigned long lastShotFirstWeightChangeTime = 0; // Gespeicherter Zeitpunkt fuer den letzten Shot
|
|
|
|
static void resetScaleDisplayFilter(float rawWeight = 0.0f);
|
|
static float updateScaleDisplayFilter(float rawWeight, bool force = false);
|
|
static void resetScaleReadingFilters(float rawWeight = 0.0f);
|
|
static float acceptScaleReading(float rawWeight, bool forceSanitizer = false, bool forceDisplay = false);
|
|
static float getWeightReadingForUi();
|
|
|
|
// --- I2C-spezifische Variablen ---
|
|
UNIT_SCALES scales; // Instanz fuer die I2C-Waage
|
|
|
|
// Button-Logik fuer die I2C-Waage
|
|
unsigned long scaleButtonPressStartTime = 0;
|
|
bool scaleButtonIsCurrentlyPressed = false;
|
|
uint8_t lastScaleButtonRawState = 1;
|
|
const unsigned long longPressThresholdScale = 1000;
|
|
const unsigned long debounceDelayScale = 50;
|
|
unsigned long lastScaleButtonDebounceTime = 0;
|
|
uint8_t currentScaleButtonDebouncedState = 1;
|
|
uint8_t lastScaleButtonDebouncedState = 1;
|
|
|
|
// Verzoegertes Tarieren fuer die I2C-Waage
|
|
bool tareScaleAfterDelay = false;
|
|
unsigned long tareScaleDelayStartTime = 0;
|
|
const unsigned long TARE_SCALE_DELAY_DURATION = 1000;
|
|
bool pendingShotStartAfterScaleTare = false;
|
|
bool hx711ShotTarePrepared = false;
|
|
uint8_t hx711ShotPostTareReadsRemaining = 0;
|
|
|
|
// Nachlaufzeit nach dem Tarieren (nicht-blockierend)
|
|
bool tareScaleSettling = false;
|
|
unsigned long tareScaleSettlingStartTime = 0;
|
|
const unsigned long TARE_SCALE_SETTLING_DURATION = 50;
|
|
// Einmalige, stille Tarierung direkt nach dem Start (ohne Piezo-Piep),
|
|
// damit nicht sofort ein Restwert (z.B. 13 g) im Display erscheint.
|
|
bool startupSilentTarePending = false;
|
|
const uint8_t HX711_POST_TARE_STABILIZATION_READS = 2;
|
|
|
|
// --- ESP-NOW-spezifische Variablen ---
|
|
typedef struct struct_espnow_scale_message {
|
|
float weight_g;
|
|
uint8_t status_flags;
|
|
uint8_t battery_percentage;
|
|
} struct_espnow_scale_message;
|
|
|
|
#define ESPNOW_SCALE_FLAG_JUST_TARED (1 << 0)
|
|
#define ESPNOW_SCALE_FLAG_TOGGLE_MODE (1 << 1)
|
|
#define ESPNOW_SCALE_FLAG_AWOKE (1 << 2)
|
|
|
|
unsigned long lastScaleMessageTime = 0;
|
|
const unsigned long SCALE_TIMEOUT = 5000; // 5 Sekunden Timeout
|
|
|
|
// --- HX711-spezifische Variablen ---
|
|
HX711 hx711;
|
|
unsigned long hx711LastReadyTime = 0;
|
|
unsigned long hx711LastReadAttemptTime = 0;
|
|
bool hx711HasValidReading = false;
|
|
uint8_t hx711NotReadyCounter = 0;
|
|
const uint8_t HX711_NOT_READY_LIMIT = 30; // ~2.4s bei 80ms Poll-Intervall bis "getrennt"
|
|
const unsigned long HX711_MIN_READ_INTERVAL = 80; // HX711-Leseversuche drosseln, um loop() flüssig zu halten
|
|
|
|
/************************************************************************************
|
|
* Temperatur-Profil-Struktur (ERWEITERT)
|
|
* - Speichert nahezu alle konfigurierbaren Einstellungen pro Profil
|
|
************************************************************************************/
|
|
// VERSIONSNUMMER
|
|
const uint16_t CURRENT_PROFILE_VERSION = 5; // Versionsnummer für die Struktur
|
|
|
|
struct TemperatureProfile {
|
|
// --- Identifikation & Version ---
|
|
uint16_t profileVersion; // Zur Kompatibilitätsprüfung beim Laden
|
|
char profileName[31]; // Angezeigter Name (etwas mehr Platz + Nullterminator)
|
|
|
|
// --- PID Einstellungen (Seite: /) ---
|
|
double setpointWasser;
|
|
double setpointDampf;
|
|
double offsetWasser;
|
|
double offsetDampf;
|
|
double kpWasser;
|
|
double kiWasser;
|
|
double kdWasser;
|
|
double kpDampf;
|
|
double kiDampf;
|
|
double kdDampf;
|
|
bool boostWasserActive;
|
|
bool boostDampfActive;
|
|
bool preventHeatAboveSetpointWasser;
|
|
bool preventHeatAboveSetpointDampf;
|
|
unsigned long windowSizeWasser;
|
|
unsigned long windowSizeDampf;
|
|
double maxTempWasser;
|
|
double maxTempDampf;
|
|
|
|
// --- PID Tuning Parameter (Seite: /PID-Tuning) ---
|
|
double tuningStepWasser;
|
|
double tuningNoiseWasser;
|
|
double tuningStartValueWasser;
|
|
unsigned int tuningLookBackWasser;
|
|
double tuningStepDampf;
|
|
double tuningNoiseDampf;
|
|
double tuningStartValueDampf;
|
|
unsigned int tuningLookBackDampf;
|
|
|
|
// --- ECO Modus (Seite: /ECO) ---
|
|
int ecoModeMinutes;
|
|
int ecoModeTempWasser;
|
|
int ecoModeTempDampf;
|
|
bool dynamicEcoActive;
|
|
int dampfVerzoegerung; // Dampfverzögerung ist auf ECO-Seite
|
|
bool steamDelayOverrideBySwitch;
|
|
bool steamHeatDisabledOnStartupWake;
|
|
|
|
// --- Fast-Heat-Up (Seite: /Fast-Heat-Up) ---
|
|
bool fastHeatUpAktiv;
|
|
|
|
// --- Brew Control Einstellungen
|
|
bool brewByTimeEnabled;
|
|
float brewByTimeTargetSeconds; // Geändert
|
|
bool steamByTimeEnabled;
|
|
float steamByTimeTargetSeconds;
|
|
bool preInfusionEnabled;
|
|
float preInfusionDurationSeconds; // Geändert
|
|
float preInfusionPauseSeconds; // Geändert
|
|
bool brewByWeightEnabled;
|
|
float brewByWeightTargetGrams;
|
|
float brewByWeightOffsetGrams; // Offset zum früheren Stoppen
|
|
|
|
// --- Systemtöne / PIEZO ---
|
|
bool piezoEnabled; // Status der Piezo-Töne
|
|
};
|
|
|
|
struct StandbyTimerEntry {
|
|
uint32_t id = 0;
|
|
uint8_t hour = 0;
|
|
uint8_t minute = 0;
|
|
uint8_t weekdaysMask = 0x7F; // Bit 0-6 = Montag-Sonntag
|
|
uint8_t action = 0; // 0 = Standby aktivieren, 1 = Standby aufheben
|
|
bool enabled = true;
|
|
int32_t lastTriggeredDateKey = -1;
|
|
};
|
|
|
|
struct MomentaryButtonState {
|
|
bool stablePressed = false;
|
|
bool lastRawPressed = false;
|
|
unsigned long lastChangeMs = 0;
|
|
bool lastStablePressed = false;
|
|
unsigned long pressedAt = 0;
|
|
bool longPressHandled = false;
|
|
};
|
|
|
|
/************************************************************************************
|
|
* PID-Regler-Konfiguration & Zeitproportionale Steuerung
|
|
************************************************************************************/
|
|
|
|
// PID Input/Output/Setpoint Variablen
|
|
double SetpointDampf, InputDampf, OutputDampf;
|
|
double SetpointWasser, InputWasser, OutputWasser;
|
|
double OffsetDampf = 0.0, OffsetWasser = 0.0;
|
|
double OffsetCase = 0.0;
|
|
double RawInputDampf = NAN, RawInputWasser = NAN;
|
|
double displayReferenceRawDampf = NAN, displayReferenceRawWasser = NAN;
|
|
bool offsetCompensationEnabled = false;
|
|
const double OFFSET_COMPENSATION_MAX_REFERENCE_RAW_C = 40.0;
|
|
|
|
void resetOffsetCompensationReferences() {
|
|
displayReferenceRawWasser = NAN;
|
|
displayReferenceRawDampf = NAN;
|
|
}
|
|
|
|
void updateOffsetCompensationReferences() {
|
|
if (!isfinite(displayReferenceRawWasser) && isfinite(RawInputWasser) && RawInputWasser <= OFFSET_COMPENSATION_MAX_REFERENCE_RAW_C) {
|
|
displayReferenceRawWasser = RawInputWasser;
|
|
}
|
|
if (!isfinite(displayReferenceRawDampf) && isfinite(RawInputDampf) && RawInputDampf <= OFFSET_COMPENSATION_MAX_REFERENCE_RAW_C) {
|
|
displayReferenceRawDampf = RawInputDampf;
|
|
}
|
|
}
|
|
|
|
double getDisplayTemperature(double correctedTemp, double rawTemp, double offset, double referenceRaw) {
|
|
if (!offsetCompensationEnabled || offset >= 0.0 || !isfinite(correctedTemp) || !isfinite(rawTemp) || !isfinite(referenceRaw)) {
|
|
return correctedTemp;
|
|
}
|
|
|
|
double switchThreshold = referenceRaw + fabs(offset);
|
|
if (rawTemp < switchThreshold) {
|
|
return rawTemp;
|
|
}
|
|
return correctedTemp;
|
|
}
|
|
|
|
double getDisplayedWaterTemperature() {
|
|
return getDisplayTemperature(InputWasser, RawInputWasser, OffsetWasser, displayReferenceRawWasser);
|
|
}
|
|
|
|
double getDisplayedSteamTemperature() {
|
|
return getDisplayTemperature(InputDampf, RawInputDampf, OffsetDampf, displayReferenceRawDampf);
|
|
}
|
|
|
|
// PID-Konstanten (Regelungsparameter)
|
|
double KpDampf = 3.0, KiDampf = 0.1, KdDampf = 0.5;
|
|
double KpWasser = 5.0, KiWasser = 0.2, KdWasser = 1.0;
|
|
|
|
// Zeitproportionale Steuerung
|
|
unsigned long windowSizeWasser; // Fenstergröße für Wasser-SSR (in ms)
|
|
unsigned long windowSizeDampf; // Fenstergröße für Dampf-SSR (in ms)
|
|
unsigned long windowStartTimeWasser; // Startzeit des aktuellen Fensters für Wasser
|
|
unsigned long windowStartTimeDampf; // Startzeit des aktuellen Fensters für Dampf
|
|
|
|
// PID-Regler Instanzen
|
|
// WICHTIG: Die Output-Grenzen werden in setup() auf die Fenstergröße gesetzt!
|
|
PID pidDampf(&InputDampf, &OutputDampf, &SetpointDampf, KpDampf, KiDampf, KdDampf, DIRECT);
|
|
PID pidWasser(&InputWasser, &OutputWasser, &SetpointWasser, KpWasser, KiWasser, KdWasser, DIRECT);
|
|
|
|
// Variablen für Sicherheitsabschaltung
|
|
double maxTempWasser = 110.0; // Standardwert, wie bisher hardcoded
|
|
double maxTempDampf = 180.0; // Standardwert, wie bisher hardcoded
|
|
|
|
// --- NTP Client Konfiguration ---
|
|
WiFiUDP ntpUDP;
|
|
// Zeitverschiebung UTC+1 (Berlin/CET), automatische Sommerzeit (CEST) wird über TZ_INFO gehandhabt
|
|
// Offset 0 hier, da TZ_INFO die Regeln enthält. Update-Intervall 60000ms = 1 Minute.
|
|
NTPClient timeClient(ntpUDP, "pool.ntp.org", 0, 60000);
|
|
bool timeSynced = false;
|
|
|
|
// Zeitzonen-String für Deutschland (CET/CEST)
|
|
const char* TZ_INFO = "CET-1CEST,M3.5.0,M10.5.0/3"; // Germany
|
|
|
|
// --- für Shot-Statistik ---
|
|
struct ShotStats {
|
|
unsigned long totalShotsLogged = 0;
|
|
unsigned long totalDurationMs = 0;
|
|
unsigned long shotsToday = 0;
|
|
unsigned long shotsThisWeek = 0;
|
|
unsigned long shotsThisMonth = 0;
|
|
double averageDurationSec = 0.0;
|
|
time_t firstShotTimestamp = 0;
|
|
time_t lastShotTimestamp = 0;
|
|
bool historyAvailable = false; // Flag, ob Daten gelesen werden konnten
|
|
unsigned long shotsYesterday = 0;
|
|
unsigned long shotsLastWeek = 0; // Kalenderwoche
|
|
unsigned long shotsLastMonth = 0; // Kalendermonat
|
|
double avgShotsPerDay = 0.0; // Durchschnitt seit erstem Log
|
|
};
|
|
|
|
/************************************************************************************
|
|
* Brew Control Variablen & Defaults
|
|
************************************************************************************/
|
|
enum PreInfusionState { PI_INACTIVE, PI_PRE_BREW, PI_PAUSE, PI_MAIN_BREW };
|
|
PreInfusionState currentPreInfusionState = PI_INACTIVE;
|
|
unsigned long preInfusionPhaseStartTime = 0;
|
|
|
|
// Globale Variablen für den aktuellen Zustand
|
|
bool brewByTimeEnabled = false;
|
|
float brewByTimeTargetSeconds = 30.0f;
|
|
bool preInfusionEnabled = false;
|
|
float preInfusionDurationSeconds = 5.0f;
|
|
float preInfusionPauseSeconds = 3.0f;
|
|
bool brewByWeightEnabled = false;
|
|
float brewByWeightTargetGrams = 36.0f;
|
|
float brewByWeightOffsetGrams = 0.0f;
|
|
bool flowGuardEnabled = false;
|
|
float flowGuardMinBrewSeconds = 30.0f;
|
|
uint16_t flowGuardPulsePeriodMs = 1200;
|
|
float flowGuardMinDutyPercent = 35.0f;
|
|
bool steamByTimeEnabled = false;
|
|
float steamByTimeTargetSeconds = 15.0f;
|
|
unsigned long steamCircuitSessionStartTime = 0;
|
|
// Kurz-Flush aus dem Dashboard
|
|
bool flushActive = false;
|
|
unsigned long flushEndTime = 0;
|
|
const uint8_t defaultFlushDurationSeconds = 3;
|
|
uint8_t flushDurationSeconds = defaultFlushDurationSeconds;
|
|
bool steamFlushActive = false;
|
|
unsigned long steamFlushEndTime = 0;
|
|
const uint8_t defaultSteamFlushDurationSeconds = 3;
|
|
uint8_t steamFlushDurationSeconds = defaultSteamFlushDurationSeconds;
|
|
// Helligkeit des UART-Touch-Displays (ESP32-P4) in %, per UART an den P4 gepusht.
|
|
const uint8_t defaultBacklightActivePercent = 100; // aktiv (Display an)
|
|
const uint8_t defaultBacklightStandbyClockPercent = 30; // Standby mit Uhrzeit-Anzeige (abgedunkelt)
|
|
uint8_t backlightActivePercent = defaultBacklightActivePercent;
|
|
uint8_t backlightStandbyClockPercent = defaultBacklightStandbyClockPercent;
|
|
bool lightOn = false;
|
|
|
|
// Die Variable "currentWeightReading" ist jetzt im konditionalen Waagen-Block definiert.
|
|
|
|
// Standard-Werte für Brew-Control
|
|
const bool defaultBrewByTimeEnabled = false;
|
|
const float defaultBrewByTimeTargetSeconds = 30.0f;
|
|
const bool defaultPreInfusionEnabled = false;
|
|
const float defaultPreInfusionDurationSeconds = 5.0f;
|
|
const float defaultPreInfusionPauseSeconds = 3.0f;
|
|
const bool defaultBrewByWeightEnabled = false;
|
|
const float defaultBrewByWeightTargetGrams = 36.0f;
|
|
const float defaultBrewByWeightOffsetGrams = 0.0f; // Standard kein Offset
|
|
const bool defaultFlowGuardEnabled = false;
|
|
const float defaultFlowGuardMinBrewSeconds = 30.0f;
|
|
const uint16_t defaultFlowGuardPulsePeriodMs = 1200;
|
|
const float defaultFlowGuardMinDutyPercent = 35.0f;
|
|
const float FLOW_GUARD_MIN_SECONDS_MIN = 5.0f;
|
|
const float FLOW_GUARD_MIN_SECONDS_MAX = 120.0f;
|
|
const uint16_t FLOW_GUARD_PULSE_PERIOD_MIN_MS = 200;
|
|
const uint16_t FLOW_GUARD_PULSE_PERIOD_MAX_MS = 3000;
|
|
const float FLOW_GUARD_MIN_DUTY_MIN = 5.0f;
|
|
const float FLOW_GUARD_MIN_DUTY_MAX = 95.0f;
|
|
const bool defaultSteamByTimeEnabled = false;
|
|
const float defaultSteamByTimeTargetSeconds = 15.0f;
|
|
|
|
float flowGuardFilteredFlow = NAN;
|
|
float flowGuardLastNetWeight = NAN;
|
|
unsigned long flowGuardLastSampleMs = 0;
|
|
bool flowGuardPumpPulseActive = false;
|
|
|
|
/************************************************************************************
|
|
* Standardwerte und Default-Einstellungen
|
|
************************************************************************************/
|
|
|
|
double defaultSetpointDampf = 165.0;
|
|
double defaultSetpointWasser = 93.0;
|
|
double defaultOffsetDampf = 0.0;
|
|
double defaultOffsetWasser = 0.0;
|
|
double defaultOffsetCase = 0.0;
|
|
double defaultKpDampf = 3.0;
|
|
double defaultKiDampf = 0.1;
|
|
double defaultKdDampf = 0.5;
|
|
double defaultKpWasser = 5.0;
|
|
double defaultKiWasser = 0.2;
|
|
double defaultKdWasser = 1.0;
|
|
int defaultEcoModeMinutes = 0;
|
|
int defaultEcoModeTempWasser = 60;
|
|
int defaultEcoModeTempDampf = 60;
|
|
String defaultInfoHersteller = "";
|
|
String defaultInfoModell = "";
|
|
String defaultInfoZusatz = "";
|
|
int defaultDampfVerzoegerung = 0;
|
|
int dampfVerzoegerung = 0;
|
|
int defaultMaintenanceInterval = 0;
|
|
String defaultHostname = "Dual-PID";
|
|
unsigned long defaultWindowSizeWasser = 2000;
|
|
unsigned long defaultWindowSizeDampf = 2000;
|
|
const bool defaultSteamDelayOverrideBySwitchEnabled = false;
|
|
bool steamDelayOverrideBySwitchEnabled = false;
|
|
const bool defaultEcoInfoOnDisplay = false;
|
|
const bool defaultEcoLightAutoOffEnabled = false;
|
|
const bool defaultSteamHeatDisabledOnStartupWake = false;
|
|
const bool defaultStandbyTimeOnDisplay = false;
|
|
const uint8_t DASHBOARD_TEMP_DISPLAY_CLASSIC = 0;
|
|
const uint8_t DASHBOARD_TEMP_DISPLAY_BARS = 1;
|
|
const uint8_t DASHBOARD_TEMP_DISPLAY_GAUGES = 2;
|
|
const uint8_t DASHBOARD_TEMP_DISPLAY_RAILS = 3;
|
|
const uint8_t defaultDashboardTempDisplayMode = DASHBOARD_TEMP_DISPLAY_CLASSIC;
|
|
const uint8_t X_SWITCH_ACTION_NONE = 0;
|
|
const uint8_t X_SWITCH_ACTION_MAINTENANCE = 1;
|
|
const uint8_t X_SWITCH_ACTION_FAST_HEAT_UP = 2;
|
|
const uint8_t X_SWITCH_ACTION_STEAM_DELAY_OVERRIDE = 3;
|
|
const uint8_t X_SWITCH_ACTION_SCALE_MODE = 4;
|
|
const uint8_t X_SWITCH_ACTION_DISABLE_STEAM_HEAT = 5;
|
|
const uint8_t X_SWITCH_ACTION_TARE_SCALE = 6;
|
|
const uint8_t X_SWITCH_ACTION_MAX = X_SWITCH_ACTION_TARE_SCALE;
|
|
const uint8_t defaultXSwitchAction = X_SWITCH_ACTION_NONE;
|
|
const uint8_t defaultXSwitchLongAction = X_SWITCH_ACTION_NONE;
|
|
const uint8_t CASE_SENSOR_TYPE_HOUSING = 0;
|
|
const uint8_t CASE_SENSOR_TYPE_CUPTRAY = 1;
|
|
const bool defaultCaseSensorEnabled = false;
|
|
const uint8_t defaultCaseSensorType = CASE_SENSOR_TYPE_HOUSING;
|
|
const bool defaultCaseTempOnDashboard = false;
|
|
const bool defaultCaseTempOnDisplay = false;
|
|
const bool defaultScaleEnabled = true;
|
|
const uint8_t defaultScaleType = DEFAULT_SCALE_TYPE;
|
|
const bool defaultHx711DisplaySmoothingEnabled = true;
|
|
const float HX711_CALIBRATION_FACTOR = 673.84998f; // TODO: Kalibrierfaktor anpassen
|
|
float hx711CalibrationFactor = HX711_CALIBRATION_FACTOR;
|
|
bool hx711DisplaySmoothingEnabled = defaultHx711DisplaySmoothingEnabled;
|
|
|
|
// MagicValue zum Erkennen bereits gespeicherter EEPROM-Daten
|
|
const char storageMagicValue[5] = "MGVE";
|
|
|
|
/************************************************************************************
|
|
* EEPROM Adressen für das Speichern/Laden von Werten
|
|
* KORRIGIERT: Adressen ab Hostname / Windowsize angepasst (Overlap entfernt)
|
|
* Kommentare für Endadressen überprüft/korrigiert
|
|
************************************************************************************/
|
|
const int EEPROM_SIZE = 1024; // Gesamtgröße des verwendeten EEPROM-Speichers
|
|
|
|
// --- Allgemeine Einstellungen ---
|
|
const int EEPROM_ADDR_MAGICVALUE = 0; // 5 Bytes (char[5]) -> Ende 4
|
|
const int EEPROM_ADDR_SETPOINT_DAMPF = 5; // 8 Bytes (double) -> Ende 12
|
|
const int EEPROM_ADDR_SETPOINT_WASSER = 13; // 8 Bytes -> Ende 20
|
|
const int EEPROM_ADDR_OFFSET_DAMPF = 21; // 8 Bytes -> Ende 28
|
|
const int EEPROM_ADDR_OFFSET_WASSER = 29; // 8 Bytes -> Ende 36
|
|
const int EEPROM_ADDR_KP_DAMPF = 37; // 8 Bytes -> Ende 44
|
|
const int EEPROM_ADDR_KI_DAMPF = 45; // 8 Bytes -> Ende 52
|
|
const int EEPROM_ADDR_KD_DAMPF = 53; // 8 Bytes -> Ende 60
|
|
const int EEPROM_ADDR_KP_WASSER = 61; // 8 Bytes -> Ende 68
|
|
const int EEPROM_ADDR_KI_WASSER = 69; // 8 Bytes -> Ende 76
|
|
const int EEPROM_ADDR_KD_WASSER = 77; // 8 Bytes -> Ende 84
|
|
const int EEPROM_ADDR_ECOMODE_MINUTES = 85; // 4 Bytes (int) -> Ende 88
|
|
const int EEPROM_ADDR_ECOMODE_TEMP_WASSER = 89; // 4 Bytes -> Ende 92
|
|
const int EEPROM_ADDR_ECOMODE_TEMP_DAMPF = 93; // 4 Bytes -> Ende 96
|
|
const int EEPROM_ADDR_INFO_HERSTELLER = 97; // 50 Bytes (char[50]) -> Ende 146
|
|
const int EEPROM_ADDR_INFO_MODELL = 147; // 50 Bytes -> Ende 196
|
|
const int EEPROM_ADDR_INFO_ZUSATZ = 197; // 50 Bytes -> Ende 246
|
|
const int EEPROM_ADDR_FASTHEATUP_DATA = 247; // 1 Byte (bool) -> Ende 247
|
|
const int EEPROM_ADDR_RUNTIME = 248; // 4 Bytes (unsigned long) -> Ende 251
|
|
const int EEPROM_ADDR_SHOTCOUNTER = 252; // 4 Bytes (unsigned long) -> Ende 255
|
|
const int EEPROM_ADDR_DYNAMIC_ECO_MODE = 256; // 1 Byte (bool) -> Ende 256
|
|
// --- AutoTune Parameter Wasser --- (Start bei 357 -> Lücke vorhanden)
|
|
const int EEPROM_ADDR_TUNING_STEP_WASSER = 357; // 8 Bytes (double) -> Ende 364
|
|
const int EEPROM_ADDR_TUNING_NOISE_WASSER = 365; // 8 Bytes (double) -> Ende 372
|
|
const int EEPROM_ADDR_TUNING_STARTVALUE_WASSER = 373; // 8 Bytes (double) -> Ende 380
|
|
const int EEPROM_ADDR_TUNING_LOOKBACK_WASSER = 381; // 4 Bytes (unsigned int) -> Ende 384
|
|
// --- WiFi Konfiguration (Feldweise) ---
|
|
const int EEPROM_ADDR_WIFI_CONFIG_MAGIC = 385; // 5 Bytes (char[5]) -> Ende 389
|
|
const int EEPROM_ADDR_WIFI_SSID = 390; // 32 Bytes -> Ende 421
|
|
const int EEPROM_ADDR_WIFI_PASSWORD = 422; // 64 Bytes -> Ende 485
|
|
const int EEPROM_ADDR_WIFI_USE_STATIC = 486; // 1 Byte -> Ende 486
|
|
const int EEPROM_ADDR_WIFI_STATIC_IP = 487; // 4 Bytes -> Ende 490
|
|
const int EEPROM_ADDR_WIFI_GATEWAY = 491; // 4 Bytes -> Ende 494
|
|
const int EEPROM_ADDR_WIFI_SUBNET = 495; // 4 Bytes -> Ende 498
|
|
const int EEPROM_ADDR_WIFI_DNS = 499; // 4 Bytes -> Ende 502
|
|
// --- Sonstige Einstellungen (Lücke vorhanden) ---
|
|
const int EEPROM_ADDR_STEAM_DELAY = 527; // 4 Bytes (int) -> Ende 530
|
|
// --- AutoTune Parameter Dampf ---
|
|
const int EEPROM_ADDR_TUNING_STEP_DAMPF = 531; // 8 Bytes (double) -> Ende 538
|
|
const int EEPROM_ADDR_TUNING_NOISE_DAMPF = 539; // 8 Bytes (double) -> Ende 546
|
|
const int EEPROM_ADDR_TUNING_STARTVALUE_DAMPF = 547; // 8 Bytes (double) -> Ende 554
|
|
const int EEPROM_ADDR_TUNING_LOOKBACK_DAMPF = 555; // 4 Bytes (unsigned int) -> Ende 558
|
|
// --- Adressen für Übertemperatursicherung ---
|
|
const int EEPROM_ADDR_MAX_TEMP_WASSER = 559; // 8 Bytes (double) -> Ende 566
|
|
const int EEPROM_ADDR_MAX_TEMP_DAMPF = 567; // 8 Bytes (double) -> Ende 574
|
|
// Zusätzliche EEPROM-Adressen
|
|
const int EEPROM_ADDR_MAINTENANCE_INTERVAL = 575; // 4 Bytes (int) -> Ende 578
|
|
const int EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER = 579; // 4 Bytes (int) -> Ende 582 // Kommentar korrigiert
|
|
const int EEPROM_ADDR_HOSTNAME = 583; // 33 Bytes (char[33]) -> Ende 615
|
|
|
|
// --- KORRIGIERTE / VERSCHOBENE ADRESSEN (Start nach Hostname) ---
|
|
const int EEPROM_ADDR_WINDOWSIZE_WASSER = 616; // WAR 614 -> 4 Bytes (unsigned long) -> Ende 619
|
|
const int EEPROM_ADDR_WINDOWSIZE_DAMPF = 620; // WAR 618 -> 4 Bytes (unsigned long) -> Ende 623
|
|
const int EEPROM_ADDR_BOOST_WASSER_ACTIVE = 624; // WAR 612 -> 1 Byte (bool) -> Ende 624
|
|
const int EEPROM_ADDR_BOOST_DAMPF_ACTIVE = 625; // WAR 613 -> 1 Byte (bool) -> Ende 625
|
|
const int EEPROM_ADDR_PREVENTHEAT_WASSER = 626; // WAR 622 -> 1 Byte (bool) -> Ende 626
|
|
const int EEPROM_ADDR_PREVENTHEAT_DAMPF = 627; // WAR 623 -> 1 Byte (bool) -> Ende 627
|
|
// --- BREW CONTROL (Start nach Prevent Heat) ---
|
|
const int EEPROM_ADDR_BREWBYTIME_ENABLED = 628; // WAR 624 -> 1 Byte (bool) -> Ende 628
|
|
// ACHTUNG: brewByTimeTargetSeconds ist float (4 Bytes), nicht unsigned long
|
|
const int EEPROM_ADDR_BREWBYTIME_SECONDS = 629; // WAR 625 -> 4 Bytes (float) -> Ende 632
|
|
const int EEPROM_ADDR_PREINF_ENABLED = 633; // WAR 629 -> 1 Byte (bool) -> Ende 633
|
|
// ACHTUNG: preInfusionDurationSeconds/preInfusionPauseSeconds sind float (4 Bytes)
|
|
const int EEPROM_ADDR_PREINF_DUR_SEC = 634; // WAR 630 -> 4 Bytes (float) -> Ende 637
|
|
const int EEPROM_ADDR_PREINF_PAUSE_SEC = 638; // WAR 634 -> 4 Bytes (float) -> Ende 641
|
|
const int EEPROM_ADDR_BREWBYWEIGHT_ENABLED = 642; // WAR 638 -> bool (1 byte) -> Ende 642
|
|
const int EEPROM_ADDR_BREWBYWEIGHT_TARGET = 643; // WAR 639 -> float (4 bytes) -> Ende 646
|
|
const int EEPROM_ADDR_BREWBYWEIGHT_OFFSET = 647; // WAR 643 -> float (4 bytes) -> Ende 650
|
|
// --- PIEZO (Start nach Brew By Weight) ---
|
|
const int EEPROM_ADDR_PIEZO_ENABLED = 651; // WAR 647 -> 1 Byte (bool) -> Ende 651
|
|
// --- Dampfverzögerung über ECO-Einstellungen temporär überspringen
|
|
const int EEPROM_ADDR_STEAM_DELAY_OVERRIDE_SWITCH = 652; // 1 Byte (bool) -> Ende 652
|
|
const int EEPROM_ADDR_ECO_INFO_ON_DISPLAY = 653; // 1 Byte (bool) -> Ende 653
|
|
const int EEPROM_ADDR_CASE_SENSOR_ENABLED = 654; // 1 Byte (bool) -> Ende 654
|
|
const int EEPROM_ADDR_CASE_SENSOR_TYPE = 655; // 1 Byte (uint8_t) -> Ende 655
|
|
const int EEPROM_ADDR_SCALE_ENABLED = 656; // 1 Byte (bool) -> Ende 656
|
|
const int EEPROM_ADDR_SCALE_TYPE = 657; // 1 Byte (uint8_t) -> Ende 657
|
|
const int EEPROM_ADDR_HX711_CAL_FACTOR = 658; // 4 Bytes (float) -> Ende 661
|
|
const int EEPROM_ADDR_LIGHT_ON = 662; // 1 Byte (bool) -> Ende 662
|
|
const int EEPROM_ADDR_ECO_LIGHT_AUTO_OFF = 663; // 1 Byte (bool) -> Ende 663
|
|
const int EEPROM_ADDR_STANDBY_TIME_ON_DISPLAY = 664; // 1 Byte (bool) -> Ende 664
|
|
const int EEPROM_ADDR_X_SWITCH_ACTION = 665; // 1 Byte (uint8_t) -> Ende 665
|
|
const int EEPROM_ADDR_DASHBOARD_TEMP_DISPLAY_MODE = 666; // 1 Byte (uint8_t) -> Ende 666
|
|
const int EEPROM_ADDR_CASE_TEMP_DASHBOARD = 667; // 1 Byte (bool) -> Ende 667
|
|
const int EEPROM_ADDR_STEAM_HEAT_ON_DRAW = 668; // 1 Byte (bool) -> Ende 668
|
|
const int EEPROM_ADDR_FLUSH_DURATION_SECONDS = 669; // 1 Byte (uint8_t) -> Ende 669
|
|
const int EEPROM_ADDR_STEAM_FLUSH_DURATION_SECONDS = 670; // 1 Byte (uint8_t) -> Ende 670
|
|
const int EEPROM_ADDR_X_SWITCH_LONG_ACTION = 671; // 1 Byte (uint8_t) -> Ende 671
|
|
const int EEPROM_ADDR_STEAMBYTIME_ENABLED = 672; // 1 Byte (bool) -> Ende 672
|
|
const int EEPROM_ADDR_STEAMBYTIME_SECONDS = 673; // 4 Bytes (float) -> Ende 676
|
|
const int EEPROM_ADDR_CASE_TEMP_ON_DISPLAY = 677; // 1 Byte (bool) -> Ende 677
|
|
const int EEPROM_ADDR_STEAM_HEAT_DISABLED_ON_STARTUP_WAKE = 678; // 1 Byte (bool) -> Ende 678
|
|
const int EEPROM_ADDR_OFFSET_COMPENSATION_ENABLED = 679; // 1 Byte (bool) -> Ende 679
|
|
const int EEPROM_ADDR_OFFSET_CASE = 680; // 8 Bytes (double) -> Ende 687
|
|
const int EEPROM_ADDR_FLOWGUARD_ENABLED = 688; // 1 Byte (bool) -> Ende 688
|
|
const int EEPROM_ADDR_FLOWGUARD_MIN_SECONDS = 689; // 4 Bytes (float) -> Ende 692
|
|
const int EEPROM_ADDR_FLOWGUARD_PULSE_PERIOD_MS = 693; // 2 Bytes (uint16_t) -> Ende 694
|
|
const int EEPROM_ADDR_FLOWGUARD_MIN_DUTY = 695; // 4 Bytes (float) -> Ende 698
|
|
const int EEPROM_ADDR_BUTTON_LONG_PRESS_MS = 699; // 2 Bytes (uint16_t) -> Ende 700
|
|
const int EEPROM_ADDR_FULLY_CONFIG_MAGIC = 701; // 5 Bytes (char[5]) -> Ende 705
|
|
const int EEPROM_ADDR_FULLY_ENABLED = 706; // 1 Byte (uint8_t) -> Ende 706
|
|
const int EEPROM_ADDR_FULLY_HOST = 707; // 64 Bytes -> Ende 770
|
|
const int EEPROM_ADDR_FULLY_PASSWORD = 771; // 64 Bytes -> Ende 834
|
|
const int EEPROM_ADDR_FULLY_PORT = 835; // 2 Bytes (uint16_t) -> Ende 836
|
|
const int EEPROM_ADDR_FULLY_TIMEOUT_MS = 837; // 2 Bytes (uint16_t) -> Ende 838
|
|
const int EEPROM_ADDR_FULLY_STANDBY_PATH = 839; // 32 Bytes -> Ende 870
|
|
const int EEPROM_ADDR_FULLY_ACTIVE_PATH = 871; // 32 Bytes -> Ende 902
|
|
const int EEPROM_ADDR_HX711_DISPLAY_SMOOTHING_ENABLED = 903; // 1 Byte (bool) -> Ende 903
|
|
const int EEPROM_ADDR_HEATUP_MINUTES = 904; // 4 Bytes (int) -> Ende 907
|
|
const int EEPROM_ADDR_BACKLIGHT_ACTIVE_PERCENT = 908; // 1 Byte (uint8_t) -> Ende 908
|
|
const int EEPROM_ADDR_BACKLIGHT_STANDBY_CLOCK_PERCENT = 909; // 1 Byte (uint8_t) -> Ende 909
|
|
// Naechste freie Adresse: 910 (Innerhalb EEPROM_SIZE=1024)
|
|
|
|
/************************************************************************************
|
|
* Eco-Mode Variablen
|
|
************************************************************************************/
|
|
|
|
unsigned long lastShotTime = 0; // Wann wurde zuletzt ein Shot beendet
|
|
int ecoModeMinutes = 0; // Zeit (Minuten) bis Eco-Modus
|
|
int heatUpMinutes = 0; // Aufheizzeit (Minuten) bis "durchgewaermt"; 0 = aus (Anzeige-Countdown ab Start)
|
|
bool ecoModeAktiv = 0; // Flag, ob Eco läuft
|
|
int ecoModeTempWasser = 50; // Zieltemp Wasser im Eco
|
|
int ecoModeTempDampf = 50; // Zieltemp Dampf im Eco
|
|
bool dynamicEcoActive = false;
|
|
bool ecoInfoOnDisplay = false;
|
|
bool ecoLightAutoOffEnabled = false;
|
|
bool steamHeatDisabledOnStartupWake = defaultSteamHeatDisabledOnStartupWake;
|
|
bool standbyTimeOnDisplay = false;
|
|
bool standbyModeActive = false;
|
|
bool standbyOverriddenByWeb = false;
|
|
bool standbySwitchStableOn = false;
|
|
bool standbySwitchHeldHintActive = false;
|
|
MomentaryButtonState standbyButtonState;
|
|
const char* standbyTimersFilePath = "/standby_timers.dat";
|
|
const uint32_t STANDBY_TIMERS_FILE_MAGIC = 0x54494D52UL; // TIMR
|
|
const uint16_t STANDBY_TIMERS_FILE_VERSION = 1;
|
|
std::vector<StandbyTimerEntry> standbyTimers;
|
|
uint32_t nextStandbyTimerId = 1;
|
|
bool ecoSwitchActive = false;
|
|
bool xSwitchActive = false;
|
|
MomentaryButtonState ecoButtonState;
|
|
MomentaryButtonState shotButtonState;
|
|
MomentaryButtonState steamButtonState;
|
|
MomentaryButtonState xButtonState;
|
|
uint8_t xSwitchAction = defaultXSwitchAction;
|
|
uint8_t xSwitchLongAction = defaultXSwitchLongAction;
|
|
uint16_t buttonLongPressMs = (uint16_t)DEFAULT_BUTTON_LONG_PRESS_MS;
|
|
uint8_t dashboardTempDisplayMode = defaultDashboardTempDisplayMode;
|
|
unsigned long ecoModeActivatedTime = 0;
|
|
bool ecoForcedActive = false;
|
|
|
|
/************************************************************************************
|
|
* Wartungsmodus Variable
|
|
************************************************************************************/
|
|
bool wartungsModusAktiv = false; // Ist der Reinigungs-/Wartungsmodus aktiv?
|
|
int wartungsModusTemp = 35; // Zieltemp Wasser und Dampf im Wartungsmodus
|
|
const uint8_t defaultCleaningAssistantBrewSeconds = 5;
|
|
const uint8_t defaultCleaningAssistantCycles = 15;
|
|
const uint8_t defaultCleaningAssistantPauseSeconds = 10;
|
|
const double cleaningAssistantWaterSetpoint = 93.0;
|
|
const double cleaningAssistantWaterReadyTolerance = 1.0;
|
|
uint8_t cleaningAssistantBrewSeconds = defaultCleaningAssistantBrewSeconds;
|
|
uint8_t cleaningAssistantCycles = defaultCleaningAssistantCycles;
|
|
uint8_t cleaningAssistantPauseSeconds = defaultCleaningAssistantPauseSeconds;
|
|
bool cleaningAssistantActive = false;
|
|
bool cleaningAssistantWaitingForTemperature = false;
|
|
bool cleaningAssistantInBrewPhase = false;
|
|
uint8_t cleaningAssistantCurrentCycle = 0;
|
|
unsigned long cleaningAssistantPhaseStartTime = 0;
|
|
String cleaningAssistantStatusMessage = "";
|
|
double cleaningAssistantPreviousSetpointWasser = 0.0;
|
|
double cleaningAssistantPreviousSetpointDampf = 0.0;
|
|
bool cleaningAssistantPreviousSteamHeatDisabled = false;
|
|
|
|
/************************************************************************************
|
|
* Fast-Heat-Up Variablen
|
|
************************************************************************************/
|
|
|
|
bool fastHeatUpAktiv = 0; // Ist Fast-Heat-Up aktiviert?
|
|
bool fastHeatUpHeating = 0; // Wird gerade hochgeheizt (Wasser >130)?
|
|
int fastHeatUpSetpoint = 130;
|
|
|
|
/************************************************************************************
|
|
* Sicherheits- und Fehlerstatus
|
|
************************************************************************************/
|
|
|
|
bool wasserSensorError = false; // Flag für Fehler am Wassertemperatursensor
|
|
bool dampfSensorError = false; // Flag für Fehler am Dampftemperatursensor
|
|
bool wasserSafetyShutdown = false;
|
|
bool dampfSafetyShutdown = false;
|
|
bool caseSensorEnabled = defaultCaseSensorEnabled;
|
|
uint8_t caseSensorType = defaultCaseSensorType;
|
|
double InputCase = NAN;
|
|
bool caseSensorError = false;
|
|
bool caseTempOnDashboard = defaultCaseTempOnDashboard;
|
|
bool caseTempOnDisplay = defaultCaseTempOnDisplay;
|
|
|
|
/************************************************************************************
|
|
* Auto-Tune-Einstellungen
|
|
************************************************************************************/
|
|
|
|
// Parameter für Wasser
|
|
double tuningStepWasser;
|
|
double tuningNoiseWasser;
|
|
double tuningStartValueWasser;
|
|
unsigned int tuningLookBackWasser;
|
|
const double defaultTuningStepWasser = 500.0;
|
|
const double defaultTuningNoiseWasser = 1.0;
|
|
const double defaultTuningStartValueWasser = 1000.0;
|
|
const unsigned int defaultTuningLookBackWasser = 60;
|
|
|
|
// Parameter für Dampf
|
|
double tuningStepDampf;
|
|
double tuningNoiseDampf;
|
|
double tuningStartValueDampf;
|
|
unsigned int tuningLookBackDampf;
|
|
const double defaultTuningStepDampf = 300.0;
|
|
const double defaultTuningNoiseDampf = 1.0;
|
|
const double defaultTuningStartValueDampf = 1000.0;
|
|
const unsigned int defaultTuningLookBackDampf = 30;
|
|
// Globale Variable für temporäre Dampfverzögerungs-Überbrückung
|
|
bool steamDelayOverridden = false;
|
|
|
|
PID_ATune* autoTuneWasser;
|
|
PID_ATune* autoTuneDampf;
|
|
bool autoTuneWasserActive = false;
|
|
bool autoTuneDampfActive = false;
|
|
|
|
// --- AutoTune-Ergebnis/-Status (fuer Web- und P4-Anzeige) ---
|
|
// Erlaubt nachzuvollziehen, wie ein Tuning-Lauf endete (Erfolg/Abbruch + Grund),
|
|
// statt nur "laeuft / laeuft nicht" zu sehen.
|
|
enum AutoTuneStatus : uint8_t {
|
|
AT_IDLE = 0, // noch nie gelaufen / nach Neustart
|
|
AT_RUNNING = 1, // laeuft gerade
|
|
AT_SUCCESS = 2, // erfolgreich abgeschlossen, neue Werte uebernommen
|
|
AT_FAILSAFE_PEAKS = 3, // beendet via Failsafe (>9 Peaks, Schwingung nie stabil) - Werte ggf. ungenau
|
|
AT_ABORT_DEGENERATE = 4, // beendet, aber keine echte Schwingung -> unbrauchbare Werte, NICHT uebernommen
|
|
AT_ABORT_SENSOR = 5, // Abbruch: Temperatursensorfehler
|
|
AT_ABORT_SAFETY = 6, // Abbruch: Sicherheitsabschaltung (Uebertemperatur)
|
|
AT_ABORT_MANUAL = 7 // Abbruch: manuell gestoppt
|
|
};
|
|
AutoTuneStatus autoTuneWasserStatus = AT_IDLE;
|
|
AutoTuneStatus autoTuneDampfStatus = AT_IDLE;
|
|
// Ergebniswerte des letzten erfolgreichen Laufs (nur zur Anzeige)
|
|
float autoTuneWasserResultKp = 0, autoTuneWasserResultKi = 0, autoTuneWasserResultKd = 0;
|
|
float autoTuneDampfResultKp = 0, autoTuneDampfResultKi = 0, autoTuneDampfResultKd = 0;
|
|
|
|
// Boost-Feature
|
|
bool boostWasserActive = false;
|
|
const bool defaultBoostWasserActive = false;
|
|
bool boostDampfActive = false;
|
|
const bool defaultBoostDampfActive = false;
|
|
|
|
// Heizen oberhalb Setpoint verhindern
|
|
bool preventHeatAboveSetpointWasser = false; // Standardmäßig AUS
|
|
const bool defaultPreventHeatAboveSetpointWasser = false;
|
|
bool preventHeatAboveSetpointDampf = false; // Standardmäßig AUS
|
|
const bool defaultPreventHeatAboveSetpointDampf = false;
|
|
const bool defaultSteamHeatOnDraw = false;
|
|
bool steamHeatOnDraw = defaultSteamHeatOnDraw;
|
|
bool steamHeatDisabledByUser = false;
|
|
|
|
/************************************************************************************
|
|
* Shot-Timer Variablen
|
|
************************************************************************************/
|
|
|
|
unsigned long shotStartTime = 0;
|
|
unsigned long shotEndTime = 0;
|
|
unsigned long lastShotStartTime = 0; // Startzeit des letzten Shots
|
|
bool shotActive = false;
|
|
unsigned long lastShotDurationMillis = 0; // Speichert die Dauer des letzten Shots in ms
|
|
bool shotSoftwareActive = false;
|
|
bool steamCircuitSoftwareActive = false;
|
|
bool steamCircuitActive = false;
|
|
unsigned long steamCircuitStartTime = 0; // Startzeitpunkt des Dampfbezugs (fuer steamElapsedMs ans Display)
|
|
|
|
static void resetScaleDisplayFilter(float rawWeight) {
|
|
if (!isfinite(rawWeight) || fabs(rawWeight) < SCALE_DISPLAY_ZERO_SNAP_GRAMS) {
|
|
rawWeight = 0.0f;
|
|
}
|
|
scaleDisplayWeightReading = rawWeight;
|
|
scaleDisplayWeightInitialized = true;
|
|
}
|
|
|
|
static float updateScaleDisplayFilter(float rawWeight, bool force) {
|
|
if (!isfinite(rawWeight)) {
|
|
return scaleDisplayWeightReading;
|
|
}
|
|
|
|
if (force || !scaleDisplayWeightInitialized) {
|
|
resetScaleDisplayFilter(rawWeight);
|
|
return scaleDisplayWeightReading;
|
|
}
|
|
|
|
if (fabs(rawWeight) < SCALE_DISPLAY_ZERO_SNAP_GRAMS) {
|
|
rawWeight = 0.0f;
|
|
}
|
|
|
|
float currentDisplayWeight = scaleDisplayWeightReading;
|
|
float delta = rawWeight - currentDisplayWeight;
|
|
float absDelta = fabs(delta);
|
|
float alpha = SCALE_DISPLAY_SLOW_ALPHA;
|
|
if (absDelta >= SCALE_DISPLAY_FAST_DELTA_GRAMS) {
|
|
alpha = SCALE_DISPLAY_FAST_ALPHA;
|
|
} else if (absDelta >= SCALE_DISPLAY_SMALL_DELTA_GRAMS) {
|
|
alpha = SCALE_DISPLAY_MID_ALPHA;
|
|
}
|
|
|
|
float filteredWeight = currentDisplayWeight + (delta * alpha);
|
|
if (fabs(filteredWeight) < SCALE_DISPLAY_ZERO_SNAP_GRAMS) {
|
|
filteredWeight = 0.0f;
|
|
}
|
|
|
|
if (fabs(filteredWeight - currentDisplayWeight) >= SCALE_DISPLAY_DEADBAND_GRAMS || absDelta >= SCALE_DISPLAY_FAST_DELTA_GRAMS) {
|
|
scaleDisplayWeightReading = filteredWeight;
|
|
}
|
|
|
|
return scaleDisplayWeightReading;
|
|
}
|
|
|
|
static float medianOfThree(float a, float b, float c) {
|
|
if ((a <= b && b <= c) || (c <= b && b <= a)) {
|
|
return b;
|
|
}
|
|
if ((b <= a && a <= c) || (c <= a && a <= b)) {
|
|
return a;
|
|
}
|
|
return c;
|
|
}
|
|
|
|
static void resetScaleReadingFilters(float rawWeight) {
|
|
if (!isfinite(rawWeight) || fabs(rawWeight) < SCALE_DISPLAY_ZERO_SNAP_GRAMS) {
|
|
rawWeight = 0.0f;
|
|
}
|
|
|
|
currentWeightReading = rawWeight;
|
|
scaleSanitizerSampleCount = 0;
|
|
scaleSanitizerSampleIndex = 0;
|
|
scaleSanitizerSuspectCount = 0;
|
|
scaleSanitizerInitialized = true;
|
|
resetScaleDisplayFilter(rawWeight);
|
|
}
|
|
|
|
static float acceptScaleReading(float rawWeight, bool forceSanitizer, bool forceDisplay) {
|
|
if (!isfinite(rawWeight)) {
|
|
return currentWeightReading;
|
|
}
|
|
|
|
if (forceSanitizer || !scaleSanitizerInitialized) {
|
|
resetScaleReadingFilters(rawWeight);
|
|
if (forceDisplay) {
|
|
resetScaleDisplayFilter(currentWeightReading);
|
|
}
|
|
return currentWeightReading;
|
|
}
|
|
|
|
scaleSanitizerSamples[scaleSanitizerSampleIndex] = rawWeight;
|
|
scaleSanitizerSampleIndex = (scaleSanitizerSampleIndex + 1) % 3;
|
|
if (scaleSanitizerSampleCount < 3) {
|
|
scaleSanitizerSampleCount++;
|
|
}
|
|
|
|
float candidate = rawWeight;
|
|
if (scaleSanitizerSampleCount >= 3) {
|
|
candidate = medianOfThree(scaleSanitizerSamples[0], scaleSanitizerSamples[1], scaleSanitizerSamples[2]);
|
|
}
|
|
|
|
if (!shotActive && fabs(candidate) < SCALE_DISPLAY_ZERO_SNAP_GRAMS) {
|
|
candidate = 0.0f;
|
|
}
|
|
|
|
float spikeLimit = shotActive ? SCALE_SANITIZER_SHOT_SPIKE_GRAMS : SCALE_SANITIZER_IDLE_SPIKE_GRAMS;
|
|
if (fabs(candidate - currentWeightReading) > spikeLimit) {
|
|
if (scaleSanitizerSuspectCount < 255) {
|
|
scaleSanitizerSuspectCount++;
|
|
}
|
|
if (scaleSanitizerSuspectCount < SCALE_SANITIZER_ACCEPT_AFTER_SUSPECTS) {
|
|
updateScaleDisplayFilter(currentWeightReading, forceDisplay);
|
|
return currentWeightReading;
|
|
}
|
|
} else {
|
|
scaleSanitizerSuspectCount = 0;
|
|
}
|
|
|
|
currentWeightReading = candidate;
|
|
scaleSanitizerSuspectCount = 0;
|
|
updateScaleDisplayFilter(currentWeightReading, forceDisplay);
|
|
return currentWeightReading;
|
|
}
|
|
|
|
static bool shouldBypassHx711ScaleModeDisplaySmoothing() {
|
|
return (scaleType == SCALE_HX711 && scaleModeActive && !shotActive && !hx711DisplaySmoothingEnabled);
|
|
}
|
|
|
|
static float getWeightReadingForUi() {
|
|
if (shouldBypassHx711ScaleModeDisplaySmoothing()) {
|
|
return currentWeightReading;
|
|
}
|
|
|
|
if (scaleModeActive && !shotActive) {
|
|
if (!scaleDisplayWeightInitialized) {
|
|
resetScaleDisplayFilter(currentWeightReading);
|
|
}
|
|
return scaleDisplayWeightReading;
|
|
}
|
|
return currentWeightReading;
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Shot-Zähler und Betriebszeit
|
|
************************************************************************************/
|
|
|
|
unsigned long shotCounter = 0; // Shots > 20 Sekunden
|
|
unsigned long totalRuntime = 0; // Gesamt-Betriebszeit in Sekunden
|
|
unsigned long lastPersistedRuntime = 0; // Zuletzt in EEPROM gespeicherte Betriebszeit
|
|
unsigned long lastRuntimeSave = 0; // Wann zuletzt gespeichert
|
|
const unsigned long initialRuntimeSaveDelay = 10 * 60 * 1000UL; // 10 Minuten
|
|
const unsigned long subsequentRuntimeSaveInterval = 5 * 60 * 1000UL; // 5 Minuten
|
|
bool initialRuntimeSaveDone = false;
|
|
// VARIABLEN ZUM VERZÖGERTEN SPEICHERN VON SHOTS, NACH DESSEN BEZUG
|
|
bool shotNeedsToBeSaved = false;
|
|
unsigned long savedShotDuration = 0;
|
|
// --- Zusätzliche Variablen für das Speichern des Gewichts ---
|
|
bool savedShotWasByWeight = false; // War der zu speichernde Shot ein "By-Weight"-Shot?
|
|
float savedShotFinalWeight = 0.0f; // Das finale Gewicht des zu speichernden Shots
|
|
|
|
/************************************************************************************
|
|
* Wartungserinnerung Variablen
|
|
************************************************************************************/
|
|
int maintenanceInterval = 0; // Intervall in Shots (0 = deaktiviert)
|
|
unsigned long maintenanceIntervalCounter = 0; // Zähler für Wartungsintervall
|
|
bool displayMaintenanceMessage = false; // Flag, ob Meldung aktiv angezeigt wird
|
|
unsigned long maintenanceMessageStartTime = 0; // Startzeit der Meldungsanzeige
|
|
|
|
/************************************************************************************
|
|
* Zustandsmaschine für die Anzeige nach dem Shot
|
|
************************************************************************************/
|
|
#define POST_SHOT_IDLE 0 // Standardzustand, nichts zu tun
|
|
#define POST_SHOT_SHOW_DURATION 1 // Zustand: Zeige Bezugsdauer an
|
|
#define POST_SHOT_SHOW_MAINTENANCE 2 // Zustand: Zeige Wartungsmeldung an
|
|
#define POST_SHOT_SHOW_WEIGHT_RESULT 3 // Zustand: Zeige Gewicht und Dauer an (für By-Weight)
|
|
|
|
uint8_t postShotDisplayState = POST_SHOT_IDLE; // Unsere neue Variable, die den aktuellen Zustand speichert
|
|
float lastShotFinalNetWeight = 0.0f; // Globale Variable für das finale Nettogewicht
|
|
bool lastShotStoppedByWeight = false; // war der letzte Bezug gewichtsbasiert? (fuer P4-Endgewicht inkl. Offset)
|
|
|
|
/************************************************************************************
|
|
* Systemtöne / Piezo
|
|
************************************************************************************/
|
|
bool piezoEnabled = true; // Standardmäßig aktiviert
|
|
const bool defaultPiezoEnabled = true;
|
|
|
|
/************************************************************************************
|
|
* Verzögerungen für Anzeigen
|
|
************************************************************************************/
|
|
|
|
int delayInit1 = 2000; // Display-Bild am Anfang
|
|
int delayInit2 = 3000; // Warte für Anzeige
|
|
unsigned long previousMillis = 0; // für Intervall z.B. 250ms
|
|
const unsigned long interval = 250; // PID-Berechnungsintervall
|
|
|
|
/************************************************************************************
|
|
* LittleFS
|
|
************************************************************************************/
|
|
File fsUploadFile; // Globale Variable für die Datei während des Uploads
|
|
String uploadStatusMessage = ""; // Statusmeldung für Upload-Ergebnisse
|
|
String uploadStatusClass = ""; // "status-success" oder "status-error"
|
|
|
|
/************************************************************************************
|
|
* Hostname Variable
|
|
************************************************************************************/
|
|
char hostname[33] = "Dual-PID"; // Max 32 Zeichen + Nullterminator
|
|
|
|
|
|
/************************************************************************************
|
|
* Profile
|
|
************************************************************************************/
|
|
String profileStatusMessage = ""; // für Nachrichten auf der Profil-Seite
|
|
String standbyTimerStatusMessage = ""; // fuer Nachrichten auf der Timer-Seite
|
|
|
|
/************************************************************************************
|
|
* WiFi Signal Bitmap (Display-Anzeige)
|
|
************************************************************************************/
|
|
#ifdef ENABLE_DISPLAY
|
|
static const unsigned char PROGMEM wifiSymbol[] = {
|
|
0x1f, 0xc0, 0x20, 0x20, 0x4f, 0x90, 0x90, 0x48,
|
|
0x27, 0x20, 0x08, 0x80, 0x02, 0x00
|
|
};
|
|
#endif // ENABLE_DISPLAY
|
|
|
|
/************************************************************************************
|
|
* Gemeinsame CSS-Styles (PROGMEM) - Unverändert
|
|
************************************************************************************/
|
|
|
|
static const char commonStyle[] PROGMEM = R"rawliteral(
|
|
<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 */
|
|
color-scheme: dark;
|
|
}
|
|
|
|
html {
|
|
min-height: 100%;
|
|
background-color: #000000;
|
|
overscroll-behavior-y: none;
|
|
}
|
|
|
|
/* Seitenhintergrund: Farbverlauf */
|
|
body {
|
|
margin: 0;
|
|
padding: 0;
|
|
font-family: "Segoe UI", Tahoma, Arial, sans-serif;
|
|
background-color: #000000;
|
|
background: linear-gradient(120deg, #000000 0%, #1a1a1a 100%);
|
|
background-repeat: no-repeat;
|
|
background-attachment: fixed;
|
|
background-size: cover;
|
|
min-height: 100vh;
|
|
min-height: 100dvh;
|
|
overscroll-behavior-y: none;
|
|
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;
|
|
min-height: 52px;
|
|
}
|
|
|
|
/* Wrapper für die Navigationslinks */
|
|
.nav-links {
|
|
display: none;
|
|
position: absolute;
|
|
top: 100%;
|
|
right: 16px;
|
|
width: min(340px, calc(100vw - 32px));
|
|
max-height: calc(100vh - 72px);
|
|
overflow-y: auto;
|
|
background: rgba(26, 26, 26, 0.95);
|
|
backdrop-filter: blur(8px);
|
|
padding: 10px 0;
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
border-radius: 10px;
|
|
z-index: 1000;
|
|
}
|
|
|
|
nav.active .nav-links {
|
|
display: block;
|
|
}
|
|
|
|
/* Desktop Navigationslinks */
|
|
.nav-links a {
|
|
color: #fff;
|
|
font-weight: 500;
|
|
text-decoration: none;
|
|
margin: 0;
|
|
padding: 14px 22px;
|
|
border-radius: 0;
|
|
transition: background 0.3s ease, color 0.3s ease;
|
|
display: block;
|
|
text-align: left;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
|
}
|
|
|
|
.nav-links a:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
/* 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: block;
|
|
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: 50%;
|
|
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;
|
|
right: auto;
|
|
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-left: none;
|
|
border-right: none;
|
|
border-radius: 0;
|
|
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) */
|
|
|
|
/************************************************************************
|
|
* Toggle-Switches
|
|
************************************************************************/
|
|
|
|
/* --- Toggle Switch Styles --- */
|
|
.toggle-switch-container {
|
|
display: flex; /* Elemente nebeneinander anordnen */
|
|
align-items: center; /* Vertikal zentrieren */
|
|
margin-bottom: 10px; /* Abstand nach unten */
|
|
min-height: 30px; /* Mindesthöhe für Konsistenz */
|
|
gap: 10px; /* Abstand zwischen Text und Schalter */
|
|
}
|
|
|
|
.toggle-switch-label-text {
|
|
/* Standard Label-Text Stil (kann angepasst werden) */
|
|
/* display: block; */ /* Nicht mehr block, da flex verwendet wird */
|
|
/* margin: 10px 0 5px; */ /* Margin wird durch Container/Gap geregelt */
|
|
font-weight: 600;
|
|
flex-grow: 1; /* Lässt den Text den verfügbaren Platz einnehmen */
|
|
}
|
|
|
|
.toggle-switch {
|
|
position: relative;
|
|
display: inline-block;
|
|
width: 50px; /* Breite des Schalters */
|
|
height: 26px; /* Höhe des Schalters */
|
|
flex-shrink: 0; /* Verhindert, dass der Schalter schrumpft */
|
|
}
|
|
|
|
/* Verstecke die eigentliche Checkbox */
|
|
.toggle-switch input {
|
|
opacity: 0;
|
|
width: 0;
|
|
height: 0;
|
|
position: absolute; /* Aus dem Layout nehmen */
|
|
}
|
|
|
|
/* Der Slider (Hintergrund des Schalters) */
|
|
.toggle-slider {
|
|
position: absolute;
|
|
cursor: pointer;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background-color: #555; /* Dunkler Hintergrund im "Aus"-Zustand */
|
|
transition: .3s;
|
|
border-radius: 26px; /* Abgerundete Ecken */
|
|
border: 1px solid #666; /* Leichter Rand */
|
|
}
|
|
|
|
/* Der Knubbel (der bewegliche Teil) */
|
|
.toggle-slider:before {
|
|
position: absolute;
|
|
content: "";
|
|
height: 20px; /* Höhe des Knubbels */
|
|
width: 20px; /* Breite des Knubbels */
|
|
left: 3px; /* Startposition von links */
|
|
bottom: 2px; /* Startposition von unten */
|
|
background-color: white;
|
|
transition: .3s;
|
|
border-radius: 50%; /* Macht ihn rund */
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
|
}
|
|
|
|
/* Styling, wenn die Checkbox aktiviert (checked) ist */
|
|
.toggle-switch input:checked + .toggle-slider {
|
|
background-color: var(--accent-color); /* Akzentfarbe für "An"-Zustand */
|
|
border-color: var(--accent-color);
|
|
}
|
|
|
|
/* Knubbel-Position im "An"-Zustand */
|
|
.toggle-switch input:checked + .toggle-slider:before {
|
|
transform: translateX(23px); /* Verschiebt den Knubbel nach rechts */
|
|
}
|
|
|
|
/* Fokus-Styling für Barrierefreiheit (optional aber empfohlen) */
|
|
.toggle-switch input:focus + .toggle-slider {
|
|
box-shadow: 0 0 1px var(--accent-color);
|
|
}
|
|
|
|
/* Optional: Disabled State */
|
|
.toggle-switch input:disabled + .toggle-slider {
|
|
cursor: not-allowed;
|
|
background-color: #444;
|
|
opacity: 0.6;
|
|
}
|
|
.toggle-switch input:disabled + .toggle-slider:before {
|
|
background-color: #ccc;
|
|
}
|
|
|
|
/* Hilfsklasse für Beschreibungen unter dem Toggle */
|
|
.toggle-description {
|
|
display: block; /* Eigene Zeile */
|
|
font-size: 0.85em;
|
|
color: #ccc;
|
|
margin-top: -20px; /* Näher an den Toggle rücken */
|
|
margin-bottom: 5px; /* Abstand zum nächsten Element */
|
|
}
|
|
|
|
/* Beschreibungen unter Dropdowns (ohne negativen margin-top) */
|
|
.select-description {
|
|
display: block;
|
|
font-size: 0.85em;
|
|
color: #ccc;
|
|
margin-top: 0;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
/* Hilfsklasse für Beschreibungen unter dem Toggle unterhalb von Input-Feldern */
|
|
.toggle-description-input {
|
|
display: block; /* Eigene Zeile */
|
|
font-size: 0.85em;
|
|
color: #ccc;
|
|
margin-top: 0px; /* Näher an den Toggle rücken */
|
|
margin-bottom: 5px; /* Abstand zum nächsten Element */
|
|
}
|
|
|
|
</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 = '×'; // Ändert zu einem 'X' (Schließen-Symbol)
|
|
} else {
|
|
hamburger.innerHTML = '☰'; // Ä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)
|
|
************************************************************************************/
|
|
static const char commonNav[] PROGMEM = R"rawliteral(
|
|
<nav>
|
|
<span class="hamburger" onclick="toggleMenu()">☰</span>
|
|
<div class="nav-links">
|
|
<a href="/">Dashboard</a>
|
|
<a href="/PID">PID-Einstellung</a>
|
|
<a href="/PID-Tuning">PID-Tuning</a>
|
|
<a href="/Profile">Profile</a>
|
|
<a href="/Timer">Timer</a>
|
|
<a href="/Brew-Control">Brew Control</a>
|
|
<a href="/Chart">Chart</a>
|
|
<a href="/Sensoren">Sensoren</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";
|
|
|
|
/************************************************************************************
|
|
* WiFi-Konfigurationsstrukturen und Funktionen
|
|
************************************************************************************/
|
|
|
|
struct WiFiConfig {
|
|
char ssid[32] = "";
|
|
char password[64] = "";
|
|
bool useStaticIP = false;
|
|
IPAddress staticIP = IPAddress(192, 168, 4, 1);
|
|
IPAddress gateway = IPAddress(192, 168, 4, 1);
|
|
IPAddress subnet = IPAddress(255, 255, 255, 0);
|
|
IPAddress dns = IPAddress(8, 8, 8, 8);
|
|
// Hostname in WiFi-Config (obwohl global gespeichert, hier für Vollständigkeit)
|
|
// Wird nicht direkt hier gespeichert, sondern über globale Variable `hostname`
|
|
};
|
|
|
|
// Liest die WLAN-Konfiguration aus dem EEPROM (Feld für Feld, IPs manuell)
|
|
// Gibt true zurück, wenn eine gültige Konfiguration (Magic Value OK und SSID nicht leer) geladen wurde
|
|
struct FullyKioskConfig {
|
|
bool enabled = (DEFAULT_FULLY_KIOSK_ENABLED != 0);
|
|
char host[64] = DEFAULT_FULLY_KIOSK_HOST;
|
|
char password[64] = DEFAULT_FULLY_KIOSK_PASSWORD;
|
|
uint16_t port = DEFAULT_FULLY_KIOSK_PORT;
|
|
uint16_t timeoutMs = DEFAULT_FULLY_KIOSK_COMMAND_TIMEOUT_MS;
|
|
char standbyPath[32] = DEFAULT_FULLY_KIOSK_STANDBY_PATH;
|
|
char activePath[32] = DEFAULT_FULLY_KIOSK_ACTIVE_PATH;
|
|
};
|
|
|
|
FullyKioskConfig fullyKioskConfig;
|
|
|
|
static void setFullyKioskDefaults(FullyKioskConfig& config) {
|
|
config.enabled = (DEFAULT_FULLY_KIOSK_ENABLED != 0);
|
|
strncpy(config.host, DEFAULT_FULLY_KIOSK_HOST, sizeof(config.host) - 1);
|
|
config.host[sizeof(config.host) - 1] = '\0';
|
|
strncpy(config.password, DEFAULT_FULLY_KIOSK_PASSWORD, sizeof(config.password) - 1);
|
|
config.password[sizeof(config.password) - 1] = '\0';
|
|
config.port = DEFAULT_FULLY_KIOSK_PORT;
|
|
config.timeoutMs = DEFAULT_FULLY_KIOSK_COMMAND_TIMEOUT_MS;
|
|
strncpy(config.standbyPath, DEFAULT_FULLY_KIOSK_STANDBY_PATH, sizeof(config.standbyPath) - 1);
|
|
config.standbyPath[sizeof(config.standbyPath) - 1] = '\0';
|
|
strncpy(config.activePath, DEFAULT_FULLY_KIOSK_ACTIVE_PATH, sizeof(config.activePath) - 1);
|
|
config.activePath[sizeof(config.activePath) - 1] = '\0';
|
|
}
|
|
|
|
static void normalizeFullyKioskConfig(FullyKioskConfig& config) {
|
|
config.host[sizeof(config.host) - 1] = '\0';
|
|
config.password[sizeof(config.password) - 1] = '\0';
|
|
config.standbyPath[sizeof(config.standbyPath) - 1] = '\0';
|
|
config.activePath[sizeof(config.activePath) - 1] = '\0';
|
|
if (config.port == 0) {
|
|
config.port = DEFAULT_FULLY_KIOSK_PORT;
|
|
}
|
|
if (config.timeoutMs < 200 || config.timeoutMs > 5000) {
|
|
config.timeoutMs = DEFAULT_FULLY_KIOSK_COMMAND_TIMEOUT_MS;
|
|
}
|
|
if (strlen(config.standbyPath) == 0) {
|
|
strncpy(config.standbyPath, DEFAULT_FULLY_KIOSK_STANDBY_PATH, sizeof(config.standbyPath) - 1);
|
|
config.standbyPath[sizeof(config.standbyPath) - 1] = '\0';
|
|
}
|
|
if (strlen(config.activePath) == 0) {
|
|
strncpy(config.activePath, DEFAULT_FULLY_KIOSK_ACTIVE_PATH, sizeof(config.activePath) - 1);
|
|
config.activePath[sizeof(config.activePath) - 1] = '\0';
|
|
}
|
|
if (config.standbyPath[0] != '/' && strncmp(config.standbyPath, "http://", 7) != 0 && strncmp(config.standbyPath, "https://", 8) != 0) {
|
|
char tmp[sizeof(config.standbyPath)];
|
|
strncpy(tmp, config.standbyPath, sizeof(tmp) - 1);
|
|
tmp[sizeof(tmp) - 1] = '\0';
|
|
snprintf(config.standbyPath, sizeof(config.standbyPath), "/%s", tmp);
|
|
}
|
|
if (config.activePath[0] != '/' && strncmp(config.activePath, "http://", 7) != 0 && strncmp(config.activePath, "https://", 8) != 0) {
|
|
char tmp[sizeof(config.activePath)];
|
|
strncpy(tmp, config.activePath, sizeof(tmp) - 1);
|
|
tmp[sizeof(tmp) - 1] = '\0';
|
|
snprintf(config.activePath, sizeof(config.activePath), "/%s", tmp);
|
|
}
|
|
}
|
|
|
|
static void loadFullyKioskConfig(FullyKioskConfig& config) {
|
|
char magic[5] = {0};
|
|
EEPROM.get(EEPROM_ADDR_FULLY_CONFIG_MAGIC, magic);
|
|
if (strncmp(magic, storageMagicValue, 4) != 0) {
|
|
setFullyKioskDefaults(config);
|
|
return;
|
|
}
|
|
|
|
uint8_t enabled = 0;
|
|
EEPROM.get(EEPROM_ADDR_FULLY_ENABLED, enabled);
|
|
EEPROM.get(EEPROM_ADDR_FULLY_HOST, config.host);
|
|
EEPROM.get(EEPROM_ADDR_FULLY_PASSWORD, config.password);
|
|
EEPROM.get(EEPROM_ADDR_FULLY_PORT, config.port);
|
|
EEPROM.get(EEPROM_ADDR_FULLY_TIMEOUT_MS, config.timeoutMs);
|
|
EEPROM.get(EEPROM_ADDR_FULLY_STANDBY_PATH, config.standbyPath);
|
|
EEPROM.get(EEPROM_ADDR_FULLY_ACTIVE_PATH, config.activePath);
|
|
config.enabled = (enabled == 1);
|
|
normalizeFullyKioskConfig(config);
|
|
}
|
|
|
|
static void saveFullyKioskConfig(const FullyKioskConfig& config) {
|
|
FullyKioskConfig stored = config;
|
|
normalizeFullyKioskConfig(stored);
|
|
uint8_t enabled = stored.enabled ? 1 : 0;
|
|
EEPROM.put(EEPROM_ADDR_FULLY_CONFIG_MAGIC, storageMagicValue);
|
|
EEPROM.put(EEPROM_ADDR_FULLY_ENABLED, enabled);
|
|
EEPROM.put(EEPROM_ADDR_FULLY_HOST, stored.host);
|
|
EEPROM.put(EEPROM_ADDR_FULLY_PASSWORD, stored.password);
|
|
EEPROM.put(EEPROM_ADDR_FULLY_PORT, stored.port);
|
|
EEPROM.put(EEPROM_ADDR_FULLY_TIMEOUT_MS, stored.timeoutMs);
|
|
EEPROM.put(EEPROM_ADDR_FULLY_STANDBY_PATH, stored.standbyPath);
|
|
EEPROM.put(EEPROM_ADDR_FULLY_ACTIVE_PATH, stored.activePath);
|
|
EEPROM.commit();
|
|
}
|
|
|
|
bool loadWiFiConfig(WiFiConfig& config) { // config wird per Referenz übergeben
|
|
char magic[5] = { 0 };
|
|
EEPROM.get(EEPROM_ADDR_WIFI_CONFIG_MAGIC, magic);
|
|
|
|
// Lade Hostname
|
|
EEPROM.get(EEPROM_ADDR_HOSTNAME, hostname);
|
|
hostname[sizeof(hostname) - 1] = '\0';
|
|
if (strlen(hostname) == 0) {
|
|
strncpy(hostname, defaultHostname.c_str(), sizeof(hostname) - 1);
|
|
hostname[sizeof(hostname) - 1] = '\0';
|
|
EEPROM.put(EEPROM_ADDR_HOSTNAME, hostname);
|
|
EEPROM.commit();
|
|
}
|
|
|
|
// Prüfe Magic Value
|
|
if (strncmp(magic, storageMagicValue, 4) != 0) {
|
|
// Serial.println("DEBUG Boot - Magic Value MISMATCH! Setze WiFi Defaults."); // Debug entfernt
|
|
strcpy(config.ssid, "");
|
|
strcpy(config.password, "");
|
|
config.useStaticIP = false;
|
|
config.staticIP = IPAddress(192, 168, 4, 1); // Default AP Mode IP
|
|
config.gateway = IPAddress(192, 168, 4, 1); // Default AP Mode GW
|
|
config.subnet = IPAddress(255, 255, 255, 0);
|
|
config.dns = IPAddress(8, 8, 8, 8); // Default Public DNS
|
|
return false;
|
|
}
|
|
|
|
// Magic Value war korrekt, fahre mit dem Lesen fort
|
|
// Serial.println("DEBUG Boot - Magic Value OK."); // Debug entfernt
|
|
byte ipBytes[4]; // Puffer für IP-Bytes
|
|
|
|
// Laden der einfachen Felder
|
|
EEPROM.get(EEPROM_ADDR_WIFI_SSID, config.ssid);
|
|
EEPROM.get(EEPROM_ADDR_WIFI_PASSWORD, config.password);
|
|
EEPROM.get(EEPROM_ADDR_WIFI_USE_STATIC, config.useStaticIP);
|
|
|
|
// --- MANUELLES LESEN FÜR IPAddress FELDER ---
|
|
for(int i=0; i<4; i++) ipBytes[i] = EEPROM.read(EEPROM_ADDR_WIFI_STATIC_IP + i);
|
|
config.staticIP = IPAddress(ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3]);
|
|
|
|
for(int i=0; i<4; i++) ipBytes[i] = EEPROM.read(EEPROM_ADDR_WIFI_GATEWAY + i);
|
|
config.gateway = IPAddress(ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3]);
|
|
|
|
for(int i=0; i<4; i++) ipBytes[i] = EEPROM.read(EEPROM_ADDR_WIFI_SUBNET + i);
|
|
config.subnet = IPAddress(ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3]);
|
|
|
|
for(int i=0; i<4; i++) ipBytes[i] = EEPROM.read(EEPROM_ADDR_WIFI_DNS + i);
|
|
config.dns = IPAddress(ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3]);
|
|
// --- ENDE MANUELLES LESEN ---
|
|
|
|
// Nullterminierung sicherstellen
|
|
config.ssid[sizeof(config.ssid) - 1] = '\0';
|
|
config.password[sizeof(config.password) - 1] = '\0';
|
|
|
|
// Normale Log-Ausgabe der geladenen Werte
|
|
// Serial.println("WiFi Konfiguration geladen:");
|
|
// Serial.print(" SSID: "); Serial.println(config.ssid);
|
|
// Serial.print(" Static IP Mode: "); Serial.println(config.useStaticIP);
|
|
// if(config.useStaticIP) {
|
|
// Serial.print(" Static IP: "); Serial.println(config.staticIP);
|
|
// Serial.print(" Gateway: "); Serial.println(config.gateway);
|
|
// Serial.print(" Subnet: "); Serial.println(config.subnet);
|
|
// Serial.print(" DNS: "); Serial.println(config.dns);
|
|
// }
|
|
|
|
return (strlen(config.ssid) > 0);
|
|
}
|
|
|
|
// Schreibt die WLAN-Konfiguration ins EEPROM (Feld für Feld, IPs manuell byte-weise)
|
|
void saveWiFiConfig(const WiFiConfig& config) {
|
|
// Serial.println("Schreibe WiFi Konfiguration Feld für Feld (manuelles Schreiben für IPs)..."); // Debug entfernt
|
|
EEPROM.put(EEPROM_ADDR_WIFI_CONFIG_MAGIC, storageMagicValue);
|
|
|
|
// Einfache Felder mit EEPROM.put
|
|
EEPROM.put(EEPROM_ADDR_WIFI_SSID, config.ssid);
|
|
EEPROM.put(EEPROM_ADDR_WIFI_PASSWORD, config.password);
|
|
EEPROM.put(EEPROM_ADDR_WIFI_USE_STATIC, config.useStaticIP);
|
|
|
|
// --- IPAddress Felder manuell mit EEPROM.write() schreiben ---
|
|
// Serial.print(" Schreibe Static IP bytes: "); Serial.println(config.staticIP); // Debug entfernt
|
|
for(int i=0; i<4; i++) {
|
|
EEPROM.write(EEPROM_ADDR_WIFI_STATIC_IP + i, config.staticIP[i]);
|
|
}
|
|
|
|
// Serial.print(" Schreibe Gateway bytes: "); Serial.println(config.gateway); // Debug entfernt
|
|
for(int i=0; i<4; i++) {
|
|
EEPROM.write(EEPROM_ADDR_WIFI_GATEWAY + i, config.gateway[i]);
|
|
}
|
|
|
|
// Serial.print(" Schreibe Subnet bytes: "); Serial.println(config.subnet); // Debug entfernt
|
|
for(int i=0; i<4; i++) {
|
|
EEPROM.write(EEPROM_ADDR_WIFI_SUBNET + i, config.subnet[i]);
|
|
}
|
|
|
|
// Serial.print(" Schreibe DNS bytes: "); Serial.println(config.dns); // Debug entfernt
|
|
for(int i=0; i<4; i++) {
|
|
EEPROM.write(EEPROM_ADDR_WIFI_DNS + i, config.dns[i]);
|
|
}
|
|
// --- ENDE Manuelles Schreiben ---
|
|
|
|
// Commit der Änderungen
|
|
EEPROM.commit();
|
|
}
|
|
|
|
// Aktiviert den Access Point Modus und zeigt IP an
|
|
void startAPMode() {
|
|
WiFi.hostname(hostname);
|
|
WiFi.softAP("Dual-PID", "QuickMill"); // Setzt SSID "Dual-PID" und Passwort "QuickMill"
|
|
// Serial.print(F("AP-Mode gestartet. SSID: Dual-PID, Passwort: QuickMill, IP: "));
|
|
// Serial.println(WiFi.softAPIP());
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Webserver-Handler: WLAN-Konfigurations-Seite - Hostname hinzugefügt
|
|
************************************************************************************/
|
|
|
|
// === PROGMEM Teile definieren (Neue Reihenfolge: IP, Gateway, DNS, Subnet) ===
|
|
static const char wifiPageChunk1[] PROGMEM = R"rawliteral(
|
|
<!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ä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 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 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 (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>)rawliteral"; // Schliesst staticFields
|
|
static const char wifiPageChunk11_rest[] PROGMEM = R"rawliteral(<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";
|
|
|
|
// --- Hilfsfunktion zur Umrechnung von RSSI in Prozent (Approximation) ---
|
|
int calculateSignalPercentage(int rssi) {
|
|
int quality = 0;
|
|
if (rssi <= -100) {
|
|
quality = 0;
|
|
} else if (rssi >= -50) { // Guter Empfang
|
|
quality = 100;
|
|
} else {
|
|
// Lineare Skalierung zwischen -100dBm (0%) und -50dBm (100%)
|
|
quality = 2 * (rssi + 100);
|
|
}
|
|
return quality;
|
|
}
|
|
|
|
void handleWiFiConfig(AsyncWebServerRequest *request) {
|
|
WiFiConfig currentConfig;
|
|
loadFullyKioskConfig(fullyKioskConfig);
|
|
char magic[5] = { 0 };
|
|
EEPROM.get(EEPROM_ADDR_WIFI_CONFIG_MAGIC, magic);
|
|
bool configValid = (strncmp(magic, storageMagicValue, 4) == 0);
|
|
|
|
if (configValid) {
|
|
// Serial.println("Lese WiFi Konfiguration für WebUI Feld für Feld (manuelles Lesen für IPs)..."); // Debug entfernt
|
|
byte ipBytes[4]; // Puffer
|
|
|
|
// Laden der einfachen Felder
|
|
EEPROM.get(EEPROM_ADDR_WIFI_SSID, currentConfig.ssid);
|
|
EEPROM.get(EEPROM_ADDR_WIFI_PASSWORD, currentConfig.password);
|
|
EEPROM.get(EEPROM_ADDR_WIFI_USE_STATIC, currentConfig.useStaticIP);
|
|
|
|
// --- MANUELLES LESEN FÜR IPAddress FELDER (ohne Byte-Logging) ---
|
|
for(int i=0; i<4; i++) ipBytes[i] = EEPROM.read(EEPROM_ADDR_WIFI_STATIC_IP + i);
|
|
currentConfig.staticIP = IPAddress(ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3]);
|
|
|
|
for(int i=0; i<4; i++) ipBytes[i] = EEPROM.read(EEPROM_ADDR_WIFI_GATEWAY + i);
|
|
currentConfig.gateway = IPAddress(ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3]);
|
|
|
|
for(int i=0; i<4; i++) ipBytes[i] = EEPROM.read(EEPROM_ADDR_WIFI_SUBNET + i);
|
|
currentConfig.subnet = IPAddress(ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3]);
|
|
|
|
for(int i=0; i<4; i++) ipBytes[i] = EEPROM.read(EEPROM_ADDR_WIFI_DNS + i);
|
|
currentConfig.dns = IPAddress(ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3]);
|
|
// --- ENDE MANUELLES LESEN ---
|
|
|
|
// Nullterminierung
|
|
currentConfig.ssid[sizeof(currentConfig.ssid) - 1] = '\0';
|
|
currentConfig.password[sizeof(currentConfig.password) - 1] = '\0';
|
|
|
|
} else {
|
|
// Defaults setzen
|
|
// Serial.println("WiFi Magic Value nicht gefunden (in handleWiFiConfig). Setze Defaults für UI."); // Debug entfernt
|
|
strcpy(currentConfig.ssid, "");
|
|
strcpy(currentConfig.password, "");
|
|
currentConfig.useStaticIP = false;
|
|
currentConfig.staticIP = IPAddress(192, 168, 4, 1);
|
|
currentConfig.gateway = IPAddress(192, 168, 4, 1);
|
|
currentConfig.subnet = IPAddress(255, 255, 255, 0);
|
|
currentConfig.dns = IPAddress(8, 8, 8, 8);
|
|
}
|
|
// Hostname ist global verfügbar
|
|
|
|
// --- Signalstärke ermitteln ---
|
|
char signalBuffer[50];
|
|
if (WiFi.getMode() == WIFI_STA && WiFi.status() == WL_CONNECTED) {
|
|
long rssi = WiFi.RSSI();
|
|
int percentage = calculateSignalPercentage(rssi);
|
|
snprintf(signalBuffer, sizeof(signalBuffer), "%d dBm (%d%%)", (int)rssi, percentage);
|
|
} else if (WiFi.getMode() == WIFI_AP) {
|
|
strcpy(signalBuffer, "AP-Modus (keine Messung)");
|
|
} else {
|
|
strcpy(signalBuffer, "Nicht verbunden");
|
|
}
|
|
|
|
// Temporäre Puffer für Senden
|
|
char formatBuffer[256];
|
|
char ipBuffer[16];
|
|
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8");
|
|
|
|
// --- Senden der HTML Chunks (unverändert) ---
|
|
response->print(FPSTR(wifiPageChunk1));
|
|
response->print(FPSTR(commonStyle));
|
|
response->print(FPSTR(wifiPageChunk2));
|
|
response->print(FPSTR(commonNav));
|
|
response->print(FPSTR(wifiPageChunk3_a));
|
|
response->print(signalBuffer);
|
|
response->print(FPSTR(wifiPageChunk3_b));
|
|
response->print(hostname);
|
|
response->print(FPSTR(wifiPageChunk3_c));
|
|
|
|
// SSID einfügen
|
|
if (strlen(currentConfig.ssid) == 0) { response->print(F("SSID eingeben")); }
|
|
else { response->print(currentConfig.ssid); }
|
|
response->print(FPSTR(wifiPageChunk4));
|
|
|
|
// Passwort einfügen
|
|
if (!demoModus) {
|
|
if (strlen(currentConfig.password) == 0) { response->print(F("Passwort eingeben")); }
|
|
else { response->print(currentConfig.password); }
|
|
} else { response->print(F("********")); }
|
|
|
|
// IP-Einstellungen (Radios + Div)
|
|
snprintf_P(formatBuffer, sizeof(formatBuffer), wifiPageChunk5_format,
|
|
currentConfig.useStaticIP ? "" : "checked",
|
|
currentConfig.useStaticIP ? "checked" : "",
|
|
currentConfig.useStaticIP ? "block" : "none");
|
|
response->print(formatBuffer);
|
|
|
|
// Statische IP-Felder füllen
|
|
response->print(FPSTR(wifiPageChunk6_ip)); // IP Label+Input
|
|
snprintf(ipBuffer, sizeof(ipBuffer), "%d.%d.%d.%d", currentConfig.staticIP[0], currentConfig.staticIP[1], currentConfig.staticIP[2], currentConfig.staticIP[3]);
|
|
response->print(ipBuffer); // IP Wert
|
|
|
|
response->print(FPSTR(wifiPageChunk7_gateway)); // GW Label+Input
|
|
snprintf(ipBuffer, sizeof(ipBuffer), "%d.%d.%d.%d", currentConfig.gateway[0], currentConfig.gateway[1], currentConfig.gateway[2], currentConfig.gateway[3]);
|
|
response->print(ipBuffer); // GW Wert
|
|
|
|
response->print(FPSTR(wifiPageChunk8_dns)); // DNS Label+Input
|
|
snprintf(ipBuffer, sizeof(ipBuffer), "%d.%d.%d.%d", currentConfig.dns[0], currentConfig.dns[1], currentConfig.dns[2], currentConfig.dns[3]);
|
|
response->print(ipBuffer); // DNS Wert
|
|
|
|
response->print(FPSTR(wifiPageChunk9_subnet)); // Subnet Label+Input
|
|
snprintf(ipBuffer, sizeof(ipBuffer), "%d.%d.%d.%d", currentConfig.subnet[0], currentConfig.subnet[1], currentConfig.subnet[2], currentConfig.subnet[3]);
|
|
response->print(ipBuffer); // Subnet Wert
|
|
|
|
// Rest der Seite
|
|
response->print(FPSTR(wifiPageChunk10_rest));
|
|
|
|
response->print(F("<h3>Kiosk-Display / Fully Kiosk</h3>"));
|
|
response->print(F("<label><input type='checkbox' name='fullyEnabled' value='1' "));
|
|
if (fullyKioskConfig.enabled) {
|
|
response->print(F("checked"));
|
|
}
|
|
response->print(F("> Fully Kiosk bei Standby steuern</label>"));
|
|
response->print(F("<label for='fullyHost'>Tablet-IP oder Host:</label><input type='text' id='fullyHost' name='fullyHost' value='"));
|
|
response->print(fullyKioskConfig.host);
|
|
response->print(F("' placeholder='192.168.178.50'>"));
|
|
response->print(F("<label for='fullyPort'>REST-Port:</label><input type='number' id='fullyPort' name='fullyPort' min='1' max='65535' value='"));
|
|
response->print(fullyKioskConfig.port);
|
|
response->print(F("'>"));
|
|
response->print(F("<label for='fullyPassword'>Fully Passwort:</label><input type='password' id='fullyPassword' name='fullyPassword' value='"));
|
|
response->print(fullyKioskConfig.password);
|
|
response->print(F("'>"));
|
|
response->print(F("<label for='fullyTimeout'>Timeout (ms):</label><input type='number' id='fullyTimeout' name='fullyTimeout' min='200' max='5000' value='"));
|
|
response->print(fullyKioskConfig.timeoutMs);
|
|
response->print(F("'>"));
|
|
response->print(F("<label for='fullyStandbyPath'>Standby-Ziel:</label><input type='text' id='fullyStandbyPath' name='fullyStandbyPath' value='"));
|
|
response->print(fullyKioskConfig.standbyPath);
|
|
response->print(F("'>"));
|
|
response->print(F("<label for='fullyActivePath'>Aktiv-Ziel:</label><input type='text' id='fullyActivePath' name='fullyActivePath' value='"));
|
|
response->print(fullyKioskConfig.activePath);
|
|
response->print(F("'>"));
|
|
response->print(F("<div style='margin-top: 10px; font-size: 0.9rem; opacity: 0.85;'>Standby lädt die Uhr-Seite, Aufwecken lädt das Dashboard. Relative Pfade beziehen sich auf diese Steuerung.</div>"));
|
|
response->print(FPSTR(wifiPageChunk11_rest));
|
|
|
|
request->send(response);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler, um den AP-Modus per Knopfdruck zu erzwingen - Heap-Optimiert
|
|
************************************************************************************/
|
|
|
|
// Die PROGMEM-Definitionen für die Bestätigungsseite bleiben bestehen
|
|
static const char forceAPHtml[] PROGMEM = R"rawliteral(
|
|
<!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(AsyncWebServerRequest *request) {
|
|
// Leer-Konfiguration speichern, damit beim nächsten Start AP aktiv wird
|
|
WiFiConfig emptyConfig;
|
|
// Wichtig: Hostname NICHT entfernen!
|
|
if (!demoModus) { saveWiFiConfig(emptyConfig); }
|
|
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8");
|
|
response->print(FPSTR(forceAPHtml)); // Chunk 1 (Head Start)
|
|
response->print(FPSTR(commonStyle)); // Common CSS
|
|
response->print(FPSTR(forceAPHtml2)); // Chunk 2 (Head End)
|
|
response->print(FPSTR(commonNav)); // Common Navigation
|
|
response->print(FPSTR(forceAPHtml3)); // Chunk 3 (Body)
|
|
request->send(response);
|
|
|
|
// --- Logik nach dem Senden (BLEIBT UNVERÄNDERT) ---
|
|
scheduleRestart(1000);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler zum Speichern der WLAN-Konfiguration - Heap-Optimiert (Bestätigungsseite)
|
|
************************************************************************************/
|
|
|
|
// Die PROGMEM-Definitionen für die Bestätigungsseite bleiben bestehen
|
|
static const char saveConfigHtml[] PROGMEM = R"rawliteral(
|
|
<!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(AsyncWebServerRequest *request) {
|
|
// --- Daten validieren und auslesen ---
|
|
if (!request->hasArg(F("ssid"))) {
|
|
request->send(400, F("text/plain"), F("Fehler: SSID fehlt!"));
|
|
return;
|
|
}
|
|
|
|
// --- Konfigurationsstruktur füllen ---
|
|
WiFiConfig newConfig;
|
|
|
|
// SSID und Passwort kopieren (mit Längenbegrenzung und Nullterminierung)
|
|
strncpy(newConfig.ssid, request->arg(F("ssid")).c_str(), sizeof(newConfig.ssid) - 1);
|
|
newConfig.ssid[sizeof(newConfig.ssid) - 1] = '\0';
|
|
|
|
if (request->hasArg(F("password"))) {
|
|
strncpy(newConfig.password, request->arg(F("password")).c_str(), sizeof(newConfig.password) - 1);
|
|
newConfig.password[sizeof(newConfig.password) - 1] = '\0';
|
|
} else {
|
|
newConfig.password[0] = '\0';
|
|
}
|
|
|
|
// IP-Typ bestimmen
|
|
newConfig.useStaticIP = (request->arg(F("ipType")) == "static");
|
|
|
|
// Statische IP-Einstellungen parsen, falls ausgewählt
|
|
if (newConfig.useStaticIP) {
|
|
if (!request->hasArg(F("ip")) || !request->hasArg(F("gateway")) || !request->hasArg(F("subnet")) || !request->hasArg(F("dns"))) {
|
|
request->send(400, F("text/plain"), F("Fehler: Fehlende Felder für statische IP!"));
|
|
return;
|
|
}
|
|
|
|
bool ipOK = newConfig.staticIP.fromString(request->arg(F("ip")));
|
|
bool gwOK = newConfig.gateway.fromString(request->arg(F("gateway")));
|
|
bool subOK = newConfig.subnet.fromString(request->arg(F("subnet")));
|
|
bool dnsOK = newConfig.dns.fromString(request->arg(F("dns")));
|
|
}
|
|
|
|
// Hostname speichern (separat, da nicht Teil der WiFiConfig im EEPROM)
|
|
if (request->hasArg(F("hostname"))) {
|
|
strncpy(hostname, request->arg(F("hostname")).c_str(), sizeof(hostname) - 1);
|
|
hostname[sizeof(hostname) - 1] = '\0'; // Sicherstellen der Nullterminierung
|
|
if (strlen(hostname) == 0) { // Wenn leer, Default verwenden
|
|
strncpy(hostname, defaultHostname.c_str(), sizeof(hostname) - 1);
|
|
hostname[sizeof(hostname) - 1] = '\0';
|
|
}
|
|
EEPROM.put(EEPROM_ADDR_HOSTNAME, hostname); // Hostname direkt ins EEPROM schreiben
|
|
EEPROM.commit();
|
|
}
|
|
|
|
|
|
FullyKioskConfig newFullyConfig = fullyKioskConfig;
|
|
newFullyConfig.enabled = request->hasArg(F("fullyEnabled"));
|
|
if (request->hasArg(F("fullyHost"))) {
|
|
strncpy(newFullyConfig.host, request->arg(F("fullyHost")).c_str(), sizeof(newFullyConfig.host) - 1);
|
|
newFullyConfig.host[sizeof(newFullyConfig.host) - 1] = '\0';
|
|
}
|
|
if (request->hasArg(F("fullyPassword"))) {
|
|
strncpy(newFullyConfig.password, request->arg(F("fullyPassword")).c_str(), sizeof(newFullyConfig.password) - 1);
|
|
newFullyConfig.password[sizeof(newFullyConfig.password) - 1] = '\0';
|
|
}
|
|
if (request->hasArg(F("fullyPort"))) {
|
|
uint32_t portValue = (uint32_t)request->arg(F("fullyPort")).toInt();
|
|
if (portValue > 0 && portValue <= 65535) {
|
|
newFullyConfig.port = (uint16_t)portValue;
|
|
}
|
|
}
|
|
if (request->hasArg(F("fullyTimeout"))) {
|
|
uint32_t timeoutValue = (uint32_t)request->arg(F("fullyTimeout")).toInt();
|
|
if (timeoutValue >= 200 && timeoutValue <= 5000) {
|
|
newFullyConfig.timeoutMs = (uint16_t)timeoutValue;
|
|
}
|
|
}
|
|
if (request->hasArg(F("fullyStandbyPath"))) {
|
|
strncpy(newFullyConfig.standbyPath, request->arg(F("fullyStandbyPath")).c_str(), sizeof(newFullyConfig.standbyPath) - 1);
|
|
newFullyConfig.standbyPath[sizeof(newFullyConfig.standbyPath) - 1] = '\0';
|
|
}
|
|
if (request->hasArg(F("fullyActivePath"))) {
|
|
strncpy(newFullyConfig.activePath, request->arg(F("fullyActivePath")).c_str(), sizeof(newFullyConfig.activePath) - 1);
|
|
newFullyConfig.activePath[sizeof(newFullyConfig.activePath) - 1] = '\0';
|
|
}
|
|
normalizeFullyKioskConfig(newFullyConfig);
|
|
|
|
// --- Speichern (wenn nicht im Demo-Modus) ---
|
|
if (!demoModus) {
|
|
// Serial.println("Schreibe WiFi-Konfiguration ins EEPROM...");
|
|
saveWiFiConfig(newConfig); // Ruft die Funktion auf, die Feld für Feld schreibt
|
|
saveFullyKioskConfig(newFullyConfig);
|
|
fullyKioskConfig = newFullyConfig;
|
|
// EEPROM.commit() wird jetzt innerhalb von saveWiFiConfig aufgerufen.
|
|
}
|
|
|
|
// --- Senden der Bestätigungsseite (Heap-Optimiert) ---
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8");
|
|
response->print(FPSTR(saveConfigHtml)); // Chunk 1 (Head Start)
|
|
response->print(FPSTR(commonStyle)); // Common CSS
|
|
response->print(FPSTR(saveConfigHtml2)); // Chunk 2 (Head End)
|
|
response->print(FPSTR(commonNav)); // Common Navigation
|
|
response->print(FPSTR(saveConfigHtml3)); // Chunk 3 (Body)
|
|
request->send(response);
|
|
|
|
// --- Neustart einleiten ---
|
|
scheduleRestart(5000); // Browser kann Bestätigung empfangen
|
|
}
|
|
|
|
// Prüfung der WiFi-Verbindung (Reconnect falls getrennt) - Unverändert
|
|
void checkWifiConnection() {
|
|
if (WiFi.status() != WL_CONNECTED) {
|
|
WiFi.reconnect();
|
|
// if (WiFi.waitForConnectResult() != WL_CONNECTED) {Serial.println(F("WiFi-Verbindung kann nicht hergestellt werden!")); }
|
|
}
|
|
}
|
|
|
|
#ifdef ENABLE_DISPLAY
|
|
// WiFi-Symbol aufs Display zeichnen
|
|
void drawwifiSymbol(int16_t x, int16_t y) {
|
|
display.drawBitmap(x, y, wifiSymbol, 13, 7, SH110X_WHITE);
|
|
}
|
|
#endif // ENABLE_DISPLAY
|
|
|
|
/************************************************************************************
|
|
* Handler zum Rücksetzen der Betriebszeit - URL angepasst
|
|
************************************************************************************/
|
|
|
|
void handleResetRuntime(AsyncWebServerRequest *request) {
|
|
totalRuntime = 0;
|
|
lastPersistedRuntime = 0;
|
|
EEPROM.put(EEPROM_ADDR_RUNTIME, totalRuntime);
|
|
EEPROM.commit();
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader(F("Location"), F("/Info"));
|
|
request->send(response);
|
|
}
|
|
|
|
static bool waitForHx711Ready(unsigned long timeoutMs) {
|
|
unsigned long waitStart = millis();
|
|
while (!hx711.is_ready() && (millis() - waitStart < timeoutMs)) {
|
|
delay(10);
|
|
yield();
|
|
}
|
|
return hx711.is_ready();
|
|
}
|
|
|
|
static void sendHx711CalibrationJson(AsyncWebServerRequest *request, bool success, const String& message, float calFactor = NAN, float measuredWeight = NAN, float rawValue = NAN) {
|
|
String json = "{\"success\":";
|
|
json += success ? "true" : "false";
|
|
json += ",\"message\":\"";
|
|
String escapedMessage = message;
|
|
escapedMessage.replace("\\", "\\\\");
|
|
escapedMessage.replace("\"", "\\\"");
|
|
json += escapedMessage;
|
|
json += "\"";
|
|
if (isfinite(calFactor)) {
|
|
json += ",\"calFactor\":";
|
|
json += String(calFactor, 4);
|
|
}
|
|
if (isfinite(measuredWeight)) {
|
|
json += ",\"measuredWeight\":";
|
|
json += String(measuredWeight, 2);
|
|
}
|
|
if (isfinite(rawValue)) {
|
|
json += ",\"rawValue\":";
|
|
json += String(rawValue, 1);
|
|
}
|
|
json += "}";
|
|
request->send(200, "application/json", json);
|
|
}
|
|
|
|
static bool canRunHx711Calibration(AsyncWebServerRequest *request) {
|
|
if (!scaleEnabled || scaleType != SCALE_HX711) {
|
|
sendHx711CalibrationJson(request, false, "HX711-Waage ist nicht aktiv.");
|
|
return false;
|
|
}
|
|
if (shotActive || pendingShotStartAfterScaleTare || tareScaleAfterDelay || tareScaleSettling) {
|
|
sendHx711CalibrationJson(request, false, "Kalibrierung nicht moeglich, waehrend Bezug oder Tara laeuft.");
|
|
return false;
|
|
}
|
|
hx711.power_up();
|
|
hx711.set_scale(hx711CalibrationFactor);
|
|
if (!waitForHx711Ready(1200)) {
|
|
scaleConnected = false;
|
|
sendHx711CalibrationJson(request, false, "HX711 ist nicht bereit. Bitte Verbindung pruefen.");
|
|
return false;
|
|
}
|
|
scaleConnected = true;
|
|
return true;
|
|
}
|
|
|
|
void handleHx711CalibrationTare(AsyncWebServerRequest *request) {
|
|
if (!canRunHx711Calibration(request)) {
|
|
return;
|
|
}
|
|
|
|
hx711.tare(10);
|
|
resetScaleReadingFilters(0.0f);
|
|
hx711LastReadyTime = millis();
|
|
hx711HasValidReading = true;
|
|
hx711NotReadyCounter = 0;
|
|
sendHx711CalibrationJson(request, true, "Nullpunkt gesetzt. Jetzt Referenzgewicht auflegen und Gewicht eingeben.");
|
|
}
|
|
|
|
void handleHx711CalibrationApply(AsyncWebServerRequest *request) {
|
|
if (!canRunHx711Calibration(request)) {
|
|
return;
|
|
}
|
|
if (!request->hasArg("knownWeight")) {
|
|
sendHx711CalibrationJson(request, false, "Referenzgewicht fehlt.");
|
|
return;
|
|
}
|
|
|
|
float knownWeight = request->arg("knownWeight").toFloat();
|
|
if (!isfinite(knownWeight) || knownWeight <= 0.0f || knownWeight > 5000.0f) {
|
|
sendHx711CalibrationJson(request, false, "Referenzgewicht muss zwischen 1 g und 5000 g liegen.");
|
|
return;
|
|
}
|
|
|
|
float rawNetValue = hx711.get_value(10);
|
|
if (!isfinite(rawNetValue) || fabs(rawNetValue) < 50.0f) {
|
|
sendHx711CalibrationJson(request, false, "Referenzgewicht wurde nicht sicher erkannt. Gewicht auflegen und erneut versuchen.");
|
|
return;
|
|
}
|
|
|
|
float newCalFactor = rawNetValue / knownWeight;
|
|
if (!isfinite(newCalFactor) || fabs(newCalFactor) < 0.0001f) {
|
|
sendHx711CalibrationJson(request, false, "Kalibrierungsfaktor konnte nicht berechnet werden.");
|
|
return;
|
|
}
|
|
|
|
hx711CalibrationFactor = newCalFactor;
|
|
EEPROM.put(EEPROM_ADDR_HX711_CAL_FACTOR, hx711CalibrationFactor);
|
|
EEPROM.commit();
|
|
hx711.set_scale(hx711CalibrationFactor);
|
|
|
|
float measuredWeight = hx711.get_units(5);
|
|
acceptScaleReading(measuredWeight, true, true);
|
|
hx711LastReadyTime = millis();
|
|
hx711HasValidReading = true;
|
|
hx711NotReadyCounter = 0;
|
|
scaleConnected = true;
|
|
|
|
sendHx711CalibrationJson(request, true, "Kalibrierungsfaktor gespeichert.", hx711CalibrationFactor, measuredWeight, rawNetValue);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler zum Rücksetzen des Shotzählers - URL angepasst
|
|
************************************************************************************/
|
|
|
|
void handleResetShots(AsyncWebServerRequest *request) {
|
|
shotCounter = 0;
|
|
EEPROM.put(EEPROM_ADDR_SHOTCOUNTER, shotCounter);
|
|
EEPROM.commit();
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader(F("Location"), F("/Info"));
|
|
request->send(response);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler zum Rücksetzen des Wartungszählers (über Shot-Zähler)
|
|
************************************************************************************/
|
|
void handleResetMaintenance(AsyncWebServerRequest *request) {
|
|
maintenanceIntervalCounter = 0; // Wartungszähler-Reset auf 0
|
|
EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter);
|
|
EEPROM.commit();
|
|
displayMaintenanceMessage = false; // Meldung ggf. ausblenden
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader(F("Location"), F("/Service"));
|
|
request->send(response);
|
|
}
|
|
|
|
|
|
/************************************************************************************
|
|
* Handler für den Export der EEPROM-Einstellungen - Unverändert
|
|
************************************************************************************/
|
|
void handleExportSettings(AsyncWebServerRequest *request) {
|
|
const size_t bufferSize = 64;
|
|
byte buffer[bufferSize];
|
|
|
|
AsyncResponseStream *response = request->beginResponseStream("application/octet-stream");
|
|
response->addHeader("Content-Disposition", "attachment; filename=\"dual_pid_settings.bin\"");
|
|
response->setContentLength(EEPROM_SIZE);
|
|
|
|
for (int i = 0; i < EEPROM_SIZE; i += bufferSize) {
|
|
size_t currentChunkSize = (EEPROM_SIZE - i < bufferSize) ? (EEPROM_SIZE - i) : bufferSize;
|
|
for (size_t j = 0; j < currentChunkSize; j++) {
|
|
buffer[j] = EEPROM.read(i + j);
|
|
yield();
|
|
}
|
|
response->write(buffer, currentChunkSize);
|
|
yield();
|
|
}
|
|
|
|
request->send(response);
|
|
}
|
|
|
|
|
|
/************************************************************************************
|
|
* Handler für den Import der EEPROM-Einstellungen (Upload-Verarbeitung) - Unverändert
|
|
************************************************************************************/
|
|
// Globale Variablen bleiben gleich
|
|
static int eepromWriteAddress = 0;
|
|
static bool importSuccessful = false;
|
|
static String importStatusMessage = "";
|
|
|
|
void handleImportUpload(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
|
|
(void)request;
|
|
if (!scaleEnabled || scaleType != SCALE_I2C) {
|
|
return;
|
|
}
|
|
(void)filename;
|
|
|
|
if (demoModus) {
|
|
if (index == 0) {
|
|
importStatusMessage = F("Import im Demo-Modus nicht erlaubt!");
|
|
importSuccessful = false;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (index == 0) {
|
|
eepromWriteAddress = 0;
|
|
importSuccessful = false;
|
|
importStatusMessage = "";
|
|
}
|
|
|
|
if (len) {
|
|
if (importStatusMessage.length() == 0) {
|
|
for (size_t i = 0; i < len; i++) {
|
|
if (eepromWriteAddress < EEPROM_SIZE) {
|
|
EEPROM.write(eepromWriteAddress, data[i]);
|
|
eepromWriteAddress++;
|
|
} else {
|
|
importStatusMessage = F("Importfehler: Empfangene Datei zu groß!");
|
|
break;
|
|
}
|
|
yield();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (final) {
|
|
if (importStatusMessage.length() > 0) {
|
|
importSuccessful = false;
|
|
} else if (eepromWriteAddress == EEPROM_SIZE) {
|
|
if (EEPROM.commit()) {
|
|
importSuccessful = true;
|
|
importStatusMessage = F("Einstellungen erfolgreich importiert! Neustart wird durchgeführt...");
|
|
} else {
|
|
importSuccessful = false;
|
|
importStatusMessage = F("Import fehlgeschlagen: EEPROM Commit Fehler!");
|
|
}
|
|
} else {
|
|
importSuccessful = false;
|
|
importStatusMessage = F("Import fehlgeschlagen: Falsche Dateigröße!");
|
|
}
|
|
eepromWriteAddress = 0;
|
|
}
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler für die Import-Seite (nach dem Upload) - Heap-Optimiert
|
|
************************************************************************************/
|
|
|
|
// PROGMEM Chunks für die Seite
|
|
static const char importDoneHtmlStart[] PROGMEM = R"rawliteral(
|
|
<!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(AsyncWebServerRequest *request) {
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html");
|
|
response->write(importDoneHtmlStart, strlen_P(importDoneHtmlStart));
|
|
response->write(commonStyle, strlen_P(commonStyle));
|
|
response->write(importDoneHtmlHeadEnd, strlen_P(importDoneHtmlHeadEnd));
|
|
response->write(commonNav, strlen_P(commonNav));
|
|
response->write(importDoneHtmlBodyStart, strlen_P(importDoneHtmlBodyStart));
|
|
if (importStatusMessage.length() > 0) {
|
|
response->print(importStatusMessage);
|
|
}
|
|
response->write(importDoneHtmlBodyEnd, strlen_P(importDoneHtmlBodyEnd));
|
|
request->send(response);
|
|
|
|
if (importSuccessful) {
|
|
scheduleRestart(5000);
|
|
}
|
|
}
|
|
|
|
/************************************************************************************
|
|
* EEPROM-Backup auf FFat (Schutz gegen Datenverlust bei unsauberem Reset/Brownout)
|
|
* - settingsBackupPath haelt eine 1:1-Kopie des EEPROM (EEPROM_SIZE Bytes).
|
|
* - Wird vor jedem Firmware-Update (S3-Selbst-Update UND P4-Display-Update)
|
|
* aufgefrischt und beim Boot angelegt, falls noch keins existiert.
|
|
* - Beim Boot ohne gueltigen Magic-Value wird DARAUS wiederhergestellt, statt
|
|
* sofort Werkseinstellungen zu setzen. So kostet ein einzelner Crash waehrend
|
|
* eines Updates nicht mehr alle Einstellungen.
|
|
************************************************************************************/
|
|
static const char* settingsBackupPath = "/settings_backup.bin";
|
|
static const char* settingsBackupTmpPath = "/settings_backup.tmp";
|
|
bool eepromRestoredFromBackup = false; // true, wenn beim letzten Boot aus dem Backup wiederhergestellt wurde
|
|
|
|
// Schreibt den aktuellen EEPROM-Inhalt atomar (temp + rename) als Backup.
|
|
// Nur wenn der aktuelle Inhalt einen gueltigen Magic-Value hat - sonst wuerde ein
|
|
// bereits beschaedigtes EEPROM ein gutes Backup ueberschreiben.
|
|
bool writeEepromBackup() {
|
|
if (!LittleFS.begin()) return false;
|
|
char magic[5] = {0};
|
|
EEPROM.get(EEPROM_ADDR_MAGICVALUE, magic);
|
|
if (strncmp(magic, storageMagicValue, 4) != 0) return false;
|
|
|
|
static uint8_t backupBuf[EEPROM_SIZE];
|
|
for (int i = 0; i < EEPROM_SIZE; i++) backupBuf[i] = EEPROM.read(i);
|
|
|
|
if (LittleFS.exists(settingsBackupTmpPath)) LittleFS.remove(settingsBackupTmpPath);
|
|
File f = LittleFS.open(settingsBackupTmpPath, FILE_WRITE);
|
|
if (!f) return false;
|
|
size_t written = f.write(backupBuf, EEPROM_SIZE);
|
|
f.flush();
|
|
f.close();
|
|
if (written != (size_t)EEPROM_SIZE) { LittleFS.remove(settingsBackupTmpPath); return false; }
|
|
|
|
// Atomar ueberschreiben: erst die alte Datei weg, dann temp umbenennen
|
|
if (LittleFS.exists(settingsBackupPath)) LittleFS.remove(settingsBackupPath);
|
|
if (!LittleFS.rename(settingsBackupTmpPath, settingsBackupPath)) {
|
|
LittleFS.remove(settingsBackupTmpPath);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Legt ein Backup an, falls noch keins existiert (z. B. erster Boot mit gueltigem EEPROM).
|
|
void ensureEepromBackupExists() {
|
|
if (!LittleFS.begin()) return;
|
|
if (LittleFS.exists(settingsBackupPath)) return;
|
|
writeEepromBackup();
|
|
}
|
|
|
|
// Stellt das EEPROM aus dem FFat-Backup wieder her (true bei Erfolg).
|
|
bool tryRestoreEepromFromBackup() {
|
|
if (!LittleFS.begin()) return false;
|
|
if (!LittleFS.exists(settingsBackupPath)) return false;
|
|
File f = LittleFS.open(settingsBackupPath, FILE_READ);
|
|
if (!f) return false;
|
|
if (f.size() != (size_t)EEPROM_SIZE) { f.close(); return false; }
|
|
static uint8_t restoreBuf[EEPROM_SIZE];
|
|
size_t rd = f.read(restoreBuf, EEPROM_SIZE);
|
|
f.close();
|
|
if (rd != (size_t)EEPROM_SIZE) return false;
|
|
if (strncmp((const char*)restoreBuf, storageMagicValue, 4) != 0) return false; // Backup unbrauchbar
|
|
for (int i = 0; i < EEPROM_SIZE; i++) EEPROM.write(i, restoreBuf[i]);
|
|
return EEPROM.commit();
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Funktion zum Zurücksetzen auf Werkseinstellungen
|
|
* Setzt auch Brew Control und Brew-by-Weight Werte zurück
|
|
************************************************************************************/
|
|
void resetToDefaults() {
|
|
|
|
// PID & Offset etc. (Bestehende Werte)
|
|
EEPROM.put(EEPROM_ADDR_SETPOINT_DAMPF, defaultSetpointDampf);
|
|
EEPROM.put(EEPROM_ADDR_SETPOINT_WASSER, defaultSetpointWasser);
|
|
EEPROM.put(EEPROM_ADDR_OFFSET_DAMPF, defaultOffsetDampf);
|
|
EEPROM.put(EEPROM_ADDR_OFFSET_WASSER, defaultOffsetWasser);
|
|
EEPROM.put(EEPROM_ADDR_KP_DAMPF, defaultKpDampf);
|
|
EEPROM.put(EEPROM_ADDR_KI_DAMPF, defaultKiDampf);
|
|
EEPROM.put(EEPROM_ADDR_KD_DAMPF, defaultKdDampf);
|
|
EEPROM.put(EEPROM_ADDR_KP_WASSER, defaultKpWasser);
|
|
EEPROM.put(EEPROM_ADDR_KI_WASSER, defaultKiWasser);
|
|
EEPROM.put(EEPROM_ADDR_KD_WASSER, defaultKdWasser);
|
|
EEPROM.put(EEPROM_ADDR_WINDOWSIZE_WASSER, defaultWindowSizeWasser);
|
|
EEPROM.put(EEPROM_ADDR_WINDOWSIZE_DAMPF, defaultWindowSizeDampf);
|
|
EEPROM.put(EEPROM_ADDR_BOOST_WASSER_ACTIVE, defaultBoostWasserActive);
|
|
EEPROM.put(EEPROM_ADDR_BOOST_DAMPF_ACTIVE, defaultBoostDampfActive);
|
|
EEPROM.put(EEPROM_ADDR_PREVENTHEAT_WASSER, defaultPreventHeatAboveSetpointWasser);
|
|
EEPROM.put(EEPROM_ADDR_PREVENTHEAT_DAMPF, defaultPreventHeatAboveSetpointDampf);
|
|
EEPROM.put(EEPROM_ADDR_MAX_TEMP_WASSER, maxTempWasser); // Sollte ggf. eigene Defaults haben
|
|
EEPROM.put(EEPROM_ADDR_MAX_TEMP_DAMPF, maxTempDampf); // Sollte ggf. eigene Defaults haben
|
|
|
|
// Eco Modus
|
|
EEPROM.put(EEPROM_ADDR_ECOMODE_MINUTES, defaultEcoModeMinutes);
|
|
EEPROM.put(EEPROM_ADDR_ECOMODE_TEMP_WASSER, defaultEcoModeTempWasser);
|
|
EEPROM.put(EEPROM_ADDR_ECOMODE_TEMP_DAMPF, defaultEcoModeTempDampf);
|
|
EEPROM.put(EEPROM_ADDR_DYNAMIC_ECO_MODE, false);
|
|
EEPROM.put(EEPROM_ADDR_STEAM_DELAY, defaultDampfVerzoegerung);
|
|
|
|
// Geräteinfo
|
|
char tempInfo[50];
|
|
strncpy(tempInfo, defaultInfoHersteller.c_str(), sizeof(tempInfo) - 1); tempInfo[sizeof(tempInfo) - 1] = '\0'; EEPROM.put(EEPROM_ADDR_INFO_HERSTELLER, tempInfo);
|
|
strncpy(tempInfo, defaultInfoModell.c_str(), sizeof(tempInfo) - 1); tempInfo[sizeof(tempInfo) - 1] = '\0'; EEPROM.put(EEPROM_ADDR_INFO_MODELL, tempInfo);
|
|
strncpy(tempInfo, defaultInfoZusatz.c_str(), sizeof(tempInfo) - 1); tempInfo[sizeof(tempInfo) - 1] = '\0'; EEPROM.put(EEPROM_ADDR_INFO_ZUSATZ, tempInfo);
|
|
|
|
// Fast Heat Up
|
|
EEPROM.put(EEPROM_ADDR_FASTHEATUP_DATA, false);
|
|
|
|
// AutoTune Parameter
|
|
EEPROM.put(EEPROM_ADDR_TUNING_STEP_WASSER, defaultTuningStepWasser);
|
|
EEPROM.put(EEPROM_ADDR_TUNING_NOISE_WASSER, defaultTuningNoiseWasser);
|
|
EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_WASSER, defaultTuningStartValueWasser);
|
|
EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_WASSER, defaultTuningLookBackWasser);
|
|
EEPROM.put(EEPROM_ADDR_TUNING_STEP_DAMPF, defaultTuningStepDampf);
|
|
EEPROM.put(EEPROM_ADDR_TUNING_NOISE_DAMPF, defaultTuningNoiseDampf);
|
|
EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_DAMPF, defaultTuningStartValueDampf);
|
|
EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_DAMPF, defaultTuningLookBackDampf);
|
|
|
|
// Hostname auf Standard
|
|
strncpy(hostname, defaultHostname.c_str(), sizeof(hostname) - 1); hostname[sizeof(hostname) - 1] = '\0';
|
|
EEPROM.put(EEPROM_ADDR_HOSTNAME, hostname);
|
|
|
|
// Wartungsintervall zurücksetzen
|
|
EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, 0UL);
|
|
EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL, defaultMaintenanceInterval);
|
|
|
|
// Brew Control auf Defaults im EEPROM schreiben
|
|
EEPROM.put(EEPROM_ADDR_BREWBYTIME_ENABLED, defaultBrewByTimeEnabled);
|
|
EEPROM.put(EEPROM_ADDR_BREWBYTIME_SECONDS, defaultBrewByTimeTargetSeconds);
|
|
EEPROM.put(EEPROM_ADDR_PREINF_ENABLED, defaultPreInfusionEnabled);
|
|
EEPROM.put(EEPROM_ADDR_PREINF_DUR_SEC, defaultPreInfusionDurationSeconds);
|
|
EEPROM.put(EEPROM_ADDR_PREINF_PAUSE_SEC, defaultPreInfusionPauseSeconds);
|
|
EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_ENABLED, defaultBrewByWeightEnabled);
|
|
EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_TARGET, defaultBrewByWeightTargetGrams);
|
|
EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_OFFSET, defaultBrewByWeightOffsetGrams);
|
|
EEPROM.put(EEPROM_ADDR_FLOWGUARD_ENABLED, defaultFlowGuardEnabled);
|
|
EEPROM.put(EEPROM_ADDR_FLOWGUARD_MIN_SECONDS, defaultFlowGuardMinBrewSeconds);
|
|
EEPROM.put(EEPROM_ADDR_FLOWGUARD_PULSE_PERIOD_MS, defaultFlowGuardPulsePeriodMs);
|
|
EEPROM.put(EEPROM_ADDR_FLOWGUARD_MIN_DUTY, defaultFlowGuardMinDutyPercent);
|
|
EEPROM.put(EEPROM_ADDR_STEAMBYTIME_ENABLED, defaultSteamByTimeEnabled);
|
|
EEPROM.put(EEPROM_ADDR_STEAMBYTIME_SECONDS, defaultSteamByTimeTargetSeconds);
|
|
|
|
// Piezo auf Default im EEPROM schreiben
|
|
EEPROM.put(EEPROM_ADDR_PIEZO_ENABLED, defaultPiezoEnabled);
|
|
|
|
// Dampfverzögerungs-Override auf Default im EEPROM schreiben
|
|
EEPROM.put(EEPROM_ADDR_STEAM_DELAY_OVERRIDE_SWITCH, defaultSteamDelayOverrideBySwitchEnabled);
|
|
// Eco-Info Anzeige auf Default im EEPROM schreiben
|
|
EEPROM.put(EEPROM_ADDR_ECO_INFO_ON_DISPLAY, defaultEcoInfoOnDisplay);
|
|
EEPROM.put(EEPROM_ADDR_ECO_LIGHT_AUTO_OFF, defaultEcoLightAutoOffEnabled);
|
|
EEPROM.put(EEPROM_ADDR_STEAM_HEAT_DISABLED_ON_STARTUP_WAKE, defaultSteamHeatDisabledOnStartupWake);
|
|
EEPROM.put(EEPROM_ADDR_STANDBY_TIME_ON_DISPLAY, defaultStandbyTimeOnDisplay);
|
|
EEPROM.put(EEPROM_ADDR_X_SWITCH_ACTION, defaultXSwitchAction);
|
|
EEPROM.put(EEPROM_ADDR_X_SWITCH_LONG_ACTION, defaultXSwitchLongAction);
|
|
EEPROM.put(EEPROM_ADDR_BUTTON_LONG_PRESS_MS, (uint16_t)DEFAULT_BUTTON_LONG_PRESS_MS);
|
|
EEPROM.put(EEPROM_ADDR_DASHBOARD_TEMP_DISPLAY_MODE, defaultDashboardTempDisplayMode);
|
|
EEPROM.put(EEPROM_ADDR_CASE_SENSOR_ENABLED, (uint8_t)(defaultCaseSensorEnabled ? 1 : 0));
|
|
EEPROM.put(EEPROM_ADDR_CASE_SENSOR_TYPE, defaultCaseSensorType);
|
|
EEPROM.put(EEPROM_ADDR_CASE_TEMP_DASHBOARD, (uint8_t)(defaultCaseTempOnDashboard ? 1 : 0));
|
|
EEPROM.put(EEPROM_ADDR_CASE_TEMP_ON_DISPLAY, (uint8_t)(defaultCaseTempOnDisplay ? 1 : 0));
|
|
EEPROM.put(EEPROM_ADDR_OFFSET_CASE, defaultOffsetCase);
|
|
EEPROM.put(EEPROM_ADDR_STEAM_HEAT_ON_DRAW, (uint8_t)(defaultSteamHeatOnDraw ? 1 : 0));
|
|
EEPROM.put(EEPROM_ADDR_FLUSH_DURATION_SECONDS, defaultFlushDurationSeconds);
|
|
EEPROM.put(EEPROM_ADDR_STEAM_FLUSH_DURATION_SECONDS, defaultSteamFlushDurationSeconds);
|
|
EEPROM.put(EEPROM_ADDR_BACKLIGHT_ACTIVE_PERCENT, defaultBacklightActivePercent);
|
|
EEPROM.put(EEPROM_ADDR_BACKLIGHT_STANDBY_CLOCK_PERCENT, defaultBacklightStandbyClockPercent);
|
|
EEPROM.put(EEPROM_ADDR_OFFSET_COMPENSATION_ENABLED, (uint8_t)0);
|
|
EEPROM.put(EEPROM_ADDR_SCALE_ENABLED, (uint8_t)(defaultScaleEnabled ? 1 : 0));
|
|
EEPROM.put(EEPROM_ADDR_SCALE_TYPE, defaultScaleType);
|
|
EEPROM.put(EEPROM_ADDR_HX711_CAL_FACTOR, HX711_CALIBRATION_FACTOR);
|
|
EEPROM.put(EEPROM_ADDR_HX711_DISPLAY_SMOOTHING_ENABLED, (uint8_t)(defaultHx711DisplaySmoothingEnabled ? 1 : 0));
|
|
EEPROM.put(EEPROM_ADDR_HEATUP_MINUTES, (int)0);
|
|
EEPROM.put(EEPROM_ADDR_LIGHT_ON, (uint8_t)0);
|
|
setFullyKioskDefaults(fullyKioskConfig);
|
|
saveFullyKioskConfig(fullyKioskConfig);
|
|
|
|
// Magic Value für allgemeine Einstellungen setzen (wichtig!)
|
|
EEPROM.put(EEPROM_ADDR_MAGICVALUE, storageMagicValue);
|
|
|
|
// Alle Änderungen ins EEPROM schreiben
|
|
EEPROM.commit();
|
|
|
|
// WICHTIG: Globale Variablen auch auf Default setzen
|
|
SetpointWasser = defaultSetpointWasser; SetpointDampf = defaultSetpointDampf;
|
|
OffsetWasser = defaultOffsetWasser; OffsetDampf = defaultOffsetDampf;
|
|
OffsetCase = defaultOffsetCase;
|
|
offsetCompensationEnabled = false;
|
|
KpWasser = defaultKpWasser; KiWasser = defaultKiWasser; KdWasser = defaultKdWasser;
|
|
KpDampf = defaultKpDampf; KiDampf = defaultKiDampf; KdDampf = defaultKdDampf;
|
|
ecoModeMinutes = defaultEcoModeMinutes; ecoModeTempWasser = defaultEcoModeTempWasser; ecoModeTempDampf = defaultEcoModeTempDampf;
|
|
dynamicEcoActive = false; fastHeatUpAktiv = false; dampfVerzoegerung = defaultDampfVerzoegerung;
|
|
heatUpMinutes = 0;
|
|
tuningStepWasser = defaultTuningStepWasser; tuningNoiseWasser = defaultTuningNoiseWasser; tuningStartValueWasser = defaultTuningStartValueWasser; tuningLookBackWasser = defaultTuningLookBackWasser;
|
|
tuningStepDampf = defaultTuningStepDampf; tuningNoiseDampf = defaultTuningNoiseDampf; tuningStartValueDampf = defaultTuningStartValueDampf; tuningLookBackDampf = defaultTuningLookBackDampf;
|
|
maintenanceInterval = defaultMaintenanceInterval; maintenanceIntervalCounter = 0;
|
|
boostWasserActive = defaultBoostWasserActive; boostDampfActive = defaultBoostDampfActive;
|
|
preventHeatAboveSetpointWasser = defaultPreventHeatAboveSetpointWasser; preventHeatAboveSetpointDampf = defaultPreventHeatAboveSetpointDampf;
|
|
windowSizeWasser = defaultWindowSizeWasser; windowSizeDampf = defaultWindowSizeDampf;
|
|
strncpy(hostname, defaultHostname.c_str(), sizeof(hostname) - 1); hostname[sizeof(hostname) - 1] = '\0';
|
|
|
|
// Globale Brew Control Variablen auf Defaults setzen
|
|
brewByTimeEnabled = defaultBrewByTimeEnabled; brewByTimeTargetSeconds = defaultBrewByTimeTargetSeconds;
|
|
preInfusionEnabled = defaultPreInfusionEnabled; preInfusionDurationSeconds = defaultPreInfusionDurationSeconds; preInfusionPauseSeconds = defaultPreInfusionPauseSeconds;
|
|
|
|
// Globale Brew-by-Weight Variablen auf Defaults setzen
|
|
brewByWeightEnabled = defaultBrewByWeightEnabled; brewByWeightTargetGrams = defaultBrewByWeightTargetGrams; brewByWeightOffsetGrams = defaultBrewByWeightOffsetGrams;
|
|
flowGuardEnabled = defaultFlowGuardEnabled;
|
|
flowGuardMinBrewSeconds = defaultFlowGuardMinBrewSeconds;
|
|
flowGuardPulsePeriodMs = defaultFlowGuardPulsePeriodMs;
|
|
flowGuardMinDutyPercent = defaultFlowGuardMinDutyPercent;
|
|
flowGuardFilteredFlow = NAN;
|
|
flowGuardLastNetWeight = NAN;
|
|
flowGuardLastSampleMs = 0;
|
|
flowGuardPumpPulseActive = false;
|
|
steamByTimeEnabled = defaultSteamByTimeEnabled; steamByTimeTargetSeconds = defaultSteamByTimeTargetSeconds;
|
|
|
|
// Globale Piezo Variable auf Default setzen
|
|
piezoEnabled = defaultPiezoEnabled;
|
|
|
|
// Globale Variable für Dampfverzögerungs-Override auf Default setzen
|
|
steamDelayOverrideBySwitchEnabled = defaultSteamDelayOverrideBySwitchEnabled;
|
|
// Globale Variable für Eco-Info Anzeige auf Default setzen
|
|
ecoInfoOnDisplay = defaultEcoInfoOnDisplay;
|
|
ecoLightAutoOffEnabled = defaultEcoLightAutoOffEnabled;
|
|
steamHeatDisabledOnStartupWake = defaultSteamHeatDisabledOnStartupWake;
|
|
standbyTimeOnDisplay = defaultStandbyTimeOnDisplay;
|
|
xSwitchAction = defaultXSwitchAction;
|
|
xSwitchLongAction = defaultXSwitchLongAction;
|
|
buttonLongPressMs = (uint16_t)DEFAULT_BUTTON_LONG_PRESS_MS;
|
|
dashboardTempDisplayMode = defaultDashboardTempDisplayMode;
|
|
xSwitchActive = false;
|
|
ecoForcedActive = false;
|
|
standbyOverriddenByWeb = false;
|
|
caseSensorEnabled = defaultCaseSensorEnabled;
|
|
caseSensorType = defaultCaseSensorType;
|
|
OffsetCase = defaultOffsetCase;
|
|
caseTempOnDisplay = defaultCaseTempOnDisplay;
|
|
caseTempOnDashboard = defaultCaseTempOnDashboard;
|
|
steamHeatOnDraw = defaultSteamHeatOnDraw;
|
|
flushDurationSeconds = defaultFlushDurationSeconds;
|
|
steamFlushDurationSeconds = defaultSteamFlushDurationSeconds;
|
|
backlightActivePercent = defaultBacklightActivePercent;
|
|
backlightStandbyClockPercent = defaultBacklightStandbyClockPercent;
|
|
scaleEnabled = defaultScaleEnabled;
|
|
scaleType = defaultScaleType;
|
|
hx711CalibrationFactor = HX711_CALIBRATION_FACTOR;
|
|
hx711DisplaySmoothingEnabled = defaultHx711DisplaySmoothingEnabled;
|
|
scaleConnected = false;
|
|
scaleModeActive = false;
|
|
resetScaleReadingFilters(0.0f);
|
|
brewStartWeight = 0.0f;
|
|
pendingShotStartAfterScaleTare = false;
|
|
hx711ShotTarePrepared = false;
|
|
firstWeightChangeDetected = false;
|
|
lightOn = false;
|
|
digitalWrite(SSR_LIGHT_PIN, LOW);
|
|
standbyModeActive = false;
|
|
InputCase = NAN;
|
|
caseSensorError = false;
|
|
|
|
// PID Limits neu setzen (falls Fenstergröße geändert wurde)
|
|
pidWasser.SetOutputLimits(0, windowSizeWasser);
|
|
pidDampf.SetOutputLimits(0, windowSizeDampf);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler für das Zurücksetzen auf Werkseinstellungen - Heap-Optimiert
|
|
************************************************************************************/
|
|
|
|
// PROGMEM Chunks für die Seite
|
|
static const char resetDoneHtmlStart[] PROGMEM = R"rawliteral(
|
|
<!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ückgesetzt. Das Gerät wird neu gestartet...</p></form>
|
|
</body></html>
|
|
)rawliteral";
|
|
|
|
// Überarbeitete Funktion
|
|
void handleResetDefaults(AsyncWebServerRequest *request) {
|
|
if (!demoModus) {
|
|
resetToDefaults();
|
|
} else {
|
|
request->send(403, "text/plain", "Reset im Demo-Modus nicht erlaubt.");
|
|
return;
|
|
}
|
|
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html");
|
|
response->write(resetDoneHtmlStart, strlen_P(resetDoneHtmlStart));
|
|
response->write(commonStyle, strlen_P(commonStyle));
|
|
response->write(resetDoneHtmlHeadEnd, strlen_P(resetDoneHtmlHeadEnd));
|
|
response->write(commonNav, strlen_P(commonNav));
|
|
response->write(resetDoneHtmlBody, strlen_P(resetDoneHtmlBody));
|
|
request->send(response);
|
|
|
|
scheduleRestart(5000);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Piezo-Hilfsfunktionen
|
|
************************************************************************************/
|
|
|
|
// Erzeugt einen kurzen Piepton
|
|
void beepShort(int frequency = 3000, int duration = 1000) {
|
|
if (!piezoEnabled) return;
|
|
tone(PIEZO_PIN, frequency, duration);
|
|
// delay(duration + 10); // Kurze Pause danach, falls nötig, aber `tone` mit Dauer ist blockierend
|
|
}
|
|
|
|
// Spezifischer Piepton für die Wartungserinnerung
|
|
void beepMaintenanceAlert(int frequency = 3000, int duration = 3000) {
|
|
if (!piezoEnabled) return;
|
|
tone(PIEZO_PIN, frequency, duration);
|
|
// delay(duration + 10); // Kurze Pause danach, falls nötig, aber `tone` mit Dauer ist blockierend
|
|
}
|
|
|
|
|
|
/************************************************************************************
|
|
* ESP-NOW Empfangsfunktion
|
|
************************************************************************************/
|
|
/**
|
|
* @brief Callback-Funktion, die bei Empfang einer ESP-NOW Nachricht aufgerufen wird.
|
|
* Verarbeitet die Daten der Waage.
|
|
* KORREKTE SIGNATUR FÜR AKTUELLE ESP32-CORES
|
|
*/
|
|
void OnDataRecv(const esp_now_recv_info * info, const uint8_t *incomingData, int len) {
|
|
recordResetCheckpoint(RESET_CP_ESPNOW_RECV);
|
|
if (!scaleEnabled || scaleType != SCALE_ESPNOW) {
|
|
return;
|
|
}
|
|
|
|
// Der erste Parameter ist jetzt eine Struktur, wir ignorieren sie aber, da wir nur die Daten brauchen.
|
|
// Die MAC-Adresse wäre z.B. über "info->src_addr" erreichbar.
|
|
|
|
// Prüfen, ob die Länge der Nachricht der erwarteten Struktur entspricht
|
|
if (len == sizeof(struct_espnow_scale_message)) {
|
|
struct_espnow_scale_message receivedData;
|
|
memcpy(&receivedData, incomingData, sizeof(receivedData));
|
|
|
|
// Globale Variablen aktualisieren
|
|
bool wasScaleConnected = scaleConnected;
|
|
lastScaleMessageTime = millis(); // Zeit des letzten Empfangs für Timeout merken
|
|
bool forceDisplayFilter = !wasScaleConnected;
|
|
bool forceSanitizer = !wasScaleConnected;
|
|
|
|
// Verbindung als hergestellt markieren, falls sie es noch nicht war
|
|
if (!scaleConnected) {
|
|
scaleConnected = true;
|
|
// Serial.println("INFO: ESP-NOW Waage verbunden und erste Daten empfangen.");
|
|
}
|
|
|
|
// Status-Flags auswerten
|
|
if (receivedData.status_flags & ESPNOW_SCALE_FLAG_TOGGLE_MODE) {
|
|
scaleModeActive = !scaleModeActive;
|
|
if (scaleModeActive) {
|
|
forceDisplayFilter = true;
|
|
}
|
|
// Serial.print("INFO: Waagen-Anzeigemodus via ESP-NOW umgeschaltet: ");
|
|
// Serial.println(scaleModeActive ? "AN" : "AUS");
|
|
if (piezoEnabled) {
|
|
beepShort(scaleModeActive ? 3500 : 3200, 70); // Unterschiedliche Töne für an/aus
|
|
}
|
|
}
|
|
if (receivedData.status_flags & ESPNOW_SCALE_FLAG_JUST_TARED) {
|
|
forceSanitizer = true;
|
|
forceDisplayFilter = true;
|
|
// Serial.println("INFO: Waage wurde via ESP-NOW tariert.");
|
|
if (piezoEnabled) {
|
|
beepShort(2800, 50); // Bestätigungston
|
|
}
|
|
}
|
|
if (receivedData.status_flags & ESPNOW_SCALE_FLAG_AWOKE) {
|
|
// Serial.println("INFO: ESP-NOW Waage ist aus dem Schlaf aufgewacht.");
|
|
}
|
|
acceptScaleReading(receivedData.weight_g, forceSanitizer, forceDisplayFilter);
|
|
}
|
|
}
|
|
|
|
|
|
void resetScaleState() {
|
|
scaleConnected = false;
|
|
scaleModeActive = false;
|
|
resetScaleReadingFilters(0.0f);
|
|
brewStartWeight = 0.0f;
|
|
firstWeightChangeDetected = false;
|
|
tareScaleAfterDelay = false;
|
|
tareScaleSettling = false;
|
|
startupSilentTarePending = false;
|
|
pendingShotStartAfterScaleTare = false;
|
|
hx711ShotTarePrepared = false;
|
|
hx711ShotPostTareReadsRemaining = 0;
|
|
scaleButtonIsCurrentlyPressed = false;
|
|
lastScaleMessageTime = 0;
|
|
hx711LastReadyTime = 0;
|
|
hx711LastReadAttemptTime = 0;
|
|
hx711HasValidReading = false;
|
|
hx711NotReadyCounter = 0;
|
|
}
|
|
|
|
void deinitScale() {
|
|
if (scaleType == SCALE_ESPNOW) {
|
|
esp_now_deinit();
|
|
} else if (scaleType == SCALE_HX711) {
|
|
hx711.power_down();
|
|
}
|
|
resetScaleState();
|
|
}
|
|
|
|
void initScale() {
|
|
resetScaleState();
|
|
if (!scaleEnabled || scaleType == SCALE_NONE) {
|
|
return;
|
|
}
|
|
if (scaleType == SCALE_I2C) {
|
|
if (!scales.begin(&Wire)) {
|
|
scaleConnected = false;
|
|
} else {
|
|
scaleConnected = true;
|
|
}
|
|
} else if (scaleType == SCALE_ESPNOW) {
|
|
esp_now_deinit();
|
|
if (esp_now_init() == ESP_OK) {
|
|
esp_now_register_recv_cb(OnDataRecv);
|
|
}
|
|
} else if (scaleType == SCALE_HX711) {
|
|
hx711.begin(HX711_DT_PIN, HX711_SCK_PIN);
|
|
hx711.power_up();
|
|
hx711.set_scale(hx711CalibrationFactor);
|
|
unsigned long waitStart = millis();
|
|
while (!hx711.is_ready() && (millis() - waitStart < 1000)) {
|
|
delay(10);
|
|
yield();
|
|
}
|
|
if (hx711.is_ready()) {
|
|
hx711.tare();
|
|
resetScaleReadingFilters(0.0f);
|
|
hx711LastReadyTime = millis();
|
|
hx711HasValidReading = true;
|
|
hx711NotReadyCounter = 0;
|
|
scaleConnected = true;
|
|
}
|
|
}
|
|
}
|
|
/************************************************************************************
|
|
* Variable für Systemstartzeit (relevant für die Dampf-Verzögerung) - Unverändert
|
|
************************************************************************************/
|
|
|
|
void updateHx711ReadingState() {
|
|
recordResetCheckpoint(RESET_CP_SCALE_HX711_READ);
|
|
unsigned long nowMs = millis();
|
|
if (nowMs - hx711LastReadAttemptTime < HX711_MIN_READ_INTERVAL) {
|
|
return;
|
|
}
|
|
hx711LastReadAttemptTime = nowMs;
|
|
|
|
if (hx711.is_ready()) {
|
|
acceptScaleReading(hx711.get_units(1));
|
|
hx711LastReadyTime = nowMs;
|
|
hx711HasValidReading = true;
|
|
hx711NotReadyCounter = 0;
|
|
scaleConnected = true;
|
|
return;
|
|
}
|
|
|
|
if (hx711HasValidReading) {
|
|
if (hx711NotReadyCounter < 255) {
|
|
hx711NotReadyCounter++;
|
|
}
|
|
if (hx711NotReadyCounter >= HX711_NOT_READY_LIMIT) {
|
|
scaleConnected = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
unsigned long startupTime;
|
|
|
|
void applyCleaningAssistantTemperatureTargets() {
|
|
SetpointWasser = cleaningAssistantWaterSetpoint;
|
|
steamHeatDisabledByUser = true;
|
|
OutputDampf = 0;
|
|
}
|
|
|
|
static void applySteamHeatDisabledOnStartupWakeIfConfigured() {
|
|
if (steamHeatDisabledOnStartupWake) {
|
|
steamHeatDisabledByUser = true;
|
|
OutputDampf = 0;
|
|
}
|
|
}
|
|
|
|
static void initMomentaryButtonState(MomentaryButtonState& button, bool initialPressed, unsigned long currentMillis) {
|
|
button.stablePressed = initialPressed;
|
|
button.lastRawPressed = initialPressed;
|
|
button.lastChangeMs = currentMillis;
|
|
button.lastStablePressed = initialPressed;
|
|
button.pressedAt = initialPressed ? currentMillis : 0;
|
|
button.longPressHandled = false;
|
|
}
|
|
|
|
static void updateMomentaryButtonState(
|
|
MomentaryButtonState& button,
|
|
bool rawPressed,
|
|
unsigned long debounceMs,
|
|
unsigned long currentMillis
|
|
) {
|
|
if (rawPressed != button.lastRawPressed) {
|
|
button.lastRawPressed = rawPressed;
|
|
button.lastChangeMs = currentMillis;
|
|
}
|
|
if (currentMillis - button.lastChangeMs >= debounceMs) {
|
|
button.stablePressed = button.lastRawPressed;
|
|
}
|
|
}
|
|
|
|
static bool buttonJustPressed(MomentaryButtonState& button, unsigned long currentMillis) {
|
|
if (button.stablePressed && !button.lastStablePressed) {
|
|
button.pressedAt = currentMillis;
|
|
button.longPressHandled = false;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static bool buttonJustReleased(const MomentaryButtonState& button) {
|
|
return !button.stablePressed && button.lastStablePressed;
|
|
}
|
|
|
|
static bool buttonLongPressReady(const MomentaryButtonState& button, unsigned long holdMs, unsigned long currentMillis) {
|
|
return button.stablePressed &&
|
|
button.pressedAt > 0 &&
|
|
!button.longPressHandled &&
|
|
(currentMillis - button.pressedAt >= holdMs);
|
|
}
|
|
|
|
static void finalizeMomentaryButtonState(MomentaryButtonState& button) {
|
|
if (!button.stablePressed) {
|
|
button.pressedAt = 0;
|
|
button.longPressHandled = false;
|
|
}
|
|
button.lastStablePressed = button.stablePressed;
|
|
}
|
|
|
|
static bool invokeDashboardAction(const String& action, const String& value = "") {
|
|
bool success = false;
|
|
String message = "";
|
|
bool settingsChanged = false;
|
|
bool pidNeedsUpdate = false;
|
|
executeDashboardAction(action, value, success, message, settingsChanged, pidNeedsUpdate);
|
|
return success;
|
|
}
|
|
|
|
static void executeXSwitchAction(uint8_t action) {
|
|
if (action == X_SWITCH_ACTION_MAINTENANCE) {
|
|
if (wartungsModusAktiv) {
|
|
wartungsModusAktiv = false;
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
} else {
|
|
wartungsModusAktiv = true;
|
|
SetpointWasser = wartungsModusTemp;
|
|
SetpointDampf = wartungsModusTemp;
|
|
}
|
|
} else if (action == X_SWITCH_ACTION_FAST_HEAT_UP) {
|
|
if (fastHeatUpAktiv) {
|
|
fastHeatUpAktiv = false;
|
|
fastHeatUpHeating = false;
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
} else {
|
|
fastHeatUpAktiv = true;
|
|
fastHeatUpHeating = true;
|
|
}
|
|
} else if (action == X_SWITCH_ACTION_STEAM_DELAY_OVERRIDE) {
|
|
steamDelayOverridden = !steamDelayOverridden;
|
|
} else if (action == X_SWITCH_ACTION_SCALE_MODE) {
|
|
bool newScaleModeState = !scaleModeActive;
|
|
if (newScaleModeState && scaleEnabled && scaleType == SCALE_HX711) {
|
|
requestHx711Tare(false, true);
|
|
} else {
|
|
scaleModeActive = newScaleModeState;
|
|
if (scaleModeActive) {
|
|
resetScaleDisplayFilter(currentWeightReading);
|
|
}
|
|
}
|
|
} else if (action == X_SWITCH_ACTION_DISABLE_STEAM_HEAT) {
|
|
steamHeatDisabledByUser = !steamHeatDisabledByUser;
|
|
if (steamHeatDisabledByUser) {
|
|
OutputDampf = 0;
|
|
}
|
|
} else if (action == X_SWITCH_ACTION_TARE_SCALE) {
|
|
invokeDashboardAction("tareScale");
|
|
}
|
|
}
|
|
|
|
static bool isXSwitchActionActive(uint8_t action) {
|
|
if (action == X_SWITCH_ACTION_MAINTENANCE) {
|
|
return wartungsModusAktiv;
|
|
}
|
|
if (action == X_SWITCH_ACTION_FAST_HEAT_UP) {
|
|
return fastHeatUpAktiv;
|
|
}
|
|
if (action == X_SWITCH_ACTION_STEAM_DELAY_OVERRIDE) {
|
|
return steamDelayOverridden;
|
|
}
|
|
if (action == X_SWITCH_ACTION_SCALE_MODE) {
|
|
return scaleModeActive;
|
|
}
|
|
if (action == X_SWITCH_ACTION_DISABLE_STEAM_HEAT) {
|
|
return steamHeatDisabledByUser;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static bool isHx711TareSequenceActive() {
|
|
return scaleEnabled &&
|
|
scaleType == SCALE_HX711 &&
|
|
(tareScaleAfterDelay || tareScaleSettling);
|
|
}
|
|
|
|
static void requestHx711Tare(bool prepareShotStart, bool keepScaleModeActive) {
|
|
if (!scaleEnabled || scaleType != SCALE_HX711) {
|
|
return;
|
|
}
|
|
|
|
if (!(scaleConnected || hx711.is_ready() || hx711HasValidReading)) {
|
|
scaleModeActive = keepScaleModeActive;
|
|
return;
|
|
}
|
|
|
|
pendingShotStartAfterScaleTare = prepareShotStart;
|
|
if (!prepareShotStart) {
|
|
hx711ShotTarePrepared = false;
|
|
}
|
|
hx711ShotPostTareReadsRemaining = 0;
|
|
scaleModeActive = keepScaleModeActive;
|
|
|
|
if (!tareScaleAfterDelay && !tareScaleSettling) {
|
|
tareScaleAfterDelay = true;
|
|
tareScaleDelayStartTime = millis();
|
|
}
|
|
}
|
|
|
|
static uint8_t standbyTimerWeekdayBitFromTmWday(int tmWday) {
|
|
return (tmWday == 0) ? 6 : (uint8_t)(tmWday - 1); // Montag = 0, Sonntag = 6
|
|
}
|
|
|
|
static int standbyTimerDateKeyFromTm(const tm& timeinfo) {
|
|
return (timeinfo.tm_year + 1900) * 10000 + (timeinfo.tm_mon + 1) * 100 + timeinfo.tm_mday;
|
|
}
|
|
|
|
static String standbyTimerActionLabel(uint8_t action) {
|
|
return (action == 0) ? "Standby aktivieren" : "Standby aufheben";
|
|
}
|
|
|
|
static String standbyTimerWeekdaysLabel(uint8_t weekdaysMask) {
|
|
static const char* labels[7] = {"Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"};
|
|
if (weekdaysMask == 0x7F) {
|
|
return "Taeglich";
|
|
}
|
|
|
|
String result;
|
|
for (uint8_t i = 0; i < 7; ++i) {
|
|
if ((weekdaysMask & (1U << i)) == 0) {
|
|
continue;
|
|
}
|
|
if (result.length() > 0) {
|
|
result += ", ";
|
|
}
|
|
result += labels[i];
|
|
}
|
|
return result.length() > 0 ? result : "Kein Wochentag";
|
|
}
|
|
|
|
static StandbyTimerEntry* findStandbyTimerById(uint32_t id) {
|
|
for (size_t i = 0; i < standbyTimers.size(); ++i) {
|
|
if (standbyTimers[i].id == id) {
|
|
return &standbyTimers[i];
|
|
}
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
static void sortStandbyTimers() {
|
|
std::sort(standbyTimers.begin(), standbyTimers.end(), [](const StandbyTimerEntry& a, const StandbyTimerEntry& b) {
|
|
const int aTotalMinutes = (int)a.hour * 60 + (int)a.minute;
|
|
const int bTotalMinutes = (int)b.hour * 60 + (int)b.minute;
|
|
if (aTotalMinutes != bTotalMinutes) {
|
|
return aTotalMinutes < bTotalMinutes;
|
|
}
|
|
if (a.action != b.action) {
|
|
return a.action < b.action;
|
|
}
|
|
return a.id < b.id;
|
|
});
|
|
}
|
|
|
|
bool saveStandbyTimers() {
|
|
struct StandbyTimersFileHeader {
|
|
uint32_t magic;
|
|
uint16_t version;
|
|
uint16_t count;
|
|
uint32_t nextId;
|
|
};
|
|
struct PersistedStandbyTimerEntry {
|
|
uint32_t id;
|
|
uint8_t hour;
|
|
uint8_t minute;
|
|
uint8_t weekdaysMask;
|
|
uint8_t action;
|
|
uint8_t enabled;
|
|
};
|
|
|
|
sortStandbyTimers();
|
|
if (LittleFS.exists(standbyTimersFilePath)) {
|
|
LittleFS.remove(standbyTimersFilePath);
|
|
}
|
|
File file = LittleFS.open(standbyTimersFilePath, FILE_WRITE);
|
|
if (!file) {
|
|
return false;
|
|
}
|
|
|
|
StandbyTimersFileHeader header;
|
|
header.magic = STANDBY_TIMERS_FILE_MAGIC;
|
|
header.version = STANDBY_TIMERS_FILE_VERSION;
|
|
header.count = (uint16_t)standbyTimers.size();
|
|
header.nextId = nextStandbyTimerId;
|
|
|
|
if (file.write((const uint8_t*)&header, sizeof(header)) != sizeof(header)) {
|
|
file.close();
|
|
return false;
|
|
}
|
|
|
|
for (size_t i = 0; i < standbyTimers.size(); ++i) {
|
|
PersistedStandbyTimerEntry persisted;
|
|
persisted.id = standbyTimers[i].id;
|
|
persisted.hour = standbyTimers[i].hour;
|
|
persisted.minute = standbyTimers[i].minute;
|
|
persisted.weekdaysMask = standbyTimers[i].weekdaysMask;
|
|
persisted.action = standbyTimers[i].action;
|
|
persisted.enabled = standbyTimers[i].enabled ? 1 : 0;
|
|
if (file.write((const uint8_t*)&persisted, sizeof(persisted)) != sizeof(persisted)) {
|
|
file.close();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
file.flush();
|
|
file.close();
|
|
return true;
|
|
}
|
|
|
|
bool loadStandbyTimers() {
|
|
standbyTimers.clear();
|
|
nextStandbyTimerId = 1;
|
|
|
|
if (!LittleFS.exists(standbyTimersFilePath)) {
|
|
return true;
|
|
}
|
|
|
|
struct StandbyTimersFileHeader {
|
|
uint32_t magic;
|
|
uint16_t version;
|
|
uint16_t count;
|
|
uint32_t nextId;
|
|
};
|
|
struct PersistedStandbyTimerEntry {
|
|
uint32_t id;
|
|
uint8_t hour;
|
|
uint8_t minute;
|
|
uint8_t weekdaysMask;
|
|
uint8_t action;
|
|
uint8_t enabled;
|
|
};
|
|
|
|
File file = LittleFS.open(standbyTimersFilePath, FILE_READ);
|
|
if (!file) {
|
|
return false;
|
|
}
|
|
|
|
StandbyTimersFileHeader header;
|
|
if (file.read((uint8_t*)&header, sizeof(header)) != sizeof(header) ||
|
|
header.magic != STANDBY_TIMERS_FILE_MAGIC ||
|
|
header.version != STANDBY_TIMERS_FILE_VERSION) {
|
|
file.close();
|
|
return false;
|
|
}
|
|
|
|
nextStandbyTimerId = (header.nextId == 0) ? 1 : header.nextId;
|
|
for (uint16_t i = 0; i < header.count; ++i) {
|
|
PersistedStandbyTimerEntry persisted;
|
|
if (file.read((uint8_t*)&persisted, sizeof(persisted)) != sizeof(persisted)) {
|
|
file.close();
|
|
return false;
|
|
}
|
|
StandbyTimerEntry timer;
|
|
timer.id = persisted.id;
|
|
timer.hour = min((int)persisted.hour, 23);
|
|
timer.minute = min((int)persisted.minute, 59);
|
|
timer.weekdaysMask = persisted.weekdaysMask & 0x7F;
|
|
timer.action = (persisted.action == 0) ? 0 : 1;
|
|
timer.enabled = (persisted.enabled != 0);
|
|
timer.lastTriggeredDateKey = -1;
|
|
standbyTimers.push_back(timer);
|
|
if (timer.id >= nextStandbyTimerId) {
|
|
nextStandbyTimerId = timer.id + 1;
|
|
}
|
|
}
|
|
|
|
file.close();
|
|
sortStandbyTimers();
|
|
return true;
|
|
}
|
|
|
|
void evaluateStandbyTimers() {
|
|
if (!timeSynced || standbyTimers.empty()) {
|
|
return;
|
|
}
|
|
|
|
time_t now_t;
|
|
time(&now_t);
|
|
struct tm localTime;
|
|
localtime_r(&now_t, &localTime);
|
|
if (localTime.tm_year < (2024 - 1900)) {
|
|
return;
|
|
}
|
|
|
|
const uint8_t weekdayBit = standbyTimerWeekdayBitFromTmWday(localTime.tm_wday);
|
|
const uint8_t weekdayMask = (uint8_t)(1U << weekdayBit);
|
|
const int dateKey = standbyTimerDateKeyFromTm(localTime);
|
|
|
|
for (size_t i = 0; i < standbyTimers.size(); ++i) {
|
|
StandbyTimerEntry& timer = standbyTimers[i];
|
|
if (!timer.enabled || timer.weekdaysMask == 0) {
|
|
continue;
|
|
}
|
|
if ((timer.weekdaysMask & weekdayMask) == 0) {
|
|
continue;
|
|
}
|
|
if (timer.hour != localTime.tm_hour || timer.minute != localTime.tm_min) {
|
|
continue;
|
|
}
|
|
if (timer.lastTriggeredDateKey == dateKey) {
|
|
continue;
|
|
}
|
|
|
|
standbyModeActive = (timer.action == 0);
|
|
timer.lastTriggeredDateKey = dateKey;
|
|
}
|
|
}
|
|
|
|
bool canStartCleaningAssistant(String& reason) {
|
|
if (cleaningAssistantActive) {
|
|
reason = "Reinigungsassistent läuft bereits.";
|
|
return false;
|
|
}
|
|
if (standbyModeActive) {
|
|
reason = "Standby aktiv - Start nicht moeglich.";
|
|
return false;
|
|
}
|
|
if (wartungsModusAktiv || autoTuneWasserActive || autoTuneDampfActive) {
|
|
reason = "Start im Wartungsmodus/PID-Tuning nicht moeglich.";
|
|
return false;
|
|
}
|
|
if (fastHeatUpAktiv || fastHeatUpHeating) {
|
|
reason = "Start im Fast-Heat-Up nicht moeglich.";
|
|
return false;
|
|
}
|
|
if (shotActive || shotSoftwareActive) {
|
|
reason = "Bezug aktiv - Start nicht moeglich.";
|
|
return false;
|
|
}
|
|
if (flushActive || steamFlushActive) {
|
|
reason = "Flush aktiv - Start nicht moeglich.";
|
|
return false;
|
|
}
|
|
if (steamCircuitActive || steamCircuitSoftwareActive) {
|
|
reason = "Dampfkreis aktiv - Start nicht moeglich.";
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void stopCleaningAssistant(const String& statusMessage = "") {
|
|
cleaningAssistantActive = false;
|
|
cleaningAssistantWaitingForTemperature = false;
|
|
cleaningAssistantInBrewPhase = false;
|
|
cleaningAssistantCurrentCycle = 0;
|
|
cleaningAssistantPhaseStartTime = 0;
|
|
if (standbyModeActive) {
|
|
SetpointWasser = 0;
|
|
SetpointDampf = 0;
|
|
} else {
|
|
SetpointWasser = cleaningAssistantPreviousSetpointWasser;
|
|
SetpointDampf = cleaningAssistantPreviousSetpointDampf;
|
|
}
|
|
steamHeatDisabledByUser = cleaningAssistantPreviousSteamHeatDisabled;
|
|
digitalWrite(PUMP_PIN, LOW);
|
|
digitalWrite(VALVE_PIN, LOW);
|
|
cleaningAssistantStatusMessage = statusMessage;
|
|
}
|
|
|
|
void startCleaningAssistant() {
|
|
cleaningAssistantPreviousSetpointWasser = SetpointWasser;
|
|
cleaningAssistantPreviousSetpointDampf = SetpointDampf;
|
|
cleaningAssistantPreviousSteamHeatDisabled = steamHeatDisabledByUser;
|
|
cleaningAssistantActive = true;
|
|
cleaningAssistantWaitingForTemperature = (!isfinite(InputWasser) || (InputWasser < (cleaningAssistantWaterSetpoint - cleaningAssistantWaterReadyTolerance)));
|
|
cleaningAssistantInBrewPhase = !cleaningAssistantWaitingForTemperature;
|
|
cleaningAssistantCurrentCycle = cleaningAssistantWaitingForTemperature ? 0 : 1;
|
|
cleaningAssistantPhaseStartTime = millis();
|
|
scaleModeActive = false;
|
|
applyCleaningAssistantTemperatureTargets();
|
|
digitalWrite(PUMP_PIN, cleaningAssistantWaitingForTemperature ? LOW : HIGH);
|
|
digitalWrite(VALVE_PIN, cleaningAssistantWaitingForTemperature ? LOW : HIGH);
|
|
cleaningAssistantStatusMessage = "";
|
|
}
|
|
|
|
void resetFlowGuardRuntime() {
|
|
flowGuardFilteredFlow = NAN;
|
|
flowGuardLastNetWeight = NAN;
|
|
flowGuardLastSampleMs = 0;
|
|
flowGuardPumpPulseActive = false;
|
|
}
|
|
|
|
void updateFlowGuardFlowEstimate(float netWeight, unsigned long nowMs) {
|
|
if (!isfinite(netWeight)) {
|
|
return;
|
|
}
|
|
|
|
if (flowGuardLastSampleMs == 0 || !isfinite(flowGuardLastNetWeight)) {
|
|
flowGuardLastSampleMs = nowMs;
|
|
flowGuardLastNetWeight = netWeight;
|
|
return;
|
|
}
|
|
|
|
unsigned long dtMs = nowMs - flowGuardLastSampleMs;
|
|
if (dtMs < 80UL) {
|
|
return;
|
|
}
|
|
|
|
float dtSec = dtMs / 1000.0f;
|
|
float dWeight = netWeight - flowGuardLastNetWeight;
|
|
float instantFlow = (dtSec > 0.0f) ? (dWeight / dtSec) : 0.0f;
|
|
if (!isfinite(instantFlow) || instantFlow < 0.0f) {
|
|
instantFlow = 0.0f;
|
|
}
|
|
|
|
if (!isfinite(flowGuardFilteredFlow)) {
|
|
flowGuardFilteredFlow = instantFlow;
|
|
} else {
|
|
flowGuardFilteredFlow = (0.25f * instantFlow) + (0.75f * flowGuardFilteredFlow);
|
|
}
|
|
|
|
flowGuardLastSampleMs = nowMs;
|
|
flowGuardLastNetWeight = netWeight;
|
|
}
|
|
|
|
void applyFlowGuardPumpControl(unsigned long currentShotTimeMillis, float netWeight, float targetWeightToStopAt) {
|
|
if (currentPreInfusionState != PI_MAIN_BREW) {
|
|
flowGuardPumpPulseActive = false;
|
|
return;
|
|
}
|
|
|
|
bool guardCanRun =
|
|
flowGuardEnabled &&
|
|
brewByWeightEnabled &&
|
|
scaleEnabled &&
|
|
scaleConnected &&
|
|
!wartungsModusAktiv &&
|
|
isfinite(targetWeightToStopAt) &&
|
|
(targetWeightToStopAt > 0.0f);
|
|
|
|
if (!guardCanRun) {
|
|
flowGuardPumpPulseActive = false;
|
|
digitalWrite(PUMP_PIN, HIGH);
|
|
digitalWrite(VALVE_PIN, HIGH);
|
|
return;
|
|
}
|
|
|
|
unsigned long nowMs = millis();
|
|
updateFlowGuardFlowEstimate(netWeight, nowMs);
|
|
|
|
float elapsedSec = currentShotTimeMillis / 1000.0f;
|
|
float remainingWeight = targetWeightToStopAt - netWeight;
|
|
if (elapsedSec >= flowGuardMinBrewSeconds || remainingWeight <= 0.0f) {
|
|
flowGuardPumpPulseActive = false;
|
|
digitalWrite(PUMP_PIN, HIGH);
|
|
digitalWrite(VALVE_PIN, HIGH);
|
|
return;
|
|
}
|
|
|
|
float flowEstimate = flowGuardFilteredFlow;
|
|
if (!isfinite(flowEstimate) || flowEstimate < 0.05f) {
|
|
flowGuardPumpPulseActive = false;
|
|
digitalWrite(PUMP_PIN, HIGH);
|
|
digitalWrite(VALVE_PIN, HIGH);
|
|
return;
|
|
}
|
|
|
|
float remainingTimeToMin = flowGuardMinBrewSeconds - elapsedSec;
|
|
if (remainingTimeToMin <= 0.0f) {
|
|
flowGuardPumpPulseActive = false;
|
|
digitalWrite(PUMP_PIN, HIGH);
|
|
digitalWrite(VALVE_PIN, HIGH);
|
|
return;
|
|
}
|
|
|
|
float requiredAvgFlow = remainingWeight / remainingTimeToMin;
|
|
if (!isfinite(requiredAvgFlow) || requiredAvgFlow <= 0.0f) {
|
|
flowGuardPumpPulseActive = false;
|
|
digitalWrite(PUMP_PIN, HIGH);
|
|
digitalWrite(VALVE_PIN, HIGH);
|
|
return;
|
|
}
|
|
|
|
float minDuty = flowGuardMinDutyPercent / 100.0f;
|
|
if (!isfinite(minDuty)) {
|
|
minDuty = defaultFlowGuardMinDutyPercent / 100.0f;
|
|
}
|
|
minDuty = constrain(minDuty, FLOW_GUARD_MIN_DUTY_MIN / 100.0f, FLOW_GUARD_MIN_DUTY_MAX / 100.0f);
|
|
|
|
float duty = requiredAvgFlow / flowEstimate;
|
|
duty = constrain(duty, minDuty, 1.0f);
|
|
|
|
uint16_t periodMs = flowGuardPulsePeriodMs;
|
|
if (periodMs < FLOW_GUARD_PULSE_PERIOD_MIN_MS || periodMs > FLOW_GUARD_PULSE_PERIOD_MAX_MS || duty >= 0.999f) {
|
|
flowGuardPumpPulseActive = false;
|
|
digitalWrite(PUMP_PIN, HIGH);
|
|
digitalWrite(VALVE_PIN, HIGH);
|
|
return;
|
|
}
|
|
|
|
unsigned long onTimeMs = (unsigned long)((float)periodMs * duty);
|
|
if (onTimeMs < 1UL) {
|
|
onTimeMs = 1UL;
|
|
}
|
|
if (onTimeMs > periodMs) {
|
|
onTimeMs = periodMs;
|
|
}
|
|
|
|
unsigned long phaseMs = currentShotTimeMillis % periodMs;
|
|
bool pumpOn = (phaseMs < onTimeMs);
|
|
flowGuardPumpPulseActive = (duty < 0.999f);
|
|
|
|
digitalWrite(VALVE_PIN, HIGH);
|
|
digitalWrite(PUMP_PIN, pumpOn ? HIGH : LOW);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Stoppt den Brühvorgang (Pumpe/Ventil) und verarbeitet Shot-Daten.
|
|
* Wird von updateShotTimer() oder aus loop() (BrewByTime/Weight) aufgerufen.
|
|
************************************************************************************/
|
|
void stopBrewSequence(unsigned long durationMillis, bool manualStop = false, bool stoppedByWeight = false) {
|
|
if (!shotActive) return; // Verhindert mehrfaches Ausführen
|
|
|
|
lastShotDurationMillis = durationMillis; // Dauer speichern
|
|
shotEndTime = millis(); // Endzeitpunkt speichern
|
|
lastShotFirstWeightChangeTime = firstWeightChangeTime;
|
|
lastShotStartTime = shotStartTime;
|
|
firstWeightChangeDetected = false;
|
|
firstWeightChangeTime = 0;
|
|
|
|
// Wenn durch Gewicht gestoppt, das finale Gewicht für die Anzeige speichern
|
|
lastShotStoppedByWeight = stoppedByWeight; // fuer P4-Endgewicht inkl. Offset
|
|
if (stoppedByWeight) {
|
|
lastShotFinalNetWeight = currentWeightReading - brewStartWeight;
|
|
}
|
|
|
|
// Statusvariablen zurücksetzen
|
|
shotActive = false;
|
|
shotSoftwareActive = false;
|
|
currentPreInfusionState = PI_INACTIVE;
|
|
lastShotTime = millis(); // Wichtig für Eco-Modus
|
|
resetFlowGuardRuntime();
|
|
|
|
// Hardware stoppen
|
|
digitalWrite(PUMP_PIN, LOW);
|
|
digitalWrite(VALVE_PIN, LOW); // Ventil öffnen -> Druck ablassen
|
|
|
|
if (!manualStop) {
|
|
beepShort();
|
|
}
|
|
|
|
// Shot zählen und loggen (nur wenn > 20 Sekunden)
|
|
if (durationMillis > 20000 && !wartungsModusAktiv) {
|
|
// Langsames Speichern wird in die Hauptschleife ausgelagert.
|
|
// Hier werden jetzt alle relevanten Daten für den Log zwischengespeichert.
|
|
shotNeedsToBeSaved = true;
|
|
savedShotDuration = durationMillis;
|
|
savedShotWasByWeight = stoppedByWeight; // Kennzeichnen, ob der Bezug gewichtsbasierend war
|
|
if(stoppedByWeight) {
|
|
savedShotFinalWeight = lastShotFinalNetWeight + brewByWeightOffsetGrams; ; // Gewicht (inkl. Offset) für den Log merken
|
|
} else {
|
|
savedShotFinalWeight = 0.0f; // Sicherstellen, dass kein altes Gewicht geloggt wird
|
|
}
|
|
}
|
|
|
|
// ZUSTANDSMASCHINE STARTEN (JETZT KONDITIONAL)
|
|
if (stoppedByWeight) {
|
|
postShotDisplayState = POST_SHOT_SHOW_WEIGHT_RESULT; // Setze neuen Zustand für Gewichtsanzeige
|
|
} else {
|
|
postShotDisplayState = POST_SHOT_SHOW_DURATION; // Setze alten Zustand für Zeit/manuell
|
|
}
|
|
|
|
// Fast-Heat-Up eventuell deaktivieren
|
|
fastHeatUpHeating = false;
|
|
|
|
}
|
|
|
|
/************************************************************************************
|
|
* setup(): Initialisierung des Systems
|
|
* - Angepasst für optionale Display-Unterstützung via ENABLE_DISPLAY
|
|
* - Korrekte Wire.begin() Logik für Display/Waage
|
|
* - Zusätzliche Serial-Ausgaben für Headless-Betrieb
|
|
************************************************************************************/
|
|
void setup() {
|
|
Serial.begin(115200);
|
|
initResetDiagnostics();
|
|
#if TOUCH_UART_ENABLED
|
|
// TX-Puffer >= max. Zeilenlaenge (State-JSON bis TOUCH_UART_JSON_BUFFER_SIZE=3000
|
|
// + "\r\n"), damit println() die Hauptschleife NIE blockiert (sonst bis ~41 ms
|
|
// Stall -> PID-Jitter). Headroom deckt zudem OTA-Chunks (~1,4 KB) und gestaute
|
|
// Nachrichten (ack direkt vor State-Push) ab.
|
|
touchUart.setTxBufferSize(4096); // war 2048; nicht-blockierendes Senden bis 3000-Byte-Zeile
|
|
touchUart.begin(TOUCH_UART_BAUDRATE, SERIAL_8N1, TOUCH_UART_RX_PIN, TOUCH_UART_TX_PIN);
|
|
touchUartRxLine.reserve(TOUCH_UART_MAX_LINE_LEN);
|
|
#endif
|
|
EEPROM.begin(EEPROM_SIZE); // EEPROM initialisieren
|
|
loadFullyKioskConfig(fullyKioskConfig);
|
|
|
|
// --- PinModes initialisieren ---
|
|
// Preload output latches with LOW before switching the pins to OUTPUT to avoid startup glitches.
|
|
digitalWrite(SSR_DAMPF_PIN, LOW);
|
|
digitalWrite(SSR_WASSER_PIN, LOW);
|
|
digitalWrite(SSR_LIGHT_PIN, LOW);
|
|
digitalWrite(SSR_STEAM_CIRCUIT_PIN, LOW);
|
|
digitalWrite(PUMP_PIN, LOW);
|
|
digitalWrite(VALVE_PIN, LOW);
|
|
digitalWrite(PIEZO_PIN, LOW);
|
|
pinMode(SSR_DAMPF_PIN, OUTPUT);
|
|
pinMode(SSR_WASSER_PIN, OUTPUT);
|
|
pinMode(SSR_LIGHT_PIN, OUTPUT);
|
|
pinMode(SSR_STEAM_CIRCUIT_PIN, OUTPUT);
|
|
digitalWrite(SSR_STEAM_CIRCUIT_PIN, LOW);
|
|
pinMode(SHOT_TIMER_PIN, INPUT_PULLUP); // Pull-up wiring: switch to GND = active
|
|
pinMode(STEAM_CIRCUIT_SWITCH_PIN, INPUT_PULLUP); // Pull-up wiring: switch to GND = on
|
|
pinMode(STANDBY_SWITCH_PIN, INPUT_PULLUP); // Pull-up wiring: switch to GND = on
|
|
pinMode(ECO_SWITCH_PIN, INPUT_PULLUP); // Pull-up wiring: switch to GND = on
|
|
pinMode(X_SWITCH_PIN, INPUT_PULLUP); // Pull-up wiring: switch to GND = on
|
|
unsigned long buttonInitNow = millis();
|
|
bool initialShotButtonPressed = (digitalRead(SHOT_TIMER_PIN) == SHOT_TIMER_ACTIVE_LEVEL);
|
|
bool initialSteamButtonPressed = (digitalRead(STEAM_CIRCUIT_SWITCH_PIN) == STEAM_CIRCUIT_SWITCH_ACTIVE_LEVEL);
|
|
bool initialStandbyButtonPressed = (digitalRead(STANDBY_SWITCH_PIN) == STANDBY_SWITCH_ACTIVE_LEVEL);
|
|
bool initialEcoButtonPressed = (digitalRead(ECO_SWITCH_PIN) == ECO_SWITCH_ACTIVE_LEVEL);
|
|
bool initialXButtonPressed = (digitalRead(X_SWITCH_PIN) == X_SWITCH_ACTIVE_LEVEL);
|
|
standbyModeActive = false;
|
|
standbySwitchStableOn = initialStandbyButtonPressed;
|
|
standbySwitchHeldHintActive = false;
|
|
initMomentaryButtonState(shotButtonState, initialShotButtonPressed, buttonInitNow);
|
|
initMomentaryButtonState(steamButtonState, initialSteamButtonPressed, buttonInitNow);
|
|
initMomentaryButtonState(standbyButtonState, initialStandbyButtonPressed, buttonInitNow);
|
|
initMomentaryButtonState(ecoButtonState, initialEcoButtonPressed, buttonInitNow);
|
|
initMomentaryButtonState(xButtonState, initialXButtonPressed, buttonInitNow);
|
|
|
|
pinMode(PUMP_PIN, OUTPUT);
|
|
pinMode(VALVE_PIN, OUTPUT);
|
|
|
|
// Piezo-Pin als Ausgang konfigurieren <<<
|
|
pinMode(PIEZO_PIN, OUTPUT);
|
|
digitalWrite(PIEZO_PIN, LOW); // Sicherstellen, dass er initial aus ist
|
|
|
|
// ADC für NTC-Pins konfigurieren
|
|
analogSetPinAttenuation(ANALOG_NTC_PIN, ADC_11db);
|
|
analogSetPinAttenuation(ANALOG_CASE_NTC_PIN, ADC_11db);
|
|
analogReadResolution(12);
|
|
// } else {
|
|
// }
|
|
|
|
// Initialisiere SSRs als AUS
|
|
digitalWrite(SSR_DAMPF_PIN, LOW);
|
|
digitalWrite(SSR_WASSER_PIN, LOW);
|
|
digitalWrite(SSR_LIGHT_PIN, LOW);
|
|
// Pumpe & Ventil Pins initialisieren (AUS)
|
|
digitalWrite(PUMP_PIN, LOW);
|
|
digitalWrite(VALVE_PIN, LOW);
|
|
|
|
// --- Wire (I2C) initialisieren (Display/Waage/Sensoren) ---
|
|
Wire.begin(IIC_5V_SDA, IIC_5V_SCK);
|
|
|
|
|
|
// --- EEPROM-Werte laden oder Defaults setzen ---
|
|
char storedMagicValue[5] = {0};
|
|
bool magicValueVorhanden = false;
|
|
EEPROM.get(EEPROM_ADDR_MAGICVALUE, storedMagicValue);
|
|
|
|
if (strncmp(storedMagicValue, storageMagicValue, 4) == 0) {
|
|
magicValueVorhanden = true;
|
|
} else {
|
|
magicValueVorhanden = false;
|
|
}
|
|
|
|
// Magic fehlt (z. B. EEPROM durch unsauberen Reset/Brownout beschaedigt):
|
|
// ERST aus dem FFat-Backup wiederherstellen versuchen, BEVOR auf Werk zurueckgesetzt wird.
|
|
if (!magicValueVorhanden) {
|
|
if (tryRestoreEepromFromBackup()) {
|
|
EEPROM.get(EEPROM_ADDR_MAGICVALUE, storedMagicValue);
|
|
if (strncmp(storedMagicValue, storageMagicValue, 4) == 0) {
|
|
magicValueVorhanden = true;
|
|
eepromRestoredFromBackup = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Werte aus dem EEPROM laden oder Defaults setzen (kompletter Block)
|
|
if (magicValueVorhanden) {
|
|
// --- Bestehende EEPROM Ladevorgänge ---
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
EEPROM.get(EEPROM_ADDR_OFFSET_DAMPF, OffsetDampf);
|
|
EEPROM.get(EEPROM_ADDR_OFFSET_WASSER, OffsetWasser);
|
|
EEPROM.get(EEPROM_ADDR_KP_DAMPF, KpDampf);
|
|
EEPROM.get(EEPROM_ADDR_KI_DAMPF, KiDampf);
|
|
EEPROM.get(EEPROM_ADDR_KD_DAMPF, KdDampf);
|
|
EEPROM.get(EEPROM_ADDR_KP_WASSER, KpWasser);
|
|
EEPROM.get(EEPROM_ADDR_KI_WASSER, KiWasser);
|
|
EEPROM.get(EEPROM_ADDR_KD_WASSER, KdWasser);
|
|
EEPROM.get(EEPROM_ADDR_ECOMODE_MINUTES, ecoModeMinutes);
|
|
EEPROM.get(EEPROM_ADDR_ECOMODE_TEMP_WASSER, ecoModeTempWasser);
|
|
EEPROM.get(EEPROM_ADDR_ECOMODE_TEMP_DAMPF, ecoModeTempDampf);
|
|
EEPROM.get(EEPROM_ADDR_INFO_HERSTELLER, infoHersteller);
|
|
EEPROM.get(EEPROM_ADDR_INFO_MODELL, infoModell);
|
|
EEPROM.get(EEPROM_ADDR_INFO_ZUSATZ, infoZusatz);
|
|
EEPROM.get(EEPROM_ADDR_DYNAMIC_ECO_MODE, dynamicEcoActive);
|
|
EEPROM.get(EEPROM_ADDR_FASTHEATUP_DATA, fastHeatUpAktiv);
|
|
EEPROM.get(EEPROM_ADDR_STEAM_DELAY, dampfVerzoegerung);
|
|
EEPROM.get(EEPROM_ADDR_HEATUP_MINUTES, heatUpMinutes);
|
|
if (heatUpMinutes < 0 || heatUpMinutes > 60) heatUpMinutes = 0; // Plausibilitaet / uninitialisiertes EEPROM
|
|
EEPROM.get(EEPROM_ADDR_TUNING_STEP_WASSER, tuningStepWasser);
|
|
EEPROM.get(EEPROM_ADDR_TUNING_NOISE_WASSER, tuningNoiseWasser);
|
|
EEPROM.get(EEPROM_ADDR_TUNING_STARTVALUE_WASSER, tuningStartValueWasser);
|
|
EEPROM.get(EEPROM_ADDR_TUNING_LOOKBACK_WASSER, tuningLookBackWasser);
|
|
EEPROM.get(EEPROM_ADDR_TUNING_STEP_DAMPF, tuningStepDampf);
|
|
EEPROM.get(EEPROM_ADDR_TUNING_NOISE_DAMPF, tuningNoiseDampf);
|
|
EEPROM.get(EEPROM_ADDR_TUNING_STARTVALUE_DAMPF, tuningStartValueDampf);
|
|
EEPROM.get(EEPROM_ADDR_TUNING_LOOKBACK_DAMPF, tuningLookBackDampf);
|
|
EEPROM.get(EEPROM_ADDR_MAX_TEMP_WASSER, maxTempWasser);
|
|
EEPROM.get(EEPROM_ADDR_MAX_TEMP_DAMPF, maxTempDampf);
|
|
EEPROM.get(EEPROM_ADDR_MAINTENANCE_INTERVAL, maintenanceInterval);
|
|
EEPROM.get(EEPROM_ADDR_BOOST_WASSER_ACTIVE, boostWasserActive);
|
|
EEPROM.get(EEPROM_ADDR_BOOST_DAMPF_ACTIVE, boostDampfActive);
|
|
EEPROM.get(EEPROM_ADDR_PREVENTHEAT_WASSER, preventHeatAboveSetpointWasser);
|
|
EEPROM.get(EEPROM_ADDR_PREVENTHEAT_DAMPF, preventHeatAboveSetpointDampf);
|
|
EEPROM.get(EEPROM_ADDR_WINDOWSIZE_WASSER, windowSizeWasser);
|
|
EEPROM.get(EEPROM_ADDR_WINDOWSIZE_DAMPF, windowSizeDampf);
|
|
uint8_t storedOffsetCompensationEnabled = 0;
|
|
EEPROM.get(EEPROM_ADDR_OFFSET_COMPENSATION_ENABLED, storedOffsetCompensationEnabled);
|
|
offsetCompensationEnabled = (storedOffsetCompensationEnabled == 1);
|
|
|
|
// Brew Control Einstellungen laden
|
|
EEPROM.get(EEPROM_ADDR_BREWBYTIME_ENABLED, brewByTimeEnabled);
|
|
EEPROM.get(EEPROM_ADDR_BREWBYTIME_SECONDS, brewByTimeTargetSeconds);
|
|
EEPROM.get(EEPROM_ADDR_PREINF_ENABLED, preInfusionEnabled);
|
|
EEPROM.get(EEPROM_ADDR_PREINF_DUR_SEC, preInfusionDurationSeconds);
|
|
EEPROM.get(EEPROM_ADDR_PREINF_PAUSE_SEC, preInfusionPauseSeconds);
|
|
EEPROM.get(EEPROM_ADDR_BREWBYWEIGHT_ENABLED, brewByWeightEnabled);
|
|
EEPROM.get(EEPROM_ADDR_BREWBYWEIGHT_TARGET, brewByWeightTargetGrams);
|
|
EEPROM.get(EEPROM_ADDR_BREWBYWEIGHT_OFFSET, brewByWeightOffsetGrams);
|
|
EEPROM.get(EEPROM_ADDR_FLOWGUARD_ENABLED, flowGuardEnabled);
|
|
EEPROM.get(EEPROM_ADDR_FLOWGUARD_MIN_SECONDS, flowGuardMinBrewSeconds);
|
|
EEPROM.get(EEPROM_ADDR_FLOWGUARD_PULSE_PERIOD_MS, flowGuardPulsePeriodMs);
|
|
EEPROM.get(EEPROM_ADDR_FLOWGUARD_MIN_DUTY, flowGuardMinDutyPercent);
|
|
if (!isfinite(flowGuardMinBrewSeconds) || flowGuardMinBrewSeconds < FLOW_GUARD_MIN_SECONDS_MIN || flowGuardMinBrewSeconds > FLOW_GUARD_MIN_SECONDS_MAX) {
|
|
flowGuardMinBrewSeconds = defaultFlowGuardMinBrewSeconds;
|
|
}
|
|
if (flowGuardPulsePeriodMs < FLOW_GUARD_PULSE_PERIOD_MIN_MS || flowGuardPulsePeriodMs > FLOW_GUARD_PULSE_PERIOD_MAX_MS) {
|
|
flowGuardPulsePeriodMs = defaultFlowGuardPulsePeriodMs;
|
|
}
|
|
if (!isfinite(flowGuardMinDutyPercent) || flowGuardMinDutyPercent < FLOW_GUARD_MIN_DUTY_MIN || flowGuardMinDutyPercent > FLOW_GUARD_MIN_DUTY_MAX) {
|
|
flowGuardMinDutyPercent = defaultFlowGuardMinDutyPercent;
|
|
}
|
|
EEPROM.get(EEPROM_ADDR_STEAMBYTIME_ENABLED, steamByTimeEnabled);
|
|
EEPROM.get(EEPROM_ADDR_STEAMBYTIME_SECONDS, steamByTimeTargetSeconds);
|
|
if (!isfinite(steamByTimeTargetSeconds) || steamByTimeTargetSeconds < 1.0f || steamByTimeTargetSeconds > 180.0f) {
|
|
steamByTimeTargetSeconds = defaultSteamByTimeTargetSeconds;
|
|
}
|
|
|
|
// Piezo-Status laden
|
|
EEPROM.get(EEPROM_ADDR_PIEZO_ENABLED, piezoEnabled);
|
|
|
|
// Status für Dampfverzögerungs-Override per Schalter laden
|
|
EEPROM.get(EEPROM_ADDR_STEAM_DELAY_OVERRIDE_SWITCH, steamDelayOverrideBySwitchEnabled);
|
|
// Eco-Info Anzeige auf dem Display laden
|
|
EEPROM.get(EEPROM_ADDR_ECO_INFO_ON_DISPLAY, ecoInfoOnDisplay);
|
|
uint8_t storedEcoLightAutoOff = 0;
|
|
uint8_t storedSteamHeatDisabledOnWake = 0;
|
|
EEPROM.get(EEPROM_ADDR_ECO_LIGHT_AUTO_OFF, storedEcoLightAutoOff);
|
|
EEPROM.get(EEPROM_ADDR_STEAM_HEAT_DISABLED_ON_STARTUP_WAKE, storedSteamHeatDisabledOnWake);
|
|
if (storedEcoLightAutoOff > 1) { storedEcoLightAutoOff = (uint8_t)(defaultEcoLightAutoOffEnabled ? 1 : 0); }
|
|
ecoLightAutoOffEnabled = (storedEcoLightAutoOff == 1);
|
|
if (storedSteamHeatDisabledOnWake > 1) { storedSteamHeatDisabledOnWake = (uint8_t)(defaultSteamHeatDisabledOnStartupWake ? 1 : 0); }
|
|
steamHeatDisabledOnStartupWake = (storedSteamHeatDisabledOnWake == 1);
|
|
uint8_t storedStandbyTimeOnDisplay = 0;
|
|
EEPROM.get(EEPROM_ADDR_STANDBY_TIME_ON_DISPLAY, storedStandbyTimeOnDisplay);
|
|
if (storedStandbyTimeOnDisplay > 1) { storedStandbyTimeOnDisplay = (uint8_t)(defaultStandbyTimeOnDisplay ? 1 : 0); }
|
|
standbyTimeOnDisplay = (storedStandbyTimeOnDisplay == 1);
|
|
uint8_t storedXSwitchAction = defaultXSwitchAction;
|
|
EEPROM.get(EEPROM_ADDR_X_SWITCH_ACTION, storedXSwitchAction);
|
|
if (storedXSwitchAction > X_SWITCH_ACTION_MAX) { storedXSwitchAction = defaultXSwitchAction; }
|
|
xSwitchAction = storedXSwitchAction;
|
|
uint8_t storedXSwitchLongAction = defaultXSwitchLongAction;
|
|
EEPROM.get(EEPROM_ADDR_X_SWITCH_LONG_ACTION, storedXSwitchLongAction);
|
|
if (storedXSwitchLongAction > X_SWITCH_ACTION_MAX) { storedXSwitchLongAction = defaultXSwitchLongAction; }
|
|
xSwitchLongAction = storedXSwitchLongAction;
|
|
uint16_t storedButtonLongPressMs = (uint16_t)DEFAULT_BUTTON_LONG_PRESS_MS;
|
|
EEPROM.get(EEPROM_ADDR_BUTTON_LONG_PRESS_MS, storedButtonLongPressMs);
|
|
if (storedButtonLongPressMs < BUTTON_LONG_PRESS_MIN_MS || storedButtonLongPressMs > BUTTON_LONG_PRESS_MAX_MS) {
|
|
storedButtonLongPressMs = (uint16_t)DEFAULT_BUTTON_LONG_PRESS_MS;
|
|
}
|
|
buttonLongPressMs = storedButtonLongPressMs;
|
|
|
|
applySteamHeatDisabledOnStartupWakeIfConfigured();
|
|
uint8_t storedTempDisplayMode = defaultDashboardTempDisplayMode;
|
|
EEPROM.get(EEPROM_ADDR_DASHBOARD_TEMP_DISPLAY_MODE, storedTempDisplayMode);
|
|
if (storedTempDisplayMode > DASHBOARD_TEMP_DISPLAY_RAILS) { storedTempDisplayMode = defaultDashboardTempDisplayMode; }
|
|
dashboardTempDisplayMode = storedTempDisplayMode;
|
|
uint8_t storedCaseEnabled = 0;
|
|
uint8_t storedCaseType = defaultCaseSensorType;
|
|
uint8_t storedCaseTempDashboard = 0;
|
|
uint8_t storedCaseTempDisplay = 0;
|
|
uint8_t storedSteamHeatOnDraw = 0;
|
|
double storedCaseOffset = defaultOffsetCase;
|
|
EEPROM.get(EEPROM_ADDR_CASE_SENSOR_ENABLED, storedCaseEnabled);
|
|
EEPROM.get(EEPROM_ADDR_CASE_SENSOR_TYPE, storedCaseType);
|
|
EEPROM.get(EEPROM_ADDR_CASE_TEMP_DASHBOARD, storedCaseTempDashboard);
|
|
EEPROM.get(EEPROM_ADDR_CASE_TEMP_ON_DISPLAY, storedCaseTempDisplay);
|
|
EEPROM.get(EEPROM_ADDR_OFFSET_CASE, storedCaseOffset);
|
|
EEPROM.get(EEPROM_ADDR_STEAM_HEAT_ON_DRAW, storedSteamHeatOnDraw);
|
|
caseSensorEnabled = (storedCaseEnabled == 1);
|
|
caseSensorType = storedCaseType;
|
|
if (caseSensorType > CASE_SENSOR_TYPE_CUPTRAY) { caseSensorType = defaultCaseSensorType; }
|
|
if (!isfinite(storedCaseOffset)) { storedCaseOffset = defaultOffsetCase; }
|
|
OffsetCase = storedCaseOffset;
|
|
if (storedCaseTempDashboard > 1) { storedCaseTempDashboard = (uint8_t)(defaultCaseTempOnDashboard ? 1 : 0); }
|
|
caseTempOnDashboard = (storedCaseTempDashboard == 1);
|
|
if (storedCaseTempDisplay > 1) { storedCaseTempDisplay = (uint8_t)(defaultCaseTempOnDisplay ? 1 : 0); }
|
|
caseTempOnDisplay = (storedCaseTempDisplay == 1);
|
|
if (storedSteamHeatOnDraw > 1) { storedSteamHeatOnDraw = (uint8_t)(defaultSteamHeatOnDraw ? 1 : 0); }
|
|
steamHeatOnDraw = (storedSteamHeatOnDraw == 1);
|
|
uint8_t storedFlushDurationSeconds = defaultFlushDurationSeconds;
|
|
EEPROM.get(EEPROM_ADDR_FLUSH_DURATION_SECONDS, storedFlushDurationSeconds);
|
|
if (storedFlushDurationSeconds < 1 || storedFlushDurationSeconds > 30) {
|
|
storedFlushDurationSeconds = defaultFlushDurationSeconds;
|
|
}
|
|
flushDurationSeconds = storedFlushDurationSeconds;
|
|
uint8_t storedSteamFlushDurationSeconds = defaultSteamFlushDurationSeconds;
|
|
EEPROM.get(EEPROM_ADDR_STEAM_FLUSH_DURATION_SECONDS, storedSteamFlushDurationSeconds);
|
|
if (storedSteamFlushDurationSeconds < 1 || storedSteamFlushDurationSeconds > 30) {
|
|
storedSteamFlushDurationSeconds = defaultSteamFlushDurationSeconds;
|
|
}
|
|
steamFlushDurationSeconds = storedSteamFlushDurationSeconds;
|
|
uint8_t storedBacklightActive = defaultBacklightActivePercent;
|
|
EEPROM.get(EEPROM_ADDR_BACKLIGHT_ACTIVE_PERCENT, storedBacklightActive);
|
|
if (storedBacklightActive < 5 || storedBacklightActive > 100) {
|
|
storedBacklightActive = defaultBacklightActivePercent; // u. a. unprogrammierte 0xFF
|
|
}
|
|
backlightActivePercent = storedBacklightActive;
|
|
uint8_t storedBacklightStandbyClock = defaultBacklightStandbyClockPercent;
|
|
EEPROM.get(EEPROM_ADDR_BACKLIGHT_STANDBY_CLOCK_PERCENT, storedBacklightStandbyClock);
|
|
if (storedBacklightStandbyClock > 100) {
|
|
storedBacklightStandbyClock = defaultBacklightStandbyClockPercent;
|
|
}
|
|
backlightStandbyClockPercent = storedBacklightStandbyClock;
|
|
uint8_t storedScaleEnabled = 0;
|
|
uint8_t storedScaleType = defaultScaleType;
|
|
EEPROM.get(EEPROM_ADDR_SCALE_ENABLED, storedScaleEnabled);
|
|
EEPROM.get(EEPROM_ADDR_SCALE_TYPE, storedScaleType);
|
|
if (storedScaleEnabled > 1) { scaleEnabled = defaultScaleEnabled; } else { scaleEnabled = (storedScaleEnabled == 1); }
|
|
scaleType = storedScaleType;
|
|
if (scaleType > SCALE_HX711) { scaleType = defaultScaleType; }
|
|
float storedHx711Cal = HX711_CALIBRATION_FACTOR;
|
|
EEPROM.get(EEPROM_ADDR_HX711_CAL_FACTOR, storedHx711Cal);
|
|
if (!isfinite(storedHx711Cal) || storedHx711Cal == 0.0f) { storedHx711Cal = HX711_CALIBRATION_FACTOR; }
|
|
hx711CalibrationFactor = storedHx711Cal;
|
|
uint8_t storedHx711DisplaySmoothing = (uint8_t)(defaultHx711DisplaySmoothingEnabled ? 1 : 0);
|
|
EEPROM.get(EEPROM_ADDR_HX711_DISPLAY_SMOOTHING_ENABLED, storedHx711DisplaySmoothing);
|
|
if (storedHx711DisplaySmoothing > 1) {
|
|
storedHx711DisplaySmoothing = (uint8_t)(defaultHx711DisplaySmoothingEnabled ? 1 : 0);
|
|
}
|
|
hx711DisplaySmoothingEnabled = (storedHx711DisplaySmoothing == 1);
|
|
uint8_t storedLightOn = 0;
|
|
EEPROM.get(EEPROM_ADDR_LIGHT_ON, storedLightOn);
|
|
if (storedLightOn > 1) { storedLightOn = 0; }
|
|
lightOn = (storedLightOn == 1);
|
|
digitalWrite(SSR_LIGHT_PIN, (!standbyModeActive && lightOn) ? HIGH : LOW);
|
|
|
|
// Sicherstellen, dass Strings nullterminiert sind
|
|
infoHersteller[sizeof(infoHersteller) - 1] = '\0';
|
|
infoModell[sizeof(infoModell) - 1] = '\0';
|
|
infoZusatz[sizeof(infoZusatz) - 1] = '\0';
|
|
|
|
} else { // Magic Value fehlt -> ALLES auf Default setzen und speichern
|
|
// Serial.println("Keine gültigen Einstellungen im EEPROM gefunden. Setze Werkseinstellungen.");
|
|
resetToDefaults(); // Diese Funktion setzt globale Variablen UND schreibt ins EEPROM
|
|
}
|
|
|
|
// --- WiFi Konfiguration ---
|
|
recordResetCheckpoint(RESET_CP_SETUP_WIFI);
|
|
WiFiConfig wifiConfig;
|
|
bool hasWiFiConfig = loadWiFiConfig(wifiConfig); // Lädt auch Hostname
|
|
WiFi.hostname(hostname); // Hostname setzen VOR begin/softAP
|
|
|
|
if (hasWiFiConfig) {
|
|
WiFi.mode(WIFI_STA);
|
|
WiFi.setSleep(false);
|
|
|
|
if (wifiConfig.useStaticIP) {
|
|
// Serial.println(F("Verwende statische IP-Konfiguration:")); // Optional für Debugging
|
|
// Serial.print(F(" IP: ")); Serial.println(wifiConfig.staticIP); // Optional
|
|
// Serial.print(F(" Gateway: ")); Serial.println(wifiConfig.gateway); // Optional
|
|
// Serial.print(F(" Subnet: ")); Serial.println(wifiConfig.subnet); // Optional
|
|
// Serial.print(F(" DNS: ")); Serial.println(wifiConfig.dns); // Optional
|
|
|
|
// ESP32 WiFi.config(ip, gateway, subnet, primaryDNS_optional, secondaryDNS_optional)
|
|
// If only one DNS server is available, wifiConfig.dns is fine.
|
|
if (!WiFi.config(wifiConfig.staticIP, wifiConfig.gateway, wifiConfig.subnet, wifiConfig.dns)) {
|
|
Serial.println(F("FEHLER: Statische IP-Konfiguration fehlgeschlagen!")); // Optional
|
|
}
|
|
// } else {
|
|
// Serial.println(F("Verwende DHCP.")); // Optional für Debugging
|
|
}
|
|
|
|
// Serial.printf("Verbinde mit SSID: %s, Hostname: %s\n", wifiConfig.ssid, hostname);
|
|
WiFi.begin(wifiConfig.ssid, wifiConfig.password);
|
|
|
|
// Warte auf Verbindung (mit Timeout)
|
|
int retries = 0;
|
|
while (WiFi.status() != WL_CONNECTED && retries < 20) {
|
|
delay(500);
|
|
retries++;
|
|
yield();
|
|
}
|
|
|
|
if (WiFi.status() == WL_CONNECTED) {
|
|
Serial.print(F("\nErfolgreich verbunden! IP-Adresse: ")); Serial.println(WiFi.localIP());
|
|
// NTP Client und Zeitzone konfigurieren
|
|
timeClient.begin();
|
|
// Serial.println("NTP Client gestartet.");
|
|
configTzTime(TZ_INFO, "pool.ntp.org");
|
|
// Serial.printf("Zeitzone gesetzt: %s\n", TZ_INFO);
|
|
|
|
// Demo Modus Check
|
|
if (WiFi.localIP() == demoModusIP) {
|
|
demoModus = true;
|
|
// Serial.println("INFO: Demo-Modus aktiviert (basierend auf IP-Adresse).");
|
|
}
|
|
// mDNS starten
|
|
if (MDNS.begin(hostname)) {
|
|
// Serial.println("mDNS Responder gestartet");
|
|
MDNS.addService("http", "tcp", 80);
|
|
}
|
|
} else {
|
|
// Serial.println(F("\nKonnte keine Verbindung herstellen! Starte AP-Modus..."));
|
|
startAPMode(); // Funktion startet Soft AP und gibt Infos aus
|
|
}
|
|
} else {
|
|
// Serial.println("Keine WLAN-Konfiguration gefunden. Starte AP-Modus...");
|
|
startAPMode(); // Funktion startet Soft AP und gibt Infos aus
|
|
}
|
|
// --- Ende WiFi Konfiguration ---
|
|
|
|
|
|
// --- Wichtige Infos auf Serial ausgeben (Alternativ zur Displayausgabe) ---
|
|
// Serial.println("---------------------------------------------");
|
|
// Serial.print("Firmware Version: "); Serial.println(version);
|
|
|
|
// #ifdef ENABLE_DISPLAY
|
|
// Serial.println("Display: Aktiviert");
|
|
// #else
|
|
// Serial.println("Display: Deaktiviert");
|
|
// #endif
|
|
// if (WiFi.getMode() == WIFI_AP || WiFi.status() != WL_CONNECTED) {
|
|
// Serial.println(F("WiFi Modus: Access Point (AP)"));
|
|
// Serial.print(F("SSID: ")); Serial.println(WiFi.softAPSSID());
|
|
// Serial.print(F("Passwort: QuickMill")); // Oder was in startAPMode() gesetzt wird
|
|
// Serial.print(F("AP IP Addresse: ")); Serial.println(WiFi.softAPIP());
|
|
// } else {
|
|
// Serial.println(F("WiFi Modus: Station (STA)"));
|
|
// Serial.print(F("Verbunden mit SSID: ")); Serial.println(WiFi.SSID());
|
|
// Serial.print(F("IP Addresse: ")); Serial.println(WiFi.localIP());
|
|
// Serial.print(F("Hostname: ")); Serial.print(hostname); Serial.println(".local");
|
|
// Serial.print(F("Signalstärke: ")); Serial.print(WiFi.RSSI()); Serial.println(" dBm");
|
|
// }
|
|
// Serial.println("---------------------------------------------");
|
|
// --- Ende Serial Ausgabe ---
|
|
|
|
|
|
// --- Weitere Initialisierungen ---
|
|
// Wartungszähler, Betriebszeit und Shotcounter aus EEPROM laden
|
|
EEPROM.get(EEPROM_ADDR_RUNTIME, totalRuntime);
|
|
if (totalRuntime == ULONG_MAX || totalRuntime > (86400UL * 365 * 10)) { totalRuntime = 0; EEPROM.put(EEPROM_ADDR_RUNTIME, totalRuntime); EEPROM.commit(); }
|
|
lastPersistedRuntime = totalRuntime;
|
|
EEPROM.get(EEPROM_ADDR_SHOTCOUNTER, shotCounter);
|
|
if (shotCounter == ULONG_MAX || shotCounter > 1000000UL) { shotCounter = 0; EEPROM.put(EEPROM_ADDR_SHOTCOUNTER, shotCounter); EEPROM.commit(); }
|
|
EEPROM.get(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter);
|
|
if (maintenanceIntervalCounter == ULONG_MAX || maintenanceIntervalCounter > 1000000UL) { maintenanceIntervalCounter = 0; EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter); EEPROM.commit(); }
|
|
|
|
// Fast Heat Up Status setzen
|
|
if (fastHeatUpAktiv) {
|
|
fastHeatUpHeating = true;
|
|
}
|
|
|
|
|
|
// --- Display-Initialisierung (nur wenn aktiviert) ---
|
|
#ifdef ENABLE_DISPLAY
|
|
recordResetCheckpoint(RESET_CP_SETUP_DISPLAY);
|
|
if (!display.begin(0x3C, true)) { // display.begin benutzt das ggf. schon initialisierte Wire
|
|
Serial.println(F("Display konnte nicht initialisiert werden!"));
|
|
} else {
|
|
Serial.println(F("Display initialisiert."));
|
|
// Display-Ausgabe am Start (1)
|
|
display.clearDisplay();
|
|
display.setCursor(0, 0);
|
|
display.setTextColor(SH110X_WHITE);
|
|
display.setTextSize(1);
|
|
display.println(infoHersteller);
|
|
display.println(infoModell);
|
|
display.println(infoZusatz);
|
|
display.println("");
|
|
display.println(F("Dual-PID"));
|
|
display.print(F("Version: "));
|
|
display.println(version);
|
|
display.println(F("von Thomas M\201ller")); // \201 ist ü für manche Displays
|
|
display.display();
|
|
delay(delayInit1); // Verzögerung nur bei aktiviertem Display
|
|
|
|
// Display-Ausgabe am Start (2) - WiFi Info
|
|
display.clearDisplay();
|
|
display.setCursor(0, 0);
|
|
display.setTextColor(SH110X_WHITE);
|
|
display.setTextSize(1);
|
|
display.println(infoHersteller);
|
|
display.println(infoModell);
|
|
display.println(infoZusatz);
|
|
display.println("");
|
|
if (WiFi.status() != WL_CONNECTED) {
|
|
display.println(F("WiFi: AP-Modus"));
|
|
display.print(F("SSID: ")); display.println(WiFi.softAPSSID());
|
|
display.print(F("IP: ")); display.println(WiFi.softAPIP());
|
|
} else {
|
|
display.println(F("Webserver gestartet"));
|
|
display.print(F("IP: ")); display.println(WiFi.localIP());
|
|
display.print(F("Host: ")); display.print(hostname); display.println(".local");
|
|
}
|
|
display.display();
|
|
delay(delayInit2); // Verzögerung nur bei aktiviertem Display
|
|
}
|
|
#endif // ENABLE_DISPLAY
|
|
|
|
|
|
// --- Waage Initialisierung (nur ESP32) ---
|
|
recordResetCheckpoint(RESET_CP_SETUP_SCALE);
|
|
initScale();
|
|
|
|
// Einmalige, stille Tarierung nach dem Start einleiten (ohne Piezo-Piep),
|
|
// damit nach dem Hochfahren nicht sofort ein Restwert (z.B. 13 g) angezeigt wird.
|
|
// Nutzt die vorhandene verzoegerte Tara-Logik in loop() (I2C und HX711).
|
|
if (scaleEnabled && (scaleType == SCALE_I2C || scaleType == SCALE_HX711)) {
|
|
tareScaleAfterDelay = true;
|
|
tareScaleDelayStartTime = millis();
|
|
startupSilentTarePending = true;
|
|
}
|
|
|
|
|
|
// --- PID-Regler Konfiguration ---
|
|
// Plausibilitätscheck für Fenstergrößen
|
|
if (windowSizeWasser == 0 || windowSizeWasser > 60000) {
|
|
// Serial.printf("WARNUNG: Ungültige windowSizeWasser (%lu), setze auf Default (%lu).\n", windowSizeWasser, defaultWindowSizeWasser);
|
|
windowSizeWasser = defaultWindowSizeWasser;
|
|
EEPROM.put(EEPROM_ADDR_WINDOWSIZE_WASSER, windowSizeWasser); // Korrigieren
|
|
EEPROM.commit();
|
|
}
|
|
if (windowSizeDampf == 0 || windowSizeDampf > 60000) {
|
|
// Serial.printf("WARNUNG: Ungültige windowSizeDampf (%lu), setze auf Default (%lu).\n", windowSizeDampf, defaultWindowSizeDampf);
|
|
windowSizeDampf = defaultWindowSizeDampf;
|
|
EEPROM.put(EEPROM_ADDR_WINDOWSIZE_DAMPF, windowSizeDampf); // Korrigieren
|
|
EEPROM.commit();
|
|
}
|
|
pidWasser.SetOutputLimits(0, windowSizeWasser);
|
|
pidDampf.SetOutputLimits(0, windowSizeDampf);
|
|
pidWasser.SetTunings(KpWasser, KiWasser, KdWasser);
|
|
pidDampf.SetTunings(KpDampf, KiDampf, KdDampf);
|
|
pidWasser.SetMode(AUTOMATIC);
|
|
pidDampf.SetMode(AUTOMATIC);
|
|
windowStartTimeWasser = millis();
|
|
windowStartTimeDampf = millis();
|
|
// Serial.printf("PID Wasser: Kp=%.2f, Ki=%.2f, Kd=%.2f, Win=%lu\n", KpWasser, KiWasser, KdWasser, windowSizeWasser);
|
|
// Serial.printf("PID Dampf : Kp=%.2f, Ki=%.2f, Kd=%.2f, Win=%lu\n", KpDampf, KiDampf, KdDampf, windowSizeDampf);
|
|
|
|
|
|
// --- FFat Initialisierung ---
|
|
// Serial.println("Initialisiere FFat...");
|
|
if (!LittleFS.begin()) {
|
|
// Serial.println("--------------------------------------------------");
|
|
// Serial.println("FEHLER: FFat Mount fehlgeschlagen!");
|
|
|
|
// Versuch zu formatieren (Achtung: löscht alle Daten!)
|
|
// Serial.println("Versuche FFat zu formatieren...");
|
|
if (!LittleFS.format()) {
|
|
// Serial.println("FEHLER: FFat Formatierung fehlgeschlagen!");
|
|
} else {
|
|
// Serial.println("FFat formatiert. Versuche erneut zu mounten...");
|
|
if (!LittleFS.begin()) {
|
|
// Serial.println("FFat Mount auch nach Formatierung fehlgeschlagen!");
|
|
} else {
|
|
// Serial.println("FFat nach Formatierung erfolgreich gemountet.");
|
|
}
|
|
}
|
|
} else {
|
|
// Serial.println("FFat erfolgreich gemountet.");
|
|
// Prüfe/Erstelle /Profile Verzeichnis
|
|
if (!LittleFS.exists("/Profile")) {
|
|
if (LittleFS.mkdir("/Profile")) {
|
|
// Serial.println("Verzeichnis '/Profile' erstellt.");
|
|
} else {
|
|
// Serial.println("FEHLER: Konnte Verzeichnis '/Profile' nicht erstellen!");
|
|
}
|
|
} else {
|
|
// Serial.println("Verzeichnis '/Profile' gefunden.");
|
|
}
|
|
// Optional: FS Info ausgeben
|
|
// #ifdef ESP32
|
|
// uint64_t totalBytes = LittleFS.totalBytes();
|
|
// uint64_t usedBytes = LittleFS.usedBytes();
|
|
// Serial.printf("FFat Info: Total= %llu, Used= %llu, Free= %llu Bytes\n", totalBytes, usedBytes, totalBytes - usedBytes);
|
|
// #else
|
|
// FSInfo fs_info;
|
|
// if(LittleFS.info(fs_info)) {
|
|
// Serial.printf("FFat Info: Total= %u, Used= %u, Free= %u Bytes\n", fs_info.totalBytes, fs_info.usedBytes, fs_info.totalBytes - fs_info.usedBytes);
|
|
// }
|
|
// #endif
|
|
} // Ende des FFat-Blocks
|
|
|
|
// EEPROM-Backup auf FFat sicherstellen (jetzt ist das Dateisystem gemountet und das
|
|
// EEPROM hat einen gueltigen Magic-Value - egal ob geladen, wiederhergestellt oder
|
|
// gerade auf Werk gesetzt). Legt nur an, falls noch keins existiert.
|
|
ensureEepromBackupExists();
|
|
|
|
if (!loadStandbyTimers()) {
|
|
standbyTimerStatusMessage = "FEHLER: Timer konnten nicht vom Dateisystem geladen werden.";
|
|
}
|
|
|
|
|
|
// --- Routen für Webserver definieren ---
|
|
// Serial.println("Definiere Webserver Routen...");
|
|
asyncServer.on("/", HTTP_GET, handleDashboard); // Dashboard als Hauptseite
|
|
asyncServer.on("/dashboard", HTTP_GET, handleDashboard);
|
|
asyncServer.on("/clock", HTTP_GET, handleClockPage);
|
|
asyncServer.on("/dashboard-data", HTTP_GET, handleDashboardData);
|
|
asyncServer.on("/dashboard-action", HTTP_POST, handleDashboardAction);
|
|
asyncServer.on("/api/status", HTTP_GET, handleApiStatus);
|
|
asyncServer.on("/api/standby/on", HTTP_POST, handleApiStandbyOn);
|
|
asyncServer.on("/api/standby/off", HTTP_POST, handleApiStandbyOff);
|
|
asyncServer.on("/api/start", HTTP_POST, handleApiStartHeating);
|
|
asyncServer.on("/PID", HTTP_GET, handlePidSettings);
|
|
asyncServer.on("/updateSettings", HTTP_POST, handleSettingsUpdate);
|
|
asyncServer.on("/Info", HTTP_GET, handleInfo);
|
|
asyncServer.on("/updateInfoSettings", HTTP_POST, handleInfoUpdate);
|
|
asyncServer.on("/downloadNutzungsstatistik", handleDownloadNutzungsstatistik);
|
|
asyncServer.on("/deleteStatistics", HTTP_POST, handleDeleteStatistics);
|
|
asyncServer.on("/toggleWartungsmodus", HTTP_POST, handleToggleWartungsmodus); // In Service integriert
|
|
asyncServer.on("/Service", HTTP_GET, handleService); // Eigene Seite für Wartung, Reset etc.
|
|
asyncServer.on("/startCleaningAssistant", HTTP_POST, handleStartCleaningAssistant);
|
|
asyncServer.on("/stopCleaningAssistant", HTTP_POST, handleStopCleaningAssistant);
|
|
asyncServer.on("/AutoTune-Wasser", handleAutoTuneWasser);
|
|
asyncServer.on("/AutoTune-Dampf", handleAutoTuneDampf);
|
|
asyncServer.on("/updateAutotuneSettings", HTTP_POST, handleUpdateAutotuneSettings);
|
|
asyncServer.on("/PID-Tuning-Abbruch", HTTP_POST, handlePidSettingsTuningAbbruch);
|
|
asyncServer.on("/PID-Tuning", handlePidSettingsTuning); // Eigene Seite für AutoTune Start & Parameter
|
|
asyncServer.on("/ECO", HTTP_GET, handleEco);
|
|
asyncServer.on("/updateEcoSettings", HTTP_POST, handleEcoUpdate);
|
|
asyncServer.on("/Firmware", HTTP_GET, handleFirmware); // Kombinierte Firmware & Settings Seite
|
|
asyncServer.on("/update", HTTP_POST, handleFirmwareUpdateResponse, handleFirmwareUploadProgress);
|
|
asyncServer.on("/displayUpdate", HTTP_GET, handleDisplayUpdatePage);
|
|
asyncServer.on("/displayUpdate", HTTP_POST, handleDisplayUpdateDone, handleDisplayFirmwareUploadProgress);
|
|
asyncServer.on("/Fast-Heat-Up", HTTP_GET, handleFastHeatUp);
|
|
asyncServer.on("/updateFast-Heat-Up-Settings", HTTP_POST, handleFastHeatUpSettings);
|
|
asyncServer.on("/Dateimanager", HTTP_GET, handleFileManager);
|
|
asyncServer.on("/Datei-Upload", HTTP_POST, handleFileUploaded, handleFileUpload);
|
|
asyncServer.on("/Datei-Entfernen", HTTP_GET, handleFileDelete);
|
|
asyncServer.on("/Datei-Download", HTTP_GET, handleFileDownload);
|
|
asyncServer.on("/Verzeichnis-Erstellen", HTTP_POST, handleCreateDirectory);
|
|
asyncServer.on("/Chart-Daten", HTTP_GET, handleChartData);
|
|
asyncServer.on("/Chart", HTTP_GET, handleCharts); // Eigene Seite für die Charts
|
|
asyncServer.on("/Sensoren", HTTP_GET, handleSensors);
|
|
asyncServer.on("/updateSensorSettings", HTTP_POST, handleUpdateSensorSettings);
|
|
asyncServer.on("/hx711CalibrationTare", HTTP_POST, handleHx711CalibrationTare);
|
|
asyncServer.on("/hx711CalibrationApply", HTTP_POST, handleHx711CalibrationApply);
|
|
asyncServer.on("/Netzwerk", HTTP_GET, handleWiFiConfig); // Eigene Seite für WiFi
|
|
asyncServer.on("/saveWiFiConfig", HTTP_POST, handleSaveWiFiConfig);
|
|
asyncServer.on("/forceAPMode", HTTP_GET, handleForceAPMode);
|
|
asyncServer.on("/Timer", HTTP_GET, handleTimerPage);
|
|
asyncServer.on("/saveStandbyTimer", HTTP_POST, handleSaveStandbyTimer);
|
|
asyncServer.on("/deleteStandbyTimer", HTTP_POST, handleDeleteStandbyTimer);
|
|
asyncServer.on("/Profile", HTTP_GET, handleProfilesPage); // Eigene Seite für Profile
|
|
asyncServer.on("/loadProfile", HTTP_POST, handleLoadProfile);
|
|
asyncServer.on("/saveProfile", HTTP_POST, handleSaveProfile);
|
|
asyncServer.on("/deleteProfile", HTTP_POST, handleDeleteProfile);
|
|
asyncServer.on("/exportSettings", HTTP_GET, handleExportSettings); // In Firmware-Seite integriert
|
|
asyncServer.on("/importSettings", HTTP_POST, handleImportSettings, handleImportUpload); // In Firmware-Seite integriert
|
|
asyncServer.on("/resetDefaults", HTTP_POST, handleResetDefaults); // In Firmware-Seite integriert
|
|
asyncServer.on("/updatePiezoSettings", HTTP_POST, handleUpdatePiezoSettings); // Service-Seite
|
|
asyncServer.on("/updateIntervalSettings", HTTP_POST, handleUpdateIntervalSettings); // Service-Seite
|
|
asyncServer.on("/resetRuntime", HTTP_POST, handleResetRuntime); // In Info-Seite integriert
|
|
asyncServer.on("/resetShots", HTTP_POST, handleResetShots); // In Info-Seite integriert
|
|
asyncServer.on("/resetMaintenance", HTTP_POST, handleResetMaintenance); // In Service-Seite integriert
|
|
asyncServer.on("/restartDevice", HTTP_POST, handleRestartDevice); // Neustart des Microcontrollers
|
|
|
|
|
|
// Routen für Brew Control
|
|
asyncServer.on("/Brew-Control", HTTP_GET, handleBrewControl);
|
|
asyncServer.on("/saveBrewControl", HTTP_POST, handleSaveBrewControl);
|
|
|
|
dashboardWs.onEvent(onDashboardWsEvent);
|
|
asyncServer.addHandler(&dashboardWs);
|
|
|
|
// Webserver starten
|
|
recordResetCheckpoint(RESET_CP_SETUP_WEBSERVER);
|
|
asyncServer.begin();
|
|
yield(); // Dem System kurz Zeit geben
|
|
|
|
// Serial.println(F("Webserver gestartet und bereit."));
|
|
// Serial.println("---------------------------------------------");
|
|
// Serial.println("Setup abgeschlossen. Hauptschleife beginnt.");
|
|
// Serial.println("---------------------------------------------");
|
|
|
|
startupTime = millis(); // Startzeit für Dampfverzögerung speichern
|
|
resetOffsetCompensationReferences();
|
|
recordResetCheckpoint(RESET_CP_LOOP_START);
|
|
yield(); // Dem System kurz Zeit geben
|
|
} // Ende setup()
|
|
|
|
void handleAutoTune(AsyncWebServerRequest *request = nullptr);
|
|
|
|
/************************************************************************************
|
|
* loop(): Hauptschleife
|
|
* Enthält Logik für Pre-Infusion Ablauf und Brew-By-Time Check
|
|
************************************************************************************/
|
|
void loop() {
|
|
recordResetCheckpoint(RESET_CP_LOOP_START);
|
|
unsigned long currentMillis = millis();
|
|
|
|
if (scheduledRestart && currentMillis >= scheduledRestart) {
|
|
ESP.restart();
|
|
}
|
|
|
|
// Pruefen, ob Zeit bereits synchronisiert wurde
|
|
if (!timeSynced && WiFi.status() == WL_CONNECTED && timeClient.update()) { timeSynced = true; }
|
|
evaluateStandbyTimers();
|
|
|
|
updateMomentaryButtonState(
|
|
standbyButtonState,
|
|
(digitalRead(STANDBY_SWITCH_PIN) == STANDBY_SWITCH_ACTIVE_LEVEL),
|
|
STANDBY_SWITCH_DEBOUNCE_MS,
|
|
currentMillis
|
|
);
|
|
if (buttonJustPressed(standbyButtonState, currentMillis)) {
|
|
standbyModeActive = !standbyModeActive;
|
|
}
|
|
standbySwitchStableOn = standbyButtonState.stablePressed;
|
|
standbySwitchHeldHintActive = standbyButtonState.stablePressed &&
|
|
standbyButtonState.pressedAt > 0 &&
|
|
(currentMillis - standbyButtonState.pressedAt >= STANDBY_SWITCH_HOLD_HINT_MS);
|
|
|
|
updateMomentaryButtonState(
|
|
ecoButtonState,
|
|
(digitalRead(ECO_SWITCH_PIN) == ECO_SWITCH_ACTIVE_LEVEL),
|
|
ECO_SWITCH_DEBOUNCE_MS,
|
|
currentMillis
|
|
);
|
|
if (buttonJustPressed(ecoButtonState, currentMillis)) {
|
|
ecoSwitchActive = !ecoSwitchActive;
|
|
}
|
|
|
|
updateMomentaryButtonState(
|
|
xButtonState,
|
|
(digitalRead(X_SWITCH_PIN) == X_SWITCH_ACTIVE_LEVEL),
|
|
X_SWITCH_DEBOUNCE_MS,
|
|
currentMillis
|
|
);
|
|
buttonJustPressed(xButtonState, currentMillis);
|
|
if (xSwitchLongAction != X_SWITCH_ACTION_NONE &&
|
|
buttonLongPressReady(xButtonState, buttonLongPressMs, currentMillis)) {
|
|
xButtonState.longPressHandled = true;
|
|
executeXSwitchAction(xSwitchLongAction);
|
|
} else if (buttonJustReleased(xButtonState) && !xButtonState.longPressHandled) {
|
|
executeXSwitchAction(xSwitchAction);
|
|
}
|
|
|
|
updateMomentaryButtonState(
|
|
shotButtonState,
|
|
(digitalRead(SHOT_TIMER_PIN) == SHOT_TIMER_ACTIVE_LEVEL),
|
|
SHOT_BUTTON_DEBOUNCE_MS,
|
|
currentMillis
|
|
);
|
|
buttonJustPressed(shotButtonState, currentMillis);
|
|
if (buttonLongPressReady(shotButtonState, buttonLongPressMs, currentMillis)) {
|
|
shotButtonState.longPressHandled = true;
|
|
invokeDashboardAction("flush");
|
|
} else if (buttonJustReleased(shotButtonState) && !shotButtonState.longPressHandled) {
|
|
invokeDashboardAction((shotActive || shotSoftwareActive) ? "stopShot" : "startShot");
|
|
}
|
|
|
|
updateMomentaryButtonState(
|
|
steamButtonState,
|
|
(digitalRead(STEAM_CIRCUIT_SWITCH_PIN) == STEAM_CIRCUIT_SWITCH_ACTIVE_LEVEL),
|
|
STEAM_BUTTON_DEBOUNCE_MS,
|
|
currentMillis
|
|
);
|
|
buttonJustPressed(steamButtonState, currentMillis);
|
|
if (buttonLongPressReady(steamButtonState, buttonLongPressMs, currentMillis)) {
|
|
steamButtonState.longPressHandled = true;
|
|
invokeDashboardAction("steamFlush");
|
|
} else if (buttonJustReleased(steamButtonState) && !steamButtonState.longPressHandled) {
|
|
invokeDashboardAction((steamCircuitActive || steamCircuitSoftwareActive || steamFlushActive) ? "stopSteam" : "startSteam");
|
|
}
|
|
|
|
xSwitchActive = isXSwitchActionActive(xSwitchAction) || isXSwitchActionActive(xSwitchLongAction);
|
|
|
|
if (steamFlushActive) {
|
|
if (standbyModeActive || steamCircuitSoftwareActive) {
|
|
steamFlushActive = false;
|
|
} else if ((long)(currentMillis - steamFlushEndTime) >= 0) {
|
|
steamFlushActive = false;
|
|
}
|
|
}
|
|
|
|
bool steamCircuitSwitchOn = !standbyModeActive && (steamCircuitSoftwareActive || steamFlushActive);
|
|
steamCircuitActive = steamCircuitSwitchOn;
|
|
digitalWrite(SSR_STEAM_CIRCUIT_PIN, steamCircuitSwitchOn ? HIGH : LOW);
|
|
|
|
static bool lastSteamCircuitActionActive = false;
|
|
bool currentSteamCircuitActionActive = !standbyModeActive && steamCircuitSoftwareActive && !steamFlushActive;
|
|
if (currentSteamCircuitActionActive && !lastSteamCircuitActionActive) {
|
|
steamCircuitSessionStartTime = currentMillis;
|
|
} else if (!currentSteamCircuitActionActive) {
|
|
steamCircuitSessionStartTime = 0;
|
|
}
|
|
lastSteamCircuitActionActive = currentSteamCircuitActionActive;
|
|
|
|
if (steamByTimeEnabled &&
|
|
currentSteamCircuitActionActive &&
|
|
steamCircuitSessionStartTime > 0 &&
|
|
(currentMillis - steamCircuitSessionStartTime >= (unsigned long)(steamByTimeTargetSeconds * 1000.0f))) {
|
|
invokeDashboardAction("stopSteam");
|
|
}
|
|
|
|
finalizeMomentaryButtonState(standbyButtonState);
|
|
finalizeMomentaryButtonState(ecoButtonState);
|
|
finalizeMomentaryButtonState(xButtonState);
|
|
finalizeMomentaryButtonState(shotButtonState);
|
|
finalizeMomentaryButtonState(steamButtonState);
|
|
|
|
static bool lastStandbyModeActive = false;
|
|
if (standbyModeActive) {
|
|
ecoForcedActive = false;
|
|
steamDelayOverridden = false;
|
|
SetpointWasser = 0;
|
|
SetpointDampf = 0;
|
|
OutputWasser = 0;
|
|
OutputDampf = 0;
|
|
shotSoftwareActive = false;
|
|
steamCircuitSoftwareActive = false;
|
|
steamFlushActive = false;
|
|
steamCircuitActive = false;
|
|
if (ecoLightAutoOffEnabled && lightOn) {
|
|
lightOn = false;
|
|
digitalWrite(SSR_LIGHT_PIN, LOW);
|
|
}
|
|
if (ecoModeAktiv) {
|
|
ecoModeAktiv = false;
|
|
ecoModeActivatedTime = 0;
|
|
}
|
|
} else if (lastStandbyModeActive) {
|
|
startupTime = currentMillis;
|
|
ecoForcedActive = false;
|
|
steamDelayOverridden = false;
|
|
windowStartTimeWasser = currentMillis;
|
|
windowStartTimeDampf = currentMillis;
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
if (ecoLightAutoOffEnabled) {
|
|
uint8_t storedLightOn = 0;
|
|
EEPROM.get(EEPROM_ADDR_LIGHT_ON, storedLightOn);
|
|
if (storedLightOn > 1) { storedLightOn = 0; }
|
|
lightOn = (storedLightOn == 1);
|
|
digitalWrite(SSR_LIGHT_PIN, lightOn ? HIGH : LOW);
|
|
}
|
|
ecoModeAktiv = false;
|
|
ecoModeActivatedTime = 0;
|
|
lastShotTime = millis();
|
|
resetOffsetCompensationReferences();
|
|
applySteamHeatDisabledOnStartupWakeIfConfigured();
|
|
}
|
|
if (standbyModeActive != lastStandbyModeActive) {
|
|
syncFullyKioskWithStandby(standbyModeActive);
|
|
}
|
|
lastStandbyModeActive = standbyModeActive;
|
|
|
|
// --- Temperaturmessung und PID-Berechnung (im Intervall) ---
|
|
if (currentMillis - previousMillis >= interval) {
|
|
recordResetCheckpoint(RESET_CP_LOOP_TEMPERATURE);
|
|
previousMillis = currentMillis;
|
|
|
|
// Temperaturmessung
|
|
RawInputDampf = thermocouple.readCelsius();
|
|
RawInputWasser = readNTCTemperature();
|
|
InputDampf = round(RawInputDampf + OffsetDampf);
|
|
InputWasser = round(RawInputWasser + OffsetWasser);
|
|
updateOffsetCompensationReferences();
|
|
if (caseSensorEnabled) {
|
|
InputCase = round(readCaseTemperature() + OffsetCase);
|
|
caseSensorError = (isnan(InputCase) || InputCase < 0.0);
|
|
} else {
|
|
InputCase = NAN;
|
|
caseSensorError = false;
|
|
}
|
|
yield();
|
|
|
|
// --- Angepasste Sicherheitsueberpruefung Wasser ---
|
|
if (InputWasser < 0.0 || isnan(InputWasser)) {
|
|
wasserSensorError = true;
|
|
wasserSafetyShutdown = false;
|
|
OutputWasser = 0;
|
|
// Ton ausgeben zur Warnung
|
|
beepShort();
|
|
// Serial.println("WARNUNG: Wassertemperatursensor liefert ungueltigen Wert!"); // Optional Log
|
|
} else if (InputWasser > maxTempWasser) {
|
|
wasserSensorError = false;
|
|
wasserSafetyShutdown = true;
|
|
OutputWasser = 0;
|
|
// Ton ausgeben zur Warnung
|
|
beepShort();
|
|
// Serial.printf("WARNUNG: Wassertemperatur ueber Limit! Shutdown."); // Optional Log
|
|
} else {
|
|
if (wasserSensorError) Serial.println("INFO: Wassertemperatursensor wieder OK."); wasserSensorError = false;
|
|
if (wasserSafetyShutdown) Serial.println("INFO: Wassertemperatur wieder im sicheren Bereich."); wasserSafetyShutdown = false;
|
|
bool activateBoost = boostWasserActive && !wartungsModusAktiv && !ecoModeAktiv && !autoTuneWasserActive && (InputWasser < (SetpointWasser * 0.95));
|
|
if (activateBoost) { OutputWasser = windowSizeWasser; } else if (!autoTuneWasserActive) { pidWasser.Compute(); }
|
|
}
|
|
yield();
|
|
|
|
// --- Angepasste Sicherheitsueberpruefung Dampf ---
|
|
if (isnan(InputDampf) || InputDampf < 0.0) {
|
|
dampfSensorError = true;
|
|
dampfSafetyShutdown = false;
|
|
OutputDampf = 0;
|
|
// Ton ausgeben zur Warnung
|
|
beepShort();
|
|
// Serial.println("WARNUNG: Dampftemperatursensor liefert ungueltigen Wert!"); // Optional Log
|
|
} else if (InputDampf > maxTempDampf) {
|
|
dampfSensorError = false;
|
|
dampfSafetyShutdown = true;
|
|
OutputDampf = 0;
|
|
// Ton ausgeben zur Warnung
|
|
beepShort();
|
|
// Serial.printf("WARNUNG: Dampftemperatur ueber Limit! Shutdown."); // Optional Log
|
|
} else {
|
|
if (dampfSensorError) Serial.println("INFO: Dampftemperatursensor wieder OK."); dampfSensorError = false;
|
|
if (dampfSafetyShutdown) Serial.println("INFO: Dampftemperatur wieder im sicheren Bereich."); dampfSafetyShutdown = false;
|
|
bool steamDelayActive = (dampfVerzoegerung > 0 && (millis() - startupTime < (unsigned long)dampfVerzoegerung * 60000UL));
|
|
if (steamDelayActive && !wartungsModusAktiv) { OutputDampf = 0; } else {
|
|
bool activateBoostDampf = boostDampfActive && !wartungsModusAktiv && !ecoModeAktiv && !autoTuneDampfActive && (InputDampf < (SetpointDampf * 0.90));
|
|
if (activateBoostDampf) { OutputDampf = windowSizeDampf; } else if (!autoTuneDampfActive) { pidDampf.Compute(); }
|
|
}
|
|
}
|
|
yield();
|
|
|
|
// Pruefen der Startverzoegerung fuer Dampf
|
|
bool steamDelayActiveSystem = (dampfVerzoegerung > 0 && (millis() - startupTime < (unsigned long)dampfVerzoegerung * 60000UL));
|
|
// Pruefe, ob WIRKLICH verzoegert werden soll (System will UND kein Override aktiv)
|
|
bool shouldDelaySteamPID = steamDelayActiveSystem && !steamDelayOverridden;
|
|
|
|
// Nur fortfahren, wenn kein Sensorfehler und keine Sicherheitsabschaltung aktiv ist
|
|
if (!dampfSensorError && !dampfSafetyShutdown)
|
|
{
|
|
if (shouldDelaySteamPID && !wartungsModusAktiv) {
|
|
// Wenn verzoegert werden soll, erzwinge PID-Output auf 0
|
|
OutputDampf = 0;
|
|
// Serial.println("Dampf-PID-Berechnung wegen Verzoegerung uebersprungen."); // Optional Debug
|
|
} else {
|
|
// Keine Verzoegerung ODER Override aktiv -> PID normal berechnen/Boost pruefen
|
|
bool activateBoostDampf = boostDampfActive && !wartungsModusAktiv && !ecoModeAktiv && !autoTuneDampfActive && (InputDampf < (SetpointDampf * 0.90));
|
|
if (activateBoostDampf) {
|
|
OutputDampf = windowSizeDampf; // Boost hat Vorrang
|
|
} else if (!autoTuneDampfActive) {
|
|
pidDampf.Compute(); // PID normal berechnen lassen (jetzt sicher)
|
|
}
|
|
// Wenn AutoTune aktiv ist, wird OutputDampf dort gesetzt (in handleAutoTune)
|
|
}
|
|
}
|
|
|
|
if (scaleEnabled && (scaleModeActive || (shotActive && brewByWeightEnabled))) {
|
|
if (scaleType == SCALE_I2C && scaleConnected) {
|
|
recordResetCheckpoint(RESET_CP_SCALE_I2C_READ);
|
|
acceptScaleReading(scales.getWeight());
|
|
} else if (scaleType == SCALE_HX711) {
|
|
updateHx711ReadingState();
|
|
}
|
|
}
|
|
|
|
if (!scaleConnected) {
|
|
resetScaleReadingFilters(0.0f);
|
|
}
|
|
|
|
// AutoTune-Logik
|
|
handleAutoTune();
|
|
|
|
} // Ende des Temperaturmessungs-/PID-Berechnungsintervalls
|
|
yield();
|
|
|
|
// --- Zeitproportionale SSR-Ansteuerung (wird kontinuierlich geprueft) ---
|
|
recordResetCheckpoint(RESET_CP_LOOP_SSR);
|
|
unsigned long now = millis();
|
|
// Wasser SSR
|
|
if (wasserSensorError || wasserSafetyShutdown || standbyModeActive) { digitalWrite(SSR_WASSER_PIN, LOW); } else {
|
|
if (now - windowStartTimeWasser > windowSizeWasser) { windowStartTimeWasser = now; }
|
|
bool pidWantsHeat = (OutputWasser > 0) && (now < (windowStartTimeWasser + (unsigned long)OutputWasser));
|
|
bool preventOverheatActive = (preventHeatAboveSetpointWasser && (InputWasser > SetpointWasser)) || (ecoModeAktiv && (InputWasser > SetpointWasser));
|
|
if (pidWantsHeat && !preventOverheatActive) { digitalWrite(SSR_WASSER_PIN, HIGH); } else { digitalWrite(SSR_WASSER_PIN, LOW); }
|
|
}
|
|
yield();
|
|
|
|
// Dampf SSR
|
|
if (dampfSensorError || dampfSafetyShutdown || standbyModeActive || steamHeatDisabledByUser) {
|
|
digitalWrite(SSR_DAMPF_PIN, LOW);
|
|
} else {
|
|
if (now - windowStartTimeDampf > windowSizeDampf) { windowStartTimeDampf = now; }
|
|
|
|
if (steamHeatOnDraw && steamCircuitSwitchOn) {
|
|
digitalWrite(SSR_DAMPF_PIN, HIGH);
|
|
} else {
|
|
// Pruefe, ob die Verzoegerung aktiv UND NICHT ueberschrieben ist
|
|
bool steamDelayActiveSystem = (dampfVerzoegerung > 0 && (millis() - startupTime < (unsigned long)dampfVerzoegerung * 60000UL));
|
|
bool shouldDelaySteam = steamDelayActiveSystem && !steamDelayOverridden;
|
|
|
|
if (shouldDelaySteam && !wartungsModusAktiv) { // Wenn verzoegert werden soll (und kein Wartungsmodus)
|
|
OutputDampf = 0; // PID Output ueberschreiben (wird unten zum Abschalten fuehren)
|
|
digitalWrite(SSR_DAMPF_PIN, LOW); // Sicherstellen, dass SSR aus ist
|
|
// Optional: Logge, dass Verzoegerung aktiv ist
|
|
// Serial.println("Dampfverzoegerung aktiv.");
|
|
} else { // Keine Verzoegerung ODER ueberschrieben -> Normale PID-Logik
|
|
bool pidWantsHeat = (OutputDampf > 0) && (now < (windowStartTimeDampf + (unsigned long)OutputDampf));
|
|
bool preventOverheatActive = (preventHeatAboveSetpointDampf && (InputDampf > SetpointDampf)) || (ecoModeAktiv && (InputDampf > SetpointDampf));
|
|
|
|
if (pidWantsHeat && !preventOverheatActive && !wartungsModusAktiv) { // Zusaetzliche Pruefung auf Wartungsmodus hier
|
|
digitalWrite(SSR_DAMPF_PIN, HIGH);
|
|
} else {
|
|
digitalWrite(SSR_DAMPF_PIN, LOW);
|
|
}
|
|
// PID muss normal weiterlaufen, auch wenn SSR aus ist, daher keine Output-Manipulation mehr hier noetig,
|
|
// ausser wenn explizit verzoegert wird (siehe oben).
|
|
}
|
|
}
|
|
}
|
|
yield();
|
|
|
|
// *** Brueh-Logik (Pre-Infusion, Brew-by-Time, Brew-by-Weight) ***
|
|
recordResetCheckpoint(RESET_CP_LOOP_SHOT);
|
|
if (shotActive) {
|
|
unsigned long currentShotTimeMillis = millis() - shotStartTime;
|
|
|
|
// --- Waage lesen ---
|
|
if (scaleEnabled) {
|
|
if (scaleType == SCALE_I2C && scaleConnected) {
|
|
recordResetCheckpoint(RESET_CP_SCALE_I2C_READ);
|
|
acceptScaleReading(scales.getWeight());
|
|
} else if (scaleType == SCALE_HX711) {
|
|
updateHx711ReadingState();
|
|
}
|
|
}
|
|
// Fuer ESP-NOW wird currentWeightReading im Hintergrund aktualisiert.
|
|
|
|
if (!scaleConnected) { // Gemeinsame Logik fuer "nicht verbunden"
|
|
resetScaleReadingFilters(0.0f);
|
|
}
|
|
|
|
// --- Stopp-Bedingungen pruefen ---
|
|
float netWeight = currentWeightReading - brewStartWeight; // Berechne das Nettogewicht des Kaffees
|
|
if (!firstWeightChangeDetected && scaleConnected && netWeight >= WEIGHT_CHANGE_THRESHOLD) {
|
|
firstWeightChangeDetected = true;
|
|
firstWeightChangeTime = millis();
|
|
}
|
|
float targetWeightToStopAt = brewByWeightTargetGrams - brewByWeightOffsetGrams;
|
|
bool stopForTime = brewByTimeEnabled && (currentShotTimeMillis >= (unsigned long)(brewByTimeTargetSeconds * 1000.0f));
|
|
bool stopForWeight = brewByWeightEnabled && scaleConnected && (netWeight >= targetWeightToStopAt);
|
|
|
|
if (stopForTime || stopForWeight) {
|
|
stopBrewSequence(currentShotTimeMillis, false, stopForWeight); // Uebergibt die aktuelle Dauer und den Grund des Stopps
|
|
}
|
|
|
|
// --- Pre-Infusion Zustandsmaschine (Nur ausfuehren, wenn noch nicht gestoppt wurde) ---
|
|
else if (preInfusionEnabled) {
|
|
unsigned long currentPhaseTimeMillis = millis() - preInfusionPhaseStartTime;
|
|
|
|
// Wechsel von Pre-Infusion zu Pause
|
|
if (currentPreInfusionState == PI_PRE_BREW && (currentPhaseTimeMillis >= (unsigned long)(preInfusionDurationSeconds * 1000.0f))) {
|
|
digitalWrite(PUMP_PIN, LOW);
|
|
currentPreInfusionState = PI_PAUSE;
|
|
preInfusionPhaseStartTime = millis();
|
|
}
|
|
// Wechsel von Pause zu Hauptbezug
|
|
else if (currentPreInfusionState == PI_PAUSE && (currentPhaseTimeMillis >= (unsigned long)(preInfusionPauseSeconds * 1000.0f))) {
|
|
digitalWrite(PUMP_PIN, HIGH);
|
|
currentPreInfusionState = PI_MAIN_BREW;
|
|
preInfusionPhaseStartTime = millis();
|
|
}
|
|
}
|
|
|
|
if (shotActive) {
|
|
applyFlowGuardPumpControl(currentShotTimeMillis, netWeight, targetWeightToStopAt);
|
|
}
|
|
// Kein else hier -> Entweder Stopp, oder PreInfusion-Check, oder normaler Bezug ohne PreInfusion laeuft weiter
|
|
}
|
|
|
|
// Shot-Timer (Diese Funktion initiiert nur den Start oder stoppt bei manuellem Ende)
|
|
updateShotTimer();
|
|
|
|
if (flushActive) {
|
|
if (shotActive) {
|
|
flushActive = false;
|
|
} else if ((long)(millis() - flushEndTime) >= 0) {
|
|
flushActive = false;
|
|
}
|
|
|
|
if (flushActive) {
|
|
digitalWrite(PUMP_PIN, HIGH);
|
|
digitalWrite(VALVE_PIN, HIGH);
|
|
} else if (!shotActive) {
|
|
digitalWrite(PUMP_PIN, LOW);
|
|
digitalWrite(VALVE_PIN, LOW);
|
|
}
|
|
}
|
|
|
|
if (cleaningAssistantActive) {
|
|
if (standbyModeActive || wartungsModusAktiv || shotActive || flushActive || steamCircuitActive || steamCircuitSoftwareActive || steamFlushActive || wasserSensorError || wasserSafetyShutdown) {
|
|
stopCleaningAssistant("Reinigungsassistent abgebrochen: Systemzustand geaendert.");
|
|
} else {
|
|
lastShotTime = currentMillis;
|
|
if (cleaningAssistantWaitingForTemperature) {
|
|
digitalWrite(PUMP_PIN, LOW);
|
|
digitalWrite(VALVE_PIN, LOW);
|
|
if (InputWasser >= (cleaningAssistantWaterSetpoint - cleaningAssistantWaterReadyTolerance)) {
|
|
cleaningAssistantWaitingForTemperature = false;
|
|
cleaningAssistantInBrewPhase = true;
|
|
cleaningAssistantCurrentCycle = 1;
|
|
cleaningAssistantPhaseStartTime = currentMillis;
|
|
digitalWrite(PUMP_PIN, HIGH);
|
|
digitalWrite(VALVE_PIN, HIGH);
|
|
}
|
|
} else {
|
|
unsigned long phaseDurationMs = cleaningAssistantInBrewPhase
|
|
? (unsigned long)cleaningAssistantBrewSeconds * 1000UL
|
|
: (unsigned long)cleaningAssistantPauseSeconds * 1000UL;
|
|
if (currentMillis - cleaningAssistantPhaseStartTime >= phaseDurationMs) {
|
|
if (cleaningAssistantInBrewPhase) {
|
|
digitalWrite(PUMP_PIN, LOW);
|
|
digitalWrite(VALVE_PIN, LOW);
|
|
if (cleaningAssistantCurrentCycle >= cleaningAssistantCycles) {
|
|
stopCleaningAssistant();
|
|
} else {
|
|
cleaningAssistantInBrewPhase = false;
|
|
cleaningAssistantPhaseStartTime = currentMillis;
|
|
}
|
|
} else {
|
|
cleaningAssistantCurrentCycle++;
|
|
cleaningAssistantInBrewPhase = true;
|
|
cleaningAssistantPhaseStartTime = currentMillis;
|
|
digitalWrite(PUMP_PIN, HIGH);
|
|
digitalWrite(VALVE_PIN, HIGH);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Eco-Timer bei Ende von Dampfbezug und Flush zuruecksetzen (analog Shot-Ende)
|
|
static bool lastSteamCircuitActiveForEcoTimer = false;
|
|
static bool lastFlushActiveForEcoTimer = false;
|
|
if (!lastSteamCircuitActiveForEcoTimer && steamCircuitActive) {
|
|
steamCircuitStartTime = currentMillis; // Dampfbezug startet -> Zeitstempel fuer Display-Timer
|
|
}
|
|
if (lastSteamCircuitActiveForEcoTimer && !steamCircuitActive) {
|
|
lastShotTime = currentMillis;
|
|
}
|
|
if (lastFlushActiveForEcoTimer && !flushActive) {
|
|
lastShotTime = currentMillis;
|
|
}
|
|
lastSteamCircuitActiveForEcoTimer = steamCircuitActive;
|
|
lastFlushActiveForEcoTimer = flushActive;
|
|
|
|
bool ecoIdleBlocked =
|
|
shotActive ||
|
|
shotSoftwareActive ||
|
|
pendingShotStartAfterScaleTare ||
|
|
steamCircuitActive ||
|
|
steamCircuitSoftwareActive ||
|
|
steamFlushActive ||
|
|
flushActive ||
|
|
cleaningAssistantActive;
|
|
|
|
// Eco-Modus, wenn kein AutoTune oder Wartungsmodus
|
|
recordResetCheckpoint(RESET_CP_LOOP_ECO);
|
|
static bool lastEcoSwitchActive = false;
|
|
if (!standbyModeActive) {
|
|
if (ecoSwitchActive || ecoForcedActive) {
|
|
unsigned long currentTimeEco = millis();
|
|
if (!ecoModeAktiv) { ecoModeActivatedTime = currentTimeEco; }
|
|
ecoModeAktiv = true;
|
|
if (dynamicEcoActive) {
|
|
int minutesPassed = (currentTimeEco - ecoModeActivatedTime) / (60UL * 1000UL);
|
|
SetpointWasser = max(0, ecoModeTempWasser - minutesPassed); SetpointDampf = max(0, ecoModeTempDampf - minutesPassed);
|
|
} else {
|
|
SetpointWasser = ecoModeTempWasser; SetpointDampf = ecoModeTempDampf;
|
|
}
|
|
} else {
|
|
if (lastEcoSwitchActive) {
|
|
ecoModeAktiv = false;
|
|
ecoModeActivatedTime = 0;
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
lastShotTime = millis();
|
|
}
|
|
if (!autoTuneWasserActive && !autoTuneDampfActive && !wartungsModusAktiv) {
|
|
if (ecoModeMinutes > 0 && !ecoIdleBlocked) { // Eco nur wenn wirklich keine Aktion aktiv oder angefordert ist
|
|
unsigned long currentTimeEco = millis();
|
|
if (currentTimeEco - lastShotTime > (ecoModeMinutes * 60UL * 1000UL)) {
|
|
if (!ecoModeAktiv) { ecoModeActivatedTime = currentTimeEco; }
|
|
ecoModeAktiv = true;
|
|
if (dynamicEcoActive) {
|
|
int minutesPassed = (currentTimeEco - ecoModeActivatedTime) / (60UL * 1000UL);
|
|
SetpointWasser = max(0, ecoModeTempWasser - minutesPassed); SetpointDampf = max(0, ecoModeTempDampf - minutesPassed);
|
|
} else {
|
|
SetpointWasser = ecoModeTempWasser; SetpointDampf = ecoModeTempDampf;
|
|
}
|
|
} else {
|
|
if (ecoModeAktiv) { EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); }
|
|
ecoModeAktiv = false; ecoModeActivatedTime = 0;
|
|
}
|
|
} else if (ecoModeAktiv) {
|
|
ecoModeAktiv = false; ecoModeActivatedTime = 0;
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
}
|
|
yield();
|
|
} else if (ecoModeAktiv) {
|
|
ecoModeAktiv = false; ecoModeActivatedTime = 0;
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
}
|
|
}
|
|
} else if (ecoModeAktiv) {
|
|
ecoModeAktiv = false; ecoModeActivatedTime = 0;
|
|
}
|
|
lastEcoSwitchActive = ecoSwitchActive;
|
|
|
|
if (cleaningAssistantActive) {
|
|
applyCleaningAssistantTemperatureTargets();
|
|
}
|
|
|
|
static bool lastEcoModeActive = false;
|
|
static bool ecoLightAutoOffApplied = false;
|
|
if (standbyModeActive) {
|
|
lastEcoModeActive = ecoModeAktiv;
|
|
ecoLightAutoOffApplied = false;
|
|
} else {
|
|
if (!lastEcoModeActive && ecoModeAktiv) {
|
|
if (ecoLightAutoOffEnabled && lightOn) {
|
|
ecoLightAutoOffApplied = true;
|
|
lightOn = false;
|
|
digitalWrite(SSR_LIGHT_PIN, LOW);
|
|
} else {
|
|
ecoLightAutoOffApplied = false;
|
|
}
|
|
} else if (lastEcoModeActive && !ecoModeAktiv) {
|
|
if (ecoLightAutoOffApplied) {
|
|
uint8_t storedLightOn = 0;
|
|
EEPROM.get(EEPROM_ADDR_LIGHT_ON, storedLightOn);
|
|
if (storedLightOn > 1) { storedLightOn = 0; }
|
|
bool restoreLightOn = (storedLightOn == 1);
|
|
if (lightOn != restoreLightOn) {
|
|
lightOn = restoreLightOn;
|
|
digitalWrite(SSR_LIGHT_PIN, lightOn ? HIGH : LOW);
|
|
}
|
|
}
|
|
ecoLightAutoOffApplied = false;
|
|
}
|
|
lastEcoModeActive = ecoModeAktiv;
|
|
}
|
|
|
|
// Display regelmaessig updaten, wenn aktiviert / angeschlossen
|
|
#ifdef ENABLE_DISPLAY
|
|
recordResetCheckpoint(RESET_CP_LOOP_DISPLAY);
|
|
updateDisplay();
|
|
#endif // ENABLE_DISPLAY
|
|
|
|
// --- Waagen-spezifische Logik in der Hauptschleife ---
|
|
recordResetCheckpoint(RESET_CP_LOOP_SCALE);
|
|
if (scaleEnabled) {
|
|
if (scaleType == SCALE_I2C) {
|
|
if (scaleConnected) {
|
|
handleScaleButtonPress(nullptr);
|
|
}
|
|
if (tareScaleAfterDelay && scaleConnected && (currentMillis - tareScaleDelayStartTime >= TARE_SCALE_DELAY_DURATION)) {
|
|
scales.setOffset();
|
|
tareScaleAfterDelay = false;
|
|
tareScaleSettling = true;
|
|
tareScaleSettlingStartTime = currentMillis;
|
|
}
|
|
if (tareScaleSettling && scaleConnected && (currentMillis - tareScaleSettlingStartTime >= TARE_SCALE_SETTLING_DURATION)) {
|
|
recordResetCheckpoint(RESET_CP_SCALE_I2C_READ);
|
|
resetScaleReadingFilters(0.0f);
|
|
tareScaleSettling = false;
|
|
Serial.println(F("Waage nach Verzoegerung tariert."));
|
|
if (piezoEnabled && !startupSilentTarePending) {
|
|
beepShort(2800, 50);
|
|
}
|
|
startupSilentTarePending = false;
|
|
}
|
|
} else if (scaleType == SCALE_HX711) {
|
|
unsigned long hx711ReadyTimeBeforeRead = hx711LastReadyTime;
|
|
updateHx711ReadingState();
|
|
bool hx711FreshReadingAvailable = (hx711LastReadyTime != hx711ReadyTimeBeforeRead);
|
|
if (tareScaleAfterDelay && (currentMillis - tareScaleDelayStartTime >= TARE_SCALE_DELAY_DURATION)) {
|
|
if (hx711.is_ready()) {
|
|
hx711.tare();
|
|
hx711LastReadyTime = millis();
|
|
hx711HasValidReading = true;
|
|
hx711NotReadyCounter = 0;
|
|
scaleConnected = true;
|
|
tareScaleAfterDelay = false;
|
|
tareScaleSettling = true;
|
|
tareScaleSettlingStartTime = currentMillis;
|
|
}
|
|
}
|
|
if (tareScaleSettling && scaleConnected && (currentMillis - tareScaleSettlingStartTime >= TARE_SCALE_SETTLING_DURATION)) {
|
|
if (hx711.is_ready()) {
|
|
resetScaleReadingFilters(0.0f);
|
|
hx711LastReadyTime = millis();
|
|
hx711HasValidReading = true;
|
|
hx711NotReadyCounter = 0;
|
|
scaleConnected = true;
|
|
}
|
|
tareScaleSettling = false;
|
|
if (pendingShotStartAfterScaleTare) {
|
|
hx711ShotPostTareReadsRemaining = HX711_POST_TARE_STABILIZATION_READS;
|
|
}
|
|
Serial.println(F("Waage nach Verzoegerung tariert."));
|
|
if (piezoEnabled && !startupSilentTarePending) {
|
|
beepShort(2800, 50);
|
|
}
|
|
startupSilentTarePending = false;
|
|
}
|
|
if (pendingShotStartAfterScaleTare && hx711ShotPostTareReadsRemaining > 0 && hx711FreshReadingAvailable) {
|
|
hx711ShotPostTareReadsRemaining--;
|
|
if (hx711ShotPostTareReadsRemaining == 0) {
|
|
resetScaleReadingFilters(0.0f);
|
|
hx711ShotTarePrepared = true;
|
|
pendingShotStartAfterScaleTare = false;
|
|
}
|
|
}
|
|
} else if (scaleType == SCALE_ESPNOW) {
|
|
if (scaleConnected && (millis() - lastScaleMessageTime > SCALE_TIMEOUT)) {
|
|
scaleConnected = false;
|
|
scaleModeActive = false;
|
|
resetScaleReadingFilters(0.0f);
|
|
Serial.println("WARNUNG: Verbindung zur ESP-NOW Waage verloren (Timeout).");
|
|
}
|
|
}
|
|
}
|
|
|
|
// AsyncWebServer verarbeitet Clients im Hintergrund, kein handleClient erforderlich
|
|
recordResetCheckpoint(RESET_CP_LOOP_WS);
|
|
dashboardWs.cleanupClients();
|
|
dashboardWsTick();
|
|
#if TOUCH_UART_ENABLED
|
|
touchUartRxTick();
|
|
touchUartTick();
|
|
#endif
|
|
|
|
yield();
|
|
|
|
// Betriebszeit zaehlen
|
|
static unsigned long lastRuntimeSecond = 0;
|
|
unsigned long runtimeNow = millis();
|
|
if (standbyModeActive) {
|
|
lastRuntimeSecond = runtimeNow;
|
|
} else if (runtimeNow - lastRuntimeSecond >= 1000) {
|
|
unsigned long elapsedSeconds = (runtimeNow - lastRuntimeSecond) / 1000UL;
|
|
totalRuntime += elapsedSeconds;
|
|
lastRuntimeSecond += elapsedSeconds * 1000UL;
|
|
}
|
|
|
|
// Speichern der Betriebszeit und WiFi-Check
|
|
if (!demoModus) {
|
|
unsigned long currentMillisLoop = runtimeNow;
|
|
unsigned long intervalToUse = !initialRuntimeSaveDone ? initialRuntimeSaveDelay : subsequentRuntimeSaveInterval;
|
|
if (currentMillisLoop - lastRuntimeSave >= intervalToUse) {
|
|
if (totalRuntime != lastPersistedRuntime) {
|
|
EEPROM.put(EEPROM_ADDR_RUNTIME, totalRuntime);
|
|
EEPROM.commit();
|
|
lastPersistedRuntime = totalRuntime;
|
|
}
|
|
lastRuntimeSave = currentMillisLoop;
|
|
if (!initialRuntimeSaveDone) initialRuntimeSaveDone = true;
|
|
if (WiFi.getMode() == WIFI_STA) checkWifiConnection();
|
|
}
|
|
}
|
|
yield();
|
|
|
|
// Zur Speicherung des Bezugszaehlers
|
|
if (!shotActive) {
|
|
handleShotSaving(nullptr);
|
|
}
|
|
yield();
|
|
|
|
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Liest die Temperatur vom NTC-Sensor ueber den Spannungsteiler (ADC)
|
|
************************************************************************************/
|
|
double readNTCTemperatureFromPin(int pin) {
|
|
double sensorVoltage = NAN;
|
|
int adcValue = analogRead(pin);
|
|
|
|
const double MAX_ADC_VALUE = 4095.0; // ESP32 standard 12-bit
|
|
|
|
// Spannung ueber ADC-API ermitteln (kompatibel mit ADC driver_ng)
|
|
uint32_t voltage_mv = analogReadMilliVolts(pin);
|
|
if (voltage_mv > 0) {
|
|
sensorVoltage = voltage_mv / 1000.0;
|
|
} else {
|
|
sensorVoltage = (adcValue / MAX_ADC_VALUE) * ADC_REF;
|
|
}
|
|
|
|
if (sensorVoltage >= V_SUPPLY || sensorVoltage <= 0) {
|
|
return NAN;
|
|
}
|
|
|
|
double R_ntc = R_FIXED * ((V_SUPPLY / sensorVoltage) - 1.0);
|
|
if (R_ntc <= 0) {
|
|
return NAN;
|
|
}
|
|
|
|
double log_R_R0 = log(R_ntc / NTC_R0);
|
|
double temp_inv_K = (1.0 / NTC_T0) + (1.0 / beta) * log_R_R0;
|
|
|
|
if (temp_inv_K == 0) {
|
|
return NAN;
|
|
}
|
|
|
|
double temperatureK = 1.0 / temp_inv_K;
|
|
return temperatureK - 273.15;
|
|
}
|
|
|
|
double readNTCTemperature() {
|
|
return readNTCTemperatureFromPin(ANALOG_NTC_PIN);
|
|
}
|
|
|
|
double readCaseTemperature() {
|
|
return readNTCTemperatureFromPin(ANALOG_CASE_NTC_PIN);
|
|
}
|
|
|
|
#ifdef ENABLE_DISPLAY
|
|
void updateDisplay() {
|
|
recordResetCheckpoint(RESET_CP_DISPLAY_RENDER);
|
|
double displayedWaterTemp = getDisplayedWaterTemperature();
|
|
double displayedSteamTemp = getDisplayedSteamTemperature();
|
|
|
|
// =================================================================================
|
|
// HÖCHSTE PRIORITÄT: Spezielle Vollbild-Anzeigen, die alles andere überschreiben
|
|
// =================================================================================
|
|
static bool lastStandbyHoldHintDisplayed = false;
|
|
|
|
if (standbySwitchHeldHintActive) {
|
|
lastStandbyHoldHintDisplayed = true;
|
|
display.clearDisplay();
|
|
display.setTextSize(1);
|
|
display.setTextColor(SH110X_WHITE);
|
|
display.setCursor(0, 0);
|
|
display.println(F("Standby-Taster"));
|
|
display.println(F("bitte loslassen"));
|
|
display.println(F(""));
|
|
display.println(F("Dann kann erneut"));
|
|
display.println(F("umgeschaltet"));
|
|
display.println(F("werden."));
|
|
if (WiFi.status() == WL_CONNECTED) {
|
|
drawwifiSymbol(113, 0);
|
|
}
|
|
display.display();
|
|
return;
|
|
}
|
|
|
|
static bool lastStandbyDisplayActive = false;
|
|
static int lastStandbyDisplayMinute = -1;
|
|
if (standbyModeActive) {
|
|
bool shouldRender = false;
|
|
bool canShowTime = false;
|
|
struct tm timeinfo;
|
|
if (standbyTimeOnDisplay && timeSynced) {
|
|
time_t now_t;
|
|
time(&now_t);
|
|
localtime_r(&now_t, &timeinfo);
|
|
if (timeinfo.tm_year > (2023 - 1900)) {
|
|
canShowTime = true;
|
|
}
|
|
}
|
|
if (!lastStandbyDisplayActive) {
|
|
shouldRender = true;
|
|
lastStandbyDisplayMinute = -1;
|
|
}
|
|
if (lastStandbyHoldHintDisplayed) {
|
|
shouldRender = true;
|
|
lastStandbyHoldHintDisplayed = false;
|
|
lastStandbyDisplayMinute = -1;
|
|
}
|
|
if (canShowTime && timeinfo.tm_min != lastStandbyDisplayMinute) {
|
|
shouldRender = true;
|
|
lastStandbyDisplayMinute = timeinfo.tm_min;
|
|
}
|
|
if (!shouldRender) {
|
|
lastStandbyDisplayActive = true;
|
|
return;
|
|
}
|
|
display.clearDisplay();
|
|
if (canShowTime) {
|
|
char timeBuffer[6];
|
|
strftime(timeBuffer, sizeof(timeBuffer), "%H:%M", &timeinfo);
|
|
int16_t x1, y1;
|
|
uint16_t w, h;
|
|
display.setTextColor(SH110X_WHITE);
|
|
display.setTextSize(2);
|
|
display.getTextBounds(timeBuffer, 0, 0, &x1, &y1, &w, &h);
|
|
display.setCursor((display.width() - w) / 2, (display.height() - h) / 2);
|
|
display.print(timeBuffer);
|
|
}
|
|
display.display();
|
|
lastStandbyDisplayActive = true;
|
|
return;
|
|
}
|
|
lastStandbyDisplayActive = false;
|
|
lastStandbyHoldHintDisplayed = false;
|
|
|
|
static bool lastSteamCircuitDisplayActive = false;
|
|
static unsigned long steamCircuitDisplayStartTime = 0;
|
|
static unsigned long steamCircuitDisplayEndTime = 0;
|
|
static unsigned long lastSteamCircuitDurationMs = 0;
|
|
const unsigned long steamTimerHoldAfterEndMs = 3000UL;
|
|
if (steamCircuitActive && !lastSteamCircuitDisplayActive) {
|
|
steamCircuitDisplayStartTime = millis();
|
|
steamCircuitDisplayEndTime = 0;
|
|
lastSteamCircuitDurationMs = 0;
|
|
} else if (!steamCircuitActive && lastSteamCircuitDisplayActive) {
|
|
if (steamCircuitDisplayStartTime > 0) {
|
|
lastSteamCircuitDurationMs = millis() - steamCircuitDisplayStartTime;
|
|
} else {
|
|
lastSteamCircuitDurationMs = 0;
|
|
}
|
|
steamCircuitDisplayEndTime = millis();
|
|
steamCircuitDisplayStartTime = 0;
|
|
}
|
|
lastSteamCircuitDisplayActive = steamCircuitActive;
|
|
|
|
if (pendingShotStartAfterScaleTare && scaleEnabled && scaleType == SCALE_HX711) {
|
|
display.clearDisplay();
|
|
display.setTextSize(1);
|
|
display.setTextColor(SH110X_WHITE);
|
|
display.setCursor(0, 0);
|
|
display.println(F("Bezug startet"));
|
|
display.println(F(""));
|
|
display.println(F("Waage wird"));
|
|
display.println(F("tariert..."));
|
|
if (WiFi.status() == WL_CONNECTED) {
|
|
drawwifiSymbol(113, 0);
|
|
}
|
|
display.display();
|
|
return;
|
|
}
|
|
|
|
if (scaleModeActive && scaleEnabled && scaleType == SCALE_HX711 && isHx711TareSequenceActive()) {
|
|
display.clearDisplay();
|
|
display.setTextSize(1);
|
|
display.setTextColor(SH110X_WHITE);
|
|
display.setCursor(0, 0);
|
|
display.println(F("Waage:"));
|
|
display.println(F(""));
|
|
display.println(F("Waage wird"));
|
|
display.println(F("tariert..."));
|
|
if (WiFi.status() == WL_CONNECTED) {
|
|
drawwifiSymbol(113, 0);
|
|
}
|
|
display.display();
|
|
return;
|
|
}
|
|
|
|
// --- Anzeige: Waage-Modus aktiv ---
|
|
if (scaleModeActive) {
|
|
display.clearDisplay();
|
|
display.setTextSize(1);
|
|
display.setTextColor(SH110X_WHITE);
|
|
display.setCursor(0, 0);
|
|
display.println(F("Waage:"));
|
|
display.println(F(""));
|
|
display.setTextSize(3);
|
|
display.setCursor(0, 20);
|
|
if (scaleConnected) {
|
|
// --- START ÄNDERUNG ---
|
|
float weightToShow = getWeightReadingForUi();
|
|
// Verhindert die Anzeige von "-0.0" bei sehr kleinen negativen Werten
|
|
if (weightToShow < 0.0 && weightToShow > -0.05) {
|
|
weightToShow = 0.0;
|
|
}
|
|
|
|
char weightBuffer[10];
|
|
// Benutzen Sie die neue Variable für die Anzeige
|
|
snprintf(weightBuffer, sizeof(weightBuffer), "%.1f g", weightToShow);
|
|
// --- ENDE ÄNDERUNG ---
|
|
|
|
int16_t x1, y1;
|
|
uint16_t w, h;
|
|
display.getTextBounds(weightBuffer, 0, 0, &x1, &y1, &w, &h);
|
|
display.setCursor((display.width() - w) / 2, 20);
|
|
display.println(weightBuffer);
|
|
} else {
|
|
display.setTextSize(1);
|
|
display.setCursor(0, 20);
|
|
display.println(F("Waage nicht"));
|
|
display.setCursor(0, 30);
|
|
display.println(F("verbunden!"));
|
|
}
|
|
display.setTextSize(1);
|
|
if (WiFi.status() == WL_CONNECTED) {
|
|
drawwifiSymbol(113, 0);
|
|
}
|
|
display.display();
|
|
return; // WICHTIG: Funktion hier beenden
|
|
}
|
|
|
|
// --- Anzeige: Wartungsmodus aktiv ---
|
|
if (wartungsModusAktiv) {
|
|
display.clearDisplay();
|
|
display.setCursor(0, 0);
|
|
display.setTextSize(1);
|
|
display.setTextColor(SH110X_WHITE);
|
|
display.println(F("Wartungsmodus"));
|
|
display.println(F("ist aktiv!"));
|
|
display.println(F(""));
|
|
display.println(F("Temperaturen werden"));
|
|
display.println(F("auf "));
|
|
display.print(wartungsModusTemp);
|
|
display.print(F(" "));
|
|
display.print((char)247);
|
|
display.print(F("C gehalten."));
|
|
if (WiFi.status() == WL_CONNECTED) {
|
|
drawwifiSymbol(113, 0);
|
|
}
|
|
display.display();
|
|
return; // Funktion hier beenden
|
|
}
|
|
|
|
// --- Anzeige: Reinigungsassistent aktiv ---
|
|
if (cleaningAssistantActive) {
|
|
display.clearDisplay();
|
|
display.setCursor(0, 0);
|
|
display.setTextSize(1);
|
|
display.setTextColor(SH110X_WHITE);
|
|
display.println(F("Reinigung aktiv:"));
|
|
if (cleaningAssistantWaitingForTemperature) {
|
|
display.println(F("Heizt auf 93 C"));
|
|
display.println(F("vor..."));
|
|
display.print(F("Wasser: "));
|
|
display.print((int)displayedWaterTemp);
|
|
display.print(F("/"));
|
|
display.print((int)cleaningAssistantWaterSetpoint);
|
|
display.print(F(" "));
|
|
display.print((char)247);
|
|
display.println(F("C"));
|
|
display.println(F("Zyklen starten"));
|
|
display.println(F("automatisch."));
|
|
} else {
|
|
display.print(F("Zyklus: "));
|
|
display.print(cleaningAssistantCurrentCycle);
|
|
display.print(F("/"));
|
|
display.println(cleaningAssistantCycles);
|
|
display.print(F("Phase: "));
|
|
display.println(cleaningAssistantInBrewPhase ? F("Bezug") : F("Pause"));
|
|
display.print(F("Rest: "));
|
|
unsigned long phaseDurationMs = cleaningAssistantInBrewPhase
|
|
? (unsigned long)cleaningAssistantBrewSeconds * 1000UL
|
|
: (unsigned long)cleaningAssistantPauseSeconds * 1000UL;
|
|
unsigned long elapsedMs = millis() - cleaningAssistantPhaseStartTime;
|
|
unsigned long remainingMs = (elapsedMs < phaseDurationMs) ? (phaseDurationMs - elapsedMs) : 0;
|
|
display.print((remainingMs + 999UL) / 1000UL);
|
|
display.println(F(" s"));
|
|
display.print(F("Wasser: "));
|
|
display.print((int)displayedWaterTemp);
|
|
display.print(F("/"));
|
|
display.print((int)cleaningAssistantWaterSetpoint);
|
|
display.print(F(" "));
|
|
display.print((char)247);
|
|
display.println(F("C"));
|
|
display.println(F("Dampfheiz. aus"));
|
|
}
|
|
if (WiFi.status() == WL_CONNECTED) {
|
|
drawwifiSymbol(113, 0);
|
|
}
|
|
display.display();
|
|
return; // Funktion hier beenden
|
|
}
|
|
|
|
// --- Anzeige: PID-Tuning aktiv ---
|
|
if (autoTuneWasserActive || autoTuneDampfActive) {
|
|
display.clearDisplay();
|
|
display.setCursor(0, 0);
|
|
display.setTextSize(1);
|
|
display.setTextColor(SH110X_WHITE);
|
|
display.println(F("PID-Tuning aktiv:"));
|
|
display.println(autoTuneWasserActive ? F("Wasser-PID") : F("Dampf-PID"));
|
|
display.println("");
|
|
int currentPeakCount = 0;
|
|
int currentPeakType = 0;
|
|
if (autoTuneWasserActive && autoTuneWasser != nullptr) {
|
|
currentPeakCount = autoTuneWasser->getPeakCount();
|
|
currentPeakType = autoTuneWasser->getPeakType();
|
|
} else if (autoTuneDampfActive && autoTuneDampf != nullptr) {
|
|
currentPeakCount = autoTuneDampf->getPeakCount();
|
|
currentPeakType = autoTuneDampf->getPeakType();
|
|
}
|
|
display.print(F("Cycle: "));
|
|
display.print( (currentPeakCount < 0 || currentPeakCount > 10) ? "0" : String(currentPeakCount) );
|
|
display.println(F(" von 5-10"));
|
|
display.print(F("Phase: "));
|
|
if (currentPeakType == 1) { display.print(F("Heizen")); }
|
|
else if (currentPeakType == -1) { display.print(F("K\201hlen")); }
|
|
else { display.print(F("Init")); }
|
|
display.println();
|
|
display.print("Temperatur:");
|
|
display.print((int)(autoTuneWasserActive ? displayedWaterTemp : displayedSteamTemp));
|
|
display.print(F(" "));
|
|
display.print((char)247);
|
|
display.print(F("C"));
|
|
if (WiFi.status() == WL_CONNECTED) {
|
|
drawwifiSymbol(113, 0);
|
|
}
|
|
display.display();
|
|
return; // Funktion hier beenden
|
|
}
|
|
|
|
// =================================================================================
|
|
// STANDARD-ANZEIGEN-LOGIK
|
|
// =================================================================================
|
|
display.clearDisplay();
|
|
if (WiFi.status() == WL_CONNECTED) {
|
|
drawwifiSymbol(113, 0);
|
|
}
|
|
display.setCursor(0, 0);
|
|
display.setTextColor(SH110X_WHITE);
|
|
display.setTextSize(1);
|
|
|
|
// --- Anzeige: Fast-Heat-Up aktiv ---
|
|
if (fastHeatUpHeating && !shotActive && !wasserSensorError && ((int)InputWasser < fastHeatUpSetpoint)) {
|
|
SetpointWasser = fastHeatUpSetpoint;
|
|
display.println(F("Fast-Heat-Up aktiv:"));
|
|
display.println("");
|
|
display.println(F("20 Sek. Flushen,"));
|
|
display.println(F("wenn Temp. erreicht!"));
|
|
display.println(F(" "));
|
|
display.println(F("Temperatur: "));
|
|
display.print((int)displayedWaterTemp);
|
|
display.print(F(" / "));
|
|
display.print((int)SetpointWasser);
|
|
display.print(F(" "));
|
|
display.print((char)247);
|
|
display.print(F("C"));
|
|
}
|
|
else {
|
|
//
|
|
// --- ZUSTANDSMASCHINE FÜR ANZEIGEN NACH DEM SHOT ---
|
|
//
|
|
if (postShotDisplayState != POST_SHOT_IDLE) {
|
|
switch (postShotDisplayState) {
|
|
|
|
case POST_SHOT_SHOW_WEIGHT_RESULT: {
|
|
// --- Werte berechnen ---
|
|
float finalWeight = lastShotFinalNetWeight + brewByWeightOffsetGrams;
|
|
float totalDurationSec = lastShotDurationMillis / 1000.0;
|
|
float activeDurationSec = 0.0f;
|
|
if (lastShotFirstWeightChangeTime > 0 && shotEndTime > lastShotFirstWeightChangeTime) {
|
|
activeDurationSec = (shotEndTime - lastShotFirstWeightChangeTime) / 1000.0f;
|
|
if (preInfusionEnabled) {
|
|
unsigned long pauseStart = lastShotStartTime + (unsigned long)(preInfusionDurationSeconds * 1000.0f);
|
|
if (lastShotFirstWeightChangeTime < pauseStart) {
|
|
activeDurationSec -= preInfusionPauseSeconds;
|
|
}
|
|
}
|
|
} else {
|
|
activeDurationSec = totalDurationSec;
|
|
if (preInfusionEnabled) {
|
|
activeDurationSec -= preInfusionPauseSeconds;
|
|
}
|
|
}
|
|
if (activeDurationSec < 0) activeDurationSec = 0;
|
|
float flowRate = 0.0;
|
|
if (activeDurationSec > 0) {
|
|
flowRate = finalWeight / activeDurationSec;
|
|
}
|
|
|
|
// --- Buffer für die formatierten Strings ---
|
|
char weightBuffer[12];
|
|
char totalTimeBuffer[12];
|
|
char activeTimeBuffer[12];
|
|
char flowBuffer[15];
|
|
|
|
// --- Werte in Strings formatieren ---
|
|
snprintf(weightBuffer, sizeof(weightBuffer), "%.1f g", finalWeight);
|
|
snprintf(totalTimeBuffer, sizeof(totalTimeBuffer), "%.1f s", totalDurationSec);
|
|
snprintf(activeTimeBuffer, sizeof(activeTimeBuffer), "%.1f s", activeDurationSec);
|
|
snprintf(flowBuffer, sizeof(flowBuffer), "%.2f g/s", flowRate);
|
|
|
|
// --- Strukturiertes Layout mit allen Details ---
|
|
display.setTextSize(1);
|
|
|
|
// Titel anzeigen
|
|
display.setCursor(0, 0);
|
|
display.print("Bezug beendet");
|
|
|
|
// Layout-Positionen definieren
|
|
const int LABEL_X = 0;
|
|
const int VALUE_X = 58;
|
|
const int Y_LINE_1 = 14;
|
|
const int Y_LINE_2 = 24;
|
|
const int Y_LINE_3 = 34;
|
|
const int Y_LINE_4 = 44;
|
|
|
|
// Zeile: Gewicht
|
|
display.setCursor(LABEL_X, Y_LINE_1);
|
|
display.print("Gewicht:");
|
|
display.setCursor(VALUE_X, Y_LINE_1);
|
|
display.print(weightBuffer);
|
|
|
|
// Zeile: Gesamtdauer
|
|
display.setCursor(LABEL_X, Y_LINE_2);
|
|
display.print("Gesamt:");
|
|
display.setCursor(VALUE_X, Y_LINE_2);
|
|
display.print(totalTimeBuffer);
|
|
|
|
// Zeile: Aktive Dauer (nur wenn Pre-Infusion aktiv war)
|
|
if (preInfusionEnabled) {
|
|
display.setCursor(LABEL_X, Y_LINE_3);
|
|
display.print("Aktiv:");
|
|
display.setCursor(VALUE_X, Y_LINE_3);
|
|
display.print(activeTimeBuffer);
|
|
}
|
|
|
|
// Zeile: Flow-Rate
|
|
display.setCursor(LABEL_X, Y_LINE_4);
|
|
display.print("Flow:");
|
|
display.setCursor(VALUE_X, Y_LINE_4);
|
|
display.print(flowBuffer);
|
|
|
|
|
|
// --- Übergangslogik zum nächsten Zustand (bleibt unverändert) ---
|
|
if (millis() > shotEndTime + 5000) { // Anzeigezeit auf 5s erhöht für mehr Infos
|
|
if (maintenanceInterval > 0 && maintenanceIntervalCounter >= maintenanceInterval) {
|
|
postShotDisplayState = POST_SHOT_SHOW_MAINTENANCE;
|
|
maintenanceMessageStartTime = millis();
|
|
if (piezoEnabled) {
|
|
beepMaintenanceAlert();
|
|
}
|
|
} else {
|
|
postShotDisplayState = POST_SHOT_IDLE;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case POST_SHOT_SHOW_DURATION: {
|
|
// --- Zustand 2: Bezugsdauer anzeigen ---
|
|
display.println(F("Bezugszeit:"));
|
|
display.println("");
|
|
display.setTextSize(3);
|
|
char buffer[6];
|
|
snprintf(buffer, sizeof(buffer), "%.1f", lastShotDurationMillis / 1000.0);
|
|
display.println(buffer);
|
|
display.setTextSize(1);
|
|
display.println(F("Sekunden"));
|
|
|
|
// Prüfen, ob 2 Sekunden um sind
|
|
if (millis() > shotEndTime + 2000) {
|
|
// Prüfen, ob eine Wartung fällig ist
|
|
if (maintenanceInterval > 0 && maintenanceIntervalCounter >= maintenanceInterval) {
|
|
// JA -> Gehe zu Zustand 2 (Wartung anzeigen)
|
|
postShotDisplayState = POST_SHOT_SHOW_MAINTENANCE;
|
|
maintenanceMessageStartTime = millis(); // Timer für Meldung JETZT starten
|
|
if (piezoEnabled) {
|
|
beepMaintenanceAlert(); // Piepton perfekt synchron zur Meldung auslösen!
|
|
}
|
|
} else {
|
|
// NEIN -> Sequenz beendet
|
|
postShotDisplayState = POST_SHOT_IDLE;
|
|
}
|
|
}
|
|
break; // Wichtig!
|
|
}
|
|
|
|
case POST_SHOT_SHOW_MAINTENANCE: {
|
|
// --- Zustand 3: Wartungsmeldung anzeigen ---
|
|
display.println(F("Erinnerung:"));
|
|
display.println(F(""));
|
|
display.println(F("Reinigung /"));
|
|
display.println(F("Wartung"));
|
|
|
|
// Prüfen, ob 6 Sekunden um sind
|
|
if (millis() > maintenanceMessageStartTime + 6000) {
|
|
displayMaintenanceMessage = false; // Flag für alle Fälle zurücksetzen
|
|
postShotDisplayState = POST_SHOT_IDLE; // Sequenz beendet
|
|
}
|
|
break; // Wichtig!
|
|
}
|
|
}
|
|
}
|
|
// --- ENDE DER ZUSTANDSMASCHINE ---
|
|
|
|
// Wenn die Zustandsmaschine nicht aktiv ist, zeige den laufenden Shot oder die Temperaturen
|
|
else if (shotActive) {
|
|
// Priorität: Shot läuft *aktuell*?
|
|
if (brewByWeightEnabled && scaleConnected) {
|
|
display.setTextSize(1);
|
|
display.setCursor(0, 0);
|
|
display.println(F("Bezug aktiv:"));
|
|
unsigned long elapsed = millis() - shotStartTime;
|
|
char timeBuffer[8];
|
|
snprintf(timeBuffer, sizeof(timeBuffer), "%.1fs", elapsed / 1000.0);
|
|
display.setCursor(0, 15);
|
|
display.println(timeBuffer);
|
|
|
|
// --- START ÄNDERUNG ---
|
|
float netWeight = currentWeightReading - brewStartWeight;
|
|
// Verhindert die Anzeige von "-0.0" für das Nettogewicht
|
|
if (netWeight < 0.0 && netWeight > -0.05) {
|
|
netWeight = 0.0;
|
|
}
|
|
char weightBuffer[15];
|
|
snprintf(weightBuffer, sizeof(weightBuffer), "%.1f/%.1fg", netWeight, brewByWeightTargetGrams);
|
|
// --- ENDE ÄNDERUNG ---
|
|
|
|
display.setCursor(0, 30);
|
|
display.println(weightBuffer);
|
|
display.println("");
|
|
if(preInfusionEnabled && currentPreInfusionState == PI_PRE_BREW) {display.println(F("Pre-Infusion ..."));}
|
|
if(preInfusionEnabled && currentPreInfusionState == PI_PAUSE) {display.println(F("Pause ..."));}
|
|
if(preInfusionEnabled && currentPreInfusionState == PI_MAIN_BREW) {display.println(F("Pre-Infusion beendet."));}
|
|
} else {
|
|
unsigned long elapsed = millis() - shotStartTime;
|
|
display.println(F("Shot-Timer:"));
|
|
display.println("");
|
|
display.setTextSize(3);
|
|
char buffer[6];
|
|
snprintf(buffer, sizeof(buffer), "%.1f", elapsed / 1000.0);
|
|
display.println(buffer);
|
|
display.setTextSize(1);
|
|
display.println(F("Sekunden"));
|
|
if(preInfusionEnabled && currentPreInfusionState == PI_PRE_BREW) {display.println(F("Pre-Infusion ..."));}
|
|
if(preInfusionEnabled && currentPreInfusionState == PI_PAUSE) {display.println(F("Pause ..."));}
|
|
if(preInfusionEnabled && currentPreInfusionState == PI_MAIN_BREW) {display.println(F("Pre-Infusion beendet."));}
|
|
}
|
|
fastHeatUpHeating = false;
|
|
}
|
|
else if (steamCircuitActive || (steamCircuitDisplayEndTime > 0 && (millis() - steamCircuitDisplayEndTime) <= steamTimerHoldAfterEndMs)) {
|
|
unsigned long steamElapsedMs = steamCircuitActive
|
|
? ((steamCircuitDisplayStartTime > 0) ? (millis() - steamCircuitDisplayStartTime) : 0)
|
|
: lastSteamCircuitDurationMs;
|
|
display.println(F("Dampf-Timer:"));
|
|
display.println("");
|
|
display.setTextSize(3);
|
|
char buffer[6];
|
|
snprintf(buffer, sizeof(buffer), "%.1f", steamElapsedMs / 1000.0);
|
|
display.println(buffer);
|
|
display.setTextSize(1);
|
|
display.println(F("Sekunden"));
|
|
if (!steamCircuitActive) {
|
|
display.println(F("Dampf beendet"));
|
|
}
|
|
}
|
|
else if (flushActive) {
|
|
unsigned long nowMs = millis();
|
|
unsigned long flushRemainingMs = 0;
|
|
if ((long)(flushEndTime - nowMs) > 0) {
|
|
flushRemainingMs = flushEndTime - nowMs;
|
|
}
|
|
unsigned long flushDurationMs = (unsigned long)flushDurationSeconds * 1000UL;
|
|
unsigned long flushElapsedMs = 0;
|
|
if (flushRemainingMs < flushDurationMs) {
|
|
flushElapsedMs = flushDurationMs - flushRemainingMs;
|
|
}
|
|
display.println(F("Flush-Timer:"));
|
|
display.println("");
|
|
display.setTextSize(3);
|
|
char buffer[6];
|
|
snprintf(buffer, sizeof(buffer), "%.1f", flushElapsedMs / 1000.0);
|
|
display.println(buffer);
|
|
display.setTextSize(1);
|
|
display.println(F("Sekunden"));
|
|
}
|
|
else {
|
|
// Standardanzeige: Temperaturen / Fehler / Sicherheitsabschaltung (Fallback, wenn nichts anderes zu tun ist)
|
|
bool ecoDisplayContentAvailable = false;
|
|
if (ecoInfoOnDisplay) {
|
|
ecoDisplayContentAvailable = ecoModeAktiv || (ecoModeMinutes > 0 && !ecoSwitchActive);
|
|
}
|
|
bool caseDisplayContentAvailable = caseTempOnDisplay && caseSensorEnabled && !caseSensorError && !isnan(InputCase);
|
|
bool displayAdditionalInfo = ecoDisplayContentAvailable || caseDisplayContentAvailable;
|
|
bool showEcoInfoBlock = false;
|
|
bool showCaseInfoBlock = false;
|
|
if (displayAdditionalInfo) {
|
|
if (ecoDisplayContentAvailable && caseDisplayContentAvailable) {
|
|
bool showCaseNow = ((millis() / 5000UL) % 2UL) == 1UL;
|
|
showCaseInfoBlock = showCaseNow;
|
|
showEcoInfoBlock = !showCaseNow;
|
|
} else {
|
|
showEcoInfoBlock = ecoDisplayContentAvailable;
|
|
showCaseInfoBlock = caseDisplayContentAvailable;
|
|
}
|
|
}
|
|
if (!displayAdditionalInfo) {
|
|
display.println(F("Temperaturen:"));
|
|
display.println("");
|
|
}
|
|
display.println(F("Wasser: "));
|
|
if (wasserSafetyShutdown) { display.print(F("Sicherheitsabsch.")); }
|
|
else if (wasserSensorError) { display.print(F("Sensorfehler!")); }
|
|
else {
|
|
display.print((int)displayedWaterTemp);
|
|
display.print(F(" / "));
|
|
display.print((int)SetpointWasser);
|
|
display.print(F(" "));
|
|
display.print((char)247);
|
|
display.print(F("C"));
|
|
if (ecoModeAktiv && dynamicEcoActive) display.print(ecoSwitchActive ? F(" (ECO+ S)") : F(" (ECO+)"));
|
|
else if (ecoModeAktiv && !dynamicEcoActive) display.print(ecoSwitchActive ? F(" (ECO S)") : F(" (ECO)"));
|
|
}
|
|
display.println("");
|
|
display.println("");
|
|
display.println(F("Dampf: "));
|
|
if (dampfSafetyShutdown) { display.print(F("Sicherheitsabsch.")); }
|
|
else if (dampfSensorError) { display.print(F("Sensorfehler!")); }
|
|
else if (steamHeatDisabledByUser) { display.print(F("Nicht Heizen aktiv")); }
|
|
else {
|
|
bool steamDelayActiveSystem = (dampfVerzoegerung > 0 && (millis() - startupTime < (unsigned long)dampfVerzoegerung * 60000UL));
|
|
bool shouldShowDelayMessage = steamDelayActiveSystem && !steamDelayOverridden;
|
|
if (!shouldShowDelayMessage) {
|
|
display.print((int)displayedSteamTemp);
|
|
display.print(F(" / "));
|
|
display.print((int)SetpointDampf);
|
|
display.print(F(" "));
|
|
display.print((char)247);
|
|
display.print(F("C"));
|
|
if (ecoModeAktiv && dynamicEcoActive) display.print(ecoSwitchActive ? F(" (ECO+ S)") : F(" (ECO+)"));
|
|
else if (ecoModeAktiv && !dynamicEcoActive) display.print(ecoSwitchActive ? F(" (ECO S)") : F(" (ECO)"));
|
|
} else {
|
|
display.print(F("Startverz\x94gerung"));
|
|
}
|
|
}
|
|
|
|
if (showEcoInfoBlock) {
|
|
if (ecoModeAktiv) {
|
|
display.println("");
|
|
display.println("");
|
|
display.print(dynamicEcoActive ? F("Eco+ Modus aktiv") : F("Eco-Modus aktiv"));
|
|
if (ecoSwitchActive) {
|
|
display.print(F(" (Schalter)"));
|
|
}
|
|
} else if (ecoModeMinutes > 0 && !ecoSwitchActive) {
|
|
display.println("");
|
|
display.println("");
|
|
unsigned long elapsedMs = millis() - lastShotTime;
|
|
unsigned long ecoMs = (unsigned long)ecoModeMinutes * 60UL * 1000UL;
|
|
long remainingMs = (long)ecoMs - (long)elapsedMs;
|
|
int remainingMin = 0;
|
|
if (remainingMs > 0) {
|
|
remainingMin = (int)((remainingMs + 60000UL - 1) / 60000UL);
|
|
}
|
|
display.print(F("Eco-Modus in "));
|
|
display.print(remainingMin);
|
|
display.print(F("min"));
|
|
}
|
|
} else if (showCaseInfoBlock) {
|
|
display.println("");
|
|
display.println("");
|
|
if (caseSensorType == CASE_SENSOR_TYPE_CUPTRAY) {
|
|
display.print(F("Tassenablage: "));
|
|
} else {
|
|
display.print(F("Geh\201use: "));
|
|
}
|
|
display.print((int)InputCase);
|
|
display.print(F(" "));
|
|
display.print((char)247);
|
|
display.print(F("C"));
|
|
}
|
|
}
|
|
}
|
|
|
|
yield();
|
|
display.display(); // Display *immer* am Ende aktualisieren
|
|
}
|
|
#endif // ENABLE_DISPLAY
|
|
|
|
void updateShotTimer() {
|
|
if (cleaningAssistantActive) {
|
|
pendingShotStartAfterScaleTare = false;
|
|
hx711ShotTarePrepared = false;
|
|
hx711ShotPostTareReadsRemaining = 0;
|
|
shotSoftwareActive = false;
|
|
return;
|
|
}
|
|
|
|
bool shotInputActive = shotSoftwareActive;
|
|
|
|
if (pendingShotStartAfterScaleTare) {
|
|
if (!shotInputActive || standbyModeActive) {
|
|
pendingShotStartAfterScaleTare = false;
|
|
hx711ShotTarePrepared = false;
|
|
hx711ShotPostTareReadsRemaining = 0;
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// --- Shot beginnt (Eingang ist aktiv) ---
|
|
if (shotInputActive && !shotActive) {
|
|
|
|
if (scaleEnabled && brewByWeightEnabled && scaleType == SCALE_HX711 && !hx711ShotTarePrepared) {
|
|
scaleModeActive = false;
|
|
requestHx711Tare(true, false);
|
|
return;
|
|
}
|
|
|
|
// Pruefen, ob die Dampfverzoegerung per Schalter uebersprungen werden soll
|
|
if (steamDelayOverrideBySwitchEnabled) {
|
|
steamDelayOverridden = true;
|
|
}
|
|
|
|
scaleModeActive = false; // Manuellen Waage-Anzeigemodus beim Start des Bezugs beenden
|
|
shotActive = true;
|
|
shotStartTime = millis();
|
|
lastShotDurationMillis = 0;
|
|
shotEndTime = 0;
|
|
firstWeightChangeDetected = false;
|
|
firstWeightChangeTime = 0;
|
|
resetFlowGuardRuntime();
|
|
|
|
if (!wartungsModusAktiv) { // Nur ausfuehren, wenn NICHT im Wartungsmodus
|
|
displayMaintenanceMessage = false; // Wartungsmeldung ausblenden
|
|
|
|
// Eco Modus direkt beenden, falls aktiv
|
|
if (ecoModeAktiv && !ecoSwitchActive) {
|
|
ecoModeAktiv = false;
|
|
ecoModeActivatedTime = 0;
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
pidWasser.SetMode(AUTOMATIC);
|
|
pidDampf.SetMode(AUTOMATIC);
|
|
}
|
|
}
|
|
|
|
if (!wartungsModusAktiv) {
|
|
if (scaleEnabled) {
|
|
if (scaleType == SCALE_I2C) {
|
|
if (scaleConnected) {
|
|
scales.setOffset();
|
|
delay(50);
|
|
recordResetCheckpoint(RESET_CP_SCALE_I2C_READ);
|
|
resetScaleReadingFilters(0.0f);
|
|
}
|
|
brewStartWeight = 0.0f;
|
|
} else if (scaleType == SCALE_HX711) {
|
|
if (hx711ShotTarePrepared) {
|
|
hx711ShotTarePrepared = false;
|
|
hx711ShotPostTareReadsRemaining = 0;
|
|
brewStartWeight = 0.0f;
|
|
} else if (hx711.is_ready()) {
|
|
hx711.tare();
|
|
resetScaleReadingFilters(0.0f);
|
|
hx711LastReadyTime = millis();
|
|
hx711HasValidReading = true;
|
|
hx711NotReadyCounter = 0;
|
|
scaleConnected = true;
|
|
brewStartWeight = 0.0f;
|
|
} else {
|
|
brewStartWeight = 0.0f;
|
|
}
|
|
} else if (scaleType == SCALE_ESPNOW) {
|
|
brewStartWeight = currentWeightReading;
|
|
}
|
|
}
|
|
|
|
// Pre-Infusion oder direkten Start behandeln
|
|
if (preInfusionEnabled) {
|
|
currentPreInfusionState = PI_PRE_BREW;
|
|
preInfusionPhaseStartTime = millis();
|
|
digitalWrite(PUMP_PIN, HIGH);
|
|
digitalWrite(VALVE_PIN, HIGH);
|
|
} else {
|
|
currentPreInfusionState = PI_MAIN_BREW;
|
|
preInfusionPhaseStartTime = millis();
|
|
digitalWrite(PUMP_PIN, HIGH);
|
|
digitalWrite(VALVE_PIN, HIGH);
|
|
}
|
|
} else { // Im Wartungsmodus: Pumpe und Ventil direkt schalten
|
|
digitalWrite(PUMP_PIN, HIGH);
|
|
digitalWrite(VALVE_PIN, HIGH);
|
|
}
|
|
}
|
|
// --- Shot ends (switch released) ---
|
|
else if (!shotInputActive && shotActive) {
|
|
unsigned long shotDuration = millis() - shotStartTime;
|
|
if (!wartungsModusAktiv) {
|
|
stopBrewSequence(shotDuration, true, false);
|
|
} else {
|
|
digitalWrite(PUMP_PIN, LOW);
|
|
digitalWrite(VALVE_PIN, LOW);
|
|
lastShotDurationMillis = shotDuration;
|
|
shotEndTime = millis();
|
|
resetFlowGuardRuntime();
|
|
}
|
|
|
|
shotActive = false;
|
|
}
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Startet AutoTune für Wasser-PID (Angepasst für Zeitproportional)
|
|
************************************************************************************/
|
|
|
|
void startAutoTuneWasser() {
|
|
// Eco-Modus deaktivieren, falls aktiv
|
|
if (ecoModeAktiv) {
|
|
ecoModeAktiv = false;
|
|
// Ursprünglichen Setpoint wiederherstellen (aus EEPROM oder Profil)
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
// Optional: Auch Dampf-Setpoint wiederherstellen, falls Eco beide beeinflusst
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
}
|
|
|
|
// Stelle sicher, dass der PID im Automatikmodus ist bevor Tuning beginnt
|
|
pidWasser.SetMode(AUTOMATIC);
|
|
|
|
// Sicherstellen, dass die Output-Grenzen korrekt gesetzt sind
|
|
pidWasser.SetOutputLimits(0, windowSizeWasser);
|
|
|
|
autoTuneWasser = new PID_ATune(&InputWasser, &OutputWasser);
|
|
// Verwende die Wasser-spezifischen Parameter
|
|
autoTuneWasser->SetOutputStep(tuningStepWasser); // Angepasst
|
|
autoTuneWasser->SetControlType(1); // DIRECT control (heating)
|
|
autoTuneWasser->SetNoiseBand(tuningNoiseWasser); // Angepasst
|
|
autoTuneWasser->SetLookbackSec((int)tuningLookBackWasser); // Angepasst (Cast zu int für Funktion)
|
|
|
|
// Setze den Startwert für den Output
|
|
// WICHTIG: AutoTune manipuliert OutputWasser direkt. Die zeitproportionale
|
|
// Logik in loop() wird diesen Wert verwenden, um den SSR zu steuern.
|
|
OutputWasser = tuningStartValueWasser; // Angepasst
|
|
|
|
// KEIN direktes digitalWrite mehr hier! Die loop() übernimmt das.
|
|
|
|
autoTuneWasserActive = true;
|
|
autoTuneWasserStatus = AT_RUNNING;
|
|
// Serial.println("Starte AutoTune für Wasser (Zeitproportional)...");
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Startet AutoTune für Dampf-PID (Angepasst für Zeitproportional)
|
|
************************************************************************************/
|
|
|
|
void startAutoTuneDampf() {
|
|
// Eco-Modus deaktivieren, falls aktiv
|
|
if (ecoModeAktiv) {
|
|
ecoModeAktiv = false;
|
|
// Ursprünglichen Setpoint wiederherstellen (aus EEPROM oder Profil)
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
// Optional: Auch Wasser-Setpoint wiederherstellen, falls Eco beide beeinflusst
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
}
|
|
|
|
// Stelle sicher, dass der PID im Automatikmodus ist bevor Tuning beginnt
|
|
pidDampf.SetMode(AUTOMATIC);
|
|
|
|
// Sicherstellen, dass die Output-Grenzen korrekt gesetzt sind
|
|
pidDampf.SetOutputLimits(0, windowSizeDampf);
|
|
|
|
autoTuneDampf = new PID_ATune(&InputDampf, &OutputDampf);
|
|
// Verwende die Dampf-spezifischen Parameter
|
|
autoTuneDampf->SetOutputStep(tuningStepDampf); // Angepasst
|
|
autoTuneDampf->SetControlType(1); // DIRECT control (heating)
|
|
autoTuneDampf->SetNoiseBand(tuningNoiseDampf); // Angepasst
|
|
autoTuneDampf->SetLookbackSec((int)tuningLookBackDampf); // Angepasst (Cast zu int für Funktion)
|
|
|
|
// Setze den Startwert für den Output
|
|
// WICHTIG: AutoTune manipuliert OutputDampf direkt. Die zeitproportionale
|
|
// Logik in loop() wird diesen Wert verwenden, um den SSR zu steuern.
|
|
OutputDampf = tuningStartValueDampf; // Angepasst
|
|
|
|
// KEIN direktes digitalWrite mehr hier! Die loop() übernimmt das.
|
|
|
|
autoTuneDampfActive = true;
|
|
autoTuneDampfStatus = AT_RUNNING;
|
|
// Serial.println("Starte AutoTune für Dampf (Zeitproportional)...");
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Stoppt AutoTune für Wasser - Unverändert
|
|
************************************************************************************/
|
|
|
|
void stopAutoTuneWasser() {
|
|
if (autoTuneWasserActive) {
|
|
autoTuneWasserActive = false;
|
|
delete autoTuneWasser;
|
|
autoTuneWasser = nullptr;
|
|
// Setze PID zurück in den Normalbetrieb (ggf. Output auf 0)
|
|
pidWasser.SetMode(AUTOMATIC); // Sicherstellen, dass PID wieder normal läuft
|
|
OutputWasser = 0; // Optional: Output zurücksetzen
|
|
// Stelle sicher, dass SSR ausgeschaltet ist
|
|
digitalWrite(SSR_WASSER_PIN, LOW);
|
|
// Serial.println("AutoTune Wasser gestoppt.");
|
|
}
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Stoppt AutoTune für Dampf - Unverändert
|
|
************************************************************************************/
|
|
|
|
void stopAutoTuneDampf() {
|
|
if (autoTuneDampfActive) {
|
|
autoTuneDampfActive = false;
|
|
delete autoTuneDampf;
|
|
autoTuneDampf = nullptr;
|
|
// Setze PID zurück in den Normalbetrieb (ggf. Output auf 0)
|
|
pidDampf.SetMode(AUTOMATIC); // Sicherstellen, dass PID wieder normal läuft
|
|
OutputDampf = 0; // Optional: Output zurücksetzen
|
|
// Stelle sicher, dass SSR ausgeschaltet ist
|
|
digitalWrite(SSR_DAMPF_PIN, LOW);
|
|
// Serial.println("AutoTune Dampf gestoppt.");
|
|
}
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Überwacht die AutoTune-Prozesse (Dampf/Wasser)
|
|
* - Prüft auf Sensorfehler ODER Sicherheitsabschaltung vor Ausführung
|
|
* - Display-Logik ist in updateDisplay() ausgelagert
|
|
************************************************************************************/
|
|
void handleAutoTune(AsyncWebServerRequest *request) {
|
|
(void)request;
|
|
// --- AutoTune Wasser ---
|
|
if (autoTuneWasserActive) {
|
|
// Erweiterte Prüfung auf Sensorfehler ODER Sicherheitsabschaltung während des Tunings
|
|
if (wasserSensorError || wasserSafetyShutdown) {
|
|
// Logge den spezifischen Grund für den Abbruch
|
|
// if (wasserSensorError) {
|
|
// Serial.println("FEHLER: Wassertemperatursensor während AutoTune ausgefallen! Breche Tuning ab.");
|
|
// } else { // Muss wasserSafetyShutdown sein
|
|
// Serial.println("WARNUNG: Sicherheitslimit Wasser während AutoTune überschritten! Breche Tuning ab.");
|
|
// }
|
|
autoTuneWasserStatus = wasserSensorError ? AT_ABORT_SENSOR : AT_ABORT_SAFETY;
|
|
stopAutoTuneWasser(); // Ruft Funktion auf, die Flag zurücksetzt, Speicher freigibt und SSR ausschaltet
|
|
return; // Verlasse die Funktion für diesen Durchlauf, um Runtime() nicht auszuführen
|
|
}
|
|
|
|
// Nur wenn kein Fehler/Shutdown vorliegt, führe AutoTune->Runtime() aus.
|
|
// Diese Funktion berechnet und SETZT OutputWasser für den nächsten Schritt!
|
|
if (autoTuneWasser->Runtime() == 1) // Runtime() gibt 1 zurück, wenn Tuning abgeschlossen
|
|
{
|
|
// Tuning abgeschlossen, Werte holen (zunaechst lokal, Plausibilitaet pruefen)
|
|
double newKp = autoTuneWasser->GetKp();
|
|
double newKi = autoTuneWasser->GetKi();
|
|
double newKd = autoTuneWasser->GetKd();
|
|
int peaks = autoTuneWasser->getPeakCount(); // > 9 => Failsafe-Ende (Schwingung nie stabil)
|
|
|
|
// Degenerierter Lauf (keine echte Schwingung -> Kp<=0/NaN): Werte NICHT uebernehmen,
|
|
// sonst wuerde der PID-Regler mit unbrauchbaren Werten ueberschrieben.
|
|
if (!(newKp > 0.0) || isnan(newKp) || isnan(newKi) || isnan(newKd)) {
|
|
autoTuneWasserStatus = AT_ABORT_DEGENERATE;
|
|
} else {
|
|
KpWasser = newKp;
|
|
KiWasser = newKi;
|
|
KdWasser = newKd;
|
|
|
|
// WICHTIG: PID-Tunings sofort anwenden
|
|
pidWasser.SetTunings(KpWasser, KiWasser, KdWasser);
|
|
|
|
// Neue Werte im EEPROM speichern
|
|
EEPROM.put(EEPROM_ADDR_KP_WASSER, KpWasser);
|
|
EEPROM.put(EEPROM_ADDR_KI_WASSER, KiWasser);
|
|
EEPROM.put(EEPROM_ADDR_KD_WASSER, KdWasser);
|
|
EEPROM.commit();
|
|
|
|
// Ergebniswerte fuer Anzeige merken
|
|
autoTuneWasserResultKp = (float)KpWasser;
|
|
autoTuneWasserResultKi = (float)KiWasser;
|
|
autoTuneWasserResultKd = (float)KdWasser;
|
|
autoTuneWasserStatus = (peaks > 9) ? AT_FAILSAFE_PEAKS : AT_SUCCESS;
|
|
}
|
|
|
|
// Serial.println("AutoTune Wasser erfolgreich abgeschlossen.");
|
|
// Serial.printf("Neue Werte: Kp=%.2f, Ki=%.2f, Kd=%.2f\n", KpWasser, KiWasser, KdWasser);
|
|
|
|
// AutoTune-Prozess korrekt beenden
|
|
stopAutoTuneWasser();
|
|
}
|
|
// Hinweis: Die Display-Anzeige für laufendes Tuning wird in updateDisplay() gehandhabt.
|
|
}
|
|
|
|
// --- AutoTune Dampf ---
|
|
if (autoTuneDampfActive) {
|
|
// Erweiterte Prüfung auf Sensorfehler ODER Sicherheitsabschaltung während des Tunings
|
|
if (dampfSensorError || dampfSafetyShutdown) {
|
|
// Logge den spezifischen Grund für den Abbruch
|
|
// if (dampfSensorError) {
|
|
// Serial.println("FEHLER: Dampftemperatursensor während AutoTune ausgefallen! Breche Tuning ab.");
|
|
// } else { // Muss dampfSafetyShutdown sein
|
|
// Serial.println("WARNUNG: Sicherheitslimit Dampf während AutoTune überschritten! Breche Tuning ab.");
|
|
// }
|
|
autoTuneDampfStatus = dampfSensorError ? AT_ABORT_SENSOR : AT_ABORT_SAFETY;
|
|
stopAutoTuneDampf(); // Ruft Funktion auf, die Flag zurücksetzt, Speicher freigibt und SSR ausschaltet
|
|
return; // Verlasse die Funktion für diesen Durchlauf, um Runtime() nicht auszuführen
|
|
}
|
|
|
|
// Nur wenn kein Fehler/Shutdown vorliegt, führe AutoTune->Runtime() aus.
|
|
// Diese Funktion berechnet und SETZT OutputDampf für den nächsten Schritt!
|
|
if (autoTuneDampf->Runtime() == 1) // Runtime() gibt 1 zurück, wenn Tuning abgeschlossen
|
|
{
|
|
// Tuning abgeschlossen, Werte holen (zunaechst lokal, Plausibilitaet pruefen)
|
|
double newKp = autoTuneDampf->GetKp();
|
|
double newKi = autoTuneDampf->GetKi();
|
|
double newKd = autoTuneDampf->GetKd();
|
|
int peaks = autoTuneDampf->getPeakCount(); // > 9 => Failsafe-Ende (Schwingung nie stabil)
|
|
|
|
// Degenerierter Lauf (keine echte Schwingung -> Kp<=0/NaN): Werte NICHT uebernehmen.
|
|
if (!(newKp > 0.0) || isnan(newKp) || isnan(newKi) || isnan(newKd)) {
|
|
autoTuneDampfStatus = AT_ABORT_DEGENERATE;
|
|
} else {
|
|
KpDampf = newKp;
|
|
KiDampf = newKi;
|
|
KdDampf = newKd;
|
|
|
|
// WICHTIG: PID-Tunings sofort anwenden
|
|
pidDampf.SetTunings(KpDampf, KiDampf, KdDampf);
|
|
|
|
// Neue Werte im EEPROM speichern
|
|
EEPROM.put(EEPROM_ADDR_KP_DAMPF, KpDampf);
|
|
EEPROM.put(EEPROM_ADDR_KI_DAMPF, KiDampf);
|
|
EEPROM.put(EEPROM_ADDR_KD_DAMPF, KdDampf);
|
|
EEPROM.commit();
|
|
|
|
// Ergebniswerte fuer Anzeige merken
|
|
autoTuneDampfResultKp = (float)KpDampf;
|
|
autoTuneDampfResultKi = (float)KiDampf;
|
|
autoTuneDampfResultKd = (float)KdDampf;
|
|
autoTuneDampfStatus = (peaks > 9) ? AT_FAILSAFE_PEAKS : AT_SUCCESS;
|
|
}
|
|
|
|
// Serial.println("AutoTune Dampf erfolgreich abgeschlossen.");
|
|
// Serial.printf("Neue Werte: Kp=%.2f, Ki=%.2f, Kd=%.2f\n", KpDampf, KiDampf, KdDampf);
|
|
|
|
// AutoTune-Prozess korrekt beenden
|
|
stopAutoTuneDampf();
|
|
}
|
|
// Hinweis: Die Display-Anzeige für laufendes Tuning wird in updateDisplay() gehandhabt.
|
|
}
|
|
}
|
|
|
|
|
|
/************************************************************************************
|
|
* Handler für die Brew Control Seite (GET /Brew-Control)
|
|
************************************************************************************/
|
|
|
|
// --- Angepasste PROGMEM Chunks für die Brew Control Seite mit Toggle Switches ---
|
|
|
|
// Head und Body Start bleiben gleich
|
|
static const char brewControlHead[] PROGMEM = R"rawliteral(
|
|
<!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ühvorgangs basierend auf Zeit oder Gewicht.
|
|
Wenn beide aktiviert sind, stoppt der Bezug, sobald die erste Bedingung (Zeit oder Gewicht) erreicht ist.
|
|
Der Bezug kann aber weiterhin manuell über den Bezugs-Schalter beendet werden.<br>
|
|
Ebenso kann eine Pre-Infusion aktiviert und eingestellt werden.<br>
|
|
FlowGuard verlängert bei Bedarf die Bezugszeit bis zur eingestellten Mindest-Brühzeit, indem die Pumpe in der Hauptbezugsphase dynamisch gepulst wird.<br><br>
|
|
)rawliteral";
|
|
|
|
// --- Brew-By-Time (BBT) Chunks ---
|
|
static const char brewControlBBT_H3[] PROGMEM = R"rawliteral(<h3>Brew-By-Time</h3>)rawliteral";
|
|
static const char brewControlBBT_ToggleContainerStart[] PROGMEM = R"rawliteral(<div class="toggle-switch-container"><span class="toggle-switch-label-text">Aktivieren:</span><label class="toggle-switch"><input type="checkbox" id="bbtEnabled" name="bbtEnabled" value="1")rawliteral"; // Endet VOR checked Attribut
|
|
static const char brewControlBBT_ToggleContainerEnd[] PROGMEM = R"rawliteral(><span class="toggle-slider"></span></label></div>)rawliteral"; // Schließt Input, Span, Label, Div
|
|
static const char brewControlBBT_Fields[] PROGMEM = R"rawliteral(
|
|
<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 class="toggle-description-input">(Stoppt nach dieser Zeit)</small><br>
|
|
)rawliteral"; // Beschreibung hat jetzt toggle-description Klasse
|
|
|
|
// --- Brew-By-Weight (BBW) Chunks ---
|
|
static const char brewControlBBW_H3[] PROGMEM = R"rawliteral(<h3>Brew-By-Weight</h3>)rawliteral";
|
|
static const char brewControlBBW_ToggleContainerStart[] PROGMEM = R"rawliteral(<div class="toggle-switch-container"><span class="toggle-switch-label-text">Aktivieren:</span><label class="toggle-switch"><input type="checkbox" id="bbwEnabled" name="bbwEnabled" value="1")rawliteral"; // Endet VOR checked/disabled Attribut(en)
|
|
static const char brewControlBBW_ToggleContainerEnd[] PROGMEM = R"rawliteral(><span class="toggle-slider"></span></label></div>)rawliteral"; // Schließt Input, Span, Label, Div
|
|
static const char brewControlBBW_Fields[] PROGMEM = R"rawliteral(
|
|
<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 class="toggle-description-input">(Stoppt x Gramm früher)</small>
|
|
{SCALE_STATUS_MSG}
|
|
<br>
|
|
)rawliteral";
|
|
|
|
// --- FlowGuard Chunks ---
|
|
static const char brewControlFG_H3[] PROGMEM = R"rawliteral(<h3>FlowGuard (Mindest-Brühzeit)</h3>)rawliteral";
|
|
static const char brewControlFG_ToggleContainerStart[] PROGMEM = R"rawliteral(<div class="toggle-switch-container"><span class="toggle-switch-label-text">Aktivieren:</span><label class="toggle-switch"><input type="checkbox" id="fgEnabled" name="fgEnabled" value="1")rawliteral";
|
|
static const char brewControlFG_ToggleContainerEnd[] PROGMEM = R"rawliteral(><span class="toggle-slider"></span></label></div>)rawliteral";
|
|
static const char brewControlFG_Fields[] PROGMEM = R"rawliteral(
|
|
<label for='fgMinSecs'>Mindest-Brühzeit (Sekunden):</label>
|
|
<input type='number' min='5.0' max='120.0' step='0.1' id='fgMinSecs' name='fgMinSecs' value='{FG_MIN_SECS}' required>
|
|
<label for='fgPulseMs'>Pulsperiode (ms):</label>
|
|
<input type='number' min='200' max='3000' step='50' id='fgPulseMs' name='fgPulseMs' value='{FG_PULSE_MS}' required>
|
|
<label for='fgMinDuty'>Min. Pumpenleistung (%):</label>
|
|
<input type='number' min='5.0' max='95.0' step='1.0' id='fgMinDuty' name='fgMinDuty' value='{FG_MIN_DUTY}' required>
|
|
<small class="toggle-description-input">(Benötigt aktive + verbundene Waage sowie Brew-By-Weight. FlowGuard schätzt den Endzeitpunkt aus Restgewicht/Flow und reduziert bei Bedarf den Pumpen-Duty-Cycle.)</small><br>
|
|
)rawliteral";
|
|
|
|
// --- Pre-Infusion (PI) Chunks ---
|
|
static const char brewControlPI_H3[] PROGMEM = R"rawliteral(<h3>Pre-Infusion</h3>)rawliteral";
|
|
static const char brewControlPI_ToggleContainerStart[] PROGMEM = R"rawliteral(<div class="toggle-switch-container"><span class="toggle-switch-label-text">Aktivieren:</span><label class="toggle-switch"><input type="checkbox" id="piEnabled" name="piEnabled" value="1")rawliteral"; // Endet VOR checked Attribut
|
|
static const char brewControlPI_ToggleContainerEnd[] PROGMEM = R"rawliteral(><span class="toggle-slider"></span></label></div>)rawliteral"; // Schließt Input, Span, Label, Div
|
|
static const char brewControlPI_Fields[] PROGMEM = R"rawliteral(
|
|
<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 class="toggle-description-input">(Pumpe AN -> Pumpe AUS -> Pumpe AN)</small><br>
|
|
)rawliteral";
|
|
|
|
// --- Steam-By-Time (SBT) Chunks ---
|
|
static const char brewControlSBT_H3[] PROGMEM = R"rawliteral(<h3>Dampf-Timer</h3>)rawliteral";
|
|
static const char brewControlSBT_ToggleContainerStart[] PROGMEM = R"rawliteral(<div class="toggle-switch-container"><span class="toggle-switch-label-text">Aktivieren:</span><label class="toggle-switch"><input type="checkbox" id="sbtEnabled" name="sbtEnabled" value="1")rawliteral";
|
|
static const char brewControlSBT_ToggleContainerEnd[] PROGMEM = R"rawliteral(><span class="toggle-slider"></span></label></div>)rawliteral";
|
|
static const char brewControlSBT_Fields[] PROGMEM = R"rawliteral(
|
|
<label for='sbtSecs'>Zieldauer Dampfbezug (Sekunden):</label>
|
|
<input type='number' min='1.0' max='180.0' step='0.1' id='sbtSecs' name='sbtSecs' value='{SBT_SECS}' required>
|
|
<small class="toggle-description-input">(Stoppt den Dampfbezug automatisch nach dieser Zeit)</small><br>
|
|
)rawliteral";
|
|
|
|
// Formular Ende bleibt gleich
|
|
static const char brewControlFormEnd[] PROGMEM = R"rawliteral(
|
|
<input type='submit' value='Einstellungen speichern'>
|
|
</form>
|
|
</body></html>
|
|
)rawliteral";
|
|
|
|
// --- Ende der PROGMEM Chunks ---
|
|
|
|
// Handler für die Brew Control Seite (GET /Brew-Control) - Mit Toggle Switches
|
|
void handleBrewControl(AsyncWebServerRequest *request) {
|
|
char buffer[16]; // Puffer für Zahlen
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8");
|
|
|
|
response->print(FPSTR(brewControlHead));
|
|
response->print(FPSTR(commonStyle)); // Dein globaler Style mit den Toggle-CSS-Regeln
|
|
response->print(FPSTR(brewControlHeadEnd));
|
|
response->print(FPSTR(commonNav));
|
|
response->print(FPSTR(brewControlBodyStart));
|
|
yield();
|
|
|
|
// --- Brew-By-Time Sektion ---
|
|
response->print(FPSTR(brewControlBBT_H3));
|
|
response->print(FPSTR(brewControlBBT_ToggleContainerStart)); // Beginnt Toggle bis vor 'checked'
|
|
if (brewByTimeEnabled) {
|
|
response->print(F(" checked")); // Fügt 'checked' hinzu, wenn aktiv
|
|
}
|
|
response->print(FPSTR(brewControlBBT_ToggleContainerEnd)); // Schließt den Toggle HTML-Teil
|
|
|
|
// Felder für BBT rendern
|
|
String bbtFieldsHtml = FPSTR(brewControlBBT_Fields);
|
|
snprintf(buffer, sizeof(buffer), "%.1f", brewByTimeTargetSeconds);
|
|
bbtFieldsHtml.replace("{BBT_SECS}", buffer);
|
|
response->print(bbtFieldsHtml);
|
|
yield();
|
|
|
|
// --- Brew-By-Weight Sektion ---
|
|
response->print(FPSTR(brewControlBBW_H3));
|
|
response->print(FPSTR(brewControlBBW_ToggleContainerStart)); // Beginnt Toggle bis vor 'checked'/'disabled'
|
|
|
|
if (brewByWeightEnabled) {
|
|
response->print(F(" checked")); // Fügt 'checked' hinzu, wenn aktiv
|
|
}
|
|
if (!scaleConnected) {
|
|
response->print(F(" disabled")); // Fügt 'disabled' hinzu, wenn Waage nicht verbunden
|
|
}
|
|
response->print(FPSTR(brewControlBBW_ToggleContainerEnd)); // Schließt den Toggle HTML-Teil
|
|
|
|
// Felder für BBW rendern
|
|
String bbwFieldsHtml = FPSTR(brewControlBBW_Fields);
|
|
snprintf(buffer, sizeof(buffer), "%.1f", brewByWeightTargetGrams);
|
|
bbwFieldsHtml.replace("{BBW_TARGET}", buffer);
|
|
snprintf(buffer, sizeof(buffer), "%.1f", brewByWeightOffsetGrams);
|
|
bbwFieldsHtml.replace("{BBW_OFFSET}", buffer);
|
|
|
|
// Deaktiviere Felder und zeige Statusmeldung, wenn Waage nicht verbunden ist
|
|
if (!scaleConnected) {
|
|
bbwFieldsHtml.replace("{SCALE_DISABLED}", "disabled");
|
|
bbwFieldsHtml.replace("{SCALE_STATUS_MSG}", "<b><small style='color: #FFCC00;'>Waage nicht verbunden!</small><br></b>");
|
|
} else {
|
|
bbwFieldsHtml.replace("{SCALE_DISABLED}", "");
|
|
bbwFieldsHtml.replace("{SCALE_STATUS_MSG}", ""); // Keine Meldung wenn ok
|
|
}
|
|
response->print(bbwFieldsHtml);
|
|
yield();
|
|
|
|
// --- FlowGuard Sektion ---
|
|
response->print(FPSTR(brewControlFG_H3));
|
|
response->print(FPSTR(brewControlFG_ToggleContainerStart));
|
|
if (flowGuardEnabled) {
|
|
response->print(F(" checked"));
|
|
}
|
|
response->print(FPSTR(brewControlFG_ToggleContainerEnd));
|
|
|
|
String fgFieldsHtml = FPSTR(brewControlFG_Fields);
|
|
snprintf(buffer, sizeof(buffer), "%.1f", flowGuardMinBrewSeconds);
|
|
fgFieldsHtml.replace("{FG_MIN_SECS}", buffer);
|
|
snprintf(buffer, sizeof(buffer), "%u", (unsigned int)flowGuardPulsePeriodMs);
|
|
fgFieldsHtml.replace("{FG_PULSE_MS}", buffer);
|
|
snprintf(buffer, sizeof(buffer), "%.1f", flowGuardMinDutyPercent);
|
|
fgFieldsHtml.replace("{FG_MIN_DUTY}", buffer);
|
|
response->print(fgFieldsHtml);
|
|
yield();
|
|
|
|
// --- Pre-Infusion Sektion ---
|
|
response->print(FPSTR(brewControlPI_H3));
|
|
response->print(FPSTR(brewControlPI_ToggleContainerStart)); // Beginnt Toggle bis vor 'checked'
|
|
if (preInfusionEnabled) {
|
|
response->print(F(" checked")); // Fügt 'checked' hinzu, wenn aktiv
|
|
}
|
|
response->print(FPSTR(brewControlPI_ToggleContainerEnd)); // Schließt den Toggle HTML-Teil
|
|
|
|
// Felder für PI rendern
|
|
String piFieldsHtml = FPSTR(brewControlPI_Fields);
|
|
snprintf(buffer, sizeof(buffer), "%.1f", preInfusionDurationSeconds);
|
|
piFieldsHtml.replace("{PI_DUR_SECS}", buffer);
|
|
snprintf(buffer, sizeof(buffer), "%.1f", preInfusionPauseSeconds);
|
|
piFieldsHtml.replace("{PI_PAUSE_SECS}", buffer);
|
|
response->print(piFieldsHtml);
|
|
yield();
|
|
|
|
// --- Dampf-Timer Sektion ---
|
|
response->print(FPSTR(brewControlSBT_H3));
|
|
response->print(FPSTR(brewControlSBT_ToggleContainerStart));
|
|
if (steamByTimeEnabled) {
|
|
response->print(F(" checked"));
|
|
}
|
|
response->print(FPSTR(brewControlSBT_ToggleContainerEnd));
|
|
|
|
String sbtFieldsHtml = FPSTR(brewControlSBT_Fields);
|
|
snprintf(buffer, sizeof(buffer), "%.1f", steamByTimeTargetSeconds);
|
|
sbtFieldsHtml.replace("{SBT_SECS}", buffer);
|
|
response->print(sbtFieldsHtml);
|
|
yield();
|
|
|
|
// Formular Ende
|
|
response->print(FPSTR(brewControlFormEnd));
|
|
|
|
request->send(response);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler zum Speichern der Brew Control Einstellungen (POST /saveBrewControl)
|
|
************************************************************************************/
|
|
|
|
struct BrewControlUpdate {
|
|
bool hasBBTEnabled = false;
|
|
bool bbtEnabled = false;
|
|
bool hasBBTSecs = false;
|
|
float bbtSecs = 0.0f;
|
|
|
|
bool hasBBWEnabled = false;
|
|
bool bbwEnabled = false;
|
|
bool hasBBWTarget = false;
|
|
float bbwTarget = 0.0f;
|
|
bool hasBBWOffset = false;
|
|
float bbwOffset = 0.0f;
|
|
|
|
bool hasFlowGuardEnabled = false;
|
|
bool flowGuardEnabled = false;
|
|
bool hasFlowGuardMinSecs = false;
|
|
float flowGuardMinSecs = 0.0f;
|
|
bool hasFlowGuardPulseMs = false;
|
|
uint16_t flowGuardPulseMs = 0;
|
|
bool hasFlowGuardMinDuty = false;
|
|
float flowGuardMinDuty = 0.0f;
|
|
|
|
bool hasPIEnabled = false;
|
|
bool piEnabled = false;
|
|
bool hasPIDurSecs = false;
|
|
float piDurSecs = 0.0f;
|
|
bool hasPIPauseSecs = false;
|
|
float piPauseSecs = 0.0f;
|
|
|
|
bool hasSBTEnabled = false;
|
|
bool sbtEnabled = false;
|
|
bool hasSBTSecs = false;
|
|
float sbtSecs = 0.0f;
|
|
};
|
|
|
|
bool applyBrewControlUpdate(const BrewControlUpdate& update) {
|
|
bool changed = false;
|
|
bool flowGuardConfigChanged = false;
|
|
|
|
if (update.hasBBTEnabled && update.bbtEnabled != brewByTimeEnabled) {
|
|
brewByTimeEnabled = update.bbtEnabled;
|
|
EEPROM.put(EEPROM_ADDR_BREWBYTIME_ENABLED, brewByTimeEnabled);
|
|
changed = true;
|
|
}
|
|
if (update.hasBBTSecs) {
|
|
float newBBTSecs = update.bbtSecs;
|
|
if (newBBTSecs >= 5.0f && newBBTSecs <= 120.0f && fabs(newBBTSecs - brewByTimeTargetSeconds) > 0.01f) {
|
|
brewByTimeTargetSeconds = newBBTSecs;
|
|
EEPROM.put(EEPROM_ADDR_BREWBYTIME_SECONDS, brewByTimeTargetSeconds);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (update.hasBBWEnabled && update.bbwEnabled != brewByWeightEnabled) {
|
|
brewByWeightEnabled = update.bbwEnabled;
|
|
EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_ENABLED, brewByWeightEnabled);
|
|
changed = true;
|
|
}
|
|
if (update.hasBBWTarget) {
|
|
float newBBWTarget = update.bbwTarget;
|
|
if (newBBWTarget >= 10.0f && newBBWTarget <= 70.0f && fabs(newBBWTarget - brewByWeightTargetGrams) > 0.01f) {
|
|
brewByWeightTargetGrams = newBBWTarget;
|
|
EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_TARGET, brewByWeightTargetGrams);
|
|
changed = true;
|
|
}
|
|
}
|
|
if (update.hasBBWOffset) {
|
|
float newBBWOffset = update.bbwOffset;
|
|
if (newBBWOffset >= 0.0f && newBBWOffset <= 10.0f && fabs(newBBWOffset - brewByWeightOffsetGrams) > 0.01f) {
|
|
brewByWeightOffsetGrams = newBBWOffset;
|
|
EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_OFFSET, brewByWeightOffsetGrams);
|
|
changed = true;
|
|
}
|
|
}
|
|
if (update.hasFlowGuardEnabled && update.flowGuardEnabled != flowGuardEnabled) {
|
|
flowGuardEnabled = update.flowGuardEnabled;
|
|
EEPROM.put(EEPROM_ADDR_FLOWGUARD_ENABLED, flowGuardEnabled);
|
|
changed = true;
|
|
flowGuardConfigChanged = true;
|
|
}
|
|
if (update.hasFlowGuardMinSecs) {
|
|
float newFlowGuardMinSecs = update.flowGuardMinSecs;
|
|
if (newFlowGuardMinSecs >= FLOW_GUARD_MIN_SECONDS_MIN && newFlowGuardMinSecs <= FLOW_GUARD_MIN_SECONDS_MAX && fabs(newFlowGuardMinSecs - flowGuardMinBrewSeconds) > 0.01f) {
|
|
flowGuardMinBrewSeconds = newFlowGuardMinSecs;
|
|
EEPROM.put(EEPROM_ADDR_FLOWGUARD_MIN_SECONDS, flowGuardMinBrewSeconds);
|
|
changed = true;
|
|
flowGuardConfigChanged = true;
|
|
}
|
|
}
|
|
if (update.hasFlowGuardPulseMs) {
|
|
uint16_t newPulseMs = update.flowGuardPulseMs;
|
|
if (newPulseMs >= FLOW_GUARD_PULSE_PERIOD_MIN_MS && newPulseMs <= FLOW_GUARD_PULSE_PERIOD_MAX_MS && newPulseMs != flowGuardPulsePeriodMs) {
|
|
flowGuardPulsePeriodMs = newPulseMs;
|
|
EEPROM.put(EEPROM_ADDR_FLOWGUARD_PULSE_PERIOD_MS, flowGuardPulsePeriodMs);
|
|
changed = true;
|
|
flowGuardConfigChanged = true;
|
|
}
|
|
}
|
|
if (update.hasFlowGuardMinDuty) {
|
|
float newFlowGuardMinDuty = update.flowGuardMinDuty;
|
|
if (newFlowGuardMinDuty >= FLOW_GUARD_MIN_DUTY_MIN && newFlowGuardMinDuty <= FLOW_GUARD_MIN_DUTY_MAX && fabs(newFlowGuardMinDuty - flowGuardMinDutyPercent) > 0.01f) {
|
|
flowGuardMinDutyPercent = newFlowGuardMinDuty;
|
|
EEPROM.put(EEPROM_ADDR_FLOWGUARD_MIN_DUTY, flowGuardMinDutyPercent);
|
|
changed = true;
|
|
flowGuardConfigChanged = true;
|
|
}
|
|
}
|
|
|
|
if (flowGuardConfigChanged) {
|
|
resetFlowGuardRuntime();
|
|
}
|
|
|
|
if (update.hasPIEnabled && update.piEnabled != preInfusionEnabled) {
|
|
preInfusionEnabled = update.piEnabled;
|
|
EEPROM.put(EEPROM_ADDR_PREINF_ENABLED, preInfusionEnabled);
|
|
changed = true;
|
|
}
|
|
if (update.hasPIDurSecs) {
|
|
float newPIDur = update.piDurSecs;
|
|
if (newPIDur >= 0.5f && newPIDur <= 20.0f && fabs(newPIDur - preInfusionDurationSeconds) > 0.01f) {
|
|
preInfusionDurationSeconds = newPIDur;
|
|
EEPROM.put(EEPROM_ADDR_PREINF_DUR_SEC, preInfusionDurationSeconds);
|
|
changed = true;
|
|
}
|
|
}
|
|
if (update.hasPIPauseSecs) {
|
|
float newPIPause = update.piPauseSecs;
|
|
if (newPIPause >= 0.0f && newPIPause <= 20.0f && fabs(newPIPause - preInfusionPauseSeconds) > 0.01f) {
|
|
preInfusionPauseSeconds = newPIPause;
|
|
EEPROM.put(EEPROM_ADDR_PREINF_PAUSE_SEC, preInfusionPauseSeconds);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (update.hasSBTEnabled && update.sbtEnabled != steamByTimeEnabled) {
|
|
steamByTimeEnabled = update.sbtEnabled;
|
|
EEPROM.put(EEPROM_ADDR_STEAMBYTIME_ENABLED, steamByTimeEnabled);
|
|
changed = true;
|
|
}
|
|
if (update.hasSBTSecs) {
|
|
float newSBTSecs = update.sbtSecs;
|
|
if (newSBTSecs >= 1.0f && newSBTSecs <= 180.0f && fabs(newSBTSecs - steamByTimeTargetSeconds) > 0.01f) {
|
|
steamByTimeTargetSeconds = newSBTSecs;
|
|
EEPROM.put(EEPROM_ADDR_STEAMBYTIME_SECONDS, steamByTimeTargetSeconds);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (changed) {
|
|
EEPROM.commit();
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
void handleSaveBrewControl(AsyncWebServerRequest *request) {
|
|
BrewControlUpdate update;
|
|
|
|
update.hasBBTEnabled = true;
|
|
update.bbtEnabled = request->hasArg("bbtEnabled");
|
|
update.hasBBTSecs = request->hasArg("bbtSecs");
|
|
if (update.hasBBTSecs) { update.bbtSecs = request->arg("bbtSecs").toFloat(); }
|
|
|
|
update.hasBBWEnabled = true;
|
|
update.bbwEnabled = request->hasArg("bbwEnabled");
|
|
update.hasBBWTarget = request->hasArg("bbwTarget");
|
|
if (update.hasBBWTarget) { update.bbwTarget = request->arg("bbwTarget").toFloat(); }
|
|
update.hasBBWOffset = request->hasArg("bbwOffset");
|
|
if (update.hasBBWOffset) { update.bbwOffset = request->arg("bbwOffset").toFloat(); }
|
|
update.hasFlowGuardEnabled = true;
|
|
update.flowGuardEnabled = request->hasArg("fgEnabled");
|
|
update.hasFlowGuardMinSecs = request->hasArg("fgMinSecs");
|
|
if (update.hasFlowGuardMinSecs) { update.flowGuardMinSecs = request->arg("fgMinSecs").toFloat(); }
|
|
update.hasFlowGuardPulseMs = request->hasArg("fgPulseMs");
|
|
if (update.hasFlowGuardPulseMs) { update.flowGuardPulseMs = (uint16_t)request->arg("fgPulseMs").toInt(); }
|
|
update.hasFlowGuardMinDuty = request->hasArg("fgMinDuty");
|
|
if (update.hasFlowGuardMinDuty) { update.flowGuardMinDuty = request->arg("fgMinDuty").toFloat(); }
|
|
|
|
update.hasPIEnabled = true;
|
|
update.piEnabled = request->hasArg("piEnabled");
|
|
update.hasPIDurSecs = request->hasArg("piDurSecs");
|
|
if (update.hasPIDurSecs) { update.piDurSecs = request->arg("piDurSecs").toFloat(); }
|
|
update.hasPIPauseSecs = request->hasArg("piPauseSecs");
|
|
if (update.hasPIPauseSecs) { update.piPauseSecs = request->arg("piPauseSecs").toFloat(); }
|
|
|
|
update.hasSBTEnabled = true;
|
|
update.sbtEnabled = request->hasArg("sbtEnabled");
|
|
update.hasSBTSecs = request->hasArg("sbtSecs");
|
|
if (update.hasSBTSecs) { update.sbtSecs = request->arg("sbtSecs").toFloat(); }
|
|
|
|
applyBrewControlUpdate(update);
|
|
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader("Location", "/Brew-Control");
|
|
request->send(response);
|
|
}
|
|
|
|
|
|
/************************************************************************************
|
|
* PROGMEM Chunks für die Info-Seite (/Info)
|
|
************************************************************************************/
|
|
|
|
// --- Kopfzeile ---
|
|
static const char infoHtmlHead[] PROGMEM = R"rawliteral(
|
|
<!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äteinfo</h3>
|
|
Die Gerä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>
|
|
<b>Betriebszeit gesamt:</b><br>
|
|
)rawliteral"; // Endet vor formatierter Betriebszeit
|
|
static const char infoChunk_AfterRuntime[] PROGMEM = R"rawliteral(<br><br>
|
|
<b>Betriebszeit seit Einschalten:</b><br>)rawliteral";
|
|
static const char infoChunk_AfterCurrentRuntime[] PROGMEM = R"rawliteral(<br>
|
|
<input type='submit' value='Zurü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üge ü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ü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ü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üge pro Tag: )rawliteral";
|
|
|
|
static const char infoChunk_StatsToday[] PROGMEM = R"rawliteral(<br><br><b>Anzahl Bezü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ähler benö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ücksetzen</h3>
|
|
Die aktuelle Statistik wird gelöscht und es kann eine neue Statistik begonnen werden.</br>
|
|
<button type='submit' class='danger'>Zurü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üge werden erst nach erfolgreicher NTP-Zeitsynchronisation geloggt.<br>Hierzu wird eine Internetverbindung benö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
|
|
// 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)
|
|
// 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_MacAddress[] PROGMEM = R"rawliteral(<br>
|
|
MAC-Adresse (WLAN): )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öße: )rawliteral";
|
|
static const char infoChunk_AfterSketchSize[] PROGMEM = R"rawliteral( Bytes<br>
|
|
Nutzbarer Sketch-Speicher: )rawliteral";
|
|
static const char infoChunk_AfterFreeSketch[] PROGMEM = R"rawliteral( Bytes<br>
|
|
Flash-Größ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
|
|
************************************************************************************/
|
|
|
|
static int16_t encodeResetDiagSignedTenths(float value) {
|
|
if (!isfinite(value)) {
|
|
return RESET_DIAG_INVALID_VALUE;
|
|
}
|
|
|
|
float scaled = value * 10.0f;
|
|
if (scaled > 32767.0f) {
|
|
scaled = 32767.0f;
|
|
} else if (scaled < -32767.0f) {
|
|
scaled = -32767.0f;
|
|
}
|
|
return (int16_t)lroundf(scaled);
|
|
}
|
|
|
|
static void printResetDiagTenths(AsyncResponseStream *response, int16_t value, const __FlashStringHelper *unit) {
|
|
if (value == RESET_DIAG_INVALID_VALUE) {
|
|
response->print(F("N/A"));
|
|
return;
|
|
}
|
|
|
|
char valueBuffer[16];
|
|
snprintf(valueBuffer, sizeof(valueBuffer), "%.1f", value / 10.0f);
|
|
response->print(valueBuffer);
|
|
if (unit != nullptr) {
|
|
response->print(unit);
|
|
}
|
|
}
|
|
|
|
static const __FlashStringHelper* resetReasonToText(esp_reset_reason_t reason) {
|
|
switch (reason) {
|
|
case ESP_RST_UNKNOWN: return F("Unbekannt");
|
|
case ESP_RST_POWERON: return F("Power On");
|
|
case ESP_RST_EXT: return F("External Pin");
|
|
case ESP_RST_SW: return F("Software");
|
|
case ESP_RST_PANIC: return F("Panic/Exception");
|
|
case ESP_RST_INT_WDT: return F("Interrupt Watchdog");
|
|
case ESP_RST_TASK_WDT: return F("Task Watchdog");
|
|
case ESP_RST_WDT: return F("Other Watchdog");
|
|
case ESP_RST_DEEPSLEEP: return F("Deep Sleep Wakeup");
|
|
case ESP_RST_BROWNOUT: return F("Brownout");
|
|
case ESP_RST_SDIO: return F("SDIO");
|
|
default: return F("Anderer Grund");
|
|
}
|
|
}
|
|
|
|
static bool resetReasonHasCustomCode(esp_reset_reason_t reason) {
|
|
switch (reason) {
|
|
case ESP_RST_UNKNOWN:
|
|
case ESP_RST_POWERON:
|
|
case ESP_RST_EXT:
|
|
case ESP_RST_SW:
|
|
case ESP_RST_PANIC:
|
|
case ESP_RST_INT_WDT:
|
|
case ESP_RST_TASK_WDT:
|
|
case ESP_RST_WDT:
|
|
case ESP_RST_DEEPSLEEP:
|
|
case ESP_RST_BROWNOUT:
|
|
case ESP_RST_SDIO:
|
|
return false;
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
static const __FlashStringHelper* resetCheckpointToText(uint16_t checkpoint) {
|
|
switch (checkpoint) {
|
|
case RESET_CP_SETUP_START: return F("Setup Start");
|
|
case RESET_CP_SETUP_WIFI: return F("Setup WLAN");
|
|
case RESET_CP_SETUP_DISPLAY: return F("Setup Display");
|
|
case RESET_CP_SETUP_SCALE: return F("Setup Waage");
|
|
case RESET_CP_SETUP_WEBSERVER: return F("Setup Webserver");
|
|
case RESET_CP_LOOP_START: return F("Loop Start");
|
|
case RESET_CP_LOOP_TEMPERATURE: return F("Loop Temperatur/PID");
|
|
case RESET_CP_LOOP_SSR: return F("Loop SSR");
|
|
case RESET_CP_LOOP_SHOT: return F("Loop Bezugslogik");
|
|
case RESET_CP_LOOP_ECO: return F("Loop Eco");
|
|
case RESET_CP_LOOP_DISPLAY: return F("Loop Display");
|
|
case RESET_CP_LOOP_SCALE: return F("Loop Waage");
|
|
case RESET_CP_LOOP_WS: return F("Loop Hintergrunddienste");
|
|
case RESET_CP_DISPLAY_RENDER: return F("Display Render");
|
|
case RESET_CP_SCALE_HX711_READ: return F("HX711 Lesen");
|
|
case RESET_CP_SCALE_I2C_READ: return F("I2C Waage lesen");
|
|
case RESET_CP_SCALE_BUTTON_READ: return F("I2C Waagenbutton");
|
|
case RESET_CP_ESPNOW_RECV: return F("ESP-NOW Empfang");
|
|
case RESET_CP_TOUCH_UART_RX: return F("Touch UART RX");
|
|
case RESET_CP_TOUCH_UART_TX: return F("Touch UART TX");
|
|
default: return F("Unbekannt");
|
|
}
|
|
}
|
|
|
|
void recordResetCheckpoint(uint16_t checkpoint) {
|
|
rtcResetDiag.magic = RESET_DIAG_MAGIC;
|
|
rtcResetDiag.version = RESET_DIAG_VERSION;
|
|
rtcResetDiag.checkpoint = checkpoint;
|
|
rtcResetDiag.uptimeMs = millis();
|
|
rtcResetDiag.freeHeap = ESP.getFreeHeap();
|
|
|
|
uint32_t flags = 0;
|
|
if (standbyModeActive) flags |= RESET_DIAG_FLAG_STANDBY;
|
|
if (ecoModeAktiv) flags |= RESET_DIAG_FLAG_ECO;
|
|
if (shotActive) flags |= RESET_DIAG_FLAG_SHOT;
|
|
if (steamCircuitActive) flags |= RESET_DIAG_FLAG_STEAM;
|
|
if (flushActive) flags |= RESET_DIAG_FLAG_FLUSH;
|
|
if (cleaningAssistantActive) flags |= RESET_DIAG_FLAG_CLEANING;
|
|
if (scaleConnected) flags |= RESET_DIAG_FLAG_SCALE_CONNECTED;
|
|
if (scaleModeActive) flags |= RESET_DIAG_FLAG_SCALE_MODE;
|
|
if (WiFi.status() == WL_CONNECTED) flags |= RESET_DIAG_FLAG_WIFI_CONNECTED;
|
|
if (tareScaleAfterDelay) flags |= RESET_DIAG_FLAG_TARE_PENDING;
|
|
if (tareScaleSettling) flags |= RESET_DIAG_FLAG_TARE_SETTLING;
|
|
if (wasserSensorError) flags |= RESET_DIAG_FLAG_SENSOR_W_ERROR;
|
|
if (dampfSensorError) flags |= RESET_DIAG_FLAG_SENSOR_D_ERROR;
|
|
if (caseSensorError) flags |= RESET_DIAG_FLAG_SENSOR_CASE_ERROR;
|
|
if (steamHeatDisabledByUser) flags |= RESET_DIAG_FLAG_STEAM_HEAT_DISABLED;
|
|
rtcResetDiag.flags = flags;
|
|
|
|
rtcResetDiag.waterTempDeciC = encodeResetDiagSignedTenths((float)InputWasser);
|
|
rtcResetDiag.steamTempDeciC = encodeResetDiagSignedTenths((float)InputDampf);
|
|
rtcResetDiag.caseTempDeciC = encodeResetDiagSignedTenths((float)InputCase);
|
|
rtcResetDiag.weightDeciG = encodeResetDiagSignedTenths(currentWeightReading);
|
|
rtcResetDiag.scaleType = scaleType;
|
|
rtcResetDiag.wifiStatus = (int8_t)WiFi.status();
|
|
rtcResetDiag.wifiMode = (uint8_t)WiFi.getMode();
|
|
}
|
|
|
|
void initResetDiagnostics() {
|
|
lastResetReason = esp_reset_reason();
|
|
lastResetDiagAvailable =
|
|
(rtcResetDiag.magic == RESET_DIAG_MAGIC && rtcResetDiag.version == RESET_DIAG_VERSION);
|
|
if (lastResetDiagAvailable) {
|
|
lastResetDiag = rtcResetDiag;
|
|
} else {
|
|
memset(&lastResetDiag, 0, sizeof(lastResetDiag));
|
|
}
|
|
|
|
memset(&rtcResetDiag, 0, sizeof(rtcResetDiag));
|
|
rtcResetDiag.magic = RESET_DIAG_MAGIC;
|
|
rtcResetDiag.version = RESET_DIAG_VERSION;
|
|
recordResetCheckpoint(RESET_CP_SETUP_START);
|
|
}
|
|
|
|
static void printLastResetDiagnostics(AsyncResponseStream *response) {
|
|
// Hinweis, falls die Einstellungen beim Boot aus dem FFat-Backup wiederhergestellt wurden
|
|
// (EEPROM war beschaedigt, aber dank Backup kein Werksreset noetig).
|
|
if (eepromRestoredFromBackup) {
|
|
response->print(F("<br><b style='color:#FFCC00;'>Hinweis:</b> EEPROM war ungültig – Einstellungen wurden beim Start automatisch aus dem Backup wiederhergestellt."));
|
|
}
|
|
|
|
if (!lastResetDiagAvailable) {
|
|
response->print(F("<br>Reset-Details: Keine Zusatzdaten verfügbar."));
|
|
return;
|
|
}
|
|
|
|
response->print(F("<br>Reset-Details: letzter Checkpoint: "));
|
|
response->print(resetCheckpointToText(lastResetDiag.checkpoint));
|
|
response->print(F("<br>Uptime vor Reset: "));
|
|
response->print(lastResetDiag.uptimeMs);
|
|
response->print(F(" ms"));
|
|
response->print(F("<br>Heap vor Reset: "));
|
|
response->print(lastResetDiag.freeHeap);
|
|
response->print(F(" Bytes"));
|
|
|
|
response->print(F("<br>Zustand: "));
|
|
bool wroteState = false;
|
|
struct StateLabel {
|
|
uint32_t flag;
|
|
const __FlashStringHelper *text;
|
|
};
|
|
static const StateLabel stateLabels[] = {
|
|
{RESET_DIAG_FLAG_STANDBY, F("Standby")},
|
|
{RESET_DIAG_FLAG_ECO, F("Eco")},
|
|
{RESET_DIAG_FLAG_SHOT, F("Bezug")},
|
|
{RESET_DIAG_FLAG_STEAM, F("Dampf")},
|
|
{RESET_DIAG_FLAG_FLUSH, F("Flush")},
|
|
{RESET_DIAG_FLAG_CLEANING, F("Reinigung")},
|
|
{RESET_DIAG_FLAG_SCALE_CONNECTED, F("Waage verbunden")},
|
|
{RESET_DIAG_FLAG_SCALE_MODE, F("Waagenmodus")},
|
|
{RESET_DIAG_FLAG_WIFI_CONNECTED, F("WLAN verbunden")},
|
|
{RESET_DIAG_FLAG_TARE_PENDING, F("Tara wartet")},
|
|
{RESET_DIAG_FLAG_TARE_SETTLING, F("Tara settling")},
|
|
{RESET_DIAG_FLAG_SENSOR_W_ERROR, F("Sensorfehler Wasser")},
|
|
{RESET_DIAG_FLAG_SENSOR_D_ERROR, F("Sensorfehler Dampf")},
|
|
{RESET_DIAG_FLAG_SENSOR_CASE_ERROR, F("Sensorfehler Gehäuse")},
|
|
{RESET_DIAG_FLAG_STEAM_HEAT_DISABLED, F("Dampfheizung gesperrt")},
|
|
};
|
|
for (size_t i = 0; i < (sizeof(stateLabels) / sizeof(stateLabels[0])); ++i) {
|
|
if ((lastResetDiag.flags & stateLabels[i].flag) == 0) {
|
|
continue;
|
|
}
|
|
if (wroteState) {
|
|
response->print(F(", "));
|
|
}
|
|
response->print(stateLabels[i].text);
|
|
wroteState = true;
|
|
}
|
|
if (!wroteState) {
|
|
response->print(F("keine Sonderzustände"));
|
|
}
|
|
|
|
response->print(F("<br>Temperaturen vor Reset: Wasser "));
|
|
printResetDiagTenths(response, lastResetDiag.waterTempDeciC, F(" °C"));
|
|
response->print(F(", Dampf "));
|
|
printResetDiagTenths(response, lastResetDiag.steamTempDeciC, F(" °C"));
|
|
response->print(F(", Gehäuse "));
|
|
printResetDiagTenths(response, lastResetDiag.caseTempDeciC, F(" °C"));
|
|
|
|
response->print(F("<br>Gewicht vor Reset: "));
|
|
printResetDiagTenths(response, lastResetDiag.weightDeciG, F(" g"));
|
|
response->print(F("<br>Waagentyp: "));
|
|
switch (lastResetDiag.scaleType) {
|
|
case SCALE_I2C: response->print(F("I2C")); break;
|
|
case SCALE_ESPNOW: response->print(F("ESP-NOW")); break;
|
|
case SCALE_HX711: response->print(F("HX711")); break;
|
|
default: response->print(F("Keine")); break;
|
|
}
|
|
response->print(F("<br>WLAN-Statuscode vor Reset: "));
|
|
response->print((int)lastResetDiag.wifiStatus);
|
|
response->print(F("<br>WLAN-Modus vor Reset: "));
|
|
response->print((unsigned int)lastResetDiag.wifiMode);
|
|
}
|
|
|
|
void handleInfo(AsyncWebServerRequest *request) // URL: /Info
|
|
{
|
|
AsyncResponseStream *response = request->beginResponseStream(F("text/html"));
|
|
response->setCode(200);
|
|
char buffer[40]; // Allzweck-Puffer für Zahlen etc.
|
|
char dateBuffer[25]; // Puffer für Datumsformatierung
|
|
char tempStringCopyBuffer[50]; // Temp Puffer für String-Kopien
|
|
|
|
|
|
// --- Kopfzeile, Styles, Navigation senden ---
|
|
response->print(FPSTR(infoHtmlHead));
|
|
response->print(FPSTR(commonStyle)); // Globale Styles nicht vergessen
|
|
response->print(FPSTR(infoHtmlHeadEnd));
|
|
response->print(FPSTR(commonNav)); // Globale Navigation nicht vergessen
|
|
yield();
|
|
|
|
// --- Geräteinfo Formular ---
|
|
response->print(FPSTR(infoChunk_BodyStart)); // Start Body, H1, Start Geräteinfo-Form
|
|
// Sicherstellen, dass Strings nicht über Puffer laufen und terminiert sind
|
|
strncpy(tempStringCopyBuffer, infoHersteller, sizeof(tempStringCopyBuffer) - 1);
|
|
tempStringCopyBuffer[sizeof(tempStringCopyBuffer) - 1] = '\0';
|
|
if (strlen(tempStringCopyBuffer) > 0) { response->print(tempStringCopyBuffer); } // Wert einfügen
|
|
response->print(FPSTR(infoChunk_AfterHersteller)); // Label+Input für Modell
|
|
yield();
|
|
|
|
strncpy(tempStringCopyBuffer, infoModell, sizeof(tempStringCopyBuffer) - 1);
|
|
tempStringCopyBuffer[sizeof(tempStringCopyBuffer) - 1] = '\0';
|
|
if (strlen(tempStringCopyBuffer) > 0) { response->print(tempStringCopyBuffer); } // Wert einfügen
|
|
response->print(FPSTR(infoChunk_AfterModell)); // Label+Input für Zusatz
|
|
yield();
|
|
|
|
strncpy(tempStringCopyBuffer, infoZusatz, sizeof(tempStringCopyBuffer) - 1);
|
|
tempStringCopyBuffer[sizeof(tempStringCopyBuffer) - 1] = '\0';
|
|
if (strlen(tempStringCopyBuffer) > 0) { response->print(tempStringCopyBuffer); } // Wert einfügen
|
|
response->print(FPSTR(infoChunk_AfterZusatz)); // Ende Geräteinfo-Form
|
|
yield();
|
|
|
|
// --- Betriebszeit Reset Formular ---
|
|
response->print(FPSTR(infoChunk_RuntimeResetForm)); // Start Betriebszeit-Reset-Form, endet vor formatierter Zeit
|
|
// Formatierte Betriebszeit berechnen und senden
|
|
unsigned long totalSeconds = totalRuntime;
|
|
unsigned long days = totalSeconds / 86400;
|
|
unsigned long hours = (totalSeconds % 86400) / 3600;
|
|
unsigned long minutes = (totalSeconds % 3600) / 60;
|
|
snprintf(buffer, sizeof(buffer), "%lu", days);
|
|
response->print(buffer);
|
|
response->print(F(" Tag(e), "));
|
|
snprintf(buffer, sizeof(buffer), "%lu", hours);
|
|
response->print(buffer);
|
|
response->print(F(" Stunde(n) und "));
|
|
snprintf(buffer, sizeof(buffer), "%lu", minutes);
|
|
response->print(buffer);
|
|
response->print(F(" Minuten"));
|
|
response->print(FPSTR(infoChunk_AfterRuntime));
|
|
unsigned long currentSeconds = millis() / 1000UL;
|
|
unsigned long currentDays = currentSeconds / 86400UL;
|
|
unsigned long currentHours = (currentSeconds % 86400UL) / 3600UL;
|
|
unsigned long currentMinutes = (currentSeconds % 3600UL) / 60UL;
|
|
snprintf(buffer, sizeof(buffer), "%lu", currentDays);
|
|
response->print(buffer);
|
|
response->print(F(" Tag(e), "));
|
|
snprintf(buffer, sizeof(buffer), "%lu", currentHours);
|
|
response->print(buffer);
|
|
response->print(F(" Stunde(n) und "));
|
|
snprintf(buffer, sizeof(buffer), "%lu", currentMinutes);
|
|
response->print(buffer);
|
|
response->print(F(" Minuten"));
|
|
response->print(FPSTR(infoChunk_AfterCurrentRuntime)); // Ende Betriebszeit-Reset-Form
|
|
yield();
|
|
|
|
// --- Shot Zähler Reset Formular ---
|
|
response->print(FPSTR(infoChunk_ShotCounterResetForm)); // Start Shot-Reset-Form, endet vor Zähler-Wert
|
|
snprintf(buffer, sizeof(buffer), "%lu", shotCounter); // Wert einfügen
|
|
response->print(buffer);
|
|
response->print(FPSTR(infoChunk_ShotCounterFormEnd)); // Ende Shot-Reset-Form
|
|
yield();
|
|
|
|
// --- Shot-Verlauf Statistik ---
|
|
ShotStats stats = calculateShotStatistics(); // Berechne Statistiken
|
|
yield(); // Nach der (potenziell) längeren Berechnung
|
|
|
|
if (stats.historyAvailable) {
|
|
response->print(FPSTR(infoChunk_StatsStart)); // Start des Statistik-Blocks (<form><h3><p>)
|
|
|
|
// Bestehende allgemeine Statistiken
|
|
response->print(FPSTR(infoChunk_StatsTotal)); // "Geloggte Bezüge gesamt: "
|
|
snprintf(buffer, sizeof(buffer), "%lu", stats.totalShotsLogged);
|
|
response->print(buffer);
|
|
yield();
|
|
|
|
response->print(FPSTR(infoChunk_StatsAvg)); // "<br>Durchschnittliche Bezugsdauer: "
|
|
snprintf(buffer, sizeof(buffer), "%.1f", stats.averageDurationSec);
|
|
response->print(buffer);
|
|
response->print(F(" s")); // Einheit hinzufügen
|
|
yield();
|
|
|
|
// Durchschnitt pro Tag
|
|
response->print(FPSTR(infoChunk_StatsAvgPerDay)); // "<br>Durchschnittliche Bezüge pro Tag (seit Log): "
|
|
snprintf(buffer, sizeof(buffer), "%.1f", stats.avgShotsPerDay);
|
|
response->print(buffer);
|
|
yield();
|
|
|
|
// Bestehende Zeitfenster
|
|
response->print(FPSTR(infoChunk_StatsToday)); // " s<br><br>Bezüge Heute: "
|
|
snprintf(buffer, sizeof(buffer), "%lu", stats.shotsToday);
|
|
response->print(buffer);
|
|
yield();
|
|
|
|
response->print(FPSTR(infoChunk_StatsYesterday)); // "<br>Bezüge Gestern: "
|
|
snprintf(buffer, sizeof(buffer), "%lu", stats.shotsYesterday);
|
|
response->print(buffer);
|
|
yield();
|
|
|
|
response->print(FPSTR(infoChunk_StatsWeek)); // "<br>Bezüge in dieser Woche: "
|
|
snprintf(buffer, sizeof(buffer), "%lu", stats.shotsThisWeek);
|
|
response->print(buffer);
|
|
yield();
|
|
|
|
response->print(FPSTR(infoChunk_StatsLastWeek)); // "<br>Bezüge Letzte Woche (Mo-So): "
|
|
snprintf(buffer, sizeof(buffer), "%lu", stats.shotsLastWeek);
|
|
response->print(buffer);
|
|
yield();
|
|
|
|
response->print(FPSTR(infoChunk_StatsMonth)); // "<br>Bezüge in diesem Monat: "
|
|
snprintf(buffer, sizeof(buffer), "%lu", stats.shotsThisMonth);
|
|
response->print(buffer);
|
|
yield();
|
|
|
|
response->print(FPSTR(infoChunk_StatsLastMonth)); // "<br>Bezüge Letzter Monat: "
|
|
snprintf(buffer, sizeof(buffer), "%lu", stats.shotsLastMonth);
|
|
response->print(buffer);
|
|
yield(); // Nach mehreren Ausgaben
|
|
|
|
|
|
// Ersten und letzten Shot anzeigen, falls vorhanden (wie bisher)
|
|
if (stats.firstShotTimestamp > 0) {
|
|
response->print(FPSTR(infoChunk_StatsFirst)); // "<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
|
|
response->print(dateBuffer); // Wert senden
|
|
response->print(F(" Uhr"));
|
|
yield();
|
|
}
|
|
if (stats.lastShotTimestamp > 0) {
|
|
response->print(FPSTR(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
|
|
response->print(dateBuffer); // Wert senden
|
|
response->print(F(" Uhr"));
|
|
yield();
|
|
}
|
|
|
|
// Hinweis auf NTP anzeigen, wenn aktuelle Zeit ungültig erscheint (wie bisher)
|
|
time_t now_t_stats;
|
|
time(&now_t_stats);
|
|
struct tm now_tm_stats;
|
|
localtime_r(&now_t_stats, &now_tm_stats);
|
|
if (now_tm_stats.tm_year <= (2023 - 1900)) { // Prüft ob aktuelles Jahr > 2023
|
|
response->print(FPSTR(infoChunk_StatsTimeError)); // Sendet den Hinweis
|
|
}
|
|
|
|
// Download Button hinzufügen (wie bisher)
|
|
response->print(FPSTR(infoChunk_StatsDownloadButton));
|
|
response->print(FPSTR(infoChunk_StatsEnd)); // Ende des Statistik-Blocks (</p></form>)
|
|
yield();
|
|
|
|
} else {
|
|
// Alternative Meldung, falls keine Statistik verfügbar (wie bisher)
|
|
response->print(FPSTR(infoChunk_StatsNotAvailable));
|
|
}
|
|
// --- Ende Statistikbereich ---
|
|
yield();
|
|
|
|
response->print(FPSTR(infoChunk_DeleteStatsForm));
|
|
|
|
// --- Systeminfo (mit plattformabhängigen Teilen) ---
|
|
response->print(FPSTR(infoChunk_SysInfoStart)); // Start Systeminfo <form>, endet vor Heap-Wert
|
|
|
|
snprintf(buffer, sizeof(buffer), "%u", ESP.getFreeHeap());
|
|
response->print(buffer); // Heap-Wert senden
|
|
response->print(FPSTR(infoChunk_AfterFreeHeap)); // Sendet " Bytes<br> NTP Zeit synchronisiert: "
|
|
yield();
|
|
|
|
// NTP Status senden (Ja/Nein)
|
|
response->print(timeSynced ? F("Ja") : F("Nein"));
|
|
response->print(FPSTR(infoChunk_AfterNTPStatus)); // Sendet "<br> SDK-Version: "
|
|
yield();
|
|
|
|
// --- SDK Version ---
|
|
const char* sdkVersion = ESP.getSdkVersion();
|
|
if (sdkVersion && strlen(sdkVersion) > 0) {
|
|
response->print(sdkVersion);
|
|
} else {
|
|
response->print(F("N/A"));
|
|
}
|
|
// --- Core Version ---
|
|
response->print(FPSTR(infoChunk_AfterSDK)); // "<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) response->print(tempStringCopyBuffer);
|
|
|
|
// --- Boot Version ---
|
|
response->print(FPSTR(infoChunk_AfterCore)); // "<br> Boot-Version: "
|
|
response->print(F("N/A")); // ESP32 has no getBootVersion()
|
|
|
|
// --- Chip ID ---
|
|
response->print(FPSTR(infoChunk_AfterBoot)); // "<br> Chip-ID: "
|
|
uint64_t chipid_64 = ESP.getEfuseMac();
|
|
uint32_t chipid_32 = (uint32_t)(chipid_64 >> 16);
|
|
snprintf(buffer, sizeof(buffer), "%08X", chipid_32);
|
|
response->print(buffer); // Formatierten Chip-ID Wert senden
|
|
|
|
// MAC-Adresse SICHER ausgeben
|
|
response->print(FPSTR(infoChunk_MacAddress)); // Label "MAC-Adresse (WLAN): " senden
|
|
if (WiFi.status() == WL_CONNECTED) {
|
|
response->print(WiFi.macAddress()); // Die MAC-Adresse als String senden
|
|
} else {
|
|
response->print(F("N/A (nicht verbunden)"));
|
|
}
|
|
|
|
// --- CPU Takt ---
|
|
response->print(FPSTR(infoChunk_AfterChipID)); // "<br> CPU-Takt: "
|
|
snprintf(buffer, sizeof(buffer), "%u", ESP.getCpuFreqMHz());
|
|
response->print(buffer);
|
|
|
|
// --- Reset Grund ---
|
|
response->print(FPSTR(infoChunk_AfterCPU)); // " MHz<br> Letzter Reset-Grund: "
|
|
response->print(resetReasonToText(lastResetReason));
|
|
if (resetReasonHasCustomCode(lastResetReason)) {
|
|
response->print(F(" ("));
|
|
response->print((int)lastResetReason);
|
|
response->print(F(")"));
|
|
}
|
|
printLastResetDiagnostics(response);
|
|
|
|
// --- Sketch Größe ---
|
|
response->print(FPSTR(infoChunk_AfterResetReason)); // "<br><br> Sketch-Größe: "
|
|
snprintf(buffer, sizeof(buffer), "%u", ESP.getSketchSize());
|
|
response->print(buffer);
|
|
yield();
|
|
|
|
// --- Freier Sketch Speicher ---
|
|
response->print(FPSTR(infoChunk_AfterSketchSize)); // " Bytes<br> Freier Sketch-Speicher: "
|
|
snprintf(buffer, sizeof(buffer), "%u", ESP.getFreeSketchSpace());
|
|
response->print(buffer);
|
|
|
|
// --- Flash Größe ---
|
|
response->print(FPSTR(infoChunk_AfterFreeSketch)); // " Bytes<br> Flash-Größe (Chip): "
|
|
snprintf(buffer, sizeof(buffer), "%u", ESP.getFlashChipSize());
|
|
response->print(buffer);
|
|
yield(); // Vor dem letzten Chunk
|
|
response->print(FPSTR(infoChunk_End)); // Ende </body></html>
|
|
yield();
|
|
request->send(response);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* 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";
|
|
|
|
// --- Systemtöne / Piezo ---
|
|
static const char serviceChunk_PiezoToggleStart[] PROGMEM = R"rawliteral(
|
|
<form action='/updatePiezoSettings' method='POST'>
|
|
<h3>Systemtöne</h3>
|
|
<div class="toggle-switch-container">
|
|
<span class="toggle-switch-label-text">Aktivieren:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="piezoEnabled" name="piezoEnabled" value="1")rawliteral"; // Endet VOR checked
|
|
static const char serviceChunk_PiezoToggleEnd[] PROGMEM = R"rawliteral(>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<small class="toggle-description">
|
|
Aktiviert oder deaktiviert die akustischen Signale des Piezo-Lautsprechers (falls angeschlossen).<br>
|
|
Hierdurch werden bei Sensorfehlern, Sicherheitsabschaltung, Übergang in den Eco-Modus, ... Hinweistöne ausgegeben.<br>
|
|
</small>
|
|
<input type='submit' value='Einstellungen speichern'>
|
|
</form>
|
|
)rawliteral";
|
|
|
|
// --- Wartung Intervall Formular (ehemals infoChunk_MaintenanceForm) ---
|
|
static const char serviceChunk_MaintenanceForm[] PROGMEM = R"rawliteral(
|
|
<form action='/updateIntervalSettings' method='POST'>
|
|
<h3>Reinigung & Wartung</h3>
|
|
Zeigt nach der eingestellten Anzahl von Bezügen eine Reinigungs-/ Wartungserinnerung im Display an.<br>
|
|
<label for='maintenanceInterval'>Anzahl an Bezü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('>
|
|
<label for='flushDurationSeconds'>Flush-Zeit Wasserkreislauf in Sekunden: (1 - 30)</label>
|
|
<input type='number' min='1' max='30' step='1' id='flushDurationSeconds' name='flushDurationSeconds' value=')rawliteral";
|
|
static const char serviceChunk_AfterFlushDuration[] PROGMEM = R"rawliteral('>
|
|
<label for='steamFlushDurationSeconds'>Flush-Zeit Dampfkreislauf in Sekunden: (1 - 30)</label>
|
|
<input type='number' min='1' max='30' step='1' id='steamFlushDurationSeconds' name='steamFlushDurationSeconds' value=')rawliteral";
|
|
static const char serviceChunk_AfterSteamFlushDuration[] PROGMEM = R"rawliteral('>
|
|
<input type='submit' value='Einstellungen speichern'>
|
|
</form>
|
|
)rawliteral"; // Ende Wartungsintervall-Formular
|
|
|
|
static const char serviceChunk_CleaningAssistantFormStart[] PROGMEM = R"rawliteral(
|
|
<form action='/startCleaningAssistant' method='POST'>
|
|
<h3>Reinigungsassistent</h3>
|
|
<p>1. Blindsieb in den Siebträger einsetzen.<br>
|
|
2. Reinigungstablette einlegen.<br>
|
|
3. Siebträger einspannen.<br>
|
|
4. Assistenten starten.</p>
|
|
<label for='cleaningBrewSeconds'>Bezugsphase in Sekunden: (1 - 30)</label>
|
|
<input type='number' min='1' max='30' step='1' id='cleaningBrewSeconds' name='cleaningBrewSeconds' value=')rawliteral";
|
|
static const char serviceChunk_CleaningAssistantMid[] PROGMEM = R"rawliteral('>
|
|
<label for='cleaningCycles'>Zyklen-Anzahl: (1 - 30)</label>
|
|
<input type='number' min='1' max='30' step='1' id='cleaningCycles' name='cleaningCycles' value=')rawliteral";
|
|
static const char serviceChunk_CleaningAssistantPause[] PROGMEM = R"rawliteral('>
|
|
<label for='cleaningPauseSeconds'>Pause zwischen den Zyklen in Sekunden: (1 - 60)</label>
|
|
<input type='number' min='1' max='60' step='1' id='cleaningPauseSeconds' name='cleaningPauseSeconds' value=')rawliteral";
|
|
static const char serviceChunk_CleaningAssistantEnd[] PROGMEM = R"rawliteral('>
|
|
<input type='submit' value='Reinigungsassistent starten'>
|
|
</form>
|
|
)rawliteral";
|
|
|
|
static const char serviceChunk_CleaningAssistantStopForm[] PROGMEM = R"rawliteral(
|
|
<form action='/stopCleaningAssistant' method='POST'>
|
|
<input type='submit' value='Reinigungsassistent abbrechen'>
|
|
</form>
|
|
)rawliteral";
|
|
|
|
static const char serviceChunk_CleaningAssistantStatusStart[] PROGMEM = R"rawliteral(
|
|
<div class='status-message status-error'>
|
|
<b>Reinigungsassistent:</b> )rawliteral";
|
|
static const char serviceChunk_CleaningAssistantStatusEnd[] PROGMEM = R"rawliteral(
|
|
</div>
|
|
)rawliteral";
|
|
|
|
// --- 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üge werden in diesem Modus nicht gezä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ähler zurücksetzen</h3>
|
|
Aktueller Zählerstand: )rawliteral"; // Endet vor Wartungszähler-Wert
|
|
static const char serviceChunk_AfterMaintCounter[] PROGMEM = R"rawliteral(<br>
|
|
<br>
|
|
Setzt den Zähler für die Reinigungs- & Wartungserinnerung zurück.<br>
|
|
Dies ist nach der erfolgreichen Durchführung notwendig,<br>
|
|
damit die Meldung nicht nach jedem Bezug erneut scheint.<br>
|
|
<input type='submit' value='Zurü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(AsyncWebServerRequest *request) {
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html");
|
|
char buffer[20]; // Puffer für Zahlen
|
|
|
|
// --- Kopfzeile, Styles, Navigation senden ---
|
|
response->print(FPSTR(serviceHtmlHead));
|
|
response->print(FPSTR(commonStyle)); // Globale Styles nicht vergessen
|
|
response->print(FPSTR(serviceHtmlHeadEnd));
|
|
response->print(FPSTR(commonNav)); // Globale Navigation nicht vergessen
|
|
yield();
|
|
|
|
// --- Body Start ---
|
|
response->print(FPSTR(serviceChunk_BodyStart));
|
|
yield();
|
|
|
|
// --- Systemtöne / Piezo ---
|
|
response->print(FPSTR(serviceChunk_PiezoToggleStart)); // Bis vor 'checked'
|
|
if (piezoEnabled) {
|
|
response->print(F(" checked")); // Fügt 'checked' hinzu, wenn aktiv
|
|
}
|
|
response->print(FPSTR(serviceChunk_PiezoToggleEnd)); // Schließt den Toggle HTML-Teil
|
|
yield();
|
|
|
|
// --- Wartung Intervall Formular ---
|
|
response->print(FPSTR(serviceChunk_MaintenanceForm)); // Start Wartungs-Form, endet vor Intervall-Wert
|
|
snprintf(buffer, sizeof(buffer), "%d", maintenanceInterval);
|
|
response->print(buffer); // Wert einfügen
|
|
response->print(FPSTR(serviceChunk_AfterMaintInterval));
|
|
snprintf(buffer, sizeof(buffer), "%u", (unsigned int)flushDurationSeconds);
|
|
response->print(buffer);
|
|
response->print(FPSTR(serviceChunk_AfterFlushDuration));
|
|
snprintf(buffer, sizeof(buffer), "%u", (unsigned int)steamFlushDurationSeconds);
|
|
response->print(buffer);
|
|
response->print(FPSTR(serviceChunk_AfterSteamFlushDuration)); // Ende Wartungs-Form
|
|
yield();
|
|
|
|
if (cleaningAssistantStatusMessage.length() > 0) {
|
|
response->print(FPSTR(serviceChunk_CleaningAssistantStatusStart));
|
|
response->print(cleaningAssistantStatusMessage);
|
|
response->print(FPSTR(serviceChunk_CleaningAssistantStatusEnd));
|
|
cleaningAssistantStatusMessage = "";
|
|
yield();
|
|
}
|
|
|
|
response->print(FPSTR(serviceChunk_CleaningAssistantFormStart));
|
|
snprintf(buffer, sizeof(buffer), "%u", (unsigned int)cleaningAssistantBrewSeconds);
|
|
response->print(buffer);
|
|
response->print(FPSTR(serviceChunk_CleaningAssistantMid));
|
|
snprintf(buffer, sizeof(buffer), "%u", (unsigned int)cleaningAssistantCycles);
|
|
response->print(buffer);
|
|
response->print(FPSTR(serviceChunk_CleaningAssistantPause));
|
|
snprintf(buffer, sizeof(buffer), "%u", (unsigned int)cleaningAssistantPauseSeconds);
|
|
response->print(buffer);
|
|
response->print(FPSTR(serviceChunk_CleaningAssistantEnd));
|
|
yield();
|
|
|
|
if (cleaningAssistantActive) {
|
|
response->print(F("<p><b>Status:</b> "));
|
|
if (cleaningAssistantWaitingForTemperature) {
|
|
response->print(F("Heizt auf 93 °C auf"));
|
|
} else {
|
|
response->print(cleaningAssistantInBrewPhase ? F("Bezug läuft") : F("Pause zwischen den Zyklen"));
|
|
}
|
|
response->print(F("<br><b>Zyklus:</b> "));
|
|
response->print(cleaningAssistantCurrentCycle);
|
|
response->print(F(" / "));
|
|
response->print(cleaningAssistantCycles);
|
|
if (cleaningAssistantWaitingForTemperature) {
|
|
response->print(F("<br><b>Wasser:</b> "));
|
|
snprintf(buffer, sizeof(buffer), "%.1f", getDisplayedWaterTemperature());
|
|
response->print(buffer);
|
|
response->print(F(" / 93.0 °C</p>"));
|
|
} else {
|
|
response->print(F("<br><b>Verbleibend:</b> "));
|
|
unsigned long phaseDurationMs = cleaningAssistantInBrewPhase
|
|
? (unsigned long)cleaningAssistantBrewSeconds * 1000UL
|
|
: (unsigned long)cleaningAssistantPauseSeconds * 1000UL;
|
|
unsigned long elapsedMs = millis() - cleaningAssistantPhaseStartTime;
|
|
unsigned long remainingMs = (elapsedMs < phaseDurationMs) ? (phaseDurationMs - elapsedMs) : 0;
|
|
snprintf(buffer, sizeof(buffer), "%lu", (remainingMs + 999UL) / 1000UL);
|
|
response->print(buffer);
|
|
response->print(F(" s</p>"));
|
|
}
|
|
response->print(FPSTR(serviceChunk_CleaningAssistantStopForm));
|
|
response->print(F("<script>setTimeout(function(){ window.location.reload(); }, 1000);</script>"));
|
|
yield();
|
|
}
|
|
|
|
// --- Wartungsmodus Sektion ---
|
|
response->print(FPSTR(serviceChunk_WartungStart)); // Start der Sektion
|
|
if (wartungsModusAktiv) {
|
|
response->print(F("Aktiv")); // Zeige "Aktiv"
|
|
} else {
|
|
response->print(F("Inaktiv")); // Zeige "Inaktiv"
|
|
}
|
|
yield();
|
|
response->print(FPSTR(serviceChunk_WartungButton)); // Bis vor Button-Text
|
|
yield();
|
|
if (wartungsModusAktiv) {
|
|
response->print(F("Deaktivieren")); // Button zum Deaktivieren
|
|
} else {
|
|
response->print(F("Aktivieren")); // Button zum Aktivieren
|
|
}
|
|
yield();
|
|
response->print(FPSTR(serviceChunk_WartungEnd)); // Rest des Formulars
|
|
yield();
|
|
|
|
// --- Wartungszähler Reset Formular ---
|
|
response->print(FPSTR(serviceChunk_MaintenanceResetForm)); // Start Zähler-Reset-Form, endet vor Zähler-Wert
|
|
snprintf(buffer, sizeof(buffer), "%lu", maintenanceIntervalCounter);
|
|
response->print(buffer); // Wert einfügen
|
|
response->print(FPSTR(serviceChunk_AfterMaintCounter)); // Ende Zähler-Reset-Form
|
|
yield();
|
|
|
|
// --- Body Ende ---
|
|
response->print(FPSTR(serviceChunk_BodyEnd));
|
|
yield();
|
|
|
|
request->send(response);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler für die Sensoren-Seite (/Sensoren)
|
|
************************************************************************************/
|
|
|
|
static const char sensorsHtmlHead[] PROGMEM = R"rawliteral(
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Sensoren</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
|
)rawliteral";
|
|
|
|
static const char sensorsHtmlHeadEnd[] PROGMEM = R"rawliteral(
|
|
</head>
|
|
)rawliteral";
|
|
|
|
static const char sensorsBodyStart[] PROGMEM = R"rawliteral(
|
|
<body>
|
|
<h1>Sensoren</h1>
|
|
<form action='/updateSensorSettings' method='POST'>
|
|
<input type='hidden' name='sensorSection' value='case'>
|
|
<h3>Zusatz-Temperatur-Sensor</h3>
|
|
<div class="toggle-switch-container">
|
|
<span class="toggle-switch-label-text">Sensor aktivieren:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="caseSensorEnabled" name="caseSensorEnabled" value="1")rawliteral";
|
|
|
|
static const char sensorsBodyAfterEnabled[] PROGMEM = R"rawliteral(>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<small class="toggle-description">Zusätzlicher NTC für Gehäuse oder Tassenablage.</small>
|
|
<br><h3>Sensor-Typ</h3>
|
|
<label for='caseSensorType'>Anzeige in UI/Chart:</label>
|
|
<select id='caseSensorType' name='caseSensorType'>
|
|
<option value='0' )rawliteral";
|
|
|
|
static const char sensorsTypeOptionMid[] PROGMEM = R"rawliteral(>Gehäuse</option>
|
|
<option value='1' )rawliteral";
|
|
|
|
static const char sensorsCaseSectionEnd[] PROGMEM = R"rawliteral(>Tassenablage</option>
|
|
</select>
|
|
<small class="select-description">Im Chart kann die Anzeige separat aktiviert werden.</small>
|
|
<br><h3>Offset</h3>
|
|
<label for='caseOffset'>Offset Zusatzsensor (°C):</label>
|
|
<input type='number' id='caseOffset' name='caseOffset' step='0.1' )rawliteral";
|
|
|
|
static const char sensorsCaseOffsetAfterInput[] PROGMEM = R"rawliteral(>
|
|
<small class="toggle-description-input">Eigenes Offset für Gehäuse/Tassenablage. Nicht von der Offset-Kompensation betroffen.</small>
|
|
<br><h3>Dashboard</h3>
|
|
<div class="toggle-switch-container">
|
|
<span class="toggle-switch-label-text">Temperatur im Dashboard anzeigen:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="caseTempDashboard" name="caseTempDashboard" value="1")rawliteral";
|
|
|
|
static const char sensorsCaseDashboardAfterToggle[] PROGMEM = R"rawliteral(>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<small class="toggle-description">Nur aktiv, wenn der Zusatzsensor eingeschaltet ist.</small>
|
|
<br><h3>Display</h3>
|
|
<div class="toggle-switch-container">
|
|
<span class="toggle-switch-label-text">Zusatzsensor auf dem Display anzeigen:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="caseTempDisplay" name="caseTempDisplay" value="1")rawliteral";
|
|
|
|
static const char sensorsCaseDisplayAfterToggle[] PROGMEM = R"rawliteral(>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<small class="toggle-description">Zeigt "Gehäuse" oder "Tassenablage" im Idle-Display an. Wenn auch die Eco-Info aktiviert ist, wechseln beide Anzeigen alle 5 Sekunden.</small>
|
|
<br>
|
|
<input type='submit' value='Einstellungen speichern'>
|
|
</form>
|
|
<form action='/updateSensorSettings' method='POST'>
|
|
<input type='hidden' name='sensorSection' value='scale'>
|
|
<h3>Waage</h3>
|
|
<div class="toggle-switch-container">
|
|
<span class="toggle-switch-label-text">Waage aktivieren:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="scaleEnabled" name="scaleEnabled" value="1")rawliteral";
|
|
|
|
static const char sensorsScaleAfterEnabled[] PROGMEM = R"rawliteral(>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<small class="toggle-description">Waage ein- oder ausschalten (Brew-By-Weight nutzt diese Einstellung).</small>
|
|
<br><h3>Waagen-Typ</h3>
|
|
<label for='scaleType'>Typ:</label>
|
|
<select id='scaleType' name='scaleType'>
|
|
<option value='1' )rawliteral";
|
|
|
|
static const char sensorsScaleOptionMid1[] PROGMEM = R"rawliteral(>I2C</option>
|
|
<option value='2' )rawliteral";
|
|
|
|
static const char sensorsScaleOptionMid2[] PROGMEM = R"rawliteral(>ESP-NOW</option>
|
|
<option value='3' )rawliteral";
|
|
|
|
static const char sensorsScaleAfterOptionsStart[] PROGMEM = R"rawliteral(>HX711</option>
|
|
</select>
|
|
<small class="select-description">Auswahl des Waage-Typs.</small>
|
|
<br><h3>HX711 Kalibrierung</h3>
|
|
<label for='hx711CalFactor'>Kalibrierungsfaktor:</label>
|
|
<input type='number' id='hx711CalFactor' name='hx711CalFactor' step='0.1' )rawliteral";
|
|
|
|
static const char sensorsBodyAfterHx711Start[] PROGMEM = R"rawliteral(>
|
|
<small class="toggle-description-input">Wert wird nur bei HX711 genutzt.</small>
|
|
<div class="toggle-switch-container" style="margin-top:10px;">
|
|
<span class="toggle-switch-label-text">Anzeige im Waage-Modus glätten:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="hx711DisplaySmoothing" name="hx711DisplaySmoothing" value="1")rawliteral";
|
|
|
|
static const char sensorsBodyAfterHx711DisplaySmoothing[] PROGMEM = R"rawliteral(>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<small class="toggle-description">Aktiviert nur die ruhigere Anzeige im HX711-Waage-Modus. Schnelle Schutzfilter bleiben immer aktiv.</small>
|
|
<div id="hx711CalAssistant" style="margin-top:12px; padding:12px; border:1px solid rgba(255,255,255,0.18); border-radius:8px;">
|
|
<h4 style="margin:0 0 8px 0;">Kalibrierungsassistent</h4>
|
|
<p style="margin:0 0 10px 0; color:#ccc; font-size:0.95em;">1. Alle Gewichte entfernen und Nullpunkt setzen. 2. Referenzgewicht auflegen, Gewicht eingeben und Faktor berechnen.</p>
|
|
<button type="button" id="hx711CalTareBtn">1. Nullpunkt setzen</button>
|
|
<br><br>
|
|
<label for="hx711KnownWeight">Referenzgewicht (g):</label>
|
|
<input type="number" id="hx711KnownWeight" min="1" max="5000" step="0.1" placeholder="z.B. 100.0">
|
|
<button type="button" id="hx711CalApplyBtn">2. Faktor berechnen</button>
|
|
<div id="hx711CalStatus" class="sensor-error-message" style="display:none; margin-top:10px;"></div>
|
|
</div>
|
|
<br>
|
|
<input type='submit' value='Einstellungen speichern'>
|
|
</form>
|
|
<form action='/updateSensorSettings' method='POST'>
|
|
<input type='hidden' name='sensorSection' value='xSwitch'>
|
|
<h3>X-Switch (GPIO42)</h3>
|
|
<label for='xSwitchAction'>Short-Press Funktion:</label>
|
|
<select id='xSwitchAction' name='xSwitchAction'>
|
|
<option value='0' )rawliteral";
|
|
|
|
static const char sensorsXSwitchOptionMid1[] PROGMEM = R"rawliteral(>Aus</option>
|
|
<option value='1' )rawliteral";
|
|
|
|
static const char sensorsXSwitchOptionMid2[] PROGMEM = R"rawliteral(>Wartungsmodus aktivieren</option>
|
|
<option value='2' )rawliteral";
|
|
|
|
static const char sensorsXSwitchOptionMid3[] PROGMEM = R"rawliteral(>Fast-Heat-Up aktivieren</option>
|
|
<option value='3' )rawliteral";
|
|
|
|
static const char sensorsXSwitchOptionMid4[] PROGMEM = R"rawliteral(>Dampfverzögerung überbrücken</option>
|
|
<option value='4' )rawliteral";
|
|
|
|
static const char sensorsXSwitchOptionMid5[] PROGMEM = R"rawliteral(>Waagen-Modus aktivieren</option>
|
|
<option value='5' )rawliteral";
|
|
|
|
static const char sensorsXSwitchOptionMid6[] PROGMEM = R"rawliteral(>Dampf nicht heizen</option>
|
|
<option value='6' )rawliteral";
|
|
|
|
static const char sensorsXSwitchLongStart[] PROGMEM = R"rawliteral(>Waage tarieren</option>
|
|
</select>
|
|
<small class="select-description">Funktion bei kurzem Tastendruck.</small>
|
|
<br>
|
|
<label for='xSwitchLongAction'>Long-Press Funktion:</label>
|
|
<select id='xSwitchLongAction' name='xSwitchLongAction'>
|
|
<option value='0' )rawliteral";
|
|
|
|
static const char sensorsXSwitchLongOptionMid6[] PROGMEM = R"rawliteral(>Dampf nicht heizen</option>
|
|
<option value='6' )rawliteral";
|
|
|
|
static const char sensorsXSwitchLongPressStart[] PROGMEM = R"rawliteral(>Waage tarieren</option>
|
|
</select>
|
|
<small class="select-description">Funktion bei langem Tastendruck.</small>
|
|
<br>
|
|
<label for='buttonLongPressMs'>Long-Press Dauer (ms):</label>
|
|
<input type='number' id='buttonLongPressMs' name='buttonLongPressMs' min='400' max='3000' step='50' value=')rawliteral";
|
|
|
|
static const char sensorsBodyEnd[] PROGMEM = R"rawliteral('>
|
|
<small class="toggle-description-input">Gilt für alle Taster mit Long-Press-Funktion (X-Switch, Bezug, Dampf).</small>
|
|
<br>
|
|
<input type='submit' value='Einstellungen speichern'>
|
|
</form>
|
|
<script>
|
|
const caseSensorToggle = document.getElementById('caseSensorEnabled');
|
|
const caseSensorTypeSelect = document.getElementById('caseSensorType');
|
|
const caseOffsetInput = document.getElementById('caseOffset');
|
|
const caseTempDashboardToggle = document.getElementById('caseTempDashboard');
|
|
const caseTempDisplayToggle = document.getElementById('caseTempDisplay');
|
|
const scaleToggle = document.getElementById('scaleEnabled');
|
|
const scaleTypeSelect = document.getElementById('scaleType');
|
|
const hx711CalInput = document.getElementById('hx711CalFactor');
|
|
const hx711KnownWeightInput = document.getElementById('hx711KnownWeight');
|
|
const hx711CalTareBtn = document.getElementById('hx711CalTareBtn');
|
|
const hx711CalApplyBtn = document.getElementById('hx711CalApplyBtn');
|
|
const hx711CalStatus = document.getElementById('hx711CalStatus');
|
|
function syncCaseSensorUI() {
|
|
if (caseSensorToggle && caseSensorTypeSelect) { caseSensorTypeSelect.disabled = !caseSensorToggle.checked; }
|
|
if (caseSensorToggle && caseOffsetInput) { caseOffsetInput.disabled = !caseSensorToggle.checked; }
|
|
if (caseSensorToggle && caseTempDashboardToggle) { caseTempDashboardToggle.disabled = !caseSensorToggle.checked; }
|
|
if (caseSensorToggle && caseTempDisplayToggle) { caseTempDisplayToggle.disabled = !caseSensorToggle.checked; }
|
|
}
|
|
function syncScaleUI() {
|
|
if (scaleToggle && scaleTypeSelect) { scaleTypeSelect.disabled = !scaleToggle.checked; }
|
|
const hx711Active = !!(scaleToggle && scaleTypeSelect && scaleToggle.checked && scaleTypeSelect.value === '3');
|
|
if (hx711CalInput) { hx711CalInput.disabled = !hx711Active; }
|
|
if (hx711KnownWeightInput) { hx711KnownWeightInput.disabled = !hx711Active; }
|
|
if (hx711CalTareBtn) { hx711CalTareBtn.disabled = !hx711Active; }
|
|
if (hx711CalApplyBtn) { hx711CalApplyBtn.disabled = !hx711Active; }
|
|
}
|
|
function setHx711CalStatus(message, success) {
|
|
if (!hx711CalStatus) { return; }
|
|
hx711CalStatus.style.display = message ? 'block' : 'none';
|
|
hx711CalStatus.style.color = success ? '#62c34b' : '#FFCC00';
|
|
hx711CalStatus.textContent = message || '';
|
|
}
|
|
async function postHx711Cal(url, params) {
|
|
setHx711CalStatus('Kalibrierung laeuft...', true);
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
|
body: new URLSearchParams(params || {})
|
|
});
|
|
const data = await response.json();
|
|
if (!data.success) { throw new Error(data.message || 'Kalibrierung fehlgeschlagen.'); }
|
|
return data;
|
|
}
|
|
if (hx711CalTareBtn) {
|
|
hx711CalTareBtn.addEventListener('click', async () => {
|
|
try {
|
|
const data = await postHx711Cal('/hx711CalibrationTare');
|
|
setHx711CalStatus(data.message || 'Nullpunkt gesetzt. Referenzgewicht auflegen.', true);
|
|
} catch (err) {
|
|
setHx711CalStatus(err.message, false);
|
|
}
|
|
});
|
|
}
|
|
if (hx711CalApplyBtn) {
|
|
hx711CalApplyBtn.addEventListener('click', async () => {
|
|
const knownWeight = hx711KnownWeightInput ? parseFloat(hx711KnownWeightInput.value) : NaN;
|
|
if (!Number.isFinite(knownWeight) || knownWeight <= 0) {
|
|
setHx711CalStatus('Bitte ein gueltiges Referenzgewicht in Gramm eingeben.', false);
|
|
return;
|
|
}
|
|
try {
|
|
const data = await postHx711Cal('/hx711CalibrationApply', {knownWeight: knownWeight.toString()});
|
|
if (hx711CalInput && typeof data.calFactor === 'number') {
|
|
hx711CalInput.value = data.calFactor.toFixed(2);
|
|
}
|
|
const measuredText = (typeof data.measuredWeight === 'number') ? (' Gemessen: ' + data.measuredWeight.toFixed(1) + ' g.') : '';
|
|
setHx711CalStatus((data.message || 'Kalibrierungsfaktor gespeichert.') + measuredText, true);
|
|
} catch (err) {
|
|
setHx711CalStatus(err.message, false);
|
|
}
|
|
});
|
|
}
|
|
if (caseSensorToggle) { caseSensorToggle.addEventListener('change', syncCaseSensorUI); }
|
|
if (scaleToggle) { scaleToggle.addEventListener('change', syncScaleUI); }
|
|
if (scaleTypeSelect) { scaleTypeSelect.addEventListener('change', syncScaleUI); }
|
|
syncCaseSensorUI();
|
|
syncScaleUI();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
)rawliteral";
|
|
|
|
void handleSensors(AsyncWebServerRequest *request) {
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html");
|
|
|
|
response->print(FPSTR(sensorsHtmlHead));
|
|
response->print(FPSTR(commonStyle));
|
|
response->print(FPSTR(sensorsHtmlHeadEnd));
|
|
response->print(FPSTR(commonNav));
|
|
response->print(FPSTR(sensorsBodyStart));
|
|
if (caseSensorEnabled) {
|
|
response->print(F(" checked"));
|
|
}
|
|
response->print(FPSTR(sensorsBodyAfterEnabled));
|
|
if (caseSensorType == CASE_SENSOR_TYPE_HOUSING) {
|
|
response->print(F("selected"));
|
|
}
|
|
response->print(FPSTR(sensorsTypeOptionMid));
|
|
if (caseSensorType == CASE_SENSOR_TYPE_CUPTRAY) {
|
|
response->print(F("selected"));
|
|
}
|
|
response->print(FPSTR(sensorsCaseSectionEnd));
|
|
if (!caseSensorEnabled) {
|
|
response->print(F("disabled "));
|
|
}
|
|
response->print(F("value='"));
|
|
char caseOffsetBuffer[16];
|
|
snprintf(caseOffsetBuffer, sizeof(caseOffsetBuffer), "%.1f", OffsetCase);
|
|
response->print(caseOffsetBuffer);
|
|
response->print(F("'"));
|
|
response->print(FPSTR(sensorsCaseOffsetAfterInput));
|
|
if (caseTempOnDashboard) {
|
|
response->print(F(" checked"));
|
|
}
|
|
response->print(FPSTR(sensorsCaseDashboardAfterToggle));
|
|
if (caseTempOnDisplay) {
|
|
response->print(F(" checked"));
|
|
}
|
|
response->print(FPSTR(sensorsCaseDisplayAfterToggle));
|
|
if (scaleEnabled) {
|
|
response->print(F(" checked"));
|
|
}
|
|
response->print(FPSTR(sensorsScaleAfterEnabled));
|
|
if (scaleType == SCALE_I2C) {
|
|
response->print(F("selected"));
|
|
}
|
|
response->print(FPSTR(sensorsScaleOptionMid1));
|
|
if (scaleType == SCALE_ESPNOW) {
|
|
response->print(F("selected"));
|
|
}
|
|
response->print(FPSTR(sensorsScaleOptionMid2));
|
|
if (scaleType == SCALE_HX711) {
|
|
response->print(F("selected"));
|
|
}
|
|
response->print(FPSTR(sensorsScaleAfterOptionsStart));
|
|
if (!(scaleEnabled && scaleType == SCALE_HX711)) {
|
|
response->print(F("disabled ") );
|
|
}
|
|
response->print(F("value='"));
|
|
char hxCalBuffer[16];
|
|
snprintf(hxCalBuffer, sizeof(hxCalBuffer), "%.2f", hx711CalibrationFactor);
|
|
response->print(hxCalBuffer);
|
|
response->print(F("'"));
|
|
response->print(FPSTR(sensorsBodyAfterHx711Start));
|
|
if (hx711DisplaySmoothingEnabled) {
|
|
response->print(F(" checked"));
|
|
}
|
|
response->print(FPSTR(sensorsBodyAfterHx711DisplaySmoothing));
|
|
if (xSwitchAction == X_SWITCH_ACTION_NONE) {
|
|
response->print(F("selected"));
|
|
}
|
|
response->print(FPSTR(sensorsXSwitchOptionMid1));
|
|
if (xSwitchAction == X_SWITCH_ACTION_MAINTENANCE) {
|
|
response->print(F("selected"));
|
|
}
|
|
response->print(FPSTR(sensorsXSwitchOptionMid2));
|
|
if (xSwitchAction == X_SWITCH_ACTION_FAST_HEAT_UP) {
|
|
response->print(F("selected"));
|
|
}
|
|
response->print(FPSTR(sensorsXSwitchOptionMid3));
|
|
if (xSwitchAction == X_SWITCH_ACTION_STEAM_DELAY_OVERRIDE) {
|
|
response->print(F("selected"));
|
|
}
|
|
response->print(FPSTR(sensorsXSwitchOptionMid4));
|
|
if (xSwitchAction == X_SWITCH_ACTION_SCALE_MODE) {
|
|
response->print(F("selected"));
|
|
}
|
|
response->print(FPSTR(sensorsXSwitchOptionMid5));
|
|
if (xSwitchAction == X_SWITCH_ACTION_DISABLE_STEAM_HEAT) {
|
|
response->print(F("selected"));
|
|
}
|
|
response->print(FPSTR(sensorsXSwitchOptionMid6));
|
|
if (xSwitchAction == X_SWITCH_ACTION_TARE_SCALE) {
|
|
response->print(F("selected"));
|
|
}
|
|
response->print(FPSTR(sensorsXSwitchLongStart));
|
|
if (xSwitchLongAction == X_SWITCH_ACTION_NONE) {
|
|
response->print(F("selected"));
|
|
}
|
|
response->print(FPSTR(sensorsXSwitchOptionMid1));
|
|
if (xSwitchLongAction == X_SWITCH_ACTION_MAINTENANCE) {
|
|
response->print(F("selected"));
|
|
}
|
|
response->print(FPSTR(sensorsXSwitchOptionMid2));
|
|
if (xSwitchLongAction == X_SWITCH_ACTION_FAST_HEAT_UP) {
|
|
response->print(F("selected"));
|
|
}
|
|
response->print(FPSTR(sensorsXSwitchOptionMid3));
|
|
if (xSwitchLongAction == X_SWITCH_ACTION_STEAM_DELAY_OVERRIDE) {
|
|
response->print(F("selected"));
|
|
}
|
|
response->print(FPSTR(sensorsXSwitchOptionMid4));
|
|
if (xSwitchLongAction == X_SWITCH_ACTION_SCALE_MODE) {
|
|
response->print(F("selected"));
|
|
}
|
|
response->print(FPSTR(sensorsXSwitchOptionMid5));
|
|
if (xSwitchLongAction == X_SWITCH_ACTION_DISABLE_STEAM_HEAT) {
|
|
response->print(F("selected"));
|
|
}
|
|
response->print(FPSTR(sensorsXSwitchLongOptionMid6));
|
|
if (xSwitchLongAction == X_SWITCH_ACTION_TARE_SCALE) {
|
|
response->print(F("selected"));
|
|
}
|
|
response->print(FPSTR(sensorsXSwitchLongPressStart));
|
|
response->print((unsigned int)buttonLongPressMs);
|
|
response->print(FPSTR(sensorsBodyEnd));
|
|
|
|
request->send(response);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler für die Eco-Modus-Konfiguration - Heap-Optimiert mit Chunked Sending
|
|
************************************************************************************/
|
|
|
|
// --- Angepasste PROGMEM Chunks für handleEco mit neuem Toggle Switch ---
|
|
|
|
// Head und Body Start bleiben gleich
|
|
static const char ecoHtmlHead[] PROGMEM = R"rawliteral(
|
|
<!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";
|
|
static const char ecoHtmlHeadEnd[] PROGMEM = R"rawliteral(
|
|
</head>
|
|
)rawliteral";
|
|
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}
|
|
|
|
// Chunk: Nach Eco-Temperatur Dampf bis VOR den Toggle Switch für Dynamic Eco
|
|
static const char ecoChunk_DynamicEco_Start[] PROGMEM = R"rawliteral('>
|
|
<br><h3>Dynamischer Eco-Modus</h3>
|
|
)rawliteral";
|
|
|
|
// Chunk: Der Toggle Switch Container für Dynamic Eco (Start)
|
|
static const char ecoChunk_ToggleDynEco_Start[] PROGMEM = R"rawliteral(
|
|
<div class="toggle-switch-container">
|
|
<span class="toggle-switch-label-text">Dynamischen Eco-Modus aktivieren (ECO+):</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="dynamicEcoMode" name="dynamicEcoMode" value="1")rawliteral"; // Endet VOR checked
|
|
|
|
// Chunk: Ende des Toggle Switch Containers
|
|
static const char ecoChunk_ToggleDynEco_End[] PROGMEM = R"rawliteral(>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
)rawliteral"; // Schließt Input, Span, Label, Div
|
|
|
|
// Chunk: Beschreibung für Dynamic Eco und Start der Dampfverzögerung
|
|
static const char ecoChunk_DynEcoDesc_And_SteamDelay[] PROGMEM = R"rawliteral(
|
|
<div style='margin-top:5px; margin-bottom: 15px;'> <small class="toggle-description">
|
|
Der dynamische Eco-Modus verhindert, dass die Maschine bis in die Unendlichkeit eine gewisse Temperatur aufrecht erhält.<br>
|
|
Er senkt die Temperatur pro Minute um ein weiteres Grad ab, bis letzlich das Heizen vollständig beendet wird.<br>
|
|
Der dynamische Eco-Modus ist nur in Kombination mit dem Eco-Modus nutzbar!<br>
|
|
<br>
|
|
<b>Beispiel fü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ü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.
|
|
</small>
|
|
</div>
|
|
<h3>Verzögerung für Dampf</h3>
|
|
<label for='dampfDelay'>Aufheizen für Dampf verzö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_BeforeToggle[] PROGMEM = R"rawliteral('>
|
|
)rawliteral";
|
|
|
|
static const char ecoChunk_SteamOverrideToggle_Start[] PROGMEM = R"rawliteral(
|
|
<div class="toggle-switch-container" style="margin-top: 5px;">
|
|
<span class="toggle-switch-label-text">Aufheizverzögerung per Bezugsschalter überspringen:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="steamDelayOverrideBySwitch" name="steamDelayOverrideBySwitch" value="1")rawliteral"; // Endet VOR checked
|
|
|
|
static const char ecoChunk_SteamOverrideToggle_End[] PROGMEM = R"rawliteral(>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<small class="toggle-description">Wenn aktiviert, startet das Heizen des Dampfkreislaufs sofort, sobald der Bezugsschalter betätigt wird, und ignoriert die eingestellte Verzögerung.</small>
|
|
)rawliteral";
|
|
|
|
static const char ecoChunk_DisplayInfoToggle_Start[] PROGMEM = R"rawliteral(
|
|
<br><h3>Display</h3>
|
|
<div class="toggle-switch-container">
|
|
<span class="toggle-switch-label-text">Eco-Info auf dem Display anzeigen:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="ecoInfoDisplay" name="ecoInfoDisplay" value="1")rawliteral"; // Endet VOR checked
|
|
|
|
static const char ecoChunk_DisplayInfoToggle_End[] PROGMEM = R"rawliteral(>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<small class="toggle-description">Zeigt im Idle-Display die Restzeit bis zur Eco-Aktivierung an.</small>
|
|
)rawliteral";
|
|
|
|
static const char ecoChunk_SteamHeatDisabledOnWakeToggle_Start[] PROGMEM = R"rawliteral(
|
|
<div class="toggle-switch-container">
|
|
<span class="toggle-switch-label-text">Dampf bei Neustart/Aufwachen auf "Nicht heizen" setzen:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="steamHeatDisabledOnWake" name="steamHeatDisabledOnWake" value="1")rawliteral";
|
|
|
|
static const char ecoChunk_SteamHeatDisabledOnWakeToggle_End[] PROGMEM = R"rawliteral(>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<small class="toggle-description">Setzt die Dampf-Heizung nach Controller-Neustart und beim Verlassen des Standby automatisch auf "Nicht heizen aktiv".</small>
|
|
)rawliteral";
|
|
|
|
static const char ecoChunk_StandbyTimeToggle_Start[] PROGMEM = R"rawliteral(
|
|
<div class="toggle-switch-container">
|
|
<span class="toggle-switch-label-text">Uhrzeit im Standby anzeigen:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="standbyTimeDisplay" name="standbyTimeDisplay" value="1")rawliteral"; // Endet VOR checked
|
|
|
|
static const char ecoChunk_StandbyTimeToggle_End[] PROGMEM = R"rawliteral(>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<small class="toggle-description">Nur aktiv, wenn die Zeit synchronisiert ist.</small>
|
|
)rawliteral";
|
|
|
|
// --- Display-Helligkeit (nur UART-Touch-Display / ESP32-P4) ---
|
|
static const char ecoChunk_BacklightActive_Start[] PROGMEM = R"rawliteral(
|
|
<br><h3>Display-Helligkeit (UART-Touch-Display)</h3>
|
|
<label for='backlightActive'>Helligkeit aktiv in % (5 - 100):</label>
|
|
<input type='number' min='5' max='100' step='1' id='backlightActive' name='backlightActive' value=')rawliteral"; // Endet vor Wert
|
|
|
|
static const char ecoChunk_BacklightStandby_Mid[] PROGMEM = R"rawliteral('>
|
|
<label for='backlightStandbyClock'>Helligkeit Standby-Uhr in % (0 - 100):</label>
|
|
<input type='number' min='0' max='100' step='1' id='backlightStandbyClock' name='backlightStandbyClock' value=')rawliteral"; // Endet vor Wert
|
|
|
|
static const char ecoChunk_Backlight_End[] PROGMEM = R"rawliteral('>
|
|
<small class="toggle-description" style="margin-top:0;">Gilt ausschließlich für das UART-Touch-Display (ESP32-P4), nicht für das OLED der Steuerung. 0 % bei der Standby-Uhr = Hintergrundbeleuchtung aus.</small>
|
|
)rawliteral";
|
|
|
|
static const char ecoChunk_LightAutoOffToggle_Start[] PROGMEM = R"rawliteral(
|
|
<br><h3>Beleuchtung</h3>
|
|
<div class="toggle-switch-container">
|
|
<span class="toggle-switch-label-text">Licht in Eco/Standby automatisch ausschalten:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="ecoLightAutoOff" name="ecoLightAutoOff" value="1")rawliteral"; // Endet VOR checked
|
|
|
|
static const char ecoChunk_LightAutoOffToggle_End[] PROGMEM = R"rawliteral(>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<small class="toggle-description">Schaltet das Licht im Eco- und Standby-Modus aus und stellt es danach wieder her.</small>
|
|
)rawliteral";
|
|
|
|
static const char ecoChunk_FinalWarningAndSubmit[] PROGMEM = R"rawliteral(
|
|
<br><br><b style='color: #FFCC00;'>ACHTUNG:</b><br>
|
|
Um Konflikte zu vermeiden, sollte der Wert der Verzögerung geringer sein, als der des Eco-Modus, falls dieser aktiviert ist!
|
|
<br><br>
|
|
<input type='submit' value='Einstellungen speichern' style='margin-top:10px;'>
|
|
</form>
|
|
</body>
|
|
</html>
|
|
)rawliteral";
|
|
|
|
|
|
// --- Vollständig überarbeitete Funktion handleEco ---
|
|
void handleEco(AsyncWebServerRequest *request) // URL: /ECO
|
|
{
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html");
|
|
char buffer[12]; // Puffer für Zahlenumwandlungen
|
|
|
|
// Kopfzeile senden
|
|
response->print(FPSTR(ecoHtmlHead));
|
|
response->print(FPSTR(commonStyle)); // Globaler Style mit Toggle-CSS
|
|
response->print(FPSTR(ecoHtmlHeadEnd));
|
|
response->print(FPSTR(commonNav)); // Navigation
|
|
|
|
// Body Start bis Eco Mode Wert
|
|
response->print(FPSTR(ecoChunk_BodyStart));
|
|
snprintf(buffer, sizeof(buffer), "%d", ecoModeMinutes); // Eco Mode Wert (int)
|
|
response->print(buffer);
|
|
yield();
|
|
|
|
// Nach Eco Mode bis Eco Temp Wasser
|
|
response->print(FPSTR(ecoChunk_AfterEcoMode));
|
|
snprintf(buffer, sizeof(buffer), "%d", ecoModeTempWasser); // Eco Temp Wasser Wert (int)
|
|
response->print(buffer);
|
|
yield();
|
|
|
|
// Nach Eco Temp Wasser bis Eco Temp Dampf
|
|
response->print(FPSTR(ecoChunk_AfterTempW));
|
|
snprintf(buffer, sizeof(buffer), "%d", ecoModeTempDampf); // Eco Temp Dampf Wert (int)
|
|
response->print(buffer);
|
|
yield();
|
|
|
|
// Dynamic Eco Start und Toggle
|
|
response->print(FPSTR(ecoChunk_DynamicEco_Start)); // '>' nach Dampf Temp, <br>, H3
|
|
response->print(FPSTR(ecoChunk_ToggleDynEco_Start)); // Toggle HTML bis vor checked
|
|
if (dynamicEcoActive) {
|
|
response->print(F(" checked")); // checked Attribut einfügen
|
|
}
|
|
response->print(FPSTR(ecoChunk_ToggleDynEco_End)); // Rest des Toggles
|
|
yield();
|
|
|
|
// Beschreibung Dynamic Eco und Start Dampfverzögerung
|
|
response->print(FPSTR(ecoChunk_DynEcoDesc_And_SteamDelay)); // Beschreibung, H3, Label bis value=
|
|
snprintf(buffer, sizeof(buffer), "%d", dampfVerzoegerung); // Dampf Delay Wert (int)
|
|
response->print(buffer);
|
|
yield();
|
|
|
|
// Schließt das Input-Feld der Dampfverzögerung
|
|
response->print(FPSTR(ecoChunk_AfterDampfDelay_BeforeToggle));
|
|
|
|
// Neuer Toggle-Switch für den Override
|
|
response->print(FPSTR(ecoChunk_SteamOverrideToggle_Start)); // HTML bis vor 'checked'
|
|
if (steamDelayOverrideBySwitchEnabled) {
|
|
response->print(F(" checked")); // 'checked' Attribut einfügen, wenn die Option aktiv ist
|
|
}
|
|
response->print(FPSTR(ecoChunk_SteamOverrideToggle_End)); // Rest des Toggle-HTMLs mit Beschreibung
|
|
yield();
|
|
|
|
response->print(FPSTR(ecoChunk_SteamHeatDisabledOnWakeToggle_Start));
|
|
if (steamHeatDisabledOnStartupWake) {
|
|
response->print(F(" checked"));
|
|
}
|
|
response->print(FPSTR(ecoChunk_SteamHeatDisabledOnWakeToggle_End));
|
|
yield();
|
|
|
|
// Toggle-Switch für Eco-Info im Display
|
|
response->print(FPSTR(ecoChunk_DisplayInfoToggle_Start)); // HTML bis vor 'checked'
|
|
if (ecoInfoOnDisplay) {
|
|
response->print(F(" checked"));
|
|
}
|
|
response->print(FPSTR(ecoChunk_DisplayInfoToggle_End)); // Rest des Toggle-HTMLs mit Beschreibung
|
|
yield();
|
|
|
|
// Toggle-Switch fuer Standby-Zeit auf dem Display
|
|
response->print(FPSTR(ecoChunk_StandbyTimeToggle_Start)); // HTML bis vor 'checked'
|
|
if (standbyTimeOnDisplay) {
|
|
response->print(F(" checked"));
|
|
}
|
|
response->print(FPSTR(ecoChunk_StandbyTimeToggle_End)); // Rest des Toggle-HTMLs mit Beschreibung
|
|
yield();
|
|
|
|
// Display-Helligkeit (UART-Touch-Display / P4)
|
|
response->print(FPSTR(ecoChunk_BacklightActive_Start));
|
|
snprintf(buffer, sizeof(buffer), "%u", (unsigned int)backlightActivePercent);
|
|
response->print(buffer);
|
|
response->print(FPSTR(ecoChunk_BacklightStandby_Mid));
|
|
snprintf(buffer, sizeof(buffer), "%u", (unsigned int)backlightStandbyClockPercent);
|
|
response->print(buffer);
|
|
response->print(FPSTR(ecoChunk_Backlight_End));
|
|
yield();
|
|
|
|
// Toggle-Switch fuer Eco-Lichtabschaltung
|
|
response->print(FPSTR(ecoChunk_LightAutoOffToggle_Start)); // HTML bis vor 'checked'
|
|
if (ecoLightAutoOffEnabled) {
|
|
response->print(F(" checked"));
|
|
}
|
|
response->print(FPSTR(ecoChunk_LightAutoOffToggle_End)); // Rest des Toggle-HTMLs mit Beschreibung
|
|
yield();
|
|
|
|
// Rest der Seite (Warnung, Button, Ende)
|
|
response->print(FPSTR(ecoChunk_FinalWarningAndSubmit));
|
|
|
|
request->send(response);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler für die Fast-Heat-Up Seite - Heap-Optimiert mit Chunked Sending
|
|
************************************************************************************/
|
|
|
|
// --- Angepasste PROGMEM Chunks für handleFastHeatUp mit Toggle Switch ---
|
|
|
|
// Kopfzeile bleibt gleich
|
|
static const char fhuHtmlHead[] PROGMEM = R"rawliteral(
|
|
<!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
|
|
static const char fhuHtmlHeadEnd[] PROGMEM = R"rawliteral(
|
|
</head>
|
|
)rawliteral";
|
|
|
|
// Neuer Chunk: Body Start bis vor den Toggle Switch
|
|
static const char fhuBodyStart[] PROGMEM = R"rawliteral(
|
|
<body>
|
|
<h1>Fast-Heat-Up-Modus</h1>
|
|
<form action='/updateFast-Heat-Up-Settings' method='POST'>
|
|
<h3>Fast-Heat-Up-Modus</h3>
|
|
)rawliteral";
|
|
|
|
// Neuer Chunk: Der Toggle Switch Container Start bis VOR das checked Attribut
|
|
static const char fhuToggleContainerStart[] PROGMEM = R"rawliteral(
|
|
<div class="toggle-switch-container">
|
|
<span class="toggle-switch-label-text">Fast-Heat-Up aktivieren:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="fastHeatUpAktiv" name="fastHeatUpAktiv" value="1")rawliteral"; // ID geändert für Konsistenz, endet VOR checked
|
|
|
|
// Neuer Chunk: Ende des Toggle Switch Containers
|
|
static const char fhuToggleContainerEnd[] PROGMEM = R"rawliteral(>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
)rawliteral"; // Schließt Input, Span, Label, Div
|
|
|
|
// Neuer Chunk: Beschreibungstext und Formular-Ende
|
|
static const char fhuDescriptionAndEnd[] PROGMEM = R"rawliteral(
|
|
<div style='margin-top:5px; margin-bottom: 15px;'> <small class="toggle-description"> Der Fast-Heat-Up-Modus ermö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ü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>
|
|
</small>
|
|
</div>
|
|
<input type='submit' value='Einstellungen speichern' style='margin-top:10px;'> </form>
|
|
</body>
|
|
</html>
|
|
)rawliteral";
|
|
|
|
// --- Ende der PROGMEM Chunks ---
|
|
|
|
|
|
// --- Überarbeitete Funktion handleFastHeatUp mit Toggle Switch ---
|
|
void handleFastHeatUp(AsyncWebServerRequest *request) // URL: /Fast-Heat-Up
|
|
{
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html");
|
|
|
|
// Kopfzeile senden
|
|
response->print(FPSTR(fhuHtmlHead));
|
|
response->print(FPSTR(commonStyle)); // Globaler Style mit Toggle-CSS
|
|
response->print(FPSTR(fhuHtmlHeadEnd));
|
|
response->print(FPSTR(commonNav)); // Navigation
|
|
|
|
// Body Start und Formular-Anfang
|
|
response->print(FPSTR(fhuBodyStart));
|
|
yield();
|
|
|
|
// Toggle Switch HTML-Teile senden
|
|
response->print(FPSTR(fhuToggleContainerStart)); // Bis vor 'checked'
|
|
if (fastHeatUpAktiv) {
|
|
response->print(F(" checked")); // Fügt 'checked' hinzu, wenn aktiv
|
|
}
|
|
response->print(FPSTR(fhuToggleContainerEnd)); // Schließt den Toggle-Switch
|
|
yield();
|
|
|
|
// Beschreibung und Rest der Seite senden
|
|
response->print(FPSTR(fhuDescriptionAndEnd));
|
|
|
|
request->send(response);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler zum Speichern der Fast-Heat-Up-Einstellung - URL angepasst
|
|
************************************************************************************/
|
|
|
|
void handleFastHeatUpSettings(AsyncWebServerRequest *request) {
|
|
if (request->hasArg(F("fastHeatUpAktiv"))) {
|
|
fastHeatUpAktiv = true;
|
|
} else {
|
|
fastHeatUpAktiv = false;
|
|
}
|
|
|
|
EEPROM.put(EEPROM_ADDR_FASTHEATUP_DATA, fastHeatUpAktiv);
|
|
EEPROM.commit();
|
|
|
|
AsyncWebServerResponse *resp = request->beginResponse(303);
|
|
resp->addHeader(F("Location"), F("/Fast-Heat-Up"));
|
|
request->send(resp);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler für Root (PID-Einstellungen) - Heap-Optimiert mit Chunked Sending
|
|
************************************************************************************/
|
|
|
|
// --- PROGMEM Chunks für handlePidSettings mit Toggle Switches ---
|
|
|
|
// Head und Body Start bleiben gleich
|
|
static const char rootHtmlHead[] PROGMEM = R"rawliteral(
|
|
<!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";
|
|
static const char rootHtmlHeadEnd[] PROGMEM = R"rawliteral(
|
|
</head>
|
|
)rawliteral";
|
|
static const char rootChunk_BodyStart[] PROGMEM = R"rawliteral(
|
|
<body>
|
|
<h1>PID-Einstellung</h1>
|
|
)rawliteral";
|
|
|
|
// --- Wasser Temperatur Sektion ---
|
|
static const char rootChunk_WasserTempStart[] PROGMEM = R"rawliteral(
|
|
<form action='/updateSettings' method='POST'>
|
|
<h3>Temperatur: Wasser / Kessel</h3>
|
|
<label for='wasser'>Setpoint (°C):</label>
|
|
<input type='number' min='0' max='135' step='1' id='wasser' name='wasser' value=')rawliteral"; // Endet vor Wasser Setpoint Wert
|
|
static const char rootChunk_WasserOffset[] PROGMEM = R"rawliteral('>
|
|
<label for='offsetWasser'>Offset (°C):</label>
|
|
<input type='number' step='0.01' id='offsetWasser' name='offsetWasser' value=')rawliteral"; // Endet vor Wasser Offset Wert
|
|
|
|
// --- Wasser Boost Toggle ---
|
|
static const char rootChunk_ToggleBoostW_Start[] PROGMEM = R"rawliteral('>
|
|
<div class="toggle-switch-container">
|
|
<span class="toggle-switch-label-text">Boost-Funktion:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="boostWasser" name="boostWasser" value="1")rawliteral"; // Endet VOR checked
|
|
static const char rootChunk_ToggleBoostW_End[] PROGMEM = R"rawliteral(>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<small class="toggle-description">Volle Heizleistung erzwingen bis 95% der Zieltemperatur</small>
|
|
)rawliteral";
|
|
|
|
// --- Wasser Prevent Heat Toggle ---
|
|
static const char rootChunk_TogglePreventW_Start[] PROGMEM = R"rawliteral(
|
|
<div class="toggle-switch-container">
|
|
<span class="toggle-switch-label-text">Heizen oberhalb Setpoint verhindern:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="preventHeatWasser" name="preventHeatWasser" value="1")rawliteral"; // Endet VOR checked
|
|
static const char rootChunk_TogglePreventW_End[] PROGMEM = R"rawliteral(>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<small class="toggle-description">Kein Heizen, wenn Temp. > Sollwert</small>
|
|
<br>
|
|
)rawliteral";
|
|
|
|
// --- Dampf Temperatur Sektion ---
|
|
static const char rootChunk_DampfTempStart[] PROGMEM = R"rawliteral(
|
|
<h3>Temperatur: Dampf / Thermoblock</h3>
|
|
<label for='dampf'>Setpoint (°C):</label>
|
|
<input type='number' min='0' max='200' step='1' id='dampf' name='dampf' value=')rawliteral"; // Endet vor Dampf Setpoint Wert
|
|
static const char rootChunk_DampfOffset[] PROGMEM = R"rawliteral('>
|
|
<label for='offsetDampf'>Offset (°C):</label>
|
|
<input type='number' step='0.01' id='offsetDampf' name='offsetDampf' value=')rawliteral"; // Endet vor Dampf Offset Wert
|
|
static const char rootChunk_OffsetCompensationToggle_Start[] PROGMEM = R"rawliteral('>
|
|
<div class="toggle-switch-container">
|
|
<span class="toggle-switch-label-text">Offset-Kompensation:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="offsetCompensation" name="offsetCompensation" value="1")rawliteral";
|
|
static const char rootChunk_OffsetCompensationToggle_End[] PROGMEM = R"rawliteral(>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<small class="toggle-description">Zeigt bei negativem Offset in Web-UI und Display zunächst die Temperatur ohne Offset, bis die offset-korrigierte Temperatur den Referenzwert beim Start/Aufwachen erreicht.</small>
|
|
)rawliteral";
|
|
|
|
// --- Dampf Boost Toggle ---
|
|
static const char rootChunk_ToggleBoostD_Start[] PROGMEM = R"rawliteral(
|
|
<div class="toggle-switch-container">
|
|
<span class="toggle-switch-label-text">Boost-Funktion:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="boostDampf" name="boostDampf" value="1")rawliteral"; // Endet VOR checked
|
|
static const char rootChunk_ToggleBoostD_End[] PROGMEM = R"rawliteral(>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<small class="toggle-description">Volle Heizleistung erzwingen bis 90% der Zieltemperatur</small>
|
|
)rawliteral";
|
|
|
|
// --- Dampf Prevent Heat Toggle ---
|
|
static const char rootChunk_TogglePreventD_Start[] PROGMEM = R"rawliteral(
|
|
<div class="toggle-switch-container">
|
|
<span class="toggle-switch-label-text">Heizen oberhalb Setpoint verhindern:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="preventHeatDampf" name="preventHeatDampf" value="1")rawliteral"; // Endet VOR checked
|
|
static const char rootChunk_TogglePreventD_End[] PROGMEM = R"rawliteral(>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<small class="toggle-description">Kein Heizen, wenn Temp. > Sollwert</small>
|
|
)rawliteral";
|
|
|
|
// --- Dampf Permanent Heat bei Bezug Toggle ---
|
|
static const char rootChunk_ToggleSteamHeatOnDraw_Start[] PROGMEM = R"rawliteral(
|
|
<div class="toggle-switch-container">
|
|
<span class="toggle-switch-label-text">Permanent heizen bei Dampf-Bezug:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="steamHeatOnDraw" name="steamHeatOnDraw" value="1")rawliteral"; // Endet VOR checked
|
|
static const char rootChunk_ToggleSteamHeatOnDraw_End[] PROGMEM = R"rawliteral(>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<small class="toggle-description">Dampf-Heizung dauerhaft ein, solange Dampfbezug aktiv ist</small>
|
|
<br>
|
|
)rawliteral";
|
|
|
|
// --- PID Wasser Sektion ---
|
|
static const char rootChunk_PIDWasser_Start[] PROGMEM = R"rawliteral(
|
|
<h3>PID-Parameter: Wasser / Kessel</h3>
|
|
<label for='kpWasser'>Kp:</label>
|
|
<input type='number' step='0.01' id='kpWasser' name='kpWasser' value=')rawliteral"; // Endet vor Kp Wasser Wert
|
|
static const char rootChunk_PIDWasser_Ki[] PROGMEM = R"rawliteral('>
|
|
<label for='kiWasser'>Ki:</label>
|
|
<input type='number' step='0.01' id='kiWasser' name='kiWasser' value=')rawliteral"; // Endet vor Ki Wasser Wert
|
|
static const char rootChunk_PIDWasser_Kd[] PROGMEM = R"rawliteral('>
|
|
<label for='kdWasser'>Kd:</label>
|
|
<input type='number' step='0.01' id='kdWasser' name='kdWasser' value=')rawliteral"; // Endet vor Kd Wasser Wert
|
|
static const char rootChunk_PIDWasser_Window[] PROGMEM = R"rawliteral('>
|
|
<label for='windowSizeWasser'>Zeitfenster (ms):</label>
|
|
<input type='number' step='1' id='windowSizeWasser' name='windowSizeWasser' value=')rawliteral"; // Step auf 1 geändert für ms, Endet vor Window Size Wert
|
|
|
|
// --- PID Dampf Sektion ---
|
|
static const char rootChunk_PIDDampf_Start[] PROGMEM = R"rawliteral('>
|
|
<br><br> <h3>PID-Parameter: Dampf / Thermoblock</h3>
|
|
<label for='kpDampf'>Kp:</label>
|
|
<input type='number' step='0.01' id='kpDampf' name='kpDampf' value=')rawliteral"; // Endet vor Kp Dampf Wert
|
|
static const char rootChunk_PIDDampf_Ki[] PROGMEM = R"rawliteral('>
|
|
<label for='kiDampf'>Ki:</label>
|
|
<input type='number' step='0.01' id='kiDampf' name='kiDampf' value=')rawliteral"; // Endet vor Ki Dampf Wert
|
|
static const char rootChunk_PIDDampf_Kd[] PROGMEM = R"rawliteral('>
|
|
<label for='kdDampf'>Kd:</label>
|
|
<input type='number' step='0.01' id='kdDampf' name='kdDampf' value=')rawliteral"; // Endet vor Kd Dampf Wert
|
|
static const char rootChunk_PIDDampf_Window[] PROGMEM = R"rawliteral('>
|
|
<label for='windowSizeDampf'>Zeitfenster (ms):</label>
|
|
<input type='number' step='1' id='windowSizeDampf' name='windowSizeDampf' value=')rawliteral"; // Step auf 1 geändert für ms, Endet vor Window Size Wert
|
|
|
|
// --- Sicherheitsabschaltung Sektion ---
|
|
static const char rootChunk_Safety_Start[] PROGMEM = R"rawliteral('>
|
|
<br><br> <h3>Sicherheitsabschaltung</h3>
|
|
<p style="font-size:0.9em; color: #ccc; margin-bottom:15px;">
|
|
Die Sicherheitsabschaltung bietet eine softwareseitige Übertemperatursicherung,<br>
|
|
ersetzt jedoch keine hardwareseitige Lösung durch z.B. Bimetall Temperaturschalter!<br>
|
|
Bei der Verwendung von Fast-Heat-Up muss der Wert für die Maximaltemperatur angepasst werden!
|
|
</p>
|
|
<label for='maxTempWasser'>Max. Temp Wasser (°C):</label>
|
|
<input type='number' step='0.1' id='maxTempWasser' name='maxTempWasser' value=')rawliteral"; // Step 0.1, Endet vor Max Temp Wasser Wert
|
|
static const char rootChunk_Safety_MaxDampf[] PROGMEM = R"rawliteral('>
|
|
<label for='maxTempDampf'>Max. Temp Dampf (°C):</label>
|
|
<input type='number' step='0.1' id='maxTempDampf' name='maxTempDampf' value=')rawliteral"; // Step 0.1, Endet vor Max Temp Dampf Wert
|
|
|
|
// --- Formular Ende ---
|
|
static const char rootChunk_FormEnd[] PROGMEM = R"rawliteral('>
|
|
<br> <input type='submit' value='Einstellungen speichern' style='margin-top:15px;'>
|
|
</form>
|
|
</body>
|
|
</html>
|
|
)rawliteral";
|
|
|
|
// --- Ende der PROGMEM Chunks ---
|
|
|
|
// Überarbeitete Funktion handlePidSettings mit Toggle Switches
|
|
void handlePidSettings(AsyncWebServerRequest *request) {
|
|
char buffer[20];
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html");
|
|
|
|
response->write(rootHtmlHead, strlen_P(rootHtmlHead));
|
|
response->write(commonStyle, strlen_P(commonStyle));
|
|
response->write(rootHtmlHeadEnd, strlen_P(rootHtmlHeadEnd));
|
|
response->write(commonNav, strlen_P(commonNav));
|
|
|
|
response->write(rootChunk_BodyStart, strlen_P(rootChunk_BodyStart));
|
|
yield();
|
|
|
|
String errorMessageHtml = "";
|
|
if (demoModus) {
|
|
errorMessageHtml = "<div class=\"sensor-error-message\"><b>Demo-Betrieb:</b><br>Es sind keine Sensoren angeschlossen und daher keine Temperaturwerte verfügbar.</div>";
|
|
} else if (wasserSensorError && dampfSensorError) {
|
|
errorMessageHtml = "<div class=\"sensor-error-message\">Die Daten der Temperatursensoren sind derzeit nicht verfügbar!<br>Bitte Temperatur-Sensoren prüfen!</div>";
|
|
} else if (wasserSensorError) {
|
|
errorMessageHtml = "<div class=\"sensor-error-message\">Temperaturwert für Wasser nicht verfügbar</div>";
|
|
} else if (dampfSensorError) {
|
|
errorMessageHtml = "<div class=\"sensor-error-message\">Temperaturwert für Dampf nicht verfügbar</div>";
|
|
}
|
|
if (errorMessageHtml.length() > 0) {
|
|
response->print(errorMessageHtml);
|
|
yield();
|
|
}
|
|
|
|
response->write(rootChunk_WasserTempStart, strlen_P(rootChunk_WasserTempStart));
|
|
snprintf(buffer, sizeof(buffer), "%d", (int)SetpointWasser);
|
|
response->print(buffer);
|
|
response->write(rootChunk_WasserOffset, strlen_P(rootChunk_WasserOffset));
|
|
snprintf(buffer, sizeof(buffer), "%.2f", OffsetWasser);
|
|
response->print(buffer);
|
|
yield();
|
|
|
|
response->write(rootChunk_ToggleBoostW_Start, strlen_P(rootChunk_ToggleBoostW_Start));
|
|
if (boostWasserActive) response->print(F(" checked"));
|
|
response->write(rootChunk_ToggleBoostW_End, strlen_P(rootChunk_ToggleBoostW_End));
|
|
yield();
|
|
|
|
response->write(rootChunk_TogglePreventW_Start, strlen_P(rootChunk_TogglePreventW_Start));
|
|
if (preventHeatAboveSetpointWasser) response->print(F(" checked"));
|
|
response->write(rootChunk_TogglePreventW_End, strlen_P(rootChunk_TogglePreventW_End));
|
|
yield();
|
|
|
|
response->write(rootChunk_DampfTempStart, strlen_P(rootChunk_DampfTempStart));
|
|
snprintf(buffer, sizeof(buffer), "%d", (int)SetpointDampf);
|
|
response->print(buffer);
|
|
response->write(rootChunk_DampfOffset, strlen_P(rootChunk_DampfOffset));
|
|
snprintf(buffer, sizeof(buffer), "%.2f", OffsetDampf);
|
|
response->print(buffer);
|
|
response->write(rootChunk_OffsetCompensationToggle_Start, strlen_P(rootChunk_OffsetCompensationToggle_Start));
|
|
if (offsetCompensationEnabled) response->print(F(" checked"));
|
|
response->write(rootChunk_OffsetCompensationToggle_End, strlen_P(rootChunk_OffsetCompensationToggle_End));
|
|
yield();
|
|
|
|
response->write(rootChunk_ToggleBoostD_Start, strlen_P(rootChunk_ToggleBoostD_Start));
|
|
if (boostDampfActive) response->print(F(" checked"));
|
|
response->write(rootChunk_ToggleBoostD_End, strlen_P(rootChunk_ToggleBoostD_End));
|
|
yield();
|
|
|
|
response->write(rootChunk_TogglePreventD_Start, strlen_P(rootChunk_TogglePreventD_Start));
|
|
if (preventHeatAboveSetpointDampf) response->print(F(" checked"));
|
|
response->write(rootChunk_TogglePreventD_End, strlen_P(rootChunk_TogglePreventD_End));
|
|
yield();
|
|
|
|
response->write(rootChunk_ToggleSteamHeatOnDraw_Start, strlen_P(rootChunk_ToggleSteamHeatOnDraw_Start));
|
|
if (steamHeatOnDraw) response->print(F(" checked"));
|
|
response->write(rootChunk_ToggleSteamHeatOnDraw_End, strlen_P(rootChunk_ToggleSteamHeatOnDraw_End));
|
|
yield();
|
|
|
|
response->write(rootChunk_PIDWasser_Start, strlen_P(rootChunk_PIDWasser_Start));
|
|
snprintf(buffer, sizeof(buffer), "%.2f", KpWasser);
|
|
response->print(buffer);
|
|
response->write(rootChunk_PIDWasser_Ki, strlen_P(rootChunk_PIDWasser_Ki));
|
|
snprintf(buffer, sizeof(buffer), "%.2f", KiWasser);
|
|
response->print(buffer);
|
|
response->write(rootChunk_PIDWasser_Kd, strlen_P(rootChunk_PIDWasser_Kd));
|
|
snprintf(buffer, sizeof(buffer), "%.2f", KdWasser);
|
|
response->print(buffer);
|
|
response->write(rootChunk_PIDWasser_Window, strlen_P(rootChunk_PIDWasser_Window));
|
|
snprintf(buffer, sizeof(buffer), "%lu", windowSizeWasser);
|
|
response->print(buffer);
|
|
yield();
|
|
|
|
response->write(rootChunk_PIDDampf_Start, strlen_P(rootChunk_PIDDampf_Start));
|
|
snprintf(buffer, sizeof(buffer), "%.2f", KpDampf);
|
|
response->print(buffer);
|
|
response->write(rootChunk_PIDDampf_Ki, strlen_P(rootChunk_PIDDampf_Ki));
|
|
snprintf(buffer, sizeof(buffer), "%.2f", KiDampf);
|
|
response->print(buffer);
|
|
response->write(rootChunk_PIDDampf_Kd, strlen_P(rootChunk_PIDDampf_Kd));
|
|
snprintf(buffer, sizeof(buffer), "%.2f", KdDampf);
|
|
response->print(buffer);
|
|
response->write(rootChunk_PIDDampf_Window, strlen_P(rootChunk_PIDDampf_Window));
|
|
snprintf(buffer, sizeof(buffer), "%lu", windowSizeDampf);
|
|
response->print(buffer);
|
|
yield();
|
|
|
|
response->write(rootChunk_Safety_Start, strlen_P(rootChunk_Safety_Start));
|
|
snprintf(buffer, sizeof(buffer), "%.1f", maxTempWasser);
|
|
response->print(buffer);
|
|
response->write(rootChunk_Safety_MaxDampf, strlen_P(rootChunk_Safety_MaxDampf));
|
|
snprintf(buffer, sizeof(buffer), "%.1f", maxTempDampf);
|
|
response->print(buffer);
|
|
yield();
|
|
|
|
response->write(rootChunk_FormEnd, strlen_P(rootChunk_FormEnd));
|
|
request->send(response);
|
|
yield();
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Schreibt die Werte in den EEPROM (PID/Eco) - Unverändert
|
|
************************************************************************************/
|
|
|
|
void handleSettingsUpdate(AsyncWebServerRequest *request) {
|
|
bool offsetCompensationParamsChanged = false;
|
|
bool valueChanged = false; // Flag um zu prüfen, ob EEPROM.commit() nötig ist
|
|
|
|
// --- Bestehende Werte lesen ---
|
|
// Sicherheitslimits
|
|
if (request->hasArg(F("maxTempWasser"))) {
|
|
float newVal = request->arg(F("maxTempWasser")).toFloat();
|
|
if (newVal != maxTempWasser) {
|
|
maxTempWasser = newVal;
|
|
EEPROM.put(EEPROM_ADDR_MAX_TEMP_WASSER, maxTempWasser);
|
|
valueChanged = true;
|
|
}
|
|
}
|
|
if (request->hasArg(F("maxTempDampf"))) {
|
|
float newVal = request->arg(F("maxTempDampf")).toFloat();
|
|
if (newVal != maxTempDampf) {
|
|
maxTempDampf = newVal;
|
|
EEPROM.put(EEPROM_ADDR_MAX_TEMP_DAMPF, maxTempDampf);
|
|
valueChanged = true;
|
|
}
|
|
}
|
|
// Setpoints
|
|
if (request->hasArg(F("wasser"))) {
|
|
float newVal = request->arg(F("wasser")).toFloat();
|
|
if (newVal != SetpointWasser) {
|
|
SetpointWasser = newVal;
|
|
EEPROM.put(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
valueChanged = true;
|
|
}
|
|
}
|
|
if (request->hasArg(F("dampf"))) {
|
|
float newVal = request->arg(F("dampf")).toFloat();
|
|
if (newVal != SetpointDampf) {
|
|
SetpointDampf = newVal;
|
|
EEPROM.put(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
valueChanged = true;
|
|
}
|
|
}
|
|
// Offsets
|
|
if (request->hasArg(F("offsetWasser"))) {
|
|
float newVal = request->arg(F("offsetWasser")).toFloat();
|
|
if (newVal != OffsetWasser) {
|
|
OffsetWasser = newVal;
|
|
EEPROM.put(EEPROM_ADDR_OFFSET_WASSER, OffsetWasser);
|
|
valueChanged = true;
|
|
offsetCompensationParamsChanged = true;
|
|
}
|
|
}
|
|
if (request->hasArg(F("offsetDampf"))) {
|
|
float newVal = request->arg(F("offsetDampf")).toFloat();
|
|
if (newVal != OffsetDampf) {
|
|
OffsetDampf = newVal;
|
|
EEPROM.put(EEPROM_ADDR_OFFSET_DAMPF, OffsetDampf);
|
|
valueChanged = true;
|
|
offsetCompensationParamsChanged = true;
|
|
}
|
|
}
|
|
{
|
|
bool newVal = request->hasArg(F("offsetCompensation"));
|
|
if (newVal != offsetCompensationEnabled) {
|
|
offsetCompensationEnabled = newVal;
|
|
EEPROM.put(EEPROM_ADDR_OFFSET_COMPENSATION_ENABLED, (uint8_t)(offsetCompensationEnabled ? 1 : 0));
|
|
valueChanged = true;
|
|
offsetCompensationParamsChanged = true;
|
|
}
|
|
}
|
|
// PID Wasser
|
|
if (request->hasArg(F("kpWasser"))) {
|
|
float newVal = request->arg(F("kpWasser")).toFloat();
|
|
if (newVal != KpWasser) {
|
|
KpWasser = newVal;
|
|
EEPROM.put(EEPROM_ADDR_KP_WASSER, KpWasser);
|
|
valueChanged = true;
|
|
}
|
|
}
|
|
if (request->hasArg(F("kiWasser"))) {
|
|
float newVal = request->arg(F("kiWasser")).toFloat();
|
|
if (newVal != KiWasser) {
|
|
KiWasser = newVal;
|
|
EEPROM.put(EEPROM_ADDR_KI_WASSER, KiWasser);
|
|
valueChanged = true;
|
|
}
|
|
}
|
|
if (request->hasArg(F("kdWasser"))) {
|
|
float newVal = request->arg(F("kdWasser")).toFloat();
|
|
if (newVal != KdWasser) {
|
|
KdWasser = newVal;
|
|
EEPROM.put(EEPROM_ADDR_KD_WASSER, KdWasser);
|
|
valueChanged = true;
|
|
}
|
|
}
|
|
// PID Dampf
|
|
if (request->hasArg(F("kpDampf"))) {
|
|
float newVal = request->arg(F("kpDampf")).toFloat();
|
|
if (newVal != KpDampf) {
|
|
KpDampf = newVal;
|
|
EEPROM.put(EEPROM_ADDR_KP_DAMPF, KpDampf);
|
|
valueChanged = true;
|
|
}
|
|
}
|
|
if (request->hasArg(F("kiDampf"))) {
|
|
float newVal = request->arg(F("kiDampf")).toFloat();
|
|
if (newVal != KiDampf) {
|
|
KiDampf = newVal;
|
|
EEPROM.put(EEPROM_ADDR_KI_DAMPF, KiDampf);
|
|
valueChanged = true;
|
|
}
|
|
}
|
|
if (request->hasArg(F("kdDampf"))) {
|
|
float newVal = request->arg(F("kdDampf")).toFloat();
|
|
if (newVal != KdDampf) {
|
|
KdDampf = newVal;
|
|
EEPROM.put(EEPROM_ADDR_KD_DAMPF, KdDampf);
|
|
valueChanged = true;
|
|
}
|
|
}
|
|
// Boost Flags
|
|
bool newBoostWasser = request->hasArg(F("boostWasser"));
|
|
if (newBoostWasser != boostWasserActive) {
|
|
boostWasserActive = newBoostWasser;
|
|
EEPROM.put(EEPROM_ADDR_BOOST_WASSER_ACTIVE, boostWasserActive);
|
|
valueChanged = true;
|
|
}
|
|
bool newBoostDampf = request->hasArg(F("boostDampf"));
|
|
if (newBoostDampf != boostDampfActive) {
|
|
boostDampfActive = newBoostDampf;
|
|
EEPROM.put(EEPROM_ADDR_BOOST_DAMPF_ACTIVE, boostDampfActive);
|
|
valueChanged = true;
|
|
}
|
|
|
|
// Prevent Heat Above Setpoint Flags
|
|
bool newPreventHeatWasser = request->hasArg(F("preventHeatWasser"));
|
|
if (newPreventHeatWasser != preventHeatAboveSetpointWasser) {
|
|
preventHeatAboveSetpointWasser = newPreventHeatWasser;
|
|
EEPROM.put(EEPROM_ADDR_PREVENTHEAT_WASSER, preventHeatAboveSetpointWasser);
|
|
valueChanged = true;
|
|
// Serial.printf("PreventHeat Wasser %s\n", preventHeatAboveSetpointWasser ? "aktiviert" : "deaktiviert");
|
|
}
|
|
bool newPreventHeatDampf = request->hasArg(F("preventHeatDampf"));
|
|
if (newPreventHeatDampf != preventHeatAboveSetpointDampf) {
|
|
preventHeatAboveSetpointDampf = newPreventHeatDampf;
|
|
EEPROM.put(EEPROM_ADDR_PREVENTHEAT_DAMPF, preventHeatAboveSetpointDampf);
|
|
valueChanged = true;
|
|
// Serial.printf("PreventHeat Dampf %s\n", preventHeatAboveSetpointDampf ? "aktiviert" : "deaktiviert");
|
|
}
|
|
bool newSteamHeatOnDraw = request->hasArg(F("steamHeatOnDraw"));
|
|
if (newSteamHeatOnDraw != steamHeatOnDraw) {
|
|
steamHeatOnDraw = newSteamHeatOnDraw;
|
|
EEPROM.put(EEPROM_ADDR_STEAM_HEAT_ON_DRAW, (uint8_t)(steamHeatOnDraw ? 1 : 0));
|
|
valueChanged = true;
|
|
}
|
|
|
|
unsigned long newWindowSizeWasser = windowSizeWasser; // Startwert mit aktuellem Wert
|
|
if (request->hasArg(F("windowSizeWasser"))) {
|
|
char* endptr;
|
|
// Konvertiere den String-Parameter zu unsigned long
|
|
unsigned long val = strtoul(request->arg(F("windowSizeWasser")).c_str(), &endptr, 10);
|
|
// Prüfe, ob Konvertierung erfolgreich war und Wert sinnvoll ist (z.B. >0 und nicht zu groß)
|
|
if (*endptr == '\0' && val > 0 && val <= 60000) { // Beispiel: max 60 Sekunden
|
|
newWindowSizeWasser = val;
|
|
// } else {
|
|
// Serial.printf("WARNUNG: Ungültiger Wert für windowSizeWasser empfangen: %s\n", request->arg(F("windowSizeWasser")).c_str());
|
|
}
|
|
}
|
|
// Wenn sich der Wert geändert hat:
|
|
if (newWindowSizeWasser != windowSizeWasser) {
|
|
windowSizeWasser = newWindowSizeWasser;
|
|
EEPROM.put(EEPROM_ADDR_WINDOWSIZE_WASSER, windowSizeWasser);
|
|
pidWasser.SetOutputLimits(0, windowSizeWasser); // <-- WICHTIG: PID Limit sofort aktualisieren!
|
|
valueChanged = true;
|
|
// Serial.printf("Fenstergröße Wasser aktualisiert auf: %lu ms\n", windowSizeWasser);
|
|
}
|
|
|
|
unsigned long newWindowSizeDampf = windowSizeDampf; // Startwert mit aktuellem Wert
|
|
if (request->hasArg(F("windowSizeDampf"))) {
|
|
char* endptr;
|
|
unsigned long val = strtoul(request->arg(F("windowSizeDampf")).c_str(), &endptr, 10);
|
|
if (*endptr == '\0' && val > 0 && val <= 60000) {
|
|
newWindowSizeDampf = val;
|
|
// } else {
|
|
// Serial.printf("WARNUNG: Ungültiger Wert für windowSizeDampf empfangen: %s\n", request->arg(F("windowSizeDampf")).c_str());
|
|
}
|
|
}
|
|
// Wenn sich der Wert geändert hat:
|
|
if (newWindowSizeDampf != windowSizeDampf) {
|
|
windowSizeDampf = newWindowSizeDampf;
|
|
EEPROM.put(EEPROM_ADDR_WINDOWSIZE_DAMPF, windowSizeDampf);
|
|
pidDampf.SetOutputLimits(0, windowSizeDampf); // <-- WICHTIG: PID Limit sofort aktualisieren!
|
|
valueChanged = true;
|
|
// Serial.printf("Fenstergröße Dampf aktualisiert auf: %lu ms\n", windowSizeDampf);
|
|
}
|
|
|
|
|
|
// --- EEPROM speichern und PID aktualisieren ---
|
|
if (valueChanged) { // Nur speichern, wenn sich mindestens ein Wert geändert hat
|
|
EEPROM.commit();
|
|
}
|
|
|
|
// PID-Parameter an die Regler übergeben
|
|
pidDampf.SetTunings(KpDampf, KiDampf, KdDampf);
|
|
pidWasser.SetTunings(KpWasser, KiWasser, KdWasser);
|
|
|
|
// Zurück zur Hauptseite (bereits vorhanden)
|
|
AsyncWebServerResponse *resp = request->beginResponse(303);
|
|
resp->addHeader(F("Location"), F("/PID"));
|
|
if (offsetCompensationParamsChanged) {
|
|
resetOffsetCompensationReferences();
|
|
}
|
|
request->send(resp);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler für die Firmware & Einstellungen Seite - Heap-Optimiert
|
|
************************************************************************************/
|
|
|
|
// --- PROGMEM Chunks für handleFirmware ---
|
|
static const char firmwareHtmlHead[] PROGMEM = R"rawliteral(
|
|
<!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 firmwareInfoChunkGitHub[] PROGMEM = R"rawliteral(<br>
|
|
GitHub: )rawliteral"; // Endet vor {SWGITHUB}
|
|
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ührt werden.<br>
|
|
Nach dem Update wird ein automatischer Neustart durchgefü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";
|
|
|
|
// Display-Firmware-Sektion (UART-Touch-Display / ESP32-P4). Wird dynamisch
|
|
// zusammengesetzt, damit Verbindungsstatus + gemeldeter Firmware-Stand eingeblendet
|
|
// werden koennen. Bewusst klar abgegrenzt vom OLED der Steuerung.
|
|
static const char firmwareDisplaySectionStart[] PROGMEM = R"rawliteral(
|
|
<form method='GET' action='/displayUpdate'>
|
|
<h3>UART-Touch-Display (ESP32-P4)</h3>
|
|
<p style='color:#FFCC00;'>Hinweis: Diese Optionen betreffen ausschließlich das per UART
|
|
angeschlossene <b>Touch-Display (ESP32-P4)</b> – <b>nicht</b> das OLED der Steuerung.</p>
|
|
Status: )rawliteral"; // Endet vor {STATUS}
|
|
static const char firmwareDisplaySectionFw[] PROGMEM = R"rawliteral(<br>
|
|
Firmware-Stand Display: )rawliteral"; // Endet vor {DISPLAYFW}
|
|
static const char firmwareDisplaySectionEnd[] PROGMEM = R"rawliteral(<br>
|
|
<br>
|
|
Firmware des P4-Touch-Displays über die Steuerung aktualisieren
|
|
(Übertragung per UART, dauert einige Minuten).<br>
|
|
<br>
|
|
<button type='submit'>Display-Update öffnen</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>
|
|
Profile und Statistiken / Verläufe müssen jedoch separat über den Dateimanager gesichert werden!<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 ü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ähler) auf die Standardwerte zurück.<br><br>
|
|
<b style='color: #FFCC00;'>ACHTUNG:</b><br>
|
|
Es ist zu empfehlen, vorher einen Export der Einstellungen durchzuführen.
|
|
<br>
|
|
<button type='submit' class='danger'>Werkseinstellungen laden</button>
|
|
</form>
|
|
)rawliteral";
|
|
|
|
static const char firmwareRestartSection[] PROGMEM = R"rawliteral(
|
|
<form action='/restartDevice' method='POST' onsubmit='return confirm("Soll das System wirklich neu gestartet werden?");'>
|
|
<h3>Neustart</h3>
|
|
Führt einen Neustart des Systems durch.<br>
|
|
Alle nicht gespeicherten Einstellungen gehen verloren.<br>
|
|
Die Verbindung wird kurzzeitig unterbrochen.<br>
|
|
<button type='submit' '>Neustart</button>
|
|
</form>
|
|
)rawliteral";
|
|
|
|
static const char firmwareBodyEnd[] PROGMEM = R"rawliteral(
|
|
</body>
|
|
</html>
|
|
)rawliteral";
|
|
|
|
|
|
// --- Neue Funktion handleFirmware ---
|
|
void handleFirmware(AsyncWebServerRequest *request) { // URL: /Firmware, Methode: GET
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html");
|
|
|
|
// Kopfzeile senden
|
|
response->print(FPSTR(firmwareHtmlHead)); // Enthält öffnendes <html> und <head> bis </head>
|
|
response->print(FPSTR(commonStyle));
|
|
// </head> ist im Head-Chunk enthalten
|
|
response->print(FPSTR(commonNav)); // Navigation
|
|
|
|
// Body Start und Firmware Info Sektion
|
|
response->print(FPSTR(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
|
|
response->print(version);
|
|
}
|
|
response->print(FPSTR(firmwareInfoChunk2)); // Enthält <br>Hersteller:
|
|
// Hersteller
|
|
if (versionHersteller.length() > 0) {
|
|
response->print(versionHersteller);
|
|
}
|
|
response->print(FPSTR(firmwareInfoChunk3)); // Enthält <br>E-Mail:
|
|
// Mail (ist HTML, kann direkt gesendet werden)
|
|
if (versionHerstellerMail.length() > 0) {
|
|
response->print(versionHerstellerMail);
|
|
}
|
|
response->print(FPSTR(firmwareInfoChunk4)); // Enthält <br>Website:
|
|
// Website (ist HTML, kann direkt gesendet werden)
|
|
if (versionHerstellerWeb.length() > 0) {
|
|
response->print(versionHerstellerWeb);
|
|
}
|
|
response->print(FPSTR(firmwareInfoChunkGitHub)); // Enthält <br>GitHub:
|
|
response->print(versionHerstellerGitHub);
|
|
response->print(FPSTR(firmwareInfoChunkEnd)); // Enthält <br></form>
|
|
|
|
// Firmware Update Sektion
|
|
response->print(FPSTR(firmwareUpdateSection));
|
|
|
|
// Display-Firmware-Sektion (UART-Touch-Display / P4) mit Live-Status
|
|
response->print(FPSTR(firmwareDisplaySectionStart));
|
|
if (touchUartClientActive) {
|
|
response->print(F("<b style='color:#00CC66;'>verbunden</b>"));
|
|
} else {
|
|
response->print(F("<b style='color:#AAAAAA;'>nicht verbunden</b>"));
|
|
}
|
|
response->print(FPSTR(firmwareDisplaySectionFw));
|
|
if (touchUartClientActive && touchUartClientFirmware.length() > 0) {
|
|
response->print(touchUartClientFirmware);
|
|
} else {
|
|
response->print(F("unbekannt"));
|
|
}
|
|
response->print(FPSTR(firmwareDisplaySectionEnd));
|
|
|
|
// Dateimanager Sektion
|
|
response->print(FPSTR(firmwareFileManagerSection));
|
|
|
|
// Einstellungen Export Sektion
|
|
response->print(FPSTR(firmwareExportSection));
|
|
|
|
// Einstellungen Import Sektion
|
|
response->print(FPSTR(firmwareImportSection));
|
|
|
|
// Werkseinstellungen Sektion
|
|
response->print(FPSTR(firmwareResetSection));
|
|
|
|
// Neustart Sektion hinzufügen
|
|
response->print(FPSTR(firmwareRestartSection));
|
|
|
|
// Body / HTML Ende
|
|
response->print(FPSTR(firmwareBodyEnd));
|
|
|
|
request->send(response);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler für AutTune Wasser
|
|
************************************************************************************/
|
|
// --- PROGMEM Chunks für handleAutoTuneWasser ---
|
|
static const char autotuneWasserHtmlHead[] PROGMEM = R"rawliteral(
|
|
<!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(AsyncWebServerRequest *request) {
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8");
|
|
|
|
response->print(FPSTR(autotuneWasserHtmlHead));
|
|
response->print(FPSTR(commonStyle));
|
|
response->print(FPSTR(commonNav));
|
|
response->print(FPSTR(autotuneWasserBodyStart));
|
|
|
|
// Prüfen, ob bereits ein Tuning läuft
|
|
if (!autoTuneWasserActive && !autoTuneDampfActive) {
|
|
// Wartungsmodus explizit deaktivieren
|
|
if (wartungsModusAktiv) {
|
|
wartungsModusAktiv = false;
|
|
// Standard-Temperaturwerte laden
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
}
|
|
|
|
startAutoTuneWasser();
|
|
response->print(FPSTR(autotuneWasserStartSuccess));
|
|
} else {
|
|
response->print(FPSTR(autotuneWasserAlreadyActive));
|
|
}
|
|
|
|
response->print(FPSTR(autotuneWasserBodyEnd));
|
|
request->send(response);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler für AutTune Dampf
|
|
************************************************************************************/
|
|
|
|
// --- PROGMEM Chunks für handleAutoTuneDampf ---
|
|
static const char autotuneDampfHtmlHead[] PROGMEM = R"rawliteral(
|
|
<!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(AsyncWebServerRequest *request) {
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8");
|
|
|
|
response->print(FPSTR(autotuneDampfHtmlHead)); // Eigener Head-Chunk
|
|
response->print(FPSTR(commonStyle));
|
|
response->print(FPSTR(commonNav));
|
|
response->print(FPSTR(autotuneWasserBodyStart)); // Wiederverwendet
|
|
|
|
// Prüfen, ob bereits ein Tuning läuft
|
|
if (!autoTuneWasserActive && !autoTuneDampfActive) {
|
|
// Wartungsmodus explizit deaktivieren
|
|
if (wartungsModusAktiv) {
|
|
wartungsModusAktiv = false;
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
}
|
|
|
|
startAutoTuneDampf(); // Funktion zum Starten des Tunings aufrufen
|
|
response->print(FPSTR(autotuneDampfStartSuccess)); // Eigener Success-Chunk
|
|
} else {
|
|
response->print(FPSTR(autotuneWasserAlreadyActive)); // Wiederverwendet
|
|
}
|
|
|
|
response->print(FPSTR(autotuneWasserBodyEnd)); // Wiederverwendet
|
|
request->send(response);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler zum Speichern der AutoTune-Einstellungen - ausgelagert
|
|
************************************************************************************/
|
|
void handleUpdateAutotuneSettings(AsyncWebServerRequest *request) {
|
|
bool changed = false; // Flag, um zu prüfen, ob EEPROM.commit() nötig ist
|
|
|
|
// --- Wasser Parameter ---
|
|
if (request->hasArg(F("tuningStepWasser"))) {
|
|
tuningStepWasser = request->arg(F("tuningStepWasser")).toFloat();
|
|
EEPROM.put(EEPROM_ADDR_TUNING_STEP_WASSER, tuningStepWasser);
|
|
changed = true;
|
|
}
|
|
if (request->hasArg(F("tuningNoiseWasser"))) {
|
|
tuningNoiseWasser = request->arg(F("tuningNoiseWasser")).toFloat();
|
|
EEPROM.put(EEPROM_ADDR_TUNING_NOISE_WASSER, tuningNoiseWasser);
|
|
changed = true;
|
|
}
|
|
if (request->hasArg(F("tuningStartValueWasser"))) {
|
|
tuningStartValueWasser = request->arg(F("tuningStartValueWasser")).toFloat();
|
|
EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_WASSER, tuningStartValueWasser);
|
|
changed = true;
|
|
}
|
|
if (request->hasArg(F("tuningLookBackWasser"))) {
|
|
int lookbackInt = request->arg(F("tuningLookBackWasser")).toInt();
|
|
if (lookbackInt >= 0) {
|
|
tuningLookBackWasser = (unsigned int)lookbackInt;
|
|
EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_WASSER, tuningLookBackWasser);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
// --- Dampf Parameter ---
|
|
if (request->hasArg(F("tuningStepDampf"))) {
|
|
tuningStepDampf = request->arg(F("tuningStepDampf")).toFloat();
|
|
EEPROM.put(EEPROM_ADDR_TUNING_STEP_DAMPF, tuningStepDampf);
|
|
changed = true;
|
|
}
|
|
if (request->hasArg(F("tuningNoiseDampf"))) {
|
|
tuningNoiseDampf = request->arg(F("tuningNoiseDampf")).toFloat();
|
|
EEPROM.put(EEPROM_ADDR_TUNING_NOISE_DAMPF, tuningNoiseDampf);
|
|
changed = true;
|
|
}
|
|
if (request->hasArg(F("tuningStartValueDampf"))) {
|
|
tuningStartValueDampf = request->arg(F("tuningStartValueDampf")).toFloat();
|
|
EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_DAMPF, tuningStartValueDampf);
|
|
changed = true;
|
|
}
|
|
if (request->hasArg(F("tuningLookBackDampf"))) {
|
|
int lookbackInt = request->arg(F("tuningLookBackDampf")).toInt();
|
|
if (lookbackInt >= 0) {
|
|
tuningLookBackDampf = (unsigned int)lookbackInt;
|
|
EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_DAMPF, tuningLookBackDampf);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (changed) {
|
|
EEPROM.commit();
|
|
}
|
|
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader(F("Location"), F("/PID-Tuning"));
|
|
request->send(response);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler für PID-Tuning
|
|
************************************************************************************/
|
|
|
|
// --- PROGMEM Chunks für handlePidSettingsTuning ---
|
|
static const char pidTuningPageHtmlHead[] PROGMEM = R"rawliteral(
|
|
<!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ür den PID-Regler (Proportional-, Integral- und Differentialanteil) selbständig zu ermitteln. Dabei analysiert das System mithilfe von Algorithmen das Regelverhalten (z. B. Reaktion auf einen Testimpuls) und passt Kp, Ki und Kd so an, dass gewünschte Kriterien wie kurze Einschwingzeit, geringe Überschwingung und stabile Regelung erreicht werden
|
|
<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ünschte Betriebstemperatur erreicht haben und stabil sein.<br>
|
|
Andernfalls könnten die automatisch ermittelten PID-Werte für diesen Temperaturbereich ungenau oder nicht optimal sein.
|
|
<br><br>
|
|
</form>
|
|
<form action='/AutoTune-Wasser' method='POST' >
|
|
<h3>Automatisches PID-Tuning für Wasser</h3>
|
|
Automatische Ermittlung der PID-Werte für Wasser / Kessel<br>
|
|
<input type='submit' value='Tuning starten'>
|
|
</form>
|
|
<form action='/AutoTune-Dampf' method='POST' >
|
|
<h3>Automatisches PID-Tuning für Dampf</h3>
|
|
Automatische Ermittlung der PID-Werte fü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önnen die AutoTune-Parameter geändert werden.<br>Änderungen sollten mit Vorsicht durchgefü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 (°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 (°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ärung der Parameter (Zeitproportional):</h3>
|
|
<p><strong>Noise (°C):</strong><br>
|
|
Beobachte das "Zittern" der Temperaturanzeige, wenn die Temperatur (nahe am Zielwert) stabil ist.<br>Wenn sie z.B. um +/- 0.5°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ätze, wie viel Heizzeit (in Millisekunden, innerhalb des eingestellten Zeitfensters (siehe PID-Einstellung) nö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öß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ß genug fü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ätze, wie lange eine volle Welle dauert (Spitze zu Spitze, in Sekunden).<br>Setze Lookback auf einen Wert, der *deutlich lä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ückschauen soll, um die Welle zu messen.</p>
|
|
<p><strong>Kontrolle per Chart:</strong><br>
|
|
Während AutoTune läuft, beobachte das Temperaturchart.<br>Wenn sich dort eine langsame, regelmäßige Welle (wie ein \"langgezogener Sinus\") um den Sollwert entwickelt, ist das ein gutes Zeichen, dass der Prozess funktioniert.</p>
|
|
</form>
|
|
</body>
|
|
</html>
|
|
)rawliteral";
|
|
|
|
// --- Hilfsfunktion: Status-/Ergebnis-Banner eines AutoTune-Laufs in den Web-Stream schreiben ---
|
|
// status als uint8_t (nicht AutoTuneStatus): der Arduino-Auto-Prototyp wird am Dateianfang
|
|
// erzeugt, wo der weiter unten definierte Enum-Typ noch nicht bekannt ist.
|
|
static void printAutoTuneStatusLine(AsyncResponseStream *response, const char *kreis,
|
|
uint8_t status, float kp, float ki, float kd) {
|
|
if (status == AT_IDLE) return; // noch nie gelaufen -> nichts anzeigen
|
|
|
|
const char *farbe = "#888888";
|
|
String text;
|
|
char vals[80];
|
|
snprintf(vals, sizeof(vals), " (Kp=%.2f, Ki=%.2f, Kd=%.2f)", kp, ki, kd);
|
|
|
|
switch (status) {
|
|
case AT_RUNNING:
|
|
farbe = "#FFCC00"; text = "läuft gerade ..."; break;
|
|
case AT_SUCCESS:
|
|
farbe = "#33CC66"; text = String("Erfolgreich abgeschlossen, neue Werte übernommen.") + vals; break;
|
|
case AT_FAILSAFE_PEAKS:
|
|
farbe = "#FF9900"; text = String("Beendet per Failsafe (Schwingung wurde nie stabil, >9 Peaks). Werte übernommen, aber evtl. ungenau – bitte prüfen.") + vals; break;
|
|
case AT_ABORT_DEGENERATE:
|
|
farbe = "#FF4444"; text = "Abgebrochen: keine verwertbare Schwingung (z. B. Zieltemperatur nicht erreicht oder Step zu klein). Werte NICHT geändert."; break;
|
|
case AT_ABORT_SENSOR:
|
|
farbe = "#FF4444"; text = "Abgebrochen: Temperatursensorfehler. Werte nicht geändert."; break;
|
|
case AT_ABORT_SAFETY:
|
|
farbe = "#FF4444"; text = "Abgebrochen: Sicherheitsabschaltung (Übertemperatur). Werte nicht geändert."; break;
|
|
case AT_ABORT_MANUAL:
|
|
farbe = "#AAAAAA"; text = "Manuell abgebrochen. Werte nicht geändert."; break;
|
|
default:
|
|
return;
|
|
}
|
|
response->print(F("<p style='margin:4px 0;'><b>"));
|
|
response->print(kreis);
|
|
response->print(F(":</b> <span style='color:"));
|
|
response->print(farbe);
|
|
response->print(F(";'>"));
|
|
response->print(text);
|
|
response->print(F("</span></p>"));
|
|
}
|
|
|
|
static void printAutoTuneStatusBanner(AsyncResponseStream *response) {
|
|
if (autoTuneWasserStatus == AT_IDLE && autoTuneDampfStatus == AT_IDLE) return;
|
|
response->print(F("<form><h3>Letztes Tuning-Ergebnis</h3>"));
|
|
printAutoTuneStatusLine(response, "Wasser / Kessel", autoTuneWasserStatus,
|
|
autoTuneWasserResultKp, autoTuneWasserResultKi, autoTuneWasserResultKd);
|
|
printAutoTuneStatusLine(response, "Dampf / Thermoblock", autoTuneDampfStatus,
|
|
autoTuneDampfResultKp, autoTuneDampfResultKi, autoTuneDampfResultKd);
|
|
response->print(F("</form>"));
|
|
}
|
|
|
|
// --- handlePidSettingsTuning ---
|
|
void handlePidSettingsTuning(AsyncWebServerRequest *request) {
|
|
char buffer[20]; // Puffer für Zahlenumwandlungen
|
|
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8");
|
|
|
|
response->print(FPSTR(pidTuningPageHtmlHead));
|
|
response->print(FPSTR(commonStyle));
|
|
response->print(FPSTR(commonNav));
|
|
response->print(FPSTR(pidTuningPageBodyStart));
|
|
|
|
if (autoTuneWasserActive || autoTuneDampfActive) {
|
|
response->print(FPSTR(pidTuningPageAbortForm));
|
|
}
|
|
|
|
// Status/Ergebnis des letzten (bzw. laufenden) Tunings anzeigen
|
|
printAutoTuneStatusBanner(response);
|
|
|
|
response->print(FPSTR(pidTuningPageIntro));
|
|
|
|
response->print(FPSTR(pidTuningPageParamsFormStart));
|
|
snprintf(buffer, sizeof(buffer), "%lu", windowSizeWasser);
|
|
response->print(buffer);
|
|
response->print(FPSTR(pidTuningPageParamsW1));
|
|
snprintf(buffer, sizeof(buffer), "%.2f", tuningStepWasser);
|
|
response->print(buffer);
|
|
response->print(FPSTR(pidTuningPageParamsW2));
|
|
snprintf(buffer, sizeof(buffer), "%.2f", tuningNoiseWasser);
|
|
response->print(buffer);
|
|
response->print(FPSTR(pidTuningPageParamsW3));
|
|
snprintf(buffer, sizeof(buffer), "%lu", windowSizeWasser);
|
|
response->print(buffer);
|
|
response->print(FPSTR(pidTuningPageParamsW4));
|
|
snprintf(buffer, sizeof(buffer), "%.2f", tuningStartValueWasser);
|
|
response->print(buffer);
|
|
response->print(FPSTR(pidTuningPageParamsW5));
|
|
snprintf(buffer, sizeof(buffer), "%u", tuningLookBackWasser);
|
|
response->print(buffer);
|
|
|
|
response->print(FPSTR(pidTuningPageParamsDStart));
|
|
snprintf(buffer, sizeof(buffer), "%lu", windowSizeDampf);
|
|
response->print(buffer);
|
|
response->print(FPSTR(pidTuningPageParamsD1));
|
|
snprintf(buffer, sizeof(buffer), "%.2f", tuningStepDampf);
|
|
response->print(buffer);
|
|
response->print(FPSTR(pidTuningPageParamsD2));
|
|
snprintf(buffer, sizeof(buffer), "%.2f", tuningNoiseDampf);
|
|
response->print(buffer);
|
|
response->print(FPSTR(pidTuningPageParamsD3));
|
|
snprintf(buffer, sizeof(buffer), "%lu", windowSizeDampf);
|
|
response->print(buffer);
|
|
response->print(FPSTR(pidTuningPageParamsD4));
|
|
snprintf(buffer, sizeof(buffer), "%.2f", tuningStartValueDampf);
|
|
response->print(buffer);
|
|
response->print(FPSTR(pidTuningPageParamsD5));
|
|
snprintf(buffer, sizeof(buffer), "%u", tuningLookBackDampf);
|
|
response->print(buffer);
|
|
|
|
response->print(FPSTR(pidTuningPageParamsFormEnd));
|
|
|
|
response->print(FPSTR(pidTuningPageExplanation));
|
|
|
|
request->send(response);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler für die Antwort nach Abbruch des PID-Tunings - Heap-Optimiert
|
|
************************************************************************************/
|
|
|
|
// PROGMEM Chunks für die Seite (Namen angepasst für Klarheit)
|
|
static const char abortPidTuningHtmlStart[] PROGMEM = R"rawliteral(
|
|
<!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 handlePidSettingsTuningAbbruch ---
|
|
void handlePidSettingsTuningAbbruch(AsyncWebServerRequest *request) {
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8");
|
|
|
|
response->print(FPSTR(abortPidTuningHtmlStart));
|
|
response->print(FPSTR(commonStyle));
|
|
response->print(FPSTR(abortPidTuningHtmlHeadEnd));
|
|
response->print(FPSTR(commonNav));
|
|
response->print(FPSTR(abortPidTuningHtmlBody));
|
|
|
|
request->send(response);
|
|
|
|
if (autoTuneWasserActive) autoTuneWasserStatus = AT_ABORT_MANUAL;
|
|
if (autoTuneDampfActive) autoTuneDampfStatus = AT_ABORT_MANUAL;
|
|
stopAutoTuneWasser();
|
|
stopAutoTuneDampf();
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Speichert Eco-Einstellungen - URL angepasst
|
|
************************************************************************************/
|
|
|
|
void handleEcoUpdate(AsyncWebServerRequest *request) {
|
|
if (request->hasArg(F("ecoMode"))) ecoModeMinutes = request->arg(F("ecoMode")).toInt();
|
|
if (request->hasArg(F("ecoModeTempWasser"))) ecoModeTempWasser = request->arg(F("ecoModeTempWasser")).toInt();
|
|
if (request->hasArg(F("ecoModeTempDampf"))) ecoModeTempDampf = request->arg(F("ecoModeTempDampf")).toInt();
|
|
|
|
if (request->hasArg(F("dynamicEcoMode"))) {
|
|
dynamicEcoActive = true;
|
|
} else {
|
|
dynamicEcoActive = false;
|
|
}
|
|
|
|
if (request->hasArg(F("dampfDelay"))) {
|
|
dampfVerzoegerung = request->arg(F("dampfDelay")).toInt();
|
|
}
|
|
|
|
if (request->hasArg("steamDelayOverrideBySwitch")) {
|
|
steamDelayOverrideBySwitchEnabled = true;
|
|
} else {
|
|
steamDelayOverrideBySwitchEnabled = false;
|
|
}
|
|
|
|
if (request->hasArg("ecoInfoDisplay")) {
|
|
ecoInfoOnDisplay = true;
|
|
} else {
|
|
ecoInfoOnDisplay = false;
|
|
}
|
|
|
|
if (request->hasArg("ecoLightAutoOff")) {
|
|
ecoLightAutoOffEnabled = true;
|
|
} else {
|
|
ecoLightAutoOffEnabled = false;
|
|
}
|
|
|
|
if (request->hasArg("steamHeatDisabledOnWake")) {
|
|
steamHeatDisabledOnStartupWake = true;
|
|
} else {
|
|
steamHeatDisabledOnStartupWake = false;
|
|
}
|
|
|
|
if (request->hasArg("standbyTimeDisplay")) {
|
|
standbyTimeOnDisplay = true;
|
|
} else {
|
|
standbyTimeOnDisplay = false;
|
|
}
|
|
|
|
if (request->hasArg("backlightActive")) {
|
|
int v = request->arg("backlightActive").toInt();
|
|
if (v < 5) v = 5; if (v > 100) v = 100;
|
|
backlightActivePercent = (uint8_t)v;
|
|
}
|
|
if (request->hasArg("backlightStandbyClock")) {
|
|
int v = request->arg("backlightStandbyClock").toInt();
|
|
if (v < 0) v = 0; if (v > 100) v = 100;
|
|
backlightStandbyClockPercent = (uint8_t)v;
|
|
}
|
|
|
|
EEPROM.put(EEPROM_ADDR_ECOMODE_MINUTES, ecoModeMinutes);
|
|
EEPROM.put(EEPROM_ADDR_ECOMODE_TEMP_WASSER, ecoModeTempWasser);
|
|
EEPROM.put(EEPROM_ADDR_ECOMODE_TEMP_DAMPF, ecoModeTempDampf);
|
|
EEPROM.put(EEPROM_ADDR_DYNAMIC_ECO_MODE, dynamicEcoActive);
|
|
EEPROM.put(EEPROM_ADDR_STEAM_DELAY, dampfVerzoegerung);
|
|
EEPROM.put(EEPROM_ADDR_STEAM_DELAY_OVERRIDE_SWITCH, steamDelayOverrideBySwitchEnabled);
|
|
EEPROM.put(EEPROM_ADDR_ECO_INFO_ON_DISPLAY, ecoInfoOnDisplay);
|
|
EEPROM.put(EEPROM_ADDR_ECO_LIGHT_AUTO_OFF, ecoLightAutoOffEnabled);
|
|
EEPROM.put(EEPROM_ADDR_STEAM_HEAT_DISABLED_ON_STARTUP_WAKE, steamHeatDisabledOnStartupWake);
|
|
EEPROM.put(EEPROM_ADDR_STANDBY_TIME_ON_DISPLAY, standbyTimeOnDisplay);
|
|
EEPROM.put(EEPROM_ADDR_BACKLIGHT_ACTIVE_PERCENT, backlightActivePercent);
|
|
EEPROM.put(EEPROM_ADDR_BACKLIGHT_STANDBY_CLOCK_PERCENT, backlightStandbyClockPercent);
|
|
EEPROM.commit();
|
|
|
|
// Wenn Eco deaktiviert, Setpoints zurückladen
|
|
if (ecoModeMinutes == 0 && !ecoSwitchActive) {
|
|
ecoModeAktiv = false;
|
|
ecoModeActivatedTime = 0;
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
}
|
|
|
|
AsyncWebServerResponse *resp = request->beginResponse(303);
|
|
resp->addHeader(F("Location"), F("/ECO")); // URL angepasst
|
|
request->send(resp);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Speichert Geräte-Infos
|
|
************************************************************************************/
|
|
|
|
void handleInfoUpdate(AsyncWebServerRequest *request) {
|
|
bool changed = false;
|
|
if (request->hasArg(F("hersteller"))) {
|
|
strncpy(infoHersteller, request->arg(F("hersteller")).c_str(), sizeof(infoHersteller) - 1);
|
|
infoHersteller[sizeof(infoHersteller) - 1] = '\0';
|
|
EEPROM.put(EEPROM_ADDR_INFO_HERSTELLER, infoHersteller);
|
|
changed = true;
|
|
}
|
|
if (request->hasArg(F("modell"))) {
|
|
strncpy(infoModell, request->arg(F("modell")).c_str(), sizeof(infoModell) - 1);
|
|
infoModell[sizeof(infoModell) - 1] = '\0';
|
|
EEPROM.put(EEPROM_ADDR_INFO_MODELL, infoModell);
|
|
changed = true;
|
|
}
|
|
if (request->hasArg(F("zusatz"))) {
|
|
strncpy(infoZusatz, request->arg(F("zusatz")).c_str(), sizeof(infoZusatz) - 1);
|
|
infoZusatz[sizeof(infoZusatz) - 1] = '\0';
|
|
EEPROM.put(EEPROM_ADDR_INFO_ZUSATZ, infoZusatz);
|
|
changed = true;
|
|
}
|
|
|
|
if (changed) {
|
|
EEPROM.commit();
|
|
}
|
|
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader(F("Location"), F("/Info"));
|
|
request->send(response);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Speichert NUR die Einstellung für die Systemtöne (Piezo)
|
|
************************************************************************************/
|
|
void handleUpdatePiezoSettings(AsyncWebServerRequest *request) {
|
|
bool changed = false;
|
|
// Piezo-Status speichern
|
|
bool newPiezoState = request->hasArg("piezoEnabled"); // Checkbox ist da, wenn checked
|
|
if (newPiezoState != piezoEnabled) {
|
|
piezoEnabled = newPiezoState;
|
|
EEPROM.put(EEPROM_ADDR_PIEZO_ENABLED, piezoEnabled);
|
|
changed = true;
|
|
}
|
|
|
|
if (changed) {
|
|
EEPROM.commit();
|
|
}
|
|
|
|
AsyncWebServerResponse *resp = request->beginResponse(303);
|
|
resp->addHeader("Location", "/Service");
|
|
request->send(resp);
|
|
}
|
|
|
|
struct ServiceSettingsUpdate {
|
|
bool hasPiezoEnabled = false;
|
|
bool piezoEnabled = false;
|
|
bool hasMaintenanceInterval = false;
|
|
int maintenanceInterval = 0;
|
|
bool hasFlushDurationSeconds = false;
|
|
uint8_t flushDurationSeconds = 1;
|
|
bool hasSteamFlushDurationSeconds = false;
|
|
uint8_t steamFlushDurationSeconds = 1;
|
|
bool resetMaintenanceCounter = false;
|
|
};
|
|
|
|
bool applyServiceSettingsUpdate(const ServiceSettingsUpdate& update) {
|
|
bool changed = false;
|
|
|
|
if (update.hasPiezoEnabled && update.piezoEnabled != piezoEnabled) {
|
|
piezoEnabled = update.piezoEnabled;
|
|
EEPROM.put(EEPROM_ADDR_PIEZO_ENABLED, piezoEnabled);
|
|
changed = true;
|
|
}
|
|
|
|
if (update.hasMaintenanceInterval) {
|
|
int newInterval = update.maintenanceInterval;
|
|
if (newInterval < 0) { newInterval = 0; }
|
|
if (newInterval != maintenanceInterval) {
|
|
maintenanceInterval = newInterval;
|
|
EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL, maintenanceInterval);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (update.hasFlushDurationSeconds) {
|
|
int newFlushDuration = (int)update.flushDurationSeconds;
|
|
if (newFlushDuration < 1) { newFlushDuration = 1; }
|
|
if (newFlushDuration > 30) { newFlushDuration = 30; }
|
|
if ((uint8_t)newFlushDuration != flushDurationSeconds) {
|
|
flushDurationSeconds = (uint8_t)newFlushDuration;
|
|
EEPROM.put(EEPROM_ADDR_FLUSH_DURATION_SECONDS, flushDurationSeconds);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (update.hasSteamFlushDurationSeconds) {
|
|
int newSteamFlushDuration = (int)update.steamFlushDurationSeconds;
|
|
if (newSteamFlushDuration < 1) { newSteamFlushDuration = 1; }
|
|
if (newSteamFlushDuration > 30) { newSteamFlushDuration = 30; }
|
|
if ((uint8_t)newSteamFlushDuration != steamFlushDurationSeconds) {
|
|
steamFlushDurationSeconds = (uint8_t)newSteamFlushDuration;
|
|
EEPROM.put(EEPROM_ADDR_STEAM_FLUSH_DURATION_SECONDS, steamFlushDurationSeconds);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (update.resetMaintenanceCounter) {
|
|
if (maintenanceIntervalCounter != 0) {
|
|
maintenanceIntervalCounter = 0;
|
|
EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter);
|
|
changed = true;
|
|
}
|
|
displayMaintenanceMessage = false;
|
|
}
|
|
|
|
if (changed) {
|
|
EEPROM.commit();
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Speichert NUR die Einstellungen fuer den Zusatz-NTC
|
|
************************************************************************************/
|
|
struct SensorSettingsUpdate {
|
|
bool updateCaseSection = false;
|
|
bool hasCaseEnabled = false;
|
|
bool caseEnabled = false;
|
|
bool hasCaseType = false;
|
|
uint8_t caseType = 0;
|
|
bool hasCaseOffset = false;
|
|
double caseOffset = 0.0;
|
|
bool hasCaseTempDashboard = false;
|
|
bool caseTempDashboard = false;
|
|
bool hasCaseTempDisplay = false;
|
|
bool caseTempDisplay = false;
|
|
|
|
bool updateScaleSection = false;
|
|
bool hasScaleEnabled = false;
|
|
bool scaleEnabled = false;
|
|
bool hasScaleType = false;
|
|
uint8_t scaleType = 0;
|
|
bool hasHx711Cal = false;
|
|
float hx711Cal = 0.0f;
|
|
bool hasHx711DisplaySmoothing = false;
|
|
bool hx711DisplaySmoothing = defaultHx711DisplaySmoothingEnabled;
|
|
|
|
bool updateXSwitchSection = false;
|
|
bool hasXSwitchAction = false;
|
|
uint8_t xSwitchAction = 0;
|
|
bool hasXSwitchLongAction = false;
|
|
uint8_t xSwitchLongAction = 0;
|
|
bool hasButtonLongPressMs = false;
|
|
uint16_t buttonLongPressMs = 0;
|
|
};
|
|
|
|
struct SensorSettingsApplyResult {
|
|
bool changed = false;
|
|
bool scaleSettingsChanged = false;
|
|
};
|
|
|
|
SensorSettingsApplyResult applySensorSettingsUpdate(const SensorSettingsUpdate& update) {
|
|
SensorSettingsApplyResult result;
|
|
|
|
if (update.updateCaseSection) {
|
|
if (update.hasCaseEnabled && update.caseEnabled != caseSensorEnabled) {
|
|
caseSensorEnabled = update.caseEnabled;
|
|
EEPROM.put(EEPROM_ADDR_CASE_SENSOR_ENABLED, (uint8_t)(caseSensorEnabled ? 1 : 0));
|
|
result.changed = true;
|
|
}
|
|
|
|
if (update.hasCaseType) {
|
|
uint8_t newType = update.caseType;
|
|
if (newType > CASE_SENSOR_TYPE_CUPTRAY) { newType = defaultCaseSensorType; }
|
|
if (newType != caseSensorType) {
|
|
caseSensorType = newType;
|
|
EEPROM.put(EEPROM_ADDR_CASE_SENSOR_TYPE, caseSensorType);
|
|
result.changed = true;
|
|
}
|
|
}
|
|
|
|
if (update.hasCaseOffset) {
|
|
double newCaseOffset = update.caseOffset;
|
|
if (!isfinite(newCaseOffset)) { newCaseOffset = defaultOffsetCase; }
|
|
if (fabs(newCaseOffset - OffsetCase) > 0.0001) {
|
|
OffsetCase = newCaseOffset;
|
|
EEPROM.put(EEPROM_ADDR_OFFSET_CASE, OffsetCase);
|
|
result.changed = true;
|
|
}
|
|
}
|
|
|
|
if (update.hasCaseTempDashboard && update.caseTempDashboard != caseTempOnDashboard) {
|
|
caseTempOnDashboard = update.caseTempDashboard;
|
|
EEPROM.put(EEPROM_ADDR_CASE_TEMP_DASHBOARD, (uint8_t)(caseTempOnDashboard ? 1 : 0));
|
|
result.changed = true;
|
|
}
|
|
|
|
if (update.hasCaseTempDisplay && update.caseTempDisplay != caseTempOnDisplay) {
|
|
caseTempOnDisplay = update.caseTempDisplay;
|
|
EEPROM.put(EEPROM_ADDR_CASE_TEMP_ON_DISPLAY, (uint8_t)(caseTempOnDisplay ? 1 : 0));
|
|
result.changed = true;
|
|
}
|
|
}
|
|
|
|
if (update.updateScaleSection) {
|
|
bool newScaleEnabled = scaleEnabled;
|
|
uint8_t newScaleType = scaleType;
|
|
|
|
if (update.hasScaleEnabled) { newScaleEnabled = update.scaleEnabled; }
|
|
if (update.hasScaleType) {
|
|
newScaleType = update.scaleType;
|
|
if (newScaleType > SCALE_HX711) { newScaleType = defaultScaleType; }
|
|
}
|
|
|
|
if (newScaleEnabled != scaleEnabled || newScaleType != scaleType) {
|
|
deinitScale();
|
|
|
|
if (newScaleEnabled != scaleEnabled) {
|
|
scaleEnabled = newScaleEnabled;
|
|
EEPROM.put(EEPROM_ADDR_SCALE_ENABLED, (uint8_t)(scaleEnabled ? 1 : 0));
|
|
result.changed = true;
|
|
}
|
|
if (newScaleType != scaleType) {
|
|
scaleType = newScaleType;
|
|
EEPROM.put(EEPROM_ADDR_SCALE_TYPE, scaleType);
|
|
result.changed = true;
|
|
}
|
|
result.scaleSettingsChanged = true;
|
|
}
|
|
|
|
if (update.hasHx711Cal) {
|
|
float newCal = update.hx711Cal;
|
|
if (!isfinite(newCal) || newCal == 0.0f) {
|
|
newCal = HX711_CALIBRATION_FACTOR;
|
|
}
|
|
if (fabs(newCal - hx711CalibrationFactor) > 0.0001f) {
|
|
hx711CalibrationFactor = newCal;
|
|
EEPROM.put(EEPROM_ADDR_HX711_CAL_FACTOR, hx711CalibrationFactor);
|
|
result.changed = true;
|
|
if (scaleEnabled && scaleType == SCALE_HX711) {
|
|
hx711.set_scale(hx711CalibrationFactor);
|
|
if (hx711.is_ready()) {
|
|
hx711.tare();
|
|
resetScaleReadingFilters(0.0f);
|
|
hx711LastReadyTime = millis();
|
|
hx711HasValidReading = true;
|
|
hx711NotReadyCounter = 0;
|
|
scaleConnected = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (update.hasHx711DisplaySmoothing && update.hx711DisplaySmoothing != hx711DisplaySmoothingEnabled) {
|
|
hx711DisplaySmoothingEnabled = update.hx711DisplaySmoothing;
|
|
EEPROM.put(EEPROM_ADDR_HX711_DISPLAY_SMOOTHING_ENABLED, (uint8_t)(hx711DisplaySmoothingEnabled ? 1 : 0));
|
|
resetScaleReadingFilters(currentWeightReading);
|
|
result.changed = true;
|
|
}
|
|
}
|
|
|
|
if (update.updateXSwitchSection && update.hasXSwitchAction) {
|
|
uint8_t newAction = update.xSwitchAction;
|
|
if (newAction > X_SWITCH_ACTION_MAX) { newAction = defaultXSwitchAction; }
|
|
if (newAction != xSwitchAction) {
|
|
xSwitchAction = newAction;
|
|
EEPROM.put(EEPROM_ADDR_X_SWITCH_ACTION, xSwitchAction);
|
|
result.changed = true;
|
|
}
|
|
}
|
|
|
|
if (update.updateXSwitchSection && update.hasXSwitchLongAction) {
|
|
uint8_t newLongAction = update.xSwitchLongAction;
|
|
if (newLongAction > X_SWITCH_ACTION_MAX) { newLongAction = defaultXSwitchLongAction; }
|
|
if (newLongAction != xSwitchLongAction) {
|
|
xSwitchLongAction = newLongAction;
|
|
EEPROM.put(EEPROM_ADDR_X_SWITCH_LONG_ACTION, xSwitchLongAction);
|
|
result.changed = true;
|
|
}
|
|
}
|
|
|
|
if (update.updateXSwitchSection && update.hasButtonLongPressMs) {
|
|
uint16_t newLongPressMs = update.buttonLongPressMs;
|
|
if (newLongPressMs < BUTTON_LONG_PRESS_MIN_MS) { newLongPressMs = (uint16_t)BUTTON_LONG_PRESS_MIN_MS; }
|
|
if (newLongPressMs > BUTTON_LONG_PRESS_MAX_MS) { newLongPressMs = (uint16_t)BUTTON_LONG_PRESS_MAX_MS; }
|
|
if (newLongPressMs != buttonLongPressMs) {
|
|
buttonLongPressMs = newLongPressMs;
|
|
EEPROM.put(EEPROM_ADDR_BUTTON_LONG_PRESS_MS, buttonLongPressMs);
|
|
result.changed = true;
|
|
}
|
|
}
|
|
|
|
if (result.changed) {
|
|
EEPROM.commit();
|
|
}
|
|
if (result.scaleSettingsChanged) {
|
|
initScale();
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
void handleUpdateSensorSettings(AsyncWebServerRequest *request) {
|
|
String section = request->hasArg("sensorSection") ? request->arg("sensorSection") : "";
|
|
bool isCaseSection = (section.length() == 0 || section == "case");
|
|
bool isScaleSection = (section.length() == 0 || section == "scale");
|
|
bool isXSwitchSection = (section.length() == 0 || section == "xSwitch");
|
|
|
|
SensorSettingsUpdate update;
|
|
update.updateCaseSection = isCaseSection;
|
|
update.updateScaleSection = isScaleSection;
|
|
update.updateXSwitchSection = isXSwitchSection;
|
|
|
|
if (isCaseSection) {
|
|
update.hasCaseEnabled = true;
|
|
update.caseEnabled = request->hasArg("caseSensorEnabled");
|
|
update.hasCaseType = request->hasArg("caseSensorType");
|
|
if (update.hasCaseType) { update.caseType = (uint8_t)request->arg("caseSensorType").toInt(); }
|
|
update.hasCaseOffset = request->hasArg("caseOffset");
|
|
if (update.hasCaseOffset) { update.caseOffset = request->arg("caseOffset").toDouble(); }
|
|
update.hasCaseTempDashboard = true;
|
|
update.caseTempDashboard = request->hasArg("caseTempDashboard");
|
|
update.hasCaseTempDisplay = true;
|
|
update.caseTempDisplay = request->hasArg("caseTempDisplay");
|
|
}
|
|
|
|
if (isScaleSection) {
|
|
update.hasScaleEnabled = true;
|
|
update.scaleEnabled = request->hasArg("scaleEnabled");
|
|
update.hasScaleType = request->hasArg("scaleType");
|
|
if (update.hasScaleType) { update.scaleType = (uint8_t)request->arg("scaleType").toInt(); }
|
|
update.hasHx711Cal = request->hasArg("hx711CalFactor");
|
|
if (update.hasHx711Cal) { update.hx711Cal = request->arg("hx711CalFactor").toFloat(); }
|
|
update.hasHx711DisplaySmoothing = true;
|
|
update.hx711DisplaySmoothing = request->hasArg("hx711DisplaySmoothing");
|
|
}
|
|
|
|
if (isXSwitchSection) {
|
|
update.hasXSwitchAction = request->hasArg("xSwitchAction");
|
|
if (update.hasXSwitchAction) { update.xSwitchAction = (uint8_t)request->arg("xSwitchAction").toInt(); }
|
|
update.hasXSwitchLongAction = request->hasArg("xSwitchLongAction");
|
|
if (update.hasXSwitchLongAction) { update.xSwitchLongAction = (uint8_t)request->arg("xSwitchLongAction").toInt(); }
|
|
update.hasButtonLongPressMs = request->hasArg("buttonLongPressMs");
|
|
if (update.hasButtonLongPressMs) { update.buttonLongPressMs = (uint16_t)request->arg("buttonLongPressMs").toInt(); }
|
|
}
|
|
|
|
applySensorSettingsUpdate(update);
|
|
|
|
AsyncWebServerResponse *resp = request->beginResponse(303);
|
|
resp->addHeader("Location", "/Sensoren");
|
|
request->send(resp);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Speichert NUR die Einstellung für das Wartungsintervall
|
|
************************************************************************************/
|
|
void handleUpdateIntervalSettings(AsyncWebServerRequest *request) {
|
|
bool changed = false;
|
|
// Wartungsintervall speichern
|
|
if (request->hasArg("maintenanceInterval")) {
|
|
int newInterval = request->arg("maintenanceInterval").toInt();
|
|
if (newInterval < 0) newInterval = 0; // Sicherstellen, dass nicht negativ
|
|
if (newInterval != maintenanceInterval) {
|
|
maintenanceInterval = newInterval;
|
|
EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL, maintenanceInterval);
|
|
changed = true;
|
|
}
|
|
}
|
|
if (request->hasArg("flushDurationSeconds")) {
|
|
int newFlushDuration = request->arg("flushDurationSeconds").toInt();
|
|
if (newFlushDuration < 1) newFlushDuration = 1;
|
|
if (newFlushDuration > 30) newFlushDuration = 30;
|
|
if ((uint8_t)newFlushDuration != flushDurationSeconds) {
|
|
flushDurationSeconds = (uint8_t)newFlushDuration;
|
|
EEPROM.put(EEPROM_ADDR_FLUSH_DURATION_SECONDS, flushDurationSeconds);
|
|
changed = true;
|
|
}
|
|
}
|
|
if (request->hasArg("steamFlushDurationSeconds")) {
|
|
int newSteamFlushDuration = request->arg("steamFlushDurationSeconds").toInt();
|
|
if (newSteamFlushDuration < 1) newSteamFlushDuration = 1;
|
|
if (newSteamFlushDuration > 30) newSteamFlushDuration = 30;
|
|
if ((uint8_t)newSteamFlushDuration != steamFlushDurationSeconds) {
|
|
steamFlushDurationSeconds = (uint8_t)newSteamFlushDuration;
|
|
EEPROM.put(EEPROM_ADDR_STEAM_FLUSH_DURATION_SECONDS, steamFlushDurationSeconds);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (changed) {
|
|
EEPROM.commit();
|
|
}
|
|
|
|
AsyncWebServerResponse *resp = request->beginResponse(303);
|
|
resp->addHeader("Location", "/Service");
|
|
request->send(resp);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler für JSON-Chart-Daten (/Chart-Daten) - Heap-Optimiert
|
|
************************************************************************************/
|
|
void handleChartData(AsyncWebServerRequest *request) {
|
|
AsyncResponseStream *response = request->beginResponseStream("application/json");
|
|
char buffer[20];
|
|
|
|
// Dynamische Flow-Rate (g/s) berechnen, falls ESP32 + Waage + Bezug aktiv
|
|
// Falls kein Bezug/Waage: 0.0 zurückgeben und internen Zustand zurücksetzen
|
|
float flowGs = 0.0f;
|
|
static float lastWeightForFlow = 0.0f;
|
|
static unsigned long lastFlowCalcTimeMs = 0;
|
|
static bool lastShotState = false;
|
|
static float smoothedFlow = 0.0f; // einfacher EMA-Filter
|
|
|
|
const bool mayCalcFlow = (shotActive && scaleConnected);
|
|
const unsigned long nowMs = millis();
|
|
if (mayCalcFlow) {
|
|
if (lastFlowCalcTimeMs == 0 || !lastShotState) {
|
|
// Initialisieren beim Start des Bezugs
|
|
lastFlowCalcTimeMs = nowMs;
|
|
lastWeightForFlow = currentWeightReading;
|
|
smoothedFlow = 0.0f;
|
|
flowGs = 0.0f;
|
|
} else {
|
|
unsigned long dt = nowMs - lastFlowCalcTimeMs;
|
|
if (dt > 0) {
|
|
float dWeight = currentWeightReading - lastWeightForFlow;
|
|
float instFlow = dWeight / (float(dt) / 1000.0f);
|
|
if (instFlow < 0) instFlow = 0.0f; // Unterdrücke negatives Rauschen
|
|
// Exponential Moving Average zur Glättung
|
|
const float alpha = 0.3f;
|
|
smoothedFlow = alpha * instFlow + (1.0f - alpha) * smoothedFlow;
|
|
flowGs = smoothedFlow;
|
|
lastFlowCalcTimeMs = nowMs;
|
|
lastWeightForFlow = currentWeightReading;
|
|
}
|
|
}
|
|
} else {
|
|
// Reset, damit beim nächsten Shot sauber neu gestartet wird
|
|
lastFlowCalcTimeMs = 0;
|
|
smoothedFlow = 0.0f;
|
|
flowGs = 0.0f;
|
|
}
|
|
lastShotState = shotActive;
|
|
|
|
response->print(F("{\"wasser\":"));
|
|
snprintf(buffer, sizeof(buffer), "%.1f", getDisplayedWaterTemperature());
|
|
response->print(buffer);
|
|
|
|
response->print(F(",\"setpointwasser\":"));
|
|
snprintf(buffer, sizeof(buffer), "%.1f", SetpointWasser);
|
|
response->print(buffer);
|
|
|
|
response->print(F(",\"dampf\":"));
|
|
snprintf(buffer, sizeof(buffer), "%.1f", getDisplayedSteamTemperature());
|
|
response->print(buffer);
|
|
|
|
response->print(F(",\"setpointdampf\":"));
|
|
snprintf(buffer, sizeof(buffer), "%.1f", SetpointDampf);
|
|
response->print(buffer);
|
|
|
|
response->print(F(",\"caseEnabled\":"));
|
|
response->print(caseSensorEnabled ? F("true") : F("false"));
|
|
response->print(F(",\"caseType\":"));
|
|
response->print(caseSensorType);
|
|
response->print(F(",\"caseTempDisplaySetting\":"));
|
|
response->print(caseTempOnDisplay ? F("true") : F("false"));
|
|
response->print(F(",\"caseTemp\":"));
|
|
if (!caseSensorEnabled || caseSensorError || isnan(InputCase)) {
|
|
response->print(F("null"));
|
|
} else {
|
|
snprintf(buffer, sizeof(buffer), "%.1f", InputCase);
|
|
response->print(buffer);
|
|
}
|
|
|
|
// Flow-Rate immer mitsenden (ESP32: berechnet, sonst 0.0) -> Null-Linie bei nicht aktivem Bezug/Waage
|
|
response->print(F(",\"flow\":"));
|
|
snprintf(buffer, sizeof(buffer), "%.2f", flowGs);
|
|
response->print(buffer);
|
|
|
|
response->print(F("}"));
|
|
request->send(response);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler für Temperatur-Charts (Live) - URL angepasst (/Chart)
|
|
* Lädt chart.js bevorzugt aus LittleFS, sonst CDN, sonst Fehler.
|
|
* Auswahl für maximale Datenpunkte hinzugefügt.
|
|
* Vollbild-Funktion hinzugefügt (Layout korrigiert: Label über Select).
|
|
************************************************************************************/
|
|
|
|
// --- PROGMEM Chunks für die Chart-Seite ---
|
|
|
|
// Teil 1: HTML-Start, Meta-Tags
|
|
static const char chartsHtml_HeadStart[] PROGMEM = R"rawliteral(
|
|
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<title>Chart</title>
|
|
<meta charset="UTF-8">
|
|
<meta name='theme-color' content='#111111'>
|
|
<meta name='color-scheme' content='dark'>
|
|
<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 {
|
|
}
|
|
/* 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); }
|
|
|
|
/* Icon-Button Overrides */
|
|
#chart-fullscreen-btn { width: 38px; height: 38px; padding: 6px; min-width: 38px; display: inline-flex; align-items: center; justify-content: center; }
|
|
#chart-fullscreen-btn svg { width: 20px; height: 20px; display: block; }
|
|
|
|
/* 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 {}
|
|
.chart-container { width: 98%; padding: 10px; height: 65vh; }
|
|
/* Zwei Selects nebeneinander + Icon rechts in einer Zeile */
|
|
.chart-controls-container { flex-direction: row; flex-wrap: nowrap; gap: 10px; align-items: flex-end; justify-content: flex-start; }
|
|
.chart-controls-container > div { flex: 0 1 calc((100% - 38px - 20px) / 2); min-width: 0; max-width: none; text-align: left; }
|
|
.chart-controls-container label { margin-bottom: 4px; }
|
|
.chart-controls-container select { width: 100%; min-width: 0; }
|
|
#chart-fullscreen-btn { margin-top: 0; margin-left: 0; width: 38px; max-width: 38px; flex: 0 0 38px; }
|
|
.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; }
|
|
}
|
|
</style>
|
|
)rawliteral";
|
|
|
|
// Teil 2: Schließendes Head-Tag
|
|
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" aria-label="Vollbild" title="Vollbild" type="button">
|
|
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
|
|
<path d="M7 3H3v4h2V5h2V3zm14 0h-4v2h2v2h2V3zM5 17H3v4h4v-2H5v-2zm16 0h-2v2h-2v2h4v-4z"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="toggle-switch-container" id="caseSensorChartToggleContainer" style="max-width: 340px; margin: 0 auto 10px auto; display: none;">
|
|
<span class="toggle-switch-label-text" id="caseSensorChartLabel">Zusatzsensor anzeigen:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="caseSensorChartToggle" value="1">
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</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 caseColor = '#8BC34A';
|
|
const setpointWasserColor = 'rgba(0, 174, 239, 0.7)';
|
|
const setpointDampfColor = 'rgba(247, 148, 29, 0.7)';
|
|
const flowColor = '#46C37B';
|
|
const gridColor = 'rgba(255, 255, 255, 0.1)';
|
|
const textColor = '#E0E0E0';
|
|
let maxDataPoints = parseInt(document.getElementById('datapointLimit').value, 10) || 100;
|
|
const combinedChart = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: [],
|
|
datasets: [
|
|
{ label: 'Wasser IST', data: [], borderColor: wasserColor, backgroundColor: 'rgba(0, 174, 239, 0.1)', borderWidth: 2, fill: 'start', tension: 0.4, pointRadius: 0, pointHoverRadius: 6, pointHitRadius: 10, pointHoverBackgroundColor: wasserColor },
|
|
{ label: 'Dampf IST', data: [], borderColor: dampfColor, backgroundColor: 'rgba(247, 148, 29, 0.1)', borderWidth: 2, fill: 'start', tension: 0.4, pointRadius: 0, pointHoverRadius: 6, pointHitRadius: 10, pointHoverBackgroundColor: dampfColor },
|
|
{ label: 'Zusatzsensor IST', data: [], borderColor: caseColor, backgroundColor: 'rgba(139, 195, 74, 0.12)', borderWidth: 2, fill: 'start', tension: 0.35, pointRadius: 0, pointHoverRadius: 6, pointHitRadius: 10, pointHoverBackgroundColor: caseColor },
|
|
{ label: 'Wasser SOLL', data: [], borderColor: setpointWasserColor, borderWidth: 1.5, borderDash: [6, 3], fill: false, tension: 0.1, pointRadius: 0, pointHoverRadius: 0 },
|
|
{ label: 'Dampf SOLL', data: [], borderColor: setpointDampfColor, borderWidth: 1.5, borderDash: [6, 3], fill: false, tension: 0.1, pointRadius: 0, pointHoverRadius: 0 },
|
|
{ label: 'Flow (g/s)', data: [], borderColor: flowColor, backgroundColor: 'rgba(70, 195, 123, 0.15)', borderWidth: 2, fill: false, tension: 0.25, pointRadius: 0, yAxisID: 'y2' }
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
animation: false,
|
|
interaction: { intersect: false, mode: 'index' },
|
|
scales: {
|
|
x: { type: 'category', title: { display: true, text: 'Uhrzeit', color: textColor, font: { size: 13, weight: '300' } }, ticks: { color: textColor, font: { size: 11 }, maxRotation: 0, autoSkip: true, maxTicksLimit: 8 }, grid: { color: gridColor, drawBorder: false } },
|
|
y: { suggestedMin: 0, suggestedMax: 180, title: { display: true, text: 'Temperatur (°C)', color: textColor, font: { size: 13, weight: '300' } }, ticks: { color: textColor, font: { size: 11 }, stepSize: 20, callback: function(value) { return value + ' °C'; } }, grid: { color: gridColor, drawBorder: false } },
|
|
y2: { position: 'right', suggestedMin: 0, suggestedMax: 6, title: { display: true, text: 'Flow (g/s)', color: textColor, font: { size: 13, weight: '300' } }, ticks: { color: textColor, font: { size: 11 } }, grid: { drawOnChartArea: false } }
|
|
},
|
|
plugins: {
|
|
legend: { position: 'bottom', align: 'center', labels: { color: textColor, font: { size: 12 }, usePointStyle: true, pointStyle: 'rectRounded', boxWidth: 15, padding: 15 } },
|
|
tooltip: {
|
|
enabled: true, mode: 'index', intersect: false,
|
|
backgroundColor: 'rgba(0, 0, 0, 0.85)', titleColor: '#ffffff', titleFont: { size: 13, weight: 'bold' },
|
|
bodyColor: '#dddddd', bodyFont: { size: 12 }, bodySpacing: 4,
|
|
borderColor: 'rgba(255, 255, 255, 0.1)', borderWidth: 1, padding: 10, cornerRadius: 8, displayColors: true, boxPadding: 4,
|
|
callbacks: {
|
|
label: function(context) {
|
|
let label = context.dataset.label || '';
|
|
if (label) { label += ': '; }
|
|
if (context.parsed.y !== null) {
|
|
const isFlow = (context.dataset.yAxisID === 'y2') || /Flow/.test(context.dataset.label||'');
|
|
label += context.parsed.y.toFixed(2) + (isFlow ? ' g/s' : ' °C');
|
|
}
|
|
return label;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
let updateInterval = parseInt(document.getElementById('updateRate').value, 10) || 15000; let intervalId = null;
|
|
const caseToggleContainer = document.getElementById('caseSensorChartToggleContainer');
|
|
const caseToggle = document.getElementById('caseSensorChartToggle');
|
|
const caseToggleLabel = document.getElementById('caseSensorChartLabel');
|
|
const caseTypeLabels = { 0: 'Gehäuse', 1: 'Tassenablage' };
|
|
let currentCaseType = 0;
|
|
let caseSensorEnabled = false;
|
|
combinedChart.data.datasets[2].hidden = true;
|
|
if (caseToggleContainer) { caseToggleContainer.style.display = 'none'; }
|
|
function fetchData() {
|
|
fetch('/Chart-Daten').then(response => {
|
|
if (!response.ok) { console.error('Netzwerkantwort war nicht ok:', response.statusText); throw new Error('Network response was not ok'); }
|
|
return response.json();
|
|
}).then(data => {
|
|
if (data.setpointwasser !== undefined) currentSetpointWasser = data.setpointwasser;
|
|
if (data.setpointdampf !== undefined) currentSetpointDampf = data.setpointdampf;
|
|
|
|
const labels = combinedChart.data.labels;
|
|
const datasets = combinedChart.data.datasets;
|
|
|
|
if (typeof data.caseEnabled === 'boolean') { caseSensorEnabled = data.caseEnabled; }
|
|
if (data.caseType !== undefined && !isNaN(data.caseType)) { currentCaseType = data.caseType; }
|
|
const caseLabel = caseTypeLabels[currentCaseType] || 'Zusatzsensor';
|
|
datasets[2].label = caseLabel + ' IST';
|
|
if (caseToggleLabel) { caseToggleLabel.textContent = caseLabel + ' anzeigen:'; }
|
|
if (caseToggleContainer) { caseToggleContainer.style.display = caseSensorEnabled ? '' : 'none'; }
|
|
if (caseToggle) {
|
|
if (!caseSensorEnabled) { caseToggle.checked = false; }
|
|
datasets[2].hidden = !caseSensorEnabled || !caseToggle.checked;
|
|
}
|
|
|
|
const now = new Date();
|
|
const timeLabel = now.toLocaleTimeString('de-DE');
|
|
labels.push(timeLabel);
|
|
datasets[0].data.push(data.wasser);
|
|
datasets[1].data.push(data.dampf);
|
|
const caseValue = (caseSensorEnabled && caseToggle && caseToggle.checked) ? data.caseTemp : null;
|
|
datasets[2].data.push((caseValue === undefined) ? null : caseValue);
|
|
datasets[3].data.push(currentSetpointWasser);
|
|
datasets[4].data.push(currentSetpointDampf);
|
|
datasets[5].data.push((typeof data.flow === 'number') ? data.flow : 0.0);
|
|
while (labels.length > maxDataPoints) {
|
|
labels.shift();
|
|
datasets.forEach(dataset => { dataset.data.shift(); });
|
|
}
|
|
combinedChart.update('none');
|
|
}).catch(err => { console.error("Fehler beim Abrufen der Daten:", err); });
|
|
}
|
|
function startFetching() { if (intervalId) { clearInterval(intervalId); intervalId = null; } fetchData(); intervalId = setInterval(fetchData, updateInterval); console.log(`Chart-Aktualisierung gestartet: Intervall ${updateInterval / 1000} Sekunden.`); }
|
|
document.getElementById('updateRate').addEventListener('change', function(event) { const newInterval = parseInt(event.target.value, 10); if (!isNaN(newInterval) && newInterval > 0) { updateInterval = newInterval; startFetching(); } });
|
|
document.getElementById('datapointLimit').addEventListener('change', function(event) { const newLimit = parseInt(event.target.value, 10); if (!isNaN(newLimit) && newLimit > 0) { maxDataPoints = newLimit; console.log(`Maximale Datenpunkte geändert auf: ${maxDataPoints}`); } });
|
|
if (caseToggle) {
|
|
caseToggle.addEventListener('change', function() {
|
|
combinedChart.data.datasets[2].hidden = !caseToggle.checked;
|
|
combinedChart.update('none');
|
|
});
|
|
}
|
|
const fullscreenBtn = document.getElementById('chart-fullscreen-btn');
|
|
const bodyElement = document.body;
|
|
const enterFsIcon = `
|
|
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
|
|
<path d="M7 3H3v4h2V5h2V3zm14 0h-4v2h2v2h2V3zM5 17H3v4h4v-2H5v-2zm16 0h-2v2h-2v2h4v-4z"/>
|
|
</svg>`;
|
|
const exitFsIcon = `
|
|
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
|
|
<path d="M3 7h4V5H5V3H3v4zm18-4h-4v2h2v2h2V3zM3 21h4v-2H5v-2H3v4zm18-4h-2v2h-2v2h4v-4z"/>
|
|
</svg>`;
|
|
fullscreenBtn.innerHTML = enterFsIcon;
|
|
fullscreenBtn.addEventListener('click', () => {
|
|
bodyElement.classList.toggle('chart-fullscreen-active');
|
|
const isFs = bodyElement.classList.contains('chart-fullscreen-active');
|
|
fullscreenBtn.innerHTML = isFs ? exitFsIcon : enterFsIcon;
|
|
fullscreenBtn.setAttribute('aria-label', isFs ? 'Vollbild verlassen' : 'Vollbild');
|
|
fullscreenBtn.setAttribute('title', isFs ? 'Vollbild verlassen' : 'Vollbild');
|
|
setTimeout(() => { if (typeof combinedChart !== 'undefined' && combinedChart) { combinedChart.resize(); } }, 50);
|
|
});
|
|
startFetching();
|
|
)rawliteral";
|
|
|
|
// Teil 5: Chunk für den Fehlerfall
|
|
static const char chartsHtml_ErrorBox[] PROGMEM = R"rawliteral(
|
|
<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
|
|
static const char chartsHtml_BodyEnd[] PROGMEM = R"rawliteral(
|
|
</body>
|
|
</html>
|
|
)rawliteral";
|
|
|
|
|
|
// --- handleCharts Funktion ---
|
|
void handleCharts(AsyncWebServerRequest *request) // URL: /Chart
|
|
{
|
|
bool chartJsLocal = LittleFS.exists("/chart.umd.min.js");
|
|
bool isConnected = (WiFi.status() == WL_CONNECTED);
|
|
bool canLoadChartJs = false;
|
|
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8");
|
|
|
|
response->print(FPSTR(chartsHtml_HeadStart));
|
|
response->print(FPSTR(commonStyle));
|
|
response->print(FPSTR(chartsHtml_SpecificStyles)); // CSS mit korrektem Normal-Layout
|
|
|
|
if (chartJsLocal) {
|
|
File chartFile = LittleFS.open("/chart.umd.min.js", "r");
|
|
if (chartFile) {
|
|
canLoadChartJs = true;
|
|
response->print(F("<script>\n"));
|
|
uint8_t buf[256];
|
|
while (chartFile.available()) {
|
|
size_t len = chartFile.read(buf, sizeof(buf));
|
|
response->write(buf, len);
|
|
}
|
|
chartFile.close();
|
|
response->print(F("\n</script>\n"));
|
|
yield();
|
|
}
|
|
} else if (isConnected) {
|
|
response->print(F("<script src=\"https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js\"></script>\n"));
|
|
canLoadChartJs = true;
|
|
yield();
|
|
}
|
|
response->print(FPSTR(chartsHtml_HeadEnd));
|
|
|
|
response->print("<body>\n");
|
|
response->print(FPSTR(commonNav));
|
|
response->print("<main class=\"chart-page-content\">\n");
|
|
|
|
response->print("<h1 class=\"chart-title\">Chart</h1>\n");
|
|
|
|
if (canLoadChartJs) {
|
|
response->print(FPSTR(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ügbar! Bitte prüfen!</div>"; }
|
|
else if (wasserSensorError) { errorMessageHtml = "<div class=\"sensor-error-message\">Temperaturwert Wasser nicht verfügbar</div>"; }
|
|
else if (dampfSensorError) { errorMessageHtml = "<div class=\"sensor-error-message\">Temperaturwert Dampf nicht verfügbar</div>"; }
|
|
else if (caseSensorEnabled && caseSensorError) {
|
|
const char* caseLabel = (caseSensorType == CASE_SENSOR_TYPE_CUPTRAY) ? "Tassenablage" : "Gehäuse";
|
|
errorMessageHtml = "<div class=\"sensor-error-message\">Temperaturwert " + String(caseLabel) + " nicht verfügbar</div>";
|
|
}
|
|
if (errorMessageHtml.length() > 0) { response->print(errorMessageHtml); }
|
|
yield();
|
|
|
|
response->print("<div class=\"chart-container\">\n");
|
|
response->print("<canvas id=\"combinedChart\"></canvas>\n");
|
|
response->print("</div>\n");
|
|
yield();
|
|
|
|
response->print("<script>\n");
|
|
response->print(FPSTR(chartsHtml_ChartJSCode));
|
|
response->print("\n</script>\n");
|
|
yield();
|
|
|
|
} else {
|
|
response->print(FPSTR(chartsHtml_ErrorBox));
|
|
yield();
|
|
}
|
|
|
|
response->print("</main>\n");
|
|
response->print(FPSTR(chartsHtml_BodyEnd));
|
|
|
|
request->send(response);
|
|
} // Ende handleCharts()
|
|
|
|
/************************************************************************************
|
|
* Hilfsfunktionen für Profile
|
|
************************************************************************************/
|
|
|
|
// Hilfsfunktion zum Bereinigen von Profilnamen für Dateinamen
|
|
String sanitizeProfileName(String profileName) {
|
|
profileName.trim(); // Leerzeichen am Anfang/Ende entfernen
|
|
if (profileName.length() == 0) {
|
|
profileName = "Unbenannt";
|
|
}
|
|
|
|
// FFat/FATFS kommt mit ASCII-Dateinamen am zuverlässigsten zurecht.
|
|
profileName.replace("Ä", "Ae");
|
|
profileName.replace("Ö", "Oe");
|
|
profileName.replace("Ü", "Ue");
|
|
profileName.replace("ä", "ae");
|
|
profileName.replace("ö", "oe");
|
|
profileName.replace("ü", "ue");
|
|
profileName.replace("ß", "ss");
|
|
|
|
String sanitizedName;
|
|
sanitizedName.reserve(profileName.length());
|
|
bool lastWasUnderscore = false;
|
|
|
|
for (size_t i = 0; i < profileName.length(); ++i) {
|
|
char c = profileName[i];
|
|
bool isAsciiLetter = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
|
|
bool isAsciiDigit = (c >= '0' && c <= '9');
|
|
bool keepChar = isAsciiLetter || isAsciiDigit || c == '-' || c == '_';
|
|
|
|
if (keepChar) {
|
|
sanitizedName += c;
|
|
lastWasUnderscore = false;
|
|
} else if (!lastWasUnderscore) {
|
|
sanitizedName += '_';
|
|
lastWasUnderscore = true;
|
|
}
|
|
}
|
|
|
|
while (sanitizedName.startsWith("_")) {
|
|
sanitizedName.remove(0, 1);
|
|
}
|
|
while (sanitizedName.endsWith("_")) {
|
|
sanitizedName.remove(sanitizedName.length() - 1);
|
|
}
|
|
|
|
if (sanitizedName.length() == 0) {
|
|
sanitizedName = "Unbenannt";
|
|
}
|
|
|
|
// Maximale Länge begrenzen (z.B. 30 Zeichen + .txt)
|
|
if (sanitizedName.length() > 30) {
|
|
sanitizedName = sanitizedName.substring(0, 30);
|
|
}
|
|
return sanitizedName;
|
|
}
|
|
|
|
String normalizeProfileDisplayName(String profileName) {
|
|
profileName.trim();
|
|
if (profileName.length() == 0) {
|
|
profileName = "Unbenannt";
|
|
}
|
|
if (profileName.length() > 30) {
|
|
profileName = profileName.substring(0, 30);
|
|
}
|
|
return profileName;
|
|
}
|
|
|
|
String htmlEscape(const String& input) {
|
|
String escaped = input;
|
|
escaped.replace("&", "&");
|
|
escaped.replace("\"", """);
|
|
escaped.replace("'", "'");
|
|
escaped.replace("<", "<");
|
|
escaped.replace(">", ">");
|
|
return escaped;
|
|
}
|
|
|
|
String jsSingleQuoteEscape(const String& input) {
|
|
String escaped = input;
|
|
escaped.replace("\\", "\\\\");
|
|
escaped.replace("'", "\\'");
|
|
escaped.replace("\r", "");
|
|
escaped.replace("\n", "\\n");
|
|
return escaped;
|
|
}
|
|
|
|
static String getStandbyTimerCurrentTimeLabel() {
|
|
if (!timeSynced) {
|
|
return "NTP-Zeit nicht synchronisiert";
|
|
}
|
|
|
|
time_t now_t;
|
|
time(&now_t);
|
|
struct tm localTime;
|
|
localtime_r(&now_t, &localTime);
|
|
if (localTime.tm_year < (2024 - 1900)) {
|
|
return "NTP-Zeit nicht synchronisiert";
|
|
}
|
|
|
|
char buffer[24];
|
|
snprintf(buffer, sizeof(buffer), "%02d.%02d.%04d %02d:%02d",
|
|
localTime.tm_mday, localTime.tm_mon + 1, localTime.tm_year + 1900,
|
|
localTime.tm_hour, localTime.tm_min);
|
|
return String(buffer);
|
|
}
|
|
|
|
static bool parseStandbyTimerTimeValue(const String& timeValue, uint8_t& hour, uint8_t& minute) {
|
|
const int colonPos = timeValue.indexOf(':');
|
|
if (colonPos < 1) {
|
|
return false;
|
|
}
|
|
|
|
int parsedHour = timeValue.substring(0, colonPos).toInt();
|
|
int parsedMinute = timeValue.substring(colonPos + 1).toInt();
|
|
if (parsedHour < 0 || parsedHour > 23 || parsedMinute < 0 || parsedMinute > 59) {
|
|
return false;
|
|
}
|
|
|
|
hour = (uint8_t)parsedHour;
|
|
minute = (uint8_t)parsedMinute;
|
|
return true;
|
|
}
|
|
|
|
static uint8_t readStandbyTimerWeekdaysFromRequest(AsyncWebServerRequest *request) {
|
|
uint8_t weekdaysMask = 0;
|
|
for (uint8_t i = 0; i < 7; ++i) {
|
|
String argName = "wd";
|
|
argName += String(i);
|
|
if (request->hasArg(argName)) {
|
|
weekdaysMask |= (uint8_t)(1U << i);
|
|
}
|
|
}
|
|
return weekdaysMask;
|
|
}
|
|
|
|
static void appendStandbyTimerWeekdayInputs(AsyncResponseStream *response, uint8_t weekdaysMask) {
|
|
static const char* labels[7] = {"Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"};
|
|
for (uint8_t i = 0; i < 7; ++i) {
|
|
String item = "<label class='weekday-chip'><input type='checkbox' name='wd";
|
|
item += String(i);
|
|
item += "'";
|
|
if ((weekdaysMask & (1U << i)) != 0) {
|
|
item += " checked";
|
|
}
|
|
item += "><span>";
|
|
item += labels[i];
|
|
item += "</span></label>";
|
|
response->print(item);
|
|
}
|
|
}
|
|
|
|
static void redirectToTimerPage(AsyncWebServerRequest *request) {
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader("Location", "/Timer");
|
|
request->send(response);
|
|
}
|
|
|
|
static const char timerPageHead[] PROGMEM = R"rawliteral(
|
|
<!DOCTYPE html><html lang='de'><head><meta charset='UTF-8'><title>Timer</title>
|
|
)rawliteral";
|
|
|
|
static const char timerPageStyle[] PROGMEM = R"rawliteral(
|
|
<style>
|
|
.timer-page {
|
|
max-width: 1100px;
|
|
margin: 0 auto;
|
|
padding-bottom: 30px;
|
|
}
|
|
.timer-note,
|
|
.timer-status {
|
|
max-width: 960px;
|
|
margin: 15px auto;
|
|
padding: 14px 16px;
|
|
border-radius: 10px;
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
background: rgba(255, 255, 255, 0.06);
|
|
}
|
|
.timer-note.warning,
|
|
.timer-status.status-error {
|
|
border-color: rgba(220, 53, 69, 0.35);
|
|
background: rgba(220, 53, 69, 0.14);
|
|
}
|
|
.timer-note.success,
|
|
.timer-status.status-success {
|
|
border-color: rgba(40, 167, 69, 0.35);
|
|
background: rgba(40, 167, 69, 0.14);
|
|
}
|
|
.timer-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
|
gap: 20px;
|
|
max-width: 1100px;
|
|
margin: 20px auto 0;
|
|
}
|
|
.timer-card {
|
|
background: rgba(255, 255, 255, 0.05);
|
|
border: 1px solid rgba(255, 255, 255, 0.10);
|
|
border-radius: 16px;
|
|
padding: 18px;
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.22);
|
|
}
|
|
.timer-card h2,
|
|
.timer-card h3 {
|
|
margin-top: 0;
|
|
margin-bottom: 8px;
|
|
}
|
|
.timer-card .summary {
|
|
color: #d5d5d5;
|
|
font-size: 0.92em;
|
|
margin-bottom: 14px;
|
|
}
|
|
.timer-row {
|
|
margin-bottom: 14px;
|
|
}
|
|
.timer-row.split {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 12px;
|
|
}
|
|
.timer-row label {
|
|
display: block;
|
|
font-weight: 600;
|
|
margin-bottom: 6px;
|
|
}
|
|
.timer-row input[type='time'],
|
|
.timer-row select {
|
|
width: 100%;
|
|
max-width: none;
|
|
box-sizing: border-box;
|
|
}
|
|
.inline-toggle {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
}
|
|
.inline-toggle input[type='checkbox'] {
|
|
width: 18px;
|
|
height: 18px;
|
|
}
|
|
.weekday-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
.weekday-chip {
|
|
position: relative;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
}
|
|
.weekday-chip input {
|
|
position: absolute;
|
|
opacity: 0;
|
|
inset: 0;
|
|
cursor: pointer;
|
|
}
|
|
.weekday-chip span {
|
|
display: inline-block;
|
|
min-width: 38px;
|
|
text-align: center;
|
|
padding: 8px 10px;
|
|
border-radius: 999px;
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
background: rgba(0, 0, 0, 0.28);
|
|
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
|
}
|
|
.weekday-chip input:checked + span {
|
|
background: var(--accent-color);
|
|
color: #000000;
|
|
border-color: var(--accent-color);
|
|
}
|
|
.timer-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
margin-top: 8px;
|
|
}
|
|
.timer-actions button,
|
|
.timer-delete-form button {
|
|
width: auto;
|
|
min-width: 160px;
|
|
}
|
|
.timer-delete-form {
|
|
margin-top: 10px;
|
|
}
|
|
.secondary-button {
|
|
background: rgba(255, 255, 255, 0.10);
|
|
color: var(--text-color);
|
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
|
}
|
|
.danger-button {
|
|
background: #b23a48;
|
|
color: #ffffff;
|
|
}
|
|
.timer-meta {
|
|
font-size: 0.9em;
|
|
color: #d0d0d0;
|
|
margin-top: 4px;
|
|
}
|
|
.empty-state {
|
|
text-align: center;
|
|
color: #d0d0d0;
|
|
font-style: italic;
|
|
padding: 10px 0 0;
|
|
}
|
|
@media (max-width: 700px) {
|
|
.timer-row.split {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.timer-actions button,
|
|
.timer-delete-form button {
|
|
width: 100%;
|
|
}
|
|
}
|
|
</style></head>
|
|
)rawliteral";
|
|
|
|
void handleTimerPage(AsyncWebServerRequest *request) {
|
|
sortStandbyTimers();
|
|
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8");
|
|
response->print(FPSTR(timerPageHead));
|
|
response->print(FPSTR(commonStyle));
|
|
response->print(FPSTR(timerPageStyle));
|
|
response->print(F("<body>"));
|
|
response->print(FPSTR(commonNav));
|
|
response->print(F("<div class='timer-page'><h1>Timer</h1>"));
|
|
|
|
if (standbyTimerStatusMessage.length() > 0) {
|
|
String statusClass = standbyTimerStatusMessage.startsWith("FEHLER") ? "status-error" : "status-success";
|
|
response->print("<div class='timer-status ");
|
|
response->print(statusClass);
|
|
response->print("'>");
|
|
response->print(standbyTimerStatusMessage);
|
|
response->print(F("</div>"));
|
|
standbyTimerStatusMessage = "";
|
|
}
|
|
|
|
response->print(F("<div class='timer-note"));
|
|
response->print(timeSynced ? F(" success") : F(" warning"));
|
|
response->print(F("'><strong>NTP-Zeit:</strong> "));
|
|
response->print(htmlEscape(getStandbyTimerCurrentTimeLabel()));
|
|
response->print(F("<br>Timer steuern den Software-Standby und werden nur ausgeführt, wenn eine gültige NTP-Zeit synchronisiert ist.</div>"));
|
|
|
|
response->print(F("<div class='timer-grid'>"));
|
|
|
|
if (standbyTimers.empty()) {
|
|
response->print(F("<div class='timer-card'><h2>Gespeicherte Timer</h2><div class='empty-state'>Noch keine Timer angelegt.</div></div>"));
|
|
} else {
|
|
for (size_t i = 0; i < standbyTimers.size(); ++i) {
|
|
const StandbyTimerEntry& timer = standbyTimers[i];
|
|
char timeBuffer[6];
|
|
snprintf(timeBuffer, sizeof(timeBuffer), "%02u:%02u", timer.hour, timer.minute);
|
|
|
|
response->print(F("<div class='timer-card'>"));
|
|
response->print(F("<h3>Timer "));
|
|
response->print((int)(i + 1));
|
|
response->print(F("</h3><div class='summary'>"));
|
|
response->print(htmlEscape(standbyTimerActionLabel(timer.action)));
|
|
response->print(F(" um "));
|
|
response->print(timeBuffer);
|
|
response->print(F(" Uhr"));
|
|
if (!timer.enabled) {
|
|
response->print(F(" · aktuell deaktiviert"));
|
|
}
|
|
response->print(F("</div>"));
|
|
|
|
response->print(F("<form action='/saveStandbyTimer' method='POST'>"));
|
|
response->print(F("<input type='hidden' name='timerId' value='"));
|
|
response->print((unsigned long)timer.id);
|
|
response->print(F("'>"));
|
|
|
|
response->print(F("<div class='timer-row split'>"));
|
|
response->print(F("<div><label for='timer_time_"));
|
|
response->print((unsigned long)timer.id);
|
|
response->print(F("'>Uhrzeit</label><input type='time' id='timer_time_"));
|
|
response->print((unsigned long)timer.id);
|
|
response->print(F("' name='time' value='"));
|
|
response->print(timeBuffer);
|
|
response->print(F("' required></div>"));
|
|
|
|
response->print(F("<div><label for='timer_action_"));
|
|
response->print((unsigned long)timer.id);
|
|
response->print(F("'>Aktion</label><select id='timer_action_"));
|
|
response->print((unsigned long)timer.id);
|
|
response->print(F("' name='action'>"));
|
|
response->print(F("<option value='0'"));
|
|
if (timer.action == 0) { response->print(F(" selected")); }
|
|
response->print(F(">Standby aktivieren</option>"));
|
|
response->print(F("<option value='1'"));
|
|
if (timer.action == 1) { response->print(F(" selected")); }
|
|
response->print(F(">Standby aufheben</option></select></div></div>"));
|
|
|
|
response->print(F("<div class='timer-row'><label class='inline-toggle'><input type='checkbox' name='enabled'"));
|
|
if (timer.enabled) { response->print(F(" checked")); }
|
|
response->print(F(">Timer aktiv</label><div class='timer-meta'>"));
|
|
response->print(htmlEscape(standbyTimerWeekdaysLabel(timer.weekdaysMask)));
|
|
response->print(F("</div></div>"));
|
|
|
|
response->print(F("<div class='timer-row'><label>Wochentage</label><div class='weekday-row'>"));
|
|
appendStandbyTimerWeekdayInputs(response, timer.weekdaysMask);
|
|
response->print(F("</div></div>"));
|
|
|
|
response->print(F("<div class='timer-actions'><button type='submit'>Timer speichern</button></div></form>"));
|
|
response->print(F("<form action='/deleteStandbyTimer' method='POST' class='timer-delete-form' onsubmit=\"return confirm('Timer wirklich entfernen?');\">"));
|
|
response->print(F("<input type='hidden' name='timerId' value='"));
|
|
response->print((unsigned long)timer.id);
|
|
response->print(F("'><button type='submit' class='danger-button'>Timer entfernen</button></form>"));
|
|
response->print(F("</div>"));
|
|
yield();
|
|
}
|
|
}
|
|
|
|
response->print(F("<div class='timer-card'><h2>Neuen Timer anlegen</h2><div class='summary'>Standardmäßig wird ein aktiver Tages-Timer angelegt.</div>"));
|
|
response->print(F("<form action='/saveStandbyTimer' method='POST'>"));
|
|
response->print(F("<div class='timer-row split'>"));
|
|
response->print(F("<div><label for='timer_new_time'>Uhrzeit</label><input type='time' id='timer_new_time' name='time' value='06:30' required></div>"));
|
|
response->print(F("<div><label for='timer_new_action'>Aktion</label><select id='timer_new_action' name='action'>"));
|
|
response->print(F("<option value='1' selected>Standby aufheben</option>"));
|
|
response->print(F("<option value='0'>Standby aktivieren</option></select></div></div>"));
|
|
response->print(F("<div class='timer-row'><label class='inline-toggle'><input type='checkbox' name='enabled' checked>Timer aktiv</label></div>"));
|
|
response->print(F("<div class='timer-row'><label>Wochentage</label><div class='weekday-row'>"));
|
|
appendStandbyTimerWeekdayInputs(response, 0x7F);
|
|
response->print(F("</div></div>"));
|
|
response->print(F("<div class='timer-actions'><button type='submit'>Timer anlegen</button></div></form></div>"));
|
|
|
|
response->print(F("</div></div></body></html>"));
|
|
request->send(response);
|
|
}
|
|
|
|
void handleSaveStandbyTimer(AsyncWebServerRequest *request) {
|
|
if (!request->hasArg("time")) {
|
|
standbyTimerStatusMessage = "FEHLER: Keine Uhrzeit für den Timer übermittelt.";
|
|
redirectToTimerPage(request);
|
|
return;
|
|
}
|
|
|
|
uint8_t hour = 0;
|
|
uint8_t minute = 0;
|
|
if (!parseStandbyTimerTimeValue(request->arg("time"), hour, minute)) {
|
|
standbyTimerStatusMessage = "FEHLER: Ungültige Timer-Uhrzeit.";
|
|
redirectToTimerPage(request);
|
|
return;
|
|
}
|
|
|
|
uint8_t weekdaysMask = readStandbyTimerWeekdaysFromRequest(request);
|
|
if (weekdaysMask == 0) {
|
|
standbyTimerStatusMessage = "FEHLER: Bitte mindestens einen Wochentag auswählen.";
|
|
redirectToTimerPage(request);
|
|
return;
|
|
}
|
|
|
|
const uint8_t action = (request->hasArg("action") && request->arg("action").toInt() == 0) ? 0 : 1;
|
|
const bool enabled = request->hasArg("enabled");
|
|
const uint32_t timerId = request->hasArg("timerId") ? (uint32_t)strtoul(request->arg("timerId").c_str(), nullptr, 10) : 0;
|
|
std::vector<StandbyTimerEntry> previousTimers = standbyTimers;
|
|
uint32_t previousNextStandbyTimerId = nextStandbyTimerId;
|
|
|
|
if (timerId != 0) {
|
|
StandbyTimerEntry* existingTimer = findStandbyTimerById(timerId);
|
|
if (!existingTimer) {
|
|
standbyTimerStatusMessage = "FEHLER: Der gewählte Timer wurde nicht gefunden.";
|
|
redirectToTimerPage(request);
|
|
return;
|
|
}
|
|
|
|
existingTimer->hour = hour;
|
|
existingTimer->minute = minute;
|
|
existingTimer->weekdaysMask = weekdaysMask;
|
|
existingTimer->action = action;
|
|
existingTimer->enabled = enabled;
|
|
existingTimer->lastTriggeredDateKey = -1;
|
|
} else {
|
|
StandbyTimerEntry newTimer;
|
|
newTimer.id = nextStandbyTimerId++;
|
|
newTimer.hour = hour;
|
|
newTimer.minute = minute;
|
|
newTimer.weekdaysMask = weekdaysMask;
|
|
newTimer.action = action;
|
|
newTimer.enabled = enabled;
|
|
newTimer.lastTriggeredDateKey = -1;
|
|
standbyTimers.push_back(newTimer);
|
|
}
|
|
|
|
if (!saveStandbyTimers()) {
|
|
standbyTimers = previousTimers;
|
|
nextStandbyTimerId = previousNextStandbyTimerId;
|
|
standbyTimerStatusMessage = "FEHLER: Timer konnte nicht gespeichert werden.";
|
|
} else {
|
|
char timeBuffer[6];
|
|
snprintf(timeBuffer, sizeof(timeBuffer), "%02u:%02u", hour, minute);
|
|
standbyTimerStatusMessage = String("Timer erfolgreich gespeichert: ") +
|
|
standbyTimerActionLabel(action) + " um " + timeBuffer + " Uhr.";
|
|
}
|
|
|
|
redirectToTimerPage(request);
|
|
}
|
|
|
|
void handleDeleteStandbyTimer(AsyncWebServerRequest *request) {
|
|
if (!request->hasArg("timerId")) {
|
|
standbyTimerStatusMessage = "FEHLER: Kein Timer zum Entfernen übermittelt.";
|
|
redirectToTimerPage(request);
|
|
return;
|
|
}
|
|
|
|
const uint32_t timerId = (uint32_t)strtoul(request->arg("timerId").c_str(), nullptr, 10);
|
|
std::vector<StandbyTimerEntry> previousTimers = standbyTimers;
|
|
uint32_t previousNextStandbyTimerId = nextStandbyTimerId;
|
|
|
|
const size_t previousSize = standbyTimers.size();
|
|
standbyTimers.erase(std::remove_if(standbyTimers.begin(), standbyTimers.end(),
|
|
[timerId](const StandbyTimerEntry& timer) { return timer.id == timerId; }),
|
|
standbyTimers.end());
|
|
|
|
if (standbyTimers.size() == previousSize) {
|
|
standbyTimerStatusMessage = "FEHLER: Der gewählte Timer wurde nicht gefunden.";
|
|
redirectToTimerPage(request);
|
|
return;
|
|
}
|
|
|
|
if (!saveStandbyTimers()) {
|
|
standbyTimers = previousTimers;
|
|
nextStandbyTimerId = previousNextStandbyTimerId;
|
|
standbyTimerStatusMessage = "FEHLER: Timer konnte nicht entfernt werden.";
|
|
} else {
|
|
standbyTimerStatusMessage = "Timer erfolgreich entfernt.";
|
|
}
|
|
|
|
redirectToTimerPage(request);
|
|
}
|
|
|
|
bool ensureProfileDirectory() {
|
|
if (LittleFS.exists("/Profile")) {
|
|
File root = LittleFS.open("/Profile");
|
|
bool isDir = root && root.isDirectory();
|
|
if (root) {
|
|
root.close();
|
|
}
|
|
if (isDir) {
|
|
return true;
|
|
}
|
|
LittleFS.remove("/Profile");
|
|
}
|
|
return LittleFS.mkdir("/Profile");
|
|
}
|
|
|
|
// Listet alle Profile (.prof Dateien) im /Profile Verzeichnis auf
|
|
std::vector<String> listProfiles() {
|
|
std::vector<String> profileNames;
|
|
if (!ensureProfileDirectory()) {
|
|
return profileNames;
|
|
}
|
|
// Serial.println("Listing profiles (.prof) in /Profile:"); // Debug-Meldung angepasst
|
|
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); // Fallback auf Dateinamen
|
|
if ((size_t)file.size() == sizeof(TemperatureProfile)) {
|
|
TemperatureProfile profileData;
|
|
file.seek(0);
|
|
size_t bytesRead = file.read((uint8_t*)&profileData, sizeof(TemperatureProfile));
|
|
if (bytesRead == sizeof(TemperatureProfile) && profileData.profileVersion == CURRENT_PROFILE_VERSION) {
|
|
profileData.profileName[sizeof(profileData.profileName) - 1] = '\0';
|
|
String storedName = normalizeProfileDisplayName(String(profileData.profileName));
|
|
if (storedName.length() > 0) {
|
|
profileName = storedName;
|
|
}
|
|
}
|
|
}
|
|
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();
|
|
// 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
|
|
if (!ensureProfileDirectory()) return false;
|
|
|
|
// 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());
|
|
|
|
if (LittleFS.exists(filePath) && !LittleFS.remove(filePath)) {
|
|
return false;
|
|
}
|
|
|
|
File profileFile = LittleFS.open(filePath, FILE_WRITE);
|
|
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!
|
|
String displayName = normalizeProfileDisplayName(String(dataToSave.profileName));
|
|
strncpy(dataToSave.profileName, displayName.c_str(), sizeof(dataToSave.profileName) - 1);
|
|
dataToSave.profileName[sizeof(dataToSave.profileName) - 1] = '\0';
|
|
|
|
// Schreibe die gesamte Struktur als Bytes
|
|
size_t bytesWritten = profileFile.write((uint8_t*)&dataToSave, sizeof(TemperatureProfile));
|
|
profileFile.flush();
|
|
|
|
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;
|
|
steamHeatDisabledOnStartupWake = profileData.steamHeatDisabledOnStartupWake;
|
|
fastHeatUpAktiv = profileData.fastHeatUpAktiv;
|
|
|
|
// Brew Control Einstellungen anwenden
|
|
brewByTimeEnabled = profileData.brewByTimeEnabled; brewByTimeTargetSeconds = profileData.brewByTimeTargetSeconds;
|
|
steamByTimeEnabled = profileData.steamByTimeEnabled; steamByTimeTargetSeconds = profileData.steamByTimeTargetSeconds;
|
|
preInfusionEnabled = profileData.preInfusionEnabled; preInfusionDurationSeconds = profileData.preInfusionDurationSeconds; preInfusionPauseSeconds = profileData.preInfusionPauseSeconds;
|
|
|
|
// Brew-by-Weight Einstellungen anwenden
|
|
brewByWeightEnabled = profileData.brewByWeightEnabled; brewByWeightTargetGrams = profileData.brewByWeightTargetGrams; brewByWeightOffsetGrams = profileData.brewByWeightOffsetGrams;
|
|
|
|
// Piezo Status anwenden
|
|
piezoEnabled = profileData.piezoEnabled;
|
|
|
|
// Dampfverzögerungs-Override anwenden
|
|
steamDelayOverrideBySwitchEnabled = profileData.steamDelayOverrideBySwitch;
|
|
|
|
// --- Werte ins EEPROM schreiben (an die korrekten Adressen) ---
|
|
EEPROM.put(EEPROM_ADDR_SETPOINT_WASSER, profileData.setpointWasser); EEPROM.put(EEPROM_ADDR_SETPOINT_DAMPF, profileData.setpointDampf);
|
|
EEPROM.put(EEPROM_ADDR_OFFSET_WASSER, profileData.offsetWasser); EEPROM.put(EEPROM_ADDR_OFFSET_DAMPF, profileData.offsetDampf);
|
|
EEPROM.put(EEPROM_ADDR_KP_WASSER, profileData.kpWasser); EEPROM.put(EEPROM_ADDR_KI_WASSER, profileData.kiWasser); EEPROM.put(EEPROM_ADDR_KD_WASSER, profileData.kdWasser);
|
|
EEPROM.put(EEPROM_ADDR_KP_DAMPF, profileData.kpDampf); EEPROM.put(EEPROM_ADDR_KI_DAMPF, profileData.kiDampf); EEPROM.put(EEPROM_ADDR_KD_DAMPF, profileData.kdDampf);
|
|
EEPROM.put(EEPROM_ADDR_BOOST_WASSER_ACTIVE, profileData.boostWasserActive); EEPROM.put(EEPROM_ADDR_BOOST_DAMPF_ACTIVE, profileData.boostDampfActive);
|
|
EEPROM.put(EEPROM_ADDR_PREVENTHEAT_WASSER, profileData.preventHeatAboveSetpointWasser); EEPROM.put(EEPROM_ADDR_PREVENTHEAT_DAMPF, profileData.preventHeatAboveSetpointDampf);
|
|
EEPROM.put(EEPROM_ADDR_WINDOWSIZE_WASSER, profileData.windowSizeWasser); EEPROM.put(EEPROM_ADDR_WINDOWSIZE_DAMPF, profileData.windowSizeDampf);
|
|
EEPROM.put(EEPROM_ADDR_MAX_TEMP_WASSER, profileData.maxTempWasser); EEPROM.put(EEPROM_ADDR_MAX_TEMP_DAMPF, profileData.maxTempDampf);
|
|
EEPROM.put(EEPROM_ADDR_TUNING_STEP_WASSER, profileData.tuningStepWasser); EEPROM.put(EEPROM_ADDR_TUNING_NOISE_WASSER, profileData.tuningNoiseWasser); EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_WASSER, profileData.tuningStartValueWasser); EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_WASSER, profileData.tuningLookBackWasser);
|
|
EEPROM.put(EEPROM_ADDR_TUNING_STEP_DAMPF, profileData.tuningStepDampf); EEPROM.put(EEPROM_ADDR_TUNING_NOISE_DAMPF, profileData.tuningNoiseDampf); EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_DAMPF, profileData.tuningStartValueDampf); EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_DAMPF, profileData.tuningLookBackDampf);
|
|
EEPROM.put(EEPROM_ADDR_ECOMODE_MINUTES, profileData.ecoModeMinutes); EEPROM.put(EEPROM_ADDR_ECOMODE_TEMP_WASSER, profileData.ecoModeTempWasser); EEPROM.put(EEPROM_ADDR_ECOMODE_TEMP_DAMPF, profileData.ecoModeTempDampf);
|
|
EEPROM.put(EEPROM_ADDR_DYNAMIC_ECO_MODE, profileData.dynamicEcoActive); EEPROM.put(EEPROM_ADDR_STEAM_DELAY, profileData.dampfVerzoegerung);
|
|
EEPROM.put(EEPROM_ADDR_STEAM_HEAT_DISABLED_ON_STARTUP_WAKE, profileData.steamHeatDisabledOnStartupWake);
|
|
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_STEAMBYTIME_ENABLED, profileData.steamByTimeEnabled); EEPROM.put(EEPROM_ADDR_STEAMBYTIME_SECONDS, profileData.steamByTimeTargetSeconds);
|
|
EEPROM.put(EEPROM_ADDR_PREINF_ENABLED, profileData.preInfusionEnabled); EEPROM.put(EEPROM_ADDR_PREINF_DUR_SEC, profileData.preInfusionDurationSeconds); EEPROM.put(EEPROM_ADDR_PREINF_PAUSE_SEC, profileData.preInfusionPauseSeconds);
|
|
|
|
// Brew-by-Weight Einstellungen ins EEPROM schreiben
|
|
EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_ENABLED, profileData.brewByWeightEnabled); EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_TARGET, profileData.brewByWeightTargetGrams); EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_OFFSET, profileData.brewByWeightOffsetGrams);
|
|
|
|
// Systemtöne / Piezo
|
|
EEPROM.put(EEPROM_ADDR_PIEZO_ENABLED, profileData.piezoEnabled); // <-- DIESE ZEILE HINZUFÜGEN
|
|
|
|
// Dampfverzögerungs-Override
|
|
EEPROM.put(EEPROM_ADDR_STEAM_DELAY_OVERRIDE_SWITCH, profileData.steamDelayOverrideBySwitch);
|
|
|
|
EEPROM.commit();
|
|
|
|
// --- PID-Regler sofort aktualisieren ---
|
|
pidWasser.SetTunings(KpWasser, KiWasser, KdWasser); pidDampf.SetTunings(KpDampf, KiDampf, KdDampf);
|
|
pidWasser.SetOutputLimits(0, windowSizeWasser); pidDampf.SetOutputLimits(0, windowSizeDampf);
|
|
// Serial.println(" PID controllers reconfigured with loaded settings.");
|
|
|
|
// Eco-Status zurücksetzen
|
|
ecoModeAktiv = false; ecoModeActivatedTime = 0;
|
|
// Serial.println(" Eco mode status reset.");
|
|
}
|
|
|
|
|
|
/************************************************************************************
|
|
* Holt ALLE aktuellen relevanten Einstellungen aus den globalen Variablen
|
|
* und packt sie in eine Profilstruktur (inkl. Brew-by-Weight).
|
|
************************************************************************************/
|
|
TemperatureProfile getCurrentSettingsAsProfile() {
|
|
TemperatureProfile currentProfile; // Ruft den Konstruktor auf
|
|
|
|
// Setze die aktuelle Versionsnummer in das zu speichernde Profil
|
|
currentProfile.profileVersion = CURRENT_PROFILE_VERSION;
|
|
|
|
// --- Globale Variablen in die Struktur kopieren ---
|
|
// PID (/), ECO (/ECO), FastHeatUp (/Fast-Heat-Up)
|
|
currentProfile.setpointWasser = SetpointWasser; currentProfile.setpointDampf = SetpointDampf;
|
|
currentProfile.offsetWasser = OffsetWasser; currentProfile.offsetDampf = OffsetDampf;
|
|
currentProfile.kpWasser = KpWasser; currentProfile.kiWasser = KiWasser; currentProfile.kdWasser = KdWasser;
|
|
currentProfile.kpDampf = KpDampf; currentProfile.kiDampf = KiDampf; currentProfile.kdDampf = KdDampf;
|
|
currentProfile.boostWasserActive = boostWasserActive; currentProfile.boostDampfActive = boostDampfActive;
|
|
currentProfile.preventHeatAboveSetpointWasser = preventHeatAboveSetpointWasser; currentProfile.preventHeatAboveSetpointDampf = preventHeatAboveSetpointDampf;
|
|
currentProfile.windowSizeWasser = windowSizeWasser; currentProfile.windowSizeDampf = windowSizeDampf;
|
|
currentProfile.maxTempWasser = maxTempWasser; currentProfile.maxTempDampf = maxTempDampf;
|
|
currentProfile.ecoModeMinutes = ecoModeMinutes; currentProfile.ecoModeTempWasser = ecoModeTempWasser; currentProfile.ecoModeTempDampf = ecoModeTempDampf;
|
|
currentProfile.dynamicEcoActive = dynamicEcoActive; currentProfile.dampfVerzoegerung = dampfVerzoegerung;
|
|
currentProfile.steamDelayOverrideBySwitch = steamDelayOverrideBySwitchEnabled;
|
|
currentProfile.steamHeatDisabledOnStartupWake = steamHeatDisabledOnStartupWake;
|
|
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.steamByTimeEnabled = steamByTimeEnabled; currentProfile.steamByTimeTargetSeconds = steamByTimeTargetSeconds;
|
|
currentProfile.preInfusionEnabled = preInfusionEnabled; currentProfile.preInfusionDurationSeconds = preInfusionDurationSeconds; currentProfile.preInfusionPauseSeconds = preInfusionPauseSeconds;
|
|
|
|
// Brew-by-Weight Einstellungen in Struktur kopieren
|
|
currentProfile.brewByWeightEnabled = brewByWeightEnabled; currentProfile.brewByWeightTargetGrams = brewByWeightTargetGrams; currentProfile.brewByWeightOffsetGrams = brewByWeightOffsetGrams;
|
|
|
|
// Piezo Status in Struktur kopieren
|
|
currentProfile.piezoEnabled = piezoEnabled;
|
|
|
|
// Profilname bleibt vorerst leer, wird in handleSaveProfile gesetzt
|
|
strncpy(currentProfile.profileName, "", sizeof(currentProfile.profileName));
|
|
|
|
return currentProfile;
|
|
}
|
|
|
|
// Löscht eine Profildatei (.prof)
|
|
bool deleteProfile(const String& profileName) {
|
|
String sanitizedName = sanitizeProfileName(profileName); // Verwende den Namen aus der Liste
|
|
if (sanitizedName.length() == 0) return false;
|
|
|
|
// Dateiendung .prof verwenden
|
|
String filePath = "/Profile/" + sanitizedName + ".prof"; // *** GEÄNDERT ZU .prof ***
|
|
// Serial.printf("Deleting profile: %s\n", filePath.c_str());
|
|
|
|
if (LittleFS.exists(filePath)) {
|
|
if (LittleFS.remove(filePath)) {
|
|
// Serial.println(" Profile deleted successfully.");
|
|
return true;
|
|
} else {
|
|
// Serial.println(" ERROR: Failed to delete profile file.");
|
|
return false;
|
|
}
|
|
} else {
|
|
// Serial.println(" ERROR: Profile file not found, cannot delete.");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// *** ENDE PROFIL-HILFS-FUNKTIONEN ***
|
|
|
|
// --- PROGMEM Chunks für die neue Profil-Seite ---
|
|
static const char profilesPageHead[] PROGMEM = R"rawliteral(
|
|
<!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 */
|
|
.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 ü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(AsyncWebServerRequest *request) {
|
|
std::vector<String> profiles = listProfiles(); // Hole die Liste der Profile
|
|
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html");
|
|
response->print(FPSTR(profilesPageHead));
|
|
response->print(FPSTR(commonStyle)); // Globale Styles
|
|
response->print(FPSTR(profilesPageStyle)); // Seiten-spezifische Styles und </head>
|
|
response->print(FPSTR(commonNav)); // Navigation
|
|
response->print(FPSTR(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>";
|
|
response->print(statusDiv);
|
|
profileStatusMessage = ""; // Nachricht entfernen, nachdem sie angezeigt wurde
|
|
}
|
|
|
|
// Profilliste generieren
|
|
response->print(FPSTR(profilesPageListStart)); // <ul>
|
|
if (profiles.empty()) {
|
|
response->print(FPSTR(profilesPageNoProfiles));
|
|
} else {
|
|
for (const String& name : profiles) {
|
|
String escapedName = htmlEscape(name);
|
|
String confirmJs = "return confirm('Profil \\'" + jsSingleQuoteEscape(name) + "\\' wirklich entfernen?');";
|
|
String escapedConfirmJs = htmlEscape(confirmJs);
|
|
String listItem = FPSTR(profilesPageListItemStart);
|
|
listItem += escapedName;
|
|
listItem += F("</span><div class=\"profile-actions\">");
|
|
listItem += F("<form action=\"/loadProfile\" method=\"POST\" class=\"profiles-form\"><input type=\"hidden\" name=\"profileName\" value=\"");
|
|
listItem += escapedName;
|
|
listItem += F("\"><button type=\"submit\" class=\"load-button\">Laden</button></form>");
|
|
listItem += F("<form action=\"/deleteProfile\" method=\"POST\" onsubmit=\"");
|
|
listItem += escapedConfirmJs;
|
|
listItem += F("\" class=\"profiles-form\"><input type=\"hidden\" name=\"profileName\" value=\"");
|
|
listItem += escapedName;
|
|
listItem += F("\"><button type=\"submit\" class=\"delete-button\">Entfernen</button></form></div></li>");
|
|
response->print(listItem);
|
|
yield(); // Bei vielen Profilen
|
|
}
|
|
}
|
|
response->print(FPSTR(profilesPageListEnd)); // </ul>
|
|
|
|
// Speicherformular
|
|
response->print(FPSTR(profilesPageSaveForm)); // Formular und </body></html>
|
|
|
|
request->send(response);
|
|
}
|
|
|
|
// Lädt ein ausgewähltes Profil und wendet es an
|
|
void handleLoadProfile(AsyncWebServerRequest *request) {
|
|
if (request->hasArg("profileName")) {
|
|
String profileToLoad = request->arg("profileName");
|
|
TemperatureProfile loadedData;
|
|
|
|
if (loadProfile(profileToLoad, loadedData)) {
|
|
applyProfileSettings(loadedData);
|
|
// *** KORRIGIERT: Erfolgsmeldung hier ***
|
|
profileStatusMessage = "Profil '" + String(loadedData.profileName) + "' erfolgreich geladen und angewendet.";
|
|
} else {
|
|
// *** KORRIGIERT: Fehlermeldung hier ***
|
|
profileStatusMessage = "FEHLER: Profil '" + profileToLoad + "' konnte nicht geladen werden, da es eventuell zu dieser Firmware-Version nicht mehr kompatibel ist.";
|
|
}
|
|
} else {
|
|
profileStatusMessage = "FEHLER: Kein Profilname zum Laden übermittelt.";
|
|
}
|
|
// Redirect zurück zur Profilseite, um die Nachricht anzuzeigen
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader("Location", "/Profile");
|
|
request->send(response);
|
|
}
|
|
|
|
// Speichert die aktuellen Einstellungen unter einem neuen Namen
|
|
void handleSaveProfile(AsyncWebServerRequest *request) {
|
|
if (request->hasArg("profileName")) {
|
|
String newName = normalizeProfileDisplayName(request->arg("profileName"));
|
|
String sanitizedName = sanitizeProfileName(newName);
|
|
|
|
if (sanitizedName.length() == 0) {
|
|
profileStatusMessage = "FEHLER: Ungültiger oder leerer Profilname angegeben.";
|
|
} else {
|
|
TemperatureProfile currentData = getCurrentSettingsAsProfile();
|
|
// Setze den vom Benutzer gewünschten (originalen) Namen in die Struktur
|
|
strncpy(currentData.profileName, newName.c_str(), sizeof(currentData.profileName) - 1);
|
|
currentData.profileName[sizeof(currentData.profileName) - 1] = '\0';
|
|
|
|
// Speichere unter dem bereinigten Dateinamen
|
|
if (saveProfile(sanitizedName, currentData)) {
|
|
profileStatusMessage = "Aktuelle Einstellungen erfolgreich als Profil '" + String(currentData.profileName) + "' gespeichert.";
|
|
} else {
|
|
profileStatusMessage = "FEHLER: Einstellungen konnten nicht als Profil '" + String(currentData.profileName) + "' gespeichert werden.";
|
|
}
|
|
}
|
|
} else {
|
|
profileStatusMessage = "FEHLER: Kein Profilname zum Speichern übermittelt.";
|
|
}
|
|
// Redirect zurück zur Profilseite
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader("Location", "/Profile");
|
|
request->send(response);
|
|
}
|
|
|
|
// Löscht ein ausgewähltes Profil
|
|
void handleDeleteProfile(AsyncWebServerRequest *request) {
|
|
if (request->hasArg("profileName")) {
|
|
String profileToDelete = request->arg("profileName");
|
|
|
|
if (deleteProfile(profileToDelete)) {
|
|
profileStatusMessage = "Profil '" + profileToDelete + "' erfolgreich entfernt.";
|
|
} else {
|
|
profileStatusMessage = "FEHLER: Profil '" + profileToDelete + "' konnte nicht entfernt werden.";
|
|
}
|
|
} else {
|
|
profileStatusMessage = "FEHLER: Kein Profilname zum entfernen übermittelt.";
|
|
}
|
|
// Redirect zurück zur Profilseite
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader("Location", "/Profile");
|
|
request->send(response);
|
|
}
|
|
|
|
// *** ENDE WEB UI HANDLER ***
|
|
|
|
/************************************************************************************
|
|
* Handler für die Dateimanager-Seite (/Dateimanager) - Heap-Optimiert
|
|
* - ESP32 FS-API
|
|
* - Unterstützt jetzt Verzeichnisnavigation
|
|
************************************************************************************/
|
|
|
|
// --- PROGMEM Chunks für handleFileManager ---
|
|
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ü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
|
|
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;
|
|
|
|
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());
|
|
}
|
|
|
|
return success;
|
|
}
|
|
|
|
|
|
/************************************************************************************
|
|
* Handler für die Dateimanager-Seite (/Dateimanager) - Heap-Optimiert
|
|
* - Plattformabhängige FS-API berücksichtigt
|
|
* - Unterstützt jetzt Verzeichnisnavigation
|
|
************************************************************************************/
|
|
|
|
void handleFileManager(AsyncWebServerRequest *request) {
|
|
// Serial.println("Anfrage für /Dateimanager");
|
|
char buffer[20]; // Puffer für Zahlen (Größe, FS Info) - HIER deklariert für die ganze Funktion
|
|
|
|
// Aktuellen Pfad aus URL holen, Standard ist "/"
|
|
String currentPath = request->hasArg("path") ? request->arg("path") : "/";
|
|
|
|
// Pfad bereinigen und validieren
|
|
if (!currentPath.startsWith("/")) {
|
|
currentPath = "/" + currentPath;
|
|
}
|
|
// Einfache Normalisierung (doppelte Slashes entfernen)
|
|
currentPath.replace("//", "/");
|
|
// Entferne abschließenden Slash, außer bei Root "/"
|
|
if (currentPath.length() > 1 && currentPath.endsWith("/")) {
|
|
currentPath.remove(currentPath.length() - 1);
|
|
}
|
|
// Serial.printf("Aktueller Pfad: %s\n", currentPath.c_str());
|
|
|
|
// --- Antwort-Stream erstellen ---
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8");
|
|
|
|
// --- Header und Style senden ---
|
|
response->print(FPSTR(fileMgrHead)); // Enthält <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
|
|
}
|
|
response->print(titleHtml);
|
|
yield();
|
|
|
|
// --- Dateiliste starten ---
|
|
response->print(FPSTR(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);
|
|
response->print(upLink);
|
|
yield();
|
|
}
|
|
|
|
// --- Verzeichnisinhalt auflisten ---
|
|
int entryCount = 0; // Zählt gefundene Dateien/Verzeichnisse (außer . und ..)
|
|
bool errorOccurred = false; // Flag für Fehler beim Öffnen
|
|
File root = LittleFS.open(currentPath);
|
|
if (!root) {
|
|
response->print(FPSTR(fileMgrOpenError));
|
|
errorOccurred = true;
|
|
} else if (!root.isDirectory()) {
|
|
response->print(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
|
|
response->print(dirItem);
|
|
} else {
|
|
// Datei-Eintrag
|
|
size_t fileSize = entry.size();
|
|
response->print(FPSTR(fileMgrListItemFileStart)); // <li><span class='item-name'>
|
|
response->print(entryName); // Dateiname
|
|
response->print(FPSTR(fileMgrListItemFileMid1)); // </span><span class='file-info'>
|
|
snprintf(buffer, sizeof(buffer), "%llu", (uint64_t)fileSize); // ESP32 size_t ist oft 64bit? Sicherer mit ll/u64
|
|
response->print(buffer); // Dateigröße
|
|
response->print(FPSTR(fileMgrListItemFileMid2)); // Bytes</span><div ... href='...?name=
|
|
response->print(entryFullPath); // Voller Pfad für Download
|
|
String fileItemEnd = FPSTR(fileMgrListItemFileMid3);
|
|
fileItemEnd.replace("{FILE_NAME}", entryName); // Name für Confirm-Dialog
|
|
response->print(fileItemEnd); // ' class...>...<input value='
|
|
response->print(entryFullPath); // Voller Pfad für Delete
|
|
response->print(FPSTR(fileMgrListItemFileEnd)); // '><input ...></div></li>
|
|
}
|
|
entry.close(); // Wichtig: Eintrag schließen
|
|
entry = root.openNextFile();
|
|
yield();
|
|
}
|
|
root.close(); // Wichtig: Verzeichnis schließen
|
|
}
|
|
|
|
// --- Meldung, wenn keine Einträge gefunden wurden ---
|
|
if (entryCount == 0 && !errorOccurred) {
|
|
response->print(FPSTR(fileMgrNoFiles)); // Zeigt "(Verzeichnis ist leer)"
|
|
}
|
|
|
|
// --- Ende der Liste + Trennlinie ---
|
|
response->print(FPSTR(fileMgrListEnd)); // </ul><hr>
|
|
yield();
|
|
|
|
// --- Datei-Upload-Formular ---
|
|
String uploadForm = FPSTR(fileMgrUploadForm);
|
|
uploadForm.replace("{CURRENT_PATH}", currentPath); // Aktuellen Pfad für Upload übergeben
|
|
response->print(uploadForm);
|
|
yield();
|
|
|
|
// --- Verzeichnis erstellen Formular ---
|
|
String createDirForm = FPSTR(fileMgrCreateDirForm);
|
|
createDirForm.replace("{CURRENT_PATH}", currentPath); // Aktuellen Pfad für Erstellung übergeben
|
|
response->print(createDirForm);
|
|
yield();
|
|
|
|
// --- Dateisystem-Informationen ---
|
|
response->print(FPSTR(fileMgrInfoStart)); // <hr><h2>Info</h2>
|
|
uint64_t totalBytes = LittleFS.totalBytes();
|
|
uint64_t freeBytes = LittleFS.freeBytes();
|
|
uint64_t usedBytes = (totalBytes >= freeBytes) ? (totalBytes - freeBytes) : 0;
|
|
response->print(FPSTR(fileMgrInfoTotal));
|
|
snprintf(buffer, sizeof(buffer), "%llu", totalBytes); response->print(buffer);
|
|
response->print(FPSTR(fileMgrInfoUsed));
|
|
snprintf(buffer, sizeof(buffer), "%llu", usedBytes); response->print(buffer);
|
|
response->print(FPSTR(fileMgrInfoFree));
|
|
snprintf(buffer, sizeof(buffer), "%llu", totalBytes - usedBytes); response->print(buffer);
|
|
response->print(FPSTR(fileMgrInfoEnd));
|
|
yield();
|
|
|
|
// --- Ende Body/HTML ---
|
|
response->print(FPSTR(fileMgrBodyEnd)); // </body></html>
|
|
|
|
request->send(response);
|
|
|
|
} // Ende handleFileManager()
|
|
|
|
|
|
/************************************************************************************
|
|
* Handler während des Datei-Uploads (angepasst für Pfad)
|
|
************************************************************************************/
|
|
void handleFileUpload(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
|
|
String targetPath = "/";
|
|
|
|
if (request->hasArg("path")) {
|
|
targetPath = request->arg("path");
|
|
if (!targetPath.startsWith("/")) { targetPath = "/" + targetPath; }
|
|
targetPath.replace("//", "/");
|
|
if (targetPath.length() > 1 && targetPath.endsWith("/")) {
|
|
targetPath.remove(targetPath.length() - 1);
|
|
}
|
|
}
|
|
|
|
if (index == 0) {
|
|
String safeName = filename;
|
|
if (safeName.indexOf('/') != -1) {
|
|
safeName = safeName.substring(safeName.lastIndexOf('/') + 1);
|
|
}
|
|
String fullTargetPath = (targetPath == "/") ? "/" + safeName : targetPath + "/" + safeName;
|
|
fsUploadFile = LittleFS.open(fullTargetPath, "w");
|
|
if (!fsUploadFile) {
|
|
uploadStatusMessage = "FEHLER: Datei '" + safeName + "' konnte nicht erstellt werden (Pfad existiert? Speicherplatz?).";
|
|
uploadStatusClass = "status-error";
|
|
} else {
|
|
uploadStatusMessage = "";
|
|
uploadStatusClass = "";
|
|
}
|
|
}
|
|
|
|
if (len) {
|
|
if (fsUploadFile) {
|
|
size_t bytesWritten = fsUploadFile.write(data, len);
|
|
if (bytesWritten != len) {
|
|
fsUploadFile.close();
|
|
uploadStatusMessage = "FEHLER: Schreibfehler während des Uploads von '" + String(fsUploadFile.name()) + "'. Upload abgebrochen.";
|
|
uploadStatusClass = "status-error";
|
|
LittleFS.remove(fsUploadFile.name());
|
|
}
|
|
}
|
|
yield();
|
|
}
|
|
|
|
if (final) {
|
|
if (fsUploadFile) {
|
|
String finalName = fsUploadFile.name();
|
|
fsUploadFile.close();
|
|
if (uploadStatusMessage.length() == 0) {
|
|
uploadStatusMessage = "Datei '" + finalName.substring(finalName.lastIndexOf('/') + 1) + "' erfolgreich hochgeladen.";
|
|
uploadStatusClass = "status-success";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler NACHDEM der Upload abgeschlossen (oder fehlgeschlagen) ist
|
|
* Gibt eine Statusmeldung aus und bietet einen Link zurück zum Dateimanager.
|
|
************************************************************************************/
|
|
void handleFileUploaded(AsyncWebServerRequest *request) {
|
|
String redirectPath = "/Dateimanager";
|
|
// Pfad aus dem Formular holen, um zur richtigen Ansicht zurückzukehren
|
|
if (request->hasArg("path")) {
|
|
String targetPath = request->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
|
|
}
|
|
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8");
|
|
response->print(F("<html><body>"));
|
|
if (uploadStatusMessage.length() > 0) {
|
|
response->printf("<div class='status-message %s'>%s</div>", uploadStatusClass.c_str(), uploadStatusMessage.c_str());
|
|
uploadStatusMessage = "";
|
|
uploadStatusClass = "";
|
|
}
|
|
response->printf("<a href='%s'>Zurück</a>", redirectPath.c_str());
|
|
response->print(F("</body></html>"));
|
|
request->send(response);
|
|
}
|
|
|
|
|
|
/************************************************************************************
|
|
* Handler zum Herunterladen einer Datei (unverändert, sollte mit Pfaden funktionieren)
|
|
************************************************************************************/
|
|
void handleFileDownload(AsyncWebServerRequest *request) {
|
|
if (!request->hasArg("name") || request->arg("name") == "") {
|
|
fileManagerStatusMessage = "FEHLER: Dateiname zum Herunterladen fehlt!";
|
|
fileManagerStatusClass = "status-error";
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader("Location", "/Dateimanager");
|
|
request->send(response);
|
|
return;
|
|
}
|
|
|
|
String filePath = request->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";
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader("Location", "/Dateimanager");
|
|
request->send(response);
|
|
return;
|
|
}
|
|
// Sicherstellen, dass der Pfad mit '/' beginnt
|
|
if (!filePath.startsWith("/")) {
|
|
filePath = "/" + filePath;
|
|
}
|
|
|
|
if (LittleFS.exists(filePath)) {
|
|
File file = LittleFS.open(filePath, "r");
|
|
if (file) {
|
|
if (file.isDirectory()) { // Verzeichnisse nicht herunterladen
|
|
file.close();
|
|
fileManagerStatusMessage = "FEHLER: Verzeichnisse können nicht heruntergeladen werden.";
|
|
fileManagerStatusClass = "status-error";
|
|
String parent = filePath.substring(0, filePath.lastIndexOf('/'));
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader("Location", "/Dateimanager?path=" + parent);
|
|
request->send(response);
|
|
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);
|
|
|
|
AsyncWebServerResponse *response = request->beginResponse(LittleFS, filePath, contentType);
|
|
response->addHeader("Content-Disposition", "attachment; filename=\"" + downloadFilename + "\"");
|
|
request->send(response);
|
|
file.close();
|
|
return;
|
|
} else {
|
|
fileManagerStatusMessage = "FEHLER: Datei '" + filePath + "' konnte nicht geöffnet werden.";
|
|
fileManagerStatusClass = "status-error";
|
|
}
|
|
} else {
|
|
fileManagerStatusMessage = "FEHLER: Datei '" + filePath + "' nicht gefunden.";
|
|
fileManagerStatusClass = "status-error";
|
|
}
|
|
|
|
// Bei Fehlern zurückleiten
|
|
String parentPath = filePath.substring(0, filePath.lastIndexOf('/'));
|
|
if (parentPath == "") parentPath = "/";
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader("Location", "/Dateimanager?path=" + parentPath);
|
|
request->send(response);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler zum Löschen einer Datei oder eines Verzeichnisses
|
|
************************************************************************************/
|
|
void handleFileDelete(AsyncWebServerRequest *request) {
|
|
if (!request->hasArg("name") || request->arg("name") == "") {
|
|
fileManagerStatusMessage = "FEHLER: Name zum Löschen fehlt!";
|
|
fileManagerStatusClass = "status-error";
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader("Location", "/Dateimanager");
|
|
request->send(response);
|
|
return;
|
|
}
|
|
|
|
String itemPath = request->arg("name"); // Enthält den vollen Pfad
|
|
bool isDir = request->hasArg("isDir") && request->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";
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader("Location", "/Dateimanager");
|
|
request->send(response);
|
|
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 = "/";
|
|
|
|
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)) {
|
|
fileManagerStatusMessage = "Datei '" + itemName + "' erfolgreich gelöscht.";
|
|
fileManagerStatusClass = "status-success";
|
|
success = true;
|
|
} else {
|
|
fileManagerStatusMessage = "FEHLER: Datei '" + itemName + "' konnte nicht gelöscht werden.";
|
|
fileManagerStatusClass = "status-error";
|
|
}
|
|
}
|
|
} else {
|
|
fileManagerStatusMessage = "FEHLER: Element '" + itemName + "' nicht gefunden.";
|
|
fileManagerStatusClass = "status-error";
|
|
}
|
|
|
|
// Redirect zurück zum übergeordneten Verzeichnis
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader("Location", "/Dateimanager?path=" + parentPath);
|
|
request->send(response);
|
|
}
|
|
|
|
// HANDLER zum Erstellen von Verzeichnissen
|
|
void handleCreateDirectory(AsyncWebServerRequest *request) {
|
|
String basePath = "/";
|
|
if (request->hasArg("basePath")) {
|
|
basePath = request->arg("basePath");
|
|
// Bereinigen
|
|
if (!basePath.startsWith("/")) { basePath = "/" + basePath; }
|
|
basePath.replace("//", "/");
|
|
if (basePath.length() > 1 && basePath.endsWith("/")) {
|
|
basePath.remove(basePath.length() - 1);
|
|
}
|
|
}
|
|
|
|
if (!request->hasArg("dirName") || request->arg("dirName") == "") {
|
|
fileManagerStatusMessage = "FEHLER: Name für neues Verzeichnis fehlt!";
|
|
fileManagerStatusClass = "status-error";
|
|
} else {
|
|
String dirName = request->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;
|
|
}
|
|
|
|
if (LittleFS.mkdir(newDirPath)) {
|
|
fileManagerStatusMessage = "Verzeichnis '" + dirName + "' erfolgreich erstellt.";
|
|
fileManagerStatusClass = "status-success";
|
|
} else {
|
|
fileManagerStatusMessage = "FEHLER: Verzeichnis '" + dirName + "' konnte nicht erstellt werden (existiert bereits?).";
|
|
fileManagerStatusClass = "status-error";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Redirect zurück zum Basisverzeichnis
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader("Location", "/Dateimanager?path=" + basePath);
|
|
request->send(response);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler zum Neustarten des Geräts
|
|
************************************************************************************/
|
|
|
|
void handleRestartDevice(AsyncWebServerRequest *request) {
|
|
// Serial.println("Neustart über Webinterface ausgelöst...");
|
|
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader("Location", "/");
|
|
request->send(response);
|
|
|
|
// Kurz warten, damit der Browser die Antwort verarbeiten kann
|
|
scheduleRestart(2000); // Neustart einplanen
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Display-Firmware-Update (P4) ueber die UART
|
|
* Die hochgeladene .bin wird chunkweise (Base64 im JSON-Zeilenprotokoll) zum P4
|
|
* gestreamt, der sie per OTA in den inaktiven Slot schreibt. Pro Chunk wird auf
|
|
* das Ack des P4 gewartet (Flusskontrolle). Waehrend des Vorgangs hat dieser
|
|
* Handler die UART exklusiv (touchUartRx/Tick pausieren via displayOtaActive).
|
|
************************************************************************************/
|
|
static bool displayOtaWaitAck(long expectSeq, uint32_t timeoutMs) {
|
|
String line; line.reserve(64);
|
|
uint32_t start = millis();
|
|
while (millis() - start < timeoutMs) {
|
|
while (touchUart.available()) {
|
|
char c = (char)touchUart.read();
|
|
if (c == '\n') {
|
|
if (line.indexOf("\"type\":\"otaAck\"") >= 0) {
|
|
if (line.indexOf("\"ok\":true") < 0) return false;
|
|
long seq = -999;
|
|
int p = line.indexOf("\"seq\":");
|
|
if (p >= 0) seq = atol(line.c_str() + p + 6);
|
|
if (seq == expectSeq) return true;
|
|
}
|
|
line = "";
|
|
} else if (c != '\r') {
|
|
if (line.length() < 200) line += c;
|
|
}
|
|
}
|
|
yield();
|
|
}
|
|
return false; // Timeout
|
|
}
|
|
|
|
static bool displayOtaSendChunk(const uint8_t* data, size_t len, uint32_t seq) {
|
|
static char b64[1500];
|
|
size_t b64len = 0;
|
|
if (mbedtls_base64_encode((unsigned char*)b64, sizeof(b64), &b64len, data, len) != 0) return false;
|
|
b64[b64len] = '\0';
|
|
touchUart.print("{\"type\":\"otaData\",\"seq\":");
|
|
touchUart.print(seq);
|
|
touchUart.print(",\"data\":\"");
|
|
touchUart.print(b64);
|
|
touchUart.println("\"}");
|
|
return displayOtaWaitAck((long)seq, 4000);
|
|
}
|
|
|
|
void handleDisplayFirmwareUploadProgress(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
|
|
(void)request; (void)filename;
|
|
static uint8_t chunk[1024];
|
|
static size_t chunkLen = 0;
|
|
static uint32_t seq = 0;
|
|
static bool failed = false;
|
|
|
|
if (index == 0) {
|
|
// Vor dem (potenziell crash-/brownout-gefaehrlichen) Update ein frisches
|
|
// EEPROM-Backup auf FFat schreiben, damit ein unsauberer Reset waehrend des
|
|
// Updates die Einstellungen nicht mehr kostet (Auto-Restore beim naechsten Boot).
|
|
writeEepromBackup();
|
|
displayOtaActive = true; // touchUartRx/Tick pausieren
|
|
chunkLen = 0; seq = 0; failed = false;
|
|
delay(10); // dem loop() Zeit geben, das Flag zu sehen
|
|
while (touchUart.available()) touchUart.read(); // RX-Reste verwerfen
|
|
touchUart.println("{\"type\":\"otaBegin\"}");
|
|
if (!displayOtaWaitAck(-1, 6000)) failed = true;
|
|
}
|
|
|
|
if (!failed && len) {
|
|
size_t i = 0;
|
|
while (i < len) {
|
|
size_t take = sizeof(chunk) - chunkLen;
|
|
if (take > (len - i)) take = (len - i);
|
|
memcpy(chunk + chunkLen, data + i, take);
|
|
chunkLen += take; i += take;
|
|
if (chunkLen == sizeof(chunk)) {
|
|
if (!displayOtaSendChunk(chunk, chunkLen, seq++)) { failed = true; break; }
|
|
chunkLen = 0;
|
|
}
|
|
}
|
|
yield();
|
|
}
|
|
|
|
if (final) {
|
|
if (!failed && chunkLen > 0) {
|
|
if (!displayOtaSendChunk(chunk, chunkLen, seq++)) failed = true;
|
|
chunkLen = 0;
|
|
}
|
|
if (!failed) {
|
|
touchUart.println("{\"type\":\"otaEnd\"}");
|
|
if (!displayOtaWaitAck(-2, 10000)) failed = true;
|
|
}
|
|
if (failed) {
|
|
touchUart.println("{\"type\":\"otaAbort\"}");
|
|
}
|
|
displayOtaResultOk = !failed;
|
|
displayOtaActive = false;
|
|
}
|
|
}
|
|
|
|
// --- PROGMEM Chunks für die Display-Update-Seite (P4) ---
|
|
static const char displayUpdateHtmlHead[] PROGMEM = R"rawliteral(
|
|
<!DOCTYPE html><html><head><title>Display-Update</title><meta charset='utf-8'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
|
)rawliteral";
|
|
static const char displayUpdateHtmlHeadEnd[] PROGMEM = R"rawliteral(</head>)rawliteral";
|
|
static const char displayUpdateBody[] PROGMEM = R"rawliteral(
|
|
<body>
|
|
<h1>Display-Update</h1>
|
|
<form method='POST' action='/displayUpdate' enctype='multipart/form-data'>
|
|
<h3>UART-Touch-Display (ESP32-P4)</h3>
|
|
<p style='color:#FFCC00;'>Hinweis: Dieser Vorgang betrifft ausschließlich das per UART
|
|
angeschlossene <b>Touch-Display (ESP32-P4)</b> – <b>nicht</b> das OLED der Steuerung.</p>
|
|
Kompilierte P4-Firmware (<code>.bin</code>) auswählen. Die Übertragung erfolgt über die
|
|
Steuerung per UART und dauert einige Minuten.<br>
|
|
<br>
|
|
<b style='color: #FFCC00;'>ACHTUNG:</b> Display und Steuerung während des Vorgangs <b>nicht</b> ausschalten.<br>
|
|
<br>
|
|
<input type='file' name='firmware' accept='.bin' required>
|
|
<br>
|
|
<button type='submit'>Update starten</button>
|
|
</form>
|
|
<form>
|
|
<h3>Zurück</h3>
|
|
Zurück zur Übersicht.<br>
|
|
<br>
|
|
<a class='info-link' href='/Firmware'>← Firmware & Einstellungen</a>
|
|
</form>
|
|
</body>
|
|
</html>
|
|
)rawliteral";
|
|
|
|
void handleDisplayUpdatePage(AsyncWebServerRequest *request) {
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8");
|
|
response->print(FPSTR(displayUpdateHtmlHead));
|
|
response->print(FPSTR(commonStyle));
|
|
response->print(FPSTR(displayUpdateHtmlHeadEnd));
|
|
response->print(FPSTR(commonNav));
|
|
response->print(FPSTR(displayUpdateBody));
|
|
request->send(response);
|
|
}
|
|
|
|
// --- PROGMEM Chunks für die Display-Update-Ergebnisseite (P4) ---
|
|
static const char displayUpdateDoneHtmlHead[] PROGMEM = R"rawliteral(
|
|
<!DOCTYPE html><html><head><title>Display-Update</title><meta charset='utf-8'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta http-equiv='refresh' content='8; url=/Firmware'>
|
|
)rawliteral";
|
|
static const char displayUpdateDoneHtmlHeadEnd[] PROGMEM = R"rawliteral(</head>)rawliteral";
|
|
static const char displayUpdateDoneBodyStart[] PROGMEM = R"rawliteral(
|
|
<body>
|
|
<h1>Display-Update</h1>
|
|
<form>
|
|
)rawliteral";
|
|
static const char displayUpdateDoneBodyEnd[] PROGMEM = R"rawliteral(
|
|
<br>
|
|
<a class='info-link' href='/Firmware'>← Zurück zu Firmware & Einstellungen</a>
|
|
</form>
|
|
</body>
|
|
</html>
|
|
)rawliteral";
|
|
|
|
void handleDisplayUpdateDone(AsyncWebServerRequest *request) {
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8");
|
|
response->print(FPSTR(displayUpdateDoneHtmlHead));
|
|
response->print(FPSTR(commonStyle));
|
|
response->print(FPSTR(displayUpdateDoneHtmlHeadEnd));
|
|
response->print(FPSTR(commonNav));
|
|
response->print(FPSTR(displayUpdateDoneBodyStart));
|
|
if (displayOtaResultOk) {
|
|
response->print(F("<h3>Update übertragen</h3>"
|
|
"<p>Das Display startet jetzt mit der neuen Firmware neu.</p>"
|
|
"<p>Diese Seite wechselt automatisch zurück ...</p>"));
|
|
} else {
|
|
response->print(F("<h3 style='color:#dc3545;'>Update fehlgeschlagen</h3>"
|
|
"<p>Bitte erneut versuchen. Das Display läuft mit der bisherigen Firmware weiter.</p>"));
|
|
}
|
|
response->print(FPSTR(displayUpdateDoneBodyEnd));
|
|
request->send(response);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler für den Firmware-Upload-Prozess (während des Uploads)
|
|
************************************************************************************/
|
|
void handleFirmwareUploadProgress(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
|
|
(void)request;
|
|
(void)filename;
|
|
|
|
if (index == 0) {
|
|
// Frisches EEPROM-Backup auf FFat, bevor die neue Firmware den App-Slot ueberschreibt
|
|
// (Auto-Restore beim naechsten Boot, falls dabei etwas schiefgeht).
|
|
writeEepromBackup();
|
|
passwordFound = false;
|
|
passMatchPos = 0;
|
|
bool updateStarted = false;
|
|
updateStarted = Update.begin(UPDATE_SIZE_UNKNOWN, U_FLASH);
|
|
if (!updateStarted) {
|
|
Update.printError(Serial);
|
|
}
|
|
}
|
|
|
|
if (len) {
|
|
for (size_t i = 0; i < len; i++) {
|
|
char c = (char)data[i];
|
|
if (!passwordFound) {
|
|
if (c == FIRMWARE_PASSWORD[passMatchPos]) {
|
|
passMatchPos++;
|
|
if (passMatchPos == passLength) {
|
|
passwordFound = true;
|
|
}
|
|
} else {
|
|
passMatchPos = (c == FIRMWARE_PASSWORD[0]) ? 1 : 0;
|
|
}
|
|
}
|
|
}
|
|
yield();
|
|
if (Update.write(data, len) != len) {
|
|
Update.printError(Serial);
|
|
}
|
|
}
|
|
|
|
if (final) {
|
|
if (!passwordFound) {
|
|
Update.end(false);
|
|
} else {
|
|
if (!Update.end(true)) {
|
|
Serial.println("FEHLER: Update.end() fehlgeschlagen!");
|
|
Update.printError(Serial);
|
|
}
|
|
}
|
|
}
|
|
|
|
yield();
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler für die Antwort nach einem Firmware-Update - Heap-Optimiert
|
|
************************************************************************************/
|
|
|
|
static const char firmwareUpdateResultHtmlHead[] PROGMEM = R"rawliteral(
|
|
<!DOCTYPE html><html><head><title>Firmware-Update</title><meta charset='utf-8'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta http-equiv='refresh' content='5; url=/'>
|
|
)rawliteral";
|
|
|
|
static const char firmwareUpdateResultHtmlHeadEnd[] PROGMEM = R"rawliteral(
|
|
</head>
|
|
)rawliteral";
|
|
|
|
static const char firmwareUpdateResultBodyStart[] PROGMEM = R"rawliteral(
|
|
<body>
|
|
<form>
|
|
<h3>Firmware-Update</h3>
|
|
)rawliteral";
|
|
|
|
static const char firmwareUpdateResultBodyEnd[] PROGMEM = R"rawliteral(
|
|
<p>Neustart wird ausgeführt ...</p>
|
|
</form>
|
|
</body>
|
|
</html>
|
|
)rawliteral";
|
|
|
|
void handleFirmwareUpdateResponse(AsyncWebServerRequest *request) {
|
|
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!");
|
|
}
|
|
}
|
|
|
|
// Statusmeldung auf der seriellen Schnittstelle ausgeben
|
|
if (statusMessage.length() > 0) {
|
|
Serial.println(statusMessage);
|
|
}
|
|
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8");
|
|
response->print(FPSTR(firmwareUpdateResultHtmlHead));
|
|
response->print(FPSTR(commonStyle));
|
|
response->print(FPSTR(firmwareUpdateResultHtmlHeadEnd));
|
|
response->print(FPSTR(commonNav));
|
|
response->print(FPSTR(firmwareUpdateResultBodyStart));
|
|
response->print(F("<b>"));
|
|
response->print(statusMessage);
|
|
response->print(F("</b>"));
|
|
response->print(FPSTR(firmwareUpdateResultBodyEnd));
|
|
request->send(response);
|
|
|
|
// Neustart-Logik
|
|
scheduleRestart(5000); // Browser hat Zeit, die Meldung anzuzeigen
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Schreibt einen gültigen Shot (Dauer > 20s) in die CSV-Logdatei.
|
|
* Dateiname: Nutzungsstatistik.csv
|
|
* Format: UnixTimestamp,DauerInMs
|
|
************************************************************************************/
|
|
void logShot(unsigned long durationMs, bool wasByWeight, float finalWeight) {
|
|
// 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
|
|
File logFile = LittleFS.open("/Nutzungsstatistik.csv", "a"); // "a" = Append
|
|
if (!logFile) {
|
|
// Serial.println("FEHLER: Konnte Nutzungsstatistik.csv zum Schreiben nicht öffnen!");
|
|
return;
|
|
}
|
|
|
|
String logEntry;
|
|
if (wasByWeight) {
|
|
// Format mit Gewicht: Timestamp,Dauer,Gewicht
|
|
logEntry = String(now_t) + "," + String(durationMs) + "," + String(finalWeight, 1) + "\n";
|
|
} else {
|
|
// Altes Format ohne Gewicht: Timestamp,Dauer
|
|
logEntry = String(now_t) + "," + String(durationMs) + "\n";
|
|
}
|
|
|
|
if (logFile.print(logEntry)) {
|
|
// Serial.printf("Shot geloggt: %s", logEntry.c_str()); // Optional: Debug-Ausgabe
|
|
// } else {
|
|
// 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(×tamp, &ts_tm);
|
|
localtime_r(&now, &now_tm);
|
|
return (ts_tm.tm_year == now_tm.tm_year && ts_tm.tm_mon == now_tm.tm_mon && ts_tm.tm_mday == now_tm.tm_mday);
|
|
}
|
|
|
|
bool isSameWeek(time_t timestamp, time_t now) {
|
|
struct tm ts_tm, now_tm;
|
|
localtime_r(×tamp, &ts_tm);
|
|
localtime_r(&now, &now_tm);
|
|
int ts_wday = (ts_tm.tm_wday == 0) ? 6 : ts_tm.tm_wday - 1; // Mo=0..So=6
|
|
int now_wday = (now_tm.tm_wday == 0) ? 6 : now_tm.tm_wday - 1;
|
|
time_t ts_monday_start_of_day = timestamp - ts_tm.tm_hour * 3600 - ts_tm.tm_min * 60 - ts_tm.tm_sec - ts_wday * 86400;
|
|
time_t now_monday_start_of_day = now - now_tm.tm_hour * 3600 - now_tm.tm_min * 60 - now_tm.tm_sec - now_wday * 86400;
|
|
return (ts_monday_start_of_day == now_monday_start_of_day);
|
|
}
|
|
|
|
bool isSameMonth(time_t timestamp, time_t now) {
|
|
struct tm ts_tm, now_tm;
|
|
localtime_r(×tamp, &ts_tm);
|
|
localtime_r(&now, &now_tm);
|
|
return (ts_tm.tm_year == now_tm.tm_year && ts_tm.tm_mon == now_tm.tm_mon);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Liest die Log-Datei (Nutzungsstatistik.csv) und berechnet Statistiken.
|
|
* Gibt eine ShotStats-Struktur zurück.
|
|
************************************************************************************/
|
|
/************************************************************************************
|
|
* Liest die Log-Datei (Nutzungsstatistik.csv) und berechnet Statistiken.
|
|
* Kann Zeilen mit 2 Werten (Timestamp, Dauer) und 3 Werten (Timestamp, Dauer, Gewicht) verarbeiten.
|
|
* 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;
|
|
|
|
// --- Flexibles Parsen für 2 oder 3 Spalten ---
|
|
int firstCommaIndex = line.indexOf(',');
|
|
int secondCommaIndex = -1;
|
|
if (firstCommaIndex != -1) {
|
|
// Suche nach dem zweiten Komma NACH dem ersten
|
|
secondCommaIndex = line.indexOf(',', firstCommaIndex + 1);
|
|
}
|
|
|
|
if (firstCommaIndex != -1) {
|
|
// strtoull verwenden, um mögliche Überläufe bei 32-Bit time_t zu vermeiden
|
|
time_t timestamp = (time_t)strtoull(line.substring(0, firstCommaIndex).c_str(), NULL, 10);
|
|
unsigned long durationMs;
|
|
|
|
if(secondCommaIndex != -1) {
|
|
// Neue Zeile mit Gewicht: Dauer ist zwischen den Kommas
|
|
durationMs = strtoul(line.substring(firstCommaIndex + 1, secondCommaIndex).c_str(), NULL, 10);
|
|
// Das Gewicht selbst wird hier nicht für die Statistik benötigt, nur korrekt geparst.
|
|
} else {
|
|
// Alte Zeile ohne Gewicht: Dauer ist nach dem ersten Komma
|
|
durationMs = strtoul(line.substring(firstCommaIndex + 1).c_str(), NULL, 10);
|
|
}
|
|
// --- Ende Flexibles Parsen ---
|
|
|
|
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++; }
|
|
|
|
// Zähler für "letzte Periode"
|
|
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 Werte 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. Verarbeitet Zeilen mit und ohne Gewichtsangabe.
|
|
************************************************************************************/
|
|
void handleDownloadNutzungsstatistik(AsyncWebServerRequest *request) {
|
|
File logFile = LittleFS.open("/Nutzungsstatistik.csv", "r");
|
|
|
|
if (!logFile) {
|
|
request->send(404, "text/plain", "Fehler: Log-Datei nicht gefunden.");
|
|
yield();
|
|
return;
|
|
}
|
|
if (logFile.size() == 0) {
|
|
logFile.close();
|
|
request->send(200, "text/plain", "Nutzungsstatistik ist leer.");
|
|
yield();
|
|
return;
|
|
}
|
|
|
|
AsyncResponseStream *response = request->beginResponseStream("text/plain; charset=utf-8");
|
|
response->addHeader("Content-Disposition", "attachment; filename=\"Nutzungsverlauf.txt\"");
|
|
|
|
char lineBuffer[120];
|
|
char dateBuffer[25];
|
|
char durationBuffer[10];
|
|
char weightBuffer[10];
|
|
|
|
response->print("Datum, Uhrzeit, Dauer (s), Gewicht (g)\n");
|
|
response->print("------------------------------------------\n");
|
|
|
|
int lineCount = 0;
|
|
while (logFile.available()) {
|
|
String line = logFile.readStringUntil('\n');
|
|
line.trim();
|
|
if (line.length() == 0) continue;
|
|
|
|
int firstCommaIndex = line.indexOf(',');
|
|
int secondCommaIndex = -1;
|
|
if (firstCommaIndex != -1) {
|
|
secondCommaIndex = line.indexOf(',', firstCommaIndex + 1);
|
|
}
|
|
|
|
if (firstCommaIndex != -1) {
|
|
time_t timestamp = (time_t)strtoull(line.substring(0, firstCommaIndex).c_str(), NULL, 10);
|
|
unsigned long durationMs;
|
|
float weight = -1.0f;
|
|
|
|
if(secondCommaIndex != -1) {
|
|
durationMs = strtoul(line.substring(firstCommaIndex + 1, secondCommaIndex).c_str(), NULL, 10);
|
|
weight = line.substring(secondCommaIndex + 1).toFloat();
|
|
} else {
|
|
durationMs = strtoul(line.substring(firstCommaIndex + 1).c_str(), NULL, 10);
|
|
}
|
|
|
|
if (timestamp > 0 && durationMs > 0) {
|
|
struct tm timeinfo;
|
|
localtime_r(×tamp, &timeinfo);
|
|
strftime(dateBuffer, sizeof(dateBuffer), "%d.%m.%Y, %H:%M:%S", &timeinfo);
|
|
snprintf(durationBuffer, sizeof(durationBuffer), "%.1f", durationMs / 1000.0);
|
|
|
|
if (weight >= 0.0) {
|
|
snprintf(weightBuffer, sizeof(weightBuffer), "%.1f", weight);
|
|
snprintf(lineBuffer, sizeof(lineBuffer), "%s, %s, %s\n", dateBuffer, durationBuffer, weightBuffer);
|
|
} else {
|
|
snprintf(lineBuffer, sizeof(lineBuffer), "%s, %s, -\n", dateBuffer, durationBuffer);
|
|
}
|
|
|
|
response->print(lineBuffer);
|
|
lineCount++;
|
|
if (lineCount % 20 == 0) { yield(); }
|
|
}
|
|
}
|
|
yield();
|
|
}
|
|
logFile.close();
|
|
|
|
request->send(response);
|
|
// Serial.printf("Formatierter Verlauf mit %d Zeilen gesendet.\n", lineCount);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Hilfsfunktionen zum Erstellen der Statistik-Daten
|
|
************************************************************************************/
|
|
|
|
// Prüft, ob der timestamp am Vortag von 'now' liegt
|
|
bool isYesterday(time_t timestamp, time_t now) {
|
|
time_t yesterday_t = now - 86400UL; // Zeitstempel für vor 24 Stunden
|
|
struct tm ts_tm, yest_tm;
|
|
localtime_r(×tamp, &ts_tm); // Konvertiert Timestamp in lokale Zeit-Struktur
|
|
localtime_r(&yesterday_t, &yest_tm); // Konvertiert Zeitstempel von Gestern
|
|
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(×tamp, &ts_tm);
|
|
localtime_r(&now, &now_tm);
|
|
|
|
// Berechne den Start der aktuellen Woche (Montag 00:00:00)
|
|
int now_wday = (now_tm.tm_wday == 0) ? 6 : now_tm.tm_wday - 1; // Korrigiert Wochentag Mo=0..So=6
|
|
time_t startOfThisWeek = now - (now_tm.tm_hour * 3600) - (now_tm.tm_min * 60) - now_tm.tm_sec - (now_wday * 86400UL);
|
|
|
|
// Berechne den Start der letzten Woche (Montag 00:00:00)
|
|
time_t startOfLastWeek = startOfThisWeek - (7 * 86400UL);
|
|
|
|
// Prüfe, ob der Timestamp in der letzten Woche liegt (>= Start der letzten Woche UND < Start dieser Woche)
|
|
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(×tamp, &ts_tm);
|
|
localtime_r(&now, &now_tm);
|
|
|
|
int lastMonth = now_tm.tm_mon - 1; // Monat vor dem aktuellen
|
|
int lastMonthYear = now_tm.tm_year; // Jahr des letzten Monats (Standard: aktuelles Jahr)
|
|
|
|
if (lastMonth < 0) { // Sonderfall: Aktueller Monat ist Januar (tm_mon = 0)
|
|
lastMonth = 11; // Letzter Monat war Dezember (tm_mon = 11)
|
|
lastMonthYear = now_tm.tm_year - 1; // im vorherigen Jahr
|
|
}
|
|
|
|
// Prüfe, ob Jahr und Monat des Timestamps mit dem letzten Monat übereinstimmen
|
|
yield();
|
|
return (ts_tm.tm_year == lastMonthYear && ts_tm.tm_mon == lastMonth);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler zum entfernen der Nutzungsstatistik-Datei (/Nutzungsstatistik.csv)
|
|
************************************************************************************/
|
|
void handleDeleteStatistics(AsyncWebServerRequest *request) {
|
|
const char* statsFilename = "/Nutzungsstatistik.csv";
|
|
|
|
bool success = false;
|
|
if (LittleFS.exists(statsFilename)) {
|
|
if (LittleFS.remove(statsFilename)) {
|
|
success = true;
|
|
shotCounter = 0;
|
|
EEPROM.put(EEPROM_ADDR_SHOTCOUNTER, shotCounter);
|
|
// Andere Statistikvariablen werden beim nächsten Aufruf von calculateShotStatistics neu berechnet
|
|
// maintenanceIntervalCounter NICHT zurücksetzen, das hat einen eigenen Reset.
|
|
}
|
|
} else {
|
|
success = true;
|
|
}
|
|
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader(F("Location"), F("/Info"));
|
|
request->send(response);
|
|
yield();
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler zum Umschalten des Wartungsmodus
|
|
************************************************************************************/
|
|
void handleToggleWartungsmodus(AsyncWebServerRequest *request) {
|
|
if (cleaningAssistantActive) {
|
|
cleaningAssistantStatusMessage = "Reinigungsassistent aktiv - Wartungsmodus kann derzeit nicht geaendert werden.";
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader(F("Location"), F("/Service"));
|
|
request->send(response);
|
|
return;
|
|
}
|
|
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");
|
|
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader(F("Location"), F("/Service"));
|
|
request->send(response);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Führt das verzögerte Speichern von Shot-Daten durch, um die loop() nicht zu blockieren.
|
|
************************************************************************************/
|
|
void handleStartCleaningAssistant(AsyncWebServerRequest *request) {
|
|
int newBrewSeconds = cleaningAssistantBrewSeconds;
|
|
int newCycles = cleaningAssistantCycles;
|
|
int newPauseSeconds = cleaningAssistantPauseSeconds;
|
|
|
|
if (request->hasArg("cleaningBrewSeconds")) {
|
|
newBrewSeconds = request->arg("cleaningBrewSeconds").toInt();
|
|
}
|
|
if (request->hasArg("cleaningCycles")) {
|
|
newCycles = request->arg("cleaningCycles").toInt();
|
|
}
|
|
if (request->hasArg("cleaningPauseSeconds")) {
|
|
newPauseSeconds = request->arg("cleaningPauseSeconds").toInt();
|
|
}
|
|
|
|
if (newBrewSeconds < 1) { newBrewSeconds = 1; }
|
|
if (newBrewSeconds > 30) { newBrewSeconds = 30; }
|
|
if (newCycles < 1) { newCycles = 1; }
|
|
if (newCycles > 30) { newCycles = 30; }
|
|
if (newPauseSeconds < 1) { newPauseSeconds = 1; }
|
|
if (newPauseSeconds > 60) { newPauseSeconds = 60; }
|
|
|
|
cleaningAssistantBrewSeconds = (uint8_t)newBrewSeconds;
|
|
cleaningAssistantCycles = (uint8_t)newCycles;
|
|
cleaningAssistantPauseSeconds = (uint8_t)newPauseSeconds;
|
|
|
|
String reason;
|
|
if (!canStartCleaningAssistant(reason)) {
|
|
cleaningAssistantStatusMessage = reason;
|
|
} else {
|
|
startCleaningAssistant();
|
|
}
|
|
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader(F("Location"), F("/Service"));
|
|
request->send(response);
|
|
}
|
|
|
|
void handleStopCleaningAssistant(AsyncWebServerRequest *request) {
|
|
if (cleaningAssistantActive) {
|
|
stopCleaningAssistant();
|
|
}
|
|
|
|
AsyncWebServerResponse *response = request->beginResponse(303);
|
|
response->addHeader(F("Location"), F("/Service"));
|
|
request->send(response);
|
|
}
|
|
|
|
void handleShotSaving(AsyncWebServerRequest *request) {
|
|
(void)request;
|
|
if (shotNeedsToBeSaved) {
|
|
// Flag sofort zurücksetzen, um Mehrfachausführung zu verhindern
|
|
shotNeedsToBeSaved = false;
|
|
|
|
// Die langsamen Operationen werden jetzt hier ausgeführt
|
|
shotCounter++;
|
|
maintenanceIntervalCounter++;
|
|
EEPROM.put(EEPROM_ADDR_SHOTCOUNTER, shotCounter);
|
|
EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter);
|
|
EEPROM.commit();
|
|
|
|
// Shot mit aktuellen Parametern protokollieren
|
|
logShot(savedShotDuration, savedShotWasByWeight, savedShotFinalWeight);
|
|
|
|
// Zusätzliche Zustandsvariablen zurücksetzen
|
|
savedShotWasByWeight = false;
|
|
savedShotFinalWeight = 0.0f;
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// START: Dashboard Code Block (PROGMEM Strings & Handler Funktionen)
|
|
// Version v3.8 - Basiert auf v3.5, mit verfeinerter Farb-Logik im JS
|
|
// =============================================================================
|
|
|
|
// --- PROGMEM Strings für Dashboard ---
|
|
|
|
static const char dashboardHtmlHead[] PROGMEM = R"rawliteral(
|
|
<!DOCTYPE html><html lang="de"><head><title>Dashboard</title><meta charset="UTF-8"><meta name='theme-color' content='#000000'><meta name='color-scheme' content='dark'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
|
)rawliteral";
|
|
|
|
// Dashboard spezifische CSS-Regeln (v3.6 - Eco Button Farbe angepasst)
|
|
static const char dashboardStyles[] PROGMEM = R"rawliteral(
|
|
<style>
|
|
/* Grundlayout */
|
|
.dashboard-container { display: flex; flex-wrap: wrap; padding: 15px; gap: 20px; max-width: 1200px; margin: 20px auto; }
|
|
.main-display { flex: 2; min-width: 300px; background: rgba(255, 255, 255, 0.05); border-radius: 12px; padding: 20px; box-shadow: 0 4px 15px rgba(0,0,0,0.2); border: 1px solid rgba(255, 255, 255, 0.1); display: flex; flex-direction: column; gap: 15px; }
|
|
.control-panel { flex: 1; min-width: 280px; background: rgba(255, 255, 255, 0.05); border-radius: 12px; padding: 16px; box-shadow: 0 4px 15px rgba(0,0,0,0.2); border: 1px solid rgba(255, 255, 255, 0.1); display: flex; flex-direction: column; gap: 12px; }
|
|
|
|
/* Temperaturanzeige */
|
|
.temp-display { text-align: center; border-bottom: 1px solid rgba(255, 255, 255, 0.1); padding-bottom: 15px; }
|
|
.temp-display h2 { margin: 0 0 5px 0; font-size: 1.1em; color: #ccc; font-weight: 500; }
|
|
.temp-actual { font-size: 3.5em; font-weight: bold; line-height: 1.1; color: #fff; transition: color 0.3s ease; }
|
|
.temp-target { font-size: 1.2em; color: #aaa; }
|
|
.temp-actual.heating { color: #F7941D; } /* Orange */
|
|
.temp-actual.ready { color: #28a745; } /* Grün */
|
|
.temp-actual.eco { color: #00AEEF; } /* Blau */
|
|
.temp-actual.error { color: #dc3545; } /* Rot */
|
|
.temp-actual.maintenance { color: #6c757d; } /* Grau */
|
|
|
|
.temp-layout { display: none; width: 100%; }
|
|
.main-display[data-temp-layout="0"] .layout-classic { display: block; }
|
|
.main-display[data-temp-layout="1"] .layout-bars { display: grid; gap: 16px; }
|
|
.main-display[data-temp-layout="2"] .layout-gauges { display: flex; gap: 20px; justify-content: space-around; flex-wrap: wrap; }
|
|
.main-display[data-temp-layout="3"] .layout-rails { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
|
|
|
|
.temp-block { --temp-color: #ffffff; }
|
|
.temp-block[data-temp-state="heating"] { --temp-color: #F7941D; }
|
|
.temp-block[data-temp-state="ready"] { --temp-color: #28a745; }
|
|
.temp-block[data-temp-state="eco"] { --temp-color: #00AEEF; }
|
|
.temp-block[data-temp-state="error"] { --temp-color: #dc3545; }
|
|
.temp-block[data-temp-state="maintenance"] { --temp-color: #6c757d; }
|
|
|
|
.layout-bars .temp-bar-row { background: rgba(0,0,0,0.2); border-radius: 10px; padding: 12px; }
|
|
.temp-bar-header { display: flex; align-items: baseline; justify-content: space-between; }
|
|
.temp-bar-title { font-size: 0.95em; color: #ccc; }
|
|
.temp-bar-actual { font-size: 2.4em; font-weight: 700; line-height: 1; }
|
|
.temp-bar { position: relative; height: 8px; background: rgba(255,255,255,0.12); border-radius: 999px; overflow: hidden; margin-top: 10px; }
|
|
.temp-bar-fill { height: 100%; width: 0%; background: linear-gradient(90deg, var(--temp-color), rgba(255,255,255,0.9)); border-radius: 999px; transition: width 0.3s ease; }
|
|
.temp-bar-target { margin-top: 6px; font-size: 0.95em; color: #aaa; text-align: right; }
|
|
|
|
.temp-gauge-card { display: flex; flex-direction: column; gap: 12px; justify-content: start; align-items: center; }
|
|
.temp-gauge { --temp-pct: 0; width: 170px; height: 170px; border-radius: 50%; padding: 10px; background: conic-gradient(var(--temp-color) calc(var(--temp-pct) * 1%), rgba(255,255,255,0.12) 0); display: flex; align-items: center; justify-content: center; }
|
|
.temp-gauge-inner { width: 100%; height: 100%; border-radius: 50%; background: rgba(0,0,0,0.45); display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 4px; }
|
|
.temp-gauge-label { font-size: 0.9em; color: #ccc; }
|
|
.temp-gauge-value { font-size: 2.3em; font-weight: 700; line-height: 1; }
|
|
.temp-gauge-target { font-size: 0.95em; color: #aaa; }
|
|
|
|
.temp-rail-card { display: flex; align-items: center; gap: 12px; background: rgba(0,0,0,0.2); border-radius: 10px; padding: 12px; }
|
|
.temp-rail { width: 14px; height: 120px; background: rgba(255,255,255,0.12); border-radius: 999px; position: relative; overflow: hidden; }
|
|
.temp-rail-fill { position: absolute; bottom: 0; width: 100%; height: 0%; background: var(--temp-color); transition: height 0.3s ease; }
|
|
.temp-rail-title { font-size: 0.95em; color: #ccc; }
|
|
.temp-rail-value { font-size: 2.2em; font-weight: 700; line-height: 1; }
|
|
.temp-rail-target { font-size: 0.95em; color: #aaa; }
|
|
.temp-rail-info { display: flex; flex-direction: column; gap: 6px; }
|
|
|
|
.temp-action { margin-top: 10px; }
|
|
.temp-action-button {
|
|
width: 100%;
|
|
max-width: none;
|
|
background: var(--accent-color, #d89904);
|
|
color: #000000;
|
|
border: none;
|
|
border-radius: 30px;
|
|
padding: 10px 18px;
|
|
font-size: 1em;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
text-align: center;
|
|
box-shadow: 0 4px 12px rgba(216, 153, 4, 0.3);
|
|
}
|
|
.temp-action-button:hover { opacity: 0.85; transform: translateY(-2px); }
|
|
.temp-action-button.active {
|
|
background-color: #dc3545;
|
|
color: #ffffff;
|
|
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3);
|
|
}
|
|
.temp-action-button.active:hover { background-color: #c82333; }
|
|
|
|
/* Statusanzeige */
|
|
.status-indicator { text-align: center; background: rgba(0,0,0,0.2); padding: 10px; border-radius: 8px; }
|
|
.status-indicator span { font-size: 1.2em; font-weight: 500; color: #eee; }
|
|
.status-indicator .error { color: #FFCC00; font-weight: bold;}
|
|
|
|
.case-temp-display,
|
|
.scale-display { text-align: center; background: rgba(0,0,0,0.2); padding: 8px; border-radius: 8px; color: #ddd; }
|
|
.case-temp-display .label,
|
|
.scale-display .label { color: #ccc; margin-right: 6px; }
|
|
.case-temp-display .value,
|
|
.scale-display .value { font-weight: 600; }
|
|
|
|
/* Shot-Anzeige */
|
|
.shot-info { text-align: center; background: rgba(0,0,0,0.2); padding: 15px; border-radius: 8px; display: none; }
|
|
.shot-info h3 { margin: 0 0 8px 0; font-size: 1.1em; color: #ccc; font-weight: 500; }
|
|
.shot-timer-display { font-size: 2.8em; font-weight: bold; color: var(--accent-color); line-height: 1; }
|
|
.shot-weight-display { font-size: 1.5em; color: #ddd; margin-top: 8px; }
|
|
|
|
/* Control Panel Allgemein */
|
|
.control-panel h3 { margin: 0 0 6px 0; font-size: 1.2em; color: #eee; font-weight: 600; border-bottom: 1px solid rgba(255, 255, 255, 0.2); padding-bottom: 6px; }
|
|
.control-group { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
.control-group label { flex-basis: 60px; font-size: 0.95em; color: #ccc; text-align: right; }
|
|
.control-group input[type="number"] { flex-grow: 1; width: auto; min-width: 80px; max-width: 100px; }
|
|
.control-group button { padding: 6px 12px; font-size: 0.9em; margin-left: 5px; }
|
|
.control-group.slider-group { flex-direction: column; align-items: stretch; gap: 6px; }
|
|
.control-group.slider-group label { flex-basis: auto; text-align: left; }
|
|
.control-group.slider-group .save-button { align-self: flex-end; margin-left: 0; }
|
|
.slider-header { display: flex; align-items: baseline; justify-content: space-between; }
|
|
.slider-value { color: #ddd; font-weight: 600; }
|
|
.control-panel input[type="range"] { width: 100%; accent-color: var(--accent-color, #d89904); }
|
|
.control-panel .toggle-switch-container { justify-content: space-between; margin-bottom: 0; min-height: 26px; gap: 6px; }
|
|
.control-panel .toggle-switch-label-text { flex-grow: 0; font-size: 0.92em; line-height: 1.2; }
|
|
.control-panel select { width: 100%; background: rgba(0, 0, 0, 0.4); color: #fff; border: 1px solid #555; border-radius: 8px; padding: 10px; }
|
|
.control-panel .select-label { display: block; font-size: 0.9em; color: #ccc; margin-bottom: 4px; }
|
|
.control-panel button { max-width: none; }
|
|
|
|
/* Allgemeiner Action Button Style (Standard: Grau) */
|
|
.control-panel .action-button {
|
|
width: 100%; margin-top: 10px; background-color: #444; color: #eee;
|
|
padding: 8px 12px; border: none; border-radius: 20px;
|
|
font-size: 0.9em; font-weight: 600; cursor: pointer; transition: all 0.2s ease;
|
|
text-align: center;
|
|
}
|
|
.control-panel .action-button:hover:not(:disabled) { background-color: #555; transform: translateY(-1px); }
|
|
.control-panel .action-button:disabled { background-color: #333; color: #777; cursor: not-allowed; transform: none; box-shadow: none; opacity: 0.6; }
|
|
|
|
/* Style für Buttons die die Akzentfarbe haben sollen (wenn nicht disabled) */
|
|
.control-panel .profile-load-button:not(:disabled),
|
|
.control-panel .flush-button:not(:disabled),
|
|
.control-panel button#steam_now_button:not(:disabled),
|
|
.control-panel button#steam_heat_button:not(:disabled),
|
|
.control-panel button#tare_button:not(:disabled),
|
|
.control-panel button#eco_start_button:not(:disabled),
|
|
.control-panel button#eco_stop_button:not(:disabled),
|
|
.control-panel button#standby_toggle_button:not(:disabled) {
|
|
background: var(--accent-color, #d89904);
|
|
color: #000000;
|
|
box-shadow: 0 4px 12px rgba(216, 153, 4, 0.2);
|
|
}
|
|
/* Hover für diese Buttons (bleibt Kupfer) */
|
|
.control-panel .profile-load-button:not(:disabled):hover,
|
|
.control-panel .flush-button:not(:disabled):hover,
|
|
.control-panel button#steam_now_button:not(:disabled):hover,
|
|
.control-panel button#steam_heat_button:not(:disabled):hover,
|
|
.control-panel button#tare_button:not(:disabled):hover,
|
|
.control-panel button#eco_start_button:not(:disabled):hover,
|
|
.control-panel button#eco_stop_button:not(:disabled):hover,
|
|
.control-panel button#standby_toggle_button:not(:disabled):hover {
|
|
background: var(--accent-color, #d89904);
|
|
opacity: 0.85;
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
/* Dashboard Nachrichten */
|
|
#dashboard-message { text-align: center; padding: 8px; margin-top: 15px; border-radius: 5px; display: none; font-weight: 500;}
|
|
#dashboard-message.success { background-color: rgba(40, 167, 69, 0.2); border: 1px solid rgba(40, 167, 69, 0.4); color: #62c34b; }
|
|
#dashboard-message.error { background-color: rgba(220, 53, 69, 0.2); border: 1px solid rgba(220, 53, 69, 0.4); color: #f8d7da; }
|
|
#dashboard-message.info { background-color: rgba(0, 123, 255, 0.1); border: 1px solid rgba(0,123,255,0.3); color: #85C1E9; }
|
|
|
|
/* Responsive Anpassungen */
|
|
@media (max-width: 768px) { /*...*/ }
|
|
</style>
|
|
</head>
|
|
)rawliteral";
|
|
|
|
static const char dashboardBodyStart[] PROGMEM = R"rawliteral(
|
|
<body>
|
|
)rawliteral";
|
|
|
|
static const char dashboardContainerStart[] PROGMEM = R"rawliteral(
|
|
<div class="dashboard-container">
|
|
<div class="main-display" id="main_display" data-temp-layout="0">
|
|
<div class="temp-layout layout-classic">
|
|
<div class="temp-display temp-block" data-temp-block="water">
|
|
<h2>Wasser</h2>
|
|
<div class="temp-actual" data-temp-role="tempW_actual">--.-</div>
|
|
<div class="temp-target" data-temp-role="tempW_target">Soll: --.- °C</div>
|
|
<div class="temp-action">
|
|
<button class="action-button temp-action-button" data-action-role="shot">Bezug starten</button>
|
|
</div>
|
|
<div class="temp-action">
|
|
<button class="action-button temp-action-button flush-button" data-action-role="flush">Flush</button>
|
|
</div>
|
|
</div>
|
|
<div class="temp-display temp-block" data-temp-block="steam">
|
|
<h2>Dampf</h2>
|
|
<div class="temp-actual" data-temp-role="tempD_actual">--.-</div>
|
|
<div class="temp-target" data-temp-role="tempD_target">Soll: --.- °C</div>
|
|
<div class="temp-action">
|
|
<button class="action-button temp-action-button" data-action-role="steam">Dampf starten</button>
|
|
</div>
|
|
<div class="temp-action">
|
|
<button class="action-button temp-action-button flush-button" data-action-role="steam-flush">Dampf-Flush</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="temp-layout layout-bars">
|
|
<div class="temp-bar-row temp-block" data-temp-block="water">
|
|
<div class="temp-bar-header">
|
|
<span class="temp-bar-title">Wasser</span>
|
|
<span class="temp-bar-actual temp-actual" data-temp-role="tempW_actual">--.-</span>
|
|
</div>
|
|
<div class="temp-bar">
|
|
<div class="temp-bar-fill" data-temp-fill="water" data-temp-fill-axis="x"></div>
|
|
</div>
|
|
<div class="temp-bar-target temp-target" data-temp-role="tempW_target">Soll: --.- °C</div>
|
|
<div class="temp-action">
|
|
<button class="action-button temp-action-button" data-action-role="shot">Bezug starten</button>
|
|
</div>
|
|
<div class="temp-action">
|
|
<button class="action-button temp-action-button flush-button" data-action-role="flush">Flush</button>
|
|
</div>
|
|
</div>
|
|
<div class="temp-bar-row temp-block" data-temp-block="steam">
|
|
<div class="temp-bar-header">
|
|
<span class="temp-bar-title">Dampf</span>
|
|
<span class="temp-bar-actual temp-actual" data-temp-role="tempD_actual">--.-</span>
|
|
</div>
|
|
<div class="temp-bar">
|
|
<div class="temp-bar-fill" data-temp-fill="steam" data-temp-fill-axis="x"></div>
|
|
</div>
|
|
<div class="temp-bar-target temp-target" data-temp-role="tempD_target">Soll: --.- °C</div>
|
|
<div class="temp-action">
|
|
<button class="action-button temp-action-button" data-action-role="steam">Dampf starten</button>
|
|
</div>
|
|
<div class="temp-action">
|
|
<button class="action-button temp-action-button flush-button" data-action-role="steam-flush">Dampf-Flush</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="temp-layout layout-gauges">
|
|
<div class="temp-gauge-card temp-block" data-temp-block="water">
|
|
<div class="temp-gauge" data-temp-gauge="water">
|
|
<div class="temp-gauge-inner">
|
|
<div class="temp-gauge-label">Wasser</div>
|
|
<div class="temp-gauge-value temp-actual" data-temp-role="tempW_actual">--.-</div>
|
|
<div class="temp-gauge-target temp-target" data-temp-role="tempW_target">Soll: --.- °C</div>
|
|
</div>
|
|
</div>
|
|
<div class="temp-action">
|
|
<button class="action-button temp-action-button" data-action-role="shot">Bezug starten</button>
|
|
</div>
|
|
<div class="temp-action">
|
|
<button class="action-button temp-action-button flush-button" data-action-role="flush">Flush</button>
|
|
</div>
|
|
</div>
|
|
<div class="temp-gauge-card temp-block" data-temp-block="steam">
|
|
<div class="temp-gauge" data-temp-gauge="steam">
|
|
<div class="temp-gauge-inner">
|
|
<div class="temp-gauge-label">Dampf</div>
|
|
<div class="temp-gauge-value temp-actual" data-temp-role="tempD_actual">--.-</div>
|
|
<div class="temp-gauge-target temp-target" data-temp-role="tempD_target">Soll: --.- °C</div>
|
|
</div>
|
|
</div>
|
|
<div class="temp-action">
|
|
<button class="action-button temp-action-button" data-action-role="steam">Dampf starten</button>
|
|
</div>
|
|
<div class="temp-action">
|
|
<button class="action-button temp-action-button flush-button" data-action-role="steam-flush">Dampf-Flush</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="temp-layout layout-rails">
|
|
<div class="temp-rail-card temp-block" data-temp-block="water">
|
|
<div class="temp-rail">
|
|
<div class="temp-rail-fill" data-temp-fill="water" data-temp-fill-axis="y"></div>
|
|
</div>
|
|
<div class="temp-rail-info">
|
|
<div class="temp-rail-title">Wasser</div>
|
|
<div class="temp-rail-value temp-actual" data-temp-role="tempW_actual">--.-</div>
|
|
<div class="temp-rail-target temp-target" data-temp-role="tempW_target">Soll: --.- °C</div>
|
|
<div class="temp-action">
|
|
<button class="action-button temp-action-button" data-action-role="shot">Bezug starten</button>
|
|
</div>
|
|
<div class="temp-action">
|
|
<button class="action-button temp-action-button flush-button" data-action-role="flush">Flush</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="temp-rail-card temp-block" data-temp-block="steam">
|
|
<div class="temp-rail">
|
|
<div class="temp-rail-fill" data-temp-fill="steam" data-temp-fill-axis="y"></div>
|
|
</div>
|
|
<div class="temp-rail-info">
|
|
<div class="temp-rail-title">Dampf</div>
|
|
<div class="temp-rail-value temp-actual" data-temp-role="tempD_actual">--.-</div>
|
|
<div class="temp-rail-target temp-target" data-temp-role="tempD_target">Soll: --.- °C</div>
|
|
<div class="temp-action">
|
|
<button class="action-button temp-action-button" data-action-role="steam">Dampf starten</button>
|
|
</div>
|
|
<div class="temp-action">
|
|
<button class="action-button temp-action-button flush-button" data-action-role="steam-flush">Dampf-Flush</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="status-indicator">
|
|
Status: <span id="machine_status">Initialisiere...</span>
|
|
</div>
|
|
<div id="case_temp_display" class="case-temp-display" style="display:none;">
|
|
<span id="case_temp_label" class="label">Zusatzsensor</span>
|
|
<span id="case_temp_value" class="value">--.-</span> °C
|
|
</div>
|
|
<div id="scale_display" class="scale-display" style="display:none;">
|
|
<span class="label">Waage</span>
|
|
<span id="scale_display_value" class="value">--.- g</span>
|
|
</div>
|
|
<div id="shot-info" class="shot-info">
|
|
<h3>Bezug aktiv</h3>
|
|
<div id="shot-timer-display" class="shot-timer-display">0.0s</div>
|
|
)rawliteral";
|
|
|
|
static const char dashboardShotWeight[] PROGMEM = R"rawliteral(
|
|
<div id="shot-weight-display" class="shot-weight-display">Gewicht: --.- g</div>
|
|
)rawliteral";
|
|
|
|
// Angepasster Control Panel HTML String (Eco Toggle -> Button)
|
|
static const char dashboardControlPanelHTML[] PROGMEM = R"rawliteral(
|
|
</div> <div id="dashboard-message"></div>
|
|
</div> <div class="control-panel">
|
|
<h3>Schnelleinstellung</h3>
|
|
<div class="control-group slider-group">
|
|
<div class="slider-header">
|
|
<label for="setpointW_slider">Wasser:</label>
|
|
<span id="setpointW_value" class="slider-value">--.- °C</span>
|
|
</div>
|
|
<input type="range" id="setpointW_slider" min="0" max="135" step="1">
|
|
<button id="setpointW_save" class="save-button">OK</button>
|
|
</div>
|
|
<div class="control-group slider-group">
|
|
<div class="slider-header">
|
|
<label for="setpointD_slider">Dampf:</label>
|
|
<span id="setpointD_value" class="slider-value">--.- °C</span>
|
|
</div>
|
|
<input type="range" id="setpointD_slider" min="0" max="200" step="1">
|
|
<button id="setpointD_save" class="save-button">OK</button>
|
|
</div>
|
|
|
|
<h3>Modus</h3>
|
|
<button id="eco_start_button" class="action-button" style="display:none;" disabled>Eco-Modus aktivieren</button>
|
|
<button id="eco_stop_button" class="action-button" style="display:none;" disabled>Eco-Modus beenden</button>
|
|
<button id="standby_toggle_button" class="action-button" style="margin-top: 10px;" disabled>Standby aktivieren</button>
|
|
<button id="steam_now_button" class="action-button" style="margin-top: 10px; display:none;" disabled>Dampf sofort heizen</button>
|
|
<button id="steam_heat_button" class="action-button" style="margin-top: 10px; display:none;" disabled>Dampf-Heizung abschalten</button>
|
|
<div class="toggle-switch-container" style="margin-top: 10px;"> <span class="toggle-switch-label-text">Wartungsmodus:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="maintenance_toggle">
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
|
|
<h3>Profile</h3>
|
|
<select id="profile_select">
|
|
<option value="">Profil wählen...</option>
|
|
{PROFILE_OPTIONS} </select>
|
|
<button id="profile_load_button" class="profile-load-button action-button">Profil laden</button>
|
|
|
|
)rawliteral";
|
|
|
|
static const char dashboardBrewControlSection[] PROGMEM = R"rawliteral(
|
|
<h3>Brew Control</h3>
|
|
<div class="toggle-switch-container">
|
|
<span class="toggle-switch-label-text">Pre-Infusion:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="pi_toggle">
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<div class="toggle-switch-container">
|
|
<span class="toggle-switch-label-text">Brew-By-Time:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="bbt_toggle">
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<div class="toggle-switch-container">
|
|
<span class="toggle-switch-label-text">Brew-By-Weight:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="bbw_toggle">
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<h3>Waage</h3>
|
|
<div class="toggle-switch-container">
|
|
<span class="toggle-switch-label-text">Waage anzeigen:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="scale_mode_toggle" disabled>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<button id="tare_button" class="action-button" disabled>Waage tarieren</button>
|
|
<h3>Beleuchtung</h3>
|
|
<div class="toggle-switch-container">
|
|
<span class="toggle-switch-label-text">Licht:</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="light_toggle">
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
)rawliteral";
|
|
|
|
static const char dashboardDisplaySection[] PROGMEM = R"rawliteral(
|
|
<h3>Temperaturanzeige</h3>
|
|
<select id="temp_display_mode">
|
|
<option value="0">Klassisch</option>
|
|
<option value="1">Balken</option>
|
|
<option value="2">Tacho</option>
|
|
<option value="3">Thermo-Leisten</option>
|
|
</select>
|
|
)rawliteral";
|
|
|
|
static const char dashboardEndContainer[] PROGMEM = R"rawliteral(
|
|
</div> </div> )rawliteral";
|
|
|
|
// --- Angepasstes JavaScript (v3.10 - Shot Timer Anzeige um 3s verzögert ausblenden) ---
|
|
static const char dashboardJavaScript[] PROGMEM = R"rawliteral(
|
|
<script>
|
|
let updateInterval = 2000;
|
|
let dashboardIntervalId = null;
|
|
let shotActiveState = false;
|
|
let steamCircuitActiveState = false;
|
|
let steamHeatDisabledState = false;
|
|
let ws = null;
|
|
let wsConnected = false;
|
|
let wsReconnectTimer = null;
|
|
const wsReconnectDelay = 2000;
|
|
|
|
function startPolling() {
|
|
if (dashboardIntervalId) return;
|
|
fetchDashboardData();
|
|
dashboardIntervalId = setInterval(fetchDashboardData, updateInterval);
|
|
}
|
|
|
|
function stopPolling() {
|
|
if (!dashboardIntervalId) return;
|
|
clearInterval(dashboardIntervalId);
|
|
dashboardIntervalId = null;
|
|
}
|
|
|
|
function scheduleWsReconnect() {
|
|
if (wsReconnectTimer) return;
|
|
wsReconnectTimer = setTimeout(() => {
|
|
wsReconnectTimer = null;
|
|
initDashboardWebSocket();
|
|
}, wsReconnectDelay);
|
|
}
|
|
|
|
function initDashboardWebSocket() {
|
|
if (!('WebSocket' in window)) {
|
|
startPolling();
|
|
return;
|
|
}
|
|
const wsProtocol = (location.protocol === 'https:') ? 'wss://' : 'ws://';
|
|
const wsUrl = wsProtocol + location.host + '/ws-dashboard';
|
|
ws = new WebSocket(wsUrl);
|
|
ws.onopen = () => {
|
|
wsConnected = true;
|
|
stopPolling();
|
|
};
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
processDashboardData(data);
|
|
} catch (err) {
|
|
console.error('WebSocket JSON Fehler:', err);
|
|
}
|
|
};
|
|
ws.onclose = () => {
|
|
wsConnected = false;
|
|
startPolling();
|
|
scheduleWsReconnect();
|
|
};
|
|
ws.onerror = (event) => {
|
|
console.error('WebSocket Fehler:', event);
|
|
if (ws) {
|
|
ws.close();
|
|
}
|
|
};
|
|
}
|
|
// --- Timer Variablen ---
|
|
let jsShotStartTime = 0; // Speichert Date.now() WENN Shot beginnt (Browser-Zeit)
|
|
let shotUpdateIntervalId = null; // ID für setInterval
|
|
let hideShotInfoTimeoutId = null;// ID für setTimeout zum Ausblenden
|
|
|
|
function sendDashboardAction(action, value = null) {
|
|
const formData = new FormData();
|
|
formData.append('action', action);
|
|
if (value !== null) { formData.append('value', value); }
|
|
showDashboardMessage('Verarbeite...', 'info', 2000);
|
|
fetch('/dashboard-action', { method: 'POST', body: formData })
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
console.log('Aktion erfolgreich:', action, value); // Behalte Konsolen-Log zum Debuggen
|
|
showDashboardMessage(data.message || 'Aktion erfolgreich!', 'success');
|
|
setTimeout(fetchDashboardData, 150); // Kurze Verzögerung vor dem Neuladen
|
|
} else {
|
|
console.error('Aktion fehlgeschlagen:', action, value, data.message);
|
|
showDashboardMessage(data.message || 'Aktion fehlgeschlagen!', 'error');
|
|
setTimeout(fetchDashboardData, 1000);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Fehler bei Aktion:', action, error);
|
|
showDashboardMessage('Kommunikationsfehler!', 'error');
|
|
setTimeout(fetchDashboardData, 1000);
|
|
});
|
|
}
|
|
|
|
let messageTimeoutId = null;
|
|
function showDashboardMessage(message, type = 'info', duration = 4000) {
|
|
const msgElement = document.getElementById('dashboard-message');
|
|
if (!msgElement) return;
|
|
clearTimeout(messageTimeoutId);
|
|
msgElement.textContent = message;
|
|
msgElement.className = ''; // Reset classes
|
|
if (type === 'success') msgElement.classList.add('success');
|
|
else if (type === 'error') msgElement.classList.add('error');
|
|
else msgElement.classList.add('info');
|
|
msgElement.style.display = 'block';
|
|
messageTimeoutId = setTimeout(() => { msgElement.style.display = 'none'; }, duration);
|
|
}
|
|
|
|
// --- Timer Anzeige Logik ---
|
|
function updateShotTimerDisplay() {
|
|
// Prüft, ob die JS-Variable einen Startzeitpunkt enthält
|
|
if (jsShotStartTime > 0) {
|
|
// Berechnet Differenz zur *aktuellen Browser-Zeit*
|
|
const elapsedSeconds = (Date.now() - jsShotStartTime) / 1000;
|
|
const timerDisplay = document.getElementById('shot-timer-display');
|
|
if(timerDisplay) timerDisplay.textContent = elapsedSeconds.toFixed(1) + 's';
|
|
}
|
|
}
|
|
|
|
// --- Timer Intervall stoppen ---
|
|
function stopShotTimerInterval() {
|
|
if (shotUpdateIntervalId) {
|
|
clearInterval(shotUpdateIntervalId);
|
|
shotUpdateIntervalId = null;
|
|
}
|
|
}
|
|
|
|
function processDashboardData(data) {
|
|
// Elemente holen
|
|
const tempWActualEls = document.querySelectorAll('[data-temp-role="tempW_actual"]');
|
|
const tempWTargetEls = document.querySelectorAll('[data-temp-role="tempW_target"]');
|
|
const tempDActualEls = document.querySelectorAll('[data-temp-role="tempD_actual"]');
|
|
const tempDTargetEls = document.querySelectorAll('[data-temp-role="tempD_target"]');
|
|
const tempWFillEls = document.querySelectorAll('[data-temp-fill="water"]');
|
|
const tempDFillEls = document.querySelectorAll('[data-temp-fill="steam"]');
|
|
const tempWGaugeEls = document.querySelectorAll('[data-temp-gauge="water"]');
|
|
const tempDGaugeEls = document.querySelectorAll('[data-temp-gauge="steam"]');
|
|
const shotActionButtons = document.querySelectorAll('[data-action-role="shot"]');
|
|
const steamActionButtons = document.querySelectorAll('[data-action-role="steam"]');
|
|
const flushButtons = document.querySelectorAll('[data-action-role="flush"]');
|
|
const steamFlushButtons = document.querySelectorAll('[data-action-role="steam-flush"]');
|
|
const mainDisplay = document.getElementById('main_display');
|
|
const statusSpan = document.getElementById('machine_status');
|
|
const shotInfoDiv = document.getElementById('shot-info');
|
|
const weightDisplay = document.getElementById('shot-weight-display');
|
|
const timerDisplay = document.getElementById('shot-timer-display');
|
|
const setpointWSlider = document.getElementById('setpointW_slider');
|
|
const setpointDSlider = document.getElementById('setpointD_slider');
|
|
const setpointWValue = document.getElementById('setpointW_value');
|
|
const setpointDValue = document.getElementById('setpointD_value');
|
|
const ecoStopButton = document.getElementById('eco_stop_button');
|
|
const ecoStartButton = document.getElementById('eco_start_button');
|
|
const standbyToggleButton = document.getElementById('standby_toggle_button');
|
|
const maintToggle = document.getElementById('maintenance_toggle');
|
|
const boostWToggle = document.getElementById('boostW_toggle');
|
|
const boostDToggle = document.getElementById('boostD_toggle');
|
|
const steamNowButton = document.getElementById('steam_now_button');
|
|
const steamHeatButton = document.getElementById('steam_heat_button');
|
|
const piToggle = document.getElementById('pi_toggle');
|
|
const bbtToggle = document.getElementById('bbt_toggle');
|
|
const bbwToggle = document.getElementById('bbw_toggle');
|
|
const scaleModeToggle = document.getElementById('scale_mode_toggle');
|
|
const tareButton = document.getElementById('tare_button');
|
|
const lightToggle = document.getElementById('light_toggle');
|
|
const tempDisplaySelect = document.getElementById('temp_display_mode');
|
|
const caseTempDisplay = document.getElementById('case_temp_display');
|
|
const caseTempLabel = document.getElementById('case_temp_label');
|
|
const caseTempValue = document.getElementById('case_temp_value');
|
|
const scaleDisplay = document.getElementById('scale_display');
|
|
const scaleDisplayValue = document.getElementById('scale_display_value');
|
|
|
|
// Temperaturen anzeigen & Dampf-Sollwert
|
|
const setTextAll = (nodeList, text) => { nodeList.forEach(el => { el.textContent = text; }); };
|
|
const setStyleAll = (nodeList, prop, value) => { nodeList.forEach(el => { el.style[prop] = value; }); };
|
|
const stateClasses = ['heating', 'ready', 'eco', 'error', 'maintenance'];
|
|
const setStateClass = (nodeList, stateClass) => { nodeList.forEach(el => { stateClasses.forEach(cls => el.classList.remove(cls)); if (stateClass) el.classList.add(stateClass); }); };
|
|
const setBlockState = (blockName, stateClass) => { document.querySelectorAll(`[data-temp-block=\"${blockName}\"]`).forEach(el => { if (stateClass) { el.setAttribute('data-temp-state', stateClass); } else { el.removeAttribute('data-temp-state'); } }); };
|
|
const updateFill = (nodeList, pct) => { nodeList.forEach(el => { const axis = el.getAttribute('data-temp-fill-axis'); if (axis === 'y') { el.style.height = pct + '%'; } else { el.style.width = pct + '%'; } }); };
|
|
const updateGauge = (nodeList, pct) => { nodeList.forEach(el => { el.style.setProperty('--temp-pct', pct.toFixed(1)); }); };
|
|
|
|
if (mainDisplay && data.tempDisplayMode !== undefined) { mainDisplay.setAttribute('data-temp-layout', data.tempDisplayMode); }
|
|
if (tempDisplaySelect && document.activeElement !== tempDisplaySelect && data.tempDisplayMode !== undefined) { tempDisplaySelect.value = String(data.tempDisplayMode); }
|
|
|
|
const steamDelayActive = (data.steamDelayActiveNow === true && !data.shotActive);
|
|
setTextAll(tempWActualEls, data.tempW.toFixed(1));
|
|
setTextAll(tempWTargetEls, 'Soll: ' + data.setW.toFixed(1) + ' \u00B0C');
|
|
setTextAll(tempDActualEls, data.tempD.toFixed(1));
|
|
if (steamDelayActive) {
|
|
setTextAll(tempDTargetEls, 'Startverzögerung');
|
|
setStyleAll(tempDTargetEls, 'fontStyle', 'italic');
|
|
} else {
|
|
setTextAll(tempDTargetEls, 'Soll: ' + data.setD.toFixed(1) + ' \u00B0C');
|
|
setStyleAll(tempDTargetEls, 'fontStyle', 'normal');
|
|
}
|
|
|
|
const scaleW = (typeof data.scaleW === 'number' && data.scaleW > 0) ? data.scaleW : 1;
|
|
const scaleD = (typeof data.scaleD === 'number' && data.scaleD > 0) ? data.scaleD : 1;
|
|
const pctW = Math.max(0, Math.min(100, (data.tempW / scaleW) * 100));
|
|
const pctD = Math.max(0, Math.min(100, (data.tempD / scaleD) * 100));
|
|
updateFill(tempWFillEls, pctW);
|
|
updateFill(tempDFillEls, pctD);
|
|
updateGauge(tempWGaugeEls, pctW);
|
|
updateGauge(tempDGaugeEls, pctD);
|
|
|
|
// --- Verfeinerte Farb-Logik START (v3.8) ---
|
|
const waterReadyThreshold = 1.0;
|
|
const waterHeatThreshold = 0.5;
|
|
const steamReadyThreshold = 2.0;
|
|
const steamHeatThreshold = 1.5;
|
|
|
|
// Wasser Status
|
|
let waterStateClass = '';
|
|
if (data.errorW || data.safetyW) { waterStateClass = 'error'; }
|
|
else if (data.maintenanceActive) { waterStateClass = 'maintenance'; }
|
|
else if (data.ecoActive) { // Ist der Eco-Kühlmodus gerade aktiv?
|
|
if (Math.abs(data.tempW - data.setW) < waterReadyThreshold) { waterStateClass = 'eco'; } // Nahe Eco-Temp -> blau
|
|
else if (data.tempW > data.setW) { waterStateClass = 'eco'; } // Zeigt blau während des Abkühlens
|
|
else { waterStateClass = 'heating'; } // Zeigt orange, wenn es zur Eco-Temp aufheizt
|
|
}
|
|
else if (data.tempW < data.setW - waterHeatThreshold) { waterStateClass = 'heating'; } // Normales Heizen
|
|
else if (Math.abs(data.tempW - data.setW) < waterReadyThreshold) { waterStateClass = 'ready'; } // Normal Bereit
|
|
setStateClass(tempWActualEls, waterStateClass);
|
|
setBlockState('water', waterStateClass);
|
|
|
|
// Dampf Status
|
|
let steamStateClass = '';
|
|
if (data.errorD || data.safetyD) { steamStateClass = 'error'; }
|
|
else if (data.maintenanceActive) { steamStateClass = 'maintenance'; }
|
|
else if (data.ecoActive) { // Ist der Eco-Kühlmodus gerade aktiv?
|
|
if (Math.abs(data.tempD - data.setD) < steamReadyThreshold) { steamStateClass = 'eco'; } // Nahe Eco-Temp
|
|
else if (data.tempD > data.setD) { steamStateClass = 'eco'; } // Kühlt ab
|
|
else { steamStateClass = 'heating'; } // Heizt auf Eco-Temp
|
|
}
|
|
else if (data.steamDelayActiveNow) { steamStateClass = ''; } // Wenn Verzögerung aktiv ist -> Standardfarbe (weiß)
|
|
else if (data.tempD < data.setD - steamHeatThreshold) { steamStateClass = 'heating'; } // Normales Heizen
|
|
else if (Math.abs(data.tempD - data.setD) < steamReadyThreshold) { steamStateClass = 'ready'; } // Normal Bereit
|
|
setStateClass(tempDActualEls, steamStateClass);
|
|
setBlockState('steam', steamStateClass);
|
|
// --- Verfeinerte Farb-Logik ENDE ---
|
|
|
|
|
|
// Status Text
|
|
if (statusSpan) { statusSpan.textContent = data.statusText || 'Unbekannt'; statusSpan.classList.toggle('error', data.errorW || data.errorD || data.safetyW || data.safetyD); }
|
|
|
|
if (caseTempDisplay) {
|
|
const showCaseTemp = (data.caseTempDashboard === true);
|
|
caseTempDisplay.style.display = showCaseTemp ? 'block' : 'none';
|
|
if (showCaseTemp) {
|
|
const label = (data.caseType === 1) ? 'Tassenablage' : 'Gehäuse';
|
|
if (caseTempLabel) { caseTempLabel.textContent = label; }
|
|
if (caseTempValue) {
|
|
caseTempValue.textContent = (typeof data.caseTemp === 'number') ? data.caseTemp.toFixed(1) : '--.-';
|
|
}
|
|
}
|
|
}
|
|
if (scaleDisplay) {
|
|
const showScale = (data.scaleModeActive === true);
|
|
scaleDisplay.style.display = showScale ? 'block' : 'none';
|
|
if (showScale && scaleDisplayValue) {
|
|
if (data.scaleConnected === undefined || data.scaleConnected === false) {
|
|
scaleDisplayValue.textContent = 'Waage nicht verbunden';
|
|
} else if (typeof data.weight === 'number') {
|
|
scaleDisplayValue.textContent = data.weight.toFixed(1) + ' g';
|
|
} else {
|
|
scaleDisplayValue.textContent = '--.- g';
|
|
}
|
|
}
|
|
}
|
|
|
|
shotActiveState = data.shotActive === true;
|
|
steamCircuitActiveState = data.steamCircuitActive === true;
|
|
steamHeatDisabledState = data.steamHeatDisabled === true;
|
|
const cleaningAssistantRunning = (data.cleaningAssistantActive === true);
|
|
const updateActionButtons = (nodeList, isActive, startLabel, stopLabel) => {
|
|
nodeList.forEach(button => {
|
|
if (!button) return;
|
|
button.textContent = isActive ? stopLabel : startLabel;
|
|
button.classList.toggle('active', isActive);
|
|
});
|
|
};
|
|
updateActionButtons(shotActionButtons, shotActiveState, 'Bezug starten', 'Bezug beenden');
|
|
updateActionButtons(steamActionButtons, steamCircuitActiveState, 'Dampf starten', 'Dampf beenden');
|
|
|
|
// *** SHOT INFO & TIMER LOGIK mit 3s Verzögerung (v3.10) ***
|
|
if (shotInfoDiv) {
|
|
if (data.shotActive) { // Shot läuft laut Backend
|
|
// Lösche einen eventuell laufenden Timeout zum Ausblenden
|
|
if (hideShotInfoTimeoutId) {
|
|
clearTimeout(hideShotInfoTimeoutId);
|
|
hideShotInfoTimeoutId = null;
|
|
}
|
|
shotInfoDiv.style.display = 'block'; // Sicherstellen, dass sichtbar
|
|
|
|
const hasShotElapsed = (data.shotElapsedMs !== undefined);
|
|
if (hasShotElapsed) {
|
|
jsShotStartTime = Date.now() - data.shotElapsedMs;
|
|
} else if (jsShotStartTime === 0) {
|
|
console.log("Shot start detected by JS.");
|
|
jsShotStartTime = Date.now(); // ...merke dir die Browser-Startzeit
|
|
}
|
|
if (!shotUpdateIntervalId) {
|
|
updateShotTimerDisplay(); // Sofort anzeigen
|
|
shotUpdateIntervalId = setInterval(updateShotTimerDisplay, 100); // Starte Update-Intervall
|
|
}
|
|
// Gewichtsanzeige
|
|
if (weightDisplay && data.weight !== undefined) { weightDisplay.textContent = `Gewicht: ${data.weight.toFixed(1)} / ${data.targetWeight.toFixed(1)} g`; }
|
|
else if (weightDisplay) { weightDisplay.textContent = 'Gewicht: --.- g'; }
|
|
|
|
} else { // Shot läuft NICHT laut Backend
|
|
// Wenn der Timer im JS aber noch lief...
|
|
if (jsShotStartTime !== 0) {
|
|
console.log("Shot stop detected by JS.");
|
|
stopShotTimerInterval(); // Stoppe das Update-Intervall
|
|
|
|
// Berechne und zeige finale Dauer AN
|
|
const finalDurationSeconds = (Date.now() - jsShotStartTime) / 1000;
|
|
if(timerDisplay) {
|
|
timerDisplay.textContent = finalDurationSeconds.toFixed(1) + 's';
|
|
}
|
|
|
|
jsShotStartTime = 0; // Setze Browser-Startzeit zurück
|
|
|
|
// Setze Timeout zum Ausblenden des Bereichs in 3 Sekunden
|
|
// (Lösche evtl. alten Timeout zuerst, falls Stop-Events sehr schnell aufeinander folgen)
|
|
if (hideShotInfoTimeoutId) { clearTimeout(hideShotInfoTimeoutId); }
|
|
hideShotInfoTimeoutId = setTimeout(() => {
|
|
if(shotInfoDiv) { shotInfoDiv.style.display = 'none'; }
|
|
hideShotInfoTimeoutId = null; // ID zurücksetzen nach Ausführung
|
|
}, 3000); // 3000ms = 3 Sekunden
|
|
}
|
|
// Verstecke den Bereich NICHT sofort, das macht der Timeout
|
|
}
|
|
}
|
|
// *** ENDE SHOT INFO & TIMER LOGIK ***
|
|
|
|
|
|
// Input Felder (nur wenn nicht fokussiert)
|
|
if (setpointWSlider && document.activeElement !== setpointWSlider) setpointWSlider.value = Math.round(data.setW);
|
|
if (setpointDSlider && document.activeElement !== setpointDSlider) setpointDSlider.value = Math.round(data.setD);
|
|
if (setpointWValue && (!setpointWSlider || document.activeElement !== setpointWSlider)) setpointWValue.textContent = data.setW.toFixed(1) + ' \u00B0C';
|
|
if (setpointDValue && (!setpointDSlider || document.activeElement !== setpointDSlider)) setpointDValue.textContent = data.setD.toFixed(1) + ' \u00B0C';
|
|
|
|
// --- Update toggle states DIREKT ---
|
|
const updateToggleState = (element, dataValue) => { if (element) { element.checked = dataValue === true || dataValue === 'true'; }};
|
|
updateToggleState(maintToggle, data.maintenanceActive);
|
|
updateToggleState(boostWToggle, data.boostWActive);
|
|
updateToggleState(boostDToggle, data.boostDActive);
|
|
updateToggleState(piToggle, data.piEnabled);
|
|
updateToggleState(bbtToggle, data.bbtEnabled);
|
|
updateToggleState(bbwToggle, data.bbwEnabled);
|
|
updateToggleState(lightToggle, data.lightOn);
|
|
//BBW Toggle basierend auf Waagenstatus aktivieren/deaktivieren ***
|
|
if (bbwToggle) {
|
|
// Deaktiviere, wenn 'scaleConnected' fehlt ODER explizit false ist
|
|
bbwToggle.disabled = (data.scaleConnected === undefined || data.scaleConnected === false);
|
|
}
|
|
updateToggleState(scaleModeToggle, data.scaleModeActive);
|
|
// Waage-Modus Toggle basierend auf Waagenstatus aktivieren/deaktivieren
|
|
if (scaleModeToggle) {
|
|
scaleModeToggle.disabled = (data.scaleConnected === undefined || data.scaleConnected === false);
|
|
}
|
|
|
|
// --- Ende Toggle Update ---
|
|
|
|
// --- Update button states ---
|
|
if (ecoStartButton) {
|
|
ecoStartButton.style.display = data.ecoActive ? 'none' : 'block';
|
|
ecoStartButton.disabled = data.ecoActive || cleaningAssistantRunning;
|
|
}
|
|
if (ecoStopButton) {
|
|
ecoStopButton.style.display = data.ecoActive ? 'block' : 'none';
|
|
ecoStopButton.disabled = !data.ecoActive || data.ecoSwitchActive;
|
|
}
|
|
if (standbyToggleButton) {
|
|
standbyToggleButton.style.display = 'block';
|
|
standbyToggleButton.disabled = false;
|
|
standbyToggleButton.textContent = (data.standbyActive === true) ? 'Standby aufheben' : 'Standby aktivieren';
|
|
}
|
|
if (steamNowButton) {
|
|
const showSteamNow = (data.steamDelayActiveNow === true && !steamHeatDisabledState);
|
|
steamNowButton.style.display = showSteamNow ? 'block' : 'none';
|
|
steamNowButton.disabled = !showSteamNow || cleaningAssistantRunning;
|
|
}
|
|
if (steamHeatButton) {
|
|
const showSteamHeat = (steamHeatDisabledState || data.steamDelayActiveNow !== true);
|
|
steamHeatButton.style.display = showSteamHeat ? 'block' : 'none';
|
|
steamHeatButton.disabled = !showSteamHeat || cleaningAssistantRunning;
|
|
steamHeatButton.textContent = steamHeatDisabledState ? 'Dampf-Heizung einschalten' : 'Dampf-Heizung abschalten';
|
|
}
|
|
if (shotActionButtons) { shotActionButtons.forEach(button => { button.disabled = cleaningAssistantRunning; }); }
|
|
if (steamActionButtons) { steamActionButtons.forEach(button => { button.disabled = cleaningAssistantRunning; }); }
|
|
if (maintToggle) { maintToggle.disabled = cleaningAssistantRunning; }
|
|
if (flushButtons) { flushButtons.forEach(button => { button.disabled = data.shotActive || data.flushActive || cleaningAssistantRunning; }); }
|
|
if (steamFlushButtons) { steamFlushButtons.forEach(button => { button.disabled = (data.steamCircuitActive && !data.steamFlushActive) || data.standbyActive || cleaningAssistantRunning; }); }
|
|
if (tareButton) {
|
|
const tarePossibleType = (data.scaleType === 1 || data.scaleType === 3); // I2C oder HX711
|
|
const tareEnabled = (data.scaleEnabled === true) && tarePossibleType && (data.scaleType === 3 || data.scaleConnected === true);
|
|
tareButton.disabled = !tareEnabled;
|
|
}
|
|
}
|
|
|
|
function fetchDashboardData() {
|
|
fetch('/dashboard-data').then(response => {
|
|
if (!response.ok) { throw new Error('Network response was not ok'); } return response.json(); })
|
|
.then(data => { processDashboardData(data); })
|
|
.catch(error => { console.error('Fehler beim Abrufen der Dashboard-Daten:', error);
|
|
const statusSpan = document.getElementById('machine_status');
|
|
if(statusSpan) statusSpan.textContent = 'Fehler bei Datenabruf!'; }); }
|
|
|
|
// --- Event Listener Setup ---
|
|
document.addEventListener('DOMContentLoaded', () => { document.getElementById('setpointW_save').addEventListener('click', () => { sendDashboardAction('setTempW', document.getElementById('setpointW_slider').value); }); document.getElementById('setpointD_save').addEventListener('click', () => { sendDashboardAction('setTempD', document.getElementById('setpointD_slider').value); });
|
|
const ecoStartBtn = document.getElementById('eco_start_button');
|
|
if (ecoStartBtn) { ecoStartBtn.addEventListener('click', () => { sendDashboardAction('startEco'); }); }
|
|
const ecoStopBtn = document.getElementById('eco_stop_button');
|
|
if (ecoStopBtn) { ecoStopBtn.addEventListener('click', () => { sendDashboardAction('stopEco'); }); }
|
|
const standbyToggleBtn = document.getElementById('standby_toggle_button');
|
|
if (standbyToggleBtn) {
|
|
standbyToggleBtn.addEventListener('click', () => {
|
|
const deactivateStandby = standbyToggleBtn.textContent === 'Standby aufheben';
|
|
sendDashboardAction(deactivateStandby ? 'deactivateStandby' : 'activateStandby');
|
|
});
|
|
}
|
|
const maintToggleListener = document.getElementById('maintenance_toggle');
|
|
if (maintToggleListener) { maintToggleListener.addEventListener('change', (event) => { sendDashboardAction('toggleMaintenance', event.target.checked); }); }
|
|
const boostWToggleListener = document.getElementById('boostW_toggle');
|
|
if (boostWToggleListener) { boostWToggleListener.addEventListener('change', (event) => { sendDashboardAction('toggleBoostW', event.target.checked); }); }
|
|
const boostDToggleListener = document.getElementById('boostD_toggle');
|
|
if (boostDToggleListener) { boostDToggleListener.addEventListener('change', (event) => { sendDashboardAction('toggleBoostD', event.target.checked); }); }
|
|
const piToggle = document.getElementById('pi_toggle');
|
|
if (piToggle) { piToggle.addEventListener('change', (event) => { sendDashboardAction('togglePI', event.target.checked); }); }
|
|
const bbtToggle = document.getElementById('bbt_toggle');
|
|
if (bbtToggle) { bbtToggle.addEventListener('change', (event) => { sendDashboardAction('toggleBBT', event.target.checked); }); }
|
|
const bbwToggle = document.getElementById('bbw_toggle');
|
|
if (bbwToggle) { bbwToggle.addEventListener('change', (event) => { sendDashboardAction('toggleBBW', event.target.checked); }); } document.getElementById('profile_load_button').addEventListener('click', () => { const select = document.getElementById('profile_select');
|
|
const profileName = select.value;
|
|
if (profileName) { sendDashboardAction('loadProfile', profileName); } else { showDashboardMessage('Bitte zuerst ein Profil auswählen.', 'error'); } });
|
|
const steamBtn = document.getElementById('steam_now_button');
|
|
if (steamBtn) { steamBtn.addEventListener('click', () => { sendDashboardAction('overrideSteamDelay'); }); }
|
|
const steamHeatBtn = document.getElementById('steam_heat_button');
|
|
if (steamHeatBtn) { steamHeatBtn.addEventListener('click', () => { sendDashboardAction(steamHeatDisabledState ? 'enableSteamHeat' : 'disableSteamHeat'); }); }
|
|
const shotActionButtons = document.querySelectorAll('[data-action-role="shot"]');
|
|
shotActionButtons.forEach((button) => {
|
|
button.addEventListener('click', () => {
|
|
const isActive = button.classList.contains('active');
|
|
sendDashboardAction(isActive ? 'stopShot' : 'startShot');
|
|
});
|
|
});
|
|
const steamActionButtons = document.querySelectorAll('[data-action-role="steam"]');
|
|
steamActionButtons.forEach((button) => {
|
|
button.addEventListener('click', () => {
|
|
const isActive = button.classList.contains('active');
|
|
sendDashboardAction(isActive ? 'stopSteam' : 'startSteam');
|
|
});
|
|
});
|
|
const flushButtons = document.querySelectorAll('[data-action-role="flush"]');
|
|
flushButtons.forEach((button) => {
|
|
button.addEventListener('click', () => { sendDashboardAction('flush'); });
|
|
});
|
|
const steamFlushButtons = document.querySelectorAll('[data-action-role="steam-flush"]');
|
|
steamFlushButtons.forEach((button) => {
|
|
button.addEventListener('click', () => { sendDashboardAction('steamFlush'); });
|
|
});
|
|
const tempDisplaySelectListener = document.getElementById('temp_display_mode');
|
|
if (tempDisplaySelectListener) { tempDisplaySelectListener.addEventListener('change', (event) => { sendDashboardAction('setTempDisplayMode', event.target.value); }); }
|
|
const lightToggleListener = document.getElementById('light_toggle');
|
|
if (lightToggleListener) { lightToggleListener.addEventListener('change', (event) => { sendDashboardAction('toggleLight', event.target.checked); }); }
|
|
const tareBtn = document.getElementById('tare_button');
|
|
if (tareBtn) { tareBtn.addEventListener('click', () => { sendDashboardAction('tareScale'); }); }
|
|
const setpointWSlider = document.getElementById('setpointW_slider');
|
|
const setpointDSlider = document.getElementById('setpointD_slider');
|
|
const setpointWValue = document.getElementById('setpointW_value');
|
|
const setpointDValue = document.getElementById('setpointD_value');
|
|
if (setpointWSlider && setpointWValue) { setpointWSlider.addEventListener('input', () => { setpointWValue.textContent = parseFloat(setpointWSlider.value).toFixed(1) + ' \u00B0C'; }); }
|
|
if (setpointDSlider && setpointDValue) { setpointDSlider.addEventListener('input', () => { setpointDValue.textContent = parseFloat(setpointDSlider.value).toFixed(1) + ' \u00B0C'; }); }
|
|
startPolling(); initDashboardWebSocket(); });
|
|
const scaleModeToggleListener = document.getElementById('scale_mode_toggle');
|
|
if (scaleModeToggleListener) { scaleModeToggleListener.addEventListener('change', (event) => { sendDashboardAction('toggleScaleMode', event.target.checked); }); }
|
|
</script>
|
|
)rawliteral";
|
|
|
|
static const char dashboardBodyEnd[] PROGMEM = R"rawliteral(
|
|
</body></html>
|
|
)rawliteral";
|
|
|
|
|
|
// =============================================================================
|
|
// C++ Handler Funktionen für das Dashboard
|
|
// =============================================================================
|
|
|
|
static String urlEncode(const String& input) {
|
|
static const char hex[] = "0123456789ABCDEF";
|
|
String encoded;
|
|
encoded.reserve(input.length() * 3);
|
|
for (size_t i = 0; i < input.length(); ++i) {
|
|
const char c = input[i];
|
|
if ((c >= 'a' && c <= 'z') ||
|
|
(c >= 'A' && c <= 'Z') ||
|
|
(c >= '0' && c <= '9') ||
|
|
c == '-' || c == '_' || c == '.' || c == '~') {
|
|
encoded += c;
|
|
} else {
|
|
encoded += '%';
|
|
encoded += hex[(uint8_t)c >> 4];
|
|
encoded += hex[(uint8_t)c & 0x0F];
|
|
}
|
|
}
|
|
return encoded;
|
|
}
|
|
|
|
static String getControllerBaseUrl() {
|
|
IPAddress ip = WiFi.localIP();
|
|
if (WiFi.getMode() == WIFI_AP || WiFi.status() != WL_CONNECTED) {
|
|
ip = WiFi.softAPIP();
|
|
}
|
|
return "http://" + ip.toString();
|
|
}
|
|
|
|
static bool sendFullyKioskLoadUrl(const String& targetUrl) {
|
|
if (!fullyKioskConfig.enabled || strlen(fullyKioskConfig.host) == 0 || strlen(fullyKioskConfig.password) == 0) {
|
|
return false;
|
|
}
|
|
if (WiFi.status() != WL_CONNECTED && WiFi.getMode() != WIFI_AP) {
|
|
return false;
|
|
}
|
|
|
|
String requestUrl = "http://";
|
|
requestUrl += fullyKioskConfig.host;
|
|
requestUrl += ":";
|
|
requestUrl += String(fullyKioskConfig.port);
|
|
requestUrl += "/?cmd=loadUrl&url=";
|
|
requestUrl += urlEncode(targetUrl);
|
|
requestUrl += "&password=";
|
|
requestUrl += urlEncode(fullyKioskConfig.password);
|
|
requestUrl += "&type=json";
|
|
|
|
WiFiClient client;
|
|
HTTPClient http;
|
|
http.setConnectTimeout(fullyKioskConfig.timeoutMs);
|
|
http.setTimeout(fullyKioskConfig.timeoutMs);
|
|
if (!http.begin(client, requestUrl)) {
|
|
return false;
|
|
}
|
|
const int httpCode = http.GET();
|
|
http.end();
|
|
return httpCode > 0 && httpCode < 500;
|
|
}
|
|
|
|
void syncFullyKioskWithStandby(bool active) {
|
|
const char* path = active ? fullyKioskConfig.standbyPath : fullyKioskConfig.activePath;
|
|
String targetUrl = path;
|
|
if (!targetUrl.startsWith("http://") && !targetUrl.startsWith("https://")) {
|
|
targetUrl = getControllerBaseUrl() + targetUrl;
|
|
}
|
|
sendFullyKioskLoadUrl(targetUrl);
|
|
}
|
|
|
|
void handleClockPage(AsyncWebServerRequest *request) {
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8");
|
|
response->print(F(
|
|
"<!DOCTYPE html><html lang='de'><head><meta charset='utf-8'>"
|
|
"<meta name='viewport' content='width=device-width,initial-scale=1,viewport-fit=cover'>"
|
|
"<title>Standby</title>"
|
|
"<style>"
|
|
":root{color-scheme:dark;--bg:#080909;--panel:#151817;--text:#f3efe6;--muted:#a5aaa4;--accent:#d79922;}"
|
|
"*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;overflow:hidden;background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,Segoe UI,sans-serif;}"
|
|
"body{display:grid;place-items:center;padding:clamp(18px,4vw,48px);}"
|
|
".clock{width:min(92vw,980px);display:grid;gap:clamp(14px,3vw,28px);text-align:center;}"
|
|
".time{font-size:clamp(74px,17vw,190px);font-weight:700;line-height:.88;font-variant-numeric:tabular-nums;}"
|
|
".date{font-size:clamp(20px,4vw,42px);color:var(--muted);font-weight:500;}"
|
|
".mode{font-size:clamp(18px,3vw,28px);color:var(--accent);font-weight:650;min-height:1.3em;}"
|
|
".wake{justify-self:center;margin-top:clamp(10px,2vw,24px);border:0;border-radius:8px;background:var(--accent);color:#080909;font-size:clamp(18px,3vw,28px);font-weight:750;padding:16px 28px;min-width:min(76vw,320px);box-shadow:0 8px 24px rgba(215,153,34,.25);}"
|
|
".wake:active{transform:translateY(1px);filter:brightness(.92);}"
|
|
".wake[disabled]{opacity:.65;}"
|
|
".message{min-height:1.2em;color:var(--muted);font-size:clamp(14px,2vw,18px);}"
|
|
"@media(max-width:720px){.time{font-size:clamp(66px,24vw,130px)}}"
|
|
"</style></head><body><main class='clock'>"
|
|
"<div class='time' id='time'>--:--</div>"
|
|
"<div class='date' id='date'></div>"
|
|
"<div class='mode' id='mode'>Standby</div>"
|
|
"<button class='wake' id='wakeBtn' type='button'>Standby deaktivieren</button>"
|
|
"<div class='message' id='message'></div>"
|
|
"</main><script>"
|
|
"const fmtTime=new Intl.DateTimeFormat('de-DE',{hour:'2-digit',minute:'2-digit'});"
|
|
"const fmtDate=new Intl.DateTimeFormat('de-DE',{weekday:'long',day:'2-digit',month:'long'});"
|
|
"function tick(){const n=new Date();time.textContent=fmtTime.format(n);date.textContent=fmtDate.format(n);}"
|
|
"async function wake(){wakeBtn.disabled=true;message.textContent='Starte...';const f=new FormData();f.append('action','deactivateStandby');"
|
|
"try{const r=await fetch('/dashboard-action',{method:'POST',body:f});if(!r.ok)throw new Error();location.href='/dashboard';}"
|
|
"catch(e){message.textContent='Start fehlgeschlagen';wakeBtn.disabled=false;}}"
|
|
"wakeBtn.addEventListener('click',wake);tick();setInterval(tick,1000);"
|
|
"</script></body></html>"
|
|
));
|
|
request->send(response);
|
|
}
|
|
|
|
void handleDashboard(AsyncWebServerRequest *request) {
|
|
AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8");
|
|
|
|
response->print(FPSTR(dashboardHtmlHead));
|
|
response->print(FPSTR(commonStyle)); // Stelle sicher, dass commonStyle existiert
|
|
response->print(FPSTR(dashboardStyles)); // Enthält </head>
|
|
response->print(FPSTR(dashboardBodyStart)); // <body>
|
|
response->print(FPSTR(commonNav)); // Stelle sicher, dass commonNav existiert
|
|
|
|
// Main Display bis Ende Shot Info
|
|
response->print(FPSTR(dashboardContainerStart));
|
|
// Optional: Gewichtsanzeige im Main Display
|
|
response->print(FPSTR(dashboardShotWeight));
|
|
|
|
// Control Panel HTML holen (enthält jetzt Eco Button statt Toggle)
|
|
String controlPanelHtml = FPSTR(dashboardControlPanelHTML);
|
|
|
|
// Profil-Optionen generieren und einfügen
|
|
std::vector<String> profiles = listProfiles();
|
|
String profileOptions = "";
|
|
if (!profiles.empty()) {
|
|
for (const String& name : profiles) {
|
|
String nameHtmlEncoded = htmlEscape(name);
|
|
profileOptions += "<option value=\"" + nameHtmlEncoded + "\">" + nameHtmlEncoded + "</option>";
|
|
yield();
|
|
}
|
|
}
|
|
controlPanelHtml.replace("{PROFILE_OPTIONS}", profileOptions);
|
|
response->print(controlPanelHtml); // Sendet den Control Panel Teil BIS zum optionalen Brew Control
|
|
|
|
// Brew Control Abschnitt senden
|
|
response->print(FPSTR(dashboardBrewControlSection));
|
|
response->print(FPSTR(dashboardDisplaySection));
|
|
|
|
// Restliche Container schließen
|
|
response->print(FPSTR(dashboardEndContainer));
|
|
|
|
// JavaScript einfügen
|
|
response->print(FPSTR(dashboardJavaScript));
|
|
|
|
// Body / HTML schließen
|
|
response->print(FPSTR(dashboardBodyEnd));
|
|
|
|
request->send(response);
|
|
}
|
|
|
|
|
|
bool executeDashboardAction(const String& action, const String& value,
|
|
bool& success, String& message,
|
|
bool& settingsChanged, bool& pidNeedsUpdate) {
|
|
success = false;
|
|
message = "";
|
|
settingsChanged = false; // Flag fuer EEPROM Commit
|
|
pidNeedsUpdate = false; // Flag fuer PID Aktualisierung
|
|
|
|
|
|
// Serial.printf("Dashboard Aktion: %s, Wert: %s\n", action.c_str(), value.c_str());
|
|
|
|
if (cleaningAssistantActive &&
|
|
(action == "startEco" || action == "toggleMaintenance" || action == "startShot" || action == "stopShot" ||
|
|
action == "startSteam" || action == "stopSteam" || action == "flush" || action == "steamFlush")) {
|
|
message = "Reinigungsassistent aktiv - Aktion derzeit nicht moeglich.";
|
|
success = false;
|
|
return false;
|
|
}
|
|
|
|
// --- Temperatur-Änderungen ---
|
|
if (action == "setTempW") {
|
|
float newVal = value.toFloat();
|
|
if (newVal >= 0 && newVal <= 135) {
|
|
if (abs(newVal - SetpointWasser) > 0.01) {
|
|
SetpointWasser = newVal;
|
|
EEPROM.put(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
settingsChanged = true; pidNeedsUpdate = true;
|
|
message = "Wasser Sollwert auf " + String(newVal, 1) + " °C gesetzt.";
|
|
success = true;
|
|
} else { message = "Wasser Sollwert bereits gesetzt."; success = true; }
|
|
} else { message = "Ungültiger Wasser Sollwert."; }
|
|
} else if (action == "setTempD") {
|
|
float newVal = value.toFloat();
|
|
if (newVal >= 0 && newVal <= 200) {
|
|
if (abs(newVal - SetpointDampf) > 0.01) {
|
|
SetpointDampf = newVal;
|
|
EEPROM.put(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
settingsChanged = true; pidNeedsUpdate = true;
|
|
message = "Dampf Sollwert auf " + String(newVal, 1) + " °C gesetzt.";
|
|
success = true;
|
|
} else { message = "Dampf Sollwert bereits gesetzt."; success = true; }
|
|
} else { message = "Ungültiger Dampf Sollwert."; }
|
|
|
|
// --- Aufheizzeit (Minuten) bis "durchgewaermt" (Anzeige-Countdown am Display) ---
|
|
} else if (action == "setHeatUpMinutes") {
|
|
int newVal = value.toInt();
|
|
if (newVal >= 0 && newVal <= 60) {
|
|
if (newVal != heatUpMinutes) {
|
|
heatUpMinutes = newVal;
|
|
EEPROM.put(EEPROM_ADDR_HEATUP_MINUTES, heatUpMinutes);
|
|
EEPROM.commit(); // gezielt sofort persistieren (UART-/Button-Pfad committet nicht selbst)
|
|
settingsChanged = true;
|
|
message = "Aufheizzeit auf " + String(newVal) + " min gesetzt.";
|
|
success = true;
|
|
} else { message = "Aufheizzeit bereits gesetzt."; success = true; }
|
|
} else { message = "Ungültige Aufheizzeit."; }
|
|
|
|
// --- Eco Modus nur für diesen Zyklus beenden (Button) ---
|
|
} else if (action == "stopEco") {
|
|
// Serial.println("[Action: stopEco] Request received.");
|
|
// Nur ausführen, wenn Eco gerade aktiv kühlt
|
|
if (ecoSwitchActive) {
|
|
message = "Eco-Schalter aktiv - Stop nicht moeglich.";
|
|
success = false;
|
|
} else if (ecoModeAktiv) {
|
|
// Zeit für letzten Bezug auf jetzt setzen, um den Eco-Modus temporär zu deaktivieren
|
|
lastShotTime = millis();
|
|
// Normale Temperaturen wiederherstellen (aus EEPROM)
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
ecoForcedActive = false;
|
|
pidNeedsUpdate = true; // PID muss die neuen Setpoints bekommen
|
|
// *** KEINE ÄNDERUNG MEHR AN ecoModeMinutes oder EEPROM hier! ***
|
|
// settingsChanged = false; // Sicherstellen, dass kein unnötiger Commit erfolgt
|
|
message = "Eco Modus für diesen Zyklus beendet."; // Angepasste Meldung
|
|
success = true;
|
|
// Serial.println("[Action: stopEco] Deactivated running Eco cooling. Timer setting remains unchanged.");
|
|
} else {
|
|
message = "Eco Modus ist derzeit nicht aktiv."; // Angepasste Meldung
|
|
success = true; // Keine Aktion nötig, aber kein Fehler
|
|
// Serial.println("[Action: stopEco] Eco currently not active, no action taken.");
|
|
}
|
|
} // --- Ende stopEco ---
|
|
else if (action == "startEco") {
|
|
if (ecoSwitchActive) {
|
|
message = "Eco-Schalter aktiv.";
|
|
success = false;
|
|
} else if (wartungsModusAktiv || autoTuneWasserActive || autoTuneDampfActive) {
|
|
message = "Eco Modus im Wartungsmodus/PID-Tuning nicht moeglich.";
|
|
success = false;
|
|
} else if (ecoModeAktiv) {
|
|
message = "Eco Modus bereits aktiv.";
|
|
success = true;
|
|
} else {
|
|
ecoForcedActive = true;
|
|
ecoModeAktiv = true;
|
|
ecoModeActivatedTime = millis();
|
|
if (dynamicEcoActive) {
|
|
SetpointWasser = ecoModeTempWasser;
|
|
SetpointDampf = ecoModeTempDampf;
|
|
} else {
|
|
SetpointWasser = ecoModeTempWasser;
|
|
SetpointDampf = ecoModeTempDampf;
|
|
}
|
|
message = "Eco Modus aktiviert.";
|
|
success = true;
|
|
}
|
|
}
|
|
else if (action == "deactivateStandby") {
|
|
if (!standbyModeActive) {
|
|
message = "Standby ist bereits aufgehoben.";
|
|
success = true;
|
|
} else {
|
|
standbyModeActive = false;
|
|
startupTime = millis();
|
|
ecoForcedActive = false;
|
|
steamDelayOverridden = false;
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
if (ecoLightAutoOffEnabled) {
|
|
uint8_t storedLightOn = 0;
|
|
EEPROM.get(EEPROM_ADDR_LIGHT_ON, storedLightOn);
|
|
if (storedLightOn > 1) { storedLightOn = 0; }
|
|
lightOn = (storedLightOn == 1);
|
|
digitalWrite(SSR_LIGHT_PIN, lightOn ? HIGH : LOW);
|
|
}
|
|
ecoModeAktiv = false;
|
|
ecoModeActivatedTime = 0;
|
|
lastShotTime = millis();
|
|
message = "Standby aufgehoben.";
|
|
success = true;
|
|
}
|
|
}
|
|
else if (action == "activateStandby") {
|
|
if (standbyModeActive) {
|
|
message = "Standby ist bereits aktiv.";
|
|
success = true;
|
|
} else {
|
|
standbyModeActive = true;
|
|
ecoForcedActive = false;
|
|
steamDelayOverridden = false;
|
|
SetpointWasser = 0;
|
|
SetpointDampf = 0;
|
|
OutputWasser = 0;
|
|
OutputDampf = 0;
|
|
shotSoftwareActive = false;
|
|
steamCircuitSoftwareActive = false;
|
|
steamFlushActive = false;
|
|
steamCircuitActive = false;
|
|
if (ecoLightAutoOffEnabled && lightOn) {
|
|
lightOn = false;
|
|
digitalWrite(SSR_LIGHT_PIN, LOW);
|
|
}
|
|
if (ecoModeAktiv) {
|
|
ecoModeAktiv = false;
|
|
ecoModeActivatedTime = 0;
|
|
}
|
|
message = "Standby aktiviert.";
|
|
success = true;
|
|
}
|
|
}
|
|
else if (action == "deactivateStandbyLegacy") {
|
|
bool standbySwitchOn = (digitalRead(STANDBY_SWITCH_PIN) == STANDBY_SWITCH_ACTIVE_LEVEL);
|
|
if (!standbySwitchOn) {
|
|
message = "Standby-Schalter ist nicht aktiv.";
|
|
success = false;
|
|
} else if (standbyOverriddenByWeb) {
|
|
message = "Standby ist bereits über die WebUI aufgehoben.";
|
|
success = true;
|
|
} else {
|
|
standbyOverriddenByWeb = true;
|
|
message = "Standby über WebUI aufgehoben.";
|
|
success = true;
|
|
}
|
|
}
|
|
else if (action == "activateStandbyLegacy") {
|
|
bool standbySwitchOn = (digitalRead(STANDBY_SWITCH_PIN) == STANDBY_SWITCH_ACTIVE_LEVEL);
|
|
if (!standbySwitchOn) {
|
|
message = "Standby-Schalter ist nicht aktiv.";
|
|
success = false;
|
|
} else if (!standbyOverriddenByWeb) {
|
|
message = "Standby ist bereits aktiv.";
|
|
success = true;
|
|
} else {
|
|
standbyOverriddenByWeb = false;
|
|
message = "Standby wieder aktiviert.";
|
|
success = true;
|
|
}
|
|
}
|
|
|
|
// --- Wartungsmodus Toggle ---
|
|
else if (action == "toggleMaintenance") {
|
|
bool newState = (value == "true");
|
|
if (newState != wartungsModusAktiv) {
|
|
wartungsModusAktiv = newState;
|
|
if (!wartungsModusAktiv) { // Deaktiviert -> Normale Temps laden
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
} else { // Aktiviert -> Wartungstemps setzen
|
|
SetpointWasser = wartungsModusTemp;
|
|
SetpointDampf = wartungsModusTemp;
|
|
}
|
|
pidNeedsUpdate = true; // Weil Setpoints geändert wurden
|
|
message = String("Wartungsmodus ") + (wartungsModusAktiv ? "aktiviert." : "deaktiviert.");
|
|
success = true;
|
|
// Kein EEPROM Commit, da temporär
|
|
} else { message="Wartungsmodus unverändert."; success=true;}
|
|
|
|
// --- Heiz-Optionen (Persistent) ---
|
|
} else if (action == "toggleBoostW") {
|
|
bool newState = (value == "true");
|
|
if (newState != boostWasserActive) {
|
|
boostWasserActive = newState;
|
|
EEPROM.put(EEPROM_ADDR_BOOST_WASSER_ACTIVE, boostWasserActive);
|
|
settingsChanged = true;
|
|
message = String("Boost Wasser ") + (boostWasserActive ? "aktiviert." : "deaktiviert.");
|
|
success = true;
|
|
} else { message="Boost Wasser unverändert."; success = true; }
|
|
} else if (action == "toggleBoostD") {
|
|
bool newState = (value == "true");
|
|
if (newState != boostDampfActive) {
|
|
boostDampfActive = newState;
|
|
EEPROM.put(EEPROM_ADDR_BOOST_DAMPF_ACTIVE, boostDampfActive);
|
|
settingsChanged = true;
|
|
message = String("Boost Dampf ") + (boostDampfActive ? "aktiviert." : "deaktiviert.");
|
|
success = true;
|
|
} else { message="Boost Dampf unverändert."; success = true; }
|
|
} else if (action == "overrideSteamDelay") {
|
|
steamDelayOverridden = true; // Globale Variable setzen
|
|
message = "Dampfverzögerung temporär übersprungen.";
|
|
success = true;
|
|
// Kein EEPROM Commit, da temporär
|
|
} else if (action == "disableSteamHeat") {
|
|
if (standbyModeActive) {
|
|
message = "Standby aktiv.";
|
|
success = false;
|
|
} else if (steamHeatDisabledByUser) {
|
|
message = "Dampf-Heizung bereits abgeschaltet.";
|
|
success = true;
|
|
} else {
|
|
steamHeatDisabledByUser = true;
|
|
message = "Dampf-Heizung abgeschaltet.";
|
|
success = true;
|
|
}
|
|
} else if (action == "enableSteamHeat") {
|
|
if (standbyModeActive) {
|
|
message = "Standby aktiv.";
|
|
success = false;
|
|
} else if (!steamHeatDisabledByUser) {
|
|
message = "Dampf-Heizung bereits aktiv.";
|
|
success = true;
|
|
} else {
|
|
steamHeatDisabledByUser = false;
|
|
message = "Dampf-Heizung aktiviert.";
|
|
success = true;
|
|
}
|
|
} else if (action == "startShot") {
|
|
if (standbyModeActive) {
|
|
message = "Standby aktiv - Bezug nicht moeglich.";
|
|
success = false;
|
|
} else if (shotActive) {
|
|
message = "Bezug laeuft bereits.";
|
|
success = true;
|
|
} else if (flushActive) {
|
|
message = "Sp\xC3\xBClen laeuft - Bezug nicht moeglich.";
|
|
success = false;
|
|
} else {
|
|
shotSoftwareActive = true;
|
|
lastShotTime = millis();
|
|
message = "Bezug gestartet.";
|
|
success = true;
|
|
}
|
|
} else if (action == "stopShot") {
|
|
if (standbyModeActive) {
|
|
message = "Standby aktiv.";
|
|
success = false;
|
|
} else if (!shotActive && !shotSoftwareActive) {
|
|
message = "Kein Bezug aktiv.";
|
|
success = true;
|
|
} else {
|
|
shotSoftwareActive = false;
|
|
message = "Bezug beendet.";
|
|
success = true;
|
|
}
|
|
} else if (action == "startSteam") {
|
|
if (standbyModeActive) {
|
|
message = "Standby aktiv - Dampf nicht moeglich.";
|
|
success = false;
|
|
} else if (steamCircuitSoftwareActive) {
|
|
message = "Dampfbezug laeuft bereits.";
|
|
success = true;
|
|
} else if (steamFlushActive) {
|
|
message = "Dampf-Flush laeuft bereits.";
|
|
success = true;
|
|
} else {
|
|
steamCircuitSoftwareActive = true;
|
|
lastShotTime = millis();
|
|
message = "Dampfbezug gestartet.";
|
|
success = true;
|
|
}
|
|
} else if (action == "stopSteam") {
|
|
if (standbyModeActive) {
|
|
message = "Standby aktiv.";
|
|
success = false;
|
|
} else if (steamFlushActive) {
|
|
steamFlushActive = false;
|
|
steamCircuitActive = false;
|
|
digitalWrite(SSR_STEAM_CIRCUIT_PIN, LOW);
|
|
message = "Dampf-Flush beendet.";
|
|
success = true;
|
|
} else if (!steamCircuitSoftwareActive) {
|
|
message = "Kein Dampfbezug aktiv.";
|
|
success = true;
|
|
} else {
|
|
steamCircuitSoftwareActive = false;
|
|
message = "Dampfbezug beendet.";
|
|
success = true;
|
|
}
|
|
|
|
// --- Profil Laden ---
|
|
} else if (action == "loadProfile") {
|
|
TemperatureProfile loadedData;
|
|
String profileNameToLoad = value;
|
|
profileNameToLoad.replace(""", "\""); // Einfaches HTML Decoding
|
|
|
|
if (loadProfile(profileNameToLoad, loadedData)) {
|
|
applyProfileSettings(loadedData); // Wendet an und speichert EEPROM
|
|
message = "Profil '" + String(loadedData.profileName) + "' geladen.";
|
|
success = true;
|
|
settingsChanged = false; // Bereits in applyProfileSettings erledigt
|
|
pidNeedsUpdate = false; // Bereits in applyProfileSettings erledigt
|
|
} else {
|
|
message = "Fehler beim Laden von Profil '" + profileNameToLoad + "'.";
|
|
}
|
|
|
|
} else if (action == "setTempDisplayMode") {
|
|
int newMode = value.toInt();
|
|
if (newMode < (int)DASHBOARD_TEMP_DISPLAY_CLASSIC || newMode > (int)DASHBOARD_TEMP_DISPLAY_RAILS) {
|
|
message = "Ungueltige Anzeigeauswahl.";
|
|
} else if (dashboardTempDisplayMode != (uint8_t)newMode) {
|
|
dashboardTempDisplayMode = (uint8_t)newMode;
|
|
EEPROM.put(EEPROM_ADDR_DASHBOARD_TEMP_DISPLAY_MODE, dashboardTempDisplayMode);
|
|
settingsChanged = true;
|
|
message = "Temperaturanzeige aktualisiert.";
|
|
success = true;
|
|
} else {
|
|
message = "Temperaturanzeige unveraendert.";
|
|
success = true;
|
|
}
|
|
|
|
// --- Brew Control Toggles (Persistent) ---
|
|
} else if (action == "flush") {
|
|
if (shotActive || shotSoftwareActive) {
|
|
message = "Spülen nicht möglich: Bezug aktiv.";
|
|
success = false;
|
|
} else if (flushActive) {
|
|
message = "Spülen läuft bereits.";
|
|
success = true;
|
|
} else {
|
|
flushActive = true;
|
|
unsigned long flushDurationMs = (unsigned long)flushDurationSeconds * 1000UL;
|
|
flushEndTime = millis() + flushDurationMs;
|
|
lastShotTime = millis();
|
|
digitalWrite(PUMP_PIN, HIGH);
|
|
digitalWrite(VALVE_PIN, HIGH);
|
|
message = "Sp\xC3\xBClen gestartet (" + String((unsigned int)flushDurationSeconds) + "s).";
|
|
success = true;
|
|
}
|
|
} else if (action == "steamFlush") {
|
|
if (standbyModeActive) {
|
|
message = "Dampf-Flush nicht moeglich: Standby aktiv.";
|
|
success = false;
|
|
} else if (steamFlushActive) {
|
|
message = "Dampf-Flush laeuft bereits.";
|
|
success = true;
|
|
} else if (steamCircuitActive || steamCircuitSoftwareActive) {
|
|
message = "Dampf-Flush nicht moeglich: Dampfbezug aktiv.";
|
|
success = false;
|
|
} else {
|
|
steamFlushActive = true;
|
|
unsigned long steamFlushDurationMs = (unsigned long)steamFlushDurationSeconds * 1000UL;
|
|
steamFlushEndTime = millis() + steamFlushDurationMs;
|
|
lastShotTime = millis();
|
|
steamCircuitActive = true;
|
|
steamCircuitStartTime = millis(); // Startzeit sofort setzen, sonst traegt der direkt
|
|
// folgende State-Push noch eine veraltete steamElapsedMs
|
|
digitalWrite(SSR_STEAM_CIRCUIT_PIN, HIGH);
|
|
message = "Dampf-Flush gestartet (" + String((unsigned int)steamFlushDurationSeconds) + "s).";
|
|
success = true;
|
|
}
|
|
} else if (action == "togglePI") {
|
|
bool newState = (value == "true");
|
|
if (newState != preInfusionEnabled) {
|
|
preInfusionEnabled = newState;
|
|
EEPROM.put(EEPROM_ADDR_PREINF_ENABLED, preInfusionEnabled);
|
|
settingsChanged = true;
|
|
message = String("Pre-Infusion ") + (preInfusionEnabled ? "aktiviert." : "deaktiviert.");
|
|
success = true;
|
|
} else { message="Pre-Infusion unverändert."; success=true;}
|
|
} else if (action == "toggleBBT") {
|
|
bool newState = (value == "true");
|
|
if (newState != brewByTimeEnabled) {
|
|
brewByTimeEnabled = newState;
|
|
EEPROM.put(EEPROM_ADDR_BREWBYTIME_ENABLED, brewByTimeEnabled);
|
|
settingsChanged = true;
|
|
message = String("Brew-By-Time ") + (brewByTimeEnabled ? "aktiviert." : "deaktiviert.");
|
|
success = true;
|
|
} else { message="Brew-By-Time unverändert."; success=true;}
|
|
} else if (action == "toggleBBW") {
|
|
bool newState = (value == "true");
|
|
if (newState != brewByWeightEnabled) {
|
|
brewByWeightEnabled = newState;
|
|
EEPROM.put(EEPROM_ADDR_BREWBYWEIGHT_ENABLED, brewByWeightEnabled);
|
|
settingsChanged = true;
|
|
message = String("Brew-By-Weight ") + (brewByWeightEnabled ? "aktiviert." : "deaktiviert.");
|
|
success = true;
|
|
} else { message="Brew-By-Weight unverändert."; success=true;}
|
|
|
|
} else if (action == "toggleLight") {
|
|
bool newState = (value == "true");
|
|
if (newState != lightOn) {
|
|
lightOn = newState;
|
|
digitalWrite(SSR_LIGHT_PIN, lightOn ? HIGH : LOW);
|
|
EEPROM.put(EEPROM_ADDR_LIGHT_ON, (uint8_t)(lightOn ? 1 : 0));
|
|
settingsChanged = true;
|
|
message = String("Licht " ) + (lightOn ? "eingeschaltet." : "ausgeschaltet.");
|
|
success = true;
|
|
} else { message="Licht unveraendert."; success=true;}
|
|
|
|
// --- Waage Aktion ---
|
|
} else if (action == "tareScale") {
|
|
if (!scaleEnabled || scaleType == SCALE_NONE) {
|
|
message = "Waage deaktiviert.";
|
|
success = false;
|
|
} else if (scaleType == SCALE_ESPNOW) {
|
|
message = "Tarieren via UI nur f\xC3\xBCr I2C/HX711.";
|
|
success = false;
|
|
} else if (scaleType == SCALE_HX711) {
|
|
if (!tareScaleAfterDelay && !tareScaleSettling) {
|
|
tareScaleAfterDelay = true;
|
|
tareScaleDelayStartTime = millis();
|
|
}
|
|
message = "Tara eingeleitet.";
|
|
success = true;
|
|
} else {
|
|
if (scaleConnected) {
|
|
if (!tareScaleAfterDelay && !tareScaleSettling) {
|
|
tareScaleAfterDelay = true;
|
|
tareScaleDelayStartTime = millis();
|
|
}
|
|
message = "Tara eingeleitet.";
|
|
success = true;
|
|
} else {
|
|
message = "Waage nicht verbunden.";
|
|
success = false;
|
|
}
|
|
}
|
|
} else if (action == "toggleScaleMode") {
|
|
bool newState = (value == "true");
|
|
bool effectiveScaleModeState = newState ? (scaleModeActive || isHx711TareSequenceActive()) : scaleModeActive;
|
|
if (newState != effectiveScaleModeState) {
|
|
if (newState && scaleEnabled && scaleType == SCALE_HX711) {
|
|
requestHx711Tare(false, true);
|
|
} else {
|
|
scaleModeActive = newState;
|
|
if (scaleModeActive) {
|
|
resetScaleDisplayFilter(currentWeightReading);
|
|
}
|
|
}
|
|
// KEIN EEPROM Commit, da dies ein temporärer Modus ist
|
|
// KEIN settingsChanged = true;
|
|
message = String("Waage-Anzeige ") + (newState ? "aktiviert." : "deaktiviert.");
|
|
success = true;
|
|
// Serial.println(message); // Log-Ausgabe
|
|
} else {
|
|
message="Waage-Anzeige unverändert.";
|
|
success=true;
|
|
}
|
|
|
|
// --- Unbekannte Aktion ---
|
|
} else { message = "Unbekannte Aktion."; }
|
|
|
|
// --- Abschlussaktionen ---
|
|
if (settingsChanged) {
|
|
EEPROM.commit();
|
|
}
|
|
if (pidNeedsUpdate) {
|
|
pidWasser.SetTunings(KpWasser, KiWasser, KdWasser);
|
|
pidDampf.SetTunings(KpDampf, KiDampf, KdDampf);
|
|
// Serial.println("PID-Parameter nach Dashboard-Aktion aktualisiert.");
|
|
}
|
|
|
|
return message != "Unbekannte Aktion.";
|
|
}
|
|
|
|
void handleDashboardAction(AsyncWebServerRequest *request) {
|
|
if (!request->hasArg("action")) {
|
|
request->send(400, "application/json", "{\"success\":false, \"message\":\"Aktion fehlt!\"}");
|
|
return;
|
|
}
|
|
|
|
const String action = request->arg("action");
|
|
const String value = request->hasArg("value") ? request->arg("value") : "";
|
|
bool success = false;
|
|
String message = "";
|
|
bool settingsChanged = false;
|
|
bool pidNeedsUpdate = false;
|
|
|
|
executeDashboardAction(action, value, success, message, settingsChanged, pidNeedsUpdate);
|
|
|
|
char responseBuf[200];
|
|
message.replace("\"", "'");
|
|
snprintf(responseBuf, sizeof(responseBuf), "{\"success\":%s, \"message\":\"%s\"}",
|
|
success ? "true" : "false", message.c_str());
|
|
request->send(200, "application/json", responseBuf);
|
|
}
|
|
|
|
String jsonEscape(const String& input) {
|
|
String escaped = input;
|
|
escaped.replace("\\", "\\\\");
|
|
escaped.replace("\"", "\\\"");
|
|
escaped.replace("\r", "");
|
|
escaped.replace("\n", "\\n");
|
|
return escaped;
|
|
}
|
|
|
|
String getMachineStatusKey() {
|
|
if (wasserSensorError || dampfSensorError || wasserSafetyShutdown || dampfSafetyShutdown) {
|
|
return "error";
|
|
} else if (standbyModeActive) {
|
|
return "standby";
|
|
} else if (wartungsModusAktiv || cleaningAssistantActive) {
|
|
return "maintenance";
|
|
} else if (autoTuneWasserActive || autoTuneDampfActive) {
|
|
return "tuning";
|
|
} else if (shotActive) {
|
|
return "shot";
|
|
} else if (steamCircuitActive) {
|
|
return "steam";
|
|
} else if (flushActive || steamFlushActive) {
|
|
return "flush";
|
|
} else if (ecoModeAktiv) {
|
|
return "eco";
|
|
}
|
|
return "ready";
|
|
}
|
|
|
|
String getMachineStatusText() {
|
|
if (wasserSensorError || dampfSensorError || wasserSafetyShutdown || dampfSafetyShutdown) {
|
|
String statusText = "";
|
|
if (wasserSensorError) statusText += "Fehler Wasser-Sensor! ";
|
|
if (dampfSensorError) statusText += "Fehler Dampf-Sensor! ";
|
|
if (wasserSafetyShutdown) statusText += "Sicherheitsabsch. Wasser! ";
|
|
if (dampfSafetyShutdown) statusText += "Sicherheitsabsch. Dampf! ";
|
|
statusText.trim();
|
|
return statusText;
|
|
} else if (standbyModeActive) {
|
|
return "Standby aktiv";
|
|
} else if (wartungsModusAktiv) {
|
|
return "Wartungsmodus aktiv";
|
|
} else if (autoTuneWasserActive) {
|
|
return "PID Tuning Wasser...";
|
|
} else if (autoTuneDampfActive) {
|
|
return "PID Tuning Dampf...";
|
|
} else if (cleaningAssistantActive) {
|
|
if (cleaningAssistantWaitingForTemperature) {
|
|
return "Reinigungsassistent: Heizt auf 93 C";
|
|
}
|
|
return cleaningAssistantInBrewPhase
|
|
? ("Reinigungsassistent: Bezug " + String(cleaningAssistantCurrentCycle) + "/" + String(cleaningAssistantCycles))
|
|
: ("Reinigungsassistent: Pause " + String(cleaningAssistantCurrentCycle) + "/" + String(cleaningAssistantCycles));
|
|
} else if (shotActive) {
|
|
switch (currentPreInfusionState) {
|
|
case PI_PRE_BREW: return "Pre-Infusion...";
|
|
case PI_PAUSE: return "Pre-Infusion Pause...";
|
|
case PI_MAIN_BREW: return "Bezug aktiv...";
|
|
default: return "Bezug aktiv...";
|
|
}
|
|
} else if (steamCircuitActive) {
|
|
return steamFlushActive ? "Dampf-Flush aktiv..." : "Dampf aktiv...";
|
|
} else if (flushActive) {
|
|
return "Flush aktiv...";
|
|
} else if (ecoModeAktiv) {
|
|
return "Eco aktiv";
|
|
}
|
|
return "Bereit";
|
|
}
|
|
|
|
String buildApiStatusJson(bool success, const String& message = "", const String& action = "") {
|
|
const bool steamDelayIsCurrentlyActive = (dampfVerzoegerung > 0 &&
|
|
(millis() - startupTime < (unsigned long)dampfVerzoegerung * 60000UL) &&
|
|
!steamDelayOverridden);
|
|
const String currentTime = timeSynced ? timeClient.getFormattedTime() : "N/A";
|
|
const double displayedWaterTemp = getDisplayedWaterTemperature();
|
|
const double displayedSteamTemp = getDisplayedSteamTemperature();
|
|
String json = "{";
|
|
json += "\"success\":";
|
|
json += success ? "true" : "false";
|
|
json += ",\"action\":\"";
|
|
json += jsonEscape(action);
|
|
json += "\",\"message\":\"";
|
|
json += jsonEscape(message);
|
|
json += "\",\"status\":\"";
|
|
json += jsonEscape(getMachineStatusKey());
|
|
json += "\",\"statusText\":\"";
|
|
json += jsonEscape(getMachineStatusText());
|
|
json += "\",\"timeSynced\":";
|
|
json += timeSynced ? "true" : "false";
|
|
json += ",\"currentTime\":\"";
|
|
json += jsonEscape(currentTime);
|
|
json += "\",\"standbyActive\":";
|
|
json += standbyModeActive ? "true" : "false";
|
|
json += ",\"ecoActive\":";
|
|
json += ecoModeAktiv ? "true" : "false";
|
|
json += ",\"maintenanceActive\":";
|
|
json += wartungsModusAktiv ? "true" : "false";
|
|
json += ",\"shotActive\":";
|
|
json += shotActive ? "true" : "false";
|
|
json += ",\"steamCircuitActive\":";
|
|
json += steamCircuitActive ? "true" : "false";
|
|
json += ",\"flushActive\":";
|
|
json += flushActive ? "true" : "false";
|
|
json += ",\"steamFlushActive\":";
|
|
json += steamFlushActive ? "true" : "false";
|
|
json += ",\"steamDelayActive\":";
|
|
json += steamDelayIsCurrentlyActive ? "true" : "false";
|
|
json += ",\"steamHeatDisabled\":";
|
|
json += steamHeatDisabledByUser ? "true" : "false";
|
|
json += ",\"waterTemp\":";
|
|
json += String(displayedWaterTemp, 1);
|
|
json += ",\"waterTarget\":";
|
|
json += String(SetpointWasser, 1);
|
|
json += ",\"steamTemp\":";
|
|
json += String(displayedSteamTemp, 1);
|
|
json += ",\"steamTarget\":";
|
|
json += String(SetpointDampf, 1);
|
|
json += "}";
|
|
return json;
|
|
}
|
|
|
|
void sendApiStatusResponse(AsyncWebServerRequest *request, bool success = true, const String& message = "", const String& action = "") {
|
|
request->send(200, "application/json", buildApiStatusJson(success, message, action));
|
|
}
|
|
|
|
void handleApiStatus(AsyncWebServerRequest *request) {
|
|
sendApiStatusResponse(request, true, "", "status");
|
|
}
|
|
|
|
void handleApiStandbyOn(AsyncWebServerRequest *request) {
|
|
bool success = false;
|
|
String message = "";
|
|
bool settingsChanged = false;
|
|
bool pidNeedsUpdate = false;
|
|
executeDashboardAction("activateStandby", "", success, message, settingsChanged, pidNeedsUpdate);
|
|
sendApiStatusResponse(request, success, message, "standby_on");
|
|
}
|
|
|
|
void handleApiStandbyOff(AsyncWebServerRequest *request) {
|
|
bool success = false;
|
|
String message = "";
|
|
bool settingsChanged = false;
|
|
bool pidNeedsUpdate = false;
|
|
executeDashboardAction("deactivateStandby", "", success, message, settingsChanged, pidNeedsUpdate);
|
|
sendApiStatusResponse(request, success, message, "standby_off");
|
|
}
|
|
|
|
void handleApiStartHeating(AsyncWebServerRequest *request) {
|
|
bool success = false;
|
|
String message = "";
|
|
bool settingsChanged = false;
|
|
bool pidNeedsUpdate = false;
|
|
executeDashboardAction("deactivateStandby", "", success, message, settingsChanged, pidNeedsUpdate);
|
|
sendApiStatusResponse(request, success, message, "start");
|
|
}
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// JSON Builder fuer Dashboard-Daten (HTTP und WebSocket)
|
|
// -----------------------------------------------------------------------------
|
|
void buildDashboardJson(char *buffer, size_t bufferSize) {
|
|
char floatBuf[10];
|
|
const double displayedWaterTemp = getDisplayedWaterTemperature();
|
|
const double displayedSteamTemp = getDisplayedSteamTemperature();
|
|
|
|
// --- Status bestimmen ---
|
|
String statusText = "Bereit"; String statusKey = "ready";
|
|
|
|
if (wasserSensorError || dampfSensorError || wasserSafetyShutdown || dampfSafetyShutdown) {
|
|
statusText = "";
|
|
if (wasserSensorError) statusText += "Fehler Wasser-Sensor! ";
|
|
if (dampfSensorError) statusText += "Fehler Dampf-Sensor! ";
|
|
if (wasserSafetyShutdown) statusText += "Sicherheitsabsch. Wasser! ";
|
|
if (dampfSafetyShutdown) statusText += "Sicherheitsabsch. Dampf! ";
|
|
statusKey = "error";
|
|
} else if (standbyModeActive) {
|
|
statusText = "Standby aktiv";
|
|
statusKey = "standby";
|
|
} else if (wartungsModusAktiv) {
|
|
statusText = "Wartungsmodus aktiv";
|
|
statusKey = "maintenance";
|
|
} else if (autoTuneWasserActive) {
|
|
statusText = "PID Tuning Wasser...";
|
|
statusKey = "tuning";
|
|
} else if (autoTuneDampfActive) {
|
|
statusText = "PID Tuning Dampf...";
|
|
statusKey = "tuning";
|
|
} else if (cleaningAssistantActive) {
|
|
if (cleaningAssistantWaitingForTemperature) {
|
|
statusText = "Reinigungsassistent: Heizt auf 93 C";
|
|
} else {
|
|
statusText = cleaningAssistantInBrewPhase
|
|
? ("Reinigungsassistent: Bezug " + String(cleaningAssistantCurrentCycle) + "/" + String(cleaningAssistantCycles))
|
|
: ("Reinigungsassistent: Pause " + String(cleaningAssistantCurrentCycle) + "/" + String(cleaningAssistantCycles));
|
|
}
|
|
statusKey = "maintenance";
|
|
} else if (shotActive) {
|
|
switch (currentPreInfusionState) {
|
|
case PI_PRE_BREW: statusText = "Pre-Infusion..."; break;
|
|
case PI_PAUSE: statusText = "Pre-Infusion Pause..."; break;
|
|
case PI_MAIN_BREW:statusText = "Bezug aktiv..."; break;
|
|
default: statusText = "Bezug aktiv..."; break;
|
|
}
|
|
statusKey = "brewing";
|
|
} else if (ecoModeAktiv && ecoSwitchActive) {
|
|
statusText = dynamicEcoActive ? "Eco+ Modus aktiv (Schalter)" : "Eco Modus aktiv (Schalter)";
|
|
statusKey = "eco";
|
|
} else if (ecoModeAktiv) {
|
|
statusText = dynamicEcoActive ? "Eco+ Modus aktiv (Kühlen)" : "Eco Modus aktiv (Kühlen)";
|
|
statusKey = "eco";
|
|
} else if (fastHeatUpHeating) {
|
|
statusText = "Fast Heat-Up aktiv..."; // Logik bleibt, auch wenn Toggle weg ist
|
|
statusKey = "fastheatup";
|
|
} else if (digitalRead(SSR_WASSER_PIN) == HIGH || digitalRead(SSR_DAMPF_PIN) == HIGH) {
|
|
statusText = "Heizen...";
|
|
statusKey = "heating";
|
|
} else if (ecoModeMinutes > 0 && !ecoModeAktiv) { // Nur anzeigen wenn Timer gesetzt, aber nicht aktiv kühlt
|
|
unsigned long elapsedMs = millis() - lastShotTime;
|
|
unsigned long ecoMs = (unsigned long)ecoModeMinutes * 60UL * 1000UL;
|
|
long remainingMs = (long)ecoMs - (long)elapsedMs;
|
|
int remainingMin = 0;
|
|
if (remainingMs > 0) {
|
|
remainingMin = (int)((remainingMs + 60000UL - 1) / 60000UL); // Aufrunden auf volle Minuten
|
|
}
|
|
statusText = "Bereit (Eco-Modus in " + String(remainingMin) + "min)";
|
|
statusKey = "ready_eco_pending";
|
|
}
|
|
// Ansonsten bleibt statusText = "Bereit"
|
|
|
|
// --- Zeit holen ---
|
|
String currentTime = timeSynced ? timeClient.getFormattedTime() : "N/A";
|
|
float scaleW = (float)(maxTempWasser * 1.1);
|
|
float scaleD = (float)(maxTempDampf * 1.1);
|
|
if (!isfinite(scaleW) || scaleW <= 0.0f) { scaleW = 1.0f; }
|
|
if (!isfinite(scaleD) || scaleD <= 0.0f) { scaleD = 1.0f; }
|
|
|
|
// --- Dampfverzögerung prüfen ---
|
|
bool steamDelayIsCurrentlyActive = (dampfVerzoegerung > 0 && !steamDelayOverridden && (millis() - startupTime < (unsigned long)dampfVerzoegerung * 60000UL));
|
|
unsigned long shotElapsedMs = shotActive ? (millis() - shotStartTime) : 0;
|
|
|
|
// --- JSON zusammensetzen ---
|
|
int offset = 0; // Wichtig: offset muss hier deklariert werden!
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "{");
|
|
|
|
// Temperaturen
|
|
dtostrf(displayedWaterTemp, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"tempW\":%s,", floatBuf);
|
|
dtostrf(SetpointWasser, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"setW\":%s,", floatBuf);
|
|
dtostrf(displayedSteamTemp, 5, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"tempD\":%s,", floatBuf);
|
|
dtostrf(SetpointDampf, 5, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"setD\":%s,", floatBuf);
|
|
dtostrf(scaleW, 5, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"scaleW\":%s,", floatBuf);
|
|
dtostrf(scaleD, 5, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"scaleD\":%s,", floatBuf);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"tempDisplayMode\":%u,", (unsigned int)dashboardTempDisplayMode);
|
|
|
|
// Status
|
|
statusText.replace("\"", "\\\"");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"statusText\":\"%s\",", statusText.c_str());
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"statusKey\":\"%s\",", statusKey.c_str());
|
|
|
|
bool caseTempDashboardActive = (caseTempOnDashboard && caseSensorEnabled);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"caseTempDashboard\":%s,", caseTempDashboardActive ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"caseType\":%u,", (unsigned int)caseSensorType);
|
|
if (!caseTempDashboardActive || caseSensorError || isnan(InputCase)) {
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"caseTemp\":null,");
|
|
} else {
|
|
dtostrf(InputCase, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"caseTemp\":%s,", floatBuf);
|
|
}
|
|
|
|
// Shot-Daten
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"shotActive\":%s,", shotActive ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"shotStart\":%llu,", (unsigned long long)shotStartTime);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"shotElapsedMs\":%lu,", shotElapsedMs);
|
|
|
|
// Modus-Flags
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"ecoActive\":%s,", ecoModeAktiv ? "true" : "false"); // Wird für Button :disabled benötigt
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"ecoSwitchActive\":%s,", ecoSwitchActive ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"standbyActive\":%s,", standbyModeActive ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"standbySwitchActive\":%s,", standbySwitchStableOn ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"standbyOverrideActive\":false,");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"maintenanceActive\":%s,", wartungsModusAktiv ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"boostWActive\":%s,", boostWasserActive ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"boostDActive\":%s,", boostDampfActive ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"steamDelayActiveNow\":%s,", steamDelayIsCurrentlyActive ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"steamCircuitActive\":%s,", steamCircuitActive ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"steamFlushActive\":%s,", steamFlushActive ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"cleaningAssistantActive\":%s,", cleaningAssistantActive ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"steamHeatDisabled\":%s,", steamHeatDisabledByUser ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"lightOn\":%s,", lightOn ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"flushActive\":%s,", flushActive ? "true" : "false");
|
|
|
|
// Fehler-Flags
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"errorW\":%s,", wasserSensorError ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"errorD\":%s,", dampfSensorError ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"safetyW\":%s,", wasserSafetyShutdown ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"safetyD\":%s,", dampfSafetyShutdown ? "true" : "false");
|
|
|
|
// Plattformdaten
|
|
dtostrf(getWeightReadingForUi(), 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"weight\":%s,", floatBuf);
|
|
dtostrf(brewByWeightTargetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"targetWeight\":%s,", floatBuf);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"scaleEnabled\":%s,", scaleEnabled ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"scaleType\":%u,", (unsigned int)scaleType);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"scaleConnected\":%s,", scaleConnected ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"piEnabled\":%s,", preInfusionEnabled ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"bbtEnabled\":%s,", brewByTimeEnabled ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"bbwEnabled\":%s,", brewByWeightEnabled ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"sbtEnabled\":%s,", steamByTimeEnabled ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"scaleModeActive\":%s,", scaleModeActive ? "true" : "false");
|
|
dtostrf(brewByTimeTargetSeconds, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"bbtSecs\":%s,", floatBuf);
|
|
dtostrf(preInfusionDurationSeconds, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"piDurSecs\":%s,", floatBuf);
|
|
dtostrf(preInfusionPauseSeconds, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"piPauseSecs\":%s,", floatBuf);
|
|
dtostrf(brewByWeightTargetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"bbwTarget\":%s,", floatBuf);
|
|
dtostrf(brewByWeightOffsetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"bbwOffset\":%s,", floatBuf);
|
|
dtostrf(steamByTimeTargetSeconds, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"sbtSecs\":%s,", floatBuf);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"caseSensorEnabled\":%s,", caseSensorEnabled ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"caseSensorType\":%u,", (unsigned int)caseSensorType);
|
|
dtostrf(OffsetCase, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"caseOffset\":%s,", floatBuf);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"caseTempDashboardSetting\":%s,", caseTempOnDashboard ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"caseTempDisplaySetting\":%s,", caseTempOnDisplay ? "true" : "false");
|
|
dtostrf(hx711CalibrationFactor, 6, 2, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"hx711CalFactor\":%s,", floatBuf);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"hx711DisplaySmoothing\":%s,", hx711DisplaySmoothingEnabled ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"xSwitchAction\":%u,", (unsigned int)xSwitchAction);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"xSwitchLongAction\":%u,", (unsigned int)xSwitchLongAction);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"piezoEnabled\":%s,", piezoEnabled ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"maintenanceInterval\":%d,", maintenanceInterval);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"maintenanceIntervalCounter\":%lu,", maintenanceIntervalCounter);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"flushDurationSeconds\":%u,", (unsigned int)flushDurationSeconds);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"steamFlushDurationSeconds\":%u,", (unsigned int)steamFlushDurationSeconds);
|
|
|
|
// Letztes Element ohne Komma
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"currentTime\":\"%s\"", currentTime.c_str());
|
|
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "}");
|
|
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Handler fuer Dashboard-Daten (GET /dashboard-data) - v3.6 Korrigierte #ifdef
|
|
// -----------------------------------------------------------------------------
|
|
void handleDashboardData(AsyncWebServerRequest *request) {
|
|
char buffer[DASHBOARD_JSON_BUFFER_SIZE];
|
|
buildDashboardJson(buffer, sizeof(buffer));
|
|
request->send(200, "application/json", buffer);
|
|
}
|
|
|
|
void onDashboardWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client,
|
|
AwsEventType type, void *arg, uint8_t *data, size_t len) {
|
|
if (type == WS_EVT_CONNECT) {
|
|
char buffer[DASHBOARD_JSON_BUFFER_SIZE];
|
|
buildDashboardJson(buffer, sizeof(buffer));
|
|
client->text(buffer);
|
|
}
|
|
}
|
|
|
|
void dashboardWsTick() {
|
|
if (dashboardWs.count() == 0) {
|
|
return;
|
|
}
|
|
|
|
unsigned long now = millis();
|
|
static bool lastShotActive = false;
|
|
static bool lastFlushActive = false;
|
|
static unsigned long burstUntil = 0;
|
|
bool forcePush = false;
|
|
bool flushActiveNow = flushActive;
|
|
|
|
if (shotActive && !lastShotActive) {
|
|
forcePush = true;
|
|
} else if (!shotActive && lastShotActive) {
|
|
burstUntil = now + DASHBOARD_WS_SHOT_END_GRACE_MS;
|
|
forcePush = true;
|
|
}
|
|
lastShotActive = shotActive;
|
|
|
|
if (flushActiveNow && !lastFlushActive) {
|
|
forcePush = true;
|
|
} else if (!flushActiveNow && lastFlushActive) {
|
|
forcePush = true;
|
|
}
|
|
lastFlushActive = flushActiveNow;
|
|
|
|
bool burstActive = shotActive || flushActiveNow || ((long)(burstUntil - now) > 0);
|
|
unsigned long interval = burstActive ? DASHBOARD_WS_INTERVAL_ACTIVE_MS : DASHBOARD_WS_INTERVAL_IDLE_MS;
|
|
if (!forcePush && (now - lastDashboardWsPushMs < interval)) {
|
|
return;
|
|
}
|
|
lastDashboardWsPushMs = now;
|
|
|
|
char buffer[DASHBOARD_JSON_BUFFER_SIZE];
|
|
buildDashboardJson(buffer, sizeof(buffer));
|
|
dashboardWs.textAll(buffer);
|
|
}
|
|
|
|
static void getTouchUartStatus(String& statusText, String& statusKey) {
|
|
statusText = "Bereit";
|
|
statusKey = "ready";
|
|
|
|
if (wasserSensorError || dampfSensorError || wasserSafetyShutdown || dampfSafetyShutdown) {
|
|
statusText = "";
|
|
if (wasserSensorError) { statusText += "Fehler Wasser-Sensor! "; }
|
|
if (dampfSensorError) { statusText += "Fehler Dampf-Sensor! "; }
|
|
if (wasserSafetyShutdown) { statusText += "Sicherheitsabsch. Wasser! "; }
|
|
if (dampfSafetyShutdown) { statusText += "Sicherheitsabsch. Dampf! "; }
|
|
statusKey = "error";
|
|
} else if (standbyModeActive) {
|
|
statusText = "Standby aktiv";
|
|
statusKey = "standby";
|
|
} else if (wartungsModusAktiv) {
|
|
statusText = "Wartungsmodus aktiv";
|
|
statusKey = "maintenance";
|
|
} else if (autoTuneWasserActive || autoTuneDampfActive) {
|
|
statusText = autoTuneWasserActive ? "PID Tuning Wasser..." : "PID Tuning Dampf...";
|
|
statusKey = "tuning";
|
|
} else if (cleaningAssistantActive) {
|
|
if (cleaningAssistantWaitingForTemperature) {
|
|
statusText = "Reinigungsassistent: Heizt auf 93 C";
|
|
} else {
|
|
statusText = cleaningAssistantInBrewPhase
|
|
? ("Reinigungsassistent: Bezug " + String(cleaningAssistantCurrentCycle) + "/" + String(cleaningAssistantCycles))
|
|
: ("Reinigungsassistent: Pause " + String(cleaningAssistantCurrentCycle) + "/" + String(cleaningAssistantCycles));
|
|
}
|
|
statusKey = "maintenance";
|
|
} else if (shotActive) {
|
|
statusText = "Bezug aktiv...";
|
|
statusKey = "brewing";
|
|
} else if (ecoModeAktiv) {
|
|
statusText = dynamicEcoActive ? "Eco+ Modus aktiv" : "Eco Modus aktiv";
|
|
statusKey = "eco";
|
|
} else if (fastHeatUpHeating) {
|
|
statusText = "Fast Heat-Up aktiv...";
|
|
statusKey = "fastheatup";
|
|
} else if (digitalRead(SSR_WASSER_PIN) == HIGH || digitalRead(SSR_DAMPF_PIN) == HIGH) {
|
|
statusText = "Heizen...";
|
|
statusKey = "heating";
|
|
} else if (ecoModeMinutes > 0) {
|
|
unsigned long elapsedMs = millis() - lastShotTime;
|
|
unsigned long ecoMs = (unsigned long)ecoModeMinutes * 60UL * 1000UL;
|
|
long remainingMs = (long)ecoMs - (long)elapsedMs;
|
|
int remainingMin = 0;
|
|
if (remainingMs > 0) {
|
|
remainingMin = (int)((remainingMs + 60000UL - 1) / 60000UL);
|
|
}
|
|
statusText = "Bereit (Eco-Modus in " + String(remainingMin) + "min)";
|
|
statusKey = "ready_eco_pending";
|
|
}
|
|
}
|
|
|
|
static void buildTouchUartStateJson(char *buffer, size_t bufferSize) {
|
|
char floatBuf[12];
|
|
String statusText;
|
|
String statusKey;
|
|
getTouchUartStatus(statusText, statusKey);
|
|
|
|
String escapedStatusText = statusText;
|
|
escapedStatusText.replace("\\", "\\\\");
|
|
escapedStatusText.replace("\"", "\\\"");
|
|
String escapedStatusKey = statusKey;
|
|
escapedStatusKey.replace("\\", "\\\\");
|
|
escapedStatusKey.replace("\"", "\\\"");
|
|
String escapedVersion = version;
|
|
escapedVersion.replace("\\", "\\\\");
|
|
escapedVersion.replace("\"", "\\\"");
|
|
|
|
bool caseTempDashboardActive = (caseTempOnDashboard && caseSensorEnabled);
|
|
unsigned long shotElapsedMs = shotActive ? (millis() - shotStartTime) : 0;
|
|
unsigned long steamElapsedMs = steamCircuitActive ? (millis() - steamCircuitStartTime) : 0;
|
|
// Wasser-Flush: verstrichene Zeit aus flushEndTime ableiten (fuer rueckwirkende
|
|
// Timer-Kopplung am Display, analog zu shotElapsedMs).
|
|
unsigned long flushElapsedMs = 0;
|
|
if (flushActive) {
|
|
unsigned long flushDurationMs = (unsigned long)flushDurationSeconds * 1000UL;
|
|
unsigned long flushRemainingMs = ((long)(flushEndTime - millis()) > 0) ? (flushEndTime - millis()) : 0;
|
|
flushElapsedMs = (flushRemainingMs < flushDurationMs) ? (flushDurationMs - flushRemainingMs) : 0;
|
|
}
|
|
// Dampf-Flush: verstrichene Zeit aus steamFlushEndTime ableiten (zuverlaessig im Handler
|
|
// gesetzt) - NICHT aus steamElapsedMs/steamCircuitStartTime (race-anfaellig beim Flush-Start).
|
|
unsigned long steamFlushElapsedMs = 0;
|
|
if (steamFlushActive) {
|
|
unsigned long sfDurationMs = (unsigned long)steamFlushDurationSeconds * 1000UL;
|
|
unsigned long sfRemainingMs = ((long)(steamFlushEndTime - millis()) > 0) ? (steamFlushEndTime - millis()) : 0;
|
|
steamFlushElapsedMs = (sfRemainingMs < sfDurationMs) ? (sfDurationMs - sfRemainingMs) : 0;
|
|
}
|
|
int offset = 0;
|
|
|
|
offset += snprintf(buffer + offset, bufferSize - offset,
|
|
"{\"type\":\"state\",\"protocolVersion\":%u,\"firmwareVersion\":\"%s\",",
|
|
(unsigned int)TOUCH_UART_PROTOCOL_VERSION,
|
|
escapedVersion.c_str());
|
|
|
|
dtostrf(getDisplayedWaterTemperature(), 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"tempW\":%s,", floatBuf);
|
|
dtostrf(SetpointWasser, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"setW\":%s,", floatBuf);
|
|
dtostrf(getDisplayedSteamTemperature(), 5, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"tempD\":%s,", floatBuf);
|
|
dtostrf(SetpointDampf, 5, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"setD\":%s,", floatBuf);
|
|
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"statusText\":\"%s\",", escapedStatusText.c_str());
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"statusKey\":\"%s\",", escapedStatusKey.c_str());
|
|
// Sicherheits-/Fehlerzustand pro Kreis (fuer Inline-Anzeige + Warn-Banner am Display)
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"wasserSensorError\":%s,", wasserSensorError ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"wasserSafetyShutdown\":%s,", wasserSafetyShutdown ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"dampfSensorError\":%s,", dampfSensorError ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"dampfSafetyShutdown\":%s,", dampfSafetyShutdown ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"shotActive\":%s,", shotActive ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"shotElapsedMs\":%lu,", shotElapsedMs);
|
|
// Tarier-Phase vor einem Brew-by-Weight-Bezug (Waage wird genullt, Bezug noch nicht gestartet)
|
|
bool scaleTaring = pendingShotStartAfterScaleTare && scaleEnabled && scaleType == SCALE_HX711;
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"scaleTaring\":%s,", scaleTaring ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"steamCircuitActive\":%s,", steamCircuitActive ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"steamElapsedMs\":%lu,", steamElapsedMs);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"steamFlushActive\":%s,", steamFlushActive ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"steamFlushElapsedMs\":%lu,", steamFlushElapsedMs);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"flushActive\":%s,", flushActive ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"flushElapsedMs\":%lu,", flushElapsedMs);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"ecoActive\":%s,", ecoModeAktiv ? "true" : "false");
|
|
// Countdown bis zur automatischen Eco-Aktivierung (Sekunden; -1 = kein Timer scharf)
|
|
long ecoCountdownSec = -1;
|
|
if (!standbyModeActive && !ecoSwitchActive && !ecoForcedActive && !ecoModeAktiv &&
|
|
!autoTuneWasserActive && !autoTuneDampfActive && !wartungsModusAktiv && ecoModeMinutes > 0 &&
|
|
!shotActive && !steamCircuitActive && !steamFlushActive && !flushActive && !cleaningAssistantActive) {
|
|
unsigned long elapsedMs = millis() - lastShotTime;
|
|
unsigned long totalMs = (unsigned long)ecoModeMinutes * 60UL * 1000UL;
|
|
ecoCountdownSec = (elapsedMs < totalMs) ? (long)((totalMs - elapsedMs) / 1000UL) : 0;
|
|
}
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"ecoCountdownSec\":%ld,", ecoCountdownSec);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"ecoShowInfo\":%s,", ecoInfoOnDisplay ? "true" : "false");
|
|
// Dampf-Startverzoegerung (wirksam = zeitlich aktiv UND nicht uebersteuert)
|
|
bool steamDelayActiveSys = (dampfVerzoegerung > 0 && (millis() - startupTime < (unsigned long)dampfVerzoegerung * 60000UL));
|
|
bool steamDelayEff = steamDelayActiveSys && !steamDelayOverridden;
|
|
long steamDelayRemainSec = 0;
|
|
if (steamDelayEff) {
|
|
unsigned long sdTotalMs = (unsigned long)dampfVerzoegerung * 60000UL;
|
|
unsigned long sdElapsedMs = millis() - startupTime;
|
|
steamDelayRemainSec = (sdElapsedMs < sdTotalMs) ? (long)((sdTotalMs - sdElapsedMs) / 1000UL) : 0;
|
|
}
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"steamDelayActive\":%s,", steamDelayEff ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"steamDelayRemainSec\":%ld,", steamDelayRemainSec);
|
|
// Aufheizzeit: konfigurierte Minuten + Restzeit ab Start (rein zeitbasiert, fuer Display-Countdown)
|
|
long heatUpRemainSec = -1;
|
|
if (heatUpMinutes > 0) {
|
|
unsigned long huTotalMs = (unsigned long)heatUpMinutes * 60000UL;
|
|
unsigned long huElapsedMs = millis() - startupTime;
|
|
heatUpRemainSec = (huElapsedMs < huTotalMs) ? (long)((huTotalMs - huElapsedMs) / 1000UL) : 0;
|
|
}
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"heatUpMinutes\":%d,", heatUpMinutes);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"heatUpRemainSec\":%ld,", heatUpRemainSec);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"standbyShowClock\":%s,", standbyTimeOnDisplay ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"backlightActive\":%u,", (unsigned int)backlightActivePercent);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"backlightStandbyClock\":%u,", (unsigned int)backlightStandbyClockPercent);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"standbyActive\":%s,", standbyModeActive ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"maintenanceActive\":%s,", wartungsModusAktiv ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"steamHeatDisabled\":%s,", steamHeatDisabledByUser ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"lightOn\":%s,", lightOn ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"piezoEnabled\":%s,", piezoEnabled ? "true" : "false");
|
|
|
|
dtostrf(getWeightReadingForUi(), 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"weight\":%s,", floatBuf);
|
|
dtostrf(brewByWeightTargetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"targetWeight\":%s,", floatBuf);
|
|
// Endgewicht des letzten Bezugs inkl. Offset (identisch zur OLED: Netto beim Stopp + Offset).
|
|
// Nur bei gewichtsbasiertem Bezug aussagekraeftig -> lastShotByWeight signalisiert das dem P4.
|
|
dtostrf(lastShotFinalNetWeight + brewByWeightOffsetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"lastShotWeight\":%s,", floatBuf);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"lastShotByWeight\":%s,", lastShotStoppedByWeight ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"scaleEnabled\":%s,", scaleEnabled ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"scaleType\":%u,", (unsigned int)scaleType);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"scaleConnected\":%s,", scaleConnected ? "true" : "false");
|
|
// Dynamische Flow-Rate (g/s), EMA-geglaettet - nur bei aktivem Bezug mit verbundener Waage
|
|
{
|
|
static float tuFlowLastW = 0.0f;
|
|
static unsigned long tuFlowLastMs = 0;
|
|
static bool tuFlowLastShot = false;
|
|
static float tuFlowSmooth = 0.0f;
|
|
float flowGs = 0.0f;
|
|
if (shotActive && scaleConnected) {
|
|
unsigned long nowF = millis();
|
|
if (tuFlowLastMs == 0 || !tuFlowLastShot) {
|
|
tuFlowLastMs = nowF; tuFlowLastW = currentWeightReading; tuFlowSmooth = 0.0f;
|
|
} else {
|
|
unsigned long dtF = nowF - tuFlowLastMs;
|
|
if (dtF > 0) {
|
|
float inst = (currentWeightReading - tuFlowLastW) / (float(dtF) / 1000.0f);
|
|
if (inst < 0) inst = 0.0f; // negatives Rauschen unterdruecken
|
|
tuFlowSmooth = 0.3f * inst + 0.7f * tuFlowSmooth;
|
|
tuFlowLastMs = nowF; tuFlowLastW = currentWeightReading;
|
|
}
|
|
flowGs = tuFlowSmooth;
|
|
}
|
|
} else {
|
|
tuFlowLastMs = 0; tuFlowSmooth = 0.0f;
|
|
}
|
|
tuFlowLastShot = shotActive;
|
|
dtostrf(flowGs, 4, 2, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"flowRate\":%s,", floatBuf);
|
|
}
|
|
dtostrf(hx711CalibrationFactor, 6, 2, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"hx711CalFactor\":%s,", floatBuf);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"hx711DisplaySmoothing\":%s,", hx711DisplaySmoothingEnabled ? "true" : "false");
|
|
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"piEnabled\":%s,", preInfusionEnabled ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"bbtEnabled\":%s,", brewByTimeEnabled ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"bbwEnabled\":%s,", brewByWeightEnabled ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"sbtEnabled\":%s,", steamByTimeEnabled ? "true" : "false");
|
|
dtostrf(brewByTimeTargetSeconds, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"bbtSecs\":%s,", floatBuf);
|
|
dtostrf(steamByTimeTargetSeconds, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"sbtSecs\":%s,", floatBuf);
|
|
dtostrf(preInfusionDurationSeconds, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"piDurSecs\":%s,", floatBuf);
|
|
dtostrf(preInfusionPauseSeconds, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"piPauseSecs\":%s,", floatBuf);
|
|
dtostrf(brewByWeightTargetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"bbwTarget\":%s,", floatBuf);
|
|
dtostrf(brewByWeightOffsetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"bbwOffset\":%s,", floatBuf);
|
|
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"caseSensorEnabled\":%s,", caseSensorEnabled ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"caseTempDashboard\":%s,", caseTempDashboardActive ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"caseTempDashboardSetting\":%s,", caseTempOnDashboard ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"caseTempDisplaySetting\":%s,", caseTempOnDisplay ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"caseSensorType\":%u,", (unsigned int)caseSensorType);
|
|
dtostrf(OffsetCase, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"caseOffset\":%s,", floatBuf);
|
|
// caseTemp ans Touch-Display senden, sobald der Sensor aktiv und der Wert gueltig ist
|
|
// (unabhaengig vom OLED-Schalter "im Dashboard zeigen"). Das P4 entscheidet selbst ueber die Anzeige.
|
|
if (!caseSensorEnabled || caseSensorError || isnan(InputCase)) {
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"caseTemp\":null,");
|
|
} else {
|
|
dtostrf(InputCase, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"caseTemp\":%s,", floatBuf);
|
|
}
|
|
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"maintenanceInterval\":%d,", maintenanceInterval);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"maintenanceIntervalCounter\":%lu,", maintenanceIntervalCounter);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"flushDurationSeconds\":%u,", (unsigned int)flushDurationSeconds);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"steamFlushDurationSeconds\":%u,", (unsigned int)steamFlushDurationSeconds);
|
|
// --- Reinigungsassistent (Live-Status + Parameter fuer das Display) ---
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"cleaningActive\":%s,", cleaningAssistantActive ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"cleaningWaiting\":%s,", cleaningAssistantWaitingForTemperature ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"cleaningBrewPhase\":%s,", cleaningAssistantInBrewPhase ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"cleaningCycle\":%u,", (unsigned int)cleaningAssistantCurrentCycle);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"cleaningCycles\":%u,", (unsigned int)cleaningAssistantCycles);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"cleaningBrewSeconds\":%u,", (unsigned int)cleaningAssistantBrewSeconds);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"cleaningPauseSeconds\":%u,", (unsigned int)cleaningAssistantPauseSeconds);
|
|
long cleaningPhaseRemainSec = 0;
|
|
if (cleaningAssistantActive && !cleaningAssistantWaitingForTemperature) {
|
|
unsigned long phaseDurationMs = cleaningAssistantInBrewPhase
|
|
? (unsigned long)cleaningAssistantBrewSeconds * 1000UL
|
|
: (unsigned long)cleaningAssistantPauseSeconds * 1000UL;
|
|
unsigned long elapsedMs = millis() - cleaningAssistantPhaseStartTime;
|
|
unsigned long remainingMs = (elapsedMs < phaseDurationMs) ? (phaseDurationMs - elapsedMs) : 0;
|
|
cleaningPhaseRemainSec = (long)((remainingMs + 999UL) / 1000UL);
|
|
}
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"cleaningPhaseRemainSec\":%ld,", cleaningPhaseRemainSec);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"xSwitchAction\":%u,", (unsigned int)xSwitchAction);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"xSwitchLongAction\":%u,", (unsigned int)xSwitchLongAction);
|
|
|
|
// --- v2: Heizleistung (Duty in %) aus PID-Output / Fenstergroesse ---
|
|
double dutyW = (windowSizeWasser > 0) ? (OutputWasser / (double)windowSizeWasser) * 100.0 : 0.0;
|
|
double dutyD = (windowSizeDampf > 0) ? (OutputDampf / (double)windowSizeDampf) * 100.0 : 0.0;
|
|
dtostrf(dutyW, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"dutyW\":%s,", floatBuf);
|
|
dtostrf(dutyD, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"dutyD\":%s,", floatBuf);
|
|
|
|
// --- v2: PID-Parameter (read-only) ---
|
|
dtostrf(KpWasser, 6, 2, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"kpW\":%s,", floatBuf);
|
|
dtostrf(KiWasser, 6, 2, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"kiW\":%s,", floatBuf);
|
|
dtostrf(KdWasser, 6, 2, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"kdW\":%s,", floatBuf);
|
|
dtostrf(KpDampf, 6, 2, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"kpD\":%s,", floatBuf);
|
|
dtostrf(KiDampf, 6, 2, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"kiD\":%s,", floatBuf);
|
|
dtostrf(KdDampf, 6, 2, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"kdD\":%s,", floatBuf);
|
|
|
|
// --- v2: AutoTune-Status ---
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"autoTuneW\":%s,", autoTuneWasserActive ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"autoTuneD\":%s,", autoTuneDampfActive ? "true" : "false");
|
|
// Ergebnis/Grund des letzten Laufs (0=Idle,1=laeuft,2=Erfolg,3=Failsafe,4=degeneriert,5=Sensor,6=Sicherheit,7=manuell)
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"autoTuneWStatus\":%u,", (unsigned)autoTuneWasserStatus);
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"autoTuneDStatus\":%u,", (unsigned)autoTuneDampfStatus);
|
|
|
|
// --- v2: Netzwerk (Quelle analog getControllerBaseUrl) ---
|
|
bool staConnected = (WiFi.status() == WL_CONNECTED);
|
|
bool apMode = (WiFi.getMode() == WIFI_AP);
|
|
IPAddress netIp = (staConnected && !apMode) ? WiFi.localIP() : WiFi.softAPIP();
|
|
String ssidStr = (staConnected && !apMode) ? WiFi.SSID() : WiFi.softAPSSID();
|
|
ssidStr.replace("\\", "\\\\");
|
|
ssidStr.replace("\"", "\\\"");
|
|
long rssiVal = staConnected ? WiFi.RSSI() : 0;
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"wifiConnected\":%s,", (staConnected || apMode) ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"apMode\":%s,", apMode ? "true" : "false");
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"ip\":\"%s\",", netIp.toString().c_str());
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"ssid\":\"%s\",", ssidStr.c_str());
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"rssi\":%ld,", rssiVal);
|
|
|
|
// Uhrzeit (HH:MM) nur wenn NTP synchronisiert; getLocalTime nicht-blockierend (0 ms)
|
|
char timeBuf[8] = "";
|
|
if (timeSynced) {
|
|
struct tm ti;
|
|
if (getLocalTime(&ti, 0)) strftime(timeBuf, sizeof(timeBuf), "%H:%M", &ti);
|
|
}
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "\"time\":\"%s\"", timeBuf);
|
|
|
|
offset += snprintf(buffer + offset, bufferSize - offset, "}");
|
|
}
|
|
|
|
static String jsonEscapeForUart(const String& input) {
|
|
String out;
|
|
out.reserve(input.length() + 8);
|
|
for (size_t i = 0; i < input.length(); i++) {
|
|
char c = input[i];
|
|
if (c == '\\' || c == '"') {
|
|
out += '\\';
|
|
out += c;
|
|
} else if (c == '\n') {
|
|
out += "\\n";
|
|
} else if (c == '\r') {
|
|
out += "\\r";
|
|
} else if (c == '\t') {
|
|
out += "\\t";
|
|
} else {
|
|
out += c;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
static bool jsonGetRawValue(const String& json, const char* key, String& outRaw) {
|
|
String keyToken = "\"" + String(key) + "\"";
|
|
// Echten Schluessel finden: auf "key" muss (nach optionalem Whitespace) ein ':' folgen.
|
|
// Sonst wuerde z.B. der WERT von "op":"action" faelschlich als Schluessel "action"
|
|
// erkannt -> der Parser griffe den falschen Doppelpunkt und scheiterte ("action fehlt").
|
|
int searchFrom = 0;
|
|
int colonPos = -1;
|
|
while (true) {
|
|
int keyPos = json.indexOf(keyToken, searchFrom);
|
|
if (keyPos < 0) {
|
|
return false;
|
|
}
|
|
int j = keyPos + keyToken.length();
|
|
while (j < (int)json.length() && (json[j] == ' ' || json[j] == '\t' || json[j] == '\r' || json[j] == '\n')) {
|
|
j++;
|
|
}
|
|
if (j < (int)json.length() && json[j] == ':') {
|
|
colonPos = j; // gueltiger Schluessel gefunden
|
|
break;
|
|
}
|
|
searchFrom = keyPos + keyToken.length(); // war nur ein Wert -> weitersuchen
|
|
}
|
|
|
|
int i = colonPos + 1;
|
|
while (i < (int)json.length() && (json[i] == ' ' || json[i] == '\t' || json[i] == '\r' || json[i] == '\n')) {
|
|
i++;
|
|
}
|
|
if (i >= (int)json.length()) {
|
|
return false;
|
|
}
|
|
|
|
if (json[i] == '"') {
|
|
int start = i;
|
|
i++;
|
|
bool escaped = false;
|
|
while (i < (int)json.length()) {
|
|
char c = json[i];
|
|
if (escaped) {
|
|
escaped = false;
|
|
} else if (c == '\\') {
|
|
escaped = true;
|
|
} else if (c == '"') {
|
|
outRaw = json.substring(start, i + 1);
|
|
return true;
|
|
}
|
|
i++;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
int start = i;
|
|
while (i < (int)json.length() && json[i] != ',' && json[i] != '}') {
|
|
i++;
|
|
}
|
|
outRaw = json.substring(start, i);
|
|
outRaw.trim();
|
|
return outRaw.length() > 0;
|
|
}
|
|
|
|
static bool jsonDecodeString(const String& token, String& out) {
|
|
String raw = token;
|
|
raw.trim();
|
|
if (raw.length() < 2 || raw[0] != '"' || raw[raw.length() - 1] != '"') {
|
|
return false;
|
|
}
|
|
|
|
out = "";
|
|
out.reserve(raw.length());
|
|
bool escaped = false;
|
|
for (int i = 1; i < raw.length() - 1; i++) {
|
|
char c = raw[i];
|
|
if (escaped) {
|
|
switch (c) {
|
|
case 'n': out += '\n'; break;
|
|
case 'r': out += '\r'; break;
|
|
case 't': out += '\t'; break;
|
|
case '\\': out += '\\'; break;
|
|
case '"': out += '"'; break;
|
|
default: out += c; break;
|
|
}
|
|
escaped = false;
|
|
} else if (c == '\\') {
|
|
escaped = true;
|
|
} else {
|
|
out += c;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static bool jsonGetStringValue(const String& json, const char* key, String& outValue) {
|
|
String raw;
|
|
if (!jsonGetRawValue(json, key, raw)) {
|
|
return false;
|
|
}
|
|
return jsonDecodeString(raw, outValue);
|
|
}
|
|
|
|
static bool jsonGetBoolValue(const String& json, const char* key, bool& outValue) {
|
|
String raw;
|
|
if (!jsonGetRawValue(json, key, raw)) {
|
|
return false;
|
|
}
|
|
|
|
String decoded;
|
|
if (jsonDecodeString(raw, decoded)) {
|
|
raw = decoded;
|
|
}
|
|
raw.trim();
|
|
raw.toLowerCase();
|
|
|
|
if (raw == "true" || raw == "1") {
|
|
outValue = true;
|
|
return true;
|
|
}
|
|
if (raw == "false" || raw == "0") {
|
|
outValue = false;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static bool jsonGetFloatValue(const String& json, const char* key, float& outValue) {
|
|
String raw;
|
|
if (!jsonGetRawValue(json, key, raw)) {
|
|
return false;
|
|
}
|
|
|
|
String decoded;
|
|
if (jsonDecodeString(raw, decoded)) {
|
|
raw = decoded;
|
|
}
|
|
raw.trim();
|
|
|
|
bool hasDigit = false;
|
|
for (size_t i = 0; i < raw.length(); i++) {
|
|
if (raw[i] >= '0' && raw[i] <= '9') {
|
|
hasDigit = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!hasDigit) {
|
|
return false;
|
|
}
|
|
|
|
outValue = raw.toFloat();
|
|
return true;
|
|
}
|
|
|
|
static bool jsonGetUIntValue(const String& json, const char* key, uint32_t& outValue) {
|
|
String raw;
|
|
if (!jsonGetRawValue(json, key, raw)) {
|
|
return false;
|
|
}
|
|
|
|
String decoded;
|
|
if (jsonDecodeString(raw, decoded)) {
|
|
raw = decoded;
|
|
}
|
|
raw.trim();
|
|
|
|
bool valid = raw.length() > 0;
|
|
for (size_t i = 0; i < raw.length(); i++) {
|
|
if (raw[i] < '0' || raw[i] > '9') {
|
|
valid = false;
|
|
break;
|
|
}
|
|
}
|
|
if (!valid) {
|
|
return false;
|
|
}
|
|
|
|
outValue = (uint32_t)raw.toInt();
|
|
return true;
|
|
}
|
|
|
|
static void touchUartSendLine(const String& line) {
|
|
#if TOUCH_UART_ENABLED
|
|
touchUart.println(line);
|
|
#endif
|
|
}
|
|
|
|
static void touchUartSendAck(uint32_t id, bool success, const String& message) {
|
|
String msg = message;
|
|
msg.replace("\"", "'");
|
|
String payload = "{\"type\":\"ack\",\"id\":" + String(id) +
|
|
",\"success\":" + String(success ? "true" : "false") +
|
|
",\"message\":\"" + jsonEscapeForUart(msg) + "\"}";
|
|
touchUartSendLine(payload);
|
|
}
|
|
|
|
static void touchUartSendError(uint32_t id, const String& message) {
|
|
String payload = "{\"type\":\"error\",\"id\":" + String(id) +
|
|
",\"message\":\"" + jsonEscapeForUart(message) + "\"}";
|
|
touchUartSendLine(payload);
|
|
}
|
|
|
|
static void touchUartSendState() {
|
|
char stateBuf[TOUCH_UART_JSON_BUFFER_SIZE];
|
|
buildTouchUartStateJson(stateBuf, sizeof(stateBuf));
|
|
touchUartSendLine(stateBuf);
|
|
}
|
|
|
|
static void touchUartSendProfiles(uint32_t id) {
|
|
std::vector<String> profiles = listProfiles();
|
|
String payload = "{\"type\":\"profiles\",\"id\":" + String(id) + ",\"profiles\":[";
|
|
for (size_t i = 0; i < profiles.size(); i++) {
|
|
if (i > 0) {
|
|
payload += ",";
|
|
}
|
|
payload += "\"" + jsonEscapeForUart(profiles[i]) + "\"";
|
|
}
|
|
payload += "]}";
|
|
touchUartSendLine(payload);
|
|
}
|
|
|
|
static bool touchUartHandleCommandLine(const String& line) {
|
|
uint32_t id = 0;
|
|
jsonGetUIntValue(line, "id", id);
|
|
|
|
String op;
|
|
if (!jsonGetStringValue(line, "op", op)) {
|
|
touchUartSendError(id, "op fehlt");
|
|
return false;
|
|
}
|
|
|
|
touchUartClientActive = true;
|
|
touchUartLastClientSeenMs = millis();
|
|
|
|
if (op == "ping") {
|
|
// Heartbeat: kein Ack noetig (Client-Aktiv-Status wurde oben gesetzt).
|
|
// Verhindert das stoerende "pong" auf dem Display.
|
|
// Die Firmware-Version reist am ping mit, damit die S3 sie auch dann kennt,
|
|
// wenn nach einem S3-Reboot kein erneutes hello kommt (P4 bleibt linkUp).
|
|
String clientFw;
|
|
if (jsonGetStringValue(line, "firmwareVersion", clientFw)) {
|
|
touchUartClientFirmware = clientFw;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (op == "hello") {
|
|
// Handshake: Display meldet sich (inkl. eigenem Firmware-Stand), Steuerung
|
|
// antwortet mit Versions-/Capability-Info. Den gemeldeten P4-Stand merken wir
|
|
// uns fuer die /Firmware-Web-Ansicht.
|
|
String clientFw;
|
|
if (jsonGetStringValue(line, "firmwareVersion", clientFw)) {
|
|
touchUartClientFirmware = clientFw;
|
|
}
|
|
String payload = "{\"type\":\"hello\",\"protocolVersion\":";
|
|
payload += String((unsigned int)TOUCH_UART_PROTOCOL_VERSION);
|
|
payload += ",\"firmwareVersion\":\"" + jsonEscapeForUart(version) + "\"";
|
|
payload += ",\"capabilities\":[\"state\",\"action\",\"listProfiles\",\"loadProfile\",\"saveProfile\",\"saveBrew\",\"saveService\",\"saveSensor\",\"setPid\",\"startAutotune\",\"stopAutotune\",\"getProfileDetails\",\"scanWifi\",\"setWifi\"]}";
|
|
touchUartSendLine(payload);
|
|
touchUartSendState();
|
|
touchUartSendAck(id, true, "hello");
|
|
return true;
|
|
}
|
|
|
|
if (op == "getState") {
|
|
touchUartSendState();
|
|
touchUartSendAck(id, true, "state gesendet");
|
|
return true;
|
|
}
|
|
|
|
if (op == "listProfiles") {
|
|
touchUartSendProfiles(id);
|
|
touchUartSendAck(id, true, "profile gesendet");
|
|
return true;
|
|
}
|
|
|
|
if (op == "action") {
|
|
String action;
|
|
if (!jsonGetStringValue(line, "action", action)) {
|
|
touchUartSendError(id, "action fehlt");
|
|
return false;
|
|
}
|
|
|
|
String value = "";
|
|
String rawValue;
|
|
if (jsonGetRawValue(line, "value", rawValue)) {
|
|
if (!jsonDecodeString(rawValue, value)) {
|
|
rawValue.trim();
|
|
value = rawValue;
|
|
}
|
|
}
|
|
|
|
bool success = false;
|
|
String message = "";
|
|
bool settingsChanged = false;
|
|
bool pidNeedsUpdate = false;
|
|
executeDashboardAction(action, value, success, message, settingsChanged, pidNeedsUpdate);
|
|
touchUartSendAck(id, success, message);
|
|
touchUartSendState();
|
|
return true;
|
|
}
|
|
|
|
if (op == "loadProfile") {
|
|
String profileName;
|
|
if (!jsonGetStringValue(line, "profile", profileName)) {
|
|
touchUartSendError(id, "profile fehlt");
|
|
return false;
|
|
}
|
|
|
|
bool success = false;
|
|
String message = "";
|
|
bool settingsChanged = false;
|
|
bool pidNeedsUpdate = false;
|
|
executeDashboardAction("loadProfile", profileName, success, message, settingsChanged, pidNeedsUpdate);
|
|
touchUartSendAck(id, success, message);
|
|
touchUartSendState();
|
|
return true;
|
|
}
|
|
|
|
if (op == "saveProfile") {
|
|
String profileName;
|
|
if (!jsonGetStringValue(line, "profile", profileName)) {
|
|
touchUartSendError(id, "profile fehlt");
|
|
return false;
|
|
}
|
|
|
|
profileName = normalizeProfileDisplayName(profileName);
|
|
String sanitizedName = sanitizeProfileName(profileName);
|
|
if (sanitizedName.length() == 0) {
|
|
touchUartSendAck(id, false, "Profilname ungueltig");
|
|
return true;
|
|
}
|
|
|
|
TemperatureProfile currentData = getCurrentSettingsAsProfile();
|
|
strncpy(currentData.profileName, profileName.c_str(), sizeof(currentData.profileName) - 1);
|
|
currentData.profileName[sizeof(currentData.profileName) - 1] = '\0';
|
|
|
|
bool saved = saveProfile(sanitizedName, currentData);
|
|
touchUartSendAck(id, saved, saved ? "Profil gespeichert" : "Profil konnte nicht gespeichert werden");
|
|
if (saved) {
|
|
touchUartSendState();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (op == "deleteProfile") {
|
|
String profileName;
|
|
if (!jsonGetStringValue(line, "profile", profileName)) {
|
|
touchUartSendError(id, "profile fehlt");
|
|
return false;
|
|
}
|
|
bool deleted = deleteProfile(profileName); // sanitisiert den Namen selbst
|
|
touchUartSendAck(id, deleted, deleted ? "Profil geloescht" : "Profil konnte nicht geloescht werden");
|
|
return true;
|
|
}
|
|
|
|
if (op == "saveBrew") {
|
|
BrewControlUpdate update;
|
|
|
|
bool boolVal = false;
|
|
float floatVal = 0.0f;
|
|
|
|
if (jsonGetBoolValue(line, "bbtEnabled", boolVal)) {
|
|
update.hasBBTEnabled = true;
|
|
update.bbtEnabled = boolVal;
|
|
}
|
|
if (jsonGetFloatValue(line, "bbtSecs", floatVal)) {
|
|
update.hasBBTSecs = true;
|
|
update.bbtSecs = floatVal;
|
|
}
|
|
|
|
if (jsonGetBoolValue(line, "bbwEnabled", boolVal)) {
|
|
update.hasBBWEnabled = true;
|
|
update.bbwEnabled = boolVal;
|
|
}
|
|
if (jsonGetFloatValue(line, "bbwTarget", floatVal)) {
|
|
update.hasBBWTarget = true;
|
|
update.bbwTarget = floatVal;
|
|
}
|
|
if (jsonGetFloatValue(line, "bbwOffset", floatVal)) {
|
|
update.hasBBWOffset = true;
|
|
update.bbwOffset = floatVal;
|
|
}
|
|
|
|
if (jsonGetBoolValue(line, "piEnabled", boolVal)) {
|
|
update.hasPIEnabled = true;
|
|
update.piEnabled = boolVal;
|
|
}
|
|
if (jsonGetFloatValue(line, "piDurSecs", floatVal)) {
|
|
update.hasPIDurSecs = true;
|
|
update.piDurSecs = floatVal;
|
|
}
|
|
if (jsonGetFloatValue(line, "piPauseSecs", floatVal)) {
|
|
update.hasPIPauseSecs = true;
|
|
update.piPauseSecs = floatVal;
|
|
}
|
|
if (jsonGetBoolValue(line, "sbtEnabled", boolVal)) {
|
|
update.hasSBTEnabled = true;
|
|
update.sbtEnabled = boolVal;
|
|
}
|
|
if (jsonGetFloatValue(line, "sbtSecs", floatVal)) {
|
|
update.hasSBTSecs = true;
|
|
update.sbtSecs = floatVal;
|
|
}
|
|
|
|
if (!update.hasBBTEnabled && !update.hasBBTSecs && !update.hasBBWEnabled && !update.hasBBWTarget &&
|
|
!update.hasBBWOffset && !update.hasPIEnabled && !update.hasPIDurSecs && !update.hasPIPauseSecs &&
|
|
!update.hasSBTEnabled && !update.hasSBTSecs) {
|
|
touchUartSendError(id, "Keine Brew-Felder");
|
|
return false;
|
|
}
|
|
|
|
applyBrewControlUpdate(update);
|
|
touchUartSendAck(id, true, "Brew gespeichert");
|
|
touchUartSendState();
|
|
return true;
|
|
}
|
|
|
|
if (op == "saveService") {
|
|
ServiceSettingsUpdate update;
|
|
bool boolVal = false;
|
|
uint32_t uintVal = 0;
|
|
|
|
if (jsonGetBoolValue(line, "piezoEnabled", boolVal)) {
|
|
update.hasPiezoEnabled = true;
|
|
update.piezoEnabled = boolVal;
|
|
}
|
|
if (jsonGetUIntValue(line, "maintenanceInterval", uintVal)) {
|
|
update.hasMaintenanceInterval = true;
|
|
update.maintenanceInterval = (int)uintVal;
|
|
}
|
|
if (jsonGetUIntValue(line, "flushDurationSeconds", uintVal)) {
|
|
update.hasFlushDurationSeconds = true;
|
|
update.flushDurationSeconds = (uint8_t)uintVal;
|
|
}
|
|
if (jsonGetUIntValue(line, "steamFlushDurationSeconds", uintVal)) {
|
|
update.hasSteamFlushDurationSeconds = true;
|
|
update.steamFlushDurationSeconds = (uint8_t)uintVal;
|
|
}
|
|
if (jsonGetBoolValue(line, "resetMaintenanceCounter", boolVal) && boolVal) {
|
|
update.resetMaintenanceCounter = true;
|
|
}
|
|
|
|
if (!update.hasPiezoEnabled && !update.hasMaintenanceInterval &&
|
|
!update.hasFlushDurationSeconds && !update.hasSteamFlushDurationSeconds &&
|
|
!update.resetMaintenanceCounter) {
|
|
touchUartSendError(id, "Keine Service-Felder");
|
|
return false;
|
|
}
|
|
|
|
applyServiceSettingsUpdate(update);
|
|
touchUartSendAck(id, true, "Service gespeichert");
|
|
touchUartSendState();
|
|
return true;
|
|
}
|
|
|
|
if (op == "saveDisplay") {
|
|
// Helligkeit des UART-Touch-Displays (P4): aktiv 5-100 %, Standby-Uhr 0-100 %.
|
|
uint32_t uintVal = 0;
|
|
bool changed = false;
|
|
if (jsonGetUIntValue(line, "backlightActive", uintVal)) {
|
|
int v = (int)uintVal; if (v < 5) v = 5; if (v > 100) v = 100;
|
|
backlightActivePercent = (uint8_t)v;
|
|
EEPROM.put(EEPROM_ADDR_BACKLIGHT_ACTIVE_PERCENT, backlightActivePercent);
|
|
changed = true;
|
|
}
|
|
if (jsonGetUIntValue(line, "backlightStandbyClock", uintVal)) {
|
|
int v = (int)uintVal; if (v < 0) v = 0; if (v > 100) v = 100;
|
|
backlightStandbyClockPercent = (uint8_t)v;
|
|
EEPROM.put(EEPROM_ADDR_BACKLIGHT_STANDBY_CLOCK_PERCENT, backlightStandbyClockPercent);
|
|
changed = true;
|
|
}
|
|
if (!changed) {
|
|
touchUartSendError(id, "Keine Display-Felder");
|
|
return false;
|
|
}
|
|
EEPROM.commit();
|
|
touchUartSendAck(id, true, "Display-Helligkeit gespeichert");
|
|
touchUartSendState();
|
|
return true;
|
|
}
|
|
|
|
if (op == "startCleaning") {
|
|
uint32_t uintVal = 0;
|
|
if (jsonGetUIntValue(line, "cycles", uintVal)) {
|
|
int v = (int)uintVal; if (v < 1) v = 1; if (v > 30) v = 30;
|
|
cleaningAssistantCycles = (uint8_t)v;
|
|
}
|
|
if (jsonGetUIntValue(line, "brewSeconds", uintVal)) {
|
|
int v = (int)uintVal; if (v < 1) v = 1; if (v > 30) v = 30;
|
|
cleaningAssistantBrewSeconds = (uint8_t)v;
|
|
}
|
|
if (jsonGetUIntValue(line, "pauseSeconds", uintVal)) {
|
|
int v = (int)uintVal; if (v < 1) v = 1; if (v > 60) v = 60;
|
|
cleaningAssistantPauseSeconds = (uint8_t)v;
|
|
}
|
|
String reason;
|
|
if (!canStartCleaningAssistant(reason)) {
|
|
touchUartSendAck(id, false, reason);
|
|
} else {
|
|
startCleaningAssistant();
|
|
touchUartSendAck(id, true, "Reinigungsassistent gestartet");
|
|
}
|
|
touchUartSendState();
|
|
return true;
|
|
}
|
|
|
|
if (op == "stopCleaning") {
|
|
if (cleaningAssistantActive) {
|
|
stopCleaningAssistant();
|
|
}
|
|
touchUartSendAck(id, true, "Reinigungsassistent gestoppt");
|
|
touchUartSendState();
|
|
return true;
|
|
}
|
|
|
|
if (op == "saveSensor") {
|
|
SensorSettingsUpdate update;
|
|
|
|
String section;
|
|
jsonGetStringValue(line, "section", section);
|
|
section.toLowerCase();
|
|
|
|
bool boolVal = false;
|
|
float floatVal = 0.0f;
|
|
uint32_t uintVal = 0;
|
|
|
|
bool hasCaseField = false;
|
|
if (jsonGetBoolValue(line, "caseSensorEnabled", boolVal)) {
|
|
update.hasCaseEnabled = true;
|
|
update.caseEnabled = boolVal;
|
|
hasCaseField = true;
|
|
}
|
|
if (jsonGetUIntValue(line, "caseSensorType", uintVal)) {
|
|
update.hasCaseType = true;
|
|
update.caseType = (uint8_t)uintVal;
|
|
hasCaseField = true;
|
|
}
|
|
if (jsonGetFloatValue(line, "caseOffset", floatVal)) {
|
|
update.hasCaseOffset = true;
|
|
update.caseOffset = (double)floatVal;
|
|
hasCaseField = true;
|
|
}
|
|
if (jsonGetBoolValue(line, "caseTempDashboard", boolVal)) {
|
|
update.hasCaseTempDashboard = true;
|
|
update.caseTempDashboard = boolVal;
|
|
hasCaseField = true;
|
|
}
|
|
if (jsonGetBoolValue(line, "caseTempDisplay", boolVal)) {
|
|
update.hasCaseTempDisplay = true;
|
|
update.caseTempDisplay = boolVal;
|
|
hasCaseField = true;
|
|
}
|
|
|
|
bool hasScaleField = false;
|
|
if (jsonGetBoolValue(line, "scaleEnabled", boolVal)) {
|
|
update.hasScaleEnabled = true;
|
|
update.scaleEnabled = boolVal;
|
|
hasScaleField = true;
|
|
}
|
|
if (jsonGetUIntValue(line, "scaleType", uintVal)) {
|
|
update.hasScaleType = true;
|
|
update.scaleType = (uint8_t)uintVal;
|
|
hasScaleField = true;
|
|
}
|
|
if (jsonGetFloatValue(line, "hx711CalFactor", floatVal)) {
|
|
update.hasHx711Cal = true;
|
|
update.hx711Cal = floatVal;
|
|
hasScaleField = true;
|
|
}
|
|
if (jsonGetBoolValue(line, "hx711DisplaySmoothing", boolVal) ||
|
|
jsonGetBoolValue(line, "hx711Stabilization", boolVal)) {
|
|
update.hasHx711DisplaySmoothing = true;
|
|
update.hx711DisplaySmoothing = boolVal;
|
|
hasScaleField = true;
|
|
}
|
|
|
|
bool hasXSwitchField = false;
|
|
if (jsonGetUIntValue(line, "xSwitchAction", uintVal)) {
|
|
update.hasXSwitchAction = true;
|
|
update.xSwitchAction = (uint8_t)uintVal;
|
|
hasXSwitchField = true;
|
|
}
|
|
if (jsonGetUIntValue(line, "xSwitchLongAction", uintVal)) {
|
|
update.hasXSwitchLongAction = true;
|
|
update.xSwitchLongAction = (uint8_t)uintVal;
|
|
hasXSwitchField = true;
|
|
}
|
|
|
|
if (section == "case") {
|
|
update.updateCaseSection = true;
|
|
if (!update.hasCaseEnabled) {
|
|
update.hasCaseEnabled = true;
|
|
update.caseEnabled = false;
|
|
}
|
|
if (!update.hasCaseTempDashboard) {
|
|
update.hasCaseTempDashboard = true;
|
|
update.caseTempDashboard = false;
|
|
}
|
|
if (!update.hasCaseTempDisplay) {
|
|
update.hasCaseTempDisplay = true;
|
|
update.caseTempDisplay = false;
|
|
}
|
|
} else if (section == "scale") {
|
|
update.updateScaleSection = true;
|
|
if (!update.hasScaleEnabled) {
|
|
update.hasScaleEnabled = true;
|
|
update.scaleEnabled = false;
|
|
}
|
|
} else if (section == "xswitch") {
|
|
update.updateXSwitchSection = true;
|
|
} else {
|
|
update.updateCaseSection = hasCaseField;
|
|
update.updateScaleSection = hasScaleField;
|
|
update.updateXSwitchSection = hasXSwitchField;
|
|
}
|
|
|
|
if (!update.updateCaseSection && !update.updateScaleSection && !update.updateXSwitchSection) {
|
|
touchUartSendError(id, "Keine Sensor-Felder");
|
|
return false;
|
|
}
|
|
|
|
applySensorSettingsUpdate(update);
|
|
touchUartSendAck(id, true, "Sensoren gespeichert");
|
|
touchUartSendState();
|
|
return true;
|
|
}
|
|
|
|
if (op == "setPid") {
|
|
// PID-Parameter setzen (nur uebergebene Felder), EEPROM + Live-SetTunings.
|
|
float f = 0.0f;
|
|
bool changedW = false, changedD = false;
|
|
if (jsonGetFloatValue(line, "kpW", f)) { KpWasser = (double)f; EEPROM.put(EEPROM_ADDR_KP_WASSER, KpWasser); changedW = true; }
|
|
if (jsonGetFloatValue(line, "kiW", f)) { KiWasser = (double)f; EEPROM.put(EEPROM_ADDR_KI_WASSER, KiWasser); changedW = true; }
|
|
if (jsonGetFloatValue(line, "kdW", f)) { KdWasser = (double)f; EEPROM.put(EEPROM_ADDR_KD_WASSER, KdWasser); changedW = true; }
|
|
if (jsonGetFloatValue(line, "kpD", f)) { KpDampf = (double)f; EEPROM.put(EEPROM_ADDR_KP_DAMPF, KpDampf); changedD = true; }
|
|
if (jsonGetFloatValue(line, "kiD", f)) { KiDampf = (double)f; EEPROM.put(EEPROM_ADDR_KI_DAMPF, KiDampf); changedD = true; }
|
|
if (jsonGetFloatValue(line, "kdD", f)) { KdDampf = (double)f; EEPROM.put(EEPROM_ADDR_KD_DAMPF, KdDampf); changedD = true; }
|
|
if (!changedW && !changedD) {
|
|
touchUartSendError(id, "Keine PID-Felder");
|
|
return false;
|
|
}
|
|
EEPROM.commit();
|
|
if (changedW) { pidWasser.SetTunings(KpWasser, KiWasser, KdWasser); }
|
|
if (changedD) { pidDampf.SetTunings(KpDampf, KiDampf, KdDampf); }
|
|
touchUartSendAck(id, true, "PID gespeichert");
|
|
touchUartSendState();
|
|
return true;
|
|
}
|
|
|
|
if (op == "startAutotune") {
|
|
// Erwartet "target":"water"|"steam" (auch "wasser"/"dampf"). Spiegelt handleAutoTune*.
|
|
String target;
|
|
jsonGetStringValue(line, "target", target);
|
|
target.toLowerCase();
|
|
bool wantWater = (target == "water" || target == "wasser");
|
|
bool wantSteam = (target == "steam" || target == "dampf");
|
|
if (!wantWater && !wantSteam) {
|
|
touchUartSendError(id, "target water|steam fehlt");
|
|
return false;
|
|
}
|
|
if (autoTuneWasserActive || autoTuneDampfActive) {
|
|
touchUartSendAck(id, false, "Tuning laeuft bereits");
|
|
return true;
|
|
}
|
|
if (wartungsModusAktiv) {
|
|
wartungsModusAktiv = false;
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
}
|
|
if (wantWater) {
|
|
startAutoTuneWasser();
|
|
touchUartSendAck(id, true, "AutoTune Wasser gestartet");
|
|
} else {
|
|
startAutoTuneDampf();
|
|
touchUartSendAck(id, true, "AutoTune Dampf gestartet");
|
|
}
|
|
touchUartSendState();
|
|
return true;
|
|
}
|
|
|
|
if (op == "stopAutotune") {
|
|
bool wasActive = autoTuneWasserActive || autoTuneDampfActive;
|
|
if (autoTuneWasserActive) autoTuneWasserStatus = AT_ABORT_MANUAL;
|
|
if (autoTuneDampfActive) autoTuneDampfStatus = AT_ABORT_MANUAL;
|
|
stopAutoTuneWasser();
|
|
stopAutoTuneDampf();
|
|
touchUartSendAck(id, true, wasActive ? "AutoTune gestoppt" : "Kein AutoTune aktiv");
|
|
touchUartSendState();
|
|
return true;
|
|
}
|
|
|
|
if (op == "getProfileDetails") {
|
|
String profileName;
|
|
if (!jsonGetStringValue(line, "profile", profileName)) {
|
|
touchUartSendError(id, "profile fehlt");
|
|
return false;
|
|
}
|
|
TemperatureProfile p;
|
|
if (!loadProfile(profileName, p)) {
|
|
touchUartSendError(id, "Profil nicht gefunden");
|
|
return false;
|
|
}
|
|
char buf[TOUCH_UART_JSON_BUFFER_SIZE];
|
|
char fb[16];
|
|
int o = 0;
|
|
String escName = jsonEscapeForUart(String(p.profileName));
|
|
o += snprintf(buf + o, sizeof(buf) - o, "{\"type\":\"profileDetails\",\"id\":%lu,\"profile\":\"%s\",", (unsigned long)id, escName.c_str());
|
|
dtostrf(p.setpointWasser, 4, 1, fb); o += snprintf(buf + o, sizeof(buf) - o, "\"setW\":%s,", fb);
|
|
dtostrf(p.setpointDampf, 5, 1, fb); o += snprintf(buf + o, sizeof(buf) - o, "\"setD\":%s,", fb);
|
|
dtostrf(p.offsetWasser, 4, 1, fb); o += snprintf(buf + o, sizeof(buf) - o, "\"offW\":%s,", fb);
|
|
dtostrf(p.offsetDampf, 4, 1, fb); o += snprintf(buf + o, sizeof(buf) - o, "\"offD\":%s,", fb);
|
|
dtostrf(p.kpWasser, 6, 2, fb); o += snprintf(buf + o, sizeof(buf) - o, "\"kpW\":%s,", fb);
|
|
dtostrf(p.kiWasser, 6, 2, fb); o += snprintf(buf + o, sizeof(buf) - o, "\"kiW\":%s,", fb);
|
|
dtostrf(p.kdWasser, 6, 2, fb); o += snprintf(buf + o, sizeof(buf) - o, "\"kdW\":%s,", fb);
|
|
dtostrf(p.kpDampf, 6, 2, fb); o += snprintf(buf + o, sizeof(buf) - o, "\"kpD\":%s,", fb);
|
|
dtostrf(p.kiDampf, 6, 2, fb); o += snprintf(buf + o, sizeof(buf) - o, "\"kiD\":%s,", fb);
|
|
dtostrf(p.kdDampf, 6, 2, fb); o += snprintf(buf + o, sizeof(buf) - o, "\"kdD\":%s,", fb);
|
|
o += snprintf(buf + o, sizeof(buf) - o, "\"boostW\":%s,", p.boostWasserActive ? "true" : "false");
|
|
o += snprintf(buf + o, sizeof(buf) - o, "\"boostD\":%s,", p.boostDampfActive ? "true" : "false");
|
|
o += snprintf(buf + o, sizeof(buf) - o, "\"winW\":%lu,", (unsigned long)p.windowSizeWasser);
|
|
o += snprintf(buf + o, sizeof(buf) - o, "\"winD\":%lu,", (unsigned long)p.windowSizeDampf);
|
|
o += snprintf(buf + o, sizeof(buf) - o, "\"ecoMin\":%d,", p.ecoModeMinutes);
|
|
o += snprintf(buf + o, sizeof(buf) - o, "\"ecoTempW\":%d,", p.ecoModeTempWasser);
|
|
o += snprintf(buf + o, sizeof(buf) - o, "\"ecoTempD\":%d,", p.ecoModeTempDampf);
|
|
o += snprintf(buf + o, sizeof(buf) - o, "\"piEnabled\":%s,", p.preInfusionEnabled ? "true" : "false");
|
|
dtostrf(p.preInfusionDurationSeconds, 4, 1, fb); o += snprintf(buf + o, sizeof(buf) - o, "\"piDurSecs\":%s,", fb);
|
|
dtostrf(p.preInfusionPauseSeconds, 4, 1, fb); o += snprintf(buf + o, sizeof(buf) - o, "\"piPauseSecs\":%s,", fb);
|
|
o += snprintf(buf + o, sizeof(buf) - o, "\"bbtEnabled\":%s,", p.brewByTimeEnabled ? "true" : "false");
|
|
dtostrf(p.brewByTimeTargetSeconds, 4, 1, fb); o += snprintf(buf + o, sizeof(buf) - o, "\"bbtSecs\":%s,", fb);
|
|
o += snprintf(buf + o, sizeof(buf) - o, "\"bbwEnabled\":%s,", p.brewByWeightEnabled ? "true" : "false");
|
|
dtostrf(p.brewByWeightTargetGrams, 4, 1, fb); o += snprintf(buf + o, sizeof(buf) - o, "\"bbwTarget\":%s,", fb);
|
|
dtostrf(p.brewByWeightOffsetGrams, 4, 1, fb); o += snprintf(buf + o, sizeof(buf) - o, "\"bbwOffset\":%s,", fb);
|
|
o += snprintf(buf + o, sizeof(buf) - o, "\"sbtEnabled\":%s,", p.steamByTimeEnabled ? "true" : "false");
|
|
dtostrf(p.steamByTimeTargetSeconds, 4, 1, fb); o += snprintf(buf + o, sizeof(buf) - o, "\"sbtSecs\":%s,", fb);
|
|
o += snprintf(buf + o, sizeof(buf) - o, "\"piezoEnabled\":%s,", p.piezoEnabled ? "true" : "false");
|
|
o += snprintf(buf + o, sizeof(buf) - o, "\"fastHeatUp\":%s", p.fastHeatUpAktiv ? "true" : "false");
|
|
o += snprintf(buf + o, sizeof(buf) - o, "}");
|
|
touchUartSendLine(buf);
|
|
touchUartSendAck(id, true, "profileDetails gesendet");
|
|
return true;
|
|
}
|
|
|
|
if (op == "scanWifi") {
|
|
// Asynchroner WLAN-Scan; Ergebnis wird spaeter als 'wifiNetworks' gepusht.
|
|
int rc = WiFi.scanComplete();
|
|
if (rc == WIFI_SCAN_RUNNING || touchUartWifiScanPending) {
|
|
touchUartSendAck(id, true, "Scan laeuft bereits");
|
|
return true;
|
|
}
|
|
WiFi.scanDelete();
|
|
// STA muss zum Scannen aktiv sein - aber bestehenden AP bzw. eine aktive
|
|
// STA-Verbindung NICHT kappen (sonst Verbindungsabbruch/Instabilitaet).
|
|
wifi_mode_t prevMode = WiFi.getMode();
|
|
if (prevMode == WIFI_MODE_NULL) WiFi.mode(WIFI_STA);
|
|
else if (prevMode == WIFI_MODE_AP) WiFi.mode(WIFI_AP_STA); // AP erhalten, STA zusaetzlich
|
|
// STA oder AP_STA: Modus unveraendert lassen
|
|
int started = WiFi.scanNetworks(true); // true = asynchron (nicht blockierend)
|
|
if (started == WIFI_SCAN_FAILED) {
|
|
touchUartSendError(id, "Scan fehlgeschlagen");
|
|
return false;
|
|
}
|
|
touchUartWifiScanPending = true;
|
|
touchUartWifiScanStartMs = millis();
|
|
touchUartSendAck(id, true, "Scan gestartet");
|
|
return true;
|
|
}
|
|
|
|
if (op == "setWifi") {
|
|
// SSID/Passwort speichern und (nicht blockierend) verbinden.
|
|
String ssid, pass;
|
|
if (!jsonGetStringValue(line, "ssid", ssid) || ssid.length() == 0) {
|
|
touchUartSendError(id, "ssid fehlt");
|
|
return false;
|
|
}
|
|
jsonGetStringValue(line, "password", pass); // darf leer sein (offenes Netz)
|
|
|
|
WiFiConfig cfg;
|
|
loadWiFiConfig(cfg); // bestehende IP-Einstellungen erhalten
|
|
strncpy(cfg.ssid, ssid.c_str(), sizeof(cfg.ssid) - 1);
|
|
cfg.ssid[sizeof(cfg.ssid) - 1] = '\0';
|
|
strncpy(cfg.password, pass.c_str(), sizeof(cfg.password) - 1);
|
|
cfg.password[sizeof(cfg.password) - 1] = '\0';
|
|
saveWiFiConfig(cfg);
|
|
EEPROM.commit();
|
|
|
|
WiFi.mode(WIFI_STA);
|
|
WiFi.setSleep(false);
|
|
WiFi.begin(cfg.ssid, cfg.password); // nicht blockierend; State-Push zeigt Fortschritt
|
|
touchUartSendAck(id, true, "Verbinde mit " + ssid + "...");
|
|
touchUartSendState();
|
|
return true;
|
|
}
|
|
|
|
touchUartSendError(id, "Unbekannter op");
|
|
return false;
|
|
}
|
|
|
|
void touchUartRxTick() {
|
|
#if TOUCH_UART_ENABLED
|
|
if (displayOtaActive) return; // waehrend OTA hat der Upload-Handler die UART exklusiv
|
|
recordResetCheckpoint(RESET_CP_TOUCH_UART_RX);
|
|
while (touchUart.available() > 0) {
|
|
char c = (char)touchUart.read();
|
|
|
|
if (c == '\r') {
|
|
continue;
|
|
}
|
|
|
|
if (c == '\n') {
|
|
if (touchUartRxOverflow) {
|
|
touchUartRxLine = "";
|
|
touchUartRxOverflow = false;
|
|
touchUartSendError(0, "Zeile zu lang");
|
|
continue;
|
|
}
|
|
|
|
String line = touchUartRxLine;
|
|
touchUartRxLine = "";
|
|
line.trim();
|
|
if (line.length() > 0) {
|
|
touchUartHandleCommandLine(line);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (touchUartRxOverflow) {
|
|
continue;
|
|
}
|
|
|
|
if (touchUartRxLine.length() >= TOUCH_UART_MAX_LINE_LEN) {
|
|
touchUartRxOverflow = true;
|
|
continue;
|
|
}
|
|
|
|
touchUartRxLine += c;
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#if TOUCH_UART_ENABLED
|
|
// Prueft, ob ein asynchroner WLAN-Scan fertig ist, und sendet dann die Netzliste ans Display.
|
|
static void touchUartPollWifiScan() {
|
|
if (!touchUartWifiScanPending) return;
|
|
int n = WiFi.scanComplete();
|
|
if (n == WIFI_SCAN_RUNNING) {
|
|
if (millis() - touchUartWifiScanStartMs > 15000UL) { // Timeout-Schutz
|
|
WiFi.scanDelete();
|
|
touchUartWifiScanPending = false;
|
|
touchUartSendLine("{\"type\":\"wifiNetworks\",\"networks\":[]}");
|
|
}
|
|
return;
|
|
}
|
|
touchUartWifiScanPending = false;
|
|
String msg = "{\"type\":\"wifiNetworks\",\"networks\":[";
|
|
if (n > 0) {
|
|
int maxN = (n > 20) ? 20 : n; // Liste begrenzen (UART-Zeilenlaenge)
|
|
for (int i = 0; i < maxN; i++) {
|
|
if (i) msg += ",";
|
|
msg += "{\"ssid\":\"";
|
|
msg += jsonEscapeForUart(WiFi.SSID(i));
|
|
msg += "\",\"rssi\":";
|
|
msg += String(WiFi.RSSI(i));
|
|
msg += ",\"enc\":";
|
|
msg += (WiFi.encryptionType(i) == WIFI_AUTH_OPEN) ? "false" : "true";
|
|
msg += "}";
|
|
}
|
|
}
|
|
msg += "]}";
|
|
WiFi.scanDelete();
|
|
touchUartSendLine(msg);
|
|
}
|
|
#endif
|
|
|
|
void touchUartTick() {
|
|
#if TOUCH_UART_ENABLED
|
|
if (displayOtaActive) return; // waehrend OTA keine State-Pushes
|
|
recordResetCheckpoint(RESET_CP_TOUCH_UART_TX);
|
|
unsigned long now = millis();
|
|
|
|
if (touchUartClientActive && (now - touchUartLastClientSeenMs > TOUCH_UART_CLIENT_TIMEOUT_MS)) {
|
|
touchUartClientActive = false;
|
|
touchUartClientFirmware = ""; // Stand gilt nur bei bestehender Verbindung
|
|
}
|
|
|
|
if (!touchUartClientActive) {
|
|
return;
|
|
}
|
|
|
|
touchUartPollWifiScan(); // asynchronen WLAN-Scan abschliessen, falls aktiv
|
|
|
|
bool forcePush = false;
|
|
bool flushActiveNow = flushActive;
|
|
|
|
if (shotActive && !touchUartLastShotActive) {
|
|
forcePush = true;
|
|
} else if (!shotActive && touchUartLastShotActive) {
|
|
touchUartBurstUntil = now + TOUCH_UART_SHOT_END_GRACE_MS;
|
|
forcePush = true;
|
|
}
|
|
touchUartLastShotActive = shotActive;
|
|
|
|
if (flushActiveNow && !touchUartLastFlushActive) {
|
|
forcePush = true;
|
|
} else if (!flushActiveNow && touchUartLastFlushActive) {
|
|
forcePush = true;
|
|
}
|
|
touchUartLastFlushActive = flushActiveNow;
|
|
|
|
// Dampfbezug UND Dampf-Flush setzen beide steamCircuitActive -> deckt beides ab.
|
|
if (steamCircuitActive && !touchUartLastSteamCircuitActive) {
|
|
forcePush = true;
|
|
} else if (!steamCircuitActive && touchUartLastSteamCircuitActive) {
|
|
touchUartBurstUntil = now + TOUCH_UART_SHOT_END_GRACE_MS;
|
|
forcePush = true;
|
|
}
|
|
touchUartLastSteamCircuitActive = steamCircuitActive;
|
|
|
|
// Tarier-Phase vor Brew-by-Weight-Bezug: sofort melden, damit das Display
|
|
// unmittelbar "Tariere..." anzeigt (sonst Eindruck, dass nichts passiert).
|
|
bool tareActiveNow = pendingShotStartAfterScaleTare;
|
|
if (tareActiveNow != touchUartLastTareActive) {
|
|
forcePush = true;
|
|
touchUartLastTareActive = tareActiveNow;
|
|
}
|
|
|
|
// Sensorfehler / Sicherheitsabschaltung: sofort melden (sicherheitsrelevant)
|
|
bool errorActiveNow = wasserSensorError || wasserSafetyShutdown || dampfSensorError || dampfSafetyShutdown;
|
|
if (errorActiveNow != touchUartLastErrorActive) {
|
|
forcePush = true;
|
|
touchUartLastErrorActive = errorActiveNow;
|
|
}
|
|
|
|
// Reinigungsassistent: Start/Stopp sofort melden (Phasenwechsel deckt der Burst-Takt ab)
|
|
if (cleaningAssistantActive != touchUartLastCleaningActive) {
|
|
forcePush = true;
|
|
touchUartLastCleaningActive = cleaningAssistantActive;
|
|
}
|
|
|
|
if (standbyModeActive != touchUartLastStandby) { // Standby-Wechsel sofort melden
|
|
forcePush = true;
|
|
touchUartLastStandby = standbyModeActive;
|
|
}
|
|
|
|
bool burstActive = shotActive || flushActiveNow || steamCircuitActive || tareActiveNow ||
|
|
cleaningAssistantActive || ((long)(touchUartBurstUntil - now) > 0);
|
|
unsigned long interval = burstActive ? TOUCH_UART_INTERVAL_ACTIVE_MS : TOUCH_UART_INTERVAL_IDLE_MS;
|
|
if (!forcePush && (now - lastTouchUartPushMs < interval)) {
|
|
return;
|
|
}
|
|
|
|
lastTouchUartPushMs = now;
|
|
touchUartSendState();
|
|
#endif
|
|
}
|
|
|
|
|
|
// =============================================================================
|
|
// ENDE: Dashboard Code Block
|
|
// =============================================================================
|
|
|
|
|
|
/************************************************************************************
|
|
* Handler für Button der Waage
|
|
************************************************************************************/
|
|
|
|
/**
|
|
* Verarbeitet den Tastendruck des Waagen-Buttons.
|
|
* Unterscheidet zwischen kurzem (Tarieren mit Verzögerung) und langem Druck (scaleModeActive umschalten).
|
|
* Verwendet invertierte Button-Logik (0 = gedrückt).
|
|
*/
|
|
void handleScaleButtonPress(AsyncWebServerRequest *request) { // Umbenannt zur Klarheit, dass es um den Press-Event geht
|
|
(void)request;
|
|
if (!scaleEnabled || scaleType != SCALE_I2C) {
|
|
return;
|
|
}
|
|
if (!scaleConnected) {
|
|
return;
|
|
}
|
|
|
|
recordResetCheckpoint(RESET_CP_SCALE_BUTTON_READ);
|
|
uint8_t currentRawButtonState = scales.getBtnStatus();
|
|
|
|
// --- Entprellung ---
|
|
if (currentRawButtonState != lastScaleButtonRawState) {
|
|
lastScaleButtonDebounceTime = millis();
|
|
}
|
|
lastScaleButtonRawState = currentRawButtonState;
|
|
|
|
if ((millis() - lastScaleButtonDebounceTime) > debounceDelayScale) {
|
|
if (currentRawButtonState != currentScaleButtonDebouncedState) {
|
|
currentScaleButtonDebouncedState = currentRawButtonState;
|
|
|
|
// Invertierte Logik: 0 = GEDRÜCKT, 1 (oder !=0) = NICHT GEDRÜCKT
|
|
if (currentScaleButtonDebouncedState == 0 && lastScaleButtonDebouncedState != 0) {
|
|
// Fallende Flanke: Button wurde gerade GEDRÜCKT
|
|
scaleButtonPressStartTime = millis();
|
|
scaleButtonIsCurrentlyPressed = true;
|
|
// Serial.println(F("Waagen-Button gedrückt")); // für Debugging
|
|
} else if (currentScaleButtonDebouncedState != 0 && lastScaleButtonDebouncedState == 0) {
|
|
// Steigende Flanke: Button wurde gerade LOSGELASSEN
|
|
if (scaleButtonIsCurrentlyPressed) {
|
|
unsigned long pressDuration = millis() - scaleButtonPressStartTime;
|
|
// Serial.print(F("Waagen-Button losgelassen. Dauer: ")); Serial.println(pressDuration); // für Debugging
|
|
|
|
if (pressDuration >= longPressThresholdScale) {
|
|
// Langer Druck
|
|
scaleModeActive = !scaleModeActive;
|
|
if (scaleModeActive) {
|
|
resetScaleDisplayFilter(currentWeightReading);
|
|
}
|
|
// Serial.print(F("Langer Druck: scaleModeActive = ")); Serial.println(scaleModeActive);
|
|
if (piezoEnabled) {
|
|
beepShort(scaleModeActive ? 3500 : 3200, 70); // Unterschiedliche Töne für an/aus
|
|
}
|
|
} else {
|
|
// Kurzer Druck: Tarieren nach Verzögerung anstoßen
|
|
if (scaleConnected && !tareScaleAfterDelay) { // Verhindere mehrfaches Auslösen der Verzögerung
|
|
// Serial.println(F("Kurzer Druck: Starte Verzögerung für Tara."));
|
|
tareScaleAfterDelay = true;
|
|
tareScaleDelayStartTime = millis();
|
|
// Der eigentliche Tara-Befehl erfolgt in loop() nach der Verzögerung
|
|
}
|
|
}
|
|
}
|
|
scaleButtonIsCurrentlyPressed = false;
|
|
}
|
|
}
|
|
}
|
|
lastScaleButtonDebouncedState = currentScaleButtonDebouncedState;
|
|
}
|