Apply saved TX power automatically across firmware roles
This commit is contained in:
1 parent
31e5d54074
commit
65928aae95
51 files changed
+3838
-2728
No files matched your search
Vendored
+16
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
Vendored
+3
-1
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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(",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 [])
|
||||
|
||||
@@ -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()
|
||||
@@ -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)'))
|
||||
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -87,6 +87,7 @@ struct Fleet {
|
||||
};
|
||||
struct CLI {
|
||||
Fleet fleet;
|
||||
void servicePendingTxPower() {}
|
||||
Profiles profiles;
|
||||
struct NoMorseReply {
|
||||
void complete(const void*) {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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@
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user