Merge PR #13 with gzip-only WebConfig assets

Keep the graceful final-ACK TCP close and optional FEM control guards from
pixitha's browser compatibility fixes. Replace Brotli negotiation and duplicate
page assets with one gzip representation compressed by Google Zopfli 0.4.3
using 1,000 iterations. Compress the bootstrap, captive success, compact OTA,
and static WiFi setup pages with the same validated build cache.

Honor gzip quality values and wildcard precedence. Validate compressed OTA
page evidence in the firmware capability gate, and add native and Python
regressions plus CI coverage. Remove the unused Brotli build dependency.

Validation: 54 native tests, 56 Python/browser checks, full V4.3 build and
verified application flash. Actual V4 WiFi responses matched the generated
gzip assets; 30 repeat downloads completed without reboot. Chromium loaded
the setup and settings pages without script or transfer errors. Physical
Android testing is pending because the Pi detects no phone over USB or mDNS.
This commit is contained in:
mikecarper committed 2026-09-30 18:55:17 -07:00
commit 267773df7d
23 files changed
+2674 -2084

No files matched your search

+37 -19
View File
@@ -31,6 +31,7 @@ static_assert(sizeof(WEBCONFIG_AP_PREFIX) <= 28,
#endif
#include "WebConfigHtml.h"
#include <helpers/HttpContentEncoding.h>
#include "WebTerminalStream.h"
#include <helpers/CLICommandUtils.h>
#include <new>
@@ -154,15 +155,11 @@ public:
if (_in_flight != 0) return 0;
if (_state == RESPONSE_WAIT_ACK) {
// The full Content-Length has now been acknowledged. ESPAsyncWebServer
// otherwise performs a graceful active close for every response, and
// this IDF 4.4 stack retains malloc-backed TCP PCBs in TIME_WAIT for about
// two minutes. Repeated WebConfig loads consume scarce internal heap and
// can also prevent AsyncTCP from retiring close events, wedging both
// infrastructure WiFi and ESP-NOW. Abort only after the final ACK so the
// peer has the complete page, while freeing this PCB immediately. Keep
// RESPONSE_WAIT_ACK: abort synchronously dispatches disconnect and the
// request's scoped owner then destroys this response.
if (client) client->abort();
// otherwise performs a graceful active close for every response. Keep
// the graceful close here too: browsers can report a complete,
// Content-Length-delimited response as a failed transfer when the peer
// force-aborts the TCP PCB immediately after the final ACK.
if (client) client->close();
return 0;
}
return queueNext(request);
@@ -1636,21 +1633,38 @@ bool WebConfigServer::checkAuth(AsyncWebServerRequest* req) {
// Handlers (async_tcp task - no CLI/prefs writes, no radio access)
// ---------------------------------------------------------------------------
static bool webConfigClientAcceptsGzip(AsyncWebServerRequest* req) {
return req && mesh::http::acceptsGzip(req->hasHeader("Accept-Encoding")
? req->getHeader("Accept-Encoding")->value().c_str() : nullptr);
}
static void webConfigRejectEncoding(AsyncWebServerRequest* req) {
AsyncWebServerResponse* res = req->beginResponse(
406, "text/plain", "WebConfig requires gzip support");
res->addHeader("Vary", "Accept-Encoding");
res->addHeader("Cache-Control", "no-store");
req->send(res);
}
void WebConfigServer::handleRoot(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { req->send(503); return; }
_last_activity = millis();
if (!webConfigClientAcceptsGzip(req)) { webConfigRejectEncoding(req); return; }
if (req->hasHeader("If-None-Match") &&
req->getHeader("If-None-Match")->value() == WEBCONFIG_HTML_ETAG) {
req->send(304);
return;
}
AsyncWebServerResponse* res =
req->beginResponse(200, "text/html", WEBCONFIG_HTML_LOADER,
req->beginResponse(200, "text/html; charset=utf-8", WEBCONFIG_HTML_LOADER,
WEBCONFIG_HTML_LOADER_LEN);
res->addHeader("ETag", WEBCONFIG_HTML_ETAG);
res->addHeader("Content-Encoding", "gzip");
res->addHeader("Vary", "Accept-Encoding");
req->send(res);
}
void WebConfigServer::handleUi(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { req->send(503); return; }
_last_activity = millis();
@@ -1664,13 +1678,11 @@ void WebConfigServer::handleUi(AsyncWebServerRequest* req) {
req->send(res);
return;
}
AsyncWebServerResponse* res =
new WebConfigPacedProgmemResponse("text/html; charset=utf-8",
WEBCONFIG_HTML_BR,
WEBCONFIG_HTML_BR_LEN);
// Keep exactly one whole-page asset in flash. Brotli is decoded by the
// browser; the ESP32 only streams these precompressed bytes.
res->addHeader("Content-Encoding", "br");
if (!webConfigClientAcceptsGzip(req)) { webConfigRejectEncoding(req); return; }
AsyncWebServerResponse* res = new WebConfigPacedProgmemResponse(
"text/html; charset=utf-8", WEBCONFIG_HTML_GZ, WEBCONFIG_HTML_GZ_LEN);
res->addHeader("Content-Encoding", "gzip");
res->addHeader("Vary", "Accept-Encoding");
res->addHeader("Cache-Control", "public, max-age=31536000, immutable");
req->send(res);
}
@@ -2767,8 +2779,14 @@ void WebConfigServer::handleNotFound(AsyncWebServerRequest* req) {
if (url.indexOf("generate_204") >= 0 || url.indexOf("gen_204") >= 0) {
req->send(204); // Android
} else if (url.indexOf("hotspot-detect") >= 0 || url.indexOf("success") >= 0) {
req->send(200, "text/html", // Apple CNA
"<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>");
if (!webConfigClientAcceptsGzip(req)) { webConfigRejectEncoding(req); return; }
AsyncWebServerResponse* res = req->beginResponse(
200, "text/html; charset=utf-8", WEBCONFIG_HTML_SUCCESS,
WEBCONFIG_HTML_SUCCESS_LEN);
res->addHeader("Content-Encoding", "gzip");
res->addHeader("Vary", "Accept-Encoding");
res->addHeader("Cache-Control", "no-store");
req->send(res); // Apple CNA
} else if (url.indexOf("ncsi.txt") >= 0) {
req->send(200, "text/plain", "Microsoft NCSI"); // Windows
} else if (url.indexOf("connecttest.txt") >= 0) {