Defer busy Companion TX-power updates without interrupting RX

A settings save could reach the power setter while RX, TX, duty cycling or a
profile transition owned the radio, returning BAD_STATE after earlier settings
had already saved. Distinguish busy admission from a hardware rejection and
reuse the existing command route, storage and three-second deadline for power.
Acknowledge only after hardware acceptance and a successful preference save.

Skip hardware work for verified unchanged power. Invalidate that verification
after failed writes or radio initialization, retaining rollback and exclusive
beacon/Morse ownership. Keep the existing firmware memory limits and features.

Cover retries, captured routes, timeout/rollover, disconnect, partial writes,
failed saves and recovery. Keep ESP-NOW/simulation adapters compatible and add
ESP-NOW Companion compilation to the existing sequential smoke job.
This commit is contained in:
mikecarper committed 2026-10-25 11:57:03 -07:00
1 parent af69918094
commit 8dfdba3f6d
17 files changed
+470 -57

No files matched your search

+5 -2
View File
@@ -201,9 +201,11 @@ jobs:
- uses: actions/checkout@v4
- name: Setup Build Environment
uses: ./.github/actions/setup-build-environment
- name: Compile RP2040 contact storage and Arduino 3 ESP32-C6 room services
- name: Compile RP2040 contacts, ESP32-C6 rooms and ESP-NOW Companion
# One process: PlatformIO environments share this checkout's build tree.
run: pio run -e Xiao_rp2040_companion_radio_usb -e LilyGo_Tlora_C6_room_server_
run: >-
pio run -e Xiao_rp2040_companion_radio_usb
-e LilyGo_Tlora_C6_room_server_ -e Generic_ESPNOW_comp_radio_usb
management-reports:
runs-on: ubuntu-latest
@@ -736,6 +738,7 @@ jobs:
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_tx_power_transaction.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
+6
View File
@@ -246,6 +246,12 @@ catalog; bytes `0x2C`-`0x31` are parked and `0x35` is unused.
The sections below detail the most common frames. Refer to the source named
above for command bodies that are not expanded here.
Radio changes (`0x0B`, `0x0C`) can wait briefly for a safe radio window before
replying. TX power waits up to three seconds when reception or transmission is
busy. Its OK response confirms both hardware acceptance and a saved setting;
timeout or disconnect discards a waiting power change. Successful settings
commands are saved independently, so an error does not undo earlier commands.
### Private text when only the sender knows the recipient
Clients continue to send an ordinary `CMD_SEND_TXT_MSG` (`0x02`) with
+52 -19
View File
@@ -3546,12 +3546,24 @@ void MyMesh::finishRadioParamApply(float freq, float bw, uint8_t sf,
writeOKFrame(route);
}
void MyMesh::deferRadioCommand(uint8_t kind) {
command_radio_reply_route = _serial->captureReplyRoute();
if (command_radio_reply_route == NULL) {
writeErrFrame(ERR_CODE_BAD_STATE);
return;
}
command_radio_apply_pending = kind;
command_radio_apply_deadline = futureMillis(COMMAND_RADIO_APPLY_TIMEOUT_MS);
}
void MyMesh::cancelPendingRadioParamApply() {
if (!command_radio_apply_pending) return;
const bool restore_tuple = command_radio_apply_pending == 1;
command_radio_apply_pending = false;
command_radio_apply_deadline = 0;
command_radio_reply_route = NULL;
if (!restore_tuple) return;
// The requested tuple was not committed. Reassert the persisted tuple in
// case a failed hardware apply only restored part of the old configuration.
saved_radio_apply_pending = true;
@@ -3569,6 +3581,22 @@ void MyMesh::servicePendingRadioParamApply() {
// Leave enough queue headroom for the eventual command response.
if (_serial->isReplyRouteWriteBusy(command_radio_reply_route)) return;
const bool expired = static_cast<int32_t>(static_cast<uint32_t>(
_ms->getMillis() - command_radio_apply_deadline)) >= 0;
if (command_radio_apply_pending == 2) {
const auto result = expired ? RadioSettingResult::RadioBusy
: applyAndSaveTxPower(static_cast<int8_t>(command_radio_repeat), true);
if (result == RadioSettingResult::RadioBusy
&& !expired) return;
if (result == RadioSettingResult::Saved) writeOKFrame(command_radio_reply_route);
else writeErrFrame(result == RadioSettingResult::SaveFailed
? ERR_CODE_FILE_IO_ERROR : ERR_CODE_BAD_STATE, command_radio_reply_route);
// The checked setter owns any failed-save rollback. Retire this command
// without forcing a tuple restore or consuming a pending receive.
cancelPendingRadioParamApply();
return;
}
mesh::RadioParamApplyResult result = tryApplyRadioParams(
command_radio_freq, command_radio_bw, command_radio_sf, command_radio_cr);
if (result == mesh::RadioParamApplyResult::APPLIED) {
@@ -3585,8 +3613,7 @@ void MyMesh::servicePendingRadioParamApply() {
} else if (result == mesh::RadioParamApplyResult::FAILED) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG, command_radio_reply_route);
cancelPendingRadioParamApply();
} else if (command_radio_apply_deadline == _ms->getMillis()
|| millisHasNowPassed(command_radio_apply_deadline)) {
} else if (expired) {
writeErrFrame(ERR_CODE_BAD_STATE, command_radio_reply_route);
cancelPendingRadioParamApply();
}
@@ -3608,7 +3635,7 @@ bool MyMesh::scheduleTempRadio(float freq, float bw, uint8_t sf, uint8_t cr,
uint32_t timeout_mins, char* reply,
size_t reply_size) {
if (command_radio_apply_pending) {
snprintf(reply, reply_size, "ERR companion radio change is already pending");
snprintf(reply, reply_size, "ERR radio change pending");
return false;
}
@@ -5648,13 +5675,13 @@ void MyMesh::execCommand(char* cmd, char* reply) {
if (strcmp(key, "tx") == 0) {
long parsed;
if (!wcParseLong(value, -9, MAX_LORA_TX_POWER, parsed)) {
snprintf(reply, 160, "Error: TX power must be -9 to %d", MAX_LORA_TX_POWER);
snprintf(reply, 160, "Error: TX -9..%d dBm", MAX_LORA_TX_POWER);
} else {
const auto result = applyAndSaveTxPower(static_cast<int8_t>(parsed));
strcpy(reply, result == RadioSettingResult::Saved ? "OK"
: result == RadioSettingResult::RadioRejected
: result != RadioSettingResult::SaveFailed
? "Error: radio busy or TX power rejected"
: "Error: TX power could not be saved");
: "Error: TX not saved");
}
return;
}
@@ -6767,18 +6794,12 @@ void MyMesh::handleCmdFrame(size_t len) {
// Capture this requester instead of relying on the mutable last-RX
// route. This remains correct if a contact stream later unlocks or a
// different transport supplies another command before completion.
command_radio_reply_route = _serial->captureReplyRoute();
if (command_radio_reply_route == NULL) {
writeErrFrame(ERR_CODE_BAD_STATE);
return;
}
command_radio_apply_pending = true;
command_radio_freq = new_freq;
command_radio_bw = new_bw;
command_radio_sf = sf;
command_radio_cr = cr;
command_radio_repeat = repeat;
command_radio_apply_deadline = futureMillis(COMMAND_RADIO_APPLY_TIMEOUT_MS);
deferRadioCommand(1);
MESH_DEBUG_PRINTLN("Deferred CMD_SET_RADIO_PARAMS while radio is busy");
return;
}
@@ -6806,8 +6827,15 @@ void MyMesh::handleCmdFrame(size_t len) {
int8_t power = (int8_t)cmd_frame[1];
if (power < -9 || power > MAX_LORA_TX_POWER) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else if (command_radio_apply_pending) {
writeErrFrame(ERR_CODE_BAD_STATE);
} else {
const auto result = applyAndSaveTxPower(power);
if (result == RadioSettingResult::RadioBusy) {
command_radio_repeat = static_cast<uint8_t>(power);
deferRadioCommand(2);
return;
}
if (result == RadioSettingResult::Saved) writeOKFrame();
else writeErrFrame(result == RadioSettingResult::RadioRejected
? ERR_CODE_BAD_STATE : ERR_CODE_FILE_IO_ERROR);
@@ -8451,10 +8479,15 @@ bool MyMesh::saveAdvertLocation(double latitude, double longitude) {
return false;
}
MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(int8_t power) {
MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(int8_t power, bool from_pending) {
if (command_radio_apply_pending && !from_pending) return RadioSettingResult::RadioBusy;
const int8_t previous = _prefs.tx_power_dbm;
if (_radio_available && !radio_driver.setTxPower(power)) {
return RadioSettingResult::RadioRejected;
if (_radio_available) {
const auto result = radio_driver.trySetTxPower(power);
if (result != mesh::RadioParamApplyResult::APPLIED) {
return result == mesh::RadioParamApplyResult::BUSY
? RadioSettingResult::RadioBusy : RadioSettingResult::RadioRejected;
}
}
_prefs.tx_power_dbm = power;
if (savePrefs()) return RadioSettingResult::Saved;
@@ -10924,15 +10957,15 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
if (!mesh::cli::parseIntegerStrict(command + 7, parsed)) {
mesh::cli::formatError(reply, reply_capacity, "invalid TX power");
} else if (parsed < -9 || parsed > MAX_LORA_TX_POWER) {
snprintf(reply, reply_capacity, "Error: TX power must be -9 to %d",
snprintf(reply, reply_capacity, "Error: TX -9..%d dBm",
MAX_LORA_TX_POWER);
} else {
const int8_t requested = static_cast<int8_t>(parsed);
const auto result = applyAndSaveTxPower(requested);
if (result == RadioSettingResult::RadioRejected) {
if (result == RadioSettingResult::RadioRejected || result == RadioSettingResult::RadioBusy) {
mesh::cli::formatError(reply, reply_capacity, "radio busy or TX power rejected");
} else if (result == RadioSettingResult::SaveFailed) {
mesh::cli::formatError(reply, reply_capacity, "TX power could not be saved");
mesh::cli::formatError(reply, reply_capacity, "TX not saved");
} else {
strcpy(reply, "OK");
}
+5 -3
View File
@@ -675,8 +675,8 @@ private:
bool applyAndSaveFemRxGain(bool enabled);
bool applyAndSaveFemTxGain(bool enabled);
bool applyAndSaveRxBoostedGain(bool enabled);
enum class RadioSettingResult { Saved, RadioRejected, SaveFailed };
RadioSettingResult applyAndSaveTxPower(int8_t power);
enum class RadioSettingResult { Saved, RadioRejected, SaveFailed, RadioBusy };
RadioSettingResult applyAndSaveTxPower(int8_t power, bool from_pending = false);
bool handleCadCommand(const char* command, char* reply, size_t reply_size);
bool saveBluetoothNameOverride(const char* name);
bool applyAndSaveBluetoothName(const char* value, char* reply,
@@ -749,6 +749,7 @@ private:
void finishRadioParamApply(float freq, float bw, uint8_t sf, uint8_t cr,
uint8_t repeat,
BaseSerialInterface* route = nullptr);
void deferRadioCommand(uint8_t kind);
void cancelPendingRadioParamApply();
void servicePendingRadioParamApply();
void servicePendingSerialReply();
@@ -912,7 +913,8 @@ private:
bool _radio_available;
unsigned long radio_apply_retry_at;
uint8_t radio_apply_failures;
bool command_radio_apply_pending;
// 0: none, 1: modulation tuple, 2: TX power (stored in the repeat byte).
uint8_t command_radio_apply_pending;
float command_radio_freq;
float command_radio_bw;
uint8_t command_radio_sf;
+3
View File
@@ -16,6 +16,9 @@
#include "CompanionBluetooth.h"
#include "CompanionWireless.h"
#include "CompanionWiFi.h"
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
#include <WiFi.h>
#endif
#if COMPANION_FEATURE_USB_MOTA_SOURCE || COMPANION_FEATURE_BLE_MOTA_SOURCE
#include <helpers/ota/MotaSourceSerial.h>
#include <helpers/ota/OtaContext.h>
+4
View File
@@ -69,6 +69,10 @@ public:
uint32_t intID();
bool setTxPower(int8_t dbm);
mesh::RadioParamApplyResult trySetTxPower(int8_t dbm) {
return setTxPower(dbm) ? mesh::RadioParamApplyResult::APPLIED
: mesh::RadioParamApplyResult::FAILED;
}
};
#if ESPNOW_DEBUG_LOGGING && ARDUINO
+24 -11
View File
@@ -243,6 +243,7 @@ void RadioLibWrapper::begin() {
#if MESH_ENABLE_TX_BEACONS
if (_beacon_owned) return;
#endif
_dbm_applied = false;
#if MESH_HIL_PROFILE_TIMING
mesh::profiletiming::initializeClock();
#endif
@@ -320,8 +321,13 @@ uint32_t RadioLibWrapper::getRngSeed() {
}
bool RadioLibWrapper::setTxPower(int8_t dbm) {
return trySetTxPower(dbm) == mesh::RadioParamApplyResult::APPLIED;
}
mesh::RadioParamApplyResult RadioLibWrapper::trySetTxPower(int8_t dbm) {
using Result = mesh::RadioParamApplyResult;
#if MESH_ENABLE_TX_BEACONS
if (_beacon_owned) return false;
if (_beacon_owned) return Result::BUSY;
#endif
#if MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX
// A CW power-meter sweep may re-key the carrier. During Morse that would
@@ -331,37 +337,42 @@ bool RadioLibWrapper::setTxPower(int8_t dbm) {
#if MESH_ENABLE_MORSE_RX
|| _morse_rx_owned
#endif
) && _cw_active) return false;
) && _cw_active) return Result::BUSY;
#if MESH_ENABLE_CARRIER_TOOLS
if (_cw_active) {
if (_cw_stopping) return false;
if (_cw_stopping) return Result::BUSY;
#if defined(MESH_HIL_TX_FREQ_MHZ)
if (!hilPacketTransmitAllowed() || _cw_profile != _active_profile
|| _cw_generation != _profile_generation) return false;
|| _cw_generation != _profile_generation) return Result::FAILED;
#endif
// Permit meter sweeps through the board's normal power/PA calibration.
// SX127x's power setter enters standby, so re-key without resetting the
// modem or extending the original CW deadline.
if (applyCachedTxPower(dbm) != RADIOLIB_ERR_NONE) {
stopCarrierWave();
return false;
return Result::FAILED;
}
if (_radio->transmitDirect() != RADIOLIB_ERR_NONE) {
stopCarrierWave();
return false;
return Result::FAILED;
}
_cur_dbm = dbm;
_dbm_valid = true;
return true;
return Result::APPLIED;
}
#else
if (_cw_active) return false;
if (_cw_active) return Result::BUSY;
#endif
#endif
// Settings screens also submit TX power when only another field changed.
// The verified normal power needs no SPI, standby, or interruption of RX.
if (_dbm_valid && _dbm_applied && _cur_dbm == dbm
&& !_profile_repair_required) return Result::APPLIED;
const uint8_t resume_rx = beginReconfigure();
if (resume_rx > 1) return false;
if (resume_rx > 1) return resume_rx == 2 ? Result::BUSY : Result::FAILED;
const int16_t status = applyCachedTxPower(dbm);
_dbm_applied = status == RADIOLIB_ERR_NONE;
if (status == RADIOLIB_ERR_NONE) {
_cur_dbm = dbm;
_dbm_valid = true;
@@ -370,7 +381,7 @@ bool RadioLibWrapper::setTxPower(int8_t dbm) {
(int)dbm, (int)status);
}
endReconfigure(resume_rx);
return status == RADIOLIB_ERR_NONE;
return status == RADIOLIB_ERR_NONE ? Result::APPLIED : Result::FAILED;
}
uint8_t RadioLibWrapper::beginReconfigure() {
@@ -516,6 +527,7 @@ bool RadioLibWrapper::restoreAfterDeepInit() {
#if MESH_ENABLE_TX_BEACONS
if (_beacon_owned) cacheBeaconRestoreParams();
#endif
_dbm_applied = false;
if (!radioDeepInit()) return false;
_rx_ps_armed = false;
@@ -533,7 +545,8 @@ bool RadioLibWrapper::restoreAfterDeepInit() {
restored = restored && applyProtocol(_physical_protocol);
#endif
if (_dbm_valid) {
restored = applyCachedTxPower(_cur_dbm) == RADIOLIB_ERR_NONE && restored;
_dbm_applied = applyCachedTxPower(_cur_dbm) == RADIOLIB_ERR_NONE;
restored = _dbm_applied && restored;
}
if (_rx_boosted_gain_valid) {
restored = applyRxBoostedGainMode(_cur_rx_boosted_gain) && restored;
+2
View File
@@ -108,6 +108,7 @@ protected:
int8_t _cur_dbm;
bool _cur_rx_boosted_gain;
bool _params_valid, _dbm_valid, _rx_boosted_gain_valid;
bool _dbm_applied = false;
mesh::RadioProfiles _profiles;
uint8_t _active_profile = 0;
uint16_t _physical_preamble = 0;
@@ -574,6 +575,7 @@ public:
const uint32_t* rx_ps_timings = NULL);
uint32_t getRngSeed();
bool setTxPower(int8_t dbm);
mesh::RadioParamApplyResult trySetTxPower(int8_t dbm);
virtual float getCurrentRSSI() =0;
virtual uint8_t getSpreadingFactor() const { return LORA_SF; }
+3
View File
@@ -76,6 +76,9 @@ public:
}
void powerOff() {}
bool setTxPower(int8_t /*dbm*/) { return true; }
mesh::RadioParamApplyResult trySetTxPower(int8_t /*dbm*/) {
return mesh::RadioParamApplyResult::APPLIED;
}
uint32_t getRngSeed() { return _simRandom(); }
uint32_t getPacketsRecv() const { return n_recv; }
uint32_t getPacketsSent() const { return n_sent; }
+1 -1
View File
@@ -75,7 +75,7 @@ struct LLCC68 : PhysicalLayer { int16_t setCRC(uint8_t length) { return setCrc(l
struct RadioLibWrapper {
PhysicalLayer* _radio;
mesh::MainBoard* _board;
bool _params_valid = true, _rx_ps_armed = true, _dbm_valid = false, _rx_boosted_gain_valid = false;
bool _params_valid = true, _rx_ps_armed = true, _dbm_valid = false, _dbm_applied = false, _rx_boosted_gain_valid = false;
bool _profile_repair_required = false;
int state = 1;
float _cur_freq = 906.875f, _cur_bw = 250;
+10 -1
View File
@@ -81,7 +81,7 @@ struct RadioLibWrapper {
float _cur_freq = 906, _cur_bw = 62.5;
uint8_t _cur_sf = 7, _cur_cr = 5;
int8_t _cur_dbm = 20;
bool _params_valid = true, _dbm_valid = true;
bool _params_valid = true, _dbm_valid = true, _dbm_applied = true;
bool _cw_active = false, pending = false, packet = false, busy = false, calibrating = false;
bool _rx_ps_enabled = false, _rx_ps_armed = false, _rx_hold_continuous = false;
bool _rx_ps_continuous_fallback = false, _nf_calib_active = false;
@@ -190,6 +190,15 @@ mesh::RadioProfileParams beacon(bool meshtext = false) {
}
int main() {
unsigned cases = 0;
{
RadioLibWrapper w; w.deep_ok = false;
assert(!w.restoreAfterDeepInit() && !w._dbm_applied);
w.deep_ok = true; w.power_fail = true;
assert(!w.restoreAfterDeepInit() && !w._dbm_applied);
w.power_fail = false;
assert(w.restoreAfterDeepInit() && w._dbm_applied);
++cases;
}
for (bool dual : {false, true}) for (bool rxps : {false, true}) for (bool mt : {false, true}) {
RadioLibWrapper w; now_ms = 100;
if (dual) {
+6 -2
View File
@@ -159,7 +159,7 @@ struct RadioLibWrapper : mesh::Radio {
float frequency=909.5;
float _cur_freq=909.5;
int8_t _cur_dbm=17;
bool _dbm_valid=true;
bool _dbm_valid=true, _dbm_applied=true;
RadioLibWrapper() {
g_mock_millis=100; state=STATE_RX;
_profiles.primary.freq=909.5;
@@ -193,6 +193,7 @@ struct RadioLibWrapper : mesh::Radio {
}
virtual float getCurrentRSSI() { ++rssi_reads; g_mock_millis+=rssi_latency; return rssi; }
bool setTxPower(int8_t dbm);
mesh::RadioParamApplyResult trySetTxPower(int8_t dbm);
@NATIVE_HOOKS@
#if MESH_ENABLE_MORSE_RX || !defined(STM32_PLATFORM)
@RX_HOOKS@
@@ -230,9 +231,11 @@ struct RadioLibWrapper : mesh::Radio {
}
bool restoreAfterDeepInit() {
++resets;
_dbm_applied=false;
if (reset_ok) {
chip.carrier=false; chip.fsk=false; chip.stop_result=chip.standby_result=0;
return applyCachedTxPower(_cur_dbm)==0;
_dbm_applied=applyCachedTxPower(_cur_dbm)==0;
return _dbm_applied;
}
return reset_ok;
}
@@ -455,6 +458,7 @@ def production_harness():
source = (ROOT/'src/helpers/radiolib/RadioLibWrappers.cpp').read_text()
signatures = ('uint8_t RadioLibWrapper::beginReconfigure(',
'bool RadioLibWrapper::setTxPower(',
'mesh::RadioParamApplyResult RadioLibWrapper::trySetTxPower(',
'mesh::RadioParamApplyResult RadioLibWrapper::setCarrierWave(',
'uint32_t RadioLibWrapper::carrierWaveRemainingMillis(',
'bool RadioLibWrapper::stopCarrierWave(',
+163 -14
View File
@@ -52,14 +52,15 @@ void recalcRxPowerSavingFromLevel(uint8_t,uint8_t sf,float bw,uint8_t,
struct Driver {
int8_t power=3;
unsigned calls=0;
std::deque<bool> accepted;
bool setTxPower(int8_t requested) {
std::deque<Apply> accepted;
Apply trySetTxPower(int8_t requested) {
++calls;
bool ok=true;
if (!accepted.empty()) { ok=accepted.front(); accepted.pop_front(); }
if (ok) power=requested;
return ok;
Apply result=Apply::APPLIED;
if (!accepted.empty()) { result=accepted.front(); accepted.pop_front(); }
if (result==Apply::APPLIED) power=requested;
return result;
}
bool setTxPower(int8_t requested) { return trySetTxPower(requested)==Apply::APPLIED; }
bool supportsRxBoostedGainMode() const { return false; }
bool setRxBoostedGainMode(bool) { return true; }
bool supportsRxPowerSaving() const { return false; }
@@ -74,7 +75,8 @@ struct MyMesh {
bool storage_accepts=true, _radio_available=true, outbound=false;
unsigned saves=0, applies=0;
std::deque<Apply> accepted;
bool saved_radio_apply_pending=false, command_radio_apply_pending=false;
bool saved_radio_apply_pending=false;
uint8_t command_radio_apply_pending=0;
uint32_t radio_apply_retry_at=0, command_radio_apply_deadline=0;
uint8_t radio_apply_failures=0;
float command_radio_freq=0, command_radio_bw=0;
@@ -100,8 +102,9 @@ struct MyMesh {
return result;
}
bool applySavedRadioParams();
RadioSettingResult applyAndSaveTxPower(int8_t);
RadioSettingResult applyAndSaveTxPower(int8_t,bool from_pending=false);
void finishRadioParamApply(float,float,uint8_t,uint8_t,uint8_t,BaseSerialInterface* = nullptr);
void deferRadioCommand(uint8_t);
void servicePendingRadioParamApply();
void cancelPendingRadioParamApply();
void frame(size_t len) { @FRAME_BRANCHES@ }
@@ -122,6 +125,11 @@ struct MyMesh {
frame(12);
}
void expectError(uint8_t code,BaseSerialInterface* route=nullptr) {
if(replies.size()!=1 || replies[0].error!=code) {
fprintf(stderr,"Expected reply %u; count=%zu first=%u pending=%u saves=%u now=%u\n",
unsigned(code),replies.size(),replies.empty()?255u:unsigned(replies[0].error),
unsigned(command_radio_apply_pending),saves,unsigned(now_ms));
}
assert(replies.size()==1 && replies[0].error==code);
assert(replies[0].route==(route?route:&original));
}
@@ -133,11 +141,12 @@ struct MyMesh {
};
@METHODS@
int main() {
// Reject unavailable/busy hardware before either persisting or reporting it.
// A real hardware rejection remains an immediate error, without a save.
{
MyMesh m;radio_driver.accepted={false};m.powerFrame(17);
MyMesh m;radio_driver.accepted={Apply::FAILED};m.powerFrame(17);
m.expectError(ERR_CODE_BAD_STATE);
assert(m.saves==0 && m._prefs.tx_power_dbm==3 && radio_driver.power==3);
assert(!m.command_radio_apply_pending && m.saves==0);
assert(m._prefs.tx_power_dbm==3 && m.durable.tx_power_dbm==3 && radio_driver.power==3);
}
for (int value : {-10,23}) {
MyMesh m;m.powerFrame(uint8_t(value));m.expectError(ERR_CODE_ILLEGAL_ARG);
@@ -155,6 +164,137 @@ int main() {
MyMesh m;m._radio_available=false;m.powerFrame(17);m.expectError(0);
assert(m.durable.tx_power_dbm==17 && radio_driver.calls==0);
}
// App settings can reach power while an RX packet is in progress. Keep the
// original requester and acknowledge only after the hardware and save pass.
for (int power : {-9,17,22}) {
MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::BUSY,Apply::APPLIED};
m.powerFrame(uint8_t(power));
assert(m.replies.empty() && m.command_radio_apply_pending==2 && m.saves==0);
assert(m.command_radio_reply_route==&m.original);
assert(int8_t(m.command_radio_repeat)==power);
assert(m.command_radio_apply_deadline==now_ms+COMMAND_RADIO_APPLY_TIMEOUT_MS);
assert(m._prefs.tx_power_dbm==3 && m.durable.tx_power_dbm==3 && radio_driver.power==3);
m.serial.current=&m.other;
m.servicePendingRadioParamApply();
assert(m.replies.empty() && m.command_radio_apply_pending==2 && m.saves==0);
m.servicePendingRadioParamApply();m.expectError(0);
assert(!m.command_radio_apply_pending && m.saves==1);
assert(m.command_radio_reply_route==nullptr && m.command_radio_apply_deadline==0);
assert(m._prefs.tx_power_dbm==power && m.durable.tx_power_dbm==power);
assert(radio_driver.power==power);
}
// Reproduce the app's multi-command settings save: the tuple is durable
// before the following power command encounters a temporary receive lease.
{
MyMesh m;m.tupleFrame();m.expectError(0);m.replies.clear();
radio_driver.accepted={Apply::BUSY,Apply::APPLIED};m.powerFrame(17);
assert(m.replies.empty() && m.command_radio_apply_pending==2 && m.saves==1);
assert(m.durable.freq==920 && m.durable.tx_power_dbm==3);
m.servicePendingRadioParamApply();m.expectError(0);
assert(m.saves==2 && m.durable.freq==920 && m.durable.tx_power_dbm==17);
}
// Wait for reply-queue headroom before applying or committing the candidate.
{
MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::APPLIED};m.powerFrame(17);
m.serial.current=&m.other;m.serial.busy=true;
const unsigned calls=radio_driver.calls;
m.servicePendingRadioParamApply();
assert(m.replies.empty() && m.saves==0 && radio_driver.calls==calls);
assert(m.command_radio_apply_pending==2 && m.durable.tx_power_dbm==3);
m.serial.busy=false;m.servicePendingRadioParamApply();m.expectError(0);
assert(!m.command_radio_apply_pending && m.durable.tx_power_dbm==17);
}
// A second settings request cannot replace the pending candidate or route.
for (bool tuple_request : {false,true}) {
MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::APPLIED};m.powerFrame(17);
m.serial.current=&m.other;
if(tuple_request)m.tupleFrame();else m.powerFrame(12);
m.expectError(ERR_CODE_BAD_STATE,&m.other);
assert(m.command_radio_apply_pending==2 && m.command_radio_repeat==17);
assert(m.command_radio_reply_route==&m.original && m.saves==0 && radio_driver.calls==1);
char reply[160]={};
assert(m.command("set tx 12",reply) && strstr(reply,"Error"));
assert(m.command_radio_apply_pending==2 && m.command_radio_repeat==17);
assert(m.saves==0 && radio_driver.calls==1 && m._prefs.tx_power_dbm==3);
m.replies.clear();m.servicePendingRadioParamApply();m.expectError(0);
assert(m.durable.tx_power_dbm==17);
}
// The power request also respects a tuple already occupying the shared slot.
{
MyMesh m;m.accepted={Apply::BUSY,Apply::APPLIED};m.tupleFrame();
assert(m.command_radio_apply_pending==1);
m.serial.current=&m.other;m.powerFrame(17);m.expectError(ERR_CODE_BAD_STATE,&m.other);
assert(m.command_radio_apply_pending==1 && radio_driver.calls==0 && m.saves==0);
m.replies.clear();m.servicePendingRadioParamApply();m.expectError(0);
assert(m.durable.freq==920 && m.durable.tx_power_dbm==3);
}
// Losing the original route cancels the unsaved power, even when another
// transport supplies commands before the deferred operation is serviced.
{
MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::APPLIED};m.powerFrame(17);
m.serial.current=&m.other;m.serial.disconnected=&m.original;
m.servicePendingRadioParamApply();
assert(!m.command_radio_apply_pending && m.replies.empty() && m.saves==0);
assert(!m.saved_radio_apply_pending);
assert(m._prefs.tx_power_dbm==3 && m.durable.tx_power_dbm==3 && radio_driver.power==3);
assert(radio_driver.calls==1);
}
{
MyMesh m;m.serial.current=nullptr;radio_driver.accepted={Apply::BUSY};m.powerFrame(17);
assert(m.replies.size()==1 && m.replies[0].error==ERR_CODE_BAD_STATE);
assert(!m.command_radio_apply_pending && m.saves==0 && radio_driver.power==3);
}
// Sustained busy hardware times out once on the captured route. Check both
// the ordinary clock and a deadline that crosses the millis rollover.
for (uint32_t start : {100u,UINT32_MAX-1000u}) {
MyMesh m;now_ms=start;
radio_driver.accepted={Apply::BUSY,Apply::BUSY,Apply::BUSY};m.powerFrame(17);
m.serial.current=&m.other;
const uint32_t deadline=m.command_radio_apply_deadline;
now_ms=deadline-1;m.servicePendingRadioParamApply();
assert(m.replies.empty() && m.command_radio_apply_pending==2 && m.saves==0);
now_ms=deadline;m.servicePendingRadioParamApply();m.expectError(ERR_CODE_BAD_STATE);
assert(!m.command_radio_apply_pending && !m.saved_radio_apply_pending && m.saves==0);
assert(m._prefs.tx_power_dbm==3 && m.durable.tx_power_dbm==3 && radio_driver.power==3);
m.servicePendingRadioParamApply();assert(m.replies.size()==1);
}
// Expiry must not commit a late success, including when queue backpressure
// prevented the timeout reply until after its original deadline.
for (bool blocked_queue : {false,true}) {
MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::APPLIED};m.powerFrame(17);
m.serial.current=&m.other;now_ms=m.command_radio_apply_deadline;
if(blocked_queue) {
m.serial.busy=true;m.servicePendingRadioParamApply();
assert(m.replies.empty() && m.saves==0 && radio_driver.calls==1);
m.serial.busy=false;++now_ms;
}
m.servicePendingRadioParamApply();m.expectError(ERR_CODE_BAD_STATE);
assert(!m.command_radio_apply_pending && !m.saved_radio_apply_pending);
assert(m.saves==0 && radio_driver.calls==1);
assert(m.durable.tx_power_dbm==3 && radio_driver.power==3);
}
// A later hardware rejection must not be mistaken for another busy retry.
{
MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::FAILED};m.powerFrame(17);
m.serial.current=&m.other;m.servicePendingRadioParamApply();m.expectError(ERR_CODE_BAD_STATE);
assert(!m.command_radio_apply_pending && !m.saved_radio_apply_pending && m.saves==0);
assert(m.durable.tx_power_dbm==3 && radio_driver.power==3);
}
// Deferred commits retain the same storage-failure rollback as immediate
// requests, and cannot send a later success after reporting the failure.
for (bool rollback_busy : {false,true}) {
MyMesh m;m.storage_accepts=false;
radio_driver.accepted={Apply::BUSY,Apply::APPLIED,
rollback_busy?Apply::BUSY:Apply::APPLIED,Apply::APPLIED};
m.powerFrame(17);assert(m.replies.empty() && m.saves==0);
m.serial.current=&m.other;m.servicePendingRadioParamApply();m.expectError(ERR_CODE_FILE_IO_ERROR);
assert(!m.command_radio_apply_pending && m.saves==1);
assert(m._prefs.tx_power_dbm==3 && m.durable.tx_power_dbm==3);
assert(m.saved_radio_apply_pending==rollback_busy);
if(rollback_busy)m.recover();
assert(!m.saved_radio_apply_pending && radio_driver.power==3 && m.replies.size()==1);
m.servicePendingRadioParamApply();assert(m.replies.size()==1);
}
// A failed commit restores RAM and the physical output-power setting.
{
MyMesh m;m.storage_accepts=false;m.powerFrame(17);m.expectError(ERR_CODE_FILE_IO_ERROR);
@@ -164,7 +304,8 @@ int main() {
// RX may become busy during flash I/O. Retain recovery through a rejected
// rollback and through a later successful tuple restore but failed power apply.
{
MyMesh m;m.storage_accepts=false;radio_driver.accepted={true,false,false,true};
MyMesh m;m.storage_accepts=false;
radio_driver.accepted={Apply::APPLIED,Apply::BUSY,Apply::FAILED,Apply::APPLIED};
m.powerFrame(17);m.expectError(ERR_CODE_FILE_IO_ERROR);
assert(m._prefs.tx_power_dbm==3 && radio_driver.power==17 && m.saved_radio_apply_pending);
m.recover();assert(m.saved_radio_apply_pending && radio_driver.power==17);
@@ -176,7 +317,8 @@ int main() {
}
// ASCII and framed setters share identical hardware/durability semantics.
{
MyMesh m;char reply[160]={};radio_driver.accepted={false};
MyMesh m;char reply[160]={};radio_driver.accepted={Apply::FAILED,Apply::BUSY};
assert(m.command("set tx 17",reply) && strstr(reply,"Error"));
assert(m.command("set tx 17",reply) && strstr(reply,"Error"));
assert(m.saves==0 && m._prefs.tx_power_dbm==3);
m.storage_accepts=false;
@@ -253,7 +395,8 @@ struct ContactStore {
struct MyMesh {
ContactStore store;
ContactStore* _store=&store;
bool _radio_available=true, _iter_started=false, command_radio_apply_pending=false;
bool _radio_available=true, _iter_started=false;
uint8_t command_radio_apply_pending=0;
bool saved_radio_apply_pending=false, _terminal_trace_pending=false;
uint32_t radio_apply_retry_at=0, _scheduled_reboot_at=0;
uint32_t emergency_client_repeat_send_at=0, dirty_contacts_expiry=0;
@@ -283,6 +426,11 @@ struct MyMesh {
int main() {
MyMesh m;
assert(!m.hasPendingWork());
m.command_radio_apply_pending=2;
assert(m.hasPendingWork()); // Deferred power must keep the retry loop awake.
assert(m.pendingWorkReason()==mesh::powerdiag::mask(mesh::powerdiag::Reason::Scheduled));
m.command_radio_apply_pending=0;
assert(!m.hasPendingWork());
m.store._contact_recovery=ContactStore::ContactRecoveryPending;
assert(m.hasPendingWork()); // Storage repair still needs the outer loop.
m.store._contact_recovery=ContactStore::ContactRecoveryLegacy;
@@ -376,6 +524,7 @@ class CompanionRadioSettingsTransactionTests(unittest.TestCase):
'bool MyMesh::applySavedRadioParams(',
'MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(',
'void MyMesh::finishRadioParamApply(',
'void MyMesh::deferRadioCommand(',
'void MyMesh::cancelPendingRadioParamApply(',
'void MyMesh::servicePendingRadioParamApply(',
))
+6 -1
View File
@@ -40,7 +40,7 @@ struct FakeWiFi {
bool mode(wifi_mode_t value){if(failure==1)return false;current=value;return true;}
} WiFi;
namespace mesh {
enum class RadioParamApplyResult {APPLIED};
enum class RadioParamApplyResult {APPLIED,BUSY,FAILED};
namespace wifi {
constexpr uint8_t kProtocolMask=15,kLongRangeRadioOwner=1,kLongRangeBridgeOwner=2;
void setLongRangeOwner(uint8_t,bool){}
@@ -89,6 +89,11 @@ int main(){
assert(radio.isEnabled()&&sdk_active&&wake_refs==1&&!WiFi.autoreconnect);
const int started=init_calls;radio.init();assert(init_calls==started&&wake_refs==1);
assert(radio.setTxPower(13)&&power==52);
assert(radio.trySetTxPower(14)==mesh::RadioParamApplyResult::APPLIED&&power==56);
failure=6;
assert(radio.trySetTxPower(15)==mesh::RadioParamApplyResult::FAILED&&power==56);
failure=0;
assert(radio.trySetTxPower(13)==mesh::RadioParamApplyResult::APPLIED&&power==52);
uint8_t raw[255]={0x15,1,2,3},out[255];
deliver(raw,4);assert(radio.recvRaw(out,sizeof(out))==4&&!memcmp(raw,out,4));
// Stop in the middle of a fragmented TX: do not report success or send fragment 2.
+1 -1
View File
@@ -324,7 +324,7 @@ struct RadioLibWrapper {
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;
bool _rx_ps_armed=true,_params_valid=true,_dbm_valid=false,_dbm_applied=false,_rx_boosted_gain_valid=false;
int state=1;uint8_t _cur_sf=11,_cur_cr=5;float _cur_freq=906.875,_cur_bw=250;
int8_t _cur_dbm=22;bool _cur_rx_boosted_gain=false;
mesh::RadioProfileProtocol _physical_protocol=mesh::RadioProfileProtocol::Meshtext;
+175
View File
@@ -0,0 +1,175 @@
"""Execute real TX-power admission, retry and unchanged-value behavior."""
from pathlib import Path
import unittest
from test_radio_interrupt_recovery import compile_run
from test_radio_receive_contract import method
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <cassert>
#include <cstdint>
#include <initializer_list>
#define MESH_DEBUG_PRINTLN(...) ((void)0)
#define RADIOLIB_ERR_NONE 0
#define STATE_IDLE 0
#define STATE_RX 1
#define STATE_TX_WAIT 3
namespace mesh { enum class RadioParamApplyResult { APPLIED, BUSY, FAILED }; }
using Result = mesh::RadioParamApplyResult;
struct Chip {
int standby_result = 0, power_result = 0, direct_result = 0;
int8_t power = 17;
bool write_before_failure = false;
unsigned standbys = 0, writes = 0, keys = 0;
int standby() { ++standbys; return standby_result; }
int setOutputPower(int8_t requested) {
++writes;
if (!power_result || write_before_failure) power = requested;
return power_result;
}
int transmitDirect() { ++keys; return direct_result; }
};
struct RadioLibWrapper {
Chip chip;
Chip* _radio = &chip;
uint8_t state = STATE_RX;
int8_t _cur_dbm = 17;
bool _dbm_valid = true, _dbm_applied = true, _profile_repair_required = false;
bool _profile_refresh_required = false, _rx_hold_continuous = false;
bool _rx_ps_armed = true, _cw_active = false, _cw_stopping = false;
bool _beacon_owned = false, _beacon_restoring = false;
bool _morse_owned = false, _morse_rx_owned = false;
bool pending = false, busy = false, packet = false;
unsigned irq_reads = 0, busy_reads = 0, packet_reads = 0, claims = 0;
unsigned resumes = 0, carrier_stops = 0;
bool hasPendingRadioInterrupt() { ++irq_reads; return pending; }
bool claimRadioInterrupt() { ++claims; bool value = pending; pending = false; return value; }
bool isChipBusy() { ++busy_reads; return busy; }
bool isReceivingPacket() { ++packet_reads; return packet; }
int16_t applyCachedTxPower(int8_t requested) { return chip.setOutputPower(requested); }
bool stopCarrierWave() { ++carrier_stops; _cw_active = false; return true; }
void startRecv() { ++resumes; state = STATE_RX; }
uint8_t beginReconfigure();
void endReconfigure(bool);
bool setTxPower(int8_t);
Result trySetTxPower(int8_t);
};
@METHODS@
int main() {
// A temporary receive/TX condition does not become a hardware rejection.
for (unsigned reason = 0; reason < 4; ++reason) {
RadioLibWrapper radio;
radio.pending = reason == 0;
if (reason == 1) radio.state = STATE_TX_WAIT;
radio.busy = reason == 2;
radio.packet = reason == 3;
assert(radio.trySetTxPower(10) == Result::BUSY);
assert(!radio.setTxPower(10));
assert(!radio.chip.writes && !radio.chip.standbys && !radio.resumes);
assert(radio._cur_dbm == 17 && radio.chip.power == 17);
if (reason == 0) assert(radio.pending && !radio.claims);
radio.pending = radio.busy = radio.packet = false;
radio.state = STATE_RX;
assert(radio.trySetTxPower(10) == Result::APPLIED);
assert(radio.chip.writes == 1 && radio.chip.standbys == 1 && radio.resumes == 1);
assert(radio._cur_dbm == 10 && radio._dbm_valid && radio._dbm_applied && radio.chip.power == 10);
}
// App saves can resend the current power while an unread packet owns RX.
for (unsigned reason = 0; reason < 4; ++reason) {
RadioLibWrapper radio;
radio.pending = reason == 0;
if (reason == 1) radio.state = STATE_TX_WAIT;
radio.busy = reason == 2;
radio.packet = reason == 3;
const auto original_state = radio.state;
assert(radio.trySetTxPower(17) == Result::APPLIED && radio.setTxPower(17));
assert(!radio.chip.writes && !radio.chip.standbys && !radio.resumes);
assert(!radio.irq_reads && !radio.busy_reads && !radio.packet_reads && !radio.claims);
assert(radio.state == original_state && radio._rx_ps_armed);
assert(radio.pending == (reason == 0) && radio.packet == (reason == 3));
}
// A matching number without a valid hardware cache must still be applied.
{
RadioLibWrapper radio; radio._dbm_valid = false; radio.busy = true;
assert(radio.trySetTxPower(17) == Result::BUSY && !radio.chip.writes);
radio.busy = false;
assert(radio.trySetTxPower(17) == Result::APPLIED && radio.chip.writes == 1);
}
// A failed earlier transaction cannot be hidden by the unchanged shortcut.
{
RadioLibWrapper radio; radio._profile_repair_required = true; radio.busy = true;
assert(radio.trySetTxPower(17) == Result::BUSY && !radio.chip.writes);
radio.busy = false;
assert(radio.trySetTxPower(17) == Result::APPLIED);
assert(radio.chip.standbys == 1 && radio.chip.writes == 1 && radio.resumes == 1);
}
// Beacon and Morse owners remain exclusive even for the cached power.
for (unsigned owner = 0; owner < 4; ++owner) {
RadioLibWrapper radio;
radio._beacon_owned = owner == 0;
radio._cw_active = owner != 0;
radio._morse_owned = owner == 1;
radio._morse_rx_owned = owner == 2;
radio._cw_stopping = owner == 3;
assert(radio.trySetTxPower(17) == Result::BUSY && !radio.setTxPower(17));
assert(!radio.chip.writes && !radio.chip.standbys && !radio.chip.keys);
assert(!radio.resumes && !radio.carrier_stops);
}
#if MESH_ENABLE_CARRIER_TOOLS
{
RadioLibWrapper radio; radio._cw_active = true;
assert(radio.trySetTxPower(17) == Result::APPLIED);
assert(radio.chip.writes == 1 && radio.chip.keys == 1 && !radio.resumes);
}
#endif
// A failed SPI command may already have changed the physical output power.
// The old cached request is usable again only after a verified hardware apply.
{
RadioLibWrapper radio;
radio.chip.power_result = -705; radio.chip.write_before_failure = true;
assert(radio.trySetTxPower(10) == Result::FAILED);
assert(radio._cur_dbm == 17 && radio.chip.power == 10 && !radio._dbm_applied);
radio.busy = true;
assert(radio.trySetTxPower(17) == Result::BUSY && radio.chip.writes == 1);
radio.busy = false; radio.chip.power_result = 0;
assert(radio.trySetTxPower(17) == Result::APPLIED && radio.chip.writes == 2);
assert(radio.chip.power == 17 && radio._dbm_applied);
assert(radio.trySetTxPower(17) == Result::APPLIED && radio.chip.writes == 2);
}
// Real command and standby failures remain failures, with the requested cache intact.
{
RadioLibWrapper radio; radio.chip.power_result = -705;
assert(radio.trySetTxPower(10) == Result::FAILED && !radio.setTxPower(10));
assert(radio._cur_dbm == 17 && radio.chip.power == 17);
assert(!radio._dbm_applied);
assert(radio.chip.writes == 2 && radio.resumes == 2);
}
{
RadioLibWrapper radio; radio.chip.standby_result = -705;
assert(radio.trySetTxPower(10) == Result::FAILED && !radio.setTxPower(10));
assert(!radio.chip.writes && radio._profile_repair_required && !radio.resumes);
}
}
'''
class RadioTxPowerTransactionTests(unittest.TestCase):
def test_actual_busy_retry_noop_ownership_and_failures(self):
source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text()
methods = "\n".join(method(source, signature) for signature in (
"uint8_t RadioLibWrapper::beginReconfigure(",
"void RadioLibWrapper::endReconfigure(",
"bool RadioLibWrapper::setTxPower(",
"mesh::RadioParamApplyResult RadioLibWrapper::trySetTxPower("))
for carrier_tools in (0, 1):
with self.subTest(carrier_tools=carrier_tools):
compile_run(HARNESS.replace("@METHODS@", methods), defines=(
"-DMESH_ENABLE_TX_BEACONS=1", "-DMESH_ENABLE_MORSE_RX=1",
f"-DMESH_ENABLE_CARRIER_TOOLS={carrier_tools}"))
if __name__ == "__main__":
unittest.main()
+4 -2
View File
@@ -47,7 +47,7 @@ struct Driver {
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;
bool _dbm_valid = true, _dbm_applied = true, _rx_boosted_gain_valid = false;
int8_t _cur_dbm = 7;
bool _cur_rx_boosted_gain = false;
unsigned power_commands = 0, gain_commands = 0;
@@ -75,6 +75,7 @@ struct Driver {
uint8_t beginReconfigure();
void endReconfigure(bool);
bool setTxPower(int8_t);
mesh::RadioParamApplyResult trySetTxPower(int8_t);
} radio_driver;
@WRAPPER_METHODS@
struct Prefs {
@@ -280,7 +281,8 @@ def harness(role, mutation=None):
wrapper = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text()
signatures = ["uint8_t RadioLibWrapper::beginReconfigure()",
"void RadioLibWrapper::endReconfigure(bool resume_rx)",
"bool RadioLibWrapper::setTxPower(int8_t dbm)"]
"bool RadioLibWrapper::setTxPower(int8_t dbm)",
"mesh::RadioParamApplyResult RadioLibWrapper::trySetTxPower(int8_t dbm)"]
if role == "room":
signatures.append("bool RadioLibWrapper::setRxBoostedGainMode(bool enabled)")
methods = "\n".join(body(wrapper, signature) for signature in signatures)