# 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.