413 lines
17 KiB
HTML
413 lines
17 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Heizungssteuerung</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||
<style>
|
||
body { font-family: system-ui, sans-serif; }
|
||
.card { @apply bg-white rounded-2xl shadow p-4; }
|
||
.val-big { @apply text-3xl font-bold; }
|
||
.label { @apply text-xs text-gray-500 uppercase tracking-wide; }
|
||
.badge-on { @apply inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800; }
|
||
.badge-off { @apply inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-gray-100 text-gray-500; }
|
||
</style>
|
||
</head>
|
||
<body class="bg-gray-100 min-h-screen p-4">
|
||
|
||
<!-- DB-Fehler Banner (nur sichtbar wenn DB konfiguriert aber nicht erreichbar) -->
|
||
<div id="db-banner" class="hidden max-w-5xl mx-auto mb-4 bg-red-50 border border-red-200 rounded-xl px-4 py-3 flex items-start gap-3">
|
||
<svg class="w-5 h-5 text-red-500 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3m0 3h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
|
||
</svg>
|
||
<div>
|
||
<p class="font-semibold text-red-700 text-sm">Datenbankverbindung fehlgeschlagen</p>
|
||
<p class="text-red-600 text-xs mt-0.5" id="db-error-msg">–</p>
|
||
<p class="text-red-500 text-xs mt-1">Messwerte werden nicht gespeichert. Alle anderen Funktionen laufen normal weiter.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Header -->
|
||
<div class="max-w-5xl mx-auto">
|
||
<div class="flex items-center justify-between mb-6">
|
||
<h1 class="text-2xl font-bold text-gray-800">🔥 Heizungssteuerung</h1>
|
||
<div class="flex items-center gap-2 text-sm text-gray-500">
|
||
<span id="serial-dot" class="w-2 h-2 rounded-full bg-gray-400"></span>
|
||
<span id="serial-label">Verbinde…</span>
|
||
<span class="ml-4 text-gray-400" id="last-update"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Sensor-Karten -->
|
||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 mb-6">
|
||
|
||
<div class="card">
|
||
<div class="label">Aussentemperatur</div>
|
||
<div class="val-big text-blue-600" id="aussentemp">–</div>
|
||
<div class="text-sm text-gray-400">°C</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="label">Wohnungstemperatur</div>
|
||
<div class="val-big text-orange-500" id="wohnung">–</div>
|
||
<div class="text-sm text-gray-400">°C</div>
|
||
</div>
|
||
|
||
<div class="card col-span-2">
|
||
<div class="label">Vorlauf</div>
|
||
<div class="flex items-end gap-3">
|
||
<div>
|
||
<div class="text-xs text-gray-400">Ist</div>
|
||
<div class="val-big text-red-500" id="vorlauf_ist">–</div>
|
||
</div>
|
||
<div class="text-2xl text-gray-300 pb-1">→</div>
|
||
<div>
|
||
<div class="text-xs text-gray-400">Soll</div>
|
||
<div class="val-big text-gray-700" id="vorlauf_soll">–</div>
|
||
</div>
|
||
<div class="text-sm text-gray-400 pb-1">°C</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="label">Kessel Oben</div>
|
||
<div class="val-big text-amber-600" id="kessel_oben">–</div>
|
||
<div class="text-sm text-gray-400">°C</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="label">Kessel Unten</div>
|
||
<div class="val-big text-amber-500" id="kessel_unten">–</div>
|
||
<div class="text-sm text-gray-400">°C</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="label">Abgastemperatur</div>
|
||
<div class="val-big text-rose-700" id="abgas">–</div>
|
||
<div class="text-sm text-gray-400">°C</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="label">Mischer</div>
|
||
<div class="val-big text-indigo-600" id="stellung">–</div>
|
||
<div class="text-sm text-gray-400">% geöffnet</div>
|
||
<div class="mt-1" id="mischeron-badge"><span class="badge-off">aus</span></div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- Regelungs-Diagnose -->
|
||
<div class="card mb-6">
|
||
<h2 class="font-semibold text-gray-700 mb-3">Regelungsdiagnose</h2>
|
||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm">
|
||
<div>
|
||
<div class="label">Abweichung</div>
|
||
<div class="text-xl font-bold" id="abweichung">–</div>
|
||
<div class="text-xs text-gray-400">°C (Soll − Ist)</div>
|
||
</div>
|
||
<div>
|
||
<div class="label">Regelzone / Puls</div>
|
||
<div class="text-xl font-bold" id="regelzone">–</div>
|
||
<div class="text-xs text-gray-400" id="puls-info">–</div>
|
||
</div>
|
||
<div>
|
||
<div class="label">Check-Intervall</div>
|
||
<div class="text-xl font-bold" id="check-intervall">–</div>
|
||
<div class="text-xs text-gray-400">Sekunden</div>
|
||
</div>
|
||
<div>
|
||
<div class="label">Status</div>
|
||
<div class="mt-1 flex flex-col gap-1">
|
||
<span id="puls-aktiv-badge" class="badge-off">kein Puls</span>
|
||
<span id="warmup-badge" class="badge-off">normal</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Verlaufs-Chart -->
|
||
<div class="card mb-6">
|
||
<div class="label mb-2">Temperaturverlauf</div>
|
||
<canvas id="tempChart" height="120"></canvas>
|
||
</div>
|
||
|
||
<!-- Einstellungen -->
|
||
<div class="card mb-6">
|
||
<h2 class="font-semibold text-gray-700 mb-4">Einstellungen</h2>
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6" id="settings-grid">
|
||
|
||
<div class="setting-block" data-key="neigung" data-type="float" data-min="0.1" data-max="4.0" data-step="0.1">
|
||
<label class="label">Neigung (Heizkurve)</label>
|
||
<div class="flex items-center gap-2 mt-1">
|
||
<input type="range" class="flex-1 accent-red-500" id="input-neigung">
|
||
<span class="w-12 text-right font-mono" id="display-neigung">–</span>
|
||
</div>
|
||
<div class="text-xs text-gray-400">Bereich 0.1 – 4.0</div>
|
||
</div>
|
||
|
||
<div class="setting-block" data-key="rtsoll" data-type="int" data-min="10" data-max="30" data-step="1">
|
||
<label class="label">Raumsoll-Temperatur (°C)</label>
|
||
<div class="flex items-center gap-2 mt-1">
|
||
<input type="range" class="flex-1 accent-orange-400" id="input-rtsoll">
|
||
<span class="w-12 text-right font-mono" id="display-rtsoll">–</span>
|
||
</div>
|
||
<div class="text-xs text-gray-400">Bereich 10 – 30 °C</div>
|
||
</div>
|
||
|
||
<div class="setting-block" data-key="nachtabs" data-type="int" data-min="0" data-max="10" data-step="1">
|
||
<label class="label">Nachtabsenkung (°C)</label>
|
||
<div class="flex items-center gap-2 mt-1">
|
||
<input type="range" class="flex-1 accent-blue-400" id="input-nachtabs">
|
||
<span class="w-12 text-right font-mono" id="display-nachtabs">–</span>
|
||
</div>
|
||
<div class="text-xs text-gray-400">Bereich 0 – 10 °C</div>
|
||
</div>
|
||
|
||
<div class="setting-block" data-key="misch_begin" data-type="int" data-min="-15" data-max="25" data-step="1">
|
||
<label class="label">Heizung aus ab Außentemp (°C)</label>
|
||
<div class="flex items-center gap-2 mt-1">
|
||
<input type="range" class="flex-1 accent-green-500" id="input-misch_begin">
|
||
<span class="w-12 text-right font-mono" id="display-misch_begin">–</span>
|
||
</div>
|
||
<div class="text-xs text-gray-400">Heizung läuft wenn Außentemp < diesem Wert</div>
|
||
</div>
|
||
|
||
<div class="setting-block" data-key="tag_anfang" data-type="int" data-min="0" data-max="12" data-step="1">
|
||
<label class="label">Tag beginnt (Uhr)</label>
|
||
<div class="flex items-center gap-2 mt-1">
|
||
<input type="range" class="flex-1 accent-yellow-400" id="input-tag_anfang">
|
||
<span class="w-12 text-right font-mono" id="display-tag_anfang">–</span>
|
||
</div>
|
||
<div class="text-xs text-gray-400">Bereich 0 – 12 Uhr</div>
|
||
</div>
|
||
|
||
<div class="setting-block" data-key="tag_ende" data-type="int" data-min="12" data-max="23" data-step="1">
|
||
<label class="label">Tag endet (Uhr)</label>
|
||
<div class="flex items-center gap-2 mt-1">
|
||
<input type="range" class="flex-1 accent-yellow-600" id="input-tag_ende">
|
||
<span class="w-12 text-right font-mono" id="display-tag_ende">–</span>
|
||
</div>
|
||
<div class="text-xs text-gray-400">Bereich 12 – 23 Uhr</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- Status-Meldung -->
|
||
<div id="save-status" class="mt-4 text-sm text-gray-500 hidden"></div>
|
||
</div>
|
||
|
||
</div><!-- /max-w -->
|
||
|
||
<script>
|
||
// ── Chart ─────────────────────────────────────────────────────────────────────
|
||
const ctx = document.getElementById('tempChart').getContext('2d');
|
||
const chart = new Chart(ctx, {
|
||
type: 'line',
|
||
data: {
|
||
labels: [],
|
||
datasets: [
|
||
{ label: 'Vorlauf Ist', data: [], borderColor: '#ef4444', tension: 0.3, pointRadius: 0 },
|
||
{ label: 'Vorlauf Soll', data: [], borderColor: '#6b7280', tension: 0.3, pointRadius: 0, borderDash: [5,3] },
|
||
{ label: 'Aussen', data: [], borderColor: '#3b82f6', tension: 0.3, pointRadius: 0 },
|
||
{ label: 'Wohnung', data: [], borderColor: '#f97316', tension: 0.3, pointRadius: 0 },
|
||
]
|
||
},
|
||
options: {
|
||
animation: false,
|
||
scales: {
|
||
x: { display: false },
|
||
y: { grid: { color: '#f3f4f6' } }
|
||
},
|
||
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12 } } }
|
||
}
|
||
});
|
||
|
||
function updateChart(hist) {
|
||
chart.data.labels = hist.map(h => new Date(h.ts * 1000).toLocaleTimeString('de'));
|
||
chart.data.datasets[0].data = hist.map(h => h.vorlauf_ist);
|
||
chart.data.datasets[1].data = hist.map(h => h.vorlauf_soll);
|
||
chart.data.datasets[2].data = hist.map(h => h.aussentemp);
|
||
chart.data.datasets[3].data = hist.map(h => h.wohnung);
|
||
chart.update();
|
||
}
|
||
|
||
// ── Einstellungen ─────────────────────────────────────────────────────────────
|
||
// Für jeden Slider: beim Loslassen Wert an Server senden
|
||
let sendTimer = null;
|
||
|
||
function setupSliders() {
|
||
document.querySelectorAll('.setting-block').forEach(block => {
|
||
const key = block.dataset.key;
|
||
const type = block.dataset.type;
|
||
const min = parseFloat(block.dataset.min);
|
||
const max = parseFloat(block.dataset.max);
|
||
const step = parseFloat(block.dataset.step);
|
||
const input = block.querySelector('input');
|
||
const disp = block.querySelector(`#display-${key}`);
|
||
|
||
input.min = min;
|
||
input.max = max;
|
||
input.step = step;
|
||
|
||
input.addEventListener('input', () => {
|
||
disp.textContent = formatVal(input.value, type);
|
||
});
|
||
|
||
input.addEventListener('change', () => {
|
||
const val = type === 'float' ? parseFloat(input.value) : parseInt(input.value);
|
||
sendSet(key, val);
|
||
});
|
||
});
|
||
}
|
||
|
||
function formatVal(v, type) {
|
||
return type === 'float' ? parseFloat(v).toFixed(1) : parseInt(v);
|
||
}
|
||
|
||
function applySlider(key, value) {
|
||
const input = document.getElementById(`input-${key}`);
|
||
const disp = document.getElementById(`display-${key}`);
|
||
if (!input) return;
|
||
const block = input.closest('.setting-block');
|
||
const type = block.dataset.type;
|
||
input.value = value;
|
||
disp.textContent = formatVal(value, type);
|
||
}
|
||
|
||
async function sendSet(key, value) {
|
||
const status = document.getElementById('save-status');
|
||
status.textContent = `Sende ${key} = ${value}…`;
|
||
status.className = 'mt-4 text-sm text-blue-500';
|
||
status.classList.remove('hidden');
|
||
|
||
try {
|
||
const res = await fetch('/api/set', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ key, value })
|
||
});
|
||
const data = await res.json();
|
||
if (data.ok) {
|
||
status.textContent = `✓ ${key} = ${value} gespeichert`;
|
||
status.className = 'mt-4 text-sm text-green-600';
|
||
} else {
|
||
status.textContent = `✗ Fehler: ${data.error}`;
|
||
status.className = 'mt-4 text-sm text-red-500';
|
||
}
|
||
} catch (e) {
|
||
status.textContent = `✗ Netzwerkfehler`;
|
||
status.className = 'mt-4 text-sm text-red-500';
|
||
}
|
||
}
|
||
|
||
// ── Daten polling ─────────────────────────────────────────────────────────────
|
||
function fmt(v, dec = 1) {
|
||
if (v === null || v === undefined) return '–';
|
||
return typeof v === 'number' ? v.toFixed(dec) : v;
|
||
}
|
||
|
||
async function refreshData() {
|
||
try {
|
||
const [dataRes, histRes] = await Promise.all([
|
||
fetch('/api/data'),
|
||
fetch('/api/history')
|
||
]);
|
||
const data = await dataRes.json();
|
||
const hist = await histRes.json();
|
||
|
||
// Verbindungsstatus
|
||
const dot = document.getElementById('serial-dot');
|
||
const label = document.getElementById('serial-label');
|
||
if (data.serial_connected && !data.data_stale) {
|
||
dot.className = 'w-2 h-2 rounded-full bg-green-500';
|
||
label.textContent = 'Arduino verbunden';
|
||
} else if (data.serial_connected && data.data_stale) {
|
||
dot.className = 'w-2 h-2 rounded-full bg-yellow-400';
|
||
label.textContent = 'Keine Daten (> 30 s)';
|
||
} else {
|
||
dot.className = 'w-2 h-2 rounded-full bg-red-500';
|
||
label.textContent = 'Arduino getrennt';
|
||
}
|
||
|
||
document.getElementById('last-update').textContent =
|
||
'Zuletzt: ' + new Date().toLocaleTimeString('de');
|
||
|
||
// DB-Status Banner
|
||
const dbBanner = document.getElementById('db-banner');
|
||
if (data.db_configured && !data.db_connected) {
|
||
dbBanner.classList.remove('hidden');
|
||
document.getElementById('db-error-msg').textContent = data.db_error || 'Unbekannter Fehler';
|
||
} else {
|
||
dbBanner.classList.add('hidden');
|
||
}
|
||
|
||
// Sensor-Werte
|
||
document.getElementById('aussentemp').textContent = fmt(data.aussentemp);
|
||
document.getElementById('wohnung').textContent = fmt(data.wohnung);
|
||
document.getElementById('vorlauf_ist').textContent = fmt(data.vorlauf_ist);
|
||
document.getElementById('vorlauf_soll').textContent = fmt(data.vorlauf_soll);
|
||
document.getElementById('kessel_oben').textContent = fmt(data.kessel_oben);
|
||
document.getElementById('kessel_unten').textContent = fmt(data.kessel_unten);
|
||
document.getElementById('abgas').textContent = fmt(data.abgas, 0);
|
||
|
||
// Mischerstellung in Prozent
|
||
if (data.stellung !== undefined && data.time_adjust) {
|
||
const proz = Math.round(data.stellung / data.time_adjust * 100);
|
||
document.getElementById('stellung').textContent = proz;
|
||
}
|
||
const badge = document.getElementById('mischeron-badge');
|
||
badge.innerHTML = data.mischeron
|
||
? '<span class="badge-on">Mischer an</span>'
|
||
: '<span class="badge-off">Mischer aus</span>';
|
||
|
||
// Regelungsdiagnose
|
||
if (data.abweichung !== undefined) {
|
||
const abw = data.abweichung;
|
||
const abwEl = document.getElementById('abweichung');
|
||
abwEl.textContent = (abw >= 0 ? '+' : '') + abw.toFixed(1);
|
||
abwEl.className = 'text-xl font-bold ' + (Math.abs(abw) > 8 ? 'text-red-600' :
|
||
Math.abs(abw) > 4 ? 'text-orange-500' :
|
||
Math.abs(abw) > 1.5 ? 'text-yellow-600' : 'text-green-600');
|
||
}
|
||
// Regelzone bestimmen (clientseitig aus Abweichung ableiten)
|
||
if (data.abweichung !== undefined && data.check_intervall !== undefined) {
|
||
const abwAbs = Math.abs(data.abweichung);
|
||
const zone = abwAbs > 8 ? 'Sprint' : abwAbs > 4 ? 'Jogging' : abwAbs > 1.5 ? 'Normal' : 'Totband';
|
||
document.getElementById('regelzone').textContent = zone;
|
||
document.getElementById('puls-info').textContent =
|
||
(data.puls_zeit || '–') + ' s Puls';
|
||
document.getElementById('check-intervall').textContent = data.check_intervall || '–';
|
||
}
|
||
document.getElementById('puls-aktiv-badge').innerHTML =
|
||
data.puls_aktiv ? '<span class="badge-on">Puls aktiv</span>' :
|
||
'<span class="badge-off">kein Puls</span>';
|
||
document.getElementById('warmup-badge').innerHTML =
|
||
data.warmup ? '<span class="badge-on">Anlaufphase</span>' :
|
||
'<span class="badge-off">Normalbetrieb</span>';
|
||
|
||
// Slider nur beim ersten Laden setzen (danach steuert der User)
|
||
const keys = ['neigung','rtsoll','nachtabs','misch_begin','tag_anfang','tag_ende'];
|
||
keys.forEach(k => {
|
||
const input = document.getElementById(`input-${k}`);
|
||
if (input && input.dataset.loaded !== 'true' && data[k] !== undefined) {
|
||
applySlider(k, data[k]);
|
||
input.dataset.loaded = 'true';
|
||
}
|
||
});
|
||
|
||
updateChart(hist);
|
||
} catch (e) {
|
||
console.warn('Refresh-Fehler:', e);
|
||
}
|
||
}
|
||
|
||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||
setupSliders();
|
||
refreshData();
|
||
setInterval(refreshData, 5000); // alle 5 Sekunden aktualisieren
|
||
</script>
|
||
</body>
|
||
</html>
|