Repair failed profile retunes before receiving packets

This commit is contained in:
mikecarper committed 2026-10-19 18:54:14 -07:00
1 parent 8038715ffe
commit 8e35f477c0
13 files changed
+189 -21

No files matched your search

+1
View File
@@ -590,6 +590,7 @@ jobs:
python3 -B test/test_radio_reply_mutation.py
python3 -B test/test_radio_profile_scan.py
python3 -B test/test_radio_profile_clock.py -v
python3 -B test/test_radio_profile_repair.py -v
python3 -B test/test_radio_profile_runtime_recommendation.py -v
python3 -B test/test_radio_profile_idle_dispatcher.py -v
python3 -B test/test_radio_rxps_rejection.py -v
+46 -15
View File
@@ -315,6 +315,21 @@ uint8_t RadioLibWrapper::beginReconfigure() {
#endif
if (_cw_active) return 2;
const uint8_t base_state = state;
if (_profile_repair_required) {
// The last transaction failed after changing the chip. Its old FIFO and
// activity flags have no trustworthy modulation/profile ownership.
if (base_state == STATE_TX_WAIT || isChipBusy()) return 2;
claimRadioInterrupt();
_rx_hold_continuous = false;
if (_radio->standby() != RADIOLIB_ERR_NONE) {
state = STATE_IDLE;
return 3;
}
_rx_ps_armed = false;
claimRadioInterrupt();
state = STATE_IDLE;
return 1; // A repair must verify RX re-entry even if software was idle.
}
// On SX126x/LR11xx duty-cycle RX, BUSY may remain asserted during the sleep
// side of the cycle. Do not issue an IRQ/preamble query over SPI then; due
// scheduled work will retry as soon as the next safe listen window opens.
@@ -324,10 +339,13 @@ uint8_t RadioLibWrapper::beginReconfigure() {
}
const bool resume_rx = base_state == STATE_RX;
if (_rx_ps_armed) {
stopReceiveDutyCycle();
} else if (resume_rx) {
_radio->standby();
if (_rx_ps_armed || resume_rx) {
if (_radio->standby() != RADIOLIB_ERR_NONE) {
state = STATE_IDLE;
_profile_refresh_required = _profile_repair_required = true;
return 3;
}
_rx_ps_armed = false;
}
// An RX completion during standby still owns its FIFO and old profile.
// Defer reconfiguration until recvRaw consumes it, rather than rearming and
@@ -368,6 +386,7 @@ bool RadioLibWrapper::restoreAfterDeepInit() {
restored = applyRxBoostedGainMode(_cur_rx_boosted_gain) && restored;
}
recalibrateNoiseFloor();
if (restored) _profile_repair_required = false;
return restored;
}
@@ -378,7 +397,8 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw,
}
uint8_t resume_rx = beginReconfigure();
if (resume_rx > 1) return mesh::RadioParamApplyResult::BUSY;
if (resume_rx > 1) return resume_rx == 2
? mesh::RadioParamApplyResult::BUSY : mesh::RadioParamApplyResult::FAILED;
const bool had_previous_params = _params_valid;
const float previous_freq = _cur_freq;
@@ -418,6 +438,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw,
_rx_ps_continuous_fallback = rxPowerSavingUsesContinuousFallback(
_rx_ps_rx_us, _rx_ps_sleep_us);
}
_profile_repair_required = false;
endReconfigure(resume_rx);
success = !resume_rx || isInRecvMode();
}
@@ -453,8 +474,12 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw,
#endif
if (!restored) restored = restoreAfterDeepInit();
if (restored) endReconfigure(resume_rx);
if (!restored || (resume_rx && !isInRecvMode())) _profile_refresh_required = true;
if (restored) {
_profile_repair_required = false;
endReconfigure(resume_rx);
}
if (!restored || (resume_rx && !isInRecvMode()))
_profile_refresh_required = _profile_repair_required = true;
if (!restored) {
MESH_DEBUG_PRINTLN("RadioLibWrapper: failed to restore radio parameters after apply failure");
@@ -518,7 +543,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) {
if (profile > 1 || (profile == 1 && !_profiles.enabled())) return mesh::RadioParamApplyResult::FAILED;
const auto& p = _profiles.params(profile);
const uint16_t preamble = profilePreamble(profile);
if (!_profile_refresh_required && _active_profile == profile && _profile_generation == _profiles.generation[profile]
if (!_profile_refresh_required && !_profile_repair_required && _active_profile == profile && _profile_generation == _profiles.generation[profile]
&& _physical_preamble == preamble) return mesh::RadioParamApplyResult::APPLIED;
if (!validateProfile(p)) return mesh::RadioParamApplyResult::FAILED;
const bool dual_profile = _profiles.enabled();
@@ -526,8 +551,8 @@ mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) {
beginProfileRetune(isInRecvMode() && !_rx_ps_armed && !_rx_ps_enabled);
const uint8_t resume = beginReconfigure();
if (resume > 1) {
endProfileRetune(true); // guard deferred before changing hardware
return mesh::RadioParamApplyResult::BUSY;
endProfileRetune(resume == 2); // Only a BUSY guard deferred before changing hardware.
return resume == 2 ? mesh::RadioParamApplyResult::BUSY : mesh::RadioParamApplyResult::FAILED;
}
const float old_freq = _cur_freq, old_bw = _cur_bw;
const uint8_t old_sf = _cur_sf, old_cr = _cur_cr, old_profile = _active_profile;
@@ -553,6 +578,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) {
_active_profile = profile;
_profile_generation = _profiles.generation[profile];
_profile_refresh_required = false;
_profile_repair_required = false;
endReconfigure(resume);
applied = !resume || isInRecvMode();
}
@@ -579,8 +605,12 @@ mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) {
restored = restored && applyProtocol(old_protocol);
#endif
if (!restored) restored = restoreAfterDeepInit();
if (restored) endReconfigure(resume);
if (!restored || (resume && !isInRecvMode())) _profile_refresh_required = true;
if (restored) {
_profile_repair_required = false;
endReconfigure(resume);
}
if (!restored || (resume && !isInRecvMode()))
_profile_refresh_required = _profile_repair_required = true;
}
_profile_visit_stamp = profileTimestamp(dual_profile);
// A profile retune re-enters RX on a different tuple. Do not read its
@@ -669,7 +699,7 @@ void RadioLibWrapper::cacheBeaconRestoreParams() {
mesh::RadioParamApplyResult RadioLibWrapper::prepareBeaconTransmit(
const mesh::RadioProfileParams& p, int8_t power) {
using Result = mesh::RadioParamApplyResult;
if (_beacon_owned || _cw_active) return Result::BUSY;
if (_beacon_owned || _cw_active || _profile_repair_required) return Result::BUSY;
#if defined(MESH_HIL_TX_FREQ_MHZ)
_hil_beacon_tx_frequency_mhz = 0;
if (!mesh::hilTransmitFrequencyAllowed(p.freq)) return Result::FAILED;
@@ -753,6 +783,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::restoreBeaconReceive() {
}
claimRadioInterrupt();
if (restored) {
_profile_repair_required = false; // Full normal tuple/protocol/power/IRQ restore succeeded.
state = STATE_IDLE;
startRecv();
restored = isInRecvMode();
@@ -2035,7 +2066,7 @@ void RadioLibWrapper::startRecv() {
#if MESH_ENABLE_TX_BEACONS
if (_beacon_owned && !_beacon_restoring) return;
#endif
if (_cw_active) return;
if (_cw_active || _profile_repair_required) return;
int err = startReceiveMode();
if (err == RADIOLIB_ERR_NONE) {
// A very short frame may complete while startReceiveMode() returns.
@@ -2190,7 +2221,7 @@ int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) {
#if MESH_ENABLE_TX_BEACONS
if (_beacon_owned) return 0;
#endif
if (_cw_active || state == STATE_TX_WAIT) return 0;
if (_cw_active || state == STATE_TX_WAIT || _profile_repair_required) return 0;
int len = 0;
#if defined(USE_LR2021)
bool receiver_stopped = false;
+3
View File
@@ -114,6 +114,9 @@ protected:
bool _profile_rxps_suspended = false;
bool _profile_saved_rxps = false;
bool _profile_refresh_required = false;
// A failed parameter transaction left the physical tuple untrusted. Unlike
// an ordinary refresh, its FIFO and activity flags cannot identify a packet.
bool _profile_repair_required = false;
#if defined(NRF52_PLATFORM)
// Published by the scanner before the final sleep predicates. The late
// query uses integers only: floating-point work can leave a stale FPU event.
+13
View File
@@ -351,6 +351,19 @@ static void recoveryOwnershipAdmission() {
int main(int argc, char** argv) {
if (argc == 2) {
if (!strcmp(argv[1], "profile-repair")) {
RadioLibWrapper w; uint8_t bytes[8];
w._profile_repair_required=true; w.events.record(); w.radio.packet_length=1;
const auto arms=w.arms;
assert(w.recvRaw(bytes,sizeof(bytes))==0);
w.startRecv();
assert(w.radio.fifo_reads==0 && w.arms==arms && w.hasPendingRadioInterrupt());
// A trusted configuration refresh does not revoke old FIFO ownership.
w._profile_repair_required=false;
assert(w.recvRaw(bytes,sizeof(bytes))==1 && w.radio.fifo_reads==1);
std::puts("PASS: untrusted profile RX waits for repair");
return 0;
}
assert(!strcmp(argv[1], "recovery"));
recoveryOwnershipAdmission();
return 0;
+6
View File
@@ -76,6 +76,7 @@ struct RadioLibWrapper {
PhysicalLayer* _radio;
mesh::MainBoard* _board;
bool _params_valid = true, _rx_ps_armed = true, _dbm_valid = false, _rx_boosted_gain_valid = false;
bool _profile_repair_required = false;
int state = 1;
float _cur_freq = 906.875f, _cur_bw = 250;
uint8_t _cur_sf = 11, _cur_cr = 5;
@@ -136,14 +137,19 @@ static void failures_and_recovery() {
assert(chip.sync == 0x12 && chip.crc && chip.explicit_header && !chip.iq);
assert(chip.cached_crc && !chip.cached_implicit && !chip.cached_iq);
wrapper._physical_protocol = Protocol::Meshtext;
wrapper._profile_repair_required = true;
assert(wrapper.restoreAfterDeepInit());
assert(!wrapper._profile_repair_required);
assert(chip.sync == 0x2b && chip.crc && chip.explicit_header && !chip.iq);
assert(!wrapper._rx_ps_armed && wrapper.state == STATE_IDLE);
chip.fail_at = fail;
wrapper._profile_repair_required = true;
assert(!wrapper.restoreAfterDeepInit());
assert(wrapper._profile_repair_required);
chip.fail_at = 0;
wrapper._physical_protocol = Protocol::MeshCore;
assert(wrapper.restoreAfterDeepInit());
assert(!wrapper._profile_repair_required);
assert(chip.sync == 0x12 && chip.crc && chip.explicit_header && !chip.iq);
}
}
+35 -4
View File
@@ -75,7 +75,7 @@ struct RadioLibWrapper {
uint32_t _beacon_restore_at = 0, _beacon_cad_timeout = 0;
uint8_t state = STATE_RX, _active_profile = 0;
uint32_t _profile_generation = 1, _profile_visit_stamp = 0;
bool _profile_refresh_required = false;
bool _profile_refresh_required = false, _profile_repair_required = false;
uint16_t _physical_preamble = 32;
mesh::RadioProfileProtocol _physical_protocol = mesh::RadioProfileProtocol::MeshCore;
float _cur_freq = 906, _cur_bw = 62.5;
@@ -219,29 +219,56 @@ int main() {
assert(w.recalibrations == 0 && w.deep_calls == 0);
++cases;
}
for (unsigned reason = 0; reason < 5; ++reason) {
for (unsigned reason = 0; reason < 6; ++reason) {
RadioLibWrapper w;
if (reason == 0) w.pending = true;
if (reason == 1) w.packet = true;
if (reason == 2) w.busy = true;
if (reason == 3) w._cw_active = true;
if (reason == 4) w.calibrating = true;
if (reason == 5) w._profile_repair_required = true;
assert(w.prepareBeaconTransmit(beacon(), 10) == Result::BUSY);
assert(!w._beacon_owned && w.applications == 0 && w.power_sets == 0);
++cases;
}
for (unsigned failure = 0; failure < 4; ++failure) {
for (unsigned failure = 0; failure < 3; ++failure) {
RadioLibWrapper w;
if (failure == 0) w.modem.param_fail = true;
if (failure == 1) w.protocol_fail = true;
if (failure == 2) w.power_fail = true;
if (failure == 3) w.modem.standby_fail = true;
assert(w.prepareBeaconTransmit(beacon(true), 10) == Result::FAILED && w._beacon_owned);
w.repair_on_deep = true;
assert(w.restoreBeaconReceive() == Result::APPLIED && !w._beacon_owned);
assert(w.modem.freq == 906 && w.modem.protocol == mesh::RadioProfileProtocol::MeshCore && w.modem.power == 20);
++cases;
}
{
RadioLibWrapper w;
w.modem.standby_fail = true;
assert(w.prepareBeaconTransmit(beacon(true), 10) == Result::BUSY);
// Refused initial standby never admits a beacon lease or mutates PA/params.
assert(!w._beacon_owned && w._profile_repair_required && w.state == STATE_IDLE);
assert(w.applications == 0 && w.power_sets == 0 && w.protocols == 0);
assert(w.modem.standbys == 1 && !w.board.before && !w.arms);
w.startRecv();
assert(w.arms == 0 && w.state == STATE_IDLE);
assert(w.prepareBeaconTransmit(beacon(), 10) == Result::BUSY && w.modem.standbys == 1);
w.repair_on_deep = true;
assert(w.restoreAfterDeepInit() && !w._profile_repair_required);
assert(w.prepareBeaconTransmit(beacon(true), 10) == Result::APPLIED && w._beacon_owned);
assert(w.restoreBeaconReceive() == Result::APPLIED && !w._beacon_owned);
++cases;
}
{
RadioLibWrapper w;
assert(w.prepareBeaconTransmit(beacon(true), 10) == Result::APPLIED);
w._profile_repair_required = true;
w.deep_ok = false; // A skipped first RX arm cannot be hidden by deep recovery.
assert(w.restoreBeaconReceive() == Result::APPLIED && !w._beacon_owned);
assert(!w._profile_repair_required && w.state == STATE_RX && w.arms == 1);
assert(w.deep_calls == 0);
++cases;
}
for (unsigned changed = 0; changed < 2; ++changed) {
RadioLibWrapper w;
w._profiles.secondary.params = w._profiles.primary;
@@ -386,6 +413,10 @@ class BeaconRadioTest(unittest.TestCase):
def test_ownership_and_snapshot_negative_controls(self):
source = self.source()
mutations = [
("if (_beacon_owned || _cw_active || _profile_repair_required) return Result::BUSY;",
"if (_beacon_owned || _cw_active) return Result::BUSY;"),
("_profile_repair_required = false; // Full normal tuple/protocol/power/IRQ restore succeeded.",
"/* omit repair retirement before ordinary RX */"),
("if (_beacon_owned && !_beacon_restoring) return 2;", "/* remove ownership guard */"),
("if (_beacon_owned && !_beacon_restoring) return;", "/* permit RX on beacon tuple */"),
("bool applied = _radio->standby() == RADIOLIB_ERR_NONE",
+3 -2
View File
@@ -117,6 +117,7 @@ struct RadioLibWrapper : mesh::Radio {
uint8_t state = STATE_RX;
RadioInterruptEvents events;
bool hasPendingRadioInterrupt() const { return events.pending(); }
bool claimRadioInterrupt() { return events.claim(); }
unsigned register_failures=0;
bool registerInterruptAction() {
if (register_failures) { --register_failures; return false; }
@@ -148,7 +149,7 @@ struct RadioLibWrapper : mesh::Radio {
if (rx_gain_apply_ok) physical_rx_gain=enabled;
return rx_gain_apply_ok;
}
bool _nf_calib_active=true, _profile_refresh_required=false;
bool _nf_calib_active=true, _profile_refresh_required=false, _profile_repair_required=false;
unsigned long _wd_observe_until=0;
bool packet=false, busy=false, reset_ok=false, supported=true, deep_supported=true;
unsigned rtc_stops=0, tunes=0, rx_starts=0, calibrations=0, resets=0;
@@ -270,7 +271,7 @@ int main() {
assert(w.setCarrierWave(profile,1250)==Result::APPLIED);
assert(w.frequency==(profile ? 911.5f : 909.5f));
assert(w.chip.carrier && !w.chip.callback && w.board.before==1 && w.board.awake);
assert(w.rtc_stops==1 && !w._rx_ps_armed && !w._nf_calib_active);
assert(w.chip.standby_calls==2 && !w._rx_ps_armed && !w._nf_calib_active);
assert(w.beginReconfigure()==2);
advance(1249); assert(w.serviceCarrierWave() && w.chip.carrier);
advance(1); assert(w.serviceCarrierWave() && !w.chip.carrier);
+1
View File
@@ -315,6 +315,7 @@ struct CustomSX1262: SX1262 {
};
struct RadioLibWrapper {
CustomSX1262 chip;CustomSX1262* _radio=&chip;
bool _profile_repair_required=false;
bool supported=true;
bool supportsMeshtext() const { return supported; }
bool _rx_ps_armed=true,_params_valid=true,_dbm_valid=false,_rx_boosted_gain_valid=false;
+70
View File
@@ -0,0 +1,70 @@
"""Execute failed transaction recovery without erasing fork feature ownership."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from cpp_source import body
from test_radio_profile_scan import HARNESS, ROOT
class ProfileRepairTests(unittest.TestCase):
def test_failed_apply_rollback_standby_and_trusted_refresh(self):
source = (ROOT / 'src/helpers/radiolib/RadioLibWrappers.cpp').read_text()
methods = '\n'.join(body(source, signature) for signature in (
'uint8_t RadioLibWrapper::beginReconfigure(',
'void RadioLibWrapper::endReconfigure(',
'mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(',
'mesh::RadioParamApplyResult RadioLibWrapper::trySetParams('))
harness = HARNESS[:HARNESS.index('int main() {')]
harness = harness.replace('bool standbyXOSC=false; int standby() { return 0; }',
'bool standbyXOSC=false; int result=0; int standby() { return result; }')
harness = harness.replace(' bool _cw_active=false;',
' bool _cw_active=false, _beacon_owned=false, _beacon_restoring=false, fail_all=false;')
harness = harness.replace('return !fail || f==909.5f;', 'return !fail_all && (!fail || f==909.5f);')
harness = harness.replace('bool restoreAfterDeepInit() { return true; }',
'bool restoreAfterDeepInit() { return false; }')
harness = harness.replace(' void startRecv() {',
' void startRecv() { if (_profile_repair_required) return;')
harness += r'''
int main() {
using Result=mesh::RadioParamApplyResult;
RadioLibWrapper w; w._rx_ps_armed=w._rx_ps_enabled=false;
auto second=w._profiles.primary;second.freq=910.5;
w._profiles.secondary.params=second;w._profiles.secondary.mode=mesh::RadioProfileMode::RxTx;
w.fail_all=true;
assert(w.tuneProfile(1)==Result::FAILED && w._profile_repair_required);
assert(w._active_profile==0 && w._profile_refresh_required);
w.fail_all=false;w.packet=true;w.events.record();
assert(w.tuneProfile(0)==Result::APPLIED); // Untrusted stale activity cannot block repair.
assert(!w._profile_repair_required && w.state==STATE_RX && !w.hasPendingRadioInterrupt());
w._profile_refresh_required=true;w.events.record();
const auto applies=w.applies;
assert(w.tuneProfile(0)==Result::BUSY && w.applies==applies && w.hasPendingRadioInterrupt());
w.events.claim();w.packet=false;w._profile_repair_required=true;
w._beacon_owned=true;assert(w.beginReconfigure()==2);w._beacon_owned=false;
w._cw_active=true;assert(w.beginReconfigure()==2);w._cw_active=false;
w.state=STATE_TX_WAIT;assert(w.beginReconfigure()==2);w.state=STATE_IDLE;
w.busy=true;assert(w.beginReconfigure()==2);w.busy=false;
w.chip.result=-7;assert(w.tuneProfile(0)==Result::FAILED && w._profile_repair_required);
w.chip.result=0;assert(w.tuneProfile(0)==Result::APPLIED && w.state==STATE_RX);
w.fail_all=true;
assert(w.trySetParams(911.5,125,8,5)==Result::FAILED && w._profile_repair_required);
w.fail_all=false;assert(w.tuneProfile(0)==Result::APPLIED && !w._profile_repair_required);
}
'''
harness = harness.replace('@METHODS@', methods)
with tempfile.TemporaryDirectory(prefix='profile-repair-') as folder:
cpp, exe = Path(folder)/'test.cpp', Path(folder)/'test.exe'
(Path(folder)/'Arduino.h').write_text('#pragma once\n#include <cstdint>\n#include <cmath>\n')
cpp.write_text(harness)
flags=[] if os.name=='nt' else ['-fsanitize=address,undefined','-fno-pie','-no-pie']
built=subprocess.run([os.environ.get('CXX','g++'),'-std=c++17',*flags,
'-DMESH_ENABLE_TX_BEACONS=1','-I',folder,'-I',str(ROOT/'src'),str(cpp),'-o',str(exe)],
capture_output=True,text=True,timeout=60)
self.assertEqual(built.returncode,0,built.stderr)
ran=subprocess.run([str(exe)],capture_output=True,text=True,timeout=10)
self.assertEqual(ran.returncode,0,ran.stderr)
if __name__=='__main__': unittest.main()
+1
View File
@@ -40,6 +40,7 @@ struct RadioLibWrapper {
Chip chip; Chip* _radio = &chip;
mesh::RadioProfiles _profiles;
bool _params_valid=true, packet=false, busy=false, fail=false;
bool _profile_repair_required=false, _rx_hold_continuous=false;
bool _rx_ps_enabled=true, _rx_ps_armed=true, _rx_ps_continuous_fallback=false;
bool _profile_saved_rxps=false, _profile_rxps_suspended=false;
bool _profile_standby_held=false, _saved_standby_xosc=false;
+1
View File
@@ -65,6 +65,7 @@ struct RadioLibWrapper {
bool hasPendingRadioInterrupt() const { return events.pending(); }
bool claimRadioInterrupt() { return events.claim(); }
bool _cw_active = false;
bool _profile_repair_required = false;
bool serviceCarrierWave() { return false; } // dedicated CW harness owns this path
mesh::RadioProfiles _profiles;
uint32_t _profile_visit_stamp = 0;
+6
View File
@@ -185,6 +185,12 @@ class RadioReceiveRetryTests(unittest.TestCase):
self.assertIn('PASS: receive retry lifecycle', result.stdout)
print(result.stdout, end='')
def test_failed_profile_transaction_blocks_fifo_delivery_and_rx_rearm(self):
with tempfile.TemporaryDirectory(prefix='meshcore-profile-repair-rx-') as folder:
result = self.run_probe(self.build(folder), 'profile-repair')
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn('PASS: untrusted profile RX waits for repair', result.stdout)
def test_old_hot_poll_and_pending_calibration_veto_negative_controls(self):
for mutation in ('unpaced', 'pending_veto', 'agc_fast'):
with self.subTest(mutation=mutation), tempfile.TemporaryDirectory(
+3
View File
@@ -45,6 +45,8 @@ struct Driver {
uint8_t state = STATE_IDLE;
bool receiving = false, pending_irq = false, busy = false, supported = true;
bool _rx_ps_armed = false, _cw_active = false;
bool _profile_repair_required = false, _profile_refresh_required = false;
bool _rx_hold_continuous = false;
bool _dbm_valid = true, _rx_boosted_gain_valid = false;
int8_t _cur_dbm = 7;
bool _cur_rx_boosted_gain = false;
@@ -53,6 +55,7 @@ struct Driver {
std::deque<bool> gain_results;
Chip* _radio = &chip;
bool hasPendingRadioInterrupt() { return pending_irq; }
bool claimRadioInterrupt() { const bool pending = pending_irq; pending_irq = false; return pending; }
bool isChipBusy() { return busy; }
bool isReceivingPacket() { return receiving; }
void stopReceiveDutyCycle() { _rx_ps_armed = false; }