Fix OTA resume and radio state recovery

Preserve resumed nRF52 flash pages and keep filtered OTA catalogs incomplete until full pages arrive. Bound stuck radio preparation, report companion setting failures, retain safe rollback, and codify conservative advert penalties with regression tests.
This commit is contained in:
mikecarper committed 2026-09-15 12:17:54 -07:00
1 parent 62ce8d3320
commit e4e0de8a79
22 files changed
+1175 -59

No files matched your search

+183
View File
@@ -0,0 +1,183 @@
#include <helpers/ota/OtaManager.h>
#include <helpers/ota/OtaByteIO.h>
#include <cassert>
#include <cstdlib>
#include <cstring>
#include <vector>
using namespace mesh::ota;
static bool send(void*, const uint8_t*, uint16_t, bool) { return true; }
static void advert(OtaManager& manager, uint32_t source, uint8_t count = 1) {
AdvMsg message{};
wr_u32le(message.seeder_id, source);
wr_u32le(message.set_digest, 123);
message.n_motas = count;
uint8_t wire[24];
manager.on_message(wire, encode_adv(wire, sizeof(wire), message));
}
static void have(OtaManager& manager, uint32_t source, uint32_t mid) {
uint8_t row[OTA_HAVE_ROW_BYTES] = {};
wr_u32le(row, mid);
wr_u32le(row + 4, 1234);
row[12] = CODEC_FULL; row[13] = MFLAG_FULL;
row[14] = 1;
HaveMsg message{};
wr_u32le(message.seeder_id, source);
wr_u32le(message.set_digest, 123);
message.frag_total = 1; message.n_rows = 1; message.rows = row;
uint8_t wire[64];
manager.on_message(wire, encode_have(wire, sizeof(wire), message));
}
static const OtaManager::CatRow* row(OtaManager& manager, uint32_t mid) {
for (unsigned i = 0; i < manager.catalogCount(); ++i) {
const auto* entry = manager.catalogRow(i);
if (rd_u32le(entry->mid) == mid) return entry;
}
return nullptr;
}
using Messages = std::vector<std::vector<uint8_t>>;
static bool capture(void* context, const uint8_t* message, uint16_t length, bool) {
static_cast<Messages*>(context)->emplace_back(message, message + length);
return true;
}
class MixedCatalogSource : public MotaSource {
uint8_t _count;
public:
explicit MixedCatalogSource(uint8_t count) : _count(count) {}
uint8_t count() override { return _count; }
bool describe(uint8_t index, MotaDesc& out) override {
if (index >= _count) return false;
wr_u32le(out.mid, 100u + index);
// Three pages: fully matching, partially matching, and empty for target 1.
out.target_id = _count == 2 ? index + 1u
: index < 10 ? 1u : index < 20 ? 1u + index % 2u : 2u;
out.fw_version = 1; out.codec_id = CODEC_FULL; out.flags = MFLAG_FULL;
out.block_size_log2 = 7; out.block_count = 1;
out.leaves_off = 8 + MOTA_MFL; out.payload_off = out.leaves_off + 4;
out.payload_size = 128; out.total_size = out.payload_off + out.payload_size + 5;
return true;
}
bool read(uint8_t, uint32_t, uint8_t*, uint32_t) override { return false; }
};
static void filtered_catalog_remains_queryable(int scenario, bool automatic) {
Messages from_server, from_client;
OtaManager server, client;
server.begin(0, capture, &from_server);
client.begin(0, capture, &from_client);
client.set_archive_interest(automatic);
uint8_t sid[4] = {1, 2, 3, 4}; server.set_seeder_id(sid);
MixedCatalogSource source(scenario == 4 ? 2 : 24);
assert(server.add_source(&source));
server.announce();
assert(from_server.size() == 1);
AdvMsg adv{};
assert(decode_adv(from_server[0].data(), from_server[0].size(), adv));
if (scenario != 8) client.on_message(from_server[0].data(), from_server[0].size());
from_server.clear();
QueryMsg query{};
memcpy(query.seeder_id, sid, 4); memcpy(query.set_digest, adv.set_digest, 4);
query.filter_target = scenario == 6 ? 99 : 1;
uint8_t wire[256];
server.on_message(wire, encode_query(wire, sizeof(wire), query));
Messages filtered = from_server;
if (scenario == 7) {
// Legacy filtered servers packed only the matching rows into fewer pages.
filtered.pop_back();
for (auto& message : filtered) {
HaveMsg legacy{};
assert(decode_have(message.data(), message.size(), legacy));
legacy.frag_total = 2;
const uint16_t length = encode_have(wire, sizeof(wire), legacy);
message.assign(wire, wire + length);
}
}
for (const auto& message : filtered) client.on_message(message.data(), message.size());
assert(client.catalogCount() == (scenario == 4 ? 1 : scenario == 6 ? 0 : 15));
if (scenario == 8) {
// Passive rows arriving before a beacon must not confer completeness on its new digest.
client.on_message(wire, encode_adv(wire, sizeof(wire), adv));
assert(client.catalogCount() == 0);
}
if (automatic) {
client.set_clock(5000);
client.loop();
} else {
client.queryAll();
}
assert(from_client.size() == 1);
QueryMsg recovery{};
assert(decode_query(from_client[0].data(), from_client[0].size(), recovery));
assert(recovery.filter_target == 0);
assert(recovery.want_fragments == (scenario == 4 ? 1u : scenario == 5 ? 6u : scenario == 8 ? 0u : 7u));
from_server.clear();
server.on_message(from_client[0].data(), from_client[0].size());
for (const auto& message : from_server) client.on_message(message.data(), message.size());
assert(client.catalogCount() == source.count());
from_client.clear();
// Late filtered packets must not downgrade already-complete pages either.
for (const auto& message : filtered) client.on_message(message.data(), message.size());
client.queryAll();
client.set_clock(5000 + OTA_CATALOG_RETRY_MS + 1);
client.loop();
assert(from_client.empty());
}
int main(int argc, char** argv) {
assert(argc == 3);
const int scenario = atoi(argv[1]);
const bool rollover = atoi(argv[2]) != 0;
if (scenario >= 4) {
filtered_catalog_remains_queryable(scenario, rollover);
return 0;
}
const uint32_t old = rollover ? 0xFFFF0000u : 1000u;
const uint32_t recent = rollover ? 0u : 100000u;
OtaManager manager;
manager.begin(1234, send, nullptr);
if (scenario == 0) {
for (unsigned i = 1; i <= OTA_MAX_SOURCES; ++i) {
manager.set_clock(old + i * 100);
advert(manager, i); have(manager, i, i);
}
manager.set_clock(recent);
advert(manager, 1); // freshest source is no longer the eviction victim
manager.set_clock(recent + 10);
advert(manager, OTA_MAX_SOURCES + 1);
assert(row(manager, 1));
assert(!row(manager, 2));
assert(manager.sourceCount() == OTA_MAX_SOURCES);
} else if (scenario == 1) {
for (unsigned i = 1; i <= OTA_MAX_CATALOG; ++i) {
manager.set_clock(old + i * 100);
have(manager, 1, i);
}
manager.set_clock(recent);
have(manager, 1, 1);
manager.set_clock(recent + 10);
have(manager, 1, OTA_MAX_CATALOG + 1);
assert(row(manager, 1));
assert(!row(manager, 2));
assert(manager.catalogCount() == OTA_MAX_CATALOG);
} else {
manager.set_clock(old);
have(manager, 1, 1);
manager.set_clock(recent);
have(manager, 2, 1);
if (scenario == 2) {
assert(row(manager, 1)->last_ms == recent);
} else {
manager.set_clock(recent + 10);
have(manager, 3, 1);
manager.set_clock(recent + 20);
advert(manager, 3, 0); // withdrawal recomputes newest surviving source
assert(row(manager, 1)->last_ms == recent);
assert(row(manager, 1)->n_seeders == 2);
}
}
}
@@ -286,6 +286,90 @@ static void dirty_flash_partial_checkpoint_finishes(bool reboot) {
CHECK(std::memcmp(active.data(), bytes.data(), total) == 0);
}
static void resumed_partial_payload_pages_preserve_committed_prefixes() {
using namespace mesh::ota;
const uint32_t total = 4u * MOTA_NRF52_FLASH_PAGE;
for (uint32_t prefix : {MOTA_NRF52_FLASH_PAGE + 1u,
MOTA_NRF52_FLASH_PAGE + 1024u,
2u * MOTA_NRF52_FLASH_PAGE,
2u * MOTA_NRF52_FLASH_PAGE + 227u}) {
reset_target_memory();
auto bytes = patterned_container(total);
std::memcpy(bytes.data() + total - 5u, MOTA_TRAILER, 5u);
{
OtaStoreFlashNrf52 original;
CHECK(original.begin(total));
CHECK(original.write(0, bytes.data(), prefix));
CHECK(original.write(total - 5u, MOTA_TRAILER, 5u));
original.checkpoint();
}
OtaStoreFlashNrf52 resumed;
CHECK(resumed.reopen());
CHECK(resumed.write(prefix, bytes.data() + prefix, total - prefix - 5u));
CHECK(resumed.finalize());
std::vector<uint8_t> output(total);
CHECK(resumed.read(0, output.data(), total));
CHECK(output == bytes);
CHECK(std::memcmp(resumed.data(), bytes.data(), total) == 0);
}
}
static void resumed_out_of_order_writes_survive_another_checkpoint() {
using namespace mesh::ota;
reset_target_memory();
const uint32_t total = 4u * MOTA_NRF52_FLASH_PAGE;
auto bytes = patterned_container(total);
std::memcpy(bytes.data() + total - 5u, MOTA_TRAILER, 5u);
{
OtaStoreFlashNrf52 original;
CHECK(original.begin(total));
CHECK(original.write(0, bytes.data(), total));
original.checkpoint();
}
const uint32_t offsets[] = {MOTA_NRF52_FLASH_PAGE + 11u,
3u * MOTA_NRF52_FLASH_PAGE + 17u,
2u * MOTA_NRF52_FLASH_PAGE + 25u,
MOTA_NRF52_FLASH_PAGE + 53u};
{
OtaStoreFlashNrf52 resumed;
CHECK(resumed.reopen());
for (uint32_t offset : offsets) {
for (unsigned i = 0; i < 13; ++i) bytes[offset + i] ^= 0x5A;
CHECK(resumed.write(offset, bytes.data() + offset, 13));
}
resumed.checkpoint();
}
OtaStoreFlashNrf52 resumed_again;
CHECK(resumed_again.reopen());
// Cross a page boundary after the second restart, preserving bytes on both sides.
const uint32_t offset = 2u * MOTA_NRF52_FLASH_PAGE - 7u;
for (unsigned i = 0; i < 19; ++i) bytes[offset + i] ^= 0xA5;
CHECK(resumed_again.write(offset, bytes.data() + offset, 19));
CHECK(resumed_again.finalize());
std::vector<uint8_t> output(total);
CHECK(resumed_again.read(0, output.data(), total));
CHECK(output == bytes);
CHECK(std::memcmp(resumed_again.data(), bytes.data(), total) == 0);
}
static void fresh_begin_after_reopen_does_not_retain_old_payload_bytes() {
using namespace mesh::ota;
reset_target_memory();
const uint32_t total = 3u * MOTA_NRF52_FLASH_PAGE;
const auto bytes = patterned_container(total);
OtaStoreFlashNrf52 store;
CHECK(store.begin(total));
CHECK(store.write(0, bytes.data(), total));
store.checkpoint();
CHECK(store.reopen());
CHECK(store.begin(total));
const uint32_t offset = MOTA_NRF52_FLASH_PAGE + 128u;
CHECK(store.write(offset, bytes.data() + offset, 16));
uint8_t preceding[128];
CHECK(store.read(MOTA_NRF52_FLASH_PAGE, preceding, sizeof(preceding)));
for (uint8_t byte : preceding) CHECK(byte == 0xFF);
}
int main() {
map_target_region(FLASH_MAP_START, FLASH_MAP_SIZE);
map_target_region(RAM_MAP_START, RAM_MAP_SIZE);
@@ -303,6 +387,12 @@ int main() {
std::puts("PASS: dirty flash partial checkpoint resumes and finishes");
dirty_flash_partial_checkpoint_finishes(false);
std::puts("PASS: dirty flash partial checkpoint continues without reboot");
std::puts("7 OtaStoreFlashNrf52 hybrid lifecycle checks passed");
resumed_partial_payload_pages_preserve_committed_prefixes();
std::puts("PASS: resumed payload pages preserve checkpointed prefixes");
resumed_out_of_order_writes_survive_another_checkpoint();
std::puts("PASS: resumed out-of-order writes survive repeated checkpoints");
fresh_begin_after_reopen_does_not_retain_old_payload_bytes();
std::puts("PASS: fresh captures after reopen discard stale payload bytes");
std::puts("10 OtaStoreFlashNrf52 hybrid lifecycle checks passed");
return 0;
}
+257 -2
View File
@@ -6,19 +6,29 @@ class DualProfileTestRadio : public RetryCodingRateRadio {
int busy_profile = -1;
bool fail_next_send = false;
bool hold_prepare_busy = false;
bool receive_mode = true, recovery_succeeds = true, clear_prepare_on_recovery = false;
bool carrier = false, carrier_on_receive = false;
unsigned carrier_services = 0, recoveries = 0;
bool isCarrierWaveActive() const override { return carrier; }
bool isInRecvMode() const override { return !carrier && !sending; }
bool isInRecvMode() const override { return receive_mode && !carrier && !sending; }
void loop() override { ++carrier_services; }
bool recoverRadio(bool) override { ++recoveries; return true; }
bool recoverRadio(bool) override {
++recoveries;
if (recovery_succeeds && clear_prepare_on_recovery) {
hold_prepare_busy = false;
receive_mode = true;
}
return recovery_succeeds;
}
uint32_t estimated_airtime = 10;
uint32_t secondary_airtime = 0;
float receive_score = 0;
float packetScore(float, int) override { return receive_score; }
TraceTestClock* sensing_clock = nullptr;
uint32_t sensing_delay = 0;
bool distinguish_airtime = false;
uint32_t getProfileAirtime(uint8_t profile, int bytes, uint8_t cr = 0) override {
if (profile == 1 && secondary_airtime) return secondary_airtime;
return distinguish_airtime ? (profile ? 70 : 900)
: mesh::Radio::getProfileAirtime(profile, bytes, cr);
}
@@ -79,6 +89,7 @@ class DualProfileTestMesh : public RetryCodingRateMesh {
bool cross_filter_allows = true;
float ota_speed = 1.0f;
bool suppress_tx = false;
bool enqueue_on_send_fail = false;
unsigned send_failures = 0;
unsigned send_completions = 0;
void onSendComplete(mesh::Packet* packet) override {
@@ -88,11 +99,24 @@ class DualProfileTestMesh : public RetryCodingRateMesh {
unsigned tx_failure_logs = 0;
void logTxFail(mesh::Packet*, int) override { ++tx_failure_logs; }
unsigned direct_successes = 0, direct_failures = 0;
unsigned flood_successes = 0, flood_failures = 0;
void onDirectRetrySucceeded(const uint8_t*, uint8_t, int8_t) override { ++direct_successes; }
void onDirectRetryFailed(const uint8_t*, uint8_t) override { ++direct_failures; }
void onFloodRetryEvent(const char* event, const mesh::Packet*, uint32_t, uint8_t) override {
if (strcmp(event, "good") == 0) ++flood_successes;
if (strcmp(event, "failure") == 0) ++flood_failures;
}
void onSendFail(mesh::Packet* packet) override {
++send_failures;
RetryCodingRateMesh::onSendFail(packet);
if (enqueue_on_send_fail) {
enqueue_on_send_fail = false;
auto* replacement = obtainNewPacket();
if (replacement) {
*replacement = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT);
sendPacket(replacement, 0);
}
}
}
bool allowPacketTransmit(const mesh::Packet* packet) const override {
return !suppress_tx && RetryCodingRateMesh::allowPacketTransmit(packet);
@@ -268,6 +292,157 @@ TEST_F(DualProfileTest, RadioFaultRetryRetainsBoundedBusyEscapeAndSingleRetryLim
EXPECT_TRUE(radio.transmissions.empty());
}
TEST_F(DualProfileTest, StuckRadioRetryPreparationReleasesPacketAndRecoversQueue) {
node.flood_attempts = 0;
radio.config.cross = mesh::RadioCrossMode::Off;
radio.fail_next_send = true;
ASSERT_NE(nullptr, queue());
tick(); ASSERT_TRUE(node.hasOutbound());
ASSERT_NE(nullptr, queue()); // later work must not inherit the stuck packet
radio.hold_prepare_busy = true;
radio.receive_mode = false;
radio.clear_prepare_on_recovery = true;
tick(500); // arm only after the first BUSY preparation
tick(8000);
EXPECT_TRUE(node.hasOutbound());
EXPECT_EQ(radio.recoveries, 0u);
tick(20);
EXPECT_FALSE(node.hasOutbound());
EXPECT_EQ(node.send_failures, 1u);
EXPECT_EQ(node.tx_failure_logs, 1u);
EXPECT_EQ(radio.recoveries, 1u);
EXPECT_EQ(manager.getOutboundTotal(), 1);
EXPECT_EQ(manager.getFreeCount(), 39);
tick(); ASSERT_TRUE(radio.sending);
radio.complete = true; tick();
EXPECT_EQ(manager.getFreeCount(), 40);
}
TEST_F(DualProfileTest, FailedBusyRecoveryDoesNotKeepRetryOwnershipOrDisableWatchdogs) {
node.flood_attempts = 0;
radio.config.cross = mesh::RadioCrossMode::Off;
radio.fail_next_send = true;
ASSERT_NE(nullptr, queue());
tick(); ASSERT_TRUE(node.hasOutbound());
radio.hold_prepare_busy = true;
radio.receive_mode = false;
radio.recovery_succeeds = false;
tick(500);
tick(9000);
EXPECT_FALSE(node.hasOutbound());
EXPECT_EQ(node.send_failures, 1u);
EXPECT_EQ(manager.getFreeCount(), 40);
EXPECT_EQ(radio.recoveries, 1u);
tick(9000); // the ordinary non-RX watchdog is no longer suppressed
EXPECT_EQ(radio.recoveries, 2u);
ASSERT_NE(nullptr, queue());
radio.recovery_succeeds = radio.clear_prepare_on_recovery = true;
tick(9000);
EXPECT_EQ(radio.recoveries, 3u);
ASSERT_TRUE(radio.sending);
radio.complete = true; tick();
EXPECT_EQ(manager.getFreeCount(), 40);
}
TEST_F(DualProfileTest, BusyTimeoutPreservesPacketsEnqueuedByFailureCallback) {
node.flood_attempts = 0;
radio.config.cross = mesh::RadioCrossMode::Off;
radio.fail_next_send = true;
ASSERT_NE(nullptr, queue());
tick(); ASSERT_TRUE(node.hasOutbound());
node.enqueue_on_send_fail = true;
radio.hold_prepare_busy = true;
radio.clear_prepare_on_recovery = true;
tick(500);
tick(9000);
EXPECT_FALSE(node.hasOutbound());
EXPECT_EQ(node.send_failures, 1u);
EXPECT_EQ(manager.getOutboundTotal(), 1);
EXPECT_EQ(manager.getFreeCount(), 39);
tick(); ASSERT_TRUE(radio.sending);
radio.complete = true; tick();
EXPECT_EQ(node.send_completions, 1u);
EXPECT_EQ(manager.getFreeCount(), 40);
}
TEST_F(DualProfileTest, InvalidPrepareAirtimeDoesNotCreateUnboundedDeadline) {
node.flood_attempts = 0;
radio.config.cross = mesh::RadioCrossMode::Off;
radio.fail_next_send = true;
ASSERT_NE(nullptr, queue());
tick(); ASSERT_TRUE(node.hasOutbound());
radio.estimated_airtime = UINT32_MAX;
radio.secondary_airtime = 20000;
radio.hold_prepare_busy = true;
tick(500);
tick(30000); // invalid primary must not erase the secondary's valid RX grace
EXPECT_TRUE(node.hasOutbound());
EXPECT_EQ(node.send_failures, 0u);
tick(8001);
EXPECT_FALSE(node.hasOutbound());
EXPECT_EQ(node.send_failures, 1u);
EXPECT_EQ(radio.recoveries, 1u);
EXPECT_EQ(manager.getFreeCount(), 40);
}
TEST_F(DualProfileTest, FinitePrepareBusyAndCadBackoffHaveSeparateAllowances) {
node.flood_attempts = 0;
radio.config.cross = mesh::RadioCrossMode::Off;
radio.fail_next_send = true;
ASSERT_NE(nullptr, queue());
tick();
radio.hold_prepare_busy = true;
tick(500);
tick(5000);
radio.hold_prepare_busy = false;
radio.busy_profile = 0;
tick(1000); // successful preparation clears the first BUSY allowance
radio.hold_prepare_busy = true;
tick(500);
tick(7000); // exceeds the original deadline, but not the new allowance
EXPECT_TRUE(node.hasOutbound());
EXPECT_EQ(node.send_failures, 0u);
EXPECT_EQ(radio.recoveries, 0u);
radio.hold_prepare_busy = false;
radio.busy_profile = -1;
tick(500); ASSERT_TRUE(radio.sending);
radio.complete = true; tick();
EXPECT_EQ(manager.getFreeCount(), 40);
}
TEST_F(DualProfileTest, PrepareBusyAllowanceCoversLongFramesAndMillisWrap) {
TraceTestClock wrap_clock;
wrap_clock.now = UINT32_MAX - 1000UL;
DualProfileTestRadio wrap_radio;
StaticPoolPacketManager wrap_manager(40);
DualProfileTestMesh wrap_node(wrap_radio, wrap_clock, rng, rtc, wrap_manager, tables);
wrap_node.begin();
wrap_node.flood_attempts = 0;
wrap_radio.config.cross = mesh::RadioCrossMode::Off;
wrap_radio.estimated_airtime = 20000;
wrap_radio.fail_next_send = true;
auto* packet = wrap_node.obtainNewPacket();
ASSERT_NE(packet, nullptr);
*packet = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT);
ASSERT_TRUE(wrap_node.sendFlood(packet));
auto advance = [&](uint32_t ms) {
wrap_clock.now = uint32_t(wrap_clock.now + ms);
wrap_node.loop();
};
advance(1); ASSERT_TRUE(wrap_node.hasOutbound());
wrap_radio.hold_prepare_busy = true;
advance(500);
advance(30000); // a slow full frame must not hit an eight-second fixed timeout
EXPECT_TRUE(wrap_node.hasOutbound());
EXPECT_EQ(wrap_radio.recoveries, 0u);
EXPECT_EQ(wrap_node.send_failures, 0u);
advance(8001); // 1.5 * 20 seconds + eight seconds, crossing millis wrap
EXPECT_FALSE(wrap_node.hasOutbound());
EXPECT_EQ(wrap_radio.recoveries, 1u);
EXPECT_EQ(wrap_node.send_failures, 1u);
EXPECT_EQ(wrap_manager.getFreeCount(), 40);
}
TEST_F(DualProfileTest, EchoDuringRadioFaultBackoffCancelsDirectAndFloodRetry) {
radio.config.cross = mesh::RadioCrossMode::Off;
radio.receive_score = 1; // strong echo is processed without a score delay
@@ -999,6 +1174,86 @@ TEST_F(DualProfileTest, ExpiredSecondaryPacketsAreDiscarded) {
EXPECT_EQ(40, manager.getFreeCount());
}
TEST_F(DualProfileTest, FinalFloodEchoWaitOnlyExpiresWhenItsOwnProfileChanges) {
node.flood_attempts = 1;
for (uint8_t origin : {0, 1}) for (unsigned change = 0; change < 3; ++change) {
SCOPED_TRACE(testing::Message() << "origin=" << unsigned(origin) << " change=" << change);
radio.config.cross = mesh::RadioCrossMode::Off;
tick();
auto* packet = node.obtainNewPacket(); ASSERT_NE(nullptr, packet);
*packet = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT);
packet->radio_profile = origin;
packet->tx_radio = origin ? mesh::RADIO_TX_SECONDARY : mesh::RADIO_TX_PRIMARY;
ASSERT_TRUE(node.sendFlood(packet));
tick(); ASSERT_TRUE(radio.sending);
radio.complete = true; tick();
ASSERT_EQ(manager.getOutboundTotal(), 1);
mesh::Packet echo = *manager.getOutboundByIdx(0);
echo.radio_bound = false; echo.radio_local = false;
echo.setPathHashCount(echo.getPathHashCount() + 1);
tick(10000); ASSERT_TRUE(radio.sending);
radio.complete = true; tick();
ASSERT_EQ(manager.getOutboundTotal(), 0);
ASSERT_EQ(manager.getFreeCount(), 40); // final echo waits own metadata only
const auto successes = node.flood_successes;
if (change == 1) {
radio.config.cross = mesh::RadioCrossMode::On;
} else if ((change == 2 ? origin : origin ^ 1) == 0) {
auto primary = radio.config.primary; primary.freq += 1;
radio.config.setPrimary(primary, false);
} else {
auto secondary = radio.config.secondary; secondary.params.freq += 1;
radio.config.setSecondary(secondary, false);
}
tick();
node.receivePacket(&echo);
EXPECT_EQ(node.flood_successes, successes + (change == 2 ? 0 : 1));
EXPECT_EQ(node.flood_failures, 0u); // profile changes are not link failures
EXPECT_EQ(manager.getFreeCount(), 40);
}
}
TEST_F(DualProfileTest, ExpiredFinalFloodEchoCannotReserveKeyOnReplacementChannel) {
node.flood_attempts = 1;
radio.config.cross = mesh::RadioCrossMode::Off;
for (uint8_t origin : {0, 1}) {
SCOPED_TRACE(testing::Message() << "origin=" << unsigned(origin));
auto send = [&] {
auto* packet = node.obtainNewPacket();
EXPECT_NE(nullptr, packet);
if (!packet) return;
*packet = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT);
packet->radio_profile = origin;
packet->tx_radio = origin ? mesh::RADIO_TX_SECONDARY : mesh::RADIO_TX_PRIMARY;
EXPECT_TRUE(node.sendFlood(packet));
};
send();
tick(); radio.complete = true; tick();
tick(10000); radio.complete = true; tick();
ASSERT_EQ(manager.getOutboundTotal(), 0);
ASSERT_EQ(manager.getFreeCount(), 40);
if (origin) {
const auto previous = radio.config.secondary;
radio.config.setSecondary({}, false);
tick();
radio.config.setSecondary(previous, false);
} else {
auto primary = radio.config.primary; primary.freq += 1;
radio.config.setPrimary(primary, false);
}
tick();
send(); // exact same logical packet, sent in a new radio session
tick(); ASSERT_TRUE(radio.sending);
radio.complete = true; tick();
EXPECT_EQ(manager.getOutboundTotal(), 1); // fresh channel owns its own retry
node.cancelAllFloodRetries();
tick(10000);
EXPECT_EQ(node.flood_failures, 0u);
EXPECT_EQ(manager.getFreeCount(), 40);
}
}
TEST_F(DualProfileTest, EchoOnOneProfileDoesNotCancelOtherProfileRetry) {
ASSERT_NE(nullptr, queue());
tick(); radio.complete = true; tick();
@@ -0,0 +1,365 @@
"""Execute Companion radio commands with production commit/recovery methods."""
from pathlib import Path
import os
import re
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / 'examples/companion_radio/MyMesh.cpp'
HARNESS = r'''
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <deque>
#include <initializer_list>
#include <vector>
#include <helpers/CLICommandUtils.h>
#define MESH_DEBUG_PRINTLN(...) ((void)0)
#define MAX_LORA_TX_POWER 22
#define COMPANION_FEATURE_TEMP_RADIO 0
#define COMMAND_RADIO_APPLY_TIMEOUT_MS 3000
@CODES@
namespace mesh { enum class RadioParamApplyResult { APPLIED, BUSY, FAILED }; }
using Apply = mesh::RadioParamApplyResult;
static uint32_t now_ms = 100;
struct Clock { uint32_t getMillis() const { return now_ms; } } clock_source;
struct BaseSerialInterface {
BaseSerialInterface* current = this;
BaseSerialInterface* disconnected = nullptr;
bool busy = false;
BaseSerialInterface* captureReplyRoute() { return current; }
bool isReplyRouteAvailable(BaseSerialInterface* route) { return route && route != disconnected; }
bool isReplyRouteWriteBusy(BaseSerialInterface*) { return busy; }
};
struct Prefs {
float freq=915, bw=250;
uint8_t sf=7, cr=5, client_repeat=0, rx_ps_level=1, rx_ps_preamble=16;
uint32_t rx_ps_rx_us=700, rx_ps_sleep_us=25000;
int8_t tx_power_dbm=3;
bool rx_boosted_gain=false;
};
void recalcRxPowerSavingFromLevel(uint8_t,uint8_t sf,float bw,uint8_t,
uint32_t* rx,uint32_t* sleep) {
*rx=sf*100; *sleep=uint32_t(bw*100);
}
struct Driver {
int8_t power=3;
unsigned calls=0;
std::deque<bool> accepted;
bool setTxPower(int8_t requested) {
++calls;
bool ok=true;
if (!accepted.empty()) { ok=accepted.front(); accepted.pop_front(); }
if (ok) power=requested;
return ok;
}
void setRxBoostedGainMode(bool) {}
} radio_driver;
@RETRY_DELAY@
struct MyMesh {
Prefs _prefs, durable, live;
BaseSerialInterface serial, original, other;
BaseSerialInterface* _serial=&serial;
Clock* _ms=&clock_source;
bool storage_accepts=true, _radio_available=true, outbound=false;
unsigned saves=0, applies=0;
std::deque<Apply> accepted;
bool saved_radio_apply_pending=false, command_radio_apply_pending=false;
uint32_t radio_apply_retry_at=0, command_radio_apply_deadline=0;
uint8_t radio_apply_failures=0;
float command_radio_freq=0, command_radio_bw=0;
uint8_t command_radio_sf=0, command_radio_cr=0, command_radio_repeat=0;
BaseSerialInterface* command_radio_reply_route=nullptr;
uint8_t cmd_frame[180]={};
struct Reply { uint8_t error; BaseSerialInterface* route; };
std::vector<Reply> replies;
@RESULT_ENUM@;
MyMesh() { serial.current=&original; radio_driver=Driver(); now_ms=100; }
uint32_t futureMillis(uint32_t delay) const { return now_ms+delay; }
bool millisHasNowPassed(uint32_t deadline) const { return int32_t(now_ms-deadline)>=0; }
bool hasOutbound() const { return outbound; }
bool isValidClientRepeatFreq(uint32_t) const { return true; }
bool savePrefs() { ++saves; if(!storage_accepts)return false; durable=_prefs; return true; }
void writeOKFrame(BaseSerialInterface* route=nullptr) { replies.push_back({0,route?route:serial.current}); }
void writeErrFrame(uint8_t code,BaseSerialInterface* route=nullptr) { replies.push_back({code,route?route:serial.current}); }
Apply tryApplyRadioParams(float freq,float bw,uint8_t sf,uint8_t cr) {
++applies;
Apply result=Apply::APPLIED;
if(!accepted.empty()) { result=accepted.front(); accepted.pop_front(); }
if(result==Apply::APPLIED) { live.freq=freq;live.bw=bw;live.sf=sf;live.cr=cr; }
return result;
}
bool applySavedRadioParams();
RadioSettingResult applyAndSaveTxPower(int8_t);
void finishRadioParamApply(float,float,uint8_t,uint8_t,uint8_t,BaseSerialInterface* = nullptr);
void servicePendingRadioParamApply();
void cancelPendingRadioParamApply();
void frame(size_t len) { @FRAME_BRANCHES@ }
void recover() { @RECOVERY@ }
bool command(const char* command,char* reply) {
const size_t reply_capacity=160;
@CLI_BRANCH@
return false;
}
void powerFrame(uint8_t power,size_t size=2) {
cmd_frame[0]=CMD_SET_RADIO_TX_POWER;cmd_frame[1]=power;frame(size);
}
void tupleFrame() {
cmd_frame[0]=CMD_SET_RADIO_PARAMS;
uint32_t freq=920000,bw=125000;
memcpy(cmd_frame+1,&freq,4);memcpy(cmd_frame+5,&bw,4);
cmd_frame[9]=9;cmd_frame[10]=6;cmd_frame[11]=1;
frame(12);
}
void expectError(uint8_t code,BaseSerialInterface* route=nullptr) {
assert(replies.size()==1 && replies[0].error==code);
assert(replies[0].route==(route?route:&original));
}
void expectOriginalPrefs() {
assert(_prefs.freq==915 && _prefs.bw==250 && _prefs.sf==7 && _prefs.cr==5);
assert(_prefs.client_repeat==0 && _prefs.rx_ps_rx_us==700 && _prefs.rx_ps_sleep_us==25000);
assert(durable.freq==915 && durable.bw==250 && durable.sf==7 && durable.cr==5);
}
};
@METHODS@
int main() {
// Reject unavailable/busy hardware before either persisting or reporting it.
{
MyMesh m;radio_driver.accepted={false};m.powerFrame(17);
m.expectError(ERR_CODE_BAD_STATE);
assert(m.saves==0 && m._prefs.tx_power_dbm==3 && radio_driver.power==3);
}
for (int value : {-10,23}) {
MyMesh m;m.powerFrame(uint8_t(value));m.expectError(ERR_CODE_ILLEGAL_ARG);
assert(m.saves==0 && radio_driver.calls==0);
}
{
MyMesh m;m.powerFrame(17,1);m.expectError(ERR_CODE_ILLEGAL_ARG);
assert(m.saves==0 && radio_driver.calls==0);
}
{
MyMesh m;m.powerFrame(17);m.expectError(0);
assert(m.saves==1 && m.durable.tx_power_dbm==17 && radio_driver.power==17);
}
{
MyMesh m;m._radio_available=false;m.powerFrame(17);m.expectError(0);
assert(m.durable.tx_power_dbm==17 && radio_driver.calls==0);
}
// A failed commit restores RAM and the physical output-power setting.
{
MyMesh m;m.storage_accepts=false;m.powerFrame(17);m.expectError(ERR_CODE_FILE_IO_ERROR);
assert(m._prefs.tx_power_dbm==3 && m.durable.tx_power_dbm==3 && radio_driver.power==3);
assert(radio_driver.calls==2 && !m.saved_radio_apply_pending);
}
// RX may become busy during flash I/O. Retain recovery through a rejected
// rollback and through a later successful tuple restore but failed power apply.
{
MyMesh m;m.storage_accepts=false;radio_driver.accepted={true,false,false,true};
m.powerFrame(17);m.expectError(ERR_CODE_FILE_IO_ERROR);
assert(m._prefs.tx_power_dbm==3 && radio_driver.power==17 && m.saved_radio_apply_pending);
m.recover();assert(m.saved_radio_apply_pending && radio_driver.power==17);
assert(m.radio_apply_failures==1 && m.radio_apply_retry_at>now_ms);
unsigned calls=radio_driver.calls;m.recover();assert(radio_driver.calls==calls);
now_ms=m.radio_apply_retry_at;m.recover();
assert(!m.saved_radio_apply_pending && radio_driver.power==3 && m.saves==1);
assert(m.replies.size()==1); // no later success for the failed command
}
// ASCII and framed setters share identical hardware/durability semantics.
{
MyMesh m;char reply[160]={};radio_driver.accepted={false};
assert(m.command("set tx 17",reply) && strstr(reply,"Error"));
assert(m.saves==0 && m._prefs.tx_power_dbm==3);
m.storage_accepts=false;
assert(m.command("set tx 17",reply) && strstr(reply,"saved"));
assert(strstr(reply,"Error") && radio_driver.power==3);
assert(m.command("set tx 17junk",reply) && strstr(reply,"Error"));
assert(m.saves==1);
}
// Both immediate and deferred tuple changes must commit before OK.
{
MyMesh m;m.tupleFrame();m.expectError(0);
assert(m._prefs.freq==920 && m.durable.freq==920 && m.live.freq==920);
assert(m.durable.rx_ps_rx_us==900 && m.durable.rx_ps_sleep_us==12500);
assert(!m.saved_radio_apply_pending);
}
{
MyMesh m;m.storage_accepts=false;m.tupleFrame();m.expectError(ERR_CODE_FILE_IO_ERROR);
m.expectOriginalPrefs();assert(m.live.freq==915 && !m.saved_radio_apply_pending);
}
{
MyMesh m;m.accepted={Apply::BUSY,Apply::APPLIED};m.tupleFrame();
assert(m.replies.empty() && m.command_radio_apply_pending && m.saves==0);
m.serial.current=&m.other;m.servicePendingRadioParamApply();m.expectError(0);
assert(!m.command_radio_apply_pending && m.durable.freq==920);
}
{
MyMesh m;m.storage_accepts=false;m.accepted={Apply::BUSY,Apply::APPLIED,Apply::BUSY};
m.tupleFrame();assert(m.replies.empty() && m.command_radio_apply_pending);
m.serial.current=&m.other;m.servicePendingRadioParamApply();
m.expectError(ERR_CODE_FILE_IO_ERROR);m.expectOriginalPrefs();
assert(m.saved_radio_apply_pending && m.live.freq==920 && !m.command_radio_apply_pending);
m.recover();assert(!m.saved_radio_apply_pending && m.live.freq==915);
assert(m.saves==1 && m.replies.size()==1);
}
// Disconnect cancels a deferred command without persisting its candidate.
{
MyMesh m;m.accepted={Apply::BUSY};m.tupleFrame();
m.serial.disconnected=&m.original;m.servicePendingRadioParamApply();
assert(!m.command_radio_apply_pending && m.replies.empty() && m.saves==0);
m.expectOriginalPrefs();
}
// Tuple completion must not swallow an outstanding power rollback.
{
MyMesh m;m.saved_radio_apply_pending=true;radio_driver.power=17;
m.tupleFrame();m.expectError(0);assert(m.saved_radio_apply_pending);
m.recover();assert(!m.saved_radio_apply_pending && radio_driver.power==3);
assert(m.live.freq==920 && m.durable.freq==920);
}
puts("Companion radio-setting commit and recovery regressions passed");
}
'''
SLEEP_HARNESS = r'''
#include <cassert>
#include <cstdint>
#include <cstddef>
static uint32_t now_ms=100;
struct Serial {
bool hasPendingIO() const { return false; }
bool isConnected() const { return false; }
};
struct {
bool isWatchdogObserving() const { return false; }
bool isCalibratingNoiseFloor() const { return false; }
} radio_driver;
struct MyMesh {
bool _radio_available=true, _iter_started=false, command_radio_apply_pending=false;
bool binary_trace_pending=false, saved_radio_apply_pending=false;
uint32_t radio_apply_retry_at=0, _scheduled_reboot_at=0;
uint32_t emergency_client_repeat_send_at=0, dirty_contacts_expiry=0;
uint8_t dirty_contacts_failures=0;
const void *pending_serial_reply_route=nullptr, *sign_data=nullptr;
const void *emergency_client_repeat_packet=nullptr;
Serial* _serial=nullptr;
#if COMPANION_FEATURE_TEMP_RADIO
bool _temp_radio_applied=false;
uint32_t _temp_radio_set_at=0, _temp_radio_revert_at=0, _temp_radio_retry_at=0;
#endif
bool millisHasNowPassed(uint32_t at) const { return int32_t(now_ms-at)>=0; }
bool isDualRadioActive() const { return false; }
bool isContactWriteDue() const { return false; }
bool hasPendingOtaApply() const { return false; }
bool hasQueuedWorkDue() const { return false; }
bool hasRetryWorkDue() const { return false; }
bool hasPendingWork() const;
};
@PENDING_WORK@
int main() {
MyMesh m;
assert(!m.hasPendingWork());
m.saved_radio_apply_pending=true;
assert(m.hasPendingWork());
m.radio_apply_retry_at=now_ms+10;
assert(!m.hasPendingWork());
now_ms+=10;
assert(m.hasPendingWork());
m.radio_apply_retry_at=0;
#if COMPANION_FEATURE_TEMP_RADIO
// An already active lease can last hours; a rejected power-save rollback
// must not keep spinning solely because saved radio restoration is pending.
m._temp_radio_applied=true;
m._temp_radio_revert_at=now_ms+3600000;
assert(!m.hasPendingWork());
// A future scheduled TempRadio entry also blocks normal-radio restoration.
m._temp_radio_applied=false;m._temp_radio_revert_at=0;
m._temp_radio_set_at=now_ms+20;
assert(!m.hasPendingWork());
now_ms=m._temp_radio_set_at;
assert(m.hasPendingWork()); // the entry timer, not the blocked restore
m._temp_radio_set_at=0;m._temp_radio_applied=true;
m._temp_radio_revert_at=now_ms+20;
assert(!m.hasPendingWork());
now_ms=m._temp_radio_revert_at;
assert(m.hasPendingWork()); // lease expiry still gets serviced
m._temp_radio_applied=false;m._temp_radio_revert_at=0;
assert(m.hasPendingWork()); // rollback is runnable after the lease clears
#endif
m.saved_radio_apply_pending=false;
assert(!m.hasPendingWork());
}
'''
class CompanionRadioSettingsTransactionTests(unittest.TestCase):
def test_lease_blocked_recovery_does_not_prevent_sleep(self):
text = SOURCE.read_text(encoding='utf-8')
pending = extract_braced(text, 'bool MyMesh::hasPendingWork() const')
harness = SLEEP_HARNESS.replace('@PENDING_WORK@', pending)
with tempfile.TemporaryDirectory(prefix='mesh-companion-radio-sleep-') as tmp:
cpp = Path(tmp) / 'test.cpp'
binary = Path(tmp) / ('test.exe' if os.name == 'nt' else 'test')
cpp.write_text(harness, encoding='utf-8')
for macros in (['-DCOMPANION_FEATURE_TEMP_RADIO=0'],
['-DCOMPANION_FEATURE_TEMP_RADIO=1'],
['-DCOMPANION_FEATURE_TEMP_RADIO=1', '-DNRF52_PLATFORM=1']):
with self.subTest(macros=macros):
subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17',
'-Wall', '-Wextra', '-Werror', *macros,
str(cpp), '-o', str(binary)], check=True)
subprocess.run([str(binary)], check=True)
def test_production_commands_commit_and_restore(self):
text = SOURCE.read_text(encoding='utf-8')
header = (SOURCE.parent / 'MyMesh.h').read_text(encoding='utf-8')
codes = '\n'.join(re.findall(
r'^#define (?:CMD_SET_RADIO_PARAMS|CMD_SET_RADIO_TX_POWER|ERR_CODE_\w+)\s+\d+',
text, flags=re.MULTILINE))
methods = '\n'.join(extract_braced(text, signature) for signature in (
'bool MyMesh::applySavedRadioParams(',
'MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(',
'void MyMesh::finishRadioParamApply(',
'void MyMesh::cancelPendingRadioParamApply(',
'void MyMesh::servicePendingRadioParamApply(',
))
frame_branches = ' else '.join(extract_braced(text, f'if (cmd_frame[0] == {command})')
for command in ('CMD_SET_RADIO_PARAMS', 'CMD_SET_RADIO_TX_POWER'))
replacements = {
'@CODES@': codes,
'@RESULT_ENUM@': extract_braced(header, 'enum class RadioSettingResult'),
'@RETRY_DELAY@': extract_braced(text, 'static uint32_t nextRadioApplyRetryDelay('),
'@METHODS@': methods,
'@FRAME_BRANCHES@': frame_branches,
'@CLI_BRANCH@': extract_braced(text, 'if (strncmp(command, "set tx ", 7) == 0)'),
'@RECOVERY@': extract_braced(text, 'if (!command_radio_apply_pending && saved_radio_apply_pending && !hasOutbound()'),
}
harness = HARNESS
for marker, value in replacements.items():
harness = harness.replace(marker, value)
with tempfile.TemporaryDirectory(prefix='mesh-companion-radio-settings-') as tmp:
cpp = Path(tmp) / 'test.cpp'
binary = Path(tmp) / ('test.exe' if os.name == 'nt' else 'test')
cpp.write_text(harness, encoding='utf-8')
subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17', '-Wall', '-Wextra', '-Werror',
'-Wno-sign-compare', # existing frame parser compares size_t and byte offsets
'-I', str(ROOT / 'src'), str(cpp), '-o', str(binary)], check=True)
subprocess.run([str(binary)], check=True)
def test_all_tx_power_frontends_use_checked_handler(self):
text = SOURCE.read_text(encoding='utf-8')
terminal = extract_braced(text, 'if (strncmp(config, "tx ", 3) == 0)')
self.assertIn('handleCommand(command, 0, local_reply)', terminal)
web = extract_braced(text, 'if (strcmp(key, "tx") == 0)')
self.assertIn('applyAndSaveTxPower(', web)
self.assertIn('RadioSettingResult::SaveFailed',
extract_braced(text, 'if (strncmp(command, "set tx ", 7) == 0)'))
if __name__ == '__main__':
unittest.main()
+8 -3
View File
@@ -94,14 +94,19 @@ assert re.search(
# Terminal-specific handlers keep precedence, then both the generic `set`
# fallthrough and the final unknown-command fallthrough delegate to the shared
# framed/rescue command surface. The explicit frequency branch also delegates
# to the strict shared frequency parser; it is not a third fallthrough.
# framed/rescue command surface. Frequency and power also delegate explicitly
# to the strict, failure-aware shared handlers; they are not extra fallthroughs.
freq_branch = re.search(
r'else if \(strncmp\(config, "freq ", 5\) == 0\) \{([^{}]*)\}', terminal
)
assert freq_branch is not None
assert "handleCommand(command, 0, local_reply)" in freq_branch.group(1)
assert terminal.replace(freq_branch.group(0), "").count(
tx_branch = re.search(
r'else if \(strncmp\(config, "tx ", 3\) == 0\) \{([^{}]*)\}', terminal
)
assert tx_branch is not None
assert "handleCommand(command, 0, local_reply)" in tx_branch.group(1)
assert terminal.replace(freq_branch.group(0), "").replace(tx_branch.group(0), "").count(
"handleCommand(command, 0, local_reply)"
) == 2
assert terminal.index('strncmp(config, "tx ", 3)') < terminal.index(
@@ -120,6 +120,46 @@ TEST_F(FloodAdvertLimit, AggregatePrefixExcessDoesNotBlameAnInnocentFullKey) {
}
}
TEST_F(FloodAdvertLimit, LateShorterDuplicateKeepsPenaltyUntilSevenDayRecovery) {
for (uint32_t start : {0U, UINT32_MAX - W / 2}) {
limiter.reset();
bad(start); // consecutive over-limit windows at eight hops
uint8_t hash[MAX_HASH_SIZE] = {1};
limiter.observe(key.data(), hash, 1, start + W + 1, true);
Limiter::LimitedEntry row;
ASSERT_EQ(1U, limiter.listLimited(start + W + 1, 0, &row, 1));
EXPECT_EQ(1, row.hops);
EXPECT_EQ(10, row.quota);
EXPECT_TRUE(row.reasons & Limiter::BadListRule);
EXPECT_EQ(D7 - 1, row.recovery_ms);
// Raising the allowance does not forgive the escalation, including when
// the now-compliant window ends. New adverts still obey the 12-hour rule.
EXPECT_EQ(Decision::BadList, receive(4, 1, start + W + 2));
EXPECT_TRUE(limiter.isBad(key.data(), start + 2 * W));
EXPECT_TRUE(limiter.isBad(key.data(), start + W + D7 - 1));
EXPECT_FALSE(limiter.isBad(key.data(), start + W + D7));
}
}
TEST_F(FloodAdvertLimit, ShorterNewAndMatchingPrefixAdvertsCannotLiftPenalty) {
bad();
auto collision = key;
collision[31] = 1;
EXPECT_EQ(Decision::Allow, receive(1, 1, W + 1, collision.data()));
EXPECT_FALSE(limiter.isBad(collision.data(), W + 1));
EXPECT_TRUE(limiter.isBad(key.data(), W + 1));
EXPECT_EQ(Decision::BadList, receive(4, 0, W + 2));
Limiter::LimitedEntry row;
ASSERT_EQ(1U, limiter.listLimited(W + 2, 0, &row, 1));
EXPECT_EQ(0, row.hops);
EXPECT_EQ(10, row.quota);
EXPECT_TRUE(row.reasons & Limiter::BadListRule);
EXPECT_EQ(0, memcmp(row.key, key.data(), PUB_KEY_SIZE));
EXPECT_EQ(Decision::BadList, receive(5, 0, W + H12 - 1));
EXPECT_EQ(Decision::Allow, receive(6, 0, W + H12));
EXPECT_TRUE(limiter.isBad(key.data(), W + H12));
}
TEST_F(FloodAdvertLimit, ShortestPrefixPathAlsoDefinesTheNormalRecoveryLevel) {
auto collision = key;
collision[31] = 1;
+6
View File
@@ -102,7 +102,12 @@ struct MyMesh {
int value=0; int* _ms=&value; int* _radio=&value; int* _mgr=&value;
uint16_t _err_flags=0;
bool _radio_available=false, save_ok=true;
bool saved_radio_apply_pending=false;
unsigned radio_apply_retry_at=0;
uint8_t radio_apply_failures=0;
int resets=0, saves=0;
enum class RadioSettingResult { Saved, RadioRejected, SaveFailed };
RadioSettingResult applyAndSaveTxPower(int8_t);
bool handleCommand(const char*,uint32_t,char*);
bool handleDirectCommand(const char*,char*,size_t);
void onCLICommandRecv(const ContactInfo&,mesh::Packet*,uint32_t,const char*,char*);
@@ -303,6 +308,7 @@ int main() {
source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
implementation = "\n".join(extract_braced(source, signature) for signature in (
"static bool isCompanionRadioPrefsCommand(",
"MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(",
"bool MyMesh::handleDirectCommand(", "bool MyMesh::handleCommand(",
"void MyMesh::onCLICommandRecv("))
for export, webconfig in ((0, False), (1, False), (0, True), (1, True)):
+11 -7
View File
@@ -4043,7 +4043,7 @@ TEST(OtaCatalog, IncompleteCatalogRetriesOnlyMissingFragment) {
client.set_archive_interest(true);
uint8_t sid[4] = {0x44, 0x33, 0x22, 0x11};
uint8_t digest[4] = {0xDE, 0xAD, 0xBE, 0xEF};
AdvMsg adv{}; memcpy(adv.seeder_id, sid, 4); memcpy(adv.set_digest, digest, 4); adv.n_motas = 3;
AdvMsg adv{}; memcpy(adv.seeder_id, sid, 4); memcpy(adv.set_digest, digest, 4); adv.n_motas = 23;
uint8_t wire[MAX_PACKET_PAYLOAD];
uint16_t n = encode_adv(wire, sizeof(wire), adv);
client.set_clock(100);
@@ -4057,13 +4057,17 @@ TEST(OtaCatalog, IncompleteCatalogRetriesOnlyMissingFragment) {
sent.items.clear();
auto deliver_have = [&](uint8_t frag) {
uint8_t row[OTA_HAVE_ROW_BYTES] = {0};
row[0] = (uint8_t)(frag + 1); row[1] = 0x77;
wr_u32le(row + 4, SIM_TARGET_ID); wr_u32le(row + 8, 0x01000000u + frag);
row[12] = CODEC_FULL; row[13] = MFLAG_FULL; row[14] = 1;
const uint8_t count = frag == 2 ? 3 : 10;
uint8_t rows[10 * OTA_HAVE_ROW_BYTES] = {0};
for (uint8_t index = 0; index < count; ++index) {
uint8_t* row = rows + index * OTA_HAVE_ROW_BYTES;
row[0] = (uint8_t)(frag * 10 + index + 1); row[1] = 0x77;
wr_u32le(row + 4, SIM_TARGET_ID); wr_u32le(row + 8, 0x01000000u + frag);
row[12] = CODEC_FULL; row[13] = MFLAG_FULL; row[14] = 1;
}
HaveMsg have{};
memcpy(have.seeder_id, sid, 4); memcpy(have.set_digest, digest, 4);
have.frag_idx = frag; have.frag_total = 3; have.n_rows = 1; have.rows = row;
have.frag_idx = frag; have.frag_total = 3; have.n_rows = count; have.rows = rows;
uint16_t have_len = encode_have(wire, sizeof(wire), have);
ASSERT_GT(have_len, 0);
client.on_message(wire, have_len);
@@ -4080,7 +4084,7 @@ TEST(OtaCatalog, IncompleteCatalogRetriesOnlyMissingFragment) {
sent.items.clear();
deliver_have(1);
EXPECT_EQ(client.catalogCount(), 3);
EXPECT_EQ(client.catalogCount(), 23);
client.set_clock(5000 + OTA_CATALOG_RETRY_MS * 2 + 2);
client.loop();
EXPECT_TRUE(sent.items.empty()); // complete means no further catalog retries
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Exercise OTA catalog lifetime, rollover, and filtered-page recovery."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class OtaCatalogLifetimeTest(unittest.TestCase):
def test_source_and_catalog_ownership_survive_clock_rollover(self):
with tempfile.TemporaryDirectory(prefix="ota-catalog-") as directory:
path = Path(directory)
flags = [] if os.name == "nt" else ["-fsanitize=address,undefined"]
tinf = path / "tinf.o"
subprocess.run([shutil.which("cc") or "gcc", "-DENABLE_OTA=1", *flags,
"-c", str(ROOT / "src/helpers/ota/OtaTinf.c"),
"-o", str(tinf)], check=True)
sources = ["OtaManager.cpp", "OtaProtocol.cpp", "MotaContainer.cpp",
"MerkleTree.cpp", "OtaDeflate.cpp"]
binary = path / "catalog.exe"
result = subprocess.run([
"c++", "-std=c++17", *flags, "-DENABLE_OTA=1",
"-I", str(ROOT / "src"), "-I", str(ROOT / "test/mocks"),
str(ROOT / "test/fixtures/ota_catalog_lifetime/test.cpp"),
*[str(ROOT / "src/helpers/ota" / name) for name in sources],
str(ROOT / "src/Utils.cpp"), str(tinf), "-o", str(binary),
], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
for rollover in (0, 1):
for scenario in range(9):
with self.subTest(scenario=scenario, rollover=rollover):
subprocess.run([str(binary), str(scenario), str(rollover)],
check=True, timeout=10)
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -51,10 +51,10 @@ class OtaStoreFlashNrf52HybridTest(unittest.TestCase):
)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn(
"7 OtaStoreFlashNrf52 hybrid lifecycle checks passed",
"10 OtaStoreFlashNrf52 hybrid lifecycle checks passed",
checked.stdout,
)
self.assertEqual(checked.stdout.count("PASS:"), 7)
self.assertEqual(checked.stdout.count("PASS:"), 10)
if __name__ == "__main__":