Add two-profile LoRa operation and radio CLI regression coverage

Implement radio2, tempradio2, radioat2 and tempradioat2 across Mesh roles,
with RX-only/RX+TX operation, optional preambles and persistent cross-TX
policy. Keep temporary OTA traffic on its profile by default and maintain
independent retry ownership and backoff for each profile.

Use slow-first receive scanning with 4.8-symbol visits and automatic
preambles rounded up in steps of eight. Preserve pending RX, restore power
saving on exit, and discard work bound to changed or expired profiles.

Restore the infrastructure path.hash.mode setter and report unsupported
extra.sf settings consistently. Add CLI, scheduling, scan and retry tests,
setup documentation, and the V4/XIAO hardware validation results.

Validation: 1,422 native tests, eight KISS tests, 63 final focused profile
tests, 18 checks from the staged source, sanitizer-enabled OTA transfers,
and builds for V4 Mesh roles, Full XIAO Companion and nRF52 T1000-E.
Hardware checks cover reception, cross-TX policy, expiry, reboot and OTA
discovery while receiving main-channel adverts.
This commit is contained in:
mikecarper committed 2026-09-13 08:25:00 -07:00
1 parent a25adbde64
commit 088c88507a
46 files changed
+5187 -143

No files matched your search

+156
View File
@@ -0,0 +1,156 @@
#include <cassert>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <initializer_list>
#include <helpers/CLICommandUtils.h>
#include <helpers/RadioProfileCommandUtils.h>
// Persistence has its own production-parser tests with an in-memory FS.
// This fixture isolates the actual CommonCLI setting branches and callbacks.
namespace mesh {
struct RadioProfileCLI {
static bool parseSuffix(const char* input, unsigned fields, char* legacy,
size_t size, uint16_t& preamble) {
return cli::parseRadioPreambleSuffix(input, fields, legacy, size, preamble);
}
bool savePrimaryPreamble(uint16_t) { return true; }
void appendPreamble(char*, size_t) {}
void appendSavedPreamble(char*, size_t, uint8_t, float) {}
bool acceptsPrimary(float, float, uint8_t, uint8_t, uint16_t) const { return true; }
};
}
#define MIN_LORA_TX_POWER -9
#define MAX_LORA_TX_POWER 22
#include <helpers/radiolib/RadioPowerLimits.h>
#include <helpers/radiolib/LR2021SideDetectorConfig.h>
struct StrHelper {
static const char* ftoa(float value) {
static char buffer[24]; snprintf(buffer, sizeof(buffer), "%.3f", double(value)); return buffer;
}
static const char* ftoa3(float value) { return ftoa(value); }
};
struct Prefs {
float freq=909.5f, bw=62.5f, airtime_factor=1, rx_delay_base=0;
float tx_delay_factor=0, direct_tx_delay_factor=0;
uint8_t sf=7, cr=5, interference_threshold=0, agc_reset_interval=0;
uint8_t path_hash_mode=0, multi_acks=0, rx_ps_level=0, rx_ps_preamble=0;
int8_t tx_power_dbm=10;
uint32_t rx_ps_rx_us=1000, rx_ps_sleep_us=1000;
bool cad_enabled=false, rx_boosted_gain=false;
uint8_t extra_sf[4]={};
};
struct Callbacks {
unsigned saves=0, tx_calls=0, gain_calls=0;
bool accept=true;
int8_t power=0;
bool gain=false;
void savePrefs() { ++saves; }
bool setTxPower(int8_t value) { ++tx_calls; if (!accept) return false; power=value; return true; }
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; }
void recalcRxPowerSavingFromLevel(uint8_t,uint8_t,float,uint8_t,uint32_t*,uint32_t*) {}
void appendRxPowerSavingAdjustmentNote(char*,Prefs*,uint8_t,float) {}
@KEY_EQUALS@
class CLI {
public:
Prefs prefs;
Callbacks callbacks;
Board board;
mesh::RadioProfileCLI _radio_profiles;
Prefs* _prefs=&prefs;
Callbacks* _callbacks=&callbacks;
Board* _board=&board;
void savePrefs() { callbacks.savePrefs(); }
void handleSetCmd(uint32_t, char* command, char* reply) {
const char* config=command+4;
@SET@
else strcpy(reply,"unknown config");
}
void handleGetCmd(uint32_t, char* command, char* reply) {
const char* config=command+4;
@GET@
else strcpy(reply,"unknown config");
}
void command(uint32_t sender_timestamp, char* command, char* reply) {
@DISPATCH@
else strcpy(reply,"unknown command");
}
void call(uint32_t sender, const char* text, char* reply) {
char command[160]; snprintf(command,sizeof(command),"%s",text);
this->command(sender,command,reply);
}
};
struct Case { const char *key, *value; double expected; };
int main() {
// Local USB and authenticated on-air calls use the same infrastructure CLI.
for (uint32_t sender : {0u, 123456u}) {
CLI cli; char text[160], reply[160];
for (const Case& c : {
Case{"freq","915.5",915.5}, {"af","2.5",2.5}, {"dutycycle","25",25},
{"int.thresh","128",128}, {"tx","14",14}, {"rxdelay","2.5",2.5},
{"agc.reset.interval","19",16}, {"path.hash.mode","2",2},
{"multi.acks","1",1}, {"txdelay","1.5",1.5}, {"direct.txdelay","0.5",0.5}}) {
unsigned saves=cli.callbacks.saves;
snprintf(text,sizeof(text),"set %s %s",c.key,c.value);
cli.call(sender,text,reply);
if (strncmp(reply,"OK",2)) { fprintf(stderr,"%s -> %s\n",text,reply); return 1; }
assert(cli.callbacks.saves==saves+1);
snprintf(text,sizeof(text),"get %s",c.key);
cli.call(sender,text,reply);
double actual=0;
assert(sscanf(reply,"> %lf",&actual)==1 && fabs(actual-c.expected)<0.001);
assert(cli.callbacks.saves==saves+1); // reading must not persist
}
for (const char* key : {"cad","radio.rxgain"}) {
for (const char* state : {"on","off"}) {
unsigned saves=cli.callbacks.saves;
snprintf(text,sizeof(text),"set %s %s",key,state); cli.call(sender,text,reply);
assert(!strcmp(reply,"OK") && cli.callbacks.saves==saves+1);
snprintf(text,sizeof(text),"get %s",key); cli.call(sender,text,reply);
assert(!strncmp(reply+2,state,strlen(state)));
}
}
unsigned saves=cli.callbacks.saves;
cli.call(sender,"set radio 916,125,8,6",reply);
assert(!strncmp(reply,"OK",2) && cli.callbacks.saves==saves+1);
cli.call(sender,"get radio",reply);
float freq=0,bw=0; int sf=0,cr=0;
assert(sscanf(reply,"> %f,%f,%d,%d",&freq,&bw,&sf,&cr)==4);
assert(freq==916 && bw==125 && sf==8 && cr==6);
saves=cli.callbacks.saves;
cli.call(sender,"set extra.sf 9,10",reply);
#if defined(USE_LR2021)
assert(!strncmp(reply,"OK",2) && cli.callbacks.saves==saves+1);
assert(cli.prefs.extra_sf[0]==9 && cli.prefs.extra_sf[1]==10 && cli.prefs.extra_sf[2]==0);
cli.call(sender,"get extra.sf",reply); assert(!strcmp(reply,"9,10"));
saves=cli.callbacks.saves;
cli.call(sender,"set extra.sf 8",reply);
assert(strstr(reply,"Invalid") && cli.callbacks.saves==saves && cli.prefs.extra_sf[0]==9);
cli.call(sender,"set extra.sf none",reply);
assert(!strncmp(reply,"OK",2) && cli.prefs.extra_sf[0]==0);
cli.call(sender,"get extra.sf",reply); assert(!strcmp(reply,"No extra SF configured"));
#else
assert(strstr(reply,"requires an LR2021") && cli.callbacks.saves==saves);
cli.call(sender,"get extra.sf",reply);
assert(strstr(reply,"requires an LR2021") && cli.prefs.extra_sf[0]==0);
#endif
// A failed hardware apply must not persist or advertise a changed setting.
cli.callbacks.accept=false;
const int8_t old_power=cli.prefs.tx_power_dbm;
const bool old_gain=cli.prefs.rx_boosted_gain;
saves=cli.callbacks.saves;
cli.call(sender,"set tx 16",reply);
assert(!strncmp(reply,"Error",5) && cli.prefs.tx_power_dbm==old_power);
cli.call(sender,"set radio.rxgain on",reply);
assert(!strncmp(reply,"Error",5) && cli.prefs.rx_boosted_gain==old_gain);
assert(cli.callbacks.saves==saves);
cli.call(sender,"set unknown.setting 1",reply);
assert(!strcmp(reply,"unknown config") && cli.callbacks.saves==saves);
}
}
+148
View File
@@ -0,0 +1,148 @@
#include <Arduino.h>
#include <helpers/RadioProfileCLI.h>
#include <cassert>
#include <cstdio>
#include <cstring>
struct Clock : mesh::RTCClock {
uint32_t epoch = 1700000000;
uint32_t getCurrentTime() override { return epoch; }
void setCurrentTime(uint32_t time) override { epoch = time; }
};
struct Radio : mesh::Radio {
mesh::RadioProfiles p;
Radio() { p.primary.freq=909.5; p.primary.bw=62.5; p.primary.sf=7; p.primary.cr=5; }
mesh::RadioProfiles* profiles() override { return &p; }
const mesh::RadioProfiles* profiles() const override { return &p; }
bool validateProfile(const mesh::RadioProfileParams& v) const override { return mesh::RadioProfiles::valid(v) && v.bw<=500; }
uint16_t profilePreamble(uint8_t profile) const override { return p.preamble(profile, 32); }
int recvRaw(uint8_t*,int) override { return 0; }
uint32_t getEstAirtimeFor(int) override { return 10; }
float packetScore(float,int) override { return 0; }
bool startSendRaw(const uint8_t*,int) override { return true; }
bool isSendComplete() override { return true; }
void onSendFinished() override {}
bool isInRecvMode() const override { return true; }
};
struct Fixture {
MemoryFS fs;
Clock clock;
Radio radio;
mesh::RadioProfileCLI cli;
char reply[160];
Fixture() { g_mock_millis=0; cli.begin(&fs,&radio,&clock); }
const char* cmd(const char* text, bool ok=true) {
memset(reply,0x55,sizeof(reply));
assert(cli.handle(text,reply,sizeof(reply)));
assert(memchr(reply,0,sizeof(reply)));
if (strncmp(text,"get ",4) && strncmp(text,"del ",4)) {
if ((!strncmp(reply,"OK",2))!=ok) { fprintf(stderr,"%s -> %s\n",text,reply); assert(false); }
}
return reply;
}
void advance(uint32_t ms, bool epoch=true) { g_mock_millis+=ms; if(epoch)clock.epoch+=ms/1000; cli.loop(); }
};
int main() {
{
Fixture f;
assert(!strcmp(f.cmd("get radio2"),"> off"));
assert(!strcmp(f.cmd("get radio2.cross"),"> auto"));
f.cmd("set radio2 910.5,500,7,5,rxtx");
assert(!f.radio.p.enabled()); f.advance(2000); assert(f.radio.p.enabled());
assert(strstr(f.cmd("get radio2"),"rxtx,120 (auto)"));
assert(strstr(f.cmd("get radio2.scan"),"slow=radio; listen_us=9831,6937"));
f.cmd("set tempradio2 911.5,500,8,5,rx,2,64");
assert(!f.radio.p.secondary_temporary); f.advance(2000);
assert(f.radio.p.secondary_temporary && f.radio.p.secondary.params.freq==911.5f);
assert(!f.radio.p.canCross());
assert(strstr(f.cmd("get tempradio2"),"0d0h2m"));
f.cmd("set radio2.cross on"); assert(f.radio.p.canCross());
f.cmd("set radio2.cross off"); assert(!f.radio.p.canCross());
f.cmd("set tempradio2 off"); f.advance(2000);
assert(!f.radio.p.secondary_temporary && f.radio.p.secondary.params.freq==910.5f);
f.cmd("set radio2 off"); f.advance(2000); assert(!f.radio.p.enabled());
f.cmd("get radio2.status");
}
{
Fixture f;
const char* invalid[] = {"910.5,500,7,5", "NaN,500,7,5,rx", "910.5,0,7,5,rx",
"910.5,500,256,5,rx", "910.5,500,7,256,rx", "910.5,500,7,5,invalid",
"910.5,500,7,5,rx,7", "910.5,500,7,5,rx,65535", "910.5,500,7,5,rx,32,junk",
"910.5,500,7,5,rx,32,", "910.5,500,7,5,rx,32junk"};
char text[160];
for (auto args : invalid) { snprintf(text,sizeof(text),"set radio2 %s",args); f.cmd(text,false); assert(!f.radio.p.enabled()); }
f.cmd("set tempradio2 910.5,500,7,5,rxtx,0",false);
f.cmd("set tempradio2 910.5,500,7,5,rxtx,10081",false);
f.cmd("set radio2.status 1",false);
assert(!f.cli.handle("set radio2junk 910.5",f.reply));
}
{
Fixture f;
f.cmd("set radio2 910.5,500,9,5,rxtx,40"); f.advance(2000);
f.fs.fail_write=true;
f.cmd("set radio2 off",false); assert(f.radio.p.enabled());
f.fs.fail_write=false;
f.fs.fail_rename=2; // publishing temp fails after moving old image to backup
f.cmd("set radio2 off",false); assert(f.radio.p.enabled());
Radio reboot; mesh::RadioProfileCLI restored; restored.begin(&f.fs,&reboot,&f.clock);
assert(reboot.p.enabled() && reboot.p.secondary.params.preamble==40);
f.cmd("set tempradio2 911.5,500,7,5,rxtx,1"); f.advance(2000);
Radio again; mesh::RadioProfileCLI restored_again; restored_again.begin(&f.fs,&again,&f.clock);
assert(!again.p.secondary_temporary && again.p.secondary.params.freq==910.5f);
}
{
Fixture f;
f.cmd("set tempradio2 910.5,500,7,5,rxtx,1"); f.advance(2000);
f.clock.epoch-=3600; f.advance(60000,false);
assert(!f.radio.p.enabled());
g_mock_millis=UINT32_MAX-1000; f.cli.loop();
f.cmd("set tempradio2 910.5,500,7,5,rxtx,1"); f.advance(2000,false);
assert(f.radio.p.secondary_temporary); f.advance(60000,false); assert(!f.radio.p.enabled());
}
{
Fixture f;
assert(f.cli.savePrimaryPreamble(48));
f.radio.p.primary.preamble=96; f.radio.p.primary_temporary=true;
strcpy(f.reply, "> 909.5,62.5,7,5");
f.cli.appendSavedPreamble(f.reply, sizeof(f.reply), 7, 62.5);
assert(strstr(f.reply, "preamble=48")); // saved getter cannot display temporary preamble
assert(!f.cli.savePrimaryPreamble(7));
f.cmd("set tempradio2 910.5,500,8,5,rx,1");
f.advance(65000); // servicing a late start cannot extend its lease
assert(!f.radio.p.enabled());
f.cmd("set tempradio2 910.5,500,8,5,rx,2");
char text[160];
snprintf(text, sizeof(text), "set tempradioat2 910.5,500,8,5,rx,%lu,%lu",
(unsigned long)(f.clock.epoch+60),(unsigned long)(f.clock.epoch+120));
f.cmd(text, false); // overlaps the pending session, before it starts
}
{
Fixture f;
f.cmd("set radio2 910.5,7.8,12,8,rx,65528",false); // driver airtime overflow
f.cmd("set radio2 910.5,500,8,5,rx,48");
assert(f.fs.rename("/radio_profiles", "/radio_profiles.bak"));
Radio radio; mesh::RadioProfileCLI restored;
restored.begin(&f.fs, &radio, &f.clock);
assert(radio.p.enabled()); // interrupted publication recovered
f.fs.files["/radio_profiles"][2]=99;
Radio corrupt; mesh::RadioProfileCLI invalid;
invalid.begin(&f.fs, &corrupt, &f.clock);
assert(!corrupt.p.enabled());
assert(invalid.handle("set radio2 off", f.reply));
assert(strstr(f.reply,"Error")); // newer/corrupt image is not overwritten
}
{
Fixture f;
char text[160];
snprintf(text,sizeof(text),"set tempradioat2 910.5,500,8,5,rxtx,%lu,%lu,48",
(unsigned long)(f.clock.epoch+60),(unsigned long)(f.clock.epoch+120));
f.cmd(text); assert(strstr(f.cmd("get tempradioat2 1"),"48"));
f.cmd(text,false); // overlap
f.advance(60000); assert(f.radio.p.secondary_temporary);
f.clock.epoch-=3600; f.advance(60000,false); assert(!f.radio.p.enabled());
snprintf(text,sizeof(text),"set radioat2 910.5,500,9,5,rx,%lu",(unsigned long)(f.clock.epoch+60));
f.cmd(text); f.advance(60000); assert(f.radio.p.secondary.mode==mesh::RadioProfileMode::Rx);
f.cmd("del radioat2 all"); f.cmd("del tempradioat2 all");
}
puts("RadioProfileCLI: command, persistence, failure, schedule and rollover tests passed");
}
+299
View File
@@ -0,0 +1,299 @@
// Included by test_trace_retry.cpp to exercise the production Mesh/Dispatcher.
class DualProfileTestRadio : public RetryCodingRateRadio {
public:
mesh::RadioProfiles config;
uint8_t selected = 0;
int busy_profile = -1;
bool fail_next_send = false;
uint32_t estimated_airtime = 10;
std::vector<uint8_t> incoming;
std::vector<uint8_t> transmissions;
DualProfileTestRadio() {
config.primary.freq = 909.5f; config.primary.bw = 62.5f;
config.primary.sf = 7; config.primary.cr = 5;
config.secondary.params = config.primary;
config.secondary.params.freq = 910.5f;
config.secondary.params.bw = 500;
config.secondary.mode = mesh::RadioProfileMode::RxTx;
}
mesh::RadioProfiles* profiles() override { return &config; }
const mesh::RadioProfiles* profiles() const override { return &config; }
uint32_t getEstAirtimeFor(int) override { return estimated_airtime; }
uint8_t receiveProfile() const override { return selected; }
mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t p) override {
if (!incoming.empty()) return mesh::RadioParamApplyResult::BUSY;
if (!config.canTransmit(p)) return mesh::RadioParamApplyResult::FAILED;
selected = p; cr = config.params(p).cr;
return mesh::RadioParamApplyResult::APPLIED;
}
bool isReceiving() override { return selected == busy_profile; }
bool isReceivingPassive(int) override { return isReceiving(); }
bool startSendRaw(const uint8_t* bytes, int size) override {
if (fail_next_send) { fail_next_send = false; return false; }
transmissions.push_back(selected);
return RetryCodingRateRadio::startSendRaw(bytes, size);
}
int recvRaw(uint8_t* bytes, int size) override {
if (incoming.empty() || (int)incoming.size() > size) return 0;
const int length = incoming.size();
memcpy(bytes, incoming.data(), length); incoming.clear();
return length;
}
};
class DualProfileTestMesh : public RetryCodingRateMesh {
public:
using RetryCodingRateMesh::RetryCodingRateMesh;
mesh::Packet* replyOn(uint8_t profile, uint32_t generation) {
ReceiveProfileScope context(*this, profile, generation);
auto* packet = obtainNewPacket();
if (!packet) return nullptr;
packet->header = (PAYLOAD_TYPE_RESPONSE << PH_TYPE_SHIFT) | ROUTE_TYPE_DIRECT;
packet->setPathHashSizeAndCount(1, 0);
packet->payload_len = 1; packet->payload[0] = 0x5a;
return packet;
}
};
class DualProfileTest : public testing::Test {
protected:
TraceTestClock clock;
TraceTestRTC rtc;
TraceTestRNG rng;
DualProfileTestRadio radio;
TraceTestTables tables;
StaticPoolPacketManager manager{40};
DualProfileTestMesh node{radio, clock, rng, rtc, manager, tables};
void SetUp() override { node.begin(); node.flood_attempts = 2; }
mesh::Packet* queue(uint8_t type = PAYLOAD_TYPE_GRP_TXT) {
auto* p = node.obtainNewPacket();
if (!p) return nullptr;
*p = makeFloodPacket(type);
if (!node.sendFlood(p)) return nullptr;
return p;
}
void tick(uint32_t ms = 1) { clock.now += ms; node.loop(); }
};
TEST(RadioProfiles, CrossPolicyAndReceiveOnlyMatrix) {
mesh::RadioProfiles p;
for (int a = 0; a < 2; ++a) for (int b = 0; b < 2; ++b) {
p.primary_temporary = a; p.secondary_temporary = b;
for (int policy = 0; policy < 3; ++policy) {
p.cross = (mesh::RadioCrossMode)policy;
const bool cross = policy == 1 || (policy == 0 && a == b);
p.secondary.mode = mesh::RadioProfileMode::RxTx;
EXPECT_EQ(cross ? 3 : 1, p.transmitMask(0));
EXPECT_EQ(cross ? 3 : 2, p.transmitMask(1));
p.secondary.mode = mesh::RadioProfileMode::Rx;
EXPECT_EQ(1, p.transmitMask(0));
EXPECT_EQ(cross ? 1 : 0, p.transmitMask(1));
p.secondary.mode = mesh::RadioProfileMode::Off;
EXPECT_EQ(1, p.transmitMask(0));
}
}
}
TEST(RadioProfiles, AutomaticPreamblesIncludeMeasuredSwitchingMargin) {
DualProfileTestRadio radio;
auto& p = radio.config;
const uint16_t expected[] = {120, 88, 48};
for (int sf = 7; sf <= 9; ++sf) {
p.secondary.params.sf = sf;
EXPECT_EQ(expected[sf-7], p.preamble(1, 32));
EXPECT_EQ(32, p.preamble(0, 32));
EXPECT_EQ(0, p.preamble(1, 32) % 8);
EXPECT_EQ(9831, p.listenUs(0));
EXPECT_EQ(6937, p.listenUs(1));
EXPECT_LE(2 * (p.listenUs(0) + p.listenUs(1) + 2 * p.SwitchBudgetUs + p.LoopBudgetUs),
p.preamble(0, 32) * p.symbolUs(p.primary));
EXPECT_LE(p.listenUs(1) + 2 * p.SwitchBudgetUs + p.LoopBudgetUs
+ p.AcquisitionSymbols * p.symbolUs(p.primary), p.preamble(0, 32) * p.symbolUs(p.primary));
}
p.secondary.params.preamble = 40;
EXPECT_EQ(40, p.preamble(1, 32));
p.secondary.mode = mesh::RadioProfileMode::Off;
EXPECT_EQ(16, p.preamble(0, 16));
}
TEST(RadioProfiles, SlowerProfileDeterminesOrderAndFasterReceiveWindow) {
DualProfileTestRadio radio;
auto& p = radio.config;
EXPECT_EQ(0, p.slowerProfile());
std::swap(p.primary, p.secondary.params);
EXPECT_EQ(1, p.slowerProfile());
EXPECT_EQ(9831, p.listenUs(1));
EXPECT_EQ(6937, p.listenUs(0));
p.secondary.params.preamble = 40;
EXPECT_EQ(15129, p.listenUs(0));
p.secondary.params.preamble = 8;
EXPECT_FALSE(p.automaticPreambleFits()); // no safe time for the faster channel
}
TEST_F(DualProfileTest, TransmitsIdenticalMessageOnBothProfilesOnce) {
node.flood_attempts = 0;
ASSERT_NE(nullptr, queue());
ASSERT_EQ(2, manager.getOutboundTotal());
auto* a = manager.getOutboundByIdx(0);
auto* b = manager.getOutboundByIdx(1);
EXPECT_EQ(a->payload_len, b->payload_len);
EXPECT_EQ(0, memcmp(a->payload, b->payload, a->payload_len));
tick(); ASSERT_TRUE(radio.sending);
radio.complete = true; tick();
radio.complete = true; tick();
radio.complete = true; tick();
EXPECT_EQ((std::vector<uint8_t>{0, 1}), radio.transmissions);
EXPECT_EQ(40, manager.getFreeCount());
}
TEST_F(DualProfileTest, DefaultSeparatesNormalTrafficFromTemporaryOta) {
radio.config.secondary_temporary = true;
ASSERT_NE(nullptr, queue());
ASSERT_EQ(1, manager.getOutboundTotal());
EXPECT_EQ(0, manager.getOutboundByIdx(0)->radio_profile);
ASSERT_NE(nullptr, queue(PAYLOAD_TYPE_OTA));
ASSERT_EQ(2, manager.getOutboundTotal());
EXPECT_EQ(1, manager.getOutboundByIdx(1)->radio_profile);
}
TEST_F(DualProfileTest, ReceiveOnlyNeverTransmitsSecondary) {
radio.config.secondary.mode = mesh::RadioProfileMode::Rx;
ASSERT_NE(nullptr, queue());
EXPECT_EQ(1, manager.getOutboundTotal());
tick(); EXPECT_EQ((std::vector<uint8_t>{0}), radio.transmissions);
}
TEST_F(DualProfileTest, ReceiveOnlyTemporaryOtaDoesNotLeakOntoNormalChannel) {
radio.config.secondary_temporary = true;
radio.config.secondary.mode = mesh::RadioProfileMode::Rx;
EXPECT_EQ(nullptr, queue(PAYLOAD_TYPE_OTA));
EXPECT_EQ(0, manager.getOutboundTotal());
EXPECT_EQ(40, manager.getFreeCount());
tick(); EXPECT_TRUE(radio.transmissions.empty());
}
TEST_F(DualProfileTest, ExplicitCrossAllowsReceiveOnlyTemporaryOtaOnPrimary) {
radio.config.secondary_temporary = true;
radio.config.secondary.mode = mesh::RadioProfileMode::Rx;
radio.config.cross = mesh::RadioCrossMode::On;
ASSERT_NE(nullptr, queue(PAYLOAD_TYPE_OTA));
ASSERT_EQ(1, manager.getOutboundTotal());
EXPECT_EQ(1, manager.getOutboundByIdx(0)->radio_origin);
EXPECT_EQ(0, manager.getOutboundByIdx(0)->radio_profile);
tick(); EXPECT_EQ((std::vector<uint8_t>{0}), radio.transmissions);
}
TEST_F(DualProfileTest, BusyPrimaryDoesNotBlockSecondaryQueue) {
node.flood_attempts = 0;
radio.busy_profile = 0;
ASSERT_NE(nullptr, queue());
tick(); EXPECT_FALSE(radio.sending);
tick(); EXPECT_EQ((std::vector<uint8_t>{1}), radio.transmissions);
}
TEST_F(DualProfileTest, PacketArrivingAfterFailedTransmitCannotDeadlockRadioRetry) {
node.flood_attempts = 0;
radio.config.cross = mesh::RadioCrossMode::Off;
radio.fail_next_send = true;
ASSERT_NE(nullptr, queue());
tick(); ASSERT_FALSE(radio.sending);
radio.incoming = {uint8_t(ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT)), 0, 0x42};
tick(500);
EXPECT_TRUE(radio.incoming.empty());
EXPECT_EQ(1, radio.config.rx_packets[0]);
EXPECT_EQ((std::vector<uint8_t>{0}), radio.transmissions);
radio.complete = true; tick();
EXPECT_EQ(40, manager.getFreeCount());
}
TEST_F(DualProfileTest, AirtimeBudgetWaitDoesNotPinReceiverToQueuedTransmitProfile) {
node.flood_attempts = 0;
radio.config.cross = mesh::RadioCrossMode::Off;
radio.estimated_airtime = 100000000;
ASSERT_NE(nullptr, queue());
radio.selected = 1; // scanner is currently on the other channel
tick();
EXPECT_FALSE(radio.sending);
EXPECT_EQ(1, radio.selected);
tick();
EXPECT_EQ(1, radio.selected);
}
TEST_F(DualProfileTest, DirectRetriesKeepSeparateCodingRatesAndEchoOwnership) {
node.direct_attempts = 2;
radio.config.secondary.params.cr = 7;
const uint8_t route[] = {0x11, 0x22};
ASSERT_NE(nullptr, makeDirectText(node, 0x55, route, sizeof(route)));
tick(); radio.complete = true; tick();
radio.complete = true; tick();
radio.complete = true; tick();
ASSERT_EQ(2, manager.getOutboundTotal());
auto* a = manager.getOutboundByIdx(0);
auto* b = manager.getOutboundByIdx(1);
ASSERT_NE(a->radio_profile, b->radio_profile);
EXPECT_EQ((std::vector<uint8_t>{5, 7}), radio.transmitted_crs);
mesh::Packet echo = *a;
echo.radio_bound = false; echo.radio_local = false;
echo.setPathHashCount(a->getPathHashCount() - 1);
node.receivePacket(&echo);
ASSERT_EQ(1, manager.getOutboundTotal());
EXPECT_EQ(b, manager.getOutboundByIdx(0));
}
TEST_F(DualProfileTest, ExpiredSecondaryPacketsAreDiscarded) {
node.flood_attempts = 0;
radio.config.secondary_temporary = true;
ASSERT_NE(nullptr, queue(PAYLOAD_TYPE_OTA));
radio.config.setSecondary({}, false);
tick();
EXPECT_TRUE(radio.transmissions.empty());
EXPECT_EQ(0, manager.getOutboundTotal());
EXPECT_EQ(40, manager.getFreeCount());
}
TEST_F(DualProfileTest, EchoOnOneProfileDoesNotCancelOtherProfileRetry) {
ASSERT_NE(nullptr, queue());
tick(); radio.complete = true; tick();
radio.complete = true; tick();
radio.complete = true; tick();
ASSERT_EQ(2, manager.getOutboundTotal());
auto* first = manager.getOutboundByIdx(0);
auto* second = manager.getOutboundByIdx(1);
EXPECT_NE(first->radio_profile, second->radio_profile);
mesh::Packet echo = *first;
echo.radio_bound = false; echo.radio_local = false;
echo.setPathHashCount(first->getPathHashCount() + 1);
node.receivePacket(&echo);
ASSERT_EQ(1, manager.getOutboundTotal());
EXPECT_EQ(second, manager.getOutboundByIdx(0));
EXPECT_NE(echo.radio_profile, second->radio_profile);
}
TEST_F(DualProfileTest, DeferredReplyRetainsItsReceiveProfileAndSession) {
radio.config.secondary_temporary = true;
auto* reply = node.replyOn(1, radio.config.generation[1]);
ASSERT_NE(nullptr, reply);
ASSERT_TRUE(node.sendPacket(reply, 0));
ASSERT_EQ(1, manager.getOutboundTotal());
EXPECT_EQ(1, manager.getOutboundByIdx(0)->radio_profile);
auto* local = node.obtainNewPacket();
ASSERT_NE(nullptr, local);
EXPECT_TRUE(local->radio_local); // the scope did not leak into later local work
node.releasePacket(local);
auto* stale = node.replyOn(1, radio.config.generation[1]);
radio.config.setSecondary({}, false);
EXPECT_FALSE(node.sendPacket(stale, 0));
tick();
EXPECT_EQ(40, manager.getFreeCount());
EXPECT_TRUE(radio.transmissions.empty());
}
TEST_F(DualProfileTest, ChangedPolicyRemovesCrossCopiesBeforeTransmission) {
node.flood_attempts = 0;
ASSERT_NE(nullptr, queue());
radio.config.cross = mesh::RadioCrossMode::Off;
tick();
radio.complete = true; tick();
EXPECT_EQ((std::vector<uint8_t>{0}), radio.transmissions);
EXPECT_EQ(40, manager.getFreeCount());
}
@@ -0,0 +1,52 @@
#pragma once
#include <map>
#include <string>
#include <vector>
#include <cstring>
#include <algorithm>
class MemoryFS;
class File {
MemoryFS* fs_ = nullptr;
std::string path_;
size_t cursor_ = 0;
public:
File() = default;
File(MemoryFS* fs, const char* path) : fs_(fs), path_(path) {}
operator bool() const { return fs_ != nullptr; }
size_t size() const;
int read(uint8_t* data, size_t size);
size_t write(const uint8_t* data, size_t size);
void flush() {}
void close() { fs_ = nullptr; }
};
class MemoryFS {
public:
std::map<std::string, std::vector<uint8_t>> files;
bool fail_write = false;
int fail_rename = 0;
bool exists(const char* path) const { return files.count(path); }
bool remove(const char* path) { return files.erase(path); }
bool rename(const char* from, const char* to) {
if (fail_rename > 0 && --fail_rename == 0) return false;
if (!exists(from) || exists(to)) return false;
files[to] = files[from]; files.erase(from); return true;
}
File open(const char* path, const char* mode) {
if (*mode == 'w') files[path].clear();
if (!exists(path)) return {};
return File(this, path);
}
};
inline size_t File::size() const { return fs_ ? fs_->files[path_].size() : 0; }
inline int File::read(uint8_t* data, size_t size) {
if (!fs_) return -1;
auto& bytes = fs_->files[path_];
size = std::min(size, bytes.size() - cursor_);
memcpy(data, bytes.data() + cursor_, size); cursor_ += size; return size;
}
inline size_t File::write(const uint8_t* data, size_t size) {
if (!fs_ || fs_->fail_write) return 0;
auto& bytes = fs_->files[path_];
bytes.insert(bytes.end(), data, data + size); return size;
}
#define FILESYSTEM MemoryFS
+245
View File
@@ -0,0 +1,245 @@
"""Audit CLI get/set coverage; execute migrated radio settings through real branches.
The inventory checks parser entry points, not documentation/whitelist mentions.
Read-only queries and settings written through another command have explicit
exceptions. Hardware callbacks are mocked in the native round-trip test.
"""
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
COMMON = ROOT / 'src/helpers/CommonCLI.cpp'
OBSERVER = ROOT / 'src/helpers/CommonCLI_Observer.cpp'
RADIO = ROOT / 'src/helpers/CommonRadioPrefs.cpp'
TOKEN = re.compile(r'//[^\n]*|/\*[\s\S]*?\*/|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|[{}]')
def literal_keys(method, variable='config', top_level=True):
"""Extract literal key comparisons, excluding nested comparisons of values."""
matches = list(re.finditer(
r'(?:memcmp|strncmp|strcmp|configKeyEquals)\(' + variable +
r',\s*"([a-z][\w.]*)(?: [^"\n]*)?"', method))
depth, cursor, result = 0, 0, set()
tokens = list(TOKEN.finditer(method))
for match in matches:
while cursor < len(tokens) and tokens[cursor].start() < match.start():
depth += (tokens[cursor].group() == '{') - (tokens[cursor].group() == '}')
cursor += 1
if not top_level or depth == 1:
result.add(match[1])
return result
def methods(text, prefix):
return tuple(extract_braced(text, f'{prefix}{verb}Cmd(') for verb in ('Set', 'Get'))
def full_command_keys(text):
comparisons = re.findall(
r'(?:strcmp|strncmp|memcmp)\((?:command|cmd|text),\s*"(get|set) ([a-z][\w.]*)[ "]', text)
return tuple({key for kind, key in comparisons if kind == verb} for verb in ('set', 'get'))
COMMON_QUERY_EXCEPTIONS = {
'bootloader.ver': 'Installed bootloader identity',
'bridge.running': 'Live bridge state; set bridge.enabled controls intent',
'bridge.type': 'Build-selected bridge transport',
'password': 'Written by the password command, not set password',
'public.key': 'Derived from identity; written through set prv.key',
'pwrmgt.bootmv': 'Boot-time voltage measurement',
'pwrmgt.bootreason': 'Boot/reset cause',
'pwrmgt.source': 'Measured power source',
'pwrmgt.support': 'Hardware power-management capabilities',
'radio.rxps.config': 'Detailed view of set radio.rxps',
'role': 'Build-selected firmware role',
'rxps.wd': 'Runtime RX power-saving recovery counters',
'wifi.pwd': 'Setter is in the observer or standalone-WiFi delegate',
}
OBSERVER_QUERY_EXCEPTIONS = {
'mqtt.config.valid': 'Configuration validation result',
'mqtt.ntp.diag': 'NTP diagnostics',
'mqtt.presets': 'List of available presets',
'mqtt.running': 'Live state; set mqtt.enabled controls intent',
'mqtt.stats': 'Runtime MQTT statistics',
'wifi.status': 'Live connection status',
}
class CLISettingsContractTest(unittest.TestCase):
def test_feature_guarded_query_coverage(self):
compiler = shutil.which('g++') or shutil.which('clang++')
self.assertIsNotNone(compiler, 'Host C++ compiler required')
profiles = {
'minimal': [],
'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_mqtt': ['ESP_PLATFORM', 'ESP32_PLATFORM', 'WITH_MQTT_BRIDGE', 'WITH_BRIDGE',
'WITH_MQTT_NEIGHBORS', 'MESH_USB_LOGGING_AVAILABLE'],
'rs232_gps': ['WITH_BRIDGE', 'WITH_RS232_BRIDGE', 'ENV_INCLUDE_GPS'],
'espnow': ['ESP_PLATFORM', 'ESP32_PLATFORM', 'WITH_BRIDGE', 'WITH_ESPNOW_BRIDGE', 'MESH_PRIMARY_ESPNOW'],
'lr2021': ['USE_LR2021'],
}
for path, prefix, exceptions in (
(COMMON, 'void CommonCLI::handle', COMMON_QUERY_EXCEPTIONS),
(OBSERVER, 'bool CommonCLI::handleObserver', OBSERVER_QUERY_EXCEPTIONS),
):
original = '\n'.join(methods(path.read_text(encoding='utf-8'), prefix))
for profile, macros in profiles.items():
with self.subTest(parser=path.name, profile=profile):
# Preprocess the production bodies so a setter behind a
# different feature guard cannot satisfy a visible getter.
result = subprocess.run(
[compiler, '-E', '-P', '-x', 'c++', *[f'-D{key}=1' for key in macros], '-'],
input=original, text=True, capture_output=True, check=True)
setter, getter = methods(result.stdout, prefix)
sets, gets = literal_keys(setter), literal_keys(getter)
gets = {'flood.retry.bucket' if k == 'flood.retry.bucket.' else k for k in gets}
self.assertFalse(gets - sets - set(exceptions))
if path == COMMON:
self.assertIn('path.hash.mode', sets)
def test_common_and_observer_query_coverage(self):
for path, prefix, exceptions in (
(COMMON, 'void CommonCLI::handle', COMMON_QUERY_EXCEPTIONS),
(OBSERVER, 'bool CommonCLI::handleObserver', OBSERVER_QUERY_EXCEPTIONS),
):
with self.subTest(parser=path.name):
setter, getter = methods(path.read_text(encoding='utf-8'), prefix)
sets, gets = literal_keys(setter), literal_keys(getter)
# The query embeds the bucket index in the key; the setter
# accepts it as a separate argument.
gets = {'flood.retry.bucket' if k == 'flood.retry.bucket.' else k for k in gets}
self.assertEqual(gets - sets, set(exceptions),
f'{path.name}: missing setter or undocumented read-only query')
def test_mqtt_slot_query_coverage(self):
setter, getter = methods(OBSERVER.read_text(encoding='utf-8'), 'bool CommonCLI::handleObserver')
sets = literal_keys(setter, 'subcmd', False)
gets = literal_keys(getter, 'subcmd', False)
self.assertGreater(len(sets), 5, 'MQTT slot parser moved; update the audit')
self.assertEqual(gets - sets, {'diag'})
def test_every_migrated_radio_setter_is_reachable_in_infrastructure(self):
common_set, common_get = methods(COMMON.read_text(encoding='utf-8'), 'void CommonCLI::handle')
shared = extract_braced(RADIO.read_text(encoding='utf-8'), 'bool CommonRadioPrefs::handleCommand(')
migrated = set(re.findall(r'startsWith\(command, "set ([\w.]+) ', shared))
self.assertEqual(len(migrated), 13, 'Review changes to the shared radio command surface')
self.assertFalse(migrated - literal_keys(common_set))
self.assertFalse(migrated - literal_keys(common_get))
def test_split_parser_delegates_are_wired(self):
common = COMMON.read_text(encoding='utf-8')
setter, getter = methods(common, 'void CommonCLI::handle')
self.assertIn('handleObserverSetCmd(sender_timestamp, config, reply)', setter)
self.assertIn('handleObserverGetCmd(sender_timestamp, config, reply)', getter)
for role, filename in (('simple_repeater', 'MyMesh.cpp'),
('simple_room_server', 'MyMesh.cpp'),
('simple_sensor', 'SensorMesh.cpp')):
text = (ROOT/'examples'/role/filename).read_text(encoding='utf-8')
self.assertIn('_cli.handleCommand(sender_timestamp, command, reply)', text)
for verb in ('get', 'set'):
block = extract_braced(common, f'if (memcmp(command, "{verb} ", 4) == 0)')
self.assertIn(f'handle{verb.title()}Cmd(sender_timestamp, command, reply)', block)
def test_companion_allowlist_is_backed_by_shared_parser(self):
text = (ROOT/'examples/companion_radio/MyMesh.cpp').read_text(encoding='utf-8')
allowlist = extract_braced(text, 'static bool isCompanionRadioPrefsCommand(')
shared = extract_braced(RADIO.read_text(encoding='utf-8'), 'bool CommonRadioPrefs::handleCommand(')
for verb, key in re.findall(r'"(get|set) ([\w.]+) ?"', allowlist):
self.assertIn(f'"{verb} {key}' + (' ' if verb == 'set' else '') + '"', shared)
handler = extract_braced(text, 'bool MyMesh::handleCommand(')
self.assertIn('_prefs.getRadioPrefs()->handleCommand(', handler)
self.assertIn('_prefs.getRadioPrefs()->isDirty()', handler)
self.assertIn('savePrefs()', handler)
def test_role_query_coverage(self):
common_set, _ = methods(COMMON.read_text(encoding='utf-8'), 'void CommonCLI::handle')
common_keys = literal_keys(common_set)
for role, filename, exceptions in (
('simple_repeater', 'MyMesh.cpp', {
'acl', # ACL list, changed with ACL commands
'battery.alert.region', # set battery.alert on <region>
'clock.sync', # clock-sync delegate
'host', # Runtime host configuration view
'recent.repeaters', # Discovery/cache query
}),
('simple_room_server', 'MyMesh.cpp', {'acl'}),
('simple_sensor', 'SensorMesh.cpp', {'acl'}),
):
text = (ROOT/'examples'/role/filename).read_text(encoding='utf-8')
sets, gets = full_command_keys(text)
with self.subTest(role=role):
self.assertEqual(gets - sets - common_keys, exceptions)
companion = (ROOT/'examples/companion_radio/MyMesh.cpp').read_text(encoding='utf-8')
sets, gets = full_command_keys(companion)
terminal = extract_braced(companion, 'void MyMesh::handleTerminalCommand(')
terminal_set = extract_braced(terminal, 'if (strncmp(command, "set ", 4) == 0)')
sets |= literal_keys(terminal_set)
shared = extract_braced(companion, 'static bool isCompanionRadioPrefsCommand(')
sets |= {k for verb, k in re.findall(r'"(get|set) ([\w.]+) ?"', shared) if verb == 'set'}
self.assertEqual(gets - sets, {
'contact.cache', 'contact.cache.timing', # Contact-storage diagnostics
'display.wifi', # Connection/display status
'mqtt', 'mqtt.running', # MQTT connection status
'password', 'prv.key', # Local secret/identity reads
'pwrmgt.bootreason', 'role', # Boot/build facts
'radio.rxps.config', # Detail view of radio.rxps
'wifi.pwd', # Saved transport credentials
})
def test_literal_key_comparison_lengths(self):
for path in (COMMON, OBSERVER):
text = path.read_text(encoding='utf-8')
for match in re.finditer(r'(?:memcmp|strncmp)\((?:config|subcmd),\s*"([^"\n]*)",\s*(\d+)\)', text):
key, length = match[1], int(match[2])
with self.subTest(file=path.name, key=key):
# Some exact getters compare the terminating NUL too.
self.assertIn(length, (len(key), len(key) + 1))
if key.endswith(' '):
self.assertEqual(length, len(key), 'Setter must not compare the value against NUL')
def test_native_radio_setting_round_trips(self):
compiler = shutil.which('g++') or shutil.which('clang++')
self.assertIsNotNone(compiler, 'Host C++ compiler required')
source = COMMON.read_text(encoding='utf-8')
setter, getter = methods(source, 'void CommonCLI::handle')
keys = ['radio', 'freq', 'af', 'dutycycle', 'int.thresh', 'cad', 'radio.rxgain',
'tx', 'rxdelay', 'agc.reset.interval', 'multi.acks', 'txdelay', 'direct.txdelay']
# Preserve the actual conditions and bodies. The only excluded branches
# are unrelated settings needing board-specific dependencies.
set_blocks = [extract_braced(setter, 'if (strncmp(config, "path.hash.mode", 14)')]
get_blocks = [extract_braced(getter, 'if (configKeyEquals(config, "path.hash.mode"))')]
set_blocks.append(extract_braced(setter, 'if (strcmp(config, "extra.sf") == 0 ||'))
get_blocks.append(extract_braced(getter, 'if (strcmp(config, "extra.sf") == 0)'))
for key in keys:
block = extract_braced(setter, f'if (memcmp(config, "{key} ", {len(key)+1}) == 0)')
# A feature guard immediately before this closing brace controls
# the following else-if arm, which is not part of this fixture.
block = re.sub(r'\n#if[^\n]*\n\s*}$', '\n}', block)
set_blocks.append(block)
get_blocks.append(extract_braced(getter, f'if (configKeyEquals(config, "{key}"))'))
dispatch = ' else '.join(extract_braced(source, f'if (memcmp(command, "{verb} ", 4) == 0)')
for verb in ('get', 'set'))
fixture = (ROOT/'test/fixtures/cli_settings/main.cpp').read_text(encoding='utf-8')
fixture = fixture.replace('@SET@', ' else '.join(set_blocks))
fixture = fixture.replace('@GET@', ' else '.join(get_blocks))
fixture = fixture.replace('@DISPATCH@', dispatch)
fixture = fixture.replace('@KEY_EQUALS@', extract_braced(source, 'static bool configKeyEquals('))
with tempfile.TemporaryDirectory() as directory:
cpp, exe = Path(directory)/'test.cpp', Path(directory)/'test.exe'
cpp.write_text(fixture, encoding='utf-8')
for macros in ([], ['-DUSE_LR2021=1']):
with self.subTest(macros=macros):
subprocess.run([compiler, '-std=c++17', '-Wall', '-Wextra', '-Werror', *macros,
'-I', str(ROOT/'src'), str(cpp), '-o', str(exe)], check=True)
subprocess.run([str(exe)], check=True)
if __name__ == '__main__':
unittest.main()
@@ -9,12 +9,14 @@ TEST(DeferredCliCommand, CopiesAuthenticatedCommandContext) {
const char command[] = "del flood.moderation.all";
ASSERT_TRUE(deferred.enqueue(7, 123456U, 2, secret, command,
strlen(command), 654321U));
strlen(command), 654321U, 1, 91));
EXPECT_TRUE(deferred.pending);
EXPECT_EQ(7, deferred.client_index);
EXPECT_EQ(123456U, deferred.sender_timestamp);
EXPECT_EQ(654321U, deferred.request_id);
EXPECT_EQ(2, deferred.path_hash_size);
EXPECT_EQ(1, deferred.radio_profile);
EXPECT_EQ(91U, deferred.radio_generation);
EXPECT_EQ(0, memcmp(secret, deferred.secret, sizeof(secret)));
EXPECT_STREQ(command, deferred.command);
EXPECT_TRUE(deferred.matches(7, 654321U, command, strlen(command)));
+12
View File
@@ -14,6 +14,7 @@ PREAMBLE = r'''
#include <cstdint>
#include <initializer_list>
#include <helpers/CLICommandUtils.h>
#include <helpers/RadioProfileCommandUtils.h>
#define ESP32 1
#define WIFI_SSID "test"
#define WITH_MQTT_BRIDGE 1
@@ -22,6 +23,16 @@ PREAMBLE = r'''
#define MAX_LORA_TX_POWER 22
#define TXT_TYPE_CLI_COMMAND 1
namespace mesh {
struct RadioProfileCLI {
bool handle(const char*, char*, size_t) { return false; }
static bool parseSuffix(const char* input, unsigned fields, char* legacy, size_t size, uint16_t& preamble) {
return cli::parseRadioPreambleSuffix(input, fields, legacy, size, preamble);
}
bool acceptsPrimary(float, float, uint8_t, uint8_t, uint16_t) { return true; }
bool savePrimaryPreamble(uint16_t) { return true; }
uint16_t primaryPreamble() const { return 0; }
void appendSavedPreamble(char*, size_t, uint8_t, float) {}
};
struct Packet {};
void resetLazyPersistenceAfterSuccess(unsigned& when, uint8_t& failures) { when=0; failures=0; }
struct Utils {
@@ -77,6 +88,7 @@ struct WebConfigServer {
const char* FIRMWARE_VERSION="test";
const char* FIRMWARE_BUILD_DATE="test";
struct MyMesh {
mesh::RadioProfileCLI _radio_profiles;
Identity self_id;
Store store; Store* _store=&store;
Prefs _prefs;
+65
View File
@@ -0,0 +1,65 @@
"""Exercise the production infrastructure setter and its persistence boundary."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
class PathHashCLITest(unittest.TestCase):
def test_production_setter(self):
compiler = shutil.which('g++') or shutil.which('clang++')
self.assertIsNotNone(compiler, 'Host C++ compiler required')
source = (ROOT/'src/helpers/CommonCLI.cpp').read_text(encoding='utf-8')
setter = extract_braced(source, 'void CommonCLI::handleSetCmd(')
branch = extract_braced(setter, 'if (strncmp(config, "path.hash.mode", 14)')
fixture = r'''
#include <cassert>
#include <initializer_list>
#include <helpers/CLICommandUtils.h>
struct CLI {
struct Prefs { uint8_t path_hash_mode = 0; } prefs;
Prefs* _prefs = &prefs;
unsigned saves = 0;
void savePrefs() { ++saves; }
void set(const char* config, char* reply) {
''' + branch + r'''
strcpy(reply, "unknown config");
}
};
int main() {
CLI cli; char reply[160];
for (unsigned mode : {0u, 1u, 2u}) {
char command[64]; sprintf(command,"path.hash.mode %u",mode);
unsigned saves=cli.saves;
cli.set(command,reply);
assert(strcmp(reply,"OK")==0);
assert(cli.prefs.path_hash_mode==mode && cli.saves==saves+1);
}
cli.set("path.hash.mode\t1 ",reply);
assert(strcmp(reply,"OK")==0 && cli.prefs.path_hash_mode==1);
for (const char* value : {"", " ", " -1", " 3", " 256", " 4294967296", " nope", " 1x", " 1 2"}) {
char command[80]; sprintf(command,"path.hash.mode%s",value);
unsigned saves=cli.saves;
cli.set(command,reply);
assert(strcmp(reply,"Error, must be 0,1, or 2")==0);
assert(cli.prefs.path_hash_mode==1 && cli.saves==saves);
}
cli.set("path.hash.mode.extra 2",reply);
assert(strcmp(reply,"unknown config")==0 && cli.prefs.path_hash_mode==1);
}
'''
with tempfile.TemporaryDirectory() as directory:
cpp = Path(directory)/'test.cpp'
exe = Path(directory)/'test.exe'
cpp.write_text(fixture, encoding='utf-8')
subprocess.run([compiler, '-std=c++11', '-Wall', '-Wextra', '-Werror',
'-I', str(ROOT/'src'), str(cpp), '-o', str(exe)], check=True)
subprocess.run([str(exe)], check=True)
if __name__ == '__main__':
unittest.main()
+25
View File
@@ -0,0 +1,25 @@
"""Run the production shared profile command parser and persistence with a memory FS."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class RadioProfileCLITest(unittest.TestCase):
def test_commands_persistence_and_timers(self):
compiler = shutil.which('g++') or shutil.which('clang++')
self.assertIsNotNone(compiler)
with tempfile.TemporaryDirectory() as work:
exe = Path(work) / 'profiles.exe'
subprocess.run([compiler, '-std=c++17', '-Wall', '-Wextra',
'-I', str(ROOT/'test/fixtures/radio_profiles/mocks'),
'-I', str(ROOT/'test/mocks'), '-I', str(ROOT/'src'),
str(ROOT/'src/helpers/RadioProfileCLI.cpp'),
str(ROOT/'test/fixtures/radio_profiles/cli_test.cpp'),
'-o', str(exe)], check=True, capture_output=True, text=True)
subprocess.run([str(exe)], check=True)
if __name__ == '__main__':
unittest.main()
+153
View File
@@ -0,0 +1,153 @@
"""Exercise production profile transitions with a controllable physical radio."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from test_radio_receive_contract import method
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <cassert>
#include <RadioProfiles.h>
#include <helpers/radiolib/RXPowerSaving.h>
#define RADIOLIB_ERR_NONE 0
#define STATE_IDLE 0
#define STATE_RX 1
#define STATE_TX_WAIT 3
#define STATE_INT_READY 16
#define MESH_DEBUG_PRINTLN(...) ((void)0)
namespace mesh { enum class RadioParamApplyResult { APPLIED, BUSY, FAILED }; }
static uint64_t elapsed_us;
static uint8_t state;
uint32_t micros() { return (uint32_t)elapsed_us; }
uint32_t millis() { return (uint32_t)(elapsed_us / 1000); }
struct Chip { int standby() { return 0; } };
struct RadioLibWrapper {
Chip chip; Chip* _radio = &chip;
mesh::RadioProfiles _profiles;
bool _params_valid=true, packet=false, busy=false, fail=false;
bool _rx_ps_enabled=true, _rx_ps_armed=true, _rx_ps_continuous_fallback=false;
bool _profile_saved_rxps=false, _profile_rxps_suspended=false;
bool _nf_calib_active=false, _noise_floor_valid=true, _profile_refresh_required=false;
uint32_t _rx_ps_rx_us=50000, _rx_ps_sleep_us=50000;
uint8_t _active_profile=0, _cur_sf=7, _cur_cr=5;
uint32_t _profile_generation=1, _profile_visit_us=0, _profile_retry_at=0;
uint32_t _profile_scan_generation[2]={};
uint16_t _physical_preamble=32;
float _cur_freq=909.5, _cur_bw=62.5;
unsigned applies=0;
RadioLibWrapper() {
state=STATE_RX; elapsed_us=100000;
auto& p=_profiles.primary;
p.freq=_cur_freq; p.bw=_cur_bw; p.sf=_cur_sf; p.cr=_cur_cr;
_profile_visit_us=micros();
}
bool isChipBusy() { return busy; }
bool isReceivingPacket() { return packet; }
bool isPacketPendingOrReceiving() { return packet || (state & STATE_INT_READY); }
bool supportsRxPowerSaving() { return true; }
void startRecv() { state=STATE_RX; _profile_visit_us=micros(); _rx_ps_armed=_rx_ps_enabled; }
void stopReceiveDutyCycle() { _rx_ps_armed=false; }
bool applyParams(float f,float,uint8_t,uint8_t) { ++applies; elapsed_us+=1200; return !fail || f==909.5f; }
void cacheParams(float f,float b,uint8_t s,uint8_t c) { _cur_freq=f;_cur_bw=b;_cur_sf=s;_cur_cr=c; }
bool restoreAfterDeepInit() { return true; }
void recalibrateNoiseFloor() { _noise_floor_valid=false; }
bool validateProfile(const mesh::RadioProfileParams& p) const { return mesh::RadioProfiles::valid(p); }
uint16_t profilePreamble(uint8_t n) const { return _profiles.preamble(n,32); }
uint8_t beginReconfigure();
void endReconfigure(bool);
void serviceProfileScan();
mesh::RadioParamApplyResult tuneProfile(uint8_t);
mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t);
mesh::RadioParamApplyResult trySetParams(float,float,uint8_t,uint8_t,const uint32_t* = nullptr);
mesh::RadioParamApplyResult trySetPrimaryParams(const mesh::RadioProfileParams&,bool,const uint32_t* = nullptr);
void enable() {
mesh::RadioProfileConfig second;
second.params=_profiles.primary;second.params.freq=910.5;second.params.bw=500;
second.mode=mesh::RadioProfileMode::RxTx;_profiles.setSecondary(second,false);
serviceProfileScan();
}
};
@METHODS@
int main() {
using Result=mesh::RadioParamApplyResult;
{
RadioLibWrapper w; w.enable();
assert(w._profile_rxps_suspended && !w._rx_ps_enabled && !w._rx_ps_armed);
elapsed_us+=w._profiles.listenUs(0)-1;w.serviceProfileScan();assert(w._active_profile==0);
elapsed_us++;w.serviceProfileScan();assert(w._active_profile==1);
elapsed_us+=w._profiles.listenUs(1)-1;w.serviceProfileScan();assert(w._active_profile==1);
elapsed_us++;w.serviceProfileScan();assert(w._active_profile==0);
elapsed_us+=w._profiles.listenUs(0);w.serviceProfileScan();assert(w._active_profile==1);
const auto generation=w._profile_generation;
w.packet=true;elapsed_us+=100000;w.serviceProfileScan();assert(w._active_profile==1);
assert(w.prepareTransmitProfile(0)==Result::BUSY);
w._profiles.setSecondary({},false);w.serviceProfileScan();
assert(w._active_profile==1 && w._profile_generation==generation);
w.packet=false;w.serviceProfileScan();
assert(w._active_profile==0 && w._rx_ps_enabled && !w._profile_rxps_suspended);
assert(w._rx_ps_armed);
w.serviceProfileScan(); assert(!w._profile_refresh_required);
}
{
RadioLibWrapper w;
w._profiles.primary.bw=500; w._cur_bw=500;
w.enable();
w._profiles.secondary.params.bw=62.5;
++w._profiles.generation[1];
w.serviceProfileScan();assert(w._active_profile==1); // slower channel first
elapsed_us+=w._profiles.listenUs(1)-1;w.serviceProfileScan();assert(w._active_profile==1);
elapsed_us++;w.serviceProfileScan();assert(w._active_profile==0);
}
{
RadioLibWrapper w;w.enable();w.fail=true;
elapsed_us+=100000;w.serviceProfileScan();
assert(w._profiles.switch_failures==1 && w._active_profile==0);
const auto applies=w.applies;
for (int i=0;i<10;++i) w.serviceProfileScan();
assert(w.applies==applies); // failed target is not hammered every loop
elapsed_us+=1001000;w.fail=false;w.serviceProfileScan();assert(w._active_profile==1);
}
{
RadioLibWrapper w;
// Zero is the inactive retry timer even after 24.8 days of uptime.
elapsed_us=uint64_t(0xf0000000UL)*1000;w.enable();
assert(w._profile_rxps_suspended);
elapsed_us+=100000;w.serviceProfileScan();assert(w._profiles.switches>=1);
}
{
RadioLibWrapper w;auto primary=w._profiles.primary;
auto temp=primary;temp.freq=910.5;temp.preamble=64;
w.packet=true;
assert(w.trySetPrimaryParams(temp,true)==Result::BUSY);
assert(w._profiles.primary==primary && !w._profiles.primary_temporary);
w.packet=false;w.fail=true;
assert(w.trySetPrimaryParams(temp,true)==Result::FAILED);
assert(w._profiles.primary==primary && w._physical_preamble==32);
w.fail=false;
assert(w.trySetPrimaryParams(temp,true)==Result::APPLIED);
assert(w._profiles.primary==temp && w._physical_preamble==64);
assert(w._profile_generation==w._profiles.generation[0]);
}
}
'''
class ProfileScanTest(unittest.TestCase):
def test_production_scan_and_transitions(self):
source=(ROOT/'src/helpers/radiolib/RadioLibWrappers.cpp').read_text()
names=[('uint8_t','beginReconfigure'),('void','endReconfigure'),
('void','serviceProfileScan')]+[('mesh::RadioParamApplyResult',name) for name in
['tuneProfile','prepareTransmitProfile','trySetParams','trySetPrimaryParams']]
methods='\n'.join(method(source,f'{kind} RadioLibWrapper::{name}(') for kind,name in names)
with tempfile.TemporaryDirectory() as folder:
cpp=Path(folder)/'test.cpp'; exe=Path(folder)/'test.exe'
(Path(folder)/'Arduino.h').write_text('#pragma once\n#include <cstdint>\n#include <cmath>\n')
cpp.write_text(HARNESS.replace('@METHODS@',methods))
result=subprocess.run([os.environ.get('CXX','g++'),'-std=c++17','-Wall','-Wextra',
'-I',folder,'-I',str(ROOT/'src'),str(cpp),'-o',str(exe)],capture_output=True,text=True)
self.assertEqual(result.returncode,0,result.stderr)
result=subprocess.run([str(exe)],capture_output=True,text=True)
self.assertEqual(result.returncode,0,result.stderr)
if __name__=='__main__': unittest.main()
+5
View File
@@ -21,6 +21,7 @@ HARNESS = r'''
#include <cassert>
#include <cstdint>
#include <initializer_list>
#include <RadioProfiles.h>
#include <helpers/radiolib/NoiseFloorEstimator.h>
#define STATE_IDLE 0
#define STATE_RX 1
@@ -39,6 +40,7 @@ HARNESS = r'''
static volatile uint8_t state = STATE_RX;
static uint32_t now_ms = 0;
uint32_t millis() { return now_ms; }
uint32_t micros() { return now_ms * 1000UL; }
void noInterrupts() {}
void interrupts() {}
void yield() { ++now_ms; }
@@ -56,6 +58,9 @@ struct Radio {
}
};
struct RadioLibWrapper {
mesh::RadioProfiles _profiles;
uint32_t _profile_visit_us = 0;
void serviceProfileScan() {} // separate profile-scan harness exercises tuning
Board board; Board* _board = &board;
Radio radio; Radio* _radio = &radio;
bool _rx_ps_enabled = false, _rx_ps_armed = false, _rx_ps_continuous_fallback = false;
+18 -5
View File
@@ -50,13 +50,18 @@ struct RTC {
uint32_t getCurrentTime() const { return now; }
};
struct Board { int reboots = 0; void reboot() { ++reboots; } };
struct CLI { Board board; Board* getBoard() { return &board; } };
struct CLI {
Board board; Board* getBoard() { return &board; }
CLI& radioProfiles() { return *this; }
bool savePrimaryPreamble(uint16_t) { return true; }
};
struct Radio { uint32_t last_rx = 0; uint32_t getLastRecvMillis() { return last_rx; } };
struct Barrier { bool held = false; bool waiting() const { return held; } void clear() { held = false; } };
struct ScheduledRadioSetting {
bool active = false, temporary = false, started = false;
float freq = 0, bw = 0;
uint8_t sf = 0, cr = 0;
uint16_t preamble = 0;
uint32_t start_time = 0, end_time = 0;
uint64_t hard_end_uptime_millis = 0;
};
@@ -100,7 +105,7 @@ public:
bool millisHasNowPassed(uint32_t deadline) const { return (int32_t)(now_ms - deadline) >= 0; }
bool hasOutbound() const { return outbound; }
bool hasStartedScheduledTempRadio() const { return scheduled_temp_radio_started; }
bool applyRadioParams(float, float, uint8_t, uint8_t) { ++applies; return apply_success; }
bool applyRadioParams(float, float, uint8_t, uint8_t, uint16_t = 0, bool = false) { ++applies; return apply_success; }
bool applySavedRadioParams() { ++restores; return restore_success; }
bool isValidScheduledRadioParams(float, float, uint8_t, uint8_t) { return true; }
void savePrefs() { ++saves; }
@@ -130,13 +135,13 @@ public:
void queueSavedRadioApply();
void refreshScheduledRadioState();
void processScheduledRadioSettings();
void applyTempRadioParams(float, float, uint8_t, uint8_t, int);
void applyTempRadioParams(float, float, uint8_t, uint8_t, int, uint16_t = 0);
bool scheduleNormalRadio();
void clearScheduledRadioSetting(int, bool);
int findFreeScheduledRadioSlot() const;
int countScheduledRadioSettings(bool) const;
bool scheduledRadioConflicts(bool, uint32_t, uint32_t) const;
void addScheduledRadioParams(bool, float, float, uint8_t, uint8_t, uint32_t, uint32_t, char*);
void addScheduledRadioParams(bool, float, float, uint8_t, uint8_t, uint32_t, uint32_t, char*, uint16_t = 0);
void servicePostMeshLoop();
};
@METHODS@
@@ -290,7 +295,14 @@ CLI_HARNESS = r'''
#include <initializer_list>
#include <helpers/RepeaterRadioTiming.h>
#include <helpers/CLICommandUtils.h>
#include <helpers/RadioProfileCommandUtils.h>
namespace mesh {
struct RadioProfileCLI {
static bool parseSuffix(const char* input, unsigned fields, char* legacy, size_t size, uint16_t& preamble) {
return cli::parseRadioPreambleSuffix(input, fields, legacy, size, preamble);
}
bool acceptsPrimary(float, float, uint8_t, uint8_t, uint16_t) { return true; }
};
class Utils { public: static int parseTextParts(char*, const char*[], int, char separator=','); };
@PARTS@
}
@@ -300,7 +312,7 @@ RTC* getRTCClock() { return &rtc; }
struct Callbacks {
int calls = 0;
uint32_t duration = 0;
void applyTempRadioParams(float, float, uint8_t, uint8_t, int minutes) {
void applyTempRadioParams(float, float, uint8_t, uint8_t, int minutes, uint16_t) {
++calls; duration = (uint32_t)minutes * 60;
}
void appendTempRadioTimingNote(char* reply, size_t size, uint32_t seconds) {
@@ -309,6 +321,7 @@ struct Callbacks {
} callbacks;
void appendRxPowerSavingAdjustmentNote(char*, const void*, uint8_t, float) {}
void handle(const char* command, char* reply) {
mesh::RadioProfileCLI _radio_profiles;
char tmp[160];
Callbacks* _callbacks = &callbacks;
const void* _prefs = nullptr;
@@ -1415,3 +1415,5 @@ int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
#include "../fixtures/radio_profiles/mesh_tests.h"