diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 524c65d8..b01bc880 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -41,8 +41,10 @@ jobs: - name: Verify nRF52 UF2-reset CLI coverage run: python3 -B test/test_nrf52_uf2reset_cli.py - - name: Verify T096 Full memory and nRF52 Bluetooth startup + - name: Verify firmware memory budgets and nRF52 Bluetooth startup run: | + python3 -B test/test_firmware_ram.py + python3 -B test/test_cascade_release_package.py python3 -B test/test_t096_full_memory.py python3 -B test/test_nrf52_ble_startup.py python3 -B test/test_shared_mota_queue.py diff --git a/MEMORY_MONITORING.md b/MEMORY_MONITORING.md index ffc288c6..62ef8390 100644 --- a/MEMORY_MONITORING.md +++ b/MEMORY_MONITORING.md @@ -1,5 +1,10 @@ # MeshCore memory monitoring +Every firmware build also has a [runtime RAM capacity check](docs/firmware_memory_budget.md) +before packaging. Its `.memory.json` report describes linked capacity before +startup allocation. Continue to use the runtime readings below for load and +soak testing; the two measurements answer different questions. + MeshCore exposes current allocator information through the CLI. On an ESP32 build, run `memory` over a supported local or administrator CLI transport: diff --git a/build.sh b/build.sh index 06279afb..c63e7daa 100755 --- a/build.sh +++ b/build.sh @@ -3504,19 +3504,18 @@ apply_companion_radio_full_profile() { append_platformio_build_unflags "-UOTA_FOLDER_SERIAL" export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DOTA_FOLDER_SERIAL=1 -DCOMPANION_FEATURE_USB_MOTA_SOURCE=1 -DCOMPANION_FEATURE_BLE_MOTA_SOURCE=1 -DCOMPANION_FEATURE_DEDICATED_USB_LOGGING=1 -DCFG_TUD_CDC=2 -DMESH_DUAL_CDC_LOGGING=1 -DMESH_DEBUG=1 -DMESH_PACKET_LOGGING=1" - case "${env_name,,}" in - heltec_t096_companion_radio_full*) - # The TFT allocates 25 KiB after linking. Together with the loop, - # callback and BLE task stacks, packet pool, filesystems and message - # previews, this exhausts the 1.17.1.5 image's ~53 KiB heap. Keep all - # 256 offline slots normally; lend the upper 128 to the mOTA context - # only while needed. Reserve 72 KiB for runtime allocations at link. - append_platformio_build_unflags "-DOFFLINE_QUEUE_SIZE=512 -DOFFLINE_QUEUE_SIZE=128" - export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DOFFLINE_QUEUE_SIZE=256 -DOTA_SHARED_COMPANION_QUEUE=1 -Wl,--defsym=__mesh_nrf52_min_heap_size=73728" - record_build_reduction \ - "T096 Full: 256 offline frames normally; 128 while mOTA borrows queue storage" + # Every nRF52 Full Companion lends the upper half of its offline queue + # to the cold mOTA context. Keep 256 slots for everyday use and reserve + # runtime space for Bluetooth, displays, tasks and filesystem buffers. + append_platformio_build_unflags "-DOFFLINE_QUEUE_SIZE=512 -DOFFLINE_QUEUE_SIZE=128 -DOFFLINE_QUEUE_SIZE=16" + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DOFFLINE_QUEUE_SIZE=256 -DOTA_SHARED_COMPANION_QUEUE=1" + case "$env_name" in + Heltec_t096_companion_radio_full_*) + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -Wl,--defsym=__mesh_nrf52_min_heap_size=73728" ;; esac + record_build_reduction \ + "nRF52 Full: 256 offline frames normally; 128 while mOTA borrows queue storage" if ! pio_env_option_contains "$pio_env_name" build_src_filter "helpers/ota/*.cpp"; then append_platformio_build_src_filter "+" @@ -3609,6 +3608,20 @@ apply_companion_radio_full_profile() { # measured-safe tables for FULL OTA without changing ordinary USB/BLE/WiFi # companion builds. case "${env_name,,}" in + generic_espnow_companion_radio_full|\ + heltec_wireless_paper_companion_radio_full|\ + heltec_wireless_tracker_companion_radio_full|\ + heltec_ct62_companion_radio_full|\ + heltec_v3_companion_radio_full|\ + xiao_c3_companion_radio_full|\ + heltec_tracker_v2_companion_radio_full_*) + # Published 1.17.1.5 images failed the runtime heap budget with 350 + # contacts. Preserve the 256-frame queue and simultaneous transports. + append_platformio_build_unflags "-DMAX_CONTACTS=350" + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DMAX_CONTACTS=150" + record_build_reduction \ + "companion.capacity limited to 150 contacts for runtime RAM; 256 queued frames and all Full transports retained" + ;; meshadventurer_sx1262_companion_radio_full|\ meshadventurer_sx1268_companion_radio_full) append_platformio_build_unflags "-DMAX_CONTACTS=160 -DMAX_GROUP_CHANNELS=40 -DOFFLINE_QUEUE_SIZE=128" @@ -3840,6 +3853,8 @@ build_artifacts_exist() { local firmware_filename=$2 output_artifact_exists "${firmware_filename}.capabilities.json" || return 1 + python3 scripts/firmware_memory_manifest.py validate-package \ + "${OUTPUT_DIR}/${firmware_filename}" >/dev/null 2>&1 || return 1 grep -q '"verified": true' \ "${OUTPUT_DIR}/${firmware_filename}.capabilities.json" || return 1 if [ "${REQUIRE_OTA_UPDATES:-0}" = "1" ]; then @@ -3882,6 +3897,9 @@ collect_build_artifacts() { local env_platform=$2 local pio_env_name=$3 local firmware_filename=$4 + local build_output_dir="${PIO_BUILD_DIR_OVERRIDE:-${PLATFORMIO_BUILD_DIR:-.pio/build}}/${pio_env_name}" + + python3 scripts/firmware_memory_manifest.py validate-build "$build_output_dir" || return $? # Qualify the linked image before copying anything into out/. A failed # capability contract must not leave an apparently publishable firmware @@ -3911,6 +3929,8 @@ collect_build_artifacts() { ;; esac + python3 scripts/firmware_memory_manifest.py package "$build_output_dir" \ + --stem "${OUTPUT_DIR}/${firmware_filename}" || return $? } get_firmware_filename() { @@ -4149,6 +4169,13 @@ build_firmware() { append_platformio_extra_script "post:scripts/check_esp32_dram.py" fi + # Variant-specific extra_scripts can replace their platform base. Every + # release path, including Option 3 and direct target builds, must be gated. + if ! pio_env_option_contains "$pio_env_name" extra_scripts \ + "scripts/check_firmware_ram.py"; then + append_platformio_extra_script "post:scripts/check_firmware_ram.py" + fi + print_build_flags "$pio_env_name" "$env_name" build_status=0 if [ "$env_platform" = "ESP32_PLATFORM" ]; then diff --git a/docs/companion_offline_queue.md b/docs/companion_offline_queue.md index 436d59bc..e003a0d1 100644 --- a/docs/companion_offline_queue.md +++ b/docs/companion_offline_queue.md @@ -12,7 +12,7 @@ A reboot clears it. | ESP32 with configured PSRAM | 512 | | ESP32 without PSRAM | 256 | | nRF52840 | 256 | -| T096 Full Companion with the memory correction | 256 normally; 128 while mOTA owns shared storage | +| nRF52 Full Companion with the memory correction | 256 normally; 128 while mOTA owns shared storage | | RP2040 | 256 | | STM32 | 16 | | Known constrained classic ESP32 target override | 128 | @@ -26,8 +26,9 @@ Meshadventurer SX1262 and SX1268 Full Companion use 16 frames together with 100 contacts and 30 group channels; their ordinary transport-specific images keep 128 frames and 40 channels. -The [T096 Full memory correction](releases/1.17.1.5.md#t096-full-companion-bluetooth-and-menu-freeze-report) -keeps 256 frames normally, while retaining 350 contacts, 40 channels, and all +The [nRF52 Full memory correction](releases/1.17.1.5.md#t096-full-companion-bluetooth-and-menu-freeze-report) +applies queue sharing to every nRF52 Full Companion. It keeps 256 frames +normally, retaining each board's contacts, channels, and all Full transports. The upper 128 slots temporarily hold the mOTA context when a source or TempRadio discovery session starts. Stopping or disconnecting the USB/Bluetooth source returns all 256 slots; a discovery-only session returns @@ -37,10 +38,10 @@ fragmentation from resizing. Existing unread messages retain their order. If more than 128 frames are pending, mOTA refuses the loan and asks you to sync messages with a Companion app first. While the loan is active, the overflow policy below applies at 128 -frames. The original `26303793` 1.17.1.5 download reserves the queue and mOTA -state separately, leaving too little runtime headroom for its color display -and Bluetooth. The corrected profile recovers about 19 KiB by sharing storage -and requires at least 72 KiB of heap space at link time. +frames. The original `26303793` 1.17.1.5 builds reserve the queue and mOTA state +separately. Sharing recovers about 19 KiB on nRF52 Full. The ST7735 color-display +Full profiles require at least 72 KiB of heap space at link time; OLED and +headless profiles use their own [memory budgets](firmware_memory_budget.md). Standard, logging, MQTT, and Cascade build overlays retain the selected target capacity; they do not silently shrink the queue. diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index c94772ef..3dfc8dc2 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -323,6 +323,12 @@ These capacities preserve the required 8 KiB static internal-DRAM reserve alongside MOTA and all Full transports; their ordinary transport-specific images retain 160 contacts, 40 channels, and 128 queued frames. +The 1.17.1.5 memory replacements for Generic ESP-NOW, Heltec Wireless Paper, +Wireless Tracker, CT62, V3, Tracker V2, and XIAO C3 Full Companion use **150 +contacts** and retain their **256-frame queue** and Full transports. Export +contacts before updating if you have more than 150; entries beyond the new +limit may be unavailable and a later save may omit them. + Full Companions normally retain 256 pending Companion message frames. ESP32 boards with configured PSRAM retain 512 and allocate that queue from PSRAM before WiFi and BLE start. If PSRAM is unavailable at runtime, allocation falls @@ -333,11 +339,10 @@ direct messages; it is not flash-backed history. See [Companion offline message queue](./companion_offline_queue.md) for all platform defaults and full-queue behavior. -The corrected T096 Full profile keeps **256 offline frames normally** and +Every corrected nRF52 Full profile keeps **256 offline frames normally** and temporarily lends 128 slots to mOTA to leave room for -its color framebuffer, Bluetooth tasks, and UI allocations. It retains 350 -contacts, 40 channels, and USB/Bluetooth mOTA sending. The published 1.17.1.5 -`26303793` image predates this [memory correction](releases/1.17.1.5.md#t096-full-companion-bluetooth-and-menu-freeze-report). +Bluetooth tasks, displays and UI allocations. Queue sharing preserves the +board's contacts, channels, and USB/Bluetooth mOTA sending. See the [memory correction](releases/1.17.1.5.md#t096-full-companion-bluetooth-and-menu-freeze-report). The nRF52 target inherits the board's ordinary USB Companion installation format and adds BLE plus the serial mOTA source. It does not enable an SD cache diff --git a/docs/firmware_memory_budget.md b/docs/firmware_memory_budget.md new file mode 100644 index 00000000..3c2d0fa7 --- /dev/null +++ b/docs/firmware_memory_budget.md @@ -0,0 +1,81 @@ +# Firmware memory checks + +Every firmware environment runs `scripts/check_firmware_ram.py` against its +linked ELF before producing or uploading an image. `build.sh`, including +option 3, also requires a passing report before collecting release files. +Native host tests do not use a microcontroller RAM budget. + +The check reserves room for enabled runtime allocations as well as static +data. A firmware image fitting its board's reported RAM total is insufficient: +the display, packet pool, USB, Bluetooth workers and WiFi can allocate after +startup. The T096 Full 1.17.1.5 report exposed this distinction. + +## What is counted + +| Platform | Source of available runtime RAM | +| --- | --- | +| nRF52 | Actual `__HeapBase` and `__HeapLimit`; excludes SoftDevice, retained state, ISR stack and the dedicated 64 KiB mOTA arena where present | +| ESP32, S3, C3, C6 | Linked ESP-IDF memory-region, capability and reservation tables; only internal, byte-addressable heap counts | +| RP2040/RP2350 | `__end__` to `__HeapLimit`, according to the selected linker | +| STM32 | `_end` to `_estack`, minus `_Min_Stack_Size` | + +ESP32 PSRAM, instruction-only RAM and RTC RAM never increase the internal +budget. On chips other than classic ESP32, the late-reclaimed ROM stack region +is excluded because its silicon-specific reservations are only known at boot. +Classic ESP32 additionally retains its existing 8 KiB **static DRAM** check. + +The policy adds allowances for task stacks, radio packet pools, screen objects +and pixel buffers, filesystem/sensor allocations, enabled wireless stacks, +MQTT connections, OTA scratch and transient allocations. A 160×80 ST7735 +framebuffer needs 25,602 bytes; an OLED allowance is 4 KiB. nRF52 Full with that +color framebuffer must have at least 72 KiB available before startup allocations. +Headless and OLED devices use their own smaller totals. The JSON lists each +component and checks the largest available region against the largest planned +single allocation. + +These are engineering allowances for supported configurations, not measured +free heap after boot or a guarantee against every future allocation failure. +Unknown platforms, unknown display drivers and missing linker metadata fail +closed. `MESH_MIN_RUNTIME_HEAP` can raise a profile's requirement; it cannot +lower the calculated requirement. Add an allocation allowance when adding a +display, transport or other substantial feature. + +## Release evidence and regression tests + +Each newly built firmware has a matching `.memory.json` report. It records +the linked ELF SHA-256, available internal RAM, required RAM, largest region, +and SHA-256 hashes for the actual firmware files and capability manifest. +Packaging and resumed builds reject absent reports, failures, stale ELFs, +missing files and changed firmware. Do not reuse a report for another build. + +Run PlatformIO commands sequentially in this checkout: + +```sh +python3 -B test/test_firmware_ram.py +python3 -B test/test_t096_full_memory.py +python3 -B test/test_nrf52_ble_startup.py +python3 -B test/test_shared_mota_queue.py +python3 -B test/test_cascade_release_package.py +pio test -e native -f test_ota +``` + +Tests cover all resolved firmware environments' hooks, real ELF parsing, +allocator table formats, excluded memory, allocation failure, package/report +binding and the published T096 failing budget. Shared mOTA tests exercise +complete transfers, queue wraparound, unread-message order, source ownership, +stop/disconnect and repeated reuse. Bluetooth tests inject task and service +startup failures. The manual staging buffer also has allocation-failure and +repeated release tests. + +For older releases without saved ELFs, an audit can compare their ESP allocator +tables against a matching pinned SDK ELF and read reservations from the +**published application itself** using `scripts/audit_esp32_image_ram.py`. +Unrecognized layouts require another matching reference or a historical rebuild. +An audit must identify original-log/linker calculations separately from new +ELF checks and verify the published firmware hashes. + +Physical validation remains necessary: boot with and without USB, pair and +exchange Bluetooth messages, visit every screen, wake with the button, enable +logging/MQTT, transfer mOTA, and monitor heap during a sustained workload. +See [memory monitoring](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MEMORY_MONITORING.md) +for runtime diagnostics. diff --git a/docs/releases/1.17.1.5.md b/docs/releases/1.17.1.5.md index 98bce82c..5a4355b0 100644 --- a/docs/releases/1.17.1.5.md +++ b/docs/releases/1.17.1.5.md @@ -103,7 +103,8 @@ and the sensor menu could write through a failed CayenneLPP allocation. Those are code paths consistent with this report; a PIN is not proof of a working Bluetooth radio. -The source correction keeps the T096 **Full** offline queue at **256 frames +The correction applies to **every nRF52 Full Companion**, keeping its offline +queue at **256 frames during normal use**. mOTA borrows the upper 128 slots for its roughly 19 KiB session state only when needed, leaving 128 message slots during mOTA use. Starting a source or a TempRadio discovery session acquires this workspace. @@ -117,7 +118,7 @@ messages with a Companion app first**; it does not discard them to make room. OTA configuration and signer keys survive reuse of the workspace. While mOTA owns it, the normal queue overflow policy applies to the remaining 128 slots. -The profile requires at least 72 KiB of heap space at link time. It retains +The T096 profile requires at least 72 KiB of heap space at link time. It retains 350 contacts, 40 channels, the color display, sensors, USB, Bluetooth, Bluetooth DFU, and USB/Bluetooth mOTA sending. This reserve is space for runtime allocations, not a measurement of free heap after boot. diff --git a/examples/companion_radio/CompanionFeatures.h b/examples/companion_radio/CompanionFeatures.h index 73709232..3f08cf98 100644 --- a/examples/companion_radio/CompanionFeatures.h +++ b/examples/companion_radio/CompanionFeatures.h @@ -1,4 +1,5 @@ #pragma once +#include // Features are selected independently so adding a transport or changing an // image-size profile cannot accidentally remove unrelated Companion behavior. diff --git a/platformio.ini b/platformio.ini index 9a3378e2..6375a326 100644 --- a/platformio.ini +++ b/platformio.ini @@ -77,6 +77,7 @@ extra_scripts = merge-bin.py post:tools/mota/pio_endf.py post:scripts/check_esp32_dram.py + post:scripts/check_firmware_ram.py build_flags = ${arduino_base.build_flags} -D ESP32_PLATFORM -D ENABLE_OTA=1 @@ -137,6 +138,7 @@ platform_packages = platformio/toolchain-gccarmnoneeabi@^1.140201.0 extra_scripts = create-uf2.py pre:scripts/nrf52_internal_bootloader_link.py + post:scripts/check_firmware_ram.py build_flags = ${arduino_base.build_flags} -D NRF52_PLATFORM -D LFS_NO_ASSERT=1 @@ -231,6 +233,7 @@ lib_deps = ${nrf52_lora_ota.lib_deps} [rp2040_base] extends = arduino_base +extra_scripts = post:scripts/check_firmware_ram.py upload_protocol = picotool board_build.core = earlephilhower platform = https://github.com/maxgerhardt/platform-raspberrypi.git ; framework-arduinopico @ 1.50600.0+sha.6a1d13e9 @@ -247,6 +250,7 @@ lib_ignore = iLabs Hearth extends = arduino_base platform = ststm32 extra_scripts = post:arch/stm32/build_hex.py + post:scripts/check_firmware_ram.py build_flags = ${arduino_base.build_flags} -D STM32_PLATFORM -D ED25519_COMPACT_BASE=1 diff --git a/scripts/audit_esp32_image_ram.py b/scripts/audit_esp32_image_ram.py new file mode 100644 index 00000000..7bedd681 --- /dev/null +++ b/scripts/audit_esp32_image_ram.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Read an older ESP image's allocator tables using a matching SDK ELF layout. + +For release audits when the original ELF was not retained. The immutable region +and capability tables must match the reference ELF exactly (only relocated type +name pointers differ). Reservations are read from the image being audited, never +copied from the reference. New builds always use check_firmware_ram.py directly. +""" + +from pathlib import Path +import re +import struct + +from firmware_elf import FirmwareElf +from check_firmware_ram import esp32_heap_regions, subtract_regions + +CHIPS = {0: "esp32", 5: "esp32c3", 9: "esp32s3", 13: "esp32c6"} +MEMORY_WINDOWS = { + "esp32": [(0x3F800000, 0x40000000), (0x40070000, 0x400A0000), (0x50000000, 0x50002000)], + "esp32s3": [(0x3C000000, 0x3E000000), (0x3FC80000, 0x3FD00000), + (0x40370000, 0x403E0000), (0x600FE000, 0x60100000)], + "esp32c3": [(0x3FC80000, 0x3FCE0000), (0x40380000, 0x403E0000), (0x50000000, 0x50002000)], + "esp32c6": [(0x40800000, 0x40880000), (0x50000000, 0x50004000)], +} + + +class EspImage: + def __init__(self, path): + self.data = Path(path).read_bytes() + if len(self.data) < 24 or self.data[0] != 0xE9 or not 1 <= self.data[1] <= 16: + raise ValueError("expected an unmerged ESP application image") + self.mcu = CHIPS.get(struct.unpack_from(" len(self.data): + raise ValueError("truncated ESP segment header") + address, size = struct.unpack_from("<2I", self.data, offset) + offset += 8 + if offset + size > len(self.data) or address + size > 0x100000000: + raise ValueError("truncated ESP segment") + if address and size: + self.segments.append((address, self.data[offset:offset + size])) + offset += size + self.symbols = {} + + def read(self, address, size): + for start, data in self.segments: + if start <= address and address + size <= start + len(data): + return data[address - start:address - start + size] + raise ValueError(f"ESP image has no initialized data at {address:#x}") + + def address(self, name): + return self.symbols[name][0] + + def words(self, name, count=1): + if name == "soc_memory_region_count": + return (self.region_count,) + return struct.unpack("<" + "I" * count, self.read(self.address(name), count * 4)) + + def find_unique(self, pattern): + matches = [start + match.start() for start, data in self.segments + for match in re.finditer(pattern, data, re.DOTALL)] + if len(matches) != 1: + raise ValueError(f"allocator signature matched {len(matches)} times; matching ELF/rebuild required") + return matches[0] + + def load_layout(self, reference): + self.region_count = reference.words("soc_memory_region_count")[0] + regions_size = reference.symbols["soc_memory_regions"][1] + regions = reference.read(reference.address("soc_memory_regions"), regions_size) + address = self.find_unique(re.escape(regions)) + self.symbols["soc_memory_regions"] = (address, regions_size, 1) + region_stride = regions_size // self.region_count + type_stride = {16: 20, 20: 16}.get(region_stride) + if type_stride is None: + raise ValueError("unsupported allocator ABI") + types_size = reference.symbols["soc_memory_types"][1] + types = reference.read(reference.address("soc_memory_types"), types_size) + pattern = b"".join(b".{4}" + re.escape(types[i + 4:i + type_stride]) + for i in range(0, types_size, type_stride)) + address = self.find_unique(pattern) + self.symbols["soc_memory_types"] = (address, types_size, 1) + # First reservation is the fixed RTC noinit guard in these pinned SDKs. + # Its unique bytes locate the linked reservation section even on RISC-V, + # where exception/unwind data follows it in the same image segment. + prefix = reference.read(reference.address("soc_reserved_memory_region_start"), 8) + start = self.find_unique(re.escape(prefix)) + end = start + while end - start < 1024: + try: + lower, upper = struct.unpack("<2I", self.read(end, 8)) + except ValueError: + break + if not any(a <= lower <= upper <= b for a, b in MEMORY_WINDOWS[self.mcu]): + break + end += 8 + if end - start < 24 or end - start >= 1024: + raise ValueError("invalid allocator reservation section") + self.symbols["soc_reserved_memory_region_start"] = (start, 0, 1) + self.symbols["soc_reserved_memory_region_end"] = (end, 0, 1) + # These reservations must cover every initialized internal RAM segment. + # This catches a partial/incorrectly located reservation section. + reserved = list(struct.iter_unpack("<2I", self.read(start, end - start))) + for address, data in self.segments: + dram_window = {"esp32": (0x3FFAE000, 0x40000000), + "esp32s3": (0x3FC80000, 0x3FD00000), + "esp32c3": (0x3FC80000, 0x3FCE0000), + "esp32c6": (0x40800000, 0x40880000)}[self.mcu] + internal = dram_window[0] <= address < dram_window[1] + external = (self.mcu == "esp32" and 0x3F800000 <= address < 0x3FC00000 + or self.mcu == "esp32s3" and 0x3D000000 <= address < 0x3E000000) + if internal and not external and subtract_regions([(address, address + len(data), 0)], [(a, b + 16) for a, b in reserved]): + raise ValueError(f"reservation section does not cover loaded RAM at {address:#x}") + + +def image_heap_regions(image_path, reference_elf): + image = EspImage(image_path) + image.load_layout(FirmwareElf(reference_elf)) + return image.mcu, esp32_heap_regions(image, image.mcu) diff --git a/scripts/check_firmware_ram.py b/scripts/check_firmware_ram.py new file mode 100644 index 00000000..7ce941ec --- /dev/null +++ b/scripts/check_firmware_ram.py @@ -0,0 +1,268 @@ +#!/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 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 + if display and display != "NullDisplayDriver": + parts["display_pixels_and_driver"] = display_heap + parts["screen_objects_and_history"] = 8192 if companion else 2048 + 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) + 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 diff --git a/scripts/firmware_elf.py b/scripts/firmware_elf.py new file mode 100644 index 00000000..7817d331 --- /dev/null +++ b/scripts/firmware_elf.py @@ -0,0 +1,65 @@ +"""Small, dependency-free reader for the 32-bit little-endian firmware ELFs.""" + +from pathlib import Path +import struct + + +class FirmwareElf: + def __init__(self, path): + self.data = Path(path).read_bytes() + if self.data[:7] != b"\x7fELF\x01\x01\x01" or len(self.data) < 52: + raise ValueError("expected a 32-bit little-endian firmware ELF") + offset = self.unpack("I", 32)[0] + entry_size, count, names_index = self.unpack("HHH", 46) + if entry_size != 40 or not count or names_index >= count: + raise ValueError("missing or invalid ELF section table") + self.sections = [self.unpack("10I", offset + i * entry_size) for i in range(count)] + self.symbols = {} + for section in self.sections: + if section[1] != 2: # SHT_SYMTAB + continue + if section[9] != 16 or section[5] % 16 or section[6] >= count: + raise ValueError("invalid ELF symbol table") + strings = self.section_bytes(self.sections[section[6]]) + for pos in range(section[4], section[4] + section[5], 16): + name, value, size, info, _, index = self.unpack("IIIBBH", pos) + if not index or not name: + continue + if name >= len(strings): + raise ValueError("invalid ELF symbol name") + name = strings[name:].split(b"\0", 1)[0].decode("ascii") + # Prefer global/weak definitions to same-named local symbols. + if name not in self.symbols or info >> 4: + self.symbols[name] = (value, size, index) + if not self.symbols: + raise ValueError("firmware ELF has no defined symbols") + + def unpack(self, fmt, offset): + try: + return struct.unpack_from("<" + fmt, self.data, offset) + except struct.error as error: + raise ValueError("truncated firmware ELF") from error + + def section_bytes(self, section): + offset, size = section[4:6] + if offset + size > len(self.data): + raise ValueError("truncated ELF section") + return self.data[offset:offset + size] + + def address(self, name): + if name not in self.symbols: + raise ValueError(f"missing ELF symbol {name}") + return self.symbols[name][0] + + def read(self, address, size): + for section in self.sections: + if section[1] == 8 or not section[2] & 2: # NOBITS / not allocated + continue + start, length = section[3], section[5] + if start <= address and address + size <= start + length: + offset = address - start + return self.section_bytes(section)[offset:offset + size] + raise ValueError(f"ELF has no initialized data at {address:#x} ({size} bytes)") + + def words(self, name, count=1): + return struct.unpack("<" + "I" * count, self.read(self.address(name), count * 4)) diff --git a/scripts/firmware_memory_manifest.py b/scripts/firmware_memory_manifest.py new file mode 100644 index 00000000..fe145f29 --- /dev/null +++ b/scripts/firmware_memory_manifest.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Keep a passing RAM check attached to exactly the firmware it qualified.""" + +import argparse +import hashlib +import json +from pathlib import Path +import re + + +def digest(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def passing_report(path): + report = json.loads(Path(path).read_text()) + fields = ("available_internal_bytes", "required_heap_bytes", + "largest_internal_region_bytes", "required_contiguous_bytes") + if not all(type(report.get(key)) is int and report[key] > 0 for key in fields): + raise ValueError("incomplete runtime RAM qualification") + if (report.get("schema_version") != 1 or report.get("passed") is not True + or report["available_internal_bytes"] < report["required_heap_bytes"] + or report["largest_internal_region_bytes"] < report["required_contiguous_bytes"] + or not re.fullmatch(r"[0-9a-f]{64}", str(report.get("elf_sha256", "")))): + raise ValueError("missing or failing runtime RAM qualification") + return report + + +def validate_build(directory): + directory = Path(directory) + report = passing_report(directory / "firmware.memory.json") + if report["elf_sha256"] != digest(directory / "firmware.elf"): + raise ValueError("runtime RAM report belongs to a different ELF") + return report + + +def artifact_files(stem): + stem = Path(stem) + candidates = [stem.parent / (stem.name + suffix) + for suffix in (".bin", "-merged.bin", ".uf2", ".zip", ".hex", ".capabilities.json")] + return [p for p in candidates if p.is_file()] + + +def package_report(directory, stem): + stem = Path(stem) + report = validate_build(directory) + files = artifact_files(stem) + if len(files) < 2 or not any(p.suffix != ".json" for p in files): + raise ValueError("cannot qualify an empty firmware package") + manifest = json.loads((stem.parent / (stem.name + ".capabilities.json")).read_text()) + report.update(target=manifest["target"], artifact_target=manifest["artifact_target"], + pio_environment=report["target"], + files={p.name: digest(p) for p in files}) + path = stem.parent / (stem.name + ".memory.json") + path.write_text(json.dumps(report, indent=2) + "\n") + + +def validate_package(stem): + stem = Path(stem) + report = passing_report(stem.parent / (stem.name + ".memory.json")) + actual = {p.name: digest(p) for p in artifact_files(stem)} + if len(actual) < 2 or actual != report.get("files"): + raise ValueError("firmware package changed after its runtime RAM check") + return report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=("validate-build", "package", "validate-package")) + parser.add_argument("path", type=Path) + parser.add_argument("--stem", type=Path) + args = parser.parse_args() + try: + if args.action == "validate-build": + validate_build(args.path) + elif args.action == "validate-package": + validate_package(args.path) + elif args.stem: + package_report(args.path, args.stem) + else: + parser.error("package requires --stem") + except (OSError, ValueError, KeyError) as error: + parser.exit(1, f"Runtime RAM qualification failed: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/package_cascade_release.py b/scripts/package_cascade_release.py index 20454b67..59fd8c04 100644 --- a/scripts/package_cascade_release.py +++ b/scripts/package_cascade_release.py @@ -10,8 +10,12 @@ import json from pathlib import Path import re import shutil +import sys from urllib.parse import quote, urljoin +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from firmware_memory_manifest import validate_package + ROOT = Path(__file__).resolve().parents[1] FIRMWARE_SUFFIXES = {".bin", ".uf2", ".hex", ".zip"} @@ -62,8 +66,11 @@ def collect_artifacts(directory, version): raise ValueError(f"{stem}: nRF52 UF2/DFU artifacts incomplete") if any(item.stat().st_size == 0 for item in files): raise ValueError(f"{stem}: empty firmware artifact") + memory = validate_package(directory / stem) + manifest["runtime_ram"] = {key: memory[key] for key in ( + "passed", "available_internal_bytes", "required_heap_bytes", "elf_sha256")} accounted.update(files) - records.append({"manifest": manifest, "files": sorted(files) + [path]}) + records.append({"manifest": manifest, "files": sorted(files) + [path, directory / (stem + ".memory.json")]}) unaccounted = {path for path in directory.iterdir() if path.suffix in FIRMWARE_SUFFIXES} - accounted if unaccounted: raise ValueError("firmware without qualification: " + ", ".join(sorted(p.name for p in unaccounted))) diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 9a5c8ef1..5500ecb6 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -673,7 +673,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board const bool discarded = !was_folder && !was_sd_archive && c.fetch_store.discard(); // Fetch cancellation and serving are independent. In particular, a - // manual ESP32 serve view can point into serve_buf, so leave the manager + // manual serve view can point into serve_buf, so leave the manager // view and its caller-owned buffer intact until `ota dev clear` (which // detaches the view before releasing the buffer). c.session_started_ms = 0; diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index 05ecd205..a4a02529 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -105,10 +105,10 @@ struct OtaContext { OtaStoreRam fetch_store; #endif SignerAllowlist allow; -#if defined(ESP32_PLATFORM) - // Manual `ota dev stage` is a diagnostic path. Reserving its full buffer in - // .bss prevents high-capacity classic ESP32 images from linking, even when - // the command is never used, so allocate it only while a manual stage exists. +#if defined(ESP32_PLATFORM) || (defined(NRF52_PLATFORM) && !defined(OTA_SEEDER_ONLY)) + // Manual `ota dev stage` is a diagnostic path. Keep its buffer out of + // startup RAM on ESP32 and nRF52 receivers; normal mOTA uses the staging + // store. A failed allocation is reported by the CLI before any write. uint8_t* serve_buf = nullptr; bool ensureServeBuffer() { if (!serve_buf) serve_buf = static_cast(malloc(OTA_SERVE_BUF_SIZE)); diff --git a/src/helpers/ota/OtaManager.h b/src/helpers/ota/OtaManager.h index 577f1181..fbd6c962 100644 --- a/src/helpers/ota/OtaManager.h +++ b/src/helpers/ota/OtaManager.h @@ -3,6 +3,7 @@ #include #include #include "OtaFormat.h" +#include "OtaMemoryPolicy.h" #include "OtaProtocol.h" #include "OtaByteIO.h" #include "OtaStore.h" diff --git a/src/helpers/ota/OtaMemoryPolicy.h b/src/helpers/ota/OtaMemoryPolicy.h new file mode 100644 index 00000000..95c64dd2 --- /dev/null +++ b/src/helpers/ota/OtaMemoryPolicy.h @@ -0,0 +1,9 @@ +#pragma once + +// Full nRF52 Companions stream host-provided firmware and need no permanent +// mOTA workspace. Apply the same policy to build.sh and direct PlatformIO builds. +#if defined(NRF52_PLATFORM) && defined(OTA_SEEDER_ONLY) && defined(COMPANION_RADIO_FULL) +#ifndef OTA_SHARED_COMPANION_QUEUE +#define OTA_SHARED_COMPANION_QUEUE 1 +#endif +#endif diff --git a/test/README.md b/test/README.md index da4ef67f..60ff1b7c 100644 --- a/test/README.md +++ b/test/README.md @@ -13,6 +13,7 @@ require integration or target testing; see "Local testing without hardware" in pio test -e native # all suites except KISS modem pio test -e native_kiss_modem # KISS modem suite pio test -e native -f test_webconfig_keys # a single suite +python3 test/test_firmware_ram.py # every firmware hook, ELF heaps, reservations, RAM report binding python3 test/test_indicator_display_profile.py # Indicator RAM/scale contract python3 test/test_indicator_render_profile.py # four-mode Indicator canvas matrix/fallback contract python3 test/test_indicator_exclusive_transport.py # Indicator secondary-transport ownership contract diff --git a/test/test_cascade_release_package.py b/test/test_cascade_release_package.py index 0d71082a..39d01d98 100644 --- a/test/test_cascade_release_package.py +++ b/test/test_cascade_release_package.py @@ -65,6 +65,11 @@ class ReleaseQualificationTest(unittest.TestCase): (inputs / (stem + ".capabilities.json")).write_text(json.dumps(manifest)) (inputs / (stem + ".bin")).write_bytes(b"test application") (inputs / (stem + "-merged.bin")).write_bytes(b"test merged image") + proof = dict(schema_version=1, passed=True, available_internal_bytes=80000, + required_heap_bytes=50000, largest_internal_region_bytes=80000, + required_contiguous_bytes=5120, elf_sha256="a" * 64, + files={p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in inputs.iterdir()}) + (inputs / (stem + ".memory.json")).write_text(json.dumps(proof)) status = directory / "status" settings = (f"exit_code=0\nworking_directory={directory}\noutput_directory=input\n" f"source_commit={commit}\nfirmware_version={label}\nfirmware_profile=cascade\n" diff --git a/test/test_firmware_ram.py b/test/test_firmware_ram.py new file mode 100644 index 00000000..341bcea9 --- /dev/null +++ b/test/test_firmware_ram.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""Exercise real ELF parsing, per-platform admission and release RAM proof binding.""" + +import contextlib +import io +import json +from pathlib import Path +import struct +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +import check_firmware_ram as ram +from firmware_elf import FirmwareElf +import firmware_memory_manifest as proof +from audit_esp32_image_ram import EspImage + + +def elf32(path, symbols, data=b"", address=0x3F400000): + """Minimal actual ELF32, including absolute linker symbols and loaded data.""" + names = bytearray(b"\0") + symtab = bytearray(16) + for name, (value, size) in symbols.items(): + offset = len(names) + names += name.encode() + b"\0" + symtab += struct.pack(" +#include +#include +bool allow = false; +int allocations = 0, releases = 0; +void* checked_malloc(size_t bytes) { + assert(bytes == 4096); + if (!allow) return nullptr; + ++allocations; + return std::malloc(bytes); +} +void checked_free(void* ptr) { if (ptr) ++releases; std::free(ptr); } +#define malloc checked_malloc +#define free checked_free +#define OTA_SERVE_BUF_SIZE 4096 +struct Context { +@BRANCH@ +}; +int main() { + Context context; + assert(context.serve_buf == nullptr && allocations == 0); + assert(!context.ensureServeBuffer() && context.serve_buf == nullptr); + for (int i = 0; i < 64; ++i) { + allow = true; + assert(context.ensureServeBuffer()); + auto* original = context.serve_buf; + assert(context.ensureServeBuffer() && context.serve_buf == original); + context.serve_buf[4095] = 1; + context.releaseServeBuffer(); + context.releaseServeBuffer(); + assert(context.serve_buf == nullptr && allocations == releases); + allow = false; + assert(!context.ensureServeBuffer()); + } +} +'''.replace("@BRANCH@", branch) + with tempfile.TemporaryDirectory() as temp: + for platform in ("NRF52_PLATFORM", "ESP32_PLATFORM"): + binary = Path(temp) / platform + subprocess.run(["c++", "-std=c++17", "-x", "c++", "-", "-D" + platform, + "-fsanitize=address,undefined", "-fno-pie", "-no-pie", "-o", str(binary)], + input=code, text=True, check=True) + subprocess.run([str(binary)], check=True) + + def test_t096_release_fails_and_exact_boundary_passes(self): + flags = {"COMPANION_RADIO_FULL": 1, "DISPLAY_CLASS": "ST7735Display", "BLE_PIN_CODE": 123456} + with tempfile.TemporaryDirectory() as temp, contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + path = Path(temp) / "firmware.elf" + for available, accepted in ((54724, False), (73727, False), (73728, True), (74060, True)): + with self.subTest(available=available): + elf32(path, {"__HeapBase": (0x2003F800 - available, 0), "__HeapLimit": (0x2003F800, 0)}) + result = ram.check_firmware(path, "NRF52_PLATFORM", "nrf52840", flags, + "Heltec_t096_companion_radio_full_femon") + self.assertEqual(result == 0, accepted) + + def test_arm_heap_excludes_stack_softdevice_and_mota_arena(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "firmware.elf" + elf = elf32(path, {"__HeapBase": (0x20020000, 0), "__HeapLimit": (0x2002F800, 0), + "__mota_ram_start__": (0x20030000, 0), "__mota_ram_end__": (0x20040000, 0)}) + self.assertEqual(ram.heap_regions(elf, "NRF52_PLATFORM", "nrf52840"), + [(0x20020000, 0x2002F800, 0)]) + for platform, symbols in ( + ("RP2040_PLATFORM", {"__end__": (0x20010000, 0), "__HeapLimit": (0x20040000, 0)}), + ("STM32_PLATFORM", {"_end": (0x20008000, 0), "_estack": (0x20010000, 0), "_Min_Stack_Size": (4096, 0)}), + ): + regions = ram.heap_regions(elf32(path, symbols), platform, "test") + self.assertEqual(regions[-1][1], 0x20040000 if platform.startswith("RP") else 0x2000F000) + for symbols in ({"__HeapBase": (0x20020000, 0)}, + {"__HeapBase": (0x20020000, 0), "__HeapLimit": (0x20010000, 0)}): + with self.assertRaises(ValueError): + ram.heap_regions(elf32(path, symbols), "NRF52_PLATFORM", "nrf52840") + + def test_idf4_idf5_ignore_psram_iram_rtc_and_reservations(self): + with tempfile.TemporaryDirectory() as temp: + for modern in (False, True): + elf = esp_fixture(Path(temp) / "firmware.elf", modern) + # Classic reclaims its ROM stack; other chips conservatively + # exclude that region until the hardware ROM table is known. + self.assertEqual(ram.esp32_heap_regions(elf, "esp32"), + [(0x3FFB4000, 0x3FFC0000, 0), (0x3FFC0000, 0x3FFD0000, 1)]) + self.assertEqual(ram.esp32_heap_regions(elf, "esp32s3"), + [(0x3FFB4000, 0x3FFC0000, 0)]) + + def test_fragmented_heap_does_not_pass_large_allocation(self): + with tempfile.TemporaryDirectory() as temp, contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + path = Path(temp) / "firmware.elf" + esp_fixture(path, modern=True, fragmented=True) + report = Path(temp) / "report.json" + ram.check_firmware(path, "ESP32_PLATFORM", "esp32s3", {}, "companion", report) + result = json.loads(report.read_text()) + self.assertEqual(result["available_internal_bytes"], 32768) + self.assertEqual(result["largest_internal_region_bytes"], 16384) + self.assertFalse(result["passed"]) + with self.assertRaisesRegex(ValueError, "overlapping"): + ram.subtract_regions([(0, 1024, 0), (512, 2048, 1)], []) + + def test_enabled_features_raise_budget_and_unknown_inputs_fail_closed(self): + base = ram.requirements("ESP32_PLATFORM", {}, "companion")["required_heap_bytes"] + flags = {"BLE_PIN_CODE": 1, "WIFI_SSID": "test", "WITH_MQTT_BRIDGE": 1} + both = ram.requirements("ESP32_PLATFORM", flags, "companion")["required_heap_bytes"] + self.assertEqual(both - base, 32768 + 49152 + 24576) + flags["COMPANION_EXCLUSIVE_WIFI_BLE"] = 1 + self.assertEqual(ram.requirements("ESP32_PLATFORM", flags, "companion")["required_heap_bytes"], both - 32768) + self.assertGreater(ram.requirements("NRF52_PLATFORM", {"DISPLAY_CLASS": "ST7735Display"}, "companion")["required_heap_bytes"], + ram.requirements("NRF52_PLATFORM", {"DISPLAY_CLASS": "SSD1306Display"}, "companion")["required_heap_bytes"]) + self.assertEqual(ram.requirements("ESP32_PLATFORM", {"MESH_MIN_RUNTIME_HEAP": 1}, "companion")["required_heap_bytes"], base) + for platform, definitions in (("NEW_PLATFORM", {}), ("NRF52_PLATFORM", {"DISPLAY_CLASS": "NewDisplay"}), + ("NRF52_PLATFORM", {"MESH_NRF52_LOOP_STACK_WORDS": "invalid"})): + with self.assertRaises(ValueError): + ram.requirements(platform, definitions, "companion") + + def test_nrf52_queue_sharing_applies_to_direct_full_builds_only(self): + source = '#include "src/helpers/ota/OtaMemoryPolicy.h"\n#ifdef OTA_SHARED_COMPANION_QUEUE\nSHARING_ENABLED\n#endif\n' + for flags, expected in ( + (["NRF52_PLATFORM", "COMPANION_RADIO_FULL", "OTA_SEEDER_ONLY"], True), + (["NRF52_PLATFORM", "OTA_SEEDER_ONLY"], False), + (["NRF52_PLATFORM", "COMPANION_RADIO_FULL"], False), + (["ESP32_PLATFORM", "COMPANION_RADIO_FULL", "OTA_SEEDER_ONLY"], False), + ): + result = subprocess.run(["c++", "-x", "c++", "-E", "-I", str(ROOT), + *("-D" + flag for flag in flags), "-"], input=source, + text=True, capture_output=True, check=True) + self.assertEqual("SHARING_ENABLED" in result.stdout, expected, flags) + + def test_affected_esp32_full_overlay_keeps_queue_and_both_transports(self): + for target in ("Heltec_v3_companion_radio_full", "Xiao_C3_companion_radio_full", + "heltec_tracker_v2_companion_radio_full_femon"): + result = subprocess.run(["bash", "-c", ''' +source build.sh +PIO_ENV_PLATFORM_BY_NAME["$1"]=ESP32_PLATFORM +pio_env_option_contains() { return 0; } +requires_esp32_companion_full_ota_fallback() { return 1; } +apply_companion_radio_full_profile "$1" "$1" +printf '%s\\n' "$PLATFORMIO_BUILD_FLAGS" +''', "test", target], cwd=ROOT, text=True, capture_output=True, check=True) + self.assertIn("-DMAX_CONTACTS=150", result.stdout) + self.assertNotIn("-DOFFLINE_QUEUE_SIZE=", result.stdout) + self.assertIn("-DWIFI_OTA_SEEDER=1", result.stdout) + self.assertIn("-DBLE_PIN_CODE=123456", result.stdout) + + def test_missing_and_truncated_elf_are_rejected(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "firmware.elf" + for data in (b"not an ELF", b"\x7fELF\x02\x01\x01" + bytes(100), + b"\x7fELF\x01\x01\x01" + bytes(45)): + path.write_bytes(data) + with self.assertRaises(ValueError): + FirmwareElf(path) + + def test_stale_failed_and_changed_artifacts_cannot_resume_or_publish(self): + with tempfile.TemporaryDirectory() as temp: + directory = Path(temp) + stem = directory / "companion-v1.17.1.5-test" + (directory / "firmware.elf").write_bytes(b"linked image") + report = dict(schema_version=1, passed=True, available_internal_bytes=80000, + required_heap_bytes=73728, largest_internal_region_bytes=80000, + required_contiguous_bytes=25602, elf_sha256=proof.digest(directory / "firmware.elf"), + target="pio_companion") + (directory / "firmware.memory.json").write_text(json.dumps(report)) + manifest = Path(str(stem) + ".capabilities.json") + manifest.write_text(json.dumps({"target": "companion", "artifact_target": "companion"})) + image = Path(str(stem) + ".uf2") + image.write_bytes(b"firmware package") + proof.package_report(directory, stem) + self.assertTrue(proof.validate_package(stem)["passed"]) + image.write_bytes(b"stale build") + with self.assertRaisesRegex(ValueError, "changed"): + proof.validate_package(stem) + (directory / "firmware.elf").write_bytes(b"other build") + with self.assertRaisesRegex(ValueError, "different ELF"): + proof.validate_build(directory) + report["passed"] = False + (directory / "firmware.memory.json").write_text(json.dumps(report)) + with self.assertRaisesRegex(ValueError, "failing"): + proof.validate_build(directory) + + def test_every_resolved_firmware_environment_has_a_policy_and_hook(self): + # PlatformIO is single-process. CI invokes this separately from builds. + result = subprocess.run(["pio", "project", "config", "--json-output"], cwd=ROOT, + text=True, capture_output=True, check=True) + checked = 0 + for name, options in json.loads(result.stdout): + if not name.startswith("env:") or name.startswith("env:native"): + continue + options = dict(options) + flags = " ".join(options.get("build_flags", [])) + platforms = [p for p in ram.SUPPORTED if p in flags] + if not platforms: + self.fail(f"{name}: no memory policy") + self.assertEqual(len(platforms), 1, name) + self.assertIn("post:scripts/check_firmware_ram.py", options.get("extra_scripts", []), name) + checked += 1 + self.assertGreater(checked, 700) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_t096_full_memory.py b/test/test_t096_full_memory.py index 99a0d7d4..87dc2e21 100644 --- a/test/test_t096_full_memory.py +++ b/test/test_t096_full_memory.py @@ -49,13 +49,16 @@ class T096FullMemoryTest(unittest.TestCase): self.assertNotIn("-DMAX_CONTACTS", flags) self.assertNotIn("-DMAX_GROUP_CHANNELS", flags) - def test_other_nrf52_profiles_keep_their_queue_policy(self): - for target in ("RAK_4631_companion_radio_full", - "Heltec_t096_companion_radio_ble_femon"): + def test_all_nrf52_full_profiles_share_without_changing_legacy(self): + for target in ("RAK_4631_companion_radio_full", "WioTrackerL1Eink_companion_radio_full", + "Heltec_t114_companion_radio_full", "RAK_3401_companion_radio_full"): with self.subTest(target=target): flags = full_flags(target) - self.assertNotIn("-DOTA_SHARED_COMPANION_QUEUE", flags) - self.assertNotIn("__mesh_nrf52_min_heap_size", flags) + self.assertIn("-DOTA_SHARED_COMPANION_QUEUE=1", flags) + self.assertIn("-DOFFLINE_QUEUE_SIZE=256", flags.splitlines()[0]) + flags = full_flags("Heltec_t096_companion_radio_ble_femon") + self.assertNotIn("-DOTA_SHARED_COMPANION_QUEUE", flags) + self.assertNotIn("__mesh_nrf52_min_heap_size", flags) def test_real_linker_rejects_release_heap_and_enforces_boundary(self): flags = full_flags("Heltec_t096_companion_radio_full_femon")