mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-19 22:50:02 +00:00
Physical LG Android 5.1.1 testing found Chrome 43 could decode gzip but failed on the bootstrap's arrow syntax. Use ES5 functions and Promises while preserving retries, version refresh, and optional abort handling. Keep Google Zopfli gzip compression at 1,000 iterations. Verified the flashed V4.3 through its WiFi link using the real Android browser: bootstrap and main page gzip responses completed, settings loaded at 910.525 MHz, and no JavaScript or transfer errors were reported. Add a loader syntax regression and document legacy browser support.
198 lines
8.4 KiB
Python
198 lines
8.4 KiB
Python
#!/usr/bin/env python
|
|
#
|
|
# Pre-build script: compress webui/index.html into a PROGMEM C header so the
|
|
# webconfig portal can serve the page straight from flash with
|
|
# Content-Encoding: gzip. The generated header is tracked so ESP32 targets
|
|
# that replace extra_scripts still compile from a clean checkout; this script
|
|
# rewrites it only when the emitted firmware bytes actually change.
|
|
#
|
|
# Comments and indentation are stripped before compressing (see strip_source).
|
|
# The source page is heavily commented by house style and none of it is worth
|
|
# flash, so the page ships smaller than it reads.
|
|
#
|
|
# Output: src/helpers/esp32/WebConfigHtml.h
|
|
# WEBCONFIG_HTML_LOADER[] - gzip-compressed bootstrap page (PROGMEM)
|
|
# WEBCONFIG_HTML_GZ[] - complete gzip-compressed page (PROGMEM)
|
|
# WEBCONFIG_HTML_ETAG - quoted strong ETag for the loader page
|
|
#
|
|
# Runnable outside SCons to inspect exactly what gets shipped:
|
|
# python3 scripts/generate_webconfig_html.py --emit /tmp/shipped.html
|
|
# or served directly by the mock backend with its --minify flag.
|
|
|
|
import hashlib
|
|
import gzip
|
|
from pathlib import Path
|
|
import tempfile
|
|
import zlib
|
|
import os
|
|
import sys
|
|
|
|
try:
|
|
Import("env") # noqa: F821
|
|
except NameError:
|
|
pass # running standalone (--emit), not as a PIO extra_script
|
|
|
|
SOURCE = os.path.join("webui", "index.html")
|
|
OUTPUT = os.path.join("src", "helpers", "esp32", "WebConfigHtml.h")
|
|
# __file__ is not defined inside PIO/SCons-executed extra_scripts
|
|
HASH_MARKER = "// shipped-page-sha256: "
|
|
|
|
sys.path.insert(0, os.path.join(os.getcwd(), "scripts"))
|
|
from webconfig_minify import check_stripped, strip_source # noqa: E402
|
|
from zopfli_compress import ZOPFLI_VERSION, MAXIMUM_ITERATIONS, gzip_compress # noqa: E402
|
|
|
|
|
|
def status(msg):
|
|
sys.stderr.write("WebConfig HTML: %s\n" % msg)
|
|
|
|
|
|
def content_hash(raw):
|
|
"""Identify the bytes that the device actually serves.
|
|
|
|
Do not hash this generator or the minifier source: a comment-only tooling
|
|
edit must not dirty the checked-in firmware header. Generator changes are
|
|
still safe because main() renders the candidate header and compares its
|
|
full byte sequence before deciding whether to write.
|
|
"""
|
|
return hashlib.sha256(raw).hexdigest()
|
|
|
|
|
|
def shipped_page():
|
|
"""The exact bytes the device serves: the source page, stripped."""
|
|
with open(SOURCE, "r", encoding="utf-8") as f:
|
|
raw = f.read()
|
|
stripped = strip_source(raw)
|
|
problem = check_stripped(raw, stripped)
|
|
if problem:
|
|
status("ERROR: comment stripping corrupted the page (%s)" % problem)
|
|
sys.exit(2)
|
|
return raw, stripped
|
|
|
|
|
|
def append_byte_array(lines, name, data):
|
|
lines.append("const uint8_t %s[] PROGMEM = {" % name)
|
|
for i in range(0, len(data), 16):
|
|
chunk = data[i:i + 16]
|
|
lines.append(" " + "".join("0x%02x," % b for b in chunk))
|
|
lines.append("};")
|
|
|
|
|
|
def compress_page(raw):
|
|
"""Cache validated 1,000-iteration output so normal builds stay fast."""
|
|
key = hashlib.sha256((ZOPFLI_VERSION + ":" + str(MAXIMUM_ITERATIONS)).encode()
|
|
+ raw).hexdigest()
|
|
cache = Path(".pio/gzip-html")
|
|
path = cache / (key + ".gz")
|
|
try:
|
|
encoded = path.read_bytes()
|
|
if gzip.decompress(encoded) == raw:
|
|
return encoded
|
|
except (OSError, EOFError, gzip.BadGzipFile, zlib.error):
|
|
pass
|
|
encoded = gzip_compress(raw, numiterations=MAXIMUM_ITERATIONS)
|
|
cache.mkdir(parents=True, exist_ok=True)
|
|
with tempfile.NamedTemporaryFile(dir=cache, delete=False) as output:
|
|
output.write(encoded)
|
|
temporary = output.name
|
|
os.replace(temporary, path)
|
|
return encoded
|
|
|
|
|
|
def main():
|
|
if not os.path.isfile(SOURCE):
|
|
status("ERROR: %s not found" % SOURCE)
|
|
sys.exit(2)
|
|
|
|
if "--emit" in sys.argv:
|
|
dest = sys.argv[sys.argv.index("--emit") + 1]
|
|
src, stripped = shipped_page()
|
|
with open(dest, "w", encoding="utf-8") as f:
|
|
f.write(stripped)
|
|
status("%s -> %s (%d -> %d bytes)" % (SOURCE, dest, len(src), len(stripped)))
|
|
return
|
|
|
|
# The compact uploader and fallback WiFi form share the same compressor.
|
|
static_lines = ["// Auto-generated by scripts/generate_webconfig_html.py.",
|
|
"// Google Zopfli %s (gzip, %d iterations)." %
|
|
(ZOPFLI_VERSION, MAXIMUM_ITERATIONS),
|
|
"#pragma once", "#include <stdint.h>", "#include <pgmspace.h>"]
|
|
for page in sorted(Path("webui/pages").glob("*.html")):
|
|
symbol = "MESH_HTML_" + page.stem.upper()
|
|
compressed = compress_page(page.read_bytes())
|
|
static_lines.append("const uint32_t %s_LEN = %d;" % (symbol, len(compressed)))
|
|
append_byte_array(static_lines, symbol, compressed)
|
|
static_header = Path("src/helpers/esp32/StaticHtml.h")
|
|
static_rendered = "\n".join(static_lines) + "\n"
|
|
if not static_header.exists() or static_header.read_text() != static_rendered:
|
|
static_header.write_text(static_rendered)
|
|
|
|
src, stripped = shipped_page()
|
|
raw = stripped.encode("utf-8")
|
|
src_hash = content_hash(raw)
|
|
|
|
gz = compress_page(raw)
|
|
version = hashlib.sha256(gz).hexdigest()[:16]
|
|
loader = ("<!doctype html><meta charset=utf-8><meta name=viewport "
|
|
"content=\"width=device-width,initial-scale=1\"><title>MeshCore "
|
|
"WebConfig</title><body>Loading MeshCore WebConfig…<script>"
|
|
# Android 5.1's stock Chrome 43 supports fetch and Promises, but
|
|
# cannot parse arrows or async functions. Match the main UI's
|
|
# ES5 syntax so the bootstrap cannot strand those browsers.
|
|
"(function(){var n=0;function load(){var C=window.AbortController,"
|
|
"c=C&&new C(),t=c&&setTimeout(function(){c.abort()},90000);"
|
|
"fetch(\"/ui?v=%s\",c?{signal:c.signal}:{}).then(function(r){"
|
|
"if(r.ok)return r.text();if(r.status===409){location.reload();"
|
|
"return null}throw Error(\"transfer failed\")}).then(function(p){"
|
|
"if(t)clearTimeout(t);if(p===null)return;document.open();"
|
|
"document.write(p);document.close()},function(e){"
|
|
"if(t)clearTimeout(t);if(++n<3)setTimeout(load,500*n);else "
|
|
"document.body.textContent=\"Unable to load WebConfig: \"+e+"
|
|
"\". Reload to retry.\"})}load()})()</script>" % version)
|
|
loader_bytes = compress_page(loader.encode("utf-8"))
|
|
success_bytes = compress_page(b"<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>")
|
|
etag = hashlib.sha256(loader_bytes).hexdigest()[:16]
|
|
|
|
lines = []
|
|
lines.append("// Auto-generated by scripts/generate_webconfig_html.py from %s" % SOURCE.replace(os.sep, "/"))
|
|
lines.append("// DO NOT EDIT - edit webui/index.html instead.")
|
|
lines.append("// Compressed with Google Zopfli %s (gzip, %d iterations)." %
|
|
(ZOPFLI_VERSION, MAXIMUM_ITERATIONS))
|
|
lines.append("%s%s" % (HASH_MARKER, src_hash))
|
|
lines.append("#pragma once")
|
|
lines.append("#include <stdint.h>")
|
|
lines.append("#include <pgmspace.h>")
|
|
lines.append("")
|
|
lines.append("const uint32_t WEBCONFIG_HTML_GZ_LEN = %d;" % len(gz))
|
|
lines.append('const char WEBCONFIG_HTML_ETAG[] = "\\"%s\\"";' % etag)
|
|
lines.append('const char WEBCONFIG_HTML_VERSION[] = "%s";' % version)
|
|
lines.append("const uint16_t WEBCONFIG_HTML_LOADER_LEN = %d;" % len(loader_bytes))
|
|
lines.append("const uint16_t WEBCONFIG_HTML_SUCCESS_LEN = %d;" % len(success_bytes))
|
|
append_byte_array(lines, "WEBCONFIG_HTML_LOADER", loader_bytes)
|
|
lines.append("")
|
|
append_byte_array(lines, "WEBCONFIG_HTML_GZ", gz)
|
|
lines.append("")
|
|
append_byte_array(lines, "WEBCONFIG_HTML_SUCCESS", success_bytes)
|
|
lines.append("")
|
|
|
|
rendered = "\n".join(lines)
|
|
try:
|
|
with open(OUTPUT, "r", encoding="utf-8", newline="") as f:
|
|
if f.read() == rendered:
|
|
return
|
|
except OSError:
|
|
pass
|
|
|
|
os.makedirs(os.path.dirname(OUTPUT), exist_ok=True)
|
|
with open(OUTPUT, "w", encoding="utf-8", newline="") as f:
|
|
f.write(rendered)
|
|
|
|
status("%s -> %s (%d bytes source, %d stripped, %d gzip)"
|
|
% (SOURCE, OUTPUT, len(src.encode("utf-8")), len(raw), len(gz)))
|
|
|
|
|
|
# This script is attached only to esp32_base. Generate unconditionally here:
|
|
# CPPDEFINES is not fully resolved when PlatformIO loads pre-scripts, so trying
|
|
# to detect the final role at this point can leave a stale embedded page.
|
|
# Non-WebConfig ESP32 builds do not compile the generated data into firmware.
|
|
main()
|