Add isolated air quality web page

This commit is contained in:
2026-11-09 11:23:55 +01:00
parent dd95628424
commit d1f3cf2959
6 changed files with 473 additions and 0 deletions
@@ -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.
@@ -0,0 +1,49 @@
"""Isolated, public, read-only office air quality server (ESP32 ESP-IDF)."""
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import sensor, socket
from esphome.const import CONF_ID, CONF_PORT, Framework
DEPENDENCIES = ["esp32", "network", "mdns"]
AUTO_LOAD = ["web_server_base", "socket"]
ns = cg.esphome_ns.namespace("air_quality_web")
AirQualityWeb = ns.class_("AirQualityWeb", cg.PollingComponent)
def validate_port(value):
value = cv.port(value)
if value in (0, 80, 32768):
raise cv.Invalid("Choose a nonzero port other than 80 or 32768 (captive portal/control socket)")
return value
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(AirQualityWeb),
cv.Optional(CONF_PORT, default=8080): validate_port,
cv.Optional("mdns_hostname"): cv.hostname,
cv.Required("co2"): cv.use_id(sensor.Sensor),
cv.Required("temperature"): cv.use_id(sensor.Sensor),
cv.Required("humidity"): cv.use_id(sensor.Sensor),
}
).extend(cv.COMPONENT_SCHEMA),
cv.only_on_esp32,
cv.only_with_framework(Framework.ESP_IDF),
socket.consume_sockets(1, "air_quality_web", socket.SocketType.TCP_LISTEN),
socket.consume_sockets(3, "air_quality_web", socket.SocketType.TCP),
socket.consume_sockets(2, "air_quality_web", socket.SocketType.UDP),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(var.set_port(config[CONF_PORT]))
if "mdns_hostname" in config:
cg.add(var.set_mdns_hostname(config["mdns_hostname"]))
for key in ("co2", "temperature", "humidity"):
ref = await cg.get_variable(config[key])
cg.add(getattr(var, f"set_{key}_sensor")(ref))
@@ -0,0 +1,138 @@
#include "air_quality_web.h"
#include "page.h"
#include "esphome/core/log.h"
#include <mdns.h>
#include <lwip/sockets.h>
#include <cerrno>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <utility>
namespace esphome::air_quality_web {
static const char *const TAG = "air_quality_web";
static esp_err_t send_and_close(httpd_req_t *request, const char *content, ssize_t length) {
const esp_err_t send_err = httpd_resp_send(request, content, length);
const int fd = httpd_req_to_sockfd(request);
// IDF drains unread Content-Length synchronously before processing queued work.
// Stop receive first so cleanup cannot wait for a slow/incomplete request body.
const int shutdown_result = shutdown(fd, SHUT_RD);
if (shutdown_result != 0)
ESP_LOGW(TAG, "Cannot shut down HTTP receive on socket %d: errno %d", fd, errno);
const esp_err_t close_err = httpd_sess_trigger_close(request->handle, fd);
if (close_err != ESP_OK)
ESP_LOGW(TAG, "Cannot queue HTTP session closure on socket %d: %s", fd, esp_err_to_name(close_err));
if (send_err != ESP_OK)
return send_err;
// Normal polls must succeed; failures make IDF discard the session directly.
return shutdown_result == 0 && close_err == ESP_OK ? ESP_OK : ESP_FAIL;
}
esp_err_t AirQualityServer::start(esp_err_t (*handler)(httpd_req_t *), void *context) {
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.stack_size = 4512;
config.server_port = this->port_;
// UDP and TCP may share a port number. Avoid the portal's default UDP 32768.
config.ctrl_port = this->port_;
config.max_open_sockets = 3;
config.max_uri_handlers = 1;
config.max_resp_headers = 12;
config.lru_purge_enable = true;
config.close_fn = AsyncWebServer::safe_close_with_shutdown;
config.uri_match_fn = [](const char *, const char *, size_t) { return true; };
esp_err_t err = httpd_start(&this->server_, &config);
if (err != ESP_OK)
return err;
httpd_uri_t route{};
route.uri = "*";
route.method = static_cast<httpd_method_t>(HTTP_ANY);
route.handler = handler;
route.user_ctx = context;
err = httpd_register_uri_handler(this->server_, &route);
if (err != ESP_OK)
this->end();
return err;
}
void AirQualityWeb::setup() {
// ESPHome mDNS initializes at AFTER_CONNECTION, before this component.
if (!this->mdns_hostname_.empty()) {
const esp_err_t err = mdns_hostname_set(this->mdns_hostname_.c_str());
if (err != ESP_OK) {
ESP_LOGE(TAG, "Cannot override mDNS hostname to '%s': %s; ensure mDNS is enabled and initialized",
this->mdns_hostname_.c_str(), esp_err_to_name(err));
} else {
ESP_LOGI(TAG, "mDNS hostname overridden to '%s.local'", this->mdns_hostname_.c_str());
}
}
this->update();
const esp_err_t err = this->server_.start(handle_request_, this);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Cannot start air quality listener: %s", esp_err_to_name(err));
this->mark_failed();
}
}
// Only called on the main loop; never dereference a Sensor in the HTTP task.
static std::string sensor_json(sensor::Sensor *sensor) {
if (sensor == nullptr || !sensor->has_state() || !std::isfinite(sensor->state))
return "null";
char value[32];
// Nine significant digits cover a float, even at its finite extrema.
snprintf(value, sizeof(value), "%.9g", static_cast<double>(sensor->state));
return value;
}
void AirQualityWeb::update() {
std::string next = "{\"co2\":" + sensor_json(this->co2_) +
",\"temperature\":" + sensor_json(this->temperature_) +
",\"humidity\":" + sensor_json(this->humidity_) + "}";
LockGuard lock(this->snapshot_mutex_);
this->json_ = std::move(next);
}
std::string AirQualityWeb::snapshot_() {
LockGuard lock(this->snapshot_mutex_);
return this->json_;
}
esp_err_t AirQualityWeb::handle_request_(httpd_req_t *request) {
// Raw exact paths: no decoding, prefix matching or redirects into other APIs.
const bool page = strcmp(request->uri, "/") == 0;
const bool api = strcmp(request->uri, "/api/air-quality") == 0;
const bool known = page || api;
const bool allowed = request->method == HTTP_GET;
httpd_resp_set_hdr(request, "Cache-Control", "no-store, max-age=0");
httpd_resp_set_hdr(request, "Pragma", "no-cache");
httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
httpd_resp_set_hdr(request, "X-Frame-Options", "DENY");
httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
httpd_resp_set_hdr(request, "Permissions-Policy", "camera=(), microphone=(), geolocation=()");
httpd_resp_set_hdr(request, "Content-Security-Policy",
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; "
"connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'");
// Receive shutdown and queued session closure below enforce this server-side.
httpd_resp_set_hdr(request, "Connection", "close");
if (!known || !allowed) {
httpd_resp_set_status(request, known ? "405 Method Not Allowed" : "404 Not Found");
if (known)
httpd_resp_set_hdr(request, "Allow", "GET");
httpd_resp_set_type(request, "text/plain; charset=utf-8");
return send_and_close(request, known ? "Method Not Allowed\n" : "Not Found\n", HTTPD_RESP_USE_STRLEN);
}
if (page) {
httpd_resp_set_type(request, "text/html; charset=utf-8");
return send_and_close(request, PAGE, sizeof(PAGE) - 1);
}
httpd_resp_set_type(request, "application/json");
auto *component = static_cast<AirQualityWeb *>(request->user_ctx);
// Release the mutex before the potentially blocking network send.
const std::string json = component->snapshot_();
return send_and_close(request, json.data(), json.size());
}
} // namespace esphome::air_quality_web
@@ -0,0 +1,47 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/web_server_base/web_server_base.h"
#include <string>
namespace esphome::air_quality_web {
// Separate IDF listener: do not use WebServerBase or its global instance/headers.
// The stock begin() has a shared control port and generic POST body processing.
class AirQualityServer : public AsyncWebServer {
public:
AirQualityServer() : AsyncWebServer(8080) {}
void set_port(uint16_t port) { this->port_ = port; }
esp_err_t start(esp_err_t (*handler)(httpd_req_t *), void *context);
};
class AirQualityWeb : public PollingComponent {
public:
AirQualityWeb() : PollingComponent(1000) {}
void set_port(uint16_t port) { this->server_.set_port(port); }
void set_mdns_hostname(const std::string &hostname) { this->mdns_hostname_ = hostname; }
void set_co2_sensor(sensor::Sensor *value) { this->co2_ = value; }
void set_temperature_sensor(sensor::Sensor *value) { this->temperature_ = value; }
void set_humidity_sensor(sensor::Sensor *value) { this->humidity_ = value; }
void setup() override;
void update() override;
void on_shutdown() override { this->server_.end(); }
float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION - 1.0f; }
protected:
static esp_err_t handle_request_(httpd_req_t *request);
std::string snapshot_();
AirQualityServer server_;
std::string mdns_hostname_;
sensor::Sensor *co2_{nullptr};
sensor::Sensor *temperature_{nullptr};
sensor::Sensor *humidity_{nullptr};
Mutex snapshot_mutex_;
std::string json_{"{\"co2\":null,\"temperature\":null,\"humidity\":null}"};
};
} // namespace esphome::air_quality_web
+82
View File
@@ -0,0 +1,82 @@
#pragma once
namespace esphome::air_quality_web {
static constexpr char PAGE[] = R"AQPAGE(<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Luftqualität im Büro</title>
<style>
:root{font-family:system-ui,sans-serif;color-scheme:light dark;background:#f1f5f9;color:#17212f}
*{box-sizing:border-box}body{margin:0;padding:clamp(1rem,5vw,3rem)}main{max-width:58rem;margin:auto}
h1{font-size:clamp(1.7rem,5vw,2.6rem);margin-bottom:.5rem}.intro,footer{color:#526173}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(14rem,1fr));gap:1rem;margin:2rem 0}
article{background:#fff;border:2px solid #b4c2d1;border-radius:1rem;padding:1.5rem;min-width:0}
h2{font-size:1.1rem;margin:0 0 1rem}.value{font-size:clamp(2.2rem,6vw,3.6rem);font-weight:700;font-variant-numeric:tabular-nums}
.unit{font-size:1.1rem;font-weight:400}.hint{min-height:1.5em;margin-bottom:0}
article.good{border-color:#15803d}article.orange{border-color:#c05a00;background:#fff4df}article.red{border-color:#c52b2b;background:#ffeded}
#status{font-weight:600}footer{font-size:.9rem;line-height:1.6}
@media(prefers-color-scheme:dark){:root{background:#101923;color:#eef3fa}article{background:#1b2837}.intro,footer{color:#b1c0d1}article.orange{background:#402a13;border-color:#ffae4a}article.red{background:#401b21;border-color:#ff7979}}
</style>
</head>
<body><main>
<h1>Luftqualität im Büro</h1>
<p class="intro">Aktuelle Messwerte · Aktualisierung alle 5 Sekunden</p>
<p id="status" role="status" aria-live="polite">Verbindung wird hergestellt …</p>
<div class="grid">
<article id="co2-card"><h2>CO₂</h2><div class="value"><span id="co2">—</span> <span class="unit">ppm</span></div><p class="hint" id="co2-hint">Keine Messdaten</p></article>
<article><h2>Temperatur</h2><div class="value"><span id="temperature">—</span> <span class="unit">°C</span></div><p class="hint" id="temperature-hint">Keine Messdaten</p></article>
<article><h2>Feuchtigkeit</h2><div class="value"><span id="humidity">—</span> <span class="unit">%</span></div><p class="hint" id="humidity-hint">Keine Messdaten</p></article>
</div>
<footer>CO₂: Orange bei mehr als 1200 ppm, Rot bei mehr als 1600 ppm.<br>Nur aktuelle Luftmesswerte – keine Aufzeichnung.</footer>
<noscript>Bitte JavaScript aktivieren, um die aktuellen Messwerte anzuzeigen.</noscript>
</main>
<script>
'use strict';
const keys = ['co2', 'temperature', 'humidity'];
const status = document.getElementById('status');
const card = document.getElementById('co2-card');
const formats = [0, 1, 1].map(d => new Intl.NumberFormat('de-DE', {minimumFractionDigits:d, maximumFractionDigits:d}));
function render(data) {
let available = 0;
keys.forEach((key, i) => {
const value = data[key];
const valid = typeof value === 'number' && Number.isFinite(value);
document.getElementById(key).textContent = valid ? formats[i].format(value) : '—';
document.getElementById(key + '-hint').textContent = valid ? 'Aktueller Messwert' : 'Keine Messdaten';
if (valid) available++;
});
const co2 = data.co2;
const valid = typeof co2 === 'number' && Number.isFinite(co2);
card.className = valid ? (co2 > 1600 ? 'red' : co2 > 1200 ? 'orange' : 'good') : '';
if (valid) document.getElementById('co2-hint').textContent = co2 > 1600 ? 'Bitte lüften – CO₂ sehr hoch' : co2 > 1200 ? 'Lüften empfohlen – CO₂ erhöht' : 'CO₂ im grünen Bereich';
return available;
}
async function poll() {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 4000);
try {
const response = await fetch('/api/air-quality', {cache:'no-store', credentials:'omit', redirect:'error', signal:controller.signal});
if (!response.ok) throw new Error('HTTP');
const data = await response.json();
if (!data || Object.keys(data).length !== 3 || !keys.every(k => Object.hasOwn(data,k) && (data[k] === null || (typeof data[k] === 'number' && Number.isFinite(data[k]))))) throw new Error('Datenformat');
const count = render(data);
status.textContent = count === 3 ? 'Online · Messwerte aktuell' : count === 0 ? 'Online · Noch keine Messdaten' : 'Online · Einzelne Messwerte fehlen';
} catch (_) {
render({co2:null, temperature:null, humidity:null});
status.textContent = 'Offline · Keine Verbindung – erneuter Versuch in 5 Sekunden';
} finally {
clearTimeout(timeout);
// Schedule only after fetch and JSON parsing finish: never overlap requests.
setTimeout(poll, 5000);
}
}
poll();
</script>
</body>
</html>)AQPAGE";
} // namespace esphome::air_quality_web
+23
View File
@@ -24,9 +24,32 @@ esp32:
minimum_chip_revision: "3.1"
sram1_as_iram: true
external_components:
- source:
type: local
path: components
components: [air_quality_web]
network:
enable_ipv6: true
mdns:
disabled: false
services:
- service: _http
protocol: _tcp
port: 8080
txt:
path: /
# Separate from the captive portal: this listener publishes only these readings.
air_quality_web:
port: 8080
mdns_hostname: office3
co2: scd30_co2
temperature: scd30_temperature
humidity: scd30_humidity
wifi:
networks:
- ssid: "Voltage-legacy"