diff --git a/build.sh b/build.sh index 54fe4757e..561ee8f12 100755 --- a/build.sh +++ b/build.sh @@ -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 diff --git a/scripts/esp32_full_partition.py b/scripts/esp32_full_partition.py index 20910d286..e40069bd3 100644 --- a/scripts/esp32_full_partition.py +++ b/scripts/esp32_full_partition.py @@ -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: diff --git a/test/README.md b/test/README.md index 3409389b7..492682194 100644 --- a/test/README.md +++ b/test/README.md @@ -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 diff --git a/test/test_build_profiles.sh b/test/test_build_profiles.sh index bbd79ea3b..40485e612 100644 --- a/test/test_build_profiles.sh +++ b/test/test_build_profiles.sh @@ -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" diff --git a/test/test_esp32_full_partition.py b/test/test_esp32_full_partition.py new file mode 100644 index 000000000..04cd9d467 --- /dev/null +++ b/test/test_esp32_full_partition.py @@ -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()