Files
MeshCore/test/fixtures/radio_profiles/cli_test.cpp
T

1158 lines
52 KiB
C++

#include <Arduino.h>
#include <helpers/RadioProfileCLI.h>
#include <cassert>
#include <cstdio>
#include <cstring>
#include <algorithm>
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;
float minimum_frequency = 137;
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.freq>=minimum_frequency && 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(bool infrastructure = false) { g_mock_millis=0; cli.begin(&fs,&radio,&clock,infrastructure); }
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(); }
void finishTimingTest(uint32_t switch_us=545) {
const uint32_t start = g_mock_millis - mesh::RadioProfiles::SwitchRecommendationWindowMs;
for (unsigned i=0;i<mesh::RadioProfiles::SwitchTestSamplesPerDirection;++i) {
radio.p.sampleSwitch(0,1,switch_us,start+i);
radio.p.sampleSwitch(1,0,switch_us,start+i);
}
radio.p.sampleSwitch(0,1,switch_us,g_mock_millis);
assert(radio.p.switchTestReady());
assert(radio.p.recommendationReady());
}
};
static void permanentScheduleOrder() {
for (bool fail_first_save : {false, true}) {
Fixture f;
char command[160];
snprintf(command, sizeof(command), "set radioat2 912.5,500,8,5,rx,%lu,80",
(unsigned long)(f.clock.epoch + 120));
f.cmd(command);
snprintf(command, sizeof(command), "set radioat2 910.5,500,8,5,rx,%lu,80",
(unsigned long)(f.clock.epoch + 60));
f.cmd(command);
f.fs.fail_write = fail_first_save;
f.advance(120000); // both are due after sleep or a forward clock correction
if (fail_first_save) {
f.fs.fail_write = false;
f.advance(60000);
}
assert(f.radio.p.secondary.params.freq == 912.5f);
Radio reboot; mesh::RadioProfileCLI restored;
restored.begin(&f.fs, &reboot, &f.clock);
assert(reboot.p.secondary.params.freq == 912.5f);
assert(!strcmp(f.cmd("get radioat2 all"), "> slots: "));
}
}
static void adjacentTemporarySchedules() {
for (bool later_first : {false, true}) {
Fixture f;
const uint32_t epoch = f.clock.epoch;
for (unsigned i = 0; i < 2; ++i) {
const bool later = later_first ? i == 0 : i == 1;
char command[160];
snprintf(command, sizeof(command), "set tempradioat2 %.1f,500,8,5,rxtx,%lu,%lu,80",
later ? 912.5 : 910.5, (unsigned long)(epoch + (later ? 60 : 1)),
(unsigned long)(epoch + (later ? 120 : 60)));
f.cmd(command);
}
f.advance(1000);
assert(f.radio.p.secondary_temporary && f.radio.p.secondary.params.freq == 910.5f);
f.advance(59000);
assert(f.radio.p.secondary_temporary && f.radio.p.secondary.params.freq == 912.5f);
f.advance(59000);
assert(f.radio.p.secondary_temporary && f.radio.p.secondary.params.freq == 912.5f);
f.advance(1000);
assert(!f.radio.p.enabled());
}
}
static void scheduledLeaseStartsAtCommandTime() {
for (bool rollover : {false, true}) {
Fixture f;
if (rollover) { g_mock_millis = UINT32_MAX - 30000; f.cli.loop(); }
g_mock_millis += 10000; f.clock.epoch += 10; // command arrives between loop calls
char command[160];
snprintf(command, sizeof(command), "set tempradioat2 910.5,500,8,5,rx,%lu,%lu,80",
(unsigned long)(f.clock.epoch + 1), (unsigned long)(f.clock.epoch + 60));
f.cmd(command);
f.advance(1000);
assert(f.radio.p.secondary_temporary);
f.advance(49000);
assert(f.radio.p.secondary_temporary); // idle time before the command is not charged to it
f.advance(9000);
assert(f.radio.p.secondary_temporary);
f.advance(1000);
assert(!f.radio.p.enabled());
}
}
static void schedulePermutations() {
int order[] = {0, 1, 2, 3};
do {
for (bool temporary : {false, true}) for (bool late : {false, true}) {
Fixture f;
const uint32_t epoch = f.clock.epoch;
for (int entry : order) {
char command[160];
if (temporary) {
snprintf(command, sizeof(command), "set tempradioat2 %.1f,500,8,5,rx,%lu,%lu,80",
910.5 + entry, (unsigned long)(epoch + 1 + entry * 60),
(unsigned long)(epoch + 61 + entry * 60));
} else {
snprintf(command, sizeof(command), "set radioat2 %.1f,500,8,5,rx,%lu,80",
910.5 + entry, (unsigned long)(epoch + (entry + 1) * 60));
}
f.cmd(command);
}
if (late) {
f.advance(temporary ? 181000 : 240000);
assert(f.radio.p.secondary.params.freq == 913.5f);
} else {
for (int entry = 0; entry < 4; ++entry) {
f.advance(temporary && entry == 0 ? 1000 : 60000);
assert(f.radio.p.secondary.params.freq == 910.5f + entry);
}
}
assert(f.radio.p.secondary_temporary == temporary);
if (temporary) { f.advance(60000); assert(!f.radio.p.enabled()); }
}
} while (std::next_permutation(order, order + 4));
}
static void storageBackoffDoesNotDelayTemporaryWindows() {
Fixture f;
f.cmd("set radio2 910.5,500,8,5,rx,80"); f.advance(2000);
char command[160];
snprintf(command, sizeof(command), "set radioat2 912.5,500,8,5,rx,%lu,80", (unsigned long)(f.clock.epoch + 2));
f.cmd(command);
snprintf(command, sizeof(command), "set tempradioat2 911.5,500,8,5,rx,%lu,%lu,80",
(unsigned long)(f.clock.epoch + 30), (unsigned long)(f.clock.epoch + 90));
f.cmd(command);
f.fs.fail_write = true;
f.advance(2000);
f.advance(28000);
assert(f.radio.p.secondary_temporary && f.radio.p.secondary.params.freq == 911.5f);
f.advance(60000);
assert(!f.radio.p.secondary_temporary && f.radio.p.secondary.params.freq == 910.5f);
f.fs.fail_write = false;
f.advance(60000);
assert(f.radio.p.secondary.params.freq == 912.5f);
}
static void scheduledSaveFailureKeepsOriginalTimes() {
Fixture f;
char command[160];
const uint32_t start = f.clock.epoch + 60;
snprintf(command, sizeof(command), "set radioat2 910.5,500,8,5,rx,%lu,80", (unsigned long)start);
f.cmd(command);
f.fs.fail_write = true;
f.advance(60000);
char timestamp[32]; snprintf(timestamp, sizeof(timestamp), "@%lu", (unsigned long)start);
assert(strstr(f.cmd("get radioat2 all"), timestamp));
f.fs.fail_write = false;
f.advance(59000);
assert(!f.radio.p.enabled());
f.advance(1000);
assert(f.radio.p.secondary.params.freq == 910.5f);
}
static void relativeSchedules() {
Fixture f;
f.cmd("set tempradioat2 910.5,500,8,5,rxtx,+1,+3,80");
f.advance(59000);
assert(!f.radio.p.enabled());
f.advance(1000);
assert(f.radio.p.secondary_temporary);
f.advance(119000);
assert(f.radio.p.secondary_temporary);
f.advance(1000);
assert(!f.radio.p.enabled());
char command[160];
snprintf(command, sizeof(command), "set tempradioat2 911.5,500,8,5,rxtx,+1,%lu,80",
(unsigned long)(f.clock.epoch + 120));
f.cmd(command);
f.advance(60000);
assert(f.radio.p.secondary_temporary && f.radio.p.secondary.params.freq == 911.5f);
f.advance(60000);
assert(!f.radio.p.enabled());
f.cmd("set radioat2 912.5,500,8,5,rx,+1,80");
f.advance(60000);
assert(!f.radio.p.secondary_temporary && f.radio.p.secondary.params.freq == 912.5f);
f.cmd("set radioat2 912.5,500,8,5,rx,+0,80", false);
f.cmd("set radioat2 912.5,500,8,5,rx,+4294967295,80", false);
f.cmd("set tempradioat2 912.5,500,8,5,rx,+3,+1,80", false);
}
static const char* spectrum_warning = "WARN radio2 overlaps radio1 (6kHz guard)";
static void expectSpectrumWarning(const char* reply, bool expected = true) {
if ((strstr(reply, spectrum_warning) != nullptr) != expected) {
fprintf(stderr, "spectrum warning expected=%d: %s\n", expected, reply);
assert(false);
}
}
static float suggestedSpectrumFrequency(const char* reply) {
expectSpectrumWarning(reply);
const char* suggestion = strstr(reply, "; try ");
float frequency = 0;
assert(suggestion && sscanf(suggestion, "; try %fMHz", &frequency) == 1);
assert(std::isfinite(frequency));
return frequency;
}
static void spectrumOverlap() {
// The warning concerns occupied RF spectrum, regardless of SF/CR or which
// profile has the wider bandwidth. The 6 kHz guard is an edge-to-edge gap.
struct Case { float primary_bw, secondary_bw; const char* frequency; bool warn; };
const Case cases[] = {
{62.5f, 62.5f, "909.500000", true},
{62.5f, 62.5f, "909.550000", true},
{62.5f, 62.5f, "909.450000", true},
{125, 31.25f, "909.540000", true}, // radio2 contained in radio1
{31.25f, 125, "909.500000", true}, // radio1 contained in radio2
{62.5f, 62.5f, "909.562500", true}, // exact occupied-band edge touch
{62.5f, 62.5f, "909.437500", true},
{62.5f, 62.5f, "909.565000", true}, // no band overlap; inside guard
{62.5f, 62.5f, "909.435000", true},
{62.5f, 62.5f, "909.568500", true}, // configured decimal guard boundary
{62.5f, 62.5f, "909.431500", true},
{62.5f, 62.5f, "909.570000", false},
{62.5f, 62.5f, "909.430000", false},
{125, 62.5f, "909.599750", true},
{125, 62.5f, "909.400250", true},
{125, 62.5f, "909.601000", false},
{125, 62.5f, "909.399000", false},
};
for (const auto& c : cases) {
Fixture f;
f.radio.p.primary.bw = c.primary_bw;
char command[100];
snprintf(command, sizeof(command), "set radio2 %s,%g,9,7,rx",
c.frequency, c.secondary_bw);
expectSpectrumWarning(f.cmd(command), c.warn);
// A warning cannot reject the request or alter the requested modem tuple.
f.advance(2000);
assert(f.radio.p.secondary.mode == mesh::RadioProfileMode::Rx);
assert(f.radio.p.secondary.params.bw == c.secondary_bw);
assert(f.radio.p.secondary.params.sf == 9 && f.radio.p.secondary.params.cr == 7);
expectSpectrumWarning(f.cmd("get radio2"), c.warn);
if (c.warn) {
const float suggestion = suggestedSpectrumFrequency(f.reply);
const float requested = strtof(c.frequency, nullptr);
if (requested < f.radio.p.primary.freq) assert(suggestion < f.radio.p.primary.freq);
else assert(suggestion > f.radio.p.primary.freq); // same center chooses above
snprintf(command, sizeof(command), "set radio2 %.3f,%g,9,7,rx", suggestion, c.secondary_bw);
expectSpectrumWarning(f.cmd(command), false); // suggested tuple must be safe
f.advance(2000);
assert(f.radio.p.secondary.params.bw == c.secondary_bw);
assert(f.radio.p.secondary.params.sf == 9 && f.radio.p.secondary.params.cr == 7);
} else assert(!strstr(f.reply, "; try "));
}
// These actual float centers round to exactly 17,700 Hz of separation.
// Together with supported 7.8/15.6 kHz bandwidths, they exercise equality
// under the same units users set, rather than a decimal approximation.
for (bool below : {false, true}) for (bool outside : {false, true}) {
Fixture f;
f.radio.p.primary.freq = 900.0f; f.radio.p.primary.bw = 7.8f;
float frequency = below ? 899.9822998046875f : 900.0177001953125f;
if (outside) frequency = std::nextafter(frequency, below ? -INFINITY : INFINITY);
const auto distance = llabs(llround(double(frequency) * 1000000) - 900000000LL);
assert(distance == (outside ? 17761 : 17700));
char command[100];
snprintf(command, sizeof(command), "set radio2 %.9f,15.6,9,7,rx", frequency);
expectSpectrumWarning(f.cmd(command), !outside);
}
// 10.4f is slightly below decimal 10.4: truncating the computed guard
// would lose a whole Hz and miss this inclusive 42,450 Hz boundary.
for (bool outside : {false, true}) {
Fixture f;
f.radio.p.primary.freq = 150.0f; f.radio.p.primary.bw = 10.4f;
float frequency = 150.042449951171875f;
if (outside) frequency = std::nextafter(frequency, INFINITY);
assert(llround(double(frequency) * 1000000) - 150000000LL
== (outside ? 42465 : 42450));
// The compact CLI decimal parser accumulates digits in float. These
// short inputs produce the exact stored floats whose boundary we test.
const char* text = outside ? "150.04247" : "150.04246";
float parsed = 0;
assert(mesh::cli::parseDecimalStrict(text, parsed) && parsed == frequency);
char command[100];
snprintf(command, sizeof(command), "set radio2 %s,62.5,8,7,rx", text);
expectSpectrumWarning(f.cmd(command), !outside);
}
{
Fixture f;
f.radio.p.primary.freq = 910.525f;
f.cmd("set radio2 910.525,62.5,8,7,rx");
assert(strstr(f.reply, "; try 910.594MHz"));
expectSpectrumWarning(f.cmd("set radio2 910.594,62.5,8,7,rx"), false);
}
for (const char* frequency : {"909.500000", "909.450000"}) {
Fixture f;
char command[100];
snprintf(command, sizeof(command), "set radio2 %s,62.5,8,7,rx", frequency);
f.cmd(command);
// Nearest outward whole-kHz center, with the requested bandwidth retained.
assert(strstr(f.reply, !strcmp(frequency, "909.500000")
? "; try 909.569MHz" : "; try 909.431MHz"));
}
{
Fixture f;
f.radio.p.primary.bw = 125;
f.cmd("set radio2 909.540,31.25,8,7,rx");
assert(strstr(f.reply, "; try 909.585MHz"));
}
for (float minimum : {137.0f, 150.0f}) {
Fixture f;
// Cover the shared parser limit and a stricter physical-radio limit.
f.radio.minimum_frequency = minimum;
f.radio.p.primary.freq = minimum + 0.01f; f.radio.p.primary.bw = 31.25f;
char command[100];
snprintf(command, sizeof(command), "set radio2 %.3f,31.25,8,7,rx", minimum + 0.001f);
f.cmd(command);
const float frequency = suggestedSpectrumFrequency(f.reply);
assert(frequency > f.radio.p.primary.freq); // lower suggestion violates radio limit
snprintf(command, sizeof(command), "set radio2 %.3f,31.25,8,7,rx", frequency);
expectSpectrumWarning(f.cmd(command), false);
}
{
Fixture f;
expectSpectrumWarning(f.cmd("set radio2 909.5,62.5,7,5,rxtx"));
const char* warning = strstr(f.reply, spectrum_warning);
const char* timing = strstr(f.reply, "timing self-test pending");
assert(timing && warning < timing); // warning is visible before calibration
assert(!f.radio.p.enabled()); // the normal 2-second delay is retained
expectSpectrumWarning(f.cmd("get radio2"));
f.advance(2000);
f.finishTimingTest();
expectSpectrumWarning(f.cmd("get radio2"));
assert(!strstr(f.reply, "timing self-test pending"));
expectSpectrumWarning(f.cmd("set radio2 off"), false);
expectSpectrumWarning(f.cmd("get radio2"), false);
expectSpectrumWarning(f.cmd("set radio2 909.5,63,7,5,rx", false), false);
expectSpectrumWarning(f.cmd("set radio2 909.5,62.5,13,5,rx", false), false);
}
{
Fixture f;
f.fs.fail_write = true;
expectSpectrumWarning(f.cmd("set radio2 909.5,62.5,7,5,rx", false), false);
assert(!f.radio.p.enabled());
assert(!f.cli.hasReplyMutation());
}
{
Fixture f;
f.cmd("set radio2 911.5,62.5,7,5,rx"); f.advance(2000);
expectSpectrumWarning(f.cmd("set tempradio2 909.5,62.5,8,7,rxtx,1"));
assert(f.radio.p.secondary.params.freq == 911.5f);
expectSpectrumWarning(f.cmd("get tempradio2")); // pending temporary tuple
expectSpectrumWarning(f.cmd("get radio2"), false); // saved tuple is separate
f.advance(2000);
expectSpectrumWarning(f.cmd("get tempradio2"));
assert(f.radio.p.secondary_temporary);
assert(f.radio.p.secondary.params.freq == 909.5f);
f.advance(60000);
expectSpectrumWarning(f.cmd("get tempradio2"), false);
assert(f.radio.p.secondary.params.freq == 911.5f);
}
for (bool temporary : {false, true}) {
Fixture f;
const char* set = temporary ? "set tempradioat2 909.5,62.5,8,7,rx,+1,+2"
: "set radioat2 909.5,62.5,8,7,rx,+1";
const char* get = temporary ? "get tempradioat2 1" : "get radioat2 1";
expectSpectrumWarning(f.cmd(set));
expectSpectrumWarning(f.cmd(get));
if (temporary) {
char reply[158]; memset(reply, 0x55, sizeof(reply));
assert(f.cli.handle(get, reply, 157));
assert(static_cast<unsigned char>(reply[157]) == 0x55);
assert(memchr(reply, 0, 157));
char timestamps[48];
snprintf(timestamps, sizeof(timestamps), "@%lu-%lu",
(unsigned long)(f.clock.epoch + 60),
(unsigned long)(f.clock.epoch + 120));
assert(strstr(reply, timestamps));
expectSpectrumWarning(reply);
assert(strstr(reply, "; try 909.569MHz"));
}
assert(!f.radio.p.enabled()); // warning does not activate a future schedule
f.advance(60000);
assert(f.radio.p.secondary.params.freq == 909.5f);
assert(f.radio.p.secondary_temporary == temporary);
}
for (bool temporary : {false, true}) for (bool delivered : {false, true}) {
Fixture f(true);
f.cmd("set radio2 911.5,62.5,7,5,rx"); f.advance(2000);
const auto saved = f.fs.files["/radio_profiles"];
f.cli.beginReplyCommand();
expectSpectrumWarning(f.cmd(temporary ? "set tempradio2 909.5,62.5,8,7,rxtx,1"
: "set radio2 909.5,62.5,8,7,rxtx"));
f.cli.endReplyCommand();
assert(f.cli.hasReplyMutation());
assert(f.fs.files["/radio_profiles"] == saved);
f.advance(2000);
assert(f.radio.p.secondary.params.freq == 911.5f); // old RF retained for ACK
assert(f.cli.finishReplyMutation(delivered));
f.advance(1);
assert(!f.cli.hasReplyMutation());
assert(f.radio.p.secondary.params.freq == (delivered ? 909.5f : 911.5f));
}
#if MESH_ENABLE_FLEET_CONTROL
for (bool temporary : {false, true}) {
Fixture f(true);
f.cli.beginReplyCommand();
expectSpectrumWarning(f.cmd(temporary ? "set tempradioat2 909.5,62.5,8,7,rx,+1,+2"
: "set radioat2 909.5,62.5,8,7,rx,+1"));
f.cli.endReplyCommand();
assert(f.cli.hasReplyMutation());
const char* get = temporary ? "get tempradioat2 1" : "get radioat2 1";
assert(!strcmp(f.cmd(get), "> off"));
assert(f.cli.finishReplyMutation(true)); f.advance(1);
expectSpectrumWarning(f.cmd(get));
assert(!f.radio.p.enabled());
f.advance(60000);
assert(f.radio.p.secondary.params.freq == 909.5f);
}
#endif
{
Fixture f;
f.cmd("set radio2 909.5,62.5,8,7,rxtx");
// The public command path must retain terminators and adjacent bytes for
// every response budget, including the CLI's tagged 157-byte response.
for (size_t capacity = 0; capacity <= 160; ++capacity) {
char bytes[162]; memset(bytes, 0x55, sizeof(bytes));
assert(f.cli.handle("get radio2", bytes, capacity));
assert(static_cast<unsigned char>(bytes[capacity]) == 0x55);
if (capacity) assert(memchr(bytes, 0, capacity));
if (capacity >= 157) expectSpectrumWarning(bytes);
}
}
// Reapply the literal displayed suggestion through the actual CLI parser.
// Its float fractional accumulation can differ from a double-to-float
// candidate, especially at high frequencies and near whole-kHz edges.
unsigned roundtrips = 0;
for (float base : {150.125f, 433.125f, 868.125f, 915.125f, 2400.125f}) {
for (float bw : {7.8f, 10.4f, 15.6f, 20.8f, 31.25f,
41.7f, 62.5f, 125.0f, 250.0f, 500.0f}) {
for (float offset : {0.0f, 0.000001f, 0.000499f, 0.000500f, 0.000501f,
0.000999f, 0.001000f, 0.001001f, 0.001499f, 0.001501f}) {
for (bool below : {false, true}) {
Fixture f;
f.radio.p.primary.freq = base + offset;
f.radio.p.primary.bw = bw;
const float requested = f.radio.p.primary.freq + (below ? -0.001f : 0.001f);
char command[100];
snprintf(command, sizeof(command), "set radio2 %.6f,%g,7,5,rx", requested, bw);
expectSpectrumWarning(f.cmd(command));
const char* begin = strstr(f.reply, "; try ");
assert(begin); begin += strlen("; try ");
const char* end = strstr(begin, "MHz");
assert(end && end - begin < 24);
char suggestion[24];
memcpy(suggestion, begin, end - begin); suggestion[end - begin] = 0;
const char* decimal = strchr(suggestion, '.');
assert(decimal && strlen(decimal + 1) == 3);
snprintf(command, sizeof(command), "set radio2 %s,%g,7,5,rx", suggestion, bw);
f.cmd(command);
if (strstr(f.reply, spectrum_warning)) {
fprintf(stderr, "Unsafe suggestion: primary=%.12f BW=%g requested=%.12f suggestion=%s\n",
f.radio.p.primary.freq, bw, requested, suggestion);
}
expectSpectrumWarning(f.reply, false);
++roundtrips;
}
}
}
}
assert(roundtrips == 1000);
}
static void mediaMeshPresets() {
for (bool infrastructure : {false, true}) {
for (bool range : {false, true}) {
Fixture f(infrastructure);
const auto primary = f.radio.p.primary;
assert(!f.radio.p.enabled() && f.radio.p.cross == mesh::RadioCrossMode::Off);
const char* command = range ? "set radio2 mediamesh-range" : "set radio2 mediamesh";
f.cmd(command);
assert(!f.radio.p.enabled()); // Same delayed apply as a saved tuple.
f.advance(2000);
const auto config = f.radio.p.secondary;
assert(config.mode == mesh::RadioProfileMode::RxTx);
assert(fabsf(config.params.freq - 911.150f) < 0.0001f
&& config.params.bw == (range ? 125 : 250));
assert(config.params.sf == 7 && config.params.cr == 5 && !config.params.preamble);
assert(config.params.protocol == mesh::RadioProfileProtocol::MeshCore);
assert(f.radio.p.primary == primary && f.radio.p.cross == mesh::RadioCrossMode::Off);
assert(!f.fs.files["/radio_profiles"][14] && !f.fs.files["/radio_profiles"][15]);
Radio reboot; mesh::RadioProfileCLI restored;
restored.begin(&f.fs, &reboot, &f.clock, infrastructure);
assert(reboot.p.secondary.params == config.params);
assert(reboot.p.secondary.mode == mesh::RadioProfileMode::RxTx);
assert(reboot.p.cross == mesh::RadioCrossMode::Off);
// A preset replaces an old preamble override but retains operator crossover.
f.cmd("set radio2.cross on");
f.cmd("set radio2 912.5,500,8,5,rxtx,80"); f.advance(2000);
f.cmd(command); f.advance(2000);
assert(!f.radio.p.secondary.params.preamble && f.radio.p.cross == mesh::RadioCrossMode::On);
const auto committed = f.fs.files["/radio_profiles"];
f.fs.fail_write = true;
f.cmd(range ? "set radio2 mediamesh" : "set radio2 mediamesh-range", false);
assert(f.radio.p.secondary.params == config.params
&& f.fs.files["/radio_profiles"] == committed);
}
}
Fixture invalid;
for (const char* command : {"set radio2 mediamesh-extra", "set radio2 mediamesh,32",
"set tempradio2 mediamesh", "set radioat2 mediamesh"}) {
invalid.cmd(command, false);
assert(!invalid.radio.p.enabled() && invalid.fs.files.empty());
}
invalid.radio.minimum_frequency = 912;
invalid.cmd("set radio2 mediamesh", false); // The hardware validator still applies.
assert(!invalid.radio.p.enabled() && invalid.fs.files.empty());
}
struct MediaScope {
bool available = true, fail_save = false, enabled = false, durable = false;
unsigned checks = 0, commits = 0;
static bool update(void* context, bool enable, bool commit) {
auto& self = *static_cast<MediaScope*>(context);
if (!commit) { ++self.checks; return !enable || self.available; }
++self.commits;
if (enable && !self.available) return false;
self.enabled = enable;
if (self.fail_save) return false;
self.durable = enable;
return true;
}
};
static void mediaMeshScopeLifecycle() {
for (const char* command : {"set radio2 mediamesh", "set radio2 mediamesh-range"}) {
Fixture f(true);
MediaScope scope;
f.cli.setMediaMeshScopeHandler(MediaScope::update, &scope);
assert(!scope.enabled && !scope.durable);
const auto before = f.fs.files;
const unsigned commits = scope.commits;
scope.available = false;
assert(strstr(f.cmd(command, false), "region unavailable or full"));
assert(f.fs.files == before && scope.commits == commits);
scope.available = true;
f.fs.fail_write = true;
f.cmd(command, false);
assert(!scope.enabled && scope.commits == commits);
f.fs.fail_write = false;
scope.fail_save = true;
assert(strstr(f.cmd(command), "scope save pending"));
assert(scope.enabled && !scope.durable);
assert(strstr(f.cmd("get radio2.status"), "scope save pending"));
const unsigned pending_commits = scope.commits;
f.advance(999);
assert(scope.commits == pending_commits);
scope.fail_save = false;
f.advance(1);
assert(scope.durable && scope.commits == pending_commits+1);
assert(!strstr(f.cmd("get radio2.status"), "scope save pending"));
// An interrupted region save is repaired from the committed radio2 preset
// only after its saved region map has been supplied by infrastructure.
Radio reboot;
mesh::RadioProfileCLI restored;
restored.begin(&f.fs, &reboot, &f.clock, true);
MediaScope loaded;
restored.setMediaMeshScopeHandler(MediaScope::update, &loaded);
assert(loaded.enabled && loaded.durable);
// Explicit RF tuning after a preset keeps the region's saved permission.
const unsigned enabled_commits = scope.commits;
f.cmd("set radio2 912.5,250,8,5,rxtx");
assert(scope.enabled && scope.durable && scope.commits == enabled_commits);
Radio custom_radio;
mesh::RadioProfileCLI custom;
custom.begin(&f.fs, &custom_radio, &f.clock, true);
const unsigned loaded_commits = loaded.commits;
custom.setMediaMeshScopeHandler(MediaScope::update, &loaded);
assert(loaded.enabled && loaded.durable && loaded.commits == loaded_commits);
f.cmd("set radio2 off");
assert(!scope.enabled && !scope.durable);
f.cmd(command);
assert(scope.enabled && scope.durable);
// Off wins after a reboot even if the old region save was interrupted.
scope.fail_save = true;
assert(strstr(f.cmd("set radio2 off"), "scope save pending"));
assert(!scope.enabled && scope.durable);
Radio off_radio;
mesh::RadioProfileCLI off;
off.begin(&f.fs, &off_radio, &f.clock, true);
loaded.enabled = loaded.durable = true;
off.setMediaMeshScopeHandler(MediaScope::update, &loaded);
assert(!loaded.enabled && !loaded.durable);
}
for (bool delivered : {false, true}) {
Fixture f(true);
MediaScope scope;
f.cli.setMediaMeshScopeHandler(MediaScope::update, &scope);
const auto before = f.fs.files;
const unsigned commits = scope.commits;
f.cli.beginReplyCommand();
f.cmd("set radio2 mediamesh");
f.cli.endReplyCommand();
assert(!scope.enabled && scope.commits == commits);
f.advance(2000);
assert(!scope.enabled && scope.commits == commits);
f.cli.finishReplyMutation(delivered);
f.advance(1);
assert(scope.enabled == delivered && scope.durable == delivered);
if (!delivered) assert(f.fs.files == before);
if (delivered) {
f.cli.beginReplyCommand(); f.cmd("set radio2 off"); f.cli.endReplyCommand();
assert(scope.enabled);
f.cli.finishReplyMutation(false); f.advance(1);
assert(scope.enabled && scope.durable);
f.cli.beginReplyCommand(); f.cmd("set radio2 off"); f.cli.endReplyCommand();
f.cli.finishReplyMutation(true); f.advance(1);
assert(!scope.enabled && !scope.durable);
}
}
// Capacity can change while the old profile is retained for a remote reply.
Fixture f(true);
MediaScope scope;
f.cli.setMediaMeshScopeHandler(MediaScope::update, &scope);
const auto before = f.fs.files;
f.cli.beginReplyCommand(); f.cmd("set radio2 mediamesh"); f.cli.endReplyCommand();
scope.available = false;
f.cli.finishReplyMutation(true); f.advance(1);
assert(f.cli.hasReplyMutation() && !scope.enabled);
assert(f.fs.files.find("/radio_profiles") == f.fs.files.end());
scope.available = true;
f.advance(1000);
assert(!f.cli.hasReplyMutation() && scope.enabled && scope.durable);
// Unknown radio intent must not disable an existing scope during recovery.
const auto valid_image = f.fs.files["/radio_profiles"];
for (bool unreadable : {false, true}) {
f.fs.files["/radio_profiles"] = valid_image;
if (unreadable) f.fs.fail_read_open = true;
else f.fs.files["/radio_profiles"].resize(1);
Radio held_radio;
mesh::RadioProfileCLI held;
held.begin(&f.fs, &held_radio, &f.clock, true);
MediaScope held_scope;
held_scope.enabled = held_scope.durable = true;
held.setMediaMeshScopeHandler(MediaScope::update, &held_scope);
assert(held_scope.enabled && held_scope.durable && !held_scope.commits);
held.loop();
assert(held_scope.enabled && !held_scope.commits);
f.fs.fail_read_open = false;
}
}
int main(int argc, char** argv) {
static_assert(uint8_t(mesh::RadioCrossMode::Auto)==0 && uint8_t(mesh::RadioCrossMode::On)==1
&& uint8_t(mesh::RadioCrossMode::Off)==2, "saved crossover encoding must remain compatible");
{
Fixture fresh;
assert(fresh.radio.p.cross==mesh::RadioCrossMode::Off && !fresh.radio.p.canCross());
assert(!strcmp(fresh.cmd("get radio2.cross"),"> off") && fresh.fs.files.empty());
for(const char* mode:{"auto","on","off"}) {
char command[40];snprintf(command,sizeof(command),"set radio2.cross %s",mode);fresh.cmd(command);
const auto expected=!strcmp(mode,"auto")?mesh::RadioCrossMode::Auto
: !strcmp(mode,"on")?mesh::RadioCrossMode::On:mesh::RadioCrossMode::Off;
assert(fresh.fs.files["/radio_profiles"][18]==uint8_t(expected));
Radio reboot;mesh::RadioProfileCLI restored;restored.begin(&fresh.fs,&reboot,&fresh.clock);
assert(reboot.p.cross==expected);
restored.handle("get radio2.cross",fresh.reply);
char reply[16];snprintf(reply,sizeof(reply),"> %s",mode);assert(!strcmp(fresh.reply,reply));
}
fresh.cmd("set radio2.cross on");fresh.fs.files.clear();
fresh.cli.begin(&fresh.fs,&fresh.radio,&fresh.clock);
assert(fresh.radio.p.cross==mesh::RadioCrossMode::Off);
assert(!strcmp(fresh.cmd("get radio2.cross"),"> off"));
}
for(bool semantic:{false,true}) {
Fixture invalid;invalid.cmd("set radio2.cross auto");
auto& image=invalid.fs.files["/radio_profiles"];
if(semantic) {
image[18]=255;
uint32_t crc=0xffffffffU;
for(size_t i=0;i<20;++i) {crc^=image[i];for(unsigned bit=0;bit<8;++bit)crc=(crc>>1)^((crc&1)?0xedb88320U:0);}
memcpy(image.data()+20,&crc,4);
} else image[0]^=1;
Radio radio;radio.p.cross=mesh::RadioCrossMode::On;
mesh::RadioProfileCLI restored;restored.begin(&invalid.fs,&radio,&invalid.clock);
assert(radio.p.cross==mesh::RadioCrossMode::Off);
restored.handle("get radio2.cross",invalid.reply);assert(!strcmp(invalid.reply,"> off"));
}
if (argc == 2) {
if (!strcmp(argv[1], "permanent_order")) permanentScheduleOrder();
else if (!strcmp(argv[1], "temporary_boundary")) adjacentTemporarySchedules();
else if (!strcmp(argv[1], "lease_creation")) scheduledLeaseStartsAtCommandTime();
else if (!strcmp(argv[1], "save_failure")) scheduledSaveFailureKeepsOriginalTimes();
else if (!strcmp(argv[1], "schedule_permutations")) schedulePermutations();
else if (!strcmp(argv[1], "storage_isolation")) storageBackoffDoesNotDelayTemporaryWindows();
else if (!strcmp(argv[1], "relative_schedule")) relativeSchedules();
else if (!strcmp(argv[1], "spectrum_overlap")) spectrumOverlap();
else if (!strcmp(argv[1], "mediamesh")) mediaMeshPresets();
else if (!strcmp(argv[1], "media_scope")) mediaMeshScopeLifecycle();
else return 2;
return 0;
}
for (bool damaged_primary : {false, true}) {
Fixture f(true);
f.cmd("set radio2 910.5,500,8,5,rxtx,80");
f.cmd("set radio2.cross off");
f.cmd("set tx.reply radio2 force");
assert(f.cli.savePrimaryPreamble(120));
assert(f.fs.rename("/radio_profiles", "/radio_profiles.bak"));
const auto backup = f.fs.files["/radio_profiles.bak"];
if (damaged_primary) {
f.fs.files["/radio_profiles"] = {0};
f.fs.fail_remove = true;
} else {
f.fs.fail_rename = 1;
}
Radio radio; mesh::RadioProfileCLI restored;
restored.begin(&f.fs, &radio, &f.clock, true);
// The verified backup stays usable even when storage cannot repair it.
assert(radio.p.enabled());
assert(radio.p.secondary.params.freq == 910.5f);
assert(radio.p.secondary.params.preamble == 80);
assert(restored.primaryPreamble() == 120);
assert(radio.p.cross == mesh::RadioCrossMode::Off);
assert(radio.p.reply_tx == mesh::RADIO_TX_SECONDARY && radio.p.reply_force);
f.fs.fail_remove = false;
assert(restored.handle("set radio2 off", f.reply));
assert(strstr(f.reply, "Error"));
assert(f.fs.files["/radio_profiles.bak"] == backup);
}
{
Fixture f;
f.cmd("set radio2 910.5,500,8,5,rxtx,80"); f.advance(2000);
const auto committed = f.fs.files["/radio_profiles"];
f.fs.fail_rename_from = {"/radio_profiles.tmp", "/radio_profiles.bak"};
f.cmd("set radio2 off", false); // both publication and rollback fail
assert(f.radio.p.enabled());
assert(f.fs.files["/radio_profiles.bak"] == committed);
f.fs.fail_rename_from.clear();
f.cmd("set radio2.cross on", false); // protect the sole committed copy
assert(f.fs.files["/radio_profiles.bak"] == committed);
Radio radio; mesh::RadioProfileCLI restored;
restored.begin(&f.fs, &radio, &f.clock);
assert(radio.p.enabled() && radio.p.secondary.params.preamble == 80);
assert(restored.handle("set radio2 off", f.reply));
assert(!strncmp(f.reply, "OK", 2)); // reboot repairs storage and releases the hold
}
{
Fixture f(true);
f.cmd("set tx.reply off");
// Simulate an image whose CRC is valid but whose saved mode is invalid.
auto& image=f.fs.files["/radio_profiles"];
image[3]=9;
uint32_t crc=0xffffffffU;
for (size_t i=0;i<20;++i) {
crc ^= image[i];
for (unsigned bit=0;bit<8;++bit) crc=(crc>>1)^((crc&1)?0xedb88320U:0);
}
memcpy(image.data()+20,&crc,4);
const auto damaged=image;
Radio radio; mesh::RadioProfileCLI restored;
restored.begin(&f.fs,&radio,&f.clock,true);
restored.loop();
assert(radio.p.reply_tx==mesh::RADIO_TX_BOTH && !radio.p.reply_force);
assert(!radio.p.enabled());
assert(restored.handle("set radio2.cross on",f.reply));
assert(!strcmp(f.reply,"OK"));
assert(radio.p.cross==mesh::RadioCrossMode::On);
assert(restored.handle("set tempradio2 911.3,500,8,7,rxtx,2",f.reply));
assert(!strncmp(f.reply,"OK",2));
g_mock_millis+=2000; restored.loop();
assert(radio.p.secondary_temporary && radio.p.canCross());
Radio reboot; mesh::RadioProfileCLI repaired;
repaired.begin(&f.fs,&reboot,&f.clock,true);
assert(reboot.p.cross==mesh::RadioCrossMode::On);
}
{
Fixture companion;
assert(companion.radio.p.reply_tx==mesh::RADIO_TX_AUTO);
assert(!companion.cli.handle("set tx.reply both force",companion.reply));
Fixture f(true);
assert(strstr(f.cmd("get tx.reply"),"> both; radio2 TX unavailable"));
f.cmd("set radio2 910.5,500,7,5,rx"); f.advance(2000);
f.cmd("set tx.reply both force");
assert(!strcmp(f.cmd("get tx.reply"),"> both force"));
assert(f.radio.p.reply_tx==mesh::RADIO_TX_BOTH && f.radio.p.reply_force);
assert(f.radio.p.secondary.mode==mesh::RadioProfileMode::Rx);
assert(f.fs.files["/radio_profiles"].size()==24);
// Every legal choice survives reboot, including explicit AUTO (distinct
// from the zero reserved byte in firmware predating reply routing).
const char* modes[]={"auto","radio","radio2","both","off","radio2 force","both force"};
const uint8_t policies[]={0,1,2,3,4,2,3};
for (unsigned i=0;i<7;++i) {
char command[64]; snprintf(command,sizeof(command),"set tx.reply %s",modes[i]);
f.cmd(command);
Radio reboot; mesh::RadioProfileCLI restored;
restored.begin(&f.fs,&reboot,&f.clock,true);
assert(reboot.p.reply_tx==policies[i] && reboot.p.reply_force==(i>=5));
}
f.fs.fail_write=true;
f.cmd("set tx.reply off",false);
assert(f.radio.p.reply_tx==mesh::RADIO_TX_BOTH && f.radio.p.reply_force);
f.fs.fail_write=false; f.fs.fail_rename=2;
f.cmd("set tx.reply off",false);
Radio reboot; mesh::RadioProfileCLI restored;
restored.begin(&f.fs,&reboot,&f.clock,true);
assert(reboot.p.reply_tx==mesh::RADIO_TX_BOTH && reboot.p.reply_force);
const char* invalid[]={"", "both junk", "auto force", "radio force", "off force",
"both force extra", "force", "bothforce", "radio2345678901234567"};
for (const char* args:invalid) {
char command[80]; snprintf(command,sizeof(command),"set tx.reply %s",args);
f.cmd(command,false);
assert(f.radio.p.reply_tx==mesh::RADIO_TX_BOTH && f.radio.p.reply_force);
}
assert(strstr(f.cmd("get tx.reply extra"),"Error"));
// A Companion neither activates nor erases infrastructure reply choices
// when it saves another profile setting.
Radio other; mesh::RadioProfileCLI other_cli;
other_cli.begin(&f.fs,&other,&f.clock);
assert(other.p.reply_tx==mesh::RADIO_TX_AUTO && !other.p.reply_force);
assert(other_cli.handle("set radio2.cross on",f.reply));
Radio back; mesh::RadioProfileCLI back_cli;
back_cli.begin(&f.fs,&back,&f.clock,true);
assert(back.p.reply_tx==mesh::RADIO_TX_BOTH && back.p.reply_force);
// Old images have a zero reserved byte and adopt BOTH on infrastructure.
Fixture legacy; legacy.cmd("set radio2 910.5,500,7,5,rxtx");
assert(legacy.fs.files["/radio_profiles"][19]==0);
Radio upgrade; mesh::RadioProfileCLI upgraded;
upgraded.begin(&legacy.fs,&upgrade,&legacy.clock,true);
assert(upgrade.p.reply_tx==mesh::RADIO_TX_BOTH && !upgrade.p.reply_force);
}
{
Fixture f;
assert(!strcmp(f.cmd("get radio2"),"> off"));
assert(!strcmp(f.cmd("get radio2.cross"),"> off"));
assert(strstr(f.cmd("set radio2 910.5,500,7,5,rxtx"),"timing self-test pending"));
assert(!f.radio.p.enabled()); f.advance(2000); assert(f.radio.p.enabled());
assert(strstr(f.cmd("get radio2.timing"),"self-test pending"));
f.finishTimingTest();
assert(strstr(f.cmd("get radio2"),"rxtx,64 (auto)"));
assert(strstr(f.reply,"switch=600us"));
assert(strstr(f.cmd("set radio2 910.5,500,7,5,rxtx"),"preamble=64;"));
assert(strstr(f.reply,"switch=600us"));
assert(strstr(f.cmd("get radio2.scan"),"slow=radio; listen_us=9421,22147"));
assert(strstr(f.cmd("get radio2.timing"),"chirps=4.60,86.51; need=32,64"));
assert(strstr(f.reply,"switch=600us"));
assert(strstr(f.reply,"loop=0us"));
assert(!strstr(f.reply,"estimate"));
assert(strstr(f.reply,"WARN recommended preamble: radio2=64"));
char timing_reply[160]; strcpy(timing_reply, f.reply);
assert(!strcmp(f.cmd("get radio.timing"),timing_reply));
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;
f.cmd("set radio2 910.5,500,7,5,rx");
f.advance(2000);
for (unsigned i=0;i<mesh::RadioProfiles::SwitchTestSamplesPerDirection;++i) {
f.radio.p.sampleSwitch(0,1,545,g_mock_millis);
f.radio.p.sampleSwitch(1,0,545,g_mock_millis);
}
assert(f.radio.p.switchTestReady() && !f.radio.p.recommendationReady());
const auto wire_preamble = f.radio.p.preamble(1,32);
assert(wire_preamble==64 && f.radio.p.switchBudgetUs()==600);
assert(strstr(f.cmd("get radio2"),"rx,64 (auto)"));
assert(strstr(f.reply,"wait=120s") && !strstr(f.reply,"WARN"));
assert(strstr(f.cmd("get radio2.timing"),"provisional switch=600us"));
assert(!strstr(f.reply,"need=") && !strstr(f.reply,"WARN"));
f.advance(119999);
f.radio.p.sampleSwitch(0,1,545,g_mock_millis);
assert(!f.radio.p.recommendationReady());
assert(strstr(f.cmd("get radio2.timing"),"wait=1s"));
f.advance(1);
assert(!f.radio.p.recommendationReady()); // No synthetic retune at deadline.
f.radio.p.sampleSwitch(1,0,545,g_mock_millis);
assert(f.radio.p.recommendationReady());
assert(f.radio.p.preamble(1,32)==wire_preamble && f.radio.p.switchBudgetUs()==600);
assert(strstr(f.cmd("get radio2.timing"),"need=32,64"));
assert(strstr(f.reply,"WARN recommended preamble: radio2=64"));
assert(!strstr(f.reply,"timing self-test pending"));
// The UI delay never changes ongoing maximum-based timing policy.
f.radio.p.sampleSwitch(0,1,8428,g_mock_millis);
assert(f.radio.p.switchBudgetUs()==9271);
assert(strstr(f.cmd("get radio2.timing"),"switch=9271us"));
f.cmd("set radio2 910.5,500,7,5,rx");
assert(f.radio.p.recommendationReady()); // No-op leaves the window intact.
f.cmd("set radio2 911.5,500,7,5,rx");
f.advance(2000);
assert(!f.radio.p.recommendationReady());
assert(strstr(f.cmd("get radio2.timing"),"wait=120s"));
}
{
Fixture f;
assert(strstr(f.cmd("set radio2 910.5,500,7,5,rx"),"timing self-test pending"));
assert(!strstr(f.reply,"switch="));
f.advance(2000);
f.finishTimingTest(497); // 497 us observed with a 10% guard -> 547 us.
assert(f.radio.p.switchBudgetUs()==547);
assert(strstr(f.cmd("get radio2"),"switch=547us"));
assert(strstr(f.cmd("set radio2 910.5,500,7,5,rx"),"switch=547us"));
assert(strstr(f.cmd("get radio2.timing"),"switch=547us"));
assert(!strstr(strstr(f.reply,"switch=")+1,"switch="));
assert(strstr(f.cmd("get radio2.status"),"budget=547us"));
assert(!strstr(f.reply,"switch=547us"));
assert(strstr(f.cmd("set radio2 911.5,500,7,5,rx"),"timing self-test pending"));
assert(!strstr(f.reply,"switch=547us"));
}
{
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);
f.cmd("set radio2.timing 1",false);
f.cmd("set radio.timing 1",false);
assert(strstr(f.cmd("get radio2.timing extra"),"read-only"));
assert(!f.cli.handle("set radio2junk 910.5",f.reply));
}
{
Fixture f;
assert(!strcmp(f.cmd("get radio2.timing"),"> off"));
// A short explicit preamble is not rewritten, and cannot hide a warning.
assert(strstr(f.cmd("set radio2 910.5,500,7,5,rx,32"),"timing self-test pending"));
f.advance(2000);
f.finishTimingTest();
assert(f.radio.p.secondary.params.preamble==32);
assert(strstr(f.cmd("get radio2"),"short override: radio2"));
f.radio.p.sampleSwitch(0,1,8428);
assert(strstr(f.cmd("get radio2.timing"),"switch=9271us"));
assert(strstr(f.reply,"WARN recommended preamble: radio2="));
assert(f.radio.p.secondary.params.preamble==32);
// Primary getters use the saved primary's SF/BW instead of a temporary one.
f.radio.p.primary.sf=10; f.radio.p.primary.bw=125;
strcpy(f.reply,"> radio");
f.cli.appendSavedPreamble(f.reply,sizeof(f.reply),7,62.5);
assert(strstr(f.reply,"timing self-test pending"));
// Bounded appenders cannot corrupt neighboring bytes, even with tiny replies.
for (size_t n=1;n<=160;++n) {
char bytes[162]; memset(bytes,0x55,sizeof(bytes)); bytes[0]=0;
mesh::RadioProfileCLI::appendChirpWarning(bytes,n,f.radio.p);
assert(memchr(bytes,0,n)); assert(bytes[n]==0x55);
}
f.radio.p.primary.sf=10;f.radio.p.primary.bw=125;
f.radio.p.secondary.params.sf=10;f.radio.p.secondary.params.bw=125;
f.radio.p.resetSwitchTest(); f.finishTimingTest();
assert(!strstr(f.cmd("get radio2.timing"),"WARN"));
// A selected value below 32 still gets the actual recommendation, not
// merely a warning label with no preamble to use.
assert(strstr(f.cmd("set radio2 910.5,125,10,5,rx,16"),"timing self-test pending"));
}
{
Fixture f;
f.radio.p.primary.sf=7;f.radio.p.primary.bw=500;
assert(strstr(f.cmd("set radio2 910.5,62.5,7,5,rx"),"timing self-test pending"));
f.advance(2000);
f.finishTimingTest();
assert(strstr(f.cmd("get radio2.timing"),"need=64,32"));
strcpy(f.reply,"OK - reboot to apply");
f.cli.appendPrimaryChirpWarning(f.reply,sizeof(f.reply),7,500,32);
assert(strstr(f.reply,"timing self-test pending"));
strcpy(f.reply,"OK - reboot to apply");
f.cli.appendPrimaryChirpWarning(f.reply,sizeof(f.reply),7,62.5,0);
assert(!strstr(f.reply,"WARN")); // Preview requested tuple, not the live one.
f.radio.p.primary.sf=5;f.radio.p.primary.bw=500;
f.radio.p.secondary.params.sf=5;f.radio.p.secondary.params.bw=500;
assert(strstr(f.cmd("get radio2.timing"),"WARN recommended preamble: radio="));
assert(strstr(f.reply,",radio2="));
}
{
Fixture f;
// The tested pair is allocated automatically without changing explicit 32.
assert(strstr(f.cmd("set radio2 910.5,500,8,5,rx,32"),"timing self-test pending"));
f.advance(2000);
f.finishTimingTest();
assert(f.radio.p.secondary.params.preamble==32);
assert(strstr(f.cmd("get radio2.scan"),"slow=radio; listen_us=9421,22147"));
assert(strstr(f.cmd("get radio2.timing"),"chirps=4.60,43.26; need=32,40"));
assert(strstr(f.reply,"loop=0us"));
}
{
Fixture f;
f.radio.p.primary.bw=500;
assert(strstr(f.cmd("set radio2 910.5,500,7,5,rx"),"timing self-test pending"));
f.advance(2000);
f.finishTimingTest(8428);
assert(f.radio.p.switchBudgetUs()==9271);
assert(f.radio.p.preamble(0,32)==168);
assert(!strstr(f.cmd("get radio2.timing"),"WARN need >"));
assert(!strstr(f.cmd("get radio2.status"),"WARN need >"));
f.finishTimingTest(40000);
assert(f.radio.p.preamble(0,32)==512);
assert(strstr(f.cmd("get radio2.timing"),"WARN need >512: radio"));
assert(strstr(f.cmd("get radio2.status"),"WARN need >512: radio"));
}
{
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());
const auto damaged=f.fs.files["/radio_profiles"];
f.fs.fail_remove=true;
assert(invalid.handle("set radio2.cross on", f.reply));
assert(strstr(f.reply,"Error")); // failed cleanup never overwrites corruption
assert(f.fs.files["/radio_profiles"]==damaged);
f.fs.fail_remove=false;
assert(invalid.handle("set radio2.cross on", f.reply));
assert(!strcmp(f.reply,"OK"));
assert(corrupt.p.cross==mesh::RadioCrossMode::On);
assert(invalid.handle("set tempradio2 911.3,500,8,7,rxtx,2", f.reply));
assert(!strncmp(f.reply,"OK",2));
g_mock_millis+=2000; invalid.loop();
assert(corrupt.p.secondary_temporary && corrupt.p.canCross());
}
{
Fixture f;
f.cmd("set radio2 910.5,500,8,5,rxtx,80");
const auto saved=f.fs.files["/radio_profiles"];
f.fs.fail_read_open=true;
Radio radio; mesh::RadioProfileCLI unavailable;
unavailable.begin(&f.fs, &radio, &f.clock);
f.fs.fail_read_open=false;
assert(unavailable.handle("set radio2.cross on", f.reply));
assert(strstr(f.reply,"Error")); // an I/O fault is never treated as corruption
assert(f.fs.files["/radio_profiles"]==saved);
}
{
Fixture f;
f.cmd("set radio2 910.5,500,8,5,rxtx,80");
const auto backup=f.fs.files["/radio_profiles"];
f.fs.files["/radio_profiles.bak"]=backup;
// This versioned image has a valid CRC but an unsupported saved mode.
// An intact backup must prevent automatic discard/recreation.
f.fs.files["/radio_profiles"][3]=3;
uint32_t crc=0xffffffffU;
for (size_t i=0;i<20;++i) {
crc ^= f.fs.files["/radio_profiles"][i];
for (unsigned bit=0;bit<8;++bit) crc=(crc>>1)^((crc&1)?0xedb88320U:0);
}
memcpy(f.fs.files["/radio_profiles"].data()+20,&crc,4);
Radio radio; mesh::RadioProfileCLI protected_backup;
protected_backup.begin(&f.fs, &radio, &f.clock);
assert(protected_backup.handle("set radio2.cross on", f.reply));
assert(strstr(f.reply,"Error"));
assert(f.fs.files["/radio_profiles.bak"]==backup);
}
{
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");
}