Files
homeassistant-config/esphome/components/air_quality_web/page.h
T

237 lines
12 KiB
C++
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.
#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}
.history{display:grid;gap:1rem;margin:1rem 0 2rem}.history article{padding:1rem}
.history h3{font-size:1.1rem;margin:0 0 .5rem}.legend{font-size:.9rem;color:#526173;margin:0 0 .5rem}
canvas{display:block;width:100%;height:15rem}#history-status{line-height:1.5}
@media(prefers-color-scheme:dark){.legend{color:#b1c0d1}}
@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>
<section aria-labelledby="history-title">
<h2 id="history-title">Verlauf · letzte 10 Stunden</h2>
<p id="history-status" role="status" aria-live="polite">Verlauf wird geladen …</p>
<div class="history">
<article><h3>CO₂</h3><p class="legend">CO₂ in ppm · Hilfslinien bei 1200 / 1600 ppm, sofern im Wertebereich</p><canvas id="co2-chart" role="img" aria-label="CO₂ in ppm über die letzten 10 Stunden">CO₂-Verlauf in ppm</canvas></article>
<article><h3>Temperatur</h3><p class="legend">Temperatur in °C</p><canvas id="temperature-chart" role="img" aria-label="Temperatur in Grad Celsius über die letzten 10 Stunden">Temperaturverlauf in °C</canvas></article>
<article><h3>Feuchtigkeit</h3><p class="legend">Relative Feuchtigkeit in %</p><canvas id="humidity-chart" role="img" aria-label="Relative Feuchtigkeit in Prozent über die letzten 10 Stunden">Feuchtigkeitsverlauf in %</canvas></article>
</div>
</section>
<footer>CO₂: Orange bei mehr als 1200 ppm, Rot bei mehr als 1600 ppm.<br>Verlauf: eine Messung pro Minute, maximal 10 Stunden. Speicherung nur im RAM des Geräts; bei einem Neustart geht der Verlauf verloren. Lücken bedeuten fehlende Messdaten. Aktualisierung etwa jede Minute.</footer>
<noscript>Bitte JavaScript aktivieren, um die Messwerte und den Verlauf 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;
}
const duration = 36000;
const historyStatus = document.getElementById('history-status');
const canvases = keys.map(key => document.getElementById(key + '-chart'));
const units = ['ppm', '°C', '%'];
const axisFormat = new Intl.NumberFormat('de-DE', {maximumFractionDigits:2});
let history = null;
let historyFailed = false;
let nextHistory = 0;
function drawHistory() {
const elapsed = history ? Math.max(0, (performance.now() - history.loadedAt) / 1000) : 0;
if (history) {
const last = 'Letzte Ladung: ' + history.loadedLabel;
historyStatus.textContent = historyFailed || elapsed > 90
? 'Verlauf derzeit nicht aktuell / nicht verfügbar · ' + last + ' · zwischengespeicherte Daten'
: last + ' · ' + (history.samples.length ? 'Zeitfenster: −10 h bis Jetzt' : 'Noch keine Messdaten seit Gerätestart');
} else {
historyStatus.textContent = historyFailed ? 'Verlauf nicht verfügbar · erneuter Versuch in etwa 15 Sekunden' : 'Verlauf wird geladen …';
}
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const ink = dark ? '#b1c0d1' : '#526173';
const grid = dark ? '#415267' : '#d8e0e9';
const colors = dark ? ['#77c9ff', '#ffbd74', '#7edcc5'] : ['#0369a1', '#a64b00', '#087f6b'];
canvases.forEach((canvas, index) => {
const width = canvas.getBoundingClientRect().width;
const height = canvas.getBoundingClientRect().height;
if (!width || !height) return;
const ratio = window.devicePixelRatio || 1;
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
const ctx = canvas.getContext('2d');
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
ctx.clearRect(0, 0, width, height);
ctx.font = '12px system-ui, sans-serif';
const samples = history ? history.samples.filter(s => s[0] + elapsed <= duration) : [];
const values = samples.map(s => s[index + 1]).filter(Number.isFinite);
let low = values.length ? Math.min(...values) : 0;
let high = values.length ? Math.max(...values) : 1;
const padding = Math.max((high - low) * .1, Math.abs(low) * .02, [20, .5, 1][index]);
low -= padding;
high += padding;
const ticks = Array.from({length:5}, (_, n) => high - (high - low) * n / 4);
const left = Math.max(48, ...ticks.map(v => ctx.measureText(axisFormat.format(v)).width + 12));
const right = width - 20, top = 20, bottom = height - 34;
const x = age => left + (1 - (age + elapsed) / duration) * (right - left);
const y = value => bottom - (value - low) / (high - low) * (bottom - top);
ctx.lineWidth = 1;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ticks.forEach(value => {
ctx.strokeStyle = grid;
ctx.beginPath(); ctx.moveTo(left, y(value)); ctx.lineTo(right, y(value)); ctx.stroke();
ctx.fillStyle = ink;
ctx.fillText(axisFormat.format(value), left - 8, y(value));
});
ctx.textBaseline = 'top';
for (let hour = 0; hour <= 10; hour += 2) {
const px = left + hour / 10 * (right - left);
ctx.strokeStyle = grid;
ctx.beginPath(); ctx.moveTo(px, top); ctx.lineTo(px, bottom); ctx.stroke();
ctx.fillStyle = ink;
ctx.textAlign = hour === 0 ? 'left' : hour === 10 ? 'right' : 'center';
ctx.fillText(hour === 10 ? 'Jetzt' : '−' + (10 - hour) + ' h', px, bottom + 10);
}
ctx.textAlign = 'left';
ctx.fillStyle = ink;
ctx.fillText(units[index], left, 0);
if (index === 0) {
[1200, 1600].forEach((limit, i) => {
if (limit < low || limit > high) return;
ctx.strokeStyle = i ? '#c52b2b' : '#c05a00';
ctx.setLineDash([5, 4]);
ctx.beginPath(); ctx.moveTo(left, y(limit)); ctx.lineTo(right, y(limit)); ctx.stroke();
});
ctx.setLineDash([]);
}
ctx.strokeStyle = colors[index];
ctx.fillStyle = colors[index];
ctx.lineWidth = 2;
ctx.beginPath();
let previous = null;
samples.forEach(sample => {
const value = sample[index + 1];
if (!Number.isFinite(value)) { previous = null; return; }
if (previous && previous[0] - sample[0] <= 90) ctx.lineTo(x(sample[0]), y(value));
else ctx.moveTo(x(sample[0]), y(value));
previous = sample;
});
ctx.stroke();
// Dots also make isolated samples visible without inventing a line to "now".
ctx.beginPath();
samples.forEach(sample => {
if (!Number.isFinite(sample[index + 1])) return;
const px = x(sample[0]), py = y(sample[index + 1]);
ctx.moveTo(px + 1.8, py); ctx.arc(px, py, 1.8, 0, Math.PI * 2);
});
ctx.fill();
if (!values.length) {
ctx.fillStyle = ink;
ctx.textAlign = 'center';
ctx.fillText(history ? 'Keine Messdaten im Zeitfenster' : 'Verlauf nicht verfügbar', (left + right) / 2, (top + bottom) / 2);
}
});
}
async function getJSON(path) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 4000);
try {
const response = await fetch(path, {cache:'no-store', credentials:'omit', redirect:'error', signal:controller.signal});
if (!response.ok) throw new Error('HTTP');
return await response.json();
} finally {
clearTimeout(timeout);
}
}
async function loadLive() {
try {
const data = await getJSON('/api/air-quality');
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';
}
}
async function loadHistory() {
try {
const data = await getJSON('/api/air-quality/history');
if (!data || data.duration_seconds !== duration || !Array.isArray(data.samples) || data.samples.length > 601) throw new Error('Datenformat');
let previousAge = duration;
const samples = data.samples.map(sample => {
if (!Array.isArray(sample) || sample.length !== 4 || !Number.isFinite(sample[0]) || sample[0] < 0 || sample[0] > previousAge ||
!sample.slice(1).every(v => v === null || typeof v === 'number')) throw new Error('Datenformat');
previousAge = sample[0];
return [sample[0], ...sample.slice(1).map(v => Number.isFinite(v) ? v : null)];
});
history = {samples, loadedAt:performance.now(), loadedLabel:new Date().toLocaleTimeString('de-DE')};
historyFailed = false;
nextHistory = performance.now() + 60000;
} catch (_) {
// Keep previous samples, but never describe a failed refresh as current.
historyFailed = true;
nextHistory = performance.now() + 15000;
}
}
async function poll() {
await loadLive();
const nextLive = performance.now() + 5000;
// One shared loop serializes both endpoints, each with its own abort timer.
if (performance.now() >= nextHistory) await loadHistory();
drawHistory();
setTimeout(poll, Math.max(0, nextLive - performance.now()));
}
window.addEventListener('resize', drawHistory);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', drawHistory);
poll();
</script>
</body>
</html>)AQPAGE";
} // namespace esphome::air_quality_web