Apply infrastructure radio changes after a two-second reply handoff

Persist set freq and set radio atomically, then apply through the existing safe radio scheduler without rebooting. Retain the old tuple until the delay and reply handoff finish, including queued room/sensor replies and TX-complete callbacks. End temporary sessions when accepting a permanent change.

Display applied RF settings even when radio2 is off. Share radio validation across immediate and scheduled setters to preserve STM32 Mini flash capacity. Update command documentation, regression coverage, and CI.

Validated production timing, persistence, display, CLI, fleet/reply, RX, sleep and Morse regressions. W12 repeater, room-server and sensor builds pass. Canonical STM32 Mini Repeater passes flash and RAM admission with 448 bytes of flash remaining. Physical W12 RF testing was not performed.
This commit is contained in:
mikecarper committed 2026-10-26 00:38:13 -07:00
1 parent c98684021a
commit 4f76345b67
22 files changed
+414 -67

No files matched your search

+1
View File
@@ -616,6 +616,7 @@ jobs:
python3 -B test/test_companion_radio_gain_restore.py -v
python3 -B test/test_room_sensor_radio_restore.py -v
python3 -B test/test_common_radio_persistence.py -v
python3 -B test/test_infrastructure_radio_apply.py -v
python3 -B test/test_common_cli_save_results.py -v
python3 -B test/test_common_cli_tx_pending.py -v
python3 -B test/test_radio_rx_settings_transaction.py -v
+1 -1
View File
@@ -103,7 +103,7 @@ over the normal binary USB, BLE, or TCP connection:
| [`get bootloader.ver`](cli_commands.md#view-the-bootloader-version-nrf52-only) | nRF52 Companion terminal and command `0x42`; unidentifiable metadata returns `unknown` |
| [`get/set sdcard`](test-results/companion_cli_parity_2026-10-23.md#shared-sd-commands) | nRF52 builds with SD OTA storage; maintenance retains OTA and Ethernet ownership guards |
| `erase` | Local terminal and binary command `0x42`; reboot after erasing stored settings |
| `set freq <MHz>` | Local terminal, binary command `0x42`, and authorized LoRa CLI; reboot to apply |
| `set freq <MHz>` | Local terminal, binary command `0x42`, and authorized LoRa CLI; applies automatically (infrastructure waits two seconds and for the reply to drain) |
| [`get public.key`](cli_commands.md#view-this-nodes-public-key) | Companion text terminal and binary command `0x42`; read-only, no private-key export feature required |
| `get prv.key` | Local terminal and binary command `0x42`; requires private key export enabled |
| `get password` | ESP32 WiFi Companion reports management-password status without revealing it; other Companions report no admin password; infrastructure returns its password locally |
+7 -2
View File
@@ -1877,7 +1877,10 @@ existing Companion apps. On dual-radio builds, use `get radio2` and
**Note:** Companion USB, BLE and WebConfig changes apply automatically when
the radio is free. A busy radio keeps the saved target across disconnects.
Infrastructure retains its reboot-to-apply behavior. Companion LoRa CLI waits
Infrastructure saves immediately, waits two seconds, then applies when the radio
is free and its reply has drained. No reboot is needed; a permanent change ends
pending or active TempRadio sessions. The device screen shows the applied radio
settings during the wait. Companion LoRa CLI waits
for the exact reply copies to drain before applying; active TempRadio leases
reject this permanent change.
If RXPS is enabled and the saved minimum
@@ -2065,7 +2068,9 @@ preamble. LoRa OTA treats v1.17.1.5 as that capability boundary.
**Default:** `869.618`
**Note:** Companion local changes apply automatically when the radio is free.
Infrastructure requires a reboot to apply. Companion LoRa CLI applies after
Infrastructure saves immediately and switches after two seconds, once the radio
is free and its reply has drained. No reboot is needed. This ends pending or
active TempRadio sessions. Companion LoRa CLI applies after
its exact reply copies drain; an active TempRadio lease rejects the change.
**Connections:** USB, browser/TCP/Ethernet CLI, Companion binary command
+2 -1
View File
@@ -335,7 +335,8 @@ the same binary CLI command over BLE or USB when TCP is unreachable. Listener
startup failures recover automatically without resetting a healthy client or
the WiFi connection; see [the CLI diagnostics](cli_commands.md#browser-configuration-portal-esp32-repeater-and-room-server).
`set freq <MHz>` works through command `0x42` and authorized LoRa CLI, like
`set radio`; it saves the frequency and requires reboot to apply. See
`set radio`; it saves the frequency and applies automatically when the radio is
free. LoRa CLI waits for its reply to drain before switching. See
[local maintenance commands](terminal_chat_cli.md#local-maintenance-commands)
for role differences and the text-terminal equivalents.
+3 -3
View File
@@ -46,7 +46,6 @@ for the first board and `Bridge-B` for the second, or choose your own names.
set espnow.enabled off
set name Bridge-A
set radio <freq>,<bw>,<sf>,<cr>
reboot
```
Do not type the angle-bracket placeholders literally.
@@ -59,8 +58,9 @@ Do not type the angle-bracket placeholders literally.
| `cr` | Coding-rate value, 5-8. Match that network's setting. |
| `name` | A label to tell the two repeaters apart. It does not pair them. |
**`set radio` requires a reboot to apply.** After reconnecting, `get radio`
shows the saved settings. Use permanent radio settings for this setup;
**`set radio` saves immediately and switches after two seconds, once the radio
is free and the reply has drained.** No reboot is needed. `get radio` shows the
saved settings; the device screen shows the applied settings. Use permanent radio settings for this setup;
`tempradio` sessions expire.
## 3. Configure the ESP-NOW link on both boards
+7 -3
View File
@@ -120,8 +120,10 @@ BLE and TCP use their existing Companion transport framing.
`set freq <MHz>` is available on these local connections and through authorized
LoRa administration, just like `set radio`. It saves the frequency without
changing bandwidth, spreading factor, or coding rate and requires a reboot to
apply. Infrastructure roles also apply the board's transmit-power limit.
changing bandwidth, spreading factor, or coding rate. Infrastructure waits two
seconds and for the radio reply to drain before applying; Companion applies
automatically when the radio is free. No reboot is needed. Infrastructure roles
also apply the board's transmit-power limit.
Commands relayed to another node using `cmd` travel over LoRa and retain the
remote restrictions on local-only maintenance and secret reads. These local
@@ -280,7 +282,9 @@ source, supported build profiles and memory costs.
set freq {frequency}
```
Set the saved LoRa frequency. Example: `set freq 915.8`. Available over local
connections and authorized LoRa CLI; reboot to apply.
connections and authorized LoRa CLI. Infrastructure switches after two seconds
and the reply has drained; Companion applies automatically when the radio is
free. No reboot is needed.
```
set tx {tx-power-dbm}
+1
View File
@@ -5132,6 +5132,7 @@ void MyMesh::serviceFleetScheduleReply() {
#endif
void MyMesh::processScheduledRadioSettings(bool tx_gap) {
if (!_cli.primaryRadioApplyReady()) return;
#if MESH_ENABLE_FLEET_CONTROL
if (!tx_gap) serviceFleetScheduleReply();
#endif
+8 -8
View File
@@ -562,14 +562,14 @@ void UITask::renderCurrScreen() {
uint8_t sf = _node_prefs->sf;
uint8_t cr = _node_prefs->cr;
const char* profile_tag = "";
if (dual_radio) {
const uint8_t profile = secondary_page ? 1 : 0;
if (profiles != NULL) {
const uint8_t profile = dual_radio && secondary_page ? 1 : 0;
const auto& params = profiles->params(profile);
freq = params.freq;
bw = params.bw;
sf = params.sf;
cr = params.cr;
profile_tag = mesh::ui::radioProfileDisplayTag(profile,
if (dual_radio) profile_tag = mesh::ui::radioProfileDisplayTag(profile,
profile == 1 ? profiles->secondary_temporary : profiles->primary_temporary);
}
@@ -722,8 +722,8 @@ uint32_t UITask::getFrameSignature() {
float bw = _node_prefs->bw;
uint8_t sf = _node_prefs->sf;
uint8_t cr = _node_prefs->cr;
if (dual_radio) {
const uint8_t profile = secondary_page ? 1 : 0;
if (profiles != NULL) {
const uint8_t profile = dual_radio && secondary_page ? 1 : 0;
const auto& params = profiles->params(profile);
freq = params.freq;
bw = params.bw;
@@ -796,13 +796,13 @@ bool UITask::buildDashboardContext(ObserverDashboard::Context* ctx) {
ctx->bw = _node_prefs->bw;
ctx->radio_label = "";
const mesh::RadioProfiles* profiles = configuredRadioProfiles();
if (profiles != NULL && profiles->enabled()) {
const uint8_t profile = showingSecondaryRadioProfilePage() ? 1 : 0;
if (profiles != NULL) {
const uint8_t profile = profiles->enabled() && showingSecondaryRadioProfilePage() ? 1 : 0;
const auto& params = profiles->params(profile);
ctx->freq = params.freq;
ctx->sf = params.sf;
ctx->bw = params.bw;
ctx->radio_label = mesh::ui::radioProfileDisplayTag(profile,
if (profiles->enabled()) ctx->radio_label = mesh::ui::radioProfileDisplayTag(profile,
profile == 1 ? profiles->secondary_temporary : profiles->primary_temporary);
}
ctx->dual_radio = the_mesh.isDualRadioActive();
+2
View File
@@ -2430,6 +2430,7 @@ void MyMesh::onGroupPacketRecv(mesh::Packet* packet) {
}
void MyMesh::servicePendingPrimaryRadioSettings(bool tx_gap) {
if (!_cli.primaryRadioApplyReady(!hasOutbound() && _mgr->getOutboundTotal() == 0)) return;
const bool revert_radio_due = revert_radio_at && millisHasNowPassed(revert_radio_at);
const bool radio_apply_ready = !radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at);
bool radio_apply_failed = false;
@@ -3044,6 +3045,7 @@ bool MyMesh::scheduleNormalRadio() {
// one as soon as the command reply has drained from the outbound queue.
set_radio_at = 0;
revert_radio_at = futureMillis(1);
saved_radio_apply_pending = true;
radio_apply_retry_at = 0;
radio_apply_failures = 0;
return true;
+8 -8
View File
@@ -317,14 +317,14 @@ void UITask::renderCurrScreen() {
uint8_t sf = _node_prefs->sf;
uint8_t cr = _node_prefs->cr;
const char* profile_tag = "";
if (dual_radio) {
const uint8_t profile = secondary_page ? 1 : 0;
if (profiles != NULL) {
const uint8_t profile = dual_radio && secondary_page ? 1 : 0;
const auto& params = profiles->params(profile);
freq = params.freq;
bw = params.bw;
sf = params.sf;
cr = params.cr;
profile_tag = mesh::ui::radioProfileDisplayTag(profile,
if (dual_radio) profile_tag = mesh::ui::radioProfileDisplayTag(profile,
profile == 1 ? profiles->secondary_temporary : profiles->primary_temporary);
}
@@ -405,8 +405,8 @@ uint32_t UITask::getFrameSignature() {
float bw = _node_prefs->bw;
uint8_t sf = _node_prefs->sf;
uint8_t cr = _node_prefs->cr;
if (dual_radio) {
const uint8_t profile = secondary_page ? 1 : 0;
if (profiles != NULL) {
const uint8_t profile = dual_radio && secondary_page ? 1 : 0;
const auto& params = profiles->params(profile);
freq = params.freq;
bw = params.bw;
@@ -447,13 +447,13 @@ bool UITask::buildDashboardContext(ObserverDashboard::Context* ctx) {
ctx->bw = _node_prefs->bw;
ctx->radio_label = "";
const mesh::RadioProfiles* profiles = configuredRadioProfiles();
if (profiles != NULL && profiles->enabled()) {
const uint8_t profile = showingSecondaryRadioProfilePage() ? 1 : 0;
if (profiles != NULL) {
const uint8_t profile = profiles->enabled() && showingSecondaryRadioProfilePage() ? 1 : 0;
const auto& params = profiles->params(profile);
ctx->freq = params.freq;
ctx->sf = params.sf;
ctx->bw = params.bw;
ctx->radio_label = mesh::ui::radioProfileDisplayTag(profile,
if (profiles->enabled()) ctx->radio_label = mesh::ui::radioProfileDisplayTag(profile,
profile == 1 ? profiles->secondary_temporary : profiles->primary_temporary);
}
ctx->dual_radio = the_mesh.isDualRadioActive();
+2
View File
@@ -1116,6 +1116,7 @@ bool SensorMesh::handleIncomingMsg(ClientInfo& from, uint32_t timestamp, uint8_t
}
void SensorMesh::servicePendingPrimaryRadioSettings(bool tx_gap) {
if (!_cli.primaryRadioApplyReady(!hasOutbound() && _mgr->getOutboundTotal() == 0)) return;
const bool revert_radio_due = revert_radio_at && millisHasNowPassed(revert_radio_at);
const bool radio_apply_ready = !radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at);
bool radio_apply_failed = false;
@@ -1488,6 +1489,7 @@ bool SensorMesh::scheduleNormalRadio() {
// path wait for an idle outbound queue before touching the radio.
set_radio_at = 0;
revert_radio_at = futureMillis(1);
saved_radio_apply_pending = true;
radio_apply_retry_at = 0;
radio_apply_failures = 0;
return true;
+3 -3
View File
@@ -195,14 +195,14 @@ void UITask::renderCurrScreen() {
uint8_t sf = _node_prefs->sf;
uint8_t cr = _node_prefs->cr;
const char* profile_tag = "";
if (dual_radio) {
const uint8_t profile = secondary_page ? 1 : 0;
if (profiles != NULL) {
const uint8_t profile = dual_radio && secondary_page ? 1 : 0;
const auto& params = profiles->params(profile);
freq = params.freq;
bw = params.bw;
sf = params.sf;
cr = params.cr;
profile_tag = mesh::ui::radioProfileDisplayTag(profile,
if (dual_radio) profile_tag = mesh::ui::radioProfileDisplayTag(profile,
profile == 1 ? profiles->secondary_temporary : profiles->primary_temporary);
}
+43 -32
View File
@@ -191,6 +191,34 @@ void CommonCLI::loop() {
#endif
}
bool CommonCLI::applyAndSavePrimaryRadioParams(float freq, float bw, uint8_t sf,
uint8_t cr, uint16_t preamble, char* reply) {
const int8_t previous_power = _prefs->tx_power_dbm;
if (!savePrimaryRadioParams(freq, bw, sf, cr, preamble)) {
mesh::cli::formatError(reply, 160, "radio settings could not be saved");
return false;
}
// Arm the role's reply handoff now, while the originating CLI command is
// still in scope. The separate deadline must also hold at TX-complete gaps.
_primary_radio_apply_at = millis() + 2000;
if (!_primary_radio_apply_at) _primary_radio_apply_at = 1;
_callbacks->scheduleNormalRadio();
if (_prefs->tx_power_dbm != previous_power) {
sprintf(reply, "OK - applying in 2s; TX power limited to %d dBm",
(int)_prefs->tx_power_dbm);
} else {
strcpy(reply, "OK - applying in 2s");
}
return true;
}
bool CommonCLI::primaryRadioApplyReady(bool reply_idle) {
if (_primary_radio_apply_at
&& (!reply_idle || static_cast<int32_t>(millis() - _primary_radio_apply_at) < 0)) return false;
_primary_radio_apply_at = 0;
return true;
}
bool CommonCLI::hasActiveUserGpioTimer() const {
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
return _user_gpio.hasActiveTimer();
@@ -3200,11 +3228,17 @@ bool CommonCLI::applyAndSaveAdcMultiplier(float multiplier, char* reply,
return true;
}
bool CommonCLI::acceptsPrimaryRadioParams(float freq, float bw, uint8_t sf,
uint8_t cr, uint16_t preamble) {
// The positive range and bandwidth whitelist also reject NaN and infinities.
return freq >= 150.0f && freq <= 2500.0f
&& sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && isValidLoRaBandwidth(bw)
&& _radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble);
}
bool CommonCLI::savePrimaryRadioParams(float freq, float bw, uint8_t sf,
uint8_t cr, uint16_t preamble) {
if (!isfinite(freq) || !isfinite(bw) || freq < 150.0f || freq > 2500.0f
|| sf < 5 || sf > 12 || cr < 5 || cr > 8 || !isValidLoRaBandwidth(bw)
|| !_radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) return false;
if (!acceptsPrimaryRadioParams(freq, bw, sf, cr, preamble)) return false;
const float old_freq = _prefs->freq, old_bw = _prefs->bw;
const uint8_t old_sf = _prefs->sf, old_cr = _prefs->cr;
const int8_t old_power = _prefs->tx_power_dbm;
@@ -5061,20 +5095,8 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
char legacy[96];
if (mesh::RadioProfileCLI::parseSuffix(&config[6], 4, legacy, sizeof(legacy), preamble)
&& mesh::cli::parseRadioTupleStrict(legacy, freq, bw, sf, cr)
&& freq >= 150.0f && freq <= 2500.0f
&& sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8
&& isValidLoRaBandwidth(bw)
&& _radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) {
const int8_t previous_power = _prefs->tx_power_dbm;
if (!savePrimaryRadioParams(freq, bw, sf, cr, preamble)) {
mesh::cli::formatError(reply, 160, "radio settings could not be saved"); return;
}
if (_prefs->tx_power_dbm != previous_power) {
sprintf(reply, "OK - reboot to apply; TX power limited to %d dBm",
(int)_prefs->tx_power_dbm);
} else {
strcpy(reply, "OK - reboot to apply");
}
&& acceptsPrimaryRadioParams(freq, bw, sf, cr, preamble)) {
if (!applyAndSavePrimaryRadioParams(freq, bw, sf, cr, preamble, reply)) return;
appendRxPowerSavingAdjustmentNote(reply, _prefs, sf, bw);
_radio_profiles.appendPrimaryChirpWarning(reply, 160, sf, bw, preamble);
} else {
@@ -5089,8 +5111,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
if (!mesh::RadioProfileCLI::parseSuffix(&config[8], 5, legacy, sizeof(legacy), preamble)
|| !parseScheduledRadioArgs(legacy, false, freq, bw, sf, cr, start_time, end_time, _rtc->getCurrentTime())) {
strcpy(reply, "Error, use: set radioat f,bw,sf,cr,start (UTC or +minutes)");
} else if (freq < 150.0f || freq > 2500.0f || sf < 5 || sf > 12 || cr < 5 || cr > 8 || !isValidLoRaBandwidth(bw)
|| !_radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) {
} else if (!acceptsPrimaryRadioParams(freq, bw, sf, cr, preamble)) {
strcpy(reply, "Error, invalid radio params");
} else {
_callbacks->addScheduledRadioParams(false, freq, bw, sf, cr, start_time, end_time, reply, preamble);
@@ -5108,8 +5129,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
if (!mesh::RadioProfileCLI::parseSuffix(&config[12], 6, legacy, sizeof(legacy), preamble)
|| !parseScheduledRadioArgs(legacy, true, freq, bw, sf, cr, start_time, end_time, _rtc->getCurrentTime())) {
strcpy(reply, "Error, use: set tempradioat f,bw,sf,cr,start,end (UTC or +minutes)");
} else if (freq < 150.0f || freq > 2500.0f || sf < 5 || sf > 12 || cr < 5 || cr > 8 || !isValidLoRaBandwidth(bw)
|| !_radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) {
} else if (!acceptsPrimaryRadioParams(freq, bw, sf, cr, preamble)) {
strcpy(reply, "Error, invalid radio params");
} else {
_callbacks->addScheduledRadioParams(true, freq, bw, sf, cr, start_time, end_time, reply, preamble);
@@ -5513,17 +5533,8 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
float freq = 0.0f;
if (mesh::cli::parseDecimalStrict(&config[5], freq)
&& freq >= 150.0f && freq <= 2500.0f) {
const int8_t previous_power = _prefs->tx_power_dbm;
_prefs->freq = freq;
_prefs->tx_power_dbm = mesh::clampLoRaTxPower(
_prefs->tx_power_dbm, _prefs->freq);
savePrefs();
if (_prefs->tx_power_dbm != previous_power) {
sprintf(reply, "OK - reboot to apply; TX power limited to %d dBm",
(int)_prefs->tx_power_dbm);
} else {
strcpy(reply, "OK - reboot to apply");
}
applyAndSavePrimaryRadioParams(freq, _prefs->bw, _prefs->sf, _prefs->cr,
_prefs->primary_radio_preamble, reply);
} else {
strcpy(reply, "Error, invalid frequency");
}
+8 -1
View File
@@ -973,6 +973,12 @@ class CommonCLI {
bool _common_save_succeeded = false;
bool _tx_power_pending = false;
uint32_t _tx_power_retry_at = 0;
uint32_t _primary_radio_apply_at = 0;
// Keep shared validation/save paths out of the large CLI dispatch on small MCUs.
bool acceptsPrimaryRadioParams(float freq, float bw, uint8_t sf, uint8_t cr,
uint16_t preamble) __attribute__((noinline));
bool applyAndSavePrimaryRadioParams(float freq, float bw, uint8_t sf, uint8_t cr,
uint16_t preamble, char* reply) __attribute__((noinline));
enum RxSetting : uint8_t {
RxGain = 1, RxPowerSaving = 2, RxSideDetectors = 4, RxRfRx = 8,
RxPendingMask = 15, RxRfRxDisabled = 128
@@ -1097,9 +1103,10 @@ public:
bool hasPendingTxPower() const { return _tx_power_pending; }
void servicePendingTxPower();
bool hasPendingRadioSettings() const {
return _tx_power_pending || (_rx_settings_pending & RxPendingMask) != 0
return _primary_radio_apply_at || _tx_power_pending || (_rx_settings_pending & RxPendingMask) != 0
|| _radio_profiles.hasPendingTuning();
}
bool primaryRadioApplyReady(bool reply_idle = true);
void servicePendingRxSettings();
void handleCommand(uint32_t sender_timestamp, char* command, char* reply);
mesh::MainBoard* getBoard() { return _board; }
+6
View File
@@ -55,6 +55,7 @@ struct Prefs {
uint8_t sf=7, cr=5, interference_threshold=0, agc_reset_interval=0;
uint8_t path_hash_mode=0, multi_acks=0, rx_ps_level=0, rx_ps_preamble=0;
int8_t tx_power_dbm=10;
uint16_t primary_radio_preamble=0;
uint32_t rx_ps_rx_us=1000, rx_ps_sleep_us=1000;
bool cad_enabled=false, rx_boosted_gain=false, rx_powersaving_enabled=false;
uint8_t extra_sf[4]={};
@@ -111,6 +112,11 @@ public:
uint8_t _rx_settings_pending=0;
uint32_t _rx_settings_retry_at=0;
void savePrefs() { callbacks.savePrefs(); }
bool applyAndSavePrimaryRadioParams(float f,float bw,uint8_t sf,uint8_t cr,uint16_t preamble,char* reply) {
if (!savePrimaryRadioParams(f,bw,sf,cr,preamble)) return false;
strcpy(reply,"OK - applying in 2s"); return true;
}
@PRIMARY_VALIDATE@
bool trySavePrefs() { savePrefs(); return true; }
void applyAndSaveTxPower(int8_t power, char* reply);
void servicePendingTxPower();
+2
View File
@@ -325,6 +325,8 @@ namespace mesh { struct Utils {
fixture = template.replace('@SET@', ' else '.join(set_blocks))
fixture = fixture.replace('@GET@', ' else '.join(get_blocks))
fixture = fixture.replace('@DISPATCH@', dispatch)
fixture = fixture.replace('@PRIMARY_VALIDATE@', extract_braced(
source, 'bool CommonCLI::acceptsPrimaryRadioParams(').replace('CommonCLI::', ''))
fixture = fixture.replace('@KEY_EQUALS@', extract_braced(source, 'static bool configKeyEquals('))
fixture = fixture.replace('@TX_METHODS@', '\n'.join(
extract_braced(source, signature).replace('CommonCLI::', 'CLI::')
+5 -1
View File
@@ -84,6 +84,7 @@ struct CommonCLI {
void savePrefs(FILESYSTEM*,PrefsSaveRouting::Scope);
bool saveCommonPrefs();
bool savePrimaryRadioParams(float,float,uint8_t,uint8_t,uint16_t);
bool acceptsPrimaryRadioParams(float,float,uint8_t,uint8_t,uint16_t);
static void recalculateRxPowerSavingFromLevel(NodePrefs* p) {
p->rx_ps_rx_us=p->sf*100; p->rx_ps_sleep_us=p->cr*200;
}
@@ -176,6 +177,8 @@ int main() {
assert(cli.loadTail({0,64,0,1,0xff})==64 && cli.prefs.gps_sync_interval_hours==48);
for(float bad : {NAN,INFINITY,149.0f,2501.0f})
assert(!cli.savePrimaryRadioParams(bad,125,9,6,0));
for(float bad : {NAN,INFINITY,-INFINITY,-125.0f})
assert(!cli.savePrimaryRadioParams(920,bad,9,6,0));
assert(cli.fs.files["/com_prefs"]==committed);
}
'''
@@ -263,7 +266,8 @@ int main() {
fields = re.sub(r'\b'+macro+r'\b', value, fields)
methods = '\n'.join(extract_braced(source, signature) for signature in (
'void CommonCLI::savePrefs(FILESYSTEM*', 'bool CommonCLI::saveCommonPrefs(',
'bool CommonCLI::savePrimaryRadioParams('))
'bool CommonCLI::savePrimaryRadioParams(',
'bool CommonCLI::acceptsPrimaryRadioParams('))
serializer = 'template<typename Writer>\n' + extract_braced(source, 'static bool writeCommonPrefsImage(')
tail = extract_braced(source, 'if (file.available() >= (int)sizeof(_prefs->bridge_format))')
code = HARNESS.replace('@FIELDS@',fields).replace('@METHODS@',("\n#include <stddef.h>\ntemplate<typename Writer>\n" + extract_braced(source, "static void writeCommonRetryPrefs(") + "\n" + methods))
+2
View File
@@ -193,6 +193,7 @@ int main(int argc,char** argv){
PRIMARY_EXTRA = r'''
struct PrimaryFixture {
@PRIMARY_ACCEPT@
fs::FS disk;Mesh mesh;PhysicalRadio physical;RadioProfileCLI profiles;MyMesh node;
FleetChannel fleet{&disk,&node};LocalIdentity publisher;unsigned dispatched=0;char reply[160]={};
PrimaryFixture(){fake_ms=1000;node.rtc.now=mesh.clock.now;profiles.begin(&disk,&physical,&mesh.clock,true);
@@ -400,6 +401,7 @@ uint16_t rxPowerSavingPreambleForParams(uint8_t,float){return 32;}
start=cli.index(' } else if (memcmp(config, "radioat ", 8) == 0)')
end=cli.index(' } else if (memcmp(config, "lat ", 4) == 0)',start)
primary_extra=PRIMARY_EXTRA.replace('@PRIMARY_SET@',cli[start:end])
primary_extra=primary_extra.replace('@PRIMARY_ACCEPT@',body(cli,'bool CommonCLI::acceptsPrimaryRadioParams(').replace('CommonCLI::','').replace('_radio_profiles','profiles'))
physical,secondary=HARNESS.split('struct Fixture {',1)
secondary=('struct Fixture {'+secondary).replace(' assert(argc==2);const std::string scenario=argv[1];Fixture f;',
' assert(argc==2);if(!strncmp(argv[1],"primary_",8))return primary(argc,argv);const std::string scenario=argv[1];Fixture f;')
+246
View File
@@ -0,0 +1,246 @@
"""Run permanent CLI setters, delay and room/sensor apply paths from production."""
from pathlib import Path
import subprocess
import tempfile
import unittest
from test_repeater_radio_timing_integration import method
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <cassert>
#include <cstdio>
#include <cstring>
#include <math.h>
#include <initializer_list>
#include <helpers/CLICommandUtils.h>
#include <helpers/RadioProfileCommandUtils.h>
#define MAX_LORA_TX_POWER 22
#include <helpers/radiolib/RadioPowerLimits.h>
#define MESH_DEBUG_PRINTLN(...) ((void)0)
static uint32_t now_ms=100;
uint32_t millis(){return now_ms;}
bool isValidLoRaBandwidth(float bw){return bw==62.5f || bw==125;}
struct Prefs {
float freq=910.51f,bw=62.5f;
uint8_t sf=7,cr=5,rx_ps_level=0;
uint16_t primary_radio_preamble=32,rx_ps_preamble=0;
uint32_t rx_ps_rx_us=100,rx_ps_sleep_us=200;
int8_t tx_power_dbm=30;
bool rx_powersaving_enabled=false;
};
struct Callbacks {virtual bool scheduleNormalRadio()=0;};
namespace mesh {
enum class RadioParamApplyResult{APPLIED,BUSY,FAILED};
struct RadioProfileCLI {
static bool parseSuffix(const char*,unsigned,char*,size_t,uint16_t&);
bool acceptsPrimary(float,float,uint8_t,uint8_t,uint16_t){return true;}
void adoptPrimaryPreamble(uint16_t){}
void appendPrimaryChirpWarning(char*,size_t,uint8_t,float,uint16_t){}
RadioParamApplyResult applyPrimary(float,float,uint8_t,uint8_t,bool,uint16_t,const uint32_t*) {
return RadioParamApplyResult::APPLIED;
}
};
@SUFFIX@
}
struct CommonCLI {
Prefs* _prefs;
Callbacks* _callbacks;
mesh::RadioProfileCLI _radio_profiles;
uint32_t _primary_radio_apply_at=0;
bool save_ok=true;
unsigned saves=0;
bool saveCommonPrefs(){++saves;return save_ok;}
bool savePrimaryRadioParams(float,float,uint8_t,uint8_t,uint16_t);
bool acceptsPrimaryRadioParams(float,float,uint8_t,uint8_t,uint16_t);
bool applyAndSavePrimaryRadioParams(float,float,uint8_t,uint8_t,uint16_t,char*);
bool primaryRadioApplyReady(bool reply_idle=true);
void command(const char*,char*);
mesh::RadioProfileCLI& radioProfiles(){return _radio_profiles;}
static void recalculateRxPowerSavingFromLevel(Prefs* p){p->rx_ps_rx_us=300;p->rx_ps_sleep_us=400;}
static bool calculateRxPowerSavingLevel(uint8_t,uint8_t,float,uint32_t,uint32_t*,uint32_t*){return true;}
};
void appendRxPowerSavingAdjustmentNote(char*,Prefs*,uint8_t,float){}
uint32_t rxPowerSavingPreambleForParams(uint8_t,float){return 16;}
uint32_t nextRadioApplyRetryDelay(uint8_t& failures){++failures;return 1000;}
struct {bool supportsRxPowerSaving(){return false;}} radio_driver;
struct Role:Callbacks {
Prefs _prefs;
CommonCLI _cli{&_prefs,this};
uint32_t set_radio_at=0,revert_radio_at=0,radio_apply_retry_at=0;
uint8_t radio_apply_failures=0,active_cr=5,pending_sf=7,pending_cr=5;
uint16_t pending_preamble=32;
float pending_freq=911,pending_bw=125,active_freq=910.51f;
bool temp_radio_applied=false,saved_radio_apply_pending=false,outbound=false,apply_ok=true;
struct Manager {unsigned queued=0;unsigned getOutboundTotal(){return queued;}} manager;
Manager* _mgr=&manager;
unsigned apply_attempts=0;
uint32_t futureMillis(uint32_t delay){return now_ms+delay;}
bool millisHasNowPassed(uint32_t at){return int32_t(now_ms-at)>=0;}
bool hasOutbound(){return outbound;}
bool applySavedRadioParams(){++apply_attempts;if(!apply_ok)return false;active_freq=_prefs.freq;return true;}
bool scheduleNormalRadio() override;
void servicePendingPrimaryRadioSettings(bool tx_gap=false);
};
@METHODS@
void CommonCLI::command(const char* config,char* reply) {
@RADIO@
else @FREQ@
}
int main(){
for(const char* command:{"freq 915.25","radio 915.25,125,9,6,48"}) {
for(bool temporary:{false,true}) {
now_ms=100;
Role node;node.temp_radio_applied=temporary;
char reply[160]{};
node._cli.save_ok=false;
node._cli.command(command,reply);
assert(strstr(reply,"Error") && node._prefs.freq==910.51f);
assert(node._prefs.tx_power_dbm==30 && node._prefs.rx_ps_rx_us==100);
assert(!node._cli._primary_radio_apply_at && !node.saved_radio_apply_pending);
node._cli.save_ok=true;
node._cli.command(command,reply);
assert(strstr(reply,"applying in 2s") && strstr(reply,"TX power limited to 22"));
assert(node._prefs.freq==915.25f && node.active_freq==910.51f);
assert(node.saved_radio_apply_pending);
if(command[0]=='f')assert(node._prefs.bw==62.5f && node._prefs.primary_radio_preamble==32);
else assert(node._prefs.bw==125 && node._prefs.sf==9 && node._prefs.cr==6 && node._prefs.primary_radio_preamble==48);
for(now_ms=101;now_ms<2100;now_ms+=333) {
node.servicePendingPrimaryRadioSettings(true);
assert(node.apply_attempts==0 && node.active_freq==910.51f);
}
now_ms=2100;node.outbound=true;
node.servicePendingPrimaryRadioSettings();
assert(node.apply_attempts==0); // Keep the current reply on the old tuple.
node.outbound=false;node.manager.queued=1;
node.servicePendingPrimaryRadioSettings(true);
assert(node.apply_attempts==0); // A different packet finishing cannot lose our queued reply.
node.manager.queued=0;node.apply_ok=false;
node.servicePendingPrimaryRadioSettings();
assert(node.apply_attempts==1 && node.saved_radio_apply_pending);
node.apply_ok=true;now_ms=3100;
node.servicePendingPrimaryRadioSettings();
assert(node.active_freq==915.25f && !node.saved_radio_apply_pending && !node.temp_radio_applied);
}
}
// A second accepted edit restarts the two-second handoff; malformed edits
// neither save nor replace an already accepted deadline.
Role node; char reply[160];now_ms=100;
node._cli.command("freq 915",reply);
now_ms=1500;node._cli.command("freq 916",reply);
assert(node._cli._primary_radio_apply_at==3500);
unsigned saves=node._cli.saves;
for(const char* invalid:{"freq nan","freq 149","radio 915,125,13,5","radio 915,125,7,5,7"}) {
node._cli.command(invalid,reply);
assert(strstr(reply,"Error") && node._cli.saves==saves && node._cli._primary_radio_apply_at==3500);
}
now_ms=3499;node.servicePendingPrimaryRadioSettings(true);assert(node.apply_attempts==0);
now_ms=3500;node.servicePendingPrimaryRadioSettings();assert(node.active_freq==916);
}
'''
class InfrastructureRadioApplyTest(unittest.TestCase):
def test_room_and_sensor_permanent_cli_handoff(self):
common = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
helpers = "\n".join(method(common, signature) for signature in (
"bool CommonCLI::savePrimaryRadioParams(",
"bool CommonCLI::acceptsPrimaryRadioParams(",
"bool CommonCLI::applyAndSavePrimaryRadioParams(",
"bool CommonCLI::primaryRadioApplyReady("))
suffix = method((ROOT / "src/helpers/RadioProfileCLI.cpp").read_text(),
"bool RadioProfileCLI::parseSuffix(")
def setter(key):
anchor = common.index(f'memcmp(config, "{key} ",')
start = common.rfind('} else if (', 0, anchor) + len('} else ')
return method(common[start:], 'if (').replace('#ifdef WITH_BRIDGE\n', '')
for file, role in (("simple_room_server/MyMesh.cpp", "MyMesh"),
("simple_sensor/SensorMesh.cpp", "SensorMesh")):
with self.subTest(role=role), tempfile.TemporaryDirectory() as temp:
source = (ROOT / "examples" / file).read_text()
methods = helpers + "\n" + "\n".join(
method(source, signature).replace(role + "::", "Role::")
for signature in (f"bool {role}::scheduleNormalRadio()",
f"void {role}::servicePendingPrimaryRadioSettings("))
code = HARNESS.replace('@METHODS@', methods).replace('@SUFFIX@', suffix)
code = code.replace('@RADIO@', setter('radio')).replace('@FREQ@', setter('freq'))
cpp = Path(temp) / "test.cpp"
binary = Path(temp) / "test"
cpp.write_text(code)
subprocess.run(['c++', '-std=c++17', '-I'+str(ROOT/'src'),
str(cpp), '-o', str(binary)], check=True)
subprocess.run([str(binary)], check=True)
def test_screens_show_applied_tuple_with_radio2_off_or_on(self):
# Compile the actual render/signature/dashboard selection blocks. A
# saved edit must not move the screen before the PHY accepts it.
selections = []
for role in ('simple_repeater', 'simple_room_server', 'simple_sensor'):
source = (ROOT / 'examples' / role / 'UITask.cpp').read_text()
offset = 0
while True:
start = source.find('float freq = _node_prefs->freq;', offset)
if start < 0:
break
condition = source.index('if (', start)
block = method(source[condition:], 'if (')
end = condition + len(block)
selections.append(source[start:end] + '\nreturn {freq,bw,sf,cr};')
offset = end
if 'bool UITask::buildDashboardContext(' in source:
start = source.index('ctx->freq = _node_prefs->freq;',
source.index('bool UITask::buildDashboardContext('))
condition = source.index('if (profiles', start)
end = condition + len(method(source[condition:], 'if ('))
selections.append('View context; View* ctx=&context;\n' + source[start:end]
+ '\nreturn context;')
self.assertEqual(len(selections), 7)
code = r'''
#include <cassert>
#include <cstddef>
#include <RadioProfiles.h>
#include <helpers/ui/RadioProfileDisplayPage.h>
#include <helpers/ui/DisplayFrameSignature.h>
struct View{float freq,bw;uint8_t sf,cr;const char* radio_label="";};
static View saved{920,125,9,6};
static View* _node_prefs=&saved;
static mesh::RadioProfiles state;
static const mesh::RadioProfiles* configuredRadioProfiles(){return &state;}
static bool secondary_page=false;
static bool showingSecondaryRadioProfilePage(){return secondary_page;}
'''
for i, selection in enumerate(selections):
# Dashboard declares its own profiles pointer.
setup = '' if 'const mesh::RadioProfiles* profiles' in selection else (
'const auto* profiles=configuredRadioProfiles();')
code += f'View render{i}(){{' + setup + (
'const bool dual_radio=state.enabled();'
'uint32_t signature=DisplayFrameSignature::INITIAL;') + selection + '}\n'
code += 'int main(){\n'
for i in range(len(selections)):
code += f'''
state.primary.freq=910.51f;state.primary.bw=62.5f;state.primary.sf=7;state.primary.cr=5;
state.secondary.params.freq=911;state.secondary.params.bw=125;state.secondary.params.sf=8;state.secondary.params.cr=6;
state.secondary.mode=mesh::RadioProfileMode::Off;secondary_page=true;
auto v{i}=render{i}();assert(v{i}.freq==910.51f && v{i}.bw==62.5f && v{i}.sf==7);
state.primary.freq=920;state.primary.bw=125;state.primary.sf=9;
v{i}=render{i}();assert(v{i}.freq==920 && v{i}.bw==125 && v{i}.sf==9);
state.primary_temporary=true;state.primary.freq=912;
v{i}=render{i}();assert(v{i}.freq==912);
state.secondary.mode=mesh::RadioProfileMode::Rx;
v{i}=render{i}();assert(v{i}.freq==911 && v{i}.sf==8);
secondary_page=false;v{i}=render{i}();assert(v{i}.freq==912);
'''
code += '}\n'
with tempfile.TemporaryDirectory() as temp:
cpp = Path(temp) / 'display.cpp'
binary = Path(temp) / 'display'
cpp.write_text(code)
subprocess.run(['c++', '-std=c++17', '-I'+str(ROOT/'src'), str(cpp),
'-o', str(binary)], check=True)
subprocess.run([str(binary)], check=True)
if __name__ == '__main__':
unittest.main()
+29 -4
View File
@@ -394,11 +394,31 @@ class LocalCliAccessTest(unittest.TestCase):
struct Prefs {
float freq=910.525f,bw=62.5f;
uint8_t sf=7,cr=5;
uint16_t primary_radio_preamble=32;
int8_t tx_power_dbm=30;
} prefs;
Prefs* _prefs=&prefs;
int saves=0;
void savePrefs(){++saves;}
int schedules=0;
bool save_ok=true;
bool savePrimaryRadioParams(float f,float bw,uint8_t sf,uint8_t cr,uint16_t preamble) {
++saves;
if (!save_ok) return false;
_prefs->freq=f; _prefs->bw=bw; _prefs->sf=sf; _prefs->cr=cr;
_prefs->primary_radio_preamble=preamble;
_prefs->tx_power_dbm=mesh::clampLoRaTxPower(_prefs->tx_power_dbm,f);
return true;
}
bool applyAndSavePrimaryRadioParams(float f,float bw,uint8_t sf,uint8_t cr,uint16_t preamble,char* reply){
const auto previous_power=_prefs->tx_power_dbm;
if(!savePrimaryRadioParams(f,bw,sf,cr,preamble)) {
strcpy(reply,"Error: radio settings could not be saved");return false;
}
++schedules;
strcpy(reply,previous_power!=_prefs->tx_power_dbm ?
"OK - applying in 2s; TX power limited" : "OK - applying in 2s");
return true;
}
void set(const char* config,uint32_t sender_timestamp,char* reply) {
''' + setter + r'''
}
@@ -408,7 +428,7 @@ int main() {
prefs=Prefs{};
set("freq 915.25",stamp,reply);
assert(prefs.freq==915.25f && prefs.tx_power_dbm==22);
assert(strstr(reply,"reboot to apply") && strstr(reply,"TX power limited"));
assert(strstr(reply,"applying in 2s") && strstr(reply,"TX power limited"));
assert(prefs.bw==62.5f && prefs.sf==7 && prefs.cr==5);
for(const char* bad:{"freq 149.9","freq 2500.1","freq 920oops",
"freq NaN","freq inf","freq 1e3","freq "}) {
@@ -418,10 +438,15 @@ int main() {
}
for(const char* boundary:{"freq 150","freq 2500"}) {
set(boundary,stamp,reply);
assert(strstr(reply,"OK - reboot to apply"));
assert(strstr(reply,"OK - applying in 2s"));
}
}
assert(saves==9);
assert(saves==9 && schedules==9);
const float previous=prefs.freq;
save_ok=false;
set("freq 920",0,reply);
assert(strstr(reply,"Error") && prefs.freq==previous && schedules==9);
assert(prefs.primary_radio_preamble==32);
}
'''
with tempfile.TemporaryDirectory() as temp:
@@ -139,6 +139,7 @@ public:
Callbacks* _callbacks = &callbacks;
uint8_t _rx_settings_pending = 0;
uint32_t _rx_settings_retry_at = 0;
uint32_t _primary_radio_apply_at = 0;
bool _tx_power_pending = false, _common_save_result_known = false, _common_save_succeeded = false;
struct Profiles {
bool pending = false;
@@ -90,6 +90,8 @@ struct CLI {
CLI& radioProfiles() { return *this; }
RadioPrefs* prefs = nullptr;
int* saves = nullptr;
uint32_t _primary_radio_apply_at = 0;
@PRIMARY_READY@
bool save_success = true;
unsigned save_attempts = 0;
unsigned fail_on_save = 0;
@@ -204,6 +206,26 @@ static void startTemp(MyMesh& m, int minutes) {
assert(m.temp_radio_applied);
}
int main() {
for (uint32_t start : {100U, 0xFFFFFC18U, 0xFFFFF830U}) {
MyMesh m;
now_ms = start;
m._cli._primary_radio_apply_at = now_ms + 2000;
if (!m._cli._primary_radio_apply_at) m._cli._primary_radio_apply_at = 1;
m.scheduleNormalRadio();
m.processScheduledRadioSettings(true);
assert(m.restores == 0 && m.saved_radio_apply_pending);
now_ms = start + 1999;
m.processScheduledRadioSettings(true); // A TX gap cannot bypass the delay.
assert(m.restores == 0);
now_ms = start + 2001;
m.temp_radio_reply_barrier.held = true;
m.processScheduledRadioSettings(true);
assert(m.restores == 0); // Even after 2s, let the exact reply finish.
m.temp_radio_reply_barrier.held = false;
m.processScheduledRadioSettings(true);
assert(m.restores == 1 && !m.saved_radio_apply_pending);
}
for (unsigned failure = 0; failure <
#ifdef USE_LR2021
4
@@ -530,6 +552,11 @@ int main() {
'''
HARNESS = HARNESS.replace("@PRIMARY_READY@", method(
(ROOT / "src/helpers/CommonCLI.cpp").read_text(),
"bool CommonCLI::primaryRadioApplyReady(").replace("CommonCLI::", "")
.replace("bool reply_idle)", "bool reply_idle=true)"))
class RepeaterRadioTimingIntegrationTest(unittest.TestCase):
def compile_and_run(self, source):
with tempfile.TemporaryDirectory(prefix="meshcore-temp-timing-") as temp: