Add guarded nRF SPI startup diagnostic for private 16 MHz trials

This commit is contained in:
mikecarper committed 2026-10-23 02:59:27 -07:00
1 parent cb03417509
commit c83d43e03c
5 files changed
+254 -11

No files matched your search

@@ -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
@@ -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 <helpers/nrf52/SoftDeviceState.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
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<uint32_t>(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
+125
View File
@@ -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 <cstdint>
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 <array>
#include <cassert>
#include <cstdint>
#include <cstring>
#include <helpers/radiolib/NrfRadioSpiStartupDiagHal.h>
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<uint8_t,4> 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<in.size();++i)assert(in[i]==uint8_t(out[i]^0xa5));
// Enabled SoftDevice must refuse the nrfx-workaround frame, report command
// failure throughout the response, and keep the original TX data intact.
test_sd_enabled=1;in.fill(0x77);out[0]=0xc0;
hal.spiTransfer(out.data(),2,in.data());
assert(capture.phase==Refused&&capture.queries==2&&capture.begun==1&&capture.completed==1&&capture.refused==1);
assert(capture.last_query_result==0&&capture.last_query_enabled==1);
assert(capture.last_opcode==0xc0&&capture.last_length==2&&bus.bursts==1);
assert(in[0]==0x2a&&in[1]==0x2a&&in[2]==0x77&&in[3]==0x77&&out[0]==0xc0);
// A failed query is unsafe even when the returned enabled byte is zero.
test_sd_enabled=0;test_sd_result=7;in.fill(0x55);
hal.spiTransfer(out.data(),out.size(),in.data());
assert(capture.phase==Refused&&capture.queries==3&&capture.begun==1&&capture.completed==1&&capture.refused==2);
assert(capture.last_query_result==7&&capture.last_query_enabled==0&&bus.bursts==1);
for(auto byte:in)assert(byte==0x2a);
// No-RX frames are still refused safely; no null pointer is dereferenced.
hal.spiTransfer(out.data(),out.size(),nullptr);
assert(capture.refused==3&&capture.queries==4&&bus.bursts==1);
// A later positively-disabled query allows transfer and preserves the
// original first-query evidence while updating the last-query evidence.
test_sd_result=0;out[0]=0x82;
hal.spiTransfer(out.data(),out.size(),in.data());
assert(capture.phase==Completed&&capture.queries==5&&capture.begun==2&&capture.completed==2&&capture.refused==3);
assert(capture.first_query_result==0&&capture.first_query_enabled==0);
assert(capture.last_query_result==0&&capture.last_query_enabled==0);
assert(capture.last_opcode==0x82&&capture.last_length==4&&bus.bursts==2&&bus.bytes==0);
Capture final;memcpy(&final,(const void*)&capture,sizeof(final));
hal.spiTransfer(nullptr,0,nullptr);
assert(memcmp(&final,(const void*)&capture,sizeof(final))==0&&test_sd_queries==5);
}
'''
class NrfRadioSpiStartupDiagTests(unittest.TestCase):
def test_actual_helper_queries_before_frames_and_refuses_unsafe_workaround(self):
with tempfile.TemporaryDirectory(prefix="nrf-spi-startup-") as directory:
folder=Path(directory);(folder/"hal/Arduino").mkdir(parents=True)
mock=MOCK_HAL.replace("constexpr unsigned MSBFIRST", "void before_test_spi_transfer();\nconstexpr unsigned MSBFIRST")
mock=mock.replace(" ++bursts;", " before_test_spi_transfer();\n ++bursts;")
(folder/"hal/Arduino/ArduinoHal.h").write_text(mock,encoding="ascii")
(folder/"nrf_sdm.h").write_text(MOCK_SDM,encoding="ascii")
source=folder/"test.cpp";source.write_text(HARNESS,encoding="ascii")
binary=folder/"test"
command=[os.environ.get("CXX","c++"),"-std=c++17","-Wall","-Wextra","-Werror",
"-fsanitize=address,undefined","-fno-sanitize-recover=all","-fno-pie","-no-pie",
"-DNRF52_PLATFORM=1","-DMESH_HIL_SPI_STARTUP_DIAG=1",
"-I",str(folder),"-I",str(ROOT/"src"),str(source),str(HELPER),"-o",str(binary)]
built=subprocess.run(command,capture_output=True,text=True)
self.assertEqual(built.returncode,0,built.stderr)
ran=subprocess.run([str(binary)],capture_output=True,text=True)
self.assertEqual(ran.returncode,0,ran.stdout+ran.stderr)
def test_default_off_translation_unit_has_no_code_ram_or_sdk_dependencies(self):
with tempfile.TemporaryDirectory(prefix="nrf-spi-startup-off-") as directory:
output=Path(directory)/"diag.o"
for flags in ([],["-DMESH_HIL_SPI_STARTUP_DIAG=0"]):
built=subprocess.run([os.environ.get("CXX","c++"),"-std=c++17","-Wall","-Wextra","-Werror",
*flags,"-I",str(ROOT/"src"),"-c",str(HELPER),"-o",str(output)],capture_output=True,text=True)
self.assertEqual(built.returncode,0,built.stderr)
symbols=subprocess.run(["nm","--defined-only",str(output)],capture_output=True,text=True)
self.assertEqual(symbols.returncode,0,symbols.stderr)
self.assertEqual(symbols.stdout.strip(),"")
if __name__=="__main__":unittest.main(verbosity=2)
+25 -8
View File
@@ -53,6 +53,7 @@ HARNESS = r'''
#include <array>
#include <initializer_list>
#include <helpers/radiolib/Nrf52BufferedRadioHal.h>
#include <helpers/radiolib/NrfRadioSpiStartupDiagHal.h>
@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 <cstdint>\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)
+15 -3
View File
@@ -3,6 +3,7 @@
#include "OnboardRadioSleep.h"
#include <helpers/ArduinoHelpers.h>
#include <helpers/radiolib/Nrf52BufferedRadioHal.h>
#include <helpers/radiolib/NrfRadioSpiStartupDiagHal.h>
#include <nrf_gpio.h>
#ifdef USE_CC310_HW_CRYPTO
#include <helpers/NRF52Crypto.h>
@@ -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);