Add isolated air quality web page
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user