Add isolated air quality web page

This commit is contained in:
2026-11-09 11:23:55 +01:00
parent dd95628424
commit d1f3cf2959
6 changed files with 473 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
#pragma once
namespace esphome::air_quality_web {
static constexpr char PAGE[] = R"AQPAGE(<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Luftqualität im Büro</title>
<style>
:root{font-family:system-ui,sans-serif;color-scheme:light dark;background:#f1f5f9;color:#17212f}
*{box-sizing:border-box}body{margin:0;padding:clamp(1rem,5vw,3rem)}main{max-width:58rem;margin:auto}
h1{font-size:clamp(1.7rem,5vw,2.6rem);margin-bottom:.5rem}.intro,footer{color:#526173}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(14rem,1fr));gap:1rem;margin:2rem 0}
article{background:#fff;border:2px solid #b4c2d1;border-radius:1rem;padding:1.5rem;min-width:0}
h2{font-size:1.1rem;margin:0 0 1rem}.value{font-size:clamp(2.2rem,6vw,3.6rem);font-weight:700;font-variant-numeric:tabular-nums}
.unit{font-size:1.1rem;font-weight:400}.hint{min-height:1.5em;margin-bottom:0}
article.good{border-color:#15803d}article.orange{border-color:#c05a00;background:#fff4df}article.red{border-color:#c52b2b;background:#ffeded}
#status{font-weight:600}footer{font-size:.9rem;line-height:1.6}
@media(prefers-color-scheme:dark){:root{background:#101923;color:#eef3fa}article{background:#1b2837}.intro,footer{color:#b1c0d1}article.orange{background:#402a13;border-color:#ffae4a}article.red{background:#401b21;border-color:#ff7979}}
</style>
</head>
<body><main>
<h1>Luftqualität im Büro</h1>
<p class="intro">Aktuelle Messwerte · Aktualisierung alle 5 Sekunden</p>
<p id="status" role="status" aria-live="polite">Verbindung wird hergestellt …</p>
<div class="grid">
<article id="co2-card"><h2>CO₂</h2><div class="value"><span id="co2">—</span> <span class="unit">ppm</span></div><p class="hint" id="co2-hint">Keine Messdaten</p></article>
<article><h2>Temperatur</h2><div class="value"><span id="temperature">—</span> <span class="unit">°C</span></div><p class="hint" id="temperature-hint">Keine Messdaten</p></article>
<article><h2>Feuchtigkeit</h2><div class="value"><span id="humidity">—</span> <span class="unit">%</span></div><p class="hint" id="humidity-hint">Keine Messdaten</p></article>
</div>
<footer>CO₂: Orange bei mehr als 1200 ppm, Rot bei mehr als 1600 ppm.<br>Nur aktuelle Luftmesswerte – keine Aufzeichnung.</footer>
<noscript>Bitte JavaScript aktivieren, um die aktuellen Messwerte anzuzeigen.</noscript>
</main>
<script>
'use strict';
const keys = ['co2', 'temperature', 'humidity'];
const status = document.getElementById('status');
const card = document.getElementById('co2-card');
const formats = [0, 1, 1].map(d => new Intl.NumberFormat('de-DE', {minimumFractionDigits:d, maximumFractionDigits:d}));
function render(data) {
let available = 0;
keys.forEach((key, i) => {
const value = data[key];
const valid = typeof value === 'number' && Number.isFinite(value);
document.getElementById(key).textContent = valid ? formats[i].format(value) : '—';
document.getElementById(key + '-hint').textContent = valid ? 'Aktueller Messwert' : 'Keine Messdaten';
if (valid) available++;
});
const co2 = data.co2;
const valid = typeof co2 === 'number' && Number.isFinite(co2);
card.className = valid ? (co2 > 1600 ? 'red' : co2 > 1200 ? 'orange' : 'good') : '';
if (valid) document.getElementById('co2-hint').textContent = co2 > 1600 ? 'Bitte lüften – CO₂ sehr hoch' : co2 > 1200 ? 'Lüften empfohlen – CO₂ erhöht' : 'CO₂ im grünen Bereich';
return available;
}
async function poll() {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 4000);
try {
const response = await fetch('/api/air-quality', {cache:'no-store', credentials:'omit', redirect:'error', signal:controller.signal});
if (!response.ok) throw new Error('HTTP');
const data = await response.json();
if (!data || Object.keys(data).length !== 3 || !keys.every(k => Object.hasOwn(data,k) && (data[k] === null || (typeof data[k] === 'number' && Number.isFinite(data[k]))))) throw new Error('Datenformat');
const count = render(data);
status.textContent = count === 3 ? 'Online · Messwerte aktuell' : count === 0 ? 'Online · Noch keine Messdaten' : 'Online · Einzelne Messwerte fehlen';
} catch (_) {
render({co2:null, temperature:null, humidity:null});
status.textContent = 'Offline · Keine Verbindung – erneuter Versuch in 5 Sekunden';
} finally {
clearTimeout(timeout);
// Schedule only after fetch and JSON parsing finish: never overlap requests.
setTimeout(poll, 5000);
}
}
poll();
</script>
</body>
</html>)AQPAGE";
} // namespace esphome::air_quality_web