163 lines
8.8 KiB
Markdown
163 lines
8.8 KiB
Markdown
# 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/`:
|
||
|
||
```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}`.
|
||
* Exact `GET /api/air-quality/history`: JSON containing `duration_seconds: 36000`
|
||
and `samples`, ordered oldest first. Each sample is
|
||
`[age_seconds, co2, temperature, humidity]`, with nonnegative ages relative to
|
||
the response snapshot and finite values or `null`. 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.
|