Apply saved TX power automatically across firmware roles

This commit is contained in:
mikecarper committed 2026-10-25 14:08:15 -07:00
1 parent 31e5d54074
commit 65928aae95
51 files changed
+3838 -2728

No files matched your search

+16
View File
@@ -9,6 +9,8 @@
// Persistence has its own production-parser tests with an in-memory FS.
// This fixture isolates the actual CommonCLI setting branches and callbacks.
namespace mesh {
enum class RadioParamApplyResult : uint8_t { APPLIED, BUSY, FAILED };
struct Radio { bool isCarrierWaveActive() const { return false; } };
struct RadioProfileCLI {
static bool parseSuffix(const char* input, unsigned fields, char* legacy,
size_t size, uint16_t& preamble) {
@@ -61,11 +63,18 @@ struct Callbacks {
bool gain=false;
void savePrefs() { ++saves; }
bool setTxPower(int8_t value) { ++tx_calls; if (!accept) return false; power=value; return true; }
mesh::RadioParamApplyResult trySetTxPower(int8_t value) {
return setTxPower(value) ? mesh::RadioParamApplyResult::APPLIED
: mesh::RadioParamApplyResult::FAILED;
}
bool isTxPowerApplied(int8_t value) const { return power == value; }
const mesh::Radio* getProfileRadio() const { return nullptr; }
bool setRxBoostedGain(bool value) { ++gain_calls; if (!accept) return false; gain=value; return true; }
bool configSideDetectors(const uint8_t*,uint8_t,float) { return accept; }
};
struct Board { unsigned n_cad_busy=0; };
bool isValidLoRaBandwidth(float bw) { return bw==125 || bw==62.5f; }
uint32_t millis() { return 1000; }
void recalcRxPowerSavingFromLevel(uint8_t,uint8_t,float,uint8_t,uint32_t*,uint32_t*) {}
void appendRxPowerSavingAdjustmentNote(char*,Prefs*,uint8_t,float) {}
@KEY_EQUALS@
@@ -78,7 +87,12 @@ public:
Prefs* _prefs=&prefs;
Callbacks* _callbacks=&callbacks;
Board* _board=&board;
bool _tx_power_pending=false;
uint32_t _tx_power_retry_at=0;
void savePrefs() { callbacks.savePrefs(); }
bool trySavePrefs() { savePrefs(); return true; }
void applyAndSaveTxPower(int8_t power, char* reply);
void servicePendingTxPower();
bool savePrimaryRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, uint16_t) {
prefs.freq=freq; prefs.bw=bw; prefs.sf=sf; prefs.cr=cr;
prefs.tx_power_dbm=mesh::clampLoRaTxPower(prefs.tx_power_dbm,freq);
@@ -104,6 +118,8 @@ public:
}
};
@TX_METHODS@
struct Case { const char *key, *value; double expected; };
int main() {
// Local USB and authenticated on-air calls use the same infrastructure CLI.
+252
View File
@@ -0,0 +1,252 @@
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <deque>
#include <initializer_list>
#include <vector>
#include <helpers/CLICommandUtils.h>
#include <helpers/PrefsSaveRouting.h>
#define MESH_HIL_TX_FREQ_MHZ 909.5f
#define MIN_LORA_TX_POWER -9
#define MAX_LORA_TX_POWER 22
#define MIN_LOCAL_ADVERT_INTERVAL 60
#include <helpers/radiolib/RadioPowerLimits.h>
static uint32_t now_ms = 1000;
static bool completion_seen = false;
uint32_t millis() { return now_ms; }
namespace mesh {
@ENUM@
struct Radio {
bool carrier = false, morse = false;
bool isCarrierWaveActive() const { return carrier; }
};
bool morseTimingActive(const Radio* radio) { return radio && radio->morse; }
}
using Result = mesh::RadioParamApplyResult;
struct CallbackBase {
virtual bool setTxPower(int8_t) = 0;
@DEFAULT_CALLBACK@
@DEFAULT_APPLIED@
};
struct LegacyCallbacks : CallbackBase {
bool accept = true;
bool setTxPower(int8_t) override { return accept; }
};
struct Prefs {
int8_t tx_power_dbm = 10;
float freq = 915;
uint8_t advert_interval = 0;
void clearDirty() {}
};
class CommonCLI;
struct Callbacks : CallbackBase {
CommonCLI* owner = nullptr;
mesh::Radio radio;
std::deque<Result> results;
std::vector<int8_t> attempts;
unsigned saves = 0;
bool save_ok = true, acknowledge = true;
bool power_verified = false, transmitting = false, require_completion = false;
int8_t hardware = 10, persisted = 10;
bool setTxPower(int8_t power) override {
return trySetTxPower(power) == Result::APPLIED;
}
Result trySetTxPower(int8_t power) override {
if (require_completion) assert(completion_seen);
attempts.push_back(power);
const Result result = transmitting ? Result::BUSY
: results.empty() ? Result::APPLIED : results.front();
if (!transmitting && !results.empty()) results.pop_front();
// FAILED deliberately models a partial hardware write.
if (result != Result::BUSY) hardware = power;
if (result != Result::BUSY) power_verified = result == Result::APPLIED;
return result;
}
bool isTxPowerApplied(int8_t power) const override {
return power_verified && hardware == power;
}
mesh::Radio* getProfileRadio() { return &radio; }
void updateAdvertTimer() {}
void savePrefs(PrefsSaveRouting::Scope);
};
@KEY_EQUALS@
class CommonCLI {
public:
Prefs prefs;
Callbacks callbacks;
Prefs* _prefs = &prefs;
Callbacks* _callbacks = &callbacks;
bool _tx_power_pending = false;
uint32_t _tx_power_retry_at = 0;
bool _common_save_result_known = false;
bool _common_save_succeeded = false;
struct Profiles { void loop() {} } _radio_profiles;
struct MorseReply { void complete(const void*) {} } _morse_reply;
MorseReply& morseReply() { return _morse_reply; }
CommonCLI() { callbacks.owner = this; }
@PENDING_QUERY@
void loopManagement() {}
void loop();
void applyAndSaveTxPower(int8_t, char*);
void servicePendingTxPower();
bool trySavePrefs(PrefsSaveRouting::Scope scope = PrefsSaveRouting::Scope::Common);
void command(const char* text, char* reply) {
if (!strncmp(text, "set ", 4)) {
const char* config = text + 4;
@SET@
else strcpy(reply, "unknown config");
} else if (!strncmp(text, "get ", 4)) {
const char* config = text + 4;
@GET@
else strcpy(reply, "unknown config");
} else strcpy(reply, "unknown command");
}
};
void Callbacks::savePrefs(PrefsSaveRouting::Scope scope) {
assert(scope == PrefsSaveRouting::Scope::Common);
++saves;
if (acknowledge) {
owner->_common_save_result_known = true;
owner->_common_save_succeeded = save_ok;
}
if (save_ok) persisted = owner->prefs.tx_power_dbm;
}
@METHODS@
int main() {
LegacyCallbacks legacy;
assert(!legacy.isTxPowerApplied(10));
assert(legacy.trySetTxPower(14) == Result::APPLIED);
assert(!legacy.isTxPowerApplied(14)); // The legacy bool setter cannot prove cache validity.
legacy.accept = false;
assert(legacy.trySetTxPower(14) == Result::FAILED);
char reply[160];
{
CommonCLI cli;
// Boot and full-radio recovery can own the outstanding hardware apply,
// with no dedicated TX queue flag. Never infer success from that flag.
assert(!cli.hasPendingTxPower() && cli.callbacks.hardware == cli.prefs.tx_power_dbm);
cli.command("get tx.status", reply);
assert(!strcmp(reply, "> desired=10,apply=pending"));
cli.callbacks.power_verified = true;
cli.command("get tx.status", reply);
assert(!strcmp(reply, "> desired=10,apply=applied"));
cli.callbacks.hardware = 12;
cli.command("get tx.status", reply);
assert(!strcmp(reply, "> desired=10,apply=pending"));
assert(cli.callbacks.saves == 0 && cli.callbacks.attempts.empty());
}
{
CommonCLI cli;
cli.callbacks.results = {Result::BUSY, Result::BUSY, Result::APPLIED};
cli.command("set tx 14", reply);
assert(!strcmp(reply, "OK - saved; pending") && cli.hasPendingTxPower());
assert(cli.prefs.tx_power_dbm == 14 && cli.callbacks.persisted == 14);
assert(cli.callbacks.hardware == 10 && cli.callbacks.saves == 1);
cli.command("get tx", reply); assert(!strcmp(reply, "> 14"));
cli.command("get tx.status", reply);
assert(!strcmp(reply, "> desired=14,apply=pending"));
cli.loop(); assert(cli.hasPendingTxPower() && cli.callbacks.saves == 1);
cli.loop(); assert(!cli.hasPendingTxPower() && cli.callbacks.hardware == 14);
cli.loop(); assert(cli.callbacks.attempts.size() == 3 && cli.callbacks.saves == 1);
cli.command("get tx.status", reply);
assert(!strcmp(reply, "> desired=14,apply=applied"));
}
{
CommonCLI cli;
cli.callbacks.results = {Result::BUSY, Result::BUSY, Result::APPLIED};
cli.command("set tx 14", reply); cli.command("set tx 16", reply);
cli.loop();
assert(!cli.hasPendingTxPower() && cli.callbacks.hardware == 16);
assert(cli.prefs.tx_power_dbm == 16 && cli.callbacks.persisted == 16);
assert(cli.callbacks.attempts == std::vector<int8_t>({14, 16, 16}));
assert(cli.callbacks.saves == 2);
}
{
CommonCLI cli;
cli.callbacks.results = {Result::BUSY, Result::BUSY};
cli.command("set tx 14", reply);
cli._tx_power_retry_at = now_ms + 500;
cli.callbacks.save_ok = false;
cli.command("set tx 16", reply);
assert(!strncmp(reply, "Error", 5) && cli.prefs.tx_power_dbm == 14);
assert(cli.hasPendingTxPower() && cli._tx_power_retry_at == now_ms + 500);
cli.loop(); assert(cli.callbacks.attempts.size() == 2);
now_ms += 500; cli.loop();
assert(cli.callbacks.hardware == 14 && !cli.hasPendingTxPower());
}
for (Result restore : {Result::APPLIED, Result::BUSY, Result::FAILED}) {
CommonCLI cli;
cli.callbacks.save_ok = false;
cli.callbacks.results = {Result::APPLIED, restore};
cli.command("set tx 16", reply);
assert(!strncmp(reply, "Error", 5) && cli.prefs.tx_power_dbm == 10);
assert(cli.callbacks.saves == 1 && cli.callbacks.persisted == 10);
assert(cli.callbacks.attempts == std::vector<int8_t>({16, 10}));
assert(cli.hasPendingTxPower() == (restore != Result::APPLIED));
cli.command("get tx.status", reply);
assert(!strcmp(reply, restore == Result::APPLIED
? "> desired=10,apply=applied" : "> desired=10,apply=pending"));
if (restore == Result::FAILED) {
// A partial restore must remain unverified even if another recovery
// owner retires the dedicated TX queue before a successful write.
cli._tx_power_pending = false;
cli.command("get tx.status", reply);
assert(!strcmp(reply, "> desired=10,apply=pending"));
cli._tx_power_pending = true;
const auto count = cli.callbacks.attempts.size();
now_ms += 999; cli.loop(); assert(cli.callbacks.attempts.size() == count);
++now_ms;
}
cli.loop(); assert(!cli.hasPendingTxPower() && cli.callbacks.hardware == 10);
assert(cli.callbacks.saves == 1);
}
{
CommonCLI cli;
cli.callbacks.results = {Result::FAILED};
cli.command("set tx 16", reply);
assert(!strncmp(reply, "Error", 5) && cli.prefs.tx_power_dbm == 10);
assert(cli.callbacks.saves == 0 && cli.hasPendingTxPower());
cli.loop(); assert(cli.callbacks.hardware == 10 && !cli.hasPendingTxPower());
}
for (uint32_t start : {1000U, UINT32_MAX - 999U, UINT32_MAX - 500U}) {
now_ms = start;
CommonCLI cli;
cli.callbacks.results = {Result::BUSY, Result::FAILED, Result::APPLIED};
cli.command("set tx 14", reply); cli.loop();
assert(cli.hasPendingTxPower() && cli._tx_power_retry_at != 0);
const auto count = cli.callbacks.attempts.size();
now_ms = start + 999; cli.loop(); assert(cli.callbacks.attempts.size() == count);
now_ms = cli._tx_power_retry_at; cli.loop();
assert(!cli.hasPendingTxPower() && cli.callbacks.hardware == 14);
assert(cli.callbacks.saves == 1);
}
{
CommonCLI cli;
cli.callbacks.results = {Result::BUSY, Result::APPLIED};
cli.command("set tx 14", reply);
cli.callbacks.radio.carrier = true; cli.loop();
assert(cli.callbacks.attempts.size() == 1 && cli.hasPendingTxPower());
cli.callbacks.radio.carrier = false; cli.callbacks.radio.morse = true; cli.loop();
assert(cli.callbacks.attempts.size() == 1 && cli.hasPendingTxPower());
cli.callbacks.radio.morse = false; cli.loop();
assert(cli.callbacks.hardware == 14 && !cli.hasPendingTxPower());
}
{
CommonCLI cli;
cli.command("set tx 14", reply); assert(!strcmp(reply, "OK"));
cli.callbacks.acknowledge = false;
cli.command("set tx 16", reply);
assert(!strncmp(reply, "Error", 5) && cli.prefs.tx_power_dbm == 14);
assert(cli.callbacks.hardware == 14 && !cli.hasPendingTxPower());
}
for (const char* value : {"garbage", "14tail", "-10", "23", "256", "2147483648"}) {
CommonCLI cli;
char command[80]; snprintf(command, sizeof(command), "set tx %s", value);
cli.command(command, reply);
assert(!strncmp(reply, "Error", 5) && cli.prefs.tx_power_dbm == 10);
assert(cli.callbacks.saves == 0 && cli.callbacks.attempts.empty());
}
}
+3 -1
View File
@@ -23,10 +23,11 @@ struct CommonCLI {
ACL* _acl;
Callbacks* _callbacks;
struct Profiles { unsigned loops = 0; void loop() { ++loops; } } _radio_profiles;
unsigned management_loops = 0;
unsigned management_loops = 0, tx_services = 0;
CommonCLI(ACL& acl, Callbacks& callbacks) : _acl(&acl), _callbacks(&callbacks) {}
void loopBatteryForwarding(bool = false) {}
void loopManagement() { ++management_loops; }
void servicePendingTxPower() { ++tx_services; }
void loop();
};
@LOOP@
@@ -56,6 +57,7 @@ int main() {
assert(radio.rx_starts == unsigned(allowed) && !cli._morse_reply.pending());
assert(acl.requested_key_size == 32 && cli._radio_profiles.loops == unsigned(allowed ? 1 : 2));
assert(cli.management_loops == unsigned(allowed ? 1 : 2));
assert(cli.tx_services == cli.management_loops);
mesh::usable = true; mesh::current = mesh::MorseSettings();
}
}
+2
View File
@@ -169,6 +169,7 @@ public:
FakeTrigger* alert_tasks[4] = {&alert1, &alert2};
int num_alert_tasks = 2;
struct CLI {
void servicePendingTxPower() {}
bool batteryAllowsForward(bool) const { return true; }
bool batteryAllowsTransmit(const mesh::Packet*) const { return true; }
struct NoMorseReply { void complete(const void*) {} void fail(const void*) {} } morse;
@@ -179,6 +180,7 @@ public:
SensorMesh* owner = nullptr;
void loop();
bool hasActiveUserGpioTimer() const { return active; }
bool hasPendingTxPower() const { return false; }
} _cli;
struct ClockSync { unsigned observed = 0, loops = 0;
void loop() { ++loops; }
+1
View File
@@ -95,6 +95,7 @@ struct CLI {
bool scheduled = false;
unsigned attempts = 0;
bool hasActiveUserGpioTimer() const { return false; }
bool hasPendingTxPower() const { return false; }
void loop();
};
class SensorMesh : public mesh::Mesh {
+1
View File
@@ -88,6 +88,7 @@ struct RadioLibWrapper {
virtual bool supportsMeshtext() const { return false; }
virtual bool applyProtocol(mesh::RadioProfileProtocol p) { return p == mesh::RadioProfileProtocol::MeshCore; }
virtual int16_t applyCachedTxPower(int8_t) { return 0; }
virtual bool checkBeaconOutputPower(float, int8_t) const { return false; }
bool setTxPower(int8_t value) { return applyCachedTxPower(value) == 0; }
bool radioDeepInit() { _radio->resetPacketDefaults(); return true; }
bool registerInterruptAction() { return true; }
+7
View File
@@ -175,6 +175,7 @@ struct RadioLibWrapper {
void onSendFinished();
uint32_t getBeaconAirtime(const mesh::RadioProfileParams&, int);
bool validateBeaconPower(float, int8_t) const;
bool isTxPowerApplied(int8_t) const;
virtual bool checkBeaconOutputPower(float, int8_t) const;
void cacheBeaconRestoreParams();
Result prepareBeaconTransmit(const mesh::RadioProfileParams&, int8_t);
@@ -197,6 +198,7 @@ int main() {
assert(!w.restoreAfterDeepInit() && !w._dbm_applied);
w.power_fail = false;
assert(w.restoreAfterDeepInit() && w._dbm_applied);
assert(w.isTxPowerApplied(20));
++cases;
}
for (bool dual : {false, true}) for (bool rxps : {false, true}) for (bool mt : {false, true}) {
@@ -210,6 +212,7 @@ int main() {
w._rx_ps_enabled = w._rx_ps_armed = rxps;
const auto before = w._profiles;
assert(w.prepareBeaconTransmit(beacon(mt), 10) == Result::APPLIED);
assert(!w._dbm_applied && !w.isTxPowerApplied(20) && !w.isTxPowerApplied(10));
assert(w._beacon_owned && w.state == STATE_IDLE && w.arms == 0);
assert(w.modem.freq == 915 && w.modem.power == 10);
assert(w.modem.preamble == (mt ? 16 : 32));
@@ -224,6 +227,7 @@ int main() {
assert(!w.isBeaconChannelBusy() && !w.pending && w.state == STATE_IDLE);
assert(w.restoreBeaconReceive() == Result::APPLIED);
assert(!w._beacon_owned && w.state == STATE_RX && w._rx_ps_armed == rxps);
assert(w.isTxPowerApplied(20));
assert(w.modem.freq == (dual ? 908 : 906) && w.modem.power == 20);
assert(w.modem.protocol == mesh::RadioProfileProtocol::MeshCore);
assert(w.recalibrations == 0 && w.deep_calls == 0);
@@ -247,8 +251,10 @@ int main() {
if (failure == 1) w.protocol_fail = true;
if (failure == 2) w.power_fail = true;
assert(w.prepareBeaconTransmit(beacon(true), 10) == Result::FAILED && w._beacon_owned);
assert(!w._dbm_applied && !w.isTxPowerApplied(20));
w.repair_on_deep = true;
assert(w.restoreBeaconReceive() == Result::APPLIED && !w._beacon_owned);
assert(w.isTxPowerApplied(20));
assert(w.modem.freq == 906 && w.modem.protocol == mesh::RadioProfileProtocol::MeshCore && w.modem.power == 20);
++cases;
}
@@ -388,6 +394,7 @@ class BeaconRadioTest(unittest.TestCase):
"void RadioLibWrapper::onSendFinished()",
"uint32_t RadioLibWrapper::getBeaconAirtime(",
"bool RadioLibWrapper::validateBeaconPower(",
"bool RadioLibWrapper::isTxPowerApplied(",
"bool RadioLibWrapper::checkBeaconOutputPower(",
"void RadioLibWrapper::cacheBeaconRestoreParams()",
"mesh::RadioParamApplyResult RadioLibWrapper::prepareBeaconTransmit(",
+14 -2
View File
@@ -89,7 +89,7 @@ struct Chip {
standby(); if (power_result) return power_result;
power=value; rfo=force_rfo; ramp_time=4; return 0;
}
int checkOutputPower(int8_t value, int8_t* clipped, bool force_hp) {
int checkOutputPower(int8_t value, int8_t* clipped, bool force_hp=false) {
const int minimum=frequency>1000 ? -18 : force_hp || value>14 ? -9 : -17;
const int maximum=frequency>1000 ? 13 : force_hp || value>14 ? 22 : 14;
if (clipped) *clipped=value<minimum ? minimum : value>maximum ? maximum : value;
@@ -193,6 +193,9 @@ struct RadioLibWrapper : mesh::Radio {
}
virtual float getCurrentRSSI() { ++rssi_reads; g_mock_millis+=rssi_latency; return rssi; }
bool setTxPower(int8_t dbm);
bool isTxPowerValid(int8_t dbm) const;
bool isTxPowerApplied(int8_t dbm) const;
virtual bool checkBeaconOutputPower(float, int8_t) const;
mesh::RadioParamApplyResult trySetTxPower(int8_t dbm);
@NATIVE_HOOKS@
#if MESH_ENABLE_MORSE_RX || !defined(STM32_PLATFORM)
@@ -272,7 +275,9 @@ int main() {
using Result=mesh::RadioParamApplyResult;
for (uint8_t profile : {0,1}) {
RadioLibWrapper w;
assert(w.isTxPowerApplied(17));
assert(w.setCarrierWave(profile,1250)==Result::APPLIED);
assert(!w.isTxPowerApplied(17));
assert(w.frequency==(profile ? 911.5f : 909.5f));
assert(w.chip.carrier && !w.chip.callback && w.board.before==1 && w.board.awake);
assert(w.chip.standby_calls==2 && !w._rx_ps_armed && !w._nf_calib_active);
@@ -280,6 +285,7 @@ int main() {
advance(1249); assert(w.serviceCarrierWave() && w.chip.carrier);
advance(1); assert(w.serviceCarrierWave() && !w.chip.carrier);
assert(!w._cw_active && w.isInRecvMode() && w._rx_ps_armed);
assert(w.isTxPowerApplied(17));
assert(w.frequency==909.5f && w.board.after==1 && !w.board.awake && w.chip.callback);
assert(!w.serviceCarrierWave());
}
@@ -321,11 +327,13 @@ int main() {
w.chip.standby_result=-705;
assert(w.setCarrierWave(0,0)==Result::FAILED);
assert(w._cw_active && w._cw_stopping && w.board.awake && w.board.after==1);
assert(!w._dbm_applied && !w.isTxPowerApplied(17));
const auto attempts=w.chip.stops;
advance(99); w.serviceCarrierWave(); assert(w.chip.stops==attempts);
w.reset_ok=true;
advance(1); w.serviceCarrierWave();
assert(!w._cw_active && !w.chip.carrier && w.board.after==1 && w.isInRecvMode());
assert(w.isTxPowerApplied(17));
}
{
RadioLibWrapper w; w.setCarrierWave(0,100);
@@ -458,6 +466,9 @@ def production_harness():
source = (ROOT/'src/helpers/radiolib/RadioLibWrappers.cpp').read_text()
signatures = ('uint8_t RadioLibWrapper::beginReconfigure(',
'bool RadioLibWrapper::setTxPower(',
'bool RadioLibWrapper::isTxPowerValid(',
'bool RadioLibWrapper::isTxPowerApplied(',
'bool RadioLibWrapper::checkBeaconOutputPower(',
'mesh::RadioParamApplyResult RadioLibWrapper::trySetTxPower(',
'mesh::RadioParamApplyResult RadioLibWrapper::setCarrierWave(',
'uint32_t RadioLibWrapper::carrierWaveRemainingMillis(',
@@ -519,7 +530,8 @@ def production_harness():
'bool validateMorseFrequency(')))
dac = (ROOT/'src/helpers/radiolib/DacPaSX1276Wrapper.h').read_text()
code = code.replace('@DAC_METHODS@', '\n'.join(method(dac, s) for s in (
'int16_t applyCachedTxPower(', 'uint8_t indexForDbm(')))
'int16_t applyCachedTxPower(', 'uint8_t indexForDbm(',
'bool checkBeaconOutputPower(')))
return code
+7
View File
@@ -63,6 +63,7 @@ COMMON_QUERY_EXCEPTIONS = {
'radio.rxps.status': 'Live radio status for set radio.rxps',
'role': 'Build-selected firmware role',
'rxps.wd': 'Runtime RX power-saving recovery counters',
'tx.status': 'HIL-only applied-power verification for guarded RF tests',
'wifi.pwd': 'Setter is in the observer or standalone-WiFi delegate',
}
OBSERVER_QUERY_EXCEPTIONS = {
@@ -84,6 +85,7 @@ class CLISettingsContractTest(unittest.TestCase):
self.assertIsNotNone(compiler, 'Host C++ compiler required')
profiles = {
'minimal': [],
'hil': ['MESH_HIL_TX_FREQ_MHZ'],
'nrf52_gps_sd': ['NRF52_PLATFORM', 'ENV_INCLUDE_GPS', 'OTA_SD_STORE', 'NRF52_POWER_MANAGEMENT'],
'esp32_web': ['ESP_PLATFORM', 'ESP32_PLATFORM', 'ADMIN_PASSWORD', 'MESH_USB_LOGGING_AVAILABLE'],
'esp32_g3': ['ESP_PLATFORM', 'ESP32_PLATFORM', 'STATION_G3_ESP32'],
@@ -216,6 +218,7 @@ class CLISettingsContractTest(unittest.TestCase):
'public.key', # Read-only public identity
'pwrmgt.bootreason', 'role', # Boot/build facts
'radio.rxps.config', # Detail view of radio.rxps
'tx.status', # HIL-only applied-power verification
'wifi.status', 'wifi.ip', # Live WiFi link state
})
@@ -268,6 +271,10 @@ class CLISettingsContractTest(unittest.TestCase):
fixture = fixture.replace('@GET@', ' else '.join(get_blocks))
fixture = fixture.replace('@DISPATCH@', dispatch)
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::')
for signature in ('void CommonCLI::applyAndSaveTxPower(',
'void CommonCLI::servicePendingTxPower(')))
cpp.write_text(fixture, encoding='utf-8')
sanitizers = (['-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie'] if sys.platform.startswith('linux') else [])
+238
View File
@@ -0,0 +1,238 @@
"""Exercise saved TX intent through the production CLI, save and loop methods."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
def preprocess(source, hil=False):
result = subprocess.run([
os.environ.get('CXX', 'g++'), '-E', '-P', '-x', 'c++',
*(['-DMESH_HIL_TX_FREQ_MHZ=909.5f'] if hil else []), '-',
], input=source, text=True, capture_output=True, check=True)
return result.stdout
def production_fixture():
source = (ROOT / 'src/helpers/CommonCLI.cpp').read_text()
header = (ROOT / 'src/helpers/CommonCLI.h').read_text()
dispatcher = (ROOT / 'src/Dispatcher.h').read_text()
code = (ROOT / 'test/fixtures/common_cli_tx_pending.cpp').read_text()
methods = '\n'.join(extract_braced(source, signature) for signature in (
'void CommonCLI::applyAndSaveTxPower(', 'void CommonCLI::servicePendingTxPower(',
'bool CommonCLI::trySavePrefs(', 'void CommonCLI::loop('))
setter = extract_braced(source, 'void CommonCLI::handleSetCmd(')
getter = preprocess(extract_braced(source, 'void CommonCLI::handleGetCmd('), hil=True)
values = {
'METHODS': methods,
'SET': extract_braced(setter, 'if (memcmp(config, "tx ", 3) == 0)'),
'GET': ' else '.join(extract_braced(getter,
f'if (configKeyEquals(config, "{key}"))') for key in ('tx.status', 'tx')),
'KEY_EQUALS': extract_braced(source, 'static bool configKeyEquals('),
'ENUM': extract_braced(dispatcher, 'enum class RadioParamApplyResult') + ';',
'DEFAULT_CALLBACK': extract_braced(header,
'virtual mesh::RadioParamApplyResult trySetTxPower('),
'DEFAULT_APPLIED': extract_braced(header, 'virtual bool isTxPowerApplied('),
'PENDING_QUERY': extract_braced(header, 'bool hasPendingTxPower() const'),
}
for key, value in values.items():
code = code.replace('@' + key + '@', value)
return code
class CommonCLITxPendingTests(unittest.TestCase):
def test_saved_power_applies_automatically_without_resaving(self):
code = production_fixture()
with tempfile.TemporaryDirectory(prefix='meshcore-common-cli-tx-') as directory:
cpp, exe = Path(directory) / 'test.cpp', Path(directory) / 'test'
variants = [('production', code, True),
('drops_pending', code.replace(
'_tx_power_pending = result == Result::BUSY;',
'_tx_power_pending = false;'), False),
('no_background_service', code.replace(
' servicePendingTxPower();', ''), False),
('unverified_applied_claim', code.replace(
' && _callbacks->isTxPowerApplied(_prefs->tx_power_dbm)', ''), False),
('stale_save_ack', code.replace(
' _common_save_result_known = false;\n'
' _common_save_succeeded = false;',
'/* stale save acknowledgement */'), False)]
for name, fixture, succeeds in variants:
with self.subTest(variant=name):
cpp.write_text(fixture, encoding='ascii')
flags = [] if os.name == 'nt' else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie']
built = subprocess.run([
os.environ.get('CXX', 'g++'), '-std=c++17', '-Wall', '-Wextra',
'-Werror', *flags, '-I', str(ROOT / 'src'), str(cpp), '-o', str(exe),
], text=True, capture_output=True)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
ran = subprocess.run([str(exe)], text=True, capture_output=True)
if succeeds:
self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr)
else:
self.assertNotEqual(ran.returncode, 0, name + ' escaped detection')
def test_actual_completion_callbacks_apply_before_a_saturated_queue_starts_next_tx(self):
dispatcher = extract_braced((ROOT / 'src/Dispatcher.cpp').read_text(),
'void Dispatcher::loop(')
completed = extract_braced(dispatcher, 'if (_radio->isSendComplete())')
self.assertLess(completed.index('_radio->onSendFinished();'),
completed.index('onSendComplete(outbound);'))
self.assertLess(dispatcher.index('onSendComplete(outbound);'), dispatcher.rindex('checkSend();'))
prefix = production_fixture().split('int main() {', 1)[0]
fixture = r'''
#define MAX_HASH_SIZE 8
#define PAYLOAD_TYPE_REQ 1
namespace mesh {
namespace tracker { constexpr unsigned kReplyWindowSeconds = 30; }
struct Packet {
int getPayloadType() const { return 0; }
void calculatePacketHash(uint8_t* out) const { memset(out, 0, MAX_HASH_SIZE); }
};
struct Mesh {
virtual void onSendComplete(Packet*) { completion_seen = true; }
};
}
struct TxRole : mesh::Mesh {
CommonCLI _cli;
struct Barrier {
bool complete(mesh::Packet*) { return false; }
bool waiting() const { return false; }
} temp_radio_reply_barrier;
mesh::Packet* pending_battery_alert_packet = nullptr;
bool battery_alert_sent = false, tracker_pending = false, tracker_sent = false;
uint64_t uptime_millis = 0, last_battery_alert_sent = 0;
uint32_t last_millis = 0, tracker_reply_until = 0;
uint8_t tracker_packet_hash[MAX_HASH_SIZE] = {};
uint32_t futureMillis(uint32_t delay) const { return millis() + delay; }
void finishRadioReply(bool) {}
void onSendComplete(mesh::Packet*) override;
};
@CALLBACK@
int main() {
// The queue never drains: every completion schedules another packet before
// CommonCLI::loop gets control again. Only the actual completion hook can
// admit the queued TX setting in the idle hardware gap.
for (bool receive_busy_at_first_completion : {false, true}) {
now_ms = 1000;
TxRole role;
auto& cli = role._cli;
auto& driver = cli.callbacks;
mesh::Packet packet;
char reply[160];
driver.transmitting = true;
cli.command("set tx 14", reply);
assert(!strcmp(reply, "OK - saved; pending"));
assert(cli.hasPendingTxPower() && driver.saves == 1 && driver.hardware == 10);
unsigned applied_after = 0;
for (unsigned sent = 1; sent <= 4; ++sent) {
cli.loop(); assert(driver.transmitting);
// Dispatcher calls onSendFinished before the role completion callback.
driver.transmitting = false;
completion_seen = false;
driver.require_completion = true;
if (sent == 1 && receive_busy_at_first_completion) driver.results = {Result::BUSY};
now_ms += 5000;
role.onSendComplete(&packet);
assert(completion_seen && driver.saves == 1);
if (!receive_busy_at_first_completion || sent > 1) {
assert(!cli.hasPendingTxPower() && driver.hardware == 14);
if (!applied_after) applied_after = now_ms;
} else assert(cli.hasPendingTxPower() && driver.hardware == 10);
driver.require_completion = false;
// checkSend() immediately starts the next queued packet. A post-Mesh
// service would now see BUSY for the entire burst.
driver.transmitting = true;
cli.loop();
}
assert(applied_after > 0 && applied_after < 30000);
assert(driver.persisted == 14 && driver.saves == 1);
}
}
'''
with tempfile.TemporaryDirectory(prefix='meshcore-common-cli-tx-burst-') as directory:
cpp, exe = Path(directory) / 'test.cpp', Path(directory) / 'test'
for role, name, filename in (
('simple_repeater', 'MyMesh', 'MyMesh.cpp'),
('simple_room_server', 'MyMesh', 'MyMesh.cpp'),
('simple_sensor', 'SensorMesh', 'SensorMesh.cpp'),
('simple_sensor', 'SensorMesh', 'Tracker.cpp')):
source = (ROOT / 'examples' / role / filename).read_text()
callback = preprocess(extract_braced(source, f'void {name}::onSendComplete('))
callback = callback.replace(name + '::', 'TxRole::')
for omit_gap in (False, True):
with self.subTest(role=role, file=filename, omit_gap=omit_gap):
actual = callback.replace('_cli.servicePendingTxPower();', '') if omit_gap else callback
cpp.write_text(prefix + fixture.replace('@CALLBACK@', actual), encoding='ascii')
flags = [] if os.name == 'nt' else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie']
built = subprocess.run([
os.environ.get('CXX', 'g++'), '-std=c++17', '-Wall', '-Wextra', '-Werror',
*flags, '-I', str(ROOT / 'src'), str(cpp), '-o', str(exe),
], capture_output=True, text=True)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
ran = subprocess.run([str(exe)], capture_output=True, text=True)
if omit_gap:
self.assertNotEqual(ran.returncode, 0, 'missing completion service escaped detection')
else:
self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr)
def test_applied_power_diagnostic_and_callbacks_are_hil_only(self):
common = (ROOT / 'src/helpers/CommonCLI.cpp').read_text()
header = (ROOT / 'src/helpers/CommonCLI.h').read_text()
getter = extract_braced(common, 'void CommonCLI::handleGetCmd(')
self.assertNotIn('"tx.status"', preprocess(getter))
self.assertIn('"tx.status"', preprocess(getter, hil=True))
self.assertIn('"tx"', preprocess(getter))
def check_guard(text, definition):
guarded = '#if defined(MESH_HIL_TX_FREQ_MHZ)\n' + definition + '\n#endif'
self.assertIn(guarded, text)
self.assertNotIn('isTxPowerApplied', preprocess(guarded))
self.assertIn('isTxPowerApplied', preprocess(guarded, hil=True))
check_guard(header, ' ' + extract_braced(header, 'virtual bool isTxPowerApplied('))
for role, name in (('simple_repeater', 'MyMesh'),
('simple_room_server', 'MyMesh'),
('simple_sensor', 'SensorMesh')):
with self.subTest(role=role):
source = (ROOT / 'examples' / role / (name + '.cpp')).read_text()
role_header = (ROOT / 'examples' / role / (name + '.h')).read_text()
check_guard(source, extract_braced(source, f'bool {name}::isTxPowerApplied('))
check_guard(role_header, ' bool isTxPowerApplied(int8_t power_dbm) const override;')
def test_every_infrastructure_role_preserves_tristate_and_pending_work(self):
for role, name in (('simple_repeater', 'MyMesh'),
('simple_room_server', 'MyMesh'),
('simple_sensor', 'SensorMesh')):
with self.subTest(role=role):
source = (ROOT / 'examples' / role / (name + '.cpp')).read_text()
header = (ROOT / 'examples' / role / (name + '.h')).read_text()
callback = extract_braced(source,
f'mesh::RadioParamApplyResult {name}::trySetTxPower(')
self.assertIn('return radio_driver.trySetTxPower(power_dbm);', callback)
applied = extract_braced(source,
f'bool {name}::isTxPowerApplied(')
self.assertIn('return radio_driver.isTxPowerApplied(power_dbm);', applied)
pending = extract_braced(source,
f'mesh::powerdiag::Mask {name}::pendingWorkReason() const')
# Include pending intent during hardware-failure backoff too:
# nRF52's event-driven sleep has no guaranteed timer wake.
self.assertIn('_cli.hasPendingTxPower()', pending)
self.assertLess(pending.index('_cli.hasPendingTxPower()'),
pending.index('radio_apply_backoff')
if 'radio_apply_backoff' in pending else len(pending))
recovery = extract_braced(header, 'bool canRecoverUsbLogging() const')
self.assertIn('!_cli.hasPendingTxPower()', recovery)
self.assertIn('_cli.loop();', extract_braced(source, f'void {name}::loop('))
if __name__ == '__main__':
unittest.main()
+261 -179
View File
@@ -23,10 +23,18 @@ HARNESS = r'''
#include <helpers/CLICommandUtils.h>
#define MESH_DEBUG_PRINTLN(...) ((void)0)
#define MAX_LORA_TX_POWER 22
#define COMPANION_FEATURE_TEMP_RADIO 0
#define COMPANION_FEATURE_TEMP_RADIO 1
#define COMMAND_RADIO_APPLY_TIMEOUT_MS 3000
@CODES@
namespace mesh { enum class RadioParamApplyResult { APPLIED, BUSY, FAILED }; }
static bool tx_inflight=false;
static std::vector<unsigned> completion_order;
namespace mesh {
enum class RadioParamApplyResult { APPLIED, BUSY, FAILED };
struct Packet {};
struct Mesh {
void onSendComplete(Packet*) { completion_order.push_back(1); }
};
}
using Apply = mesh::RadioParamApplyResult;
static uint32_t now_ms = 100;
struct Clock { uint32_t getMillis() const { return now_ms; } } clock_source;
@@ -49,15 +57,27 @@ void recalcRxPowerSavingFromLevel(uint8_t,uint8_t sf,float bw,uint8_t,
uint32_t* rx,uint32_t* sleep) {
*rx=sf*100; *sleep=uint32_t(bw*100);
}
struct Radio {
bool carrier=false, morse_tx=false, morse_rx=false;
bool isCarrierWaveActive() const { return carrier; }
bool isMorseTxActive() const { return morse_tx; }
bool isMorseRxActive() const { return morse_rx; }
} radio;
struct Driver {
int8_t power=3;
bool partial_failure=false, power_verified=true;
int8_t power=3, invalid_power=127;
unsigned calls=0;
std::deque<Apply> accepted;
bool isTxPowerValid(int8_t requested) const { return requested!=invalid_power; }
bool isTxPowerApplied(int8_t requested) const { return power_verified && power==requested; }
Apply trySetTxPower(int8_t requested) {
++calls;
Apply result=Apply::APPLIED;
if (!accepted.empty()) { result=accepted.front(); accepted.pop_front(); }
if (result==Apply::APPLIED) power=requested;
if (tx_inflight || radio.carrier || radio.morse_tx || radio.morse_rx) result=Apply::BUSY;
if (result!=Apply::BUSY) power_verified=result==Apply::APPLIED;
if (result==Apply::APPLIED || (result==Apply::FAILED && partial_failure)) power=requested;
if (result==Apply::APPLIED) completion_order.push_back(2);
return result;
}
bool setTxPower(int8_t requested) { return trySetTxPower(requested)==Apply::APPLIED; }
@@ -67,7 +87,7 @@ struct Driver {
bool setRxPowerSaving(bool,uint32_t,uint32_t) { return true; }
} radio_driver;
@RETRY_DELAY@
struct MyMesh {
struct MyMesh : mesh::Mesh {
Prefs _prefs, durable, live;
BaseSerialInterface serial, original, other;
BaseSerialInterface* _serial=&serial;
@@ -76,7 +96,10 @@ struct MyMesh {
unsigned saves=0, applies=0;
std::deque<Apply> accepted;
bool saved_radio_apply_pending=false;
uint8_t command_radio_apply_pending=0;
bool tx_power_apply_pending=false, _temp_radio_applied=false;
uint32_t tx_power_apply_retry_at=0, _temp_radio_set_at=0, _temp_radio_revert_at=0;
Radio* _radio=&radio;
bool command_radio_apply_pending=false;
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;
@@ -85,11 +108,18 @@ struct MyMesh {
uint8_t cmd_frame[180]={};
struct Reply { uint8_t error; BaseSerialInterface* route; };
std::vector<Reply> replies;
struct {
void complete(mesh::Packet*) { completion_order.push_back(3); }
} _morse_reply;
@RESULT_ENUM@;
MyMesh() { serial.current=&original; radio_driver=Driver(); now_ms=100; }
MyMesh() {
serial.current=&original;radio_driver=Driver();radio=Radio();now_ms=100;
tx_inflight=false;completion_order.clear();
}
uint32_t futureMillis(uint32_t delay) const { return now_ms+delay; }
bool millisHasNowPassed(uint32_t deadline) const { return int32_t(now_ms-deadline)>=0; }
bool hasOutbound() const { return outbound; }
bool isTempRadioActive() const { return _temp_radio_applied; }
bool isValidClientRepeatFreq(uint32_t) const { return true; }
bool savePrefs() { ++saves; if(!storage_accepts)return false; durable=_prefs; return true; }
void writeOKFrame(BaseSerialInterface* route=nullptr) { replies.push_back({0,route?route:serial.current}); }
@@ -102,15 +132,20 @@ struct MyMesh {
return result;
}
bool applySavedRadioParams();
RadioSettingResult applyAndSaveTxPower(int8_t,bool from_pending=false);
RadioSettingResult applyAndSaveTxPower(int8_t);
void servicePendingTxPower();
void onSendComplete(mesh::Packet*);
void finishRadioParamApply(float,float,uint8_t,uint8_t,uint8_t,BaseSerialInterface* = nullptr);
void deferRadioCommand(uint8_t);
void deferRadioCommand();
void servicePendingRadioParamApply();
void cancelPendingRadioParamApply();
void frame(size_t len) { @FRAME_BRANCHES@ }
void recover() { @RECOVERY@ }
bool command(const char* command,char* reply) {
const size_t reply_capacity=160;
#if defined(MESH_HIL_TX_FREQ_MHZ)
@TX_STATUS_BRANCH@
#endif
@CLI_BRANCH@
return false;
}
@@ -141,13 +176,31 @@ struct MyMesh {
};
@METHODS@
int main() {
// A real hardware rejection remains an immediate error, without a save.
#if defined(MESH_HIL_TX_FREQ_MHZ)
// Saved power alone is not proof of the current hardware state. Startup,
// recovery and unavailable radios must not report an unverified target.
{
MyMesh m;radio_driver.accepted={Apply::FAILED};m.powerFrame(17);
m.expectError(ERR_CODE_BAD_STATE);
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);
MyMesh m;char reply[160]={};
assert(m.command("get tx.status",reply) && !strcmp(reply,"> desired=3,apply=applied"));
radio_driver.power_verified=false;
assert(m.command("get tx.status",reply) && !strcmp(reply,"> desired=3,apply=pending"));
radio_driver.power_verified=true;radio_driver.power=7;
assert(m.command("get tx.status",reply) && !strcmp(reply,"> desired=3,apply=pending"));
radio_driver.power=3;m._radio_available=false;
assert(m.command("get tx.status",reply) && !strcmp(reply,"> desired=3,apply=pending"));
m._radio_available=true;m.tx_power_apply_pending=true;
assert(m.command("get tx.status",reply) && !strcmp(reply,"> desired=3,apply=pending"));
m.tx_power_apply_pending=false;
assert(m.command("get tx.status",reply) && !strcmp(reply,"> desired=3,apply=applied"));
assert(m.saves==0 && radio_driver.calls==0);
}
#else
{
MyMesh m;char reply[160]={};
assert(!m.command("get tx.status",reply));
}
#endif
// Invalid input never reaches hardware or storage.
for (int value : {-10,23}) {
MyMesh m;m.powerFrame(uint8_t(value));m.expectError(ERR_CODE_ILLEGAL_ARG);
assert(m.saves==0 && radio_driver.calls==0);
@@ -156,178 +209,197 @@ int main() {
MyMesh m;m.powerFrame(17,1);m.expectError(ERR_CODE_ILLEGAL_ARG);
assert(m.saves==0 && radio_driver.calls==0);
}
// Hardware-specific invalid values cannot become saved intent, even while
// a pending tuple prevents the ordinary hardware setter from being called.
for(bool tuple_pending : {false,true}) {
MyMesh m;radio_driver.accepted={Apply::BUSY};m.powerFrame(17);
m.replies.clear();m.command_radio_apply_pending=tuple_pending;
m.tx_power_apply_retry_at=now_ms+1000;radio_driver.invalid_power=18;
const unsigned calls=radio_driver.calls;
m.powerFrame(18);m.expectError(ERR_CODE_BAD_STATE);
assert(m.tx_power_apply_pending && m.tx_power_apply_retry_at==now_ms+1000);
assert(m._prefs.tx_power_dbm==17 && m.durable.tx_power_dbm==17);
assert(m.saves==1 && radio_driver.calls==calls);
assert(m.command_radio_apply_pending==tuple_pending);
}
// Immediate application still commits before acknowledging the request.
{
MyMesh m;m.powerFrame(17);m.expectError(0);
assert(m.saves==1 && m.durable.tx_power_dbm==17 && radio_driver.power==17);
assert(!m.tx_power_apply_pending && !m.command_radio_apply_pending);
}
{
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.
// Busy reception accepts one durable intent. It completes automatically,
// without a second command, additional save or delayed protocol reply.
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);
m.powerFrame(uint8_t(power));m.expectError(0);
assert(m.tx_power_apply_pending && !m.command_radio_apply_pending);
assert(m._prefs.tx_power_dbm==power && m.durable.tx_power_dbm==power);
assert(radio_driver.power==power);
assert(m.saves==1 && radio_driver.power==3);
m.serial.current=&m.other;m.serial.disconnected=&m.original;m.serial.busy=true;
m.servicePendingTxPower();
assert(m.tx_power_apply_pending && m.saves==1 && radio_driver.power==3);
m.servicePendingTxPower();
assert(!m.tx_power_apply_pending && m.saves==1 && radio_driver.power==power);
assert(m.replies.size()==1); // It is saved intent, not an owned reply slot.
}
// Reproduce the app's multi-command settings save: the tuple is durable
// before the following power command encounters a temporary receive lease.
// A long receive lease cannot expire accepted power intent. Keep servicing
// it for more than 30 seconds, then apply once the radio is free.
{
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;
MyMesh m;radio_driver.accepted={Apply::BUSY};m.powerFrame(17);m.expectError(0);
for(uint32_t elapsed=0;elapsed<=31000;elapsed+=1000) {
now_ms=100+elapsed;radio_driver.accepted={Apply::BUSY};m.servicePendingTxPower();
assert(m.tx_power_apply_pending && m.durable.tx_power_dbm==17);
assert(m.saves==1 && radio_driver.power==3 && m.replies.size()==1);
}
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);
++now_ms;radio_driver.accepted={Apply::APPLIED};m.servicePendingTxPower();
assert(!m.tx_power_apply_pending && radio_driver.power==17 && m.saves==1);
assert(m.replies.size()==1); // No resend or reboot was required.
}
// A later hardware rejection must not be mistaken for another busy retry.
// Recovery-mode firmware can accept a saved target before radio startup.
{
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);
MyMesh m;m._radio_available=false;m.powerFrame(17);m.expectError(0);
assert(m.tx_power_apply_pending && m.durable.tx_power_dbm==17 && radio_driver.calls==0);
m.servicePendingTxPower();assert(radio_driver.calls==0);
m._radio_available=true;m.servicePendingTxPower();
assert(!m.tx_power_apply_pending && radio_driver.power==17 && m.saves==1);
}
// 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 saturated TX queue can start its next packet before the outer loop gets
// another chance. Apply pending power in the real completion callback's gap.
{
MyMesh m;mesh::Packet completed;tx_inflight=true;
m.powerFrame(17);m.expectError(0);
assert(m.tx_power_apply_pending && m.saves==1 && radio_driver.power==3);
completion_order.clear();tx_inflight=false; // The completed TX is now idle.
m.onSendComplete(&completed);
assert((completion_order==std::vector<unsigned>{1,2,3}));
assert(!m.tx_power_apply_pending && radio_driver.power==17 && m.durable.tx_power_dbm==17);
assert(m.saves==1 && m.replies.size()==1);
completion_order.push_back(4);tx_inflight=true; // Dispatcher starts next TX.
const unsigned calls=radio_driver.calls;m.servicePendingTxPower();
assert(radio_driver.calls==calls && radio_driver.power==17 && m.saves==1);
}
// A failed commit restores RAM and the physical output-power setting.
// Newer accepted values replace saved pending intent, across frontends.
{
MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::BUSY,Apply::APPLIED};
m.powerFrame(17);m.expectError(0);
char reply[160]={};
assert(m.command("set tx 12",reply) && !strncmp(reply,"OK",2));
assert(strstr(reply,"pending"));
assert(m.tx_power_apply_pending && m._prefs.tx_power_dbm==12 && m.durable.tx_power_dbm==12);
m.servicePendingTxPower();
assert(!m.tx_power_apply_pending && radio_driver.power==12 && m.saves==2);
m.servicePendingTxPower();assert(m.saves==2 && radio_driver.power==12);
}
// A rejected second save preserves the previously accepted pending target.
{
MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::BUSY,Apply::APPLIED};
m.powerFrame(17);m.replies.clear();m.storage_accepts=false;
m.tx_power_apply_retry_at=now_ms+1000;
m.powerFrame(12);m.expectError(ERR_CODE_FILE_IO_ERROR);
assert(m.tx_power_apply_pending && m._prefs.tx_power_dbm==17 && m.durable.tx_power_dbm==17);
assert(m.tx_power_apply_retry_at==now_ms+1000);
const unsigned calls=radio_driver.calls;
m.servicePendingTxPower();assert(radio_driver.calls==calls);
now_ms=m.tx_power_apply_retry_at;
m.storage_accepts=true;m.servicePendingTxPower();
assert(!m.tx_power_apply_pending && radio_driver.power==17 && m.saves==2);
}
// Storage failure restores both the durable value and physical output.
{
MyMesh m;m.storage_accepts=false;m.powerFrame(17);m.expectError(ERR_CODE_FILE_IO_ERROR);
assert(m._prefs.tx_power_dbm==3 && m.durable.tx_power_dbm==3 && radio_driver.power==3);
assert(radio_driver.calls==2 && !m.saved_radio_apply_pending);
assert(radio_driver.calls==2 && !m.saved_radio_apply_pending && !m.tx_power_apply_pending);
}
// 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.
// A rollback blocked by RX is retried without restoring the whole tuple or
// waiting for a long-running temporary profile to expire.
{
MyMesh m;m.storage_accepts=false;
radio_driver.accepted={Apply::APPLIED,Apply::BUSY,Apply::FAILED,Apply::APPLIED};
radio_driver.accepted={Apply::APPLIED,Apply::BUSY,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);
assert(m.radio_apply_failures==1 && m.radio_apply_retry_at>now_ms);
unsigned calls=radio_driver.calls;m.recover();assert(radio_driver.calls==calls);
now_ms=m.radio_apply_retry_at;m.recover();
assert(!m.saved_radio_apply_pending && radio_driver.power==3 && m.saves==1);
assert(m.replies.size()==1); // no later success for the failed command
assert(m._prefs.tx_power_dbm==3 && radio_driver.power==17 && m.tx_power_apply_pending);
assert(!m.saved_radio_apply_pending);
m._temp_radio_applied=true;m.servicePendingTxPower();
assert(!m.tx_power_apply_pending && radio_driver.power==3 && m.saves==1 && m.applies==0);
assert(m.replies.size()==1); // A failed command receives no later success.
}
// ASCII and framed setters share identical hardware/durability semantics.
// A hardware-confirmed rollback clears prior pending intent. If rollback
// is busy, retain the durable target for automatic physical recovery.
for(bool rollback_busy : {false,true}) {
MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::APPLIED,
rollback_busy?Apply::BUSY:Apply::APPLIED,Apply::APPLIED};
m.powerFrame(17);m.replies.clear();m.storage_accepts=false;
m.tx_power_apply_retry_at=now_ms+1000;
m.powerFrame(12);m.expectError(ERR_CODE_FILE_IO_ERROR);
assert(m._prefs.tx_power_dbm==17 && m.durable.tx_power_dbm==17);
assert(m.tx_power_apply_pending==rollback_busy && m.tx_power_apply_retry_at==0);
assert(radio_driver.power==(rollback_busy?12:17));
const unsigned calls=radio_driver.calls;
m.servicePendingTxPower();
assert(!m.tx_power_apply_pending && radio_driver.power==17 && m.saves==2);
assert(radio_driver.calls==calls+unsigned(rollback_busy));
}
// A genuine hardware rejection remains an error, including partial writes.
// The previous durable value is reasserted automatically after rejection.
for(bool partial : {false,true}) {
MyMesh m;radio_driver.partial_failure=partial;
radio_driver.accepted={Apply::FAILED,Apply::APPLIED};m.powerFrame(17);
m.expectError(ERR_CODE_BAD_STATE);
assert(m.saves==0 && m._prefs.tx_power_dbm==3 && m.durable.tx_power_dbm==3);
assert(m.tx_power_apply_pending && !m.saved_radio_apply_pending);
assert(radio_driver.power==(partial?17:3));
m.servicePendingTxPower();
assert(!m.tx_power_apply_pending && radio_driver.power==3 && m.saves==0);
}
// Repeated hardware failures back off, then complete without losing intent.
// Verify wrap-safe retry deadlines as well as the ordinary clock.
for(uint32_t start : {100u,UINT32_MAX-500u,UINT32_MAX-999u}) {
MyMesh m;now_ms=start;
radio_driver.accepted={Apply::BUSY,Apply::FAILED,Apply::APPLIED};
m.powerFrame(17);m.servicePendingTxPower();
assert(m.tx_power_apply_pending && m.tx_power_apply_retry_at!=0);
const unsigned calls=radio_driver.calls;
assert(m.tx_power_apply_retry_at==(uint32_t(start+1000u)?uint32_t(start+1000u):1u));
now_ms=m.tx_power_apply_retry_at-1;m.servicePendingTxPower();
assert(radio_driver.calls==calls && m.tx_power_apply_pending);
++now_ms;m.servicePendingTxPower();
assert(!m.tx_power_apply_pending && radio_driver.power==17 && m.saves==1);
}
// CW and Morse ownership are retained. Temporary channel leases do not
// block TX-only updates: changing power must not revert their radio tuple.
for(unsigned owner=0;owner<3;++owner) {
MyMesh m;radio_driver.accepted={Apply::BUSY};m.powerFrame(17);
if(owner==0)radio.carrier=true;
else if(owner==1)radio.morse_tx=true;
else radio.morse_rx=true;
m.servicePendingTxPower();assert(m.tx_power_apply_pending && radio_driver.power==3);
radio=Radio();m._temp_radio_applied=true;m.servicePendingTxPower();
assert(!m.tx_power_apply_pending && radio_driver.power==17 && m.applies==0);
}
// A pending tuple retains its original route/deadline, while power is saved
// independently. Completing the tuple cannot overwrite newer power intent.
{
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;
assert(m.command("set tx 17",reply) && strstr(reply,"saved"));
assert(strstr(reply,"Error") && radio_driver.power==3);
assert(m.command("set tx 17junk",reply) && strstr(reply,"Error"));
assert(m.saves==1);
MyMesh m;m.accepted={Apply::BUSY,Apply::APPLIED};m.tupleFrame();
assert(m.replies.empty() && m.command_radio_apply_pending && m.saves==0);
m.serial.current=&m.other;m.powerFrame(17);m.expectError(0,&m.other);
assert(m.command_radio_apply_pending && m.tx_power_apply_pending && radio_driver.calls==0);
assert(m.durable.freq==915 && m.durable.tx_power_dbm==17);
m.replies.clear();m.servicePendingTxPower();assert(radio_driver.calls==0);
m.servicePendingRadioParamApply();m.expectError(0);
assert(!m.command_radio_apply_pending && m.tx_power_apply_pending);
assert(m.durable.freq==920 && m.durable.tx_power_dbm==17);
m.servicePendingTxPower();
assert(!m.tx_power_apply_pending && radio_driver.power==17 && m.saves==2);
}
// Both immediate and deferred tuple changes must commit before OK.
// Frontends retain strict validation and report accepted pending intent.
{
MyMesh m;char reply[160]={};radio_driver.accepted={Apply::BUSY,Apply::APPLIED};
assert(m.command("set tx 17",reply) && !strncmp(reply,"OK",2) && strstr(reply,"pending"));
assert(m.command("set tx 17junk",reply) && strstr(reply,"Error"));
assert(m.saves==1 && m.durable.tx_power_dbm==17);
m.servicePendingTxPower();assert(!m.tx_power_apply_pending && radio_driver.power==17);
}
// Both immediate and deferred tuple changes still commit before OK.
{
MyMesh m;m.tupleFrame();m.expectError(0);
assert(m._prefs.freq==920 && m.durable.freq==920 && m.live.freq==920);
@@ -353,22 +425,16 @@ int main() {
m.recover();assert(!m.saved_radio_apply_pending && m.live.freq==915);
assert(m.saves==1 && m.replies.size()==1);
}
// Disconnect cancels a deferred command without persisting its candidate.
// Disconnect still cancels an unsaved tuple.
{
MyMesh m;m.accepted={Apply::BUSY};m.tupleFrame();
m.serial.disconnected=&m.original;m.servicePendingRadioParamApply();
assert(!m.command_radio_apply_pending && m.replies.empty() && m.saves==0);
m.expectOriginalPrefs();
}
// Tuple completion must not swallow an outstanding power rollback.
{
MyMesh m;m.saved_radio_apply_pending=true;radio_driver.power=17;
m.tupleFrame();m.expectError(0);assert(m.saved_radio_apply_pending);
m.recover();assert(!m.saved_radio_apply_pending && radio_driver.power==3);
assert(m.live.freq==920 && m.durable.freq==920);
}
puts("Companion radio-setting commit and recovery regressions passed");
puts("Companion automatic TX-power commit and recovery regressions passed");
}
'''
SLEEP_HARNESS = r'''
@@ -396,8 +462,10 @@ struct MyMesh {
ContactStore store;
ContactStore* _store=&store;
bool _radio_available=true, _iter_started=false;
uint8_t command_radio_apply_pending=0;
bool command_radio_apply_pending=false;
bool saved_radio_apply_pending=false, _terminal_trace_pending=false;
bool tx_power_apply_pending=false;
uint32_t tx_power_apply_retry_at=0;
uint32_t radio_apply_retry_at=0, _scheduled_reboot_at=0;
uint32_t emergency_client_repeat_send_at=0, dirty_contacts_expiry=0;
uint8_t dirty_contacts_failures=0;
@@ -426,10 +494,17 @@ 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.
m.command_radio_apply_pending=true;
assert(m.hasPendingWork()); // Deferred tuples must keep the retry loop awake.
assert(m.pendingWorkReason()==mesh::powerdiag::mask(mesh::powerdiag::Reason::Scheduled));
m.command_radio_apply_pending=0;
m.command_radio_apply_pending=false;
assert(!m.hasPendingWork());
m.tx_power_apply_pending=true;
assert(m.hasPendingWork()); // TX-only intent must be serviced automatically.
assert(m.pendingWorkReason()==mesh::powerdiag::mask(mesh::powerdiag::Reason::Scheduled));
m.tx_power_apply_retry_at=now_ms+1000;
assert(m.hasPendingWork()); // Event-driven sleep must not strand the retry.
m.tx_power_apply_pending=false;m.tx_power_apply_retry_at=0;
assert(!m.hasPendingWork());
m.store._contact_recovery=ContactStore::ContactRecoveryPending;
assert(m.hasPendingWork()); // Storage repair still needs the outer loop.
@@ -525,6 +600,8 @@ class CompanionRadioSettingsTransactionTests(unittest.TestCase):
'MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(',
'void MyMesh::finishRadioParamApply(',
'void MyMesh::deferRadioCommand(',
'void MyMesh::servicePendingTxPower(',
'void MyMesh::onSendComplete(',
'void MyMesh::cancelPendingRadioParamApply(',
'void MyMesh::servicePendingRadioParamApply(',
))
@@ -536,6 +613,7 @@ class CompanionRadioSettingsTransactionTests(unittest.TestCase):
'@RETRY_DELAY@': extract_braced(text, 'static uint32_t nextRadioApplyRetryDelay('),
'@METHODS@': methods,
'@FRAME_BRANCHES@': frame_branches,
'@TX_STATUS_BRANCH@': extract_braced(text, 'if (strcmp(command, "get tx.status") == 0)'),
'@CLI_BRANCH@': extract_braced(text, 'if (strncmp(command, "set tx ", 7) == 0)'),
'@RECOVERY@': extract_braced(text, 'if (!command_radio_apply_pending && saved_radio_apply_pending && !hasOutbound()'),
}
@@ -546,10 +624,13 @@ class CompanionRadioSettingsTransactionTests(unittest.TestCase):
cpp = Path(tmp) / 'test.cpp'
binary = Path(tmp) / ('test.exe' if os.name == 'nt' else 'test')
cpp.write_text(harness, encoding='utf-8')
subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17', '-Wall', '-Wextra', '-Werror',
'-Wno-sign-compare', # existing frame parser compares size_t and byte offsets
'-I', str(ROOT / 'src'), str(cpp), '-o', str(binary)], check=True)
subprocess.run([str(binary)], check=True)
for hil in (False, True):
with self.subTest(hil=hil):
subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17', '-Wall', '-Wextra', '-Werror',
'-Wno-sign-compare', # existing frame parser compares size_t and byte offsets
*(['-DMESH_HIL_TX_FREQ_MHZ=907.125f'] if hil else []),
'-I', str(ROOT / 'src'), str(cpp), '-o', str(binary)], check=True)
subprocess.run([str(binary)], check=True)
def test_all_tx_power_frontends_use_checked_handler(self):
text = SOURCE.read_text(encoding='utf-8')
@@ -557,6 +638,7 @@ class CompanionRadioSettingsTransactionTests(unittest.TestCase):
self.assertIn('handleCommand(command, 0, local_reply)', terminal)
web = extract_braced(text, 'if (strcmp(key, "tx") == 0)')
self.assertIn('applyAndSaveTxPower(', web)
self.assertIn('servicePendingTxPower();', extract_braced(text, 'void MyMesh::loop()'))
self.assertIn('RadioSettingResult::SaveFailed',
extract_braced(text, 'if (strncmp(command, "set tx ", 7) == 0)'))
+88 -23
View File
@@ -19,7 +19,8 @@ SPEC.loader.exec_module(BENCH)
class FakePair:
def __init__(self, *, loss=False, bad_guard=False, primary_tx=0, conflict=False, inactive=False):
def __init__(self, *, loss=False, bad_guard=False, primary_tx=0, conflict=False, inactive=False,
initial_power=21, pending_reads=0):
self.clock = 100.0
self.last_source = None
self.last_payload = None
@@ -34,7 +35,10 @@ class FakePair:
self.name, self.preamble = name, 128
self.commands, self.requests = [], []
self.tx = 0
self.tx_power = 21
self.tx_power = initial_power
self.applied_tx_power = 21 if pending_reads else initial_power
self.pending_reads = pending_reads
self.tx_verified = False
def channel_name(self, slot):
return "mt-conflict" if conflict else "Public"
@@ -49,8 +53,16 @@ class FakePair:
return f"> RX=0,0 TX={primary_tx},{self.tx}; active=radio2"
if command == "get tx":
return f"> {self.tx_power}"
if command == "get tx.status":
if self.pending_reads:
self.pending_reads -= 1
return f"> desired={self.tx_power},apply=pending"
self.applied_tx_power = self.tx_power
self.tx_verified = self.applied_tx_power == 2
return f"> desired={self.tx_power},apply=applied"
if command == "set tx 2":
self.tx_power = 2
return "OK - saved; pending" if self.pending_reads else "OK"
if command.startswith("set radio2 "):
self.preamble = int(command.rsplit(",", 1)[1])
if command == "get radio2":
@@ -62,6 +74,8 @@ class FakePair:
return "OK"
def request(self, command, expected):
assert all(node.tx_verified and node.applied_tx_power == 2
for node in owner.nodes.values()), "RF before verified low TX power"
self.requests.append(command)
assert command[:2] == b"\x19\x00"
assert expected == (0,)
@@ -222,35 +236,86 @@ class CompanionRawRadio2SweepTest(unittest.TestCase):
self.assertEqual(result["unexpected_raw"], 1)
self.assertEqual(result["ignored_frames"], 1)
def test_existing_low_tx_skips_setter_and_transient_busy_is_bounded(self):
node = SimpleNamespace(cli=mock.Mock(return_value="> 2"))
def test_existing_low_tx_still_requires_applied_status(self):
node = SimpleNamespace(cli=mock.Mock(side_effect=("> 2", "> desired=2,apply=applied")))
result = BENCH.ensure_low_tx(node)
self.assertEqual(result["setter_attempts"], 0)
node.cli.assert_called_once_with("get tx")
node = SimpleNamespace(cli=mock.Mock(side_effect=("> 21",
RuntimeError("Companion CLI set tx rejected: Error: radio busy or TX power rejected"), "OK", "> 2")))
with mock.patch.object(BENCH.time, "sleep") as sleep:
result = BENCH.ensure_low_tx(node)
self.assertEqual(result["setter_attempts"], 2)
sleep.assert_called_once_with(0.25)
self.assertEqual(node.cli.call_args_list[-1], mock.call("get tx"))
self.assertEqual(node.cli.call_args_list, [mock.call("get tx"), mock.call("get tx.status")])
def test_unknown_tx_errors_or_wrong_readback_fail_closed(self):
for responses in (("> 21", RuntimeError("Companion CLI set tx rejected: Error: invalid TX power")),
("> 21", "OK", "> 21")):
def test_pending_power_is_polled_without_resubmitting_same_or_changed_desired_value(self):
for initial in (2, 21):
with self.subTest(initial=initial):
responses = [f"> {initial}"]
if initial != 2:
responses.append("OK - saved; pending")
responses += ["> desired=2,apply=pending"] * 2 + ["> desired=2,apply=applied"]
node = SimpleNamespace(cli=mock.Mock(side_effect=responses))
with mock.patch.object(BENCH.time, "sleep") as sleep:
result = BENCH.ensure_low_tx(node)
expected = [mock.call("get tx")]
if initial != 2:
expected.append(mock.call("set tx 2"))
expected += [mock.call("get tx.status")] * 3
self.assertEqual(node.cli.call_args_list, expected)
self.assertEqual(result["setter_attempts"], int(initial != 2))
self.assertEqual(result["status_polls"], 3)
self.assertEqual(sleep.call_args_list, [mock.call(0.25)] * 2)
def test_pending_apply_blocks_all_rf_until_both_radios_confirm_low_power(self):
for initial in (2, 21):
with self.subTest(initial=initial):
pair = FakePair(initial_power=initial, pending_reads=2)
result, _ = self.run_matrix(pair)
self.assertEqual(result["result"], "PASS", result.get("failure"))
self.assertEqual(pair.total_sent, 32)
for node in pair.nodes.values():
self.assertEqual(node.commands.count("set tx 2"), int(initial != 2))
self.assertEqual(node.commands.count("get tx.status"), 3)
def test_unapplied_power_times_out_without_setter_resubmission_or_rf(self):
for initial in (2, 21):
with self.subTest(initial=initial):
pair = FakePair(initial_power=initial, pending_reads=1000)
result, _ = self.run_matrix(pair)
self.assertEqual(result["result"], "FAIL")
self.assertIn("bounded setup window", result["failure"]["message"])
self.assertEqual(pair.total_sent, 0)
for node in pair.nodes.values():
self.assertLessEqual(node.commands.count("set tx 2"), int(initial != 2))
self.assertFalse(any(command.startswith("set radio2 ") for command in node.commands))
node = SimpleNamespace(cli=mock.Mock(side_effect=("> 21", "OK - saved; pending",
"> desired=2,apply=pending", "> desired=2,apply=pending")))
with mock.patch.object(BENCH.time, "sleep"), \
mock.patch.object(BENCH.time, "monotonic", side_effect=(0, 1, 8)):
with self.assertRaisesRegex(BENCH.Failure, "bounded setup window"):
BENCH.ensure_low_tx(node)
self.assertEqual(node.cli.call_args_list.count(mock.call("set tx 2")), 1)
def test_missing_malformed_or_contradictory_status_fails_closed(self):
for initial in (2, 21):
for status in ("OK", "unknown config", "", "> 2", "> desired=21,apply=applied",
"> desired=21,apply=pending", "> desired=2,apply=unknown",
"> desired=2,apply=applied; desired=21", "> desired=2,apply=applied\nOK"):
with self.subTest(initial=initial, status=status):
pair = FakePair(initial_power=initial)
original = pair.nodes["b"].cli
pair.nodes["b"].cli = lambda command: status if command == "get tx.status" else original(command)
result, _ = self.run_matrix(pair)
self.assertEqual(result["result"], "FAIL")
self.assertEqual(pair.total_sent, 0)
self.assertFalse(any(command.startswith("set radio2 ")
for node in pair.nodes.values() for command in node.commands))
def test_unknown_tx_setter_errors_or_missing_numeric_readback_fail_closed(self):
for responses in (("unknown config",),
("> 21", RuntimeError("Companion CLI set tx rejected: Error: invalid TX power")),
("> 21", "Error: radio busy or TX power rejected"),
("> 21", "unknown config"), ("> 21", "OK but rejected")):
with self.subTest(responses=responses):
node = SimpleNamespace(cli=mock.Mock(side_effect=responses))
with self.assertRaises(Exception), mock.patch.object(BENCH.time, "sleep") as sleep:
BENCH.ensure_low_tx(node)
sleep.assert_not_called()
def busy(command):
if command == "get tx":
return "> 21"
raise RuntimeError("Companion CLI set tx rejected: Error: radio busy or TX power rejected")
with mock.patch.object(BENCH.time, "sleep"), \
mock.patch.object(BENCH.time, "monotonic", side_effect=(0, 1, 8)):
with self.assertRaisesRegex(BENCH.Failure, "bounded8-second"):
BENCH.ensure_low_tx(SimpleNamespace(cli=busy))
def test_packet_window_rate_excludes_checkpoint_time_but_keeps_loss_timeouts(self):
packets = [{"received": True, "raw_bytes": 64, "elapsed_seconds": 0.1, "direction": "a_to_b"},
+1
View File
@@ -46,6 +46,7 @@ protected:
CustomSX1276* _radio;
int8_t cached = 0;
virtual int16_t applyCachedTxPower(int8_t dbm) = 0;
virtual bool checkBeaconOutputPower(float, int8_t) const = 0;
public:
CustomSX1276Wrapper(CustomSX1276& radio, mesh::MainBoard&) : _radio(&radio) {}
bool setTxPower(int8_t dbm) {
+1
View File
@@ -130,6 +130,7 @@ struct RoleCLI {
Board* getBoard() { return &board; }
ObserverPrefs* getObserverPrefs() { return &prefs; }
bool hasActiveUserGpioTimer() const { return false; }
bool hasPendingTxPower() const { return false; }
};
struct AbstractBridge {
bool running = false, stopping = false, allow_flash = true;
+7 -1
View File
@@ -82,16 +82,21 @@ int main(){
for(int stage=1;stage<=10;++stage){
failure=stage;radio.init();assert(!radio.isEnabled());
assert(!sdk_active&&wake_refs==0);
assert(!radio.isInRecvMode()&&!radio.isSendComplete());
assert(!radio.isInRecvMode()&&!radio.isSendComplete()&&!radio.isTxPowerApplied(20));
radio.end();assert(wake_refs==0); // failed startup and repeated off are safe
}
failure=0;WiFi.current=WIFI_OFF;radio.init();
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.isTxPowerApplied(13)&&!radio.isTxPowerApplied(2));
assert(radio.isTxPowerValid(2)&&radio.isTxPowerValid(21));
assert(!radio.isTxPowerValid(1)&&!radio.isTxPowerValid(22));
assert(radio.trySetTxPower(22)==mesh::RadioParamApplyResult::FAILED&&power==52);
assert(radio.trySetTxPower(14)==mesh::RadioParamApplyResult::APPLIED&&power==56);
failure=6;
assert(radio.trySetTxPower(15)==mesh::RadioParamApplyResult::FAILED&&power==56);
assert(radio.isTxPowerApplied(14)&&!radio.isTxPowerApplied(15));
failure=0;
assert(radio.trySetTxPower(13)==mesh::RadioParamApplyResult::APPLIED&&power==52);
uint8_t raw[255]={0x15,1,2,3},out[255];
@@ -100,6 +105,7 @@ int main(){
assert(radio.startSendRaw(raw,sizeof(raw))&&tx_second_pending);
OnDataSent(nullptr,ESP_NOW_SEND_SUCCESS);
radio.end();const int sent=send_calls;
assert(!radio.isTxPowerApplied(13));
assert(!radio.isSendComplete()&&!radio.startSendRaw(raw,4)&&send_calls==sent);
OnDataSent(nullptr,ESP_NOW_SEND_SUCCESS); // callback arriving while disabled
deliver(raw,4);assert(radio.recvRaw(out,sizeof(out))==0);
+1
View File
@@ -87,6 +87,7 @@ struct Fleet {
};
struct CLI {
Fleet fleet;
void servicePendingTxPower() {}
Profiles profiles;
struct NoMorseReply {
void complete(const void*) {}
+54 -4
View File
@@ -1,5 +1,6 @@
"""Compile real Companion command dispatch with local and LoRa callers."""
from pathlib import Path
from itertools import product
import os
import subprocess
import tempfile
@@ -34,6 +35,7 @@ PREAMBLE = r'''
#define TXT_TYPE_CLI_COMMAND 1
uint32_t millis() { return 0; }
namespace mesh {
enum class RadioParamApplyResult { APPLIED, BUSY, FAILED };
// The real Morse parser, capture paging and ownership are executed by their
// dedicated tests. This fixture isolates unrelated local/remote CLI access.
template <typename Radio>
@@ -117,7 +119,19 @@ struct Board {
bool handleCommand(const char*,uint32_t,char*){return false;}
void powerOff(){++power_offs;}
} board;
struct Radio {bool setTxPower(int){return true;}} radio_driver;
struct Radio {
mesh::RadioParamApplyResult accepted=mesh::RadioParamApplyResult::APPLIED;
int8_t power=10;
bool power_verified=true;
bool isTxPowerValid(int8_t) const {return true;}
bool isTxPowerApplied(int8_t requested) const {return power_verified && power==requested;}
bool setTxPower(int requested){power=requested;power_verified=true;return true;}
mesh::RadioParamApplyResult trySetTxPower(int requested){
if(accepted==mesh::RadioParamApplyResult::APPLIED){power=requested;power_verified=true;}
if(accepted==mesh::RadioParamApplyResult::FAILED)power_verified=false;
return accepted;
}
} radio_driver;
struct AdvertDataParser {static bool isValidName(const char*){return true;}};
struct StrHelper {static void strncpy(char* out,const char* in,size_t len){::strncpy(out,in,len);}};
void recalcRxPowerSavingFromLevel(uint8_t,uint8_t,float,uint8_t,uint32_t*,uint32_t*){}
@@ -145,7 +159,8 @@ struct MyMesh : CompanionFleetFixture {
int value=0; int* _ms=&value; int* _radio=&value; int* _mgr=&value;
uint16_t _err_flags=0;
bool _radio_available=false, save_ok=true;
bool saved_radio_apply_pending=false;
bool saved_radio_apply_pending=false, command_radio_apply_pending=false, tx_power_apply_pending=false;
uint32_t tx_power_apply_retry_at=0;
unsigned radio_apply_retry_at=0;
uint8_t radio_apply_failures=0;
int resets=0, saves=0;
@@ -291,6 +306,40 @@ int main() {
assert(node.handleCommand(command,0,reply));
assert(!strcmp(reply,"OK") && board.power_offs==previous+1);
}
// Both directly attached and on-air CLI replies describe accepted durable
// intent. Request prefixes and the fixed 160-byte reply boundary survive.
node.save_ok=true;node._radio_available=true;
radio_driver.accepted=mesh::RadioParamApplyResult::BUSY;
for(uint32_t stamp:{0u,100u}) {
char bounded[162];memset(bounded,0x5A,sizeof(bounded));
assert(node.handleCommand("A7|set tx 17",stamp,bounded+1));
assert(!strcmp(bounded+1,"A7|OK - saved; pending"));
assert(bounded[0]==0x5A && bounded[161]==0x5A);
assert(node._prefs.tx_power_dbm==17 && node.tx_power_apply_pending);
#if defined(MESH_HIL_TX_FREQ_MHZ)
assert(node.handleCommand("A7|get tx.status",stamp,bounded+1));
assert(!strcmp(bounded+1,"A7|> desired=17,apply=pending"));
assert(bounded[0]==0x5A && bounded[161]==0x5A);
#else
assert(!node.handleCommand("A7|get tx.status",stamp,bounded+1));
#endif
node.onCLICommandRecv(ContactInfo{},nullptr,stamp,"B8|set tx 12",reply);
assert(!strcmp(reply,"B8|OK - saved; pending") && node._prefs.tx_power_dbm==12);
}
radio_driver.accepted=mesh::RadioParamApplyResult::APPLIED;
assert(node.handleCommand("set tx 15",0,reply) && !strcmp(reply,"OK"));
assert(!node.tx_power_apply_pending);
#if defined(MESH_HIL_TX_FREQ_MHZ)
assert(node.handleCommand("get tx.status",0,reply) && !strcmp(reply,"> desired=15,apply=applied"));
radio_driver.power_verified=false;
assert(node.handleCommand("get tx.status",0,reply) && !strcmp(reply,"> desired=15,apply=pending"));
radio_driver.power_verified=true;node._radio_available=false;
assert(node.handleCommand("get tx.status",0,reply) && !strcmp(reply,"> desired=15,apply=pending"));
node._radio_available=true;
assert(node.handleCommand("get tx.status",0,reply) && !strcmp(reply,"> desired=15,apply=applied"));
#else
assert(!node.handleCommand("get tx.status",0,reply));
#endif
// Exercise the real timezone dispatch and typed transaction helper, not a
// fixture-only setter: a failed save must preserve the exact fraction.
node.save_ok=true;
@@ -429,14 +478,15 @@ int main() {
header = (ROOT / "examples/companion_radio/MyMesh.h").read_text()
preamble = PREAMBLE.replace("@SAVE_PREFERENCE@", extract_braced(
header, "template <typename T> bool savePreference("))
for export, webconfig in ((0, False), (1, False), (0, True), (1, True)):
with self.subTest(export=export, webconfig=webconfig), tempfile.TemporaryDirectory() as temp:
for export, webconfig, hil in product((0, 1), (False, True), (False, True)):
with self.subTest(export=export, webconfig=webconfig, hil=hil), tempfile.TemporaryDirectory() as temp:
binary = Path(temp) / "test"
result = subprocess.run([
"c++", "-std=c++17", "-x", "c++", "-", "-I"+str(ROOT / "src"),
"-I"+str(ROOT / "test/fixtures"),
"-DENABLE_PRIVATE_KEY_EXPORT="+str(export),
"-DMESH_ENABLE_LOST_REPLY=1",
*(["-DMESH_HIL_TX_FREQ_MHZ=907.125f"] if hil else []),
*(["-DWITH_WEBCONFIG=1"] if webconfig else []),
*SANITIZER_FLAGS, "-o", str(binary),
], input=preamble+implementation+SCENARIOS, text=True, capture_output=True)
+4 -1
View File
@@ -43,7 +43,10 @@ struct MyMesh {
MQTTBridge* bridge = nullptr;
const AbstractBridge* activeBridge() const { return bridge; }
struct { bool pending = false; } deferred_cli_command;
struct { bool hasActiveUserGpioTimer() const { return false; } } _cli;
struct {
bool hasActiveUserGpioTimer() const { return false; }
bool hasPendingTxPower() const { return false; }
} _cli;
struct { int getNumClients() const { return 0; } } acl;
struct { bool battery_alert_enabled = false; } _prefs;
Radio radio_driver;
+4 -1
View File
@@ -172,9 +172,10 @@ struct CommonCLI {
Callbacks callbacks; Callbacks* _callbacks=&callbacks;
UserGpio _user_gpio;
struct Profiles { unsigned loops=0; void loop() { ++loops; } } _radio_profiles;
unsigned management=0;
unsigned management=0, tx_services=0;
void loopBatteryForwarding(bool = false) {}
void loopManagement() { ++management; }
void servicePendingTxPower() { ++tx_services; }
void loop();
};
@LOOP@
@@ -185,9 +186,11 @@ int main() {
assert(cli._user_gpio.loops==1 && cli.callbacks.completed==1);
const unsigned ordinary=mode==1 || mode==2 ? 0:1;
assert(cli._radio_profiles.loops==ordinary && cli.management==ordinary);
assert(cli.tx_services==ordinary);
radio_driver.tx=radio_driver.rx=false;
cli.loop();
assert(cli._radio_profiles.loops==ordinary+1 && cli.management==ordinary+1);
assert(cli.tx_services==ordinary+1);
assert(cli._user_gpio.loops==2 && cli.callbacks.completed==1);
}
}
+6 -1
View File
@@ -181,7 +181,11 @@ struct MyMesh {
uint32_t queue_delay = 0, retry_delay = 0, calibration_delay = 0;
struct { bool busy_value = false; bool busy() const { return busy_value; } } _local_cli_output;
struct { bool pending = false; } deferred_cli_command;
struct { bool gpio = false; bool hasActiveUserGpioTimer() const { return gpio; } } _cli;
struct {
bool gpio = false, tx_pending = false;
bool hasActiveUserGpioTimer() const { return gpio; }
bool hasPendingTxPower() const { return tx_pending; }
} _cli;
struct { bool watchdog = false, calibration = false;
bool isWatchdogObserving() const { return watchdog; }
bool isCalibratingNoiseFloor() const { return calibration; }
@@ -240,6 +244,7 @@ int main() {
node.dual = node.profile_idle = false;
check(node._local_cli_output.busy_value, Reason::SerialIO);
check(node.deferred_cli_command.pending, Reason::Scheduled);
check(node._cli.tx_pending, Reason::Scheduled);
check(node.uart.running, Reason::Bridge);
node.active_rs232_bridge_uart = 1;
assert(node.pendingWorkReason() == mask(Reason::BridgeCleanup));
+2
View File
@@ -44,6 +44,7 @@ struct Profiles {
};
struct CLI {
Profiles profiles;
void servicePendingTxPower() {}
struct NoMorseReply {
void complete(const void*) {}
void fail(const void*) {}
@@ -62,6 +63,7 @@ struct Manager {
};
struct MyMesh : mesh::Mesh {
CLI _cli;
void servicePendingTxPower() {}
Manager manager; Manager* _mgr = &manager;
mesh::TempRadioReplyBarrier temp_radio_reply_barrier;
bool radio_reply_secondary = false, primary_radio_mutation_starts_temp = false;
+114
View File
@@ -31,12 +31,16 @@ struct Chip {
return power_result;
}
int transmitDirect() { ++keys; return direct_result; }
int checkOutputPower(int8_t requested, int8_t*) const {
return requested >= -9 && requested <= 22 ? 0 : -1;
}
};
struct RadioLibWrapper {
Chip chip;
Chip* _radio = &chip;
uint8_t state = STATE_RX;
int8_t _cur_dbm = 17;
float _cur_freq = 915;
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;
@@ -55,10 +59,37 @@ struct RadioLibWrapper {
uint8_t beginReconfigure();
void endReconfigure(bool);
bool setTxPower(int8_t);
bool isTxPowerValid(int8_t) const;
bool isTxPowerApplied(int8_t) const;
virtual bool checkBeaconOutputPower(float, int8_t) const;
Result trySetTxPower(int8_t);
};
@METHODS@
int main() {
// Status needs proof of current normal hardware power, without SPI reads.
{
RadioLibWrapper radio;
assert(radio.isTxPowerApplied(17) && !radio.isTxPowerApplied(2));
radio.pending = radio.busy = radio.packet = true;
assert(radio.isTxPowerApplied(17));
radio._dbm_applied = false;
assert(!radio.isTxPowerApplied(17));
radio._dbm_applied = true; radio._dbm_valid = false;
assert(!radio.isTxPowerApplied(17));
radio._dbm_valid = true; radio._profile_repair_required = true;
assert(!radio.isTxPowerApplied(17));
radio._profile_repair_required = false; radio._cw_active = true;
assert(!radio.isTxPowerApplied(17));
radio._cw_active = false; radio._beacon_owned = true;
assert(!radio.isTxPowerApplied(17));
assert(!radio.chip.writes && !radio.chip.standbys && !radio.irq_reads
&& !radio.busy_reads && !radio.packet_reads && !radio.claims);
}
{
RadioLibWrapper radio; radio.pending = radio.busy = radio.packet = true;
assert(!radio.isTxPowerValid(23) && radio.trySetTxPower(23) == Result::FAILED);
assert(!radio.chip.writes && !radio.chip.standbys && !radio.irq_reads && !radio.claims);
}
// A temporary receive/TX condition does not become a hardware rejection.
for (unsigned reason = 0; reason < 4; ++reason) {
RadioLibWrapper radio;
@@ -76,6 +107,7 @@ int main() {
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);
assert(radio.isTxPowerApplied(10) && !radio.isTxPowerApplied(17));
}
// App saves can resend the current power while an unread packet owns RX.
for (unsigned reason = 0; reason < 4; ++reason) {
@@ -132,11 +164,13 @@ int main() {
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);
assert(!radio.isTxPowerApplied(17) && !radio.isTxPowerApplied(10));
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.isTxPowerApplied(17));
assert(radio.trySetTxPower(17) == Result::APPLIED && radio.chip.writes == 2);
}
// Real command and standby failures remain failures, with the requested cache intact.
@@ -157,12 +191,92 @@ int main() {
class RadioTxPowerTransactionTests(unittest.TestCase):
def test_pure_family_and_board_limits_preserve_band_clipping(self):
source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text()
validator = method(source, "bool RadioLibWrapper::isTxPowerValid(")
checker = method(source, "bool RadioLibWrapper::checkBeaconOutputPower(")
dac = method((ROOT / "src/helpers/radiolib/DacPaSX1276Wrapper.h").read_text(),
"bool checkBeaconOutputPower(")
code = r'''
#include <cassert>
#include <cstdint>
#include <helpers/radiolib/LR2021Band.h>
#define RADIOLIB_ERR_NONE 0
struct Chip {
mutable unsigned checks = 0;
int checkOutputPower(int8_t power, int8_t*, bool rfo = false) const {
++checks;
#if MESH_RADIO_PROFILE_CHIP == 1276 || MESH_RADIO_PROFILE_CHIP == 1272
return rfo ? (power >= -4 && power <= 15 ? 0 : -1)
: ((power >= 2 && power <= 17) || power == 20 ? 0 : -1);
#else
return power >= -9 && power <= 22 ? 0 : -1;
#endif
}
};
using SX1276 = Chip;
using SX1272 = Chip;
struct RadioLibWrapper {
Chip chip;
Chip* _radio = &chip;
float _cur_freq = 2440;
virtual bool checkBeaconOutputPower(float, int8_t) const;
bool isTxPowerValid(int8_t) const;
};
@CHECKER@
@VALIDATOR@
struct DacWrapper : RadioLibWrapper {
int8_t _min_dbm = 17, _max_dbm = 30;
@DAC@
};
int main() {
RadioLibWrapper radio;
DacWrapper amplified;
for (int power = -128; power <= 127; ++power) {
bool board_ok = true;
#ifdef MIN_LORA_TX_POWER
board_ok = board_ok && power >= MIN_LORA_TX_POWER;
#endif
#ifdef MAX_LORA_TX_POWER
board_ok = board_ok && power <= MAX_LORA_TX_POWER;
#endif
#if MESH_RADIO_PROFILE_CHIP == 1121 || MESH_RADIO_PROFILE_CHIP == 2021
// Ordinary desired power remains valid on a lower-power temporary band.
const bool chip_ok = true;
#elif MESH_RADIO_PROFILE_CHIP == 1276 || MESH_RADIO_PROFILE_CHIP == 1272
const bool chip_ok = (power >= -4 && power <= 17) || power == 20;
#else
const bool chip_ok = power >= -9 && power <= 22;
#endif
assert(radio.isTxPowerValid(int8_t(power)) == (board_ok && chip_ok));
assert(amplified.isTxPowerValid(int8_t(power)) ==
#if MESH_RADIO_PROFILE_CHIP == 1121 || MESH_RADIO_PROFILE_CHIP == 2021
board_ok);
#else
(board_ok && power >= 17 && power <= 30));
#endif
}
#if MESH_RADIO_PROFILE_CHIP == 1121 || MESH_RADIO_PROFILE_CHIP == 2021
assert(radio.chip.checks == 0);
#endif
assert(amplified.chip.checks == 0);
}
'''
code = code.replace("@CHECKER@", checker).replace("@VALIDATOR@", validator).replace("@DAC@", dac)
for family in (1262, 1268, 68, 1276, 1272, 1110, 1121, 2021):
for limits in ((), ("-DMIN_LORA_TX_POWER=17", "-DMAX_LORA_TX_POWER=30")):
with self.subTest(family=family, limits=limits):
compile_run(code, defines=(f"-DMESH_RADIO_PROFILE_CHIP={family}", *limits))
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(",
"bool RadioLibWrapper::isTxPowerValid(",
"bool RadioLibWrapper::isTxPowerApplied(",
"bool RadioLibWrapper::checkBeaconOutputPower(",
"mesh::RadioParamApplyResult RadioLibWrapper::trySetTxPower("))
for carrier_tools in (0, 1):
with self.subTest(carrier_tools=carrier_tools):
+1
View File
@@ -75,6 +75,7 @@ struct Driver {
uint8_t beginReconfigure();
void endReconfigure(bool);
bool setTxPower(int8_t);
bool isTxPowerValid(int8_t) const { return true; }
mesh::RadioParamApplyResult trySetTxPower(int8_t);
} radio_driver;
@WRAPPER_METHODS@
+4 -1
View File
@@ -144,7 +144,10 @@ struct MyMesh : Callbacks {
#endif
}
struct { bool pending = false; } deferred_cli_command;
struct { bool hasActiveUserGpioTimer() const { return false; } } _cli;
struct {
bool hasActiveUserGpioTimer() const { return false; }
bool hasPendingTxPower() const { return false; }
} _cli;
struct {
bool isWatchdogObserving() const { return false; }
bool isCalibratingNoiseFloor() const { return false; }
+7 -3
View File
@@ -31,7 +31,11 @@ struct Radio {bool watchdog=false,calibration=false;
bool isWatchdogObserving()const{return watchdog;}
bool isCalibratingNoiseFloor()const{return calibration;}
} radio_driver;
struct Cli {bool gpio=false;bool hasActiveUserGpioTimer()const{return gpio;}};
struct Cli {
bool gpio=false,tx_pending=false;
bool hasActiveUserGpioTimer()const{return gpio;}
bool hasPendingTxPower()const{return tx_pending;}
};
struct Manager {int queued=0;int getOutboundTotal()const{return queued;}};
struct Clock {uint32_t now=100;uint32_t getCurrentTime()const{return now;}};
class SensorMesh {
@@ -94,7 +98,7 @@ int main(){
s.tracker_storage_fault=false;s.tracker_next_check=1900;
// All standard radio/console/update owners veto RF sleep.
for(bool* busy:{&s.board.usb,&s.board.ota,&s.board.test,&logging,&s.temp,&s.ota,
&s.dual,&s._cli.gpio,&s.outbound}){
&s.dual,&s._cli.gpio,&s._cli.tx_pending,&s.outbound}){
*busy=true;assert(s.trackerNeedsRadio());*busy=false;
}
s.manager.queued=1;assert(s.trackerNeedsRadio());s.manager.queued=0;
@@ -103,7 +107,7 @@ int main(){
s.saved_radio_apply_pending=true;assert(s.trackerNeedsRadio());
s.saved_radio_apply_pending=false;
#endif
for(bool* due:{&s.dual,&s.ota,&s._cli.gpio,&radio_driver.watchdog,
for(bool* due:{&s.dual,&s.ota,&s._cli.gpio,&s._cli.tx_pending,&radio_driver.watchdog,
&radio_driver.calibration,&s.queue_due,&s.retry_due}){
*due=true;assert(s.hasPendingWork()&&s.getPowerSaveSleepSeconds(30)==0);*due=false;
}
+11 -1
View File
@@ -45,11 +45,15 @@ struct SerialInterface {
bool isPassthroughMode() const{return passthrough;}
};
struct RoleMesh {
struct {
bool pending=false;
bool hasPendingTxPower() const{return pending;}
} _cli;
uint32_t dirty_contacts_expiry=0, _ota_update_at=0, set_radio_at=0,
_scheduled_reboot_at=0;
bool outbound=false, temporary=false, ota_apply=false,
saved_radio_apply_pending=false, temp_radio_handoff_pending=false,
command_radio_apply_pending=false,
command_radio_apply_pending=false, tx_power_apply_pending=false,
_iter_started=false, _terminal_trace_pending=false, terminal=true;
mesh::CompanionDelayedReplies _delayed_replies;
void* sign_data=nullptr;
@@ -154,6 +158,10 @@ class UsbLoggingWatchdogRoleTests(unittest.TestCase):
'bool canRecoverUsbLogging() const')
callback = method((directory / 'main.cpp').read_text(),
'static bool usbLoggingRecoverySafe(void*)')
specific += '''
the_mesh._cli.pending=true;assert(!usbLoggingRecoverySafe(nullptr));
the_mesh._cli.pending=false;
'''
command_checks = '''
reset();command[0]='x';assert(!usbLoggingRecoverySafe(nullptr));
mesh::status.reader_connected=false;assert(usbLoggingRecoverySafe(nullptr));
@@ -193,6 +201,8 @@ class UsbLoggingWatchdogRoleTests(unittest.TestCase):
role_checks = r'''
the_mesh.command_radio_apply_pending=true;assert(!usbLoggingRecoverySafe(nullptr));
the_mesh.command_radio_apply_pending=false;
the_mesh.tx_power_apply_pending=true;assert(!usbLoggingRecoverySafe(nullptr));
the_mesh.tx_power_apply_pending=false;
for(auto slot : {&the_mesh._delayed_replies.request, &the_mesh._delayed_replies.trace}){
slot->phase=mesh::CompanionDelayedReplies::AwaitRadio;
assert(!usbLoggingRecoverySafe(nullptr));
+39
View File
@@ -77,6 +77,45 @@ def embedded_page():
class WebConfigUiRuntimeTest(unittest.TestCase):
def test_tx_save_receipt_confirms_durable_save_and_automatic_application(self):
for reply, chip, toast in (("OK - saved; pending", "Saved", "Saved; radio changes apply automatically"),
("OK", "OK", "Saved [OK]")):
with self.subTest(reply=reply):
status, config = self.setup_values()
status.update(mode="lan", password_supported=False)
prelude = r'''<script>
(function(){
var status=%s,config=%s,reply=%s,fieldText="",toastText="";
function response(value){return Promise.resolve({ok:true,status:200,json:function(){return Promise.resolve(value)}})}
window.fetch=function(path){
if(path==="/api/status")return response(status);
if(path.indexOf("/api/config/result?")===0)return response({state:"done",reqid:"tx-receipt",all_ok:true,
results:[{key:"tx",reply:reply}]});
return response(config);
};
window.addEventListener("load",function(){
setTimeout(function(){
var input=document.createElement("input");input.setAttribute("data-k","tx");
document.getElementById("v-app").appendChild(input);
chipFor=function(el,ok,text){fieldText=text};
toast=function(text){toastText=text};
loadConfigSoft=function(){};
st.dirty.tx="17";
pollResult(null,0,0,true,"tx-receipt",{tx:"17"},{});
},150);
setTimeout(function(){
document.body.setAttribute("data-test-tx-chip",fieldText);
document.body.setAttribute("data-test-tx-toast",toastText);
document.body.setAttribute("data-test-tx-clean",!("tx" in st.dirty));
},600);
});
})();
</script>''' % (json.dumps(status), json.dumps(config), json.dumps(reply))
dom = self.run_page(prelude, virtual_time=900)
self.assertIn('data-test-tx-chip="%s"' % chip, dom)
self.assertIn('data-test-tx-toast="%s"' % toast, dom)
self.assertIn('data-test-tx-clean="true"', dom)
def test_saved_network_recovery_requires_login_before_setup_wizard(self):
status, config = self.setup_values()
status.update(auth=False, mode="setup", needs_setup=False,