Reduce routine RX power-saving noise-floor refreshes

This commit is contained in:
mikecarper committed 2026-10-14 01:37:55 -07:00
1 parent 7cbe629bb3
commit ea117ca91b
4 files changed
+293 -28

No files matched your search

+27 -3
View File
@@ -11,6 +11,29 @@ Periodic requests coalesce while a block is active. RXPS resumes after a complet
block, including a held block, or at timeout. Receive and transmit activity are
never interrupted just to finish calibration.
In single-profile RXPS, routine refreshes wait at least 60 seconds after the
last completed or timed-out block before requesting another continuous-RX
window. Both periodic maintenance and passive retry checks use this cooldown;
retries do not restart a partially collected block. With RXPS off, or when the
selected tuple requires continuous-RX fallback, the existing two-second
maintenance tick and passive-probe age check are unchanged. Startup, explicit
gain/tuning changes, AGC resets, and radio recovery still request a fresh block
promptly. Dual-profile scanning retains
its separate refresh policy.
Frequent configured AGC resets can still dominate calibration time: Cascade
repeater builds default to an eight-second AGC interval, and MQTT observers
use 28 seconds. Those resets are not throttled by the routine cooldown. After
a timed-out startup block with no valid floor, the next routine RXPS attempt
also waits 60 seconds; passive retries retain their existing bounded busy
escape while waiting for a valid baseline.
The longer RXPS cooldown trades a slower response to ambient noise changes for
more time in duty-cycle RX. It does not reduce sample count, shorten settling,
or put the radio to sleep between samples. A large upward change still needs
three complete high blocks to pass the rise hold. No battery-life improvement
has been measured for this scheduling change.
The block estimate is the 12.5th percentile: the average of the eighth and
ninth sorted samples, retaining fractional dBm. A published update uses 25% of
the previous floor and 75% of the new estimate, with a -120 dBm lower bound. Initial calibration and explicit gain/tuning changes seed a new baseline.
@@ -30,9 +53,10 @@ out of 64 to avoid higher-valued traffic entirely. Nearly continuous
interference or enough low outliers can still mislead it. The rise hold delays
large changes but cannot identify their source.
RSSI sampling waits 20 ms after every receive re-arm, including CAD and TX
completion, to avoid unsettled frontend readings. The ten-second trial window
often failed to complete during heavy traffic with that settling guard; the
RSSI sampling waits 7 ms on SX1262, SX1268, and LLCC68 builds, or 20 ms on other
families, after every receive re-arm, including CAD and TX completion, to avoid
unsettled frontend readings. The ten-second trial window often failed to
complete during heavy traffic with that settling guard; the
30-second cap allows more idle opportunities. Partial blocks still time out
without publishing. Longer collection spends more time in continuous RX when
RXPS is enabled. A quiet minimum is about 3.2 seconds; real completion time
+24 -7
View File
@@ -19,7 +19,10 @@ extern "C" caddr_t _sbrk(int increment);
// Bounded noise-floor calibration in continuous RX, including RXPS refreshes.
// Requests are coalesced so retries cannot repeatedly force continuous RX.
#define NF_CALIB_INTERVAL_MS 2000UL // match the original 2-second refresh cadence
#define NF_CALIB_INTERVAL_MS 2000UL // continuous-RX refresh / forced-window cooldown
// A quiet block needs about 3.2 s of continuous RX. Do not reopen one after
// only 2 s of duty cycling: routine maintenance would defeat RX power saving.
#define NF_RXPS_REFRESH_INTERVAL_MS (60UL * 1000UL)
#define NF_CALIB_TIMEOUT_MS NoiseFloorEstimator::WINDOW_TIMEOUT_MS
#define NF_CONTINUOUS_TIMEOUT_MS NoiseFloorEstimator::WINDOW_TIMEOUT_MS
#define NF_CALIB_SETTLE_MS RadioLibWrapper::NoiseFloorSettleMillis
@@ -754,7 +757,13 @@ void RadioLibWrapper::triggerNoiseFloorCalibrate(int threshold) {
}
return;
}
requestNoiseFloorRefresh();
if (_rx_ps_enabled && !_rx_ps_continuous_fallback) {
requestPeriodicNoiseFloorRefresh();
} else {
// RX is already continuous. Keep the original maintenance-tick behavior;
// only passive retry probes apply the existing two-second age check.
requestNoiseFloorRefresh();
}
}
void RadioLibWrapper::recalibrateNoiseFloor() {
@@ -780,6 +789,18 @@ void RadioLibWrapper::recalibrateNoiseFloor() {
}
}
void RadioLibWrapper::requestPeriodicNoiseFloorRefresh() {
const uint32_t interval = _rx_ps_enabled && !_rx_ps_continuous_fallback
? NF_RXPS_REFRESH_INTERVAL_MS : NF_CALIB_INTERVAL_MS;
// Startup must not wait for a maintenance tick. Subsequent attempts, even
// failed ones, need a real rest interval. Forced RF-path/AGC recalibration
// bypasses this routine policy and retains its existing prompt behavior.
if (_profiles.enabled() || (!_noise_floor_valid && _nf_last_calib == 0)
|| static_cast<uint32_t>(millis() - _nf_last_calib) >= interval) {
requestNoiseFloorRefresh();
}
}
void RadioLibWrapper::requestNoiseFloorRefresh() {
if (_nf_refresh_requested) return;
_nf_refresh_requested = true;
@@ -1923,11 +1944,7 @@ bool RadioLibWrapper::isReceivingPassive(int interference_margin_db) {
// calibration. Keep retries in RX and use the preamble/header IRQs here.
if (_profiles.enabled()) return false;
unsigned long now = millis();
if ((!_noise_floor_valid && _nf_last_calib == 0)
|| now - _nf_last_calib >= NF_CALIB_INTERVAL_MS) {
requestNoiseFloorRefresh();
}
requestPeriodicNoiseFloorRefresh();
// Until the startup baseline is ready, defer rather than compare against the
// zero-initialized floor and transmit blind. Dispatcher retains its bounded
// busy timeout if sampling cannot complete.
+1
View File
@@ -201,6 +201,7 @@ protected:
void idle() override;
void startRecv() override;
void rxPsWatchdogCheck();
void requestPeriodicNoiseFloorRefresh();
void requestNoiseFloorRefresh();
void requestRestartRecv();
bool isPacketPendingOrReceiving();
+241 -18
View File
@@ -2,7 +2,9 @@
"""Execute production CAD, receive re-arm, calibration and mode-watchdog methods."""
from pathlib import Path
import os
import re
import subprocess
import sys
import tempfile
import unittest
@@ -19,6 +21,7 @@ def method(text, signature):
HARNESS = r'''
#include <cassert>
#include <cstdio>
#include <cstdint>
#include <initializer_list>
#include <RadioProfiles.h>
@@ -33,11 +36,7 @@ HARNESS = r'''
#define RADIOLIB_ERR_RX_TIMEOUT -6
#define MAX_TRANS_UNIT 255
#define MESH_DEBUG_PRINTLN(...) ((void)0)
#define NF_CALIB_INTERVAL_MS 2000UL
#define NF_CALIB_TIMEOUT_MS NoiseFloorEstimator::WINDOW_TIMEOUT_MS
#define NF_CONTINUOUS_TIMEOUT_MS NoiseFloorEstimator::WINDOW_TIMEOUT_MS
#define NF_CALIB_SETTLE_MS 7UL
#define NF_FAST_PROFILE_REFRESH_INTERVAL_MS (15UL * 60UL * 1000UL)
@NF_CONSTANTS@
static uint32_t now_ms = 0;
uint32_t millis() { return now_ms; }
uint32_t micros() { return now_ms * 1000UL; }
@@ -63,6 +62,7 @@ struct Radio {
}
};
struct RadioLibWrapper {
@SETTLE_CONSTANT@
uint8_t state = STATE_RX;
RadioInterruptEvents events;
bool hasPendingRadioInterrupt() const { return events.pending(); }
@@ -73,7 +73,7 @@ struct RadioLibWrapper {
uint32_t _profile_visit_stamp = 0;
uint32_t _profile_scan_generation[2] = {};
uint8_t _active_profile = 0;
bool _profile_rxps_suspended = false;
bool _profile_rxps_suspended = false, _profile_saved_rxps = false;
void serviceProfileScan() {} // separate profile-scan harness exercises tuning
Board board; Board* _board = &board;
Radio radio{&events}; Radio* _radio = &radio;
@@ -92,6 +92,7 @@ struct RadioLibWrapper {
int arm_result = 0, chip_mode = 1;
float rssi = -100;
unsigned arms = 0, stops = 0, soft = 0, hard = 0, reads = 0, mode_reads = 0;
unsigned calibration_arms = 0, gain_changes = 0, agc_resets = 0;
bool inject_mode_irq = false;
NoiseFloorEstimator _floor_estimator;
NoiseFloorEstimator _secondary_floor_estimator;
@@ -112,11 +113,15 @@ struct RadioLibWrapper {
void rxPsWatchdogCheck() {}
unsigned long getEstAirtimeFor(int) { return 10; }
void stopReceiveDutyCycle() { ++stops; _rx_ps_armed = false; }
void doResetAGC() {}
bool applyRxBoostedGainMode(bool) { return true; }
void doResetAGC() { ++agc_resets; }
bool applyRxBoostedGainMode(bool) { ++gain_changes; return true; }
uint8_t beginReconfigure() { return 0; } // counted SPI/RX boundaries are separate
void endReconfigure(bool) {}
bool supportsRxPowerSaving() const { return true; }
int startReceiveMode() {
++arms;
if (!arm_result) _rx_ps_armed = _rx_ps_enabled && !_nf_calib_active;
if (_nf_calib_active) ++calibration_arms;
if (!arm_result) _rx_ps_armed = _rx_ps_enabled && !_rx_ps_continuous_fallback && !_nf_calib_active;
if (inject_arm_irq) events.record();
return arm_result;
}
@@ -124,6 +129,9 @@ struct RadioLibWrapper {
int16_t performChannelScanWithTimeout(unsigned long);
bool isPacketPendingOrReceiving();
bool isChannelActive();
bool isReceivingPassive(int);
bool setRxPowerSaving(bool, uint32_t, uint32_t);
bool setRxBoostedGainMode(bool);
void startRecv();
void checkReceiveMode(uint32_t);
void loop();
@@ -132,12 +140,186 @@ struct RadioLibWrapper {
void noiseFloorCalibCheck(unsigned long);
void endNoiseFloorCalib(unsigned long);
void requestNoiseFloorRefresh();
void requestPeriodicNoiseFloorRefresh();
void recalibrateNoiseFloor();
void resetAGC();
};
@METHODS@
// Exercise both public callers and the shared policy itself. Continuous and
// fallback Dispatcher triggers intentionally retain their unconditional legacy
// request; only passive polling and the helper use the two-second age gate.
static void routineRequest(RadioLibWrapper& w, unsigned path, int margin = 7) {
if (path == 0) w.triggerNoiseFloorCalibrate(margin);
else if (path == 1) {
const bool active = w.isReceivingPassive(margin);
assert(active == (!w._noise_floor_valid || (margin > 0 && w.rssi - w._noise_floor >= margin)));
} else w.requestPeriodicNoiseFloorRefresh();
}
static void selectMode(RadioLibWrapper& w, unsigned mode) {
const uint32_t period = mode == 2 ? RX_POWERSAVING_CONTINUOUS_FALLBACK_US : 50000U;
assert(w.setRxPowerSaving(mode != 0, period, period));
assert(w._rx_ps_enabled == (mode != 0));
assert(w._rx_ps_continuous_fallback == (mode == 2));
}
static void periodicRefreshContracts() {
static_assert(NF_RXPS_REFRESH_INTERVAL_MS == 60000UL, "Routine RXPS refresh must rest one minute");
static_assert(NF_CALIB_INTERVAL_MS == 2000UL, "Continuous passive refresh retains its two-second cadence");
unsigned cases = 0;
for (unsigned path : {0U, 1U, 2U}) for (unsigned mode : {0U, 1U, 2U})
for (uint32_t last : {10000U, 0xfffffff0U}) for (bool valid : {false, true})
for (uint32_t age : {0U, 1U, 1999U, 2000U, 59999U, 60000U}) {
RadioLibWrapper w; selectMode(w, mode);
w._nf_refresh_requested = false; w._noise_floor_valid = valid;
w._nf_last_calib = last; w._threshold = 42;
now_ms = last + age;
routineRequest(w, path);
const uint32_t interval = mode == 1 ? NF_RXPS_REFRESH_INTERVAL_MS : NF_CALIB_INTERVAL_MS;
const bool expected = (path == 0 && mode != 1) || age >= interval;
assert(w._nf_refresh_requested == expected);
assert(w._threshold == (path == 0 ? 7 : 42)); // threshold changes never wait
assert(w.arms == 0 && w.stops == 0 && w.radio.scans == 0);
++cases;
}
// Invalid startup bypasses the cooldown. A failed, timestamped attempt does
// not: it remains conservatively busy without immediately reopening RX.
for (unsigned path : {0U, 1U, 2U}) {
RadioLibWrapper w; selectMode(w, 1);
w._noise_floor_valid = false; w._nf_last_calib = 0; w._nf_refresh_requested = false;
now_ms = 1; routineRequest(w, path); assert(w._nf_refresh_requested);
w._noise_floor_valid = true; w._nf_refresh_requested = false;
now_ms = NF_RXPS_REFRESH_INTERVAL_MS - 1; routineRequest(w, path);
assert(!w._nf_refresh_requested); // zero timestamp alone is not startup
++now_ms; routineRequest(w, path); assert(w._nf_refresh_requested);
++cases;
}
// Select the current receive mode, not the mode of the last completed batch.
for (unsigned path : {0U, 1U, 2U}) for (unsigned from : {0U, 1U, 2U})
for (unsigned to : {0U, 1U, 2U}) {
RadioLibWrapper w; selectMode(w, from);
w._nf_last_calib = 10000; w._nf_refresh_requested = false;
now_ms = 20000; selectMode(w, to); routineRequest(w, path);
assert(w._nf_refresh_requested == (to != 1));
if (to == 1) {
now_ms = 10000 + NF_RXPS_REFRESH_INTERVAL_MS - 1; routineRequest(w, path);
assert(!w._nf_refresh_requested);
++now_ms; routineRequest(w, path); assert(w._nf_refresh_requested);
}
++cases;
}
// A periodic request may join, but must not reset or extend, a partial block.
for (unsigned path : {0U, 1U, 2U}) {
RadioLibWrapper w; selectMode(w, 1); w._rx_ps_armed = true;
w._nf_last_calib = 100; w._nf_refresh_requested = false;
now_ms = 100 + NF_RXPS_REFRESH_INTERVAL_MS;
routineRequest(w, path); w.loop(); w.startRecv();
now_ms += NF_CALIB_SETTLE_MS; w.loop();
assert(w._nf_calib_active && w._floor_estimator.count() == 1);
assert(w._secondary_floor_estimator.add(-90, now_ms));
const auto deadline = w._nf_calib_deadline;
for (unsigned repeat = 0; repeat < 5; ++repeat) {
routineRequest(w, path, 9);
assert(w._nf_refresh_requested && w._floor_estimator.count() == 1);
assert(w._secondary_floor_estimator.count() == 1 && w._nf_calib_deadline == deadline);
}
++cases;
}
// Passive busy/packet/CW ownership still avoids SPI reads, CAD and refresh.
for (unsigned owned : {0U, 1U, 2U, 3U}) {
RadioLibWrapper w; selectMode(w, 1); w._nf_refresh_requested = false;
w._noise_floor_valid = false; now_ms = 100000;
if (owned == 0) w._cw_active = true;
if (owned == 1) w.busy = true;
if (owned == 2) w.packet = true;
if (owned == 3) w.events.record();
assert(w.isReceivingPassive(7) && !w._nf_refresh_requested);
assert(w.reads == 0 && w.arms == 0 && w.radio.scans == 0);
++cases;
}
{
RadioLibWrapper w; selectMode(w, 1); w._nf_refresh_requested = false;
w._nf_last_calib = now_ms;
assert(w.isReceivingPassive(3)); // published floor still gates interference
assert(!w.isReceivingPassive(6) && !w._nf_refresh_requested);
++cases;
}
{
RadioLibWrapper w;
mesh::RadioProfileConfig second;
second.params.freq = 910.5; second.params.bw = 62.5; second.params.sf = 7; second.params.cr = 5;
second.mode = mesh::RadioProfileMode::Rx;
w._profiles.primary = second.params; w._profiles.setSecondary(second, false);
w._noise_floor_valid = w._noise_floor_secondary_valid = false;
w._nf_last_calib = now_ms; w._nf_refresh_requested = false;
assert(!w.isReceivingPassive(7) && !w._nf_refresh_requested && w.reads == 0);
w.triggerNoiseFloorCalibrate(9); // dual initialization still bypasses the single-profile cooldown
assert(w._nf_refresh_requested && w._threshold == 9);
++cases;
}
// Forced gain/tuning and AGC remain prompt, independent of routine cooldown.
for (unsigned forced : {0U, 1U, 2U}) {
RadioLibWrapper w; selectMode(w, 1); w._rx_ps_armed = true;
now_ms = 100000; w._nf_last_calib = now_ms; w._nf_refresh_requested = false;
assert(w._floor_estimator.add(-30, now_ms));
assert(w._secondary_floor_estimator.add(-30, now_ms));
++now_ms;
if (forced == 0) w.recalibrateNoiseFloor();
if (forced == 1) assert(w.setRxBoostedGainMode(true));
if (forced == 2) w.resetAGC();
assert(w._nf_refresh_requested && w._nf_last_calib == 0);
assert(w._floor_estimator.count() == 0 && w._secondary_floor_estimator.count() == 0);
assert(w._noise_floor_valid == (forced == 2));
assert(w._noise_floor_centi_dbm == -10500);
assert(w.gain_changes == (forced == 1 ? 1U : 0U));
assert(w.agc_resets == (forced == 2 ? 1U : 0U));
// AGC already schedules the normal receive re-arm; calibration follows it.
if (w.state == STATE_IDLE) w.startRecv();
w.loop(); assert(w._nf_calib_active);
++cases;
}
// A failed initial block gets the same one-minute rest as a valid block.
// Quiet and busy windows are exercised through the actual loop/re-arm path.
for (bool busy : {false, true}) for (unsigned path : {0U, 1U}) {
RadioLibWrapper w; selectMode(w, 1); w._rx_ps_armed = true;
w._noise_floor_valid = false; w._nf_refresh_requested = false; w.busy = busy;
const uint32_t start = now_ms;
// The passive busy guard correctly returns without a new request. Seed
// the initial startup demand via the actual Dispatcher method instead.
w.triggerNoiseFloorCalibrate(7);
for (; w._nf_last_calib == 0; ++now_ms) {
assert(uint32_t(now_ms - start) <= NF_CALIB_TIMEOUT_MS + 1);
if (uint32_t(now_ms - start) % NF_CALIB_INTERVAL_MS == 0) {
if (path == 0) w.triggerNoiseFloorCalibrate(7);
else routineRequest(w, path);
}
w.loop(); if (w.state == STATE_IDLE) w.startRecv();
}
const uint32_t finished = w._nf_last_calib;
assert(w.calibration_arms == 1 && w._rx_ps_armed && !w._nf_refresh_requested);
assert(w._noise_floor_valid == !busy);
if (busy) assert(w.reads == 0 && w._noise_floor_centi_dbm == -10500);
// A quiet passive caller reads RSSI, but those reads do not open windows.
for (; uint32_t(now_ms - finished) < NF_RXPS_REFRESH_INTERVAL_MS; ++now_ms) {
if (uint32_t(now_ms - finished) % NF_CALIB_INTERVAL_MS == 0) {
if (path == 0) w.triggerNoiseFloorCalibrate(7);
else routineRequest(w, path);
}
w.loop(); if (w.state == STATE_IDLE) w.startRecv();
assert(w.calibration_arms == 1 && !w._nf_calib_active && !w._nf_refresh_requested);
}
// A timestamped failed attempt is not the invalid-startup exception.
w.busy = false; routineRequest(w, path); w.loop();
if (w.state == STATE_IDLE) w.startRecv();
assert(w._nf_calib_active && w.calibration_arms == 2);
++cases;
}
std::printf("PASS: routine noise-floor cooldown (%u cases)\n", cases);
}
int main() {
periodicRefreshContracts();
// A completed packet, current frame, or TX owns the radio; CAD cannot erase it.
for (int owned : {1, 2, 3}) {
RadioLibWrapper w;
@@ -594,24 +776,65 @@ int main() {
result = subprocess.run([str(binary)], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
def test_production_transitions(self):
source = (ROOT / 'src/helpers/radiolib/RadioLibWrappers.cpp').read_text()
def run_production_transitions(self, source=None, expect_failure=False):
if source is None:
source = (ROOT / 'src/helpers/radiolib/RadioLibWrappers.cpp').read_text()
header = (ROOT / 'src/helpers/radiolib/RadioLibWrappers.h').read_text()
rxps = (ROOT / 'src/helpers/radiolib/RXPowerSaving.h').read_text()
names = [('int16_t','performChannelScanWithTimeout'), ('bool','isPacketPendingOrReceiving'),
('bool','isChannelActive'), ('void','startRecv'), ('void','checkReceiveMode'),
('bool','isChannelActive'), ('bool','isReceivingPassive'),
('bool','setRxPowerSaving'), ('bool','setRxBoostedGainMode'),
('void','startRecv'), ('void','checkReceiveMode'),
('void','loop'), ('void','triggerNoiseFloorCalibrate'), ('void','requestRestartRecv'), ('void','noiseFloorCalibCheck'),
('void','endNoiseFloorCalib'), ('void','requestNoiseFloorRefresh'),
('void','requestPeriodicNoiseFloorRefresh'),
('void','recalibrateNoiseFloor'), ('void','resetAGC')]
methods = '\n'.join(method(source, f'{kind} RadioLibWrapper::{name}(') for kind,name in names)
methods = method(rxps, 'inline bool rxPowerSavingUsesContinuousFallback(') + '\n'
methods += '\n'.join(method(source, f'{kind} RadioLibWrapper::{name}(') for kind,name in names)
constants = '\n'.join(re.findall(r'^#define NF_\w+\s+[^\n]+', source, re.MULTILINE))
constants += '\n' + re.search(
r'^#define RX_POWERSAVING_CONTINUOUS_FALLBACK_US\s+[^\n]+', rxps, re.MULTILINE).group(0)
settle_start = header.index('#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LLCC68)')
settle = header[settle_start:header.index('#endif', settle_start) + len('#endif')]
harness = (HARNESS.replace('@METHODS@', methods).replace('@NF_CONSTANTS@', constants)
.replace('@SETTLE_CONSTANT@', settle))
with tempfile.TemporaryDirectory(prefix='meshcore-radio-transitions-') as tmp:
cpp = Path(tmp) / 'test.cpp'
binary = Path(tmp) / 'test'
cpp.write_text(HARNESS.replace('@METHODS@', methods))
# An inherited production comment uses an em dash. Keep generated
# test files ASCII without changing any executable source token.
cpp.write_text(harness.replace('\u2014', '-'), encoding='ascii')
result = subprocess.run([os.environ.get('CXX','c++'), '-std=c++17', '-O1',
'-Wall', '-Wextra', '-I', str(ROOT / 'src'), str(cpp), '-o', str(binary)],
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
result = subprocess.run([str(binary)], capture_output=True, text=True)
'-Wall', '-Wextra', '-DUSE_SX1262=1',
*(['-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie'] if sys.platform.startswith('linux') else []),
'-I', str(ROOT / 'src'), str(cpp), '-o', str(binary)],
capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stderr)
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20)
if expect_failure:
self.assertNotEqual(result.returncode, 0, 'unthrottled negative control escaped the regression')
self.assertIn('periodicRefreshContracts', result.stderr)
self.assertIn('w._nf_refresh_requested == expected', result.stderr)
else:
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn('PASS: routine noise-floor cooldown (262 cases)', result.stdout)
def test_production_transitions(self):
self.run_production_transitions()
def test_unthrottled_periodic_callers_and_helper_negative_controls(self):
source = (ROOT / 'src/helpers/radiolib/RadioLibWrappers.cpp').read_text()
for caller in ('triggerNoiseFloorCalibrate', 'isReceivingPassive', 'requestPeriodicNoiseFloorRefresh'):
with self.subTest(caller=caller):
kind = 'bool' if caller == 'isReceivingPassive' else 'void'
body = method(source, f'{kind} RadioLibWrapper::{caller}(')
if caller == 'requestPeriodicNoiseFloorRefresh':
unthrottled = body[:body.index('{') + 1] + '\n requestNoiseFloorRefresh();\n}'
else:
self.assertEqual(body.count('requestPeriodicNoiseFloorRefresh();'), 1)
unthrottled = body.replace('requestPeriodicNoiseFloorRefresh();', 'requestNoiseFloorRefresh();')
self.run_production_transitions(source.replace(body, unthrottled), expect_failure=True)
if __name__ == '__main__':
unittest.main()