Reserve configured NOR pins from user GPIO control

This commit is contained in:
mikecarper committed 2026-10-16 15:09:46 -07:00
1 parent 9e4891989f
commit 4865162fb4
4 files changed
+211

No files matched your search

+1
View File
@@ -754,6 +754,7 @@ jobs:
python3 -B test/test_ethernet_terminal_shutdown.py -v
python3 -B test/test_rak_sd_receiver.py -v
python3 -B test/test_rak15002_shared_spi.py -v
python3 -B test/test_rak_nor_gpio_reservations.py -v
python3 -B test/test_rak_companion_ethernet_worker.py -v
python3 -B test/test_rak_sd_context_handoff.py -v
python3 -B test/test_rak_sd_room_ethernet.py -v
+23
View File
@@ -540,6 +540,29 @@ inline bool isFirmwareReserved(uint8_t pin) {
#ifdef SDCARD_CS
SDCARD_CS,
#endif
#if defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE)
// The raw NOR backend owns these configured pads even while its bus is
// released or no matching chip was found. User GPIO/timers must not
// defeat a later OTA storage claim or drive an installed flash's CS.
#ifdef OTA_QSPI_SCK_ARDUINO_PIN
OTA_QSPI_SCK_ARDUINO_PIN,
#endif
#ifdef OTA_QSPI_CS_ARDUINO_PIN
OTA_QSPI_CS_ARDUINO_PIN,
#endif
#ifdef OTA_QSPI_IO0_ARDUINO_PIN
OTA_QSPI_IO0_ARDUINO_PIN,
#endif
#ifdef OTA_QSPI_IO1_ARDUINO_PIN
OTA_QSPI_IO1_ARDUINO_PIN,
#endif
#if defined(OTA_QSPI_IO2_ARDUINO_PIN) && !defined(OTA_QSPI_IO2_NOT_CONNECTED)
OTA_QSPI_IO2_ARDUINO_PIN,
#endif
#if defined(OTA_QSPI_IO3_ARDUINO_PIN) && !defined(OTA_QSPI_IO3_NOT_CONNECTED)
OTA_QSPI_IO3_ARDUINO_PIN,
#endif
#endif
#ifdef SENSOR_POWER_CTRL_PIN
SENSOR_POWER_CTRL_PIN,
#endif
+96
View File
@@ -0,0 +1,96 @@
#include <Arduino.h>
#include <variant.h>
#include <helpers/UserGpio.h>
#include <UserGpioPinPolicy.h>
#include <cassert>
#include <cstdio>
#include <cstring>
#include <initializer_list>
// Actual RAK4631 variant.cpp maps Arduino pins 0..47 directly to P0/P1.
static uint8_t digitalPinToPinName(uint8_t pin) { return pin; }
class NRF52Board : public mesh::MainBoard {
public:
uint16_t getBattMilliVolts() override { return 0; }
const char* getManufacturerName() const override { return "RAK test"; }
void reboot() override {}
uint8_t getStartupReason() const override { return BD_STARTUP_NORMAL; }
bool isUserGpioAvailable(uint8_t pin) const override;
};
@EXPOSED@
@APPROVAL@
static unsigned scenarios = 0;
static void refused(NRF52Board& board, UserGpio& gpio, uint8_t pin) {
assert(!board.isUserGpioAvailable(pin));
for (const char* action : {"on", "off", "reset", "on 30 off", "off 1 reset"}) {
uint8_t levels[64], modes[64];
memcpy(levels, g_mock_pin_levels, sizeof(levels));
memcpy(modes, g_mock_pin_modes, sizeof(modes));
const unsigned writes = gpio_writes, directions = gpio_modes;
char args[64], reply[160] = {};
snprintf(args, sizeof(args), "%u %s", unsigned(pin), action);
assert(gpio.handleSet(args, reply, sizeof(reply)).outcome == UserGpio::SetResult::ERROR);
assert(!strncmp(reply, "Error:", 6));
assert(gpio_writes == writes && gpio_modes == directions);
assert(!memcmp(levels, g_mock_pin_levels, sizeof(levels)));
assert(!memcmp(modes, g_mock_pin_modes, sizeof(modes)));
assert(!gpio.hasActiveTimer());
UserGpio::Completion completion;
assert(!gpio.takeCompletion(completion));
++scenarios;
}
}
static void available(NRF52Board& board, UserGpio& gpio, uint8_t pin) {
assert(board.isUserGpioAvailable(pin));
char args[64], reply[160] = {};
for (const char* action : {"on", "off", "reset"}) {
snprintf(args, sizeof(args), "%u %s", unsigned(pin), action);
assert(gpio.handleSet(args, reply, sizeof(reply)).outcome == UserGpio::SetResult::APPLIED);
assert(!strncmp(reply, "OK", 2));
assert(g_mock_pin_modes[pin] == (!strcmp(action, "reset") ? INPUT : OUTPUT));
if (strcmp(action, "reset"))
assert(g_mock_pin_levels[pin] == (!strcmp(action, "on") ? HIGH : LOW));
++scenarios;
}
}
int main() {
NRF52Board board;
UserGpio gpio(board);
// Shared supply and existing SD bus claims remain protected independently
// of the new NOR policy. Refusal must perform no pin writes or timer work.
for (uint8_t pin : {WB_IO2, WB_IO4, WB_IO5}) refused(board, gpio, pin);
#if defined(OTA_RAK15002_SD_STORE)
for (uint8_t pin : {3, 10, 26, 29, 30}) refused(board, gpio, pin);
#endif
#if defined(TEST_ARBITRARY)
for (uint8_t pin : {OTA_QSPI_SCK_ARDUINO_PIN, OTA_QSPI_CS_ARDUINO_PIN,
OTA_QSPI_IO0_ARDUINO_PIN, OTA_QSPI_IO1_ARDUINO_PIN,
OTA_QSPI_IO2_ARDUINO_PIN, OTA_QSPI_IO3_ARDUINO_PIN}) {
#if defined(TEST_DISCONNECTED_OVERRIDES)
if (pin == OTA_QSPI_IO2_ARDUINO_PIN || pin == OTA_QSPI_IO3_ARDUINO_PIN) {
available(board, gpio, pin);
continue;
}
#endif
#if TEST_EXPECT_NOR
refused(board, gpio, pin);
#else
available(board, gpio, pin);
#endif
}
// No blanket GPIO31 ban: this override does not use it as a NOR pad.
available(board, gpio, 31);
#elif TEST_EXPECT_NOR
digitalWrite(31, HIGH); pinMode(31, OUTPUT); // Live NOR initially deselected.
refused(board, gpio, 31);
assert(g_mock_pin_levels[31] == HIGH && g_mock_pin_modes[31] == OUTPUT);
#else
// The same valid board GPIO remains usable when no NOR backend is compiled.
available(board, gpio, 31);
#endif
available(board, gpio, 33); // Unrelated exposed GPIO is never removed.
printf("%u RAK NOR GPIO ownership scenarios passed\n", scenarios);
}
+91
View File
@@ -0,0 +1,91 @@
"""Run actual RAK board GPIO approval and commands with NOR pin ownership."""
from pathlib import Path
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = ROOT / 'test/fixtures/rak_nor_gpio_reservations.cpp'
POLICY = ROOT / 'src/helpers/UserGpioPinPolicy.h'
ARDUINO = r'''
#pragma once
#include <cassert>
#include <cstdint>
constexpr uint8_t LOW=0,HIGH=1,INPUT=0,OUTPUT=1,INPUT_PULLUP=2,INPUT_PULLDOWN=3;
inline uint8_t g_mock_pin_modes[64]={},g_mock_pin_levels[64]={};
inline unsigned gpio_writes=0,gpio_modes=0;
inline uint32_t g_mock_millis=0;
inline uint32_t millis(){return g_mock_millis;}
inline void pinMode(uint8_t p,uint8_t m){assert(p<64);++gpio_modes;g_mock_pin_modes[p]=m;}
inline void digitalWrite(uint8_t p,uint8_t v){assert(p<64);++gpio_writes;g_mock_pin_levels[p]=v;}
inline int digitalRead(uint8_t p){assert(p<64);return g_mock_pin_levels[p];}
'''
class RakNorGpioReservationsTest(unittest.TestCase):
def test_real_board_and_gpio_preserve_nor_pin_ownership(self):
compiler = os.environ.get('CXX') or shutil.which('g++') or shutil.which('clang++')
self.assertIsNotNone(compiler, 'host C++17 compiler required')
board = (ROOT / 'src/helpers/NRF52Board.cpp').read_text(encoding='ascii')
fixture = FIXTURE.read_text(encoding='ascii')
fixture = fixture.replace('@EXPOSED@', extract_braced(board, 'bool isExposedNrf52UserGpio('))
fixture = fixture.replace('@APPROVAL@', extract_braced(board, 'bool NRF52Board::isUserGpioAvailable('))
shared = ('RAK_4631=1', 'PIN_BOARD_SDA=13', 'PIN_BOARD_SCL=14',
'PIN_GPS_EN=-1', 'PIN_VBAT_READ=5', 'USER_GPIO_CONTROL=1')
normal = ('OTA_RAK15002_SD_STORE=1', 'OTA_SD_STORE=1',
'OTA_QSPI_SCK_ARDUINO_PIN=3', 'OTA_QSPI_CS_ARDUINO_PIN=31',
'OTA_QSPI_IO0_ARDUINO_PIN=30', 'OTA_QSPI_IO1_ARDUINO_PIN=29',
'OTA_QSPI_IO2_NOT_CONNECTED=1', 'OTA_QSPI_IO3_NOT_CONNECTED=1')
arbitrary = ('TEST_ARBITRARY=1', 'OTA_QSPI_SCK_ARDUINO_PIN=19',
'OTA_QSPI_CS_ARDUINO_PIN=2', 'OTA_QSPI_IO0_ARDUINO_PIN=20',
'OTA_QSPI_IO1_ARDUINO_PIN=21', 'OTA_QSPI_IO2_ARDUINO_PIN=24',
'OTA_QSPI_IO3_ARDUINO_PIN=25')
variants = (
('normal_sensor_nor', normal + ('NRF52_PLATFORM=1', 'OTA_QSPI_STORE=1',
'OTA_RAK_AUTO_STORE=1', 'TEST_EXPECT_NOR=1'), 48),
('normal_sensor_without_nor', normal + ('NRF52_PLATFORM=1', 'TEST_EXPECT_NOR=0'), 46),
('wrong_backend_does_not_claim', normal + ('OTA_QSPI_STORE=1', 'TEST_EXPECT_NOR=0'), 46),
('arbitrary_six_nor_pads', arbitrary + ('NRF52_PLATFORM=1', 'OTA_QSPI_STORE=1',
'TEST_EXPECT_NOR=1'), 51),
('arbitrary_without_backend', arbitrary + ('NRF52_PLATFORM=1', 'TEST_EXPECT_NOR=0'), 39),
# Policy-only robustness control: ignored explicit pads must not
# become user reservations. The storage backend independently
# rejects contradictory connected/disconnected configuration.
('disconnected_explicit_io2_io3', arbitrary + ('NRF52_PLATFORM=1',
'OTA_QSPI_STORE=1', 'TEST_EXPECT_NOR=1', 'TEST_DISCONNECTED_OVERRIDES=1',
'OTA_QSPI_IO2_NOT_CONNECTED=1', 'OTA_QSPI_IO3_NOT_CONNECTED=1'), 47),
)
sanitizer = (['-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie'] if sys.platform.startswith('linux') else [])
with tempfile.TemporaryDirectory(prefix='rak-nor-gpio-reservations-') as folder:
work = Path(folder)
(work / 'WVariant.h').write_text('#pragma once\n#include <cstdint>\n', encoding='ascii')
(work / 'Arduino.h').write_text(ARDUINO, encoding='ascii')
# Preserve the header unchanged; its quoted sensor include resolves
# against the real helper directory after this private directory.
(work / 'UserGpioPinPolicy.h').write_bytes(POLICY.read_bytes())
(work / 'test.cpp').write_text(fixture, encoding='ascii')
for name, macros, scenarios in variants:
with self.subTest(variant=name):
binary = work / (name + ('.exe' if os.name == 'nt' else ''))
command = [compiler, '-std=c++17', '-Wall', '-Wextra', '-Werror',
'-Wno-unused-parameter', *sanitizer,
*['-D' + x for x in shared + macros], '-I' + str(work),
'-I' + str(ROOT / 'src/helpers'), '-I' + str(ROOT / 'src'),
'-I' + str(ROOT / 'variants/rak4631'),
str(work / 'test.cpp'), str(ROOT / 'src/helpers/UserGpio.cpp'),
'-o', str(binary)]
built = subprocess.run(command, capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
tested = subprocess.run([str(binary)], capture_output=True, text=True, timeout=15)
self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr)
self.assertIn(f'{scenarios} RAK NOR GPIO ownership scenarios passed', tested.stdout)
if __name__ == '__main__':
unittest.main()