mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-12 20:45:41 +00:00
Integrate ExpressLRS TX module support and its stacked ESP32 heap changes. Use the approved Linkflow calibration (17-30 dBm), preserve the PA drive and output path after radio recovery, and keep LoRa OTA enabled. Preserve stored ACLs and filters on allocation failure, release owned client/filter buffers, service heap OTA contexts on Companions, release self-serving workspaces after TempRadio, and reset staged-resume state when a context is released. Keep manual staging and active operations alive. Account for all moved allocations in the runtime RAM gate. Validation: 1,393 native cases; radio-power, heap-context, ACL persistence, shared-queue transfer, display/inbox, radio receive, and memory regressions. Firmware builds passed for Linkflow, Heltec V2 Companion, T-Beam MQTT repeater, Heltec V4 R8 MQTT repeater, RAK4631 repeater, and Indicator Full. Physical verification awaits access to the currently offline lab Pi.
304 lines
14 KiB
Python
304 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Gate firmware builds on internal runtime RAM, including enabled startup features.
|
|
|
|
Uses real linker heap bounds on ARM and the linked ESP-IDF allocator tables on
|
|
ESP32. These are capacity checks before dynamic allocation, not hardware soak
|
|
results. PSRAM, instruction-only RAM and reserved bootloader arenas never count
|
|
as internal heap. See docs/firmware_memory_budget.md for the budget policy.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import struct
|
|
import sys
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent if "__file__" in globals() else Path("scripts").resolve()
|
|
sys.path.insert(0, str(SCRIPT_DIR))
|
|
from firmware_elf import FirmwareElf
|
|
|
|
SUPPORTED = {"NRF52_PLATFORM", "ESP32_PLATFORM", "RP2040_PLATFORM", "STM32_PLATFORM"}
|
|
DISPLAY_HEAP = {
|
|
"": 0, "NullDisplayDriver": 0,
|
|
"ST7735Display": 25602,
|
|
"SSD1306Display": 4096, "SH1106Display": 4096, "U8g2Display": 4096,
|
|
# These drivers render directly or embed their pixel storage in globals.
|
|
"ST7789Display": 0, "ST7789LCDDisplay": 0, "NV3001BDisplay": 0,
|
|
"GxEPDDisplay": 0, "E213Display": 8192, "E290Display": 8192,
|
|
}
|
|
|
|
|
|
def integer(defines, name, default):
|
|
value = str(defines.get(name, default)).strip().strip("()")
|
|
value = re.sub(r"[uUlL]+$", "", value)
|
|
try:
|
|
result = int(value, 0)
|
|
except ValueError as error:
|
|
raise ValueError(f"memory budget needs an integer {name}, got {value!r}") from error
|
|
if result < 0:
|
|
raise ValueError(f"negative memory budget input {name}")
|
|
return result
|
|
|
|
|
|
def requirements(platform, defines, target):
|
|
if platform not in SUPPORTED:
|
|
raise ValueError(f"no memory policy for {platform}")
|
|
companion = bool(re.search(r"companion|comp_radio|comp_.*radio", target, re.I))
|
|
full = "COMPANION_RADIO_FULL" in defines or "companion_radio_full" in target.lower()
|
|
display = str(defines.get("DISPLAY_CLASS", "")).strip('"')
|
|
if display == "SCIndicatorDisplay":
|
|
# Largest supported runtime canvas is 480 square at four bits/pixel;
|
|
# scanout is separately allocated in PSRAM, never included here.
|
|
depth = integer(defines, "UI_BUFFER_COLOR_DEPTH", 8)
|
|
pixels = 480 * 480 if "INDICATOR_TRANSPORT_RENDER_PROFILE" in defines else 320 * 320
|
|
display_heap = (pixels * depth + 7) // 8 + 1024
|
|
elif display in DISPLAY_HEAP:
|
|
display_heap = DISPLAY_HEAP[display]
|
|
else:
|
|
raise ValueError(f"add a runtime allocation budget for display {display!r}")
|
|
parts = {}
|
|
if platform == "NRF52_PLATFORM":
|
|
parts["loop_and_callback_stacks"] = integer(defines, "MESH_NRF52_LOOP_STACK_WORDS", 2048) * 4 + 3072
|
|
parts["core_usb_filesystems_sensors"] = 12288
|
|
if "BLE_PIN_CODE" in defines:
|
|
parts["bluetooth_worker_stacks"] = 5920
|
|
elif platform == "ESP32_PLATFORM":
|
|
parts["core_tasks_usb_filesystems"] = 24576
|
|
wifi = 49152 if "WIFI_SSID" in defines or "WIFI_OTA_SEEDER" in defines else 0
|
|
ble = 32768 if "BLE_PIN_CODE" in defines else 0
|
|
parts["wireless_stacks"] = max(wifi, ble) if "COMPANION_EXCLUSIVE_WIFI_BLE" in defines else wifi + ble
|
|
if "WITH_MQTT_BRIDGE" in defines:
|
|
parts["mqtt_connections_buffers"] = 24576
|
|
if "ENABLE_OTA" in defines:
|
|
parts["ota_source_scratch"] = 8192
|
|
if ("WEBCONFIG_DISABLED" not in defines
|
|
and ((companion and "WIFI_SSID" in defines)
|
|
or (not companion and "ADMIN_PASSWORD" in defines))):
|
|
parts["browser_terminal_session"] = 2048
|
|
if "BOARD_HAS_PSRAM" not in defines:
|
|
# Larger replies grow on demand only while preserving 32 KiB
|
|
# of free internal heap, and shrink after the browser reads.
|
|
parts["browser_terminal_scrollback"] = 4096
|
|
if not companion:
|
|
parts["neighbor_history"] = integer(defines, "MAX_RECENT_REPEATERS", 50) * 12
|
|
else:
|
|
parts["core_filesystems_sensors"] = 8192
|
|
# Packet bytes plus all three queue tables and allocation overhead.
|
|
parts["radio_packet_pool"] = 5120 if companion else 10240
|
|
# These tables moved out of .bss, so linker heap bounds now include their
|
|
# space. Count it as startup allocation instead; C++ assertions bind the
|
|
# per-entry bounds to the production structures.
|
|
if re.search(r"repeater|room_server|sensor", target, re.I) or "COMPANION_MESH_CLOCK_SYNC" in defines:
|
|
parts["client_table"] = integer(defines, "MAX_CLIENTS", 32) * 320 + 16
|
|
if "repeater" in target.lower():
|
|
engine = integer(defines, "MESH_ENABLE_FLOOD_RULE_ENGINE", int(platform != "STM32_PLATFORM"))
|
|
slots = integer(defines, "FLOOD_PACKET_FILTER_SLOTS", 63 if engine else 16)
|
|
parts["flood_filter_table"] = slots * (200 if engine else 40) + 16
|
|
if "ENABLE_OTA" in defines and "OTA_HEAP_CONTEXT" in defines:
|
|
parts["ota_context"] = 16384 + 16
|
|
if display and display != "NullDisplayDriver":
|
|
parts["display_pixels_and_driver"] = display_heap
|
|
parts["screen_objects_and_history"] = 8192 if companion else 2048
|
|
if companion:
|
|
# ui-new retains 32 previews in one heap allocation. The baseline
|
|
# covers 78 bytes per message; budget larger buffers explicitly,
|
|
# including worst-case 8-byte Entry alignment.
|
|
small_display = display in {
|
|
"SSD1306Display", "SH1106Display", "ST7735Display", "U8g2Display",
|
|
}
|
|
small_font = integer(defines, "UI_SMALL_MESSAGE_FONT", int(small_display))
|
|
default_preview = 161 if small_font else 78
|
|
extra = max(0, integer(defines, "UI_MSG_PREVIEW_SIZE", default_preview) - 78)
|
|
if extra:
|
|
parts["expanded_message_previews"] = 32 * ((extra + 7) // 8) * 8
|
|
parts["allocation_and_transient_margin"] = 16384 if platform == "ESP32_PLATFORM" else 4096
|
|
required = sum(parts.values())
|
|
if platform == "NRF52_PLATFORM" and full and display == "ST7735Display":
|
|
required = max(required, 73728)
|
|
# A new profile may increase this budget; it cannot override it downward.
|
|
required = max(required, integer(defines, "MESH_MIN_RUNTIME_HEAP", 0))
|
|
largest = max(display_heap, parts["radio_packet_pool"], 8192 if platform == "ESP32_PLATFORM" else 0)
|
|
if "expanded_message_previews" in parts:
|
|
largest = max(largest, 8192 + parts["expanded_message_previews"])
|
|
for name in ("client_table", "flood_filter_table", "ota_context"):
|
|
largest = max(largest, parts.get(name, 0))
|
|
return {"required_heap_bytes": required, "required_contiguous_bytes": largest,
|
|
"components": parts, "display": display, "full_companion": full}
|
|
|
|
|
|
def subtract_regions(regions, reserved):
|
|
result = list(regions)
|
|
for lower, upper in reserved:
|
|
if lower > upper:
|
|
raise ValueError("reversed reserved memory range")
|
|
lower, upper = lower & ~3, (upper + 3) & ~3
|
|
next_regions = []
|
|
for start, end, kind in result:
|
|
if upper <= start or lower >= end:
|
|
next_regions.append((start, end, kind))
|
|
else:
|
|
if start < lower:
|
|
next_regions.append((start, lower, kind))
|
|
if upper < end:
|
|
next_regions.append((upper, end, kind))
|
|
result = next_regions
|
|
merged = []
|
|
for start, end, kind in sorted(result):
|
|
if end - start <= 16:
|
|
continue
|
|
if merged and start < merged[-1][1]:
|
|
raise ValueError("overlapping internal heap regions")
|
|
if merged and start == merged[-1][1] and kind == merged[-1][2]:
|
|
merged[-1] = (merged[-1][0], end, kind)
|
|
else:
|
|
merged.append((start, end, kind))
|
|
return merged
|
|
|
|
|
|
def esp32_heap_regions(elf, mcu):
|
|
count = elf.words("soc_memory_region_count")[0]
|
|
size = elf.symbols["soc_memory_regions"][1]
|
|
if not count or count > 256 or size % count:
|
|
raise ValueError("invalid ESP-IDF memory-region table")
|
|
stride = size // count
|
|
if stride not in (16, 20):
|
|
raise ValueError(f"unsupported ESP-IDF memory-region ABI ({stride} bytes)")
|
|
# IDF 4 puts startup_stack/alias flags in each type; current IDF 5 puts
|
|
# startup_stack in each region. Read the actual linked ABI and capabilities.
|
|
type_stride = 20 if stride == 16 else 16
|
|
type_size = elf.symbols["soc_memory_types"][1]
|
|
if not type_size or type_size % type_stride:
|
|
raise ValueError("unsupported ESP-IDF memory-type ABI")
|
|
types = []
|
|
for i in range(type_size // type_stride):
|
|
row = elf.read(elf.address("soc_memory_types") + i * type_stride, type_stride)
|
|
_, a, b, c = struct.unpack_from("<4I", row)
|
|
types.append((a | b | c, bool(row[17]) if type_stride == 20 else False))
|
|
regions = []
|
|
for i in range(count):
|
|
row = elf.read(elf.address("soc_memory_regions") + i * stride, stride)
|
|
start, length, kind, _ = struct.unpack_from("<4I", row)
|
|
if kind >= len(types) or not length or start + length > 0x100000000:
|
|
raise ValueError("invalid ESP-IDF heap region")
|
|
caps, startup = types[kind]
|
|
if stride == 20:
|
|
startup = bool(row[16])
|
|
# MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT, excluding external and RTC RAM.
|
|
if caps & 0x804 != 0x804 or caps & (0x400 | 0x8000):
|
|
continue
|
|
# New chips read a ROM reservation table at boot. Exclude their entire
|
|
# late-reclaimed ROM-stack region so unknown silicon reservations can
|
|
# never inflate the budget. Classic ESP32 has fixed linked reservations.
|
|
if startup and mcu != "esp32":
|
|
continue
|
|
regions.append((start, start + length, kind))
|
|
start = elf.address("soc_reserved_memory_region_start")
|
|
end = elf.address("soc_reserved_memory_region_end")
|
|
if end < start or (end - start) % 8 or end - start > 8192:
|
|
raise ValueError("invalid ESP-IDF reservation table")
|
|
reservations = list(struct.iter_unpack("<2I", elf.read(start, end - start)))
|
|
return subtract_regions(regions, reservations)
|
|
|
|
|
|
def heap_regions(elf, platform, mcu):
|
|
if platform == "ESP32_PLATFORM":
|
|
return esp32_heap_regions(elf, mcu)
|
|
if platform == "NRF52_PLATFORM":
|
|
start, end = elf.address("__HeapBase"), elf.address("__HeapLimit")
|
|
elif platform == "RP2040_PLATFORM":
|
|
start, end = elf.address("__end__"), elf.address("__HeapLimit")
|
|
elif platform == "STM32_PLATFORM":
|
|
start = elf.address("_end")
|
|
end = elf.address("_estack") - elf.address("_Min_Stack_Size")
|
|
else:
|
|
raise ValueError(f"unsupported platform {platform}")
|
|
if not 0x20000000 <= start < end <= 0x30000000:
|
|
raise ValueError("invalid ARM runtime heap boundaries")
|
|
return [(start, end, 0)]
|
|
|
|
|
|
def check_firmware(elf_path, platform, mcu, defines, target, output=None):
|
|
elf = FirmwareElf(elf_path)
|
|
policy = requirements(platform, defines, target)
|
|
regions = heap_regions(elf, platform, mcu)
|
|
available = sum(end - start for start, end, _ in regions)
|
|
largest = max((end - start for start, end, _ in regions), default=0)
|
|
passed = available >= policy["required_heap_bytes"] and largest >= policy["required_contiguous_bytes"]
|
|
report = {"schema_version": 1, "target": target, "platform": platform, "mcu": mcu,
|
|
"elf_sha256": hashlib.sha256(elf.data).hexdigest(), "passed": passed,
|
|
"available_internal_bytes": available, "largest_internal_region_bytes": largest,
|
|
**policy, "regions": [{"start": start, "end": end} for start, end, _ in regions],
|
|
"scope": "Linked capacity before runtime allocation; physical boot/load/soak validation remains required."}
|
|
if output:
|
|
Path(output).write_text(json.dumps(report, indent=2) + "\n")
|
|
print(f"Runtime RAM: {available:,} internal bytes available; {policy['required_heap_bytes']:,} required; "
|
|
f"largest region {largest:,}; {'PASS' if passed else 'FAIL'}")
|
|
if not passed:
|
|
print("Runtime RAM check failed: insufficient heap for enabled features. "
|
|
"Share cold buffers or reduce allocations before publishing this build.", file=sys.stderr)
|
|
return 0 if passed else 1
|
|
|
|
|
|
def register_platformio(env):
|
|
definitions = {}
|
|
for item in env.get("CPPDEFINES", []):
|
|
if isinstance(item, (tuple, list)):
|
|
definitions[str(item[0])] = item[1] if len(item) > 1 else 1
|
|
else:
|
|
definitions[str(item)] = 1
|
|
platforms = SUPPORTED.intersection(definitions)
|
|
if not platforms:
|
|
if env.subst("$PIOENV").startswith("native"):
|
|
return
|
|
raise ValueError("firmware build has no recognized RAM policy platform")
|
|
if len(platforms) != 1:
|
|
raise ValueError("firmware build has conflicting platform definitions")
|
|
platform = next(iter(platforms))
|
|
mcu = str(env.BoardConfig().get("build.mcu", "")).lower()
|
|
target = env.subst("$PIOENV")
|
|
def check(source, target, env):
|
|
path = Path(env.subst("$BUILD_DIR/${PROGNAME}.elf"))
|
|
output = Path(env.subst("$BUILD_DIR/${PROGNAME}.memory.json"))
|
|
try:
|
|
result = check_firmware(path, platform, mcu, definitions,
|
|
env.subst("$PIOENV"), output)
|
|
return result
|
|
except (OSError, ValueError, KeyError, struct.error) as error:
|
|
print(f"Runtime RAM check failed: {error}", file=sys.stderr)
|
|
return 2
|
|
|
|
for alias in ("checkprogsize", "mergebin", "upload"):
|
|
env.AddPreAction(env.Alias(alias), check)
|
|
for suffix in ("bin", "hex", "uf2", "zip"):
|
|
env.AddPreAction("$BUILD_DIR/${PROGNAME}." + suffix, check)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("elf", type=Path)
|
|
parser.add_argument("--platform", required=True, choices=sorted(SUPPORTED))
|
|
parser.add_argument("--mcu", required=True)
|
|
parser.add_argument("--target", required=True)
|
|
parser.add_argument("--defines", type=Path, help="JSON object of resolved build definitions")
|
|
parser.add_argument("--output", type=Path)
|
|
args = parser.parse_args()
|
|
try:
|
|
defines = json.loads(args.defines.read_text()) if args.defines else {}
|
|
return check_firmware(args.elf, args.platform, args.mcu, defines, args.target, args.output)
|
|
except (OSError, ValueError, KeyError, struct.error) as error:
|
|
print(f"Runtime RAM check failed: {error}", file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
try:
|
|
Import("env") # noqa: F821 -- PlatformIO/SCons
|
|
except NameError:
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|
|
else:
|
|
register_platformio(env) # noqa: F821
|