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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -24,9 +24,32 @@ esp32:
|
|||||||
minimum_chip_revision: "3.1"
|
minimum_chip_revision: "3.1"
|
||||||
sram1_as_iram: true
|
sram1_as_iram: true
|
||||||
|
|
||||||
|
external_components:
|
||||||
|
- source:
|
||||||
|
type: local
|
||||||
|
path: components
|
||||||
|
components: [air_quality_web]
|
||||||
|
|
||||||
network:
|
network:
|
||||||
enable_ipv6: true
|
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:
|
wifi:
|
||||||
networks:
|
networks:
|
||||||
- ssid: "Voltage-legacy"
|
- ssid: "Voltage-legacy"
|
||||||
|
|||||||
Reference in New Issue
Block a user