135 lines
7.0 KiB
Markdown
135 lines
7.0 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,
|
|
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.
|