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