8.8 KiB
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,
or authentication. Only the three explicitly referenced sensor states and their
10-hour air-quality history 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/:
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 exactlyco2,temperature,humidity, each a finite number ornull. Example:{"co2":850,"temperature":22.5,"humidity":46}. - Exact
GET /api/air-quality/history: JSON containingduration_seconds: 36000andsamples, ordered oldest first. Each sample is[age_seconds, co2, temperature, humidity], with nonnegative ages relative to the response snapshot and finite values ornull. Only samples within the last 10 hours are returned; there are at most 601 samples. - 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 refreshes live readings approximately every five seconds and history approximately every minute, using one sequential request loop and a separate 4-second timeout for each request. Missing live measurements show dashes. A live request failure clears the live cards; a history failure retains previously loaded history with a stale/unavailable warning, without clearing healthy live cards. CO₂ is orange only above 1200 ppm and red only above 1600 ppm; text accompanies the colors.
Three separate responsive charts show CO₂ (ppm), temperature (°C), and relative humidity (%) on a fixed −10 h to now axis, with independent automatic Y scales. Missing values and timestamp gaps over 90 seconds break the traces. Partial history is not stretched across the time axis or extrapolated to the present. CO₂ guides at 1200 and 1600 ppm appear when within the chart's value range.
The device records one sample every 60 seconds, independently of browser, VPN, or Home Assistant connections. History is kept only in RAM and starts empty following reboot; firmware updates therefore also clear it. There are no flash writes for history. Delayed sampling is represented by actual timestamps, not fabricated catch-up samples.
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. The history ring uses 601 compact 16-byte samples (9,616
bytes), plus small bookkeeping fields. Each history request allocates a bounded
heap snapshot of approximately the same size, then streams JSON in chunks of at
most 512 bytes; the ring is not copied onto the small HTTP task stack and no
whole-response JSON string is allocated. Allocation failure returns 503, and
send failures close the connection. Sample ages use rollover-safe uptime
arithmetic and old samples are pruned on the main loop. 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, history retention/wraparound, uptime rollover, allocation failures, and chunked-send failures), and the embedded JavaScript (threshold boundaries, missing and offline states, chart bounds/gaps, 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.