213 lines
10 KiB
Python
213 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Qualify the optional source-only RAK15002 integration without RF or a card.
|
|
|
|
Compile the actual bus helper and extracted production context/CLI methods;
|
|
the separate test_ota_sd_cache suite executes the real FAT cache and protocol.
|
|
No PlatformIO invocation is used here. Physical module qualification is still
|
|
required before promoting this experimental target to a receiver/release.
|
|
"""
|
|
from pathlib import Path
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
from test_replay_reset_integration import extract_braced
|
|
from test_upstream_power_policy_merge import Recipes
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
FIXTURES = ROOT / "test/fixtures/rak15002_sd_seeder"
|
|
CONTEXT = ROOT / "src/helpers/ota/OtaContext.h"
|
|
ENV = "env:RAK_4631_repeater_rak15002_sd_seeder_only"
|
|
BASE = "env:RAK_4631_repeater_lora_ota_no_external_sensors"
|
|
DEFINES = ["NRF52_PLATFORM", "RAK_4631", "OTA_SD_STORE", "OTA_SEEDER_ONLY",
|
|
"OTA_RAK15002_SD_CACHE", "SPI_INTERFACES_COUNT=1", "PIN_SPI_MISO=29",
|
|
"PIN_SPI_SCK=3", "PIN_SPI_MOSI=30", "PIN_SPI1_NSS=12"]
|
|
|
|
|
|
def build(testcase, source, defines=(), files=None, expect_success=True):
|
|
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
|
|
if not compiler:
|
|
testcase.skipTest("a host C++17 compiler is required")
|
|
with tempfile.TemporaryDirectory(prefix="rak15002-") as folder:
|
|
work = Path(folder)
|
|
(work / "main.cpp").write_text(source, encoding="utf-8")
|
|
for name, content in (files or {}).items():
|
|
(work / name).write_text(content, encoding="utf-8")
|
|
sanitizer = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
|
|
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
|
|
result = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
|
"-Wno-unused-parameter", *sanitizer,
|
|
*["-D" + value for value in defines],
|
|
"-I" + str(work), "-I" + str(ROOT / "src"),
|
|
str(work / "main.cpp"), "-o", str(work / "test")],
|
|
capture_output=True, text=True)
|
|
if not expect_success:
|
|
testcase.assertNotEqual(result.returncode, 0, "unsafe configuration compiled")
|
|
return
|
|
testcase.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
run = subprocess.run([str(work / "test")], capture_output=True, text=True)
|
|
testcase.assertEqual(run.returncode, 0, run.stdout + run.stderr)
|
|
|
|
|
|
SPI_MOCK = r'''
|
|
#pragma once
|
|
#include <cassert>
|
|
#include <vector>
|
|
#define HIGH 1
|
|
#define OUTPUT 1
|
|
struct NRF_SPIM_Type {};
|
|
inline NRF_SPIM_Type peripheral2, peripheral3;
|
|
#define NRF_SPIM2 (&peripheral2)
|
|
#define NRF_SPIM3 (&peripheral3)
|
|
struct SPIClass {
|
|
NRF_SPIM_Type* peripheral;
|
|
int miso, sck, mosi;
|
|
SPIClass(NRF_SPIM_Type* p, int i, int c, int o) : peripheral(p), miso(i), sck(c), mosi(o) {}
|
|
};
|
|
inline SPIClass SPI(NRF_SPIM3, 45, 43, 44);
|
|
inline SPIClass SPI1(NRF_SPIM2, 9, 10, 11);
|
|
struct GpioEvent { int type, pin, value; };
|
|
inline std::vector<GpioEvent> events;
|
|
inline void digitalWrite(int pin, int value) { events.push_back({0, pin, value}); }
|
|
inline void pinMode(int pin, int value) { events.push_back({1, pin, value}); }
|
|
'''
|
|
|
|
BUS_TEST = r'''
|
|
#include <helpers/ota/OtaSdSpiBusNrf52.h>
|
|
int main() {
|
|
using namespace mesh::ota;
|
|
auto& bus = ota_sd_spi_bus();
|
|
assert(&bus == &ota_sd_spi_bus());
|
|
#if defined(OTA_RAK15002_SD_CACHE)
|
|
assert(&bus != &SPI && &bus != &SPI1);
|
|
assert(bus.peripheral == NRF_SPIM2 && bus.miso == 29 && bus.sck == 3 && bus.mosi == 30);
|
|
assert(SPI.peripheral == NRF_SPIM3 && SPI.miso == 45 && SPI.sck == 43 && SPI.mosi == 44);
|
|
static_assert(OTA_SD_CS_PIN == 26, "exact RAK CS");
|
|
#else
|
|
assert(&bus == &SPI1);
|
|
static_assert(OTA_SD_CS_PIN == PIN_SPI1_NSS, "unchanged legacy CS");
|
|
#endif
|
|
ota_sd_prepare_chip_select();
|
|
assert(events.size() == 2 && events[0].type == 0 && events[0].value == HIGH);
|
|
assert(events[1].type == 1 && events[1].value == OUTPUT);
|
|
assert(events[0].pin == OTA_SD_CS_PIN && events[1].pin == OTA_SD_CS_PIN);
|
|
}
|
|
'''
|
|
|
|
|
|
class Rak15002SdSeederTests(unittest.TestCase):
|
|
def test_exact_profile_preserves_features_and_bounds_source_only_memory(self):
|
|
recipes = Recipes()
|
|
self.assertEqual(recipes.config.get(ENV, "extends"), BASE)
|
|
flags = recipes.value(ENV, "build_flags")
|
|
unflags = recipes.value(ENV, "build_unflags")
|
|
|
|
# The ordinary reduced receiver is now adaptive internal/NOR/SD, but
|
|
# this historical exact profile must still compile as its distinct
|
|
# CACHE-only source. Check the effective compiler macro set rather
|
|
# than treating a parent recipe's library dependency as a child store.
|
|
def macros(text, directive):
|
|
return set(re.findall(
|
|
r"(?:^|\s)-" + directive + r"\s*([A-Za-z_][A-Za-z0-9_]*)",
|
|
text,
|
|
))
|
|
|
|
effective = macros(flags, "D")
|
|
effective -= macros(flags, "U")
|
|
# PlatformIO's build_unflags removes inherited compiler arguments;
|
|
# -D entries here deliberately fence an adaptive parent macro.
|
|
effective -= macros(unflags, "D")
|
|
effective -= macros(unflags, "U")
|
|
for flag in ("OTA_SEEDER_ONLY=1", "OTA_SD_STORE=1", "OTA_RAK15002_SD_CACHE=1",
|
|
"OTA_SD_CS_PIN=26", "OTA_SD_SCK_MHZ=4", "OTA_SD_CACHE_MAX=16",
|
|
"OTA_MAX_SERVE=24", "OTA_HEAP_CONTEXT=1"):
|
|
self.assertRegex(flags, r"-D\s*" + re.escape(flag) + r"(?:\s|$)")
|
|
self.assertIn("-UOTA_FLASH_STORE", flags)
|
|
for flag in ("DISPLAY_CLASS=SSD1306Display", "NRF52_POWER_MANAGEMENT",
|
|
"WITH_RS232_BRIDGE=Serial2"):
|
|
self.assertIn(flag, flags)
|
|
for flag in ("OTA_QSPI_STORE", "OTA_RAK_AUTO_STORE", "OTA_TOWER_AUTO_STORE",
|
|
"OTA_SD_BOOTLOADER_UPDATE", "COMPANION_RADIO_FULL", "ETHERNET_ENABLED"):
|
|
self.assertNotIn(flag, effective)
|
|
self.assertNotIn("OTA_RAK15002_SD_STORE", effective)
|
|
self.assertNotIn("OTA_FLASH_STORE", effective)
|
|
for flag in ("OTA_SEEDER_ONLY", "OTA_SD_STORE", "OTA_RAK15002_SD_CACHE",
|
|
"OTA_HEAP_CONTEXT"):
|
|
self.assertIn(flag, effective)
|
|
libraries = recipes.value(ENV, "lib_deps")
|
|
self.assertIn("SdFat-beta.git#a249cfa", libraries)
|
|
self.assertIn("u-blox", libraries)
|
|
self.assertIn("SSD1306", libraries)
|
|
base_flags = recipes.value(BASE, "build_flags")
|
|
self.assertIn("OTA_RAK15002_SD_STORE=1", base_flags)
|
|
self.assertNotIn("OTA_RAK15002_SD_CACHE", base_flags)
|
|
self.assertIn("SdFat", recipes.value(BASE, "lib_deps"))
|
|
|
|
def test_real_bus_keeps_radio_pins_and_cs_safe(self):
|
|
build(self, BUS_TEST, DEFINES, {"SPI.h": SPI_MOCK})
|
|
|
|
def test_existing_tower_bus_is_unchanged(self):
|
|
build(self, BUS_TEST, ["PIN_SPI1_NSS=12"], {"SPI.h": SPI_MOCK})
|
|
|
|
def test_conflicting_buses_and_unqualified_handoffs_fail_closed(self):
|
|
for conflict in ("ETHERNET_ENABLED=0", "OTA_QSPI_STORE", "OTA_RAK_AUTO_STORE",
|
|
"OTA_TOWER_AUTO_STORE", "OTA_SD_BOOTLOADER_UPDATE",
|
|
"OTA_INTERNAL_BOOTLOADER_UPDATE", "OTA_QSPI_BOOTLOADER_UPDATE",
|
|
"SPI_32MHZ_INTERFACE=1", "OTA_SD_CS_PIN=25"):
|
|
with self.subTest(conflict=conflict):
|
|
build(self, BUS_TEST, [*DEFINES, conflict], {"SPI.h": SPI_MOCK}, False)
|
|
for missing in ("NRF52_PLATFORM", "RAK_4631", "OTA_SD_STORE", "OTA_SEEDER_ONLY"):
|
|
with self.subTest(missing=missing):
|
|
build(self, BUS_TEST, [d for d in DEFINES if d != missing],
|
|
{"SPI.h": SPI_MOCK}, False)
|
|
build(self, BUS_TEST, [d.replace("SPI_INTERFACES_COUNT=1", "SPI_INTERFACES_COUNT=2")
|
|
for d in DEFINES], {"SPI.h": SPI_MOCK}, False)
|
|
|
|
def test_real_context_storage_ownership_service_preemption_and_apply_refusal(self):
|
|
context = CONTEXT.read_text(encoding="utf-8")
|
|
start = context.index(" OtaManager manager;")
|
|
end = context.index(" SignerAllowlist allow;", start)
|
|
signatures = ("OtaStoreSdNrf52& sdStagingStore()", "bool ensureSdCache()",
|
|
"bool setSdCacheEnabled(", "bool sdCacheFetching()",
|
|
"void stopSdCacheFetch()", "void prepareSdCardReset()",
|
|
"void finishSdCardReset(", "void serviceSdCache(",
|
|
"bool apply_fetched_impl(", "bool apply_fetched_bootloader(")
|
|
methods = "\n".join(extract_braced(context, sig) for sig in signatures)
|
|
fixture = (FIXTURES / "context.cpp").read_text()
|
|
build(self, fixture.replace("@MEMBERS@", context[start:end]).replace("@METHODS@", methods),
|
|
["NRF52_PLATFORM", "OTA_SD_STORE", "OTA_SEEDER_ONLY", "OTA_SD_CACHE_MAX=16"])
|
|
|
|
def test_actual_rail_policy_leaves_unswitched_module_off_gps_rail(self):
|
|
source = (ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp").read_text()
|
|
method = extract_braced(source, "uint8_t EnvironmentSensorManager::detectWisBlockStorageHolds()")
|
|
harness = r'''
|
|
#include <helpers/sensors/WisBlockSharedPowerPolicy.h>
|
|
#include <cassert>
|
|
class EnvironmentSensorManager { public: uint8_t detectWisBlockStorageHolds(); };
|
|
@METHOD@
|
|
int main() { EnvironmentSensorManager sensors; assert(sensors.detectWisBlockStorageHolds() == EXPECT_HOLD); }
|
|
'''.replace("@METHOD@", method)
|
|
for defines in (["OTA_SD_STORE", "OTA_RAK15002_SD_CACHE", "EXPECT_HOLD=0"],
|
|
["OTA_SD_STORE", "OTA_RAK15002_SD_STORE", "EXPECT_HOLD=0"],
|
|
["OTA_SD_STORE", "EXPECT_HOLD=mesh::gps::ExternalStorage"],
|
|
["OTA_QSPI_SHARED_WISBLOCK_SPI", "EXPECT_HOLD=mesh::gps::ExternalStorage"],
|
|
["EXPECT_HOLD=0"]):
|
|
with self.subTest(defines=defines):
|
|
build(self, harness, defines)
|
|
|
|
def test_sd_cli_cold_context_oom_validation_and_card_actions(self):
|
|
source = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
|
|
methods = "\n".join(extract_braced(source, signature) for signature in (
|
|
"static void formatSdCardElapsed(", "bool CommonCLI::handleSdCardSetCmd(",
|
|
"bool CommonCLI::handleSdCardGetCmd("))
|
|
fixture = (FIXTURES / "cli.cpp").read_text()
|
|
build(self, fixture.replace("@METHODS@", methods), ["NRF52_PLATFORM", "OTA_SD_STORE"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|