mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-16 04:06:56 +00:00
Gate every firmware build on runtime RAM and share nRF52 mOTA storage
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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)))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user