diff --git a/esphome/components/air_quality_web/README.md b/esphome/components/air_quality_web/README.md index 25242b2..ae0fe64 100644 --- a/esphome/components/air_quality_web/README.md +++ b/esphome/components/air_quality_web/README.md @@ -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. diff --git a/esphome/components/air_quality_web/air_quality_web.cpp b/esphome/components/air_quality_web/air_quality_web.cpp index 5a29497..448896b 100644 --- a/esphome/components/air_quality_web/air_quality_web.cpp +++ b/esphome/components/air_quality_web/air_quality_web.cpp @@ -1,6 +1,7 @@ #include "air_quality_web.h" #include "page.h" #include "esphome/core/log.h" +#include "esphome/core/hal.h" #include #include @@ -10,13 +11,14 @@ #include #include #include +#include +#include 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(sensor->state)); - return value; + if (std::isfinite(value)) + snprintf(buffer, size, "%.9g", static_cast(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 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(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(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()); diff --git a/esphome/components/air_quality_web/air_quality_web.h b/esphome/components/air_quality_web/air_quality_web.h index b13fca1..342a05e 100644 --- a/esphome/components/air_quality_web/air_quality_web.h +++ b/esphome/components/air_quality_web/air_quality_web.h @@ -5,6 +5,8 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/web_server_base/web_server_base.h" +#include +#include #include 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}"}; }; diff --git a/esphome/components/air_quality_web/page.h b/esphome/components/air_quality_web/page.h index afa2dfa..1be8433 100644 --- a/esphome/components/air_quality_web/page.h +++ b/esphome/components/air_quality_web/page.h @@ -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}} @@ -31,8 +35,17 @@ article.good{border-color:#15803d}article.orange{border-color:#c05a00;background

Temperatur

— °C

Keine Messdaten

Feuchtigkeit

— %

Keine Messdaten

-
CO₂: Orange bei mehr als 1200 ppm, Rot bei mehr als 1600 ppm.
Nur aktuelle Luftmesswerte – keine Aufzeichnung.
- +
+

Verlauf · letzte 10 Stunden

+

Verlauf wird geladen …

+
+

CO₂

CO₂ in ppm · Hilfslinien bei 1200 / 1600 ppm, sofern im Wertebereich

CO₂-Verlauf in ppm
+

Temperatur

Temperatur in °C

Temperaturverlauf in °C
+

Feuchtigkeit

Relative Feuchtigkeit in %

Feuchtigkeitsverlauf in %
+
+
+
CO₂: Orange bei mehr als 1200 ppm, Rot bei mehr als 1600 ppm.
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.
+