Compare commits

..

No commits in common. "c32eabf4648b87df50f5fa8c66c926b9116b9b2c" and "2069d36715b33d979e6229cf5a181a0a28d8a78c" have entirely different histories.

3 changed files with 51 additions and 150 deletions

View File

@ -1,7 +1,8 @@
{ {
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [ "recommendations": [
"diegoomal.ollama-connection", "diegoomal.ollama-connection",
"pioarduino.pioarduino-ide",
"platformio.platformio-ide" "platformio.platformio-ide"
], ],
"unwantedRecommendations": [ "unwantedRecommendations": [

View File

@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Simple build tool to minify web assets in the `web/` folder and generate gzipped versions. Simple build tool to minify web assets in the `web/` folder.
Usage: Usage:
python3 scripts/minify_web.py python3 scripts/minify_web.py
@ -15,11 +15,6 @@ and write minified outputs to:
- web/cleaned/style.css - web/cleaned/style.css
- web/cleaned/script.js - web/cleaned/script.js
Additionally, gzipped variants are produced to enable efficient on-device serving:
- web/cleaned/index.html.gz
- web/cleaned/style.css.gz
- web/cleaned/script.js.gz
The minifiers are intentionally conservative (no external deps) and aim to be The minifiers are intentionally conservative (no external deps) and aim to be
safe for typical static files used in this project. They remove comments, safe for typical static files used in this project. They remove comments,
collapse unnecessary whitespace and do small syntax-preserving transformations. collapse unnecessary whitespace and do small syntax-preserving transformations.
@ -34,7 +29,6 @@ from pathlib import Path
import re import re
import sys import sys
import os import os
import gzip
BASE = Path(__file__).resolve().parent.parent BASE = Path(__file__).resolve().parent.parent
WEB = BASE / "web" WEB = BASE / "web"
@ -224,15 +218,6 @@ def write_file(path: Path, data: str):
except Exception as e: except Exception as e:
print(f"ERROR writing {path}: {e}", file=sys.stderr) print(f"ERROR writing {path}: {e}", file=sys.stderr)
def write_gzip(path: Path, data: str):
"""Write UTF-8 text as gzip with deterministic mtime for reproducible builds."""
try:
gz_bytes = gzip.compress(data.encode('utf-8'), mtime=0)
path.write_bytes(gz_bytes)
print(f"Wrote {path} ({len(gz_bytes)} bytes, gzip)")
except Exception as e:
print(f"ERROR writing {path}: {e}", file=sys.stderr)
def minify_all(): def minify_all():
ensure_clean_dir() ensure_clean_dir()
@ -243,7 +228,6 @@ def minify_all():
s = read_file(index) s = read_file(index)
out = minify_html(s) out = minify_html(s)
write_file(CLEAN / "index.html", out) write_file(CLEAN / "index.html", out)
write_gzip(CLEAN / "index.html.gz", out)
else: else:
print("No index.html found in web/") print("No index.html found in web/")
@ -254,7 +238,6 @@ def minify_all():
s = read_file(css) s = read_file(css)
out = minify_css(s) out = minify_css(s)
write_file(CLEAN / "style.css", out) write_file(CLEAN / "style.css", out)
write_gzip(CLEAN / "style.css.gz", out)
else: else:
print("No style.css found in web/") print("No style.css found in web/")
@ -265,7 +248,6 @@ def minify_all():
s = read_file(js) s = read_file(js)
out = minify_js(s) out = minify_js(s)
write_file(CLEAN / "script.js", out) write_file(CLEAN / "script.js", out)
write_gzip(CLEAN / "script.js.gz", out)
else: else:
print("No script.js found in web/") print("No script.js found in web/")

View File

@ -1109,59 +1109,35 @@ void init_webserver() {
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) server.on("/", HTTP_GET, [](AsyncWebServerRequest *request)
{ {
webreq_enter(); webreq_enter();
/* onDisconnect set later with cleanup */ request->onDisconnect([](){ webreq_exit(); sd_lock_release();});
deactivateRFID(); deactivateRFID();
activateSD(); activateSD();
sd_lock_acquire();
static String htmlPath = ""; static String htmlPath = "";
static String htmlPathGz = "";
if (htmlPath.isEmpty()) { if (htmlPath.isEmpty()) {
htmlPath = getSysDir(index_file); htmlPath = getSysDir(index_file);
htmlPathGz = htmlPath + F(".gz");
} }
// Prefer gz if present
bool useGz = SD.exists(htmlPathGz); if (SD.exists(htmlPath))
const String &sendPath = useGz ? htmlPathGz : htmlPath;
if (SD.exists(sendPath))
{ {
#ifdef DEBUG uint32_t fsize = 0;
Serial.printf("Serving %s heap=%u webreq_cnt=%u\n", sendPath.c_str(), (unsigned)xPortGetFreeHeapSize(), (unsigned)webreq_cnt); {
#endif File f = SD.open(htmlPath);
// Chunked streaming with short SD lock per read if (f) { fsize = f.size(); f.close(); }
struct FileCtx { File f; }; }
FileCtx* ctx = new FileCtx(); #ifdef DEBUG
ctx->f = SD.open(sendPath); Serial.printf("Serving %s size=%u heap=%u webreq_cnt=%u\n", htmlPath.c_str(), (unsigned)fsize, (unsigned)xPortGetFreeHeapSize(), (unsigned)webreq_cnt);
if (!ctx->f) { delete ctx; request->send(500, txt_plain, F("Open failed")); return; } #endif
auto response = request->beginChunkedResponse(String(txt_html_charset), AsyncWebServerResponse *response = request->beginResponse(SD, htmlPath, txt_html_charset);
[ctx](uint8_t *buffer, size_t maxLen, size_t index) -> size_t {
size_t toRead = maxLen;
if (toRead > 512) toRead = 512;
sd_lock_acquire();
size_t n = ctx->f.read(buffer, toRead);
sd_lock_release();
if (n == 0) { ctx->f.close(); }
return n;
});
response->addHeader(hdr_cache_control_key, hdr_cache_control_val); response->addHeader(hdr_cache_control_key, hdr_cache_control_val);
response->addHeader(hdr_connection_key, hdr_connection_val); response->addHeader(hdr_connection_key, hdr_connection_val);
if (useGz) {
response->addHeader(F("Content-Encoding"), F("gzip"));
}
// Ensure FileCtx cleanup even on aborted connections
request->onDisconnect([ctx](){
sd_lock_acquire();
if (ctx->f) ctx->f.close();
sd_lock_release();
delete ctx;
webreq_exit();
});
request->send(response); request->send(response);
} }
else else
{ {
// Fallback: serve minimal error if file not found // Fallback: serve minimal error if file not found
request->send(404, txt_plain, F("ERROR: /system/index.html(.gz) not found!")); request->send(404, txt_plain, F("ERROR: /system/index.html not found!"));
} }
}); });
@ -1169,62 +1145,36 @@ server.on("/", HTTP_GET, [](AsyncWebServerRequest *request)
server.on("/style.css", HTTP_GET, [](AsyncWebServerRequest *request) server.on("/style.css", HTTP_GET, [](AsyncWebServerRequest *request)
{ {
webreq_enter(); webreq_enter();
/* onDisconnect set later with cleanup */ request->onDisconnect([](){ webreq_exit(); sd_lock_release();});
deactivateRFID(); deactivateRFID();
activateSD(); activateSD();
// Ensure SD is active and RFID is deactivated while serving files. // Ensure SD is active and RFID is deactivated while serving files.
static String cssPath = ""; static String cssPath = "";
static String cssPathGz = "";
if (cssPath.isEmpty()) { if (cssPath.isEmpty()) {
cssPath = getSysDir(style_file); cssPath = getSysDir(style_file);
cssPathGz = cssPath + F(".gz");
} }
// Prefer gz if present if (SD.exists(cssPath))
bool useGz = SD.exists(cssPathGz);
const String &sendPath = useGz ? cssPathGz : cssPath;
if (SD.exists(sendPath))
{ {
#ifdef DEBUG uint32_t fsize = 0;
Serial.printf("Serving %s heap=%u webreq_cnt=%u\n", sendPath.c_str(), (unsigned)xPortGetFreeHeapSize(), (unsigned)webreq_cnt); {
#endif File f = SD.open(cssPath);
if (f) { fsize = f.size(); f.close(); }
}
#ifdef DEBUG
Serial.printf("Serving %s size=%u heap=%u webreq_cnt=%u\n", cssPath.c_str(), (unsigned)fsize, (unsigned)xPortGetFreeHeapSize(), (unsigned)webreq_cnt);
#endif
{ {
// Chunked streaming with short SD lock per read AsyncWebServerResponse *resp = request->beginResponse(SD, cssPath, F("text/css"));
struct FileCtx { File f; };
FileCtx* ctx = new FileCtx();
ctx->f = SD.open(sendPath);
if (!ctx->f) { delete ctx; request->send(500, txt_plain, F("Open failed")); return; }
auto resp = request->beginChunkedResponse(F("text/css"),
[ctx](uint8_t *buffer, size_t maxLen, size_t index) -> size_t {
size_t toRead = maxLen;
if (toRead > 512) toRead = 512;
sd_lock_acquire();
size_t n = ctx->f.read(buffer, toRead);
sd_lock_release();
if (n == 0) { ctx->f.close(); }
return n;
});
resp->addHeader(hdr_cache_control_key, F("public, max-age=300")); resp->addHeader(hdr_cache_control_key, F("public, max-age=300"));
resp->addHeader(hdr_connection_key, hdr_connection_val); resp->addHeader(hdr_connection_key, hdr_connection_val);
if (useGz) {
resp->addHeader(F("Content-Encoding"), F("gzip"));
}
// Ensure FileCtx cleanup even on aborted connections
request->onDisconnect([ctx](){
sd_lock_acquire();
if (ctx->f) ctx->f.close();
sd_lock_release();
delete ctx;
webreq_exit();
});
request->send(resp); request->send(resp);
} }
} }
else else
{ {
// Fallback: serve minimal CSS if file not found // Fallback: serve minimal CSS if file not found
request->send(404, txt_plain, F("ERROR: /system/style.css(.gz) not found!")); request->send(404, txt_plain, F("ERROR: /system/style.css not found!"));
} }
}); });
@ -1232,62 +1182,37 @@ server.on("/", HTTP_GET, [](AsyncWebServerRequest *request)
server.on("/script.js", HTTP_GET, [](AsyncWebServerRequest *request) server.on("/script.js", HTTP_GET, [](AsyncWebServerRequest *request)
{ {
webreq_enter(); webreq_enter();
/* onDisconnect set later with cleanup */ request->onDisconnect([](){ webreq_exit(); sd_lock_release();});
deactivateRFID(); deactivateRFID();
activateSD(); activateSD();
sd_lock_acquire();
static String jsPath = ""; static String jsPath = "";
static String jsPathGz = "";
if (jsPath.isEmpty()) { if (jsPath.isEmpty()) {
jsPath = getSysDir(script_file); jsPath = getSysDir(script_file);
jsPathGz = jsPath + F(".gz");
} }
// Prefer gz if present if (SD.exists(jsPath))
bool useGz = SD.exists(jsPathGz);
const String &sendPath = useGz ? jsPathGz : jsPath;
if (SD.exists(sendPath))
{ {
#ifdef DEBUG uint32_t fsize = 0;
Serial.printf("Serving %s heap=%u webreq_cnt=%u\n", sendPath.c_str(), (unsigned)xPortGetFreeHeapSize(), (unsigned)webreq_cnt);
#endif
{ {
// Chunked streaming with short SD lock per read File f = SD.open(jsPath);
struct FileCtx { File f; }; if (f) { fsize = f.size(); f.close(); }
FileCtx* ctx = new FileCtx(); }
ctx->f = SD.open(sendPath); #ifdef DEBUG
if (!ctx->f) { delete ctx; request->send(500, txt_plain, F("Open failed")); return; } Serial.printf("Serving %s size=%u heap=%u webreq_cnt=%u\n", jsPath.c_str(), (unsigned)fsize, (unsigned)xPortGetFreeHeapSize(), (unsigned)webreq_cnt);
auto resp = request->beginChunkedResponse(F("application/javascript"), #endif
[ctx](uint8_t *buffer, size_t maxLen, size_t index) -> size_t { {
size_t toRead = maxLen; AsyncWebServerResponse *resp = request->beginResponse(SD, jsPath, F("application/javascript"));
if (toRead > 512) toRead = 512;
sd_lock_acquire();
size_t n = ctx->f.read(buffer, toRead);
sd_lock_release();
if (n == 0) { ctx->f.close(); }
return n;
});
resp->addHeader(hdr_cache_control_key, F("public, max-age=300")); resp->addHeader(hdr_cache_control_key, F("public, max-age=300"));
resp->addHeader(hdr_connection_key, hdr_connection_val); resp->addHeader(hdr_connection_key, hdr_connection_val);
if (useGz) {
resp->addHeader(F("Content-Encoding"), F("gzip"));
}
// Ensure FileCtx cleanup even on aborted connections
request->onDisconnect([ctx](){
sd_lock_acquire();
if (ctx->f) ctx->f.close();
sd_lock_release();
delete ctx;
webreq_exit();
});
request->send(resp); request->send(resp);
} }
} }
else else
{ {
// Fallback: serve minimal JS if file not found // Fallback: serve minimal JS if file not found
request->send(404, txt_plain, F("ERROR: /system/script.js(.gz) not found!")); request->send(404, txt_plain, F("ERROR: /system/script.js not found!"));
} }
}); });
@ -1296,8 +1221,10 @@ server.on("/", HTTP_GET, [](AsyncWebServerRequest *request)
server.on("/directory", HTTP_GET, [](AsyncWebServerRequest *request) server.on("/directory", HTTP_GET, [](AsyncWebServerRequest *request)
{ {
webreq_enter(); webreq_enter();
request->onDisconnect([](){ webreq_exit(); }); request->onDisconnect([](){ webreq_exit(); sd_lock_release(); });
// Stream the response directly from the directory tree to avoid large temporary Strings // Acquire SD lock to prevent concurrent modifications to directory tree
// and to prevent audio playback from interrupting the stream
sd_lock_acquire();
// Stream the response directly from the directory tree to avoid large temporary Strings // Stream the response directly from the directory tree to avoid large temporary Strings
AsyncResponseStream* stream = request->beginResponseStream(txt_html_charset); AsyncResponseStream* stream = request->beginResponseStream(txt_html_charset);
#ifdef DEBUG #ifdef DEBUG
@ -1313,7 +1240,8 @@ server.on("/", HTTP_GET, [](AsyncWebServerRequest *request)
server.on("/mapping", HTTP_GET, [](AsyncWebServerRequest *request) server.on("/mapping", HTTP_GET, [](AsyncWebServerRequest *request)
{ {
webreq_enter(); webreq_enter();
request->onDisconnect([](){ webreq_exit();}); request->onDisconnect([](){ webreq_exit(); sd_lock_release();});
sd_lock_acquire();
// Stream mapping to avoid Content-Length mismatches and reduce heap spikes // Stream mapping to avoid Content-Length mismatches and reduce heap spikes
AsyncResponseStream* stream = request->beginResponseStream(txt_html_charset); AsyncResponseStream* stream = request->beginResponseStream(txt_html_charset);
#ifdef DEBUG #ifdef DEBUG
@ -1326,7 +1254,6 @@ server.on("/", HTTP_GET, [](AsyncWebServerRequest *request)
request->send(stream); request->send(stream);
}); });
server.on("/state", HTTP_GET, [](AsyncWebServerRequest *request) server.on("/state", HTTP_GET, [](AsyncWebServerRequest *request)
{ {
webreq_enter(); webreq_enter();
@ -1389,16 +1316,7 @@ server.on("/", HTTP_GET, [](AsyncWebServerRequest *request)
[](AsyncWebServerRequest *request) [](AsyncWebServerRequest *request)
{ {
webreq_enter(); webreq_enter();
// Ensure any in-progress upload file is closed on client abort to free the FD request->onDisconnect([](){ webreq_exit(); });
request->onDisconnect([request](){
// Close temporary upload file if still open
if (request->_tempFile) {
sd_lock_acquire();
request->_tempFile.close();
sd_lock_release();
}
webreq_exit();
});
request->send(200); request->send(200);
}, },
handleUpload); handleUpload);