Add air quality history charts
This commit is contained in:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user