From c83d43e03ce7ba2bb1454df8ac91c2c1bd526220 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 23 Oct 2026 02:59:27 -0700 Subject: [PATCH] Add guarded nRF SPI startup diagnostic for private 16 MHz trials --- .../radiolib/NrfRadioSpiStartupDiagHal.cpp | 12 ++ .../radiolib/NrfRadioSpiStartupDiagHal.h | 77 +++++++++++ test/test_nrf_radio_spi_startup_diag.py | 125 ++++++++++++++++++ test/test_rak3401_buffered_radio_hal.py | 33 +++-- variants/rak3401/target.cpp | 18 ++- 5 files changed, 254 insertions(+), 11 deletions(-) create mode 100644 src/helpers/radiolib/NrfRadioSpiStartupDiagHal.cpp create mode 100644 src/helpers/radiolib/NrfRadioSpiStartupDiagHal.h create mode 100644 test/test_nrf_radio_spi_startup_diag.py diff --git a/src/helpers/radiolib/NrfRadioSpiStartupDiagHal.cpp b/src/helpers/radiolib/NrfRadioSpiStartupDiagHal.cpp new file mode 100644 index 000000000..023d1298c --- /dev/null +++ b/src/helpers/radiolib/NrfRadioSpiStartupDiagHal.cpp @@ -0,0 +1,12 @@ +#include "NrfRadioSpiStartupDiagHal.h" + +#if MESH_HIL_SPI_STARTUP_DIAG +namespace mesh { namespace spistartup { +extern "C" { +volatile Capture mesh_hil_spi_startup = { + 0x4d435353, 1, sizeof(Capture), Idle, + 0, UINT32_MAX, UINT32_MAX, UINT32_MAX, UINT32_MAX, + 0, 0, 0, UINT32_MAX, 0}; +} +} } +#endif diff --git a/src/helpers/radiolib/NrfRadioSpiStartupDiagHal.h b/src/helpers/radiolib/NrfRadioSpiStartupDiagHal.h new file mode 100644 index 000000000..1eb477b7b --- /dev/null +++ b/src/helpers/radiolib/NrfRadioSpiStartupDiagHal.h @@ -0,0 +1,77 @@ +#pragma once + +#ifndef MESH_HIL_SPI_STARTUP_DIAG +#define MESH_HIL_SPI_STARTUP_DIAG 0 +#endif +#if MESH_HIL_SPI_STARTUP_DIAG != 0 && MESH_HIL_SPI_STARTUP_DIAG != 1 +#error "SPI startup diagnostic flag must be 0 or 1" +#endif + +#if MESH_HIL_SPI_STARTUP_DIAG +#if !defined(NRF52_PLATFORM) +#error "SPI startup diagnostic requires nRF52" +#endif + +#include "Nrf52BufferedRadioHal.h" +#include +#include +#include +#include + +namespace mesh { namespace spistartup { + +enum Phase : uint32_t { Idle = 0, Query = 1, Transfer = 2, Completed = 3, Refused = 4 }; + +// Fixed 14-word ABI for private SWD reads, including before USB is usable. +// Each word is independently observable; this is not an atomic snapshot. +struct Capture { + uint32_t magic, version, size_bytes, phase; + uint32_t queries, first_query_result, first_query_enabled; + uint32_t last_query_result, last_query_enabled; + uint32_t begun, completed, refused, last_opcode, last_length; +}; +static_assert(sizeof(Capture) == 56 && offsetof(Capture, begun) == 36 + && offsetof(Capture, last_length) == 52, "SPI startup capture ABI changed"); +extern "C" { +extern volatile Capture mesh_hil_spi_startup; +} + +} } // namespace mesh::spistartup + +// Diagnostic only: the global nrfx anomaly-198 workaround cannot coexist +// with SoftDevice. Refuse the actual frame unless its disabled state was +// positively established by the existing LTO-safe supervisor query. +class NrfRadioSpiStartupDiagHal : public Nrf52BufferedRadioHal { +public: + explicit NrfRadioSpiStartupDiagHal(SPIClass& bus, uint32_t hz) + : Nrf52BufferedRadioHal(bus, hz) {} + + void spiTransfer(uint8_t* out, size_t len, uint8_t* in) override { + if (!len) return; + auto& capture = mesh::spistartup::mesh_hil_spi_startup; + capture.last_opcode = out ? out[0] : UINT32_MAX; + capture.last_length = static_cast(len); + capture.phase = mesh::spistartup::Query; + uint8_t enabled = 0; + const uint32_t result = mesh_nrf52::softdeviceIsEnabled(enabled); + if (!capture.queries) { + capture.first_query_result = result; + capture.first_query_enabled = enabled; + } + ++capture.queries; + capture.last_query_result = result; + capture.last_query_enabled = enabled; + if (result != 0 || enabled != 0) { + if (in) memset(in, 0x2a, len); // SX126x command-failed status. + ++capture.refused; + capture.phase = mesh::spistartup::Refused; + return; + } + ++capture.begun; + capture.phase = mesh::spistartup::Transfer; + Nrf52BufferedRadioHal::spiTransfer(out, len, in); + ++capture.completed; + capture.phase = mesh::spistartup::Completed; + } +}; +#endif diff --git a/test/test_nrf_radio_spi_startup_diag.py b/test/test_nrf_radio_spi_startup_diag.py new file mode 100644 index 000000000..9a112ae60 --- /dev/null +++ b/test/test_nrf_radio_spi_startup_diag.py @@ -0,0 +1,125 @@ +"""Execute the actual default-off SPI startup capture and refusal gate.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +from test_rak3401_buffered_radio_hal import MOCK_HAL + + +ROOT = Path(__file__).resolve().parents[1] +HELPER = ROOT / "src/helpers/radiolib/NrfRadioSpiStartupDiagHal.cpp" + +MOCK_SDM = r''' +#pragma once +#include +extern uint32_t test_sd_result, test_sd_queries; +extern uint8_t test_sd_enabled; +uint32_t sd_softdevice_is_enabled(uint8_t* state); +''' + +HARNESS = r''' +#include +#include +#include +#include +#include +uint32_t test_sd_result=0,test_sd_queries=0; +uint8_t test_sd_enabled=0; +using namespace mesh::spistartup; +uint32_t sd_softdevice_is_enabled(uint8_t* state) { + assert(mesh_hil_spi_startup.phase==Query); + ++test_sd_queries;*state=test_sd_enabled;return test_sd_result; +} +void before_test_spi_transfer() { + const auto& capture=mesh_hil_spi_startup; + assert(capture.phase==Transfer&&capture.begun==capture.completed+1); + assert(capture.queries==test_sd_queries); + assert(capture.last_query_result==0&&capture.last_query_enabled==0); +} +int main() { + SPIClass bus;NrfRadioSpiStartupDiagHal concrete(bus,16000000); + ArduinoHal& hal=concrete; + auto& capture=mesh_hil_spi_startup; + assert(capture.magic==0x4d435353&&capture.version==1&&capture.size_bytes==56); + assert(capture.phase==Idle&&capture.queries==0&&capture.begun==0&&capture.completed==0&&capture.refused==0); + assert(capture.first_query_result==UINT32_MAX&&capture.first_query_enabled==UINT32_MAX); + assert(concrete.spiSettings.hz==16000000&&concrete.spiSettings.order==MSBFIRST&&concrete.spiSettings.mode==SPI_MODE0); + Capture initial;memcpy(&initial,(const void*)&capture,sizeof(initial)); + // A zero-size call must not touch capture, query the supervisor, or use SPI. + hal.spiTransfer(nullptr,0,nullptr); + assert(memcmp(&initial,(const void*)&capture,sizeof(initial))==0&&test_sd_queries==0&&bus.bursts==0); + std::array out={0x83,0x11,0x22,0x33},in{}; + const auto original=out; + // Positively disabled permits exactly one buffered full-duplex frame. + hal.spiTransfer(out.data(),out.size(),in.data()); + assert(capture.phase==Completed&&capture.queries==1&&capture.begun==1&&capture.completed==1&&capture.refused==0); + assert(capture.first_query_result==0&&capture.first_query_enabled==0); + assert(capture.last_opcode==0x83&&capture.last_length==4&&bus.bursts==1&&bus.bytes==0); + assert(out==original); + for(unsigned i=0;i #include #include +#include @CLOCK_POLICY@ SPIClass SPI, SPI1; constexpr unsigned P_LORA_NSS=1, P_LORA_DIO_1=2, P_LORA_RESET=3, P_LORA_BUSY=4; @@ -97,15 +98,18 @@ int main() { ''' +def construction_from_target(target): + start=target.index("#if MESH_HIL_SPI_STARTUP_DIAG",target.index("RAK3401Board board;")) + end=target.index("RADIO_CLASS radio = new Module(",start) + return target[start:target.index(";",end)+1] + + class Rak3401BufferedRadioHalTests(unittest.TestCase): def test_actual_target_uses_one_transfer_per_command_on_same_spi1_at_8mhz(self): target = (ROOT / "variants/rak3401/target.cpp").read_text(encoding="ascii") clock_policy = target[target.index("#ifndef MC_RAK3401_SPI_HZ"): target.index("\nnamespace {")] - construction = "\n".join(re.findall( - r"^(?:static Nrf52BufferedRadioHal radioHal\([^;]+;|" - r"RADIO_CLASS radio = new Module\([^;]+;)$", target, re.MULTILINE)) - self.assertEqual(len(construction.splitlines()), 2) + construction = construction_from_target(target) helper = (ROOT / "src/helpers/radiolib/Nrf52BufferedRadioHal.h").read_text( encoding="ascii") # Removing the real override reinstates ArduinoHal's per-byte path. @@ -128,7 +132,8 @@ class Rak3401BufferedRadioHalTests(unittest.TestCase): (base / "helpers/radiolib/Nrf52BufferedRadioHal.h").write_text( implementation, encoding="ascii") built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", - "-Werror", "-I", str(base), str(cpp), "-o", str(executable)], + "-Werror", "-I", str(base), "-I", str(ROOT / "src"), + "-I",str(ROOT / "src/helpers/radiolib"),str(cpp), "-o", str(executable)], capture_output=True, text=True, timeout=60) self.assertEqual(built.returncode, 0, built.stdout + built.stderr) ran = subprocess.run([str(executable)], capture_output=True, @@ -143,14 +148,15 @@ class Rak3401BufferedRadioHalTests(unittest.TestCase): target = (ROOT / "variants/rak3401/target.cpp").read_text(encoding="ascii") policy = target[target.index("#ifndef MC_RAK3401_SPI_HZ"): target.index("\nnamespace {")] - construction = "\n".join(re.findall( - r"^(?:static Nrf52BufferedRadioHal radioHal\([^;]+;|" - r"RADIO_CLASS radio = new Module\([^;]+;)$", target, re.MULTILINE)) + construction = construction_from_target(target) fixture = HARNESS.replace("@CLOCK_POLICY@", policy).replace("@CONSTRUCTION@", construction) with tempfile.TemporaryDirectory() as directory: folder = Path(directory) (folder / "hal/Arduino").mkdir(parents=True) (folder / "hal/Arduino/ArduinoHal.h").write_text(MOCK_HAL, encoding="ascii") + (folder / "nrf_sdm.h").write_text( + "#pragma once\n#include \ninline uint32_t sd_softdevice_is_enabled(uint8_t* enabled)" + "{*enabled=0;return 0;}\n",encoding="ascii") cpp, binary = folder / "test.cpp", folder / "test.exe" cpp.write_text(fixture, encoding="ascii") cases = [([], True)] + [ @@ -166,11 +172,22 @@ class Rak3401BufferedRadioHalTests(unittest.TestCase): "NRFX_SPIM3_NRF52840_ANOMALY_198_WORKAROUND_ENABLED=0"], False), (["MC_RAK3401_SPI_HZ=12000000"], False), (["MC_RAK3401_SPI_HZ=32000000"], False)] + diagnostic=["MC_RAK3401_SPI_HZ=16000000","MESH_HIL_SPI_STARTUP_DIAG=1", + "MESH_HIL_PROFILE_TIMING=1","SPI_32MHZ_INTERFACE=1", + "NRFX_SPIM3_NRF52840_ANOMALY_198_WORKAROUND_ENABLED=1","NRF52_PLATFORM=1"] + cases.append((diagnostic,True)) + for required in diagnostic[1:-1]: + cases.append(([flag for flag in diagnostic if flag!=required],False)) + cases.append(([flag if flag!="MESH_HIL_PROFILE_TIMING=1" else "MESH_HIL_PROFILE_TIMING=0" + for flag in diagnostic],False)) + cases.append(([flag if flag!="MC_RAK3401_SPI_HZ=16000000" else "MC_RAK3401_SPI_HZ=32000000" + for flag in diagnostic],False)) for flags, valid in cases: with self.subTest(flags=flags): built = subprocess.run([os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", *["-D" + flag for flag in flags], "-I", str(folder), "-I", str(ROOT / "src"), str(cpp), + str(ROOT/"src/helpers/radiolib/NrfRadioSpiStartupDiagHal.cpp"), "-o", str(binary)], capture_output=True, text=True) if valid: self.assertEqual(built.returncode, 0, built.stderr) diff --git a/variants/rak3401/target.cpp b/variants/rak3401/target.cpp index 115110c8d..23f8237e2 100644 --- a/variants/rak3401/target.cpp +++ b/variants/rak3401/target.cpp @@ -3,6 +3,7 @@ #include "OnboardRadioSleep.h" #include #include +#include #include #ifdef USE_CC310_HW_CRYPTO #include @@ -12,7 +13,13 @@ #define MC_RAK3401_SPI_HZ 8000000 #endif #if MC_RAK3401_SPI_HZ > 8000000 -#error "RAK3401 SPI above 8 MHz needs an unimplemented SoftDevice-compatible SPIM3 mitigation" +#if MC_RAK3401_SPI_HZ != 16000000 || !MESH_HIL_SPI_STARTUP_DIAG \ + || !defined(MESH_HIL_PROFILE_TIMING) || MESH_HIL_PROFILE_TIMING != 1 \ + || !defined(SPI_32MHZ_INTERFACE) || SPI_32MHZ_INTERFACE != 1 \ + || !defined(NRFX_SPIM3_NRF52840_ANOMALY_198_WORKAROUND_ENABLED) \ + || NRFX_SPIM3_NRF52840_ANOMALY_198_WORKAROUND_ENABLED != 1 +#error "RAK3401 SPI above 8 MHz requires the explicit 16 MHz startup diagnostic and nrfx workaround" +#endif #elif MC_RAK3401_SPI_HZ != 2000000 && MC_RAK3401_SPI_HZ != 4000000 \ && MC_RAK3401_SPI_HZ != 8000000 #error "RAK3401 radio SPI clock must be 2, 4 or 8 MHz" @@ -136,10 +143,15 @@ RAK3401Board board; // controller instance for startup and watchdog recovery; resetting the radio // must not replace or tear down the SPI object used by its Module. // Batch each RadioLib command into one SPIM transfer rather than starting a -// transfer per byte. Keep 8 MHz by default on SPI1/SPIM2. Higher speeds need -// a separate SPIM3/SoftDevice mitigation before they can be offered safely. +// transfer per byte. Keep 8 MHz by default on SPI1/SPIM2. The explicit HIL-only +// 16 MHz startup diagnostic refuses frames unless SoftDevice is disabled; +// this is not a production SPIM3/SoftDevice mitigation. // Changing the speed never replaces this bus or its recovery/handoff path. +#if MESH_HIL_SPI_STARTUP_DIAG +static NrfRadioSpiStartupDiagHal radioHal(SPI1, MC_RAK3401_SPI_HZ); +#else static Nrf52BufferedRadioHal radioHal(SPI1, MC_RAK3401_SPI_HZ); +#endif RADIO_CLASS radio = new Module(&radioHal, P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); WRAPPER_CLASS radio_driver(radio, board);