Timer funktionalität debug discord nachrichten zum finden von geschwindigkiets verlust. READEME

This commit is contained in:
Jannis 2026-10-13 22:47:48 +02:00
parent df4b67dab3
commit da45700cc7
4 changed files with 347 additions and 15 deletions

11
README.md Normal file
View file

@ -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

View file

@ -44,6 +44,49 @@ bool stopMove = false;
// Start/Stopp von der Weboberfläche (nur ohne Zeitschaltuhr) // Start/Stopp von der Weboberfläche (nur ohne Zeitschaltuhr)
bool webRun = false; 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; struct tm timeinfo;
TimeEditStep timeEditStep = EDIT_NONE; TimeEditStep timeEditStep = EDIT_NONE;
@ -77,6 +120,7 @@ void loadSettings() {
settings.maxTemp = prefs.getShort("max_temp", 70); settings.maxTemp = prefs.getShort("max_temp", 70);
settings.coldTemp = prefs.getShort("cold_temp", 50); settings.coldTemp = prefs.getShort("cold_temp", 50);
settings.accelRate = prefs.getInt("accel_rate", 20000); settings.accelRate = prefs.getInt("accel_rate", 20000);
settings.autoSpeed = prefs.getInt("auto_speed", 700);
prefs.end(); prefs.end();
Serial.println("[NVS] Einstellungen erfolgreich geladen."); Serial.println("[NVS] Einstellungen erfolgreich geladen.");
@ -92,8 +136,9 @@ void saveSettings() {
prefs.putUChar("e_min", settings.endMin); prefs.putUChar("e_min", settings.endMin);
prefs.putBool("t_on", settings.timerEnabled); prefs.putBool("t_on", settings.timerEnabled);
prefs.putShort("max_temp", settings.maxTemp); 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("accel_rate", settings.accelRate);
prefs.putInt("auto_speed", settings.autoSpeed);
prefs.end(); prefs.end();
Serial.println("[NVS] Einstellungen im Flash gespeichert."); 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) { void sendDiscordNotification(const String& message) {
if (WiFi.status() != WL_CONNECTED) { if (WiFi.status() != WL_CONNECTED) {
Serial.println("[Discord] Nicht verbunden mit WiFi."); Serial.println("[Discord] Nicht verbunden mit WiFi.");
@ -164,7 +236,15 @@ void handleWebCommands() {
case WC_RUN: case WC_RUN:
// Nur ohne Zeitschaltuhr erlaubt // 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; break;
case WC_MAXTEMP: 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["maxRpm"] = (MAX_SPEED_HZ * 60) / MIKROSTEPS;
d["accel"] = settings.accelRate; d["accel"] = settings.accelRate;
d["sh"] = settings.startHour; d["sm"] = settings.startMin; 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; d["eh"] = settings.endHour; d["em"] = settings.endMin;
char t[6]; char t[6];
@ -228,6 +321,8 @@ void setup() {
// Daten aus Flash speicher laden // Daten aus Flash speicher laden
loadSettings(); loadSettings();
lastSeenAutoSpeed = settings.autoSpeed;
engine.init(); engine.init();
stepper = engine.stepperConnectToPin(stepPinStepper); stepper = engine.stepperConnectToPin(stepPinStepper);
@ -523,9 +618,6 @@ bool checkOverHeat(int sensorPin){
return overheated; return overheated;
} }
// ################## TODO #########################
// Betriebsmodi in funktions auslagern
void printStatus(FastAccelStepper *stepper, int speed, int clickPlus, int clickMinus){ void printStatus(FastAccelStepper *stepper, int speed, int clickPlus, int clickMinus){
// Plotter-Ausgabe alle 50 ms (überflutet den Bus nicht) // Plotter-Ausgabe alle 50 ms (überflutet den Bus nicht)
if (millis() - lastPlotTime >= 50) { if (millis() - lastPlotTime >= 50) {
@ -561,7 +653,8 @@ void applyMotor(MotorCmd cmd, int32_t speed) {
lastAutoHz = 0; lastAutoHz = 0;
break; break;
} }
case MOTOR_AUTO: {
case MOTOR_AUTO:{
uint32_t autoHz = ((uint32_t)settings.autoSpeed * MIKROSTEPS) / 60; uint32_t autoHz = ((uint32_t)settings.autoSpeed * MIKROSTEPS) / 60;
if (!stepper->isRunning()) { if (!stepper->isRunning()) {
stepper->setSpeedInHz(autoHz); stepper->setSpeedInHz(autoHz);
@ -576,7 +669,8 @@ void applyMotor(MotorCmd cmd, int32_t speed) {
} }
break; break;
} }
case MOTOR_MANUAL: {
case MOTOR_MANUAL:{
if (speed != lastSpeed) { if (speed != lastSpeed) {
stepper->setSpeedInHz(MAX_SPEED_HZ); stepper->setSpeedInHz(MAX_SPEED_HZ);
stepper->setAcceleration(settings.accelRate); stepper->setAcceleration(settings.accelRate);
@ -586,11 +680,14 @@ void applyMotor(MotorCmd cmd, int32_t speed) {
} }
break; break;
} }
case MOTOR_KEEP:{
} case MOTOR_KEEP:{
default:
break; break;
}
default:{
break;
}
} }
} }
@ -613,6 +710,13 @@ void loop() {
// Befehle von der Weboberfläche abarbeiten // Befehle von der Weboberfläche abarbeiten
handleWebCommands(); 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 // 2. Zeitschaltuhr & NTP prüfen
int curH, curM, curS; int curH, curM, curS;
bool timeIsValid = getCurrentTime(curH, curM, curS); bool timeIsValid = getCurrentTime(curH, curM, curS);
@ -634,6 +738,7 @@ void loop() {
// HotKey Timer Toggeln // HotKey Timer Toggeln
if (holdStop && clickMinus) { if (holdStop && clickMinus) {
settings.timerEnabled = !settings.timerEnabled; settings.timerEnabled = !settings.timerEnabled;
webRun = false;
} }
// Sperr-Flag, falls der Nutzer im aktiven Timer-Fenster bewusst Stopp gedrückt hat // 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 speed = 0; // während Sperre: nichts tun
} else if (holdPlus) { } else if (holdPlus) {
speed = settings.accelRate; speed = settings.accelRate;
webRun = false;
} else if (holdMinus) { } else if (holdMinus) {
speed = -settings.accelRate; speed = -settings.accelRate;
webRun = false;
} else if (webRun) { } else if (webRun) {
motorCmd = MOTOR_AUTO; // Web-Start: läuft mit autoSpeed motorCmd = MOTOR_AUTO; // Web-Start: läuft mit autoSpeed
} else { } else {
@ -719,15 +826,101 @@ void loop() {
} }
wasManualMode = isManualMode; 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) // 6. Motor ansteuern (einzige Stelle)
applyMotor(motorCmd, speed); 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 // 7. Geschwindigkeitsberechnung & UI
int32_t currentSpeed_HZ = (stepper->getCurrentSpeedInUs() == 0) ? 0 : (1000000 / stepper->getCurrentSpeedInUs()); int32_t currentSpeed_HZ = (stepper->getCurrentSpeedInUs() == 0) ? 0 : (1000000 / stepper->getCurrentSpeedInUs());
int32_t currentSpeed = (currentSpeed_HZ * 60) / MIKROSTEPS; 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); 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) // 8. Discord-Alarm (bewusst NACH der Motorsteuerung, da der HTTP-Call blockiert)
// Genau eine Nachricht pro Überhitzungs-Ereignis, Reset erst wenn wieder abgekühlt // Genau eine Nachricht pro Überhitzungs-Ereignis, Reset erst wenn wieder abgekühlt
@ -739,6 +932,76 @@ void loop() {
send = false; 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 // 9. Status alle 500 ms an die Weboberfläche schicken
static uint32_t lastWeb = 0; static uint32_t lastWeb = 0;
if (millis() - lastWeb >= 500) { if (millis() - lastWeb >= 500) {

View file

@ -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; } 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, "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, "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, "accel")) { cmd.type = WC_ACCEL; cmd.v[0] = doc["v"] | 0; }
else if (!strcmp(c, "window")) { else if (!strcmp(c, "window")) {

View file

@ -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=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%; } input[type=range] { width:100%; }
.small { font-size:.8rem; color:var(--dim); margin-top:6px; } .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; }
</style> </style>
</head> </head>
<body> <body>
@ -52,6 +54,10 @@ static const char INDEX_HTML[] PROGMEM = R"rawliteral(
<div><div class="val" id="rpm">--</div><div class="lbl">U/min</div></div> <div><div class="val" id="rpm">--</div><div class="lbl">U/min</div></div>
<div><div class="state" id="state">--</div><div class="lbl">Motor</div></div> <div><div class="state" id="state">--</div><div class="lbl">Motor</div></div>
<div><div class="state" id="clock">--:--</div><div class="lbl">Uhrzeit</div></div> <div><div class="state" id="clock">--:--</div><div class="lbl">Uhrzeit</div></div>
<div style="grid-column:1/-1">
<div class="val" id="runTime">00:00:00</div>
<div class="lbl">Laufzeit seit letztem Start<span id="remainInfo"></span></div>
</div>
</div> </div>
</div> </div>
@ -65,6 +71,20 @@ static const char INDEX_HTML[] PROGMEM = R"rawliteral(
<div class="small">Bei aktiver Zeitschaltuhr läuft der Motor nur im eingestellten Zeitfenster.</div> <div class="small">Bei aktiver Zeitschaltuhr läuft der Motor nur im eingestellten Zeitfenster.</div>
</div> </div>
<div class="card">
<h2>Für eine bestimmte Zeit starten</h2>
<div class="row">
<input type="number" id="durVal" min="0" step="any" value="2">
<select id="durUnit">
<option value="60">Minuten</option>
<option value="3600" selected>Stunden</option>
<option value="86400">Tage</option>
</select>
</div>
<div class="row"><button class="go" id="btnTimed" onclick="startTimed()">Jetzt starten</button></div>
<div class="small" id="timedInfo"></div>
</div>
<div class="card"> <div class="card">
<h2>Drehzahl (Automatik / Start)</h2> <h2>Drehzahl (Automatik / Start)</h2>
<div><b id="speedVal">--</b> U/min</div> <div><b id="speedVal">--</b> U/min</div>
@ -104,11 +124,28 @@ function connect() {
ws.onmessage = e => render(JSON.parse(e.data)); ws.onmessage = e => render(JSON.parse(e.data));
} }
function send(cmd, v) { function send(cmd, v, sec) {
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ cmd, v })); if (ws && ws.readyState === 1) ws.send(JSON.stringify({ cmd, v, sec }));
} }
const pad = n => String(n).padStart(2, '0'); 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) { function setIfIdle(id, value) {
const el = $(id); const el = $(id);
if (document.activeElement !== el) el.value = value; // nicht überschreiben, während getippt/gezogen wird 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; $('rpm').textContent = d.rpm;
$('clock').textContent = d.time; $('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'); const st = $('state');
st.className = 'state'; st.className = 'state';
if (d.overheat) { st.textContent = 'ÜBERHITZT'; st.classList.add('err'); } 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 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 toggleTimer() { send('timer', last.timerOn ? 0 : 1); }
function sendWindow() { function sendWindow() {
@ -165,4 +223,4 @@ connect();
</script> </script>
</body> </body>
</html> </html>
)rawliteral"; )rawliteral";