Add air quality history charts

This commit is contained in:
2026-11-09 11:32:05 +01:00
parent d1f3cf2959
commit 7cfa2102ed
4 changed files with 329 additions and 29 deletions
+37 -9
View File
@@ -2,8 +2,8 @@
Local external component for **ESPHome 2026.10.2, ESP32 with ESP-IDF**. No remote
libraries, standard `web_server`, entity enumeration, SSE, OTA, configuration,
authentication, or history. Only the three explicitly referenced sensor states
are published. Other device services are not secured by this component. The
or authentication. Only the three explicitly referenced sensor states and their
10-hour air-quality history are published. Other device services are not secured by this component. The
optional mDNS hostname override affects the device-wide mDNS responder.
## Integration (in the parent configuration)
@@ -61,16 +61,36 @@ uses its native mDNS library; it does not initialize a second responder.
* Exact `GET /api/air-quality`: JSON with **exactly** `co2`, `temperature`,
`humidity`, each a finite number or `null`. Example:
`{"co2":850,"temperature":22.5,"humidity":46}`.
* Exact `GET /api/air-quality/history`: JSON containing `duration_seconds: 36000`
and `samples`, ordered oldest first. Each sample is
`[age_seconds, co2, temperature, humidity]`, with nonnegative ages relative to
the response snapshot and finite values or `null`. Only samples within the
last 10 hours are returned; there are at most 601 samples.
* Non-GET methods on those paths: 405 with `Allow: GET`.
* All other paths: 404, including `/events`, `/update`, `/config`, general sensor
APIs, trailing slashes, encoded aliases, and query-string variants. No body
parsing or upload handlers. HTTP parser errors can still be rejected by IDF
before application routing.
The browser polls five seconds after each completed request (4-second timeout),
never concurrently. Missing measurements show dashes. Network/JSON errors clear
old measurements and display an offline message. CO₂ is orange **only above**
1200 ppm and red **only above** 1600 ppm; text accompanies the colors.
The browser refreshes live readings approximately every five seconds and history
approximately every minute, using one sequential request loop and a separate
4-second timeout for each request. Missing live measurements show dashes. A live
request failure clears the live cards; a history failure retains previously
loaded history with a stale/unavailable warning, without clearing healthy live
cards. CO₂ is orange **only above** 1200 ppm and red **only above** 1600 ppm;
text accompanies the colors.
Three separate responsive charts show CO₂ (ppm), temperature (°C), and relative
humidity (%) on a fixed −10 h to now axis, with independent automatic Y scales.
Missing values and timestamp gaps over 90 seconds break the traces. Partial
history is not stretched across the time axis or extrapolated to the present.
CO₂ guides at 1200 and 1600 ppm appear when within the chart's value range.
The device records one sample every 60 seconds, independently of browser, VPN,
or Home Assistant connections. History is kept only in RAM and starts empty
following reboot; firmware updates therefore also clear it. There are no flash
writes for history. Delayed sampling is represented by actual timestamps, not
fabricated catch-up samples.
## Isolation, security, and resource use
@@ -119,7 +139,13 @@ IDF's own timeouts for receiving headers or sending responses.
A fixed 1-second `PollingComponent` serializes only the three configured sensors
on the main loop. A mutex protects replacement and copying of the cached JSON;
HTTP sends happen after releasing it. The HTTP task never reads live sensors or
the entity registry. Uninitialized, missing, NaN and infinite states become
the entity registry. The history ring uses 601 compact 16-byte samples (9,616
bytes), plus small bookkeeping fields. Each history request allocates a bounded
heap snapshot of approximately the same size, then streams JSON in chunks of at
most 512 bytes; the ring is not copied onto the small HTTP task stack and no
whole-response JSON string is allocated. Allocation failure returns 503, and
send failures close the connection. Sample ages use rollover-safe uptime
arithmetic and old samples are pruned on the main loop. Uninitialized, missing, NaN and infinite states become
`null`. Sensor expiry stays with the parent configuration's existing filters
(e.g. `timeout: 30s`); this component adds no independent sample-age policy.
@@ -128,7 +154,9 @@ the entity registry. Uninitialized, missing, NaN and infinite states become
APIs were inspected against installed ESPHome 2026.10.2 and local ESP-IDF headers.
Host checks cover schema/socket registration and codegen, C++ routing/serialization
with API stand-ins (including send/shutdown/close ordering and close-queue
failures), and the embedded JavaScript (threshold boundaries, missing
and offline states, timeout and sequential polling). These are not a firmware or
failures, history retention/wraparound, uptime rollover, allocation failures, and
chunked-send failures), and the embedded JavaScript (threshold boundaries,
missing and offline states, chart bounds/gaps, timeout and sequential polling).
These are not a firmware or
on-device integration test. The parent integration must build and check both
listeners on hardware, including captive portal operation while Wi-Fi is down.
@@ -1,6 +1,7 @@
#include "air_quality_web.h"
#include "page.h"
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
#include <mdns.h>
#include <lwip/sockets.h>
@@ -10,13 +11,14 @@
#include <cstdio>
#include <cstring>
#include <utility>
#include <memory>
#include <new>
namespace esphome::air_quality_web {
static const char *const TAG = "air_quality_web";
static esp_err_t send_and_close(httpd_req_t *request, const char *content, ssize_t length) {
const esp_err_t send_err = httpd_resp_send(request, content, length);
static esp_err_t close_response(httpd_req_t *request, esp_err_t send_err) {
const int fd = httpd_req_to_sockfd(request);
// IDF drains unread Content-Length synchronously before processing queued work.
// Stop receive first so cleanup cannot wait for a slow/incomplete request body.
@@ -32,6 +34,10 @@ static esp_err_t send_and_close(httpd_req_t *request, const char *content, ssize
return shutdown_result == 0 && close_err == ESP_OK ? ESP_OK : ESP_FAIL;
}
static esp_err_t send_and_close(httpd_req_t *request, const char *content, ssize_t length) {
return close_response(request, httpd_resp_send(request, content, length));
}
esp_err_t AirQualityServer::start(esp_err_t (*handler)(httpd_req_t *), void *context) {
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.stack_size = 4512;
@@ -78,21 +84,54 @@ void AirQualityWeb::setup() {
}
// Only called on the main loop; never dereference a Sensor in the HTTP task.
static std::string sensor_json(sensor::Sensor *sensor) {
static float sensor_value(sensor::Sensor *sensor) {
if (sensor == nullptr || !sensor->has_state() || !std::isfinite(sensor->state))
return "null";
char value[32];
return NAN;
return sensor->state;
}
static void format_value(char *buffer, size_t size, float value) {
// Nine significant digits cover a float, even at its finite extrema.
snprintf(value, sizeof(value), "%.9g", static_cast<double>(sensor->state));
return value;
if (std::isfinite(value))
snprintf(buffer, size, "%.9g", static_cast<double>(value));
else
snprintf(buffer, size, "null");
}
static std::string sensor_json(float value) {
char buffer[32];
format_value(buffer, sizeof(buffer), value);
return buffer;
}
void AirQualityWeb::update() {
std::string next = "{\"co2\":" + sensor_json(this->co2_) +
",\"temperature\":" + sensor_json(this->temperature_) +
",\"humidity\":" + sensor_json(this->humidity_) + "}";
const float co2 = sensor_value(this->co2_);
const float temperature = sensor_value(this->temperature_);
const float humidity = sensor_value(this->humidity_);
std::string next = "{\"co2\":" + sensor_json(co2) +
",\"temperature\":" + sensor_json(temperature) +
",\"humidity\":" + sensor_json(humidity) + "}";
LockGuard lock(this->snapshot_mutex_);
const uint32_t now = millis();
this->json_ = std::move(next);
// Prune on the main loop too, so old entries cannot reappear after millis wraps.
while (this->history_count_ != 0 &&
uint32_t(now - this->history_[this->history_head_].timestamp) > HISTORY_DURATION_MS) {
this->history_head_ = (this->history_head_ + 1) % HISTORY_CAPACITY;
--this->history_count_;
}
if (!this->has_sample_ || uint32_t(now - this->last_sample_ms_) >= HISTORY_INTERVAL_MS) {
// Delayed updates produce a gap, never fabricated catch-up samples.
if (this->history_count_ == HISTORY_CAPACITY) {
this->history_head_ = (this->history_head_ + 1) % HISTORY_CAPACITY;
--this->history_count_;
}
const size_t index = (this->history_head_ + this->history_count_) % HISTORY_CAPACITY;
this->history_[index] = {now, co2, temperature, humidity};
++this->history_count_;
this->last_sample_ms_ = now;
this->has_sample_ = true;
}
}
std::string AirQualityWeb::snapshot_() {
@@ -100,11 +139,64 @@ std::string AirQualityWeb::snapshot_() {
return this->json_;
}
esp_err_t AirQualityWeb::send_history_(httpd_req_t *request) {
// The HTTP task has only 4512 bytes of stack; keep the bounded copy on the heap.
std::unique_ptr<HistorySnapshot> snapshot(new (std::nothrow) HistorySnapshot);
if (!snapshot) {
httpd_resp_set_status(request, "503 Service Unavailable");
httpd_resp_set_type(request, "text/plain; charset=utf-8");
return send_and_close(request, "Service Unavailable\n", HTTPD_RESP_USE_STRLEN);
}
{
LockGuard lock(this->snapshot_mutex_);
snapshot->timestamp = millis();
snapshot->count = 0;
for (size_t i = 0; i < this->history_count_; ++i) {
const auto &sample = this->history_[(this->history_head_ + i) % HISTORY_CAPACITY];
if (uint32_t(snapshot->timestamp - sample.timestamp) <= HISTORY_DURATION_MS)
snapshot->samples[snapshot->count++] = sample;
}
}
char chunk[512];
size_t used = snprintf(chunk, sizeof(chunk), "{\"duration_seconds\":36000,\"samples\":[");
for (size_t i = 0; i < snapshot->count; ++i) {
const auto &sample = snapshot->samples[i];
char co2[32], temperature[32], humidity[32], row[128];
format_value(co2, sizeof(co2), sample.co2);
format_value(temperature, sizeof(temperature), sample.temperature);
format_value(humidity, sizeof(humidity), sample.humidity);
const size_t length = snprintf(row, sizeof(row), "%s[%lu,%s,%s,%s]", i == 0 ? "" : ",",
static_cast<unsigned long>(uint32_t(snapshot->timestamp - sample.timestamp) / 1000),
co2, temperature, humidity);
if (used + length > sizeof(chunk)) {
const esp_err_t err = httpd_resp_send_chunk(request, chunk, used);
if (err != ESP_OK)
return close_response(request, err);
used = 0;
}
memcpy(chunk + used, row, length);
used += length;
}
if (used + 2 > sizeof(chunk)) {
const esp_err_t err = httpd_resp_send_chunk(request, chunk, used);
if (err != ESP_OK)
return close_response(request, err);
used = 0;
}
memcpy(chunk + used, "]}", 2);
const esp_err_t err = httpd_resp_send_chunk(request, chunk, used + 2);
if (err != ESP_OK)
return close_response(request, err);
return close_response(request, httpd_resp_send_chunk(request, nullptr, 0));
}
esp_err_t AirQualityWeb::handle_request_(httpd_req_t *request) {
// Raw exact paths: no decoding, prefix matching or redirects into other APIs.
const bool page = strcmp(request->uri, "/") == 0;
const bool api = strcmp(request->uri, "/api/air-quality") == 0;
const bool known = page || api;
const bool history = strcmp(request->uri, "/api/air-quality/history") == 0;
const bool known = page || api || history;
const bool allowed = request->method == HTTP_GET;
httpd_resp_set_hdr(request, "Cache-Control", "no-store, max-age=0");
httpd_resp_set_hdr(request, "Pragma", "no-cache");
@@ -130,6 +222,8 @@ esp_err_t AirQualityWeb::handle_request_(httpd_req_t *request) {
}
httpd_resp_set_type(request, "application/json");
auto *component = static_cast<AirQualityWeb *>(request->user_ctx);
if (history)
return component->send_history_(request);
// Release the mutex before the potentially blocking network send.
const std::string json = component->snapshot_();
return send_and_close(request, json.data(), json.size());
@@ -5,6 +5,8 @@
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/web_server_base/web_server_base.h"
#include <cstddef>
#include <cstdint>
#include <string>
namespace esphome::air_quality_web {
@@ -34,6 +36,23 @@ class AirQualityWeb : public PollingComponent {
protected:
static esp_err_t handle_request_(httpd_req_t *request);
std::string snapshot_();
esp_err_t send_history_(httpd_req_t *request);
static constexpr uint32_t HISTORY_DURATION_MS = 36000000;
static constexpr uint32_t HISTORY_INTERVAL_MS = 60000;
static constexpr size_t HISTORY_CAPACITY = 601;
struct HistorySample {
uint32_t timestamp;
float co2;
float temperature;
float humidity;
};
static_assert(sizeof(HistorySample) == 16, "History samples must remain compact");
struct HistorySnapshot {
uint32_t timestamp;
size_t count;
HistorySample samples[HISTORY_CAPACITY];
};
AirQualityServer server_;
std::string mdns_hostname_;
@@ -41,6 +60,11 @@ class AirQualityWeb : public PollingComponent {
sensor::Sensor *temperature_{nullptr};
sensor::Sensor *humidity_{nullptr};
Mutex snapshot_mutex_;
HistorySample history_[HISTORY_CAPACITY]{};
size_t history_head_{0};
size_t history_count_{0};
uint32_t last_sample_ms_{0};
bool has_sample_{false};
std::string json_{"{\"co2\":null,\"temperature\":null,\"humidity\":null}"};
};
+163 -9
View File
@@ -19,6 +19,10 @@ h2{font-size:1.1rem;margin:0 0 1rem}.value{font-size:clamp(2.2rem,6vw,3.6rem);fo
.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>
@@ -31,8 +35,17 @@ article.good{border-color:#15803d}article.orange{border-color:#c05a00;background
<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>
<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';
@@ -55,25 +68,166 @@ function render(data) {
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 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('/api/air-quality', {cache:'no-store', credentials:'omit', redirect:'error', signal:controller.signal});
const response = await fetch(path, {cache:'no-store', credentials:'omit', redirect:'error', signal:controller.signal});
if (!response.ok) throw new Error('HTTP');
const data = await response.json();
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';
} finally {
clearTimeout(timeout);
// Schedule only after fetch and JSON parsing finish: never overlap requests.
setTimeout(poll, 5000);
}
}
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>