Gate every firmware build on runtime RAM and share nRF52 mOTA storage

This commit is contained in:
mikecarper
2026-09-08 02:09:26 -07:00
parent 1b6a9dcb6a
commit aa20e9278e
22 changed files with 1037 additions and 36 deletions
+3 -1
View File
@@ -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
+5
View File
@@ -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:
+38 -11
View File
@@ -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 "+<helpers/ota/*.cpp>"
@@ -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
+8 -7
View File
@@ -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.
+9 -4
View File
@@ -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
+81
View File
@@ -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.
+3 -2
View File
@@ -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.
@@ -1,4 +1,5 @@
#pragma once
#include <helpers/ota/OtaMemoryPolicy.h>
// Features are selected independently so adding a transport or changing an
// image-size profile cannot accidentally remove unrelated Companion behavior.
+4
View File
@@ -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
+122
View File
@@ -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("<H", self.data, 12)[0])
if self.mcu is None:
raise ValueError("unsupported ESP image chip ID")
self.segments = []
offset = 24
for _ in range(self.data[1]):
if offset + 8 > 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)
+268
View File
@@ -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
+65
View File
@@ -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))
+87
View File
@@ -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()
+8 -1
View File
@@ -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)))
+1 -1
View File
@@ -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;
+4 -4
View File
@@ -105,10 +105,10 @@ struct OtaContext {
OtaStoreRam<OTA_FETCH_BUF_SIZE> 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<uint8_t*>(malloc(OTA_SERVE_BUF_SIZE));
+1
View File
@@ -3,6 +3,7 @@
#include <stdint.h>
#include <stddef.h>
#include "OtaFormat.h"
#include "OtaMemoryPolicy.h"
#include "OtaProtocol.h"
#include "OtaByteIO.h"
#include "OtaStore.h"
+9
View File
@@ -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
+1
View File
@@ -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
+5
View File
@@ -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"
+306
View File
@@ -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("<IIIBBH", offset, value, size, 0x10, 0, 0xFFF1)
section_offset = 52
payload_offset = section_offset + 4 * 40
string_offset = payload_offset + len(data)
symbol_offset = string_offset + len(names)
header = b"\x7fELF\x01\x01\x01" + bytes(9)
header += struct.pack("<HH5I6H", 2, 40, 1, 0, 0, section_offset, 0,
52, 0, 0, 40, 4, 0)
sections = bytes(40)
sections += struct.pack("<10I", 0, 1, 2, address, payload_offset, len(data), 0, 0, 4, 0)
sections += struct.pack("<10I", 0, 3, 0, 0, string_offset, len(names), 0, 0, 1, 0)
sections += struct.pack("<10I", 0, 2, 0, 0, symbol_offset, len(symtab), 2, 1, 4, 16)
Path(path).write_bytes(header + sections + data + names + symtab)
return FirmwareElf(path)
def esp_fixture(path, modern=False, fragmented=False):
address = 0x3F400000
caps = [0x804, 0x804, 0x404, 0x803, 0x8804]
data = bytearray(struct.pack("<I", len(caps)))
symbols = {"soc_memory_region_count": (address, 4)}
symbols["soc_memory_regions"] = (address + len(data), len(caps) * (20 if modern else 16))
for index in range(len(caps)):
data += struct.pack("<4I", 0x3FFB0000 + index * 0x10000, 0x10000, index, 0)
if modern:
data += bytes([int(index == 1), 0, 0, 0])
symbols["soc_memory_types"] = (address + len(data), len(caps) * (16 if modern else 20))
for index, cap in enumerate(caps):
data += struct.pack("<4I", 0, cap, 0, 0)
if not modern:
data += bytes([0, int(index == 1), 0, 0])
symbols["soc_reserved_memory_region_start"] = (address + len(data), 0)
data += struct.pack("<2I", 0x3FFB0000, 0x3FFB4000)
if fragmented:
data += struct.pack("<2I", 0x3FFB8000, 0x3FFBC000)
symbols["soc_reserved_memory_region_end"] = (address + len(data), 0)
return elf32(path, symbols, data, address)
class FirmwareRamTest(unittest.TestCase):
def test_published_image_tables_match_elf_and_use_its_own_reservations(self):
with tempfile.TemporaryDirectory() as temp:
path = Path(temp) / "reference.elf"
reference = esp_fixture(path)
# Build a real ESP image containing the same allocated section;
# the RTC guard starts the reservation section in the pinned SDK.
section = reference.sections[1]
data = reference.section_bytes(section)
data = data.replace(struct.pack("<2I", 0x3FFB0000, 0x3FFB4000),
struct.pack("<2I", 0x3FF81FF0, 0x3FF82000)
+ struct.pack("<2I", 0x3FFB0000, 0x3FFB4000)
+ struct.pack("<2I", 0x3FFC0000, 0x3FFC1000))
# Relocate the complete type table; name pointer words may move,
# capability and startup flags must still match exactly.
start = reference.address("soc_reserved_memory_region_start")
original_read = reference.read
reference.read = lambda address, size: (struct.pack("<2I", 0x3FF81FF0, 0x3FF82000)
if address == start and size == 8 else original_read(address, size))
header = bytearray(24)
header[0:2] = bytes([0xE9, 1])
image_path = Path(temp) / "firmware.bin"
image_path.write_bytes(header + struct.pack("<2I", section[3], len(data)) + data)
image = EspImage(image_path)
image.load_layout(reference)
regions = ram.esp32_heap_regions(image, "esp32")
self.assertEqual(regions, [(0x3FFB4000, 0x3FFC0000, 0), (0x3FFC1000, 0x3FFD0000, 1)])
changed = bytearray(image_path.read_bytes())
# Region table is immutable SDK data. A changed layout must require
# another matching reference/rebuild, never silently be accepted.
changed[32 + 8] ^= 1
image_path.write_bytes(changed)
with self.assertRaisesRegex(ValueError, "signature"):
EspImage(image_path).load_layout(reference)
def test_lazy_manual_stage_allocation_failure_and_clear(self):
source = (ROOT / "src/helpers/ota/OtaContext.h").read_text()
begin = source.index("#if defined(ESP32_PLATFORM) || (defined(NRF52_PLATFORM) && !defined(OTA_SEEDER_ONLY))")
end = source.index("#endif", begin) + len("#endif")
branch = source[begin:end]
code = r'''
#include <cstdlib>
#include <cassert>
#include <cstdint>
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()
+8 -5
View File
@@ -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")