From 4865162fb4fbdeb88ba916519397e98f7d12c215 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 16 Oct 2026 15:09:46 -0700 Subject: [PATCH] Reserve configured NOR pins from user GPIO control --- .github/workflows/run-unit-tests.yml | 1 + src/helpers/UserGpioPinPolicy.h | 23 +++++ test/fixtures/rak_nor_gpio_reservations.cpp | 96 +++++++++++++++++++++ test/test_rak_nor_gpio_reservations.py | 91 +++++++++++++++++++ 4 files changed, 211 insertions(+) create mode 100644 test/fixtures/rak_nor_gpio_reservations.cpp create mode 100644 test/test_rak_nor_gpio_reservations.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index cb31da15d..6c57609eb 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -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 diff --git a/src/helpers/UserGpioPinPolicy.h b/src/helpers/UserGpioPinPolicy.h index a1bb59f72..9a2d1376d 100644 --- a/src/helpers/UserGpioPinPolicy.h +++ b/src/helpers/UserGpioPinPolicy.h @@ -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 diff --git a/test/fixtures/rak_nor_gpio_reservations.cpp b/test/fixtures/rak_nor_gpio_reservations.cpp new file mode 100644 index 000000000..b35b03499 --- /dev/null +++ b/test/fixtures/rak_nor_gpio_reservations.cpp @@ -0,0 +1,96 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +// 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); +} diff --git a/test/test_rak_nor_gpio_reservations.py b/test/test_rak_nor_gpio_reservations.py new file mode 100644 index 000000000..c29e61e1b --- /dev/null +++ b/test/test_rak_nor_gpio_reservations.py @@ -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 +#include +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 \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()