139 lines
5.7 KiB
C++
139 lines
5.7 KiB
C++
#include "air_quality_web.h"
|
|
#include "page.h"
|
|
#include "esphome/core/log.h"
|
|
|
|
#include <mdns.h>
|
|
#include <lwip/sockets.h>
|
|
|
|
#include <cerrno>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <utility>
|
|
|
|
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);
|
|
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;
|
|
}
|
|
|
|
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<httpd_method_t>(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 std::string sensor_json(sensor::Sensor *sensor) {
|
|
if (sensor == nullptr || !sensor->has_state() || !std::isfinite(sensor->state))
|
|
return "null";
|
|
char value[32];
|
|
// Nine significant digits cover a float, even at its finite extrema.
|
|
snprintf(value, sizeof(value), "%.9g", static_cast<double>(sensor->state));
|
|
return value;
|
|
}
|
|
|
|
void AirQualityWeb::update() {
|
|
std::string next = "{\"co2\":" + sensor_json(this->co2_) +
|
|
",\"temperature\":" + sensor_json(this->temperature_) +
|
|
",\"humidity\":" + sensor_json(this->humidity_) + "}";
|
|
LockGuard lock(this->snapshot_mutex_);
|
|
this->json_ = std::move(next);
|
|
}
|
|
|
|
std::string AirQualityWeb::snapshot_() {
|
|
LockGuard lock(this->snapshot_mutex_);
|
|
return this->json_;
|
|
}
|
|
|
|
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 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<AirQualityWeb *>(request->user_ctx);
|
|
// 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
|