Add isolated air quality web page
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user