2026-10-11 21:41:53 +02:00
|
|
|
|
#pragma once
|
|
|
|
|
|
#include <Arduino.h>
|
|
|
|
|
|
|
|
|
|
|
|
// Komplette Weboberfläche im Flash des Programms – kein LittleFS-Upload nötig.
|
|
|
|
|
|
static const char INDEX_HTML[] PROGMEM = R"rawliteral(
|
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
|
<html lang="de">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
|
|
|
<title>Kugelmühle</title>
|
|
|
|
|
|
<style>
|
|
|
|
|
|
:root { --bg:#1b1d21; --card:#272a30; --txt:#eceff4; --dim:#8b93a1; --ok:#3fb950; --warn:#f85149; --acc:#2f81f7; }
|
|
|
|
|
|
* { box-sizing:border-box; }
|
|
|
|
|
|
body { margin:0; font-family:system-ui,Arial,sans-serif; background:var(--bg); color:var(--txt); padding:16px; }
|
|
|
|
|
|
.wrap { max-width:460px; margin:0 auto; display:grid; gap:14px; }
|
|
|
|
|
|
.card { background:var(--card); border-radius:12px; padding:16px; }
|
|
|
|
|
|
h1 { font-size:1.2rem; margin:0; display:flex; justify-content:space-between; align-items:center; }
|
|
|
|
|
|
h2 { font-size:.8rem; margin:0 0 10px; color:var(--dim); text-transform:uppercase; letter-spacing:.06em; }
|
|
|
|
|
|
.dot { width:10px; height:10px; border-radius:50%; background:var(--warn); }
|
|
|
|
|
|
.dot.on { background:var(--ok); }
|
|
|
|
|
|
.grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
|
|
|
|
|
.val { font-size:1.8rem; font-weight:700; }
|
|
|
|
|
|
.lbl { font-size:.8rem; color:var(--dim); }
|
|
|
|
|
|
.hot { color:var(--warn); }
|
|
|
|
|
|
.state { font-weight:700; }
|
|
|
|
|
|
.state.run { color:var(--ok); }
|
|
|
|
|
|
.state.err { color:var(--warn); }
|
|
|
|
|
|
button { width:100%; padding:14px; border:0; border-radius:8px; font-size:1rem; font-weight:600; color:#fff; background:var(--acc); cursor:pointer; }
|
|
|
|
|
|
button.go { background:var(--ok); }
|
|
|
|
|
|
button.stop { background:var(--warn); }
|
|
|
|
|
|
button.off { background:#444b57; }
|
|
|
|
|
|
button:disabled { opacity:.4; cursor:not-allowed; }
|
|
|
|
|
|
.row { display:flex; gap:10px; align-items:center; margin-top:10px; }
|
|
|
|
|
|
.row > * { flex:1; }
|
|
|
|
|
|
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; }
|
2026-10-13 22:47:48 +02:00
|
|
|
|
select { width:100%; padding:10px; border-radius:8px; border:1px solid #444b57; background:#1b1d21; color:var(--txt); font-size:1rem; }
|
|
|
|
|
|
.val.dim { opacity:.45; }
|
2026-10-11 21:41:53 +02:00
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<div class="wrap">
|
|
|
|
|
|
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<h1>Kugelmühle <span class="dot" id="conn" title="Verbindung"></span></h1>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<h2>Status</h2>
|
|
|
|
|
|
<div class="grid">
|
|
|
|
|
|
<div><div class="val" id="temp">--</div><div class="lbl">Temperatur °C (Limit <span id="maxT">--</span>)</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="clock">--:--</div><div class="lbl">Uhrzeit</div></div>
|
2026-10-13 22:47:48 +02:00
|
|
|
|
<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>
|
2026-10-11 21:41:53 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<h2>Steuerung</h2>
|
|
|
|
|
|
<button id="btnRun" onclick="toggleRun()">Start</button>
|
|
|
|
|
|
<div class="small" id="runHint"></div>
|
|
|
|
|
|
<div class="row">
|
|
|
|
|
|
<button id="btnTimer" onclick="toggleTimer()">Zeitschaltuhr</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="small">Bei aktiver Zeitschaltuhr läuft der Motor nur im eingestellten Zeitfenster.</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-10-13 22:47:48 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-10-11 21:41:53 +02:00
|
|
|
|
<div class="card">
|
|
|
|
|
|
<h2>Drehzahl (Automatik / Start)</h2>
|
|
|
|
|
|
<div><b id="speedVal">--</b> U/min</div>
|
|
|
|
|
|
<input type="range" id="speed" min="50" max="1987" step="50"
|
|
|
|
|
|
oninput="$('speedVal').textContent=this.value"
|
|
|
|
|
|
onchange="send('speed', +this.value)">
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<h2>Zeitfenster</h2>
|
|
|
|
|
|
<div class="row">
|
|
|
|
|
|
<div><div class="lbl">Start</div><input type="time" id="tStart"></div>
|
|
|
|
|
|
<div><div class="lbl">Ende</div><input type="time" id="tEnd"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="row"><button onclick="sendWindow()">Übernehmen</button></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<h2>Einstellungen</h2>
|
|
|
|
|
|
<div class="row">
|
|
|
|
|
|
<div><div class="lbl">Max. Temperatur °C</div><input type="number" id="maxTemp" min="-20" max="150" step="5"></div>
|
|
|
|
|
|
<div><div class="lbl">Beschleunigung</div><input type="number" id="accel" min="1000" max="30000" step="500"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="row"><button onclick="sendSettings()">Übernehmen</button></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
|
const $ = id => document.getElementById(id);
|
|
|
|
|
|
let ws, last = {};
|
|
|
|
|
|
|
|
|
|
|
|
function connect() {
|
2026-10-28 11:59:27 +01:00
|
|
|
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
|
|
|
|
ws = new WebSocket(`${protocol}//${location.host}/ws`);
|
2026-10-11 21:41:53 +02:00
|
|
|
|
ws.onopen = () => $('conn').classList.add('on');
|
|
|
|
|
|
ws.onclose = () => { $('conn').classList.remove('on'); setTimeout(connect, 2000); };
|
|
|
|
|
|
ws.onmessage = e => render(JSON.parse(e.data));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-13 22:47:48 +02:00
|
|
|
|
function send(cmd, v, sec) {
|
|
|
|
|
|
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ cmd, v, sec }));
|
2026-10-11 21:41:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const pad = n => String(n).padStart(2, '0');
|
2026-10-13 22:47:48 +02:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-10-28 11:59:27 +01:00
|
|
|
|
|
2026-10-11 21:41:53 +02:00
|
|
|
|
function setIfIdle(id, value) {
|
|
|
|
|
|
const el = $(id);
|
2026-10-28 11:59:27 +01:00
|
|
|
|
if (document.activeElement !== el) el.value = value;
|
2026-10-11 21:41:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function render(d) {
|
|
|
|
|
|
last = d;
|
|
|
|
|
|
$('temp').textContent = d.temp;
|
|
|
|
|
|
$('temp').classList.toggle('hot', d.overheat);
|
|
|
|
|
|
$('maxT').textContent = d.maxTemp;
|
|
|
|
|
|
$('rpm').textContent = d.rpm;
|
|
|
|
|
|
$('clock').textContent = d.time;
|
|
|
|
|
|
|
2026-10-13 22:47:48 +02:00
|
|
|
|
$('runTime').textContent = fmt(d.runSec);
|
|
|
|
|
|
$('runTime').classList.toggle('dim', !d.running);
|
|
|
|
|
|
$('remainInfo').textContent = d.timed
|
|
|
|
|
|
? ' · noch ' + fmt(d.remain) + ' (bis ' + endText(d.remain) + ')'
|
|
|
|
|
|
: '';
|
|
|
|
|
|
|
2026-10-28 11:59:27 +01:00
|
|
|
|
$('btnTimed').disabled = d.overheat || d.timerOn || d.running;
|
2026-10-13 22:47:48 +02:00
|
|
|
|
$('timedInfo').textContent = d.timerOn
|
|
|
|
|
|
? 'Gesperrt, solange die Zeitschaltuhr aktiv ist.'
|
2026-10-28 11:59:27 +01:00
|
|
|
|
: (d.running ? 'Motor läuft bereits.' : 'Läuft ab jetzt für die gewählte Dauer und stoppt dann von selbst (max. 30 Tage). Nur per Web möglich.');
|
2026-10-13 22:47:48 +02:00
|
|
|
|
|
2026-10-11 21:41:53 +02:00
|
|
|
|
const st = $('state');
|
|
|
|
|
|
st.className = 'state';
|
|
|
|
|
|
if (d.overheat) { st.textContent = 'ÜBERHITZT'; st.classList.add('err'); }
|
|
|
|
|
|
else if (d.running) { st.textContent = 'Läuft'; st.classList.add('run'); }
|
|
|
|
|
|
else { st.textContent = 'Gestoppt'; }
|
|
|
|
|
|
|
2026-10-28 11:59:27 +01:00
|
|
|
|
// ZENTRALE ÄNDERUNG: Prüft d.running ODER d.webRun
|
|
|
|
|
|
const active = d.running || d.webRun;
|
2026-10-11 21:41:53 +02:00
|
|
|
|
const run = $('btnRun');
|
|
|
|
|
|
run.disabled = d.overheat || d.timerOn;
|
2026-10-28 11:59:27 +01:00
|
|
|
|
run.textContent = active ? 'Stopp' : 'Start';
|
|
|
|
|
|
run.className = active ? 'stop' : 'go';
|
2026-10-11 21:41:53 +02:00
|
|
|
|
$('runHint').textContent = d.timerOn ? 'Start/Stopp ist gesperrt, solange die Zeitschaltuhr aktiv ist.' : '';
|
|
|
|
|
|
|
|
|
|
|
|
const tm = $('btnTimer');
|
|
|
|
|
|
tm.textContent = 'Zeitschaltuhr: ' + (d.timerOn ? 'AN' : 'AUS');
|
|
|
|
|
|
tm.className = d.timerOn ? 'go' : 'off';
|
|
|
|
|
|
|
|
|
|
|
|
$('speed').max = d.maxRpm;
|
|
|
|
|
|
if (document.activeElement !== $('speed')) { $('speed').value = d.autoSpeed; $('speedVal').textContent = d.autoSpeed; }
|
|
|
|
|
|
setIfIdle('tStart', pad(d.sh) + ':' + pad(d.sm));
|
|
|
|
|
|
setIfIdle('tEnd', pad(d.eh) + ':' + pad(d.em));
|
|
|
|
|
|
setIfIdle('maxTemp', d.maxTemp);
|
|
|
|
|
|
setIfIdle('accel', d.accel);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 11:59:27 +01:00
|
|
|
|
// ZENTRALE ÄNDERUNG: Stoppt sobald d.running oder d.webRun aktiv ist
|
|
|
|
|
|
function toggleRun() {
|
|
|
|
|
|
const active = last.running || last.webRun;
|
|
|
|
|
|
send('run', active ? 0 : 1);
|
|
|
|
|
|
}
|
2026-10-13 22:47:48 +02:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-10-28 11:59:27 +01:00
|
|
|
|
|
2026-10-11 21:41:53 +02:00
|
|
|
|
function toggleTimer() { send('timer', last.timerOn ? 0 : 1); }
|
|
|
|
|
|
|
|
|
|
|
|
function sendWindow() {
|
|
|
|
|
|
const [sh, sm] = $('tStart').value.split(':').map(Number);
|
|
|
|
|
|
const [eh, em] = $('tEnd').value.split(':').map(Number);
|
|
|
|
|
|
if ([sh, sm, eh, em].some(isNaN)) return;
|
|
|
|
|
|
send('window', [sh, sm, eh, em]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sendSettings() {
|
|
|
|
|
|
send('maxTemp', +$('maxTemp').value);
|
|
|
|
|
|
send('accel', +$('accel').value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
connect();
|
|
|
|
|
|
</script>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|
2026-10-13 22:47:48 +02:00
|
|
|
|
)rawliteral";
|