Fix ESP32 Full Companion partition policy

This commit is contained in:
mikecarper committed 2026-08-29 20:26:27 -07:00
1 parent fdf528bb27
commit f873e51b43
5 files changed
+266 -2

No files matched your search

+25 -1
View File
@@ -2808,6 +2808,11 @@ declare_build_capability_contract() {
record_build_expectation "companion.wifi_ota_seeder" \
"OTA seeder listening on :"
record_build_expectation "web.webconfig" "start webconfig"
# This branch is constant-folded away when the expanded-profile marker
# is missing. Make artifact qualification prove that canonical Full
# Companion images really contain the bounded first-boot WiFi policy.
record_build_expectation "web.unconfigured_setup_cutoff" \
"WiFi still unconfigured after"
pio_env_name=$(get_pio_build_env "$env_name")
if pio_env_option_contains "$pio_env_name" build_flags \
"WITH_MQTT_BRIDGE"; then
@@ -3257,6 +3262,7 @@ apply_companion_radio_full_profile() {
local env_name=$1
local pio_env_name=$2
unset MESHCORE_ESP32_FULL_PARTITION_TABLE
is_companion_radio_full_target "$env_name" || return 0
# Every full Companion is a LoRa mOTA source, never an update destination.
@@ -3289,7 +3295,24 @@ apply_companion_radio_full_profile() {
# ESP32 keeps both source transports. TCP remains the normal unattended
# path; a host may explicitly hold the native USB port in Companion terminal
# mode when WiFi is unavailable and serve the same folder protocol there.
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DWIFI_OTA_SEEDER=1 -DCOMPANION_FEATURE_NETWORK_TERMINAL=1 -DCOMPANION_FEATURE_MEMORY_DIAGNOSTICS=1"
# The canonical Full Companion target is already built with an expanded
# partition table even when it is selected from the ordinary Companion
# release matrix (ESP32_FULL_BUILD remains 0 in that path). Mark that
# layout explicitly so Full-only runtime policies, including the bounded
# first-boot WebConfig window, cannot silently compile with legacy defaults.
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DWIFI_OTA_SEEDER=1 -DCOMPANION_FEATURE_NETWORK_TERMINAL=1 -DCOMPANION_FEATURE_MEMORY_DIAGNOSTICS=1 -DMESHCORE_EXPANDED_PARTITION_PROFILE=1"
# Both Indicator radio layouts share the same original NVS/SPIFFS data
# placement. The LoRa WiFi base names that map directly; the ESP-NOW USB
# base does not, so pass the physical-board requirement to the partition
# hook from the canonical target identity rather than guessing from flash
# size or the generic esp32-s3-devkitc manifest.
case "${env_name,,}" in
sensecapindicator-espnow_companion_radio_full|\
sensecapindicator-lora_companion_radio_full)
export MESHCORE_ESP32_FULL_PARTITION_TABLE="variants/sensecap_indicator-espnow/dual_ota_2560k_preserve_spiffs.csv"
;;
esac
# ESP-NOW is the primary mesh radio on these two layouts, so conventional
# Companion WiFi must share its protocol mask and fixed channel instead of
@@ -3864,6 +3887,7 @@ build_firmware() {
restore_platformio_build_flags "$had_platformio_build_flags" "$original_platformio_build_flags"
unset MESHCORE_ESP32_FULL_BUILD
unset MESHCORE_COMPANION_RADIO_FULL
unset MESHCORE_ESP32_FULL_PARTITION_TABLE
unset MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE
unset MESHCORE_FORCE_LORA_OTA
if [ "$had_platformio_build_unflags" -eq 1 ]; then
+37
View File
@@ -3,6 +3,15 @@ Import("env")
import os
# These layouts encode board-specific data-placement contracts, not merely an
# application-size preference. A Full merged image must retain them so an
# application/partition-table migration does not silently move or orphan the
# board's persistent data.
PRESERVED_FULL_PARTITION_TABLES = {
"variants/sensecap_indicator-espnow/dual_ota_2560k_preserve_spiffs.csv",
}
def parse_flash_size(value):
text = str(value).strip().upper()
multipliers = {"KB": 1024, "MB": 1024 * 1024}
@@ -12,9 +21,35 @@ def parse_flash_size(value):
return int(text, 0)
def preserved_full_partition_table(value):
path = str(value or "").strip().replace("\\", "/")
return next(
(
table
for table in PRESERVED_FULL_PARTITION_TABLES
if path == table or path.endswith("/" + table)
),
None,
)
if os.environ.get("MESHCORE_ESP32_FULL_BUILD") == "1":
board = env.BoardConfig()
flash_size = parse_flash_size(board.get("upload.flash_size", "4MB"))
requested_partitions = os.environ.get(
"MESHCORE_ESP32_FULL_PARTITION_TABLE", ""
).strip()
required_partitions = preserved_full_partition_table(
requested_partitions
)
if requested_partitions and not required_partitions:
raise ValueError(
"unsupported required Full partition table: "
+ requested_partitions
)
preserved_partitions = required_partitions or preserved_full_partition_table(
board.get("build.partitions", "")
)
if str(board.get("build.mcu", "")).lower() == "esp32s3":
# Every S3 Full image must remain ROM-bootable across the supported
# flash parts. Some board manifests default to QIO even though their
@@ -33,6 +68,8 @@ if os.environ.get("MESHCORE_ESP32_FULL_BUILD") == "1":
# a merged recovery image changes only the application, not the proven
# boot/partition chain. The 3 MiB app slot has ample room for this role.
partitions = "huge_app.csv"
elif preserved_partitions:
partitions = preserved_partitions
elif flash_size >= 16 * 1024 * 1024:
partitions = "default_16MB.csv"
elif flash_size >= 8 * 1024 * 1024:
+1
View File
@@ -15,6 +15,7 @@ pio test -e native_kiss_modem # KISS modem suite
pio test -e native -f test_webconfig_keys # a single suite
python3 test/test_indicator_display_profile.py # Indicator RAM/scale contract
python3 test/test_companion_terminal_profile.py # Companion CLI capability gates
python3 test/test_esp32_full_partition.py # Full partition-preservation policy
```
A green `[PASSED]` per suite means GoogleTest returned 0 (all assertions
+29 -1
View File
@@ -12,6 +12,18 @@ fail() {
[ "$OPTION3_BUILD_WORKERS" -eq 1 ] \
|| fail "logging matrix permits concurrent PlatformIO target builds"
# The expanded-profile marker applied below must select a real, nonzero
# boot-local setup window. Keep the build overlay, WebConfig compile-time
# mapping, and pure timing policy tied together.
grep -Fq \
'static const uint32_t kFullSetupApWindowMs = 30UL * 60UL * 1000UL;' \
src/helpers/WebConfigBatch.h \
|| fail "Full setup-AP window is no longer exactly 30 minutes"
grep -Fq \
'#define WEBCONFIG_UNCONFIGURED_SETUP_TIMEOUT_MS WebConfigBatch::kFullSetupApWindowMs' \
src/helpers/esp32/WebConfigServer.h \
|| fail "expanded Full profile no longer enables the unconfigured-WiFi cutoff"
requires_esp32_arduino3_framework \
heltec_rc32_repeater heltec_rc32_repeater \
|| fail "RC32 omitted its Arduino-ESP32 3.x package preflight"
@@ -331,6 +343,20 @@ for full_env in "${SUPPORTED_PIO_ENVS[@]}"; do
fi
[[ "$PLATFORMIO_BUILD_FLAGS" == *"WIFI_OTA_SEEDER=1"* ]] \
|| fail "$full_env Full Companion omitted its WiFi runtime overlay"
[[ "$PLATFORMIO_BUILD_FLAGS" == *"MESHCORE_EXPANDED_PARTITION_PROFILE=1"* ]] \
|| fail "$full_env Full Companion omitted its expanded runtime profile"
case "${full_env,,}" in
sensecapindicator-espnow_companion_radio_full|\
sensecapindicator-lora_companion_radio_full)
[ "${MESHCORE_ESP32_FULL_PARTITION_TABLE:-}" \
= "variants/sensecap_indicator-espnow/dual_ota_2560k_preserve_spiffs.csv" ] \
|| fail "$full_env omitted the Indicator preserve-SPIFFS table"
;;
*)
[ -z "${MESHCORE_ESP32_FULL_PARTITION_TABLE:-}" ] \
|| fail "$full_env inherited another board's required partition table"
;;
esac
[[ "$PLATFORMIO_BUILD_FLAGS" == *"CONFIG_ASYNC_TCP_STACK_SIZE=4096"* ]] \
|| fail "$full_env Full Companion omitted its bounded AsyncTCP stack"
[[ "$PLATFORMIO_BUILD_FLAGS" == *"CONFIG_ASYNC_TCP_RUNNING_CORE=1"* ]] \
@@ -351,7 +377,7 @@ for full_env in "${SUPPORTED_PIO_ENVS[@]}"; do
esac
done
unset PLATFORMIO_BUILD_FLAGS PLATFORMIO_BUILD_UNFLAGS \
PLATFORMIO_BUILD_SRC_FILTER
PLATFORMIO_BUILD_SRC_FILTER MESHCORE_ESP32_FULL_PARTITION_TABLE
# The bounded AsyncTCP task stack belongs to every ESP32 Full profile, including
# expanded non-Companion builds, but must not leak into ordinary or nRF52
@@ -785,6 +811,8 @@ expectations=" ${BUILD_EXPECTATIONS[*]} "
|| fail "ESP32 Full contract uses no stable network-terminal evidence"
[[ "$expectations" == *"companion.wifi_ota_seeder=OTA seeder listening on :"* ]] \
|| fail "ESP32 Full contract omitted the WiFi seeder"
[[ "$expectations" == *"web.unconfigured_setup_cutoff=WiFi still unconfigured after"* ]] \
|| fail "ESP32 Full contract omitted the bounded unconfigured-WiFi window"
[[ "$expectations" != *"Full Companion terminal listening"* ]] \
|| fail "ESP32 Full contract still depends on optional debug logging"
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
import contextlib
import io
import os
from pathlib import Path
import runpy
import unittest
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "esp32_full_partition.py"
INDICATOR_PROFILE = (
ROOT / "variants" / "sensecap_indicator-espnow" / "platformio.ini"
)
INDICATOR_PARTITIONS = (
"variants/sensecap_indicator-espnow/"
"dual_ota_2560k_preserve_spiffs.csv"
)
class FakeBoardConfig:
def __init__(self, values):
self.values = dict(values)
self.updates = []
def get(self, key, default=None):
return self.values.get(key, default)
def update(self, key, value):
self.values[key] = value
self.updates.append((key, value))
class FakeEnvironment:
def __init__(self, board):
self.board = board
def BoardConfig(self):
return self.board
def apply_policy(
values, *, full=True, companion=True, required_partitions=""
):
board = FakeBoardConfig(values)
environment = {
"MESHCORE_ESP32_FULL_BUILD": "1" if full else "0",
"MESHCORE_COMPANION_RADIO_FULL": "1" if companion else "0",
"MESHCORE_ESP32_FULL_PARTITION_TABLE": required_partitions,
}
output = io.StringIO()
with mock.patch.dict(os.environ, environment, clear=False):
with contextlib.redirect_stdout(output):
runpy.run_path(
str(SCRIPT),
init_globals={
"Import": lambda _name: None,
"env": FakeEnvironment(board),
},
)
return board, output.getvalue()
class Esp32FullPartitionTest(unittest.TestCase):
def test_indicator_full_retains_preserve_spiffs_layout(self):
profile = INDICATOR_PROFILE.read_text()
self.assertIn(
f"board_build.partitions = {INDICATOR_PARTITIONS}", profile
)
table = (ROOT / INDICATOR_PARTITIONS).read_text()
self.assertIn("app0, app, ota_0, 0x10000, 0x280000", table)
self.assertIn("spiffs, data, spiffs, 0x290000, 0x160000", table)
self.assertIn("app1, app, ota_1, 0x400000, 0x280000", table)
board, output = apply_policy(
{
"upload.flash_size": "8MB",
"build.mcu": "esp32s3",
"build.variant": "esp32s3",
"build.flash_mode": "qio",
"build.partitions": INDICATOR_PARTITIONS,
}
)
self.assertEqual(board.values["build.partitions"], INDICATOR_PARTITIONS)
self.assertIn(("build.partitions", INDICATOR_PARTITIONS), board.updates)
self.assertEqual(board.values["build.flash_mode"], "dio")
self.assertIn(INDICATOR_PARTITIONS, output)
def test_absolute_indicator_partition_path_is_also_preserved(self):
absolute = str(ROOT / INDICATOR_PARTITIONS)
board, _output = apply_policy(
{
"upload.flash_size": "8MB",
"build.mcu": "esp32s3",
"build.partitions": absolute,
}
)
self.assertEqual(board.values["build.partitions"], INDICATOR_PARTITIONS)
def test_indicator_target_override_preserves_espnow_base_data_layout(self):
board, _output = apply_policy(
{
"upload.flash_size": "8MB",
"build.mcu": "esp32s3",
"build.partitions": "default.csv",
},
required_partitions=INDICATOR_PARTITIONS,
)
self.assertEqual(board.values["build.partitions"], INDICATOR_PARTITIONS)
def test_unknown_required_partition_table_fails_closed(self):
with self.assertRaisesRegex(
ValueError, "unsupported required Full partition table"
):
apply_policy(
{
"upload.flash_size": "8MB",
"build.mcu": "esp32s3",
"build.partitions": "default.csv",
},
required_partitions="variants/not-a-real-layout.csv",
)
def test_generic_eight_megabyte_full_still_expands(self):
board, _output = apply_policy(
{
"upload.flash_size": "8MB",
"build.mcu": "esp32s3",
"build.partitions": "default.csv",
}
)
self.assertEqual(board.values["build.partitions"], "default_8MB.csv")
def test_capacity_only_custom_table_does_not_bypass_full_policy(self):
board, _output = apply_policy(
{
"upload.flash_size": "4MB",
"build.mcu": "esp32",
"build.partitions": "variants/dual_ota_1536k.csv",
}
)
self.assertEqual(board.values["build.partitions"], "huge_app.csv")
def test_tbeam_1w_factory_layout_keeps_precedence(self):
board, _output = apply_policy(
{
"upload.flash_size": "8MB",
"build.mcu": "esp32s3",
"build.variant": "lilygo_tbeam_1w",
"build.partitions": INDICATOR_PARTITIONS,
}
)
self.assertEqual(board.values["build.partitions"], "huge_app.csv")
def test_non_full_build_is_untouched(self):
board, output = apply_policy(
{
"upload.flash_size": "8MB",
"build.mcu": "esp32s3",
"build.flash_mode": "qio",
"build.partitions": INDICATOR_PARTITIONS,
},
full=False,
)
self.assertEqual(board.updates, [])
self.assertEqual(output, "")
if __name__ == "__main__":
unittest.main()