Fix ESP32 Full Companion partition policy
This commit is contained in:
1 parent
fdf528bb27
commit
f873e51b43
5 files changed
+266
-2
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user