Files
Heizung/raspi/templates/index.html
T
2026-04-15 17:05:07 +02:00

413 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 &lt; 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>