From d1f3cf29590b58d55d466cf9d2c4b705fe4e23dc Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Mon, 9 Nov 2026 11:23:55 +0100 Subject: [PATCH] Add isolated air quality web page --- esphome/components/air_quality_web/README.md | 134 +++++++++++++++++ .../components/air_quality_web/__init__.py | 49 +++++++ .../air_quality_web/air_quality_web.cpp | 138 ++++++++++++++++++ .../air_quality_web/air_quality_web.h | 47 ++++++ esphome/components/air_quality_web/page.h | 82 +++++++++++ esphome/energymonitor.yaml | 23 +++ 6 files changed, 473 insertions(+) create mode 100644 esphome/components/air_quality_web/README.md create mode 100644 esphome/components/air_quality_web/__init__.py create mode 100644 esphome/components/air_quality_web/air_quality_web.cpp create mode 100644 esphome/components/air_quality_web/air_quality_web.h create mode 100644 esphome/components/air_quality_web/page.h diff --git a/esphome/components/air_quality_web/README.md b/esphome/components/air_quality_web/README.md new file mode 100644 index 0000000..25242b2 --- /dev/null +++ b/esphome/components/air_quality_web/README.md @@ -0,0 +1,134 @@ +# Office air quality web page + +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 +optional mDNS hostname override affects the device-wide mDNS responder. + +## Integration (in the parent configuration) + +For a YAML file located in `esphome/`: + +```yaml +external_components: + - source: + type: local + path: components + components: [air_quality_web] + +mdns: + disabled: false + services: + - service: _http + protocol: _tcp + port: 8080 + +air_quality_web: + port: 8080 + mdns_hostname: office3 # Optional device-wide mDNS hostname override + co2: scd30_co2 + temperature: scd30_temperature + humidity: scd30_humidity +``` + +Merge with existing `external_components` if present. All three sensor references +are required; their units should be ppm, °C, and % respectively. The default port +is 8080. Port 0, port 80 (fallback captive portal), and port 32768 (the standard +IDF server's control port) are rejected. Avoid conflicts with other TCP/UDP +services. Merge the explicit `mdns: disabled: false` and `_http._tcp` service on +port 8080 into the parent YAML; the component does not register that service. + +### Optional mDNS hostname override + +`mdns_hostname` is validated with ESPHome's `cv.hostname`. When omitted, this +component does not change the mDNS hostname. When set to `office3`, it calls the +native `mdns_hostname_set` after ESPHome's mDNS setup, using setup priority +`AFTER_CONNECTION - 1`, so the page is reachable at `http://office3.local:8080/`. +Failures are logged with the requested hostname and native error; the HTTP +listener still starts and can be reached by IP. + +This is a **hostname override, not an alias**: it replaces the device-wide mDNS +hostname, including the hostname targeted by other advertised services. The old +`.local` hostname is not retained as an additional name. Only the mDNS hostname +changes: `App`/ESPHome device name, entity identity, and mDNS service instance +names are unchanged. The component requires ESPHome's `mdns` dependency and +uses its native mDNS library; it does not initialize a second responder. + +## Public interface + +* Exact `GET /`: self-contained German page; no external assets or requests. +* 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}`. +* 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. + +## Isolation, security, and resource use + +This is intentionally public, unauthenticated **HTTP**, not HTTPS. Anyone who can +reach this port can read the three measurements. Restrict network exposure as +appropriate; do not forward the device's other ports alongside it. There is no +CORS permission, no credentials in fetches, and no general entity API. + +Responses include `Cache-Control: no-store, max-age=0`, `Pragma: no-cache`, +`nosniff`, frame denial, no-referrer, a restrictive Permissions Policy and CSP. +CSP permits the fixed inline script/style needed for this single-file page and +same-origin fetches only. It denies forms, framing, and other resource types. +These headers apply to application errors too; they do not alter portal headers. + +A standalone `AsyncWebServer` subclass uses the ESPHome `web_server_base` header +and its automatically loaded `web_server_idf` dependency. It never accesses +`global_web_server_base`, registers shared handlers, or changes `DefaultHeaders`. +Its small startup override is needed because stock `AsyncWebServer::begin()` +uses a fixed UDP control port (incompatible with a second server) and generic +POST parsing. The new listener uses UDP control on its own port number and a +single all-method dispatcher, rejecting unsupported requests before body reads. +The fallback captive portal remains independently on port 80. + +Socket accounting registers one TCP listener, three TCP clients, and two UDP +sockets (IDF 5.5.6's `ctrl_fd` and `msg_fd`): six sockets total, using ESPHome's +`socket.consume_sockets` validators. IDF allows at most three client connections, +with LRU eviction. Its HTTP task stack is 4512 bytes. The page lives as a constant +in flash and is sent directly, not copied into a per-request string. IDF task, +control/session structures and socket buffers require additional RAM; exact use +depends on the parent build. This is a modest LAN status page, not a hardened +high-concurrency Internet server. + +Every application response, including HTML, JSON, 404 and 405, sends +`Connection: close`. A shared helper sends the response, then calls +`shutdown(fd, SHUT_RD)` to terminate further receives, then queues +`httpd_sess_trigger_close`. Queued closure alone is insufficient: IDF first +synchronously drains unread `Content-Length` during request cleanup. Receive +shutdown prevents that cleanup from waiting for a slow or incomplete body; +already-buffered bytes are finite, and the component performs no body receive +loop. Shutdown leaves the send direction intact. Normal responses return +`ESP_OK`, avoiding IDF's per-poll "uri handler execution failed" warning. Actual +send, receive-shutdown, or close-queue failures return an error so IDF discards +the session; shutdown and queue failures are logged. This does not eliminate +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 +`null`. Sensor expiry stays with the parent configuration's existing filters +(e.g. `timeout: 30s`); this component adds no independent sample-age policy. + +## Validation + +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 +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/__init__.py b/esphome/components/air_quality_web/__init__.py new file mode 100644 index 0000000..e4f5588 --- /dev/null +++ b/esphome/components/air_quality_web/__init__.py @@ -0,0 +1,49 @@ +"""Isolated, public, read-only office air quality server (ESP32 ESP-IDF).""" + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import sensor, socket +from esphome.const import CONF_ID, CONF_PORT, Framework + +DEPENDENCIES = ["esp32", "network", "mdns"] +AUTO_LOAD = ["web_server_base", "socket"] + +ns = cg.esphome_ns.namespace("air_quality_web") +AirQualityWeb = ns.class_("AirQualityWeb", cg.PollingComponent) + + +def validate_port(value): + value = cv.port(value) + if value in (0, 80, 32768): + raise cv.Invalid("Choose a nonzero port other than 80 or 32768 (captive portal/control socket)") + return value + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(AirQualityWeb), + cv.Optional(CONF_PORT, default=8080): validate_port, + cv.Optional("mdns_hostname"): cv.hostname, + cv.Required("co2"): cv.use_id(sensor.Sensor), + cv.Required("temperature"): cv.use_id(sensor.Sensor), + cv.Required("humidity"): cv.use_id(sensor.Sensor), + } + ).extend(cv.COMPONENT_SCHEMA), + cv.only_on_esp32, + cv.only_with_framework(Framework.ESP_IDF), + socket.consume_sockets(1, "air_quality_web", socket.SocketType.TCP_LISTEN), + socket.consume_sockets(3, "air_quality_web", socket.SocketType.TCP), + socket.consume_sockets(2, "air_quality_web", socket.SocketType.UDP), +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + cg.add(var.set_port(config[CONF_PORT])) + if "mdns_hostname" in config: + cg.add(var.set_mdns_hostname(config["mdns_hostname"])) + for key in ("co2", "temperature", "humidity"): + ref = await cg.get_variable(config[key]) + cg.add(getattr(var, f"set_{key}_sensor")(ref)) diff --git a/esphome/components/air_quality_web/air_quality_web.cpp b/esphome/components/air_quality_web/air_quality_web.cpp new file mode 100644 index 0000000..5a29497 --- /dev/null +++ b/esphome/components/air_quality_web/air_quality_web.cpp @@ -0,0 +1,138 @@ +#include "air_quality_web.h" +#include "page.h" +#include "esphome/core/log.h" + +#include +#include + +#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); + 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(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(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(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 diff --git a/esphome/components/air_quality_web/air_quality_web.h b/esphome/components/air_quality_web/air_quality_web.h new file mode 100644 index 0000000..b13fca1 --- /dev/null +++ b/esphome/components/air_quality_web/air_quality_web.h @@ -0,0 +1,47 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/web_server_base/web_server_base.h" + +#include + +namespace esphome::air_quality_web { + +// Separate IDF listener: do not use WebServerBase or its global instance/headers. +// The stock begin() has a shared control port and generic POST body processing. +class AirQualityServer : public AsyncWebServer { + public: + AirQualityServer() : AsyncWebServer(8080) {} + void set_port(uint16_t port) { this->port_ = port; } + esp_err_t start(esp_err_t (*handler)(httpd_req_t *), void *context); +}; + +class AirQualityWeb : public PollingComponent { + public: + AirQualityWeb() : PollingComponent(1000) {} + void set_port(uint16_t port) { this->server_.set_port(port); } + void set_mdns_hostname(const std::string &hostname) { this->mdns_hostname_ = hostname; } + void set_co2_sensor(sensor::Sensor *value) { this->co2_ = value; } + void set_temperature_sensor(sensor::Sensor *value) { this->temperature_ = value; } + void set_humidity_sensor(sensor::Sensor *value) { this->humidity_ = value; } + void setup() override; + void update() override; + void on_shutdown() override { this->server_.end(); } + float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION - 1.0f; } + + protected: + static esp_err_t handle_request_(httpd_req_t *request); + std::string snapshot_(); + + AirQualityServer server_; + std::string mdns_hostname_; + sensor::Sensor *co2_{nullptr}; + sensor::Sensor *temperature_{nullptr}; + sensor::Sensor *humidity_{nullptr}; + Mutex snapshot_mutex_; + std::string json_{"{\"co2\":null,\"temperature\":null,\"humidity\":null}"}; +}; + +} // namespace esphome::air_quality_web diff --git a/esphome/components/air_quality_web/page.h b/esphome/components/air_quality_web/page.h new file mode 100644 index 0000000..afa2dfa --- /dev/null +++ b/esphome/components/air_quality_web/page.h @@ -0,0 +1,82 @@ +#pragma once + +namespace esphome::air_quality_web { + +static constexpr char PAGE[] = R"AQPAGE( + + + + + +Luftqualität im Büro + + +
+

Luftqualität im Büro

+

Aktuelle Messwerte · Aktualisierung alle 5 Sekunden

+

Verbindung wird hergestellt …

+
+

CO₂

— ppm

Keine Messdaten

+

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.
+ +
+ + +)AQPAGE"; + +} // namespace esphome::air_quality_web diff --git a/esphome/energymonitor.yaml b/esphome/energymonitor.yaml index 25bf3c7..9562c7e 100644 --- a/esphome/energymonitor.yaml +++ b/esphome/energymonitor.yaml @@ -24,9 +24,32 @@ esp32: minimum_chip_revision: "3.1" sram1_as_iram: true +external_components: + - source: + type: local + path: components + components: [air_quality_web] + network: enable_ipv6: true +mdns: + disabled: false + services: + - service: _http + protocol: _tcp + port: 8080 + txt: + path: / + +# Separate from the captive portal: this listener publishes only these readings. +air_quality_web: + port: 8080 + mdns_hostname: office3 + co2: scd30_co2 + temperature: scd30_temperature + humidity: scd30_humidity + wifi: networks: - ssid: "Voltage-legacy"