#include "air_quality_web.h" #include "page.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" #include #include #include #include #include #include #include #include #include namespace esphome::air_quality_web { static const char *const TAG = "air_quality_web"; 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. const int shutdown_result = shutdown(fd, SHUT_RD); if (shutdown_result != 0) ESP_LOGW(TAG, "Cannot shut down HTTP receive on socket %d: errno %d", fd, errno); const esp_err_t close_err = httpd_sess_trigger_close(request->handle, fd); if (close_err != ESP_OK) ESP_LOGW(TAG, "Cannot queue HTTP session closure on socket %d: %s", fd, esp_err_to_name(close_err)); if (send_err != ESP_OK) return send_err; // Normal polls must succeed; failures make IDF discard the session directly. 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; config.server_port = this->port_; // UDP and TCP may share a port number. Avoid the portal's default UDP 32768. config.ctrl_port = this->port_; config.max_open_sockets = 3; config.max_uri_handlers = 1; config.max_resp_headers = 12; config.lru_purge_enable = true; config.close_fn = AsyncWebServer::safe_close_with_shutdown; config.uri_match_fn = [](const char *, const char *, size_t) { return true; }; esp_err_t err = httpd_start(&this->server_, &config); if (err != ESP_OK) return err; httpd_uri_t route{}; route.uri = "*"; route.method = static_cast(HTTP_ANY); route.handler = handler; route.user_ctx = context; err = httpd_register_uri_handler(this->server_, &route); if (err != ESP_OK) this->end(); return err; } void AirQualityWeb::setup() { // ESPHome mDNS initializes at AFTER_CONNECTION, before this component. if (!this->mdns_hostname_.empty()) { const esp_err_t err = mdns_hostname_set(this->mdns_hostname_.c_str()); if (err != ESP_OK) { ESP_LOGE(TAG, "Cannot override mDNS hostname to '%s': %s; ensure mDNS is enabled and initialized", this->mdns_hostname_.c_str(), esp_err_to_name(err)); } else { ESP_LOGI(TAG, "mDNS hostname overridden to '%s.local'", this->mdns_hostname_.c_str()); } } this->update(); const esp_err_t err = this->server_.start(handle_request_, this); if (err != ESP_OK) { ESP_LOGE(TAG, "Cannot start air quality listener: %s", esp_err_to_name(err)); this->mark_failed(); } } // Only called on the main loop; never dereference a Sensor in the HTTP task. static float sensor_value(sensor::Sensor *sensor) { if (sensor == nullptr || !sensor->has_state() || !std::isfinite(sensor->state)) 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. 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() { 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_() { LockGuard lock(this->snapshot_mutex_); 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 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"); httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff"); httpd_resp_set_hdr(request, "X-Frame-Options", "DENY"); httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer"); httpd_resp_set_hdr(request, "Permissions-Policy", "camera=(), microphone=(), geolocation=()"); httpd_resp_set_hdr(request, "Content-Security-Policy", "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " "connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"); // Receive shutdown and queued session closure below enforce this server-side. httpd_resp_set_hdr(request, "Connection", "close"); if (!known || !allowed) { httpd_resp_set_status(request, known ? "405 Method Not Allowed" : "404 Not Found"); if (known) httpd_resp_set_hdr(request, "Allow", "GET"); httpd_resp_set_type(request, "text/plain; charset=utf-8"); return send_and_close(request, known ? "Method Not Allowed\n" : "Not Found\n", HTTPD_RESP_USE_STRLEN); } if (page) { httpd_resp_set_type(request, "text/html; charset=utf-8"); return send_and_close(request, PAGE, sizeof(PAGE) - 1); } 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()); } } // namespace esphome::air_quality_web