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(
--
U/min
--
Motor
--:--
Uhrzeit
+
+
00:00:00
+
Laufzeit seit letztem Start
+
@@ -65,6 +71,20 @@ static const char INDEX_HTML[] PROGMEM = R"rawliteral(
Bei aktiver Zeitschaltuhr läuft der Motor nur im eingestellten Zeitfenster.
+
+

Für eine bestimmte Zeit starten

+
+ + +
+
+
+
+

Drehzahl (Automatik / Start)

-- U/min
@@ -104,11 +124,28 @@ function connect() { ws.onmessage = e => render(JSON.parse(e.data)); } -function send(cmd, v) { - if (ws && ws.readyState === 1) ws.send(JSON.stringify({ cmd, v })); +function send(cmd, v, sec) { + if (ws && ws.readyState === 1) ws.send(JSON.stringify({ cmd, v, sec })); } const pad = n => String(n).padStart(2, '0'); + +// Sekunden -> "HH:MM:SS" bzw. "2 T HH:MM:SS" +function fmt(s) { + s = Math.max(0, Math.floor(s)); + const d = Math.floor(s / 86400); s %= 86400; + const h = Math.floor(s / 3600); s %= 3600; + const m = Math.floor(s / 60); + return (d > 0 ? d + ' T ' : '') + pad(h) + ':' + pad(m) + ':' + pad(s % 60); +} + +// Voraussichtliches Ende (Uhr des Browsers) +function endText(remainSec) { + const end = new Date(Date.now() + remainSec * 1000); + const t = pad(end.getHours()) + ':' + pad(end.getMinutes()) + ' Uhr'; + if (end.toDateString() === new Date().toDateString()) return t; + return end.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' }) + ' ' + t; +} function setIfIdle(id, value) { const el = $(id); if (document.activeElement !== el) el.value = value; // nicht überschreiben, während getippt/gezogen wird @@ -122,6 +159,17 @@ function render(d) { $('rpm').textContent = d.rpm; $('clock').textContent = d.time; + $('runTime').textContent = fmt(d.runSec); + $('runTime').classList.toggle('dim', !d.running); + $('remainInfo').textContent = d.timed + ? ' · noch ' + fmt(d.remain) + ' (bis ' + endText(d.remain) + ')' + : ''; + + $('btnTimed').disabled = d.overheat || d.timerOn; + $('timedInfo').textContent = d.timerOn + ? 'Gesperrt, solange die Zeitschaltuhr aktiv ist.' + : 'Läuft ab jetzt für die gewählte Dauer und stoppt dann von selbst (max. 30 Tage). Nur per Web möglich.'; + const st = $('state'); st.className = 'state'; if (d.overheat) { st.textContent = 'ÜBERHITZT'; st.classList.add('err'); } @@ -147,6 +195,16 @@ function render(d) { } function toggleRun() { send('run', last.webRun ? 0 : 1); } + +function startTimed() { + const sec = Math.round(+$('durVal').value * +$('durUnit').value); + if (!(sec >= 60) || sec > 2592000) { + alert('Bitte eine Dauer zwischen 1 Minute und 30 Tagen eingeben.'); + return; + } + if (sec > 12 * 3600 && !confirm('Die Mühle läuft dann ' + fmt(sec) + ' lang ohne Aufsicht weiter. Wirklich starten?')) return; + send('run', 1, sec); +} function toggleTimer() { send('timer', last.timerOn ? 0 : 1); } function sendWindow() { @@ -165,4 +223,4 @@ connect(); -)rawliteral"; +)rawliteral"; \ No newline at end of file