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:
mikecarper committed 2026-08-28 16:36:34 -07:00
1 parent c6476b8ba8
commit 2b3b93ab2f
56 files changed
+5783 -2155

No files matched your search

+35 -11
View File
@@ -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&hellip;<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)))
+2
View File
@@ -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"),
+14 -9
View File
@@ -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])