Files
MeshCore/test/test_onboard_qspi_profiles.py
T

334 lines
15 KiB
Python

#!/usr/bin/env python3
"""Resolved PlatformIO contracts for onboard-NOR Repeater and Room targets.
Set MESHCORE_TEST_PIO_CONFIG to a previously generated PlatformIO JSON file
when sharing a checkout with a build. Otherwise this suite resolves the project
once; it must follow the repository's single-PlatformIO-process rule.
"""
from dataclasses import dataclass
from functools import lru_cache
import glob
import json
import os
from pathlib import Path
import re
import shlex
import subprocess
import unittest
ROOT = Path(__file__).resolve().parents[1]
V6 = "boards/nrf52840_s140_v6.ld"
V7 = "boards/nrf52840_s140_v7.ld"
XIAO = "boards/nrf52840_s140_v7_xiao_bootloader_ota.ld"
XIAO_BOARD = "seeed-xiao-afruitnrf52-nrf52840"
PENDING = "OTA_QSPI_HARDWARE_QUALIFICATION_PENDING"
INTERNAL_ONLY = ("OTA_HYBRID_RAM_STORE", "OTA_INTERNAL_BOOTLOADER_UPDATE")
STORAGE_CHANGES = {
"ENABLE_OTA", "DISABLE_LORA_OTA", "QSPIFLASH", "OTA_FLASH_STORE",
"OTA_QSPI_STORE", "OTA_FOLDER_SERIAL", "OTA_QSPI_BOOTLOADER_UPDATE", PENDING,
}
@dataclass(frozen=True)
class Profile:
parent: str
board: str
variant: str
linker: str = V6
maximum_size: int = 815104
@property
def target(self):
return self.parent + "_lora_ota"
SUPPORTED = (
Profile("Xiao_nrf52_room_server", XIAO_BOARD, "xiao_nrf52", XIAO, 757760),
Profile("ikoka_handheld_nrf_e22_30dbm_room_server", XIAO_BOARD,
"ikoka_handheld_nrf", XIAO, 757760),
*(Profile(f"ikoka_nano_nrf_{power}dbm_room_server", XIAO_BOARD,
"ikoka_nano_nrf", XIAO, 757760) for power in (22, 30, 33)),
*(Profile(f"ikoka_stick_nrf_{power}dbm_room_server", XIAO_BOARD,
"ikoka_stick_nrf", XIAO, 757760) for power in (22, 30, 33)),
Profile("LilyGo_T-Echo_room_server", "t-echo", "lilygo_techo"),
Profile("ThinkNode_M1_room_server", "thinknode_m1", "thinknode_m1"),
Profile("ThinkNode_M6_room_server", "thinknode_m6", "thinknode_m6"),
Profile("WioTrackerL1_room_server", "seeed-wio-tracker-l1",
"wio-tracker-l1", V7, 811008),
Profile("SenseCap_Solar_room_server", "seeed_sensecap_solar",
"sensecap_solar", V7, 811008),
Profile("LilyGo_T-Echo-Lite_repeater", "t-echo", "lilygo_techo_lite"),
Profile("LilyGo_T-Echo-Lite_room_server", "t-echo", "lilygo_techo_lite"),
)
PREEXISTING_SOLAR_ROOMS = (
Profile("solarxiao_30S_room_server", XIAO_BOARD, "xiao_nrf52", XIAO, 757760),
Profile("solarxiao_33S_room_server", XIAO_BOARD, "xiao_nrf52", XIAO, 757760),
)
QUALIFICATION = (
*(Profile(f"muzi_base_{radio}_{role}", "muzi_base", "muzi_base")
for radio in ("uno", "duo") for role in ("repeater", "room_server")),
*(Profile(f"LilyGo_T-Echo_Card_{role}", "t-echo", "lilygo_techo_card")
for role in ("repeater", "room_server")),
*(Profile(f"Nano_G2_Ultra_{role}", "nano-g2-ultra", "nano_g2_ultra")
for role in ("repeater", "room_server")),
)
def text_option(value):
if isinstance(value, list):
return "\n".join(str(item) for item in value)
return str(value)
def macro_operations(value):
"""Parse both '-D NAME=1' and '-UNAME', preserving command-line order."""
tokens = iter(shlex.split(text_option(value)))
operations = []
for token in tokens:
if token in ("-D", "-U"):
kind, argument = token[1], next(tokens)
elif token.startswith(("-D", "-U")):
kind, argument = token[1], token[2:]
else:
continue
name, separator, value = argument.partition("=")
operations.append((kind, name, value if separator else None))
return operations
def effective_defines(options):
# ProcessFlags emits CPPDEFINES first and appends every -U afterwards in
# _CPPDEFFLAGS, regardless of the order in build_flags. ProcessUnFlags
# removes matching CPPDEFINES; a bare -D name also removes valued defines.
# A defined zero still violates raw-QSPI/QSPIFLASH exclusivity.
removed = set(macro_operations(options.get("build_unflags", "")))
operations = macro_operations(options.get("build_flags", ""))
defines = {}
for operation in operations:
kind, name, value = operation
if kind == "D" and operation not in removed and (kind, name, None) not in removed:
defines[name] = value
for kind, name, _ in operations:
if kind == "U":
defines.pop(name, None)
return defines
def role_defines(options):
return {
name: value for name, value in effective_defines(options).items()
if name not in STORAGE_CHANGES
}
def variant_includes(options):
tokens = iter(shlex.split(text_option(options.get("build_flags", ""))))
includes = set()
for token in tokens:
if token == "-I":
path = next(tokens)
elif token.startswith("-I"):
path = token[2:]
else:
continue
if path.startswith("variants/"):
includes.add(path)
return includes
@lru_cache(maxsize=None)
def source_matches(pattern, path):
# MatchSourceFiles expands glob patterns under PROJECT_SRC_DIR, then
# recursively includes matched directories. fnmatch('*.cpp') would also
# match helpers/ota/foo.cpp or ../examples/foo.cpp, which PIO does not.
source = (ROOT / "src" / path).resolve()
for candidate in glob.iglob(str(ROOT / "src" / pattern), recursive=True):
candidate = Path(candidate).resolve()
if candidate == source or candidate.is_dir() and candidate in source.parents:
return True
return False
def source_selected(options, path):
selected = False
for action, pattern in re.findall(
r"([+-])<([^>]+)>", text_option(options.get("build_src_filter", ""))
):
if source_matches(pattern, path):
selected = action == "+"
return selected
class OnboardQspiProfilesTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cached = os.environ.get("MESHCORE_TEST_PIO_CONFIG")
if cached:
payload = Path(cached).read_text(encoding="utf-8")
else:
payload = subprocess.check_output(
["pio", "project", "config", "--json-output"], cwd=ROOT, text=True
)
cls.config = {section: dict(options) for section, options in json.loads(payload)}
def options(self, target):
self.assertIn("env:" + target, self.config)
return self.config["env:" + target]
def assert_external_contract(self, profile, pending=False):
options = self.options(profile.target)
parent = self.options(profile.parent)
defines = effective_defines(options)
parents = re.split(r"[,\s]+", text_option(options.get("extends", "")).strip())
self.assertEqual(parents, ["env:" + profile.parent])
self.assertEqual(options.get("board"), profile.board)
self.assertEqual(options.get("board"), parent.get("board"))
self.assertEqual(role_defines(options), role_defines(parent))
self.assertEqual(variant_includes(options), {"variants/" + profile.variant})
self.assertEqual(variant_includes(options), variant_includes(parent))
self.assertEqual(options.get("board_build.ldscript"), profile.linker)
self.assertEqual(int(options.get("board_upload.maximum_size", 0)), profile.maximum_size)
for name in ("ENABLE_OTA", "OTA_FLASH_STORE", "OTA_QSPI_STORE"):
self.assertEqual(defines.get(name), "1", name)
for name in ("QSPIFLASH", "DISABLE_LORA_OTA", "OTA_SD_STORE", *INTERNAL_ONLY):
self.assertNotIn(name, defines)
if profile.linker == XIAO:
self.assertEqual(defines.get("OTA_QSPI_BOOTLOADER_UPDATE"), "1")
else:
self.assertNotIn("OTA_QSPI_BOOTLOADER_UPDATE", defines)
if pending:
self.assertEqual(defines.get(PENDING), "1")
else:
self.assertNotIn(PENDING, defines)
self.assertIn("post:tools/mota/pio_endf.py", text_option(options.get("extra_scripts", "")))
for source in ("helpers/ota/OtaStoreQspiNrf52.cpp", "helpers/ota/OtaManager.cpp"):
self.assertTrue(source_selected(options, source), source)
role = "simple_room_server" if "_room_server" in profile.parent else "simple_repeater"
self.assertTrue(source_selected(options, "../examples/" + role + "/main.cpp"))
def test_supported_external_profiles_keep_exact_legacy_hardware(self):
self.assertEqual(len(SUPPORTED), 15)
for profile in (*SUPPORTED, *PREEXISTING_SOLAR_ROOMS):
with self.subTest(target=profile.target):
self.assert_external_contract(profile)
def test_original_internal_recipes_remain_distinct(self):
for profile in (*SUPPORTED, *PREEXISTING_SOLAR_ROOMS):
with self.subTest(target=profile.parent):
options = self.options(profile.parent)
defines = effective_defines(options)
self.assertEqual(options.get("board"), profile.board)
self.assertEqual(options.get("board_build.ldscript"), V7 if profile.linker == XIAO else profile.linker)
for name in ("OTA_QSPI_STORE", "OTA_QSPI_BOOTLOADER_UPDATE", PENDING, *INTERNAL_ONLY):
self.assertNotIn(name, defines)
self.assertNotEqual(profile.target, profile.parent)
def test_linker_files_keep_the_checked_application_geometry(self):
for linker, start, end, maximum_size in (
(V6, 0x26000, 0xED000, 815104),
(V7, 0x27000, 0xED000, 811008),
(XIAO, 0x27000, 0xE0000, 757760),
):
with self.subTest(linker=linker):
source = (ROOT / linker).read_text(encoding="ascii")
flash = re.search(
r"FLASH\s*\(rx\)\s*:\s*ORIGIN\s*=\s*(0x[0-9A-Fa-f]+),"
r"\s*LENGTH\s*=\s*(0x[0-9A-Fa-f]+)\s*-\s*(0x[0-9A-Fa-f]+)",
source,
)
self.assertIsNotNone(flash)
self.assertEqual(tuple(int(value, 16) for value in flash.groups()), (start, end, start))
self.assertEqual(end - start, maximum_size)
def test_unqualified_profiles_are_explicit_and_do_not_claim_xiao_layout(self):
marked = {
section[4:] for section, options in self.config.items()
if section.startswith("env:") and PENDING in effective_defines(options)
}
self.assertEqual(len(QUALIFICATION), 8)
self.assertEqual(marked, {profile.target for profile in QUALIFICATION})
for profile in QUALIFICATION:
with self.subTest(target=profile.target):
self.assert_external_contract(profile, pending=True)
parent = self.options(profile.parent)
self.assertNotIn(PENDING, effective_defines(parent))
self.assertNotIn("OTA_QSPI_STORE", effective_defines(parent))
if profile.board == "muzi_base":
self.assertEqual(effective_defines(parent).get("QSPIFLASH"), "1")
self.assertIn(
("D", "QSPIFLASH", "1"),
macro_operations(self.options(profile.target).get("build_unflags", "")),
)
def test_pending_profiles_preserve_base_optimization_unflags(self):
baseline = set(shlex.split(text_option(self.config["nrf52_base"]["build_unflags"])))
self.assertIn("-Ofast", baseline)
for profile in QUALIFICATION:
with self.subTest(target=profile.target):
options = self.options(profile.target)
unflags = set(shlex.split(text_option(options.get("build_unflags", ""))))
self.assertTrue(baseline <= unflags, "missing inherited build_unflags: " + repr(baseline - unflags))
self.assertIn("-Oz", shlex.split(text_option(options.get("build_flags", ""))))
def test_xiao_normal_repeaters_have_a_disabled_merged_uart_driver(self):
for target in ("Xiao_nrf52_repeater", "solarxiao_30S_repeater", "solarxiao_33S_repeater"):
with self.subTest(target=target):
options = self.options(target)
defines = effective_defines(options)
expected = {
"WITH_RS232_BRIDGE": "Serial1",
"WITH_RS232_BRIDGE_UART": "1",
"WITH_RS232_BRIDGE_RX": "PIN_SERIAL1_RX",
"WITH_RS232_BRIDGE_TX": "PIN_SERIAL1_TX",
"BRIDGE_MAX_BAUD": "500000",
"RS232_BRIDGE_MERGED": "1",
}
for name, value in expected.items():
self.assertEqual(defines.get(name), value, name)
self.assertNotIn("RS232_BRIDGE_DEFAULT_ON", defines)
self.assertNotIn("WITH_MQTT_BRIDGE", defines)
self.assertTrue(source_selected(options, "helpers/bridges/RS232Bridge.cpp"))
legacy = self.options("Xiao_nrf52_repeater_bridge_rs232")
defines = effective_defines(legacy)
self.assertEqual(defines.get("WITH_RS232_BRIDGE"), "Serial1")
self.assertNotIn("RS232_BRIDGE_MERGED", defines)
self.assertNotIn("OTA_QSPI_STORE", defines)
self.assertEqual(legacy.get("board_build.ldscript"), V7)
self.assertTrue(source_selected(legacy, "helpers/bridges/RS232Bridge.cpp"))
def test_effective_defines_honor_undefines_and_removed_definitions(self):
options = {
"build_flags": ["-D ENABLE_OTA=0 -UENABLE_OTA -DENABLE_OTA=1",
"-D QSPIFLASH=1 -D ZERO_IS_DEFINED=0 -UABSENT"],
"build_unflags": "-D QSPIFLASH=1",
}
self.assertEqual(effective_defines(options), {"ZERO_IS_DEFINED": "0"})
self.assertEqual(effective_defines({
"build_flags": "-D QSPIFLASH=1 -D KEEP=2",
"build_unflags": "-DQSPIFLASH",
}), {"KEEP": "2"})
self.assertEqual(effective_defines({
"build_flags": "-D LEVEL=1 -D LEVEL=2",
"build_unflags": "-D LEVEL=1",
}), {"LEVEL": "2"})
def test_source_filters_keep_globs_inside_the_selected_directory(self):
options = {"build_src_filter": "+<*.cpp> +<helpers/*.cpp>"}
self.assertFalse(source_selected(options, "../examples/simple_repeater/main.cpp"))
self.assertFalse(source_selected(options, "helpers/ota/OtaStoreQspiNrf52.cpp"))
options["build_src_filter"] = "+<*>"
self.assertFalse(source_selected(options, "../examples/simple_repeater/main.cpp"))
self.assertTrue(source_selected(options, "helpers/ota/OtaStoreQspiNrf52.cpp"))
options["build_src_filter"] = "+<../examples/simple_repeater>"
self.assertTrue(source_selected(options, "../examples/simple_repeater/main.cpp"))
options["build_src_filter"] = "+<helpers/ota> -<helpers/ota/OtaStoreQspiNrf52.cpp>"
self.assertFalse(source_selected(options, "helpers/ota/OtaStoreQspiNrf52.cpp"))
options["build_src_filter"] += " +<helpers/ota/OtaStoreQspiNrf52.cpp>"
self.assertTrue(source_selected(options, "helpers/ota/OtaStoreQspiNrf52.cpp"))
if __name__ == "__main__":
unittest.main()