From 3d04bb30483fc67a328f53a48c97d330681341cd Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 17 Oct 2026 17:02:54 -0700 Subject: [PATCH] Sleep unused onboard SX1262 in RAK13302 builds Adapt the optional onboard-radio probe for RAK3401 and RAK4631 cores using the external RAK13302 image. Preserve SPI1 radio ownership, external FEM startup, and ordinary RAK4631 primary-radio operation. Probe before boot voltage protection, bound missing-chip waits, release temporary pads, and confirm cold sleep without waking the chip. Add sanitizer-backed host regression tests and CI coverage. --- .github/workflows/run-unit-tests.yml | 1 + docs/power_saving_diagnostics.md | 29 ++ test/test_rak3401_onboard_radio.py | 481 +++++++++++++++++++++++++++ variants/rak3401/OnboardRadioSleep.h | 38 +++ variants/rak3401/RAK3401Board.cpp | 4 + variants/rak3401/target.cpp | 101 ++++++ variants/rak3401/variant.h | 12 + 7 files changed, 666 insertions(+) create mode 100644 test/test_rak3401_onboard_radio.py create mode 100644 variants/rak3401/OnboardRadioSleep.h diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 0d0138dca..e7d3cb022 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -610,6 +610,7 @@ jobs: python3 -B test/test_gps_sync_preferences.py -v python3 -B test/test_rak_gps_power.py -v python3 -B test/test_rak_shutdown_power.py -v + python3 -B test/test_rak3401_onboard_radio.py -v python3 -B test/test_xiao_battery_safety.py -v python3 -B test/test_peripheral_mode_prefs.py -v python3 -B test/test_t1000e_sensor_power.py -v diff --git a/docs/power_saving_diagnostics.md b/docs/power_saving_diagnostics.md index 9a5cad47f..6f6f5d677 100644 --- a/docs/power_saving_diagnostics.md +++ b/docs/power_saving_diagnostics.md @@ -106,6 +106,35 @@ The [nRF52 idle-sleep investigation](nrf52_sleep_event_validation.md) describes an eligible-but-not-sleeping regression, its fix, controlled hardware evidence and the limits of CPU-state sampling without a current meter. +## RAK13302 builds with an unused onboard radio + +The RAK3401/external RAK13302 firmware now probes the private onboard-radio +bus once during startup. This supports both a RAK3401 core (no onboard SX1262) +and a RAK4631 core using the external RAK13302. It is adapted from +[agessaman's optional-radio sleep change](https://github.com/agessaman/MeshCore/tree/feat/rak13302-auto-onboard-sleep). +[RAK's internal connection table](https://docs.rakwireless.com/product-categories/wisblock/rak4631/datasheet/#internal-connections-between-nrf52840-mcu-and-sx1262-lora-transceiver) +identifies the private SPI, reset, BUSY and RF-switch pins used by the probe. + +A matching six-byte chip signature triggers cold sleep with the RTC disabled. +The RF switch stays off, NSS/reset stay high, and the temporary SPI/data/BUSY +pads are released. Sleep confirmation reads BUSY only: another SPI transaction +would wake the chip. Each BUSY wait is bounded to 20 ms, including when no chip +is fitted. A timeout or mismatched signature does not block startup of the +external radio. Debug-enabled startup logging reports `asleep`, `absent`, +`busy-timeout` or `sleep-failed` when the USB debug output is available; a +missing chip normally produces `busy-timeout`, not a verified sleep result. + +This happens before the boot-voltage check, so a low-voltage shutdown cannot +skip the unused-radio sleep attempt. It does not cycle the shared GPS/storage +rail, change external-radio recovery, or enable its FEM before initialization +succeeds. There is no runtime probing or automatic primary-radio selection. + +Ordinary **RAK4631 firmware is unchanged**: its onboard SX1262 is the active +primary radio and must not be disabled. Use the external RAK13302 image for +that hardware arrangement, not a normal RAK4631 image. This startup change has +host regression tests; sleep-current improvement still needs matched current +measurements on the physical core/module combination. + ## Board regulator initialization RAK4631 Companion uses the same nRF52840 REG1 DC/DC initialization as the other diff --git a/test/test_rak3401_onboard_radio.py b/test/test_rak3401_onboard_radio.py new file mode 100644 index 000000000..21bc60ba8 --- /dev/null +++ b/test/test_rak3401_onboard_radio.py @@ -0,0 +1,481 @@ +#!/usr/bin/env python3 +"""Exercise the optional RAK4631 radio probe on the host, without PIO or RF.""" + +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 + + +ROOT = Path(__file__).resolve().parents[1] +VARIANT = ROOT / "variants/rak3401" + + +def numeric_define(source, name): + match = re.search(rf"^\s*#define\s+{name}\s+\(?\s*(\d+)\s*\)?", source, re.MULTILINE) + if match is None: + raise AssertionError(f"missing numeric definition for {name}") + return int(match.group(1)) + + +def compile_source(testcase, source, *, expect_success=True): + compiler = shutil.which("c++") or shutil.which("g++") + testcase.assertIsNotNone(compiler, "this host-only suite requires a C++ compiler") + with tempfile.TemporaryDirectory(prefix="rak3401-onboard-radio-") as folder: + work = Path(folder) + generated = work / "case.cpp" + generated.write_text(source, encoding="ascii") + binary = work / ("case.exe" if os.name == "nt" else "case") + command = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-I", str(ROOT), str(generated), "-o", str(binary)] + if sys.platform.startswith("linux"): + command[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"] + built = subprocess.run(command, capture_output=True, text=True, timeout=60) + testcase.assertEqual(built.returncode, 0, built.stdout + built.stderr) + ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + if expect_success: + testcase.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + else: + testcase.assertNotEqual(ran.returncode, 0, "negative control did not fail") + + +def onboard_defines(): + variant = (VARIANT / "variant.h").read_text() + return "\n".join(f"#define ONBOARD_LORA_{name} {numeric_define(variant, 'ONBOARD_LORA_' + name)}" + for name in ("NSS", "SCLK", "MOSI", "MISO", "BUSY", "RESET", "ANT_SW")) + + +ADAPTER_MOCKS = r''' +#include +#include +#include +#include +#include +#include +#include "variants/rak3401/OnboardRadioSleep.h" +#define HIGH 1 +#define LOW 0 +#define INPUT 0 +#define OUTPUT 1 +#define INPUT_PULLUP 2 +#define INPUT_PULLDOWN 3 +#define MSBFIRST 1 +#define SPI_MODE0 0 +#define RADIOLIB_SX126X_CMD_READ_REGISTER 0x1d +#define RADIOLIB_SX126X_REG_VERSION_STRING 0x0320 +#define RADIOLIB_SX126X_CMD_SET_SLEEP 0x84 +#define RADIOLIB_SX126X_SLEEP_START_COLD 0 +#define RADIOLIB_SX126X_SLEEP_RTC_OFF 0 +#define RADIOLIB_SX1262_CHIP_TYPE "SX1261" +template void debug(const char*, Args...) {} +#define MESH_DEBUG_PRINTLN(...) debug(__VA_ARGS__) + +enum class Scenario { Present, MissingBusy, WrongVersion, ZeroVersion, + FloatingVersion, BusyAfterRead, SleepRejected }; +struct Event { std::string name; unsigned pin; int value; }; +struct Hardware { + Scenario scenario = Scenario::Present; + uint32_t clock = 0; + uint32_t elapsed = 0; + std::array modes; + std::array levels; + std::array defaults{}; + std::vector events; + std::vector> frames; + std::vector frame; + bool version_read = false; + bool sleep_issued = false; + bool reset_low_seen = false; + bool transaction = false; + Hardware() { modes.fill(-1); levels.fill(-1); } +} hw; +std::array pin_map() { + std::array pins{}; + for (unsigned i = 0; i < pins.size(); ++i) pins[i] = i; + return pins; +} +const std::array g_ADigitalPinMap = pin_map(); +void event(const char* name, unsigned pin = 0, int value = 0) { + hw.events.push_back({name, pin, value}); +} +void pinMode(unsigned pin, int mode) { + hw.modes.at(pin) = mode; + event("mode", pin, mode); +} +void digitalWrite(unsigned pin, int value) { + if (pin == ONBOARD_LORA_RESET && value == LOW) { + assert(hw.modes.at(pin) == OUTPUT && hw.levels.at(pin) == HIGH); + hw.reset_low_seen = true; + } + if (pin == ONBOARD_LORA_NSS && value == LOW) { + assert(hw.transaction && !hw.sleep_issued); + } + if (pin == ONBOARD_LORA_NSS && value == HIGH && hw.transaction && + !hw.frame.empty() && hw.frame.front() == RADIOLIB_SX126X_CMD_SET_SLEEP) { + assert((hw.frame == std::vector{RADIOLIB_SX126X_CMD_SET_SLEEP, 0})); + assert(hw.modes.at(ONBOARD_LORA_BUSY) == INPUT); + assert(hw.modes.at(ONBOARD_LORA_MISO) == INPUT); + hw.sleep_issued = true; + } + hw.levels.at(pin) = value; + event("write", pin, value); +} +int digitalRead(unsigned pin) { + assert(pin == ONBOARD_LORA_BUSY); + assert(hw.modes.at(pin) == INPUT || hw.modes.at(pin) == INPUT_PULLUP); + event("read", pin); + if (hw.scenario == Scenario::MissingBusy) return HIGH; + if (hw.sleep_issued) return hw.scenario == Scenario::SleepRejected ? LOW : HIGH; + if (hw.version_read && hw.scenario == Scenario::BusyAfterRead) return HIGH; + return LOW; +} +uint32_t millis() { return hw.clock; } +void delay(uint32_t interval) { + hw.clock += interval; + hw.elapsed += interval; + assert(hw.elapsed <= 100); // Infinite or wrap-unsafe waits fail promptly. + event("delay", 0, static_cast(interval)); +} +void nrf_gpio_cfg_default(uint32_t pin) { + assert(pin == ONBOARD_LORA_SCLK || pin == ONBOARD_LORA_MOSI || + pin == ONBOARD_LORA_MISO || pin == ONBOARD_LORA_BUSY); + hw.modes.at(pin) = 99; + ++hw.defaults.at(pin); + event("default", pin); +} +struct SPISettings { + SPISettings(unsigned frequency, unsigned bit_order, unsigned mode) { + assert(frequency == 2000000 && bit_order == MSBFIRST && mode == SPI_MODE0); + } +}; +struct SPIClass { + std::string name; + bool begun = false; + bool pins_set = false; + explicit SPIClass(const char* value) : name(value) {} + void check_owner() const { assert(name == "SPI"); } + void setPins(unsigned miso, unsigned sclk, unsigned mosi) { + check_owner(); + assert(!begun && miso == ONBOARD_LORA_MISO && + sclk == ONBOARD_LORA_SCLK && mosi == ONBOARD_LORA_MOSI); + pins_set = true; + event("spi-pins"); + } + void begin() { + check_owner(); + assert(pins_set && !begun); + assert(hw.levels.at(ONBOARD_LORA_NSS) == HIGH); + assert(hw.modes.at(ONBOARD_LORA_NSS) == OUTPUT); + assert(hw.levels.at(ONBOARD_LORA_RESET) == HIGH); + assert(hw.modes.at(ONBOARD_LORA_RESET) == OUTPUT); + assert(hw.levels.at(ONBOARD_LORA_ANT_SW) == LOW); + assert(hw.modes.at(ONBOARD_LORA_ANT_SW) == OUTPUT); + assert(hw.modes.at(ONBOARD_LORA_BUSY) == INPUT_PULLUP); + begun = true; + event("spi-begin"); + } + void beginTransaction(SPISettings) { + check_owner(); + assert(begun && !hw.transaction && !hw.sleep_issued); + assert(hw.levels.at(ONBOARD_LORA_NSS) == HIGH); + hw.transaction = true; + hw.frame.clear(); + event("transaction"); + } + uint8_t transfer(uint8_t value) { + check_owner(); + assert(begun && hw.transaction && !hw.sleep_issued); + assert(hw.levels.at(ONBOARD_LORA_NSS) == LOW && hw.reset_low_seen); + hw.frame.push_back(value); + event("transfer", 0, value); + if (hw.frame.front() != RADIOLIB_SX126X_CMD_READ_REGISTER || hw.frame.size() < 5) + return 0; + assert(hw.frame.size() <= 10); + assert(hw.modes.at(ONBOARD_LORA_MISO) == INPUT_PULLDOWN); + const unsigned index = static_cast(hw.frame.size() - 5); + if (hw.scenario == Scenario::ZeroVersion) return 0; + if (hw.scenario == Scenario::FloatingVersion) return 0xff; + if (hw.scenario == Scenario::WrongVersion) return static_cast("SX126X"[index]); + return static_cast("SX1261"[index]); + } + void endTransaction() { + check_owner(); + assert(begun && hw.transaction && hw.levels.at(ONBOARD_LORA_NSS) == HIGH); + if (hw.frame.front() == RADIOLIB_SX126X_CMD_READ_REGISTER) { + assert((hw.frame == std::vector{0x1d, 3, 0x20, 0, 0, 0, 0, 0, 0, 0})); + hw.version_read = true; + } + hw.frames.push_back(hw.frame); + hw.transaction = false; + event("transaction-end"); + } + void end() { + check_owner(); + assert(begun && !hw.transaction); + begun = false; + event("spi-end"); + } +} SPI("SPI"), SPI1("SPI1"); +''' + + +ADAPTER_MAIN = r''' +size_t first(const char* name) { + for (size_t i = 0; i < hw.events.size(); ++i) + if (hw.events[i].name == name) return i; + return hw.events.size(); +} +int main() { + for (uint32_t start : {0u, UINT32_MAX - 10u}) { + for (Scenario scenario : {Scenario::Present, Scenario::MissingBusy, Scenario::WrongVersion, + Scenario::ZeroVersion, Scenario::FloatingVersion, Scenario::BusyAfterRead, + Scenario::SleepRejected}) { + hw = Hardware(); hw.clock = start; hw.scenario = scenario; + SPI.begun = SPI.pins_set = SPI1.begun = SPI1.pins_set = false; + const auto result = rak3401::sleepOnboardRadio(); + using State = rak3401::OnboardRadioState; + const auto expected = scenario == Scenario::Present ? State::Asleep : + scenario == Scenario::MissingBusy || scenario == Scenario::BusyAfterRead ? State::BusyTimeout : + scenario == Scenario::SleepRejected ? State::SleepFailed : State::Absent; + assert(result == expected); + assert(!SPI.begun && !SPI1.begun && !SPI1.pins_set && !hw.transaction); + assert(first("spi-pins") < first("spi-begin")); + assert(first("spi-begin") < first("spi-end")); + for (unsigned pin : {ONBOARD_LORA_SCLK, ONBOARD_LORA_MOSI, + ONBOARD_LORA_MISO, ONBOARD_LORA_BUSY}) { + assert(hw.modes.at(pin) == 99 && hw.defaults.at(pin) == 1); + for (size_t i = 0; i < hw.events.size(); ++i) + if (hw.events[i].name == "default" && hw.events[i].pin == pin) + assert(first("spi-end") < i); + } + for (unsigned pin : {ONBOARD_LORA_NSS, ONBOARD_LORA_RESET}) { + assert(hw.modes.at(pin) == OUTPUT && hw.levels.at(pin) == HIGH); + assert(hw.defaults.at(pin) == 0); + } + assert(hw.modes.at(ONBOARD_LORA_ANT_SW) == OUTPUT); + assert(hw.levels.at(ONBOARD_LORA_ANT_SW) == LOW); + // The optional radio cannot touch shared slot power, the primary radio, + // GPS, storage CS, or the internal radio's unused DIO1 interrupt pin. + for (unsigned pin : {3u, 4u, 9u, 10u, 15u, 16u, 17u, 21u, 26u, 29u, 30u, 31u, 34u, 47u}) + assert(hw.modes.at(pin) == -1 && hw.levels.at(pin) == -1 && hw.defaults.at(pin) == 0); + const bool should_sleep = scenario == Scenario::Present || scenario == Scenario::SleepRejected; + assert(hw.sleep_issued == should_sleep); + assert(hw.frames.size() == (scenario == Scenario::MissingBusy ? 0u : should_sleep ? 2u : 1u)); + if (scenario == Scenario::MissingBusy || scenario == Scenario::BusyAfterRead || + scenario == Scenario::SleepRejected) assert(hw.elapsed >= 20); + assert(hw.elapsed <= 70); + assert(static_cast(hw.clock - start) == hw.elapsed); + } + } +} +''' + + +def adapter_source(mutation=None): + target = (VARIANT / "target.cpp").read_text() + adapter = extract_braced(target, "class OnboardRadioIO") + ";" + if mutation == "probe-pulls-retained": + adapter = adapter.replace("pinMode(ONBOARD_LORA_BUSY, INPUT);", "// negative control") + elif mutation == "bus-left-active": + adapter = adapter.replace("SPI.end();", "// negative control") + elif mutation == "nss-wakes-radio": + before, after = adapter.rsplit("digitalWrite(ONBOARD_LORA_NSS, HIGH);", 1) + adapter = before + "digitalWrite(ONBOARD_LORA_NSS, LOW);" + after + function = extract_braced(target, "rak3401::OnboardRadioState rak3401::sleepOnboardRadio()") + return onboard_defines() + ADAPTER_MOCKS + adapter + "\n" + function + ADAPTER_MAIN + + +STARTUP_MOCKS = r''' +#include +#include +#include +#include +#include +#include "variants/rak3401/OnboardRadioSleep.h" +#define HIGH 1 +#define LOW 0 +#define INPUT 0 +#define OUTPUT 1 +#define INPUT_PULLUP 2 +#define NRF52_POWER_MANAGEMENT 1 +#define PIN_VBAT_READ 5 +#define PIN_USER_BTN 33 +#define PIN_USER_BTN_ANA 31 +#define PIN_BOARD_SDA 13 +#define PIN_BOARD_SCL 14 +#define PIN_3V3_EN 34 +#define SX126X_POWER_EN 21 +#define P_LORA_NSS 26 +#define P_LORA_BUSY 9 +#define P_LORA_RESET 4 +#define SHUTDOWN_REASON_LOW_VOLTAGE 0x4c +#define SHUTDOWN_REASON_BOOT_PROTECT 0x42 +struct Event { std::string name; unsigned pin; int value; }; +std::vector events; +std::array levels; +std::array modes; +bool permit_boot = true; +bool primary_busy = false; +uint32_t clock_ms = 0; +rak3401::OnboardRadioState radio_state; +void event(const char* name, unsigned pin = 0, int value = 0) { + events.push_back({name, pin, value}); +} +void pinMode(unsigned pin, int mode) { modes.at(pin) = mode; event("mode", pin, mode); } +void digitalWrite(unsigned pin, int value) { levels.at(pin) = value; event("write", pin, value); } +int digitalRead(unsigned pin) { assert(pin == P_LORA_BUSY); return primary_busy ? HIGH : LOW; } +uint32_t millis() { return clock_ms; } +void delay(uint32_t value) { clock_ms += value; assert(clock_ms <= 200); } +struct PowerMgtConfig { uint8_t lpcomp_ain_channel = 3, lpcomp_refsel = 4; } power_config; +struct SystemOff { uint8_t reason; }; +class NRF52BoardDCDC { +public: + void begin() { event("dcdc"); } + virtual void initiateShutdown(uint8_t) = 0; + void checkBootVoltage(const PowerMgtConfig*) { + event("voltage"); + if (!permit_boot) initiateShutdown(SHUTDOWN_REASON_BOOT_PROTECT); + } + void configureVoltageWake(uint8_t ain, uint8_t ref) { + assert(ain == 3 && ref == 4); event("voltage-wake"); + } + void enterSystemOff(uint8_t reason) { event("systemoff"); throw SystemOff{reason}; } + void serviceWatchdog() { event("watchdog"); } +}; +class RAK3401Board : public NRF52BoardDCDC { +public: + void begin(); + bool recoverRadio(); + void enableRadioFrontend(); + void initiateShutdown(uint8_t reason) override; +}; +struct WireMock { + void setPins(unsigned sda, unsigned scl) { assert(sda == 13 && scl == 14); event("wire-pins"); } + void begin() { event("wire-begin"); } +} Wire; +rak3401::OnboardRadioState rak3401::sleepOnboardRadio() { + event("onboard"); + return radio_state; +} +''' + + +STARTUP_MAIN = r''' +size_t first(const char* name) { + for (size_t i = 0; i < events.size(); ++i) + if (events[i].name == name) return i; + return events.size(); +} +void prepare() { + events.clear(); modes.fill(-1); levels.fill(-1); clock_ms = 0; primary_busy = false; + // initVariant() already holds the shared rail up and external FEM down. + levels.at(PIN_3V3_EN) = HIGH; levels.at(SX126X_POWER_EN) = LOW; +} +int main() { + using State = rak3401::OnboardRadioState; + for (State state : {State::Asleep, State::Absent, State::BusyTimeout, State::SleepFailed}) { + for (bool boot : {true, false}) { + prepare(); radio_state = state; permit_boot = boot; RAK3401Board board; + bool stopped = false; + try { board.begin(); } catch (const SystemOff& off) { + assert(off.reason == SHUTDOWN_REASON_BOOT_PROTECT); stopped = true; + } + assert(stopped == !boot); + assert(first("dcdc") == 0 && first("onboard") == 1); + assert(first("onboard") < first("voltage")); + assert(levels.at(SX126X_POWER_EN) == LOW); + if (boot) { + assert(levels.at(PIN_3V3_EN) == HIGH); + assert(first("systemoff") == events.size()); + assert(board.recoverRadio()); + assert(levels.at(SX126X_POWER_EN) == LOW); + assert(levels.at(PIN_3V3_EN) == HIGH); + board.enableRadioFrontend(); + assert(levels.at(SX126X_POWER_EN) == HIGH); + // A later failed external-radio reset must keep shared peripherals up + // and the external frontend quiescent, without reproving the optional radio. + primary_busy = true; + assert(!board.recoverRadio()); + assert(levels.at(PIN_3V3_EN) == HIGH && levels.at(SX126X_POWER_EN) == LOW); + unsigned probes = 0; + for (const auto& e : events) if (e.name == "onboard") ++probes; + assert(probes == 1); + } else { + assert(first("voltage") < first("voltage-wake")); + assert(first("voltage-wake") < first("systemoff")); + assert(levels.at(PIN_3V3_EN) == LOW); + } + } + } +} +''' + + +def startup_source(mutation=None): + board = (VARIANT / "RAK3401Board.cpp").read_text() + methods = "\n".join(extract_braced(board, signature) for signature in ( + "void RAK3401Board::begin()", "void RAK3401Board::initiateShutdown(uint8_t reason)", + "bool RAK3401Board::recoverRadio()", "void RAK3401Board::enableRadioFrontend()")) + if mutation == "probe-after-voltage": + methods = methods.replace("rak3401::sleepOnboardRadio();", "// negative control") + methods = methods.replace("checkBootVoltage(&power_config);", + "checkBootVoltage(&power_config); rak3401::sleepOnboardRadio();") + return STARTUP_MOCKS + methods + STARTUP_MAIN + + +class Rak3401OnboardRadioTests(unittest.TestCase): + def test_actual_adapter_and_template_all_outcomes_and_timer_wrap(self): + for debug_enabled in (0, 1): + with self.subTest(debug=debug_enabled): + compile_source(self, f"#define MESH_DEBUG {debug_enabled}\n" + adapter_source()) + + def test_gpio_cleanup_and_sleep_readback_negative_controls(self): + for mutation in ("probe-pulls-retained", "bus-left-active", "nss-wakes-radio"): + with self.subTest(mutation=mutation): + compile_source(self, adapter_source(mutation), expect_success=False) + + def test_real_startup_protects_low_voltage_and_preserves_external_recovery(self): + compile_source(self, startup_source()) + + def test_boot_voltage_order_negative_control(self): + compile_source(self, startup_source("probe-after-voltage"), expect_success=False) + + def test_optional_pin_map_matches_the_rak4631_internal_radio(self): + external_variant = (VARIANT / "variant.h").read_text() + internal_variant = (ROOT / "variants/rak4631/variant.h").read_text() + for optional, active in (("NSS", "P_LORA_NSS"), ("SCLK", "P_LORA_SCLK"), + ("MOSI", "P_LORA_MOSI"), ("MISO", "P_LORA_MISO"), + ("BUSY", "P_LORA_BUSY"), ("RESET", "P_LORA_RESET"), + ("DIO_1", "P_LORA_DIO_1"), + ("ANT_SW", "SX126X_POWER_EN")): + with self.subTest(pin=optional): + self.assertEqual(numeric_define(external_variant, "ONBOARD_LORA_" + optional), + numeric_define(internal_variant, active)) + + def test_standard_rak4631_keeps_its_active_radio(self): + board = (ROOT / "variants/rak4631/RAK4631Board.cpp").read_text() + target = (ROOT / "variants/rak4631/target.cpp").read_text() + self.assertNotIn("sleepOnboardRadio", board + target) + self.assertNotIn("ONBOARD_LORA_", board + target) + begin = extract_braced(board, "void RAK4631Board::begin()") + self.assertIn("digitalWrite(SX126X_POWER_EN, HIGH);", begin) + init = extract_braced(target, "bool radio_init()") + self.assertIn("radio.std_init(&SPI)", init) + self.assertIn("P_LORA_RESET, P_LORA_BUSY, SPI)", target) + # External-radio construction and retry must retain their SPI1 owner. + external_target = (VARIANT / "target.cpp").read_text() + self.assertIn("P_LORA_RESET, P_LORA_BUSY, SPI1)", external_target) + external_init = extract_braced(external_target, "bool radio_init()") + self.assertIn("radio.std_init(&SPI1)", external_init) + self.assertNotIn("sleepOnboardRadio", external_init) + + +if __name__ == "__main__": + unittest.main() diff --git a/variants/rak3401/OnboardRadioSleep.h b/variants/rak3401/OnboardRadioSleep.h new file mode 100644 index 000000000..09fe38c96 --- /dev/null +++ b/variants/rak3401/OnboardRadioSleep.h @@ -0,0 +1,38 @@ +#pragma once + +#include + +// Adapted from agessaman/MeshCore feat/rak13302-auto-onboard-sleep +// (d58042d8d25fbc9177f1b393ba0e393e3e5b276f). +namespace rak3401 { + +enum class OnboardRadioState { Absent, Asleep, BusyTimeout, SleepFailed }; + +// Startup only, before radio, storage or other SPI users have begun. The +// RAK3401 image also supports a RAK4631 core using the external RAK13302. +OnboardRadioState sleepOnboardRadio(); + +// IO owns the temporary SPI setup and must release it on every outcome. +template +OnboardRadioState sleepOnboardRadio(IO& io, const char* expectedVersion) { + io.begin(); + io.reset(); + const auto state = [&]() { + // A missing chip also times out: the probe pull keeps floating BUSY high. + if (!io.waitBusy(false, 20)) return OnboardRadioState::BusyTimeout; + char version[6]; + io.readVersion(version); + if (memcmp(version, expectedVersion, sizeof(version)) != 0) { + return OnboardRadioState::Absent; + } + io.releaseProbePulls(); + if (!io.waitBusy(false, 20)) return OnboardRadioState::BusyTimeout; + io.sleep(); + // Never read SPI to confirm sleep: an NSS falling edge wakes the chip. + return io.waitBusy(true, 20) ? OnboardRadioState::Asleep : OnboardRadioState::SleepFailed; + }(); + io.end(); + return state; +} + +} // namespace rak3401 diff --git a/variants/rak3401/RAK3401Board.cpp b/variants/rak3401/RAK3401Board.cpp index e2df74899..434aaf559 100644 --- a/variants/rak3401/RAK3401Board.cpp +++ b/variants/rak3401/RAK3401Board.cpp @@ -4,6 +4,7 @@ #include "RAK3401Board.h" #include "target.h" +#include "OnboardRadioSleep.h" #include "../../src/helpers/nrf52/TerminalPeripheralShutdown.h" #ifdef NRF52_POWER_MANAGEMENT @@ -82,6 +83,9 @@ void RAK3401Board::shutdownPeripherals() { void RAK3401Board::begin() { NRF52BoardDCDC::begin(); + // Also handles a RAK4631 core with an external RAK13302. Do this before + // voltage protection can enter SYSTEMOFF and leave the unused chip awake. + rak3401::sleepOnboardRadio(); pinMode(PIN_VBAT_READ, INPUT); #ifdef PIN_USER_BTN pinMode(PIN_USER_BTN, INPUT_PULLUP); diff --git a/variants/rak3401/target.cpp b/variants/rak3401/target.cpp index f33464315..759540488 100644 --- a/variants/rak3401/target.cpp +++ b/variants/rak3401/target.cpp @@ -1,10 +1,111 @@ #include #include "target.h" +#include "OnboardRadioSleep.h" #include +#include #ifdef USE_CC310_HW_CRYPTO #include #endif +namespace { + +// This is the optional private radio bus, NOT the external RAK13302's SPI1. +// It is only probed during board startup, before any other bus owner starts. +class OnboardRadioIO { +public: + void begin() { + digitalWrite(ONBOARD_LORA_NSS, HIGH); + pinMode(ONBOARD_LORA_NSS, OUTPUT); + digitalWrite(ONBOARD_LORA_RESET, HIGH); + pinMode(ONBOARD_LORA_RESET, OUTPUT); + digitalWrite(ONBOARD_LORA_ANT_SW, LOW); + pinMode(ONBOARD_LORA_ANT_SW, OUTPUT); + pinMode(ONBOARD_LORA_BUSY, INPUT_PULLUP); + SPI.setPins(ONBOARD_LORA_MISO, ONBOARD_LORA_SCLK, ONBOARD_LORA_MOSI); + SPI.begin(); + // Reject a floating MISO rather than mistaking it for a version string. + pinMode(ONBOARD_LORA_MISO, INPUT_PULLDOWN); + } + + void reset() { + digitalWrite(ONBOARD_LORA_RESET, LOW); + delay(1); + digitalWrite(ONBOARD_LORA_RESET, HIGH); + delay(5); + } + + bool waitBusy(bool high, uint32_t timeout) { + const uint32_t started = millis(); + while ((digitalRead(ONBOARD_LORA_BUSY) != LOW) != high) { + if (millis() - started >= timeout) return false; + delay(1); + } + return true; + } + + void readVersion(char* version) { + SPI.beginTransaction(SPISettings(2000000, MSBFIRST, SPI_MODE0)); + digitalWrite(ONBOARD_LORA_NSS, LOW); + SPI.transfer(RADIOLIB_SX126X_CMD_READ_REGISTER); + SPI.transfer(RADIOLIB_SX126X_REG_VERSION_STRING >> 8); + SPI.transfer(RADIOLIB_SX126X_REG_VERSION_STRING & 0xff); + SPI.transfer(0); + for (unsigned i = 0; i < 6; ++i) version[i] = SPI.transfer(0); + digitalWrite(ONBOARD_LORA_NSS, HIGH); + SPI.endTransaction(); + } + + void releaseProbePulls() { + pinMode(ONBOARD_LORA_BUSY, INPUT); + pinMode(ONBOARD_LORA_MISO, INPUT); + } + + void sleep() { + SPI.beginTransaction(SPISettings(2000000, MSBFIRST, SPI_MODE0)); + digitalWrite(ONBOARD_LORA_NSS, LOW); + SPI.transfer(RADIOLIB_SX126X_CMD_SET_SLEEP); + SPI.transfer(RADIOLIB_SX126X_SLEEP_START_COLD | RADIOLIB_SX126X_SLEEP_RTC_OFF); + digitalWrite(ONBOARD_LORA_NSS, HIGH); + SPI.endTransaction(); + delay(1); + } + + void end() { + SPI.end(); + nrf_gpio_cfg_default(g_ADigitalPinMap[ONBOARD_LORA_SCLK]); + nrf_gpio_cfg_default(g_ADigitalPinMap[ONBOARD_LORA_MOSI]); + nrf_gpio_cfg_default(g_ADigitalPinMap[ONBOARD_LORA_MISO]); + nrf_gpio_cfg_default(g_ADigitalPinMap[ONBOARD_LORA_BUSY]); + // Retain NSS/reset HIGH and RF switch LOW. Releasing NSS can wake the + // sleeping SX1262; only its clock/data/BUSY pads should be disconnected. + digitalWrite(ONBOARD_LORA_NSS, HIGH); + } +}; + +static_assert(ONBOARD_LORA_MISO == PIN_SPI_MISO && + ONBOARD_LORA_MOSI == PIN_SPI_MOSI && + ONBOARD_LORA_SCLK == PIN_SPI_SCK, + "The optional radio probe must leave SPI on its default pins"); + +} // namespace + +rak3401::OnboardRadioState rak3401::sleepOnboardRadio() { + OnboardRadioIO onboard; +#if MESH_DEBUG + const uint32_t started = millis(); +#endif + // RadioLib identifies the SX1262 by the same six-byte SX1261 prefix. + const auto state = rak3401::sleepOnboardRadio(onboard, RADIOLIB_SX1262_CHIP_TYPE); +#if MESH_DEBUG + const char* outcome = state == OnboardRadioState::Absent ? "absent" : + state == OnboardRadioState::Asleep ? "asleep" : + state == OnboardRadioState::BusyTimeout ? "busy-timeout" : "sleep-failed"; + MESH_DEBUG_PRINTLN("onboard SX1262: %s elapsed=%lu ms", outcome, + static_cast(millis() - started)); +#endif + return state; +} + RAK3401Board board; #ifndef PIN_USER_BTN diff --git a/variants/rak3401/variant.h b/variants/rak3401/variant.h index b8f714c46..ddcce4735 100644 --- a/variants/rak3401/variant.h +++ b/variants/rak3401/variant.h @@ -122,6 +122,18 @@ static const uint8_t AREF = PIN_AREF; #define PIN_SPI1_MOSI (30) #define PIN_SPI1_SCK (3) +// Optional private SX1262 on a RAK4631 core running this external-radio image. +// These pads are unconnected on a RAK3401 core. The external RAK13302 remains +// on SPI1; ordinary RAK4631 firmware instead uses its onboard chip as primary. +#define ONBOARD_LORA_NSS (42) // P1.10 +#define ONBOARD_LORA_SCLK (43) // P1.11 +#define ONBOARD_LORA_MOSI (44) // P1.12 +#define ONBOARD_LORA_MISO (45) // P1.13 +#define ONBOARD_LORA_BUSY (46) // P1.14 +#define ONBOARD_LORA_DIO_1 (47) // P1.15 +#define ONBOARD_LORA_RESET (38) // P1.06 +#define ONBOARD_LORA_ANT_SW (37) // P1.05 + static const uint8_t SS = 42; static const uint8_t MOSI = PIN_SPI_MOSI; static const uint8_t MISO = PIN_SPI_MISO;