#include #include #include #include #include const char* ssid = WIFI_SSID; const char* password = WIFI_PASSWORD; const char* DISCORD_WEBHOOK = DISCORD_WEBHOOK_URL; // NTP-Server & Zeitzone für Deutschland const char* ntpServer = "pool.ntp.org"; const char* tzInfo = "CET-1CEST,M3.5.0,M10.5.0/3"; FastAccelStepperEngine engine = FastAccelStepperEngine(); FastAccelStepper *stepper = NULL; // ################ Variabeln ################ const uint32_t MAX_SPEED_HZ = 106000; const int32_t COLDTEMP_C = 70; const int32_t MIKROSTEPS = 3200; bool clickPlus = 0; bool clickMinus = 0; bool clickStop = 0; bool holdPlus = 0; bool holdMinus = 0; bool holdStop = 0; bool shouldRun = true; int lastSpeed = 0; uint32_t lastAutoHz = 0; int32_t lastControl = 0; uint32_t lastPlotTime = 0; bool autoStop = false; bool bSettings = false; int32_t curTemp = 0; bool send = false; bool stopMove = false; // Start/Stopp von der Weboberfläche (nur ohne Zeitschaltuhr) bool webRun = false; bool webStopReq = false; // DEBUG // Laufzeit-Vorgabe per Web (0 = unbegrenzt). // Bewusst NICHT im Flash gespeichert: nach einem Neustart läuft die Mühle nicht von allein weiter. uint32_t webRunStartMs = 0; uint32_t webRunDurMs = 0; // Wird gesetzt, wenn die Laufzeit-Vorgabe abgelaufen ist -> löst die Discord-Nachricht aus bool timedFinished = false; uint32_t timedFinishedSec = 0; // Drehzahl-Wächter: meldet per Discord, wenn die Ist-Drehzahl im Automatik-Lauf dauerhaft // mehr als 10 % von der Soll-Drehzahl abweicht uint32_t devSinceMs = 0; bool devAlarmSent = false; bool devAlarmPending = false; int32_t devSoll = 0, devIst = 0; // Meldung nach jedem Neustart des ESP (mit Grund), sobald das WLAN verbunden ist bool bootNotifyPending = true; // Positions-Wächter: berechnet die Drehzahl aus den tatsächlich ausgegebenen Schritten uint32_t posWinStart = 0; int32_t posWinPos = 0; bool posWinValid = false; int32_t posWinSoll = 0; uint8_t posBadWindows = 0; bool posAlarmSent = false; bool posAlarmPending = false; int32_t posRpm = 0, posSoll = 0, posShown = 0; // Meldung, wenn die Soll-Drehzahl (autoSpeed) während des Laufs geändert wurde int32_t lastSeenAutoSpeed = -1; int32_t speedChangeFrom = 0; uint32_t speedChangeAtMs = 0; bool speedChangePending = false; bool speedChangeWhileRunning = false; bool speedChangeViaWeb = false; // Laufzeitmessung: Zeit seit dem letzten Start des Motors (bleibt nach dem Stopp stehen) uint32_t runStartMs = 0; uint32_t runSeconds = 0; bool motorWasRunning = false; struct tm timeinfo; TimeEditStep timeEditStep = EDIT_NONE; // fest in den Speicher schreiben Preferences prefs; SystemSettings settings; //##################################### //########### Hilfsfunktion ########### //##################################### // Überladung des Präfix-Operators (++timeEditStep) TimeEditStep& operator++(TimeEditStep& step) { step = (step == EDIT_SPEED) ? EDIT_NONE : static_cast(step + 1); return step; } // Werte aus dem Flash speicher laden void loadSettings() { // Namespace öffnen (Name max. 15 Zeichen, "true" = read-only) prefs.begin("timer_cfg", true); // Werte auslesen settings.startHour = prefs.getUChar("s_hour", 0); settings.startMin = prefs.getUChar("s_min", 0); settings.endHour = prefs.getUChar("e_hour", 0); settings.endMin = prefs.getUChar("e_min", 0); settings.timerEnabled = prefs.getBool("t_on", false); settings.maxTemp = prefs.getShort("max_temp", 70); settings.coldTemp = prefs.getShort("cold_temp", 50); settings.accelRate = prefs.getInt("accel_rate", 20000); settings.autoSpeed = prefs.getInt("auto_speed", 700); prefs.end(); Serial.println("[NVS] Einstellungen erfolgreich geladen."); } // Save Settings in Flash Speichern void saveSettings() { prefs.begin("timer_cfg", false); // "false" = Read/Write prefs.putUChar("s_hour", settings.startHour); prefs.putUChar("s_min", settings.startMin); prefs.putUChar("e_hour", settings.endHour); prefs.putUChar("e_min", settings.endMin); prefs.putBool("t_on", settings.timerEnabled); prefs.putShort("max_temp", settings.maxTemp); prefs.putShort("cold_temp", settings.coldTemp); prefs.putInt("accel_rate", settings.accelRate); prefs.putInt("auto_speed", settings.autoSpeed); prefs.end(); Serial.println("[NVS] Einstellungen im Flash gespeichert."); } void initWiFiAndTime() { WiFi.begin(ssid, password); // Zeitzone und NTP-Server setzen configTzTime(tzInfo, ntpServer); // Warten auf Verbindung (optional mit Timeout) int retries = 0; while (WiFi.status() != WL_CONNECTED && retries < 20) { delay(500); retries++; } } // Sekunden -> lesbarer Text, z. B. "2 T 3 Std 0 Min" String formatDuration(uint32_t s) { uint32_t d = s / 86400; s %= 86400; uint32_t h = s / 3600; s %= 3600; uint32_t m = s / 60; String out; if (d > 0) out += String(d) + " T "; out += String(h) + " Std " + String(m) + " Min"; return out; } // Grund des letzten Neustarts als lesbarer Text const char* resetReasonText(esp_reset_reason_t r) { switch (r) { case ESP_RST_POWERON: return "Spannung eingeschaltet (Power-On)"; case ESP_RST_EXT: return "Reset ueber Reset-Pin"; case ESP_RST_SW: return "Software-Neustart"; case ESP_RST_PANIC: return "ABSTURZ (Panic/Exception)"; case ESP_RST_INT_WDT: return "ABSTURZ (Interrupt-Watchdog)"; case ESP_RST_TASK_WDT: return "ABSTURZ (Task-Watchdog)"; case ESP_RST_WDT: return "ABSTURZ (Watchdog)"; case ESP_RST_DEEPSLEEP: return "Aufwachen aus Deep-Sleep"; case ESP_RST_BROWNOUT: return "Spannungseinbruch (Brownout)"; default: return "unbekannt"; } } void sendDiscordNotification(const String& message) { if (WiFi.status() != WL_CONNECTED) { Serial.println("[Discord] Nicht verbunden mit WiFi."); return; } WiFiClientSecure client; client.setInsecure(); // Überspringt die rechenintensive Zertifikatsketten-Prüfung HTTPClient http; if (http.begin(client, DISCORD_WEBHOOK)) { http.addHeader("Content-Type", "application/json"); // JSON-Payload: {"content": "Deine Nachricht"} String payload = "{\"content\": \"" + message + "\"}"; int httpResponseCode = http.POST(payload); if (httpResponseCode == 204 || httpResponseCode == 200) { Serial.println("[Discord] Nachricht erfolgreich gesendet."); } else { Serial.printf("[Discord] Fehler beim Senden: HTTP %d\n", httpResponseCode); } http.end(); } } // ############################################################ // ################ WEBOBERFLÄCHE ############################# // ############################################################ // Arbeitet die Befehle ab, die der Webserver in die Queue gelegt hat. // Läuft im loop() -> kein Zugriffskonflikt mit dem Webserver-Task. void handleWebCommands() { WebCmd c; const int32_t maxRpm = (MAX_SPEED_HZ * 60) / MIKROSTEPS; while (xQueueReceive(webQueue, &c, 0) == pdTRUE) { switch (c.type) { case WC_TIMER: settings.timerEnabled = (c.v[0] != 0); webRun = false; // Handbetrieb beim Umschalten beenden saveSettings(); break; case WC_SPEED: settings.autoSpeed = constrain(c.v[0], 50, maxRpm); saveSettings(); // wirkt live über applyMotor(MOTOR_AUTO) break; case WC_RUN: // Nur ohne Zeitschaltuhr erlaubt if (!settings.timerEnabled) { if (c.v[0] != 0) { webRun = true; uint32_t sec = (uint32_t)constrain(c.v[1], 0, 2592000); webRunStartMs = millis(); webRunDurMs = sec * 1000UL; } else { webRun = false; webStopReq = true; // Stopp-Anforderung aus dem Web vermerken } } break; case WC_MAXTEMP: settings.maxTemp = constrain(c.v[0], -20, 150); settings.coldTemp = settings.maxTemp - 20; saveSettings(); break; case WC_ACCEL: settings.accelRate = constrain(c.v[0], 1000, 30000); stepper->setAcceleration(settings.accelRate); saveSettings(); break; case WC_WINDOW: settings.startHour = constrain(c.v[0], 0, 23); settings.startMin = constrain(c.v[1], 0, 59); settings.endHour = constrain(c.v[2], 0, 23); settings.endMin = constrain(c.v[3], 0, 59); saveSettings(); break; } } } // Schickt den aktuellen Status als JSON an alle verbundenen Browser void sendWebStatus(int32_t rpm, bool timeValid, int h, int m, bool overheat) { if (webSocket.count() == 0) return; // niemand verbunden -> nichts tun JsonDocument d; d["temp"] = curTemp; d["maxTemp"] = settings.maxTemp; d["overheat"] = overheat; d["rpm"] = rpm; d["running"] = stepper->isRunning(); d["timerOn"] = settings.timerEnabled; d["webRun"] = webRun; d["autoSpeed"] = settings.autoSpeed; d["maxRpm"] = (MAX_SPEED_HZ * 60) / MIKROSTEPS; d["accel"] = settings.accelRate; d["sh"] = settings.startHour; d["sm"] = settings.startMin; d["eh"] = settings.endHour; d["em"] = settings.endMin; // Laufzeit seit letztem Start + Restzeit der Laufzeit-Vorgabe (alles in Sekunden) uint32_t remainSec = 0; bool timed = (webRun && webRunDurMs > 0); if (timed) { uint32_t el = millis() - webRunStartMs; remainSec = (el >= webRunDurMs) ? 0 : (webRunDurMs - el + 999) / 1000; } d["runSec"] = runSeconds; d["timed"] = timed; d["remain"] = remainSec; d["sh"] = settings.startHour; d["sm"] = settings.startMin; d["eh"] = settings.endHour; d["em"] = settings.endMin; char t[6]; if (timeValid) snprintf(t, sizeof(t), "%02d:%02d", h, m); else strcpy(t, "--:--"); d["time"] = t; String out; serializeJson(d, out); webSocket.textAll(out); webSocket.cleanupClients(); } //####################################################### //####################### Setup ######################### //####################################################### void setup() { Serial.begin(115200); delay(1000); // Zeit für USB-CDC-Verbindung geben // Daten aus Flash speicher laden loadSettings(); lastSeenAutoSpeed = settings.autoSpeed; engine.init(); stepper = engine.stepperConnectToPin(stepPinStepper); if (stepper) { stepper->setDirectionPin(dirPinStepper); stepper->setEnablePin(enablePinStepper, true); stepper->setAutoEnable(true); stepper->setSpeedInHz(MAX_SPEED_HZ); } // Pin Config pinMode(buttonPlus, INPUT_PULLUP); pinMode(buttonMinus, INPUT_PULLUP); pinMode(buttonStop, INPUT_PULLUP); initWiFiAndTime(); // Weboberfläche starten initWebServer(); Serial.print("[Web] Erreichbar unter http://"); Serial.println(WiFi.localIP()); initDisplay(); // Start Animation } bool getCurrentTime(int &hour, int &minute, int &second) { if (!getLocalTime(&timeinfo, 50)) { // 50ms Timeout return false; // Noch keine NTP-Zeit synchronisiert } hour = timeinfo.tm_hour; minute = timeinfo.tm_min; second = timeinfo.tm_sec; return true; } // Rückgabe von Flanken (Klick) und Pegel (Halten) void getInputs(bool &clickPlus, bool &clickMinus, bool &clickStop, bool &holdPlus, bool &holdMinus, bool &holdStop) { // Physische Pegel einlesen (Active-Low) bool curPlus = (digitalRead(buttonPlus) == LOW); bool curMinus = (digitalRead(buttonMinus) == LOW); bool curStop = (digitalRead(buttonStop) == LOW); // Speicher für vorherigen Durchlauf static bool lastPlus = false; static bool lastMinus = false; static bool lastStop = false; // Nur bei Veränderung true (Sperre) clickPlus = (curPlus && !lastPlus); clickMinus = (curMinus && !lastMinus); clickStop = (curStop && !lastStop); // Dauerzustand (für Stepper-Handbetrieb) holdPlus = curPlus; holdMinus = curMinus; holdStop = curStop; // Zustand für nächsten Durchlauf merken lastPlus = curPlus; lastMinus = curMinus; lastStop = curStop; } // Liefert die Aktuelle Temperatur in °C int getTempInC(uint8_t senTemp){ int milliVolt = analogReadMilliVolts(senTemp); return ((milliVolt - 500)/10); } int32_t getFilteredTemp(int rawSensorPin) { constexpr uint8_t FILTER_SAMPLES = 100; // Puffergröße (100 Messwerte) Mittelwert über 10s static int readings[FILTER_SAMPLES]; static uint8_t readIndex = 0; static int32_t total = 0; static int average = 0; static bool initialized = false; static uint32_t lastSampleTime = 0; // Abtastrate: Alle 100 ms einen neuen Messwert erfassen if (millis() - lastSampleTime >= 100 || !initialized) { lastSampleTime = millis(); int rawVal = getTempInC(rawSensorPin); // Messwert in mV // Kaltstart: Puffer beim ersten Aufruf vollflächig vorbefüllen if (!initialized) { for (uint8_t i = 0; i < FILTER_SAMPLES; i++) { readings[i] = rawVal; } total = (int32_t)rawVal * FILTER_SAMPLES; average = rawVal; initialized = true; return average; } // Ältesten Wert abziehen, neuen Wert einfügen und addieren total -= readings[readIndex]; readings[readIndex] = rawVal; total += rawVal; // Index weiterschalten readIndex = (readIndex + 1) % FILTER_SAMPLES; // Ganzzahlige Division mit kaufmännischer Rundung (+ 5 bei 10 Werten) if (total >= 0) { average = (total + (FILTER_SAMPLES / 2)) / FILTER_SAMPLES; } else { average = (total - (FILTER_SAMPLES / 2)) / FILTER_SAMPLES; } } return average; } // Liefert true wenn der Timer aktuell Active ist und false wenn nicht aktiv bool isTimerActiveNow(int curH, int curM, int startH, int startM, int endH, int endM) { int current = curH * 60 + curM; int start = startH * 60 + startM; int end = endH * 60 + endM; // Fall A: Zeitfenster am selben Tag (z. B. 08:00 bis 16:00) if (start <= end) { return (current >= start && current < end); } // Fall B: Zeitfenster über Mitternacht (z. B. 22:00 bis 04:00) else { return (current >= start || current < end); } } // Die Funktion behandelt das gesammte Einstellungs menu void setting(){ //######### Timer Edit ######### if (timeEditStep != EDIT_NONE) { //PLUS GEDRÜCKT: Wert erhöhen if (clickPlus) { switch (timeEditStep) { case EDIT_START_HOUR: settings.startHour = (settings.startHour + 1) % 24; break; case EDIT_START_MIN: settings.startMin = (settings.startMin + 5) % 60; break; case EDIT_END_HOUR: settings.endHour = (settings.endHour + 1) % 24; break; case EDIT_END_MIN: settings.endMin = (settings.endMin + 5) % 60; break; case EDIT_SPEED: if(settings.autoSpeed < ((MAX_SPEED_HZ * 60)/MIKROSTEPS) - 50){ settings.autoSpeed += 50; } break; default: break; } } // MINUS GEDRÜCKT: Wert verringern if (clickMinus) { switch (timeEditStep) { case EDIT_START_HOUR: settings.startHour = (settings.startHour == 0) ? 23 : settings.startHour - 1; break; case EDIT_START_MIN: settings.startMin = (settings.startMin < 5) ? 55 : settings.startMin - 5; break; case EDIT_END_HOUR: settings.endHour = (settings.endHour == 0) ? 23 : settings.endHour - 1; break; case EDIT_END_MIN: settings.endMin = (settings.endMin < 5) ? 55 : settings.endMin - 5; break; case EDIT_SPEED: if(settings.autoSpeed >= 50){ settings.autoSpeed -= 50; } break; default: break; } } // STOP GEDRÜCKT: Zum nächsten Wert springen if (clickStop) { ++timeEditStep; } return; // Wichtig: Rest der Funktion überspringen, solange editiert wird! } //######### Max Temp Edit ######### if (settings.editMaxTemp) { // PLUS: Bis maximal 150 if (clickPlus) { if (settings.maxTemp <= 145) { settings.maxTemp += 5; } } // MINUS: Bis minimal -20 if (clickMinus) { if (settings.maxTemp >= -15) { settings.maxTemp -= 5; } } if (clickStop) { settings.coldTemp = settings.maxTemp - 20; settings.editMaxTemp = false; } return; // Wichtig: Rest der Funktion überspringen, solange editiert wird! } //######### Beschleunigung Edit ######### if (settings.editAcc) { // PLUS: Erhöhen in 1000er-Schritten bis max 20.000 if (clickPlus) { if (settings.accelRate <= 29000) { settings.accelRate += 500; } } // MINUS: Verringern in 1000er-Schritten bis min 1.000 (niemals 0!) if (clickMinus) { if (settings.accelRate >= 1000) { settings.accelRate -= 500; } } // STOP: Speichern / Verlassen if (clickStop) { settings.editAcc = false; stepper->setAcceleration(settings.accelRate); // Direkt am Stepper aktualisieren } return; // Wichtig: Rest der Funktion überspringen, solange editiert wird! } //######### Menü ######### // Menü bewegen if(clickPlus){ if(settings.settingIdx <= 3){ settings.settingIdx++; } } if(clickMinus){ if(settings.settingIdx >=1){ settings.settingIdx--; } } // Stop zum bestätigen if (clickStop) { if (settings.settingIdx == 0) { // Zeitschaltuhr-Einstellung starten timeEditStep = EDIT_START_HOUR; } else if (settings.settingIdx == 1) { // Zeitschaltuhr ON/OFF settings.timerEnabled = !settings.timerEnabled; }else if (settings.settingIdx == 2) { // Temperatur Grenze einstellen settings.editMaxTemp = true; }else if (settings.settingIdx == 3) { // Beschleunigung settings.editAcc = true; }else if (settings.settingIdx == 4) { // Menü verlassen bSettings = false; // Einstellungen Speichern saveSettings(); } } } // Prüft ob der Motor zu heiß wird (mit Hysterese: AN ab maxTemp, AUS erst bei coldTemp) // return: true wenn zu heiß // false wenn unter der Grenze bool checkOverHeat(int sensorPin){ static bool overheated = false; curTemp = getFilteredTemp(sensorPin); if (curTemp >= settings.maxTemp) { overheated = true; } else if (curTemp <= settings.coldTemp) { overheated = false; } return overheated; } void printStatus(FastAccelStepper *stepper, int speed, int clickPlus, int clickMinus){ // Plotter-Ausgabe alle 50 ms (überflutet den Bus nicht) if (millis() - lastPlotTime >= 50) { lastPlotTime = millis(); int32_t currentSpeed_HZ = stepper->getCurrentSpeedInUs() == 0 ? 0 : (1000000 / stepper->getCurrentSpeedInUs()); int32_t currentSpeed = (currentSpeed_HZ * 60)/1600; // Format kompatibel für den Arduino Serial Plotter Serial.print("Current_Speed_U/Min:"); Serial.print((60*currentSpeed)/3200); Serial.print(" Accel_Control:"); Serial.print(speed); Serial.print(" Btn_Up:"); Serial.print(clickPlus ? 5000 : 0); Serial.print(" Btn_Down:"); Serial.print(clickMinus ? -5000 : 0); Serial.println(); } } // ############################################################ // ################ EINZIGE STELLE FÜR MOTORSTEUERUNG ######### // ############################################################ void applyMotor(MotorCmd cmd, int32_t speed) { switch(cmd){ case MOTOR_STOP:{ if (stepper->isRunning()) { stepper->setAcceleration(10000); stepper->applySpeedAcceleration(); stepper->stopMove(); } lastSpeed = 0; lastAutoHz = 0; break; } case MOTOR_AUTO:{ uint32_t autoHz = ((uint32_t)settings.autoSpeed * MIKROSTEPS) / 60; if (!stepper->isRunning()) { stepper->setSpeedInHz(autoHz); stepper->setAcceleration(settings.accelRate); stepper->runForward(); lastAutoHz = autoHz; } else if (autoHz != lastAutoHz) { // Drehzahl wurde geändert (z. B. per Web) -> live übernehmen stepper->setSpeedInHz(autoHz); stepper->applySpeedAcceleration(); lastAutoHz = autoHz; } break; } case MOTOR_MANUAL:{ if (speed != lastSpeed) { if(speed == 1){ // Plus taste Beschleunigen bis max Speed stepper->setAcceleration(settings.accelRate); stepper->setSpeedInHz(MAX_SPEED_HZ); stepper->runForward(); }else if(speed == -1){ if (stepper->isRunning()){ // Minus Taste bremsen bis fast stillstand stepper->setAcceleration(settings.accelRate); stepper->setSpeedInHz(50); // Zeiel Speed 10 stepper->runForward(); } }else{ // Keine Taste gedrückt Geschwindigkeit halten uint32_t currentSpeedUs = stepper->getCurrentSpeedInUs(); if(currentSpeedUs > 0){ uint32_t currentHz = 1000000/currentSpeedUs; if(currentHz < 10){ currentHz = 10; // Absicherunng gegen 0 Hz } stepper->setSpeedInHz(currentHz); stepper->applySpeedAcceleration(); } } lastSpeed = speed; lastAutoHz = 0; } break; } case MOTOR_KEEP:{ break; } default:{ break; } } } //############################################################# //#################### MAIN ################################### //############################################################# void loop() { int32_t speed = 0; MotorCmd motorCmd = MOTOR_KEEP; if (!stepper) { Serial.println("[HALT] Kein Stepper-Objekt aktiv."); delay(1000); return; } // 1. Eingaben & Sensoren lesen getInputs(clickPlus, clickMinus, clickStop, holdPlus, holdMinus, holdStop); // Befehle von der Weboberfläche abarbeiten handleWebCommands(); // Laufzeit-Vorgabe abgelaufen? -> Web-Lauf beenden (Motor wird unten sauber angehalten) if (webRun && webRunDurMs > 0 && (millis() - webRunStartMs) >= webRunDurMs) { webRun = false; timedFinishedSec = webRunDurMs / 1000; // Dauer sichern, bevor sie weiter unten zurückgesetzt wird timedFinished = true; } // 2. Zeitschaltuhr & NTP prüfen int curH, curM, curS; bool timeIsValid = getCurrentTime(curH, curM, curS); if (settings.timerEnabled && timeIsValid) { shouldRun = isTimerActiveNow(curH, curM, settings.startHour, settings.startMin, settings.endHour, settings.endMin); } else { shouldRun = false; } // 3. Schutz-Verriegelung (Überhitzung) bool tempOverheat = checkOverHeat(senTemp); // 4. Menü / Hotkeys auswerten if (holdStop && clickPlus) { bSettings = true; } // HotKey Timer Toggeln if (holdStop && clickMinus) { settings.timerEnabled = !settings.timerEnabled; webRun = false; } // Sperr-Flag, falls der Nutzer im aktiven Timer-Fenster bewusst Stopp gedrückt hat static bool manualTimerStopped = false; if (!shouldRun) { manualTimerStopped = false; // Reset für den nächsten regulären Zeitzyklus } // Merker, ob wir im manuellen Betrieb sind (für sauberes Anhalten beim Moduswechsel) static bool wasManualMode = false; bool isManualMode = false; // 5. Betriebsmodi: hier wird nur ENTSCHIEDEN, nicht am Motor gesteuert if (bSettings) { if (webRun) { // Web-Lauf beim Öffnen des Menüs beenden webRun = false; motorCmd = MOTOR_STOP; } else { motorCmd = MOTOR_KEEP; } setting(); } // NOT-AUS BEI ÜBERHITZUNG else if (tempOverheat) { motorCmd = MOTOR_STOP; } // AUTOMATIK-BETRIEB (Zeitschaltuhr) else if (settings.timerEnabled) { // Durch Drücken von Plus den Timerbetrieb fortsetzen if (manualTimerStopped && clickPlus) { manualTimerStopped = false; } if (shouldRun && !manualTimerStopped) { motorCmd = MOTOR_AUTO; // Manuelles Anhalten im laufenden Timer erlauben if (clickStop) { manualTimerStopped = true; motorCmd = MOTOR_STOP; } } else { // Außerhalb des Fensters oder manuell gestoppt motorCmd = MOTOR_STOP; } } // MANUELLER BETRIEB (Zeitschaltuhr aus) else { static bool manualStopped = false; static bool wasWebRun = false; isManualMode = true; motorCmd = MOTOR_MANUAL; if (!wasManualMode) { // Gerade erst in den Handbetrieb gewechselt: Motor einmal sauber anhalten motorCmd = MOTOR_STOP; speed = 0; motorCmd = MOTOR_STOP; } else if (clickStop || webStopReq) { manualStopped = true; webRun = false; webStopReq = false; speed = 0; motorCmd = MOTOR_STOP; } else if (manualStopped) { if (!holdPlus && !holdMinus) { manualStopped = false; // erst freigeben wenn Tasten los sind } speed = 0; // während Sperre: nichts tun } else if (holdPlus) { speed = 1; webRun = false; } else if (holdMinus) { speed = -1; webRun = false; } else if (webRun) { motorCmd = MOTOR_AUTO; // Web-Start: läuft mit autoSpeed } else { speed = 0; if (wasWebRun){ motorCmd = MOTOR_STOP; // Web-Stopp: einmal sauber anhalten } } wasWebRun = webRun; } wasManualMode = isManualMode; // Laufzeit-Vorgabe verwerfen, sobald der Web-Lauf beendet ist // (Stopp, Taster, Überhitzung, Menü, Ablauf ...) if (!webRun) webRunDurMs = 0; // 6. Motor ansteuern (einzige Stelle) applyMotor(motorCmd, speed); // Laufzeitmessung: Zeit seit dem letzten Start, bleibt nach dem Stopp stehen bool motorIsRunning = stepper->isRunning(); if (motorIsRunning && !motorWasRunning) { runStartMs = millis(); // Motor ist gerade angelaufen } if (motorIsRunning) { runSeconds = (millis() - runStartMs) / 1000; } motorWasRunning = motorIsRunning; // 7. Geschwindigkeitsberechnung & UI int32_t currentSpeed_HZ = (stepper->getCurrentSpeedInUs() == 0) ? 0 : (1000000 / stepper->getCurrentSpeedInUs()); int32_t currentSpeed = (currentSpeed_HZ * 60) / MIKROSTEPS; // Drehzahl-Wächter: nur im Automatik-Lauf (Soll = autoSpeed). // Alarm, wenn die Abweichung > 10 % länger als Hochlauf-Rampe + 15 s anhält. if (motorCmd == MOTOR_AUTO && stepper->isRunning() && settings.autoSpeed > 0) { int32_t soll = settings.autoSpeed; bool abweichend = (abs(currentSpeed - soll) * 10 > soll); if (!abweichend) { devSinceMs = 0; devAlarmSent = false; } else { if (devSinceMs == 0) devSinceMs = millis(); uint32_t autoHz = ((uint32_t)settings.autoSpeed * MIKROSTEPS) / 60; uint32_t graceMs = (autoHz * 1000UL) / settings.accelRate + 15000UL; if (!devAlarmSent && (millis() - devSinceMs) >= graceMs) { devAlarmSent = true; // pro Ereignis nur eine Nachricht devAlarmPending = true; devSoll = soll; devIst = currentSpeed; } } } else { devSinceMs = 0; devAlarmSent = false; } // Positions-Wächter: berechnet die Drehzahl aus den tatsächlich ausgegebenen Schritten // (Positionszähler) und vergleicht sie mit dem Soll. Fängt den Fall ab, dass die Anzeige // stimmt, der ESP aber weniger (oder mehr) Impulse ausgibt. Nur im stabilen Automatik-Lauf. { int32_t pos = stepper->getCurrentPosition(); bool steady = (motorCmd == MOTOR_AUTO && stepper->isRunning() && settings.autoSpeed > 0 && devSinceMs == 0); if (!steady || !posWinValid || settings.autoSpeed != posWinSoll) { // Messfenster neu starten (Hochlauf, Stopp, Drehzahländerung ...) posWinValid = steady; posWinSoll = settings.autoSpeed; posWinStart = millis(); posWinPos = pos; if (!steady) { posBadWindows = 0; posAlarmSent = false; } } else if (millis() - posWinStart >= 10000) { uint32_t dt = millis() - posWinStart; int32_t dPos = (int32_t)((uint32_t)pos - (uint32_t)posWinPos); // überlaufsicher int32_t rpmPos = (int32_t)(((int64_t)abs(dPos) * 60000LL) / ((int64_t)MIKROSTEPS * dt)); int32_t soll = settings.autoSpeed; if (abs(rpmPos - soll) * 10 > soll) { if (posBadWindows < 255) posBadWindows++; } else { posBadWindows = 0; posAlarmSent = false; } // Erst nach 2 schlechten Fenstern in Folge (ca. 20 s) melden if (posBadWindows >= 2 && !posAlarmSent) { posAlarmSent = true; posAlarmPending = true; posRpm = rpmPos; posSoll = soll; posShown = currentSpeed; } posWinStart = millis(); posWinPos = pos; } } updateDisplay(bSettings, settings.settingIdx, timeEditStep, settings, currentSpeed, curH, curM, timeIsValid, curTemp); printStatus(stepper, speed, clickPlus, clickMinus); // 8. Discord-Alarm (bewusst NACH der Motorsteuerung, da der HTTP-Call blockiert) // Genau eine Nachricht pro Überhitzungs-Ereignis, Reset erst wenn wieder abgekühlt if (tempOverheat && !send) { String message = "⚠️ **Kugelmuehle ueberhitzt!** Aktuelle Temp: " + String(curTemp) + " °C"; sendDiscordNotification(message); send = true; } else if (!tempOverheat) { send = false; } // 8b. Discord-Nachricht, wenn die Laufzeit-Vorgabe abgelaufen ist // (auch NACH der Motorsteuerung, da der HTTP-Call blockiert; genau eine Nachricht pro Ablauf) if (timedFinished) { timedFinished = false; String message = "✅ **Kugelmuehle: Timer abgelaufen.** Laufzeit von " + formatDuration(timedFinishedSec) + " beendet, Motor wurde gestoppt. Aktuelle Temp: " + String(curTemp) + " °C"; sendDiscordNotification(message); } // 8c. Discord-Alarm bei Drehzahlabweichung (einmal pro Ereignis) if (devAlarmPending) { devAlarmPending = false; int32_t pct = (devSoll > 0) ? ((devIst - devSoll) * 100) / devSoll : 0; String message = "⚠️ **Kugelmuehle: Drehzahlabweichung!** Soll " + String(devSoll) + " U/min, Ist " + String(devIst) + " U/min (" + String(pct) + " %)" + ", Schrittperiode " + String(stepper->getCurrentSpeedInUs()) + " us" + ", Laufzeit " + formatDuration(runSeconds) + ", Temp " + String(curTemp) + " °C" + ", freier Heap " + String(ESP.getFreeHeap()) + " Byte"; sendDiscordNotification(message); } // 8c2. Discord-Alarm: aus der Position berechnete Drehzahl weicht vom Soll ab if (posAlarmPending) { posAlarmPending = false; String message = "⚠️ **Kugelmuehle: Schrittzaehler weicht ab!** Aus der Position berechnet " + String(posRpm) + " U/min, Soll " + String(posSoll) + " U/min, Anzeige " + String(posShown) + " U/min. Der ESP gibt " + ((posRpm < posSoll) ? "weniger" : "mehr") + " Impulse aus als erwartet" + ", Laufzeit " + formatDuration(runSeconds) + ", Temp " + String(curTemp) + " °C" + ", freier Heap " + String(ESP.getFreeHeap()) + " Byte"; sendDiscordNotification(message); } // 8c3. Discord-Info: Soll-Drehzahl wurde während des Laufs geändert (Web oder Menü). // Wird erst nach 5 s Ruhe gesendet, damit nicht jeder Tastendruck im Menü eine Nachricht auslöst. if (settings.autoSpeed != lastSeenAutoSpeed) { if (!speedChangePending) { speedChangeFrom = lastSeenAutoSpeed; speedChangeWhileRunning = false; } speedChangePending = true; speedChangeAtMs = millis(); speedChangeWhileRunning |= stepper->isRunning(); lastSeenAutoSpeed = settings.autoSpeed; } if (speedChangePending && (millis() - speedChangeAtMs) >= 5000) { speedChangePending = false; if (speedChangeWhileRunning && settings.autoSpeed != speedChangeFrom) { String message = "ℹ️ **Kugelmuehle: Drehzahl-Vorgabe geaendert** von " + String(speedChangeFrom) + " auf " + String(settings.autoSpeed) + " U/min (Quelle: " + (speedChangeViaWeb ? "Weboberflaeche" : "Menue am Geraet") + "), waehrend die Muehle lief."; sendDiscordNotification(message); } speedChangeViaWeb = false; } // 8d. Meldung nach Neustart des ESP (einmalig, sobald WLAN da ist) if (bootNotifyPending && WiFi.status() == WL_CONNECTED) { bootNotifyPending = false; String message = String("🔄 **Kugelmuehle gestartet.** Grund: ") + resetReasonText(esp_reset_reason()) + ". Weboberflaeche: http://" + WiFi.localIP().toString(); sendDiscordNotification(message); } // 9. Status alle 500 ms an die Weboberfläche schicken static uint32_t lastWeb = 0; if (millis() - lastWeb >= 500) { lastWeb = millis(); sendWebStatus(currentSpeed, timeIsValid, curH, curM, tempOverheat); } }