Add air quality history charts
This commit is contained in:
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
Local external component for **ESPHome 2026.10.2, ESP32 with ESP-IDF**. No remote
|
Local external component for **ESPHome 2026.10.2, ESP32 with ESP-IDF**. No remote
|
||||||
libraries, standard `web_server`, entity enumeration, SSE, OTA, configuration,
|
libraries, standard `web_server`, entity enumeration, SSE, OTA, configuration,
|
||||||
authentication, or history. Only the three explicitly referenced sensor states
|
or authentication. Only the three explicitly referenced sensor states and their
|
||||||
are published. Other device services are not secured by this component. The
|
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.
|
optional mDNS hostname override affects the device-wide mDNS responder.
|
||||||
|
|
||||||
## Integration (in the parent configuration)
|
## 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`,
|
* Exact `GET /api/air-quality`: JSON with **exactly** `co2`, `temperature`,
|
||||||
`humidity`, each a finite number or `null`. Example:
|
`humidity`, each a finite number or `null`. Example:
|
||||||
`{"co2":850,"temperature":22.5,"humidity":46}`.
|
`{"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`.
|
* Non-GET methods on those paths: 405 with `Allow: GET`.
|
||||||
* All other paths: 404, including `/events`, `/update`, `/config`, general sensor
|
* All other paths: 404, including `/events`, `/update`, `/config`, general sensor
|
||||||
APIs, trailing slashes, encoded aliases, and query-string variants. No body
|
APIs, trailing slashes, encoded aliases, and query-string variants. No body
|
||||||
parsing or upload handlers. HTTP parser errors can still be rejected by IDF
|
parsing or upload handlers. HTTP parser errors can still be rejected by IDF
|
||||||
before application routing.
|
before application routing.
|
||||||
|
|
||||||
The browser polls five seconds after each completed request (4-second timeout),
|
The browser refreshes live readings approximately every five seconds and history
|
||||||
never concurrently. Missing measurements show dashes. Network/JSON errors clear
|
approximately every minute, using one sequential request loop and a separate
|
||||||
old measurements and display an offline message. CO₂ is orange **only above**
|
4-second timeout for each request. Missing live measurements show dashes. A live
|
||||||
1200 ppm and red **only above** 1600 ppm; text accompanies the colors.
|
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
|
## 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
|
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;
|
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
|
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
|
`null`. Sensor expiry stays with the parent configuration's existing filters
|
||||||
(e.g. `timeout: 30s`); this component adds no independent sample-age policy.
|
(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.
|
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
|
Host checks cover schema/socket registration and codegen, C++ routing/serialization
|
||||||
with API stand-ins (including send/shutdown/close ordering and close-queue
|
with API stand-ins (including send/shutdown/close ordering and close-queue
|
||||||
failures), and the embedded JavaScript (threshold boundaries, missing
|
failures, history retention/wraparound, uptime rollover, allocation failures, and
|
||||||
and offline states, timeout and sequential polling). These are not a firmware or
|
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
|
on-device integration test. The parent integration must build and check both
|
||||||
listeners on hardware, including captive portal operation while Wi-Fi is down.
|
listeners on hardware, including captive portal operation while Wi-Fi is down.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "air_quality_web.h"
|
#include "air_quality_web.h"
|
||||||
#include "page.h"
|
#include "page.h"
|
||||||
#include "esphome/core/log.h"
|
#include "esphome/core/log.h"
|
||||||
|
#include "esphome/core/hal.h"
|
||||||
|
|
||||||
#include <mdns.h>
|
#include <mdns.h>
|
||||||
#include <lwip/sockets.h>
|
#include <lwip/sockets.h>
|
||||||
@@ -10,13 +11,14 @@
|
|||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
#include <memory>
|
||||||
|
#include <new>
|
||||||
|
|
||||||
namespace esphome::air_quality_web {
|
namespace esphome::air_quality_web {
|
||||||
|
|
||||||
static const char *const TAG = "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) {
|
static esp_err_t close_response(httpd_req_t *request, esp_err_t send_err) {
|
||||||
const esp_err_t send_err = httpd_resp_send(request, content, length);
|
|
||||||
const int fd = httpd_req_to_sockfd(request);
|
const int fd = httpd_req_to_sockfd(request);
|
||||||
// IDF drains unread Content-Length synchronously before processing queued work.
|
// IDF drains unread Content-Length synchronously before processing queued work.
|
||||||
// Stop receive first so cleanup cannot wait for a slow/incomplete request body.
|
// 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;
|
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) {
|
esp_err_t AirQualityServer::start(esp_err_t (*handler)(httpd_req_t *), void *context) {
|
||||||
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
||||||
config.stack_size = 4512;
|
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.
|
// 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))
|
if (sensor == nullptr || !sensor->has_state() || !std::isfinite(sensor->state))
|
||||||
return "null";
|
return NAN;
|
||||||
char value[32];
|
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.
|
// Nine significant digits cover a float, even at its finite extrema.
|
||||||
snprintf(value, sizeof(value), "%.9g", static_cast<double>(sensor->state));
|
if (std::isfinite(value))
|
||||||
return 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() {
|
void AirQualityWeb::update() {
|
||||||
std::string next = "{\"co2\":" + sensor_json(this->co2_) +
|
const float co2 = sensor_value(this->co2_);
|
||||||
",\"temperature\":" + sensor_json(this->temperature_) +
|
const float temperature = sensor_value(this->temperature_);
|
||||||
",\"humidity\":" + sensor_json(this->humidity_) + "}";
|
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_);
|
LockGuard lock(this->snapshot_mutex_);
|
||||||
|
const uint32_t now = millis();
|
||||||
this->json_ = std::move(next);
|
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_() {
|
std::string AirQualityWeb::snapshot_() {
|
||||||
@@ -100,11 +139,64 @@ std::string AirQualityWeb::snapshot_() {
|
|||||||
return this->json_;
|
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) {
|
esp_err_t AirQualityWeb::handle_request_(httpd_req_t *request) {
|
||||||
// Raw exact paths: no decoding, prefix matching or redirects into other APIs.
|
// Raw exact paths: no decoding, prefix matching or redirects into other APIs.
|
||||||
const bool page = strcmp(request->uri, "/") == 0;
|
const bool page = strcmp(request->uri, "/") == 0;
|
||||||
const bool api = strcmp(request->uri, "/api/air-quality") == 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;
|
const bool allowed = request->method == HTTP_GET;
|
||||||
httpd_resp_set_hdr(request, "Cache-Control", "no-store, max-age=0");
|
httpd_resp_set_hdr(request, "Cache-Control", "no-store, max-age=0");
|
||||||
httpd_resp_set_hdr(request, "Pragma", "no-cache");
|
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");
|
httpd_resp_set_type(request, "application/json");
|
||||||
auto *component = static_cast<AirQualityWeb *>(request->user_ctx);
|
auto *component = static_cast<AirQualityWeb *>(request->user_ctx);
|
||||||
|
if (history)
|
||||||
|
return component->send_history_(request);
|
||||||
// Release the mutex before the potentially blocking network send.
|
// Release the mutex before the potentially blocking network send.
|
||||||
const std::string json = component->snapshot_();
|
const std::string json = component->snapshot_();
|
||||||
return send_and_close(request, json.data(), json.size());
|
return send_and_close(request, json.data(), json.size());
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
#include "esphome/components/sensor/sensor.h"
|
#include "esphome/components/sensor/sensor.h"
|
||||||
#include "esphome/components/web_server_base/web_server_base.h"
|
#include "esphome/components/web_server_base/web_server_base.h"
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
namespace esphome::air_quality_web {
|
namespace esphome::air_quality_web {
|
||||||
@@ -34,6 +36,23 @@ class AirQualityWeb : public PollingComponent {
|
|||||||
protected:
|
protected:
|
||||||
static esp_err_t handle_request_(httpd_req_t *request);
|
static esp_err_t handle_request_(httpd_req_t *request);
|
||||||
std::string snapshot_();
|
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_;
|
AirQualityServer server_;
|
||||||
std::string mdns_hostname_;
|
std::string mdns_hostname_;
|
||||||
@@ -41,6 +60,11 @@ class AirQualityWeb : public PollingComponent {
|
|||||||
sensor::Sensor *temperature_{nullptr};
|
sensor::Sensor *temperature_{nullptr};
|
||||||
sensor::Sensor *humidity_{nullptr};
|
sensor::Sensor *humidity_{nullptr};
|
||||||
Mutex snapshot_mutex_;
|
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}"};
|
std::string json_{"{\"co2\":null,\"temperature\":null,\"humidity\":null}"};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
.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}
|
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}
|
#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}}
|
@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>
|
</style>
|
||||||
</head>
|
</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>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>
|
<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>
|
</div>
|
||||||
<footer>CO₂: Orange bei mehr als 1200 ppm, Rot bei mehr als 1600 ppm.<br>Nur aktuelle Luftmesswerte – keine Aufzeichnung.</footer>
|
<section aria-labelledby="history-title">
|
||||||
<noscript>Bitte JavaScript aktivieren, um die aktuellen Messwerte anzuzeigen.</noscript>
|
<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>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
'use strict';
|
'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';
|
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;
|
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 controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 4000);
|
const timeout = setTimeout(() => controller.abort(), 4000);
|
||||||
try {
|
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');
|
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');
|
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);
|
const count = render(data);
|
||||||
status.textContent = count === 3 ? 'Online · Messwerte aktuell' : count === 0 ? 'Online · Noch keine Messdaten' : 'Online · Einzelne Messwerte fehlen';
|
status.textContent = count === 3 ? 'Online · Messwerte aktuell' : count === 0 ? 'Online · Noch keine Messdaten' : 'Online · Einzelne Messwerte fehlen';
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
render({co2:null, temperature:null, humidity:null});
|
render({co2:null, temperature:null, humidity:null});
|
||||||
status.textContent = 'Offline · Keine Verbindung – erneuter Versuch in 5 Sekunden';
|
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();
|
poll();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user