diff --git a/README.md b/README.md new file mode 100644 index 0000000..dbbe3d4 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# Bibliotheken + +* *ESP Async WebServer* von ESP32Async +* *Async TCP* von ESP32Async +* *ArduinoJson* von Benoit Blanchon, Version 7.x +* *FastAccelStepper* von Jochen Kiemes +* *U8g2* von oliver + +# Partitions mode + +* No OTA 2MB APP / 2MB SPIFFS \ No newline at end of file diff --git a/kugelmuehle.ino b/kugelmuehle.ino index a6295f6..a708f27 100644 --- a/kugelmuehle.ino +++ b/kugelmuehle.ino @@ -44,6 +44,49 @@ bool stopMove = false; // Start/Stopp von der Weboberfläche (nur ohne Zeitschaltuhr) bool webRun = 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; @@ -77,6 +120,7 @@ void loadSettings() { 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."); @@ -92,8 +136,9 @@ void saveSettings() { 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.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."); @@ -113,6 +158,33 @@ void initWiFiAndTime() { } } +// 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."); @@ -164,7 +236,15 @@ void handleWebCommands() { case WC_RUN: // Nur ohne Zeitschaltuhr erlaubt - if (!settings.timerEnabled) webRun = (c.v[0] != 0); + if (!settings.timerEnabled) { + webRun = (c.v[0] != 0); + if (webRun) { + // v[1] = Dauer in Sekunden (0 = unbegrenzt, max. 30 Tage) + uint32_t sec = (uint32_t)constrain(c.v[1], 0, 2592000); + webRunStartMs = millis(); + webRunDurMs = sec * 1000UL; + } + } break; case WC_MAXTEMP: @@ -206,6 +286,19 @@ void sendWebStatus(int32_t rpm, bool timeValid, int h, int m, bool overheat) { 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]; @@ -228,6 +321,8 @@ void setup() { // Daten aus Flash speicher laden loadSettings(); + lastSeenAutoSpeed = settings.autoSpeed; + engine.init(); stepper = engine.stepperConnectToPin(stepPinStepper); @@ -523,9 +618,6 @@ bool checkOverHeat(int sensorPin){ return overheated; } -// ################## TODO ######################### -// Betriebsmodi in funktions auslagern - void printStatus(FastAccelStepper *stepper, int speed, int clickPlus, int clickMinus){ // Plotter-Ausgabe alle 50 ms (überflutet den Bus nicht) if (millis() - lastPlotTime >= 50) { @@ -561,7 +653,8 @@ void applyMotor(MotorCmd cmd, int32_t speed) { lastAutoHz = 0; break; } - case MOTOR_AUTO: { + + case MOTOR_AUTO:{ uint32_t autoHz = ((uint32_t)settings.autoSpeed * MIKROSTEPS) / 60; if (!stepper->isRunning()) { stepper->setSpeedInHz(autoHz); @@ -576,7 +669,8 @@ void applyMotor(MotorCmd cmd, int32_t speed) { } break; } - case MOTOR_MANUAL: { + + case MOTOR_MANUAL:{ if (speed != lastSpeed) { stepper->setSpeedInHz(MAX_SPEED_HZ); stepper->setAcceleration(settings.accelRate); @@ -586,11 +680,14 @@ void applyMotor(MotorCmd cmd, int32_t speed) { } break; } - case MOTOR_KEEP:{ - } - default: + case MOTOR_KEEP:{ break; + } + + default:{ + break; + } } } @@ -613,6 +710,13 @@ void loop() { // 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); @@ -634,6 +738,7 @@ void loop() { // 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 @@ -705,8 +810,10 @@ void loop() { speed = 0; // während Sperre: nichts tun } else if (holdPlus) { speed = settings.accelRate; + webRun = false; } else if (holdMinus) { speed = -settings.accelRate; + webRun = false; } else if (webRun) { motorCmd = MOTOR_AUTO; // Web-Start: läuft mit autoSpeed } else { @@ -719,15 +826,101 @@ void loop() { } 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); + 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 @@ -739,6 +932,76 @@ void loop() { 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) { diff --git a/web_server.h b/web_server.h index 8929608..2db3b90 100644 --- a/web_server.h +++ b/web_server.h @@ -37,7 +37,7 @@ static void onWsEvent(AsyncWebSocket *, AsyncWebSocketClient *, AwsEventType typ if (!strcmp(c, "timer")) { cmd.type = WC_TIMER; cmd.v[0] = doc["v"] | 0; } else if (!strcmp(c, "speed")) { cmd.type = WC_SPEED; cmd.v[0] = doc["v"] | 0; } - else if (!strcmp(c, "run")) { cmd.type = WC_RUN; cmd.v[0] = doc["v"] | 0; } + else if (!strcmp(c, "run")) { cmd.type = WC_RUN; cmd.v[0] = doc["v"] | 0; cmd.v[1] = doc["sec"] | 0; } else if (!strcmp(c, "maxTemp")) { cmd.type = WC_MAXTEMP; cmd.v[0] = doc["v"] | 0; } else if (!strcmp(c, "accel")) { cmd.type = WC_ACCEL; cmd.v[0] = doc["v"] | 0; } else if (!strcmp(c, "window")) { diff --git a/web_ui.h b/web_ui.h index 6b74a5a..24f90a5 100644 --- a/web_ui.h +++ b/web_ui.h @@ -36,6 +36,8 @@ static const char INDEX_HTML[] PROGMEM = R"rawliteral( input[type=number], input[type=time] { width:100%; padding:10px; border-radius:8px; border:1px solid #444b57; background:#1b1d21; color:var(--txt); font-size:1rem; } input[type=range] { width:100%; } .small { font-size:.8rem; color:var(--dim); margin-top:6px; } + select { width:100%; padding:10px; border-radius:8px; border:1px solid #444b57; background:#1b1d21; color:var(--txt); font-size:1rem; } + .val.dim { opacity:.45; }
@@ -52,6 +54,10 @@ static const char INDEX_HTML[] PROGMEM = R"rawliteral(