feat(rak3401): auto-sleep optional onboard SX1262

Use the existing RAK3401 firmware roles for RAK4631 cores paired with a
RAK13302. Probe the optional onboard radio and put it into cold sleep
before handing SPI to the primary radio, removing seven duplicate build
environments.

Preserve BUSY startup readiness, release probe pulls before sleep, and
disconnect unused GPIO input buffers during cleanup. Add focused tests
for sequencing, missing signatures, timeouts, and failed sleep readback.

Validated with four native tests, seven firmware-role builds, and repeater
boot checks on RAK4631 + RAK13302. Supply current was not measured.
This commit is contained in:
agessaman committed 2026-10-16 19:02:23 -07:00
1 parent 51cbab5311
commit de5d6697d1
6 files changed
+184 -164

No files matched your search

+6
View File
@@ -173,6 +173,12 @@ build_src_filter =
lib_deps =
google/googletest @ 1.17.0
[env:native_rak3401_onboard_radio]
; Header-only tests without unrelated production sources; also included in env:native CI.
extends = env:native
test_build_src = no
test_filter = test_rak3401_onboard_radio
[env:native_kiss_modem]
platform = native
test_framework = googletest
@@ -0,0 +1,66 @@
#include <gtest/gtest.h>
#include <array>
#include <string>
#include "../../variants/rak3401/OnboardRadioSleep.h"
namespace {
struct FakeRadioIO {
std::string version = "SX1261";
std::array<bool, 3> ready = {true, true, true};
std::string calls;
unsigned waits = 0;
bool probePulls = true;
void begin() { calls += "begin "; }
void reset() { calls += "reset "; }
bool waitBusy(bool high, unsigned timeout) {
calls += high ? "busy-high " : "busy-low ";
EXPECT_EQ(timeout, 20u);
if (high) EXPECT_FALSE(probePulls);
const bool result = ready.at(waits++);
return result;
}
void readVersion(char* out) { calls += "version "; memcpy(out, version.data(), 6); }
void releaseProbePulls() { calls += "release-pulls "; probePulls = false; }
void sleep() { EXPECT_FALSE(probePulls); calls += "sleep "; }
void end() { calls += "end "; }
};
}
TEST(RakOnboardRadio, PresentRadioSleepsBeforeBusRelease) {
FakeRadioIO io;
EXPECT_EQ(rak3401::sleepOnboardRadio(io, "SX1261"), rak3401::OnboardRadioState::Asleep);
EXPECT_EQ(io.calls, "begin reset busy-low version release-pulls busy-low sleep busy-high end ");
}
TEST(RakOnboardRadio, AbsentAndFloatingResponsesNeverReceiveSleep) {
for (const std::string version : {std::string(6, '\0'), std::string(6, '\xff'), std::string("SX126X")}) {
FakeRadioIO io;
io.version = version;
EXPECT_EQ(rak3401::sleepOnboardRadio(io, "SX1261"), rak3401::OnboardRadioState::Absent);
EXPECT_EQ(io.calls, "begin reset busy-low version end ");
}
}
TEST(RakOnboardRadio, BusyTimeoutsReleaseBusWithoutSleep) {
for (unsigned failure = 0; failure < 2; failure++) {
FakeRadioIO io;
io.ready[failure] = false;
EXPECT_EQ(rak3401::sleepOnboardRadio(io, "SX1261"), rak3401::OnboardRadioState::BusyTimeout);
EXPECT_EQ(io.calls.find("sleep "), std::string::npos);
EXPECT_EQ(io.calls, failure == 0 ? "begin reset busy-low end " :
"begin reset busy-low version release-pulls busy-low end ");
}
}
TEST(RakOnboardRadio, MissingSleepReadbackIsReportedAndBusReleased) {
FakeRadioIO io;
io.ready[2] = false;
EXPECT_EQ(rak3401::sleepOnboardRadio(io, "SX1261"), rak3401::OnboardRadioState::SleepFailed);
EXPECT_EQ(io.calls.substr(io.calls.size() - 4), "end ");
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <string.h>
namespace rak3401 {
enum class OnboardRadioState { Absent, Asleep, BusyTimeout, SleepFailed };
// IO owns the temporary SPI pin assignment; every outcome must release it.
template <typename IO>
OnboardRadioState sleepOnboardRadio(IO& io, const char* expectedVersion) {
io.begin();
io.reset();
const auto state = [&]() {
// A missing chip also times out: BUSY is pulled high until a radio drives it low.
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();
// Reading over SPI here would wake the radio again. BUSY is a passive check.
return io.waitBusy(true, 20) ? OnboardRadioState::Asleep : OnboardRadioState::SleepFailed;
}();
io.end();
return state;
}
}
-125
View File
@@ -134,128 +134,3 @@ build_src_filter = ${rak3401.build_src_filter}
extends = rak3401
build_src_filter = ${rak3401.build_src_filter}
+<../examples/kiss_modem/>
; ------------------------- RAK4631 core + RAK13302 -------------------------
; Same RF hardware as the RAK3401 1W kit, but the RAK4631 core carries a second,
; unused SX1262 that would otherwise idle in standby forever. These envs match
; the RAK_3401_* ones and additionally cold-sleep that radio at boot.
; Do not flash them to a genuine RAK3401 -- there is no onboard radio to sleep.
[rak4631_13302]
extends = rak3401
board = rak4631
build_flags = ${rak3401.build_flags}
-D RAK4631_ONBOARD_SX1262_SLEEP
build_src_filter = ${rak3401.build_src_filter}
lib_deps = ${rak3401.lib_deps}
[env:RAK_4631_13302_repeater]
extends = rak4631_13302
build_flags =
${rak4631_13302.build_flags}
-D DISPLAY_CLASS=SSD1306Display
-D ADVERT_NAME='"RAK4631 1W Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
;-D MESH_PACKET_LOGGING=1
;-D MESH_DEBUG=1
build_src_filter = ${rak4631_13302.build_src_filter}
+<helpers/ui/SSD1306Display.cpp>
+<../examples/simple_repeater>
[env:RAK_4631_13302_room_server]
extends = rak4631_13302
build_flags =
${rak4631_13302.build_flags}
-D DISPLAY_CLASS=SSD1306Display
-D ADVERT_NAME='"Test Room"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D ROOM_PASSWORD='"hello"'
;-D MESH_PACKET_LOGGING=1
;-D MESH_DEBUG=1
build_src_filter = ${rak4631_13302.build_src_filter}
+<helpers/ui/SSD1306Display.cpp>
+<../examples/simple_room_server>
[env:RAK_4631_13302_companion_radio_usb]
extends = rak4631_13302
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
board_upload.maximum_size = 712704
build_flags =
${rak4631_13302.build_flags}
-I examples/companion_radio/ui-new
-D PIN_USER_BTN_ANA=31
-D DISPLAY_CLASS=SSD1306Display
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
-D ENABLE_USB_INTERFACE
; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1
; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1
build_src_filter = ${rak4631_13302.build_src_filter}
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
lib_deps =
${rak4631_13302.lib_deps}
densaugeo/base64 @ ~1.4.0
[env:RAK_4631_13302_companion_radio_ble]
extends = rak4631_13302
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
board_upload.maximum_size = 712704
build_flags =
${rak4631_13302.build_flags}
-I examples/companion_radio/ui-new
-D PIN_USER_BTN_ANA=31
-D DISPLAY_CLASS=SSD1306Display
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
-D BLE_PIN_CODE=123456
-D BLE_DEBUG_LOGGING=1
-D OFFLINE_QUEUE_SIZE=256
;-D MESH_PACKET_LOGGING=1
;-D MESH_DEBUG=1
build_src_filter = ${rak4631_13302.build_src_filter}
+<helpers/nrf52/SerialBLEInterface.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
lib_deps =
${rak4631_13302.lib_deps}
densaugeo/base64 @ ~1.4.0
[env:RAK_4631_13302_terminal_chat]
extends = rak4631_13302
build_flags =
${rak4631_13302.build_flags}
-D MAX_CONTACTS=100
-D MAX_GROUP_CHANNELS=1
;-D MESH_PACKET_LOGGING=1
;-D MESH_DEBUG=1
build_src_filter = ${rak4631_13302.build_src_filter}
+<../examples/simple_secure_chat/main.cpp>
lib_deps =
${rak4631_13302.lib_deps}
densaugeo/base64 @ ~1.4.0
[env:RAK_4631_13302_sensor]
extends = rak4631_13302
build_flags =
${rak4631_13302.build_flags}
-D DISPLAY_CLASS=SSD1306Display
-D ADVERT_NAME='"RAK4631 Sensor"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
;-D MESH_PACKET_LOGGING=1
;-D MESH_DEBUG=1
build_src_filter = ${rak4631_13302.build_src_filter}
+<helpers/ui/SSD1306Display.cpp>
+<../examples/simple_sensor>
[env:RAK_4631_13302_kiss_modem]
extends = rak4631_13302
build_src_filter = ${rak4631_13302.build_src_filter}
+<../examples/kiss_modem/>
+77 -33
View File
@@ -1,6 +1,8 @@
#include <Arduino.h>
#include "target.h"
#include <helpers/ArduinoHelpers.h>
#include "OnboardRadioSleep.h"
#include <nrf_gpio.h>
RAK3401Board board;
@@ -32,49 +34,91 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock);
EnvironmentSensorManager sensors;
#endif
#ifdef RAK4631_ONBOARD_SX1262_SLEEP
// With a RAK13302 fitted the RAK4631's own SX1262 never gets initialized, so it
// sits in STDBY_RC drawing standby current for the life of the board. Firmware
// cannot cut its supply -- P1.05 is the RF switch rail, not the radio's -- so
// bring it up on its own pins just long enough to put it into cold sleep.
static void sleep_onboard_radio() {
pinMode(ONBOARD_LORA_ANT_SW, OUTPUT);
digitalWrite(ONBOARD_LORA_ANT_SW, LOW); // nothing downstream needs the RF switch
namespace {
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);
// BUSY is weakly pulled high during startup; never override it with a pull-down.
pinMode(ONBOARD_LORA_BUSY, INPUT_PULLUP);
SPI.setPins(ONBOARD_LORA_MISO, ONBOARD_LORA_SCLK, ONBOARD_LORA_MOSI);
SPI.begin();
pinMode(ONBOARD_LORA_MISO, INPUT_PULLDOWN);
}
SPI.setPins(ONBOARD_LORA_MISO, ONBOARD_LORA_SCLK, ONBOARD_LORA_MOSI);
SPI.begin();
void reset() {
digitalWrite(ONBOARD_LORA_RESET, LOW);
delay(1);
digitalWrite(ONBOARD_LORA_RESET, HIGH);
delay(5);
}
Module mod(ONBOARD_LORA_NSS, ONBOARD_LORA_DIO_1, ONBOARD_LORA_RESET, ONBOARD_LORA_BUSY, SPI);
SX1262 onboard(&mod);
bool waitBusy(bool high, uint32_t timeout) {
uint32_t start = millis();
while ((digitalRead(ONBOARD_LORA_BUSY) != LOW) != high) {
if (millis() - start >= timeout) return false;
delay(1);
}
return true;
}
// BUSY is driven low in STDBY_RC and high while asleep, so it doubles as a
// non-invasive readback of what state we found the chip in and left it in.
pinMode(ONBOARD_LORA_BUSY, INPUT);
int busy_before = digitalRead(ONBOARD_LORA_BUSY);
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();
}
// begin() resets the chip and leaves it in standby. A TCXO calibration failure
// still leaves SPI usable, so issue the sleep regardless of what it returns.
int status = onboard.begin(434.0f, 125.0f, 9, 7, RADIOLIB_SX126X_SYNC_WORD_PRIVATE,
0, 8, ONBOARD_LORA_TCXO_VOLTAGE, false);
int slept = onboard.sleep(false); // cold sleep: config discarded, RTC off
delay(2);
int busy_after = digitalRead(ONBOARD_LORA_BUSY);
MESH_DEBUG_PRINTLN("onboard SX1262: begin=%d sleep=%d busy %d->%d",
status, slept, busy_before, busy_after);
void releaseProbePulls() {
pinMode(ONBOARD_LORA_BUSY, INPUT);
pinMode(ONBOARD_LORA_MISO, INPUT);
}
SPI.end(); // release SPIM3 so std_init() can repin it onto the RAK13302
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);
}
// The SX1262 wakes on an NSS falling edge, so keep NSS driven high from here on.
digitalWrite(ONBOARD_LORA_NSS, HIGH);
pinMode(ONBOARD_LORA_NSS, OUTPUT);
void end() {
SPI.end();
// INPUT leaves buffers enabled on floating pins; restore Nordic's disconnected default.
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]);
// An NSS falling edge wakes the radio, so leave it driven high.
digitalWrite(ONBOARD_LORA_NSS, HIGH);
}
};
}
#endif
bool radio_init() {
rtc_clock.begin(Wire);
#ifdef RAK4631_ONBOARD_SX1262_SLEEP
sleep_onboard_radio();
#endif
// Called once from setup, before SPI is started for the RAK13302.
OnboardRadioIO onboard;
const uint32_t started = millis();
// The SX1262 version register shares the "SX1261" prefix used by RadioLib.
const auto state = rak3401::sleepOnboardRadio(onboard, RADIOLIB_SX1262_CHIP_TYPE);
const char* outcome = state == rak3401::OnboardRadioState::Absent ? "absent" :
state == rak3401::OnboardRadioState::Asleep ? "asleep" :
state == rak3401::OnboardRadioState::BusyTimeout ? "busy-timeout" : "sleep-failed";
MESH_DEBUG_PRINTLN("onboard SX1262: %s elapsed=%lu ms", outcome,
(unsigned long)(millis() - started));
return radio.std_init(&SPI);
}
+1 -6
View File
@@ -175,10 +175,7 @@ static const uint8_t AREF = PIN_AREF;
#define P_LORA_BUSY SX126X_BUSY
#define P_LORA_RESET SX126X_RESET
// The RAK4631 core carries its own SX1262, which is unused when a RAK13302
// supplies the RF path. RAK4631_ONBOARD_SX1262_SLEEP builds put it into cold
// sleep at boot; on a genuine RAK3401 these pins are unconnected.
#ifdef RAK4631_ONBOARD_SX1262_SLEEP
// Optional RAK4631 onboard radio; these internal pins are unused on RAK3401.
#define ONBOARD_LORA_NSS (42) // P1.10
#define ONBOARD_LORA_SCLK (43) // P1.11
#define ONBOARD_LORA_MOSI (44) // P1.12
@@ -187,8 +184,6 @@ static const uint8_t AREF = PIN_AREF;
#define ONBOARD_LORA_DIO_1 (47) // P1.15
#define ONBOARD_LORA_RESET (38) // P1.06
#define ONBOARD_LORA_ANT_SW (37) // P1.05, powers the onboard RF switch
#define ONBOARD_LORA_TCXO_VOLTAGE 1.8
#endif
// enables 3.3V periphery like GPS or IO Module
// Do not toggle this for GPS power savings