fix: harden ESP-NOW and full companion networking
Add selectable shared ESP-NOW channels, raw/wrapped bridge compatibility, and bounded fragmentation for maximum-size packets.\n\nGive ESP32 Full profiles WiFi coexistence, stabilize WebConfig and USB terminal lifecycles, and keep disabled bridges stopped during observer updates.\n\nUpdate generated UI, firmware selection, documentation, and regression coverage.
This commit is contained in:
1 parent
c6476b8ba8
commit
2b3b93ab2f
56 files changed
+5783
-2155
No files matched your search
@@ -11,9 +11,9 @@
|
||||
# flash, so the page ships smaller than it reads.
|
||||
#
|
||||
# Output: src/helpers/esp32/WebConfigHtml.h
|
||||
# WEBCONFIG_HTML_GZ[] - gzipped page (PROGMEM)
|
||||
# WEBCONFIG_HTML_GZ_LEN - byte length
|
||||
# WEBCONFIG_HTML_ETAG - quoted strong ETag (sha256 prefix of the gz body)
|
||||
# WEBCONFIG_HTML_LOADER[] - small bootstrap page (PROGMEM)
|
||||
# WEBCONFIG_HTML_GZ[] - complete gzipped 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
|
||||
@@ -83,6 +83,14 @@ def shipped_page():
|
||||
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 main():
|
||||
if not os.path.isfile(SOURCE):
|
||||
status("ERROR: %s not found" % SOURCE)
|
||||
@@ -103,9 +111,24 @@ def main():
|
||||
src, stripped = shipped_page()
|
||||
raw = stripped.encode("utf-8")
|
||||
|
||||
# mtime=0 keeps the gzip output (and therefore the ETag) deterministic
|
||||
# mtime=0 keeps the gzip output and its immutable URL deterministic.
|
||||
gz = gzip.compress(raw, compresslevel=9, mtime=0)
|
||||
etag = hashlib.sha256(gz).hexdigest()[:16]
|
||||
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>"
|
||||
"(async()=>{let p;for(let n=0;n<3&&p===undefined;n++){const C="
|
||||
"window.AbortController,c=C&&new C(),t=c&&setTimeout(()=>c.abort()"
|
||||
",90000);try{const r=await fetch(\"/ui?v=%s\",c?{signal:c.signal}"
|
||||
":{});if(r.ok)p=await r.text();else if(r.status===409)return "
|
||||
"location.reload()}catch(e){}finally{if(t)clearTimeout(t)}if(p==="
|
||||
"undefined)await new Promise(r=>setTimeout(r,500*(n+1)))}if(p==="
|
||||
"undefined)throw Error(\"transfer failed\");document.open();"
|
||||
"document.write(p);document.close()})().catch(e=>"
|
||||
"document.body.textContent=\"Unable to load WebConfig: \"+e+"
|
||||
"\". Reload to retry.\")</script>" % version)
|
||||
loader_bytes = loader.encode("utf-8")
|
||||
etag = hashlib.sha256(loader_bytes).hexdigest()[:16]
|
||||
|
||||
lines = []
|
||||
lines.append("// Auto-generated by scripts/generate_webconfig_html.py from %s" % SOURCE.replace(os.sep, "/"))
|
||||
@@ -115,20 +138,21 @@ def main():
|
||||
lines.append("#include <stdint.h>")
|
||||
lines.append("#include <pgmspace.h>")
|
||||
lines.append("")
|
||||
lines.append("const uint32_t WEBCONFIG_HTML_RAW_LEN = %d;" % len(raw))
|
||||
lines.append("const uint32_t WEBCONFIG_HTML_GZ_LEN = %d;" % len(gz))
|
||||
lines.append('const char WEBCONFIG_HTML_ETAG[] = "\\"%s\\"";' % etag)
|
||||
lines.append("const uint8_t WEBCONFIG_HTML_GZ[] PROGMEM = {")
|
||||
for i in range(0, len(gz), 16):
|
||||
chunk = gz[i:i + 16]
|
||||
lines.append(" " + "".join("0x%02x," % b for b in chunk))
|
||||
lines.append("};")
|
||||
lines.append('const char WEBCONFIG_HTML_VERSION[] = "%s";' % version)
|
||||
lines.append("const uint16_t WEBCONFIG_HTML_LOADER_LEN = %d;" % len(loader_bytes))
|
||||
append_byte_array(lines, "WEBCONFIG_HTML_LOADER", loader_bytes)
|
||||
lines.append("")
|
||||
append_byte_array(lines, "WEBCONFIG_HTML_GZ", gz)
|
||||
lines.append("")
|
||||
|
||||
os.makedirs(os.path.dirname(OUTPUT), exist_ok=True)
|
||||
with open(OUTPUT, "w") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
status("%s -> %s (%d bytes source, %d stripped, %d gzipped)"
|
||||
status("%s -> %s (%d bytes source, %d stripped, %d gzip)"
|
||||
% (SOURCE, OUTPUT, len(src.encode("utf-8")), len(raw), len(gz)))
|
||||
|
||||
|
||||
|
||||
@@ -102,6 +102,8 @@ ROUND_TRIPS = [
|
||||
("set dutycycle 25", "get dutycycle", "25.0"),
|
||||
("set alert.mqtt on", "get alert.mqtt", "on"),
|
||||
("set bridge.source tx", "get bridge.source", "tx"),
|
||||
("set bridge.channel 1", "get bridge.channel", "1"),
|
||||
("set bridge.format raw", "get bridge.format", "raw"),
|
||||
("set mqtt.neighbors on", "get mqtt.neighbors", "on"),
|
||||
("set path.hash.mode 2", "get path.hash.mode", "2"),
|
||||
("set mqtt.iata den", "get mqtt.iata", "DEN"),
|
||||
|
||||
@@ -60,10 +60,11 @@ ADMIN_PASSWORD = "password" # matches the default ADMIN_PASSWORD build
|
||||
BATCH_PENDING_SECS = 0.8 # how long POST->done takes, to exercise polling
|
||||
SCAN_SECS = 0.8
|
||||
|
||||
# Destination buffer sizes (chars, minus the NUL) -- mirrors the MQTTPrefs fields
|
||||
# the firmware validates in CommonCLI_Observer.cpp.
|
||||
# Destination buffer sizes (chars, minus the NUL). Most mirror MQTTPrefs;
|
||||
# wifi.pwd models the standalone mesh-wifi NVS path and receives the additional
|
||||
# exact-64-hex check in apply_set(). Slot credentials remain fixed-layout.
|
||||
LEN_LIMITS = {
|
||||
"name": 31, "wifi.ssid": 31, "wifi.pwd": 63, "mqtt.origin": 31,
|
||||
"name": 31, "wifi.ssid": 31, "wifi.pwd": 64, "mqtt.origin": 31,
|
||||
"mqtt.email": 63, "mqtt.ntp": 63, "timezone": 31, "snmp.community": 23,
|
||||
}
|
||||
# "filter" is absent on purpose: it is a bitmask, not a text buffer, so it has
|
||||
@@ -89,11 +90,11 @@ PRESETS = (
|
||||
)
|
||||
|
||||
SCAN_NETWORKS = [
|
||||
{"ssid": "Wokwi-GUEST", "rssi": -42, "enc": False},
|
||||
{"ssid": "HomeNet", "rssi": -55, "enc": True},
|
||||
{"ssid": "HomeNet-5G", "rssi": -61, "enc": True},
|
||||
{"ssid": "Neighbor 2.4", "rssi": -78, "enc": True},
|
||||
{"ssid": "OpenGuest", "rssi": -83, "enc": False},
|
||||
{"ssid": "Wokwi-GUEST", "rssi": -42, "enc": False, "channel": 6},
|
||||
{"ssid": "HomeNet", "rssi": -55, "enc": True, "channel": 1},
|
||||
{"ssid": "HomeNet-5G", "rssi": -61, "enc": True, "channel": 11},
|
||||
{"ssid": "Neighbor 2.4", "rssi": -78, "enc": True, "channel": 9},
|
||||
{"ssid": "OpenGuest", "rssi": -83, "enc": False, "channel": 3},
|
||||
]
|
||||
|
||||
|
||||
@@ -137,7 +138,8 @@ def default_config(setup_mode):
|
||||
"alert.region": "", "alert.interval": 15,
|
||||
"alert.mqtt": False, "alert.wifi": False,
|
||||
"bridge.enabled": False, "bridge.source": "rx", "bridge.baud": 115200,
|
||||
"bridge.delay": 0, "bridge.channel": 0, "bridge.secret": "",
|
||||
"bridge.delay": 0, "bridge.channel": 0,
|
||||
"bridge.format": "wrapped", "bridge.secret": "",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -196,6 +198,7 @@ class State:
|
||||
"role": "Repeater", "board": "Heltec V3 (mock)",
|
||||
"uptime_s": int(time.time() - self.start),
|
||||
"runtime_slots": 6, "max_slots": 6, "active_slots": self.active_slots,
|
||||
"wifi_psk64": True,
|
||||
# Every ordinary repeater feature except an external FEM, plus
|
||||
# radio RX power saving (bit 12) and device power saving (bit 13).
|
||||
"capabilities": 0x37FF,
|
||||
@@ -262,6 +265,8 @@ def _rxps_level_timings(level, sf, bw, preamble):
|
||||
def apply_set(cfg, key, val):
|
||||
"""Return (ok, reply) and mutate cfg. Mirrors the firmware's validation for
|
||||
the fields where it matters (length, IATA, owner key, port, radio combo)."""
|
||||
if key == "wifi.pwd" and (len(val) > 64 or (len(val) == 64 and not _hex64(val))):
|
||||
return False, "Error: WiFi password must be 0-63 characters or 64 hex characters"
|
||||
# length guard for the plain string fields
|
||||
if key in LEN_LIMITS and len(val) > LEN_LIMITS[key]:
|
||||
return False, "Error: %s too long (max %d chars)" % (key, LEN_LIMITS[key])
|
||||
|
||||
Reference in new issue
Block a user